Skip to content
Charles Shao
Go back

检查通过仍会打转:护栏拦截与累计 Token

–views

上一篇文章里,我们用检查机制验证了 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)
195%
386%
577%
1060%
2036%

从工程实战看,少走一步,连乘的损耗就能砍掉一大截。因此,对于那些仅供大模型内部推理使用、用户无需感知的中间结果,应该在底层把工具合并,避免把链路拆得过细。需要强调的是,护栏并不负责优化或减少调用步骤,它的唯一职责是精准拦截那些不该被执行的异常调用——这是两件事,不要混为一谈。

相比于正常的步骤消耗,“打转”是另一种极其耗资源的异常状态。比如大模型可能带着同一组参数连续三次调用 get_weather。此时跳步、参数来源、房型等常规检查都能顺利通过——它既没跳过必要步骤,也没伪造 ID,更没执行非法预订。可问题是,天气状态毫无变化,调度循环的轮数却在无意义地空转飙升。

检查看轨迹过不过;护栏在 dispatch 之前拦打转和未确认的写操作;出口停循环。累计 token 按每轮重发加总。

二、护栏在 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 进行累加。

轮次本轮发送量累计消耗量
1System 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 的消耗按每轮全量重发加总,出口熔断必须基于累计量而非单轮消耗。


–views
Share this post on:

Previous Post
上线实战:轮内并发、超时、续跑与审计
Next Post
评测一个 Agent:轨迹级校验,而非答案级