一个 token 的诞生:从 llama.cpp 主循环看懂 Prefill 和 Decode

从 llama.cpp 的命令行入口、tokenize、生成 while 循环、llama_decode、batch、n_past 和 KV cache 串起一次 LLM 推理,解释 prefill 与 decode 的代码分叉和性能差异。

大模型推理最容易被说成一句空话:输入 prompt,模型 forward,然后采样输出 token。

这句话没有错,但它把最重要的工程细节都藏起来了。真正读一遍 llama.cpp 的生成主循环后,会发现 LLM 推理并不是一个神秘流程,而是一个很朴素的循环:

准备本轮 token
→ 调用 llama_decode 做一次 forward
→ 从 logits 里 sample 出下一个 token
→ 把新 token 放回下一轮

模型输出一整段话,不是一次性吐出完整字符串,而是一个 token 一个 token 接出来。每次生成一个 token,都要再走一轮 forward。这篇文章记录的就是这条链路:从命令行入口到 llama_decode,再到 prefill / decode 的真正分叉。

本文不依赖某个固定源码行号。llama.cpp 版本变化很快,文件名和行号会漂移,但主流程稳定:tokenize、batch、decode、sample、回填。

1. 模型不处理文本,只处理 token id

用户输入的是字符串,例如:

你好,请解释一下 KV cache

但模型内部不直接处理字符串。第一步是 tokenizer,把文本切成 token,并映射成一串整数 ID:

文本
→ tokenizer / vocab
→ token ids

这里有一个很容易混的地方:vocabtoken_embd.weight 不是同一张表。

vocab:文本片段 / 子词 → token id 的字典
token_embd.weight:token id → hidden 向量的权重表

tokenizer 负责把文本变成数字;embedding 负责把数字变成模型能计算的向量。二者共享同一套 token id 编号空间,所以 vocab_size 既是 tokenizer 能产生的 token id 范围,也是 embedding 表可查询的 token 数。

以 Qwen2.5-7B 为例,关键数字是:

hidden size = 3584
vocab size = 152064
layers     = 28
heads      = 28
kv_heads   = 4
head_dim   = 128

一个 token id 进入 embedding 后,会查到一个 3584 维向量:

token id
→ token_embd.weight 查表
→ [3584]

如果 prompt 被切成 16 个 token,那么 embedding 后就是:

[16] → [16, 3584]

这一步不是矩阵乘,而是查表。第 108386 号 token 就去 embedding 权重表里取第 108386 个向量。后面所有 Transformer 层处理的都不再是文本,而是这些 hidden 向量。

2. 命令行入口只是把参数变成一次生成任务

用 llama.cpp 跑模型时,命令大概长这样:

llama-cli -m model.gguf -p "你好" -n 128 --temp 0.8 --top-p 0.95

这些参数会被解析成一次生成任务:

参数作用
-mGGUF 模型路径
-pprompt 文本
-n最多生成多少个新 token
--temp采样温度
--top-pnucleus sampling 截断阈值

命令行工具本身不是推理引擎。它做的是外围工作:读参数、加载模型、初始化上下文、tokenize prompt、创建 sampler,然后进入生成循环。

可以把外层流程简化成这样:

main / command entry
→ parse params
→ load GGUF model
→ create llama_context
→ tokenize prompt
→ while loop:
     prepare batch
     llama_decode
     sample next token
     print token

真正的计算不在命令行入口,而在 llama_decode 往下的 context decode 逻辑里。

3. tokenize 通常只处理原始 prompt

在普通非交互式生成里,prompt 会先被整体 tokenize 成 embd_inp 之类的 token id 数组:

"你好"
→ common_tokenize(...)
→ [token_id_0, token_id_1, ...]

这个动作发生在生成循环之前。进入循环之后,模型世界里基本只剩整数 token id,不再处理原始字符串。

后续模型生成的新 token 也不是从文本再次 tokenize 来的,而是从 logits 里采样出的 token id。这个 id 会直接进入下一轮 decode。

所以生成过程里有两个方向:

输入 prompt:文本 → token id
输出结果:token id → 文本片段

前者叫 tokenize,后者叫 detokenize。模型本身夹在中间,只看 token id 和 hidden 向量。

4. 生成主循环:forward + sample

LLM 生成文本的核心是一个 while 循环。简化成伪代码,大概是:

prompt_tokens = tokenize(prompt)
n_past = 0
n_remain = max_new_tokens

while n_remain > 0:
    batch = prepare_tokens_for_this_round()

    llama_decode(ctx, batch)
    n_past += batch.n_tokens

    logits = get_logits_for_last_position()
    next_id = sample(logits)

    print(detokenize(next_id))
    feed next_id into the next round
    n_remain -= 1

这里最关键的是:每一轮都只有两类核心动作。

第一类是 forward:

token ids
→ embedding
→ 28 层 decoder
→ logits

第二类是 sampling:

logits
→ temperature / top-k / top-p / repetition penalty
→ next token id

模型不会“计划好一整段话再输出”。它每次只根据当前上下文预测下一个 token,然后把这个 token 追加到上下文里,再预测下一个。

