手把手教你用 LangGraph 搭建三层嵌套 Agent 架构
🤖 从单层 Supervisor 到三层嵌套:如何让 AI Agent 学会并行研究、分层决策、压缩输出。附完整代码实现和踩坑记录。
📖 前言
你是不是也遇到过这种情况:用 LangGraph 写了一个 Supervisor + 多 Agent 的系统,跑起来发现------
- Agent 是串行的,一个完了才能跑下一个
- 所有状态挤在一个大 TypedDict 里,30+ 个字段
- Supervisor 每次都要重新理解上下文,决策质量不稳定
- Worker 的完整执行历史全部返回,token 成本爆炸
这篇文章记录了 OpenLearning 项目从单层 Supervisor hub-and-spoke 演进到三层嵌套状态图的完整过程,包括架构设计、代码实现和踩坑经验。
参考了open_deep_research的三层嵌套 Agent 架构
🏗️ 架构演进
阶段一:单层 Supervisor(最初的实现)
css
START → Supervisor → [Agent] → Supervisor → [Agent] → ... → END
所有 Agent 共享一个扁平的 AgentState,Supervisor 通过条件边路由到下一个 Agent。每个 Agent 执行完必须返回 Supervisor。
问题:
- 串行执行,无法并行
- Agent 返回 Supervisor 后,Supervisor 要重新理解整个状态
- 没有"研究循环"的概念,一次决策一个 Agent
阶段二:三层嵌套(当前架构)
makefile
主图: preprocess → Supervisor 子图 → postprocess
├── supervisor(决策)
└── supervisor_tools(执行)
├── Worker 子图 × N(并行)
│ ├── worker
│ ├── worker_tools
│ └── compress
└── 回到 supervisor
核心思想:每层各司其职,通过子图嵌套实现分层编排。
🧩 三层架构详解
第一层:主图
python
from langgraph.graph import StateGraph, START, END
def build_main_graph():
graph = StateGraph(AgentState, input=AgentInputState)
graph.add_node("preprocess", _preprocess) # Memory + Planner
graph.add_node("supervisor", supervisor_subgraph) # 子图嵌入
graph.add_node("postprocess", _postprocess) # Builder
graph.add_edge(START, "preprocess")
graph.add_edge("preprocess", "supervisor")
graph.add_edge("supervisor", "postprocess")
graph.add_edge("postprocess", END)
return graph.compile()
关键点 :子图通过 add_node(name, compiled_subgraph) 嵌入,LangGraph 自动处理状态映射。主图只关心宏观流程,不关心内部如何执行。
第二层:Supervisor 子图
Supervisor 是一个双节点循环:
python
def build_supervisor_subgraph(worker_subgraph, tools):
builder = StateGraph(SupervisorState)
builder.add_node("supervisor", supervisor_fn) # 决策
builder.add_node("supervisor_tools", tools_fn) # 执行
builder.add_edge(START, "supervisor")
# supervisor_tools 内部通过 Command 动态路由
return builder.compile()
决策节点:调用 LLM,绑定工具(ConductResearch / ResearchComplete / think_tool)
python
async def supervisor(state, config) -> Command:
model = ChatOpenAI(model="mimo-v2.5-pro").bind_tools(tools)
response = await model.ainvoke(state["supervisor_messages"])
return Command(
goto="supervisor_tools",
update={"supervisor_messages": [response]}
)
执行节点:处理工具调用,委派 Worker 或结束
python
async def supervisor_tools(state, config) -> Command:
most_recent = state["supervisor_messages"][-1]
# 退出条件(三选一)
if exceeded or no_calls or complete:
return Command(goto=END, update={"notes": extract_notes(state)})
# 处理 ConductResearch → 并行启动 Worker
conduct_calls = [t for t in most_recent.tool_calls if t["name"] == "ConductResearch"]
if conduct_calls:
tasks = [worker_subgraph.ainvoke({"topic": c["args"]["topic"]}) for c in conduct_calls]
results = await asyncio.gather(*tasks) # 真正的并行!
return Command(goto="supervisor", update={"supervisor_messages": tool_outputs})
第三层:Worker 子图
Worker 是一个 ReAct 循环 + 压缩出口:
python
def build_worker_subgraph(tools):
builder = StateGraph(WorkerState, output=WorkerOutputState)
builder.add_node("worker", worker_fn) # LLM 调用工具
builder.add_node("worker_tools", tools_fn) # 执行工具
builder.add_node("compress", compress_fn) # 压缩输出
builder.add_edge(START, "worker")
builder.add_edge("compress", END)
return builder.compile()
压缩节点是 Worker 的必经出口,确保返回给 Supervisor 的是精简信息:
python
async def compress(state, config):
messages = state["worker_messages"]
messages.append(HumanMessage(content="请清理上述发现,保留所有相关信息但去除重复。"))
model = ChatOpenAI(model="mimo-v2.5") # 压缩用轻量模型
response = await model.ainvoke([SystemMessage(content=compress_prompt), *messages])
return {"compressed_output": response.content}
🔧 状态设计
状态分层
三层架构的核心是状态分层:每层有自己的状态,通过字段名对齐通信。
python
# 主图状态(全局)
class AgentState(TypedDict, total=False):
user_request: str
knowledge_graph: dict
raw_resources: Annotated[list[dict], operator.add]
analyzed_resources: Annotated[list[dict], operator.add]
evaluation: dict
status: str
# Supervisor 子图状态(决策循环)
class SupervisorState(TypedDict, total=False):
supervisor_messages: Annotated[list, override_reducer] # 可覆盖
research_brief: str
notes: Annotated[list[str], override_reducer]
research_iterations: int
# 与主图对齐的字段(自动映射)
knowledge_graph: dict
raw_resources: Annotated[list[dict], operator.add]
# Worker 子图状态(执行循环)
class WorkerState(TypedDict, total=False):
worker_messages: Annotated[list, operator.add] # 纯追加
tool_call_iterations: int
topic: str
compressed_output: str
# Worker 输出状态(过滤后返回)
class WorkerOutputState(TypedDict, total=False):
compressed_output: str # 只暴露这个给 Supervisor
override_reducer
默认的 operator.add 只会追加。但在某些场景需要覆盖:
python
def override_reducer(current, new):
if isinstance(new, dict) and new.get("type") == "override":
return new.get("value", [])
return current + new
使用场景 :Supervisor 子图初始化时,需要重置 supervisor_messages:
python
return Command(
goto="supervisor",
update={
"supervisor_messages": {
"type": "override",
"value": [SystemMessage(content=prompt), HumanMessage(content=brief)]
}
}
)
没有 override_reducer,新消息会追加到旧消息后面,导致 Supervisor 看到过时的上下文。
output 过滤
Worker 子图定义时指定 output=WorkerOutputState:
python
builder = StateGraph(WorkerState, output=WorkerOutputState)
这确保 Supervisor 只收到 compressed_output,而非 Worker 的全部 worker_messages(可能有几百条工具调用记录)。
🛠️ 工具设计
Supervisor 工具
python
@tool
def ConductResearch(topic: str) -> str:
"""委派研究任务给 Worker。"""
return f"Research task queued: {topic}" # 占位,由子图拦截
@tool
def ResearchComplete() -> str:
"""表示研究已完成。"""
return "Research complete" # 信号,由子图拦截
@tool
def think_tool(reflection: str) -> str:
"""记录反思,无外部副作用。"""
return f"Reflection recorded: {reflection}"
@tool
async def EvaluateQuality(resources: list[dict], knowledge_graph: dict) -> dict:
"""运行评估引擎。"""
return await run_evaluation(resources, knowledge_graph)
设计要点 :ConductResearch 和 ResearchComplete 是伪工具------它们的返回值不重要,重要的是 Supervisor 子图拦截这些调用并执行相应逻辑。
Worker 工具
python
def get_worker_tools():
return [
web_search, arxiv_search, youtube_search, github_search,
fetch_page, summarize, extract_knowledge, llm_summarize,
save_resource, plugin_search,
]
Worker 工具是真实的 LangChain Tools,由 Worker 的 LLM 自主决定调用哪些。
📊 数据流全景
🐛 踩坑记录
坑 1:状态字段不匹配
现象 :Supervisor 子图收不到主图的 knowledge_graph。
原因 :LangGraph 子图嵌套时,只自动映射同名字段 。如果主图有 knowledge_graph 但 SupervisorState 没有,数据就丢了。
解决:确保 SupervisorState 包含所有需要从主图继承的字段。
坑 2:Worker 输出太大
现象:Supervisor 的 context window 被 Worker 的完整执行历史撑爆。
原因:Worker 执行了 10 轮工具调用,每轮返回 8000 字符的搜索结果,全部传回 Supervisor。
解决:
output=WorkerOutputState过滤,只返回compressed_output- Worker 工具输出截断到 8000 字符
- 压缩节点用轻量模型精简输出
坑 3:并行 Worker 的错误处理
现象 :一个 Worker 失败,导致整个 asyncio.gather 抛异常。
解决:
python
results = await asyncio.gather(*tasks, return_exceptions=True)
for result, call in zip(results, tool_calls):
if isinstance(result, Exception):
tool_outputs.append(ToolMessage(content=f"Error: {result}", ...))
else:
tool_outputs.append(ToolMessage(content=result["output"], ...))
坑 4:override_reducer 的陷阱
现象:Supervisor 的消息列表无限增长,每次决策都追加新消息。
原因 :默认的 operator.add 只会追加。Supervisor 初始化时应该覆盖旧消息,但没有用 override。
解决 :用 {"type": "override", "value": [...]} 触发覆盖。
坑 5:tool_calls 格式
现象 :most_recent.tool_calls 报错 AttributeError。
原因 :不是所有 AIMessage 都有 tool_calls。LLM 可能直接回复文本而不调用工具。
解决:
python
if not most_recent or not hasattr(most_recent, "tool_calls") or not most_recent.tool_calls:
return Command(goto="compress") # 无工具调用,直接压缩
🔌 插件集成
Worker 工具支持动态扩展。通过插件系统,用户可以添加自定义数据源:
python
# tools.py
@tool
async def plugin_search(query: str, max_results: int = 15) -> list[dict]:
"""使用已启用的插件搜索资源。"""
pm = PluginManager()
pm.discover()
return await pm.search_all(query, max_results=max_results)
插件只需实现 BaseCollector 接口:
python
class MyCollector(BaseCollector):
@property
def meta(self):
return PluginMeta(name="my-source", source_type="custom")
async def search(self, query, max_results=20, **kwargs):
return [SearchResult(url="...", title="...", snippet="...")]
放在 plugins/ 目录下自动发现,Worker 可以通过 plugin_search 工具调用。
📡 LangSmith 可观测性
三层嵌套的调试是个挑战。LangSmith 可以追踪每一层的执行:
python
from openlearning.monitoring.tracer import traceable
@traceable("preprocess")
async def preprocess(state):
...
@traceable("postprocess")
async def postprocess(state):
...
环境变量 LANGCHAIN_TRACING_V2=true 让 LangChain 自动追踪所有 LLM 调用、工具调用和子图执行。在 LangSmith 面板上可以看到完整的调用树:
scss
run_pipeline
├── preprocess
│ ├── memory_agent
│ └── planner_agent
├── supervisor_subgraph
│ ├── supervisor (iteration 1)
│ ├── supervisor_tools
│ │ └── Worker × 3 (parallel)
│ │ ├── worker → web_search → fetch_page
│ │ └── compress
│ ├── supervisor (iteration 2)
│ └── supervisor_tools → END
└── postprocess
└── builder_agent
💡 设计思考
为什么用子图而不是函数调用?
函数调用的问题:
- 没有独立状态,所有数据通过参数传递
- 没有内置的循环/条件控制
- 没有 checkpoint,中断后无法恢复
子图的优势:
- 独立状态,通过字段名自动映射
- 内置
Command动态路由 - LangGraph checkpoint 自动保存中间状态
为什么 Supervisor 用工具调用而不是直接路由?
直接路由(旧方案):
python
# Supervisor 直接决定下一个 Agent
return {"_next_agent": "collector"}
工具调用(新方案):
python
# Supervisor 通过工具调用委派任务
return tool_calls=[{"name": "ConductResearch", "args": {"topic": "ownership"}}]
工具调用的优势:
- Supervisor 可以一次调用多个 Worker(并行)
- 工具参数是结构化的(topic 明确)
- 可以混合不同类型的工具(研究 + 反思 + 评估)
为什么需要压缩节点?
Worker 的完整执行历史可能有:
- 10 轮工具调用
- 每轮 3-5 个工具
- 每个工具返回 2000-8000 字符
总计:100K-400K tokens。如果全部传回 Supervisor:
- Context window 爆炸
- Token 成本爆炸
- Supervisor 被细节淹没,无法做高层决策
压缩节点用轻量模型(Qwen3 4B)将执行历史压缩为结构化摘要,通常只需要 1K-3K tokens。
📁 代码结构
bash
src/openlearning/agents/
├── graph.py # 主图编译 + run_pipeline
├── state.py # 四层状态定义
├── tools.py # Supervisor/Worker 工具
├── prompts.py # Prompt 模板集中管理
├── subgraphs/
│ ├── __init__.py
│ ├── supervisor.py # Supervisor 子图
│ └── worker.py # Worker 子图
├── collector.py # 资源采集逻辑(Worker 使用)
├── builder.py # 知识图谱 + 学习系统生成
├── planner.py # 需求分析 + 搜索词生成
├── memory.py # 三层记忆查询
├── evaluator.py # 规则引擎评估
└── updater.py # 增量更新
🚀 未来优化
- Worker 特化:不同类型的 Worker 绑定不同的工具集(搜索型、分析型、抓取型)
- 动态并发:根据任务复杂度自动调整 Worker 数量
- Worker 复用:缓存 Worker 的执行结果,相同 topic 不重复执行
- 流式输出:Supervisor 的决策过程实时推送到前端
- 自适应压缩:根据 token 预算动态调整压缩比例
📝 总结
三层嵌套架构的核心收益:
| 维度 | 单层 Supervisor | 三层嵌套 |
|---|---|---|
| 并行 | ❌ 串行 | ✅ asyncio.gather |
| 状态隔离 | ❌ 30+ 字段共享 | ✅ 每层独立状态 |
| 输出过滤 | ❌ 全量返回 | ✅ output=WorkerOutputState |
| 决策质量 | ❌ 每次重新理解 | ✅ 持续对话上下文 |
| Token 成本 | ❌ 完整历史 | ✅ 压缩输出 |
如果你也在构建复杂的多 Agent 系统,推荐试试三层嵌套模式。核心就一句话:每层各司其职,通过子图嵌套实现分层编排。
资源
GitHub 仓库 :open_learning
💬 有问题欢迎评论区交流。代码已开源,欢迎 Star ⭐