Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

推理与测试时计算:多想一会儿,怎样才有用

工坊里来了一个新问题:三项作业分别需要 30、45、25 秒,相邻作业之间暂停 5 秒。从第一项开始,到最后一项结束,一共需要多久?

我们很快就能算出:

30+45+25+2×5=110 30+45+25+2\times5=110

关键不在加法,而在“相邻”二字:三项作业之间只有两次暂停。如果模型漏看了这个条件,多给它一点时间,会不会有帮助?

也许。它可以重新读题,换一种算法,检查时间线,还可以生成几个答案再比较。但它也可能把同一个错误说得更长,或者从四个错误答案中选出最有把握的一个。

测试时计算研究的,就是模型已经训练好以后,怎样把额外的计算花在当前问题上。 这一页会先拆开“生成候选”和“挑选答案”,再用真实的小模型记录和一个能精确检查的搜索实验,看看计算预算究竟换来了什么。

前置内容是语言模型与采样、推理能力与可验证奖励和大模型推理过程与加速。本页资料核查截至 2026 年 10 月 20 日,选择六篇代表文献串起问题,不作为该领域的完整论文清单。首版包含 32 次真实生成、精确搜索和失败分析;没有复现论文中的大模型训练与排行榜成绩。

1. 额外的计算,可以放在哪里

先把训练与解题分开。训练会改变模型参数;本页的基本场景是参数固定,来了一个问题,再决定怎样生成、检查和选择。模型能够有效使用这些步骤,往往仍需要此前的训练支持。

做法多出来的计算做了什么容易忽略的条件
多次独立生成从头尝试几种回答,最后投票或排序候选里得有好答案,选择办法也得可靠
延长一次生成在同一条轨迹中继续计算、检查或改写后续文字依赖前文,错误也可能被沿用
搜索中间步骤保留几条尚未完成的路线,继续展开中间状态能否判断,错误分支能否及时放弃
分配不同预算简单题少算,值得继续的题多算解题前并不知道正确答案,判断难度也有成本

这里的“独立生成”是指后一份候选不读取前一份回答,并不要求它们在硬件上同时执行。本书的小模型实验逐次运行;不能把算法上的并行候选,直接说成实际延迟缩短了四倍。

还有一个常见误会:将最大输出长度从 192 改成 1024,只是放宽上限。若模型在第 3 个词元就输出结束标记,它不会因为上限更高而自动继续。让模型能写更久,与让它学会利用这段时间,是两件需要分别检查的事。

2. 候选中有正确答案,和最终选对答案

投票不需要标准答案,但也不保证真理

假设同一个问题产生了四份回答,抽取出的最终数值依次为:

110, 100, 100, 110

这是一个手工例子。两种答案各得两票,如果预先规定“并列时选最早出现者”,就选 110。顺序换成 100, 110, 110, 100,同一套规则会选 100。因此,候选数、答案归一化方式和并列规则,都属于方法的一部分。

Wang 等人的 Self-Consistency 从同一模型采样多条解题路径,再聚合最终答案。不同的文字表达可以指向同一个结果;需要合并的是答案,而不是逐字相同的整段说明。本文采用最简单的整数答案投票,机制与这条思路相关,规模、模型和提示均不同。原论文第 2 节

“多数票”在这类实现中常指票数最多,未必超过全部候选的一半。例如 110, 110, 100, 105, 120 中,110 得两票,也会被选中。没有合法答案的候选如何处理,同样要事先约定。

一个简单公式,藏着几个重要前提

对一道固定的题,假设每次尝试成功的概率都是 pp,各次成功事件相互独立。那么尝试 nn 次,至少成功一次的概率是:

P(至少成功一次)=1−(1−p)n P(\text{至少成功一次})=1-(1-p)^n

推导只需要反着想:一次失败的概率为 1−p1-p,连续 nn 次都失败是 (1−p)n(1-p)^n,用 1 减去它即可。

这算的是至少出现过正确答案,不是投票或验证器一定选对的概率。某些报告中的 pass@nn 描述的就是候选中存在成功解的机会;还要核对它采用直接抽样还是组合估计。本书只统计固定候选池的前 nn 份,不把单次观察当作精确的成功概率。

假设每次有 40% 的概率回答正确,剩下 60% 都答同一个错误数值。三票多数正确,需要恰好两票或三票正确:

P(三票多数正确)=3×0.42×0.6+0.43=0.352 \begin{aligned} P(\text{三票多数正确}) &=3\times0.4^2\times0.6+0.4^3\\ &=0.352 \end{aligned}

一票正确率为 0.4,三票反而降到 0.352;五票进一步降到 0.31744。与此同时,候选中至少出现一次正确答案的概率却在上升。更多候选与更好的最终选择,可以朝不同方向变化。

这个反例已经假定每次独立采样。多次答出同一个错数,并不能单独证明采样“不独立”:同一个错误本来就可能在模型的分布里占很大概率。

不同题的难度,不能揉成一个平均数

再假设题目一半特别容易,模型每次都答对;另一半无论采样多少次,都答不对。平均单次正确率为 0.5,但每题尝试 5 次后,能够找到正确答案的题目仍只有一半。

如果把平均值直接代入 1−(1−p)51-(1-p)^5,却会得到 0.96875。这是把两种题误当成了一种题。

