对智能体栈做故障注入

10 分钟读完

O28
运维 · 智能体运维:部署与运营

故障注入:你的仪表盘看不见的那场故障。

普通服务里依赖挂了,你会拿到一个 500 和一条告警;智能体里依赖挂了,你拿到的是一段流畅、自信、错误的答案,外加一张全绿的仪表盘——因为处理这个错误的组件是一个语言模型,而它受的全部训练就是把事情接着往下做。这正是故障注入要找的失败模式,也是这门功夫在这里长得和微服务集群里不一样的原因:你在工具边界注入,而不是在网络层;你断言的是轨迹,而不是状态码。问题从来不是「它扛住故障了吗」——而是「它告诉谁了吗」。

STEP 1

把故障藏起来的,正是模型本身。

工具结果是以文本抵达模型的。一个带着正确 JSON 的 200、一个 503 的响应体、一个空结果数组、一段 SDK 塞进去的超时占位符——在上下文窗口里全都只是令牌;而这个循环的错误处理逻辑,是一个被狠狠训练过、务必产出有帮助的下一步的模型。它照做了。这就是缺陷所在。

  • 错误被吸收掉了,而不是被抛出来。搜索什么都没返回,于是智能体凭参数记忆作答。定价工具超时,于是智能体报了一个来自提示词示例里的数字。两条路径都不抛异常。你的错误率纹丝不动,而你的答案已经错了。
  • 重试把看不见的成本乘了起来。模型绕过三次的一个瞬时故障,就是同一个任务花了三倍令牌、三倍延迟,而指标里除了略胖一点的 p99 之外什么都没有——那些常量如何复合,见超时与截止时间预算。
  • 经典的混沌工具打错了层。杀个 Pod、用 tc 加 200 毫秒抖动,练的是基础设施的韧性,而那部分通常本来就还行。有意思的故障是语义层面的——空的、陈旧的、截断的、格式良好但内容错误的结果——没有任何网络层工具能造出这些。
  • 爆炸半径在答案的下游。一个绕过失败读取继续往前、然后基于它执行了一次写入的智能体,已经把一次依赖抖动变成了一起数据完整性事故。撤销它要花多少代价,见修复智能体的副作用。

一句话版本:在正常系统里,故障很吵,正确性很安静。在智能体里,故障很安静,只有正确性很吵——而你并没有在每个请求上度量正确性。故障注入就是你弄清那条安静的路径究竟干了什么的手段。

STEP 2

真正要紧的那份故障清单。

把这份清单写下来,因为它很短、很稳定,而且几乎没人测到过第二条以后。在任何有真实流量的系统里,下面每一条每周都会发生。

  • 空的成功。工具返回 200 和零条结果。这是收益最高的一项注入,因为在文本上它和「确实没有东西可找」无从分辨——而从循环内部看去,权限过滤、索引滞后、查询里的一个错别字,长得全都是这个样子。
  • 慢但成功。一次本该 400 毫秒、却在 25 秒时正确返回的调用。它测的是截止时间的传播,而不是错误处理;也是最可能在不触发任何东西的情况下把一次运行的预算吹爆的那种故障。
  • 畸形与截断的输出。在对象中途被切断的 JSON、模式里写着整数却来了个字符串的字段、你以为 4KB 结果来了 2MB 的响应。每一种走的解析器路径不同,走的模型路径也不同。
  • 陈旧的成功。看起来正确、但早于最后一次写入的数据。任何地方都不会给它打标记,这正是它该进测试套件而不是进告警的原因。
  • 200 里面装着错误。工具返回 HTTP 200,响应体里是 {"error": "rate limited"}。你的传输层看见的是成功;你的模型看见的是一句关于限流的话,然后在无人监督之下决定拿它怎么办。
  • 提供方的拒绝与内容过滤。模型 API 在运行途中返回一次拒答或一段被截断的生成。智能体处理这种情况差得惊人——它们经常再问一遍、再被拒一次,然后把循环预算烧光。
  • 模式漂移。一个字段被改名、多了一个必填参数、某个枚举加了一个取值。这不是假想中的维护工作,这就是第三方工具漂移里描述的常态。
  • 凭证在运行途中过期。令牌在第 2 步还有效,到第 14 步就没了。长运行让这件事变得稀松平常,而正确的行为——停下、上报、不要自作主张绕过去——很少是实际观察到的那个。
STEP 3

在工具边界注入,而且要可确定复现。

这套机制应该是你整个技术栈里最不聪明的那部分。一层夹在智能体与其工具之间的垫片,由每次运行的一份配置控制,能在任何工具结果变成文本之前改写或延迟它。

  • 放进工具调用路径,而不是传输层。包住工具分发器的一层包装——或者让你的 MCP 客户端指过去的一个代理——看得见带参数的逻辑调用,而这正是精确打击所需要的(「让这次运行里第三次 search_orders 调用失败」,而不是「随机 5% 的 HTTP 请求」)。
  • 一切都要有种子。一份故障计划就是 (run_id, seed) → (call_index, fault) 列表。种子相同,同一次运行就产生同样的故障。无法重放的混沌测试只是一则轶事,而它也会被当成轶事驳回——这与重放测试的要求是同一条。
  • 把它挂在运行上,而不是环境上。用一个请求头或运行属性来选故障计划,意味着同一套共享环境既服务正常流量也服务注入流量,也意味着你终有一天可以把它对一小份带标记的生产流量打开。
  • 模型边界也要注入。来自模型提供方的拒答、截断和 429,需要在循环另一侧有同样的一层垫片。团队几乎总是只建了工具那一半,然后被自己从未演练过的提供方行为打个措手不及。
  • 没有幂等,绝不注入写路径故障。如果一次故障造成了部分写入、而你又无法按键对账,那你就亲手制造了自己本想测出来的那场事故——前置条件是幂等与重试,先到位。
