手把手教你用 LangGraph 搭建三层嵌套 Agent 架构

手把手教你用 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

核心思想:每层各司其职,通过子图嵌套实现分层编排。

🧩 三层架构详解

flowchart TD subgraph Main[&#34;第一层:主图&#34;] direction LR Pre[&#34;preprocess<br/>Memory + Planner&#34;] --> Sup[&#34;Supervisor 子图&#34;] Sup --> Post[&#34;postprocess<br/>Builder&#34;] end subgraph SupSub[&#34;第二层:Supervisor 子图&#34;] direction LR S1[&#34;supervisor<br/>LLM 决策&#34;] --> S2[&#34;supervisor_tools<br/>执行工具&#34;] S2 -->|继续| S1 S2 -->|结束| END end subgraph Workers[&#34;第三层:Worker 子图(并行 ×N)&#34;] direction LR W1[&#34;worker<br/>LLM 调用工具&#34;] --> W2[&#34;worker_tools<br/>执行工具&#34;] W2 -->|继续| W1 W2 -->|完成| W3[&#34;compress<br/>压缩输出&#34;] end SupSub -.->|&#34;ConductResearch&#34;| Workers

第一层:主图

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)

设计要点ConductResearchResearchComplete伪工具------它们的返回值不重要,重要的是 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 自主决定调用哪些。

📊 数据流全景

sequenceDiagram participant U as 用户 participant M as 主图 participant S as Supervisor participant W as Worker ×N participant B as Builder U->>M: &#34;我想学 Rust&#34; M->>M: preprocess(Memory + Planner) M->>S: 研究简报 + 知识图谱 loop 研究循环 S->>S: LLM 决策(绑定工具) S->>W: ConductResearch(&#34;ownership 模型&#34;) S->>W: ConductResearch(&#34;错误处理&#34;) par 并行执行 W->>W: search → fetch → analyze W->>W: compress(压缩输出) end W-->>S: compressed_output S->>S: 评估结果,决定继续或结束 end S->>M: notes + research_brief M->>B: 知识图谱 + 资源 B->>B: 匹配资源 → 丰富概念 → 生成路径 B-->>U: 学习系统

🐛 踩坑记录

坑 1:状态字段不匹配

现象 :Supervisor 子图收不到主图的 knowledge_graph

原因 :LangGraph 子图嵌套时,只自动映射同名字段 。如果主图有 knowledge_graph 但 SupervisorState 没有,数据就丢了。

解决:确保 SupervisorState 包含所有需要从主图继承的字段。

坑 2:Worker 输出太大

现象:Supervisor 的 context window 被 Worker 的完整执行历史撑爆。

原因:Worker 执行了 10 轮工具调用,每轮返回 8000 字符的搜索结果,全部传回 Supervisor。

解决

  1. output=WorkerOutputState 过滤,只返回 compressed_output
  2. Worker 工具输出截断到 8000 字符
  3. 压缩节点用轻量模型精简输出

坑 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            # 增量更新

🚀 未来优化

  1. Worker 特化:不同类型的 Worker 绑定不同的工具集(搜索型、分析型、抓取型)
  2. 动态并发:根据任务复杂度自动调整 Worker 数量
  3. Worker 复用:缓存 Worker 的执行结果,相同 topic 不重复执行
  4. 流式输出:Supervisor 的决策过程实时推送到前端
  5. 自适应压缩:根据 token 预算动态调整压缩比例

📝 总结

三层嵌套架构的核心收益:

维度 单层 Supervisor 三层嵌套
并行 ❌ 串行 ✅ asyncio.gather
状态隔离 ❌ 30+ 字段共享 ✅ 每层独立状态
输出过滤 ❌ 全量返回 ✅ output=WorkerOutputState
决策质量 ❌ 每次重新理解 ✅ 持续对话上下文
Token 成本 ❌ 完整历史 ✅ 压缩输出

如果你也在构建复杂的多 Agent 系统,推荐试试三层嵌套模式。核心就一句话:每层各司其职,通过子图嵌套实现分层编排

资源

GitHub 仓库open_learning


💬 有问题欢迎评论区交流。代码已开源,欢迎 Star ⭐

相关推荐
CodeSheep6 小时前
DeepSeek正式官宣摇人,夯!
前端·后端·程序员
花酒锄作田17 小时前
Pydantic校验配置文件
python
hboot17 小时前
AI工程师第四课 - 深度学习入门
pytorch·python·神经网络
爱勇宝1 天前
从 Ctrl+CV 到 Enter:程序员正在失去什么
前端·后端·程序员
DyLatte1 天前
AI 时代,最危险的不是被替代,而是努力不沉淀
前端·后端·程序员
ZhengEnCi1 天前
P2M-Matplotlib折线图完全指南-从数据可视化到趋势分析的Python绘图利器
python·matlab·数据可视化
Coffeeee1 天前
闲聊几句,Android老哥们,你们多久没做技改需求了
android·程序员·代码规范
字节跳动数据库1 天前
文章分享——相似函数处理方法
人工智能·后端·程序员
ZhengEnCi1 天前
P2L-Matplotlib饼图完全指南-从数据可视化到图表定制的Python绘图利器
python·matlab