记第 ii 道题的单次成功概率为 pip_i,总共有 mm 道题。在各题内部满足前面独立、同分布的条件时,平均成功机会应为:

1m∑i=1m[1−(1−pi)n] \frac{1}{m}\sum_{i=1}^{m}\left[1-(1-p_i)^n\right]

先分别算,再平均。不能只知道平均单次正确率,就断言增加采样会沿某条理想曲线上升。

3. 实际跑一次:先看清小模型在哪里失败

在生成之前固定问题和规则

我们用固定版本的 Qwen2.5-0.5B-Instruct,回答八道本书自编的英文算术题:页数计时、彩色打印、零件装包、折扣、排队暂停、混合液体、合格产量和重复周期。选择英文是本次实验的设置,不能据此推断中文任务的表现。

每题采样 4 次,使用同一个系统提示,要求简短计算后以 FINAL: <整数> 结束。解析器只接受最后一行的这一格式;不合规候选弃票,但题目仍留在准确率分母里。票数并列时选择最早出现的合法答案;全部弃票时,该题失败。参考答案只用于事后计分,没有放进提示或投票函数。

关键设置如下:

项目本次设置
模型修订Qwen2.5-0.5B-Instruct 固定版本,完整指纹在记录中
采样温度 0.8,top-p 0.95,top-k 关闭
长度每次最多新增 192 个词元;输入加输出最多 2048
随机种子20261020~20261051,每次调用一个固定种子
执行CPU、float32、eager attention、4 个线程
环境Python 3.12.12、PyTorch 2.14.1、Transformers 4.57.3
比较方式同一批候选的前 1、2、4 份

使用同一个候选池的前缀,能避免不同预算恰好抽到完全不同的一批回答,但这些预算点相互关联,不是三次独立实验。八道题也太少,不适合给模型做通用能力排名。

结果没有出现期待中的上升曲线

每题候选数投票答对候选中存在合规正确答案不合规候选
10/80/88/8
20/80/816/16
40/80/832/32

这个结果首先说明:当前模型与提示组合没有完成约定的输出任务。 因为没有任何合法票,主表也无法比较“多数票究竟能否从合法答案中选得更好”。不能从这张表推出自一致性方法普遍无效。

再把账单放在旁边:

每题候选数输入词元合计输出词元合计
169848
2139681
42792490

图 1:本次真实记录的计算用量。蓝色为输入词元,橙色为输出词元。词元数便于核对,但没有把输入、输出词元当成等价 FLOPs,也没有将它们换算成硬件延迟。

输入计数包含聊天模板,输出计数包含实际生成的结束标记(若有),都不是可见字数。输入词元重复计算,是因为每次生成都重新提交完整提示,没有复用不同请求的前缀缓存。输出总数增长较快,其中一份回答写满了 192 个词元;其余 31 份都因结束标记而停止。因此,只增加输出上限,不会让这 31 份已经结束的回答继续思考。

格式不对,不等于每个算术结果都错了

现在再逐条阅读实际文本。下面使用从 1 开始的采样编号,原始文件中的 sample 从 0 开始:

题目与采样实际文本中的结果能看出什么
合格产量,第 1 次48数值正确,但没有 FINAL:
零件装包,第 4 次最后给出 \boxed{7}算出 7 个完整包装,格式仍不合规
彩色打印,第 4 次10 x 3 + 2 x 5 + 20 = 40写出了固定开销,却把和算错;正确应为 60
相邻暂停,第 3、4 次两次都答 80重复了同一个错数,并没有因重复而更可信

在生成完成后进行的逐条释读中,32 份文本只有两份给出了正确的最终数值。这是补充错误分析,没有修改预先固定的解析规则,也没有用事后挑选出的结果替换主表。全部文本及释读结果可在原始记录和内容复核记录中核对。

这一组失败给出的下一步很具体:另设开发题,先检查模型是否稳定理解任务与输出契约;如果要修改提示、解析器或换模型,就把新方案固定下来,在新一轮记录中重新评估。不要反复调整这八道题,直到图表终于向上,再把它们称为独立测试。

4. 搜索中间步骤:24 点为什么适合做实验

刚才的整数答案,只有拿到标准答案以后才能判对错。现在换一个能直接检查解法的任务:给定四个数,每个数恰好使用一次,只用加、减、乘、除和括号,凑成 24。

例如,四张牌是 3、3、8、8:

83−83=813=24 \frac{8}{3-\frac{8}{3}} =\frac{8}{\frac{1}{3}} =24

这个式子很有意思:先算出来的是 8/38/3,看上去离 24 很远,却是正确路线的第一步。

一步怎样变成下一步

我们把“还剩哪些数”叫作状态。初始状态有四个数;每一步选两个数做一次运算,再把结果放回去,数量就减少一个。做完三步,只剩一个数;若它等于 24,就找到解。

这个任务可以完整枚举:对每一对数尝试加、乘、两种方向的减法和合法的除法。中间结果使用 Python 的 Fraction 保存精确分数,避免把 1/31/3 舍入为小数后再比较。

程序保留完整的表达式。最后的检查器不只问“结果是不是 24”,还要确认四张牌各用了一次。例如,直接输出 24 数值正确,却没有使用任何牌,应当拒绝。

