
这半年我一直在维护一个叫 bl 的命令行小工具核心功能是给本地脚本提供联网检索能力。前阵子我终于把检索逻辑从模型调用链里拆出来拆完以后整个链路稳定了不少也顺手把之前一直被掩盖的两个问题彻底暴露了出来。今天不聊模型本身也不聊排序算法而是把这次重构里我觉得最有价值的三个部分——三层结构、退出码设计、以及两个特别难发现的静默坑——完整梳理一遍。如果你也在做搜索增强、RAG 数据管道或者 Agent 工具链这几点应该能直接用上。我一直坚持一个观点检索这种带有强不确定性的操作就不应该和模型生成搅在一起。一个要快、要确定、要可以无限重试一个要慢、要发散、要一次成功。把两者拆开不仅是为了代码整洁更是为了让失败可以被预期、被处理、被程序化地兜底。下面我就按“为什么拆、怎么拆、拆完遇到什么坑”这个顺序来讲。1. 拆之前先搞清楚检索和生成到底该不该耦合早期版本里联网检索是走模型工具调用协议完成的。流程大概是用户问一个问题模型判断需要查资料然后生成一个工具调用参数程序拿着这个参数去请求外部搜索接口再把返回的 HTML 塞进模型上下文最后模型基于这一大堆原文生成回答。听起来很智能实际用起来全是坑。1.1 模型直连检索的四个老毛病第一个问题是上下文污染。搜索引擎返回的页面内容动辄几千字全部塞进上下文后Prompt 变得又长又乱。模型要在一堆 HTML 标签、导航文字、广告区块里找有用信息不仅响应变慢还经常把页面上的噪音当成事实说出来。第二个问题是一旦检索失败模型会自己“脑补”。超时了、返回空页了、解析出乱码了只要工具调用的结果不理想模型为了完成对话很容易基于残缺信息编一个看似合理的答案。这种错误非常隐蔽因为整个调用链没有报错只是内容错了。第三个问题是无法复用。同样的检索逻辑想用在离线批处理、定时监控、数据分析脚本里全部得重新实现一遍。模型工具调用协议绑定在对话服务里出了这个场景就用不了。第四个问题是观测困难。日志里全是模型调用记录和工具调用参数真正消耗时间的网络请求、解析耗时反而被淹没。出了问题很难说清是模型生成慢还是某个搜索源响应慢。1.2 拆开之后模型反而更像“模型”了把检索拆出来后模型不再负责决定怎么查、查哪里只需要负责一件事基于已经结构化好的检索结果做信息整合与表达生成。现在我的调用链是用户提问 - 模型只做意图识别 - 生成一个标准查询词 - bl search web 独立执行检索 - 输出干净的 JSON 结果 - 模型基于 JSON 生成最终回答这个改动的收益非常直接。模型上下文里不再出现原始 HTML只出现整理后的标题、摘要、来源和发布日期上下文长度大幅下降。检索失败时程序可以直接根据退出码决定降级策略而不是让模型去“处理异常”。同样的检索模块也可以被命令行直接调用甚至被 cron 定时任务复用完全脱离聊天场景。模型回归到它最擅长的事情根据可靠信息做判断而不是在噪音里捞针。2. 三层结构怎么分每一层到底管什么拆法确定之后最关键的其实是模块边界。我最终收敛成三层接口层、调度层、解析层。三层各管一段互相之间只通过数据结构和退出码通信任何一层替换掉都不影响另外两层。2.1 接口层把“人话”翻译成“检索请求”接口层是整个模块对外的门面。它接收一个查询词比如“某个框架最新版本有哪些变化”然后把它转换成结构化的检索请求dataclass class SearchRequest: query: str sources: list[str] max_records: int per_source_timeout: float total_deadline: float接口层做的事情很朴素校验查询不能为空、来源列表必须是白名单内、超时参数必须是正数。如果校验不过直接返回退出码 1。这里有一个设计细节值得说路由策略不要交给模型判断用确定性规则。比如包含“价格”“对比”这类词就走比价源包含“API”“文档”就走官方文档源包含“新闻”就走资讯源。一开始我也试过让模型选择来源结果发现每多一次模型调用就多几百毫秒延迟而且选择结果还不稳定。同样的输入这次选 A 源下次选 B 源后续缓存和回归测试都没法做。用规则虽然“笨”但可复现、零延迟、易调试。2.2 调度层把“不确定网络”收敛成“固定退出码”调度层是整个模块的心脏也是最容易设计失败的地方。它的任务是把“网络不稳定”“站点响应慢”“页面被屏蔽”这些外部不确定性全部拦截在这一层转换成调用方看得懂的退出码或结构化标记。我推荐用 asyncio 做并发调度async def dispatch(request: SearchRequest, selected_engines: list[Engine]): async with timeout(total_deadline): results await asyncio.gather( *[engine.search(request) for engine in selected_engines], return_exceptionsTrue ) # return_exceptionsTrue 保证单个引擎的异常不会拖垮整个调度这里有两个关键选择。第一并发而不是串行。四个来源串行查询最慢的那个直接决定总耗时并发的话总耗时基本等于最快的那个用户体验完全不同。第二return_exceptionsTrue 而不是直接抛出异常。一个引擎挂了不应该让整个检索任务失败而是要把这个引擎标记为 error继续用其他引擎的结果。真正彻底的失败比如所有引擎全部异常才需要映射成非零退出码。调度层还要负责分阶段超时。连接超时和读取超时是两件事连接超时指 TCP 握手能否建立读取超时指服务端能否在限定时间内返回响应头和数据。分开设置才能精确定位到底卡在哪一步。后面讲静默坑的时候会展开这里先埋个伏笔。2.3 解析层HTML 到这里才算真正结束解析层的输入是引擎返回的原始页面内容输出是结构清晰的检索记录dataclass class SearchRecord: title: str url: str snippet: str source: str published_at: str score: float这层做的事情包括去噪、正文抽取、去重、格式清洗、排序。技术栈可以选 BeautifulSoup 也可以选 lxml差别不是重点重点是这层必须做“内容语义校验”。所谓语义校验就是解析完之后要再问一句这东西真的是一条可用的搜索记录吗正文长度有没有达到基本阈值标题是不是常见的错误提示文案页面正文是否包含验证页特征这些问题如果不回答解析层就会把一个空壳页面当作正常结果输出进而导致整个调用链“静默失败”。详细解法在第 4 节展开。三层职责可以用一张表看得很清楚层核心职责输入输出主要失败场景接口层校验参数、路由选择原始查询词标准 SearchRequest参数非法、来源不在白名单调度层并发请求、超时、重试SearchRequest各引擎原始响应聚合网络异常、超时、全源失败解析层HTML 清洗、正文抽取、排序原始 HTMLSearchRecord 列表空壳页面、解析异常、无有效正文3. 退出码为什么值得认真设计退出码是模块和调用方之间唯一的契约。如果调用方还要靠读日志来确认成功还是失败那退出码就没有价值。我的要求是看到退出码的瞬间调用方就能决定下一步动作不需要任何额外上下文。3.1 非零即失败细分原因才叫设计最终定版退出码如下退出码含义典型场景调用方建议动作0成功至少有 1 条有效结果引擎返回并解析出有效记录直接使用结果1参数错误查询为空、来源非法、超时参数为 0修调用代码属代码 bug2网络层错误DNS 解析失败、TCP 连接被拒绝、TLS 握手失败检查网络环境可稍后重试3超时连接超时或总 deadline 超时降级为无检索回答或做熔断4无可用结果引擎正常返回但解析出的有效记录为空告诉用户“没查到”不要编造5解析失败页面 HTML 获取成功但内容校验不通过触发告警人工查看页面结构6被限流或风控站点返回“请求过于频繁”等特征文案降低请求频率稍后重试设计时特别纠结的是 2 和 3 的区别。本质上超时也是网络问题但调用方的动作完全不同。遇到 2通常是环境问题重试成功率不低。遇到 3可能是目标站点本身变慢重压期间无脑重试只会加重问题。分开之后调用方可以分别处理而不是一刀切重试。3.2 部分失败怎么编码一个容易搞错的关键细节这里有个我吃过亏的细节不要把“某个引擎失败”直接映射成整体退出码非零。假设调度层并发请求了 4 个来源其中 2 个正常返回1 个超时1 个被限流。这种情况下整体退出码应该是 0因为调用方已经拿到了可用结果。单个引擎的失败应该记录在结构化输出的 debug 字段里方便排查但不影响主流程成败。退出码只回答一个问题这次检索有没有拿到“可用结果”。至于“哪些来源失败了”那是诊断信息不是结果信息。如果一开始就把单个引擎失败编码成错误调用方会因为一个次要来源的抖动丢弃三个正常来源的结果非常可惜。脚本消费退出码的方式可以很简单bl search web --query 某个框架最新版本说明 --json result.json code$? case $code in 0) echo 检索成功直接处理 result.json ;; 2) echo 网络异常不重试走离线回答 ;; 3) echo 超时降级为静态知识回答 ;; 4) echo 无结果明确告诉用户没查到 ;; *) echo 其他异常触发告警 ;; esac4. 两个静默坑全部踩过之后才发现的标题里说的“静默坑”指的是那种不会报错、不会退出、也不会在日志里留明显痕迹的错误。它会把一个失败伪装成成功让整个系统在错误的道路上安静地跑很久。下面这两个坑是我实测中真实遇到的第一个花费了很久才定位第二个则是重构后才被觉察到。4.1 静默坑一HTTP 200 不等于检索成功最典型的场景是某些站点对无头请求返回的不是正常搜索结果而是一个验证页或占位页。关键是这种页面通常仍然返回 HTTP 200 状态码。如果你只检查 resp.status_code 200就认为请求成功那解析层拿到的就是一堆“请开启 JavaScript 后重试”之类的内容。我当时遇到过一次某个来源页面状态码 200HTML 完整解析器也没抛异常最终输出了一个 title 为空的记录整体退出码 0。整个过程没有任何报错链路日志看起来完全正常但模型拿到的实际上是一条无意义内容。这个问题是后来做结果抽样对比人工翻到那条记录才发现。解法其实不复杂但必须放在解析层def validate_page(content: str) - bool: main_text extract_main_text(content) if len(main_text) 200: return False if has_challenge_marker(main_text): return False if has_blocked_notice(main_text): return False return True校验失败直接映射为退出码 5绝对不能返回 0。关键点在于这类校验只能由解析层完成因为只有它知道 HTML 语义知道什么样的内容算是“有效正文”。调度层拿到的是原始 HTML它看不了这么深。做完这个改动我会再提一句内容校验不能只是“不报错”必须真正检查内容是不是符合预期。宁可多识别一些误伤也不要让垃圾内容通过。误伤可以通过白名单调整静默失败则很难被察觉。4.2 静默坑二总超时和慢引擎的障眼法第二个坑出在超时设计。一开始我图省事给整个检索流程设了一个总的超时时间比如 10 秒然后用 requests.get(url, timeout10) 去请求每个引擎。表面上看没问题实际上有两个盲区。第一个盲区是requests.get 的 timeout 参数实际上覆盖了连接建立、响应头读取、正文读取全部阶段。但站点表现往往是分段差异巨大的DNS 解析很慢但下载很快或者连接很快但首字节迟迟不来。如果只有一个 10 秒的总超时你会看到一个现象某次检索花了 9 秒但日志里什么都看不出来因为没超时。而实际上 9 秒里有 7 秒是某个引擎的 DNS 解析异常。第二个盲区是并发请求多个引擎时只设总体 deadline 不够。总 deadline 是 10 秒引擎 A 花了 9.8 秒返回垃圾页引擎 B 0.2 秒就返回了正常结果。整体退出码是 0引擎 B 成功但调用方实际等待了近 10 秒。从退出码看完全正常从体验上看慢到离谱而日志里又没有任何超时记录。正确的做法是分阶段超时加单引擎子超时# 连接超时 3 秒读取超时 8 秒分开设置 HTTP_TIMEOUT (3, 8) # 调度层给每个引擎独立子超时 per_engine_timeout { engine_a: 5, engine_b: 3, }这样设计之后引擎 A 如果超过 5 秒还没返回调度层会单独把它标记为 timeout继续等引擎 B 的结果。整体退出码仍然可以是 0但 debug 字段里会明确记录“engine_a: timeout, 5.2s”。调用方不需要承担 A 的慢也能精确知道是谁拖了后腿。这两个坑有一个共同点错误都被包装成了“成功”。HTTP 200 包装了被拦截的失败总超时包装了慢引擎的失败。破解思路就是增加两道检测面内容校验负责拆穿假成功分阶段计时负责曝光慢成功。5. 排查静默失败的一套诊断流程踩过这些坑之后我总结出一套固定的排查流程所有静默问题基本都能在十五分钟内定位。5.1 三步定位法先看退出码再看日志最后比内容第一步看退出码。退出码 0 但结果可疑说明问题出在“结果内容质量”上大概率是解析层或者来源页面出了问题。退出码非零那好办按第 3 节的表直接定位到失败域。第二步看 debug 日志。在 bl search web 里我所有失败都会输出一个结构化 JSON 日志包含每个引擎的耗时、状态码、解析后的正文长度。这里重点看两个数据每个引擎的实际耗时是否接近子超时阈值、正文长度是否异常偏小。第三步对比原始内容。在开发模式下把某个引擎的原始 HTML 落盘成文本注意稳妥处理页面内容仅用于本地调试然后人工打开看。这个动作可以直接还原“静默坑一”的场景看到底是验证页还是空壳页。开发阶段还有一个很实用的参数--verbose。它会打印每个来源的返回状态、表层耗时、解析状态以及最终退出码的映射原因。我在 bl 里默认关闭定位问题时再打开因为全量开启会让输出非常啰嗦。5.2 把“内容校验”做成强制规则而不是可选优化如果问我在这个项目里最后悔没早做什么答案一定是没有从第一天就把内容校验设计成强制流程。很多早期版本里解析层拿到 HTML 就抽正文抽出来不管好坏直接返回。当时的想法是“能抽到多少是多少让模型自己判断”。后来发现模型没有判断能力它会把任何给它的文本都当成事实。与其让模型去尝试理解垃圾不如在进入模型之前就把垃圾过滤掉。我的建议是所有解析层代码必须带一个 validate 步骤。校验不通过不允许直接输出空列表必须要么标记为解析失败退出码 5要么明确返回“无有效内容”退出码 4。错误可以被容忍但静默不可以。诊断流程可以整理成一个速查表现象第一步检查常见结论处理动作退出码 0但模型回答明显缺信息查看 debug 字段里的正文长度某个来源内容过短被当作有效记录提高正文长度阈值收紧校验规则退出码 0但查询总耗时接近数秒对比各引擎耗时与子超时值某个慢引擎拖累整体体验降低该引擎子超时值或改异步并发退出码 3但网络看起来正常查看是连接超时还是读取超时目标站点响应头慢而非网络断连调整单引擎超时阈值增加重试策略退出码 5单个来源频繁出现落盘该来源 HTML 并人工查看页面结构改版或触发了风控页更新解析规则必要时短期禁用该来源这套表格基本覆盖了我日常排查的大部分问题。遇到没覆盖到的情况我的经验是回到“退出码 内容校验 分阶段计时”这三板斧绝大多数奇怪现象都能对上号。拆完之后bl search web 从一个“跟模型绑定的工具函数”变成了一个真正独立的可编程模块。现在我可以随便写一个脚本搜索关键词拿到干净的 JSON 结果再交给任何逻辑去处理完全不依赖模型对话环境。它能做定时监控能做离线分析能并行跑多个查询这些在拆分前都是很难实现的。最后再分享一个小技巧给所有网络型工具的输出加一个 source_debug 字段记录每个来源的耗时、状态和失败原因。这个字段平时不影响正常结果排查静默问题时却能省下一整个下午。我把它加入 bl 的 JSON 输出后再没用过“猜原因”这种原始的排障方式。