上篇文章,我們從0到1寫了一個(gè)手動(dòng)調(diào)用模型文件的過程。
今天,我們?cè)?main.cpp 文件上,深入看一下 llama.cpp 接口的定義,以及一些編碼過程中可能有的疑問,例如: 為什么調(diào)用模型文件是這么一個(gè)過程?Batch是什么?等。
這篇文章可以跳著看,前后沒有關(guān)聯(lián)性,只是上篇文章中的代碼的一些深入探索。
0. 系列文章
- · 【端側(cè)AI 與 C++】1. llama.cpp源碼編譯與本地運(yùn)行
- · 【端側(cè)AI 與 C++】2. 第一個(gè) C++ AI 程序 (HelloAI)
1. 核心概念
在開始之前,先介紹下幾個(gè)核心概念,已經(jīng)了解的同學(xué)請(qǐng)直接跳過,直接跳到第2部分。
1.1 模型 (Model)
比喻:就像一本厚重的百科全書,包含了模型學(xué)到的所有知識(shí)。
實(shí)際:一個(gè)文件(通常叫 .gguf),里面存儲(chǔ)了神經(jīng)網(wǎng)絡(luò)的所有權(quán)重參數(shù)。就像人腦的神經(jīng)元連接一樣。
1.2 上下文 (Context)
比喻:就像讀書時(shí)的書簽和筆記本,記錄你讀到哪里了,有什么筆記。
實(shí)際:存儲(chǔ)當(dāng)前對(duì)話狀態(tài),包括:
- · Key-Value緩存(記憶之前的對(duì)話)
- · 運(yùn)行時(shí)的各種狀態(tài)
1.3 Token
比喻:就像語言的"樂高積木塊",模型用這些積木塊來構(gòu)建句子。
實(shí)際:模型不認(rèn)識(shí)完整的單詞,只認(rèn)識(shí)這些編號(hào)。比如:
1.4 采樣 (Sampling)
比喻:就像從一堆候選詞中抽獎(jiǎng),決定下一個(gè)說什么。
策略:
- · 隨機(jī)采樣:按概率隨機(jī)選擇(更有創(chuàng)意)
- · 溫度調(diào)節(jié):控制隨機(jī)性的程度
2. 接口說明
這里對(duì)幾個(gè)重要函數(shù)(參數(shù)較多)進(jìn)行說明。
2.1 llama_tokenize
LLAMA_API int32_t llama_tokenize(
const struct llama_vocab * vocab, // [in] 詞匯表對(duì)象
const char * text, // [in] 要token化的文本
int32_t text_len, // [in] 文本長(zhǎng)度(字節(jié)數(shù))
llama_token * tokens, // [out] 輸出token數(shù)組
int32_t n_tokens_max, // [in] tokens數(shù)組的最大容量
bool add_special, // [in] 是否添加特殊token
bool parse_special // [in] 是否解析特殊token
);
參數(shù)詳解:
模型的詞匯表,包含token到文本的映射關(guān)系
要處理的輸入文本(UTF-8編碼
文本的字節(jié)長(zhǎng)度(不是字符數(shù))
類型:llama_token*(輸出參數(shù))
存儲(chǔ)生成的token ID數(shù)組,需要調(diào)用者預(yù)先分配足夠空間
tokens數(shù)組的最大容量
是否自動(dòng)添加特殊token(如BOS/EOS)
true:可能添加 <s>(開始符)
false:保持原始文本
是否解析文本中的特殊token(如<|im_start|>)
注意:當(dāng)false時(shí),特殊token會(huì)被當(dāng)作普通文本
2.2 llama_sampler_chain_add
LLAMA_API void llama_sampler_chain_add(
struct llama_sampler * chain, // [in] 采樣器鏈對(duì)象
struct llama_sampler * smpl // [in] 要添加的采樣器
);
目標(biāo)采樣器鏈,新的采樣器將被添加到這里
必須先調(diào)用: llama_sampler_chain_init() 創(chuàng)建鏈
要添加到鏈中的具體采樣器
創(chuàng)建方式: 通過各種 llama_sampler_init_*() 函數(shù)創(chuàng)建
采樣器鏈的工作原理
// 創(chuàng)建采樣器鏈(就像一個(gè)空盒子)
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)); // 第三層調(diào)節(jié)
llama_sampler_chain_add(chain, llama_sampler_init_dist(seed)); // 最終選擇
上面的采樣器鏈的工作過程如下:
原始logits
→ 經(jīng)過top_k(40)過濾
→ 經(jīng)過top_p(0.9)過濾
→ 經(jīng)過溫度(0.8)調(diào)節(jié)
→ dist采樣器最終選擇
添加采樣器的順序很重要:
// 錯(cuò)誤順序:溫度應(yīng)該在過濾之后
llama_sampler_chain_add(chain, llama_sampler_init_temp(0.8)); // 先溫度
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // 后過濾 ← 不對(duì)!
// 正確順序:先過濾再調(diào)節(jié)
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // 先過濾
llama_sampler_chain_add(chain, llama_sampler_init_temp(0.8)); // 后溫度 ← 正確!
必須以采樣器結(jié)束:
// 鏈的最后必須是一個(gè)實(shí)際選擇token的采樣器
llama_sampler_chain_add(chain, llama_sampler_init_dist(seed)); // ? 正確
llama_sampler_chain_add(chain, llama_sampler_init_greedy()); // ? 正確
// 錯(cuò)誤:以過濾采樣器結(jié)束
llama_sampler_chain_add(chain, llama_sampler_init_top_k(40)); // ? 錯(cuò)誤!
2.3 llama_token_to_piece
LLAMA_API int32_t llama_token_to_piece(
const struct llama_vocab * vocab, // [in] 詞匯表對(duì)象
llama_token token, // [in] 要轉(zhuǎn)換的token ID
char * buf, // [out] 輸出緩沖區(qū)
int32_t length, // [in] 緩沖區(qū)長(zhǎng)度
int32_t lstrip, // [in] 跳過前導(dǎo)空格數(shù)
bool special // [in] 是否顯示特殊token
);
包含token到文本映射的詞匯表
要轉(zhuǎn)換為文本的token編號(hào)
存儲(chǔ)轉(zhuǎn)換后的文本片段,需要調(diào)用者預(yù)先分配內(nèi)存
- · length (緩沖區(qū)長(zhǎng)度)
buf 緩沖區(qū)的最大容量(字節(jié)數(shù))
建議: 至少16字節(jié)(大多數(shù)token的文本長(zhǎng)度)
跳過token文本前面的指定數(shù)量的空格
示例:
0: 不跳過任何空格
1: 跳過1個(gè)前導(dǎo)空格
-1: 跳過所有前導(dǎo)空格
是否將特殊token轉(zhuǎn)換為可讀形式
示例:
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] 采樣器對(duì)象
struct llama_context * ctx, // [in] 上下文對(duì)象
int32_t idx // [in] 輸出位置索引
);
包含采樣策略配置(如top-k/top-p/溫度等)
提供模型狀態(tài)和logits輸出
關(guān)鍵數(shù)據(jù):
llama_get_logits(ctx):獲取概率分布
KV緩存:存儲(chǔ)歷史token信息
指定從哪個(gè)輸出位置采樣
典型值:
-1:最后一個(gè)輸出位置(最常用)
0:第一個(gè)輸出位置
正數(shù):指定具體位置
3. 深入了解
3.1 什么是Batch?
在 LLM 中,Batch 就是讓模型一次處理多個(gè) token,而不是一個(gè)一個(gè)處理。
3.1.1 有 Batch 和無 Batch 對(duì)比
// 一次處理一個(gè) 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); // 調(diào)用 prompt_tokens.size() 次!
}
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
llama_decode(ctx, batch)
假設(shè)處理 10 個(gè) token:
- · 單個(gè)處理:10次函數(shù)調(diào)用 + 10次硬件啟動(dòng)
- · 批量處理:1次函數(shù)調(diào)用 + 1次硬件啟動(dòng)
速度提升:通常能快 2-10 倍!
3.1.2 為什么剛開始 Batch 大,后面變?。?/span>
可能大家也發(fā)現(xiàn)了,剛開始:
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
batch 大小為 prompt_tokens.size(),但到了循環(huán)中:
// 準(zhǔn)備下一輪推理的batch(單token)
batch = llama_batch_get_one(&new_token_id, 1);
batch 的 size 變成了 1。
這是因?yàn)椋?/span>
- 1. 處理輸入(理解階段)
輸入:你給模型的完整問題或提示
處理方式:一次性處理所有輸入 tokens(大Batch)
目的:讓模型充分理解整個(gè)上下文 - 2. 生成輸出(創(chuàng)作階段)
輸入:每次只輸入最新生成的一個(gè) token
處理方式:一個(gè)一個(gè)生成(小Batch)
目的:基于前面內(nèi)容逐步創(chuàng)作
- · 并行化優(yōu)勢(shì):輸入處理可以高度并行化(大Batch好)
- · 序列依賴性:輸出生成必須順序進(jìn)行(只能小Batch)
就像寫作文:
- · 閱讀題目:一次性讀完整個(gè)題目要求(大Batch)
- · 寫作過程:一個(gè)字一個(gè)字寫,依賴前文(小Batch)
- · 性能考慮:雖然生成階段用小Batch看似低效,但是:
- · KV緩存:模型已經(jīng)緩存了之前的結(jié)果
- · 增量計(jì)算:只計(jì)算最新的變化部分
- · 實(shí)時(shí)性:用戶可以逐步看到生成結(jié)果
3.2 為什么需要先獲取默認(rèn)參數(shù)?
代碼中很多這樣的設(shè)計(jì),在初始化一個(gè)模塊前,先獲取一下默認(rèn)參數(shù),然后修改默認(rèn)參數(shù),再初始化該模塊。

