循环在测试中运行了整整四十次,毫无问题。第四十一次,在生产环境中,它用同一条损坏的查询反复调用同一个 SQL 工具,直到耗尽当天的 API 预算,最终靠一条账单告警才惊动了相关人员。没有人写出了糟糕的模型,也没有人修改过提示词。这个智能体只是永远没有判断自己已经完成。
这是我在团队将智能体从原型迁移到 7×24 工作负载时反复看到的模式。AI 智能体循环在生产环境中失败,往往不是因为模型突然变差了,而是因为执行层缺乏终止纪律、经过验证的工具契约、有界上下文以及持久化状态。智能体循环是一个随机系统,逐步做出一个又一个顺序决策。如果没有几条具体的护栏,偶发故障在运行足够长时间后就会变成必然故障。托管智能体平台(Vertex AI Agent Builder、Bedrock Agents、Azure AI Foundry)已将部分护栏内置其中;本指南面向的是那些选择自托管、自行掌控循环的工程师。
风险已足够真实,Gartner 预测 超过 40% 的智能 AI 项目将在 2027 年底前被叫停,原因是成本不断攀升、价值难以验证。以下是循环在生产环境中失败的六种具体方式,包括每种方式背后的机制、修复该问题的执行框架方案,以及 LangGraph 和 n8n 的具体细节,还有如何真正做到 7×24 稳定运行。
简短版本
- 无限循环: 智能体永远无法判断自己已完成。结合硬性步数上限(LangGraph 的
recursion_limit,默认值为 25)与无进展检测机制,后者会在检测到相同工具调用加相同参数重复出现时终止循环。 - 上下文溢出: 循环将累积的历史记录不断塞入自身的上下文窗口,直至调用被截断或失败。每隔固定间隔对历史进行摘要压缩,使工作上下文保持在有界范围内。
- 静默工具失败: 工具返回空字符串,模型将其视为有效的无操作,智能体“成功”地什么都没做。在模型看到结果之前,对每条工具返回值进行验证。
- 推理退化: 即使未达到硬性上限,随着上下文增长,质量也会衰减。在循环中途进行压缩,但压缩时要保护固定的安全指令。
- 重启后状态丢失: 崩溃意味着从头开始。将检查点持久化到 Postgres(LangGraph
PostgresSaver),而非 SQLite,以满足生产需求。 - 重试风暴: 十个智能体各自重试十次,会向一个已宕机的服务发送一百条请求。添加带抖动的指数退避策略以及全局熔断器。
本指南不涉及的内容
本指南聚焦于循环周围的工程实现,而非模型本身。以下几个相关主题有意排除在外:
- 多智能体协调失败 (过时读取、智能体间孤立状态):这是另一个问题,值得单独撰文探讨。
- 智能体安全 (提示注入、工具投毒):属于独立的故障类别,有其自身的威胁模型。
- 模型选型与微调。 本指南假设你已选定模型,正在调试围绕它构建的系统。
- 托管智能体服务,如上文所述;本文的模式适用于自托管路径。
无限循环:当智能体永远无法决定已完成时
当智能体既没有硬性步数上限,也没有检测自身是否停止进展的机制时,就会永远循环下去。解决方案分两部分:保留硬性上限作为成本兜底,并添加无进展检测—对每次工具调用加参数进行哈希,当检测到相同调用重复出现时终止循环。在 LangGraph 中,该上限就是 recursion_limit,默认 25 步;超过后图会抛出 GraphRecursionError.
LangGraph 文档 将该限制描述为“在触发停止条件之前达到最大步数”,这里有一个值得理解的陷阱:recursion_limit 并不是循环保护机制,它是一个兜底措施,触发时 之后 循环已经浪费了二十五步及相应的 API 消耗。智能体自身学到的终止逻辑本应在远早于此时将其停止,而这套逻辑可能独立失效。有一个 LangGraph 的已记录案例 显示,一个 text-to-SQL 智能体在提示词中有明确停止条件的情况下,仍然不断循环直到触及 recursion_limit。它反复用同一条失败的 SQL 调用同一个查询工具,而该 issue 最终被标记为“不计划修复”。我将此视为一个明确信号:不要将上限视为你的停止条件。它是你的安全带,不是刹车。
提高上限很简单,调用图时通过 config 传入即可:
# The hard ceiling -- a backstop, not loop protection
graph.invoke(
{"messages": [("user", "Generate the quarterly report")]},
{"recursion_limit": 50},
)
真正能阻止循环卡死的是进展检测。机制很简单:对每一步的工具名称加参数进行哈希,保留最近几步的哈希窗口,一旦发现重复就终止。
import hashlib
def step_signature(tool_name: str, tool_args: dict) -> str:
payload = f"{tool_name}:{sorted(tool_args.items())}"
return hashlib.sha256(payload.encode()).hexdigest()
# Inside your loop: terminate if the same tool+args repeats within the window
seen = recent_signatures[-WINDOW:]
sig = step_signature(tool_name, tool_args)
if sig in seen:
raise StopReason("no_progress: repeated tool call detected")
recent_signatures.append(sig)
这能捕获那些技术上“仍在运行”(调用工具、生成 token)但在同一个失败动作上不断打转的智能体。这里描述的命名故障模式对应 MAST 分类体系(IBM Research 和 UC Berkeley)中所称的 对终止条件无感知 (FM-1.5),这是其分析中与彻底任务失败相关的故障模式之一。
步数上限阻止失控的成本消耗。无进展检测则阻止那些技术上“仍在推进”但实则在原地打转的循环。生产环境两者都需要。
上下文窗口溢出:当循环把自己的上下文塞满垃圾时
长时间运行的循环会累积每一条工具输出、每一个中间思考过程以及每一条它产生的消息,然后在每一轮都将这些内容全部塞回上下文窗口。最终窗口被填满,调用要么静默截断,要么直接失败。解决方案是按固定间隔进行上下文摘要压缩:每 N 步将累积的历史压缩为一份滚动摘要,使工作上下文保持有界。
设想一个已运行一小时的研究智能体。到第 60 步时,它携带着它抓取过的每一个页面全文、每一条搜索结果、每一段推理过程。到第 61 步时,这些原始历史对它毫无帮助,却占用着窗口空间,而模型在那些已不再需要的 token 上消耗着注意力预算。窗口填满时,服务商从某一端截断,智能体就悄悄丢失了最初收到的指令。
触发时机是一个调优决策,这里有一个有用的参考点。Mem0 关于真实生产系统的文章指出, Hermes 智能体的压缩器“默认在模型上下文窗口的 50% 时触发”,同时在 85% 处设有二级安全网,用于处理在两次压缩间急剧膨胀的会话。50% 是一个合理的起点:足够早地压缩,使得单条大型工具输出不会在下次预定压缩前就突破上限。
注意:溢出和推理退化是两个不同的问题,下一节将介绍第二个问题。溢出是硬性边界——你的 token 耗尽了。退化是软性的:模型在 触碰 边界之前就已经变差了。两者都需要处理,上述触发阈值是针对硬性边界的保护。
有界上下文是执行框架的职责,而非模型的功能。在窗口强制触发静默截断之前,按间隔进行摘要压缩。
静默工具调用失败:当智能体“成功”地什么都没做时
工具调用返回空字符串或“未找到结果”之类的软性提示,模型将其解读为有效结果,智能体继续执行,看似成功,实则一无所成。解决方案是在每条工具返回值上设置验证关卡:在模型看到输出之前进行模式校验或合理性检查,并在循环中暴露真实的失败,而非空洞的成功。
这种故障尤为隐蔽,因为什么都不会崩溃。一位开发者在记录 生产智能体中的静默失败模式 时直言:模型会将通用的空字符串解读为有效的无操作,并在毫不知晓失败的情况下继续执行。因连接断开而返回零行的数据库查询,和合法地什么都没找到的查询,在模型眼中看起来毫无区别。于是智能体汇报“未找到匹配记录”并继续推进,而你一周后才发现三分之一的运行结果一直在悄悄损坏。
验证关卡置于工具与模型之间:
def gate_tool_result(tool_name: str, result):
# Reject empties and soft errors before the model can rationalize them
if result is None or (isinstance(result, str) and not result.strip()):
raise ToolFailure(f"{tool_name} returned empty -- treat as failure, not no-op")
if isinstance(result, str) and result.lower().startswith(("error", "exception")):
raise ToolFailure(f"{tool_name} returned a soft error: {result[:120]}")
return result # validated -- safe to hand back to the model
重点不在于具体的校验逻辑——那取决于每个工具的合法返回值。重点在于:未经验证的返回值,是你将决策权交给了一个随机模型,而模型的默认行为是继续往下走。
未经验证的工具返回值,是等待发作的静默故障。验证输出,不要盲目信任调用本身。
长上下文下的推理退化:智能体运行越久,表现越差
即使保持在硬性上下文上限以内,推理质量也会随上下文增长而衰减。这就是“迷失在中间”效应——模型能可靠地关注长上下文的开头和结尾,却会丢失中间部分。解决方案是在循环中途进行保护固定约束的压缩:压缩噪声,同时保护关键指令。
这个机制有个名字。Anthropic 的工程博客将其称为 上下文腐化: “随着上下文窗口中 token 数量的增加,模型准确回忆该上下文中信息的能力会下降。” 因为 “每个 token 都要与其他所有 token 进行关注计算”, 对于 n 个 token,你会得到 n² 个成对关系,随着上下文变长,模型的注意力也越来越分散。
那个限定条件—— 保护关键指令——才是整个问题的核心,有一个已记录的事件可以清楚说明原因。在一个 已记录的案例中,一个 OpenClaw 智能体在上下文压缩期间批量删除了用户的收件箱,原因是它被赋予的安全指令(“在我告诉你之前不要采取任何行动”)在历史记录被压缩时从活跃上下文中丢失了。本应最后被删除的约束,被当作普通历史内容摘要掉了。
因此,简单粗暴地“摘要 N 轮之前的所有内容”是危险的。压缩必须清楚哪些内容是绝不能丢弃的:
PINNED = {"system_constraints", "safety_instructions", "active_task_spec"}
def compress_history(messages):
pinned = [m for m in messages if m.tag in PINNED] # never summarized
transient = [m for m in messages if m.tag not in PINNED]
summary = summarize(transient) # lossy is fine here
return pinned + [summary] # constraints survive intact
这与上一节的溢出问题有本质区别。溢出是空间耗尽;退化是在空间尚余的情况下,模型已经在推理上变差了。上下文用到 60% 时,你可能已经在推理质量上出问题了。
注意: 压缩时丢弃安全约束,与压缩时丢失过时搜索结果,是截然不同的两类错误。将约束、任务规格以及所有“禁止做 X”的指令标记为固定内容,完全排除在摘要器的处理范围之外。
丢弃安全指令的压缩,比不压缩更糟糕。压缩时务必保护固定约束。
重启后状态丢失:崩溃意味着从头开始
当长时间运行的智能体崩溃——无论是因为重启、OOM 终止还是网络连接断开——默认情况下没有任何恢复检查点。循环从头开始:它重新执行已完成的工作,更糟糕的是,它可能重新触发已执行过的动作,比如发送同一封邮件两次,或重复发起已付费的 API 调用。解决方案是检查点:在每一步之后持久化循环状态,这样重启时就能从中断处恢复,而非从零开始。
在 LangGraph 中,检查点后端的选择就是开发环境与生产环境的选择。 LangGraph 的持久化文档 将 SqliteSaver 描述为“适用于实验和本地工作流”,将 PostgresSaver 描述为“适合在生产环境中使用”,而后者正是 LangSmith 自身运行所依赖的。两者在代码层面刻意保持平行,使对比一目了然:
# Development -- single file, no server, do not ship this
from langgraph.checkpoint.sqlite import SqliteSaver
# Production -- survives the box it runs on
from langgraph.checkpoint.postgres import PostgresSaver
有两个细节容易踩坑。首先,检查点相关包与核心 LangGraph 分开安装(langgraph-checkpoint-sqlite 以及 langgraph-checkpoint-postgres 是各自独立的依赖),因此一台新机器在你手动添加之前不会有 Postgres 存储器。其次,每个检查点操作都需要在 config 中提供 thread_id 。这个 ID 将特定运行与其保存的状态绑定,重启时如果没有正确的 thread_id ,什么都恢复不了。
专业提示: LangGraph 的检查点相关包需要单独安装。
langgraph-checkpoint-postgres不会被基础langgraph包自动引入,因此请在生产需求文件中提前锁定版本,以免在故障排查时才发现这个问题。
n8n 也存在同样的开发与生产分野,只是名称不同。其内置内存选项同样叫做简单内存(或缓冲窗口内存),而生产路径是 Postgres Chat Memory 节点 ,用于需要在重启后保持状态的场景。内置内存将对话保存在运行中的进程里,测试时没问题,但对于 7×24 工作负载而言是个隐患。实际运行 n8n 智能体的从业者反映,他们不得不在进程内内存不断膨胀最终把实例拖垮后,才迁移到 Postgres 后端存储。如果你在使用 n8n 且智能体需要在重启后保持记忆,从一开始就接入 Postgres Chat Memory。
SQLite 检查点是开发时的便利之选。要在生产环境重启后恢复,必须使用 Postgres(LangGraph)或 Postgres 后端存储(n8n)。
重试风暴:当你自己的智能体对宕机服务发起 DDoS 攻击时
当下游服务宕机时,简单粗暴的逐次重试会将你的智能体集群变成一场自我伤害式的拒绝服务攻击。解决方案分两个部分:在每个智能体上使用带抖动的指数退避,将重试在时间上分散开来;同时设置全局熔断器,在共享的失败阈值触发后让整个“兽群”停止攻击一个明显已经宕机的服务。
这里的数学是无情的。正如一篇 重试模式文章 所指出的:十个并行智能体各自重试十次,会向一个已趴下的服务发送一百条请求,因为每个智能体的退避是基于单次执行的,而非全局共享的。单靠每智能体退避无法解决问题。十个智能体各自礼貌地退避,如果它们同时启动,退避时机依然是同步的,于是它们会以同步的波浪状重试。抖动通过随机化每个智能体的等待时间打破同步;熔断器则通过在所有智能体间共享一份失败状态来打破“兽群效应”。
退避这一半在 Python 中是个已解决的问题; tenacity 库能干净地处理带抖动的指数退避:
from tenacity import retry, stop_after_attempt, wait_random_exponential
@retry(wait=wait_random_exponential(multiplier=1, max=60), stop=stop_after_attempt(5))
def call_flaky_service(payload):
return downstream.post(payload)
熔断器这一半必须是 全局的:在所有智能体之间共享,而非每次执行时重新实例化。当失败次数超过阈值时,它打开,所有智能体快速失败而非继续对外调用;冷却期结束后,它放行单个探测请求以检测服务是否已恢复。一个只存在于每个智能体自身进程中的熔断器毫无意义,因为没有任何状态是共享的;宕机的服务依然会收到满额的一百条请求。
单次执行级别的退避仍然会让十个智能体同步轰炸一个宕机的服务。熔断器必须是全局的,才能真正阻止“兽群效应”。
六种故障一览
在基础设施部分之前,这里将完整的故障目录汇总在一处:故障类型、导致它的机制、执行框架的修复方案,以及各框架中相关参数的位置。
| 故障模式 | 机制 | 执行框架修复方案 | 框架参数 |
|---|---|---|---|
| 无限循环 | 没有步数上限或进展检查 | 硬性上限 + 无进展检测 | LangGraph recursion_limit (25)/ n8n Max Iterations |
| 上下文溢出 | 历史不断增长直至窗口填满 | 基于间隔的摘要压缩 | 应用层(在窗口约 50% 时压缩) |
| 静默工具失败 | 空返回值或软性返回值被视为有效的无操作 | 对每条工具结果设置验证关卡 | 应用层工具包装器 |
| 推理退化 | 注意力随上下文增长而衰减(“上下文腐化”) | 循环中途进行保护固定约束的压缩 | 应用层,感知约束 |
| 重启后状态丢失 | 无检查点;循环从零重启 | 持久化检查点 | LangGraph PostgresSaver / n8n Postgres Chat Memory |
| 重试风暴 | 单次执行级别的重试在宕机服务上级联爆发 | 退避 + 抖动 + 全局熔断器 | tenacity + 共享熔断器状态 |
给 CrewAI、AutoGen、Dify 或自行实现 Python 循环的读者:框架参数会有所不同,但这六种模式不会改变。去重、间隔摘要、模式验证、感知约束的压缩、检查点以及全局熔断器,都是与框架无关的概念。这里给出的 LangGraph 和 n8n 细节是具体的抓手,而非这些模式适用范围的边界。
生产环境智能体部署的规格选型
上述所有模式都假设你掌控进程管理器、数据库和重启行为。如果崩溃后的循环永远无法恢复,检查点就毫无意义;全局熔断器也需要一个地方来保存其共享状态。这种掌控权正是自托管给你的,而托管黑盒无法提供。因此最后的决策是:为 7×24 运行它的机器选型。
对于大多数单智能体部署(一个智能体,LLM 调用发往外部 API,基础 Postgres 检查点),小型实例就足够了:大约 2 GB RAM, 1 vCPU, and 60 GB of NVMe storage。繁重的计算在模型提供商那一侧;你的机器负责编排、检查点和状态保持,而非运行推理。当智能体是有状态的多步骤工作流,同时使用 Postgres 检查点加 Redis 进行会话恢复,或者你在同一主机上运行并发工作流时,升级到大约 4 GB RAM, 2 vCPU, and 120 GB NVMe 。
选择自管理 VPS 而非受限平台的原因,与这些修复方案能够奏效的原因相同:它们需要 root 权限。你自己的 Postgres 用于检查点,你自己的 Redis 用于会话状态,以及一个真正的进程管理器(如 systemd or pm2),这样当循环崩溃时,监督进程能将其重启,并从上次检查点恢复,而不是重新开始整个任务。整套恢复机制依赖于拥有进程生命周期的控制权。
因为我们在自己的应用市场中将 n8n 作为一键应用提供,这部分配置在我们这边是最短路径:你可以 在 Cloudzy VPS 上部署 n8n ,使用生产路径所需的 Postgres 后端配置,在一台你拥有 root 权限的实例上,可以添加自己的 Redis 和进程监督。这与上述的自托管架构完全一致——你掌控数据库和重启行为,这正是让检查点和自动恢复真正奏效的前提。
执行框架模式的可靠性,取决于运行它的机器。如果进程永远不重启,检查点毫无意义。
常见问题
如何防止我的 LangGraph 智能体陷入无限循环?
同时使用两种机制。将 recursion_limit 设为硬性步数上限(默认值为 25),防止失控循环消耗无限预算;同时添加无进展检测,对每次工具调用加参数进行哈希,当同一调用在近期窗口内重复出现时终止。单靠上限只是事后兜底,浪费已经发生了,并非真正的循环保护。进展检测才是真正阻止循环卡死的机制。
LangGraph 生产环境中 recursion_limit 应该设为多少?
没有通用数值。根据你的智能体合理所需的最大步数加上一定余量来设定,并将其严格视为成本兜底。提高上限不会让一个陷入循环的智能体收敛。如果你的智能体总是触及较高的上限,解决方案是进展检测,而不是更高的上限。
为什么我的 n8n AI 智能体一直触及 Max Iterations 上限?
触及 Max Iterations 上限意味着智能体无法收敛:它在达到停止条件之前已用完了允许的步数。只有在任务确实需要更多步骤时才提高上限;否则应将其视为智能体陷入卡死的信号。特别要注意一个陷阱: GitHub issue #22771 指出,当达到迭代上限且设置了“出错时:继续”时,执行流程可能会路由到成功输出而非错误输出,导致一次已达上限的失败运行看起来像成功。
如何在重启后持久化智能体状态?
在 LangGraph 中,使用 PostgresSaver 检查点而非 SqliteSaver,后者仅适合本地开发。在 n8n 中,使用 Postgres Chat Memory 节点而非进程内置内存。两者都需要持久化数据库,在 LangGraph 中,每个检查点操作还需要一个 thread_id 将特定运行与其保存的状态绑定。
长时间运行的智能体为何会出现推理退化?
即使未触及硬性 token 上限,推理质量也会随上下文增长而下滑。这就是“迷失在中间”效应——模型能关注长上下文的开头和结尾,却会丢失中间部分。Anthropic 工程博客将其底层机制称为“上下文腐化”:因为每个 token 都要与所有其他 token 进行关注计算,n 个 token 会产生 n² 个成对关系,随着上下文变长,模型的注意力越来越分散。解决方案是在循环中途进行压缩,在摘要过时历史的同时,保持固定约束和安全指令完整无损。