跳到正文
Hacker News· joshcsimmons·· 6 天前AI 评分32

重放 AI 智能体不应调用模型:如何用录制回放复现 Agent 失败

Replaying an agent should not call the model

AI 导读

文章主张 Agent 的"重放"应严格限定为:用录制好的模型响应和工具结果重跑旧代码,一旦请求超出录制内容就报错停止,而不是悄悄调用真实模型。作者用一个 Python 示例演示:同一段有 bug 的发布门控代码在历史重放中复现了 CI 失败时的误发布,而在 CI 已通过的新环境中该 bug 被掩盖。

正文 · AI 翻译

想象一个发布代理,它在 CI 失败后提议发布。你保存对话,重新运行任务,然后看着它通过。下一次尝试看起来没问题。没有人改过代码。

方便的解释是模型抽了一下。第二次就成功了。发布吧。

但第二次运行可能从未遇到导致第一次失败的条件。CI 现在通过了。检索到的文档变了。模型选择了另一个工具。你得到了一次成功的新运行和一次无法解释的旧失败。

对于围绕模型复现代码,我希望重放意味着相当严格的东西。用记录下来的代码版本,针对它收到的观测(包括模型的响应)运行。如果程序请求了录制无法提供的东西,就停止。新的模型调用属于另一个实验。

“重放”已经描述了若干种保证不同的操作。查看保存的对话、恢复检查点、针对记录输入执行代码,回答的是不同的问题。一个同名按钮并不能让它们可以互换。

一个足够小、可以保留的 bug

这里有一个刻意构造的例子。一个代理向工具询问 main 上的 CI 状态。周围的代码决定是否提议发布。不涉及任何真实的模型、CI 服务或部署系统。

这个门控包含一个普通的 Python bug:

def buggy_gate(result):
    return "publish-intent" if result["status"] else "stop"

字符串 "failed" 为真。"passed" 也为真。两者产生相同的发布意图。

我们编写的录制包含一个模型响应,请求 get_ci(ref="main"),随后是一个工具结果,带有 status="failed"。另一个夹具模拟之后的世界,那时同一分支在不同提交上 CI 通过。旧代码在两种情况下都提议发布。只有第一种情况暴露了这个错误。

下载完整的 Python 示例。它使用标准库,不进行网络调用,也没有任何可以发布东西的执行器。保存它并运行 python3 replay_example.py。

它的前三个结果是:

historical replay: publish-intent on failed CI (bug reproduced)
changed world: publish-intent on passed CI (bug hidden)
code regression test: stop on failed CI (fix handles old input)

回归测试只把门控改为要求 result["status"] == "passed"。它保留旧的失败观测。这是针对旧输入的一次受控代码更改。历史重放也保留原来有 bug 的门控。

在这个例子中,程序收到一个编写的模型响应。冻结其响应让我们可以调查程序其余部分对该响应做了什么。我们对于真实模型请求这个工具的频繁程度,或者修改后的提示是否会改善其判断,一无所知。

输出应该告诉你测试能回答其中哪些问题。

请求也必须匹配

返回存储的响应是容易的部分。把它返回给正确的请求,才是绿色测试可能变得毫无意义的地方。

假设你更改提示以检查发布分支。你的测试拦截对同一模型端点的调用,并提供旧响应,它仍然请求 main。测试完成。你得出结论,新提示有效。新提示从未到达模型。

因此,该示例在提供每个记录响应之前检查下一个请求:

expected_kind, expected_request, response = self.events[self.cursor]
if (kind, request) != (expected_kind, expected_request):
    raise ReplayDivergence(f"request changed at event {self.cursor}")

它检查模型请求以及工具名称和参数。它按顺序消费事件,并拒绝不完整或已耗尽的事件记录。更改提示会在事件零处停止;更改工具参数会在事件一处停止。当夹具缺失时,没有悄悄调用实时服务的回退。

HTTP 测试工具已经让这一选择变得明确。VCR.py 的配置让请求匹配变得可配置。其文档中的默认值包括方法和 URL 组成部分,但省略了请求体。对于模型 API,请求体可能包含整个实验。因此,仅匹配端点就可能接受不同的提示词。其 none 录制模式会拒绝被拦截流量中的新请求。本地文件和其他未被捕获的 I/O 仍需要单独控制。

