# 策展 · X (Twitter) 🔥

> 📖 本站完整內容索引（documentation index）：[llms.txt](/llms.txt)

> 作者：Harvey (@harvey) · 平台：X (Twitter) · 日期：2026-06-16

> 原始來源：https://x.com/harvey/status/2066574083060531420

## 中文摘要

# 在 Vault 中打造更快、更可靠的檔案上傳體驗

在過去幾個月裡，Vault 已成為 Harvey 使用率最高的介面之一，法律團隊越來越依賴它作為 AI 驅動檔案處理的核心系統。每週上傳量從 1 月中旬的每週 220 萬個檔案，攀升至 5 月的每週 1,500 萬個檔案，而本週 Vault 的活躍檔案總數更突破了 2 億個。第一個 1 億個檔案大約花了兩年時間，而第二個 1 億個檔案僅用了兩個月。

去年秋天我們致力於擴展 Vault 的檔案上傳與管理功能，而近期使用量的成長促使我們重新思考如何進一步擴展這些能力。這迫使我們回到大規模上傳背後的核心架構問題：Harvey 的後端應該繼續承載每一個檔案位元組（byte），還是應該由後端負責協調上傳，而由儲存服務處理傳輸？

Presigned URL 上傳是解決經典檔案上傳系統設計問題的黃金標準：將應用程式伺服器保留在控制平面（control plane），並將檔案位元組保留在資料平面（data plane）。在理想的白板設計中，後端授權上傳並回傳一個短效期的 Azure Blob Storage URL，瀏覽器直接上傳至儲存空間，最後由後端完成檔案紀錄。這聽起來簡單得令人懷疑，但在 Vault 的規模下，難點在於如何讓這個架構在生產環境中穩定運作。

在本文接下來的部分，我們將探討如何將上傳控制平面與資料平面分離，將瀏覽器轉變為並發上傳管線（concurrent upload pipeline），並針對真實的企業環境強化這條新路徑。

## 伺服器代理上傳的問題

在某個階段，每位工程師都會被問到那個神聖的系統設計考題：「設計一個 Google Drive」。預期的答案通常很快就會出現：如果物件儲存（object storage）可以直接接收檔案，就不要讓客戶的數 GB 檔案通過你的應用程式伺服器。Vault 最初的上傳路徑為了開發速度、簡單的驗證以及易於理解的單一後端控制流程而進行了優化。當上傳量較小且產品功能快速迭代時，這是一個正確的取捨。隨著 Vault 的採用率成長，同樣的簡單性反而變成了我們需要重新設計的瓶頸。

在先前的架構中，每個上傳到 Vault 的檔案都透過 Harvey 的後端進行代理。瀏覽器將檔案發送到我們的伺服器，後端驗證請求、將位元組寫入 Azure、建立檔案紀錄、更新進度狀態，並啟動後續處理。由於後端控制了整個交易，這在邏輯上很直觀，但也意味著後端直接處於每個位元組的資料路徑上。

![這是一張展示檔案上傳流程與並行處理架構的時序圖。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/3db008922ace24f2.png)

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">該圖表為系統架構時序圖，描述了從使用者上傳檔案到完成處理的流程。涉及的參與者包括：User、Upload、Batcher、Workers、Backend、Progress、Database。

流程節點與文字轉錄如下：
- User -&gt; Upload: Select Files
- Upload: Validate
- Upload -&gt; Progress: Show “Uploading...”
- Upload -&gt; Batcher: Create Optimistic UI
- Batcher -&gt; Workers: Parallel Upload
- Workers 區塊 (par)：Concurrent Processing，包含 Batch 1、Batch 2、Batch N
- Backend -&gt; Database: Transactional insert
- Database -&gt; Backend: File IDs
- Backend -&gt; Workers: Success
- Workers -&gt; Batcher: Reconcile Files
- Progress 區塊 (loop)：Real-time Polling，包含 Status Check、Update、Refresh UI
- Backend -&gt; User: Complete
- 底部標籤：Web Worker Pool Dynamic Concurrency、Conflict Resolution

圖表重點：
1. 使用「樂觀 UI (Optimistic UI)」策略，在檔案上傳時即時向使用者回饋。
2. 透過 Workers 進行並行處理 (Parallel Upload) 與批次處理 (Batch 1-N)。
3. 使用後端輪詢 (Real-time Polling) 機制來更新進度與 UI。
4. 包含資料庫事務處理與衝突解決機制。</div></details>

