把 Agent 当成分布式系统:可靠性问题不在模型里

很多人第一次给 Agent 做稳定性建设,做的是 prompt 调优和模型换血:温度降一点、换成更会遵循指令的模型、补几个 few-shot 示例。这些手段能改善质量,但对可靠性几乎无效,因为 Agent 崩溃的原因极少来自模型智力不夠,绝大多数来自模型之外的工程缺口。

把一次 Agent 运行摊开看,它是这样一个东西:模型在生成一段不可预测的控制流,控制流里嵌套着对外部系统的真实调用,调用会改状态,状态又反过来影响后续控制流,最后还要把这一长串过程的结果交给人看。这里面每一个环节都会失败,而且失败方式互相放大。它长得像分布式系统,但比绝大多数分布式系统更难做容错,原因是三个结构性差异。

第一,控制流没有静态上界。普通服务的重试边界由调用图决定,调用了几层、每层几次,在代码里写死了。Agent 的步数、每步调什么工具、参数是什么,全由模型现场决定,编译期根本不存在重试图。你没法对一个还不存在的图做超时预算和熔断配置。

第二,每次调用都是多级嵌套。一次工具调用下面还有编排层、网关层、下游服务、数据库,超时如果每层都配一个固定值,超时预算会随嵌套层数呈乘法膨胀。二十层的链路配上三秒超时,最外层能挂七十五分钟才放弃,而你的会话超时可能只有一分钟。

第三,推理和副作用被耦合在同一条轨迹里。普通服务里,推理失败就重试推理,写库失败就重试写库,两条路径清晰分离。Agent 里,一次重试既可能重新跑一遍推理,也可能重新触发一次已经生效的写操作。这就是幂等性问题的根源,也是本文后半部分大量篇幅的由来。

所以本文的组织方式是把可靠性拆成三个层面来谈:控制层管循环与状态,对应卡死检测、步数预算、熔断;数据层管上下文与时限,对应超时预算的分配与继承;效应层管外部世界,对应幂等、重试、补偿事务与限流。三层的失效互相传染,最终用混沌测试把三层一起验。

Agent 特有的六类故障

在动手写任何机制之前,先把要治的病列清楚。下面这六类是 Agent 场景下最高频的,而且每一类都能在普通微服务里找到对应物,却在 Agent 里表现得更极端。

一、超时缺失:一个没有上界的长事务

典型症状是工具调用没有 timeout 参数,默认等待到 TCP 层重传耗尽;或者写了 timeout,但从编排层到工具适配器这段中间层忘了传,最终用了一个更宽松的框架默认值。用户看到的是一个按钮转了四十分钟,然后一个空响应。

更隐蔽的是只配了整体超时没配空闲超时。流式响应里对端建立连接后不发任何字节,服务端会一直挂着;或者 agent 每五秒吐一个心跳,工具其实早就死了但连接还活着。首字节超时和流间隔超时必须分开配,这一条在 MCP 与各类 Agent 工具协议里都还经常被漏掉。

二、非幂等重试:一次超时,三封邮件

这是代价最高的一类。工具已经执行成功,只是响应在回程丢了,客户端看不见就重试,于是发信、扣款、下单、发版各发生两次。很多团队上线前会用测试桩把工具全部替换成返回固定值,成功率很好,上生产第一周就收到客诉说重复扣费。

更隐蔽的变体是模型级自愈:把工具异常原文塞回上下文,模型自己决定再调一次。这类重试完全绕过了你写在框架里的重试策略,你在网关上看不到重试计数,只有模型推理费用悄悄翻倍。

三、部分失败无处安放:三条成功,第四条超时

Agent 规划出五步操作,前三步写库成功,第四步超时。此时系统面临一个必须立刻回答的问题:这三条已经生效的记录是留着还是撤销?很多实现的做法是假装什么都没发生,把异常抛回去等用户重试,结果用户在重试时又执行了一遍已经成功的前三步。

四、循环不收敛:看起来在思考,其实在原地

模型的推理过程有随机性,同一个状态可以生成上千种不同措辞的无效动作。这类循环用「检测重复字符串」抓不住,因为每一步的文本都不一样。要检测的是语义上的无进展:状态摘要没变、目标没靠近、只是换了说法。

五、扇出过载:一次规划,三十六个调用

模型看到一句「把这个季度的所有渠道数据都拉一遍」,规划出三十个子调用。如果编排层老老实实并发执行,同一个下游服务瞬间收到三十倍流量,触发限流甚至雪崩。单工具限流在这里完全失效,因为每个子调用在那个服务的视角里都是不同的请求。

六、降级未传播:上游已经很忙,下游还在猛抽

模型网关返回 429 或 503,编排层不解析这些状态码,统一当成工具错误交给模型,模型换个说法再试,连续几次打穿配额。这类故障的特征是成本比错误更快上升,你会先在账单上发现它,再在监控上找到原因。

超时预算:作为上下文分配,而不是逐层传参

超时的第一个认知纠偏是:你要管理的是截止时间,不是超时长度。timeout 是一个长度,deadline 是一个绝对时刻。当调用链有三层、每层都可能重试时,逐层传 timeout 会让预算失控,因为没有任何一层知道上一层还剩多少。

两者的区别可以用一句话概括:timeout 回答「这次最多等多久」,deadline 回答「这件事最迟什么时候必须做完」。只有后者才能在嵌套调用里正确衰减。

预算的三种分配形态

实际场景里只有三种分配形态,先分清再谈继承。

串行链:多个调用排队执行,共享同一份墙钟预算。分法是按剩余量滚动分配,而不是开头一次性切好——因为前一段可能提前结束,剩下的时间应该留给后面。

并行扇出:N 个分支同时跑,必须瓜分剩余预算。注意是瓜分而不是各自复制一份,否则并行分支总预算会等于父预算乘以分支数,这是超时爆炸最常见的来源。

嵌套 Agent:子 Agent 继承父的剩余额度,通常再乘一个比例并设绝对上限。比例是为了防止单个昂贵子任务吃掉全部预算,上限是为了防止长尾请求在层层传递中不断被放大。

预算与模型推理的耦合

超时的另一半常被忽略:一次工具调用失败之后,模型还要再推理一次来决定怎么办,而这次推理本身也要花时间和 token。如果预算只覆盖工具不覆盖推理,就会在最需要权衡的时刻没有额度可用,结果要么是被粗暴切断,输出一句「由于超时,无法完成」,要么是在没有额度的情况下偷偷放行一次推理,让实际耗时超出预期。这两种都不是好结果:前者浪费了已经完成的工具工作,后者让预算彻底失去意义。所以预算至少要覆盖三段——一次模型推理、一次工具往返、以及失败后那一次重决策的推理。

推论是要给推理本身也配上限。当剩余预算已经小于一次推理的典型时长时,继续让模型思考是纯浪费,此时更合理的做法是走一次极短输出的降级路径,让模型用最少的 token 把已获得的信息组织成回复,或者干脆交给确定性模板。这种「预算不足即降级为最小输出」的行为,在长任务的尾部出现频率比想象中更高,值得单独设计而不是当作兜底。

还有一个方向是被忽略的正向用法:让预算反过来约束规划。规划器在生成动作清单时就看到剩余额度,它会倾向于「预算够做三步就只规划三步」,而不是规划三十步然后在中途被砍断。后者会留下大量已生效但未完成的副作用,前者从一开始就不会产生这些。这比在执行层拦截要干净得多,代价是需要把预算信息在提示或工具描述里表达清楚,并确认模型确实会读它。

子 Agent 的预算继承

一旦引入子 Agent 或者并行的专家模型,预算就变成跨进程甚至跨供应商的资源。可行的规则有三条。第一,子 Agent 继承父的剩余额度乘一个比例,并且有绝对上限;父给子的总份额必须能被父收回,否则并行分支数量由模型决定,会让总预算失控。第二,子 Agent 消耗掉的额度必须回写,用一棵贯穿整棵调用树的令牌或账本记账,避免父子两侧都以为还有钱。第三,子 Agent 的不可逆副作用必须回传到父的台账,否则恢复逻辑只看得到父侧的记录,会漏掉子 Agent 已经发出去的那些消息。

第三条最容易被忽略。子 Agent 通常有自己的执行上下文和自己的持久化,父在崩溃恢复时一般只加载自己的状态。两者之间如果没有共享台账,恢复时就会出现「父以为没做过、子 Agent 其实做过了」的空洞,最后只能靠猜。更麻烦的是这类问题不会在运行时暴露,只会在恢复路径上偶发失败,极难定位。共享台账的成本并不高,通常只是在写副作用时多写一条带父子关联的记录。

三种分配形态的对照

形态分配方式最常见的两种错法后果
串行链按剩余量滚动分配,前段提前结束则后段多得开头一次性切好固定份额前段快就浪费额度,前段慢就中途无额度
并行扇出瓜分剩余额度,按权重分配各分支复制一份父预算总预算随分支数膨胀,整体卡在限流上
嵌套 Agent剩余量乘比例并设绝对上限,消费回写只继承不记账父子都以为还有额度,实际远超预期

这张表里并行那一行的两种错法方向相反但结果一样:一种让总预算膨胀,一种让额度在第一次尝试时就用光而补偿阶段无钱可用。前者是放大,后者是枯竭,共同点都是留下一个没跑完的 run 和一堆悬空副作用。所以并行分支的额度分配必须和补偿预算一起考虑,而不是各自分一块就以为互不干扰。

三条必须写死的规则

  1. 预算不可重置。重试必须复用同一个截止时刻,绝不能每次重试都重新计时。这是把重试从「放大器」拉回「恢复手段」的第一道闸。
  2. 预算不足则不发起。发起一个注定要超时的调用,会白白消耗连接、占用下游配额,还可能留下半个副作用。应该在调用前检查,而不是等超时。
  3. 剩余额度必须跨进程传播。工具常常是独立部署的 HTTP 服务,需要把剩余毫秒数放进请求头,并且允许子服务回写消耗量。否则跨服务边界那一层的预算是纯猜测。

实现:预算作为上下文对象

# 预算不是参数,是上下文:一次 run 只有一个墙钟截止时刻,
# 所有下游调用从它继承剩余额度,而不是各自拿一份新的 timeout。

from __future__ import annotations

import time
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field

_budget: ContextVar['Budget | None'] = ContextVar('budget', default=None)


class BudgetExceeded(Exception):
    pass


@dataclass
class Budget:
    total: float                      # 本次 run 的墙钟预算(秒)
    started: float = field(default_factory=time.monotonic)
    name: str = 'root'

    @property
    def remaining(self) -> float:
        return max(0.0, self.total - (time.monotonic() - self.started))

    @property
    def expired(self) -> bool:
        return self.remaining <= 0.0

    def check(self, need: float = 0.0) -> None:
        # need 是这次调用预计要花的时长。剩余量大于 0 但不够跑完时
        # 就该放弃,而不是发出去再等它超时。
        if self.expired or self.remaining < need:
            raise BudgetExceeded(
                f'budget {self.name} exhausted: '
                f'need={need:.2f}s remain={self.remaining:.2f}s'
            )

    def child(self, name: str, weight: float = 1.0, cap: float | None = None) ->Budget':
        # 并行分支按权重瓜分「剩余」额度;不给每个分支一份完整副本,
        # 否则 N 路并行总预算就变成了 N 倍父预算。
        share = self.remaining * weight
        if cap is not None:
            share = min(share, cap)
        return Budget(total=share, name=f'{self.name}/{name}')


DEADLINE_HEADER = 'x-agent-deadline-ms'


def outbound_headers() -> dict:
    b = _budget.get()
    if b is None:
        raise RuntimeError('no budget in context: run must enter run_with_budget()')
    return {DEADLINE_HEADER: str(int(b.remaining * 1000))}


def adopt_downstream(headers: dict) ->Budget':
    # 工具在另一个进程里:从请求头恢复自己的预算,继续向上衰减
    left_ms = int(headers.get(DEADLINE_HEADER, '0'))
    return Budget(total=left_ms / 1000.0, name='downstream')


@contextmanager
def run_with_budget(total: float, name: str = 'root'):
    token = _budget.set(Budget(total=total, name=name))
    try:
        yield _budget.get()
    finally:
        _budget.reset(token)


def call_tool(fn, spec, *args, **kw):
    # spec 是工具声明的延迟画像(p50/p99),用来决定给它多少额度
    b = _budget.get()
    allowance = min(spec.p99 * 1.5, b.remaining)
    b.check(need=allowance)
    return fn(*args, deadline=time.monotonic() + allowance, **kw)

这段实现里最容易被忽略的是 check(need=...) 的提前判定。用 p99 乘一个系数作为单次调用的额度上限是有讲究的:p99 太贴近均值会让长尾频繁触发预算耗尽,太宽松又会让一个慢工具吃掉整个 run 的时间。多数团队的起点是 p99 乘 1.5 到 2 倍,然后按线上分布调。

还有一个细节值得单独说:工具声明延迟画像(p50/p99)这件事,只有在画像来自自己观测的直方图时才有意义。用拍脑袋写的常量当 p99,等于把预算决策建立在一个从来没有被验证过的猜测上。做法很土但有效:每个工具记录最近一小时的分位延迟,定期更新画像,超过样本量门槛的工具不参与预算分配而是走保守固定值。

重试的前提是幂等

一句话概括本节:重试不是一种容错策略,重试是对不确定是否已生效的副作用的一次猜测。当副作用幂等时,猜错的代价是零;不幂等时,猜错就是数据损坏。所以重试策略的正确顺序是:先给工具分级,再决定哪些能重试,最后才谈退避算法。

把工具按可重试性分成三类

分类标准不是「读还是写」,而是「重复执行会不会改变语义」。按这个标准分三类的结果比直觉更清晰。

类别判定标准典型工具自动重试
天然幂等重复执行产生相同结果,或只有读取语义搜索、查询、读文件、列目录、翻译、摘要允许,最多重试若干次
带自然幂等键的写服务端按某个业务键去重,重复写入被折叠UPSERT、按消息 ID 落库、按外部单号记账允许,前提是幂等键确实透传到了服务端
不可逆副作用已经对外可见,且无法用业务键去重发邮件、发消息、扣款、开子进程、发版禁止自动重试,只能靠补偿

