Skip to main content

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「資料集嵌入」相關的主題。

大型資料處理

即便在 ONNX 中使用 WebGPU 實現硬體加速,資料集包含 140k 筆資料,逐一進行嵌入依然需要歷時兩個小時左右。

info

關於嵌入伺服器的更多資訊請見: Qwen3 TTS 之旅:語音嵌入 本步驟的前置作業請見: Qwen3 TTS 之旅:資料集預處理

運行當下 intel_gpu_top 顯示只有使用一半,另一方面所有 CPU 則是跑滿的狀態且負載皆在伺服器端,但是我並沒有仔細研究性能瓶頸在哪裡,推測是伺服器的 mp3 轉換的影響。 當然也有 JSON 和 base64 的解編碼運算,不過這件事情資料處理端也有,但是並沒有佔用太多 CPU,因此可能性比較低。

因此我花了一點時間實作了運算進度存檔的機制,以 100 筆資料為一個單位,除了將 100 筆作為一個 Transaction 以外,還會在用於紀錄進度的資料表紀錄進度,若過程中中斷,下次運算便可從紀錄點繼續。

向量資料庫

Milvus 是蠻有名氣的向量資料庫實作之一,不過當下我並沒有檢索的需求,僅需要一個儲存的載體,因此選擇了其 Lite 版本,會將資料儲存成 SQLite 格式。

然而實際使用時在運算到 4.9k 的資料左右會出現異常:

Assert "suc"  => failed to parse insert data from records at /workspace/milvus-lite/thirdparty/milvus/internal/core/src/segcore/segment_c.cpp:303

不確定是不是到達 1M~10M 左右的官方上限:

info

嵌入向量是 2048 維,中斷時大約是 4.9k資料;因此: 4900×2048≈10M

所以最後將向量的 JSON 序化成字串後直接使用 SQLite 儲存,雖然效率很差,但是當下最重要的是先把資料做嵌入之後找地方放,而且方便後續步驟提取,應該避免無謂的為了效率而提高程式碼複雜度。

SQLite 雖然是「輕量」的資料庫實作,但是不代表它隨便,專案下的測試程式碼甚至比實作本身還多。並且在摸索資料的階段,可攜性、方便轉移複製版控...與生產環境需要考量的性能、低冗餘與最佳化...屬於不同情境。

dataset

在進行這個主題的額外收穫是發現這個名為 dataset 的 Python 套件,它可以像這樣:

import dataset

db = dataset.connect('sqlite:///:memory:')

table = db['sometable']
table.insert(dict(name='John Doe', age=37))
table.insert(dict(name='Jane Doe', age=34, gender='female'))

john = table.find_one(name='John Doe')

直接操作一個 SQLite 實例讀寫資料,無須預先定義 Schema 或是新增資料表。

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

在我開始撰寫主成份分析、視覺化...等程式以前,先對相關的領域知識進行學習,最後定位了「流形學習 (manifold learning)」這個領域,雖然「流形學習」這個詞比較正式且有嚴謹的定義,不過本文比較接近學習筆記。

本文雖然跟學術性的文章比起來隨性很多,但是跟其他軟體實作相比又抽象了許多,所以額外拉出來當成獨立的主題談:

視覺化與降維

一個語音經過 Qwen3 模型嵌入之後,會得到 2048 個數字,這 2048 個數字稍微學術一點會被稱呼為「高維向量」或是「高維空間」,2048 個數字排列組合構成的所有可能性便是「空間」,特定的 2048 個數字,也就是某個資料點就是「向量」。

然而人類並不能直接理解這 2048 個數字代表的意思,也就是人類不能理解高維空間或是高維向量的內含。

理解空間的其中一種方法就是視覺化,但是紙張只能呈現二維的圖像、就算透過軟體與之互動也只能呈現三維的畫面,那麼我們要如何把 2048 個數字變成等效的 3 個數字然後做成圖表呢?是的這個過程就是降維。

「沒吃過豬也看過豬走路」是我的中心思想之一,在進行更進一步的介紹以前我們先隨便看幾個降維的例子吧1

它分別對兩個三維的資料用各種不同的方法降成二維。

流形 (Manifolds)

warning

以下是關於流形這個概念的通俗解釋,不具備數學上的嚴謹性。

「2048 維的高維空間」聽起來好像很厲害,不過我們可以這樣假設:

這個高維空間其實很稀疏

舉例來說,從宏觀角度來看宇宙,會發現它像海綿一樣充滿了空孔2

只有少數的空間有被恆星和物質填充。有興趣的朋友可以聽聽看 YouTube 頻道 Kurzgesagt 的介紹

又或是揉成一團的報紙,即便我們在三維空間觀察它是一個球體,但是它實際上充滿了空氣,真正有資訊的部份是以二維紙張的形式分佈的。

這個實際上是低維情報,但是在高維空間中被觀察到的東西,就是流形。

info

嚴謹的流形定義還包含了「局部近似歐幾里得空間」,只是解釋起來比較麻煩,所以以上描述省略了。 就像地球表面是弧形的,但是放大之後會接近平面。

流形學習則是把揉成球的報紙攤平的過程,並且是一種機器學習。

降維

最簡單的降維方式就是投影:

現實的例子就是你跟你的影子,找一個平面/方向就能把三維的資料壓成二維的,如何找到那個「正確的平面」則是最重要的問題。

