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

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

- 網址：https://www.weibuilds.com/blog/shiyin-enterprise-server/
- 作者：weiwei
- 日期：2026-10-28
- 主題：後端架構
- 標籤：shiyin、asp-net-core、oauth、whisper.cpp、cuda、sqlite

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

## 架構

<figure>
<svg class="diagram" viewBox="0 0 760 446" role="img" aria-label="拾音 app 經 HTTPS 和公司的反向代理連到 shiyin-server。伺服器是一個 Docker image，對外有 /api/v1、/login 與 /join、/admin 後台三組入口。API 寫 SQLite，轉錄工作交給 JobQueue 和拾音的 C# 核心，核心用 whisper.cpp 或 llama-server 子程序跑在 NVIDIA 顯示卡上；整理請求經 LlmProxy 轉給公司設定的模型。" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <marker id="srv-ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--ink-2)"/></marker>
  </defs>
  <g font-size="13" font-family="inherit" fill="var(--ink)">
    <rect x="250" y="12" width="260" height="38" rx="19" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="380" y="36" text-anchor="middle">拾音 app（Mac、Windows）</text>
    <line x1="380" y1="50" x2="380" y2="86" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#srv-ar)"/>
    <text x="392" y="73" font-size="12" fill="var(--ink-2)">HTTPS：PKCE 登入、8 MB 分塊上傳、輪詢、整理</text>
    <rect x="250" y="88" width="260" height="38" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="380" y="112" text-anchor="middle">公司的反向代理（TLS）</text>
    <line x1="380" y1="126" x2="380" y2="148" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#srv-ar)"/>
    <rect x="10" y="150" width="740" height="206" rx="10" fill="none" stroke="var(--ink-2)" stroke-dasharray="5 4"/>
    <text x="24" y="170" font-size="12" fill="var(--ink-2)">shiyin-server・一個 Docker image・ASP.NET Core（.NET 10）</text>
    <rect x="20" y="182" width="230" height="50" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="135" y="203" text-anchor="middle" font-weight="600">/api/v1</text>
    <text x="135" y="221" text-anchor="middle" font-size="12" fill="var(--ink-2)">app 的 API（Bearer token）</text>
    <rect x="265" y="182" width="230" height="50" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="380" y="203" text-anchor="middle" font-weight="600">/login、/join</text>
    <text x="380" y="221" text-anchor="middle" font-size="12" fill="var(--ink-2)">公司帳號登入、加入連結</text>
    <rect x="510" y="182" width="230" height="50" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="625" y="203" text-anchor="middle" font-weight="600">/admin</text>
    <text x="625" y="221" text-anchor="middle" font-size="12" fill="var(--ink-2)">後台（Razor Pages）</text>
    <g stroke="var(--ink-2)" stroke-width="1.6" fill="none">
      <path d="M135 232V246H625"/>
      <line x1="135" y1="246" x2="135" y2="262" marker-end="url(#srv-ar)"/>
      <line x1="380" y1="246" x2="380" y2="262" marker-end="url(#srv-ar)"/>
      <line x1="625" y1="246" x2="625" y2="262" marker-end="url(#srv-ar)"/>
    </g>
    <rect x="20" y="264" width="230" height="78" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="135" y="287" text-anchor="middle" font-weight="600">SQLite（WAL）</text>
    <text x="135" y="307" text-anchor="middle" font-size="12" fill="var(--ink-2)">佇列、帳號、token 的 SHA-256</text>
    <text x="135" y="325" text-anchor="middle" font-size="12" fill="var(--ink-2)">稽核紀錄（雜湊鏈）</text>
    <rect x="265" y="264" width="230" height="78" rx="6" fill="var(--topic-backend)"/>
    <g fill="var(--paper)" text-anchor="middle">
      <text x="380" y="287" font-weight="600">JobQueue → 拾音的 C# 核心</text>
      <text x="380" y="307" font-size="12">whisper.cpp（CUDA，同一個程序）</text>
      <text x="380" y="325" font-size="12">llama-server（Qwen3-ASR，子程序）</text>
    </g>
    <rect x="510" y="264" width="230" height="78" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="625" y="287" text-anchor="middle" font-weight="600">LlmProxy</text>
    <text x="625" y="307" text-anchor="middle" font-size="12" fill="var(--ink-2)">格式白名單、額度、預算</text>
    <text x="625" y="325" text-anchor="middle" font-size="12" fill="var(--ink-2)">上游只看到匿名代號</text>
    <line x1="380" y1="342" x2="380" y2="380" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#srv-ar)"/>
    <line x1="625" y1="342" x2="625" y2="380" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#srv-ar)"/>
    <rect x="265" y="382" width="230" height="50" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="380" y="403" text-anchor="middle" font-weight="600">NVIDIA 顯示卡 × N</text>
    <text x="380" y="421" text-anchor="middle" font-size="12" fill="var(--ink-2)">照正在跑的場數分配</text>
    <rect x="510" y="382" width="230" height="50" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="625" y="403" text-anchor="middle" font-weight="600">公司設定的模型</text>
    <text x="625" y="421" text-anchor="middle" font-size="12" fill="var(--ink-2)">自架、OpenAI、Anthropic、Gemini</text>
  </g>
