慢 ≠ 挂:AI 搜索降级判定的设计,与一次 reasoning 模型排查复盘

慢 ≠ 挂:AI 搜索降级判定的设计,与一次 reasoning 模型排查复盘

主题:搜索双索引与降级分层

先说结论

这篇讲两件事:

  1. 降级必须分层 ------ 只有独立进程才是真降级;而且**"慢"绝不能当成"挂"**
  2. 一次完整的模型排查复盘 ------ 从"以为超时"到"定位到模型行为",中间走了两条弯路

一、架构债:同一份数据两个写入者,必然漂移

现象

一个项目里有两个搜索服务,各维护一个 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,才把问题锁死。完整因果链(全部实测):

  1. 该模型是 reasoning(思考)模型 ,响应 = reasoning_content(思考)+ content(答案)
  2. 它对重排 / 意图提取这类 JSON 小任务过度思考 ,且思考量随 max_tokens 水涨船高 (实测:max_tokens=1000 → 想满 1000;=4000 → 想满 4000;=2000 → 想 ~1630)
  3. 思考占满预算 → content 为空或被截断 (日志铁证:reasoning_tokens = max_tokens)
  4. 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(它本来就是兜底路径)
🔑 "架构叙事自洽"是硬约束 架构必须长成设计文档里讲的样子

六、一句总结

降级设计的难点不在"怎么降",而在"什么时候该降"。 把"慢"当"挂",比不降级更糟。


本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。

相关推荐
UaoN1 小时前
作为全栈开发者,为什么看到 Shopify 回归原生,我依然选择 React Native
前端·后端
PBitW2 小时前
行内编辑,前端如何兜底?😭😭😭
前端·面试
小羊没烦恼!2 小时前
C#综合揭秘——Entity Framework 并发处理详解
java·开发语言·前端·javascript·数据库·mysql·c#
wing982 小时前
人生第一个私活:不到 2 小时,到手 350 元
前端·后端·程序员
张张123y2 小时前
后端第十二课:Agent 开发(让大模型从“会答“升级到“会做“)
前端·大模型·llm·agent·rag
A小码哥3 小时前
大模型成本怎么降,5 个动作先做前两个
java·前端·spring
PHP是世界上最好的语言3 小时前
ComPDF预览本地正常,部署后白屏:检查资源、路径和许可
前端
kiros_wang3 小时前
项目分包方案:模块拆分、跨模块组件与方法引用规范
前端
PedroQue993 小时前
unix-utils v0.2.0 发布:modal 全端统一,自绘窗口解决输入框难题
前端·uni-app