检查器只读取受限的表达式语法树,允许正整数叶子和二元四则运算,不使用 eval 执行生成的代码。这是一个有清楚规则的小任务,检查通过就能确认该表达式符合这里的要求;它不能直接检查任意自然语言证明。

只保留看起来最接近 24 的状态,会怎样

完整搜索可能很贵。我们试一个刻意简单的评分:在当前状态里,找与 24 差得最少的数,把这个距离当作状态的分数:

h(s)=min⁡v∈s∣v−24∣ h(s)=\min_{v\in s}|v-24|

ss 表示一组尚未用完的数,vv 是其中一个数。分数越小越优先,每一层只保留前 bb 个状态。这个 bb 就是这里的搜索宽度。同分时按排序后的精确数值确定顺序,规则固定,不读取标准解。

这条规则有明显弱点:只要暂时得到 24,它就非常满意,哪怕旁边还剩两张没法处理的牌。对 3、3、8、8,宽度为 1 的搜索首先留下 3, 8, 24,之后一路走到 19。

图 2:左侧来自宽度为 1 的实际搜索轨迹;右侧把完整搜索输出的正确表达式逐步展开。右侧的第一步没有急着接近 24,却保留了走向正确解的可能。

这里使用的是本书自编的确定性搜索器,没有语言模型参与提议或评分。Yao 等人的 Tree of Thoughts 将中间思路组织成可搜索状态,并用语言模型生成、评价候选;其 24 点实验也采用有限宽度的搜索。本书借用了同类任务来拆解搜索机制,不把自己的精确枚举称为 ToT 复现。原论文第 3、4 节

八组牌,实际搜索了多少

固定的八组输入保存在实验方案里,全部存在合法解。对它们运行三种宽度:

保留宽度找到解扩展状态数生成候选状态数
11/824479
41/8721055
全部8/8159711995

“扩展”表示取一个状态,枚举它的下一步;“生成”包含去重前产生的候选。同一层中,包含相同精确数值、且各数出现次数相同的状态可以合并,因为它们后续可进行的算术相同;保留一条使用原牌构造表达式的路径即可。程序在最后一层完成检查,没有提前找到一个解就停止。

宽度从 1 增加到 4,算得更多,成功数却没变。全部保留才找回那些过早被删掉的路线。这不能证明“搜索应该永远穷举”:换成几十步、每步上百个分支,状态数量就会迅速增长。它说明的是,预算和评分质量要一起看。真实语言模型的中间状态,也很难像这里的有理数集合一样精确合并。

5. 给答案打分的模型,也会犯错

在 24 点里,我们可以执行算术并检查牌的使用次数。换成开放问题,通常没有这么便宜、完整的检查器。

一种做法是训练结果奖励模型,为完整回答打分;另一种是训练过程奖励模型,为中间步骤提供反馈,再将这些分数用于筛选或搜索。Lightman 等人的 Let’s Verify Step by Step 比较了这两类监督,并公开逐步标注数据。其模型评分依然是学习得到的判断,不能等同于前面的精确表达式检查。原论文、PRM800K 数据与说明

先生成 NN 份完整候选,再交出评分最高的一份,常称为 best-of-N。它和投票的区别很直接:投票数“大家都答什么”,评分器判断“哪一份看起来更好”。这里的“best”指评分最高;是否真的正确,还要由独立的评价来回答。过程评分也可以提前用于搜索,不必等整份答案写完。

例如,一份答案每一步都写得很像训练中的正确解,但第二步偷偷漏掉了条件,后面仍可能得到高分。把步骤分数相乘,也不会自动把这些分数变成可靠的联合正确概率:步骤如何切分、分数是否校准、错误之间的关系,都需要检查。

更多候选还会放大“找到高分错误”的机会。做一个只用于说明机制的假设:16 个候选其实全错,检查器对每个候选各有 5% 的误放概率,而且这些误放事件相互独立。那么至少误放一个的概率是:

1−0.9516≈0.55987 1-0.95^{16}\approx0.55987

这不是任何现成验证器的实测错误率,也不直接等于最终选择错误的概率——我们还没有规定从通过者中怎样选。它提醒我们,单条候选的误放率与整个选择流程的风险不是同一个量。

评估时至少要把两层分开:候选池里有没有正确解,以及选择器最终交出的解是否正确。如果前者上升、后者不升,可能该改进的是选择规则;如果候选里根本没有好解,再聪明的排序也无从选择。

6. 继续思考,什么时候只是继续重复

多次从头生成之外,还可以沿同一条轨迹检查、修订。后一步能利用前一步的中间结果,这是优势;后一步也可能被前面的错误带着走,这是需要观察的失败方式。

Muennighoff 等人的 s1 先用筛选后的推理示例微调 Qwen2.5-32B-Instruct,再通过解码干预控制思考长度:需要提前结束时插入结束思考的分隔;需要延长时抑制这一分隔,并追加 Wait 等提示。论文也报告过延长过多后出现重复、收益趋缓的限制。因此,这项研究不能被简化成“任何模型多加一句再想想,就会更聪明”。本文所读 v1 的第 3、5、6 节

做顺序修订实验时,可以让每一轮保留“改之前”和“改之后”的答案,并分别统计错改对、对改错和没有变化。只展示一条成功纠错的长回答,会漏掉另外两种情况。

