AIGC标识 端到端多智能体 AI 系统

端到端多智能体 AI 系统:把 LangGraph 状态图、Supervisor 路由、MCP 外部能力、Guardrails 输入拦截与 Human-in-the-Loop 审批串成一条端到端可观测、可恢复、可审批的 production-grade Agent 流水线,以 TripMate 多智能体旅行规划助手为完整示例的工程蓝图
在这里插入图片描述

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。

multi-agent-mental-model

切给专项 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 状态机骨架 —— StateGraphSTARTENDadd_messagesAnnotated[list, operator.add] 这些原语构成节点与边的最小骨架,详见 https://langchain-ai.github.io/langgraph/ 官方文档。第二层是 MCP 工具接入 —— 通过 langchain-mcp-adaptersmcp.ClientSession 把天气 / 航班 / 搜索等外部能力以标准化协议挂进来,std 或 HTTP 传输都支持,规范见 https://modelcontextprotocol.io/ 。第三层是 Supervisor 路由 —— 用条件边或 Command 在子 Agent 之间做派发。第四层是 Guardrails 入口拦截 —— input_guardrail 在请求到达任何 Agent 之前先做越权 / 越界检查,比如拒绝"帮我订一张明天去火星的票"这种无解请求。第五层是 HITL 审批闭环 —— interrupt(...) 在最终输出前挂起,等用户通过前端按钮回传"批准"信号后再 Command(resume=...) 继续。第六层是 PostgresSaver 持久化 —— 用 langgraph-checkpoint-postgresthread_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 四件套

stategraph-skeleton

在 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:虚拟的入口与出口

STARTEND 并不是真实存在的节点,而是 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 TypedDictoperator / 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-HITLentbappy/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 决策下一个节点,而不是写死控制流

supervisor-routing

接续上一节骨架,StateGraph 真正「活」起来的关键,在于边(Edge)上的决策由谁来拍板。如果沿用传统工作流思路,工程师往往会在节点之间手写一长串 if state["user_query"] contains "机票" then flight_agent,这种写死的控制流在小流量场景下尚可,但只要用户意图稍微模糊,或者意图本身需要组合,代码就会迅速膨胀成难以维护的胶水逻辑。

本节要回答的核心问题是:谁来决定下一个节点跑什么。答案是 Supervisor 自己——也就是一个专门负责「调度」的 LLM 调用。它把「下一个节点」从编译期常量,变成运行期由模型推理得出的结构化决策。LangGraph 官方文档对这一机制的描述见 langgraph/reference/graphlangchain-ai/langgraph GitHub 仓库,其设计哲学就是把调度显式化。

Supervisor 节点本身就是一个 LLM 调用

在 LangGraph 里,Supervisor 没有任何神秘色彩:它就是一个普通的 Node,只不过 node_fn 里装的是一次 ChatModel.invokemodel.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 字段缺失。TypedDictAnnotated 的组合用法参见 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_agentssupervisor_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

把这套机制落到一个真实的旅行规划工作流上,完整的链路可以这样编排:

  1. 用户输入 user_query,由入口 Guardrails 拦截越权请求。
  2. Supervisor 第一次决策:从 user_query 提取 destination(如「东京」)与 trip_constraints(如「预算 1.5 万、五月出发、不红眼航班」),写入 State。
  3. Supervisor 第二次决策:根据 destination 是否已落地,决定 selected_agents=["flight_agent"] 还是 ["weather_agent"];两者并不互斥,可以并行。
  4. Specialist 节点通过 MCP(Model Context Protocol)接入 tavily-python 或 langchain-tavily 等外部能力,回写结果。
  5. Supervisor 在所有 specialist 完成(或任意一个失败)后,统一决策 selected_agents=["approval_request"],强制走 Human-in-the-Loop 的 interrupt 流程。
  6. 用户在 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 模型上下文协议:把外部能力抽象成统一工具边界

mcp-client-server

如果说上一节的 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 风格会话,负责发送 initializetools/listtools/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 子进程时,StdioServerParameterscommand 字段需要显式带上 python.exe 完整路径,否则在 PATH 解析差异下容易出现「找不到可执行文件」。

