【AI】集成doubao-vision视觉api实现卡路里识别

【AI】集成doubao-vision视觉api实现卡路里识别

本文介绍了Android和IOS平台集成在线视觉AI的过程

这个功能实现很早了,在开源项目 PeachAssistant 里已经有体现了,利用CMP跨平台技术在Android和IOS平台均完成了功能的集成。现记录一下api的介绍和两个平台的执行流程。

豆包API介绍

首先在个人控制台的服务管理页面开通视觉识别api权限,获取 API_KEY ,baseURL为 https://ark.cn-beijing.volces.com/api/v3 。图片可以使用url或者文件文件base64编码上传两种方案。

如果你要传入的图片/视频在本地,你可以将这个其转化为 Base64 编码,然后提交给大模型。下面是一个简单的示例代码。

传入 Base64 编码格式时,请遵循以下规则

传入的是图片:
格式遵循data:image/<图片格式>;base64,<Base64编码>,其中,
图片格式:jpeg、png、gif等,支持的图片格式详细见图片格式说明。
Base64 编码:图片的 Base64 编码。

传入的是视频:
格式遵循data:video/<视频格式>;base64,<Base64编码>,其中,
视频格式:MP4、AVI等,支持的视频格式详细见视频格式说明。
Base64 编码:视频的 Base64 编码。

请求实例:

BASE64_IMAGE=$(base64 < path_to_your_image.jpeg) && curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json"  \
   -H "Authorization: Bearer $ARK_API_KEY"  \
   -d @- <<EOF
   {
    "model": "doubao-seed-1-6-251015",
    "messages": [
      {
        "role": "user",
        "content": [
            {
            "type": "image_url",
            "image_url": {
              "url": "data:image/jpeg;base64,$BASE64_IMAGE"
            },
            {
            "type": "text",
            "text": "图里有什么"
            }
        ]
      }
    ],
    "max_tokens": 300
    }
EOF

可以通过 detail 字段控制图片理解的精细度。

  • low:“低分辨率”模式,默认此模式,处理速度会提高,适合图片本身细节较少或者只需要模型理解图片大致信息或者对速度有要求的场景。此时 min_pixels 取值3136、max_pixels 取值1048576,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)将会等比例缩放至范围内。
  • high:“高分辨率”模式,这代表模型会理解图片更多的细节,但是处理图片速度会降低,适合需要模型理解图像细节,图像细节丰富,需要关注图片细节的场景。此时 min_pixels 取值3136、max_pixels 取值4014080,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)的图片将会等比例缩放至范围内。

例如:

curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer $ARK_API_KEY" \
   -d '{
    "model": "doubao-seed-1-6-251015",
    "messages": [
        {
            "role": "user",
            "content": [                
                {"type": "image_url","image_url": {"url":  "https://ark-project.tos-cn-beijing.volces.com/doc_image/ark_demo_img_1.png"},"detail": "high"},
                {"type": "text", "text": "支持输入图片的模型系列是哪个?"}
            ]
        }
    ],
    "max_tokens": 300
  }'

KMP公共网络请求

class DoubaoVisionRepository(private val ktorClient: KtorClient) {

    companion object {
        const val BASE_URL =
            "https://ark.cn-beijing.volces.com/api/v3"
        const val VISION_SYSTEM_PROMT =
            "下图是一张食物图片,请你计算每种食物的重量和卡路里,返回一个json,其中name为String,weight为Int,calorie为Int(单位千卡),json格式:\n" +
                    "{\n" +
                    "  \"foods\": [\n" +
                    "    {\n" +
                    "      \"name\": \"食物名称\",\n" +
                    "      \"weight\": \"食物重量\",\n" +
                    "      \"calorie\": \"食物卡路里\"\n" +
                    "    }\n" +
                    "  ]\n" +
                    "}"
        const val API_KEY = "xxxxxxxxxxxx"
        const val MODEL_NAME = "doubao-1-5-vision-pro-32k-250115"
    }

    suspend fun calCalorieByAI(imageType: String, imageBase64:String) = withContext(Dispatchers.IO) {
        ktorClient.client.post("${BASE_URL}/chat/completions") {
            // 配置请求头
            headers {
                append("Content-Type", "application/json")
                append("Authorization", "Bearer $API_KEY")
            }
            setBody(
                DoubaoVisionRequest(
                    model = MODEL_NAME,
                    messages = listOf(
                        DoubaoRequestMessage(
                            role = ChatRole.SYSTEM.roleDescription,
                            content = listOf(
                                DoubaoVisionContent(
                                    type = "text",
                                    text = VISION_SYSTEM_PROMT,
                                ),
                                DoubaoVisionContent(
                                    type = "image_url",
                                    image_url = ImageUrl(
                                        url = "data:image/$imageType;base64,$imageBase64"
                                    ),
                                )
                            )
                        ),
                    )
                )
            )
        }.body<DoubaoVisionResponse>()
    }
}

Android实现

权限申请

相册上传

运行截图:

实时拍照上传

IOS实现

权限申请

相册上传

实时拍照上传

【AI】多模态模型的多样化数据处理

【AI】多模态模型的多样化数据处理

本文介绍了文本模型之外的多模态AI模型如何处理数据的

经过前面若干篇的学习,我了解到LLM是如何处理输入文本,一轮一轮地进行前向推理,最后输出结果反馈的。

那多模态的AI模型,又是如何处理一帧一帧的图像,或者音频数据呢?现对这些不同于文本的数据处理进行一段学习总结。

简单来说,模型通过专门设计的 “编码器” 将不同类型的数据“翻译”成同一种“语言”——也就是 向量 。这个过程可以分为两大步:

  1. 独立编码(Independent Encoding):每种数据类型(图像、音频)都有一个专门的编码转换器,负责将其从原始格式转换成一个初步的向量序列。
  2. 对齐与融合(Alignment & Fusion):通过特殊的训练方法,让这些来自不同专家的向量在同一个“语义空间”里对齐,使得“小狗的图片”和“小狗的叫声”以及文字“小狗”的向量在空间中的位置非常接近。

向量嵌入模型

向量嵌入(Vector Embedding) 模型是当今许多AI应用的基石。

想象一下,你有一个巨大的图书馆,里面有成千上万本书。现在,你想找到所有和“科幻”相关的书。一个笨方法是逐一阅读每一本书的简介。这太慢了。

一个聪明的图书管理员(我们的AI模型)想出了一个好办法:他没有给书贴上“科幻”、“历史”这样的 简单标签 ,而是为每本书在图书馆里分配了一个 精确的三维坐标 (例如,坐标 [x, y, z])。

这个坐标的分配原则是:

  • 内容相似的书,在空间中的位置就非常接近。比如,《三体》和《银河帝国》的坐标可能非常靠近。
  • 内容无关的书,在空间中的位置就非常遥远。比如,《三体》和《莎士比亚戏剧集》的坐标会离得很远。
  • 坐标轴本身也代表了某种“意义”。也许x轴代表“虚构程度”,y轴代表“科技含量”,z轴代表“年代”。

这样一来,找书就变得非常简单:

  1. 你告诉管理员你要找“一部关于星际旅行和外星文明的小说”。
  2. 管理员将你的需求也转换成一个坐标。
  3. 然后,他在图书馆的这个三维空间里,找到离你的需求坐标 最近 的那些书。

在这个比喻里:

  • 书/你的需求:就是我们要处理的数据(单词、句子、图片、商品等)。
  • 坐标 [x, y, z]:就是向量嵌入 (Vector Embedding)。它是一个由数字组成的数组(向量),代表了原始数据在高维空间中的位置。
  • 整个三维空间:被称为嵌入空间 (Embedding Space)
  • 聪明的图书管理员:就是向量嵌入模型

向量嵌入可以将各种复杂、离散的数据(如文字、图片)转换成计算机可以理解和比较的、连续的、稠密的数字向量,并在这个过程中保留数据的“语义信息”。

为什么需要向量嵌入?

计算机不理解“苹果”这个词。它只懂数字。在AI出现之前,我们可能会用 One-Hot 编码(独热编码) 来表示单词。

假设我们的词典里只有5个词:[猫, 狗, 苹果, 香蕉, 橙子]。

  • 猫:[1, 0, 0, 0, 0]
  • 狗:[0, 1, 0, 0, 0]
  • 苹果:[0, 0, 1, 0, 0]

这种方法有两个 严重的缺陷

  1. 维度灾难:如果词典有10万个词,每个词的向量就有10万维,非常稀疏和浪费空间。
  2. 无法表达语义相似性:从数学上看,[1, 0, 0][0, 1, 0] 之间的距离,与 [1, 0, 0][0, 0, 1] 之间的距离是完全一样的。也就是说,模型无法知道“猫”和“狗”的关系比“猫”和“苹果”更近。所有词之间都是孤立的。

向量嵌入完美地解决了这两个问题。它使用一个更低维度(通常是几百到几千维)稠密向量 来表示数据,并且向量之间的距离和方向能够反映数据之间的语义关系。

向量嵌入模型的工作原理

模型是如何学会给每个单词或句子分配一个“有意义”的坐标的呢?答案是:通过在一个巨大的数据集上进行 “自监督学习”

核心原理可以用一句话概括:“一个词的意义,由它周围的词来定义”

我们以一个经典的词嵌入模型 Word2Vec 为例来解释这个过程。

训练过程(以 Word2Vec 的 Skip-gram 模式为例):

  1. 准备数据:获取海量文本,比如整个维基百科。

  2. 建立任务:我们给模型设定一个任务——根据一个中心词,预测它周围的词(上下文)
    • 例如,在句子 “一只可爱的正坐在垫子上” 中。
    • 中心词是 “猫”。
    • 上下文是 “一只”、“可爱的”、“正”、“坐在”。
  3. 模型初始化
    • 为词典里的每一个词,随机生成一个向量(比如300维)。此时,这些向量是毫无意义的。
  4. 开始训练(迭代学习)
    • 输入:我们把 “猫” 的随机向量输入到一个简单的神经网络中。
    • 预测:模型会根据这个输入向量,输出一个预测,表示它认为“猫”周围最可能出现哪些词。在训练初期,这个预测肯定是乱七八糟的。
    • 计算误差:我们将模型的预测结果与真实的上下文(“一只”、“可爱的”等)进行比较,计算出一个损失(Loss)误差(Error)。误差越大,说明模型预测得越差。
    • 反向传播与更新:算法会根据这个误差,微调(更新)神经网络的权重,尤其是“猫”的输入向量。调整的原则是:让“猫”的向量变得“更擅长”预测出它周围的词
    • 重复:对文本库里的每一个词都重复这个过程亿万次。
  5. 最终结果
    • 训练结束后,词典里每个词的向量都经过了无数次的微调。
    • 因为“猫”和“狗”经常出现在相似的上下文中(比如“可爱的__”、“喂养__”、“宠物__”),为了能同时预测好这些上下文,模型会“自发地”将“猫”和“狗”的向量调整到嵌入空间中非常相近的位置。
    • 而“猫”和“苹果”的上下文几乎完全不同,所以它们的向量在空间中就会相距很远。

最终,我们扔掉用于预测的神经网络,只保留训练好的、包含所有词及其对应向量的那个查找表。这个表就是我们的词嵌入模型

嵌入的奇妙特性:

训练好的嵌入向量甚至可以捕捉到更复杂的关系,最经典的例子是: \[\text{vector('King')} - \text{vector('Man')} + \text{vector('Woman')} \approx \text{vector('Queen')}\]

这表明,嵌入空间中的向量方向也蕴含了语义,例如“性别”或“皇室”等抽象概念。

著名/主流的嵌入模型

  1. Word2Vec (Google): 开创性的词嵌入模型,简单高效。它包含两种模式:Skip-gram(根据中心词预测上下文)和 CBOW(根据上下文预测中心词)。
  2. GloVe (Stanford): 另一种经典的词嵌入模型,它利用全局词-词共现矩阵来生成嵌入,考虑了全局统计信息。
  3. BERT (Google) & Transformer-based Models: 这是现代嵌入模型的主流。
    • 关键区别:Word2Vec 为每个词生成的向量是静态的、唯一的。但在现实中,词的意义随语境而变。例如,“bank”在 “river bank”(河岸)和 “investment bank”(投资银行)中的意思完全不同。
    • BERT这类模型是上下文相关的(Contextual)。它在生成一个词的嵌入时,会同时考虑整个句子的信息。因此,同一个词在不同句子中会得到不同的嵌入向量,这极大地提升了表示的准确性。
  4. OpenAI Embeddings (如 text-embedding-ada-002): 目前非常流行和强大的通用文本嵌入模型,广泛用于各种AI应用。
  5. CLIP (OpenAI): 一种强大的多模态嵌入模型。它可以为一张图片和一个描述该图片的句子生成非常相似的向量。这使得通过文本搜索图片成为可能。

应用场景

向量嵌入是许多现代AI系统的“引擎”,它的应用无处不在:

  1. 语义搜索/向量搜索
    • 传统的关键字搜索只能匹配字面内容。而向量搜索可以理解查询的“意图”。你搜索“夏天穿的透气鞋子”,它能返回商品名里没有这些词但符合描述的“网面运动凉鞋”。
    • 这是目前 RAG (Retrieval-Augmented Generation,检索增强生成) 技术的核心,大语言模型通过向量搜索找到相关知识库内容,再进行回答,以减少幻觉。
  2. 推荐系统
    • 将用户和商品都嵌入到同一个向量空间中。一个用户的向量,会和他可能喜欢的商品的向量非常接近。通过计算向量相似度,可以为用户推荐他可能感兴趣的商品、电影或音乐。
  3. 文本分类与聚类
    • 将文本转换成向量后,可以轻松地使用机器学习算法进行情感分析(正面/负面评论)、新闻主题分类等。相似的文本向量会自然地“聚”在一起。
  4. 问答系统和聊天机器人
    • 将用户的问题和知识库中的“问题-答案”对都转换成向量。通过找到与用户问题向量最相似的问题向量,来返回对应的答案。
  5. 图像搜索
    • 以图搜图(找到相似图片)或以文搜图(输入“一只猫在草地上”,返回对应的图片)。

图像数据

第一步类似于文本模型,首先要理解输入内容物是什么东西。在将图片信息与其他模态(如文本)进行融合之前,模型需要将原始像素数据转换为有意义的、可供计算的向量表示,这称为特征提取。

数据特征提取

一般通过 卷积神经网络 (CNN),尤其是像 ResNet、VGG 或 ViT (Vision Transformer) 这样的模型架构。

  • CNN 的作用: CNN 通过多层卷积操作,从图片中自动学习和提取层级特征。浅层提取边缘、纹理等基础特征;深层提取鼻子、眼睛、汽车等高层语义特征。
  • Vision Transformer (ViT) 的作用: ViT 不使用卷积,而是将图像分割成许多小块,然后使用 Transformer 的自注意力机制来捕捉这些小块之间的关系,这与处理文本的方式相似,有助于模态间的对齐。

提取器最终输出一个图像嵌入向量,它是一个高维向量,浓缩了整张图片或图片中关键区域的语义信息。

一、 图像数据的向量化

原始的图像数据是一个由像素值(RGB)构成的三维矩阵(宽 x 高 x 通道)。使用当前最主流的 Vision Transformer 架构来处理它时,这个流程是怎样的呢?

ViT过程拆解:

  1. 图像分块
    • 模型不会一次性看整个图像的几百万个像素,这计算量太大了。相反,它会像切拼图一样,将原始图像(例如 224x224 像素)切割成一系列固定大小的小方块(Patches),比如每个方块是 16x16 像素。
    • 这样,一张 224x224 的图像就变成了一个由 (224/16) * (224/16) = 14 * 14 = 196 个小方块组成的序列
  2. 展平与线性投射
    • 将每个 16x16x3 (3是RGB通道) 的小方块展平,变成一个长向量。
    • 然后,通过一个可学习的线性投射层(Linear Projection Layer),将这个长向量映射(降维或升维)到一个固定的维度,比如768维。
    • 现在,我们就得到了一个由196个768维向量组成的序列。这在结构上就和经过词嵌入的句子(由多个词向量组成的序列)非常相似了!
  3. 加入位置编码
    • 和文本一样,这些图像块的相对位置非常重要(“耳朵”在“头”的上面)。因此,模型会为每个图像块向量加入一个位置编码向量,来告诉模型每个小块的原始位置信息。
  4. 通过 Transformer 编码器
    • 将这个带有位置信息的向量序列输入到一个标准的 Transformer 编码器中。
    • 编码器内部的 自注意力机制(Self-Attention) 会让每个图像块去“关注”其他的图像块,从而理解它们之间的关系和全局结构。例如,一个代表“车轮”的图像块会和代表“车身”的图像块建立强关联。
    • 经过多层Transformer Block的处理后,模型就得到了对整个图像内容和结构的深度理解。
  5. 输出最终向量
    • 通常会借鉴BERT中的 [CLS] 思想,在图像块序列的最前面添加一个特殊的 [CLASS] 向量。在经过Transformer编码器后,这个 [CLASS] 向量对应的最终输出向量,就被认为是代表整个图像语义的聚合向量。

最终,一张复杂的图像就被转换成了一个单一的、高维的、包含丰富语义的向量(例如768维)。

音频数据

原始的音频数据是 一维的波形信号 ,它记录了随时间变化的振幅。直接处理这个长序列非常困难。因此,标准做法是先将其转换成一种“像图像一样”的二维表示。

预处理:波形转频谱图

音频的核心信息在于不同频率的声音随时间如何变化。通过 短时傅里叶变换(STFT) 将原始的一维波形转换成一个 频谱图(Spectrogram)

这个频谱图是一个二维图像:

  • X轴 代表 时间
  • Y轴 代表 频率
  • 颜色/亮度 代表该频率在该时间的 能量(音量) 通常会使用梅尔频谱图(Mel-Spectrogram),因为它更贴近人耳对频率的感知方式。通过这个转换之后,音频数据就变成了一张“图像”!

使用类似图像的处理方法

一旦我们有了频谱图这个二维表示,接下来的处理就和上面图像处理的流程非常相似了。模型(例如 Audio Spectrogram Transformer, AST)也会将这张频谱图切割成一系列的小方块(Patches)。同样地,对这些方块进行 线性投射 、加入 位置编码 ,然后将它们组成的序列送入一个 Transformer 编码器 。Transformer的自注意力机制能够捕捉音频序列中长距离的依赖关系,类似于理解一句话中前后词语的语境。

输出最终向量

与ViT类似,经过Transformer编码器处理后,模型会输出一个代表整个音频片段语义的聚合向量。这个向量捕捉了音频中的内容,比如是人声(说了什么)、音乐(什么风格)还是环境音(狗叫、汽车声)。

图像和音频的模态对齐融合

现在我们有了 图像向量、音频向量和文本向量 。但此时它们还处在各自的世界维度里,无法直接比较。让它们统一到同一个语义空间的关键技术是 对比学习 。这是多模态理解的核心。模型需要学会这些音频向量和文本/视觉向量之间的关系。

CLIP (Contrastive Language-Image Pre-training) 模型为例,它就是专门用来对齐图像和文本的:

  • 数据输入 :收集数亿个 (图像, 文本描述) 的配对数据。
  • 训练目标 :在训练过程中,模型会看到大量的“音频-文本” 键值对,例如: “一段狗叫声” 和文本 “一只狗在叫” 。将一个图像和它 正确匹配 的文本描述分别通过各自的编码器,得到 image_vectortext_vector。模型的目标是 拉近(Maximize Similarity) 这对正样本(matched pair)向量的相似度(例如,余弦相似度)。同时,对于一个图像, batch里的所有其他文本描述都是负样本(unmatched pairs)。模型的目标是 推远(Minimize Similarity) 这个图像向量和所有这些错误文本向量的相似度。

最终,“狗叫”的音频向量和“狗叫”的文本向量在语义空间中的位置会非常接近。通过在这种“连连看”式的任务上进行大规模训练,图像编码器和文本编码器会“被迫”学会一种共识。它们会自发地将 语义上相似 的概念映射到向量空间中的 邻近区域 ,无论这个概念是来自图片还是文字。

输入一张 “金毛犬在草地上玩耍” 的图片所生成的向量,会和句子 “a golden retriever playing on the grass” 生成的向量在空间上非常非常接近。

这个对齐过程同样适用于音频。通过训练 (音频, 文本描述) 配对数据,音频编码器也能学会将“狗叫声”的音频片段映射到和文字“dog barking”相近的空间位置。

【AI】端侧模型部署LiteRT篇

【AI】端侧模型部署LiteRT篇

本文介绍了借助Google的LiteRT框架在Android平台上运行端侧AI模型的流程

LiteRT简介

TensorFlow Lite 是 TensorFlow 生态系统中的一个重要组成部分,它是专门为在资源受限的设备(如手机、物联网设备、嵌入式系统和微控制器)上高效运行机器学习模型而设计的。你可以理解为,它是 TensorFlow 模型的“压缩和优化版本”的运行时环境,让 AI 能够 “走出云端,进入设备”

llama.cpp 一样,TensorFlow Lite也是通过 模型优化和压缩 来实现有限资源时高效运行的。

将模型的参数(如权重和激活值)从浮点数转换为更小的整数类型(如 8 位整数),大大减少模型大小和内存占用,同时提高推理速度。移除模型中不重要的连接和参数。将多个操作合并为一个,减少计算开销。TFLite 解释器本身非常小巧,可以在内存和存储空间有限的设备上运行。

TensorFlowLite在24年9月已经更名为LiteRT。 之所以更名,是因为 TensorFlow Lite 在发展过程中已经超越了最初仅支持 TensorFlow 模型的范畴。它现在能够高效支持从 PyTorch、JAX 和 Keras 等其他主流机器学习框架导出的模型。为了更好地体现这种“多框架”的愿景,并强调其作为设备端高性能运行时的通用性,Google 决定将其更名为 LiteRT。

LiteRT 的核心价值在于提供一个 快速小巧高效 的运行时环境,使训练好的机器学习模型能够在设备上本地执行推理。主要特性:

  • 针对设备端机器学习进行了优化:LiteRT 解决了五项关键的 ODML 约束条件:延迟时间(无需往返服务器)、隐私性(没有个人数据离开设备)、连接性(无需连接到互联网)、大小(缩减了模型和二进制文件大小)和功耗(高效推理和缺少网络连接)。
  • 支持多平台:与 Android 和 iOS 设备、嵌入式 Linux 和微控制器兼容。
  • 多框架模型选项:AI Edge 提供了一些工具,可将 TensorFlow、PyTorch 和 JAX 模型转换为 FlatBuffers 格式 (.tflite),让您能够在 LiteRT 上使用各种先进的模型。您还可以使用可处理量化和元数据的模型优化工具。
  • 支持多种语言:包括适用于 Java/Kotlin、Swift、Objective-C、C++ 和 Python 的 SDK。
  • 高性能:通过 GPU 和 iOS Core ML 等专用代理实现硬件加速。

LiteRT 将模型打包成一种名为 FlatBuffers 的高效可移植格式,文件扩展名为 .tflite

LiteRT 的部署运行流程

1. 模型加载

第一步,将 .tflite 文件(包含模型的执行图、权重和偏差)加载到内存中。

LiteRT 模型采用 FlatBuffers 格式,这种格式允许直接映射到内存,避免了传统序列化/反序列化所需的额外解析和内存复制,从而加快加载速度。

2. 构建解释器 (Interpreter)

LiteRT Interpreter 解释器是执行模型的核心组件。它使用静态图排序自定义(非动态)内存分配器

在内存分配方面, Interpreter 在运行时根据模型图预先分配好所需的张量内存,避免了推理过程中的动态内存分配开销,确保推理延迟稳定且较低。

3. 设置硬件加速器(Delegate/委派)

这一步是 LiteRT 实现高性能的关键。LiteRT 引入了 Delegate(委派) 机制,这是一个 API 接口,用于将模型的部分或全部操作卸载到设备上的特定 硬件加速器 上执行,而不是仅仅依赖 CPU。

常见的加速器:

  • GPU:通过 MLDrift(新的 GPU 加速实现)或旧的 GPU Delegate 进行加速。
  • NPU/DSP:通过 NNAPI(Android 上的神经网络 API)或高通、联发科等供应商特定的 SDK 来利用神经处理单元。
  • Edge TPU:Google 专用的边缘张量处理单元。
  • XNNPack:一个高度优化的 CPU 浮点运算库。

Delegate 会检查模型中的哪些操作可以在加速器上运行,并将它们打包交给加速器执行。

4. 数据预处理与运行推理 (Invoke)

数据预处理截断会将输入数据(如图像、音频)转换为模型期望的格式和维度。通过调用 Interpreter::Invoke()(或 LiteRT Next 中的 CompiledModel::Run())方法,LiteRT 解释器执行模型图中的操作。如果设置了 Delegate,相应的操作将在硬件加速器上执行。

5. 解释输出

获取输出张量的值,并将其转换为对应用有意义的结果(例如,将概率列表映射到具体的类别标签,或绘制目标检测的边界框)。

支持的 AI 模型

LiteRT 主要用于推理(Inference),它可以运行各种类型的经过优化的机器学习模型,特别是那些针对视觉音频自然语言处理 (NLP) 任务的模型:

模型类型常见应用优化特点
计算机视觉 (CV)图像分类、目标检测、图像分割、姿态估计、面部识别。CNN (如 MobileNet, EfficientNet) 经过量化和剪枝,利用 GPU 和 NPU 加速。
自然语言处理 (NLP)文本分类、命名实体识别、问答系统、小规模 LLM (轻量级大语言模型) 推理。Transformer 模型(如 BERT 变体)经过优化,注重模型大小和低延迟。
音频处理语音识别、关键词唤醒、声纹识别、环境声分类。各种序列模型和特定设计的声学模型。
通用机器学习分类、回归、时间序列预测。各种通用的 ML 模型,通常通过 TensorFlowPyTorch 等框架训练后转换为 .tflite 格式。