然後讓我們回來看這張圖:

可以發現 S 型的資料在 ISOMAP 算法下被攤成漂亮的方形,接著再想想要投影出這個漂亮的方形的「面」長什麼樣子?想必不是一個平面對吧?

學術上會把這兩者分別稱呼成線性跟非線性的降維技術。

流形學習其實就是降維炫泡一點的講法。

PCA

主成份分析 (PCA, Principal component analysis) 簡單來說是一種對資料進行線性降維的技巧,而且屬於非監督機器學習。

OS:恩?怎麼突然之間我就機器學習了!?(◐_◑)

至於不簡單來說嘛...它建立在不少統計與數學工具與概念之上...

敘述統計

把幾個基本的敘述統計概念列一下。

  • 母體平均數 (Mean)
    • μ\mu
  • 標準差
    • σ=1Ni=1N(xiμ)2\sigma ={\sqrt {{\frac {1}{N}}\sum _{i=1}^{N}(x_{i}-\mu )^{2}}}
    • 需要先建立在平均數之上。
  • 變異數 (Variance)
    • Var(X)=σ2\operatorname {Var} (X)=\sigma ^{2}
    • 概念上是標準差的平方,但是實務上通常會先算出變異數,開根號之後才會得到標準差。

共變異數 (Covariance)

cov(X,Y)=1Ni=1N(xiμx)(yiμy)\operatorname {cov}(X, Y) = {{\frac {1}{N}}\sum _{i=1}^{N}(x_{i}-\mu_x )(y_{i}-\mu_y )}

計算兩個隨機變數的關聯性,越接近零代表兩個隨機變數越不相關。

共變異數矩陣(Covariance Matrix)

共變異數矩陣是 pp 個特徵交叉進行獲得共變異數構成的矩陣。以下是五維資料的共變異數矩陣例子3