这张表的实战价值在于,它把「这个工具要不要重试」从一个每次都要讨论的开放问题,变成了一条可以从工具注册表直接读出来的属性。工具注册的时候就要声明类别和幂等键的构造方式,等到线上才发现某个工具不可重试,通常已经晚了。

还要单独提一句 LLM 推理调用。它看起来是天然幂等的——同样的输入同样的输出?并不。推理重试虽然不产生外部副作用,但它会烧钱,而且会把一个可能本该放弃的分支续命。更重要的是,当推理和工具写在同一次循环里时,重试推理的次数会被计入模型的自我修正次数,那个次数必须有上限,否则就进入了下一节要讲的循环熔断话题。

幂等键必须绑在意图上

幂等键的构造方式决定了整套机制是否成立。最常见的错误是把键绑在尝试上:每次重试生成一个新的 UUID,服务端一看是新键,照单全收。这等于没有幂等。正确的绑定对象是逻辑意图:同一个意图的所有尝试、所有重试、所有跨进程重放,共享同一个键。

键的组成建议包含四段:租户标识、意图标识(由编排层在规划阶段生成并持久化)、工具名、以及规范化后的参数摘要。规范化那一步经常被跳过,于是模型第一次传 {'q': '天气'}、重试传 {'q': '天气 '},两个键不同,去重失效。规范化要处理空白、大小写、字典序、时间戳占位符、随机 id 这几类常见差异。

还有两个容易被忽略的前提:服务端必须真的存储键到结果的映射,而不是只在缓存里看一眼;这个存储的存活时间必须覆盖最大重试窗口加上进程重启恢复窗口。很多实现把 TTL 设成一小时,结果一次跨天的断点恢复重放就把去重击穿了。

推理重试与工具重试必须分层

这两类重试经常被混进同一个策略里,结果是预算和计数同时失去意义。工具重试的触发条件是明确的传输错误或服务端错误,重试的是同一次调用的执行;推理重试的触发条件是输出不满足格式或校验,重试的是模型的采样过程。两者的预算、次数上限和观测指标都应该分开统计,合并之后你既看不清下游抖动,也看不清模型质量波动,最后只能笼统地说「今天不太稳定」。

更关键的是要禁止模型自发无限自愈。把工具异常原文塞回上下文、让模型自己决定重试,这在体验上很自然,但代价是完全失去控制:没有上限、没有预算、没有退避,也就没有熔断可以配合。一个可行的折中是给每个工具声明一个「模型可自愈次数」,默认一到两次,超过就把该工具在本次 run 内标记为不可用,强制模型走降级路径而不是继续试探。

这个自愈次数和熔断器是互补而不是重复的:熔断管的是「这个工具整体是不是坏了」,看的是跨 run 的统计;自愈次数管的是「这一次调用是不是值得再试」,看的是单 run 内的步数。混在一起会出现两种典型错误:熔断太松时自愈次数成了唯一防线,模型反复去打同一个坏工具;熔断太紧时一点点抖动就打开,用户频繁看到「暂不可用」而任务其实很快就好了。

尊重服务端的限流信号

当下游返回明确的限流或过载信号时,重试策略必须让位于服务端的指令。最基本的一条是直接采用响应里给出的等待时长,而不是套用自己的退避公式——服务端的建议值来自它真实的队列长度,本地退避对这些一无所知。自研退避只在响应里完全没有信号时作为兜底,而且此时应该更保守而不是更激进,因为「没有信号」常常意味着对方已经没时间生成信号了。

更上一层是分布式限流协调。多个 Agent 实例同时被同一个上游限流时,各自为战的退避仍会形成同步尖峰,因为它们的退避起点是同一个上游返回时刻。可行的做法有两种:一是把上游的限流状态短暂缓存到共享存储,让所有实例共享同一个冷却窗口;二是引入长尾抖动的重试时刻分布,把请求打散在整个窗口上而不是聚集在窗口前段。两种都不复杂,但没有它们,重试预算只能约束单个实例的总量,约束不了集群峰值。

还有一条来自模型侧的信号值得注意:推理请求本身返回的过载错误,和工具返回的过载错误,对用户的含义完全不同。工具过载通常还有降级路径(用缓存、换数据源、缩小范围),模型过载往往意味着整个任务做不了。两类错误如果都被归类成「可重试」,就会出现一个很差的组合:工具过载期间模型疯狂重试,把本来能撑住的推理配额也耗光,最后哪里都过不去。

重试过程对用户可见

一次持续二十秒才成功的调用,用户体感是「卡住了」。可靠性在这里有一个纯粹体验层面的收益:如果重试带明确的进度反馈——正在第几次、为什么重试、还要等多久——二十秒的可感知损失会显著下降。实现成本很低,把原因和预计时间放进重试事件即可,不需要改交互设计。

反过来,如果重试注定要失败,尽快失败并说清楚比耗尽所有重试更友好。判断依据是「剩余预算是否还够至少一次成功」:够就重试,不够就直接返回结构化失败。这条规则把重试预算和用户等待时间两个目标对齐了,避免出现一个既没成功、又让用户白等三十秒的最坏组合。

退避与重试预算

指数退避加抖动的组合是常识,但真正决定风暴会不会发生的是重试预算这个概念:重试请求数必须被限制在总请求数的一个比例内,通常是百分之二十以内。超过预算就快速失败,让调用方降级,而不是继续加压。

抖动要用全抖动(full jitter),也就是在 [0, 上界] 区间上均匀采样,而不是在上界附近做小幅扰动。原因很直接:指数退避加小幅扰动在大规模下仍然会同步,因为所有客户端的退避曲线形状一样。只有把延迟随机摊开到整个区间,才能把尖峰压平。

实现:分级、重试预算与全抖动

# 重试的前提是幂等:先给工具分级,再决定退避与预算。
# 幂等键固定在意图上,而不是尝试上 —— 这是整套机制成立的前提。

import hashlib
import json
import random
import threading
import time
from dataclasses import dataclass

READ, IDEM_WRITE, IRREVERSIBLE = 'read', 'idempotent_write', 'irreversible'


@dataclass(frozen=True)
class ToolSpec:
    name: str
    kind: str
    p50: float
    p99: float


def classify(exc: Exception) -> bool:
    # 5xx / 429 / 超时可重试;4xx 业务错与参数错重试一万次也是错
    code = getattr(exc, 'status_code', None)
    return code is None or code == 429 or code >= 500


class RetryBudget:
    '总请求数里只有一小部分允许是重试,超出就快速失败。'

    def __init__(self, ratio: float = 0.2):
        self.rate = ratio
        self.tokens, self.last = 10.0, time.monotonic()
        self.lock = threading.Lock()

    def allow(self) -> bool:
        with self.lock:
            now = time.monotonic()
            self.tokens = min(20.0, self.tokens + (now - self.last) * self.rate * 60)
            self.last = now
            if self.tokens < 1.0:
                return False
            self.tokens -= 1.0
            return True


def normalize(args: dict) -> dict:
    # 剥离空白与不稳定字段,否则同一意图会算出不同的键
    return {k: (v.strip() if isinstance(v, str) else v)
            for k, v in sorted(args.items())
            if not k.startswith('_') and k not in ('trace_id', 'request_id')}


def idempotency_key(spec: ToolSpec, args: dict, intent_id: str, tenant: str):
    if spec.kind == IRREVERSIBLE:
        return None                     # 不可逆:不重试,交给补偿事务
    payload = json.dumps(normalize(args), sort_keys=True, ensure_ascii=False, default=str)
    raw = f'{tenant}|{intent_id}|{spec.name}|{payload}'.encode('utf-8')
    return hashlib.sha256(raw).hexdigest()[:32]


def backoff_delay(attempt: int, rng: random.Random) -> float:
    # 全抖动:把尖峰摊到整个区间,而不是在上界附近小幅扰动
    return rng.uniform(0.0, min(8.0, 0.25 * (2 ** attempt)))


def call_with_retry(spec, args, intent_id, tenant, invoke, budget, deadline, rng=None):
    rng = rng or random.Random()
    key = idempotency_key(spec, args, intent_id, tenant)
    for attempt in range(4):
        try:
            return invoke(args, key=key)
        except Exception as exc:
            left = deadline - time.monotonic()
            if not classify(exc):                   # 4xx 业务错不重试
                raise
            # 先判次数上限,再判幂等键,让「不幂等绝不重试」成为短路规则
            if attempt >= 3 or (key is None and spec.kind != READ):
                raise
            if left <= spec.p50 or not budget.allow():
                raise                               # 预算不足 -> 快速失败
            time.sleep(min(backoff_delay(attempt, rng), max(0.0, left - spec.p50)))

代码里那句 if attempt >= 3 or key is None and spec.kind != READ 的判断顺序值得展开。先判次数上限,再判幂等键,是为了让「不幂等就绝对不重试」这条规则在代码层面成为一条无法绕过的短路,而不是散落在多个条件里的一个可选项。这类规则一旦写成分散的条件,半年后重构时一定会漏掉一处。

另外注意退避睡眠的时长被 min 到了剩余预算之内。这里有个反直觉的结论:退避时间和调用时间一样花预算。很多实现在预算里只算了工具的 p99,没算退避,结果一次重试链的实际占用是三倍 p99 加两段退避,预算当场爆掉。

还有一个容易被忽略的成本维度:前缀缓存。当模型调用重试时,如果重试的提示词前缀与上一次完全一致,KV 缓存可能命中,重试的边际成本很低。这个特性会让「重试风暴」在账单上表现得不那么吓人,从而更难被发现。所以成本告警不能只看总量,要看单位 run 的平均推理次数。

外部副作用与补偿事务

重试解决不了不可逆工具的问题。发出去的邮件收不回来,扣掉的钱退不回来,能做的只有两件事:事前减少不可逆动作,事后用新动作把状态推回可接受区间。后者就是补偿事务,它是你唯一能用的手段。

先做减法:不可逆动作的排序

在引入任何补偿机制之前,先做一件成本最低、收益最高的事:把不可逆动作尽量排到编排的最后。如果一个任务需要发三封确认邮件和写一条数据库记录,顺序应该是「先写库、校验、准备好邮件内容,最后一步才发信」。这样前面任何一步失败,都没有需要补偿的东西。

这条原则可以直接落到规划器的提示约束里,也能落到编排框架的依赖声明里。更进一步的做法是给工具打上可逆性等级,让规划器在选择顺序时把高不可逆性的动作排到依赖图的最上层。有个反例要特别警惕:让模型并行发信和扣款,因为「反正都要做」。并行分支的补偿顺序几乎是不可解的,必须先把它们串起来。

补偿动作有强弱之分

把补偿笼统理解成「反向操作」会导致过度乐观。补偿的可靠程度差异极大,至少分三级,混在一起处理就会在补偿失败时不知所措。

级别特征处理方式典型例子
强补偿可精确撤销且天然幂等自动执行,失败可安全重试到预算耗尽取消尚未消费的任务、删除自己创建的草稿
弱补偿只能把状态推向接近一致自动执行一次,失败即转人工,不做无限重试把订单标为待人工确认、发一封更正通知
无补偿对外不可撤回且无替代动作以运行前规避为主,运行期只能告警与上报已扣款且超出对账窗口

分级之后有两个直接收益。一是自动补偿的失败处理终于有了依据:强补偿可以放心重试,因为重复执行没有代价;弱补偿重试一次就够,因为重复执行本身可能构成第二次伤害——重复发更正通知,比不补偿更糟。二是无补偿的动作可以在规划期就被前置规避,而不是等到运行时才发现无路可退。

还有一个实务上的坑:补偿动作的生效时间比补偿动作本身更重要。删除一条记录可能立刻生效,而发一封更正邮件要等对方看到,两者在一致性窗口里的意义完全不同。评估一个补偿是否够用,要看它多久能把外部可见状态拉回可接受区间,而不是看它是否会在系统内部成功。

补偿不是回滚

数据库的回滚是把状态恢复到某个历史点,补偿是向前执行一个新动作,让系统到达一个业务上可接受的近邻状态。两者差别很大,必须在设计阶段就说清楚。

差别体现在三处。第一,可见性:回滚在数据库内部不可见,补偿动作对外是可见的,补偿本身也可能失败。第二,可逆性不一定对等:退款可能失败、撤回消息可能超时。第三,时间窗口:补偿越晚执行,代价越大,扣了款十分钟才发现错了,退款流程可能已经过了对账窗口。

实践上要给每个副作用明确写出三行元数据:compensate 是补偿动作、compensatable_until 是补偿的有效截止时间、compensation_fail 是补偿失败后的处置(告警、转人工、还是接受不一致)。这三行写在工具注册表里,比写在编排逻辑里可靠得多。

台账必须先落盘再执行

补偿的前提是你知道做过什么。如果台账只存在于 Agent 的上下文里,那么进程崩溃、上下文截断、上下文压缩都会让它消失。台账必须与 Agent 状态同事务地持久化,并且遵循一个顺序:先把「我要做 X」写进台账,再执行 X,执行完把状态更新为已成功。

这个「写前意图」的顺序保证了崩溃恢复的唯一性:如果崩溃发生在写入台账之后、执行之前,恢复时你会看到一条 pending 记录,此时需要判断 X 是否真的执行过——判断不了就假设它执行过并触发补偿,宁可多补偿一次也不能漏。补偿动作必须幂等,才能承受这种多执行一次的情况。

实现:Saga 台账与逆序补偿

# 副作用台账:正向动作与补偿动作都要在「执行前」落盘。
# 顺序固定为 记录意图 -> 执行 -> 标记结果,三步缺一不可。

import asyncio
import time


class CompensationFailed(Exception):
    pass


class Ledger:
    def __init__(self, store, run_id):
        self.store = store
        self.run_id = run_id

    async def declare(self, step, intent, compensate=None, ttl=None):
        rec = {
            'run_id': self.run_id,
            'step': step,
            'intent': intent,
            'compensate': compensate,          # 补偿动作的可调用标识
            'compensatable_until': ttl,        # 过了这个时刻补偿就无意义了
            'status': 'pending',               # pending / succeeded / failed /
                                             # compensated / uncompensatable / manual
            'result': None,
        }
        await self.store.insert_if_absent(rec)
        return rec

    async def mark(self, step, status, result=None):
        await self.store.update_where(
            {'run_id': self.run_id, 'step': step},
            {'status': status, 'result': result},
        )

    async def settled_but_uncompensated(self):
        rows = await self.store.find({'run_id': self.run_id, 'status': 'succeeded'})
        return [r for r in rows if r['compensate']]


