你的 LangGraph、Temporal 或 AutoGen 工作流挂起后再也不结束。Agent A 在等 Agent B 先产出 schema,才能去写 API handler;Agent B 又在等 Agent A 写完 handler,好从里面反推 schema。两边都不动。整个 run 就那么卡着,占着一个 worker、一个超时或者一个轮询循环,日志也安静下来了。
最快的修法:给每一处阻塞等待都套上硬超时(asyncio.wait_for(...) 或框架自带的 deadline),让挂起以报错的形式暴露出来,而不是无限静默;然后把依赖图打印出来,让其中一个 Agent 先吐一个 stub/默认值,把环打断。下面的 Step 1、Step 3、Step 5 做的就是这件事。
Agent 编排里的死锁比数据库锁死锁少见,但更难发现,因为死锁看起来像「变慢」而不是「崩溃」。区分两者的关键特征是:死锁的 run 在好几分钟里 CPU 接近 0、没有任何新的 LLM API 调用,但 run 的状态还标着「running」。
先判断你属于哪一类
把你的现象对到下面的成因,再跳到对应的修复步骤。
| 你观察到的现象 | 可能成因 | 跳转 |
|---|---|---|
报 GraphRecursionError: Recursion limit of 25 reached,或某个节点永远到不了 END | conditional edge 形成意外的环 | Step 2 + Step 3 |
| 两个 Agent 各持有一把锁、从同一时间点起互相等待 | 锁的获取顺序不一致 | Step 4 |
两个 Agent 都卡在阻塞的 wait_for_reply(),而消息互相躺在队列里 | 消息队列死锁(没人去读自己的收件箱) | Step 1 + Step 5 |
| AutoGen 团队反复在「你先来 / 不,你先来」打转 | 没有回合上限或破平规则 | Step 5 + 预防建议 |
Temporal run 卡住,worker 日志出现 PotentialDeadlock / Deadlock detected | 在 workflow 线程里做了阻塞调用(2 秒检测器) | 见「Temporal 的两类死锁」 |
| run 还活着但一直空转,CPU 约 0%,几分钟没有 token 消耗 | 任何没有超时的阻塞等待 | Step 1 + Step 5 |
常见原因
1. 依赖图存在循环
最直接的原因。Agent A 依赖 Agent B 的输出 Y,Agent B 又依赖 Agent A 的输出 X,构成一个环:A -> 需要 Y -> B -> 需要 X -> A。如果两边都没有默认值或缓存值,就会永远阻塞。
怎么判断:把依赖图画出来或打印出来。在 LangGraph 里调用 graph.get_graph().draw_mermaid(),找有没有哪条边顺着走下去最终又绕回同一个节点。
2. 锁的获取顺序不一致
Agent A 先拿 resource_1 的锁,再去拿 resource_2;Agent B 先拿 resource_2,再去拿 resource_1。这是经典的「哲学家就餐」死锁。当多个 Agent 共用一个文件认领登记表或数据库行锁,却没有统一的获取顺序时,就会出现。
怎么判断:把每次加锁都记下来——Agent ID、资源名、时间戳。死锁的表现是:两个 Agent 各持一把锁、互相等对方那把,且都从同一个 t=T 起卡住。
3. 消息队列死锁——双方都在等回复
在 AutoGen 或 CrewAI 的多 Agent 对话里,Agent A 给 Agent B 发消息并阻塞等回复,Agent B 同时也给 Agent A 发消息并阻塞等回复。两边都卡在没有超时的 wait_for_reply()。队列里两条消息都在,但谁也没有在等待期间去读自己的收件箱。
怎么判断:检查两个 Agent 的待处理消息队列。如果 Agent A 有一条来自 B 的未读消息、Agent B 也有一条来自 A 的未读消息,且两者都在阻塞等待,就是消息队列死锁。
4. conditional edge 形成意外的环
在 LangGraph 里,根据 Agent 输出来路由的 conditional edge 可能不小心造出一个环:Agent A 的输出触发一条「需要审核」的边去 Agent B,Agent B 的输出又触发一条「需要上下文」的边回到 Agent A,两条边都没有 base case。实际上这里不会真的永远挂——LangGraph 有迭代上限,会抛出 langgraph.errors.GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition.(截至 2026 年 6 月默认上限为 25)。请把这个报错理解成「我有环」,而不是「该调高上限了」。
怎么判断:逐个排查 conditional edge 函数。对每个可能路由回前面节点的函数,确认存在一条可达的、不会绕回去的路径——也就是能终止这个环的 base case。
5. 超时设在了错误的层级——外层先于内层触发
编排器设了 60 秒超时,而它在等的子工作流设了 90 秒超时。编排器先超时、试图取消,但子工作流还在跑。取消请求被发到子工作流的输入队列里,可子工作流正卡在一个工具调用上、根本没在读这个队列。结果两边都完不成。
怎么判断:把系统里所有超时都列出来(编排器、子工作流、工具调用、外部 API 调用)。如果任何一个外层超时比正常一次运行中内层超时之和还短,外层就会先于内层触发。
最短修复路径
Step 1:打印所有在等待的 Agent,以及它们在等什么
import sys, threading, traceback
def dump_thread_stacks():
for thread_id, frame in sys._current_frames().items():
print(f"\n--- Thread {thread_id} ---")
traceback.print_stack(frame)
# 用 watchdog 在 N 秒无进展后触发
threading.Timer(120, dump_thread_stacks).start()
对 LangGraph 工作流,去看持久化的 state。get_state 要求编译图时配置了 checkpointer,它返回一个 StateSnapshot,其中 .next 是即将运行的节点元组,.tasks 是待执行的工作:
state = graph.get_state(config) # 需要配置 checkpointer
print("Next nodes:", state.next) # 例如 ('agent_b',)
print("Pending tasks:", state.tasks) # 仍待运行的 PregelTask
print("Pending interrupts:", state.interrupts)
如果隔几秒连续取两次快照,.next 始终指向同一个节点、毫无推进,那个节点就是被卡住的一方。
Step 2:可视化依赖图并找环
# LangGraph —— 渲染图来发现环
print(graph.get_graph().draw_mermaid()) # Mermaid 源码
# graph.get_graph().draw_mermaid_png() # 想要图片就用这个,返回 PNG 字节
对任意依赖字典做手动环检测:
def has_cycle(graph: dict[str, list[str]]) -> bool:
visited, rec_stack = set(), set()
def dfs(node):
visited.add(node)
rec_stack.add(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.discard(node)
return False
return any(dfs(n) for n in graph if n not in visited)
Step 3:用「初始化契约」打破循环依赖
对于「A 需要 B 的 Y,B 需要 A 的 X」这种模式,必须让一个 Agent 先产出 stub 或默认值:
# Agent A 先产出一个 stub schema,Agent B 再精修
initial_schema = {
"endpoint": "/api/users",
"method": "POST",
"body": "TBD", # 占位符,由 Agent B 填充
}
# 接成无环的流水线:A(stub) -> B(精修) -> A(用真 schema 实现)
graph.add_edge("agent_a_stub", "agent_b_refine")
graph.add_edge("agent_b_refine", "agent_a_implement")
指定一个 Agent 为「默认值的提供者」、另一个为「负责精修的消费者」。就这一条规则,就能把一个 2 环变成一条三段式的直线。
Step 4:统一锁的获取顺序
RESOURCE_ORDER = ["database", "file_system", "message_queue"]
def acquire_locks(resources: list[str]) -> list:
# 始终按规范顺序获取,防止死锁
ordered = sorted(resources, key=lambda r: RESOURCE_ORDER.index(r))
locks = []
for r in ordered:
lock = get_lock(r)
lock.acquire(timeout=10) # 永远不要无限阻塞在锁上
locks.append(lock)
return locks
所有 Agent 都必须走这个函数。两把锁「随手」以不同顺序去拿,正是制造「持有并等待」环的元凶。
Step 5:给每一处阻塞等待都加 watchdog 超时
import asyncio
class DeadlockError(Exception):
pass
async def wait_with_timeout(coro, timeout_seconds: float, label: str):
try:
return await asyncio.wait_for(coro, timeout=timeout_seconds)
except asyncio.TimeoutError:
raise DeadlockError(
f"Timed out after {timeout_seconds}s waiting for: {label}. "
"Possible deadlock — check the dependency graph."
)
把每个可能阻塞的 await 都包起来:子 Agent 调用、消息队列读取、工具调用、外部 API。报错信息里写清你在等什么,下一个值班工程师看一眼就能定位。对 AutoGen 团队,还要给对话本身设上限(见预防建议),让「你先来」的循环主动结束,而不是一直烧到把 token 预算耗光。
Temporal 的两类死锁
Temporal 自带一个死锁检测器,很容易和上面那种编排层挂起搞混,所以先弄清你遇到的是哪一类:
- Workflow 死锁检测器(worker 报错)。 Temporal 的 Rust core 给每个 workflow task 让出控制权设了一个硬编码的约 2 秒预算。如果你的 workflow 代码里跑了阻塞调用(同步网络请求、
time.sleep、CPU 密集循环)而不是await,worker 就会记录PotentialDeadlock/ “Deadlock detected” 错误并让该 task 失败。截至 2026 年 6 月,这个 2 秒上限硬编码在 Rust core 里,Python SDK 无法配置。修法:把阻塞工作挪进 Activity,保持 workflow 代码确定性且非阻塞。 - 逻辑上的「永远等待」挂起。 Durable execution 能扛住崩溃,但救不了一个真的在等一个永不到来的 signal 或 activity 的 workflow。请给
workflow.wait_condition(...)传timeout,给workflow.execute_activity(...)传start_to_close_timeout:
from datetime import timedelta
from temporalio import workflow
# 对人工/对端 signal 设有界等待 —— 超时就走默认路径,而不是挂起
got_it = await workflow.wait_condition(
lambda: self._approved is not None,
timeout=timedelta(hours=72),
)
if not got_it:
return "auto_rejected_timeout"
如何确认已经修好
- 重跑那条挂起过的工作流。现在它应该要么很快完成,要么很快抛出一个带名字的超时或
GraphRecursionError——绝不会再静默地卡上几分钟。 - 运行时盯着 CPU 和 token 消耗。健康的 run 会周期性发出 LLM 调用;死锁的 run 则是零。加一条告警:run 已超出正常时长却仍标着「running」、而 token 消耗为零,就该排查。
- 对 LangGraph,隔几秒取两次
graph.get_state(config).next快照。如果它前进了,说明你不再卡在某个节点上。 - 对基于锁的流程,grep 你的加锁日志,确认每一次多锁调用都按 Step 4 的规范顺序列资源。
预防建议
- 在动手实现之前先画出 Agent 依赖图,并把环检测(Step 2)放进 CI;启动时直接拒绝含环的工作流定义。
- 绝不让两个 Agent 各持一把锁、再互相请求对方那把——全局强制一个统一的获取顺序。
- 给每一处阻塞等待设显式超时:工具调用、子 Agent 调用、消息队列读取、外部 API 调用。
- 在 AutoGen v0.4+ 的 AgentChat 里,用
RoundRobinGroupChat或SelectorGroupChat搭团队,并用max_turns加上MaxMessageTermination(...)之类的终止条件封顶,让「你先来」的循环能结束。(在更老的 AutoGen v0.2 / AG2GroupChat里,对应的保护是GroupChatManager上的max_round。) - 让 Temporal 的 workflow 代码保持非阻塞,避免触发那个 2 秒死锁检测器;并给每个
wait_condition和 activity 都设 deadline。 - 对确实双向的依赖,重构成三段:A-stub -> B-精修 -> A-定稿。
- 加一个 watchdog,在 N 秒无进展时把所有 Agent 的状态 dump 出来。
- 给加锁延迟做埋点:获取一把锁耗时超过 5 秒,就是死锁的早期预警。
常见问答 (FAQ)
Q:Agent 死锁和 Agent 死循环有什么区别?
A:循环是有进展的——Agent 在跑、在出结果、在迭代,烧的是 token 预算。死锁则毫无进展:Agent 阻塞在一个永远满足不了的前置条件上,烧的是墙钟时间,还占着资源。在 LangGraph 里,失控的循环会以 GraphRecursionError(默认上限 25)收场;真正的死锁则会一直挂,直到你的超时触发。
Q:Temporal 会自动避免死锁吗?
A:它能通过 durable execution 避免崩溃导致的挂起,它的 2 秒死锁检测器也能抓到「workflow 线程阻塞而不让出」的情况。但它无法避免一个真的在等永不到来的 signal 或 activity 的 workflow——这要靠你自己:给每个 wait_condition() 设 timeout,给每个 execute_activity() 设 start_to_close_timeout。
Q:能不改 Agent 代码就检测死锁吗? A:可以。监控 CPU 和 token 消耗。死锁的流水线在数分钟窗口里 CPU 接近 0、LLM API 调用为 0。设一条告警:token 消耗为 0、但工作流仍标「running」且已超出预期时长,就触发。
Q:我的 LangGraph run 报 “Recursion limit of 25 reached”,直接调高上限就行吗?
A:通常不行。这个报错几乎总是意味着某条 conditional edge 没有 base case、绕回去了(成因 4)。只有当图确实需要更多步时才调高 recursion_limit;否则应该修边,让它能走到 END。
Q:在不丢数据的前提下,怎样安全地解开一个正在发生的死锁? A:先给所有 Agent 的当前状态拍快照(输入、输出、持有的锁)。然后取消持有资源最少的那个 Agent,等另一个完成后再重试它。不要在写操作进行中强杀 Agent——先让写操作完成再取消。