共變異數矩陣的符號:

  • C=[Cjj]\mathbf {C} =[C_{jj'}]
  • KXiXj=cov[Xi,Xj]\operatorname {K} _{X_{i}X_{j}}=\operatorname {cov} [X_{i},X_{j}]
  • 在一些地方會使用符號 Σ\Sigma 代表。

特徵值與特徵向量

一般的 PCA 介紹大概會提到這個步驟:

對共變異數矩陣求特徵值 (eigenvalue) 與特徵向量 (eigenvector)

但是這個步驟開始比較抽象了,特徵值?特徵向量是啥?為什麼共變異矩陣的特徵值跟特徵向量會跟主成份有關系?

這裡我要試著用工程數學的經隨:先射箭再畫靶,來解釋這件事情。

首先我們假設一個白化空間 (whitening space),每一個維度都是標準常態分佈且每一個維度的隨機變數都獨立於彼此:

在這個 nn 維空間的向量可以透過一個 n×nn\times n矩陣變換到另外一個歪曲的空間去,圓形可能變成橢圓形。

共變異數矩陣反應的就是這個白化空間經過變換矩陣得到的觀察結果,那個歪曲的空間就是我們觀測到的空間,因此求那個矩陣的的特徵值與特徵向量就是分解這個矩陣的變換行為。

特徵向量是原本在這個白化空間中正交的向量經過變換後在觀察空間的表示,而特徵值則是從白化空間變換到觀察空間拉伸的比率。

所以我們可以知道,白化空間中一部分的維度在觀察空間中會被放大,換言之,觀察空間中的主要變化可能來自於白化空間中少數幾個維度的貢獻。

這個白化空間就是我們的主成份空間。(這句話嚴謹意義上不完全正確,但是方便理解)

解釋變異量 (Explained Variance)

解釋變異量是各個特徵值在所有特徵值和的佔比,代表著主成份空間某個維度對觀察空間資訊量貢獻的比例,因為特徵值越大、成份在觀察空間中放大的程度就越大。

將解釋變異量排序後畫成圖表便能一目了然各個主成份的佔比為何,積分後便得到累積解釋變異圖(Cumulative Explained Variance):

綠色方條是解釋變異量;紅色曲線是累積解釋變異。透過累積解釋變異我們可以知道降維到幾個主成份可以保留足夠多的特徵。

實務上可取 70% 左右的固定值或是觀察拐點;即便主成份比例低,但是其他成份分佈過於均勻的話依然可以視為雜訊。

PCA 降維

PCA 的最後一個步驟。有了特徵向量、特徵值並選定主成份維度,便可構造一個矩陣把高維資料進行線性變換以實現降維了。

Footnotes

  1. Principal Component Analysis(PCA) | by venkateshtantravahi | Medium. Retrieved 2026-04-06, from https://vtantravahi.medium.com/principal-component-analysis-pca-37dc2c22cdf0

  2. New Research Supports the Idea That We Live in a Void. Retrieved 2026-04-06, from https://scitechdaily.com/new-research-supports-the-idea-that-we-live-in-a-void/

  3. Principal Component Analysis Made Easy: A Step-by-Step Tutorial | by Marcus Sena | TDS Archive | Medium. Retrieved 2026-04-06, from https://medium.com/data-science/principal-component-analysis-made-easy-a-step-by-step-tutorial-184f295e97fe

Wei Ji

從我踏入 Qwen3 TTS 這個兔子洞到現在也已經兩個多禮拜了 (2026-03-20~2026-04-06),即便還沒徹底完成一開始設定的目標也該設定一個存檔點;把歷程做個紀錄,然後回去忙其他事情了。然而因為整個過程並不是一個線性的故事,很難結構化的描述,因此本文會先概述整個事件的全面以及這個兔子洞是怎麼發生的,細節將由其他文章補充。

火種

這個旅途說是因為一張圖而起的也不違過:

Qwen3-TTS 是 2026 年一月釋出極為先進的 TTS 開放權重模型,而有個老兄將該模型前段一區用於進行語音嵌入的類神經網路抽出做成可獨立運行的模型1,這裡要稍微解釋一下這件事為什麼了不起,從嵌入的角度來說:

  1. OpenAI-Compatible API 中的 /embeddings 端點僅覆蓋了「文字嵌入」,語音嵌入在工程應用領域依然相對稀疏。(細節我會在文章後面的內容詳談)
  2. 它是開箱即用的開放權重模型,不像一些語音嵌入模型僅有論文。
  3. 該模型產出的嵌入向量可作為後端聲音複製 (Voice Clone) 模型的輸入,因此該嵌入向量是有額外工程用途的,而不是單純比較餘弦距離來判斷語音音色的相似度。

從語音的角度:

  1. 基於文字的 TTS 其輸出並不穩定,因為「一個沈重的男子聲音」可以存在無限多種解。
  2. 大部分基於語音複製的 TTS 需要仰賴原音樣本的存在。
  3. 大部分基於語音複製模型須提供語音的內文標籤,往往會鎖定特定語言。(原音樣本鎖定特定語言)

因此只要能調控嵌入空間,就能產生一個穩定的 TTS 客製化流水線。

構想

於是一個構想油然而生:

透過資料降維的技術將一個已知資料集的嵌入向量視覺化,使用者在三維的的空間中可以選定某個資料點試聽,同時也可以也可以選定空間中不存在資料點的空間,透過降維的反函數生成嵌入向量,將開嵌入向量傳遞給 TTS 進行生成。

在三維空間中透過已知資料集的標籤與特徵讓使用者理解某個區域具有某些語音特徵,例如:男性特徵、女性特徵、年長者特徵...。

簡單來說這是一個能夠客製化聲音的 TTS 工作流程。

旅程概覽

整個旅途到目前為止大概可以分成幾個主題:

  • Voice Embedding: 我把玩 Qwen3 Voice Embedding 以及進行一些模組化的過程。
  • Audio ETL (Extract, Transform, Load):對 cv-corpus-25.0-2026-03-09 (Mozilla Common Voice 25.0) 進行一些預處理的過程。
  • Embedding ETL:對資料集進行嵌入的過程。
  • PCA ETL:對嵌入向量的資料集進行主成份分析 (Principal components analysis) 的過程。
  • Qwen3 TTS ONNX:我把玩 Qwen3 TTS ONNX 以及試圖進行一些模組化的過程。
  • Continuity Test:嵌入空間連續性測試。
  • Manifold Learning:對「資料降維」這一領域進行學習的過程。

並且上述主題並沒有明確的嵌後順序,而且事件相互交錯,在後續的文章我會試著僅以一個主題為主軸、相關事件為輔的方式描述,讓整個敘述比較不會太混亂。

Footnotes

  1. Qwen3's most underrated feature: Voice embeddings : r/LocalLLaMA. Retrieved 2026-04-02, from https://www.reddit.com/r/LocalLLaMA/comments/1rc59ze/qwen3s_most_underrated_feature_voice_embeddings/

Wei Ji

前情提要

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「語音嵌入」相關的主題。

"運行" Qwen3-Voice-Embedding

我當然可以依照作者提供的程式碼直接運行這個模型:

import librosa
import torch
from transformers import AutoModel, AutoProcessor

processor = AutoProcessor.from_pretrained(
"marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B", trust_remote_code=True,
)
model = AutoModel.from_pretrained(
"marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B", trust_remote_code=True,
)
model.eval()

audio, sr = librosa.load("audio.wav", sr=None, mono=True)
inputs = processor(audio, sampling_rate=sr)

with torch.no_grad():
embedding = model(**inputs).last_hidden_state # (1, 2048)

但是對我而言「運行」不只是這樣,它必須遵守幾個基本要件:

  1. 軟體編排的解偶:模型推論本質上是仰賴 GPU 的重負載運算,因此不能跟應用程式的業務邏輯耦合在一起,必須使用 OpenAI-Compatible API。
  2. 硬體的解偶:不能對特定的 GPU 品盤形成供應商鎖定,因此不能使用 CUDA。
  3. GPU 加速:這類模型典型的「無 CUDA 備用方案」是直接降回使用 CPU 運算,這對我而言是不能接受。

簡化之後的描述為:

  1. 軟體編排的解偶:封裝成 OpenAI-Compatible API。
  2. 硬體的解偶:在不使用 CUDA, ROCm...等專有 SDK 的前提實現 GPU 加速推論。

軟體編排的解偶

info

關於這個主題我有撰寫一份非線性筆記

OpenAI 嵌入 API 設計上只能處理「文字→向量」的嵌入運算,從官方的規格書可以看到:

輸入必然為字串,原因是 OpenAI 提供的嵌入模型僅有:text-embedding-3-smalltext-embedding-3-largetext-embedding-ada-002 這幾款文字嵌入模型,因此無法處理多模態問題,諸如嵌入音訊、影像、圖像...之類的。

即便參考其他多模態嵌入 API 設計,其 API 設計僅部份參考 OpenAI API 但不兼容,例如:

LCO-Embedding-Omni-7B-GGUF 透過 llama.cpp 的 API:
curl -s http://localhost:8080/embeddings \
-d '{"content": [{"prompt_string": "<__media__>", "multimodal_data": ["<base64-audio-data>"]}]}'
nvidia/llama-nemotron-embed-vl-1b-v2 透過 OpenRouter 的 API:
curl https://openrouter.ai/api/v1/embeddings \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "nvidia/llama-nemotron-embed-vl-1b-v2:free",
"input": [
{
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://live.staticflickr.com/3851/14825276609_098cac593d_b.jpg"}}
]
}
],
"encoding_format": "float"
}'
mistralai/voxtral-small-24b-2507 透過 OpenRouter 的 API:
curl https://openrouter.ai/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d '{
"model": "mistralai/voxtral-small-24b-2507",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this audio?"
},
{
"type": "input_audio",
"input_audio": {
"data": "UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB",
"format": "wav"
}
}
]
}
}'

