☰
搜索模块重构:从模型调用链到独立组件的设计实践与静默坑
2026/10/22 15:19:34 网站建设 项目流程

刚把一个折腾了大半个月的搜索模块重构收尾,趁热把过程里最值得说的部分整理出来。这次做的不是给某个模型加联网功能,而是反过来:把“联网检索”从模型调用链路里彻底拆成一个独立组件,名字就叫bl search web。拆完之后,模型只负责决定“搜什么”,真正的请求、解析、归一化、失败处理全都不经过模型。这个决定直接解决了之前好几个牛皮癣一样的老问题——模型输出不稳定、接口超时拖垮整轮对话、搜索结果脏数据污染上下文。这篇文章会把bl search web的三层结构、退出码约定写清楚,再重点聊两个我踩过最深的“静默坑”,希望对正在做Agent工具链拆分的人有点参考价值。

1. 项目源起:为什么要把联网检索从模型里拆出来

1.1 模型直接调搜索接口的三个老毛病

最早一版实现是很常见的方式:在Agent的tool列表里注册一个search_web工具,模型判断需要实时信息时,自己决定传什么参数,然后工具函数在模型进程里执行HTTP请求,拿回结果直接塞进上下文。

听起来没毛病,但跑了一段时间,三个老毛病越来越刺眼。

第一个毛病是参数幻觉。模型给出的query常常不是用户真实意图,而是它“猜”的关键词,多词组合经常带上多余的引号、括号,搜出来的东西牛头不对马嘴。还有一个高频问题:max_results这个参数,模型偶尔会编一个不存在的数字,比如37,工具如果不做边界钳制,下游解析直接炸。

第二个毛病是超时拖垮整轮对话。搜索请求平均耗时200到800毫秒,但遇到慢源或网络抖动,单个请求跑四五秒也是常事。在模型调用链里,这个延迟是用户可感知的“卡顿”,而且会占用大模型服务的worker线程,并发一高,整条链路雪崩。

第三个毛病是脏数据污染。搜索接口返回的东西,除了正常的摘要结果,还混着广告、验证页、无关的版权声明、甚至JS渲染失败后的空壳HTML。这些脏文本塞回上下文,模型容易被误导,回答质量忽高忽低。

1.2 拆出去之后,架构上发生了什么

所以这次重构的核心动作,是把“联网检索”从模型调用链里拆成三块:

  • 模型只生成结构化搜索意图,比如谓词化的query、时间范围、目标数量上限;
  • bl search web负责真实发送请求、处理超时重试、检查反爬、解析HTML;
  • 调用方拿到的是统一结构的条目列表,而不是原始HTML片段。

这个拆分的收益,用一句运维里常说的话概括:让搜索失败成为一个“可观测的异常”,而不是一次“静默的没搜到”。拆出去之后,模型进程的稳定性立刻改善,搜索慢也不会拖垮对话;同时我们自己可以在检索模块里做精细的退出码分类和错误上下文记录,而不是让模型面对一堆无法归因的原始报错。

注意:拆分的关键不是“把代码放到另一个文件”,而是“把失败模式放到另一个边界”。只要模型还要处理超时、解析、去重这类脏活,架构上就没有真正拆干净。

2. bl search web 的三层结构

整个组件我按职责分成三层,每层只做一件事,层与层之间用明确的接口对象传递数据。下面依次拆开讲。

2.1 第一层:意图接口层(模型说话的地方)

这一层是模型和bl search web对话的“入口”,核心是一个严格校验的请求对象。模型的输出并不直接成为HTTP参数,而是先经过这里的约束和转换。

请求对象的核心字段设计是这样的:

from pydantic import BaseModel, Field class SearchIntent(BaseModel): query: str = Field(..., min_length=1, max_length=200) max_results: int = Field(default=5, ge=1, le=20) freshness: str | None = Field(default=None, pattern=r"^(1d|1w|1m|1y)$") region: str | None = Field(default=None, max_length=8)

几个字段的选取逻辑,都是踩坑踩出来的:

  • max_results上下界直接钳在1到20。模型真的传999的时候,校验层就会拒绝,而不是带着一个离谱数字去请求外部接口。
  • freshness用枚举正则约束,只接受1d、1w、1m、1y,避免模型生成“三天内”这种无法直接映射的表达。
  • query强制去首尾空白、压缩连续空格,减少无效请求。

2.2 第二层:检索执行层(真正干活的地方)

