diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 758ef17..cea75aa 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -412,6 +412,7 @@ CREATE TABLE biz_llm_models ( model_code VARCHAR(100), temperature DECIMAL(3,2) DEFAULT 0.7, top_p DECIMAL(3,2) DEFAULT 0.9, + max_tokens BIGINT NOT NULL DEFAULT 30000, is_default SMALLINT DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, status SMALLINT DEFAULT 1, diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java index e64fd7e..310e143 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java @@ -1,5 +1,6 @@ package com.imeeting.dto.biz; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.math.BigDecimal; @@ -32,6 +33,9 @@ public class AiModelDTO { private BigDecimal temperature; @Schema(description = "TopP 参数") private BigDecimal topP; + @JsonProperty("max_tokens") + @Schema(description = "最大输出 token 数") + private Long maxTokens; @Schema(description = "媒体配置") private Map mediaConfig; @Schema(description = "是否默认") diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java index 866d200..d39f16b 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java @@ -1,5 +1,6 @@ package com.imeeting.dto.biz; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.math.BigDecimal; @@ -33,6 +34,9 @@ public class AiModelVO { private BigDecimal temperature; @Schema(description = "TopP 参数") private BigDecimal topP; + @JsonProperty("max_tokens") + @Schema(description = "最大输出 token 数") + private Long maxTokens; @Schema(description = "多媒体配置") private Map mediaConfig; @Schema(description = "是否为默认模型") diff --git a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java index 4dacfdc..c8b50b2 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java @@ -1,6 +1,7 @@ package com.imeeting.entity.biz; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.unisbase.entity.BaseEntity; @@ -43,6 +44,10 @@ public class LlmModel extends BaseEntity { @Schema(description = "Top P参数") private BigDecimal topP; + @TableField("max_tokens") + @Schema(description = "最大输出 token 数") + private Long maxTokens; + @Schema(description = "是否默认模型") private Integer isDefault; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index 4f71467..93db1d8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -55,6 +55,7 @@ public class AiModelServiceImpl implements AiModelService { private static final String MEDIA_TENCENT_OFFLINE_MODEL_CODE = "tencentOfflineModelCode"; private static final String MEDIA_TENCENT_REALTIME_MODEL_CODE = "tencentRealtimeModelCode"; private static final int DEFAULT_SORT_ORDER = 0; + private static final long DEFAULT_LLM_MAX_TOKENS = 30000L; private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions"; private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages"; private static final String CONNECTIVITY_TEST_SYSTEM_PROMPT = """ @@ -397,7 +398,7 @@ public class AiModelServiceImpl implements AiModelService { Map body = new LinkedHashMap<>(); body.put("model", dto.getModelCode().trim()); body.put("stream", false); - body.put("max_tokens", 32); + body.put("max_tokens", resolveConnectivityMaxTokens(dto.getMaxTokens())); if (dto.getTemperature() != null) { body.put("temperature", dto.getTemperature()); } @@ -425,7 +426,7 @@ public class AiModelServiceImpl implements AiModelService { Map body = new LinkedHashMap<>(); body.put("model", dto.getModelCode().trim()); body.put("system", CONNECTIVITY_TEST_SYSTEM_PROMPT); - body.put("max_tokens", 32); + body.put("max_tokens", resolveConnectivityMaxTokens(dto.getMaxTokens())); body.put("messages", List.of( Map.of("role", "user", "content", resolveTestMessage(dto.getTestMessage())) )); @@ -461,7 +462,7 @@ public class AiModelServiceImpl implements AiModelService { "parts", List.of(Map.of("text", resolveTestMessage(dto.getTestMessage()))) ) )); - if (dto.getTemperature() != null || dto.getTopP() != null) { + if (dto.getTemperature() != null || dto.getTopP() != null || dto.getMaxTokens() != null) { Map generationConfig = new LinkedHashMap<>(); if (dto.getTemperature() != null) { generationConfig.put("temperature", dto.getTemperature()); @@ -469,6 +470,9 @@ public class AiModelServiceImpl implements AiModelService { if (dto.getTopP() != null) { generationConfig.put("topP", dto.getTopP()); } + if (dto.getMaxTokens() != null) { + generationConfig.put("maxOutputTokens", resolveConnectivityMaxTokens(dto.getMaxTokens())); + } body.put("generationConfig", generationConfig); } @@ -874,6 +878,11 @@ public class AiModelServiceImpl implements AiModelService { if (Integer.valueOf(1).equals(dto.getIsDefault()) && !Integer.valueOf(1).equals(dto.getStatus())) { throw new RuntimeException("默认模型必须为启用状态"); } + if (TYPE_LLM.equals(normalizeType(dto.getModelType())) + && dto.getMaxTokens() != null + && dto.getMaxTokens() <= 0) { + throw new RuntimeException("max_tokens 必须为正整数"); + } validateTencentAsrConfig(dto); // if ("custom".equals(normalizeProvider(dto.getProvider()))) { // if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) { @@ -1178,12 +1187,21 @@ public class AiModelServiceImpl implements AiModelService { entity.setModelCode(dto.getModelCode()); entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.2) : dto.getTemperature()); entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP()); + entity.setMaxTokens(resolveMaxTokens(dto.getMaxTokens())); entity.setIsDefault(dto.getIsDefault()); entity.setStatus(dto.getStatus()); entity.setSortOrder(normalizeSortOrder(dto.getSortOrder())); entity.setRemark(dto.getRemark()); } + private Long resolveMaxTokens(Long maxTokens) { + return maxTokens == null ? DEFAULT_LLM_MAX_TOKENS : maxTokens; + } + + private Long resolveConnectivityMaxTokens(Long maxTokens) { + return maxTokens == null ? 32L : maxTokens; + } + private AiModelVO toAsrVO(AsrModel entity) { return toAsrVO(entity, null, true); } @@ -1281,6 +1299,7 @@ public class AiModelServiceImpl implements AiModelService { vo.setModelCode(entity.getModelCode()); vo.setTemperature(entity.getTemperature()); vo.setTopP(entity.getTopP()); + vo.setMaxTokens(resolveMaxTokens(entity.getMaxTokens())); vo.setIsDefault(entity.getIsDefault()); vo.setStatus(entity.getStatus()); vo.setScope(Long.valueOf(0L).equals(entity.getTenantId()) ? "PLATFORM" : "TENANT"); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 97b875d..26c1668 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -1325,7 +1325,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme Map req = new HashMap<>(); req.put("model", llmModel.getModelCode()); req.put("temperature", llmModel.getTemperature()); - req.put("max_tokens", 30000); + req.put("max_tokens", llmModel.getMaxTokens() == null ? 30000L : llmModel.getMaxTokens()); req.put("messages", List.of( Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())), Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(taskRecord.getTaskConfig(), meeting, summarySource, userPrompt)) diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java index c0a814c..da75506 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java @@ -200,6 +200,7 @@ public class MeetingExternalSummaryWebhookTrigger { config.put("modelCode", model.getModelCode()); config.put("temperature", model.getTemperature()); config.put("topP", model.getTopP()); + config.put("max_tokens", model.getMaxTokens() == null ? 30000L : model.getMaxTokens()); return config; } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java index c44148b..6e1eca1 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java @@ -335,7 +335,7 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha Map requestBody = new LinkedHashMap<>(); requestBody.put("model", llmModel.getModelCode()); requestBody.put("temperature", llmModel.getTemperature()); - requestBody.put("max_tokens", 30000); + requestBody.put("max_tokens", llmModel.getMaxTokens() == null ? 30000L : llmModel.getMaxTokens()); requestBody.put("messages", List.of( Map.of("role", "system", "content", renderChapterSystemPrompt()), Map.of("role", "user", "content", renderChapterUserPrompt(transcripts)) @@ -1015,7 +1015,7 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha Map requestBody = new LinkedHashMap<>(); requestBody.put("model", llmModel.getModelCode()); requestBody.put("temperature", llmModel.getTemperature()); - requestBody.put("max_tokens", 30000); + requestBody.put("max_tokens", llmModel.getMaxTokens() == null ? 30000L : llmModel.getMaxTokens()); requestBody.put("messages", List.of( Map.of("role", "system", "content", renderChapterSystemPrompt()), Map.of("role", "user", "content", renderChapterUserPrompt(transcripts)) diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java index d471150..59f3d12 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java @@ -481,6 +481,64 @@ class AiModelServiceImplTest { assertEquals(7, captor.getValue().getSortOrder()); } + @Test + void saveModelShouldPersistMaxTokensForLlm() throws Exception { + AsrModelMapper asrModelMapper = mock(AsrModelMapper.class); + LlmModelMapper llmModelMapper = mock(LlmModelMapper.class); + when(llmModelMapper.insert(any(LlmModel.class))).thenReturn(1); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + asrModelMapper, + llmModelMapper + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setModelType("LLM"); + dto.setModelName("max-token-llm"); + dto.setProvider("openai"); + dto.setBaseUrl("http://127.0.0.1:9000"); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + dto.setMaxTokens(8192L); + dto.setIsDefault(0); + dto.setStatus(1); + + AiModelVO result = service.saveModel(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LlmModel.class); + verify(llmModelMapper, times(1)).insert(captor.capture()); + assertEquals(8192L, captor.getValue().getMaxTokens()); + assertEquals(8192L, result.getMaxTokens()); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(result)); + assertEquals(8192L, json.path("max_tokens").asLong()); + assertEquals(false, json.has("maxTokens")); + } + + @Test + void saveModelShouldRejectNonPositiveMaxTokensForLlm() { + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setModelType("LLM"); + dto.setModelName("invalid-max-token-llm"); + dto.setProvider("openai"); + dto.setBaseUrl("http://127.0.0.1:9000"); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + dto.setMaxTokens(0L); + dto.setIsDefault(0); + dto.setStatus(1); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto)); + assertEquals("max_tokens 必须为正整数", ex.getMessage()); + } + @Test void saveModelShouldRejectDisabledDefaultModel() { AiModelServiceImpl service = new AiModelServiceImpl( diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index 2b08f4d..5af1cc1 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -13,6 +13,7 @@ export interface AiModelVO { wsUrl?: string; temperature?: number; topP?: number; + max_tokens?: number; mediaConfig?: Record; isDefault: number; status: number; @@ -47,6 +48,7 @@ export interface AiModelDTO { wsUrl?: string; temperature?: number; topP?: number; + max_tokens?: number; mediaConfig?: Record; isDefault: number; status: number; @@ -157,6 +159,7 @@ export const testLlmModelConnectivity = (data: { modelCode: string; temperature?: number; topP?: number; + max_tokens?: number; testMessage: string; }) => { return http.post<{ code: string; data: boolean; msg: string }>( diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index e54f280..2fe9b94 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -323,6 +323,7 @@ const AiModels: React.FC = () => { : undefined, temperature: values.temperature, topP: values.topP, + max_tokens: values.max_tokens, isDefault: values.isDefaultChecked ? 1 : 0, status: values.statusChecked ? 1 : 0, sortOrder: values.sortOrder ?? 0, @@ -347,8 +348,8 @@ const AiModels: React.FC = () => { const handleTestConnectivity = async () => { if (activeType === "LLM") { - const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode"]); - const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP"]); + const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode", "max_tokens"]); + const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP", "max_tokens"]); setConnectivityLoading(true); try { await testLlmModelConnectivity({ @@ -359,6 +360,7 @@ const AiModels: React.FC = () => { modelCode: values.modelCode, temperature: extraValues.temperature, topP: extraValues.topP, + max_tokens: extraValues.max_tokens, testMessage: DEFAULT_LLM_TEST_MESSAGE, }); message.success("LLM 连通性测试成功"); @@ -773,16 +775,38 @@ const AiModels: React.FC = () => { - + - + + + { + if (value === undefined || value === null || value === "") { + return Promise.resolve(); + } + if (Number.isInteger(value) && value > 0) { + return Promise.resolve(); + } + return Promise.reject(new Error("max_tokens 必须为正整数")); + }, + }, + ]} + > + + + )}