它本身並不是嵌入模型,但是作為「使用 OpenAI API 輸入音訊檔案」的參照。

最後我決定透過引入 RFC 2397 與 RFC 3003 並同時遵守 OpenAI API 的設計哲學並兼容 OpenAI 嵌入 API,同時擴增支援多模態的能力。

# Note: "input" also supports batch processing with arrays: ["text1", "text2", "text3"]
curl https://openrouter.ai/api/v1/embeddings \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B",
"input": "data:audio/mpeg;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA
AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
9TXL0Y4OHwAAAABJRU5ErkJggg==",
"encoding_format": "float"
}'

硬體的解偶

我原本想遵循之前使用 Zero123++ 時的路徑,使用 IPEX (Intel® Extension for PyTorch) 來實現 GPU 加速,不過過程並不順利,然而 Qwen3-Voice-Embedding 上傳者也準備了一份 ONNX 版本的模型,於是我便研究一下 ONNX 這條路徑要怎麼跑這個模型。

就像 llama.cpp 支援諸如 CUDA、Vulkan ...之類的多種後端 (backend) 一樣,ONNX 則是透過 EP (Execution Provider) 的架構來和運行時的硬體加速解偶1,同時它也允許運行在網頁瀏覽器使用 WebGPU 作為 EP,然而官方文件與資料卻鮮少提及如何在 Python 使用 WebGPU 作為 EP。

經過一番搜尋終於找到了線索,總之需要安裝 onnxruntime-webgpu 這個套件,程式碼大致如下:

run.py
import numpy as np
import onnxruntime as ort
import librosa
from huggingface_hub import hf_hub_download

model_path = hf_hub_download(
repo_id="marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B-onnx",
filename="speaker_encoder_fp32.onnx",
)

# Load model
session = ort.InferenceSession(
model_path,
providers=["WebGpuExecutionProvider"],
)

# Compute mel spectrogram (must match training preprocessing)
audio, sr = librosa.load("female.mp3", sr=24000, mono=True)
mel = librosa.feature.melspectrogram(
y=audio,
sr=24000,
n_fft=1024,
hop_length=256,
n_mels=128,
fmin=0,
fmax=12000,
)
mel = np.log(np.clip(mel, a_min=1e-5, a_max=None))
mel = mel.T[np.newaxis, ...] # (1, time, 128)

# Run inference
embedding = session.run(None, {"mel_spectrogram": mel.astype(np.float32)})[0]
print(embedding)

最後做個補充,我亦考慮過 llama.cpp 的 GGUF 路徑來解決這個問題,然而 GGUF 的生態系是以 LLAMA,即 LLM 建立起來的,對於多模態應用生態系覆蓋的不夠完善,舉例來說,我們可以在上述程式碼看到使用 librosa 來處理音訊檔案,這在 llama.cpp 中可不是只靠一個 mmprog (多模態映射)就能在類神經結構上解決的問題,而需要仰賴額外的音訊整理實做;API 的格式也是相同的情況,從 LCO-Embedding-Omni-7B-GGUF 就可以看到非通用標準的 API 格式。

結論

封裝成 OpenAI-Compatible API 之後,不論是後續實驗還是生產佈署都可以大幅簡化環境設定,只要有 Docker 就能跑起來:

services:
qwen3-voice-embedding-server:
image: ghcr.io/flyskypie/qwen3-voice-embedding-server:0.1.2
devices:
- /dev/dri/:/dev/dri/
ports:
- 8000:8000
environment:
- HF_HOME=/cache
volumes:
- ./cache:/cache

程式碼:https://github.com/FlySkyPie/qwen3-voice-embedding-server

插曲

一直到在進行連續性測試的時候,才發現嵌入伺服器這邊有 bug:輸入女聲進行嵌入、嵌入向量 TTS 出來的聲音是男聲。

雖然早在進行視覺化的前期就有徵兆了:

可以看到男聲(藍色資料)跟女聲(粉紅色)混在一起,但是當時以為是資料特性,並沒有意識到是嵌入出 bug。