应用层的集成,在 Google 自己的开源项目中有已经体现:

google-ai-edge gallery

该应用也同步上架了Play Store,项目截图:

进入首页可以看到主要有四种使用主题,分别是图片分析,音频描述,提示词试验,以及AI模型对话。选择其中一个主题进入之后,通过 Chrome 浏览器授权 Hugging face 账号,就可以在 Gallery 中直接下载模型到其英应用到内部存储中。下图是音频识别到效果,做语言翻译,物种识别效果还不错,歌曲识别准确率不高。

Image 1 Image 1 Image 2

整体占用和 llama.cpp 持平,主要区别就是分了两段加载,在刚进入对话 loadModel() 时,没有加载全部权重数据到内存,在推理真正调用再加载的。

简化方式一 MediaPipe Tasks

底层依然基于 LiteRT 的运行时来运行端侧AI模型,只是在应用层进行了封装,提供了更方便的接口。

简化方式二 AI Core 应用进行IPC通信

这个方法,便利性上较前两种方式更进了一步。Google 直接将Gemini Nano模型的下载,加载,推理都封装在 AI Core 中,应用层只需要调用AIDL接口和 AI Core 进行通信即可。

Google介绍:Gemini Nano 是我们专为设备端任务打造的最高效模型,它直接在移动芯片上运行,从而支持一系列重要用例。设备端运行支持数据无需离开设备的功能,例如在端到端加密消息应用中提供消息回复建议。它还能通过确定性延迟实现一致的体验,即使在没有网络的情况下也能始终使用各项功能。

图中的Lora是什么呢?

LoRA (Low-Rank Adaptation) 是一种用于微调(fine-tuning)大型预训练模型的技术,比如大型语言模型 (LLMs) 或图像生成模型 (如 Stable Diffusion)。

简单来说,LoRA 的核心思想是:在不修改原始大模型参数的情况下,通过向模型中注入少量可训练的层(或称为适配器)来适应新的任务或数据。

可以类比 Kotlin的扩展函数 来理解。

需要注意,目前测试版的 AI Core 只有 Pixel 9 及以上的设备支持。

使用AICore来和Genimi Nano模型进行通信的步骤非常简单。首先加入gradle依赖:

implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp01")

注意最低SDK需要31及以上。

在此仅做最小功能验证,直接在Composable组合项中观察一个顶层变量 aiCoreOutput 这个 StateFlow 的状态变化,在顶层方法中出发通信逻辑,结果会更新到 aiCoreOutput 中。

val aiCoreOutput = MutableStateFlow("")

@SuppressLint("StaticFieldLeak")
val generationConfig = generationConfig {
    context = appContext
    temperature = 0.2f
    topK = 16
    maxOutputTokens = 256
}

val downloadCallback = object : DownloadCallback {
    override fun onDownloadProgress(totalBytesDownloaded: Long) {
        super.onDownloadProgress(totalBytesDownloaded)
        println("Download progress: $totalBytesDownloaded")
    }

    override fun onDownloadCompleted() {
        super.onDownloadCompleted()
        println("Download completed")
    }
}

val downloadConfig = DownloadConfig(downloadCallback)
val generativeModel = GenerativeModel(
    generationConfig = generationConfig,
    downloadConfig = downloadConfig
)

suspend fun startChat(input: String) {
    runCatching {
        val response = generativeModel.generateContent(input)
        print(response.text)
        aiCoreOutput.value = response.text.toString()
    }.onFailure { e ->
        e.printStackTrace()
    }
}

fun closeChatResponse() {
    println("Closing chat response")
    generativeModel.close()
}

Android 平台使用 LiteRT

在 Android 上,可以使用 Java 或 C++ API 执行 LiteRT 推理。通过 Java API 提供了便利,可以直接在 Android 应用中使用 activity 类。C++ API 提供了更高的灵活性和速度,但可能需要 编写 JNI 封装容器以在 Java 层和 C++ 层之间移动数据。

运行架构解析

LiteRT 模型需要特殊的运行时环境才能执行,并且传入模型的数据必须采用特定的数据格式(称为张量)。当模型处理数据(称为运行推理)时,它会将预测结果生成为新的张量,并将其传递给 Android 应用,以便应用执行操作,例如向用户显示结果或执行其他业务逻辑。

在功能设计层面,您的 Android 应用需要以下元素才能运行 LiteRT 模型:

  • 用于执行模型的 LiteRT 运行时环境
  • 模型输入处理程序,用于将数据转换为张量
  • 模型输出处理脚本,用于接收输出结果张量并将其解读为预测结果

Google Play 服务的运行时

使用 Java API 访问 Google Play 服务中的 LiteRT。具体而言,Google Play 服务中的 LiteRT 可通过 LiteRT 解释器 API 来使用。

使用 Interpreter API

TensorFlow 运行时提供的 LiteRT 解释器 API 提供了一个用于构建和运行机器学习模型的通用接口。按照以下步骤操作,即可使用 Google Play 服务中的 TensorFlow Lite 运行时通过 Interpreter API 运行推理。

1. 添加项目依赖项

注意: Google Play 服务中的 LiteRT 使用 play-services-tflite 软件包。 将以下依赖项添加到您的应用项目代码中,以访问 LiteRT 的 Play 服务 API:

dependencies {
...
    // LiteRT dependencies for Google Play services
    implementation 'com.google.android.gms:play-services-tflite-java:16.1.0'
    // Optional: include LiteRT Support Library
    implementation 'com.google.android.gms:play-services-tflite-support:16.1.0'
...
}

2. 添加了 LiteRT 的初始化

在之前使用 LiteRT API 时,初始化 Google Play 服务 API 的 LiteRT 组件:

val initializeTask: Task<Void> by lazy { TfLite.initialize(this) }

注意: 请确保 TfLite.initialize 任务在执行访问 LiteRT API 的代码之前完成。使用 addOnSuccessListener() 方法,如下一部分所示。

3. 创建解释器并设置运行时选项

使用 InterpreterApi.create() 创建解释器,并通过调用 InterpreterApi.Options.setRuntime() 将其配置为使用 Google Play 服务运行时 ,如以下示例代码所示:

import org.tensorflow.lite.InterpreterApi
import org.tensorflow.lite.InterpreterApi.Options.TfLiteRuntime
...
private lateinit var interpreter: InterpreterApi
...
initializeTask.addOnSuccessListener {
  val interpreterOption =
    InterpreterApi.Options().setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  interpreter = InterpreterApi.create(
    modelBuffer,
    interpreterOption
  )}
  .addOnFailureListener { e ->
    Log.e("Interpreter", "Cannot initialize interpreter", e)
  }

您应使用上述实现,因为它可以避免阻塞 Android 界面线程。如果您需要更密切地管理线程执行,可以向解释器创建添加 Tasks.await() 调用:

import androidx.lifecycle.lifecycleScope
...
lifecycleScope.launchWhenStarted { // uses coroutine
  initializeTask.await()
}

警告: 请勿在前景界面线程上调用 .await(),因为这会中断界面元素的显示,从而导致用户体验不佳。

4. 运行推理

使用您创建的 interpreter 对象,调用 run() 方法以生成推理结果。

interpreter.run(inputBuffer, outputBuffer)

硬件加速

借助 LiteRT,您可以使用专用硬件处理器(例如图形处理单元 GPU)来提升模型的性能。您可以使用称为“委托”的硬件驱动程序来利用这些专用处理器。

GPU 委托通过 Google Play 服务提供,并且会动态加载,就像 Interpreter API 的 Play 服务版本一样。

检查设备兼容性

并非所有设备都支持使用 TFLite 进行 GPU 硬件加速。为了减少错误和潜在的崩溃,请使用 TfLiteGpu.isGpuDelegateAvailable 方法检查设备是否与 GPU 委托兼容。

使用此方法可确认设备是否与 GPU 兼容,并在不支持 GPU 时使用 CPU 作为后备。

useGpuTask = TfLiteGpu.isGpuDelegateAvailable(context)

获得 useGpuTask 等变量后,您可以使用它来确定设备是否使用 GPU 委托。

val interpreterTask = useGpuTask.continueWith { task ->
  val interpreterOptions = InterpreterApi.Options()
      .setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  if (task.result) {
      interpreterOptions.addDelegateFactory(GpuDelegateFactory())
  }
  InterpreterApi.create(FileUtil.loadMappedFile(context, MODEL_PATH), interpreterOptions)
}
使用 Interpreter API 的 GPU

如需将 GPU 委托与 Interpreter API 搭配使用,请执行以下操作:

更新项目依赖项以使用 Play 服务中的 GPU 委托:

implementation 'com.google.android.gms:play-services-tflite-gpu:16.2.0'

在 TFlite 初始化中启用 GPU 委托选项:

TfLite.initialize(context,
  TfLiteInitializationOptions.builder()
    .setEnableGpuDelegateSupport(true)
    .build())

在解释器选项中启用 GPU 代理:通过调用 InterpreterApi.Options() 中的 addDelegateFactory() 将代理工厂设置为 GpuDelegateFactory:

val interpreterOption = InterpreterApi.Options()
  .setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  .addDelegateFactory(GpuDelegateFactory())

独立的 LiteRT 运行时

通常,您应使用 Google Play 服务提供的运行时环境,因为它比标准环境更节省空间,因为它会动态加载,从而缩减应用大小。Google Play 服务还会自动使用最新的稳定版 LiteRT 运行时,随着时间的推移,为您提供更多功能并提升性能。如果您 在未包含 Google Play 服务的设备上提供应用,或者需要密切管理 ML 运行时环境,则应使用标准 LiteRT 运行时 。此选项会将额外的代码捆绑到您的应用中,让您可以更好地控制应用中的机器学习运行时,但代价是增加应用的下载大小。

您可以通过将 LiteRT 开发库添加到应用开发环境,在 Android 应用中访问这些运行时环境。

步骤一:添加 LiteRT 核心依赖

应用级 build.gradle 文件(通常是 app/build.gradle)中,添加 LiteRT 核心库的依赖项。

dependencies {
    // 1. LiteRT 核心库(Standalone/Bundled 运行时)
    implementation 'org.tensorflow:tensorflow-lite:LITERT_VERSION'

    // 2. 推荐:用于模型元数据和实用程序的库
    implementation 'org.tensorflow:tensorflow-lite-support:LITERT_VERSION'

    // 3. 可选:如果您需要特定的硬件加速(Delegate),请添加对应的独立依赖。
    // 例如,使用 GPU Delegate:
    implementation 'org.tensorflow:tensorflow-lite-gpu:LITERT_VERSION'

    // ... 其他依赖项
}

注意: 应将 LITERT_VERSION 替换为当前的稳定版本号。这些库就是以前的 org.tensorflow:tensorflow-lite 系列,是独立于 Google Play 服务的。

步骤二:将 LiteRT 模型文件添加到项目

将的 .tflite 模型文件放置在 Android 项目的 assets 文件夹中:

  1. app/src/main/ 目录下创建或找到 assets 文件夹。
  2. 将您的 model_name.tflite 文件复制到此文件夹中。

步骤三:在 Kotlin/Java 中加载和运行模型

使用 LiteRT 的 Interpreter 类来加载模型并执行推理。

import org.tensorflow.lite.Interpreter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.io.FileInputStream
import java.nio.channels.FileChannel

// ... 您的 Activity 或 Fragment ...

fun runInference() {
    // 1. 配置 LiteRT 解释器选项
    val options = Interpreter.Options()
    
    // 2. [可选] 如果添加了 GPU 依赖,可以设置 GPU Delegate
    // **注意:** 独立的 GPU Delegate 在某些设备上可能不如 Play Services 托管的稳定。
    // val gpuDelegate = GpuDelegate()
    // options.addDelegate(gpuDelegate)

    // 3. 创建解释器实例
    val interpreter: Interpreter
    try {
        interpreter = Interpreter(loadModelFile(), options)
    } catch (e: Exception) {
        // 处理加载模型时的错误
        e.printStackTrace()
        return
    }

    // --- 假设输入和输出张量大小 ---
    // 示例:输入形状 [1, 224, 224, 3] (Float32)
    val inputShape = interpreter.getInputTensor(0).shape() 
    val outputShape = interpreter.getOutputTensor(0).shape()
    
    // 创建输入和输出缓冲区
    val inputBuffer = ByteBuffer.allocateDirect(
        inputShape[0] * inputShape[1] * inputShape[2] * inputShape[3] * 4 // 4 bytes for float
    ).apply { order(ByteOrder.nativeOrder()) }
    
    val outputBuffer = ByteBuffer.allocateDirect(
        outputShape[0] * outputShape[1] * 4 // 4 bytes for float
    ).apply { order(ByteOrder.nativeOrder()) }
    
    // [TODO] 准备您的输入数据,并将其写入 inputBuffer
    // 例如:将您的图像数据转换为 float 数组并写入 inputBuffer
    
    // 4. 运行模型推理
    interpreter.run(inputBuffer, outputBuffer)
    
    // 5. [TODO] 处理 outputBuffer 中的结果
    
    // 6. 清理资源
    // interpreter.close()
    // gpuDelegate.close() // 如果使用了 Delegate
}

/**
 * 从 assets 文件夹加载 .tflite 模型文件
 */
private fun loadModelFile(): ByteBuffer {
    val fileDescriptor = assets.openFd("model_name.tflite")
    val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
    val fileChannel = inputStream.channel
    val startOffset = fileDescriptor.startOffset
    val declaredLength = fileDescriptor.declaredLength
    
    return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
}

步骤四:清理和资源管理

不再需要解释器时,务必调用 interpreter.close() 来释放本地资源,这对于防止内存泄漏非常重要。如果使用了硬件加速委托(如 GPU Delegate),也要调用其 close() 方法。

// 确保在 Activity/Fragment 生命周期结束时或不再需要时关闭
override fun onDestroy() {
    interpreter.close()
    // gpuDelegate?.close()
    super.onDestroy()
}

通过这些步骤,Android 应用将使用独立的 LiteRT 运行时进行机器学习推理,完全不依赖 Google Play 服务。

自己构建 LiteRT

某些高级自定义操作可能需要自己构建LiteRT的整个依赖库,直接参考官方指南:

Build LiteRT for Android

【AI】端侧模型部署llama.cpp篇

【AI】端侧模型部署llama.cpp篇

本文介绍了借助llama.cpp开源框架运行Android平台上的AI小模型的流程

接上文:

【AI】大模型开发流程和运行环境简介

llama.cpp

作为一名 Android 开发者,llama.cpp 打开了在移动应用中实现端侧 AI 的全新大门。

llama.cpp 是一个由 Georgi Gerganov 开发的开源项目,其核心是用 C/C++ 编写的。它的主要目标是让大型语言模型 (LLMs) 能够在各种硬件上高效地运行推理,尤其是在本地设备上,包括那些没有高端独立 GPU 的普通电脑或移动设备。该项目的愿景是 “民主化”LLMs 的部署 ,让每个人都能在自己的设备上体验和使用这些强大的模型,而不仅仅依赖于昂贵的云服务。

对于Android平台,我们可以通过 Android NDKllama.cpp 的 C/C++ 代码编译为 .so 库,并在您的 Java/Kotlin 代码中通过 JNI 调用它。llama.cpp 让您可以在 Android 应用中直接拥有一个“本地的智能大脑”,从而创造出前所未有的智能应用体验。

llama.cpp 的设计理念是追求极致的效率、最小化外部依赖、广泛的硬件兼容性以及高度的灵活性

llama.cpp 并非一个虚拟机,而是一个高效的、C/C++ 实现的 LLM 推理引擎。它通过模型量化、GGUF 格式、底层硬件优化、多平台支持以及灵活的 API 接口,极大地降低了在个人电脑和边缘设备上运行大型语言模型的门槛。

模型量化

之前已经总结过,权重等参数是如何存储于大模型中的:

【AI】LLM中张量的计算

量化 是一种技术,可以将模型中通常以 32 位浮点数(FP32)或 16 位浮点数(FP16)存储的权重和激活值,转换为更低精度的格式,如 8 位整数(INT8)、4 位整数(INT4),甚至更低。降低了存储和传输模型的成本。使得更大的模型能够加载到有限的 RAMVRAM 中。

模型的量化(Model Quantization)是一个在机器学习,尤其是深度学习领域中非常重要的概念,其核心目的是让模型变得更小、更快、更节能

简单来说,模型的量化就是降低模型中数据的精度

一个 FP32 的数字需要 4 字节(32位)。一个有数十亿参数的模型需要巨大的内存,计算开销相当高,浮点数计算比整数计算更耗时、耗电。

量化是如何工作的?

量化 就是将这些高精度的浮点数,转换成低精度的整数或更小的浮点数,比如:

  • 8 位整数 (INT8)
  • 4 位整数 (INT4 或 Q4_K)
  • 16 位浮点数 (FP16 或 BF16)

假设你有一个模型的权重值是 0.785。在 FP32 中,它需要 4 个字节来存储。通过量化,它可以被映射到一个 8 位整数,比如 200,同时有一个缩放因子(Scale)和零点(Zero Point)来帮助在推理时近似还原原来的浮点数。

量化带来的好处

优势描述
模型尺寸减小将 FP32(4 字节)量化到 INT4(0.5 字节),理论上模型大小可以缩小8倍。这使得模型可以部署到内存有限的设备上。
推理速度加快低精度的数据在特定的硬件(如 CPU、移动端 NPU)上可以更快地计算,因为它减少了内存访问,并允许使用更高效的整数运算单元。
能耗降低更少的计算和更小的内存访问量,意味着模型在运行时消耗的电量更少,这对于移动设备和边缘计算非常重要。

量化类型分为两种:

  • 训练后量化 (Post-Training Quantization, PTQ),这是最常见的量化方式。模型在 训练完成后 ,才进行量化转换。这种方式不需要重新训练模型,实现简单,成本低。对精度损失不太敏感,或希望快速部署模型的场景。
  • 量化感知训练 (Quantization-Aware Training, QAT),这是在 训练过程中 就模拟量化后的低精度运算。由于模型在训练时就“知道”自己会被量化,因此可以更好地调整参数以 最小化精度损失 ,通常能获得比 PTQ 更好的性能。适合对精度要求较高,且愿意投入额外训练时间的场景。

在移动设备(如 Android 手机)上部署深度学习模型时, INT8量化 是常用的优化手段,因为移动设备的内存和计算资源都相对有限。

llama.cpp 框架特点

轻量与高效的 C/C++ 实现

llama.cpp 完全使用 C 和 C++ 编写,不依赖于大型的深度学习框架(如 TensorFlow 或 PyTorch)的运行时库。这意味着它编译出来的程序 体积非常小,运行时内存占用也低 。这对于资源受限的移动设备来说至关重要。

除了底层的 BLAS (基本线性代数子程序库) 或特定硬件的计算库(如 CUDA、Metal), llama.cpp 几乎没有其他复杂的外部依赖。这使得它非常容易编译和部署。

llama.cpp 对 CPU 进行了大量底层优化,利用了各种 CPU 指令集(如 x86-64 上的 AVX/AVX2/AVX-512,以及 ARM 上的 Neon)。这让 LLMs 可以在没有独立 GPU 的设备上,仅仅依靠 CPU 也能获得令人惊讶的推理速度。

广泛的硬件加速支持

除了强大的 CPU 优化,llama.cpp 还支持多种 GPU 和专用硬件加速,这意味着它是跨平台的,跨平台支持在任何行业都备受推崇,无论是游戏、人工智能还是其他类型的软件。赋予开发者在他们想要的系统和环境中运行软件所需的自由永远不是一件坏事。

  • Apple Silicon (Metal):针对苹果 M 系列芯片的 Metal API 进行了优化,充分利用了其强大的统一内存架构和神经网络引擎。
  • NVIDIA GPU (CUDA):支持 NVIDIA 显卡,通过 CUDA 加速推理,性能非常出色。
  • AMD GPU (hipBLAS):兼容 AMD GPU。
  • Intel GPU (SYCL/oneAPI):支持英特尔的集成和独立显卡。
  • 通用 GPU 后端 (Vulkan/OpenCL):这对于 Android 开发者尤为重要。通过 OpenCL 后端,llama.cpp 能够利用 Android 设备中 SoC(System on Chip)内置的 GPU 或 DSP(数字信号处理器,如高通骁龙的 Hexagon DSP)进行加速,将推理负载从 CPU 转移到更擅长并行计算的硬件上。
  • CPU + GPU 混合推理:对于一些较大的模型,即使单张 GPU 的显存不足以容纳整个模型,llama.cpp 也能将模型的某些层加载到 GPU 上运行,其余部分则在 CPU 上运行,实现资源的有效利用。

GGUF 文件格式

GGML

GGML 是一种为机器学习设计的文件格式和张量库,它最初由开发者 Georgi Gerganov 创建(GGML = Gerganov’s General Machine Learning)。它的核心目标是高效地在 CPU 上运行大型机器学习模型,特别是大型语言模型(LLMs),并且支持各种硬件平台。

GGML 的出现,是将 LLMs 带到消费级硬件上的关键一步。在它之前,运行大型模型通常需要昂贵的 GPU 和复杂的配置。GGML 通过一系列创新技术,让这些模型能够在普通的个人电脑上也能跑起来。

GGML 的核心是用 C 语言编写的,这意味着它的 执行效率非常高 ,并且依赖性极少。这使得它非常轻量级,可以在各种不同的操作系统和硬件上轻松编译和运行,包括 macOS、Linux、Windows,甚至 iOS 和 Android。

GGML 最初的重点在于在 CPU 上实现高效推理。它利用了现代 CPU 的特性,例如 SIMD(单指令多数据)指令集 ,比如 Intel 的 AVX、AVX2、AVX512 和 ARM 的 NEON,这些指令允许 CPU 同时处理多个数据点,从而加速矩阵乘法等并行计算。可以利用 CPU 的多个核心并行执行计算任务。

GGUF格式的出现

GGUF (GPT-Generated Unified Format) 就是 GGML 文件格式的最新演进版本。GGUF 在 GGML 的基础上,提供了更强的灵活性、更好的向后兼容性,并能包含更多的元数据(例如分词器信息、提示模板等),使其成为目前社区首选的本地 LLM 格式。

可以把它想象成一个 包含了模型“大脑”里所有知识的“盒子” ,这个盒子设计得非常紧凑和高效,便于在各种设备上快速打开和使用。

GGUF 格式支持从全精度 (FP32) 到半精度 (FP16) 以及多种低精度(如 INT8、INT5、INT4、INT2)的模型量化。量化可以在牺牲极小精度损失的情况下,大幅减小模型体积,降低内存占用和计算需求。这对于在手机上运行大型模型至关重要,能让原本无法加载的模型变得可用。

GGUF 文件不仅包含模型权重,还打包了模型的词表、超参数、架构信息和特殊 token ID 等所有运行所需的元数据。一个 GGUF 文件就是一个独立的、可运行的模型包。

此外,GGUF 支持内存映射。这意味着操作系统可以直接将文件内容映射到内存中,而无需将整个模型完全复制到 RAM。这大大加快了模型加载速度,并允许在物理内存不足的情况下也能运行大模型(通过操作系统的虚拟内存管理)。

模型文件命名约定

GGUF 遵循命名约定, <BaseName><SizeLabel><FineTune><Version><Encoding><Type><Shard>.gguf 每个组件之间用 分隔(-如果存在)。这样做的最终目的是让人们能够一目了然地了解模型的最重要细节。

这些组件包括:

  • BaseName:模型基础类型或架构的描述性名称。
  • SizeLabel:参数权重类(对排行榜有用)表示为<expertCount>x<count><scale-prefix>
  • FineTune:模型微调目标的描述性名称(例如聊天、指导等…)
  • 版本:(可选)表示模型版本号,格式为v<Major>.<Minor>
  • 编码:表示应用于模型的权重编码方案。内容、类型组合和排列由用户代码决定,并可能根据项目需求而变化。
  • 类型:表示 gguf 文件的类型及其预期用途
  • Shard:(可选)表示并表明模型已被拆分为多个分片,格式为<ShardNum>-of-<ShardTotal>

例如:Mixtral-8x7B-v0.1-KQ2.gguf

型号名称:Mixtral
专家数量:8
参数数量:7B
版本号:v0.1
权重编码方案:KQ2

GGUF 文件的结构

一个 GGUF 文件大致可以分为两个主要部分:

  1. 头部 (Header):包含 GGUF 文件的魔数(用于识别文件类型)、版本号以及一些全局元数据(如模型总层数、维度等)。
  2. 元数据 (Metadata) 和张量 (Tensors)
    • KV 对 (Key-Value Pairs):存储了模型的各种超参数、架构信息、词表和一些其他配置。这些都是以键值对的形式存储的,方便访问。
    • 张量数据 (Tensor Data):这是模型真正的权重数据。每个张量都会有其名称、维度和数据类型(例如,量化后的 INT4、INT8 或 FP16 等)。这些数据会按照特定的对齐方式存储,以确保高效读取。

运行原理简介

详细运行流程见原文:

【AI】Understanding how LLM inference works with llama.cpp

即使没有高性能的独立 GPU 的设备也可以运行大模型,但这通常会有一些重要的限制和权衡