這樣設(shè)計(jì)的好處在哪?以建筑工地比喻:
// 錯(cuò)誤的做法:直接蓋樓
build_house(?, ?, ?, ?); // 需要什么參數(shù)?怎么設(shè)置?
// 正確的做法:先拿標(biāo)準(zhǔn)圖紙,再按需修改
struct house_plan plan = get_default_house_plan(); // 獲取默認(rèn)設(shè)計(jì)
plan.bedrooms = 3; // 按需修改:3個(gè)臥室
plan.has_garage = true;// 增加車庫
build_house(plan); // 按修改后的設(shè)計(jì)建造
如果沒有默認(rèn)參數(shù),我們可能這樣寫:
// 直接填參數(shù) - 很容易出錯(cuò)!
struct llama_model_params params = {
.n_gpu_layers = 1, // 這個(gè)參數(shù)叫什么?
.use_mmap = true, // 應(yīng)該是 true 還是 false?
// ... 還有很多參數(shù),容易漏掉或?qū)戝e(cuò)
};
而有了默認(rèn)參數(shù):
// 先獲取安全的默認(rèn)值
struct llama_model_params params = llama_model_default_params();
// 然后只修改需要的部分
params.n_gpu_layers = 1; // 明確知道在修改什么
params.use_mmap = true; // 不會(huì)影響到其他默認(rèn)設(shè)置
當(dāng)增加新的參數(shù)時(shí),之前的舊版本不用改,向后兼容:
// 版本1.0
struct params { int a; int b; };
// 版本2.0:添加了新參數(shù)c
struct params { int a; int b; int c; };
// 使用默認(rèn)參數(shù)函數(shù),自動(dòng)處理新老版本
struct params p = get_default_params(); // 自動(dòng)設(shè)置c的默認(rèn)值
大家可以學(xué)習(xí)這種設(shè)計(jì)接口的方式。
3.3 llama_decode 函數(shù)的入?yún)ⅰ⒊鰠⒁约皟?nèi)部發(fā)生的具體過程
3.3.1 函數(shù)簽名
int32_t llama_decode(
struct llama_context * ctx, // 上下文對(duì)象
struct llama_batch batch // 輸入批次數(shù)據(jù)
);
- · ctx (上下文)
類型: struct llama_context *
作用: 包含模型狀態(tài)、KV緩存、計(jì)算圖等
比喻: 就像游戲的存檔文件,記錄了之前的進(jìn)度 - · batch (批次數(shù)據(jù))
結(jié)構(gòu):
struct llama_batch {
int32_t n_tokens; // token數(shù)量
llama_token * token; // token數(shù)組
float * embd; // 嵌入向量(可選)
llama_pos * pos; // 位置信息
llama_seq_id * seq_id; // 序列ID
int8_t * logits; // 是否輸出logits
};
3.3.2 內(nèi)部發(fā)生的過程
(1)第一階段:輸入處理
// 假設(shè)輸入: ["The", "weather", "is"]
batch.token = [123, 456, 789]; // token編號(hào)
batch.pos = [0, 1, 2]; // 位置信息
batch.n_tokens = 3;
(2)第二階段:向量轉(zhuǎn)換
Token → 向量: 每個(gè)token轉(zhuǎn)換成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)第三階段:神經(jīng)網(wǎng)絡(luò)計(jì)算 ,計(jì)算出 Logits(下一個(gè)詞及概率)
Logits 輸出具體數(shù)值示例
Token "is" 的 logits:
- "sunny": 2.8 (高概率)
- "rainy": 1.2
- "cloudy": 0.9
- ... 其他31997個(gè)詞的概率
3.3.3 調(diào)用 llama_decode 后,ctx 會(huì)更新
// ctx 更新后狀態(tài):
// - kv_cache: 新增了本次batch的KV向量
// - logits: 指向最新計(jì)算的概率分布
// - embeddings: 指向最新計(jì)算的嵌入向量
// - memory_usage: 內(nèi)存使用量更新
本篇文章在上篇文章中代碼的基礎(chǔ)上進(jìn)行了深入一點(diǎn)的了解。下篇文章, 我們將不局限于 llama.cpp,而是擴(kuò)展到目前端側(cè)模型部署最常用的推理引擎庫:ONNX Runtime 庫的上手使用。