Agent 调用图出现循环但没人发现

多 Agent 之间互相交接,形成永不终止的环,因为没有任何东西在检测循环。本文教你抓出这个环、配好框架自带的上限、并把循环彻底限定住。

你的 LangGraph 或 AutoGen 编排器里有一个「planner」Agent,它把子任务派给「researcher」和「coder」。当 planner 觉得 coder 产出的东西太抽象时,就把活儿路由回 researcher 去补细节;researcher 又找 coder 要一个具体例子;例子又显得太抽象。这个环跑了 400 次,把 token 预算烧光才停。又或者在 OpenAI Agents SDK(已归档的 Swarm 的继任者)流水线里,几个 Agent 互相交接、没有真正的上限:Agent A 到 Agent B 到 Agent C 再回到 Agent A,每跳一次都多推理一点。跳了 200 次之后,这一次运行花了 $40,却什么有用的东西都没产出。多数情况下根本不会抛异常——这个环就一直转,直到别的地方先崩。

最快的修法: 每个框架都自带一个硬性的轮数/步数上限——把它调小,而不是关掉。配好 LangGraph 的 recursion_limit(默认 25)、OpenAI Agents SDK 的 max_turns(默认 10)、AutoGen 的 MaxMessageTermination(...)、或 CrewAI 每个 Agent 的 max_iter(默认 25,建议降到 5-8)。这几分钟就能先止血。然后再把一个 call_path 串进每次调用,让循环带着确切的 Agent 环路「大声」报错,而不是死在一个笼统的上限上。下面两件事都会讲。

各框架自带的防护(截至 2026 年 6 月)

在写任何自定义代码之前,先把框架已经给你的上限配好。下面是各框架当前的默认值,以及你要改的那个确切参数。

框架参数默认值抛出什么在哪里设置
LangGraphrecursion_limit(按步数,不是节点数)25GraphRecursionErrorgraph.invoke(state, {"recursion_limit": 50})
OpenAI Agents SDKmax_turns10agents.exceptions.MaxTurnsExceededRunner.run(agent, input, max_turns=12)
AutoGen(AgentChat)MaxMessageTermination(n) / 团队 max_turns默认无团队停止并返回结果RoundRobinGroupChat(..., termination_condition=MaxMessageTermination(20))
CrewAI每个 Agent 的 max_iter;crew 级 max_rpmmax_iter=25Agent 停止迭代Agent(..., max_iter=8)

