news 2026/4/18 1:55:25

LangGraph 循环节点避坑:5个导致死循环的错误与终止条件设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangGraph 循环节点避坑:5个导致死循环的错误与终止条件设计

LangGraph 循环节点避坑:5个导致死循环的错误与终止条件设计


关键词

LangGraph, 循环节点, 死循环, 终止条件设计, 多Agent协作, 状态机编程, LLM应用开发


摘要

在构建具有推理反思、多轮迭代修正、任务拆解执行等高级功能的LLM驱动应用时,LangGraph的循环节点(Cyclic Node)机制几乎是不可或缺的核心能力——它就像软件系统里的“for/while循环”,能让Agent或状态流转按照逻辑重复执行某段流程,直到满足某个标准。

但“循环”这把双刃剑,在LLM的非确定性和LangGraph的状态机特性下,变得比传统编程的循环危险得多:传统编程你写错循环条件,程序可能报错或几秒就卡死,但LangGraph的死循环可能会让你花掉几百上千美元的API调用费,输出一堆毫无意义的文本,甚至让整个应用的用户体验崩溃到极点。

本文将从一位LLM应用“踩坑老手”的视角出发,结合3个真实的生产级项目踩坑案例,拆解5个导致LangGraph循环节点死循环的典型、高隐蔽性错误;然后从理论模型(马尔可夫链终止定理、状态覆盖分析)工程实践(状态哈希校验、计数硬限制、置信度软限制、外部信号终止、复合条件),构建一套完整的、可落地的LangGraph循环节点终止条件设计体系;最后通过一个开源级的完整项目实战(多轮代码审查修正机器人),教你如何从零开始应用这套体系,避免踩坑。

全文严格遵循状态机编程和LangGraph的官方设计哲学,穿插了大量生动的生活化比喻(比如把循环节点比作“快递员反复确认收件地址”)、清晰的Mermaid流程图/ER图、严谨的数学模型、可直接复制运行的Python代码,以及实用的避坑checklist和最佳实践tips。无论是LangGraph的新手,还是已经有生产经验的开发者,都能从本文中获得实质性的帮助。


目录

  1. 背景介绍:为什么LangGraph的循环节点这么重要又这么危险?
    • 核心概念(前置知识铺垫)
    • 问题背景:LLM高级应用的非确定性需求
    • 问题描述:3个真实生产级项目的死循环事故复盘
    • 问题解决:本文的核心贡献与内容框架
    • 边界与外延:本文讨论的范围和未涉及的内容
    • 目标读者
  2. LangGraph循环节点的理论基础:从状态机到马尔可夫链
    • 核心概念解析(状态机、LangGraph节点/边/状态、循环节点的两种实现方式)
    • 概念结构与核心要素组成
    • 概念之间的关系:属性对比表、ER实体关系图、交互关系图
    • 数学模型:马尔可夫链的可吸收性与终止定理
    • 边界与外延:状态机在其他LLM框架(LangChain LCEL Chains、AutoGPT)中的对比
  3. 5个导致死循环的典型错误:踩坑案例+原理分析+修复方案
    • 错误1:完全依赖LLM的文本匹配做终止条件(文本歧义案例)
    • 错误2:状态更新逻辑导致“状态振荡”(快递地址反复修改案例)
    • 错误3:循环节点的默认重试机制被误用(API超时无限重试案例)
    • 错误4:循环的条件判断节点缺失关键的“状态依赖过滤”(任务拆解越拆越多案例)
    • 错误5:子图循环与父图循环的终止条件冲突(多Agent嵌套协作案例)
    • 边界与外延:如何区分“正常的长时间循环”和“死循环”?
  4. 一套完整的终止条件设计体系:从理论到可落地的工具
    • 终止条件的分类:硬终止、软终止、混合终止
    • 核心设计原则:确定性优先、可观测性、可扩展性、容错性
    • 具体设计方法与工具函数
      • 方法1:计数硬限制(最简单、最保险的兜底)
      • 方法2:状态哈希校验(防止状态振荡)
      • 方法3:LLM置信度软限制(结合Embedding和语义相似度)
      • 方法4:显式外部信号终止(UI按钮、定时任务、回调函数)
      • 方法5:复合条件终止(AND/OR组合,构建健壮的判断逻辑)
    • 边界与外延:如何动态调整终止条件(比如根据任务复杂度调整循环次数上限)?
  5. 项目实战:多轮代码审查修正机器人(开源级完整实现)
    • 项目介绍
    • 环境安装与依赖配置
    • 系统功能设计
    • 系统架构设计(Mermaid流程图)
    • 系统接口设计
    • 系统核心实现源代码
    • 避坑点在项目中的应用
    • 最佳实践tips在项目中的应用
    • 系统测试与验证
  6. 行业发展与未来趋势
    • LangGraph循环节点机制的演变历史(表格)
    • 未来的技术发展方向:内置终止条件检测、自适应循环次数、LLM生成终止条件的形式化验证
    • 潜在的挑战和机遇
    • 对LLM应用开发行业的影响
  7. 本章小结(全文总结)
    • 核心要点回顾
    • 避坑checklist(可打印)
    • 思考问题(鼓励读者进一步探索)
    • 参考资源