回到整套系统的视角,MCP 并不是多智能体系统的「核心创新」,但它是让 Agent 能够稳定调用大量异构能力的「承重墙」。当 Server 端新增一个工具时,LangGraph 主流程无需改动一行;当某个工具下线时,只需要在注册表里移除,运行时也不会因 import 缺失而崩溃——这种「在协议层解耦」的好处,会随着工具数量的增长被持续放大,也让下一节要继续展开的 Supervisor 节点,在统一工具视图的基础上做出更稳定的路由决策。

工具发现与适配层:MultiServerMCPClient 与异步 session 管理

tool-discovery-async

如果说上一节的 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()

参考链接

整节收一下:MultiServerMCPClient + nest_asyncio 桥接 + 结果归一化 + timeout/retry/circuit-breaker 三件套 + trace 日志,这五层叠在一起,才让 MCP 真正成为「外部能力的统一边界」,而不是给 LangGraph 又引入一个新的故障域。Supervisor 拿到的是经过归一化、带降级语义、有可观测 trace 的 LangChain Tool 列表,业务节点不必感知 MCP 协议细节,这才是把多智能体系统接入现实世界该有的工程姿态。

Guardrails 输入门神:Supervisor 之前先把越权与越界拦下来

guardrails-input

在多智能体系统里,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: boolguardrail_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 写入,真实的成本节省会比表面数字更显著。

易踩的工程坑清单

在生产里反复出现过、值得提前规避的几类问题:

  1. 规则集合膨胀失控:BLOCK_KEYWORDS 一开始只有 20 条,半年后涨到 4000 条,每次匹配都 O(n) 扫描,延迟从 1ms 涨到 30ms。正确做法是用 Aho-Corasick 或 trie 树做多模式匹配,把关键词集合的扫描复杂度压到 O(text_length + matches)。
  2. 8B 模型冷启动抖振:llama-3.3-8b-instant 在流量低谷时会出现 500~800ms 的冷启动尖刺,影响前端 SSE 的「第一秒反馈」体验。需要在 guardrail 节点外包一层本地缓存(LRU + user_msg 的语义 hash),把相同/相似请求的判定结果复用 5~15 分钟。
  3. PII 正则漏掉国际化场景:\d{3}-\d{2}-\d{4} 只覆盖美国 SSN,对中国身份证(18 位)、日本 My Number(12 位)完全失效。需要按目标用户地域分别配置正则集,并在 guardrail_reason 里区分国家代码,方便后续合规团队分桶处理。
  4. 规则与模型判定冲突:规则说「放行」、8B 模型说「拒」,到底听谁?需要明确「模型可以放行规则判否的请求,但不能否决规则的拦截」——把规则作为强约束、模型作为弱建议,避免模型幻觉导致违规请求被放行。

拒绝理由必须落 State,不落临时变量

