跳到主要內容

    拾音企業伺服器架構:錄音交給公司的顯示卡轉錄,伺服器不留內容

    拾音 4.2 的企業伺服器把會議錄音交給機房的 NVIDIA 顯示卡轉錄。這篇只寫難的地方:跟 app 逐段相同的轉錄、兩套 ggml 的衝突、多卡編號、幾小時錄音的續傳、refresh token 的競態,還有兩邊都不能信任對方送來的值。

    重點

    • 伺服器用 git submodule 引用拾音的 C# 轉錄核心,Mac 和 Linux 轉出的 27 段文字完全相同;Qwen3-ASR 因為兩套 ggml 符號同名,只能用子程序跑。
    • 多張顯示卡要設 CUDA_DEVICE_ORDER=PCI_BUS_ID 編號才對得上 nvidia-smi,分配照正在跑的場數,不照使用率。
    • 安全審查找到的都是沒檢查對方送來的值:100 bytes 的上傳讓伺服器配 2 GB,307 轉址把 refresh token 送到別台。
    目錄

    拾音 4.2 多了企業伺服器。員工登入公司帳號,錄音就交給機房的 NVIDIA 顯示卡轉錄,整理用公司設定的模型。這篇只寫做的時候難的地方。

    架構

    拾音 app(Mac、Windows) HTTPS:PKCE 登入、8 MB 分塊上傳、輪詢、整理 公司的反向代理(TLS) shiyin-server・一個 Docker image・ASP.NET Core(.NET 10) /api/v1 app 的 API(Bearer token) /login、/join 公司帳號登入、加入連結 /admin 後台(Razor Pages) SQLite(WAL) 佇列、帳號、token 的 SHA-256 稽核紀錄(雜湊鏈) JobQueue → 拾音的 C# 核心 whisper.cpp(CUDA,同一個程序) llama-server(Qwen3-ASR,子程序) LlmProxy 格式白名單、額度、預算 上游只看到匿名代號 NVIDIA 顯示卡 × N 照正在跑的場數分配 公司設定的模型 自架、OpenAI、Anthropic、Gemini
    app 只連得到三組入口。轉錄走 JobQueue 到顯示卡,整理走 LlmProxy 到公司的模型。

    一台主機、一個 Docker image。ASP.NET Core 加 SQLite,佇列也放 SQLite,不用 Redis 和訊息佇列。

    伺服器轉的要跟 app 逐段相同

    伺服器另外用 Python 寫一套轉錄,結果會跟本機轉的不一樣,規則要維護兩份,標準答案也管不到。所以伺服器不另外寫,用 git submodule 引用拾音 Windows 版的 C# 核心,分段、接縫、過濾的規則只有一份。91 秒的合成會議,Mac(Metal)和 Linux 容器(CPU)都轉出 27 段,文字完全相同。

    卡住的是 Qwen3-ASR。whisper.cpp 和 llama.cpp 各帶一套 ggml,符號同名,Linux 的動態連結會用先載入的那份,放同一個程序就壞。Qwen 只能用 llama-server 子程序跑。

    多張顯示卡的編號對不上

    shiyin-server 程序・CUDA_DEVICE_ORDER=PCI_BUS_ID JobQueue・GpuAllocation 新的一場給正在跑最少的卡,一樣多再比顯示卡記憶體 whisper.cpp(Breeze) 第一套 ggml・gpu_device = 0 llama-server(Qwen3-ASR) 第二套 ggml・CUDA_VISIBLE_DEVICES=1 子程序 GPU 0 nvidia-smi 的編號 GPU 1 子程序裡看到的是 0 號 CUDA 預設把最快的卡排第一, nvidia-smi 照 PCI 匯流排的順序
    兩套 ggml 符號同名,不能放同一個程序,所以 Qwen 一場開一個子程序,用環境變數限定它看得到的卡。

    CUDA 預設把最快的卡排第一,nvidia-smi 照 PCI 匯流排的順序,後台寫的 GPU 1 不一定是 CUDA 的 1。整個程序要設 CUDA_DEVICE_ORDER=PCI_BUS_ID,whisper 在同一個程序裡才對得上。

    分配照正在跑的場數,不照使用率。轉錄剛開始幾秒使用率還沒上來,照使用率挑,同時開始的兩場會擠到同一張。

    幾小時的錄音怎麼傳

    八小時的 16-bit WAV 約 920 MB,一次傳完、斷一次就要重來。上傳借 tus 協定的 Upload-Offset,一次 8 MB。

    拾音 app shiyin-server Mac:同時在本機 辨識說話者 POST /transcriptions(大小、SHA-256) id、status: uploading PATCH Upload-Offset: 0(8 MB) PATCH Upload-Offset: 8 MB ✕ 斷線 HEAD Upload-Offset:收到哪裡 PATCH 從那裡接著傳,直到收滿 GET(排隊 5 秒、轉錄中 2 秒一次) queueAhead、progress、done GET /result,寫進本機資料庫後 DELETE 收滿 → 比 SHA-256 → 讀 WAV 標頭 → 排隊
    斷線只重傳沒收到的部分。SHA-256 收滿才比,不對就清掉、從頭傳一次。

    refresh token 輪換的競態

    refresh token 每換一次舊的就作廢;作廢的又被拿來用,代表可能被偷,整台電腦登出。問題是 app 自己會同時換:重試、幾個工作同時發現 token 過期,第二個請求拿的就是剛作廢的那張,app 把自己登出了。

    改成兩邊各擋一層。伺服器:作廢後 2 分鐘內再用只回 401、不撤銷。app:同時只換一次,新的存進鑰匙圈才算換好。

    兩邊都不能信任對方送來的值

    上線前的安全審查,比較有意思的都是同一類問題:

    方向 問題 改法
    app → 伺服器 WAV 的 fmt 區塊照檔案寫的長度配置記憶體,100 bytes 的上傳讓伺服器配 2 GB 先比 SHA-256 再解析,fmt 限 16~64 bytes
    伺服器 → app 307、308 轉址把 refresh token、錄音、逐字稿原封不動送到別台 對企業伺服器的請求一律不跟轉址
    伺服器 → app 很大的時間讓 Mac 版閃退,很大的說話者編號讓 Windows 版一直建說話者 超過 95,040 秒的段落略過,說話者只收 1~64
    伺服器 → app 負的 Upload-Offset 讓 Mac 版閃退,比檔案大的會一直重送 不合理就從頭傳,位置連續 3 次沒往前算失敗

    307、308 規定方法和 body 原樣重送,URLSession 和 HttpClient 預設都會跟,POST 的內容就直接送到轉去的那台。

    分享LINEThreadsX