CPU 也能执行并行计算 ,现代 CPU 通常有多个核心,并且支持 SIMD (Single Instruction, Multiple Data) 指令集(如 Intel 的 AVX、SSE 指令集),这使得它们能够同时处理少量数据。例如机器学习框架(如 TensorFlow 或 PyTorch),在设备没有GPU时,会自动回退到使用 CPU 来执行所有的矩阵乘法和其他计算。这些框架的 CPU 版本也会进行高度优化,以尽可能利用 CPU 的并行能力。

传统的 CPU 指令(称为 标量指令 或 SISD - Single Instruction, Single Data)一次只能对一个数据对(例如,两个整数或浮点数)进行操作。相比之下,SIMD 指令将多个数据项打包到一个特殊的 宽寄存器 中,然后一次性对所有这些数据项执行相同的操作。向量寄存器是 SIMD 的关键。它们比普通的通用寄存器宽得多。常见的宽度有 128位、256位,以及最新的 512位。假设有一个 256 位的寄存器和 32 位的整数。您可以将 256/32=8 个整数打包到这个寄存器中。当 CPU 执行一个 SIMD 加法指令时,它会在一个时钟周期内同时对这 8 个整数执行加法操作。这就像将一条生产线变成了八条。

相比于大模型的训练,推理阶段的计算量相对较小 ,训练大模型需要极其强大的 GPU,因为它涉及数万亿次的参数更新,需要多次迭代和反向传播。而在 推理(Inference) 阶段,即模型用于实际预测时,只需要进行前向传播。虽然计算量依然庞大,但比训练时少得多。对于量化后的模型,推理的计算需求会进一步降低。

本质上,llama.cpp 加载数据,构建计算图并进行计算。主要关注点也是在于使用高效的 SIMD 指令在 CPU 上运行——这内置于库的核心实现中(ggml.c)。后端(ggml-cuda、ggml-metal等)用于在 GPU 加速器上计算图。

因此,它是一个通用 API,可以更轻松地在项目中运行 gguf 模型。如果有非常具体的需求或用例,也可以直接在其基础上构建 gguf,或者通过删除不必要的内容来创建一个精简版本 llama.cpp

它使用 llama_init_from_file 函数从 gguf 文件初始化一个 llama 上下文。此函数读取 gguf 文件的头文件和正文,并创建一个 llama 上下文对象,该对象包含模型信息和运行模型的后端(CPU、GPU 或 Metal)。

再使用 llama_tokenize 函数对输入文本进行标记。此函数根据 gguf 文件头中指定的标记器将输入文本转换为标记序列。这些标记存储在一个 llama 标记数组中,llama 标记是表示标记 ID 的整数。

在执行推理生成时,通过 llama_generate 函数生成输出文本。此函数将输入标记和 llama 上下文作为参数,并在后端运行模型。它使用 gguf 文件头中指定的计算图执行模型的前向传递并计算下一个标记的概率。然后,它从概率分布中采样下一个标记并将其附加到输出标记中。它会重复此过程,直到文本结束标记或达到最大标记数。输出标记存储在另一个 llama 标记数组中。

最后通过 llama_detokenize 函数对输出文本进行去标记化。该函数根据 gguf 文件头中指定的标记器将输出标记转换为文本字符串。它会处理特殊标记,例如文本结束标记、填充标记和未知标记,并返回最终的输出文本。

部署运行实操

接下来介绍下如何在项目中集成llama.cpp,从而加载gguf格式的模型,运行本地的小模型。

一、使用Termux命令行编译运行

这种方法就是将 Android 设备当作 Linux 设备来使用,手机需要安装Termux。

可以在Github Releases 选择 termux-app_v0.118.2+github-debug_arm64-v8a.apk 下载,并且安装到手机。

Releases · termux/termux-app

下载 llama.cpp 库:

# 切换国内源
termux-change-repo
apt list --upgradable
# 安装依赖工具
pkg install -y cmake git build-essential
# 下载 llama.cpp
git clone https://github.com/ggml-org/llama.cpp.git
# 如果git下不下来,通过scp拷贝进去
scp -P 8022 .\llama.cpp-master.zip u0_a456@192.168.31.44:~

编译llama.cpp源代码:

# 进入目录
cd llama.cpp
# 创建build文件,并且进入文件夹
mkdir build && cd build
# 生成编译配置,-DGGML_CUDA=OFF 关闭GPU
cmake .. -DGGML_CUDA=OFF 
# 4个线程编译
make -j4
# 编译完成目录在/bin
ls ~/llama.cpp/build/bin
# bin添加到环境变量中
echo 'export PATH=$PATH:~/llama.cpp/build/bin/' >> ~/.bashrc
source ~/.bashrc

直接从 Hugging Face 下载 .gguf 文件,避免转换步骤。我下载的是 DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf 。网络问题,推荐在电脑端下载完毕,通过USB使用adb或者文件模式,推送到手机端。

注意Termux默认是无法操作手机文件系统的,需要执行命令来获取权限,初始化文件管理系统。

termux-setup-storage

出现管理所有文件的权限授予弹窗,打开之后将文件复制到内部目录:

可以先试试运行效果,使用 llama-cli 直接在命令行中启动:

llama-cli -m DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf

llama-cli,即 ​​CLI 模式​​(Command-Line Interface 模式)是指通过命令行直接运行模型进行推理(文本生成)的方式,而不是通过 API 或图形界面。这是 llama.cpp 最基础的使用方式,适合本地测试、脚本调用或服务器部署。

运行效果如下:

也可以使用 llama-server 的方式启动:

llama-server -m DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf --port 8080 --host 0.0.0.0

手机端client直接访问

通过 llama-server 作为服务器启动之后,我们可以直接在手机端编写client,通过http请求来访问,直接和这个服务交互。这里大部分可以复用之前写的和deepseek官方api的请求逻辑。

网络Repository代码:

class DeepseekChatRepository(private val ktorClient: KtorClient) {

    companion object {
        const val BASE_URL =
            "https://api.deepseek.com"
        const val LOCAL_SERVER = "http://0.0.0.0:8080/v1"
        const val COMMON_SYSTEM_PROMT = "你是一个人工智能系统,可以根据用户的输入来返回生成式的回复"
        const val ENGLISH_SYSTEM_PROMT =
            "You are a English teacher, you can help me improve my English skills, please answer my questions in English."
        const val API_KEY = "xxxxxxxxxxxxxxxxx"
        const val MODEL_NAME = "deepseek-chat"
    }

    suspend fun localLLMChat(chat: String) = withContext(Dispatchers.IO) {
        ktorClient.client.post("${LOCAL_SERVER}/chat/completions") {
            // 配置请求头
            headers {
                append("Content-Type", "application/json")
            }
            setBody(
                DeepSeekRequestBean(
                    model = "DeepSeek-R1-Distill-Qwen-1.5B-Q2_K",
                    max_tokens = 256,
                    temperature = 0.7f,
                    stream = false,
                    messages = listOf(
                        RequestMessage(COMMON_SYSTEM_PROMT, ChatRole.SYSTEM.roleDescription),
                        RequestMessage(chat, ChatRole.USER.roleDescription)
                    )
                )
            )
        }.body<LocalModelResult>()
    }
}

界面上维护一个chatListState,里面是一个

data class AiChatUiState(
    val chatList: List<ChatItem> = listOf(),
    val listSize: Int = chatList.size
) {
    fun toUiState() = AiChatUiState(chatList = chatList, listSize = listSize)
}

data class ChatItem(
    val content: String,
    val role: ChatRole,
)

界面观察这个State响应式刷新即可。

运行结果:

局域网内其他设备访问

除了同一设备直接访问本地服务,在同一个局域网中,比如电脑端,我们也可以使用Python,通过 openai 的Python开发套件,和手机端运行的服务进行通信:

import requests
import json
import time

API_URL = "http://192.168.31.44:8080/v1/chat/completions"

payload = {
    "model": "DeepSeek-R1-Distill-Qwen-1.5B-Q2_K",  # llama-server 中可随意写
    "messages": [
        {"role": "system", "content": "你是一个英语学习助手。"},
        {"role": "user", "content": "请用中文解释单词 ability 的含义,并给出一个英文例句。"}
    ],
    "temperature": 0.7,
    "max_tokens": 256,
    "stream": False
}

# 记录开始时间
start_time = time.time()

# 发送请求
response = requests.post(API_URL, headers={"Content-Type": "application/json"}, data=json.dumps(payload))

# 记录结束时间
end_time = time.time()

if response.ok:
    result = response.json()
    message = result['choices'][0]['message']['content']
    print("模型回复:\n", message)
    
    # 处理 token usage 和速度统计
    usage = result.get("usage", {})
    total_tokens = usage.get("total_tokens", "未知")
    elapsed = end_time - start_time
    
    print(f"\n总 tokens: {total_tokens}")
    print(f"耗时: {elapsed:.2f} 秒")
    if isinstance(total_tokens, int) and elapsed > 0:
        print(f"生成速度: {total_tokens / elapsed:.2f} tokens/秒")
else:
    print("请求失败,状态码:", response.status_code)
    print(response.text)

二、单进程集成方案

上面那种在Termux中运行模型的方式还是感觉比较麻烦,每次也需要手动开启服务。

下面这种方案就是比较符合 Android 设备上运行的直观预期,通过一个APP页面来承载功能,在一个应用中,以用户友好的 UX交互 来和本地模型进行通信。使用JNI开发接口和llama.cpp交互。

底层依然是使用 llama.cpp 加载和执行 GGUF 模型。由于 llama.cpp 是用纯 C/C++ 编写的,因此很容易在apk编译阶段利用 AndroidStudio 的NDK工具,打包为 .so 动态库,在端侧运行。

GGUF文件读取

首先,定义JNI函数,第一步需要加载 gguf 文件。在 Android 应用中,需要使用 Kotlin 语言来定义页面需要用到的接口,再到 Native 层使用 llama.cpp 的能力,来编写 C++ 的桥接代码。

class GGUFReader {
    companion object {
        init {
            System.loadLibrary("ggufreader")
        }
    }

    private var nativeHandle: Long = 0L

    suspend fun load(modelPath: String) =
        withContext(Dispatchers.IO) {
            nativeHandle = getGGUFContextNativeHandle(modelPath)
        }

    fun getContextSize(): Long? {
        assert(nativeHandle != 0L) { "Use GGUFReader.load() to initialize the reader" }
        val contextSize = getContextSize(nativeHandle)
        return if (contextSize == -1L) {
            null
        } else {
            contextSize
        }
    }

    fun getChatTemplate(): String? {
        assert(nativeHandle != 0L) { "Use GGUFReader.load() to initialize the reader" }
        val chatTemplate = getChatTemplate(nativeHandle)
        return chatTemplate.ifEmpty {
            null
        }
    }

    private external fun getGGUFContextNativeHandle(modelPath: String): Long

    private external fun getContextSize(nativeHandle: Long): Long

    private external fun getChatTemplate(nativeHandle: Long): String
}

nativeHandle 是一个长整型(Long)变量,代表指向本地(C/C++)端创建的 gguf_context 的指针。在 Native 代码里,gguf_context 是一个上下文对象,负责管理 GGUF 文件的读取操作。nativeHandle 唯一标识这个上下文对象,方便 Kotlin 代码引用。借助 nativeHandle 能把本地对象的地址传递给 Kotlin 代码,进而在 Kotlin 代码里调用本地函数操作这些对象。

定义的三个JNI方法作用分别如下:

  • getGGUFContextNativeHandle() : 加载模型文件,返回模型上下文的指针。
  • getContextSize() : 获取模型上下文的大小,即模型参数的数量。
  • getChatTemplate() : 获取模型的聊天模板,用于生成聊天对话的提示。

在Native层的代码中,实现也非常简单,引入 llama.cpp 中的 gguf.h 头文件。

在获取上下文指针的方法中,传入模型文件的绝对地址字符串,调用 gguf_init_from_file ,即可获取到 gguf_context 对象指针,转换回 jlong 类型传递给Kotlin即可。

第二,在获取模型参数数量的方法中,需要先从 gguf_context 中找到 architecture 字段,再根据 architecture 字段的值,拼接出 context_length 字段的名称,最后调用 gguf_get_val_u32 方法获取参数数量。

第三个方法是获取模型的聊天模板,需要先找到分词器 tokenizer.chat_template 字段,调用 gguf_get_val_str 方法获取字符串值。

#include "gguf.h"
#include <jni.h>
#include <string>

extern "C" JNIEXPORT jlong JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getGGUFContextNativeHandle(JNIEnv *env, jobject thiz,
                                                                          jstring modelPath) {
    jboolean isCopy = true;
    const char *modelPathCStr = env->GetStringUTFChars(modelPath, &isCopy);
    // 初始化 GGUF 上下文所需的参数,不分配额外内存,上下文指针初始化为 nullptr
    gguf_init_params initParams = {.no_alloc = true, .ctx = nullptr};
    // 根据模型文件路径和初始化参数创建 GGUF 上下文
    gguf_context *ggufContext = gguf_init_from_file(modelPathCStr, initParams);
    env->ReleaseStringUTFChars(modelPath, modelPathCStr);
    return reinterpret_cast<jlong>(ggufContext);
}

extern "C" JNIEXPORT jlong JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getContextSize(JNIEnv *env, jobject thiz,
                                                              jlong nativeHandle) {
    gguf_context *ggufContext = reinterpret_cast<gguf_context *>(nativeHandle);
    // 查找模型架构信息对应的键 ID
    int64_t architectureKeyId = gguf_find_key(ggufContext, "general.architecture");
    // 若未找到架构信息键 ID,返回 -1
    if (architectureKeyId == -1)
        return -1;
    // 获取模型架构信息
    std::string architecture = gguf_get_val_str(ggufContext, architectureKeyId);
    // 构建上下文长度信息对应的键名
    std::string contextLengthKey = architecture + ".context_length";
    // 查找上下文长度信息对应的键 ID
    int64_t contextLengthKeyId = gguf_find_key(ggufContext, contextLengthKey.c_str());
    // 若未找到上下文长度信息键 ID,返回 -1
    if (contextLengthKeyId == -1)
        return -1;
    uint32_t contextLength = gguf_get_val_u32(ggufContext, contextLengthKeyId);
    return contextLength;
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getChatTemplate(JNIEnv *env, jobject thiz,
                                                               jlong nativeHandle) {
    gguf_context *ggufContext = reinterpret_cast<gguf_context *>(nativeHandle);
    // 查找聊天模板信息对应的键 ID
    int64_t chatTemplateKeyId = gguf_find_key(ggufContext, "tokenizer.chat_template");
    // 存储聊天模板的字符串
    std::string chatTemplate;
    // 若未找到聊天模板信息键 ID,将聊天模板设为空字符串
    if (chatTemplateKeyId == -1) {
        chatTemplate = "";
    } else {
        // 若找到聊天模板信息键 ID,获取聊天模板信息
        chatTemplate = gguf_get_val_str(ggufContext, chatTemplateKeyId);
    }
    return env->NewStringUTF(chatTemplate.c_str());
}

模型的加载与对话

gguf 文件成功读取和加载后,就可以运行LLM的推理功能了。

根据 llama.cpp 的几个核心的方法,如加载,对话等功能,来编写对接的接口 C++ 类。

关于第一步加载模型 load_1model ,官方例程的JNI接口编写如下:

extern "C"
JNIEXPORT jlong JNICALL
Java_android_llama_cpp_LLamaAndroid_load_1model(JNIEnv *env, jobject, jstring filename) {
    // 获取模型的默认参数
    llama_model_params model_params = llama_model_default_params();

    // 将 Java String 转换为 C 风格字符串
    auto path_to_model = env->GetStringUTFChars(filename, 0);
    LOGi("Loading model from %s", path_to_model);

    // 调用 llama.cpp 核心函数加载模型
    auto model = llama_model_load_from_file(path_to_model, model_params);
    // 释放 C 风格字符串,防止内存泄漏
    env->ReleaseStringUTFChars(filename, path_to_model);

    if (!model) {
        LOGe("load_model() failed");
        // 如果加载失败,抛出 Java 异常
        env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), "load_model() failed");
        return 0;
    }

    // 将 C++ 指针转换为 jlong 返回给 Java
    return reinterpret_cast<jlong>(model);
}

返回的是一个指向结构体 llama_model 的指针,这是 llama.cpp 库中最核心的结构体之一,它代表了加载到内存中的整个大语言模型 (LLM)。

这个 llama_model 结构体中包含了 模型的元数据、超参数、词汇表、所有权重张量以及硬件配置信息

struct llama_model {
    // 模型的类型(例如 LLaMA, Falcon, Mixtral 等)
    llm_type type = LLM_TYPE_UNKNOWN;
    // 模型的架构(llm_arch 是 llama.cpp 内部用于区分不同模型结构的枚举)
    llm_arch arch = LLM_ARCH_UNKNOWN;

    // 模型的名称或描述
    std::string name = "n/a";

    // 模型超参数:包含模型的固定配置,如层数、注意力头数、KV 缓存上下文长度等
    llama_hparams hparams = {};
    // 模型的词汇表(Vocabulary):包含 Token 列表及其与 ID 的映射关系
    llama_vocab   vocab;

    // 用于分类器模型(如 Sentiments Analysis)的标签列表
    std::vector<std::string> classifier_labels;

    // ggml_tensor* 是 ggml 库中的张量指针,存储模型的权重数据。
    // 这部分包含了不同模型架构共有的或用于输入处理的权重。

    // Token 嵌入层权重 (Token Embeddings)
    struct ggml_tensor * tok_embd   = nullptr;
    // Token 类型嵌入层权重(例如用于 BERT 风格模型区分句子 A 和 B)
    struct ggml_tensor * type_embd  = nullptr;
    // 位置嵌入层权重 (Positional Embeddings)
    
    ...

    // -------------------------------------------------------------------------
    // 3. 特殊张量 (Specialized Tensors)
    // -------------------------------------------------------------------------

    // **分类器张量 (Classifier Tensors)**
    struct ggml_tensor * cls       = nullptr; // 分类器权重
    struct ggml_tensor * cls_b     = nullptr; // 分类器偏置
    struct ggml_tensor * cls_out   = nullptr; // 分类器输出层权重
    struct ggml_tensor * cls_out_b = nullptr; // 分类器输出层偏置

    // **1D 卷积张量 (用于某些早期模型如 GPT-2 或特殊层)**
    struct ggml_tensor * conv1d   = nullptr;
    struct ggml_tensor * conv1d_b = nullptr;

    ...

    // 4. 配置与运行状态 (Configuration and Runtime State)

    ...

    // 创建上下文内存结构体(KV 缓存等)
    // note: can mutate `cparams`
    // TODO: move this to new llm_arch_model_i interface
    llama_memory_i * create_memory(const llama_memory_params & params, llama_cparams & cparams) const;

    // 构建 ggml 计算图:将模型计算逻辑转化为可执行的计算图
    // TODO: move this to new llm_arch_model_i interface
    ggml_cgraph * build_graph(const llm_graph_params & params) const;
};

llama_model_load_from_file 函数最终调用到了 llama_model_load_from_file_impl 函数,看看这里面做了哪些工作:

/**
 * @brief 核心实现函数:从文件加载 LLM 模型到 llama_model 结构体中。
 * @param path_model 模型文件的主要路径。
 * @param splits 如果模型被分割成多个文件,包含其余文件路径的向量。
 * @param params 模型加载参数,如设备选择、KV 缓存大小等。
 * @return 成功加载的 llama_model 指针,失败返回 nullptr。
 */
static struct llama_model * llama_model_load_from_file_impl(
        const std::string & path_model,
        std::vector<std::string> & splits,
        struct llama_model_params params) {
    
    // 初始化 ggml 库的计时器
    ggml_time_init();
    // 1. 后端检查 (Backend Check)
    // 如果不是只加载词汇表,并且没有注册任何计算后端,则报错。
    // 第二个条件是为了确保 llama.cpp 运行时环境中有可用的硬件(或软件)模块来执行模型的实际计算。
    if (!params.vocab_only && ggml_backend_reg_count() == 0) {
        LLAMA_LOG_ERROR("%s: no backends are loaded. hint: use ggml_backend_load() or ggml_backend_load_all() to load a backend before calling this function\n", __func__);
        return nullptr;
    }

    // 2. 进度回调设置 略

    // 在堆上创建 llama_model 实例,保存模型参数
    llama_model * model = new llama_model(params);

    // 3. 计算设备选择 (Device Selection)
    // 这一段决定了模型中的权重和计算将在哪些设备上(如 GPU、集成 GPU 或远程服务器)运行,而不是完全依赖 CPU。根据用户参数和系统环境,构建一个最优的计算设备列表(model->devices),用于模型权重和计算的分配。
    // 4. 单 GPU 模式调整 (Single GPU Mode Adjustment)
    // 如果是单设备模式 (LLAMA_SPLIT_MODE_NONE),则只保留主设备
    if (params.split_mode == LLAMA_SPLIT_MODE_NONE) {
        if (params.main_gpu < 0) {
            // main_gpu < 0 表示强制在 CPU 上运行
            model->devices.clear();
        } else {
            // 检查指定的 main_gpu 索引是否有效
            if (params.main_gpu >= (int)model->devices.size()) {
                LLAMA_LOG_ERROR("%s: invalid value for main_gpu: %d (available devices: %zu)\n", __func__, params.main_gpu, model->devices.size());
                llama_model_free(model);
                return nullptr;
            }
            // 仅保留指定的主 GPU
            ggml_backend_dev_t main_gpu = model->devices[params.main_gpu];
            model->devices.clear();
            model->devices.push_back(main_gpu);
        }
    }

    // 5. 实际模型加载 (Actual Model Loading)
    // 调用 llama.cpp 库的底层函数来执行实际的文件读取和权重加载
    const int status = llama_model_load(path_model, splits, *model, params);
    GGML_ASSERT(status <= 0); // 确认 status <= 0 (成功或取消/失败)
    
    // 检查加载状态,如果加载失败,释放已分配的 llama_model 内存
    // 并返回空指针
        // llama_model_free(model);
        // return nullptr;
    
    // 6. 返回load的结果
    return model;
}

可以看到, llama_model_load_from_file_impl 方法是确定运行的设备环境是否符合要求,除CPU之外,是否有GPU和远程设备可以使用。

实际的模型初始化加载函数为 llama_model_load ,其中有5个核心的加载步骤:

const int status = llama_model_load(path_model, splits, *model, params);

//    void load_stats  (llama_model_loader & ml);
//    void load_arch   (llama_model_loader & ml);
//    void load_hparams(llama_model_loader & ml);
//    void load_vocab  (llama_model_loader & ml);
//    bool load_tensors(llama_model_loader & ml);

如备注,其中会调用如下几个函数:

  • load_stats,读取模型的元数据统计信息。包括模型的创建时间、上次修改时间、版本号等非关键但有用的信息。在 GGUF 格式中,这些信息通常存储在头部或元数据区。
  • load_arch ,负责识别和设置模型的核心架构信息。它读取模型文件中的架构类型(如 LLaMA、Gemma、Mixtral 等),并设置 llama_model 结构体中的 archtype 字段,为后续的超参数和张量加载做准备。
  • load_hparams,负责加载模型的超参数 (Hyperparameters)。这些参数定义了模型的结构和大小,包括:层数 (n_layer)、嵌入维度 (n_embd)、注意力头数 (n_head)、上下文窗口大小 (n_ctx) 等。这些参数是构建模型计算图和分配 KV 缓存所必需的。
  • load_vocab , 负责加载模型的词汇表 (Vocabulary)。词汇表包含所有 Token 及其对应的 ID。这个步骤确保模型知道如何将输入的文本分词 (tokenize) 成数字 ID,以及如何将输出的数字 ID 转换回可读的文本。它填充了 llama_model 中的 vocab 结构体。
  • load_tensors,最关键的步骤。负责将模型的所有权重张量(如 tok_embd, wq, wk, wv, wo 等)从磁盘读取到内存或分配给选定的硬件设备(GPU)。这个过程通常涉及大量的数据传输和内存分配。它返回一个布尔值,用于指示加载是否被用户的进度回调函数取消。
数据预处理:添加系统提示和用户提示

模型加载完毕之后,我们可以单独提前加入系统prompt提示语:

// 存储聊天过程中用户和助手的消息列表
std::vector<llama_chat_message> _messages;

后续用户的聊天消息也会被添加进这个数组里,一起作为推理输入。

当用户输入一个请求,会在 startCompletion 函数中对所有的数据进行预处理,这个函数完成了所有开始推理前的准备工作,为后续的推理调用铺平了道路。使用 llama_chat_apply_template 将用户消息 (query) 格式化为 LLM 模型能够理解的、带有特殊标记(如 [INST] , <<SYS>> )的完整 Prompt 字符串。调用 common_tokenize 将格式化后的 Prompt 字符串转换成模型需要的数字 ID 序列(_promptTokens)。创建并填充 llama_batch 结构体,将 Token ID 序列和数量赋值给它。

void
LLMInference::startCompletion(const char *query) 

第一步会把最新的用户请求也添加进 _messages 数组中。

// 添加用户类型的prompt
addChatMessage(query, "user");

接着调用 llama_chat_apply_template 函数,将内部消息列表 (_messages) 格式化为模型可接受的 Prompt 字符串 (_formattedMessages)

int newLen = llama_chat_apply_template(_chatTemplate,       // 聊天模板句柄
                                           _messages.data(),    // 输入消息列表
                                           _messages.size(),    // 消息数量
                                           true,                // 强制添加 BOS(开始标记)
                                           _formattedMessages.data(), // 输出缓冲区
                                           _formattedMessages.size());// 输出缓冲区大小
    