预算也不只是新写了多少字。把前几轮回答放回提示,会增加后续输入;调用验证器或工具,也要计入计算与等待。对于固定服务,还可以记录总词元、实际 FLOPs 的明确口径、首词元延迟和总完成时间。它们相互有关,却不能互相冒充。

读模型给出的解释时,也应分别问:最终答案对不对?写出来的步骤成立吗?这些文字是否准确反映了产生答案的内部原因?前两项可以逐步检查,不能仅靠一段流畅文字就替第三项下结论。

7. 不同问题,需要一样多的预算吗

“把 3 和 5 相加”与“安排一周生产计划”,显然不该机械地得到相同的计算配额。可是部署时,我们只有题目,没有标准答案,怎样知道下一份计算值不值得?

Snell 等人把测试时计算拆成候选生成与验证,比较修订、搜索及不同预算分配。一个值得仔细读的细节是难度怎样估计:原文区分利用真值成功率的难度分组与验证器预测的分组,并说明难度估计本身的额外计算没有纳入其主要比较。使用了专门微调的修订或验证能力,也影响结论能够迁移到哪里。原论文第 3.2、4 节

这个细节很实用。假如先生成很多候选来判断难度,随后只报告正式求解使用的采样数,前面的判断成本就可能被漏掉。研究中的机制比较有其用途,但部署账单必须覆盖完整流程。

一种简洁的写法是:对问题 xx,策略 π\pi 决定采用哪种方法、用多少预算,目标为:

max⁡πE[R(x,π)]满足E[C(x,π)]≤B \begin{aligned} \max_{\pi}\quad&\mathbb{E}[R(x,\pi)]\\ \text{满足}\quad&\mathbb{E}[C(x,\pi)]\leq B \end{aligned}

RR 可以是最终是否答对,CC 是预先定义的完整成本,BB 是平均预算上限;期望表示在目标问题分布与生成随机性上取平均。这是本页对预算选择问题的概括,不是一条直接可用的训练算法。

平均预算也不能代替单次上限。一个策略可能平均很省,但偶尔让用户等很久,因此实际系统往往还需要每个请求的长度或时间限制。

2026 年的一条研究路线:让模型先选择思考模式

Kassenaar、Yang 与 François-Lavet 的 Learning When to Think 让一个 1.5B 蒸馏推理模型在开头选择快速回答、短推理或长推理。作者用 GRPO、不同模式的奖励与长度上限等设计学习这种选择,并与固定模式比较。它把“何时多想”也纳入了学习,而不是一律增加长度。2026 年 8 月 v2

但适用范围仍需保留:研究使用一个模型规模、一个数学训练分布和三个种子;验证时没有沿用训练的各模式长度上限。某个短模式的正确率更高,也不能直接说明缩短推理提高了正确率——较容易的题本来就可能被分到短模式。本文没有复现这项训练,后续若比较这种策略,应在同一批留出题上同时核对准确率、完整成本和强制预算条件。

接下来的开放问题,也因此变得具体:模型判断难度的成本能否收回来?题型改变后还会分配得当吗?更好的训练与更多的单次推理,应该怎样一起选择?这也连接到下一方向的后训练方法。

8. 运行本页实验

先用标准库重算记录与搜索

在本书仓库根目录运行:

python3 src/examples/test_time_compute.py

它读取随书保存的 32 次生成记录,重新解析、投票和计数,再实际执行八组 24 点搜索,不下载模型。输出为:

每题 1 次:投票正确 0/8;候选中存在正确 0/8;输入/输出词元 698/48;无合规答案 8,触顶 0。
每题 2 次:投票正确 0/8;候选中存在正确 0/8;输入/输出词元 1396/81;无合规答案 16,触顶 0。
每题 4 次:投票正确 0/8;候选中存在正确 0/8;输入/输出词元 2792/490;无合规答案 32,触顶 1。
24 点搜索,宽度 1:成功 1/8;扩展状态 24,生成候选状态 479。
24 点搜索,宽度 4:成功 1/8;扩展状态 72,生成候选状态 1055。
24 点搜索,宽度 全部:成功 8/8;扩展状态 1597,生成候选状态 11995。
正确概率 0.4、错误都投同一答案时,1/3/5 票多数正确率:0.40000 / 0.35200 / 0.31744
16 个错误候选各有独立 5% 误放率,至少误放一个:0.55987。
默认重算保存的生成记录与精确搜索,不调用语言模型。

可下载计算脚本、固定方案、生成记录和搜索及汇总结果。单独下载时,把 JSON 放在脚本旁的 data/ 目录,保留原文件名;内容复核 JSON 是补充分析,不是默认计算脚本的输入。

展开完整的计分、检查器和搜索代码
"""推理与测试时计算:固定采样方案、投票计分及精确有理数搜索。"""

import argparse
import ast
from collections import Counter
from fractions import Fraction
import hashlib
from itertools import combinations
import json
from math import comb
from pathlib import Path


DATA = Path(__file__).resolve().parent / "data"
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"
SYSTEM = ("Solve the arithmetic question. Give a brief calculation, then end "
          "with one line exactly FINAL: <integer>. Do not include units or "
          "any text after that final line.")


def digest(path):
    h = hashlib.sha256()
    with Path(path).open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            h.update(block)
    return h.hexdigest()