async def resume_and_compensate(ledger: Ledger, registry, actor):
    # 恢复入口:先读台账,再决定继续还是补偿。不要凭上下文猜。
    rows = await ledger.store.find({'run_id': ledger.run_id})
    rows.sort(key=lambda r: r['seq'], reverse=True)   # 逆序补偿,后做的先撤

    for rec in rows:
        if rec['status'] in ('compensated', 'failed', 'uncompensatable'):
            continue
        comp = registry.get(rec['compensate'])
        if comp is None:
            await ledger.mark(rec['step'], 'uncompensatable')
            continue
        if rec['compensatable_until'] and time.time() > rec['compensatable_until']:
            await ledger.mark(rec['step'], 'manual')   # 过期补偿只能转人工
            continue

        for attempt in range(3):
            try:
                await actor.call(comp, args=rec['result'])   # 补偿自身必须幂等
                await ledger.mark(rec['step'], 'compensated')
                break
            except Exception as exc:
                if attempt == 2:
                    await ledger.mark(rec['step'], 'manual')
                    raise CompensationFailed(rec['step']) from exc
                await asyncio.sleep(0.5 * (2 ** attempt))

恢复逻辑里那行 rows.sort(key=lambda r: r['seq'], reverse=True) 看着不起眼,实际项目里经常被漏掉。逆序补偿是 Saga 的定义性要求:先发信后扣款,撤销时必须先退钱再撤通知,因为通知里的内容依赖扣款这个事实,顺序反了会出现「已退款但用户没收到通知」这种难以解释的状态。

还要注意 'pending' 状态的处理。上面代码里 pending 记录被排除在补偿之外,因为在没有结果的情况下无法确定要不要补偿;更严谨的做法是把 pending 视为「可能已执行」,用查询类工具去核对实际状态再决定。把这类分支显式写出来,比留给下游隐式处理可靠。

关于台账存哪儿,一个务实的选择是:如果你的编排框架已经提供了持久化检查点(比如把每步状态写入外部存储并支持从任意检查点恢复),那么台账可以退化为检查点本身的一个字段,而不必单独建表。但要确认这个检查点满足两个条件:写入时机是每个副作用之前,恢复粒度是单个步骤。很多框架的检查点粒度是整个 run 的某一步骤,这不够。

对账:补偿机制的兜底一层

补偿是事件驱动的——有失败才有补偿。这意味着它只能处理「被观察到」的失败。如果台账写入本身丢了、或者进程在两个关键步骤之间崩溃导致状态永远停在 pending,补偿就永远不会被触发。这类「静默失败」是所有机制里最难防的一类,因为它不会产生任何日志。

对策是定期对账:周期性扫描台账,找出所有处于异常状态的记录并核对服务端实际状态。需要重点捞出的有四类——状态长期停在 pending 且已超时的(可能是崩溃遗留)、状态为成功但超过有效期仍无补偿结论的(补偿被漏掉)、补偿被标记为失败但从未重试的(人工交接遗漏),以及服务端状态与台账记录矛盾的(说明有一次写入既没记成功也没记失败)。

对账的成本主要在于「查服务端实际状态」这一步,所以要按记录的类型分级:对幂等写入,可以直接用幂等键查询,成本很低;对不可逆动作,往往需要向对方系统发起核对,成本较高。因此对账的频率可以按记录类型区分——幂等类的可以频繁跑,不可逆类的可以按天跑。

发现异常之后的处置也要分级。能自动修的(比如服务端其实根本没执行,直接把台账标记为失败并释放配额)就自动修;需要判断的(比如服务端执行了但结果未知)转人工,并且把完整的判断所需信息打包好,和前面讲的人工交接用同一套格式。

最后强调一点:对账不能替代补偿,只能兜底。因为对账的发现延迟通常在分钟到小时级,而补偿的价值几乎全部来自及时性——用户对「十分钟后被撤销」和「当场就被撤销」的接受度完全不同。所以对账的定位是最后一道安全网,用来保证系统最终不留下永久的不一致,而不是正常路径下的纠错手段。

循环卡死与熔断

Agent 的循环不收敛有三种截然不同的形态,用一种检测手段只能覆盖其中一种。这三种形态值得分开对待,因为它们对应的根因不同。

形态一:真重复

同一个动作、同一组参数被反复执行。这类最好检测,做动作指纹哈希计数就行。它的根因通常是工具返回了模型无法消化的结果,比如空列表或错误码,模型就不断重试同一条路径。

形态二:无进展

动作在变,措辞在变,但状态摘要没有变化。模型在原地打转:换三个说法查询同一个页面,每次都得到同一个结果,都判定「信息不足」,然后继续查。这是最耗成本的一种,因为每一步都在真实调用工具、真实消耗推理,检测却最难。

检测办法是对状态摘要做哈希:如果连续若干步的状态摘要完全相同,说明外部世界没有变化,无论模型怎么换动作都不会有新信息,应该直接停并向用户报告「当前信息不足以推进」。这里的状态摘要要覆盖模型的真实进展依据,比如已读文件的哈希、已查工具的返回摘要、待办列表的内容,而不只是对话文本的哈希。

形态三:预算耗尽仍不收敛

循环本身有变化,但始终不完成任务。这类只能靠硬预算拦住。关键判断是预算触顶时的行为:不能是静默截断然后给一个看起来完整的答案,那是在骗用户;必须是显式失败,且把已经完成的部分和未完成的部分都交出来。

步数预算不该是全局常数

把最大步数写成一个全局常量,本质上是承认自己没有对任务难度建模。更可行的做法是让预算随任务分型变化:从一句明确指令出发的单步查询可能两步就够,跨三个系统核对的核对类任务可能要二十步,开放式的研究类任务可能上百步仍不保证收敛。如果暂时没有任务分型,至少可以让预算自适应——按任务类型统计历史收敛步数分布,取一个分位数再加余量作为该类任务的预算。

自适应的风险是容易被异常值带偏。一个任务触顶后如果处理方式是简单地把预算调大,就会形成「越难越给钱、越给钱越不收敛」的循环。稳妥做法是分位数取中位数到高分位之间,并且给单任务预算设绝对天花板,同时把触顶率作为长期指标盯着看。触顶率持续上升说明预算体系本身有问题,而不是说明任务变难了——后者通常伴随成功率同步上升,前者不会。

跨 run 的健康度记忆

单 run 内的熔断只保护当前这一条轨迹。真正有价值的是把工具健康度记在 run 之外,让新 run 一开始就知道「这个工具刚刚连续失败了八次」。这个记忆体通常是共享缓存加滑动窗口计数,成本很低,但对体验的改善是直接的:新任务不必先撞一遍墙再绕路,也能省下那一串无用的试探调用。

健康度记忆还应当参与规划。在工具描述里动态附加一句「近期不稳定」,让规划器在有替代路径时优先选择稳定工具,相当于把运维视角的信号注入了推理视角。这比在执行层硬拦要柔和得多,也更符合 Agent 的工作方式——模型本身很擅长在信息充分时做出正确的取舍,问题只在于它默认所有工具同样可靠。

熔断的状态要有用户可见的解释

熔断触发时,最终反馈给用户的说法不该是「任务失败」,要分两种情况处理。如果任务可以降级完成,就明确说「部分信息来自实时查询,其余来自缓存,因为实时查询暂时不可用」;如果任务必须依赖该工具才能完成,则要说清「实时查询服务当前不可用,建议稍后重试,已完成的部分不会重复执行」。后一句尤其重要,它同时传递了熔断状态和幂等保证,能显著降低用户重试时的顾虑,也顺带完成了可靠性机制的用户教育。

熔断器:给工具装上限

循环检测管的是单个 run 内部,熔断器管的是跨 run 的同一个依赖。两者都要有:一个 run 内换了三个工具绕圈,靠熔断器拦不住;而某个下游服务整体劣化时,逐个 run 各自重试,等于把故障放大 N 倍。

标准三态熔断器适用于工具:关闭态按失败率阈值累计并打开;打开态直接快速失败并经过冷却窗口;半开态放少量探测流量。Agent 场景需要两处调整:一是失败计数要把同工具连续失败单独算,因为交替调用两个都在坏的工具会稀释失败率;二是打开态的错误反馈要结构化地告诉模型「这个工具暂不可用,不要重试」,否则模型会自己反复试探,把熔断又顶回去。

实现:指纹、重复检测与三重预算

# 死循环不是异常,是「有进展地重复」。三种症状分别用三种检测。

import hashlib
import json
import time


class StepBudgetExhausted(Exception):
    pass


class LoopDetected(Exception):
    pass


class NoProgress(Exception):
    pass


class LoopGuard:
    def __init__(self, max_steps=40, max_repeat=3, window=4):
        self.max_steps = max_steps
        self.max_repeat = max_repeat
        self.window = window
        self.steps = 0
        self.fingerprints: list[str] = []
        self.state_log: list[str] = []

    @staticmethod
    def _digest(value) -> str:
        body = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
        return hashlib.sha256(body.encode('utf-8')).hexdigest()[:16]

    def _action_fp(self, tool: str, args: dict, state_digest: str) -> str:
        return self._digest({'t': tool, 'a': args, 's': state_digest})

    def observe(self, tool: str, args: dict, state_digest: str):
        self.steps += 1
        if self.steps > self.max_steps:
            raise StepBudgetExhausted(
                f'step budget {self.max_steps} exhausted at {tool}'
            )

        fp = self._action_fp(tool, args, state_digest)
        self.fingerprints.append(fp)
        self.state_log.append(state_digest)

        # 形态一:同一动作在同一个状态下反复出现
        repeats = self.fingerprints.count(fp)
        if repeats > self.max_repeat:
            raise LoopDetected(f'tool={tool} repeated {repeats}x on same state')

        # 形态二:动作在变,但外部状态摘要连续不变 -> 无进展
        recent = self.state_log[-self.window:]
        if len(recent) == self.window and len(set(recent)) == 1:
            raise NoProgress(f'state digest unchanged for {self.window} steps')


class ToolBreaker:
    '三态熔断:closed / open / half_open。反馈给模型时必须结构化,
     否则模型会自己反复试探,把刚打开的熔断又顶回关闭态。'

    def __init__(self, fail_threshold=5, cooldown=30.0, half_open_probes=2):
        self.fail_threshold = fail_threshold
        self.cooldown = cooldown
        self.half_open_probes = half_open_probes
        self.state = 'closed'
        self.fails = 0
        self.opened_at = 0.0
        self.probes = 0

    def allow(self) -> bool:
        if self.state == 'closed':
            return True
        if self.state == 'open':
            if time.monotonic() - self.opened_at > self.cooldown:
                self.state, self.probes = 'half_open', 0
                return True
            return False
        if self.state == 'half_open':
            if self.probes < self.half_open_probes:
                self.probes += 1
                return True
            return False

    def on_success(self):
        self.state, self.fails = 'closed', 0

    def on_failure(self):
        self.fails += 1
        if self.state == 'half_open' or self.fails >= self.fail_threshold:
            self.state = 'open'
            self.opened_at = time.monotonic()

「无进展」检测里最关键的设计选择是状态摘要的覆盖范围。只哈希对话历史会把模型的冗余复述也当成进展,从而永不触发;只哈希工具返回值又会漏掉模型在推理层面取得的进展。比较合适的做法是摘要三样东西:已获得的外部事实集合、待办列表的变化、以及已尝试动作的集合。这三样都变了才算真进展。

三重预算里还有一个常被忽略的维度:成本预算。步数上限控制不住单步昂贵的情况,一个每步十万 token 的 run 走二十步,步数没超,钱已经花完了。把 token 数和美元成本作为与步数并列的硬预算,并且和墙钟预算一样在触顶时显式失败,是 2026 年 Agent 生产化的基本要求。

熔断打开后怎么处理,有个务实的建议:不要静默失败,也不要让模型硬试,而是把它变成规划输入。给模型一个明确说明「工具 X 暂时不可用」的结构化错误,并提示它可以选择降级路径或向用户说明。这样熔断既阻断了故障放大,又保留了 Agent 的自主降级能力。

模型侧的故障:不是超时,却同样致命

前面几节都在处理「工具和基础设施会失败」的问题,但 Agent 还有一类故障完全发生在模型这一侧:它没有超时,没有报错,只是做错了。这类故障不需要混沌测试就能复现,却往往比网络抖动更难被发现,因为系统看起来一切正常。

输出截断与结构崩塌

最常见的一类是结构化输出在生成途中被截断。原因可能是达到了最大 token 长度,也可能是流式连接中断。表面上表现为解析 JSON 失败,实际上是半成品被当成失败。这里的关键判断是:解析失败和生成失败必须区分开。前者说明模型可能已经想清楚了,只是没说完;后者说明模型本身没给出可用输出。前者的正确处理是续接生成或降低输出要求,而不是重头再来一次。

实践中最有效的做法是在提示里显式约束输出体量,并为解析失败准备降级解析路径——比如从截断的 JSON 里抢救出已经完整的字段。这不是优雅的方案,但在真实流量里它的收益远高于一次昂贵且不确定的重试。

工具幻觉:调用不存在的东西

模型会调用工具注册表里根本没有的工具名,或者给一个真实工具传入不存在的参数。这类故障在工具名与真实函数名不一致时尤其高发:描述里的名字和实际注册的名字差一个字,模型就会按描述里的那个来调。

防护必须在执行之前完成,而不是在异常里兜。校验分三层:工具名是否在注册表中、必填参数是否齐全、参数类型与取值范围是否合法。三层都在编排层做,不要指望下游服务返回 400 再由模型自我修正——那是一次昂贵的往返,而且模型有相当概率会原样再发一次。更彻底的做法是工具名在提示和注册表之间建立单一来源,从生成注册表文档而不是手写,消掉不一致的物理来源。

错误事实的累积

这是 Agent 特有且最隐蔽的故障:工具报错之后,模型把错误信息当成事实写进了推理,并基于它继续规划。比如查询返回「无权限」,模型可能把它理解成「数据不存在」,然后据此给出一个实际上有偏的结论。再比如一次写入超时,模型可能假设写入成功并继续后续步骤,而实际什么都没发生。