然后会对这个 prompt 进行分词和解码。

std::string prompt(_formattedMessages.begin() + _prevLen, _formattedMessages.begin() + newLen);
_promptTokens = common_tokenize(llama_model_get_vocab(_model), prompt, true, true);

// create a llama_batch containing a single sequence
// see llama_batch_init for more details
_batch = new llama_batch();
_batch->token = _promptTokens.data();
_batch->n_tokens = _promptTokens.size();

一个序列的所有 Prompt 会被打包进一个 llama_batch ,其中只有最后一个 Tokenlogits 字段会被设为 true,以预测下一个 Token。以批量(Batch)的方式将一个或多个序列的 Token 输入给模型准备进行一次前向计算。

推理的触发

数据准备好之后,就可以循环调用 completionLoop 函数来进行对话补全推理:

/**
 * 循环获取 LLM 模型生成的响应片段。
 */
extern "C" JNIEXPORT jstring JNICALL
Java_com_stephen_llamacppbridge_LlamaCppBridge_completionLoop(JNIEnv* env, jobject thiz, jlong modelPtr) {
    // 将 jlong 类型的指针转换为 LLMInference 实例指针
    auto* llmInference = reinterpret_cast<LLMInference*>(modelPtr);
    try {
        // 调用 LLMInference 实例的 completionLoop 方法获取响应片段
        std::string response = llmInference->completionLoop();
        // 将 C++ 字符串转换为 Java 字符串并返回
        return env->NewStringUTF(response.c_str());
    } catch (std::runtime_error& error) {
        // 若生成过程中抛出异常,在 Java 层抛出 IllegalStateException 异常
        env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what());
        return nullptr;
    }
}

调用到 llama.cpp 框架的 completionLoop 函数,它负责在模型已经处理完初始 Prompt 之后,每调用一次就生成并处理下一个 Token

/**
 * 执行一次模型推理,采样下一个 Token,并处理输出。
 */
std::string
LLMInference::completionLoop() {
    // 1. 上下文大小检查

    // 获取模型的最大上下文大小
    uint32_t contextSize = llama_n_ctx(_ctx);
    
    // 获取当前 KV 缓存中已使用的位置(即已处理的 Token 数量)
    // llama_memory_seq_pos_max(..., 0) 获取序列 0 的最大位置
    _nCtxUsed = llama_memory_seq_pos_max(llama_get_memory(_ctx), 0) + 1;
    
    // 检查:当前已使用的上下文长度 + 批次中的 Token 数是否超过模型最大上下文
    // 如果超过,则抛出运行时错误,停止生成
    if (_nCtxUsed + _batch->n_tokens > contextSize) {
        throw std::runtime_error("context size reached");
    }

    // 2. 模型推理
    auto start = ggml_time_us(); // 计时开始
    
    // 运行模型解码:执行前向传播,计算当前批次中 Token 的 Logits
    // 此时 _batch 中应该只包含上一步采样出的新 Token,并且已设置好位置等信息。
    if (llama_decode(_ctx, *_batch) < 0) {
        throw std::runtime_error("llama_decode() failed"); // 解码失败
    }
    // 3. Token 采样和生成结束检查 (Sampling and EOG Check)
    // 从最新的 Logits 中采样下一个 Token ID
    // -1 表示使用批次中最后一个 Token 的 Logits 进行采样
    _currToken = llama_sampler_sample(_sampler, _ctx, -1);
    
    // 检查采样出的 Token 是否是 EOG (End of Generation) 标记
    if (llama_vocab_is_eog(llama_model_get_vocab(_model), _currToken)) {
        // 如果是 EOG,则将完整的回复添加到聊天记录中
        addChatMessage(strdup(_response.data()), "assistant");
        _response.clear();
        return "[EOG]"; // 返回特殊标记表示生成结束
    }
    
    // 将 Token ID 转换为可读的文本片段 (word-piece)
    std::string piece = common_token_to_piece(_ctx, _currToken, true);

    // 4. 性能记录和缓存 略
    ...

    // 5. 为下一轮循环准备
    // 重新初始化批次:为下一次 llama_decode 准备输入数据
    // 下一次解码只需要处理这一个新生成的 Token
    _batch->token = &_currToken; // 将批次的 Token 指针指向新生成的 Token ID
    _batch->n_tokens = 1;        // 设置批次中只有一个 Token

    // **注意:** 在下一个循环中,这个 `_batch` 中的 Token 将会被 `llama_decode` 处理,
    // 其位置信息等需要在使用前被更新 (通常由 llama_decode 内部处理或在一个辅助函数中完成)。
    // 
    // token有效性检查,检查缓存的 Token 片段是否是一个有效的 UTF-8 序列
    if (_isValidUtf8(_cacheResponseTokens.c_str())) {
        // 如果有效, 将有效片段添加到完整的回复中
        _response += _cacheResponseTokens;             
        // 拷贝有效片段用于返回
        std::string valid_utf8_piece = _cacheResponseTokens; 
        // 清空缓存,等待下一个 Token
        _cacheResponseTokens.clear();                  
        // 在这里返回完整的 UTF-8 文本片段
        return valid_utf8_piece;                       
    }

    // 如果无效,返回空字符串
    return "";
}

这个函数结合了 模型推理(llama_decode)、Token 采样(llama_sampler_sample)、生成停止检查和 UTF-8 编码处理 ,是实现流式输出的关键。

下一层核心的方法为 llama_decodellama_sampler_sample 函数。

llama_decode

llama_context::decode 是 llama.cpp 中负责执行模型前向传播(即推理)的核心函数。它将一个批次的输入 Token(存储在 llama_batch 中)转化为模型的输出(Logits 或嵌入向量),并同时管理模型的 KV 缓存。

/**
 * @brief 执行模型解码(前向传播)。将输入的 Token 批次通过模型进行计算。
 * @return 0 成功;-1 失败;-2 内存/计算错误;1 KV 缓存不足但已尝试优化;2 被取消。
 */
int llama_context::decode(const llama_batch & batch_inp)

这个函数可以大致分为以下几个核心阶段:

  • 输入验证和初始化: 检查输入批次是否有效,处理特殊情况。
  • KV 缓存管理: 核心步骤,决定如何将批次中的 Token 放入 KV 缓存。
  • 子批次循环 (UBatch Loop): 如果批次太大,将其分解为适合内存的小块进行处理。
  • 计算图构建与执行: 为每个子批次构建并执行模型计算图(Transformer Layers)。
  • 结果提取: 将计算结果(Logits 和/或嵌入向量)从设备内存异步传输回 CPU 内存。
  • 输出排序与映射: 确保输出结果的顺序与用户的输入顺序一致。
llama_sampler_sample

llama_sampler_sample 函数是 llama.cpp 中负责从模型输出中选出下一个 Token 的函数,即执行 Token 采样 的过程。

它的作用是将模型计算出的原始概率(Logits)转换为一个具体的、用于文本生成的新 Token ID。

这个函数可以分解为以下几个关键步骤:

llama_token llama_sampler_sample(struct llama_sampler * smpl, struct llama_context * ctx, int32_t idx) {
    // 1. 获取 Logits 和模型信息
    // 从上下文中获取指定索引位置的 Logits 数组
    const auto * logits = llama_get_logits_ith(ctx, idx);
    const llama_model * model = llama_get_model(ctx);
    const llama_vocab * vocab = llama_model_get_vocab(model);

    // 获取词汇表大小
    const int n_vocab = llama_vocab_n_tokens(vocab);
    // 2. 构建 Token 候选列表
    // 创建一个临时的 std::vector 用于存储所有 Token 的数据结构
    // TODO: 考虑优化,避免每次采样都重新分配内存
    std::vector<llama_token_data> cur;
    cur.reserve(n_vocab);
    // 遍历整个词汇表,将每个 Token ID 及其对应的 Logits 值打包成 llama_token_data 结构
    for (llama_token token_id = 0; token_id < n_vocab; token_id++) {
        // llama_token_data 结构体包含 ID, Logits 和概率 (prob,这里初始化为 0.0f)
        cur.emplace_back(llama_token_data{token_id, logits[token_id], 0.0f});
    }
    // 将 std::vector 包装成 llama_token_data_array 结构,这是采样链的标准输入格式
    llama_token_data_array cur_p = {
        /* .data       = */ cur.data(),  // 指向数据数组
        /* .size       = */ cur.size(),  // 数组大小 (词汇表大小)
        /* .selected   = */ -1,          // 初始化为 -1 (未选择)
        /* .sorted     = */ false,       // 尚未排序
    };
    // 3. 应用采样链
    // 调用核心采样函数:遍历 smpl 中配置的所有采样策略(如 Logits 惩罚、Top-K、Top-P、Temperature)
    // 这个函数会修改 cur_p.data 中的 Logits 值,并最终在 cur_p.selected 中标记选中的 Token 索引
    llama_sampler_apply(smpl, &cur_p);
    // 4. 提取和接受 Token
    // 断言检查:确保采样器已经成功选择了一个有效的 Token
    GGML_ASSERT(cur_p.selected >= 0 && cur_p.selected < (int32_t) cur_p.size);
    // 从选中的索引位置提取最终的 Token ID
    auto token = cur_p.data[cur_p.selected].id;
    // 通知采样器:这个 Token 已经被选中并使用。
    // 这允许采样器更新内部状态,例如:
    // - 更新上次生成的 Token 列表,以便在下一轮应用重复惩罚 (Repetition Penalty)。
    llama_sampler_accept(smpl, token);
    // 返回最终选出的 Token ID
    return token;
}
返回阶段性推理结果

模型的前向推理和采样完成之后,最后一步就是结合模型的词汇表。转换为可读的string字符串数据:

std::string common_token_to_piece(const struct llama_context * ctx, llama_token token, bool special) {
    const llama_model * model = llama_get_model(ctx);
    const llama_vocab * vocab = llama_model_get_vocab(model);
    return common_token_to_piece(vocab, token, special);
}

对于Java层,可以通过token数量,或者检测返回的token中是否有 “EOG” 字符串。即 End Of Generation 。当模型采样到一个被词汇表 (llama_vocab) 识别为 EOG 的 Token ID 时,意味着模型认为它已经完成了对用户 Prompt 的回答。

    // sample a token and check if it is an EOG (end of generation token)
    _currToken = llama_sampler_sample(_sampler, _ctx, -1);
    if (llama_vocab_is_eog(llama_model_get_vocab(_model), _currToken)) {
        // ... 返回 "[EOG]" 停止生成 ...
    }

运行效果

将这个模组直接封装成一个aar,也可以直接被其他模组依赖编译。

外部使用时,先将 .gguf 文件从手机下载路径复制到内部目录,也可以直接在线从 Hugging Face 上下载到本地内部目录。然后调用 loadModel()getResponseAsFlow() 等接口来加载模型,获取生成的对话回复。

运行结果如下,模型加载和对话回复:

打开实时的片段生成和性能追踪对比。推理过程的打印日志如下:

使用 Android StudioProfiler ,实时性能监控:

在加载模型是有一段巨大的爬升,将整个模型包括权重数据都对应读取到了 Native 堆中等待使用。在推理时可以看到CPU是程锯齿状一段一段地起伏,说明LLM正在执行一轮一轮的 自回归生成

多模态展望

在5个月前,llama.cpp已经启动多模态的集成开发,仅支持llama-server的模式启动,目前还没有尝试使用JNI的模式集成到app内,待有空了再研究下图像和音频的处理。

【AI】Understanding how LLM inference works with llama.cpp

【AI】Understanding how LLM inference works with llama.cpp

本文是llama.cpp项目的介绍,主要介绍了llama.cpp项目的运行流程和数据结构概念。

本文从原理上讲解了llama.cpp项目是如何运行LLM模型的,原文链接:

Understanding how LLM inference works with llama.cpp

Posted on: November 11, 2023 | at 04:00 PM (34 min read) llm ai llama llm-internals

In this post, we will dive into the internals of Large Language Models (LLMs) to gain a practical understanding of how they work. To aid us in this exploration, we will be using the source code of llama.cpp, a pure c++ implementation of Meta’s LLaMA model. Personally, I have found llama.cpp to be an excellent learning aid for understanding LLMs on a deeper level. Its code is clean, concise and straightforward, without involving excessive abstractions. We will use this commit version.

We will focus on the inference aspect of LLMs, meaning: how the already-trained model generates responses based on user prompts.

This post is written for engineers in fields other than ML and AI who are interested in better understanding LLMs. It focuses on the internals of an LLM from an engineering perspective, rather than an AI perspective. Therefore, it does not assume extensive knowledge in math or deep learning.

Throughout this post, we will go over the inference process from beginning to end, covering the following subjects (click to jump to the relevant section):

  • Tensors: A basic overview of how the mathematical operations are carried out using tensors, potentially offloaded to a GPU.
  • Tokenization: The process of splitting the user’s prompt into a list of tokens, which the LLM uses as its input.
  • Embedding: The process of converting the tokens into a vector representation.
  • The Transformer: The central part of the LLM architecture, responsible for the actual inference process. We will focus on the self-attention mechanism.
  • Sampling: The process of choosing the next predicted token. We will explore two sampling techniques.
  • The KV cache: A common optimization technique used to speed up inference in large prompts. We will explore a basic kv cache implementation.

By the end of this post you will hopefully gain an end-to-end understanding of how LLMs work. This will enable you to explore more advanced topics, some of which are detailed in the last section.

High-level flow from prompt to output

As a large language model, LLaMA works by taking an input text, the “prompt”, and predicting what the next tokens, or words, should be.

To illustrate this, we will use the first sentence from the Wikipedia article about Quantum Mechanics as an example. Our prompt is:

Quantum mechanics is a fundamental theory in physics that

The LLM attempts to continue the sentence according to what it was trained to believe is the most likely continuation. Using llama.cpp, we get the following continuation:

provides insights into how matter and energy behave at the atomic scale.

Let’s begin by examining the high-level flow of how this process works. At its core, an LLM only predicts a single token each time. The generation of a complete sentence (or more) is achieved by repeatedly applying the LLM model to the same prompt, with the previous output tokens appended to the prompt. This type of model is referred to as an autoregressive model. Thus, our focus will primarily be on the generation of a single token, as depicted in the high-level diagram below:

The full flow for generating a single token from a user prompt includes various stages such as tokenization, embedding, the Transformer neural network and sampling. These will be covered in this post.

Following the diagram, the flow is as follows:

  • The tokenizer splits the prompt into a list of tokens. Some words may be split into multiple tokens, based on the model’s vocabulary. Each token is represented by a unique number.
  • Each numerical token is converted into an embedding. An embedding is a vector of fixed size that represents the token in a way that is more efficient for the LLM to process. All the embeddings together form an embedding matrix.
  • The embedding matrix serves as the input to the Transformer. The Transformer is a neural network that acts as the core of the LLM. The Transformer consists of a chain of multiple layers. Each layer takes an input matrix and performs various mathematical operations on it using the model parameters, the most notable being the self-attention mechanism. The layer’s output is used as the next layer’s input.
  • A final neural network converts the output of the Transformer into logits. Each possible next token has a corresponding logit, which represents the probability that the token is the “correct” continuation of the sentence.
  • One of several sampling techniques is used to choose the next token from the list of logits.
  • The chosen token is returned as the output. To continue generating tokens, the chosen token is appended to the list of tokens from step (1), and the process is repeated. This can be continued until the desired number of tokens is generated, or the LLM emits a special end-of-stream (EOS) token.

In the following sections, we will delve into each of these steps in detail. But before doing that, we need to familiarize ourselves with tensors.

Understanding tensors with ggml

Tensors are the main data structure used for performing mathemetical operations in neural networks. llama.cpp uses ggml, a pure C++ implementation of tensors, equivalent to PyTorch or Tensorflow in the Python ecosystem. We will use ggml to get an understanding of how tensors operate.

A tensor represents a multi-dimensional array of numbers. A tensor may hold a single number, a vector (one-dimensional array), a matrix (two-dimensional array) or even three or four dimensional arrays. More than is not needed in practice.

It is important to distinguish between two types of tensors. There are tensors that hold actual data, containing a multi-dimensional array of numbers. On the other hand, there are tensors that only represent the result of a computation between one or more other tensors, and do not hold data until actually computed. We will explore this distinction soon.

Basic structure of a tensor

In ggml tensors are represented by the ggml_tensor struct. Simplified slightly for our purposes, it looks like the following:

// ggml.h
struct ggml_tensor {
    enum ggml_type    type;
    enum ggml_backend backend;

    int     n_dims;
    // number of elements
    int64_t ne[GGML_MAX_DIMS];
    // stride in bytes
    size_t  nb[GGML_MAX_DIMS];

    enum ggml_op op;

    struct ggml_tensor * src[GGML_MAX_SRC];

    void * data;

    char name[GGML_MAX_NAME];
};

The first few fields are straightforward:

  • type contains the primitive type of the tensor’s elements. For example, GGML_TYPE_F32 means that each element is a 32-bit floating point number.
  • enum contains whether the tensor is CPU-backed or GPU-backed. We’ll come back to this bit later.
  • n_dims is the number of dimensions, which may range from 1 to 4.
  • ne contains the number of elements in each dimension. ggml is row-major order, meaning that ne[0] marks the size of each row, ne[1] of each column and so on.

nb is a bit more sophisticated. It contains the stride: the number of bytes between consequetive elements in each dimension. In the first dimension this will be the size of the primitive element. In the second dimension it will be the row size times the size of an element, and so on. For example, for a 4x3x2 tensor:

An example tensor of 32-bit floating points with dimensions {4,3,2} and strides {4,16,48}.

The purpose of using a stride is to allow certain tensor operations to be performed without copying any data. For example, the transpose operation on a two-dimensional that turns rows into columns can be carried out by just flipping ne and nb and pointing to the same underlying data:

// ggml.c (the function was slightly simplified).
struct ggml_tensor * ggml_transpose(
        struct ggml_context * ctx,
        struct ggml_tensor  * a) {
    // Initialize `result` to point to the same data as `a`
    struct ggml_tensor * result = ggml_view_tensor(ctx, a);

    result->ne[0] = a->ne[1];
    result->ne[1] = a->ne[0];

    result->nb[0] = a->nb[1];
    result->nb[1] = a->nb[0];

    result->op   = GGML_OP_TRANSPOSE;
    result->src[0] = a;

    return result;
}

In the above function, result is a new tensor initialized to point to the same multi-dimensional array of numbers as the source tensor a. By exchanging the dimensions in ne and the strides in nb, it performs the transpose operation without copying any data.

Tensor operations and views

As mentioned before, some tensors hold data, while others represent the theoretical result of an operation between other tensors. Going back to struct ggml_tensor:

  • op may be any supported operation between tensors. Setting it to GGML_OP_NONE marks that the tensor holds data. Other values can mark an operation. For example, GGML_OP_MUL_MAT means that this tensor does not hold data, but only represents the result of matrix multiplication between two other tensors.
  • src is an array of pointers to the tensors between which the operation is to be taken. For example, if op == GGML_OP_MUL_MAT, then src will contain pointers to the two tensors to be multiplied. If op == GGML_OP_NONE, then src will be empty.
  • data points to the actual tensor’s data, or NULL if this tensor is an operation. It may also point to another tensor’s data, and then it’s known as a view. For example, in the ggml_transpose() function above, the resulting tensor is a view of the original, just with flipped dimensions and strides. data points to the same location in memory. The matrix multiplication function illustrates these concepts well:
// ggml.c (simplified and commented)
struct ggml_tensor * ggml_mul_mat(
        struct ggml_context * ctx,
        struct ggml_tensor  * a,
        struct ggml_tensor  * b) {
    // Check that the tensors' dimensions permit matrix multiplication.
    GGML_ASSERT(ggml_can_mul_mat(a, b));

    // Set the new tensor's dimensions
    // according to matrix multiplication rules.
    const int64_t ne[4] = { a->ne[1], b->ne[1], b->ne[2], b->ne[3] };
    // Allocate a new ggml_tensor.
    // No data is actually allocated except the wrapper struct.
    struct ggml_tensor * result = ggml_new_tensor(ctx, GGML_TYPE_F32, MAX(a->n_dims, b->n_dims), ne);

    // Set the operation and sources.
    result->op   = GGML_OP_MUL_MAT;
    result->src[0] = a;
    result->src[1] = b;

    return result;
}

In the above function, result does not contain any data. It is merely a representation of the theoretical result of multiplying a and b.

Computing tensors

The ggml_mul_mat() function above, or any other tensor operation, does not calculate anything but just prepares the tensors for the operation. A different way to look at it is that it builds up a computation graph where each tensor operation is a node, and the operation’s sources are the node’s children. In the matrix multiplication scenario, the graph has a parent node with operation GGML_OP_MUL_MAT, along with two children.

As a real example from llama.cpp, the following code implements the self-attention mechanism which is part of each Transformer layer and will be explored more in-depth later:

// llama.cpp
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    // K,Q,V are tensors initialized earlier
    struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
    // KQ_scale is a single-number tensor initialized earlier.
    struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
    struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0, KQ_scaled, n_past);
    struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
    struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

    // ...
}

The code is a series of tensor operations and builds a computation graph that is identical to the one described in the original Transformer paper:

In order to actually compute the result tensor (here it’s KQV) the following steps are taken:

  • Data is loaded into each leaf tensor’s data pointer. In the example the leaf tensors are K, Q and V.
  • The output tensor (KQV) is converted to a computation graph using ggml_build_forward(). This function is relatively straightforward and orders the nodes in a depth-first order.
  • The computation graph is run using ggml_graph_compute(), which runs ggml_compute_forward() on each node in a depth-first order. ggml_compute_forward() does the heavy lifting of calculations. It performs the mathetmatical operation and fills the tensor’s data pointer with the result.
  • At the end of this process, the output tensor’s data pointer points to the final result.

Offloading calculations to the GPU

Many tensor operations like matrix addition and multiplication can be calculated on a GPU much more efficiently due to its high parallelism. When a GPU is available, tensors can be marked with tensor->backend = GGML_BACKEND_GPU. In this case, ggml_compute_forward() will attempt to offload the calculation to the GPU. The GPU will perform the tensor operation, and the result will be stored on the GPU’s memory (and not in the data pointer).

Consider the self-attention omputation graph shown before. Assuming that K,Q,V are fixed tensors, the computation can be offloaded to the GPU:

The process begins by copying K,Q,V to the GPU memory. The CPU then drives the computation forward tensor-by-tensor, but the actual mathematical operation is offloaded to the GPU. When the last operation in the graph ends, the result tensor’s data is copied back from the GPU memory to the CPU memory.

Note: In a real transformer K,Q,V are not fixed and KQV is not the final output. More on that later.

With this understanding of tensors, we can go back to the flow of LLaMA.

Tokenization

The first step in inference is tokenization. Tokenization is the process of splitting the prompt into a list of shorter strings known as tokens. The tokens must be part of the model’s vocabulary, which is the list of tokens the LLM was trained on. LLaMA’s vocabulary, for example, consists of 32k tokens and is distributed as part of the model.

For our example prompt, the tokenization splits the prompt into eleven tokens (spaces are replaced with the special meta symbol ’▁’ (U+2581)):

Quantum▁mechanics▁is▁a▁fundamental▁theory▁in▁physics▁that

For tokenization, LLaMA uses the SentencePiece tokenizer with the byte-pair-encoding (BPE) algorithm. This tokenizer is interesting because it is subword-based, meaning that words may be represented by multiple tokens. In our prompt, for example, ‘Quantum’ is split into ‘Quant’ and ‘um’. During training, when the vocabulary is derived, the BPE algorithm ensures that common words are included in the vocabulary as a single token, while rare words are broken down into subwords. In the example above, the word ‘Quantum’ is not part of the vocabulary, but ‘Quant’ and ‘um’ are as two separate tokens. White spaces are not treated specially, and are included in the tokens themselves as the meta character if they are common enough.

Subword-based tokenization is powerful due to multiple reasons:

  • It allows the LLM to learn the meaning of rare words like ‘Quantum’ while keeping the vocabulary size relatively small by representing common suffixes and prefixes as separate tokens.
  • It learns language-specific features without employing language-specific tokenization schemes. Quoting from the BPE-encoding paper:

    consider compounds such as the German Abwasserbehandlungsanlange ‘sewage water treatment plant’, for which a segmented, variable-length representation is intuitively more appealing than encoding the word as a fixed-length vector.
  • Similarly, it is also useful in parsing code. For example, a variable named model_size will be tokenized into model|_|size, allowing the LLM to “understand” the purpose of the variable (yet another reason to give your variables indicative names!).

In llama.cpp, tokenization is performed using the llama_tokenize() function. This function takes the prompt string as input and returns a list of tokens, where each token is represented by an integer:

// llama.h
typedef int llama_token;
// common.h
std::vector<llama_token> llama_tokenize(
        struct llama_context * ctx,
        // the prompt
        const std::string & text,
        bool   add_bos);

The tokenization process starts by breaking down the prompt into single-character tokens. Then, it iteratively tries to merge each two consequetive tokens into a larger one, as long as the merged token is part of the vocabulary. This ensures that the resulting tokens are as large as possible. For our example prompt, the tokenization steps are as follows:

Q|u|a|n|t|u|m|▁|m|e|c|h|a|n|i|c|s|▁|i|s|▁a|▁|f|u|n|d|a|m|e|n|t|a|l|

Qu|an|t|um|▁m|e|ch|an|ic|s|▁|is|▁a|▁f|u|nd|am|en|t|al|

Qu|ant|um|▁me|chan|ics|▁is|▁a|▁f|und|am|ent|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|ament|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|amental|

