输出为什么每次不一样
同一个请求发两次,拿到两段不一样的回复——这是默认行为,不是 bug。但"默认如此"不等于"不用管":在抽取、分类、回归测试、审计这些场景里,它是必须被处理掉的变量;在创作、改写、生成候选这类场景里,它恰恰是要保留的东西。
要处理它,得先知道随机性是从哪一步进来的,以及为什么把温度调成 0 通常也解决不了。
1. 随机性从哪一步进来
模型每一步先算出一个词表上的分数分布(logits),然后:
温度缩放——把整个分布变尖或变平。温度趋近 0 时,最高分的词几乎必然被选中;温度高时,低分词也有机会。
top_p 截断——只保留累积概率达到 p 的最小候选集合,把长尾里那些几乎不可能但仍非零概率的词切掉。
采样——在剩下的候选里按概率抽一个。这一步是唯一的随机来源。
关键推论:一段几百 token 的输出是几百次这样的抽取累积出来的。即使每一步的随机性很小,累积偏差会随长度放大——这就是为什么短的分类输出很稳定,而长回复每次都不一样。
2. 温度设成 0 为什么还是不完全一致
常见误解是"temperature=0 就是确定的"。它是可用的最确定模式(贪心解码,每步取最高分),但不是逐字节保证。残余的方差来自三处:
GPU 浮点运算。 并行计算的顺序不总是完全一致,极小的数值差异就可能在两个概率非常接近的候选之间翻转选择。
动态批处理。 你的请求和哪些请求被批在一起,会轻微改变数值结果。同一个请求在不同负载下可能落到不同的批。
混合专家路由。 MoE 架构里由路由器决定哪些专家处理这个 token,路由本身会引入额外的方差。
所以可复现性有个明确的层级:温度 0 消掉采样方差 → 固定 seed 让随机流可重复 → 钉住模型版本才能挡住静默更新。三级都做了,才接近真正可复现。
3. seed 能做什么,不能做什么
seed 固定的是随机流:同样的模型、同样的输入、同样的温度、同样的 seed,理论上得到同样的输出。
它的限制很实际:
- 只在供应商支持时有效。有的 API 提供 seed 参数,有的不提供(不提供时只能靠温度 0)
- 只在同硬件、同推理服务版本下可靠。跨硬件或跨版本,浮点差异仍可能打破它
- 它是"尽力而为",不是硬保证。各家都明确不承诺跨请求、跨硬件一致;没有任何主流推理栈保证"seed 相同则输出逐字节相同"。设计上把它当降方差手段,别当确定性开关
开源推理栈(vLLM、llama.cpp、Ollama 一类)普遍支持 seed。真正需要逐字节可复现的场景,最大的杠杆其实是自托管开源权重 + 冻结推理栈——权重和运行时都能钉死,这是云 API 给不了的。
4. 模型版本是最大的变量,而它不在你手里
供应商会更新权重,gpt-4o-mini 这类名字是标签不是校验和。你以为在测同一个模型,周二可能已经换了一批张量。
对策只有一条:在配置里钉住模型版本,并且把实际服务的模型和版本记进日志。另外,OpenAI 的响应会带 system_fingerprint(构建指纹)字段,把它一起记下来——排查"结果突然变了"这类问题时,它是少数能证明"是模型变了还是你的代码变了"的证据。
如果前面还挂了网关,还要注意网关的降级逻辑:上游限流时自动切换供应商,模型就静默换了。这就是为什么降级必须返回"实际由谁服务"(见《统一接入层:AI 网关》)。
5. 请求可能没你想的那么一致
在怪模型之前,先证明请求是恒定的。多数"同样的提示词"其实不一样:
- 消息顺序不同:有的 SDK 会重排,有的不会
- 系统提示里插了动态内容:
datetime.now()、"当前用户是 xxx"——这类内容除了改变结果,还会顺带把提示缓存全部打掉(见《提示缓存怎么才命中》) - 检索结果的顺序在两次调用间不同:这是 RAG 场景最常见的原因,而且它同时说明你的重排不稳定
- SDK 默认注入的参数:老版本可能默认带一个很小的
max_tokens - 模板版本换了:聊天模板在不同库版本间会变
一个便宜有效的做法:把完整请求(URL、头、体、模型名、全部采样参数)序列化后算哈希,打进日志。两次调用哈希一样,才谈得上"同一个请求"。
def fingerprint(body, headers):
payload = {"body": body, "headers": {k.lower(): v for k, v in headers.items()}}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
6. 什么时候要确定性,什么时候不要
要的:结构化抽取、分类与路由、工具调用的参数生成、任何进 CI 的回归测试、审计与合规场景。这些场景里温度取 0(或很低)配合结构化输出,是标准做法。
不要的:创意写作、营销文案、头脑风暴、给用户多个候选。这些场景里确定性输出是负价值——三个一模一样的回复等于只有一个。
中间的:客服问答、摘要。通常 0.3 到 0.5,准确但不至于句句雷同。
一个有用的工程习惯:按工作流步骤分别设温度,而不是整个应用一个值。分类那步取 0,生成答复那步取 0.7,这两步的目标完全不同,用同一个值必然有一边是错的。
7. 只调一个参数
温度缩放整个分布,top_p 砍掉长尾候选,两者有交互。供应商普遍建议只主动调其中一个,另一个留默认。 两个同时大幅调整,效果难以预测,也让不同实验之间无法比较。
另外,温度很高(比如 2.0)又不加核过滤是常见的错误配置:分布被拉得太平,本来概率接近零的碎片词也拿到了被抽中的机会,输出会开始出现"前半句通顺后半句崩坏"的现象。
8. 用多次采样换稳定性
对高价值的推理任务,有一个不降温度也能提高正确率的做法:在同一个温度上采样多次,然后投票(自一致性)。它能在不换模型的前提下显著提升数学与逻辑类任务的准确率,代价是 N 倍成本。
这个方法只在"正确性比成本重要"的任务上值得用。判断标准:这一步错了会怎样?错了要人工返工,就值;错了用户刷新一次就行,就不值。
9. 回归测试怎么办
不要对完整长文本做精确匹配。 它必然 flaky,最后的结果通常是有人把断言删掉。
正确做法按性质分:
- 确定性的部分用代码断言:JSON 结构、必填字段、枚举值、工具名。这些在温度 0 下应当稳定
- 语义性的部分用判据而非字面匹配:包含哪些要点、不包含哪些禁用内容
- 每个用例跑 k 次看分布,而不是跑一次看通过(这一点《Agent 怎么评测》里讲得更细)
最后一条纪律:把采样参数当成代码的一部分纳入版本管理。温度散落在各处、改了没记录,是"明明没改代码结果却变了"这类问题最常见的根因。