大模型推理最容易被说成一句空话:输入 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
这里有一个很容易混的地方:vocab 和 token_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
这些参数会被解析成一次生成任务:
| 参数 | 作用 |
|---|---|
-m | GGUF 模型路径 |
-p | prompt 文本 |
-n | 最多生成多少个新 token |
--temp | 采样温度 |
--top-p | nucleus 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_context 和 llama_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 cache | TTFT |
| Decode | 每轮 1 个新 token | 持续生成后续 token | TPOT / 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 在模型内部经历了哪些张量变换”。