最后一条工程纪律:Guardrails 节点的输出必须写入 state(例如 guardrail_allowed: boolguardrail_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 边界清晰

specialist-merge

接续上一节 Guardrails 把越权请求挡在门口之后,真正进入图的请求都会被 Supervisor 拆解、再分发给若干「专科医生」。在 LangGraph 体系里,这些 Specialist 其实就是普通的节点(Node),只不过每个节点都只绑定一种工具集与一份 Prompt。把节点做小、把边界做窄,是这套实践从「能跑」走向「能维护」的分水岭。

节点契约:进什么、出什么,完全由 state 决定

在 LangGraph 的 StateGraph 中,所有 Specialist 节点共享同一份 TypedDict 形态的 state。机票 Specialist 节点只读取 user_querydestinationdates 这几个字段,再写回 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-adaptersload_mcp_tools 即可按需挑选,具体规范参考 Model Context Protocollangchain-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.exceptionmetrics.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 做窄这条路,看似简单,实操中仍有几处高频陷阱值得提前点出:

  1. 隐式全局状态:某些团队为了让 Specialist「更聪明」,偷偷在节点内读写 Redis 或进程内 dict,绕过 state schema。短期看节点能拿到额外信息,长期看图的可重放性(replayability)彻底丧失——同一条 state 在不同进程跑出不同结果。建议在 CI 里加 lint,禁止 Specialist 直接 import 任何 os.environ 之外的全局变量。
  2. Reducer 误配:把 flight_resultsAnnotated[list, operator.add] 合并是合理的,但若不小心把 destination 这种标量也写成 add reducer,就会出现 ["上海", "东京"] 这种灾难。规则很简单:字段是集合/列表/日志用 add reducer,字段是当前事实用 overwrite。
  3. Prompt 串味:多个 Specialist 共用一份「通用 system prompt」,里面塞了所有领域的话术,导致机票节点其实也读到了酒店相关指令,既浪费 token 又污染输出。建议每节点配独立 prompt 文件,通过路径而非字符串拼接引用。
  4. MCP Server 复用过度:为了省连接,把 aviation_mcphotel_mcp 装进同一个 Server,看起来节点加载的工具变多了,实际把「工具隔离」这一道防线彻底打穿。Model Context Protocol 的初衷就是让每个 Server 对应一类能力,合并等于倒退到单 tool registry。
  5. 并行分支的时序假设: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_resultsweather_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_resultsweather_results 汇总节点需要手动读多 key
共享 key + reducer specialist 输出结构同构,如都写 selected_agentsmessages reducer 写错会让数据污染
共享 key + 显式 merge 节点 同构但 reducer 不够用,如两条路径都给 budget_estimate 数字 多一个节点,牺牲一点延迟

常见踩坑清单(经验维度)

  1. reducer 签名写反:误把 (new, existing) 写成 (existing, new),导致「先到的被后到覆盖」,看似正常运行,但数据顺序错乱。
  2. list 字段忘了声明 Annotated:字段类型是 list[str],但没标 Annotated[list[str], operator.add],并行分支写入会直接抛 InvalidUpdateError,初学者最常被这里的报错信息劝退。
  3. dict 字段误用 operator.add:Python 的 dict 不支持 +,如果在 Annotated[dict, operator.add] 上写,会在 fan-in 时直接 TypeError。dict 字段要写自定义 reducer 返回 {**existing, **new}
  4. 在 reducer 里访问外层副作用:reducer 应当是纯函数,有人会图省事在里头调 printlogging,看似无关,但会污染日志、影响 unit test 的快照稳定性,以及跨进程重放时的可观测性。
  5. Send 列表里重复派发同一节点:某些场景下「想跑两个相同节点的实例」,但 LangGraph 期望实例用不同 node name 或 input 区分,否则会被框架去重或调度混乱。
  6. 覆盖式字段被误放入并行分支:把 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 必须写对,这是它把复杂度从「调度器」转嫁到了「开发者」。

生产建议

  1. 优先把 specialist 输出设计成「each-specialist-its-own-key」,例如 flight_resultshotel_resultsweather_results,这样无论怎么并发都不会撞 key,reducer 也无需介入。
  2. messagesselected_agents 这类 list 字段是 reducer 接管的安全区,可以放心并发写。
  3. 如果两条 specialist 不可避免要写同一个字段(如两条路径都给 budget_estimate),不要寄希望于「写一个 reducer 自动挑最优」,在边沿显式加一个 merge 节点,把多份候选值丢给 LLM 或规则仲裁,这一步通常更可控、可观测。
  4. 任何「覆盖式写」的字段(如 current_step: strfinal_answer: dict),都只能串行,不能放进 Send API 的并行分支里。
  5. 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 系统的可恢复性叙事里更可持续。

参考资源:

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 一并决定。

hitl-interrupt-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 的 updategoto 在同一帧里执行,意味着 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 概念)。

postgres-saver-thread

这里有一个常被忽略的设计细节: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 创建 checkpointscheckpoint_writescheckpoint_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 的工作流即服务架构更合适。

工程取舍与常见踩坑

