给 Agent 加上可靠性机制后,为什么反而交不出结果?
给 Agent 加日志,是为了失败后查得清;加检查点,是为了中断后接着做。这些选择都有充分理由。但当一份结果已经保存,系统却因为后续记录失败而无法继续时,我们需要重新问:这套机制究竟在保护什么?
最近维护 ETF Agent 时,两个问题让这个疑问变得具体。一处可选的研究证据写入卷进数据库锁竞争,影响了正文提交;另一处工具读取已经完成,随后保存恢复检查点失败,独立的后续研究却被限制了。
它们并不意味着日志和恢复没有价值。真正需要检查的是:我们为了处理一种失败,是否又让主任务依赖了一个本不必成功的环节?
一、先问清楚,用户究竟在等什么
假设用户让 Agent 比较几份材料,最后取得一份报告。这是一个简化的教学场景。
系统会做许多用户看不见的事:记录请求、保存工具结果、更新运行状态、写入恢复信息,再把正文交给用户。它们看起来都叫“保存”,承担的责任却不同。
有的记录决定能不能安全执行下一步。例如,外部操作可能产生不可逆效果,系统就需要在派发前保留足够的身份和状态,避免恢复时重复执行。有的记录保存已经发生的事实。有的只是为了以后更容易观察和排查。
如果把这些写入都当成同样重要,最省事的实现就是逐个等待:每一步成功,才允许下一步继续。正常时,这条路径很整齐;任何一个附加环节出问题,用户的报告也跟着停下。
因此,“失败就停止”是否合理,取决于不停止会造成什么具体风险。必要授权无法确认,应当阻止相应操作;少了一份可选调用副本,则应先问它是否真的影响原研究继续。
把这个区别说明白,比增加多少个异常处理分支更重要。
二、已经异步保存,为什么还会卡住正文?
第一个案例来自研究证据留存。系统希望留下模型请求与输出的有限副本,方便后续回查。这份副本不负责宣布模型请求有没有发生,也不负责决定正文能不能提交。
从调用关系看,它已经是旁路:原请求继续执行,证据进入有限队列,后台再写数据库。直觉上,这样就不会拖慢主流程了。
问题出在队列之后。
后台写入仍然使用同一个数据库,也要取得锁。在一次排查中,原数据库日志记录了三个事务之间的循环等待;证据写入持有来源相关的锁,又等待模型记录上的锁,正文提交也参与了这条等待链。调用是异步的,不代表资源已经隔离。
排查没有靠“后台保存应该没问题”结束。我们冻结旧版本,通过原存储入口安排受控竞争,复现了锁环,再检查哪一条等待可以解除。
修改落在可选证据写入这一侧:当它已经持有来源锁、却无法立即取得需要的模型记录锁时,让本次证据事务失败并回滚,释放已经占用的资源。失败仍被记录,不能伪装成证据保存成功;正文提交则沿自己的规则继续。
为什么不先延长超时,或者再加一次重试?因为循环等待没有因等待更久而消失。重试如果仍然以相同顺序持锁,也可能重新制造竞争。这里需要解除的是一条具体的资源依赖。
受控数据库验证中,正文的后续版本可以提交并读回,任务收尾和历史读取能够继续,后来的独立证据采集也能成功。这支持的是这条锁竞争路径的修复。原始事务中仍有未完全确认的调用细节,不能把受控复现写成已经还原了历史故障的全部原因。
这类问题也不限于 Agent。Google SRE 关于级联故障的讨论提醒我们,局部资源问题可能沿依赖关系扩散。对这个案例,更值得迁移的做法是:审查一个“可选后台任务”时,继续追到它实际使用的连接、锁和队列,而不是只看函数有没有被异步调用。
三、动作已经完成,为什么恢复记录还能让它失败?
第二个案例发生在动作完成之后。
工具读取已经结算,存储中有原动作的完成回执。系统随后补写恢复检查点,方便之后接续。受控故障注入让这一步在写入存储之前发生序列化错误。
结果是:已经完成的读取没有丢,但下一轮被迫进入只允许收尾的状态,原本还可进行的独立读取不再可用。恢复机制没有帮系统接着研究,反而缩小了它继续工作的空间。
这里最容易想到两个极端:所有检查点失败都终止,或者所有检查点失败都忽略。两者都没有处理最关键的差别:失败时,哪些事情已经确定发生,哪些还不知道?
| 当前能够确认的事实 | 应保留的判断 |
|---|---|
| 原动作已经完成,后续检查点确定在写入前失败 | 不撤销原动作事实;核对原回执及继续执行条件后,局部处理这次检查点失败 |
| 检查点可能已经写入,只是确认丢失 | 保留结果未知,不能当作“肯定没写”,也不能直接重放 |
| 缺少可信完成回执,或已失去执行租约 | 不能借局部降级继续产生受保护效果 |
修复因此很窄:只有能够沿原身份核对已完成动作,并且确认是对应检查点的写前失败,才采用局部隔离。原有取消和租约检查仍然生效。下一次工具派发也必须通过它自己需要的持久化边界,不能拿“上一步已经完成”作为以后都免检查的理由。
修复后的受控路径中,原读取只执行一次,另一项独立读取能够继续,后续答案和收尾可以完成。结果未知、缺失或伪造回执等对照仍走原有处理。它说明的是特定故障后的继续行为,不是任意崩溃都可安全恢复。
这个案例改变了我们看待检查点的方式:它描述和支持执行过程,但不是一份可以随意改写已经提交事实的第二账本。
四、可靠性能力,也要接受反向验证
两个案例表面不同:一个是数据库等待,一个是恢复状态保存。共同的问题是,主任务多承担了一条依赖,却没有充分说明这条依赖为什么必要。
普通功能测试常问:“记录能否成功保存?”可靠性测试还应该反过来问:“记录保存失败时,其他哪些事情应该仍然成立?”
以报告任务为例,可以故意让可选证据写入争用、让已完成动作后的检查点在写前失败,再观察:
- 原本已经提交的内容还能不能读到?
- 不依赖这次失败的后续工作能不能继续?
- 原动作有没有被重复执行?
- 写入未知、用户取消或执行权失效时,系统是否仍按原规则处理?
前两项防止故障扩散,后两项防止为了“继续”而跨过真正需要保留的边界。只测到“不抛异常”远远不够:任务可能没有报错,却已经失去了继续研究的能力。
也不必因此建立一套庞大的新故障管理系统。先找到具体等待、写入和回执所在的位置,在原来负责它的模块修复,往往更容易解释影响范围。
五、下一次增加可靠性机制,先写下它失败后的样子
日志、追踪和检查点通常因成功路径而被引入:能看清一次调用,能接续一项任务,能复查一份结果。设计时也应该写清它们失败后的行为。
可以从三个问题开始:
- 它为什么必须成功? 如果答案只是“这样记录更完整”,就还不足以让它阻塞用户结果;如果它保护权限或防止重复外部效果,就需要指出具体的执行时点。
- 它与主流程共享了什么? 异步接口之后,仍可能共享数据库锁、连接、容量和失败处理路径。
- 失败后,系统还能准确承诺什么? 有些记录缺失,可以明确留下缺口;结果未知,则需要保留未知。两者都不能被包装成成功。
回到那份报告:缺少一次可选观察,不应该自动抹掉独立合法的成果;已确认完成的动作,也不应该因为补写记录失败就被重新解释为没有发生。与此同时,系统必须继续知道何时没有足够条件安全执行下一步。
可靠性机制的价值,不只在于它成功时补救了什么,也在于它失败时,仍然给主任务留下了多大的继续空间。
延伸阅读
- Run 的终结权、阶段预算与安全恢复:谁决定局部失败是否需要终止整个任务。
- 研究证据的采集、留存与回读:可选观察副本与原执行事实的区别。
- English edition
欢迎在 GitHub 文集讨论这些取舍;如果对你的工程判断有帮助,也欢迎点一个 Star。