本页目录
编排篇Note 08

第 8 章:综合迷你研究员

把前七章知识点串成离线 mini researcher。

学习目标

把前 7 章串成一个不联网的 mini deep researcher:输入用户问题,生成结构化研究计划,并发调用 researcher 子图,每个 researcher 跑一次本地工具循环,最后汇总成报告,并用 checkpointer 保存线程状态。

它是什么

这个综合案例不是新概念,而是把项目主线缩小到能一次看懂的版本。它对应当前项目的骨架:外层图负责 planning 和 final report,researcher 子图负责单个 topic,父图用 asyncio.gather 并发调用多个 researcher。为了安全和省钱,示例不调用搜索、不连接 MCP,只用本地工具模拟“研究中记录边界”。

知识点对应关系

前面章节 综合案例位置 项目对应
真实模型与消息 所有模型节点都用 HumanMessage 和真实 ainvoke researcher()final_report_generation()
状态与路由 节点返回 Command(update=..., goto=...) clarify_with_user()researcher_tools()
结构化输出 make_plan 返回 MiniPlan write_research_brief() 返回 ResearchQuestion
工具循环 researcher 子图绑定 record_learning_boundary researcher() 绑定搜索/MCP/think 工具
搜索与 MCP 明确不联网,只保留工具装配协议 get_all_tools()
子图与并发 父图 asyncio.gather 调两个 researcher 子图 supervisor_tools()
持久化与观测 InMemorySaverthread_idastream_eventsget_state 部署时的 checkpointer / LangSmith 观测

最小真实 Agent

示例文件:08_integrated_mini_researcher.py

流程:

START
  -> make_plan
  -> run_researchers
       -> researcher_graph(topic A)
       -> researcher_graph(topic B)
  -> write_final
  -> END

researcher 子图内部:

researcher_agent
  -> researcher_tools
  -> researcher_agent
  -> END

第一轮 researcher 模型被强制调用本地工具 record_learning_boundary;第二轮模型读取 ToolMessage 后生成一句摘要。

运行

uv run python docs/langgraph-learning/examples/08_integrated_mini_researcher.py

预期现象:

  1. 输出事件数。
  2. 输出结构化计划产生的两个 topic。
  3. 输出两个 researcher 摘要。
  4. 输出最终报告。
  5. 输出持久化状态中的消息数。

常见误区

把综合案例当生产版 deep research。 不是。它是学习版,刻意去掉搜索、MCP、长报告、错误重试和 token 截断。

以为子图并发只要套 gather 就完了。 不够。父图必须把父状态转换成子图输入,再把子图输出转换回父状态;真实项目还要处理限流、失败和部分结果。

以为本地工具等价于搜索/MCP。 不等价。本地工具只验证工具协议;真实搜索/MCP 的认证、权限、网络失败和费用边界要单独验证。

本次真实验证

已使用默认 openai:gpt-5.5 运行一次。结果为:

事件数: 675
主题: open_deep_research 中的 LangGraph 工具循环:研究 Agent 如何在模型调用、工具调用、观察结果与继续/结束判断之间循环执行 | open_deep_research 中的子图并发模式:如何拆分多个研究子任务、并行运行子图并汇总结果
路由: make_plan -> run_researchers | run_researchers -> write_final | write_final -> END
摘要数: 2
最终报告: open_deep_research 展示了用 LangGraph 构建研究型 Agent 的核心模式:通过状态 schema 记录研究问题、消息历史、工具观察、阶段性摘要与最终输出,并由节点定义模型推理、工具执行、总结聚合等步骤。
其工具循环采用“LLM 决定下一步或发起工具调用 → 工具节点执行搜索/抓取/分析并写回状态 → 条件边判断继续检索、进入总结或终止”的状态流转,使研究过程能在信息不足时持续迭代,在达到轮次上限、无新工具调用或结果充分时结束。
子图并发则将复杂主题拆成多个独立研究任务,并行运行相同研究子图,最后汇总成功结果、处理失败或缺失输出并生成综合报告;这一模式可迁移到自己的 LangGraph 应用中,用于多主题搜索、并行分析和结构化结果聚合。
持久化消息数: 2

