这篇能做出什么
给本地跑 llama.cpp 的人一个"零成本"提速手段:prompt lookup(查表起草 / lookup decoding)。
做完之后你会得到:
- 一个开了查表起草的
llama-server或llama-cli命令,不需要加载任何额外的草稿模型 GGUF。 - 一套可复现的对照测速脚本,能量化出"开 / 不开起草"的 tokens/s 差距。
- 对"什么样的任务能提速、什么样的任务白开"的判断力,以及它和草稿模型、并行起草之间的取舍。
典型适用任务:把 CSV 转成 Markdown 表格、把一段配置改写成另一种格式、代码重命名与补全、让模型引用原文作答。这类任务的输出里有大量 token 是从输入里"抄"过来的,命中率高,提速明显。反过来,写故事、自由翻译这类每个词都要新造的任务,收益会掉到接近 0。
---
前置条件清单
1. 一台能跑动目标模型的机器(纯 CPU 也能测,GPU 上更容易看出效果)。
2. 一份已编译好的 llama.cpp,至少包含 llama-server 和 llama-cli。编译方式以官方文档当前版本为准。
3. 一个 GGUF 模型文件,以及你自己机器上的实际磁盘路径。
4. Python 3 与 urllib(标准库即可,不用装第三方包)。
5. 会看命令行帮助:llama-server --help。参数名在不同构建里会演进,凡是本文提到的开关,都以你自己那份构建的 --help 输出为准。
---
第 1 步:先确认你的构建里有这些开关
查表起草没有独立的一套"大命令",它是挂在投机采样(speculative decoding)参数体系上的。先确认可用性:
```bash
llama-server --help | grep -iE "draft|lookup"
```
你应该能看到类似下面这几类条目:
```text
--draft-max N 单次最多起草多少个 token
--draft-min N 少于这个长度的起草直接放弃
--draft-p-min P 起草 token 的最低概率门槛
--lookup-cache-static FNAME 从文件加载一份预置 n-gram 表
--lookup-cache-dynamic FNAME 把运行中形成的 n-gram 表保存到文件
```
如果 grep 出来的为空,说明这份构建里相关功能没编进去,或者参数名改了。用 llama-server --help | head -n 200 手动翻一遍,或者搜 draft。
怎么算"开启":不指定草稿模型(不传 -md / --model-draft)而只给出 --draft-max 时,llama.cpp 会走 n-gram 查表这条路径。如果你的构建里另有更明确的 lookup 开关(例如帮助文本里出现 lookup 字样且带默认值),优先按帮助文本写的来,本文后续命令在此基础上加一行即可。
---
第 2 步:理解查表起草是怎么"抄"的
投机采样的通用套路是:先廉价地猜一段后续 token(draft),再让主模型一次前向并行验证这段猜测,把匹配的前缀一次性接受。猜得越准、接受得越长,同样的墙钟时间里产出的 token 就越多。
普通做法要额外挂一个小模型来猜。查表起草不挂模型,改成"翻历史记录":
1. 维护一张 n-gram 表,键是最近若干个 token 组成的片段,值是这个片段在历史上后面跟着的 token 序列。
2. 生成到某一步时,拿当前上下文末尾的 n-gram 去查表。
3. 命中,就把历史上它后面跟着的那串 token 拿出来当 draft,交给主模型验证。
4. 长度上限由 --draft-max 控制;如果查到的后续长度不够、低于 --draft-min,这次起草直接作废,省掉一次无谓的验证开销。
5. 表里的候选可能有多个,具体挑哪一条由实现决定(出现频率、最近出现等策略都有)。
6. 表的来源有两种:动态——本次运行已经处理过的 prompt 加上已经生成的 token;静态——事先存好的一份文件,用 --lookup-cache-static 加载。
这里有个关键结论:命中条件是"字面重复",不是"语义相近"。模型不知道"总结一下"和"概括一下"是一个意思,但它知道刚才输入里出现过 user_007,dept_2 这串东西。所以查表起草吃的是模板化、引用型、格式转换型的活。
也正因为如此,它和上下文长度直接相关:-c 给得越大,能查到的历史越多,命中率越高。
---
第 3 步:准备一个高重复度的测试任务
写一个脚本,让同一个 prompt 分别打到两个端口的服务上。任务选"CSV 转 Markdown 表格",因为输出几乎逐字复用了输入。
```python
bench_lookup.py
import json
import sys
import urllib.request
CSV = "\n".join(
f"{i},user_{i:03d},dept_{i % 5},L{i % 4 + 1},active" for i in range(1, 61)
)
PROMPT = f"""把下面的 CSV 转成 Markdown 表格,表头保持原样,只输出表格本身,不要解释。
CSV:
id,name,dept,level,status
{CSV}
"""
BODY = {
"prompt": PROMPT,
"n_predict": 1024,
"temperature": 0,
"top_k": 1,
"cache_prompt": False, # 关掉 KV 复用,否则第二次请求会被 prompt cache 加速
"stream": False,
}
def run(port: int):
req = urllib.request.Request(
f"http://127.0.0.1:{port}/completion",
data=json.dumps(BODY).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=900) as resp:
return json.load(resp)
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
data = run(port)
t = data["timings"]
print(f"generated_tokens = {t['predicted_n']}")
print(f"tok_per_second = {t['predicted_per_second']:.2f}")
```
两个要点:
cache_prompt: false必须写。否则第二个服务收到的同一段 prompt 会命中 KV 缓存,你会把"缓存复用"误判成"起草加速"。temperature: 0+top_k: 1用贪心采样。这既让结果可复现,也避开采样路径带来的差异。
---
第 4 步:跑基线
```bash
llama-server \
-m /path/to/your-model.gguf \
-c 8192 \
-ngl 99 \
--parallel 1 \
--host 127.0.0.1 --port 8080
```
-ngl 99 表示尽量把层放到 GPU;纯 CPU 测试就删掉它。--parallel 1 是让服务只开一个槽位,避免并发调度干扰测速。
等模型加载完,另开一个终端:
```bash
python3 bench_lookup.py 8080
```
记下 tok_per_second。建议连跑三次取中位数,第一次往往被显存预热和首轮分配拖慢。
---
第 5 步:开查表起草,再跑一次
保持模型、上下文、量化方式全部不变,只加起草参数,换一个端口:
```bash
llama-server \
-m /path/to/your-model.gguf \
-c 8192 \
-ngl 99 \
--parallel 1 \
--draft-max 16 \
--draft-min 4 \
--host 127.0.0.1 --port 8081
```
```bash
python3 bench_lookup.py 8081
```
把两次的 tok_per_second 相除就是加速比。下面的输出只是示意格式,数字必须在你自己机器上测,不同模型、量化、硬件差异很大:
```text
基线(不开起草)
generated_tokens = 1018
tok_per_second = 27.9
查表起草(--draft-max 16 --draft-min 4)
generated_tokens = 1018
tok_per_second = 49.6
```
在"CSV 转 Markdown 表格"这类高度模板化的任务上,观察到 1.5 倍以上、2 倍上下是常见量级;在写散文、自由翻译这类任务上,常见结果是接近 1.0 倍。请以你自己的实测为准。
同时看一眼服务端日志。llama.cpp 会打印每一轮起草与接受的统计(形如 n_draft / n_accept 的字段),接受率是判断"值不值得开"最直接的指标:接受率低说明表里查不到东西,加参数也救不回来。
---
第 6 步:调 --draft-max 和 --draft-min
这两个是核心旋钮,含义不同:
--draft-max:一次最多起草多长。调大能覆盖更长的重复片段(比如整行 CSV 一次性抄下来),但验证成本随长度线性上升。从 8 起,逐步试到 16、24、32,看加速比什么时候掉头。--draft-min:短于这个长度的起草直接放弃。设成 0 意味着哪怕只查到 1 个 token 也要走一遍投机流程,这个开销可能比省下的还多。重复度中等时,把它设到 3~5 通常更划算。--draft-p-min:主要影响带草稿模型的采样路径,纯查表场景一般保持默认即可。
一个实用做法:固定 --draft-max,只扫 --draft-min,因为后者对"低命中场景"的伤害更直接。
如果你的构建里有 --lookup-cache-static / --lookup-cache-dynamic,可以进一步把 n-gram 表持久化:
```bash
llama-server \
-m /path/to/your-model.gguf \
-c 8192 -ngl 99 \
--draft-max 16 --draft-min 4 \
--lookup-cache-static /tmp/lookup.bin \
--host 127.0.0.1 --port 8081
```
前者用于从文件加载一份预置表(适合有一批固定语料,比如你自己的代码仓库),后者用于把运行中形成的表写出去。具体语义以你那份 --help 的说明为准。
---
第 7 步:做一次正确性对照
投机采样被接受的部分是逐 token 验证过的,所以在贪心采样下,开与不开起草的输出应当完全一致。把两次的返回文本 diff 一下:
```bash
curl -s http://127.0.0.1:8080/completion -H 'Content-Type: application/json' \
-d @body.json | python3 -c "import sys,json;print(json.load(sys.stdin)['content'])" > /tmp/a.txt
curl -s http://127.0.0.1:8081/completion -H 'Content-Type: application/json' \
-d @body.json | python3 -c "import sys,json;print(json.load(sys.stdin)['content'])" > /tmp/b.txt
diff /tmp/a.txt /tmp/b.txt && echo "一致"
```
如果出现差异,先确认两边采样参数完全一致;在非贪心采样下,实现可能会走近似路径,这时以贪心结果做基准对比更可靠。
---
查表起草 / 草稿模型 / 并行起草,怎么选
| 方案 | 额外开销 | 擅长的任务 | 什么时候不划算 |
|---|---|---|---|
| 查表起草(prompt lookup) | 无额外模型,只多一张 n-gram 表的内存 | 输出大量复用输入或历史的任务 | 重复度低时收益趋近于 0 |
草稿模型(-md 挂小模型) | 额外 GGUF、额外显存,且 tokenizer 需兼容 | 自由生成、翻译等通用场景 | 显存紧张、小模型和主模型风格差异大时接受率低 |
| 并行起草 | 通常建立在草稿模型路线之上,需要额外的后端或线程资源 | 想压掉"先猜再验"这条串行链的开销 | 没有草稿模型时无从谈起 |
选择顺序可以简单点:
1. 先开查表起草。它零额外模型、零额外显存,试错成本最低。
2. 如果实测加速比接近 1.0,看接受率。低接受率说明任务本身重复度不够,这时候才考虑上草稿模型。
3. 已经挂了草稿模型、但发现起草本身占了不少时间,再考虑并行起草把起草和验证重叠起来。
4. 两者不冲突:有草稿模型的构建里,查表起草和草稿模型可以分别测试,用同一个脚本对比。
---
常见坑与排错
开了没变快。 九成是任务重复度不够。先看服务端日志的接受统计,低接受率就别在参数上折腾了,换任务或者换方案。
起草全被拒,甚至直接报错。 先确认采样是不是贪心(temperature: 0、top_k: 1)。早期实现对此有硬性要求,新版本对采样的支持在放宽,但用贪心做基准最稳。
第二次请求突然变快。 那是 prompt cache 命中,不是起草生效。请求体里带上 cache_prompt: false。
--draft-max 调得越大越慢。 正常现象。draft 太长时,主模型验证的批次变大,而接受的 token 数没有同比增加。降回 16 附近再试。
--draft-min 设成 0 反而更慢。 短 draft 的固定开销(准备验证批次、调度)可能超过收益。设到 3 以上。
服务器开了多槽位时结果飘。 多并发槽位下投机解码的调度会变化。测速统一用 --parallel 1。
上下文开太小。 n-gram 表能覆盖的历史有限,-c 给到 4096 以下时,长文档任务的命中率会明显下降。
中文任务担心 token 粒度。 查表是在 token 层级做的,不是字符层级。只要输入输出在 token 层面高度重合(比如引用原文作答),照样命中。
输出质量担忧。 查表起草不改模型权重,只影响"一次前向处理多少个 token",被接受的猜测都经过验证。用上面的 diff 方法做一次对照即可放心。
---
下一步建议
- 把测速脚本留下。 换模型、换量化、改上下文长度时都重跑一遍,形成自己的加速比基线,而不是记住别人嘴里的数字。
- 按 workload 决定开关。 如果你的服务同时有"格式转换"和"开放式问答"两类请求,可以起两个端口、两套参数,由上游路由分发。
- 试静态缓存。 有固定语料(内部文档、代码库)的团队,把 n-gram 表预置成静态文件,首次请求的命中率会更好。缓存构建方式以官方文档当前版本为准。
- 再评估草稿模型。 查表起草跑完一轮后,你手上已经有了接受率的实测数据,这时再决定要不要为一个通用小模型多花显存,判断会理性得多。
- 关注上游变化。 投机采样这块在 llama.cpp 里迭代较快,参数名和默认值会调整。升级构建后重新跑一次
--help | grep -iE "draft|lookup",再重跑一遍脚本。
