本页目录
LangGraphNote 15

第 15 章:主 Agent、子 Agent 与结构化数据交接

学习目标

学习目标

读完本章,你应能回答四个问题:

  1. 用户问题、用户画像、身份和密钥分别应该从哪里进入图?
  2. 主 agent 怎样把一个问题拆成可执行的子任务?
  3. 主 agent 与子 agent 为什么不直接共享整份 state,而要定义输入/输出契约?
  4. 子 agent 的结果怎样以可验证、可汇总的格式回到主 agent?

本章对应当前项目的真实层级:主图 deep_researcher、supervisor 子图、researcher 子图。新案例把它缩小为“协调 agent -> 两个 researcher 子图 -> 协调 agent 汇总”,并使用真实模型调用。

1. 先分清四种数据

多 agent 系统最容易出错的地方不是模型,而是把不同生命周期的数据混在一起。

数据 正确位置 当前项目例子 是否应进入模型 prompt
用户问题、用户明确允许使用的偏好 图输入和 graph state AgentInputState.messages 需要时明确拼入
研究简报、子任务、子 agent 发现、最终报告 graph state 或子图 output research_briefnotescompressed_research 由消费该字段的节点明确拼入
用户身份、模型选择、密钥、访问 token Runtime.context Configuration.user_idapi_keyssupabase_access_token 默认不进入
跨运行的 token、用户偏好等长期数据 Runtime.store MCP token 默认不进入

因此,“把用户信息传入图”需要先问:这条信息是否真的让模型看到?

await graph.ainvoke(
    {
        # 可以进入 prompt 的最小信息。
        "user_question": "怎样设计多 agent 交接?",
        "user_profile": "Python 初学者,希望中文解释。",
    },
    # 仅供应用逻辑使用的运行依赖,不应原样拼入 prompt。
    context=Configuration(user_id="trusted-user-id"),
)

不要把 JWT、邮箱、完整 CRM 档案、API key 或 Supabase access token 写进 user_profileuser_id 用于鉴权、Store 命名空间或审计,除非业务明确需要,模型不需要知道它。

2. 当前项目的真实交接链

源码证据:

  • state.py 定义 AgentInputStateAgentStateSupervisorStateResearcherStateResearcherOutputState
  • deep_researcher.pywrite_research_briefsupervisor_toolsresearchercompress_researchfinal_report_generation 完成交接。

真实调用链如下:

调用者输入 {messages: [HumanMessage(用户问题)]}
  -> AgentInputState.messages
  -> clarify_with_user
  -> write_research_brief
       ResearchQuestion(research_brief=...)             # 结构化模型输出
  -> AgentState.research_brief
  -> supervisor_messages
  -> supervisor 模型调用 ConductResearch(research_topic=...)
       AIMessage.tool_calls                              # 工具协议中的结构化参数
  -> supervisor_tools 显式投影子图输入
       {researcher_messages, research_topic}
  -> researcher_subgraph
  -> compress_research
       {compressed_research, raw_notes}                 # ResearcherOutputState
  -> ToolMessage(content=compressed_research)
  -> SupervisorState.supervisor_messages
  -> AgentState.notes
  -> final_report_generation
  -> final_report

这里有两种不同的“交接格式”,不要混为一谈:

场景 格式 原因
supervisor 请求应用执行动作 AIMessage.tool_calls + ConductResearch 参数 符合模型工具调用协议,结果必须回填匹配的 ToolMessage.tool_call_id
Python 节点直接调用 researcher 子图 普通 dict 输入 + ResearcherOutputState 输出 是图调用边界,不需要伪造模型工具消息

supervisor_tools 先从工具调用中取出 research_topic,再构造 researcher 子图所需字段。子图并不会自动看到 SupervisorState,这是刻意的边界控制。

3. 为什么必须显式投影输入

父 state 往往包含不属于子 agent 的字段,例如主对话历史、其他子任务的结果、密钥引用、内部路由计数。直接把整份 parent state 传给子图会产生三个问题:

  1. 职责污染:子 agent 不知道哪些字段是自己可读、可写的。
  2. 上下文膨胀:无关消息和其他子任务结果被带进 prompt,增加成本并干扰回答。
  3. 数据泄露:本不该给子 agent 或模型看的用户信息会顺带传播。

推荐在调用点建立一个小而稳定的投影:

researcher_input = {
    "user_profile": state["user_profile"],
    "user_question": state["user_question"],
    "task": task,
}
result = await researcher_graph.ainvoke(
    researcher_input,
    context=runtime.context,
)

这段代码就是数据边界:只有 ResearcherInput 声明的字段可进入子图。context=runtime.context 只传运行依赖,不等于把它放进 researcher_input 或 prompt。

4. 用 Pydantic 模型固定交接格式

本项目的 ConductResearchResearchQuestionSummary 都是 Pydantic BaseModel。第 15 章案例进一步把每一层交接都命名成模型:

class Subtask(BaseModel):
    topic: str
    question: str
    expected_evidence: str


class Finding(BaseModel):
    topic: str
    answer: str
    evidence: list[str]
    limitation: str

with_structured_output(ResearchPlan) 约束主 agent 只能生成含两个 Subtask 的计划;with_structured_output(Finding) 约束每个子 agent 都返回相同字段。主 agent 汇总时不用猜测某段自然语言里哪里是结论、哪里是证据、哪里是不确定性。

主 agent -- ResearchPlan[Subtask, Subtask] --> 两个 researcher
researcher A -- Finding --> 主 agent
researcher B -- Finding --> 主 agent
主 agent -- FinalAnswer --> 调用者

结构化输出不是“模型一定正确”的保证。它保证的是形状:字段缺失、类型不对或列表长度不满足限制时,模型调用会失败或重试,而不是把难以解析的自由文本悄悄传给下一层。事实准确性仍要靠工具、引用、评估和人工检查。

5. 输入 schema、内部 state 与 output schema

案例定义了三套契约:

class UserInput(TypedDict):
    user_profile: str
    user_question: str


class ResearcherInput(TypedDict):
    user_profile: str
    user_question: str
    task: Subtask


class ResearcherOutput(TypedDict):
    finding: Finding

它们在构图中分别使用:

researcher_graph = StateGraph(
    ResearcherState,               # 子图内部完整 state
    context_schema=Configuration,  # 本次运行依赖
    input_schema=ResearcherInput,  # 父图允许传入的字段
    output_schema=ResearcherOutput,# 子图返回给父图的字段
)

input_schema 不是模型 prompt 的自动过滤器,output_schema 也不是访问控制系统。它们首先是图 API 的数据契约。真正进入模型的内容仍由 research_one() 中的 HumanMessage(content=...) 决定,因此要只拼接最小必要字段。

6. 最小真实 Agent 案例

运行 10_multi_agent_handoff.py

uv run python docs/langgraph-learning/examples/10_multi_agent_handoff.py

它会执行四次短模型调用:

  1. 主 agent 根据 user_profileuser_question 生成 ResearchPlan
  2. 两个 researcher 子图并发生成各自的 Finding
  3. 主 agent 只根据 ResearchPlanFinding 生成 FinalAnswer

案例不使用搜索、MCP、数据库或用户真实身份;费用仅来自四次短模型调用。模型配置从 Configuration.from_env() 读取,.envload_dotenv() 载入。

7. 跟当前项目相比,案例省略了什么

当前项目 第 15 章案例 省略原因
messages 与澄清循环 user_question / user_profile 两个字段 聚焦数据契约,不展开对话状态
ConductResearch 工具协议 主节点直接调用 compiled subgraph 先区分图边界与工具协议
搜索、MCP、token 截断、重试 无外部工具的短模型调用 防止外部副作用掩盖交接结构
compressed_research 文本交接 Finding Pydantic 交接 更直观地展示格式化结果

生产代码选择 ToolMessage(content=compressed_research),是因为 supervisor 与 researcher 的交接发生在模型工具调用循环中。若你直接在 Python 节点调子图,像本案例一样返回 Finding 字段更简单。两者都合理,关键是不要跨越协议边界。

8. 常见错误

把全部用户资料放入 state

错。state 可能被 checkpoint、流式输出或其他节点读取。只传模型回答需要的、经过脱敏的最小字段;身份和凭据放 Runtime.context 或受控 Store。

把普通字典当成“结构化结果”

{"answer": "..."} 可以运行,但没有模型校验、字段说明或 IDE 补全。跨 agent、跨团队或需要长期维护时,优先 BaseModel 并给 Field(description=...) 写清语义。

子 agent 直接修改父 state