</svg>
<figcaption>app 只連得到三組入口。轉錄走 JobQueue 到顯示卡，整理走 LlmProxy 到公司的模型。</figcaption>
</figure>

一台主機、一個 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 子程序跑。

## 多張顯示卡的編號對不上

<figure>
<svg class="diagram" viewBox="0 0 760 310" role="img" aria-label="一個 shiyin-server 程序裡有 JobQueue 和 whisper.cpp，整個程序設 CUDA_DEVICE_ORDER=PCI_BUS_ID。JobQueue 把新的一場給正在跑最少的卡。whisper.cpp 帶第一套 ggml，用 gpu_device 指定 GPU 0。Qwen3-ASR 用 llama-server 子程序，帶第二套 ggml，設 CUDA_VISIBLE_DEVICES=1 跑在 GPU 1，子程序裡看到的是 0 號。" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <marker id="gpu-ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--ink-2)"/></marker>
  </defs>
  <g font-size="13" font-family="inherit" fill="var(--ink)">
    <rect x="10" y="10" width="480" height="190" rx="10" fill="none" stroke="var(--ink-2)" stroke-dasharray="5 4"/>
    <text x="24" y="30" font-size="12" fill="var(--ink-2)">shiyin-server 程序・CUDA_DEVICE_ORDER=PCI_BUS_ID</text>
    <rect x="30" y="42" width="440" height="52" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="250" y="63" text-anchor="middle" font-weight="600">JobQueue・GpuAllocation</text>
    <text x="250" y="82" text-anchor="middle" font-size="12" fill="var(--ink-2)">新的一場給正在跑最少的卡，一樣多再比顯示卡記憶體</text>
    <rect x="30" y="122" width="210" height="60" rx="6" fill="var(--topic-backend)"/>
    <g fill="var(--paper)" text-anchor="middle">
      <text x="135" y="146" font-weight="600">whisper.cpp（Breeze）</text>
      <text x="135" y="166" font-size="12">第一套 ggml・gpu_device = 0</text>
    </g>
    <rect x="500" y="122" width="250" height="60" rx="6" fill="var(--topic-ai)"/>
    <g fill="var(--paper)" text-anchor="middle">
      <text x="625" y="146" font-weight="600">llama-server（Qwen3-ASR）</text>
      <text x="625" y="166" font-size="12">第二套 ggml・CUDA_VISIBLE_DEVICES=1</text>
    </g>
    <g stroke="var(--ink-2)" stroke-width="1.6" fill="none">
      <line x1="135" y1="94" x2="135" y2="120" marker-end="url(#gpu-ar)"/>
      <path d="M400 94V152H498" marker-end="url(#gpu-ar)"/>
      <line x1="135" y1="182" x2="135" y2="238" marker-end="url(#gpu-ar)"/>
      <line x1="625" y1="182" x2="625" y2="238" marker-end="url(#gpu-ar)"/>
    </g>
    <text x="412" y="144" font-size="12" fill="var(--ink-2)">子程序</text>
    <rect x="30" y="240" width="210" height="56" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="135" y="263" text-anchor="middle" font-weight="600">GPU 0</text>
    <text x="135" y="282" text-anchor="middle" font-size="12" fill="var(--ink-2)">nvidia-smi 的編號</text>
    <rect x="500" y="240" width="250" height="56" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="625" y="263" text-anchor="middle" font-weight="600">GPU 1</text>
    <text x="625" y="282" text-anchor="middle" font-size="12" fill="var(--ink-2)">子程序裡看到的是 0 號</text>
    <text x="370" y="263" text-anchor="middle" font-size="12" fill="var(--ink-2)">CUDA 預設把最快的卡排第一，</text>
    <text x="370" y="281" text-anchor="middle" font-size="12" fill="var(--ink-2)">nvidia-smi 照 PCI 匯流排的順序</text>
  </g>
