Files

780 lines
26 KiB
Go
Raw Permalink Normal View History

2026-08-14 21:50:48 +08:00
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 实际返回的 MessagecallLLMOnce 已通过 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. 若客户端实现 OptAwareClientDeepSeek/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] + "...(截断)"
}