llama-server 开启推测解码后,所有推测 token 的 logprob 固定为 0.0、top_logprobs 为空,需关闭推测解码才能获取真实概率。
TL;DR: 当 llama-server 以投机解码模式运行(draft model 配合 -md、MTP 或某一种 n-gram 类型)时,投机循环中输出的每一个 token 都以 logprob: 0.0 和空的 top_logprobs 列表发送。代码将概率设为 1.0,注释写着 // set later,但之后再也没有实际设置它。对于 draft model 来说,每个 token(第一个之后)都是如此。在 b11430(当前 release)上,温度 1、64-token 采样的平均 logprob:无投机时为 -0.48,有投机时为 -0.0011。生成的文本看起来正常,响应或日志中也没有任何信息表明这些数字是占位符。没有按请求切换的开关:过去用于调整投机的请求字段已被编译掉并被忽略。将需要 logprobs 的请求发送到未启用投机的实例。token 本身没问题:用一个小 fixture 测试时,服务器的投机输出与普通采样的下一个 token 分布完全一致。llama-speculative 示例程序则不然。工具包的 check-spec-logprobs.sh 可以检查运行中的服务器。上游:ggml-org/llama.cpp#27972 和 #29975。
环境是 Debian 13 LXC,纯 CPU,从 release tarball 编译的 llama.cpp b11430(当前 release,SHA-256 与 release digest 匹配),模型为 gemma-3-1b-it-Q4_K_M。Draft model 通常必须比 target 模型小才能节省时间;用同一个文件作为自己的 draft 就足以让每个 token 都经过投机路径,而且不需要额外下载。请求内容来自该报告:
curl -s http://127.0.0.1:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{
"messages": [{"role": "user", "content": "Explain how a CPU branch predictor works."}],
"max_tokens": 24, "temperature": 1.0, "seed": 1, "logprobs": true, "top_logprobs": 3}'
'Okay' logprob=-0.00813608 top_logprobs=3
',' logprob=0 top_logprobs=3
' let' logprob=-2.38419e-07 top_logprobs=3
'’' logprob=-2.60903 top_logprobs=3
's' logprob=0 top_logprobs=3
' break' logprob=-0.0479959 top_logprobs=3
同一服务器启动时加 -md gemma-3-1b-it-Q4_K_M.gguf --spec-type draft-simple:
'Okay' logprob=-0.00813608 top_logprobs=3
',' logprob=0 top_logprobs=0
' let' logprob=0 top_logprobs=0
"'" logprob=0 top_logprobs=0
's' logprob=0 top_logprobs=0
' break' logprob=0 top_logprobs=0
第一个之后的全部 23 个 token 都返回 0 且没有候选词,温度 0 和温度 1 都是如此,在 /v1/chat/completions 和原生 /completion 配合 n_probs 上都是如此。开启 post_sampling_probs: true 时,原生端点对第一个之后的每个 token 都报告 prob: 1.0 和空的 top_probs;而未经过投机的运行中,例如 What 的 prob 是 0.048。
对任何聚合 logprobs 的操作来说,影响不可忽视。5 个 seed,每个 64 tokens,"Write a short story about a lighthouse keeper.",温度 1:
no speculation -0.5158 -0.6775 -0.3832 -0.3360 -0.4898 mean -0.4805
draft model -0.0055 -0.0 -0.0 -0.0 -0.0 mean -0.0011
该报告在 Vulkan iGPU + Gemma 4 26B + MTP drafter 上也看到了同样的形态:用 MTP 时均值为 -0.00107,不使用时为 -0.38244,测量本意是看 MTP 是否改变了输出。b10703(8 月的版本,在 #27694 之前)在同样的探测下同样给出了占位符。
为什么容易漏掉
所有字段都在。logprob 是个数字,top_logprobs 是个列表,响应也能通过你手头的任何 schema 校验。
logprob 恰好为 0 也不可疑。在上面无投机的运行中,, 和 s 也返回了 0 并带有三个候选词:1B 模型对这些 token 就是这么确定。所以不能通过找零来发现占位符。关键信号是请求了 3 个候选词但 top_logprobs 是空的。
文本看起来正常,所以读输出的人不会觉得有问题。服务器自己的描述也没有帮助:/props 在以 -md 和 --spec-type ngram-mod 启动的服务器上报告默认生成配置中 "speculative.types": "none"。/slots 才是正确的地方:"speculative": true。
用 n-gram 类型时情况更复杂。那些类型只在近期文本有重复时才触发 draft,所以单个响应中混合了真实值和占位符。在一个让模型复制重复句子的 prompt 上,--spec-type ngram-mod 默认配置下,30 个 token(第一个之后)中有 27 个正确、3 个是占位符。换用更短的匹配长度则是 30 个中有 26 个占位符。
到底发生了什么
tools/server/server-context.cpp 在 b11430 有两个地方发出生成的 token。正常路径:
result.prob = 1.0f; // TODO: set it here instead of doing inside populate_token_probs
if (slot.task->params.sampling.n_probs > 0) {
populate_token_probs(slot, result, slot.task->params.post_sampling_probs, params_base.special, tok_idx);
}
以及 target 验证了一个 draft 之后的那个,它遍历被接受的 token 加上 target 自己采样的那个:
for (size_t i = 0; i < ids.size(); ++i) {
completion_token_output result;
result.tok = ids[i];
result.text_to_send = common_token_to_piece(slot.ctx_tgt, result.tok, accept_special_token(slot, result.tok));
result.prob = 1.0f; // set later
// TODO: set result.probs
没有任何地方在"之后"设置它。populate_token_probs 只在一个地方调用,就是正常路径。响应的第一个 token 总是来自正常路径,因为此时还没有什么可以 draft,所以只有它有真实值。之后,对于有 draft model 的情况,每一步都经过投机循环。这包括 target 拒绝整个 draft 的那些步骤:在一个 2-token 的原生请求中 draft 得到 0/3 被接受,而第二个 token(由 target 自己采样)仍然返回空值。
投机解码是否也会改变 token?
如果 logprobs 是错的,下一个显而易见的问题就是输出是否也错了。投机解码的本意是无损的:target 的验证应该让采样 token 的分布与没有 draft 时完全一致。#27694 在 2026-10-02 合入,已在 b11430 中,它让服务器通过在温度大于 0 时做拒绝采样来验证 draft-simple 和 MTP draft,其描述说输出分布被精确保留。
#29975 报告 llama-speculative 示例程序没有保留分布,并提供了一个 fixture 来展示:两个 24 KB 的 GGUF,带一个八字符词表,一个作为 target,一个作为加噪副本作为 draft。在 prompt bcdbc 和第一个生成的 f 之后,target 自己的 next token top-3 分布是 e 0.345、g 0.452、h 0.203。归档的 SHA-256 与 issue 中的一致。在 b11430 上从源码构建,用报告中的脚本跑 seed 1 到 1000:
seeds 1..1000; runs with first token 'f' (5): 820
next token 4 ('e'): 1.000 target: 0.345
next token 6 ('g'): 0.000 target: 0.452
next token 7 ('h'): 0.000 target: 0.203
该报告没有测试服务器。同样的 fixture 走同一构建的 llama-server,相同的采样器设置(top-k 3, temperature 1),1000 个 seed,有无 -md draft.gguf:
no speculation first token 'f': 840 next e 0.330 g 0.452 h 0.218
draft model first token 'f': 840 next e 0.330 g 0.452 h 0.218 (2470 drafted, 884 accepted)
两者都在采样误差范围内与 target 分布一致(840 个样本时一个标准差约 0.016)。所以服务器的投机路径做到了 #27694 所说的:它改变了报告概率的 token,但没有改变选取的 token。示例程序在有 draft 时总是选 e,而学习投机解码或做基准测试时它是很自然的参考。该报告指出两行代码:示例取了 draft 的 top 候选而不是它采样的那个,并且在拒绝时它逐位置对两个分布做减法,当 top-k 留下不同集合时会将不同的 token 配对在一起。这些理由来自报告;上面只有输出是我自己测的。
没有办法单独为一个请求关闭投机。服务器过去接受每个请求级别的 speculative.n_max 及相关字段;在 b11430 上它们在 tools/server/server-schema.cpp 的一个 #if 0 块里("we disable speculative parameter adjustments for now"),未知字段则被忽略。发送 "speculative.n_max": 0 或 1 没有任何改变:同样 17 个 token 被 draft,同样 23 个占位符返回。
将需要 logprobs 的请求发送到未以 -md / --spec-type 启动的服务器。上面的未经过投机的运行在每个 token 上都返回了真实值。如果其他请求需要投机,那就需要第二个实例。
查看 /slots 而不是 /props 来确认服务器是否开启了投机。curl -s localhost:8080/slots | jq '.[].speculative'。
把请求了候选词但 top_logprobs 是空的当作缺失值,而不是一个确定的值。如果你无法更换服务器,至少不要对这些 token 做平均。
如果要做分布基准测试,用 llama-server 而不是 llama-speculative,直到 #29975 被修复。
当一个测量结果太好时,先检查测量工具再检查被测物。报告者的数字显示 MTP 使一个模型在温度 1 时几乎完全确定,这在物理上不可信,这才让他们去看 logprobs 而不是模型。一个简单的检查方法是:在你知道必须不同的条件下跑同样的测量,这里就是有 draft 和无 draft 两种情况,看工具能否区分开。在这里它做到了,而全部差异都来自工具。
第二个检查是人们常跳过的一个:当一个优化应该不可见时,用小到可以计数的东西来验证它。两个 24 KB 的模型和 1000 个 seed 足以证明服务器的投机保持分布不变,而示例程序没有。
工具包的 check-spec-logprobs.sh 读取 /slots,发送一个 logprobs 请求,报告第一个之后的 token 是否带有候选词。
这篇文章的修复方案以经过测试、可直接运行的脚本形式存在于工具包中。