先说一个我踩了很久才想明白的结论:联网检索这件事,天然就不该放在模型的工具调用循环里跑。早期我们做一个 Agent 原型,习惯性把搜索注册成模型的一个 function,让它“自己决定什么时候搜”。听起来很优雅,实际用起来全是内伤:搜索超时,整个生成跟着超时;搜索返回一个错误码,模型不会直接说查不到,而是把错误信息包装成“联网检索暂不可用,请稍后再试”这种漂亮话,白白烧掉几百个 token,问题却一点没解决。后来我把联网检索从模型推理里彻底拆了出去,做成一个独立模块,就叫bl search web。
这个模块不跟模型玩什么智能编排,就是老老实实的三层结构:Request 层负责把各种输入捏成合法检索请求,Executor 层负责网络请求、超时预算和重试退避,Parser 层负责把脏响应清洗成干净的结构化条目。同时我给进程设计了语义化退出码,让每一类失败都能在日志里留下可查的记号。拆完以后稳定跑了很久,又陆续撞上两个非常隐蔽的静默坑——它们都不报错、退出码都是 0,但结果悄悄不对。这篇就把三层结构、退出码设计,以及这两个坑的完整排查过程写清楚,给正在把工具调用和模型解耦的工程师做个参考。
1. 为什么最终决定把联网检索从模型工具调用里拆出去
1.1 早期内嵌方案的三宗罪:生成被拖垮、状态被叙事化、观测等于零
最开始的实现很常见:把search_web(query)注册成工具,模型在推理过程中输出一个 function call,然后程序执行搜索、把结果塞回上下文,模型继续生成。第一宗罪是延迟耦合。搜索接口慢一点、重试一次,用户的界面就卡住,因为整个生成链在等网络。第二宗罪是模型会把失败状态“叙事化”。模型拿到一个空结果或者超时信号,并不会把它当作结构化错误处理,而是用自然语言解释成“网络状况不好”“暂时无法获得实时信息”。这些话在日志里看起来像诊断结论,实际上没有任何排查价值,因为你根本不知道到底是 DNS 挂了、服务端 429 限流,还是请求参数写错了。第三宗罪是观测性为零。搜索层级的重试、超时、缺数据,全部藏在变长生成的中间过程里,你想单独对“搜索成功率”做监控和告警,只能去翻模型日志碰运气。
token 浪费也要算一笔账。一次空结果搜索,模型大概率生成一段“抱歉,由于网络原因暂时无法获取实时数据,请稍后重试”之类的解释,按 200 token 算,一天一千次空搜就是 20 万 token 的纯垃圾输出。更气人的是,模型越礼貌,问题被掩盖得越深。
1.2 拆出去的核心收益:模型只读“结果摘要”,不读“状态机”
拆完之后,模块和模型之间的接口变成了一条很窄的数据通道:有没有结果、总共几条、每条结果的标题和链接。模型只消费这个结果摘要,不再需要理解 HTTP 500、504、连接重置、配额耗尽这些状态的区别。
这个边界是整件事的灵魂。搜索模块内部可以有退避重试,有超时预算,有远端业务错误码,但这些复杂度不该进入模型上下文。之前模型得在生成过程中为一个搜索错误组织语言,现在模块直接把最终状态翻译成退出码和结构化结果,模型只需要说“有结果,引用它”或者“没有结果,承认不知道”。这样既省 token,又避免了模型编造失败的坏习惯。
1.3 拆分之后必须回答的三个新问题
拆出去不等于把问题外包了,而是把边界搬到了更合理的位置。边界移动之后,有三个问题立刻浮出来,不回答清楚,后面全是坑。
第一个问题:超时算谁的?模块内部必须独自管理连接超时、读取超时、重试次数和总预算,外层只等模块的最终退出码,不能把超时责任推给调用方。第二个问题:空结果算不算失败?我最终把“业务上没有结果”定义为一种正常返回,退出码 0;但“服务端返回了业务错误”则必须是非零退出码。这两个状态长得一模一样,不区分就会踩到后面那个静默坑。第三个问题:并发谁管?模型内嵌搜索时,单次请求的并发是隐式的、不可控的,拆成独立模块后,上层可以批量调用,模块必须自己设计并发限制和排队策略,否则压力会全部压在检索服务的配额上。
2.bl search web的三层结构:Request、Executor、Parser 的职责与接口
2.1 Request 层:把各种输入归一成合法检索请求
这一层是整个模块的入口。它接收的原始输入五花八门:模型生成的简短 query、用户原话、粘贴过来还带着换行和不可见字符的句子。Request 层要做的不是看懂语义,而是把输入整理成一个“检索服务愿意接受的请求”。
最重要的原则是克制。正常的字符规范化只做安全操作:剥离不可见控制字符、处理跨平台换行、去掉首尾空格、控制总长度。至于引号、括号、英文标点这些可能携带检索语义的符号,我强烈建议不要乱动。某个搜索引擎里英文双引号代表精确匹配短语,你把引号删了,搜索结果可能完全变样,而且没有任何报错。这个问题的完整排查过程放在第四章,这里先记住结论:规范化函数宁可少做,不要多做。
长度预算也在这一层做。很多检索接口对 query 有长度限制,盲目整体截断会破坏关键词。我的处理是先按空格拆词,尽量保留核心片段,如果必须在中间切断,宁可返回参数错误,也不要发出一个残缺的请求。代码结构大概是这样的:
class SearchRequest: def __init__(self, raw_query: str, top_k: int = 5, api_key: str | None = None): self.raw_query = raw_query self.query = self._normalize_query(raw_query) self.query_id = uuid4().hex[:8] self.top_k = min(max(top_k, 1), 10) self.api_key = api_key @staticmethod def _normalize_query(text: str) -> str: text = "".join(ch for ch in text if not unicodedata.category(ch).startswith("C")) text = text.strip() if not text: raise InvalidQueryError("empty query") return textRequest 层不碰网络,不碰重试,它就是一个纯函数,方便单独测试。
2.2 Executor 层:专门负责“撞网”
Executor 是唯一允许发生真实网络请求的地方。连接超时、读超时、重试、退避、并发限制、User-Agent、请求头统一都在这一层处理。它本质上是一个“什么都不懂的网络搬运工”:接收一个 SearchRequest,带着原始响应体回来,不做任何业务解析。
超时控制是这层最容易做错的地方。很多人只设置了连接超时和读超时,忽略了整体预算。连接超时 3 秒、读超时 5 秒,看起来都不大,但叠加两次重试,一次请求可能拖到 20 秒以上。所以我给 Executor 增加了一个total_budget总预算,任何一次检索从开始到放弃不能超过它。核心循环长这样:
deadline = time.monotonic() + total_budget for attempt in range(max_retries + 1): if time.monotonic() >= deadline: return ExecutionOutcome(status=SearchStatus.TIMEOUT, raw=None, runtime_ms=...) try: resp = session.get(url, params=params, timeout=(connect_timeout, read_timeout)) if resp.status_code in (429, 503) and attempt < max_retries: time.sleep(backoff_factor * (2 ** attempt)) continue if resp.status_code >= 400: return ExecutionOutcome(status=SearchStatus.NETWORK_ERROR, http_status=resp.status_code) return ExecutionOutcome(status=SearchStatus.OK, raw=resp.text, http_status=resp.status_code) except requests.Timeout: continue except requests.ConnectionError: continue注意,ReadTimeout、ConnectionError都是requests的异常类型。404 不需要重试,429 和 503 值得重试,这个should_retry逻辑不要做成“所有非 200 都重试”,否则会把远端错误放大成网络抖动。
2.3 Parser 层:从脏响应里挤出干净字段
Parser 接收 Executor 返回的原始响应体,输出一个结构化的SearchResponse。它要处理的问题包括:字段缺失、列表为空、链接字段叫url还是link、标题被截断、响应编码不是 UTF-8。
解析逻辑本身不难,难在“宽容的边界”。早期我的 Parser 很宽容,items缺失时直接返回空列表,这导致了一个大坑,后面详说。现在它的第一件事永远是检查业务状态码,而不是直接提取数据:
def parse_payload(raw: dict) -> SearchResponse: biz_code = raw.get("code") if biz_code != 0: return SearchResponse( status=SearchStatus.REMOTE_ERROR, remote_code=biz_code, results=[], ) items = raw.get("items", []) results = [] for item in items: link = item.get("url") or item.get("link") if not link: continue results.append( SearchResult( url=link, title=item.get("title") or "", snippet=item.get("snippet") or "", ) ) return SearchResponse(status=SearchStatus.OK, results=results)编码问题也在这里处理。有些响应声称是 UTF-8,实际内容却带着非标准的编码标记,直接resp.text会得到一堆乱码标题,而乱码不报错。稳妥做法是用resp.content读取原始字节,再结合响应头里的 charset 手动解码。
2.4 三层之间的接口契约:层与层之间只传数据对象
三层之间不共享实现细节,只共享数据对象。我一开始用裸 dict 传参,后来发现字段名会被改来改去,改一处崩一片。换成数据类之后,每层输入输出都变得可验证,测试也好写。一张表说清楚边界:
| 层 | 输入 | 输出 | 不允许做的事 |
|---|---|---|---|
| Request | 原始文本 | SearchRequest | 网络请求、查询改写 |
| Executor | SearchRequest | ExecutionOutcome(raw, status) | 业务解析、状态码语义判断 |
| Parser | ExecutionOutcome | SearchResponse | 网络重试、请求参数修改 |
部署形态上,我把bl search web同时做成了两个入口:一个是可被命令行调用的子进程,一个是可被 Python import 的库。CLI 子进程模式天然产生退出码,这也直接促成了下一章的语义化退出码设计;库模式方便 Agent 在进程内集成。两种模式共用同一套三层结构,核心逻辑不重写。
3. 语义化退出码:把失败痕迹从“exit=1”升级为可定位的标记
3.1 早期全用 exit 1:日志里只有一堆无差别失败
最初模块的所有失败都返回sys.exit(1),看起来没问题,排障时痛苦得要命。一次线上检索大面积变慢,日志里清一色exit_code=1,我根本分不清是网络抖动、配额耗尽还是解析出错。后来我把退出码按失败阶段拆开,监控规则才真正有了用武之地。
3.2 退出码映射表:和三层结构一一对应
新的退出码严格对齐三层结构,避免一个阶段失败却挂在另一个阶段的名义下。这是我在生产环境沉淀下来的映射:
| 退出码 | 语义 | 对应层 | 典型处理 |
|---|---|---|---|
| 0 | 成功(包括业务空结果) | Parser / 全部 | 正常消费结果 |
| 1 | 参数不合法 | Request | 检查调用方输入 |
| 2 | 网络请求失败 | Executor | 检查网络或服务端健康 |
| 3 | 超时(总预算耗尽) | Executor | 升级超时配置或服务扩容 |
| 4 | 配额或鉴权失败 | Request / Executor | 检查 API key 和配额 |
| 5 | 响应解析失败 | Parser | 检查响应格式 |
| 6 | 远端业务错误 | Parser | 检查服务端业务状态码 |
为什么“业务空结果”也用 0?因为搜索这个动作天然可能没有答案,用户问一个冷门问题,返回空列表是合法结果,不是系统故障。把空结果当非零退出码,告警会全天响,最后所有人都会忽略告警。把“远端业务错误”单独拎出来,是为了和“真空结果”做区分,这是避免静默坑的第一道防线。
3.3 退出码在 bash 管道里怎么安全取到
CLI 模式的退出码本身很简单,真正阴险的是外层 shell 的管道。很多人写bl search web "$query" | jq '.results',然后发现模块明明失败了,脚本却继续往下走。原因很简单:管道返回的是最后一个命令的退出码,jq成功了,整个管道就成功了,bl search web的退出码被静默吞掉。
我现在的标准写法是:
set -o pipefail bl search web "$query" > /tmp/search.json code=$? if [ "$code" -ne 0 ]; then echo "search failed with exit code: $code" >&2 fi jq -r '.results[0].url' /tmp/search.jsonset -o pipefail让管道中任意一个环节失败都会被捕获,避免退出码漂移。在 Python 里调子进程也一样:
import subprocess r = subprocess.run(["bl", "search", "web", query], capture_output=True, text=True) if r.returncode == 3: # 超时,走重试或降级3.4 退出码进监控:告警规则终于能写了
退出码拆分后的最大收益是监控规则可以具体到阶段。我目前接了三类告警:
exit_code=3高频出现,说明检索服务响应变慢,触发网络健康检查。exit_code=4出现就告警,说明配额快用完,需要人工介入。exit_code=6占比升高,说明远端服务在降级,可能需要切换备用检索通道。
这类告警以前根本写不出来,因为所有失败都被折叠成一个数字。拆完退出码之后,故障的早期信号明显清楚了很多。
4. 两个静默坑的完整排查链:一个藏在 HTTP 200 后面,一个藏在查询词里
4.1 静默坑一:HTTP 200 不代表业务成功,假成功把空结果伪装成正常结果
这是我拆完模块之后遇到的第一个真正的静默问题。某天跑回归,发现几个 query 模块返回空列表、退出码 0,日志显示 HTTP 200。手动打开浏览器用同一关键词去搜,明明有很多结果。整个链路看起来每一个环节都“成功”了,结果却错了,非常诡异。
彻查之后发现,响应确实是 200,但响应体里的业务状态码是code=1001,对应的 message 是“service busy, degraded result”。也就是说,检索服务用 200 的状态码掩盖了自身的降级。而我的 Parser 当时很“宽容”,items列表缺失时不是报错,而是正常返回空列表,最后退出码当然是 0。
这次的教训很直白:raise_for_status()和resp.ok只能说明 HTTP 网关层可达,不能说明业务层成功。我把修复落实成两条规则。第一,Parser 必须在提取任何数据之前检查业务状态码,只要不等于成功值,立刻返回REMOTE_ERROR并映射到退出码 6。第二,日志里必须同时记录 HTTP 状态码和业务状态码,不能再只打一个 200 的假安心。
单测也很容易写,重点是构造一个 200 但业务失败的 fixture:
fake_payload = {"code": 1001, "message": "degraded", "items": []} resp = parser.parse_payload(fake_payload) assert resp.status == SearchStatus.REMOTE_ERROR assert resp.remote_code == 1001assert这一句以前是过不去的,因为老 Parser 会把它当成“空结果”。现在有了这条防线,这类假成功至少会在退出码上暴露出来。
4.2 静默坑二:查询词被“过度清洗”,请求看起来正常,结果却悄悄漂移
第二个坑埋得更深。某次模型回来后发现,凡是 query 里带英文引号的检索,模块返回的候选和手动检索差异巨大。例如手动搜"bl search web" 退出码,得到的文档排序明显更贴近精确匹配;模块搜出来的却是泛化的结果,像是把引号直接删掉了。
顺着调用链往下查,先打开调试模式,打印实际发出的请求和参数:
bl search web "bl search web 退出码" --debug-sent-query日志打印出来,URL 里的 query 参数确实被 URL 编码了,但英文双引号对应的一对%22根本不存在。再定位到 Request 层的_normalize_query,问题清楚了:当初写这个函数的时候,为了“清理用户输入里的奇怪符号”,我把所有标点符号都过滤了一遍,包括引号、括号、冒号。对一个普通文本输入来说,这个清洗也许无害;但对检索 query 来说,英文双引号是有检索语义的,删了就等于把用户的精确匹配意图丢掉了。
类似的子情况还有全角半角归一化。把全角括号()转成半角(),在某些检索系统里问题不大,但全角逗号、全角冒号被转掉之后,分词结果可能完全变化。吃了这次教训,我把 Request 层的策略彻底改成了“少干预”:只剥离不可见控制字符、处理明显换行和重复空格、控制长度上限,不主动删除或改写任何可能携带检索语义的标点。
超长截断是同一个坑的变体。之前 query 超过 60 字符就query[:60]硬切,结果某个问题在 50 字符处把一个关键词拦腰切断,请求依然成功、退出码依然是 0,但搜出来的东西完全货不对板。现在超长的时候,我会先按空格做关键词切分,尽量保留完整的语义单元;如果切出来的片段仍像是残词,就返回PARAM_INVALID并明确告诉上层“换个问法”,而不是硬着头皮发一个残缺请求。
4.3 复盘:为什么这两个坑能静默这么久
事后复盘时我发现,这两个问题的共性在于“结果看起来是合法的”。假成功返回空列表,退出码 0,在图表上就是一句普通的“无结果”;过度清洗发出的请求也完全合法,没人会怀疑规范化函数会改掉有语义的符号。静默坑之所以静默,是因为失败没有在状态码或日志格式上留下足够强的异常信号。
对应的防御策略也就清晰了:一是状态码必须区分“真空结果”和“远端业务错误”;二是要把最终发出的请求内容完整打到调试日志里,不能只记录返回值;三是给 Parser 和 Request 层都配上针对“看起来正常但实际偏离”的回归样例。这三条做到之后,这个类别的静默问题就有了提前暴露的路径。
5. 拆出去之后的回归验证与监控建议
5.1 mock 掉网络后的三层单测最少要覆盖哪些场景
联网模块最怕测试依赖真实网络,那样跑一次挂一次、断言飘忽不定。我的做法是把网络全部 mock 掉,确保测试稳定。每层的最少覆盖场景列在下面:
| 层 | 必须覆盖的用例 | 断言重点 |
|---|---|---|
| Request | 全角引号、英文引号、超长文本、纯标点、空字符串 | 生成请求参数不丢失语义符号 |
| Executor | 连接超时、读超时、404、429、503、连续重试后成功 | 退出状态、总预算内结束 |
| Parser | 正常 items、items 缺失、业务 code 非零、字段缺失、乱码编码 | 状态分类、字段兜底、退出码映射 |
这套单测写起来不算复杂,但每次改动模块都可以靠它兜底,非常值。
5.2 建立一组固定 query 的对比基线
除了单测,我还会留一组固定的对比 query 存在 fixture 文件里,每次改完模块就跑一遍,记录三条指标:结果数量、第一条结果的 URL、总耗时。这组 query 不能太宽泛,要能暴露洗词和截断问题。比如刻意包含带英文引号的短语、带全角括号的论文标题、超过长度限制的完整问句。只要第一名的 URL 发生漂移,就要回头检查是不是某个规范化规则又误伤了查询语义。
5.3 监控指标不要只看退出码分布
退出码监控是基础,但不是全部。我额外盯四个指标:exit_code分布、p95 耗时、result_count分布、重试次数均值。其中最容易骗人的是exit_code=0 且 result_count=0的占比。这个占比单独升高有两种可能:真的没结果,或者又出现了新的假成功。为了避免过度告警,我不会设置固定阈值,而是每周人工抽样一次,拿几个真实 query 对比模块结果和手动检索结果,看有没有重新漂移。
结合三层结构和退出码,整个监控链路就成了一个闭环:Request 层的问题看参数日志,Executor 层的问题看耗时重试和退出码 2/3/4,Parser 层的问题看退出码 5/6 和 result_count 分布。每个环节都有明确定位手段,排障顺序自然清晰。
最后再分享一个我个人的体会:最危险的代码往往不是那些逻辑复杂、一眼看过去就会出错的分支,而是那些跑得通、返回 0、结果却悄悄变形的“正常分支”。把联网检索从模型里拆出来只是第一步,真正让这个模块稳定可维护的,是对静默异常的敏感度——状态码要分层,日志要留请求快照,测试要覆盖看起来正常却偏离预期的样例。做到这几点,这个模块才算真正立住了。