本文由 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)。
其底層數據流如下:
在 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 80803. 打開瀏覽器用語音聊天
開瀏覽器連到 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 專案見囉!