第 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() |
| 持久化与观测 | InMemorySaver、thread_id、astream_events、get_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
预期现象:
- 输出事件数。
- 输出结构化计划产生的两个 topic。
- 输出两个 researcher 摘要。
- 输出最终报告。
- 输出持久化状态中的消息数。
常见误区
把综合案例当生产版 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())