在一個週末的時間裡,我在玩桌遊和陪伴孩子之餘,與 Claude 一同建構了一個具備拜占庭容錯、強一致性及任意故障恢復能力的分布式系統。我用 Allium 描述了期望的行為,透過對話解決了 Bug,並且沒有編寫一行實施程式碼。

以下是僅用 50 分鐘就生成了前 4,749 行 Kotlin 程式碼和 103 個通過單元測試的提示詞:

提示詞就是這麼簡短,因為規格並不簡短。3,000 行 Allium 行為規格就藏在該提示詞背後,汲取了 András Gerlits、Martin Kleppmann 和 Mark Burgess 在分布式系統方面的專業知識。正是這些規格讓系統得以運作。

幾天後,經過 64 次提交,該系統能夠以低於 100 毫秒的尾延遲處理每秒數千次請求 (RPS),且無請求丟失。更重要的是,故障恢復場景暴露了細微的分布式系統 Bug,我們透過規格進行了修復。

Allium 是一種我們為 LLM 驅動的程式碼生成而開發的行為規格語言,介於 TLA+ 和結構化散文之間。以下是 Warden 元件(負責確保同一請求在時間窗口內不會被處理兩次,即冪等性)的一條規則:

沒有人會手動編寫這些規格。它們是透過對話產生的,供 LLM 參考:將行為討論建立在足夠精確以供建構、足夠具體以供驗證的基礎上。規格的運作粒度取決於想法的需要。一條規則可能描述一個涉及數十個類別的高層次升級策略,也可能描述限制單一數據結構的低層次快取語義。規格與程式碼的耦合是鬆散的:規格是我們不受編碼語言、函式庫和框架限制地迭代設計的地方,而閱讀此類規則的 LLM 則有足夠的資訊來編寫實施程式碼。我也有足夠的資訊來判斷它是否正確。

Allium 還有另外兩個重要的構造。Guidance 區塊包含實施提示,用於指導 LLM 做出特定選擇:

Resolved-question 區塊則預防了設計辯論:

這些區塊在不強制指定解決方案的情況下縮小了設計空間。Guidance 指導 LLM 在第一次嘗試時選擇正確的數據結構(儘管如你所見,它並不保證),無論是吞吐量決定的 ConcurrentHashMap 還是用於事件分區的帶路徑壓縮的併合查找。Resolved questions 則防止它重新審視已做出的決定。

規格是透過對話產生的。在與 Claude 進行數小時的交談中,我透過 András Gerlits 的工作,構建了一個分布式事件溯源框架的架構,其中每個狀態變更都被捕獲為不可變事件。多個冗餘實例獨立處理每個事件,並在發布前比較其輸出,這是一種稱為拜占庭容錯 (BFT) 的技術,可以捕獲硬體故障和靜默數據損壞。我心中有著雄心勃勃的吞吐量和延遲目標,同時還有在任意崩潰後恢復到一致狀態的能力,我們在過程中透過 Allium 逐步解決了設計決策。

第一次嘗試產生了一個單一的單體規格。我讓 Claude 進入迭代循環,以收緊語言並與我一起解決開放性問題,在迭代之間審查輸出。然後我們討論了分解:是否可以沿著組件邊界自然分割?跨文件引用應放在何處?到第二天早上,我們有了 10 個文件:Clerk (BFT 共識)、Arbiter (事件評估)、Registrar (實體快取)、Usher (Kafka 消費)、Ledger (持久化)、Warden (輸入去重),以及用於恢復和實時版本控制的跨領域規格。

透過命名,出現了一種司法主題,這些隱喻成為了每個組件實際需要做什麼的有用簡稱。

我設想的系統以規模化處理庫存移動:倉庫之間的庫存轉移和數量調整。目標是高吞吐量,尾延遲低於 100 毫秒。但吞吐量只是挑戰的一部分。系統需要強一致性(每個實例都同意每個事件的結果)、拜占庭容錯(檢測靜默數據損壞)以及在任意故障後恢復到正確狀態的能力。

我想看看 Claude 是否能建構一個,以及我是否能僅透過規格來指導它。

在提交了初始規格和一個 CLAUDE.md 文件(Claude Code 自動讀取的專案級指令文件),其中建立了架構和命名約定後,我將 Claude 指向了規格,然後去和我的孩子們玩了。這篇文章頂部的提示詞與我使用的非常接近。

50 分鐘後:44 個文件,4,749 行 Kotlin 程式碼,103 個通過的測試。Usher、Arbiter、Clerk、Registrar、Ledger 和 Warden 都已根據規格中描述的線程模型和實體生命週期實現。我將 Claude 指向了剩餘的規格和恢復邏輯,一個領域模組、REST API、Docker Compose 配置和 Kafka 集成在接下來的 90 分鐘內又通過 7 次提交完成。提交在進行時我跟隨其後。我並沒有真正意義上審查程式碼;Detekt,一個靜態分析工具,負責處理程式碼品質。當 Claude 選擇 @Suppress 一個警告時,我並沒有質疑。