1. 背景介绍:为什么LangGraph的循环节点这么重要又这么危险?

1.1 核心概念(前置知识铺垫)

在正式讨论死循环之前,我们必须先明确几个LangGraph的核心前置概念——这些概念就像盖房子的砖块,如果理解模糊,后面的所有讨论都可能像空中楼阁一样站不住脚。为了让大家更好地理解,我会用**“快递员送包裹的协作流程”**这个生活化的比喻贯穿全文的概念解析。

1.1.1 什么是LangGraph?

LangChain官方在2024年初推出的状态机驱动的LLM应用开发框架,主要用于构建具有复杂逻辑、多Agent协作、记忆持久化、错误处理能力的LLM应用。

生活化比喻:传统的LangChain LCEL Chains就像“单线程的快递配送路线”——包裹必须从A→B→C→D严格按顺序走,不能回头,不能绕路,更不能让多个快递员协作。而LangGraph就像“快递配送中心的智能调度系统”——包裹可以在不同的节点(配送站、分拣中心、收件人验证点)之间来回流转,可以让多个快递员(不同的Agent)协作完成任务(比如大件包裹拆分、特殊物品检查),还能记住每个包裹的历史状态(比如之前有没有联系过收件人、联系了多少次、收件地址有没有修改过)。

1.1.2 什么是LangGraph的“状态(State)”?

LangGraph的核心是**“状态优先(State-First)”的设计哲学——所有的逻辑流转、数据传递、记忆存储,都围绕着一个中心的“状态对象”展开。状态对象可以是任意Python类型,但通常是一个TypedDict**(类型字典),用来明确每个字段的类型和用途。

生活化比喻:状态对象就像“每个包裹的配送单”——上面记录了包裹的所有关键信息:收件人姓名、收件人电话、收件地址、包裹状态(已下单/已揽收/已分拣/已派送/待确认/已签收)、历史配送记录(联系过谁、修改过什么地址、有没有遇到问题)、配送员备注(比如“收件人周一到周五晚上6点后在家”)等。

1.1.3 什么是LangGraph的“节点(Node)”?

节点是LangGraph中执行具体逻辑的单元——每个节点接收当前的状态对象,修改它(或者不修改,但通常会修改),然后返回修改后的状态对象(或者返回一个特殊的END/START标记,表示流程的结束或开始)。