这一层负责发HTTP请求、超时处理、重试、识别反爬验证页,是整个组件里最脏最累的部分。核心设计点有三个。

一个是统一超时控制。外部搜索接口的P95响应时间通常在三秒左右,所以默认请求超时定三秒,连接超时定一点五秒。超过阈值直接进入重试逻辑,而不是傻等。

import httpx TIMEOUT = httpx.Timeout(3.0, connect=1.5) async def fetch_search_page(intent: SearchIntent) -> str: params = { "q": intent.query, "num": intent.max_results, } if intent.freshness: params["tbs"] = f"qdr:{intent.freshness}" async with httpx.AsyncClient(timeout=TIMEOUT) as client: resp = await client.get(SEARCH_ENDPOINT, params=params) resp.raise_for_status() return resp.text

另一个是重试策略。对网络抖动类错误最多重试一次,重试间隔用指数退避,一点五倍起步;但对HTTP 403、429这类被限流的情况不重试,直接向上抛,因为重试只会雪上加霜。

第三个是反爬识别。搜索源偶尔会返回验证页或者“存在异常流量”的提示页,这类页面内容往往很短,HTML结构也和正常结果完全不同。执行层在拿到页面文本后会先做一个启发式嗅探:

def looks_like_challenge_page(html: str) -> bool: indicators = ["captcha", "unusual traffic", "verify", "enable javascript"] text = html[:2000].lower() return any(ind in text for ind in indicators)

如果命中,这一层会立刻短路并返回“检索源拒绝服务”的状态,而不是把验证页当结果解析。

2.3 第三层:结果归一化层(统一口径的地方)

不同搜索源返回的页面结构千差万别,这一层负责把所有源的结果转换成统一条目。归一化后的每条结果结构固定:

class SearchResultItem(BaseModel): title: str url: str snippet: str rank: int source: str

归一化过程不是简单地切HTML,而是按顺序做四件事:

  • 用选择器或正则抽取候选条目,通常先拿整个结果列表容器,再逐条提取标题、链接、摘要;
  • 过滤明显无效的条目,比如href="javascript:void(0)"、title为空、url不包含http开头;
  • 对snippet做长度钳制,默认截断到200字符,防止一块八百字的广告文案混进上下文;
  • 按rank排序并截取前max_results条,保证输出数量稳定。

这层还负责去重。同一个搜索词翻到第二页时,经常出现前几名域名完全相同的条目。归一化层里我加了一个基于域名和标题相似度的去重逻辑,避免模型上下文被重复信息刷屏。

提示:归一化层最大的坑是“解析成功但内容毫无价值”。我见过不少实现,正则匹配到一个很大范围的容器就认为解析成功,结果返回的snippet全是站点导航文字。建议加一道“燃料检查”——正常结果条目数量必须大于0,且有效snippet的平均长度超过30个字符,否则判定为解析异常。

3. 退出码设计:让调用方一眼看懂失败原因

3.1 退出码表

联网检索失败的原因很分散,如果只是抛一个笼统的异常,调用方(尤其是Agent的调度器)很难决定要不要换策略重试。所以我给bl search web设计了一套退出码,每个退出码不仅表示“失败”,还表示“该怎么处理”。

退出码含义调用方建议动作
0成功,返回结果正常使用结果
1参数错误修正请求对象,不重试
2检索源不可达短暂等待后重试,最多一次
3被限流或拒绝不重试,建议降级到缓存或等待期
4检索无结果可尝试改写query后重试一次
5解析归一化失败记录原始HTML,不重试
6内部状态异常重启组件检查配置

Python里用IntEnum定义这套退出码,让枚举值直接对上层可见:

from enum import IntEnum class SearchExitCode(IntEnum): SUCCESS = 0 INVALID_ARGUMENT = 1 SOURCE_UNAVAILABLE = 2 RATE_LIMITED = 3 NO_RESULTS = 4 NORMALIZE_FAILED = 5 INTERNAL_ERROR = 6

3.2 谁消费退出码

退出码的最大价值,是让上层调度逻辑可以针对不同退出码做差异化决策。

举一个实际场景:用户问“今天某款手机的最新报价”,模型生成搜索意图后调用bl search web。如果退出码是3(被限流),调度器不应该立刻重试,而应该直接告诉用户“暂时查不了”;如果退出码是4(无结果),调度器反而可以让模型改写query再搜一次。

退出码还会被写进结构化日志,方便统计。我后来在监控面板上直接按退出码分桶,一眼就能看出近期是限流多了(3)还是无结果多了(4),再做针对性优化,比看一堆异常栈直观得多。

