上篇文章,我们从0到1写了一个手动调用模型文件的过程。
今天,我们在 main.cpp 文件上,深入看一下 llama.cpp 接口的定义,以及一些编码过程中可能有的疑问,例如: 为什么调用模型文件是这么一个过程?Batch是什么?等。
这篇文章可以跳着看,前后没有关联性,只是上篇文章中的代码的一些深入探索。
0. 系列文章
1. 核心概念
在开始之前,先介绍下几个核心概念,已经了解的同学请直接跳过,直接跳到第2部分。
1.1 模型 (Model)
比喻:就像一本厚重的百科全书,包含了模型学到的所有知识。
实际:一个文件(通常叫 .gguf),里面存储了神经网络的所有权重参数。就像人脑的神经元连接一样。
1.2 上下文 (Context)
比喻:就像读书时的书签和笔记本,记录你读到哪里了,有什么笔记。
实际:存储当前对话状态,包括:
- Key-Value缓存(记忆之前的对话)
- 计算图(推理的路线图)
- 运行时的各种状态
1.3 Token
比喻:就像语言的"乐高积木块",模型用这些积木块来构建句子。
实际:模型不认识完整的单词,只认识这些编号。比如:
- “你好” → [234, 567]
- “谢谢” → [890, 123]
1.4 采样 (Sampling)
比喻:就像从一堆候选词中抽奖,决定下一个说什么。
策略:
- 贪心采样:总是选概率最高的(最保守)
- 随机采样:按概率随机选择(更有创意)
- 温度调节:控制随机性的程度
2. 接口说明
这里对几个重要函数(参数较多)进行说明。
2.1 llama_tokenize
LLAMA_API int32_t llama_tokenize(
const struct llama_vocab * vocab, // [in] 词汇表对象
const char * text, // [in] 要token化的文本
int32_t text_len, // [in] 文本长度(字节数)
llama_token * tokens, // [out] 输出token数组
int32_t n_tokens_max, // [in] tokens数组的最大容量
bool add_special, // [in] 是否添加特殊token
bool parse_special // [in] 是否解析特殊token
);
参数详解:
- vocab
模型的词汇表,包含token到文本的映射关系
- text
要处理的输入文本(UTF-8编码
- text_len
文本的字节长度(不是字符数)
- tokens
类型:llama_token*(输出参数)
存储生成的token ID数组,需要调用者预先分配足够空间
- n_tokens_max
tokens数组的最大容量
- add_special
是否自动添加特殊token(如BOS/EOS)
true:可能添加 <s>(开始符)
false:保持原始文本
- parse_special
是否解析文本中的特殊token(如<|im_start|>)
注意:当false时,特殊token会被当作普通文本
2.2 llama_sampler_chain_add
LLAMA_API void llama_sampler_chain_add(
struct llama_sampler * chain, // [in] 采样器链对象
struct llama_sampler * smpl // [in] 要添加的采样器
);
- chain (采样器链对象)
目标采样器链,新的采样器将被添加到这里
必须先调用: llama_sampler_chain_init() 创建链
- smpl (采样器对象)
要添加到链中的具体采样器
创建方式: 通过各种 llama_sampler_init_*() 函数创建
采样器链的工作原理
// 创建采样器链(就像一个空盒子)
struct llama_sampler * chain = llama_sampler_chain_init(params);
// 按顺序添加采样策略(就像装过滤器)
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // 第一层过滤
llama_sampler_chain_add(chain, llama_sampler_init_top_p(0.9, 1)); // 第二层过滤
llama_sampler_chain_add(chain, llama_sampler_init_temp(0.8)); // 第三层调节
llama_sampler_chain_add(chain, llama_sampler_init_dist(seed)); // 最终选择
上面的采样器链的工作过程如下:
原始logits
→ 经过top_k(40)过滤
→ 经过top_p(0.9)过滤
→ 经过温度(0.8)调节
→ dist采样器最终选择
添加采样器的顺序很重要:
// 错误顺序:温度应该在过滤之后
llama_sampler_chain_add(chain, llama_sampler_init_temp(0.8)); // 先温度
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // 后过滤 ← 不对!
// 正确顺序:先过滤再调节
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // 先过滤
llama_sampler_chain_add(chain, llama_sampler_init_temp(0.8)); // 后温度 ← 正确!
必须以采样器结束:
// 链的最后必须是一个实际选择token的采样器
llama_sampler_chain_add(chain, llama_sampler_init_dist(seed)); // ✓ 正确
llama_sampler_chain_add(chain, llama_sampler_init_greedy()); // ✓ 正确
// 错误:以过滤采样器结束
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // ✗ 错误!
2.3 llama_token_to_piece
LLAMA_API int32_t llama_token_to_piece(
const struct llama_vocab * vocab, // [in] 词汇表对象
llama_token token, // [in] 要转换的token ID
char * buf, // [out] 输出缓冲区
int32_t length, // [in] 缓冲区长度
int32_t lstrip, // [in] 跳过前导空格数
bool special // [in] 是否显示特殊token
);
- vocab (词汇表对象)
包含token到文本映射的词汇表
- token (token ID)
要转换为文本的token编号
- buf (输出缓冲区)
存储转换后的文本片段,需要调用者预先分配内存
- length (缓冲区长度)
buf 缓冲区的最大容量(字节数)
建议: 至少16字节(大多数token的文本长度)
- lstrip (前导空格跳过)
跳过token文本前面的指定数量的空格
示例:
0: 不跳过任何空格
1: 跳过1个前导空格
-1: 跳过所有前导空格
- special (特殊token处理)
是否将特殊token转换为可读形式
示例:
true: <|im_end|> → "<|im_end|>"
false: <|im_end|> → 空字符串或占位符
2.4 llama_sampler_sample
LLAMA_API llama_token llama_sampler_sample(
struct llama_sampler * smpl, // [in] 采样器对象
struct llama_context * ctx, // [in] 上下文对象
int32_t idx // [in] 输出位置索引
);
- smpl (采样器对象)
包含采样策略配置(如top-k/top-p/温度等)
- ctx (上下文对象)
提供模型状态和logits输出
关键数据:
llama_get_logits(ctx):获取概率分布
KV缓存:存储历史token信息
- idx (输出索引)
指定从哪个输出位置采样
典型值:
-1:最后一个输出位置(最常用)
0:第一个输出位置
正数:指定具体位置
3. 深入了解
3.1 什么是Batch?
在 LLM 中,Batch 就是让模型一次处理多个 token,而不是一个一个处理。
3.1.1 有 Batch 和无 Batch 对比
- 单个处理(没有 Batch)
// 一次处理一个 token - 慢!
for (int i = 0; i < prompt_tokens.size(); i++) {
llama_token token = tokens[i];
struct llama_batch batch = llama_batch_get_one(&token, 1);
llama_decode(ctx, batch); // 调用 prompt_tokens.size() 次!
}
- 批量处理(使用 Batch)
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
llama_decode(ctx, batch)
假设处理 10 个 token:
- 单个处理:10次函数调用 + 10次硬件启动
- 批量处理:1次函数调用 + 1次硬件启动
速度提升:通常能快 2-10 倍!
3.1.2 为什么刚开始 Batch 大,后面变小?
可能大家也发现了,刚开始:
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
batch 大小为 prompt_tokens.size(),但到了循环中:
// 准备下一轮推理的batch(单token)
batch = llama_batch_get_one(&new_token_id, 1);
batch 的 size 变成了 1。
这是因为:
- 处理输入(理解阶段)
输入:你给模型的完整问题或提示
处理方式:一次性处理所有输入 tokens(大Batch)
目的:让模型充分理解整个上下文 - 生成输出(创作阶段)
输入:每次只输入最新生成的一个 token
处理方式:一个一个生成(小Batch)
目的:基于前面内容逐步创作
-
技术原因
-
并行化优势:输入处理可以高度并行化(大Batch好)
-
序列依赖性:输出生成必须顺序进行(只能小Batch)
-
内存效率:KV缓存需要按顺序更新
-
就像写作文:
阅读题目:一次性读完整个题目要求(大Batch)
写作过程:一个字一个字写,依赖前文(小Batch)
-
性能考虑:虽然生成阶段用小Batch看似低效,但是:
-
KV缓存:模型已经缓存了之前的结果
-
增量计算:只计算最新的变化部分
-
实时性:用户可以逐步看到生成结果
-
3.2 为什么需要先获取默认参数?
代码中很多这样的设计,在初始化一个模块前,先获取一下默认参数,然后修改默认参数,再初始化该模块。

这样设计的好处在哪?
以建筑工地比喻:
// 错误的做法:直接盖楼
build_house(?, ?, ?, ?); // 需要什么参数?怎么设置?
// 正确的做法:先拿标准图纸,再按需修改
struct house_plan plan = get_default_house_plan(); // 获取默认设计
plan.bedrooms = 3; // 按需修改:3个卧室
plan.has_garage = true;// 增加车库
build_house(plan); // 按修改后的设计建造
如果没有默认参数,我们可能这样写:
// 直接填参数 - 很容易出错!
struct llama_model_params params = {
.n_gpu_layers = 1, // 这个参数叫什么?
.use_mmap = true, // 应该是 true 还是 false?
// ... 还有很多参数,容易漏掉或写错
};
而有了默认参数:
// 先获取安全的默认值
struct llama_model_params params = llama_model_default_params();
// 然后只修改需要的部分
params.n_gpu_layers = 1; // 明确知道在修改什么
params.use_mmap = true; // 不会影响到其他默认设置
当增加新的参数时,之前的旧版本不用改,向后兼容:
// 版本1.0
struct params { int a; int b; };
// 版本2.0:添加了新参数c
struct params { int a; int b; int c; };
// 使用默认参数函数,自动处理新老版本
struct params p = get_default_params(); // 自动设置c的默认值
大家可以学习这种设计接口的方式。
3.3 llama_decode 函数的入参、出参以及内部发生的具体过程
3.3.1 函数签名
int32_t llama_decode(
struct llama_context * ctx, // 上下文对象
struct llama_batch batch // 输入批次数据
);
-
ctx (上下文)
类型: struct llama_context *
作用: 包含模型状态、KV缓存、计算图等
比喻: 就像游戏的存档文件,记录了之前的进度 -
batch (批次数据)
结构:
struct llama_batch {
int32_t n_tokens; // token数量
llama_token * token; // token数组
float * embd; // 嵌入向量(可选)
llama_pos * pos; // 位置信息
llama_seq_id * seq_id; // 序列ID
int8_t * logits; // 是否输出logits
};
3.3.2 内部发生的过程
(1)第一阶段:输入处理
// 假设输入: ["The", "weather", "is"]
batch.token = [123, 456, 789]; // token编号
batch.pos = [0, 1, 2]; // 位置信息
batch.n_tokens = 3;
(2)第二阶段:向量转换
Token → 向量: 每个token转换成768维向量
"The" → [0.1, 0.2, 0.3, ..., 0.768]
"weather" → [0.4, 0.5, 0.6, ..., 0.768]
"is" → [0.7, 0.8, 0.9, ..., 0.768]
(3)第三阶段:神经网络计算 ,计算出 Logits(下一个词及概率)
Logits 输出具体数值示例
Token "is" 的 logits:
- "sunny": 2.8 (高概率)
- "rainy": 1.2
- "cloudy": 0.9
- ... 其他31997个词的概率
3.3.3 调用 llama_decode 后,ctx 会更新
// ctx 更新后状态:
// - kv_cache: 新增了本次batch的KV向量
// - logits: 指向最新计算的概率分布
// - embeddings: 指向最新计算的嵌入向量
// - memory_usage: 内存使用量更新
本篇文章在上篇文章中代码的基础上进行了深入一点的了解。下篇文章, 我们将不局限于 llama.cpp,而是扩展到目前端侧模型部署最常用的推理引擎库:ONNX Runtime 库的上手使用。

1951

被折叠的 条评论
为什么被折叠?



