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

分享

【端側(cè)AI 與 C++】2. 第一個 C++ AI 程序 (HelloAI)

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

上篇文章我們本地編譯運行了 llama.cpp,跑通了本地模型的運行。但是用的是 llama.cpp 自帶的調(diào)用模型和運行程序,今天,我們不使用 llama.cpp 自帶的 main 程序,我們要自己寫代碼調(diào)用 libllama 庫。

0. 系列文章

  • · 【端側(cè)AI 與 C++】1. llama.cpp源碼編譯與本地運行

1. 準(zhǔn)備工作

(1)直接在 llama.cpp 根目錄(你可以自己選擇喜歡的目錄)下創(chuàng)建一個新目錄 HelloAI:

mkdir HelloAI
在這里插入圖片描述

(2)在 HelloAI 目錄下,創(chuàng)建 main.cpp 主程序文件和 CMakeLists.txt 文件。

(3)main.cpp 和 CMakeLists.txt 文件寫入最小化代碼,先保證可運行。

  • · main.cpp 代碼:
#include <iostream>

int main(int argc, char** argv)
{
    std::cout << "Hello World!" << std::endl;
    return 0;
}
  • · CMakeLists.txt 代碼:
cmake_minimum_required(VERSION 3.10)
project(HelloAI)

# 設(shè)置C++標(biāo)準(zhǔn)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 添加可執(zhí)行文件
add_executable(HelloAI main.cpp)
  • · 測試編譯運行
cd HelloAI
mkdir build
cd build
cmake ..
make

./HelloAI

運行結(jié)果如下:

在這里插入圖片描述

至此,準(zhǔn)備工作完成,剩下的任務(wù)就是往里面加代碼,與 llama.cpp 聯(lián)通 !

2. 編寫 main.cpp

這是一個最小化的推理代碼。它做了三件事:

  • · 加載模型 -> 將文本轉(zhuǎn)為數(shù)字(Tokenize) -> 循環(huán)預(yù)測下一個數(shù)字。

2.1 加載模型

把模型文件從硬盤加載到內(nèi)存中,準(zhǔn)備好計算資源。

(1)模型路徑(上一篇文章中咱們自己下載的模型)

(2)獲取模型默認(rèn)參數(shù)

(3)加載模型文件

std::string model_path = "xxx/models/qwen2.5-0.5b-instruct-q4_k_m.gguf";  // 模型文件絕對路徑

llama_model_params model_params = llama_model_default_params();  // 獲取默認(rèn)模型參數(shù)

// 從文件加載模型,返回模型指針
llama_model* model = llama_model_load_from_file(model_path.c_str(), model_params);
if (!model) {  // 模型加載失敗檢查
    std::cerr << "Failed to load model: " << model_path << std::endl;
    return 1;
}

2.2 將文本轉(zhuǎn)化為數(shù)字(tokenization)

把你寫的中文 Prompt 轉(zhuǎn)換成模型能理解的 Token 序列(整數(shù)數(shù)組)。

(1)獲取模型詞匯表: 模型不認(rèn)識“你好”,它只認(rèn)識數(shù)字。你需要拿到模型的“字典”指針,后面用來把文字轉(zhuǎn)換成數(shù)字。

(2)對 Prompt 進行 Token 化

// 獲取模型的詞匯表(用于tokenization)
const llama_vocab* vocab = llama_model_get_vocab(model);
// 首先獲取token數(shù)量
const int n_prompt = -llama_tokenize(vocab, prompt.c_str(), prompt.size(), nullptr0truetrue);
std::vector<llama_token> prompt_tokens(n_prompt);  // 存儲token序列的vector
// 執(zhí)行tokenization
if (llama_tokenize(vocab, prompt.c_str(), prompt.size(), prompt_tokens.data(), prompt_tokens.size(), truetrue) < 0)
{
    std::cerr << "Failed to tokenize prompt" << std::endl;
    return 1;
}

2.3 準(zhǔn)備推理

2.3.1 創(chuàng)建推理上下文: 推理的“工作區(qū)”

上下文是模型“思考”時的臨時記憶區(qū)。

  • · 設(shè)置 ctx_params.n_ctx(比如 2048),決定模型能記多少東西。
  • · 設(shè)置 ctx_params.n_batch,決定一次處理多少 token。
  • · 使用 llama_init_from_model 創(chuàng)建上下文對象 ctx。
llama_context_params ctx_params = llama_context_default_params();  // 默認(rèn)上下文參數(shù)
ctx_params.n_ctx = 2048;       // 上下文窗口大小(最大token數(shù))
ctx_params.n_batch = n_prompt; // 批處理大小(提升推理效率)
ctx_params.no_perf = false;    // 啟用性能統(tǒng)計

