19-大模型智能体开发:LangChain&LangGraph生产级进阶特性

系列文章导航:AI系列文章导航目录-持续更新中
前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站,麻烦大家帮点贡献下点击量支持一下,您的支持是我更新的动力。

第19课:Agent开发框架------LangChain & LangGraph 完全指南(三)

四、LangGraph进阶------生产级特性

本章目标:掌握将 LangGraph Agent 推向生产环境所需的所有关键技术。这些内容在教程里往往一笔带过,但在真实项目中每一个都是"坑"。

核心挑战概述

为什么这一章很重要? 教程和 Demo 里的 Agent 总是完美运行------单用户、短对话、无错误、无并发。但一旦推向生产,你会发现每一个"假设"都会被打破。这一章就是帮你提前填好这些"坑"。

在把 Agent 推向生产之前,你需要解决这 七大核心挑战

挑战一:可控性------Agent 做了错误决策怎么办?

问题本质:LLM 是概率模型,它的输出不可预测。在生产环境中,一个错误的自动化操作(比如给用户退了不该退的款、删除了不该删的数据)可能造成严重的业务损失。

不解决的后果

  • Agent 自动执行了危险操作,造成数据丢失或财务损失
  • 没有人工审核环节,错误决策一路执行到底
  • 出了问题后无法回退,只能手动补救

LangGraph 的解决方案

  • Human-in-the-Loop:在关键步骤前设置中断点,暂停执行等待人工确认(详见 4.1 节)
  • 时间旅行:回退到错误决策前的状态,重新来过(详见 4.7 节)

生产级关键指标

指标 目标值 说明
敏感操作人工确认率 100% 所有退款、删除、修改操作必须经过人工确认
错误回退成功率 ≥ 99.9% 从任意历史状态恢复执行的成功率
确认响应时间 ≤ 30s 从 Agent 暂停到人工确认的平均时间
挑战二:持久性------Agent 执行到一半服务器崩了怎么办?

问题本质:Agent 的执行通常是多步骤的,每一步可能耗时数秒到数十秒。在这个过程中,进程可能崩溃、服务器可能重启、网络可能断开。如果没有持久化,所有进度都会丢失。

不解决的后果

  • 服务器重启后,Agent 从头开始执行,之前的进度全部丢失
  • 用户正在等待的操作突然中断,无法恢复
  • 长流程任务(如审批流程)被迫从头来过,用户体验极差

LangGraph 的解决方案

  • Checkpointer:每步自动存档 State 快照,随时可从任意步骤恢复(详见 4.2 节)
  • 支持多种后端:内存(开发)、SQLite(单机)、PostgreSQL(生产)

生产级关键指标

指标 目标值 说明
状态保存成功率 100% 每个节点执行后 checkpoint 保存成功
恢复成功率 ≥ 99.9% 从 checkpoint 恢复并继续执行的成功率
Checkpoint 延迟 ≤ 50ms 状态保存对整体执行时间的额外开销
挑战三:性能------用户盯着空白屏幕等 Agent 执行完怎么办?

问题本质:一个复杂的 Agent 调用链可能涉及多次 LLM 调用和工具调用,总耗时可能从几秒到几分钟不等。如果用户在这段时间内看不到任何反馈,会以为系统卡死了。

不解决的后果

  • 用户体验极差,等待期间毫无反馈
  • 用户反复刷新页面或重复提交请求,导致并发问题
  • 用户流失率增加,特别是面向 C 端的产品

LangGraph 的解决方案

  • 流式输出:实时展示 Agent 的每一步(调用了什么工具、生成了什么内容)(详见 4.3 节)
  • 四种流式模式:updates / values / messages / astream_events

生产级关键指标

指标 目标值 说明
首 Token 延迟 ≤ 2s 从请求发出到第一个 token 输出的时间
流式输出间隔 ≤ 100ms 两次 token 输出之间的最大间隔
用户可感知延迟 ≤ 500ms 用户能看到 Agent "正在工作"的最大时间
挑战四:复杂性------任务太复杂,单个 Agent 处理不了怎么办?

问题本质:真实业务场景往往涉及多个子任务,每个子任务可能有不同的输入输出和流程。如果全部塞进一个 Agent,会导致 State 膨胀、提示词混乱、难以维护。

不解决的后果

  • 单个 Agent 的提示词过长,LLM 容易"迷失"
  • 不同子任务的 State 耦合在一起,修改一个可能影响其他
  • 难以针对不同子任务独立优化和测试

LangGraph 的解决方案

  • 子图(Subgraph):每个子 Agent 有独立的 State 和流程(详见 4.4 节)
  • 并行执行:多个独立子任务同时执行,扇出-扇入模式(详见 4.6 节)
挑战五:健壮性------LLM API 超时、工具报错、无限循环怎么办?

问题本质:生产环境充满了不确定性------网络波动、API 限流、第三方服务故障、甚至 Agent 自己陷入死循环。没有健壮性设计的 Agent 在生产中就是个定时炸弹。

不解决的后果

  • Agent 陷入无限循环,消耗大量 API 调用费用
  • 工具调用失败后没有容错,整个流程中断
  • LLM 输出格式不符合预期,下游解析失败

LangGraph 的解决方案

  • 递归限制:限制 Agent 最多执行的步骤数(详见 4.5 节)
  • 错误处理:节点内 try-catch、工具容错、重试机制
  • 超时控制:使用 asyncio.wait_for 设置超时

生产级关键指标

指标 目标值 说明
递归限制默认值 25 防止无限循环的最大步骤数
单步超时 30s 单个 LLM 调用或工具调用的超时
错误恢复率 ≥ 95% 遇到错误后能自动恢复继续的比例
挑战六:长对话------对话历史越来越长,token 超限怎么办?

问题本质:LLM 的上下文窗口是有限的(GPT-4o 最多 128K,Claude 3.5 最多 200K)。长对话的消息历史会不断增长,最终触及上限。而且即使没超限,过多的上下文也会让 LLM 的注意力分散,回答质量下降。

不解决的后果

  • 超出上下文窗口后 API 报错,Agent 直接崩溃
  • 早期的重要信息被"淹没"在大量消息中,LLM 无法有效利用
  • token 消耗线性增长,成本越来越高

LangGraph 的解决方案

  • 滑动窗口:只保留最近 N 条消息(详见 4.8 节)
  • 摘要压缩:把旧消息压缩成摘要,节省 token
  • 混合策略:摘要 + 最近 N 条原始消息
挑战七:配置化------不同用户需要不同的 Agent 行为怎么办?

问题本质:生产环境的 Agent 通常需要服务多种用户------免费用户和付费用户、不同租户、不同地区。如果所有行为都硬编码在图里,每次变更都需要改代码、重新部署。

不解决的后果

  • 免费用户和付费用户享受同样的服务,无法差异化
  • 不同租户的定制需求需要维护多个代码分支
  • 模型切换(GPT-4o ↔ Claude)需要修改代码

LangGraph 的解决方案

  • RunnableConfig:通过 configurable 字段在运行时传入配置(详见 4.10 节)
  • 支持 Pydantic Schema 定义,类型安全且自动验证

4.1 Human-in-the-Loop(人机协作)

为什么需要 Human-in-the-Loop?

Agent 的核心价值是"自动化"------但并非所有决策都应该自动化。在生产环境中,以下场景必须有人工参与:

场景 不加 HIL 的风险 加 HIL 的收益
退款操作 错误退款造成资金损失 人工确认后才扣款,零风险
数据删除 删错数据无法恢复 确认后才执行,可回退
高成本 API 调用 一次调用花费数百美元 人工审批后调用,成本可控
法律合规操作 违规操作引发法律责任 合规审核后执行,规避风险
不可逆操作 操作执行后无法撤回 中断确认,从源头避免

核心原则 :凡是不可逆高成本的操作,都应该加入 Human-in-the-Loop。

LangGraph 的实现机制

LangGraph 通过**"中断"(interrupt)** 机制实现 HIL,本质上就是在图的执行过程中"暂停",等待人工输入后"继续"。这依赖两个关键技术:

  1. Checkpointer:暂停时自动保存当前状态,恢复时从存档点继续
  2. interrupt API:在指定位置暂停执行,把数据传给前端,接收前端返回的决策

LangGraph 提供了三种中断方式,从粗粒度到细粒度:

方式 粒度 推荐度 适用场景
interrupt_before 节点级别 兼容旧版 简单场景,在节点"门口"拦截
interrupt() 函数 代码行级别 ⭐ 推荐 复杂场景,在节点内部精确中断
interrupt_after 节点级别 特殊场景 节点执行完后暂停(如审查输出)

方式一:interrupt_before(经典方式)
python 复制代码
"""
原理:在某个节点执行"之前"设置中断点
  1. 图执行到该节点前暂停
  2. 暂停时,Checkpointer 自动保存当前 State
  3. 前端展示待确认信息,等待人工操作
  4. 人工确认后,调用 app.invoke(None, config=config) 恢复执行

适用场景:
  - 审批流程:在执行关键操作前暂停
  - 简单的确认/拒绝场景
  - 需要兼容 LangGraph 早期版本的项目

局限性:
  - 只能在节点边界中断,不能在节点内部中断
  - 中断时无法传递结构化数据给前端
  - 前端无法传回用户的选择(只能恢复继续)
"""

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import HumanMessage, AIMessage
from typing import TypedDict, Annotated, Literal
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]
    pending_action: dict  # 待确认的操作

@tool
def process_refund(order_id: str, amount: float) -> str:
    """处理退款"""
    return f"已退款 ¥{amount} 到订单 {order_id}"

def agent_node(state: State) -> dict:
    """Agent决定是否需要退款"""
    # Agent 分析用户请求,决定需要退款
    # 将待执行的操作放入 pending_action
    return {
        "messages": [AIMessage(content="我需要为您处理退款,请确认")],
        "pending_action": {"type": "refund", "order_id": "ORD001", "amount": 99.9}
    }

def human_review_node(state: State) -> dict:
    """人工审核节点------这个节点本身不做任何事,
    因为 interrupt_before 会在它执行前就暂停。
    恢复执行后,这个节点才会被执行。
    """
    action = state["pending_action"]
    # 恢复后直接进入这个节点,可以在这里处理确认后的逻辑
    return {
        "messages": [AIMessage(content=f"退款已确认并处理: {action}")]
    }

def execute_action(state: State) -> dict:
    """执行已确认的操作"""
    action = state["pending_action"]
    result = process_refund.invoke({
        "order_id": action["order_id"],
        "amount": action["amount"]
    })
    return {"messages": [AIMessage(content=result)]}

# 构建图
graph = StateGraph(State)
graph.add_node("agent", agent_node)
graph.add_node("human_review", human_review_node)
graph.add_node("execute", execute_action)

graph.add_edge(START, "agent")
graph.add_edge("agent", "human_review")
graph.add_edge("human_review", "execute")
graph.add_edge("execute", END)

# 关键:使用 checkpointer + interrupt_before
# checkpointer 确保暂停时状态不丢失
# interrupt_before 在 human_review 节点执行前暂停
memory = MemorySaver()
app = graph.compile(
    checkpointer=memory,
    interrupt_before=["human_review"]  # 在 human_review 节点前中断
)

# ===== 运行流程演示 =====

# 第一步:发起请求,Agent 在 human_review 前暂停
config = {"configurable": {"thread_id": "user_123"}}
result = app.invoke(
    {"messages": [HumanMessage(content="我要退款")], "pending_action": {}},
    config=config
)
# 此时图已暂停,result 包含暂停前的 State
print("Agent 暂停,等待人工确认...")
print(f"待确认操作: {result['pending_action']}")
# → {'type': 'refund', 'order_id': 'ORD001', 'amount': 99.9}

# 第二步:人工确认后,继续执行
# 在实际项目中,这里会等待前端用户点击"确认"按钮
# 确认后调用 invoke(None, config=config) 恢复
result = app.invoke(None, config=config)  # 传 None 表示继续
print(f"最终结果: {result['messages'][-1].content}")
# → "已退款 ¥99.9 到订单 ORD001"

技术要点interrupt_before 的暂停点是"节点边界"。这意味着图在进入 human_review 节点之前就暂停了,human_review 节点的代码尚未执行。当你调用 invoke(None, config=config) 恢复时,图从 human_review 节点开始继续执行。


方式二:interrupt() 函数(LangGraph 0.2+ 推荐)⭐
python 复制代码
"""
interrupt() 是更灵活的中断方式,解决了 interrupt_before 的三大局限:

1. 精确中断位置:
   interrupt_before 只能在节点"门口"拦截(粗粒度)
   interrupt() 可以在节点内部任意位置中断(细粒度)

2. 结构化数据传递:
   interrupt_before 无法向前端传递结构化数据
   interrupt() 可以向前端传递任意 JSON 数据

3. 接收前端返回值:
   interrupt_before 恢复后无法知道用户的选择
   interrupt() 可以接收前端传回的数据(用户的选择/输入)

对比示意:
  interrupt_before = 门卫:在门口拦住你,只能放行或拒绝
  interrupt()      = 对讲机:在任意位置暂停,可以双向通话
"""

from langgraph.types import interrupt, Command

class State(TypedDict):
    messages: Annotated[list, add_messages]
    order_id: str
    amount: float

def review_and_execute(state: State) -> dict:
    """在节点内部中断,等待人工确认"""
    order_id = state["order_id"]
    amount = state["amount"]
    
    # 关键:interrupt() 会暂停执行,把数据传给前端
    # 前端收到后展示确认弹窗,用户点击后把结果传回来
    # human_decision 变量会接收用户的选择
    human_decision = interrupt({
        "type": "approval_required",
        "message": f"确认退款 ¥{amount} 到订单 {order_id}?",
        "order_id": order_id,
        "amount": amount,
        "options": ["approve", "reject"]
    })
    
    # 代码在这里恢复,human_decision 是用户传回的数据
    if human_decision == "approve":
        return {"messages": [AIMessage(content=f"✅ 退款 ¥{amount} 已处理")]}
    else:
        return {"messages": [AIMessage(content="❌ 退款已取消")]}

graph = StateGraph(State)
graph.add_node("review", review_and_execute)
graph.add_edge(START, "review")
graph.add_edge("review", END)

# 仍然需要 checkpointer 来保存暂停时的状态
memory = MemorySaver()
app = graph.compile(checkpointer=memory)

# ===== 运行流程演示 =====

