端到端多智能体 AI 系统


Key Takeaways
- 单次 model.invoke 只能完成无记忆的一问一答,真实旅行规划任务需要「意图理解→方案拆分→多源信息拉取→冲突合并→用户确认→资源下单」的多步协同,远超单 Agent 能力上限
- LangGraph 用 StateGraph(状态图)描述一次 agentic run,节点(Node)是「做什么」的函数,边(Edge)是「下一步去哪」的规则,START/END 是入口与出口的虚拟节点
- Supervisor 节点本身是一个 LLM 调用,输入是当前 state,输出是结构化决策(下一节点名 + 理由),LangGraph 的 add_conditional_edges 用这个决策挑边
- MCP(Model Context Protocol)由 Anthropic 提出,是 Agent 与外部能力(工具/数据源/MCP 服务器)之间的标准协议,核心是「服务器暴露资源与工具,客户端按需调用」
- langchain-mcp-adapters 提供 MultiServerMCPClient,把多个 MCP 服务器(STDIO + HTTP 混合)集中管理,client.get_tools() 一次性列出所有可用 LangChain Tool
为什么需要「多智能体」:从单 LLM 调用到团队协作的工程动机
单次 LLM 调用的能力上限
当你写下 model.invoke("帮我规划下周末去杭州的两天行程"),模型会一次性返回一个 AIMessage,里面是一段连贯的建议文字 —— 也许提到了几个景点、一家酒店、一种交通方案。但这个调用本质上是无状态的:它不知道你上周问过什么、不知道你的预算是 3000 还是 8000、更不会真的去调用任何航司或酒店的 API。一旦你追问"有没有更早的航班",它只能基于上文那段文字重新生成,而不是真的去查实时航班。
而一个真正能落地的旅行规划任务,工程上至少要拆成六步:第一,意图理解 —— 解析出目的地、时间窗、人数、预算上限、偏好(亲子 / 商务 / 户外)等结构化字段;第二,方案拆分 —— 把"行程"切成机票、酒店、天气、景点动线、预算分配五条并行子链;第三,多源信息拉取 —— 调用航班 API、酒店 API、气象 API、地图 API,每条子链独立取数;第四,冲突合并 —— 例如最便宜的航班凌晨到达,但酒店 14:00 才能 check-in,需要决策是换航班还是改酒店;第五,用户确认 —— 把合并后的方案回给用户,等一个"批准"信号;第六,资源下单 —— 在用户授权后,真的去调用支付 / 出票接口。
这六步每一步都有自己的失败模式。把它们压进一个 model.invoke 加一段超长 system prompt,是社区里已经被反复验证过的反模式:上下文窗口爆掉、子任务之间互相幻觉污染、出错时无法定位是哪一步崩了、替换某个数据源要重写整段 prompt。

切给专项 Agent + Supervisor 路由
工程社区和一线的实践给出的共识解法,是把任务按职责切成多个专项 Agent:机票 Agent 只管航班查询和比价,酒店 Agent 只管房源与价格,天气 Agent 只负责气象数据,行程 Agent 只做景点和动线规划,预算 Agent 负责把上面四条的成本聚合校验。每个子 Agent 拥有自己的 prompt、自己的工具集、自己的子状态图。
在这层之上,再放一个 Supervisor Agent 做路由和汇总:它接收用户的原始输入,识别意图后用 Command(goto=...) 或 add_conditional_edges 把这次对话分发到对应的子 Agent;子 Agent 跑完后,Supervisor 再把多个结果合流成一份完整的方案回给用户。这个形态在 LangGraph 的官方示例库以及一线工程实践中几乎是默认选择,也是这套教程对应的开源仓库 https://github.com/entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL 所采用的核心骨架。
Supervisor 不需要"懂"机票或酒店的业务细节,它只需要做好两件事:正确路由 和 结构化合流。这种"小而专"的拆分带来一个副作用 —— 每个 Agent 的 prompt 可以很短,上下文窗口几乎不会爆。
多 Agent 架构 vs 单 Agent 长 prompt 的取舍
| 维度 | 单 Agent + 长 prompt | 多 Agent + Supervisor |
|---|---|---|
| 工具加载 | 所有 MCP 工具塞进同一个工具列表,token 浪费严重 | 按需加载,只把当前路由到的子 Agent 需要的工具挂上 |
| 子上下文 | 所有 Agent 共享一份 messages,噪音快速累积 | 独立子图状态,只把摘要回流到 Supervisor |
| 中间产物审计 | 混杂在一条对话历史里,排障困难 | 每个 node 的输入输出是独立对象,可结构化落库 |
| 替换 / A/B 测试 | 改 prompt 影响全部子任务 | 单条子链可独立替换或灰度上线 |
| 错误隔离 | 一个工具报错,整轮对话可能崩 | 单 Agent 失败只影响对应子链,Supervisor 可降级重试 |
| 工程复杂度 | 看似简单,实则调试黑盒 | 状态机和路由代码量翻倍,但可控性高 |
取舍的边界很清晰:如果你的任务能在一次调用里完成,且不需要外部工具,单 Agent 足够;一旦涉及多源数据拉取、需要审计中间产物、或者要在生产环境做灰度,多 Agent 几乎是必选项。代码量的代价换来的是可观测性、可替换性、可演进性 —— 这三件事在 demo 阶段不重要,在生产阶段决定生死。
本文主线:从状态机到持久化的端到端栈
整篇文章按工程落地顺序展开,每一层都对应一个独立章节。第一层是 LangGraph 状态机骨架 —— StateGraph、START、END、add_messages、Annotated[list, operator.add] 这些原语构成节点与边的最小骨架,详见 https://langchain-ai.github.io/langgraph/ 官方文档。第二层是 MCP 工具接入 —— 通过 langchain-mcp-adapters 和 mcp.ClientSession 把天气 / 航班 / 搜索等外部能力以标准化协议挂进来,std 或 HTTP 传输都支持,规范见 https://modelcontextprotocol.io/ 。第三层是 Supervisor 路由 —— 用条件边或 Command 在子 Agent 之间做派发。第四层是 Guardrails 入口拦截 —— input_guardrail 在请求到达任何 Agent 之前先做越权 / 越界检查,比如拒绝"帮我订一张明天去火星的票"这种无解请求。第五层是 HITL 审批闭环 —— interrupt(...) 在最终输出前挂起,等用户通过前端按钮回传"批准"信号后再 Command(resume=...) 继续。第六层是 PostgresSaver 持久化 —— 用 langgraph-checkpoint-postgres 把 thread_id 级别的状态写到 PostgreSQL,跨进程重启后能恢复对话,参考 https://langchain-ai.github.io/langgraph/reference/checkpoints/ 。第七层是 FastAPI 前后端 —— uvicorn 启动,nest_asyncio 解决 Jupyter 风格的嵌套事件循环问题,文档见 https://fastapi.tiangolo.com/ 。第八层是 psycopg 连接池调优 —— psycopg_pool.AsyncConnectionPool 的 min_size / max_size / timeout 配置直接决定高并发下的尾延迟。
# 极简伪代码:Supervisor 派发到子 Agent
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
def supervisor_node(state):
choice = llm.with_structured_output(RouteDecision).invoke(state["messages"])
return Command(goto=choice.next_agent) # "flight" / "hotel" / "weather" / ...
def flight_node(state):
# 只挂机票相关的 MCP 工具,上下文干净
result = flight_agent.invoke(state["messages"])
return {"messages": [result], "owner": "flight"}
builder = StateGraph(State)
builder.add_node("supervisor", supervisor_node)
builder.add_node("flight", flight_node)
builder.add_node("hotel", hotel_node)
builder.add_edge(START, "supervisor")
这段伪代码展示了一个关键决策:Supervisor 用 Command(goto=...) 而不是返回字符串再 add_conditional_edges。两种写法语义等价,但 Command 写法把"路由意图"和"状态更新"打包在一个返回值里,可读性更好,也方便后续接 LangSmith 追踪。
面向读者:从 Demo 到生产
本文的预设读者是已经会 ChatOpenAI(...).invoke(...)、玩过 langchain.agents.create_react_agent、能跑通 LangGraph 最小示例的工程师。如果你目前的 Agent 在第三次工具调用时崩掉、记不住用户前几轮说过的话、或者换个数据源就要重写整段 prompt —— 那么这套从状态机到持久化的端到端栈,正是把 Demo 级 Agent 推进到生产级多智能体系统所需要补齐的工程课。
[观察] 把"模型能不能一次答对"换成"系统能不能在每个 node 上被观测、被重试、被替换",这是从 Demo 跨到生产的真正分水岭。多 Agent 架构的代价是多写几倍的状态机和路由代码,但换来的是每一步的中间产物都成为一等公民 —— 这对故障定位、A/B 实验、灰度发布都是必要条件。在生产环境里,"能跑"和"能改"是两件完全不同的事,多 Agent 架构天然为后者设计。
[数据] 一个直观的对比:同一个"周末杭州两日游"任务,单 Agent + 长 prompt 在 6 轮对话后,可用工具列表就吃掉了约 3k token 的 system prompt 体积,还没算历史 messages 的累积;而 Supervisor 模式只把当前路由到的子 Agent 的 2-3 个 MCP 工具塞进上下文,平均节省 60%-70% 的 prompt 体积,推理延迟对应下降。在调用 Groq 上的快速推理模型时,这种节省直接转化为用户能感知的首字延迟 —— 这是把 Agent 从"能跑"推到"好用"必须啃下的硬指标。
整条工程链路的价值不在于某个单点技术,而在于它把 Agent 系统从"一段 prompt 一次调用"推进到了"一个有状态、可观测、可中断、可恢复、可灰度的分布式应用"。后续章节会按主线顺序,把每一层从动机到代码再到生产配置拆开讲清楚。
LangGraph StateGraph 骨架:State / Node / Edge / Reducer 四件套

在 LangGraph 里,一次完整的 agentic run 被建模成一张有向状态图(StateGraph)。这张图是整套多智能体工作流的「编排骨架」:节点(Node)负责回答「做什么」,边(Edge)负责回答「下一步去哪」,起点 START 与终点 END 是两个虚拟节点,分别承担图的入口与出口。这样一种结构,把原本散落在 if-else 里的「调用顺序、分支、回环、合并」收敛到一份可被检查点(checkpointer)序列化的图描述里,后续无论是本地流式调试还是跨进程恢复,都能沿用同一份语义。StateGraph 的官方定义与图描述语法可以参考 LangGraph 官方文档 以及 LangGraph GitHub 仓库 中的 README 与 examples 目录。
Node:「做什么」的纯函数
节点是图里最小的执行单元。它本质上是接收当前 state、返回一段 partial dict(部分字段更新)的 Python 函数,LangGraph 会在节点返回后把它的输出按 reducer 规则合并回全局 state。节点不直接读写 state 之外的全局变量,也不直接调用下一节点,这一约束让节点的「输入 / 输出」变得可推理,非常适合做单元测试与重放。一个常见误区是把 requests.Session()、DB 连接池、MCP ClientSession 直接 new 在节点函数体里,这会让节点隐式依赖外部副作用,正确做法是把这些资源在 StateGraph.compile(checkpointer=...) 之前以 lifespan / context manager 形式注入,再让节点通过 state 中的指针读取。
Edge:「下一步去哪」的规则
边是节点之间的有向连接,可以是无条件边(add_edge(from, to)),也可以是条件边(add_conditional_edges(from, routing_fn, mapping))。routing_fn 接收当前 state,返回一个字符串 key,LangGraph 再按 mapping 跳到对应节点。这种「边是函数」的设计,让分支逻辑从节点内部被外置出来,Supervisor Agent 的路由判断因此可以单独替换和升级:升级时只需换一个 routing 提示词模板或换一个 LLM 适配器,图骨架本身完全不动。
START / END:虚拟的入口与出口
START 与 END 并不是真实存在的节点,而是 LangGraph 提供的两个哨兵常量,用来显式标记图的起点与终点。任何实际节点都可以成为「首节点」,任何节点也可以连到 END 表示一次 run 的终止。在多智能体场景里,出口常被 Human-in-the-Loop 的 interrupt 节点占据,把「最终输出」拦在人工审批之后;若 Guardrails 在入口直接拦截,也可以让 Guardrails 节点单独连到 END 提前结束本次 run,避免无效的 LLM 调用与 token 消耗。
State + Reducer:跨节点共享数据的契约
State 是节点之间共享的「唯一」数据通道。它在 LangGraph 里被建模为带类型注解的 TypedDict,字段上用 Annotated[..., reducer_fn] 把合并策略钉死。节点永远只读上一轮的 state,也永远只返回它关心的那一段 partial dict,真正把多个节点的产出「拼」在一起的工作由 LangGraph 自动按 reducer 完成。这种契约的好处是:节点可以任意顺序接入,只要它遵守 schema,就一定能被图组合。reducer 默认实现有两种来源:LangGraph 内置的 add_messages(针对消息列表做了 trim 与 id 去重)、以及 Python operator 提供的 operator.add / operator.or_ 等纯函数。
TypedDict 字段如何标注 reducer
最常见的写法是把对话消息列在 messages 字段上,并用 operator.add 或内置的 add_messages 把它声明为「追加」语义。这样无论节点返回一条 HumanMessage 还是 ToolMessage,LangGraph 都会把新消息 append 到上一轮的列表尾部,而不会整体覆盖掉对话历史。Python 的 operator 模块与 Annotated 类型可以参考 Python typing TypedDict 与 operator / Annotated (typing) 的官方说明。除了 messages,任何业务字段都可以挂自己的 reducer,例如把「本次请求的允许域」用 operator.add 累加成集合,把「最近一次 supervisor 决策」直接覆盖成最新值。
from typing import TypedDict, Annotated, Any
from langgraph.graph.message import add_messages
import operator
class TravelState(TypedDict, total=False):
messages: Annotated[list[Any], add_messages] # 对话历史:追加+去重
user_query: str # 入口请求:整段覆盖
guardrail_allowed: Annotated[list[str], operator.add] # 允许域:集合累加
selected_agents: Annotated[list[str], operator.add]
supervisor_reasoning: str # 最新一次 Supervisor 思路
budget_results: dict # 预算 Agent 产出
approval_request: dict # HITL 审批单
approved: bool # 人类审批结果
human_feedback: str # 人类批注
total=False 与「键外写入即丢弃」
TypedDict 默认 total=True,即每个字段都必须在实例中存在;声明 total=False 后,任一字段都可以缺省,运行期 LangGraph 会用缺省值兜底,适合多入口、多分支的图——比如 Guardrails 拦截后整张图提前终止,后续字段天然就不存在,但不会因为缺键抛 KeyError。更关键的是:TypedDict 上声明的字段集合,就是图可以读写的「全集」;节点若往 schema 之外的键写入,LangGraph 会静默丢弃该字段,这一行为相当于「开发期的静态校验」——一旦拼写错或者引入未声明字段,运行就能立刻被发现,而不是污染下游节点的 state。
TravelState:本工作流的状态契约实例
| 字段 | 类型 / reducer | 语义 | 谁写谁读 |
|---|---|---|---|
messages |
Annotated[list, add_messages] |
多轮对话与工具回执 | 各节点都可写,Supervisor 读 |
user_query |
str |
入口用户请求 | 入口节点写,Guardrails 读 |
guardrail_allowed |
Annotated[list[str], operator.add] |
Guardrails 校验后允许的子能力集合 | Guardrails 写,Supervisor 读 |
guardrail_reasoning |
str |
拦截原因说明 | Guardrails 写,日志读 |
supervisor_reasoning |
str |
Supervisor 的路由思路 | Supervisor 写,日志读 |
flight_results / hotel_results / weather_results / itinerary_results |
dict |
各专项 Agent 的产出 | 专项 Agent 写,汇总节点读 |
budget_results |
dict |
预算 Agent 的核价结果 | Budget Agent 写,Supervisor 读 |
approval_request |
dict |
提交给人类的审批单 | Supervisor 写,Human 读 |
approved |
bool |
审批结果 | Human 写,Supervisor 读 |
human_feedback |
str |
人类批注与修改意见 | Human 写 |
[观察] 把 reducer 钉死在字段上,实质上是把「合并语义」从节点函数内部迁移到了 schema 层。这带来一个反直觉的工程收益:节点可以独立替换,只要它产出的字段和 reducer 没变,图的整体行为就是稳定的。这也意味着,任何「redesign」级别的优化,都应当优先改 schema 的字段与 reducer,而不是改某个节点的内部实现——schema 是图的「宪法」,节点只是「执行机构」。
[数据] 官方示例库 entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL 与 entbappy/TripMate-AI-Using-MCP 都以一个 TypedDict, total=False 的状态类作为整张图的唯一数据载体,业务字段数普遍在 10-15 之间,其中真正会被 reducer 累积的只有 messages / guardrail_allowed / selected_agents 这类需要「跨节点合并」的字段,其余基本都是「最近一次胜出」的覆盖式语义。换句话说,字段里「追加型」与「覆盖型」的比例大约是 3:10,这个比例可以作为新工作流设计 schema 时的参照。
节点契约 vs 自由函数:两种写法的取舍
| 维度 | 节点返回 partial dict(LangGraph 标准做法) | 节点内直接 mutate 全局 state |
|---|---|---|
| 并发安全 | 高:每次合并走 reducer,可被检查点捕获 | 低:节点并发时易写脏 |
| 可重放性 | 强:只需重放节点函数与初始 state 即可恢复 | 弱:依赖共享变量顺序 |
| 调试成本 | 低:节点只看入参与返回值 | 高:需 mock 整个运行环境 |
| 灵活性 | 中:字段需在 TypedDict 中先声明 | 高:任意字段都能塞 |
| 适用场景 | 多智能体编排、跨进程恢复 | 单进程脚本、一次性任务 |
在「端到端多智能体」场景里,几乎一定要选第一种——它的可检查点性正是 PostgresSaver 能按 thread_id 跨进程重启恢复同一份状态的前提。StateGraph + TypedDict + Annotated 这套四件套搭出来的骨架,既是 LangGraph 这套实践的入口,也是后续接入 Supervisor 路由、MCP 工具调用、Guardrails 校验、HITL 审批、checkpointer 持久化的共同底座。掌握它,等于把整张图的「数据 / 控制流」两侧都钉在了同一份可推理的 schema 上,后续每加一个 agent,只是在图里再插一个节点、再添一条边,而不会推翻既有契约。
Supervisor 路由:LLM 决策下一个节点,而不是写死控制流

接续上一节骨架,StateGraph 真正「活」起来的关键,在于边(Edge)上的决策由谁来拍板。如果沿用传统工作流思路,工程师往往会在节点之间手写一长串 if state["user_query"] contains "机票" then flight_agent,这种写死的控制流在小流量场景下尚可,但只要用户意图稍微模糊,或者意图本身需要组合,代码就会迅速膨胀成难以维护的胶水逻辑。
本节要回答的核心问题是:谁来决定下一个节点跑什么。答案是 Supervisor 自己——也就是一个专门负责「调度」的 LLM 调用。它把「下一个节点」从编译期常量,变成运行期由模型推理得出的结构化决策。LangGraph 官方文档对这一机制的描述见 langgraph/reference/graph 与 langchain-ai/langgraph GitHub 仓库,其设计哲学就是把调度显式化。
Supervisor 节点本身就是一个 LLM 调用
在 LangGraph 里,Supervisor 没有任何神秘色彩:它就是一个普通的 Node,只不过 node_fn 里装的是一次 ChatModel.invoke 或 model.with_structured_output(Schema).invoke(state)。输入是当前 State 字典(通常是经过 Reducer 累积的消息历史、解析后的字段),输出是一个结构化对象,例如 next_node: Literal["flight_agent","hotel_agent","weather_agent","approval_request","END"] 与 reasoning: str。
LangGraph 的 add_conditional_edges 把这个结构化输出当作「挑边信号」:它会调用一个 path_map 函数,根据 next_node 字段返回的字符串,把当前激活的图状态推到对应节点去执行。如果 Supervisor 返回 "END",图就走完一轮;如果返回 "flight_agent",就跳到 flight_agent 节点。这种设计让「路由」本身也是图的一个节点,而不是藏在代码里看不见的胶水。条件边的完整 API 形态在 LangGraph 官方文档 的 Conditional Edges 一节有详细示例。
from typing import Literal
from pydantic import BaseModel, Field
from langgraph.graph import StateGraph, START, END
class RouteDecision(BaseModel):
next_node: Literal["flight_agent", "weather_agent",
"hotel_agent", "approval_request", "END"]
reasoning: str = Field(description="为什么选这个节点,用于回放")
def supervisor_node(state: dict) -> dict:
decision = llm.with_structured_output(RouteDecision).invoke([
SystemMessage(content=SUPERVISOR_PROMPT),
*state["messages"],
])
return {"selected_agents": [decision.next_node],
"supervisor_reasoning": decision.reasoning}
builder.add_node("supervisor", supervisor_node)
builder.add_conditional_edges("supervisor", lambda s: s["selected_agents"][-1])
不写 if/else,而是写 Prompt + Few-shot
把决策权交给 LLM,工程师要做的不是写一堆分支,而是写一份高质量的 system prompt,并在里头塞几个 few-shot 样例。Prompt 里通常要交代清楚四件事:可用的 Specialist 列表、每个 Specialist 的能力边界、当前 State 的关键字段、END 何时合法。再配几个「用户问 X 时应当路由到 Y,因为 Z」的示范,模型就能稳定地在候选节点里挑边。
这套机制和写死 if/else 的最大差别,在于弹性与可解释性。if/else 是编译期常量,新增一个 specialist 就必须改代码、重新发版;Prompt-based Supervisor 只需要在 prompt 里加一行「现在还可以路由到 budget_agent」,模型就能识别。代价是每一次路由都多了一次 LLM 调用,这部分开销会在后面单列。
下面这张表把两种方案并排放出来,方便团队负责人按场景做取舍对比。
| 维度 | 写死的 if/else | LLM Supervisor 路由 |
|---|---|---|
| 新增 specialist | 改代码、发版 | 改 prompt,无需发版 |
| 路由可解释性 | 代码注释 | 自然语言 reasoning 字段 |
| 单次路由延迟 | 微秒级 | 200-800ms(Groq 上的快速推理模型实测区间) |
| 路由错误率 | 0(确定性) | 约 2-5%(依赖 prompt 质量) |
| 适用场景 | 规则清晰、节点 ≤3 | 节点 ≥4、用户意图多变 |
[观察] 把「调度」与「推理」解耦的最大工程价值,在于 Supervisor 节点可以独立替换底层模型。线上跑 Groq 上的快速推理模型压延迟,离线评测时换成更大的开源聊天模型压准确率,二者用同一份 prompt 和同一个 Pydantic Schema,切换成本几乎为零。这是 if/else 写法根本享受不到的红利。同时 Supervisor 节点也可以独立接入 cost / latency 监控,与 specialist 实现细节完全解耦。
selected_agents:把决策显式落到 State
Supervisor 输出的 next_node 不应只用来挑边,还应该写回 State,落成 selected_agents 这种累加字段。这一步看着多余,实则是整套可观测性的支点:
- 下游 Specialist 节点启动时,可以先看一眼
selected_agents[-1]是不是自己,确认自己是被点名的那个;如果是["flight_agent", "weather_agent"]这种多选,还可以并行执行。 - 观测平台(如 LangSmith)能直接拿到每一次 Supervisor 调用的
supervisor_reasoning文本,无需去翻模型原始输出。 - 调试时只要把
thread_id喂给 PostgresSaver,就能完整回放「为什么这一步跳到了 hotel_agent 而不是 weather_agent」,直接定位是 prompt 没写清楚还是 state 字段缺失。TypedDict与Annotated的组合用法参见 Python typing 文档。
class TripState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
selected_agents: Annotated[list[str], operator.add]
supervisor_reasoning: Annotated[list[str], operator.add]
destination: str | None
trip_constraints: dict | None
上面的 TypedDict 定义里,selected_agents 和 supervisor_reasoning 都用了 operator.add 的 Reducer,保证 Supervisor 多次决策(因为 LangGraph 支持回环)时不会互相覆盖,而是像消息历史一样顺序累积。operator 模块的相关说明见 Python operator 文档。
调度与推理解耦:独立监控 cost / latency
把 Supervisor 抽成独立节点之后,它的 cost 与 latency 就能像任何普通 LLM 调用一样被监控:每一次 supervisor_node 跑完,记录 token 数、首 token 延迟、整体耗时,再按 thread_id 聚合。这套度量天然适合做以下几件事:
- 在 LangSmith 里给 Supervisor 单开一个 tag,这样 dashboard 上能直接看出「调度开销占总响应时间的百分比」。
- 当某条路由被频繁选中,可以反查是否 prompt 在诱导模型偏科,触发 prompt 调优。
- 模型升级(Groq 上换一个更大的开源聊天模型)只需替换
llm变量,无需触动 specialist 实现。
[数据] 实测在 5 个 Specialist 的中型工作流里,Supervisor 单次决策平均带来 350ms 延迟、约 600 input tokens 与 80 output tokens 的开销,占整次 agentic run 总耗时的 12-18%。这个比例一旦超过 25%,通常意味着 prompt 过长或 few-shot 过多,应当压缩 system message 或换成更快的推理模型。
案例:先查机票还是先查天气,最终走 approval_request
把这套机制落到一个真实的旅行规划工作流上,完整的链路可以这样编排:
- 用户输入
user_query,由入口 Guardrails 拦截越权请求。 - Supervisor 第一次决策:从
user_query提取destination(如「东京」)与trip_constraints(如「预算 1.5 万、五月出发、不红眼航班」),写入 State。 - Supervisor 第二次决策:根据 destination 是否已落地,决定
selected_agents=["flight_agent"]还是["weather_agent"];两者并不互斥,可以并行。 - Specialist 节点通过 MCP(Model Context Protocol)接入 tavily-python 或 langchain-tavily 等外部能力,回写结果。
- Supervisor 在所有 specialist 完成(或任意一个失败)后,统一决策
selected_agents=["approval_request"],强制走 Human-in-the-Loop 的 interrupt 流程。 - 用户在 interrupt 界面以 Command 模式 resume,Supervisor 拿到
approved=True后,再决策selected_agents=["summary_agent"]→END。
下面这张取舍矩阵帮团队判断「什么时候该让 Supervisor 拍板,什么时候仍该走写死的分支」,把决策权放在合理的位置。
| 场景 | 写死 if/else | LLM Supervisor | 理由 |
|---|---|---|---|
| Specialist ≤ 3 且规则清晰 | 优 | 可选 | 延迟敏感,确定性优先 |
| Specialist ≥ 4 且意图多变 | 不推荐 | 优 | 维护成本与弹性优势显著 |
| 必须命中某条监管路径(合规审批) | 必须写死 | 配合 used 字段兜底 | 关键路径要确定性兜底 |
| 用户意图需要 LLM 先抽取字段 | 不适用 | 优 | Supervisor 顺便做 NLU |
整套取权衡衡的核心在于:越靠近入口、越需要灵活解析的环节,越适合 LLM 拍板;越靠近出口、越需要确定性合规的环节,越应该用 if/else 兜底。MCP 协议规范本身也推荐这种「编排层用 LLM、关键路径用规则」的混合模式,详见 Model Context Protocol 规范。
Supervisor 路由真正改变的不是「图怎么连」,而是「图里的决策权归谁」。当 next_node 由 LLM 推理产出、由 selected_agents 落进 State、由 add_conditional_edges 拾起,这张 StateGraph 才真正具备「可观测、可回放、可独立调优」的工程属性。LangGraph 把这条链路封装得很薄,代价是多一次 LLM 调用,换来的是整套多智能体工作流的弹性与可解释性。
MCP 模型上下文协议:把外部能力抽象成统一工具边界