原因在於 pytorch 的實作中並沒有複雜的參數設定:

audio, sr = librosa.load("audio.wav", sr=None, mono=True)
inputs = processor(audio, sampling_rate=sr)

而 ONNX 的有:

audio, sr = librosa.load("audio.wav", sr=24000, mono=True)
mel = librosa.feature.melspectrogram(
y=audio, sr=24000, n_fft=1024, hop_length=256,
n_mels=128, fmin=0, fmax=12000,
)

特別是採樣率的部份我疏忽了,造成輸入的樣本在處理過程中被放慢兩倍,因而產生女聲變男聲的問題。

Footnotes

  1. Execution Providers | onnxruntime. Retrieved 2026-04-02, from https://onnxruntime.ai/docs/execution-providers/

Wei Ji

前情提要

我在 Homelab 使用 Longhorn 作為儲存後端,並且其實已經經歷過幾次調整 PVC (Persistent Volume Claim) 容量了,分別是 PyPi 和 Hugging Face 本地鏡像。

info

「儲存後端」在 K8s 的正確術語為 Storage Provider 或是理解成 CSI(Container Storage Interface) 實作,我稱為儲存後端只是為了通俗理解與方便不熟的讀者閱讀。

最近則是 S3 實例 (MinIO) 快滿了,想說這次擴展的時候順便寫個筆記紀錄一下。

調整大小

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
labels:
io.kompose.service: minio-data
name: minio-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 250Gi
kubectl apply -k .

然後...就可以了...

配額已達上限

算是小插曲,原本想從 200Gi 翻成兩倍的,但是看來總配額已經滿了:

kubectl apply -k .
service/minio-service unchanged
statefulset.apps/minio unchanged
ingress.networking.k8s.io/minio unchanged
Error from server (Forbidden): error when applying patch:
{"metadata":{"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"kind\":\"PersistentVolumeClaim\",\"metadata\":{\"annotations\":{},\"labels\":{\"io.kompose.service\":\"minio-data\"},\"name\":\"minio-data\",\"namespace\":\"minio-stack\"},\"spec\":{\"accessModes\":[\"ReadWriteOnce\"],\"resources\":{\"requests\":{\"storage\":\"400Gi\"}}}}\n"}},"spec":{"resources":{"requests":{"storage":"400Gi"}}}}
to:
Resource: "/v1, Resource=persistentvolumeclaims", GroupVersionKind: "/v1, Kind=PersistentVolumeClaim"
Name: "minio-data", Namespace: "minio-stack"
for: ".": error when patching ".": admission webhook "validator.longhorn.io" denied the request: error while CheckReplicasSizeExpansion for volume pvc-2fad0e19-2f42-4dd2-8dc7-4e705f227432: cannot schedule 214748364800 more bytes to disk b08f46da-f50f-481d-8eff-de1e605e7859 with &{DiskUUID:b08f46da-f50f-481d-8eff-de1e605e7859 StorageAvailable:1702363136000 StorageMaximum:3936770629632 StorageReserved:214748364800 StorageScheduled:3625277456384 OverProvisioningPercentage:100 MinimalAvailablePercentage:25}

故障排除

這次過程蠻順利的,但是之前擴展的時候其實遇過 PVC 改大小是卡住的,但是那個時候沒截圖。故障排除的方式是需要把有使用 PVC 的 Pod (StatefulSet) 暫時移除並且 Detach PVC1

後記

順便補一下一些跟 PVC 有關的東西。

PV 持久化設定

PV (Persistent Volume) 有一個叫做 reclaimPolicy 的設定,如果沒有設定為 Retain 的話 (預設值為 Delete),在 PVC 資源移除時對應的 PV 會被系統自動清除。

更新系統層級的設計,日後新的 PVC 會遵守該設定2

kubectl -n longhorn-system edit configmaps longhorn-storageclass
# 找到 `reclaimPolicy` 欄位設定成 `Retain`

若要更新已經建立的 PV,則使用以下指令:

for pv in $(kubectl get pv -o jsonpath='{.items[*].metadata.name}'); do     kubectl patch pv "$pv" -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'; done

PVC Dashboard

平常是使用 Prometheus Helm 內建的 Grafana Dashboard (Kubernetes/Persistent Volumes),雖然可以單獨檢查 PVC 但是缺乏快速對所有 PVC 有一個總覽可以快速掌握的功能。於是我安裝了另外一個 Dashboard 來做這件事:

只是不知道為什麼有一點瑕疵:

  • 其中一個 PVC 的數字異常。
  • 使用百分比的進度條顯示異常。

只是目前暫時懶得故障排除。

Footnotes

  1. How can I resize the longhorn volume? The new size should be shown in both rancher&longhorn UI · Issue #2263 · longhorn/longhorn. Retrieved 2026-04-01 from https://github.com/longhorn/longhorn/issues/2263#issuecomment-935739738

  2. How to change reclaimPolicy without reinstalling everything? · longhorn/longhorn · Discussion #10102. Retrieved 2026-04-01 from https://github.com/longhorn/longhorn/discussions/10102#discussioncomment-14380415

Wei Ji

我認為教育的目的應該是提高知識的廣度,因此對學生的要求應該以「知道定性描述」為重點。