</svg>
<figcaption>兩套 ggml 符號同名，不能放同一個程序，所以 Qwen 一場開一個子程序，用環境變數限定它看得到的卡。</figcaption>
</figure>

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。

<figure>
<svg class="diagram" viewBox="0 0 760 440" role="img" aria-label="上傳的時序。app 先 POST 建工作並宣告大小和 SHA-256，伺服器回工作 id。app 每次 PATCH 8 MB，標頭帶 Upload-Offset。中途斷線後 app 用 HEAD 問伺服器收到哪裡，再從那裡接著傳到收滿。伺服器收滿才比 SHA-256、讀 WAV 標頭、排隊。之後 app 輪詢進度，拿結果寫進本機資料庫後送 DELETE。Mac 版在上傳和排隊的同時在本機辨識說話者。" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <marker id="up-ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--ink-2)"/></marker>
  </defs>
  <g font-size="12" font-family="inherit" fill="var(--ink)">
    <rect x="40" y="12" width="220" height="36" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="150" y="35" text-anchor="middle" font-size="13" font-weight="600">拾音 app</text>
    <rect x="500" y="12" width="220" height="36" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="610" y="35" text-anchor="middle" font-size="13" font-weight="600">shiyin-server</text>
    <g stroke="var(--rule)" stroke-width="1.4" stroke-dasharray="4 4">
      <line x1="150" y1="48" x2="150" y2="430"/>
      <line x1="610" y1="48" x2="610" y2="430"/>
    </g>
    <rect x="144" y="140" width="12" height="200" rx="3" fill="var(--topic-backend)" opacity="0.55"/>
    <text x="134" y="234" text-anchor="end" fill="var(--ink-2)">Mac：同時在本機</text>
    <text x="134" y="252" text-anchor="end" fill="var(--ink-2)">辨識說話者</text>
    <g stroke="var(--ink-2)" stroke-width="1.5">
      <line x1="158" y1="80" x2="602" y2="80" marker-end="url(#up-ar)"/>
      <line x1="602" y1="108" x2="158" y2="108" stroke-dasharray="5 4" marker-end="url(#up-ar)"/>
      <line x1="158" y1="146" x2="602" y2="146" marker-end="url(#up-ar)"/>
      <line x1="158" y1="184" x2="440" y2="184"/>
      <line x1="158" y1="222" x2="602" y2="222" marker-end="url(#up-ar)"/>
      <line x1="602" y1="250" x2="158" y2="250" stroke-dasharray="5 4" marker-end="url(#up-ar)"/>
      <line x1="158" y1="288" x2="602" y2="288" marker-end="url(#up-ar)"/>
      <line x1="158" y1="362" x2="602" y2="362" marker-end="url(#up-ar)"/>
      <line x1="602" y1="390" x2="158" y2="390" stroke-dasharray="5 4" marker-end="url(#up-ar)"/>
      <line x1="158" y1="418" x2="602" y2="418" marker-end="url(#up-ar)"/>
    </g>
    <g text-anchor="middle">
      <text x="380" y="73">POST /transcriptions（大小、SHA-256）</text>
      <text x="380" y="101" fill="var(--ink-2)">id、status: uploading</text>
      <text x="380" y="139">PATCH　Upload-Offset: 0（8 MB）</text>
      <text x="300" y="177">PATCH　Upload-Offset: 8 MB</text>
      <text x="452" y="189" font-size="15" font-weight="600" fill="var(--accent)">✕</text>
      <text x="484" y="189" fill="var(--accent)">斷線</text>
      <text x="380" y="215">HEAD</text>
      <text x="380" y="243" fill="var(--ink-2)">Upload-Offset：收到哪裡</text>
      <text x="380" y="281">PATCH 從那裡接著傳，直到收滿</text>
      <text x="380" y="355">GET（排隊 5 秒、轉錄中 2 秒一次）</text>
      <text x="380" y="383" fill="var(--ink-2)">queueAhead、progress、done</text>
      <text x="380" y="411">GET /result，寫進本機資料庫後 DELETE</text>
    </g>
    <rect x="460" y="302" width="290" height="34" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="605" y="324" text-anchor="middle">收滿 → 比 SHA-256 → 讀 WAV 標頭 → 排隊</text>
  </g>
</svg>
<figcaption>斷線只重傳沒收到的部分。SHA-256 收滿才比，不對就清掉、從頭傳一次。</figcaption>
</figure>

## 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 的內容就直接送到轉去的那台。