子图没有父 state 的共享可变引用。让子图返回一个小 output,再由父节点决定怎样合并。这避免两个并发子 agent 竞争写同一个列表或覆盖对方数据。

Runtime.context 自动拼进 prompt

LangGraph 不会自动做这件事,且不应该这样做。代码必须显式选择 user_profiletask 等可见字段;user_idapi_keys、token 和 Store 永远不应通过 f-string 传给模型。

9. 检查题

  1. 当前项目中 ResearchQuestion.research_briefConductResearch.research_topicResearcherOutputState.compressed_research 分别由哪个节点产生、由哪个节点消费?
  2. 为什么 run_researchers() 要逐个构造 ResearcherInput,而不是 researcher_graph.ainvoke(state)
  3. 若要让子 agent 使用“回答应简洁”的用户偏好,应该放在 user_profileConfiguration 还是 Store?分别说明三种选择的条件。
  4. 若 researcher 必须调用受权限保护的 MCP 工具,哪些字段仍绝对不能拼进 prompt?
  5. 把案例的 Finding.evidence 改为需要 URL、标题和摘录的结构化引用对象,然后思考最终主 agent 怎样避免引用未经子 agent 支持的结论。

10. 验证记录

静态检查已通过:uv run python -m compileall -q docs/langgraph-learning/examples 与构图导入检查均成功。

真实模型验证已完成:

uv run python docs/langgraph-learning/examples/10_multi_agent_handoff.py

本次执行完成了一个 ResearchPlan、两个并发 Finding 和一个 FinalAnswer 的结构化输出调用链。没有触发 Tavily、MCP、数据库或其他外部工具;成本只来自四次短模型调用。模型的具体自然语言内容每次会变化,因此本记录只固定可复查的数据契约和外部副作用边界。

