ICHIKI
Log

多模型 AI 客户端的「思考等级」适配层:一个抽象等级,翻译成十几家的字段

用户只选一个抽象等级,代码按「协议 + 模型名」把它翻译成各服务端真实的请求字段;识别不出模型就一个字段都不发。本文记录这套翻译层的家族映射表、三条识别铁律、四层兜底链路,以及为什么默认值是「关闭」。

约 9 分钟 AILLMreasoningAPI架构
关键词:LLM、reasoning_effort、thinking budget、Anthropic、GPT-5、GLM、豆包、模型适配

一句话设计原则

用户只选一个抽象等级,代码按「协议 + 模型名」把它翻译成各服务端真实的请求字段;识别不出模型就一个字段都不发,绝不去猜。

这条原则是整套设计的地基。下面所有看起来「保守」的取舍,都是它的推论。

用户侧只暴露一个抽象等级

7 个等级,存一个字符串就够:

等级含义
auto自动关闭(按模型名)—— 默认值
none不发送,用服务端默认
off关闭思考
low / medium / high三档强度
custom自定义 JSON

这里有个容易踩的语义坑:auto 的意图是「别思考」,实现上等于「识别到模型就发关闭取值,识别不到就什么都不发」。它不是「自动开启思考」。

所以 UI 文案必须写全,例如「自动关闭(按模型名)」。只写「自动」,用户几乎一定会理解成反的——而且这个误解不会立刻暴露,等到某天发现账单涨了才反应过来。

自定义 JSON 单独存一个 thinkingCustom 字段承载。

翻译层:协议 + 模型名 → 家族 → 字段

先判协议:protocol == "anthropic" 直接归 Anthropic 家族,其余按模型名匹配。

家族识别结果除了家族本身,还要带一个 supportsDisable 标记——这个模型能不能真的关掉思考,UI 靠它给警告。

然后是字段映射,这是整个适配层的核心表:

家族关闭档低 / 中 / 高
Anthropic 协议 · 原生 Claude(名含 claude / opus / sonnet / haiku)不发任何字段(不发即关闭)thinking={"type":"enabled","budget_tokens":1024/4096/16384}
Anthropic 协议 · 非 Claude(中转挂 DeepSeek / GLM)thinking={"type":"disabled"}同上
GPT-5 及更新reasoning_effort: minimalminimal / low / medium / high
OpenAI o 系(o1 / o3 …)reasoning_effort: low(没有 minimal,发 minimal 会 400)low / medium / high
Grokreasoning_effort: low只有 low / high,用户选 medium 时退到 low
智谱 GLM 4.5 – 5.1thinking={"type":"disabled"}thinking={"type":"enabled"}
智谱 GLM 5.2thinking.type = disabledthinking.type = enabled + reasoning_effort
智谱 GLM 5.3 及更新强制思考,只能降 effort:关闭 / 低 → low,中 → high,高 → max同左
智谱 GLM 4.7 / 4.5V强制思考且无档位可调 → 什么都不发同左
豆包(doubao / seed-N)、DeepSeek V4 及更新、MiniMax M3、小米 MiMo(自家接口)thinking={"type":"disabled"}thinking={"type":"enabled"}
通义千问、小米 MiMo(百炼托管,模型名带 xiaomi/ 前缀)enable_thinking: falseenable_thinking: true + thinking_budget: 1024/4096/16384
MiniMax M3.1-Flash-Preview不能关闭(发 disabled 会 400,且 effort 默认 max)→ reasoning_effort: lowlow / medium / high
未识别 / MiniMax M2.x / DeepSeek V3 及更早 / gpt-4o 类不发送不发送
MiniMax M2.x 是「接受但忽略该参数」,DeepSeek V3 及更早则靠模型名区分思考模式——两者都不该发,归到未识别一档处理。

Anthropic 协议上有两个反直觉的点

第一,关闭档「不发」才是关闭,但只对原生 Claude 成立。

原生 Claude 在关闭档不发任何字段,这个协议里不发就是关闭。但中转服务经常把 DeepSeek / GLM 挂在 Anthropic 兼容端点上,那里只有显式发 thinking={"type":"disabled"} 才真的关。所以非 Claude 模型走 Anthropic 协议时,关闭档要显式发一次。

第二,这个协议没有档位字段。

低 / 中 / 高只能靠 token 预算表达强弱:

{"thinking": {"type": "enabled", "budget_tokens": 4096}}

三档分别是 1024 / 4096 / 16384。

温度那个坑

Anthropic 开启 extended thinking 时,temperature 只能是 1。

于是判断「是否开启思考」时不能只看请求体里有没有 thinking 字段——关闭档也会发 thinking(值为 disabled)。必须看 thinking.type == "enabled"。

否则关闭档的温度会被错误地顶成 1。这类问题不会报错,只会让输出变得莫名其妙。

三条识别铁律

铁律一:用「品牌 + 版本门槛」,不要枚举版本号

写成 glm-4.5 || glm-4.6 || glm-4.7 的映射表,会在 GLM 4.8 发布的那天静默失效——不报错,只是悄悄退回「不发送」。

