升 Fable 5.1 踩了两天,三个会直接 400 的地方记一下

model ID 换成 claude-fable-5-1,就改了一行,然后服务开始报 400。查了两天,三个原因,都不是我代码写错,是这个版本本身不支持了。写下来省得后面的人再踩。

一、tool_choice 不能再强制了

以前为了拿稳定的 JSON,我习惯把 tool_choice 设成 any,或者直接点名某个工具。5.1 上这两种写法直接 400,报错说 type tool 和 any 在这个模型上不支持。

现在只剩 auto 和 none 能用。官方给的理由是这版思考常开,强制调工具会跳过思考,模型就把推理过程写进参数里,参数质量反而更差。

我的改法:tool_choice 保持 auto,schema 那边开 strict,然后在提示词里明确写清楚什么情况下必须调哪个工具。实测它是听话的,几百次调用下来没出现该调不调的情况。要是只想要结构化输出,直接走 structured outputs 更干净。

补一句,token counting 那个接口也是同一套校验,别以为只有正式请求会拦,我就是在那儿先撞的。

二、思考块只能往上兼容

现在每个思考块都记着是哪个模型生成的,兼容方向是单向的:5.1 能读旧模型的块,旧模型读不了 5.1 的。

我这边有个路由,遇到限流会自动降级到别的模型。以前一直没事,换 5.1 之后降级那一段的推理全丢了,回答质量肉眼可见地掉。麻烦的是它不报错,API 会把读不了的块直接丢掉,既不计费也不提示。

想知道到底丢没丢,得带 thinking-binding-controls-2026-08-01 这个 beta 头,丢弃动作会出现在 input_transformations 里。不带就是无声的。我是加了这个头跑了一晚上日志才看出问题。

三、改历史等于作废后面所有思考块

这个坑最深。规则是只要动了思考块前面的任何东西,后面的块全失效。重建系统提示、重新拼 tools 数组、往早先的消息里塞一行提醒然后下一轮删掉,全算改。

我的代码正好干了第三件事:每轮往历史里注入一句当前时间和任务状态,下一轮再替换掉。以前完全没问题,5.1 上第二轮就 400,报的是 block is bound to a different conversation。

改法是官方那套:per-turn 的提醒改成 turn-scoped 系统消息,设 clear_at 为 next_user_message,消息留在数组里原样发回去,过了这一轮就不渲染,缓存也不断。tools 要改就用会话中工具变更,别去重拼数组。要裁上下文就用服务端的 context editing 或者 compaction,那两个不算改历史。

还有个细节挺阴的:这个校验只对 2026 年 8 月 31 号之后新建的账号强制生效。老账号默认只记录不拦截,除非你自己设了 prefix_mismatch_behavior。所以你在老账号上测没事,不代表上线没事。

排查建议

如果你的 messages 数组是自己拼的,别直接切过去。先把 prefix_mismatch_behavior 设成 drop_block 跑一轮,把 input_transformations 打进日志,看有没有块被丢。有就说明你的代码在改历史,先改干净再切。

用官方 SDK、Claude Code 或者托管那一套的可以跳过这一节,前缀是帮你保住的。

第三条我们也中了,原因更蠢:system 里带了个时间戳,每次请求重新生成一遍,等于每一轮都在改前缀。改成固定文案、把时间放到 user 消息里之后就好了。排查的时候完全没往这想。

strict 加上之后,参数质量真能顶掉原来 forced 的效果吗?我们对格式要求比较死,之前就是靠强制点名工具来保证一定出结构化结果的。担心换成 auto 之后偶尔它就用文本回你了。

无声丢弃这个设计我是不太能接受。降级路由是很普遍的做法,默认既不报错也不提示,出了问题只能靠肉眼看质量下降来发现。这种应该默认就写进响应里,而不是藏在一个 beta 头后面。

请教一下,turn-scoped 的那个系统消息,发回去之后是不是就不占 token 了?我看你说过了那一轮就不渲染,但它还留在数组里,这块到底计不计费没太看懂。

老账号不拦截这点我也吃了亏。测试环境用的是几年前的老号,生产是上个月新开的,上线当天全线 400,回滚回去才反应过来是账号创建日期的差别。这个真的应该写在迁移文档第一行。