修复deepseek无法对话的bug

This commit is contained in:
李琦
2026-08-15 17:05:22 +08:00
parent d7e382f2e7
commit 59a35db285
9 changed files with 428 additions and 79 deletions

View File

@@ -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).

View File

@@ -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": [...],

View File

@@ -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))

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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"
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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 {
// ---- 路径 AReactLoop多轮 + 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 优先级最高)