跳到主要内容

02 · 逐节点走读

这一章按执行顺序,逐个节点看真实代码在干什么。每节先一句「它要解决的小问题」,再给关键源码引用。结构化输出的细节留到 03 章,这里只看主干。

2.1 generate_query — 话题变查询

它要解决的小问题: 用户给的是话题("向量数据库对比"),但搜索引擎更吃具体查询词。让 LLM 先把话题改写成一条好搜的 query。

做法(graph.py:138-189):

  1. query_writer_instructions 模板填入当前日期和话题(prompts.py:9,提示词里塞了「current date」以引导搜最新信息)。
  2. 在函数内就地定义一个 Query 工具类(带 queryrationale 两个字段,graph.py:161-169)。
  3. 交给统一的结构化输出助手 generate_search_query_with_structured_output 去实际调 LLM。

关键的兜底设计:如果模型没能吐出可用查询,就退回一个「保底查询」:

# graph.py:186 —— 真实源码
fallback_query=f"Tell me more about {state.research_topic}",

这样即便本地小模型结构化能力差,流程也不会卡死(详见 03 章)。

2.2 web_research — 去搜、格式化、累加

它要解决的小问题: 拿查询去搜、把各家搜索后端五花八门的返回抹平成同一段文本,并把来源攒起来。

做法(graph.py:192-262):

  1. 读配置里的 search_api,用 if/elif 分派到四个后端之一(graph.py:213-256);后端不认识就 raise ValueError
  2. 各后端返回的原始结果交给 deduplicate_and_format_sources 拍平成一段带 Source:/URL:/content 的文本(utils.py:55,详见 04 章)。
  3. 返回三样东西,喂给状态:
# graph.py:258-262 —— 真实源码
return {
"sources_gathered": [format_sources(search_results)], # 追加来源
"research_loop_count": state.research_loop_count + 1, # 轮数 +1
"web_research_results": [search_str], # 追加本轮资料
}

一处不一致值得记一笔: Tavily 分支写死了 max_results=1(graph.py:217),而 DuckDuckGo/SearXNG 用 max_results=3(graph.py:236/248)。也就是说选 Tavily 时每轮只取 1 条来源。这是代码里明确的取值,不是笔误说明——用 Tavily 想要更多来源就得改这里。

2.3 summarize_sources — 滚动摘要

它要解决的小问题: 不把每轮资料各写一段,而是维护一段不断长大的摘要,既连贯又省上下文。

核心在于两套 human message,取决于「是不是第一轮」(graph.py:287-297):

  • 没有现有摘要(第一轮):让 LLM 直接从 <Context> 造一段新摘要。
  • 已有摘要:把旧摘要包在 <Existing Summary>、新资料包在 <New Context>,让 LLM 把新资料并进旧摘要

配套的系统提示 summarizer_instructions 写得很细,专门规定「扩展摘要时」的动作:相关就并进对应段落、全新但相关就另起一段、不相关就跳过,并要求「最终输出必须与输入摘要不同」(prompts.py:54-62)。

注意这个节点不做结构化输出——摘要是自然语言,不需要 JSON。所以它绕过统一助手,直接建一个普通(非 json 模式)LLM 来调(graph.py:302-314)。最后按配置可选地剥掉 <think>...</think> 推理标记(graph.py:324-326,详见 03 章)。

2.4 reflect_on_summary — 找缺口、生成追问

它要解决的小问题: 这是「深度」的来源——不满足于一次搜索,而是让 LLM 审视自己写的摘要,指出还缺什么,并据此提出下一条更深的问题。

做法几乎是 generate_query 的镜像(graph.py:331-384):

  1. reflection_instructions 填入话题,提示 LLM「找知识缺口 + 生成能扩展理解的追问 + 聚焦技术细节/新趋势」(prompts.py:74-84)。
  2. 就地定义 FollowUpQuery 工具类(字段 follow_up_query + knowledge_gap,graph.py:352-363)。
  3. 同样交给统一的结构化输出助手,兜底查询同样是 Tell me more about {topic}

它的返回是 {"search_query": ...}——覆盖掉状态里的 search_query,于是下一轮 web_research 就搜这个新问题。

2.5 finalize_summary — 去重来源、拼报告

它要解决的小问题: 多轮下来 sources_gathered 里可能有重复的来源行,得去重,再和摘要拼成规整的 Markdown。

去重手法是逐行、保序去重(graph.py:401-414):把每个来源块按 \n 拆行,用一个 seen_sources 集合跳过重复行、同时用 list 维持出现顺序。最后拼成固定格式:

# graph.py:415-417 —— 真实源码
state.running_summary = (
f"## Summary\n{state.running_summary}\n\n ### Sources:\n{all_sources}"
)

这就是图的最终输出 running_summary(经 SummaryStateOutput 只暴露这一个字段)。

小结:五个节点各自的读/写

节点读状态写状态用不用 LLM
generate_queryresearch_topicsearch_query用(结构化)
web_researchsearch_query, loop_countweb_research_results, sources_gathered, loop_count不用
summarize_sourcesrunning_summary, web_research_results[-1]running_summary用(自然语言)
reflect_on_summaryresearch_topic, running_summarysearch_query用(结构化)
finalize_summaryrunning_summary, sources_gatheredrunning_summary不用

两个「用结构化 LLM」的节点(生成查询、反思)共享同一套稳健化机制——那正是下一章的主题。→ 03-structured-output.md

代码地图

主题文件符号
生成查询节点src/ollama_deep_researcher/graph.pygenerate_query
搜网节点src/ollama_deep_researcher/graph.pyweb_research
滚动摘要节点src/ollama_deep_researcher/graph.pysummarize_sources
反思节点src/ollama_deep_researcher/graph.pyreflect_on_summary
收尾去重节点src/ollama_deep_researcher/graph.pyfinalize_summary
五段提示词src/ollama_deep_researcher/prompts.pyquery_writer_instructions / summarizer_instructions / reflection_instructions