Quant|um|▁mechan|ics|▁is|▁a|▁fundamental|

Note that each intermediate step consists of valid tokenization according to the model’s vocabulary. However, only the last one is used as the input to the LLM.

Embeddings

The tokens are used as input to LLaMA to predict the next token. The key function here is the llm_build_llama() function:

// llama.cpp (simplified)
static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                   int   n_tokens,
                   int   n_past);

This function takes a list of tokens represented by the tokens and n_tokens parameters as input. It then builds the full tensor computation graph of LLaMA, and returns it as a struct ggml_cgraph. No computation actually takes place at this stage. The n_past parameter, which is currently set to zero, can be ignored for now. We will revisit it later when discussing the kv cache.

Beside the tokens, the function makes use of the model weights, or model parameters. These are fixed tensors learned during the LLM training process and included as part of the model. These model parameters are pre-loaded into lctx before the inference begins.

We will now begin exploring the computation graph structure. The first part of this computation graph involves converting the tokens into embeddings.

An embedding is a fixed vector representation of each token that is more suitable for deep learning than pure integers, as it captures the semantic meaning of words. The size of this vector is the model dimension, which varies between models. In LLaMA-7B, for example, the model dimension is n_embd=4096.

The model parameters include a token-embedding matrix that converts tokens into embeddings. Since our vocabulary size is n_vocab=32000, this is a 32000 x 4096 matrix with each row containing the embedding vector for one token:

Each token has an associated embedding which was learned during training and is accessible as part of the token-embedding matrix.

The first part of the computation graph extracts the relevant rows from the token-embedding matrix for each token:

// llama.cpp (simplified)
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_tokens);
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens));

    inpL = ggml_get_rows(ctx0, model.tok_embeddings, inp_tokens);
}
//

The code first creates a new one-dimensional tensor of integers, called inp_tokens, to hold the numerical tokens. Then, it copies the token values into this tensor’s data pointer. Last, it creates a new GGML_OP_GET_ROWS tensor operation combining the token-embedding matrix model.tok_embeddings with our tokens.

This operation, when later computed, pulls rows from the embeddings matrix as shown in the diagram above to create a new n_tokens x n_embd matrix containing only the embeddings for our tokens in their original order:

The embedding process creates a fixed-size embedding vector for each of the original tokens. When stacked together they make up the embedding matrix of the prompt.

The Transformer

The main part of the computation graph is called the Transformer. The Transformer is a neural network architecture that is the core of the LLM, and performs the main inference logic. In the following section we will explore some key aspects of the transformer from an engineering perspective, focusing on the self-attention mechanism. If you want to gain an understanding of the intuition behind the Transformer’s architecture instead, I recommend reading The Illustrated Transformer2.

Self-attention

We first zoom in to look at what self-attention is; after which we will zoom back out to see how it fits within the overall Transformer architecture3.

Self-attention is a mechanism that takes a sequence of tokens and produces a compact vector representation of that sequence, taking into account the relationships between the tokens. It is the only place within the LLM architecture where the relationships between the tokens are computed. Therefore, it forms the core of language comprehension, which entails understanding word relationships. Since it involves cross-token computations, it is also the most interesting place from an engineering perspective, as the computations can grow quite large, especially for longer sequences.

The input to the self-attention mechanism is the n_tokens x n_embd embedding matrix, with each row, or vector, representing an indivisual token4. Each of these vectors is then transformed into three distinct vectors, called “key”, “query” and “value” vectors. The transformation is achieved by multiplying the embedding vector of each token with the fixed wk, wq and wv matrices, which are part of the model parameters:

Multiplying the embedding vector of a token with the wk, wq and wv parameter matrices produces a “key”, “query” and “value” vector for that token.

This process is repeated for every token, i.e. n_tokens times. Theoretically, this could be done in a loop but for efficiency all rows are transformed in a single operation using matrix multiplication, which does exactly that. The relevant code looks as follows:

// llama.cpp (simplified to remove use of cache)

// `cur` contains the input to the self-attention mechanism
struct ggml_tensor * K = ggml_mul_mat(ctx0,
    model.layers[il].wk, cur);
struct ggml_tensor * Q = ggml_mul_mat(ctx0,
    model.layers[il].wq, cur);
struct ggml_tensor * V = ggml_mul_mat(ctx0,
    model.layers[il].wv, cur);

We end up with K,Q and V: Three matrices, also of size n_tokens x n_embd, with the key, query and value vectors for each token stacked together.

The next step of self-attention involves multiplying the matrix Q, which contains the stacked query vectors, with the transpose of the matrix K, which contains the stacked key vectors. For those less familiar with matrix operations, this operation essentially calculates a joint score for each pair of query and key vectors. We will use the notation S(i,j) to denote the score of query i with key j.

This process yield n_tokens^2 scores, one for each query-key pair, packed within a single matrix called KQ. This matrix is subsequently masked to remove the entries above the diagonal:

A joint score S(i,j) is calculated for each query-key pair by multiplying Q with the transpose of K. The result shown here is for the first four tokens, along with the tokens represented by each score. The masking step ensures that only scores between a token and its preceding tokens are kept. An intermediate scaling operation has been omitted for simplicity.

The masking operation is a critical step. For each token it retains scores only with its preceeding tokens. During the training phase, this constraint ensures that the LLM learns to predict tokens based solely on past tokens, rather than future ones. Moreover, as we’ll explore in more detail later, it allows for significant optimizations when predicting future tokens.

The last step of self-attention involves multiplying the masked scoring KQ_masked with the value vectors from before5. Such a matrix multiplication operation creates a weighted sum of the value vectors of all preceeding tokens, where the weights are the scores S(i,j). For example, for the fourth token ics it creates a weighted sum of the value vectors of Quant, um, ▁mechan and ics with the weights S(3,0) to S(3,3), which themselves were calculated from the query vector of ics and all preceeding key vectors.

The KQV matrix contains weighted sums of the value vectors. For example, the highlighted last row is a weighted sum of the first four value vectors, with the weights being the highlighted scores.

The KQV matrix concludes the self-attention mechanism. The relevant code implementing self-attention was already presented before in the context of general tensor computations, but now you are better equipped fully understand it.

The layers of the Transformer

Self-attention is one of the components in what are called the layers of the transformer. Each layer, in addition to the self-attention mechanism, contains multiple other tensor operations, mostly matrix addition, multiplication and activation that are part of a feed-forward neural network. We will not explore these more in detail, but just note the following facts:

  • Large, fixed, parameter matrices are used in the feed-forward network. In LLaMA-7B, their sizes are n_embd x n_ff = 4096 x 11008.
  • Besides self-attention, all other operations can be thought of as being carried row-by-row, or token-by-token. As mentioned before, only self-attention contains cross-token calculations. This will be important later when discussing the kv-cache.
  • The input and output are always of size n_tokens x n_embd: One row for each token, each the size of the model’s dimension.

For completeness I included a diagram of a single Transformer layer in LLaMA-7B. Note that the exact architecture will most likely vary slightly in future models.

Full computation graph of a Transformer layer in LLaMA-7B, containing self-attention and feed-foward mechanisms. The output of each layer serves as the input to the next. Large parameter matrices are used both in the self-attention stage and in the feed-forward stage. These constitute most of the 7 billion parameters of the model.

In a Transformer architecture there are multiple layers. For example, in LLaMA-7B there are n_layers=32 layers. The layers are identical except that each has its own set of parameter matrices (e.g. its own wk, wq and wv matrices for the self-attention mechanism). The first layer’s input is the embedding matrix as described above. The first layer’s output is then used as the input to the second layer and so on. We can think of it as if each layer produces a list of embeddings, but each embedding no longer tied directly to a single token but rather to some kind of more complex understanding of token relationships.

Calculating the logits

The final step of the Transformer involves the computation of logits. A logit is a floating-point number that represents the probability that a particular token is the “correct” next token. The higher the value of the logit, the more likely it is that the corresponding token is the “correct” one.

The logits are calculated by multiplying the output of the last Transformer layer with a fixed n_embd x n_vocab parameter matrix (also called output in llama.cpp). This operation results in a logit for each token in our vocabulary. For example, in LLaMA, it results in n_vocab=32000 logits:

The final step of the Transformer computes the logits by multiplying the output of the last layer with a fixed parameter matrix (also called ‘output’). Only the last row of the result, highlighted here, is of interest, and contains a logit for each possible next token in the vocabulary.

The logits are the Transformer’s output and tell us what the most likely next tokens are. By this all the tensor computations are concluded. The following simplified and commented version of the llm_build_llama() function summarizes all steps which were described in this section:

// llama.cpp (simplified and commented)

static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                   int   n_tokens,
                   int   n_past) {
    ggml_cgraph * gf = ggml_new_graph(ctx0);
    struct ggml_tensor * cur;
    struct ggml_tensor * inpL;

    // Create a tensor to hold the tokens.
    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, N);
    // Copy the tokens into the tensor
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens));

    // Create the embedding matrix.
    inpL = ggml_get_rows(ctx0,
        model.tok_embeddings,
        inp_tokens);

    // Iteratively apply all layers.
    for (int il = 0; il < n_layer; ++il) {
        struct ggml_tensor * K = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
        struct ggml_tensor * Q = ggml_mul_mat(ctx0, model.layers[il].wq, cur);
        struct ggml_tensor * V = ggml_mul_mat(ctx0, model.layers[il].wv, cur);

        struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
        struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
        struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0,
            KQ_scaled, n_past);
        struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
        struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

        // Run feed-forward network.
        // Produces `cur`.
        // ...

        // input for next layer
        inpL = cur;
    }

    cur = inpL;

    // Calculate logits from last layer's output.
    cur = ggml_mul_mat(ctx0, model.output, cur);

    // Build and return the computation graph.
    ggml_build_forward_expand(gf, cur);
    return gf;
}

To actually performn inference, the computation graph returned by this function is computed, using ggml_graph_compute() as described previously. The logits are then copied out from the last tensor’s data pointer into an array of floats, ready for the next step called sampling.

Sampling

With the list of logits in hand, the next step is to choose the next token based on them. This process is called sampling. There are multiple sampling methods available, suitable for different use cases. In this section we will cover two basic sampling methods, with more advanced sampling methods like grammar sampling reserved for future posts.

Greedy sampling

Greedy sampling is a straightforward approach that selects the token with the highest logit associated with it.

For our example prompt, the following tokens have the highest logits:

tokenlogit
▁describes18.990
▁provides17.871
▁explains17.403
▁de16.361
▁gives15.007

Therefore, greedy sampling will deterministically choose ▁describes as the next token. Greedy sampling is most useful when deterministic outputs are required when re-evaluating identical prompts.

Temperature sampling

Temperature sampling is probabilistic, meaning that the same prompt might produce different outputs when re-evaluated. It uses a parameter called temperature which is a floating-point value between 0 and 1 and affects the randomness of the result. The process goes as follows:

  • The logits are sorted from high to low and normalized using a softmax function to ensure that they all sum to 1. This transformation converts each logit into a probability.
  • A threshold (set to 0.95 by default) is applied, retaining only the top tokens such that their cumulative probability remains below the threshold. This step effectively removes low-probability tokens, preventing “bad” or “incorrect” tokens from being rarely sampled.
  • The remaining logits are divided by the temperature parameter and normalized again such that they all sum to 1 and represent probabilities.
  • A token is randomly sampled based on these probabilities. For example, in our prompt, the token ▁describes has a probability of p=0.6, meaning that it will be chosen approximately 60% of the time. Upon re-evaluation, different tokens may be chosen.

The temperature parameter in step 3 serves to either increase or decrease randomness. Lower temperature values suppress lower probability tokens, making it more likely that the same tokens will be chosen on re-evaluation. Therefore, lower temperature values decrease randomness. In contrast, higher temperature values tend to “flatten” the probability distribution, emphasizing lower probability tokens. This increases the likelihood that each re-evaluation will result in different tokens, increasing randomness.

Normalized next-token probabilities for our example prompt. Lower temperatures suppress low-probability tokens, while higher temperatures emphasize them. temp=0 is essentially identical to greedy sampling.

Sampling a token concludes a full iteration of the LLM. After the initial token is sampled, it is added to the list of tokens, and the entire process runs again. The output iteratively becomes the input to the LLM, increasing by one token each iteration.

Theoretically, subsequent iterations can be carried out identically. However, to address performance degradation as the list of tokens grows, certain optimizations are employed. These will be covered next.

Optimizing inference

The self-attention stage of the Transformer can become a performance bottleneck as the list of input tokens to the LLM grows. A longer list of tokens means that larger matrices are multiplied together. Each matrix multiplication consists of many smaller numerical operations, known as floating-point operations, which are constrained by the GPU’s floating-point-operations-per-second capacity (flops). In the Transformer Inference Arithmetic, it is calculated that for a 52B parameter model, on an A100 GPU, performance starts to degrade at 208 tokens due to excessive flops. The most commonly employed optimization technique to solve this bottleneck is known as the kv cache.

The KV cache

To recap, each token has an associated embedding vector, which is further transformed into key and value vectors by multiplying it with the parameter matrices wk and wv. The kv cache is a cache for these key and value vectors. By caching them, we save the floating point operations required for re-calculating them on each iteration.

The cache works as follows:

  • During the initial iteration, the key and value vectors are computed for all tokens, as previously described, and then saved into the kv cache.
  • In subsequent iterations, only the key and value vectors for the newest token need to be calculated. The cached k-v vectors, together with the k-v vectors for the new token, are concatenated together to form the K and V matrices. This saves recalculating the k-v vectors for all previous tokens, which can be significant.

On subsequent iterations, the key vector of the latest token only is calculated. The rest are pulled from the cache, and together they form the K matrix. The newly-computed key vector is also saved to the cache. The same process is applied to the value vectors.

The ability to utilize a cache for key and value vectors arises from the fact that these vectors remain identical between iterations. For example, if we first process four tokens, and then five tokens, with the initial four unchanged, then the first four key and value vectors will remain identical between the first and the second iteration. As a result, there’s no need to recalculate key and value vectors for the first four tokens in the second iteration.

This principle holds true for all layers within the Transformer, not just the first one. In all layers, the key and value vectors for each token are solely dependent on previous tokens. Therefore, as new tokens are appended in subsequent iterations, the key and value vectors for existing tokens remain the same.

For the first layer, this concept is relatively straightforward to verify: the key vector of a token is determined by multiplying the token’s fixed embedding with the fixed wk parameter matrix. Thus, it remains unchanged in subsequent iterations, regardless of the additional tokens introduced. The same rationale applies to the value vector.

For the second layer and beyond, this principle is a bit less obvious but still holds true. To understand why, consider the first layer’s KQV matrix, the output of the self-attention stage. Each row in the KQV matrix is a weighted sum that depends on:

  • Value vectors of previous tokens.
  • Scores calculated from key vectors of previous tokens.

Therefore each row in KQV solely relies on previous tokens. This matrix, following a few additional row-based operations, serves as the input to the second layer. This implies that the second layer’s input will remain unchanged in future iterations, except for the addition of new rows. Inductively, the same logic extends to the rest of the layers.

Another look at how the KQV matrix is calculated. The third row, highlighted, is determined based only on the third query vector and the first three key and value vectors, also highlighted. Subsequent tokens do not affect it. Therefore it will stay fixed in future iterations.

Further optimizing subsequent iterations

You might wonder why we don’t cache the query vectors as well, considering we cache the key and value vectors. The answer is that in fact, except for the query vector of the current token, query vectors for previous tokens are unnecessary in subsequent iterations. With the kv cache in place, we can actually feed the self-attention mechanism only with the latest token’s query vector. This query vector is multiplied with the cached K matrix to calculate the joint scores of the last token and all previous tokens. Then, it is multiplied with the cached V matrix to calculate only the latest row of the KQV matrix. In fact, across all layers, we now pass 1 x n_embd -sized vectors instead of the n_token x n_embd matrices calculated in the first iteration. To illustrate this, compare the following diagram, showing a later iteration, with the previous one:

Self-attention in subsequent iterations. In this example, there were four tokens in the first iteration and a fifth token, ‘▁is’, is added in the second iteration. The latest’s token key, query and value vectors, together with the cached key and value vectors, are used to compute the last row of KQV, which is all that is needed for predicting the next token.

This process repeats across all layers, utilizing each layer’s kv cache. As a result, the Transformer’s output in this case is a single vector of n_vocab logits predicting the next token.

With this optimization we save floating point operations of calculating unnecessary rows in KQ and KQV, which can become quite significant as the list of tokens grows in size.

The KV cache in practice

We can dive into llama.cpp code to see how the kv cache is implemented in practice. Unsurprisingly maybe, it is built using tensors, one for key vectors and one for value vectors:

// llama.cpp (simplified)

struct llama_kv_cache {
    // cache of key vectors
    struct ggml_tensor * k = NULL;

    // cache of value vectors
    struct ggml_tensor * v = NULL;

    int n; // number of tokens currently in the cache
};

When the cache is initialized, enough space is allocated to hold 512 key and value vectors for each layer:

// llama.cpp (simplified)
// n_ctx = 512 by default
static bool llama_kv_cache_init(
    struct llama_kv_cache & cache,
    ggml_type   wtype,
    int   n_ctx) {
    // Allocate enough elements to hold n_ctx vectors for each layer.
    const int64_t n_elements = n_embd*n_layer*n_ctx;

    cache.k = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);
    cache.v = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);

    // ...
}

Recall that during inference, the computation graph is built using the function llm_build_llama(). This function has a parameter called n_past that we ignored before. In the first iteration, the n_tokens parameter contains the number of tokens and n_past is set to 0. In subsequent iterations, n_tokens is set to 1 because only the latest token is processed, and n_past contains the number of past tokens. n_past is then used to pull the correct number of key and value vectors from the kv cache.

The relevant part from this function is shown here, utilizing the cache for calculating the K matrix. I simplified it slightly to ignore the multi-head attention and added comments for each step:

// llama.cpp (simplified and commented)

static struct ggml_cgraph * llm_build_llama(
    llama_context & lctx,
    const llama_token * tokens,
    int   n_tokens,
    int   n_past) {
    // ...

    // Iteratively apply all layers.
    for (int il = 0; il < n_layer; ++il) {
         // Compute the key vector of the latest token.
         struct ggml_tensor * Kcur = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
         // Build a view of size n_embd into an empty slot in the cache.
         struct ggml_tensor * k = ggml_view_1d(
            ctx0,
            kv_cache.k,
            // size
            n_tokens*n_embd,
            // offset
            (ggml_element_size(kv_cache.k)*n_embd) * (il*n_ctx + n_past)
         );

         // Copy latest token's k vector into the empty cache slot.
         ggml_cpy(ctx0, Kcur, k);

         // Form the K matrix by taking a view of the cache.
         struct ggml_tensor * K =
             ggml_view_2d(ctx0,
                 kv_self.k,
                 // row size
                 n_embd,
                 // number of rows
                 n_past + n_tokens,
                 // stride
                 ggml_element_size(kv_self.k) * n_embd,
                 // cache offset
                 ggml_element_size(kv_self.k) * n_embd * n_ctx * il);
    }
}

First, the new key vector is calculated. Then, n_past is used to find the next empty slot in the cache, and the new key vector is copied there. Last, the matrix K is formed by taking a view into the cache with the correct number of tokens (n_past + n_tokens).

The kv cache is the basis for LLM inference optimization. It’s worth noting that the version implemented in llama.cpp (as of this writing) and presented here is not the most optimal one. For instance, it allocates a lot of memory in advance to hold the maximum number of key and value vectors supported (512 in this case). More advanced implementations, such as vLLM, aim to enhance memory usage efficiency and may offer further performance improvement. These advanced techniques are reserved for future posts. Moreover, as this field moves forward at lightning speeds, there are likely to be new and improved optimization techniques in the future.

Concluding

This post covered quite a lot of ground and should give you a basic understanding of the full process of LLM inference. With this knowledge you can get around more advanced resources:

  • LLM parameter counting and Transformer Inference Arithmetic analyze LLM performance in depth.
  • vLLM is a library for managing the kv cache memory more efficiently.
  • Continuous batching is an optimization technique to batch multiple LLM prompts together.

I also hope to cover the internals of more advanced topics in future posts. Some options include:

  • Quantized models.
  • Fine-tuned LLMs using LoRA.
  • Various attention mechanisms (Multi-head attention, Grouped-query attention and Sliding window attention).
  • LLM request batching.
  • Grammar sampling.

Stay tuned!

Footnotes

Footnotes

  1. ggml also provides ggml_build_backward() that computes gradients in a backward manner from output to input. This is used for backpropagation only during model training, and never in inference. ↩
  2. The article describes an encoder-decoder model. LLaMA is a decoder-only model, because it only predicts one token at a time. But the core concpets are the same. ↩
  3. For simplicity I have chosen to describe here a single-head self-attention mechanism. LLaMA uses a multi-head self-attention mechanism. Except for making the tensor operations a bit more complicated, it has no implications for the core ideas presented in this section. ↩
  4. To be precise, the embeddings first undergo a normalization operation that scales their values. We ignore it as it does not affect the core ideas presented. ↩
  5. The scores also undergo a softmax operation, which scales them so that each row of scores sums of to 1. ↩

【AI】LLM开发流程和运行环境简介

【AI】LLM开发流程和运行环境简介

本文介绍了LLM一般的开发流程和运行环境相关内容

接上文,了解了LLM的发展历程,从简单的线性回归模型到神经网络,再到生成式预训练转换器。

【AI】LLM大语言模型是如何工作的

现在更多从实际部署运行的操作上,介绍下LLM在各个平台上的运行。

LLM一般的开发流程

根据 OpenAI 联合创始人 Andrej Karpathy 在微软 Build 2023 大会上所公开的信息,OpenAI 所使用的大规模语言模型构建流程如下图所示。主要包含四个阶段:预训练、有监督微调、奖励建模、强化学习。这四个阶段都需要不同规模数据集合以及不同类型的算法,会产出不同类型的模型,同时所需要的资源也有非常大的差别。

1. 阶段一:数据准备

任何AI模型,要让神经网络输出的结果足够准确,必须以大量的数据来做训练,调节权重参数。数据的准备过程是整个流程的基石,数据的质量和规模直接决定了模型能力的上限。

首先需要构建一个规模庞大(通常是 PB 级别,即数千 TB)且多样化的数据集。来源包括网页数据、书籍、代码、专业文献、对话数据等。 然后要进行数据清洗。原始数据非常“脏”,必须进行严格清洗。例如要进行去重,移除完全相同或高度相似的文本,防止模型过度拟合这些重复内容。还有从质量上过滤,移除低质量文本,有害内容移除,过滤掉色情、暴力、极端言论等内容。最后还有隐私考量,移除个人身份信息。

数据集准备完成之后,要进行分词。计算机不认识“文字”,只认识数字。分词就是将文本转换成模型能理解的数字序列,它能有效地平衡词汇表大小和序列长度。

2. 阶段二:预训练 (Pre-training)

这是最耗时、最烧钱的阶段,目的是让模型掌握语言的“本质”。

现代 LLM 几乎都基于 Transformer 架构。最主流的是 Decoder-only (仅解码器) 架构(如 GPT 系列、Llama、PaLM),它非常擅长生成任务。

最常见的训练目标目标是 Next-Token Prediction 。简单来说,就是给模型一段文本,让它预测下一个最可能出现的词。

例如,输入 "The quick brown fox jumps over the",模型需要输出 "lazy"

通过在数万亿个 Token 上反复进行这个任务,模型可以学习语法、语义、事实知识、上下文理解甚至一定的推理能力。

同时,这一步需要极大规模的算力集群,通常由数千甚至上万块高端 GPU 组成,互联成一个超级计算机。训练时间可能长达数周甚至数月,成本高达数千万至数亿美元。

预训练结束后,我们得到一个 “基础模型” (Base Model)。这个模型博学,但“野性难驯”,它只会续写文本,不一定会听从指令(比如你让它“写首诗”,它可能会续写成“……是一个常见的作业要求”)。

3. 阶段三:对齐 (Alignment)

这是让模型从“文本续写机”变为“智能助手”的关键一步,目的是让模型的输出符合人类的意图 (Intent) 和价值观 (Values)。

这个阶段主要包含两个步骤:

1. 监督微调 (SFT)**

教会模型理解并执行指令。雇佣大量的数据标注员,编写高质量的“指令-回答”对(Prompt-Response pairs),利用这些少量高质量数据集合。

例如:

提示词(Prompt):复旦大学有几个校区?

理想输出:复旦大学现有 4 个校区,分别是邯郸校区、新江湾校区、枫林校区和张江校区。其中邯郸校区是复旦大学的主校区,邯郸校区与新江湾校区都位于杨浦区,枫林校区位于徐汇区,张江校区位于浦东新区。

利用这些有监督数据,使用与预训练阶段相同的语言模型训练算法,在基础语言模型基础上再进行训练,从而得到有监督微调模型(SFT 模型)。让它学会“对话”和“问答”的模式。

经过训练的 SFT 模型具备了初步的指令理解能力和上下文理解能力,能够完成开放领域问题、阅读理解、翻译、生成代码等能力,也具备了一定的对未知任务的泛化能力。

由于有监督微调阶段的所需的训练语料数量较少,SFT 模型的训练过程并不需要消耗非常大量的计算。根据模型的大小和训练数据量,通常只需要数十块GPU,花费数天时间完成训练。

SFT 模型具备了初步的任务完成能力,可以开放给用户使用,很多类 ChatGPT的模型都属于该类型,包括:Alpaca[38]Vicuna[39]MOSSChatGLM-6B 等。

