工程篇Note 09
第 9 章:项目知识覆盖矩阵
将概念、项目源码和真实示例映射为学习矩阵。
前 8 章按 LangGraph/LangChain 的核心 API 递进讲解。本章把这些概念重新映射到 src/open_deep_research,目的是避免“会写一个 demo,却看不懂项目为什么这样组合”。
先看全局
用户 messages
-> clarify_with_user
-> write_research_brief
-> supervisor 子图
-> 0..N 个 researcher 子图(并发)
-> 汇总 notes
-> final_report_generation
-> final_report
主图定义在 deep_researcher.py,主状态定义在 state.py,运行期选择由 configuration.py 提供。
覆盖表
| 项目中的问题 | 实际类型或 API | 阅读位置 | 已验证的真实 Agent 示例 | 学习时要回答的问题 |
|---|---|---|---|---|
| 用户消息怎样累计 | MessagesState、messages reducer |
AgentInputState、AgentState |
01 | 为什么节点只返回新消息而不是整段历史? |
| 非消息状态怎样合并 | TypedDict、Annotated、operator.add、自定义 reducer |
AgentState、SupervisorState、ResearcherState |
02 | 这个字段是追加、覆盖,还是最后写入者获胜? |
| 模型如何产生可控决策 | BaseModel、Field、with_structured_output |
ClarifyWithUser、ResearchQuestion |
03 | 这里为什么不用让模型直接输出 JSON 文本? |
| 模型如何请求外部动作 | @tool、bind_tools、ToolMessage |
think_tool、ConductResearch、ResearchComplete |
04 | 工具调用结果为什么必须回填为同一个 tool_call_id? |
| 运行时下一跳如何决定 | Command(goto=..., update=...) |
clarify_with_user、两个 tool 节点 |
02 | goto 与 update 为什么要在一次返回中同时出现? |
| 搜索与外部工具如何装配 | Tavily、原生 web search、MCP | get_all_tools、get_search_tool、load_mcp_tools |
05 | 哪些工具运行在模型侧,哪些由本地代码执行? |
| 研究任务为什么可并发 | 子图、asyncio.gather |
supervisor_tools |
06 | 并发上限放在哪里,为什么不完全交给模型? |
| 状态如何跨请求保存和观察 | checkpointer、thread_id、事件流 |
LangGraph 平台运行时与第 7 章 | 07 | thread_id 与“用户身份”是不是一回事? |
| 小型完整工作流如何拼装 | StateGraph、工具循环、汇总节点 |
第 8 章的 mini researcher | 08 | 哪些部分可复用,哪些必须按业务重写? |
| 当前项目完整链路 | 主图 + supervisor/researcher 子图 | 本章后续第 10 章 | 第 1–8 章组合验证 | 每次 Command 写了什么状态,谁消费它? |
| 主/子 agent 如何交接信息 | input_schema、output_schema、Pydantic handoff model、显式 state 投影 |
supervisor_tools、ResearcherOutputState |
15 | 用户问题、任务和结果为何不能共用一份无约束 dict? |
| 配置、密钥、MCP 登录 | Runtime.context、Pydantic、RunnableConfig 控制面、LangGraph Store |
configuration.py、utils.py |
第 5、7 章的真实调用锚点 | 配置和密钥分别从哪来,谁能看到? |
| 失败恢复和评估 | retry、token 截断、pytest、LangSmith evaluation |
utils.py、tests/ |
第 6、7 章的运行锚点 | 哪些异常应该终止,哪些应该降级? |
三种运行数据不要混淆
- Graph state:一次图执行中被节点读写的数据,例如
notes、research_brief、researcher_messages。它是工作流的短期记忆。 - Runtime context:一次运行的业务上下文,例如
research_model、search_api、并发上限。它通过context_schema和context=传入,节点从runtime.context读取;它也不是图状态。 - RunnableConfig:一次调用的运行控制参数,例如
tags、callbacks、recursion limit、thread_id。它不承载本项目业务配置,节点也不会自动把它写进 checkpoint。 - Store 中的持久数据:本项目用于保存 MCP access token,按
(user_id, "tokens")命名空间隔离。它不应混入AgentState或 prompt。
第 7 章里同一个 thread_id 可恢复图状态;第 11 章会说明它仍不能代替认证用户的 owner。
版本差异提醒
当前项目的构图参数已迁移为当前命名:
StateGraph(AgentState, input_schema=AgentInputState)
当前 Python 文档推荐显式使用 input_schema、output_schema 和 context_schema。本项目主图和学习示例均已使用这三个入口;context_schema 的完整迁移原因放在第 14 章。
参考:LangGraph 官方 Python Graph API 的“multiple schemas”和“input/output schemas”章节。
本章检查
回答下面四题,再进入下一章:
researcher_messages追加一条ToolMessage时,谁决定“追加”而不是覆盖?runtime.context为什么不应该写回AgentState?为什么业务配置不应放入RunnableConfig?ResearcherOutputState为什么只暴露压缩结果和原始笔记,而不暴露整个工具对话?messages和supervisor_messages为什么必须是两个不同的字段?
LangChain Agent 扩展覆盖
LangChain v1 的标准 Agent 课程位于 langchain/README.md。它与主项目的取舍如下:
| 需求 | 首选 | 当前项目证据 | 原因 |
|---|---|---|---|
| 一个模型加有限工具的标准 ReAct 循环 | create_agent |
researcher -> researcher_tools 是同类手写循环 |
框架维护 AIMessage -> ToolMessage 循环,少写样板代码 |
| 结构化最终答复、记忆、流式、审批 | create_agent + 参数/middleware |
主图有结构化输出、checkpoint、事件流,但未使用 Agent middleware | 标准能力可以独立学习后用于简单助理 |
| 澄清、研究 brief、supervisor、多 researcher 并发、压缩、报告 | StateGraph |
deep_researcher.py 的 main/supervisor/researcher 三层图 |
需要显式节点、子图、state 契约和可审查路由,不能压成单 Agent |
| 自动子 Agent 委派 | 先用 compiled subgraph;deepagents 为可选扩展 |
supervisor_subgraph、researcher_subgraph |
deepagents 非当前依赖,不能假装已使用 |
这不是“LangChain 或 LangGraph 二选一”:create_agent 本身返回 CompiledStateGraph。区别在于谁定义运行图,标准循环由 LangChain 预制,复杂业务编排由应用显式编写。
相关资源
查看示例代码:docs/langgraph-learning/examples/01_real_model_agent.py
"""Chapter 1: one real model call inside a minimal LangGraph agent.""" import asyncio 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, MessagesState, StateGraph from langgraph.runtime import Runtime from open_deep_research.configuration import Configuration load_dotenv() configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def answer(state: MessagesState, runtime: Runtime[Configuration]): """Call the configured real model and append its reply to message state.""" settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 80, }, "tags": ["langsmith:nostream"], } ) response = await model.ainvoke(state["messages"]) return {"messages": [response]} async def main(): graph = ( StateGraph(MessagesState, context_schema=Configuration) .add_node("answer", answer) .add_edge(START, "answer") .add_edge("answer", END) .compile() ) result = await graph.ainvoke( { "messages": [ HumanMessage(content="只用一句中文说明 LangGraph 的作用。"), ] }, context=Configuration.from_env(), ) print(result["messages"][-1].content) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/02_state_and_command.py
"""Chapter 2: state reducers and Command routing with one real model call.""" 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 from langgraph.graph import END, START, MessagesState, StateGraph from langgraph.runtime import Runtime from langgraph.types import Command from open_deep_research.configuration import Configuration load_dotenv() class LearningState(MessagesState): """Conversation state plus an append-only record of graph routing.""" route_log: Annotated[list[str], operator.add] configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def answer( state: LearningState, runtime: Runtime[Configuration], ) -> Command[Literal["finish"]]: """Call the real model, update state, then choose the next node.""" settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 80, }, "tags": ["langsmith:nostream"], } ) response = await model.ainvoke(state["messages"]) return Command( update={ "messages": [response], "route_log": ["answer -> finish"], }, goto="finish", ) def finish(_: LearningState): """Append one local state update, then use its static edge to end.""" return {"route_log": ["finish -> END"]} async def main(): graph = ( StateGraph(LearningState, context_schema=Configuration) .add_node("answer", answer) .add_node("finish", finish) .add_edge(START, "answer") .add_edge("finish", END) .compile() ) result = await graph.ainvoke( { "messages": [ HumanMessage(content="只用一句中文解释状态机为什么适合 Agent。"), ], "route_log": [], }, context=Configuration.from_env(), ) print(f"消息数: {len(result['messages'])}") print("路由: " + " | ".join(result["route_log"])) print("答复: " + str(result["messages"][-1].content)) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/03_structured_output.py
"""Chapter 3: one real structured-output call inside a minimal graph.""" import asyncio from dotenv import load_dotenv from langchain.chat_models import init_chat_model from langchain_core.messages import AIMessage, HumanMessage from langgraph.graph import END, START, MessagesState, StateGraph from langgraph.runtime import Runtime from pydantic import BaseModel, Field from open_deep_research.configuration import Configuration load_dotenv() class TopicBrief(BaseModel): """A tiny schema for learning structured output.""" title: str = Field(description="A short Chinese title.") research_question: str = Field(description="One focused Chinese research question.") needs_tools: bool = Field(description="Whether external search/tools are needed.") configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def make_brief(state: MessagesState, runtime: Runtime[Configuration]): settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 160, }, "tags": ["langsmith:nostream"], } ) structured_model = model.with_structured_output(TopicBrief) brief = await structured_model.ainvoke(state["messages"]) return {"messages": [AIMessage(content=brief.model_dump_json(ensure_ascii=False))]} async def main(): graph = ( StateGraph(MessagesState, context_schema=Configuration) .add_node("make_brief", make_brief) .add_edge(START, "make_brief") .add_edge("make_brief", END) .compile() ) result = await graph.ainvoke( { "messages": [ HumanMessage(content="我想研究 LangGraph 的状态管理。"), ] }, context=Configuration.from_env(), ) print(result["messages"][-1].content) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/04_tool_loop.py
"""Chapter 4: a tiny real ReAct loop with one bound tool.""" import asyncio from dotenv import load_dotenv from langchain.chat_models import init_chat_model from langchain_core.messages import HumanMessage, ToolMessage from langchain_core.tools import tool from langgraph.graph import START, MessagesState, StateGraph from langgraph.runtime import Runtime from langgraph.types import Command from open_deep_research.configuration import Configuration load_dotenv() @tool def multiply_by_two(value: int) -> str: """Multiply the input integer by two.""" return str(value * 2) configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def agent(state: MessagesState, runtime: Runtime[Configuration]): settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 120, }, "tags": ["langsmith:nostream"], } ).bind_tools([multiply_by_two]) response = await model.ainvoke(state["messages"]) if response.tool_calls: return Command(update={"messages": [response]}, goto="run_tools") return {"messages": [response]} async def run_tools(state: MessagesState): last_message = state["messages"][-1] outputs = [] for tool_call in last_message.tool_calls: result = multiply_by_two.invoke(tool_call["args"]) outputs.append( ToolMessage( content=result, name=tool_call["name"], tool_call_id=tool_call["id"], ) ) return Command(update={"messages": outputs}, goto="agent") async def main(): graph = ( StateGraph(MessagesState, context_schema=Configuration) .add_node("agent", agent) .add_node("run_tools", run_tools) .add_edge(START, "agent") .add_edge("run_tools", "agent") .compile() ) result = await graph.ainvoke( { "messages": [ HumanMessage( content="请使用可用工具计算 21 的两倍,然后只用一句中文给出答案。" ) ] }, context=Configuration.from_env(), ) print(f"消息数: {len(result['messages'])}") print("最终答复: " + str(result["messages"][-1].content)) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/05_search_and_mcp.py
"""Chapter 5: assemble project tools, then run one safe real tool loop.""" import asyncio from dotenv import load_dotenv from langchain.chat_models import init_chat_model from langchain_core.messages import HumanMessage, ToolMessage from open_deep_research.configuration import Configuration from open_deep_research.utils import get_all_tools, get_api_key_for_model load_dotenv() configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) def tool_name(tool): return tool.name if hasattr(tool, "name") else tool.get("name", "web_search") async def main(): settings = Configuration.from_env() tool_context = Configuration.model_validate( {**settings.model_dump(), "search_api": "none"} ) tools = await get_all_tools(tool_context) print("可用工具: " + ", ".join(tool_name(tool) for tool in tools)) think_tool = next(tool for tool in tools if tool_name(tool) == "think_tool") model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 160, "api_key": get_api_key_for_model(settings.research_model, settings), }, "tags": ["langsmith:nostream"], } ).bind_tools([think_tool]) messages = [ HumanMessage( content=( "请先调用 think_tool,反思学习搜索与 MCP 时最该关注的一个边界," "然后用一句中文总结。" ) ) ] first_response = await model.ainvoke(messages) messages.append(first_response) print(f"工具调用数: {len(first_response.tool_calls)}") tool_outputs = [] for tool_call in first_response.tool_calls: result = think_tool.invoke(tool_call["args"]) tool_outputs.append( ToolMessage( content=result, name=tool_call["name"], tool_call_id=tool_call["id"], ) ) final_response = await model.ainvoke(messages + tool_outputs) print("最终答复: " + str(final_response.content)) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/06_subgraphs_and_concurrency.py
"""Chapter 6: invoke two compiled subgraphs concurrently with real models.""" 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 open_deep_research.configuration import Configuration load_dotenv() class ResearcherState(TypedDict): topic: str summary: str class ResearcherOutput(TypedDict): summary: str class ParentState(TypedDict): topics: list[str] summaries: list[str] configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def summarize_topic( state: ResearcherState, runtime: Runtime[Configuration], ): settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 120, }, "tags": ["langsmith:nostream"], } ) response = await model.ainvoke( [ HumanMessage( content=f"只用一句中文概括这个 LangGraph 学习主题: {state['topic']}" ) ] ) return {"summary": str(response.content)} researcher_graph = ( StateGraph( ResearcherState, context_schema=Configuration, input_schema=ResearcherState, output_schema=ResearcherOutput, ) .add_node("summarize_topic", summarize_topic) .add_edge(START, "summarize_topic") .add_edge("summarize_topic", END) .compile() ) async def run_researchers( state: ParentState, runtime: Runtime[Configuration], ): results = await asyncio.gather( *( researcher_graph.ainvoke( {"topic": topic}, context=runtime.context, ) for topic in state["topics"] ) ) return {"summaries": [result["summary"] for result in results]} async def main(): parent_graph = ( StateGraph(ParentState, context_schema=Configuration) .add_node("run_researchers", run_researchers) .add_edge(START, "run_researchers") .add_edge("run_researchers", END) .compile() ) result = await parent_graph.ainvoke( { "topics": ["子图隔离状态", "asyncio.gather 并发调用"], "summaries": [], }, context=Configuration.from_env(), ) print(f"子图数: {len(result['summaries'])}") for summary in result["summaries"]: print("- " + summary) if __name__ == "__main__": asyncio.run(main())查看示例代码:docs/langgraph-learning/examples/07_persistence_streaming_observability.py
"""Chapter 7: persist one thread, stream events, inspect saved state.""" import asyncio from dotenv import load_dotenv from langchain.chat_models import init_chat_model from langchain_core.messages import HumanMessage from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import END, START, MessagesState, StateGraph from langgraph.runtime import Runtime from open_deep_research.configuration import Configuration load_dotenv() configurable_model = init_chat_model( configurable_fields=("model", "max_tokens", "api_key"), ) async def reply(state: MessagesState, runtime: Runtime[Configuration]): settings = runtime.context model = configurable_model.with_config( { "configurable": { "model": settings.research_model, "max_tokens": 120, }, "tags": ["langsmith:nostream"], } ) response = await model.ainvoke(state["messages"]) return {"messages": [response]} memory = InMemorySaver() graph = ( StateGraph(MessagesState, context_schema=Configuration) .add_node("reply", reply) .add_edge(START, "reply") .add_edge("reply", END) .compile(checkpointer=memory) ) async def count_events(input_value, config, context): count = 0 stream = await graph.astream_events( input_value, config=config, context=context, version="v3", ) async for _event in stream: count += 1 return count async def main(): config = {"configurable": {"thread_id": "learning-thread-1"}} context = Configuration.from_env() first_input = { "messages": [HumanMessage(content="你好,我叫老李。请记住我的名字。")] } event_count = await count_events(first_input, config, context) second_result = await graph.ainvoke( { "messages": [HumanMessage(content="我刚才说我叫什么?只用一句中文回答。")] }, config=config, context=context, ) snapshot = graph.get_state(config) print(f"事件数: {event_count}") print("第二轮答复: " + str(second_result["messages"][-1].content)) print(f"持久化消息数: {len(snapshot.values['messages'])}") if __name__ == "__main__": asyncio.run(main())查看示例代码: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())查看示例代码: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())