本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在先前的系列文章中,我們已經為本地推論引擎奠定了非常堅實的基礎:
- 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫
llama.cpp 原生函式庫。
- 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
- 第三篇:編譯嵌入式 Web UI 與 Axum 靜態資源整合:利用
rust-embed 將靜態網頁打包進 Rust 執行檔、實現了 SPA Fallback 路由,並用 Vanilla JS 打造出精美的暗黑毛玻璃聊天介面。
到目前為止,我們的「LLM 本地工作室」已經是一個非常成熟的文字對談系統。但在今天這個多模態(Multimodal)大放異彩的時代,僅僅能打字交流顯然有些單調。如果我們的本地服務也能直接聽懂我們的聲音,並在瀏覽器上實現即時的「語音對講」,那體驗絕對會上好幾個檔次!
為了實現這個目標,我們在最新的 llm-local-studio-4 中,帶來了兩項重量級的升級:
- 整合 Vulkan GPU 硬體加速:擺脫 CPU 推論的緩慢,讓生成速度成倍飆升。
- 導入
libmtmd 實現本地多模態音訊(Audio)直接推論:無需先用額外的語音轉文字(STT)模型,而是將聲音特徵直接送入多模態大模型進行解碼!
這篇文章將為大家解密:如何在 Rust 中開啟 Vulkan GPU 加速、如何使用 libmtmd 進行音訊與文字的向量融合、後端如何透過 ffmpeg 子行程將瀏覽器的 WebM 格式轉換成標準 WAV,以及前端如何使用 HTML5 MediaRecorder 原生串接相容於 OpenAI 格式的多模態 API。
技術關鍵一:跨平台 GPU 加速的救星 —— Vulkan 整合
要在本地流暢運行多模態大模型,GPU 加速是不可或缺的。然而,常見的 CUDA 方案有著不少痛點:它僅限於 NVIDIA 顯示卡,且安裝驅動與 CUDA Toolkit 的過程極度臃腫,這對發布一個輕量級、易於部署的 Rust 執行檔來說是個阻礙。
為了達到真正的「跨平台、低依賴」,我們在 Cargo.toml 中將 llama-cpp-4 的推論引擎啟用了 vulkan 特徵:
[dependencies]
llama-cpp-4 = { version = "0.3.0", features = ["mtmd", "vulkan"] }
Vulkan 是一個現代的、跨平台的低階圖形與計算 API。不論你使用的是 NVIDIA、AMD 還是 Intel 的顯示卡,只要顯卡驅動支援 Vulkan,我們的 Rust 程式就能直接調用顯示卡進行計算!
在 Rust 程式碼中,開啟 GPU 加速也變得無比簡單。在 src/inference.rs 的 load_model 函數中,我們只需設定 n_gpu_layers 參數:
let model = LlamaModel::load_from_file(
&backend,
&request.path,
&LlamaModelParams::default().with_n_gpu_layers(99) // 將 99 層(即所有層)模型載入 GPU 記憶體
)
.with_context(|| format!("failed to load model {}", request.path.display()))?;
透過這行設定,llama.cpp 底層會自動將模型權重搬移至顯示記憶體(VRAM)。在 Vulkan 的加持下,即使是在中階顯卡上,模型的 Tokens/s 生成速度也立刻提升了數倍,為流暢的語音對答提供了硬體基礎。
技術關鍵二:本地多模態音訊推論的奧秘 —— libmtmd
傳統的「語音對話」通常採用級聯(Cascade)架構:
- 麥克風錄音 → 2. 送給 STT 模型(如 Whisper)轉成文字 → 3. 文字送給 LLM 產生回答 → 4. 回答文字送給 TTS 模型唸出來。
這種架構在本地運行時非常笨重,需要同時載入好幾個模型,且語音中的情緒、語調等特徵在轉成文字的過程中會完全流失。
而在 llm-local-studio-4 中,我們採用了 Direct Multimodal Audio(直接音訊多模態) 機制。我們使用支援音訊輸入的多模態模型(例如 gemma-4-E4B-it),並搭配其專屬的 音訊投影器(Audio Projector, mmproj)。
其底層數據流如下:
graph TD
Audio[輸入 WAV 語音檔] --> AudioEncoder[音訊編碼器]
AudioEncoder --> Projector[音訊投影器 mmproj]
Projector --> AudioEmbeds[音訊特徵向量 Audio Embeddings]
Text[輸入文字 Prompt + 預留置換符] --> Tokenizer[分詞器]
Tokenizer --> TextEmbeds[文字特徵向量 Text Embeddings]
AudioEmbeds --> Merge[在預留位置將文字向量替換為音訊向量]
TextEmbeds --> Merge
Merge --> LlamaEval[llama.cpp 一次性評估完整 Context]
LlamaEval --> SampleLoop[標準採樣輸出 Token 串流]
在 Rust 中,我們透過 llama_cpp_4::mtmd 模組來實現這套極度精密的操作(詳見 src/inference.rs):
// 1. 從音訊投影器檔案 (mmproj.gguf) 初始化多模態上下文
let params = MtmdContextParams::default();
let mtmd_ctx = MtmdContext::init_from_file(&mmproj_path, &session.model, params)?;
// 2. 將輸入的音訊檔案載入為 MtmdBitmap (此處 libmtmd 會處理對數梅爾頻譜圖轉換與特徵提取)
let bitmap = MtmdBitmap::from_file(mtmd_ctx, &request.audio_path)?;
// 3. 在文字 Prompt 中加入特殊的媒體預留標記 (Default Marker)
let marker = MtmdContext::default_marker();
let full_prompt = format!("{} {}", request.prompt, marker);
// 4. Tokenize:將文字與音訊資料進行融合,將預留標記替換為音訊向量
let text = MtmdInputText::new(&full_prompt, true, true);
let bitmaps = [&bitmap];
let mut chunks = MtmdInputChunks::new();
mtmd_ctx.tokenize(&text, &bitmaps, &mut chunks)?;
// 5. 評估(Evaluation)音訊與文字的混合向量
let mut lctx = session.model.new_context(&session.backend, self.loaded_context_params.clone())?;
let mut n_past = 0i32;
mtmd_ctx.eval_chunks(lctx.as_ptr(), &chunks, 0, 0, lctx.n_batch() as i32, true, &mut n_past)?;
// 6. 進入標準的 Token 採樣循環,即時輸出文字答案
let batch = LlamaBatch::new(512, 1);
let (output_text, generated_tokens) = sample_loop(
session,
&mut lctx,
batch,
n_past, // 評估音訊後的起始位置
request.max_tokens,
request.seed,
&request.stream_callback,
)?;
這套實作完全繞過了傳統的「語音轉文字(STT)」步驟,由大模型的大腦直接去聽並理解你的聲波向量,實現了真正的原生多模態互動!
技術關鍵三:後端 WebM 到 WAV 的音訊處理管線
這時候,身為開發者的你一定會想到一個實際問題:
現代瀏覽器透過 MediaRecorder 錄製的音訊,通常是 audio/webm (包含 Opus 編碼) 格式;然而底層 libmtmd 進行特徵提取時,只收標準的 16 kHz 單聲道 (Mono) 16-bit WAV 格式。
為了解決這個格式鴻溝,我們在 Axum 後端的 /v1/chat/completions API 路由中(src/api/routes.rs),設計了一個即時音訊轉換管線。
當後端偵測到 API 請求中含有音訊部分時,它會:
- 將前端傳送的 Base64 字串解碼回原始的 WebM 二進位數據。
- 啟動本機的
ffmpeg 作為子行程(Subprocess),並透過管道(Pipe)進行流式轉換,避免在硬碟上寫入未壓縮的超大暫存檔。
具體實作在 audio_bytes_to_wav 函數中:
fn audio_bytes_to_wav(audio_bytes: &[u8], _format_hint: &str) -> Result<std::path::PathBuf, String> {
let tmp_dir = std::env::temp_dir();
let wav_path = tmp_dir.join(format!("llm_audio_{}.wav", uuid::Uuid::new_v4()));
// 啟動 ffmpeg 子行程
let mut child = Command::new("ffmpeg")
.args([
"-y", // 若檔案存在則覆寫
"-i", "pipe:0", // 從標準輸入 (stdin) 讀取 WebM 數據
"-ar", "16000", // 設定採樣率為 16000 Hz
"-ac", "1", // 單聲道 (Mono)
"-f", "wav", // 強制輸出格式為 wav
wav_path.to_str().ok_or("temp path not UTF-8")?,
])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
// 將 WebM 二進位數據寫入 ffmpeg 的標準輸入
{
let mut stdin = child.stdin.take().ok_or("Failed to open ffmpeg stdin")?;
stdin.write_all(audio_bytes).map_err(|e| format!("Failed to write audio to ffmpeg: {e}"))?;
}
// 等待轉換完成
let status = child.wait().map_err(|e| format!("ffmpeg wait error: {e}"))?;
if !status.success() {
return Err("ffmpeg failed to convert audio to WAV".to_string());
}
Ok(wav_path)
}
推論引擎接收到轉換後的 wav_path 並完成 eval_chunks 之後,後端會立刻刪除該暫存檔案(std::fs::remove_file),確保使用者的隱私資料與暫存檔不會殘留在硬碟中。
技術關鍵四:前端 UI 語音錄製與 OpenAI 多模態 API 串接
在前端部分,我們遵循了 OpenAI 最新的多模態語音 API 規範,將網頁端錄製的語音封裝為 ContentPart 陣列傳送給後端。
前端 ui/src/main.ts 使用瀏覽器原生的 navigator.mediaDevices.getUserMedia 獲取麥克風權限,並啟動 MediaRecorder 錄製 WebM 音訊:
// 1. 啟動錄音
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// 將錄音數據打包為 Blob
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
stream.getTracks().forEach(track => track.stop());
await handleAudioMultimodal(audioBlob);
};
mediaRecorder.start();
當錄音結束時,前端會將 Blob 轉換為 Base64 編碼,並建構一個 OpenAI 規格的多模態請求體(Request Body):
// 將 Blob 轉成 Base64 字串
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(audioBlob);
});
// 建立多模態內容片段
const contentParts = [
{ type: 'text', text: chatInput.value.trim() || 'Listen and respond to this audio input.' },
{
type: 'input_audio',
input_audio: {
data: base64Data,
format: 'webm' // 瀏覽器錄製的 webm 格式
}
}
];
// 推入對話歷史紀錄
messages.push({
role: 'user',
content: contentParts // 陣列格式的多模態訊息
});
// 呼叫 Axum 後端 (支援與純文字相同的 SSE 串流輸出)
await generateResponse();
這項設計使得我們的 UI 與後端 API 都具備極佳的通用性。你可以用相同的 UI 連接線上 OpenAI 語音 API,也可以用任何支援 OpenAI 協定的第三方工具(如 Continue 或 CLI 工具)無縫串接我們本地的多模態語音服務!
如何構建與運行?
想要親身體驗這款「能聽、能說」且具備 GPU 加速的本地多模態工作站,流程非常簡單:
1. 下載多模態模型與投影器
至 Hugging Face 下載多模態語音模型 gemma-4-E4B-it 及其對應的音訊投影器 mmproj-gemma-4-E4B-it-BF16.gguf,並放置於專案根目錄的 models/ 下。
2. 一鍵啟動服務
使用 Cargo 啟動服務。除了指定載入的主模型外,你需要額外加上 --mmproj 參數指定投影器路徑,以便初始化多模態上下文:
# 啟動服務並加載 Vulkan GPU 加速與音訊投影器
cargo run --release -- serve gemma-4-E4B-it-Q4_K_M.gguf --mmproj models/huggingface/lmstudio-community/gemma-4-E4B-it-GGUF/mmproj-gemma-4-E4B-it-BF16.gguf --port 8080
3. 打開瀏覽器用語音聊天
開啟瀏覽器連線至 http://localhost:8080。現在你會看到輸入框左側多了一個「麥克風」圖示:
- 點擊麥克風即可開始錄音(狀態列與按鈕會呈現錄音中的呼吸燈特效)。
- 再次點擊麥克風完成錄音,前端會自動將語音編碼並發送。
- 本地 LLM 在收到聲音後,會透過 Vulkan GPU 加速迅速解碼,並以流暢的打字機效果(SSE Stream)即時輸出回答!
總結與展望
在 llm-local-studio-4 中,我們完成了本地 LLM 工作站從「純文字」到「硬體加速多模態」的華麗轉身。
透過本章的實踐,我們學習到了:
- 如何在 Rust 中整合 Vulkan GPU API 來進行高效能的張量計算。
- 深入探索了
libmtmd 底層多模態編碼器與投影器(MLP) 的融合原理與程式碼實現。
- 搭建了後端多執行緒子行程的
ffmpeg 影音流式轉換管線。
- 掌握了前端 HTML5 錄音與 OpenAI 多模態 API 標準 的完美整合。
原生多模態代表著 AI 互動的未來趨勢。能直接處理聲音、影像等物理世界訊號的模型,其資訊表達能力遠高於單純的文字。在下一篇文章中,我們將在此基礎上更進一步,挑戰將**本地相機影像(Vision)與即時語音輸出(TTS)**進行深度整合,打造一個完全運行在本機、完全隱私安全的「本地雙向影音對話助理」!
本專案的所有原始碼已收錄於我們的 Rust 52 Projects 挑戰計畫中。如果你也對本地 AI 與 Rust 系統編程感興趣,歡迎前來專案倉庫點個 Star 並親自體驗!
我們下一個 Rust 專案見!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在過去的幾篇文章中,我們專注於打造本地的文字 LLM 工作站,支援了標準的文字生成與對談。然而,當今的 AI 領域正快速朝向 多模態(Multi-modal) 發展。不論是 GPT-4o、Llama 3.2 Vision 還是 Qwen2-VL,它們不僅能讀懂文字,還能看懂圖片、圖表,甚至進行複雜的視覺推理。
身為開發者,你是否曾好奇過:一個本質上只能處理文字 Token ID 的 Transformer 模型,到底是怎麼「看」懂由無數像素組成的影像?
本篇文章將深入探討多模態 LLM(通常稱為視覺語言模型,Vision-Language Models, VLMs)的底層運行機制,從 模型架構差異、GGUF 格式的資料封裝,一路解析到 REST API 的影像資料傳輸與融合實作。
一、架構對比:文字 LLM 與多模態 LLM 的根本差異
要理解多模態 LLM,我們先複習一下傳統文字 LLM 的處理流程:
graph TD
Text["輸入文字: Hello"] --> Tokenizer["分詞器 (Tokenizer)"]
Tokenizer --> TokenIDs["Token IDs: 15263"]
TokenIDs --> Embedding["嵌入表 Lookup (Vector d_model)"]
Embedding --> Transformer["Transformer 解碼器堆疊"]
Transformer --> Logits["語言輸出頭 (LM Head)"]
Logits --> NextToken["預測下一個 Token"]
文字模型非常單純:文字轉成整數 ID,ID 換成向量(Embedding),然後塞給 Transformer 運算。
那麼,多模態 LLM 要怎麼把「影像」塞進這個流程中?現代 VLM 最主流的設計是 Late Fusion(後期融合)架構,其核心由三個部分組成:
- 視覺編碼器(Vision Encoder):通常使用 Vision Transformer (ViT) (例如 CLIP 或 SigLIP)。它負責將輸入的圖片分割成許多小方塊(Patches,如 14x14 像素),並將每個方塊轉換成一個視覺向量。一張圖片經過 ViT 後,會變成一串「視覺 Token 向量」。
- 投影層(Projector):這是連接視覺與文字世界的橋樑。ViT 輸出的視覺向量維度(如 1024)通常與 LLM 的文字向量維度(如 4096)不同。投影層(通常是一個簡單的 MLP 兩層全連接網路 或 Resampler)負責將視覺向量投影(對齊)到 LLM 的向量空間中。
- 文字 LLM(Text LLM):充當大腦。對它而言,經過投影層轉換後的視覺向量, 就跟普通的文字 Token 向量沒有兩樣。它只管把文字向量與視覺向量拼接在一起,送入 Transformer 計算自注意力(Self-Attention)。
VLM 的資料流動架構
graph TD
subgraph 影像處理分支
Image["輸入圖片"] --> ViT["視覺編碼器 (ViT)"]
ViT --> VisualTokens["視覺 Patch 向量 (Dimension: d_vision)"]
VisualTokens --> Projector["投影層 (MLP)"]
Projector --> ProjectedVisual["對齊後的視覺向量 (Dimension: d_model)"]
end
subgraph 文字處理分支
Text["輸入文字: 這張圖裡有什麼?"] --> Tokenizer["分詞器"]
Tokenizer --> TokenIDs["Token IDs"]
TokenIDs --> EmbedLookup["嵌入表 Lookup"]
EmbedLookup --> TextEmbed["文字 Token 向量 (Dimension: d_model)"]
end
ProjectedVisual --> Concat["向量拼接 (Concatenate)"]
TextEmbed --> Concat
Concat --> Transformer["Transformer 解碼器堆疊"]
Transformer --> LMHead["LM Head"]
LMHead --> Output["生成文字答案"]
在這個架構下, LLM 本身不需要做巨大的修改。我們只需要在文字 Token 序列中插入一個特殊的預留位置(如 <image> 標籤),在將向量送入 Transformer 前,把這個標籤對應的文字向量替換成投影層算出來的影像向量即可。
二、多模態版圖的擴張:音訊與影片處理機制
除了「看懂圖片」之外,現代的多模態 LLM(如 GPT-4o 或 Gemini 1.5 Pro)已經能夠處理「聲音」與「影片」。本質上,它們採用的依然是相似的 編碼器 + 投影層 + LLM 架構,但在資料前處理與壓縮技術上更具挑戰性。
1. 音訊處理(Audio Processing)
語音是連續的聲波訊號,沒有像圖片那樣直觀的像素網格。為了讓模型處理語音:
- 特徵提取(Log-Mel Spectrogram):首先,後端會將原始音訊(如 WAV 檔案)經過快速傅立葉變換(FFT),轉化為 對數梅爾頻譜圖(Log-Mel Spectrogram)。這本質上將一維的波形訊號轉化為了二維的「聲音圖像」,X 軸代表時間,Y 軸代表頻率,顏色深度代表能量。
- 音訊編碼器(Audio Encoder):使用如 Whisper 的 Encoder 或 AST(Audio Spectrogram Transformer)。它會掃描這張「聲音圖像」,提取出包含發音、語調、環境音特徵的音訊特徵向量(Audio Tokens)。
- 向量對齊:同樣透過一個專用的音訊投影層(Audio Projector),將音訊特徵向量映射到與 LLM 一致的維度,最後與文字 Token 向量拼接。這使得模型能夠做到「語音直達」,即不經過語音轉文字(STT)就能直接聽懂你的情緒與說話內容。
2. 影片處理(Video Processing)
影片處理的難度在於時空維度(Spatial-Temporal)的複雜度。一秒鐘的影片包含 30 到 60 張影像幀(Frames),如果直接把每一幀都當作獨立圖片送入 ViT,產生的 Token 數量會瞬間引爆 LLM 的上下文視窗(Context Window)。因此,影片模型會進行以下優化:
- 幀取樣(Frame Sampling):在輸入端進行稀疏抽樣(例如:一段 10 秒的影片只抽取 16 或 32 幀)。
- 時空注意力與特徵壓縮(Spatio-Temporal Compression):使用 3D-ViT 或專門的時空重採樣器(Spatio-Temporal Resampler,例如 Qwen2-VL 的 Naive Dynamic Resolution 技術)。它會對相鄰影格進行空間上的「降採樣」(例如將 2x2 的視覺 patch 合併為一個向量),並在時間維度進行跨影格注意力計算,以大幅減少 Token 數量。
- 時間位置嵌入(Temporal Position Embeddings):為了讓 LLM 知道畫面的先後順序,每一幀的視覺向量在拼接時會被額外加上「時間維度」的位置編碼,這樣模型才能理解「跌倒」是發生在「滑倒」之後。
三、GGUF 格式中的多模態封裝:mmproj 檔的秘密
在本地部署領域,llama.cpp 所主導的 GGUF 格式是絕對的主流。那麼,多模態模型在 GGUF 中是如何儲存的?
以經典的 LLaVA (Large Language and Vision Assistant) 模型為例,在下載模型時,你通常會看到兩個檔案:
- 主模型檔(如
llava-v1.5-7b-q4_k.gguf):包含傳統的 Transformer 權重、Tokenizer 以及 LM Head。
- 多模態投影檔(如
llava-v1.5-7b-mmproj-f16.gguf):這個檔案就是多模態的精華,裡面封裝了 Vision Encoder (CLIP) 與 Projector (MLP) 的權重。
為什麼要將 mmproj 獨立出來?
這種「分離式設計」有著極大的工程優勢:
- 權重共享與硬碟節省:Vision Encoder 通常使用 FP16(半精度浮點數)以確保影像特徵提取的精準度,不需要也通常不建議進行量化。而主 LLM 體積龐大,非常適合量化(如 Q4_K、Q8_0)。將它們分離後,你可以只下載一個
mmproj 檔案,並搭配不同量化精度的 LLM 主檔案運行,無需重複下載高達數百 MB 的視覺權重。
mmproj 內部的 Tensor 結構
如果你用 GGUF 讀取工具拆解 mmproj 檔案,會看到以下關鍵 Tensor:
v.patch_emb.weight:這是 ViT 最底層的卷積層,負責將 14x14 的影像 Patch 轉換為初始向量。
v.blocks.[N].attn.qkv.weight:ViT 內部多頭自注意力機制的權重。
model.mm.projections.0.weight 與 model.mm.projections.2.weight:這就是投影層(MLP)的權重,負責將 ViT 向量映射到 LLM 隱藏層維度。
在 llama.cpp 執行時,其 C API 會提供 llava_image_embed_make_with_bytes 等函式,將傳入的圖片像素經由 mmproj 運算,輸出為一組連續的 float 陣列,這組陣列的大小正好是 (影像 Patch 數量) * (LLM 隱藏層維度)。
四、從 REST API 到底層融合:資料是如何傳遞的?
當我們將多模態模型封裝成 OpenAI 相容的 REST API 時,前端與後端之間的通訊協定會變得比純文字複雜一些。
1. REST API 請求載荷(Request Payload)
在純文字 API 中,對話內容只是一個簡單的字串。但在多模態 API 中,content 欄位會變成一個 物件陣列,允許同時傳入文字與圖片資訊(通常採用 Base64 編碼的 Data URL):
傳統文字請求:
{
"model": "gemma-4-E4B-it",
"messages": [
{
"role": "user",
"content": "請解釋什麼是 Rust 所有權。"
}
]
}
多模態影像請求:
{
"model": "llava-v1.5-7b",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "這張圖片中的水果叫什麼名字?"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
}
}
]
}
]
}
2. 後端伺服器的處理流程
當我們的後端 API 伺服器(例如基於 Axum 的服務)收到上述的多模態請求時,會執行以下步驟:
Step 1: 解析與 Base64 解碼
伺服器解析 JSON,識別出 type: "image_url",提取出 Base64 字串,並將其解碼為原始的圖片二進位位元組(Bytes,如 JPEG/PNG 格式)。
Step 2: 呼叫視覺引擎(FFI)
伺服器將圖片位元組傳給本地載入的 mmproj 視覺引擎。視覺引擎利用 CPU/GPU 執行 ViT 推論,將圖片像素轉換成視覺特徵向量。
Step 3: 文字 Token 化與預留位置替換
伺服器將文字部分 "這張圖片中的水果叫什麼名字?" 送入 Tokenizer,並在前面加上一個特殊的圖片預留標記(例如 [IMAGE] 或 <image>)。
當 Token 轉換成向量時,伺服器會定位到 <image> Token 的位置,將其對應的 Embedding 向量抹除,並 替換插入 Step 2 中產生的整串視覺向量。
原始 Token 向量序列:
[ <bos>, <image>, "這", "張", "圖", "片", "中", "的", ... ]
│ │
│ └─ 被替換為由 ViT + Projector 生成的 576 個視覺特徵向量 (4096-dim)
▼
融合後的 Embedding 序列直接送入 LLM context 進行 evaluation
Step 4: LLM 推論自注意力計算
Transformer 開始執行推論。當計算到自注意力時,文字 Token 向量會與影像 Token 向量進行矩陣乘法。藉此,文字 Token 能夠「注意到」影像中不同方塊(Patches)的特徵,從而產生與圖片內容高度相關的文字回覆。
總結:多模態融合的優雅與未來
多模態 LLM 的架構設計展示了現代深度學習的模組化藝術。我們不需要從頭訓練一個全新的巨型模型來理解視覺,而是 藉由一個輕量化的投影層(Projector),將成熟的視覺世界(ViT)與強大的語言大腦(LLM)完美拼裝在一起。
在本地端部署上,透過 GGUF 將 LLM 與 mmproj 分離封裝,給予了開發者極大的靈活性;而在網路 API 層面,OpenAI 的標準多模態 JSON 規範則隱藏了底層複雜的向量替換細節,讓前端開發者能用最直覺的 Base64 載荷進行多模態互動。
掌握了這套「視覺與語言」的融合技術,不論是開發智慧安防、自動化圖表分析,還是打造下一代支援語音與視覺的本地 AI 助理,你都已經握有了最核心的底層知識鑰匙。
在接下來的專案挑戰中,我們也將嘗試在本地工作站中整合多模態 FFI 引擎。敬請期待!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在之前的系列文章中,我們逐步建立起本機推論引擎的核心:
- 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫
llama.cpp 原生函式庫。
- 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
- API 串接篇:多語言 SDK 實戰:介紹了如何用 Python、Node.js、Rust 及 VS Code 外掛(Continue)無縫對接我們本地的服務。
到目前為止,我們的專案都還是一個純粹的「後端 API 服務」。雖然功能健全,但對於不想動手寫程式串接、只想安靜與本地模型聊天的使用者來說,沒有一個開箱即用的圖形介面(GUI)實在有些美中不足。
為了徹底解決這個問題,我們在最新的 llm-local-studio-3 中,開發了一個精美的單頁網頁應用(SPA)聊天介面,並將其直接編譯並嵌入到 Rust 二進位檔案中。
現在,你不需要下載繁雜的前端依賴,不需要執行 npm run dev,更不需要處理惱人的跨來源資源共用(CORS)問題——只要執行一個編譯好的 Rust 執行檔,就能立刻在瀏覽器打開一個功能完備、視覺效果極佳的本地 LLM 聊天室!
這篇文章將為大家解密:如何使用 rust-embed 將靜態網頁打包進 Rust 執行檔、如何在 Axum 中實現 SPA 路由重導向(Fallback Routing)、以及前端如何用 Vanilla JS 原生串接 SSE 串流並打造極具質感的毛玻璃(Glassmorphism)暗黑風格介面。
為什麼選擇「嵌入式 Web UI」?
常見的 Web App 開發架構中,前端與後端通常是分離運行的。前端透過 Vite 等開發伺服器啟動於 http://localhost:5173,而後端 API 運行在 http://localhost:8080。
這種架構在開發時非常方便,但在部署與發布給一般使用者時,會帶來不少痛點:
- 依賴環境複雜:使用者電腦必須安裝 Node.js 才能運行前端,對非網頁開發者門檻過高。
- CORS 跨網域阻擋:因為連接埠(Port)不同,瀏覽器會因為安全性考量阻擋請求,後端伺服器必須配置特別的 CORS 允許規則。
- 多檔案分發麻煩:你需要同時打包前端編譯產出的
dist 資料夾與後端的執行檔,一旦路徑出錯就無法運行。
而將網頁靜態資源**打包嵌入(Embedded)**到 Rust 二進位檔中,有著壓倒性的優勢:
- 單一執行檔(Zero Dependencies):打包編譯後只產生一個執行檔(如
llm-local-studio.exe),所有網頁圖示、HTML、CSS、JS 程式碼全部都存在於這個二進位檔的唯讀資料段(ReadOnly Data Segment)中。
- 同源政策(Same-Origin):因為網頁和 API 都是由同一個監聽連接埠(例如
8080)的 Axum 服務提供,瀏覽器視其為同源,完全不需要擔心 CORS 問題。
- 極佳的部署體驗:使用者只需下載該執行檔,下達一條指令,點開網址就能直接使用。
技術關鍵一:使用 rust-embed 封裝靜態資源
在 Rust 中要將整個資料夾嵌入到二進位檔,最方便的工具莫過於 rust-embed。
它的用法極度直覺,只需要定義一個結構體,並掛上 RustEmbed 的屬性巨集(Attribute Macro),指定前端打包輸出的目錄:
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;
在編譯期,Rust 編譯器會掃描 ui/dist/ 目錄下的所有檔案,將它們的二進位內容讀入,並作為靜態陣列嵌入到二進位檔中。
兼顧開發效率的「雙重模式」
你可能會擔心:「如果每次修改網頁 CSS 或 JS,都得重新編譯 Rust 專案,那開發效率不就低落到無法接受?」
這就是 rust-embed 設計非常精妙的地方!
- 偵錯模式(Debug Mode):在執行
cargo run 或 cargo build 時,它不會真正嵌入檔案。相反地,它會在運行時動態去讀取你硬碟上的 ui/dist/ 目錄。因此,你只需要在前端資料夾進行 Vite 編譯,Rust 伺服器端就能立刻看到更新,無需重啟 Rust 服務!
- 發布模式(Release Mode):當執行
cargo build --release 時,它才會真正把檔案寫死在二進位檔中,達到零外部依賴。
技術關鍵二:Axum 中的 SPA Fallback 路由與 MIME 處理
嵌入資源後,我們必須讓 Axum 伺服器知道如何讀取這些檔案並回傳給瀏覽器。
因為現代前端單頁應用(SPA)的路由是由前端 JS(例如網頁瀏覽器內部的 History API)控制的,當使用者在瀏覽器網址列輸入 http://localhost:8080/settings 並重新整理時,後端伺服器會收到對 /settings 的請求。如果後端沒有配置對應的路由,就會回傳 404 錯誤。
因此,後端必須實現 SPA Fallback 機制:只要請求的路徑不是已註冊的 API 端點(如 /v1/chat/completions、/health),就一律將 ui/dist/index.html 的內容丟回給瀏覽器,讓前端 JS 接手後續的路由渲染。
我們在 src/api/assets.rs 中實現了這個資源處理器:
use axum::{
http::{header, StatusCode, Uri},
response::IntoResponse,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
// 1. 去除請求路徑開頭的斜線,符合 rust-embed 的鍵值格式
let mut path = uri.path().trim_start_matches('/').to_string();
// 2. 如果是首頁請求,預設尋找 index.html
if path.is_empty() {
path = "index.html".to_string();
}
// 3. 嘗試從嵌入資源中取得檔案
match Asset::get(path.as_str()) {
Some(content) => {
// 使用 mime_guess 根據副檔名判斷正確的 Content-Type (例如 text/css, application/javascript)
let mime = mime_guess::from_path(path).first_or_octet_stream();
(
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
)
.into_response()
}
None => {
// 4. 找不到檔案時,Fallback 回傳 index.html(讓前端 SPA 處理路由)
if let Some(index_content) = Asset::get("index.html") {
(
[(header::CONTENT_TYPE, "text/html")],
index_content.data.into_owned(),
)
.into_response()
} else {
// 如果連 index.html 都沒有(例如忘記先 build 前端),則回傳 404
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
}
}
}
}
接著,在 src/api/mod.rs 中,我們透過 .fallback() 將這個 handler 註冊到 Axum 路由表的最後:
let app = Router::new()
.route("/health", get(routes::health))
.route("/v1/models", get(routes::list_models))
.route("/v1/chat/completions", post(routes::chat_completions))
// 當以上路由都沒配對成功時,交給靜態資源處理器
.fallback(get(assets::static_handler))
.layer(cors)
.with_state(engine);
如此一來,我們的伺服器就完美具備了 serving 靜態資源以及引導 SPA 路由的能力!
前端介面設計:極簡暗黑毛玻璃美學
在 UI 設計上,我們拒絕了粗糙簡陋的陽春畫面。取而代之的是,我們以極簡主義為出發點,選用了符合現代審美標準的深色質感色系:
- 背景與版面:採用極深色漸層底色(
#0f1115 到 #171923),避免純黑帶來的死板感。
- 側邊欄 (Sidebar):使用半透明的白色背景,結合
backdrop-filter: blur(12px) 實現高質感的毛玻璃(Glassmorphism)視覺效果,並用細緻的半透明邊框細線畫出分界。
- 狀態指示燈:在側邊欄上方顯示「Connected」連線狀態,並利用 CSS 關鍵影格(
@keyframes)製作綠色光暈的呼吸燈脈衝動畫(Pulse Animation),讓介面顯得栩栩如生。
- 聊天對話框:使用帶有漸層藍紫色的氣泡背景(
linear-gradient(135deg, #3b82f6, #4f46e5))代表使用者訊息,並加上柔和的陰影;AI 訊息則使用低調的半透明灰,形成強烈的視覺對比與易讀性。
- 自適應輸入框:對話輸入框會監聽輸入字數,動態調整輸入框高度(最大 150px),並客製化了滾動條(Scrollbar)樣式。
「好的軟體不只功能要強大,外觀也必須讓人賞心悅目。」
原生 JavaScript SSE 令牌串流串接
為保持整個應用的輕量與極致速度,我們沒有引入 React 或 Vue 等重型框架,而是直接用 vanilla JavaScript 撰寫互動邏輯。
前端最核心的技術,就是利用網頁的 ReadableStream 讀取並即時解析後端傳回的 SSE(Server-Sent Events)事件流,並實現「打字機」效果:
// 建立一個空的 AI 訊息元素,並附加閃爍的輸入游標 (cursor-blink)
const msgEl = document.createElement('div');
msgEl.className = 'message ai';
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
const cursor = document.createElement('span');
cursor.className = 'cursor-blink';
msgEl.appendChild(contentEl);
msgEl.appendChild(cursor);
chatHistory.appendChild(msgEl);
let aiContent = '';
// 發送請求並讀取串流
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel || 'default',
messages: messages,
stream: true,
max_tokens: 512
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解碼串流 byte 資料為文字
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '').trim();
if (dataStr === '[DONE]') continue;
if (dataStr) {
try {
const data = JSON.parse(dataStr);
if (data.choices && data.choices[0].delta.content) {
// 累加 token 片段並即時渲染到介面
aiContent += data.choices[0].delta.content;
contentEl.textContent = aiContent; // 使用 textContent 防範 XSS 攻擊
chatHistory.scrollTop = chatHistory.scrollHeight; // 自動滾動到底部
}
} catch (e) {
console.error('Error parsing SSE JSON:', e);
}
}
}
}
}
// 結束後移除閃爍游標
cursor.remove();
messages.push({ role: 'assistant', content: aiContent });
這段 vanilla JS 的實作展現了 SSE 協議的輕巧。只用了不到 50 行程式碼,就完美複製出了 ChatGPT 原生的打字流暢感,甚至還帶有復古的終端機閃爍游標。
如何構建與運行?
想要親身體驗這款完全整合的本地 LLM Studio,編譯與啟動流程比你想像的還要簡單很多!
因為在 llm-local-studio-3 中,我們寫了一個強大的 build.rs 構建指令碼(Build Script)。當你執行 cargo build 或 cargo run 時,Rust 的編譯系統會自動檢查前端 ui 目錄並執行 npm install 與 npm run build!
同時,它也利用 cargo:rerun-if-changed 監聽了前端的檔案變更。當你修改前端 HTML、CSS 或 JS 時,Cargo 會自動重新觸發前端打包與 Rust 編譯整合,讓開發體驗如絲般順滑。
一鍵編譯與啟動
因此,你不需要手動進入 ui 目錄去執行 npm 指令。只要確保你的電腦中安裝了 Node.js / npm,然後直接在專案根目錄下達 Cargo 啟動命令,並傳入你已下載或掃描註冊的模型識別名稱即可:
# 直接啟動服務(Rust 會自動接管 npm 前端編譯打包)
cargo run -- serve gemma-4-E4B-it --port 8080
Tip
如果你想手動編譯前端,也可以在 ui 目錄下執行:
cd ui
npm install
npm run build
這會在 ui/dist 下生成靜態網頁檔案。而 Cargo 在編譯時,無論是自動還是手動,最終都會藉由 rust-embed 將該目錄的內容編譯並嵌入到最終的 Rust 執行檔中。
3. 打開瀏覽器享受對話
當你在終端機看到:
Starting llm-local-studio server (axum)...
Base URL: http://127.0.0.1:8080
Endpoints:
GET /health
GET /v1/models
POST /v1/chat/completions
GET / (Web UI)
現在,直接打開瀏覽器瀏覽:
👉 http://localhost:8080
你就能看到精美的暗黑毛玻璃介面!在左側選擇已載入的模型,就能立即開始體驗完全處於本機、安全且快速的 LLM 對談!
總結與展望
藉由 llm-local-studio-3,我們成功地把原本複雜的 LLM 本地部署流程,精簡到只需要一個 Rust 執行檔就能搞定。
透過這三個章節的探討,我們從最底層的 GGUF 檔案解析、Rust FFI 原生 llama.cpp 運算,一路寫到 Axum 多執行緒異步 Web API、以及最終的嵌入式前端 UI 整合。這不僅是一個完整的實用工具,更是學習 Rust 系統級程式設計、異步 Web 開發與跨語言整合的絕佳實踐範例。
本專案 llm-local-studio-3 的完整原始碼已收錄於我們的 Rust 52 Projects 挑戰計畫中。如果你也對本地 AI 隱私安全、極致效能與 Rust 技術感興趣,歡迎至專案倉庫點個 Star,並嘗試下載編譯它!
我們下一篇 Rust 挑戰見!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在上一篇文章中,我們為本機 LLM 推理引擎加上了以 Axum 為核心的 HTTP 伺服器,並實現了相容於 OpenAI Chat Completions 規範的 API 端點,支援 Server-Sent Events (SSE) 令牌串流輸出。
標準化的最大好處就是互操作性(Interoperability)。因為 API 格式與 OpenAI 完全一致,你不需要為本機的 llm-local-studio 重新撰寫任何客製化的 HTTP 請求解析程式,直接使用官方提供的 SDK 或現成的開源工具,僅需修改 伺服器端點位址(Base URL) 並將金鑰設為任意值,即可無縫對接。
這篇文章整理了如何使用 Python、Node.js、Rust 及 curl 串接本機伺服器的實用程式碼範例,並介紹如何將它對接到 VS Code 等日常開發工具中。
本機 API 服務端點回顧
在開始寫程式之前,先確認你的本機伺服器已啟動並監聽 8080 連接埠:
cargo run --release -- serve gemma-4-e4b --port 8080
此時,你的本機服務對外提供以下相容於 OpenAI 的端點:
- API 根路徑 (Base URL):
http://localhost:8080/v1
- 模型清單:
GET http://localhost:8080/v1/models
- 對談補完:
POST http://localhost:8080/v1/chat/completions
Tip
因為本機服務不需要驗證,你的 api_key 可以傳入任意字串(例如 "local-studio" 或 "noop")。許多 SDK 要求必須設定該值,否則會拋出未配置金鑰的錯誤。
深入協議:OpenAI API 與 SSE 串流運作原理
在我們開始看各語言的 SDK 程式碼之前,先來了解這個「OpenAI 相容協議」在 HTTP 底層到底是怎麼傳遞資料的。這能幫助我們理解為什麼各大 SDK 只需要換個 URL 就能直接對接本機服務。
1. 聊天補完請求 (Chat Completions Request)
當客戶端向本機服務發送請求時,發起的是一個標準的 HTTP POST 請求:
- URL:
http://localhost:8080/v1/chat/completions
- Headers:
Content-Type: application/json
- JSON 欄位:
model (String): 本機載入的模型識別代號(如 gemma-4-e4b)。
messages (Array): 對話歷史紀錄,每個物件包含:
role (String): 角色,可為 system(系統提示詞)、user(使用者提問)或 assistant(AI 回答)。
content (String): 對話的文字內容。
stream (Boolean): 是否開啟串流模式(逐字生成)。
max_tokens (Integer, 選填): 限制生成的 Token 最大數量。
2. 非串流模式的響應 (Non-Streaming Response)
如果 stream: false,伺服器會阻塞等待推理完全結束後,一次性返回 HTTP 200 與完整的 JSON 響應:
{
"id": "chatcmpl-c513296e-7eb2-4f66-9af7-045fbae2fa36",
"object": "chat.completion",
"created": 1779608333,
"model": "gemma-4-e4b",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! I am a local assistant."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 8,
"total_tokens": 20
}
}
3. 串流模式與 Server-Sent Events (SSE) 協議
當 stream: true 時,底層協議會切換為 Server-Sent Events (SSE)。這是一種基於 HTTP 的單向持久化連線技術,非常適合 LLM 逐字輸出的場景:
- HTTP 響應標頭:
Content-Type: text/event-stream (告訴瀏覽器/客戶端這是一個事件流)
Cache-Control: no-cache (停用快取)
Connection: keep-alive (保持連線不中斷)
- 傳輸格式:
伺服器會保持連接,每生成一個 token 片段,就向 TCP 通道寫入一段以
data: 開頭、\n\n 結尾的文字資料,內容是 JSON 格式的增量區塊(Delta Chunk):
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
- 結束信號:
當模型生成結束或達到最大 token 限制時,伺服器會先傳送最後一個帶有
finish_reason 的 chunk(通常內容為空),緊接著發送一個特殊的結束標記:
客戶端收到 [DONE] 後,便會主動關閉這個 HTTP 連線。
這套簡潔的文本串流規範就是 OpenAI API 的精髓。接下來我們來看看如何使用各語言的 SDK 包裝這套協議。
1. Python 串接範例
Python 是資料科學與 AI 開發的首選語言。我們可以使用官方的 openai 庫直接串接本機服務。
首先安裝 SDK:
非串流模式 (Non-Streaming)
from openai import OpenAI
# 指向本機伺服器的 Base URL,api_key 填入任意值即可
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="local-studio"
)
response = client.chat.completions.create(
model="gemma-4-e4b",
messages=[
{"role": "user", "content": "Explain ownership in Rust programming"}
],
max_tokens=80
)
print(response.choices[0].message.content)
串流模式 (Streaming)
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="local-studio"
)
# 啟用 stream=True 進行打字機效果輸出
stream = client.chat.completions.create(
model="gemma-4-e4b",
messages=[
{"role": "user", "content": "Explain ownership in Rust programming"}
],
max_tokens=80,
stream=True
)
for chunk in stream:
# 讀取 delta 增量內容並即時印出
content = chunk.choices[0].delta.content
if content is not None:
print(content, end="", flush=True)
print()
2. Node.js / JavaScript 串接範例
如果你正在開發 Web 應用或 Node.js 後端服務,可以使用官方的 @openai/api 軟體包。
首先安裝 SDK:
非同步串流模式 (ESM / TypeScript)
使用現代 JavaScript 的 for await...of 語法,可以極度簡潔地處理本機傳回的 SSE 串流:
import OpenAI from "openai";
const openai = new OpenAI({
baseURL: "http://localhost:8080/v1",
apiKey: "local-studio",
});
async function main() {
const stream = await openai.chat.completions.create({
model: "gemma-4-e4b",
messages: [
{ role: "user", content: "Explain ownership in Rust programming" }
],
max_tokens: 80,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
process.stdout.write(content);
}
process.stdout.write("\n");
}
main().catch(console.error);
3. Rust 串接範例
對於 Rust 開發者,社群中廣受歡迎的 async-openai crate 是串接 OpenAI 的主力。
在你的 Cargo.toml 中加入依賴:
[dependencies]
async-openai = "0.26"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
串流模式範例
我們需要透過 ClientConfig 自訂底層呼叫的 API 根路徑:
use async_openai::{
config::OpenAIConfig,
types::{CreateChatCompletionRequestArgs, ChatCompletionRequestMessage},
Client,
};
use futures::StreamExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. 自訂配置,將 api_base 指向本機端點
let config = OpenAIConfig::default()
.with_api_base("http://localhost:8080/v1")
.with_api_key("local-studio");
let client = Client::with_config(config);
// 2. 建立 Request 參數
let request = CreateChatCompletionRequestArgs::default()
.model("gemma-4-e4b")
.max_tokens(80u32)
.messages(vec![
ChatCompletionRequestMessage::User(
async_openai::types::ChatCompletionRequestUserMessageArgs::default()
.content("Explain ownership in Rust programming")
.build()?,
)
])
.stream(true)
.build()?;
// 3. 獲取非同步 Stream 並依序讀取 token
let mut stream = client.chat().create_stream(request).await?;
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
for choice in response.choices {
if let Some(content) = choice.delta.content {
print!("{content}");
std::io::Write::flush(&mut std::io::stdout())?;
}
}
}
Err(err) => eprintln!("Error: {err}"),
}
}
println!();
Ok(())
}
4. 終端機 curl 快速測試
不需要寫任何程式碼,使用 curl 也是進行快速排錯與 API 驗證的好幫手:
非串流 (返回單一 JSON)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-e4b",
"messages": [
{"role": "user", "content": "Explain ownership in Rust programming"}
],
"max_tokens": 80
}'
串流 (返回 SSE 封包)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-e4b",
"messages": [
{"role": "user", "content": "Hello!"}
],
"stream": true,
"max_tokens": 30
}'
實戰整合:VS Code 開發助手 Continue
除了自己編寫程式外,你還可以直接將 llm-local-studio-2 連接至現成的編輯器外掛中。
以熱門的開源 VS Code 程式編寫助理外掛 Continue 為例,你只需修改 Continue 的 config.json 設定檔,將其模型供應商設為 openai,並將 apiBase 指向你的本機服務:
{
"models": [
{
"title": "Local Studio - Gemma",
"provider": "openai",
"model": "gemma-4-e4b",
"apiBase": "http://localhost:8080/v1",
"apiKey": "local-studio"
}
],
"tabAutocompleteModel": {
"title": "Local Studio - Gemma Autocomplete",
"provider": "openai",
"model": "gemma-4-e4b",
"apiBase": "http://localhost:8080/v1",
"apiKey": "local-studio"
}
}
存檔後,VS Code 側邊欄的 Continue 對話框與行內自動補完功能,就會直接利用本機運行的 llm-local-studio 進行推論!
總結
相容於成熟的公有雲 API 標準,為本地 AI 工具注入了無限的可能性。透過將 FFI 執行緒與 Axum Web 伺服器解耦,並對外曝露標準的 OpenAI 協定,llm-local-studio-2 現在可以輕鬆地成為你開發流程中任何一環的「智慧核心」。
不論你是使用 Python 進行指令碼開發、用 Node.js 建立網頁應用、還是用 Rust 編寫系統級程式,串接方式都非常直覺簡單。歡迎將這些程式碼片段複製到你的專案中試試看!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在上一篇文章中,我們搭建了 llm-local-studio 的基石:實現了模型註冊表(Model Registry)、Hugging Face 模型下載,以及透過 Rust FFI 直接載入 llama.cpp 底層進行本機推理。
然而,光有 CLI 介面還不夠。要讓這個專案成為真正好用的「本地工作室」,它必須能夠跟現有的第三方生態系無縫對接。不論是網頁端的 Chat UI、VS Code 的 Copilot 外掛、還是本機的 Markdown 編輯器,它們大多都遵循同一個工業標準——OpenAI Chat Completions API。
因此,這次我們推出了 llm-local-studio-2。這篇文章將帶大家深入探討,我們是如何用 Rust 的 Axum 框架架設非同步 HTTP 伺服器、如何在非同步執行緒與同步的 llama.cpp FFI 引擎之間搭建高效安全的 thread 邊界,以及如何實現流暢的 Server-Sent Events (SSE) 令牌串流(Token Streaming)。
設計難題:非同步 HTTP 與同步 FFI 的衝突
在 Rust 裡寫 Web 伺服器,我們追求的是極高的併發處理能力。Axum 基於 Tokio 非同步執行時(Runtime),其核心設計是利用少數的 worker thread 透過 .await 快速切換任務,來達成高吞吐量。
但是,llama.cpp 的推理完全是另一回事:
- CPU/GPU 密集型運算:生成一個 token 需要執行龐大的矩陣乘法,會直接佔滿 CPU 核心或 GPU。
- 非 thread-safe 與同步阻塞:底層 C/C++ FFI 是同步阻塞的,而且對同一個模型上下文(Context)做推理是無法並行的,必須互斥存取。
如果在 Axum 的 handler 裡直接呼叫 llama.cpp 的推理函式,該 Tokio worker thread 就會被徹底阻塞。這會導致伺服器無法即時回應其他人的 HTTP 請求,甚至連簡單的 /health 健康檢查都會超時。
為了解決這個衝突,我們設計了下圖的架構:
sequenceDiagram
participant Client as Web Client
participant Axum as Axum / Tokio Task
participant Service as EngineService (Mutex)
participant Blocking as Tokio Blocking Thread
participant Llama as llama.cpp Engine (C FFI)
Client->>Axum: POST /v1/chat/completions (stream=true)
Axum->>Service: chat_streaming(messages)
Note over Service: 建立 tokio::sync::mpsc 管道 (tx, rx)
Service->>Blocking: tokio::task::spawn_blocking
Axum-->>Client: 立即回傳 HTTP 200 (SSE stream)
loop 推理循環 (在 Blocking Thread 中)
Blocking->>Service: 獲取 Mutex 鎖
Blocking->>Llama: 推理下一個 Token
Llama-->>Blocking: 回傳 token piece ("Rust")
Blocking->>Service: 釋放 Mutex 鎖
Blocking->>Axum: tx.blocking_send("Rust")
Axum-->>Client: SSE event (data: {"choices": [...]})
end
Note over Blocking: 推理結束 / 遇到 EOG
Blocking->>Axum: drop(tx) 關閉管道
Axum-->>Client: data: [DONE]
這套設計有三個關鍵點:
tokio::task::spawn_blocking:將所有 CPU 密集的 FFI 運算移交給 Tokio 專門的 blocking thread pool。這樣做可以解放主要的 non-blocking worker thread,確保 HTTP 伺服器隨時保持高響應性。
std::sync::Mutex:用來保護互斥的推理引擎。因為鎖是在 blocking thread 內部獲取與釋放,且絕不跨越 .await 邊界,所以我們可以直接使用標準庫的高效 Mutex,而不需要承擔 Tokio 異步 Mutex 的額外開銷。
tokio::sync::mpsc 管道:在非同步與同步 thread 之間搭建橋樑。當 blocking thread 生成 token 時,透過 tx.blocking_send 傳送;非同步端則將 rx 包裝成 Stream 連續輸出給 HTTP 客戶端。
非同步引擎服務:EngineService
我們封裝了一個 thread-safe 且 clone 成本極低的 EngineService 作為 HTTP handler 與底層引擎的媒介:
/// 執行緒安全、對非同步友善的推理服務
#[derive(Clone)]
pub struct EngineService {
inner: Arc<Mutex<LlamaCppEngine>>,
}
串流生成橋樑:chat_streaming
讓我們來看看如何把同步的 llama.cpp 回呼函式(callback)轉換成非同步的 mpsc::Receiver 串流:
pub fn chat_streaming(
&self,
messages: Vec<ChatMessage>,
max_tokens: u32,
seed: Option<u32>,
) -> (mpsc::Receiver<String>, tokio::task::JoinHandle<Result<GenerateOutput>>) {
// 1. 建立非同步多生產者單消費者管道,緩衝區設為 64
let (tx, rx) = mpsc::channel::<String>(64);
let engine = self.inner.clone();
// 2. 在 blocking thread pool 中啟動推理任務
let handle = tokio::task::spawn_blocking(move || {
let mut engine = engine.lock().expect("engine mutex poisoned");
let tx_clone = tx.clone();
let result = engine.chat(ChatRequest {
messages,
max_tokens,
seed,
// 3. 當底層生成一個 token piece 時,觸發此 FFI 回呼
stream_callback: Some(Box::new(move |piece: &str| {
// 如果用戶端已斷開連接(Receiver 已 drop),我們忽略錯誤並繼續
let _ = tx_clone.blocking_send(piece.to_owned());
})),
});
// 4. 任務結束時主動 drop(tx),使 Receiver 收到 None 訊號得知結束
drop(tx);
result
});
(rx, handle)
}
這個設計非常優雅:
blocking_send 能確保當非同步端的網路發送隊列滿載時,同步執行緒會進行適度的回壓(Backpressure)。
- 即使客戶端突然中斷連線,非同步端丟棄了
rx,blocking_send 也只會回傳錯誤,推理任務仍會安全地收尾,而不會導致伺服器崩潰。
構建 OpenAI 相容 API 伺服器
有了 EngineService 後,我們就能用 Axum 輕鬆搭建起對外的 HTTP API。
伺服器啟動與路由設定
在 src/api/mod.rs 中,我們設定了三個端點,並套用了 permissive CORS 中間件,以相容各類 Web 前端工具:
pub async fn start_server(config: ServerConfig, engine: EngineService) -> Result<()> {
let bind_addr = format!("{}:{}", config.host, config.port);
// 允許任何來源的 CORS 設定,方便第三方 Web UI 連接
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", get(routes::health))
.route("/v1/models", get(routes::list_models))
.route("/v1/chat/completions", post(routes::chat_completions))
.layer(cors)
.with_state(engine);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
axum::serve(listener, app).await.context("HTTP server error")
}
SSE 串流與非串流的分流處理
在 /v1/chat/completions 的實作中,我們會依據客戶端傳入的 JSON 中是否含有 "stream": true,來決定回傳單一的 JSON 響應,還是 SSE 事件串流:
pub async fn chat_completions(
State(engine): State<EngineService>,
Json(request): Json<ChatCompletionRequest>,
) -> Response {
let max_tokens = request.max_tokens.unwrap_or(128);
let seed = request.seed;
let streaming = request.stream.unwrap_or(false);
let model_id = engine
.model_info()
.map(|info| info.model_id)
.unwrap_or_else(|| request.model.clone());
if streaming {
handle_streaming(engine, request.messages, max_tokens, seed, model_id).await
} else {
handle_non_streaming(engine, request.messages, max_tokens, seed, model_id).await
}
}
實現 SSE Token 串流
串流生成需要遵循 Server-Sent Events (SSE) 規範。在 Axum 中,我們可以使用 Sse 回傳型態,將一個 Stream 轉化為 HTTP 串流響應。
為了完全模擬 OpenAI 的行為,我們的 SSE 串流需要包含三個部分:
- 初始區塊:宣布
role: assistant,但不含任何內容。
- 內文區塊:連續發送包含
delta 內容的 token 片段。
- 結束區塊:提供結束原因(例如
finish_reason: "stop"),並以 data: [DONE] 標記串流正式結束。
我們使用 futures::stream 的 combinator 將這三個階段鏈接成一個完整的 Stream:
async fn handle_streaming(
engine: EngineService,
messages: Vec<ChatMessage>,
max_tokens: u32,
seed: Option<u32>,
model_id: String,
) -> Response {
let completion_id = generate_completion_id();
let created = unix_timestamp();
// 1. 取得 mpsc 接收端與 blocking thread 的 JoinHandle
let (rx, result_handle) = engine.chat_streaming(messages, max_tokens, seed);
let token_stream = ReceiverStream::new(rx);
// 2. 第一個事件:發送 Role
let role_chunk = ChatCompletionChunk {
id: completion_id.clone(),
object: "chat.completion.chunk".to_string(),
created,
model: model_id.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: Some(Role::Assistant), content: None },
finish_reason: None,
}],
};
let role_event = Event::default().data(serde_json::to_string(&role_chunk).unwrap_or_default());
let role_stream = stream::once(async move { Ok::<Event, std::convert::Infallible>(role_event) });
// 3. 中間的 token 事件流
let id_for_tokens = completion_id.clone();
let model_for_tokens = model_id.clone();
let token_events = token_stream.map(move |piece| {
let chunk = ChatCompletionChunk {
id: id_for_tokens.clone(),
object: "chat.completion.chunk".to_string(),
created,
model: model_for_tokens.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: None, content: Some(piece) },
finish_reason: None,
}],
};
Ok::<Event, std::convert::Infallible>(
Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
)
});
// 4. 最後的結束事件:等 JoinHandle 結束取得 finish_reason,然後送出 [DONE]
let id_for_final = completion_id;
let model_for_final = model_id;
let final_events = stream::once(async move {
let finish_reason = match result_handle.await {
Ok(Ok(output)) if output.generated_tokens >= max_tokens => "length",
_ => "stop",
};
let done_chunk = ChatCompletionChunk {
id: id_for_final,
object: "chat.completion.chunk".to_string(),
created,
model: model_for_final,
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: None, content: None },
finish_reason: Some(finish_reason.to_string()),
}],
};
let done_json = serde_json::to_string(&done_chunk).unwrap_or_default();
let events = vec![
Ok::<Event, std::convert::Infallible>(Event::default().data(done_json)),
Ok::<Event, std::convert::Infallible>(Event::default().data("[DONE]")),
];
stream::iter(events)
}).flatten();
// 5. 鏈接所有串流並包裝成 SSE 響應
let full_stream = role_stream.chain(token_events).chain(final_events);
let sse = Sse::new(full_stream).keep_alive(axum::response::sse::KeepAlive::default());
// 6. 設定緩衝控制標頭,防止中介 Proxy 攔截阻礙實時輸出
(
[
(axum::http::header::CACHE_CONTROL, "no-cache"),
(axum::http::header::HeaderName::from_static("x-accel-buffering"), "no"),
],
sse,
).into_response()
}
Note
在回傳 SSE 時,我們加入了 x-accel-buffering: no 標頭。這是一個很容易被忽視但至關重要的設定。如果你的本機服務未來透過 Nginx 進行反向代理,Nginx 預設會快取(buffer)伺服器傳回的資料,直到集滿一定大小才一口氣發給客戶端,這會直接摧毀 SSE 的實時「打字機」效果。這個標頭能強制代理伺服器不要進行任何快取。
自動化對談範本偵測 (Chat Template System)
LLM 本質上只是接龍工具,把對談紀錄(例如 Messages Array)轉換成模型能讀懂的單一純文字 prompt,就需要「對談範本(Chat Template)」。
不同的模型使用的範本格式大相逕庭。例如:
- ChatML 格式(Qwen, Yi 等模型常用):
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
Hello!<|im_end|>
<|im_start|>assistant
- Llama 3 格式:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
Hello!<|eot_id|><|start_header_id|>assistant<|end_header_id|>
在 src/chat_template.rs 中,我們定義了 ChatTemplate trait,並實作了 ChatMLTemplate、Llama3Template 以及 GenericTemplate(基於簡單的 ### User:)。
為了讓使用者不必每次都手動指定格式,我們寫了一個簡單實用的自動偵測機制:
pub fn auto_detect(model_name: &str) -> Box<dyn ChatTemplate> {
let lower = model_name.to_lowercase();
if lower.contains("llama-3") || lower.contains("llama3") {
Box::new(Llama3Template)
} else if lower.contains("chatml") || lower.contains("im_start") {
Box::new(ChatMLTemplate)
} else {
// 安全的安全預設值,因為大多數現代微調模型皆相容於 ChatML
Box::new(ChatMLTemplate)
}
}
有了自動偵測,當系統載入模型時,會自動判斷並解析出合適的 prompt 格式,大幅減少配置的繁瑣度。
命令列指令整合:serve
現在我們將這一切整合進 CLI。透過 clap 定義新的 serve 子命令:
/// 啟動與 OpenAI 相容的 HTTP API 伺服器
Serve {
/// 本地 GGUF 路徑、模型 ID 或已下載檔案名稱的局部片段
model: String,
/// 搜尋與解析下載模型時的模型根資料夾
#[arg(short, long, default_value = "models")]
dir: PathBuf,
/// 模型的上下文大小
#[arg(long, default_value_t = 2048)]
ctx_size: u32,
/// 伺服器繫結的 Host 位址
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// 伺服器繫結的連接埠 (Port)
#[arg(long, default_value_t = 8080)]
port: u16,
}
在啟動時,它會先透過 resolve_model 模糊匹配找出實體檔案,將模型載入記憶體,然後開啟 HTTP 監聽:
async fn serve_model(model: String, dir: PathBuf, ctx_size: u32, host: String, port: u16) -> Result<()> {
let model_record = resolve_model(&model, dir)?;
println!("Loading model: {}", model_record.id);
println!(" path: {}", model_record.path.display());
println!();
let engine = EngineService::new();
engine.load_model(LoadModelRequest {
model_id: model_record.id.clone(),
path: model_record.path.clone(),
context_size: Some(ctx_size),
}).await?;
let config = ServerConfig { host, port };
start_server(config, engine).await
}
實測驗證
我們可以透過簡單的命令列直接運行它:
# 啟動 API 伺服器(系統會自動模糊搜尋 models/ 目錄下的 gemma-4-E4B-it 模型)
cargo run --release -- serve gemma-4-E4B-it --port 8080
當伺服器成功運行後,我們可以用 curl 來測試。
1. 測試一般 JSON 響應 (Non-Streaming)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-E4B-it",
"messages": [
{"role": "user", "content": "Explain ownership in Rust programming"}
],
"max_tokens": 80
}'
輸出結果:
{
"id": "chatcmpl-a6bf02ed-3775-4dcd-a1b4-2492bec524d0",
"object": "chat.completion",
"created": 1779653484,
"model": "gemma-4-E4B-it-Q4_K_M",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Ownership is one of the most fundamental and unique concepts in the Rust programming language. It is the core mechanism that allows Rust to guarantee **memory safety** (preventing issues like dangling pointers and data races) *without* needing a garbage collector (like in Java or Python).\n\nIn simple terms, **Ownership is a set of rules that governs how Rust manages memory and how long data lives in memory"
},
"finish_reason": "length"
}
],
"usage": {
"prompt_tokens": 29,
"completion_tokens": 80,
"total_tokens": 109
}
}
Note
咦?這個回答是不是出乎意料的好?它非常準確地解釋了 Rust 的所有權(Ownership)機制:這是保證記憶體安全的核心機制,而且不需要垃圾回收機制(Garbage Collector)。
沒錯!這就是本地運行 gemma-4-E4B-it 的真實寫照。雖然它能在個人電腦上流暢運行,但依然保留了強大的程式碼與技術概念理解能力。
這也千真萬確證明了一件事:我們的本機推論引擎確實 100% 跑在自己的 CPU/GPU 上,成功載入了模型並進行實時推理。
2. 測試 SSE 串流響應 (Streaming)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-E4B-it",
"messages": [
{"role": "user", "content": "Explain ownership in Rust programming"}
],
"stream": true,
"max_tokens": 30
}'
輸出串流結果:
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":"Ownership"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":" one"},"finish_reason":null}]}
...
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}
data: [DONE]
可以看到,所有的輸出都嚴格符合 OpenAI 協定,並且流暢地以 Token 片段傳回!我們看到了 “Ownership”, " is", " one" 等單字片段逐一生成,這無疑是 100% 真實的非同步 SSE 串流封包。
結語與展望
透過非同步執行緒與同步 FFI 的清晰解耦,我們讓 Rust 成功扮演起高效能 API 閘道器(Gateway)的角色,完美串接起底層 llama.cpp 原生庫與現代非同步 Web 生態系。
目前我們的本地 LLM 工作站已具備以下能力:
- 遞迴掃描管理本機 GGUF 模型。
- 從 Hugging Face 自動化規劃與安全原子下載。
- 自動化偵測與套用對談範本(Chat Templates)。
- 相容於 OpenAI API 標準的非同步 HTTP 伺服器,支援 SSE 串流。
這意味著你可以立刻把它當作 Ollama 的輕量化替代品,串接到你常用的開發工具或瀏覽器外掛中!
下一個階段,我們將會探索:
- 多模型熱插拔 API:允許客戶端在運行時透過 API 請求動態載入/卸載不同模型。
- 進階採樣參數配置:在 API 中實作 Temperature、Top-P、Top-K 及 Repetition Penalty 等精細化推理配置。
- 更美觀的 Web 或 Desktop 管理介面。
如果你對此專案有興趣,歡迎參考完整的原始碼並一起交流!