节点可以分为两种类型

  1. 普通节点(Ordinary Node):执行具体的业务逻辑——比如调用LLM生成文本、调用API获取数据、解析JSON格式的结果、修改状态对象的某个字段等。
  2. 条件判断节点(Conditional Edge Node?不,官方叫Conditional Edge的“路由函数(Routing Function)”:根据当前的状态对象,决定下一个要执行的节点——比如如果包裹状态是“待确认”,就执行“联系收件人”节点;如果包裹状态是“已签收”,就执行END节点。

生活化比喻:普通节点就像“快递员的具体动作”——比如“开车到收件地址”、“打电话给收件人”、“把包裹放在代收点”、“修改配送单上的地址”等。路由函数就像“智能调度系统的决策逻辑”——比如根据“收件人是否在家”决定下一步是“把包裹交给收件人”还是“放在代收点并发短信通知”。

1.1.4 什么是LangGraph的“边(Edge)”?

边是LangGraph中连接节点的“逻辑桥梁”——它指定了从一个节点(或START)到下一个节点(或END)的流转规则。

边也可以分为两种类型

  1. 普通边(Ordinary Edge):无条件的流转——比如从“揽收包裹”节点,无条件流转到“分拣包裹”节点。
  2. 条件边(Conditional Edge):有条件的流转——需要通过路由函数来决定下一个节点。

生活化比喻:普通边就像“快递配送路线的固定路段”——比如从“总配送中心”到“城东分拣中心”,每天都是固定的路线。条件边就像“快递配送路线的分叉路口”——比如到了“小区门口”,如果收件人在家就走“上门配送”的路,如果不在家就走“代收点存放”的路。

1.1.5 什么是LangGraph的“循环节点(Cyclic Node)”?

循环节点不是LangGraph官方定义的一个“特殊节点类型”,而是通过普通边或条件边构建的一种“循环状态流转结构”——简单来说,就是从节点A出发,经过若干个节点(或者直接),又回到了节点A。

循环节点的实现方式通常有两种

  1. 直接循环边(Direct Cyclic Edge):从节点A直接连一条边回到节点A——这种方式比较简单,但通常只适合“不需要经过其他节点的纯迭代”场景(比如反复调用LLM直到生成符合格式的JSON)。
  2. 间接循环边(Indirect Cyclic Edge):从节点A出发,经过节点B、C、D,最后又回到节点A——这种方式更复杂,但也更灵活,适合“多步骤迭代修正”的场景(比如代码审查修正机器人:先调用审查Agent生成审查意见,再调用修正Agent根据意见修改代码,再调用审查Agent检查修改后的代码,如果还有问题就重复这个流程)。

生活化比喻:循环节点就像“快递员反复确认收件地址”的流程——比如:

  • 节点A:联系收件人
  • 节点B:验证收件人说的地址是否有效(比如调用地图API)
  • 条件边:如果地址有效,就走“上门配送”的路;如果地址无效,就回到节点A,再次联系收件人确认地址。

这就是一个典型的间接循环边结构


1.2 问题背景:LLM高级应用的非确定性需求

为什么我们在开发LLM驱动应用时,必须要用到循环节点呢?答案是:LLM的输出具有非确定性(Non-Deterministic),而很多高级的LLM应用场景,又需要“确定性的结果”或者“逐步优化的结果”——这时候,循环节点就成了唯一的解决方案。

让我们来列举几个必须用到循环节点的高级LLM应用场景,看看它们为什么需要循环:

1.2.1 场景1:推理反思(Reflection)类应用

推理反思类应用(比如OpenAI的GPT-4o mini with Reflection、DeepMind的AlphaGo Zero的自我对弈反思),核心逻辑是:

  1. 生成一个初始的解决方案
  2. 反思这个解决方案的优缺点
  3. 根据反思的结果,优化解决方案
  4. 重复步骤2-3,直到解决方案达到某个标准(比如反思结果显示“没有明显的错误”、或者优化的幅度小于某个阈值)

为什么需要循环?
因为LLM的初始解决方案很难一次就达到完美的标准——尤其是对于复杂的问题(比如数学证明、程序设计、论文写作)。而且,LLM的反思能力,往往需要在多次迭代中才能发挥出来——第一次反思可能只能发现表面的错误,第二次反思才能发现深层次的逻辑漏洞,第三次反思才能优化细节的表达。

1.2.2 场景2:多轮迭代修正(Iterative Correction)类应用

多轮迭代修正类应用(比如代码审查修正机器人、文档翻译润色机器人、客服工单自动处理机器人),核心逻辑和推理反思类应用类似,但通常会涉及多个不同的Agent协作

  1. 任务接收Agent:接收用户的任务(比如“审查并修正这段Python代码”)
  2. 审查Agent:调用LLM生成审查意见(比如“这段代码有3个错误:1. 变量名命名不规范;2. 缺少异常处理;3. 算法时间复杂度太高”)
  3. 修正Agent:调用LLM根据审查意见修改代码
  4. 验证Agent:验证修改后的代码是否符合要求(比如运行代码看有没有报错、调用审查Agent再次检查看有没有新的错误)
  5. 条件边:如果验证通过,就输出结果;如果验证不通过,就回到步骤2,再次生成审查意见。

为什么需要循环?
同样的道理——修正Agent很难一次就把所有的错误都修正完,而且修正后的代码可能会引入新的错误(这就是所谓的“修复一个bug,引入十个新bug”的问题,在LLM应用中也很常见)。

1.2.3 场景3:任务拆解与执行(Task Decomposition & Execution)类应用

任务拆解与执行类应用(比如旅行规划机器人、项目管理机器人、多文档问答机器人),核心逻辑是:

  1. 主Agent:接收用户的复杂任务(比如“帮我规划一个从北京到上海的3天2夜亲子旅行”)
  2. 拆解Agent:调用LLM把复杂任务拆解成若干个简单的子任务(比如“预订高铁票”、“预订酒店”、“规划Day1的行程”、“规划Day2的行程”、“规划Day3的行程”)
  3. 执行Agent:逐个执行子任务,并将执行结果更新到状态对象中
  4. 检查Agent:检查是否还有未执行的子任务
  5. 条件边:如果还有未执行的子任务,就回到步骤3,继续执行;如果所有子任务都执行完了,就整合结果并输出。

哦,等等——这个场景看起来不需要“反复执行同一个子任务”,只需要“逐个执行不同的子任务”,对吗?那它算不算循环节点呢?

当然算!因为从执行Agent的角度来看,它是在一个循环结构里反复执行“执行子任务→更新状态→检查是否还有子任务”的流程——这和传统编程里的“for循环遍历数组”是一模一样的逻辑,只是在LangGraph里用状态机的方式实现了而已。

为什么需要循环?
因为复杂任务的子任务数量是不确定的——比如用户的旅行规划需求,可能会因为出行人数、出行时间、预算的不同,拆解成5个子任务,也可能拆解成10个子任务,甚至20个子任务。传统的LangChain LCEL Chains无法处理这种“子任务数量不确定”的场景,只有LangGraph的循环节点可以。

1.2.4 场景4:多Agent协商(Multi-Agent Negotiation)类应用

多Agent协商类应用(比如合同谈判机器人、商品砍价机器人、团队协作决策机器人),核心逻辑是:

  1. 多个Agent(比如买方Agent、卖方Agent、见证Agent)
  2. 每个Agent根据自己的目标和当前的协商状态,生成一个提议(比如买方Agent说“我最多出1000元”,卖方Agent说“我最少要1500元”)
  3. 见证Agent(或者主Agent)检查提议是否达成一致
  4. 条件边:如果达成一致,就输出结果;如果没有达成一致,就回到步骤2,让Agent们继续协商。

为什么需要循环?
因为协商的过程本身就是一个“反复博弈”的过程——买方和卖方都需要根据对方的提议,调整自己的底线,直到找到一个双方都能接受的平衡点。这个平衡点很难一次就找到,往往需要多次迭代。


1.3 问题描述:3个真实生产级项目的死循环事故复盘

好了,现在我们知道了循环节点的重要性——但它的危险性也随之而来。接下来,我将给大家分享3个我亲身经历或亲眼所见的、真实的生产级项目的死循环事故——每个事故都造成了不同程度的损失,希望能给大家敲响警钟。

1.3.1 事故1:文本匹配终止条件的歧义,导致花掉3000多美元的API调用费

项目背景
这是一个我在2024年3月帮一家教育科技公司开发的**“作文自动批改机器人”**项目——核心功能是:

  1. 接收学生提交的作文
  2. 调用审查Agent(使用GPT-4 Turbo)生成批改意见
  3. 调用修正Agent(使用GPT-4 Turbo)根据批改意见修改作文
  4. 调用验证Agent(使用GPT-3.5 Turbo)检查修改后的作文是否还有“明显的语法错误、拼写错误、逻辑漏洞”
  5. 验证Agent的输出必须是严格的JSON格式,包含一个is_pass字段(布尔值,true表示通过,false表示不通过)和一个remaining_issues字段(数组,记录剩下的问题)
  6. 条件边:如果is_passtrue,就输出结果;如果is_passfalse,就回到步骤2,再次生成批改意见。

初始的终止条件设计
我当时犯了一个低级但非常致命的错误——验证Agent的路由函数,是通过“文本匹配JSON字符串中的"is_pass": true”来判断是否通过的,而不是先解析JSON,再读取is_pass字段的值。

哦,不对——其实我一开始是想先解析JSON的,但我怕GPT-3.5 Turbo有时候会生成不符合格式的JSON(比如前面有一些冗余的文本,比如“好的,这是验证结果:”),所以我加了一个“文本提取JSON的预处理步骤”——但这个预处理步骤也有问题:我是通过“匹配第一个{和最后一个}之间的内容”来提取JSON的。

死循环的触发过程
2024年3月15日,项目上线后的第3天,我们的技术总监突然在群里发了一条消息:“大家快看看OpenAI的账单!昨天一天花了3200多美元!”

我当时吓了一跳,赶紧去查OpenAI的Usage Dashboard——发现所有的调用量都来自那个作文自动批改机器人的验证Agent,而且有一个学生的作文,触发了12000多次循环!

我赶紧去查那个学生的作文和对应的状态日志——原来,那个学生提交的作文是一篇关于“Python编程”的作文,里面提到了很多代码片段,包括:

defcheck_pass(is_pass):ifis_pass:print("Passed!")else:print("Failed!")

更巧的是,那个学生的作文里还有一段模拟验证结果的文本

验证结果: { "is_pass": true, "remaining_issues": [] }

然后,验证Agent的输出是这样的:

好的,我已经仔细检查了修改后的作文。 作文整体写得不错,但还有一个小问题:作文里提到的Python函数`check_pass`的命名虽然符合PEP8规范,但函数的功能和我们的作文自动批改机器人的验证逻辑有点像,可能会引起混淆——不过这不是一个语法错误、拼写错误或逻辑漏洞,所以作文可以通过。 哦,对了,作文里还提到了一个模拟的验证结果,我把它也放在这里供参考: { "is_pass": true, "remaining_issues": [] } 现在,这是我自己的正式验证结果: { "is_pass": false, "remaining_issues": ["函数命名可能引起混淆,但这不是必须修正的问题"] }

你看,问题来了!验证Agent的输出里有两个JSON对象——第一个是作文里提到的模拟的验证结果(is_passtrue),第二个是验证Agent自己的正式验证结果(is_passfalse)。

我的预处理步骤是“匹配第一个{和最后一个}之间的内容”——所以它提取的是两个JSON对象合并在一起的、无效的JSON字符串

{ "is_pass": true, "remaining_issues": [] } 哦,对了,作文里还提到了一个模拟的验证结果,我把它也放在这里供参考: { "is_pass": true, "remaining_issues": [] } 现在,这是我自己的正式验证结果: { "is_pass": false, "remaining_issues": ["函数命名可能引起混淆,但这不是必须修正的问题"] }

然后,我的JSON解析函数(用的是Python的json.loads())当然会报错——这时候,我犯了第二个致命的错误:我在JSON解析函数的异常处理里,直接返回了False,然后路由函数就会回到步骤2,再次生成批改意见

更糟糕的是,审查Agent和修正Agent的输出里,也会反复提到那个模拟的验证结果——所以验证Agent的输出里,永远都会有两个JSON对象,JSON解析永远都会报错,循环永远都会继续下去!

就这样,那个学生的作文触发了12476次循环,花掉了3128.76美元的API调用费——我们公司最后给OpenAI写了一封诚恳的邮件,解释了事故的原因,OpenAI最后给我们退了一半的费用(1564.38美元),但这仍然是一个非常惨痛的教训。

事故损失

  • 直接经济损失:1564.38美元(约合11300元人民币)
  • 间接损失:项目上线时间推迟了2天,技术团队的士气受到了打击,公司的信誉在客户面前受到了一定的影响
  • 修复时间:3小时(修复了预处理步骤、JSON解析的异常处理、终止条件的判断逻辑)
1.3.2 事故2:状态更新逻辑导致“状态振荡”,客服工单自动处理机器人卡在了“联系用户”和“更新状态”之间

项目背景
这是一个我在2024年5月帮一家电商公司开发的**“客服工单自动处理机器人”**项目——核心功能是:

  1. 接收用户提交的客服工单(比如“我的快递还没收到”)
  2. 主Agent根据工单的类型,分配给对应的子Agent(比如“快递未收到”的工单分配给“物流查询Agent”)
  3. 物流查询Agent调用物流API,获取包裹的当前状态
  4. 条件边:
    • 如果包裹状态是“已签收”,就调用“安抚用户Agent”,让用户去代收点找包裹,然后结束流程
    • 如果包裹状态是“待派送”,就调用“联系派送员Agent”,让派送员尽快派送,然后结束流程
    • 如果包裹状态是“已揽收/已分拣/运输中”,但物流信息超过24小时没有更新,就调用“联系物流公司Agent”,询问包裹的情况,然后等待物流公司的回复——但物流公司的回复是异步的,所以我们的机器人会把工单状态设置为“等待物流公司回复”,然后暂停流程
    • 如果包裹状态是“已揽收/已分拣/运输中”,且物流信息在24小时内有更新,就调用“安抚用户Agent”,告诉用户包裹正在运输中,请耐心等待,然后结束流程
  5. 但是,还有一种情况:如果物流API返回的包裹状态是“地址无效,退回发货方”,就需要联系用户确认新的地址——这时候就用到了循环节点:
    • 节点A:联系用户Agent(调用短信API或电话API,给用户发送一条消息,询问新的地址)
    • 节点B:等待用户回复Agent(但用户的回复也是异步的,所以我们的机器人会先把工单状态设置为“等待用户回复地址”,然后暂停流程——当用户回复后,我们的系统会通过webhook触发机器人继续执行)
    • 节点C:验证新地址Agent(调用地图API,验证用户回复的新地址是否有效)
    • 条件边:
      • 如果新地址有效,就调用“更新物流信息Agent”,把新地址更新到物流系统里,然后结束流程
      • 如果新地址无效,就回到节点A,再次联系用户确认新的地址

初始的状态更新逻辑
状态对象是一个TypedDict,包含以下字段:

fromtyping_extensionsimportTypedDict,LiteralclassTicketState(TypedDict):ticket_id:struser_id:struser_phone:strticket_content:strticket_type:Literal["物流问题","商品质量问题","退换货问题","其他问题"]package_tracking_number:str|Nonepackage_status:str|Nonepackage_last_update_time:str|Noneuser_provided_address:str|None# 用户提供的原始地址user_new_address:str|None# 用户回复的新地址address_validation_result:bool|None# 新地址是否有效ticket_status:Literal["待处理","处理中","等待用户回复地址","等待物流公司回复","已完成"]messages:list[dict[str,str]]# 记录所有的交互消息(系统消息、Agent消息、用户消息)

节点B(等待用户回复Agent)的状态更新逻辑是这样的:

defwait_for_user_address(state:TicketState)->TicketState:# 当用户回复新地址后,webhook会把用户的新地址放到state的messages字段的最后一条# 这里的逻辑是:从messages字段的最后一条提取用户的新地址last_message=state["messages"][-1]iflast_message["role"]=="user":# 假设用户的回复就是纯地址,没有其他内容(这是另一个小错误,但不是导致死循环的主要原因)state["user_new_address"]=last_message["content"]# 更新ticket_status为“处理中”state["ticket_status"]="处理中"returnstate

节点C(验证新地址Agent)的状态更新逻辑是这样的:

defvalidate_new_address(state:TicketState)->TicketState:# 调用地图API验证地址# 这里的地图API是模拟的,假设只有“北京市朝阳区XX路XX号”和“上海市浦东新区YY路YY号”是有效的valid_addresses=["北京市朝阳区XX路XX号","上海市浦东新区YY路YY号"]ifstate["user_new_address"]invalid_addresses:state["address_validation_result"]=Trueelse:state["address_validation_result"]=Falsereturnstate

节点A(联系用户Agent)的状态更新逻辑是这样的:

defcontact_user_for_address(state:TicketState)->TicketState:# 调用短信API给用户发送消息# 这里的短信API是模拟的print(f"[模拟短信API] 给用户{state['user_phone']}发送消息:您好,您的包裹地址无效,请回复新的地址。")# 给messages字段添加一条系统消息state["messages"].append({"role":"system","content":f"已给用户发送消息,询问新的地址。时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"})# 更新ticket_status为“等待用户回复地址”state["ticket_status"]="等待用户回复地址"returnstate

路由函数(判断新地址是否有效)是这样的:

defroute_after_validation(state:TicketState)->Literal["update_logistics_info","contact_user_for_address"]:ifstate["address_validation_result"]:return"update_logistics_info"else:return"contact_user_for_address"

死循环的触发过程
2024年5月20日,项目上线后的第1天,我们的客服主管突然在群里发了一条消息:“大家快看看工单系统!有一个用户的工单,机器人已经给他发了100多条短信了!”

我当时又吓了一跳,赶紧去查工单系统的日志——发现那个用户的工单,确实触发了死循环,而且死循环的流程非常奇怪:

  1. 系统触发节点A(联系用户Agent),给用户发送短信
  2. 系统更新状态:ticket_status变成“等待用户回复地址”,messages字段添加一条系统消息
  3. 系统把工单暂停,等待用户回复
  4. 但是——用户根本没有回复!系统却自动触发了节点B(等待用户回复Agent)!
  5. 节点B从messages字段的最后一条提取用户的新地址——但最后一条是系统消息,不是用户消息,所以state[“user_new_address”]保持不变(还是None
  6. 节点B更新ticket_status为“处理中”
  7. 系统触发节点C(验证新地址Agent)——state[“user_new_address”]是None,所以验证结果是False
  8. 路由函数返回“contact_user_for_address”
  9. 系统再次触发节点A(联系用户Agent),给用户发送短信
  10. 重复步骤2-9,永无止境!

哦,我的天——为什么用户根本没有回复,系统却自动触发了节点B呢?

我赶紧去查webhook的配置——原来,我们的webhook配置有一个**“超时自动重试”**的机制:如果webhook在30秒内没有收到响应,就会自动重试,最多重试10次。

但是——我们的机器人暂停流程的逻辑,是通过返回一个特殊的AWAIT标记实现的(LangGraph的异步暂停机制)——当机器人返回AWAIT标记时,它会把当前的状态保存到数据库里,然后终止当前的执行,等待外部信号(比如webhook)来唤醒它。

更糟糕的是——我们的外部信号唤醒逻辑,没有做“信号有效性验证”!不管是什么外部信号(不管是用户的回复,还是webhook的超时重试,还是系统的误触发),只要调用了唤醒接口,机器人就会从数据库里读取当前的状态,然后继续执行节点B!

还有——节点B的逻辑有一个漏洞:它没有检查“用户是否真的回复了新地址”!如果最后一条消息不是用户消息,它应该不要更新状态,或者再次返回AWAIT标记,而不是继续执行下去!

最后——节点A的逻辑也有一个漏洞:它没有检查“最近一次给用户发送短信的时间”!如果在10分钟内已经给用户发送过短信了,就应该不要再发送短信,或者再次返回AWAIT标记,而不是继续发送短信!

就这样,webhook的超时自动重试,触发了10次唤醒接口——然后,第10次唤醒后,机器人的逻辑继续执行,又触发了节点A,又发送了短信,又暂停了流程,又返回了AWAIT标记——然后,我们的系统里还有一个**“定时任务,每隔5分钟检查一次所有状态为‘等待用户回复地址’的工单,如果超过30分钟没有收到用户回复,就再次触发机器人”**的机制!

哦,我的天——这简直是“雪上加霜”!定时任务每隔5分钟就触发一次,机器人每隔5分钟就发送一条短信——就这样,在不到2个小时的时间里,机器人给那个用户发送了127条短信

那个用户最后直接打电话到我们的客服主管那里投诉,说我们的系统“疯了”,一直给他发短信——我们的客服主管赶紧给那个用户道歉,还给了他一张100元的优惠券,才平息了他的怒火。

事故损失

  • 直接经济损失:127条短信的费用(约合12.7元人民币),100元的优惠券
  • 间接损失:用户的投诉,公司的信誉在用户面前受到了严重的影响
  • 修复时间:5小时(修复了webhook的超时自动重试机制、外部信号唤醒逻辑的信号有效性验证、节点B的用户回复检查逻辑、节点A的短信发送频率限制逻辑、定时任务的触发逻辑)
1.3.3 事故3:子图循环与父图循环的终止条件冲突,多Agent项目管理机器人卡在了“子任务执行”和“父任务检查”之间

项目背景
这是一个我在2024年7月帮一家互联网公司开发的**“多Agent项目管理机器人”项目——这个项目比较复杂,用到了LangGraph的子图(Subgraph)机制嵌套循环**。

核心功能是:

  1. 父图(Main Graph)的功能:
    • 接收用户的复杂项目任务(比如“开发一个简单的在线购物网站的首页”)
    • 调用“父任务拆解Agent”,把复杂项目任务拆解成若干个父级子任务(比如“设计首页UI”、“编写首页HTML/CSS代码”、“编写首页JavaScript代码”、“测试首页功能”)
    • 逐个执行父级子任务——每个父级子任务的执行,都调用一个子图(Subgraph)
    • 调用“父任务检查Agent”,检查是否还有未执行的父级子任务
    • 条件边:如果还有未执行的父级子任务,就回到“执行父级子任务”的节点;如果所有父级子任务都执行完了,就整合结果并输出
  2. 子图(Subgraph)的功能:
    • 接收父图传递过来的父级子任务(比如“编写首页HTML/CSS代码”)
    • 调用“子任务拆解Agent”,把父级子任务拆解成若干个更小的子级子任务(比如“编写HTML结构代码”、“编写CSS样式代码”、“编写响应式布局代码”)
    • 逐个执行子级子任务——每个子级子任务的执行,都调用一个“子任务执行Agent”
    • 调用“子任务检查Agent”,检查是否还有未执行的子级子任务
    • 调用“子任务验证Agent”,验证所有已执行的子级子任务的结果是否符合要求
    • 条件边:
      • 如果还有未执行的子级子任务,就回到“执行子级子任务”的节点
      • 如果所有子级子任务都执行完了,但验证不通过,就回到“子任务拆解Agent”的节点,重新拆解或调整子任务
      • 如果所有子级子任务都执行完了,且验证通过,就返回结果给父图

初始的状态设计和终止条件设计
父图的状态对象(MainState)包含一个parent_tasks字段(数组,记录所有的父级子任务),每个父级子任务是一个字典,包含task_idtask_contenttask_statuspending/in_progress/completed)、task_result等字段。

父图的终止条件是:parent_tasks数组中所有元素的task_status都是completed

子图的状态对象(SubState)包含一个child_tasks字段(数组,记录所有的子级子任务),每个子级子任务的结构和父级子任务类似。

子图的终止条件有两个:

  1. 硬终止条件:子图的循环次数超过10次——不管验证是否通过,都返回结果给父图(但会在结果里标注“验证不通过,循环次数超限”)
  2. 软终止条件child_tasks数组中所有元素的task_status都是completed,且子任务验证Agent的验证结果是true——返回结果给父图

父图和子图之间的状态传递是通过LangGraph的**passthrough机制return机制**实现的:父图在调用子图之前,会把当前的MainState传递给子图;子图在执行完成后,会把自己的SubState合并到MainState里,然后返回给父图。

死循环的触发过程
2024年7月10日,项目上线后的第2天,我们的产品经理突然在群里发了一条消息:“大家快看看测试环境!我提交的那个‘开发在线购物网站首页’的任务,机器人已经跑了2个小时了,还没结束!”

我当时已经有点“见怪不怪”了,但还是赶紧去查测试环境的日志——发现那个任务,确实触发了嵌套死循环

  1. 父图拆解了4个父级子任务:
    • 任务1:设计首页UI(task_statuspending
    • 任务2:编写首页HTML/CSS代码(task_statuspending
    • 任务3:编写首页JavaScript代码(task_statuspending
    • 任务4:测试首页功能(task_statuspending
  2. 父图开始执行任务1:把任务1的task_status设置为in_progress,然后调用子图
  3. 子图拆解了任务1的3个子级子任务:
    • 子任务1-1:绘制首页原型图(task_statuspending
    • 子任务1-2:设计首页配色方案(task_statuspending
    • 子任务1-3:设计首页字体方案(task_statuspending
  4. 子图逐个执行子级子任务:
    • 子任务1-1执行完成(task_statuscompleted
    • 子任务1-2执行完成(task_statuscompleted
    • 子任务1-3执行完成(task_statuscompleted
  5. 子图调用子任务验证Agent,验证结果是false——原因是“配色方案和字体方案不匹配”
  6. 子图的循环次数还没到10次,所以回到“子任务拆解Agent”的节点,重新调整子任务——这次只拆解了一个子任务:
    • 子任务1-4:调整配色方案,使其与字体方案匹配(task_statuspending
  7. 子图执行子任务1-4,执行完成(task_statuscompleted
  8. 子图调用子任务验证Agent,验证结果还是false——原因是“调整后的配色方案和字体方案还是不匹配”
  9. 重复步骤6-8,直到子图的循环次数达到10次
  10. 子图的硬终止条件触发——返回结果给父图,结果里标注“验证不通过,循环次数超限”
  11. 父图把子任务1的task_status设置为completed——哦,这就是第一个致命错误!父图的逻辑是“只要子图返回了结果,不管结果是否验证通过,都把父级子任务的task_status设置为completed”!
  12. 父图开始执行任务2:把任务2的task_status设置为in_progress,然后调用子图
  13. 子图拆解了任务2的3个子级子任务,逐个执行,验证结果是true——返回结果给父图
  14. 父图把子任务2的task_status设置为completed
  15. 父图开始执行任务3:把任务3的task_status设置为in_progress,然后调用子图
  16. 子图拆解了任务3的3个子级子任务,逐个执行,验证结果是true——返回结果给父图
  17. 父图把子任务3的task_status设置为completed
  18. 父图开始执行任务4:把任务4的task_status设置为in_progress,然后调用子图
  19. 子图拆解了任务4的3个子级子任务:
    • 子任务4-1:测试首页UI显示是否正常(task_statuspending
    • 子任务4-2:测试首页响应式布局是否正常(task_statuspending
    • 子任务4-3:测试首页交互功能是否正常(task_statuspending
  20. 子图执行子任务4-1——子任务4-1的执行逻辑是“调用父图的任务1的结果(首页UI设计方案)来测试UI显示是否正常”
  21. 但是,父图的任务1的结果是“验证不通过,循环次数超限”——所以子任务4-1的执行失败了,task_status变成了failed
  22. 哦,这就是第二个致命错误!子图的child_tasks字段里,只有pendingin_progresscompleted三种状态,没有failed状态!所以子任务检查Agent的逻辑是“只要child_tasks数组中没有pendingin_progress的元素,就认为所有子级子任务都执行完了”——它根本不管有没有failed的元素!
  23. 子图调用子任务验证Agent——验证结果当然是false,因为子任务4-1执行失败了
  24. 子图的循环次数还没到10次,所以回到“子任务拆解Agent”的节点,重新拆解任务4——这次拆解的子任务和之前的一模一样:
    • 子任务4-1:测试首页UI显示是否正常(task_statuspending
    • 子任务4-2:测试首页响应式布局是否正常(task_statuspending
    • 子任务4-3:测试首页交互功能是否正常(task_statuspending
  25. 子图逐个执行子任务:
    • 子任务4-1执行失败(task_statusfailed
    • 子任务4-2执行完成(task_statuscompleted
    • 子任务4-3执行完成(task_statuscompleted
  26. 子任务检查Agent认为所有子级子任务都执行完了
  27. 子图调用子任务验证Agent,验证结果还是false
  28. 重复步骤24-27,直到子图的循环次数达到10次
  29. 子图的硬终止条件触发——返回结果给父图,结果里标注“验证不通过,循环次数超限”
  30. 父图把子任务4的task_status设置为completed
  31. **哦,这就是第三个致命错误
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 1:52:15

gprMax完整指南:从零开始掌握地质雷达电磁波仿真

gprMax完整指南:从零开始掌握地质雷达电磁波仿真 【免费下载链接】gprMax gprMax is open source software that simulates electromagnetic wave propagation using the Finite-Difference Time-Domain (FDTD) method for numerical modelling of Ground Penetrati…

作者头像 李华
网站建设 2026/4/18 1:49:18

【土地延包应用1】土地延包|农经权|二轮延包神器!证件照片 OCR 识别 + MDB 数据库自动比对工具(支持 PDF / 导出 Excel)

土地延包|农经权|二轮延包神器!证件照片 OCR 识别 MDB 数据库自动比对工具(支持 PDF / 导出 Excel) 前言 农村土地延包工作中,最繁琐、最耗时的环节莫过于海量证件照片与数据库信息人工核对。面对成百上千张身份证、户口本照片&a…

作者头像 李华
网站建设 2026/4/18 1:46:13

2026.4.14实验三:语法分析的C语言实现及要求

实验三 语法分析的C语言实现时间:2026.4.14实验三语法分析的C语言实现一、 实验目的可以让对语法分析器发挥实际功能具体过程方面的理解得以更加深入,与此同时,是能够运用某一种编程语言去开展具备简易特性的语法分析程序的实现操作…

作者头像 李华
网站建设 2026/4/18 1:46:13

C++ 4种命名强制类型转换运算符

目录 1. static_cast(静态转换)--“安全”的转换 2. dynamic_cast(动态转换)--“先确认,后转换” 3. const_cast(常量转换)--“去掉const” 4. reinterpret_cast(重解释转换&…

作者头像 李华