推理与测试时计算:多想一会儿,怎样才有用
工坊里来了一个新问题:三项作业分别需要 30、45、25 秒,相邻作业之间暂停 5 秒。从第一项开始,到最后一项结束,一共需要多久?
我们很快就能算出:
关键不在加法,而在“相邻”二字:三项作业之间只有两次暂停。如果模型漏看了这个条件,多给它一点时间,会不会有帮助?
也许。它可以重新读题,换一种算法,检查时间线,还可以生成几个答案再比较。但它也可能把同一个错误说得更长,或者从四个错误答案中选出最有把握的一个。
测试时计算研究的,就是模型已经训练好以后,怎样把额外的计算花在当前问题上。 这一页会先拆开“生成候选”和“挑选答案”,再用真实的小模型记录和一个能精确检查的搜索实验,看看计算预算究竟换来了什么。
前置内容是语言模型与采样、推理能力与可验证奖励和大模型推理过程与加速。本页资料核查截至 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 得两票,也会被选中。没有合法答案的候选如何处理,同样要事先约定。
一个简单公式,藏着几个重要前提
对一道固定的题,假设每次尝试成功的概率都是 ,各次成功事件相互独立。那么尝试 次,至少成功一次的概率是:
推导只需要反着想:一次失败的概率为 ,连续 次都失败是 ,用 1 减去它即可。
这算的是至少出现过正确答案,不是投票或验证器一定选对的概率。某些报告中的 pass@ 描述的就是候选中存在成功解的机会;还要核对它采用直接抽样还是组合估计。本书只统计固定候选池的前 份,不把单次观察当作精确的成功概率。
假设每次有 40% 的概率回答正确,剩下 60% 都答同一个错误数值。三票多数正确,需要恰好两票或三票正确:
一票正确率为 0.4,三票反而降到 0.352;五票进一步降到 0.31744。与此同时,候选中至少出现一次正确答案的概率却在上升。更多候选与更好的最终选择,可以朝不同方向变化。
这个反例已经假定每次独立采样。多次答出同一个错数,并不能单独证明采样“不独立”:同一个错误本来就可能在模型的分布里占很大概率。
不同题的难度,不能揉成一个平均数
再假设题目一半特别容易,模型每次都答对;另一半无论采样多少次,都答不对。平均单次正确率为 0.5,但每题尝试 5 次后,能够找到正确答案的题目仍只有一半。
如果把平均值直接代入 ,却会得到 0.96875。这是把两种题误当成了一种题。
记第 道题的单次成功概率为 ,总共有 道题。在各题内部满足前面独立、同分布的条件时,平均成功机会应为:
先分别算,再平均。不能只知道平均单次正确率,就断言增加采样会沿某条理想曲线上升。
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 份 |
使用同一个候选池的前缀,能避免不同预算恰好抽到完全不同的一批回答,但这些预算点相互关联,不是三次独立实验。八道题也太少,不适合给模型做通用能力排名。
结果没有出现期待中的上升曲线
| 每题候选数 | 投票答对 | 候选中存在合规正确答案 | 不合规候选 |
|---|---|---|---|
| 1 | 0/8 | 0/8 | 8/8 |
| 2 | 0/8 | 0/8 | 16/16 |
| 4 | 0/8 | 0/8 | 32/32 |
这个结果首先说明:当前模型与提示组合没有完成约定的输出任务。 因为没有任何合法票,主表也无法比较“多数票究竟能否从合法答案中选得更好”。不能从这张表推出自一致性方法普遍无效。
再把账单放在旁边:
| 每题候选数 | 输入词元合计 | 输出词元合计 |
|---|---|---|
| 1 | 698 | 48 |
| 2 | 1396 | 81 |
| 4 | 2792 | 490 |
图 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:
这个式子很有意思:先算出来的是 ,看上去离 24 很远,却是正确路线的第一步。
一步怎样变成下一步
我们把“还剩哪些数”叫作状态。初始状态有四个数;每一步选两个数做一次运算,再把结果放回去,数量就减少一个。做完三步,只剩一个数;若它等于 24,就找到解。
这个任务可以完整枚举:对每一对数尝试加、乘、两种方向的减法和合法的除法。中间结果使用 Python 的 Fraction 保存精确分数,避免把 舍入为小数后再比较。
程序保留完整的表达式。最后的检查器不只问“结果是不是 24”,还要确认四张牌各用了一次。例如,直接输出 24 数值正确,却没有使用任何牌,应当拒绝。
检查器只读取受限的表达式语法树,允许正整数叶子和二元四则运算,不使用 eval 执行生成的代码。这是一个有清楚规则的小任务,检查通过就能确认该表达式符合这里的要求;它不能直接检查任意自然语言证明。
只保留看起来最接近 24 的状态,会怎样
完整搜索可能很贵。我们试一个刻意简单的评分:在当前状态里,找与 24 差得最少的数,把这个距离当作状态的分数:
表示一组尚未用完的数, 是其中一个数。分数越小越优先,每一层只保留前 个状态。这个 就是这里的搜索宽度。同分时按排序后的精确数值确定顺序,规则固定,不读取标准解。
这条规则有明显弱点:只要暂时得到 24,它就非常满意,哪怕旁边还剩两张没法处理的牌。对 3、3、8、8,宽度为 1 的搜索首先留下 3, 8, 24,之后一路走到 19。
图 2:左侧来自宽度为 1 的实际搜索轨迹;右侧把完整搜索输出的正确表达式逐步展开。右侧的第一步没有急着接近 24,却保留了走向正确解的可能。
这里使用的是本书自编的确定性搜索器,没有语言模型参与提议或评分。Yao 等人的 Tree of Thoughts 将中间思路组织成可搜索状态,并用语言模型生成、评价候选;其 24 点实验也采用有限宽度的搜索。本书借用了同类任务来拆解搜索机制,不把自己的精确枚举称为 ToT 复现。原论文第 3、4 节
八组牌,实际搜索了多少
固定的八组输入保存在实验方案里,全部存在合法解。对它们运行三种宽度:
| 保留宽度 | 找到解 | 扩展状态数 | 生成候选状态数 |
|---|---|---|---|
| 1 | 1/8 | 24 | 479 |
| 4 | 1/8 | 72 | 1055 |
| 全部 | 8/8 | 1597 | 11995 |
“扩展”表示取一个状态,枚举它的下一步;“生成”包含去重前产生的候选。同一层中,包含相同精确数值、且各数出现次数相同的状态可以合并,因为它们后续可进行的算术相同;保留一条使用原牌构造表达式的路径即可。程序在最后一层完成检查,没有提前找到一个解就停止。
宽度从 1 增加到 4,算得更多,成功数却没变。全部保留才找回那些过早被删掉的路线。这不能证明“搜索应该永远穷举”:换成几十步、每步上百个分支,状态数量就会迅速增长。它说明的是,预算和评分质量要一起看。真实语言模型的中间状态,也很难像这里的有理数集合一样精确合并。
5. 给答案打分的模型,也会犯错
在 24 点里,我们可以执行算术并检查牌的使用次数。换成开放问题,通常没有这么便宜、完整的检查器。
一种做法是训练结果奖励模型,为完整回答打分;另一种是训练过程奖励模型,为中间步骤提供反馈,再将这些分数用于筛选或搜索。Lightman 等人的 Let’s Verify Step by Step 比较了这两类监督,并公开逐步标注数据。其模型评分依然是学习得到的判断,不能等同于前面的精确表达式检查。原论文、PRM800K 数据与说明
先生成 份完整候选,再交出评分最高的一份,常称为 best-of-N。它和投票的区别很直接:投票数“大家都答什么”,评分器判断“哪一份看起来更好”。这里的“best”指评分最高;是否真的正确,还要由独立的评价来回答。过程评分也可以提前用于搜索,不必等整份答案写完。
例如,一份答案每一步都写得很像训练中的正确解,但第二步偷偷漏掉了条件,后面仍可能得到高分。把步骤分数相乘,也不会自动把这些分数变成可靠的联合正确概率:步骤如何切分、分数是否校准、错误之间的关系,都需要检查。
更多候选还会放大“找到高分错误”的机会。做一个只用于说明机制的假设:16 个候选其实全错,检查器对每个候选各有 5% 的误放概率,而且这些误放事件相互独立。那么至少误放一个的概率是:
这不是任何现成验证器的实测错误率,也不直接等于最终选择错误的概率——我们还没有规定从通过者中怎样选。它提醒我们,单条候选的误放率与整个选择流程的风险不是同一个量。
评估时至少要把两层分开:候选池里有没有正确解,以及选择器最终交出的解是否正确。如果前者上升、后者不升,可能该改进的是选择规则;如果候选里根本没有好解,再聪明的排序也无从选择。
6. 继续思考,什么时候只是继续重复
多次从头生成之外,还可以沿同一条轨迹检查、修订。后一步能利用前一步的中间结果,这是优势;后一步也可能被前面的错误带着走,这是需要观察的失败方式。
Muennighoff 等人的 s1 先用筛选后的推理示例微调 Qwen2.5-32B-Instruct,再通过解码干预控制思考长度:需要提前结束时插入结束思考的分隔;需要延长时抑制这一分隔,并追加 Wait 等提示。论文也报告过延长过多后出现重复、收益趋缓的限制。因此,这项研究不能被简化成“任何模型多加一句再想想,就会更聪明”。本文所读 v1 的第 3、5、6 节
做顺序修订实验时,可以让每一轮保留“改之前”和“改之后”的答案,并分别统计错改对、对改错和没有变化。只展示一条成功纠错的长回答,会漏掉另外两种情况。
预算也不只是新写了多少字。把前几轮回答放回提示,会增加后续输入;调用验证器或工具,也要计入计算与等待。对于固定服务,还可以记录总词元、实际 FLOPs 的明确口径、首词元延迟和总完成时间。它们相互有关,却不能互相冒充。
读模型给出的解释时,也应分别问:最终答案对不对?写出来的步骤成立吗?这些文字是否准确反映了产生答案的内部原因?前两项可以逐步检查,不能仅靠一段流畅文字就替第三项下结论。
7. 不同问题,需要一样多的预算吗
“把 3 和 5 相加”与“安排一周生产计划”,显然不该机械地得到相同的计算配额。可是部署时,我们只有题目,没有标准答案,怎样知道下一份计算值不值得?
Snell 等人把测试时计算拆成候选生成与验证,比较修订、搜索及不同预算分配。一个值得仔细读的细节是难度怎样估计:原文区分利用真值成功率的难度分组与验证器预测的分组,并说明难度估计本身的额外计算没有纳入其主要比较。使用了专门微调的修订或验证能力,也影响结论能够迁移到哪里。原论文第 3.2、4 节
这个细节很实用。假如先生成很多候选来判断难度,随后只报告正式求解使用的采样数,前面的判断成本就可能被漏掉。研究中的机制比较有其用途,但部署账单必须覆盖完整流程。
一种简洁的写法是:对问题 ,策略 决定采用哪种方法、用多少预算,目标为:
可以是最终是否答对, 是预先定义的完整成本, 是平均预算上限;期望表示在目标问题分布与生成随机性上取平均。这是本页对预算选择问题的概括,不是一条直接可用的训练算法。
平均预算也不能代替单次上限。一个策略可能平均很省,但偶尔让用户等很久,因此实际系统往往还需要每个请求的长度或时间限制。
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. 自己动手:看看哪里值得多算
- 单题独立成功概率为 0.2,尝试 4 次后,至少成功一次的概率是多少?为什么这个数不等于多数票正确率?
- 正确答案为 71,候选为
71, 70, 70。候选中存在正确答案吗?投票会选什么? - 一半题必对,一半题必错。每题采样 3 次,候选成功覆盖率是多少?直接代入平均概率会得到多少?
FINAL: 48、48、FINAL: 48 seconds,哪一个符合本页预先固定的格式?怎样另外报告内容正确性,而不偷换指标?- 为什么 24 点的检查器要拒绝只有
24的回答?表达式8/(3-8/3)用到了哪些原始数? - 观察图 2:为什么当前状态中出现了 24,还不能宣布成功?宽度从 1 增加到 4 后,哪一项成本增加了?
- 修改搜索器的评分规则,或尝试宽度 16。把八组输入的全部结果记录下来,再增加新的四张牌组合。哪些结果只能叫开发观察,哪些才是固定方案后的新验证?
- 对同一批回答,将“最后一个整数”作为新的抽取规则,会有哪些问题?用
rate题那份被截断的回答说明。 - 某个自适应系统将简单题送到短模式、难题送到长模式。短模式准确率更高,能证明“少想更好”吗?怎样做更合适的比较?
- 给顺序修订实验设计一张结果表:除了最终正确率,你还会保存哪些计数与成本,才能看出它有没有把原本正确的答案改错?
展开答案与实验提示
- 。它只要求四份候选中有一份正确;投票还取决于其余答案的分布和并列规则。
- 存在正确候选,但投票会选 70。这就是候选覆盖与最终选择的差别。
- 仍为 0.5。误代入平均值会得到 ,因为它把必对与必错的题误当成单题随机成功率为 0.5。
- 只有
FINAL: 48。可以同时保留契约正确率和事后内容复核,明确两个规则及复核时机;不能把后者悄悄替换成原始实验得分。 - 只有 24 没有使用四张牌。正确表达式使用两次 8、两次 3,且分母为 ,不为零。
- 还要使用所有剩余牌。当前八组的扩展状态从 24 增加到 72,生成候选从 479 增加到 1055,成功仍为 1/8。这里没有测量语言模型成本。
- 这八组已参与阅读和方法调整,属于开发材料。先固定新评分、宽度、停止规则和计数口径,再对未用于调整的新输入运行;仍需说明新输入怎样产生,不只保留成功牌组。
- 中间步骤、单位或错误推导中的整数,可能被误当成最终答案。被截断的
rate回答最后提到了 1,却没有完整结论;“最后一个整数”会让没有答案的轨迹看起来有答案。 - 不能,分组中的题目难度不同。应把相同的留出题分别交给各模式,或做预先设计的随机分配,再比较正确率与成本;还要把路由成本计入自适应方案。
- 保存每题每轮答案、正确到错误、错误到正确、始终正确、始终错误的数量,以及输入/输出词元、验证器调用、结束原因和实际耗时。只看最后准确率,会掩盖纠错与破坏同时发生的情况。
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,预印本 | 学习模式选择、固定模式对照与适用范围 | 论文及方法附录 |
本书实际运行的是固定小模型采样和自编精确搜索;六篇论文中的训练、提示规模、验证器和基准均有各自条件。阅读时可以拿着同一组问题追问:它增加了哪部分计算?怎样知道自己算对了?评价者是否看到了标准答案?所有选择与检查的成本有没有算进去?
当这些问题有了清楚的答案,“多想一会儿”才从一个愿望变成可以比较、可以改进的方法。