// 從模型創(chuàng)建推理上下文
llama_context* ctx = llama_init_from_model(model, ctx_params);
if (!ctx) {  // 上下文創(chuàng)建失敗檢查
    std::cerr << "Failed to create context" << std::endl;
    return 1;
}

2.3.2 配置采樣器:規(guī)定模型“怎么說話”

采樣器決定模型如何選擇下一個詞(是選概率最大的,還是隨機一點?)。

代碼中使用的是 Greedy (貪婪) 策略:永遠(yuǎn)只選概率最大的那個詞(最穩(wěn),但可能缺乏創(chuàng)造力)。

auto sparams = llama_sampler_chain_default_params();  // 默認(rèn)采樣器參數(shù)
sparams.no_perf = false;  // 啟用性能統(tǒng)計
llama_sampler* smpl = llama_sampler_chain_init(sparams);  // 初始化采樣器鏈

// 添加貪婪采樣策略(每次選擇概率最高的token)
llama_sampler_chain_add(smpl, llama_sampler_init_greedy());

2.4 正式推理

這是最核心的部分。輸入 -> 模型計算 -> 吐出一個詞 -> 再把這個詞輸進去 -> 循環(huán)。

2.4.1 準(zhǔn)備第一批數(shù)據(jù) (Batch)

使用 llama_batch_get_one 把剛才轉(zhuǎn)換好的 prompt_tokens 打包成一個批次。

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

2.4.2 處理編碼器模型 (可選,本文案例可以不要)

注:大部分聊天模型(如 Llama, Qwen)不需要這一步,只有 T5 等架構(gòu)需要。

如果模型有 Encoder,先跑 llama_encode,然后準(zhǔn)備 Decoder 的起始 Token。

// 如果是編碼器-解碼器架構(gòu)的模型
if (llama_model_has_encoder(model))
{
    // 先編碼prompt
    if (llama_encode(ctx, batch))
    {
        std::cerr << "llama_encode failed" << std::endl;
        return1;
    }
    // 獲取解碼起始token
    llama_token decoder_start_token_id = llama_model_decoder_start_token(model);
    if (decoder_start_token_id == LLAMA_TOKEN_NULL)
    {
        // 如果模型沒有指定,使用BOS(begin of sequence) token
        decoder_start_token_id = llama_vocab_bos(vocab);
    }
    // 準(zhǔn)備解碼階段的batch
    batch = llama_batch_get_one(&decoder_start_token_id, 1);
}

2.3.4 主推理循環(huán) - 生成文本

直到達到生成的長度限制 n_predict。

// 循環(huán)直到生成足夠token或遇到結(jié)束標(biāo)記
for (int n_pos = 0; n_pos + batch.n_tokens < n_prompt + n_predict; )
{
    n_pos += batch.n_tokens;  // 更新已處理token位置
}

具體步驟:

2.3.4.1 解碼 (Decode)

if (llama_decode(ctx, batch))
{
    std::cerr << "llama_decode failed" << std::endl;
    return 1;
}

2.3.4.2 采樣 (Sample)

調(diào)用 llama_sampler_sample 挑出下一個 Token (new_token_id)。

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

2.3.4.3 檢查結(jié)束 (Check End)

如果生成的 Token 是“結(jié)束符”(EOG),就跳出循環(huán)。

if (llama_vocab_is_eog(vocab, new_token_id)) {
    break;  // 遇到結(jié)束標(biāo)記則終止生成
}

2.3.4.4 更新Batch

把這個新生成的 Token 放入 batch,作為下一次推理的輸入。

batch = llama_batch_get_one(&new_token_id, 1);

然后進入下一次循環(huán)。

3. 完整代碼與運行結(jié)果

3.1 CMakeLists.txt 文件加入 llama.cpp 相關(guān)內(nèi)容

這里就不細(xì)講了,不是本文重點。

文件中重點關(guān)注:

  • · llama.cpp 目錄路徑
  • · 之前你編譯的 llama 庫的路徑
  • · 如果你跟我一樣是mac電腦,庫的后綴名是 .dylib
cmake_minimum_required(VERSION 3.10)
project(HelloAI)

set(CMAKE_CXX_STANDARD 17)

# 設(shè)置 llama.cpp 的路徑
set(LLAMA_DIR "${CMAKE_SOURCE_DIR}/../../llama.cpp")

# 頭文件路徑
include_directories(${LLAMA_DIR}/include)
include_directories(${LLAMA_DIR}/ggml/include)

# 庫文件路徑 (根據(jù)你的編譯結(jié)果,可能會變)
link_directories(${LLAMA_DIR}/build/src)
link_directories(${LLAMA_DIR}/build/ggml/src)
link_directories(${LLAMA_DIR}/build/common) # 有時候需要 common

