我的 AI Agent 多步任务跑着跑着就再也不动了、既不报错也不返回结果,用户那边一直转圈等到天荒地老,我盯着日志看了半天发现它卡在某一次调用外部工具的地方一动不动,最后才意识到我给每个工具调用都没设超时、一个外部接口不返回就能让整个 Agent 永远等下去
这是一次让我把 AI Agent 调工具这件事,从"调了等它返回就行",重新理解成"任何外部调用都得设超时、否则一个不返回就能把整个 Agent 永久卡死"的事故。我的 Agent 多步任务跑着跑着就再也不动了——既不报错也不返回结果,用户那边一直转圈等到天荒地老。我盯着日志看了半天,发现它卡在某一次调用外部工具的地方一动不动。最后才意识到:我给每个工具调用都没设超时,一个外部接口不返回,就能让整个 Agent 永远等下去。这篇就把这次"一个工具卡住、整个 Agent 永久阻塞"的事故,从头到尾复盘一遍。
故障现场:Agent 不报错、不返回,就那么永远卡着
我的 Agent 编排了一串步骤:理解任务 → 调工具 A 查数据 → 调工具 B 处理 → 调工具 C 生成结果 → 返回。每个工具调用,我都是"调用 → 同步等它返回 → 拿结果进下一步"。平时跑得好好的。可某天,它在处理一个任务时,卡住了:没有任何报错、没有任何结果、进度条停在中间,用户界面一直转圈。
我去看日志,Agent 的执行轨迹停在"正在调用工具 B"这一行,后面再无任何输出。我以为是 Agent 内部逻辑死循环,查了半天没有循环;又以为是崩溃了,可进程还活着、就是不动。直到我去查工具 B 对应的那个外部接口,才发现它那次因为下游故障,既没返回成功、也没返回失败,就那么挂着不响应。而我的 Agent,正同步地、无限期地等着工具 B 返回。这时我才彻底明白根因——我调用每个工具时,都是"发出请求,然后死等返回",没有给这个等待设任何超时上限。在正常情况下工具很快返回,看不出问题;可一旦某个工具因为任何原因(下游挂了、网络黑洞、对方卡死)不返回,我的 Agent 就会在那一步永远地等下去——它不报错(因为没出错,只是没返回)、不前进(因为在等返回)、也不超时退出(因为我没设超时)。一个我控制不了的外部工具的"不响应",就这样把我整个 Agent 任务,拖进了永久的阻塞。
# 我的 Agent: 调每个工具都"同步死等返回", 没有任何超时
def run_agent(task):
plan = understand(task)
a = call_tool("tool_a", plan) # 死等返回
b = call_tool("tool_b", a) # ★ 死等返回 —— 工具B不响应, 就永远卡在这
c = call_tool("tool_c", b) # 永远走不到这里
return c
def call_tool(name, args):
resp = external_api(name, args) # 没有 timeout! 对方不返回就无限期阻塞
return resp
# 工具B那次因下游故障挂着不响应:
# external_api 永不返回 → call_tool 永不返回 → run_agent 永远卡在第二步
# Agent: 不报错(没出错)、不前进(在等)、不退出(没超时) → 永久阻塞
# 用户: 一直转圈, 等到天荒地老
# 更糟: 卡住的 Agent 占着线程/内存/会话, 越积越多, 拖垮整个服务
问题被钉死在这个认知错位上:我以为"调用一个工具,它总会返回(成功或失败)",于是放心地同步等它;但现实是,一个外部调用完全可能既不成功也不失败、就是不返回——而我没设超时,就等于把"我的 Agent 要不要继续活下去"这个决定权,完全交给了一个我控制不了的外部工具。它要是永远不返回,我就永远等着。Agent 的多步编排,本质是一条由一个个"等待外部返回"串起来的链;只要其中任何一个等待没有上限,这条链就可能在那里永久断掉、整个任务永久挂起。我没意识到"不返回"是一种比"返回失败"更隐蔽、更致命的情况——失败我还能处理,不返回则让我连处理的机会都没有。我以为我在等一个迟早会来的答复,其实我把自己的命,押在了一个可能永远不开口的人身上。
第一件事:想明白"没有超时"等于把存活权交给外部
把这次事故彻底想清楚,关键是理解任何"调用外部、然后等待其返回"的操作(调 API、调工具、调下游服务、等 LLM 响应),都存在三种结果而非两种:成功返回、失败返回、以及永不返回(对方卡死、网络黑洞、下游无限期挂起)。前两种你都能处理,唯独"永不返回"会让你无限期阻塞——而防住它的唯一办法,就是给等待设一个超时上限:等够了这个时间还没返回,就主动放弃这次等待、把它当作一种"可恢复的失败"来处理。
这对 Agent 尤其致命,因为 Agent 是多步骤、强依赖外部工具的:它的每一步几乎都是"调一个工具、等它返回"。只要任何一步的等待没有超时,整个 Agent 就有一个"永久卡死点";而 Agent 任务往往运行时间长、并发多,一个卡死的 Agent 会一直占着线程、内存、会话上下文等资源,卡死的越多,资源泄漏越严重,最终可能拖垮整个服务。没有超时的等待,本质是"无条件信任外部一定会及时回应";可外部是你控制不了的,这份信任随时可能落空。给等待设超时,就是收回这份不该交出去的信任——把"我等多久"的决定权,牢牢握在自己手里,而不是交给一个可能永远不回应的外部。关键认知是:凡是把自己的"继续运行"建立在"外部一定会返回"这个假设上、又不给这个假设设兜底(超时)的,都是在拿自己的存活,赌一个自己掌控不了的东西。
# 正解: 每个工具调用都设超时; 超时当作"可恢复失败"处理
import concurrent.futures
def call_tool(name, args, timeout=10):
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
future = ex.submit(external_api, name, args)
try:
return future.result(timeout=timeout) # 最多等 timeout 秒
except concurrent.futures.TimeoutError:
# 超时: 不再死等, 当作一次可处理的失败
raise ToolTimeout(f"{name} 调用超时 ({timeout}s)")
def run_agent(task, total_budget=60):
plan = understand(task)
deadline = monotonic() + total_budget # 整个任务也设总预算
for step in plan.steps:
if monotonic() > deadline: # 总超时, 整体止损
return partial_result_with_warning("任务总超时")
try:
step.result = call_tool(step.tool, step.args, timeout=10)
except ToolTimeout:
# 超时的处理策略: 有限重试 / 降级 / 跳过该步 / 告知用户
step.result = handle_tool_failure(step) # 而不是永远卡着
return assemble(plan)
想通这一层,我才明白自己错在哪:我默认"调工具 = 它会返回",于是写了"同步死等",却没给这个"等"设任何上限——等于把 Agent 能不能继续往下走,完全寄托在每个外部工具都及时返回上。而工具是外部的、我控制不了,它一旦不返回,我就被它拖住、永久挂起。根治之道,是给每一次外部等待都设超时、超时就当可恢复失败来处理(重试/降级/跳过/告知),同时给整个 Agent 任务设一个总预算/总超时兜底。不是指望外部永远及时,而是承认它可能不返回、并为这种情况设好"等不到就主动止损"的退路。
第二件事:正解——每步设超时 + 总预算 + 超时后的止损策略
找到根因,正解就清晰了:给 Agent 的每一次工具调用(以及 LLM 调用)都设单步超时,给整个 Agent 任务设总预算/总超时;一旦某步超时,把它当作可恢复的失败,按预定策略处理——有限次重试(带退避)、降级到备用方案、跳过该步继续、或带着已知问题告知用户;绝不让任何一步无上限地死等。
// 正解: 用 context 给每步设超时, 用总 deadline 控整体, 超时即止损
func runAgent(ctx context.Context, task Task) (Result, error) {
// 整个任务总预算
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
var acc Result
for _, step := range plan(task) {
// 每一步单独的超时(派生子 context)
stepCtx, stepCancel := context.WithTimeout(ctx, 10*time.Second)
out, err := callTool(stepCtx, step)
stepCancel()
if err != nil {
// 超时/失败 → 走止损策略, 而不是永远卡着
out, err = handleFailure(step, err) // 重试/降级/跳过
if err != nil {
return acc, fmt.Errorf("step %s failed: %w", step.Name, err)
}
}
acc = merge(acc, out)
if ctx.Err() != nil { // 总预算耗尽, 整体止损
return acc, ctx.Err()
}
}
return acc, nil
}
func callTool(ctx context.Context, step Step) (Out, error) {
// 关键: 把 ctx 传进去, 让底层调用能被超时/取消真正打断
return step.Tool.Invoke(ctx, step.Args)
// ctx 超时 → Invoke 返回 ctx.DeadlineExceeded, 不再无限期阻塞
}
这套做法的精髓,是给每一个"等待外部"的动作都套上一个"等不到就放手"的上限,并让这个上限能真正打断底层调用(而不只是表面放弃、底层仍在阻塞)。单步超时防住"一个工具卡死整条链";总预算防住"很多步各自不超时、累加起来还是太久";超时后的止损策略(重试/降级/跳过/告知)则保证 Agent 在等不到时仍能往前走或体面收场,而不是僵在原地。关键是 context/cancellation 要一路传到最底层的网络调用,让超时真的生效。不是消极地等外部返回,而是主动设定"我最多等多久",到点就自己做决定。
【给 Agent 的外部调用兜底, 我现在认死的几条】
1. 外部调用有三种结果: 成功、失败、永不返回; 第三种最致命
2. 每个工具/LLM 调用都必须设单步超时, 绝不无上限死等
3. 整个 Agent 任务设总预算/总超时, 防多步累加过久
4. 超时当作"可恢复失败": 有限重试(带退避)/降级/跳过/告知用户
5. 超时/取消要能真正打断底层调用(context 一路传到网络层)
6. 卡死的 Agent 会占线程/内存/会话, 必须能超时退出释放资源
7. 没有超时 = 把自己的存活权, 交给一个你控制不了的外部
第三件事:其他"无上限地等一个不受控外部"的同类坑
顺着"把自己的继续运行,无条件押在一个不受控外部会及时回应上"这条线,我把同类的坑都排查了一遍:
第一个,HTTP/RPC 调用不设超时。客户端发请求不设 timeout,下游卡住就一直挂着,连接和线程被耗尽——和 Agent 工具调用同理。
第二个,获取锁/信号量不设超时。无限期等一把可能永远拿不到的锁,线程被永久挂起,还可能引发连锁死锁。
第三个,从队列/channel 阻塞读取不设超时或取消。等一个可能永远不来的消息,协程永久阻塞、泄漏。
第四个,等待用户/外部事件没有兜底超时。流程卡在"等审批""等回调",对方一直不动作,整个流程就僵死,没有催办/超时关闭机制。
第四件事:无超时 vs 有超时——一张对照表
我把"外部调用不设超时"和"设了超时"摆在一起对比,核心看"外部不返回时会怎样":
| 维度 | 不设超时(同步死等) | 设超时 + 止损 |
|---|---|---|
| 外部正常返回 | 正常 | 正常 |
| 外部返回失败 | 能处理 | 能处理 |
| 外部永不返回 | 永久阻塞 | 超时后止损, 继续 |
| 对资源的影响 | 卡死占线程/内存/会话, 泄漏 | 到点释放, 不泄漏 |
| 对用户 | 一直转圈, 无响应 | 拿到结果/降级/明确报错 |
| 存活的决定权 | 交给不受控的外部 | 握在自己手里 |
看清这张表,选择就毫无悬念了:不设超时只在"外部正常/失败返回"时没事,一旦外部"永不返回"就永久阻塞、拖垮资源;设超时则把这种最致命的情况也兜住,到点主动止损。我这次踩坑,正是栽在"没设超时 + 外部永不返回"这个组合上。给每次等待设超时,不是多此一举,而是为那个"看似不会发生、一旦发生就致命"的"永不返回"准备的唯一退路。
第五件事:我曾经对工具调用想当然的几个误区
这次事故也把我对 Agent 外部调用的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 调用工具它总会返回(成功或失败) | 它完全可能既不成功也不失败、永不返回 |
| Agent 卡住肯定是死循环或崩溃 | 更可能是某步在无超时地死等外部返回 |
| 不报错就说明没问题 | "永不返回"不报错, 却是最致命的卡死 |
| 超时是可选的优化, 平时用不上 | 它是防"永不返回"的唯一退路, 必备 |
| 一个工具卡住顶多这步慢点 | 同步死等会让整个 Agent 永久挂起、资源泄漏 |
这些误区的根子是同一个:我把"外部调用一定会有个结果"当成了理所当然,从而放心地无上限等待,却忽略了"永不返回"这种既不报错、又让你无从处理的第三种结果。对一个我控制不了的外部,"它会及时回应我"只是一个美好的假设,而我把整个 Agent 的存活,毫无保留地建立在了这个假设上。超时,就是给这个不可靠的假设兜的底。把"外部一定会及时回应"当成事实、又不为它落空准备退路,是这类永久阻塞的共同根源。
第六件事:编排 Agent、排查"Agent 卡住不动"时,我现在的自检习惯
现在每当我编排 Agent、或排查"Agent 不报错不返回、就那么卡着",我都会先按这张图问自己:
这张图的精髓,是"Agent 卡住先看是不是停在某个无超时的外部调用上;给每次等待设超时+总预算,超时就止损"。设计就每个工具/LLM 调用设单步超时、整个任务设总预算、超时按重试降级跳过告知止损、context 一路传到底层让超时真生效、排查就看轨迹停在哪一步、那个外部调用有没有设超时。这套习惯,让我从"调工具就同步死等"变成了"每次等待都先设好上限"——核心始终是:任何调用外部、然后等待其返回的操作(调 API、调工具、调下游服务、等 LLM 响应)都存在三种结果而非两种:成功返回、失败返回、以及永不返回(对方卡死、网络黑洞、下游无限期挂起),前两种你都能处理唯独永不返回会让你无限期阻塞,而防住它的唯一办法就是给等待设一个超时上限、等够了还没返回就主动放弃这次等待并当作一种可恢复的失败来处理;这对 Agent 尤其致命因为 Agent 是多步骤强依赖外部工具的、每一步几乎都是调一个工具等它返回,只要任何一步的等待没有超时整个 Agent 就有一个永久卡死点,而 Agent 任务往往运行时间长并发多、一个卡死的 Agent 会一直占着线程内存会话上下文等资源、卡死的越多资源泄漏越严重最终可能拖垮整个服务;没有超时的等待本质是无条件信任外部一定会及时回应、可外部是你控制不了的这份信任随时可能落空,给等待设超时就是把我等多久的决定权牢牢握在自己手里;正解是给每个工具/LLM 调用设单步超时、给整个 Agent 任务设总预算/总超时、超时当可恢复失败按有限重试带退避或降级或跳过或告知用户来处理、并让超时和取消能真正打断底层调用(context/cancellation 一路传到最底层的网络调用)。
我立下的几条规矩
这场"一个工具卡住、整个 Agent 永久阻塞"的事故,换来了我编排 Agent 时,刻进骨子里的几条铁律:
- 外部调用有三种结果:成功、失败、永不返回;第三种最致命,必须兜住。
- 每个工具/LLM 调用都必须设单步超时,绝不无上限死等。
- 整个 Agent 任务设总预算/总超时,防多步累加过久。
- 超时当可恢复失败:有限重试(带退避)/降级/跳过/告知用户。
- 超时和取消要能真正打断底层调用(context 一路传到网络层)。
- 卡死的 Agent 会占线程/内存/会话,必须能超时退出释放资源。
- 没有超时 = 把自己的存活权,交给一个你控制不了的外部。
附:我现在给 Agent 加超时兜底的"单步超时+总预算+止损"骨架
这是我现在编排 Agent 固定套的骨架——把这次踩坑的教训(每步超时、总预算、超时止损、context 传到底)固化成一套结构,让"一个工具卡死整个 Agent"那种坑再不会埋进系统:
import time, concurrent.futures
class ToolTimeout(Exception): pass
class Agent:
def __init__(self, step_timeout=10, total_budget=60, max_retry=2):
self.step_timeout = step_timeout # 单步超时
self.total_budget = total_budget # 整个任务总预算
self.max_retry = max_retry
def call_tool(self, tool, args):
# 单步超时: 最多等 step_timeout 秒, 超时抛 ToolTimeout 而非死等
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(tool.invoke, args)
try:
return fut.result(timeout=self.step_timeout)
except concurrent.futures.TimeoutError:
raise ToolTimeout(f"{tool.name} 超时 {self.step_timeout}s")
def step_with_recovery(self, step):
# 超时/失败 → 有限重试(带退避) → 仍不行就降级/跳过, 绝不死等
for attempt in range(self.max_retry + 1):
try:
return self.call_tool(step.tool, step.args)
except (ToolTimeout, Exception) as e:
if attempt < self.max_retry:
time.sleep(2 ** attempt) # 指数退避
continue
return step.fallback(e) # 降级/跳过/标记
def run(self, task):
deadline = time.monotonic() + self.total_budget
acc = []
for step in self.plan(task):
if time.monotonic() > deadline: # 总预算耗尽, 整体止损
return self.finalize(acc, warning="任务总超时, 返回部分结果")
acc.append(self.step_with_recovery(step))
return self.finalize(acc)
# 自检: 故意接一个"永不返回"的假工具, 确认 Agent 会在 step_timeout 后止损
# 而不是永久卡死(集成测试里务必覆盖这条路径)
这套骨架把我这次的教训钉死在了结构里:每个工具调用都走 call_tool 的单步超时(超时抛异常而非死等)、失败走 step_with_recovery 的有限重试带退避+降级兜底、整个任务受 total_budget 总预算约束、到点就返回部分结果止损;并在集成测试里专门用一个永不返回的假工具验证 Agent 真能超时退出。这样,任何一个外部工具的"永不返回",顶多让那一步超时、走止损,而再也不会像当初那样把整个 Agent 拖进永久阻塞、用户干等、资源泄漏。把"承认外部可能永远沉默、为每次等待设上限并备好止损退路"这个道理,沉淀成编排 Agent 的固定骨架,这是我对这次"卡死不动的 Agent"最实在的交代——毕竟,我能决定的从来不是别人何时回应,而是我愿意为它等到几时。
写在最后
回头看,这场由"工具调用没设超时"引发的"Agent 永久阻塞"事故,真正教给我的,远不止"加个 timeout"这一个技巧。它让我对"当我们让自己的'继续前进',依赖于'一个我们控制不了的外部给出回应'时,我们就把自己一部分的命运,交到了那个外部手里;如果我们没有为'它万一一直不回应'设下任何退路,那么它的'沉默',就足以让我们永远地僵在原地——而'沉默'比'拒绝'更可怕,因为拒绝至少给了我们一个可以应对的答复,沉默却让我们连应对的机会都没有,只能无尽地等",有了一次刻骨的体会。我栽跟头,是因为我把"外部一定会给我一个回应(无论好坏)"当成了理所当然,从而毫无保留地、无限期地等它——我设想的世界里,调用一个工具,它要么给我成功、要么给我失败,总会有个交代;我从没认真考虑过"它什么交代都不给、就那么挂着"这第三种可能;于是当这第三种可能真的发生时,我的 Agent 既等不到成功、也等不到失败、更没有一个"等够了就走"的闹钟,只能在那个无人回应的门口,永远地、安静地等下去,直到把自己和身后的资源一起拖垮。这让我领悟到一个关于"依赖外部与自我止损"的深刻认知:凡是把自己的"继续运行/继续推进"建立在"等待一个不受控外部的回应"之上的,都必须为"这个回应可能永远不来"准备一个属于自己的、不依赖对方的退路——也就是一个"等待的上限"(超时);因为我们能控制的,从来不是"对方会不会回应、何时回应",而只是"我愿意为这个回应等多久";放弃设定这个上限,就等于把"我还要不要继续活下去"这个本该由自己掌握的决定,拱手交给了一个根本不关心我、也可能永远不回应我的外部;而真正的健壮,不是假设外部永远可靠、永远及时,而是承认它随时可能沉默、可能卡死、可能永不回应,并为这种沉默预先备好"等到点就主动止损、自己往前走"的能力——把命运的缰绳,握回自己手里。这给了我一种看待"一切'等待一个不受控外部'之事"时的清醒:每当我要等待一个我控制不了的外部(接口、工具、锁、消息、审批、回调)给出回应时,要追问"如果它永远不回应,我会怎样?我有没有一个不依赖它的退路,让我能在等够之后主动止损、继续前进"——给每一次对外部的等待,都设一个属于自己的上限,到点就主动放手,而不是把存活权无限期地交出去;"承认外部可能永远沉默、为每次等待设上限并备好止损退路",是编排健壮 Agent、也是构建一切依赖外部的系统的关键。认清外部调用可能永不返回、无超时等于把存活权交给外部、每次等待都要设超时和止损——这,是我用一次"Agent 永久卡死、用户干等"的事故,换来的、关于 AI Agent、也关于如何把命运握回自己手里的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给 Agent 写一个工具调用时,先停一秒问"它要是永远不返回呢?我设超时了吗?",并给每次等待都加上一个上限和止损,那我对着那个"不报错也不返回、就那么卡着"的 Agent 排查的大半天,就值了。
—— 别看了 · 2026