很多这类模型效果也非常好,甚至在一些评测中达到了 ChatGPT 的 90% 的效果。当前的一些研究表明有监督微调阶段数据选择对 SFT 模型效果有非常大的影响,因此如何构造少量并且高质量的训练数据是本阶段有监督微调阶段的研究重点。

2. 偏好对齐 (RLHF)**

让模型的回答更有用、更诚实、更无害。主流技术是 RLHF (Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)。主要有两步:

训练奖励模型 (Reward Model, RM)

奖励建模阶段目标是 构建一个文本质量对比模型 ,对于同一个提示词,SFT模型给出的多个不同输出结果的质量进行排序。

奖励模型(RM 模型)可以通过二分类模型,对输入的两个结果之间的优劣进行判断。RM 模型与基础语言模型和 SFT 模型不同,RM 模型本身并不能单独提供给用户使用。

奖励模型的训练通常和 SFT 模型一样,使用数十块 GPU,通过几天时间完成训练。由于 RM 模型的准确率对于强化学习阶段的效果有着至关重要的影响,因此对于该模型的训练通常需要大规模的训练数据。

Andrej Karpathy 在报告中指出,该部分需要百万量级的对比数据标注,而且其中很多标注需要花费非常长的时间才能完成。

如图,InstructGPT 系统中奖励模型训练样本标注示例。可以看到,示例中文本表达都较为流畅,标注其质量排序需要制定非常详细的规范,标注人员也需要非常认真的对标规范内容进行标注,需要消耗大量的人力,同时如何保持众包标注人员之间的一致性,也是奖励建模阶段需要解决的难点问题之一。

此外 奖励模型的泛化能力边界 也是在本阶段需要重点研究的另一个问题。如果 RM 模型的目标是针对所有提示词系统所生成输出都能够高质量的进行判断,该问题所面临的难度在某种程度上与文本生成等价,因此如何限定 RM 模型应用的泛化边界也是本阶段难点问题。

强化学习 (RL)

强化学习阶段根据数十万用户给出的提示词,利用在前一阶段训练的 RM 模型,给出 SFT 模型对用户提示词补全结果的质量评估,并与语言模型建模目标综合得到更好的效果。

该阶段所使用的提示词数量与有监督微调阶段类似,数量在十万量级,并且不需要人工提前给出该提示词所对应的理想回复。使用强化学习,在 SFT 模型基础上调整参数,使得最终生成的文本可以获得更高的奖励(Reward)。该阶段所需要的计算量相较预训练阶段也少很多,通常也仅需要数十块 GPU,经过数天时间的即可完成训练。

强化学习和有监督微调的对比,在模型参数量相同的情况下,强化学习可以得到相较于有监督微调好得多的效果。关于为什么强化学习相比有监督微调可以得到更好结果的问题,截止到 2023 年 9 月也还没有完整和得到普遍共识的解释。

此外,Andrej Karpathy 也指出强化学习也并不是没有问题的,它会使得基础模型的熵降低,从而减少了模型输出的多样性。在经过强化学习方法训练完成后的 RL 模型,就是最终提供给用户使用具有理解用户指令和上下文的类 ChatGPT 系统。

由于强化学习方法稳定性不高,并且超参数众多,使得模型收敛难度大,再叠加 RM 模型的准确率问题,使得在大规模语言模型如何能够有效应用强化学习非常困难。

DPO (Direct Preference Optimization) 是一种更新的、更简单的替代技术,它跳过了训练独立奖励模型的步骤,直接使用偏好数据来优化模型,效果也非常好。

4. 阶段四:评估与迭代 (Evaluation)

如何知道模型有多好?需要一套严格的“考试”体系。

  • 学术基准 (Academic Benchmarks):
    • 使用标准化的数据集来测试模型的各项能力。
    • MMLU: 综合能力测试(涵盖数学、历史、法律、计算机科学等57个科目)。
    • HumanEval / MBPP: 代码生成能力。
    • GSM8K: 小学数学应用题(测试推理)。
    • TruthfulQA: 评估模型是否会产生常见的错误信息或谎言。
  • 人类评估 (Human Evaluation):
    • 基准测试有其局限性(可能已被“污染”)。最终,模型的好坏还是需要人来主观判断。
    • 红队测试 (Red Teaming): 专门组织“攻击者” (Red Teamers) 尝试诱导模型说出有害、违规或错误的内容,以测试其安全性和鲁棒性。
  • 迭代 (Iteration):
    • 评估中发现的问题(如“数学不行”、“容易被诱导”)会反馈回前面的阶段,通过补充更高质量的数据、改进对齐算法等方式进行优化,然后重新训练和评估。

5. 阶段五:部署与推理

这是将模型交付给最终用户的阶段。

  • 模型优化 (Optimization):
    • 预训练出的模型非常庞大(动辄几百 GB),直接运行(推理)的成本高昂且速度慢。
    • 量化 (Quantization): 将模型的权重从高精度(如 32 位浮点数)降低到低精度(如 8 位或 4 位整数),极大减少显存占用和提高速度,同时尽量保持性能。
    • 蒸馏 (Distillation): 训练一个“小模型”来模仿“大模型”的行为。
    • 剪枝 (Pruning): 移除模型中不那么重要的参数。
  • 服务部署 (Serving):
    • 将优化后的模型部署到配备 GPU 的服务器上,并通过 API 接口对外提供服务。
    • 这需要解决高并发、低延迟等工程挑战,例如使用 vLLM、TensorRT-LLM 等推理框架。
  • 持续监控 (Monitoring):
    • 模型上线后,需要持续监控其表现,收集用户反馈和模型可能产生的“幻觉” (Hallucinations),用于下一轮的迭代优化。

开源社区

Huggingface

Hugging Face 是一家在人工智能(AI)领域迅速崛起的开源社区平台和公司,专注于让机器学习的构建和使用变得更加简单和开放。它最初是一家开发聊天机器人应用的公司,但后来转型并成为了全球AI领域,尤其是自然语言处理 (NLP) 领域的关键资源中心。

Hugging Face 的核心理念是 开放科学民主化AI,通过提供一系列工具、库和平台,使得研究人员和开发者能够轻松地共享、发现、训练和部署机器学习模型和数据集。

Hugging Face Hub 是一个类似 GitHub 的平台,但专门用于托管和共享机器学习模型、数据集和演示空间。

  • 模型共享:用户可以上传和下载各种预训练模型,每个模型都有详细的“模型卡片”介绍其用途、性能、局限性等。这促进了模型的可复用性和社区协作。
  • 数据集共享:Hub 上也托管了大量公开数据集,涵盖文本、图像、音频等多种格式,方便研究人员和开发者快速获取训练数据。
  • Gated Datasets:一些数据集可能需要申请权限才能访问,以确保数据合规性。
  • 版本控制:Hugging Face Hub 使用类似 Git 的版本控制系统,可以追踪模型的迭代和更新。

Hugging Face 对机器学习社区产生了深远的影响。通过提供开源工具和平台,它极大地促进了AI模型的开放共享和协作。降低AI了门槛,使得非专业人士也能更容易地使用和部署复杂的AI模型,推动了AI的普及。标准化了模型接口和共享机制,加速研究与开发,让研究人员可以更快地构建和迭代新模型,开发者可以更快地将研究成果转化为实际应用。Hugging Face 聚集了全球大量的AI研究者和开发者,共同推动AI技术的发展。

总的来说,Hugging Face 已经成为机器学习领域不可或缺的一部分,无论你是研究人员、开发者还是仅仅对AI感兴趣,它都提供了丰富的资源和工具来帮助你探索和构建AI应用。作为一名安卓开发者,Hugging Face 也能帮助你更便捷地将强大的AI能力集成到你的移动应用中。

魔塔

魔塔AI社区 是一个聚焦于人工智能(AI)技术交流与实践的线上社区平台,致力于为开发者、研究者、学生及AI爱好者提供技术分享、协作创新和资源整合的空间。

分主题板块(如模型架构设计、算法优化、行业应用案例),支持代码片段分享、论文解读和技术争议探讨。定期举办“技术擂台”或“挑战赛”(如模型压缩竞赛、数据集标注比赛),激发创新思维。

同时,提供代码托管功能(类似GitHub集成),支持成员发布AI项目、工具库或插件,并通过社区协作迭代优化。设立“明星项目”推荐板块,帮助优质项目获得曝光和贡献者。

还有学习与资源中心,整理AI领域的经典教材、课程笔记、工具链指南(如PyTorch/TensorFlow实战手册),并附有社区成员的批注与实战反馈。开设“新手村”板块,提供从环境配置到模型训练的逐步教程,降低入门门槛。

分享AI在医疗、金融、游戏等垂直行业的落地案例,讨论技术适配与商业化挑战。设立“内推直通车”和“技能树自测”功能,帮助成员对接企业需求,提升就业竞争力。

魔塔AI社区不仅是技术交流的场所,更是一个推动AI技术民主化、产业化的生态平台。无论是寻求答案的学习者、寻找协作的开发者,还是希望探索商业机会的创业者,都能在这里找到对应的价值支点。

模型架构 (Model Architecture)

目前,绝大多数大型 LLM 都基于Transformer (注意力机制) 架构。Transformer 是一种特殊的神经网络,它有两个主要部分:

  • 编码器 (Encoder): 负责理解输入文本的上下文信息。
  • 解码器 (Decoder): 负责根据编码器的理解生成输出文本。

Transformer 的关键是自注意力机制 (Self-Attention)。它允许模型在处理一个词时,同时考虑输入序列中所有其他词的重要性,从而捕捉词与词之间的长距离依赖关系。

Transformer 架构图示:

运行环境

为什么现在的大模型对GPU需求这么高?

大模型之所以对 GPU(图形处理器) 有着“饥渴”般的需求,根本原因在于它们的架构特性计算模式与 GPU 的设计理念完美契合。

CPU 就像一个拥有几名博士的团队,擅长攻克复杂的独立难题;而 GPU 更像一个拥有成千上万名小学生的大部队,虽然每个小学生只能做简单的加减法,但他们可以同时处理数百万个简单的加减法,效率惊人。大模型的计算任务,恰恰就是这种“数百万个简单加减法”的集合。

GPU更擅长海量的并行计算

大模型,特别是深度神经网络,在训练和推理过程中会进行天文数字般的数学运算,核心就是大量的矩阵乘法(Matrix Multiplication)向量运算(Vector Operations)

  • 特点:这些运算的特点是高度并行,即许多独立的、相似的计算可以同时进行。例如,一个矩阵的每个元素或一个向量的每个分量可以并行地进行计算,互不干扰。
  • GPU 的优势:GPU 内部拥有成百上千甚至上万个小型计算核心(流处理器),它们天生就是为并行计算而设计的。CPU 只有少数几个强大的核心,擅长处理复杂且串行的任务;而 GPU 则能以“人海战术”的方式,同时处理数百万个简单计算。这使得 GPU 在执行矩阵乘法这类并行任务时,比 CPU 快上数十甚至数百倍。

在神经网络中,每一层神经元从前一层接收输入,然后通过与权重矩阵相乘来计算其输出。这个过程就是矩阵乘法。 矩阵乘法计算:一个 m X nA 行列式,和一个 n X tB 行列式可以进行乘法运算得到一个 C 行列式。拿 A 的第 i 行(n个元素)和 B 的第 j 列(n个元素)元素乘积之和,就是 C 的第 [i,j] 位的元素值。

运算参数量巨大

大模型的“大”体现在其巨量的参数(Parameters)上。这些参数就是模型从训练数据中学习到的“知识”和“权重”。一个大模型可能拥有数十亿、数千亿甚至上万亿个参数。

每次进行前向传播(推理)或反向传播(训练)时,每个输入数据点都必须与模型的所有相关参数进行交互计算。参数量越大,需要进行的乘加运算就越多。这些参数通常以巨大的矩阵形式存储,GPU 高效的并行处理能力是处理如此大规模矩阵运算的关键。

包含大量的浮点运算

深度学习中的计算,尤其是权重和激活值的计算,通常使用浮点数(Floating-point Numbers)。大模型需要执行大量的浮点数乘法和加法。GPU 在设计时就高度优化了浮点运算单元。例如,NVIDIA 的 Tensor Cores 就是专门为深度学习中的混合精度计算(FP16/BF16)而设计的,能够极大地加速这些浮点运算。

高内存带宽和内存容量

大模型不仅参数量大,每次处理的数据批次(batch size)也可能很大,这意味着在计算过程中需要频繁地读取和写入大量数据。GPU 需要快速地从其显存中读取模型参数和输入数据,并将中间结果写回显存。现代 GPU 配备了高速的 GDDR 显存(如 GDDR6 或 HBM),拥有比 CPU 系统内存高得多的内存带宽,可以快速地吞吐大量数据。同时,高端 GPU 也提供了数十 GB 甚至上百 GB 的显存容量来存储庞大的模型参数。

专门的软件和硬件优化(CUDA 和 Tensor Cores)

NVIDIA 不仅仅提供了强大的 GPU 硬件,还开发了 CUDA 这样的并行计算平台,以及专门针对深度学习的 cuDNN 等库。

  • CUDA:它让开发者能够更容易地编写在 GPU 上高效运行的代码,连接了上层软件和底层硬件。
  • Tensor Cores:在最新的 NVIDIA GPU 中,集成了专门用于加速深度学习矩阵乘法的 Tensor Cores,它们能够以极高的效率执行低精度浮点运算,进一步提升了大模型训练和推理的速度。
CUDA 是什么?

CUDA(Compute Unified Device Architecture,统一计算设备架构)是 NVIDIA 公司开发的一种并行计算平台和编程模型。简单来说,它是一套让软件能够利用 NVIDIA 图形处理器(GPU) 强大计算能力的工具和接口。CUDA 的核心思想是让你能够编写代码,并将其中适合并行计算的部分“卸载”到 GPU 上执行。

它主要通过以下几个方面实现:

  1. 编程模型
    • CUDA 提供了一种基于 C、C++、Fortran 等传统语言的扩展,允许开发者编写被称为 “核函数”(Kernels) 的代码。
    • 核函数是一段在 GPU 上并行执行的代码。当你在 CPU(称为 Host)上调用一个核函数时,它会被分发到 GPU(称为 Device)上的成千上万个线程中并行执行。
    • 你需要将计算任务分解成小的、独立的子任务,每个子任务由一个 GPU 线程处理。
  2. GPU 硬件抽象
    • CUDA 提供了一套 API(应用程序编程接口),让开发者能够直接控制 GPU 的内存和计算资源。
    • 它抽象了 GPU 复杂的硬件细节,让程序员可以用相对更高层的方式来编写并行代码,而无需深入了解 GPU 内部的微架构。
  3. 内存管理
    • CPU 和 GPU 有各自独立的内存空间。在使用 CUDA 时,你需要将数据从 CPU 内存(Host Memory)传输到 GPU 内存(Device Memory),在 GPU 上进行计算,然后再将结果传回 CPU 内存。CUDA 提供了相应的 API 来管理这些数据传输。
  4. 工具套件
    • NVIDIA 提供了一个完整的 CUDA Toolkit,里面包含了编译器(如 nvcc,用于编译 CUDA C/C++ 代码)、库(如 cuDNN 用于深度学习,cuBLAS 用于线性代数)、调试工具、性能分析工具等,帮助开发者编写、优化和部署 GPU 加速的应用。

性能受限设备如何运行大模型

即使没有高性能的独立 GPU 的设备也可以运行大模型,但这通常会有一些重要的限制和权衡

CPU 也能执行并行计算 ,现代 CPU 通常有多个核心,并且支持 SIMD (Single Instruction, Multiple Data) 指令集(如 Intel 的 AVX、SSE 指令集),这使得它们能够同时处理少量数据。当没有 GPU 时,机器学习框架(如 TensorFlow 或 PyTorch)会自动回退到使用 CPU 来执行所有的矩阵乘法和其他计算。这些框架的 CPU 版本也会进行高度优化,以尽可能利用 CPU 的并行能力。

量化和剪枝是让大模型在资源受限设备上运行的关键技术。

  • 量化 (Quantization):将模型中原来用 32 位浮点数(FP32)表示的权重和激活值,转换为更低精度的格式,如 16 位浮点数(FP16/BF16)、8 位整数(INT8)甚至 4 位整数(INT4)。可以让 内存占用大幅减少 ,模型文件更小,加载更快。同时,低精度运算所需的计算资源更少,CPU 处理起来更快。
  • 剪枝 (Pruning):移除模型中不重要或冗余的连接和神经元,从而减小模型的大小和计算量。

这些优化技术可以在保持模型大部分性能的同时,显著降低其对计算资源(包括 CPU)的需求。

相比于大模型的训练,推理阶段的计算量相对较小训练大模型需要极其强大的 GPU,因为它涉及数万亿次的参数更新,需要多次迭代和反向传播。而在 推理(Inference)阶段,即模型用于实际预测时,只需要进行前向传播。虽然计算量依然庞大,但比训练时少得多。对于量化后的模型,推理的计算需求会进一步降低。

在没有 GPU 的电脑上运行大模型也会面临一些问题。

首先就是推理速度会比有 GPU 的系统慢很多,尤其是对于较大的模型和批次处理。其次,在这些设备上训练大型模型几乎不可能,因为 CPU 的计算能力和内存带宽远远不足。

在性能受限的设备上通常只能运行经过高度优化、量化甚至剪枝的较小版本的大模型,原始的巨型模型无法加载或运行。由于几乎所有的计算任务都需要 CPU 来完成,CPU 满负荷运行时能耗较高,可能导致设备发热严重。

LLM内部组件和运行流程

一个大模型(特指大型语言模型 LLM)在运行时,其进程内部包含多个核心组件协同工作,才能完成从接收输入到生成输出的全过程。这些组件涵盖了数据处理、模型计算和结果输出等多个环节。

一个典型的大模型运行时进程通常会包含以下核心组件:

1. Tokenizer (分词器)

大模型无法直接理解人类的自然语言文本。分词器是模型处理文本的第一步,它将输入的原始文本(如句子、段落)分解成模型能够理解的最小单位,这些单位称为 “token”

Token 可以是单词、子词(例如 “un”、”happy” 组成 “unhappy”),甚至是单个字符。

分词器还将每个 token 映射到一个唯一的整数 ID

  • 示例: 输入 “Hello, world!” 可能会被分词为 ["Hello", ",", " ", "world", "!"],并转换为对应的 ID 序列 [15496, 11, 220, 995, 0]

正确的分词对于模型理解输入和生成连贯的输出至关重要。不同的模型可能使用不同的分词器。

2. Model Core (模型核心 / 神经网络)

这个组件是大模型的心脏,负责执行实际的推理计算。它是一个由数千亿甚至上万亿个参数组成的深度神经网络(通常基于 Transformer 架构)。

核心接收分词器输出的 token ID 序列作为输入。将这些 ID 转换为词嵌入(Word Embeddings),这是一种高维向量表示,捕捉了 token 的语义信息。通过多层 Transformer 块进行复杂的数学运算(主要是矩阵乘法和激活函数),处理这些嵌入,捕捉文本中的上下文关系、语法结构和语义含义。最终输出每个位置上下一个 token 预测的概率分布

  • Embedding Layer(嵌入层): 将 token ID 转换为高维向量。
  • Transformer Blocks(Transformer 块): 包含多头自注意力机制 (Multi-Head Self-Attention) 和前馈网络 (Feed-Forward Network),负责学习和转换输入序列的表示。
  • Output Layer(输出层): 将最终的隐藏状态转换为词汇表中每个 token 的概率。

3. Generation Strategy / Decoding Algorithm (生成策略 / 解码算法)

模型核心输出的是下一个 token 的概率分布,而生成策略则根据这些概率来实际选择下一个 token,并决定何时停止生成。这好比模型给出了所有可能的字词及其概率,而生成策略就是选择最合理的那一个。常见的生成策略有:

  • 贪婪搜索 (Greedy Search): 每次都选择概率最高的 token。
  • 束搜索 (Beam Search): 同时跟踪多个最有可能的序列路径。
  • Top-K / Top-P (Nucleus Sampling): 从概率最高的 K 个 token 或累计概率达到 P 的 token 中随机选择,引入多样性。
  • 温度(Temperature): 调整概率分布的“锐利度”,影响生成文本的随机性和创造性。

不同的生成策略会显著影响模型输出的质量、多样性和流畅性

4. Context Management (上下文管理)

大模型在生成文本时需要记住之前的对话或输入内容,这就是上下文(Context)。上下文管理组件负责维护和更新模型当前处理的上下文。将新生成的 token 添加到现有上下文中。在上下文过长时(超过模型的上下文窗口限制),需要执行截断滑动窗口等策略来管理,确保模型始终在有效范围内工作。

5. Output Handler (输出处理器)

将模型生成的新 token ID 转换回人类可读的文本。通过分词器的逆向操作(detokenization)转换回字符串。同时可能还包括对输出文本的格式化、清理或后处理,例如删除多余的空格、处理特殊字符等。

6. System/Hardware Interface (系统/硬件接口)

这个组件不是模型本身的一部分,但它是大模型进程运行的底层基础。它负责将模型核心的计算任务调度到硬件加速器(如 GPU 或 NPU)上执行,并管理数据在内存和显存之间的传输。比如利用 CUDA、cuDNN (NVIDIA GPU) 或其他 AI 加速器 SDK (如 Android 设备的 NPU 驱动)。可以优化数据加载和计算流,以最大化硬件利用率。如果缺乏强大的硬件接口和底层优化,即使有优秀的模型,也无法高效运行。

运行流程概览:

  1. 用户输入:用户输入文本(例如 “帮我写一首诗关于秋天。”)。
  2. 分词器处理:分词器将输入文本转换为 token ID 序列。
  3. 模型核心计算:token ID 序列被送入模型核心。模型进行前向传播计算,输出词汇表中下一个可能 token 的概率分布。
  4. 生成策略选择:生成策略根据概率分布选择最合适的下一个 token ID。
  5. 上下文更新:新选出的 token ID 被添加到当前上下文。
  6. 循环迭代:重复步骤 3-5,直到达到停止条件(例如生成了完整句子、达到最大 token 数或生成了停止符)。
  7. 输出处理器:将最终生成的 token ID 序列转换回人类可读的文本输出。

【AI】从简单模式识别到ChatGPT

【AI】从简单模式识别到ChatGPT

本文介绍了AI领域的不同范围的分层概念,以及现在强大的LLM是如何进化而来的

手动翻译自:

How Large Language Models work? From zero to ChatGPT

得益于 大型语言模型(Latge Language Models 简称LLM) 的迅速发展,人工智能领域如今几乎吸引了所有人的目光。

ChatGPT,或许是最著名的LLM,由于自然语言本身就是一种非常自然的交互方式,这使得人工智能领域的最新突破变得触手可及,因此其人气迅速飙升。然而,除非您是数据科学家或其他与AI相关的职位,否则对于LLM的工作原理仍然是不太清楚的。在本文中,我将尝试帮您改变这个现状。

要讲清楚很难,毕竟,我们今天拥有的强大的大模型(LLM)是数十年人工智能研究的结晶。遗憾的是,大多数涉及AI的文章都属于以下两种情况:要么技术性很强,需要大量的先验知识;要么内容过于琐碎,最终你并不会比之前获得更多收获。

本文旨在在这两种方法之间取得平衡。或者换个说法,它旨在带你从零开始,一路了解大模型(LLM)的培养方式,以及它们为何如此出色。我们将通过逐步梳理所有相关内容来实现这一点。

本文不会深入探讨所有细节,因此我们将尽可能地依靠直觉而非数学,并尽可能地使用视觉工具。正如你将看到的,虽然LLM在细节上确实非常复杂,但它背后的主要机制非常直观,仅凭这一点就能让我们走得更远。

本文也能帮助你更好地利用像 ChatGPT 这样的大模型 (LLM)。事实上,我们将学习一些巧妙的技巧,帮助你提高获得有用回复的几率。

但首先,让我们尝试了解大模型在人工智能领域中的地位。

AI领域分层概念

人工智能领域通常被形象地分为多个层次:

  • 人工智能(AI) 是一个非常宽泛的术语,但一般涉及智能机器。
  • 机器学习(ML) 是人工智能的一个子领域,专门研究数据中的模式识别。可以想象,一旦你识别出一种模式,你就可以将这种模式应用于新的观察。这就是该理念的精髓所在,不过我们稍后会讲到这一点。
  • 深度学习 是 ML 中专注于非结构化数据(包括文本和图像)的领域。它依赖于人工神经网络,这种方法的灵感(粗略地)来源于人脑。
  • 大型语言模型(LLM) 专门处理文本。

机器学习

机器学习的目标是发现数据中的模式。或者更具体地说,是描述 输入和结果之间关系 的模式。最好用一个例子来解释这一点。

音乐类型区分

假设我们想区分我最喜欢的两种音乐类型:雷鬼音乐和R&B

雷鬼音乐是一种拉丁都市音乐,以其活泼的节奏和适合跳舞的韵律而闻名;而 R&B(节奏布鲁斯)则是一种根植于非裔美国音乐传统的音乐类型,其特点是深情的唱腔以及轻快和慢节奏歌曲的融合。

假设我们有 20 首歌曲。我们知道每首歌曲的节奏和能量,这两个指标对于任何歌曲来说都可以轻松测量或计算。此外,我们还给每首歌曲贴上了不同的风格标签,例如雷鬼或 R&B。当我们将数据可视化时,我们可以看到,高能量、高节奏的歌曲主要属于雷鬼,而低节奏、低能量的歌曲主要属于 R&B,这很合理。