真实的录制需要一种深思熟虑的匹配策略。你可能想忽略传输请求 ID。你大概不想忽略已更改的系统提示词或工具参数。你丢弃的每个字段都变成了一种断言:它不可能影响你正在测试的行为。

未匹配的请求是有用的证据。它告诉你旧录制在何处不再描述新的执行。把这种不一致当作即兴生成响应的许可,会毁掉实验。

其他人已经解决了这个问题的部分内容

这是一个旧的调试问题,只是有了新的外部依赖。在 Martin Fowler 2005 年关于事件溯源的讨论中,他指出了问题的两个方面。历史查询需要历史答案。重放事件时还必须避免再次发送相同的外部更新。他提出的网关可以记住查询响应,并在重放期间抑制向外产生的影响。

2016 年的 rr 论文描述了在用户空间程序与内核边界处更严苛的实现。它记录输入和非确定性来源,然后在执行期间应用它们被记录的效果。它不需要文件系统自发地重现昨天。其保真度取决于其录制边界和支持的执行条件,包括其线程调度方法。

Agent fixture 的承诺要小得多。它可以在普通应用代码运行时,让跨越模型和工具接口的观察结果保持稳定。它不会仅仅因为有人在产品名称里放了“时间旅行”,就继承 rr 的指令级保证。

当前的编排系统让这些区别变得具体。Temporal 要求确定性的 Workflow 代码,并将模型调用和数据库查询等非确定性工作放在 Activity 中。在重放期间,Workflow 命令会与历史记录进行核对。成功的历史重放只能说明该工作流与已记录事件的兼容性;它不能证明模型今天会生成相同的响应。

LangGraph 的时间旅行文档描述了另一种操作。所选检查点之前的结果会被保存,而其后的节点会再次执行。这包括模型调用和 API 请求,它们可能返回不同结果。这对于尝试另一种延续很有用。除非你在这些调用周围添加控制,否则它不是上面那种冻结输入的实验。

在判断一次通过的运行证明了什么之前,先阅读系统再次执行了什么。

但模型才是 bug

这是最强烈的反对意见,而且它是对的。如果失败源于糟糕的模型选择,重放那个选择无法表明新模型会做得更好。你可以复现程序如何接受它、验证在哪里失败,或者随后采取了哪个动作。你无法通过替换旧推理来评估新推理。

对于提示词或模型的变更,保留旧前缀,标记实验发生变化的位置,并在那里调用候选模型。如果想隔离该变更,请保持相关工具环境受控。运行足够多的试验,以支撑你打算做出的可靠性声明。一次成功的续写只能告诉你那一次续写成功了。

对于代码变更,冻结的录制可以成为回归用例,正如示例中那样。对于已更改的数据库或服务,请明确选择是恢复旧观测还是测试当前行为。将这些结果分开保存。否则,代码修复、不同的模型回答以及修复后的外部依赖都可能以同一个“通过”标签出现。

无需为了修复一行条件而构建通用回放平台。一个捕获的响应和一个单元测试可能就足够了。当序列和周围状态足够重要、单个固定装置会丢失解释时,才使用录制。

HTTP 录制会遗漏本地文件读取。序列化的工具结果列表可能会遗漏并发任务更改共享状态的顺序。被脱敏的值可能会改变分支条件。如果问题是原始程序做了什么,请保留原始可执行文件和依赖版本,并记录录制器保持活动状态的一切。缺失的输入是对声明的限制,而不是无论如何都宣布运行具有确定性的借口。

保持回放与生产隔离,因为预期操作可能正是你正在复现的 bug。记录提议的写入,或将其路由到惰性替代品。通过发送另一封电子邮件来调试重复电子邮件,将是对复现的一种异常字面的解释。

我希望失败的 agent 运行能附带一个小型可执行案例:代码版本、它发出的请求、它收到的观测,以及一种在不接触真实账户的情况下运行它的方法。然后我可以更改一件事,看看会发生什么。

在庆祝成功重跑之前,先故意让旧的失败发生。

来源:Hacker News · drjoshcsimmons.com