fault_plan:
  run_id: 7f3a…            # replay handle
  seed: 4291
  faults:
    - on: {tool: search_orders, call_index: 3}
      inject: empty_success        # 200, zero results
    - on: {tool: get_pricing, call_index: 1}
      inject: {delay_ms: 25000}    # slow but correct
    - on: {tool: post_refund, call_index: 1}
      inject: {status: 200, body: '{"error":"rate limited"}'}
    - on: {model: completion, step: 9}
      inject: refusal
STEP 4

断言在轨迹上,并且给三种结局命名。

这里是智能体故障注入与微服务版本分歧最大的地方。没有状态码可供断言,因为这次运行成功了——它产出了一个答案。你评判的是那条路径,而值得区分的结论恰好只有三种。

  • 正确地降级。智能体察觉了故障,做了件明智的事——带退避重试、改用备用来源、把任务收窄——并交付了正确结果,或者一个被准确描述过的部分结果。这个可以上线。
  • 诚实地停下。智能体完不成任务并且说了出来,没有编造结果,也没有写任何东西。这是通过,而团队严重低估了它;一个在依赖故障下干净停住的智能体,比一个四次里聪明三次的更值钱。
  • 无声的编造。智能体产出了一个自信、合理、却无依据的答案——或者更糟,写了点什么。这是唯一一种该卡住发布的结论,而把它找出来正是整件事的全部意义。

用来区分这三者的断言是轨迹属性,而不是输出字符串:有没有哪次工具调用做了重试、而且带退避?最终答案有没有引用一个被注入故障弄得不可用的来源?同一次运行里,一次失败的读取之后有没有发生写入?智能体面向用户的文字里,有没有承认数据缺失?用你在轨迹评估里用的同一套机制去打分,再把失败归进失败分类与分诊里的那些桶。

有一条断言就能把整个项目赚回来:同一次运行里,写入绝不能跟在一次出故障的读取之后。它可以从轨迹里机械地检查、不需要任何裁判,而且它抓的正是那一类把五分钟的依赖抖动变成一周对账的缺陷。

STEP 5

三个场地,按顺序来,预算各不相同。

故障注入不是一件事。把它当成一件事来做,结果要么慢到进不了 CI、要么危险到不能上生产,然后就被放弃了。

  • 在 CI 里,跑一套固定用例。十到三十个场景,每个把一种故障钉在一次调用上,只用确定性的轨迹检查来断言——不要 LLM 裁判,因为这道关卡必须又快又稳。它能在有人改了重试策略、或改了提示词里那句错误处理指令时抓住回归。它该和你其他关卡一起,摆在评测驱动开发旁边。
  • 在预发环境里,做成定期的演练日。更宽的故障计划、多故障并发的运行、更长的时间跨度,再加一个人去读一部分轨迹样本。有意思的行为都在这儿被发现——那个靠换个账号来「解决」权限错误的智能体,那个把空结果当成许可、一路放宽查询直到匹配上一切的智能体。
  • 在生产里,窄而且晚。只有在前两个场地都变得无聊之后才做。设上限的一份流量、先内部或先同意参与的租户、一条接到急停开关上的实时中止条件,以及在幂等工作做完之前绝不对有写能力的运行做。之所以还是要做,是因为预发环境的工具桩不是你的供应商,而你从没见过的那些故障,正是桩造不出来的那些。

预算要诚实:CI 套件应以分钟计,演练日每月一名工程师半天,生产项目要有一个署名的负责人。拿它对照压力测试——后者回答的是另一个问题:压测告诉你系统什么时候倒,注入告诉你它倒下去的时候说了什么。

STEP 6

记分牌,以及「变好」长什么样。

两个数字,按发布版本追踪,都从同一批运行里导出:

  • 注入下的无声编造率。在注入过的运行里,以自信而无依据的答案、或一次没有正当理由的写入收场的比例。这是唯一一个必须趋向于零的数字,也是该摆到发布评审面前的那个。
  • 诚实停下的占比。在那些本就不可能成功的运行里,停下来并说明的比例。上升是好事,而且值得单独盯着——因为把编造率压下去的廉价办法(让智能体多拒绝)会在这里显形,成为一笔你随后可以定价的明摆着的交易。

当编造率偏高时,修复几乎从来不是一个更好的提示词。它是结构性的:用一个显式的、带类型的错误、而不是一段字符串化的异常,把故障对模型变得可读(这份设计工作在工具错误信息里),并且让那个有后果的动作变得不可用、而不只是不被鼓励——一个在降级状态下模型根本调不到的工具,胜过一句叫它别调的指令。优雅降级是同一个论证的可用性侧写法。

这周就从一个场景、一条断言开始。挑你流量最大的那个工具,在你最常见的十个任务的副本里,对它的第一次调用注入空的成功,然后只检查一件事:最终答案有没有承认它什么都没找到?第一次跑这个的团队,大多至少会发现一个任务里智能体凭记忆自信作答——而这一个结果为后续整个项目做的辩护,胜过本页上的任何论证。接着加上慢但成功与200 里面装着错误,把这三条用确定性检查接进 CI,然后才去张罗演练日。