运行时出现两个告警:

  • v3 streaming protocol on Pregel is experimental:当前版本的 v3 事件流仍是 beta。
  • PydanticSerializationUnexpectedValue:事件流序列化结构化输出对象时的提示,不影响最终状态。

本次没有触发搜索、MCP 或生产 API;成本来自 plan、两个并发 researcher 工具循环和 final report 的短模型调用。

相关资源

  • 查看示例代码:docs/langgraph-learning/examples/08_integrated_mini_researcher.py
    """Chapter 8: a compact mini deep researcher using the prior chapters."""
    
    import asyncio
    import operator
    from typing import Annotated, Literal
    
    from dotenv import load_dotenv
    from langchain.chat_models import init_chat_model
    from langchain_core.messages import HumanMessage, MessageLikeRepresentation, ToolMessage
    from langchain_core.tools import tool
    from langgraph.checkpoint.memory import InMemorySaver
    from langgraph.graph import END, START, MessagesState, StateGraph
    from langgraph.runtime import Runtime
    from langgraph.types import Command
    from pydantic import BaseModel, Field
    from typing_extensions import TypedDict
    
    from open_deep_research.configuration import Configuration
    
    
    load_dotenv()
    
    
    class MiniPlan(BaseModel):
        """A small structured research plan."""
    
        research_brief: str = Field(description="One focused Chinese research brief.")
        topics: list[str] = Field(
            description="Exactly two focused Chinese subtopics.",
            min_length=2,
            max_length=2,
        )
    
    
    class MiniState(MessagesState):
        research_brief: str
        topics: list[str]
        summaries: list[str]
        final_report: str
        route_log: Annotated[list[str], operator.add]
    
    
    class ResearcherState(TypedDict):
        topic: str
        researcher_messages: Annotated[list[MessageLikeRepresentation], operator.add]
        summary: str
    
    
    class ResearcherOutput(TypedDict):
        summary: str
    
    
    configurable_model = init_chat_model(
        configurable_fields=("model", "max_tokens", "api_key"),
    )
    
    
    def configured_model(settings: Configuration, max_tokens: int = 180):
        return configurable_model.with_config(
            {
                "configurable": {
                    "model": settings.research_model,
                    "max_tokens": max_tokens,
                },
                "tags": ["langsmith:nostream"],
            }
        )
    
    
    def text_content(content) -> str:
        if isinstance(content, list):
            return "".join(
                part.get("text", str(part)) if isinstance(part, dict) else str(part)
                for part in content
            )
        return str(content)
    
    
    @tool
    def record_learning_boundary(boundary: str) -> str:
        """Record one learning boundary before summarizing a research topic."""
        return f"已记录边界: {boundary}"
    
    
    async def researcher_agent(
        state: ResearcherState,
        runtime: Runtime[Configuration],
    ) -> Command[Literal["researcher_tools", "__end__"]]:
        model = configured_model(runtime.context)
        has_tool_result = any(
            getattr(message, "type", None) == "tool"
            for message in state.get("researcher_messages", [])
        )
        if not has_tool_result:
            response = await model.bind_tools(
                [record_learning_boundary],
                tool_choice="record_learning_boundary",
            ).ainvoke(
                [
                    HumanMessage(
                        content=(
                            f"研究主题: {state['topic']}。先调用工具记录一个学习边界。"
                        )
                    )
                ]
            )
            return Command(
                update={"researcher_messages": [response]},
                goto="researcher_tools",
            )
    
        response = await model.ainvoke(
            state["researcher_messages"]
            + [HumanMessage(content=f"基于工具记录,用一句中文总结: {state['topic']}")]
        )
        return Command(
            update={"researcher_messages": [response], "summary": text_content(response.content)},
            goto=END,
        )
    
    
    async def researcher_tools(
        state: ResearcherState,
    ) -> Command[Literal["researcher_agent"]]:
        last_message = state["researcher_messages"][-1]
        outputs = []
        for tool_call in last_message.tool_calls:
            outputs.append(
                ToolMessage(
                    content=record_learning_boundary.invoke(tool_call["args"]),
                    name=tool_call["name"],
                    tool_call_id=tool_call["id"],
                )
            )
        return Command(update={"researcher_messages": outputs}, goto="researcher_agent")
    
    
    researcher_graph = (
        StateGraph(
            ResearcherState,
            context_schema=Configuration,
            input_schema=ResearcherState,
            output_schema=ResearcherOutput,
        )
        .add_node("researcher_agent", researcher_agent)
        .add_node("researcher_tools", researcher_tools)
        .add_edge(START, "researcher_agent")
        .compile()
    )
    
    
    async def make_plan(
        state: MiniState,
        runtime: Runtime[Configuration],
    ) -> Command[Literal["run_researchers"]]:
        model = configured_model(runtime.context).with_structured_output(MiniPlan)
        plan = await model.ainvoke(
            state["messages"]
            + [
                HumanMessage(
                    content=(
                        "把用户学习目标整理成一个 research_brief 和两个子主题,"
                        "必须聚焦当前 open_deep_research 项目。"
                    )
                )
            ]
        )
        return Command(
            update={
                "research_brief": plan.research_brief,
                "topics": plan.topics,
                "route_log": ["make_plan -> run_researchers"],
            },
            goto="run_researchers",
        )
    
    
    async def run_researchers(
        state: MiniState,
        runtime: Runtime[Configuration],
    ) -> Command[Literal["write_final"]]:
        results = await asyncio.gather(
            *(
                researcher_graph.ainvoke(
                    {
                        "topic": topic,
                        "researcher_messages": [],
                        "summary": "",
                    },
                    context=runtime.context,
                )
                for topic in state["topics"]
            )
        )
        return Command(
            update={
                "summaries": [result["summary"] for result in results],
                "route_log": ["run_researchers -> write_final"],
            },
            goto="write_final",
        )
    
    
    async def write_final(state: MiniState, runtime: Runtime[Configuration]):
        response = await configured_model(runtime.context, max_tokens=240).ainvoke(
            [
                HumanMessage(
                    content=(
                        "请用三句中文写一个迷你研究报告。\n"
                        f"研究 brief: {state['research_brief']}\n"
                        f"研究摘要: {state['summaries']}"
                    )
                )
            ]
        )
        return {
            "final_report": text_content(response.content),
            "messages": [response],
            "route_log": ["write_final -> END"],
        }
    
    
    memory = InMemorySaver()
    mini_researcher = (
        StateGraph(MiniState, context_schema=Configuration)
        .add_node("make_plan", make_plan)
        .add_node("run_researchers", run_researchers)
        .add_node("write_final", write_final)
        .add_edge(START, "make_plan")
        .add_edge("write_final", END)
        .compile(checkpointer=memory)
    )
    
    
    async def main():
        config = {"configurable": {"thread_id": "mini-researcher-learning"}}
        context = Configuration.from_env()
        input_value = {
            "messages": [
                HumanMessage(
                    content=(
                        "我想通过 open_deep_research 学会 LangGraph 的工具循环和子图并发。"
                    )
                )
            ],
            "route_log": [],
            "summaries": [],
        }
    
        event_count = 0
        stream = await mini_researcher.astream_events(
            input_value,
            config=config,
            context=context,
            version="v3",
        )
        async for _event in stream:
            event_count += 1
    
        snapshot = mini_researcher.get_state(config)
        values = snapshot.values
        print(f"事件数: {event_count}")
        print("主题: " + " | ".join(values["topics"]))
        print("路由: " + " | ".join(values["route_log"]))
        print("摘要数: " + str(len(values["summaries"])))
        print("最终报告: " + values["final_report"])
        print("持久化消息数: " + str(len(values["messages"])))
    
    
    if __name__ == "__main__":
        asyncio.run(main())