LangChain / LangGraph源码学习路线:第 17 篇 Multi-Agent、Subagents 与 Handoffs源码解剖
核心问题: 多 Agent 系统如何在 LangChain / LangGraph 中表达控制权转移、状态传递和子 Agent 边界,而不是退化成一团 prompt 拼接?
源码主线:
supervisor agent→agent-as-tool / subgraph→Command(goto=...)→state update→handoff target agent前置文章: 第 9 篇
Plan-and-Execute、第 16 篇Agentic RAG依赖基线:
langgraph==1.2.7、langchain==1.3.11、langchain-core==1.4.8阅读边界: 本文覆盖 supervisor、agent-as-tool、subgraph、Command handoff 和状态交接;不展开组织管理理论、复杂博弈策略和远程多进程 Agent 平台。
0. 本篇在源码学习主线中的位置
前面的文章已经解释了底层 Runnable、Tool、StateGraph、Checkpoint 和 Streaming。本篇把这些能力放到一个更接近生产 Agent 的问题里:Multi-Agent / Handoffs。
Agentic RAG / Tool Runtime ↓Multi-Agent / Handoffs ↓Functional API本篇只解决:
- 解释 supervisor 与 subagent 的控制关系。
- 解释 agent-as-tool 和 handoff 的区别。
- 解释状态如何在多 Agent 间收敛而不是互相污染。
本篇不展开:
- 不讨论远程 Agent 网络协议。
- 不把每个工具都强行包装成 Agent。
1. 本篇问题、学习目标与能力边界
核心问题
多 Agent 系统如何在 LangChain / LangGraph 中表达控制权转移、状态传递和子 Agent 边界,而不是退化成一团 prompt 拼接?
学习目标
完成本篇后,读者必须能够:
- 画出Multi-Agent / Handoffs的构建期对象关系。
- 解释一次运行时调用如何进入主链。
- 说明关键分支、异常和停止条件。
- 区分公共 API、扩展接口和内部实现。
- 根据旅行规划助手场景做工程选型。
能力边界
| 能力 | 本篇是否覆盖 | 说明 |
|---|---|---|
| 构建期对象组装 | 是 | 解释公开参数如何变成运行时对象 |
| 运行时主链 | 是 | 解释 invoke/stream/tool/task 等主路径 |
| 分支与异常 | 是 | 解释失败、降级、重试、终止边界 |
| 扩展协作 | 是 | 解释与相邻框架组件如何组合 |
| 底层供应商实现 | 否 | 不展开模型、数据库或平台内部实现 |
2. 核心概念与最小心智模型
一句话定义
Multi-Agent 是把复杂任务拆成多个具有独立职责的 Agent 或子图,并通过 supervisor、tool call 或 handoff 控制协作边界的架构形态。
最小心智模型
Agentic RAG / Tool Runtime ↓Multi-Agent / Handoffs ↓Functional API ↓工程化 Agent 能力核心术语
| 术语 | 源码对象 | 语义 | 不要误解为 |
|---|---|---|---|
| Supervisor | router / controller node | 决定下一个 Agent 或工具 | 更聪明的大模型而已 |
| Subagent | agent graph / runnable | 封装一个专门能力域 | 普通函数 |
| Agent-as-tool | BaseTool wrapper | 把 Agent 当工具调用并返回结果 | 控制权永久转移 |
| Handoff | Command(goto=...) | 把控制权转给另一个节点/Agent | 普通工具返回值 |
与相邻抽象的边界
| 对象 | 负责什么 | 不负责什么 | 与本篇对象的关系 |
|---|---|---|---|
Runnable | 统一 invoke / stream / batch 协议 | 不决定业务策略 | 提供可组合执行底座 |
StateGraph | 编排状态、节点和边 | 不实现所有外部系统 | 承载复杂 Agent 工作流 |
RunnableConfig | 传递 config、metadata、callbacks | 不保存业务状态 | 让观测和配置沿调用链传播 |
3. 完整执行链路
本篇的主线不是“多个 prompt 轮流调用”,而是控制权如何在多个 Agent 或子图之间移动。LangGraph 中最值得观察的对象是 state、message、Command 和 graph runtime:谁更新 state,谁决定下一跳,谁把 worker 的结果带回 supervisor。
从用户请求到 worker handoff 的对象流转
用户请求 ↓HumanMessage(content="帮我规划大阪 4 天,交通和餐厅都要可靠") ↓父图 state["messages"] ↓supervisor node ↓路由决策:"travel_planner" / "research_agent" / "finalize" / END ↓Command(goto="research_agent", update={"active_agent": "research_agent", "handoff_reason": "需要检索营业时间"}) ↓LangGraph runtime 合并 update,移动到目标节点 ↓worker agent / subgraph 接收 state 子集 ↓worker 追加 AIMessage、ToolMessage 或结构化结果 ↓Command(goto="supervisor", update={"messages": [...], "research_notes": [...]}, graph=Command.PARENT) ↓父图 supervisor 继续路由 ↓最终 AIMessage / END这里的关键不是“调用了另一个函数”,而是控制权移动。普通工具调用通常是父 Agent 调用工具并等待字符串结果;handoff 则表示下一个运行节点变成另一个 Agent 或子图,它拥有一段独立执行窗口,并通过 state update 把结果交回。
对象形态也在变化:用户请求是 HumanMessage,supervisor 的决策可以是字符串路由、结构化输出或 Command,worker 的产物可能是消息列表、业务字段或返回父图的 Command。如果把这些都压成一个大 prompt,就会丢失“谁做了什么、为什么转交、转交后谁负责”的边界。
伪代码:supervisor 路由与 handoff command
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from typing import Literalfrom langgraph.types import Command
WorkerName = Literal["travel_planner", "research_agent", "booking_guard", "finalize"]
def supervisor_node(state: MultiAgentState) -> Command[WorkerName]: decision = supervisor_chain.invoke({ "messages": state["messages"], "active_agent": state.get("active_agent"), "open_tasks": state.get("open_tasks", []), "handoff_history": state.get("handoff_history", []), })
if decision.next_agent == "finalize": return Command( goto="finalize", update={"handoff_reason": decision.reason}, )
if decision.next_agent not in state["allowed_agents"]: return Command( goto="booking_guard", update={ "handoff_error": f"agent not allowed: {decision.next_agent}", "handoff_reason": decision.reason, }, )
return Command( goto=decision.next_agent, update={ "active_agent": decision.next_agent, "handoff_reason": decision.reason, "handoff_history": [ *state.get("handoff_history", []), {"from": "supervisor", "to": decision.next_agent, "reason": decision.reason}, ], }, )这段伪代码的输入是父图 state,输出不是普通 dict,而是 Command。Command.goto 表达下一跳,Command.update 表达进入下一跳之前要合并到 state 的字段。对象形态从模型决策结果变成运行时路由协议。
源码观察点是 goto 和 update 的分离。goto 只决定控制权去哪里,update 才决定哪些事实写入 state。这样 supervisor 可以记录 handoff 原因、当前负责人、历史轨迹,而不需要 worker 反向猜测为什么自己被调用。
框架这样设计,是为了把“状态变更”和“执行拓扑变更”放在一个返回对象里。节点不再只能返回 state patch,也可以同时告诉 runtime 下一步去哪;这正是 handoff 比普通函数返回更适合多 Agent 协作的地方。
伪代码:worker 接收任务并返回父图
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def research_agent_node(state: MultiAgentState) -> Command[Literal["supervisor"]]: task = extract_research_task( messages=state["messages"], reason=state["handoff_reason"], )
worker_messages = [ SystemMessage(content="你是旅行事实核查 Agent,只返回有来源的结论。"), *select_messages_for_worker(state["messages"]), HumanMessage(content=task), ]
result = research_agent.invoke({"messages": worker_messages}) notes = extract_research_notes(result["messages"])
return Command( goto="supervisor", graph=Command.PARENT, update={ "messages": [AIMessage(content=summarize_for_supervisor(notes), name="research_agent")], "research_notes": notes, "active_agent": "supervisor", }, )输入是父图传来的共享 state,但 worker 不应该消费全部字段。select_messages_for_worker 是上下文边界:只把完成核查任务需要的消息交给 worker。输出是返回父图的 Command,并且 update 中只带回 summary、notes 和必要消息。
源码观察点是 graph=Command.PARENT。当 worker 是子图时,返回父图不是“跳到同名节点”那么简单,而是告诉 runtime 这次 goto 应该在父图命名空间解析。否则子图内部节点名和父图节点名可能混淆。
框架这样设计,是为了让 worker 有独立执行空间,同时不污染父图状态。worker 可以在内部调用工具、检索、重试,但交回父图时只暴露经过整理的结果。这样 supervisor/worker 的边界才清楚:supervisor 管控制权,worker 管专门任务。
agent-as-tool 与 handoff 的差异链路
def supervisor_with_agent_tool(state: MultiAgentState) -> dict: tool_result = research_agent_tool.invoke({ "question": latest_user_question(state["messages"]), "scope": "opening_hours_and_transport", })
return { "messages": [ToolMessage(content=tool_result, tool_call_id="manual_call")], "research_notes": parse_notes(tool_result), }agent-as-tool 的输入输出更像普通工具:父节点仍然持有控制权,worker 被压缩成一次工具调用,返回值通常是字符串或结构化结果。handoff 则把控制权交出去,让目标 Agent 成为下一段运行的主体。
工程上可以这样判断:如果只是让研究 Agent 帮忙查一段资料,agent-as-tool 足够;如果研究 Agent 需要多轮工具调用、向用户追问、进入子图、或决定何时返回 supervisor,就应该使用 handoff 或 subgraph。
正常结束条件
一次多 Agent handoff 正常结束,至少要满足:每次路由的目标在允许集合中,worker 返回的 state patch 符合父图 schema,消息交接没有丢失关键上下文,子图返回父图时明确使用父图边界,最终由 supervisor 或 finalize 节点收束到 END。
4. 源码地图、关键文件与阅读顺序
核心目录
langgraph/├── graph/├── pregel/├── types.py└── func/
langchain_core/├── runnables/├── tools/├── retrievers.py└── messages/关键文件
| 优先级 | 文件 | 核心对象 | 阅读目的 |
|---|---|---|---|
| 1 | libs/langgraph/langgraph/types.py | Command | 理解 goto/update/resume 类型 |
| 2 | libs/langgraph/langgraph/graph/state.py | StateGraph | 理解节点和子图如何编排 |
| 3 | libs/langgraph/langgraph/pregel/main.py | Pregel | 理解运行时调度 |
| 4 | libs/core/langchain_core/tools/base.py | BaseTool | 理解 agent-as-tool 协议 |
推荐阅读顺序
1. 先读官方概念文档,确认公共契约。2. 再读公开 API 或装饰器入口。3. 顺着构建期对象进入 runtime。4. 追踪一次 invoke / stream / tool call / task call。5. 单独检查异常、重试、恢复和扩展点。6. 最后回到旅行规划助手做工程判断。不建议的阅读顺序
不建议直接从最底层 private helper 开始读。源码学习的第一目标是建立调用链,而不是收集函数名。先找公开入口,再沿参数和返回值追下去,才不会把内部实现误当成稳定 API。
5. 对象模型、继承关系与协议边界
核心对象关系
Public API / Decorator / Tool Protocol ↓Runtime wrapper ↓State / Config / Context ↓Storage / Model / Tool / Graph Runtime对象职责
| 对象 | 生命周期 | 输入 | 输出 | 核心职责 |
|---|---|---|---|---|
| 公开入口 | 构建期或调用期 | 用户参数 | runtime object | 提供稳定 API |
| runtime context | 单次调用 | config/state | 下游上下文 | 传递配置、状态和观测信息 |
| 协议对象 | 单步执行 | 上游对象 | 下游可消费结果 | 维持模块边界 |
| 扩展点 | 构建期注册、运行时触发 | request/response | 修改或观察后的结果 | 插入业务控制逻辑 |
协议边界
公共 API 负责:给业务代码稳定入口。 不负责:暴露所有内部调度细节。
运行时协议 负责:让状态、配置、工具、模型和持久化协作。 不负责:替业务判断所有策略。
扩展接口 负责:允许业务插入可维护的定制逻辑。 不负责:保证错误扩展仍然安全。稳定接口与内部实现
| 类型 | 对象 | 文章中的使用原则 |
|---|---|---|
| 公共 API | 官方文档列出的函数、类和装饰器 | 可以用于工程示例 |
| 扩展接口 | middleware、store、retriever、task、Command 等协议 | 说明契约和约束 |
| 内部实现 | private helper、runner、loop 细节 | 只用于解释,不建议业务依赖 |
6. 源码阅读策略与证据标准
本篇阅读策略
先找公开入口 ↓确认输入输出类型 ↓沿调用方追到核心实现 ↓记录状态与对象变化 ↓检查分支、异常和结束条件 ↓回到设计目的证据等级
| 标记 | 含义 | 写作要求 |
|---|---|---|
| 源码事实 | 当前正式版源码可以证明 | 附源码链接 |
| 官方契约 | 官方文档或 API Reference 明确说明 | 附官方链接 |
| 简化伪代码 | 压缩真实控制流 | 标注不是源码逐字复制 |
| 作者推断 | 根据调用链得出的理解 | 明确使用“从调用关系可以推断” |
| 工程建议 | 面向项目实践的建议 | 说明适用条件 |
本篇证据清单
| 结论 | 证据类型 | 文件或文档 | 定位 |
|---|---|---|---|
| 公共入口存在稳定契约 | 官方契约 | 官方 docs / reference | 第 14 章链接 |
| 核心协议对象存在源码定义 | 源码事实 | 官方源码仓库 | 第 4 章关键文件 |
| 运行时分支需要按协议处理 | 作者推断 | 调用链和源码结构 | 第 9 章 |
| 工程选型依赖场景边界 | 工程建议 | 旅行规划助手场景 | 第 11 章 |
7. 构建期源码解剖
本章回答:多 Agent 系统在构建期如何把 supervisor、worker、subgraph、agent-as-tool 和 handoff policy 装配成可运行对象?构建期最重要的是定义边界,而不是把所有 Agent 塞进一个 prompt。
构建期职责
| 输入 | 归一化动作 | 构建结果 |
|---|---|---|
| state schema | 声明共享字段、私有字段、消息 reducer | 父图和子图的状态协议 |
| supervisor node | 绑定路由模型或策略函数 | 决定下一跳的控制节点 |
| worker agent / subgraph | 封装专门能力和内部工具 | 可被 handoff 或 tool 调用的执行单元 |
| handoff policy | 限制目标集合、原因、返回路径 | 可审计的控制权转移规则 |
supervisor graph 构建
真实源码签名
builder.add_conditional_edges("supervisor", route)细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from typing import Annotated, TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.graph.message import add_messages
class MultiAgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] active_agent: str | None allowed_agents: list[str] handoff_reason: str | None handoff_history: list[dict] research_notes: list[dict] plan_draft: str | None final_answer: str | None
builder = StateGraph(MultiAgentState)builder.add_node("supervisor", supervisor_node)builder.add_node("travel_planner", travel_planner_node)builder.add_node("research_agent", research_agent_node)builder.add_node("booking_guard", booking_guard_node)builder.add_node("finalize", finalize_node)
builder.add_edge(START, "supervisor")builder.add_edge("finalize", END)
multi_agent_graph = builder.compile()输入是状态 schema 和节点函数,输出是编译后的父图。messages 使用 reducer 合并,active_agent、handoff_reason、research_notes 等字段则是业务状态。对象形态从一组 Python 节点变成 runtime 可调度的图。
源码观察点是 state schema。多 Agent 系统最容易出问题的地方是共享状态过宽:所有 worker 都能看见和修改所有字段。构建期应该先区分共享消息、路由字段、worker 结果字段和最终输出字段。
框架这样设计,是为了让图结构承担协作边界。supervisor 不需要知道 worker 内部怎么检索,worker 也不需要知道整个产品流程;双方通过 state schema 和 Command 协议对接。
handoff Command 准备
真实源码签名
Command(goto="research_agent", update={...})细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def make_handoff( *, source: str, target: str, reason: str, state: MultiAgentState,) -> Command: if target not in state["allowed_agents"]: return Command( goto="booking_guard", update={"handoff_error": f"disallowed target: {target}"}, )
return Command( goto=target, update={ "active_agent": target, "handoff_reason": reason, "handoff_history": [ *state.get("handoff_history", []), {"from": source, "to": target, "reason": reason}, ], }, )输入是来源、目标、原因和当前 state,输出是 Command。它不是普通状态更新,因为它同时改变控制流。goto 是运行时调度信号,update 是状态事实,两者缺一不可。
源码观察点是目标白名单。让模型直接输出任意节点名是危险的,尤其是有高风险 booking、支付、外部 API 的系统。构建期应该明确可路由目标,并在 handoff helper 中集中校验。
框架这样设计,是为了让 handoff 可审计。每次交接都能记录 from/to/reason,后续 trace、评测和人工复盘才能解释为什么任务被交给某个 worker。
agent-as-tool 包装
真实源码签名
tool = subagent.as_tool(...)细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
research_tool = research_agent.as_tool( name="research_travel_facts", description=( "Use for bounded fact lookup. Input must include a focused question " "and a scope. Returns summarized notes with sources." ),)
def planner_node(state: MultiAgentState) -> dict: result = research_tool.invoke({ "question": "大阪城到道顿堀晚间交通方式", "scope": "transport", }) return {"research_notes": parse_research_tool_result(result)}输入是 subagent,输出是工具对象。运行时父 Agent 仍然掌握控制权,subagent 被限制在一次工具调用内。对象形态从“可运行 Agent”变成 BaseTool 协议。
源码观察点是描述和输入 schema。agent-as-tool 不是 handoff,它不应该让子 Agent 无限执行或自行改写父图状态。适合用在边界清晰、返回值可压缩的专门能力上。
subgraph 构建与父图边界
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
class ResearchState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] task: str notes: list[dict]
research_builder = StateGraph(ResearchState)research_builder.add_node("search", search_node)research_builder.add_node("verify", verify_node)research_builder.add_node("return_to_parent", return_to_parent_node)research_builder.add_edge(START, "search")research_builder.add_edge("search", "verify")research_builder.add_edge("verify", "return_to_parent")
research_subgraph = research_builder.compile()parent_builder.add_node("research_agent", research_subgraph)输入是子图 schema 和节点,输出是可挂到父图的 compiled subgraph。子图可以有自己的 state,但返回父图时必须映射成父图理解的字段,例如 research_notes 和 summary message。
源码观察点是父子 state 的映射。不要假设子图所有字段都能自动进入父图;生产系统最好显式写一个 return node,把 worker 内部轨迹压缩成父图需要的结果。
框架支持 subgraph,是为了隔离复杂 worker。研究 Agent 可以有搜索、验证、去重、引用检查多个内部节点,但父图只需要知道“研究完成了吗、有什么 notes、下一步交给谁”。
构建期产物
| 产物 | 保存的信息 | 运行时用途 |
|---|---|---|
父图 StateGraph | supervisor、worker 节点、END 路径 | 承载控制权流转 |
Command helper | goto、update、目标校验、原因记录 | 表达 handoff |
| agent-as-tool | name、description、args schema | 让父 Agent 短调用子能力 |
| subgraph | worker 内部状态和节点 | 隔离复杂专门任务 |
8. 运行时主链源码解剖
本章回答:一次多 Agent 调用进入运行时后,supervisor 如何路由,worker 如何接收 state,handoff command 如何被 runtime 消费,结果如何回到父图。
运行时入口
| 调用方式 | 公开入口 | 核心运行对象 | 返回类型 |
|---|---|---|---|
| 父图调用 | graph.invoke(initial_state) | StateGraph / Pregel runtime | 合并后的 state |
| supervisor 路由 | node 返回 Command 或路由标签 | graph scheduler | 下一跳 + state update |
| worker 子图 | subgraph node | 子图 runtime | 父图字段 patch 或 parent command |
| agent-as-tool | tool.invoke(args) | BaseTool wrapper | tool result / ToolMessage |
supervisor 路由主链
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def supervisor_node(state: MultiAgentState, config: RunnableConfig) -> Command: routing_input = { "conversation": trim_messages(state["messages"]), "active_agent": state.get("active_agent"), "research_notes": summarize_notes(state.get("research_notes", [])), "plan_draft": state.get("plan_draft"), }
route = supervisor_router.invoke(routing_input, config=config)
if route.next == "research_agent": return make_handoff( source="supervisor", target="research_agent", reason=route.reason, state=state, )
if route.next == "travel_planner": return make_handoff( source="supervisor", target="travel_planner", reason=route.reason, state=state, )
return Command(goto="finalize", update={"handoff_reason": route.reason})输入是共享 state 和 config,输出是 Command。routing_input 是经过筛选的上下文,不是整个 state;supervisor 只需要对话摘要、已有研究结论和计划草稿来决定下一跳。
源码观察点是 supervisor/worker 边界。supervisor 不执行检索、不写详细行程、不直接调用 booking;它只判断下一段工作应该由谁负责。把执行细节塞进 supervisor,会让路由节点越来越胖,后续很难测试和替换。
框架这样设计,是为了让控制节点保持小而明确。节点返回 Command 后,runtime 负责合并 update 和调度 goto;业务代码不需要手动调用下一个节点函数。
state/message 交接
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def select_messages_for_worker(messages: list[BaseMessage], worker: str) -> list[BaseMessage]: if worker == "research_agent": return [ msg for msg in messages if msg.type in {"human", "ai"} and not contains_private_booking_data(msg) ][-6:]
if worker == "booking_guard": return [msg for msg in messages if mentions_booking_or_payment(msg)]
return messages[-10:]
def worker_input_from_parent(state: MultiAgentState, worker: str) -> dict: return { "messages": select_messages_for_worker(state["messages"], worker), "task": state["handoff_reason"], }输入是父图 messages 和目标 worker,输出是 worker 可见的输入。对象形态仍然是 BaseMessage 列表,但经过裁剪和权限过滤。不要把父图全部 state 直接交给每个 worker。
源码观察点是 messages reducer。父图中 messages 往往采用追加合并,但 worker 输入可以是子集。共享 state 的合并规则和 worker 可见上下文是两个问题:前者是 runtime 协议,后者是业务安全策略。
框架这样设计,是为了支持既共享协作事实,又限制上下文扩散。多 Agent 系统里,信息隔离和任务专注同样重要;否则每个 worker 都会被无关历史干扰,甚至看到不该看的字段。
worker 返回父图
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def travel_planner_node(state: MultiAgentState) -> Command[Literal["supervisor"]]: planner_input = { "messages": select_messages_for_worker(state["messages"], "travel_planner"), "research_notes": state.get("research_notes", []), }
plan = planner_chain.invoke(planner_input)
return Command( goto="supervisor", graph=Command.PARENT, update={ "messages": [AIMessage(content=plan.summary, name="travel_planner")], "plan_draft": plan.markdown, "active_agent": "supervisor", }, )输入是父图 state 的受控子集,输出是返回 supervisor 的 Command。plan_draft 是业务字段,messages 是对话审计字段,二者都要写入父图,但语义不同。
源码观察点是 Command.PARENT。当 worker 作为子图运行时,返回父图需要明确指定父图边界。没有这个边界,runtime 可能在子图内部解析 goto,导致“看起来节点名正确但跳不到预期位置”的问题。
框架这样设计,是为了允许 worker 有内部拓扑。worker 可以先检索、再规划、再自检,最后只把稳定结果返回父图;父图不必知道 worker 内部经历了多少步。
agent-as-tool 运行时
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def call_research_as_tool(state: MultiAgentState) -> dict: result = research_tool.invoke({ "question": latest_user_question(state["messages"]), "scope": "facts_only", })
return { "messages": [AIMessage(content="已完成事实核查。", name="supervisor")], "research_notes": result["notes"], }输入是工具 args,输出是普通 state patch。控制权没有离开当前节点;父图不会把下一跳交给 research tool,它只是拿到一个结果继续执行。
源码观察点是 agent-as-tool 的返回值压缩。工具结果应该尽量结构化、短小、可校验;如果子 Agent 需要多轮决策并影响路由,就不要把它压成 tool。
运行时结束条件
正常结束:supervisor 路由到 finalize,finalize 写入 final_answer,然后进入 END。worker 返回:子图或 worker 返回 Command(goto="supervisor", graph=Command.PARENT)。短调用返回:agent-as-tool 返回工具结果,父节点继续持有控制权。保护结束:达到 recursion_limit、handoff 次数上限或人工中断。异常失败:未知节点、非法目标、坏 state patch 或 worker 不可恢复异常上抛并进入 trace。9. 关键分支、异常与边界
多 Agent 的异常通常不是单个函数失败,而是控制权、状态和消息边界出错。本章重点看未知目标、非法 handoff、worker 失败、父图返回和循环保护。
未知或未授权目标
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def validate_handoff_target(target: str, state: MultiAgentState) -> Command | None: if target not in REGISTERED_NODES: return Command( goto="supervisor", update={"handoff_error": f"unknown node: {target}"}, )
if target not in state["allowed_agents"]: return Command( goto="booking_guard", update={"handoff_error": f"disallowed handoff: {target}"}, )
return None输入是目标节点名和 state,输出要么是修复性 Command,要么是 None 表示可以继续。这里的对象形态仍然是路由协议,而不是异常字符串;这样 runtime 可以继续把错误交给 supervisor 或 guard 节点处理。
源码观察点是不要直接信任模型输出的节点名。supervisor 可以由 LLM 驱动,但图的节点集合是代码定义的稳定边界。模型输出必须经过白名单和注册表校验。
框架这样设计的工程意义是把“语义路由”和“拓扑安全”分开。模型可以判断应该找谁,代码必须决定是否允许它真的跳过去。
循环与递归保护
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def route_with_handoff_limit(command: Command, state: MultiAgentState) -> Command: history = state.get("handoff_history", [])
if len(history) >= 8: return Command( goto="finalize", update={ "handoff_error": "handoff limit reached", "final_answer": "任务在多 Agent 协作中未能稳定收敛,需要人工检查。", }, )
last_three = [(item["from"], item["to"]) for item in history[-3:]] if len(set(last_three)) == 1: return Command( goto="supervisor", update={"handoff_error": "repeated handoff loop detected"}, )
return command输入是即将返回的 Command 和当前 state,输出是可能被替换的 Command。状态变化在于记录了保护性错误,而不是继续盲目路由。
源码观察点是 recursion_limit 只能提供框架级上限,不能解释业务为什么循环。业务层的 handoff_history 能告诉你是 supervisor 和 planner 互相踢皮球,还是 research_agent 一直返回证据不足。
框架保护和业务保护应该同时存在。前者防止无限执行耗尽资源,后者让失败可解释、可修复。
worker 失败与返回父图失败
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def safe_worker_node(state: MultiAgentState) -> Command: try: return research_agent_node(state) except RecoverableToolError as exc: return Command( goto="supervisor", graph=Command.PARENT, update={ "messages": [AIMessage(content=f"研究 Agent 暂时失败:{exc}", name="research_agent")], "handoff_error": str(exc), "active_agent": "supervisor", }, ) except Exception: raise输入是 worker state,输出是返回父图的 Command 或直接抛出不可恢复异常。可恢复错误被包装成 state update,不可恢复错误继续向上抛出,让 trace 记录真实失败。
源码观察点是异常不要全部吞掉。worker 内部工具失败可以返回 supervisor 重新规划;schema 错误、未知节点、权限错误等不应伪装成正常结果,否则父图会基于坏状态继续执行。
state patch 不符合 schema
| 条件 | 行为 | 结果 |
|---|---|---|
| worker 返回未知字段 | 拒绝合并或在边界转换 | 防止污染父图 state |
messages 返回字符串而非 BaseMessage | 转换或抛错 | 保持消息 reducer 正常工作 |
子图忘记 Command.PARENT | 路由留在子图或报错 | 明确父子图命名空间 |
| agent-as-tool 返回过大历史 | 压缩为 notes / summary | 避免父图上下文膨胀 |
| handoff reason 缺失 | 写入默认原因或拒绝 handoff | 保持审计链完整 |
supervisor/worker 边界
| 边界 | supervisor 应该做 | worker 应该做 |
|---|---|---|
| 控制权 | 决定下一跳、终止或人工介入 | 在授权范围内完成专门任务 |
| state | 写入路由原因和全局进度 | 写入本任务结果和 summary |
| messages | 维护用户可理解的协作轨迹 | 只追加必要结果,不倾倒内部草稿 |
| 异常 | 分类、重试、降级或结束 | 暴露可恢复失败,不伪造成功 |
能力边界
| 容易误判的能力 | 实际提供者 | 本篇对象的真实职责 |
|---|---|---|
| 自动让多 Agent 更聪明 | 任务拆分、路由策略、评测 | 提供控制权和状态交接机制 |
| 自动保证 worker 不越权 | 权限策略、白名单、middleware | 提供可插入的路由边界 |
| 自动保证收敛 | 终止条件、handoff 上限、人工审核 | 提供可观测执行路径 |
10. 扩展机制与框架协作
本章回答:多 Agent 系统应该在哪里扩展,而不是改内部 scheduler?核心原则是 supervisor 管控制权,worker 管专门能力,handoff policy 管边界,observability 记录每次交接。
扩展点总览
| 扩展点 | 扩展方式 | 执行时机 | 可修改内容 | 约束 |
|---|---|---|---|---|
| supervisor router | 结构化输出模型 / 规则函数 | 每轮路由前 | next_agent、reason、confidence | 必须校验目标集合 |
| handoff policy | helper / middleware | Command 返回前 | 允许目标、上限、人工审批 | 不信任模型自由节点名 |
| worker context builder | 消息裁剪 / state 映射 | 进入 worker 前 | worker 可见消息和字段 | 避免跨域污染 |
| return mapper | 子图 return node | worker 返回父图前 | summary、notes、messages | 不泄露内部草稿 |
| observability | tags / metadata / trace | 每次节点调用 | from/to/reason/error | 避免敏感数据 |
自定义 handoff policy
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def should_handoff(state: MultiAgentState, decision: RouteDecision) -> Command: if decision.confidence < 0.55: return Command( goto="supervisor", update={"handoff_error": "low routing confidence"}, )
if decision.next_agent == "booking_guard" and not state.get("user_confirmed_booking"): return Command( goto="supervisor", update={"handoff_error": "booking handoff requires user confirmation"}, )
return make_handoff( source="supervisor", target=decision.next_agent, reason=decision.reason, state=state, )输入是当前 state 和路由决策,输出是 Command。policy 不执行 worker 任务,它只决定是否允许控制权转移。对象形态保持在 handoff 协议层。
源码观察点是高风险 worker 的前置条件。比如 booking、支付、邮件发送不能只靠模型一句“需要预订”就进入 worker,必须有用户确认、权限和审计字段。
与 Memory 协作
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def load_worker_memory(user_id: str, worker: str, store: BaseStore) -> dict: global_preferences = store.search(("user", user_id, "preferences")) worker_memory = store.search(("user", user_id, "agents", worker))
return { "global_preferences": redact_sensitive(global_preferences), "worker_memory": worker_memory, }输入是 user id、worker 名称和 store,输出是该 worker 可见的记忆。全局偏好可以共享,worker 专属记忆按命名空间读取;不要让 research_agent 随便读取 booking_guard 的审批历史。
源码观察点是 store namespace。多 Agent memory 的关键不是“有没有记忆”,而是记忆属于谁、谁能读、什么时候写回。命名空间可以把全局用户偏好和 worker 私有经验分开。
框架提供 store/checkpointer 协议,但不替业务决定隐私边界。工程上应在 context builder 中显式选择哪些 memory 进入 worker 输入。
与 Observability 协作
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def tag_handoff_config(config: RunnableConfig, command: Command, state: MultiAgentState) -> RunnableConfig: return { **config, "tags": [*config.get("tags", []), "multi-agent", "handoff"], "metadata": { **config.get("metadata", {}), "from_agent": state.get("active_agent") or "supervisor", "to_agent": command.goto, "handoff_reason": state.get("handoff_reason"), "handoff_count": len(state.get("handoff_history", [])), }, }输入是 config、command 和 state,输出是增强后的 config。观测字段不需要进入模型 prompt,它们用于 trace、评测和调试。
源码观察点是 from/to/reason。没有这些字段,多 Agent 失败时只能看到一堆模型调用,不知道控制权为什么移动。良好的 handoff trace 应该能重放“谁把任务交给谁,为什么,结果是什么”。
与 Agentic RAG 协作
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def research_agent_node(state: MultiAgentState) -> Command: rag_result = rag_graph.invoke({ "question": state["handoff_reason"], "documents": [], "answer": None, })
return Command( goto="supervisor", graph=Command.PARENT, update={ "research_notes": extract_notes(rag_result), "messages": [AIMessage(content=rag_result["answer"], name="research_agent")], }, )输入是 handoff reason,内部调用第 16 篇的 RAG graph,输出是 research notes 和 summary message。这里 RAG 是 worker 的内部能力,不直接接管父图控制权。
这个协作方式把两篇文章串起来:第 16 篇解决“一个 Agent 如何可靠检索和 grounding”,第 17 篇解决“哪个 Agent 何时接手这项能力、结果如何交回”。两层边界清楚,系统才容易测试和扩展。
选择 agent-as-tool、handoff 还是 subgraph
| 条件 | agent-as-tool | handoff | subgraph |
|---|---|---|---|
| 一次短任务、返回结果即可 | 是 | 否 | 否 |
| 需要目标 Agent 多轮执行 | 否 | 是 | 是 |
| 需要隔离 worker 内部状态 | 部分 | 是 | 是 |
| 需要返回父图并继续总控 | 否 | 是 | 是 |
| 需要父 Agent 始终持有控制权 | 是 | 否 | 否 |
11. 工程决策与适用场景
适用场景
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 旅行规划助手生产化 | 是 | 需要状态、工具、检索、观测和恢复闭环 |
| 一次性脚本问答 | 否 | 直接调用模型更简单 |
| 高风险工具调用 | 是 | 可以加入 guardrail、interrupt、trace |
| 大规模知识问答 | 是 | 可以把 retrieval、rerank、grounding 拆清楚 |
| 简单静态 FAQ | 否 | 复杂运行时收益不高 |
工程决策表
| 决策点 | 推荐选择 | 前提 | 风险 |
|---|---|---|---|
| 是否抽象成独立模块 | 有稳定职责时抽象 | 边界清楚 | 过早拆分增加复杂度 |
| 是否持久化 | 需要恢复或复盘时持久化 | 有 thread_id / user_id | 隐私和清理成本 |
| 是否加入 guardrail | 涉及外部工具或敏感数据时加入 | 有明确策略 | 误杀正常请求 |
| 是否流式观测 | 用户等待时间长时加入 | 前端能消费事件 | 泄露内部状态 |
性能、可靠性与安全边界
性能:额外抽象会增加序列化、检索、trace 和存储成本。可靠性:可恢复机制要求状态 schema、幂等工具和错误分类清楚。安全:metadata、state、tool args、retrieved docs 都可能包含敏感信息。可观测性:至少记录 thread_id、node/tool、策略命中、错误和耗时。12. 常见误区与源码纠正
误区:把公共 API 当成全部源码机制
错误原因:
公开 API 很短,看起来像全部逻辑都在这一层。
源码事实:
公开 API 通常只负责归一化和装配;真正的运行语义在 runtime、协议对象和扩展点之间流转。工程影响:
只读公开 API 会误判能力边界,导致扩展时依赖错误位置。
误区:把所有中间结果都写进模型上下文
错误原因:
Agent 运行时的 state、memory、documents、trace 看起来都像“上下文”。
源码事实:
state、store、Document、metadata、trace、prompt messages 是不同协议;只有经过明确注入的内容才进入模型上下文。工程影响:
如果不区分这些协议,轻则上下文膨胀,重则泄露敏感数据或污染长期记忆。
误区:异常都应该在框架层吞掉
错误原因:
Agent 产品希望“永远给用户一个答案”。
源码事实:
可恢复异常可以转成 retry、fallback、interrupt;不可恢复异常必须上抛并进入 trace。工程影响:
吞掉异常会让错误状态继续进入后续节点,调试成本比显式失败高得多。
13. 最终心智模型与掌握检查
构建期心智模型
Public Config ↓Normalize ↓Build Runtime Object ↓Attach Protocol / Extension / Storage运行时心智模型
Input ↓Runtime Entry ↓Context / State / Config ↓Core Execute ↓State / Event / Output分支与异常心智模型
正常路径 → 返回协议对象并更新必要状态分支路径 → 路由、降级、拒答、人工确认或恢复可恢复异常 → retry / fallback / repair / resume不可恢复异常 → trace + raise保护上限 → 停止循环、预算或高风险动作一句话总结
Multi-Agent / Handoffs通过构建期协议装配形成可运行对象,运行时沿公开入口进入核心执行链,使用分支机制处理边界,并通过扩展点与 LangChain / LangGraph 的状态、工具、模型、存储和观测能力协作。
掌握检查
- 能说清公开入口与真实执行入口的区别。
- 能画出构建期对象关系。
- 能画出运行时对象流转。
- 能解释至少一个核心函数的伪代码。
- 能指出同步、异步、流式或批量路径的边界。
- 能说明异常在哪里抛出、在哪里处理。
- 能说明正常结束和保护性终止的区别。
- 能区分公共 API、扩展接口和内部实现。
- 能根据旅行规划助手场景判断是否应该使用该抽象。
14. 参考资料与下一篇衔接
官方概念文档
-
LangChain Multi-agent
https://docs.langchain.com/oss/python/langchain/multi-agent -
LangGraph Subgraphs
https://docs.langchain.com/oss/python/langgraph/use-subgraphs -
Thinking in LangGraph
https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph
官方 API Reference
官方源码
-
libs/langgraph/langgraph/types.py
https://github.com/langchain-ai/langgraph/blob/1.2.7/libs/langgraph/langgraph/types.py -
libs/langgraph/langgraph/graph/state.py
https://github.com/langchain-ai/langgraph/blob/1.2.7/libs/langgraph/langgraph/graph/state.py -
libs/core/langchain_core/tools/base.py
https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/tools/base.py
下一篇衔接
下一篇进入:
第 18 篇:Functional API、entrypoint 与 task 源码解剖需要继续回答:
这项能力在旅行规划助手中应该放在哪一层?它和前一篇能力如何协作?哪些边界需要通过源码证据确认?