【端侧AI 与 C++】3. 第一个 C++ AI 程序 (HelloAI)-解析

上篇文章,我们从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。

这是因为:

  1. 处理输入(理解阶段)
    输入:你给模型的完整问题或提示
    处理方式:一次性处理所有输入 tokens(大Batch)
    目的:让模型充分理解整个上下文
  2. 生成输出(创作阶段)
    输入:每次只输入最新生成的一个 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 库的上手使用。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

同学小张

如果觉得有帮助,欢迎给我鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值