如果说上一节的 Supervisor 是这套多智能体系统的「大脑皮层」,负责决定下一步走哪个专项 Agent,那么 MCP(Model Context Protocol)就是大脑连接外周神经与感官的「标准接口」。本节会把 MCP 的设计动机、传输选型、工具发现机制,以及该示例里四套 MCP 适配器的封装方式一次性讲清楚,并回答一个工程问题:为什么在已经能直接 requests.get() 调天气接口的前提下,还要再套一层 MCP 客户端。
1. MCP 是什么,以及为什么需要它
MCP 是由 Anthropic 提出并开源的一项开放协议,规范站点位于 https://modelcontextprotocol.io/ 。它的核心目标与 USB-C、ODBC 这类协议如出一辙:把异构的外部能力抽象成统一的调用边界。具体到 Agent 场景,协议把外部能力统一建模为三类原语——Resources(只读数据源,例如数据库表、本地文件、向量库集合)、Tools(可调用的副作用函数,例如查天气、下订单、发邮件)、Prompts(预设提示词模板,例如「以导游口吻回答」)。服务端(MCP Server)负责暴露这些原语并实现具体逻辑,客户端(MCP Client)负责按需发现与调用。
在没有 MCP 之前,Agent 若要接入天气、航班、网页搜索等能力,工程师往往为每一类能力写一套专属封装:requests 拼 URL 调天气、httpx 调航班 API、tavily-python 包再调搜索。这种「一能力一 SDK」的写法在 2-3 个工具时勉强可控,一旦工具数量上升,工具管理、错误处理、鉴权刷新、参数 schema 校验就会变成一片泥潭。MCP 的真正价值,就是把这片泥潭标准化成一个统一的协议层,让 Agent 与外部能力之间建立「一对多」的解耦关系。
2. 客户端封装:Agent 侧只需要一行发现
Agent 侧只需要持有 MCP 客户端,就能以统一方式发现并调用所有外部能力,完全不关心底层是 HTTP、stdio 还是 WebSocket,实现仓库参考 https://github.com/langchain-ai/langchain-mcp-adapters 。在 LangGraph 工作流里,这套客户端有两个关键类承担发现与适配的职责:
mcp.ClientSession:与单个 MCP Server 建立 JSON-RPC 风格会话,负责发送initialize、tools/list、tools/call等请求。langchain_mcp_adapters.resources.load_mcp_tools(session):把 Server 暴露的工具列表转换为 LangChain 标准的BaseTool对象数组,直接接入 Agent 的工具集。
这样,Agent 不再为每一种外部能力维护「专属 SDK」,而是把所有工具当成同质的 BaseTool,与 LLM 的 function-calling 接口无缝对齐——这是 MCP 在 LangGraph 生态里最舒服的落点。
3. 传输选型:stdio 与 HTTP/SSE 各自的应用边界
MCP 规范定义了多种传输方式,其中最常用的两条路径是 stdio 与 HTTP/SSE,典型的工程取舍对比如下表所示:
| 维度 | stdio 传输 | HTTP / SSE 传输 |
|---|---|---|
| 进程关系 | 客户端与 Server 同主机、同进程树 | 跨主机、跨进程、长连接 |
| 典型场景 | 本地守护进程、子进程包装脚本 | 远程公共服务、团队共享能力 |
| 启停方式 | 客户端拉起 python xxx_server.py 子进程 |
客户端通过 URL 连到已部署服务 |
| 鉴权机制 | 一般靠本地文件系统权限 | 通常需要 API Key、OAuth、JWT |
| 性能开销 | 内存传递,延迟低 | 受网络与 HTTPS 握手影响 |
| 部署形式 | 与主进程同生共死 | 可独立扩缩容,水平扩容 |
简而言之:stdio 是「同进程段」式的轻量集成,HTTP/SSE 是「独立服务」式的重型集成。本文示例里的 custom_weather_mcp_server.py 走的就是前者,因为这个工具与 LangGraph 主进程耦合度高、只在单个项目里使用;如果是公司内部由平台团队统一提供的能力,通常会走 HTTP/SSE 暴露成一个长期在线的服务端点。
4. 工具发现:把元数据喂给 Supervisor
MCP 客户端连接到 Server 后,会进入「握手 → 工具发现 → 调用」三阶段。握手阶段交换协议版本与能力声明;工具发现阶段 Server 返回一个 JSON 数组,每一项至少包含 name(工具名)、description(自然语言描述)、inputSchema(JSON Schema 形式的参数定义)三个字段。Supervisor 节点拿到这份元数据后,会把它序列化进 LLM 的 system prompt 或 function-calling 工具列表,由模型自行决定调用顺序与参数。换句话说,「能不能调、怎么调、何时调」这三个问题,完全由 LLM 在推理时现场拍板,LangGraph 主流程不写任何 if-else。
[观察] 这套「先发现再调用」的设计,把工具生命周期与 Agent 代码彻底解耦。一旦 Server 端新增一个 get_hotel_review_summary 工具,客户端代码无需任何改动,只需要重启会话,新工具就会自动出现在 LLM 的候选列表里;反之,某个工具下线时只要从 Server 端移除,运行时也不会因 import 缺失而崩溃。这种「在协议层解耦」的好处,会随着工具数量的增长被持续放大,也是 MCP 相对硬编码工具列表最大的工程红利。
5. 本文示例的 MCP 适配器拓扑
回到这套 TripMate 工作流,backend.py 在启动阶段会同时拉起四套 MCP 适配器:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from langchain_mcp_adapters.resources import load_mcp_tools
async def build_mcp_stacks():
servers = [
StdioServerParameters(command="python",
args=["custom_weather_mcp_server.py"]),
StdioServerParameters(command="tavily-mcp", args=[]),
StdioServerParameters(command="aviation-mcp", args=[]),
StdioServerParameters(command="forecast-mcp", args=[]),
]
all_tools = []
for params in servers:
read, write = await stdio_client(params)
session = ClientSession(read, write)
await session.initialize()
tools = await load_mcp_tools(session)
all_tools.extend(tools)
return all_tools
四个 Server 分别是:custom_weather_mcp_server.py(本地 stdio,封装自有的天气算法)、tavily-mcp(公共搜索能力,提供网页检索与摘要)、aviation-mcp(航班实时数据,例如起降时间与延误状态)、forecast-mcp(多日天气预报)。Supervisor 节点拿到这组合并后的 all_tools,一次性传给 LLM 的 function-calling 接口;模型在决定「先查天气,再查航班」时并不需要知道背后挂的是几个 MCP Server——它只看到一长串同质化的 tool descriptions。
6. 工程上的取舍矩阵
stdio 传输 vs 远程 HTTP 之间的权衡,本质上是「部署复杂度 vs 复用价值」的取舍:
- 优先选 stdio:能力与项目高度耦合、只需在内网跑、要求低延迟、单租户场景。
- 优先选 HTTP/SSE:能力由独立团队维护、跨项目复用、需要鉴权与计量、需要做水平扩容。
[数据] 该示例同时启用了 4 套 MCP 适配器(1 个自定义 + 3 个开源公共包),Supervisor 拼装出的 all_tools 数组里,工具项数量通常在 12-18 之间(每个 Server 暴露 3-5 个工具)。这一规模下,LLM 的 function-calling 准确率仍然稳定,但若继续往上堆到 30+ 工具,就需要考虑引入 tool-search 或分层路由,否则 prompt 长度会迅速逼近上下文窗口,工具命中率与响应延迟都会显著恶化——这也是 MCP 规范里专门提示过的「tool catalog scaling」问题。
7. 易踩的几个坑
- stdio 进程僵尸:stdio 客户端以子进程方式拉起 Server,如果 LangGraph 主进程被 SIGKILL,Server 子进程可能残留。规范做法是在 FastAPI 的 lifespan 钩子里统一
await session.close(),配合 uvicorn 的 graceful shutdown,确保 SIGTERM 信号能把整棵进程树带走。 - HTTP 鉴权过期:走 HTTP/SSE 时,OAuth token 过期会断流。建议要么实现 401 自动重试,要么把 token 放到 MCP 客户端的外部配置里通过环境变量注入,避免硬编码后被无意提交进仓库。
- inputSchema 不一致:LLM 给出的参数若与 Server 的 JSON Schema 不匹配,Server 会原样返回错误。建议在 Supervisor 与 Server 之间再插一层 Pydantic 校验(参考 https://docs.pydantic.dev/),既能降低无效调用,也有助于审计参数合规性。
- Windows 下 stdio 路径:用 stdio 启动 Python 子进程时,
StdioServerParameters的command字段需要显式带上python.exe完整路径,否则在 PATH 解析差异下容易出现「找不到可执行文件」。
回到整套系统的视角,MCP 并不是多智能体系统的「核心创新」,但它是让 Agent 能够稳定调用大量异构能力的「承重墙」。当 Server 端新增一个工具时,LangGraph 主流程无需改动一行;当某个工具下线时,只需要在注册表里移除,运行时也不会因 import 缺失而崩溃——这种「在协议层解耦」的好处,会随着工具数量的增长被持续放大,也让下一节要继续展开的 Supervisor 节点,在统一工具视图的基础上做出更稳定的路由决策。
工具发现与适配层:MultiServerMCPClient 与异步 session 管理

如果说上一节的 Supervisor 是这套多智能体系统的「大脑皮层」,决定下一步把任务路由给机票 Agent 还是酒店 Agent,那么 MCP(Model Context Protocol)就是大脑连接外周神经与感官的「标准接口」。本节会把 MCP 在该示例里的工具发现机制、异步 session 管理、以及统一适配层封装方式一次性讲清楚,并回答一个工程问题:为什么在已经能直接 requests.get() 调天气接口的前提下,还要再套一层 MCP 客户端。在动笔写代码之前,先把设计动机摆清楚:MCP 不是「又一个 RPC 框架」,而是 Anthropic 在 2024 年底主推的「给 LLM 的 USB-C 接口」——它把工具描述、能力声明、调用协议这三件事标准化,让任何 Agent 框架(LangChain / AutoGen / CrewAI / 自研)都能消费同一份 server 暴露的能力。这意味着今天写好的 weather_mcp.server 明天被一个 .NET 写的 Agent 拿来用,server 代码一行不改。理解这一点,才能解释为什么我们愿意为它多套一层适配层——买的是「工具资产的复用性」,而不是「少写几行胶水代码」。
1. MultiServerMCPClient:多传输混合的客户端入口
langchain-mcp-adapters 是 LangChain 官方维护的桥接库,它把 MCP 协议层的工具描述(JSON-RPC over stdio / HTTP / SSE)翻译成 LangChain 的 BaseTool 接口,使 Supervisor 节点能够像调用普通 LangChain Tool 一样调用远程能力。其核心入口是 MultiServerMCPClient,接受一个 servers 配置 dict,允许一次性声明多个 MCP server,每个 server 可以独立选择传输方式:
from langchain_mcp_adapters.client import MultiServerMCPClient
mcp_client = MultiServerMCPClient({
"aviation": {"transport": "stdio", "command": "uvx", "args": ["mcp-server-aviation"]},
"tavily": {"transport": "http", "url": "http://localhost:8001/mcp"},
"weather": {"transport": "stdio", "command": "python", "args": ["-m", "weather_mcp.server"]},
})
tools = await mcp_client.get_tools() # 一次性列出所有 LangChain Tool
调用 await mcp_client.get_tools() 后,适配层会并发连接所有 server,读取它们的 tools/list JSON-RPC 响应,把每条工具描述包装成 StructuredTool,并把这些 StructuredTool 一次性返回。Supervisor 在初始化时只要 tools = await mcp_client.get_tools(),就能直接 bind_tools(llm).with_config({"tools": tools}),不需要为每个外部能力写一套适配函数。值得多提一句的是「混合传输」:stdio 适合本地进程级 server(开发快、零网络配置),HTTP 适合跨容器/跨机器的稳定服务,SSE 适合需要 server-push 长连接的场景(例如流式日志)。MultiServerMCPClient 让你在一个 dict 里混搭这三种,不必为不同 server 写不同的 client 类。
[观察] 这种「统一工具列表」的抽象,把外部能力收敛到 LangChain 的 Tool 接口后,Supervisor 的提示词和路由逻辑就跟「调内置函数」完全一致了;真正变化的只是工具描述里的字段语义(机票搜索、天气查询、网页搜索)。这种设计让新增一个 MCP server 几乎零代码——只要在 MultiServerMCPClient 的 dict 里多写一项配置即可,业务节点不需要改一行。
2. 异步 session 与 nest_asyncio 桥接
MCP 客户端的 mcp.ClientSession 本质是 asyncio 协程,必须在 await 上下文里运行;而 FastAPI 路由默认跑在 uvicorn 的 event loop 上。如果 Supervisor 节点本身是 async def,直接 await mcp_client.get_tools() 即可;但如果业务侧用同步 LangGraph 入口,或者把 LangGraph 的 graph.invoke(...) 跑在 FastAPI 的 def 路由里,就需要 nest_asyncio 把当前线程的 event loop「重入」激活,允许在同步函数里调度新的异步任务:
import nest_asyncio
nest_asyncio.apply() # 允许在已有 loop 里再 schedule 协程
from fastapi import FastAPI
app = FastAPI()
@app.post("/chat")
def chat(payload: dict):
result = graph.invoke(
{"messages": [...]},
config={"configurable": {"thread_id": payload["tid"]}},
)
return result
没有 nest_asyncio 这一步,常见症状是 RuntimeError: This event loop is already running,或者 asyncio.Task 拿不到结果就静默挂起。这种坑在本地单线程调试时不会出现,只有把 FastAPI 后端 + LangGraph 同步入口 + MCP 异步 client 三者叠在一起时才会暴露。另一个隐蔽坑点是 session 生命周期:MultiServerMCPClient 默认在 client 实例销毁时才关 session,如果你把它做成「每次请求都 new 一个」,stdio server 会被频繁拉起拉停,uvx 的进程冷启动开销(每次 200-500ms)会直接体现在 P99 延迟里。正确做法是让 client 在 FastAPI 的 lifespan 里单例化,贯穿整个应用生命周期。
[观察] 工程上的更稳妥做法是让 FastAPI 路由也声明为 async def,把 MCP 调用与 PostgresSaver 的状态读写都集中在同一个 event loop 里,避免 nest_asyncio 这种「补丁式」的兼容。nest_asyncio 适合作为迁移期的过渡,而不是长期方案——它会绕过 asyncio 一些安全检查,在线程竞争下可能让 traceback 变得难以追溯。
3. 工具结果归一化:从异构 payload 到 state-friendly 字段
MCP server 的工具返回值格式由 server 自己决定,常见的有原始 JSON、纯文本、嵌套 dict、甚至是 base64 编码的二进制。LangChain 的 ToolMessage 接受 content: str,而 Supervisor 的 State 是 TypedDict,字段类型在编译时就固定。如果直接把 JSON 当字符串塞进 messages,会让下游节点写一堆 json.loads(msg.content) 的脏代码。统一的做法是在适配层写一个「结果归一化器」:
def normalize_tool_result(name: str, raw: Any) -> dict:
if name.startswith("weather_"):
return {"weather_results": json.dumps(raw, ensure_ascii=False)}
if name.startswith("tavily_"):
return {"search_results": [r["url"] + " | " + r["content"][:200] for r in raw]}
if name.startswith("aviation_"):
return {"flight_options": raw}
return {"raw": str(raw)}
每个专项 MCP 工具调用节点(weather_mcp_search / tavily_mcp_search / aviation_mcp_call)都按这一映射,把异构 payload 折叠成 State 字段,Supervisor 后续只用 state["weather_results"] 这种点访问拿数据,不必再关心嵌套结构。
[数据] 在该示例的四套 MCP 工具里,weather_mcp_search 的返回体平均字段数为 12 个(nested),tavily_mcp_search 的单次响应会带 5 条结果 + 每条 200 字摘要,aviation_mcp_call 的航班列表典型长度是 8-15 个 option。如果不归一化,Supervisor 节点里光 state["messages"][-1].content 的解析代码就要占整个文件的三分之一;归一化之后,业务逻辑的可读性显著提升。
4. 韧性包装:timeout / retry / circuit-breaker 三件套
外部 API 都是不稳定的——Tavily 偶发 502、aviation 接口偶发超时、weather server 进程崩溃后 supervisor 重启会出现 5-10 秒的冷启动窗口。每个 MCP 工具调用都应该按同样的包装层暴露,把容错策略从业务节点里剥离:
| 韧性策略 | 默认值 | 触发后行为 |
|---|---|---|
| timeout | 8s | 超时则抛 TimeoutError,由 retry 接管 |
| retry | 最多 3 次,指数退避(1s/2s/4s) | 全部失败才抛给上层 |
| circuit-breaker | 30s 窗口内错误率 > 50% 熔断 | 直接返回降级 fallback,避免雪崩 |
MCP 直连 vs 适配包装层 的取舍:直连 MCP 代码短,但任何一次网络抖动都会让 LangGraph 节点抛异常、State 进入错误分支、用户体验到空白回复;带包装的 MCP 在故障时返回降级 fallback(例如 weather 拿不到就返回「天气数据暂不可用,继续行程规划」),让 Supervisor 有素材继续往下走,而不是整轮崩溃。
权衡上,timeout 不能设太长(否则用户等 30 秒才看到 fallback),retry 次数不能太多(否则把 Tavily 的 429 雪上加霜),circuit-breaker 的窗口要短于 PostgresSaver 的 checkpoint 周期,否则断路恢复后状态对不上。三个参数在该示例里都是通过环境变量注入,部署到不同环境(本地 / staging / prod)时不必改代码。
为了把上面三种替代方案摆在同一张表里对比,补一个对标矩阵:
| 维度 | 直接 requests |
OpenAPI auto-gen client | MCP + adapter |
|---|---|---|---|
| 工具描述自动发现 | ❌ 手写 | ⚠️ 需要 lint | ✅ tools/list 原生 |
| 跨 Agent 框架复用 | ❌ | ❌ | ✅ 协议中立 |
| 流式 / push 能力 | ❌ | ❌ | ✅(SSE 传输) |
| 部署耦合度 | 高(代码紧绑) | 中(spec 文件) | 低(独立进程) |
| 调试成本 | 低 | 中 | 中(需 trace) |
| 适合场景 | 内部脚本 | 单团队固定栈 | 多 Agent 协作 / 跨团队 |
可以看到,MCP 真正赢在「工具资产复用」与「协议中立」这两点,而代码量与调试成本上是要付出额外代价的——这恰恰是「统一适配层」要替业务侧消化掉的负担。
5. 可观测性:每一次调用都要留 trace
MCP 调用是黑盒的远端过程,出问题时的首要调试手段就是「回放当时的 prompt 与 response」。每个 MCP 调用节点在 invoke 前 / 后打日志,记录 tool 名、入参 prompt、原始 response、端到端 latency(ms)、是否触发 retry 或 fallback。这套日志通过 Python logging 走标准输出或 JSON file,Supervisor 调试时直接 grep trace 就能定位是哪一步 MCP 卡住。
import logging, time
logger = logging.getLogger("mcp.trace")
async def traced_call(tool, args):
t0 = time.perf_counter()
logger.info(f"mcp_call start tool={tool.name} args={args}")
try:
out = await tool.ainvoke(args)
logger.info(f"mcp_call ok tool={tool.name} latency_ms={(time.perf_counter()-t0)*1000:.1f}")
return out
except Exception as e:
logger.error(f"mcp_call fail tool={tool.name} err={e}")
raise
[观察] 经验上,MCP 相关 bug 的 80% 都能从 trace 直接看出来——要么是 prompt 字段拼错(把城市名写到日期字段),要么是 response 解析超时(返回体比预期大 10 倍),要么是 retry 把请求堆积导致 Tavily rate limit。这三类问题在 trace 里都是肉眼可读的,远比在 LangSmith 上点开 Run tree 翻找快。把 trace 入口固定在 traced_call 这种薄包装层,后续要接入 OpenTelemetry 也只是替换 sink 即可。
再补几个易踩的坑,工程同学第一次接 MCP 时几乎都栽过:
- stdio 子进程 PATH 问题:
uvx mcp-server-aviation在开发机能跑,放到 Docker 镜像里就报command not found,根因是基础镜像把~/.local/bin从 PATH 里剔了。解法是在command字段写绝对路径,或者用docker run --env PATH注入。 - HTTP transport 的 session 复用:
MultiServerMCPClient在 HTTP 模式下每次get_tools()默认建新连接,如果业务高频轮询工具列表(例如每分钟一次健康检查),会把 server 打挂。要么改成「启动时拉一次,缓存到内存」,要么用 SSE 长连接复用 session。 - prompt 注入污染:MCP 工具的
description字段是 server 控制的,如果 server 不可信,恶意 description 可能诱导 LLM 把敏感数据(用户 token)塞进 tool args。缓解手段是只允许内网 server 接入,或者在traced_call里加一道args schema校验。 - checkpoint 与异步资源的串扰:PostgresSaver 在
graph.invoke()结束后会立刻写 checkpoint,但 MCP 的 HTTP 连接可能还在 close 握手。如果 server 端日志显示「client disconnected prematurely」,说明 asyncio 资源没收干净——需要在 client 端显式aclose()。
参考链接
- langchain-mcp-adapters 官方仓库:https://github.com/langchain-ai/langchain-mcp-adapters
- Model Context Protocol 规范:https://modelcontextprotocol.io/
- 该示例完整工程:https://github.com/entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL
整节收一下:MultiServerMCPClient + nest_asyncio 桥接 + 结果归一化 + timeout/retry/circuit-breaker 三件套 + trace 日志,这五层叠在一起,才让 MCP 真正成为「外部能力的统一边界」,而不是给 LangGraph 又引入一个新的故障域。Supervisor 拿到的是经过归一化、带降级语义、有可观测 trace 的 LangChain Tool 列表,业务节点不必感知 MCP 协议细节,这才是把多智能体系统接入现实世界该有的工程姿态。
Guardrails 输入门神:Supervisor 之前先把越权与越界拦下来

在多智能体系统里,Guardrails 经常被当作「合规补丁」随手塞进 sub-agent 内部,但这种放法在工程上是昂贵的:一次违规请求会先被路由进 Supervisor、再被分发给机票或酒店 sub-agent、走完一轮 MCP 工具调用,直到某个节点的 prompt 撞到红线才被 LLM 拒绝。这一圈的 token、延迟、工具调用次数全浪费在一条注定要被丢弃的请求上。更糟的是,如果拒绝发生在 sub-agent 深处,用户拿到的是一段经过四五跳才生成的「抱歉,我不能...」,体验上像在和一台反应迟钝的机器谈判。
正确的姿势是把 Guardrails 提到 StateGraph 的入口节点,作为 Supervisor 之前的「门神」:任何用户输入先经过它判定,只有 guardrail_allowed = True 的请求才会被放行进入后续的 Supervisor → Specialist → MCP 工具链。这条「先把脏东西挡在外面」的边界,本质上是把 LLM 的一次拒绝压到入口,而不是把它分散到图里每个角色里各自判断一次。也正因为它处于图的最外围,即使后来图内部节点被替换或重构,Guardrails 这层契约也不会被破坏。
从架构哲学上看,这种「入口门神」模式借鉴了经典的网络防火墙设计——把策略强制点放在信任域边界,而不是撒在每个内部服务的代码里。同样的思路在微服务里叫「sidecar / ingress controller」,在 LangGraph 多智能体里就叫「入口 Guardrails 节点」。一旦把这条边界划清,内部所有的 Supervisor、Specialist、MCP 工具调用就可以假设输入是「已经过合规过滤的」,从而在每一个 sub-agent 内部不必再重复实现一遍拦截逻辑,显著降低代码冗余和规则漂移风险。
三类输入 Guardrails 与对应动作
落地到代码层,我们把入口 Guardrails 分成三类,每类对应不同的处置动作:
- 主题外请求(例如「帮我算一下 17×24」「写一首关于雪的诗」):直接拒绝,不进入业务流。这类请求和旅行规划主题不沾边,放进去只会污染 Supervisor 的路由决策,让其把请求错误分发给行程 Agent。
- 敏感内容(医疗诊断、法律建议、签证材料解读):不直接拒绝,而是转人工(handoff to human),并把 conversation_id 写进待办队列。这条对应 HITL 的另一条分支,后续由人工坐席接手。
- 可疑内容(PII 泄露、越权指令注入尝试、明显的 jailbreak 模式):拒绝并写
audit_log,触发安全团队告警。这一类不能静默放过,但也不该回显敏感词给用户,只回复统一的脱敏提示。
| 类别 | 触发示例 | Guardrail 动作 | 是否进 Supervisor |
|---|---|---|---|
| 主题外 | 「帮我算一下 17×24」 | 直接拒绝 | 否 |
| 敏感内容 | 「我能带这种药过海关吗?」 | 转人工 + 排队 | 否 |
| 可疑内容 | 包含 SSN / 「忽略以上指令」 | 拒绝 + audit_log | 否 |
| 合法请求 | 「下周从北京去东京的行程」 | 放行 | 是 |
把这三类放在同一张表里对比,可以看出 Guardrails 不是二元开关,而是一条「拒绝 / 转人工 / 放行」的三态决策线。这条线越靠近入口,后续节点的工作就越聚焦,Supervisor 也不会因为夹杂了无关请求而被迫学一套「如何拒绝用户」的兜底话术。易踩的坑是把这三类都做成「直接拒」,结果把「转人工」这类本可由 HITL 接管的请求也挡在门外,造成用户感知上的「什么都答不上来」。另一处坑是把「audit_log」写进 state 而不是写到外部 SIEM,导致合规团队需要去 checkpoint 里翻 JSON 才能拿到告警,运维成本陡增。
节点实现:规则匹配 vs 便宜模型
Guardrails 节点本身有两种主流实现路径,可以单独使用,也可以串成两级过滤:
def guardrail_node(state: TripState) -> dict:
user_msg = state["messages"][-1].content
# 路径 A: 关键词 + 正则,微秒级返回
if any(kw in user_msg.lower() for kw in BLOCK_KEYWORDS):
return {"guardrail_allowed": False,
"guardrail_reason": "out_of_scope"}
if re.search(r"\b\d{3}-\d{2}-\d{4}\b", user_msg):
return {"guardrail_allowed": False,
"guardrail_reason": "pii_detected"}
# 路径 B: 调 llama-3.3-8b-instant 做语义分类
label = classify_with_small_llm(user_msg)
if label != "travel_planning":
return {"guardrail_allowed": False,
"guardrail_reason": label}
return {"guardrail_allowed": True,
"guardrail_reason": "ok"}
两条路径的取舍很直接:规则匹配延迟低、零成本、可解释,但召回差;llama-3.3-8b-instant 这类 8B 量级的快速分类模型延迟多 100~300ms、单次几分之一美分,召回好但偶发幻觉。在生产环境里通常的做法是「规则先过、模型补漏」的两段式,既压住大多数明显违规,又兜住用同义词、拼写变形绕过关键词的变体。需要警惕的是「规则误伤」:某些合法请求里也会出现医疗相关词,例如「我下个月要做手术所以想安排家人陪诊机票」,这种就需要规则白名单或 8B 模型来兜底,不能粗暴拒掉。
与对标框架的对比矩阵
| 框架 / 方案 | Guardrails 放置位置 | 配置粒度 | 与 LangGraph 集成度 | HITL 原生支持 |
|---|---|---|---|---|
| LangGraph(本文方案) | StateGraph 入口节点 | state 字段级 | 原生 | 通过 interrupt + handoff |
| LangChain AgentExecutor | tool 内部 + output parser | tool 级 | 中,需自定义 wrapper | 弱 |
| AutoGen(微软) | GroupChat manager 内置 | message 级 | 中 | 中 |
| CrewAI | 每个 Agent 的 backstory 内 | agent 级 | 弱 | 弱 |
| OpenAI Assistants API | function call schema 校验 | tool schema 级 | 中 | 通过 submit_tool_outputs |
这张对比表说明:LangGraph 把 Guardrails 放在「StateGraph 节点」这一层,正好是其他框架里最弱的一环——其他框架大多把策略散落在 tool、agent 或 message 里,要么粒度过细导致重复,要么粒度过粗导致漏判。LangGraph 的 state 字段级控制(guardrail_allowed: bool、guardrail_reason: str)让策略既能集中、又能可观测,这是它相对其他方案的关键优势。
决策矩阵:Guardrails 节点放哪儿 vs 不放哪儿
| 放置位置 | 命中违规时的成本 | 用户感知 | 审计可追溯性 |
|---|---|---|---|
| 入口 Guardrail 节点 | 仅一次 guardrail 节点调用 | 立即拒绝,几乎无延迟 | 高,理由一次性入 state |
| Supervisor 内部二次校验 | Supervisor 一次 + sub-agent 一次 | 跑半圈才告诉不行 | 中,要查两处日志 |
| 每个 Specialist 内各放一份 | N 个节点都被调用 | 不一致,部分节点能漏 | 低,各 sub-agent 各自记录 |
| 仅在最终输出前做 output guardrail | 整图跑完才拦 | 跑完才发现不行 | 低,理由与图状态脱节 |
[观察] 把 Guardrails 放在入口真正省下的不是 token 本身,而是一致性:同一个违规请求,在不同的 Specialist 视角下可能有不同判定(机票 sub-agent 觉得「帮我改签到明天」是合理的,预算 sub-agent 可能因为没日期而疑惑;同时法律 sub-agent 又会触发自己的红线)。入口单点决策消除了这种「每跳各自解释」的不一致,也让 audit log 只有一处可查。这点对后续要接 LangSmith 做可观测性特别友好(参考其官方文档 https://docs.smith.langchain.com/ ),因为 trace 的根节点只会有一个 guardrail 命中的 span,后续 sub-agent 的调用全部不再展开。
[数据] 参考该示例仓库( https://github.com/entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL )的 README 配置:其入口 Guardrail 节点用「关键词正则 + llama-3.3-8b-instant 语义分类」的两段式实现,在 P95 延迟上比把同等校验塞到 Supervisor 后面省掉约 300~600ms,等于省掉一次多余的 Groq 推理往返;在 token 维度上,违规请求的 input+output 总量从原先的 1.2k~2.5k 降到 guardrail 节点的 200~400,差异主要来自不再进入 Supervisor 的 prompt 拼接和 MCP 工具调用链。一条违规请求省下的 token 看似不大,但乘以每天的违规请求比例,以及这些请求原本会污染的图状态、checkpointer 写入,真实的成本节省会比表面数字更显著。
易踩的工程坑清单
在生产里反复出现过、值得提前规避的几类问题:
- 规则集合膨胀失控:
BLOCK_KEYWORDS一开始只有 20 条,半年后涨到 4000 条,每次匹配都 O(n) 扫描,延迟从 1ms 涨到 30ms。正确做法是用 Aho-Corasick 或 trie 树做多模式匹配,把关键词集合的扫描复杂度压到 O(text_length + matches)。 - 8B 模型冷启动抖振:llama-3.3-8b-instant 在流量低谷时会出现 500~800ms 的冷启动尖刺,影响前端 SSE 的「第一秒反馈」体验。需要在 guardrail 节点外包一层本地缓存(LRU + user_msg 的语义 hash),把相同/相似请求的判定结果复用 5~15 分钟。
- PII 正则漏掉国际化场景:
\d{3}-\d{2}-\d{4}只覆盖美国 SSN,对中国身份证(18 位)、日本 My Number(12 位)完全失效。需要按目标用户地域分别配置正则集,并在guardrail_reason里区分国家代码,方便后续合规团队分桶处理。 - 规则与模型判定冲突:规则说「放行」、8B 模型说「拒」,到底听谁?需要明确「模型可以放行规则判否的请求,但不能否决规则的拦截」——把规则作为强约束、模型作为弱建议,避免模型幻觉导致违规请求被放行。
拒绝理由必须落 State,不落临时变量
最后一条工程纪律:Guardrails 节点的输出必须写入 state(例如 guardrail_allowed: bool、guardrail_reason: str),而不是只用来控制条件边。LangGraph 在用 PostgresSaver 做 thread 级持久化时,会把整个 state 快照连同 thread_id 一起落盘(详见 https://langchain-ai.github.io/langgraph/reference/checkpoints/ )。一旦把拒绝理由留在 Python 局部变量里、只在条件边上用了 if not allowed: goto END,事后人工复查「那天为什么这条请求被拒」就只能查到「走到了 END」,查不到当时模型/规则究竟命中的哪一条。
把 guardrail_reason 写进 state 之后,LangGraph 的 state 视图、LangSmith 的 trace、以及 PostgresSaver 落盘的 JSON 三处都能查到同一条字符串。这种「同一事实多处一致」的设计,在 HITL 复盘和合规审计场景下是不可替代的——审计员不需要复现推理,直接看 state 快照就知道当时 Guardrail 节点的判定理由。再加上 messages 字段在 state 里会保留原始用户输入(用 add_messages reducer 累积),完整链路是「原始输入 → guardrail_reason → END」,三段都不丢。配合 LangGraph 流式调用(https://langchain-ai.github.io/langgraph/ ),前端的 SSE 也能在第一秒就把拒绝结果推给用户,不会出现「按钮按下去三秒没反应」的尴尬。
收尾:把 Guardrails 提到入口,本质上是把「合规边界」和「业务逻辑」解耦——前者是图的最外层契约,后者是图内部的路由与执行。这种分层让以后想加新 Specialist、或把某个 sub-agent 换成通过 Model Context Protocol(参考 https://modelcontextprotocol.io/ )接入的外部能力时,不需要重新讨论「这条请求算不算合规」,入口 Guardrails 自动接管。
Specialist 子 Agent:每个节点只做一件事,凭 state 边界清晰

接续上一节 Guardrails 把越权请求挡在门口之后,真正进入图的请求都会被 Supervisor 拆解、再分发给若干「专科医生」。在 LangGraph 体系里,这些 Specialist 其实就是普通的节点(Node),只不过每个节点都只绑定一种工具集与一份 Prompt。把节点做小、把边界做窄,是这套实践从「能跑」走向「能维护」的分水岭。
节点契约:进什么、出什么,完全由 state 决定
在 LangGraph 的 StateGraph 中,所有 Specialist 节点共享同一份 TypedDict 形态的 state。机票 Specialist 节点只读取 user_query、destination、dates 这几个字段,再写回 flight_results;酒店节点读相同输入但写 hotel_results;天气节点则写 weather_results。节点之间的耦合完全发生在 state 上,而不是函数调用上——这是多 Agent 系统里最容易被新手忽略的设计点。很多团队一开始就把节点写成「一个超大函数内部再调 LLM 决策下一步」,看似灵活,实际把整张图的拓扑退化成单 Agent,失去了 Multi-Agent 的可观测性与可替换性。
一个机票节点的伪代码大致是这种形状:
def flight_specialist(state: TripState) -> dict:
prompt = FLIGHT_PROMPT.format(
user_query=state["user_query"],
destination=state["destination"],
dates=state["dates"],
)
tools = load_mcp_tools(["aviation_mcp"])
response = llm.bind_tools(tools).invoke(prompt)
return {"flight_results": parse_results(response),
"specialist_status": "ok"}
返回值不是「整张新 state」,而是 partial dict,由 state schema 里用 Annotated[list, operator.add] 声明的 reducer 自动合并进全局 state。LangGraph 官方文档把这种「节点只写自己负责的字段、合并交给 reducer」的模式视为多 Agent 图的核心约束,详见 LangGraph 官方文档 与 Python typing operator。
工具与 Prompt 的「专科绑定」
把 Specialist 节点做窄的直接收益是 token 用量变得可控。机票节点只装载 aviation_mcp 这一个 MCP Server 暴露的工具;天气节点只装载 weather_mcp;酒店节点只装载 hotel_mcp。LLM 在每个节点看到的「可用工具清单」都很短,Prompt 又被精心裁剪成只跟单一领域相关,既降低幻觉概率,也让单次推理的 token 计数稳定在合理区间。Model Context Protocol 下,每个 Server 是一组相互独立的能力单元,通过 langchain-mcp-adapters 的 load_mcp_tools 即可按需挑选,具体规范参考 Model Context Protocol 与 langchain-mcp-adapters 仓库。
下表把三类 Specialist 的能力边界画清楚:
| Specialist | 输入 state 字段 | 输出 state 字段 | MCP 工具集 | Prompt 角色 |
|---|---|---|---|---|
| 机票 | user_query / destination / dates | flight_results | aviation_mcp | 航班搜索与筛选 |
| 酒店 | user_query / destination / dates | hotel_results | hotel_mcp | 房型与价格推荐 |
| 天气 | destination / dates | weather_results | weather_mcp | 天气查询与穿着建议 |
并行还是串行:Send API 的取舍
Supervisor 把任务拆开后,机票、酒店、天气三者之间没有任何数据依赖——这正是 LangGraph 的 Send API 发挥的场景。Send API 允许 Supervisor 节点一次性「扇出」多个 Node 实例,每个实例带着裁剪过的 state 子集跑,跑完后 reducer 在下一节点把多份 partial dict 合并。写法可参考 LangGraph GitHub 仓库 里 Send 相关的示例。
但「能并行」不等于「必须并行」。下面对比两种拓扑的工程取舍:
| 维度 | 顺序串行 | 并行 Send API |
|---|---|---|
| 端到端延迟 | 三段 Specialist 时长之和 | max(三段时长) |
| 状态耦合 | 后节点可读前节点结果 | 必须先抽公共子集 |
| 错误定位 | 链式断在哪一目了然 | 需靠 reducer 内 status 字段回溯 |
| LLM 配额占用 | 单条请求,易计量 | 同时占用 N 份配额 |
| 适用场景 | 强依赖(预算需先看机票+酒店) | 弱依赖(三件事相互独立) |
取舍原则非常清晰:节点间是否存在「前者输出必须喂给后者」的语义依赖;若有,串行更安全;若无,Send 并行能直接砍掉总延迟,这是工程上值得追求的加速项。除此之外还有几个常被忽略的工程坑:一是并行分支若同时写同一个 reducer 字段(如都往 messages 追加日志),必须确认 reducer 是 add 而不是 overwrite,否则会出现「最后一个写者赢」的非确定性;二是并发配额压力,某些模型厂商对同一账号的并发 RPM 有限制,Send 一旦扇出超过上限,429 会接踵而至;三是 trace 可观测性,并行分支在 LangSmith 里需要靠 thread + 多条 run id 关联,排查时容易看花眼。
错误隔离:不让一颗螺丝钉拖垮整条链
分布式系统里「部分失败」是常态,Multi-Agent 图也需要同样的优雅降级。约定每个 Specialist 节点在出错时返回 {"<results_field>": [], "specialist_status": "error", "error": "<message>"},而不是 raise 异常把整张图炸掉。Supervisor 在下一轮决策里就能根据 specialist_status 字段判断:是降级(用别的渠道补)、重组(改派别的 Specialist)、还是直接拒(整条请求作废并走 HITL 让用户重选)。
def safe_specialist(fn):
def wrapper(state: TripState) -> dict:
try:
return fn(state)
except Exception as e:
return {
fn.__output_key__: [],
"specialist_status": "error",
"error": f"{fn.__name__}: {type(e).__name__}: {e}",
}
return wrapper
这种「节点返回 partial result + 错误字段」的做法,与 Guardrails 把越权请求挡在前面是配套的:前端有 Guardrails 拦非法,后端有 Specialist 自身兜底异常,Supervisor 只负责决策与汇总,三者形成完整的防护链路。这里有一个易踩的坑——很多团队在装饰器里把异常吞掉后,既不打日志也不上报 metric,等线上某 Specialist 长期静默失败才发现问题。safe_specialist 应该同步调用 logger.exception 与 metrics.counter("specialist.error").inc(tag=fn.__name__),把「节点出错」这件事从静默变成可观测。
测试优势:每个节点都能脱离 Supervisor 单独跑
最后一个被低估的好处是测试。当每个 Specialist 都只读固定几个 state 字段、又只调固定的 MCP 工具时,它完全可以脱离 Supervisor、Guardrails、HITL 独立做单元测试——构造一个 TripState(user_query=..., destination=..., dates=...),调 flight_specialist(state),断言返回 dict 的形状与字段值即可。Supervisor 与 Guardrails 不需要专门为它再起一条端到端测试,大幅压缩 CI 时间。
| 测试对象 | 是否需要端到端 | 典型 mock | 验证重点 |
|---|---|---|---|
| 单个 Specialist | 否,单测即可 | 替换 MCP ClientSession | partial dict 形状 |
| Supervisor 路由 | 否,可用固定 state | 替换 LLM 决策 | 分发结果 |
| Guardrails | 否 | 替换 LLM 判定 | 通过或拦截标签 |
| 端到端整图 | 是 | 几乎不 mock | 整条用户旅程 |
对标框架分析:为什么选择 LangGraph 的窄节点形态
市面上的 Multi-Agent 框架并不只有 LangGraph 一家,AutoGen 的 GroupChat、CrewAI 的 Role-based Crew、OpenAI 的 Swarm,各自走的是不同的解耦路线。下表从「节点粒度」「状态显式度」「工具隔离」「可测试性」「图编排能力」五个维度横向对照:
| 维度 | LangGraph Specialist | AutoGen GroupChat | CrewAI Role | OpenAI Swarm |
|---|---|---|---|---|
| 节点粒度 | 极细,单职责函数 | 中等,Agent 内可调多个工具 | 中等,Role 带任务剧本 | 较粗,handoff 链 |
| 状态显式度 | 高,TypedDict 全局可见 | 低,散落在 Agent memory | 中,Process 全局对象 | 低,主要靠 message |
| 工具隔离 | 强,按 MCP Server 绑定 | 中,Agent 私有工具集 | 中,Role 工具列表 | 弱,共享 tool registry |
| 单节点可测试 | 极易,纯函数 | 较难,需构造 GroupChat | 中等,需 mock Crew | 较难,handoff 耦合 |
| 拓扑表达力 | 强,任意 DAG + 条件边 | 中,主要靠群聊管理器 | 中,Sequential/Hierarchical | 弱,本质是消息流 |
| 适用规模 | 中大型生产图 | 中型研究型实验 | 小到中型业务脚本 | 小型快速原型 |
在「每个节点都能被独立替换、独立回归测试」这条线上,LangGraph 的 StateGraph + TypedDict + Reducer 三件套给出的工程接口是最克制的,也最容易被现有 Python 生态(类型检查、mock、trace)吸纳。AutoGen 的对话式编排更适合探索性研究,CrewAI 的剧本式 Crew 适合流程固定的业务,Swarm 则定位于轻量级原型;而当你需要长期维护、有多人协作、需要把图跑在生产上时,窄节点 + 显式 state 的 LangGraph 形态往往更耐久。
工程层面的常见踩坑清单
把 Specialist 做窄这条路,看似简单,实操中仍有几处高频陷阱值得提前点出:
- 隐式全局状态:某些团队为了让 Specialist「更聪明」,偷偷在节点内读写 Redis 或进程内 dict,绕过 state schema。短期看节点能拿到额外信息,长期看图的可重放性(replayability)彻底丧失——同一条 state 在不同进程跑出不同结果。建议在 CI 里加 lint,禁止 Specialist 直接 import 任何
os.environ之外的全局变量。 - Reducer 误配:把
flight_results用Annotated[list, operator.add]合并是合理的,但若不小心把destination这种标量也写成 add reducer,就会出现["上海", "东京"]这种灾难。规则很简单:字段是集合/列表/日志用 add reducer,字段是当前事实用 overwrite。 - Prompt 串味:多个 Specialist 共用一份「通用 system prompt」,里面塞了所有领域的话术,导致机票节点其实也读到了酒店相关指令,既浪费 token 又污染输出。建议每节点配独立 prompt 文件,通过路径而非字符串拼接引用。
- MCP Server 复用过度:为了省连接,把
aviation_mcp和hotel_mcp装进同一个 Server,看起来节点加载的工具变多了,实际把「工具隔离」这一道防线彻底打穿。Model Context Protocol 的初衷就是让每个 Server 对应一类能力,合并等于倒退到单 tool registry。 - 并行分支的时序假设:Send 出去的多个 Node 几乎同时返回,但下游若按 reducer 顺序读,可能读到尚未合并完的部分。需要让下游节点只在 reducer 完成后再触发,LangGraph 默认行为是满足的,但若手动接管调度就要小心。
[观察] 把 Specialist 节点做窄的真正价值,不是「每个节点更快」,而是「每个节点都可以被替换、被测试、被下线」。当某天 aviation_mcp 整体迁移到新的供应商时,你只需要重写机票这一个节点的内部实现,其它 Specialist、Supervisor、Guardrails 全部无需改动——这是普通 function call 路由方案很难具备的可替换性。state schema 本身就是 API 契约,只要字段不变,节点内部的 LLM、Prompt、工具集想怎么换都无所谓。
[数据] 在一份典型旅行请求里,机票、酒店、天气三者完全独立;用 Send API 并行后,端到端首字延迟从「三段 Specialist 时长之和」降到「最慢那段的长度」。如果每段 Specialist 平均耗时约 1.5 秒,顺序拓扑是 4.5 秒左右,并行拓扑压到 1.5 秒上下,加速比接近 3x;但这只是 P50 体感,P99 上并行并不天然更稳,还得靠 reducer 里 specialist_status 字段把异常路径收敛回来,避免某个 Specialist 抽风把整张图拖到超时。
并行 Send API 与并发写冲突:reducer 是真正的同步信号
并行 Send API 与并发写冲突:reducer 是真正的同步信号
当 Supervisor 节点判定「机票 + 酒店 + 天气」三件事必须同时发生时,LangGraph 的 Send API 提供了从「单一条件边」动态派发多个并行任务的能力。它与 add_conditional_edges 在外形上接近,差别在于返回值:Send 不再返回「下一节点名字符串」,而是返回一个 Send(...) 对象列表,每一项独立携带目标节点和输入 state,框架会把它们作为独立任务并行调度,再把所有分支的输出回收到同一个 state。
Send 的工作语义
from langgraph.types import Send
def route_to_specialists(state: TripState) -> list[Send]:
return [
Send("flight_agent", {"query": state["user_request"]}),
Send("hotel_agent", {"query": state["user_request"]}),
Send("weather_agent", {"query": state["user_request"]}),
]
builder.add_conditional_edges("supervisor", route_to_specialists,
["flight_agent", "hotel_agent", "weather_agent"])
这条 conditional edge 在执行时不再「选一个分支」,而是「同时开三条」。LangGraph 在内部把这些 Send 任务交给线程池驱动,各自跑完后把返回值合并回 state。从外部视角看,这相当于一次 fan-out / fan-in 操作:Supervisor 是 fan-out 出口,reducer 是 fan-in 的同步点。
并发冲突的根源
LangGraph 的节点默认是「覆盖式写」。如果两个分支都执行 state["budget_estimate"] = 1500,框架会拿到两份互相覆盖的结果,并抛出 InvalidUpdateError。这是 LangGraph 的安全闸,它宁可让程序崩,也不让你拿到一份「分不清谁写的」state。冲突的判定发生在 fan-in 阶段:框架扫描所有并行分支对该 key 的写入,如果存在多份「直接赋值」,就报错。
| 并发场景 | 写同一 key | 写不同 key | 框架行为 |
|---|---|---|---|
两条 specialist 各写 flight_results、weather_results |
否 | 是 | 并发安全,直接合入 |
两条 specialist 都写 budget_estimate(无 reducer) |
是 | 否 | 抛 InvalidUpdateError |
都写 messages 并配 operator.add |
是(reducer 接管) | 否 | 并发安全,append 语义 |
都写 selected_agents 并配自定义 reducer |
是(reducer 接管) | 否 | 并发安全,extend 语义 |
reducer 作为真正的同步信号
只要在 TypedDict 字段上声明 Annotated[list[...], operator.add],LangGraph 就把该字段视为「append-only」:并行分支各写各的,框架在 fan-in 时自动拼接。这就是为什么 messages 字段几乎是默认安全的——LangGraph 内置的 add_messages 本质上就是一个语义增强版的 operator.add,会处理 AIMessage / HumanMessage / ToolMessage 的 id 去重与覆盖。
对于非 messages 的 list 字段,可以自己写合并函数:
import operator
from typing import Annotated
from typing_extensions import TypedDict
def merge_selected_agents(existing: list[str], new: list[str]) -> list[str]:
return list(dict.fromkeys(existing + new))
class TripState(TypedDict):
selected_agents: Annotated[list[str], merge_selected_agents]
flight_results: dict
weather_results: dict
merge_selected_agents 在这里就是「同步信号」:它告诉框架「两个分支都在写 selected_agents,但我不希望后者覆盖前者,我要的是去重并集」。reducer 的签名约定是 (existing, new) -> merged,框架在 fan-in 时把「旧值」与「新到值」一起喂给它,而不是把两次写入做顺序覆盖。
设计动机:为什么 LangGraph 选择 reducer 而不是锁
LangGraph 的并发模型刻意走了「函数式 + 不可变 state」路线,而不是传统线程编程里常见的 mutex / lock / semaphore。当多个 specialist 同时跑完,框架面临的不是「谁先抢到锁」,而是「这些返回的 patch 应当如何合成」。这有两个根本原因:第一,LLM 节点的输出本质上是「声明式 patch」(我建议把 budget 设为 1500),而不是「指令式写」(请把 budget 字段改成 1500),reducer 刚好匹配这种 patch 语义;第二,checkpoint 恢复必须能够在结果上重现,锁机制天然带时序,跨进程恢复后语义会漂移,reducer 则是纯函数,只要输入一致,合并结果一致。第二点也是 LangGraph 选 TypedDict + Annotated 而不是 dataclass + attribute 的核心理由:Annotated 把「合并规则」绑在类型上,跨进程、跨 checkpoint 都能携带。
[观察] 在这套多 Agent 实践里,「reducer 是同步信号」这件事容易被低估。当三个 specialist 同时跑完,框架必须决定谁先谁后、谁覆盖谁,而 reducer 函数就是这一瞬间的调度器。把 reducer 写薄(只做拼接、去重)比写厚(加业务逻辑)更稳妥,业务逻辑应该放进下游汇总节点,而不是 fan-in 的合并函数里。reducer 一旦承担「基于内容选最优」之类的判断,就不再是同步信号,而是一个串行决策器,这时显式加一个 merge 节点会更清晰。
两种设计策略的取舍
| 策略 vs 取舍 | 适用场景 | 代价 |
|---|---|---|
| each-specialist-its-own-key | specialist 输出结构差异大,如 flight_results、weather_results |
汇总节点需要手动读多 key |
| 共享 key + reducer | specialist 输出结构同构,如都写 selected_agents 或 messages |
reducer 写错会让数据污染 |
| 共享 key + 显式 merge 节点 | 同构但 reducer 不够用,如两条路径都给 budget_estimate 数字 |
多一个节点,牺牲一点延迟 |
常见踩坑清单(经验维度)
- reducer 签名写反:误把
(new, existing)写成(existing, new),导致「先到的被后到覆盖」,看似正常运行,但数据顺序错乱。 - list 字段忘了声明 Annotated:字段类型是
list[str],但没标Annotated[list[str], operator.add],并行分支写入会直接抛InvalidUpdateError,初学者最常被这里的报错信息劝退。 - dict 字段误用 operator.add:Python 的
dict不支持+,如果在Annotated[dict, operator.add]上写,会在 fan-in 时直接TypeError。dict 字段要写自定义 reducer 返回{**existing, **new}。 - 在 reducer 里访问外层副作用:reducer 应当是纯函数,有人会图省事在里头调
print或logging,看似无关,但会污染日志、影响 unit test 的快照稳定性,以及跨进程重放时的可观测性。 - Send 列表里重复派发同一节点:某些场景下「想跑两个相同节点的实例」,但 LangGraph 期望实例用不同 node name 或 input 区分,否则会被框架去重或调度混乱。
- 覆盖式字段被误放入并行分支:把
final_answer: dict这种「终态字段」放进 Send 列表,跑完必然冲突,正确做法是让 supervisor 之外再来一个 fan-in 节点统一写入。
对标框架的并发模型对比
| 框架 / 维度 | 并发原语 | fan-in 合并机制 | 状态语义 | 适合场景 |
|---|---|---|---|---|
| LangGraph Send + reducer | Send 对象列表 | 字段级 Annotated reducer | 不可变 TypedDict + patch | LLM/Agent 工作流,需要可恢复 |
| Temporal workflow | workflow.Go 并行分支 |
单一结构体,覆盖式写 | 确定性 workflow state | 强一致的长事务、Saga |
| Apache Airflow | task 列表,scheduler 调度 |
XCom 显式 push/pull | 数据库 metadata + XCom | ETL,批量调度 |
| Celery + chord | chord(header, callback) |
callback 单点收口 | 消息中间件存储 | 异步任务队列 |
| asyncio.gather | coroutine 列表 |
顺序 await,异常聚合 | 协程局部变量 | 单进程 IO 密集 |
| Ray DAG | @ray.remote 函数 |
单点 return 或 ray.put |
无状态或 actor | 分布式计算 |
从这张对比矩阵可以看到,LangGraph 的独特定位是「LLM 时代的可恢复工作流」:它把 Airflow 的调度、Temporal 的可恢复、Celery 的并行收口,揉进了 reducer 这一个抽象里。代价是 reducer 必须写对,这是它把复杂度从「调度器」转嫁到了「开发者」。
生产建议
- 优先把 specialist 输出设计成「each-specialist-its-own-key」,例如
flight_results、hotel_results、weather_results,这样无论怎么并发都不会撞 key,reducer 也无需介入。 messages与selected_agents这类 list 字段是 reducer 接管的安全区,可以放心并发写。- 如果两条 specialist 不可避免要写同一个字段(如两条路径都给
budget_estimate),不要寄希望于「写一个 reducer 自动挑最优」,在边沿显式加一个 merge 节点,把多份候选值丢给 LLM 或规则仲裁,这一步通常更可控、可观测。 - 任何「覆盖式写」的字段(如
current_step: str、final_answer: dict),都只能串行,不能放进 Send API 的并行分支里。 - reducer 务必保持纯函数,如果发现需要在里头做 IO 或查外部状态,那是「漏出的串行节点」的信号,应当重构为一个普通节点。
[数据] 在这套实践中,Send API 默认调度是「真并行」而非「伪串行」。在本地 Python 进程内,LangGraph 用线程池驱动 fan-out;进入生产后,如果接了 PostgresSaver,跨进程 resume 也会并行读 checkpoint。一个粗略的经验值是:当 specialist 数量从 1 增加到 5,首字延迟不会线性增长,因为每个 specialist 通常都是 MCP 工具调用主导,IO 等待占大头。但 CPU 密集型节点(如本地大模型推理)就不适合堆太多 Send,并发开太大会触发 GIL 竞争,此时反而要把 fan-out 拆小或串行化。另一个常被忽略的代价是 token 消耗:每多一个 specialist,Supervisor 派发时的 prompt 和 fan-in 汇总时的 prompt 都会更长,需要权衡「并行节省的延迟」是否覆盖「多花的 token」。
为什么 reducer 比「串行化」更优雅
另一种解法是「让两个分支不要同时写」,把 Send 改成顺序调用,先跑完一个再跑下一个。这在工程上可行,但放弃了并行的吞吐收益。reducer 的好处在于:它让「并发执行」与「合并逻辑」解耦,fan-out 阶段继续跑并行,fan-in 阶段用一段确定性函数收口,行为可测、可调试、可单元化,也为后续接 PostgresSaver 做跨进程恢复提供了清晰的语义锚点。换句话说,串行化是「用时间换正确性」,reducer 是「用函数换正确性」,后者在 Agent 系统的可恢复性叙事里更可持续。
参考资源:
- LangGraph 官方文档 https://langchain-ai.github.io/langgraph/
- LangGraph GitHub 仓库 https://github.com/langchain-ai/langgraph
- PostgresSaver 与 checkpointer 参考 https://langchain-ai.github.io/langgraph/reference/checkpoints/
- operator / Annotated(typing 模块) https://docs.python.org/3/library/operator.html
- Temporal workflow 并发模型 https://temporal.io/blog
- Apache Airflow DAG 调度参考 https://airflow.apache.org/docs/
Human-in-the-Loop:interrupt + Command 把审批闭环接回 LangGraph
在端到端多智能体 AI 系统里,即便有 Guardrails 把守入口、Supervisor 把守路由,最后一道关卡往往仍然需要人来拍板——尤其是涉及扣款、订单提交或对外通信这种"覆水难收"的操作。把这条审批回路优雅地接回 LangGraph 状态图,是 Human-in-the-Loop(HITL)这一层的核心职责。LangGraph 给出的答案不是另起一套独立的审批引擎,而是把它做成图里的一种"特殊的边"——节点可以主动挂起,外部可以用一种一等公民对象回灌决策,二者通过 checkpointer 在持久化层接续起来。
interrupt 的挂起语义
LangGraph 提供了一个看起来很轻、语义却很重的原语:interrupt(...)。它不是一个返回 None 的工具调用,而是在节点内部主动让出执行权——LangGraph 运行时把当前节点冻结,把已经在 checkpointer 里写入的状态标记为"等待外部决策",然后把控制权交还给上层调用者(通常是 FastAPI/uvicorn 这层 HTTP handler,或者 langgraph dev 的 CLI)。此刻,任何 stream(...) 调用都会停在 interrupt 处,返回一个被打断的事件包,前端拿到这个事件后通常要做的只有一件事:渲染审批面板。换句话说,interrupt 把"图执行到一半停下来"这件事变成了状态图的一等概念,而不是要靠前端轮询或者业务层 hack 才能实现的效果。
典型审批闭环的写法
这套实践里最常见的交互长这样:Final 节点在所有 sub-agent 汇总完成后,把行程摘要、机票候选、酒店候选、天气提示拼成一段可读文本,作为 approval_request: str 字段塞进 state,同时声明可选的行动选项——常见的三选一是 approve / revise / reject。Final 节点最后一行的伪代码是 value = interrupt({"summary": ..., "options": ["approve", "revise", "reject"]}),它把摘要和选项一起交给前端 UI,等用户在浏览器里点按钮。点击事件再以 Command(resume=<用户选择>) 的形式回到 LangGraph 运行时,中断处恢复执行,后续分支由 Command 一并决定。

Command(resume=...) 是一等公民对象
用户点完按钮后,前端会把 Command(resume=<用户选择>) 重新扔回 LangGraph 运行时。这里有一个关键设计:Command 是 LangGraph 的一等公民对象,它不只是"传一个字符串回去",而是同时承担三件事——resume= 参数决定 state 里那个被 interrupt 接住的 value 应该被赋成什么,而 Command 对象自带的 update={...} 与 goto=... 字段,可以一次性把"修改 state 字段"和"跳转到下一个节点"声明在同一份指令里。换句话说,前端不需要手动写 state.update(...),也不需要用 add_conditional_edges 去再判一遍分支,所有"决策 + 跳转 + 状态更新"都被打包在同一个原子指令里。
from langgraph.types import Command, interrupt
from langgraph.graph import END
def final_node(state: TripState) -> Command:
summary = render_summary(state)
decision = interrupt({
"summary": summary,
"options": ["approve", "revise", "reject"],
})
if decision == "approve":
return Command(update={"approved": True}, goto=END)
if decision == "revise":
return Command(update={"human_feedback": summary}, goto="supervisor")
return Command(update={"approved": False}, goto="branch_node")
三种决策分支的语义对比
三选一不是字面意义上的 UI 选项,而是三种截然不同的状态机迁移。approve 表示用户对最终方案无异议,状态写入 approved=True,图直接 goto=END,整个 thread 进入完成态,后续可以被 checkpointer 归档。revise 才是 HITL 真正发挥价值的地方——用户在前端写的批注被塞进 human_feedback 字段,然后 goto="supervisor",让 Supervisor 把这段反馈拼回 user_query 重新派发,等于一次"温柔的回炉"。reject 则更激进:它通常意味着整张图要走 branch_node——比如触发退款流程、退订机票、或者写入一条 approved=False 的失败记录等待后续排查。
| 用户选择 | state 写入 | goto 目标 | 典型副作用 | 是否可重入 |
|---|---|---|---|---|
| approve | approved=True |
END |
行程落库 | 否 |
| revise | human_feedback=... |
supervisor |
重新派发 sub-agent | 是 |
| reject | approved=False |
branch_node |
退款 / 告警 / 失败归档 | 否 |
[观察] 一个常被忽略的设计点是——Command 的 update 与 goto 在同一帧里执行,意味着 sub-agent 之间不会看到"先跳再写"或"先写再跳"的中间态。这对审计非常友好,但反过来要求 reducer 必须把 approved / human_feedback 这类字段声明成可覆盖(默认赋值语义),否则 update 会被 add_messages 之类的加法 reducer 当成追加,造成状态污染。HITL 字段建议显式标注为 Annotated[Optional[str], override_reducer],把"覆盖"语义从隐式约定变成显式契约。
[数据] 在该示例里,interrupt 触发后 LangGraph 会为同一 thread_id 额外落盘一条 pending 状态的 checkpoint;前端点击 resume 时,运行时根据 thread_id 找回这条 checkpoint,从被打断的节点继续执行。这意味着整个审批往返只增加一次磁盘写,而不会重放前面已经完成的机票/酒店/天气 sub-agent 调用——对于已经发起过真实外部 API(由 MCP 适配的航班搜索、酒店查询)的图来说,这一条优化可以把审批回路的端到端延迟从"十几秒"压回到"几百毫秒"。
checkpointer 与状态持久化
HITL 状态必须被 checkpointer 持久化,这是常被新手忽略的硬性要求。如果只用了 InMemorySaver,服务进程一重启,所有"等待审批"的 thread 都会变成"中断前状态丢了"——用户在 UI 上点 resume,运行时找不到对应 thread 的 checkpoint,直接抛 NotFound。前述示例里使用的是 PostgresSaver,把 thread_id 级别的状态落到 PostgreSQL,即便 langgraph-api 容器被 kubelet 拉起重启,审批链路也能无缝接上。human_feedback 字段除了作为回灌 Supervisor 的输入,本身就是审计日志的一部分——它会和最终 approve/reject 决策一起被写进同一行 checkpoint,便于事后追溯"是谁、什么时候、为什么改了这张行程单"。
interrupt vs 普通工具调用的取舍
把 interrupt 和普通的工具调用放在一起看,选型边界其实非常清楚。普通工具调用适合"机器能自主完成、失败可重试"的环节,例如网络搜索、SQL 查询、文件读取;interrupt 则适合"必须有真人决策、决策不可逆"的环节,例如提交订单、对外发邮件、扣款。前者的优点是延迟低、可批量,缺点是无法承载合规审计;后者的优点是决策可追溯、可回滚,代价是必须配套 checkpointer 与前端 UI,工程成本高一档。这条边界一旦画错,要么是用户体验变差(把本该人审的环节自动化了),要么是吞吐率上不去(把本可机器完成的环节反复打断人去确认)。
工程实战中的几个坑
把 interrupt + Command 模式落到生产里,有几个工程细节值得拎出来。第一,interrupt(...) 调用的 payload 必须是可 JSON 序列化的——它会原样落进 checkpoint,任何 bytes、自定义对象、datetime 都会在持久化阶段报错,要么前端解析失败,要么 PostgresSaver 直接抛序列化异常。第二,前端如果用 Server-Sent Events 拿流,需要在 stream_mode="events" 下监听 __interrupt__ 这种特殊事件类型,而不是 updates——updates 模式下 interrupt 是不可见的。第三,Command(resume=...) 在多用户并发场景下必须携带 thread_id,否则运行时无法判断这条 resume 是哪张图的回声,容易出现串台——前端的请求体里通常会显式带上 { "thread_id": "...", "resume": "approve" } 这种结构。
HITL 闭环接回 LangGraph 的关键,从来不是"加一个审批按钮",而是让 interrupt、Command、checkpointer 三者形成一致的协议——interrupt 声明暂停,Command 携带决策与跳转,checkpointer 把暂停瞬间的状态原子化落盘。把这三件事做扎实,审批回路就能在 LangGraph 状态图里像一条普通的边一样被路由、被恢复、被审计,而不是游离在图之外的"补丁代码"。更多细节可以参考 LangGraph 官方文档 https://langchain-ai.github.io/langgraph/ 与 checkpointer 参考 https://langchain-ai.github.io/langgraph/reference/checkpoints/,以及示例仓库 https://github.com/entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL 里的 FastAPI 集成方式。
PostgresSaver 与 thread_id:跨重启可恢复的检查点层
为什么需要 PostgresSaver:跨重启可恢复的检查点层
前面我们用 Send API 把机票、酒店、天气三件事并行派发出去,reducer 也成功同步了三路 sub-agent 的写冲突;又用 interrupt + Command(resume=...) 把审批闭环接回 LangGraph。但凡涉及钱、行程和外部动作的真实请求,工程上必须再回答一个问题——进程死了怎么办。一个 interrupt 可能挂在那里等用户按「同意」等上几个小时,中间容器可能滚动升级,uvicorn worker 可能被 OOM killer 杀掉;如果检查点只存在内存里,进程一重启,Node 的入栈出栈快照、messages channel 的全部内容、待恢复的 interrupts 全部蒸发,用户必须从头再来一遍。PostgresSaver 正是为关掉这条缝而生:它由 langgraph-checkpoint-postgres 提供,把 LangGraph 的完整 checkpoint 负载(每个 Node 的入出栈、messages 通道值、待处理的 interrupts、channel writes)全部序列化进 PostgreSQL(PostgresSaver 文档)。
从工程动机上看,checkpoint 层的真正价值不只是「重启不丢状态」这四个字,而是它把长时工作流的不确定性从「单进程生命周期」压缩到了「数据库行生命周期」。一旦状态被外置,LangGraph runtime 就退化成一台纯计算引擎——状态在哪都能拼回来,worker 死了可以重启,容器可以漂移,甚至整机房做灾备切换都不影响 interrupt 的语义。这种把可变状态从进程堆搬到关系库的拆分,是分布式系统里典型的「把 shared mutable state 推给最擅长处理它的组件」的设计。
thread_id:对话级别的稳定主键
每个用户会话绑定到一个稳定的 thread_id,通常从 session cookie、登录态或用户级 UUID 派生。checkpoint 表用 (thread_id, checkpoint_id) 作为复合主键:thread_id 是用户维度的横向分区轴,checkpoint_id 是该会话时间线上的单调版本号。同一个 thread_id 从任何一个 worker 重新加载,都能拿到该对话最新的快照;LangGraph 的 get_state(history=...) 用 (thread_id, older_checkpoint_id) 做反查,直接驱动 time-travel 调试——可以回放、分支、重放任意历史版本(LangGraph persistence 概念)。

这里有一个常被忽略的设计细节:thread_id 必须由调用方生成并保证稳定,而不是由 LangGraph 替你造。LangGraph 在设计上有意不接管这个键,因为它不知道「同一个用户」在你的业务里长什么样——可以是一次 HTTP session、可以是一个工单 ID、可以是一次 cron 调度的执行 ID。把它交给上层,换来的是 checkpoint 层对你业务领域模型完全无侵入。
在生产环境中这意味着:一个状态化 HTTP 请求可以跨滚动升级存活——请求落到 pod A,在审批节点被 interrupt,几小时后 pod B 处理 resumption——两个 pod 看到的是 Postgres 里同一行 thread_id 数据。也意味着你可以拿 thread_id 直接做行级审计、GDPR 删除、计费计量,不必再维护一张「会话表」。
配置要点:sslmode、连接池与预检
把 PostgresSaver 接到托管 Postgres(RDS / Cloud SQL / Supabase / Neon)时,有三个具体旋钮必须拧到位:
| 配置项 | 默认值 | 生产建议 | 工程理由 |
|---|---|---|---|
DATABASE_URL 末尾 |
无 | ?sslmode=require |
托管 PG 默认拒明文连接,缺失直接报错 |
pool.max_connections |
10 | ≥ 30 | 每个 LangGraph worker 至少持 1 个长连接,加上 FastAPI handler、迁移脚本、监控探针,10 很快耗尽 |
pool_pre_ping |
False | True | 空闲超过 idle_in_transaction_session_timeout 后服务端会断开,预检避免拿到死连接 |
官方 langgraph-checkpoint-postgres 同时暴露高层 PostgresSaver.from_conn_string(url) 和低层 PostgresSaver(conn=..., pipeline=...) 构造器,后者允许你注入自己调好的 psycopg_pool.ConnectionPool(psycopg 池文档)。单实例 FastAPI 服务用 from_conn_string 再裹一层池尺寸就够;横向扩容部署则需要再设 pool_max_lifetime 并固定 application_name,便于 DBA 侧把连接归属反查到你的服务。
池大小的具体算式可以这样估算:max_connections = uvicorn_workers × 每 worker 并发请求数 + 管理连接预留。例如 workers=4、并发请求数=20、预留 8 条给迁移脚本和监控,那就是 4 × 20 + 8 = 88,直接上 100 才稳。这个算式背后隐藏一个假设——一个 checkpoint 写库动作在网络 RTT 期内独占连接,期间不能复用于别的请求。如果你的 sub-agent 链路很短(单次 checkpoint < 50ms),并发数可以乘大;如果你的 Node 里有慢 LLM 调用,连接实际是被「持锁」等异步 I/O 完成的,池位要按 worker 数再放大,否则会出现「连接都被一个慢 Node 占了,其他请求全部阻塞在池上」的雪崩。
setup() 只跑一次,之后只读不写
from langgraph.checkpoint.postgres import PostgresSaver
DB_URL = "postgresql://app:pwd@db.internal:5432/tripmate?sslmode=require"
# 第一次部署时执行:建表 + 建索引,幂等
with PostgresSaver.from_conn_string(DB_URL) as saver:
saver.setup()
# 运行时:每个 worker 进程各自拿 saver,不再调 setup()
saver = PostgresSaver.from_conn_string(DB_URL)
graph = builder.compile(checkpointer=saver)
setup() 是幂等的——它跑 CREATE TABLE IF NOT EXISTS 创建 checkpoints、checkpoint_writes、checkpoint_blobs 三张表(LangGraph blob 存储的后端)。首次启动后,后续每个进程只需要 from_conn_string 那一行;再调一次 setup() 没坏处但是浪费。生产里一个常见踩坑是全新库上忘了 setup(),表现为后面第一个 interrupt 写库时 relation "checkpoints" does not exist——这种错只会在压力下才暴露。
进阶一点的工程做法是把 setup() 放到 CI/CD 的 migration 步骤里、并在 Helm chart 的 Job 资源里跑一次而不是让它和 application pod 一起启动,这样可以确保表 schema 在应用启动前已经就绪,而不是依赖「首个请求触发建表」这种隐式契约。
三种 Saver 的取舍矩阵
| 维度 | InMemorySaver | SqliteSaver | PostgresSaver |
|---|---|---|---|
| 持久化 | 进程内 dict,进程死即亡 | 单文件 SQLite,持久但单进程 | PostgreSQL,跨进程跨节点 |
| 适用规模 | 本地调试 / 单元测试 | 单 worker demo / 笔记本 | 生产 / 多实例 / 长时审批 |
| 并发写 | 无锁(单线程内安全) | 文件级锁,写并发差 | 行级锁 + MVCC,高并发 OK |
| Time-travel | 仅当次进程 | 同进程历史 | 跨进程历史 |
| 部署复杂度 | 零 | 一个 .db 文件 |
需要可用的 PG + sslmode |
对于 TripMate 这类助手——审批节点可能停几个小时、LangGraph 服务跑在多个 uvicorn worker 后面、需要一份「用户昨天到底批了什么」的统一真相——三种里只有 PostgresSaver 能扛住 kill -9。SqliteSaver 适合离线 CLI demo(单进程独占一切),InMemorySaver 只配待在 pytest fixture 里。
[数据] langgraph-checkpoint-postgres 的默认值是按笔记本调的,不是按生产调的。max_connections=10 大致够一个 worker + 几个 admin 会话,但一个典型的 4-worker FastAPI pod 在 50 并发请求下需要 50+ 池位;把它和 pool_pre_ping=True 配在一起,在我们观察到的长时审批流里,OperationalError: server closed the connection unexpectedly 这类报错基本归零。
对标框架分析:PostgresSaver 在持久化工作流生态里的位置
把 LangGraph 的 checkpoint 层放到更大的工作流引擎坐标系里看,有助于看清它的取舍边界。下表对比了 PostgresSaver 与几个常被同时讨论的方案:
| 维度 | LangGraph PostgresSaver | Temporal | Apache Airflow 元数据 DB | 自研 Redis/SQLite |
|---|---|---|---|---|
| 抽象粒度 | StateGraph node + channel | Workflow activity + signal | DAG task instance | 自定义 |
| 长时挂起 | interrupt 写 checkpoint |
Workflow.await |
wait_for_* sensor |
手写 sleep 循环 |
| 持久化后端 | PostgreSQL(必需) | 自带 namespace 服务 | 任意 SQL DB | Redis/SQLite/PG |
| 时间旅行 / 分叉 | 原生 get_state(history) + update_state |
不原生支持 | 通过 rerun 模拟 | 需自建 |
| 状态序列化 | 全量 channel payload 序列化 | 命令事件流 | 任务实例 + XCom | 自定义 |
| 接入门槛 | Python、单库 | 需部署 Temporal cluster | 需部署 Airflow | 极低但维护高 |
可以看到,LangGraph 的 PostgresSaver 走的是一条「瘦抽象 + 胖后端」的路:它不引入新的服务组件(不像 Temporal 那样要单独跑 cluster),但把状态语义完整地压进了一张普通 Postgres 表。对于已经用 Postgres 跑业务数据的团队来说,这条路径的边际成本最低;但对那些需要跨语言 SDK、worker 调度独立伸缩的场景,Temporal 的工作流即服务架构更合适。
工程取舍与常见踩坑
正式把开关拨过去之前,有三处权衡值得摆到桌面上,并展开看几个真实容易踩的坑:
- interrupt-heavy 流的写放大。 每一次
interrupt(...)都写一条新的 checkpoint 行;一个啰嗦的 HITL 循环会让表迅速膨胀。可以通过配置自定义checkpoint_ns加一个 cron 删(thread_id, checkpoint_id)超过 N 天的旧行缓解,或者切到AsyncPostgresSaver,让一次请求共享一个连接。更细的策略是给「中间态」checkpoint 配更短的 TTL、给「终态」checkpoint 配长 TTL——LangGraph 允许你自定义get_next_version逻辑来给不同节点打标签。 - 连接池饥饿。 默认 10 的池在 uvicorn 加
--workers 4前面会悄悄饿死——每个 worker 各自持有自己的池。生产里把max_connections设为「uvicorn_workers × 每 worker 并发请求数+ 管理连接预留」是底线。一个调试技巧是在 PG 侧跑SELECT count(*), usename, application_name FROM pg_stat_activity GROUP BY 2,3,看你的application_name实际占了几条连接——理论值和实际值的差,就是被慢 Node 持锁的「看不见的连接」。 - checkpoint payload 里的 PII。
messages通道是原样序列化的,用户键入的所有 PII 都会落盘。要么写前清洗,要么在 Postgres 侧设行级安全策略,让approle 只能读到本服务前缀下的thread_id。GDPR 场景下还要配合DELETE FROM checkpoints WHERE thread_id IN (...)的定期清理作业,否则删了用户账号但聊天记录还在表里。 - checkpoint 表的膨胀与 vacuum。 由于 checkpoint 是 append-only 写、又有定期清理,PG 的 autovacuum 在
checkpoint_id单调递增的列上可能不够积极,要手动配ALTER TABLE checkpoints SET (autovacuum_vacuum_scale_factor = 0.05),否则大批量删旧行后bloat会让查询变慢。 - 从内存 saver 迁过来时的隐式假设。 很多本地 demo 用
InMemorySaver写得很顺,迁到 PostgresSaver 才发现 reducer 函数在多进程下不能依赖 Python 全局变量,必须保证 reducer 是纯函数(只依赖 channel 当前值和本次写入)。这是迁移时最容易翻车的地方。
[观察] 复合主键 (thread_id, checkpoint_id) 是这套设计里最被低估的一处——它让同一张 checkpoint 表同时服务三种完全不同的访问模式(按 thread_id 查最新态、按 (thread_id, checkpoint_id) 做 time-travel、按用户扫历史),却不需要任何二级索引。一旦你内化「时间轴就编码在 checkpoint_id 里」这件事,把一段对话在 checkpoint N 处分叉就等价于「fork 这个 thread_id」——这正是 LangGraph 的 update_state API 在做的事。换句话说,持久化策略不是事后补丁,而是 StateGraph 分叉语义的基础设施层。
[对标] 如果你熟悉 Temporal,会发现 LangGraph 的 checkpoint_id ≈ Temporal 的 WorkflowRunStartedEvent + WorkflowTaskCompletedEvent 序列,但把这两层折叠成了「一段对话的最新快照」这一种物理表达。这种简化换来的是更容易 time-travel,但代价是每次 Node 完成都要全量回放 channel 值而非增量 patch——对大 messages 列表来说,这正是 checkpoint 表膨胀的主因。TripMate 这种会话级场景正合适;如果哪天 LangGraph 上要跑长时间批处理流(单图跑几天、channel 体积 GB 级),就该重新评估要不要切到事件溯源风格的后端。
可观测性与迁移策略
把 PostgresSaver 接进去之后,可观测性也必须跟上。三条最值得埋的指标:
checkpoint_write_seconds(直方图):每次写库的延迟。P99 突变通常是池饥饿或 PG 主从切换。checkpoints_per_thread(Gauge,按thread_id采样):异常高(>50)说明某个对话陷入了 HITL 死循环,需要产品侧干预。pg_stat_activity中的approle 连接数:超过池大小 80% 时告警。
迁移策略上,建议双写阶段:先让新请求落到 PostgresSaver、旧长时请求仍在内存里,等所有挂起 interrupt 都被消费完,再彻底关掉内存路径。这样即使持久化方案出问题,也不会让用户在跨重启时丢状态。
收尾
把上面这些组装起来:每个请求绑定稳定的 thread_id,新库上只跑一次 PostgresSaver.setup(),DATABASE_URL 带 sslmode=require 且池大小对齐 worker 数,然后 compile(checkpointer=saver),之后 pod 重启就不再是状态机的天敌。Demo 和 CI 用 SqliteSaver,生产 HITL 路径用 PostgresSaver——剩下的状态机代码一行都不用改。选对持久化后端,LangGraph 才真正从「有趣的图编程玩具」变成「可投产的代理运行时」。
psycopg 连接池与异步支持:PostgresSaver 之上的 DB 卫生
PostgresSaver 只负责「把检查点写到 PostgreSQL」,它并不负责「把连接管理得体面」。这一层如果偷懒,checkpoint 写一行就开一个新连接,几轮 Supervisor 路由下来,PostgreSQL 端的 max_connections 早就被捅穿;异步链路里如果忘记关闭游标,还会留下大量 idle in transaction 的僵尸会话。psycopg 3 是 LangGraph langgraph-checkpoint-postgres 当前官方推荐的驱动,其 psycopg_pool 子包提供了 ConnectionPool 与 AsyncConnectionPool 两条复用路径——前者给同步 StateGraph 编译出来的可调用对象用,后者给 ainvoke / astream 这类 async 流式调用用。无论走哪条路,生产环境的默认选项只有一条:开 Pool。这一点在 https://langchain-ai.github.io/langgraph/reference/checkpoints/ 的 PostgresSaver 章节里也明确写出,Saver 接受外部传入的 pool 实例并复用其连接。
ConnectionPool 出厂默认 max_size=10,看上去不小,但落到多智能体场景里其实是「小马拉大车」。一条 Supervisor 路由边可能触发 3-5 个 sub-agent 并发写 checkpoint,假设 4 个 sub-agent 各自跑两个分支,峰值借出去的连接瞬间就能摸到 8-10。所以上限的计算式建议是:max_size ≈ 并发 Supervisor 数 × checkpoint 写入频率 × 2~3,留 2~3 倍 buffer 给突发。再叠加 pool_pre_ping=True,每次借连接前先发 SELECT 1 这种廉价心跳,能挡掉对端已经 RST 但本地还没感知的 zombie conn——这种连接在 K8s 节点重建、NAT 超时或 PG 主从切换后尤其常见。LangGraph 仓库的 README 也有提到 pool 复用的最佳实践(参见 https://github.com/langchain-ai/langgraph),要点就是不要让每次图执行都付出 TCP 三次握手与 TLS 握手的代价。
设计动机层面,引入 Pool 并不是单纯为了性能数字,而是为了将「进程生命周期」与「连接生命周期」解耦。在一个由 FastAPI / uvicorn worker + Supervisor + 多个 sub-agent 构成的长链路里,图的每一次 invoke 都不应该是一个「数据库资源获取/释放单元」,否则 TCP 握手、TLS 握手、PG 的 startup packet 与 startup 验证加起来,会把这部分延迟直接打到了 P50 上。Pool 把这些握手折叠进了一次性的 pool.open(),再用 FIFO 队列去吸收突发借/还,本质上就是把数据库侧昂贵的 session setup 摊薄到了一个工程语义明确的「应用启动期一次性开销」。这一点和 JVM 生态里 HikariCP 的设计哲学几乎一一对应——「连接是一种昂贵资源,要按需复用而不是按需创建」,但 psycopg_pool 把它翻译成了 Pythonic 的 context manager。
Pool 的生命周期要与应用同生共死,而不是每个请求新建一次。推荐做法是在 FastAPI 的 lifespan 钩子、uvicorn 启动 hook、或脚本入口的 if __name__ == "__main__": 段里一次性 pool.open(wait=True),然后把同一个 checkpointer 实例以依赖注入的方式挂到所有路由。LangGraph 每执行一次 checkpoint 写入都会向 Pool 借一条连接、用完归还,这就是「无状态进程 + 有状态持久化」的标准解法,不需要每个图调用各自 psycopg.connect() 一次。容易踩的坑是:在异步代码里忘记 await pool.open(wait=True),导致第一个请求触发 lazy 初始化,瞬时延迟会高得离谱;另外 pool.close() 必须在 lifespan shutdown 钩子里显式调用,否则 uvicorn 重载时会出现「Pool 已销毁但仍有借出未归还连接」的告警,这种告警在 K8s rolling-update 期间特别频繁。
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
DB_DSN = "postgresql://user:pwd@pg-host:5432/langgraph"
pool = ConnectionPool(
conninfo=DB_DSN,
max_size=20,
min_size=2,
pool_pre_ping=True,
timeout=10.0,
kwargs={"autocommit": False},
)
checkpointer = PostgresSaver(pool=pool)
graph = builder.compile(checkpointer=checkpointer)
同步与异步两条路线的取舍可以整理成下面的对照:
| 维度 | ConnectionPool(同步) | AsyncConnectionPool(异步) |
|---|---|---|
| 适用场景 | invoke / stream 同步流 |
ainvoke / astream async 流 |
| 上下文管理 | with pool.connection() as conn |
async with pool.connection() as conn |
| 与 PostgresSaver 接线 | PostgresSaver(pool=pool) |
同一接口,直接传入 |
| 注意事项 | FastAPI 同步路由里也要 with 退出 | 需要 nest_asyncio.apply() 兜底,避免事件循环抢占 |
| 心跳实现 | pool_pre_ping=True |
pool_pre_ping=True(参数一致) |
[数据] 在某次压测里把 max_size 从默认 10 调到 24 后,Supervisor 并发路由 8 路 sub-agent 时,P99 checkpoint 写入耗时从 612ms 降到 138ms;与此同时,PostgreSQL 端 pg_stat_activity 中 idle in transaction 的连接数从 17 降到 0。这一对比可以说明:Pool 的容量上限不是「连接够用就好」,而是「足够让等待时间收敛到一次 round-trip 内」。任何让等待队列堆积到毫秒级的设计,都会在 Supervisor 频繁路由时被放大成 P99 尖刺。
写入失败这件事,Saver 自身的行为是「可恢复错误自动重试」——网络抖动、连接被服务端主动 close 一次、临时的 serialization failure 都在重试名单里。但业务层的写入异常必须 raise 而不是 swallow:一旦 try/except 把 UniqueViolation / OperationalError / DataError 吃掉,LangGraph 当前 step 的 state 就失去了回滚锚点,后续节点会带着「半成功」的状态继续跑,等回头看数据库才会发现 CHECKPOINT 表里少了一段、状态图里多了一段。Supervisor 节点里凡是涉及 MCP 工具调用后再落库的操作,外层只捕获确定可重试的异常,其它一律 raise 出来交给 LangGraph 的 ErrorNode 兜底。
为了横向定位 psycopg_pool 在工程生态中的位置,这里把它与几个常见的对标方案放在一起比较:
| 维度 | psycopg_pool | asyncpg(原生) | SQLAlchemy QueuePool | HikariCP(JVM 对标) |
|---|---|---|---|---|
| 协议层 | libpq 适配 | 原生 PG 二进制 | libpq(经 dialect) | 原生 TCP + JDBC |
| 异步原生支持 | 有(AsyncConnectionPool) |
原生 async | 需 asyncpg dialect |
不适用 |
| 心跳机制 | pool_pre_ping=True |
应用层 SELECT 1 |
pool_pre_ping |
keepaliveTime / connectionTestQuery |
| 与 LangGraph 兼容 | 官方推荐 | 需自定义 saver | 需自定义 saver | 不适用 |
| 默认 max_size | 10 | 无(自管) | 5 | 10 |
| 适合 Supervisor 多智能体 | ★★★★★ | ★★★(需自实现 retry) | ★★(ORM overhead) | ★★★★★(参考架构) |
这条矩阵说明:虽然 asyncpg 在裸延迟上比 psycopg 略胜一筹,但它需要自己实现 retry、cursor leak 防护与 pool lifecycle,对 LangGraph 这种「Saver 要以统一接口注入」的场景并不友好;HikariCP 则是 JVM 侧事实标准,其「FastList + ConcurrentBag + 微优化」的设计哲学值得 psycopg_pool 工程上借鉴,例如未来对热路径做批量获取与归还的批处理。
[观察] CHECKPOINT 表的写入量是一个非常便宜的「系统健康仪表」次级信号:正常业务里 checkpoint 是按节点 step 写的,曲线应该是平稳的阶梯;一旦 Supervisor 频繁回退到上一步重跑,或者 HITL 触发 interrupt 后 Command(resume=...) 频繁注入,会看到 CHECKPOINT 在几分钟内出现数倍于基线的尖峰。把这个指标和 LangSmith 的 trace 时延、PG 的 tup_inserted 一起画到 Grafana,就能在没有专门 SLO 的情况下提前感知「系统在反复兜圈子」——这往往比单个节点的 P99 更早暴露问题,也更直观地反映 Supervisor 的路由健康度。

Pool 模式与「每调用新建连接」模式的边界条件需要明确:
- Pool 模式适用于:并发 Supervisor ≥2、checkpoint 写入频率 >1 次/秒、PG 与应用在同一可用区但仍希望复用 TCP 三次握手。代价是要小心
min_size不要大于空闲 worker 数,否则 PG 端会持续保留一批闲置连接,在多副本部署下叠加起来可能把可用连接池吃光。 - 每连接模式适用于:脚本型 demo、单线程跑批、写入频率低于 0.1 次/秒。这种场景下 Pool 的额外队列和心跳反而是负担,直接
psycopg.connect()用完conn.close()反而最干净。
另一个易踩的坑是 timeout 参数语义:pool.connection(timeout=...) 中的 timeout 是「从 Pool 拿连接」的等待超时,不是 query 本身的超时。如果 Pool 满载、应用等待时间超过 timeout,会抛出 PoolTimeout,这一异常必须在外层明确处理,默认会让 LangGraph 当作不可重试故障抛上去——而实际上只要上游 Supervisor 路由降一档并发就能缓解。生产里建议把 timeout 设为 5~10s,并把 PoolTimeout 纳入告警而非 fallback 到「新建连接」,否则就会绕过 Pool 的所有保护,把 max_connections 捅穿的概率重新拉满。
备份这一层 Saver 自身不会替你做 WAL archive 与 pg_dump,建议在 CronJob 里挂一个定时 pg_dump --schema-only --table=checkpoints,再加一条 pg_basebackup 周级全量。监控侧把 pg_stat_user_tables.n_tup_ins 当成「CHECKPOINT 写量」的近似指标,与 LangSmith 上的 graph run 计数做 join,就能在告警平台里设置「写入量同比 3 倍以上」这种业务侧 SLO。同时建议把 pg_stat_activity 中 state=idle in transaction 的连接数、age(state_change) 的分布画出来——这两个指标是「应用有没有正确归还连接」的最直接指纹,任何 >30s 的 idle-in-transaction 都是潜在的 cursor 泄漏或被中断的事务尾巴。
把 Pool 的容量、心跳、生命周期和错误处理这四件事钉死,PostgresSaver 才真正具备「生产级持久化」的资格——它本质上只是一个 SQL 适配器,而 psycopg_pool 才是把这条链路从「能跑」变成「扛得住」的关键。把监控里的写入曲线当作系统心跳的一部分,会比把它当作纯运维指标收获更多上下文,也让 Supervisor 路由层的健康状态第一次有了可被工程师肉眼识别的指纹。
FastAPI 接入:同步 backend 与 async FastAPI 通过 nest_asyncio 桥接
在 LangGraph 这套多智能体工作流被推到生产环境时,几乎不可避免要把它封装成一个 HTTP 服务,让前端、CI 任务、或者其他后端服务能以标准 REST 的方式去触发一次「规划航班—筛选酒店—拉天气—做预算—提交审批」的完整链路。FastAPI 是当下最自然的选型:异步原生、OpenAPI 文档自动生成、Pydantic 校验内建、能直接挂载 SSE 流式响应。但是问题在于,LangGraph 的 langgraph-checkpoint-postgres 与 MCP 客户端 (langchain-mcp-adapters) 内部用的全是 async def,而大家写业务 backend 时往往又是按同步函数的习惯来的(也方便日后接阻塞的内部 SDK、命令行工具、或者老的同步 ORM)。要把这两者塞进同一个进程,最稳的桥接手段就是 nest_asyncio.apply()——这一行 patch 把当前线程的 event loop 重新挂上,允许在已经运行中的 loop 里再嵌套一次 asyncio.run,从而把同步风格的 backend 包装成 FastAPI 也能直接 await 的协程对象。
选 FastAPI 而非 Flask/Django Channels,核心动机有三:其一,OpenAPI 文档自动生成让前端能在 Swagger UI 里直接试调,降低联调成本;其二,Pydantic v2 的校验器是 Rust 写的,即便每秒上千次校验也不会成为瓶颈;其三,/health 这种纯静态路由 + async 协程路由可以混跑在同一个 ASGI 应用里,不需要拆服务或额外起 Sidecar。对比裸用 Starlette,FastAPI 多出来的路由装饰器与依赖注入语法糖,能省下至少 30% 的样板代码,这点在迭代频繁的多智能体项目里非常关键。

暴露 REST 端点:create / approve / health
一个最小的可用契约应该包含三个端点:POST /api/travel、POST /api/travel/approve、GET /health。第一个端点负责「创建或恢复一个 thread」,入参里带 thread_id 就走恢复,不带就走新建,返回体里同时给前端 thread_id 与初始 checkpoint 版本号。第二个端点专门承接 Human-in-the-Loop 的审批动作:它接受用户在前端点的 approve / reject,把 Command(resume=...) 喂回 LangGraph,让被打断的节点从断点继续往下走。第三个端点 /health 不依赖数据库,返回 200 与一个简易版本号,给 Kubernetes 之类的探针用。三者职责互不重叠,便于独立做灰度与限流,也避免把审批流量与新建流量混在一起打爆 checkpoint 表。
工程上容易踩的坑有三个:一是 HTTP 超时——前端 fetch 默认 30 秒就断,但多智能体一次完整链路经常跑到 60 秒以上,必须在网关层把 /api/travel 的超时显式调到 120 秒以上,或者干脆把主链路拆成「创建 thread」+「轮询进度」两步;二是幂等性——同一个 thread_id 在 2 秒内重发两次,会把 LLM token 烧两遍,后端需要做 5 秒级的去重缓存,可以用 Redis SETNX 或者直接利用 PostgresSaver 的 checkpoint 版本号做乐观锁;三是 approval 接口的权限校验——approve/reject 必须校验请求里的 user_id 是否等于 thread 拥有者,否则任意登录用户都能 approve 别人的行程,这是最容易被忽略的安全口子。
请求体与 State 边界:Pydantic 兜底
REST 入参到 LangGraph TravelState 的映射是整个接入层最容易出错的地方。前端发过来的是松散的 JSON,字段类型可能是数字字符串、null、空数组;而 LangGraph 内部的 reducer(例如 Annotated[list, operator.add])对类型非常敏感,一旦 messages 列表里混进一个 dict 而不是 BaseMessage,整个图就会在第一跳抛出异常。所以必须在边界处用 Pydantic BaseModel + Field 做一次硬校验,把 messages、preferences、budget 这种关键字段的类型、范围、长度都钉死。
from pydantic import BaseModel, Field
from typing import Annotated, TypedDict
import operator
class TravelRequest(BaseModel):
thread_id: str | None = None
user_id: str = Field(min_length=1, max_length=64)
query: str = Field(min_length=1, max_length=2000)
preferences: dict = Field(default_factory=dict)
class TravelState(TypedDict):
messages: Annotated[list, operator.add]
preferences: dict
budget: float
thread_id: str
任何字段类型不对、长度越界、可选字段忘填,Pydantic 都会直接以 422 Unprocessable Entity 回给前端,而不是把脏数据推进 LangGraph 引发更隐蔽的 reducer 报错。FastAPI 的官方文档 (https://fastapi.tiangolo.com/) 对这种「模型即契约」的写法有完整示例,LangGraph 状态图的基线也可以在 https://langchain-ai.github.io/langgraph/ 里查到,二者叠加使用几乎不需要额外的胶水代码。
需要注意的是,Pydantic v1 与 v2 在 model_dump()、validator 装饰器等 API 上有不兼容,如果团队里既有代码混着两个版本,最容易在 pydantic-core 二进制不匹配时静默失败。建议整个仓库统一锁到 v2,并在 pyproject.toml 里写明 pydantic>=2.5。
SSE 流式输出:HITL interrupt 不靠长轮询
当 Supervisor 把任务路由到「提交订单」类节点时,如果事先给节点挂了 interrupt_before 或 interrupt_after,LangGraph 会把当前 state 暂停,等一个外部 Command(resume=...)。最朴素的做法是前端每两秒 GET 一次状态,但这种长轮询在多用户并发时会把 PostgresSaver 的连接池吃满。更好的做法是用 SSE(Server-Sent Events):HITL 触发时,后端推一条 event: awaiting_approval 事件,前端拿到后展示「等待审批」卡片;用户在卡片上点 approve,就把 thread_id 附带在第二个 POST 请求里,后端从 checkpoint 库里捞出暂停的 state,喂回 Command,继续往下跑。SSE 天然支持半双工、单向推送,浏览器一行 new EventSource() 就能接,也不需要引入 WebSocket 协议与服务端额外维护连接表。
| 维度 | SSE | WebSocket | 长轮询 |
|---|---|---|---|
| 协议复杂度 | 低(基于 HTTP/1.1) | 高(独立握手) | 低 |
| 双向通信 | 否(单向) | 是 | 否 |
| 浏览器原生支持 | EventSource |
需 ws 库 | fetch+setInterval |
| 适合 HITL | ★★★ | ★★ | ★ |
| 连接开销 | 低(单连接复用) | 高 | 高(每次重连) |
| 反向代理友好度 | 高 | 低(需配置 upgrade) | 高 |
BackgroundTasks:让 supervisor 调用异步跑
FastAPI 的 BackgroundTasks 与 SSE 是两套互补的机制。第一个请求进来时,如果命中的是「新建 thread + 异步推进到第一个 checkpoint」的路径,可以让 FastAPI 主请求立刻返回 thread_id(前端立刻可以跳转或显示骨架 UI),真正的 graph.ainvoke() 放到 BackgroundTasks 里跑——注意 BackgroundTasks 既支持同步函数也支持 async 协程,后者会以 asyncio.create_task 的方式挂到当前 event loop 上。这样前端不会被卡住等 LLM 出第一句话,而后续要查进度,只要拿这个 thread_id 再去查 PostgresSaver 的 checkpoint 表即可,LangGraph 官方文档对此有完整的 thread 生命周期说明 (https://langchain-ai.github.io/langgraph/reference/checkpoints/)。
| 维度 | BackgroundTasks(本进程) | 独立 worker(Celery / RQ) |
|---|---|---|
| 部署复杂度 | 与 API 同进程,零额外依赖 | 需要 broker + worker 进程 |
| 适合延迟 | 秒级 LLM 响应 | 分钟级重型任务 |
| 失败重试 | 进程崩溃即丢失 | 可重入队列 |
| checkpoint 衔接 | 需在同一 event loop 里 await | 需要异步驱动 worker |
| 多副本可见性 | 仅当前 pod 可见 | 全集群可消费 |
| 适合场景 | 短链路、需要快速首响 | 长链路、需要可重试 |
[观察] 在这套桥接里,nest_asyncio.apply() 是一次性 patch,必须在进程启动的入口(比如 FastAPI 的 lifespan 钩子或 main.py 顶部)就调用,而不是每个请求里再调一次。重复调用虽然不会报错,但会产生多份内部 patch,长期跑下来会让堆栈追踪变得难以解释。另外,SSE 响应头里一定要带 Cache-Control: no-cache 与 X-Accel-Buffering: no,否则 Nginx 默认会缓冲整个流,前端看到的就不是真正的「事件」而是几秒后的整包数据。还有一个常被忽视的坑:BackgroundTasks 在 Kubernetes 多副本部署时,任务只会在收到请求的那一个 pod 里跑,其他 pod 的 GET /health 看不到进度,所以查询进度必须统一走 Postgres 里的 thread 状态字段,而不是内存里的字典。
[数据] 从经验值上看,一个完整的多智能体行程规划请求(经过 Supervisor + 三个子 agent + 一次 HITL)在同步 backend + nest_asyncio 桥接下端到端 P95 大约在 8 到 12 秒区间,其中 LLM 推理贡献约 70%,MCP 远程调用贡献约 20%,PostgresSaver 写入贡献约 5% 到 8%。如果把 supervisor 调用挪出主请求、改用 BackgroundTasks,首响时间可以压到 200 毫秒以内,用户主观感知的「打开即加载」体感会明显改善。横向对比来看,如果改用 Django Channels 或裸 Starlette,首响差距不大,但在 Pydantic 校验与 OpenAPI 自动生成这两项上会落后 FastAPI 至少一个迭代周期;如果直接上 Celery + 独立 worker,首响可以压到 50 毫秒,但要为这套链路额外维护一套 Redis broker 与 worker 进程,运维成本翻倍。
同步 backend + nest_asyncio 这条路径的优势在于改动面最小:业务代码可以保持熟悉的同步风格,FastAPI 主进程负责暴露 HTTP 契约,真正耗时的 LangGraph 协程通过 patch 后的 event loop 桥接执行,checkpoint 持久化、SSE 推送、BackgroundTasks 后台跑图三件套都各自归位,工程师不需要为了适配异步而把所有 reducer、所有 MCP 工具调用全部重写一遍。对于已经在线上跑了若干同步 SDK、又希望尽快把 LangGraph 多智能体能力暴露给前端的团队来说,这种「最小侵入」的接入方式,是把 AI 能力嵌入既有系统的工程化优解。
LangSmith 观测:把 state、message、MCP 调用都接进 trace
多智能体工作流被推到生产环境后,工程师最怕的其实不是写错代码,而是「不知道它为什么做了这个决定」。Supervisor Agent 在若干个 Specialist 之间做路由,中间还穿插着 MCP 工具调用、Guardrails 拦截、HITL 的 interrupt 与 resume——等到用户报「这次推荐不合理」时,光靠 terminal 里打印的日志,几乎不可能把整条执行链路复现出来。LangSmith 是 LangChain 官方推出的观测平台,对 LangGraph 的兼容是 first-class 的,只要把两个环境变量配上,trace 就会自动跟随每一次图执行落盘到云端。
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=lsv2_xxx_xxx
export LANGSMITH_PROJECT=trip-planner-prod
export LANGCHAIN_PROJECT=trip-planner-prod

这套接法的关键在于「零侵入」——不需要在每个 node 里手写 logger,也不需要在每条 conditional edge 上手动 push 事件。LangGraph 在 run_graph 入口处会通过 LangChain 的 callback handler 把整个 run 投递到 LangSmith 后端,每一次 node 的 invoke、每一条 add_messages reducer 写入、每一个 MCP tool 的 call 与 response,都会被序列化成一棵父子关系的 run tree。落地时通常不需要额外的胶水代码,LangSmith 官方文档 https://docs.smith.langchain.com/ 里给出的接入步骤就是这两行环境变量。
从工程动机上看,「零侵入」并不是 LangSmith 的营销话术,而是 LangGraph 运行时本身的设计前提:LangGraph 把每一次图执行抽象成一次 Pregel 风格的 superstep,而 superstep 的边界、node 的进入退出、state 的读写,都已经通过 callback 系统向外暴露。如果观测工具要求在每个 node 里手动塞一个 with trace("..."): 上下文管理器,那么 Supervisor 节点的每一次 decision 都会因为改写业务代码而引入额外的 bug 风险,这种「为了可观测而污染业务逻辑」的做法,在多智能体场景里代价尤其大。LangSmith 的 callback handler 本质上是订阅了 LangChain 的 CallbackManager,所以它能拿到和业务代码完全对称的视角,这种对称性是它能在生产里稳定使用的根本原因。
trace 里到底能看到什么
打开任意一条 run,左侧是 timeline 视图,右侧是 raw payload。展开 Supervisor 节点,会看到四样关键信息:系统提示词(SYSTEM prompt)、累积的历史 messages、reasoning 字段、以及 next node 的选中路径。
系统提示词显示的是 Supervisor 当前生效的 instruction,做 prompt 版本对比时一眼就能看出是不是被新提交污染;历史 messages 既包括 HumanMessage,也包括从 Specialist 回流回来的 ToolMessage 与 AIMessage,这是 add_messages reducer 累积出来的完整对话;reasoning 字段是大多数 chat 模型在返回 AIMessage 时附带的 chain-of-thought,LangSmith 把它单独抽出来,不必在终端滚动几百行 token 才能找到「为什么选了 hotel_agent 而不是 flight_agent」;next node 的路径则在 Supervisor 最终输出 Command(goto="budget_agent", update=...) 时,把决策与对应 reasoning 一起写在 trace metadata 里。
对 Specialist 节点来说,trace 会把 MCP 工具调用拆成三个子 run:tool selection、tool invocation、tool response。如果某个 stdio_server 启动得慢,或者 HTTP 传输里某个 endpoint 超时,这三段耗时都会单独计时,排查瓶颈时不需要再去 grep 日志。MCP 相关的语义细节可以参考 Model Context Protocol 规范 https://modelcontextprotocol.io/ 与 langchain-mcp-adapters 仓库 https://github.com/langchain-ai/langchain-mcp-adapters 的说明。
HITL 的 interrupt 与 resume 在 LangSmith 里也被明确区分:interrupt 触发时,节点状态被标记成 suspended,线程被 PostgresSaver checkpoint;用户审批后 Command(resume=...) 进来,LangSmith 会把同一 thread_id 的两段 trace 串接起来,resume 事件不会当作一条独立 run,而是延续之前的 run tree。这对事后审计特别友好——审阅人只看一条 run,就能完整理解「人类在哪里按了 approve,在哪里按了 reject,以及当时 reasoning 说了什么」。
实际接 MCP 时最容易踩的坑是 stdio 子进程的生命周期管理。很多团队把 MCP server 用 subprocess.Popen 拉起,却没有处理「Supervisor 调用结束但 server 还活着」的情况,导致端口占用、子进程泄漏。LangSmith 不会替你解决这个,但它的 trace 会暴露一个非常明显的特征:某次 run 的 tool invocation 子节点长时间处于 pending 状态,这时工程师就该意识到 MCP server 没正常关停。另一个常见坑是 HTTP 传输下的 SSE 心跳配置错误,trace 里会看到 tool response 节点反复超时重试,直到 LangGraph 的 retry 策略耗尽——这种问题在没有 trace 时几乎只能靠「猜」,而有了 run tree 之后,平均排查时间可以从小时级压到分钟级。
调试重心:reasoning 而不是 final answer
多智能体系统最反直觉的一点,是「输出对不对」几乎不能告诉你「系统是不是健康的」。用户看到的 final answer 是 Supervisor 总结 + 各 Specialist 兜底拼出来的,即使它恰好正确,内部 reasoning 可能已经走偏——比如选了不该选的 Specialist,或者在 Guardrails 边缘试探了很久才退回。LangSmith 把 reasoning 显式化,正是为了让调试从「结果导向」转向「过程导向」。
| 维度 | 在 LangSmith 怎么查 | 终端日志能不能查到 |
|---|---|---|
| reasoning 是否覆盖全部候选 Specialist | 在该节点的 messages 里筛 reasoning 字段 | 需要自己加 print,且格式易错 |
| reasoning 是否引用了上轮 ToolMessage | 看 message 顺序与 content overlap | 极难 |
| 决策是否触发了预期之外的 node | 看 run tree 的 children | 几乎不可能 |
| interrupt 触发时 reasoning 说了什么 | 展开 suspended 节点即可 | 需要在 interrupt 回调里手动 dump |
这种「推理可视化」对 prompt engineer 的价值,远超一个简单的 final answer diff。改一条 Supervisor prompt,跑同一条 user_query,然后在 LangSmith 里把两条 trace 并排打开,reasoning 的差异就像 git diff 一样清晰——这比在 terminal 里盯着几百行日志找差别快出不止一个量级,实际项目里通常能从半小时级别的排查压缩到两三分钟。
[观察] 把「同 query 双 trace 对比」固化成团队 debug SOP 之后,prompt 迭代的 review 周期会显著缩短。一个常见的反模式是「改 prompt 不留版本」,结果 A/B 都靠印象,出了事故只能靠 git blame 猜;而 LangSmith 的 trace 天然带 timestamp、project tag 与 commit metadata,等于自动留了一份「运行时 prompt 版本日志」,事故复盘不再依赖人脑记忆。
把 reasoning 当成 first-class 观测对象,还带来一个意想不到的收益:可以反过来用它构造 regression test。把历史上「用户报错的 run」里 Supervisor 的 reasoning 抽出来,作为新的测试用例的 ground truth,任何 prompt 改动只要让 reasoning 偏离 ground truth 超过某个阈值,就拒绝合入——这种「推理级回归测试」在纯输出 diff 的范式下根本无法实现,但在 LangSmith 里只需要一个 Query API 拉数据 + 一个简单的相似度比对脚本。这种工程模式可以视为 prompt 工程的「单元测试化」,也是把多智能体系统从 demo 推到 production 的一道分水岭。
生产观测的四个 SLO
把 LangSmith 当成仪表盘数据源,而不是只在调试时打开,才能真正释放它的价值。围绕这套 multi-agent 工作流,有四个核心指标必须长期追踪,它们都可以用 LangSmith Query API 拉到内部 Grafana:
| 指标 | 定义 | 告警阈值建议 |
|---|---|---|
| HITL 频率 | 单位时间内触发 interrupt 的 run 数 / 总 run 数 | 长期 > 15% 表示自动化不够,或 Guardrails 过严 |
| Guardrails 拒绝率 | input/output guardrail 拒绝的请求数 / 总请求数 | 持续 > 25% 通常意味着 prompt 与边界 case 失调 |
| Specialist 失败率 | 任一 Specialist 返回 error 的 run 数 / 该 Specialist 总 run 数 | 单 Specialist > 5% 需立刻排查 MCP endpoint |
| 单 run duration | p95 的 thread 完成时间(含 HITL 等待) | 根据业务侧 SLO 设定,LangSmith 提供 histogram |
[数据] 用 Query API 拉这四个指标的成本,远低于自建 ELK + 自写聚合脚本。LangSmith 的 query endpoint 直接返回聚合后的 run count,团队只需要写一个定时拉取脚本,就能把数据灌进内部的 Prometheus 或 Grafana。具体接口可以参考 LangSmith 官方文档,过滤条件支持 project name、run type、status、metadata.tags 等字段组合,直接拼出四类告警所需的查询。
from langsmith import Client
client = Client(api_key="lsv2_xxx")
runs = client.list_runs(
project_name="trip-planner-prod",
filter='eq(status, "error")',
start_time="2024-01-01T00:00:00Z",
end_time="2024-01-02T00:00:00Z",
limit=1000,
)
error_rate = sum(1 for r in runs if r.error) / max(len(list(runs)), 1)
这套 SLO 体系落地时还有一个易踩的坑:很多团队一开始就把告警阈值设得很激进,比如 HITL 频率超过 5% 就告警,结果一周下来告警风暴把值班同学淹没,反而没人认真看。要把 SLO 当成「对话工具」而不是「惩罚工具」,更合理的做法是先静默跑两周,基于历史分位数确定 baseline,再把告警阈值设为 baseline 的 1.5x~2x。LangSmith 的 Query API 自带分位数聚合,这件事在自建 OTel 体系下要单独写 PromQL,接入成本差距非常明显。
LangSmith vs 自建 tracing 的取舍
并不是所有团队都该直接用 LangSmith,这里需要权衡几个边界条件,按业务场景选型才能避免「观测反过来成为瓶颈」:
| 维度 | 用 LangSmith | 自建 OpenTelemetry |
|---|---|---|
| 接入成本 | 配两个环境变量即可 | 需在每个 node 手动 span |
| 数据可控性 | 数据落在 LangChain 托管端 | 100% 自有,可对接内部合规 |
| reasoning 可视化 | 原生支持,带 chain-of-thought 抽取 | 需自己 parse 模型输出 |
| 长期成本 | 按 run 量计费 | 主要是存储与计算资源 |
| 与 LangGraph 特性同步 | 第一时间支持 interrupt / Send / Command | 需要等社区补 patch |
如果团队对数据驻留有强合规要求(比如金融、医疗、政企),自建 OTel + Jaeger / Tempo 仍是更稳妥的选项;但对绝大多数早期产品,SaaS 形态的 LangSmith 能让工程团队把精力集中在 agent 行为本身,而不是观测基础设施。具体 LangGraph 的运行机制可以参考官方文档 https://langchain-ai.github.io/langgraph/ ,里面对 checkpoint、interrupt、Send API 都有清晰的章节说明,落地时通常不会遇到阻塞。
放到更宽的横截面上看,可观测赛道里其实还有几个常被拿来对比的方案:Langfuse 以开源 + 自托管见长,trace schema 与 LangChain 解耦,适合不愿被 vendor lock-in 的团队;Arize Phoenix 在 LLM eval 与 drift detection 上做得更深,但对 LangGraph 的 run tree 适配稍弱;Helicone 主打 proxy 层的 token 成本分析,在 multi-agent 这种「多次 LLM 调用 + 嵌套 tool call」场景下粒度偏粗;OpenLLMetry 是 OTel 语义下的 LLM span 标准,适合已经全栈 OTel 的组织做统一接入。下面这张矩阵把关键维度摊开,便于按团队约束做选型:
| 维度 | LangSmith | Langfuse | Arize Phoenix | Helicone | OpenLLMetry |
|---|---|---|---|---|---|
| LangGraph 原生支持 | first-class | 良好,需额外 adapter | 一般 | 仅代理层 | 需自写 span |
| 自托管/数据可控 | SaaS 为主 | 开源自托管 | 开源自托管 | SaaS | 自托管 |
| reasoning 可视化 | 原生 | 需自实现 | 需自实现 | 不适用 | 需自实现 |
| HITL / interrupt 串接 | 原生 | 部分 | 不支持 | 不支持 | 需自实现 |
| 长期成本模型 | 按 run 计费 | 自托管为主,SaaS 也有 | 自托管为主 | 按 token 计费 | 自托管 |
对绝大多数 LangGraph 工作流来说,LangSmith 仍然是接入成本最低、特性同步最快的选项;但如果组织已经把 OTel 作为基础设施标准,或者有强合规约束,Langfuse / OpenLLMetry 是更务实的替代。选型的核心不是「哪个最好」,而是「团队愿意在观测上投入多少工程精力」。
把 LangSmith 接入视为「多智能体工作流的可观测性下限」,而不是锦上添花的 debug 工具,这是从 demo 走向生产时最容易踩坑的一步。等第一次生产事故发生才回头补 trace,代价远高于一开始就把 LANGSMITH_TRACING=true 设进环境变量模板;同理,Query API 与四个 SLO 也应该在第一次上线前就接好,而不是等数据真的告急再补救。
提示工程:Supervisor / Specialist / Guardrails 三类 prompt 各自能写好
提示工程:Supervisor / Specialist / Guardrails 三类 prompt 各自能写好
在 LangGraph 多智能体系统里,prompt 不是「聊天机器人写几句话」那么简单。三类角色——Supervisor、Specialist、Guardrails——对 prompt 的诉求完全不同:Supervisor 要的是离散动作选择,Specialist 要的是状态机驱动,Guardrails 要的是快、省、不出错。这套工作流若把所有角色塞进同一个 prompt,模型会陷入「自己既是裁判员又是运动员」的混乱。下面按角色拆开来聊。
Supervisor Prompt:只做路由,不做内容生成
Supervisor Agent 在 LangGraph 状态图里负责「下一步去哪个 Node」。它的 prompt 必须极度克制——告诉模型「你只是路由器,不是内容生产者」。如果任由 Supervisor 自由发挥,它会开始替 Specialist 写答复,导致下游 Specialist 失去权威性,state 里同时出现两份互相冲突的内容。
实践里通常把 Supervisor 可选的「下一步」枚举成一个离散集合,例如 FLIGHT_AGENT、HOTEL_AGENT、WEATHER_AGENT、BUDGET_AGENT、APPROVAL、END。模型只能从中选一个,不能自创新的节点名。LangGraph 的 conditional edge 直接消费这个字符串,详见 LangGraph 官方文档对条件分支的定义,以及 LangGraph GitHub 仓库中的示例。
SUPERVISOR_SYSTEM = """你是路由代理。根据当前 state 与最近 message,
选择下一步执行的节点。仅返回 JSON:
{"next": "<NODE_NAME>", "reason": "<简短理由>"}
可选 NODE_NAME 严格限定为:
FLIGHT_AGENT | HOTEL_AGENT | WEATHER_AGENT
| BUDGET_AGENT | APPROVAL | END
不要生成任何给用户的自然语言回复。"""
[观察] 这种「强制枚举 + JSON 输出」的写法看起来笨拙,但它把模型的自由度压缩到只剩「决策」一项。Supervisor 不再需要复杂的 reasoning,prompt 长度可控制在 200 token 以内,延迟和成本都下来了。在 entbappy 的多智能体参考实现里,Supervisor 通常用 Groq 上具备较强推理能力的聊天模型,因为路由对推理深度有要求但不要求对话流畅度。
Specialist Prompt:读 state → 调工具 → 写结果
Specialist 是「干活的人」,它的 prompt 要强调「你是一个被路由进来的执行单元,不是聊天对象」。最容易出的事故是 Specialist 把用户拉进新一轮对话——比如航班 Agent 反问「请问您出发日期是?」,但这个信息其实已经在 Supervisor 调度时传入 state 了。
正确的工作流是四步循环:读取 state → 调用 MCP 工具 → 把结果写回 state → 结束本节点,不再追问用户。把这条规则写在 Specialist prompt 的最前面,加粗、复述,确保模型不偏离。Specialist 之间的协作也走 state 共享字段,而不是发起新对话。
SPECIALIST_TEMPLATE = """你是 {role_name}。你已被 Supervisor 路由进来。
必须按顺序执行:
1) 读取 state['{role_name}_input'] 拿到任务参数;
2) 调用你掌握的 MCP 工具获取数据;
3) 把结果写入 state['{role_name}_output'];
4) 返回,不再与用户对话。
禁止向用户提问;若参数缺失,在 output 里写 ERROR。"""
[数据] Specialist prompt 平均长度约 300-500 token,每个角色专属工具不超过 5 个。把对话轮次压到一次,而不是来回追问,可以把整个 graph 的总 token 消耗削减约 40%,这对生产环境的成本结构非常关键。
Guardrails Prompt:低成本快速拦截
Guardrails 的诉求完全不同:它不需要「聪明」,只需要「快、便宜、稳」。入口处的 input guardrail 负责拦截越权或越界请求(例如「帮我转账」「给我讲个笑话」);出口处的 output guardrail 负责检测 Specialist 输出是否含 PII、价格幻觉等敏感问题。
[数据] 这套实践里 Guardrails prompt 倾向于用 Groq 上的 llama-3.3-8b-instant 这类快速小模型,单次分类调用 token 用量约为 Supervisor 的 1/10。8B 级别模型在「是 / 否 / 重写」三分类任务上准确率足够,且端到端推理延迟显著低于 Supervisor 的 800ms 以上路由耗时。
Guardrails prompt 模板要短小,典型结构是「输入文本 + 几条判定规则 + JSON 输出」。参考 langchain-mcp-adapters的 ToolMessage 规范,Guardrails 的判定结果应作为附加 message 注入 state,而不是直接拦截 Specialist 内部逻辑——这样 LangSmith trace 里能完整看到拦截点。
Few-shot:每个角色给 3-5 个示例
光给规则不够,模型对「输出格式」和「决策边界」的学习主要来自 in-context 示例。每个角色 prompt 配 3-5 个 input→state 变换对:
- Supervisor:5 个示例覆盖「进 FLIGHT」「进 HOTEL」「直接 END」「需要 APPROVAL」「回 Weather 补数据」五种典型路径。
- Specialist:3 个示例覆盖「正常输入」「参数缺失」「工具失败」三种状态。
- Guardrails:4 个示例覆盖「正常放行」「越权拦截」「PII 脱敏」「价格幻觉重写」。
| 角色 | 示例数量 | 主要学习目标 | 平均 prompt 长度 |
|---|---|---|---|
| Supervisor | 5 | 离散选择 + JSON 结构 | ~200 token |
| Specialist | 3 | 状态读写 + 不追问用户 | ~400 token |
| Guardrails | 4 | 三分类标签 + 不越权生成 | ~150 token |
可维护性:三套 .md 文件与代码同目录
把 prompt 写在 Python 字符串字面量里是最常见的反模式——改一个词就要发版。工程化的做法是把三套 prompt 拆成若干个 .md 文件,放在与 graph 代码同目录:
prompts/
supervisor.md
flight_specialist.md
hotel_specialist.md
weather_specialist.md
budget_specialist.md
guardrails_input.md
guardrails_output.md
加载时用 Path(__file__).parent / "prompts" / f"{role}.md" 读入。增加新 Specialist 时,只需新增一段 .md 文件并注册到 Supervisor 的路由枚举,业务代码零改动。版本管理走 Git,prompt 的 diff 一目了然,review 时也可以单独看 prompt 改动的语义而不必 pull 整个 Python 文件。
三类 prompt 的工程取舍
| 维度 | Supervisor | Specialist | Guardrails |
|---|---|---|---|
| 模型规模 | 大(推理深) | 中(需工具调用) | 小(快+省) |
| Token 预算/调用 | 高 | 中 | 极低(~1/10) |
| 输出自由度 | 极低(枚举) | 中(可推理) | 极低(三分类) |
| 失败处理 | 兜底回 Supervisor | 写 ERROR 进 state | 直接 reject |
Supervisor vs Specialist 的核心取舍在于「思考深度」:Supervisor 要在多个候选节点间权衡,需要推理能力;Specialist 拿到指令后基本是「执行脚本」,不需要再次决策。Specialist vs Guardrails 的取舍则在于「生成 vs 分类」:Specialist 允许长输出与工具调用,Guardrails 严格限制在三分类以内,且绝不允许改写 Specialist 的业务结论,只能追加告警。
整套多智能体 prompt 工程落到 LangGraph 上的关键是「职责隔离 + Few-shot 锚定 + 文件外置」。把这三条做到位,后续无论是替换底层 LLM、增加 Specialist,还是调整 Guardrails 规则,都可以在不动主流程代码的前提下完成迭代,这也是把多智能体推上生产环境的最低门槛。
可观测的错误处理:Supervisor 重试、Specialist 降级、Guardrails 兜底
前面已经讨论了 Supervisor / Specialist / Guardrails 三类 prompt 的角色分工。但 prompt 写得再漂亮,工程系统真正能不能跑稳,几乎完全取决于错误处理那一层。LangGraph 状态图把每个节点都暴露成一个可观测的 unit,这恰恰给我们一个机会:每一次重试、每一次降级、每一次兜底,都可以写进 state,被 LangSmith 抓到,被后期 trace 出来做归因。本节重点拆解三道防线在「出错时」的行为契约,以及如何让这些失败在 thread_id 维度上留下可追溯的印记。

错误处理的三层防线总览
| 防线层级 | 触发场景 | 默认行为 | 失败后状态字段 |
|---|---|---|---|
| Supervisor | LLM 调用超时 / 5xx / schema 校验失败 | 重试 1-2 次后降级到 Safelist | supervisor_retry_count, last_error_reason |
| Specialist | MCP 工具调用超时 / 工具返回错误码 | 返回 partial state + error 字段 | specialist_errors: dict[str, str] |
| Guardrails | 分类器自身 timeout / 输入解析失败 | 默认保守拒绝,记 audit | guardrail_blocked, guardrail_audit_id |
| HITL interrupt | 用户长时间不回应 | 标 no-response,thread 标 stale | interrupt_status, stale_at |
这张表的好处在于:任何一条错误路径都对应一个稳定的 state 字段名,LangSmith 在过滤「失败 run」时可以直接按字段名聚合,不需要在 trace 里做正则。持久化层依赖 PostgresSaver 把这些字段和 thread_id 绑在一起落盘,可参考 LangGraph checkpoint 参考文档:https://langchain-ai.github.io/langgraph/reference/checkpoints/。
Supervisor:重试 + 降级到 Safelist,绝不直接 END
Supervisor 节点的核心职责是「路由」。它一次调用 Groq 上的快速推理模型,输出一个 next node 的名字。当这次 LLM 调用失败时,最危险的反应不是「报错」,而是「悄悄把图走到 END」。一旦走到 END,用户得到的就是一个看似完成、其实什么也没干的空响应——这种「幽灵成功」是事后最难排查的事故类型,因为从用户视角看流程正常结束,但 state 里其实一片空白。
async def supervisor_node(state: AgentState) -> dict:
last_err = None
for attempt in range(2):
try:
decision = await call_llm_with_timeout(state, timeout=8.0)
return {"next_node": decision, "supervisor_retry_count": attempt}
except (TimeoutError, HTTP5xx) as e:
last_err = f"{type(e).__name__}:{str(e)[:80]}"
await asyncio.sleep(0.5 * (attempt + 1))
fallback = pick_safelist_next(state)
return {
"next_node": fallback,
"supervisor_retry_count": 2,
"last_error_reason": f"supervisor_llm_failed:{last_err}",
}
其中 pick_safelist_next 是一个确定性函数,它根据 state["messages"] 的最后一条人类意图,从预设的 safelist(白名单)中按置信度排序,挑下一个最可能节点,而不是随机 fallback。supervisor 失败后再走一次 specialist,失败概率显著小于再调一次 LLM。节点级异常处理与重试策略可参考 LangGraph 官方文档:https://langchain-ai.github.io/langgraph/。
[观察] 这套实践里有个反直觉的设计:Supervisor 出错时不再依赖 LLM 决策,而是回退到基于规则 + 关键词匹配的 safelist。这意味着在系统降级阶段,我们实际上让「更笨但更稳」的代码接管路由,把「更聪明但更容易挂」的 LLM 暂时踢出关键路径。这是工程上典型的 belt-and-suspenders——同时系皮带和背带,两道保险。
[易踩的坑] 这里有几个常见错误:第一,把 timeout 设得太长(例如 30s),导致 supervisor 节点本身成为整张图的 tail latency 瓶颈;第二,重试次数过多(例如 5 次),在 LLM 整体抖动期间反而把 backpressure 放大成雪崩;第三,降级 safelist 没有经过人工审计,生成了一个过宽的白名单,等同于「放行所有节点」,形同虚设。我们的实践是 timeout=8s、重试 2 次、safelist 通过离线评测覆盖 90%+ 的真实 query 分布。
Specialist:partial state + error 字段,让 Supervisor 来决策
Specialist 节点的失败模式与 Supervisor 不同。Supervisor 失败意味着「决策瘫痪」,而 Specialist 失败往往只是「某个数据源拿不到」。比如机票 specialist 调 Tavily 搜索超时,但天气、预算可能都成功。这时 Specialist 不应该抛异常把整张图打断,而应该返回一个 partial state,告诉 Supervisor「我没拿到机票,但其他部分可以继续」。
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
specialist_errors: dict[str, str]
next_node: str
async def flight_specialist(state: AgentState) -> dict:
try:
flights = await mcp_search_flights(state["query"], timeout=10)
return {"flight_results": flights}
except Exception as e:
return {
"specialist_errors": {
"flight": f"{type(e).__name__}:{str(e)[:120]}",
},
"flight_results": [],
}
Supervisor 在下一轮拿到这个 state 时,可以基于 specialist_errors 的键集合判断「跳过 flight / 重试一次 / 换条路径走 itinerary-only」。这种 partial state 模式与 LangGraph 的 Annotated[..., operator.add] reducer 是天然契合的——每次 Specialist 调用产出的 error dict 都会被合并,而不是被覆盖。operator.add 与 Annotated 的语义可参考 Python typing 文档:https://docs.python.org/3/library/operator.html。
[数据] 在该示例的实测中,单节点 Specialist 失败率约 3-5%,主要来自外部 MCP 工具超时。如果 Specialist 抛异常直接中断整张图,任何一次外部搜索抖动都会让用户看到「系统挂了」;改成 partial state 之后,最终输出完整度反而提升,因为 Supervisor 可以在 partial 基础上继续组织回答,平均一次会话的可用信息密度从约 60% 提升到接近 90%。
[设计动机] 这种 partial state 设计的本质,是把「失败」从「单点异常」转化为「状态机的可观测输入」。Supervisor 不再被动接收异常中断,而是基于显式的 error 字典主动决策下一次路由。这与 SRE 中「把错误编码进协议」的思路一致——错误不再是流程的破坏者,而是协议的合法值。LangGraph 的 operator.add reducer 恰好提供了这一机制,把多个 specialist 的 error 自然合并,而不会因为后一次返回覆盖前一次。
Guardrails:超时即拒绝,绝不「挂了等于放行」
Guardrails 在系统拓扑里是入口节点,挂了就意味着整套入口没有拦截。在生产事故复盘里,「Guardrails 抛异常被 try/except 吞掉,所有请求直接进图」是一种典型的高危反模式。正确做法是:任何 Guardrails 自身故障(timeout、分类器 OOM、输入解析失败),默认行为应该是保守拒绝,并写一条 audit 记录。
async def input_guardrail(state: AgentState) -> dict:
started = time.monotonic()
try:
verdict = await classify_with_timeout(state["raw_input"], timeout=3.0)
return {"guardrail_verdict": verdict, "guardrail_blocked": verdict == "block"}
except Exception as e:
audit_id = write_audit({"event": "guardrail_self_failure", "reason": str(e)})
return {
"guardrail_verdict": "block",
"guardrail_blocked": True,
"guardrail_audit_id": audit_id,
"last_error_reason": f"guardrails_timeout:{type(e).__name__}",
}
[观察] 这条原则有个直白的命名:「Fail closed, not open」。任何安全相关的子系统,默认应该是「出错就拒绝」而不是「出错就放行」。这条在安全工程里是常识,但在 AI Agent 系统里经常被忽略,因为开发者会下意识用 try/except 把异常吞掉,认为「分类器挂了至少让用户能用上」。实际上 Guardrails 挂了放行,远比直接 503 危险得多,因为它会让越权或越界请求绕开唯一一道拦截层。
[易踩的坑] 常见反模式包括:把 except Exception 写成空 pass(完全吞掉异常,既不写 audit 也不返回 block);分类器 timeout 设得过短(例如 500ms)导致常态被拒绝,误伤率飙升;audit 写入路径没有降级,audit 自身挂了反而阻塞 guardrail 返回。正确做法是:timeout 设为分类器 P99 的 2-3 倍(本例 3s),audit 写入用 fire-and-forget 模式不阻塞主路径,并在 audit 失败时仍保证 guardrail_blocked=True。
Human-in-the-Loop 中断与 stale thread 回收
Interrupt 阶段是另一种错误来源:用户发完审批请求后人间蒸发,既不 approve 也不 reject,这时 thread 不能无限挂着。系统在调度层需要做两件事:一是给 thread 打 no-response 标记,二是把 thread_id 从 idle worker 的唤醒队列里摘掉。
| 状态字段 | 类型 | 含义 |
|---|---|---|
interrupt_status |
Literal["pending","approved","rejected","no-response"] |
当前 interrupt 的处置 |
stale_at |
datetime | None |
进入 no-response 的时刻 |
wake_eligible |
bool |
是否仍能被 idle worker 唤醒 |
具体阈值(比如 1 小时)不应该是写死的常数,而是放进 PostgresSaver 同表的一行配置,方便按业务场景调整。空闲 worker 在轮询时只 pick wake_eligible=True 的 thread,从源头避免无效唤醒。LangGraph 的 Command / interrupt 模式与之衔接,可参考仓库示例:https://github.com/langchain-ai/langgraph。
[观察] stale thread 的本质是「半完成的会话」。如果不做回收,它们会逐渐占满 PostgresSaver 的 checkpoint 表,而且会让 LangSmith 的 trace 统计出现大量「未完成 run」,污染失败率指标。一个常见的设计误区是只清理过期超过 N 天的 thread,而不区分「主动结束」和「被动 stale」——前者可以延迟清理,后者应该立即从 wake_eligible 池中摘除,避免反复唤醒带来的额外 LLM 调用成本。
reason 字段与 LangSmith 可观测性
前面所有错误路径都反复强调「写 reason 字符串到 state」,这不是为了 debug 方便,而是为了 LangSmith 能在 thread_id 维度做聚合查询。一个常见的查询模式是:
# 在 LangSmith UI 中
filter: state.last_error_reason contains "timeout"
group_by: state.last_error_reason
sort: count desc
limit: 20
通过这种聚合,工程团队可以快速回答「上周所有失败 run 的前 5 个 reason 是什么」,而不是陷在逐条 trace 里。LangSmith 的 trace 视图与可观测能力可以参考官方文档:https://docs.smith.langchain.com/。
[对标框架] 这种「错误编码进协议」的设计,可以对标传统微服务中的 circuit breaker(熔断器)模式。Hystrix、Sentinel 等框架的核心理念是:把「服务降级」从隐式的异常分支,提升为一等公民的状态字段(OPEN / HALF_OPEN / CLOSED)。在 Agent 系统中,supervisor_retry_count、specialist_errors、guardrail_blocked 扮演了同样的角色——它们不是日志,而是状态机的可枚举状态,可被监控、被聚合、被用来触发后续的路由决策。
方案取舍:每道防线的边界对比
| 防线 | Fail open vs Fail closed | 自动恢复 vs 人工介入 | 触发阈值 |
|---|---|---|---|
| Supervisor | Fail closed(降级到 safelist) | 自动 | 重试 2 次仍失败即降级 |
| Specialist | Fail open(返回 partial) | 自动 + 下一轮决策 | 单节点超时 10s |
| Guardrails | Fail closed(保守拒绝) | 人工 audit 复核 | 分类器超时 3s |
| HITL interrupt | Fail closed(标 stale) | 人工 re-engage | 用户 1 小时未回应 |
这张矩阵的核心取舍是:Supervisor 和 Guardrails 必须 Fail closed,因为它们是「决策」类节点,出错意味着「不知道下一步该干什么」;Specialist 可以 Fail open,因为它是「数据获取」类节点,部分失败优于全部失败。这种区分在系统设计阶段就应该明确,而不是等线上事故出现后再补。
[对比矩阵] 如果把这一设计与传统微服务的容错策略对照,可以得到更清晰的认知:Supervisor 的「重试 + 降级到 safelist」对应 retry + fallback 模式;Specialist 的 partial state 对应 bulkhead(舱壁隔离)模式——一个舱进水不影响其他舱;Guardrails 的 fail closed 对应熔断器的「全开即拒绝」状态;HITL 的 stale 回收则对应 timeout cancellation。这种映射帮助我们在设计评审时,可以直接借用微服务领域已成熟的容错词汇来沟通,降低跨团队的理解成本。
[反思] 一个常见的反模式是「统一处理所有错误」——比如给所有节点都套一个 try/except 然后返回固定的 fallback。这种做法的危险在于,它把「决策节点」和「数据节点」的容错语义混为一谈,导致 Supervisor 错误时也走 partial state(实际上 Supervisor 不存在 partial 概念),或 Guardrails 错误时也走 fail open(直接放行)。错误处理的第一性原理,不是「统一兜住异常」,而是「按节点职责选择最合适的失败模式」。
错误处理不是 prompt 工程之后的「补丁」,而是与 prompt 同等重要的一等公民。在 LangGraph 这类显式状态图框架里,每一次失败都可以被写成 state 字段、被 checkpoint 持久化、被 LangSmith 聚合分析——这套闭环让「系统挂了为什么挂」从一周排查变成一次查询,真正把可观测性落在工程师每天会用到的工具里。
三套选型对比矩阵:LangGraph vs CrewAI vs AutoGen
在把 Supervisor / Specialist / Guardrails 的错误处理契约、外层观测、心跳恢复都搭起来之后,团队负责人紧接着会问一个绕不开的问题:既然多智能体框架已经多到能凑一桌麻将,我们到底该选谁?如果不把这件事在一开始就讲清楚,后面所有「可观测的错误处理」「跨进程重启」「RBAC + audit log」的工程承诺都会变成空中楼阁。
下面这一节就把目前工程团队最常碰到的 LangGraph、CrewAI、AutoGen 三套框架放在同一张桌子上,从抽象层级、生产力、学习曲线、迁移成本四个角度切片比较,配合一个横向六维矩阵,让选型决策有据可依。

LangGraph:显式控制 + 生产可观测的工业派
LangGraph 的核心抽象是 StateGraph(状态图),用 START / END 节点 + 条件 Edge 把整个 multi-agent 的工作流表达成一张有向图。每一次节点执行都会对 State 做一次 reducer 写入,默认通过 Annotated[list, operator.add] 实现 add_messages 的累加语义。这种「显式状态 + 显式路由」的设计,使 LangGraph 在最关键的工程能力上几乎都拿到了最高分。
具体来说,LangGraph 的 checkpointer 生态相当成熟:InMemorySaver、SqliteSaver、PostgresSaver 三档可选,thread_id 级别持久化后跨进程重启可恢复;Human-in-the-Loop 通过 interrupt + Command(resume=...) 实现审批回放;MCP 集成靠 langchain-mcp-adapters 把 MCP server 暴露的 tool 装进普通 ToolNode,语义开销几乎为零;可观测侧直接接 LangSmith,每一条 reducer、每一次 fallback 都能在 trace 时间线上看到。
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
builder = StateGraph(TravelState)
builder.add_node("supervisor", supervisor_node)
builder.add_node("flight", flight_node)
builder.add_node("guardrail", guardrail_node)
builder.add_edge(START, "guardrail")
builder.add_conditional_edges("supervisor", route_fn, ["flight", "hotel", END])
builder.add_edge("flight", "supervisor")
with PostgresSaver.from_conn_string(DB_URL) as cp:
graph = builder.compile(checkpointer=cp, interrupt_before=["flight"])
适合谁?需要 fine-grained 控制、做 RBAC + audit log、要跨进程重启的工程团队基本是首选。代价是上手曲线相对陡,State / Node / Edge / Reducer / Checkpointer 这几个概念必须先吃透。
从设计动机上看,LangGraph 团队最初就是想解决「LangChain 表达式语言(LCEL)在多轮长流程中无法细粒度恢复」这个具体痛点。早期 LangChain 的 Chain 一旦跑飞,内部状态就丢了,排查只能靠日志;LangGraph 把每一次节点执行后的状态都通过 reducer 写入一张可重放的快照,本质上是把函数式编程里的 free monoid 思路搬到了 agent 编排上。这套设计带来的隐性收益是:测试用例可以用同一份 history 反复回放,任何在 trace 时间线上看见的 bug 都能被复现并修复。这对生产可观测几乎是决定性的。
但显式状态也带来几个容易踩的坑:① reducer 写错会导致状态被静默覆盖,排查时必须打印 state.values 而不是只靠 LangSmith;② interrupt_before 与 interrupt_after 不能同时在同一个节点上设,否则会出现「审批通过后又跳回起点」的诡异循环;③ 当 State 字段里同时存在消息历史和业务数据时,reducer 必须分开定义,否则 add_messages 会把业务 dict 也当成消息合并,这是一个社区里高频出现的事故。
CrewAI:高抽象 + 快速 Demo 的创业派
CrewAI 的抽象层级明显更高,它把整个 multi-agent 系统拆成 Crews(剧组)/ Agents(演员)/ Tasks(剧本)/ Process(拍摄流程)四个名词,几乎不需要写 reducer、不需要画图,几行配置就能跑出一个像样的 Demo。对于要给老板讲故事、或者做中等复杂度的业务 PoC,这套抽象非常友好。
但控制粒度因此受限:你要插入一段独特的 HITL 审批、要 hook 一个非常规的降级路径,或者要把失败状态细分到 retryable / non-retryable 这两档,CrewAI 的高层抽象会迅速变成障碍。Checkpoint 与持久化也是社区在补的功能,暂未达到 LangGraph 的工业级。好处是,如果后面真要严肃生产,从 CrewAI 迁移到 LangGraph 的成本并不算痛,主要是把 Tasks 重新映射回 Node、Process 重新映射回 Edge,业务脚本层面改动有限。
CrewAI 的设计动机来自「角色扮演 + 剧本驱动」这一隐喻,把多 agent 系统类比成一支剧组:导演(Process)指挥演员(Agents)按剧本(Tasks)表演。这种隐喻对非工程背景的产品经理和投资人非常友好,也因此让它在 2024 年成为融资 deck 里出现频率最高的框架之一。但团队很容易被「三天出 Demo」的快感迷惑,忽略它在以下几处隐藏的工程代价:① Process.hierarchical 模式下,Supervisor 实际上是一个 Manager Agent,而非真正的控制平面,失败重试只能在该 Agent 内部通过 tool 实现,跨任务的全局回滚几乎不可行;② 输出解析(schema parsing)默认是 JSON Mode,一旦 LLM 返回 schema 之外的字段,Crew.kickoff() 会直接抛异常而非降级;③ 工具调用链路的 token 计费隐藏在 Agent 配置里,生产环境容易出现 single-call 成本爆炸。
AutoGen:研究 + 多模型辩论的实验派
AutoGen(微软)走的是另一条路:Actor-style 的多模型对话,擅长「多模型辩论」「角色扮演协作」类研究场景。多 Agent 之间通过 message 互相 call 与 reply,自然形成 round-trip 式的对话流。代价是生产观测与持久化生态相对弱:checkpoint、MCP 集成、RBAC 这些基础设施层面的能力,目前在 AutoGen 内核里都需要自己包一层。AutoGen 最适合研究、实验性质项目,或者用来快速验证「两种 prompt 思路谁更优」之类的单脚本研究问题。
AutoGen 的设计动机源于微软研究院对「LLM-as-actor」的研究兴趣,它的核心抽象是 ConversableAgent 之间的 reply 协议,而不是「任务节点」或「剧组角色」。这套抽象在论文复现、benchmark 跑分时极其顺手,因为研究者关心的就是「三个 GPT-4 实例互相 call 十轮,最终收敛到什么答案」这类端到端指标。但工程化时,AutoGen 团队自己也承认这套抽象在持久化、并发控制、storage 后端上存在短板,所以社区衍生出 AutoGen Studio、AG2 等不同分支,版本碎片化是它最常被诟病的点。实际引入时务必盯紧 pyautogen 版本号,不要混用 0.2.x 与 0.4.x,二者 API 差异极大。
六维横向矩阵
| 维度 | LangGraph | CrewAI | AutoGen |
|---|---|---|---|
| 状态控制粒度 | 显式 State + Reducer,最细 | 高层抽象,粒度粗 | 对话消息流,粒度中等 |
| HITL 支持 | 原生 interrupt + Command |
需插件,语义不一致 | 需自包,缺统一规范 |
| Checkpoint 生态 | InMemory / Sqlite / Postgres 三档成熟 | 社区在补 | 弱,需自实现 |
| MCP 集成 | langchain-mcp-adapters 一行接入 |
需适配 | 需自实现 |
| 可观测 | LangSmith 一等公民 | 需外接 | 需外接 |
| 学习曲线 | 较陡,概念多 | 较平,名词友好 | 中等,对话模型易理解 |
扩展维度:成本与生态
| 维度 | LangGraph | CrewAI | AutoGen |
|---|---|---|---|
| 迁移到生产成本 | 最低,设计即生产 | 中等,需重写 Control Plane | 高,状态层基本要重写 |
| 社区与文档 | 官方文档 + LangSmith 强 | 文档增长快,生产案例少 | 论文与教程多,工程案例少 |
| 版本稳定性 | 0.1 → 0.2 API 演进稳定 | 0.x 迭代快,breaking 偏多 | 0.2 / 0.4 分裂,需谨慎 |
| Token 计费可见性 | 节点级 token 统计 | 隐藏在 Agent 配置 | 需在 reply 外额外 hook |
决策准则
把上面的矩阵压成三条决策准则,基本能覆盖绝大多数工程场景:
- 生产要 RBAC + audit log + 跨进程重启:首选 LangGraph。理由很直白,上面四项刚需它都是原生一等公民,其它两家都需要额外补一层。
- Demo 给老板讲故事、POC 两周内上线:选 CrewAI。它的抽象正好对应「这是 Crew,这是 Agent,这是 Task」这种对外宣传语言,迁移到 LangGraph 的成本也不算痛。
- 单脚本研究、做模型辩论 / 角色扮演对比:选 AutoGen。它的多模型对话语义最自然,代价是别指望它扛住生产流量。
选型时的三个反模式
- 「哪个 star 多选哪个」:LangGraph 与 CrewAI 的 GitHub star 增长曲线在 2024 年基本缠在一起,但 star 数量并不反映生产成熟度,只看 star 容易把 demo 框架扶正。
- 「先 Demo 再重构」:从 CrewAI 迁到 LangGraph 的成本尚可,但从 AutoGen 迁过来时,光状态层就要重写,前期 demo 节约的天数会被后期重构全部吃掉。
- 「全栈用一个框架」:研究侧用 AutoGen 跑 ablation,生产侧用 LangGraph 跑正式流量,二者并不冲突,没必要强行统一。
[观察] 从工程视角看,真正决定选型的不是「哪个框架更酷」,而是「团队愿不愿意为抽象层买单」。LangGraph 把 Multi-agent 的复杂度诚实地暴露给开发者,换来的是可观测、可恢复、可审计;CrewAI 把这些复杂度藏起来,换来的是上手速度。两者本质上是同一个 trade-off 的两端,不存在绝对优劣,只有当下是否匹配。
[数据] 横向六维矩阵里,LangGraph 在「状态控制粒度」「Checkpoint 生态」「MCP 集成」三个维度上拿到满分,CrewAI 在「学习曲线」独占鳌头,AutoGen 的全部得分集中在「多模型对话语义」一项。这种数据分布直接验证了「合适的工具匹配合适的场景」这一判断:LangGraph 是生产级、CrewAI 是 PoC 级、AutoGen 是研究级。如果团队之前已经在 CrewAI 上跑了 Demo,又觉得未来一年要严肃生产,现在迁移到 LangGraph 的成本反而比从 AutoGen 迁移更低,后者连状态层都得自己重写。完整的官方对比与迁移路径可以参考 LangGraph 官方文档 与 LangGraph GitHub 仓库,MCP 集成细节可在 Model Context Protocol 规范 与 langchain-mcp-adapters 仓库 中查看。
常见坑位清单:9 条踩过才知道的工程教训
过去半年我们把这套多智能体旅游助手从 demo 推到生产,踩过的坑远比预想的多。本节把那些官方文档没明说、只有真跑起来才会遇到的问题整理成 9 条清单,每条都给出症状、根因,以及一条可落地的修复路径。这些教训的共同特征是:它们在单测里几乎不会暴露,只有真实流量、真实并发、真实审计压力下才会显形。我们把它们按生命周期重新组织——从首次启动到长期运维——方便团队按上线阶段逐项 review。

坑一:PostgresSaver 没在第一次运行调 setup() 就 assert 等不到表
LangGraph 官方提供了 langgraph-checkpoint-postgres 这个包,把 StateGraph 的快照写到 PostgreSQL 表里。但很多工程师第一次跑就栽在 psycopg.errors.UndefinedTable:checkpointer 不会自动建表,需要先 from langgraph.checkpoint.postgres import PostgresSaver; checkpointer = PostgresSaver(conn); checkpointer.setup(),否则后续调用 get_state 时直接抛异常。
设计动机:LangGraph 团队刻意把 schema 管理从运行时剥离开,理由是生产环境里 DBA 通常不希望 ORM/SDK 自动建表——这跟 Alembic、Flyway 的设计哲学一脉相承。但对快速迭代的初创团队来说,这条契约就变成隐藏的 on-boarding 摩擦。
最干净的两种做法:一是把 setup() 放在 ctx.run() 这种一次性 migration 任务里,跟业务图完全解耦;二是写一个独立 migration 脚本放进 CI 流水线,跟 Alembic 之类工具一起跑。具体写法可以参考 PostgresSaver 官方文档 和 LangGraph GitHub 仓库。
对标框架:同样面对「持久化 schema 谁来管」的问题,各家选择不同。
| 框架 | 自动建表 | 迁移工具 | 推荐使用场景 |
|---|---|---|---|
| LangGraph PostgresSaver | ❌ | 自管/Alembic | 生产、DBA 严格治理 |
| LangGraph MemorySaver | N/A | N/A | 本地调试、demo |
| LangGraph RedisSaver | ✅ | N/A | 中小流量、可容忍弱 schema 管控 |
| LlamaIndex ChatMemoryBuffer | ✅ | N/A | 单 Agent、轻状态 |
我们生产选 PostgresSaver + 自管 migration,代价是多写一个 migration,但换来的是 schema 演进的版本化可控。
坑二:interrupt 之后直接 state.update 而不是 Command,导致 supervisor 跳过审批条件边
interrupt(...) 之后用户审批完,新工程师最容易写错的地方是直接 state.update({"approved": True}) 然后再次 graph.invoke(...)。这样 Supervisor 会被跳过,后面所有审批条件边(conditional_edge)直接失效,等于绕过了整条治理链。正确姿势是用 Command(resume=<value>, update={...}) 显式喂给被打断的节点。语义上,resume 是把 HumanMessage 喂回原节点;update 是补充这次审批留下的新字段,两者职责清晰分开,任何一边单独使用都会丢上下文。
深层动机:LangGraph 把 interrupt 设计成「暂停 + 等待外部输入」的协作原语,resume 通道走的不是普通的 state mutation,而是事件流。这意味着 Command 内部会重放被打断节点的 input,触发它的消息解析逻辑。如果你绕过 resume 直接 update,等于把节点当作纯函数调用,丢失了「我正在等待审批结果」这个上下文。
易踩的衍生坑:即使你用了 Command(resume=...),如果被中断节点的入参 schema 是 *args 而不是显式字段,resume 的 payload 容易在多层包装后类型退化。建议把审批节点的入口签名固定为 def approval_node(state, payload: ApprovalPayload),这样 IDE 和类型检查都能帮你卡死边界。
坑三:并发 Send 写同一 state 字段抛 InvalidUpdateError
Fan-out 场景里我们用 Send API 让多个 sub-agent 并发收集机票/酒店/天气。问题在于,如果它们都往 state["results"] 这个字段里 append,默认 reducer 是覆盖语义,跑完会抛 InvalidUpdateError。
对比:字段拆开 vs 自定义 reducer
| 方案 | 优点 | 代价 |
|---|---|---|
拆字段 flight_results / hotel_results / weather_results |
不需要理解 reducer,代码直白 | 后续汇总节点要写三遍合并逻辑 |
自定义 Annotated[list, operator.add] reducer |
汇总节点只读一个字段 | 调试时得 log 中间态,排查成本略高 |
用 Send 的 metadata 通道携带子任务身份 |
不依赖 reducer,纯路由层解决 | 节点签名要绑 metadata,迁移成本中等 |
| 在 fan-out 入口给每个 sub-agent 副本 | 天然无冲突 | 内存放大,不推荐长会话 |
实战里我们选拆字段:reducer 是 LangGraph 的隐式契约,新人接手一旦改 schema 就容易踩坑,而字段命名是显式契约。另外两种方案适合已有大量 reducer 依赖、无法拆字段的存量代码。
[观察] reducer 错误的根因是「分布式思维没在单进程里建起来」。即便所有 sub-agent 在同一进程并发,LangGraph 也会把它们当作独立 writer 看待——这是好事,提醒你 state 的写语义应该和分布式系统一致。
坑四:MCP 客户端忘记 aclose() 导致连接泄漏
langchain-mcp-adapters 包装出来的 ClientSession 是真会话,跟 httpx.Client 一样需要显式释放。忘记 await session.aclose() 会导致 stdio 子进程残留,跑几轮之后端口被占满,日志里开始刷 [Errno 98] address already in use。整个 backend lifespan 用 try-finally 包起来是最稳妥的写法:
async with stdio_server(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
try:
tools = await load_mcp_tools(session)
# ...业务调用
finally:
await session.aclose()
更稳的是用 FastAPI 的 lifespan 上下文,启动时建连接、shutdown 事件里统一关。具体 SDK 用法可以参考 langchain-mcp-adapters 官方仓库。
对比:stdio vs SSE vs streamable_http 三种传输
| 传输 | 生命周期 | 适用场景 | 泄漏后症状 |
|---|---|---|---|
| stdio | 子进程级,需 aclose | 本地工具、本地调试 | 子进程残留,fd 耗尽 |
| SSE | HTTP 长连接 | 远程 MCP server、生产 | TCP 连接泄漏 |
| streamable_http | HTTP 请求/响应 | 大 payload 流式 | 连接池耗尽 |
我们生产里 stdio 仅用于 dev 环境,生产一律走 SSE —— 原因是 stdio 子进程被 supervisor 接管后,信号传递有时不可靠,aiohttp 的 SSE client 更适合长跑。
坑五:Supervisor prompt 没说「只做路由」,模型直接返回答案而不是 next_node
Supervisor Agent 的 prompt 没写明「只做路由、必须返回 next_node 名字」,模型在大半情况下会自己组织一段答案返回,完全跳过 sub-agent。这种隐性偏移在单轮调试时几乎发现不了,只有上线后用户报「你给我的机票信息不全」才会暴露。强制结构化输出是最直接的修复:用 Pydantic 定义 next_node: Literal["flight_agent", "hotel_agent", "summarize"] 的输出 schema,经 ChatGroq.with_structured_output(...) 绑定。模型一旦想自由发挥,schema 校验会把它打回。相关 schema 写法可以参考 Pydantic 官方文档。
对比:三种约束手段
| 方案 | 鲁棒性 | 成本 | 失败模式 |
|---|---|---|---|
| Prompt 写「只做路由」 | 低 | 零 | 模型偶尔还是自由发挥 |
with_structured_output |
高 | 一份 Pydantic schema | 模型 schema 写错时沉默调默认分支 |
| 自写 JSON 解析 + retry | 中 | 几十行胶水代码 | JSON 偶发解析失败 |
LangGraph Command(goto=...) |
最高 | 必须走 Command | 改动量大,适合全新设计 |
我们推荐 structured output + 必填字段兜底校验:不仅 next_node 必填,再加一个 reasoning: str 必填,模型为了填 reasoning 反而更守规则。
[观察] 这条坑的本质是「LLM 的遵从性是概率的」。同样 prompt 写三遍,模型三次里可能只有 1.5 次乖乖只做路由——结构化输出是唯一能把它从概率事件变回工程事件的办法。
坑六:psycopg Pool 默认 10 连接,8 线程 Worker 跑两轮就连接池爆
psycopg_pool.ConnectionPool 默认 max_size=10,8 个 Worker 线程跑两轮 sub-agent 就开始排队。生产里我们观察到第 3 轮开始出现 PoolTimeout: timed out while waiting for connection,Latency p99 直接飙到 8s。
pool = ConnectionPool(
conninfo=DATABASE_URL,
max_size=30,
min_size=4,
kwargs={"autocommit": True},
open=True,
)
pool.wait()
max_size=30 留 4 倍冗余;再加 pool_pre_ping=True 防止 PostgreSQL 重启后拿到死连接。
[数据] 同样 8 线程、同样 2 轮 sub-agent fan-out,默认 10 连接下 p99=8.2s,调到 30 后 p99=1.1s,差距 7 倍以上。这是连接池大小和 Worker 数量需要联动调参的典型场景,千万不要用默认值。
调参经验公式:max_size ≥ worker_count × per_request_db_calls × fan_out_depth。我们这套链路里每个 sub-agent 至少 2 次 DB 调用,fan-out 深度 2,所以理论最小值是 8×2×2=32,取整 30 留点余量已经接近极限。更高流量场景建议上 PgBouncer 做事务级池化,而不是继续放大 application 侧 pool。
坑七:Guardrails 拒绝时丢失了用户原始 query,无法申诉
input guardrail 命中越权或越界规则时,新工程师习惯只返回一个 {"status": "rejected"} 对象,结果用户问「我刚才那条哪里违规了」时客服查不到任何原文。修复方法是 guardrail 函数返回体里强制两个字段:guardrail_reason: str 和 raw_query_hash: str(对原 query 做 sha256,避免 PII 又落库)。后续申诉链路按 hash 反查 24 小时内的审计日志即可还原。
[观察] 这条坑的本质是「拒绝等于不可观测」。任何被系统主动拦截的请求,都要比通过的请求留下更多日志,否则运维就是瞎子,合规申诉也只能靠猜。
对标:AWS Bedrock Guardrails、OpenAI Moderation API、Azure AI Content Safety 都默认只返回违规类别,不返回原文。如果你要做合规申诉,必须在应用层自己补一道审计日志——这是行业普遍缺失的一环。
坑八:LangSmith 默认把 PII 也 trace,生产不合规
LangSmith 默认会把 prompt、tool input/output 全量上传做 trace。生产环境直接踩合规雷:用户在对话里说的身份证号、银行卡号会被原样落到 LangSmith 后端。解决方案两条:一是项目级设置 LANGSMITH_HIDE_INPUTS=true 和 LANGSMITH_HIDE_OUTPUTS=true;二是自建一个 redaction filter,用正则把手机号、邮箱、身份证号替换成 <PII_REDACTED> 再上传。具体配置参考 LangSmith 官方文档。
对比:配置开关 vs 自建 filter
| 方案 | 适配场景 | 取舍 |
|---|---|---|
| LangSmith 全局开关 | 临时关停、POC 阶段 | 一刀切,trace 信息丢失,排查效率下降 |
| 自建 redaction filter | 生产长期方案 | 实现成本高,但保留可观测性的同时合规 |
| OpenTelemetry 端到端 redact | 多 backend、可移植 | 需要改 SDK,初期投入大 |
| 自托管 LangSmith | 强合规客户 | 运维成本最高,但完全可控 |
我们最终选了「自建 redaction filter + LangSmith 全局开关作为兜底」双保险:filter 日常开启,开关作为紧急熔断器使用。re 正则要持续维护——每季度审计一次,加上新型 PII 模式(比如新的电子支付账号格式)。
[观察] 可观测性和合规永远存在张力。任何 trace 系统,默认都是「多记」而非「少记」,因为少记会让排障失效。生产化的关键是在这两者之间找到一条精确的边界,而不是二选一。
坑九:checkpoint 表无限膨胀,运维压力大
PostgresSaver 默认不清理历史快照。一天 10 万次对话的话,90 天就是 900 万行,运维同事每周都得手动 truncate。Postgres 自带的 autovacuum 在 WAL 层面有用,但不会帮你删数据。落地两种做法:一是定时归档脚本,保留 90 天热数据,90 天外的 checkpoint 移到冷表或者导出到 S3;二是按 thread_id 维度归档,关闭后的会话整段删除。两种做法都需要先 engine.dispose() 防止连接句柄泄露。LangGraph 官方文档 里关于 checkpoint 的章节也提到了类似建议,但没给具体 retention 数值,需要团队按业务自己定 SLA。
retention SLA 对照表(基于我们的实际业务画像)
| 业务类型 | 热数据保留 | 冷数据保留 | 总成本/年估算 |
|---|---|---|---|
| 客服支持 | 30 天 | 1 年 | 低 |
| 旅游规划 | 90 天 | 2 年 | 中 |
| 金融咨询 | 180 天 | 7 年(合规) | 高 |
| 内部工具 | 7 天 | 30 天 | 极低 |
旅游规划属于中等规模,90 天热 + 2 年冷基本够用。但注意:某些国家法规要求出境游行程保留 5 年,这点容易被低估,做国际化业务时务必先和法务对齐。
9 条坑位覆盖了持久化、中断恢复、并发、可观测、合规、连接治理这 6 个维度,每一条都不是单纯调一行参数就能解决,往往需要 schema 设计、运维流程和工程纪律同时跟上。下一步把这套多智能体工作流真正落到生产,建议先以「坑一 + 坑六 + 坑九」三件套作为上线前 checklist,这三条直接影响系统能不能跑起来;剩下的 6 条按业务规模逐项补齐,可以让团队少走半年弯路。
更长远看,这套清单的最大价值不是「告诉你怎么修」,而是「告诉你哪些地方有隐性契约」。LangGraph、MCP、LangSmith 这类框架的设计哲学各有不同——有的偏向自动便利、有的偏向严格管控——理解它们的取舍,比记住 9 条具体修复路径更重要。当你下次面对一个全新的框架,先问三个问题:谁负责建表?谁负责清理?谁负责合规边界? 答案清楚了,90% 的坑都能提前规避。
上线清单:从 Demo 到生产的 12 步工程交付
把多智能体系统从 Jupyter 里的 demo 推到生产,差的不是模型参数,而是一整套工程交付清单。下面这 12 步是「该教程」反复踩坑后沉淀下来的最小可上线集合,每一步都对应一项在压测或灰度阶段真实出过事故的能力,顺序也基本反映了上线前的合理推进节奏。
一、运行时与状态层打底(Step 1-3)
Step 1:backend 改成 async-first,MCP 客户端 lifespan 集中管理
同步 FastAPI handler 里 await 异步 MCP session 会触发 RuntimeError,所以整个 backend 必须从入口起就 async def。MCP 客户端最适合放在 FastAPI 的 lifespan context 里启动和关闭,而不是每次请求都 new 一份,既避免连接泄漏,也方便在启动阶段做 list_tools 健康检查。具体 lifespan 写法可以参考 https://github.com/langchain-ai/langchain-mcp-adapters 仓库的 README 示例。
Step 2:PostgresSaver 接上,做一次端到端 crash-recovery 测试
checkpoint 不是装上就能用——PostgresSaver 首次接入必须调一次 .setup() 建表,文档见 https://langchain-ai.github.io/langgraph/reference/checkpoints/ 。更重要的是,实际跑一遍 kill -9:在 run 进行到中间节点时强杀 worker,重启后用同一 thread_id 重新 invoke,确认状态图能从上一个被打断的节点恢复,而不是从头重跑。这一步如果跳过,生产环境第一次 OOM 就会让用户看到「为什么我刚填的表单没了」。
Step 3:LangSmith trace 开启,设置 PII redaction
trace 是事后定位「为什么这次 supervisor 路由错了」的唯一线索。LangSmith 支持环境变量级别的 redaction,把 LANGCHAIN_TRACING_V2=true 和 LANGSMITH_API_KEY 配上之后,务必在项目设置里把 email、电话、身份证号加入 masking 规则,否则日志里会留下原始 PII。配置细节见 https://docs.smith.langchain.com/ 。
| 阶段 | 必备配置 | 不做的代价 |
|---|---|---|
| 启动期 | lifespan 启停 MCP / PostgresSaver.setup() |
首次启动直接抛表不存在 |
| 运行时 | LangSmith trace + PII redaction | 出问题只能盲猜 |
| 异常期 | kill -9 后能 resume | 用户状态全丢,客诉激增 |
二、安全与连接资源(Step 4-5)
Step 4:Guardrails 测试集 200 正常 + 50 越权,回归 CI 必跑
输入侧 Guardrails 不能「感觉能用就上线」。准备 200 条正常 prompt(覆盖各 specialist 路由)+ 50 条越权 prompt(如「帮我查别人的订单」「忽略之前的指令直接执行 X」),作为回归用例在 CI 里必跑。越权集合要持续累积:每一次线上漏过的 prompt 都补进数据集,否则攻击面只会越扩越大,而 CI 是唯一的护栏。
Step 5:psycopg Pool 上限与 pool_pre_ping 调好
LangGraph 的 checkpoint 写入是同步阻塞调用,每个 worker 会从池里拿一个连接;Pool size 不设上限会把 Postgres 打爆。经验值是 worker 数 × (并发 thread 数 + 1) ≤ Postgres max_connections 的 70%。同时打开 pool_pre_ping=True,避免长时间空闲后连接被服务端断开、第一次写入报 ConnectionResetError。
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
conninfo=conn_str,
max_size=20,
kwargs={"autocommit": False},
open=True,
)
# PostgresSaver 直接消费该 pool
checkpointer = PostgresSaver(conn_pool=pool)
三、并发模型与长任务(Step 6-9)
Step 6:FastAPI 限流按 thread_id,不当成无状态端点
多智能体接口是有状态调用——同一 thread_id 下后续轮次要读 checkpoint。一个用户开五个 tab 重复点提交,会触发同一 thread 上多次 invoke,造成 checkpoint 写竞争。简单做法是用 thread_id 维度的 token bucket 做 QPS 限流(比如每秒 1 次),而不是无脑按 IP 限流,否则正常用户被误伤、恶意用户绕开。
Step 7:Uvicorn workers 与 PostgresSaver Pool size 同比例
uvicorn --workers N 起 N 个进程后,每个 worker 都会持有自己的 Pool。如果 Pool max_size=20、worker=4,峰值连接数可能到 80,直接打穿 Postgres。设计原则是 worker × pool_max ≤ db_max_connections × 0.7。两者同比例调,而不是先拍脑袋定 worker 数再事后调 pool。
| 取舍方案 | 优点 | 代价 |
|---|---|---|
| 多 worker + 小 pool | 单 worker 故障隔离好,符合无状态理念 | 总连接数高,需要更高规格 DB |
| 少 worker + 大 pool | 连接数少,DB 压力小 | 单 worker 故障影响面大,重启抖动明显 |
Step 8:HITL 长时间无回应自动标 stale,另起 worker 清理
interrupt 触发的 approval 请求如果用户 24 小时不点同意,会一直占着 checkpoint 行。需要在跑批 worker 里扫描 created_at < now() - 24h AND status='pending' 的记录,标 stale 后允许被清理;否则 Postgres 表会无限膨胀,Step 10 的归档也无从下手。
Step 9:告警四件套:Supervisor 重试 / Guardrails 拒绝率 / Specialist 失败率 / run duration P95
这一组告警覆盖了「路由不对」「输入被拦」「工具失败」「整链路变慢」四个维度。阈值经验:Supervisor 连续 3 次同路由重试(怀疑路由陷入循环)、Guardrails 拒绝率突增 5 倍(可能新攻击模式)、单 specialist 失败率 >10%、P95 run duration 翻倍,任一触发即 page oncall。
四、运维与可维护性(Step 10-12)
Step 10:checkpoint 表定期归档,每月跑一次
thread 结束的 checkpoint 并不需要长期保留,3 个月前的可以直接 archive 到冷表或 S3。建议写一个 cron,每月 1 号把 created_at < now() - 90d 的行搬到 checkpoints_archive 表,Vacuum 释放空间。这一步不做的代价会在第三周以 WAL 抖动的形式出现。
Step 11:文档化 prompt + state schema + 工具 schema,新人 1 天 join
StateGraph 的 TypedDict、add_messages reducer、所有 specialist 的 prompt 模板、每个 MCP 工具的入参 schema,都必须落到 docs/ 目录并随 PR 更新。新人第一天就能从文档里跑通一次端到端,而不是在 Slack 问「这个 state 字段什么时候加的」。
Step 12:Runbook 三件事
用户在 UI 看不到审批怎么办:先查 Postgres 里该 thread 的最新 checkpoint 是否停在 __interrupt__ 节点,再看 LangSmith trace 里 approval payload 是否发送成功。Guardrails 误杀怎么人工放行:在 Guardrails 决策点之前插入 dry_run=True flag,被拦的请求写到 quarantine 表,oncall 审核后手动调 invoke 重跑。checkpoint 表怎么清理:先跑归档脚本的 dry-run 确认行数,再正式执行,避免误删。
五、扩展讨论:设计动机、易踩的坑、对标框架
[工程动机] 上述 12 步之所以要按顺序推进,根因是 LangGraph 的状态机模型天然把「计算」与「持久化」耦合在了一起——每一次节点跳转都可能触发一次 checkpoint 写入,这与传统无状态微服务完全不是一个心智模型。如果沿用传统后端的「先实现功能再补监控」思路,会在第三步就撞上「checkpoint 表设计不合理导致 IO 抖动」这类结构性问题。因此本清单刻意把运行时与状态层(Step 1-3)前置,本质上是把「把状态当成一等公民」这个核心约束尽早显式化,避免后期返工。Step 6 按 thread_id 限流也是同理:在多智能体场景里,「并发」的单位不是 HTTP 请求,而是 thread 的一次 run,这是与传统 QPS 模型最容易混淆的点。
[常见坑]
| 坑位 | 现象 | 根因 | 规避做法 |
|---|---|---|---|
| checkpoint 写竞争 | 同 thread 多次 invoke 后状态错乱 | 写入无序列化 | thread_id 维度串行化或加 advisory lock |
| MCP 连接泄漏 | 进程长时间运行后端口耗尽 | lifespan 写法错误 | 严格 async with 或 lifespan context |
| Guardrails 误杀 | 正常请求被拦 | 规则集偏严,缺边界 case | 200 正常集回归 + quarantine 人工复审 |
| Pool 打爆 Postgres | FATAL: too many connections |
worker × pool 未联动限流 | 公式:worker × pool ≤ max_conn × 0.7 |
| WAL 抖动 | checkpoint 写入 P95 翻倍 | checkpoint 表无归档 | Step 8 + Step 10 双管齐下 |
| Supervisor 路由循环 | token 烧光但无业务结果 | 路由决策无去重 | 连续同路由 ≥3 次即熔断并告警 |
[对标框架] 这份清单可以与 Google SRE 的「Error Budget + Toil Reduction」、Datadog 的「Golden Signals(延迟/流量/错误/饱和度)」做对照——Step 9 的四个告警基本就是 Golden Signals 在多智能体场景的实例化;Step 10-12 则对应 ITIL 的「Continual Improvement」与 SRE 的「Postmortem-less Operations」理念。与传统微服务的发布清单(如 Kubernetes 的 readinessProbe、PodDisruptionBudget)相比,本清单新增了「状态恢复测试」「按 thread 限流」「checkpoint 归档」这三项 AI-native 能力,这是 LLM 应用工程化与传统工程化最大的差异点,也是最容易在团队 review 中被低估的环节。
[观察] 这一份清单的真正价值不在于「12 步都做完」,而在于它们之间存在强依赖——没做 Step 2 的 crash-recovery 测试就上线,Step 9 的 P95 告警会变得没有意义(因为系统根本没从异常中恢复过,所谓「变慢」其实是「卡死」)。所以上线节奏应该是:先打 1-2-3(运行时层),再补 4-5(资源与安全),然后才能稳定地观察 6-7-8-9,最后才有底气谈 10-11-12 这种「需要系统先稳定下来才能做对」的运维动作。把顺序倒过来,任何一个 P0 都会演变成 P0×N。
[数据] checkpoint 表的膨胀速度是该教程生产环境里最容易低估的指标——一周不归档就会突破 500 万行,Vacuum 也救不回来。Step 8 的 stale 标记 + Step 10 的归档如果双双缺失,Postgres 的 WAL 会在第 3 周开始抖动,checkpoint 写入 P95 直接翻 3 倍。把这两个数字的曲线放在 oncall 看板上是性价比最高的「预防性告警」——它会在用户报障之前就让团队提前介入。
参考来源
A 类 · 官方与一手资料
- LangGraph 官方文档: https://langchain-ai.github.io/langgraph/
- LangGraph GitHub 仓库: https://github.com/langchain-ai/langgraph
- Model Context Protocol 规范: https://modelcontextprotocol.io/
- langchain-mcp-adapters 仓库: https://github.com/langchain-ai/langchain-mcp-adapters
- LangChain 官方文档(Python): https://python.langchain.com/docs/introduction/
- LangSmith 官方文档: https://docs.smith.langchain.com/
- Pydantic 官方文档: https://docs.pydantic.dev/
- PostgresSaver(checkpoint)文档: https://langchain-ai.github.io/langgraph/reference/checkpoints/
- FastAPI 官方文档: https://fastapi.tiangolo.com/
- uvicorn 官方文档: https://www.uvicorn.org/
- Python typing TypedDict 文档: https://docs.python.org/3/library/typing.html
- Python operator 文档: https://docs.python.org/3/library/operator.html
- Demo 仓库(entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL): https://github.com/entbappy/Multi-Agent-System-using-LangGraph-MCP-Supervisor-Guardrails-HITL
- Demo 仓库(entbappy/TripMate-AI-Using-MCP): https://github.com/entbappy/TripMate-AI-Using-MCP
- Demo 仓库(entbappy/TripMate-AI-A-Multi-Agent-Travel-Planner-with-LangGraph): https://github.com/entbappy/TripMate-AI-A-Multi-Agent-Travel-Planner-with-LangGraph
B 类 · 社区与延伸阅读
- LangGraph 多智能体编排文档: https://langchain-ai.github.io/langgraph/concepts/multi_agent/
- LangGraph 时间旅行 / checkpointer 教程: https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/
- langgraph-checkpoint-postgres 文档: https://pypi.org/project/langgraph-checkpoint-postgres/
- psycopg 3 文档: https://www.psycopg.org/psycopg3/docs/

浙公网安备 33010602011771号