数据截至 (上游 commit a53b13c7022b)
01 · 状态与图:骨架长什么样
这一章讲整个循环的骨架:贯穿全程的状态是怎么设计的,五个节点怎么连成一张会自我回环的图,循环靠什么变量、在哪里停。读完你就能在脑子里跑一遍这张图。
1.1 共享状态:一本被反复改写的笔记本
LangGraph 的每个节点都是纯函数:读状态 → 返回一个「状态更新 」字典。所以先看状态。整个项目的状态就一个 dataclass,SummaryState:
# state.py:7 —— 真实源码(略去注释)
@dataclass(kw_only=True)
class SummaryState:
research_topic: str = field(default=None)
search_query: str = field(default=None)
web_research_results: Annotated[list, operator.add] = field(default_factory=list)
sources_gathered: Annotated[list, operator.add] = field(default_factory=list)
research_loop_count: int = field(default=0)
running_summary: str = field(default=None)
各字段的角色:
| 字段 | 作用 | 谁写它 |
|---|---|---|
research_topic | 用户给的话题,全程只读 | 输入 |
search_query | 当前这一轮要搜的查询 | generate_query / reflect_on_summary |
web_research_results | 每轮格式化后的搜索文本(累加) | web_research |
sources_gathered | 每轮的来源清单(累加) | web_research |
research_loop_count | 已经搜了几轮 | web_research |
running_summary | 那本「不断被改写的笔记本」,最终即报告 | summarize_sources / finalize_summary |
关键设计:两个字段带 Annotated[list, operator.add]。 这是 LangGraph 的 reducer(归并器) 机制——当一个节点返回 {"web_research_results": [x]} 时,LangGraph 不会覆盖旧值,而是用 operator.add 把新列表拼到旧列表后面(state.py:10-11)。所以每轮搜到的东西是累积的,而不是互相覆盖。
而 running_summary 是普通 str 字段,没有 reducer——所以 summarize_sources 每次返回的新摘要是整体替换旧摘要。这正是我们想要的:笔记本被改写(replace),来源被追加(append)。
1.2 输入/输出裁 剪:对外只露两个字段
虽然内部状态有 6 个字段,但对图的调用方只暴露一个入、一个出:
# state.py:16-23 —— 真实源码
@dataclass(kw_only=True)
class SummaryStateInput:
research_topic: str = field(default=None) # 只需要给话题
@dataclass(kw_only=True)
class SummaryStateOutput:
running_summary: str = field(default=None) # 只拿回报告
这两个 schema 在 graph.py:445-450 建图时通过 input= / output= 传给 StateGraph。好处:调用方不必了解 research_loop_count、sources_gathered 这些内部机械,接口干净。
1.3 把节点连成图
建图代码集中在 graph.py:444-465,读起来几乎就是那张全景图的文字版:
# graph.py:445-463 —— 真实源码(节选)
builder = StateGraph(
SummaryState, input=SummaryStateInput,
output=SummaryStateOutput, config_schema=Configuration,
)
builder.add_edge(START, "generate_query")
builder.add_edge("generate_query", "web_research")
builder.add_edge("web_research", "summarize_sources")
builder.add_edge("summarize_sources", "reflect_on_summary")
builder.add_conditional_edges("reflect_on_summary", route_research) # ← 回环在这
builder.add_edge("finalize_summary", END)
graph = builder.compile()
注意有两种边:
add_edge(A, B):无条件边,A 走完必定去 B。前四步是一条直线。add_conditional_edges("reflect_on_summary", route_research):条件边,reflect走完后调用route_research函数,由它返回的字符串决定去哪。这就是「再来一轮 vs 收尾」的岔路口。
graph 这个编译好的对象,正是 langgraph.json:4 里注册给 LangGraph Studio 的入口("ollama_deep_researcher": "./src/ollama_deep_researcher/graph.py:graph")。
1.4 循环靠什么转、在哪停
岔路口的逻辑非常短:
# graph.py:421-441 —— 真实源码(节选)
def route_research(state, config) -> Literal["finalize_summary", "web_research"]:
configurable = Configuration.from_runnable_config(config)
if state.research_loop_count <= configurable.max_web_research_loops:
return "web_research" # 回到搜网,带着 reflect 生成的新 query
else:
return "finalize_summary" # 够了,收尾
计数器 research_loop_count 由 web_research 每次 +1(graph.py:260)。这里有个容易数错、值得 点明的细节:判断用的是 <=(小于等于),而计数在每次搜网后才递增。把默认 max_web_research_loops = 3(configuration.py:19-23)代进去逐拍推演:
| 时刻 | research_loop_count | route 判断 | 结果 |
|---|---|---|---|
| 第 1 次 web_research 后 | 1 | 1 ≤ 3 真 | 再搜 |
| 第 2 次后 | 2 | 2 ≤ 3 真 | 再搜 |
| 第 3 次后 | 3 | 3 ≤ 3 真 | 再搜 |
| 第 4 次后 | 4 | 4 ≤ 3 假 | 收尾 |
所以 max_web_research_loops=3 实际会执行 4 次搜索(不是 3 次)。这是 <= 加「先搜后计数」共同造成的,阅读或改这段时要留意。
1.5 一次完整回合的数据流
把状态和图合起来,一轮循环里数据这样流动:
search_query ──▶ [web_research] ──▶ 追加 web_research_results[]
└─▶ 追加 sources_gathered[]
└─▶ research_loop_count += 1
│
web_research_results[-1] ──▶ [summarize] ──▶ 替换 running_summary
│
running_summary ──▶ [reflect] ──▶ 覆盖 search_query(新的追问)
│
[route] ──▶ 回到 web_research 或去 finalize
注意 summarize_sources 只吃最新一轮的资料(state.web_research_results[-1],graph.py:284),不是每次重读全部——因为「历史」已经沉淀进 running_summary 那本笔记本里了。这就是「滚动摘要」省上下文的关键。
下一章我们钻进每个节点,看它们各自的真实实现。→ 02-nodes-walkthrough.md
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 共享状态 | src/ollama_deep_researcher/state.py | SummaryState |
| 累加字段 reducer | src/ollama_deep_researcher/state.py | Annotated[list, operator.add] |
| 输入/输出 schema | src/ollama_deep_researcher/state.py | SummaryStateInput / SummaryStateOutput |
| 建图与边 | src/ollama_deep_researcher/graph.py | builder / add_conditional_edges |
| 循环判停 | src/ollama_deep_researcher/graph.py | route_research |
| Studio 入口注册 | langgraph.json | graphs.ollama_deep_researcher |