3.3 配合退出码的错误上下文

只有退出码还不够,排障时还需要知道这次失败发生在哪个环节、消耗了多少时间、访问了哪个源。为此我在异常对象里附带了结构化上下文:

class SearchError(Exception): def __init__(self, exit_code: SearchExitCode, message: str, *, context: dict): super().__init__(message) self.exit_code = exit_code self.context = context

比如限流失败时,context里会带上retry_after字段;解析失败时,context里会带上原始HTML的前500个字符,方便后续分析。调用方捕获异常后,既可以根据exit_code做决策,又可以通过context快速定位。

4. 两个静默坑:最隐蔽的两类失败

这一节是重头戏。整个组件写完后,我做了大量失败注入测试,最惊讶的不是错误处理不够多,而是有两类问题在默认配置下竟然会“静默通过”,不报错、不提示,只是返回一个看起来合理的结果。

4.1 坑一:超时被吞掉,变成“空结果”

第一个坑藏得非常深。代码里超时后通常会让人想到抛异常,但在早期版本里,为了“不因为搜索失败而打扰用户”,我把超时异常默默吞掉,返回了一个空列表。

# 早期的问题版本 try: results = await execute_search(intent) except TimeoutError: results = [] # 想表达“没搜到”,实际吞掉了真故障

这个设计的后果很严重:模型拿到空列表,会认为“搜索执行成功但没有结果”,于是它照着这个逻辑一本正经地回答“当前没有相关信息”。用户看到的结果是:明明网络有问题,产品却告诉他“没有这条信息”。

排查的时候,日志里没有异常,只有一条“搜索完成,结果数量0”的记录。我还因此怀疑过搜索源的索引数据,折腾了两天才意识到真正的超时异常早就被吞了。

修正办法很简单:区分“无结果”和“搜索失败”,超时或不可达必须走SOURCE_UNAVAILABLE退出码,绝不能返回空列表。

# 修正版 try: results = await execute_search(intent) except TimeoutError as exc: raise SearchError( SearchExitCode.SOURCE_UNAVAILABLE, "search source timed out", context={"query": intent.query, "timeout": 3.0}, ) from exc

这个坑的教训是:功能追求“不打扰用户”本身没错,但语义完整性更重要。失败就是失败,把它伪装成“正常空结果”,等于给上层调用方埋了一个逻辑地雷。

4.2 坑二:HTTP 200 的假成功

第二个坑比第一个更隐蔽,也更气人。某一天测试“联网搜索”的时候,我发现搜索源返回了HTTP 200,页面也正常加载,但解析出来的结果数量一直是0,而且日志里没有任何异常。

把原始HTML拉下来一看,页面压根是一个“请开启JavaScript”的提示页,或者是验证页。换句话说,这次请求在HTTP层是成功的,但业务层什么都没给。

问题出在归一化层把“解析到0条结果”当成了NO_RESULTS,而不是解析失败。它安静地返回了一个空列表,又在语义上被当作“真实无结果”。

修复方式是加一道“解析置信度”校验:

def normalize(html: str, max_results: int) -> list[SearchResultItem]: items = extract_items(html) if len(items) == 0: # 不能直接返回空列表,先检查页面是不是正常结果页 if is_challenge_page(html) or len(html) < 2_000: raise SearchError( SearchExitCode.NORMALIZE_FAILED, "empty extraction from suspicious page", context={"html_preview": html[:500]}, ) return [] return items[:max_results]

这道校验的思路是:真正的“无结果”页面,通常结构完整,包含“没有找到相关结果”之类的提示文案;而验证页、空白页、脚本渲染加载页,要么内容极短,要么蕴含大量反爬特征。两者必须走不同分支。

后来我又在别的源上遇到一种变体:HTTP 200返回的是正常页面,但内容里混了一个巨大的推荐栏,真正的搜索结果被挤到N页以后。这种“假成功”同样逃过了数量检查,因为解析出来确实有几条结果。对付它只能靠字段级校验——比如要求snippet长度必须超过30字符、url不能是站内导航链接。

4.3 两个坑的共同教训

这两个静默坑虽然表现不同,但根因是同一个:把“请求完成”和“请求成功”混为一谈了。

  • HTTP请求发出去了,不等于外部服务正常;
  • 外部服务正常,不等于页面里有可用结果;
  • 页面里有可用结果,不等于结果格式能过归一化。