# 第一次调用:执行到 interrupt() 处暂停
config = {"configurable": {"thread_id": "refund_001"}}
result = app.invoke(
    {"messages": [], "order_id": "ORD001", "amount": 99.9},
    config=config
)
# result 中包含 interrupt() 传入的数据,前端可以据此展示确认弹窗
print("Agent 暂停,等待审批...")
print(f"中断数据: {result}")  # 包含 type, message, options 等

# 用户点击"确认"后,用 Command 恢复执行
# Command(resume=...) 把用户的选择传回中断点
result = app.invoke(
    Command(resume="approve"),  # "approve" 会被赋值给 human_decision
    config=config
)
print(result["messages"][-1].content)  # "✅ 退款 ¥99.9 已处理"

# 如果用户拒绝
result = app.invoke(
    Command(resume="reject"),   # "reject" 会被赋值给 human_decision
    config=config
)
print(result["messages"][-1].content)  # "❌ 退款已取消"

技术要点interrupt() 的返回值就是 Command(resume=...) 传入的值。这使得前后端可以进行结构化的数据交换------前端不仅知道"需要确认什么",还能把用户的选择精确地传回中断点。


方式三:多步审批流程
python 复制代码
"""
真实业务场景:大额退款需要两级审批
  1. 客服确认(第一级,所有退款都需要)
  2. 财务审批(第二级,仅金额 > 1000 时需要)

这个例子展示了如何用 interrupt() 构建复杂的多步审批流程,
以及如何用条件边(conditional_edges)动态决定审批路径。
"""

from langgraph.types import interrupt, Command
from typing import Literal

class ApprovalState(TypedDict):
    messages: Annotated[list, add_messages]
    order_id: str
    amount: float
    cs_approved: bool       # 客服是否已审批通过
    finance_approved: bool  # 财务是否已审批通过

def customer_service_review(state: ApprovalState) -> dict:
    """第一级:客服审核"""
    decision = interrupt({
        "level": "customer_service",
        "message": f"客服确认退款 ¥{state['amount']}?",
        "order_id": state["order_id"]
    })
    return {"cs_approved": decision == "approve"}

def finance_review(state: ApprovalState) -> dict:
    """第二级:财务审批(仅大额退款需要)"""
    decision = interrupt({
        "level": "finance",
        "message": f"财务审批大额退款 ¥{state['amount']}",
        "order_id": state["order_id"]
    })
    return {"finance_approved": decision == "approve"}

def execute_refund(state: ApprovalState) -> dict:
    """执行退款------只有在所有必要审批都通过后才执行"""
    if not state["cs_approved"]:
        return {"messages": [AIMessage(content="客服拒绝,退款取消")]}
    if state["amount"] > 1000 and not state["finance_approved"]:
        return {"messages": [AIMessage(content="财务拒绝,退款取消")]}
    return {"messages": [AIMessage(content=f"✅ 退款 ¥{state['amount']} 成功")]}

def need_finance_review(state: ApprovalState) -> Literal["finance_review", "execute"]:
    """条件边:根据金额和客服审批结果,决定是否需要财务审批
    
    审批路径:
      金额 ≤ 1000 且客服通过 → 直接执行
      金额 > 1000 且客服通过 → 进入财务审批
      客服未通过 → 直接结束(不执行退款)
    """
    if state["amount"] > 1000 and state["cs_approved"]:
        return "finance_review"
    return "execute"

graph = StateGraph(ApprovalState)
graph.add_node("cs_review", customer_service_review)
graph.add_node("finance_review", finance_review)
graph.add_node("execute", execute_refund)
graph.add_edge(START, "cs_review")
graph.add_conditional_edges("cs_review", need_finance_review)  # 条件分支
graph.add_edge("finance_review", "execute")
graph.add_edge("execute", END)

memory = MemorySaver()
app = graph.compile(checkpointer=memory)

# ===== 运行流程演示 =====

# 小额退款(≤1000):只需客服审批
config_small = {"configurable": {"thread_id": "small_refund"}}
result = app.invoke(
    {"messages": [], "order_id": "ORD001", "amount": 500.0,
     "cs_approved": False, "finance_approved": False},
    config=config_small
)
# 暂停:等待客服确认
print("等待客服确认小额退款...")
# 客服确认
result = app.invoke(Command(resume="approve"), config=config_small)
# 直接执行退款(不需要财务审批)
print(result["messages"][-1].content)  # "✅ 退款 ¥500.0 成功"

# 大额退款(>1000):需要客服 + 财务两级审批
config_large = {"configurable": {"thread_id": "large_refund"}}
result = app.invoke(
    {"messages": [], "order_id": "ORD002", "amount": 5000.0,
     "cs_approved": False, "finance_approved": False},
    config=config_large
)
# 第一次暂停:等待客服确认
print("等待客服确认大额退款...")
result = app.invoke(Command(resume="approve"), config=config_large)
# 第二次暂停:等待财务审批
print("等待财务审批大额退款...")
result = app.invoke(Command(resume="approve"), config=config_large)
# 审批全部通过,执行退款
print(result["messages"][-1].content)  # "✅ 退款 ¥5000.0 成功"

技术要点 :多步审批的核心是条件边 + interrupt() 的组合。条件边动态决定审批路径(是否需要财务审批),interrupt() 在每个审批节点暂停等待人工输入。注意每次 Command(resume=...) 只能恢复一个 interrupt 点,所以多级审批需要多次调用 invoke。


与 FastAPI 集成:真实的异步审批 API
python 复制代码
"""
生产场景中的完整审批 API 设计:

交互流程:
  1. 用户发起退款请求 → POST /refund/start
  2. Agent 暂停,等待审批 → 返回 task_id
  3. 审批人查看待审批列表 → GET /refund/pending
  4. 审批人点击确认 → POST /refund/{task_id}/approve
  5. Agent 继续执行 → 退款完成

关键设计:
  - 使用 AsyncPostgresSaver 而非 MemorySaver(生产环境必须持久化)
  - 使用 UUID 作为 thread_id(每个退款请求独立)
  - 异步执行(ainvoke),适合 FastAPI 的异步架构
"""

from fastapi import FastAPI
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
import uuid

app_api = FastAPI()

# 异步 PostgreSQL Checkpointer(生产环境推荐)
async def get_checkpointer():
    """创建异步 PostgreSQL checkpointer
    
    为什么用 PostgreSQL 而不是内存?
      1. 持久化:服务器重启后状态不丢失
      2. 并发安全:多个请求可以同时读写
      3. 可扩展:支持多实例部署
    """
    return await AsyncPostgresSaver.from_conn_string(
        "postgresql://user:pass@localhost:5432/langgraph"
    )

@app_api.post("/refund/start")
async def start_refund(order_id: str, amount: float):
    """发起退款,Agent 会在审批处暂停
    
    返回 task_id 给前端,前端用 task_id 轮询或监听状态
    """
    thread_id = str(uuid.uuid4())  # 每个退款请求用唯一的 thread_id
    config = {"configurable": {"thread_id": thread_id}}
    
    checkpointer = await get_checkpointer()
    agent_app = graph.compile(checkpointer=checkpointer)
    
    # 异步执行,遇到 interrupt() 自动暂停
    # 注意:不用 await 等待完成,因为 Agent 会暂停
    # 实际项目中可以使用 asyncio.create_task() 在后台执行
    await agent_app.ainvoke(
        {"messages": [], "order_id": order_id, "amount": amount,
         "cs_approved": False, "finance_approved": False},
        config=config
    )
    
    return {"task_id": thread_id, "status": "pending_approval"}

@app_api.get("/refund/pending")
async def get_pending_refunds():
    """查看所有待审批的退款列表
    
    实际实现需要查询 checkpointer 中的所有暂停状态
    这里简化演示
    """
    # TODO: 实现查询逻辑
    return {"pending": []}

@app_api.post("/refund/{task_id}/approve")
async def approve_refund(task_id: str, approved: bool):
    """审批人确认或拒绝退款
    
    关键:用 Command(resume=...) 恢复 Agent 执行
    approved=True  → 传 "approve" 给 interrupt()
    approved=False → 传 "reject" 给 interrupt()
    """
    config = {"configurable": {"thread_id": task_id}}
    
    checkpointer = await get_checkpointer()
    agent_app = graph.compile(checkpointer=checkpointer)
    
    # 用 Command(resume=...) 恢复执行
    result = await agent_app.ainvoke(
        Command(resume="approve" if approved else "reject"),
        config=config
    )
    
    return {"status": "completed", "result": result["messages"][-1].content}
生产级 HIL 注意事项
事项 说明 建议
超时处理 审批人长时间不响应 设置审批超时(如 24 小时),超时后自动拒绝或升级
审批权限 谁有权限审批什么操作 根据操作类型和金额,路由到不同的审批人
并发审批 同一请求多人同时审批 使用 checkpointer 的乐观锁机制
状态查询 前端需要知道审批进度 可通过 app.get_state(config) 查询当前状态
回退机制 审批人误操作后如何撤回 使用时间旅行(4.7 节)回退到审批前的状态
审计日志 所有审批操作需要留痕 在节点中记录审批人、时间、决策等信息

4.2 Checkpointer(持久化和恢复)

为什么需要 Checkpointer?

没有 Checkpointer 的 Agent 就像没有存档的游戏------一旦中断,一切都得从头来。在生产环境中,以下场景必须依赖持久化:

没有 Checkpointer 的后果 有 Checkpointer 的保障
服务器重启后,Agent 从头开始 从中断点恢复,继续执行
多轮对话丢失历史,用户需要重复输入 同一 thread_id 共享历史,自动续接
Agent 执行一半崩溃,进度全丢 每步自动存档,随时回退
Human-in-the-Loop 暂停后无法恢复 配合 interrupt 暂停后可恢复
无法回溯历史决策(时间旅行不可能) 完整历史快照,任意回溯

核心类比:Checkpointer ≈ 游戏存档系统,thread_id ≈ 存档槽位。


Checkpointer 选型指南
类型 存储 持久化 并发安全 适用场景 推荐度
MemorySaver 内存 ❌ 重启丢失 ✅ 单进程安全 开发/测试、原型验证 ⭐ 学习用
SqliteSaver SQLite 文件 ✅ 文件持久化 ⚠️ 写锁粒度粗 单机部署、低并发场景 小规模使用
PostgresSaver PostgreSQL ✅ 数据库持久化 ✅ 行级锁 多实例部署、生产环境 ⭐⭐⭐ 生产首选
AsyncPostgresSaver PostgreSQL ✅ 数据库持久化 ✅ 异步安全 FastAPI/asyncio 异步架构 ⭐⭐⭐⭐ 异步生产首选

关键选型原则

  • 开发阶段用 MemorySaver,零配置,快速迭代
  • 生产环境必须用 PostgreSQL,确保数据不丢、并发安全
  • 异步框架(FastAPI)搭配 AsyncPostgresSaver,性能最优

1. MemorySaver(开发测试)
python 复制代码
"""
特点:零配置,内存存储,重启即丢失
适用:本地开发、单元测试、原型验证
"""

from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
app = graph.compile(checkpointer=memory)

# 使用示例
config = {"configurable": {"thread_id": "dev_test_001"}}
result = app.invoke({"messages": [...]}, config=config)

注意:MemorySaver 在单进程内是线程安全的,但如果用 Gunicorn 多 worker 运行,每个 worker 有独立的内存空间,状态无法共享。此时必须切换到 PostgreSQL。


2. SqliteSaver(单机持久化)
python 复制代码
"""
特点:文件持久化,重启不丢失,但写锁粒度粗(整个数据库级别)
适用:单机部署、低并发场景(并发写时可能遇到 "database is locked" 错误)
"""

from langgraph.checkpoint.sqlite import SqliteSaver

# 同步版本
with SqliteSaver.from_conn_string("./checkpoints.db") as checkpointer:
    app = graph.compile(checkpointer=checkpointer)
    result = app.invoke({"messages": [...]}, config=config)

# 异步版本(配合 asyncio 使用)
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver

async with AsyncSqliteSaver.from_conn_string("./checkpoints.db") as checkpointer:
    app = graph.compile(checkpointer=checkpointer)
    result = await app.ainvoke({"messages": [...]}, config=config)

常见问题OperationalError: database is locked ------ 说明有并发写操作。解决方案:切换到 PostgreSQL,或使用 WAL 模式 (PRAGMA journal_mode=WAL)。


3. PostgresSaver(生产环境,同步)
python 复制代码
"""
特点:数据库持久化,行级锁,支持并发读写
适用:多实例部署、生产环境首选

关键:必须使用连接池!
  ❌ 错误:每次请求新建连接(连接开销大,连接数有限)
  ✅ 正确:使用连接池复用连接(高性能,连接数可控)
"""

from langgraph.checkpoint.postgres import PostgresSaver
from psycopg_pool import ConnectionPool

# 连接池配置
connection_kwargs = {
    "autocommit": True,        # 自动提交,避免事务堆积
    "prepare_threshold": 0,    # 禁用 prepared statement 缓存(避免连接池问题)
}
pool = ConnectionPool(
    conninfo="postgresql://user:pass@localhost:5432/langgraph",
    max_size=20,               # 最大连接数(根据并发量调整)
    kwargs=connection_kwargs,
)

checkpointer = PostgresSaver(pool)
checkpointer.setup()  # 首次运行时创建表结构(只需执行一次!后续运行不需要)

app = graph.compile(checkpointer=checkpointer)

# 使用
config = {"configurable": {"thread_id": "user_prod_001"}}
result = app.invoke({"messages": [...]}, config=config)

生产环境连接池参数调优

  • max_size:根据 并发用户数 × 每个 Agent 的 checkpoint 写入频率 来设定,通常 10-50
  • autocommit=True:确保每次写入立即提交,避免长事务
  • prepare_threshold=0:在使用连接池时禁用 prepared statement 缓存,避免跨连接的 statement 冲突

4. AsyncPostgresSaver(生产环境,异步)
python 复制代码
"""
特点:PostgresSaver 的异步版本,配合 FastAPI / asyncio 使用
适用:异步 Web 框架、高并发场景

为什么用异步?
  同步版本的 checkpoint 读写会阻塞事件循环,
  在高并发下会成为性能瓶颈。异步版本彻底解决了这个问题。
"""

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool

