模型名停用与分层降级:一次 LLM 链路的失败面设计

0 阅读8分钟

模型名停用与分层降级:一次 LLM 链路的失败面设计

主题:LLM 链路的契约漂移与分层降级

先说结论

在 LLM 参与的链路里,"失败"是常态而不是异常;更隐蔽的是"契约漂移"—— 你一行代码没改,模型名、参数语义、响应格式已经变了。

别的地方怕的是 500;这一块最怕的是 200 —— 但答案是错的、或者链路其实没生效。

我最终落到的结构是这样:

             ┌─ 契约层:把"模型侧的不确定"实测钉成契约(模型名/参数/响应格式)
LLM 链路 ────┼─ 降级层:六层降级,分界是"是否已经写给用户"
             └─ 边界层:把模型当"不可信输入",工具只给只读能力

一、问题:生产依赖一个"已公告停用"的模型名

官方更新日志写明:旧的模型别名 deepseek-chat / deepseek-reasoner 将于某日停止使用。而现实是:

时点事实
公告日官方宣布旧别名"三个月后停用"
名义停用日实际未执行,兼容层仍在
项目某次提交因为线上问题,把 chat 模型改成了这个旧别名
排查当天实测仍 HTTP 200(可用),但这是"欠着的债"

一旦兼容层撤销:意图提取、重排、偏好提取、RAG、对比会全线降级(降级路径能兜住不 500,但 AI 功能等于失效)。

怎么把"生效值"钉死(这一步比结论重要)

  1. 别读文档也别读代码猜 —— 解包生产容器里的 jar,直接看 application.yml;
  2. 确认 prod 配置没有覆盖这个段(否则读到的还是默认值);
  3. git log 找到是哪次提交改的;
  4. 向模型服务商发最小请求(max_tokens=1,只读、成本可忽略)探测兼容层还剩多少。

第 4 步的附带发现是决定性的:

请求的 modelHTTP响应里回显的 model是否返回 reasoning 字段
deepseek-chat200deepseek-flash无
deepseek-v4-flash200deepseek-flash有
deepseek-reasoner200deepseek-flash有
deepseek-v4-pro200deepseek-v4-pro有

⇒ 两个别名底层已经是同一个模型,差异只来自"思考模式开/关"。这直接指向:应该用官方显式的思考开关,而不是靠换模型名。

⚠️ GET /models 不是权威清单:它不含那两个旧别名,但两者都能调。不能据它判断可用性 —— 这条如果不实测,很容易得出反向结论。

根因(历史上为什么这么改):推理型模型对"重排 / 意图提取"这类 JSON 小任务过度思考,且思考量随 max_tokens 水涨船高(实测 max_tokens=1000 就想满 1000,4000 就想满 4000)→ 思考吃满预算 → content 为空或被截断 → JSON 解析失败 → 降级。当时用"换模型名"间接控制思考开关,方向对但手段过时。


二、方案:把契约实测成"硬约束"

改造本身不复杂(用官方思考开关),值钱的是实测出来的几条契约:

  • 工具轮与 JSON 输出模式互斥 —— 工具轮上不能同时要求结构化输出;
  • 思考模式必须回传推理内容 —— 不回传直接 400;
  • 工具轮不需要长思考 —— 工具选择是结构化决策,一律关掉思考;
  • 流式下的工具调用分片 —— arguments 会被切成十几段,必须按 index 拼接连缀;同一次响应里还会先来一批正文分片(preamble)再来工具调用;
  • 强制工具调用是支持的 —— 但只对"商品意图"用:永远强制会让"你好"也去检索。

改完用路由日志确认"恰好路由 1 次"(防双调用/双 bean)。


三、⭐ 更深一层的认知:降级的分界是"是否已经写给用户"

这是整篇设计里最值钱的一段 —— 六层降级,每一层都有明确的触发条件与用户可见行为:

层触发行为用户看到
① 工具级工具异常 / 未知工具 / 参数非法转成可读 observation 回灌,循环继续正常回答(换个方式或如实说)
② 未写出内容就失败首轮调用异常(流式:尚未写出任何分片)降级到旧的固定流水线与升级前一致
③ 已写出内容后失败流式回合中途断流如实报错收尾,绝不降级error + done(不会两段回答拼接)
④ 轮数用尽模型一直调工具摘掉工具,追加"请立即作答"再问一次正常回答
⑤ 预算超限当日成本超限入口直接拒绝"服务繁忙"
⑥ 空答复收敛轮正文为空再问一次;仍空 → 降级话术降级文案(不返回空)