工作形成了一種節奏。我們會一起構思,有時會持續很長時間:解決一個設計決策,爭論權衡,完善規格。然後我會讓 Claude 開始運行,有時是迭代解決一個挑戰,有時是並行調度多個工作者。完成後,我們會重新集合,我會為下一階段設定方向。何時開始負載測試?何時為不同領域構建框架抽象?當我有一個項目列表時,我會問 Claude 是否有任何並行處理的機會以及應首先處理哪些組。

Claude 能自己完成這種排序嗎?也許吧。優先級決策從未令人意外。但對話是我能夠增加價值的地方,而框架的領域接口就是一個很好的例子。

該系統是領域無關的:一個單獨的 DomainRegistry 插入實體定義和評估邏輯。庫存跟踪領域定義了倉庫之間的庫存項目,其中庫存移動事件觸及源實體和目標實體,檢查可用數量並更新餘額。相同的框架可以處理物聯網遙測或物流跟踪。我透過闡述我認為重要的設計目標來施加影響,而不是編寫程式碼:當我審查接口設計時,我要求 Claude 考慮與單一責任等原則的權衡。表達這些設計優先級有助於 Claude 在提出建議之前權衡相互競爭的選項。到第一天結束時,系統已編譯,測試通過,Docker 容器已運行。

然後我運行了負載測試,但每個請求都失敗了。

Clerk 需要兩個實例的共識才能發布任何輸出,但當時沒有任何東西將實例連接起來。聯邦協議已指定,程式碼具有類型和方法,但運行實例之間的線路卻缺失了。每個請求都永遠掛起,等待第二個永遠不會到達的副本。

Fred Brooks 在 1986 年提出,軟體的本質複雜性是可以控制但永遠無法消除的。我們如何分解一個問題決定了複雜性在哪裡集中,而它往往集中在邊界上。我們在失敗的負載測試中發現了這個問題的一個具體實例。每個組件規格都非常詳盡,並且實施與之匹配。遺漏的是集成:TCP 連接何時何地建立不是任何單個組件的責任。

我要求 Claude 連接聯邦協議並更新規格,以防止這類間隙再次發生。一次提交連接了 TCP 層,為 Clerk 添加了線程安全,並將 BFT 閾值設置為 2 個副本。規格增加了關於聯邦啟動順序的指導。連接聯邦協議後,1,000 RPS 在 p99 低於 100 毫秒的情況下運行。

然後我嘗試了 5,000 RPS,p99 上升到 31 秒。

規格將 Claude 的實施限制在我關心的行為範圍內,並且可以按需運行負載測試來衡量進度。我設定了一個大膽的目標,並告訴 Claude 完成後通知我:

Claude 進入了為期 3 小時的迭代循環:分析、假設、更改、運行 Gatling、測量、重複。我沒有提示。p99 最初快速下降:

31,154ms → 1,520ms → 907ms → 234ms → 157ms。

然後收益開始趨於平緩。每次更改都節省了幾毫秒,而早期的修復節省了幾秒鐘。Claude 仍在尋找改進,但迭代方法耗盡了容易的勝利。

我定期檢查,可以看到 Claude 在掙扎。相同的分析-更改-測量循環產生的回報遞減,因為它一次只優化一個瓶頸,而沒有退後一步看全貌。我們需要從多個角度同時考慮程式碼庫,而 Claude Code 的新代理團隊功能正好可以做到這一點:

5 個代理同時審查了程式碼庫,尋找任何阻礙 5,000 RPS 目標的因素。一個鎖定爭用專家發現了固定虛擬線程的同步塊,一個算法複雜度專家在 Clerk 的水印推進中發現了 O(n) 掃描。每個代理都返回了一個帶有文件和行號引用的優先級列表。

代理識別了 27 項優化:用緊湊的替代方案替換重量級數據結構,用更快的算法替換規格的指導塊推薦但初始實施未遵循的簡單算法。每項修復都符合規格的行為。它們都位於規格級別之下,在規格留下的實施選擇中。

該審計的修復將 p99 從 157ms 降低到 25ms。

在達到 5,000 RPS 目標後,我將目標提高到 10,000。

在 10,000 RPS 時,p99 頑固地停留在 208ms。Claude 迭代了數小時,測試了一個又一個假設:聯邦延遲和垃圾回收暫停。對應用程式程式碼的每次更改都沒有產生影響。Claude 繼續努力,勤奮地嘗試每一條途徑,即使在我會感到沮喪並休息之後。

轉折點來自於比較兩組數字。服務器端儀表顯示 99.998% 的請求在 100 毫秒內完成。Gatling 報告 p99 為 209 毫秒。延遲根本不在我們的程式碼中,而在 Docker Desktop 的用戶空間端口轉發代理 gvproxy 中,它位於 Gatling 和容器之間。