async def create_async_app():
    """异步版本,配合 FastAPI 使用"""
    async with AsyncConnectionPool(
        conninfo="postgresql://user:pass@localhost:5432/langgraph",
        max_size=20,
        kwargs={"autocommit": True, "prepare_threshold": 0},
    ) as pool:
        checkpointer = AsyncPostgresSaver(pool)
        await checkpointer.setup()  # 创建表结构
        
        app = graph.compile(checkpointer=checkpointer)
        return app

异步 vs 同步性能对比(100 个并发 Agent 执行):

指标 同步 PostgresSaver 异步 AsyncPostgresSaver
总耗时 ~30s(阻塞等待) ~8s(非阻塞)
事件循环阻塞 严重
适合并发数 ≤ 10 ≤ 100+

thread_id 管理策略(生产关键)
python 复制代码
"""
thread_id 是多轮对话的核心概念,它决定了对话历史的隔离和共享。
设计不好会导致:
  - 对话历史混乱(不同用户的对话混在一起)
  - 用户数据泄露(A 用户看到了 B 用户的历史)
  - 存储爆炸(thread 永远不清理,数据越来越多)

推荐的 thread_id 设计策略:
"""

import uuid
from datetime import datetime

# 策略一:用户级别(一个用户一个 thread)
# 适合:个人助手、持续对话场景
# 优点:简单,用户始终从上次对话继续
# 缺点:对话历史无限增长(需要配合内存管理------见 4.8 节)
def get_user_thread_id(user_id: str) -> str:
    return f"user_{user_id}"

# 策略二:会话级别(每次新对话新 thread)
# 适合:客服系统、独立任务场景
# 优点:每次对话干净独立,历史不会无限增长
# 缺点:无法跨会话记忆(需要额外的长期记忆机制)
def create_session_thread_id(user_id: str) -> str:
    session_id = str(uuid.uuid4())[:8]
    return f"user_{user_id}_session_{session_id}"

# 策略三:任务级别(每个任务一个 thread)
# 适合:长时运行任务、审批流程
# 优点:每个任务独立追踪,互不干扰
# 缺点:任务结束后需要清理(见 Checkpointer 清理策略)
def create_task_thread_id(task_type: str) -> str:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    task_id = str(uuid.uuid4())[:8]
    return f"task_{task_type}_{timestamp}_{task_id}"

# 策略四:命名空间隔离(多租户场景)
# 适合:SaaS 产品,不同租户数据隔离
# 优点:数据严格隔离,防止越权访问
# 缺点:管理复杂度高
def get_tenant_thread_id(tenant_id: str, user_id: str, session_id: str) -> str:
    return f"tenant_{tenant_id}_user_{user_id}_session_{session_id}"

# 使用示例
config = {
    "configurable": {
        "thread_id": get_user_thread_id("user_12345"),
        # 可选:checkpoint_ns 用于子图隔离
        "checkpoint_ns": "main",
    }
}

安全注意事项

  • thread_id 不要包含敏感信息(如密码、token),因为它会存储在数据库中
  • 多租户场景务必使用命名空间隔离,防止数据泄露
  • 定期审计 thread_id 的使用,确保没有越权访问

多轮对话实战
python 复制代码
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import HumanMessage

memory = MemorySaver()
app = graph.compile(checkpointer=memory)

# 同一个 thread_id = 同一个对话,自动携带历史
config = {"configurable": {"thread_id": "user_001"}}

# 第一轮
result1 = app.invoke(
    {"messages": [HumanMessage(content="我叫张三,我在北京工作")]},
    config=config
)
print(result1["messages"][-1].content)
# → "你好张三!北京是个好地方..."

# 第二轮(自动带上第一轮的历史)
result2 = app.invoke(
    {"messages": [HumanMessage(content="我叫什么名字?在哪里工作?")]},
    config=config
)
print(result2["messages"][-1].content)
# → "你叫张三,在北京工作"  ← 记住了!

# 不同 thread_id = 全新对话,没有历史
config_new = {"configurable": {"thread_id": "user_002"}}
result3 = app.invoke(
    {"messages": [HumanMessage(content="我叫什么名字?")]},
    config=config_new
)
print(result3["messages"][-1].content)
# → "我不知道你的名字,你还没有告诉我"

查看和管理历史状态
python 复制代码
# 获取某个 thread 的所有历史快照(从新到旧)
history = list(app.get_state_history(config))
print(f"共 {len(history)} 个历史状态")

for snapshot in history:
    step = snapshot.metadata.get("step", "?")
    msg_count = len(snapshot.values.get("messages", []))
    ts = snapshot.metadata.get("created_at", "")
    print(f"  Step {step}: {msg_count} 条消息, 时间: {ts}")

# 获取当前状态
current = app.get_state(config)
print("当前消息数:", len(current.values["messages"]))
print("下一个节点:", current.next)  # 如果 Agent 暂停了,这里会显示下一步

# 手动更新状态(调试用------慎用!)
# as_node 参数指定"假装哪个节点产出了这条消息"
app.update_state(
    config,
    {"messages": [HumanMessage(content="[系统注入] 请记住用户是VIP")]},
    as_node="__start__"
)

# 从修改后的状态继续执行
result = app.invoke(None, config=config)

技术要点

  • get_state_history() 返回的是一个生成器,用 list() 转为列表
  • update_state()as_node 参数非常重要:它决定了这条消息在图中的"来源",影响后续条件边的路由逻辑
  • 调试完成后务必移除 update_state 调用,否则会注入非预期数据

生产环境:Checkpointer 清理策略
python 复制代码
"""
问题:长期运行后,checkpoint 数据会越来越多,需要定期清理

为什么需要清理?
  - 存储空间:每个 checkpoint 大约 1-10KB,长年累月可达 GB 级别
  - 查询性能:history 查询越来越慢
  - 合规要求:某些数据有保留期限限制

清理策略:
"""

from datetime import datetime, timedelta
import psycopg

# 策略一:按时间清理(删除 N 天前的 checkpoint)
async def cleanup_old_checkpoints(days: int = 30):
    """清理 30 天前的 checkpoint"""
    cutoff = datetime.now() - timedelta(days=days)
    
    async with await psycopg.AsyncConnection.connect(
        "postgresql://user:pass@localhost:5432/langgraph"
    ) as conn:
        # LangGraph 的 checkpoint 表结构
        await conn.execute(
            """
            DELETE FROM checkpoints 
            WHERE metadata->>'created_at' < %s
            """,
            (cutoff.isoformat(),)
        )
        print(f"已清理 {days} 天前的 checkpoint")

# 策略二:按数量清理(每个 thread 只保留最近 N 个快照)
async def cleanup_excess_checkpoints(max_per_thread: int = 50):
    """每个 thread 只保留最近 max_per_thread 个快照"""
    async with await psycopg.AsyncConnection.connect(
        "postgresql://user:pass@localhost:5432/langgraph"
    ) as conn:
        # 找出每个 thread 的 checkpoint 数量
        await conn.execute(
            """
            DELETE FROM checkpoints
            WHERE ctid IN (
                SELECT ctid FROM (
                    SELECT thread_id, ctid, 
                           ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
                    FROM checkpoints
                ) t
                WHERE rn > %s
            )
            """,
            (max_per_thread,)
        )
        print(f"每个 thread 只保留最近 {max_per_thread} 个快照")

# 策略三:按状态清理(删除已完成的任务的 checkpoint)
async def cleanup_completed_tasks():
    """删除已完成任务(不再需要恢复)的 checkpoint"""
    async with await psycopg.AsyncConnection.connect(
        "postgresql://user:pass@localhost:5432/langgraph"
    ) as conn:
        # 查找已完成的任务(next 为空且最后一条消息是 AI 回复)
        await conn.execute(
            """
            DELETE FROM checkpoints
            WHERE parent_id IS NULL  -- 不是子 checkpoint
            AND ctid IN (
                SELECT c.ctid FROM checkpoints c
                LEFT JOIN checkpoint_writes w ON c.thread_id = w.thread_id 
                    AND c.checkpoint_ns = w.checkpoint_ns
                    AND c.checkpoint_id = w.checkpoint_id
                WHERE w.task_id IS NULL  -- 没有挂起的写入(说明执行已完成)
                AND c.next IS NULL       -- 没有下一步(说明图已执行完毕)
            )
            """
        )
        print("已清理已完成任务的 checkpoint")

清理策略选择建议

策略 适用场景 风险 推荐度
按时间 有合规保留期限要求 可能误删还在使用的长期对话 ⭐⭐
按数量 大多数生产场景 保留数量太小时可能丢失重要回溯点 ⭐⭐⭐ 推荐
按状态 审批类等一次性任务 如果任务可能重启则不适用 ⭐⭐
组合策略 最完善 实现复杂 ⭐⭐⭐⭐

4.3 流式输出(Streaming)

为什么需要流式输出?

传统的 invoke() 是同步阻塞的------用户必须等待 Agent 完全执行完毕才能看到结果。在生产环境中,这会导致严重的用户体验问题:

不用流式输出的问题 流式输出的收益
用户盯着空白页面等 10-30 秒 第一个 token 在 1-2 秒内出现
不知道 Agent 在做什么(黑盒) 实时看到 Agent 的思考、工具调用过程
长时间无响应,用户以为页面卡死 逐步展示进度,用户知道系统在运行
无法取消正在执行的请求 可以在任意时刻取消

核心原则:生产级 Agent 必须支持流式输出,否则用户体验不可接受。


LangGraph 的四种流式模式对比
模式 粒度 输出内容 适用场景 推荐度
stream_mode="updates" 节点级别 每个节点执行完后的增量更新 调试、监控节点执行 ⭐⭐
stream_mode="values" 节点级别 每个节点执行完后的完整 State 状态追踪、审计 ⭐⭐
stream_mode="messages" 消息级别 只输出消息流(token 级别) 聊天场景首选 ⭐⭐⭐
astream_events() 事件级别 最细粒度,含 LLM token + 工具调用 生产级前端集成 ⭐⭐⭐⭐

模式一:updates(默认,节点级别)
python 复制代码
"""
原理:每个节点执行完后,输出该节点的增量更新
输出格式:{节点名: 该节点的输出增量}

适用:调试、监控节点执行过程
局限:粒度较粗,看不到 token 级别的流式
"""

config = {"configurable": {"thread_id": "stream_demo"}}

for chunk in app.stream(
    {"messages": [HumanMessage(content="北京天气怎么样?")]},
    config=config,
    stream_mode="updates"  # 默认
):
    # chunk = {节点名: 该节点的输出增量}
    for node_name, output in chunk.items():
        print(f"\n[节点: {node_name}]")
        if "messages" in output:
            for msg in output["messages"]:
                content = msg.content or "[工具调用]"
                print(f"  {msg.__class__.__name__}: {content[:80]}")

技术要点updates 模式输出的是节点的增量而非完整 State。如果一个节点修改了 State 的多个字段,updates 只包含该节点返回的变更部分。


模式二:values(完整 State)
python 复制代码
"""
原理:每个节点执行完后,输出完整的 State 对象
输出格式:完整的 State dict

适用:状态追踪、审计
局限:数据量大(包含完整历史),不适合前端展示
"""

for state in app.stream(
    {"messages": [HumanMessage(content="帮我搜索一下")]},
    config=config,
    stream_mode="values"
):
    # state = 完整的 State 对象
    last_msg = state["messages"][-1]
    print(f"当前最新消息: {last_msg.content[:80]}")

技术要点values 模式每次都返回完整 State,包括所有历史消息。如果对话很长,每次迭代的数据量会很大。生产环境不建议在前端直接使用,但非常适合后端审计和调试。


模式三:messages(聊天场景首选)⭐
python 复制代码
"""
原理:只输出消息流,粒度到单条消息的消息块(AIMessageChunk)
  - LLM 生成的文本:token 级别的流式
  - 工具调用:以完整的 tool_call 块为单位

输出格式:(消息块, 元数据) 的元组
  消息块 = AIMessageChunk 或 HumanMessage 等
  元数据 = {"langgraph_node": "agent", "langgraph_step": 1, ...}

适用:聊天界面、实时文本展示
"""

for msg, metadata in app.stream(
    {"messages": [HumanMessage(content="给我讲个故事")]},
    config=config,
    stream_mode="messages"
):
    # msg = 消息对象(可能是 AIMessageChunk,即 token 级别)
    # metadata = {"langgraph_node": "agent", "langgraph_step": 1, ...}
    if hasattr(msg, "content") and msg.content:
        print(msg.content, end="", flush=True)
print()  # 换行

技术要点

  • messages 模式的 msg 可能是 AIMessageChunk,它是 AIMessage 的子类,每次只包含一小部分内容(一个或几个 token)
  • 工具调用期间不会产生 token 流(因为 function call 不是文本生成),此时需要监听 on_tool_start 事件
  • 前端需要累积拼接 msg.content 来构建完整的 AI 回复

模式四:astream_events(最细粒度,生产首选)⭐⭐
python 复制代码
"""
原理:最细粒度的事件流,包含:
  - LLM 的 token 级流式(on_chat_model_stream)
  - 工具调用的开始和结束(on_tool_start / on_tool_end)
  - 节点的开始和结束(on_chain_start / on_chain_end)
  - 以及更多...

适用:生产级前端集成,需要精确控制展示内容
"""

async def stream_with_full_events():
    """完整的事件流,包含 LLM token 级别"""
    config = {"configurable": {"thread_id": "events_demo"}}
    
    async for event in app.astream_events(
        {"messages": [HumanMessage(content="帮我查一下今天的新闻")]},
        config=config,
        version="v2"  # 使用 v2 版本(推荐)
    ):
        event_type = event["event"]
        
        # LLM 开始生成
        if event_type == "on_chat_model_start":
            print(f"\n🤔 LLM 开始思考...")
        
        # LLM 流式 token
        elif event_type == "on_chat_model_stream":
            chunk = event["data"]["chunk"]
            if chunk.content:
                print(chunk.content, end="", flush=True)
        
        # LLM 生成完毕
        elif event_type == "on_chat_model_end":
            print(f"\n✅ LLM 生成完毕")
        
        # 工具开始调用
        elif event_type == "on_tool_start":
            tool_name = event["name"]
            tool_input = event["data"].get("input", {})
            print(f"\n🔧 调用工具: {tool_name}({tool_input})")
        
        # 工具调用完毕
        elif event_type == "on_tool_end":
            output = event["data"].get("output", "")
            print(f"📦 工具结果: {str(output)[:100]}")
        
        # 节点开始
        elif event_type == "on_chain_start":
            node = event.get("name", "")
            if node in ["agent", "tools"]:  # 只关注关键节点
                print(f"\n▶️  节点 [{node}] 开始")
        
        # 节点结束
        elif event_type == "on_chain_end":
            node = event.get("name", "")
            if node in ["agent", "tools"]:
                print(f"⏹️  节点 [{node}] 结束")