這產生了三個擴展性問題：

- **後端處於資料路徑中**：每個位元組都從「瀏覽器 → 後端 → Azure」傳輸，而不是「瀏覽器 → Azure」。這增加了延遲，並使上傳吞吐量取決於後端的網路、CPU、磁碟 I/O、工作負載容量與出口頻寬，而不是讓物件儲存直接接收位元組。

- **長效上傳工作與一般流量競爭**：大型上傳會佔用後端連線與工作執行緒，直到傳輸結束，這會與一般的 API 請求、進度輪詢（polling）以及後續的上傳協調工作產生競爭。

- **批次規模下的脆弱重試機制**：如果 Pod 重啟、請求逾時或傳輸中途連線中斷，使用者通常必須從頭開始重試。對於正在上傳案件資料夾、資料室匯出檔或數萬個檔案的客戶來說，這會讓一次基礎設施的小波動變成緩慢且不確定的上傳體驗。

對於較小的 Vault 來說，這種取捨是可以接受的。在 Vault 擴展的先前版本中，我們每個 Vault 支援最多 10,000 個檔案，舊架構足以應付當時的需求。但新的使用模式看起來截然不同：法律團隊正在上傳案件資料夾、匯出檔、封存檔以及 DMS 規模的批次檔案。上傳效能的瓶頸不再是客戶到儲存空間的連線，而是後端能吸收多少長時間執行的檔案傳輸工作。該架構使後端同時負責控制平面與資料平面，而這兩項工作必須分開。

## 移除中間人

顯而易見的系統設計答案也是正確的：停止讓檔案位元組通過應用程式伺服器。

我們將 Vault 上傳遷移至 Presigned URL 架構。後端不再透過 Harvey 後端代理檔案內容，而是發行短效期的 Azure SAS URL，賦予瀏覽器對 Blob Storage 直接且限時的寫入權限。後端依然擁有控制平面：權限、驗證、重複檔案處理、檔案紀錄與處理協調。但資料平面完全移出了後端。

從高層級來看，上傳過程變成了三次握手：

1. **初始化 (Init)**：瀏覽器將檔案元資料（metadata）發送到後端：名稱、大小、內容類型、目標資料夾、重複模式與批次 ID。後端驗證存取權限、建立 WAITING_FOR_UPLOAD 的檔案紀錄、產生 SAS URL，並為每個被接受的檔案回傳一個上傳目標。

2. **上傳 (Upload)**：瀏覽器使用這些 SAS URL 直接將檔案位元組上傳至 Azure Blob Storage。後端不再位於傳輸路徑上，因此大型檔案與大型批次不會在傳輸期間消耗後端的工作負載容量。

3. **完成 (Finalize)**：瀏覽器告知後端哪些檔案 ID 已完成上傳。後端驗證對應的 Blob 是否存在，將檔案紀錄轉換為已上傳狀態，並啟動後續的文件處理。

![這是一張描述檔案上傳與處理流程的時序圖（Sequence Diagram）。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/d1c9a42cb9eb4d97.png)

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">此圖展示了從使用者選擇檔案到系統完成處理的八個步驟，涉及 User、Client、ObjectStore 與 Database 四個實體。

流程細節如下：
1. User 選擇檔案 (Select files) 傳送至 Client。
2. Client 向 Database 建立待處理紀錄 (Create pending records)，並取得預簽名 URL (pre-signed URLs)。
3. Database 回傳 ID 與每個檔案的憑證 (IDs + per-file credentials)。
4. Client 向 User 顯示待處理介面 (Show pending UI)。
5. Client 將檔案位元組進行並發上傳 (Upload bytes (concurrent)) 至 ObjectStore。
6. Client 向 Database 提交上傳完成請求 (Commit uploads)。
7. Database 執行「Finalization」階段：驗證物件 (Verify objects)、標記就緒或失敗 (Mark ready / failed)、等待 (Awaited)。隨後回傳提交結果與失敗資訊 (Committed + failures) 給 Client。
8. Client 執行「Reconcile UI」更新介面，並向 User 回報完成 (Complete)。

圖中右側標示了「Post-commit」階段，包含：擴展、記帳、審計 (Expand, bookkeep, audit)，以及內容處理 (Content processing) 與分離 (Detached) 等後續作業。</div></details>

這個乾淨的版本在白板上看起來很完美。但在生產環境中需要更多的機制：前端必須成為並發上傳的協調者，後端必須確保初始化與完成階段是安全、批次化、冪等（idempotent）且可觀測的。這正是大部分工程工作所在之處。

