研究引擎主线与策略骨架(默认 source-based)
30 秒导读: Local Deep Research(LDR)把"一个问题 → 一份带引用的报告"这件事,拆成一条统一契约:每种研究"策略"都实现同一个方法
analyze_topic(query)。本章讲清这条主线——协调器AdvancedSearchSystem如何按名字选出策略、给它套上安全网、再把问题交给它;并逐行精读默认策略 source-based 的完整循环。搜索引擎内部机制在 02,智能体策略在 03,引用与报告在 04。
1. 这节讲什么(一句话主线)
研究引擎 = 一个协调器 + 一组可互换的策略;所有策略共享同一个契约 analyze_topic(query) -> dict。
把它想成餐厅:
- 协调器(
AdvancedSearchSystem)是领班——不亲自做菜,只负责按你点的名字(strategy_name)叫来对应的厨师,把食材(LLM、搜索引擎、设置快照)交给他,并在上菜前做食品安全检查(egress 安全网)。 - 策略(source-based / langgraph-agent / …)是厨师——每个厨师做法不同,但交付接口一样:你给一句问题,他还你一份
dict(findings、合成正文、引用清单)。
因为契约统一,换策略对上层几乎零成本——这就是本章要让你记住的那句话:"策略 = 一套 analyze_topic 流水线"。
2. 顶层全景(它大概怎么转)
一次研究从"一句问题"到"带引用的合成结果",走这条链:
调用方(Web worker / CLI / 程序化 API)
│ new AdvancedSearchSystem(llm, search, strategy_name="source-based", ...)
▼
┌─────────────────────────────────────────────────────────┐
│ AdvancedSearchSystem (协调器 / 领班) │
│ __init__ : │
│ · _ensure_snapshot_username 给快照塞 _username │
│ · create_strategy(...) 工厂按名字造厨师 │
│ analyze_topic(query) : │
│ · _arm_egress_backstop 上菜前武装安全网 │
│ · _perform_search ──► strategy.analyze_topic(query) │
└───────────────────────────────┬─────────────────────────┘
│ 统一契约
▼
┌─────────────────────────────────────────────────────────┐
│ SourceBasedSearchStrategy (默认厨师) │
│ 迭代循环: 生成问题 → 并行搜索 → 累积结果 │
│ 收尾: 跨引擎过滤 → 引用偏移合成 │
│ 共享真源: all_links_of_system(引用的唯一权威清单) │
└─────────────────────────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
AdvancedSearchSystem | 协调器:选策略、注入快照、武装安全网、转调策略 | search_system.py:53 |
create_strategy | 工厂:按 strategy_name(含别名/降级)造出策略实例 | search_system_factory.py:30 |
AVAILABLE_STRATEGIES | UI 可见的策略清单(单一真源) | constants.py:123 |
BaseSearchStrategy | 策略抽象基类:进度回调、终止检查、错误响应、引用格式化 | advanced_search_system/strategies/base_strategy.py:14 |
SourceBasedSearchStrategy | 默认策略:迭代搜索 + 跨引擎过滤 + 合成 | advanced_search_system/strategies/source_based_strategy.py:22 |
run_parallel_searches | 把一批问题并发跑,每个 worker 拿到独立 Flask 上下文 | advanced_search_system/parallel_search.py:45 |
StandardQuestionGenerator | 让 LLM 从当前知识产出下一批搜索问题 | advanced_search_system/questions/standard_question.py:13 |
FindingsRepository | 暂存文档/问题,把 findings 格式化成报告文本 | advanced_search_system/findings/repository.py:30 |
3. 协调器 AdvancedSearchSystem:它只做三件事
协调器不含任何检索逻辑。它的价值在于把"选策略、准备环境、兜底安全"这些横切关注点收在一处。
3.1 构造期:选策略 + 修快照
默认策略就是 source-based(strategy_name: str = "source-based",search_system.py:62)。构造函数做两件关键事:
① 给设置快照补 _username。 settings_snapshot 是一份冻结的设置字典(整个运行期不再变,细节见 05)。用户身份原本存在 system.user 下,但某些快照驱动的消费者只认顶层 _username 键——尤其是 langgraph-agent 每次工具调用都会用这份快照重新造一个搜索引擎,注册用户的文档集合时要用户名。于是构造期用一个纯函数补上:
# search_system.py:21 _ensure_snapshot_username(settings_snapshot, username)
# 无 username / 非 dict / 已有 _username → 原样返回(绝不覆盖显式值)
# 否则返回一份浅拷贝,加上 _username(不改原对象)
if username and isinstance(settings_snapshot, dict) and not settings_snapshot.get("_username"):
return {**settings_snapshot, "_username": username}
return settings_snapshot
这段的用意:在"跑策略"这条最窄的公共入口修一次,Web 运行和程序化 API 就一起修好了(search_system.py:130)。
② 经工厂造策略。 大多数策略走 create_strategy(...)(search_system.py:225);只有 follow-up 系列因为要包一个"委托策略",走单独分支(search_system.py:188-221)。构造好后把 all_links_of_system(空列表)和一堆策略专属参数一并注入——注意 all_links_of_system 是按引用传进去的同一个列表对象,这是后面引用合成的关键(见 §5.5)。
3.2 运行期:analyze_topic 只是给策略套了层安全网
协调器自己的 analyze_topic 很薄——它不做检索,只在调策略前后加一道 egress(出站)安全网,真正干活的是 _perform_search:
analyze_topic(query, ...) # search_system.py:305
│
├─ 生成 search_id(缺省则 uuid4)
├─ _armed = _arm_egress_backstop() # :336 武装安全网(若无人先武装)
│ try:
├───────── return _perform_search(...) # :339 真正干活
│ finally:
└─ if _armed: clear_active_context() # :347 只清自己武装的那层
egress 安全网是什么、为什么在这。 LDR 有一套出站管制:研究过程会往外发很多请求(搜索引擎、抓网页),安全层要拦住不该出的流量(完整机制见 06)。主防线是代码里显式的策略执行点(PEP);这里的 _arm_egress_backstop 是第二道防线——基于 PEP-578 审计钩子的兜底:
- Web worker 在调协调器之前已经武装好了,
get_active_context()非空 → 这里直接返回False,不重复武装(search_system.py:371)。 - 但 CLI、新闻调度器、程序化 API 是直接 new 出协调器的,没人先武装 → 这里补上一层,否则整条管线就在没有次级网的情况下裸奔(
search_system.py:334)。 _arm_egress_backstop绝不抛异常(兜底失败不能拖垮研究运行);策略不可评估时静默不武装(search_system.py:385-393)。- 谁武装谁负责清:只有
_armed == True(是本次调用武装的)才在finally里clear_active_context(),免得复用的线程把上下文泄漏给后续无关任务(search_system.py:342-349)。
3.3 _perform_search:转调策略并回收结果
这才是把问题交给策略的地方,核心就一行:
# search_system.py:460
result = self.strategy.analyze_topic(query)
前后是进度播报和结果回收:先播报 LLM/搜索引擎信息(search_system.py:436-457),调完策略后:
- 把策略的
questions_by_iteration复制回协调器(向后兼容,:464); - 只有当两个
all_links_of_system不是同一对象时才extend,避免详细报告模式下引用清单翻倍(修复 issue #301,search_system.py:472-475); - 把
search_system自身和all_links_of_system塞进result,供报告生成器回来取引用(:478-479); - 最后触发新闻回调(
:487-497)。
4. 策略工厂与可用清单
4.1 工厂 create_strategy:名字 → 实例
工厂是一长串 if strategy_name_lower in [...] 的分派(search_system_factory.py:30)。每个分支负责一族别名,并从 settings_snapshot 读该策略的专属默认值。三个要点:
① 别名归一。 名字先 .lower(),每族接受多个写法,例如 source-based 同时认 "source-based"、"source_based"、"source_based_search"(:60-64)。
② 已移除策略的降级(inferred 补注:降级目标)。 mcp 和 agentic 两个旧策略在 #4548 被删,但工厂保留它们作为弃用别名,路由到最接近的后继 langgraph-agent,并打一条 warning——这样存量的已保存设置、排队任务、API 调用不会掉进"未知策略"的默认分支:
# search_system_factory.py:342
if strategy_name_lower in ["langgraph-agent", "langgraph_agent", "mcp", "agentic"]:
if strategy_name_lower in ("mcp", "agentic"):
logger.warning(f"Strategy {strategy_name!r} was removed (#4548); using 'langgraph-agent' instead.")
... # 造 LangGraphAgentStrategy
③ 未知名兜底 source-based。 任何认不出的名字,记一条 warning,降级为默认 source-based(search_system_factory.py:386-402)。所以引擎"永远能跑",最坏也是退回默认厨师。
4.2 UI 清单 AVAILABLE_STRATEGIES
工厂认的名字(含别名、内部策略如 news_aggregation)远多于 UI 展示的。给用户看的清单是 constants.py:123 的 AVAILABLE_STRATEGIES,get_available_strategies() 返回它的拷贝(constants.py:261)。当前 UI 面向用户的五种:
| name | 定位 |
|---|---|
source-based | 默认;小上下文窗口(<16k)也能跑的综合研究,带内联引用 |
focused-iteration | 快速精准问答,输出精简 |
focused-iteration-standard | 长文详细输出,需 >16k 上下文 |
topic-organization | 把来源按主题聚类 |
langgraph-agent | 自主智能体研究(见 03) |
注意区分两个清单:
AVAILABLE_STRATEGIES只管 UI 下拉;工厂create_strategy才是运行时真正能造出来的全集(含别名和内部策略)。改一处不自动改另一处。
5. 策略抽象基类:所有厨师的公共台面
BaseSearchStrategy(base_strategy.py:14)是抽象基类,定义了那条契约,并提供每个策略都用得上的公共设施。它只强制一个抽象方法:
# base_strategy.py:119
@abstractmethod
def analyze_topic(self, query: str) -> dict[str, Any]:
# 返回 dict 至少含:findings / iterations / questions_by_iteration
# / formatted_findings / current_knowledge / (可选 error)
...
其余都是共享工具,子类直接用: