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的權重僅載入一次。