From 59a35db2856e38613ca50deeca5ca32264b069e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Sat, 15 Aug 2026 17:05:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Ddeepseek=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/dao/ai_generation_dao.go | 39 ++- internal/handler/enhancer_handler.go | 4 +- internal/handler/history_handler.go | 16 +- internal/llm/deepseek.go | 19 +- internal/llm/openai.go | 22 +- internal/llm/openai_compat_url.go | 29 +++ internal/llm/openai_compat_url_test.go | 28 +++ internal/llm/other_providers.go | 28 ++- internal/service/enhancer.go | 322 ++++++++++++++++++++++--- 9 files changed, 428 insertions(+), 79 deletions(-) create mode 100644 internal/llm/openai_compat_url.go create mode 100644 internal/llm/openai_compat_url_test.go diff --git a/internal/dao/ai_generation_dao.go b/internal/dao/ai_generation_dao.go index 3492b5a..ef15cdf 100644 --- a/internal/dao/ai_generation_dao.go +++ b/internal/dao/ai_generation_dao.go @@ -43,7 +43,16 @@ type AIGenerationRow struct { ErrorMsg string `gorm:"column:error_msg" json:"error_msg"` InputSnapshot string `gorm:"column:input_snapshot" json:"input_snapshot,omitempty"` ResultJSON string `gorm:"column:result_json" json:"result_json,omitempty"` - CreatedAt int `gorm:"column:created_at" json:"created_at"` + // 人工打分 / 归档(超管在 xk-admin 写入,本端只读展示) + IsCorrect int `gorm:"column:is_correct" json:"is_correct"` + QualityScore int `gorm:"column:quality_score" json:"quality_score"` + ReviewRemark string `gorm:"column:review_remark" json:"review_remark"` + ReviewedBy int `gorm:"column:reviewed_by" json:"reviewed_by"` + ReviewedAt int `gorm:"column:reviewed_at" json:"reviewed_at"` + IsArchived int `gorm:"column:is_archived" json:"is_archived"` + ArchivedAt int `gorm:"column:archived_at" json:"archived_at"` + EvalCaseID int `gorm:"column:eval_case_id" json:"eval_case_id"` + CreatedAt int `gorm:"column:created_at" json:"created_at"` } // TableName 指定表名 @@ -75,16 +84,18 @@ func (AIGenerationStepRow) TableName() string { return "xk_ai_generation_step" } // AIGenerationListFilter 历史列表的筛选条件 // -// Status / ViaAgent 用 -1 表示"不筛选"(0 是合法业务值,不能当哨兵) +// Status / ViaAgent / IsArchived / IsCorrect 用 -1 表示"不筛选"(0 是合法业务值,不能当哨兵) type AIGenerationListFilter struct { - Page int // 页码(从 1 开始) - Size int // 每页条数(1~100) - Scene string // 场景精确匹配,空=全部 - Status int // -1=全部 0进行中 1成功 2失败 - ViaAgent int // -1=全部 0=PHP直连 1=Go Agent - Provider string // 供应商精确匹配,空=全部 - DateStart int64 // created_at >= 起始时间戳,0=不限 - DateEnd int64 // created_at <= 截止时间戳,0=不限 + Page int // 页码(从 1 开始) + Size int // 每页条数(1~100) + Scene string // 场景精确匹配,空=全部 + Status int // -1=全部 0进行中 1成功 2失败 + ViaAgent int // -1=全部 0=PHP直连 1=Go Agent + Provider string // 供应商精确匹配,空=全部 + IsArchived int // -1=全部 0未归档 1已归档 + IsCorrect int // -1=全部 0未评 1正确 2不正确 + DateStart int64 // created_at >= 起始时间戳,0=不限 + DateEnd int64 // created_at <= 截止时间戳,0=不限 } // AIGenerationList 分页查询生成历史(倒序) @@ -115,6 +126,12 @@ func AIGenerationList(f AIGenerationListFilter) ([]AIGenerationRow, int64, error if f.Provider != "" { q = q.Where("provider = ?", f.Provider) } + if f.IsArchived >= 0 { + q = q.Where("is_archived = ?", f.IsArchived) + } + if f.IsCorrect >= 0 { + q = q.Where("is_correct = ?", f.IsCorrect) + } if f.DateStart > 0 { q = q.Where("created_at >= ?", f.DateStart) } @@ -128,7 +145,7 @@ func AIGenerationList(f AIGenerationListFilter) ([]AIGenerationRow, int64, error } var rows []AIGenerationRow - err := q.Select("id, store_id, register_id, doctor_id, scene, prescription_type, name, provider, model, status, api_key_id, via_agent, step_count, prompt_tokens, completion_tokens, total_tokens, started_at, finished_at, duration_ms, error_msg, created_at"). + err := q.Select("id, store_id, register_id, doctor_id, scene, prescription_type, name, provider, model, status, api_key_id, via_agent, step_count, prompt_tokens, completion_tokens, total_tokens, started_at, finished_at, duration_ms, error_msg, is_correct, quality_score, review_remark, reviewed_by, reviewed_at, is_archived, archived_at, eval_case_id, created_at"). Order("id DESC"). Offset((f.Page - 1) * f.Size). Limit(f.Size). diff --git a/internal/handler/enhancer_handler.go b/internal/handler/enhancer_handler.go index 994cec0..74d645c 100644 --- a/internal/handler/enhancer_handler.go +++ b/internal/handler/enhancer_handler.go @@ -48,7 +48,9 @@ func NewEnhancerHandler(svc *service.EnhancerService) *EnhancerHandler { // { // "code": 200, // "data": { -// "content": "...", +// "content": "...", // 第一份成功(兼容) +// "contents": ["..."], // 仅成功内容 +// "results": [{index,ok,content,error,steps}], // 多份时按槽位 // "provider": "spark", // "model": "spark-max", // "steps": [...], diff --git a/internal/handler/history_handler.go b/internal/handler/history_handler.go index 4698e60..63721ea 100644 --- a/internal/handler/history_handler.go +++ b/internal/handler/history_handler.go @@ -31,15 +31,17 @@ func NewHistoryHandler() *HistoryHandler { } // List 分页查询历史 -// GET /api/v1/agent/history?page=1&size=20&scene=&status=-1&via_agent=-1&provider=&date_start=0&date_end=0 +// GET /api/v1/agent/history?page=1&size=20&scene=&status=-1&via_agent=-1&is_archived=-1&is_correct=-1&provider=&date_start=0&date_end=0 func (h *HistoryHandler) List(c *gin.Context) { filter := dao.AIGenerationListFilter{ - Page: queryInt(c, "page", 1), - Size: queryInt(c, "size", 20), - Scene: c.Query("scene"), - Status: queryInt(c, "status", -1), - ViaAgent: queryInt(c, "via_agent", -1), - Provider: c.Query("provider"), + Page: queryInt(c, "page", 1), + Size: queryInt(c, "size", 20), + Scene: c.Query("scene"), + Status: queryInt(c, "status", -1), + ViaAgent: queryInt(c, "via_agent", -1), + Provider: c.Query("provider"), + IsArchived: queryInt(c, "is_archived", -1), + IsCorrect: queryInt(c, "is_correct", -1), } filter.DateStart = int64(queryInt(c, "date_start", 0)) filter.DateEnd = int64(queryInt(c, "date_end", 0)) diff --git a/internal/llm/deepseek.go b/internal/llm/deepseek.go index f04ddef..58b405d 100644 --- a/internal/llm/deepseek.go +++ b/internal/llm/deepseek.go @@ -34,12 +34,12 @@ import ( // DeepSeekClient DeepSeek 模型客户端 type DeepSeekClient struct { - apiKey string // API 密钥 - baseURL string // API 地址(默认 https://api.deepseek.com) - model string // 模型名称(deepseek-chat / deepseek-reasoner) - client *http.Client // HTTP 客户端(带超时) - capabilities map[string]bool // 能力声明 - lastResult *types.ChatResult // 最近一次 Chat 的 token/finish_reason 快照(线程不安全,仅用于单线程场景) + apiKey string // API 密钥 + baseURL string // API 地址(默认 https://api.deepseek.com) + model string // 模型名称(deepseek-chat / deepseek-reasoner) + client *http.Client // HTTP 客户端(带超时) + capabilities map[string]bool // 能力声明 + lastResult *types.ChatResult // 最近一次 Chat 的 token/finish_reason 快照(线程不安全,仅用于单线程场景) } // createDeepSeekClient 工厂方法:创建 DeepSeek 客户端 @@ -73,7 +73,7 @@ func createDeepSeekClient(cfg *config.LLMConfigEx) (LLMClient, error) { client.capabilities["deep_reasoning"] = true } - log.Printf("[DeepSeek] 初始化完成 | 模型: %s | 地址: %s", model, baseURL) + log.Printf("[DeepSeek] 初始化完成 | 模型: %s | 地址: %s | 请求: %s", model, baseURL, ResolveChatCompletionsURL(baseURL)) return client, nil } @@ -184,7 +184,7 @@ func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []types.Messag } buf, _ := json.Marshal(body) - req, _ := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(buf)) + req, _ := http.NewRequestWithContext(ctx, "POST", ResolveChatCompletionsURL(c.baseURL), bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") // SSE 流 @@ -313,7 +313,8 @@ func (c *DeepSeekClient) buildToolDefs(tools []types.Tool) []map[string]any { // doRequest 执行 HTTP 请求 func (c *DeepSeekClient) doRequest(ctx context.Context, body map[string]any) (map[string]any, error) { buf, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(buf)) + // 兼容 DB 完整路径与 yaml 仅 host,避免 /chat/completions 双重拼接 404 + req, err := http.NewRequestWithContext(ctx, "POST", ResolveChatCompletionsURL(c.baseURL), bytes.NewReader(buf)) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } diff --git a/internal/llm/openai.go b/internal/llm/openai.go index c3ca8cb..efa5f4b 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -31,12 +31,12 @@ import ( // OpenAIClient OpenAI 模型客户端 type OpenAIClient struct { - apiKey string - baseURL string - model string + apiKey string + baseURL string + model string embeddingModel string - client *http.Client - capabilities map[string]bool + client *http.Client + capabilities map[string]bool } // createOpenAIClient 工厂方法:创建 OpenAI 客户端 @@ -54,11 +54,11 @@ func createOpenAIClient(cfg *config.LLMConfigEx) (LLMClient, error) { isReasoningModel := len(model) >= 2 && model[:2] == "o1" client := &OpenAIClient{ - apiKey: cfg.APIKey, - baseURL: baseURL, - model: model, + apiKey: cfg.APIKey, + baseURL: baseURL, + model: model, embeddingModel: cfg.EmbeddingModel, - client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}, + client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}, capabilities: map[string]bool{ CapFunctionCalling: true, CapStreaming: true, @@ -163,7 +163,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []types.Message, tools // 发送请求 buf, _ := json.Marshal(body) - url := c.baseURL + "/chat/completions" + url := ResolveChatCompletionsURL(c.baseURL) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") @@ -258,7 +258,7 @@ func (c *OpenAIClient) StreamChat(ctx context.Context, messages []types.Message, } buf, _ := json.Marshal(body) - url := c.baseURL + "/chat/completions" + url := ResolveChatCompletionsURL(c.baseURL) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") diff --git a/internal/llm/openai_compat_url.go b/internal/llm/openai_compat_url.go new file mode 100644 index 0000000..36f4072 --- /dev/null +++ b/internal/llm/openai_compat_url.go @@ -0,0 +1,29 @@ +package llm + +import "strings" + +// ResolveChatCompletionsURL 将配置里的 baseURL 归一成最终 Chat Completions 请求地址。 +// +// 背景:xk_ai_platform.api_url 与 PHP 约定存「完整路径」 +// (如 https://api.deepseek.com/v1/chat/completions),而 yaml 常只写 host +// (如 https://api.deepseek.com)。DeepSeek/OpenAI/Qwen 客户端若无脑追加 +// /chat/completions,会把完整路径拼成双重路径导致厂商 404。 +// +// 规则: +// - 已以 /chat/completions 结尾 → 原样使用(兼容 DB 完整路径) +// - 以 /v1 结尾 → 追加 /chat/completions +// - 其它 → 追加 /v1/chat/completions(兼容仅 host 的 yaml) +func ResolveChatCompletionsURL(baseURL string) string { + u := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if u == "" { + return u + } + lower := strings.ToLower(u) + if strings.HasSuffix(lower, "/chat/completions") { + return u + } + if strings.HasSuffix(lower, "/v1") { + return u + "/chat/completions" + } + return u + "/v1/chat/completions" +} diff --git a/internal/llm/openai_compat_url_test.go b/internal/llm/openai_compat_url_test.go new file mode 100644 index 0000000..5ec0702 --- /dev/null +++ b/internal/llm/openai_compat_url_test.go @@ -0,0 +1,28 @@ +package llm + +import "testing" + +func TestResolveChatCompletionsURL(t *testing.T) { + cases := []struct { + in string + want string + }{ + // DB 完整路径:不可再拼 + {"https://api.deepseek.com/v1/chat/completions", "https://api.deepseek.com/v1/chat/completions"}, + {"https://api.deepseek.com/v1/chat/completions/", "https://api.deepseek.com/v1/chat/completions"}, + // yaml 仅 host + {"https://api.deepseek.com", "https://api.deepseek.com/v1/chat/completions"}, + {"https://api.deepseek.com/", "https://api.deepseek.com/v1/chat/completions"}, + // 已到 /v1 + {"https://api.deepseek.com/v1", "https://api.deepseek.com/v1/chat/completions"}, + // OpenAI 常见写法 + {"https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"}, + {"https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1/chat/completions"}, + } + for _, tc := range cases { + got := ResolveChatCompletionsURL(tc.in) + if got != tc.want { + t.Errorf("ResolveChatCompletionsURL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/llm/other_providers.go b/internal/llm/other_providers.go index ca17218..8e592b3 100644 --- a/internal/llm/other_providers.go +++ b/internal/llm/other_providers.go @@ -77,13 +77,17 @@ func createAzureClient(cfg *config.LLMConfigEx) (LLMClient, error) { return client, nil } -func (c *AzureOpenAIClient) Name() string { return c.deployment } -func (c *AzureOpenAIClient) Provider() string { return "azure" } +func (c *AzureOpenAIClient) Name() string { return c.deployment } +func (c *AzureOpenAIClient) Provider() string { return "azure" } func (c *AzureOpenAIClient) Supports(cap string) bool { return c.capabilities[cap] } func (c *AzureOpenAIClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) { - // Azure 的 chat completions URL 格式 - url := fmt.Sprintf("%s/chat/completions?api-version=%s", c.baseURL, c.apiVersion) + // Azure 部署基址常不含 /chat/completions;若配置已是完整路径则不再追加,避免双重路径 + base := strings.TrimRight(strings.TrimSpace(c.baseURL), "/") + if !strings.HasSuffix(strings.ToLower(base), "/chat/completions") { + base = base + "/chat/completions" + } + url := fmt.Sprintf("%s?api-version=%s", base, c.apiVersion) openAIMsgs := make([]map[string]any, 0, len(messages)) for _, m := range messages { @@ -248,7 +252,7 @@ func createOllamaClient(cfg *config.LLMConfigEx) (LLMClient, error) { model: model, client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}, capabilities: map[string]bool{ - CapFunctionCalling: true, // Ollama 支持 tool calling(部分模型) + CapFunctionCalling: true, // Ollama 支持 tool calling(部分模型) CapStreaming: true, CapJSONMode: false, // 原生不支持,需 Prompt 引导 CapLongContext: false, // 取决于具体模型 @@ -260,8 +264,8 @@ func createOllamaClient(cfg *config.LLMConfigEx) (LLMClient, error) { return client, nil } -func (c *OllamaClient) Name() string { return c.model } -func (c *OllamaClient) Provider() string { return "ollama" } +func (c *OllamaClient) Name() string { return c.model } +func (c *OllamaClient) Provider() string { return "ollama" } func (c *OllamaClient) Supports(cap string) bool { return c.capabilities[cap] } func (c *OllamaClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) { @@ -477,8 +481,8 @@ func createQwenClient(cfg *config.LLMConfigEx) (LLMClient, error) { return client, nil } -func (c *QwenClient) Name() string { return c.model } -func (c *QwenClient) Provider() string { return "qwen" } +func (c *QwenClient) Name() string { return c.model } +func (c *QwenClient) Provider() string { return "qwen" } func (c *QwenClient) Supports(cap string) bool { return c.capabilities[cap] } func (c *QwenClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) { @@ -519,7 +523,7 @@ func (c *QwenClient) Chat(ctx context.Context, messages []types.Message, tools [ } buf, _ := json.Marshal(body) - url := c.baseURL + "/chat/completions" + url := ResolveChatCompletionsURL(c.baseURL) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") @@ -627,8 +631,8 @@ func createMockClient(cfg *config.LLMConfigEx) (LLMClient, error) { }, nil } -func (c *MockClient) Name() string { return c.name } -func (c *MockClient) Provider() string { return c.provider } +func (c *MockClient) Name() string { return c.name } +func (c *MockClient) Provider() string { return c.provider } func (c *MockClient) Supports(cap string) bool { // Mock 支持所有能力(测试用) return true diff --git a/internal/service/enhancer.go b/internal/service/enhancer.go index 2cc3443..02fb996 100644 --- a/internal/service/enhancer.go +++ b/internal/service/enhancer.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "tcm-agent/internal/agent" @@ -49,11 +50,11 @@ import ( // EnhancerService 知识增强服务 type EnhancerService struct { - maxkb *tool.MaxKBClient // MaxKB 客户端(kb_enabled=false 或 source=local 时可为 nil) - localSearch *kb.Searcher // 本地知识库检索器(V1 默认走这条路径) - llmRouter *llm.ModelRouter // 模型路由(按 scene 取 LLM) - llmFallback *llm.FallbackChain - cfg *config.Config + maxkb *tool.MaxKBClient // MaxKB 客户端(kb_enabled=false 或 source=local 时可为 nil) + localSearch *kb.Searcher // 本地知识库检索器(V1 默认走这条路径) + llmRouter *llm.ModelRouter // 模型路由(按 scene 取 LLM) + llmFallback *llm.FallbackChain + cfg *config.Config toolRegistry map[string]types.Tool // ReactLoop 用:工具注册表(name → tool);非空时启用 Function Calling } @@ -108,17 +109,20 @@ func (s *EnhancerService) WithTools(tools map[string]types.Tool) *EnhancerServic // EnhanceRequest PHP 发来的增强请求 type EnhanceRequest struct { - Scene string `json:"scene"` // 场景:medical_record / prescription - Context string `json:"context"` // 检索关键词(如"痰湿中阻 煎法") - Messages []types.Message `json:"messages"` // PHP 拼好的完整消息列表 - KBEnabled bool `json:"kb_enabled"` // 是否启用知识库检索 - TopK int `json:"top_k,omitempty"` // 检索条数(默认 5) - Provider string `json:"provider,omitempty"` // 强制用某 provider(空则走路由表) + Scene string `json:"scene"` // 场景:medical_record / prescription + Context string `json:"context"` // 检索关键词(如"痰湿中阻 煎法") + Messages []types.Message `json:"messages"` // PHP 拼好的完整消息列表 + KBEnabled bool `json:"kb_enabled"` // 是否启用知识库检索 + TopK int `json:"top_k,omitempty"` // 检索条数(默认 5) + Provider string `json:"provider,omitempty"` // 强制用某 provider(空则走路由表) AgentConfig *AgentConfigOpts `json:"agent_config,omitempty"` // 可选:PHP 透传覆盖 DB 中的 Agent 配置(不传则 Go 直读 DB) // 生成参数(P0 修复:此前 PHP 按场景调的参数在 via-agent 路径被丢弃) // 0 表示"未显式指定",走客户端默认(deepseek/spark 默认 0.3 / 4096) Temperature float64 `json:"temperature,omitempty"` // 生成温度(0-2) MaxTokens int `json:"max_tokens,omitempty"` // 单次响应 token 上限 + // Count 一次生成几份(默认 1,上限 5)。>1 时 Go 并行打多路 LLM,按槽位返回 Results; + // 单路失败不拖死整批,至少成功 1 路才算业务成功。 + Count int `json:"count,omitempty"` } // AgentConfigOpts 可选的 Agent 配置覆盖项(PHP 透传) @@ -145,17 +149,30 @@ type EnhanceStep struct { StartedAt int64 `json:"started_at"` // unix 秒 FinishedAt int64 `json:"finished_at"` Detail string `json:"detail,omitempty"` // 备注(如命中文档数 / 失败原因) - Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致) - lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON) + Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致) + lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON) +} + +// EnhanceSlotResult 多份生成时每个槽位的结果(成功填 Content,失败填 Error) +type EnhanceSlotResult struct { + Index int `json:"index"` // 0-based 槽位 + OK bool `json:"ok"` // 本槽是否成功 + Content string `json:"content,omitempty"` // 成功时的 LLM 文本 + Error string `json:"error,omitempty"` // 失败原因 + Steps []EnhanceStep `json:"steps,omitempty"` // 本槽独立步骤 + Provider string `json:"provider,omitempty"` // 本槽实际 provider + Model string `json:"model,omitempty"` // 本槽实际 model } // EnhanceResponse 返回给 PHP 的结果 type EnhanceResponse struct { - Content string `json:"content"` // LLM 生成的文本 - Provider string `json:"provider"` // 实际使用的供应商 - Model string `json:"model"` // 实际使用的模型名 - Steps []EnhanceStep `json:"steps"` // 每一步过程(PHP 据此写 step 子表) - TotalMs int `json:"total_ms"` // 总耗时 + Content string `json:"content"` // 第一份成功 content(兼容旧 PHP) + Contents []string `json:"contents,omitempty"` // 仅成功内容数组(可选兼容) + Results []EnhanceSlotResult `json:"results,omitempty"` // 多份时按槽位完整结果 + Provider string `json:"provider"` // 实际使用的供应商(首份成功) + Model string `json:"model"` // 实际使用的模型名(首份成功) + Steps []EnhanceStep `json:"steps"` // 共享步骤 + 汇总(单份时完整) + TotalMs int `json:"total_ms"` // 总耗时(并行时约等于最慢一路) // cfgSource 本次请求的配置来源(内部字段,不返回 PHP): // doEnhance 写入,Enhance 包装层读出来写 RunLog—— // 用 resp 传递而不是 service 字段,保证并发请求间不串号 @@ -267,6 +284,13 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (* } } + // 多份并行:KB/守卫/客户端解析只做一次,LLM 按槽位 goroutine 打; + // 处方严格 JSON 场景 PHP 已关 React,多份路径统一走单次 Chat,避免 N×多轮成本爆炸 + count := normalizeEnhanceCount(req.Count) + if count > 1 { + return s.doEnhanceMulti(ctx, req, messages, client, provider, meta, agentCfg, totalStart, resp, count) + } + if reactOn { // ---- 路径 A:ReactLoop(多轮 + Planning + Reflection + JSON 修复)---- reactReq := &ReactLoopRequest{ @@ -443,6 +467,247 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (* return resp, nil } +// normalizeEnhanceCount 钳制一次生成份数:默认 1,上限 5 +func normalizeEnhanceCount(n int) int { + if n <= 1 { + return 1 + } + if n > 5 { + return 5 + } + return n +} + +// doEnhanceMulti 多份并行 LLM:共享前置步骤,按槽位返回成功/失败 +// +// 为什么并行:份数>1 时串行会把医生等待拉到 N× 单次;WaitGroup 并行后墙钟约等于最慢一路。 +// 部分失败语义:某路超时/LLM 错误只标该槽 ok=false,其它成功槽照常返回;全部失败才 error。 +func (s *EnhancerService) doEnhanceMulti( + ctx context.Context, + req *EnhanceRequest, + messages []types.Message, + client llm.LLMClient, + provider string, + meta resolveMeta, + agentCfg *agentcfg.AllConfig, + totalStart time.Time, + resp *EnhanceResponse, + count int, +) (*EnhanceResponse, error) { + log.Printf("[Enhancer] 多份并行开始 scene=%s count=%d provider=%s", req.Scene, count, provider) + + slots := make([]EnhanceSlotResult, count) + var wg sync.WaitGroup + for i := 0; i < count; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + slots[idx] = EnhanceSlotResult{ + Index: idx, + OK: false, + Error: fmt.Sprintf("panic: %v", r), + } + } + }() + slotMsgs := cloneMessagesWithVariantHint(messages, idx, count) + content, steps, usedProvider, usedModel, err := s.runSingleChatSlot( + ctx, req, slotMsgs, client, provider, meta, agentCfg, idx, + ) + if err != nil { + slots[idx] = EnhanceSlotResult{ + Index: idx, + OK: false, + Error: err.Error(), + Steps: steps, + Provider: usedProvider, + Model: usedModel, + } + return + } + slots[idx] = EnhanceSlotResult{ + Index: idx, + OK: true, + Content: content, + Steps: steps, + Provider: usedProvider, + Model: usedModel, + } + }(i) + } + wg.Wait() + + resp.Results = slots + successContents := make([]string, 0, count) + var firstOK *EnhanceSlotResult + successN := 0 + for i := range slots { + if slots[i].OK { + successN++ + successContents = append(successContents, slots[i].Content) + if firstOK == nil { + cp := slots[i] + firstOK = &cp + } + } + // 汇总步骤时带上槽位前缀,便于 PHP 写子表排障 + for _, st := range slots[i].Steps { + st.Detail = fmt.Sprintf("[slot%d] %s", i, st.Detail) + resp.Steps = append(resp.Steps, st) + } + } + resp.Contents = successContents + resp.TotalMs = int(time.Since(totalStart).Milliseconds()) + + if firstOK == nil { + log.Printf("[Enhancer] 多份并行全部失败 scene=%s count=%d total=%dms", req.Scene, count, resp.TotalMs) + return resp, fmt.Errorf("多份生成全部失败(%d 路)", count) + } + + resp.Content = firstOK.Content + resp.Provider = firstOK.Provider + resp.Model = firstOK.Model + log.Printf("[Enhancer] 多份并行完成 scene=%s success=%d/%d total=%dms", + req.Scene, successN, count, resp.TotalMs) + return resp, nil +} + +// cloneMessagesWithVariantHint 复制 messages,并在末条 user 追加「第 i 套方案」提示以拉开差异 +func cloneMessagesWithVariantHint(src []types.Message, idx, total int) []types.Message { + out := make([]types.Message, len(src)) + copy(out, src) + if total <= 1 || len(out) == 0 { + return out + } + hint := fmt.Sprintf( + "\n\n【多方案要求】请生成第 %d/%d 套组方方案,与其它套方案在用药组合或君臣佐使上尽量有可对比的差异;仍只输出约定 JSON,不要解释。", + idx+1, total, + ) + // 从后往前找最后一条 user,找不到则追加 + for i := len(out) - 1; i >= 0; i-- { + if strings.EqualFold(strings.TrimSpace(out[i].Role), "user") { + out[i].Content = out[i].Content + hint + return out + } + } + out = append(out, types.Message{Role: "user", Content: strings.TrimSpace(hint)}) + return out +} + +// runSingleChatSlot 单槽一次 Chat(含 fallback),供多份并行调用 +// +// 每个 goroutine 独立温度微调(基温 + idx*0.05,上限 0.6),进一步提高方案差异。 +func (s *EnhancerService) runSingleChatSlot( + ctx context.Context, + req *EnhanceRequest, + messages []types.Message, + client llm.LLMClient, + provider string, + meta resolveMeta, + agentCfg *agentcfg.AllConfig, + idx int, +) (content string, steps []EnhanceStep, usedProvider, usedModel string, err error) { + usedProvider = provider + usedModel = client.Name() + llmStep := EnhanceStep{ + StepType: "llm_call", + StartedAt: time.Now().Unix(), + } + llmStart := time.Now() + + opts := llm.ChatOpts{} + if agentCfg != nil && agentCfg.TokenBudget.Enabled { + opts.MaxTokens = agentCfg.TokenBudget.MaxTokensPerCall + } + baseTemp := req.Temperature + if baseTemp <= 0 { + baseTemp = 0.2 + } + // 槽位温度微调:略抬高后续方案温度,拉开差异但仍偏稳 + opts.Temperature = baseTemp + float64(idx)*0.05 + if opts.Temperature > 0.6 { + opts.Temperature = 0.6 + } + if req.MaxTokens > 0 { + if opts.MaxTokens == 0 || req.MaxTokens < opts.MaxTokens { + opts.MaxTokens = req.MaxTokens + } + } + + msg, chatResult := s.callLLMWithMeta(ctx, client, messages, nil, opts) + llmStep.DurationMs = int(time.Since(llmStart).Milliseconds()) + llmStep.FinishedAt = time.Now().Unix() + llmStep.Provider = usedProvider + llmStep.Model = usedModel + if meta.APIKeyID > 0 { + llmStep.APIKeyID = meta.APIKeyID + } + if chatResult != nil { + llmStep.PromptTokens = chatResult.PromptTokens + llmStep.CompletionTokens = chatResult.CompletionTokens + llmStep.TotalTokens = chatResult.TotalTokens + llmStep.Usage = chatResult.Usage + if llmStep.APIKeyID == 0 { + llmStep.APIKeyID = chatResult.APIKeyID + } + } + + if msg == nil && s.llmFallback != nil && provider != "" { + sceneKey := req.Scene + if sceneKey == "" { + sceneKey = "emr-generator" + } + chain, ok := s.llmFallback.GetChain(sceneKey) + if ok { + for _, nextProvider := range chain { + if nextProvider == provider { + continue + } + fbClient, fbErr := s.llmRouter.GetByProvider(nextProvider) + if fbErr != nil { + continue + } + fbMsg, fbChatResult := s.callLLMWithMeta(ctx, fbClient, messages, nil, opts) + if fbMsg != nil { + client = fbClient + usedProvider = nextProvider + usedModel = client.Name() + msg = fbMsg + chatResult = fbChatResult + llmStep.Provider = usedProvider + llmStep.Model = usedModel + if fbChatResult != nil { + llmStep.PromptTokens = fbChatResult.PromptTokens + llmStep.CompletionTokens = fbChatResult.CompletionTokens + llmStep.TotalTokens = fbChatResult.TotalTokens + llmStep.Usage = fbChatResult.Usage + llmStep.APIKeyID = fbChatResult.APIKeyID + llmStep.Detail = "成功(fallback)" + } + break + } + } + } + } + + if msg == nil { + llmStep.Status = 2 + llmStep.Detail = "LLM 调用失败" + steps = append(steps, llmStep) + return "", steps, usedProvider, usedModel, fmt.Errorf("LLM 调用失败") + } + + llmStep.Status = 1 + if chatResult != nil && chatResult.FinishReason != "" { + llmStep.Detail = "成功 | finish_reason=" + chatResult.FinishReason + } else if llmStep.Detail == "" { + llmStep.Detail = "成功" + } + steps = append(steps, llmStep) + return msg.Content, steps, usedProvider, usedModel, nil +} + // jsonSnapshot 把 BudgetSnapshot 序列化成短字符串(仅用于日志) func jsonSnapshot(s agent.BudgetSnapshot) string { b, _ := json.Marshal(s) @@ -583,11 +848,11 @@ func (s *EnhancerService) getTimeoutForProvider(provider string) int { // 为什么要限:TopK*整段 chunk + 方剂可能过万字,直接塞 system 会稀释业务指令 // 且逼近轻量模型上下文上限;预算按"药材知识为主、方剂为辅"分配 const ( - kbDocMaxRunes = 500 // 单条药材知识注入上限 - kbDocsTotalRunes = 2600 // 药材知识总预算 - kbFormulaMaxRunes = 400 // 单条参考方剂注入上限 - kbFormulaTopK = 3 // 方剂检索条数(方剂是"参考骨架",2~3 个足够化裁) - kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声 + kbDocMaxRunes = 500 // 单条药材知识注入上限 + kbDocsTotalRunes = 2600 // 药材知识总预算 + kbFormulaMaxRunes = 400 // 单条参考方剂注入上限 + kbFormulaTopK = 3 // 方剂检索条数(方剂是"参考骨架",2~3 个足够化裁) + kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声 ) // performKBRetrieval 执行知识库检索(按 agentcfg.KB.Source 分流) @@ -681,11 +946,11 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe // localKBHit 本地多库归并检索的中间结果(按 doc 去重、加权后排序用) type localKBHit struct { - docID uint - title string - content string - score float64 - libName string + docID uint + title string + content string + score float64 + libName string } // retrieveFromLocalMulti 本地知识库多库归并检索(P1 主路径) @@ -895,6 +1160,7 @@ func listActiveLibraries() ([]libraryView, error) { } return out, nil } + // // 插入位置策略: // - 若 messages[0] 是 system → 在其后插入(保持业务 system 优先级最高)