為什麼要重建,而不是直接延伸現有程式碼?
這個 fork(boboidvtw/headroom)已有 1465 個 commits,PR-A1 到 G3 的大部分功能早就實作了。原本的計劃是看著 issue 繼續往下走, 但盤點後發現 issues 是兩週前建的舊追蹤項, 真正的缺口只剩 Phase H(Python 退役)和幾個 CI gate。
重新和使用者確認方向後,答案是「從頭重做」——不是清空重寫,
而是在 rewrite/ 子目錄從零建核心精華,
現有成熟程式碼當解答本。這是學習導向的重建,不是工程改版。
M0–M2:快取的根本是確定性,不是壓縮
M0 建最薄的 passthrough:FastAPI + httpx,把 request body 原封不動轉發。
就是這步讓 M0 教訓刻進腦子:快取不是「存起來」,是「逐字節前綴比對」。
把 body 用 json.dumps 重序列化一次就全 miss,
因為 Python dict 不保證 key 順序、且 Unicode 跳脫序列(\u 開頭)與空格規則都可能不一樣。
想讓快取命中,proxy 必須輸出 byte-for-byte 相同的內容。
M1 加 live-zone 壓縮(只壓 messages[-1] 的 user tool_result)。
選擇「只壓最後一輪」的鐵律是確定性:client 每輪重送完整歷史,
同樣的輸入必須得到同樣的輸出,標記放 sha256 而不是時間戳。
M2 是 SSEByteSplitter + MessageAccumulator——接收 streaming 回應、
逐字節緩衝到完整才 decode。第一個地雷:errors="ignore"
會靜默吞掉被切半的 emoji(編號 P1-8),變成 silent data corruption。
正確做法是緩衝到有完整 UTF-8 序列才 decode,不丟 bytes。
M3:「語意相等 ≠ bytes 相等」第一次踢到
M3 做 tools 確定性正規化:按名字排序 tool 定義、遞迴排序 schema keys, 再自動放置 cache_control 標記(client 已放任何標記 = 神聖不可侵犯)。
實作時用 normalized != tools 偵測是否有變更。
Python dict 的 == 不看 key 順序——排序後被誤判「沒改」而放行原文。
RED 測試當場抓到。修法是比 json.dumps 後的字串。
語意相等(dict 值一樣)和 bytes 相等(序列化後一樣)是兩件事。
M4:content-addressed 儲存與「沒壓就不註冊」的陷阱
M4 是 CcrStore:content-addressed HashMap,把被壓縮掉的大段 tool_result
存起來,再透過 ccr_retrieve 工具讓模型按需取回。
每個請求都會呼叫 register_ccr_tool 把工具插到 tools 清單。
鐵律 4 / B7:「沒壓縮就不註冊工具」 會讓 tools 定義在請求間閃爍—— 有壓縮時工具出現、沒壓縮時消失。tools 段是快取最前面的大塊,一閃爍快取就全 bust。 正確做法是無條件每請求都註冊,讓 tools 段穩定。 可逆性(壓縮過的東西能取回)才是壓縮的免死金牌,不是節省工具數量。
M5:Rust port 與「同輸入、SHA-256 完全一致」
M5 把 M0–M4 的核心邏輯 port 到 Rust(rust-lite/)。
Rust 這邊有幾個生存前提:
serde_json 需要開 arbitrary_precision
(避免 1.50 被解析成 1.5)和 preserve_order
(讓 JSON 序列化的 key 順序跟輸入相同)。
Cow::Borrowed 型別讓「原始 bytes 沒有被動到」從文字說明升格成型別可驗證的事實。
驗收標準是跨語言 parity:同樣的輸入丟給 Python pipeline 和 Rust pipeline, 輸出 SHA-256 必須完全一致。30 個 Python 測試 + 11 個 Rust 測試全綠。
M6:「語意相等 ≠ bytes 相等」第三次踢到
M6 把 M3(tools 正規化)和 M4(CcrStore)port 到 Rust,
再加 parity gate 腳本(scripts/parity.sh + 4 個 fixture)一鍵驗收。
這次 Rust 的地雷是 serde_json::Value 的 ==——
preserve_order 的 IndexMap 相等語意不看 key 順序,
但快取前綴看字節順序,變更偵測必須比 serde_json::to_vec 後的 bytes。
M0 教訓第三次,換了語言但本質相同。
另一個 Rust 特有地雷:Python 版在 _squeeze_text 行數門檻一過
就立刻呼叫 store.put(text),即使之後 fallback 放棄壓縮。
Rust 若把 put 放在 filter(|s| s.len() < text.len())
之後,罕見路徑 parity 就不齊。移植時「副作用發生點」要逐字對齊,不只對齊回傳值。
M6 完成後:Python 30 tests + Rust 30 tests + parity gate 4 fixtures,全通。
關鍵教訓
語意相等 ≠ bytes 相等:Python dict ==、Rust Value ==、JSON round-trip,三個地方犯同一個錯。快取靠 bytes 比對,所有「有沒有改變」的判斷都要比序列化後的字串。
標記放 sha256 不放時間戳:時間戳每次不同、確定性就沒了。快取代理的核心約束是同輸入永遠同輸出,標記也不例外。
可逆性才是壓縮的通行證:「壓了能取回」讓快取安全;「沒壓就不插工具」讓 tools 閃爍反而 bust 快取,邏輯正好反過來。
Rust 移植要逐字對齊副作用時機:回傳值對齊只是開始,副作用(put / register)的觸發時序一偏,罕見路徑的 parity 就會悄悄斷掉。
解答本策略有效:不清空現有程式碼、在子目錄從零建,每次遇到問題都能「問」現有實作。邊做邊對照比對著空氣自以為的效率高很多。