Browse Source

feat(model): 支持豆包 Seed 2.0 思考强度配置

feature/test
wei-py 3 weeks ago
parent
commit
b3f6774ccb
  1. 16
      frontend/src/types/models.ts
  2. 33
      frontend/src/views/ModelConfigManager.vue
  3. 27
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  4. 177
      src/test/java/com/wok/supportbot/ChatModelFactoryTests.java

16
frontend/src/types/models.ts

@ -99,6 +99,20 @@ export type ModelProvider = 'dashscope' | 'deepseek' | 'volcengine' | 'moonshot'
/** 应用类型 */ /** 应用类型 */
export type AppType = 'CHAT' | 'EMBEDDING' | 'RAG_REWRITE' | 'RERANK' 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 { export interface AiModelConfig {
id: string id: string
@ -112,7 +126,7 @@ export interface AiModelConfig {
temperature?: number temperature?: number
maxTokens?: number maxTokens?: number
topP?: number topP?: number
extraConfig?: Record<string, any>
extraConfig?: ModelExtraConfig
isActive: boolean isActive: boolean
priority?: number priority?: number
healthStatus?: 'healthy' | 'unhealthy' | 'unknown' healthStatus?: 'healthy' | 'unhealthy' | 'unknown'

33
frontend/src/views/ModelConfigManager.vue

@ -207,6 +207,14 @@
<t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;"> <t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;">
<t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" /> <t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" />
</t-form-item> </t-form-item>
<t-form-item v-if="supportsSeedReasoning" label="Seed 2.0 思考强度" style="grid-column:1/-1;">
<t-select v-model="editModal.form.reasoningEffort" :options="reasoningEffortOptions"
clearable :placeholder="reasoningDefaultLabel" />
<template #help>
<span>对话留空默认 minimal(关闭思考、优先响应速度);low / medium / high 启用思考,
可能改善复杂问题的回答质量,但会增加首字等待与生成耗时。RAG 重写留空沿用厂商默认。</span>
</template>
</t-form-item>
</div> </div>
</t-collapse-panel> </t-collapse-panel>
</t-collapse> </t-collapse>
@ -292,6 +300,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/model-config' import * as api from '@/api/model-config'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import type { ModelExtraConfig } from '@/types/models'
const { confirm } = useConfirm() const { confirm } = useConfirm()
@ -404,11 +413,27 @@ const editModal = ref<any>({
form: createEmptyForm(), 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() { function createEmptyForm() {
return { return {
name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '', name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '',
temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024, temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024,
topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '', topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '',
reasoningEffort: '', extraConfig: {} as ModelExtraConfig,
priority: 0, is_active: false, description: '', priority: 0, is_active: false, description: '',
} }
} }
@ -578,6 +603,7 @@ function openEditModal(config: any) {
topP: extra.topP ?? null, topK: extra.topK ?? null, topP: extra.topP ?? null, topK: extra.topK ?? null,
frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null, frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null,
stopSequences: extra.stopSequences || '', priority: config.priority || 0, stopSequences: extra.stopSequences || '', priority: config.priority || 0,
reasoningEffort: extra.reasoningEffort || '', extraConfig: { ...extra },
is_active: config.is_active || false, description: config.description || '', is_active: config.is_active || false, description: config.description || '',
}, },
} }
@ -591,13 +617,18 @@ function openEditModal(config: any) {
// ==================== 保存 ==================== // ==================== 保存 ====================
function toCamelCase(form: 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 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.app_type === 'EMBEDDING') extraConfig.dimensions = form.embeddingDimensions || 1024
if (form.topP != null) extraConfig.topP = form.topP if (form.topP != null) extraConfig.topP = form.topP
if (form.topK != null) extraConfig.topK = form.topK if (form.topK != null) extraConfig.topK = form.topK
if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty
if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty
if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim() if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim()
if (form.reasoningEffort) extraConfig.reasoningEffort = form.reasoningEffort
data.extraConfig = extraConfig data.extraConfig = extraConfig
return data return data
} }

27
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -200,6 +200,7 @@ public class ChatModelFactory {
} }
// F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences) // F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences)
applyExtraConfig(config, optionsBuilder); applyExtraConfig(config, optionsBuilder);
applyReasoningEffort(config, optionsBuilder);
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.openAiApi(api) .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 * 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder
*/ */

177
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<JsonNode> 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;
}
}
Loading…
Cancel
Save