然而,我们希望避免总是手动标记音乐类型,因为这既耗时又不可扩展。相反,我们可以学习歌曲指标(节奏、能量)与音乐类型之间的关系,然后仅使用现有的指标进行预测

用机器学习的术语来说,我们称之为分类问题,因为结果变量(音乐类型)只能归类到一组固定的类别/标签之一,这里指的是雷鬼和 R&B。这与回归问题不同,回归问题的结果是连续值(例如温度或距离)。

现在,我们可以使用标记好的数据集(即使用一组我们已知类型的歌曲)来“训练”一个机器学习模型(或“分类器”) 。直观地说,模型的训练就是找到最能区分两个类别的界线。

这有什么用呢?嗯,既然我们知道了这条界线,对于任何一首新歌,我们都可以预测它是雷鬼音乐还是 R&B 歌曲,这取决于这首歌落在界线的哪一边。我们只需要节奏和能量,我们假设这些更容易获得。这比人工为每首歌分配类型要简单得多,也更具可扩展性。

此外,正如你所想象的,距离这条线越远,我们就越能确定预测的正确性。因此,我们通常也可以根据与这条线的距离来判断我们对预测的正确性的信心程度。例如,对于我们新的低能量、低节奏的歌曲,我们可能有 98% 的把握认为这是一首 R&B 歌曲,而只有 2% 的可能性认为它实际上是雷鬼音乐。

但当然,现实往往比这更复杂。

划分类别的最佳边界可能并非线性的。换句话说,输入和结果之间的关系可能更加复杂。它可能呈曲线状,甚至比曲线还复杂很多倍

现实里,评价的尺度也更多,也更加复杂,可能有数十、数百甚至数千个输入变量,而不是像我们示例中那样只有两个输入。此外,我们通常有两个以上的类别。所有的类别都可能通过一种极其复杂的非线性关系依赖于所有这些输入。

即使以我们的例子来说,我们也知道现实中音乐类型远不止两种,而且除了节奏和能量之外,我们还需要更多其他指标。它们之间的关系可能也没有那么简单。

我主要想让你记住的是:输入和输出之间的关系越复杂,我们用来学习这种关系的机器学习模型就越复杂、越强大。通常,复杂性会随着输入数量和类别数量的增加而增加。

除此之外,我们还需要更多数据。你很快就会明白为什么这很重要。

图片内容识别

现在我们来讨论一个略有不同的问题,但我们会尝试运用之前的思维模型。在新问题中,我们输入一张图片,例如这张包里有一只可爱的猫的图片(因为有猫的例子总是最好的)。

至于结果,假设这次我们有三种可能的标签:老虎、猫和狐狸。如果你需要一些动机来完成这项任务,假设我们想要保护一群羊,如果看到老虎就发出警报,但如果看到猫或狐狸就不发出警报。

我们已经知道这又是一个分类任务,因为输出只能属于几个固定的类别之一。因此,就像之前一样,我们可以简单地使用一些可用的标记数据(即带有指定类别标签的图像)来训练机器学习模型。

然而,由于计算机只能处理数字输入,我们究竟如何处理视觉输入呢?

当然,我们的歌曲指标——能量和节奏——都是数字。幸运的是,图像也只是数字输入,因为它们由像素组成。它们有高度、宽度和三个通道(红、绿、蓝)。因此,理论上,我们可以直接将像素输入机器学习模型(暂时忽略这里的空间元素,我们之前没有处理过)。

然而,现在我们面临两个问题。首先,即使是一张小尺寸、低质量的 224x224 图像,其像素也超过 15 万(224x224x3)。记住,我们之前讨论的是输入变量最多只有几百个(很少超过一千个),但现在突然就至少有 15 万个了。

其次,如果你思考原始像素和类别标签之间的关系,就会发现它极其复杂,至少从机器学习的角度来看是如此。我们人类的大脑拥有惊人的能力,通常能够轻松区分老虎、狐狸和猫。然而,如果你逐个查看这 15 万像素,你根本无法知道图像中包含什么。而这正是机器学习模型看待它们的方式,因此它需要从头学习这些原始像素与图像标签之间的映射关系,这并非易事。

句子情感感知

让我们考虑另一种极其复杂的输入输出关系——句子与其情感之间的关系。我们所说的情感通常是指句子所传达的情感,可以是积极的,也可以是消极的。

让我们再次形式化地描述问题设置:这里的输入是一系列单词,也就是一个句子,情绪是我们的结果变量。和之前一样,这是一个分类任务,这次的输出目标有两个可能的标签,即正面或负面

与前面讨论的图像示例一样,作为人类,我们自然地理解这种关系,但是我们能否教会机器学习模型做同样的事情呢?

在回答这个问题之前,我们首先要明确,如何将单词转换为机器学习模型的数字输入。事实上,这比我们在图像中看到的情况要复杂一到两个层次,因为图像本质上已经是数字了,但单词的情况并非如此。我们在这里就不赘述了,但你需要知道的是,每个单词都可以转换成一个词向量

简而言之,词向量表示单词在特定语境下的语义和句法意义。这些词向量可以在机器学习模型训练过程中获得,也可以通过单独的训练过程获得。通常,每个单词的词向量包含数万个变量

总而言之,我们可以将一个句子转换成一系列数字输入,即词向量,它包含语义和句法含义。然后,我们可以将其输入到机器学习模型中。

很好,我们把一个句子成功转换为了数字输入,但是仍然面临和视觉输入相同的挑战。可以想象,对于一个长句子(或段落,甚至整个文档),由于词向量的规模很大,我们就会面临大量的输入。

第二个问题是语言与其情感之间的关系,这很复杂——非常复杂。举个例子:

“那真是一次伟大的跌倒”

这样的句子,它可能会有很多种解读方式(更不用说讽刺了)。

我们需要的是极其强大的机器学习模型和海量数据。这正是深度学习的用武之地。

深度学习

通过了解机器学习的基础知识和使用更强大模型背后的动机,我们已经朝着理解 LLM 迈出了重要一步,现在我们将继续尝试介绍深度学习。

我们讨论过,如果输入和输出之间的关系非常复杂,并且输入或输出变量的数量很大(之前的图像和语言示例就属于这种情况),我们就需要更灵活、更强大的模型。我们之前提到的线性模型或任何类似的模型都无法解决这类视觉或情感分类任务

这就是 神经网络(Neural networks) 发挥作用的地方。

神经网络是强大的机器学习模型,能够对任意复杂的关系进行建模。它们是大规模学习此类复杂关系的引擎。

事实上,神经网络大致上受到了人类大脑的启发,尽管两者之间的相似之处尚有争议。它们的基本架构相对简单。它们由一系列相互连接的“神经元”层组成,输入信号经过这些神经元层来预测结果变量。你可以将它们想象成 堆叠在一起的多层线性回归,并在其间添加非线性,这使得神经网络能够模拟高度非线性的关系

神经网络通常具有多层深度(因此称为深度学习),这意味着它们可以非常大。例如,ChatGPT 的背后,是一个由 1760 亿个神经元组成的神经网络,这比人类大脑中大约 1000 亿个神经元还要多。

因此,从这里开始我们将使用神经网络作为我们的机器学习模型,并考虑到我们还学习了如何处理图像和文本。

大语言模型LLM

最后,我们可以开始讨论大型语言模型了,这才是真正有趣的地方。如果你已经读到这里,那么你应该已经具备了理解 LLM 所需的所有知识。

有什么好的开始方式吗?或许可以先解释一下大型语言模型的真正含义。我们已经知道 “大型” 的含义,在这里,它仅仅指的是神经网络中神经元(也称为参数)的数量。大型语言模型的构成并没有明确的数字,但你可以将超过 10 亿个神经元的模型视为大型模型。

既然已经明确了这一点,那么“语言模型”又是什么呢? 接下来我们来讨论一下。并且,我们稍后还会了解 ChatGPT 中的 GPT 代表什么。不过,我们还是一步一步来吧。

让我们将以下想法构建成一个机器学习问题:

给定一个词序列,例如输入一个一个句子或段落,输出下一个可能的词是什么?

换句话说,我们只是想学习如何随时预测下一个词。此前,我们已经学习了如何将一个句子转换陈数字输入,并判断其情绪。事实上,这个任务与我们之前看到的情绪分类并无二致。

就像那个例子一样,神经网络的输入是一系列单词,但现在,输出是预测下一个单词。同样,这只是一个分类任务。唯一的区别在于,我们不再只有两个或几个类别,而是拥有与单词数量一样多的类别——假设大约 50,000 个。这就是语言建模的意义所在——学习预测下一个单词。

好吧,你可以想象,这比二元情绪分类要复杂几个数量级。但既然我们也了解了神经网络及其强大的威力,对这个问题唯一的回应其实就是“为什么不呢?”

快速免责声明:我们在这里简化了很多内容,实际上情况要复杂一些,但这不应该妨碍我们理解主要机制,因此我们简化并省略了一些细节。

我们知道了任务,现在我们需要数据来训练神经网络。实际上,为我们的“下一个单词预测”任务创建大量数据并不难:互联网、书籍、研究论文等等都拥有丰富的文本。我们可以轻松地从所有这些文本中创建一个庞大的数据集。我们甚至不需要标记数据,因为下一个单词本身就是标签,这就是为什么它也被称为自监督学习。

上图展示了这个过程。一个序列可以转换成多个序列进行训练。我们有很多这样的序列。重要的是,我们对许多短序列和长序列(有些序列长达数千个单词)都进行了同样的操作,以便在每个上下文中都能学习到下一个单词应该是什么。

总而言之,我们在这里所做的就是训练一个神经网络(LLM)来预测给定单词序列中的下一个单词,无论该序列是长是短,是德语、英语还是其他任何语言,无论是推文、数学公式、诗歌还是代码片段。所有这些序列都可以在训练数据中找到。

如果我们拥有足够大的神经网络和足够的数据,LLM 就能非常擅长预测下一个单词。它会完美吗?当然不会,因为一个序列后面通常有多个单词。但它会擅长选择一个在句法和语义上都合适的单词。

现在我们的神经网络可以根据一段文本来预测下一个单词了,我们可以将扩展后的文本再次反馈给LLM,预测另一个单词,以此类推。

换句话说,使用我们训练好的LLM,我们现在可以生成文本,而不仅仅是一个单词。这就是为什么LLM是我们所说的生成式人工智能的一个例子。我们刚刚教会了LLM说话,也就是说,一次一个单词。

我认为还有一个细节需要理解。我们不一定总是要预测出最准确的单词。我们可以让模型在给定时间内,从最可能一些单词中选取出5个可能的结果。因此,我们或许可以从大模型(LLM)中获得更多创造力。有些大模型实际上允许你选择输出的确定性或创造性程度(temperature)。这也是为什么在使用这种取样策略的ChatGPT中,你重新生成响应时通常不会得到相同的答案。

说到 ChatGPT,你现在可能会问,为什么它不叫 ChatLLM。事实证明,语言建模(Language Models)并非故事的终点——事实上,它只是一个开始。那么,ChatGPT 中的 GPT 到底代表什么呢?

GPT

实际上,我们刚刚了解了 G 的含义,即 “generative(生成式)” ——这意味着它是以语言生成为目的进行训练的,这一点我们之前已经讨论过了。那么 P 和 T 呢?

这里我们先简单聊一下 T ,它代表 Transformer(转换器)。这个可不是说的不是电影里的那个(变形金刚就叫Transformer),而是指目前所使用的一种神经网络架构

Transformer架构的的主要优势是什么呢? 那么Transformer架构之所以如此高效,是因为它能够随时将注意力集中在输入序列中最相关的部分。 你可能会说这与人类的工作方式类似。我们也需要将注意力集中在与任务最相关的部分,而忽略其他部分。

现在来谈谈 P ,它代表 “pre-training(预训练)” 。接下来,我们将讨论为什么我们突然开始谈论“预训练”,而不是仅仅谈论“训练”。

原因是,像 ChatGPT 这样的大型语言模型实际上是分阶段训练的。

(1)预训练,(2)指令微调,(3)人工反馈强化(RLHF)。

预训练

第一阶段是预训练,也就是我们刚才经历的阶段。这个阶段需要大量的数据来学习预测下一个单词。在这个阶段,模型不仅要学习掌握语言的语法和句法,还要获得大量关于世界的知识,甚至一些我们稍后会谈到的新兴能力。

但现在我有几个问题想问你:首先,这种预训练可能存在什么问题?当然存在一些问题,但我在这里想指出的一个问题是LLM到底学到了什么。

也就是说,它主要学会了如何滔滔不绝地谈论某个话题。它甚至可能做得非常好,但它无法很好地响应你通常想给人工智能的输入,比如一个问题或一个指令。问题在于,这个模型还没有学会如何成为一个助手,因此它的行为也并非助手。

例如,如果你问一个预训练的大模型

“你的名字是什么?”

它可能会回答

“你的姓氏是什么?”

仅仅是因为它在预训练过程中见过这种类型的数据,例如在许多空表格中。它只是试图完成输入序列。

它在遵循指令方面表现不佳,仅仅是因为这种一问一答式的语言结构在训练数据中并不常见。 或许 Quora 或 StackOverflow 是最接近这种结构的代表。

在这个阶段,我们认为 LLM 与人类意图不一致。一致性是 LLM 的一个重要课题,我们将学习如何在很大程度上解决这个问题,因为事实证明,这些预先训练好的 LLM 实际上非常易于操控。所以,即使它们最初对指令的反应不太好,它们也可以被训练来做到这一点。

指令微调和 RLHF

这就是指令调整的用武之地。我们基于预先训练的 LLM 当前的能力,再次让他做同样的输入和输出训练,即学习一次预测一个单词。不一样的是,我们只使用高质量的指令和响应对作为我们的训练数据。

这样,模型就不再只是学习完成文本,而是学习成为一个有用的助手,能够遵循指令并以符合用户意图的方式做出响应。该指令数据集的大小通常比预训练集小得多。这是因为高质量的指令-响应对的创建成本要高得多,因为它们通常来自人工。这与我们在预训练中使用的廉价自监督标签截然不同。这就是为什么这个阶段也称为监督指令微调

有些大模型(比如 ChatGPT)还会经历第三个阶段,即人类反馈强化学习(RLHF)。我们在此不再赘述,但其目的与指令微调类似。强化学习高频 (RLHF) 也有助于对齐,并确保大模型 (LLM) 的输出能够反映人类的价值观和偏好。一些早期研究表明,这一阶段对于达到或超越人类水平的表现至关重要。事实上,强化学习和语言建模领域的结合已被证明前景广阔,并可能带来比我们现有的大模型 (LLM) 更显著的改进。

三个有趣的问题

现在让我们通过三个问题测试一下我们的理解。

第一个问题,为什么 LLM 可以对较长的文本进行摘要?

如果您还不知道,它确实做得很棒。只需粘贴一份文档并让它进行总结即可。

要理解其中的原因,我们需要思考一下训练数据。事实上,人们经常在互联网、研究论文、书籍等等中进行总结。因此,用这些数据训练的大模型(LLM)也学会了如何做到这一点。它学会了关注要点,并将其压缩成简短的文本。

请注意,在生成摘要这个用例中,待总结的全文不是全部,只是LLM输入序列的一部分。这个输入的结构类似于一篇研究论文,它前面是正文,而后面是全文总结。

因此,该技能可能在预训练阶段就已经习得,尽管指令微调肯定有助于进一步提升该技能。我们可以假设此阶段也包含了一些摘要示例。

第二,为什么LLM可以回答常识性的问题?

如上所述,能够充当助手并做出适当响应的能力归功于指令微调和 RLHF。但所有(或大部分)回答问题的知识本身在预训练期间就已经获得了。

当然,这又引出了我们的第三个问题。

第三,如果大模型(LLM)不知道答案怎么办?不幸的是,在这种情况下,他们可能会编造一个答案。

要理解其中的原因,我们需要重新思考训练数据和训练目标。

问题:LLM 接受的训练是生成人类文字,而不是真实文字。

解决思路:我们需要让它们 “立足 “于现实,这样它们就不会胡编乱造。

措施:将相关知识纳入LLM的上下文中。

LLM 可能会出现幻觉,但可以通过提供额外的背景信息来缓解。“幻觉”一词,它指的是大模型 (LLM) 编造不该编造的事实的现象。

为什么会这样?因为 LLM 学习的只是生成文本,而不是生成符合事实的真实文本。 它的训练过程中,并没有为模型提供任何关于训练数据真实性或可靠性的指标。然而,这也不是主要问题,问题在于互联网和书籍上的文本通常听起来都很有说服力,即使它是错误的,所以 LLM 当然也会学习到这种做法。这样一来,从LLM 的角度出发,几乎看不到任何关于内容不确定性的迹象。

话虽如此,这是一个活跃的研究领域,我们可以预期,随着时间的推移,LLM 的幻觉倾向会降低。例如,在教学调整过程中,我们可以尝试教会 LLM 在一定程度上避免产生幻觉,但能否完全解决这个问题,只有时间才能证明。

你可能会惊讶,我们现在居然可以一起尝试解决这个问题。我们拥有所需的知识,能够找到一个至少能在一定程度上提供帮助、并且目前已被广泛应用的解决方案。

Bing 聊天是基于搜索的 LLM 工作流程的一个示例。

假设你问大模型(LLM)这个问题: 哥伦比亚现任总统是谁? 大模型很可能会答错。这可能有两个原因:

  • 第一个是我们已经提到的:LLM 可能只是出现幻觉,并简单地用错误的名字甚至是假名来回应。
  • 第二个我只想顺便提一下:大模型(LLM)的训练数据仅限于某个截止日期,最早可能是去年的数据。正因如此,LLM 甚至无法确切地知道现任总统是谁,因为自数据创建以来,情况可能已经发生了变化

那么,我们该如何解决这两个问题呢?答案在于为模型提供一些相关的上下文。其原理是,在 LLM 输入序列中的内容比它在预训练过程中获得的任何隐性知识都更容易被模型处理,容易检索,且更稳定。 就像让他从一个已知的数组中找出最大的一个数字一样。

假设我们将维基百科上关于哥伦比亚政治史的文章作为大模型课程的背景信息。在这种情况下,它更有可能正确回答问题,因为它可以直接从上下文中提取姓名(前提是上下文是最新的,并且包含现任总统)。

在上图中,你可以看到带有附加上下文的 LLM 典型提示是什么样子的。(顺便说一下,prompt(提示)只是我们给 LLM 的指令的另一种说法,即构成输入序列的指令。)

这个过程被称为将 LLM 置于上下文中,或者如果你愿意的话,置于现实世界中,而不是让它自由生成。

这正是 Bing Chat 和其他基于搜索的 LLM 的工作原理。它们首先使用搜索引擎从网络中提取相关上下文,然后将所有信息连同用户的初始问题一起传递给 LLM。上图直观地展示了这个过程是如何实现的。

回到AI魔法

现在你基本上了解了最先进的 LLM 的主要机制(无论如何,截至 2023 年下半年)。

你可能会想“这其实没什么神奇的”,这只是一次预测一个单词而已。毕竟,这只是纯粹的统计数据。事实真的如此吗?

让我们回顾一下。这一切的神奇之处在于它的效果如此出色

事实上,每个人,甚至是 OpenAI 的研究人员,都对这种语言建模的进展感到惊讶。过去几年,其发展如此迅猛的关键因素之一就是神经网络和数据集的大规模扩展,这推动了 LLM 表现的提升。例如,据报道,GPT-4 的参数总数超过一万亿个,它能够以前 10% 的成绩通过律师资格考试或 AP 生物学考试。

令人惊讶的是,这些大模型甚至表现出某些新兴能力,即解决任务和完成一些他们没有被训练过的事情。

在本文的最后一部分,我们将讨论一些新兴的能力,并向您展示一些如何使用它们来解决问题的技巧。

零样本和少量样本学习

LLM 可以以零样本方式解决全新的任务。正如其名称所示,LLM 正在兴起一种普遍存在的能力:它可以执行训练中从未遇到过的全新任务,这被称为零样本(zero-shot)。它所需要的只是一些关于如何解决任务的指令。

为了用一个愚蠢的例子来说明这种能力,你可以要求大模型将一个句子从德语翻译成英语,同时句子中的每个单子都要以字母 f 开头。

例如,我们测试的其中一个大模型将

“Die Katze schläft gerne in der Box”(德语,字面意思是“猫喜欢睡在盒子里”)

翻译成了

“Feline friend finds fluffy fortress”

我认为这是一个非常酷的翻译。

对于更复杂的任务,您可能很快就会意识到零样本提示通常需要非常详细的说明,即使这样,性能也往往远非完美。

大模型就像人类一样,可以从示例或演示中提取有用的信息。如果有人要求你执行一项全新的任务,你可能会要求提供一些示例或演示来说明如何执行该任务。大模型(LLM)也一样。

举个例子,假设你想要一个模型将不同的货币金额转换为通用格式。你可以详细描述你的目标,或者只给出一个简短的说明和一些示例演示。上图展示了一个示例任务。

使用这个提示,模型应该在最后一个例子上表现良好,即“牛排:24.99 美元”,并用 24.99 美元回答。

注意我们是如何省略掉上一个例子的答案的。记住,LLM 本质上仍然是一个文本补全器,所以想要输出中保持一致的结构,你应该几乎强制模型只给出你想要的结果,就像我们在上面的例子中所做的那样。

总而言之,如果LLM在零样本学习中遇到困难,一个通用的建议是提供一些示例。你会发现,这通常有助于LLM理解任务,从而使其输出的效果更好、更可靠。

长思维链

思路链为大模型 (LLM) 提供了工作记忆,这可以大大提高他们的表现,特别是在更复杂的任务上。这也让人联想到人类智能,当任务更加复杂,需要多步推理才能解决时,推理思路链尤其有用。

假设我问你

“莱昂内尔·梅西出生前一年谁赢得了世界杯?”

你会怎么做?你可能会一步一步地解决这个问题,写下所有需要的中间解,直到得出正确答案。先找到梅西的生日,再找到对应前一年的这届世界杯冠军是哪个国家。而这正是大模型(LLM)也可以做到的。

研究发现,只要简单地告诉大模型“一步一步思考”,就能在许多任务中显著提高其表现。

为什么这样做有效?我们已经知道了回答这个问题所需的一切。问题在于,这种不寻常的复合知识可能并不直接存在于大模型(LLM)的记忆中。 然而,所有单独的事实,比如梅西的生日,以及历届世界杯的冠军,都可能存在。

让 LLM 逐渐形成最终答案是有帮助的,因为它给了模型时间去大声思考——可以说是工作记忆——并在给出最终答案之前解决更简单的子问题。

这里的关键在于记住,待生成单词左侧的所有内容都是模型可以依赖的上下文。因此,如上图所示,当模型说出答案 “阿根廷” 时,梅西的生日查询到的世界杯年份 的数据已经存在于 LLM 的工作记忆中,这使得模型更容易正确回答。

结论

在结束之前,我想回答我在文章前面提出的一个问题。LLM 真的只是预测下一个单词,还是它还有其他功能?一些研究人员支持后者,他们认为,LLM 在任何情况下都如此擅长预测下一个单词,实际上一定是在内部获得了对世界的压缩理解。而不是像其他人所说的那样,模型只是学会了记忆和复制在训练中看到的模式,而没有真正理解语言、世界或其他任何东西。

目前来看,这两种观点或许并没有绝对的对错;或许只是看待同一事物的不同视角。显然,这些大模型已被证明非常有用,展现出令人印象深刻的知识和推理能力,甚至可能展现出一些通用智能体的火花。但它们是否或在多大程度上与人类智能相似仍有待确定,语言建模还能在多大程度上提升现有水平也同样有待确定。


我希望本文能帮助你了解大模型(LLM)及其当前的热潮,从而对人工智能的潜力和风险形成自己的见解。如何利用人工智能造福世界,不仅取决于人工智能研究人员和数据科学家;每个人都应该拥有发言权。正因如此,我才想写一篇不需要太多背景知识的文章。

如果您读完了这篇文章,我想您就大致了解一些最先进的 LLM 是如何运作的(截至 2023 年秋季),至少在大体上是这样。

【AI】激活函数

【AI】激活函数

本文介绍了若干种常见的AI领域的算法及其应用场景

简单来说,激活函数的主要作用是 向神经网络中引入非线性因素 。可以将激活函数理解为神经网络中一个至关重要的“开关”和“调节器”。它被 应用在每个神经元的输出 上,用来决定这个神经元应该在多大程度上被“激活”,以及它应该向下一层传递多强的信号。

在人工神经网络中,一个神经元会接收来自上一层的多个输入信号。它会首先将这些输入信号进行 “加权求和” ,并加上一个 偏置项

这个加权求和的结果是一个线性的值。 \[z = \sum(weight \cdot input) + bias\]

激活函数 f(z) 就是紧接着应用在这个线性结果 z 上的一个非线性函数。它会产生该神经元的最终输出 a = f(z) ,这个输出 a 随后会作为输入传递给网络的下一层。

这一点至关重要。想象一下,如果没有激活函数会发生什么?

  • 每一层的输出都只是上一层输入的线性组合。
  • 无论你堆叠多少层神经网络,整个网络的最终输出也仍然只是最开始输入的线性组合。
  • 这样的网络,无论多深,其能力都等同于一个单层的线性模型(比如线性回归或逻辑回归)。
  • 它将完全无法学习和拟合现实世界中复杂的非线性关系(例如图像识别、语音识别等)。

