开场:同一个 bug,两个答案
有一次我遇到一个诡异的 bug,把报错贴给模型,它说:"问题出在连接池没有设置超时。"我觉得有道理,但不太放心,于是点了一下"重新生成"。
这一次它说:"问题出在你在循环里重复创建了 HTTP 客户端。"
我又点了一次。第三次它说:"可能是 DNS 解析的问题。"
三个回答,三个方向,每一个都说得头头是道。那一刻我的心情很复杂:我是该信第一个、第二个,还是第三个?还是说,这三个回答其实都只是"有可能"?
同题不同答,用过模型的人都遇到过。它不是 bug,是设计:模型每一步都是从一个概率分布里"抽"一个 token 出来的,抽签的方式不同,结果就不同。 上一篇我们讲到 softmax 把分数变成概率,这一篇讲"从概率里选一个"这一步,以及控制它的那几个旋钮。读完本篇,你应该能回答这几个问题:
- 贪心解码和随机采样有什么区别?
- temperature、top-p、top-k 分别控制什么?
- 为什么 temperature 设成 0,也不保证每次输出完全一样?
- 为什么有些推理模型不让你调采样参数?
- 在 Agent 里,随机性是朋友还是敌人?
一、从概率到选择
1.1 两种选法
上一篇的例子里,"今天天气"后面,模型给出的概率是:
| 候选 | 概率 |
|---|---|
| 很 | 66% |
| 不 | 24% |
| 真 | 10% |
接下来怎么选?最朴素的两种办法:
- 贪心解码(greedy):永远选概率最高的那个。这里就永远选"很";
- 随机采样(sampling):按概率抽签。66% 的时候选"很",24% 的时候选"不",10% 的时候选"真"。
很 66% / 不 24% / 真 10%"] --> G["贪心:永远选最高的"] P --> S["采样:按概率抽签"] G --> G1["今天天气很……
每次都一样"] S --> S1["今天天气很……"] S --> S2["今天天气不……"] S --> S3["今天天气真……"] style G1 fill:#e3f2fd,stroke:#1565c0 style S1 fill:#e8f5e9 style S2 fill:#e8f5e9 style S3 fill:#e8f5e9
贪心听起来更"稳",为什么大家默认都用采样?
因为每一步都选最可能的,整段话不一定最好。贪心解码生成的文字往往很平淡,而且容易陷入重复:"这是一个很好的问题。这是一个很好的问题。这是一个……"。因为一旦某句话出现过,它后面跟着同一句话的概率就会变高,贪心会一头扎进去出不来。适度的随机性,让输出更自然、更多样。
1.2 随机性是怎么累积的
还有一点很关键:采样的随机性会累积。 第一个 token 选了"不"而不是"很",后面整句话的走向就变了:"今天天气不太好,我们还是在家吧"和"今天天气很好,我们去公园吧",从第三个字开始就是两个世界。
开场那个 bug 的三个答案,就是这么来的:可能第一句话的某个 token 选得不一样,模型就沿着"连接池""HTTP 客户端""DNS"三条不同的路走了下去。每一条路都是自洽的,因为每一步都是在已有内容的基础上选"最合理的下一个"。
1.3 还有一种选法:束搜索
除了贪心和采样,还有一种经典的选法叫束搜索(beam search):每一步不只保留一个候选,而是同时保留几条"到目前为止总概率最高"的路径,比如 4 条;每一步把每条路径各往后延伸一个 token,再从所有延伸结果里挑出总概率最高的 4 条,一直到结束,最后选总概率最高的那一条。
它在机器翻译时代很流行,因为翻译通常有一个"标准答案"附近的最优解,多看几条路能避免贪心的短视。但在今天的对话模型里基本见不到了,原因有几个:
- 开放式生成没有"唯一的好答案":写一段解释、回答一个问题,好的答案有很多种,"总概率最高"的那个往往是最平淡、最套话的那个;
- 它同样容易重复:束搜索追求高概率,而重复的内容恰恰概率很高;
- 成本高:保留 4 条路径,就要算 4 倍的量,流式输出也变得很麻烦,因为最后选哪条路径要到结束才知道。
所以今天主流的做法,就是"采样 + 下面这几个旋钮"。
二、三个旋钮
2.1 temperature:把分布拉尖或压扁
temperature 是最常用的旋钮。它在 softmax 之前动手:把每个 logit 除以 temperature,再做 softmax。
还是用上一篇那三个分数(2.0、1.0、0.1),看看不同 temperature 下的概率:
| 候选 | T = 0.5 | T = 1.0 | T = 2.0 |
|---|---|---|---|
| 很 | 86.4% | 65.9% | 50.2% |
| 不 | 11.7% | 24.2% | 30.4% |
| 真 | 1.9% | 9.9% | 19.4% |
- T < 1:分数之间的差距被放大,分布变"尖",高概率的更高,低概率的几乎没机会。输出更保守、更稳定;
- T = 1:原样;
- T > 1:差距被缩小,分布变"平",冷门的选项也有机会。输出更多样,也更容易胡说;
- T → 0:分布尖到极致,几乎就是贪心解码。所以很多 API 里,temperature = 0 就表示"总是选最可能的"。
稳定、保守"] S --> P2["T 大:平
多样、冒险"] style P1 fill:#e3f2fd,stroke:#1565c0 style P2 fill:#fff3e0,stroke:#e65100
2.2 top-k 和 top-p:先砍掉长尾
temperature 调的是分布的形状,但不管怎么调,词表里那十几万个 token,每个都还有一点点概率。哪怕每个只有万分之一,加起来也不少,偶尔抽中一个离谱的 token,整句话就歪了。
top-k 和 top-p 的思路是:抽签之前,先把明显不靠谱的候选砍掉。
- top-k:只保留概率最高的 k 个,其余的直接丢掉,然后在剩下的里面重新归一化再抽。k = 1 就是贪心;
- top-p(也叫 nucleus sampling):从概率最高的开始往下累加,累加到 p 为止,只保留这些。
用 T = 1 时的分布试一下 top-p = 0.9:
| 候选 | 概率 | 累计 | 保留? |
|---|---|---|---|
| 很 | 65.9% | 65.9% | 保留 |
| 不 | 24.2% | 90.1% | 保留(刚好超过 90%) |
| 真 | 9.9% | 100% | 砍掉 |
剩下"很"和"不",重新归一化后,大约是 73% 和 27%。
top-p 比 top-k 更聪明的地方在于它是自适应的:模型很有把握时(一个候选就占了 95%),它只保留一两个;模型拿不准时(前几十个候选都差不多),它会保留很多。top-k 则不管分布长什么样,都死板地保留 k 个。
2.3 三个旋钮怎么配合
调形状"] T --> S["softmax"] S --> K["top-k / top-p
砍长尾"] K --> R["重新归一化"] R --> D["抽签"] style T fill:#fff9c4 style K fill:#fff9c4 style D fill:#fff3e0,stroke:#e65100
实践中,一般只调一个:要么调 temperature,要么调 top-p,两个一起调,效果很难预测。大部分时候用默认值就够了。
几个经验值(不是定律):
| 场景 | 倾向 |
|---|---|
| 写代码、提取信息、分类、按格式输出 | 低 temperature,要稳 |
| 普通对话、写说明文字 | 默认值 |
| 头脑风暴、起名字、写创意文案 | 高一点,要多样 |
2.4 还有几个不常用的旋钮
除了这三个,很多 API 还提供一些别的参数,名字和含义各家略有不同,大致是这几类:
- 重复惩罚(frequency penalty / presence penalty):一个 token 在前面出现得越多(或者只要出现过),就降低它下一次被选中的分数。用来对付 1.1 节说的"一句话来回说"。代价是,代码和 JSON 里本来就需要大量重复的符号和关键字,惩罚开大了反而会把格式搞坏;
- seed:给随机数生成器一个固定的种子,有的 API 宣称"尽力"让同样的种子得到同样的输出。注意是"尽力",原因下一节讲;
- min-p 之类的新方法:只保留概率不低于"最高概率乘以某个比例"的候选,可以看作 top-p 的另一种自适应版本。
这些参数用到的机会不多,知道有这么回事、遇到了会查文档就行。DeepSeek 在思考模式下,同样会忽略其中的一部分,具体以官方文档为准。
三、temperature = 0,为什么还是不一样?
3.1 理论上应该一样
temperature = 0 等于贪心,每一步都选概率最高的那个。只要输入一样,模型参数一样,输出就应该完全一样,对吧?
理论上是的,实际上经常不是。你用 temperature = 0 跑同一个请求十次,大概率会发现其中有几次输出不一样,通常是在长回答的后半段出现了分叉。
3.2 罪魁祸首:浮点数和批处理
原因要从硬件说起:
- 浮点数加法不满足结合律:在计算机里,
(a + b) + c和a + (b + c)的结果可能在最后几位上有微小差别。GPU 为了快,会把一个很大的求和拆成很多块并行地算,拆法不同,结果的最后几位就可能不同; - 你的请求和别人的请求拼在一起算:模型服务为了效率,会把同一时刻的很多请求"拼成一批"一起算。批的大小每次都不一样,而批的大小会影响底层计算的拆法,进而影响那几位小数;
- 微小的差别会被放大:如果两个候选 token 的分数本来就非常接近,最后几位小数的差别就足以让它们的排名对调。一旦某一步选了不同的 token,后面就是另一条路了。
3.3 能不能彻底消除
能,但要付出代价。2025 年有研究团队专门分析过这个问题,结论是:真正的元凶主要是第 2 条,计算结果会随批大小变化。只要把底层的计算核改写成"不管批多大,求和的拆法都一样",同样的输入就能得到逐位相同的输出。代价是速度变慢,而且需要推理服务的提供方专门去做。
对我们这些调 API 的人来说,结论没变:你控制不了服务端怎么拼批,所以只能假设输出是不可复现的,然后围绕这个假设去设计。
所以,不要指望 temperature = 0 给你可复现的输出。需要稳定的地方,靠的是别的手段,第五节再说。
四、有些模型不让你调
4.1 推理模型的限制
你可能会发现,有些模型的 API 文档里写着:某些采样参数"不支持""无效",甚至传了直接报错。这在推理模型上尤其常见。
以 DeepSeek 为例(截至写作时,以官方文档为准):它的模型默认开着思考模式,而在思考模式下,temperature 不起作用。不报错,但设了也白设。top_p 在思考模式下也有自己的限制。只有把思考模式关掉,这些采样参数才会生效。
为什么推理模型要限制采样?一个合理的理解是:推理模型的"先想再答"是专门训练出来的,训练时用的采样设置就是它表现最好的设置。你随手把 temperature 调高,它的思考过程可能更容易跑偏;调到 0,又可能让它在思考里陷入重复。厂商干脆替你管了。
这也是为什么配套代码 examples/ch08-sampling/main.go 在每次请求里都显式关掉了思考:
resp, err := c.Create(ctx, &llm.Request{
Model: *modelName, MaxTokens: 16, Temperature: &t, Thinking: &llm.Thinking{Type: "disabled"},
Messages: []llm.Message{llm.UserText(question)},
})不关的话,你会看到三种 temperature 下的分布几乎一样,然后以为 temperature 没有用。
4.2 那想要稳定输出怎么办
靠 prompt 和格式,而不是靠温度。 后面几篇会陆续讲到这些手段:
- 写清楚的 prompt 和示例(第 15 篇);
- structured outputs,让输出必须符合某个格式(第 04 篇的
askJSON); - 推理强度(effort),让模型想得更充分(第 12 篇);
- 验证和重试:输出不合格就让它重来(第 35 篇)。
4.3 mini-agent 怎么设
第五部分手写的 mini-agent,在请求里一个采样参数都没设:没有 temperature,没有 top-p,全用服务端的默认值,思考模式也保持 DeepSeek 默认的开启状态。你可以在 agent/agent.go 里搜一下 Temperature,一个都找不到。
这是有意的选择,理由就是这一节说的:
- 思考模式下 temperature 本来就不起作用,设了也是白设;
- 编码 Agent 需要的稳定性,来自工具的严格格式(function calling 保证)和测试的反馈(第 35 篇),而不是来自温度;
- 厂商的默认值,通常就是模型在训练和评测时表现最好的那组设置。
在 Agent 里,最好的采样参数,往往就是不设采样参数。 你要调的,是 prompt、工具和验证。
五、在 Agent 里,随机性是朋友还是敌人
5.1 敌人的一面
Agent 一个任务要转几十圈,每一圈都有随机性。同一个任务跑两次,路径可能完全不同:这次先读 A 文件,下次先读 B 文件;这次三步就修好了,下次绕了二十步。
这带来几个实际的麻烦:
- 很难复现问题:用户说"它刚才把我的文件改坏了",你再跑一次,它又不改坏了;
- 很难评估好坏:改了一句 system prompt,成功率从 60% 变成了 70%,这是真的变好了,还是运气?第 36 篇讲评测时会说,Agent 的效果要看多次运行的通过率,不能只看一次;
- 测试不好写:所以配套仓库里所有的测试,都把模型换成了按剧本出牌的假模型(
llmtest.Fake),而不是去调真实的模型。
还有一个更微妙的麻烦:随机性会和复合错误叠加。 一个任务转 30 圈,每一圈都有一次"抽签"。哪怕每一圈抽到坏签的概率都很小,30 次里抽中一次的机会也不小;而一旦抽中,比如某一圈它决定先去改一个不相关的文件,后面的每一圈都在这个基础上继续。第 39 篇会把这件事算成一张表。
5.2 朋友的一面
但随机性也有用:
- 卡住了可以重来:一条路走不通,重新跑一次,可能就换了一条路。很多时候"重新生成"比改 prompt 更快;
- 投票:第 04 篇的并行投票,就是利用随机性:同一个问题问三次,如果三次答案一致,可信度更高;如果三次各不相同,说明模型自己也拿不准。开场那个 bug,三次给了三个方向,这本身就是一个信号:它其实不知道。
可信度高"] C -->|"各不相同"| B["模型拿不准
别全信,去验证"] style A fill:#e8f5e9,stroke:#2e7d32 style B fill:#fff9c4
这个"问几次看一致性"的技巧,在排查问题、做判断题时特别实用,成本只是多几次调用。
5.3 两个数字:pass@k 和 pass^k
既然 Agent 的结果是随机的,评价它的时候就要换一种问法。评测里有两个很有用的概念,这里先打个底,第 36 篇讲评测时会再回到它们:
- pass@k:同一个任务跑 k 次,至少有一次成功的概率。它衡量的是"这个 Agent 有没有能力做成"。如果你能自动判断哪次成功了(比如跑测试),那多跑几次、挑成功的那一次,就是一种实用的策略;
- pass^k:同一个任务跑 k 次,每一次都成功的概率。它衡量的是"这个 Agent 靠不靠得住"。如果你要把它放进一个每天自动跑的流程里,没人在旁边挑结果,你关心的就是这个数字。
假设一个 Agent 单次成功率是 70%,跑 3 次:
| 指标 | 怎么算 | 结果 |
|---|---|---|
| pass@3 | 1 − 0.3³ | 约 97% |
| pass^3 | 0.7³ | 约 34% |
同一个 Agent,同一个任务,一个数字说它"几乎总能做成",另一个说它"三次全对的机会只有三分之一"。两个数字都对,回答的是不同的问题。 随机性对"能不能做成"是朋友,对"靠不靠得住"是敌人,这正是 5.1 和 5.2 两节的量化版本。
有一次成就行"] S --> B["pass^3 ≈ 34%
每次都得成"] style A fill:#e8f5e9,stroke:#2e7d32 style B fill:#ffcdd2,stroke:#b71c1c
第 39 篇讲复合错误率时,你会看到 pass^k 的另一种面目:一个任务内部的每一步,也都要"每次都成"。
六、动手:让模型"随便选一个数"
实验:画出答案的分布
配套代码问的问题是:"从 1 到 10 里随便选一个整数。只输出这个数字,不要别的。"然后在 temperature = 0、0.5、1.0 下各问 N 次,把答案画成直方图。
go run ./examples/ch08-sampling -n 20不设 Key 时用的是离线演示模型(也可以在 Playground 的第 08 篇里跑)。它的"采样"是模拟出来的:以 7 为中心,temperature 越高,随机的范围越大。所以你会看到类似这样的结果:
== temperature = 0.0 ==
7 | ████████████████████ 20
== temperature = 0.5 ==
5 | ███ 3
6 | ███ 3
7 | ██████ 6
8 | ██████ 6
9 | ██ 2
== temperature = 1.0 ==
3 | █ 1
4 | ███ 3
5 | █ 1
6 | █ 1
7 | █████ 5
8 | ███ 3
9 | █ 1
10 | █████ 5演示模型把"偏爱 7"写成了规则,直接照搬了真实模型的一个著名习惯。它的随机数来自 Go 的随机数生成器,和真实模型的采样毫无关系,只是让你先看清楚这个实验的输出长什么样。
要看真实模型的分布,在线 Playground 演示不了:先export DEEPSEEK_API_KEY=sk-...再跑。-n 20就是 3 × 20 = 60 次调用,每次只输出一两个 token,花不了多少钱。
export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch08-sampling -n 20接上真实模型后,留意这几件事:
- temperature = 0 时:是不是几乎每次都一样?有没有偶尔冒出一个不一样的?如果有,那就是第三节说的浮点数和批处理在作怪;
- 哪个数字最多:很多模型在这个问题上偏爱 7。不是因为 7 有什么特别,而是人类在被要求"随便选一个"时就偏爱 7,模型从人类写的文本里学会了这个习惯;
- temperature = 1.0 时:分布变宽了多少?是不是 1 和 10 这种"边缘"数字依然很少?
动手作业
- 把问题换成"随便说一种水果""随便起一个变量名",看看分布有多集中。如果几十次里有一大半是同一个答案,想一想:让模型"随机"地生成测试数据、抽样用户,靠得住吗?真需要随机,就让程序用随机数生成器来做;
- 把
Thinking: &llm.Thinking{Type: "disabled"}删掉,重新跑一次。思考模式下 temperature 不起作用,三种 temperature 的分布应该看不出区别。亲眼看一次,你就再也不会在推理模型上白调温度了; - 把
MaxTokens: 16改成MaxTokens: 1,看看会发生什么(提示:答案有可能被截断成半个数字吗?想想第 06 篇讲的 token)。
七、踩坑记录:采样的七个坑
坑 1:把 temperature = 0 当成"确定性模式"。
它只是"尽量稳定",不是"保证一样"。需要可复现的地方,别依赖它。要测试 Agent 的逻辑,用假模型;要评估 Agent 的效果,跑多次看通过率。
坑 2:在推理模型上白调温度。
很多推理模型在思考模式下忽略 temperature,有的甚至会直接报错。调之前先看文档,或者像配套代码那样显式关掉思考。
坑 3:temperature 和 top-p 一起调。
两个都会改变分布,叠在一起效果很难预测。选一个调,另一个用默认值。
坑 4:投票时把 temperature 设成 0。
第 04 篇的坑 4 在这里有了解释:temperature = 0 时三次投票几乎一模一样,等于只问了一次,还花了三倍的钱。
坑 5:写代码、出 JSON 时用高 temperature。
高 temperature 会让冷门的 token 更容易被选中,在代码和 JSON 里,一个冷门 token 可能就是一个语法错误。格式要求严格的输出,用低 temperature 或者默认值。
坑 6:用"重新生成"掩盖 prompt 的问题。
如果一个任务要重新生成好几次才能碰到一个满意的结果,问题多半不在运气,而在 prompt 没写清楚,或者上下文里缺了关键信息。随机性是用来探索的,不是用来赌的。
坑 7:用一次成功或一次失败下结论。
改了 prompt,跑了一次,成功了,于是认为改对了;或者跑了一次,失败了,于是把改动撤了。单次运行的结果,很大一部分是运气。5.3 节的两个数字告诉你,哪怕单次成功率只有 70%,连跑三次全成功的机会也有三分之一。至少跑几次再下结论,最好用第 36 篇的评测集。
一句话总结
模型每一步都是在概率分布里抽签:temperature 调分布的形状,top-k 和 top-p 砍掉不靠谱的长尾;temperature = 0 也不保证每次一样,推理模型往往不让你调;在 Agent 里,别指望靠温度换稳定,要靠格式、验证和多次运行。
调形状"| P2["拉尖 / 压扁"] P2 -->|"top-k / top-p
砍长尾"| P3["候选集"] P3 -->|"抽签"| T["下一个 token"] style T fill:#fff3e0,stroke:#e65100
评论
还没有评论,来说点什么吧。