Claude 立即意識到這一點並採取了行動:將負載測試移到 Docker 網絡內部。隨著 Gatling 與應用程式容器一起運行,真實的數字出現了:在超過 6,000 次持續 RPS 的情況下,p99 為 29ms,在 302,662 個請求中零失敗。

後續運行達到了 10,000 RPS 的目標,p99 仍低於 100 毫秒。

在達到性能目標後,我們轉向了一個更難的問題:系統在故障下是否能產生正確的結果?

一個快速的系統如果丟失數據就毫無價值。庫存領域為我們提供了自然的正確性檢查:庫存水平不能低於零。將所有庫存從一個倉庫轉移到另一個倉庫並確認總數是否平衡。如果庫存水平變為負數,則表示請求被應用了兩次。如果總數不匹配,則表示實例之間的實體狀態已發散。這個不變量是彈性測試的基礎:400,000 個事件跨越 4 種場景,每種場景都旨在破壞系統。殺死主實例,殺死備份,同時殺死兩者,在恢復期間殺死。

4 種場景中有 3 種在第一次運行時通過。同時殺死,即兩個實例同時崩潰,暴露了一個 Bug。每個實例都跟踪一個水印:它已持久處理了多少事件流。當一個實例在崩潰後恢復時,它會廣播其水印,以便其對等節點知道它處理到了哪裡。問題在於,恢復中的實例在恢復實際完成之前就廣播了其崩潰前的水印。其他實例信任了這個過時的進度信息,並跳過了它們不應該跳過的事件。

Claude 建構了這些彈性測試,運行了它們,當場景失敗時,它在沒有我任何干預的情況下診斷了根本原因並進行了修復。水印 Bug 是一個細微的分布式系統問題:恢復和聯邦之間的競爭條件,僅在同時故障下才會顯現。Claude 確定了確切的機制,實施了修復,並重新運行了所有 4 種場景以確認正確性。一個 AI 代理將一個分布式系統測試到崩潰並修復了它發現的問題。

系統之所以能工作,是因為規格。它們並沒有阻止所有 Bug。它們遺漏了聯邦連接,並且 Claude 並非總是遵循了指導塊。

但規格使得查找和修復 Bug 系統化。當水印 Bug 在彈性測試期間出現時,規格是判斷程式碼錯誤還是設計需要修改的參考點。沒有它,調查將意味著從數千行生成的程式碼中重建預期的行為。

規格在編碼開始前並未完成。我的系統理解能力透過與 Claude 的對話在構建過程中不斷增長,每個階段都暴露了權衡和約束,這些都反饋到了規格中。

當 Arbiter 從順序處理轉向並行處理時,規格首先被更新,然後是程式碼。當負載測試顯示 Clerk 的水印推進需要重新思考時,我們在觸碰實施之前修改了規格。Allium 規格隨著程式碼在所有 64 次提交中一起演進,因為我是在規格中進行設計,而不僅僅是記錄。

聯邦 Bug 指向了軟體工程中一個長期存在的問題:任何分解都控制了一種複雜性,但在邊界引入了另一種複雜性。這並不新鮮。

但規格以程式碼本身無法做到的方式解決了這個問題。就像我們使用多個代理角色從不同角度同時分析程式碼庫一樣,規格不必沿著單一的故障線分解。組件規格描述了邊界內的行為,但集成規格可以描述它們之間的連接,而故障模式規格可以同時涵蓋兩者。我們正在 Allium 中探索這看起來是什麼樣子。規格尚未完成。語言也是如此。

3,000 行規格產生了約 5,500 行生產用的 Kotlin 程式碼和 5,000 行測試程式碼。大約每行規格產生兩行工作程式碼,其中大部分是在我與孩子們玩桌遊時生成的。

軟體工程的技能一直是流動的。我們從打孔卡發展到高級語言,從編寫服務器到配置它們。每一次轉變都淘汰了一套技能,提升了另一套。

這是一次比以往更大的飛躍,但它沿著相同的軌跡。幾十年來,我們一直在告訴其他行業他們需要適應技術。現在,顛覆正在衝擊我們自己的工作實踐。

在這個專案中最關鍵的技能是形式化意圖。規格在整個過程中與程式碼一起演進。當崩潰測試顯示恢復中的實例需要考慮其已持久化的內容與其對等節點已發布的內容之間的差距時,我們在更改程式碼之前修改了恢復規格。當負載測試顯示 Clerk 的水印推進是一個瓶頸時,我們首先在規格中重新思考了設計。

迭代和增量開發並沒有消失:它只是轉移到了更高的抽象層級。形式化規格,即使是透過對話達成的,也為工程判斷提供了一個立足點。

如果您有興趣使用 Allium 和 Claude 建構新系統,您今天就可以從 juxt/claude-plugins 市場安裝 Allium。