这也是流式输出的基础:每轮采样出一个 token,就可以立刻 detokenize 成文本片段并显示出来。

5. llama_decode 不是魔法,而是 C API 门面

从调用链上看,命令行工具通常不会直接操作底层 backend。中间会经过 common 层封装,然后调用 C API:

command line tool
→ common_prompt_batch_decode(...)
→ llama_decode(ctx, batch)
→ ctx->decode(batch)

llama_decode 更像一个门面:它接收 llama_contextllama_batch,然后把工作交给 context 内部的 decode 方法。真正复杂的事情发生在更里面:

检查 batch
→ 准备位置和 sequence id
→ 管理 KV cache
→ 构建 ggml graph
→ 调 backend 执行 graph
→ 回收 logits / embeddings

Day 5 的重点不是把 ctx->decode 里面的计算图全部拆完,而是先看清楚外层链路:token 是怎么被送进去的,logits 是怎么被拿出来的,下一个 token 又是怎么塞回循环的。

换句话说,Day 5 解决的是“推理循环怎么转起来”,Day 6 才继续解决“一次 forward 内部到底算了什么”。

6. Prefill 和 Decode 的代码分叉:一次喂几个 token

这是最重要的结论:从 llama.cpp 外层主循环看,prefill 和 decode 不一定是两个完全不同的函数。

它们用的都是 llama_decode。区别主要在于 batch 里本轮装了多少 token。

prefill:prompt 还没消化完,一次喂入一批 prompt token
decode:prompt 已经消化完,每轮只喂入上一次采样出的 1 个 token

也就是:

同一个 forward
不同的输入 token 数

假设 prompt 有 512 个 token,n_batch 允许一次处理 512 个,那么第一轮可以直接把整段 prompt 喂进去:

prefill batch.n_tokens = 512

prompt 处理完之后,模型开始生成新 token。每次采样出一个新 token,再把这一个 token 放进下一轮:

decode batch.n_tokens = 1

这就是为什么很多系统文章会把 LLM serving 拆成两个阶段:

阶段输入主要目标用户感知指标
Prefill整段 prompt建立上下文和 KV cacheTTFT
Decode每轮 1 个新 token持续生成后续 tokenTPOT / tokens per second

TTFT 是 time to first token,第一个 token 出来之前,必须先完成 prompt 的 prefill。TPOT 是 time per output token,主要由后续每一步 decode 决定。

7. 为什么 Prefill 快,Decode 慢

直觉上,decode 每次只算 1 个 token,应该更快。但实际测量中,decode 的 tokens/s 往往远低于 prefill。

原因在于:decode 输入虽然短,但每生成一个 token,仍然要让这个 token 经过整套模型权重。

对一个 7B Q4 模型来说,模型文件大约 4.4GB。每生成一个 token,都需要大量读取权重并执行各层投影。decode 阶段的矩阵形状很瘦,算术强度低,硬件算力很难被吃满,瓶颈常常落在内存带宽上。

可以用一句话概括:

prefill 更像大矩阵乘,容易吃满算力;
decode 更像每次只处理一个向量,但仍然要扫过大量权重,容易卡在带宽。

如果本机统一内存带宽约 150GB/s,模型权重约 4.4GB,一个粗糙的 decode 理论上限是:

150GB/s ÷ 4.4GB ≈ 34 token/s

实际跑到 25 token/s 左右并不奇怪,因为还有反量化、KV cache 访问、调度开销、非理想访存、采样和框架开销。

这就是推理系统里反复出现的主题:decode 的单请求路径很难把硬件吃满,所以需要 batching、continuous batching、speculative decoding 等机制把空闲算力或带宽利用起来。

8. llama_batch 是装 token 的托盘

llama_batch 可以理解成“本轮要送进模型的 token 托盘”。它不仅包含 token id,还包含位置、序列 id、哪些位置需要 logits 等信息。

简化后可以这样看:

llama_batch {
  n_tokens
  tokens
  pos
  seq_id
  logits
}

单用户本地推理时,这个 batch 通常只服务一个请求:

prefill:装一批 prompt token
decode:装一个新生成 token

但到了服务端推理引擎,这个概念会变得非常关键。vLLM、SGLang 这类系统会把多个请求的 decode token 拼到同一个 batch 里:

request A 的下一个 token
request B 的下一个 token
request C 的下一个 token
→ 合成一个 batch
→ 一起跑 decode

这就是 continuous batching 的基本动机:不要让昂贵硬件每次只为一个用户的一个 token 工作。

所以 Day 5 看懂 batch,不是只为了读 llama.cpp,而是在给后面的推理调度打地基。

9. n_past 是 KV cache 的游标

生成循环里还有一个关键变量:n_past。它可以理解成当前序列已经有多少个位置被模型处理过,也就是 KV cache 已经写到了哪里。

简化逻辑是:

llama_decode(ctx, batch)
n_past += n_new

Prefill 阶段,一轮可能处理几百个 prompt token:

n_past += 512

Decode 阶段,每轮只处理一个新 token:

n_past += 1