💡 ②③ 的分界是核心:"已经写给用户的东西不能装没发生" —— 所以必须在写第一个字节之前决定"这条链路还能不能降级"。 为此流式路径专门缓冲正文,并把商品事件排在第一个正文分片之前。这不是优化,是降级能力的前提。

配套的边界设计(把模型当不可信输入,而不是当函数):

  • 工具权限最小化:只暴露 3 个只读工具,写操作(加购/下单/退款)一个都不给 —— 不给 AI 犯错的机会,比事后审计便宜;
  • 参数一律在工具内收敛(白名单/截断/类型),因为模型输出是自然语言;实测模型编造的 id 会被工具优雅拒绝,循环继续不炸;
  • 轮数上限 + 成本记账每轮都做(Agent 是"多次调用",成本约为旧流水线的 2 倍);
  • 结果真实性:只把工具结果拼进上下文 —— 让"编造"没有素材。

四、⭐ 顺带挖出的三个"死的"东西

改这一类问题时,一定会挖出**"设了但没用"的东西。它们不报错,但会误导后来人**:

类型实例为什么是"死的"
死参数采样温度官方说思考模式下温度不生效(设了不报错)→ 一直是死参数
死配置对比模型配置项全仓 grep 只命中定义处与配置文件各一次 → 零引用(实际用的是 chat 模型)
死代码LLM 客户端上的 embedding 方法没有任何注入点使用(真正走的是另一个 embedding 客户端);而那套代码打的是 A 家的地址 + B 家的模型 + A 家的 key → 三样对不上,真被调用必然失败

五、另一条线索:4 条"不崩但不对"的生产踩坑

#现象根因处置
1部署后立刻打接口 503 No servers available固定 sleep 早于实际启动(启动+注册 44 秒),正好卡在**"已反注册、还没重注册"的空窗**改成轮询启动日志判据,不靠固定睡眠
2偶发 500(鉴权异常 + "响应已提交")⭐ 用定量对照定性:客户端 18/18 全成功,但日志仍出现鉴权拒绝 ⇒ 它是内部 ERROR 派发的次生现象:请求先失败(典型是 SSE 流)→ 容器转 /error → 安全过滤器链在 ERROR 派发上又跑一遍,而 JWT 过滤器默认跳过 ERROR 派发 → 匿名 → 拒绝 /error登记待办:放行 ERROR 派发类型(公共配置,需单独窗口 + 全服务回归)
3回答正确但商品列表是空的模型遇到与历史相似的问题时直接引用历史商品作答,本轮没调工具 → 没有命中数据提示词加规则 + 首轮强制工具调用
4库存回答张冠李戴工具的 observation 没带商品名 → 模型只能靠猜的 id 自圆其说工具返回里增加商品名自报家门,查不到直接报错

六、踩坑清单

认知 / 坑说明
🔑 判"某配置的生效值"必须同时查 yml 与代码默认值只看代码默认值会得出错误结论(我自己第一版就判错了)—— 配置文件才是生效源
🔑 停用模型名不会立刻报错兼容期内一切正常 ⇒ 靠"调用点清单 + 定期实测"发现,别等 4xx
⚠️ /models 类接口不是权威清单它可能不列某些仍可用的别名
⚠️ 看到 ERROR 先别改代码用"客户端成功率 + 派发类型"定性;本例真病灶在"失败后转 /error"
⚠️ 判"服务起来了"用日志判据 + 外部探活启动几十秒,固定 sleep 会踩 503 空窗;判据要按服务自适应(网关主类名可能不带 Application)
⚠️ 测试要离线正确性在"循环控制/事件顺序/降级策略",与真实模型无关;用脚本化假件才能主动制造"写一半就失败"
🔑 降级必须在"写第一个字节之前"决策流式一旦写出就不能收回

七、一句总结

LLM 链路要把"契约、降级、边界"三件事分开做:契约靠实测钉死,降级靠"是否已写给用户"分层,边界靠"只给只读能力 + 参数不可信"。


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