正确做法是写成「glm ≥ 4.5」。实现一个 versionAtLeast(name, keyword, major, minor),并且要能吃下这些写法:

gpt-5
gpt5
openai/gpt-5.1
deepseek-v4-flash

铁律二:不要裸匹配品牌

裸匹配 gpt 会把 gpt-4o、gpt-4.1 也圈进来,而它们不支持 reasoning_effort——发过去直接 400。

发错字段比不发送糟得多,所以这类必须排除干净。

o 系同理,要用带边界的正则,比如 (^|[/\-_.])o\d。不然 gpt-4o 里的 4o 会被当成 o 系。

铁律三:认不出就什么都不发

这个方向是有意选的:

  • 不发送 → 拿服务端默认值,最坏情况是它仍然思考、多花点 token;
  • 发错字段 → 请求直接 400,功能彻底不可用。

两害相权,宁可少发,也不猜。

边界情形要专门写用例覆盖:gpt-oss、glm-z1、deepseek-r1 这类「关键词后面不是版本号」的,必须判为未识别。

四层兜底

1. 失败降级重试 + 成功才写回

捕获 API 异常后,先判断它是不是「服务端不认我们发的思考参数」。要求同时出现两类关键词:

  • 参数名:thinking / reasoning_effort / enable_thinking / 思考 / 思维链
  • 否定词:unrecognized / unknown / unsupported / not permitted / unexpected / invalid / requires / input should be / literal_error / not a valid / 不支持 / 始终思考 / 不能关闭

命中 → 用 none(不发送)重试 → 重试成功之后才把该等级永久写回设置,并 toast 告知用户。

「同时出现」这个约束是为了避免把 401 / 429 / 模型不存在误判成参数问题——否则会把本来好好的配置改坏。

「先重试、成功才写回」是硬要求:失败时留下坏配置,会让之后每一次生成都失败。

2. 设置页可核对

提供一个 describe(config),返回一句人话,展示「识别到的模型家族 | 当前等级 | 实际将发送的字段」。

未识别时明说「将不发送」;关不掉的模型要写清楚:「该模型不支持关闭思考,关闭档只会落到最低档;要彻底不思考只能换模型」。

3. 外部能力表只做解释,不拼参数

可以拉 models.dev(https://models.dev/api.json)这类能力表,在未识别时补一句「该模型不能关闭思考,只能调强度(low / high)」。

但这类表没有请求字段名,拼不出参数,所以不得参与拼装。而且拉取 / 解析 / 匹配任何一步失败都静默降级(超时兜住、只尝试一次),绝不能影响主功能。

4. 自定义 JSON 逃生舱

内置表认不出的模型,允许用户直接填一段 JSON 合并进请求体。解析失败 → 不发送,并在 UI 侧给出解析错误提示,不要等请求时才炸。

另外,每次请求都打一条日志:模型名、等级、实际发送的字段(或 (none))。思考字段是最容易被服务端静默忽略或拒绝的地方,不留痕就没法排查。

为什么默认值是「关闭」

不要建议默认打开思考。三个理由:

  1. 这类任务(按规则查表、模式匹配)的决策步骤已经全部写进 prompt 了,思考的边际收益很低;
  2. 思考内容和正文抢同一个 max_tokens。开思考会加剧截断,而截断的 JSON 直接解析失败——这比「规则质量差一点」致命得多;
  3. 用户能手动点选目标节点,已经把「到底是哪个元素」这个最大的歧义消掉了。

还有一句必须记住:「不发送」≠「思考关闭」。

有些模型根本关不掉,只能降 effort。所以对外只能承诺「不发那个字段」,不能承诺「零思考」。

落地清单

  1. 纯函数翻译层(单独一个 object / 模块):familyOf(model, protocol)、buildParams(config)、describe(config)、validateCustom(text)、isThinkingRejection(e)
  2. 配置里加 thinkingLevel(默认 auto)+ thinkingCustom
  3. 请求体拼装处把 buildParams 的结果合并进去(mergeParams),空 Map 就什么都不动
  4. 调用点接入降级重试与写回
  5. 设置页:等级下拉 + 自定义 JSON 输入框 + 实时展示 describe 结果 + JSON 校验提示
  6. 表驱动单测

单测至少覆盖四类:

  • 每个家族的分档取值,含「没有该档位时退到低档」的情况;
  • 误判防护:gpt-4o / gpt-4.1-mini / glm-4-flash / deepseek-r1 必须判为「未识别」;
  • 新版本自动识别:gpt-6、openai/gpt-7.2-codex、glm-6 不改代码就能认出;
  • 错误文案识别:Unrecognized request argument: reasoning_effort、unknown field "thinking"、智谱中文原文「该模型始终思考,不支持关闭思考」要判为参数问题;而 401 / 429 / model not found 必须判为不是。

小结

这套适配层里,真正难的不是那张映射表,而是边界:

  • 认不出的模型怎么办 → 不发;
  • 发错了怎么办 → 降级重试,且成功才写回;
  • 关不掉的模型怎么跟用户交代 → 说清「只能降档」,不承诺「已关闭」。

把这些边界写清楚之后,映射表反而是最容易维护的部分——因为它只由「品牌 + 版本门槛」驱动,新模型发布不用改代码。