相关资源

  • 查看示例代码:docs/langgraph-learning/examples/10_multi_agent_handoff.py
    """Chapter 15: structured handoff between a coordinator and researcher subgraphs."""
    
    import asyncio
    from typing import TypedDict
    
    from dotenv import load_dotenv
    from langchain.chat_models import init_chat_model
    from langchain_core.messages import HumanMessage
    from langgraph.graph import END, START, StateGraph
    from langgraph.runtime import Runtime
    from pydantic import BaseModel, Field
    
    from open_deep_research.configuration import Configuration
    
    
    load_dotenv()
    
    
    class Subtask(BaseModel):
        """A coordinator-to-researcher handoff contract."""
    
        topic: str = Field(description="One focused research topic in Chinese.")
        question: str = Field(description="The exact question this researcher must answer.")
        expected_evidence: str = Field(description="What evidence or explanation to return.")
    
    
    class ResearchPlan(BaseModel):
        """Coordinator output that decides the subgraph inputs."""
    
        brief: str = Field(description="A concise restatement of the user's goal.")
        tasks: list[Subtask] = Field(
            description="Exactly two independent research tasks.", min_length=2, max_length=2
        )
    
    
    class Finding(BaseModel):
        """A researcher-to-coordinator handoff contract."""
    
        topic: str
        answer: str = Field(description="A factual answer in at most two Chinese sentences.")
        evidence: list[str] = Field(
            description="Two concise supporting points.", min_length=2, max_length=2
        )
        limitation: str = Field(description="One uncertainty or scope limitation.")
    
    
    class FinalAnswer(BaseModel):
        """The public response contract returned by the coordinator."""
    
        answer: str = Field(description="A concise Chinese answer for the user.")
        key_points: list[str] = Field(
            description="Exactly three user-facing key points.", min_length=3, max_length=3
        )
    
    
    class UserInput(TypedDict):
        """Public graph input: only information the model needs to answer."""
    
        user_profile: str
        user_question: str
    
    
    class CoordinatorState(UserInput):
        plan: ResearchPlan
        findings: list[Finding]
        final_answer: FinalAnswer
    
    
    class ResearcherInput(TypedDict):
        """Explicit projection from coordinator state into a researcher subgraph."""
    
        user_profile: str
        user_question: str
        task: Subtask
    
    
    class ResearcherState(ResearcherInput):
        finding: Finding
    
    
    class ResearcherOutput(TypedDict):
        finding: Finding
    
    
    class CoordinatorOutput(TypedDict):
        final_answer: FinalAnswer
    
    
    configurable_model = init_chat_model(
        configurable_fields=("model", "max_tokens", "api_key"),
    )
    
    
    def model_for(settings: Configuration, max_tokens: int):
        return configurable_model.with_config(
            {
                "configurable": {
                    "model": settings.research_model,
                    "max_tokens": max_tokens,
                },
                "tags": ["langsmith:nostream", "learning:multi-agent-handoff"],
            }
        )
    
    
    async def research_one(
        state: ResearcherState,
        runtime: Runtime[Configuration],
    ) -> ResearcherOutput:
        task = state["task"]
        finding = await model_for(runtime.context, 160).with_structured_output(
            Finding
        ).ainvoke(
            [
                HumanMessage(
                    content=(
                        "你是研究子 agent。只完成分配给你的一个任务,不要重新规划。\n"
                        f"用户画像: {state['user_profile']}\n"
                        f"用户问题: {state['user_question']}\n"
                        f"任务主题: {task.topic}\n"
                        f"任务问题: {task.question}\n"
                        f"要求证据: {task.expected_evidence}"
                    )
                )
            ]
        )
        return {"finding": finding}
    
    
    researcher_graph = (
        StateGraph(
            ResearcherState,
            context_schema=Configuration,
            input_schema=ResearcherInput,
            output_schema=ResearcherOutput,
        )
        .add_node("research_one", research_one)
        .add_edge(START, "research_one")
        .add_edge("research_one", END)
        .compile()
    )
    
    
    async def make_plan(
        state: CoordinatorState,
        runtime: Runtime[Configuration],
    ):
        plan = await model_for(runtime.context, 180).with_structured_output(
            ResearchPlan
        ).ainvoke(
            [
                HumanMessage(
                    content=(
                        "你是主协调 agent。把用户问题拆成两个互补、可并行的研究任务。\n"
                        f"用户画像: {state['user_profile']}\n"
                        f"用户问题: {state['user_question']}"
                    )
                )
            ]
        )
        return {"plan": plan}
    
    
    async def run_researchers(
        state: CoordinatorState,
        runtime: Runtime[Configuration],
    ):
        results = await asyncio.gather(
            *(
                researcher_graph.ainvoke(
                    {
                        "user_profile": state["user_profile"],
                        "user_question": state["user_question"],
                        "task": task,
                    },
                    context=runtime.context,
                )
                for task in state["plan"].tasks
            )
        )
        return {"findings": [result["finding"] for result in results]}
    
    
    async def write_answer(
        state: CoordinatorState,
        runtime: Runtime[Configuration],
    ) -> CoordinatorOutput:
        findings = "\n".join(
            finding.model_dump_json() for finding in state["findings"]
        )
        final_answer = await model_for(runtime.context, 220).with_structured_output(
            FinalAnswer
        ).ainvoke(
            [
                HumanMessage(
                    content=(
                        "你是主协调 agent。仅依据下列子 agent 的结构化 finding 回答用户;"
                        "不要编造未提供的证据,并保留必要限制。\n"
                        f"用户画像: {state['user_profile']}\n"
                        f"用户问题: {state['user_question']}\n"
                        f"研究计划: {state['plan'].model_dump_json()}\n"
                        f"子 agent 结果:\n{findings}"
                    )
                )
            ]
        )
        return {"final_answer": final_answer}
    
    
    coordinator_graph = (
        StateGraph(
            CoordinatorState,
            context_schema=Configuration,
            input_schema=UserInput,
            output_schema=CoordinatorOutput,
        )
        .add_node("make_plan", make_plan)
        .add_node("run_researchers", run_researchers)
        .add_node("write_answer", write_answer)
        .add_edge(START, "make_plan")
        .add_edge("make_plan", "run_researchers")
        .add_edge("run_researchers", "write_answer")
        .add_edge("write_answer", END)
        .compile()
    )
    
    
    async def main():
        result = await coordinator_graph.ainvoke(
            {
                "user_profile": "Python 初学者,正在学习当前 open_deep_research 项目。",
                "user_question": "主 agent 和 researcher 子 agent 怎样通过结构化数据协作?",
            },
            context=Configuration.from_env(),
        )
        answer = result["final_answer"]
        print("最终回答: " + answer.answer)
        print("要点: " + " | ".join(answer.key_points))
    
    
    if __name__ == "__main__":
        asyncio.run(main())