采样,temperature、top-p 与"同题不同答"

outlier 15 分钟 5758 字 0 0
系列教程Agent从入门到放弃 第 9 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:同一个 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% 的时候选"真"。
flowchart TB P["概率分布
很 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.5T = 1.0T = 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 就表示"总是选最可能的"。
flowchart LR L["logits"] -->|"÷ temperature"| L2["缩放后的 logits"] L2 --> S["softmax"] S --> P1["T 小:尖
稳定、保守"] 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 三个旋钮怎么配合

flowchart LR L["logits"] --> T["temperature
调形状"] 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 罪魁祸首:浮点数和批处理

原因要从硬件说起:

  1. 浮点数加法不满足结合律:在计算机里,(a + b) + c 和 a + (b + c) 的结果可能在最后几位上有微小差别。GPU 为了快,会把一个很大的求和拆成很多块并行地算,拆法不同,结果的最后几位就可能不同;
  2. 你的请求和别人的请求拼在一起算:模型服务为了效率,会把同一时刻的很多请求"拼成一批"一起算。批的大小每次都不一样,而批的大小会影响底层计算的拆法,进而影响那几位小数;
  3. 微小的差别会被放大:如果两个候选 token 的分数本来就非常接近,最后几位小数的差别就足以让它们的排名对调。一旦某一步选了不同的 token,后面就是另一条路了。
flowchart LR A["同一个请求"] --> B1["这次和 7 个请求一批"] A --> B2["这次和 31 个请求一批"] B1 --> C1["logit:2.0000001 vs 2.0000000"] B2 --> C2["logit:1.9999999 vs 2.0000000"] C1 --> D1["选「很」"] C2 --> D2["选「不」"] D1 --> E1["……后面全不一样"] D2 --> E2["……后面全不一样"] style D2 fill:#ffcdd2,stroke:#b71c1c

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,三次给了三个方向,这本身就是一个信号:它其实不知道。
flowchart TB Q["同一个问题,问 N 次"] --> C{"答案一致吗?"} C -->|"高度一致"| A["模型有把握
可信度高"] 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@31 − 0.3³约 97%
pass^30.7³约 34%

同一个 Agent,同一个任务,一个数字说它"几乎总能做成",另一个说它"三次全对的机会只有三分之一"。两个数字都对,回答的是不同的问题。 随机性对"能不能做成"是朋友,对"靠不靠得住"是敌人,这正是 5.1 和 5.2 两节的量化版本。

flowchart LR S["单次成功率 70%"] --> A["pass@3 ≈ 97%
有一次成就行"] 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

接上真实模型后,留意这几件事:

  1. temperature = 0 时:是不是几乎每次都一样?有没有偶尔冒出一个不一样的?如果有,那就是第三节说的浮点数和批处理在作怪;
  2. 哪个数字最多:很多模型在这个问题上偏爱 7。不是因为 7 有什么特别,而是人类在被要求"随便选一个"时就偏爱 7,模型从人类写的文本里学会了这个习惯;
  3. temperature = 1.0 时:分布变宽了多少?是不是 1 和 10 这种"边缘"数字依然很少?

动手作业

  1. 把问题换成"随便说一种水果""随便起一个变量名",看看分布有多集中。如果几十次里有一大半是同一个答案,想一想:让模型"随机"地生成测试数据、抽样用户,靠得住吗?真需要随机,就让程序用随机数生成器来做;
  2. 把 Thinking: &llm.Thinking{Type: "disabled"} 删掉,重新跑一次。思考模式下 temperature 不起作用,三种 temperature 的分布应该看不出区别。亲眼看一次,你就再也不会在推理模型上白调温度了;
  3. 把 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 里,别指望靠温度换稳定,要靠格式、验证和多次运行。

flowchart LR P["概率分布"] -->|"temperature
调形状"| P2["拉尖 / 压扁"] P2 -->|"top-k / top-p
砍长尾"| P3["候选集"] P3 -->|"抽签"| T["下一个 token"] style T fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