add_executable(HelloAI main.cpp)

# 鏈接庫
# 這里使用 glob 暴力鏈接所有 ggml 相關(guān)的靜態(tài)庫,防止漏掉
file(GLOB GGML_LIBS "${LLAMA_DIR}/build/bin/*dylib")

target_link_libraries(HelloAI 
    ${LLAMA_DIR}/build/bin/libllama.dylib
    ${GGML_LIBS}
)

if (APPLE)
    target_link_libraries(HelloAI "-framework Foundation -framework Metal -framework MetalKit")
endif()

3.2 main.cpp 完整代碼 & 詳細(xì)注釋

// 標(biāo)準(zhǔn)庫頭文件引入
#include <iostream>    // 用于標(biāo)準(zhǔn)輸入輸出流操作
#include <vector>      // 使用動態(tài)數(shù)組容器存儲token序列
#include <string>      // 字符串處理
#include <cstring>     // C風(fēng)格字符串處理
#include "llama.h"     // llama.cpp核心庫,提供LLM推理功能

// -------------------------------------------------------------------------
// 主函數(shù) - 程序入口
// -------------------------------------------------------------------------
int main(int argc, char** argv) {
    // 1. 模型路徑和推理參數(shù)配置
    std::string model_path = "xxxx/models/qwen2.5-0.5b-instruct-q4_k_m.gguf";  // 模型文件絕對路徑
    std::string prompt = "你是誰?";  // 初始提示詞(prompt)
    int n_predict = 32;              // 最大生成token數(shù)量限制

    // 2. 初始化GGML后端計算資源
    ggml_backend_load_all();  // 加載所有可用的后端計算設(shè)備(CPU/GPU等)

    // 3. 加載LLM模型
    llama_model_params model_params = llama_model_default_params();  // 獲取默認(rèn)模型參數(shù)
    // model_params.n_gpu_layers = 99; // 開啟Metal GPU加速(適用于macOS)
    
    // 從文件加載模型,返回模型指針
    llama_model* model = llama_model_load_from_file(model_path.c_str(), model_params);
    if (!model) {  // 模型加載失敗檢查
        std::cerr << "Failed to load model: " << model_path << std::endl;
        return1;
    }

    // 獲取模型的詞匯表(用于tokenization)
    const llama_vocab* vocab = llama_model_get_vocab(model);
    
    // 4. 將提示詞(prompt)轉(zhuǎn)換為token序列
    // 4.1 首先獲取token數(shù)量
    constint n_prompt = -llama_tokenize(vocab, prompt.c_str(), prompt.size(), nullptr0truetrue);

    // 4.2 分配空間并執(zhí)行tokenization
    std::vector<llama_token> prompt_tokens(n_prompt);  // 存儲token序列的vector
    if (llama_tokenize(vocab, prompt.c_str(), prompt.size(), prompt_tokens.data(), prompt_tokens.size(), truetrue) < 0)
    {
        std::cerr << "Failed to tokenize prompt" << std::endl;
        return1;
    }

    // 5. 創(chuàng)建推理上下文(context)
    llama_context_params ctx_params = llama_context_default_params();  // 默認(rèn)上下文參數(shù)
    ctx_params.n_ctx = 2048;       // 上下文窗口大小(最大token數(shù))
    ctx_params.n_batch = n_prompt; // 批處理大小(提升推理效率)
    ctx_params.no_perf = false;    // 啟用性能統(tǒng)計
    
    // 從模型創(chuàng)建推理上下文
    llama_context* ctx = llama_init_from_model(model, ctx_params);
    if (!ctx) {  // 上下文創(chuàng)建失敗檢查
        std::cerr << "Failed to create context" << std::endl;
        return1;
    }

    // 6. 初始化采樣器(sampler) - 控制文本生成策略
    auto sparams = llama_sampler_chain_default_params();  // 默認(rèn)采樣器參數(shù)
    sparams.no_perf = false;  // 啟用性能統(tǒng)計
    llama_sampler* smpl = llama_sampler_chain_init(sparams);  // 初始化采樣器鏈
    
    // 添加貪婪采樣策略(每次選擇概率最高的token)
    llama_sampler_chain_add(smpl, llama_sampler_init_greedy());

    // 打印原始prompt(用于調(diào)試)
    for (auto id : prompt_tokens) {
        char buf[128];
        // 將token轉(zhuǎn)換為可讀文本
        int n = llama_token_to_piece(vocab, id, buf, sizeof(buf), 0true);
        if (n < 0) {
            fprintf(stderr, "%s: error: failed to convert token to piece\n", __func__);
            return1;
        }
        std::string s(buf, n);
        printf("%s", s.c_str());  // 打印token對應(yīng)的文本
    }

    // 7. 準(zhǔn)備批處理(batch)數(shù)據(jù)
    llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
    // 如果是編碼器-解碼器架構(gòu)的模型
    // if (llama_model_has_encoder(model))
    // {
    //     // 先編碼prompt
    //     if (llama_encode(ctx, batch))
    //     {
    //         std::cerr << "llama_encode failed" << std::endl;
    //         return 1;
    //     }
    //     // 獲取解碼起始token
    //     llama_token decoder_start_token_id = llama_model_decoder_start_token(model);
    //     if (decoder_start_token_id == LLAMA_TOKEN_NULL)
    //     {
    //         // 如果模型沒有指定,使用BOS(begin of sequence) token
    //         decoder_start_token_id = llama_vocab_bos(vocab);
    //     }
    //     // 準(zhǔn)備解碼階段的batch
    //     batch = llama_batch_get_one(&decoder_start_token_id, 1);
    // }

    // 8. 主推理循環(huán) - 生成文本
    constauto t_main_start = ggml_time_us();  // 記錄開始時間(微秒)
    int n_decode = 0;  // 解碼token計數(shù)器
    llama_token new_token_id;  // 存儲新生成的token
    
    // 循環(huán)直到生成足夠token或遇到結(jié)束標(biāo)記
    for (int n_pos = 0; n_pos + batch.n_tokens < n_prompt + n_predict; )
    {
        // 執(zhí)行解碼推理
        if (llama_decode(ctx, batch))
        {
            std::cerr << "llama_decode failed" << std::endl;
            return1;
        }

        n_pos += batch.n_tokens;  // 更新已處理token位置
        
        // 選擇下一個token
        {
            // 使用采樣器選擇最可能的下一個token
            new_token_id = llama_sampler_sample(smpl, ctx, -1);

            // 檢查是否是結(jié)束標(biāo)記(End of Generation)
            if (llama_vocab_is_eog(vocab, new_token_id)) {
                break;  // 遇到結(jié)束標(biāo)記則終止生成
            }

            // 將token轉(zhuǎn)換為可讀文本
            char buf[128];
            int n = llama_token_to_piece(vocab, new_token_id, buf, sizeof(buf), 0true);
            if (n < 0) {
                std::cerr << "Failed to convert token to piece" << std::endl;
                return1;
            }
            std::string s(buf, n);
            std::cout << s;  // 輸出生成的文本

            // 準(zhǔn)備下一輪推理的batch(單token)
            batch = llama_batch_get_one(&new_token_id, 1);

            n_decode += 1;  // 增加解碼計數(shù)
        }
    }

    // 生成結(jié)束提示
    std::cout << "\n\nDone!" << std::endl;

    // 9. 性能統(tǒng)計
    constauto t_main_end = ggml_time_us();  // 記錄結(jié)束時間
    // 計算并輸出生成速度(tokens/秒)
    std::cout << "decoded: " << n_decode << " tokens in "
        << (t_main_end - t_main_start) / 1000000.0f << " s, speed: "
        << n_decode / ((t_main_end - t_main_start) / 1000000.0f) << " tok/s"
        << std::endl;
    
    // 打印采樣器和上下文的性能數(shù)據(jù)
    llama_perf_sampler_print(smpl);
    llama_perf_context_print(ctx);

    // 10. 資源清理
    llama_sampler_free(smpl);  // 釋放采樣器
    llama_free(ctx);           // 釋放上下文
    llama_model_free(model);   // 釋放模型

    return0;  // 程序正常退出
}

3.3 編譯運行

mkdir build
cd build
cmake ..
make 

./HelloAI

3.4 運行結(jié)果

可以看到正?;貜?fù)了我的提問,當(dāng)然,被截斷了,因為到達了 n_predict 數(shù)量。
在這里插入圖片描述

4. 總結(jié)

總結(jié)一下主要步驟:

(1)指路:定好模型路徑字符串。
(2)載入:Load Backend -> Load Model -> Get Vocab。
(3)預(yù)處理:Tokenize (把字符串變數(shù)字)。
(4)配置:Create Context (申請內(nèi)存) -> Init Sampler (設(shè)定規(guī)則)。
(5)跑圈:Batch (填入Prompt) -> Decode (計算) -> Sample (選詞) -> Print -> Next Batch (填入新詞) -> Repeat。
(6)關(guān)燈:Free 所有指針。

本文結(jié)束,至此,我們大體知道了手動調(diào)用一個模型文件的總過程。下篇文章,我們在這個 main.cpp 文件上,深入看一下 llama.cpp 接口的定義,以及一些編碼過程中可能有的疑問,例如: 為什么調(diào)用模型文件是這么一個過程?Batch是什么?等。

在這里插入圖片描述

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

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多