给 AI Agent 加超时和重试后,我才发现真正的坑在状态清理
做 AI Agent 开发的朋友应该都有这种体验:Agent 跑起来容易,想让它在生产环境稳定跑一天很难。LLM 推理慢、工具调用超时、第三方接口抽风,任何一个环节卡住,整个 Agent 就像死机一样。我最初的处理思路很朴素,给所有可能卡住的地方加超时,给失败的调用加重试,以为这样就能把稳定性拉起来。代码写完之后确实好了不少,但随后连续踩了好几个诡异的问题:超时重试之后,Agent 突然开始“说胡话”,或者重复调用同一个写操作,甚至上一轮已经废弃的任务还在偷偷往数据库里写数据。排查到最后,所有矛头都指向同一个被我忽视的地方——状态清理。
这篇文章把我这段踩坑经历完整拆开讲:超时和重试到底应该怎么设计,为什么说状态清理才是真正的深水区,以及我在实际项目中总结出的一套可落地的状态清理方案。如果你正在用 LangGraph、Spring AI Agent 或者自己手写调度框架,这篇文章大概率能帮你少熬几个通宵。
1. 超时和重试:稳定性的第一道防线,但远比想象中复杂
先聊点基础的。AI Agent 跟传统 Web 服务最大的区别是什么?传统接口是“请求-响应”模式,你调一个服务,它要么成功要么失败,结果和副作用都相对可控。Agent 不一样,它本身是一个“过程”:接收任务、编排工具、调用模型、产生中间结果、迭代决策,可能还要操作外部系统写数据。这个过程通常要几十秒甚至几分钟,期间任何一步都可能出问题。
我见过很多刚接触 Agent 的团队,会把 Agent 当成一个普通 API 来调,一个请求打过去就不管了,直到超时。实际上 Agent 的稳定性问题比单次接口调用复杂得多,最典型的有三个:
- LLM 推理时长不确定。同一个模型,同一段 prompt,有时候 3 秒返回,有时候 30 秒还在生成首 token,如果赶上服务端排队,几分钟都是可能的。
- 工具调用有真实副作用。Agent 不是只读的,它会调数据库、发消息、写文件,这些操作一旦发出,即使后面超时了,副作用也已经发生了。
- 多步骤之间共享状态。每轮 LLM 决策都要基于前面的对话历史、工具返回结果、记忆片段,状态一乱,整个决策链就乱了。
所以,给 Agent 加超时和重试,不是在“接口层”加一个 timeout 参数那么简单,而是要在“过程层”设计一套容错机制。我最初犯的错误,就是把这套机制完全等同于普通 HTTP 调用的超时重试,用最简单的方式去套,结果引入了更大的问题。
1.1 哪些环节必须设置超时
我后来梳理了一遍,Agent 运行过程中至少有六个关键环节需要单独考虑超时,而不是只设一个总的超时时间:
| 环节 | 为什么需要超时 | 参考超时范围 |
|---|---|---|
| LLM 首 token 返回 | 判断模型服务是否真正开始响应,防止连接建立后一直等待模型输出 | 10s ~ 30s |
| LLM 完整响应 | prompt 长、输出长时模型可能需要很久,但也要设上限,避免死等 | 60s ~ 120s |
| 工具函数执行 | 外部 API、数据库查询、文件操作都可能阻塞 | 跟具体工具性质挂钩,5s ~ 30s 起步 |
| Agent 总运行时长 | 防止 Agent 陷入死循环式的“思考-调用-再思考” | 结合业务要求,常见 60s ~ 300s |
| 流式传输空闲 | 流式输出中间可能“假死”,需要心跳或空闲超时 | 30s ~ 60s |
| 队列等待 | 并发高时 Agent 任务可能排队,需要让调用方及时感知 | 根据业务容忍度 |
说“参考范围”其实也保守了,实际还是要看你的应用场景。比如你做一个数据分析 Agent,一轮要读 100 万行数据再总结,那工具超时给 30 秒肯定不够;但如果你做的是客服 Agent,用户等着回复,工具调用超过 10 秒就该降级。
我的经验是分两层设计:第一层是每个环节独立的超时,第二层是 Agent 运行总超时。总超时是兜底,防止某些环节超时设置失效或者嵌套调用导致整体时间被拉长。你可以把总超时看作“整个 Agent 任务的预算”,一旦超时,不管当前步骤是不是还正常,统一中断并走清理流程。
1.2 超时时间不是拍脑袋定的
这里给一个具体例子。我的一个项目里有个 Agent 需要调用内部文档检索 API,正常情况 90% 的请求在 1.2 秒内返回,极端情况会达到 8 秒。如果简单设一个 5 秒超时,就会导致大约 2% 的正常请求被误杀。我后来花了一周时间收集工具调用的 P50、P95、P99 延迟,再结合业务容忍度来定超时。
具体做法很简单:P95 延迟的 2 到 3 倍,作为该工具的“正常超时”;同时再设一个硬性超时,通常是正常超时的 5 倍,用于兜底。为什么不是直接取最大值?因为那些极端值往往就是故障信号,如果无限容忍,超时机制就失去意义了。
这个逻辑跟追公交车一样:你等车,95% 的情况下 5 分钟内一定会来,那你就等 15 分钟;但如果 30 分钟都没来,就要怀疑是不是线路出问题了,而不是继续干等。
1.3 超时后怎么通知调用方
超时之后调用方需要感知。我当时设计了超时告知三段式:
- 超时时间点:告诉前端或上游“这次请求超时了,响应时间超过了 xxx ms”。
- 状态描述:Agent 当前处于哪个阶段,比如“正在调用商品查询工具”。
- 可恢复性提示:这次超时是“可直接重试”,还是“可能导致数据不一致,建议人工介入”。
这也是我后来踩坑的伏笔之一——我一开始觉得超时之后直接把异常往上抛就行,根本没考虑 Agent 内部已经执行到哪一步了。
2. 重试机制:不是所有失败都适合“再来一次”
重试是超时的自然延伸。超时代表这次尝试没有在预期时间内完成,重试则代表我们想再给它一次机会。但在 Agent 场景里,“再来一次”的代价比普通接口大得多,因为每次重试可能都会重新执行工具调用,而工具调用是有副作用的。
2.1 哪些失败可以重试
我总结了一个非常简单的判断标准:看这次失败有没有可能对系统留下副作用,以及副作用是否幂等。
- LLM 推理超时、网络抖动、模型服务返回 5xx,这些属于“安全重试”。LLM 本身没有副作用,你重新生成一次,结果可能不同,但也可能产生两个版本的回答,这时候要清掉上一次生成的部分内容。
- 只读工具(查库存、查天气、算价格)超时,可以安全重试,因为不改变系统状态。
- 写操作工具(下单、转账、发消息、改数据库)超时,绝不能无条件重试。原因很简单:你这边超时了,不代表服务端没收到请求。很可能请求已经处理成功,只是响应丢了。这时候再重试,就是重复下单、重复转账、重复发消息。
- 非幂等操作即使重试,也必须带上请求 ID 做去重,或者走“查询-确认”流程,先查上一次操作的结果,再决定是否补做。
2.2 重试退避策略
重试不是越勤越好。我在早期犯过一个错误:Agent 里某个工具调用失败后,我直接在同一毫秒级延迟内重试了五次,结果不仅全部失败,还把一个下游服务打到 RT 飙升。后来我用的是指数退避加抖动:
import random import time def retry_with_backoff(func, max_retries=3, base_delay=1.0): for attempt in range(max_retries): try: return func() except Exception as e: if attempt == max_retries - 1: raise delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5) time.sleep(delay)第一次失败后等约 1 到 1.5 秒,第二次等约 2 到 2.5 秒,第三次等约 4 到 4.5 秒。为什么加随机抖动?因为如果很多 Agent 实例同时失败,它们会按相同的退避节奏重试,结果还是会在同一时刻打爆下游。加一个 0~0.5 秒的随机抖动,能把“共振”变成“分散”。
更重要的是,每次重试前都应该重新评估 Agent 状态。我第一次做的时候,只在函数外层套了个重试装饰器,完全不关心 Agent 已经思考到哪一步了。结果 LLM 之前已经根据旧工具结果生成了半段结论,重试之后,新结果和旧结论混在一起,生成的内容前后矛盾,这就是典型的“重试污染”。
2.3 重试粒度选择
这里有个关键问题:发生失败时,是只重试失败的那一步,还是把整个 Agent 从头跑一遍?
我经历过两个极端。一开始我选择整流程重试——直接把 Agent 的整个“规划-执行-观察-总结”循环重新跑一遍。好处是实现简单,坏处极多:浪费大量 token、重复执行没有副作用的只读工具还可以,但写操作会被重复执行;而且如果 Agent 在第一次运行中修改了外部状态,第二次运行时的初始上下文就不同了,结果可能完全不同。
后来我改成“最小粒度重试”:只重试失败的那个工具调用或者那次 LLM 生成。为此我需要在状态里记录当前执行到了哪个节点、已经拿到了哪些中间结果、哪些工具调用已经成功执行。这一步逼着我去正视状态管理。
最终我采用的方式是:根据失败类型分级。
| 失败类型 | 重试粒度 | 说明 |
|---|---|---|
| LLM 调用失败 | 仅重试当前 LLM 生成 | 清理掉 LLM 已输出的中间增量,重新生成 |
| 只读工具失败 | 仅重试该工具调用 | 更新工具结果即可 |
| 写工具失败 | 不自动重试 | 进入特殊流程:查询对账、人工兜底、标记任务为待确认 |
| Agent 总循环超时 | 不自动重试 | 因为状态可能已经混乱,必须先清理再决定是否重建 |
这些设计听着不复杂,但真正实现起来,最大的工作量都落在“状态清理”上。也是从这里开始,我才意识到之前完全低估了状态清理的分量。
3. 状态清理:那些真正让人崩溃的问题都出在这里
前两部分说的超时和重试,还只是“表面功夫”。我把超时重试全部上线之后,系统确实稳了一段时间,但随之而来的是更隐蔽的 bug:Agent 重试后带着上一轮的记忆继续跑、明明超时了任务还在后台改数据、会话结束之后内存里面的状态却一直不清空,直到系统 OOM。
这些问题的根源只有一个:超时和重试会使“Agent 的逻辑状态”和“系统的真实状态”产生偏差,而我却没有在状态清理上做好文章。
3.1 Agent 的状态到底包括什么
很多人一想到 Agent 状态,第一反应就是“对话历史”。其实远不止。我列一个完整的清单:
- 对话上下文:用户输入、历史消息、中间结论。
- 执行轨迹:Agent 已经执行了哪些节点、调用了哪些工具、每个工具返回的结果是什么。
- 临时变量:例如当前正在处理的文件路径、待注册的订单数据、从数据库读出来的用户信息。
- 会话锁/租约:为了防止同一个会话被并发调用加上的锁,比如 Redis 里的 setnx key。
- 外部副作用记录:已经发出去的消息 ID、已经写过的数据库记录、已经扣减过的库存。
- 队列/回调状态:Agent 任务是否已经在消息队列里被消费,是否已经触发了回调通知。
- 内存缓存:比如 LangGraph 中 checkpoint、Spring AI Agent 里的 conversation memory。
如果这些状态中有一个不被清理,就会在超时或重试时产生异常行为。举个最典型的例子:Agent 在处理“给用户退款”这个任务时,第一步调用支付接口成功退款了,然后第二步写日志时超时了。这时候整体任务超时,你选择重试整个 Agent。重试后 Agent 又执行了退款步骤——用户被退了两次钱。
你说这能怪重试机制吗?不能,根子在于你给 Agent 加超时和重试的时候,没有同步实现“状态恢复”或“状态清理”。重试前应该先检查:退款这个动作到底成功没有?如果成功了,下一步应该是跳到写日志,而不是从头再来。
3.2 超时后“任务已死但状态没死”的现象
我踩过一个非常经典的问题:用 FastAPI 接收 Agent 请求时,我设置了整个请求的 timeout。当 timeout 触发时,FastAPI 直接返回了 504 给前端,但我后端的事件循环里,那个 Agent 任务并没有被取消,它还在后台默默地继续跑。结果前端用户看到“超时”,以为任务失败了,于是再次点击提交,又创建了一个新 Agent 实例。两个实例同时操作同一个用户订单,最终数据被篡改。
这就是“任务已死但状态没死”的真实案例:超时中断的是调用方的等待,而不是 Agent 本身的执行。如果你在超时回调里不去主动取消任务、不去清理正在执行的资源,那么超时就是一个假象,任务还在背后消耗资源,产生你不知道的副作用。
后来我总结出一条铁律:超时和重试必须成对实现“取消与清理”逻辑。超时不只是“返回一个错误”,而是一个完整的中断流程:中断执行、清理未完成的尝试、标记外部副作用所处状态、释放会话锁。
用生活化的比喻来说:你往洗衣机里放了一堆衣服,中途突然停电了(超时)。真正的“超时处理”不是告诉你说“洗完了”,而是要帮你把还湿着的衣服拿出来,并且记清楚哪些衣服已经洗了哪些没洗,否则来电后你又按一次开始,那些已经洗过的衣服就会被重新洗一遍,甚至洗坏。
3.3 状态清理的三种粒度
状态清理并不只是简单清空内存。我根据 Agent 的生命周期把清理分成三种粒度,实际项目中三个都要做。
第一种:步骤级清理。每次 LLM 生成结束、每个工具调用结束之后,清理本步骤产生的临时状态。比如 LLM 流式输出过程中产生的内容片段,如果 LLM 这步失败了,需要把这些片段丢弃;工具调用产生了局部缓存变量,用完之后应当从上下文中移除,避免污染后续决策。
第二种:会话级清理。一次用户请求对应的完整 Agent 会话结束之后,清理整个会话上下文、释放会话锁、删除临时文件、清空步骤轨迹。注意,如果你需要长期记忆,那记忆数据要单独存放,而不是保留在“运行态上下文”里。
第三种:全局级清理。比如由于超时导致 Agent 实例被遗弃时,需要有一个“垃圾回收”机制,定时扫描处于异常状态的会话,清理掉这些会话占用的锁、缓存和队列资源。这个有点像 JVM 的 GC,不清理就会内存泄漏。
我遇到的一个线上 bug 就是因为忽略了全局清理:有一个 Agent 会话在调用外部工具时超时了,但我只在请求返回路径上做了超时处理,没在工具执行线程里做清理。于是会话的 Redis 锁一直没释放,后续所有对该用户 ID 的请求都卡在“获取锁失败”上,而且锁的 TTL 设置得过长,整整影响了几个小时。后来我改成“锁必须随着任务取消而释放”,以及“锁要设置合理的短 TTL 并在释放时校验 owner”,才算彻底解决。
4. 基于 FastAPI + LangGraph 的状态清理实战
理论说再多,不如跑一个例子。我用的技术栈是 FastAPI + LangGraph,这也是目前比较主流的 Agent 组合。LangGraph 本身提供了 checkpoint 机制,可以在中断后恢复,但这个机制如果配置不当,反而会放大状态清理的复杂度。这里我完整演示一遍我的做法。
4.1 定义结构化状态,而不是一堆散乱的全局变量
LangGraph 要求你定义 State,所有节点共享。我见过不少人把全局变量或者类成员变量塞进 Agent 里,这在并发场景下风险极大。正确做法是把所有状态显式定义在 State 里:
from typing import TypedDict, Annotated, Optional import operator class AgentState(TypedDict): task_id: str # 任务唯一ID,用于幂等 user_id: str # 用户标识 messages: Annotated[list, operator.add] # 对话历史 tool_results: dict # 每个工具最后一次成功的返回 tool_attempts: dict # 工具调用尝试次数 pending_effects: list # 待确认的外部副作用(例如已发出的请求) finished: bool # 是否已完成 error: Optional[str] # 最近一次错误相比定义一个简单的“history: list”,这样的状态结构让清理变得可追踪。每个节点都能知道自己之前执行到了哪一步,以及哪些外部副作用已经发生。
4.2 给每个步骤写入执行记录
另一个关键做法是:在进入每个节点时,先把这一步的意图写入状态;在退出节点时,把实际执行结果也写入状态。再结合工具调用的幂等 ID,超时后重试时就能判断“这一步到底做了没有”。
def execute_tool_with_idempotency(state: AgentState, tool_name: str, request_payload: dict): call_id = f"{state['task_id']}:{tool_name}:{hash_payload(request_payload)}" # 先检查这个调用是否已经执行过 existing = state["tool_attempts"].get(call_id) if existing and existing["status"] == "success": return existing["result"] # 直接复用,避免重复执行 # 执行工具,并把结果记录进状态 try: result = call_tool(tool_name, request_payload) state["tool_attempts"][call_id] = {"status": "success", "result": result} return result except TimeoutError: state["tool_attempts"][call_id] = {"status": "timeout", "pending": True} raise注意我还把超时状态记录了下来。这样当一个工具超时但不确定下游是否已经执行时,重试逻辑就可以去查询“下游执行状态”,而不是再一次盲目发送请求。
4.3 FastAPI 层的超时处理
FastAPI 层我用了asyncio.wait_for包住整个 Agent 执行流程。关键点在于:超时之后,不是直接返回错误,而是先触发一个清理函数。
import asyncio async def run_agent_task(submit_data: dict): task_id = submit_data["task_id"] agent = build_agent() try: result = await asyncio.wait_for( agent.ainvoke(submit_data), timeout=60.0 ) await cleanup_agent_state(task_id, reason="completed") return result except asyncio.TimeoutError: # 关键:不能只是 raise,要主动取消任务 await force_cancel_agent(task_id, reason="timeout") await cleanup_agent_state(task_id, reason="timeout") raise HTTPException(status_code=504, detail="Agent execution timeout")force_cancel_agent里做什么?要能取消在任务内部创建的 asyncio 子任务。如果你 Agent 内部用了同步阻塞工具,还需要把工具执行放到线程池里,取消时用concurrent.futures处理。注意 asyncio 的取消不是万能的,如果一个同步函数阻塞在线程池里,你没法直接终止线程,只能等它自然结束,但可以利用回调让它尽快感知取消。
我在 LangGraph 里用的是ainvoke,它内部会执行多个节点。如果整体超时,LangGraph 本身不会自动清理 checkpoint,这就要我们密切配合。
4.4 状态清理函数的实现
这一步是我这篇文章里最核心的实践部分。我写了一个通用清理函数,涵盖四类动作:
async def cleanup_agent_state(task_id: str, reason: str): # 1. 释放分布式锁 await release_session_lock(task_id) # 2. 清理临时文件和临时数据 await delete_temp_files(task_id) # 3. 将外部副作用标记为待确认或执行补偿 await mark_pending_effects(task_id, reason) # 4. 清理内存/Redis 中的运行态上下文 await delete_runtime_context(task_id) # 5. 发送状态变更事件,让相关系统感知 await notify_agent_finished(task_id, reason)mark_pending_effects专门处理“超时但不确定副作用是否已发生”的情况。它会把任务标记为UNCERTAIN,然后之后通过一个对账任务去查询支付、下单等操作的真实状态。这个思路参考了分布式事务里的 Saga 和 Outbox,Agent 场景同样适用。
我分享一个小小的经验:如果你使用了 LangGraph 的checkpoint持久化功能,那清理时不仅要删运行状态,也要把 checkpoint 中对应会话的数据删除或标记失效。否则你下次 resume 一个陈旧会话,Agent 会带着之前混乱的记忆继续工作,这比没有 checkpoint 还危险。
4.5 重试前先“重置到安全快照”
有了状态清理能力,重试就不再是简单地重新执行,而是先恢复到上次“已知安全”的状态,再继续。
我在 LangGraph 里做了一个快照机制:在 Agent 开始执行任何一个“副作用工具”之前,把当前状态打一份快照。如果该工具超时了,并且我们不能确定是否执行成功,就走“查询对账分支”,而不是直接重试。如果 Agent 在某一个无副作用节点失败,则恢复到最近快照再重试。
用代码表示大概是:
async def safe_retry(agent_state: dict): if agent_state["pending_effects"]: # 有未确认的外部副作用,不能盲目重试 await query_and_align(agent_state["pending_effects"]) if not all_confirmed_success(): raise ManualInterventionRequired() # 恢复到上次安全快照 safe_snapshot = load_safe_snapshot(agent_state["task_id"]) agent_state.update(safe_snapshot) return run_agent_from_snapshot(agent_state)这一套跑下来,线上那些诡异的“重复执行”“状态污染”问题才算真正解决。
5. 常见问题与排查技巧实录
这部分把我实际踩过的坑整理成一份速查表,方便大家对照排查。以下每个问题我都经历过,并且都能在状态清理上找到根因。
5.1 速查表:超时重试后状态异常的常见症状
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent 重试后输出明显重复内容 | 旧对话历史没清理,新上下文带着旧结论 | 打印 Task 的 messages,看是否包含上一次半截结论 | 重试前重建 messages 或打快照清增量 |
| 同一个写操作被执行多次 | 超时后无脑重试整个 Agent | 看数据库/下游系统操作日志的时间戳 | 引入幂等 ID,配合去重表或查询对账 |
| 超时返回后任务还在后台跑 | 超时只是中断了调用方等待,任务线程没被取消 | 检查进程内是否还有同名任务,看日志输出 | 用 asyncio 取消任务,或加入线程中断标志 |
| 两个并发请求互相覆盖状态 | 会话锁未在超时后清理,或状态存到了共享变量 | 查看 Redis 锁的 TTL 和释放时机 | 为每次会话设置唯一 lock owner,释放前校验 |
| Agent 响应越来越慢 | Agent 上下文里的消息越来越多,旧状态没清理 | 查看 messages 长度、token 消耗 | 定期裁剪历史,或把长期记忆与短期运行态分离 |
| 重试后工具参数变成上一次的 | 工具调用的 payload 被保存在全局变量中,被上一次覆盖 | 打印每次工具调用入参 | 所有输入输出走 State,不做跨请求的变量复用 |
| 流式输出出现“先说完结再说开头” | LLM 流式输出的部分结果被缓存,重试时没有清空缓冲区 | 查看流式 chunk 日志 | 每次 LLM 生成前重置输出 buffer,失败时 discard buffer |
| Agent 状态持久化后越滚越乱 | Checkpoint 保存了未完成状态,恢复时加载了旧轨迹 | 查看 checkpoint 中的节点执行历史 | 清理失效的 checkpoint,或每次新任务用新的 session id |
5.2 排查状态清理问题的三个入手点
如果你现在还看不出来自己的 Agent 是哪里状态乱了,我的建议是先做三件事:
第一,给 Agent 每次请求打上全局唯一的 trace id,把所有节点、工具调用、LLM 调用都带上这个 trace id 打印日志。这样超时重试之后,你就能清晰地对比“上一次执行到哪一步”“这一次从哪一步开始”。我使用之后效果立竿见影,几乎能在几分钟内定位到是哪一个状态字段没清理。
第二,把 Agent 运行的关键状态在每次节点切换时序列化成 JSON,写入本地日志或监控系统。你不需要一直开启这个功能,但在排查问题的时候打开,相当于给 Agent 做了一次“全麻记录”,事后可以复盘它为什么做了偏激决策。
第三,给每一个“可能产生外部副作用”的工具调用配上对账接口。如果超时后不确定是否成功,可以直接调对账接口查询。这个其实很多支付系统已经在做了,但在 Agent 工具链路里,几乎没人从一开始就设计进去,因为大家都默认重试是安全的。
5.3 一些容易被忽略的状态清理细节
我在这里补充几个细节,猜大概率也会成为你的“惊喜坑”:
- 文件句柄和临时目录。Agent 有时候会生成临时文件、调用外部进程。一旦超时,这些文件不会自动清除。我一直建议所有临时文件都放在以 task_id 命名的目录里,超时清理时直接删除整个目录,避免遍历删除漏网之鱼。
- 内存引用。如果你的 Agent 对象被保存在全局字典里,那么即便任务超时,只要字典里还有引用,对象就永远无法被 GC 回收。这种情况在长时间运行的服务上会逐渐表现为内存增长。解决办法是把运行中任务放进 WeakValueDictionary,或者显式在结束后从字典移除。
- 消息队列。Agent 超时重试时,可能会把同一个消息重复投递到队列里。如果你的队列消费端没有做 dedup,你就可能看到同一个任务被同时执行多次。我后来在所有消息里都加入 event_id,消费端记录已经处理过的 event_id,重复消息直接丢弃。
- 外部回调状态。Agent 经常会注册 Webhook 或回调地址,用于异步等待第三方结果。超时之后,注册的回调还挂在第三方那边,如果不清除,后续可能收到一个“过期回调”,导致 Agent 在未来某个时间突然被唤醒。这个问题我踩过,非常隐蔽。
5.4 如何设计隔离性更强的 Agent 状态
最后聊一个更上游的问题。与其等状态乱了再清理,不如从结构上设计得更抗造一点。
我强烈建议一个原则:每个 Agent 任务都应该有自己独立的“状态隔离域”。听起来玄乎,做起来其实就是三点:
- 不共享全局可变量。所有运行时数据挂在任务维度的上下文对象里。
- 为每个任务生成独立的临时数据库事务或独立 schema。至少也要给关键状态表加上 task_id 列,查询和清理都按 task_id 隔离。
- 状态过期必须有 TTL。在 Redis 里存状态,必须设置合理的 TTL,绝对不能永久保存。这个 TTL 可以比 Agent 总超时略长,但不能无限长,否则即使清理逻辑漏了,状态也能自己腐烂。
我在某个版本里就是靠 TTL 救回一次线上事故:当时清理逻辑有 bug,锁没释放,但是因为锁设置了 10 秒自动过期,系统虽然在 10 秒内大量报错,但没有演变成长时间不可用。事后我把这个 10 秒视作系统最好的朋友。所有状态都应该设 TTL,这句话一点不夸张。
6. 写在最后的实操经验
回到文章开头的问题。我最初以为给 AI Agent 加超时和重试是件简单事,后来才意识到,这两个机制只是铺垫,真正的复杂度在于它们触发了状态混乱后,你有没有能力把状态干净地“复位”或“清理”。
如果你现在准备给自己的 Agent 加超时重试,我建议按这个顺序来做,不要跳步:
第一阶段:先把 Agent 的每一步状态显式定义出来,确保每个节点都能知道自己“执行到哪、执行了什么、副作用是什么”。
第二阶段:引入会话级、任务级的幂等 ID,保障重试时不会重复执行写操作。
第三阶段:再写超时处理逻辑,并且一定包含“主动取消”和“资源释放”,而不是只抛异常。
第四阶段:最后再设计重试策略,重试前先做状态检查和快照恢复。
第五阶段:持续的监控与对账机制。任何状态清理逻辑都可能出 bug,但监控能让你第一时间发现。我建议至少监控“超时任务数”“重试任务数”“状态清理失败数”以及“任务完成后状态残留数”。
我个人现在的体会是,AI Agent 的工程复杂度和传统分布式系统非常像,只是更隐蔽,因为它有一个不断“思考”的黑盒子在里面转。超时和重试只是表面上的保护措施,真正的鲁棒性来自于你对 Agent 内部状态的掌控力。你能把状态理得多清楚,你的 Agent 就有多稳定。若是以后有人问我 Agent 工程化最重要的一课,我的回答依旧是:先学会清理,再谈智能。