def fingerprint(value):
    raw = json.dumps(value, sort_keys=True, ensure_ascii=False,
                     separators=(",", ":")).encode()
    return hashlib.sha256(raw).hexdigest()


def save_new(path, value):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x") as stream:
        json.dump(value, stream, ensure_ascii=False, indent=2)
        stream.write("\n")


def make_plan():
    problems = [
        ("pages", "Printing takes 3 seconds per page plus 20 seconds of setup. "
         "How many seconds does one 17-page job take?", 71, "3*17+20"),
        ("color", "A document has 10 black-and-white pages and 2 color pages. "
         "Each black-and-white page takes 3 seconds and each color page 5 seconds. "
         "Add 20 seconds of setup once. How many seconds in total?", 60, "10*3+2*5+20"),
        ("packs", "There are 3 trays with 12 parts each. Discard 5 defective parts. "
         "Put the remaining parts into packs of 4. How many full packs can be made?",
         7, "(3*12-5)//4"),
        ("discount", "Buy 4 spools at 18 coins each. Apply a 25 percent discount "
         "to the spool subtotal, then add a shipping fee of 6 coins. "
         "How many coins are paid?", 60, "4*18*3/4+6"),
        ("queue", "Three jobs run one after another, taking 30, 45 and 25 seconds. "
         "There is a 5-second pause between adjacent jobs, but none before the "
         "first or after the last. How many seconds from start to finish?", 110, "30+45+25+2*5"),
        ("mixture", "A 45 mL mixture has red and blue liquid in the ratio 3:2. "
         "Add 15 mL of blue liquid. How many mL of blue liquid are now present?",
         33, "45*2/5+15"),
        ("rate", "Printer A makes 12 parts per hour and printer B makes 18 parts "
         "per hour. Both run for 2 hours at constant rates. Exactly one fifth "
         "of all produced parts are defective. How many nondefective parts remain?",
         48, "(12+18)*2*4/5"),
        ("cycles", "Two signals flash together now. One repeats every 18 minutes, "
         "the other every 24 minutes. After how many minutes will they first "
         "flash together again?", 72, "lcm(18,24)"),
    ]
    return dict(
        schema=1, model=MODEL, revision=REVISION, system=SYSTEM,
        problems=[dict(id=i, question=q, answer=a, reference=r) for i, q, a, r in problems],
        generation=dict(samples=4, max_new_tokens=192, do_sample=True,
                        temperature=0.8, top_p=0.95, top_k=0,
                        attention="eager", device="cpu", dtype="float32", threads=4,
                        input_plus_output_cap=2048, first_seed=20261020),
        evaluation=dict(prefix_budgets=[1, 2, 4], tie_break="earliest valid candidate",
                        invalid="abstain from voting; the problem remains in the denominator"),
        puzzles=[[1, 2, 3, 4], [2, 3, 4, 5], [3, 3, 8, 8], [1, 5, 5, 5],
                 [2, 2, 6, 6], [1, 3, 4, 6], [4, 7, 8, 8], [1, 2, 5, 8]],
    )


def messages(problem):
    return [{"role": "system", "content": SYSTEM},
            {"role": "user", "content": problem["question"]}]


def parse_answer(text):
    import re
    lines = text.strip().splitlines()
    if not lines:
        return None
    match = re.fullmatch(r"FINAL:\s*(-?[0-9]+)", lines[-1])
    return int(match.group(1)) if match else None


def majority(rows):
    answers = [parse_answer(row["text"]) for row in rows]
    answers = [a for a in answers if a is not None]
    if not answers:
        return None
    counts = Counter(answers)
    return max(answers, key=counts.get)  # 并列时保持最早出现者。


def verify_expression(expression, numbers):
    """限定二元四则运算和正整数叶子;不使用 eval,也不执行任何代码。"""
    leaves = []
    if len(expression) > 500:
        return False

    def visit(node):
        if isinstance(node, ast.Constant) and type(node.value) is int and node.value > 0:
            leaves.append(node.value)
            return Fraction(node.value)
        if not isinstance(node, ast.BinOp):
            raise ValueError("只接受二元四则运算")
        left, right = visit(node.left), visit(node.right)
        if isinstance(node.op, ast.Add):
            return left + right
        if isinstance(node.op, ast.Sub):
            return left - right
        if isinstance(node.op, ast.Mult):
            return left * right
        if isinstance(node.op, ast.Div):
            return left / right
        raise ValueError("运算符不在规则中")

    try:
        value = visit(ast.parse(expression, mode="eval").body)
    except (SyntaxError, ValueError, ZeroDivisionError, RecursionError):
        return False
    return value == 24 and Counter(leaves) == Counter(numbers)


def state_key(items):
    return tuple(sorted(value for value, _ in items))


def children(items):
    for i, j in combinations(range(len(items)), 2):
        a, ae = items[i]
        b, be = items[j]
        rest = [item for k, item in enumerate(items) if k not in (i, j)]
        variants = [(a+b, f"({ae}+{be})"), (a*b, f"({ae}*{be})"),
                    (a-b, f"({ae}-{be})"), (b-a, f"({be}-{ae})")]
        if b:
            variants.append((a/b, f"({ae}/{be})"))
        if a:
            variants.append((b/a, f"({be}/{ae})"))
        for value, expression in variants:
            yield sorted(rest + [(value, expression)], key=lambda x: (x[0], x[1]))