激活函数 被应用到每个神经元的输出上,对加权求和后的结果进行一次 非线性变换 。正是这种非线性变换,使得神经网络能够:

  1. 拟合复杂模式:能够学习和逼近几乎任何复杂的非线性函数,从而处理像图像识别、自然语言处理这样复杂的问题。
  2. 增强网络能力:赋予了网络更强的表达能力,使其能够区分和学习那些线性模型无法区分的数据特征。
  3. 控制输出范围:某些激活函数(如 Sigmoid)可以将输出值压缩到特定范围内(例如 0 到 1),这在特定任务中(如概率预测)非常有用。

反向传播

正向传播反向传播是神经网络训练过程中相辅相成的两个阶段。

正向传播 (Forward Propagation)是模型进行预测的过程。数据从输入层“正向”流到输出层,得出一个预测结果。

反向传播 (Backpropagation)是模型学习和修正错误的过程。根据预测结果和真实答案之间的“误差”,从输出层 “反向”计算每个权重参数(W)应该如何调整

一个生动的比喻是学生考试和老师批改

  • 正向传播过程:你(模型)拿到考卷(输入数据),凭你当前的知识(权重 W),从第一题做到最后一题,最后给出一个完整的答案(预测值)。
  • 反向传播:老师(算法)拿到你的答案,和标准答案(真实标签)进行对比,计算出你错得有多离谱(计算损失 L)。然后,老师从最后一题开始,反向分析:“你这道题错了(输出层的梯度),是因为你上一步的这个公式用错了,而这个公式用错,又是因为你最开始的那个定义就没背对(更早隐藏层的梯度)…”。老师就这样一步步 把“错误”的责任分摊 给你知识体系中的每一个知识点(W),并告诉你每个知识点具体该怎么修正,这就是:
\[\frac{\partial L}{\partial W}\]

损失函数(L)对权重(W)的偏导数

\[\frac{\partial L}{\partial W}\]

的意思是 “损失函数(L)”对“权重(W)”的偏导数

它代表的是 “梯度”(Gradient) ,用来衡量 “当权重 W 发生一个极小的变化时,损失 L 会相应地发生多大变化”

这个值是神经网络训练(“学习”)的根本依据。

  • L:代表损失函数 (Loss Function)
    • “损失”是一个数字,它用来衡量你的模型(比如一个神经网络)预测得有多“糟糕”
    • 如果 L 很大,说明模型预测的结果和真实答案相差很远。
    • 如果 L 很小(接近0),说明模型预测得非常准确。
    • 训练模型的目标,就是最小化这个 L 值。
  • W:代表权重 (Weights)
    • “权重”是神经网络中的参数。你可以把它们想象成网络中神经元之间连接的“强度”。
    • 模型如何从输入得到最终的预测结果,完全是由这些 W 值决定的。
    • 模型“学习”的过程,实际上就是不断调整和优化所有 W 值的过程。
\[\frac{\partial}{\partial W}\]

代表偏导数 (Partial Derivative),在神经网络中,L(损失)的值是 由成千上万个不同的 W(权重)共同决定的

偏导数的作用就是,单独衡量 L 是如何受到某一个特定权重 W 影响的。它在问一个问题:“如果我们只把这一个 W 增加一点点,同时保持所有其他权重不变,那么 L 会增加还是减少?变化的幅度有多大?”

详细解释“反向传播”

反向传播的目标是计算出每个权重 W 到底对最终的“错误”负有多大责任,即计算梯度 \[\frac{\partial L}{\partial W}\]

以便在下一步更新它们。

数据(梯度)流向是从 输出 -> 隐藏层 -> 输入

损失函数和权重的偏导数它通常和梯度下降(Gradient Descent) 算法协同工作。

  1. 计算损失 (Loss)
    • 首先,我们比较模型的预测值(来自正向传播)和真实答案(即标签)。
    • 使用损失函数 L(例如交叉熵)来计算出一个分数,这个分数代表模型“错得有多离谱”。
  2. 计算输出层的梯度
    • 反向传播从 L 开始
    • 它首先计算 L 相对于最后一层的权重 W_last 的偏导数。这告诉我们 最后一层的 W 应该如何调整。
  3. 利用“链式法则”反向传播: 这是最关键的一步。算法使用微积分中的 链式法则 ,将“错误梯度” 从后一层反向传播到前一层。它会计算: \[\frac{\partial L}{\partial W_{\text{hidden}}}\]

    这个计算会依赖于它后面一层(即 W_last )的梯度。这个过程就像在说:“最后一层对错误负有 X 的责任,而你(当前层)对最后一层的输出负有 Y 的责任,所以你对总错误负有 X · Y 的责任。”

    这个过程一直重复,直到计算出网络中 每一个 W 对应的偏导数

  4. 更新所有权重(梯度下降步骤): 一旦反向传播计算出了所有 W 的梯度,优化器(如梯度下降)就会使用这个信息来更新所有的 W。更新公式: \[W_{\text{new}} = W_{\text{old}} - \text{learning\_rate} \cdot \frac{\partial L}{\partial W}\]

    (减去梯度,是因为梯度指向 L 增加最快的方向,所以我们要朝相反方向去最小化 L)。

一句话总结:反向传播是模型在“复盘”或“订正错误”,它找出每个 W 犯了多少错,并告诉 W 应该朝哪个方向改正。

激活函数分类和优缺点

以下是几种最常用和最重要的激活函数,包括它们的公式、特点以及优缺点。激活函数分类图表:

1. Sigmoid (Logistic) 函数

对应的数学模型也叫 逻辑回归 模型,公式: \[f(x) = \frac{1}{1 + e^{-x}}\]

它能将输入值“压缩”到 0 和 1 之间,输出平滑且易于求导。在早期神经网络中很流行,常用于二元分类任务的输出层(输出概率)。

缺点

  1. 梯度消失:当输入值非常大或非常小时,函数的导数(梯度)趋近于 0。在反向传播过程中,误差(损失)的梯度需要从输出层一路“传播”回输入层,以便更新每一层的权重。梯度消失就是指,在传播过程中,梯度信号变得越来越小,当传到网络的浅层(靠近输入的层)时,梯度已经小到几乎为零。
  2. 输出非零中心:输出始终为正数(0到1),这会导致后续网络层的输入是非零均值的,这使网络的收敛速度变慢,可能降低训练效率。
  • Sigmoid 的输出:始终在 (0, 1) 区间,恒为正数
  • 对下一层的影响:在反向传播中,某一层的权重 W 的梯度,会包含来自上一层的输入 x(即 Sigmoid 的输出)。
\[\frac{\partial L}{\partial W} = \text{(上游梯度)} \times x\]

由于 x(Sigmoid 的输出)始终为正,导致偏导数的 所有分量的符号(正或负)都完全取决于上游梯度

这会导致权重在更新时,要么 所有的权重都一起增加,要么所有的权重都一起减小 。这限制了梯度下降的寻优路径,使其只能呈“Z”字形(ZigZag)下降,收敛效率低下。

相比之下, Tanh (输出为 -1 到 1,零中心)在这个问题上表现更好。而 ReLUf(x) = max(0, x) )则极大地解决了另外一个梯度消失问题(在正区间,导数恒为 1),因此成为了现在最主流的激活函数。

2. Tanh (双曲正切) 函数

公式: \[f(x) = \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}}\]

它将输入值“压缩”到 -1 和 1 之间。与 Sigmoid 相比,它的 输出是零中心 的(均值为 0),这通常能带来更快的收敛速度。

缺点是仍然存在 梯度消失 的问题,当输入值饱和时(接近 -1 或 1),梯度也会趋近于 0。

3. ReLU (Rectified Linear Unit, 修正线性单元)

公式: \[f(x) = \max(0, x)\]

解决了梯度消失(在正区间):当输入 x > 0 时,导数恒为 1,这极大地缓解了梯度消失问题,使得训练深度网络成为可能。

计算非常简单(只是一个阈值判断),比 Sigmoid 和 Tanh 的指数运算快得多。

当输入 x < 0 时,输出为 0,这能使网络中的一些神经元“关闭”,带来稀疏性,可能 有助于提取特征和防止过拟合

缺点是 Dying ReLU(神经元死亡) 如果一个神经元的输入在训练过程中始终为负数,那么它的输出将永远是 0,梯度也永远是 0。这个神经元将停止学习和更新。

4. Leaky ReLU (LReLU, 泄露型 ReLU)

公式: \[f(x) = \max(\alpha x, x)\]

(其中 alpha 是一个很小的常数,如 0.01)

为了解决 “Dying ReLU” 神经元死亡问题而设计。当输入 x < 0 时,它不再输出 0,而是 输出一个非常小的正值 (如 0.01x),从而保证了在负区间的梯度不为零。

PReLU(Parametric ReLU)Leaky ReLU 的一个变种,alpha 不是固定的,而是作为一个参数通过网络训练学习得到。

5. ELU (Exponential Linear Unit, 指数线性单元)

公式: \[f(x) = \begin{cases} x & \text{if } x > 0 \\ \alpha(e^x - 1) & \text{if } x \le 0 \end{cases}\]

融合了 ReLULeaky ReLU 的优点。它在负区间有输出(避免神经元死亡),且输出均值接近于 0(类似 Tanh),有助于加速学习。在负区间的“软饱和”特性使其对噪声有一定的鲁棒性。

缺点是计算上比 ReLU 复杂(涉及指数运算)。

6. Softmax 函数

公式: \[f(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}}\]

严格来说,它更像是一个“归一化”函数,而非隐藏层的激活函数。它 专门用于多分类问题的输出层

它能将一个包含任意实数的向量,转换成一个“概率分布”向量。向量中所有元素的和为 1,且每个元素都在 0 和 1 之间,可以被解释为该样本属于各个类别的概率。

Softmax 没有一个统一的函数图,通常以概率分布柱状图来表示其输出结果。

7. Maxout 函数

Maxout 可以作为神经网络的隐藏层激活函数

隐藏层位于输入层和输出层之间。之所以被称为“隐藏”,是因为它的输入和输出既不是直接来自外部世界,也不是直接输出到外部世界,而是网络内部进行信息处理和转换的地方。一个神经网络可以有零个(例如简单的线性模型)或多个隐藏层。拥有多个隐藏层的网络称为深度神经网络(Deep Neural Network)。

Maxout 不是一个固定的非线性函数(如 ReLUSigmoid ),而是一个 可学习 的激活函数。它将输入分成若干组,然后输出每组中的最大值。 \[h(\mathbf{x}) = \max_{j \in [1, k]} (\mathbf{w}_{j}^T \mathbf{x} + b_j)\]

其中 x 是输入,w_j 和 b_j 是可学习的参数, k 是组的大小(通常称为 piece 或 unit 的数量)。

Maxout 具有 分段线性 的特性,并且可以 近似任何凸函数 是由多条直线段组成的折线图,形状上是一个凸函数。它在一定程度上避免了 ReLU 的“神经元死亡”问题,但在计算上和参数数量上会更复杂。

如何选择激活函数?

在现代深度学习实践中(特别是作为 Android 开发者,你可能接触到的 TFLite 模型中):

  • 首选 ReLU:在绝大多数情况下,ReLU 是隐藏层的默认和首选。它简单、高效,并且效果很好。
  • 尝试 ReLU 变体:如果发现 ReLU 导致了大量的“神经元死亡”,可以尝试使用 Leaky ReLUPReLUELU 作为替代。
  • 用于输出层
    • 二元分类(是/否):使用 Sigmoid
    • 多元分类(猫/狗/鸟):使用 Softmax
    • 回归任务(预测一个连续值,如房价):不使用激活函数(即线性输出)。
  • TanhSigmoid 现在较少用于深度网络的隐藏层,但在某些特定架构(如循环神经网络 RNN)中仍会见到 Tanh。

【AI】常见AI算法

【AI】常见AI算法

本文介绍了若干种常见的AI领域的算法及其应用场景

前言

人工智能在GPT吹响冲锋号之后,突然迎来了关注度大爆发,在AI领域的算法也开始出现了一些新的算法,本文将介绍一些常见的AI算法及其应用场景。

首先,了解下人工智能项目的训练方法,训练方法大模型的训练需要使用深度学习方法。以下是一些常见的深度学习方法:

  • 监督学习:监督学习是一种深度学习方法,其中模型被训练来预测输入数据的标签。在自然语言处理任务中,监督学习通常用于文本分类、命名实体识别、问答系统等。
  • 无监督学习:无监督学习是一种深度学习方法,其中模型被训练来从数据中学习模式和特征。在自然语言处理任务中,无监督学习通常用于文本聚类、主题建模、文本生成等。
  • 强化学习:强化学习是一种深度学习方法,其中模型被训练来通过与环境的交互来学习如何做出最佳决策。在自然语言处理任务中,强化学习通常用于对话系统、推荐系统等。

线性回归(Linear Regression)

线性回归是一种常见的机器学习算法,用于预测连续型变量的值。它的基本思想是通过建立一个线性模型来预测目标变量的值。

一元线性回归的过程可以表示为: \[y = \theta_0 + \theta_1x\]

多元线性回归的模型可以表示为: \[y = \theta_0 + \theta_1x_1 + \theta_2x_2 + \cdots + \theta_nx_n\]

其中,$y$ 是目标变量,$x_1, x_2, \cdots, x_n$ 是特征变量,$\theta_0, \theta_1, \theta_2, \cdots, \theta_n$ 是模型参数。线性回归的目标是 找到一组最优的模型参数,使得模型能够最好地拟合训练数据

那么,如何找到一组最优的模型参数呢?这就需要用到损失函数。损失函数是用来衡量模型预测值与真实值之间的差异的函数。常用的损失函数为均方误差(Mean Squared Error,MSE)。

均方误差的公式为: \[MSE = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y_i})^2\]

其中,$y_i$ 是第 $i$ 个样本的真实值,$\hat{y_i}$ 是第 $i$ 个样本的预测值,$n$ 是样本的数量。

图形表达:

误差从图形上看是一条线段,平方后就形成了一个正方形, 将正方形的面积求和再取平均 ,就是均方误差的损失函数。所有的正方形的平均面积越小,损失越小。对于给定数据集,x和y的值是已知的,参数m和b是需要求解的,模型求解的过程就是解公式4的过程。以上就是 最小二乘法 的数学表示,”二乘”表示取平方,”最小”表示损失函数最小。至此我们发现,有了损失函数,机器学习的过程被化解成对损失函数求最优解过程,即求一个最优化问题。

简单线性回归实践:

import numpy as np
import matplotlib.pyplot as plt

x = np.array([1, 2, 3, 4, 5], dtype=np.int8)
y = np.array([1, 3.0, 2, 3, 5])
plt.scatter(x, y)

x_mean = np.mean(x)
y_mean = np.mean(y)
num = 0.0
d = 0.0
for x_i, y_i in zip(x, y):
    num += (x_i - x_mean) * (y_i - y_mean)
    d += (x_i - x_mean) ** 2
    a = num / d
    b = y_mean - a * x_mean
y_hat = a * x + b

plt.figure(2)
plt.scatter(x, y)
plt.plot(x, y_hat, c='r')
x_predict = 4.8
y_predict = a * x_predict + b
print(y_predict)
plt.scatter(x_predict, y_predict, c='b', marker='+')
plt.show()

结果:

适用场景

什么样的数据适合使用线性回归呢?统计学家安斯库姆给出了四个数据集,被称为 安斯库姆四重奏(Anscombe’s quartet) ,分别是:

从这四个数据集的分布可以看出,并不是所有的数据集都可以用一元线性回归来建模。现实世界中的问题往往更复杂,变量几乎不可能非常理想化地符合线性模型的要求。因此使用线性回归,需要遵守下面几个假设:

  • 线性回归是一个回归问题。
  • 要预测的变量 y 与自变量 x 的关系是线性的(图2 是一个非线性)。
  • 各项误差服从正太分布,均值为0,与 x 同方差(图4 误差不是正太分布)。
  • 变量 x 的分布要有变异性。
  • 多元线性回归中不同特征之间应该相互独立,避免线性相关。

回归问题与分类问题

与回归相对的是分类问题(classification),分类问题要预测的变量y输出集合是有限的,预测值只能是有限集合内的一个。当要预测的变量y输出集合是无限且连续,我们称之为回归。比如,天气预报预测明天是否下雨,是一个二分类问题;预测明天的降雨量多少,就是一个回归问题。

变量之间是线性关系

线性通常是指变量之间保持等比例的关系,从图形上来看,变量之间的形状为直线,斜率是常数。这是一个非常强的假设,数据点的分布呈现复杂的曲线,则不能使用线性回归来建模。可以看出,四重奏右上角的数据就不太适合用线性回归的方式进行建模。

误差服从正态分布

最小二乘法求解过程已经提到了误差的概念,误差可以表示为误差 = 实际值 - 预测值。 可以这样理解这个假设:线性回归允许预测值与真实值之间存在误差,随着数据量的增多,这些数据的误差平均值为0;从图形上来看,各个真实值可能在直线上方,也可能在直线下方,当数据足够多时,各个数据上上下下相互抵消。如果误差不服从均值为零的正太分布,那么很有可能是出现了一些异常值,数据的分布很可能是安斯库姆四重奏右下角的情况。 这也是一个非常强的假设,如果要使用线性回归模型,那么必须假设数据的误差均值为零的正太分布。

变量 x 的分布要有变异性

线性回归对变量 x也有要求,要有一定变化,不能像安斯库姆四重奏右下角的数据那样,绝大多数数据都分布在一条竖线上。

多元线性回归不同特征之间相互独立

如果不同特征不是相互独立,那么可能导致特征间产生共线性,进而导致模型不准确。举一个比较极端的例子,预测房价时使用多个特征:房间数量,房间数量*2,-房间数量等,特征之间是线性相关的,如果模型只有这些特征,缺少其他有效特征,虽然可以训练出一个模型,但是模型不准确,预测性差。

线性回归的优缺点

优点: (1)思想简单,实现容易。建模迅速,对于小数据量、简单的关系很有效; (2)是许多强大的非线性模型的基础。 (3)线性回归模型十分容易理解,结果具有很好的可解释性,有利于决策分析。 (4)蕴含机器学习中的很多重要思想。 (5)能解决回归问题。

缺点: (1)对于非线性数据或者数据特征间具有相关性多项式回归难以建模. (2)难以很好地表达高度复杂的数据。

逻辑回归(Logistic Regression)

逻辑回归与线性回归都是一种广义线性模型(generalized linear model,GLM)。具体的说,都是从指数分布族导出的线性模型,线性回归假设Y|X服从高斯分布,逻辑回归假设Y|X服从伯努利分布。

伯努利分布:伯努利分布又名0-1分布或者两点分布,是一个离散型概率分布。随机变量X只取0和1两个值,比如正面或反面,成功或失败,有缺陷或没有缺陷,病人康复或未康复。为方便起见,记这两个可能的结果为0和1,成功概率为p(0<=p<=1),失败概率为q=1-p。

高斯分布:高斯分布一般指正态分布。

首先我们要先介绍一下 Sigmoid函数,也称为逻辑函数(Logistic function) 。 Sigmoid函数的公式为: \[Sigmoid(x) = \frac{1}{1 + e^{-x}}\]

Sigmoid函数是一个非线性函数,它将输入值映射到0到1之间的输出值。Sigmoid函数的输出值可以被解释为概率。Sigmoid函数的图像如下:

从上图可以看到sigmoid函数是一个s形的曲线,它的取值在[0, 1]之间,在远离0的地方函数的值会很快接近0或者1,它的这个特性对于解决二分类问题十分重要。

逻辑回归与线性回归有很多相同之处,去除 Sigmoid映射函数 的话,逻辑回归算法就是一个线性回归。可以说,逻辑回归是以线性回归为理论支持的,但是逻辑回归通过Sigmoid函数引入了非线性因素,因此可以轻松处理0/1分类问题。

逻辑回归假设函数的形式为: \[h_\theta(x) = \frac{1}{1 + e^{-\theta^Tx}}\]

其中,$h_\theta(x)$ 是逻辑回归模型的预测值,$\theta^Tx$ 是线性回归模型的预测值。逻辑回归模型的预测值是一个概率值,它表示样本属于正类的概率。

一个机器学习的模型,实际上是把决策函数限定在某一组条件下,这组限定条件就决定了模型的假设空间。当然,我们还希望这组限定条件简单而合理。而逻辑回归模型所做的假设是: \[h_\theta(x) = P(y=1|x;\theta)\]

即在给定 x 和 θ 的条件下,y = 1的概率。

我们知道,在二分类问题模型:例如逻辑回归「Logistic Regression」、神经网络「Neural Network」等,真实样本的标签为 [0,1],分别表示负类和正类。模型的最后通常会经过一个 Sigmoid 函数,输出一个概率值,这个概率值反映了预测为正类的可能性:概率越大,可能性越大。

损失函数

通常提到损失函数,我们不得不提到代价函数(Cost Function)及目标函数(Object Function)。

  • 损失函数(Loss Function) 直接作用于单个样本,用来表达样本的误差
  • 代价函数(Cost Function)是整个样本集的平均误差,对所有损失函数值的平均
  • 目标函数(Object Function)是我们最终要优化的函数,也就是代价函数+正则化函数(经验风险+结构风险)

概况来讲,任何能够衡量模型预测出来的值 h(θ) 与真实值 y 之间的差异的函数都可以叫做代价函数 C(θ) 如果有多个样本,则可以将所有代价函数的取值求均值,记做 J(θ) 。因此很容易就可以得出以下关于代价函数的性质:

  • 选择代价函数时,最好挑选对参数 θ 可微的函数(全微分存在,偏导数一定存在)
  • 对于每种算法来说,代价函数不是唯一的;
  • 代价函数是参数 θ 的函数;
  • 总的代价函数 J(θ) 可以用来评价模型的好坏,代价函数越小说明模型和参数越符合训练样本(x,y);
  • J(θ) 是一个标量;

经过上面的描述,一个好的代价函数需要满足两个最基本的要求:能够评价模型的准确性,对参数 θ 可微。

在线性回归中,最常用的是均方误差(Mean squared error),即: \[J(\theta) = \frac{1}{2m}\sum_{i=1}^m(h_\theta(x^{(i)}) - y^{(i)})^2\]

而在逻辑回归中,最常用的是代价函数是交叉熵(Cross Entropy),交叉熵是一个常见的代价函数: \[J(\theta) = -\frac{1}{m}\sum_{i=1}^m[y^{(i)}log(h_\theta(x^{(i)})) + (1-y^{(i)})log(1-h_\theta(x^{(i)}))]\]

逻辑回归在确定了模型的形式后,通过最大似然估计法来实现最小散度从而求出模型参数。

为什么逻辑回归不能使用线性回归的损失函数均方差来计算呢?

线性回归的均方差损失函数是一个凸函数,可以很容易求解,而逻辑回归的损失函数是非凸函数,无法直接使用梯度下降法求解。所以逻辑回归使用的是对数损失函数(log loss function)。

LR一般需要连续特征离散化原因

  • 离散特征的增加和减少都很容易,易于模型快速迭代
  • 稀疏向量内积乘法速度快,计算结果方便存储,容易扩展
  • 离散化的特征对异常数据有很强的鲁棒性(比如年龄为300异常值可归为年龄>30这一段)
  • 逻辑回归属于广义线性模型,表达能力受限。单变量离散化为N个后,每个变量有单独的权重,相当于对模型引入了非线性,能够提升模型表达能力,加大拟合
  • 离散化进行特征交叉,由 m+n 个变量为 m*n 个变量(将单个特征分成 m 个取值),进一步引入非线性,提升表达能力
  • 特征离散化后,模型会更稳定(比如对用户年龄离散化,20-30作为一个区间,不会因为用户年龄,增加一岁变成完全不同的人,但区间相邻处样本会相反,所以怎样划分区间很重要)
  • 特征离散化后,简化了LR模型作用,降低模型过拟合风险

逻辑回归优缺点

LR优点:

  • 直接对分类的可能性建模,无需事先假设数据分布,避免了假设分布不准确带来的问题
  • 不仅预测出类别,还可得到近似概率预测
  • 对率函数是任意阶可导凸函数,有很好得数学性质,很多数值优化算法可直接用于求取最优解
  • 容易使用和解释,计算代价低
  • LR对时间和内存需求上相当高效
  • 可应用于分布式数据,并且还有在线算法实现,用较小资源处理较大数据
  • 对数据中小噪声鲁棒性很好,并且不会受到轻微多重共线性影响
  • 因为结果是概率,可用作排序模型

LR缺点:

  • 容易欠拟合,分类精度不高
  • 数据特征有缺失或特征空间很大时效果不好

python例程

逻辑回归的简单例程如下:

import numpy as np
from sklearn.linear_model import LogisticRegression
import matplotlib.pyplot as plt

# 训练数据
X = np.array([[1, 2], [2, 3], [3, 4], [4, 5]])
y = np.array([0, 0, 1, 1])

# 创建逻辑回归模型
model = LogisticRegression()

# 训练模型
model.fit(X, y)

# 预测新数据
new_data = np.array([[5, 6]])
prediction = model.predict(new_data)

print("预测结果:", prediction)
# 绘制训练数据
plt.scatter(X[y == 0][:, 0], X[y == 0][:, 1], color='blue', label='Class 0')
plt.scatter(X[y == 1][:, 0], X[y == 1][:, 1], color='red', label='Class 1')
# 绘制决策边界
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1), np.arange(y_min, y_max, 0.1))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)
plt.contourf(xx, yy, Z, alpha=0.4)
# 绘制新数据的预测结果
plt.scatter(new_data[:, 0], new_data[:, 1], color='green', marker='x', label='New Data')
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.legend()
plt.show()

运行结果:

预测结果: [1]

朴素贝叶斯(Naive Bayes)

Pagination