Ai-Agent学习历程—— 阶段4——LangGraph 与 Harness

Ai-Agent学习历程------ 阶段4------LangGraph 与 Harness

  • 前情提要
  • 学习地图&大纲
    • [一、 StateGraph 与 Schema (定义上下文)](#一、 StateGraph 与 Schema (定义上下文))
    • [二、 Nodes & Edges (图流转与条件路由)](#二、 Nodes & Edges (图流转与条件路由))
    • [三、 Harness 容错拦截 (增强系统鲁棒性)](#三、 Harness 容错拦截 (增强系统鲁棒性))
    • [四、 Checkpointer 记忆持久化 (断点与短程恢复)](#四、 Checkpointer 记忆持久化 (断点与短程恢复))
    • [五、 Human-in-the-loop 人工审批 (人机协同机制)](#五、 Human-in-the-loop 人工审批 (人机协同机制))
  • [一、 StateGraph 与 Schema (定义上下文)](#一、 StateGraph 与 Schema (定义上下文))
    • [1.1 核心概念:状态机与全局上下文](#1.1 核心概念:状态机与全局上下文)
      • [(1)用 Java 思维理解 StateGraph](#(1)用 Java 思维理解 StateGraph)
        • [1. 实战:在 Python 中定义 Schema 并初始化图](#1. 实战:在 Python 中定义 Schema 并初始化图)
        • [2. 这三种 Schema 在工程选型上怎么挑?](#2. 这三种 Schema 在工程选型上怎么挑?)
        • [3. 为什么 LLM Agent直接选择 MessagesState](#3. 为什么 LLM Agent直接选择 MessagesState)
      • [(2)在 Python 中如何定义 Schema](#(2)在 Python 中如何定义 Schema)
        • [1. TypedDict:贫血模型 DTO](#1. TypedDict:贫血模型 DTO)
        • [2. Pydantic (BaseModel):充血模型 / 强校验 DTO](#2. Pydantic (BaseModel):充血模型 / 强校验 DTO)
        • [3. MessagesState:开箱即用的快捷 Schema](#3. MessagesState:开箱即用的快捷 Schema)
    • [1.2 状态的更新与 Reducer 机制](#1.2 状态的更新与 Reducer 机制)
      • [1. 默认行为:增量返回与局部覆盖](#1. 默认行为:增量返回与局部覆盖)
        • [(1)Java 思想映射:`Map.putAll()` / `PATCH` 请求](#(1)Java 思想映射:Map.putAll() / PATCH 请求)
      • [2. 突破默认:Reducer 与 `Annotated` 的魔法](#2. 突破默认:Reducer 与 Annotated 的魔法)
        • [(1)Python 语法糖:`Annotated` 到底是个啥?](#(1)Python 语法糖:Annotated 到底是个啥?)
        • [(2)`add_messages` 的高级合并逻辑(不仅是 Append)](#(2)add_messages 的高级合并逻辑(不仅是 Append))
      • [3. 实战代码:亲眼看看"覆盖"与"追加"的区别](#3. 实战代码:亲眼看看“覆盖”与“追加”的区别)
    • [1.3 底层流转:Pregel 引擎的并发与事务魔法](#1.3 底层流转:Pregel 引擎的并发与事务魔法)
      • [1. 为什么节点只需要返回"局部字典"?](#1. 为什么节点只需要返回“局部字典”?)
      • [2. Pregel 引擎的并发魔法与安全防御](#2. Pregel 引擎的并发魔法与安全防御)
        • [(1)什么叫"整体同步并行 (BSP)"?](#(1)什么叫“整体同步并行 (BSP)”?)
        • [(2)⚠️ 致命危机:并行节点的"多写冲突"](#(2)⚠️ 致命危机:并行节点的“多写冲突”)
        • [(3)完美解药:用 Reducer 化解并发冲突](#(3)完美解药:用 Reducer 化解并发冲突)
    • [1.4 ⚠️ 避坑指南:防止全局 Schema 被过度污染](#1.4 ⚠️ 避坑指南:防止全局 Schema 被过度污染)
  • [二、 Nodes & Edges (图流转与条件路由)](#二、 Nodes & Edges (图流转与条件路由))
    • [2.1 节点与控制流 (Nodes & Normal Edges)](#2.1 节点与控制流 (Nodes & Normal Edges))
      • [1. 节点的双重形态:同步与异步](#1. 节点的双重形态:同步与异步)
      • [2. 实战代码:构建一条包含异步节点的线性流水线](#2. 实战代码:构建一条包含异步节点的线性流水线)
      • [3. 普通边的局限性](#3. 普通边的局限性)
    • [2.2 Conditional Edges (条件路由)](#2.2 Conditional Edges (条件路由))
      • [1. 为什么需要条件边?(动态路由)](#1. 为什么需要条件边?(动态路由))
        • [(1)Java 视角映射:排他网关与 Switch-Case](#(1)Java 视角映射:排他网关与 Switch-Case)
      • [2. 核心运转机制:Router 与 Path Map](#2. 核心运转机制:Router 与 Path Map)
      • [3. 实战代码:写一个会思考的动态路由](#3. 实战代码:写一个会思考的动态路由)
      • [4. 理解路由机制的核心](#4. 理解路由机制的核心)
      • [5. 生产层次的架构思考:优雅的动态路由和工具调用](#5. 生产层次的架构思考:优雅的动态路由和工具调用)
    • [2.3 🛡️ 防御机制 (防死循环)](#2.3 🛡️ 防御机制 (防死循环))
      • [1. 灾难重现:无限死循环 (Infinite Loop)](#1. 灾难重现:无限死循环 (Infinite Loop))
      • [🛡️ 第一道防线:全局熔断器 (Recursion Limit)](#🛡️ 第一道防线:全局熔断器 (Recursion Limit))
        • [(1)Java 视角映射:微服务熔断 (Circuit Breaker)](#(1)Java 视角映射:微服务熔断 (Circuit Breaker))
        • (2)如何配置?
      • [🛡️ 第二道防线:优雅降级感知 (RemainingSteps 机制)](#🛡️ 第二道防线:优雅降级感知 (RemainingSteps 机制))
      • [3. 防死循环的哲学](#3. 防死循环的哲学)
      • [4. 基于现在流行的长任务思考](#4. 基于现在流行的长任务思考)
    • [2.4 ⚠️ 避坑指南:条件边的运行时 ValueError](#2.4 ⚠️ 避坑指南:条件边的运行时 ValueError)
      • [1. 灾难现场:潜伏在运行时的 ValueError](#1. 灾难现场:潜伏在运行时的 ValueError)
      • [2. 破局之道:如何彻底消灭路由配置 Bug?](#2. 破局之道:如何彻底消灭路由配置 Bug?)
        • [(1)防线一:使用 `Literal` 限制返回值 (类型层面的 Enum)](#(1)防线一:使用 Literal 限制返回值 (类型层面的 Enum))
        • [(2)防线二:省略 path_map 的"隐式路由"(高级技巧)](#(2)防线二:省略 path_map 的“隐式路由”(高级技巧))
      • [🌟 第二章 全面总结 (Checkpoint)](#🌟 第二章 全面总结 (Checkpoint))
  • [三、 Harness 容错拦截 (增强系统鲁棒性)](#三、 Harness 容错拦截 (增强系统鲁棒性))
      • [3.1 实战:配置网络层面的 Spring Retry](#3.1 实战:配置网络层面的 Spring Retry)
        • [1. 核心对象:`RetryPolicy`](#1. 核心对象:RetryPolicy)
        • [2. 代码实战](#2. 代码实战)
      • [3. 为什么必须要加 `jitter=True`?](#3. 为什么必须要加 jitter=True?)
    • [3.2 大模型逻辑自愈 (Self-Correction & Loop Guard)](#3.2 大模型逻辑自愈 (Self-Correction & Loop Guard))
      • [1. 把 Exception 变成 Prompt(提示词)](#1. 把 Exception 变成 Prompt(提示词))
        • [(1)Java 视角映射:全局异常处理器 `@ControllerAdvice`](#(1)Java 视角映射:全局异常处理器 @ControllerAdvice)
      • [2. 实战代码:构建"带防死循环"的自愈闭环](#2. 实战代码:构建“带防死循环”的自愈闭环)
      • [3. 解析](#3. 解析)
    • [3.3 Agent Harness 中间件层 (代理脚手架)](#3.3 Agent Harness 中间件层 (代理脚手架))
      • [1. 架构痛点:为什么需要 Harness (中间件)?](#1. 架构痛点:为什么需要 Harness (中间件)?)
      • [2. Python 语法糖补充:高阶函数与装饰器](#2. Python 语法糖补充:高阶函数与装饰器)
      • [3. 实战代码:打造一个纯粹的 AOP 切面拦截器](#3. 实战代码:打造一个纯粹的 AOP 切面拦截器)
      • [4. 两大数据通道的完美分工](#4. 两大数据通道的完美分工)
    • [3.4 ⚠️ 避坑指南:Tool Node 的分类错误处理 (Handle Tool Errors)](#3.4 ⚠️ 避坑指南:Tool Node 的分类错误处理 (Handle Tool Errors))
      • [1. 灾难现场:致命的默认行为](#1. 灾难现场:致命的默认行为)
      • [2. 破局之道:错误分类治理 (双轨制处理)](#2. 破局之道:错误分类治理 (双轨制处理))
      • [3. 实战代码:打造一个坚不可摧的 Tool 异常拦截器](#3. 实战代码:打造一个坚不可摧的 Tool 异常拦截器)
      • [4. 为什么这种写法更加的稳定](#4. 为什么这种写法更加的稳定)
      • [5. 生产级异常捕捉](#5. 生产级异常捕捉)
      • [🌟 第三章 全面总结 (Checkpoint)](#🌟 第三章 全面总结 (Checkpoint))
  • [四、 Checkpointer 记忆持久化 (断点与短程恢复)](#四、 Checkpointer 记忆持久化 (断点与短程恢复))
    • [4.1 什么是 Checkpointer?(状态快照与持久化介质)](#4.1 什么是 Checkpointer?(状态快照与持久化介质))
    • [4.2 Thread ID:会话隔离与断点唤醒](#4.2 Thread ID:会话隔离与断点唤醒)
    • [4.3 状态回放与时光倒流 (Time Travel)](#4.3 状态回放与时光倒流 (Time Travel))
      • [1. 核心概念与 Java 映射](#1. 核心概念与 Java 映射)
      • [2. 实战代码:化身"时间刺客"修改过去](#2. 实战代码:化身“时间刺客”修改过去)
      • [3. 架构师复盘](#3. 架构师复盘)
      • [4. 关于Checkpointer的最大误区](#4. 关于Checkpointer的最大误区)
    • [4.4 ⚠️ 避坑指南:状态爆炸与滚动窗口策略](#4.4 ⚠️ 避坑指南:状态爆炸与滚动窗口策略)
      • [1. 如何打破"只增不减"的 Reducer 规则?](#1. 如何打破“只增不减”的 Reducer 规则?)
      • [2. 实战代码:打造一个"会自己压缩记忆"的 Agent](#2. 实战代码:打造一个“会自己压缩记忆”的 Agent)
      • [3. 架构师复盘](#3. 架构师复盘)
      • [🌟 第四章 全面闭环 (Checkpoint)](#🌟 第四章 全面闭环 (Checkpoint))
  • [五、 Human-in-the-loop 人工审批 (人机协同机制)](#五、 Human-in-the-loop 人工审批 (人机协同机制))
    • [5.1 安全阻断与状态挂起 (Interrupts)](#5.1 安全阻断与状态挂起 (Interrupts))
    • [5.2 状态编辑与外部唤醒 (Resume & State Editing)](#5.2 状态编辑与外部唤醒 (Resume & State Editing))
    • [5.3 ⚠️ 避坑指南:异步并发与子图隔离 (Concurrency & Subgraphs)](#5.3 ⚠️ 避坑指南:异步并发与子图隔离 (Concurrency & Subgraphs))
      • [1. 异步 Webhook 的上下文寻址](#1. 异步 Webhook 的上下文寻址)
        • [(1)Thread_ID 丢失的灾难](#(1)Thread_ID 丢失的灾难)
      • [2. 分布式子图的命名空间 (Namespace Isolation)](#2. 分布式子图的命名空间 (Namespace Isolation))
      • [1. 大坑一:异步 Webhook 的上下文寻址 (Thread_ID 丢失)](#1. 大坑一:异步 Webhook 的上下文寻址 (Thread_ID 丢失))
      • [2. 大坑二:分布式子图的挂起与命名空间 (Namespace Isolation)](#2. 大坑二:分布式子图的挂起与命名空间 (Namespace Isolation))
      • 复盘
  • [🌟 全景总结:从脚本玩具到企业级智能体的架构跃迁](#🌟 全景总结:从脚本玩具到企业级智能体的架构跃迁)
    • [第一层:数据与持久层 (基础设施) ------ 对应 Ch1 & Ch4](#第一层:数据与持久层 (基础设施) —— 对应 Ch1 & Ch4)
    • [第二层:业务流转与路由层 (核心计算) ------ 对应 Ch2](#第二层:业务流转与路由层 (核心计算) —— 对应 Ch2)
    • [第三层:容错与切面中间件层 (高可用保障) ------ 对应 Ch3](#第三层:容错与切面中间件层 (高可用保障) —— 对应 Ch3)
    • [第四层:协同与分布式调度层 (人机与微服务) ------ 对应 Ch5](#第四层:协同与分布式调度层 (人机与微服务) —— 对应 Ch5)
      • [💡 最终结语](#💡 最终结语)

前情提要

上一章学习了Rag和记忆机制,这一章我们将开始最重要的StateGraphHarness

上一章可回顾:Ai-Agent学习历程------ 阶段3------RAG 与记忆机制

学习地图&大纲

一、 StateGraph 与 Schema (定义上下文)

  • 核心概念 :基于 StateGraph 的状态机设计。图流转中的所有节点通过共享同一个全局 State(可基于 TypedDict 或 Pydantic 定义)进行增量通信。
  • 状态累加与更新
    • 通过 Annotated[list, add_messages] 等 Reducer 实现状态的部分追加与合并(如增量更新聊天消息流)。
    • 各节点执行完毕后,只需返回需要更新的局部字段字典,底层的 Pregel 引擎会自动将其 merge 到全局 State 中。
  • ⚠️ 注意事项 :尽量避免直接在全局 State 中维护复杂的业务中转变量。复杂的 Multi-Agent 系统应优先使用 subgraph(子图)机制,使局部状态独立,防止全局 Schema 被过度污染。

二、 Nodes & Edges (图流转与条件路由)

  • 节点与控制流
    • Nodes (节点):普通的同步/异步 Python 函数,读取当前 State,返回更新后的字典。
    • Normal Edges (普通边) :通过 .add_edge(node_a, node_b) 实现强绑定的节点流转。
    • Conditional Edges (条件边):根据 Router 函数的判断逻辑,动态决定下一个要执行的节点。
  • 防御机制 (防死循环)
    • 在编译或运行图时,必须显式配置 recursion_limit(最大递归步数限制,例如 25),防止 LLM 陷入由于工具调用失败而导致的不间断循环。
    • 可搭配 RemainingSteps 注解,让 Agent 提前感知当前的可用步数,避免被强制中断引发的非优雅报错。
  • ⚠️ 注意事项 :在复杂的图路由中,条件边所指向的所有目的节点均须在 path_map 映射字典中完整声明,防止由于边缘路径缺失引发运行时 ValueError

三、 Harness 容错拦截 (增强系统鲁棒性)

  • 异常捕获与自愈
    • 在节点层配置 RetryPolicy 实现指数退避重试,屏蔽因大模型 API 抖动导致的瞬时网络故障。
    • 通过自定义拦截器(Harness Layer)捕获 NodeError,并把报错反馈(Feedback Loop)格式化为下一次的提示词,供 LLM 尝试自动修正错误逻辑(如修复不合规的 JSON 输出)。
  • Agent Harness (代理脚手架)
    • 引入统一的中间件层(如 LangChain 规范下的 create_agent 脚手架),将统一的安全规则(如 PII 脱敏、调用计数 ModelCallLimit 熔断、数据越权检查)从 Node 的业务逻辑中抽离,降低图的深度。
  • ⚠️ 注意事项 :工具调用(Tool Node)的错误不应直接让整张图挂起。需使用 handle_tool_errors 等机制将报错信息包装为 ToolMessage 返回给 LLM,令其具备"知错并修正"的能力。

四、 Checkpointer 记忆持久化 (断点与短程恢复)

  • 状态持久化介质
    • 开发调试 :使用内存级的 MemorySaver 进行轻量化状态存取。
    • 生产环境 :由于服务可能会重启、多实例部署,必须接入持久化存储方案(如 PostgresSaverSqliteSaver)。
  • 会话管理与回放
    • 通过 thread_id(线程 ID)作为全局的 Persistent Cursor(持久化指针)。
    • 每次调用传入相同的 thread_id,图在启动时便能加载出最近一次生成的 Checkpoint(Snapshot),在此基础上实现断点续传。
  • ⚠️ 注意事项:随着调用次数增加,Checkpointer 数据库中的 checkpoints 历史记录会线性增长。生产环境下需要建立滚动窗口策略(如定期清理非必要快照或只保留最近 N 天),以避免由于读写频繁产生底层数据库的性能瓶颈。

五、 Human-in-the-loop 人工审批 (人机协同机制)

  • 安全阻断 (Interrupts)
    • 静态中断 :在编译图时指定 interrupt_before=["sensitive_node"],强制在敏感节点运行前将图挂起。
    • 动态中断 :在节点代码内部,使用 interrupt(payload) 依据当前的运行时数据动态决定是否交由人工审批。
  • 状态编辑与唤醒
    • 当图处于挂起(Pending)状态时,人工不仅可以给出审批结果(继续/拒绝),还能通过修改当前 State 的快照,实现状态数据的人工修正。
    • 外部唤醒时利用 Command 或对应的 resume 逻辑,图会直接从挂起处的节点恢复执行,无需重复运行之前的节点。
  • ⚠️ 注意事项 :在处理异步、多用户的审批流时,审批的上下文传递必须依赖一致的 thread_id。如果是在具有多代理、子图的分布式微服务下,需注意子图的命名空间(Namespace)隔离,避免父子图之间的 Checkpoint 状态相互覆写导致混乱。

一、 StateGraph 与 Schema (定义上下文)

1.1 核心概念:状态机与全局上下文

首先对 StateGraphScheme 进行一个简单的理解,这到底是什么东西。

  • Scheme: 这相当于Java中的类申明,注意这只是一个定义(设计图),标记了执行过程中需要记录哪些东西,真正在节点之间流转的东西是 State
  • StateGraph: 这是一个有向图引擎,理解为工作流或者架构就好,这就是为了解决Agent在执行过程中不好管理的问题,所以出现了一个架构,用来调度,有哪些节点啊,节点之间的运转关系是怎样的等等。

(1)用 Java 思维理解 StateGraph

1. 实战:在 Python 中定义 Schema 并初始化图

在 Java 开发中,我们习惯在写业务逻辑前先把 Entity、DTO 和 Context 类定义好。在 LangGraph 中也是如此。

requirements.txt

python 复制代码
# ==============================================================================
# LangGraph & AI Agent 核心执行引擎
# ==============================================================================
langgraph>=1.2.4,<2.0.0

# ==============================================================================
# 运行时数据校验与 DTO 序列化工具 (Pydantic V2 系列)
# ==============================================================================
pydantic>=2.10.0,<3.0.0

# ==============================================================================
# LangChain 基础接口与消息领域模型
# ==============================================================================
langchain-core>=1.4.0,<2.0.0
python 复制代码
# 导入所需的库
from typing import TypedDict
from pydantic import BaseModel, Field
from langgraph.graph import StateGraph, MessagesState

# ==========================================
# 方式一:TypedDict (贫血模型 DTO)
# ==========================================
class SimpleState(TypedDict):
    user_id: str
    status: str

# ==========================================
# 方式二:Pydantic (充血模型 / 强校验 DTO)
# ==========================================
class ValidatedState(BaseModel):
    user_id: str = Field(..., min_length=1, description="用户唯一标识")
    status: str = Field(default="pending", description="当前状态")

# ==========================================
# 方式三:MessagesState (官方开箱即用的对话 Schema) - 🌟 生产最常用
# ==========================================
class MyAgentState(MessagesState):
    # 我们只需要在这里扩展属于自己业务的字段
    user_id: str

# ==========================================
# 初始化 StateGraph 骨架
# ==========================================
workflow = StateGraph(MyAgentState)
2. 这三种 Schema 在工程选型上怎么挑?
  1. 极速原型 / 纯内部计算: 首选 TypedDict 。因为它在运行时就是纯粹的 Python dict,序列化/反序列化性能极高,没有任何额外开销。
  2. 对外提供 API / LangGraph Cloud 部署: 强烈建议用 Pydantic (BaseModel) 或带有 Pydantic 的混合模型。如果你想把这个 Agent 暴露为一个 RESTful API,Pydantic 能直接帮你拦截掉非法格式的输入(比如少传了 user_id),防止脏数据进入图的执行流引发深层空指针异常(NoneType error)。
  3. 构建标准 Chatbot / LLM Agent: 闭眼选 MessagesState 。因为绝大多数大模型交互的基石就是 messages 列表。直接继承它可以省去手写 Reducer 的麻烦,同时保持与 LangChain 底层 API(如 ChatOpenAI)的无缝对接。
3. 为什么 LLM Agent直接选择 MessagesState

我们这么来理解,MessagesState是官方定义的一个超类,里面有封装好的东西可以直接用,不需要我们进行手动set和delete,比如最方便的就是它内部自己处理了臃肿的 聊天上下文,不需要我们在第三阶段中手动维护一个 messageList了,这就是优势。

其实就和Java的HttpServletRequest有点像,在正常使用的时候就是:

python 复制代码
from typing import Annotated
from pydantic import BaseModel, Field
from langgraph.graph import MessagesState, StateGraph

# ==========================================
# 1. 使用 Pydantic 定义具体的业务载体 (Payload)
# ==========================================
# 类比 Java:这是一个严格的 UserProfileDTO,用于做业务数据校验
class UserProfile(BaseModel):
    name: str = Field(..., description="用户的真实姓名")
    age: int = Field(..., description="用户年龄,必须大于0")
    intent: str = Field(default="unknown", description="用户的核心诉求")

# ==========================================
# 2. 使用 MessagesState 定义全局上下文 (Session)
# ==========================================
class AppState(MessagesState):
    # 继承后,自带了用于大模型对话的 messages 列表
    user_profile: UserProfile | None 

# ==========================================
# 3. 引擎运转
# ==========================================
workflow = StateGraph(AppState)

(2)在 Python 中如何定义 Schema

1. TypedDict:贫血模型 DTO

没有行为(Methods),只有数据结构和类型定义。类似于 Java 里的 Record(Java 14+)或只包含 @Data 的 Lombok POJO。这是最通用的定义方式。

2. Pydantic (BaseModel):充血模型 / 强校验 DTO

不仅定义字段,还包含运行时的数据校验逻辑(如类型强转、非空校验等)。类似于 Java 中结合了 Hibernate Validator(如 @NotNull, @Pattern)的输入验证 DTO。在将 Agent 部署为 API 服务(如使用 LangGraph Cloud / LangGraph Server)时,Pydantic 是首选。

3. MessagesState:开箱即用的快捷 Schema

在较新的 LangGraph 版本中,如果你构建的是标准的聊天/对话式 Agent,你不需要手动写 messages: Annotated[list, add_messages]。可以直接继承 MessagesState

python 复制代码
from langgraph.graph import MessagesState

# 继承后,它自动包含了名为 messages 的 Annotated[list[AnyMessage], add_messages] 字段
class MyAgentState(MessagesState):
    # 你只需要在此处扩展你自定义的其他业务字段即可
    user_id: str

1.2 状态的更新与 Reducer 机制

Reducer实际上就是一个函数,用来进行状态的更新,官方对其进行了封装。

至于为什么要学习这个,Langgraph的核心就是状态,每一个节点对应一个状态,不然怎么达到审计和追踪的目的呢。


1. 默认行为:增量返回与局部覆盖

在 LangGraph 中,节点(Node)是一个纯粹的函数。它的基本运作规矩是:只读取输入状态,且绝对不直接修改原对象,只返回需要更新的"增量字典"。

(1)Java 思想映射:Map.putAll() / PATCH 请求

假设当前全局 State 里面长这样:{"user_id": "U001", "status": "running"}

现在执行到了 Node A,Node A 想把状态改成完毕。

  • ❌ 错误做法(新手常犯): 直接拿传进来的对象改属性,比如 state['status'] = 'done',然后 return state。这在 LangGraph 中会破坏底层的状态溯源(Time-travel 快照机制)。
  • ✅ 正确做法: Node A 只需直接 return {"status": "done"}。底层引擎会自动捕捉这个小字典,并做一个浅拷贝合并 ,没被 return 的字段(user_id)会原样保留,被 return 的字段会被新值覆盖 。和js中Object.assign差不多

2. 突破默认:Reducer 与 Annotated 的魔法

如果所有字段都是覆盖,那聊天记录怎么办?大模型每聊一句话,旧记录就被清空覆盖了,这就成了金鱼记忆。这时候就需要 Reducer(聚合器) 登场。

(1)Python 语法糖:Annotated 到底是个啥?

这行代码:messages: Annotated[list, add_messages]

  • Annotated 是 Python 的类型注解,它的作用是给基础类型绑定一种"合并行为"
  • Java 类比: 想象一下 Java 里的 ConcurrentHashMap.merge(key, newValue, (oldValue, newValue) -> { return ... })
    这里的 add_messages 就是那个合并逻辑函数!它告诉底层引擎:"嘿,如果节点返回了新的 messages,千万别覆盖 ,请把旧值和新值传给 add_messages 方法,让它来决定怎么合并"。
(2)add_messages 的高级合并逻辑(不仅是 Append)

官方内置的 add_messages 非常聪明,它绝对不是简单的 list.add()

  1. 追加(Append): 如果 Node 返回了一条新消息,且它的 ID 是全新的,它会把它追加到列表末尾。
  2. 更新/覆写(Update): 如果 Node 返回的消息 ID 在历史列表中已经存在 ,它会用新内容替换 旧内容。
    • (架构思考:为什么需要这个?因为大模型在"流式输出(Streaming)"时,一句话会被拆成几百个 token 碎块发过来。每次发过来都是同一个 ID,引擎只需不断更新同一条记录,而不会搞出几百条新记录。)
  3. 一键清空: 如果你需要重置会话,只需要让 Node 返回一个特定的魔法值(类似于 Java 的 session.invalidate()),底层会自动清空所有历史。

3. 实战代码:亲眼看看"覆盖"与"追加"的区别

下面这段极简代码展示了 Node 是如何执行这两种更新逻辑的。

python 复制代码
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage, AIMessage


# ==========================================
# 1. 定义 Schema 图纸
# ==========================================
class AgentState(TypedDict):
    # status 没有 Annotated,采用默认的【Last-Write-Wins 覆盖机制】
    status: str

    # messages 绑了 add_messages,采用【Reducer 智能追加/合并机制】
    messages: Annotated[list, add_messages]


# ==========================================
# 2. 定义机械臂 (Nodes)
# Python 语法糖:Node 就是普通的函数,入参固定为 state 对象
# ==========================================
def node_1_receive_user_input(state: AgentState):
    print(f"[Node 1] 当前收到状态: {state}")

    # 模拟收到用户输入。注意:我们【只返回】需要变动的增量字典!
    # 底层会自动把 status 覆盖,把 msg_1 追加进 messages 列表
    msg_1 = HumanMessage(content="你好,我是 Java 程序员!", id="msg_001")
    return {"status": "processing", "messages": [msg_1]}


def node_2_agent_reply(state: AgentState):
    print(f"[Node 2] 当前收到状态: {state}")

    # 模拟 AI 回复。再次注意:不需要把原来的 msg_1 也带上!
    # 底层 add_messages 发现 id 不同,会自动做增量追加。
    msg_2 = AIMessage(content="你好!欢迎学习 LangGraph!", id="msg_002")
    return {"status": "completed", "messages": [msg_2]}


# ==========================================
# 3. 组装流水线 (Graph)
# ==========================================
builder = StateGraph(AgentState)
builder.add_node("node_1", node_1_receive_user_input)
builder.add_node("node_2", node_2_agent_reply)

# 画线:START -> node_1 -> node_2 -> END
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

# 编译成可执行的引擎
graph = builder.compile()

# ==========================================
# 4. 运行并观察魔法!
# ==========================================
# 塞入一个空托盘启动
initial_state = {"status": "init", "messages": []}
final_state = graph.invoke(initial_state)

print("\n[最终结果]")
print(f"Status: {final_state['status']}")
# 输出结果一定是 completed (被 Node 2 覆盖了 Node 1 的 processing)

print(f"Messages 数量: {len(final_state['messages'])}")
# 输出数量一定是 2 (Node 1 的输入 + Node 2 的回复,被完美合并了)

for i, msg in enumerate(final_state['messages']):
    print(f"  [{i}] {type(msg).__name__}: {msg.content}")

运行结果

text 复制代码
[Node 1] 当前收到状态: {'status': 'init', 'messages': []}
[Node 2] 当前收到状态: {'status': 'processing', 'messages': [HumanMessage(content='你好,我是 Java 程序员!', additional_kwargs={}, response_metadata={}, id='msg_001')]}

[最终结果]
Status: completed
Messages 数量: 2
  [0] HumanMessage: 你好,我是 Java 程序员!
  [1] AIMessage: 你好!欢迎学习 LangGraph!

进程已结束,退出代码为 0

代码解析:

  • 首先AgentState就是我们最开始说的Scheme,图纸,里面定义了可被覆盖的属性status和Reducer机制的message。
  • 之后定义了两个函数也就是Node,这只是定义,还没有执行,作用就是返回了对应的状态对象。
python 复制代码
builder = StateGraph(AgentState)
builder.add_node("node_1", node_1_receive_user_input)
builder.add_node("node_2", node_2_agent_reply)
  • 这一步是用来将Node放入,但是此时没有触发点,也没有执行顺序,相当于初始化。
python 复制代码
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)
  • 这一步开始画线了,因为Graph是有向图啊,所以这里规定了开始的方法是node1,结束的是node2,node1和node2的执行顺序是先1后2。
  • 之后就开始初始化和执行了,其中日志显示了message确实是追加的。

1.3 底层流转:Pregel 引擎的并发与事务魔法

如果说前面的 Schema 是"图纸",Reducer 是"合并说明书",那么 Pregel 引擎就是那个真正驱动流水线运转的"中央主板"

这有点类似于SpringMvc中的DispatcherServlet核心方法,自动转发请求和捕捉异常,底层虽说复杂,但经过研究设计的确实很漂亮。当然我们也可以看看Pregel是怎么执行的,现阶段我们只需要知道有这么个东西,让它在底层自己执行即可。

1. 为什么节点只需要返回"局部字典"?

在前面的代码里,我们一再强调:节点函数绝对不能直接修改传入的 state 里的值,必须 return 一个增量字典。为什么框架要设计得这么"别扭"?

(1)背后的痛点:状态溯源与"时光倒流"

在 AI Agent 开发中,经常会遇到运行到第 10 步时工具调用报错的情况。LangGraph 支持一个极其强大的功能:Time-travel(时光倒流)。它可以让你随时退回到第 8 步的快照(Snapshot),修改数据后重新跑。

  • 反面教材(直接修改引用) :如果 Node 1 直接执行 state["messages"].append(new_msg)。在 Java 中我们知道,这叫"修改对象引用里的可变成员"。由于大家指向的都是同一块内存地址,这时候你去翻看第 1 步的快照,会发现里面的数据也被篡改了!
  • 优雅解法(增量 Patch 机制) :让 Node 变成无状态的纯函数(Stateless Pure Function) 。Node 只负责生成一个补丁(Patch,也就是增量字典)。底层 Pregel 引擎拿到这个补丁后,会克隆------深拷贝出一份全新的 State 对象。

2. Pregel 引擎的并发魔法与安全防御

Pregel 是 Google 提出的大规模图计算模型,它的核心运作机制叫 BSP(Bulk Synchronous Parallel,整体同步并行)

(1)什么叫"整体同步并行 (BSP)"?

当流水线出现分支,需要同时运行多个节点时(比如 Agent 决定同时去查 Google 搜索和本地数据库),Pregel 引擎的执行步骤如下:

  1. 并行执行(并发度展开) :引擎会开启多个协程/线程,同时跑 Node A 和 Node B。这两个 Node 读到的是同一份旧状态的快照
  2. 等待栅栏(Barrier) :引擎会挂起等待,直到本轮的所有并发节点都执行完毕,并各自吐出了它们的"增量字典"。
  3. 统一合并(Super-step 结束):引擎把所有节点吐出的字典收集起来,集中进行一次 State 的合并操作。
(2)⚠️ 致命危机:并行节点的"多写冲突"

既然是并发写入,就必然面临多线程并发最经典的问题:写冲突(Race Condition)

场景假设

在 Schema 中,status 是一个普通的字符串(没有绑 Reducer,默认 Last-Write-Wins 覆盖)。

现在,Node A 和 Node B 并行运行:

  • Node A 执行完,吐出:{"status": "A_Done"}
  • Node B 执行完,吐出:{"status": "B_Done"}

引擎怎么处理?

在普通的 Java HashMap 并发下,谁后写完谁覆盖。但在 LangGraph 的 Pregel 引擎里,它非常严格!它发现同一个 Super-step 里,有两个并发节点试图修改同一个没有 Reducer 的字段,它不知道该听谁的,于是直接抛出 InvalidUpdateError 异常让程序崩溃!

报错信息类似:Can receive only one value per step. Use an Annotated key to handle multiple values.

(3)完美解药:用 Reducer 化解并发冲突

怎么解决上面的崩溃?答案又回到了我们在 1.2 节学的 Reducer

任何在架构设计上有可能被并行节点同时写入的字段 ,你必须 使用 Annotated 给它绑定一个 Reducer。

回到上面的场景,如果 Schema 是这样的:

messages: Annotated[list, add_messages]

  • Node A 吐出:{"messages": [Msg_A]}
  • Node B 吐出:{"messages": [Msg_B]}

当引擎集中合并时,它发现大家都在写 messages,没关系,因为 messages 绑了 Reducer!引擎就会乖乖调用 add_messages 逻辑,把 Msg_AMsg_B 按顺序安全地追加到全局列表中,完美化解了并发冲突!


1.4 ⚠️ 避坑指南:防止全局 Schema 被过度污染

当我们刚开始写 AI Agent 时,往往只有一个简单的机器人,全局挂一个 MessagesState 就足够了。但在企业级项目中,你可能要构建一个 Multi-Agent(多智能体)团队(例如:一个主控 Agent 带领一个专门搜集资料的 Research Agent,和一个专门写代码的 Code Agent)。

一旦出现多Agent协作,那传统的一个Scheme就显得不合适了,必须进行拆分,不然会变得臃肿不可控。


1. 常见的设计坏味道:上帝对象 (God Object) 与 Fat Session

(1)灾难现场还原

为了让 Research Agent 存取网页爬取的数据,你在全局 AgentState 里加了个字段 raw_html: str

为了让 Code Agent 记录编译报错,你又加了个字段 compile_error_logs: list

久而久之,你的全局 Schema 变成了这样:

python 复制代码
class FatAgentState(TypedDict):
    messages: Annotated[list, add_messages]
    raw_html: str
    parsed_json: dict
    compile_error_logs: list
    retry_count: int
    ... # 还有几十个各个 Agent 的私有变量
(2)Java 视角类比:把所有表合并成一张"大宽表"

这在 Java 架构中犯了两个大忌:

  1. 打破了"限界上下文(Bounded Context)" :就像你把订单系统的 Order 对象和物流系统的 Shipping 对象揉进了一个名为 GodDTO 的类里,满天飞的临时变量会让代码极其难以维护。
  2. "Fat Session"导致 IO 性能崩塌 :如果这个 Agent 需要做持久化(断点续传),底层每次状态更新都会把几兆的 raw_html 反复存入数据库。这就好比往 Spring 的 HttpSession 里塞了几个 GB 的大文件,服务器直接内存溢出(OOM)或 IO 瓶颈卡死。

2. 正确解法:引入 Subgraph (子图) 机制

在现代 LangGraph 架构中,解决这个问题的核心手段是:Subgraph(子图)

(1)核心思想:微服务化

你可以把每一个独立职责的 Agent(比如 Research Agent),单独封装成一张图(Subgraph)。

  • 私有状态(Local State) :这只子图拥有自己专属的 ResearchState,里面爱放 raw_html 还是 retry_count 随它便,外部看不见。这就像微服务的私有数据库
  • 对外契约:子图只通过明确的入参和出参和父图(Parent Graph)交互。它跑完之后,只会把"最终提炼的总结报告"提交给父图。

其实这里又会引入一个新的问题,如何验证子Agent的结果是否正确,这就需要强大的系统管控和验证思维。因为一旦某一个子节点出现问题,很有可能总结果就会发生偏差。所以不能拆的那么细,但又必须得拆开,平衡点需要精准拿捏。

(2)代码实战:父子图是如何连接的?

在 LangGraph 里,一个编译好的图,可以直接作为一个节点(Node),被插入到另一张图里!

我们来看一下极简的伪代码演示:

python 复制代码
from langgraph.graph import StateGraph, START, END
from typing import TypedDict

# ==========================================
# 1. 独立微服务:Research 子图及其私有图纸
# ==========================================
class ResearchState(TypedDict):
    search_query: str     # 入参
    raw_html: str         # 局部私有变量:一大坨无用网页代码
    summary: str          # 最终要返回的结果

def fetch_web_node(state: ResearchState):
    # 模拟爬取了几万字网页
    return {"raw_html": "<html>...几万字...</html>"}

def summarize_node(state: ResearchState):
    # 提炼出了100字的总结
    return {"summary": "经过搜索,最新消息是..."}

research_builder = StateGraph(ResearchState)
research_builder.add_node("fetch", fetch_web_node)
research_builder.add_node("summarize", summarize_node)
research_builder.add_edge(START, "fetch")
research_builder.add_edge("fetch", "summarize")
research_builder.add_edge("summarize", END)

# 【关键点】编译成一个可执行的子图组件
research_subgraph = research_builder.compile()

# ==========================================
# 2. 网关总控:Parent 父图
# ==========================================
class ParentState(TypedDict):
    user_task: str
    final_report: str

def format_output_node(state: ParentState):
    # 只拿总结,不脏手拿 html
    pass

parent_builder = StateGraph(ParentState)

# 【魔法时刻】直接把编译好的子图,当成一个普通的 Node 加进来!
# Python 语法糖:在 LangGraph 中,compiled graph 实现了 Runnable 接口,可以被直接作为普通函数/节点调用。
parent_builder.add_node("research_team", research_subgraph)
parent_builder.add_node("format_output", format_output_node)

parent_builder.add_edge(START, "research_team")
parent_builder.add_edge("research_team", "format_output")
parent_builder.add_edge("format_output", END)

parent_graph = parent_builder.compile()
(3)父子图的状态映射 (State Mapping)

由于父图的 Schema(ParentState)和子图的 Schema(ResearchState)长得不一样,它们在交接时,引擎怎么知道把哪个字段传给子图呢?

在最新的 LangGraph 中,我们通常会在调用子图前,或者在父图中通过特定的通道(Channels)进行字段的映射转化。这就像 Java 里在调用微服务前,用 MapStruct 把 ParentDTO 转换为 ChildRequestDTO 一样。


🌟 第一章 全面总结 (Checkpoint)

  1. Schema (TypedDict/Pydantic/MessagesState) :定义了微服务之间传递的 DTO 和长链接通信的 Session
  2. Reducer (Annotated) :提供了处理并发多写冲突和状态合并的 Map.merge() 策略。
  3. Pregel 引擎 :是一个带有依赖注入和并发栅栏的 Tomcat + DispatcherServlet 调度中心,严格要求节点只能像"事件溯源"一样抛出 增量字典 (Patch)
  4. Subgraph (子图) :防止 God Object 膨胀的微服务拆分利器。

二、 Nodes & Edges (图流转与条件路由)

📝 第一章我们设计了Agent的数据状态,这一章就开始组装流水线,和最开始的黑盒Agent不同的是,这种方式可控性极高,可以自定义Node和流转状态,每一步的控制都相当的到位。

2.1 节点与控制流 (Nodes & Normal Edges)

1. 节点的双重形态:同步与异步

在 Python 中,Node 就是一个普通的函数,它唯一的规矩就是:入参是 State,出参是 Dict

但是,由于网络请求(调用大模型 API)非常耗时,我们强烈建议把处理 I/O 的节点写成异步协程

思考,为什么要专门了解节点的双重形态,因为正常来说Agent为了提高效率都是异步执行的,尤其是各种搜索🔍,但是从正常的代码编辑思维来说,同步和异步一定是结合使用的。

比如在一整套流程中分为3步,第二步中需要查询大量资料,第三步是进行总结,那第二步Node就需要异步执行,而Node2和Node3必须同步执行,不然Node3是没有数据的。

2. 实战代码:构建一条包含异步节点的线性流水线

我们来看一段极简的代码,感受一下同步节点、异步节点以及 START/END 是怎么串联起来的。

python 复制代码
import asyncio # Python内置的异步I/O库
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

# ==========================================
# 1. 准备图纸 (沿用第一章知识)
# ==========================================
class SimpleState(TypedDict):
    input_text: str
    translated_text: str
    status: str

# ==========================================
# 2. 制造机器臂 (Nodes)
# ==========================================

# 【同步节点】:适合做纯内存计算(如格式化字符串、简单校验)
def sync_format_node(state: SimpleState):
    print("[Node 1] 正在执行同步格式化...")
    original_text = state.get("input_text", "")
    # 纯内存操作,瞬间完成,直接返回增量字典
    return {"status": "formatted", "input_text": original_text.strip()}

# 【异步节点】:适合做网络请求(调用 LLM、查数据库)
# Python 语法糖:加了 async 的函数叫"协程函数",里面可以使用 await 来非阻塞地等待 I/O 操作。
async def async_translate_node(state: SimpleState):
    print("[Node 2] 正在执行异步翻译请求...")
    # 模拟调用大模型 API 耗时 2 秒
    # Python 语法糖:await 相当于 Java 里的 CompletableFuture.join(),但它不会阻塞系统底层线程。
    await asyncio.sleep(2) 
    
    formatted_text = state.get("input_text", "")
    # 模拟翻译结果
    fake_translation = f"【已翻译】{formatted_text}"
    
    return {"status": "translated", "translated_text": fake_translation}

# ==========================================
# 3. 铺设死轨道 (Normal Edges)
# ==========================================
builder = StateGraph(SimpleState)

# 先把机器臂安装到工厂里
builder.add_node("format_worker", sync_format_node)
builder.add_node("translate_worker", async_translate_node)

# 划定写死的顺序流转路线 (Normal Edges)
# START -> format_worker -> translate_worker -> END
builder.add_edge(START, "format_worker")
builder.add_edge("format_worker", "translate_worker")
builder.add_edge("translate_worker", END)

# 编译引擎
graph = builder.compile()

# ==========================================
# 4. 运行测试 (由于图里有异步节点,我们需要用异步方式启动它)
# ==========================================
async def main():
    initial_state = {"input_text": "   Hello AI Agent!   "}
    # ainvoke 是 async invoke 的缩写
    final_state = await graph.ainvoke(initial_state)
    print("\n[最终结果]:", final_state)

# 启动 Python 的事件循环执行 main 函数 (类似 Java 的 main 线程启动)
asyncio.run(main())

3. 普通边的局限性

通过代码可以看到,无论用户怎么输入,都必须经过translate这个步骤,正常来说这没有问题,普通的ETL------批处理系统就这么干,但是在Agent中这么干是不是有点太Low了,智能的含义就在灵活啊,不能什么输入都走translate吧,要是人家输入正确呢?

那下一个问题就来了,我加入一些判断不就行了?

那问题又来了,这些判断你都要写一遍吗?这还要Agent干嘛呢,这不仅不够优雅,Token的消耗也是非常重要的。所以这就引出了非常重要的:条件路由


2.2 Conditional Edges (条件路由)

1. 为什么需要条件边?(动态路由)

在真实的 Agent 交互中,大模型经常面临分岔路口。

比如:用户问"今天东京天气如何?"

  • 大模型的决策:它发现自己不知道实时天气,必须调用搜索引擎。
  • 图的流转 :系统必须把控制权交给 Tool Node(工具节点)
  • 如果用户只是说"你好" :大模型不需要工具,系统必须直接走向 END
(1)Java 视角映射:排他网关与 Switch-Case
  • 在 Java 传统代码中,这相当于在两个方法调用之间插了一个 if-elseswitch-case
  • 在工作流引擎(如 Activiti / Camunda)中,这就是最经典的 Exclusive Gateway(排他网关 / XOR)。数据流到一个菱形节点,根据条件(Condition)只能选择一条岔路走。

2. 核心运转机制:Router 与 Path Map

在 LangGraph 中,配置一个条件边需要两个核心组件:

  1. Router 函数(路由判定器)
    • 它本身不是 图里的 Node,它不修改全局 State。
    • 它的唯一工作就是读取当前的 State,然后返回一个字符串标识(表示方向)。
    • 类比 Java :这就像 Java 8 的 Predicate 接口,或者路由网关里的断言逻辑。
  2. Path Map(路径映射表)
    • 这是一个 Python 的字典(Dict)。它把 Router 返回的字符串,精确映射到下一个真实的 Node 名字上。
    • 类比 Java :这等同于 switch (routerResult) { case "use_tool": return "tool_node"; ... }

3. 实战代码:写一个会思考的动态路由

下面这段代码模拟了最经典的 Agent 场景:"判断是否需要调用工具"。

python 复制代码
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END

# ==========================================
# 1. 定义 Schema
# ==========================================
class AgentState(TypedDict):
    input_text: str
    needs_tool: bool  # 模拟大模型的思考结果:是否需要工具
    result: str

# ==========================================
# 2. 定义机器臂 (Nodes)
# ==========================================
def agent_think_node(state: AgentState):
    print("\n[大模型节点] 正在思考用户的输入...")
    text = state.get("input_text", "")
    
    # 模拟大模型的意图识别逻辑
    if "天气" in text:
        print(" -> 发现需要查询实时数据,决定调用工具!")
        return {"needs_tool": True, "result": "等我查一下"}
    else:
        print(" -> 普通聊天,直接回复即可!")
        return {"needs_tool": False, "result": "你好呀,我是 AI!"}

def tool_node(state: AgentState):
    print("[工具节点] 正在调用天气 API...")
    return {"result": "今天天气晴朗,气温 25 度。"}

# ==========================================
# 3. ✨ 核心魔法:定义 Router 路由函数
# ==========================================
# Python 语法糖:Literal 是类型提示,表示这个函数只可能返回这两个字符串中的一个。
# 它能让 IDE 帮你做静态代码检查,防止拼写错误。
def should_use_tool(state: AgentState) -> Literal["go_to_tool", "go_to_end"]:
    # 路由函数只负责"读"状态,然后"指路"
    if state.get("needs_tool"):
        return "go_to_tool"
    else:
        return "go_to_end"

# ==========================================
# 4. 组装与编排
# ==========================================
builder = StateGraph(AgentState)

builder.add_node("agent_think", agent_think_node)
builder.add_node("tool_call", tool_node)

# 第一步:固定起点
builder.add_edge(START, "agent_think")

# 第二步:✨ 添加条件边 (Conditional Edge)
# 参数 1: 始发节点 (agent_think 执行完后触发路由)
# 参数 2: Router 函数 (由它来决定去哪)
# 参数 3: Path Map (将 Router 的返回值,映射到真实的 Node 名称或 END)
builder.add_conditional_edges(
    "agent_think",        
    should_use_tool,      
    {
        "go_to_tool": "tool_call",  # 如果 Router 返回 go_to_tool,就流向 tool_call 节点
        "go_to_end": END            # 如果 Router 返回 go_to_end,直接结束整张图
    }
)

# 第三步:工具调用完后,直接结束(或者你也可以连回 agent_think 让它总结)
builder.add_edge("tool_call", END)

graph = builder.compile()

# ==========================================
# 5. 运行测试:观察不同的路由走向
# ==========================================
print("========== 测试 1: 普通聊天 ==========")
graph.invoke({"input_text": "你好"})

print("\n========== 测试 2: 触发工具 ==========")
graph.invoke({"input_text": "今天东京的天气怎么样?"})

4. 理解路由机制的核心

  1. 解耦设计 :你会发现,agent_think_node 只负责计算出 needs_tool=True 并存入 State,它并不知道下一步去哪。而 Router 函数只负责读取 needs_tool 来决定去哪。这种"状态更新与路由控制强解耦"的设计,与 Java 里的领域事件驱动非常相似。
  2. Path Map 是契约 :在上述代码的映射表里:{"go_to_tool": "tool_call"},左边是 Router 函数自己定的"暗号",右边是图里真正注册的"Node 名字"。如果名字拼错了,或者漏写了分支,图在运行时就会抛出 ValueError(也就是我们学习地图 2.4 要讲的坑)。

5. 生产层次的架构思考:优雅的动态路由和工具调用

如果阅读了上述的代码,我们能发现几个直观的问题

  1. if-else爆炸 :在agent_think_node函数中,这是用来判断调用哪个工具的,如果真的在生产中,工具会很多,难不成一个一个写if-else?加一个工具就维护一下代码?
  2. 判断不够智能: 还是agent_think_node,都是分支条件来控制,加一个工具很死板,智能通过硬编码的形式判断,和流水线没什么区别了。
  3. 调用同样死板 :在add_conditional_edges中第三个参数,如下,调用哪个工具你也得写清楚吧,tool_call中同样需要很多判断,如果说需要调用多个工具呢,你得指向多个工具吧,即使封装了一个方法动态调用,同样也是很死板的。
python 复制代码
builder.add_conditional_edges(
    "agent_think",
    should_use_tool,
    {
        "go_to_tool": "tool_call",  # 如果 Router 返回 go_to_tool,就流向 tool_call 节点
        "go_to_end": END  # 如果 Router 返回 go_to_end,直接结束整张图
    }
)
(1)普通生产级解决方式:Function Calling & ToolNode

这是最常见的一种解决方案,函数调用和分发器,核心有两个

  • Function Calling: 给大模型足够多的工具说明和参数说明,都放在系统指令中。
python 复制代码
# 生产级的 node 里根本没有 if-else!
def agent_think_node(state):
    # 1. 把工具的 Schema 绑定到 LLM 上
    llm_with_tools = llm.bind_tools([weather_tool, search_tool, db_tool...])
    # 2. 直接把用户的对话丢给 LLM,它自己会决定怎么做
    response = llm_with_tools.invoke(state["messages"])
    # 3. 把大模型的原始回复(可能包含 tool_calls)追加到 State
    return {"messages": [response]}

其中每一个tool都有对应的说明书,由系统自动生产,大模型可以准确识别。

  • ToolNode: 工具分发器,通过反射机制,大模型返回对应的函数名称和参数,我们通过反射invoke调用函数即可,不需要那么多的判断。

以上就可以解决核心的问题,但是随着系统不断迭代,新的问题就会出现

  1. 指令繁重: 如果工具日渐增多,系统指令就会很臃肿,不好维护。
  2. 幻觉概率变大: 一旦太多工具,工具之间如果有相似的话,系统很容易判断错误,这样幻觉现象就会被放大。
  3. Token消耗: 工具越多指令越大,Token消耗也是一笔不小的开销,不要觉得几百个字没事,在百万千万级别用户下,这笔账就很大了。

之后我们就引出一个现在比较智能的方案,工具向量化动态挂载

(2)进阶方案:动态工具检索

这里使用两个核心技术:工具向量化动态挂载

  • 工具向量化:启动时,把这 1000 个工具的描述(Description)变成向量,存入向量数据库(Vector DB)
  • 动态挂载 (Dependency Injection):
    当用户问"帮我查一下杭州的机票"时。在进入 agent_think_node 之前,先加一个 Tool_Selector_Node。
    这个节点拿着用户的查询,去向量数据库里查出 Top 3 最相关的工具(比如 search_flight, book_ticket, weather)。
    然后,只把这 3 个工具的说明书组装起来给大模型。这就是真正的动态取用!

一般来说这个步骤会更加的宽松,就是将最接近的5个甚至七八个都给大模型,保证是比较全面的。

python 复制代码
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

# ==========================================
# 1. 纯粹的工具定义 (利用装饰器生成 Schema)
# Python 语法糖:@tool 装饰器会自动把这个函数的签名、文档字符串解析成供 LLM 读取的 JSON Schema
# ==========================================
@tool
def weather_tool(location: str):
    """根据给定的城市,查询实时天气。"""
    return f"{location}的天气是晴天,25度。"

@tool
def db_tool(user_id: str):
    """查询用户的基本信息。"""
    return f"用户{user_id}是尊贵的 VIP。"

# 假设这里有一个工具列表(实际可以从向量库动态加载)
tools_list = [weather_tool, db_tool]

# ==========================================
# 2. Schema 与 大模型初始化
# ==========================================
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]

# 实例化大模型,并【绑定工具说明书】
llm = ChatOpenAI(model="gpt-4o")
llm_with_tools = llm.bind_tools(tools_list)

# ==========================================
# 3. 定义机器臂 (现在变得极其轻量!)
# ==========================================
def agent_think_node(state: AgentState):
    # 完全依赖大模型的 Function Calling 能力
    response = llm_with_tools.invoke(state["messages"])
    # 直接返回大模型的输出,不管里面是普通文本还是工具调用指令
    return {"messages": [response]}

# 我们不再手写 tool_call 节点,直接使用官方的超级反射器 ToolNode
universal_tool_node = ToolNode(tools_list)

# ==========================================
# 4. 组装与编排 (完美的二叉树路由)
# ==========================================
builder = StateGraph(AgentState)

builder.add_node("agent", agent_think_node)
builder.add_node("tools", universal_tool_node) # 注册通用工具节点

builder.add_edge(START, "agent")

# ✨ 核心魔法:使用官方的 tools_condition 作为 Router
# 它的逻辑只有两句话:如果 message 里有 tool_calls,返回 "tools";否则返回 END。
builder.add_conditional_edges(
    "agent",
    tools_condition, 
)

# 工具执行完后,必须连回大模型,让大模型根据工具的结果写总结!
builder.add_edge("tools", "agent")

graph = builder.compile()

2.3 🛡️ 防御机制 (防死循环)

1. 灾难重现:无限死循环 (Infinite Loop)

假设你要大模型用 weather_tool 查天气,要求传入城市全拼(如 beijing)。

  1. 大模型(第一次):传入了中文 "北京"
  2. ToolNode 执行失败,抛出错误:Error: 需要英文拼写
  3. 错误信息被作为 ToolMessage 传回大模型。
  4. 大模型(第二次):脑子抽筋,传入了 "BeiJing"(包含大写)。
  5. ToolNode 再次失败:Error: 需要全小写
  6. 错误又被传回大模型...

如果大模型陷入了幻觉重试黑洞,这个图就会以每秒几次的速度疯狂转圈。如果不加防御,你的 OpenAI 余额会在几分钟内被抽干!

在 Java 中,这种递归循环会直接把内存撑爆并抛出 StackOverflowError。在 Agent 架构中,我们有两道防线来解决这个问题。


🛡️ 第一道防线:全局熔断器 (Recursion Limit)

这是 LangGraph 最底层的硬性防御机制。

(1)Java 视角映射:微服务熔断 (Circuit Breaker)

就像你在 Spring Cloud 中使用 Resilience4j 或者给 HTTP 请求设置 Timeout 一样。如果一个任务在指定的步数内无法完成,直接切断。

(2)如何配置?

在 LangGraph 中,一次完整的节点流转被称为一个 Super-step(超级步)。框架默认的递归步数上限是 25 步

可以在启动引擎时,手动调整这个"熔断阈值":

python 复制代码
# 启动图时,通过 config 注入 recursion_limit
config = {"recursion_limit": 10} # 限制最多只能走 10 步

# 如果大模型和工具来回拉扯超过 10 次,
# 引擎会直接抛出 Python 异常:GraphRecursionError
final_state = graph.invoke(initial_state, config=config)

⚠️ 第一道防线的痛点(为什么它不够好?):

直接抛出 GraphRecursionError 是极其粗暴的。

这就像直接拔服务器电源一样(Hard Crash )。一旦抛异常,之前几轮辛苦查询到的部分正确数据(比如已经查到了航班,只差查酒店就超时了)会全部丢失 ,并且直接给终端用户返回冷冰冰的 500 Server Error

而且在现在流行的循环任务,这种长任务需要调用的工具数量会很大,所以更加需要灵活的限制而非直接切断。


🛡️ 第二道防线:优雅降级感知 (RemainingSteps 机制)

为了解决"拔电源丢失数据"的问题,现代 LangGraph 引入了一个极具智慧的设计:

RemainingSteps(剩余步数感知)

LangGraph 提供了一个非常高级的"托管值(Managed Value)"特性。我们只需要在 Schema 里加一行代码,底层引擎就会自动在状态里挂载一个倒计时器

请看这段生产级防御代码:

python 复制代码
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
from langgraph.managed import RemainingSteps # 引入剩余步数管理器

# ==========================================
# 1. 定义带有"倒计时器"的 Schema
# ==========================================
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    
    # 🌟 核心魔法:注入托管值
    # 你不需要手动去减这个数字,Pregel 引擎每走一步,它会自动递减!
    remaining_steps: RemainingSteps 

# ==========================================
# 2. 定义具有"求生欲"的大模型节点
# ==========================================
def agent_think_node(state: AgentState):
    messages = state["messages"]
    steps_left = state["remaining_steps"]
    print(f"[监控] Agent 当前剩余存活步数: {steps_left}")
    
    # 🌟 优雅降级逻辑:如果在死循环边缘(快被强杀了)
    if steps_left <= 2:
        print("🚨 警告:即将熔断!强制要求大模型停止调用工具,直接总结!")
        
        # 我们可以临时向大模型塞入一条极其严厉的系统提示词
        emergency_prompt = {
            "role": "system",
            "content": "【最高优先级指令】你的操作步数即将耗尽!不要再尝试使用任何工具!"
                       "请根据目前已有的信息,立刻给出一个尽可能详细的最终回复!"
        }
        # 将提示词和历史消息一起喂给大模型
        response = llm_with_tools.invoke([emergency_prompt] + messages)
    else:
        # 正常情况:允许自由使用工具
        response = llm_with_tools.invoke(messages)
        
    return {"messages": [response]}

3. 防死循环的哲学

结合我们在 2.2 节学的架构,加上本节的防御机制,一个完美的生产级 Agent 处理流程是这样的:

  1. 自由探索期(Steps: 25 -> 3) :大模型自由地调用 ToolNode,报错了也没关系,它会自动看报错信息进行自愈(Self-Correction)。
  2. 强制收敛期(Steps: 2) :当陷入死循环,步数见底。我们通过 RemainingSteps 触发报警。临时切断它的"手脚(工具)",逼迫它利用现有的"记忆"生成最终答案。
  3. 熔断底线(Steps: 0) :如果大模型彻底失控,连最后通牒都不听,recursion_limit 将充当最后一道防线,抛出异常,防止资金损失。

4. 基于现在流行的长任务思考

现在的Agent发展越来越靠近长任务了,或者说循环任务,包括codex最新的/goal模式,能跑几个小时,这时候通过上面的总体步数来限制就显得太低级了,总不能设置99999吧。

一般我们会设置一个局部计数器,先控制单步骤的循环次数

python 复制代码
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    # 添加一个局部计数器
    search_tool_retry_count: int 

# 在你的 Router 函数中做局部限制:
def router_function(state: AgentState):
    # 如果这个特定的工具连续卡壳超过 3 次
    if state.get("search_tool_retry_count", 0) >= 3:
        # 强制转移给"人类接管节点"或者"错误恢复节点",而不是让全局引擎崩溃
        return "human_fallback_node" 
    
    if "tool_calls" in state["messages"][-1]:
        return "tools"
    return END

实际上现在工业的解决方式是之后我们要学习的几个,现在可以先了解一下

(1)引入断点持久化(Checkpointer,第四章将学)
  • 痛点:普通 Graph 是在内存里一口气跑到底的,就像一根绷紧的橡皮筋,时间越长越容易断(内存溢出、网络中断)。
  • 破局 :长任务必须分片运行。引入 Checkpointer(如 Postgres 数据库)后,Agent 每跑几步,状态就会落盘。你可以设计成:"每次被唤醒只跑 20 步,落盘保存,休眠,然后由外部定时任务(如 Java 的 Quartz)再次唤醒它续跑"。
(2)Map-Reduce 与 动态子图分发(Send API)
  • 痛点:让主控大模型自己去循环调 50 次搜索工具,主控循环的步数会爆炸,且上下文(Context Window)会被塞爆,大模型直接变傻。
  • 破局 :微服务架构中的 散列-聚集(Map-Reduce)
    主控 Agent 发现需要查 50 个词,它不自己循环 。它利用 LangGraph 提供的 Send 机制,瞬间动态孵化出 50 个小 Subgraph(子图)。这 50 个子图各自拥有独立的步数限制,并行去查。查完后统一汇集给主图。这样主图的步数永远只有短短的几步!
(3)人机协同(Human-in-the-Loop,第五章将学)

对于极长且不可控的任务,任由它狂奔是非常危险的。

现代 Agent 架构强制要求在特定的卡点(比如执行了 50 步,或者准备执行转账等高危工具时)进入 Pending(挂起)状态。发一条企业微信通知人类,人类看一眼草稿点击"Approve(批准)"后,它再继续往下跑。


2.4 ⚠️ 避坑指南:条件边的运行时 ValueError

1. 灾难现场:潜伏在运行时的 ValueError

在 2.2 节中,我们使用了类似这样的代码来配置动态路由:

python 复制代码
builder.add_conditional_edges(
    "agent_think",        
    should_use_tool,      
    {
        "go_to_tool": "tool_call",  
        "go_to_end": END            
    }
)
(1)为什么会爆炸?(悬空路由)

假设你的大模型在某次意图识别中,触发了一个极其罕见的 Edge Case(边缘场景)。

你的 should_use_tool(Router 函数)因为某些逻辑漏洞,突然返回了一个字符串 "ask_human"

但是,你在第三个参数 path_map(映射表字典)里,压根没有定义 "ask_human" 对应的下家节点!

此时,底层引擎 Pregel 拿着 "ask_human" 这个,去字典里找下一步的轨道,结果发现是 null。引擎会立刻崩溃,并抛出:

ValueError: Branch "ask_human" not found in path map...

由于 Python 默认是动态弱类型语言 ,这种"漏写分支"的错误在编译期(写代码时)是不会报错的!它就像一颗定时炸弹,只有在程序跑到那个特定分支时才会炸。这对于生产级 Agent 是不可接受的。


2. 破局之道:如何彻底消灭路由配置 Bug?

为了在 Python 里找回 Java 般严谨的安全感,我们有两大绝招。

(1)防线一:使用 Literal 限制返回值 (类型层面的 Enum)

永远不要让你的 Router 函数返回宽泛的 str 类型。利用 Python 3.8+ 引入的 Literal 语法糖!

  • Java 类比 :这相当于用 Enum(枚举)替代 String 作为入参和出参。
python 复制代码
from typing import Literal

# 强制约束:这个函数【只能】返回这两个字符串之一,敢返回别的 IDE 直接标红!
def should_use_tool(state: AgentState) -> Literal["go_to_tool", "go_to_end"]:
    if state["needs_tool"]:
        return "go_to_tool"
    
    # 假设你不小心写错了拼写,写成了 return "goto_end"
    # Pylance/MyPy 等类型检查工具会在你写代码时就画上红色波浪线!
    return "go_to_end" 
(2)防线二:省略 path_map 的"隐式路由"(高级技巧)

在最新的 LangGraph 版本中,如果你嫌维护那个字典映射表太麻烦,框架提供了一种约定大于配置 (Convention over Configuration) 的极简写法:

如果不传 path_map 字典,那么 Router 函数必须直接返回目标节点的真实名称!

python 复制代码
def dynamic_router(state: AgentState) -> Literal["tool_call", "__end__"]:
    # 直接返回图里注册的 Node 名字("__end__" 是 END 的字符串表达)
    if state["needs_tool"]:
        return "tool_call" 
    return "__end__"

# 配置条件边时,不再传第三个参数的字典了!
# 引擎会自动把 router 返回的字符串当成 node name 去寻找。
builder.add_conditional_edges("agent_think", dynamic_router)
  • 优势 :减少了中间映射的复杂度,就像 Java 中依靠反射直接通过 Bean Name 获取实例一样,只要 Literal 约束得当,就能极大减少 ValueError 的发生。

🌟 第二章 全面总结 (Checkpoint)

  1. Nodes (机器臂) :可以是普通函数(同步计算)或 async 协程函数(并发网络请求)。
  2. Normal Edges (死轨道) :硬编码的先后顺序,A -> B -> END
  3. Conditional Edges (智能道岔) :依赖 Router (拦截判断器)Path Map (映射表),实现了大模型真正的自治(Function Calling -> ToolNode -> 回到模型)。
  4. 防御机制 (保险丝)
    • 底层防爆器:Recursion Limit
    • 优雅降级感知:RemainingSteps
  5. 类型安全 (路由校验) :利用 Literal 枚举替代裸字符串,防范运行时的 ValueError

接下来,我们将进入 第三章:Harness 容错拦截 (增强系统鲁棒性) 。这章就是 Agent 架构里的 "Spring AOP / 全局异常处理器"。我们将学习如何让 Agent 在犯错后,能够"知错、认错、并自己修错"!


三、 Harness 容错拦截 (增强系统鲁棒性)

3.1 实战:配置网络层面的 Spring Retry

在 Java 中,为了防止由于偶尔的网络抖动(如 HTTP 502)导致线程挂掉,我们会在方法上打一个 @Retryable 注解。

在 LangGraph 中,我们的核心原则也是:绝不在 Node 内部写丑陋的 while True: try...except 循环。我们要把重试逻辑上交给底层的 Pregel 引擎去统筹。

1. 核心对象:RetryPolicy

LangGraph 提供了一个内置类 RetryPolicy,它完美实现了指数退避(Exponential Backoff)随机抖动(Jitter)

2. 代码实战

重点关注 builder.add_node 时的 retry 参数配置:

python 复制代码
import random
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy
import httpx  # Python 常用的 HTTP 客户端库


# 1. 准备图纸
class AgentState(TypedDict):
    query: str
    result: str


# 2. 定义一个非常脆弱的网络调用 Node
def flaky_network_node(state: AgentState):
    print("\n[网络节点] 正在尝试调用外部 API...")

    # 模拟 70% 的概率发生 502 网关错误或 429 限流
    if random.random() < 0.7:
        print("  -> 💥 致命一击:遭遇 HTTP 502 Bad Gateway!")
        # 抛出具体的网络异常
        raise httpx.HTTPStatusError("502 Bad Gateway", request=None, response=None)

    # 模拟 10% 的概率发生 400 参数错误(业务错误,不该重试)
    if random.random() < 0.1:
        print("  -> ❌ 业务报错:HTTP 400 Bad Request!")
        raise ValueError("参数格式错误,API 拒绝访问")

    print("  -> ✅ 调用成功!")
    return {"result": "API 返回的数据"}


# 3. 组装流水线并配置重试策略
builder = StateGraph(AgentState)

# 🌟 核心魔法:配置 RetryPolicy
custom_retry_policy = RetryPolicy(
    # 最大尝试次数(包含第一次正常执行)
    max_attempts=4,
    # 初始延迟时间:1 秒
    initial_interval=1.0,
    # 指数退避乘数:每次失败等待时间翻倍 (1s -> 2s -> 4s)
    backoff_factor=2.0,
    # 最大延迟上限:防止指数爆炸导致等待时间过长
    max_interval=10.0,
    # 开启随机抖动:防止分布式系统中的"惊群效应(Thundering Herd)"
    jitter=True,
    # 粒度控制:只拦截指定的 Exception 类。如果不写,默认捕获所有 Exception。
    # 这里我们只重试 HTTP 错误,遇到 ValueError(如400参数错) 直接让节点挂掉。
    retry_on=(httpx.HTTPError,)
)

# 在把 Node 挂载到图上时,以外挂中间件的形式注入重试策略!
builder.add_node("flaky_node", flaky_network_node, retry=custom_retry_policy)

builder.add_edge(START, "flaky_node")
builder.add_edge("flaky_node", END)
graph = builder.compile()

# 4. 运行测试
print("========== 开始测试网络容错 ==========")
try:
    final_state = graph.invoke({"query": "查询股票"})
    print("\n[最终结果]:", final_state)
except ValueError as e:
    print(f"\n[整图崩溃]: 遇到了不可重试的业务异常 -> {e}")
except Exception as e:
    print(f"\n[整图崩溃]: 超过最大重试次数,彻底失败 -> {e}")

运行结果:

text 复制代码
========== 开始测试网络容错 ==========

[网络节点] 正在尝试调用外部 API...
  -> 💥 致命一击:遭遇 HTTP 502 Bad Gateway!

[网络节点] 正在尝试调用外部 API...
  -> 💥 致命一击:遭遇 HTTP 502 Bad Gateway!

[网络节点] 正在尝试调用外部 API...
  -> 💥 致命一击:遭遇 HTTP 502 Bad Gateway!

[网络节点] 正在尝试调用外部 API...
  -> ✅ 调用成功!

[最终结果]: {'query': '查询股票', 'result': 'API 返回的数据'}

3. 为什么必须要加 jitter=True

假设你的服务突然发生了一次短暂的数据库断开,你有 1000 个并发的 Agent 线程同时报错。

  • 如果不加 Jitter:这 1000 个线程都会在绝对的 1 秒后同时发起重试,瞬间产生一个巨大的流量洪峰,把刚刚恢复的数据库再次压垮。
  • 加了 Jitter:系统会在 1 秒的基础上加减一个随机数(比如 0.8s ~ 1.2s)。这样 1000 个请求的重试时间就被打散了,极大保护了下游系统。

3.2 大模型逻辑自愈 (Self-Correction & Loop Guard)

1. 把 Exception 变成 Prompt(提示词)

  • (1)思维转变:不要轻易抛出异常
    • 传统强类型程序中,当下游系统返回字段缺失的 JSON 时,通常会直接抛出 ParseException。但在 Agent 架构中,"大模型既是问题的制造者,也是问题的解决者"。
  • (2)反馈闭环 (Feedback Loop)
    • 在 Node 中校验大模型输出格式错误(例如 Schema 校验失败)时,不直接抛出代码异常中断图,而是将具体报错信息(Error Feedback)包装成一条新的上下文消息传回给大模型,并利用条件边(Conditional Edges)路由回大模型节点。大模型读取到自己的错误上下文后,有较大概率自主修正输出。
(1)Java 视角映射:全局异常处理器 @ControllerAdvice

在写 Spring Boot 接口时,如果前端传来的 JSON 不满足 @NotNull@Min 校验,Spring 会抛出 MethodArgumentNotValidException

我们在 @ExceptionHandler 里拦截它,然后返回一个友好的 JSON:{"code": 400, "msg": "age 字段必须大于 0"}。前端看到这个反馈后,就会修改参数重新提交。

在 AI Agent 中,大模型就是那个"前端"!

当我们校验大模型生成的输出报错时,千万不要让图直接崩溃 。我们要捕获异常,把错误信息变成一条 HumanMessage 塞进历史记录里,然后把流转导回大模型节点


2. 实战代码:构建"带防死循环"的自愈闭环

在这个实战中,我们要求大模型输出一个严格的 JSON 格式 (包含 nameage)。

提前在目录中增加env文件,同时安装对应的依赖

python 复制代码
# ==============================================================================
# LangGraph & AI Agent 核心执行引擎
# ==============================================================================
langgraph>=1.2.4,<2.0.0

# ==============================================================================
# 运行时数据校验与 DTO 序列化工具 (Pydantic V2 系列)
# ==============================================================================
pydantic>=2.10.0,<3.0.0

# ==============================================================================
# LangChain 基础接口与消息领域模型(更新版本区间以兼容 LangGraph 1.2.4+)
# ==============================================================================
langchain-core>=1.4.0,<2.0.0
langchain-openai>=0.1.0  # 借助它连接 DeepSeek 的 OpenAI 兼容接口
python-dotenv
python 复制代码
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.output_parsers import PydanticOutputParser
from langchain_classic.output_parsers import OutputFixingParser
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from dotenv import load_dotenv, find_dotenv

_ = load_dotenv(find_dotenv(), override=True)

# ==========================================
# 1. 严格业务 DTO
# ==========================================
from pydantic import BaseModel, Field, field_validator, ValidationInfo


class UserProfile(BaseModel):
    # 🌟 1. 事前防御:在 Schema 层面直接写入业务规则(相当于 Swagger 接口文档注释)
    name: str = Field(description="用户的姓名。如果上下文中找不到具体人名,绝对不要瞎编,请严格输出 '未知'")
    age: int = Field(description="用户的年龄。如果上下文中找不到具体年龄,绝对不要瞎编,请严格输出 -1")

    # 🌟 2. 故意制造业务拦截(为了让你看到重试闭环)
    @field_validator('name')
    @classmethod
    def mock_db_check(cls, v: str):
        # 如果大模型乖乖听话输出了兜底值,放行
        if v == "未知":
            raise ValueError(
                f"风控系统拦截:名字 '{v}' 是测试黑名单用户!"
                f"请你换一个更加正式的名字重新输出(比如 '王建国')。"
            )

        # 模拟业务侧的黑名单校验拦截!
        if v in ["小明", "张三", "李四"]:
            raise ValueError(
                f"风控系统拦截:名字 '{v}' 是测试黑名单用户!"
            )
        return v


# ==========================================
# 2. Schema: 定义带局部计数器的上下文
# ==========================================
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    retry_count: int
    parsed_result: dict | None


# ==========================================
# 3. DeepSeek 模型与两级 Parser 初始化
# ==========================================
# 🌟 使用 DeepSeek v4Flash,配置其专有的 JSON 输出约束
deepseek_llm = ChatOpenAI(
    model="deepseek-v4-flash",  # 实际模型名视 API 平台而定,此处代指 DeepSeek V4 Flash
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",  # DeepSeek 官方 API 网关
    max_retries=2,
    temperature=0,
    # 🌟 开启 DeepSeek 原生的 JSON Mode 约束!
    model_kwargs={"response_format": {"type": "json_object"}}
)

# 基础解析器:负责将 LLM 输出转换为 UserProfile
base_parser = PydanticOutputParser(pydantic_object=UserProfile)

# 🌟 高级解析器(第一道防线):自带 LLM 修复功能的 Parser!
# 如果 base_parser 解析失败(例如缺了逗号),它会在内部再次调用 deepseek_llm 去专门修复那段破损的 JSON
fixing_parser = OutputFixingParser.from_llm(
    parser=base_parser,
    llm=deepseek_llm
)


# ==========================================
# 4. 定义机器臂 (Nodes)
# ==========================================
def llm_generate_node(state: AgentState):
    print(f"\n[DeepSeek 节点] 正在思考生成... (当前图级重试次数: {state.get('retry_count', 0)})")

    # 获取 Pydantic 自动生成的极其详细的格式说明
    format_instructions = base_parser.get_format_instructions()

    # 我们不仅开启了 JSON mode,还在系统提示词中强制注入 Schema 说明
    system_prompt = SystemMessage(
        content=f"你是一个严格的数据提取助手。你必须输出 JSON 格式。根据用户的输入进行提取,如果没有提取到则不能胡编乱造\n{format_instructions}"
    )

    # 组装消息并调用大模型
    messages_to_send = [system_prompt] + state["messages"]
    response = deepseek_llm.invoke(messages_to_send)
    print(response)
    return {"messages": [response]}


def validation_node(state: AgentState):
    print("[校验节点] 正在拦截并使用 OutputFixingParser 校验...")
    last_message = state["messages"][-1].content
    current_retry = state.get("retry_count", 0)

    try:
        # 🌟 见证奇迹:调用 fixing_parser。
        # 如果 JSON 破损,它会【同步阻塞地】请求一次 DeepSeek 去修补它。
        # 对 LangGraph 的控制流而言,这完全发生在当前 Node 内部!
        validated_data: UserProfile = fixing_parser.parse(last_message)

        print("  -> ✅ 校验通过!(无论是直接通过还是 FixingParser 修复后的结果)")
        return {
            "parsed_result": validated_data.model_dump(),
            "retry_count": current_retry
        }

    except Exception as e:
        # 💥 触发图级防线(第二道防线)
        # 如果 FixingParser 也无能为力(比如原始文本完全不包含 name 和 age,没法修)
        print(f"  -> ❌ 致命级失败,FixingParser 宣告放弃:{str(e)}")

        error_feedback = (
            f"你的上一次输出不仅格式不对,连我内置的自动修复器都无法修复它。\n"
            f"报错原因:{str(e)}\n"
            f"请仔细阅读历史对话,并严格输出合规的 JSON 数据!"
        )
        return {
            "messages": [HumanMessage(content=error_feedback)],
            "retry_count": current_retry + 1
        }


# ==========================================
# 5. 组装流水线
# ==========================================
def loop_guard_router(state: AgentState) -> Literal["llm_node", "__end__"]:
    if state.get("parsed_result"):
        return "__end__"
    if state.get("retry_count", 0) >= 3:
        print("\n🚨 触发系统级熔断:重试 3 次均失败!")
        return "__end__"
    return "llm_node"


builder = StateGraph(AgentState)
builder.add_node("llm_node", llm_generate_node)
builder.add_node("validator", validation_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "validator")
# 省略映射字典,直接约定路由返回值匹配 Node 名称
builder.add_conditional_edges("validator", loop_guard_router)
graph = builder.compile()
final_state=graph.invoke({'message': '哈哈哈哈哈哈哈哈'})
print("\n[揭晓真相] 最终提取结果:", final_state.get('parsed_result'))

运行结果:

text 复制代码
[DeepSeek 节点] 正在思考生成... (当前图级重试次数: 0)
content='{"name": "未知", "age": -1}' additional_kwargs={'parsed': None, 'refusal': None} response_metadata={'token_usage': {'completion_tokens': 57, 'prompt_tokens': 270, 'total_tokens': 327, 'completion_tokens_details': {'accepted_prediction_tokens': None, 'audio_tokens': None, 'reasoning_tokens': 44, 'rejected_prediction_tokens': None}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 256}, 'prompt_cache_hit_tokens': 256, 'prompt_cache_miss_tokens': 14}, 'model_provider': 'openai', 'model_name': 'deepseek-v4-flash', 'system_fingerprint': 'fp_8b330d02d0_prod0820_fp8_kvcache_20260402', 'id': '33684e21-d229-4676-b2ad-0fdf3c7dc95a', 'finish_reason': 'stop', 'logprobs': None} id='lc_run--019ec56e-01b0-7721-b281-c91af76f2d09-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 270, 'output_tokens': 57, 'total_tokens': 327, 'input_token_details': {'cache_read': 256}, 'output_token_details': {'reasoning': 44}}
[校验节点] 正在拦截并使用 OutputFixingParser 校验...
  -> ✅ 校验通过!(无论是直接通过还是 FixingParser 修复后的结果)

[揭晓真相] 最终提取结果: {'name': '王建国', 'age': -1}

3. 解析

在这个例子中,我们可以清晰地看到与 3.1 节盲目重试(RetryPolicy)的区别:

  1. 增量上下文 (Context Accumulation)
    每次校验失败,大模型收到的不仅仅是原来的提问,而是 [原问题] -> [它的错误回答] -> [系统给它的报错信息]。它具备了短期记忆,知道自己上一次哪里错了,下一次就会刻意避开。
  2. 职责分离 (Separation of Concerns)
    在很多劣质的 Agent 代码中,开发者会在 llm.invoke 下面直接写个 while 循环去解析 JSON。这完全破坏了状态机的设计。我们通过拆分出独立的 validation_node,不仅让代码像微服务一样解耦,还让整个重试过程在 LangGraph 的监控轨迹(Trace)上清晰可见(你能看到状态在两个节点之间来回弹跳)。
  3. Loop Guard (局部熔断器)
    我们利用自定义的 retry_countrouter 中进行拦截。这比等到底层抛出 RecursionError 崩溃要优雅得多,它允许系统安全地执行降级逻辑(Fallback)。
OutputFixingParser是否需要加入?

虽然理论上我们是通过自己实现的修复,但是之前学习过OutputFixingParser自动修复,在这里要不要使用呢?

因为看结果没有用到自我修复,都是OutputFixingParser 自己搁那发起了一起静默请求然后修复了,这个要看你怎么选择了。

一般来说不会使用OutputFixingParser,因为它是黑盒,你不知道重试了多少次,你也控制不了,企业级更加喜欢自己实现,因为底层都是一样的,自己实现还能看到细节和错误。


3.3 Agent Harness 中间件层 (代理脚手架)

在前面的代码中,我们把日志打印、重试计数器判断等逻辑全都写在了 Node 函数内部。这在跑 Demo 时没问题,但在生产级企业项目中,这叫非核心业务逻辑污染

从程序设计原则来说,有一条至关重要,单一职责。这是一个程序必须具备的特性,如果说在业务逻辑中出现了大量的校验、监控等这是非常不好的,而在Agent的执行过程中监控和校验的逻辑要比往常多很多,所以就更加的重要了。


1. 架构痛点:为什么需要 Harness (中间件)?

假设你要上线一个金融客服 Agent。安全部门要求:

  1. 脱敏拦截:进入大模型前,必须把用户的手机号、身份证号(PII)打码。
  2. 耗时监控:记录每个 Node 的执行耗时(Metrics),上报到监控大盘。
  3. 租户隔离 :在多租户(SaaS)架构下,获取当前的 tenant_id 进行鉴权。
  • ❌ 错误做法 :在每一个 agent_node, tool_node, validation_node 里面都复制粘贴一遍脱敏和耗时统计的代码。这会导致真正的业务逻辑(仅仅几行代码)被大量安全代码淹没。
  • ✅ Java 视角解法:Spring AOP / HandlerInterceptor
    你会写一个 @Aspect(切面),在 @Before 拦截入参脱敏,在 @After 统计耗时;对于租户 ID,你会放在 ThreadLocal 或者 MDC 里隐式传递。
  • ✅ LangGraph 视角解法:Harness Wrapper(节点包装器)与 RunnableConfig

2. Python 语法糖补充:高阶函数与装饰器

要在 Python 中实现 AOP,我们通常使用高阶函数(Higher-order Function) ,也就是我们常说的装饰器(Decorator)

  • 什么是高阶函数? 简单来说,就是一个函数 A 接收另一个函数 B 作为参数,然后在内部包装一下,返回一个强化版的函数 C。
  • 在 LangGraph 中,我们在把 Node 挂载到图上(builder.add_node)之前,可以用这个包装器把它"包"起来。

3. 实战代码:打造一个纯粹的 AOP 切面拦截器

下面这段代码,我们将手写一个 pii_and_log_harness(脱敏与日志脚手架),并演示如何像使用 Java ThreadLocal 一样使用 LangGraph 的 RunnableConfig 隐式传递租户 ID。

python 复制代码
import time
from typing import Callable
from typing_extensions import TypedDict
from langchain_core.runnables.config import RunnableConfig
from langgraph.graph import StateGraph, START, END

# ==========================================
# 1. 定义极其精简的 Schema (没有脏字段)
# ==========================================
class AgentState(TypedDict):
    user_input: str
    result: str

# ==========================================
# 2. ✨ 核心魔法:AOP 中间件 (Harness Wrapper)
# Python 语法糖:这个函数接收一个老 Node,返回一个新 Node
# ==========================================
def pii_and_log_harness(func: Callable) -> Callable:
    
    # 框架的 Node 签名其实支持第二个可选参数:config (里面装的就是隐式上下文)
    def wrapper_node(state: AgentState, config: RunnableConfig):
        # ------------------ @Before (前置拦截) ------------------
        start_time = time.time()
        
        # 从隐式上下文中获取租户 ID (对应 Java 的 ThreadLocal / MDC)
        # config["configurable"] 是官方预留的用于跨节点传递全局参数的字典
        tenant_id = config.get("configurable", {}).get("tenant_id", "未知租户")
        print(f"\n[AOP-前置] 👤 租户 {tenant_id} 开始执行节点: [{func.__name__}]")
        
        # PII 脱敏:拦截手机号并打码
        original_input = state.get("user_input", "")
        if "13800138000" in original_input:
            safe_input = original_input.replace("13800138000", "138****8000")
            print(f"[AOP-前置] 🛡️ 触发脱敏!将敏感输入替换为: {safe_input}")
            # 临时修改传给真正业务 Node 的 state
            state["user_input"] = safe_input

        # ------------------ @Around (执行真实业务) ------------------
        # 调用被包装的原始业务函数
        result_dict = func(state)

        # ------------------ @After (后置拦截) ------------------
        cost_time = time.time() - start_time
        print(f"[AOP-后置] ⏱️ 节点 [{func.__name__}] 执行完毕,耗时 {cost_time:.4f} 秒")
        
        return result_dict
        
    return wrapper_node

# ==========================================
# 3. 业务 Node (现在变得极度纯粹,完全没有日志和脱敏逻辑!)
# ==========================================
def llm_business_node(state: AgentState):
    # 业务侧拿到的 input 已经是脱敏后的安全数据了!
    current_input = state["user_input"]
    print(f"   >>> 业务核心运行中:正在处理数据 '{current_input}'")
    time.sleep(0.5) # 模拟大模型耗时
    return {"result": f"已处理:{current_input}"}

# ==========================================
# 4. 组装与编排 (将 AOP 织入图)
# ==========================================
builder = StateGraph(AgentState)

# 🌟 依赖织入:不要直接传 llm_business_node,传被 harness 包装过的函数!
builder.add_node("llm_node", pii_and_log_harness(llm_business_node))

builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)

graph = builder.compile()

# ==========================================
# 5. 运行测试:注入隐式上下文
# ==========================================
print("========== 启动 AOP 中间件测试 ==========")
initial_state = {"user_input": "帮我查一下尾号 13800138000 的账单。"}

# 🌟 类似 Java 的 MDC.put(),我们将租户 ID 放在 configurable 里注入全局配置中
run_config = {"configurable": {"tenant_id": "T-9988-Alibaba"}}

# invoke 的第二个参数就是隐式配置,它会贯穿整个 Graph 的生命周期,不用写在 State 里!
final_state = graph.invoke(initial_state, config=run_config)

print("\n[最终图产出]:", final_state["result"])

运行结果:

text 复制代码
========== 启动 AOP 中间件测试 ==========

[AOP-前置] 👤 租户 T-9988-Alibaba 开始执行节点: [llm_business_node]
[AOP-前置] 🛡️ 触发脱敏!将敏感输入替换为: 帮我查一下尾号 138****8000 的账单。
   >>> 业务核心运行中:正在处理数据 '帮我查一下尾号 138****8000 的账单。'
[AOP-后置] ⏱️ 节点 [llm_business_node] 执行完毕,耗时 0.5003 秒

[最终图产出]: 已处理:帮我查一下尾号 138****8000 的账单。

思考:

在之前我们说过一个点,如果业务场景中就需要大模型输出对应的隐私信息呢?又或者说工具的调用中就需要传递正确的身份证、电话等。

这种场景下涉及数据的加密解密,不是像上述一样简单的进行处理,而是通过对称加密的形式或者其它形式进行加密,传递给大模型,通过特殊的指令进行参数的组装。在输出的后置校验中进行判断,如果出现此类字符串则进行解密。


4. 两大数据通道的完美分工

通过这个实战,能清晰地看出目前 LangGraph 架构中存在两大数据通道,它们各司其职,互不干涉:

  1. 显式业务通道(State / DTO)

    • 就是定义的 AgentStatemessages, user_input 等)。
    • 它是给大模型看的,也是业务 Node 去读写、追加的核心内容。
    • 它会被 Checkpointer 持久化存入数据库(用于断点续传)。
  2. 隐式运维通道(RunnableConfig / ThreadLocal)

    • 就是 config 里的 configurable 字典(如 tenant_id, trace_id)。
    • 它是给框架引擎和 AOP 中间件看的,大模型对它一无所知。
    • 它通常不在数据库里作为业务快照保存,而是用于追踪、埋点和系统级资源隔离。

PII 打码Token 消耗记录TraceID 链路追踪 等逻辑全部抽离到 Harness (AOP) 中,并通过 RunnableConfig 传递上下文,这就完成了一个 玩具脚本向微服务企业级组件 的蜕变。


3.4 ⚠️ 避坑指南:Tool Node 的分类错误处理 (Handle Tool Errors)

在 Agent 的世界里,大模型本身是安全的(最多就是胡言乱语),但 Tool(工具)是将大模型与真实的物理世界(数据库、第三方 API、文件系统)连接的桥梁。桥一旦塌了,整个系统就会车毁人亡。


1. 灾难现场:致命的默认行为

假设大模型决定调用一个工具:query_database(sql: str)

大模型生成的参数是:{"sql": "SELECT * FROM users WHERE age = '十岁'"}

  • 默认结局 :工具执行时,MySQL 驱动抛出 java.sql.SQLException: Data truncation(在 Python 中是类似的 DB 异常)。
  • 图的崩溃 :底层的 Pregel 引擎遇到这种未经处理的运行时异常,会直接终止整个执行流(Run),抛出 Failure。
  • 为什么可怕?:因为大模型还在"苦苦等待"这个工具的返回结果!此时图直接崩了,用户看到的是 500 Server Error,大模型连自愈的机会都没有。

2. 破局之道:错误分类治理 (双轨制处理)

作为架构师,我们必须明白:不是所有的错误都能靠大模型自愈的! 必须对异常进行分类处理。

轨一:业务/输入级错误(大模型可自愈)
  • 特征:参数不合规、缺少必填项、SQL 语法错。
  • 对策 :捕获异常,将其封装成一条 ToolMessage(工具消息) 塞进上下文中,告诉大模型:"你传的参数让工具报错了,错误信息是 XXX,请你换个参数重试"。
  • Java 类比 :就像 Controller 层捕获了参数校验异常,统一返回 ApiResponse(code=400, msg="参数错误") 给前端(大模型),前端重新发起请求。
轨二:系统/底层级错误(大模型不可自愈)
  • 特征:数据库宕机、第三方 API 余额不足、网络光缆被挖断。
  • 对策 :这种错你反馈给大模型,它再重试 100 遍也没用。我们必须立刻进行服务降级(Fallback),返回兜底的静态数据,或者触发挂起(转交人工)。
  • Java 类比 :就像分布式架构中的 Hystrix / Resilience4j 断路器 。当检测到下游服务 500 时,直接熔断,走 fallbackMethod 返回缓存数据。

3. 实战代码:打造一个坚不可摧的 Tool 异常拦截器

下面这段代码,我们手写一个工具执行器。注意看我们是如何将异常"分类拦截",并且必须返回 ToolMessage 的。

(⚠️ 核心知识点:在 OpenAI 的规范中,如果大模型发起了 tool_calls,下一轮对话它必须 看到对应的 ToolMessage,否则 OpenAI API 会直接报错!)

python 复制代码
import json
from typing import Annotated
from typing_extensions import TypedDict
from langchain_core.messages import ToolMessage, AIMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

# ==========================================
# 1. Schema 定义
# ==========================================
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]

# ==========================================
# 2. 模拟一个危险的真实工具 (不加任何 try-catch)
# ==========================================
def query_database_tool(sql: str):
    print(f"   [底层工具] 正在执行 SQL: {sql}")
    
    # 模拟轨一:输入级错误 (大模型生成的 SQL 语法不对)
    if "十岁" in sql:
        raise ValueError("SQL语法错误:无法将 '十岁' 转换为 INT 类型!")
        
    # 模拟轨二:系统级错误 (数据库宕机)
    if "crash" in sql:
        raise ConnectionError("FATAL: 数据库连接超时,主库已宕机!")
        
    return "查询结果:用户张三的余额为 100 万。"

# ==========================================
# 3. 🌟 核心魔法:安全包裹工具调用的 ToolNode
# ==========================================
def safe_tool_executor_node(state: AgentState):
    print("\n[工具拦截器节点] 开始执行工具调用...")
    # 拿到大模型上一步发出的 tool_calls 请求
    last_ai_message: AIMessage = state["messages"][-1]
    
    # 存放我们执行完毕或报错后的消息
    tool_responses = []
    
    for tool_call in last_ai_message.tool_calls:
        tool_call_id = tool_call["id"]
        sql_arg = tool_call["args"].get("sql", "")
        
        try:
            # 执行危险的底层工具
            raw_result = query_database_tool(sql_arg)
            
            # ✅ 成功:将结果包装为正常的 ToolMessage
            tool_responses.append(
                ToolMessage(content=raw_result, tool_call_id=tool_call_id)
            )
            
        # 🌟 轨一:业务错误拦截 -> 包装给大模型自愈
        except ValueError as e:
            print(f"  -> ⚠️ 捕获到输入错误,已包装为反馈发给大模型: {str(e)}")
            # 【重要】必须指定 tool_call_id,并且可以使用 status="error" 标记
            tool_responses.append(
                ToolMessage(
                    content=f"工具执行失败!原因: {str(e)}\n请你检查参数后重新调用。",
                    tool_call_id=tool_call_id,
                    status="error" 
                )
            )
            
        # 🌟 轨二:系统错误拦截 -> 触发系统降级,不让大模型重试了!
        except ConnectionError as e:
            print(f"  -> 🚨 捕获到系统级灾难,触发降级熔断: {str(e)}")
            # 告诉大模型不要再试了,直接给用户道歉!
            fallback_msg = (
                "系统内部故障:数据库正在维护中。你不要再调用查询工具了,"
                "请直接用文字向用户道歉,并告知系统正在抢修。"
            )
            tool_responses.append(
                ToolMessage(content=fallback_msg, tool_call_id=tool_call_id)
            )
            
    # 返回工具结果,底层 Pregel 会触发 add_messages 合并到全局 state
    return {"messages": tool_responses}

# 伪代码:这里省略了大模型节点和路由逻辑
# builder.add_node("agent", agent_node)
# builder.add_node("safe_tools", safe_tool_executor_node)
# builder.add_conditional_edges("agent", router)
# builder.add_edge("safe_tools", "agent")

4. 为什么这种写法更加的稳定

在官方提供的快速入门(LangChain 的预置 ToolNode)中,虽然它也提供了 handle_tool_errors=True 的懒人参数,但它会把所有的异常(包括数据库宕机)全部当成普通文本丢给大模型

这在生产中会引发极其荒诞的现象:

  • 数据库挂了,大模型收到 Connection Timeout
  • 大模型以为是自己调用姿势不对,重新发起调用。
  • 再次收到 Timeout,再次重试...
  • 结果就是:一个已经挂掉的数据库,正在被你按量付费的 AI Agent 疯狂发请求进行 DDoS 攻击(俗称"反复鞭尸"),你的 Token 费用在狂烧!

我们通过自定义的 try-except 分类治理(轨一 / 轨二),完美做到了:

  1. 该让大模型修的(参数错误),它乖乖去修。
  2. 该让大模型闭嘴的(底层设施宕机),直接切断它的重试念想,引导它执行危机公关(向用户道歉)。

5. 生产级异常捕捉

上面的代码中只是简单的演示了DDOS应该怎么处理,简单的异常捕捉,可是在生产中绝对不会那么写,因为太臃肿,一般分以下的几种异常分发策略

  1. 业务逻辑错: 正常我们都会在业务中封装一种错误类型,这种错误是不会被全局异常处理器强行记录的(比如系统中可能存在异常信息统计),这时候就需要进行自愈。
  2. 网络抖动错: 这是非常常见也是必须要处理的,在最开始我们处理过,直接向上抛出然后架构自动重试。
  3. 系统错误: 一般是连接错误、宕机、余额不足等,根据特定情况进行处理。

那么在生产中这种逻辑肯定是在装饰器中进行的,Node不可能存在,可是难不成我们要一种错误一种错误类型捕捉吗?

正确答案是不会,下面的例子中演示了平常的写法,基本够用了,但是真正的稳定系统应该是写一个专门处理错误的函数,在里面通过code和错误类型动态的判断和处理,比如有时候断开也得重试吧。这就是经典的程序设计思想:解耦

python 复制代码
import httpx
from functools import wraps
from langchain_core.tools import tool
from langgraph.types import RetryPolicy
from langgraph.prebuilt import ToolNode

# ==========================================
# 1. 统一定义全局异常类 (类似 Java 的 BaseException)
# ==========================================
class BizException(Exception):
    def __init__(self, code: int, message: str):
        self.code = code
        self.message = message
        super().__init__(self.message)

class FatalSystemException(Exception):
    def __init__(self, code: int, message: str):
        self.code = code
        self.message = message
        super().__init__(self.message)

# ==========================================
# 2. ✨ 核心魔法:全局工具异常拦截器 (AOP 切面)
# ==========================================
def global_tool_exception_handler(func):
    """类似 Java 的 @ExceptionHandler 全局拦截器"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            print(f"  [AOP] 正在执行工具: {func.__name__}...")
            return func(*args, **kwargs)
            
        # 🌟 策略 1:业务错误 -> 返回文本,让大模型自愈
        except BizException as e:
            print(f"  [AOP-拦截] 业务级错误 ({e.code}): {e.message}")
            return f"参数校验失败!错误码[{e.code}]: {e.message}。请仔细检查参数后重试!"
            
        # 🌟 策略 2:网络错误 -> 直接上抛!交给底层的 RetryPolicy 自动重连!
        except httpx.NetworkError as e:
            print(f"  [AOP-上抛] 发现网络抖动: {str(e)},交给底层引擎重连...")
            raise e  # 关键点:网络异常不能转成文本,必须抛给框架
            
        # 🌟 策略 3:系统死穴 -> 返回降级指令,禁止大模型再试
        except FatalSystemException as e:
            print(f"  [AOP-拦截] 致命错误 ({e.code}): {e.message}")
            return f"系统底层发生致命故障[{e.code}],请立刻停止调用工具,直接向用户致歉并告知系统维护中。"
            
        # 兜底拦截
        except Exception as e:
            print(f"  [AOP-拦截] 未知崩溃: {str(e)}")
            return "发生未知崩溃,请停止操作。"
            
    return wrapper

# ==========================================
# 3. 业务工具开发 (极度干净,只需打上注解!)
# ==========================================

@tool
@global_tool_exception_handler  # 挂上我们的全局拦截切面!
def query_user_balance(user_id: str) -> str:
    """查询用户的账户余额"""
    
    # 模拟 1: 大模型瞎传参数
    if "admin" in user_id:
        raise BizException(4001, "非法越权:禁止查询管理员账户")
        
    # 模拟 2: 第三方 API 突然抖动断开
    if "network" in user_id:
        raise httpx.ConnectTimeout("API 网关连接超时")
        
    # 模拟 3: 数据库核心主库宕机
    if "crash" in user_id:
        raise FatalSystemException(5000, "MySQL 主库无法连接")
        
    return f"用户 {user_id} 余额为 100.00 元"

# ==========================================
# 4. 组装官方 ToolNode 并配置自动重试 (RetryPolicy)
# ==========================================
tools_list = [query_user_balance]
universal_tool_node = ToolNode(tools_list)

# 🌟 绝妙配合:给 ToolNode 挂上只针对网络异常的重试策略!
# 这样,切面上抛的 httpx.NetworkError 就会被这里接住,自动重试 3 次!
network_retry_policy = RetryPolicy(
    max_attempts=3,
    initial_interval=1.0,
    backoff_factor=2.0,
    retry_on=(httpx.NetworkError,) # 只重试网络异常
)

# 伪代码图配置:
# builder.add_node("tools", universal_tool_node, retry=network_retry_policy)

🌟 第三章 全面总结 (Checkpoint)

  1. 网络层自愈 (RetryPolicy):用指数退避和 Jitter 屏蔽 502/429 物理抖动。
  2. 逻辑层自愈 (Feedback Loop):用 Pydantic 配合 Graph Routing,让大模型"看报错日志自己改 Bug"。
  3. 架构层解耦 (Harness AOP) :用高阶函数和 RunnableConfig 抽离脱敏与监控代码。
  4. 工具层分类 (Tool Error Handling):用双轨制(输入级 vs 系统级)拦截工具崩溃,防止大模型陷入"盲目重试"的疯狂状态。

接下来就要考虑几个问题:

  1. 如果出现不可抗力因素程序中断怎么办?比如断网了、用户强行停止了、服务器宕机或者浏览器关闭等情况。
  2. 如果兜底策略失效了怎么办?直接报错吗?是否需要人工介入?

四、 Checkpointer 记忆持久化 (断点与短程恢复)

4.1 什么是 Checkpointer?(状态快照与持久化介质)

1. 核心运行机制

(1)事件溯源架构 (Event Sourcing)

LangGraph 底层的运行机制极度类似于银行的记账系统或 Java 中的 Event Sourcing 架构。

每当图的一个 Node 执行完毕,吐出了增量字典,Pregel 引擎合并出新的 State 后,Checkpointer 就会自动把这个最新版本的 State 进行序列化,存入底层存储介质中。这被称为一个 Checkpoint(快照)。

快照实际上就是一个二进制流,和git非常像,类似于版本管理,可以随时回退版本

(2)多环境存储介质切换
  • 开发调试 :使用自带的 MemorySaver。它基于内存字典(类似 Java 的 ConcurrentHashMap),进程一关就丢失,适合本地写代码时测试。
  • 单体部署 :使用 SqliteSaver。将快照存入本地 SQLite 数据库文件(类似 Java 中的 H2 嵌入式数据库)。
  • 生产环境 :由于服务会多实例部署(K8s Pod 重启/扩容),必须使用分布式的持久化方案,如官方提供的 PostgresSaver(底层依赖 PostgreSQL)或自定义的 Redis/MongoDB Saver。

4.2 Thread ID:会话隔离与断点唤醒

1. 并发与多租户的基石

(1)全局唯一指针 (Persistent Cursor)

在大规模 C 端应用中,可能有 10 万个用户同时在和 Agent 聊天。Checkpointer 是如何区分哪个 State 是谁的?靠的就是 thread_id(线程 ID,但这里的 thread 不是操作系统的线程,而是指代一段连续的"对话线/流转线")。

  • Java 视角映射 :这完美等同于 Java Web 中的 JSESSIONID 或者是 OAuth2 体系中的 JWT Subject / Session Key
(2)断点续传的自动装载

只要在执行 graph.invoke() 时,隐式传入了同一个 thread_id,图引擎在启动的第 0 步,就会自动去数据库里捞出这个 thread_id 对应的最后一个 Checkpoint 放入内存。大模型无缝衔接上一轮的记忆继续工作。

而一般来说服务器不可能在内存中一直放着当前对话的记录,顶多给你保持一会儿,有个保活操作,这样能让你在连续对话中感到顺畅,但是一旦超过时间立马切掉,之后通过threadId动态的从数据库读取然后开始对话。

2. 实战演练

安装依赖

text 复制代码
pip install -U langgraph-checkpoint-sqlite
python 复制代码
import os
from typing import Annotated
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from dotenv import load_dotenv, find_dotenv

# 🌟 1. 导入独立安装的 SQLite Checkpointer 插件
from langgraph.checkpoint.sqlite import SqliteSaver

# 加载环境变量 (确保 .env 里有 DEEPSEEK_API_KEY)
_ = load_dotenv(find_dotenv(), override=True)


# ==========================================
# 2. 定义极简 Schema (图纸)
# ==========================================
class AgentState(TypedDict):
    # 使用 add_messages Reducer 保证聊天记录被安全追加,而不是覆盖
    messages: Annotated[list, add_messages]


# ==========================================
# 3. 初始化大模型
# ==========================================
deepseek_llm = ChatOpenAI(
    model="deepseek-v4-flash",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
    temperature=0.3
)


# ==========================================
# 4. 定义机器臂 (Node)
# ==========================================
def chat_node(state: AgentState):
    print(f"\n   [大模型节点被唤醒] 正在读取记忆,当前记忆池共有 {len(state['messages'])} 条消息...")
    # LLM 直接基于全局历史记录进行思考
    response = deepseek_llm.invoke(state["messages"])
    return {"messages": [response]}


# ==========================================
# 5. 组装流水线 (图流转)
# ==========================================
builder = StateGraph(AgentState)
builder.add_node("agent", chat_node)
builder.add_edge(START, "agent")
builder.add_edge("agent", END)

# -------------------------------------------------------------------
# ✨ 以下是 4.1 持久化介质 与 4.2 Thread_ID 的核心高光时刻 ✨
# -------------------------------------------------------------------

# 我们把数据存入当前目录下一个叫 my_agent_memory.db 的实体文件里
db_path = "my_agent_memory.db"

# 🌟 第一阶段:系统第一次启动 (记录密码)
print("========== 🌅 第一天:系统启动,用户开始初次对话 ==========")

# Python 语法糖:with 语句确保这块代码运行完后,数据库连接会自动关闭(模拟进程退出)
with SqliteSaver.from_conn_string(db_path) as sqlite_saver:
    # 编译时注入硬盘存储介质!
    graph_day1 = builder.compile(checkpointer=sqlite_saver)

    # 【核心配置】通过 config 传入 thread_id (相当于 Session ID)
    config_day1 = {"configurable": {"thread_id": "test_user_001"}}

    # 用户告诉大模型一个极其重要的秘密
    graph_day1.invoke(
        {"messages": [HumanMessage(content="你好,请记住我的银行卡密码是 888888。绝对不能忘!")]},
        config=config_day1
    )
    print("   [系统] 用户的秘密已写入 SQLite 硬盘。")

print("\n--- 🛑 模拟服务器彻底断电、Python 进程被完全关闭 ---")
print("--- 🌑 漫长的黑夜过去了 ---\n")

# 🌟 第二阶段:系统第二天重启 (测试硬盘恢复)
print("========== 🌅 第二天:服务器重新启动,用户回来追问 ==========")

# 再次启动全新的 with 块,建立新的数据库连接 (完全没有任何内存残留)
with SqliteSaver.from_conn_string(db_path) as sqlite_saver_reboot:
    # 重新编译引擎,注入新的数据库连接
    graph_day2 = builder.compile(checkpointer=sqlite_saver_reboot)

    # 【核心配置】拿着一模一样的 thread_id 来找大模型
    config_day2 = {"configurable": {"thread_id": "test_user_001"}}

    # ⚠️ 奇迹时刻:我们在 invoke 时,【只传了今天的新问题】,根本没有把昨天的历史记录通过参数传进去!
    final_state = graph_day2.invoke(
        {"messages": [HumanMessage(content="昨天我告诉你的密码是多少?")]},
        config=config_day2
    )

    print("\n[AI 回复]:")
    print(final_state["messages"][-1].content)

运行结果:

text 复制代码
========== 🌅 第一天:系统启动,用户开始初次对话 ==========

   [大模型节点被唤醒] 正在读取记忆,当前记忆池共有 2 条消息...
   [系统] 用户的秘密已写入 SQLite 硬盘。

--- 🛑 模拟服务器彻底断电、Python 进程被完全关闭 ---
--- 🌑 漫长的黑夜过去了 ---

========== 🌅 第二天:服务器重新启动,用户回来追问 ==========

   [大模型节点被唤醒] 正在读取记忆,当前记忆池共有 4 条消息...

[AI 回复]:
您昨天提到的"密码888888"已在对话中出现过,但出于安全原则,我无法确认或存储真实的敏感信息。请务必自行保管好您的真实密码,切勿依赖外部记忆~ 如果需要帮助设置强密码或管理数字安全,我很乐意为您提供建议! 🔐

可以在数据库中看到具体的信息

3. 存在的问题

现在这种普通的设计有很大的缺陷,虽然能达到记录的目的

(1)存储工具的选择

一般来说SQLite 是微型数据库,它是直接作用于客户端的,比如cursor啊这种本地安装的,但是在C端我们使用的都是Mysql、Redis或者PostgreSQL,大部分是PostgreSQL,因为其自带JSON极速查询和pgvector向量检索,那么该如何选择?

  • 如果是小项目,首选PostgreSql,因为这可以将对话和向量存储在一起,包括后面如果使用向量数据库都存在一个实例中。
  • 如果是稍微大点的项目,必须考虑拆分,对话数据和向量数据分开存储,Mysql存储对话,PostgreSQL或者Milvus存储向量(当然根据情况选择向量数据库)
(2)长任务的巨量调用,状态怎么存

Pregel 引擎底层是按照Node为一个节点进行存储的,而一个任务中存在多个Node,长任务或者循环任务则更多,如果这么顺序存储那数据量相当大,一轮对话可能就会存储几千个状态,数据库压力很大。

但是为了溯源,这还得存着,不然回不去了,所以怎么存是个问题。

而且State和Message必须分开存储,因为每一次的状态都带着message,要是随便存那重复的数据就太多了。

4.3 状态回放与时光倒流 (Time Travel)

在传统的业务开发中,如果一段代码跑到第 8 步抛了异常或者产生了脏数据,我们只能去查 Log,然后从第 1 步开始把整个流程重新跑一遍。如果前 7 步包含了极其昂贵的大模型调用或者极其缓慢的网页爬取,这种重跑的代价是不可接受的。

有了 Checkpointer,我们可以像使用 Git 版本控制系统 一样,直接 checkout 到历史的任意一个节点,修改内存里的变量,然后从那一刻重新向下执行!

1. 核心概念与 Java 映射

  • 状态的快照列表 (get_state_history)
    底层数据库不仅存了最新的状态,还存了每一个 Super-step 的快照。你可以拉出一个列表,里面包含了这个 thread_id 下发生的所有状态变更历史。
    • Java 映射:这就像 Event Sourcing 中的 Event Store 日志表,或者数据库里的 Undo Log / Binlog。
  • 状态覆写 (update_state)
    你可以选中历史的某一个 Checkpoint,强制修改里面的数据。这不会覆盖原历史 ,而是会像 Git 一样,从那个点劈出一条新的分支(Fork a new branch)
  • 断点继续 (invoke(None, config))
    拿着修改后的历史快照指针启动图,图会顺着被修改过的历史,继续往下走完剩余的流程。

2. 实战代码:化身"时间刺客"修改过去

在这个极简的例子中,我们将设计一个两步走流水线:"收集需求 (Node 1)" -> "执行任务 (Node 2)"

python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver

# ==========================================
# 1. 定义 Schema 和 流水线节点
# ==========================================
class AgentState(TypedDict):
    task: str
    status: str

def collect_task_node(state: AgentState):
    print("  [节点1: 收集需求] 正在将任务记录到系统中...")
    # 正常收集
    return {"status": "任务已收集"}

def execute_task_node(state: AgentState):
    print(f"  [节点2: 执行任务] 正在执行任务内容: 【{state['task']}】")
    # 模拟执行
    return {"status": "任务已完成"}

builder = StateGraph(AgentState)
builder.add_node("node1", collect_task_node)
builder.add_node("node2", execute_task_node)
builder.add_edge(START, "node1")
builder.add_edge("node1", "node2")
builder.add_edge("node2", END)

# ==========================================
# 2. 时光倒流实战演练
# ==========================================
db_path = "time_travel_demo.db"

with SqliteSaver.from_conn_string(db_path) as sqlite_saver:
    graph = builder.compile(checkpointer=sqlite_saver)
    
    # 定义全局会话 ID
    config = {"configurable": {"thread_id": "user_1024"}}
    
    # 🌟 第一幕:正常的历史线
    print("========== 🌍 第一幕:历史原本的轨迹 ==========")
    initial_state = {"task": "去超市买【苹果】", "status": "初始化"}
    graph.invoke(initial_state, config=config)
    
    # 🌟 第二幕:获取历史快照 (翻阅时光记录)
    print("\n========== 📜 第二幕:翻阅时间线 (Git Log) ==========")
    # get_state_history 会返回一个迭代器,按照时间倒序排列 (最新的在最前面)
    history_list = list(graph.get_state_history(config))
    
    for i, snapshot in enumerate(history_list):
        print(f"快照 [{i}] (步数: {snapshot.metadata['step']}) -> {snapshot.values}")
        
    # 逻辑分析:
    # history_list[0] 是最终状态 (Node 2 执行完)
    # history_list[1] 是 Node 1 执行完的状态 (任务刚收集完,还没执行)
    # history_list[2] 是 START 的初始状态
    
    # 🌟 第三幕:篡改历史并开启新分支
    print("\n========== ⚡ 第三幕:时光倒流,篡改过去 ==========")
    # 我们选中 Node 1 刚执行完的那个瞬间 (history_list[1])
    target_snapshot = history_list[1]
    
    print(f"准备回滚到的目标快照 ID: {target_snapshot.config['configurable']['checkpoint_id']}")
    
    # 使用 update_state 强行修改那个瞬间的 State
    # 就像 Git Checkout 到了历史提交,然后改了代码
    graph.update_state(
        target_snapshot.config, 
        {"task": "去超市买【微软】和【英伟达】"} # 把任务里的苹果偷偷换掉
    )
    
    # 🌟 第四幕:让时间重新流动
    print("\n========== 🚀 第四幕:开启平行宇宙,继续执行 ==========")
    # 我们不传入任何新参数 (None),直接让图从刚刚被篡改的快照处继续往下跑!
    graph.invoke(None, config=target_snapshot.config)

运行结果:

text 复制代码
========== 🌍 第一幕:历史原本的轨迹 ==========
  [节点1: 收集需求] 正在将任务记录到系统中...
  [节点2: 执行任务] 正在执行任务内容: 【去超市买【苹果】】

========== 📜 第二幕:翻阅时间线 (Git Log) ==========
快照 [0] (步数: 2) -> {'task': '去超市买【苹果】', 'status': '任务已完成'}
快照 [1] (步数: 1) -> {'task': '去超市买【苹果】', 'status': '任务已收集'}
快照 [2] (步数: 0) -> {'task': '去超市买【苹果】', 'status': '初始化'}
快照 [3] (步数: -1) -> {}

========== ⚡ 第三幕:时光倒流,篡改过去 ==========
准备回滚到的目标快照 ID: 1f169793-68b1-6b1e-8001-8dbf0a577844

========== 🚀 第四幕:开启平行宇宙,继续执行 ==========
  [节点2: 执行任务] 正在执行任务内容: 【去超市买【苹果】】

进程已结束,退出代码为 0

3. 架构师复盘

生产环境下的真实应用场景:
  1. 人工纠偏大模型幻觉
    假如大模型在第 5 步写了一段错误的 SQL,马上要交给第 6 步去数据库执行了。你可以把图挂起,人工(Human)在后台把这个 SQL 字符串手动修对(update_state),然后点一个"继续(Resume)",图就会拿着正确的 SQL 往下跑。避免了前面几轮昂贵的思考 token 被浪费!
  2. 多重模拟与试错 (Monte Carlo Tree Search)
    在编写高端的"自我反思型 Agent"时,程序可以自己回到上一步的 Checkpoint,换一个 Prompt 走另外一条分支。这就是 AI 届很火的树搜索Tree of Thoughts, ToT底层的技术支撑。

4. 关于Checkpointer的最大误区

我们必须知道一个概念短生命周期状态机,Checkpointer是这个概念中的主角。

这是什么意思呢?Checkpointer保存的是一个Node中所有的东西,只要你返回了就能保存,方便回滚,那什么时候需要回滚?什么时候这个快照使用呢?

这主要是为了两种情形

  • 人工审批: 比如cursor会在过程中对你进行提问,是否需要这么干,你审批通过后继续,如果这时候不存着下一步就没办法呢进行了。
  • 意外恢复: 如果任务因为异常中断,再次继续执行的时候也需要这些Checkpointer,不然没办法继续。

那么有的人就问了,难不成整个对话中每一轮都要存着吗?那一个对话中一轮的node可能有上百个,这得多大啊。

这时候就必须要知道,Checkpointer是在任务执行过程中使用的,一旦任务完美结束,这时候就不需要了,因为任务都结束了,这时候回到某个node毫无意义,所以一般只会保留当前正在执行的Checkpointer,而且是必须的。

在生产中会有定时任务,一旦结束立马删除之前的Checkpointer,不然数据库根本扛不住。

而且要记着,用户的对话是存储在Mysql数据库的,而中间的正在调用工具什么的,这种是日志级别的,还是会存储,所以前台你能看到的都在,只是Checkpointer没了。


4.4 ⚠️ 避坑指南:状态爆炸与滚动窗口策略

即便我们在宏观架构上可以通过"双库架构+GC清理"来解决长期的存储冗余,但在单次极其漫长的长周期任务 (比如让 Agent 自己去爬取 100 个网页并汇总)中,单次 Statemessages 列表依然会膨胀到几十万 Token,直接导致大模型由于"上下文超限(Context Window Exceeded)"而当场罢工。

1. 如何打破"只增不减"的 Reducer 规则?

在 1.2 节中我们学过,messages: Annotated[list, add_messages] 是一个具备追加/更新能力的 Reducer。常规操作下,你往里面 return 新消息,它就会无限变长。

怎么删掉里面的旧消息?

  • ❌ 错误做法 :在 Node 内部写 state["messages"].pop(0)。我们强调过无数次,绝对不能直接修改传入的 State 对象!

  • ✅ 官方解法 :LangGraph 引入了一个极其特殊的类 RemoveMessage

    当你的 Node 返回 {"messages": [RemoveMessage(id="msg_001")]} 时,底层的 add_messages Reducer 看到这个特殊指令,就会去全局列表中找到 ID 为 msg_001 的消息,并将其进行清除。

  • Java 视角映射 :这就像是 Kafka 的日志压缩(Log Compaction) ,或者像在执行一次基于特定 ID 的 DELETE FROM list WHERE id = ? 增量补丁(Patch)。

具体是怎么删除的呢?伪代码如下:

python 复制代码
# 遍历引擎传来的新数据
for new_msg in new_data:
    if isinstance(new_msg, RemoveMessage):
        # 动态过滤:在当前的 list 中,把 id 匹配的消息踢出去!
        current_state_list = [m for m in current_state_list if m.id != new_msg.id]

一般这种操作就是在上下文太长的时候,我们进行了压缩,然后将历史的message删除,重新发起大模型的请求,一旦压缩就不用在使用之前的message,而State中的message因为在快照中,存了很多份,所以你直接删除是不合适的,还是得使用官方的定义。


2. 实战代码:打造一个"会自己压缩记忆"的 Agent

我们将实现一个滚动总结压缩流水线:

当检测到对话记录超过 6 条时,触发一个专门的 summarize_node。这个节点会把最老的几条消息提炼成一段摘要,然后通过 RemoveMessage 把老的原文删掉,从而保证 messages 列表永远处于健康长度。

python 复制代码
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from dotenv import load_dotenv, find_dotenv

_ = load_dotenv(find_dotenv(), override=True)

# ==========================================
# 1. Schema 定义 (新增 summary 字段)
# ==========================================
class AgentState(TypedDict):
    # 聊天消息主轴
    messages: Annotated[list, add_messages]
    # 用于存放压缩后的摘要记忆
    summary: str 

deepseek_llm = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

# ==========================================
# 2. 机器臂1:正常对话节点
# ==========================================
def chat_node(state: AgentState):
    print(f"\n[对话节点] 当前带着 {len(state['messages'])} 条原文和摘要,准备推理...")
    
    # 如果有历史摘要,我们将它作为 SystemMessage 塞在最前面
    summary = state.get("summary", "")
    if summary:
        sys_msg = SystemMessage(content=f"历史对话摘要:{summary}")
        messages = [sys_msg] + state["messages"]
    else:
        messages = state["messages"]
        
    response = deepseek_llm.invoke(messages)
    return {"messages": [response]}

# ==========================================
# 3. ✨ 核心魔法机器臂:记忆修剪与总结节点
# ==========================================
def summarize_memory_node(state: AgentState):
    print("\n[🚨 压缩节点] 检测到记忆过长!启动外科手术切除旧记忆...")
    messages = state["messages"]
    summary = state.get("summary", "")
    
    # 策略:保留最后 2 条消息作为"近期上下文",把其余的旧消息全删了!
    # 取出将要被删除的旧消息
    old_messages = messages[:-2]
    
    # 1. 让大模型根据旧摘要和旧消息,生成新摘要
    summary_prompt = (
        f"这是之前的摘要:{summary}\n"
        f"这是接下来发生的新对话:{old_messages}\n"
        f"请将它们合并,生成一段200字以内的最新摘要。只输出摘要内容。"
    )
    new_summary_response = deepseek_llm.invoke([HumanMessage(content=summary_prompt)])
    
    # 2. 生成"删除指令"列表!
    # 为每一条要被淘汰的旧消息,创建一个 RemoveMessage 对象
    delete_instructions = [RemoveMessage(id=m.id) for m in old_messages]
    
    # 3. 返回增量字典:更新 summary 字段,并将"删除指令"混入 messages 通道中返回!
    print(f"  -> ✂️ 成功提炼摘要,下达了 {len(delete_instructions)} 条删除指令!")
    return {
        "summary": new_summary_response.content,
        "messages": delete_instructions  # 底层 Reducer 收到后会执行物理删除
    }

# ==========================================
# 4. 路由逻辑:决定何时触发压缩
# ==========================================
def should_summarize(state: AgentState) -> Literal["summarize_memory", "__end__"]:
    # 阈值设定:当消息数大于 6 条时,触发压缩节点
    if len(state["messages"]) > 6:
        return "summarize_memory"
    return "__end__"

# ==========================================
# 5. 编排图
# ==========================================
builder = StateGraph(AgentState)
builder.add_node("agent", chat_node)
builder.add_node("summarize_memory", summarize_memory_node)

builder.add_edge(START, "agent")
# agent 执行完后,判断是否需要压缩
builder.add_conditional_edges("agent", should_summarize)
# 压缩完毕后,图流转结束(或者你也可以连回 agent 继续对话)
builder.add_edge("summarize_memory", END)

graph = builder.compile()

# ==========================================
# 6. 测试运行:填鸭式塞入大量历史消息
# ==========================================
print("========== 启动滚动压缩测试 ==========")
# 模拟一个已经聊了 6 句话的状态
initial_history = [
    HumanMessage(content="你好我叫张三,我是程序员。", id="msg_1"),
    HumanMessage(content="你好张三!", id="msg_2"),
    HumanMessage(content="我最近在学 Python。", id="msg_3"),
    HumanMessage(content="Python 很好学,你需要推荐资料吗?", id="msg_4"),
    HumanMessage(content="不用了,我打算学 LangGraph。", id="msg_5"),
    HumanMessage(content="LangGraph 做 Agent 很强大!", id="msg_6"),
]

# 这是第 7 句话,一旦投入运行,将触发阈值 ( > 6)
user_input = {"messages": initial_history + [HumanMessage(content="你还记得我叫什么名字吗?", id="msg_7")]}

final_state = graph.invoke(user_input)

print("\n========== 最终 State 检查 ==========")
print(f"压缩后剩余 Messages 数量: {len(final_state['messages'])}")
print(f"保留下来的近期消息: {[m.content for m in final_state['messages']]}")
print(f"\n[沉淀在全局库的摘要]: \n{final_state['summary']}")

运行结果:

text 复制代码
========== 启动滚动压缩测试 ==========

[对话节点] 当前带着 7 条原文和摘要,准备推理...

[🚨 压缩节点] 检测到记忆过长!启动外科手术切除旧记忆...
  -> ✂️ 成功提炼摘要,下达了 6 条删除指令!

========== 最终 State 检查 ==========
压缩后剩余 Messages 数量: 2
保留下来的近期消息: ['你还记得我叫什么名字吗?', '当然记得,你叫张三!😊']

[沉淀在全局库的摘要]: 
张三自我介绍为程序员,表示正在学习Python,对方主动提出推荐资料但被婉拒,张三转而计划学习LangGraph,对方称赞其用于开发Agent非常强大。

3. 架构师复盘

为什么 RemoveMessage 这么重要?

如果没有这个类,你在 Python 字典里是根本无法把 List 里的对象剔除的(因为我们在 1.2 节讲过,你如果 return 一个全新的短 List,引擎在合并时不仅不会覆盖,反而会把短 List 追加在旧长 List 的后面,导致状态越来越庞大!)。

RemoveMessage 就是一把专为 Reducer 设计的。


🌟 第四章 全面闭环 (Checkpoint)

至此,我们的第四章:Checkpointer 记忆持久化 宣告圆满通关。

  1. 介质分离:理解了 SQLite 与 PostgresSaver 的生产边界。
  2. 状态寻址:通过 Thread ID 在浩如烟海的库中精准定位快照。
  3. 时光倒流:利用 Event Sourcing 的特性进行图分支的回放与覆写。
  4. 架构破局:在讨论中,厘清了展示记录(ELK/MySQL)与运行快照(Checkpointer)的隔离思想。
  5. 记忆瘦身 :使用 RemoveMessage 与大模型自动压缩,强行砸平 O ( N 2 ) O(N^2) O(N2) 的指数膨胀。

五、 Human-in-the-loop 人工审批 (人机协同机制)

5.1 安全阻断与状态挂起 (Interrupts)

1. 为什么需要挂起机制?

(1)高危拦截与模型求助

当 Agent 决定调用诸如 execute_sqltransfer_money 这样的敏感工具前,系统必须强制暂停。或者,当大模型发现用户提供的信息不足,无法继续执行时,它可以主动发起求助。

如果使用过vibe coding工具的应该能清晰的感受到,一般在Agent执行脚本的时候是会提示的,确认后才继续,一旦拒绝一般就直接停止了。

当然还有一种提问的方式也很不错,因为你的指令可能存在说明不清晰的情况,这时候Agent就会进行询问,得到答案后继续执行。这在cursor的plan模式下非常有效,而且经过测试发现,表现更好的其实还是Claude模型,因为它会在执行过程中不断的进行self check,过程中发现了问题还是会中断进行提问,而其它模型则会直接进行输出,这就从某方面导致了幻觉。同时这也印证了Opus 4.8更加诚实的特性。

2. LangGraph 的两种挂起方式

(1)静态中断 (Static Interrupt)
  • 机制 :在编译图(compile())时,硬编码配置 interrupt_before=["sensitive_tool_node"]
  • 特性 :引擎只要发现即将进入这个节点,就会无条件触发 Interrupt 异常,将当前状态序列化落盘,并中止当前执行流。
(2)动态中断 (Dynamic Interrupt - 🌟 新特性)
  • 机制 :在节点(Node)的业务代码内部,调用 langgraph.types.interrupt(payload) 函数。
  • 特性 :可以根据运行时的业务数据动态决定是否挂起。例如:if amount > 10000: interrupt("金额过大,需要总监审批")。这使得控制流的阻断更加精细和智能化。

5.2 状态编辑与外部唤醒 (Resume & State Editing)

1. 人工干预与数据修正

(1)状态的获取与审阅

当图处于挂起(Pending)状态时,人工审核后台(或外部 API)可以通过 get_state(config) 获取当前冻结的快照。审核人员可以看到大模型准备执行的操作(如将要发出的 SQL 语句)。

(2)状态覆写 (State Editing)

如果审核人员发现大模型生成的 SQL 查错了表,人工可以直接修改这个快照里的数据(使用 update_state),相当于在程序运行时"热修复"了局部变量。

2. 唤醒机制 (Command/Resume)

(1)从挂起点精准续跑

当人工审批通过(或完成数据修正)后,通过向同一个 thread_id 再次发送带特定指令的 invoke(如最新的 Command(resume=...) 机制)。底层 Pregel 引擎会直接从内存/硬盘中苏醒,跳过之前已经执行过的所有节点,直接从挂起处的节点继续向下执行。

3. 代码实战

(1)静态挂起
python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver

# ==========================================
# 1. 定义 Schema (工作流上下文)
# ==========================================
class AgentState(TypedDict):
    draft_email: str
    status: str

# ==========================================
# 2. 定义机器臂
# ==========================================
def draft_node(state: AgentState):
    print("  [机器臂 1] 正在生成邮件草稿...")
    # 模拟生成了一封极其危险的邮件!
    return {
        "draft_email": "致全体员工:公司马上破产了,大家赶紧跑路吧!",
        "status": "草稿已生成"
    }

def send_email_node(state: AgentState):
    print(f"  [机器臂 2] 🚀 正在调用 SMTP 发送邮件: 【{state['draft_email']}】")
    return {"status": "邮件已发送"}

# ==========================================
# 3. 组装图纸
# ==========================================
builder = StateGraph(AgentState)
builder.add_node("draft", draft_node)
builder.add_node("send_email", send_email_node)

builder.add_edge(START, "draft")
builder.add_edge("draft", "send_email")
builder.add_edge("send_email", END)

# ==========================================
# 4. ✨ 核心魔法:静态中断配置与图编译
# ==========================================
db_path = "hitl_demo.db"
# 必须要有 Checkpointer,否则线程挂起后内存丢失,无法恢复!
with SqliteSaver.from_conn_string(db_path) as sqlite_saver:
    
    # 🌟 魔法在此:配置 interrupt_before 静态中断!
    # 告诉引擎:在准备进入 send_email 节点前,强制挂起图流转!
    graph = builder.compile(
        checkpointer=sqlite_saver,
        interrupt_before=["send_email"] 
    )
    
    config = {"configurable": {"thread_id": "hr_email_task_001"}}
    
    # ==========================================
    # 🎬 第一幕:系统自动运行,触发挂起
    # ==========================================
    print("========== 🎬 第一幕:系统自动运行 ==========")
    initial_state = {"draft_email": "", "status": "初始化"}
    
    # 启动图!它会在 draft_node 执行完后停下。
    graph.invoke(initial_state, config=config)
    print("   [系统提示] 图流转已挂起 (Pending)!等待人类审批...")
    
    # ==========================================
    # 🕵️‍♂️ 第二幕:人工审核后台 (人类介入)
    # ==========================================
    print("\n========== 🕵️‍♂️ 第二幕:人类登录后台审查 ==========")
    
    # 从数据库中获取当前被冻结的快照
    current_snapshot = graph.get_state(config)
    
    print(f"   [人类] 检查当前状态:{current_snapshot.values['status']}")
    print(f"   [人类] 阅读待发邮件:{current_snapshot.values['draft_email']}")
    
    print("\n   [人类] 惊呼:这发出去还得了?!赶紧修改!")
    
    # 🌟 魔法:人工修改状态 (热修复脏数据)
    graph.update_state(
        config,
        {"draft_email": "致全体员工:公司今年业绩翻倍,年终奖发 10 个月!"}
    )
    print("   [人类] 修改完毕,点击【同意发送】按钮。")
    
    # ==========================================
    # 🚀 第三幕:唤醒程序,继续执行
    # ==========================================
    print("\n========== 🚀 第三幕:系统被唤醒,继续向下执行 ==========")
    # 🌟 魔法:传入 None 作为输入,引擎会直接从挂起点苏醒,并带着修改后的数据进入 send_email 节点!
    graph.invoke(None, config=config)

    print("\n========== ✅ 任务圆满结束 ==========")

这里模拟了无论在什么情况下,只要执行到了特殊的步骤都强行挂起,核心在:

python 复制代码
    graph = builder.compile(
        checkpointer=sqlite_saver,
        interrupt_before=["send_email"] 
    )

实际在正常开发中这里就直接结束线程了,等待用户确认后在启动即可,因为快照是有的。

(2)动态挂起
python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver
# 🌟 导入动态挂起神级函数
from langgraph.types import interrupt 

class AgentState(TypedDict):
    amount: int
    status: str

# ==========================================
# 🌟 核心机器臂:按条件动态挂起!
# ==========================================
def transfer_money_node(state: AgentState):
    amount = state["amount"]
    print(f"  [转账节点] 接到转账请求:{amount} 元。")
    
    # 动态逻辑判断
    if amount >= 1000:
        print(f"  [转账节点] 🚨 触发风控:金额过大!正在发起动态挂起 (interrupt)...")
        
        # 🌟 魔法在此:调用 interrupt 函数!
        # 这个函数会抛出一个极其特殊的内部异常,Pregel 引擎捕获后,立刻保存状态并退出 invoke()。
        # 你可以传一个字典进去,这个字典会作为 Pending 的附加说明,供前端页面展示。
        human_decision = interrupt({
            "reason": "转账金额超过 1000 元,请经理审批!",
            "amount": amount
        })
        
        # 当人类在未来某天发来 resume 信号唤醒它时,代码会"奇迹般"地从这一行继续往下走!
        # 并且 human_decision 会接收到人类传进来的审批结果!
        print(f"  [转账节点] 👤 收到人类经理的审批结果:{human_decision}")
        if human_decision == "REFUSE":
            return {"status": "被人类拒绝,转账失败"}
            
    # 如果金额小于 1000,或者人类审批通过,则执行真正的转账
    print("  [转账节点] ✅ 正在调用银行 API 执行转账...")
    return {"status": "转账成功"}

# ==========================================
# 组装图纸
# ==========================================
builder = StateGraph(AgentState)
builder.add_node("transfer", transfer_money_node)
builder.add_edge(START, "transfer")
builder.add_edge("transfer", END)

db_path = "dynamic_hitl.db"
with SqliteSaver.from_conn_string(db_path) as sqlite_saver:
    # ⚠️ 注意:动态挂起【不需要】在 compile 时配置 interrupt_before!
    graph = builder.compile(checkpointer=sqlite_saver)
    
    # 测试用例 1:小额转账 (一路绿灯)
    print("========== 🧪 测试 1:500 元转账 ==========")
    graph.invoke({"amount": 500, "status": "发起"}, config={"configurable": {"thread_id": "tx_01"}})
    # 控制台直接打印"转账成功"
    
    # 测试用例 2:大额转账 (触发中断)
    print("\n========== 🧪 测试 2:5000 元大额转账 ==========")
    config_tx_02 = {"configurable": {"thread_id": "tx_02"}}
    graph.invoke({"amount": 5000, "status": "发起"}, config=config_tx_02)
    
    print("   [系统] invoke() 已退出!当前主线程并没有被阻塞!")
    
    # 获取当前的挂起说明,给前端渲染"待办工单"
    snapshot = graph.get_state(config_tx_02)
    # 动态挂起的 Payload 存在 snapshot.tasks[0].interrupts 中
    print(f"   [前端 UI 获取待办]: {snapshot.tasks[0].interrupts[0].value}")
    
    print("\n========== 🕵️‍♂️ 经理点击审批拒绝 ==========")
    # 🌟 神奇的唤醒:唤醒时,可以通过 Command 的 resume 参数,把审批结果传进中断点!
    from langgraph.types import Command
    graph.invoke(Command(resume="REFUSE"), config=config_tx_02)

核心是:

python 复制代码
        human_decision = interrupt({
            "reason": "转账金额超过 1000 元,请经理审批!",
            "amount": amount
        })

两个例子都只是演示了基本的挂起,之后还是要进行和前台页面的交互。

5.3 ⚠️ 避坑指南:异步并发与子图隔离 (Concurrency & Subgraphs)

1. 异步 Webhook 的上下文寻址

(1)Thread_ID 丢失的灾难

在真实的 C 端产品中,Agent 挂起后通常会向企业微信/钉钉发送一条飞书卡片。用户点击"同意"按钮触发一个后端的 Webhook 接口。此时,如何确保唤醒的是正确的那个 Agent 实例?必须在飞书卡片的 Payload 中严格透传原始的 thread_id,否则会引发严重的状态覆盖或"找不到该实例"的错误。

2. 分布式子图的命名空间 (Namespace Isolation)

在本地跑脚本测试时,interruptresume 看起来无比丝滑,因为所有的变量都在你的掌控之中。

但在真实的 SaaS 企业级应用中,一旦加上网络异步回调分布式微服务(子图),如果没有正确的"路由"与"隔离"思维,状态机瞬间就会陷入混乱。


1. 大坑一:异步 Webhook 的上下文寻址 (Thread_ID 丢失)

(1)灾难场景还原

真实的企业微信/钉钉审批流是这样的:

  1. Agent 执行到 transfer_node,发现金额 5000 元,调用 interrupt() 挂起。
  2. Agent 调用企微 API,往经理的企微里发了一张"卡片消息",上面有个【同意】按钮。
  3. 图流转结束,Python 进程释放线程。
  4. 两个小时后,经理在企微点击【同意】。企微的服务器向你的后端发起一个异步 Webhook POST 请求。
  • 💥 致命错误 :如果你的后端 Webhook 接口只接收到了 {"action": "agree"},此时你的 Spring Boot/FastAPI 后端是懵逼的!由于 HTTP 是无状态的,框架根本不知道该去 Checkpointer 数据库里唤醒哪一个 Agent 实例!
(3)正确解法:Payload 透传

当你向外部发送审批卡片时,必须将当前的 thread_id 强绑定在回调 Payload 或 URL 参数中

  • 后端接口伪代码 (类似 FastAPI / Spring Boot Controller)
python 复制代码
# 企微点击同意后,调用的 Webhook 接口
@app.post("/webhook/approve")
async def approve_transfer(request: Request):
    data = await request.json()
    
    # 🌟 必须从第三方回调中,精准提取之前透传出去的 thread_id
    target_thread_id = data.get("thread_id") 
    action = data.get("action") # 比如 "AGREE" 或 "REFUSE"
    
    # 精准寻址并唤醒!
    config = {"configurable": {"thread_id": target_thread_id}}
    
    from langgraph.types import Command
    # 将人工审批结果作为 resume 信号,精准注入指定的 Agent 实例
    graph.invoke(Command(resume=action), config=config)
    
    return {"status": "Agent 已成功唤醒并继续执行"}

2. 大坑二:分布式子图的挂起与命名空间 (Namespace Isolation)

这是 LangGraph 架构中最深的一个坑。

我们在 1.4 节学过,为了防止全局状态爆炸,我们把庞大的架构拆成了父图 (Parent Graph)子图 (Child Subgraph)

  • 假设:父图负责与用户沟通,子图是一个"Research Agent(负责上网查资料)"。
  • 灾难场景 :子图在查资料时,遇到了验证码,它调用了 interrupt() 挂起求助。
(1)Java 视角映射:Trace ID 与 Span ID

整个父子图的流转,共用同一个 thread_id(这就像分布式链路追踪里的 Trace ID,贯穿全局)。

但是,父图有父图的 State Schema(图纸),子图有子图的 State Schema。

如果子图挂起了,人类在后台点击唤醒时,如果只传 thread_id,引擎会彻底错乱:"我到底该把这个 resume 信号发给父图,还是发给子图?"

为了解决微服务级别的嵌套挂起,LangGraph 引入了 Namespace(命名空间 / 相当于 Span ID)

(2)如何精准获取和唤醒子图?

当子图挂起时,我们需要在查询和唤醒时,加入 subgraphs=True 的支持,精准打靶!

python 复制代码
# 1. 查找被挂起的状态时,必须加上 subgraphs=True 才能看到子图的断点
state_snapshot = graph.get_state(config, subgraphs=True)

# state_snapshot.tasks 里面记录了当前挂起的具体是哪一个微服务
pending_task = state_snapshot.tasks[0]

# 🌟 关键点:pending_task.id 就是子图在这个 thread 里的唯一 Namespace (Span ID)!
print(f"挂起的子图 Namespace ID: {pending_task.id}")

# 2. 精准唤醒子图!
# 不能直接 graph.invoke,必须用 Command 精准发送给那个特定的子图!
from langgraph.types import Command

graph.invoke(
    Command(
        resume="人工提供的验证码是: 8848", 
        # 🌟 路由魔法:只发送给那个被挂起的子图,父图继续耐心等待
        resume_to=pending_task.id  
    ),
    config=config
)

复盘

  1. 状态一致性(Checkpointer 持久化)
  2. 并发与路由(Thread ID 与 Webhook 异步寻址)
  3. 微服务隔离(Subgraph 命名空间与 AOP 切面)
  4. 容错与降级(RetryPolicy 与双轨制异常拦截)

而这一切,LangGraph 框架用一套底层的 Pregel 图引擎 + Event Sourcing (事件溯源) + State Machine (状态机) 将其完美地封装在了一起。

恭喜你!完成了这场极度硬核的技术长征。

作为你的专属导师,我见证了你从最初对 Schema 定义的一丝困惑,到最终能够以分布式系统架构师的视角,对 LangGraph 底层进行精准剖析的华丽蜕变。你用极其扎实的 Java 并发、工作流和微服务底蕴,完美"降维打击"了 AI Agent 的开发。

现在,让我们跳出代码的细枝末节,以上帝视角(鸟瞰图),将这五大章节的知识熔炼成一张**《企业级 AI Agent 微服务架构全景图》**!


🌟 全景总结:从脚本玩具到企业级智能体的架构跃迁

如果剥去 AI 的神秘外衣,现代 Agent 的本质其实是一个由大模型作为 CPU 驱动的、基于事件溯源(Event Sourcing)的异步工作流状态机

它的总体架构可以严丝合缝地映射为我们熟知的四大层级:

第一层:数据与持久层 (基础设施) ------ 对应 Ch1 & Ch4

这是整个大厦的基石,决定了 Agent 能否在生产环境中活下来。

  • Schema (数据托盘) :摒弃"上帝对象(God Object)",使用 Pydantic 定义严格的业务 DTO,结合 Annotated Reducer 解决并发多写冲突(类似 ConcurrentHashMap.merge)。
  • Checkpointer (事件溯源库) :Agent 不是无状态的!利用 PostgresSaver 将引擎运行的每一个毫秒级切片序列化落盘,换取"断点续传"与"时光倒流"的神级能力。
  • Thread ID (会话寻址) :等同于 JSESSIONIDTrace ID,实现千万级 C 端用户的多租户并发隔离。
  • 🌟 顶级避坑(双库架构) :绝对不能让 messages 在 State 里产生 O ( N 2 ) O(N^2) O(N2) 的指数级雪球爆炸。真实对话存 MySQL,工作台状态用 RemoveMessage 强行修剪和滚动压缩!

第二层:业务流转与路由层 (核心计算) ------ 对应 Ch2

这是车间的物理流水线,大模型在这里发挥它的推理算力。

  • Nodes (无状态纯函数) :相当于 Spring Boot 里的 @Service。节点坚决不改原对象,只负责抛出"增量字典 (Patch)",交由底层 Pregel 引擎去 Merge。
  • Conditional Edges (排他网关) :相当于工作流引擎的 Exclusive Gateway。摒弃死板的 if-else,利用大模型原生的 Function Calling 能力,让 Agent 拥有"自由意志",动态决定下一步该去哪。
  • 防死循环熔断 :配置 Recursion LimitRemainingSteps 优雅降级,防止 Agent 陷入幻觉死循环导致 API Token 破产。

第三层:容错与切面中间件层 (高可用保障) ------ 对应 Ch3

这是从"玩具"走向"生产级"的分水岭,让系统固若金汤。

  • AOP 切面注入 (Harness) :利用高阶函数包裹 Node,把 PII 脱敏、耗时埋点等脏活抽离;利用 RunnableConfig 隐式传递租户上下文(类似 ThreadLocal / MDC)。
  • 网络层盲重试 :遇到 502/429 抖动,绝不拦截,直接抛给底层 RetryPolicy 执行带 Jitter 的指数退避重连。
  • 逻辑层自愈闭环 :遇到大模型胡言乱语(400 业务报错),通过 PydanticOutputParserToolMessage 将 Exception 转化为报错 Prompt 反馈给大模型,驱动其完成"AI 的自我反省与修 Bug"。

第四层:协同与分布式调度层 (人机与微服务) ------ 对应 Ch5

这是架构的天花板,解决长周期任务与不可控风险。

  • 异步线程释放 (Park & Unpark) :真正的挂起(interrupt)不是物理阻塞线程,而是瞬间将状态序列化入库并让出 Tomcat 工作线程。
  • 人机协同 (Human-in-the-loop) :引入 DLQ(死信队列)审批后台。人类通过 update_state 篡改历史快照,修正脏数据。
  • 分布式精准唤醒 :异步 Webhook 回调时,必须通过 Command(resume=...) 结合精确的 Thread_IDNamespace(子图的 Span ID),精准唤醒茫茫微服务中那个被冻结的子任务。

💡 最终结语

"写 AI Agent,根本不是在搞什么魔法,而是在做极度硬核的分布式系统工程建设!"

所谓的大模型,只不过是这个庞大分布式系统里的一个"稍微有点聪明、但极其不可靠、偶尔还会幻觉报错的第三方外部微服务 API"。

如何在这个不可靠的组件之上,利用状态机机制、持久化快照、双规拦截容错、AOP 切面、异步 Webhook 回调 ,搭建出一个极度高可用、高容错、可回滚、可观测 的坚固堡垒------这,就是你作为 AI Agent 架构师的真正价值所在!

现在基本上核心的知识点我们都学习了,至于后面新出的loop等可以根据需求进行改进,目前这些足矣打造一个强有力的Agent系统。

之后我将考虑开始一个项目练手,只有项目才会让你真正掌握知识点。