技术要点

  • version="v2" 是必须的,v1 已废弃
  • on_chat_model_stream 中的 chunkAIMessageChunk,可能包含 tool_call_chunks(工具调用的流式块)
  • 事件顺序:on_chain_starton_chat_model_starton_chat_model_stream(多次) → on_chat_model_endon_tool_starton_tool_endon_chain_end
  • 如果有多个并行节点,事件会交错出现,需要通过 metadata.langgraph_node 区分来源

与 FastAPI 集成:SSE(Server-Sent Events)
python 复制代码
"""
SSE 是最适合 Agent 流式输出的协议:
  - 服务器单向推送,客户端只读
  - 比 WebSocket 简单,比轮询高效
  - 浏览器原生支持(EventSource API)
  - 自动重连(EventSource 内置重连机制)

为什么选 SSE 而不是 WebSocket?
  ┌──────────────┬────────────────┬─────────────────┐
  │ 特性          │ SSE            │ WebSocket        │
  ├──────────────┼────────────────┼─────────────────┤
  │ 通信方向      │ 单向(服务端→客户端)│ 双向            │
  │ 协议          │ HTTP           │ WS              │
  │ 重连          │ 自动           │ 手动            │
  │ 复杂度        │ 低             │ 高              │
  │ 适合场景      │ Agent 流式输出  │ 实时协作/双向交互 │
  └──────────────┴────────────────┴─────────────────┘
  Agent 场景大多数是单向推送(服务端→客户端),SSE 足够且更简单。
"""

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
import json
import asyncio

app_api = FastAPI()

@app_api.post("/chat/stream")
async def chat_stream(user_message: str, thread_id: str):
    """流式聊天接口,使用 SSE"""
    
    async def generate():
        config = {"configurable": {"thread_id": thread_id}}
        
        async for event in agent_app.astream_events(
            {"messages": [HumanMessage(content=user_message)]},
            config=config,
            version="v2"
        ):
            event_type = event["event"]
            
            # 只推送 LLM token 和工具调用事件
            if event_type == "on_chat_model_stream":
                chunk = event["data"]["chunk"]
                if chunk.content:
                    # SSE 格式:data: {...}\n\n
                    data = json.dumps({
                        "type": "token",
                        "content": chunk.content
                    }, ensure_ascii=False)
                    yield f"data: {data}\n\n"
            
            elif event_type == "on_tool_start":
                data = json.dumps({
                    "type": "tool_start",
                    "tool": event["name"],
                    "input": str(event["data"].get("input", ""))[:200]
                }, ensure_ascii=False)
                yield f"data: {data}\n\n"
            
            elif event_type == "on_tool_end":
                data = json.dumps({
                    "type": "tool_end",
                    "tool": event["name"],
                    "output": str(event["data"].get("output", ""))[:200]
                }, ensure_ascii=False)
                yield f"data: {data}\n\n"
        
        # 发送结束信号
        yield f"data: {json.dumps({'type': 'done'})}\n\n"
    
    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # 禁用 Nginx 缓冲
        }
    )

# 前端 JavaScript 使用示例:
"""
const eventSource = new EventSource('/chat/stream?user_message=你好&thread_id=123');

eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    
    if (data.type === 'token') {
        // 实时追加 token 到界面
        document.getElementById('output').textContent += data.content;
    } else if (data.type === 'tool_start') {
        console.log(`调用工具: ${data.tool}`);
    } else if (data.type === 'done') {
        eventSource.close();
    }
};
"""

SSE 生产级注意事项

