我把 18th 鐵人賽那 30 篇文章全部抓出來,搜了一個關鍵字。
gateway 出現在 18 篇裡面。
聽起來覆蓋率很高。但我一篇一篇看過去之後,發現一件有點尷尬的事——那 18 次出現,全部長這樣:
src/gateway/session-lifecycle-state.ts(路徑)GatewayDrainingError(錯誤類別名)isGatewaySubordinateWorkAdmissionClosed()(函式名)沒有任何一篇解釋過它是什麼。
它就這樣以一個專有名詞的姿態,在 30 天裡飄過 18 次。讀者大概會以為自己知道那是什麼——畢竟「閘道」嘛,聽起來就是入口。我自己在寫的時候也是這麼混過去的。
然後我做了一件事:數檔案。
src/agents/ 1136 個檔案
src/gateway/ 604 個檔案 ← 第二大
src/plugin-sdk/ 482 個檔案
src/skills/ 89 個檔案 ← 我寫了四篇以上
604 個檔案,第二大的模組,零篇。
而 89 個檔案的 skills/ 我寫了四篇還不只。
這個對比讓我意識到,那 30 天的大綱反映的不是這個倉庫的形狀,是我動筆前腦子裡的形狀。所以這個系列的第一篇,就從那個最大的洞開始補。
官方文件開頭三句話就把定位講完了:
📄 文件:
docs/concepts/architecture.md
- A single long-lived **Gateway** owns all messaging surfaces (WhatsApp via
Baileys, Telegram via grammY, Slack, Discord, Signal, iMessage, WebChat).
- Control-plane clients (macOS app, CLI, web UI, automations) connect to the
Gateway over **WebSocket** on the configured bind host (default
`127.0.0.1:18789`).
- One Gateway per host; it is the only place that opens a WhatsApp session.
三個關鍵詞:single、long-lived、owns。
單一、長壽、而且是擁有者。
注意它用的動詞不是 "routes"(轉送)也不是 "manages"(管理),是 owns。這個字很重要,等一下會回來講。
先看它到底接了誰。根據同一份文件,連到 gateway 的東西分成三類:
Clients(控制面) —— macOS app、CLI、web admin
health、status、send、agent、system-presence)tick、agent、presence、shutdown)Nodes(裝置) —— macOS / iOS / Android / headless
role: node
canvas.*、camera.*、screen.record、location.get 這些命令Providers(訊息來源) —— WhatsApp、Telegram、Slack、Discord、Signal、iMessage、WebChat
而 gateway 自己做的事,文件列了四項:
### Gateway (daemon)
- Maintains provider connections.
- Exposes a typed WS API (requests, responses, server-push events).
- Validates inbound frames against JSON Schema.
- Emits events like `agent`, `chat`, `presence`, `health`, `heartbeat`, `cron`.
看到這裡,我第一個念頭是:這跟我 30 天裡講的那些東西,是什麼關係?
答案是——我那 30 天講的全部住在它裡面。
這才是 gateway 存在的真正理由,而且它不是軟體設計的品味問題,是物理限制。
文件那句話值得再念一次:
One Gateway per host; it is the only place that opens a WhatsApp session.
一個 WhatsApp session,同一時間只能有一個持有者。
你不能開兩個行程同時登入同一個 WhatsApp 帳號。Telegram、Signal、iMessage 也都有類似的約束——連線是有狀態的、是獨佔的、而且斷線重連有代價。
所以問題變成:如果我有一個 CLI、一個 macOS app、一個排程器、還有三個 agent,它們都想送訊息到同一個 WhatsApp 帳號,怎麼辦?
只能有一個東西真正握著那條連線,其他人跟它要。
那個東西就是 gateway。
一旦你接受這個前提,後面所有的設計就都推得出來了:
604 個檔案就是這樣長出來的。它不是一個「閘道」,它是整個系統的宿主。
這是我補完這一篇之後,最大的認知調整。
18th 鐵人賽第 3 天我講「一個請求怎麼進來」,然後順著呼叫鏈一路往下追進 agent 內部——分派、工具、記憶、子代理、失敗處理。
但我從來沒往上看過。
而往上看的話,那整條鏈其實是 gateway 收到一個 WS frame 之後才開始的。我花 30 天描述的那個「任務的一生」,對 gateway 來說只是它眾多職責裡的一項:把一個 agent 請求交給 agent runtime,然後等結果回來。
用一個比喻:
員工再優秀,大樓停電就什麼都不用談。而我花了 30 天描述員工,一次都沒提過大樓。
這也解釋了為什麼 GatewayDrainingError 會出現在我講任務分派那篇的程式碼節錄裡:
if (isGatewaySubordinateWorkAdmissionClosed()) {
throw new GatewayDrainingError();
}
當時我把它當成 admission 邏輯的一部分帶過去了。現在回頭看,那一行在說的是:
大樓要關門了,不要再放新的工作進來。
它不是 agent 的邏輯,它是宿主的邏輯。 我當時沒看出來,因為我根本不知道有宿主這回事。
大致掃過去,可以分成幾塊:
| 子區 | 檔案數 | 在做什麼 |
|---|---|---|
| 根目錄散檔 | 334 | 連線管理、事件派送、各種 policy |
server-methods/ |
174 | WS API 的每一個方法 |
server/ |
46 | HTTP / WS 伺服器本體 |
worker-environments/ |
40 | 工作執行環境 |
server-methods/ 那 174 個檔案特別能說明問題——一個方法一個檔案。
這代表 gateway 對外的 API 表面非常大。它不是「一個入口收所有東西」,而是「一個伺服器提供一百多種操作」。
文件那句 Validates inbound frames against JSON Schema 也值得注意:每一個進來的 frame 都要先過 schema 驗證。這在一個可以被外部裝置連線的系統裡是必要的,而這又帶出下一個問題:誰連得上它、憑什麼連。
最後那一條其實是這整個系列的起點。
基礎設施做得好的時候是隱形的。 你順著任務往下追,不會撞到它;你讀 agent 的程式碼,只會看到它的名字出現在 import 裡。直到你去數檔案,才發現那是第二大的模組。
127.0.0.1:18789),靠 role 區分身分server-methods/ 就有 174 個檔案,一個方法一個檔GatewayDrainingError 不是 agent 的邏輯,是宿主在說「要關門了」既然所有 client 和裝置都連同一個 WS server,那就有一個很現實的問題:
它憑什麼相信連進來的是誰?
而且我在查資料的時候,發現 OpenClaw 以前其實有兩套協定,現在只剩一套。另一套被整個拿掉了。
下一篇就從那個被刪掉的東西開始講。