## 前端：三階段並發管線

一旦後端停止代理檔案位元組，前端就繼承了新的工作：它不再只是提交表單，而是必須協調跨瀏覽器、Harvey 後端與 Azure Blob Storage 的分散式上傳。

最簡單的實作方式是序列化（sequential）：

![這是一張展示瀏覽器、後端伺服器與 Azure 雲端服務之間檔案上傳流程的時序圖。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/8a7b9bb206842a67.png)

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">該圖表描述了檔案上傳的互動步驟，包含以下節點：Browser（瀏覽器）、Backend（後端）、Azure。

互動流程如下：
1. Browser 發送「Init file metadata」至 Backend。
2. Backend 回傳「File ID + SAS URL」至 Browser。
3. Browser 發送「Upload file bytes」至 Azure。
4. Browser 發送「Finalize file ID」至 Backend。
5. Backend 發送「Verify blob exists」至 Azure。
6. Backend 回傳「File is uploaded」至 Browser。</div></details>

對於一個檔案，這運作得很好。但對於 50,000 個檔案，它就會崩潰。

我們可以預先初始化所有 50,000 個檔案，但這樣第一個上傳就會等待所有 SAS URL 回傳。我們可以用 `Promise.all` 上傳所有內容，但要求瀏覽器同時處理 50,000 個 Promise、檔案讀取、網路請求與 Azure SDK 呼叫，這與其說是策略，不如說是求救訊號。我們可以在所有上傳完成後才進行完成階段，但這樣早期的檔案就會閒置，無法進入處理流程。

我們需要的是一條組裝線。前端管線有三個由輕量級非同步佇列連接的並發階段：

![這是一張展示瀏覽器上傳協調器（Browser Upload Orchestrator）運作流程的架構圖。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/1f5891927efea777.png)

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">該圖表描述了瀏覽器上傳檔案至後端的處理流程，分為三個主要階段：
1. **1: Init**：處理批次元數據至後端，每請求處理 1,000 個檔案。此階段會將 SAS URLs 與 IDs 傳送至後端進行驗證與建立紀錄。
2. **2: Upload**：執行上傳作業，使用 50 個並發工作執行緒（concurrent workers），透過 SAS 直接將位元組（bytes）上傳至 Azure Blob Storage。
3. **3: Finalize**：確認上傳至後端，每請求處理 100 個檔案。此階段會驗證並開始後續處理程序。

圖表下方包含兩個核心組件：
- **Azure Blob Storage**：負責接收透過 SAS 的直接位元組上傳。
- **Backend**：負責驗證、建立紀錄與驗證上傳結果。</div></details>

這三個階段都在瀏覽器中並發執行。第一階段將檔案元資料批次發送到後端，並在檔案的上傳 URL 準備好後立即開始轉發檔案。第二階段以受控的並發度將這些檔案直接上傳到 Azure，讓瀏覽器能快速運作，而不會壓垮使用者的電腦或網路。第三階段再次以批次方式告知後端哪些上傳已完成，這樣成功上傳的檔案就能進入處理流程，而不必等待整個上傳集全部完成。

這意味著 50,000 個檔案的上傳不再表現得像一個巨大的請求，而是更像一個串流管線：當一個批次正在準備時，另一個批次已經可以開始上傳，而較早成功的檔案可以在後續檔案仍在傳輸時就開始完成階段。

我們還必須將失敗處理納入管線本身。不同的失敗需要不同的回應：無法從使用者電腦讀取的檔案，與暫時性的網路問題截然不同，而兩者又與後端驗證失敗不同。透過在失敗發生時進行分類，我們可以顯示更清晰的錯誤，僅對安全的案例進行重試，並避免將一個損壞的檔案變成整個批次的混亂失敗。

## 後端：保持控制平面快速

將位元組移出後端並沒有降低後端的重要性，反而使其工作更精確：授權上傳、準備儲存目標、建立持久的檔案紀錄、強制執行重複處理行為、驗證完成的上傳並啟動處理。

關鍵在於讓控制平面以批次而非單一檔案的方式擴展。初始化呼叫一次可以準備多達 1,000 個檔案，這將原本可能數千次的後端來回請求，轉變為數量更少、效率更高的操作。以下幾項優化帶來了顯著差異：