正式把开关拨过去之前,有三处权衡值得摆到桌面上,并展开看几个真实容易踩的坑:

  1. interrupt-heavy 流的写放大。 每一次 interrupt(...) 都写一条新的 checkpoint 行;一个啰嗦的 HITL 循环会让表迅速膨胀。可以通过配置自定义 checkpoint_ns 加一个 cron 删 (thread_id, checkpoint_id) 超过 N 天的旧行缓解,或者切到 AsyncPostgresSaver,让一次请求共享一个连接。更细的策略是给「中间态」checkpoint 配更短的 TTL、给「终态」checkpoint 配长 TTL——LangGraph 允许你自定义 get_next_version 逻辑来给不同节点打标签。
  2. 连接池饥饿。 默认 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 持锁的「看不见的连接」。
  3. checkpoint payload 里的 PII。 messages 通道是原样序列化的,用户键入的所有 PII 都会落盘。要么写前清洗,要么在 Postgres 侧设行级安全策略,让 app role 只能读到本服务前缀下的 thread_id。GDPR 场景下还要配合 DELETE FROM checkpoints WHERE thread_id IN (...) 的定期清理作业,否则删了用户账号但聊天记录还在表里。
  4. checkpoint 表的膨胀与 vacuum。 由于 checkpoint 是 append-only 写、又有定期清理,PG 的 autovacuum 在 checkpoint_id 单调递增的列上可能不够积极,要手动配 ALTER TABLE checkpoints SET (autovacuum_vacuum_scale_factor = 0.05),否则大批量删旧行后 bloat 会让查询变慢。
  5. 从内存 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 中的 app role 连接数:超过池大小 80% 时告警。

迁移策略上,建议双写阶段:先让新请求落到 PostgresSaver、旧长时请求仍在内存里,等所有挂起 interrupt 都被消费完,再彻底关掉内存路径。这样即使持久化方案出问题,也不会让用户在跨重启时丢状态。

收尾

把上面这些组装起来:每个请求绑定稳定的 thread_id,新库上只跑一次 PostgresSaver.setup(),DATABASE_URLsslmode=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 子包提供了 ConnectionPoolAsyncConnectionPool 两条复用路径——前者给同步 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_activityidle 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 触发 interruptCommand(resume=...) 频繁注入,会看到 CHECKPOINT 在几分钟内出现数倍于基线的尖峰。把这个指标和 LangSmith 的 trace 时延、PG 的 tup_inserted 一起画到 Grafana,就能在没有专门 SLO 的情况下提前感知「系统在反复兜圈子」——这往往比单个节点的 P99 更早暴露问题,也更直观地反映 Supervisor 的路由健康度。

psycopg-pool-tuning

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_activitystate=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% 的样板代码,这点在迭代频繁的多智能体项目里非常关键。

fastapi-sync-async

暴露 REST 端点:create / approve / health

一个最小的可用契约应该包含三个端点:POST /api/travelPOST /api/travel/approveGET /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 做一次硬校验,把 messagespreferencesbudget 这种关键字段的类型、范围、长度都钉死。

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_beforeinterrupt_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-cacheX-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

langsmith-observability

这套接法的关键在于「零侵入」——不需要在每个 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_AGENTHOTEL_AGENTWEATHER_AGENTBUDGET_AGENTAPPROVALEND。模型只能从中选一个,不能自创新的节点名。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 维度上留下可追溯的印记。

error-retry-degrade

错误处理的三层防线总览

防线层级 触发场景 默认行为 失败后状态字段
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.addAnnotated 的语义可参考 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_countspecialist_errorsguardrail_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 三套框架放在同一张桌子上,从抽象层级、生产力、学习曲线、迁移成本四个角度切片比较,配合一个横向六维矩阵,让选型决策有据可依。

stack-comparison-matrix

LangGraph:显式控制 + 生产可观测的工业派

LangGraph 的核心抽象是 StateGraph(状态图),用 START / END 节点 + 条件 Edge 把整个 multi-agent 的工作流表达成一张有向图。每一次节点执行都会对 State 做一次 reducer 写入,默认通过 Annotated[list, operator.add] 实现 add_messages 的累加语义。这种「显式状态 + 显式路由」的设计,使 LangGraph 在最关键的工程能力上几乎都拿到了最高分。

具体来说,LangGraph 的 checkpointer 生态相当成熟:InMemorySaverSqliteSaverPostgresSaver 三档可选,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_beforeinterrupt_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 重新映射回 NodeProcess 重新映射回 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。

pitfalls-list

坑一: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 中间态,排查成本略高
Sendmetadata 通道携带子任务身份 不依赖 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: strraw_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=trueLANGSMITH_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=trueLANGSMITH_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
StateGraphTypedDictadd_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 类 · 官方与一手资料

B 类 · 社区与延伸阅读

posted @ 2026-08-02 22:17  Shockang  阅读(1)  评论(0)    收藏  举报