理解Go AI推理:什么是推理?
本文是系列文章的第一篇,介绍如何从Go语言直接本地运行大型语言模型。文章解释了推理的本质:基于冻结的权重进行下一个标记预测,并详细介绍了自回归循环、分词器工作原理以及GGUF文件格式的结构和加载过程。
欢迎来到全新系列!对于当今大多数开发者来说,使用大语言模型意味着向别人的计算机发送HTTP请求:将提示输入API,获得返回的令牌,中间的一切都是他人的魔法。但更令人感兴趣的是,你能在自己的硬件上、在自己的进程中本地运行这些模型——如果你是Go开发者,甚至可以直接从Go中调用。目前有两个项目使得这一过程变得轻松愉快:Yzma,它让Go直接调用llama.cpp库(无需cgo——这将在系列后续文章中单独介绍);以及Kronk,它在Yzma之上构建了一个高级的、类OpenAI API的SDK和模型服务器。而这两个项目的底层都是llama.cpp,这个C/C++推理引擎使得在普通硬件上运行LLM成为可能。
本系列将从内部理解整个技术栈:llama.cpp实际上在“运行模型”时做了什么,Yzma如何从Go调用它,以及Kronk如何将其转化为一个可用于生产的引擎。 范围说明:本文的目标是理解模型文件如何存储、加载和运行的机制,而非神经网络的内部数学原理。那些数学本身是一个完整话题,不在本文讨论范围内。llama.cpp的细节针对2026年7月的master版本(提交ad8d8219)——llama.cpp更新迅速且没有稳定版本,因此我们锁定一个提交而非版本号。本文是有意概念性的:我们将不深入C++代码细节,也完全避开Go层面——Yzma和Kronk各有专文介绍。
推理到底是什么? 语言模型有两种截然不同的生命周期。第一个是训练:向模型呈现TB级别的文本,通过优化过程逐渐调整数十亿的数字——权重(也称参数)——直到模型擅长一项任务:给定文本序列,预测下一个内容。训练需要数月和数据中心。本系列完全不涉及训练。 第二个是推理:训练结束,权重冻结,现在只需使用它们。最佳心智模型是一个纯函数:它接收两个输入——你的文本序列和那数十亿的冻结权重——返回一个输出,即下一个标记的预测。类似nextToken(input, weights)。就是这样。没有学习发生,没有更新——权重是只读参数,因此模型文件在第一次调用时和第一百万次调用时一样聪明。 关于权重的说明:如果你对权重实际占用多少内存、量化如何缩小它们以及为什么它们通常主导本地运行模型所需的VRAM感兴趣,Kronk的VRAM计算器文章值得一读。
当你下载一个“模型”时,下载的基本上就是一大堆冻结的数字——那些权重——加上如何使用它们的描述。而“运行”模型意味着在你的输入和那些数字之间进行大量的算术运算。如果这听起来有些模糊,请别担心:我们稍后在打开实际模型文件时会澄清权重。现在,你只需记住模型是别人已经为你计算好的大量数字。
我们已经多次提到令牌,但令牌到底是什么?让我们一探究竟。
令牌:模型的字母表 在进行任何算术运算之前有一个前提:模型不读取文本。它们处理的是令牌——来自固定词汇表的整数ID,每个ID对应一段文本(一个词、词的一部分、标点符号)。我们的提示“The capital of France is ”可能变成五或六个令牌ID——例如[791, 6864, 315, 9822, 374](具体ID完全取决于模型的词汇表)。从这一刻起,模型再也看不到字母;后续一切都在这些整数的基础上进行。
所以模型接收到整数列表。它实际做了什么?
一次前向传递,一次预测 以下是计算的核心形态。首先要明确:模型并非逐词分轮处理输入——整个令牌序列一次性通过网络,产生一个预测。让我们通过图示来理解:从左到右,首先将每个令牌ID转换为嵌入——一种表示令牌的向量,使模型能够处理。然后所有这些向量被送入模型,经过大量计算后产生一个分数列表,每个词汇表中的令牌对应一个得分,表示该令牌出现的可能性。从该列表中我们选择一个:“ Paris”。 这最后一步称为采样,我们稍后会讨论它的工作原理。但首先要注意:所有这些工作只产生了一个令牌。我们如何从一个只预测下一个单词的机器中得到一个完整句子?
自回归循环 通过重复这个过程。这就是自回归部分,也是LLM对话的核心。图示展示了:我们将序列“The capital of France is ”输入模型,一次前向传递后选择一个令牌“ Paris”。诀窍在于:不停止,而是将“ Paris”附加到序列中,再次运行模型,现在输入是“The capital of France is Paris”。下一个令牌可能是“.”,继续附加,如此循环——每轮一个新令牌——直到模型选择一个特殊的生成结束令牌(表示“我完成了”),或者我们达到长度限制后停止。 你读过的LLM回答中的每个词都是通过这个循环逐个生成的。生成并非一次大的计算,而是同一个下一个标记预测的for循环。
理论部分到此为止。在观察循环实际运行之前,一切都依赖于我们一直在含糊其辞的东西:冻结的权重。它们来自何处,模型在磁盘上实际长什么样?是时候打开盒子了。
GGUF:单一文件中的完整模型 让我们澄清权重。那堆冻结的数字并非无定形:它们被组织成名为张量的命名数组(张量只是n维数组——矩阵推广至更多维度)——例如,神经网络的一个单层通常由几个张量组成。GGUF(GGML通用文件)是这些张量在磁盘上的存储方式,其整个设计遵循一个目标:单一自描述文件。一个.gguf文件包含运行模型所需的一切——权重、架构、超参数、完整分词器,甚至聊天模板——无需额外下载旁路文件。复制一个文件即可运行模型。(最大的模型有时会分成几个.gguf分片,但原理相同。)
那么这些文件内部实际是什么?格式记录在ggml的GGUF头部——基于llama.cpp的张量库,文件格式本身位于ggml/include/gguf.h。但让我们用图示说明:从顶部开始,有一个小头部——幻数字节GGUF(用于程序确认这是GGUF文件)、版本号以及两个计数,告诉读取器有多少个张量和多少个元数据条目。之后的所有内容分为三个部分:元数据、张量描述符和数据块。我们逐一介绍。
元数据:自我描述的模型 这是图示中的框1。元数据是一个扁平的键值对列表,值可以是简单标量、字符串或同类型数组。键是命名空间字符串,有几个命名空间值得关注:general.*标识模型身份,general.architecture决定模型类型(llama、qwen2、gemma等),此键决定llama.cpp用于搭建推理机器的图构建代码。<arch>.*包含超参数,前缀为架构名称:llama.embedding_length(向量大小)、llama.block_count(层数)、llama.context_length(上下文窗口大小)等。tokenizer.*包含完整分词器,tokenizer.ggml.tokens是词汇表字符串数组,还有配置参数和tokenizer.chat_template,用于将聊天对话转换为平铺提示字符串。当llama.cpp打开文件时,首先读取general.architecture,其余一切由此而来。
数据块:将权重加载到内存 现在看框2和框3,它们协同工作。框2是张量描述符:每个张量一行,包含名称、形状和类型——但没有数据。相反,每行带有一个偏移量(图中的红色箭头),指向该张量字节在框3数据块中的实际位置。有趣的部分是这些字节如何进入内存。加载器(llama_model_loader,src/llama-model-loader.cpp)首先只解析头部、元数据和张量描述符——不读取任何张量数据。这足以确定何处分配:某些层留在CPU,其他层送往GPU,每个目的地以其方式处理字节。对于CPU张量,llama.cpp默认不通过read()将千兆字节权重读入新分配缓冲区——而是通过mmap将文件映射到进程地址空间,并将每个张量直接指向mapping_address + tensor_offset。对于GPU张量,此技巧无效(VRAM是不同内存),因此它们确实被复制:在加载时从文件一次性上传至GPU缓冲区。
现在,文件的张量已按各自位置存放于内存中。是时候运行了。
KV缓存:为何生成保持快速 从我们已经拥有的开始:模型的数据,永不改变的部分。加载后得到llama_model——文件本身在内存中:权重、分词器,所有只读内容。由于没有任何变化,它可以被共享——一个加载的模型,多个对话,而那些若干GB的权重仅加载一次。