
给 AI Agent 加超时和重试之前我一直觉得这就是个“配置两个参数”的事设置一个最长执行时间超时就杀掉重试就重新跑一遍。直到我们一个 Agent 服务在线上连续挂了三次每次都是因为一次卡死的工具调用拖垮了整个流程我才真正开始认真对待这个问题。但等我花了两周把超时和重试都补上之后却发现最棘手的问题根本不是“超时后怎么拉起来”而是“超时前那个 Agent 在现场留下了什么烂摊子”——也就是状态清理。这篇文章就围绕这件事展开记录我从“给 Agent 加超时和重试”到“处理状态清理”的完整过程包括具体的实现方案、踩坑排查链路以及一些我事后复盘才想明白的设计原则。如果你是正在做 AI Agent 工程化、或者准备把 Agent 从 Demo 推到生产环境的人这篇内容大概率能帮你少走几段弯路。1. 兜底方案上线前的那次翻车不加超时的 Agent 有多危险先说一个具体的线上事故。我们最初版本的 Agent 是一个基于 LLM 循环执行的流程大模型决定调用什么工具工具返回结果再喂回大模型继续推理。听起来很简单对吧问题出在整个循环没有一个总体的执行上限。那天是下午三点多一个用户触发了一次很常规的查询类任务Agent 需要依次调用订单系统、库存系统和推荐系统三个内部工具。结果订单系统的接口因为上游数据库锁竞争响应时间从平时的 200 毫秒慢慢涨到了 30 秒、60 秒最后直接挂起不返回。而 Agent 这边的逻辑是同步等待工具结果等了 60 秒没等到就再等 60 秒——没有任何中断机制。于是那个请求就在线程池里占着茅坑不拉屎越积越多。等到傍晚六点线程池被全部打满新请求全部排队用户端的表现就是“输入问题之后一直转圈几分钟没有任何响应”。最后我们只能靠重启服务来恢复但重启前那些卡死的请求到底执行到哪一步、有没有已经调用过下游系统、调用结果是什么全成了黑盒。这个事故让我意识到两层问题第一层Agent 必须要有超时机制而且是分层级的超时。单次工具调用要有超时整个 Agent 执行必须要有总体 Deadline。第二层超时不是“一键终止”那么简单——终止之前这个 Agent 的运行现场到底处于什么状态是一件直接被忽略、但后来变成最大麻烦的事情。事后我查了很多公开资料发现这个问题并非个例。很多 Agent 框架包括 LangChain、LangGraph、各类国产编排框架在设计时都假定“工具会正常返回”但对于“工具超时后 Agent 的内部状态如何一致性收敛”这一块大多数开源项目都处理得很粗糙。有的框架干脆在超时后直接抛异常终止整个 run有的把异常当作文本塞回给模型让它自行判断——但没有一个框架能替你回答“上一轮已经调用的外部系统副作用到底算不算数”。所以这篇文章的基调是这样的先讲超时和重试怎么做再重点讲状态清理这个所有人都会碰到、但很少有人系统分析过的坑。2. 超时设计不是给 HTTP 请求计时那么简单2.1 三个层级的超时缺一不可我最初犯的错误是只给工具调用所在的 HTTP Client 设置了 timeout。这个做法的局限很明显HTTP 超时只能管住“网络请求”这一层但 Agent 的一步执行往往包含很多内容——大模型推理、工具结果解析、多轮上下文拼装、内置代码执行比如 Python REPL、以及多个外部系统调用之间的编排。任何一个环节卡住都会让整体流程悬挂。后面我重新设计了三个层级的超时缺一不可层级超时位置典型阈值说明L1单次外部工具 HTTP 调用5-10 秒管住网络层防止某个下游接口把 Agent 拖死L2单轮“模型决策 工具执行”循环30-60 秒管住大模型推理和工具返回的组合耗时L3整个 Agent 任务执行2-5 分钟管住全流程防止 Agent 陷入无限循环L1 是大多数人都会想到的但 L2 和 L3 才是真正防止 Agent 失控的关键。我在线上见过的最典型失控模式是Agent 在某个节点上一个劲地生成“工具调用意图”但每次生成的参数都不合法工具层反复报错报错文本又被塞回上下文模型看了报错又继续生成下一个不合法调用——如果只设了 L1 超时这个循环可以无限持续下去因为每一步本身都不慢。L3 需要在 Agent 循环的最外层包一个 Deadline 机制。在 Python 里可以直接给 asyncio 包的 Task 加上asyncio.wait_for在循环内部则随时检查time.monotonic()和 Deadline 的差值。Go 里自然是context.Context配合context.WithTimeout。2.2 context 传递超时控制的核心工程点如果你用 Go超时控制的关键工程问题只有一个context 有没有被完整地传到每一个调用点上。这句话听着简单实际做起来到处都是坑。举个例子。我们的 Runner 最开始负责调用 Agent 循环给循环加了一个超时 context但这个 context 传递到某个内部服务调用的 HTTP Client 时代码里因为context.Background()随手一写把父级 context 丢掉了。结果是总控的超时到点了Runner 确实被中断了但那个 HTTP 调用还挂在后台线程里继续跑。下游系统的日志显示请求还是成功处理了可我们这边 Agent 已经判定超时失败两边状态直接不一致。正确做法是从最外层创建一个带超时的 context然后所有下游链路只准使用从这个 context 派生出来的子 context禁止在中间任何一层替换成context.Background()或者context.TODO()。后来我在代码评审里加了一条硬性规则所有请求维度的函数签名第一个参数必须是 ctx谁要绕过谁负责。这也是从那次事故里被逼出来的。2.3 超时阈值该怎么定别拍脑袋去看 P99 分布超时阈值设多少是一个很有争议的问题。设太短正常执行的任务会被误杀设太长超时机制就失去了保护意义。我建议的做法是把每个 L1/L2/L3 层级的耗时数据全部埋点分别统计 P50、P95、P99 和 max然后以 P99 加上一个缓冲作为预置阈值同时在配置中心留一个可动态调整的口子。不要用平均值因为平均值会被极端值拉偏也绝对不要照着网上随手搜到的“10 秒”作为万能答案——不同功能差异极大比如一个本地检索类工具可能 500 毫秒就返回而一个调用外部 AI 生图服务的工具跑 20 秒也是正常的。真要上手操作有个简单方法先以最宽阈值上线比如 L3 设 5 分钟跑一周看日志把正常任务的 P99 算出来再砍掉 30% 左右作为正式阈值。之后每周观察误杀率如果误杀率超时但后续人工审计发现任务其实完成了超过 0.5%就需要重新调阈值。3. 重试策略做了幂等设计才算真正的重试超时只会让一个任务失败但失败之后要不要重试、怎么重试又是一个新的旋涡。这一节我只讲一个最要命的问题Agent 的重试和普通 HTTP 请求的重试不一样因为你不能无脑重放整个执行过程。3.1 普通服务重试 vs Agent 任务重试常规的 HTTP 接口重试逻辑很简单请求失败等一会儿重发同一个请求。因为一个典型的 HTTP 请求一般只对一个后端系统产生一次副作用重发虽然可能造成重复写操作但配合上请求体里的唯一 ID大多数系统都能做幂等。但 Agent 任务不是一次请求而是一连串动作的序列——它可能在重试之前已经调用了三个工具、生成了两份文档、给一个外部系统发了一条消息。如果你只是把整个任务重新跑一遍那前面那些已经生效的调用就要么被重复执行要么被再次尝试最终结果大概率是一团乱麻。这里有一个我踩过的非常具体的例子。任务流程里有一个步骤是“给用户创建工单并发送提醒”Agent 调用了一个后端 API 创建工单。由于该 API 响应超时实际上工单已经创建成功了Agent 整体超时失败。重试开始后Agent 重新跑全流程又调用了一次创建工单 API——用户最终收到了两张重复工单。这就是 Agent 重试和普通请求重试最本质的区别普通请求的幂等靠“请求 ID”Agent 任务的幂等靠“对已发生副作用的感知”。后者难得多因为你必须回答一个问题上一次执行到哪一步了哪些副作用已经发生了3.2 幂等键与副作用补偿我后来在两个层面分别做设计第一个层面是每次 Agent 任务在开始时生成一个全局唯一run_id所有下游系统调用都会在请求头或请求体里带上这个run_id下游系统负责对带相同run_id的“创建型操作”做幂等。比如工单系统的 API如果发现同一个run_id已经创建过工单就直接返回已成功的老结果。第二个层面是Agent 执行引擎自己维护一张“副作用日志表”每调用一个外部系统就记录一条run_id, step_id, tool_name, request_digest, response_digest, 状态。重试的时候引擎会先加载这张表把已经成功的步骤标记为“跳过”只重跑失败或未执行的步骤。这样才是真正意义上的“继续执行”而不是“从头再来”。如果某些外部系统根本没法做幂等就要用补偿操作比如“创建了临时文件但后续流程失败”就主动删除该文件“扣款成功但通知失败”就调用退款接口。补偿也是一种状态清理后文还会详细讲。3.3 指数退避和熔断才是重试的灵魂重试不能像连珠炮一样立刻再来一次。Agent 任务本身往往要消耗大模型调用而大模型调用是按 token 计费的无脑重试会把成本打上天。我设计重试策略时遵循了三条原则只有特定错误码才触发重试网络错误超时、连接重置、下游 5xx允许重试业务逻辑错误工具参数非法、鉴权失败、下游 4xx不重试直接把错误内容返回给大模型让它重新规划。重试必须有上限且采用指数退避1s - 2s - 4s - 8s - 16s超过 5 次直接宣告任务失败。连续失败达到阈值后开启熔断熔断点位针对的是“某一个工具”而不是“整个 Agent”。比如某个下游工具在 1 分钟内连续失败了 8 次就熔断该工具 30 秒期间 Agent 再想调用它就直接返回错误避免为必然失败的调用反复烧钱。这里有个容易忽略的点重试间隔时间的计算要基于 L3 的整体 Deadline。假如整个任务只剩 10 秒就要超时这时候再发起一次退避重试就不合理了因为重试注定跑不完。所以每次重试前都要重新检查剩余时间剩余时间不足以支撑一次完整的重试就直接放弃。4. 状态清理才是真泥潭一次卡死后的烂摊子怎么收拾这是全文的核心。前面讲的超时和重试是“手段”它们的存在价值恰恰是引发了一系列关于状态的问题。我复盘线上事故后发现真正让人崩溃的时刻不是“任务超时了”而是“超时之后系统里留下了一个半执行、半成功、半失效的 Agent 执行现场”——你既没法让它继续跑也没法干净地从头跑。这一节我把状态的具体形态和对抗手段拆开说。4.1 先看一个真实事故超时后大模型的“记忆”全乱了我们有一次用 LangGraph 编排了一个稍微复杂的任务流程Agent 先收集用户需求然后调用一个代码解释器做数据处理最后生成报告。数据处理那一步跑得很久突破了超时上限外层直接抛异常终止了任务。我们当时的重试策略很简单从零开始重新构造一个新的 LangGraph 实例再次运行。第一次跑的时候Agent 已经就“用户需求”和“数据处理方案”问了好几轮并产生了一些中间结论。第二次从零开始时系统 prompt 和用户需求原样重放看起来没毛病但坏就坏在工具调用的副作用已经残留下来了第一次运行时创建了一个临时数据表、写入了中间计算结果。第二次运行时Agent 依然调用了“创建数据表”的工具但这个工具是不做幂等校验的报错“table already exists”。大模型看到这个报错后自己“脑补”了一个结论认为上一次运行成功生成了数据就直接跳到报告生成步骤把一份基于空表的报告交了出来。这个事故里超时后没人清理掉第一次运行留下的临时表重试后新运行看到旧状态反而误判了当前局面。这就是我说的“状态清理”最典型也最常见的坑——前一次执行留下的外在状态污染了后一次执行的内在大模型推理。4.2 拆解 Agent 的状态都有哪些“残骸”要系统处理状态清理必须先搞清楚 Agent 在一个任务执行过程中到底产生了哪些状态。我梳理了一下大致可以分成四类状态类型具体示例超时后残留会引发什么问题LLM 上下文消息多轮对话记录、工具返回文本、中间推理结论重试时若强制重新拼接会导致模型推理错乱、重复动作外部系统副作用已创建的工单、已发送的邮件、已写入的临时表、已调用的付费 API重放时产生重复副作用旧副作用干扰新推理运行时进程状态内存中的迭代器游标、步骤编排器的当前节点、线程池任务、协程栈被强制终止后无法安全恢复或清理可能造成资源泄漏缓存与持久化中间态Redis 里的临时 Key、数据库中的半成品记录、分布式锁锁不释放会导致后续任务全部阻塞脏数据会成为下次任务的输入第一类状态往往被框架自己接管LangChain 会把 messages 保存在内部第二类状态往往只有开发人员自己清楚第三类状态是最难搞的而第四类状态最容易被忽略。我在设计状态清理方案时对这几类分别采用了不同策略。4.3 状态清理的工程化方案快照、回滚与干净上下文针对上面四类状态我最终落地的方案可以概括为“三个动作”动作一执行前打快照执行中记录变更超时后按快照回滚。这里的快照不是虚拟机那种重量级快照而是逻辑层的轻量快照。在任务开启时一次性记录 Agent 在外部系统中将会影响的“资源版本”比如临时表的建表 SQL、会写入的缓存 Key 列表、需要创建的工单模板。一旦任务超时且确认要放弃就执行回滚脚本——把已建的表删掉、把已写入的 Key 删掉、把已创建的临时资源标记为失效。这个方案听起来怎么这么像数据库事务没错它的本质就是把 Agent 任务当作一个长事务来对待。但 Agent 的长事务比数据库事务复杂得多因为其中每一步都可能调用外部不可回滚的 API。所以遇到真正不可回滚的副作用比如“已发送邮件”“已扣款”回滚动作只能是补偿操作发一封“处理失败请忽略前序通知”的邮件或者执行退款。动作二用“干净上下文”重建重试现场不直接复用旧上下文。这一步直接回应前面那个“临时表报错”的事故。我的做法是重试时system prompt原样保留但旧轮次的对话消息全部丢弃取而代之的是从副作用日志表里生成一段“事实摘要”例如“注在此前的一次尝试中以下操作已经成功完成创建临时表 tmp_xxx以下操作未完成生成报告。请基于当前已知事实继续不要重复执行已完成的操作。”大模型读了这段摘要后往往会做出正确判断而不是凭空脑补。这叫“状态广播”而不是“状态复用”。刚开始我也担心丢弃上下文会不会让模型丢失重要信息实测下来发现并不会——因为摘要已经把关键结果全部覆盖了丢掉的反而是那些“没用的过程消息”和“已经过时的中间推理”模型反而更清醒。动作三强制清理运行时资源与分布式锁别让协程和锁陪伴你的失败一同长存。超时终止后协程栈里可能还挂着其他子任务。框架层面的做法是用asyncio.TaskGroup管理所有的子任务超时后统一cancel()并对每个子任务执行await asyncio.gather(..., return_exceptionsTrue)来保证它们真正退出。这个细节非常关键因为我最初试过直接抛异常退出结果任务虽然中断了但子协程还在后台跑日志里都是一堆堆孤立执行的残留调用。分布式锁的清理也一样。我们用的 Redis 锁都有过期时间但过期时间往往比任务超时时间要长如果超时后没人主动释放锁后面所有想申请这个锁的新任务都要等到超时。正确的做法是锁的持有时间和任务的 Deadline 绑定任务超时的同时主动释放锁并且在锁的 value 中写入 run_id释放时用 Lua 脚本先比对再删除防止误删别人持有的锁。4.4 状态清理中的一个边界坑部分成功怎么判定状态清理还有一个必须回答的问题什么时候该清理什么时候不该清理如果任务在最后一步失败了前面已经成功执行了几十个操作一股脑全部回滚反而不理智——因为有些操作的成本很高比如已经生成好的模型结果、已经跑完的数据处理放弃它们是一种浪费。我的经验是引入“任务语义边界”来判断。如果一个 Agent 任务的产物是用户最终要直接使用的文档或数据那么状态清理采用“保留已成功的中间产物重试时复用”如果任务的产物是对系统产生持久副作用的变更比如自动下单、批量发消息那么状态清理采用“失败尽量补偿回滚”。判断依据可以在任务编排配置里预先声明commit_on_complete还是rollback_on_fail。给每个任务配一个“提交/回滚策略”比写死所有任务都回滚要现实得多也灵活得多。5. 用 Go 重写 Runner 后我对这套方案有了新理解在把“超时 重试 状态清理”这套逻辑跑通之后我们又做了一件比较大的重构把 Agent 的任务编排 Runner 从 Python 换成了 Go。原因其实都和这篇文章的主题有关。Python asyncio 的框架生态确实强大但在“超时后的资源回收”“并发任务隔离”这些底层保障上做起来相对费劲。asyncio 的Task.cancel()确实会抛CancelledError但你的代码里只要有一个finally块或者一个不规范的await取消就可能被吞掉导致协程悄悄存活。Go 的context.Context虽然不是万能的但它的设计哲学从一开始就强迫你思考“取消信号的传递链路是否完整”。我们重写后Agent 循环长这样ctx, cancel : context.WithTimeout(context.Background(), overallTimeout) defer cancel() results, err : agent.Run(ctx, taskRequest) if err ! nil { if errors.Is(err, context.DeadlineExceeded) { // 超时进入状态清理流程 cleanupTaskState(ctx, taskRequest.RunID) // 决定是否重试 if retryInfo.Allow(taskRequest.RunID) { taskRequest.AttemptNo return runWithRetry(taskRequest) } } }cleanupTaskState里面做的事情就是 4.3 节的那三件套读取副作用日志回滚可逆操作执行补偿操作刷新分布式锁。整个逻辑在一个独立模块里并且这个模块自己也有超时——因为清理操作本身也可能失败不能让清理把系统再次拖死。换 Go 之后另一个收益是单个 Runner 进程可以扛住更多的并发 Agent 任务因为 goroutine 的开销比协程在调度上的不可控性小很多。这直接回应当前很多团队关心的“AI Agent 怎么扛并发”问题——Agent 并发的瓶颈往往不是 LLM API而是编排层撑不撑得住那么多并行的状态管理。用 Go 做编排用 Python 做模型调用这个组合在当前阶段是比较实用的架构形态。听不少做 Agent 的同行提过 Rust 路线我们暂时没有切到 Rust 的硬需求但原理是一致的编排层要小、要快、要稳状态清理的逻辑要完全可控。语言只是实现手段状态管理的核心思想才是工程上真正要守住的东西。6. 给正在做 Agent 工程化的朋友的几条实操建议按我的实际运作经验把最重要的注意点压缩成清单——每一条都是上面某次事故的直接产物如果你正在做类似的工作碰到同类问题可以按这个顺序排查。先分清“可重试”和“不可重试”的失败类型再写重试逻辑。不要看到超时就一律重试先把错误分类器写好网络类错误可重试业务类错误直接把语义交给大模型自己判断4xx 一票否决。任何创建型工具调用务必带幂等键下单。run_id step_id这个组合最简单有效逼着下游做幂等如果下游不愿意配合改造那就只能在编排层做副作用日志靠日志判断是否跳过。超时后立刻清理分布式锁不要等锁过期。锁过期时间设成任务超时时间的 1.5 倍左右任务正常结束时主动释放超时强制结束时也主动释放。释放前一定要比对持有者 IDrun_id防止误删。重试不要复用旧上下文要生成事实摘要替代。这是保住模型“判断力”的关键。一次很长的对话历史里有大量中间推理和过时的工具返回原样重放只会让模型在旧状态上做新动作产生混乱。摘要控制在几百字内把已完成的操作列清楚把未完成的目标写明白模型的表现会稳定得多。给状态清理逻辑自己设一个超时和重试上限。清理逻辑通常涉及多个外部系统的删除、补偿 API 调用同样可能失败。如果清理动作失败了记录一条独立的“需要人工介入”告警并把任务标记为requires_manual_review而不是无限重试清理。监控要做到“状态维度”。不要只看请求数和延迟要加监控 Agent 任务的平均执行步数、平均外部调用次数、超时后成功清理的比例、重试后成功恢复的比例。这些指标才是判断你这套超时重试体系是否健康的关键。我自己经历过最深的教训是超时和重试从来都不是“防御性代码”它们是“最后一道保险丝”。保险丝烧断之后不应该只想着再插一根新保险丝——你得知道这根保险丝为什么烧断以及烧断之后电路里的那些电流状态都去了哪里。Agent 工程的成熟度很大程度上就体现在面对半途而废的执行现场时系统能多优雅地收拾残局。如果这篇文章能帮你少写几行被线上告警逼出来的补丁我就非常欣慰了。你后续做 Agent 状态管理时如果碰到有意思的坑欢迎拿着具体的场景来交流我们可以继续把这一类“看似简单、实则泥泞”的工程细节聊透。