**批次初始化**：後端以群組而非逐個檔案的方式準備上傳目標。它在批次中重複使用 Azure 授權資訊，產生短效期上傳 URL，並批次建立待處理的檔案紀錄。即使使用者上傳數千個檔案，這也能保持較低的後端來回請求次數。

**批次重複處理**：Vault 上傳通常包含名稱已存在的檔案。後端不再逐一檢查每個檔案名稱，而是使用專為快速查詢「此活躍檔案是否存在？」而設計的資料庫索引，一次解決整個批次的重複行為。

**嚴謹的交易邊界**：後端避免在等待雲端呼叫或昂貴的讀取操作時保持資料庫交易開啟。儲存路徑與上傳 URL 會先準備好；接著資料庫交易保持簡短，專注於持久化的檔案紀錄寫入。

後端的角色從「承載每個位元組」轉變為「協調生命週期」。這種區別正是架構得以擴展的原因；瀏覽器與 Azure 處理高流量資料傳輸，而後端依然是正確性的唯一來源。

## 確保直接上傳在生產環境中的安全性

將位元組移出後端解決了核心擴展問題，但也引入了新的可靠性問題：瀏覽器、Azure 與後端現在必須協調跨真實客戶環境的分散式上傳。

## 保護瀏覽器與網路

一旦瀏覽器負責移動檔案位元組，就必須嚴格控制上傳並發度。大型批次不能變成數千個同時進行的檔案讀取、網路請求與 Azure SDK 操作。

前端管線限制了檔案層級的並發度與傳輸中的總位元組數。大型檔案以區塊方式上傳，重試行為由傳輸層處理，因此暫時性失敗不會強迫使用者從零開始。完成階段也是自適應的：已完成的檔案會進行批次處理，但部分批次會透過計時器觸發，以便小檔案能進入處理流程，同時大型檔案的尾端上傳仍在進行中。

## 維護後端正確性

直接上傳並不意味著由瀏覽器決定檔案是否存在。後端依然擁有權限、驗證、重複處理、檔案紀錄、稽核行為、保留狀態與處理協調。

在瀏覽器將位元組上傳至 Azure 後，後端會在將檔案標記為已上傳之前驗證每個 Blob。從未接收到位元組的待處理紀錄會由排程的清理工作過期刪除，因此廢棄的連線不會殘留在 Vault 中。上傳後的工作接著透過 Temporal 繼續進行，這為封存解壓縮、記帳、稽核與保留更新以及文件處理交接提供了請求路徑之外的持久重試邊界。

## 在企業環境中生存

Presigned 上傳的乾淨版本假設瀏覽器可以直接存取 Azure Blob Storage。企業環境並不總是允許這樣做。客戶從受管理的筆記型電腦、VPN、虛擬桌面、企業代理伺服器、防火牆與 DLP 工具上傳，這些工具可能會封鎖或修改直接儲存請求。

在使用 Presigned 路徑之前，瀏覽器會使用與實際上傳流程相同的 Azure 客戶端執行輕量級連線探測。如果探測失敗，Vault 會在使用者開始上傳之前，退回到舊版的伺服器代理路徑。跨檔案讀取、初始化、Azure 上傳、探測、驗證、完成與處理交接的階段級觀測能力，讓我們能區分客戶網路問題與應用程式錯誤，並安全地進行遷移。

## 結果

Presigned URL 是讓上傳更快的知名模式，但對我們來說真正的問題是：在真實的客戶工作負載下，這實際上讓 Vault 快了多少？

值得一提的是，Presigned 上傳同時改善了常見案例與特殊案例，但方式不同。

![優化後的整體上傳延遲在各項指標上均有改善，其中平均上傳延遲縮短了 13%，P99 上傳延遲則縮短了 27%（從 1分25秒 降至 1分2秒）。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/7623568f757cdd03.jpg)

<details class="chart-data"><summary>展開數據表</summary><table><thead><tr><th>指標</th><th>Before</th><th>After</th><th>Improvement</th></tr></thead><tbody><tr><td>Average upload latency</td><td>6.7s</td><td>5.9s</td><td>13% faster</td></tr><tr><td>P90 upload latency</td><td>10.5s</td><td>8.6s</td><td>18% faster</td></tr><tr><td>P95 upload latency</td><td>20.6s</td><td>16.2s</td><td>21% faster</td></tr><tr><td>P99 upload latency</td><td>1m 25s</td><td>1m 2s</td><td>27% faster</td></tr></tbody></table></details>

