loading

Loading

探索与发现

首页 Agent 工程

Agent 子目标检查点回归:从回滚状态到登记副作用

分类:Agent 工程
字数: (2950)
阅读: (0)
0
摘要:Agent 的子目标检查点能回滚本地状态,却回滚不了已提交的外部副作用。本文拆解恢复后重新合成工具调用导致的语义回滚,给出基于副作用指纹的检查点边界划分与确定性回归断言。

把 Agent 的 checkpoint/restore 做成产品功能之后,第一个撞上的坑不是状态恢复失败,而是恢复得太干净了。本地上下文回退到第三步,外部世界还停在第八步:邮件已经发出去、工单已经建好、支付接口已经扣款。Agent 重新跑一遍子目标,于是第二封邮件、第二张工单、第二笔扣款。

这类问题在调试环境里出现概率低,在生产环境出现概率高,因为只有生产环境的工具调用才有真实副作用。做子目标检查点回归时,需要把“回滚本地状态”和“外部副作用已提交”当成两件事分开测。

状态回滚与语义回滚不是一回事

云微在 ACRFence 里把这层区分讲得很清楚:传统检查点恢复只回滚本地状态,撤销不了外部世界已经发生的事。普通程序常用的解法是幂等键——重试带同一个 request id,服务端看到重复请求就返回旧结果。这个前提是请求内容逐字节可重现,而 LLM Agent 恰恰不满足。

恢复之后,模型重新合成一次工具调用,语义等价但字段不同:UUID 新生成、时间戳变成当下、引用号换了一种编法。服务端眼里这不是重试,是一次全新的合法请求。ACRFence 把这类场景归到 Action Replay,针对的是已经提交成功的不可逆工具调用。

幂等键假设请求可复现,LLM Agent 恢复后的重新合成打破了这个假设。这是子目标级检查点最需要单独覆盖的回归类型。

工程上的做法不是让模型学会复现,而是在检查点边界做“副作用登记”:每个会产生外部效果的工具调用,把调用指纹(工具名、参数规范化后的哈希、业务幂等键)写进检查点旁挂的日志。恢复时先查这份日志,命中就走缓存的返回值,而不是重新调用。这段逻辑不该放在 prompt 里,应该放在工具网关里。

子目标边界应该选在哪

bojieli 的 AI Agent 教材第七章提到一个容易被忽略的分层:端到端回归任务从初始状态跑到最终状态,最接近生产结果,但失败时难以定位是哪一步坏的;轨迹前缀回归任务把上下文、对话、工具返回、环境状态冻结,只让 Agent 执行下一步或下几步可观察动作,成本低,也能隔离单个策略或工具问题。对于需要高可靠性的生产 Agent,作者认为构建轨迹前缀回归集往往比端到端回归集更重要。

子目标检查点正好落在这两者之间。粒度选得太细,每一步都存检查点,存储和恢复成本上去,且大量检查点从未被用到;选得太粗,一次回滚吃掉五个子目标,等于重跑,外部副作用的风险窗口也就更大。

实践中一个可用的划分依据是“副作用边界”:把检查点放在一组只读操作之后、不可逆写操作之前。Coding Agent 里这可能是“读完仓库规则和跑完测试”之后、“开分支提交”之前。跑完 git commit 再回滚就是典型的危险位置,因为提交可以撤销但 PR 已经创建。

第七章还给了一个判断标准:轨迹前缀回归任务的答案应该定义为可接受动作集合,而不是唯一动作。检查点回归的断言也一样,重点应该问“恢复后有没有触碰危险工具”,不需要管“恢复后是不是走了黄金轨迹”。

回归用例怎么写才有信号

tianpan.co 那篇讲 Agent 评估的文章里有一个具体数字:一个 Agent 最终输出评估拿到 82 分,上线两周后用户投诉不断,回看 traces 发现 40% 的查询路由错误——最终答案却因为误打误撞而正确。放在子目标检查点上,这个结论更尖锐:语义回滚导致的重复副作用,最终答案可能非常合理,测试人员根本看不出来。