对付它的手段是把「事实」和「未决」严格分开。工具返回的结构里必须有一项显式标注本次调用的状态,模型在推理时只能把状态为成功的返回当作事实,未决或失败的返回只能当作待处理项。这个约束如果只是写在提示里,遵守率并不高;更可靠的做法是让框架在拼装下一轮上下文时把失败调用替换成结构化的占位说明,而不是把原始错误文本原样透传。原始错误文本的信息量对模型是过剩的,而它的歧义恰好是幻觉的原料。

参数幻觉与前置校验

比缺参数更隐蔽的是看起来合理但实际不存在的参数值:让模型填一个订单 ID,它可能从上下文里抄一个格式相似的,或者按某种规律编一个。这类错误下游往往无法判断,只能当作「查无此单」返回,于是模型再试一次、再编一个,进入一个既慢又不收敛的循环。

有效的防护是把校验尽量前移到能确定的地方:参数必须来自一次工具返回的集合,而不是模型自由生成;必须满足某个可校验的模式,比如固定的格式或长度;必须能通过一次存在性检查。前置检查的失败应当返回一个结构化的澄清请求而不是错误,让模型去补全而不是重猜。对于金额、时间这类字段,宁可用一个显式的选择器让用户或系统提供,也不要让模型手写。

超时之后:从断点续接,而不是重来

超时是最容易被当成「这次失败了」处理的信号,但它真正的语义是不知道结果。调用可能没发出去,可能执行到一半,可能已经完成只是响应没回来。这个不确定性决定了超时的正确处理方式与明确的业务错误完全不同。

超时是一类不确定结果

对不确定结果有三种处理,对应三类工具。对只读工具,直接重试是最优解,因为重复执行没有代价。对幂等写工具,先用幂等键查询上一次的结果,拿到就用,没拿到再重试——这一步比直接重试更稳,因为它避免了一次不必要的重复执行。对不可逆工具,必须先查状态再决定,必要时进入补偿流程,绝不能盲重。

把「超时」和「明确失败」归到同一个错误类别,是很多实现里隐藏最深的一处错误。它看起来只是一行分类代码,实际后果是系统对不可逆工具做了本不该做的重试,而这类 bug 不会在测试环境出现,因为测试环境通常不超时。

续接的三种粒度

一次 run 超时之后,续接有三个可选粒度,代价和收益差别很大。

最轻的是从当前步重来:已完成的步骤靠幂等键跳过,从超时的那一步重试。实现最简单,绝大多数场景够用。第二种是从检查点续接整个 run:把上下文、已完成动作、预算余额全部持久化,恢复时重新装载。对于耗时很长的任务这是唯一选择,因为重新规划一遍既昂贵又可能改变结论。

第三种是降级交付:不再追求完成,改为把已完成的部分组织成结果交给用户,并说明剩余部分。它常被当成失败处理,其实是长任务在预算耗尽时最合理的结局。对交互式产品来说,及时给出一份标注了缺口的答案,价值远高于一个仍在转圈的进度条。

续接必须让用户知道

只要续接发生过,返回结果里就应该有明确的续接标记:这次是从哪一步继续的、之前跳过了什么、有没有可能重复执行过副作用。用户看到「已从第三步恢复,前四步不会重复」和看到一个和首次完全一样的返回,信任程度完全不同。后者会让人不敢点重试,于是问题悬在那里。

还有一个容易忽略的点:续接要沿用原来的意图标识,而不是新的。如果续接时生成了新的意图标识,幂等键就变了,前面已经成功的步骤会被重新执行一遍,整个续接机制的意义就完全反过来了。这条约束应当由框架强制,而不是靠调用方记得传对参数。

资源型故障:子进程、句柄与泄漏

Agent 相对普通服务还有一类系统性的故障模式:资源泄漏。它不属于超时、不属于重试、不属于幂等,但同样会导致系统逐渐不可用,而且症状往往被误判成「负载太高」。

Agent 特有的泄漏来源

三个来源最常见。第一是工具调用遗留的子进程:执行命令类工具启动了一个长跑任务,超时后调用返回了,进程还在跑。如果工具是并发执行的,几轮循环下来就攒下大量僵尸进程,把机器的 CPU 和内存吃光。第二是文件句柄与临时文件:工具打开的文件、下载的中间产物、浏览器会话,都要在异常路径上被关闭;只处理正常返回路径的实现,在失败率上升时会同时泄漏。

第三是会话与连接:每次工具调用新建连接但不复用、不归还池子,高并发下连接数会单调增长直到打满文件描述符。第四个容易被忽略的是上下文里的资源引用:把大文件、整页 HTML 直接塞进上下文,几步之后上下文就撑爆了,随之而来的截断会让模型行为一起劣化。

租约与回收

对付泄漏的通用手段是租约:任何资源在创建时登记一个租约,租约有过期时间;清理器定期扫描并回收过期租约。这比依赖调用结束时的清理可靠得多,因为它对异常退出和超时都有效。

租约登记要覆盖全部资源类型,包括子进程、临时文件、打开的句柄、租出去的下游配额。配额这一项特别值得强调:Agent 场景下的配额经常是按 run 分配的,如果 run 因为崩溃没有正常结束,配额就泄漏了,长期看等于把整个租户的额度慢慢耗光。这类故障的特征是「某个租户的用量莫名增长但看不到调用」,排查起来极其困难。

给工具调用加资源护栏

护栏可以加在适配器层,规则相对固定:子进程必须放进进程组,超时后杀掉整个组而不是只杀主进程;临时文件登记到租约表并设置清理路径;每个工具调用设置独立的内存与文件大小上限;返回内容超过阈值时自动截断并附上明确说明,而不是把完整内容原样透传给模型。

其中「返回内容截断」值得单独强调,因为它同时是可靠性问题和质量问题。一个返回了十万行日志的工具,会挤爆上下文、拖慢推理、并让模型在一堆无关信息里找那一行有用的。可靠的护栏应该优先保留结构化的关键字段(错误码、状态、目标字段),把大段正文折叠成占位说明,并告诉模型「详情已折叠,需要时可按需查询」。

成本也是一类预算

把成本单独拎出来,是因为它经常被当成性能优化的副产品,而实际上是可靠性的硬约束之一。一个消耗预算失控的 Agent,即使每一步都成功,最终结果也是失败的——它烧掉了预算、占用了配额、并且让同一租户的其他用户排队。

重试的边际收益是递减的

第一次重试的期望收益通常很高:它处理的是瞬时抖动,成功率很高。第二次和第三次递减明显,因为能通过两次重试的请求往往是真实故障而不是抖动。但很多实现的退避策略恰好在这里出错:它按错误类型决定要不要重试,而不是按已经重试了几次。正确的做法是让重试次数本身成为决策的一部分,并且把「重试成本」显式计入预算,让第二次重试在第一次已经花掉大部分预算时自动被否决。

更根本的判断是:一个调用值得重试,当且仅当它重试成功的概率乘以它的价值,大于它消耗的预算。对一次价值十块钱的操作重试五次显然不划算,而对一次价值十万的转账就值得。这个判断可以做成一个简单的期望值函数,甚至不需要很精确,大致量级对了就能避免明显的浪费。

把成本写进目标

实践中最有效的做法是给每类任务设一个成本预算,并在预算消耗到一定比例时改变行为:比如用掉一半预算还没收敛,就提示规划器简化剩余步骤;用掉八成,就停止探索只做收尾。预算耗尽时的降级路径必须提前设计好,因为等到那一刻已经没有时间做复杂的决策了。

另一个相关指标是单位成功的成本,而不是单次调用成本。它把重试、空转循环和失败尝试的开销全都算进去,因此更接近真实经济性。一个团队可能看到单次调用成本很低,却在账单上发现每完成一个任务的成本是预期的五倍,差额就藏在重试和无进展循环里。

什么时候必须让人接手

再完善的自动机制也有它的终点,需要一条清晰的升级路径。把「让人接手」当成兜底而不是失败,意味着要在自动机制内部明确哪些情况不再尝试。

升级的三档触发条件

第一档是不可逆且无补偿:动作已经对外生效且无法撤销,或补偿已超出有效期。这类情况应当立即停止自动流程,转人工,不再做任何试探性的重试或补偿尝试。继续自动化的唯一结果是扩大损失。

第二档是重复失败:同一目标连续多个不同策略都失败,或者熔断反复打开。这类问题的根因通常在框架之外——配置错误、权限缺失、下游接口不兼容——自动机制解决不了,继续尝试只是消耗预算。判据要基于「不同策略的失败数」而不是「总失败数」,否则一个坏策略会被无限重试。

第三档是不确定性超出承受范围:补偿会成功但业务上不能接受,或者继续下去有法律或合规风险。这一档的判断需要业务知识参与,所以系统要能够把决策所需的全部信息整理出来交给人,而不是只丢一句「失败了」。

交接包应该包含什么

交接质量决定了人工接手的成本。一个合格的交接包至少要有:已经发生了什么的完整时间线(每个动作、入参摘要、结果)、当前的确定与不确定项(哪些确认生效、哪些状态未知)、已尝试和未尝试的策略、以及每个在途动作的账(有没有可能还在跑、要不要先取消)。

其中「有没有可能还在跑」这一项最容易被漏掉。一个超时但可能仍在执行的子进程或异步任务,如果不在交接包里说明,人工接手后很可能重复执行。正确的做法是把在途资源的租约信息一并交出去,让接手的人知道该先检查什么。

可靠性的回归验证

前面讲的机制都容易被后续的重构悄悄破坏,而这种破坏通常不会在功能测试里暴露。所以可靠性本身也需要一套回归验证。

故障场景集要版本化

做法是把混沌测试的场景集合当成代码来维护:每个场景有编号、描述、期望的不变量结果,并且随着发布走版本。这样做的价值在于,当某次发布导致熔断不再触发、或者补偿不再执行时,回归测试会立刻发现,而不是等到线上出事。

场景集应该覆盖前面提到的所有注入层次,并且刻意包含那些组合场景——「工具成功但返回失败、同时下游限流、同时进程崩溃」这种三件事一起发生的组合,才是真正考验机制的时刻。单独测每一项都通过、组合起来失败的情况在实践中非常常见。

退化信号的判读

可靠性指标会退化,退化本身需要被正确解读。最容易误判的是某个指标突然大幅改善——比如熔断触发率降到零,通常不是系统变好了,而是阈值被人临时调高了,或者健康度记忆过期导致统计口径变了。可靠性指标的改动必须和代码改动一起评审,否则你会失去对系统的基本感知。

另一个判读原则是看比值而不是看绝对值。熔断触发次数从十次涨到一百次可能只是流量翻倍,而重试放大系数从一点二涨到一点五则意味着真实退化。前面列的那组比率型指标之所以重要,就是因为它们对流量变化不敏感,能在绝对值还没动的时候先发出信号。

部分失败:继续还是回滚

到了决策环节,核心问题只有一个:已经生效的副作用该留着还是撤销。答案取决于三个维度的组合——可逆性、幂等性、以及失败能否重试。把它们交叉起来,决策空间其实很有限。

已生效步骤的性质失败点可重试失败点不可重试
只读,无副作用继续,通常无需用户介入降级:用已有信息作答并标注缺口
幂等写继续:重放前序步骤会被键折叠回滚或标记部分成功,交由用户决定
有补偿动作的写继续,补偿暂不触发补偿:逆序执行补偿并报告结果
不可逆且无补偿尽量避免;已发生则立即告警转人工,绝不自动重试

默认策略要偏保守

这张表读起来复杂,但落到代码里应该只有少数几条分支。一条好用的默认规则是:只读前缀可以自由继续;一旦进入写路径,后续任何失败都不允许静默继续,必须显式落到部分成功状态。原因在于只读失败不改变世界,而写路径失败后如果继续,Agent 会在一个自己已经误判的状态上继续操作,把单点失败放大成连锁错误。

「部分成功」这个状态必须对用户可见,而且要具体。返回给用户的内容至少包含三块:已完成的动作清单、失败的动作及其原因、以及这个状态是否可恢复。让用户看到「已完成 3 步,第 4 步超时,你可以直接重试(不会重复前三步)」,比看到一个笼统的失败提示有用得多,也比让用户以为什么都没发生要诚实得多。

把部分成功如实报告出去

部分成功需要一个固定的报告结构,而不是让模型自由发挥。至少包含四块:已完成的动作及其关键结果,让用户知道系统真的做了什么;未完成的动作及原因,让用户判断能否接受;当前状态是否可恢复,也就是能不能直接重试而不重复副作用;以及推荐的处置。缺了第四块,用户最自然的反应就是重试,而重试语义必须与之前完全一致,否则前功尽弃。

这个结构还有一个常被忽略的作用:它把部分成功从一种异常状态变成一种可被下游识别的正常状态。如果返回值里有一个明确的状态字段和已完成动作的机器可读列表,调用方就能在自己的界面上做出正确反应,而不需要从自然语言里猜测发生了什么。这对把 Agent 嵌进已有业务流程的场景尤其重要,因为下游系统通常没有能力理解一句「已完成大部分操作」到底意味着什么。

反过来,有一类做法要明确避免:把部分成功包装成成功。比如在返回里加一句「已完成大部分操作」,但状态字段仍然是成功。调用方会据此推进后续流程,等于把一个已知的不一致推迟到更远的地方爆发,而且届时更难归因。可靠性工程里,一个诚实的失败加上一个可恢复的状态标记,价值远高于一个看起来成功的假象——后者的问题不是不好看,而是它让错误失去了可被处理的形态。

检查点决定恢复的粒度

回滚能不能做,取决于你能不能精确回到那一步。这就要求检查点的粒度与副作用的粒度对齐。一个 run 二十步、第五步失败要回滚,如果检查点只记录在第十步,你就只能从头重来——而从头重来在有非幂等副作用时是灾难。

具体做法是:每个外部副作用落盘后立即写检查点,检查点里记录幂等键、调用参数、结果摘要、以及对应的补偿标识。重放时以检查点为界跳过已完成的步骤,靠幂等键保证重放安全。这一套组合起来才是完整的「可恢复执行」,也是当前主流编排框架在补的能力方向。

跨工具的并发与限流

