Agent 编排器互相等待出现死锁

两个 Agent 互相等待对方的输出,整条流水线永远挂起。本文教你找到环、加超时、几分钟内打破死锁,并给出 LangGraph、Temporal、AutoGen 的具体做法。

你的 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,或某个节点永远到不了 ENDconditional 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"

如何确认已经修好

  1. 重跑那条挂起过的工作流。现在它应该要么很快完成,要么很快抛出一个带名字的超时或 GraphRecursionError——绝不会再静默地卡上几分钟。
  2. 运行时盯着 CPU 和 token 消耗。健康的 run 会周期性发出 LLM 调用;死锁的 run 则是零。加一条告警:run 已超出正常时长却仍标着「running」、而 token 消耗为零,就该排查。
  3. 对 LangGraph,隔几秒取两次 graph.get_state(config).next 快照。如果它前进了,说明你不再卡在某个节点上。
  4. 对基于锁的流程,grep 你的加锁日志,确认每一次多锁调用都按 Step 4 的规范顺序列资源。

预防建议

  • 在动手实现之前先画出 Agent 依赖图,并把环检测(Step 2)放进 CI;启动时直接拒绝含环的工作流定义。
  • 绝不让两个 Agent 各持一把锁、再互相请求对方那把——全局强制一个统一的获取顺序。
  • 给每一处阻塞等待设显式超时:工具调用、子 Agent 调用、消息队列读取、外部 API 调用。
  • 在 AutoGen v0.4+ 的 AgentChat 里,用 RoundRobinGroupChatSelectorGroupChat 搭团队,并用 max_turns 加上 MaxMessageTermination(...) 之类的终止条件封顶,让「你先来」的循环能结束。(在更老的 AutoGen v0.2 / AG2 GroupChat 里,对应的保护是 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——先让写操作完成再取消。

相关阅读

标签: #AI 编程 #Agents #排查