AI 大模型模型部署系列(一):从 Prompt 到 Token,理解 llama.cpp 最小推理

将大模型接入 C++ 应用时,“模型能够运行”只是部署链路的起点。要进一步分析推理性能、显存占用和生成行为,需要先明确输入数据如何进入模型,以及 Model、Context、Batch、Sampler 和 KV Cache 分别承担什么职责。

本文以 llama.cpp 的最小示例 examples/simple/simple.cpp 为入口,从工程调用链的角度拆解一段 Prompt 生成 Token 的完整过程,重点分析 Tokenize、Decode、Sample 和 KV Cache 之间的数据关系,为后续进行模型集成、参数调优和性能优化建立基础。

llama.cpp 的接口变化较快。本文对应本地版本 b8870-82209efb7,阅读其他版本源码时,函数名称和内部结构可能不同。

一、实验环境

项目 配置
操作系统 Windows
编译工具 Visual Studio 2022、CMake
GPU NVIDIA GeForce GTX 1080 Ti,11 GB
llama.cpp CUDA 后端构建
模型 Qwen2.5-1.5B-Instruct-Q3_K_M GGUF
示例程序 examples/simple/simple.cpp

本次验证把模型路径、Prompt 和 GPU 层数直接写在测试代码中,目的是缩短调试路径,集中观察 Tokenize、Decode 和 Sample 的过程。正式集成时,可以再将这些参数移入配置文件或上层调用接口。

二、最小推理流程

整个过程可以概括为:

1
2
3
4
5
6
7
8
9
10
加载计算后端
→ 加载 GGUF 模型
→ 文本 Tokenize
→ 创建推理 Context
→ 创建 Sampler
→ 构造 Batch
→ llama_decode 计算 Logits
→ Sampler 选择 Token
→ Token 转换为文本 Piece
→ 将新 Token 放回 Batch,继续生成

这个流程中有四个需要先建立直觉的核心对象。

1. llama_model:可复用的静态模型数据

llama_model 主要保存模型权重、词表、超参数和 GGUF 元数据。加载时,llama.cpp 会根据 n_gpu_layers、可用设备和后端能力,将不同权重 Tensor 分配到 CPU 或 GPU。

它不保存某一轮对话的历史状态,因此同一个 Model 可以被多个推理 Context 复用。

1
2
3
4
5
llama_model_params model_params = llama_model_default_params();
model_params.n_gpu_layers = 99;

llama_model * model =
llama_model_load_from_file(model_path.c_str(), model_params);

2. llama_context:推理运行时

llama_context 依赖已经加载的 Model,保存一次推理运行所需的 Context 容量、计算缓冲区、Logits、KV Cache 和后端调度状态。

1
2
3
4
5
llama_context_params ctx_params = llama_context_default_params();
ctx_params.n_ctx = n_prompt + n_predict - 1;
ctx_params.n_batch = n_prompt;

llama_context * ctx = llama_init_from_model(model, ctx_params);

这里两个参数容易混淆:

  • n_ctx:一个 Sequence 最多能够容纳的上下文 Token 数。
  • n_batch:一次 llama_decode() 允许提交的最大逻辑 Token 数。
  • batch.n_tokens:当前这一次 Batch 实际包含的 Token 数。

simple.cppn_ctx 设置为 n_prompt + n_predict - 1。最后一个生成 Token 不需要再次送入模型,因为程序已经不再需要用它预测下一个 Token。

3. llama_batch:本次送入模型的数据

Prompt 已经全部确定,因此第一次可以把多个 Prompt Token 一起组成 Batch:

1
2
llama_batch batch =
llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());

进入生成阶段后,下一个 Token 依赖上一个 Token 的输出,未来 Token 尚未确定,所以示例程序每轮通常只把一个新 Token 放回 Batch。

4. llama_sampler:从 Logits 选择 Token

模型输出的不是文字,也不是最终 Token,而是词表中候选 Token 的 Logits。Sampler 根据采样策略从中选择下一个 Token。

1
2
llama_sampler * smpl = llama_sampler_chain_init(sparams);
llama_sampler_chain_add(smpl, llama_sampler_init_greedy());

本例使用 Greedy Sampler,始终选择 Logit 最大的 Token。实际对话程序还可以使用 Temperature、Top-K、Top-P 等策略,使输出具有不同程度的随机性。

三、从文本到 Token

Tokenize 使用了两次调用:

1
2
3
4
5
6
7
8
9
10
const int n_prompt = -llama_tokenize(
vocab, prompt.c_str(), prompt.size(),
nullptr, 0, true, true);

std::vector<llama_token> prompt_tokens(n_prompt);

llama_tokenize(
vocab, prompt.c_str(), prompt.size(),
prompt_tokens.data(), prompt_tokens.size(),
true, true);

第一次传入空指针和容量 0,利用“输出缓冲区不足时返回所需 Token 数量的负值”得到真实容量。取反后分配 vector,第二次调用再完成真正的 Tokenize。

测试 Prompt 为:

1
你是一个智能编程助手,能解决一些代码问题。你的回答是专业的。

部分输出如下:

Token ID Piece UTF-8 字节数
56568 3
101909 是一个 9
100168 智能 6
110569 编程 6
110498 助手 6
3837 3
46100 代码 6
1773 3

从结果可以看到,Token 并不等于单个汉字。一个 Token 可以对应一个字、多个字组成的词、标点或其他字节片段。

llama_token_to_piece() 返回的长度是 Piece 的 UTF-8 字节数,不是字符数。例如“是一个”包含三个常用汉字,在 UTF-8 中占 9 字节。

Token ID 与 Piece 的映射由当前模型词表决定。本例中两个句号都被分成 Token 1773,但不能据此认为同一个汉字在所有上下文中都会拥有独立且相同的 Token ID,因为 Tokenizer 可能将它与相邻文本合并为更长的 Token。

四、llama_decode 到底输出什么

生成循环中的关键代码是:

1
2
3
4
5
if (llama_decode(ctx, batch) != 0) {
// 处理错误
}

llama_token new_token_id = llama_sampler_sample(smpl, ctx, -1);

需要注意,llama_decode() 的返回值不是 Token ID,而是执行状态码。成功执行后,它主要完成三件事:

  1. 处理当前 Batch,执行模型计算图。
  2. 更新 Context 内的推理 Memory,例如 KV Cache。
  3. 将需要的 Logits 保存到 Context,供 Sampler 使用。

llama_sampler_sample() 再从 Context 读取最后一个输出位置的 Logits,根据采样链选出下一个 Token。最后调用 llama_token_to_piece(),才能得到可以显示的文本片段。

所以更准确的链路是:

1
2
3
4
5
6
7
Batch
→ llama_decode
→ Context 中的 Logits
→ llama_sampler_sample
→ Token ID
→ llama_token_to_piece
→ UTF-8 文本

五、为什么需要 KV Cache

在 Transformer 的每一层 Self-Attention 中,每个 Token 都会产生 Query、Key 和 Value。生成新 Token 时,新 Query 仍然需要与全部历史 Key 计算注意力,并使用对应的历史 Value 得到注意力输出。

历史 Token 的 Key 和 Value 一旦计算完成就不会改变,因此可以缓存起来:

1
2
3
4
5
6
7
8
Prompt 阶段:
计算 K1...Kn、V1...Vn,并写入 KV Cache

生成阶段:
计算新 Token 的 Q、K、V
Qnew 与缓存的历史 K 计算 Attention
使用历史 V 得到输出
把 Knew、Vnew 追加到 KV Cache

如果没有 KV Cache,每生成一个 Token,都需要重新处理整个历史前缀。使用 KV Cache 后,只计算新位置对应的网络过程,复用历史 K/V。

KV Cache 没有消除新 Query 对全部历史 K/V 的注意力计算,而且它会随着上下文增长占用更多内存;它是在计算量与内存占用之间做出的关键交换。

在当前版本中,simple.cpp 不直接管理 KV Cache。llama_context 创建时会调用 model.create_memory();对于本次使用的 Qwen2.5,最终会创建标准的 llama_kv_cache。这些细节被封装在 Context 内部,因此最小示例只需要持续调用 llama_decode()

六、Prompt Eval 为什么通常更快

推理日志通常会分别给出 Prompt Eval 和 Generation 的速度。

Prompt Eval 也叫 Prefill。Prompt 中所有 Token 已经确定,可以组成一个较大的 Batch,通过矩阵乘矩阵等操作并行计算,GPU 的计算单元更容易得到充分利用。

Generation 是自回归过程:必须先生成第一个 Token,才能把它作为输入预测第二个 Token。每一步通常只处理一个新 Token,存在严格的前后依赖,计算更接近矩阵乘向量,也更容易受到显存带宽和调度开销限制。

1
2
Prompt Eval:多个已知 Token,可以批量并行
Generation:下一个 Token 依赖上一个结果,只能逐步推进

因此 Prompt Eval 的 Token/s 通常明显高于 Generation Token/s。不过这不是绝对规律,具体结果仍然取决于模型、上下文长度、Batch、量化方式和硬件。

七、工程结论与后续验证

通过阅读和修改 simple.cpp,可以确定 llama.cpp 最小推理链路中各对象的职责:

1
2
3
4
5
6
Model 提供权重和词表
Context 保存推理运行状态
Batch 描述本次输入
Decode 执行模型并产生 Logits
Sampler 选择下一个 Token
KV Cache 复用历史 Attention 状态

本文聚焦接口职责和数据流,没有继续展开 GGML 计算图,也没有进行完整的 CPU/GPU 性能对比。性能测试需要在固定模型、量化格式、上下文长度和 Batch 参数后单独进行,否则对比结果缺少可复现性。

后续将对比 Greedy、Temperature、Top-K 和 Top-P 等采样策略,验证不同参数对输出稳定性、生成质量和推理结果可复现性的影响。

参考

本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码和结论均由本人复核。


AI 大模型模型部署系列(一):从 Prompt 到 Token,理解 llama.cpp 最小推理
https://suntfly.github.io/2026/08/24/AI大模型模型部署系列(一):从Prompt到Token/
作者
sunTFly
发布于
2026年8月24日
许可协议