Verify Before Retry
现实问题
在之前我们用的是 LLM,是以 chat 的形式,它只需要对下一个 token 负责。但是现在它变成了 Agent,可以调用工具了,那么这里就会有很大的问题。
- 动作不可逆:在一些场景下,调用工具是不可逆的,比如说发出的消息、比如说支付,这些场景都是不可逆的。
- 动作有成本:每一次调用,它其实都会去占用大量的资源。
- 后果要负责:Agent 需要知道这一步会发生什么,需要知道我们这一步的动作到底是否成功,这样他才能够进行下一步。
这样就会带来一个问题:Agent 如何知道在调用一个工具之后,真实的世界会发生什么变化呢?
大语言模型,它其实看到的东西都是上下文,那么对于它来说,看到的就是真实的。我们将工具返回的结果叫做 ,那么这个 response 它返回的结果就是对于 Agent 来说这次工具调用的结果。
但是这里就会有一个问题,比如说工具执行超时了,但是服务器那面已经成功了;又或者是说,有一些并发的问题,你以为你写进去了,实际上它没写进去;再或者是说,他执行一半儿出现情况了。各种情况都会有。
上述问题其实代表了一个现象,就是工具的 response 并不能够代表工具真的对现实世界进行了操作。那么,我们把工具执行前的状态叫做 ,工具执行后的状态叫做 ,在实际效果中, 是不成立的。一旦这个东西不成立,就会出现一个非常严重的后果:工具返回的结果,并不能完全表示该工具是否真的在现实世界中正确发挥作用。
这里的话,论文其实也针对这些场景做了一些分类。
- 超时后执行:这就是我们上面说的例子:虽然工具超时了,但服务端已经处理过了。比如支付场景超时了,但服务端已经支付了。那这个时候就会发生什么呢?Agent 会以为没有成功,他会再支付一遍。
- 延迟可见:有时工具执行确实成功了(状态确实发生了变化),但是因为一些原因,Agent 他去读的时候读到的是旧的状态。这是因为写跟读是分开的,举一个例子,我向一个系统里去存一个数据,工具执行成功,它也确实存进去了,但是这里它会在系统里处理一下,会有一个时间,导致我们第一次读的时候,它其实会读到旧的状态。这个时候我们需要再等一段时间,让它再去读一下新的状态。这里会有一个延时的问题。
- 部分成功:比如说我们有一批 200 条数据,我想要全部都上传进去,但是它只上传了 100 条,返回了成功。
- 陈旧冲突:这就是并发的问题。比如说,我想要去编辑一个文件。在编辑之前,我需要先看这个文件长什么样子,对吧?我去看这个文件,等它读完之后去分析,就在这个分析的过程中,文件被更改了。这个时候它再去编辑的话,是基于它看到的那个时刻的文件状态去进行的。而它不知道的是,这个文件状态已经被修改掉了。这是一个非常非常常见的场景。
解决方法
好,现在我们来聊一下理论上的解决方案。其实应该能看出来,上面这些场景主要想要解决的是:当出现问题之后,Agent 需要重试,是吧?那么怎么让 Agent 判断出来它应不应该重试?这就相当于给 Agent 的重试行为加上一道锁。
这里要注意,验证轮次和重试次数是两个不同的计数器:验证是只读的,没有副作用,可以多查几轮(我们前面说的 ” 耐心值 ”);重试是重新执行动作,有副作用,必须严格限制——论文里每个逻辑操作只允许重试 1 次(MAX_TOOL_RETRIES_PER_CALL=1)。多花预算在 ” 查 ” 上,重试只给一次。
从工具层面来看,在这个动作发生之后,我们可以分成两个角色:Agent 是动作的发起者,服务端是动作的承受者。
对于动作的承受者(服务端)来说,当动作到来时,它需要进行判断:
- 该动作之前是否已经发生过(即这是一个重复动作);
- 或者该动作是否正在执行。
如果满足上述情况,说明这个动作之前已经处理过了,服务端不应该再执行一遍,而是应该告诉调用方 ” 已处理 “,并返回第一次执行的结果,以避免重复执行同一个动作(例如支付)。当然,服务端侧的这个去重和我们这次要关注的并没有什么太大关系,我们这次还是聚焦于工具调用这一层。
超时后执行
首先,我们根据场景来看,第一个是超时后执行,那么什么行为会导致超时呢?这个就比较多了吧,但主要还是集中于网络超时。
- 从服务端来看:服务端正在忙啊,处理慢啊,正在发生各种各样的情况。
- 从客户端来看:客户端超时设置得太短了,导致超时。
- 从传输来看:可能是网络卡、网慢。
举个例子,比如你在用 Docker,去 pull 一个镜像,但在国内环境下会很慢。如果你的超时设置是 10 秒,那肯定会超时的呀。
超时场景有一个很大的特点,就是它没有返回信息,直接超时了。它跟其他三种故障不一样,其他三种故障最起码要返回一些东西,但超时就是什么也不返回。什么也不返回,也就意味着我们也不知道它到底成功了还是失败了。那这里就出现了一个两难:不重试吧,万一真的失败了,任务就卡住了;重试吧,万一其实成功了,就会造成重复副作用。所以我们是没办法判断要不要重试的——因为判断所需要的那个依据(到底成没成),恰恰是我们最缺的信息。比如说支付场景超时了,如果失败了,你重试可以,那万一它成功了呢?那相当于你支付了两次。并且,如果对面的服务端并没有做好幂等关系,它真的就可以支付两次了。
那么怎么解决呢?解决的方法其实有点像“零信任”的概念。我们不要去相信 response 的反馈内容——或者这么说其实不对,因为超时时它没有反馈信息。
遇到超时,我们应该先去确认一下:这个东西到底有没有真正发生状态转移,也就是这个世界真实的状态有没有发生变化。
这里我们默认服务端它有幂等处理。
遇到超时,我们走 ” 验证 ” 这条路径。整个超时处理的完整流程如下:
这个动作发生之后,会出现以下几种情况:
- 成功或失败:如果成功,就是成功了;如果失败,就是失败的。AI 可以自己想办法去修复、重试、换动作等。
- 超时:如果超时,就会进入到一个模糊结果的处理。此时程序会继续往后走,去验证真实的物理世界状态是什么样子的。这里又会分成两种情况:
- 可通过程序验证:这个状态可以通过程序去验证,而且非常方便。比如你向数据库里写个东西,验证很简单,就是去查数据库里到底有没有这个数据。
- 难通过程序验证:有些东西很难通过程序去验证,就会给 AI 返回一个结果(比如“超时,不确定成功或者失败”),让 AI 自己主动去验证。(注:论文倾向于在 wrapper 内部消化模糊状态——最多用 LLM 充当验证器去判断状态,不把“不确定”抛给模型自由处理;“让 AI 自己验证”是我们自己的工程取舍。)
我们现在沿着“可以通过程序验证”这条路往下走。现在可以验证了,去查看这个东西到底有没有成功,那这里就会出现一个延时的问题。如果验证成功了,那它其实就是成功了,对吧?但是如果验证失败了,那它有可能是真的失败,也有可能是状态还没有更新,需要再等一下。
这里相当于有一个“耐心值”一样的东西,我们会有一个验证轮次,比如验证 3 次:第一次、第二次、第三次。如果 3 次之内他成功了,那说明他就是成功了,对吧?如果这 3 次之内他没有成功,那就是有两种情况:
- 他确实失败了。
- 哎呀,这 3 次不够,可能要继续去验证。
但是第二种情况的话,它就是一个无底洞了。比如说这个东西很长很长对吧,那你这个验证它就是一个无底洞的存在,所以验证轮次必须要有上限。N 次耗尽之后,我们不能再无限等下去,而是把 pending 状态返回给上层去决策——可以按失败处理,配合服务端的幂等兜底;也可以继续等。
延迟可见
上面的流程里其实已经涉及到了延迟可见。超时是 response 返回一个模糊的结果,那么延迟可见就是验证有问题,即明明操作成功了, 已经成立了,但是需要一段时间才能被验证,所以需要一段时间去等待,然后我们再去检查一次。如果说再次检查也不过,这个时候就再查一次(重新验证,不是重试动作——重试会造成重复副作用)。
这里不是一直去重试,而是有一个 的次数限制,这里其实还有一个问题,就是当 次到了,还是失败,这里就有两个情况
- 失败了,所以 次没有东西(这里说的没有东西指的是超时这类模糊的信息,不能得到具体的结果)
- 还在进行时,工作还在跑,所以没有东西。
但是我们无法确认,到底是失败了,还是进行时,这时候就需要 AI 自主判断了。也就是说,将 pending 这个状态返回给主流程的 Agent 上下文里,让他进行决策。
部分成功
部分成功这个场景主要在于,工具执行的结果是假成功。比如说批量上传 200 条数据,只成功上传了 100 条,但是接口返回了成功;又比如说更新一条记录,字段 A 改好了、字段 B 没改,接口也返回了成功。这个时候如果 agent 是基于响应码进行判断的话,这就是一个假成功。他会带来一个非常严重的问题,Agent 不会去做验证,因为对于 Agent 的理解来说,工具确实成功了。
这个根源在于复合操作的原子性被打破了:内部出错导致只应用了一半效果,状态码却谎报成功。所以工具应该有一种能够反馈进度的机制,比如说进度条啊,或者是类似的能够返回成功数量、失败数量的机制,把残缺暴露出来。根据论文的说法,就是我们的验证器(我们上面的说的程序中的一个状态验证器),它必须要有一个完整的后置条件,不能只查 ” 对象存在 “,要检查所有应该生效的字段,因为部分成功的典型形态就是对象存在但字段缺失。
简单来说,就是动作发生之前,就应该知道这个动作会造成哪些状态的变化,然后去对这些状态进行全面的检查。
陈旧冲突
这个最常见了,比如说我们想要编辑一个文件 A,Agent 先 Read 了一下,然后去 Edit
Read file A -> Edit file A但是会出现这种情况,文件 A 中间被改了一下。
Read file A -> file A has been change这里就有一个情况,edit 会出错,因为他是基于原有的记忆上下文来进行编辑的,而我们此时的状态已经发生了更改。更加严重的是 write,在重写前文件被更改了,结果被重新重写了(所以尽可能的不要重写)。
那怎么解决呢?其实这个问题的本质还是我们上面说的那个核心矛盾:agent 以为的状态(记忆里文件长什么样)和真实状态(文件现在长什么样)发生了发散。解决也很简单,在操作一个对象之前,先确认手里的状态是不是最新的。
具体来说,这套东西在分布式系统里已经非常成熟了,主要有三种:
- 条件更新:写入的时候带上你读到的版本号,比如 ” 我要基于版本 3 写入 “,服务端检查现在是不是还是版本 3,不是就拒绝。相当于写之前先检查 ” 我看到的还是不是最新的 “,防止覆盖别人的修改。
- 加锁:编辑之前先锁住文件,别人动不了,编辑完再释放。简单直接,但是并发度会下降。
- 写前验证(状态指纹 hash):读文件的时候顺便拿到内容的 hash,写入之前重新算一次对比,只有两者一致才写,不一致说明状态已经被改过了,拒绝写入。
总结
上述内容,全部都是围绕“如何判断是否应该重试,让行为更加可靠”这一论点进行讨论,并针对四种场景进行了介绍。核心的点就是,尽可能的去看 到底是否真的发生了,论文的实验也证实了这一点:验证是主要功臣,重试未必有益(只验证不重试的效果和验证后重试差不多,因为每次重试都会带来新的故障和重复机会)。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时