给 Agent 加上防爆闸:Tool Calling 异常循环的防护设计
给 Agent 加上防爆闸Tool Calling 异常循环的防护设计把大模型接入业务系统做 Tool Calling工具调用时很多团队都会踩到一个坑——Agent 突然卡死在某一个工具调用上陷入无限死循环。其实模型并不是“故意不停”。常见的情况有三种一是工具返回的 JSON 格式不符合模型的预期模型误以为是自己参数填错了下一轮修改一下参数继续调二是系统本身已经执行成功了但响应在网络传输中丢了模型不知道已经成功盲目发起重试三是 Prompt 里根本没有写明白什么情况下应该结束任务。只靠设置“最大调用轮数Max Iterations”只能在最后强行关闭任务根本没法阻止中间已经发生的重复扣费、重复写数据库或者误发邮件这些严重后果。要解决这个问题必须用确定性的后端代码去约束不确定的模型输出。大模型 Tool Calling 死循环的工程根因要设计防爆机制先看看大模型在 Tool Calling 时发生死循环的三种常见情况flowchart TD A[LLM 发起 Tool Call 请求] -- B[后端执行器调用微服务 API] B -- C{API 返回结果形态} C --|类型 1: 格式不相符/未加结构化界定| D[模型误判为参数未提供 ➔ 再次产生相同调用] C --|类型 2: 网络超时但后端已成功| E[模型未感知幂等 ➔ 重复发起写操作调用] C --|类型 3: 缺少收敛判定条件| F[模型在相似工具间反复交替切换] D -- G[陷入无限循环死锁] E -- G F -- G响应格式不对导致模型一直在“猜参数”如果 API 返回了一大堆带有堆栈信息的错误文本或者不是标准的 JSONLLM 分析上下文时很容易误判觉得是自己上一次传入的参数不对。于是它会在下一轮稍微改改参数再试一次陷入“失败 ➔ 猜参数 ➔ 再次失败”的怪圈。网络超时引发的重复写入在微服务环境下网络超时经常发生在 API 已经处理完、正在回传响应的阶段。由于没有全局 Task ID 和幂等 Key 的限制当编排层把超时错误扔给模型时模型会直接重试导致原本不能重复执行的操作比如扣款、下单被执行了多次。目标不明确导致任务收敛不了如果 Prompt 给的目标太模糊比如“帮我分析并优化这份数据”又没有给明确的停止触发条件LLM 就会在好几个功能差不多的查询工具之间来回调用自己不知道什么时候该输出最终答案。把循环拆成可审计的状态机体系为了打破死循环服务端绝对不能直接拿大模型的输出去调工具。必须在编排层引入一个有限状态机FSM。在整个任务的生命周期里任务只能处于以下几种明确的状态而且每次状态变更都要记日志Planning大模型正在分析上下文生成下一步的行动计划。Validating防爆闸正在检查模型想调用的工具名称、参数格式、权限、预算和幂等 Key。Executing后端正在安全地执行这个工具。NeedsReview碰到了敏感操作或者规则校验异常暂停自动执行转给人工确认。Completed/Failed任务正常完成或者因为不可恢复的错误退出。stateDiagram-v2 [*] -- Planning Planning -- Validating: 模型返回 Tool Call 请求 state Validating { [*] -- CheckBudget CheckBudget -- CheckPermission: 预算正常 CheckPermission -- CheckIdempotency: 鉴权通过 CheckIdempotency -- ValidateSchema: 无幂等冲突 } Validating -- Executing: 防爆闸全量校验通过 Validating -- NeedsReview: 触发高风险操作或规则校验异常 Validating -- Failed: 超出硬预算 limit Executing -- Planning: 工具执行成功结果写入 Context NeedsReview -- Executing: 人工批准执行 NeedsReview -- Failed: 人工拒绝或超时 Planning -- Completed: 模型产出最终 Answer Completed -- [*] Failed -- [*]有了状态机之后每次工具调用就不再是随意的 Prompt 拼接而是变成可以监控、审计和人工接管的确定性流程。确定性防护防线硬限制、语义幂等 Key 与业务审批隔离防爆闸的设计应该采用三层拦截机制第一层全局硬配额限制这是保底的熔断机制。对单个 Task 必须在服务端强制配置以下阈值最大工具调用轮数比如最多 15 轮单 Task 最大 Token 消耗上限任务总超时时间比如最多 180 秒单个工具连续重试次数同一个工具不能连续尝试超过 3 次。只要触发了任何一条硬限制状态机立刻终止任务切到Failed或者NeedsReview状态。第二层基于参数 Hash 的语义幂等 Key为了防止模型连续发起完全相同的无效调用服务端必须对模型给出的工具参数做标准化处理把 JSON 参数的 Key 按字母顺序升序排列过滤掉无意义的空格、换行符和默认空值用TaskID ToolName NormalizedArgsHash拼成全局唯一的幂等 Key。如果服务端检查发现这个幂等 Key 在当前任务里已经成功执行过了拦截器就会直接拦截这次调用把上一次执行的结果直接填进 Context并提示模型“该操作已经完成请直接读取已有结果进行下一步。”第三层高风险写操作的人工审批对于扣款、删除数据、修改权限这些不可逆的操作防爆闸会把对应的工具标记为RequiresApproval。不管模型的推理看起来多么合理都必须把任务挂起到NeedsReview状态发送通知给人工审批界面拿到明确的批准信号后才由后端继续执行。生产级 Go 语言 Agent Guardrail 防爆中间件实现下面是一段集成了预算检查、JSON 参数 Hash 计算、幂等拦截和审批控制的 Go 代码实现package guardrail import ( crypto/sha256 encoding/hex encoding/json errors fmt sort sync time ) var ( ErrBudgetExceeded errors.New(task budget or max iterations exceeded) ErrDuplicateCall errors.New(duplicate tool call with identical arguments) ErrApprovalRequired errors.New(action requires manual human approval) ) type ToolCall struct { ID string json:id Name string json:name Arguments map[string]interface{} json:arguments } type TaskContext struct { TaskID string ToolCalls int MaxToolCalls int Deadline time.Time Approved map[string]bool mu sync.RWMutex } type Guardrail struct { sensitiveTools map[string]bool completedKeys map[string]string mu sync.Mutex } func NewGuardrail(sensitiveTools []string) *Guardrail { st : make(map[string]bool) for _, tool : range sensitiveTools { st[tool] true } return Guardrail{ sensitiveTools: st, completedKeys: make(map[string]string), } } func (g *Guardrail) Allow(ctx *TaskContext, call ToolCall) (string, error) { ctx.mu.Lock() defer ctx.mu.Unlock() // 1. 硬限制配额检查 ctx.ToolCalls if ctx.ToolCalls ctx.MaxToolCalls || time.Now().After(ctx.Deadline) { return , ErrBudgetExceeded } // 2. 参数标准化并生成 Hash argHash, err : g.computeNormalizedHash(call.Name, call.Arguments) if err ! nil { return , fmt.Errorf(failed to normalize arguments: %w, err) } idempotencyKey : fmt.Sprintf(%s:%s:%s, ctx.TaskID, call.Name, argHash) // 3. 幂等性检查 g.mu.Lock() cachedResult, exists : g.completedKeys[idempotencyKey] g.mu.Unlock() if exists { // 返回上一次的结果阻止重复调用 return cachedResult, ErrDuplicateCall } // 4. 敏感写操作人工审批检查 if g.sensitiveTools[call.Name] { if !ctx.Approved[call.ID] { return , ErrApprovalRequired } } return idempotencyKey, nil } func (g *Guardrail) RecordSuccess(idempotencyKey string, resultStr string) { g.mu.Lock() defer g.mu.Unlock() g.completedKeys[idempotencyKey] resultStr } func (g *Guardrail) computeNormalizedHash(toolName string, args map[string]interface{}) (string, error) { keys : make([]string, 0, len(args)) for k : range args { keys append(keys, k) } sort.Strings(keys) sortedMap : make([]string, 0, len(args)) for _, k : range keys { valBytes, err : json.Marshal(args[k]) if err ! nil { return , err } sortedMap append(sortedMap, fmt.Sprintf(%q:%s, k, string(valBytes))) } rawPayload : fmt.Sprintf(%s|{%s}, toolName, joinStrings(sortedMap, ,)) hash : sha256.Sum256([]byte(rawPayload)) return hex.EncodeToString(hash[:]), nil } func joinStrings(elems []string, sep string) string { if len(elems) 0 { return } res : elems[0] for _, s : range elems[1:] { res sep s } return res }故障注入测试与实际权衡故障注入自动化测试防爆系统不能只用正常流程做测试必须做针对编排层的故障注入测试模拟 API 返回损坏的 JSON让测试 API 返回格式错误的文本验证防爆闸能不能在达到次数限制前正常拦截而不是直接崩溃。模拟丢包与延迟在测试 API 里注入超时模拟“后端写成功了但响应丢了”的场景看看幂等 Key 机制能不能挡住二次重复写入。模拟高自信度的违规调用构造带有误导性的 Prompt 诱导模型调用敏感工具验证审批拦截是否有效。权衡分析设计 Agent 防控时要找准安全和灵活之间的平衡点评估维度完全放任 (无 Guardrail)适度工程防线 (推荐)过度保守安全性与控费很差极易死循环拉爆账单或者误删数据。好硬限制配幂等 Key 能挡住绝大多数异常。很好但复杂任务很容易被打断。复杂任务成功率表面看起来高实际上很多靠运气。高能提供明确反馈引导模型收敛。低正常的长链任务容易被误杀。人工介入成本事后救火成本极高。低只有敏感写操作需要点一下确认。极高每一步都要确认失去了自动化的意义。总结大模型的优势在于灵活的推理能力但后端系统要求的是确定性的安全与稳定。靠修改 Prompt 很难彻底避免死循环。正确的做法是把推理和执行分开大模型只负责给出下一步想做什么至于这一步能不能做由后端的防爆闸中间件说了算。参考资料OWASP Top 10 for LLM ApplicationsNIST AI Risk Management FrameworkBuilding Effective Agents - Anthropic