模型名停用与分层降级:一次 LLM 链路的失败面设计
主题:LLM 链路的契约漂移与分层降级
先说结论
在 LLM 参与的链路里,"失败"是常态而不是异常;更隐蔽的是"契约漂移"------ 你一行代码没改,模型名、参数语义、响应格式已经变了。
别的地方怕的是 500;这一块最怕的是 200 ------ 但答案是错的、或者链路其实没生效。
我最终落到的结构是这样:
arduino
┌─ 契约层:把"模型侧的不确定"实测钉成契约(模型名/参数/响应格式)
LLM 链路 ────┼─ 降级层:六层降级,分界是"是否已经写给用户"
└─ 边界层:把模型当"不可信输入",工具只给只读能力
一、问题:生产依赖一个"已公告停用"的模型名
官方更新日志写明:旧的模型别名 deepseek-chat / deepseek-reasoner 将于某日停止使用。而现实是:
| 时点 | 事实 |
|---|---|
| 公告日 | 官方宣布旧别名"三个月后停用" |
| 名义停用日 | 实际未执行,兼容层仍在 |
| 项目某次提交 | 因为线上问题,把 chat 模型改成了这个旧别名 |
| 排查当天 | 实测仍 HTTP 200(可用),但这是"欠着的债" |
一旦兼容层撤销 :意图提取、重排、偏好提取、RAG、对比会全线降级(降级路径能兜住不 500,但 AI 功能等于失效)。
怎么把"生效值"钉死(这一步比结论重要)
- 别读文档也别读代码猜 ------ 解包生产容器里的 jar ,直接看
application.yml; - 确认 prod 配置没有覆盖这个段(否则读到的还是默认值);
git log找到是哪次提交改的;- 向模型服务商发最小请求 (
max_tokens=1,只读、成本可忽略)探测兼容层还剩多少。
第 4 步的附带发现是决定性的:
| 请求的 model | HTTP | 响应里回显的 model | 是否返回 reasoning 字段 |
|---|---|---|---|
deepseek-chat |
200 | deepseek-flash |
无 |
deepseek-v4-flash |
200 | deepseek-flash |
有 |
deepseek-reasoner |
200 | deepseek-flash |
有 |
deepseek-v4-pro |
200 | deepseek-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 链路要把"契约、降级、边界"三件事分开做:契约靠实测钉死,降级靠"是否已写给用户"分层,边界靠"只给只读能力 + 参数不可信"。
本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。