AI News HubLIVE
站內改寫4 分鐘閱讀

理解Go AI推理:什麼是推理?

本文是系列文章的第一篇,介紹如何從Go語言直接本地運行大型語言模型。文章解釋了推理的本質:基於凍結的權重進行下一個標記預測,並詳細介紹了自迴歸循環、分詞器工作原理以及GGUF文件格式的結構和加載過程。

來源Hacker News AI作者: valyala

歡迎來到全新系列!對於當今大多數開發者來説,使用大語言模型意味着向別人的計算機發送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的權重僅加載一次。