def search_24(numbers, width=None):
    # 状态相同的精确值有同样的后续可达性;保留其中一条构造表达式的路径。
    frontier = [[(Fraction(n), str(n)) for n in sorted(numbers)]]
    expanded = generated = 0
    trace = []
    for depth in range(1, len(numbers)):
        unique = {}
        for items in frontier:
            expanded += 1
            for child in children(items):
                generated += 1
                unique.setdefault(state_key(child), child)
        ordered = sorted(unique.values(),
                         key=lambda items: (min(abs(v-24) for v, _ in items),
                                            state_key(items)))
        kept = ordered if width is None else ordered[:width]
        trace.append(dict(depth=depth, unique=len(ordered), kept=len(kept),
                          values=[[str(v) for v, _ in items] for items in kept]))
        frontier = kept
    solutions = [items[0][1] for items in frontier
                 if len(items) == 1 and items[0][0] == 24]
    expression = solutions[0] if solutions else None
    if expression is not None and not verify_expression(expression, numbers):
        raise AssertionError("搜索输出没有通过独立表达式检查")
    return dict(numbers=numbers, width=width, success=expression is not None,
                expression=expression, expanded=expanded, generated=generated, trace=trace)


def evaluate_record(record):
    plan = make_plan()
    if not record["complete"] or record["plan_sha256"] != fingerprint(plan):
        raise ValueError("模型记录未完成或方案不同")
    keys = {(r["id"], r["sample"]) for r in record["rows"]}
    expected = {(p["id"], i) for p in plan["problems"] for i in range(4)}
    if keys != expected or len(record["rows"]) != len(expected):
        raise ValueError("缺失或重复的模型调用")
    by_key = {(r["id"], r["sample"]): r for r in record["rows"]}
    results = []
    for budget in plan["evaluation"]["prefix_budgets"]:
        details = []
        for problem in plan["problems"]:
            rows = [by_key[problem["id"], i] for i in range(budget)]
            if any(row["messages"] != messages(problem) for row in rows):
                raise ValueError("提示与固定方案不同")
            answers = [parse_answer(row["text"]) for row in rows]
            voted = majority(rows)
            details.append(dict(id=problem["id"], answers=answers, voted=voted,
                                correct=voted == problem["answer"],
                                oracle=problem["answer"] in answers,
                                input_tokens=sum(r["input_tokens"] for r in rows),
                                output_tokens=sum(r["output_tokens"] for r in rows),
                                invalid=sum(a is None for a in answers),
                                capped=sum(not r["stopped_on_eos"] for r in rows)))
        results.append(dict(budget=budget, rows=details))
    return results


