1. 为什么“失败就重试”在 Multi-Agent 里是个陷阱
1.1 单 Agent 时代的重试逻辑为什么能凑合用
大部分人第一次接触 Agent 开发,都是从单 Agent 起步的。一个 LLM 调用,包一层try/except,失败了就for i in range(3)重试三次,再不行就抛异常。这套逻辑在单 Agent 场景下确实能跑通,因为它的假设非常朴素:失败是瞬时的、无状态的、可重复的。
比如你调一个翻译接口,超时了,重试一次大概率能成功。因为翻译这个动作不依赖上一次调用的任何中间状态,输入一样,输出就一样。这就是典型的幂等操作——执行一次和执行十次,对系统的影响是一样的。
但 Multi-Agent 系统完全不是这个逻辑。我见过太多项目,一个 Orchestrator 带着三五个 Worker Agent,每个 Worker 有自己的工具调用、有自己的记忆、有自己的中间产物。这时候某个 Worker 挂了,你直接重试,会发生什么?
1.2 Multi-Agent 失败的真实代价:状态污染与副作用叠加
举个我实际踩过的坑。之前做一个文档分析的多 Agent 系统,结构是这样的:一个 Planner 负责拆任务,一个 Retriever 负责检索,一个 Summarizer 负责总结,一个 Writer 负责成文。四个 Agent 串行协作,中间通过共享的 Context 传递数据。
有一次 Summarizer 在调用外部模型时超时了。当时的代码逻辑很简单,捕获异常后直接重试。结果重试成功了,但最终输出里出现了两份摘要——因为 Summarizer 在超时之前,其实已经把第一份摘要写进了共享 Context,只是返回响应的时候超时了。重试之后它又写了一份,Context 里就有了重复数据,Writer 拿到两份摘要,输出直接乱掉。
这就是 Multi-Agent 重试的第一个大坑:副作用已经发生,但调用方不知道。在分布式系统里这叫“部分失败”,是经典难题。单 Agent 场景下,Agent 本身没有持久化状态,重试是干净的;Multi-Agent 场景下,每个 Agent 都可能是状态的持有者和修改者,重试就是在脏状态上再叠一层。
第二个坑更隐蔽:重试会放大下游压力。假设你有 10 个 Worker Agent 并行跑,其中一个因为下游限流失败了,你重试。如果重试策略没做好退避,10 个 Worker 同时重试,下游瞬间被打爆,本来只是偶发失败,现在变成雪崩。这在 Agent 编排里特别常见,因为 Agent 之间的调用链比普通微服务更长,一个失败可能触发上游多个 Agent 的连锁重试。
第三个坑是语义层面的不可重试。有些 Agent 的动作天然有副作用,比如“发送邮件”“写入数据库”“调用支付接口”。这类操作失败了,你根本不知道它到底执行没执行。盲目重试可能导致重复发送、重复扣款。这时候需要的不是重试,而是幂等性设计和状态确认。
所以标题说“只会重试就太初级了”,不是危言耸听。重试只是最表层的容错手段,真正成熟的 Multi-Agent 系统,需要一整套围绕状态管理、幂等性、检查点、补偿的机制。下面我按自己实际做过的项目,把这套东西拆开讲。
2. Multi-Agent 容错的核心思路拆解
2.1 先搞清楚失败的类型,再决定怎么处理
很多人一上来就写重试逻辑,但根本没分类失败。我的经验是,Multi-Agent 里的失败至少分四类,每类的处理策略完全不同:
| 失败类型 | 典型场景 | 是否可重试 | 推荐策略 |
|---|---|---|---|
| 瞬时故障 | 网络抖动、下游限流、模型超时 | 可重试 | 指数退避 + 抖动 + 最大次数 |
| 状态不一致 | Agent 写了一半 Context 就挂了 | 不可直接重试 | Checkpoint 回滚 + 重放 |
| 语义副作用 | 发邮件、写库、调支付 | 不可盲目重试 | 幂等键 + 状态查询 |
| 逻辑错误 | Prompt 有问题、工具参数错 | 重试无用 | 快速失败 + 告警 + 人工介入 |
这张表是我踩了无数坑之后总结的。核心观点是:重试只对第一类有效。后面三类你重试一百次也没用,甚至越重试越糟。
举个具体例子。之前有个项目,Writer Agent 调用一个内部 API 生成 PDF。这个 API 有副作用——每次调用都会在对象存储里生成一个文件。有一次调用超时了,代码重试,结果对象存储里出现了两个文件,后续的下载链接指向了旧的那个,用户拿到的是过期内容。这就是典型的“语义副作用 + 盲目重试”导致的 bug。
后来我们的改法是:给每次 PDF 生成请求带一个幂等键(比如task_id + version),API 端先查这个键有没有对应的文件,有就直接返回,没有才生成。这样重试就安全了。这个思路在分布式系统里叫幂等性设计,是 Multi-Agent 容错的基础设施。
2.2 Checkpoint 机制:让 Agent 可以“存档读档”
如果说幂等性是防重复,那 Checkpoint 就是防丢失。Multi-Agent 系统跑一个长任务,可能涉及几十次 Agent 调用、上百次工具调用,中间任何一步失败,如果从头再来,成本极高。这时候就需要 Checkpoint。
Checkpoint 的本质很简单:在关键节点把整个系统的状态序列化存下来。这个状态包括:每个 Agent 的输入输出、共享 Context 的内容、已经执行到哪一步、哪些工具调用已经完成。失败之后,从最近的 Checkpoint 恢复,而不是从零开始。
我在一个数据分析的多 Agent 项目里用过这套机制。整个流程是:数据清洗 Agent → 特征工程 Agent → 建模 Agent → 报告 Agent。每个 Agent 跑完,就把当前状态写到一个 JSON 文件里,同时记录一个step_id。如果建模 Agent 挂了,重启后直接读step_id=2的 Checkpoint,从特征工程的结果继续,前面的清洗和特征工程不用重跑。
这里有个关键细节:Checkpoint 的粒度。粒度太粗,恢复时浪费算力;粒度太细,写 Checkpoint 本身的开销就很大。我的经验是,按“Agent 调用”为粒度比较合适,因为 Agent 调用通常耗时较长(几秒到几十秒),写一次 Checkpoint 的开销可以接受。如果 Agent 内部有多个工具调用,可以在工具调用前后也加轻量级的 Checkpoint。
还有一个坑:Checkpoint 的版本兼容。你存的状态格式,恢复的时候代码可能已经改了,字段对不上。所以 Checkpoint 里一定要带 schema 版本号,恢复时做兼容处理或者拒绝恢复。这个在快速迭代的项目里特别容易出问题。
2.3 幂等性:不是可选项,是必选项
幂等性这个词听起来很学术,其实用生活类比很好理解:电梯按钮。你按一次和按十次,电梯都只来一趟。这就是幂等。Multi-Agent 系统里,每个有副作用的操作都应该设计成幂等的。
实现幂等性有几个层次,从简单到复杂:
第一层:天然幂等。查询类操作天然幂等,比如“读取文件内容”“查询数据库”。这类操作重试无副作用,随便重试。
第二层:幂等键去重。给每个操作分配一个唯一 ID,服务端记录已处理的 ID,重复请求直接返回缓存结果。这是最常用的方案。幂等键的生成要保证全局唯一,通常用task_id + agent_id + step_id组合。
第三层:状态机约束。把操作设计成状态机,只有特定状态才能执行特定动作。比如订单 Agent,只有pending状态才能pay,paid状态再调pay直接拒绝。这样即使重试,也不会重复扣款。
第四层:补偿事务。对于无法幂等的操作,设计一个反向操作来抵消。比如“扣款”失败了但不确定是否成功,就查一下账户余额,如果扣了就走退款流程。这就是 Saga 模式的思路。
我在实际项目里,第二层和第三层用得最多。幂等键去重适合工具调用,状态机约束适合 Agent 之间的协作流程。两者结合,基本能覆盖 90% 的场景。
2.4 重试策略本身也要讲究:退避、抖动、熔断
就算确定了某个失败可以重试,重试的方式也很关键。我见过最粗暴的写法是while True: try: ... except: continue,这简直是灾难。正确的重试策略至少包含三个要素:
指数退避:第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,以此类推。目的是给下游恢复的时间,避免持续施压。
抖动(Jitter):在退避时间上加一个随机量,比如sleep = base * 2^n + random(0, 1)。目的是避免多个 Agent 同时重试造成“惊群效应”。这个在并行 Agent 场景下特别重要。
熔断:如果某个下游连续失败 N 次,直接熔断,不再重试,快速失败。等一段时间后再半开试探。这个能防止一个坏掉的下游拖垮整个系统。
我用 Python 写过一个通用的重试装饰器,核心逻辑大概是这样:
import time import random from functools import wraps def retry_with_backoff(max_retries=3, base_delay=1, max_delay=30, jitter=True): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries + 1): try: return func(*args, **kwargs) except RetryableError as e: if attempt == max_retries: raise delay = min(base_delay * (2 ** attempt), max_delay) if jitter: delay += random.uniform(0, delay * 0.5) time.sleep(delay) except NonRetryableError: raise return wrapper return decorator注意这里区分了RetryableError和NonRetryableError。这个区分非常重要,前面说的四类失败,只有第一类应该抛RetryableError,其他三类直接抛NonRetryableError,快速失败。很多项目就是没做这个区分,所有异常都重试,结果逻辑错误也重试,白白浪费时间。
3. 核心细节解析与实操要点
3.1 Checkpoint 存什么、存哪里、怎么恢复
Checkpoint 的设计是 Multi-Agent 容错的核心,我把它拆成三个问题:存什么、存哪里、怎么恢复。
存什么:一个完整的 Checkpoint 至少包含以下字段:
{ "checkpoint_id": "ckpt_20240101_001", "schema_version": "1.2", "task_id": "task_abc123", "step_id": 3, "timestamp": 1704067200, "agent_states": { "planner": {"status": "done", "output": {...}}, "retriever": {"status": "done", "output": {...}}, "summarizer": {"status": "running", "partial_output": {...}} }, "shared_context": {...}, "completed_tool_calls": ["call_1", "call_2"], "pending_tool_calls": ["call_3"] }这里的关键是completed_tool_calls和pending_tool_calls。恢复的时候,已完成的工具调用直接跳过,用缓存结果;未完成的重新执行。这样既避免了重复副作用,又不会丢失进度。
存哪里:小项目直接写本地文件就行,JSON 或 pickle 都可以。但生产环境建议用对象存储(比如 S3 兼容的存储)或者数据库。我一般用 Redis 存热数据(最近几个 Checkpoint),用对象存储存冷数据(历史 Checkpoint)。Redis 的好处是读写快,恢复延迟低;对象存储的好处是容量大、成本低。
怎么恢复:恢复逻辑要处理三种情况:
- Checkpoint 完整且 schema 兼容:直接加载,从
step_id继续。 - Checkpoint 存在但 schema 不兼容:尝试迁移,迁移不了就回退到更早的 Checkpoint,或者从头开始。
- Checkpoint 损坏:校验和失败,回退到上一个可用 Checkpoint。
这里有个实操心得:Checkpoint 一定要做校验和。我遇到过磁盘写入中断导致 Checkpoint 文件半截的情况,恢复的时候 JSON 解析失败,整个任务卡死。后来加了 CRC32 校验,损坏的 Checkpoint 直接跳过,回退到上一个,问题就解决了。
3.2 幂等键的设计与生成规则
幂等键的设计看似简单,其实有很多细节。核心原则是:同一个逻辑操作,无论重试多少次,幂等键必须相同;不同的逻辑操作,幂等键必须不同。
我常用的生成规则是:
idempotency_key = hash(task_id + agent_id + step_id + operation_name + input_hash)逐个解释:
task_id:整个任务的唯一标识,保证不同任务之间不冲突。agent_id:哪个 Agent 发起的操作,同一任务里不同 Agent 的操作要区分。step_id:第几步,同一 Agent 的不同步骤要区分。operation_name:操作名称,比如send_email、write_db。input_hash:输入参数的哈希,保证相同输入才复用结果。
这里有个坑:input_hash 不能包含时间戳、随机数等易变字段。我见过有人把timestamp放进输入里算哈希,结果每次重试哈希都不一样,幂等键失效,去重完全没用。所以输入哈希之前,要把易变字段剔除。
另一个坑:幂等键的存储要有 TTL。不能无限期存着,否则存储会爆。TTL 的设置要大于任务的最大可能重试时间窗口。我一般设 24 小时,对于长任务设 7 天。
幂等键的存储用 Redis 最合适,因为需要高性能的读写和 TTL 支持。用SET key value NX EX ttl一条命令就能实现“不存在才写入 + 过期时间”,天然适合幂等去重。
3.3 Agent 之间的状态同步:共享 Context 的并发问题
Multi-Agent 系统里,Agent 之间通常通过共享 Context 传递数据。这个 Context 在并行场景下就是并发写热点,处理不好会出现数据覆盖、丢失更新。
我遇到过最典型的问题:两个 Worker Agent 同时往 Context 的一个列表里 append 数据,结果其中一个的写入被覆盖了。原因是读-改-写不是原子的。解决方案有几个:
方案一:加锁。用分布式锁(比如 Redis 的 Redlock)保护 Context 的写操作。简单有效,但性能差,Agent 多了会排队。
方案二:CAS(Compare-And-Swap)。每次写 Context 带一个版本号,写入时检查版本号是否变化,变了就重试。这个比锁性能好,但实现复杂。
方案三:事件溯源。不直接改 Context,而是把每个 Agent 的输出作为事件追加到事件日志里,最终状态由事件重放得到。这个最优雅,但改造成本高。
我在实际项目里,小规模用方案一,大规模用方案三。方案二用得少,因为 CAS 的重试逻辑容易写错,调试困难。
这里还有个细节:Context 的 schema 要严格定义。我见过项目用裸 dict 当 Context,不同 Agent 往里塞各种字段,最后字段冲突、类型不一致,排查起来极其痛苦。后来改成 Pydantic 模型,每个字段有明确类型和默认值,问题少了很多。
3.4 失败分类器的实现:怎么判断该不该重试
前面说了失败分四类,但实际代码里怎么判断?我的做法是写一个失败分类器,根据异常类型、错误码、错误信息来判断。
class FailureClassifier: RETRYABLE_CODES = {408, 429, 500, 502, 503, 504} NON_RETRYABLE_CODES = {400, 401, 403, 404, 422} @staticmethod def classify(exception): if isinstance(exception, TimeoutError): return FailureType.TRANSIENT if isinstance(exception, ConnectionError): return FailureType.TRANSIENT if isinstance(exception, HTTPError): if exception.status_code in FailureClassifier.RETRYABLE_CODES: return FailureType.TRANSIENT if exception.status_code in FailureClassifier.NON_RETRYABLE_CODES: return FailureType.LOGIC if isinstance(exception, StateInconsistencyError): return FailureType.STATE if isinstance(exception, SideEffectError): return FailureType.SIDE_EFFECT return FailureType.UNKNOWN分类之后,不同类别走不同处理路径:TRANSIENT走重试,STATE走 Checkpoint 回滚,SIDE_EFFECT走幂等查询,LOGIC直接告警。
这个分类器不是一次写完的,是随着项目跑,不断补充规则。我建议一开始就搭好框架,后面遇到新的失败类型往里加规则就行。
4. 实操过程与核心环节实现
4.1 搭建一个带 Checkpoint 的 Multi-Agent 骨架
下面我用 Python 写一个最小可用的 Multi-Agent 骨架,包含 Checkpoint、幂等、重试三个核心机制。代码不追求生产级完备,但逻辑是完整的,可以直接参考。
先定义状态和 Checkpoint 的数据结构:
from pydantic import BaseModel, Field from typing import Dict, List, Optional, Any import json import hashlib import time class AgentState(BaseModel): agent_id: str status: str # pending, running, done, failed output: Optional[Dict[str, Any]] = None error: Optional[str] = None class Checkpoint(BaseModel): checkpoint_id: str schema_version: str = "1.0" task_id: str step_id: int timestamp: float agent_states: Dict[str, AgentState] shared_context: Dict[str, Any] completed_tool_calls: List[str] = Field(default_factory=list) checksum: Optional[str] = None def compute_checksum(self): data = self.model_dump(exclude={"checksum"}) raw = json.dumps(data, sort_keys=True).encode() return hashlib.sha256(raw).hexdigest()[:16]然后是 Checkpoint 的存储层,我用本地文件模拟,生产环境换成 Redis 或对象存储:
import os class CheckpointStore: def __init__(self, base_dir="./checkpoints"): self.base_dir = base_dir os.makedirs(base_dir, exist_ok=True) def save(self, ckpt: Checkpoint): ckpt.checksum = ckpt.compute_checksum() path = os.path.join(self.base_dir, f"{ckpt.task_id}_{ckpt.step_id}.json") tmp_path = path + ".tmp" with open(tmp_path, "w") as f: f.write(ckpt.model_dump_json()) os.replace(tmp_path, path) # 原子替换,避免半截文件 def load_latest(self, task_id: str) -> Optional[Checkpoint]: files = [f for f in os.listdir(self.base_dir) if f.startswith(task_id)] if not files: return None files.sort(key=lambda f: int(f.split("_")[-1].split(".")[0])) for f in reversed(files): path = os.path.join(self.base_dir, f) try: with open(path) as fp: ckpt = Checkpoint.model_validate_json(fp.read()) if ckpt.checksum == ckpt.compute_checksum(): return ckpt except Exception: continue return None注意os.replace这个细节,它是原子操作,保证不会出现半截文件。还有load_latest里的校验和检查,损坏的 Checkpoint 直接跳过。
4.2 幂等工具调用的实现
接下来是幂等工具调用的封装。核心思路是:调用前先查幂等键,有缓存直接返回,没有才真正执行,执行完写缓存。
import redis import json class IdempotentToolExecutor: def __init__(self, redis_client, ttl=86400): self.redis = redis_client self.ttl = ttl def _make_key(self, task_id, agent_id, step_id, op_name, params): # 剔除易变字段 stable_params = {k: v for k, v in params.items() if k not in ("timestamp", "nonce", "request_id")} raw = f"{task_id}:{agent_id}:{step_id}:{op_name}:{json.dumps(stable_params, sort_keys=True)}" return "idem:" + hashlib.sha256(raw.encode()).hexdigest() def execute(self, task_id, agent_id, step_id, op_name, params, func): key = self._make_key(task_id, agent_id, step_id, op_name, params) # 先查缓存 cached = self.redis.get(key) if cached: return json.loads(cached) # 用 SET NX 占位,防止并发重复执行 lock_key = key + ":lock" acquired = self.redis.set(lock_key, "1", nx=True, ex=60) if not acquired: # 其他进程正在执行,等待结果 for _ in range(30): time.sleep(0.5) cached = self.redis.get(key) if cached: return json.loads(cached) raise TimeoutError("等待幂等结果超时") try: result = func(**params) self.redis.set(key, json.dumps(result), ex=self.ttl) return result finally: self.redis.delete(lock_key)这里有两个关键点:一是SET NX占位锁,防止并发重复执行;二是执行完写结果缓存,后续重试直接命中。这个模式在分布式系统里叫“幂等执行器”,是处理有副作用操作的标准方案。
4.3 带退避和熔断的重试执行器
重试执行器我前面给了装饰器版本,这里给一个更完整的、带熔断的版本:
class CircuitBreaker: def __init__(self, failure_threshold=5, recovery_timeout=60): self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.failures = 0 self.last_failure_time = 0 self.state = "closed" # closed, open, half_open def call(self, func, *args, **kwargs): if self.state == "open": if time.time() - self.last_failure_time > self.recovery_timeout: self.state = "half_open" else: raise CircuitOpenError("熔断器打开,快速失败") try: result = func(*args, **kwargs) if self.state == "half_open": self.state = "closed" self.failures = 0 return result except Exception as e: self.failures += 1 self.last_failure_time = time.time() if self.failures >= self.failure_threshold: self.state = "open" raise熔断器的三个状态:closed正常放行,open直接拒绝,half_open试探性放行。这个模式能有效防止一个坏掉的下游拖垮整个系统。
把重试、熔断、幂等组合起来,一个完整的工具调用流程是:
- 检查熔断器状态,
open直接失败。 - 检查幂等缓存,命中直接返回。
- 执行实际调用,失败则分类。
- 可重试的失败,走退避重试。
- 重试仍失败,记录熔断计数。
- 不可重试的失败,直接抛出。
4.4 一个完整的失败恢复流程演示
假设我们有一个三 Agent 的流程:Planner → Worker → Writer。Worker 在调用外部 API 时失败了。完整的恢复流程是这样的:
第一步:失败捕获与分类。Worker 捕获到TimeoutError,分类器判定为TRANSIENT,可重试。
第二步:检查幂等缓存。用幂等键查 Redis,发现没有缓存,说明是首次执行。
第三步:重试。按指数退避重试三次,如果都失败,进入下一步。
第四步:保存 Checkpoint。把当前状态(Planner 已完成、Worker 失败、Writer 未开始)存成 Checkpoint,step_id=2。
第五步:告警与人工介入。如果配置了自动恢复,可以等一段时间后从 Checkpoint 恢复;否则告警,人工决定是否恢复。
第六步:恢复执行。从step_id=2的 Checkpoint 加载,Worker 重新执行。这次因为幂等键相同,如果之前有部分副作用,会被去重。
这个流程看起来步骤多,但每一步都是必要的。少了任何一步,都可能出问题。比如少了幂等检查,恢复时可能重复副作用;少了 Checkpoint,恢复时要从头跑。
5. 常见问题与排查技巧实录
5.1 重试导致重复副作用的排查
症状:任务重试后,下游出现了重复数据,比如重复的邮件、重复的文件、重复的数据库记录。
排查思路:
- 先确认副作用操作有没有幂等键。没有的话,这是根因。
- 有幂等键的话,检查幂等键的生成规则,是不是包含了易变字段。
- 检查幂等键的存储,是不是 TTL 太短,重试时缓存已经过期。
- 检查并发场景,是不是两个请求同时执行,都没命中缓存。
解决方案:按前面说的幂等执行器模式改造,确保幂等键稳定、TTL 足够、并发有锁。
5.2 Checkpoint 恢复后状态错乱的排查
症状:从 Checkpoint 恢复后,Agent 的行为异常,比如重复处理已经处理过的数据,或者跳过了某些步骤。
排查思路:
- 检查 Checkpoint 里的
completed_tool_calls和pending_tool_calls是否正确。 - 检查恢复逻辑有没有正确跳过已完成的步骤。
- 检查 schema 版本,是不是恢复时代码已经改了,字段对不上。
- 检查 Checkpoint 的写入时机,是不是在副作用发生之前就写了。
解决方案:Checkpoint 的写入时机很关键,要在“副作用完成之后、下一步开始之前”写。这样恢复时,已完成的副作用不会重复,未开始的步骤会重新执行。
5.3 并行 Agent 的惊群效应排查
症状:下游服务偶发失败后,突然收到大量重试请求,导致雪崩。
排查思路:
- 检查重试策略有没有加抖动。没有抖动的话,多个 Agent 会同时重试。
- 检查重试的退避曲线,是不是退避时间太短。
- 检查有没有熔断机制,失败多了应该快速失败而不是继续重试。
解决方案:加抖动、加长退避、加熔断。三个一起上,基本能解决。
5.4 常见问题速查表
| 问题 | 可能原因 | 快速排查 | 解决方案 |
|---|---|---|---|
| 重复副作用 | 无幂等键或幂等键不稳定 | 查幂等键生成逻辑 | 稳定幂等键 + Redis 去重 |
| 状态错乱 | Checkpoint 时机不对 | 查 Checkpoint 写入点 | 副作用后写 Checkpoint |
| 雪崩 | 重试无抖动无熔断 | 查重试策略 | 退避 + 抖动 + 熔断 |
| 恢复失败 | Checkpoint 损坏或 schema 不兼容 | 查校验和和版本号 | 校验和 + schema 迁移 |
| 并发覆盖 | Context 读改写非原子 | 查并发写逻辑 | 加锁或事件溯源 |
| 重试无效 | 逻辑错误被当瞬时故障 | 查失败分类器 | 区分可重试与不可重试 |
5.5 几个我踩过的坑和独家心得
坑一:幂等键用了 UUID。UUID 每次生成都不一样,幂等键就失效了。幂等键必须是确定性的,由输入参数计算得出,不能用随机数。
坑二:Checkpoint 存了不可序列化的对象。比如存了一个数据库连接、一个文件句柄,恢复的时候直接报错。Checkpoint 里只能存可序列化的纯数据,连接这类资源要重新建立。
坑三:重试次数设太多。有人设max_retries=10,结果一个失败要等好几分钟才最终失败。我的经验是 3 次足够,超过 3 次还失败,说明不是瞬时故障,重试也没用。
坑四:忘了清理旧的 Checkpoint。任务跑多了,Checkpoint 文件堆满磁盘。要加定期清理,只保留最近 N 个或者最近 M 天的。
坑五:幂等缓存和实际状态不一致。幂等缓存说操作成功了,但实际数据库里没有。这种情况通常是缓存写入和实际写入不在一个事务里。解决方案是缓存写入放在实际写入之后,或者用两阶段提交。
这些坑,文档里不会写,但实际项目里一定会遇到。我的建议是,一开始就把幂等、Checkpoint、重试这三件事的框架搭好,后面遇到问题往里填规则,比事后补救省事得多。
6. 从“会重试”到“会容错”的思维转变
写到这里,我想聊聊思维层面的东西。很多人做 Agent 开发,脑子里只有“调用-失败-重试”这个循环,这是单机时代的思维。Multi-Agent 系统本质上是分布式的,分布式系统的容错是一整套体系,重试只是其中最基础的一环。
我自己的转变是从做第一个多 Agent 项目开始的。当时一个任务跑了半小时,最后一步失败了,从头再来又是半小时,效率极低。后来加了 Checkpoint,失败后从中间恢复,时间缩短到几分钟。再后来遇到重复副作用的问题,加了幂等。再后来遇到雪崩,加了熔断。每一步都是被问题逼出来的。
现在回头看,如果一开始就有这套体系的设计意识,能少走很多弯路。所以我的建议是,做 Multi-Agent 项目,先把容错框架搭起来,再写业务逻辑。框架包括:失败分类器、重试执行器、幂等执行器、Checkpoint 存储、熔断器。这五个组件搭好,后面写 Agent 就是往里填逻辑,容错自动生效。
最后分享一个我常用的调试技巧:给每个 Agent 调用打上 trace_id,所有日志、Checkpoint、幂等键都带上这个 trace_id。出问题的时候,用 trace_id 一搜,整个调用链一目了然。这个在 Multi-Agent 场景下特别有用,因为调用链长、Agent 多,没有 trace_id 根本理不清。
这套东西不是一天建成的,我也是在项目里一点点磨出来的。但只要你开始往这个方向想,从“失败就重试”升级到“失败有分类、状态有检查点、操作有幂等、系统有熔断”,你的 Multi-Agent 系统就上了一个台阶。