慢 ≠ 挂:AI 搜索降级判定的设计,与一次 reasoning 模型排查复盘
主题:搜索双索引与降级分层
先说结论
这篇讲两件事:
- 降级必须分层 ------ 只有独立进程才是真降级;而且**"慢"绝不能当成"挂"**
- 一次完整的模型排查复盘 ------ 从"以为超时"到"定位到模型行为",中间走了两条弯路
一、架构债:同一份数据两个写入者,必然漂移
现象
一个项目里有两个搜索服务,各维护一个 ES 索引、各一套同步链路 → 数据不一致(实测 A 索引 20 条 vs B 索引 18 条)。
但复核发现了三个反直觉的事实
原记录说"普通搜索查不到新商品 = 用户可见 bug"。我用代码 + 服务器双重实测推翻了这个判断:
| 证据 | 内容 |
|---|---|
| ① 前端早已全走 AI 搜索 | 所有调用都打 /ai/**;旧搜索的 api 文件里没有一个 /search/** 调用 |
| ② 旧搜索服务零真实流量 | nginx 24 小时 /api/search = 0 条(同窗口全是扫描器噪音) |
| ③ 它是孤立遗留 | 零反向依赖、零跨模块 import、不提供任何 Dubbo 服务 、类上标着 @Deprecated |
而真正的问题比原记载更深 :同步模型只 upsert 不 delete + 删除/下架/审核不触发同步 + 写入不过滤业务状态 → 已下架商品残留在索引里能被搜到。
⭐ 复核的价值在于:既纠正了严重性判断,又发现了更深的问题。
方案选择:保留(改造)还是退役?
最终我们选了保留并改造为"只读降级层"。最关键的理由:
⭐ 进程级降级才是真降级。 AI 服务内部 的降级代码和它同进程 ------ 进程 OOM/崩溃时,"降级"也不存在。只有独立进程才能覆盖进程级故障。
第二条理由是架构叙事自洽:文档里已经定稿"普通搜索是召回地基、AI 是重排增强",那代码架构就必须长成那个样子。
第三条:未来升级 AI 框架只动 AI 服务内部,搜索独立则升级零波及。
改造后 :统一数据源(单一索引 + 单写链路)→ 旧服务从"自建索引的写入者"变成"只读统一索引的查询者" → 无写链路 = 无漂移。
退役方案我保留着没删 ------ 它是决策树的完整证据。
二、⭐ 降级判定:慢 ≠ 挂(本文最值钱的部分)
这是我一开始做错、被纠正后最有收获的地方。
错误做法
js
// ❌ 原实现:catch 所有错误 → 一律降级
try { aiSearch() } catch (e) { fallbackSearch() }
问题 :LLM 语义重排本来就要数秒~十几秒 ,这是正常等待。把"慢"当成"挂",用户会莫名其妙看到降级结果。
正确做法:只认确定性故障信号
| 信号 | 是否降级 | 理由 |
|---|---|---|
| HTTP 5xx(503=服务不可达 / 500=后端失败) | ✅ 降级 | 确定性故障 |
| 网络层错误(连接拒绝等) | ✅ 降级 | 进程真挂 |
| 4xx(参数错/未登录) | ❌ 不静默降级 | 会掩盖真实问题 |
| 响应慢(5~15 秒) | ❌ 不降级 | 这是正常等待 |
配套的工程细节(决定"降级是特性还是事故")
| 细节 | 做法 |
|---|---|
| 超时要留足余量 | 前端 axios 对 AI 请求给 30s (远大于后端 10s 的 LLM 超时)→ 正常慢响应根本不会触发降级;真能撑到 30s 超时的,说明确实卡死了 |
| 错误类型要能识别 | 在请求拦截器补 errType(timeout / network / http)+ status 元数据,供调用方精准判定(原来的 new Error(msg) 把类型丢了) |
| UI 要区分 | 提示"AI 响应超时" vs "AI 搜索暂不可用",而不是静默切换 ------ 用户要知道发生了什么 |
| 降级请求不弹错 | AI 请求设 silent ------ 降级是预期路径,不该报错;仅两条通道都失败才由外层提示 |
| 竞态守卫 | 快速翻页/切词时,旧慢请求可能晚到覆盖新结果 → 加请求序号守卫(过期请求不写回、不报错、不清 loading) |
三、排查复盘:从"以为超时"到"模型过度思考"
这是完整的一条排查链,中间走了两条弯路。
现象
AI 重排偶发降级 / 超时。
弯路一:以为超时不够
最早看到的表象是 18s 超时 → 把后端 timeout 15s → 60s。
结果 :只是延缓症状。
⭐ 教训:表象(超时)≠ 根因(模型行为),调超时只是把问题往后推。
弯路二:以为 max_tokens 太小
既然是"思考把预算吃满",那就给更多预算 → max_tokens 2000 → 4000。
结果 :更糟 ------ 给它更多预算,它想得更久。
定位:直连 API 二分
绕过应用直接调 API,才把问题锁死。完整因果链(全部实测):
- 该模型是 reasoning(思考)模型 ,响应 =
reasoning_content(思考)+content(答案) - 它对重排 / 意图提取这类 JSON 小任务过度思考 ,且思考量随
max_tokens水涨船高 (实测:max_tokens=1000→ 想满 1000;=4000→ 想满 4000;=2000→ 想 ~1630) - 思考占满预算 →
content为空或被截断 (日志铁证:reasoning_tokens = max_tokens) - JSON 解析得到 null / 报错 → 降级 → 前端提示"AI 响应超时"
正解:按任务类型做模型分工
| 任务类型 | 用什么 | 理由 |
|---|---|---|
| JSON 结构化(重排 / 意图提取 / 查询扩展) | 非推理模型 | 实测 0.5-0.8s 稳定返回完整 JSON、无 reasoning、永不截断 |
| 需要深度推理(SSE 流式对话) | 推理模型 | 真正的推理场景 |
为什么不能靠"提示词叫它别思考" :试过了 ------ reasoning 收敛到几百 token,但仍会偶发截断(content 被 cut 在字符串中间)。
⭐ 推理模型的三种误用 : ① 它适合"深度推理问答",不适合"快 + 稳 + 结构化输出" ------ 选模型先想任务类型 ② 推理量随预算膨胀是设计特性,不能靠加预算解决 ③ 排查"偶发失败"要连测多次看统计,单次成功会误导
四、插曲:真凶藏在 IDE 里
排查中还有一个 401 问题,最后发现根因是 IDE 的 Run Configuration 里残留了一个旧 key ------ 它覆盖了一切(重启 IDE 也不变)。
环境变量优先级(记住这张表):
arduino
IDE Run Configuration > 系统/用户变量 > spring.config.import(.env) > yml 占位符默认值
排查顺序 :先 curl 直连 验证 key + model(排除 key 本身)→ 再加临时脱敏日志 看运行时实际用的值 → 别猜。
五、踩坑清单
| 认知 / 坑 | 说明 |
|---|---|
| ⭐ 慢 ≠ 挂 | 只认确定性故障信号;4xx 不静默降级 |
| ⭐ 进程级降级才是真降级 | 同进程内的降级代码,在进程崩溃时也不存在 |
| ⭐ 同步模型必须"全" | 只 upsert 不 delete = 残留脏数据能被搜到 |
| ⭐ 推理模型不适合"快+稳+结构化输出" | 且推理量随预算膨胀,加预算反而更糟 |
| ⚠️ 表象 ≠ 根因 | "超时"是表象,"模型过度思考"才是根因 |
| ⚠️ 辅助请求要有 silent | 辅助接口失败不该弹全局错误提示 |
| ⚠️ 降级通道自身要兜底 | 查询异常时返回空结果优于 500(它本来就是兜底路径) |
| 🔑 "架构叙事自洽"是硬约束 | 架构必须长成设计文档里讲的样子 |
六、一句总结
降级设计的难点不在"怎么降",而在"什么时候该降"。 把"慢"当"挂",比不降级更糟。
本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。