Agent 的并发有一个普通服务没有的性质:扇出规模由模型决定,且缺乏先验上界。你可以在代码里写死一次最多并发八个,但如果模型规划出三十六个子调用,限流必须发生在工具出口,而不是入口。

限流的层次:全局优于单工具

逐个工具限流在 Agent 场景里几乎无效。同一个下游服务可能被三十个不同工具名访问,逐工具令牌桶各放行五个,实际并发就是一百五十。正确的做法是把令牌桶挂在下游资源标识上,而不是工具名上,多个工具映射到同一个资源就共享一个桶。

这需要一个工具到资源组的映射声明,比如「搜索工具」和「向量检索工具」都映射到检索集群,「写库工具」和「读库工具」都映射到主库。只声明工具名的限流配置,在工具数量增长之后一定会被绕过。

自适应并发优于固定上限

固定并发上限有个众所周知的问题:上限设高了会在下游劣化时压垮系统,设低了会在下游健康时浪费吞吐。自适应并发把端到端延迟当成并发上限的反馈信号——延迟开始上升说明队列已经开始堆积,此时继续放人进来只会更糟;延迟平稳说明还有余量,可以逐步放大。这是从排队论出发的经典做法,Envoy 的自适应并发过滤器以及服务网格里的相关实现都是这个思路。

在 Agent 里的具体收益更明显:一次 run 可能包含大量形态各异的调用,固定上限要么卡住慢工具要么放飞快工具,而自适应能在慢工具上自动收缩、在快工具上自动放开。

限流要与模型侧配额联动

一个容易被忽略的耦合是:工具侧的限流和模型侧的配额消耗,是同一条链路上的两个阀门。如果一次扇出会同时放大推理成本和下游压力,那么只压住其中一个会把压力转移到另一个上。例如把工具并发从八降到四,如果规划逻辑不变,模型侧的 token 消耗并不会下降多少,只是每一步等得更久,整体链路反而可能因为等待而更慢。

更根本的做法是从源头减少扇出:把「逐个查三十条」改成「批量查一次」,把「逐个改」改成「批量提交」。这类改动收益是结构性的,不依赖任何限流机制就能生效。相比之下,调并发上限只是把同一个总量摊开到更长的时间里,用户体验变差而总工作量没变。所以在动限流参数之前,先确认自己没有在用并发上限掩盖一个本该用批量接口解决的问题。

还有一个联动维度是成本预算。当剩余成本不足以支撑当前规划时,正确的动作是缩小任务范围而不是排队等待:一个「导出全部记录」的任务如果预算只够三分之一,要么改成导出最近三十天,要么拆成三次让用户分别触发。排队等待的做法在多租户环境下会迅速演变成公平性问题——预算大的任务把队列占满,预算小的任务永远排在后面,而后者往往是更紧急的交互式请求。

限流和降级之间还存在一个容易被忽略的顺序问题:先告诉用户你在降级,再执行降级。很多实现在触发降级时静默地把实时查询换成缓存,结果用户拿到一份过期数据却以为是最新的。把降级标注放进返回值,和部分成功报告是同一套机制,这两件事应该共用一个结构而不是各写一遍。

扇出收敛与写冲突

除了限流,还要在规划阶段限制扇出规模。三个层次的手段:从提示层面约束一次返回的动作数量;在编排层给并行度设硬上限并把超出的部分排队;在执行层把同一资源的多步操作合并成一个批量工具。前两层是策略,最后一层是工程,最有效但成本最高。

写冲突是另一个正交问题。同一资源的写必须串行,否则会出现经典的丢失更新。Agent 里这个问题更容易发生,因为「先查再改」的推理模式被自然地写进了动作序列:模型查了库存、改了价格、顺便又把库存写成查到的旧值。防护手段有三种:乐观并发(带版本号写,冲突则重试并重新读取)、悲观锁、以及单写者模式(所有写操作进同一条队列,由一个消费者执行)。对于财务、库存这类场景,单写者加乐观版本号是最省心的组合。

实现:资源级令牌桶与自适应并发

# 并发控制挂在「下游资源」上而不是「工具名」上。
# 同一个集群被多个工具访问时,共享一个桶才拦得住扇出。

import asyncio
import time
from collections import defaultdict


class TokenBucket:
    def __init__(self, rate: float, burst: float):
        self.rate = rate
        self.burst = burst
        self.tokens = burst
        self.ts = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self, n: int = 1) -> None:
        while True:
            async with self._lock:
                now = time.monotonic()
                self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
                self.ts = now
                if self.tokens >= n:
                    self.tokens -= n
                    return
                deficit = n - self.tokens
                wait = deficit / self.rate
            await asyncio.sleep(wait)          # 队内等待,而不是直接拒绝


class Governor:
    def __init__(self, resource_rates):
        self.buckets = {r: TokenBucket(rate, burst=rate)
                        for r, rate in resource_rates.items()}
        self.concurrent = defaultdict(int)
        self.limit = 8
        self.min_limit, self.max_limit = 1, 64
        self.ewma_latency = 0.05
        self._sem = asyncio.Semaphore(self.limit)

    async def run(self, resource: str, tool_fn, *args, **kw):
        await self.buckets[resource].acquire()
        async with self._sem:
            self.concurrent[resource] += 1
            t0 = time.monotonic()
            try:
                return await tool_fn(*args, **kw)
            finally:
                self.concurrent[resource] -= 1
                rtt = time.monotonic() - t0
                # 自适应:延迟抬升就收缩并发,平稳就缓慢放开
                self.ewma_latency = 0.9 * self.ewma_latency + 0.1 * rtt
                if self.ewma_latency > self.limit * 0.25 and self.limit > self.min_limit:
                    self.limit = max(self.min_limit, int(self.limit * 0.9))
                    self._sem = asyncio.Semaphore(self.limit)
                elif self.limit < self.max_limit:
                    self.limit += 1
                    self._sem = asyncio.Semaphore(self.limit)

上面 Governor 里的自适应逻辑有一处示意性简化:把信号量整体换掉会让在途任务短暂失去约束。生产实现应该在释放时按新上限决定是否补充许可,而不是重建信号量。这里保留简洁是为了突出反馈逻辑本身:延迟是排队压力的观测值,也是唯一的调节依据。

多租户场景还要额外做公平性。一个跑了二十分钟的长任务如果持有大量并发许可,会把其他租户饿死。可行的做法是按租户分桶再加一层轮转调度,或者给单次 run 的总许可数设上限并按用户优先级分配。这一层在多租户 SaaS 形态的 Agent 平台上几乎必需,在内部工具场景可以先不做。

混沌测试:证明这些机制真的有效

上面五节的所有机制都有一个共同的问题:它们在正常路径上完全不可见。幂等键写对了不会有任何反馈,补偿逻辑正确执行也只是一行日志。不注入故障,你无法知道重试预算是不是真的拦住了、熔断是不是真的打开了、补偿是不是真的覆盖了全部已生效的写入。

所以混沌测试在 Agent 场景不是锦上添花,而是这些机制唯一的验证手段。关键认知是:混沌测试断言的是不变量,不是通过率。「请求成功了」不是有用的断言,因为在注入大量故障时成功率本来就不该高;「每个幂等键最多产生一次副作用」才是。

注入点的四个层次

按从外到内的顺序,注入点分四层,每层验证的东西不同。

网络层:用本地代理或中间件注入超时、连接重置、响应截断、5xx。这一层最接近真实,能同时验证超时预算和重试逻辑。

工具适配器层:在工具调用前后插桩,制造「执行成功但返回失败」这个最难处理的场景。这一层专门验证幂等键,没有别的办法能测出它。

模型网关层:注入限流、上下文超长、输出截断、以及返回结构不完整的 JSON。输出截断会逼出很多解析层的脆弱假设,值得专门覆盖。

编排与存储层:注入进程崩溃、存储短暂不可用、检查点写入失败。这一层验证台账的写前落盘顺序和恢复逻辑,是补偿机制的验收关。

必须写下来的不变量

不变量是混沌测试的核心资产,建议集中维护成一份可执行的清单。下这几条覆盖了前面所有机制:

  • 每个幂等键最多产生一次外部副作用;
  • 步数、墙钟、token 三重预算都没有被击穿,且超限时是显式失败而非静默截断;
  • 每一个状态为成功的副作用,最终都落到「已补偿」或「不可补偿」或「转人工」三选一,没有悬空记录;
  • 没有未处理的异常逃逸出 run 边界,所有失败都被转成了结构化错误;
  • 熔断打开之后,同一 run 内不再有该工具的调用;
  • 补偿动作的逆序性:补偿的执行顺序与正向动作严格相反;
  • 并发峰值不超过配置上限,且没有单租户饿死。

实现:不变量检查器

# 混沌测试断言的是不变量,不是「没报错」。
# 这几条就是超时/重试/补偿三层机制是否真的生效的判据。

from collections import defaultdict


class InvariantViolation(AssertionError):
    pass


def check_invariants(txn) -> None:
    # 1) 幂等:同一个键最多产生一次外部副作用
    by_key = defaultdict(int)
    for e in txn.effects:
        if e.idempotency_key:
            by_key[e.idempotency_key] += 1
    dup = {k: n for k, n in by_key.items() if n > 1}
    if dup:
        raise InvariantViolation(f'幂等失效: {dup}')

    # 2) 三重预算都没被击穿,且是显式失败
    if txn.steps > txn.limits.steps:
        raise InvariantViolation('步数超限')
    if txn.wall_clock > txn.limits.wall_clock + 0.5:
        raise InvariantViolation('墙钟超限')
    if txn.tokens > txn.limits.tokens:
        raise InvariantViolation('token 超限')
    if txn.limit_breached and not txn.degraded_explicitly:
        raise InvariantViolation('预算触顶但未显式降级')

    # 3) 没有悬空副作用:成功的写入都必须有处置结论
    succeeded = {e.step for e in txn.effects if e.status == 'succeeded'}
    settled = {e.step for e in txn.effects
               if e.status in ('compensated', 'uncompensatable', 'manual')}
    if succeeded - settled:
        raise InvariantViolation(f'悬空副作用: {sorted(succeeded - settled)}')

    # 4) 补偿逆序
    order = [e.seq for e in txn.effects if e.status == 'compensated']
    if order != sorted(order, reverse=True):
        raise InvariantViolation(f'补偿顺序错误: {order}')

    # 5) 没有未处理异常逃逸
    for e in txn.events:
        if e.kind == 'exception' and not e.handled:
            raise InvariantViolation(f'未处理异常逃逸: {e.name}')

    # 6) 熔断打开后同 run 内不再有该工具的调用
    for name, opened_at in txn.breaker_opens:
        after = [c for c in txn.calls if c.tool == name and c.t > opened_at]
        if after:
            raise InvariantViolation(f'熔断后仍调用 {name} 共 {len(after)} 次')

    # 7) 并发峰值不超上限
    if txn.peak_concurrency > txn.limits.max_concurrency:
        raise InvariantViolation('并发超限')

在 CI 里跑混沌而不产生 flaky

混沌测试最常见的失败模式是变成随机失败的持续集成,这比没有混沌测试更糟,因为它会训练团队习惯性忽略红灯。控制手段有三条,都很具体。

第一,固定故障场景而不是固定随机数种子。真正需要随机的只有故障模式的选择,具体参数(超时多少毫秒、连续失败几次、在第几步注入)应当由场景编号决定,这样任何一次失败都能用同一个编号精确复现。把随机性从参数层挪到场景层,就能同时拿到覆盖广度和可复现性;只固定种子会掩盖组合爆炸,只固定场景则覆盖不到没被列出来的组合。

第二,断言不变量而不是断言成功。在注入故障之后,成功与否本身就是不确定的,把它当判据必然产生抖动。不变量则始终成立——不管注入什么故障,一个幂等键最多产生一次副作用这条都不会被违反,所以它是稳定的判据。这一条和上一节的不变量清单是同一件事,混沌测试的价值几乎全部集中在它上面。

第三,把不变量违规和普通失败分成两种状态。不变量违规必须立刻阻断合并,因为它说明机制本身有缺陷;普通失败只记录并进入统计,用来观察机制生效率。把两者混在同一个红灯里,团队的理性反应要么是无差别重跑,要么是无差别忽略,两种都会让混沌测试退化成一个纯粹的噪音源。

混沌环境的数据隔离

在测试环境注入「执行成功但返回失败」这类故障时,如果目标服务有真实副作用,测试就会产生真实数据。轻则污染开发库,重则触发真实通知和真实扣款。因此注入点必须满足两个条件之一:故障只改客户端观测到的结果而不改服务端状态,或者目标指向一个行为等价的替身服务。

最干净的实现是在工具适配器与真实服务之间放一层可编程代理,把注入全部发生在这层。它同时解决了另外两个问题:注入点与业务代码解耦,不会因为业务重构而失效;代理层能记录完整的调用轨迹,为后续的轨迹回放提供素材。这也是把混沌测试纳入持续集成的前提——没有这层隔离,就没法在每次提交上跑。

还应覆盖一类容易漏掉的场景:长期故障下的状态收敛。短时故障测的是快速失败与熔断,长期故障测的是恢复路径——故障持续十分钟之后系统状态是否仍然一致、恢复之后有没有悬空副作用、补偿任务是否堆积到无法在合理时间内处理完。这类测试无法在持续集成里跑,需要定期的演练,频率可以按月安排,演练报告本身也是很好的团队对齐材料。

执行策略:从代理层逐步深入

注入顺序要遵循从可控到不可控的原则。先在网络层做确定性注入(固定超时、固定失败),目的是验证单点逻辑;再注入随机组合故障,目的是验证多机制叠加时的行为;最后注入长时间持续故障,目的是验证熔断与降级路径的稳定性。

轨迹回放是提高效率的关键。把线上真实轨迹脱敏后作为输入,注入点固定随机种子,这样每次 CI 跑的是同一组故障场景,失败可以精确复现。反过来,随机性应该留给故障模式的选择而不是每个故障的具体参数,否则同一个 bug 今天出现明天消失,谁都定位不了。

还有一个容易被跳过的环节:统计机制生效率。不只统计测试通过与否,还要统计重试预算被触发的比例、熔断被打开的比例、补偿被执行的比例。这些比例如果长期为零,说明机制根本没被触发,测试是假的安全;如果某个比例高得离谱,说明阈值需要调整。这个指标比单纯的通过率更能反映机制的真实状态。