def theory():
    return dict(
        heterogeneous_success=[0.5 for n in (1, 3, 5)],
        wrong_average_substitution=[1-0.5**n for n in (1, 3, 5)],
        majority_p04=[sum(comb(n, k)*0.4**k*0.6**(n-k)
                          for k in range(n//2+1, n+1)) for n in (1, 3, 5)],
        false_positive_16=1-0.95**16,
    )


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--record", type=Path, default=DATA / "test-time-model.json")
    parser.add_argument("--out", type=Path)
    args = parser.parse_args()
    record = json.loads(args.record.read_text())
    results = evaluate_record(record)
    searches = [search_24(puzzle, width) for width in (1, 4, None)
                for puzzle in make_plan()["puzzles"]]
    report = dict(model_record_sha256=digest(args.record),
                  source_sha256=digest(__file__), sampling=results,
                  searches=searches, theory=theory())
    if args.out:
        save_new(args.out, report)
    for result in results:
        rows = result["rows"]
        print(f"每题 {result['budget']} 次:投票正确 {sum(r['correct'] for r in rows)}/8;"
              f"候选中存在正确 {sum(r['oracle'] for r in rows)}/8;"
              f"输入/输出词元 {sum(r['input_tokens'] for r in rows)}/"
              f"{sum(r['output_tokens'] for r in rows)};"
              f"无合规答案 {sum(r['invalid'] for r in rows)},触顶 {sum(r['capped'] for r in rows)}。")
    for width in (1, 4, None):
        subset = [r for r in searches if r["width"] == width]
        print(f"24 点搜索,宽度 {width if width else '全部'}:"
              f"成功 {sum(r['success'] for r in subset)}/8;"
              f"扩展状态 {sum(r['expanded'] for r in subset)},"
              f"生成候选状态 {sum(r['generated'] for r in subset)}。")
    print("正确概率 0.4、错误都投同一答案时,1/3/5 票多数正确率:"
          + " / ".join(f"{p:.5f}" for p in theory()["majority_p04"]))
    print(f"16 个错误候选各有独立 5% 误放率,至少误放一个:"
          f"{theory()['false_positive_16']:.5f}。")
    print("默认重算保存的生成记录与精确搜索,不调用语言模型。")


if __name__ == "__main__":
    main()

阅读时可以从 parse_answer、majority 和 verify_expression 开始,再进入 search_24。前两个函数不读取参考答案;精确检查器读取题目的四张牌,而不是预存的标准表达式。evaluate_record 才使用参考整数为最终结果评分。

再选择是否重新运行模型

重跑需要下载模型文件,CPU 上的耗时取决于设备与缓存。使用固定依赖,在一个尚不存在的目录保存新记录:

uv run --python 3.12 --with-requirements src/examples/requirements-inference.txt \
  python src/examples/test_time_models.py --run --out-dir runs/test-time-new

python3 src/examples/test_time_compute.py \
  --record runs/test-time-new/model.json \
  --out runs/test-time-new/analysis.json

程序先保存方案,再加载固定模型版本。记录包含提示、输入输出词元 ID、种子、结束原因、环境、模型文件及源代码指纹;失败回答也保留。生成被打断时,complete 仍为假,计分程序会拒绝把半份记录当作完整实验。不同硬件或依赖版本可能产生不同的随机生成,不能只靠种子保证跨环境逐字一致。

模型运行脚本与计算脚本应放在同一目录。没有 --run 时,它只输出说明:

显式传入 --run --out-dir 新目录,才执行固定的 32 次真实模型采样。
展开真实模型采样代码
"""显式 --run 才执行测试时采样实验;先固定方案,再生成全部 32 个回答。"""

import argparse
from datetime import datetime, timezone
from importlib.metadata import version
import json
from pathlib import Path
import platform
import time

import test_time_compute as lab


def experiment(directory):
    directory.mkdir(parents=True, exist_ok=False)
    plan = lab.make_plan()
    lab.save_new(directory / "plan.json", plan)
    import torch
    from huggingface_hub import snapshot_download
    from transformers import AutoModelForCausalLM, AutoTokenizer
    settings = plan["generation"]
    torch.set_num_threads(settings["threads"])
    snapshot = Path(snapshot_download(
        plan["model"], revision=plan["revision"],
        allow_patterns=["*.json", "*.safetensors", "merges.txt", "README.md", "LICENSE"]))
    tokenizer = AutoTokenizer.from_pretrained(snapshot, trust_remote_code=False)
    model = AutoModelForCausalLM.from_pretrained(
        snapshot, dtype=torch.float32, attn_implementation=settings["attention"],
        trust_remote_code=False).eval()
    report = dict(
        complete=False, started_at=datetime.now(timezone.utc).isoformat(),
        model=plan["model"], revision=plan["revision"], generation=settings,
        plan_sha256=lab.fingerprint(plan),
        source_sha256={path.name: lab.digest(path) for path in (Path(lab.__file__), Path(__file__))},
        model_files_sha256={path.name: lab.digest(path) for path in snapshot.iterdir()
                            if path.is_file() and (path.suffix in {".json", ".safetensors"}
                                                   or path.name == "merges.txt")},
        environment=dict(python=platform.python_version(), platform=platform.platform(),
                         packages={name: version(name) for name in
                                   ("torch", "transformers", "huggingface-hub",
                                    "tokenizers", "safetensors")}),
        rows=[],
    )
    target = directory / "model.json"

    def checkpoint():
        temporary = directory / "model.pending.json"
        temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
        temporary.replace(target)

    checkpoint()
    eos = model.generation_config.eos_token_id
    eos = [eos] if isinstance(eos, int) else eos
    for problem_index, problem in enumerate(plan["problems"]):
        conversation = lab.messages(problem)
        rendered = tokenizer.apply_chat_template(
            conversation, tokenize=False, add_generation_prompt=True)
        inputs = tokenizer.apply_chat_template(
            conversation, tokenize=True, add_generation_prompt=True,
            return_dict=True, return_tensors="pt")
        length = inputs["input_ids"].shape[1]
        if length + settings["max_new_tokens"] > settings["input_plus_output_cap"]:
            raise ValueError("超过固定预算,不静默截断")
        for sample in range(settings["samples"]):
            seed = settings["first_seed"] + problem_index * settings["samples"] + sample
            torch.manual_seed(seed)
            started = time.perf_counter()
            with torch.inference_mode():
                output = model.generate(
                    **inputs, max_new_tokens=settings["max_new_tokens"],
                    do_sample=True, temperature=settings["temperature"],
                    top_p=settings["top_p"], top_k=settings["top_k"],
                    pad_token_id=tokenizer.eos_token_id)
            seconds = time.perf_counter() - started
            ids = output[0, length:].tolist()
            text = tokenizer.decode(ids, skip_special_tokens=True)
            report["rows"].append(dict(
                id=problem["id"], sample=sample, seed=seed,
                messages=conversation, rendered_prompt=rendered,
                input_ids=inputs["input_ids"][0].tolist(), input_tokens=length,
                output_ids=ids, output_tokens=len(ids), text=text,
                stopped_on_eos=bool(ids and ids[-1] in eos), seconds=seconds,
                parsed=lab.parse_answer(text)))
            checkpoint()
            print(f"{problem['id']}/{sample}: answer={lab.parse_answer(text)}, "
                  f"output={len(ids)}, eos={report['rows'][-1]['stopped_on_eos']}", flush=True)
    report["complete"] = True
    report["finished_at"] = datetime.now(timezone.utc).isoformat()
    checkpoint()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if not args.run:
        print("显式传入 --run --out-dir 新目录,才执行固定的 32 次真实模型采样。")
        return
    if args.out_dir is None:
        parser.error("请指定尚不存在的输出目录")
    experiment(args.out_dir)


if __name__ == "__main__":
    main()

9. 自己动手:看看哪里值得多算

  1. 单题独立成功概率为 0.2,尝试 4 次后,至少成功一次的概率是多少?为什么这个数不等于多数票正确率?
  2. 正确答案为 71,候选为 71, 70, 70。候选中存在正确答案吗?投票会选什么?
  3. 一半题必对,一半题必错。每题采样 3 次,候选成功覆盖率是多少?直接代入平均概率会得到多少?
  4. FINAL: 48、48、FINAL: 48 seconds,哪一个符合本页预先固定的格式?怎样另外报告内容正确性,而不偷换指标?
  5. 为什么 24 点的检查器要拒绝只有 24 的回答?表达式 8/(3-8/3) 用到了哪些原始数?
  6. 观察图 2:为什么当前状态中出现了 24,还不能宣布成功?宽度从 1 增加到 4 后,哪一项成本增加了?
  7. 修改搜索器的评分规则,或尝试宽度 16。把八组输入的全部结果记录下来,再增加新的四张牌组合。哪些结果只能叫开发观察,哪些才是固定方案后的新验证?
  8. 对同一批回答,将“最后一个整数”作为新的抽取规则,会有哪些问题?用 rate 题那份被截断的回答说明。
  9. 某个自适应系统将简单题送到短模式、难题送到长模式。短模式准确率更高,能证明“少想更好”吗?怎样做更合适的比较?
  10. 给顺序修订实验设计一张结果表:除了最终正确率,你还会保存哪些计数与成本,才能看出它有没有把原本正确的答案改错?
展开答案与实验提示
  1. 1−0.84=0.59041-0.8^4=0.5904。它只要求四份候选中有一份正确;投票还取决于其余答案的分布和并列规则。
  2. 存在正确候选,但投票会选 70。这就是候选覆盖与最终选择的差别。
  3. 仍为 0.5。误代入平均值会得到 1−0.53=0.8751-0.5^3=0.875,因为它把必对与必错的题误当成单题随机成功率为 0.5。
  4. 只有 FINAL: 48。可以同时保留契约正确率和事后内容复核,明确两个规则及复核时机;不能把后者悄悄替换成原始实验得分。
  5. 只有 24 没有使用四张牌。正确表达式使用两次 8、两次 3,且分母为 1/31/3,不为零。
  6. 还要使用所有剩余牌。当前八组的扩展状态从 24 增加到 72,生成候选从 479 增加到 1055,成功仍为 1/8。这里没有测量语言模型成本。
  7. 这八组已参与阅读和方法调整,属于开发材料。先固定新评分、宽度、停止规则和计数口径,再对未用于调整的新输入运行;仍需说明新输入怎样产生,不只保留成功牌组。
  8. 中间步骤、单位或错误推导中的整数,可能被误当成最终答案。被截断的 rate 回答最后提到了 1,却没有完整结论;“最后一个整数”会让没有答案的轨迹看起来有答案。
  9. 不能,分组中的题目难度不同。应把相同的留出题分别交给各模式,或做预先设计的随机分配,再比较正确率与成本;还要把路由成本计入自适应方案。
  10. 保存每题每轮答案、正确到错误、错误到正确、始终正确、始终错误的数量,以及输入/输出词元、验证器调用、结束原因和实际耗时。只看最后准确率,会掩盖纠错与破坏同时发生的情况。

10. 沿着六篇论文继续读

下面列出本文实际采用的版本。早期论文用来解释方法如何形成,较新的预印本用来观察问题正在向哪里移动;没有把所有版本和不同实验条件混在同一个排行榜里。

文献与版本本页重点阅读原始材料
Xuezhi Wang 等,Self-Consistency Improves Chain of Thought Reasoning in Language Models;初稿 2022,本文读 v4,2023-03-07多条路径与最终答案聚合论文及附录提示
Shunyu Yao 等,Tree of Thoughts;v2,2023-12-03,NeurIPS 2023 定稿中间状态、生成、评价与搜索论文 · 作者代码与轨迹
Hunter Lightman 等,Let’s Verify Step by Step;v1,2023-05-31过程监督与结果监督、验证器仍可能犯错论文 · PRM800K
Charlie Snell、Jaehoon Lee、Kelvin Xu、Aviral Kumar,Scaling LLM Test-Time Compute Optimally can be More Effective than Scaling Model Parameters;v1,2024-08-06生成与验证、难度估计及其成本条件论文
Niklas Muennighoff 等,s1: Simple test-time scaling;本文读初版 v1,2025-01-31微调后的模型如何控制思考长度、重复与上限论文 · 作者代码
Gijs Kassenaar、Zhao Yang、Vincent François-Lavet,Learning When to Think: Adaptive Reasoning for Test-Time Compute Allocation;v2,2026-08-21,预印本学习模式选择、固定模式对照与适用范围论文及方法附录

本书实际运行的是固定小模型采样和自编精确搜索;六篇论文中的训练、提示规模、验证器和基准均有各自条件。阅读时可以拿着同一组问题追问:它增加了哪部分计算?怎样知道自己算对了?评价者是否看到了标准答案?所有选择与检查的成本有没有算进去?

当这些问题有了清楚的答案,“多想一会儿”才从一个愿望变成可以比较、可以改进的方法。