上一篇文章里,我们用检查机制验证了 Agent 的执行轨迹是否符合预期。然而剥离了框架的黑盒后,一个更棘手的事实浮现出来:即便天气查询、酒店搜索、订房这三步轨迹全部通过检查,终答也信誓旦旦地宣称”预订成功”,底层依然可能藏着两类隐患——其一是大模型陷入幻觉,带着同一组参数连续三次调用完全相同的工具,即所谓的”打转”;其二是尚未拿到用户授权,就擅自执行了敏感的写操作。单纯的轨迹检查,兜不住这两类异常。
为了解决这些痛点,本篇继续坚持”手搓”路线:在保留原有的核心调度循环(Loop)、状态记忆、计划与检查机制不动的前提下,对底层做一次深度加固。测试目标照旧——西安 10 月 1 日住两晚,下雨订 suite,否则订 standard。这次的核心改造只有两处:其一,在路由分发(dispatch)之前硬编码一道”护栏(Guardrail)“;其二,把调度循环的出口与累计 Token 的消耗量强绑定。整个过程不依赖对大模型本身做任何微调。
在深入架构细节之前,先把下文频繁出现的核心术语严格打平,避免概念混淆。全文统一采用以下语义:
| 术语 | 架构语义 |
|---|---|
| 误差累积 | 随着业务工具调用步数增加,任务最终成功率按各步成功率连乘衰减。 |
| 打转(Spin) | 大模型陷入死循环,连续两次调用相同业务工具,且透传参数完全一致。 |
| 重试(Retry) | 针对同一工具,大模型在调整参数后重新发起调用。 |
| 护栏(Guardrail) | 在 dispatch 路由分发之前拦截 tool_calls,校验不通过则直接阻断执行。 |
| 确认(Confirm) | 接收到一条新的 User 消息,明确授权允许执行当前的写操作。 |
| 续跑(Resume) | 携带已有的 messages 上下文状态机重新进入调度循环,而非新开一次会话。 |
| 累计 Token | 从第 1 轮交互到当前轮次,每次 API 请求消耗的 Token 总和。 |
| 出口(Exit) | 触发调度循环终止的条件,包括:无新的 tool_calls、达到轮数上限,或累计 Token 超出阈值。 |
run | 一次完整的任务执行周期,从进入调度循环开始,到触发出口结束。 |
需要说明的是,前文提到的检查、终答、回灌、配对以及 hotel_id 白名单等机制,底层流转逻辑与之前完全一致。白名单的职责是在执行阶段校验 ID 的合法来源,护栏的职责则是提前研判当前这次工具调用是否应该被放行——两者一前一后,互不替代。
核心原则:检查机制负责验证轨迹的正确性,护栏负责拦截不合法的执行动作,出口机制则决定调度循环何时终止。
Table of contents
Open Table of contents
一、步数在连乘,打转在涨轮数
剖析护栏设计之前,必须先正视大模型在多步推理中的误差累积问题。以订房这条轨迹为例,要顺利通过检查,Agent 至少需要调用三个业务工具:天气、搜索、订房。假设单步调用成功率高达 95%,三步连乘后整体成功率降至约 86%;若业务链路被拆成十步,成功率锐减至约 60%;拆成二十步,则仅剩 36%。
| 业务工具步数 | 连乘成功率(单步 0.95) |
|---|---|
| 1 | 95% |
| 3 | 86% |
| 5 | 77% |
| 10 | 60% |
| 20 | 36% |
从工程实战看,少走一步,连乘的损耗就能砍掉一大截。因此,对于那些仅供大模型内部推理使用、用户无需感知的中间结果,应该在底层把工具合并,避免把链路拆得过细。需要强调的是,护栏并不负责优化或减少调用步骤,它的唯一职责是精准拦截那些不该被执行的异常调用——这是两件事,不要混为一谈。
相比于正常的步骤消耗,“打转”是另一种极其耗资源的异常状态。比如大模型可能带着同一组参数连续三次调用 get_weather。此时跳步、参数来源、房型等常规检查都能顺利通过——它既没跳过必要步骤,也没伪造 ID,更没执行非法预订。可问题是,天气状态毫无变化,调度循环的轮数却在无意义地空转飙升。
二、护栏在 dispatch 之前
在状态机流转中,出口机制负责管控循环终止:没有新的 tool_calls 时输出终答,达到轮数上限或累计 Token 超标时强制停机。而护栏的拦截时机更靠前——当当前轮次已经解析出 tool_calls 时,护栏率先介入,决定这些调用是否分发执行。
实际”手搓”开发中,只需要手搓出两条核心护栏规则,就能兜底大部分异常:
| 护栏规则 | 拦截后的兜底策略 |
|---|---|
| 拦截打转 | 拒绝执行并终止当前调用。若再次强行调用,依然回灌相同的错误信息。 |
| 拦截未确认的写操作 | 拒绝执行 book_hotel。必须严格按 tool_call_id 回灌,否则下一轮请求会因上下文配对失败直接报 400 错误。 |
针对打转,通过简单的状态比对即可拦截:
def is_spin(calls):
biz = [c for c in calls if c.name in BUSINESS_TOOLS]
if len(biz) < 2:
return False
a, b = biz[-2], biz[-1]
return a.name == b.name and a.args == b.args
需要注意的是,若大模型在重试时修改了参数,这属于正常的纠错行为,而非打转。比如查询日期从 10-01 变更为 10-02,护栏应当放行。只有工具名称与参数完全一致、且连续发生两次时,护栏才判定为打转并强制终止。此时切忌反复回灌同一份 Error 信息——否则大模型会在错误的上下文里继续死循环。
对于敏感的写操作,必须引入人工确认机制。执行预订前,Agent 需先输出终答:“即将预订钟楼索菲特 suite,确认吗?“此时调度循环被挂起,当前的 messages 上下文状态完整保留,等待用户输入。接收到用户的”确认”指令后,系统必须携带同一份 messages 状态机重新进入循环,继续执行 book_hotel。这里最容易踩坑:千万不要新开一次 run,否则记忆中的 hotel_ids 与累计 Token 都会丢失,用户的确认指令也无法与当前的预订操作正确匹配。
若大模型在未获授权的情况下擅自发起调用,护栏必须坚决拦截,并向其回灌错误状态:
if name == "book_hotel" and not confirmed:
return {"error": "写操作未确认,不执行 book_hotel"}
这句回灌的核心目的,仅仅是为了维持上下文的 tool_call_id 配对,而非引导大模型更换 hotel_id 重新尝试。只要用户尚未确认,护栏就会持续拦截。
当用户最终确认后,原有的 hotel_id 白名单机制依然生效:传入的 ID 必须来源于本次 search_hotels 的回灌数据。用户的确认只代表”授权执行预订”,绝不意味着大模型可以”随意伪造一个 ID 来兜底”。至于如何基于 run_id 持久化 messages 状态,以及在用户回复后如何实现并发调度与续跑,留到下一篇文章深入探讨。
工程实现上还有一处细节:当护栏触发拦截时,千万不要直接抛异常(raise)。一旦丢失一条 tool 消息,上下文的配对链条就会断裂,后续请求会直接返回 400 错误。
三、累计 Token 按每轮重发加总
为了解决上下文膨胀,必须先吃透 Token 消耗的底层逻辑。裸调 API 时,messages 数组只增不减,每一轮交互都要把完整的历史上下文重新发给大模型。假设第 1 轮发送的 Token 量为基数,那么第 k 轮的发送量大约是第 1 轮的 k 倍;从第 1 轮累加到第 k 轮,总消耗量约为 1+2+…+k,呈现 k²/2 的平方级增长。换句话说,轮数是线性增加的,累计 Token 的消耗却是指数级飙升。
对照测试中,每增加一轮交互,上下文里就多出一对 Assistant / Tool 消息记录。本地调试时,可以用字符数除以 4 粗略估算 Token 消耗;但生产环境必须严格依赖 API 返回的 usage.total_tokens 进行累加。
| 轮次 | 本轮发送量 | 累计消耗量 |
|---|---|---|
| 1 | System Prompt 和 User 消息 | 极小 |
| 2 | 增加第一对工具调用的上下文 | 开始偏离”轮数 × 单轮”的线性增长 |
| 6 | 前五对历史记录全部重发 | 飙升至第 1 轮的几十倍,远非 6 倍 |
设计出口机制时,必须基于累计 Token 量做熔断。即使单轮请求的 Token 量并未超标,累加起来的总量也极有可能击穿阈值。一旦超标,立即终止循环——此时不要再指望大模型”再压缩一次上下文”。真正的上下文截断或压缩应该发生在每次请求发起之前:比如把旧的 tool 内容替换为精简摘要,或提前裁掉返回结果中的冗余字段。这类压缩手段只能减小每一轮发送的增量,若增量依然存在,总消耗量仍会按平方级堆积。只有从根本上减少调用步骤、杜绝打转,才能真正把轮数和 Token 消耗压下来。
打转最致命的危害,正是提前引爆这颗平方级增长的炸弹:同一对 Assistant / Tool 的冗余记录在上下文里越积越长,且每轮都被全量重发。因此护栏必须作为第一道防线提前介入,而基于累计 Token 的出口机制则是最后一道兜底防线。
四、三条对照轨迹
为验证上述架构设计的有效性,我们用相同的测试目标跑了三条对照轨迹。需要再次强调:护栏只负责判定是否放行执行动作,并不关心最终回答的内容。
| 对照轨迹 | 轨迹流转状态 | 护栏判定结果 |
|---|---|---|
ok | 天气 → 搜索 → 订房,且已获用户确认 | 校验通过,放行 |
spin | 携带同一组参数连续三次调用 get_weather | 判定为打转,强制终止 |
unconfirmed | 调用顺序正确,但未获确认就直接调用 book_hotel | 判定为未授权,拒绝执行 |
在 spin 轨迹中,大模型的终答可能依然正确输出”西安 10 月 1 日下雨”。从答案层面和跳步检查层面看,它都顺利过关,但护栏依然会无情地将其拦截。而在 unconfirmed 轨迹中,若放任其执行,它甚至能通过轨迹检查(顺序正确且 ID 合法),唯一缺陷就是缺失了用户确认环节。这再次印证了:护栏的核心价值,在于拦截非法的执行动作。
五、对不上时,先看是检查、护栏还是出口
脱离框架”手搓” Agent 时,遇到非预期行为,一线开发者应当遵循以下排查链路:
| 异常现象 | 架构层面的排查与修复策略 |
|---|---|
| 检查通过,但同一工具同一参数被反复调用 | 触发护栏:判定为打转,直接终止循环。 |
| 检查通过,但未获确认就执行了写操作 | 触发护栏:拒绝执行,并强制要求终答询问确认。 |
| 调整参数后依然连续调用失败 | 属于重试而非打转;若确实无法完成,则通过 mark_step(failed) 标记状态。 |
| 循环轮数不多,但累计 Token 已经飙升 | 实施上下文压缩:裁剪冗余字段,减小每一轮的发送增量。 |
| 单轮 Token 未超标,但总消耗量超标 | 触发出口:基于累计 Token 强制终止循环。 |
| 护栏成功拦截,但下一轮请求报 400 错误 | 拦截后缺失回灌动作,导致上下文配对断裂。必须确保拦截后依然回灌状态。 |
已获确认,但依然预订了非法的 HT-999 | 属于 hotel_id 白名单校验失败,而非护栏职责。 |
| 用户回复了”确认”,却触发了全新的预订流程 | 状态机割裂:必须携带同一份 messages 续跑,严禁新开 run。 |
最棘手的问题在于,遇到打转现象时,很多开发者会试图通过简单粗暴地增加 max_turns 来掩盖问题;同样,发生未确认就预订的异常时,也不要试图通过修改终答的 Prompt 措辞来解决。必须从底层的护栏与状态机流转入手。
完整代码
为便于理解底层流转逻辑,本次提供的完整代码并未接入真实的调度循环与大模型 API。源码已落盘至 agent-in-action/part1-handmade-agent/07-agent-reliability-cost/cost.py。执行 python cost.py,可直观打印出三张核心数据表:误差连乘衰减表、累计 Token 消耗表,以及三条对照轨迹的护栏判定结果。
运行测试时,可重点观察:ok 轨迹顺利放行;spin 轨迹被精准终止;unconfirmed 轨迹被拒绝执行。若手动将 spin 轨迹中后两次 get_weather 的日期参数改掉,护栏就会将其判定为合法的重试并予以放行。
集成到现有调度循环时,只需将护栏逻辑硬编码在 dispatch 路由分发的前一行即可。累计 Token 的统计则直接依赖 usage.total_tokens 进行累加,一旦超标即路由至统一的出口。当触发用户确认时,调度循环将被挂起,等待那条关键的 User 消息回传后再恢复流转。
接下来
在单次 run 的生命周期内,护栏与出口机制已经足以兜底大部分异常。然而在真实的生产环境中,我们还要面对更复杂的工程挑战:多用户并发调度、工具调用超时、写操作状态未知(不确定是否预订成功),以及用户确认后的上下文续跑。这些硬核的底层架构设计,留到下一篇《轮内并发、超时、续跑和审计》中深度拆解。
一句话总结
检查机制负责验证轨迹的正确性。护栏必须前置于
dispatch路由分发:精准终止打转,坚决拦截未确认的写操作,且拦截后必须回灌状态以维持上下文配对。误差会随着业务步数的增加呈连乘衰减,在底层合并步骤远比更换更强的大模型有效。累计 Token 的消耗按每轮全量重发加总,出口熔断必须基于累计量而非单轮消耗。