在一个动画语义搜索里,我有一个 reranker,用 LLM 给二十五个候选排序。它从第一天起就带着 temperature: 0,想要可复现的排序,这是显而易见的做法。我把模型换成一个推理模型,调用开始返回错误:那个模型拒绝 temperature。不是忽略,是拒绝。
修复不是删掉那一行。而是把它换成 reasoning_effort,提取成一个默认值为 low 的常量,并且在另一个调用同一个模型的地方也传上——一个合成系列作品设定的摄取脚本。三个文件、一个新环境变量,还有一份要更新的文档。就为了一个参数。
这种事一年半里发生了六次,每次形式都不一样。
四种坏法
收集了一圈之后,重复出现的永远是这四种:
- 消失的参数。 推理模型里的
temperature。没有精确的等价物:reasoning_effort不是同一回事,它是另一根杠杆。你不是迁移数值,你是重新决定一个。 - 请求体的形状。 把一个视觉解析器从 Chat Completions 换到 Responses API,不只是改个方法名。
messages变成input,{"type": "text"}变成input_text,image_url也不再是{url, detail}对象,而是一个字符串,detail成了同级的兄弟字段。 - 响应的形状。 在同一次迁移里,
response.choices[0].message.content变成了response.output_text。而且 token 计数不再是一个:现在有分开的input_tokens和output_tokens。这迫使函数签名从-> str改成-> tuple[str, int, int],并且在并行收集 future 时手工累加。 - 质量悄无声息地变了。 这个最糟,因为它不报错。模型有响应,JSON 校验通过,结果就是更差。
前三种你在第一次部署就能看到。第四种要吃上好几周。
OpenRouter 和 LangChain 只是把问题挪了个地方
合乎逻辑的反应是加一层抽象。我做过两次。
在一个转录 SaaS 里,我有一个 lib/claude.ts,里面的 processWithClaude(text, prompt) 直接调 Anthropic 的 SDK。我把它整个删掉,换成一个面向 OpenRouter 的 AIService 单例,modelName 和 temperature 作为参数从端点的 FormData 一路传过来。实际收益:换模型变成了在请求里发另一个字符串。
在一个带项目和上下文的聊天应用里,这一层是 LangChain,用 @langchain/anthropic 和 @langchain/google-genai,好让下拉菜单能按供应商分组。在那里我学到了另外一半:这一层自己也会坏。最初几天有个提交的名字就叫 "update dependencies for @langchain/anthropic and zod to latest versions"。在一堆会变的 API 之上的抽象里升级到 latest,是把一个你理解的问题换成一个你不理解的。
这些层做的事是真实但有限的:它们统一了调用的形状。它们不统一能力。如果模型 A 接受 temperature 而 B 不接受,这一层会让你把两个参数都传下去,错误照样会到你手里,只不过现在来自库的内部,栈跟踪多了三层。
产品做到一半换模型的代价
我把一个排班生成器的模型从 google/gemini-3-flash-preview 换成 deepseek/deepseek-v3.2 那天,diff 动了三个文件:app/api/generate/route.ts、app/api/adjust-schedule/route.ts 和 app/api/verify-checks/route.ts。同一个字面量,被复制到三个不同的 JSON.stringify 里。这不是供应商的错,是我的,而且这就是真实代价:当模型名嵌在调用点上时,迁移就是一次 find-and-replace,然后祈祷没漏掉一个。
在一个文档分析服务里,我试图通过把模型和成本计算集中到一个常量文件来解决这件事。代价是三周内三次重构:一次在 18 号,一次在第二天,之前还有一次。其中一个提交的名字还带着错别字,"reafactor constants",这挺能说明那个下午的状态。常量层没有一次成型,因为我在试图把两件不同的事塞进同一个结构里:该用哪个模型,以及它多贵。
我现在怎么隔离调用
没有仪式感。三条规则。
模型名永远不是调用点上的字面量。 永远是一个从环境变量读取、带默认值的常量,类似 RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini"。在生产环境换模型不该需要一次部署。
模型特有的参数跟着模型走。 如果一个模型需要 reasoning_effort 而另一个需要 temperature,那是模型的配置,不是函数的配置。一个模型一个对象,而不是一个散参数被拖过六个函数签名。
边界上永远要防御。 给 OpenAI 调用加超时的时候,我在 embedding 批处理里也加了这个:
if (embeddings.length !== input.length) {
throw new Error(
`Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
);
}
四行。它把向量和文本之间悄无声息的错位——会让你的数据库里塞满分配给错误动画的 embedding——变成一个立即抛出的错误。同一个提交里的 AbortSignal.timeout(30_000) 对挂起的调用做同样的事。
我认为哪些是稳定的
稳定:有一个 HTTP 端点,它接受 JSON,它返回 JSON。到此为止。
不稳定:模型名、采样参数、消息的形状、响应的形状、token 计数在哪里、价格,以及今天这个模型六个月后是否还存在。
我不为我在用的模型做设计。我为它消失的那天做设计。
