DEBUG.WIKI
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

LangGraph Agent 反复调用 save_memory,最后报 GraphRecursionError

检索已有结果,Agent 却反复调用 save_memory。按工具请求、结果与图路由判断循环,再设置业务上限和真正的结束路径。

检索工具已经返回内容,Agent 却一直调用 save_memory,每次写入后又发起相同工具调用,最终停止于:

GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition

这个报错说明图用完了运行步数预算,尚未到达停止条件。它不会替你判断究竟是模型反复选择工具、路由形成了循环,还是任务本来就需要更多步骤。先看停止前发生了什么,比直接把 recursion_limit 调大更有用。

这里处理 Python LangGraph 的工具循环。下面的图接口按 0.6.7 核对;使用历史 create_react_agent 的服务,应记录自己的 LangGraph、langchain-core 和模型集成包版本。不同 Agent 实现可能在预算不足时返回提示文字,也可能抛出异常,不能只靠最后一行输出推断执行路径。

把模型选择和工具执行分开看

保留每步节点名称、AI 消息中的工具名和调用 ID,以及工具结果是否返回。只记录 message.content 会漏掉正文为空但带 tool_calls 的 AI 消息。调试历史消息时,可以用下面的辅助函数;它只显示调用结构,不打印记忆正文或完整工具参数:

from langchain_core.messages import AIMessage, ToolMessage

def show_tool_history(messages):
    for index, message in enumerate(messages):
        if isinstance(message, AIMessage):
            calls = [
                (call.get("name"), call.get("id"))
                for call in message.tool_calls
            ]
            print(index, "ai calls:", calls)
        elif isinstance(message, ToolMessage):
            print(index, "tool result for:", message.tool_call_id)

它接收应用已经取得的消息列表,不调用模型或执行工具。调用 ID 也应遵守服务的日志访问和保留规则。

如果每次都是新的 AI 工具请求,随后有对应的成功结果,说明模型在读到结果后又选择了同一个工具。核对模型实际收到的消息,而不仅是保存到数据库的消息:提示词封装、消息过滤或自定义状态可能让工具结果没有进入下一次模型输入。

也要看提示词是否每次模型调用都会追加一条新的用户指令。固定提醒可以放在适当的系统指令中,避免把提醒伪装成不断新增的用户任务。但改提示词仍只是处理候选,不能据此认定所有重复调用都由提示词造成。

如果没有新的模型工具请求,执行节点仍在重复跑,则检查图中的普通边、条件边及业务重试。特别注意同时存在返回模型的固定边和另一条结束路径时,是否仍有路径继续调度节点。

确定要保存记忆,就单独安排保存节点

提示词写“保存后回答用户”不等于运行时停止条件。对于确定要在回答完成后保存的任务,可以把生成回答与保存动作分开,让应用检查结果,再进入保存节点,保存节点完成后连到 END。保存内容仍需满足业务权限、隐私和格式要求,不能把任意模型输出直接写入长期记忆。

确实需要模型自行决定调用时,保留最大工具次数、总运行预算,以及重复调用的拒绝或幂等处理。相同工具名和参数并不总表示重复业务:读取实时状态可以合理重复;创建记忆、发消息或扣款则需要明确的业务标识。不要拿模型新生成的工具调用 ID 当作跨重试的业务去重键。

下面只演示有界路由,供检查自定义图是否真正能结束。它不调用模型、不保存记忆,也不是给预制 Agent 加一个字段就能生效的补丁:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    attempts: int
    limit: int

def step(state: State):
    return {"attempts": state["attempts"] + 1}

def route(state: State):
    return "again" if state["attempts"] < state["limit"] else "done"

builder = StateGraph(State)
builder.add_node("step", step)
builder.add_edge(START, "step")
builder.add_conditional_edges(
    "step", route, {"again": "step", "done": END}
)
graph = builder.compile()
result = graph.invoke(
    {"attempts": 0, "limit": 3},
    {"recursion_limit": 10},
)
print(result["attempts"])

limit 是这个示例自己定义的业务上限;recursion_limit 是图的运行配置。两者职责不同,图步骤也不能直接等同于模型请求数或工具调用数。正式任务应把正常结束、达到业务上限和运行失败区分开,不能把强制中止包装成已完成回答。

提高预算前,先给出有限的预期

如果任务明确要经过一批节点,且每次运行都能在有限步骤后结束,可以在调用配置的顶层设置更高的 recursion_limit。它不放在节点输入或 configurable 中。增加预算也会增加潜在模型调用、工具操作和等待时间,应同时控制请求超时与费用预算。

反复执行写操作的运行应先停止继续派发,保留日志并核对已经产生的记忆记录,再决定是否补偿或清理。不要为重新跑一次而删除整个会话的 checkpoint;如需验证改动,使用独立测试会话和不产生外部写入的工具替身。

验收时检查 Agent 是否按预期生成最终回答、是否到达正常结束路径,以及保存动作的实际记录数。用正常任务、工具错误和重复请求分别核对。只看到异常消失,或换成“需要更多步骤”的提示,不能证明工具循环已经解决。