电竞比分网-中国电竞赛事及体育赛事平台

分享

【端側(cè)AI 與 C++】3. 第一個(gè) C++ AI 程序 (HelloAI)-解析

 小張學(xué)AI 2025-12-04 發(fā)布于山東

上篇文章,我們從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ì)話)
  • · 計(jì)算圖(推理的路線圖)
  • · 運(yùn)行時(shí)的各種狀態(tài)

1.3 Token

比喻:就像語言的"樂高積木塊",模型用這些積木塊來構(gòu)建句子。

實(shí)際:模型不認(rèn)識(shí)完整的單詞,只認(rèn)識(shí)這些編號(hào)。比如:

  • · "你好" → [234, 567]
  • · "謝謝" → [890, 123]

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ù)詳解:

  • · vocab

模型的詞匯表,包含token到文本的映射關(guān)系

  • · text

要處理的輸入文本(UTF-8編碼

  • · text_len

文本的字節(jié)長(zhǎng)度(不是字符數(shù))

  • · tokens

類型:llama_token*(輸出參數(shù))

存儲(chǔ)生成的token ID數(shù)組,需要調(diào)用者預(yù)先分配足夠空間

  • · n_tokens_max

tokens數(shù)組的最大容量

  • · add_special

是否自動(dòng)添加特殊token(如BOS/EOS)

true:可能添加 <s>(開始符)

false:保持原始文本

  • · parse_special

是否解析文本中的特殊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] 要添加的采樣器
)
;
  • · chain (采樣器鏈對(duì)象)

目標(biāo)采樣器鏈,新的采樣器將被添加到這里

必須先調(diào)用: llama_sampler_chain_init() 創(chuàng)建鏈

  • · smpl (采樣器對(duì)象)

要添加到鏈中的具體采樣器

創(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.91)); // 第二層過濾  
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
)
;
  • · vocab (詞匯表對(duì)象)

包含token到文本映射的詞匯表

  • · token (token ID)

要轉(zhuǎn)換為文本的token編號(hào)

  • · buf (輸出緩沖區(qū))

存儲(chǔ)轉(zhuǎn)換后的文本片段,需要調(diào)用者預(yù)先分配內(nèi)存

  • · length (緩沖區(qū)長(zhǎng)度)

buf 緩沖區(qū)的最大容量(字節(jié)數(shù))
建議: 至少16字節(jié)(大多數(shù)token的文本長(zhǎng)度)

  • · lstrip (前導(dǎo)空格跳過)

跳過token文本前面的指定數(shù)量的空格

示例:

0: 不跳過任何空格
1: 跳過1個(gè)前導(dǎo)空格
-1: 跳過所有前導(dǎo)空格
  • · special (特殊token處理)

是否將特殊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] 輸出位置索引
)
;
  • · smpl (采樣器對(duì)象)

包含采樣策略配置(如top-k/top-p/溫度等)

  • · ctx (上下文對(duì)象)

提供模型狀態(tài)和logits輸出

關(guān)鍵數(shù)據(jù):

llama_get_logits(ctx):獲取概率分布
KV緩存:存儲(chǔ)歷史token信息
  • · idx (輸出索引)

指定從哪個(gè)輸出位置采樣

典型值:

-1:最后一個(gè)輸出位置(最常用)
0:第一個(gè)輸出位置
正數(shù):指定具體位置

3. 深入了解

3.1 什么是Batch?

在 LLM 中,Batch 就是讓模型一次處理多個(gè) token,而不是一個(gè)一個(gè)處理。

3.1.1 有 Batch 和無 Batch 對(duì)比

  • · 單個(gè)處理(沒有 Batch)
// 一次處理一個(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() 次!
}
  • · 批量處理(使用 Batch)
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. 1. 處理輸入(理解階段)
    輸入:你給模型的完整問題或提示
    處理方式:一次性處理所有輸入 tokens(大Batch)
    目的:讓模型充分理解整個(gè)上下文
  2. 2. 生成輸出(創(chuàng)作階段)
    輸入:每次只輸入最新生成的一個(gè) token
    處理方式:一個(gè)一個(gè)生成(小Batch)
    目的:基于前面內(nèi)容逐步創(chuàng)作
  • · 技術(shù)原因
    • · 并行化優(yōu)勢(shì):輸入處理可以高度并行化(大Batch好)
    • · 序列依賴性:輸出生成必須順序進(jìn)行(只能小Batch)
    • · 內(nèi)存效率:KV緩存需要按順序更新
    • ·

就像寫作文:

  • · 閱讀題目:一次性讀完整個(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 = [123456789];  // token編號(hào)
batch.pos   = [012];        // 位置信息
batch.n_tokens = 3;

(2)第二階段:向量轉(zhuǎn)換

Token → 向量: 每個(gè)token轉(zhuǎn)換成768維向量
"The" → [0.10.20.3, ..., 0.768]
"weather" → [0.40.50.6, ..., 0.768]
"is" → [0.70.80.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 庫的上手使用。

在這里插入圖片描述

    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評(píng)論

    發(fā)表

    請(qǐng)遵守用戶 評(píng)論公約

    類似文章 更多