agent runtime lab · 2026 · 技术研究笔记
17. 怎样给 Agent 做一次故障演练
正常路径通过只能证明系统会跑,故障注入才能看到它如何停下和恢复
在工具超时、返回损坏数据和进程中断时观察 Agent 的重试、状态恢复与副作用边界
研究版 · 9 个章节 · 约 10 分钟 · 更新于 2026-08-23
一个订单 Agent 调用支付工具时,工具已经扣款,但响应在回传途中超时。运行时只看见“没有收到结果”,于是再调一次。
正常测试不会暴露这个问题。请求成功、结果正常回传时,状态机和重试策略看起来都没有异常。只有在“副作用已发生,确认丢失”这个位置主动中断,重复扣款才会出现。
故障注入是在可控环境中主动制造超时、损坏数据或进程中断,观察系统能否停在正确状态并安全恢复。
故障要注入到精确的边界
“让工具随机报错”可以测到一部分异常处理,但无法回答故障发生在哪个状态边界。工具调用可以拆成五个观察点:
参数校验 -> 记录调用意图 -> 执行副作用 -> 记录结果 -> 回填模型
中断发生在“记录调用意图”之前,运行时可以当作这次调用从未开始。如果发生在“执行副作用”之后,系统就不能凭本地状态判断外部动作是否完成。
每个观察点都要有独立的故障开关。否则一次“测试超时”只能证明某个地方失败了,不能证明恢复逻辑覆盖了最危险的窗口。
先用一个小程序复现丢失确认
下面的脚本模拟一个具有副作用的工具。副作用指修改文件、扣款或发送消息这类会改变外部状态的动作。idempotency_key 是幂等键,多次提交同一个键时,外部系统只执行一次。
from dataclasses import dataclass, field
class LostAcknowledgement(Exception):
"""外部动作已完成,但确认信息丢失。"""
@dataclass
class PaymentService:
"""用内存模拟支持幂等键的支付服务。"""
charges: dict[str, int] = field(default_factory=dict)
def charge(
self,
idempotency_key: str,
amount: int,
lose_ack: bool = False,
) -> int:
"""
扣款并返回金额。
idempotency_key: 业务调用的唯一标识。
amount: 本次扣款金额,单位为分。
lose_ack: 为 True 时,在扣款后模拟回传中断。
"""
if idempotency_key not in self.charges:
self.charges[idempotency_key] = amount
if lose_ack:
raise LostAcknowledgement("扣款已完成,但回传中断")
return self.charges[idempotency_key]
service = PaymentService()
key = "order-20260823-001"
try:
service.charge(key, 5000, lose_ack=True)
except LostAcknowledgement:
# 恢复时使用原键查询或重试,不生成新键。
charged = service.charge(key, 5000)
assert charged == 5000
assert service.charges == {key: 5000}
print(service.charges)
典型输出:
{'order-20260823-001': 5000}
如果恢复时使用新键,模拟服务会留下两条扣款记录。因此,幂等键应在运行时接受工具调用时就生成并持久化,不能在每次尝试前重新生成。
三类故障应分开测
工具报错、响应超时和运行时崩溃在界面上可能都显示为“任务失败”,恢复方式却不同。
- 工具明确返回参数错误时,原样重试没有价值,需要修正参数或终止。
- 读取类工具超时时,可以在有次数和总时间上限的前提下重试。
- 写入类工具在回传前中断时,要用幂等键查询原操作,不能直接当作未执行。
故障演练应记录注入点、当时状态、重试次数、外部系统回执和最终恢复方式。只保留一张“演练成功”截图,后续无法判断系统是自动恢复,还是测试人员手动修改了状态。
先画故障矩阵,再写注入代码
我不建议一上来就装一个随机故障库。先把关键路径画出来,逐格问“这里失败以后,系统知道多少事实”,通常更快。
以一次写工具为例,可以列出这样的矩阵:
| 注入点 | 运行时看到什么 | 外部状态 | 正确动作 |
|---|---|---|---|
| 参数校验前 | 尚未接受调用 | 未改变 | 修正参数或结束 |
| 意图落盘后、执行前 | 有 pending 记录 | 未改变 | 使用原调用 ID 继续 |
| 外部执行后、确认前 | 本地仍是 pending | 可能已改变 | 查询外部状态,禁止盲重试 |
| 结果落盘后、回填前 | 有完成记录 | 已改变 | 重放保存的结果 |
| 回填后、模型续跑前 | 模型未确认收到 | 已改变 | 恢复会话,不再执行工具 |
最危险的是第三格,因为系统面对的是“不知道”,不是“失败”。很多重复扣款、重复发信和重复创建工单,都来自运行时把未知状态误写成失败状态。
给每次演练写明确的验收条件
“没有崩”不算通过。一次故障注入至少应该检查三类结果:
experiment: lost-payment-ack
inject_at: after_external_commit_before_local_result
expected:
external_charge_count: 1
final_run_state: completed
reused_idempotency_key: true
forbidden:
- second_charge
- fabricate_success_message
- discard_pending_record
recovery_deadline_seconds: 30
这里既有最终状态,也有恢复过程。Agent 最后告诉用户“付款成功”并不能证明演练通过;只有外部系统确实只有一笔扣款、本地 pending 记录被正确收敛,而且恢复过程复用了同一个幂等键,才算过关。
重试预算必须跨进程保存
不少系统把 retry_count 放在进程内存里。进程一重启,计数归零,表面上“最多重试三次”的策略就能循环很多轮。总耗时预算也有同样问题。
真正需要保存的是一次逻辑操作的预算,而不是某个进程的预算:首次开始时间、已经尝试的次数、最近一次错误、下一次允许重试的时间,以及调用使用的幂等键。这些字段要和调用意图一起落盘。
退避策略也不能只有 sleep(2 ** attempt)。读取类操作可以自动退避;写操作在结果未知时,应先查询状态;权限拒绝、参数错误和安全策略拦截通常不应重试。把所有错误都归到“网络不稳定”只会让 Agent 更执着地做错事。
进程恢复要从持久状态开始,而不是让模型猜
运行时重启后,最省事的做法是把一段摘要塞给模型:“刚才可能调用了支付工具,请继续。”这相当于让模型根据自然语言猜系统状态。
恢复程序应先读取结构化记录,确定哪些调用已经完成、哪些尚未开始、哪些处于未知状态。只有状态被收敛后,才把必要事实回填给模型。模型可以决定接下来如何完成用户目标,但不该决定过去那笔副作用是否发生。
这个边界很重要:语言模型擅长根据上下文选择下一步,不是事务日志,也不是外部系统的事实来源。
做一次真正可复盘的 Game Day
单元测试里的故障注入稳定以后,可以安排一次小范围 Game Day,也就是由团队共同执行的故障演练。参与者提前知道停止条件和恢复负责人,但不知道每个故障会落在哪个时刻。
一场有用的演练会留下时间线:谁在什么时候注入了什么故障,监控多久发现,Agent 进入了哪个状态,值班人员看到了哪些信号,自动恢复是否成功,以及人工用了哪条命令收尾。演练后真正要修改的往往不只是重试代码,还包括告警文字、状态面板、runbook 和权限开关。
如果一个故障只能由写那段代码的人发现和恢复,它还不能算可运营。故障演练的价值,就是在用户替你踩中那个窗口之前,把这种依赖暴露出来。
在测试环境中保持可控
故障注入不等于在生产环境中随机终止进程。起步阶段应使用独立账号、模拟外部服务和专用数据。注入条件要由明确的测试标识触发,不使用随机数决定是否对真实请求制造故障。
等到恢复路径在隔离环境中可重复,再考虑范围很小的生产演练。那时仍需要停止条件、值班人员和恢复方案,而不是让 Agent 自行决定演练边界。
正常路径说明系统在条件充足时能完成任务。故障演练回答的是另一个问题:条件不再充足时,系统是否知道自己停在哪里。
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:17. 怎样给 Agent 做一次故障演练
本文链接:https://www.sshipanoo.com/blog/ai/agent-runtime-lab/17-Agent故障演练/