可观测性:为 Agent run 定义 SLO

所有可靠性工作最终都要落到可观测的数字上,否则无法判断改进是否有效。Agent 的观测有一个特殊困难:一次 run 是一个跨越多分钟的、包含多次外部调用的复合过程,把它当成一个请求来监控会丢掉几乎所有有用信息。

必须一等公民化的事件

每次 run 的事件流里,以下事件类型必须结构化记录,且不能只留在日志文本里:规划产生的动作清单、每个工具调用的入参摘要与幂等键、每次重试的原因与次数、每次熔断状态迁移、每次补偿的执行与结果、以及每一步的预算消耗。缺了任何一类,事后复盘就只能靠读日志猜。

幂等键一定要打进事件里。一个实用的排查姿势是:拿用户报告的异常单号,去反查所有携带该键的调用,如果出现两条以上,问题就在幂等环节而不是业务逻辑。这个查询如果键没记录就没法做。

从平均成功率转向尾部指标

Agent 场景要盯的指标不是平均成功率,而是一组尾部相关的比率:

  • 重试放大系数:总工具调用数与用户发起的任务数之比。它比平均成本更早暴露重试风暴。
  • 熔断触发率:按工具分组。持续触发的工具说明它在持续故障,零触发的工具可能是熔断阈值过松。
  • 补偿成功率:补偿成功数与补偿尝试数之比,以及补偿失败转人工的比例。
  • 无进展步占比:被无进展检测拦下的步数占总算力消耗的比例,这是识别模型能力不足的先行指标。
  • 幂等键冲突数:同一键被去重折叠的次数。突增意味着上游在重复提交。
  • 预算触顶率:墙钟或步数触顶的比例。它反映的是预算设置与实际需求的匹配度。

把这些指标按租户、按工具类别、按任务类型切开会更有信息量。整体无进展步占比只有百分之几,但某个特定任务类型高达百分之三十,那就说明这个任务类型该改规划方式,而不是该调阈值。

埋点与事件:没有这些,前面的机制都无法验证

前面每一节提到的机制都有一个共同前提:它们在正常路径上不产生任何可见效果。幂等键有没有起作用、熔断有没有真的打开、补偿有没有覆盖全部写入,这些问题只能靠事件流回答。所以事件契约不是可观测性的附属品,而是可靠性机制本身的一部分。

一份够用的事件清单

不需要一长串指标,需要的是一组能把机制串起来的事件。下面这些是最小集合,缺任何一类都会留下排查盲区:

  • run_started 与 run_finished:一次运行的起止,带总预算、总耗时、总成本、最终状态。
  • plan_created:规划器产出的动作清单快照,带意图标识。这是后来所有幂等键的锚点。
  • budget_allocated:每一次预算分配,带分配依据和剩余量。没有它就无法解释「为什么这里没有额度了」。
  • tool_call_started 与 tool_call_finished:调用配对,带幂等键、截止时刻剩余量、尝试次数、结果分类。
  • retry_scheduled:带重试原因、剩余预算、是否来自服务端指令。这一条用来区分「我们决定重试」和「服务端让我们等」。
  • breaker_state_changed:熔断状态迁移,带触发依据。让「熔断为什么没生效」变成一个可查的问题。
  • compensation_started 与 compensation_finished:带台账记录标识与结果。这是核对悬空副作用的唯一依据。
  • budget_exhausted 与 partial_success_returned:预算触顶和部分成功的显式标记,用来统计退化信号。

每个事件的必备字段

字段设计上有一个原则:让事后能把同一次尝试的所有事件串起来。因此至少需要运行标识、意图标识、步骤序号、尝试标识和幂等键这五个贯穿字段。少了意图标识,重放时无法复用幂等键;少了尝试标识,重试次数统计会重复计数;少了幂等键,前面反复强调的「反查重复副作用」根本做不了。

其次是决策依据要跟着事件一起存。预算分配事件要带分配规则,熔断事件要带失败计数,退避事件要带上界和实际抖动值。只记结果不记依据,事后就只能猜;猜错一次就会得出错误的因果结论,然后把不该调的参数调了。

事件流是复盘的主要材料

有了完整事件流,一次线上故障的复盘就从「读日志猜」变成「拉一条时间线」。这个差别在事故复盘中非常明显:有了事件,你能看到预算在第几步被耗尽、哪次重试消耗了最后的额度、熔断为什么在那个时刻打开;没有事件,你只有一堆孤立的错误日志,需要靠时间戳猜因果。

一次线上故障的排查路径

机制建好之后,真正考验它的是出事那天能不能快速定位。下面是一条按顺序执行的排查路径,每一步都对应前面某节的一个机制。

先分诊,再深挖

第一个问题永远是范围:是全部任务失败、部分任务失败,还是只有慢和贵。这个判断能把搜索空间缩小一个数量级。全量失败优先怀疑依赖与配置(网关、鉴权、配额),部分失败优先怀疑特定工具或特定参数组合,只慢不失败优先怀疑预算分配,只贵不慢优先怀疑重试与循环。

第二个问题是时间对齐:故障是从什么时候开始的,和最近一次发布、配置变更、下游变更是否对齐。如果对不齐,不要在代码里找了,去查依赖侧的变更记录。

三类高频故障的定位动作

重复副作用:拿用户报告的异常单号或目标标识,去反查所有携带相关幂等键的调用。如果出现两条以上成功记录,问题在幂等环节;如果只有一条但用户看到了两次,那问题在下游的通知机制而不在 Agent。两种情况的修复方向完全不同,所以这个区分必须在动手前完成。

大面积超时:按工具聚合预算触顶事件,看是所有工具同时触顶(指向全局预算设置或模型推理变慢)还是特定工具触顶(指向该工具的延迟画像失真或下游劣化)。同时检查截止时刻的传播链:如果同一次 run 里不同步骤的超时剩余量对不上,说明预算在中途被重置了。

补偿没执行或执行失败:直接查台账,找状态为成功但没有对应补偿结论的记录。这批记录就是悬空副作用,它们的数量就是这次事故的实际影响面——这个数字应该在复盘里明确写出来,而不是用「部分任务受影响」含糊带过。

区分机制失效和机制没触发

最后一步是判断机制本身有没有出问题。熔断没打开有三种可能:失败率确实没到阈值(不是故障)、阈值配置有问题(机制失效)、或者熔断状态被别处的健康度记忆覆盖了(机制冲突)。区分它们的方法是看同一时间窗内的统计:如果失败率高但熔断没开,是配置问题;如果熔断开了但任务仍然失败,是熔断的反馈没有正确传给模型。

这个区分很重要,因为两类问题的处理方式相反——配置问题要调参数,反馈问题要改错误契约,而后者如果被误判成前者,团队会去反复调一个根本没问题的阈值。

告警该配在哪几个指标上

可靠性告警最容易犯的错误是配得太多,最后全部变成噪音被忽略。有效的做法是只对「明确需要人介入」的情况告警,其余交给看板。

四个必须告警的指标

补偿失败转人工:任何一次转人工都代表一个用户可见的不一致没有被处理完。这类事件数量应该接近零,出现就应该有人跟进。

熔断长期打开:注意是「长期」而不是「打开」。单次打开是正常的保护行为,持续打开说明下游真的坏了,而且需要一个明确的责任人来跟进。阈值上可以设成「连续打开超过若干分钟」。

幂等键冲突突增:去重折叠次数的突增意味着上游在重复提交,或者某个调用路径出现了意外的重复。这类变化通常先于用户投诉出现。

预算触顶率突增:它反映的是预算设置与实际需求之间的偏差突然变大。突增通常由上游变慢或规划变复杂引起,是很好的先行指标。

不该告警的东西

单次超时率的短时波动、重试次数的偶发升高、熔断的瞬时打开,这三类都不该单独告警。它们是系统的正常呼吸,告警多了只会让人对红灯脱敏。合理的处理是给它们设置基线和偏离阈值,只在偏离持续一段时间后才升级为告警。

还要强调分维度切片。整体无进展步占比只有百分之几不代表没问题,按任务类型切片之后可能会看到某个类型高达百分之三十——那才是真正需要解决的问题,而且它指向的是规划质量而不是可靠性机制。把所有信号混在一个总数里,是可靠性看板最常见的失真来源。

指标动了以后怎么判断

比指标本身更重要的是判读动作。同一个指标上升,可能意味着机制正在正确工作,也可能意味着系统在真的变差,而这两种情况的应对完全相反。下面是最容易搞混的几组。

重试放大系数跳升,先看是否集中在单一工具。集中在单一工具,说明是那个依赖在抖,与 Agent 逻辑无关;均匀分布在所有工具上,则更可能是预算设置过紧导致每次调用都只够试一次。两种情况的处理分别是加重试预算和调大单次预算,方向完全相反。

熔断触发率上升,要区分「保护正在生效」和「阈值配错了」。判断方法是看熔断打开期间的任务成功率:如果打开之后成功率反而更高,说明熔断是有效的;如果打开之后仍然大量失败,说明断路器没切在正确的位置,应该改的是反馈给模型的内容而不是阈值。

补偿成功率下降,先看补偿动作是不是弱补偿占多数、或者超出了有效时间窗。如果开始大量出现「过期补偿」,那是一个设计问题:说明不可逆动作被排得太靠前,或者业务流程本身就允许太长的执行时长。这种情况要改设计,改参数是没用的。

无进展步占比上升,这个指标经常被误读成模型变笨了。实际上要先排除两个技术原因:状态摘要的设计是否覆盖了真实的进展依据,以及工具返回的内容是否过于贫乏导致模型无法判断进展。很多「模型变笨」的感知,实际上是工具返回质量的下降。

幂等键冲突突增,最常见的真实原因是上游在重复提交同一请求——比如用户在界面上快速点了两次。这类重复是可以被折叠的,所以冲突数上升本身不是坏事。真正需要警惕的是折叠失效:如果同一个业务意图产生了两个不同的键,说明键配方里混进了不稳定字段,去重正在悄悄失效。

预算触顶率上升,先分清是单步变慢还是步数变多。单步变慢指向依赖劣化或画像失真,步数变多指向规划质量下降或者无进展循环增多。这两者的修复位置完全不同,前者在工具侧,后者在规划与熔断侧。

悬空副作用数是其中最严重的指标,因为它直接对应用户可见的不一致。任何非零值都应该有明确的处置结论,哪怕结论是「暂时接受并加入观察清单」。把它长期挂在看板上不做处置,比不统计更糟糕——那会训练团队对这个数字脱敏。

所有这些判读背后是同一条原则:先问「是机制在正确工作,还是系统真的在变差」,再决定动作。这个顺序不能颠倒,因为把「机制正在正确工作」误判成「系统变差」,最典型的后果就是去调那些本来正确的阈值,从而真的把系统调坏。

看板怎么分层

可观测性做多了会出问题:所有指标放在同一块看板上,结果是没有一块能被看完。有效的做法是按回答的问题而不是按数据来源分层。

实时层回答「现在是否正常」,只放少数几个需要人介入的判据:补偿失败转人工、熔断长期打开、幂等冲突突增、预算触顶率。这一层的设计目标是让人在三十秒内做出「要不要立刻处理」的判断,其余一律不进。

趋势层回答「最近是不是在退化」,放那些比率型指标:重试放大系数、无进展步占比、平均开闭次数、补偿成功率、悬空副作用数。这一层不需要实时响应,但需要按周看趋势,因为很多退化是缓慢的。

容量层回答「还能撑多久」,放成本预算消耗、配额使用、并发峰值与限流命中次数。这一层服务于容量规划和预算参数调整,通常按租户和资源组切片。

切片维度也要提前定好。至少三个:按租户,用于定位租户特有的问题;按工具或资源组,用于定位依赖问题;按任务类型,用于定位规划质量问题。最后一个维度最容易被忽略,也最有价值——它能把「整体看起来正常」掩盖下的严重局部问题暴露出来。

迁移路径:从裸奔到有机制

如果你的 Agent 现在完全没有这些机制,改造的难点不在技术,而在于顺序。一次性把六个方面都推上去,团队会在第一个月就失去耐心。合理的路径是先止血、再约束、后恢复、最后验证。

先止血:从最不可逆的工具开始

第一步不要从超时开始,而要从不可逆工具开始。给它们加人工确认闸门,或者干脆暂时改成不执行并返回提示。这个动作见效最快、风险最低,而且能立刻堵住最贵的漏洞——重复扣款和误发通知的损失远大于任务失败。

并行地做一件几乎零成本的事:给所有工具调用加一条统一的事件日志,字段按前面那份必备清单来。哪怕没有任何机制,这一件事也能让后续所有改造的效果可被观测。

再约束:预算和重试闸门

第二步引入全局预算对象和工具分级。这一步是纯约束性质的,不改变业务语义,因此不需要业务方点头,但它会改变用户的体感(有些任务会更快失败)。要提前说明这一点,并把「显式失败」做得足够友好,避免被当成系统不稳定。

同时引入幂等键。这一步要按工具逐个做,而不是全量上线,因为每个工具的键配方和服务端的配合都需要单独确认。全量上线的典型结果是服务端去重不生效,但客户端已经不再重试,于是原本被掩盖的重复问题开始暴露,看起来像是改造引入了故障。

后恢复:台账、补偿与续接

第三步才是台账与补偿。这一步涉及持久化存储和恢复逻辑,改动面大,但它的前置条件(工具分级、幂等键、事件日志)在前面几步里已经就位,所以风险是可控的。

建议先在只影响单个资源组的场景上做,验证恢复路径正确之后再扩展。而且要专门测一次「在副作用执行之后立刻崩溃」的场景,这是台账设计里最容易出错、而日常测试又最难覆盖的时刻。

双轨运行一段时间

改造期间让新旧机制并行一段时间是有价值的:新机制做决策但结果先只记录不生效,用来对照。这个阶段能回答一个关键问题——新机制会拒绝掉多少本来能成功的任务。这个数字如果过高,说明阈值太紧;如果接近零甚至为负(说明新机制救回了任务),就说明阈值合理。

双轨期的成本主要在推理和实现上,不在运行成本上,所以不需要太长,一般覆盖各类典型任务各若干次就够。结束双轨前要把对照结果整理成一页结论,作为参数决策的依据和向团队解释的依据。

