[agent] Deep Research - Graph Implementation
Link: https://github.com/ShenSeanChen/launch-DeepResearch-Backend
讲解了一些核心代码作为参考学习。
# ----------------------------------------------------------------------------- # 6.3 主 Deep Research Graph # ----------------------------------------------------------------------------- deep_researcher_builder = StateGraph( AgentState, # 整个graph的总账本 input=AgentInputState, # 入口 输入参数 config_schema=Configuration ) # 四个节点对应四次责任交接:澄清目标、形成合同、执行研究、编辑交付。 deep_researcher_builder.add_node("clarify_with_user", clarify_with_user) # 澄清判断 deep_researcher_builder.add_node("write_research_brief", write_research_brief) # 需求定稿 deep_researcher_builder.add_node("research_supervisor", supervisor_subgraph) # 研究编排与执行 deep_researcher_builder.add_node("final_report_generation", final_report_generation) # 最终成稿 # 主干由静态边串起;clarify_with_user 的 Command 只会追加动态目标,不会替换它的静态后继。 deep_researcher_builder.add_edge(START, "clarify_with_user") # 接收用户问题 deep_researcher_builder.add_edge("clarify_with_user", "write_research_brief") # 无条件静态进入研究简报 deep_researcher_builder.add_edge("write_research_brief", "research_supervisor") # 用研究合同启动编排 deep_researcher_builder.add_edge("research_supervisor", "final_report_generation") # 用研究结论启动写作 deep_researcher_builder.add_edge("final_report_generation", END) # 报告交付后结束 # 核心变量:deep_researcher 是源模块对外提供的编译后 Graph;上面的 builder 只是施工图。 deep_researcher = deep_researcher_builder.compile()
用户的口头表达 ==> 正式表达
一般,Command要负责两个事情:① 更新 state ② 指定下一个节点
async def clarify_with_user(state: AgentState, config: RunnableConfig) -> Command[Literal["write_research_brief", "__end__"]]: """Analyze user messages and ask clarifying questions if the research scope is unclear. This function determines whether the user's request needs clarification before proceeding with research. If clarification is disabled or not needed, it proceeds directly to research. Args: state: Current agent state containing user messages config: Runtime configuration with model settings and preferences Returns: Command to either end with a clarifying question or proceed to research brief """ # 第一步:把运行参数还原成业务配置;是否允许追问,本身就是工作流的一项策略。 configurable = Configuration.from_runnable_config(config) if not configurable.allow_clarification: # 关闭澄清时直接放行,让调用方可以用“零交互模式”运行同一张图。 return Command(goto="write_research_brief") # 第二步:取出完整对话,并为这一次“是否追问”的判断选择研究模型及其运行凭据。 messages = state["messages"] # 关键变量:model_config 给共享模型插座装上本节点所需的具体模型,而不是再造一个模型实例。 model_config = { "model": configurable.research_model, "max_tokens": configurable.research_model_max_tokens, # SIMPLIFIED: Direct user API key access (no more complex lookup) "api_key": configurable.user_api_key, "model_provider": getattr(configurable, 'research_model_provider', None), "tags": ["langsmith:nostream"] # LEGACY: Old complex system (commented out for future env variable use) # "api_key": get_api_key_for_model(configurable.research_model, config), } # 强制模型返回 ClarifyWithUser 结构,使后面的路由依赖字段而不是猜测自然语言。 clarification_model = ( configurable_model .with_structured_output(ClarifyWithUser) .with_retry(stop_after_attempt=configurable.max_structured_output_retries) .with_config(model_config) ) # 第三步:把“历史对话 + 当前日期”压进专用提示词,只回答是否需要澄清以及该说什么。 prompt_content = clarify_with_user_instructions.format( messages=get_buffer_string(messages), date=get_today_str() ) response = await clarification_model.ainvoke([HumanMessage(content=prompt_content)]) # 第四步:Command 同时承载动态目标和状态更新;但动态目标不会覆盖构图处已有的静态边。 if response.need_clarification: # 这里请求动态前往 END 并写回澄清问题;底部静态边仍会另外调度 write_research_brief。 return Command( goto=END, # ----> 这里其实可以引入 interrupt,再返回 START 那里开始。 update={"messages": [AIMessage(content=response.question)]} ) else: # 目标已经清楚:记录模型对任务的确认,并进入研究简报节点。 return Command( goto="write_research_brief", update={"messages": [AIMessage(content=response.verification)]} )
正式表达 ==> AI 表达
async def write_research_brief(state: AgentState, config: RunnableConfig) -> Command[Literal["research_supervisor"]]: """Transform user messages into a structured research brief and initialize supervisor. This function analyzes the user's messages and generates a focused research brief that will guide the research supervisor. It also sets up the initial supervisor context with appropriate prompts and instructions. Args: state: Current agent state containing user messages config: Runtime configuration with model settings Returns: Command to proceed to research supervisor with initialized context """ # 第一步:沿用研究模型,但把输出契约切换为 ResearchQuestion。 configurable = Configuration.from_runnable_config(config) # 关键变量:这里的配置只服务“生成研究简报”这次调用,不会改变其他节点的模型选择。 research_model_config = { "model": configurable.research_model, "max_tokens": configurable.research_model_max_tokens, # SIMPLIFIED: Direct user API key access (no more complex lookup) "api_key": configurable.user_api_key, "model_provider": getattr(configurable, 'research_model_provider', None), "tags": ["langsmith:nostream"] # LEGACY: Old complex system (commented out for future env variable use) # "api_key": get_api_key_for_model(configurable.research_model, config), } # 结构化输出把含糊对话收束成唯一的 research_brief 字段,给下游一个稳定接口。 research_model = ( configurable_model .with_structured_output(ResearchQuestion) .with_retry(stop_after_attempt=configurable.max_structured_output_retries) .with_config(research_model_config) ) # 第二步:把用户全部消息重写成聚焦、可研究的任务描述,而不是直接把聊天记录扔给主管。 prompt_content = transform_messages_into_research_topic_prompt.format( messages=get_buffer_string(state.get("messages", [])), date=get_today_str() ) response = await research_model.ainvoke([HumanMessage(content=prompt_content)]) # 第三步:主管提示词把并发数和迭代数写成明确预算,约束它如何拆解任务。 supervisor_system_prompt = lead_researcher_prompt.format( date=get_today_str(), max_concurrent_research_units=configurable.max_concurrent_research_units, max_researcher_iterations=configurable.max_researcher_iterations ) # 用 override 建立一段干净的主管上下文:主管只看自己的制度和研究合同,不继承闲聊噪声。 return Command( goto="research_supervisor", update={ "research_brief": response.research_brief, "supervisor_messages": { "type": "override", "value": [ SystemMessage(content=supervisor_system_prompt), HumanMessage(content=response.research_brief) ] } } )
Pydantic 在这里你可以先简单理解成:“给数据定格式 + 检查数据格式”的工具。
举一个另外一个较为复杂的例子,如下:
class FraudReview(BaseModel): is_suspicious: bool risk_score: float reason: str missing_evidence: list[str] requires_human_review: bool FraudReview( is_suspicious=True, risk_score=0.91, reason="短时间内出现多个国家的异常支付", missing_evidence=[ "设备指纹", "客户旅行记录" ], requires_human_review=True )
Supervisor 大管家 - 子图
这里重点关注supervisor 以及 supervisor_tools之间的羁绊。
# ----------------------------------------------------------------------------- # 6.2 Supervisor 子图 # ----------------------------------------------------------------------------- # # START # ↓ # supervisor ←────────────┐ # ↓ │ # supervisor_tools ───────┘ 资料不足时继续规划和委派 # ↓ # END 资料足够、无工具调用、超过上限或执行异常 # # supervisor_tools 内部会通过 researcher_subgraph.ainvoke() 并行执行研究任务。 # 核心变量:supervisor_builder 只保存项目经理自己的循环状态,不承载单个研究员的过程消息。 supervisor_builder = StateGraph(SupervisorState, config_schema=Configuration) # 主管负责出决策,工具节点负责兑现决策;动态回环和结束仍由 Command 表达。 supervisor_builder.add_node("supervisor", supervisor) supervisor_builder.add_node("supervisor_tools", supervisor_tools) # Supervisor 每次进入子图都先从“读研究合同并做决定”开始。 supervisor_builder.add_edge(START, "supervisor") # 核心变量:supervisor_subgraph 把整套编排循环封成主流程眼中的一个研究执行节点。 supervisor_subgraph = supervisor_builder.compile()
这里只是分析 该调用哪个 Tool,但不是执行的地点。
# 角色定位:研究项目经理的大脑——它不亲自搜索,只决定下一步该思考、派谁研究,还是宣布收工。 async def supervisor(state: SupervisorState, config: RunnableConfig) -> Command[Literal["supervisor_tools"]]: """Lead research supervisor that plans research strategy and delegates to researchers. The supervisor analyzes the research brief and decides how to break down the research into manageable tasks. It can use think_tool for strategic planning, ConductResearch to delegate tasks to sub-researchers, or ResearchComplete when satisfied with findings. Args: state: Current supervisor state with messages and research context config: Runtime configuration with model settings Returns: Command to proceed to supervisor_tools for tool execution """ # 第一步:读取主管这一次决策要使用的模型配置。 configurable = Configuration.from_runnable_config(config) # 关键变量:同一个研究模型在这里扮演“主管”,角色差异来自提示词、上下文和工具权限。 research_model_config = { "model": configurable.research_model, "max_tokens": configurable.research_model_max_tokens, # SIMPLIFIED: Direct user API key access (no more complex lookup) "api_key": configurable.user_api_key, "model_provider": getattr(configurable, 'research_model_provider', None), "tags": ["langsmith:nostream"] # LEGACY: Old complex system (commented out for future env variable use) # "api_key": get_api_key_for_model(configurable.research_model, config), } # 这三个工具就是主管全部的动作词汇:反思、委派、结束;故意不授予搜索工具以维持职责边界。 lead_researcher_tools = [ConductResearch, ResearchComplete, think_tool] # bind_tools 把“可以做什么”交给模型,with_retry 则为模型调用异常提供有限重试。 research_model = ( configurable_model .bind_tools(lead_researcher_tools) .with_retry(stop_after_attempt=configurable.max_structured_output_retries) .with_config(research_model_config) ) # 第二步:主管的对话记忆保存在 supervisor_messages,它会在每轮工具结果回来后继续累积。 supervisor_messages = state.get("supervisor_messages", []) response = await research_model.ainvoke(supervisor_messages) # 第三步:这里只产出“决策意图”;真正执行工具留给 supervisor_tools,并顺手推进熔断计数器。 return Command( goto="supervisor_tools", update={ "supervisor_messages": [response], # 注意,这里是 append "research_iterations": state.get("research_iterations", 0) + 1 } )
supervisor_messages 的实际情况:
supervisor_messages = [ SystemMessage(...), HumanMessage(research_brief), # 刚刚 Supervisor LLM 产生的 AIMessage( tool_calls=[ { "name": "think_tool", "args": {"reflection": "..."}, "id": "toolu_01W..." } ] ) ] research_iterations = 1
得到 think_tool的返回值后,states 的 value变化:
{ "research_brief": "正式研究任务...", "supervisor_messages": [ SystemMessage("Supervisor 工作规则"), HumanMessage("正式 research brief"), AIMessage( tool_calls=[ { "name": "think_tool", "args": { "reflection": "我的分析和下一步计划..." }, "id": "toolu_01W..." } ] ), # >>>> 新增这一条 <<<< ToolMessage( content="Reflection recorded: 我的分析和下一步计划...", name="think_tool", tool_call_id="toolu_01W..." ) ], "research_iterations": 1, "notes": [], "raw_notes": [] }
这里接收到命令后,会执行。但只有一个 ConductResearch会去执行,也就是 invoke sub graph。(也被叫做 tool,多少有点歧义)
async def supervisor_tools( state: SupervisorState, config: RunnableConfig ) -> Command[Literal["supervisor", "__end__"]]: configurable = Configuration.from_runnable_config(config) supervisor_messages = state.get("supervisor_messages", []) --------> AIMessage就是作为response在上一个节点的return附近内嵌到了supervisor_messages里面。 research_iterations = state.get("research_iterations", 0) latest_message = supervisor_messages[-1] tool_calls = latest_message.tool_calls or [] # 1. 先判断这一轮是否应该结束 if research_iterations > configurable.max_researcher_iterations: return Command( goto=END, update={ "notes": get_notes_from_tool_calls(supervisor_messages), "research_brief": state.get("research_brief", "") } ) if not tool_calls: return Command( goto=END, update={ "notes": get_notes_from_tool_calls(supervisor_messages), "research_brief": state.get("research_brief", "") } ) if any(call["name"] == "ResearchComplete" for call in tool_calls): return Command( goto=END, update={ "notes": get_notes_from_tool_calls(supervisor_messages), "research_brief": state.get("research_brief", "") } ) # 2. 把本轮动作分清楚 think_tool_call = None conduct_research_calls = [] for tool_call in tool_calls: if tool_call["name"] == "think_tool": think_tool_call = tool_call elif tool_call["name"] == "ConductResearch": conduct_research_calls.append(tool_call) # >>>> 独立的研究并发执行 tool_reply_messages = [] state_update = {} # 3.1 如果这一轮是 Think,就记录 reflection if think_tool_call is not None: reflection = think_tool_call["args"]["reflection"] tool_reply_messages.append( ToolMessage( content=f"Reflection recorded: {reflection}", name="think_tool", tool_call_id=think_tool_call["id"] ) ) # 3.2. 如果这一轮有 Research,就启动 Researcher if conduct_research_calls: research_messages, raw_notes = await run_research_tasks( ---------> conduct_research_calls, configurable, config ) tool_reply_messages.extend(research_messages) if raw_notes: state_update["raw_notes"] = [raw_notes] # 4. 把工具结果写回去,再交给 Supervisor 判断下一步 state_update["supervisor_messages"] = tool_reply_messages return Command( goto="supervisor", update=state_update )
并行调度各个独立的 researcher。
async def run_research_tasks( conduct_research_calls, configurable, config ): """ 把 Supervisor 分下来的多个研究任务真正跑起来。 输入: - conduct_research_calls:Supervisor 这一轮要做的所有研究任务 - configurable:并发数量等运行参数 - config:传给每个 Researcher 的运行配置 输出: - tool_reply_messages:每个研究任务返回给 Supervisor 的结果 - raw_notes:Researcher 研究过程中留下的原始资料 """ # 一次最多只能同时启动这么多个 Researcher。 # 例如最多允许 2 个,但 Supervisor 一次给了 3 个任务, # 那么前 2 个真的执行,第 3 个先不执行。 max_concurrent = configurable.max_concurrent_research_units allowed_calls = conduct_research_calls[:max_concurrent] overflow_calls = conduct_research_calls[max_concurrent:] # 为每一个允许执行的研究任务启动一个独立 Researcher。 # # 比如 Supervisor 给了两个任务: # # A:研究 attachment style # B:研究 conflict resolution # # 这里就会启动两次同一个 researcher_subgraph, # 但每一次都有自己独立的 research_topic 和 researcher_messages。 research_tasks = [] for tool_call in allowed_calls: research_topic = tool_call["args"]["research_topic"] task = researcher_subgraph.ainvoke( { # Researcher 一启动,就把自己的研究题目当成第一条任务消息。 "researcher_messages": [ HumanMessage(content=research_topic) ], # 同一份题目再单独保存一份, # 后面的 Researcher 节点可以直接从 state 里取。 "research_topic": research_topic }, config ) research_tasks.append(task) # 前面只是把多个 Researcher 任务准备好了。 # 到这里才真正一起等待它们执行完成。 # # 如果有两个任务: # # Researcher A ──┐ # ├── 同时运行 # Researcher B ──┘ # # 两个都结束以后,research_results 才会拿到完整结果。 research_results = await asyncio.gather(*research_tasks) # Researcher 返回的是自己的子图结果。 # Supervisor 不直接接收这整个 State, # 所以这里把每个结果重新包装成 ToolMessage。 tool_reply_messages = [] for result, tool_call in zip(research_results, allowed_calls): research_summary = result.get( "compressed_research", "Error synthesizing research report" ) tool_reply_messages.append( ToolMessage( content=research_summary, # 告诉 Supervisor: # 这是之前 ConductResearch 的执行结果。 name="ConductResearch", # 用原来的 tool_call_id 对号入座。 # Supervisor 才知道这个结果对应之前哪一个研究任务。 tool_call_id=tool_call["id"] ) ) # 超过并发上限的任务虽然没有真正执行, # 但也必须给原来的 tool call 一个明确回执。 # # 否则从消息协议上看, # Supervisor 会以为这个 ConductResearch 还一直没有结果。 for tool_call in overflow_calls: tool_reply_messages.append( ToolMessage( content=( "Error: maximum concurrent " "research units exceeded." ), name="ConductResearch", tool_call_id=tool_call["id"] ) ) # compressed_research 是给 Supervisor 快速看的结论。 # # raw_notes 则是 Researcher 搜索、分析过程中留下的原始资料, # 后面还要保留下来做证据追溯,所以这里单独收集。 raw_notes_parts = [] for result in research_results: raw_notes_parts.extend( result.get("raw_notes", []) ) raw_notes = "\n".join(raw_notes_parts) # 返回两样东西: # # 1. tool_reply_messages # → 直接放回 supervisor_messages, # 让 Supervisor 下一轮看到各个 Researcher 的结果。 # # 2. raw_notes # → 保存研究底稿,供后面的最终报告和证据追溯使用。 return tool_reply_messages, raw_notes
Researcher - 子图
# ----------------------------------------------------------------------------- # 6.1 Researcher 子图 # ----------------------------------------------------------------------------- # # START # ↓ # researcher ←─────────────┐ # ↓ │ # researcher_tools ────────┘ 资料不足时继续调用工具 # ↓ # compress_research # ↓ # END # # researcher 和 researcher_tools 使用 Command(goto=...) 动态跳转,所以这里只 # 声明固定入口 START → researcher,以及固定出口 compress_research → END。 # 核心变量:researcher_builder 是“一名研究员如何工作”的蓝图,状态边界限定为 ResearcherState。 researcher_builder = StateGraph( ResearcherState, output=ResearcherOutputState, config_schema=Configuration ) # 三个节点依次承担思考、行动、压缩;循环方向由前两个节点返回的 Command 决定。 researcher_builder.add_node("researcher", researcher) researcher_builder.add_node("researcher_tools", researcher_tools) researcher_builder.add_node("compress_research", compress_research) # 固定边只声明永远成立的入口和出口,条件性流转留在节点旁边阅读更直观。 researcher_builder.add_edge(START, "researcher") researcher_builder.add_edge("compress_research", END) # 核心变量:编译后的 researcher_subgraph 是可反复、可并行调用的“标准研究员执行体”。 researcher_subgraph = researcher_builder.compile()
# ============================================================================= # 第四部分:Researcher 节点 # ============================================================================= # # 一个 Supervisor 可以并行启动多个 Researcher 子图;每个 Researcher 只负责 # 一个具体子问题,并在 researcher ↔ researcher_tools 之间执行 ReAct 循环。 # 角色定位:一线研究员的大脑——围绕一个子问题阅读已有证据,并决定下一步该调用哪个工具。 async def researcher(state: ResearcherState, config: RunnableConfig) -> Command[Literal["researcher_tools"]]: """Individual researcher that conducts focused research on specific topics. This researcher is given a specific research topic by the supervisor and uses available tools (search, think_tool, MCP tools) to gather comprehensive information. It can use think_tool for strategic planning between searches. Args: state: Current researcher state with messages and topic context config: Runtime configuration with model settings and tool availability Returns: Command to proceed to researcher_tools for tool execution """
# # 第一步:恢复这名研究员的运行配置和局部对话;它看不到其他研究员的过程噪声。(为何提到“恢复”) #
configurable = Configuration.from_runnable_config(config) researcher_messages = state.get("researcher_messages", []) # 工具集在运行时装配,搜索服务和 MCP 能力可以变化,而研究员节点本身保持不变。 tools = await get_all_tools(config) # ---- ---- ---- ----> if len(tools) == 0: # 没有工具就不可能“研究”;尽早失败比让模型凭空编造资料更诚实。 raise ValueError( "No tools found to conduct research: Please configure either your " "search API or add MCP tools to your configuration." )
# # 第二步:给共享模型插座装上研究模型,并授予本次实际可用的工具。
#
# 关键变量:research_model_config 决定“用谁思考”,tools 决定“能对外做什么”。 research_model_config = { "model": configurable.research_model, "max_tokens": configurable.research_model_max_tokens, # SIMPLIFIED: Direct user API key access (no more complex lookup) "api_key": configurable.user_api_key, "model_provider": getattr(configurable, 'research_model_provider', None), "tags": ["langsmith:nostream"] # LEGACY: Old complex system (commented out for future env variable use) # "api_key": get_api_key_for_model(configurable.research_model, config), } # 系统提示词补入 MCP 使用说明和当前日期,为工具选择提供环境语境。 researcher_prompt = research_system_prompt.format( mcp_prompt=configurable.mcp_prompt or "", date=get_today_str() ) # 绑定工具后,模型的响应既可能是结论,也可能是一组等待执行的 tool_calls。 research_model = ( configurable_model .bind_tools(tools) .with_retry(stop_after_attempt=configurable.max_structured_output_retries) .with_config(research_model_config) )
# # 第三步:每轮都把稳定的研究员规则放在最前,再接上不断增长的 ReAct 对话。 #
messages = [SystemMessage(content=researcher_prompt)] + researcher_messages response = await research_model.ainvoke(messages)
# # 第四步:把模型意图交给 researcher_tools;计数器保证这个思考—行动循环最终会停。 #
return Command( goto="researcher_tools", update={ "researcher_messages": [response], "tool_call_iterations": state.get("tool_call_iterations", 0) + 1 } )
大模型依然需要调用工具来完整事情。
async def get_all_tools(config: RunnableConfig): """ 组装 Researcher 这一轮真正可以使用的全部工具。 可以把它理解成:给 Researcher 准备“工具箱”。 工具箱由三部分组成: ① 固定工具 - ResearchComplete:告诉系统“这个子研究任务已经完成” - think_tool:让 Researcher 做一次反思和下一步规划 ② 搜索工具 - 根据 config.search_api 决定使用哪一种搜索能力 - 例如 OpenAI Web Search / Anthropic Web Search / Tavily - 如果配置为 NONE,则不加入搜索工具 ③ MCP 工具 - 如果配置了 MCP Server,就从 MCP Server 动态加载允许使用的工具 - 例如企业数据库、内部知识库、GitHub、业务系统等 - 同时避免 MCP 工具名称与前面的工具发生冲突 最终返回: [ ResearchComplete, think_tool, search_tool(s), mcp_tool(s) ] 注意: 这里不是执行工具。 这里只负责“决定 Researcher 这次有哪些工具可以用”。 真正什么时候调用哪个工具, 是后面的 Researcher LLM 根据任务自己决定的。 """ # ========================================================================= # 1. 先放入 Researcher 永远拥有的两个基础工具 # ========================================================================= tools = [ tool(ResearchComplete), # 当前子研究任务完成 think_tool # 反思已有结果,并决定下一步 ] # ========================================================================= # 2. 根据运行配置,加入搜索工具 # ========================================================================= # 把原始 RunnableConfig 转成项目自己的 Configuration, # 方便读取 search_api 等配置。 configurable = Configuration.from_runnable_config(config) # 例如: # # OPENAI # ANTHROPIC # TAVILY # NONE # search_api = SearchAPI( get_config_value(configurable.search_api) ) # 根据 search_api 得到真正的搜索工具。 # # 例如: # TAVILY -> tavily_search # OPENAI -> OpenAI native web search # NONE -> [] # search_tools = await get_search_tool(search_api) # 把搜索工具加入 Researcher 工具箱。 tools.extend(search_tools) # ========================================================================= # 3. 记录目前已经存在的工具名 # 目的是 防止后面加载 MCP 工具时发生重名 # ========================================================================= existing_tool_names = { tool.name if hasattr(tool, "name") else tool.get("name", "web_search") for tool in tools } # ========================================================================= # 4. 如果配置了 MCP,再动态加载 MCP Server 提供的工具 # ========================================================================= # load_mcp_tools() 会: # # ① 检查是否配置 MCP # ② 必要时处理认证 # ③ 连接 MCP Server # ④ 获取 MCP Server 暴露的工具 # ⑤ 只保留本次配置允许使用的工具 # ⑥ 跳过和现有工具重名的工具 # mcp_tools = await load_mcp_tools( config, existing_tool_names ) # 把 MCP 工具加入 Researcher 工具箱。 tools.extend(mcp_tools) # ========================================================================= # 5. 返回最终工具箱,后面会 bind 给 Researcher LLM # ========================================================================= return tools
# 角色定位:ReAct 循环的交通警察——执行模型动作,并判定下一站是继续研究还是压缩交付。 async def researcher_tools(state: ResearcherState, config: RunnableConfig) -> Command[Literal["researcher", "compress_research"]]: """Execute tools called by the researcher, including search tools and strategic thinking. This function handles various types of researcher tool calls: 1. think_tool - Strategic reflection that continues the research conversation 2. Search tools (tavily_search, web_search) - Information gathering 3. MCP tools - External tool integrations 4. ResearchComplete - Signals completion of individual research task Args: state: Current researcher state with messages and iteration count config: Runtime configuration with research limits and tool settings Returns: Command to either continue research loop or proceed to compression """ # 第一步:只检查研究员最后一条消息,因为工具调用代表它对“下一步”的最新决定。 configurable = Configuration.from_runnable_config(config) researcher_messages = state.get("researcher_messages", []) most_recent_message = researcher_messages[-1] # 原生网页搜索可能不出现在标准 tool_calls 中,所以必须单独识别,避免过早结束循环。 has_tool_calls = bool(most_recent_message.tool_calls) has_native_search = ( openai_websearch_called(most_recent_message) or anthropic_websearch_called(most_recent_message) ) if not has_tool_calls and not has_native_search: # 没有任何外部动作,视为研究员已经停止探索,直接进入成果压缩。 return Command(goto="compress_research") # 第二步:重新取得运行时工具,并建立“模型给出的名字 → 真正工具对象”的分发表。 tools = await get_all_tools(config) # 关键变量:tools_by_name 是语言模型的符号世界与 Python 可执行对象之间的桥梁。 tools_by_name = { tool.name if hasattr(tool, "name") else tool.get("name", "web_search"): tool for tool in tools } # 同一轮的多个调用按可并行任务处理:先组装协程再并发执行,减少串行搜索的等待时间。 tool_calls = most_recent_message.tool_calls tool_execution_tasks = [ execute_tool_safely(tools_by_name[tool_call["name"]], tool_call["args"], config) for tool_call in tool_calls ] observations = await asyncio.gather(*tool_execution_tasks) # 关键变量:tool_outputs 用 tool_call_id 把每个观察绑回原请求,闭合工具调用协议。 tool_outputs = [ ToolMessage( content=observation, name=tool_call["name"], tool_call_id=tool_call["id"] ) for observation, tool_call in zip(observations, tool_calls) ] # 第三步:即使准备收尾,也先执行本轮工具并保留结果,避免浪费最后一次有效观察。 exceeded_iterations = state.get("tool_call_iterations", 0) >= configurable.max_react_tool_calls research_complete_called = any( tool_call["name"] == "ResearchComplete" for tool_call in most_recent_message.tool_calls ) if exceeded_iterations or research_complete_called: # 达到硬上限或研究员主动完成:带着本轮结果进入压缩,不再回到思考节点。 return Command( goto="compress_research", update={"researcher_messages": tool_outputs} ) # 尚未满足退出条件:把观察送回 researcher,开始下一轮“观察后再思考”。 return Command( goto="researcher", update={"researcher_messages": tool_outputs} )
这里是真正执行的地方。
# 角色定位:工具调用的保险丝——把单个外部工具故障降级成可读观察,避免一处失败炸掉整批研究。 async def execute_tool_safely(tool, args, config): """Safely execute a tool with error handling.""" try: # 保留统一的异步调用入口,让搜索工具、MCP 工具都遵循同一种执行方式。 return await tool.ainvoke(args, config) except Exception as e: # 错误也作为观察返回,后续模型可以据此换工具或基于已有证据收尾。 return f"Error executing tool: {str(e)}"
得到结果后,如果认为满意,则总结压缩。
# 角色定位:信息压缩器——把一名研究员冗长的探索轨迹炼成主管可比较的结论,同时保留证据底稿。 async def compress_research(state: ResearcherState, config: RunnableConfig): """Compress and synthesize research findings into a concise, structured summary. This function takes all the research findings, tool outputs, and AI messages from a researcher's work and distills them into a clean, comprehensive summary while preserving all important information and findings. Args: state: Current researcher state with accumulated research messages config: Runtime configuration with compression model settings Returns: Dictionary containing compressed research summary and raw notes """ # 第一步:压缩是独立认知任务,因此允许使用与搜索阶段不同、可能更擅长长文归纳的模型。 configurable = Configuration.from_runnable_config(config) # 关键变量:synthesizer_model 只负责“整理已有材料”,不再绑定任何搜索工具。 synthesizer_model = configurable_model.with_config({ "model": configurable.compression_model, "max_tokens": configurable.compression_model_max_tokens, # SIMPLIFIED: Direct user API key access (no more complex lookup) "api_key": configurable.user_api_key, "model_provider": getattr(configurable, 'compression_model_provider', None), "tags": ["langsmith:nostream"] # LEGACY: Old complex system (commented out for future env variable use) # "api_key": get_api_key_for_model(configurable.compression_model, config), }) # 第二步:取出这名研究员的完整局部轨迹,作为压缩阶段唯一的信息来源。 researcher_messages = state.get("researcher_messages", []) # 追加一条明确的人类指令,把模型从“继续找资料”切换到“停止探索、开始归纳”。 researcher_messages.append(HumanMessage(content=compress_research_simple_human_message)) # 第三步:有限重试吸收偶发模型失败;上限则避免压缩节点自身变成死循环。 synthesis_attempts = 0 max_attempts = 3 while synthesis_attempts < max_attempts: try: # 压缩提示词定义输出职责,再叠加刚才积累的全部研究消息。 compression_prompt = compress_research_system_prompt.format(date=get_today_str()) messages = [SystemMessage(content=compression_prompt)] + researcher_messages # 模型在这里第一次把“过程型对话”变成“结果型文本”。 response = await synthesizer_model.ainvoke(messages) # raw_notes 故意与 compressed_research 并存:前者可追溯,后者便于主管快速消费。 raw_notes_content = "\n".join([ str(message.content) for message in filter_messages(researcher_messages, include_types=["tool", "ai"]) ]) # 子图输出同时交付摘要和底稿,兼顾后续写作效率与证据完整性。 return { "compressed_research": str(response.content), "raw_notes": [raw_notes_content] } except Exception as e: synthesis_attempts += 1 # 超出上下文时从最近一条 AI 消息处截掉较新的尾部,只保留更早的对话前缀来缩短输入。 # 注意:这里忠实保留源文件写法,用 research_model 而非实际调用的 compression_model 判别异常。 if is_token_limit_exceeded(e, configurable.research_model): researcher_messages = remove_up_to_last_ai_message(researcher_messages) continue # 其他异常不改材料直接重试,交给有限次数兜底,而不让异常穿透整张图。 continue # 第四步:摘要失败仍把底稿留在 raw_notes 输出中供追溯;Supervisor 收到的工具回执仍是错误摘要。 raw_notes_content = "\n".join([ str(message.content) for message in filter_messages(researcher_messages, include_types=["tool", "ai"]) ]) return { "compressed_research": "Error synthesizing research report: Maximum retries exceeded", "raw_notes": [raw_notes_content] }

浙公网安备 33010602011771号