package service // ======================================================================== // ReactLoop —— 完整 ReAct 循环(Planning + Reflection + JSON 自修复) // ======================================================================== // 本模块是 EnhanceService 的"增强路径",独立于 agent.Runner。 // // 为什么不复用 agent.Runner.Run: // 1. Runner.Run 是按 sessionID 操作的(带会话管理、并发清理协程), // 而 EnhanceService 是"无状态一次性请求",没有 session 概念; // 2. Runner.Run 只返回最终文本,无法返回步骤明细(token/耗时/思考过程), // 而我们需要把每一步落到 xk_ai_generation_step 子表; // 3. Runner.Run 没有预算管理、没有 Reflection、没有 JSON 修复。 // // 本循环的执行步骤(每一步都生成 EnhanceStep 落表): // [1] Planning(可选):让模型先输出 plan JSON // [2] 多轮 Think-Act-Observe: // for i := 0; i < maxIter; i++ { // budget 检查(超限中止) // LLM 调用(带 tools,可触发 Function Calling) // 累加 token,写 llm_call step // 若触发工具 → 执行 → 工具结果入栈 → continue // 否则进入 Reflection // } // [3] Reflection(可选):让模型自检输出(温度更低) // [4] JSON 修复(可选):JSON 不合法时让模型修复 // // 设计原则: // - 任一步骤失败都不中断整体(除非超预算或 ctx 取消) // - 所有 LLM 调用都通过 TokenBudget 控制 // - 步骤明细实时累加到 resp.Steps(即使中途失败也要返回已生成的部分) // ======================================================================== import ( "context" "encoding/json" "fmt" "log" "strings" "time" "tcm-agent/internal/agent" "tcm-agent/internal/agentcfg" "tcm-agent/internal/llm" "tcm-agent/internal/types" ) // ------------------------------------------------------------------ // 入参与返回结构 // ------------------------------------------------------------------ // ReactLoopRequest ReactLoop 的一次调用入参 // // 与 EnhanceRequest 区别: // - Messages 已注入 KB 检索结果(KB 步骤已由 EnhancerService 完成) // - Tools 是可选的(若客户端模型不支持 Function Calling,传 nil 走纯 chat) // - Client 已通过 resolveClient 解析好(避免 ReactLoop 重复解析) type ReactLoopRequest struct { Scene string // 场景(仅用于日志/step 记录) Messages []types.Message // 注入 KB 后的消息列表(会被本循环修改) Tools []types.Tool // 可用工具集(可空) Client llm.LLMClient // 已解析的 LLM 客户端 Provider string // 客户端对应的 provider 名 // Temperature 主生成温度覆盖(PHP 按场景显式传入时 >0); // 只影响主生成调用,Planning/Reflection/JSON 修复仍用各自的低温配置 Temperature float64 } // ReactLoopResult ReactLoop 的执行结果 type ReactLoopResult struct { Content string // 最终生成内容(最后一次 LLM 返回的文本) Steps []EnhanceStep // 全部步骤明细(Planning + 多轮 Chat + Reflection + JSON 修复) Aborted bool // 是否因预算超限而中止(true 时 Content 仍是已生成部分) Budget agent.BudgetSnapshot // 预算使用快照 } // ------------------------------------------------------------------ // ReactLoop 主体 // ------------------------------------------------------------------ // runReactLoop 执行完整 ReAct 循环 // // 调用方:EnhancerService.Enhance(仅在 agentcfg.ReAct.Enabled=true 时调用) // // 参数: // - ctx - 请求上下文(支持超时取消) // - req - 循环入参(messages/client/tools 已就绪) // - reactCfg - ReAct 配置(maxIter/Planning/Reflection 等,来自 agentcfg) // - budgetCfg - Token 预算配置(用于创建 budget 累加器) // // 返回:循环结果(含步骤明细 + 最终内容) func (s *EnhancerService) runReactLoop( ctx context.Context, req *ReactLoopRequest, reactCfg agentcfg.ReActConfig, budgetCfg agentcfg.TokenBudgetConfig, ) *ReactLoopResult { result := &ReactLoopResult{Steps: []EnhanceStep{}} // 创建 Token 预算器(limit=0 表示不限制,按配置决定) var limit int if budgetCfg.Enabled { limit = budgetCfg.PerRequest } budget := agent.NewTokenBudget(limit, budgetCfg.MaxTokensPerCall) // 消息快照(循环内修改的是这份拷贝,不污染调用方原 messages) messages := make([]types.Message, len(req.Messages)) copy(messages, req.Messages) // ===== [1] Planning 阶段(可选) ===== if reactCfg.PlanningEnabled { planStep := s.doPlanning(ctx, req, reactCfg, budget, &messages) result.Steps = append(result.Steps, planStep) if budget.IsExceeded() { result.Aborted = true result.Budget = budget.Snapshot() return result } } // ===== [2] Think-Act-Observe 多轮循环 ===== maxIter := reactCfg.MaxIterations if maxIter < 1 { maxIter = 3 } var lastContent string var lastProvider = req.Provider var lastModel = req.Client.Name() for i := 0; i < maxIter; i++ { // 预算检查(超限立即中止,返回已生成的部分) if budget.IsExceeded() { result.Aborted = true log.Printf("[ReactLoop] scene=%s 预算超限中止 at iter=%d used=%d limit=%d", req.Scene, i, budget.Used(), limit) break } // 计算本次调用 max_tokens(取 min(配置单次上限, 剩余预算)) maxTokens := budget.CalcMaxTokensForCall(budgetCfg.MaxTokensPerCall) // 主生成温度:PHP 按场景显式指定时优先(处方 0.3→可调低),否则用默认 0.3 mainTemp := 0.3 if req.Temperature > 0 { mainTemp = req.Temperature } opts := llm.ChatOpts{ MaxTokens: maxTokens, Temperature: mainTemp, } // 调用 LLM(带工具) callStep := s.callLLMOnce(ctx, req, opts, messages, "llm_call") result.Steps = append(result.Steps, callStep) // 累加预算 budget.Consume(callStep.PromptTokens, callStep.CompletionTokens) if callStep.Status != 1 { // 调用失败:直接返回(上层决定要不要走降级) break } // 取 LLM 实际返回的 Message(callLLMOnce 已通过 LastChatResult 拿到 token, // 这里要拿 Message 内容 + ToolCall 信息) llmMsg := callStep.lastMessage if llmMsg == nil { break } lastContent = llmMsg.Content lastProvider = req.Provider lastModel = req.Client.Name() // 触发工具调用 → 执行工具 → 工具结果入栈 → 进入下一轮 if llmMsg.ToolCall != nil { toolStep := s.executeToolCall(ctx, llmMsg.ToolCall) result.Steps = append(result.Steps, toolStep) // 【协议正确性】assistant 帧 + tool 帧都入栈(与 runner.go 修复一致) // tool 帧必须带 ToolCallID 与 assistant 帧 tool_calls[].id 对应, // 否则 OpenAI 协议厂商(讯飞/DeepSeek)会拒绝下一轮请求 messages = append(messages, *llmMsg) messages = append(messages, types.Message{ Role: "tool", Content: toolStep.Detail, ToolCallID: llmMsg.ToolCall.ID, Timestamp: time.Now().Unix(), }) continue } // 未触发工具:本轮就是最终答案,跳出循环进入 Reflection break } // ===== [3] Reflection 阶段(可选) ===== if reactCfg.ReflectionEnabled && lastContent != "" && !budget.IsExceeded() { reflectStep, reflectOK := s.doReflection(ctx, req, reactCfg, budgetCfg, budget, lastContent) result.Steps = append(result.Steps, reflectStep) // 反思未通过:把反思详情放到 lastContent 末尾,让用户看到模型自检结果 if !reflectOK && reflectStep.Detail != "" { lastContent += "\n\n[反思反馈] " + reflectStep.Detail } } // ===== [4] JSON 修复(可选) ===== if reactCfg.JSONRepairEnabled && lastContent != "" { repaired, repairStep := s.repairJSONIfNeeded(ctx, req, reactCfg, budget, lastContent) if repairStep != nil { result.Steps = append(result.Steps, *repairStep) if repaired != "" { lastContent = repaired } } } result.Content = lastContent _ = lastProvider _ = lastModel result.Budget = budget.Snapshot() return result } // ------------------------------------------------------------------ // [1] Planning 阶段 // ------------------------------------------------------------------ // doPlanning 让模型先输出任务计划(先查什么 → 再查什么 → 最后生成) // // 设计目的: // - 让模型在"消耗大量 token 之前"先规划清楚步骤 // - 复杂任务(如开中药处方)效果显著:模型可能意识到要"先查 18 反禁忌" // - 计划本身也作为 step 落表,运维可以审计 // // prompt 策略: // - 单独一次 LLM 调用,只产出 plan,不让它真的执行 // - 用低温度(0.15)+ 强约束 prompt,避免计划发散 func (s *EnhancerService) doPlanning( ctx context.Context, req *ReactLoopRequest, reactCfg agentcfg.ReActConfig, budget *agent.TokenBudget, messages *[]types.Message, ) EnhanceStep { step := EnhanceStep{ StepType: "plan", StartedAt: time.Now().Unix(), } start := time.Now() // 构造规划 prompt:复用业务 system + 追加"请先输出计划"指令 // // 【prompt 锚定】明确提示"这是医疗任务",避免轻量模型(spark-lite 等) // 把"执行计划"模式匹配成"软件项目管理计划"套话(已实际发生过的跑题案例) planPrompt := "【Planning 阶段】你正在处理一个中医/医疗任务(见上面的 system 与用户消息)。" + "请基于该医疗任务输出一个简短的执行计划(不超过 5 步,每步一句话," + "说明你打算查什么医学知识/做什么医疗判断,例如:先辨证、查配伍禁忌、再组方)。" + "计划必须紧扣上面的医疗任务本身,不要输出通用的项目管理步骤。" + "不要执行任务,只输出计划。" tryMessages := append([]types.Message{}, *messages...) tryMessages = append(tryMessages, types.Message{ Role: "user", Content: planPrompt, Timestamp: time.Now().Unix(), }) opts := llm.ChatOpts{ MaxTokens: budget.CalcMaxTokensForCall(512), // 计划阶段最多 512 token Temperature: 0.15, // 低温保证稳定 } msg, chatResult := s.callLLMWithMeta(ctx, req.Client, tryMessages, nil, opts) step.DurationMs = int(time.Since(start).Milliseconds()) step.FinishedAt = time.Now().Unix() step.Provider = req.Provider step.Model = req.Client.Name() if msg == nil || msg.Content == "" { step.Status = 2 step.Detail = "Planning 调用失败" if chatResult != nil { budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens) step.PromptTokens = chatResult.PromptTokens step.CompletionTokens = chatResult.CompletionTokens step.TotalTokens = chatResult.TotalTokens } return step } // 累加预算 if chatResult != nil { budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens) step.PromptTokens = chatResult.PromptTokens step.CompletionTokens = chatResult.CompletionTokens step.TotalTokens = chatResult.TotalTokens step.Usage = chatResult.Usage } // ===== 计划跑题检测:只有命中医疗白名单的计划才允许注入 ===== // // 为什么用白名单制(而不是"没命中黑名单就放行"): // 轻量模型可能输出既无医疗词也无黑名单词的泛泛计划(如"1.收集信息 2.分析 3.输出"), // 这种计划注入后同样会稀释医疗任务焦点、污染后续生成 // (实际案例:plan 跑题成项目管理 → 最终处方输出也变成了项目管理步骤)。 // 丢弃计划无副作用:step 记录保留(status=2 便于后台审计),主循环按无计划模式继续。 planGuard := validateMedicalRelevance([]types.Message{{Content: msg.Content}}) if len(planGuard.HitWhitelist) == 0 { log.Printf("[ReactLoop] ⚠️ scene=%s Planning 输出未命中任何医疗关键词已丢弃(命中非医疗词=%v),主循环按无计划模式继续", req.Scene, planGuard.HitBlacklist) step.Status = 2 step.Detail = "计划跑题已丢弃: " + truncateForLog(msg.Content, 200) return step } // ===== 把计划追加到对话历史,让后续主循环能参考 ===== // // 【关键约定】messages 序列最后必须是 user 角色才能让下一次 LLM 调用生效。 // 之前直接把 plan 当 assistant 帧追加,导致序列以 assistant 结尾, // 触发讯飞 Lite "10003 用户的消息格式有错误"(Pro/Max 宽松,Lite 严格)。 // // 正确做法: // 1) 先把 plan 当 user 帧(标明是"计划"内容)追加 → 让 LLM 知道计划是什么 // 2) 再追加一个 user 帧作为"执行指令" → 保证最后一条是 user *messages = append(*messages, types.Message{ Role: "user", Content: "[Planning 阶段输出的计划]\n" + msg.Content, Timestamp: time.Now().Unix(), }) *messages = append(*messages, types.Message{ Role: "user", Content: "【执行阶段】请严格按照上述计划完成最初的医疗任务,并按 system 要求的格式输出最终结果。", Timestamp: time.Now().Unix(), }) step.Status = 1 step.Detail = "计划: " + truncateForLog(msg.Content, 200) return step } // ------------------------------------------------------------------ // [2] 单次 LLM 调用(带预算与 step 记录) // ------------------------------------------------------------------ // callLLMOnce 调用一次 LLM,包装成 EnhanceStep(含 token 统计) // // 调用方:runReactLoop 主循环 func (s *EnhancerService) callLLMOnce( ctx context.Context, req *ReactLoopRequest, opts llm.ChatOpts, messages []types.Message, stepType string, ) EnhanceStep { step := EnhanceStep{ StepType: stepType, StartedAt: time.Now().Unix(), } start := time.Now() msg, chatResult := s.callLLMWithMeta(ctx, req.Client, messages, req.Tools, opts) step.DurationMs = int(time.Since(start).Milliseconds()) step.FinishedAt = time.Now().Unix() step.Provider = req.Provider step.Model = req.Client.Name() step.lastMessage = msg if chatResult != nil { step.PromptTokens = chatResult.PromptTokens step.CompletionTokens = chatResult.CompletionTokens step.TotalTokens = chatResult.TotalTokens step.Usage = chatResult.Usage step.APIKeyID = chatResult.APIKeyID } if msg == nil { step.Status = 2 step.Detail = "LLM 返回空" return step } step.Status = 1 if msg.ToolCall != nil { step.Detail = fmt.Sprintf("触发工具调用: %s", msg.ToolCall.ToolName) } else { step.Detail = "成功 | finish_reason=" + chatResult.FinishReason } return step } // callLLMWithMeta 调用 LLM 并提取 token 元数据(统一入口) // // 策略: // 1. 若客户端实现 OptAwareClient(DeepSeek/Spark/OpenAI)→ 用 ChatWithOpts 透传参数 // 2. 否则回落到 LLMClient.Chat // 3. 若客户端实现 TokenAwareClient → 取 LastChatResult 拿 token/finish_reason func (s *EnhancerService) callLLMWithMeta( ctx context.Context, client llm.LLMClient, messages []types.Message, tools []types.Tool, opts llm.ChatOpts, ) (*types.Message, *types.ChatResult) { var ( msg *types.Message err error ) // 单次调用的闭包(优先用 ChatWithOpts) doCall := func() (*types.Message, error) { if optClient, ok := client.(llm.OptAwareClient); ok { return optClient.ChatWithOpts(ctx, messages, tools, opts) } return client.Chat(ctx, messages, tools) } msg, err = doCall() // ---------- 稳定性加固:网络类错误自动重试 1 次 ---------- // 只重试瞬时网络抖动(连接失败/超时/reset),不重试业务错误(4xx/5xx API 返回): // - 业务错误重试大概率还是失败,白白双倍消耗 token 配额 // - 网络抖动重试一次通常就能恢复,显著提升单点抖动下的成功率 // 间隔 1s:给对端 LB/连接池一个恢复窗口,也避免瞬间重试风暴 if err != nil && isRetryableNetworkError(err) && ctx.Err() == nil { log.Printf("[Enhancer] LLM 网络类错误,1s 后自动重试 1 次: %v", err) select { case <-time.After(1 * time.Second): msg, err = doCall() case <-ctx.Done(): // 上游已取消(如客户端断开),不再重试 } } if err != nil { log.Printf("[Enhancer] LLM 调用失败: %v", err) return nil, nil } // 尝试取 token 用量(DeepSeek/Spark 实现 TokenAwareClient) var chatResult *types.ChatResult if tac, ok := client.(llm.TokenAwareClient); ok { chatResult = tac.LastChatResult() } if chatResult == nil { chatResult = &types.ChatResult{} } return msg, chatResult } // isRetryableNetworkError 判断 LLM 调用错误是否属于「可重试的网络类错误」 // // 判定思路:LLM 客户端返回的错误是 fmt.Errorf 包装的字符串, // 无法用 errors.Is 精确匹配,只能按错误文案特征识别。 // 白名单只收纯网络故障(连接/超时/reset),凡是「API 返回 xxx」 // 这种服务端已给出响应的业务错误一律不重试,避免双倍烧 token。 func isRetryableNetworkError(err error) bool { if err == nil { return false } s := strings.ToLower(err.Error()) // 服务端已返回明确响应(业务错误),不属于网络抖动 if strings.Contains(s, "api 返回") { return false } // 常见网络故障特征(Go net/http 错误文案) networkHints := []string{ "timeout", "deadline exceeded", // 超时 "connection refused", "connection reset", // 连接被拒/被重置 "broken pipe", "unexpected eof", "eof", // 传输中断 "no such host", "dial tcp", // DNS/建连失败 "tls handshake", // TLS 握手失败 "请求失败", // 客户端封装的通用请求失败前缀 } for _, hint := range networkHints { if strings.Contains(s, hint) { return true } } return false } // ------------------------------------------------------------------ // [3] Reflection 阶段 // ------------------------------------------------------------------ // doReflection 让模型自检生成结果是否合格 // // prompt 策略: // - 用更低温度(reactCfg.ReflectionTemperature,默认 0.2) // - 强约束输出:要求模型回答"合格"或"不合格 + 原因" // - 用预算的剩余部分作为 max_tokens,避免反思膨胀 // // 返回: // - EnhanceStep:反思过程记录 // - bool:是否合格(true=通过,false=不通过,调用方可决定是否重试) func (s *EnhancerService) doReflection( ctx context.Context, req *ReactLoopRequest, reactCfg agentcfg.ReActConfig, budgetCfg agentcfg.TokenBudgetConfig, budget *agent.TokenBudget, generatedContent string, ) (EnhanceStep, bool) { step := EnhanceStep{ StepType: "reflection", StartedAt: time.Now().Unix(), } start := time.Now() // 构造反思 prompt:让模型扮演"审核者"角色 reflectPrompt := fmt.Sprintf( "【Reflection 反思阶段】请对以下 AI 生成内容进行严格审核:\n\n"+ "---\n%s\n---\n\n"+ "审核标准:\n"+ "1. 是否有医学常识性错误(剂量、配伍禁忌、诊断矛盾)\n"+ "2. 是否漏掉关键字段(处方缺剂量/用法;病历缺主诉对应诊断)\n"+ "3. 是否符合中文医疗文案规范(无歧义、无营销用语)\n\n"+ "输出格式:第一行必须是「合格」或「不合格」;如果不合格,第二行起说明原因。\n"+ "不要重新生成内容,只做审核。", generatedContent, ) // 反思用独立 messages,避免污染主对话 reflectMessages := []types.Message{ { Role: "system", Content: "你是一名严谨的中医临床审核专家。", Timestamp: time.Now().Unix(), }, { Role: "user", Content: reflectPrompt, Timestamp: time.Now().Unix(), }, } temp := reactCfg.ReflectionTemperature if temp <= 0 { temp = 0.2 } maxTokens := budget.CalcMaxTokensForCall(1024) // 反思最多 1024 token opts := llm.ChatOpts{ MaxTokens: maxTokens, Temperature: temp, } msg, chatResult := s.callLLMWithMeta(ctx, req.Client, reflectMessages, nil, opts) step.DurationMs = int(time.Since(start).Milliseconds()) step.FinishedAt = time.Now().Unix() step.Provider = req.Provider step.Model = req.Client.Name() if chatResult != nil { budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens) step.PromptTokens = chatResult.PromptTokens step.CompletionTokens = chatResult.CompletionTokens step.TotalTokens = chatResult.TotalTokens step.Usage = chatResult.Usage } if msg == nil { step.Status = 2 step.Detail = "反思调用失败(不阻断,按合格处理)" return step, true } // 解析"合格/不合格" content := strings.TrimSpace(msg.Content) firstLine := strings.ToLower(strings.SplitN(content, "\n", 2)[0]) passed := true if strings.Contains(firstLine, "不合格") { passed = false } step.Status = 1 if passed { step.Detail = "审核通过" } else { step.Detail = "审核不通过: " + truncateForLog(content, 300) } return step, passed } // ------------------------------------------------------------------ // [4] JSON 修复阶段 // ------------------------------------------------------------------ // repairJSONIfNeeded 检查内容是否为合法 JSON,不合法时让模型修复 // // 返回: // - string:修复后的内容(无需修复或修复失败时返回空字符串) // - *EnhanceStep:修复步骤(无需修复时返回 nil) func (s *EnhancerService) repairJSONIfNeeded( ctx context.Context, req *ReactLoopRequest, reactCfg agentcfg.ReActConfig, budget *agent.TokenBudget, content string, ) (string, *EnhanceStep) { trimmed := strings.TrimSpace(content) // 已经是合法 JSON:不修复 if json.Valid([]byte(trimmed)) { return "", nil } // 不是 JSON 但不是预期的 JSON 场景(内容不以 { 或 [ 开头):也不修复 // 避免对纯文本响应(如知识问答)误触发修复 if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") { return "", nil } maxRetries := reactCfg.JSONRepairMaxRetries if maxRetries < 1 { maxRetries = 2 } var lastRepaired string var lastStep *EnhanceStep for retry := 0; retry < maxRetries; retry++ { if budget.IsExceeded() { break } repairStep, repaired := s.doJSONRepairOnce(ctx, req, budget, content, retry) lastStep = repairStep if repaired != "" && json.Valid([]byte(repaired)) { lastRepaired = repaired break // 修复成功 } // 用修复后的内容(即使还不合法)作为下次输入 if repaired != "" { content = repaired } } return lastRepaired, lastStep } // doJSONRepairOnce 一次 JSON 修复尝试 func (s *EnhancerService) doJSONRepairOnce( ctx context.Context, req *ReactLoopRequest, budget *agent.TokenBudget, brokenContent string, retry int, ) (*EnhanceStep, string) { step := EnhanceStep{ StepType: "json_repair", StartedAt: time.Now().Unix(), } start := time.Now() repairPrompt := fmt.Sprintf( "以下内容应当是合法 JSON 但解析失败,请修复并只输出修复后的 JSON(不要解释、不要 markdown):\n\n---\n%s\n---", brokenContent, ) messages := []types.Message{ { Role: "system", Content: "你是一个 JSON 修复工具,只输出合法的 JSON 内容,不要任何其他文字。", Timestamp: time.Now().Unix(), }, { Role: "user", Content: repairPrompt, Timestamp: time.Now().Unix(), }, } opts := llm.ChatOpts{ MaxTokens: budget.CalcMaxTokensForCall(2048), Temperature: 0.1, // 极低温度保证修复稳定 } msg, chatResult := s.callLLMWithMeta(ctx, req.Client, messages, nil, opts) step.DurationMs = int(time.Since(start).Milliseconds()) step.FinishedAt = time.Now().Unix() step.Provider = req.Provider step.Model = req.Client.Name() step.Detail = fmt.Sprintf("第 %d 次修复尝试", retry+1) if chatResult != nil { budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens) step.PromptTokens = chatResult.PromptTokens step.CompletionTokens = chatResult.CompletionTokens step.TotalTokens = chatResult.TotalTokens step.Usage = chatResult.Usage } if msg == nil { step.Status = 2 step.Detail = "修复调用失败" return &step, "" } repaired := strings.TrimSpace(msg.Content) // 剥离可能的 markdown 代码块包裹 if strings.HasPrefix(repaired, "```json") { repaired = strings.TrimPrefix(repaired, "```json") repaired = strings.TrimSuffix(repaired, "```") repaired = strings.TrimSpace(repaired) } else if strings.HasPrefix(repaired, "```") { repaired = strings.TrimPrefix(repaired, "```") repaired = strings.TrimSuffix(repaired, "```") repaired = strings.TrimSpace(repaired) } if json.Valid([]byte(repaired)) { step.Status = 1 step.Detail = fmt.Sprintf("第 %d 次修复成功", retry+1) } else { step.Status = 2 step.Detail = fmt.Sprintf("第 %d 次修复仍不合法", retry+1) } return &step, repaired } // ------------------------------------------------------------------ // [5] 工具调用执行 // ------------------------------------------------------------------ // executeToolCall 执行一次工具调用并记录 step // // 注意:本服务目前没有直接持有 tool 注册表(工具注册在 agent.Runner 里), // 这里通过 s.toolRegistry 引用 runner 的工具集。如果 toolRegistry 为 nil, // 本方法返回"工具未注册"的失败 step。 func (s *EnhancerService) executeToolCall(ctx context.Context, call *types.ToolCallInfo) EnhanceStep { step := EnhanceStep{ StepType: "tool_call", StartedAt: time.Now().Unix(), } start := time.Now() step.Detail = fmt.Sprintf("调用工具: %s", call.ToolName) var result string var err error if s.toolRegistry != nil { if t, ok := s.toolRegistry[call.ToolName]; ok { result, err = t.Execute(ctx, call.Params) } else { err = fmt.Errorf("工具 %s 未注册", call.ToolName) } } else { err = fmt.Errorf("工具注册表为空") } step.DurationMs = int(time.Since(start).Milliseconds()) step.FinishedAt = time.Now().Unix() if err != nil { step.Status = 2 step.Detail = fmt.Sprintf("工具 %s 执行失败: %v", call.ToolName, err) // 失败时仍返回错误内容作为 Detail,让 LLM 下一轮知道工具挂了 } else { step.Status = 1 step.Detail = truncateForLog(result, 2000) // 工具结果可能很长,截断 } return step } // ------------------------------------------------------------------ // 工具方法 // ------------------------------------------------------------------ // truncateForLog 截断字符串用于日志(保留前 n 字符) func truncateForLog(s string, n int) string { if len(s) <= n { return s } return s[:n] + "...(截断)" }