怎么证明改造有效

可靠性改造最难的一点是效果难以证明——因为它主要改善的是没发生的事。可行的证明方式是三类证据。

一是机制生效率:熔断被触发的次数、补偿被执行的次数、重试预算被拦下的次数。这些从零开始增长的曲线,直接说明机制在工作,而不只是躺在代码里。

二是不变量违规次数:混沌测试中不变量被违反的次数。它应该随着改造单调下降,这比任何成功率的提升都更能说明问题——因为成功率本身受业务波动影响太大。

三是尾部指标的改善:P99 耗时、重试放大系数、单位成功成本。这些数字的变化幅度通常不大,所以要强调看趋势而不是看绝对值,并且在对比时按任务类型切片,避免被结构变化干扰。

把失败表达成契约,而不是字符串

前面各节的所有机制都依赖同一个前提:失败的形态是可分类的。如果工具只返回一个字符串,编排层就分不清「网络抖动,值得重试」「参数写错了,重试一万次还是错」「下游限流,要按对方给的时间等」「这个操作可能已经执行过,只是响应丢了」。模型拿到的信息越少,它的行为就越随机,最后往往退化成盲目重试或者直接放弃。

一个可用的错误契约至少要携带五类信息:错误类别,用来决定走重试还是降级路径;是否可重试;建议等待时长;是否可能已生效,用来决定要不要触发补偿;以及一段给模型看的简短说明。其中第四类最容易被忽略,它是唯一一条从传输层错误跨到业务语义错误的信息,也是补偿事务能否被正确触发的唯一依据。一个连接重置的写调用,究竟是「肯定没执行」还是「可能已经执行」,这个判断只能由工具适配器根据操作类型给出,外层无论如何推断都不可靠。

契约的另一面是给模型看的反馈格式。给人看的错误和给模型看的错误应当分开:人需要「下游服务暂不可用,建议稍后重试」,模型需要「工具 X 不可用,不要重试,请选择替代路径或告知用户」。后者如果写成一段完整的自然语言句子,模型很容易理解成「再试一次也许会好」;写成带明确指令的结构化文本,它才会照做。这个细节看起来很小,实际决定了熔断和降级能不能真正生效——熔断打开了但模型不知道,就等于没打开。

工程上还有一条要求:错误类别必须在整条调用链上保持一致,从工具适配器到编排层再到模型反馈,中途不能退化成通用异常。实现上可以用一个带类型的错误对象加明确的分类枚举,转换点只放在两个地方——跨进程序列化和跨语言边界。不要在中间层写一个宽泛的捕获然后塞一个字符串,那等于在架构最脆弱的位置上把刚刚建立的信息全部丢弃,而且这种丢失很难被发现,因为功能表面上仍然工作。

契约还应该包含尝试标识。同一个意图的不同尝试要能通过它被关联起来,这样重试预算、熔断计数和幂等去重才有共同的统计口径。没有这个标识,重试相关的指标就只能靠打日志统计,最后会得出「这个工具大概每天重试很多次」这种无法行动的数字。

八个最常见的反模式

下面这些几乎每一项都真实发生过,而且大多不抛异常,只是安静地让可靠性一点点漏掉。它们的共同特征是:单独看每一处代码都很合理,组合起来才出问题。

  • 逐层传固定超时值。每一层都配三秒,二十层的链路能挂一小时才放弃。正确做法是改成传递截止时刻,让每一层从上游继承剩余额度。之所以顽固,是因为它在单层测试里永远正确。
  • 并行分支各自拿满父预算。看起来最安全,实际是让总预算随分支数膨胀,二十路并行就是二十倍,最后整体卡在某个上游限流上。改法是按权重瓜分剩余量并设绝对上限。
  • 重试时重新开始计时。每次重试都从零计超时,等于给了无限时间,也让重试预算失去意义。改法是重试复用同一个截止时刻,退避时间也要从预算里扣。
  • 幂等键绑在尝试上。每次重试生成一个新 UUID,服务端无法去重,于是「重试三次」等于「执行三次」。改法是让键绑定规划阶段生成的意图标识,并且确认服务端真的存储了键到结果的映射。
  • 用完整轨迹做无进展检测。模型的冗余复述被当成进展,检测永远不触发。改法是只哈希外部事实集合与待办列表,模型说了什么不参与判断。
  • 补偿逻辑只写在应用代码里。进程一挂就没人知道做过什么,恢复时只能靠猜。改法是引入写前落盘的台账,把补偿从流程里抽出来变成独立的恢复阶段。
  • 只按工具名限流。多个工具名指向同一个集群时,逐工具限流等于没限,并发被乘以工具数量。改法是按下游资源分组限流,工具声明自己属于哪个资源组。
  • 把部分成功包装成成功。返回里加一句「已完成大部分操作」但状态字段仍是成功,调用方据此推进流程,不一致被推迟到更远的地方爆发。改法是显式的部分成功状态加可恢复标记。

这八条如果要在评审时快速检查,判据可以压缩成三个问题:这个重试安全吗(副作用幂等吗),这个失败可解释吗(用户知道发生了什么吗),这笔账有人负责吗(状态在失败后还能被找回吗)。三个问题都能立刻回答的实现,基本不会出上面八类问题;有一个答不上来的地方,大概率已经埋了一个反模式。

超时预算怎么定:让数据而不是直觉做决定

整套超时机制最容易流于形式的地方,是预算数值的来源。拍一个「三十秒」然后全链路沿用,看起来统一,实际上既没有依据也无法调整。真正可维护的做法是让预算从延迟分布里长出来。

从延迟分布出发

每个工具都应该有一份延迟画像,至少包含 p50、p95、p99 和样本量。这份画像的用途有两个:一是给预算分配提供依据,二是判断工具是否处于异常状态。p99 超过历史基线两倍以上,往往意味着下游正在劣化,此时按基线分配的预算会让每一个调用都超时。

这里有个容易被忽略的方法论问题:画像必须按工具和参数规模分别统计。一个不带过滤条件的全量查询和一个精确的单条查询,延迟差一个数量级,混在一起的 p99 毫无参考价值。参数规模可以用结果的预估行数、请求体大小这类代理指标来分层,粗糙一点也比混着算好。

分位数不是拍脑袋,但也不能照抄

常见的建议是「单次调用给 p99 的两倍」。这个数字有一定道理,因为它给长尾留了一倍余量,同时不至于让预算膨胀到无法完成多步任务。但它是起点不是结论,真正的判断依据是下游劣化时你希望发生什么:如果希望快速失败把资源让给健康路径,就该贴近 p99;如果下游是唯一数据源、没有替代,就得给足余量甚至允许重试。

一个更实用的校准方法是看预算触顶率。这个比例过低说明预算给得太宽,run 经常被无谓拖长;过高说明预算太紧,正常的任务都跑不完。把目标定在几个百分点,然后按周调整,是比一次性拍定更可靠的做法。

预算与成功率的帕累托

更深一层的问题是预算和成功率的权衡。把预算从十秒加到六十秒,成功率会上升,但收益递减得很快:超过某个点之后,超时基本都来自真正的慢任务而不是运气问题,而等待三倍的代价却是实打实的。这条曲线上有一个拐点,找到它比追求「越高越好」更有价值。

判断拐点的判据通常不是技术指标而是产品约束:如果单次失败可以接受用户多等十秒,预算就该往长的一侧偏;如果用户预期是即时响应,那么宁可快速失败并给出降级结果,也不要让用户盯着一个三分钟的进度条。

把工具调用变成可持久化的任务

前面讨论的重试和补偿都假设调用是即时的、结果就在这次调用里。但很多真实场景下,工具调用天然是一个可以异步执行的长任务:跑一个训练任务、处理一批文档、调用一个耗时几十分钟的接口。这类场景如果继续用同步超时的思路,必然要在「等」和「放弃」之间二选一。

队列带来的免费重投递

把调用投递到消息队列,会得到一个同步方案里得不到的性质:投递本身是持久的。消费者超时退出、机器重启、编排进程崩溃,任务都不会丢,重启后继续消费。这相当于把可靠性的一部分从应用代码转移到了基础设施,而基础设施的这部分通常是更可靠的。

但队列的语义必须理解清楚才能用好。大多数队列提供的是至少一次投递,不是恰好一次:消息可能重复投递,也可能因为消费者在处理完但确认之前崩溃而重新投递。这个语义和幂等是天生的一对——队列负责「保证不丢」,幂等负责「重复了也没事」。缺了幂等,至少一次就等于「保证会重」。

可见性超时是这个模型的超时

在队列模型下,超时的含义发生了变化:不再是「等不到结果就放弃」,而是「处理时间超过可见性超时,消息会被重新投递」。这个差别很关键,因为重新投递是自动发生的,不需要应用层决策,但也意味着处理逻辑必须在任意时刻被安全打断。

实践上要给消费者加幂等的处理函数和显式的完成标记:只有在处理真正结束时才确认消息,过程中可以定期续期可见性超时。这样长任务既能安全中断,又不会因为超时被反复重投。续期逻辑必须和业务处理分离,否则业务代码里一个异常就会同时破坏两者。

状态查询优于轮询结果

异步任务的结果获取有一个常见错误:客户端反复轮询一个结果接口。更稳妥的形态是提交后返回一个任务句柄,配合状态订阅或回调。轮询的问题是它把一个长等待拆成了很多次短请求,仍然消耗预算,而且无法区分「还在跑」和「卡住了」这两种截然不同的状态。

推荐的做法是让状态查询返回结构化的阶段信息:排队中、执行中(第几步、预计还要多久)、已完成、已失败(带原因)。这不仅对用户有用,也让上层能够区分「需要继续等」和「应该提前补偿」——后者的判断依据是失败原因而不是时间。

工具契约的演进:别让一次改名毁掉正在跑的 run

工具是活的:参数会改、返回结构会改、语义会调整。而在跑的任务可能持续几分钟甚至几十分钟,其中任何一次变更都可能让一个本可以成功的任务在中途失败。这一节讨论的是契约变更的兼容性约束。

运行中的任务与工具版本

最基本的要求是版本化的返回值读取:新版本可以增加字段,但不能删除或改变已有字段的语义,因为旧任务还在按老结构解析。最危险的不是删字段,而是改语义——比如把状态字段从字符串枚举改成布尔,或者把金额单位从元改成分。这种变更不会报错,只会让旧任务悄悄算错,比直接崩溃更糟。

务实的做法是给契约变更定级:新增可选字段是安全的;新增必填参数是破坏性的;改变语义需要双写过渡期;删除需要先确认没有在跑的旧任务。对高风险变更,可以用一个短期的契约版本号,让编排层在任务开始时记录版本、结束时校验,把不兼容暴露成明确的失败而不是静默的错误结果。

契约测试作为回归

工具的可靠性同时也是它的契约质量。一个好的契约测试套件应该覆盖:结构符合 schema、错误路径返回预期的错误类别、幂等键确实被服务端接受、以及在重复调用下结果一致。把这套测试挂在每次工具变更上,比任何文档都更能防止退化。

契约测试里最容易被省掉的是错误路径。大家习惯测正常返回,但 Agent 的可靠性恰恰依赖错误信息是否规范。一个返回 500 的工具和一个返回结构化错误的工具,在框架看来是完全不同的两回事,前者只能被当作未知故障处理。

灰度与双版本并存

涉及语义变更时,正确的节奏是先并行再切换:新旧两个契约同时在线,按比例把流量分给新版本,用不变量的观测指标判断是否可以全量。这个过程的前提是两边都能被独立观测,也就是前面说的幂等键与调用事件都要打上版本标记。

很多团队会在全量之后才发现问题,但那时旧契约已经下线,回滚窗口关闭了。双版本并存期的额外成本是可控的,而它买到的正是「随时能退回去」这个选项——对一个可靠性机制本身就在变更的系统来说,这个选项的价值很高。

可靠性机制自身的变更管理

一个很少被讨论的风险是:可靠性机制本身也会成为故障源。熔断阈值设错、预算给错、补偿顺序写错,产生的故障比没有这些机制时更严重,而且更难排查。

阈值类配置尤其危险

熔断阈值、重试次数、预算秒数这类数字看起来是调优参数,实际是安全边界。它们错了不会立刻报错,而是让系统在特定条件下做出与预期相反的行为。这类配置一旦生效就很难归因,因为「熔断没打开」和「熔断打开得太频繁」在日志上都只是一个状态迁移。

因此这类配置应该像数据库的隔离级别一样被对待:有明确的取值范围、有变更评审、有灰度、并且能一键回滚。同时为关键阈值配置观测项,比如熔断触发率、平均开闭次数、预算触顶率,任一项超出预期范围就告警,而不是等用户投诉。

机制变更要有观测窗口

改了熔断策略之后,至少要观察一个完整业务周期再下结论。一个常见的事故是:某次调整让熔断触发率短暂升高(因为阈值收紧),团队据此认为改坏了,于是改回去——而这个过程里,真正的问题是被掩盖的另一个故障。定一个最短观察窗口,并且在窗口内不做二次调整,比追求快速响应更可靠。

保留回滚路径本身要被测

「一键关掉某个机制」这个能力应该被定期演练。很多团队在真正需要回滚时才发现配置项改了之后没法生效,或者回滚脚本依赖了一个已经下线的服务。演练的成本很低,收益是在最坏的时候有一个可以立刻按下的按钮。

多租户下的公平性与优先级

单租户场景下,限流解决的是保护下游;多租户场景下,它还必须解决相互干扰:一个长任务不能因为持续占用并发许可而让其他租户排队到超时。

分层配额

有效的做法是分层:全局上限保护下游,单租户上限保护租户间的公平,单 run 上限保护单次任务的膨胀。三层之间用借还机制连接——空闲租户的配额可以被其他租户临时借用,借出方进入低优先级,这样一旦它自己需要资源就能优先拿回。

交互式请求优先于批处理

长耗时的批处理任务和秒级交互请求混在同一个池子里,无论怎么调限流都会有人不满意。正确的解法是物理隔离:分开限流、分开队列、必要时分开下游资源。共享池子里的优先级只能改善体验,无法保证,而对于交互式请求,延迟的可预测性比平均吞吐更重要。