我以理想氣體狀態方程式作為例子,理想氣體狀態方程式在歷史的發展上其實是由多個定律構成的:

  • 波以耳定律
    • 氣體壓力與氣體體積成反比。
  • 定壓查理定律
    • 壓力恆定時,一定量氣體的體積與其溫度成正比。
  • 定容查理定律
    • 定量定容的理想氣體,壓力與絕對溫度成正比。

我們可以發現它們都是定性描述,當然當你把理想氣體方程式這個定量描述背起來之後能夠回答所有定性描述。

問題是人應該是對多個事物有定性描述的基本理解,必要的時候再根據定性描述去找到定量描述才對。但是我們的教育體系卻總是將定量描述當成驗收標準。

以上是沈澱在我思緒中已久的想法,直到最近在學習 RAG (Retrieval-Augmented Generation) 系統時才對這件事有更深的體悟。

「把一個知識塊嵌入到高維向量空間,檢索的時候先對問題嵌入,在透過向量把知識檢索出來」這個過程不就是人類運作的方式嗎?

  • 嵌入向量本身不自帶知識,而是一種「感覺」。
  • 對問題進行嵌入本質上就是一種透過定性描述找資料,「感覺這個問題屬於這個空間這區域的東西」。
  • 檢索知識塊,即是找到定量描述。

Wei Ji

一天到晚聽 AI 跟風仔滿嘴 Agent;Agent 這個;Agent 那個...所以 Agent 到底是什麼?

心血來潮我想寫一篇文解釋 Agent 是什麼。

OpeAI API

故事要從 OpeAI API 說起,一個目前實質成為產業標準的 HTTP API。

OpeAI API 的規格書包含了很多 Endpoint,檔案甚至高達 2.4 MB,不過我們今天要談的主角是 POST /chat/completions 這個 Endpoint:

簡單來說 LLM 本身是無狀態的,messages 這個欄位簡單來說就是一個對話紀錄,透過上傳使用者跟 LLM 說過的話(對話紀錄)令 LLM 預測下一個回答從而產生「有記憶」的效果。

不過今天的重點是這個,

請求 (Request) 內的 functions 和回應 (Response) 內的 function_call

看規格書太抽象,接下來我們來看具體的例子。

OpeAI API function_call 具體案例

這是一個應用程式中的「證據」功能,也就是要從參考資料中標示具體回答問題的段落文字:

info

這是我調查 kotaemon 這個應用程式時發現的,更多內容可以至我的另外一篇文章查看:

不正經 LLM APP 調查:kotaemon

messages 長這樣,包含了使用者個原始問題、系統檢索的資料、系統提示詞:

答案跟問題放在一起?這不就是開書考(open book)嗎?沒錯,這就是 RAG (Retrieval-augmented generation) 運作的基本原理,不過 RAG 不是本文的重點,以後再談。

functions 長這樣:

LLM 的 function_call 長這樣:

我們可以看到 LLM 根據 functions 的提示填入對應的資訊,上面那一串 Thought 是一種名為 Reasoning 的東西,在這裡先不管它。

LLM Agent

原本使用者跟 LLM 的關係是這樣的,使用者問、LLM 答。

但是透過 function_call API,它允許角色翻轉過來:

主動權交給 LLM,由程式操作的 User 則負責回答 LLM 的 function_call,只要 LLM 不主動結束對話,這個迴圈就不會結束。

小結

"Agent" 一詞大概是從增強式學習 (Reinforcement learning) 借鏡過來的。

一來是比較專業,二來是這個用詞可能會給人一種「可獨立運作的 AI」的感覺(可以用來欺騙投資人和大眾)。不過老實說,目前大部分的 LLM 應用軟體都受到 OpenAI API 的設計影響(比如那個會不對累積最後把上下文塞爆的對話紀錄萬惡設計),與其去牽扯什麼高大上的理論,目前的實做方式就只是透過 OpenAI API 的特性反轉了請求與回應之間的關係罷了。

最近在研究 RAG 的範式,便看到了 Agentic RAG,我的天啊!Agentic Coding (俗稱 Vibe Coding) 就已經夠我煩躁的了,連 RAG 也來?總之我想說試著以這個視角整理一下思緒,順便寫下來向其他人解釋 Agent 是什麼。

Wei Ji

一圖以敝之:

開發者本應跟著專案一起成長,實際上只有部份被開發者消化過的領域知識會變成程式碼,並且程式碼本身又隱性的蘊含了一些領域知識,如果不具備相關領域知識的人可能無法從程式碼表面得知那些領域知識。一般透過註解或是文件來降低這種落差。

透過 Agentic Coding (俗成 Vibe Coding),即 LLM (大型語言模型)的幫助,程式碼產出的速度來到前所未有的高度,但是這可能會面臨一些問題:

程式碼本身並不蘊含來自現實世界的領域模型或領域知識,又或是蘊含甚少,因為 LLM 的上下文可能缺少了關鍵的領域知識,程式碼是根據表象的需求實做的,如此建立的軟體模型很有可能會跟現實問題脫句。

開發者成長遭到削弱,當工作流程一謂的關注堆砌程式碼,開發者不再學習領域知識,而是專注於建構虛有其表的程式碼,這不論是對開發者個個人生涯還是專案擁有者都十分不利,開發者的成長停滯;開發者缺乏對領域知識的理解更不可能撰寫出對應的交接文件。

