780 lines
26 KiB
Go
780 lines
26 KiB
Go
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] + "...(截断)"
|
||
}
|