AgentBench 式的分层可以借用。L1 确定性断言每次 PR 跑:检查点恢复后,`tool("send_email").toBeCalledTimes(0)`(已登记过副作用的工具不允许重放);检查点文件是否存在;恢复耗时在区间内;token 增量没超预算;恢复后的上下文里工具返回值和外部日志一致。这些断言零 LLM 调用,成本接近零。

const restored = await replay.fromCheckpoint("ckpt-subgoal-3")
expect(restored)
  .tool("send_email").toBeCalledTimes(0)
  .tool("create_ticket").toBeCalledTimes(0)
  .tool("search_docs").toBeCalled()
  .latency().toBeBetween(800, 8000)
  .run()

L2 留给必须看语义的部分:恢复后重新合成的工具调用,其规范化指纹是否和登记记录等价;答案内容是否和已提交的外部事实矛盾。把规范化的责任压到工具网关,是对的方案,压给 judge 是错的,因为 fingerprint 等价是确定性问题,不需要 LLM。

同一个源还提到一条踩坑经验,放在这里也成立:对温度大于 0 的输出做快照断言必然 flaky,快照只适合做 replay 的输入侧。检查点文件本身就应该被当作严格的输入侧——它必须能逐字节复现,路径明确、绝对不含对话文本之外的自由文本拼接。

量化和成本账

pass@k 和 pass^k 的区分在这种回归里格外有意义。恢复路径本身带有重试性质,用 pass@k 看待表现会显得乐观;但在生产里每次用户交互只有一次机会,真正要看的是所有 k 次恢复尝试都没有触碰副作用——也就是 pass^k 口径。

检查点回归的成本主要在两块:生成时保存完整 Agent 状态(对话历史、工具返回、中间变量、副作用日志),回放时逐步重演并拿回观察。如果检查点存得太完整,恰好就是本地需要推演的状态集合之外的部分(比如文件系统、外部 API 的响应需要提前录制),这些跟“Agent 的状态”其实并非一回事,必须提前扣在一起。而且重放代码的维护成本跟着 Agent 的框架版本走,框架改协议以后很多旧检查点是回不过去的。这点容易被低估——检查点文件和测试用例不一样,后者是别的部门能写的文本,前者依赖执行环境。同一种框架下能够稳定回放的时间窗口并不长,如果要长期保存回归资产,回放的输入和字段应该尽量写成规范化的形式,而不是把整份运行时快照当作真相。

维度成本收益
检查点粒度到每个子目标存储放大、框架耦合加深回滚代价最低,窗口最窄
检查点放在写操作之前需要手工规划边界杜绝重复副作用,最值
副作用指纹登记工具网关多一层子目标级恢复的核心保障
全量端到端回归每次跑完整任务最接近生产,但定位能力差

容易被低估的一处细节

“恢复”其实包括两种动作:回到检查点后重新决策、或者从这个检查点分叉走另一条路径试结果。前者是一个稳健性问题,目标是同一个子目标不要被执行两遍;后者是一个采样问题,本来就要跑不同分支,此时外部副作用必须全部拦在分叉之外,或者只允许读操作。在产品里把这两者做成一个按钮会导致副作用在业务侧随随便便翻倍,所以要么修复路径只允许回滚只读区,要么回滚以后自动挂到沙箱或测试环境。

子目标检查点的核心并不在于能回退多少步,而在于明确用一份地方记录下的“这次副作用已经发生过”,以此定义哪些步骤在恢复以后绝对不允许再走一遍。这件事靠 prompt 里的提醒是防不住的。

转载请注明出处: 皮哈皮阿

本文的链接地址: https://blog.phpat.com/agent-engineering/621.html

手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

您可能对以下文章感兴趣
1 / 1
预览图