2026 年最容易踩的两个坑:

  • LangGraph 数的是步数,不是访问次数。 recursion_limit 是 super-step 的总数,所以一个宽 fan-out 的图可能合法地撞到 25。图确实很深时调大它;怀疑有环时调小它。报错原文是:Recursion limit of 25 reached without hitting a stop condition.
  • 子 Agent 的上限不会继承。 在 LangGraph 的子图、以及 deepagents / SubAgentMiddleware 模式里,父图上调高的 recursion_limit 不会传给子 Agent——它们仍然悄悄按默认值 25 跑(langchain-ai/deepagents #1698)。每个子图都要单独设。OpenAI Agents SDK 正相反:max_turns 会跨 handoff 累计,一个上限就罩住整条链。

常见原因

1. 条件路由没有 base case

LangGraph 里一条条件边在质量低于阈值时路由到 Agent B。而 Agent B 的产出总是刚好卡在阈值之下,因为阈值定得太严。这条路由每次都触发,形成一个没有任何分支会评估为「往前走」的环。

怎么判断:对每个可能往回路由到前序节点的条件路由函数,检查是否存在一条不往回路由的代码路径。如果所有分支都往回路由、或都路由到一个「最终还是往回路由」的等待态,那就没有 base case。

2. visited 节点集合没有沿调用链维护

每次 Agent 调用都是无状态的。Agent A 调 Agent B,B 调 Agent C,C 又调回 Agent A。它们谁都没检查「我在这条链里被调用过吗?」,因为那个「visited」集合是进程内内存,不会在 Agent 调用之间持续存在。

怎么判断:搜一下有没有沿 Agent 调用链传递的「visited」集合、「调用栈」或「深度计数器」。如果根本没有、或者只存在调用方 Agent 的局部变量里(没往下传),那么跨调用边界的环就检测不出来。

3. 路由决策交给 LLM,且没有深度约束

路由逻辑是「问 LLM 下一个该由哪个 Agent 处理」。LLM 可以产出任意 Agent 名,包括刚刚在执行的那一个。如果路由提示词里没有注入深度上限或环路检测约束,LLM 就能随意生成环。

怎么判断:检查路由提示词里有没有包含调用历史或深度。如果 LLM 只收到当前任务和可选 Agent 列表(而没有走到这一步的路径),它就没有任何信息去检测或规避环。

4. 动态 Agent 注册在注册期就允许了环

各 Agent 在启动时注册自己的「可委派给谁」列表。Agent A 说「可委派给 B、C」,Agent B 说「可委派给 A、C」,这在能力图里就构成了一个合法的环。编排器没有在注册期对这张图做环路校验——它只在运行时环真的发生后才发现。

怎么判断:从 Agent 注册信息构建委派图,并在启动时对它跑一遍环路检测算法(带递归栈的 DFS)。如果图里有环,编排器就应该拒绝这次注册。

5. 加了 max-depth 检查,但检查的层级不对

路由函数里加了一个 depth < 10 的守卫。但这个路由函数是被一个 wrapper 调用的,wrapper 捕获 MaxDepthError 后又用 depth=0 静默重新调用,美其名曰「干净地重试路由」。深度计数器被重置,守卫永远拦不住这个环。

怎么判断:追踪每一处 MaxDepthError(或等价物)被捕获的路径。如果有任何一个 catch 处理器是重置深度计数器、而不是把错误往上抛,那么深度限制就是无效的。

6. Agent 派生的子 Agent 又重新进入同一条流水线

Agent A 是流水线 P 的一部分。它派生出一个子 Agent,让它跑流水线 P 来处理某个子任务。流水线 P 最终又派生出 Agent A。这个递归是跨流水线边界的,因此对任何「单条流水线内」的环路检测都是不可见的。

怎么判断:检查流水线里是否有任何 Agent 能触发同一条流水线(或另一条会触发本流水线的流水线)作为子工作流。跨流水线的环更难检测,但遵循的是同一个模式。

最短修复路径

Step 1:在图定义期就加入环路检测

def validate_no_cycles(edges: dict[str, list[str]]) -> None:
    """若 Agent 委派图含有环则抛异常。"""
    visited = set()
    recursion_stack = set()

    def dfs(node: str) -> bool:
        visited.add(node)
        recursion_stack.add(node)
        for neighbor in edges.get(node, []):
            if neighbor not in visited:
                if dfs(neighbor):
                    return True
            elif neighbor in recursion_stack:
                cycle_path = list(recursion_stack) + [neighbor]
                raise CycleDetectedError(
                    f"Cycle detected in agent graph: {' → '.join(cycle_path)}"
                )
        recursion_stack.discard(node)
        return False

    for node in edges:
        if node not in visited:
            dfs(node)

# 在 Agent 注册期运行:
AGENT_EDGES = {
    "planner": ["researcher", "coder"],
    "researcher": ["coder"],  # OK——没有回指 planner 的边
    "coder": [],              # 叶子节点
}
validate_no_cycles(AGENT_EDGES)

Step 2:把一个 call-path 令牌串进每次 Agent 调用

import hashlib

@dataclass
class CallContext:
    run_id: str
    call_path: list[str]  # 到目前为止已调用的 Agent 名(有序)
    max_depth: int = 20

    def enter_agent(self, agent_name: str) -> "CallContext":
        if agent_name in self.call_path:
            cycle = " → ".join(self.call_path + [agent_name])
            raise CycleDetectedError(f"Cycle detected: {cycle}")
        if len(self.call_path) >= self.max_depth:
            raise MaxDepthError(
                f"Max depth {self.max_depth} reached: {' → '.join(self.call_path)}"
            )
        return CallContext(
            run_id=self.run_id,
            call_path=self.call_path + [agent_name],
            max_depth=self.max_depth,
        )

# 把 context 传进每次 Agent 调用:
def invoke_agent(agent_name: str, task: str, ctx: CallContext) -> str:
    child_ctx = ctx.enter_agent(agent_name)
    agent = AGENT_REGISTRY[agent_name]
    return agent.run(task, ctx=child_ctx)

Step 3:把调用历史注入 LLM 路由提示词

def build_routing_prompt(task: str, call_path: list[str]) -> str:
    history = " → ".join(call_path) if call_path else "none"
    return f"""
You must choose the next agent to handle this task.

Task: {task}

Agents already invoked in this chain (DO NOT route back to any of these):
{history}

Available agents (choose one that has NOT already been invoked):
- researcher: gathers information
- coder: implements solutions
- reviewer: checks quality

Respond with ONLY the agent name. No other text.
"""

提示词里带上调用历史后,做路由的 LLM 就有了规避环的信息。

Step 4:在编排层加一个硬性深度上限

MAX_AGENT_DEPTH = 15

def run_agent_chain(task: str, depth: int = 0) -> str:
    if depth >= MAX_AGENT_DEPTH:
        raise MaxDepthError(
            f"Agent chain reached maximum depth {MAX_AGENT_DEPTH}. "
            "Possible cycle — review the routing logic."
        )
    agent_name = route_task(task)
    return invoke_agent(agent_name, task, depth=depth + 1)

这个深度上限是独立于环路检测的安全网。它能兜住那些躲过 visited 集合检查的环,思路和上面表格里的框架自带参数是同一个。两个都设:框架上限拦住失控的运行,call_path 检查则告诉你是哪些 Agent 形成了环。

Step 5:在 CI 里用图校验测试环路

# 把环路检测作为测试套件的一部分跑起来
python -m pytest tests/test_agent_graph.py -k "test_no_cycles" -v
def test_agent_delegation_graph_has_no_cycles():
    graph = build_agent_delegation_graph()
    with pytest.raises(CycleDetectedError):
        # 注入一个已知的环,确认检测能生效
        graph["coder"] = ["planner"]
        validate_no_cycles(graph)

def test_production_graph_is_acyclic():
    # 真实的生产图必须能通过
    graph = PRODUCTION_AGENT_EDGES
    validate_no_cycles(graph)  # 不应抛异常

怎么确认已经修好

跑一个会触发循环的用例和一个正常用例,检查这三点:

  1. 已知的环要快速报错并指出环路。 喂一个之前会循环的输入(或临时加一条回边),你应该拿到一个会列出 Agent 的 CycleDetectedError 报错,比如 Cycle detected: planner → researcher → coder → planner。如果只是一个笼统的 GraphRecursionErrorMaxTurnsExceeded、没指出路径,说明是框架上限兜住了、而你的 call_path 检测没触发——把检测的位置摆对,让环路被点名。
  2. 正常运行要远低于上限就结束。 把最终的调用链长度(或 LangGraph 的步数)打到日志里。一个健康的流水线应该在深度 5 以内结束。如果一次正常运行已经贴近上限,就说明没留余量,输入稍有变化就会撞线。
  3. 框架上限是「设了」而不是「关了」。 确认 recursion_limit / max_turns / max_iter 在代码里是个明确的数值,绝不是 None。在 LangGraph 里,再确认每个子图都各自设了 recursion_limit,因为父图的值不会被继承。

要长期监控,把每次运行的调用链深度分布记下来。一周内尾部慢慢爬到 10+,就是一次次「差点成环」的预警。

预防建议

  • 先配好框架自带的上限、并且永远不要关掉它:LangGraph recursion_limit、OpenAI Agents SDK max_turns、AutoGen MaxMessageTermination、CrewAI max_iter。在 LangGraph 里每个子图也都要单独设——它不会继承。
  • 在启动时对 Agent 委派图跑一遍环路检测,拒绝任何会构成环的注册。
  • 把一个 call_path 列表串进每一处 Agent 调用边界;执行前先检查当前 Agent 名是否已在路径里。
  • 在每个 LLM 路由提示词里都带上调用历史,让模型有信息规避「路由回已访问过的 Agent」。
  • 加一个独立于环路检测的硬性 max-depth 上限,作为第二道安全网。
  • 写一个 CI 测试,在每次改动图定义后都校验生产 Agent 图是无环的。
  • 对那些确实需要迭代的模式(如「优化到质量达标为止」),用一个带硬上限的显式迭代计数器,而不是路由边——让循环在图定义里清晰可见且有界。
  • 在生产里监控 Agent 调用链的深度分布;尾部爬到深度 10+ 就是一次「差点成环」。
  • 在图定义里区分「允许的环」(带计数器的显式有界重试循环)和「意外的环」(无界委派循环)。

常见问答 (FAQ)

Q: LangGraph 会自动阻止循环吗? A: 不会。LangGraph 是故意支持有环图的——重试和迭代优化循环就是这么实现的——而且它不会在定义时校验是否有环。它自带的防护是 recursion_limit,默认 25,当跑了这么多 super-step 还没命中停止条件时抛 GraphRecursionError(报错原文 Recursion limit of 25 reached without hitting a stop condition)。每张图都要显式设:graph.invoke(state, {"recursion_limit": 50})。注意它数的是总步数、不是去重后的节点访问次数,所以宽图可能合法地撞上限。

Q: 我已经调高了 recursion_limit,但子 Agent 还是一直撞上限,为什么? A: 截至 2026 年 6 月,LangGraph 父图上调高的 recursion_limit 不会传进子图或 SubAgentMiddleware 子 Agent——它们仍按默认值 25 跑(deepagents #1698langgraphjs #1524)。要在每个子图自己的 invoke/stream 配置里传 recursion_limit。OpenAI Agents SDK 没有这个问题:max_turns 会跨 handoff 累计,一个上限罩住整条链。

Q: 多 Agent 链的深度上限 / 轮数上限设多少合理? A: 按你框架的单位来调。如果是调用链深度,10 已经很宽松,比这更深通常是路由 bug 而不是真复杂——硬上限设 15,超过 8 就告警。OpenAI Agents SDK 的 max_turns(默认 10,每个模型轮次包括 tool 调用都算一轮),用工具的 Agent 一般 12-20 够用。CrewAI 的 max_iter 默认 25,是主要的成本来源,建议每个 Agent 降到 5-8

Q: AutoGen 的 Agent 一直聊个没完,怎么让它停? A: AutoGen AgentChat 的团队默认没有轮数上限,必须挂一个 termination_condition。用 |MaxMessageTermination(n)(硬性上限)和 TextMentionTermination("TERMINATE")(干净退出)组合起来;如果 Agent 会交接给人类或交回编排器,再加 HandoffTermination。两个都设:文本条件在活干完时干净退出,消息计数则是「它永远不说完成」时的兜底。

Q: DAG 工作流会在运行时产生环吗? A: 静态 DAG 按定义不可能有环。但动态路由——下一个节点是在运行时根据当前 Agent 的产出选出来的——即便在号称「DAG」的框架里也能产生环。动态路由需要运行时的环路检测(即 call_path 那套方案),光靠静态图分析不够。

Q: 怎么实现一个合法的「优化到够好为止」循环、又不冒成环的风险? A: 用显式的迭代计数器,而不是路由环:while quality < threshold and iteration < 5: output = refine(output); iteration += 1。它不管质量如何,到 5 就终止。如果第 5 轮质量仍低于阈值,就失败上报、而不是继续转。更好的做法是加一个「有没有进展」检查——这一轮质量分没改善就提前退出。

相关阅读

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