From b3f6774ccb9deabf91e57b4f374337375507df7d Mon Sep 17 00:00:00 2001 From: wei-py Date: Mon, 14 Sep 2026 14:13:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E6=94=AF=E6=8C=81=E8=B1=86?= =?UTF-8?q?=E5=8C=85=20Seed=202.0=20=E6=80=9D=E8=80=83=E5=BC=BA=E5=BA=A6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/models.ts | 16 +- frontend/src/views/ModelConfigManager.vue | 33 +++- .../supportbot/config/ChatModelFactory.java | 27 +++ .../wok/supportbot/ChatModelFactoryTests.java | 177 ++++++++++++++++++ 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/wok/supportbot/ChatModelFactoryTests.java diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 3981520..24e3627 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -99,6 +99,20 @@ export type ModelProvider = 'dashscope' | 'deepseek' | 'volcengine' | 'moonshot' /** 应用类型 */ export type AppType = 'CHAT' | 'EMBEDDING' | 'RAG_REWRITE' | 'RERANK' +/** 豆包 Seed 2.0 思考强度;未设置时 CHAT 使用 minimal,其他应用沿用厂商默认。 */ +export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' + +export interface ModelExtraConfig { + [key: string]: unknown + reasoningEffort?: ReasoningEffort + dimensions?: number + topP?: number + topK?: number + frequencyPenalty?: number + presencePenalty?: number + stopSequences?: string +} + /** 模型配置 */ export interface AiModelConfig { id: string @@ -112,7 +126,7 @@ export interface AiModelConfig { temperature?: number maxTokens?: number topP?: number - extraConfig?: Record + extraConfig?: ModelExtraConfig isActive: boolean priority?: number healthStatus?: 'healthy' | 'unhealthy' | 'unknown' diff --git a/frontend/src/views/ModelConfigManager.vue b/frontend/src/views/ModelConfigManager.vue index b4f98d6..bfe856e 100644 --- a/frontend/src/views/ModelConfigManager.vue +++ b/frontend/src/views/ModelConfigManager.vue @@ -207,6 +207,14 @@ + + + + @@ -292,6 +300,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import * as api from '@/api/model-config' import { toast } from '@/utils/toast' import { useConfirm } from '@/composables/useConfirm' +import type { ModelExtraConfig } from '@/types/models' const { confirm } = useConfirm() @@ -404,11 +413,27 @@ const editModal = ref({ form: createEmptyForm(), }) +const supportsSeedReasoning = computed(() => { + const form = editModal.value.form + return form.provider?.toLowerCase() === 'volcengine' + && form.model_name?.startsWith('doubao-seed-2-0-') + && ['CHAT', 'RAG_REWRITE'].includes(form.app_type) +}) +const reasoningDefaultLabel = computed(() => editModal.value.form.app_type === 'CHAT' + ? '系统快速默认(minimal,关闭思考)' : '厂商默认(medium)') +const reasoningEffortOptions = [ + { label: 'minimal · 关闭思考,快速响应', value: 'minimal' }, + { label: 'low · 低强度思考', value: 'low' }, + { label: 'medium · 中强度思考', value: 'medium' }, + { label: 'high · 高强度思考,等待更久', value: 'high' }, +] + function createEmptyForm() { return { name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '', temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024, topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '', + reasoningEffort: '', extraConfig: {} as ModelExtraConfig, priority: 0, is_active: false, description: '', } } @@ -578,6 +603,7 @@ function openEditModal(config: any) { topP: extra.topP ?? null, topK: extra.topK ?? null, frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null, stopSequences: extra.stopSequences || '', priority: config.priority || 0, + reasoningEffort: extra.reasoningEffort || '', extraConfig: { ...extra }, is_active: config.is_active || false, description: config.description || '', }, } @@ -591,13 +617,18 @@ function openEditModal(config: any) { // ==================== 保存 ==================== function toCamelCase(form: any) { const data: any = { name: form.name, appType: form.app_type, provider: form.provider || 'other', apiKey: form.api_key, modelName: form.model_name, temperature: form.temperature, maxTokens: form.max_tokens, baseUrl: form.base_url, priority: form.priority, isActive: form.is_active, description: form.description } - const extraConfig: any = {} + const extraConfig: ModelExtraConfig = { ...form.extraConfig } + // 仅覆盖表单管理的参数,保留其他扩展配置;清空控件应删除旧值。 + for (const key of ['topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'stopSequences', 'reasoningEffort']) { + delete extraConfig[key] + } if (form.app_type === 'EMBEDDING') extraConfig.dimensions = form.embeddingDimensions || 1024 if (form.topP != null) extraConfig.topP = form.topP if (form.topK != null) extraConfig.topK = form.topK if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim() + if (form.reasoningEffort) extraConfig.reasoningEffort = form.reasoningEffort data.extraConfig = extraConfig return data } diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java index 03185a4..591e385 100644 --- a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java @@ -200,6 +200,7 @@ public class ChatModelFactory { } // F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences) applyExtraConfig(config, optionsBuilder); + applyReasoningEffort(config, optionsBuilder); return OpenAiChatModel.builder() .openAiApi(api) @@ -349,6 +350,32 @@ public class ChatModelFactory { } } + /** + * Seed 2.0 的 minimal 关闭思考;仅 CHAT 默认快速生成,其他模型不发送未知参数。 + * https://docs.volcengine.com/docs/82379/1449737 + */ + private void applyReasoningEffort(AiModelConfig config, OpenAiChatOptions.Builder optionsBuilder) { + if (!"volcengine".equalsIgnoreCase(config.getProvider()) + || config.getModelName() == null + || !config.getModelName().startsWith("doubao-seed-2-0-")) { + return; + } + Object configured = config.getExtraConfig() == null + ? null : config.getExtraConfig().get("reasoningEffort"); + if (configured == null || configured instanceof String value && value.isBlank()) { + if ("CHAT".equalsIgnoreCase(config.getAppType())) { + optionsBuilder.reasoningEffort("minimal"); + } + return; + } + if (!(configured instanceof String effort) + || !("minimal".equals(effort) || "low".equals(effort) + || "medium".equals(effort) || "high".equals(effort))) { + throw new IllegalArgumentException("reasoningEffort 必须为 minimal、low、medium 或 high"); + } + optionsBuilder.reasoningEffort(effort); + } + /** * 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder */ diff --git a/src/test/java/com/wok/supportbot/ChatModelFactoryTests.java b/src/test/java/com/wok/supportbot/ChatModelFactoryTests.java new file mode 100644 index 0000000..b4d33ff --- /dev/null +++ b/src/test/java/com/wok/supportbot/ChatModelFactoryTests.java @@ -0,0 +1,177 @@ +package com.wok.supportbot; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import com.wok.supportbot.config.ChatModelFactory; +import com.wok.supportbot.entity.AiModelConfig; +import com.wok.supportbot.service.AiModelConfigService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatOptions; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatModelFactoryTests { + + private static final ObjectMapper JSON = new ObjectMapper(); + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + private HttpServer server; + + @Mock + private AiModelConfigService configService; + @InjectMocks + private ChatModelFactory factory; + + @BeforeEach + void startLocalEndpoint() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + try (exchange) { + JsonNode request = JSON.readTree(exchange.getRequestBody()); + requests.add(request); + boolean streaming = request.path("stream").asBoolean(); + String response = streaming + ? "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + : "{\"id\":\"test\",\"object\":\"chat.completion\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}"; + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", streaming ? "text/event-stream" : "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + } + }); + server.start(); + } + + @AfterEach + void stopLocalEndpoint() { + if (server != null) server.stop(0); + } + + @ParameterizedTest + @ValueSource(strings = {"doubao-seed-2-0-pro-260215", "doubao-seed-2-0-lite-260215", + "doubao-seed-2-0-mini-260215", "doubao-seed-2-0-code-preview-260215"}) + void seedChatDefaultsToMinimalOnTheWire(String modelName) throws Exception { + AiModelConfig config = config("CHAT", "volcengine", modelName); + assertRequest(model(config), "minimal"); + } + + @ParameterizedTest + @ValueSource(strings = {"minimal", "low", "medium", "high"}) + void explicitSeedEffortOverridesFastDefault(String effort) throws Exception { + AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215"); + config.setExtraConfig(Map.of("reasoningEffort", effort, "topP", 0.8)); + JsonNode request = assertRequest(model(config), effort); + assertEquals(0.8, request.path("top_p").asDouble()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void clearedSeedEffortRestoresFastDefault(String cleared) throws Exception { + AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215"); + config.setExtraConfig(cleared == null ? Map.of() : Map.of("reasoningEffort", cleared)); + assertRequest(model(config), "minimal"); + } + + @ParameterizedTest + @CsvSource({"CHAT,openai,gpt-4o", "CHAT,deepseek,deepseek-chat", "CHAT,moonshot,moonshot-v1-8k", + "CHAT,volcengine,doubao-1-5-pro-32k", "CHAT,openai,doubao-seed-2-0-pro-260215", + "RAG_REWRITE,volcengine,doubao-seed-2-0-pro-260215", "RAG_REWRITE,volcengine,doubao-1-5-pro-32k"}) + void otherModelsAndRewriteRetainProviderDefaults(String appType, String provider, String name) throws Exception { + assertRequest(model(config(appType, provider, name)), null); + } + + @Test + void unsupportedRewriteModelDoesNotReceiveStaleReasoningSetting() throws Exception { + AiModelConfig config = config("RAG_REWRITE", "volcengine", "doubao-1-5-pro-32k"); + config.setExtraConfig(Map.of("reasoningEffort", "medium")); + assertRequest(model(config), null); + } + + @Test + void supportedRewriteModelHonorsExplicitEffort() throws Exception { + AiModelConfig config = config("RAG_REWRITE", "volcengine", "doubao-seed-2-0-pro-260215"); + config.setExtraConfig(Map.of("reasoningEffort", "low")); + assertRequest(model(config), "low"); + } + + @Test + void cacheRefreshAppliesChangedAndClearedEffort() throws Exception { + AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215"); + ChatModel initial = model(config); + assertSame(initial, factory.getChatModel("CHAT")); + assertRequest(initial, "minimal"); + + config.setExtraConfig(Map.of("reasoningEffort", "medium")); + factory.clearCache(); + ChatModel changed = factory.getChatModel("CHAT"); + assertNotSame(initial, changed); + assertRequest(changed, "medium"); + + config.setExtraConfig(Map.of()); + factory.clearCache(); + assertRequest(factory.getChatModel("CHAT"), "minimal"); + } + + @Test + void streamingUsesTheSameFastDefault() throws Exception { + ChatModel model = model(config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215")); + var responses = model.stream(new Prompt("测试")).collectList().block(Duration.ofSeconds(5)); + assertNotNull(responses); + assertFalse(responses.isEmpty()); + JsonNode request = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(request); + assertTrue(request.path("stream").asBoolean()); + assertEquals("minimal", request.path("reasoning_effort").asText()); + } + + private AiModelConfig config(String appType, String provider, String modelName) { + return AiModelConfig.builder() + .id(1L).appType(appType).provider(provider).modelName(modelName) + .apiKey("local-test-only").baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) + .temperature(0.7).maxTokens(4096).build(); + } + + private ChatModel model(AiModelConfig config) { + when(configService.getActiveConfigWithFullKey(config.getAppType())).thenReturn(config); + return factory.getChatModel(config.getAppType()); + } + + private JsonNode assertRequest(ChatModel model, String expectedEffort) throws Exception { + assertEquals(expectedEffort, ((OpenAiChatOptions) model.getDefaultOptions()).getReasoningEffort()); + assertEquals("ok", model.call(new Prompt("测试")).getResult().getOutput().getText()); + JsonNode request = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(request); + if (expectedEffort == null) assertFalse(request.has("reasoning_effort")); + else assertEquals(expectedEffort, request.path("reasoning_effort").asText()); + assertEquals(4096, request.path("max_tokens").asInt()); + assertEquals(0.7, request.path("temperature").asDouble()); + assertFalse(request.has("thinking")); + return request; + } +}