Trino + Iceberg 不是新東西,Cloudflare 把 Town Lake 的價值押在「無聊的部分」——per-row access control、default-closed allowlist、PII 自動偵測、idempotent ingestion、schema evolution。會寫 SQL 的 Claude 反而是最後上桌的那道菜。
Cloudflare 怎麼蓋 Town Lake——R2 + Iceberg + Trino + 一個會寫 JS 的 Claude
Town Lake 是 Cloudflare 內部「所有資料」的單一查詢入口——把散在 Postgres、ClickHouse、Kafka、Google Cloud 上的 operational store 全部映射到 R2 Iceberg、用 Trino 做 federated SQL、用 DataHub 維 metadata、用 Lifeguard 管 access、用 Skimmer 掃 PII、用 Transformer 做 ELT。最上層蓋了一個叫 Skipper 的 agent:使用者問「過去 30 天 R2 storage cost 最高的 10 個客戶、跟前 30 天的差異」,Skipper 翻 metadata、寫 SQL、丟給 Trino、把表格跟圖渲染回來——大約三秒。重點不是 LLM 寫 SQL,而是底下這一整套基礎設施怎麼被組裝起來、讓 LLM 寫的 SQL 真的可以跑、真的會被授權、真的會擋住 PII。
click any service to read its responsibility · 6 named services
click a service above
Trino · 責任邊界
Federated SQL engine——一條 query 可以同時 join Postgres 表、ClickHouse 表、跟 R2 上的 Iceberg 表,不需要把中間結果落到第三個系統。對 Town Lake 的意義:原本散在七八個 operational store 的問題現在用一條 SQL 回答。
不負責的事:資料怎麼進來、欄位什麼意思、使用者能不能看。這三件分別由 Transformer、DataHub、Lifeguard 處理。
R2 Data Catalog · 責任邊界
「Where cold and warm data lives」——managed Apache Iceberg on R2。Iceberg 的 partition evolution 啟用自動 compaction:「Per-minute usage from last week becomes hourly, hourly from last quarter becomes daily」——舊資料降頻率但保持可查詢。儲存成本隨年齡遞減。
不負責的事:欄位語意、欄位由哪條 SQL 產出——這走 DataHub。
DataHub · 責任邊界
Metadata 集散地——「Every table, column, owner, lineage edge, and glossary term lives there」。使用者查 DataHub 找表的描述、欄位細節、ownership、上下游 lineage。Skipper 也是 DataHub 的客戶:先 search dataset、再 get entity details,才開始寫 SQL。
不負責的事:強制 access。DataHub 只描述「這張表存在、由誰擁有」;可不可以查由 Lifeguard 決定。
Lifeguard · 責任邊界
Access control plane。Rules 存在 D1、user/group membership 動態從內部 access management system 拉、組合成「a combined JSON policy that Trino reads over HTTP」。Default-closed:表在 Skimmer 審完 + 人類核可前都不能查;未審欄位從 DESCRIBE、SHOW COLUMNS、SELECT * 隱藏,但不會打斷既有 dashboard。
不負責的事:判斷哪個欄位是 PII——那是 Skimmer 的工作。Lifeguard 只執行規則。
Skimmer · 責任邊界
PII 偵測。連續對所有表 sample rows,Workers AI 分類器跑兩 pass:fast 的 per-column classifier 過第一輪,「agentic second pass that gets full table context and can query Trino directly to verify」做第二輪驗證。發現的候選 PII 寫進 DataHub、進 Lifeguard 的 allowlist 等人類審核。
不負責的事:判決——Skimmer 只標候選,redact / unredact 由 Lifeguard 政策 + 人類審決定。
Transformer + Skipper · 責任邊界
Transformer 是 ELT engine——「Directed Acyclic Graph (DAG) of SQL transformations with YAML frontmatter」描述 target table、materialization mode、依賴、排程,跑在 Workflows 上、state 落 Durable Objects、歷史落 D1。Transformer 每次成功 run 都會 emit .meta.json 進 DataHub——這份 metadata 後來變成 Skipper 最重要的 context 來源(見〈prompting lessons〉)。Skipper 是最上層的 agent——把所有底層服務當 tool 用。
不負責的事:對 storage 跟 access 層的直接操作。Transformer 跟 Skipper 都繞著走,從不繞過 Lifeguard。
互動圖表
Town Lake 六服務分層,Trino 居查詢層,Lifeguard 管存取,Skimmer 掃 PII,Skipper agent 居頂。
底下七個小節,前五個各拆一層——從儲存與查詢開始、再往上走到 metadata、access、PII——然後是 Skipper 怎麼一步步把這些 tool 用起來、code mode 為什麼比 chatty tool calls 好、prompting 的反模式、最後是這套系統還處理不了的事。先把 Cloudflare 為什麼自己蓋這件事說清楚:Brian Brunner、Dmitry Alexeenko、Matt Moen 三位作者開門見山——「Parts of our previous internal reporting stack were powered by external vendors. Beyond the cost, we had a hard external dependency on another cloud for some of our critical data」。billing 跟 legal 案例需要 unsampled 資料、外部供應商會 downsample;governance 層面又要 default-closed 表 allowlist 加 PII 自動偵測,這在現成商業產品裡不是 baseline。「If we were going to make a major investment in our data infrastructure, it was going to be built on the same products we sell to customers」——R2、Workers、Access、Workflows——Town Lake 也是 Cloudflare 內部規模對自家平台的真實壓力測試。
R2 Data Catalog 與 Trino:cold + warm 同址、federated 查詢
底層是 R2 Data Catalog——Cloudflare 對 Apache Iceberg 的 managed 包裝。Iceberg 本身就是一套「open table format」標準:把一張邏輯表拆成多份 Parquet 檔加一層 manifest,讀寫端用 manifest 來知道哪些檔案構成某一版的表。R2 Data Catalog 把 Iceberg 的 commit、compaction、partition evolution 都接到 R2 的物件儲存上,讓使用者寫 SQL 時拿到的是「彈性 schema 的傳統表」、底層 byte 是 R2 上分散的 Parquet。對 Town Lake 的意義在於「cold 跟 warm data 在同一個地方」——不需要把熱資料留在 ClickHouse、冷資料 archive 到 S3 然後在兩邊切 query 邏輯。同一張表,舊 partition 自動降頻率,「Per-minute usage from last week becomes hourly, hourly from last quarter becomes daily」——但 SQL 語意不變。
partition evolution 是 Iceberg 對「自動 compaction」的關鍵能力——傳統 Hive table 的 partition 一旦選定就鎖死,要改 partition strategy 必須重寫整張表;Iceberg 把 partition 規格寫進 manifest、新舊 partition 可以共存,舊資料用舊 layout、新資料用新 layout,讀端透明。所以 Cloudflare 可以把「過去一週」的 1 billion 事件每秒級資料保持在 per-minute 粒度、再把它逐步聚合到 per-hour、per-day,舊的高頻 partition 整個被退役。整個降頻率動作對 SQL 使用者是看不見的——一條 WHERE event_time BETWEEN ... AND ... 跨越多個 partition layout 的 query 仍然回得到一致結果。對 storage cost 的影響是線性的:兩天前的資料用 1/60 的列數承載一整小時、一個月前的用 1/1440 承載一整天——同樣的 query 走過、I/O 量級下降。
R2 在這層的角色不只是「便宜的物件儲存」——它的零 egress 是 Town Lake 能跟 Cloudflare 內外多個 workload 直接接的前提。Trino 查 R2 不收 egress、Workers AI 從 R2 跑 PII 分類也不收 egress、Transformer 把 Parquet 寫進 R2 也不收 egress。這在 multi-cloud 路徑下是常見的隱性瓶頸:cross-region、cross-cloud 的 I/O cost 會把任何 federated 查詢的經濟模型壓爛——這也是 Cloudflare 強調「don't depend on another cloud for our critical data」的具體含義之一。
查詢層用 Apache Trino。Trino 是 Facebook(後來 PrestoSQL fork 出來)的 distributed SQL engine,特點是 connector 模型——同一條 SQL 可以從多個異質 source 拉資料、coordinator 規劃 query plan、worker 平行執行。對 Town Lake 的價值具體是「a single SQL query can join a Postgres table, a ClickHouse table, and an Iceberg table on R2 without a need to materialize intermediate results into a different system」。一個分析師想做「我們 ClickHouse 裡的某個事件流跟 Postgres 裡的客戶表 join 起來看 R2 上一個月份的 storage 用量分布」——以前要先 dump、再 load 進第三個系統、寫 SQL;現在這條問題就是一條 query。
不 materialise 中間結果這件事在大型 federated workload 下非常關鍵——materialise 意味著把中間 join 結果落到一個 staging table、然後從那裡再讀。對 1 billion+ events/second 的事件流,這條路會把 I/O 倍增。Trino 的 plan optimiser 會把 join key 推到 source 端、用 partition pruning 在 connector 層就過濾掉大部分 row,最終回到 coordinator 的只是已經被縮過的 stream。Cloudflare 不需要在這層自己寫 query 規劃——Trino 已經處理掉了——這就是文章作者那句「Trino + Iceberg is not new technology」的具體含義:底層的 query plan、connector、pushdown,社群已經做好,Cloudflare 拿來組裝、改寫 connector 對 R2 Data Catalog 的部分、把 Lifeguard 的 JSON policy 接到 Trino 的 access control hook 上、剩下都是「在這個基礎上加 Cloudflare 特有的 governance 與整合」。
ingestion 端走另一條路——Trino 不直接從 operational store 串資料進 Iceberg。Cloudflare 寫了一個長壽的 Kubernetes orchestrator:「An orchestrator runs as a long-lived Kubernetes deployment, reads pipeline configs, and spawns short-lived worker jobs」。每條 pipeline 走 extract → transform to Parquet → load into R2 as Iceberg table,要嘛 full-replace、要嘛 incremental-append。原始事件量級值得記一下:「Raw events 1 billion+ per second initially land in production databases and ClickHouse」,downsample 給 dashboard 用的版本仍有「700M+ events per second」——Town Lake 的 Iceberg 表是這些事件流的 long-term 落腳處,而 Trino 是讓人能對它們提問的方式。
DataHub:metadata 才是 LLM 真正的 fuel
第三層是 DataHub。DataHub 本身是 LinkedIn 開源的 metadata 平台,Cloudflare 拿來做 Town Lake 的 catalog——「Every table, column, owner, lineage edge, and glossary term lives there」。對人類使用者,這是「我要找一張表」的入口;對 Skipper 這個 agent,這是它知道哪張表存在、欄位什麼意思、誰擁有、上下游接什麼的唯一來源。沒有 DataHub,Skipper 寫的 SQL 等於對著黑盒子 hallucinate 表名。
但「metadata is enough」並不是真的——這是 prompting lessons 裡最關鍵的一條:context 分五層,「biggest accuracy wins came when we started ingesting the actual SQL that produces a table」。換句話說,DataHub 上人寫的 column description(layer 2 human annotations)不夠強;真正讓 Skipper 寫對 SQL 的是 Transformer pipeline 跑完後寫進 DataHub 的 .meta.json——裡面包含「這個 column 是哪段 SQL 算出來的」、「上游從哪裡來」(layer 3 code-derived knowledge)。理由也很直覺:欄位的「真實語意」就藏在生它的那段 SQL 裡,人寫的 description 容易過時或太抽象。把 SQL 本身餵給 LLM,等於讓它直接讀「定義」而不是「介紹」。
Skipper 用 DataHub 的方式可以分成兩個 phase。前 phase 是 discovery——「search_datasets」這類工具,用使用者問題裡的關鍵字(「R2 storage cost」「customers」)找候選表;後 phase 是 zoom-in——「get_entity_details」拉特定表的完整 schema、column-level description、上游 lineage、最近誰查過、查過的常見 join 模式。Skipper 不會把整個 DataHub 都灌進 context window,而是 just-in-time 拉它真正需要的部分;這也是為什麼 code mode(下面會講)的「在 sandbox 裡跑 JS」優於「一個一個 tool call」——code mode 讓 model 可以一次寫出「先 search、再對前三個結果 get_entity_details、再做決定」的程式邏輯,省掉好幾輪 round-trip。
Skipper 餵給 LLM 的五層 context——L3 是準確度上跳最大的一層
click any layer above
L1 · schema & usage metadata
從 DataHub 拉的最基本 fuel——表 schema、foreign key、primary key、根據歷史查詢算出來的「這張表常跟哪幾張一起 join」。讓 LLM 從「我聽過這個 column」進到「我知道 join 該怎麼接」。
L2 · human annotations
team 寫的 table / column 描述、tag(例如 curated 代表這張是經過審核的指標表)。有用,但 Cloudflare 的觀察是:比想像中弱——人寫的描述容易過時、用語抽象,LLM 拿來推 column 真實語意不夠。
L3 · code-derived knowledge · biggest accuracy win
Transformer 每次成功跑 ELT pipeline 後,會 emit 一份 .meta.json 進 DataHub——裡面含「產生這張表的 SQL」、column 是怎麼從上游算來的。原文直引:「the biggest accuracy wins came when we started ingesting the actual SQL that produces a table」。理由很直觀:column 的真實語意就藏在生它的那段 SQL 裡,這比任何 description 都精準。
L4 · curated data models
人類寫的「概念架構」文件——例如「Cloudflare billing 的時候 customer / account / zone / domain 的關係是這樣」。當 SQL 本身不夠抽象、要回答需要領域知識的問題時補的層。
L5 · runtime introspection
當所有前面四層都還不夠時的 safety net——Skipper 直接對 Trino 跑 DESCRIBE table、SELECT DISTINCT col、SELECT COUNT(*),用真實的資料形狀來校正自己對表的理解。
互動圖表
把產生表格的 SQL 本身餵給 LLM 是最大準確度躍升點,勝過人寫的欄位描述。
Lifeguard 與 Skimmer:governance 是默認關閉
Cloudflare 在 access control 的選擇值得拆細。Lifeguard 把 access rule 存進 D1、user/group membership 從內部 access management system 動態拉、組合產出一份 JSON policy 餵給 Trino——Trino 端原生支援透過 HTTP 拉外部 policy。所以 query authorisation 的決策邏輯不是塞在 Trino 設定檔裡的硬編碼,而是「每次查的時候現算」。membership 變了立刻生效——不是設定變更要重啟 Trino。
真正特別的是 default-closed 這條設計:「Tables are inaccessible for querying until they have been reviewed」。要查的表沒被 Skimmer + 人類核可前,使用者收到的是「this table needs review, click here to request one」,不是冷冰冰的 permission denied。這把 discovery 跟 access 分開:使用者可以看到表存在、但欄位內容是審完才開放。沒被審到的欄位從 DESCRIBE、SHOW COLUMNS、SELECT * 隱藏,但 not break existing dashboards——既有 dashboard 已經 column 名單寫死、不會掃到新增的未審欄位,所以舊查詢繼續跑、新欄位走 review flow。
PII 走再一層機制——「PII columns opt-in per session; redacted by default unless user flips permission bit」。預設情況下 Trino 在 PII column 流回到使用者螢幕之前就已經 redact 掉;要看 raw PII 必須這個 session 內主動翻權限旗、整個動作會被 audit log 記錄。對「客戶服務 debug 某筆訂單明細」這類有正當理由摸 PII 的場景,這個 opt-in 機制不會把人擋掉——但會留下「誰、什麼時候、為什麼」的紀錄。對 Skipper,這條規則的 enforcing 寫得很直接:「Everything Skipper does runs as the calling user. If you don't have access to a table, Skipper can't query it for you」——Skipper 不是繞過 Lifeguard 的捷徑,它的權限 ceiling 是 calling user 的權限 ceiling。
Skimmer 是 PII 偵測的引擎。它連續對所有表 sample rows、丟給 Workers AI 跑兩 pass classifier:第一 pass 是 fast per-column,用便宜 model 過所有 column;第二 pass 是「agentic second pass that gets full table context and can query Trino directly to verify」——對第一 pass 標的 candidate 走更貴的 reasoning,並能對 Trino 下查詢驗證假設(譬如「這個 column 真的是 email 嗎?拉 100 row 出來看看」)。結果寫進 DataHub 作 metadata、進 Lifeguard 的 allowlist 等人類審核。從工程角度,這把「PII 偵測」這件本來需要昂貴專業 service 的事情,拆成「便宜的 first pass 過大量 column + 昂貴的 second pass 只看候選」——cost 跟 Cloudflare 自家 Workers AI 的 token cost 對齊。
Skipper 的 code mode:把工具當函式庫呼叫
Skipper 對外是「跟 Cloudflare 員工聊天的對話介面」,跑在 Workers + Workers AI + Durable Objects + D1 + R2 + Workflows 上——一份標準的 Cloudflare-native stack。對內,它把底層的 DataHub、Trino、Lifeguard、Transformer 包成一組工具:search_datasets、get_entity_details、start_query、fetch_results、create_chart,外加直接操作 Transformer 跟 Lifeguard 的 admin 工具。使用者可以從 Skipper 自己的網頁介面用,也可以從本地 IDE 透過 MCP server 用——本地 mode 是同一組工具透過 MCP protocol 暴露給 Claude Code 之類的 client。
關鍵的工程創新在於 code mode:與其讓 model 一次只能呼叫一個工具、等回應、再決定下一步,Skipper 讓 model「寫一段 JavaScript snippet 程式呼叫整個工具集」。原文「The model writes a JavaScript snippet that calls our entire toolset programmatically」——「That JavaScript runs in a sandboxed Dynamic Worker isolate via WorkerLoader」。WorkerLoader 是 Cloudflare 用來在執行時動態載入並執行 Worker 程式碼的 primitive,本來就為 multi-tenant 的 isolation 設計——程式碼跑在獨立的 V8 isolate,記憶體、CPU、能呼叫的 capability 都受限。Skipper 把這個機制借來:每次 model 想做事,它先想出一段 JS、丟給 sandbox 跑、拿到結果再決定下一步。Skipper 把它的工具集做成「在那個 isolate 裡看得到的全域函式」,model 寫的程式可以直接呼叫。
code mode 比起 chatty 一次只呼叫一個工具的好處具體:少了 round-trip——model 不用 plan → call tool A → 等回應 → 再 plan → call tool B → 等回應,而是寫一段「search → if-result-empty fallback → 對前幾個結果並行 get_entity_details → 從中挑最像答案的開始寫 SQL」的程式碼一次跑完。每多一輪 tool call 就要重新建立一次 context、付一次 token cost;code mode 把多個邏輯步驟壓成一次。對複雜問題的影響非常明顯:原本一條包含 if/else、loop、parallel fetch 的 plan 變成「寫程式就好」——而 LLM 本來就擅長寫程式。下面的 tab 把同一個問題在 chatty mode 跟 code mode 下展開、再展示 sandbox 邊界。
switch tabs to compare three modes · 3 tabs
傳統 agent 把每個 tool 當一輪對話:model 想出一個 tool call、丟給 host、host 跑、結果回到 model context、model 再想下一步。每多一輪就重建一次 context、付一次 token,邏輯複雜起來尤其貴。
turn 1
model: call search_datasets(q="r2 storage cost customers")
host: → returns [datasets/billing.r2_costs, datasets/billing.customer_r2_usage, ...]
turn 2
model: call get_entity_details("datasets/billing.r2_costs")
host: → returns schema + lineage + description
turn 3
model: call get_entity_details("datasets/billing.customer_r2_usage") // 還在試表
turn 4
model: // 終於開始寫 SQL... 已經 4 個 round-trip
call start_query("SELECT customer_id, SUM(...) ... ")
turn 5
model: call fetch_results(query_id)
turn 6
model: call create_chart(rows, kind="table")
六輪 round-trip——每輪 model 都要重新讀整個之前的 context;對話越長 context 越腫,最終吃掉 token cost 跟 latency。文章作者觀察「Tool overlap is poison」——當工具有同名變體(三個版本的 fetch results、兩個版本的 search),model 在這些 turn 之間還會選錯工具。
code mode:model 寫一段 JavaScript snippet 把整個探索邏輯一次跑完。fetch、condition、parallel、loop、reduce 都是程式碼,不是對話 turn。
// model 寫的 snippet,跑在 sandboxed Dynamic Worker isolate
const candidates = await search_datasets({
q: "r2 storage cost customers",
limit: 5
});
// 平行抓前 3 個的 detail
const details = await Promise.all(
candidates.slice(0, 3).map(c => get_entity_details(c.urn))
);
// 從 detail 裡挑出含 customer_id + monthly_cost 欄位的
const target = details.find(d =>
d.columns.some(c => c.name === "customer_id") &&
d.columns.some(c => c.name === "monthly_cost")
);
if (!target) throw new Error("no suitable table found");
const queryId = await start_query(`
WITH cur AS (
SELECT customer_id, SUM(monthly_cost) AS cost
FROM ${target.name}
WHERE day >= current_date - INTERVAL '30' DAY
GROUP BY customer_id
), prev AS (
SELECT customer_id, SUM(monthly_cost) AS cost
FROM ${target.name}
WHERE day >= current_date - INTERVAL '60' DAY
AND day < current_date - INTERVAL '30' DAY
GROUP BY customer_id
)
SELECT cur.customer_id, cur.cost, cur.cost - prev.cost AS delta
FROM cur LEFT JOIN prev USING (customer_id)
ORDER BY cur.cost DESC LIMIT 10
`);
return await create_chart(await fetch_results(queryId), { kind: "table" });
整個流程一次跑完——一個 sandbox 執行 + 一次回傳結果給 model。對複雜問題、需要 if/else 或 fan-out 的探索路徑,差距尤其大。原文:「Top 100 customers by revenue」這種問題透過 Skipper 大約三秒回出來。
code mode 不是「讓 LLM 任意執行程式碼」——它跑在 WorkerLoader 動態建立的 isolate 裡,邊界清楚。
┌───────────────────── sandboxed Dynamic Worker isolate ─────────────────┐
│ │
│ globals exposed: │
│ search_datasets(...) ← 透過 host RPC 走到 DataHub │
│ get_entity_details(...) ← 同上 │
│ start_query(...) / fetch_results(...) ← 走 Trino,user 身分 │
│ create_chart(...) ← 渲染結果 │
│ │
│ 禁止: │
│ fetch、XMLHttpRequest ← 不能對外發網路 │
│ eval、new Function ← 不能再寫一層 sandbox │
│ require、import 任意 module ← 只看得到 host 注入的 globals │
│ process.env、fs ← 不存在 │
│ │
│ ──────────── 邊界 ──────────── │
│ │
│ isolate CPU / memory budget by WorkerLoader │
│ user identity stamped on every tool call → Lifeguard 仍然檢查 │
│ │
└────────────────────────────────────────────────────────────────────────┘
關鍵是「user identity stamped on every tool call」——sandbox 裡 model 寫的程式呼叫 start_query 時,host 把 calling user 注入,Lifeguard 用這個 user 算 access policy。所以 model 自己生成的程式碼,仍然不能查到 user 看不到的表。「Everything Skipper does runs as the calling user」——sandbox 是執行邊界、user identity 是授權邊界,兩者獨立。
互動圖表
code mode 讓 model 以一段 JS 完成搜尋、並行 fetch、寫 SQL 全流程,省掉多輪 round-trip。
prompting 的反模式:少即是多、工具不能重疊
Skipper 開發過程提煉出兩條反模式,都直接打臉「業界常識」。第一條:「Less prompting is more」——「Elaborate prescriptive system prompts reduced quality」。早期版本給 model 寫了非常詳細的 system prompt——「先做這、再做那、遇到 X 怎麼處理、遇到 Y 怎麼處理」——quality 反而下降。後來改成 high-level 指引(「你的任務是回答 Cloudflare 員工的資料問題,工具如下」),讓 model 自己依靠 in-context tool descriptions 做決策,準確率上升。這跟 prompt engineering 流傳的「越詳細越好」直覺相反——對 capable 的 model,過度規定流程相當於把它的判斷力綁起來。
第二條:「Tool overlap is poison」——多個變體的同類 tool(三個「fetch results」、兩個「search」)讓 model 選錯。直覺上「給 model 多選擇」應該好,但實際是 model 對「subtle differences in similar tools」非常敏感,多餘的相似工具反而讓它 confuse。解法是強制 consolidation——一個 tool 用 mode parameter 涵蓋多個用法,而不是拆成多個近義 tool。對寫工具的人,「把 fetch_results(format='json')、fetch_results(format='table')、fetch_results(format='paginated')」做成一個 tool 跟一個 enum、不要做成三個近似 endpoint。
第三條觀察是關於 memory:「Without a memory layer, the agent rediscovers and re-learns. With one, it gets monotonically better at recurring questions」。沒有記憶層的 agent 每次對話從零開始——同樣的 ambiguity(「customer」這個字在這個 org 到底指哪張表)每次都要重推;有了記憶層之後,agent 在 recurring question 上會「monotonically better」。重複問題的修正、特定團隊的 jargon、常用 KPI 的定義——這些一旦學會就不該再重新學一次。具體實作 Cloudflare 沒在 blog 細講,但「monotonically better」是個有力的描述:不是「有時候對有時候錯」,是「該記的記下來、下次直接用」。對 production agent 工程,這條意味著你需要一個「跨 conversation 的 store」——把 user 在 conversation A 對某個解釋的 correction 寫進去,conversation B 看到同樣 ambiguity 時直接套用。
把這三條反模式擺在一起看,可以反推 Skipper 的 system prompt 應該是什麼樣子:minimal、不規定流程、暴露工具但工具設計乾淨、外加一層 cross-conversation memory。這跟「給 LLM 寫一份巨大的 instruction manual」的直覺相反,但 Cloudflare 的觀察是——這條路真的更有效。對任何在自家 stack 裡接 LLM agent 的工程團隊,這三條值得抄進設計準則。
實際用量:billing 主導、5 行 SQL 取代 200 行
用量值得看具體數字,因為它揭露 Town Lake 的真實價值不是「跑得多快」而是「讓誰能查到從前查不到的東西」。最近一段量測期間:91,760 條 query 從 324 位 distinct Cloudflare 員工發出。Billing-related query 占 53%——超過一半的查詢是關於計費。這在「資料平台」這種模糊概念裡很尖銳:Town Lake 的高頻使用情境不是科學家做 ML、不是 PM 做產品決策,是 finance、sales、support 等部門搞清楚客戶到底用了多少資源、產生多少 revenue、跟上個月比怎麼變。
「Top 100 customers by revenue」這種 query 透過 Skipper 大約三秒回出來——這數字背後是 Trino + Iceberg + 預先 compaction 過的 monthly aggregate 全部上場的結果。而更具體的提升在程式碼長度上:作者提到 legacy 200–300 行的 SQL query 在 Town Lake 上變「five lines」。從 200 行降到 5 行不是 Skipper 寫得短——是底層 schema 設計好、partition 對齊使用模式之後,原本需要手動 union 多個表、處理 partition 邊界、加 hack 修 schema drift 的 SQL,現在的等價問題已經被 Transformer pipeline 預先 normalise 過。
click column header to sort · 5 columns × 6 rows
| 指標 | 數值 | 單位 | 範圍 | 註解 |
|---|---|---|---|---|
| recent measurement period queries | 91,760 | queries | recent window | 324 distinct Cloudflare employees |
| billing-related share | 53 | % | of all queries | 過半都是「客戶用了多少/付了多少」 |
| raw events landing | 1,000,000,000+ | events/sec | initial ingest | 先進 production DB + ClickHouse |
| downsampled for dashboards | 700,000,000+ | events/sec | dashboard tier | unsampled 版仍保留給 billing / legal |
| Skipper top-100 customers by revenue | ~3 | seconds | end-to-end | 自然語言問題 → 表格 |
| legacy SQL → Town Lake SQL | 200–300 → 5 | lines | per query | 底層 schema normalise 後 50× 縮短 |
互動圖表
91,760 次查詢中 billing 佔 53%;top-100 客戶 SQL 從 200 行降到 5 行,縮短 40 倍。
91,760 / 324 大概是每個員工每天平均不到一條 query——但分布肯定極不均勻:finance / billing team 高頻、其他人偶爾。billing 的 53% 也意味著 Town Lake 沒有變成「分析師的玩具」,而是 finance、support、客戶服務的日常 dependency。從產品設計角度看,這是好兆頭:當一個內部工具成為跨部門日常使用的 plumbing,它的工程投入就有了穩定的 ROI。並非每一個自建資料平台都能爬到這個位置——許多內部 ML 平台從未走出 data team。
另一個值得記的細節是 Transformer 的形狀。Transformer 跑在 Workflows 上、state 落 Durable Objects、歷史落 D1——這幾乎是 Cloudflare 自家所有 stateful primitive 的組合用法。使用者寫的不是程式碼而是 YAML:「Directed Acyclic Graph (DAG) of SQL transformations with YAML frontmatter」描述 target table、materialization mode(full-replace / incremental-append)、依賴、排程。Materialization mode 的選擇是 ELT 的核心——大表的 daily aggregate 通常是 incremental-append(只算新增的一天),小表的 reference data 走 full-replace(重算整張),這條規格直接寫在 YAML 開頭,Workflows engine 跑起來照做。對 Skipper 來說這個設計是 transparent 的:它只需要從 DataHub 拉「這張表怎麼產的」,背後是 incremental 還是 full-replace 都不影響它寫 SELECT 的方式。
用量是「會用什麼」;下面這幾段是「還沒能處理好的事」。Town Lake 是一套很完整的設計,但 Cloudflare 在 blog 結尾誠實列了幾個尚未閉合的邊界。第一是 schema evolution——Iceberg 對 column add / rename 友善、對 type change 跟 nested structure change 不那麼友善。Transformer pipeline 的 schema drift 還是會出現需要人介入的情況,特別是當 upstream Postgres 改 type、downstream Iceberg 表已經有大量歷史 partition 用舊 type 寫過。改 type 的具體成本:要嘛保留歷史 partition 用舊 type、新 partition 用新 type(讀端要做 cast);要嘛重寫所有歷史 partition(IO 量大、要協調好不要踩到正在讀的 query)。沒有銀彈,Cloudflare 的選擇是 case-by-case 人類介入。
第二是 PII 偵測的 false positive——Skimmer 的 two-pass classifier 在大多數情境能正確標出 PII,但仍會有把「看起來像 email 但其實是內部 hash」這種 column 標成 PII 的情況,整張表就被打到 review queue 等審。這在量大時對 platform team 是負擔——他們是 false positive 的下游。Cloudflare 在 blog 沒承諾這條會自動化解,明確說是 platform team 的人工負擔。
第三是 access control 模型本身有它的代價——「default-closed」對 governance 是優點、對「我只是想快速看一眼」的探索是阻力。剛加進系統的表必須等 review;review queue 變長時,使用者的探索意願會被磨掉。Cloudflare 沒回答這條怎麼平衡,但提供 self-service 的 review request flow(「click here to request review」)至少把摩擦從「找誰問」降到「按一下」。
第四是 Skipper 寫出來的 SQL 偶爾錯。對重要 query 仍需要人類審——特別是 billing / legal 場景,幾個錯誤的 join condition 可能算出差 10 倍的數字。Cloudflare 強調 Skipper 不是 unsupervised:它是 conversational 介面,每一條查詢都有人在迴圈裡看結果合不合理。對重要的決策 query(譬如算給 CFO 看的 quarterly numbers),仍然要走人類審的 pipeline。LLM-generated SQL 不會在沒人看的情況下自動推到 production dashboard。
第五是「為什麼自家蓋」這條也有 trade-off。文章作者明說:「If we were going to make a major investment in our data infrastructure, it was going to be built on the same products we sell to customers」——dogfooding 是優點,但也意味著 Town Lake 的可用性受 R2、Workers、Workflows、D1、Access 的可用性影響。任何一條 critical path 上的服務出問題,Town Lake 也跟著掛。對外部產品這是「正常的 multi-service dependency」,對內部資料平台這可能是更尖銳的問題——因為 billing 需要 5 個 9 的 query availability。Cloudflare 沒在 blog 細談這條,但選 dogfooding 就接受了這個 coupling。
What this enables:Town Lake 真正不是「又一個 Trino + Iceberg」——它證明了把 default-closed allowlist、column-level PII redaction、agentic SQL writing 這三件事縫進同一個查詢路徑是可工程化的,而且當底層的 metadata 包含「產生表的那段 SQL 本身」時,agent 寫 SQL 的準確率會上跳一個量級。