那天晚上十一点,我被告警吵醒的时候,第一反应不是去看监控大盘,而是打开 IDE 翻那篇 PDF。那篇 PDF 是我三个月前打印出来的 Hector Garcia‑Molina 和 Kenneth Salem 的《Sagas》,1987 年的老论文,纸边都被我翻得有点卷了。告警原因是库存 Agent 的返回值解析失败,整个退款长流程卡在第三步已经 45 分钟。我在手机上看着 Step Functions 执行历史里的那一行红色的 States.TaskFailed,脑子里只有一个念头:Saga 论文假设所有参与者都是确定性、可补偿的,但没告诉我们如果其中一个参与者是会说胡话的 LLM 该怎么办。
这个项目本质上是一个多智能体协作的订单售后 pipeline,有三个 Bedrock Agent:支付 Agent、库存 Agent 和通知 Agent。每个 Agent 背后都是一个 Anthropic Claude 最新模型,通过 Bedrock 的 InvokeAgent API 触发。流程不复杂:用户发起退款 → 支付 Agent 调用外部支付网关原路退款 → 库存 Agent 把之前锁定的库存加回去 → 通知 Agent 发邮件和站内信。但问题出在这个“不复杂”上——微服务架构里,这种场景已经有一套成熟的 Saga + 补偿模式可以兜底,可一旦执行单元变成基于 LLM 的 Agent,整个容错语义突然就变得模糊起来。
我花了将近三个月时间,把 Step Functions、DynamoDB 状态记录、指数退避重试、死信队列和 CloudWatch 告警全都焊在这个多智能体 workflow 上,最终做到即使某个 Agent 返回了风马牛不相及的输出,整个长事务也能在不丢数据的前提下自行修复或优雅降级到人工处理。这个过程里有太多“论文里一句话,工程上脱层皮”的地方,下面我就用第一人称、踩坑笔记的方式,把这套架构的设计思路和关键的工程暴力破解写出来。
30秒速览
- - 多智能体工作流的失败模式远超微服务,因为 LLM Agent 会输出格式错误或语义矛盾,必须将输出校验和事务日志嵌入 Saga 编排。
- - 用 Step Functions 配合 DynamoDB 记录每步补偿参数,把 Agent 当作不可靠的 RPC,实现正向执行和补偿的确定性。
- - 重试策略要根据错误类型和模型成本动态调整,加入退避和 cost 上限,而非盲目指数退避。
- - 死信队列结合人工介入界面,是处理 Agent 不可预测行为的最后防线,实际落地中救了多次命。
多智能体工作流不是微服务编排,它的失败模式会让你的最终一致性彻底失控
模型幻觉不是 bug,而是 feature,它让事务边界彻底模糊
我先直接摊开那个周五晚上的翻车案例。当时支付 Agent 已经成功调用了外部支付网关,退款 198 元原路返回,Step Functions 记录下这一步的状态是 Succeeded。接下来库存 Agent 需要根据订单里的 SKU 和数量恢复库存,这是一个内部 API 调用,理论上只是数据库 update。但我给库存 Agent 的提示词里让它去理解退款请求里的商品信息,然后用自然语言生成调用指令。结果它返回的不是我约定的 {"action": "restore_inventory", "sku": "...", "quantity": ...} 这样的 JSON,而是一段解释性文字:“根据退款商品,我认为应该把 SKU 78901 的库存加回 2 件,但需要确认该商品是否已下架……”——这在学术上就是典型的模型幻觉,模型认为它需要进行额外的推理和说明,但这完全破坏了下游 Lambda 解析的契约。
Garcia‑Molina 的 Saga 论文里,每个事务步骤 Ti 都对应一个补偿操作 Ci,补偿逻辑的触发依赖步骤执行的结果:成功则继续,失败则反向执行已完成的补偿。这个前提是每个 Ti 的执行结果要么是成功(并产出明确的状态变更),要么是失败(并回滚自身内部的状态)。但 LLM Agent 多了第三种状态:执行语义正确,输出格式错误。这种错误在数据库操作里几乎不可能出现,MySQL 不会在写 Binlog 的时候突然给你写一段莎士比亚,但 Bedrock Agent 会。(延伸阅读:我把单元测试覆盖率从12%拉到87%,但AI第一次生成的Mock直接干穿了生产库)
Google DeepMind 在 2023 年底那篇 Chain‑of‑Verification 论文里提到,通过让模型在生成后自检可以大幅降低事实类幻觉,我在后来的优化里也部分借鉴了这个思路——让 Agent 多问自己一次“你的输出是否符合 JSON 契约”,再用一个轻量级验证 Lambda 检查。但论文里的消融实验都是在离线 benchmark 上跑的,生产环境里你多一次 API 调用就是多几美分的成本和几百毫秒的延迟,而且 Bedrock 的按 token 收费模型让你没法像训练那样随意加验证轮次。理论和实践的鸿沟就出现在这里:论文告诉你“加验证能降幻觉”,但没告诉你对于一条长工作流,降到的幻觉率需要多低才能让最终一致性不崩,也没告诉你验证本身会成为新的单点故障。
服务不可用是常态,但我们总假设 Agent 是“可靠公民”
除了模型输出的不确定性,另一个让我头疼的现实是 Bedrock 服务本身的不稳定。虽然 AWS 的 SLA 是 99.9%,但当你以每分钟几十次调用频率跑十几个 Agent 的时候,ThrottlingException 和 ServiceUnavailableException 几乎是每天都会碰见的老朋友。微服务里,你面对的可能是 Redis 连接超时或者数据库死锁,这些可以通过连接池预热、重试和主从切换来消解,但 Bedrock Agent 的限流更像是一种共享资源的拥塞——你不知道上游有多少租户在同时抢同一个模型的路由。(延伸阅读:免费T4的30分钟术语注射:4-bit量化+LoRA把Llama 3从随机猜测提到89%准确率,200条问答就够了)
更麻烦的是,Agent 不只是一个模型推理,它还包含知识库检索、动作组调用和 Lambda 编排。任何一个子组件超时,都会让整个 InvokeAgent 返回 5xx,这时工作流如果直接标记为失败,已经完成的前几步(比如支付)就很难补偿。传统微服务的 Saga 编排里,我们会给每个参与方设置心跳、超时和熔断,但把 Agent 当成参与方时,它的“心跳”并不能反映内部推理的进展,你只能在超时后整步重试,而重试又可能带来幂等性问题。所以我在设计重试策略之前,先得把每个 Agent 的执行契约改造成可记录的、确定性的状态机步骤。
Step Functions 不是编排器,而是分布式事务的“暴力执行引擎”
把 Bedrock Agent 当作 saga 参与方,用 DynamoDB 记录每个“补偿承诺”
既然 Agent 不保证输出格式,也不能自己管理事务状态,那我索性在架构上把它当作一个“不可靠的远程过程调用”,由 Step Functions 来强制注入事务上下文。整个工作流的状态存储在一张 DynamoDB 表里,表结构很简单:用 requestId 作为主键,记录每一步的输入参数、执行状态、正向结果和补偿所需的最小参数集。Step Functions 的每一个 Task 状态在执行前从 DynamoDB 读取对应的上下文,执行完将结果写回,如果下一步失败,补偿 Lambda 就从这个表里拿前几步的补偿数据去执行回滚。
这里有一个关键思路,来自 Saga 论文里“补偿日志”的概念,但我把日志变成了一个持久化的 Saga 状态账本。比如支付 Agent 成功后,不仅记录返回的 transactionId,还会把补偿需要的信息(退款金额、原始支付流水号)存进表里的 compensationPayload 字段。这样即使后续步骤全挂掉,我依然可以在几小时内通过一个批处理脚本重新触发补偿,而不用担心信息丢失。
下面是 Step Functions 状态机的一个核心片段,展示了执行、补偿和 DynamoDB 记录的耦合方式。注意我用的是 Express Workflows 来保证低延迟,同时用 Retry 和 Catch 把 Agent 调用的各种错误分流到不同路径:
{
"Comment": "Saga for multi-agent refund workflow",
"StartAt": "LoadSagaState",
"States": {
"LoadSagaState": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:getItem",
"Parameters": {
"TableName": "SagaStateTable",
"Key": {
"requestId": {"S.$": "$.requestId"}
}
},
"ResultPath": "$.sagaState",
"Next": "PaymentAgent"
},
"PaymentAgent": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "InvokePaymentAgent",
"Payload": {
"sagaState.$": "$.sagaState",
"input.$": "$"
}
},
"Retry": [
{
"ErrorEquals": ["States.Timeout", "Lambda.ServiceException"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2
},
{
"ErrorEquals": ["AgentFormatError"],
"IntervalSeconds": 1,
"MaxAttempts": 2,
"BackoffRate": 1.5
}
],
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "CompensatePayment"
}
],
"ResultPath": "$.paymentResult",
"Next": "InventoryAgent"
},
"InventoryAgent": {
...
},
"CompensatePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "CompensatePayment",
"Payload": {
"sagaState.$": "$.sagaState",
"error.$": "$.error"
}
},
"Next": "WorkflowFailed"
},
"WorkflowFailed": {
"Type": "Fail",
"Error": "SagaFailed",
"Cause": "One or more compensation steps executed"
}
}
}
这段 ASL 我改了至少三十个版本,最纠结的地方是 Catch 里的 CompensatePayment 跳转。因为支付 Agent 如果调用成功但返回格式错误,我到底应不应该触发补偿?触发补偿意味着可能要调用支付网关冲正,但如果真实状态是已经退款成功,冲正就会导致用户收到两次钱。所以我后来在 InvokePaymentAgent 这个 Lambda 里加了一层严格的结果校验:只有当确认外部支付网关返回明确成功,并且解析出的参数完整时,才在 DynamoDB 里写入正向记录;任何格式错误都当作未完成处理,并让 Step Functions 按 AgentFormatError 重试有限次,若仍失败则进入人工判断的死信队列,而不是直接补偿。
幂等性设计:为什么我给每个 Agent 调用都安了一个“请求 ID”神符
多智能体流程里最恐怖的不是某一步失败,而是同一步被执行了两次。比如库存 Agent 因为网络抖动触发了 Step Functions 的重试,结果它两次恢复库存,直接凭空多出 100 件库存。这类幂等性问题在 Saga 论文里是通过在每个补偿操作中维护补偿计数器来解决的,但我的 Agent 调用是异步的,没办法原子地检查“是否已执行”。所以我用了一个土办法——为每个 Agent 调用生成一个 idempotencyKey,通常就是 {requestId}-{stepName},Lambda 在调用 Bedrock InvokeAgent 前先去 DynamoDB 里查这个 key 是否存在并已经成功执行过,如果是,就直接返回之前的结果,不再调用模型。
这看起来简单,但在 Bedrock 语境下有一个坑:即使 Lambda 侧判断重复调用了,我还是要在返回的响应里塞上原始的 trace 信息,否则 Step Functions 的上下文会认为这一步没有输出而报错。我在 InvokeInventoryAgent 的代码里专门处理了这种情况,通过读取 DynamoDB 里之前写好的结果 JSON 重建 Lambda 输出,让状态机无感知地“快进”过重试的步骤。这个实践在微服务里很常见,但放到 LLM Agent 的环境下,还得额外考虑结果 JSON 是否还能被后续步骤正确解析,所以幂等性检查其实又绑定了输出契约的稳定性,环环相扣。
重试退避不是简单的 sleep,而是在模型成本、延迟和最终一致性之间走钢丝
指数退避加上模型 cost 感知的动态优先级,我们写了这样的策略表
重试策略的设计,我一开始直接用了 Step Functions 自带的 Exponential Backoff,但很快发现不同错误类型用同一套退避参数是浪费钱。比如 Bedrock 的 ModelTimeoutException 通常是因为多租户争抢 GPU,等久一点可能就恢复了,适合长退避;而 AgentFormatError 往往是模型本身生成的模式有问题,重试太快反而会增加 cost 却不大可能修复问题。(延伸阅读:在Jetson Orin上跑Qwen-1.8B生成PPT:仿真0故障,实测92%成功率,延迟暴涨340%但我再也不怕数据泄密了)
所以我写了一个动态退避调度表,放在 Lambda 层,根据错误类型和当前 Step Functions 重试次数,动态计算 sleep 并返回给状态机(也可以通过 Lambda 内部循环重试实现,但要小心超时)。同时我把调用成本作为指标:每个 Agent 调用大约 $0.02(按 Claude 4.8 最新版 API 价格估算),一次无脑重试 5 次就是 $0.1,但流程价值可能才几十块钱,所以必须对重试次数做经济上的约束。下面的代码片段展示了我们最终用的退避和优先级控制逻辑,核心是把错误分为三类——可恢复(瞬时)、半可恢复(格式错误通过调整温度可能修复)、不可恢复(权限不足等)——并给半可恢复类型一个低温度重试和成本上限:
import time, random
from decimal import Decimal
ERROR_PROFILES = {
"ThrottlingException": {"base_s": 5, "max_attempts": 5, "backoff": 2.0, "cost_limit": Decimal('0.15')},
"ModelTimeoutException": {"base_s": 8, "max_attempts": 3, "backoff": 2.5, "cost_limit": Decimal('0.10')}
}
# 动态优先级:如果当前 Saga 的总成本已接近上限,降低重试次数
def compute_retry_delay(error_code, attempt, saga_cost_accrued):
profile = ERROR_PROFILES.get(error_code, {"base_s": 2, "max_attempts": 2, "backoff": 1.5, "cost_limit": Decimal('0.05')})
if saga_cost_accrued >= profile["cost_limit"]:
# 快超出预算,只给最后一次机会
return None if attempt > 1 else profile["base_s"]
sleep = profile["base_s"] * (profile["backoff"] ** attempt) + random.uniform(0, 1)
return min(sleep, 30) # 最长 30 秒退避
这个策略在生产环境跑了两个多月,把 Agent 调用总成本压低了约 28%,同时 Saga 的成功率(无需人工干预)从 81% 提到了 93%。最大的收益不是性能,而是避免了在模型偶尔“抽风”的夜里,工作流疯狂重试到天亮,把按量付费账单打到三位数美元。论文里的重试策略往往只考虑平均响应时间或尾延迟,但在 LLM Agent 的场景下,每多一次推理都是真金白银的成本,这是分布式事务理论里完全没有考虑的一个维度。(延伸阅读:Gemma 2那篇技术报告我读了三遍,直到我把2B模型量化塞进安卓机,才发现离线翻译的真正代价)
下面这张表是我归纳的微服务 Saga 与 LLM Agent Saga 在失败模式和应对上的核心差异,可以快速建立一个心理模型:
| 维度 | 微服务 Saga | LLM Agent Saga |
|---|---|---|
| 步骤失败判定 | 明确的异常码或超时 | 输出格式违反契约、语义不对、自相矛盾 |
| 补偿确定性 | 补偿逻辑是已知且可靠的函数 | 补偿可能需要推理出反向操作,存在二次失败风险 |
| 幂等性保障 | 通过数据库唯一约束或请求 ID | 需要额外存储 Agent 调用的完整输入输出进行校验 |
| 重试成本 | 通常只有计算资源 | 每次重试都产生模型调用费用和延迟 |
| 人工介入边界 | 极少需要,多为致命故障 | 频繁需要,因为无法百分之百自动判断语义正确性 |
死信队列不应该是垃圾桶——人工介入通知的半自动恢复
尽管加了多层重试和格式校验,总有一些边界情况是自动化搞不定的:比如支付 Agent 已经扣款但外部支付网关返回了一个中间状态,任何重试都会返回“重复请求”,而补偿又可能造成退款失败。这种时候,我的 Saga 流程会主动将整个事务元信息打进 SQS 死信队列,同时触发 CloudWatch Alarm 调用 SNS 发消息到企业微信和 PagerDuty。(延伸阅读:90MB内存、40ms延迟:我把AutoTrain微调的情感分析模型塞进了树莓派4)
死信消息里包含了 DynamoDB 中的完整 Saga 状态、各 Agent 的输出、以及所有重试的 trace。人工介入的同事可以直接打开一个内部工具(我用 Lambda + API Gateway 搭了个简易控制台),查看每步执行情况,选择“强制补偿”、“重新触发该步骤”或“标记为不可恢复并通知用户”。因为 DynamoDB 里已经有补偿所需的所有参数,一键触发的 Lambda 可以复用正常的补偿函数,而不用再从日志里拼凑信息。
这个“半自动恢复”机制在实际中救了至少五次命,尤其是当 Bedrock 模型更新后,Agent 的输出风格突然从一个版本的 JSON 变成另一个版本的格式化文本,导致整个 workflow 连锁失败。我们在死信处理界面里增加了一个按钮叫“用最新模型重试”,它会把步骤标记为未完成并重新入队,极大缩短了故障恢复时间。
退款流程实战:当库存 Agent 超时,我们怎么把扣减给“补偿”回去
订单退款→通知用户→恢复库存,一个典型的 saga 正向执行和补偿
我用完整的退款 Saga 作为例子,串一下正向执行和补偿触发。假设 Saga 包含三个步骤:
- 退款执行 (PaymentAgent): 调用支付接口原路退款,成功后记录 transactionId 和补偿需要的原始支付流水号。
- 库存恢复 (InventoryAgent): 将之前预占/扣减的库存加回可售数量。
- 用户通知 (NotificationAgent): 发送退款成功邮件和站内信。
正向顺序是 1 → 2 → 3。如果步骤 2 因为 Agent 返回格式错误或服务不可用而失败,Saga 的补偿逻辑如下:步骤 1 已经退款,不可撤销,所以补偿不是“冲正退款”,而是确保步骤 2 最终要么被人工完成,要么标记为待处理并通知客服;同时步骤 3 不应该发送“退款成功”通知,因为库存没恢复,用户看到的仍然是退款成功但商品不可售的状态。所以我的补偿操作是:CompensatePayment(实际上并不撤销支付,而是将 Saga 状态标记为 PARTIALLY_COMPLETED,并生成一个工单)和 SkipNotification(阻止通知发送)。如果步骤 3 已经执行后才发生步骤 2 失败,那补偿还包括触发一个“库存恢复失败”的警示邮件给用户,而不是误发成功邮件。
这个反向定义过程比微服务复杂的地方在于,Agent 本身的补偿动作也可能是调用另一个 Agent(比如工单 Agent),这又引入了第二层的失败风险。所以我在设计时硬性规定:补偿 Lambda 必须不依赖任何 Bedrock Agent,全部使用确定性的内部 API 调用或直接写 DynamoDB。这是从一次次半夜排查中总结的血泪教训。
监控与告警:CloudWatch 的仪表盘没告诉你的那些事
Step Functions 自带的 CloudWatch 集成能给你展示工作流执行成功/失败的数量,但对多智能体 Saga 来说,这远远不够。我最需要的是每个 Saga 步骤的平均重试次数、Agent 输出格式错误率、补偿触发率以及人工介入的比例。这些指标我在 DynamoDB Streams 上挂了一个 Lambda,把状态变更实时推送到 CloudWatch 自定义 Metrics,并建了如下仪表盘:
- Saga 健康度热力图:横轴是时间,纵轴是不同 Agent 步骤,颜色深度代表失败率(包括格式错误),一眼就可以看到哪个 Agent 最近不稳定。
- 补偿触发比例趋势:如果突然从 2% 飙到 15%,基本意味着模型行为有变更或者下游服务抖动,需要立刻检查。
- 死信堆积量:结合 SQS 队列深度,设置阈值告警,因为我们希望死信只是少量异常,不能积累成山。
另外,我还在工作流里埋了一个“毒丸”检测:如果同一个 requestId 在 10 分钟内重试超过 8 次且仍失败,就自动把流程状态转为 QUARANTINED,并切断进一步的重试,防止一个坏请求拖死整个 Lambda 并发额度。这其实是从 Netflix 的 Hystrix 模式里借鉴的,只不过这里的雪崩来源不是微服务,而是模型的不确定性。
现在回过头看,Saga 论文最让我着迷的地方不是它提出补偿事务这个概念,而是它定义了一种长事务的“可恢复性契约”——只要你能记录每个步骤的逆操作,你就能最终达到一致。这个思想在 1987 年是划时代的。但当你把执行单元从数据库操作换成 LLM Agent 时,逆操作的定义本身就变得模糊了。模型可能不理解“逆操作”的语义一致性,补偿可能因为同样的幻觉而再次失败。所以我在实际工程中做的,其实是给 Agent 套了一层确定性外壳:把 Agent 的能力限制在“提供决策所需的信息或格式化指令”,把真正的状态变更和补偿逻辑放在 Lambda 里,用事务日志确保每一步都有据可查。这也许是对 Saga 思想的一种退化,但它是这个阶段让多智能体长流程跑起来的唯一现实路径。
我最开始提到的那篇 Chain‑of‑Verification 论文,实验里通过反复自我验证在几个 QA 数据集上把幻觉率压到了个位数,但我复现类似机制时发现,在多代理链式调用中,哪怕单步验证将幻觉率降到 5%,经过三个 Agent 串联后,整体流程的正确率也会落到 0.95³ ≈ 86%,这还不算重试带来的额外波动。所以我最大的疑问是:到底有没有可能在多智能体协作中把端到端的一致性做到微服务级别的 99.99%?我打算下一步尝试引入一个独立的“验证 Agent”,它不参与业务,只看前序 Agent 的输出是否符合业务规则和格式,并用更小的专用模型(比如 Anthropic 的 Claude Haiku)来降低成本,看能不能把最终的成功率再往上推一两个九。至少,这是 Saga 论文没给答案,而工程实践又逼着你不得不去回答的问题。