任何一步的“凑合”,最终都会变成模型回答里的错误信息。所以我在整个设计里强制约定:宁可让一个失败以明确的状态码炸出来,也不能让它吞成“看起来正常”的空结果。

5. 实测排查清单与避坑建议

5.1 快速排查速查表

如果你也在做类似的搜索模块拆分,遇到问题可以按下面这个顺序排查,能省很多时间:

现象可能原因排查动作
退出码2(源不可达)网络抖动或DNS解析失败curl同一个搜索源,对比响应时间
退出码3(被限流)请求频率过高或IP被风控看响应头里的Retry-After字段
结果始终为空但退出码0命中坑一,超时被吞检查是否捕获了TimeoutError
解析到0条但HTTP 200命中坑二,验证页/空白页打印原始HTML前2000字符,检查反爬特征
解析结果数量对但内容奇怪推荐栏占据解析区检查选择器是否命中推荐容器
模型回答与搜索内容无关上下文截断或脏数据检查snippet长度和去重逻辑

5.2 把静默失败变响亮的三步走

看过那两个坑之后,我总结了一套把“静默失败”转换成“响亮失败”的方法,任何类似组件都适用。

第一步是“给每个失败一个名字”,也就是退出码。无法归类的失败越少越好,我现在的组件里99%的失败都能映射到明确的退出码。

第二步是“给每次失败留下证据”。上下文信息记录是必须的,至少要有请求参数、耗时、来源、HTTP状态码、原始页面片段。这样即使某个退出码出现频率上升,也能快速定位是哪个源、哪种页面结构导致的。

第三步是“在关键路径上拒绝静默”。搜索模块对外提供的结果不能只是一个list,而应该是一个带状态的对象,比如SearchResult(status, items, meta)。status是ok还是error,调用方不看items也能知道整体是否成功。

class SearchOutcome(BaseModel): status: Literal["ok", "error"] exit_code: SearchExitCode items: list[SearchResultItem] = [] meta: dict = {}

这个设计让“空列表”不再有歧义——只有在status=ok且items=[]时,才表示“真的没有结果”。

5.3 常见问题与解决方案

最后整理几个我在开发和联调中经常踩的问题,都是已经验证过的解法。

问:搜索源返回的速度特别慢,三个请求同时打过来,组件会不会卡死?

答:我在检索执行层用了信号量控制并发,默认上限是5。超过上限的请求进入等待队列,同时每个请求的等待时间也计入超时预算。实测下来,并发8个模型请求时,最后一个请求的等待时间不会超过2秒,整体可控。

问:重试和限流冲突怎么办?

答:规则很简单——只有网络层错误才重试,应用层错误(限流、拒绝)一律不重试。区分方式是看异常类型和HTTP状态码。网络错误通常是连接失败、超时,应用层错误是401、403、429。重试策略里我也加入了“重试预算”,一个请求最多重试一次,防止雪崩。

问:如何处理搜索源的字段缺失?

答:归一化层不会因为某个字段缺失就整体失败。标题缺失时用URL域名补位,snippet缺失时用空字符串占位,但会在条目上打一个incomplete标记。这样做是为了保留尽量多的数据,同时不让半成品数据被当作完整结果使用。如果缺失字段占比超过一半,就会触发强制失败,因为这时候数据质量已经不可信了。

问:两次搜索间隔多久比较合适?

答:这取决于搜索源对频率的容忍度。我一般建议用令牌桶限制在每秒1个请求左右,避免对同一个源产生突发压力。连续同一query的重复搜索,可以加一个极简的内存缓存,TTL设置为5分钟,既不消耗配额,也不会让结果过度陈旧。

写在最后

如果你也在做Agent工具链,我个人的建议是:先别急着把搜索塞进模型的tool列表,花一点时间设计好检索模块自己的边界、退出码和错误语义。拆的过程里会遇到一堆“看似没什么大不了”的细节,比如超时后返回空列表还是抛出异常,比如解析到0条结果要不要报警——但恰恰是这些细节决定了整个链条是稳定还是时好时坏。

那几个静默坑让我最大的改变是:现在写任何工具模块,我都会先写一份“失败模式清单”,把每一种可能失败的情况和对应的对外表现列清楚,再开始写主流程。代码写起来反而快了,因为异常路径想清楚后,主路径会变得特别简单。bl search web后续我还在往里加两个能力,一是针对同一query的多源结果融合去重,二是更细粒度的内容质量打分,尽量把“搜到了”升级成“搜到了且值得看”。这个方向我觉得值得继续投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询