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。這個方式在原型階段沒問題,但在生產環境有兩個顯著缺陷:
每輪對話 system prompt 幾乎不變,但如果以單一字串傳入,API 快取無法有效命中
部分內容(如目前目錄、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 額度。
每次迭代的流程:
檢查是否需要 compact
建構完整 prompt
發起流式請求
即時處理事件流
檢查 stop_reason — tool_use 則執行工具,end_turn 或 max_tokens 則結束迴圈
建構 tool_result message,追加到 messages 陣列,進入下一輪
工具執行管線是 Agent Loop 中最複雜的部分,共六個階段:
renderToolCall — 在終端機展示將要執行的工具名稱和參數
permissionCheck — 根據工具類型和參數決定是否需要使用者確認
preHook — plugin 系統的前置攔截點
checkpoint — 對於破壞性操作,在執行前快照相關檔案狀態
executeTool — 呼叫實際工具函數
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 讀取。零新增程式碼路徑,工具權限模型自動適用。生命週期:
首次執行 — 建立索引
後續對話 — 自動載入,按需讀取
過時記憶 — 模型主動更新或刪除
記憶檔案是普通文字,使用者可以直接編輯,不存在格式鎖定。
整個專案驗證了一個認知:Claude Code 的工程品質確實高於平均水準。分段 prompt 的三層快取設計——靜態系統提示快取、工具定義快取、動態內容不快取——這個粒度的 cache 意識在大多數 LLM 應用中是缺失的。
這兩天的體驗就是,想建構 Agent 工具/產品,最重要的一個結論是:核心難點在於 Harness Engineering。畢竟呼叫 API 是十行程式碼的事,只有把工具呼叫結果正確地回饋給模型、在流式輸出中間插入使用者互動、處理長任務中的錯誤恢復
