温度设为0仍不能保证模型输出完全一致,浮点运算、批处理、硬件内核及服务端变化都可能引入差异。大模型测试应围绕结构、约束和语义设计断言,而非依赖重试通过的精确字符串比较。
书籍:AI That Answers
系列:AI in TypeScript——共 5 本书,涵盖从第一次调用 LLM 到在生产环境中部署 Agent——五本都在这里。
我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 和其他 AI 编程工具交付产品的开发者 IDE。
关于我:xgabriel.com | GitHub
你把 temperature 设置为 0,写了一个断言精确字符串的测试,而且测试通过了。接下来连续五十次也都通过了。然后某个星期四,它在 CI 中失败了,重新运行却又通过,于是现在有人在测试配置里加上了 retry: 3。
这个重试配置,是你对未来的自己撒的一个谎。temperature: 0 可以减少变化,但无法带来确定性。如果测试套件建立在“它能保证确定性”这一假设之上,最终只会让人们不再信任这套测试。
Temperature 会在采样前对 logits 进行缩放。当它为 0 时,采样器每次都会选择概率最高的 token——这就是贪心解码(greedy decoding)。
Top-P(核采样,nucleus sampling)会把候选集合限制为一组最小的 token,使其概率总和达到 P,然后在这个集合内进行采样。当 top_p: 1 时,不会排除任何 token。
它们会组合生效,而同时设置两者正是人们容易困惑的地方。当 temperature: 0 时,Top-P 基本无关紧要——因为贪心解码已经会选择唯一一个概率最高的 token。把 top_p: 0.9 和 temperature: 0 放在一起并没有错,只是它不会产生任何你能够合理推断的作用。
只选一个调节手段。对于任何希望保持稳定的场景,将 temperature 设为 0,不要动 Top-P。
const params = {
model: "claude-opus-5",
max_tokens: 1024,
temperature: 0, // greedy
messages,
};
即使 temperature 为 0,两次完全相同的请求也可能产生不同结果,而原因全都发生在你的 API 调用之下。
浮点运算不满足结合律。在浮点运算中,(a + b) + c 并不总是等于 a + (b + c)。批量推理会按照批处理实际产生的顺序进行求和,因此,如果某一步概率最高的两个候选 token 极为接近,仅仅一次舍入差异就可能让结果发生翻转。你的请求只要和不同的相邻请求一起被批处理,就足以造成这种差异。
硬件与 kernel 差异。不同代际的 GPU、不同的 kernel 选择、不同的张量并行拆分方式,都会改变算术运算的顺序。
模型更新。模型别名指向的版本会发生变化。如果提供商支持固定带日期的版本,那么锁定该版本就能消除这一因素。使用未固定的别名,意味着你的“确定性”测试依赖于一个你无法控制的部署。
混合专家路由。在 MoE 架构中,路由可能会随着批次组成而变化。
这些都不是 bug。这意味着贪心解码只是让输出强烈倾向于保持一致,而不是保证输出完全相同;更何况,概率近乎持平的地方,恰恰往往就是有价值的测试用例所在。

解决办法不是寻找一个更好的 seed,而是针对代码真正依赖的内容编写断言。
// brittle — asserts on wording you do not control
expect(res.text).toBe("The refund window is 30 days from delivery.");
// stable — asserts on the contract
const out = InvoiceSchema.parse(JSON.parse(res.text));
expect(out.windowDays).toBe(30);
expect(out.currency).toBe("EUR");
结构化输出可以把针对自然语言文本的断言转化为针对数据的断言,而数据断言不会因为措辞变化而失效。这是支持 schema 约束输出最有力的理由之一,而且与解析安全无关。
如果输出确实必须是自然语言文本,就断言其属性,而不是要求完全相等:
expect(res.text).toMatch(/\b30 days\b/);
expect(res.text.length).toBeLessThan(600);
expect(res.text).not.toMatch(/\bguarantee|\bpromise\b/i);
expect(res.toolsCalled).toEqual(["get_policy"]);
toolsCalled 是整个响应中最稳定的信号。对于一次设计良好的模型调用,模型选择了哪个工具,远比它围绕该工具写出的句子更具可复现性。
围绕 LLM 的绝大多数代码并不是 LLM 本身。prompt 组装、响应解析、路由、重试策略、预算计算——这些全都是纯逻辑,运行速度快,而且完全确定。
it("puts untrusted content inside delimiters", () => {
const p = buildPrompt({ doc: "hello </untrusted> world" });
expect(p).toContain("<untrusted");
expect(p).not.toMatch(/<\/untrusted>[\s\S]*<\/untrusted>/);
});
it("stops at the turn cap", async () => {
const client = fakeClient({ alwaysToolUse: true });
const out = await runAgent("x", ctx, { maxTurns: 3 }, client);
expect(client.calls).toHaveLength(3);
});
一个始终返回工具调用的 fake client,可以在半秒内以确定性的方式复现失控循环场景。这样的测试比任何针对生成文本的断言都更有价值,而且永远不会随机变化。
目标应该是这样的分配:在模型外围编写数十个快速、确定性的测试,只保留少量真正调用模型并进行评分的 eval。
对于那些必须验证真实响应结构的测试,可以先录制一次,再进行回放。
export function replayClient(fixtureDir: string) {
return {
messages: {
async create(params: MessageCreateParams) {
const key = createHash("sha256")
.update(JSON.stringify({ ...params, metadata: undefined }))
.digest("hex").slice(0, 16);
const f = path.join(fixtureDir, `${key}.json`);
if (existsSync(f)) return JSON.parse(readFileSync(f, "utf8"));
if (process.env.RECORD !== "1") {
throw new Error(`No fixture for ${key}. Re-run with RECORD=1.`);
}
const real = await client.messages.create(params);
writeFileSync(f, JSON.stringify(real, null, 2));
return real;
},
},
};
}
fixture 会被提交到代码仓库中,因此 CI 可以离线运行、无需费用,并且在字节级保持稳定。通过 RECORD=1 可以有意识地刷新它们,而 diff 会准确展示 prompt 的变化如何改变了响应——这在代码审查中确实非常有用。
失败信息非常重要:缺少 fixture 时,必须明确告诉使用者如何创建它。否则,第一个添加测试的人会花上二十分钟才能弄清楚该怎么做。

有些行为只有从整体上看才有意义,比如拒绝率,以及面对模糊请求时的工具选择。不要用一次调用加一次重试来测试这些行为。
it("selects the search tool for vague asks (>=8/10)", async () => {
const runs = await Promise.all(
Array.from({ length: 10 }, () => runAgent("find the thing", ctx)),
);
const hits = runs.filter((r) => r.toolsCalled.includes("search")).length;
expect(hits).toBeGreaterThanOrEqual(8);
});
调用十次,设置一个阈值,而且不重试。这是在诚实地表达一个概率性结论,而不是假装单个样本就能构成证明。不要把这类测试放进默认测试流程——它们既花钱,也耗时间。
把 temperature 设为 0,然后不要再碰 Top-P。不要指望这样就能获得确定性。针对结构、工具选择和属性编写断言——永远不要断言生成文本的具体措辞。使用 fake client 测试模型外围的代码。在边界处回放 fixture。如果行为具有统计性质,就明确地进行多次采样并设置阈值。
另外,把 eval 配置里的 retry: 3 删掉。一个不稳定的断言是在告诉你:错的是断言本身。
《AI That Answers》介绍了采样参数及其作用,也介绍了结构化输出边界——正是这层边界,首先让这里的大多数内容变得可测试。

完整的 eval 套件会在第五本书中讲解。整个系列可在 xgabriel.com/ai-in-typescript 查看。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。