表象上軟體的程式碼成長了,但是實際上軟體專案本身的成長是受阻的。

Wei Ji

前情提要

想著調查一些 LLM 應用程式的 RAG 功能,關於調查的方向跟基準請見前一篇文章,不在此贅述:

不正經 LLM APP 調查:AnythingLLM

同系列其他調查文:

OCI 構成

podman image tree
podman image tree ghcr.io/cinnamon/kotaemon:0.11.0-full 
Image ID: 74d5df3245d9
Tags: [ghcr.io/cinnamon/kotaemon:0.11.0-full]
Size: 6.548GB
Image Layers
├── ID: 1bb35e8b4de1 Size: 77.88MB
├── ID: 353fe48ef9d7 Size: 9.561MB
├── ID: 82b4b4274b1a Size: 44.84MB
├── ID: d638946917f2 Size: 5.12kB
├── ID: 04c0a0d2e22a Size: 841.8MB
├── ID: aeb378591839 Size: 1.536kB
├── ID: 5fd7d86f70a2 Size: 4.096kB
├── ID: aed840e6f7fe Size: 4.096kB
├── ID: 92333158b865 Size: 16.69MB
├── ID: 0bf855215466 Size: 17.63MB
├── ID: 2235373e4fb4 Size: 1.024kB
├── ID: 45504146ffa4 Size: 4.096kB
├── ID: 8402ed8b9654 Size: 2.006GB
├── ID: 764658892e4b Size: 591.2MB
├── ID: 184d088eca1f Size: 7.68kB Top Layer of: [ghcr.io/cinnamon/kotaemon:0.11.0-lite]
├── ID: cf40a4fad18d Size: 868.5MB
├── ID: e67900442530 Size: 709MB
├── ID: 8c92b78037b8 Size: 723.4MB
├── ID: c4c98de4129d Size: 28.91MB
├── ID: 54f8511f72bc Size: 596.6MB
├── ID: f5e4b450a048 Size: 15.28MB
└── ID: 4f1b040c4c99 Size: 7.68kB Top Layer of: [ghcr.io/cinnamon/kotaemon:0.11.0-full]

單一映像檔 6.548GB,最大一層 2GB,有趣的是有切一個 Lite 版本。

簡單對話

同有有對話標題生成,這裡採用的策略有點有趣:

系統提示詞可以設定:

嵌入文件

UI 雖然很簡陋,但是可以看到所有切割的字串塊:

檢索知識

右邊會顯示參考資料:

被提示詞「Give answer in English.」約束,輸入中文還是回答英文:

翻了一下 LLM 紀錄,一次 RAG 有 13 次 LLM 請求,一個是標題摘要:

其中 10 次是針對每一個資料塊進行評分:

其中一次是呼叫工具:

最後是總結:

編排與構成

docker-compose.yaml
services:
kotaemon:
image: ghcr.io/cinnamon/kotaemon:0.11.0-full
environment:
- GRADIO_SERVER_NAME=0.0.0.0
- GRADIO_SERVER_PORT=7860
volumes:
- kotaemon-data:/app/ktem_app_data
ports:
- 7860:7860

llama-cpp:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
restart: always
devices:
- /dev/dri/:/dev/dri/
ports:
- 8080:8080
entrypoint: /app/llama-server
environment:
- HF_ENDPOINT=http://huggingface.mirrors.solid.arachne
volumes:
- llama-cpp-cache:/root/.cache/llama.cpp
command:
- --hf-repo
- Qwen/Qwen3-Embedding-8B-GGUF
- --hf-file
- Qwen3-Embedding-8B-Q6_K.gguf
- --embeddings
- --pooling
- mean
- --ctx-size
- "2048"
- --batch-size
- "1024"
- --ubatch-size
- "2048"
- --gpu-layers
- "999"
- --flash-attn
- on
- --no-webui
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 20s
retries: 3

volumes:
kotaemon-data:
llama-cpp-cache:

雖然不是這一系列評測的重點,不過我覺得這個設計值得提一下,我認為 kotaemon 蠻優雅的處理不同的 AI 來源,它直接暴露 YAML 以及所有有效參數的說明:

看起來是直接使用 LangChain 的實例:

實作程序關閉

是否有實作 Graceful Shutdown? 否。

kotaemon-1 exited with code 137

Wei Ji

OCI 構成