對於日常上傳，延遲曲線的改善非常穩定：平均延遲從 6.7 秒降至 5.9 秒，P99 從 1 分 25 秒降至 1 分 02 秒。這意味著對於大多數使用者來說，上傳流程感覺更快，但更重要的是，最糟糕的上傳情況不太可能演變成數分鐘的不確定狀態。

![在 1,000 個檔案的上傳時間測試中，優化後（After）在平均及各百分位數（P90、P95、P99）的持續時間皆有顯著提升，速度加快了 46% 至 57% 不等。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/22069ec5e4d303da.jpg)

<details class="chart-data"><summary>展開數據表</summary><table><thead><tr><th></th><th>Before</th><th>After</th><th>Improvement</th></tr></thead><tbody><tr><td>Average duration</td><td>2m 35s</td><td>1m 6s</td><td>57% faster</td></tr><tr><td>P90 duration</td><td>6m 55s</td><td>2m 59s</td><td>57% faster</td></tr><tr><td>P95 duration</td><td>7m 57s</td><td>4m 17s</td><td>46% faster</td></tr><tr><td>P99 duration</td><td>13m 22s</td><td>6m 15s</td><td>53% faster</td></tr></tbody></table></details>

最大的收穫出現在較重的工作負載中。對於 1,000 個檔案的上傳，平均持續時間從 2 分 35 秒降至 1 分 06 秒，P99 從 13 分 22 秒降至 6 分 15 秒。這顯示 Presigned 管線達到了我們對多小檔案工作負載的預期：減少了元資料準備、後端來回請求與完成階段帶來的每個檔案開銷。

![大型檔案上傳時間在優化後顯著縮短，各項指標（平均、P90、P95、P99）的速度提升了 34% 至 47%，其中 P99 時間縮短了 47% 最為顯著。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/f7d582f09e0ac965.jpg)

<details class="chart-data"><summary>展開數據表</summary><table><thead><tr><th></th><th>Before</th><th>After</th><th>Improvement</th></tr></thead><tbody><tr><td>Average duration</td><td>1m 17s</td><td>50s</td><td>35% faster</td></tr><tr><td>P90 duration</td><td>2m 52s</td><td>1m 53s</td><td>34% faster</td></tr><tr><td>P95 duration</td><td>4m 32s</td><td>2m 55s</td><td>36% faster</td></tr><tr><td>P99 duration</td><td>11m 38s</td><td>6m 8s</td><td>47% faster</td></tr></tbody></table></details>

大型檔案上傳也有所改善，平均持續時間從 1 分 17 秒降至 50 秒，P99 從 11 分 38 秒降至 6 分 08 秒。這顯示直接存取 Azure 的路徑與受控的並發度有助於維持傳輸吞吐量，同時減少了由後端代理或從零位元組重試造成的長尾案例。

換句話說，Presigned 上傳不僅讓 Vault 的平均上傳速度變快，還讓那些最慢、最令人沒信心的上傳過程變得不再那麼痛苦。

## 不僅僅是更快的上傳

Presigned 上傳始於一個熟悉的系統設計模式：將檔案位元組移出後端，讓物件儲存做它擅長的事。困難的部分在於讓該模式在真實的企業產品中運作，因為上傳可能涉及數千個檔案、受管理的裝置、企業代理伺服器、大型封存檔、不穩定的網路以及嚴格的正確性要求。

最終的架構之所以更快，是因為後端不再承載每個位元組；而之所以更可靠，是因為後端依然擁有生命週期：初始化、驗證、完成、清理與處理協調。瀏覽器變成了並發上傳管線，Azure 成為了資料平面，而 Temporal 為後端在位元組落地儲存後發生的所有事情提供了持久的路徑。

對於法律團隊而言，這意味著大型案件資料夾、資料室匯出檔、封存檔以及 DMS 規模的批次檔案可以更少等待、更少模糊失敗地進入 Vault。這種結合正是讓遷移變得值得的原因：我們不僅讓上傳變快，還讓上傳系統變得更易於理解、觀測、恢復，並為下一個數量級的成長做好準備。

作者：Cindy Nguyen, Adam Shen

致謝：

感謝 Harvey 工程團隊以下成員對 Vault 的貢獻：Qingyu Shen, Cindy Nguyen, Jin Zhang, Tau Jin, Isabelle Tao, Anna Zhang, John Graham, Ganesh Jothikumar, Guru Sivanesan.

請點擊此處閱讀我們的部落格。

## 標籤

功能更新, 產業趨勢, Harvey
