Test-time Compute:用更多推理换更高正确率
- Published on
Test-time Compute:用更多推理换更高正确率
以前我们谈模型升级,习惯把注意力放在训练阶段:更大的参数、更多的数据、更好的微调。模型发布以后,推理基本被看成一次固定动作——输入问题,生成答案,结束。
Test-time Compute 改变了这个默认设定。它允许模型在回答问题时使用更多计算:多生成几条候选、检查中间结果、搜索不同路径、调用验证器,甚至在不确定时继续思考。模型不一定只靠“记住更多”变强,也可以靠“回答时认真一点”提高复杂任务的正确率。
我第一次把它放进真实服务时,兴奋只持续了半天。准确率确实上去了,但 Token 变多了,P95 延迟变长了,简单问题也被迫走复杂流程。后来才明白,Test-time Compute 不是免费性能,它是一笔可以调度的预算。真正的工程问题不是“要不要多算”,而是“哪些问题值得多算,多算到什么时候,怎么证明多算确实带来了收益”。
一、训练时计算和推理时计算有什么不同
训练时计算发生在模型参数更新之前。大量样本通过前向、反向传播改变参数,让模型在以后面对类似输入时拥有更好的概率分布。成本通常集中在训练集群,用户请求不会直接感受到这笔计算。
推理时计算发生在一次具体请求里。模型已经固定,系统为某个问题临时投入更多计算,以换取更高质量的答案。它不修改模型参数,却可以改变最终结果。
训练时 Compute:很多问题 → 更新参数 → 以后整体能力提升
推理时 Compute:一个问题 → 多次尝试/检查 → 当前答案更可靠
两者不是互相替代。训练可以让模型学会更好的策略,推理时计算则让它在困难问题上有更多机会使用这些策略。简单问题不需要把全部预算花掉,复杂问题也不应该只靠一次短生成碰运气。
二、最简单的方式:Best-of-N
Best-of-N 的思路非常直白:同一个问题生成 N 个候选,再从中选一个最好的。选择可以通过确定性验证器、规则、奖励模型或裁判模型完成。
问题 q
├─ 候选 1 → 验证
├─ 候选 2 → 验证
├─ 候选 3 → 验证
└─ 候选 N → 验证
↓
选出最佳结果
对于有明确答案的数学题和代码题,验证器最可靠。对于开放式写作,可能需要综合事实、相关性、风格和安全性评分。关键是“最好”必须有定义,不能让另一个模型只凭语气选出最长的答案。
type Candidate = {
text: string
tokens: number
}
type ScoredCandidate = Candidate & {
score: number
passed: boolean
}
async function bestOfN(prompt: string, n = 4) {
const candidates: Candidate[] = await Promise.all(
Array.from({ length: n }, () => model.generate(prompt)),
)
const scored: ScoredCandidate[] = await Promise.all(
candidates.map(async (candidate) => {
const verification = await verify(prompt, candidate.text)
return {
...candidate,
score: verification.score,
passed: verification.passed,
}
}),
)
return scored
.filter((candidate) => candidate.passed)
.sort((left, right) => right.score - left.score)[0]
}
Best-of-N 的优点是容易理解、容易并行、容易接入现有模型。缺点也明显:成本大约随 N 增加,候选可能共享同一个错误,验证器不可靠时只是在选择最会骗验证器的答案。
三、自洽性不是简单投票
如果任务没有严格的标准答案,可以让模型多次采样,再把不同答案归一化后看是否一致。这就是常说的 self-consistency。比如一道需要多步推导的题,多条独立路径最后得到同一个数字,通常比单次生成更有信心。
但多数票不是事实证明。模型可能共享同一条错误捷径,也可能因为提示词或训练数据偏差,稳定地重复错误。更稳妥的组合是:先尽量把候选转成可验证结果,再在通过验证的候选中做一致性统计;无法自动验证的任务,保留人工抽样和不确定性表达。
function normalizeAnswer(text: string) {
return text
.trim()
.toLowerCase()
.replace(/[,,。.!!??\s]/g, '')
}
function majorityVote(candidates: string[]) {
const counts = new Map<string, number>()
for (const candidate of candidates) {
const key = normalizeAnswer(candidate)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
}
文本归一化只能处理很浅的差异。1/2 和 0.5 需要数学解析器,两个不同措辞的方案需要语义评测。不要因为代码只有十行,就误以为一致性判断已经解决。
四、搜索与分支:把答案当成一个小型搜索问题
对于复杂推理,模型可以先提出多个中间计划,再分别展开,最后比较结果。这个过程更接近树搜索:每个节点代表一个中间状态,每条边代表下一步动作,验证器或奖励函数负责判断哪些分支值得继续。
起点
/ | \
计划 A 计划 B 计划 C
/ \ | \
A1 A2 B1 C1
↓
校验并选择分支
搜索的价值是避免一次错误决定把后面全部带偏,代价是状态数量可能指数增长。因此必须限制深度、宽度和总预算,尽早剪枝明显失败的分支。对没有可靠评估函数的开放问题,盲目扩大搜索只会制造更多互相矛盾的文本。
工程上可以把搜索节点持久化为结构化状态,而不是把整棵树拼进 Prompt:
type SearchNode = {
id: string
parentId?: string
plan: string
observations: string[]
score?: number
status: 'open' | 'pruned' | 'verified' | 'failed'
depth: number
}
有了节点 ID,系统可以缓存已经验证的结果,失败后从未完成节点恢复,也可以分析模型最常在哪一层做出错误选择。
五、动态预算比固定长思考更合理
并不是每个问题都需要同样多的推理。一个成熟的服务应该根据问题难度和中间结果动态分配预算:
先用小预算尝试
↓
答案通过验证?──是──→ 返回
│否
↓
增加预算或改变策略
↓
再次验证,直到成功或达到上限
可以使用问题分类器、历史失败率、模型自报不确定性和验证器结果作为信号。但“模型说自己很有信心”不能单独决定预算,因为语言模型的置信表达经常校准不佳。真正可靠的信号来自结果检查和任务状态。
type InferenceBudget = {
maxAttempts: number
maxTokens: number
deadlineMs: number
}
function chooseBudget(input: { complexity: 'low' | 'medium' | 'high' }) {
if (input.complexity === 'high') {
return { maxAttempts: 4, maxTokens: 12000, deadlineMs: 15000 }
}
if (input.complexity === 'medium') {
return { maxAttempts: 2, maxTokens: 5000, deadlineMs: 7000 }
}
return { maxAttempts: 1, maxTokens: 1200, deadlineMs: 2500 }
}
预算策略也要允许提前结束。答案已经通过验证时继续生成,只会增加延迟和风险。相反,连续多次没有新信息时,应该停止并说明无法确认,而不是把“再试一次”当成万能药。
六、推理时计算的成本怎么算
成本不能只看模型标价。一次复杂请求可能包含多个候选、验证器调用、检索、排序和人工审批。总成本至少包括:模型输入输出 Token、GPU 或 API 调用、验证器运行、存储 Trace、网络和人工处理。
单请求成本
= 候选数量 × 单次模型成本
+ 验证器成本
+ 检索与重排成本
+ 失败重试成本
+ 必要的人工成本
延迟也不能只报平均值。并行生成可以减少墙钟时间,但会提高瞬时并发;串行验证更容易控制资源,却会拉长 P95。服务设计要同时看 P50、P95、超时率和单位任务成本。
一个常见错误是为了提高离线准确率,把所有线上请求都切换到最大预算。结果是简单问题也变慢,用户体验变差,GPU 队列被长任务占满,真正困难的问题反而得不到资源。合理的做法是按任务价值和难度分层,并设置全局并发与成本上限。
七、质量收益必须通过对照实验证明
Test-time Compute 不是“生成越多越好”,需要做预算曲线:预算为 1、2、4、8 时,准确率、延迟、Token 和成本分别如何变化。你会经常看到一个拐点:增加预算早期收益明显,超过某个点后,正确率几乎不再增长,成本却继续上涨。
评测集要分层,包括简单题、复杂题、无答案题、对抗题和需要工具的题。还要观察错误类型是否变化:模型可能减少粗心错误,却增加过度推理和幻觉引用;最终平均分上升,不代表所有用户场景都变好。
type BudgetExperiment = {
budget: number
accuracy: number
p95LatencyMs: number
avgTokens: number
costPerTask: number
}
保存每个预算点的完整轨迹,才能解释曲线。只保存最后答案,无法知道多出来的计算究竟用于有效检查,还是用于重复同一错误。
八、什么时候不值得使用更多计算
有些任务的瓶颈不是思考,而是信息。知识库里没有资料,模型再生成十次也不会凭空获得事实;权限被拒绝,搜索再多次也不应该绕过边界;输入本身含糊,继续推理只会把猜测写得更确定。
遇到这些情况,正确的动作是检索、追问、请求授权或直接拒答。Test-time Compute 适合解决“已有能力但一次没做对”的问题,不适合掩盖缺数据、缺权限和目标不清。
同样,格式转换、简单分类和确定性计算往往应该交给普通代码。让大模型为一个可以用正则表达式解决的问题采样八次,是对成本和架构的双重浪费。
九、生产环境必须设置护栏
多次推理会放大任何已有缺陷,所以要把限制写进系统:最大步骤、最大 Token、最大墙钟时间、最大并发、最大工具调用次数和最大副作用次数。每个候选都应有自己的 Trace,验证结果与最终答案要能关联。
高风险动作尤其不能因为某个候选分数最高就自动执行。付款、删除、发布和权限变更应在候选生成后进入预览和审批;选择“最好答案”不等于获得执行授权。
此外要防止候选之间互相污染。多个采样应尽量保持独立,不能把第一个候选的错误结论无条件塞给后续候选;如果使用共享检索结果,要记录它们共同依赖的证据,避免把相关性误当成独立验证。
十、我的总结:把计算当成预算,而不是信仰
Test-time Compute 最重要的改变,是让我们承认一次回答不必只有一条路径。模型可以尝试、比较、校验和回退,复杂问题的正确率因此有机会提升。但每一次额外计算都要付钱,都要占延迟,都可能把错误重复得更坚定。
我现在看一个“推理模型变强”的结果,会追问四件事:它增加了多少计算?正确率提升来自哪些任务?验证器是否独立可靠?单位任务的成本和延迟是否值得?只有把这些问题答清楚,质量提升才有工程意义。
最好的策略通常不是让所有问题都长思考,而是先用小预算判断,证据不足时再逐步加码;能自动验证的地方自动验证,信息缺失时去找信息,风险过高时停下来请求确认。这样,推理时计算才是一种可调度的能力,而不是一条“模型越努力,系统越先进”的口号。
从更长的历史看,这可能是大模型发展的一个重要转折:模型能力不再只写在参数里,也写在回答时允许它使用多少时间、多少候选和多少外部验证里。真正成熟的系统,会把这份计算用在值得的地方,并且能证明它确实换来了更好的结果。