事项 问题 解决方案
Nginx 缓冲 Nginx 默认会缓冲响应,导致 SSE 不实时 响应头加 X-Accel-Buffering: no
连接超时 代理服务器/浏览器可能超时断开 定期发送心跳(data: {"type": "heartbeat"}\n\n
内存泄漏 客户端关闭连接后,服务端仍在生成 generate() 中检查 asyncio.current_task().cancelled()
并发限制 同时太多流式连接,服务端压力大 限制并发 SSE 连接数(如 asyncio.Semaphore(100)
错误处理 Agent 执行出错时,SSE 连接挂起 捕获异常,发送错误事件后关闭连接

与 WebSocket 集成(双向通信场景)
python 复制代码
"""
WebSocket 适合需要双向通信的场景:
  - 用户可以在 Agent 执行过程中发送新消息
  - 用户可以在 Agent 执行过程中取消任务
  - 需要实时状态同步

SSE vs WebSocket 选型:
  - 只需要单向推送(Agent → 前端)→ SSE
  - 需要双向交互(前端 ↔ Agent)→ WebSocket
"""

from fastapi import WebSocket, WebSocketDisconnect
import asyncio

@app_api.websocket("/ws/chat/{thread_id}")
async def websocket_chat(websocket: WebSocket, thread_id: str):
    await websocket.accept()
    
    try:
        while True:
            # 接收用户消息
            user_input = await websocket.receive_text()
            data = json.loads(user_input)
            
            if data["type"] == "message":
                config = {"configurable": {"thread_id": thread_id}}
                
                # 流式推送 Agent 响应
                async for event in agent_app.astream_events(
                    {"messages": [HumanMessage(content=data["content"])]},
                    config=config,
                    version="v2"
                ):
                    if event["event"] == "on_chat_model_stream":
                        chunk = event["data"]["chunk"]
                        if chunk.content:
                            await websocket.send_json({
                                "type": "token",
                                "content": chunk.content
                            })
                
                await websocket.send_json({"type": "done"})
            
            elif data["type"] == "cancel":
                # 取消当前任务(需要配合 asyncio.CancelledError)
                await websocket.send_json({"type": "cancelled"})
                break
    
    except WebSocketDisconnect:
        print(f"WebSocket 断开: {thread_id}")

WebSocket 生产级注意事项

  • 心跳机制:WebSocket 没有内置心跳,需要自己实现(每 30 秒发一次 ping)
  • 重连策略:客户端断开后需要指数退避重连
  • 消息幂等:网络不稳定时消息可能重复,后端需要幂等处理
  • 并发控制:一个 WebSocket 连接可能同时收到多条消息,需要串行处理

流式输出的常见陷阱
陷阱 原因 解决方案
工具调用时没有 token 流 工具调用是 function call,不是文本生成 监听 on_tool_start/on_tool_end 事件,展示"正在调用工具..."
stream_mode="messages" 收不到工具调用信息 messages 模式只输出消息,不输出工具调用过程 改用 astream_events,或同时监听 updates 模式
Nginx 缓冲导致 SSE 不实时 Nginx 默认会缓冲响应 响应头加 X-Accel-Buffering: no,或在 Nginx 配置 proxy_buffering off
多个节点的流式输出顺序混乱 并行节点的输出顺序不确定 在事件中携带节点名(langgraph_node),前端按节点分区展示
SSE 连接在代理后超时 CDN/反向代理有默认超时 发送心跳事件保持连接活跃
astream_events v1 vs v2 格式不同 v1 已废弃,但默认可能还是 v1 显式指定 version="v2"

4.4 子图(Subgraph)

为什么需要子图?

当 Agent 变得复杂时,将所有逻辑塞进一个扁平的图中会导致:

不用子图的问题 子图带来的收益
单个图过于庞大,难以维护 拆分为多个独立子图,各自独立开发和测试
不同功能的 State 混在一起 子图有独立 State,关注点分离
团队协作困难(所有人改同一个图) 不同团队负责不同子图
无法复用通用流程(如审批、搜索) 子图可以作为组件,在多个主图中复用

核心原则:子图 = 图中嵌套图,就像代码中的函数调用------每个子图有自己的"作用域"和"返回值"。


子图的三种集成方式
方式 适用场景 复杂度 灵活度
直接调用子图 invoke() 主图需要子图的完整结果
将子图作为节点添加 add_node("name", subgraph) 子图与主图共享 State
条件边中使用 Send 动态分发 动态并行,运行时决定数量 最高

方式一:直接调用子图(手动映射)
python 复制代码
"""
原理:主图节点中直接调用子图的 invoke/ainvoke,
     手动将主图数据映射到子图 State,再将结果映射回来。

类比:手动函数调用------自己传参,自己处理返回值。

优点:最直观,最灵活
缺点:映射代码需要手写,字段多时繁琐
"""

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage, AIMessage

# ===== 子图1: 研究子Agent =====

class ResearchState(TypedDict):
    messages: Annotated[list, add_messages]
    research_topic: str
    findings: str

def search_web(state: ResearchState) -> dict:
    topic = state["research_topic"]
    return {"findings": f"关于{topic}的研究发现: ..."}

def summarize_findings(state: ResearchState) -> dict:
    return {"messages": [AIMessage(content=f"研究总结: {state['findings']}")]}

# 构建研究子图
research_graph = StateGraph(ResearchState)
research_graph.add_node("search", search_web)
research_graph.add_node("summarize", summarize_findings)
research_graph.add_edge(START, "search")
research_graph.add_edge("search", "summarize")
research_graph.add_edge("summarize", END)
research_subgraph = research_graph.compile()

# ===== 子图2: 写作子Agent =====

class WritingState(TypedDict):
    messages: Annotated[list, add_messages]
    topic: str
    draft: str

def write_draft(state: WritingState) -> dict:
    return {"draft": f"关于{state['topic']}的文章草稿..."}

def polish_draft(state: WritingState) -> dict:
    return {"messages": [AIMessage(content=f"最终文章: {state['draft']}")]}

writing_graph = StateGraph(WritingState)
writing_graph.add_node("write", write_draft)
writing_graph.add_node("polish", polish_draft)
writing_graph.add_edge(START, "write")
writing_graph.add_edge("write", "polish")
writing_graph.add_edge("polish", END)
writing_subgraph = writing_graph.compile()

# ===== 主图: 编排子图 =====

class MainState(TypedDict):
    messages: Annotated[list, add_messages]
    task: str

def route_task(state: MainState) -> dict:
    """路由: 决定用哪个子Agent"""
    task = state["task"]
    if "研究" in task:
        # 调用研究子图(手动映射输入输出)
        result = research_subgraph.invoke({
            "messages": state["messages"],
            "research_topic": task,
            "findings": ""
        })
        return {"messages": result["messages"]}
    else:
        # 调用写作子图
        result = writing_subgraph.invoke({
            "messages": state["messages"],
            "topic": task,
            "draft": ""
        })
        return {"messages": result["messages"]}

main_graph = StateGraph(MainState)
main_graph.add_node("router", route_task)
main_graph.add_edge(START, "router")
main_graph.add_edge("router", END)
main_app = main_graph.compile()

技术要点:手动映射是最基本的方式。主图节点调用子图时,需要手动构造子图的输入 State,并从子图的输出 State 中提取需要的字段返回给主图。字段多时容易出错,建议使用方式二。


方式二:将子图作为节点(自动映射)
python 复制代码
"""
原理:直接把编译好的子图作为一个节点添加到主图中。
     LangGraph 会自动处理 State 的映射。

好处:
  - 不需要手写映射代码
  - 子图和主图之间的 State 自动衔接

注意:子图作为节点时,主图和子图的 State 字段名必须匹配。
     不匹配的字段会被丢弃(不会报错,只是静默忽略)。
"""

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage

# ===== 子图定义 =====

class SubgraphState(TypedDict):
    """子图 State------与主图共享 messages 和 task 字段"""
    messages: Annotated[list, add_messages]
    task: str               # 与主图共享
    sub_result: str         # 子图独有(主图没有这个字段)

def sub_node(state: SubgraphState) -> dict:
    task = state["task"]
    return {"sub_result": f"处理结果: {task}"}

sub_graph = StateGraph(SubgraphState)
sub_graph.add_node("process", sub_node)
sub_graph.add_edge(START, "process")
sub_graph.add_edge("process", END)
sub_app = sub_graph.compile()

# ===== 主图:直接添加子图为节点 =====

class MainState(TypedDict):
    messages: Annotated[list, add_messages]
    task: str               # 与子图共享

main_graph = StateGraph(MainState)
main_graph.add_node("sub_process", sub_app)  # 直接添加子图作为节点
main_graph.add_edge(START, "sub_process")
main_graph.add_edge("sub_process", END)
main_app = main_graph.compile()

# 使用
result = main_app.invoke({
    "messages": [HumanMessage(content="帮我处理这个任务")],
    "task": "分析数据"
})
print(result["messages"][-1].content)  # 包含子图的处理结果

技术要点 :子图作为节点时,LangGraph 自动把主图的 State 中与子图 State 同名的字段传入子图,再把子图输出中与主图 State 同名的字段传回主图。不同名的字段会被静默丢弃,这是最容易踩的坑。


方式三:Send API 动态并行(Map-Reduce 模式)
python 复制代码
"""
Send API 解决的是"动态并行"问题:
  - 静态并行:图编译时就确定了并行节点数量(如固定的 3 个搜索源)
  - 动态并行:运行时才知道并行数量(如搜索结果的每个 URL 并行抓取)

类比:
  静态并行 = 固定 3 条流水线
  动态并行 = 根据订单数量动态开启流水线

典型场景:
  - 对搜索结果的每个 URL 并行抓取内容
  - 对文档的每个章节并行总结
  - 对用户列表的每个用户并行发送通知
"""

from langgraph.types import Send
from typing import TypedDict, Annotated
from operator import add

class MapReduceState(TypedDict):
    documents: list[str]          # 输入:文档列表
    summaries: Annotated[list, add]  # 输出:每个文档的摘要(并行写入)
    final_report: str

class DocumentState(TypedDict):
    """每个并行任务的独立状态"""
    document: str
    summaries: Annotated[list, add]

def summarize_document(state: DocumentState) -> dict:
    """对单个文档生成摘要(并行执行)"""
    doc = state["document"]
    # 调用 LLM 生成摘要
    summary = llm.invoke(f"请用一句话总结:{doc[:500]}").content
    return {"summaries": [summary]}

def generate_report(state: MapReduceState) -> dict:
    """汇总所有摘要,生成最终报告"""
    all_summaries = "\n".join(state["summaries"])
    report = llm.invoke(f"根据以下摘要生成综合报告:\n{all_summaries}").content
    return {"final_report": report}

def dispatch_documents(state: MapReduceState):
    """
    关键:使用 Send 动态分发任务
    返回 Send 列表,每个 Send 对应一个并行任务
    
    Send 的第一个参数 = 目标节点名
    Send 的第二个参数 = 传给该节点的 State
    """
    return [
        Send("summarize", {"document": doc, "summaries": []})
        for doc in state["documents"]
    ]

# 构建 Map-Reduce 图
graph = StateGraph(MapReduceState)
graph.add_node("summarize", summarize_document)
graph.add_node("report", generate_report)

# 条件边:使用 Send 动态分发
graph.add_conditional_edges(START, dispatch_documents, ["summarize"])
graph.add_edge("summarize", "report")
graph.add_edge("report", END)

app = graph.compile()

# 运行:无论有多少文档,都会并行处理
result = app.invoke({
    "documents": [
        "文档一:关于人工智能的发展...",
        "文档二:关于量子计算的研究...",
        "文档三:关于气候变化的报告...",
        # 可以有任意多个
    ],
    "summaries": [],
    "final_report": ""
})
print(result["final_report"])

技术要点add_conditional_edges(START, dispatch_documents, ["summarize"]) 中,第三个参数 ["summarize"]必须的------它告诉 LangGraph 条件边可能路由到的所有目标节点。如果不指定,LangGraph 无法在编译时验证图的完整性。


并行执行的注意事项
陷阱 原因 解决方案
State 的 reducer 不支持并发写入 results: list 并行写入会覆盖 使用 results: Annotated[list, add] 让并行写入合并
并行节点的执行顺序不确定 LangGraph 不保证并行节点的执行顺序 汇聚节点负责整合结果,不要依赖顺序
并行节点共享 Checkpointer 每个并行分支都会保存 checkpoint 存储开销会成倍增加,注意清理
Send API 任务数量失控 太多并行任务会导致 LLM API 限流 加入并发限制(asyncio.Semaphore
python 复制代码
import asyncio
from langgraph.types import Send

# 带并发限制的 Send 模式
semaphore = asyncio.Semaphore(5)  # 最多同时 5 个并行任务

async def summarize_with_limit(state: DocumentState) -> dict:
    async with semaphore:
        doc = state["document"]
        summary = await llm.ainvoke(f"总结:{doc[:500]}")
        return {"summaries": [summary.content]}

4.5 错误处理和重试

为什么需要错误处理?

生产环境中,Agent 不是在理想条件下运行的。各种异常都可能发生:

异常类型 频率 不处理的后果
LLM API 超时/限流 高(尤其是高峰期) Agent 卡死,用户无限等待
外部工具/API 调用失败 Agent 崩溃,丢失上下文
LLM 输出格式不符合预期 后续节点解析失败,级联崩溃
Agent 无限循环 低但致命 CPU/GPU 持续满载,API 费用爆炸
内存不足/OOM 进程直接被杀死

核心原则 :生产级 Agent 必须假设一切都会出错,并为每种失败模式设计好恢复策略。


LangGraph 的五种错误处理机制
机制 粒度 解决的问题 推荐度
递归限制(recursion_limit) 图级别 防止无限循环 ⭐⭐⭐ 必用
节点内 try/except 节点级别 处理单个节点的异常 ⭐⭐⭐ 必用
工具内 try/except 工具级别 处理单个工具的异常 ⭐⭐⭐ 必用
重试装饰器(tenacity) 函数级别 自动重试可恢复的失败 ⭐⭐ 推荐
超时控制(asyncio.wait_for) 函数级别 防止操作无限挂起 ⭐⭐ 推荐

1. 递归限制(防止无限循环)
python 复制代码
"""
原理:限制 Agent 的最大执行步数。超过限制则抛出 GraphRecursionError。

为什么必须用?
  - LLM 可能陷入"调用工具 → 得到结果 → 再调用工具"的无限循环
  - 递归限制是最后一道防线,确保 Agent 不会无限运行

默认值:25 步(可以在 invoke 时通过 config 修改)
"""

from langgraph.errors import GraphRecursionError

app = graph.compile()

# 方式一:在 invoke 的 config 中设置
try:
    result = app.invoke(
        {"messages": [HumanMessage(content="...")]},
        config={"recursion_limit": 10}  # 最多执行10步
    )
except GraphRecursionError:
    print("Agent执行步骤过多,已强制停止")
    # 生产环境:记录日志,返回友好的错误消息
    # 而不是让异常泄露给用户

# 方式二:在编译时设置默认限制
app = graph.compile(
    recursion_limit=15  # 修改默认递归限制
)

技术要点

  • recursion_limit 不是"递归深度",而是"图中节点的最大执行次数"。每次一个节点被执行,就计数一次。
  • 如果 Agent 经常触发递归限制,说明图的设计有问题(如缺少退出条件),而不是简单增加限制值。
  • 最佳实践:先用较低的递归限制测试(如 10),确保 Agent 能在合理步数内完成任务,再根据实际情况调整。

2. 节点内错误处理
python 复制代码
"""
原理:在每个节点函数内部使用 try/except 捕获异常,
     返回友好的错误消息而不是让异常传播。

为什么需要?
  - LLM 调用可能抛出各种异常(RateLimitError, APIConnectionError 等)
  - 不捕获的话,异常会导致整个图的执行中断
  - 返回错误消息可以让 Agent "优雅地"处理问题
"""

from langchain_core.messages import AIMessage

def safe_agent_node(state: State) -> dict:
    """带错误处理的Agent节点"""
    try:
        response = llm_with_tools.invoke(state["messages"])
        return {"messages": [response]}
    except Exception as e:
        # 错误时返回友好消息,而不是让异常传播
        error_msg = AIMessage(content=f"抱歉,处理出错了: {str(e)}")
        return {"messages": [error_msg]}

技术要点

  • 捕获 Exception 而非更具体的异常类型,因为在生产环境中你无法预见所有可能的异常。
  • 错误消息应该包含足够的信息供调试,但对用户要友好。
  • 高级模式 :在 State 中增加 error_message 字段,节点出错时设置该字段,条件边根据该字段决定是重试还是终止。

3. 工具错误处理
python 复制代码
"""
原理:在每个 @tool 函数内部使用 try/except 捕获外部 API 调用的异常。

为什么需要?
  - 外部 API 可能超时、返回错误、格式不符
  - 工具抛异常会导致 Agent 的 LLM 无法正确处理
  - 返回错误字符串比抛异常更好(LLM 可以"读到"错误信息并调整策略)
"""

@tool
def risky_tool(query: str) -> str:
    """可能失败的工具"""
    try:
        # 可能抛异常的操作
        result = external_api_call(query)
        return result
    except TimeoutError:
        return "错误: API超时,请稍后重试"
    except ConnectionError:
        return "错误: 网络连接失败"
    except Exception as e:
        return f"错误: {str(e)}"

技术要点

  • 工具的异常绝对不能抛出------必须返回字符串错误消息。
  • LLM 会读到这个错误消息,并可能尝试不同的策略(如换一个工具、修改查询等)。
  • 区分不同异常类型有助于 LLM 做出更好的决策(超时 → 重试,权限错误 → 放弃)。

4. 带重试的节点
python 复制代码
"""
原理:使用 tenacity 库的 @retry 装饰器,对 LLM 调用进行自动重试。

什么时候需要重试?
  - LLM API 临时限流(429 Too Many Requests)
  - 网络抖动导致请求失败
  - LLM 输出格式偶尔不符(但这种情况重试可能也解决不了)

什么时候不需要重试?
  - 参数错误(如 prompt 太长),重试只会浪费 token
  - 工具逻辑错误,重试会重复同样的错误
"""

import time
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(3),              # 最多重试 3 次
    wait=wait_exponential(multiplier=1, min=1, max=10),  # 指数退避:1s, 2s, 4s, ...
    retry_error_callback=lambda x: AIMessage(content="多次重试后仍然失败")  # 重试耗尽后的回调
)
def call_llm_with_retry(messages):
    """带重试的LLM调用"""
    return llm.invoke(messages)

def agent_with_retry(state: State) -> dict:
    """带重试逻辑的Agent节点"""
    try:
        response = call_llm_with_retry(state["messages"])
        return {"messages": [response]}
    except Exception as e:
        return {"messages": [AIMessage(content=f"多次重试后仍然失败: {e}")]}

# 也可以直接在 LLM 对象上加重试
from langchain_openai import ChatOpenAI

llm_with_retry = ChatOpenAI(
    model="gpt-4o",
    max_retries=3,            # LangChain 内置重试支持
    timeout=30,               # 超时时间(秒)
    temperature=0
)

技术要点

  • ChatOpenAImax_retries 参数是 LangChain 内置的重试机制,对 OpenAI API 的 429/5xx 错误自动重试。
  • tenacity@retry 更灵活,可以自定义重试条件、等待策略等。
  • 指数退避是关键:第1次重试等1秒,第2次等2秒,第3次等4秒......避免在 API 限流时加重服务器负担。
  • 重试耗尽后的处理 很重要:retry_error_callback 确保重试耗尽后不会抛异常,而是返回一条友好的错误消息。

5. 超时控制
python 复制代码
"""
原理:使用 asyncio.wait_for 为异步操作设置超时。

为什么需要?
  - LLM 有时会"卡住"(特别是长上下文时)
  - 外部 API 可能永远不响应
  - 没有超时控制,Agent 会无限等待

超时后的行为:
  - asyncio.TimeoutError 被抛出
  - 节点可以捕获它并返回友好消息
"""

import asyncio

async def agent_with_timeout(state: State) -> dict:
    """带超时的Agent节点"""
    try:
        response = await asyncio.wait_for(
            llm.ainvoke(state["messages"]),
            timeout=30.0  # 30秒超时
        )
        return {"messages": [response]}
    except asyncio.TimeoutError:
        return {"messages": [AIMessage(content="处理超时,请重试")]}
    except Exception as e:
        return {"messages": [AIMessage(content=f"处理出错: {str(e)}")]}

# 对于同步调用,可以用 threading.Timer 或 signal.alarm(Linux)
# 但在 LangGraph 中,推荐使用异步版本

技术要点

  • asyncio.wait_for 只能用于异步函数(ainvoke),不能用于同步调用。
  • 超时时间要根据 LLM 的响应速度和输入长度来设定。复杂问题需要更长的超时。
  • 生产建议 :在 LLM 客户端级别设置超时(如 ChatOpenAI(timeout=30)),作为全局默认超时;在节点级别再用 wait_for 设置更长/更短的超时。

生产级错误处理策略
策略 实现方式 适用场景
基础防护 recursion_limit + 节点内 try/except 所有生产环境(必用)
自动重试 tenacity + ChatOpenAI(max_retries=3) LLM API 调用(推荐)
超时控制 asyncio.wait_for + ChatOpenAI(timeout=30) 防止无限等待(推荐)
降级处理 捕获异常后使用更简单的模型/工具 对可用性要求极高的场景
熔断机制 连续失败 N 次后跳过该节点 工具不稳定时的容错
错误上报 except 块中发送到监控平台 运维可观测性
python 复制代码
# 生产级示例:综合所有机制
from langchain_openai import ChatOpenAI
from langchain_core.messages import AIMessage
from tenacity import retry, stop_after_attempt, wait_exponential
import asyncio

# 1. LLM 客户端配置:内置重试和超时
llm = ChatOpenAI(
    model="gpt-4o",
    max_retries=3,      # OpenAI API 级别自动重试
    timeout=30,         # 单次请求超时
    temperature=0
)

# 2. 工具定义:带错误处理
@tool
def search_tool(query: str) -> str:
    """搜索工具(带错误处理)"""
    try:
        return search_api(query)
    except TimeoutError:
        return "搜索超时,请稍后重试"
    except Exception as e:
        return f"搜索失败: {str(e)}"

# 3. Agent 节点:带超时和降级
async def robust_agent(state: State) -> dict:
    """生产级 Agent 节点"""
    try:
        response = await asyncio.wait_for(
            llm.bind_tools(tools).ainvoke(state["messages"]),
            timeout=60.0  # 节点级别超时
        )
        return {"messages": [response]}
    except asyncio.TimeoutError:
        # 降级:用更快的模型重试
        try:
            fast_llm = ChatOpenAI(model="gpt-4o-mini", timeout=10)
            response = await fast_llm.ainvoke(state["messages"])
            return {"messages": [AIMessage(
                content=f"[降级响应] {response.content}"
            )]}
        except Exception:
            return {"messages": [AIMessage(content="系统繁忙,请稍后重试")]}

# 4. 递归限制:防止无限循环
app = graph.compile(recursion_limit=15)

4.6 并行执行(Parallel Nodes)

为什么需要并行?

Agent 在执行过程中经常需要同时执行多个独立任务:

串行执行的问题 并行执行的收益
3 个搜索源依次调用,耗时 9 秒 3 个搜索源并行调用,耗时 3 秒
用户等待时间长 响应速度提升 N 倍
顺序依赖导致延迟累加 独立任务同时完成,零等待

核心原则 :凡是互相独立的任务,都应该并行执行。只有存在依赖关系的任务才需要串行。


扇出-扇入模式(Fan-out / Fan-in)
python 复制代码
"""
原理:
  扇出(Fan-out):一个节点 → 多个并行节点(同时启动)
  扇入(Fan-in):多个并行节点 → 一个汇聚节点(全部完成后汇聚)

类比:
  扇出 = 项目经理同时给 3 个团队分配任务
  扇入 = 3 个团队都完成后,汇总给项目经理

关键:汇聚节点会等待所有并行节点完成后才执行。
"""

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add

class ParallelState(TypedDict):
    query: str
    results: Annotated[list, add]  # 多个节点的结果会合并(必须用 reducer!)

def search_google(state: ParallelState) -> dict:
    """搜索Google"""
    return {"results": [f"Google结果: {state['query']}..."]}

def search_bing(state: ParallelState) -> dict:
    """搜索Bing"""
    return {"results": [f"Bing结果: {state['query']}..."]}

def search_arxiv(state: ParallelState) -> dict:
    """搜索学术论文"""
    return {"results": [f"Arxiv结果: {state['query']}..."]}

def merge_results(state: ParallelState) -> dict:
    """合并所有搜索结果"""
    all_results = state["results"]
    summary = f"共找到{len(all_results)}个来源的结果"
    return {"results": [summary]}

# 构建并行图
graph = StateGraph(ParallelState)
graph.add_node("google", search_google)
graph.add_node("bing", search_bing)
graph.add_node("arxiv", search_arxiv)
graph.add_node("merge", merge_results)

# 扇出: START同时到3个搜索节点
graph.add_edge(START, "google")
graph.add_edge(START, "bing")
graph.add_edge(START, "arxiv")

# 扇入: 3个搜索节点都完成后到merge
graph.add_edge("google", "merge")
graph.add_edge("bing", "merge")
graph.add_edge("arxiv", "merge")

graph.add_edge("merge", END)

app = graph.compile()
result = app.invoke({"query": "LangGraph教程", "results": []})
print(result["results"])

技术要点results 字段必须使用 Annotated[list, add] reducer。因为三个搜索节点会并行写入 results,如果没有 reducer,后面的写入会覆盖前面的,只保留最后一个结果。add reducer 会把所有结果追加到列表中。


Send API:动态并行(运行时决定并行数量)
python 复制代码
"""
上面的"扇出"是静态的:图编译时就确定了有几个并行节点。
编译时确定 3 个搜索节点,运行时就不能变成 5 个。

Send API 解决的是动态并行问题:
  "我有一个列表,列表长度不确定,需要对每个元素并行处理"

典型场景:
  - 对搜索结果的每个 URL 并行抓取内容
  - 对文档的每个章节并行总结
  - 对用户列表的每个用户并行发送通知
"""

from langgraph.types import Send
from typing import TypedDict, Annotated
from operator import add

class MapReduceState(TypedDict):
    documents: list[str]          # 输入:文档列表
    summaries: Annotated[list, add]  # 输出:每个文档的摘要(并行写入)
    final_report: str

class DocumentState(TypedDict):
    """每个并行任务的独立状态"""
    document: str
    summaries: Annotated[list, add]

def summarize_document(state: DocumentState) -> dict:
    """对单个文档生成摘要(并行执行)"""
    doc = state["document"]
    # 调用 LLM 生成摘要
    summary = llm.invoke(f"请用一句话总结:{doc[:500]}").content
    return {"summaries": [summary]}

def generate_report(state: MapReduceState) -> dict:
    """汇总所有摘要,生成最终报告"""
    all_summaries = "\n".join(state["summaries"])
    report = llm.invoke(f"根据以下摘要生成综合报告:\n{all_summaries}").content
    return {"final_report": report}

def dispatch_documents(state: MapReduceState):
    """
    关键:使用 Send 动态分发任务
    返回 Send 列表,每个 Send 对应一个并行任务
    
    Send 的第一个参数 = 目标节点名
    Send 的第二个参数 = 传给该节点的 State
    """
    return [
        Send("summarize", {"document": doc, "summaries": []})
        for doc in state["documents"]
    ]

# 构建 Map-Reduce 图
graph = StateGraph(MapReduceState)
graph.add_node("summarize", summarize_document)
graph.add_node("report", generate_report)

# 条件边:使用 Send 动态分发
graph.add_conditional_edges(START, dispatch_documents, ["summarize"])
graph.add_edge("summarize", "report")
graph.add_edge("report", END)

app = graph.compile()

# 运行:无论有多少文档,都会并行处理
result = app.invoke({
    "documents": [
        "文档一:关于人工智能的发展...",
        "文档二:关于量子计算的研究...",
        "文档三:关于气候变化的报告...",
        # 可以有任意多个
    ],
    "summaries": [],
    "final_report": ""
})
print(result["final_report"])

技术要点add_conditional_edges(START, dispatch_documents, ["summarize"]) 中,第三个参数 ["summarize"]必须的 ------它告诉 LangGraph 条件边可能路由到的所有目标节点。如果不指定,LangGraph 无法在编译时验证图的完整性。dispatch_documents 返回的 Send 列表中的每个 Send("summarize", ...) 都会创建一个独立的 summarize 节点实例,各自拥有独立的 State。


并行执行的注意事项
陷阱 原因 解决方案
State 的 reducer 不支持并发写入 results: list 并行写入会覆盖 使用 results: Annotated[list, add] 让并行写入合并
并行节点的执行顺序不确定 LangGraph 不保证并行节点的执行顺序 汇聚节点负责整合结果,不要依赖顺序
并行节点共享 Checkpointer 每个并行分支都会保存 checkpoint 存储开销会成倍增加,注意清理
Send API 任务数量失控 太多并行任务会导致 LLM API 限流 加入并发限制(asyncio.Semaphore
python 复制代码
import asyncio
from langgraph.types import Send

# 带并发限制的 Send 模式
semaphore = asyncio.Semaphore(5)  # 最多同时 5 个并行任务

async def summarize_with_limit(state: DocumentState) -> dict:
    async with semaphore:
        doc = state["document"]
        summary = await llm.ainvoke(f"总结:{doc[:500]}")
        return {"summaries": [summary.content]}

4.7 动态断点和时间旅行(Time Travel)

为什么需要时间旅行?

Agent 的执行不总是一帆风顺的。在生产环境中,你经常需要"回到过去":

不用时间旅行的问题 时间旅行带来的能力
Agent 做了错误决策,只能从头来 回退到错误决策前的状态,重新来
调试困难,不知道 Agent 在哪一步出了问题 查看每一步的完整状态,精确定位问题
无法比较不同路径的结果 从同一状态出发,尝试不同路径(A/B 测试)
审批人误操作后无法撤回 回退到审批前的状态重新决策

核心原理:Checkpointer 保存了每一步的状态快照,时间旅行就是回到某个快照,从那里继续执行。


核心 API 详解
python 复制代码
from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
app = graph.compile(checkpointer=memory)
config = {"configurable": {"thread_id": "time_travel_demo"}}

# 1. 正常执行
result = app.invoke(
    {"messages": [HumanMessage(content="帮我分析一下这个问题")]},
    config=config
)

查看所有历史状态

python 复制代码
# get_state_history 返回一个生成器,包含从新到旧的所有快照
history = list(app.get_state_history(config))
print(f"共有 {len(history)} 个历史状态")

for i, state_snapshot in enumerate(history):
    print(f"  状态 {i}: step={state_snapshot.metadata.get('step')}, "
          f"messages={len(state_snapshot.values.get('messages', []))}, "
          f"checkpoint_id={state_snapshot.config['configurable'].get('checkpoint_id', 'N/A')}")

关键概念checkpoint_id 是每个快照的唯一标识。回到某个历史状态,就是指定对应的 checkpoint_id

回到某个历史状态

python 复制代码
# 选择一个历史状态
target_state = history[2]  # 回到第3个状态
target_checkpoint_id = target_state.config["configurable"]["checkpoint_id"]

# 从这个状态重新执行(关键:传入 checkpoint_id)
result = app.invoke(
    None,  # 不传新输入,沿用该快照的输入
    config={
        **config,
        "configurable": {
            **config["configurable"],
            "checkpoint_id": target_checkpoint_id  # 指定历史快照
        }
    }
)

手动修改 State 后继续

python 复制代码
# 获取当前状态
current = app.get_state(config)

# 修改状态(比如修正Agent的错误判断)
# as_node 参数指定"假装哪个节点产出了这条消息"
app.update_state(
    config,
    {"messages": [HumanMessage(content="不对,请重新考虑")]},
    as_node="human"  # 假装这是human节点的输出
)

# 从修改后的状态继续执行
result = app.invoke(None, config=config)

技术要点update_stateas_node 参数非常重要。它决定了这条消息在图中的"来源节点",会影响后续条件边的路由逻辑。如果 as_node 设置错误,可能会导致图执行到错误的分支。


四种使用场景

场景一:回退到历史状态重新执行(经典时间旅行)

python 复制代码
"""
场景:Agent 在第 3 步做出了错误决策(如调用了错误的工具),
     你想回到第 2 步的状态,让 Agent 重新决策。

适用:调试、纠错
"""

# 找到出错前的那个状态
for state_snapshot in app.get_state_history(config):
    if state_snapshot.metadata.get("step") == 2:
        target_checkpoint_id = state_snapshot.config["configurable"]["checkpoint_id"]
        break

# 从该状态重新执行
result = app.invoke(
    None,
    config={**config, "configurable": {**config["configurable"], "checkpoint_id": target_checkpoint_id}}
)

场景二:手动修改 State 后继续(调试模式)

python 复制代码
"""
场景:Agent 在某个节点返回了不满意的结果,
     你想手动修改 State(如注入新的提示词),然后继续执行。

适用:调试、A/B 测试
"""

# 在某个节点后暂停,修改 State
app.update_state(
    config,
    {
        "messages": [AIMessage(content="请换一种方式思考")]
    },
    as_node="agent"  # 假装是 agent 节点的输出
)

# 从修改后的 State 继续
result = app.invoke(None, config=config)

场景三:从同一状态分叉(A/B 测试)

python 复制代码
"""
场景:你想从同一个状态出发,尝试不同的路径(如不同的提示词),
     比较哪条路径的结果更好。

适用:A/B 测试、提示词优化、策略比较

关键:每次使用不同的 thread_id 来隔离不同的路径
"""

# 获取当前状态
current_state = app.get_state(config)

# 路径 A:使用原始提示词
config_a = {"configurable": {"thread_id": "ab_test_path_a"}}
# 先把当前状态"复制"到新的 thread
app.update_state(config_a, current_state.values, as_node="__start__")
result_a = app.invoke(None, config=config_a)

# 路径 B:修改提示词
config_b = {"configurable": {"thread_id": "ab_test_path_b"}}
app.update_state(config_b, current_state.values, as_node="__start__")
app.update_state(
    config_b,
    {"messages": [SystemMessage(content="请用更简洁的方式回答")]},
    as_node="agent"
)
result_b = app.invoke(None, config=config_b)

# 比较两条路径的结果
print(f"路径A: {result_a['messages'][-1].content}")
print(f"路径B: {result_b['messages'][-1].content}")

场景四:Human-in-the-Loop 审批回退

python 复制代码
"""
场景:审批人误点了"拒绝",但实际上应该"通过"。
     使用时间旅行回退到审批前的状态,重新审批。

适用:审批纠错、误操作恢复
"""

# 获取审批前的状态(找到 human_review 节点之前的快照)
for state_snapshot in app.get_state_history(config):
    node = state_snapshot.metadata.get("source", "")
    if node == "human_review":
        target_checkpoint_id = state_snapshot.parent_config["configurable"]["checkpoint_id"]
        break

# 回退到审批前
result = app.invoke(
    None,
    config={**config, "configurable": {**config["configurable"], "checkpoint_id": target_checkpoint_id}}
)

# 这次选择"通过"
result = app.invoke(Command(resume="approve"), config=config)

时间旅行的注意事项
事项 说明 建议
checkpoint_id 必须准确 错误的 ID 会导致回到错误的状态 使用 get_state_history 获取准确的 ID
回退后的执行是"追加"而非"覆盖" 回退后继续执行,新的 checkpoint 会在历史之上追加 这意味着存储会持续增长,需要清理策略
update_stateas_node 要正确 错误的 as_node 会导致条件边路由到错误的分支 仔细确认消息的"来源节点"
回退会重置 next 节点 回退到某个快照后,next 会变成该快照记录的下一个节点 确保你理解回退后图会从哪个节点继续
并发修改可能冲突 两个请求同时修改同一个 thread 的状态 使用 checkpointer 的乐观锁机制(PostgresSaver 支持)

4.8 长对话内存管理(Memory Management)

为什么需要内存管理?

随着对话越来越长,messages 列表会无限增长,引发一系列问题:

问题 原因 后果
Token 超限 LLM 有上下文窗口限制(GPT-4: 128K, Claude: 200K) 超出后 API 报错,Agent 崩溃
推理变慢 上下文越长,LLM 处理越慢 响应时间从秒级变分钟级
成本增加 Token 数 = 计费单位 100 轮对话的成本是 1 轮的 100 倍
注意力稀释 LLM 对早期内容的关注度降低 "遗忘"了用户最开始的需求
存储膨胀 Checkpointer 保存所有历史 磁盘占用持续增长

核心原则 :生产级 Agent 必须有内存管理策略,不能让消息无限增长。


四种策略对比
策略 原理 优点 缺点 适用场景 推荐度
滑动窗口 只保留最近 N 条消息 简单,效果好 丢失早期上下文 短期对话 ⭐⭐⭐
摘要压缩 把旧消息压缩成摘要 保留核心信息 摘要可能丢失细节 长期对话 ⭐⭐⭐
重要性过滤 只保留重要消息 减少 token 最有效 需要额外判断逻辑 高选择性场景 ⭐⭐
混合策略 摘要 + 最近 N 条 兼顾历史和近期 实现最复杂 生产首选 ⭐⭐⭐⭐

策略一:滑动窗口(最简单)
python 复制代码
"""
原理:只保留最近 N 条消息(或最近 N 个 token),
     丢弃更早的消息。

优点:实现简单,效果直观
缺点:丢失早期上下文,Agent "忘记"了对话开始时说的内容

适用:短期对话、工具调用为主的场景(不需要记住太久前的事)
"""

from langchain_core.messages import trim_messages, SystemMessage

def agent_node_with_trim(state: State) -> dict:
    """只保留最近的消息"""
    # trim_messages: LangChain 内置的消息裁剪工具
    trimmed = trim_messages(
        state["messages"],
        max_tokens=4000,           # 最大 token 数
        strategy="last",           # 保留最后的消息("first" 保留最前面的)
        token_counter=llm,         # 用 LLM 计算 token 数
        include_system=True,       # 保留 system 消息
        allow_partial=False,       # 不允许截断单条消息
        start_on="human",          # 从 human 消息开始(保证对话完整性)
    )
    response = llm_with_tools.invoke(trimmed)
    return {"messages": [response]}

技术要点

  • include_system=True 确保 SystemMessage 永远不被丢弃(SystemMessage 通常包含角色设定和行为约束)
  • start_on="human" 确保裁剪后的第一条消息是 HumanMessage,避免出现"AI 凭空说话"的情况
  • strategy="last" 保留最新的消息,这是最常用的策略
  • allow_partial=False 确保不会把一条消息切成两半(可能导致 LLM 理解异常)

策略二:摘要压缩(推荐长期对话使用)
python 复制代码
"""
原理:当消息过多时,把旧消息压缩成一段摘要,
     只保留摘要 + 最近几条完整消息。

优点:保留了历史对话的核心信息,不丢失关键上下文
缺点:摘要过程本身消耗 token;摘要可能丢失细节

适用:长期对话、需要记住早期信息的场景(如客服、心理咨询)
"""

from langchain_core.messages import RemoveMessage

class SummaryState(TypedDict):
    messages: Annotated[list, add_messages]
    summary: str  # 历史对话的摘要

def summarize_conversation(state: SummaryState) -> dict:
    """当消息过多时,压缩旧消息为摘要"""
    summary = state.get("summary", "")
    
    # 构建摘要请求
    if summary:
        summary_prompt = f"这是之前对话的摘要:{summary}\n\n请在此基础上,继续总结以下新的对话内容:"
    else:
        summary_prompt = "请总结以下对话的主要内容:"
    
    messages = state["messages"] + [HumanMessage(content=summary_prompt)]
    new_summary = llm.invoke(messages).content
    
    # 删除旧消息(只保留最近 2 条)
    messages_to_delete = state["messages"][:-2]
    delete_ops = [RemoveMessage(id=m.id) for m in messages_to_delete]
    
    return {
        "summary": new_summary,
        "messages": delete_ops  # RemoveMessage 会触发删除操作
    }

def should_summarize(state: SummaryState) -> str:
    """判断是否需要压缩"""
    messages = state["messages"]
    # 超过 10 条消息时压缩
    if len(messages) > 10:
        return "summarize"
    return "agent"

# 在图中加入摘要节点
graph = StateGraph(SummaryState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tools_node)
graph.add_node("summarize", summarize_conversation)

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue_or_summarize)
graph.add_edge("tools", "agent")
graph.add_edge("summarize", "agent")

技术要点

  • RemoveMessage(id=m.id) 是 LangChain 的特殊消息类型,它告诉 add_messages reducer 删除对应 ID 的消息
  • 摘要节点返回 {summary: new_summary, messages: delete_ops}add_messages 会先执行删除操作,再添加新消息
  • 摘要的质量取决于 LLM 的能力,复杂对话建议使用更强大的模型(如 GPT-4)做摘要

策略三:在 Agent 节点中注入摘要
python 复制代码
"""
原理:不删除旧消息,而是在调用 LLM 时,
     把摘要作为 SystemMessage 注入到消息列表前面。

优点:不需要复杂的删除操作
缺点:消息列表仍然很长(只是 LLM 看到的输入被压缩了)

适用:不想修改消息列表的场景
"""

def agent_with_summary(state: SummaryState) -> dict:
    """把摘要注入到系统消息中"""
    summary = state.get("summary", "")
    
    messages = state["messages"]
    if summary:
        # 把摘要作为系统消息注入
        system_msg = SystemMessage(
            content=f"以下是之前对话的摘要,请参考:\n{summary}"
        )
        messages = [system_msg] + messages
    
    response = llm_with_tools.invoke(messages)
    return {"messages": [response]}

技术要点 :这种方式不会修改 State 中的 messages 列表,只是在调用 LLM 时临时构造输入。因此不会触发 Checkpointer 的写入,也不会影响时间旅行。


策略四:混合策略(生产首选)⭐⭐⭐⭐
python 复制代码
"""
原理:结合摘要压缩和滑动窗口的优点
  1. 旧消息 → 压缩为摘要(保留核心信息)
  2. 新消息 → 保持原样(保留完整上下文)
  3. LLM 输入 = 摘要 + 最近 N 条完整消息

这是生产环境最推荐的策略,因为它:
  - 不会丢失重要的历史信息(摘要保留了要点)
  - 最近的对话保持完整(不裁剪最近的内容)
  - Token 使用可控(摘要 + 窗口 = 固定大小)
"""

class HybridState(TypedDict):
    messages: Annotated[list, add_messages]
    summary: str           # 历史对话摘要
    recent_count: int      # 保留最近几条完整消息(默认 6)

def agent_hybrid(state: HybridState) -> dict:
    """混合策略:摘要 + 最近 N 条"""
    summary = state.get("summary", "")
    recent_count = state.get("recent_count", 6)
    
    # 构造 LLM 输入
    messages = []
    if summary:
        messages.append(SystemMessage(
            content=f"以下是之前对话的摘要:\n{summary}"
        ))
    # 只取最近 N 条完整消息
    messages.extend(state["messages"][-recent_count:])
    
    response = llm_with_tools.invoke(messages)
    return {"messages": [response]}

def maybe_summarize(state: HybridState) -> dict:
    """当消息超过阈值时,更新摘要并删除旧消息"""
    if len(state["messages"]) <= 10:  # 10 条以内不需要压缩
        return {}
    
    # 更新摘要
    old_summary = state.get("summary", "")
    if old_summary:
        prompt = f"当前摘要:{old_summary}\n\n请结合以下新对话更新摘要:"
    else:
        prompt = "请总结以下对话:"
    
    # 取最近的对话(不包括最前面的几条,因为它们会被删除)
    recent_msgs = state["messages"][:-6]  # 保留最近 6 条,其余压缩
    all_msgs = recent_msgs + [HumanMessage(content=prompt)]
    new_summary = llm.invoke(all_msgs).content
    
    # 删除被压缩的旧消息
    delete_ops = [RemoveMessage(id=m.id) for m in state["messages"][:-6]]
    
    return {
        "summary": new_summary,
        "messages": delete_ops
    }

生产建议

  • recent_count 建议设为 4-10(取决于平均消息长度和模型的上下文窗口大小)
  • 摘要更新不要太频繁------每次更新摘要都要消耗 LLM token,建议在消息数超过阈值(如 10 条)时才触发
  • 摘要的质量直接影响 Agent 的"记忆力"------如果摘要不好,Agent 就像失忆了一样

实用工具:消息 Token 统计
python 复制代码
from langchain_openai import ChatOpenAI
from langchain_core.messages import get_buffer_string

def count_tokens(messages: list, model: str = "gpt-4o-mini") -> int:
    """统计消息列表的 token 数"""
    llm = ChatOpenAI(model=model)
    return llm.get_num_tokens_from_messages(messages)

def get_messages_size_info(messages: list) -> dict:
    """获取消息列表的大小信息"""
    total_chars = sum(len(str(m.content)) for m in messages)
    estimated_tokens = total_chars // 4  # 粗略估算:4字符≈1token
    
    return {
        "message_count": len(messages),
        "total_chars": total_chars,
        "estimated_tokens": estimated_tokens,
        "warning": estimated_tokens > 100000  # 超过 10 万 token 警告
    }

经验值

  • 一条典型的 HumanMessage + AIMessage 对话约 200-500 tokens
  • 一个工具调用(含结果)约 100-1000 tokens
  • GPT-4o-mini 的 128K 上下文大约能容纳 200-500 轮对话
  • 当 token 数接近上下文窗口的 80% 时,就应该触发内存管理

4.9 State Schema 设计最佳实践

为什么 State 设计很重要?

State 是 LangGraph 的数据核心------图的每个节点读写 State,边根据 State 路由,Checkpointer 保存 State。设计糟糕的 State 会导致:

问题 原因 后果
并行写入数据丢失 忘记定义 reducer 后写入的覆盖先写入的
类型错误难以排查 用 dict 代替 TypedDict IDE 无法提示,运行时才报错
State 膨胀 把所有东西都塞进 messages Token 浪费,LLM 性能下降
维护困难 字段过多、命名不规范 改一个字段影响多个节点

核心原则 :State 应该精简、类型安全、关注点分离


四大常见错误及其后果
python 复制代码
# ❌ 错误一:把所有东西都塞进 messages
class BadState1(TypedDict):
    messages: Annotated[list, add_messages]  # 对话历史 + 工具调用结果 + 用户信息 + ...

# 问题:messages 太长,LLM 被"噪音"干扰;难以单独更新某类信息

# ✅ 正确:分离关注点
class GoodState1(TypedDict):
    messages: Annotated[list, add_messages]  # 只放对话历史
    user_info: dict                           # 用户信息单独字段
    tool_results: Annotated[list, add]        # 工具结果单独字段

# ❌ 错误二:用 dict 代替 TypedDict
class BadState2(dict):  # 失去类型检查,IDE 无法提示字段名
    pass

# ✅ 正确:始终使用 TypedDict
class GoodState2(TypedDict):
    messages: Annotated[list, add_messages]

# ❌ 错误三:忘记定义 reducer
class BadState3(TypedDict):
    search_results: list  # 并行写入时会覆盖!

# ✅ 正确:对可能并行写入的字段定义 reducer
class GoodState3(TypedDict):
    search_results: Annotated[list, add]  # 并行写入会合并

# ❌ 错误四:State 字段过多
class BadState4(TypedDict):
    messages: Annotated[list, add_messages]
    field1: str
    field2: int
    # ... 20 多个字段 ...
    field20: Optional[dict]

# 问题:难以维护,一个节点改了一个字段,另一个节点不知道

# ✅ 正确:按职责分组,每个 State 不超过 7±2 个字段

基础 State 模板
python 复制代码
from typing import TypedDict, Annotated, Optional, Literal
from langgraph.graph.message import add_messages
from operator import add
from datetime import datetime

class BaseAgentState(TypedDict):
    """
    所有 Agent State 的基础模板
    
    只包含最核心的字段------对话历史。
    其他业务字段应该在子类中按需添加。
    """
    messages: Annotated[list, add_messages]  # 对话历史,必须用 add_messages reducer

设计哲学messages 是 LangGraph Agent 的"心脏"------几乎所有的 Agent 交互都通过消息完成。它是唯一必须add_messages reducer 的字段(因为 LangGraph 的 tool 节点默认往 messages 里写入工具调用结果)。


生产级 State 设计示例详解
python 复制代码
from typing import TypedDict, Annotated, Optional, Literal
from langgraph.graph.message import add_messages
from operator import add
from datetime import datetime

class CustomerServiceState(TypedDict):
    """
    客服 Agent 的完整 State 设计
    
    设计原则:
    1. 对话历史用 messages(必须)
    2. 用户信息只读(不被节点修改,通过 config 传入)
    3. 任务状态清晰(active → pending_human → completed/failed)
    4. 业务数据按需填充(节点查询后才赋值)
    5. 流程控制字段明确(retry_count, error_message)
    6. 并行结果用 reducer(Annotated[list, add])
    7. 元数据记录时间(session_start, last_updated)
    """
    
    # === 1. 对话历史(必须用 add_messages)===
    # 为什么必须?因为 LangGraph 的 tool 节点会把工具调用结果追加到 messages
    # 如果不用 add_messages reducer,工具调用结果会覆盖对话历史
    messages: Annotated[list, add_messages]
    
    # === 2. 用户信息(只读,不会被节点修改)===
    # 这些字段通常在 invoke 时通过 config 传入,节点只读取不修改
    user_id: str
    user_name: str
    user_tier: Literal["free", "pro", "enterprise"]  # 字面量类型,限制取值范围
    
    # === 3. 当前任务状态 ===
    current_intent: Optional[str]          # 用户意图:refund/inquiry/complaint
    task_status: Literal["active", "pending_human", "completed", "failed"]
    
    # === 4. 业务数据(节点查询后填充)===
    order_info: Optional[dict]             # 订单信息(查询订单节点填充)
    refund_amount: Optional[float]         # 退款金额(计算退款节点填充)
    
    # === 5. 流程控制 ===
    retry_count: int                        # 重试次数(默认 0,每次重试 +1)
    error_message: Optional[str]           # 错误信息(出错时设置,供后续节点参考)
    
    # === 6. 并行任务结果(需要 reducer)===
    # 多个搜索节点并行执行,结果需要合并而不是覆盖
    search_results: Annotated[list, add]   # 用 add reducer 合并并行搜索结果
    
    # === 7. 元数据 ===
    session_start: str                     # 会话开始时间(ISO 8601 格式)
    last_updated: str                      # 最后更新时间(节点执行后更新)

Reducer 的各种用法详解
python 复制代码
from typing import Any

# === 1. keep_last:覆盖语义(默认行为)===
# 场景:只需要最新值,旧值不需要保留
# 典型用途:当前步骤、最新状态
def keep_last(existing: Any, new: Any) -> Any:
    """只保留最新值(覆盖)"""
    return new

# 使用示例
class ExampleState(TypedDict):
    messages: Annotated[list, add_messages]
    current_step: Annotated[str, keep_last]  # 只保留最新的步骤名
    # 如果节点 A 设置 current_step="search",节点 B 设置 current_step="summarize"
    # 最终 current_step = "summarize"(后者覆盖前者)

# === 2. merge_dicts:合并语义 ===
# 场景:新旧字典需要合并(新字典的键覆盖旧字典的同名键)
# 典型用途:元数据、配置信息
def merge_dicts(existing: dict, new: dict) -> dict:
    """合并字典"""
    return {**existing, **new}

# 使用示例
class ExampleState(TypedDict):
    metadata: Annotated[dict, merge_dicts]  # 字典合并
    # 如果节点 A 设置 metadata={"source": "web"},节点 B 设置 metadata={"lang": "en"}
    # 最终 metadata = {"source": "web", "lang": "en"}

# === 3. max_value:最大值语义 ===
# 场景:只关心最大值(如最多重试次数、最高分数)
# 典型用途:重试计数、评分
def max_value(existing: int, new: int) -> int:
    """取最大值"""
    return max(existing, new)

# 使用示例
class ExampleState(TypedDict):
    max_retries_seen: Annotated[int, max_value]  # 记录遇到的最大重试次数
    # 如果节点 A 报告 max_retries_seen=3,节点 B 报告 max_retries_seen=5
    # 最终 max_retries_seen = 5(取最大值)

# === 4. deduplicate:去重合并 ===
# 场景:列表需要合并,但要去除重复项
# 典型用途:已访问 URL、已处理文档
def deduplicate(existing: list, new: list) -> list:
    """去重合并"""
    seen = set()
    result = []
    for item in existing + new:
        key = str(item)  # 用字符串作为去重键(适用于 URL、文件路径等)
        if key not in seen:
            seen.add(key)
            result.append(item)
    return result

# 使用示例
class ExampleState(TypedDict):
    visited_urls: Annotated[list, deduplicate]  # 去重添加已访问 URL
    # 节点 A 添加 ["url1", "url2"],节点 B 添加 ["url2", "url3"]
    # 最终 visited_urls = ["url1", "url2", "url3"](url2 不重复)

# === 5. add:追加语义 ===
# 场景:简单追加,不去重
# 典型用途:工具输出、日志、搜索结果
class ExampleState(TypedDict):
    tool_outputs: Annotated[list, add]  # 简单追加(与 add_messages 类似,但不限制消息类型)

# === 综合:一个使用多种 Reducer 的 State ===
class AdvancedState(TypedDict):
    messages: Annotated[list, add_messages]
    
    # 覆盖语义(默认行为)
    current_step: Annotated[str, keep_last]
    
    # 合并语义
    metadata: Annotated[dict, merge_dicts]
    
    # 最大值语义
    max_retries_seen: Annotated[int, max_value]
    
    # 去重合并
    visited_urls: Annotated[list, deduplicate]
    
    # 追加语义(add_messages 的通用版本)
    tool_outputs: Annotated[list, add]

技术要点

  • 选择 reducer 的核心问题:当多个节点并行写入同一个字段时,如何处理? 覆盖?合并?追加?
  • 如果字段不会被并行写入,可以不用 Annotated,直接声明类型(默认就是 keep_last 覆盖语义)
  • 最常见错误 :该用 add 的地方用了默认的覆盖语义,导致并行写入时数据丢失

子图的 State 设计:输入/输出映射
python 复制代码
"""
子图有自己的 State,主图需要把数据"映射"进去,再把结果"映射"出来。
这是子图最容易出错的地方。

核心问题:主图的 State 和子图的 State 字段名不同时怎么办?
答案:需要手动映射(方式一)或确保字段名匹配(方式二)
"""

class MainState(TypedDict):
    messages: Annotated[list, add_messages]
    task: str           # 主图用 "task"
    result: str         # 主图用 "result"

class SubgraphState(TypedDict):
    messages: Annotated[list, add_messages]
    input_data: str     # 子图用 "input_data"(对应主图的 task)
    output_data: str    # 子图用 "output_data"(对应主图的 result)

# ===== 方式一:手动映射(在调用子图的节点中做转换)=====
def call_subgraph(state: MainState) -> dict:
    """在主图节点中调用子图,手动做数据映射"""
    # 映射:主图 → 子图
    subgraph_input = {
        "messages": state["messages"],
        "input_data": state["task"],        # 主图的 task → 子图的 input_data
        "output_data": ""                   # 初始化为空
    }
    
    result = subgraph.invoke(subgraph_input)
    
    # 映射:子图 → 主图
    return {
        "messages": result["messages"],
        "result": result["output_data"]     # 子图的 output_data → 主图的 result
    }

# ===== 方式二:直接添加子图为节点(自动映射)=====
# 如果子图的 State 字段名与主图完全匹配,则无需手动映射
# LangGraph 自动把同名字段传入子图,再把子图输出的同名字段传回主图
# 不同名的字段会被静默丢弃!

# 最佳实践:设计子图 State 时,确保与主图共享的字段名完全一致

技术要点

  • 子图作为节点时,LangGraph 自动匹配同名字段。不同名字段不报错,只是静默丢弃。
  • 最佳实践:设计子图 State 时,确保与主图共享的字段名完全一致。可以在子图中同时定义两种名字(兼容主图和子图),但这样会增加复杂度。
  • 手动映射虽然繁琐,但更灵活(可以做字段转换、数据清洗等)。

State 设计的注意事项
事项 说明 建议
字段数量 太多字段难以维护 每个State不超过7±2个字段
命名规范 随意命名导致混乱 统一用snake_case,见名知意
默认值 没有默认值导致KeyError 用Optional或设默认值
Reducer选择 用错reducer导致数据丢失 仔细考虑并行写入场景
不可变字段 节点意外修改了只读字段 用Annotated标记只读,节点中不要修改
敏感信息 State中保存了密码/token 不要在State中保存敏感信息,用config传入

复制代码
sub_result = subgraph_app.invoke({
    "messages": state["messages"],
    "input_data": state["task"],
    "output_data": ""
})
# 映射:子图 → 主图
return {
    "result": sub_result["output_data"],
    "messages": sub_result["messages"]
}


### 4.10 RunnableConfig------运行时配置化

#### 为什么需要运行时配置?

不同用户/租户/场景需要不同的 Agent 行为:

| 不用配置化的问题 | 配置化带来的收益 |
|--------------|--------------|
| 模型硬编码在图里,换模型就要改代码 | 运行时选择模型,不改代码 |
| 所有用户行为一样(免费用户也能无限调用工具) | 按用户等级限制行为 |
| 系统提示词写死在节点里 | 按租户/场景动态注入提示词 |
| 无法做 A/B 测试 | 不同用户用不同配置,对比效果 |

**核心原则**:图的逻辑是通用的,具体行为由运行时配置决定------**代码与配置分离**。

---

#### configurable 字段详解

```python
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic

# ============ 定义可配置的 Agent ============

def create_configurable_agent(state: State, config: RunnableConfig) -> dict:
    """根据运行时配置,动态调整 Agent 行为"""
    
    # 从 config 中读取运行时参数
    configurable = config.get("configurable", {})
    
    # 1. 模型选择:运行时决定用哪个模型
    model_name = configurable.get("model", "gpt-4o-mini")
    
    # 2. 工具调用限制:按用户等级限制
    max_tool_calls = configurable.get("max_tool_calls", 5)
    
    # 3. 系统提示词:按租户/场景动态注入
    system_prompt = configurable.get("system_prompt", "你是一个有用的助手")
    
    # 4. 用户等级:控制功能权限
    user_tier = configurable.get("user_tier", "free")
    
    # 根据配置选择模型
    if model_name.startswith("claude"):
        llm = ChatAnthropic(model=model_name, temperature=0)
    else:
        llm = ChatOpenAI(model=model_name, temperature=0)
    
    llm_with_tools = llm.bind_tools(tools)
    
    # 根据用户等级限制工具调用次数
    tool_call_count = sum(
        1 for m in state["messages"]
        if hasattr(m, "tool_calls") and m.tool_calls
    )
    
    if user_tier == "free" and tool_call_count >= max_tool_calls:
        return {
            "messages": [AIMessage(
                content="您已达到免费版的工具调用限制,请升级到付费版"
            )]
        }
    
    # 注入系统提示词
    messages = [SystemMessage(content=system_prompt)] + state["messages"]
    response = llm_with_tools.invoke(messages)
    return {"messages": [response]}

生产级配置管理模式
python 复制代码
# ============ 模式一:直接在 config 中传参 ============

# 免费用户:GPT-4o-mini,最多 3 次工具调用
free_user_config = {
    "configurable": {
        "thread_id": "user_free_001",
        "model": "gpt-4o-mini",
        "max_tool_calls": 3,
        "user_tier": "free",
        "system_prompt": "你是一个有用的助手,请简洁回答"
    }
}

# 付费用户:GPT-4o,不限工具调用
pro_user_config = {
    "configurable": {
        "thread_id": "user_pro_001",
        "model": "gpt-4o",
        "max_tool_calls": 999,
        "user_tier": "pro",
        "system_prompt": "你是一个专业的高级助手,请提供详细深入的分析"
    }
}

# 企业用户:Claude,自定义系统提示词
enterprise_config = {
    "configurable": {
        "thread_id": "tenant_abc_user_001",
        "model": "claude-sonnet-4-20250514",
        "max_tool_calls": 999,
        "user_tier": "enterprise",
        "system_prompt": "你是 ABC 公司的专属 AI 助手,只回答与公司业务相关的问题"
    }
}

# 调用
result = app.invoke(
    {"messages": [HumanMessage(content="帮我查一下最新的 AI 新闻")]},
    config=pro_user_config  # 使用付费用户配置
)

模式二:使用 Pydantic 定义配置 Schema(推荐)
python 复制代码
"""
为什么用 Pydantic?
  - 类型安全:IDE 可以提示字段名
  - 数据验证:传入无效值会立即报错
  - 文档化:Schema 就是文档
"""

from pydantic import BaseModel, Field
from typing import Literal, Optional

class AgentConfig(BaseModel):
    """Agent 运行时配置 Schema"""
    
    # 必须字段
    thread_id: str = Field(..., description="会话ID,用于 Checkpointer 隔离")
    
    # 模型配置
    model: str = Field(
        default="gpt-4o-mini",
        description="LLM 模型名称",
        examples=["gpt-4o-mini", "gpt-4o", "claude-sonnet-4-20250514"]
    )
    temperature: float = Field(
        default=0,
        description="LLM 温度参数",
        ge=0, le=2  # 0 ≤ temperature ≤ 2
    )
    
    # 行为限制
    max_tool_calls: int = Field(
        default=5,
        description="最大工具调用次数",
        ge=1, le=100  # 1 ≤ max_tool_calls ≤ 100
    )
    user_tier: Literal["free", "pro", "enterprise"] = Field(
        default="free",
        description="用户等级"
    )
    
    # 提示词
    system_prompt: str = Field(
        default="你是一个有用的助手",
        description="系统提示词"
    )
    
    # 可选的高级配置
    max_tokens: Optional[int] = Field(
        default=None,
        description="LLM 最大输出 token 数,None 表示不限制"
    )
    timeout: int = Field(
        default=60,
        description="LLM 调用超时时间(秒)"
    )
    
    class Config:
        # Pydantic V2 用 model_config
        json_schema_extra = {
            "example": {
                "thread_id": "user_001",
                "model": "gpt-4o",
                "temperature": 0,
                "max_tool_calls": 10,
                "user_tier": "pro",
                "system_prompt": "你是专业的AI助手",
                "timeout": 30
            }
        }

# 使用 Pydantic Schema 创建配置
config_instance = AgentConfig(
    thread_id="user_pro_001",
    model="gpt-4o",
    max_tool_calls=999,
    user_tier="pro",
    system_prompt="你是一个专业的高级助手"
)

# 转换为 RunnableConfig 格式
pro_user_config = {
    "configurable": config_instance.model_dump()
}

result = app.invoke(
    {"messages": [HumanMessage(content="帮我分析一下这个问题")]},
    config=pro_user_config
)

技术要点

  • thread_id 是必须的------它是 Checkpointer 的隔离标识。不同 thread_id 的状态完全隔离。
  • model 参数让同一个图可以灵活切换模型,不需要为每个模型建一个图。
  • system_prompt 的动态注入让同一个图适配不同的角色/场景(客服、分析师、翻译等)。

模式三:从数据库/API 加载配置(企业级)
python 复制代码
"""
场景:企业级 SaaS,不同租户有不同的配置(存在数据库中)。
     用户请求时,从数据库加载该租户的配置。

架构:
  用户请求 → API 网关 → 加载租户配置 → 构建 RunnableConfig → 调用 Agent
"""

import asyncpg  # 异步 PostgreSQL 驱动

async def load_tenant_config(tenant_id: str, user_id: str) -> dict:
    """从数据库加载租户配置"""
    conn = await asyncpg.connect(DB_URL)
    
    # 查询租户配置
    tenant = await conn.fetchrow(
        "SELECT model, max_tool_calls, system_prompt FROM tenants WHERE id = $1",
        tenant_id
    )
    
    # 查询用户等级
    user = await conn.fetchrow(
        "SELECT tier FROM users WHERE id = $1",
        user_id
    )
    
    await conn.close()
    
    # 组装配置
    return {
        "thread_id": f"{tenant_id}_{user_id}",
        "model": tenant["model"],
        "max_tool_calls": tenant["max_tool_calls"],
        "user_tier": user["tier"],
        "system_prompt": tenant["system_prompt"]
    }

# 在 FastAPI 中使用
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/chat")
async def chat(request: Request):
    body = await request.json()
    tenant_id = request.headers.get("X-Tenant-ID")
    user_id = request.headers.get("X-User-ID")
    
    # 从数据库加载配置
    config_data = await load_tenant_config(tenant_id, user_id)
    config = {"configurable": config_data}
    
    # 调用 Agent
    result = await agent_app.ainvoke(
        {"messages": [HumanMessage(content=body["message"])]},
        config=config
    )
    
    return {"response": result["messages"][-1].content}

技术要点

  • 租户配置应该是缓存的------每次请求都查数据库太慢。推荐用 Redis 缓存,配置变更时失效。
  • thread_idtenant_id + user_id 组合,确保不同租户的状态完全隔离。
  • 数据库中的配置应该有默认值------新注册的租户还没配置时,使用系统默认配置。

RunnableConfig 的注意事项
事项 说明 建议
thread_id 是必须的 Checkpointer 用它隔离状态 始终提供 thread_id
configurable 的 key 是自定义的 图的节点代码决定了读取哪些 key key 名要与节点代码一致
配置不会持久化 RunnableConfig 是运行时参数,不保存在 Checkpointer 中 每次调用都要传入完整的 config
配置验证 传入无效的 key 不会报错(只是被忽略) 用 Pydantic Schema 做验证
敏感信息 API Key 等不应放在 configurable 中 使用环境变量或密钥管理服务

好处:

  1. 类型安全,IDE 有代码补全
  2. 自动验证配置参数
  3. 文档化配置选项
    """

from pydantic import BaseModel, Field

from typing import Optional

from langgraph.graph import StateGraph

class AgentConfig(BaseModel):

"""Agent 的运行时配置"""

model: str = Field(default="gpt-4o-mini", description="使用的模型")

temperature: float = Field(default=0, ge=0, le=2, description="温度参数")

max_tool_calls: int = Field(default=10, ge=1, le=100, description="最大工具调用次数")

system_prompt: str = Field(default="你是一个有用的助手", description="系统提示词")

user_tier: str = Field(default="free", description="用户等级")

enable_memory: bool = Field(default=True, description="是否启用记忆")

language: str = Field(default="zh", description="回复语言")

def configurable_agent(state: State, config: RunnableConfig) -> dict:

"""使用 Pydantic 解析配置"""

从 config 中解析,自动验证和填充默认值

agent_config = AgentConfig(**config.get("configurable", {}))

复制代码
llm = ChatOpenAI(
    model=agent_config.model,
    temperature=agent_config.temperature
)

system_content = agent_config.system_prompt
if agent_config.language == "en":
    system_content += " Please respond in English."

messages = [SystemMessage(content=system_content)] + state["messages"]
response = llm.bind_tools(tools).invoke(messages)
return {"messages": [response]}


> 下一篇文章见:[AI系列文章导航目录-持续更新中](https://blog.csdn.net/a1369760658/article/details/161341453)