跳到主要内容

一次 ask 请求的主线

本章讲:一句提问进来后,NLWebHandler 把它带过哪几个阶段,以及这些阶段之间用什么机制协调(重点是 asyncio 的事件信号)。把这条主线吃透,后面三章的机制就都有了挂靠点。

1. 入口:所有协议都收敛到 NLWebHandler

无论请求从 REST 还是 MCP 进来,最终都构造一个 NLWebHandler,参数统一成 query_params 字典。

__init__ 做的全是「解析参数 + 初始化状态容器」,没有业务逻辑(baseHandler.py:38)。值得记住几个字段:

字段含义默认
site问哪个站,可逗号分隔多站"all"
query用户原始提问""
prev_queries之前的提问(多轮用)[]
generate_mode输出形态:none / summarize / generate"none"
min_score排序后过滤的分数下限51
max_results最多返回几条10
streaming是否流式发送True

关键直觉:__init__ 还顺手 new 了一堆 asyncio.EventbaseHandler.py:150-154)——pre_checks_done_eventretrieval_done_eventabort_fast_track_event 等。整个管道的「谁等谁」全靠这些事件,而不是轮询标志位。

2. runQuery:主线的五步

runQuery() 是顶层骨架,短到可以全文记住其结构(baseHandler.py:321):

# 示意,非源码:runQuery 的骨架
async def runQuery(self):
await self.message_sender.send_begin_response() # 发「开始」消息
await self.prepare() # 并发预检 + 检索
if self.query_done: # 中途判定结束就直接返回
return [...]
if not self.fastTrackWorked: # FastTrack 没把结果发出去
await self.route_query_based_on_tools() # 才按工具路由处理
if self.query_done:
return [...]
await post_ranking.PostRanking(self).do() # 排序后处理
await self.message_sender.send_end_response() # 发「结束」消息
return [msg.to_dict() for msg in self.messages]

注意 if not self.fastTrackWorked 这行(baseHandler.py:330):如果 FastTrack 已经成功把结果流式发出去了,主线就跳过正常的工具路由+ranking,不重复干活。这是 FastTrack 设计的回报。

3. prepare:并发预检是整个引擎的心脏

prepare() 把几件互相独立的事同时丢进 asyncio 任务里跑(baseHandler.py:352):

# 示意,非源码:prepare 并发启动的任务
tasks = [
decontextualizeQuery().do(), # 多轮补全
FastTrack(self).do(), # 赌简单查询,先检索打分
QueryRewrite(self).do(), # 查询重写
ToolSelector(self).do(), # 选工具(除非请求显式指定 tool)
]
await asyncio.gather(*tasks, ...) # 等它们都结束

这里有一处真实代码值得点出:是否抛异常取决于运行模式(baseHandler.py:380-389)——开发/测试模式 should_raise_exceptions() 为真时直接 gather 让异常炸出来;生产模式用 return_exceptions=True 吞掉单个任务的异常,避免一个子任务挂掉拖垮整个请求。这是「测试要严、生产要稳」的常见两面派写法。

prepare 收尾时(finally 块)无论成败都会 pre_checks_done_event.set()baseHandler.py:390-392)——保证下游等待者不会永久卡死。

prepare 末尾的兜底检索

并发任务结束后,prepare 检查 retrieval_done_event 是否已被置位(baseHandler.py:394)。

  • 若 FastTrack 已经做过检索(它会 set 这个事件),就跳过。
  • 若该站点不支持标准向量检索(如 "all""datacommons"),把结果置空(baseHandler.py:397,依据 site_supports_standard_retrievalfastTrack.py:25)。
  • 否则才在这里同步做一次 search()baseHandler.py:401)。

这保证了:不管 FastTrack 走没走,到这一步 final_retrieved_items 一定有值

4. 阶段协调:用事件 + 一个小状态机

FastTrack 的结果不能随便发——它是「赌」出来的,得等预检确认「这查询确实简单、确实该用 search、确实不用补上下文」才能放行。这套协调逻辑落在 NLWebHandlerStatecore/state.py)。

