← 返回首頁
小八
小八
@IceBearMiner
81🔁 5
𝕏 (Twitter)🔥🔥🔥🔥

AI 語音朗讀 · Edge TTS

從零開始兩天建構一個 Claude Code:帶你拆解 AI CLI 的每一層

前兩天突發奇想:一個生產級的 agentic CLI 到底需要哪些組件?每一層的具體怎麼實作?SSE 緩衝區怎麼管理、system prompt 怎麼分段、工具權限怎麼攔截、上下文滿了怎麼壓縮。這些問題靠讀文件回答不了,靠逆向混淆程式碼效率極低。

所以選擇了另一條路:以 Claude Code 為參照系,從零重建一個功能等價的實作——純 TypeScript,零框架,唯一的依賴是 fast-glob(因為原生 glob 在跨平台路徑處理上有已知缺陷)。

兩天之後的結果是 46 個檔案,一萬行 TypeScript。這篇文章記錄的是這個過程中每一層的技術決策和實作細節。

整體架構

在開始寫任何程式碼之前,先要在腦子裡跑通一個最小迴圈:使用者輸入一句話,CLI 怎麼把它變成一次 API 呼叫,API 回應怎麼變成終端機輸出或工具執行,工具結果怎麼再送回模型。把這個迴圈畫清楚,架構就基本定了。

核心流程如下:

使用者輸入 → 組裝請求 → API 呼叫(SSE)→ 解析回應事件流 → 根據 stop_reason 決定分支:

  • end_turn — 輸出文字,結束本輪

  • tool_use — 執行工具呼叫 → 將 tool_result 追加到 messages → 反覆迭代

目錄結構按層劃分:

  • core/ — 引擎層,包含 Agent Loop、SSE 客戶端、context 管理、compact 邏輯

  • tools/ — 工具系統,包含所有內建工具的實作和 MCP 客戶端

  • ui/ — 終端機渲染層,處理流式文字輸出、進度指示、顏色主題

  • plugins/ — 擴充系統,允許執行時注入工具和 hook

  • skills/ — 技能庫,對應 Claude Code 的 slash command 進階功能

  • commands/ — 處理 / 前綴命令的解析和分發

這六層之間的依賴是單向的,core/ 不依賴 ui/,tools/ 不依賴 skills/。

技術選型的核心理由是 Node.js 22 的原生能力。fetch 和 ReadableStream 在 22 版本中已經穩定,不需要 node-fetch 或任何 HTTP 客戶端程式庫。TextDecoder 處理 UTF-8 位元組流,Buffer 處理二進位,child_process.exec 執行 Shell 命令——所有這些都是標準程式庫。唯一無法繞過的是檔案系統 glob,fast-glob 在處理 gitignore 規則和大型目錄的效能上比原生實作好一個數量級,這個依賴是值得的。

多平台 LLM 相容

預設支援御三家和自訂平台,自訂模型(OpenAI 相容格式)是一個附加需求。部分本地模型(如 Ollama、LM Studio)公開 OpenAI 相容介面,事件格式與 其他格式不同。處理方式是在 SSE 客戶端初始化時傳入 format: 'openai' 參數,在事件解析層做格式適配,將 OpenAI 的 delta 結構轉換成統一的內部事件類型。這樣 Agent Loop 層完全不感知 API 格式差異。

System Prompt 分段架構與 Prompt Caching

最直覺的 system prompt 寫法是一個大字串,把所有指令拼湊在一起傳給 API。這個方式在原型階段沒問題,但在生產環境有兩個顯著缺陷:

  1. 每輪對話 system prompt 幾乎不變,但如果以單一字串傳入,API 快取無法有效命中

  2. 部分內容(如目前目錄、Git 狀態、CLAUDE.md 檔案內容)每輪都會變化,與靜態內容混雜在一起會污染快取

將 system 參數設為 block 陣列,每個 block 可以獨立設定 cache_control。

按可變性將 system prompt 分為兩類:

靜態段(程序生命週期內不變,標記上 cache_control 後首次寫入快取,後續命中):

  • 身份聲明(參考龍蝦的 User、Soul)

  • 工具使用規範(何時用 Bash vs 讀取檔案、何時拒絕執行)

  • 程式撰寫風格(規範、註解原則)

  • 安全執行規則(禁止執行的命令類型)

這個就是官方訂閱為什麼呼叫 API 能省下不少錢,因為快取命中率高。

動態段(不帶 cache_control,每輪重新計算):

  • 目前工作目錄和系統環境(每次啟動可能不同)

  • Git 儲存庫狀態(git status 輸出,每輪可能變化)

  • CLAUDE.md 內容(使用者可隨時修改)

  • MCP 伺服器的自訂指令(執行時發現)

完整請求主體的 system 欄位最終是一個有序 block 陣列,順序固定:身份 → 工具指南 → 程式撰寫規範 → 安全規則 → 風格指南 → 環境資訊 → Git 上下文 → CLAUDE.md → MCP 指令。Anthropic 的 prompt caching 按照 block 陣列的前綴匹配來識別快取,排在前面的靜態內容越穩定,快取命中率越高。

Agent Loop:while 迴圈背後的状态機

Agent Loop 的骨架是一個有上限的 while 迴圈,最大迭代次數 25 次,每次迭代對應一輪模型呼叫——25 輪足以完成大多數真實任務(讀取檔案、分析、修改、驗證通常在 10 輪內完成),同時防止失控迴圈耗盡 API 額度。

每次迭代的流程:

  1. 檢查是否需要 compact

  2. 建構完整 prompt

  3. 發起流式請求

  4. 即時處理事件流

  5. 檢查 stop_reason — tool_use 則執行工具,end_turn 或 max_tokens 則結束迴圈

  6. 建構 tool_result message,追加到 messages 陣列,進入下一輪

工具執行管線是 Agent Loop 中最複雜的部分,共六個階段:

  1. renderToolCall — 在終端機展示將要執行的工具名稱和參數

  2. permissionCheck — 根據工具類型和參數決定是否需要使用者確認

  3. preHook — plugin 系統的前置攔截點

  4. checkpoint — 對於破壞性操作,在執行前快照相關檔案狀態

  5. executeTool — 呼叫實際工具函數

  6. postHook — plugin 後置鉤子

自動 compact 機制處理上下文視窗溢出。在每輪迭代開始時,估算目前 messages 陣列的 token 數量(總字元數除以 4),如果超過模型上下文限制的 85%,觸發壓縮——發起一次獨立的 API 呼叫產生摘要,用 [{role: 'user', content: summary}, {role: 'assistant', content: 'Understood.'}] 替換原來的 messages 陣列。(這點跟 Claude Code 是一樣的)

Prompt Caching 在 Agent Loop 層有三個施力點:

  • system prompt blocks — 如前文所述,靜態段走快取

  • tools 陣列 — 最後一個 tool definition 標記上 cache_control

  • 最後一條 tool_result message — 作為快取斷點

三層疊加的實際效果是每輪 API 呼叫只有少量 token 是真正的輸入計費,大部分走快取價格(約為正常輸入價格的 10%,這個官方 API 都帶,中轉站的快取命中率一般都不會很高)。

21 個內建工具

工具系統的入口是 TOOL_DEFINITIONS,一個 JSON Schema 陣列,描述每個工具的名稱、用途和參數結構。模型透過這個陣列"知道"有哪些工具可以呼叫以及如何呼叫。陣列中包含 21 個內建工具定義,加上執行時從 MCP 伺服器動態注入的工具。(json scheme能保證輸出格式,LLM json也更易讀)

執行入口是單一的 executeTool 函數,內部使用 switch 按工具名稱分發。MCP 工具透過 mcp__ 前綴識別,走獨立的呼叫路徑。

核心工具的實作細節各有側重:

  • Read — fs.readFile 讀取檔案後加上行號前綴,支援 offset 和 limit 參數分頁讀取

  • Write — 寫入前 fs.mkdir({ recursive: true }) 確保父目錄存在

  • Edit — 精確字串替換,關鍵約束是 old_string 必須在檔案中唯一出現,多次出現則報錯——迫使模型提供足夠精確的定位字串

  • Bash — child_process.exec 執行,120 秒逾時,輸出超 500 行時截斷(保留前 200 + 後 100)

  • Grep — 自行實作 regex 引擎,支援三種輸出模式、上下文行、跨行匹配,不依賴系統 grep

  • WebFetch / WebSearch — fetch + HTML 剝離 + 截斷,搜尋使用 DuckDuckGo

Deferred Tools 是一個效能最佳化。低頻工具(如 NotebookRead、TodoWrite)不放入每次請求的 tools 陣列,而是標記為 deferred——模型需要時透過 ToolSearch 工具按關鍵字查詢取得完整 schema,然後在下一輪呼叫。這個機制將 tools 陣列的固定開銷降低了約 40%。

權限系統:三種模式與兩階段分類器

權限系統是 agentic CLI 的安全核心。設計不當要么讓使用者一直點擊確認直到放棄,要么給模型太多自主權造成不可逆損壞。

三種模式:

  • default — safe 類工具(Read/Glob/Grep/WebFetch)自動執行,dangerous 類(Bash/Agent)和 write 類(Write/Edit)需要使用者確認

  • auto — 繞過所有互動式提示,適合 CI 環境——但 deny rules 仍然生效,底線不可逾越

  • plan — 唯讀沙盒,safe 工具放行,dangerous 和 write 工具被靜默拒絕,使用者可以先看執行計畫再切換到 default

工具分類在註冊時靜態聲明:

  • safe — Read, Glob, Grep, WebFetch, WebSearch 等

  • dangerous — Bash, Agent(副作用範圍不可預測)

  • write — Write, Edit(檔案修改是最常見的需要稽核的操作)

  • bypass — PlanMode 切換,始終無需確認

兩階段分類器增強 auto 模式下的安全性:

Stage 1 — 純模式匹配:維護已知安全命令和已知危險命令的規則表,命中即返回,涵蓋 90% 以上的情況,零延遲。

Stage 2 — 處理未涵蓋的情況:將命令字串發送給 Haiku 模型,要求返回 allow/deny/ask_user 三種之一。Haiku 延遲約 300-500ms,相比模型主迴圈幾乎可以忽略。ask_user 路徑讓自動模式在遇到模糊操作時能夠優雅降級。

MCP 動態工具與 LSP 整合

MCP(Model Context Protocol)本質上是一個標準化的工具發現協議。傳統做法是把工具硬程式碼在 CLI 裡,MCP 讓工具變成可以獨立部署的程序,透過統一介面被任何相容客戶端發現和呼叫。

協議層是 JSON-RPC 2.0 over stdio:啟動 MCP server 程序,透過 stdin/stdout 交換 JSON-RPC 消息。啟動序列固定:initialize 握手 → tools/list 取得工具定義陣列。工具定義包含 JSON Schema 格式的 inputSchema,與 Anthropic API 的工具格式直接相容。McpManager 管理多個 server 的生命週期,工具名稱加上 mcpserver_name 前綴做命名空間隔離。

LSP 整合解決不同的問題:不是擴充工具,而是給模型提供即時的程式碼診斷資訊。LspClient 實作 Language Server Protocol 客戶端側,使用 Content-Length 幀協議通訊。LspManager 維護檔案副檔名到語言伺服器的路由表,Write/Edit 執行後自動通知對應語言伺服器,診斷結果作為 lsp_diagnostics section 注入下一輪 system prompt。

這個設計讓模型在修改程式碼後能立即看到編譯器回饋,而不需要單獨的"檢查錯誤"工具呼叫,縮短了發現問題到修復問題的路徑。

plugin 系統與 Skills

plugin 系統回答的問題是:如何在不修改 CLI 核心程式碼的情況下擴充能力?答案是 manifest 驅動的目錄結構。每個 plugin 是一個目錄,包含 plugin.json,聲明提供的六類擴充點:

  • skills — 注入可呼叫的技能

  • agents — 注入自訂 Agent 定義

  • hooks — 前置/後置鉤子

  • commands — 自訂斜線命令

  • mcpServers — 注入 MCP 伺服器,啟動時自動連線

  • lspServers — 注入 LSP 伺服器

Skills 是比自訂 Agent 更輕量的重複使用單元——本質上是參數化的 prompt 模板。使用者輸入 /commit 時,skill 系統展開模板、注入上下文,提交給目前 agent loop。8 個內建 skill:

  • commit / pr / review — Git 操作類,讀取 diff 後產生規範化訊息或 PR 描述

  • init — 掃描專案結構,產生設定檔

  • simplify — 審查變更程式碼,查找重複/低效邏輯,直接修復

  • loop / schedule — 自動化類,持續執行或定時觸發

  • update-config — 修改 CLI 自身設定

Skill 查找遵循固定優先級:內建 → plugin → 專案 .clio/skills/。專案級 skill 可以覆蓋 plugin 級,但不能覆蓋內建,防止安全敏感的內建 skill 被意外替換。

補上之前提到的 Agent Teams:多 Agent 協作

單 Agent 的瓶頸在於串行執行。當任務可以分解為多個獨立子任務時,多 Agent 並行能顯著縮短完成時間。

子 Agent 透過 executeSubAgent() 執行,它是主 agent loop 的簡化版本:排除團隊管理工具防止無限巢狀,最多 15 輪迭代。隔離模式可選——isolation: "worktree" 建立 Git worktree,子 Agent 在獨立分支上操作,完成後由主 Agent 決定是否合併。

背景 Agent 透過 run_in_background: true 標記,Promise 存入 Map,完成時以 tool_result 形式通知主 Agent。

自訂 Agent 定義在 .clio/agents/*.md 檔案中,YAML front-matter 聲明工具子集、模型選擇、最大迭代次數。

完整協作流程:主 Agent 呼叫 TeamCreate 建立團隊 → SendMessage 分發任務 → 等待完成 → 彙總結果 → TeamDelete 釋放資源。

這三個工具構成了一個最小化的多 Agent 協調協議,沒有訊息佇列,沒有共享狀態,只有顯式的訊息傳遞。

Auto Memory:跨會話記憶

LLM 本身是無狀態的。每次對話從空白上下文開始,之前建立的偏好、專案約定、使用者回饋全部消失。Auto Memory 用檔案系統模擬持久記憶,不引入資料庫,不設計新的儲存格式。

記憶檔案儲存在專案專屬目錄下,每個是帶有 YAML frontmatter 的 Markdown 檔案,四種類型:

  • user — 使用者偏好(角色、習慣、專長)

  • feedback — 行為糾正(使用者確認或否定的做法)

  • project — 專案約定(不可從程式碼/git 推導的上下文)

  • reference — 外部資源指標(URL、看板、文件連結)

MEMORY.md 是索引檔案,每次對話開始時注入 system prompt,模型按需透過 Read 工具讀取具體內容。

記憶的寫入完全重複使用現有工具鏈:Write 建立、Edit 更新、Read 讀取。零新增程式碼路徑,工具權限模型自動適用。生命週期:

  1. 首次執行 — 建立索引

  2. 後續對話 — 自動載入,按需讀取

  3. 過時記憶 — 模型主動更新或刪除

記憶檔案是普通文字,使用者可以直接編輯,不存在格式鎖定。

整個專案驗證了一個認知:Claude Code 的工程品質確實高於平均水準。分段 prompt 的三層快取設計——靜態系統提示快取、工具定義快取、動態內容不快取——這個粒度的 cache 意識在大多數 LLM 應用中是缺失的。

這兩天的體驗就是,想建構 Agent 工具/產品,最重要的一個結論是:核心難點在於 Harness Engineering。畢竟呼叫 API 是十行程式碼的事,只有把工具呼叫結果正確地回饋給模型、在流式輸出中間插入使用者互動、處理長任務中的錯誤恢復