為什麼 SSE 重切器必須先改再測 proxy?

原本的 SSE splitter(feed 方法)吐事件時會丟掉邊界字節—— 它知道 \n\n 是一個 SSE 事件邊界,但吐出去的 frame 不帶這個邊界, 只有事件內容。用它重組回程的 bytes 就會不一致。

新 API split_frames 吐「含原始邊界的 frame」。 不變量可以直接測:concat(frames) + take_remaining() == 所有輸入。 任何一個 byte 都不能少。重構成功的標準是舊的 4 個測試一個不動照過, 4 個新測試也全綠——只有這樣才算「安全重構」而不是「換一種寫法猜看看」。

整合測試:問引擎本人,不要預設期望值

Python 版的 proxy 測試用 httpx.MockTransport 假裝網路, 全程在 process 內跑。Rust 版的策略不同: mock axum server 綁 127.0.0.1:0 隨機埠、 proxy 把請求轉發到這個本地 server、server 攔下實際收到的 method / uri / headers / body。 走真正的 TCP 網路棧,streaming 路徑才測得到,rechunk_sse 不是在內存裡跑的。

期望值的設定方式: 直接呼叫 process_request(MESSY) 問引擎本人「你會把這個輸入變成什麼」, proxy 測試用那個答案當 expected。proxy 測試不該有引擎邏輯的意見, 只測「proxy 有沒有把引擎說的話完整送達」。

七個整合測試涵蓋:全路徑 fallback 轉發、POST body 過 pipeline、 hop-by-hop headers 過濾(connection / transfer-encoding 等)、 回程 stream 不攢整包、SSE 且無 content-encoding 才重切。

四個 Rust 特有地雷

地雷一:MutexGuard 不能跨 await。 CcrStore 的鎖必須在 await 點之前釋放,否則編譯器會說 MutexGuard 不是 Send,整個 future 就不能跨線程。 解法是用大括號把鎖的存活範圍縮小到 await 之前。 這不是 workaround,是正確設計:pipeline 是同步呼叫,鎖確實在 await 前就該釋放。

地雷二:reqwest bytes_stream 不保證 Unpin。 unfold 的 async 閉包要 .next() 需要流是 Unpin, reqwest stream 不是。解法是 Box::pin,因為 Pin<Box<S>>Unpin

地雷三:reqwest 刻意 default-features=false。 不開 gzip feature 就不會自動解壓。 自動解壓後 body 和 content-encoding header 就不一致, byte-faithful 轉發直接破功。壓縮後的流要原樣穿過代理, 不能讓 reqwest 在背後悄悄解開。

地雷四:SSE 重切要躲開有 content-encoding 的流。 如果上游回應帶 content-encoding: gzip, 事件邊界(\n\n / \r\n\r\n)在密文裡找不到, splitter 會緩衝到天荒地老。這種流必須原樣轉發,不能進 rechunk 路徑。 判斷條件:SSE(content-type: text/event-stream) 且無 content-encoding,才重切。

串流結束前要沖洗殘料

上游最後一包 SSE 資料如果沒帶結尾邊界, splitter 的緩衝區裡還有剩餘 bytes 沒吐出去。 stream 結束(None)時要呼叫 take_remaining() 把殘料補吐, 否則最後幾個 byte 就消失了。 這也是 M7 第一步先把 SSE API 改掉的原因—— 舊版沒有 take_remaining,強行用舊版 proxy 是不可能 byte-faithful 的。

數字與下一步

M7 完成後:Python 30 / Rust 40(新增 SSE 3 + proxy 7)/ parity gate 4 fixtures, clippy 0 warnings,全綠。 commit bf024e4(feat)+ 54aad4a(docs),已推上 fork。

M7 解鎖了真流量實測——現在可以把 proxy 指向真實的 Anthropic API, 用真實的 claude-haiku 請求驗證快取命中率和壓縮省錢的實際數字。

關鍵教訓

SSE 重組的不變量要可測:concat(frames) + take_remaining() == 原始輸入,任何一個 byte 都不能少。先測這個不變量,proxy 才能放心用這個 API。

整合測試問引擎本人當期望值:proxy 測試不該寫死「引擎應該輸出 X」,直接呼叫引擎問它。引擎邏輯改了,proxy 測試自動跟上,不會變成脆測試。

reqwest default-features=false 是有意識的選擇:gzip 自動解壓是 convenience,但在 byte-faithful proxy 裡是破壞者。選技術棧時要知道每個 feature flag 在做什麼。

Rust async 的 MutexGuard 存活範圍必須主動縮小:不是 workaround,是正確設計的表達——讓鎖的存活時間精確反映實際需求。

byte-faithful 代理的核心約束傳染給所有決策:自動解壓不行、SSE 重切要躲壓縮流、殘料要沖洗——每個決策都是同一個原則的衍生。

來源:個人開發日誌 2026-06-12 · headroom fork (boboidvtw/headroom) · Rust 40 tests / Python 30 tests / parity 4 fixtures · M7 完成