数据截至 (上游 commit a53b13c7022b)
02 · 逐节点走读
这一章按执行顺序,逐个节点看真实代码在干什么。每节先一句「它要解决的小问题」,再给关键源码引用。结构化输出的细节留到 03 章,这里只看主干。
2.1 generate_query — 话题变查询
它要解决的小问题: 用户给的是话题("向量数据库对 比"),但搜索引擎更吃具体查询词。让 LLM 先把话题改写成一条好搜的 query。
做法(graph.py:138-189):
- 用
query_writer_instructions模板填入当前日期和话题(prompts.py:9,提示词里塞了「current date」以引导搜最新信息)。 - 在函数内就地定义一个
Query工具类(带query和rationale两个字段,graph.py:161-169)。 - 交给统一的结构化输出助手
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):
- 读配置里的
search_api,用if/elif分派到四个后端之一(graph.py:213-256);后端不认识就raise ValueError。 - 各后端返回的原始结果交给
deduplicate_and_format_sources拍平成一段带Source:/URL:/content的文本(utils.py:55,详见 04 章)。 - 返回三样东西,喂给状态:
# 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):
- 用
reflection_instructions填入话题,提示 LLM「找知识缺口 + 生成能扩展理解的追问 + 聚焦技术细节/新趋势」(prompts.py:74-84)。 - 就地定义
FollowUpQuery工具类(字段follow_up_query+knowledge_gap,graph.py:352-363)。 - 同样交给统一的结构化输出助手,兜底查询同样是
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_query | research_topic | search_query | 用(结构化) |
web_research | search_query, loop_count | web_research_results, sources_gathered, loop_count | 不用 |
summarize_sources | running_summary, web_research_results[-1] | running_summary | 用(自然语言) |
reflect_on_summary | research_topic, running_summary | search_query | 用(结构化) |
finalize_summary | running_summary, sources_gathered | running_summary | 不用 |
两个「用结构化 LLM」的节点(生成查询、反思)共享同一套稳健化机制——那正是下一章的主题。→ 03-structured-output.md
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 生成查询节点 | src/ollama_deep_researcher/graph.py | generate_query |
| 搜网节点 | src/ollama_deep_researcher/graph.py | web_research |
| 滚动摘要节点 | src/ollama_deep_researcher/graph.py | summarize_sources |
| 反思节点 | src/ollama_deep_researcher/graph.py | reflect_on_summary |
| 收尾去重节点 | src/ollama_deep_researcher/graph.py | finalize_summary |
| 五段提示词 | src/ollama_deep_researcher/prompts.py | query_writer_instructions / summarizer_instructions / reflection_instructions |