它维护一个 precheck_step_state 字典,每个预检步骤("Decon""ToolSelector")从 INITIAL 走到 DONE。当所有步骤都 DONE 时,自动置位 pre_checks_done_eventstate.py:21-30 precheck_step_done):

# 示意,非源码:每个预检步骤完成时
async def precheck_step_done(self, step_name):
self.precheck_step_state[step_name] = DONE
if step_name == "Decon": # 去上下文化完成,单独放一个信号
self._decon_event.set()
elif step_name == "ToolSelector":
self._tool_router_event.set()
if all(s == DONE for s in self.precheck_step_state.values()):
self.handler.pre_checks_done_event.set() # 全部完成

为什么要给「去上下文化」和「工具路由」单独的事件?因为它们是 FastTrack「能不能发」的两个决定性输入:一旦去上下文化判定需要改写、或工具路由选了非 search,FastTrack 攒的结果就作废了(详见 02-ranking-and-fasttrack.md03-tool-routing.md)。

谁来判 FastTrack 该不该作废

should_abort_fast_track() 把所有「作废条件」收成一个方法(state.py:75):

条件含义
query_done查询已被判定结束
query_is_irrelevant提问与站点无关
requires_decontextualization需要补上下文(说明原始 query 不完整)
连接断了客户端跑了
顶层工具不是 search该走专门处理器,不是普通搜索

把这些条件集中而非散落各处,是这份代码里少见的整洁设计——任何一处想问「现在该叫停吗」,调一个方法即可。

5. 去上下文化:按情况选一个改写策略

decontextualizeQuery() 是个工厂,按当前请求的形态返回不同的改写器(baseHandler.py:412):

没有历史提问 → NoOpDecontextualizer(原样用,不改)
请求已带 decontextualized_query → NoOpDecontextualizer(别人已经改好了)
有历史提问 → PrevQueryDecontextualizer(用历史补全)
只有 context_url → ContextUrlDecontextualizer(用页面内容补全)

PrevQueryDecontextualizer 调一次 LLM,prompt 直接硬编码在 prompts.py:311PromptRunner.get_prompt)——大意是「把这句提问结合之前的提问改写成独立问题,拿不准就当成是追问」。若 LLM 判定 requires_decontextualization == "True",就立刻 abort_fast_track_event.set()decontextualize.py:84),因为这说明原始 query 不能直接拿去检索。

6. 巧妙之处

  • 「乐观执行 + 事后核对」。FastTrack 不等预检就先检索打分,把昂贵的工作和预检并行起来;预检完成后再决定放不放行。在「大多数查询其实很简单」的假设下,这把延迟砍掉一大截(fastTrack.py:5-8 的模块注释把这个设计意图写得很清楚)。
  • 事件而非标志位。早期可能用布尔标志轮询,现在统一成 asyncio.EventbaseHandler.py:149 注释 "replace flags with proper async primitives"),等待方 await event.wait() 即可,无忙等。
  • finally 里无条件放行preparefinally 必置 pre_checks_done_eventbaseHandler.py:390),杜绝异常路径下的死等。

7. 边界与局限

  • __init__ 里有 print("=== NLWebHandler INIT ===") 这类裸 print(baseHandler.py:40),加上多处被注释掉的预检任务(baseHandler.py:373-378:relevance_detection、memory、required_info 都被注释),说明管道仍在演进,部分预检尚未接回主线。
  • 30 秒超时是 MCP 那侧硬编码的(见 05-protocol-surface.md),prepare 本身没有整体超时。

8. 代码地图

主题文件符号
主编排器 / 构造core/baseHandler.pyNLWebHandler.__init__
顶层骨架core/baseHandler.pyNLWebHandler.runQuery
并发预检 + 兜底检索core/baseHandler.pyNLWebHandler.prepare
去上下文化工厂core/baseHandler.pyNLWebHandler.decontextualizeQuery
工具路由分发core/baseHandler.pyNLWebHandler.route_query_based_on_tools
预检状态机core/state.pyNLWebHandlerState.precheck_step_done
FastTrack 作废条件core/state.pyNLWebHandlerState.should_abort_fast_track
多轮改写器core/query_analysis/decontextualize.pyPrevQueryDecontextualizer.do