KV cache 存的是每一层历史 token 的 K/V。没有 KV cache 时,生成第 513 个 token 需要重新计算前 512 个 token 的 K/V;有了 KV cache,过去的 K/V 可以复用,新一轮只需要为新 token 追加一格。

Qwen2.5-7B 的 KV cache 形状可以粗略理解成:

[layer, K/V, batch, n_kv_heads, seq, head_dim]

代入关键数字:

layers   = 28
K/V      = 2
kv_heads = 4
head_dim = 128
seq      = 2048
dtype    = fp16, 2 bytes

单请求 KV cache 大小约为:

28 × 2 × 4 × 2048 × 128 × 2 bytes
= 117 MB

如果没有 GQA,KV heads 从 4 变成 28,这笔账会放大 7 倍,约 820MB。

这也解释了为什么 GQA、KV cache 管理、PagedAttention 都不是孤立技术点。它们都在解决同一个问题:长上下文和高并发下,KV cache 会迅速变成推理系统的核心资源。

10. 从张量形状看一次推理

把形状写出来,整个流程会更清楚。

假设 prefill 阶段输入 16 个 token:

token ids:      [16]
embedding:      [16, 3584]
decoder layers: [16, 3584]
logits:         [1, 152064]

最后的 logits 通常只取最后一个位置,因为生成下一个 token 只需要根据当前序列最后位置预测。

Decode 阶段输入 1 个 token:

token ids:      [1]
embedding:      [1, 3584]
decoder layers: [1, 3584]
logits:         [1, 152064]

注意这里有一个反直觉点:decode 的输入形状小了很多,但它并不等于“成本几乎没有”。每层仍然要做 RMSNorm、QKV 投影、RoPE、attention、out projection、FFN,并且要读取大量权重。

到了 attention 内部,Qwen2.5-7B 的 GQA 形状大致是:

Q: [B, S, 28, 128]
K: [B, S,  4, 128]
V: [B, S,  4, 128]

Q 有 28 个头,K/V 只有 4 个头。这就是 grouped-query attention 的痕迹:多个 query head 共享较少的 K/V head,从而显著减少 KV cache。

11. 采样不是 forward,它在 logits 之后

模型 forward 的输出是 logits:

[1, 152064]

这表示模型对 152064 个候选 token 的打分。采样器会在 logits 上做一系列处理:

temperature
top-k
top-p
repetition penalty

最后得到一个 token id。这个 token id 有两个去处:

1. detokenize 成文本片段,打印给用户
2. 放回下一轮 batch,继续生成

这就是生成循环闭环的地方:

logits → sample → next token id → next decode round

很多初学者会把“模型生成文本”和“采样策略”混在一起。更准确地说,模型只给出分布,采样器决定从分布里怎么挑下一个 token。

12. Day 5 最重要的结论

把整条链路压缩成一张图:

prompt text


tokenizer / vocab


prompt token ids


prefill batch ──► llama_decode ──► KV cache 写入
  │                                  │
  ▼                                  ▼
last-position logits             n_past 增长


sample next token id

  ├──► detokenize / print

  └──► decode batch ──► llama_decode ──► sample again

这里最值得带走的不是某个源码行号,而是四个概念边界:

第一,tokenizer 和 embedding 是两张不同的表。tokenizer 把文本变成 token id,embedding 把 token id 变成 hidden 向量。

第二,生成是一轮轮 forward + sample,不是一次性吐出整段文本。

第三,prefill 和 decode 的核心区别是本轮 batch 里有多少 token。prompt 阶段一次喂一批,生成阶段每轮喂一个。

第四,n_past 和 KV cache 把历史上下文连接起来。没有 KV cache,decode 会反复重算历史;有了 KV cache,每轮只追加新 token 的 K/V。

13. 为什么这一天连接到后面的推理系统

Day 5 表面上是在读 llama.cpp 主循环,实际上它把后面几周的推理系统主题都埋好了。

batch 会通向 continuous batching:多个请求的 token 如何拼成一个高效 batch。

n_past 会通向 KV cache 管理:系统如何知道每个请求的上下文写到了哪里。

decode memory-bound 会通向性能建模:为什么 tokens/s 可以用带宽除以模型大小做粗估。

KV cache 的 117MB 账会通向 PagedAttention:当并发上来、上下文变长,连续大块 KV cache 会浪费和碎片化,系统需要像操作系统管理内存页一样管理 KV block。

所以这一天不是孤立的源码阅读,而是把“本地跑起来一个模型”升级成“开始理解推理引擎为什么这样设计”的转折点。

14. 下一步:进入 ggml 计算图

Day 5 解决的是外层问题:

token 怎么进入生成循环?
logits 怎么拿出来?
下一个 token 怎么回到下一轮?
prefill 和 decode 在哪里分叉?

下一步要钻进 llama_decode 内部,看一次 forward 到底算了什么:

RMSNorm
→ QKV projection
→ RoPE
→ masked attention
→ output projection
→ FFN / SwiGLU
→ residual

如果 Day 5 是“一个 token 如何进入循环”,Day 6 就是“这个 token 在模型内部经历了哪些张量变换”。