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 | |
这个流程中有四个需要先建立直觉的核心对象。
1. llama_model:可复用的静态模型数据
llama_model 主要保存模型权重、词表、超参数和 GGUF 元数据。加载时,llama.cpp 会根据 n_gpu_layers、可用设备和后端能力,将不同权重 Tensor 分配到 CPU 或 GPU。
它不保存某一轮对话的历史状态,因此同一个 Model 可以被多个推理 Context 复用。
1 | |
2. llama_context:推理运行时
llama_context 依赖已经加载的 Model,保存一次推理运行所需的 Context 容量、计算缓冲区、Logits、KV Cache 和后端调度状态。
1 | |
这里两个参数容易混淆:
n_ctx:一个 Sequence 最多能够容纳的上下文 Token 数。n_batch:一次llama_decode()允许提交的最大逻辑 Token 数。batch.n_tokens:当前这一次 Batch 实际包含的 Token 数。
simple.cpp 将 n_ctx 设置为 n_prompt + n_predict - 1。最后一个生成 Token 不需要再次送入模型,因为程序已经不再需要用它预测下一个 Token。
3. llama_batch:本次送入模型的数据
Prompt 已经全部确定,因此第一次可以把多个 Prompt Token 一起组成 Batch:
1 | |
进入生成阶段后,下一个 Token 依赖上一个 Token 的输出,未来 Token 尚未确定,所以示例程序每轮通常只把一个新 Token 放回 Batch。
4. llama_sampler:从 Logits 选择 Token
模型输出的不是文字,也不是最终 Token,而是词表中候选 Token 的 Logits。Sampler 根据采样策略从中选择下一个 Token。
1 | |
本例使用 Greedy Sampler,始终选择 Logit 最大的 Token。实际对话程序还可以使用 Temperature、Top-K、Top-P 等策略,使输出具有不同程度的随机性。
三、从文本到 Token
Tokenize 使用了两次调用:
1 | |
第一次传入空指针和容量 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 | |
需要注意,llama_decode() 的返回值不是 Token ID,而是执行状态码。成功执行后,它主要完成三件事:
- 处理当前 Batch,执行模型计算图。
- 更新 Context 内的推理 Memory,例如 KV Cache。
- 将需要的 Logits 保存到 Context,供 Sampler 使用。
llama_sampler_sample() 再从 Context 读取最后一个输出位置的 Logits,根据采样链选出下一个 Token。最后调用 llama_token_to_piece(),才能得到可以显示的文本片段。
所以更准确的链路是:
1 | |
五、为什么需要 KV Cache
在 Transformer 的每一层 Self-Attention 中,每个 Token 都会产生 Query、Key 和 Value。生成新 Token 时,新 Query 仍然需要与全部历史 Key 计算注意力,并使用对应的历史 Value 得到注意力输出。
历史 Token 的 Key 和 Value 一旦计算完成就不会改变,因此可以缓存起来:
1 | |
如果没有 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 | |
因此 Prompt Eval 的 Token/s 通常明显高于 Generation Token/s。不过这不是绝对规律,具体结果仍然取决于模型、上下文长度、Batch、量化方式和硬件。
七、工程结论与后续验证
通过阅读和修改 simple.cpp,可以确定 llama.cpp 最小推理链路中各对象的职责:
1 | |
本文聚焦接口职责和数据流,没有继续展开 GGML 计算图,也没有进行完整的 CPU/GPU 性能对比。性能测试需要在固定模型、量化格式、上下文长度和 Batch 参数后单独进行,否则对比结果缺少可复现性。
后续将对比 Greedy、Temperature、Top-K 和 Top-P 等采样策略,验证不同参数对输出稳定性、生成质量和推理结果可复现性的影响。
参考
本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码和结论均由本人复核。