这也影响任务的中止设计:低优先级任务被高优先级任务挤压时,应该是降级(缩小范围、降低精度)而不是中断。一个被挤掉一半的批处理任务通常比一个被杀掉的更好,因为前者仍然有交付物。

端到端走一遍:一个订单调整任务

把前面所有机制串起来看一次,抽象的东西会变得具体。设想的场景是:Agent 收到「把订单 A 的配送时间改到明天上午,并通知买家」这个指令,其中订单在内部系统,通知走邮件,两者分属不同团队的系统。

规划阶段

规划器输出两个动作:查订单、改配送时间、然后发通知。编排层在这里做三件事。排序:不可逆的邮件被排到最后,并显式依赖前两步成功。预算分配:整体给了四十秒,查订单分配到剩余额度的三成且不超过八秒,改配送时间不超过十秒,剩下的留给通知和推理。契约校验:两个工具名都在注册表里,参数格式合法。

同时写入台账:两条记录,一条对应改配送时间(带补偿动作「恢复原配送时间」),一条对应发邮件(无补偿动作,标记为不可逆)。这一步在执行任何副作用之前完成。

执行与故障注入

查订单成功。改配送时间这一步,下游返回超时。这里发生的事是最能体现机制价值的地方:

编排层检查剩余预算——还有二十二秒,而这次尝试的预算是十秒,够。于是它按幂等键发起第二次尝试,两秒后成功。同时补偿预算没有被这两次尝试吃光,因为预算判定是滚动计算的而不是一次性切好的。

接着发邮件。邮件服务此时处于限流状态,返回明确的重试等待时间。系统采用服务端给的等待时长而不是自研退避,并在等待前检查剩余预算——只剩四秒,不够再等一次,于是直接返回「配送时间已修改成功,但通知买家失败」。这个结果是不完整的,但它是诚实的:已完成的部分明确标注,未完成的部分给了原因,而且邮件这条记录在台账里是 pending 状态,后续可以对账补发。

假如补偿失败

换一个更糟的场景:改配送时间之后,后续步骤全部失败,而评估之后决定应该撤销。此时触发补偿。

系统按逆序处理,先发一封「您的配送信息即将变更」的更正说明(如果已发过),再恢复原配送时间。恢复动作用的是同一个幂等键,因此重复执行是安全的。如果恢复动作连续失败三次,它不会继续重试,而是把这条记录标记为转人工,并把完整的交接包推给值班人员——包括台账记录、原订单信息、已经尝试过的补偿动作及其失败原因、以及那个可能仍在跑的子任务。

最终交付

返回给用户的是一个带明确状态的结构:配送时间已修改(附新值),买家通知未发送(附原因与建议),当前状态可恢复且重试不会重复修改订单。这个结果的信息量远大于「操作失败」,而且它让用户在后续想重试时知道该怎么做。

回头看,这个任务从开始到结束一共触发了七八个机制:预算继承与滚动分配、幂等键构造、服务端退避指令的采纳、预算不足判定、台账写前落盘、部分成功报告、以及在另一个分支上的熔断。它们的共同点是没有一件需要模型做判断——机制负责约束模型,模型负责在约束内做选择。这是把 Agent 推向可运营状态的关键分界线。

落到现有框架上:哪些该直接用,哪些必须自己写

本文讲的机制里有一部分已经被框架和工作流引擎覆盖,没必要重复造。但也有一部分无论用什么框架都得自己写。分清这条线能省掉大量无谓的工程量,也能避免最危险的一种误判:以为框架给了检查点就等于有了可靠性。

框架通常已经解决的

最典型的三类。第一是状态持久化与检查点:主流的图式编排框架都提供了把每步状态写入外部存储并从任意检查点恢复的能力,工作流引擎里的确定性重放也是同一类能力。它们解决的是「崩溃后从哪里继续」和「长任务不用从头重跑」。

第二是单次调用的自动重试:包括按次数或按退避策略的重复执行、可见性超时、基于心跳的存活检测。这一层相当于免费给了你至少一次投递的语义,在异步任务里尤其有用。

第三是调用链追踪:主流 agent 框架都集成了 tracing,能给出完整的调用时序、每次工具调用的入参与返回、以及嵌套调用的父子关系。它是事件流最好的数据来源之一,能省掉大量手工埋点。

框架解决不了、必须自己写的

缺口集中在三处,而且这三处恰好是本文最核心的内容。

幂等键的构造与透传。框架知道「调用失败了,可以重试」,但它不知道这次调用属于哪个意图、幂等键该由谁生成。键绑在尝试上还是绑在意图上,这是一个业务语义决定,框架无从推断。绝大多数重复副作用事故的根因就在这里。

补偿事务。恢复执行解决了「重跑」,但很多副作用不能重跑,只能撤销。「哪些动作可撤销、撤销的顺序、撤销失败怎么办」是业务知识,必须由工具的提供方声明,框架没有这个信息。

跨进程的资源描述。预算预算、幂等键、台账标识都需要穿透到工具所在的其他进程或供应商。这类跨边界传播通常要靠应用层显式实现,加一个请求头、写一个字段,或者在工具适配器里统一注入。

工具协议层能帮到哪一步

随着工具调用被标准化成一个跨进程的协议,请求超时和错误码有了统一表达,这确实降低了一部分跨服务的实现成本。但协议的语义层面还有几个必须由应用层补齐的部分:

  • 错误码到重试策略的映射。协议会告诉你「服务端内部错误」,但不会告诉你这个操作是否可能已经生效。后者决定了要不要进补偿流程,而它只能由操作类型推断。
  • 超时预算的分配。协议能传递超时时长,但要传递剩余额度并且区分调用与子调用的层级关系,需要应用层自己做。
  • 幂等键字段的约定。协议负责把参数传过去,但幂等键由谁构造、放在哪个字段、服务端按什么规则去重,是双方要协商的契约。

把这三条约定清楚之后,跨服务的可靠性才真正成立。缺任何一条,都会出现「协议层显示调用失败、应用层却不知道该不该重试」的模糊地带。

一个判断标准

可以用一句话来分:凡是关于「业务语义」的,都自己写;凡是关于「执行骨架」的,尽量用现成的。执行骨架包括状态存储、重试循环、调用时序、任务投递;业务语义包括这个操作能不能重复、撤销怎么做、谁对它的正确性负责。

这个标准的实践价值在于,它能挡住一种很常见的过度设计:把大量精力花在自建一套分布式任务执行上,而真正没做的是给每个不可逆操作想清楚该怎么撤销。后者才是线上事故的主要来源。

工具注册表:把所有声明集中到一处

本文前面提到的很多机制,最终都依赖于同一份元数据:工具属于哪一类、幂等键怎么构造、补偿动作是什么、延迟画像、资源类型、责任人。如果这些信息散落在代码、配置和文档里,任何一项缺失都会让对应机制静默失效。把它们集中到一个注册表里,是让整套机制可维护的前提。

一份注册条目应该包含什么

最少要有七项:幂等类别(三分类之一,决定是否允许自动重试);幂等键配方(说明键由哪些部分构成、由谁生成、透传到哪个字段);补偿动作(有或没有,以及补偿的强度分级);延迟画像(p50/p95/p99 与样本量);资源归属(指向哪个下游资源组,用于限流);副作用等级(对外可见程度,决定是否需要额外闸门);以及责任人(出问题时知道找谁)。

其中「幂等键配方」这一项最容易被写成一个模糊的描述。合格的写法应该能直接指导实现:键由租户标识、意图标识、工具名、规范化参数摘要四段拼接,意图标识在规划阶段生成并向下透传,规范化要剥离的字段列在配置里。只写「用请求 ID 做幂等」是不合格的,因为请求 ID 在重试时会变。

注册表应该被当作代码对待

它是运行时依赖,不是文档。所以它应该有版本、有评审、有契约测试,并且和其他代码走同一条发布流程。每次变更都要回答一个问题:正在运行的旧任务会怎么读这一条。字段新增通常安全,字段语义变更往往需要双写过渡期。

更有价值的一步是用注册表生成提示里的工具文档。前面提到过工具幻觉的根源是描述和注册表不一致,而用同一份数据生成两边就能从物理上消除这个不一致。这是一个投入很小、收益立刻可见的改动,建议排在可靠性改造的最前面做。

用注册表做资源治理

有了统一的资源归属声明,很多治理动作可以自动做:限流按资源组配置、监控按资源组聚合、容量评估按资源组的调用量统计、故障演练按资源组选择注入目标。不需要额外维护第二份映射表,也就不会出现配置漂移。

最后一个实用建议:给注册表加一条完整性校验,在启动时检查所有条目的必填字段是否齐全、幂等类别与补偿声明是否自洽(比如声明为不可逆却没有补偿声明且没有被标记为前置规避)。这类校验能在部署时就拦下一批配置错误,而它们在上线后通常要到第一次事故才会被发现。

落地顺序:按投入产出比推进

最后给一个可执行的推进顺序。前两个阶段成本极低、收益极高,不应该跳过。

第一周:全局截止时间与硬超时

在 run 入口建立唯一预算对象,所有工具调用从它继承剩余额度而不是自取 timeout;给每个工具补上首字节与空闲两个超时。这一步通常能直接消掉大部分「长时间无响应」的工单。

这一阶段的验收标准很明确:任意一次工具调用都能在事件流里查到它的截止时刻,并且这个时刻是从 run 入口一路继承下来的。如果做不到,就说明中间某层把它重新初始化了。顺手把工具的延迟画像采集也建起来,后面所有预算决策都依赖它,早一周开始采能早一周拿到可信的分位数。

第二周:工具分级与重试闸门

把工具注册表按三类分级,明确标记不可逆工具;幂等键绑到意图上;不可逆工具禁用自动重试;加重试预算比例。这一步会暴露出一批「原来一直在重复执行」的工具,属于收益发现。

这一步的验收方式是查幂等键冲突数:分级完成后,那些从来没被折叠过重复请求的工具应该开始出现折叠计数。长期为零通常说明键构造有问题,而不是业务上恰好没有重复。同时要抽查不可逆工具的调用链,确认它们确实没有自动重试路径。

第一个月:台账、补偿与三重预算

引入副作用台账与写前落盘,给每个有外部效应的工具补齐补偿元数据;加入步数、墙钟、token 三重预算与显式降级;把不可逆动作排到编排末端。这一步是改动最大的一段,建议按工具类别逐个推进而不是一次全量。

这一步建议按工具类别逐个推进:先做只读加幂等写的组合,这部分几乎零风险;再做有补偿动作的写;最后才碰不可逆动作,因为只有到这一步才会真正改变外部世界。顺序反过来的话,第一次改动就可能造成不可逆的错误,而此时团队的补偿链路往往还没验证过。

一个季度:混沌工程与 SLO

把不变量清单变成 CI 里可执行检查;建立尾指标看板;按资源组做限流与自适应并发;最后再考虑多租户公平性。这一阶段的产出不是「没有故障」,而是故障发生时损失可控、事后能定位。

不要等这一阶段才开始建指标,前面三步的观测埋点必须同步做。否则到了最后一个季度,你会发现无法判断前三步到底有没有效果,只能凭感觉汇报。

边界与代价

最后要说清楚这套东西的适用边界,因为可靠性工程本身是有成本的。

如果你的 Agent 是一轮问答、只有只读工具、没有外部副作用,那么上面九成机制都是过度设计。这类场景值得做的只有两件:推理调用的超时,以及一个总步数上限。反过来,如果你的 Agent 会发消息、会动钱、会改生产配置,那么本文里的每一条都在你的风险清单上。

可靠性与自主性之间存在真实的张力,这一点要诚实地承认。熔断会降低单次成功率(很多本来能自愈的调用被快速失败了),补偿会引入新的失败点,保守的默认策略会让 Agent 更频繁地交回控制权。这些都是用峰值体验换尾部下限,在长尾尾部决定生死的场景里这笔交易通常划算,但在短生命周期、低风险的内部工具上可能不划算。

从演进方向看,有三件事值得持续关注。其一是可恢复执行成为编排框架的默认能力,把检查点、台账、补偿这些从应用代码下沉到框架,从而降低每个团队的重复建设成本。其二是工具协议层对时限与错误语义的标准化,让截止时间传播和结构化错误成为跨语言跨进程的通用约定,而不是每个框架自己定义一套。其三是把预算变成静态约束,让规划器在生成动作时就受预算约束,而不是执行到一半才被熔断打断。

还有一笔常被忽略的代价是机制自身带来的延迟。预算检查、幂等键计算、台账写入、熔断状态查询都在关键路径上,加起来未必是零。经验上的做法是把它们做成「按需触发」:只读工具跳过台账,命中快路径时跳过部分校验,熔断状态用本地缓存加周期性刷新而不是每次查共享存储。这条路的风险是快路径和慢路径的行为不一致,所以必须定期强制走一次慢路径做对照,防止快路径悄悄掩盖了问题。

另一笔代价是流程上的。熔断打开需要有人跟进,补偿失败需要有人处理,阈值变更需要有人评审,这些都意味着一个值班机制和一份明确的责任划分。缺了流程保障,机制会退化成「告警了但没人看」的状态,此时它不但没有价值,还会因为持续产生噪音而损害团队对告警的整体信任。所以在上线机制之前,先确认谁来响应,比先写代码更重要。

同样值得说清楚的是什么时候不该做。判断标准不是复杂度,而是「单次失败的代价」和「失败概率」的乘积。如果单次失败代价极低——比如一个内部的只读查询工具,失败了大不了用户再问一次——那整套机制就是负收益。反过来,只要单次失败涉及对外可见的不可逆动作,或者失败会污染后续所有步骤的状态,机制的投入产出就非常明确。

还有一个介于中间的状态值得单独说:低频但高代价的场景,比如每季度才会跑一次的年度结算流程。它的失败概率不高,但单次事故的代价可能远超所有日常任务的总和。对这类场景,投入方向应该和日常流量相反——重点不是压低失败率,而是保证失败之后能被人发现、能被安全重放、能被完整追溯。可观测性和恢复能力在这里比熔断和限流重要得多。

最后一个判断,也是整篇文章想留下的那句话:Agent 的可靠性不会来自更聪明的模型,只会来自那些在模型之外、把失败当作必然来设计的机制。当你的系统默认工具会超时、下游会崩、模型会绕圈、进程会死,它才开始像一个能上生产的服务。