系列文章导航: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,本质上就是在图的执行过程中"暂停",等待人工输入后"继续"。这依赖两个关键技术:
- Checkpointer:暂停时自动保存当前状态,恢复时从存档点继续
- 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-50autocommit=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中的chunk是AIMessageChunk,可能包含tool_call_chunks(工具调用的流式块)- 事件顺序:
on_chain_start→on_chat_model_start→on_chat_model_stream(多次) →on_chat_model_end→on_tool_start→on_tool_end→on_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
)
技术要点:
ChatOpenAI的max_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,后面的写入会覆盖前面的,只保留最后一个结果。addreducer 会把所有结果追加到列表中。
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_state的as_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_state 的 as_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_messagesreducer 删除对应 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_messagesreducer 的字段(因为 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_id用tenant_id + user_id组合,确保不同租户的状态完全隔离。- 数据库中的配置应该有默认值------新注册的租户还没配置时,使用系统默认配置。
RunnableConfig 的注意事项
| 事项 | 说明 | 建议 |
|---|---|---|
| thread_id 是必须的 | Checkpointer 用它隔离状态 | 始终提供 thread_id |
| configurable 的 key 是自定义的 | 图的节点代码决定了读取哪些 key | key 名要与节点代码一致 |
| 配置不会持久化 | RunnableConfig 是运行时参数,不保存在 Checkpointer 中 | 每次调用都要传入完整的 config |
| 配置验证 | 传入无效的 key 不会报错(只是被忽略) | 用 Pydantic Schema 做验证 |
| 敏感信息 | API Key 等不应放在 configurable 中 | 使用环境变量或密钥管理服务 |
好处:
- 类型安全,IDE 有代码补全
- 自动验证配置参数
- 文档化配置选项
"""
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)