podman image tree
$ podman image tree ghcr.io/danny-avila/librechat:v0.8.3
Image ID: db02d9011e1e
Tags: [ghcr.io/danny-avila/librechat:v0.8.3]
Size: 2.201GB
Image Layers
├── ID: 989e799e6349 Size: 8.724MB
├── ID: 6758d7b35d86 Size: 124MB
├── ID: 333310cf910c Size: 5.389MB
├── ID: d29cbceffa1f Size: 3.584kB
├── ID: 935b10faad71 Size: 624.1kB
├── ID: 1659844c9896 Size: 86.56MB
├── ID: 2adf2e4404c0 Size: 50.48MB
├── ID: 880e8eea9c5e Size: 1.024kB
├── ID: ab8949522823 Size: 1.536kB
├── ID: b71ecaa26171 Size: 1.024kB
├── ID: a47335e9373a Size: 1.763MB
├── ID: 4d4581101e46 Size: 6.656kB
├── ID: cbfbdba828ac Size: 8.704kB
├── ID: 999156c3e462 Size: 5.632kB
├── ID: a06c4078ab7d Size: 5.632kB
├── ID: 1cb7320dde2b Size: 8.704kB
├── ID: e81caf326165 Size: 1.857GB
├── ID: d7bc0ff01085 Size: 21.7MB
└── ID: 2cda3d91d984 Size: 44.34MB Top Layer of: [ghcr.io/danny-avila/librechat:v0.8.3]
podman image tree ghcr.io/danny-avila/librechat-rag-api-dev-lite:v0.7.2
Image ID: bbfc3176d88a
Tags: [ghcr.io/danny-avila/librechat-rag-api-dev-lite:v0.7.2]
Size: 1.628GB
Image Layers
├── ID: a257f20c716c Size: 81.04MB
├── ID: 1820c49e830a Size: 4.123MB
├── ID: 1e553be60c00 Size: 41.2MB
├── ID: a24335924e53 Size: 5.12kB
├── ID: 700e73441c4c Size: 1.536kB
├── ID: 573812830903 Size: 414.4MB
├── ID: 7458178c5deb Size: 3.072kB
├── ID: e160554a24aa Size: 1.063GB
├── ID: 14437fc54cb5 Size: 24.2MB
└── ID: 22b5883936f5 Size: 502.3kB Top Layer of: [ghcr.io/danny-avila/librechat-rag-api-dev-lite:v0.7.2]

ghcr.io/danny-avila/librechat-rag-api-dev-lite:v0.7.2 1.6GB,單層最多 1.1GB。

ghcr.io/danny-avila/librechat:v0.8.3 2.2GB,最大單層 1.86GB。稍微看了一下是 npm ci 安裝套件造成的,不過我理解的沒錯的話,主因是映像檔同時包含了前端的仰賴。

簡單對話

沒有系統提示詞:

但是有看到似乎可以修改提示詞的界面。

另外一次請求則是做總結來幫該次對話取一個標題:

上傳與嵌入文件

LibreChat 有兩種上傳方式:純文字與嵌入模式。

並且沒有實做類似知識庫的系統,只能在聊天視窗上傳:

檢索知識

它有一個 "File Search" 的功能,打開之後並沒有觸發檔案檢索:

看起來需要搭配檔案使用:

全文檢索:

直接把整個檔案變成純文字之後放進上下文:

嵌入檢索:

透過嵌入模型與向量資料檢索部份文字塊之後放入上下文:

編排與構成

以下是官方的 Docker 設定:

docker-compose.yaml
# Do not edit this file directly. Use a ‘docker-compose.override.yaml’ file if you can.
# Refer to `docker-compose.override.yaml.example’ for some sample configurations.

services:
api:
container_name: LibreChat
ports:
- "${PORT}:${PORT}"
depends_on:
- mongodb
- rag_api
image: registry.librechat.ai/danny-avila/librechat-dev:latest
restart: always
user: "${UID}:${GID}"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- HOST=0.0.0.0
- MONGO_URI=mongodb://mongodb:27017/LibreChat
- MEILI_HOST=http://meilisearch:7700
- RAG_PORT=${RAG_PORT:-8000}
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
volumes:
- type: bind
source: ./.env
target: /app/.env
- ./images:/app/client/public/images
- ./uploads:/app/uploads
- ./logs:/app/logs
mongodb:
container_name: chat-mongodb
image: mongo:8.0.17
restart: always
user: "${UID}:${GID}"
volumes:
- ./data-node:/data/db
command: mongod --noauth
meilisearch:
container_name: chat-meilisearch
image: getmeili/meilisearch:v1.35.1
restart: always
user: "${UID}:${GID}"
environment:
- MEILI_HOST=http://meilisearch:7700
- MEILI_NO_ANALYTICS=true
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
volumes:
- ./meili_data_v1.35.1:/meili_data
vectordb:
container_name: vectordb
image: pgvector/pgvector:0.8.0-pg15-trixie
environment:
POSTGRES_DB: mydatabase
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
restart: always
volumes:
- pgdata2:/var/lib/postgresql/data
rag_api:
container_name: rag_api
image: registry.librechat.ai/danny-avila/librechat-rag-api-dev-lite:latest
environment:
- DB_HOST=vectordb
- RAG_PORT=${RAG_PORT:-8000}
restart: always
depends_on:
- vectordb
env_file:
- .env

volumes:
pgdata2:

一開始照著用有把嵌入檔案的功能弄出來,但是因為它讓兩個服務共用 .env,所以其實不知道哪個設定是對應哪個服務的,所以我是著精簡化它,但是嵌入功能就不見了,折騰一下子還是找不到正確的配置方式。官方的文件也沒有把設定分開來講,最後就放棄了。

實作程序關閉

是否有實作 Graceful Shutdown? 有。

LibreChat exited with code 0
rag_api exited with code 0

小結

有 Libre 開頭暗示著它的開發者立場是擁抱開源的資訊人(?),所以大部分設定都是透過組態檔完成的,並沒有提供太多 GUI 來供使用者設定,這對外行人而言無疑提高了入門門檻。

但是反過來,映像檔內殘留前端的仰賴;服務拆成兩個設定卻混在一起(不論是實作還是文件);RAG 缺乏審計機制...都顯得不夠成熟。