拆解 OpenCode:一個開源 AI Coding Agent 的完整架構剖析
AI Coding Agent 正在重新定義軟體工程的工作流。Claude Code、Cursor、Windsurf、Codex — 這些工具背後都有一個共同的核心問題要解決:如何讓 LLM 安全、有效地操作真實的程式碼庫?
這個問題遠比表面看起來複雜。你需要一個能理解多種 AI Provider 的抽象層、一套精確的檔案編輯策略、一個能防止 Agent 失控的權限系統、一個能管理長對話的記憶機制,以及把這一切串起來的事件驅動架構。
OpenCode 是一個開源的 AI Coding Agent,提供 CLI/TUI、Web 和桌面三種介面。它的原始碼是學習「如何從零建構一個生產級 AI Agent 系統」的絕佳教材。這篇文章基於對 OpenCode 原始碼的深度閱讀,拆解它的每一層架構設計,看看一個成熟的 AI Coding Agent 內部到底長什麼樣。
技術棧:現代但務實的選擇
在進入架構之前,先看 OpenCode 的技術選型。這些選擇本身就透露了設計哲學:
| 層級 | 技術 | 設計意圖 |
|---|---|---|
| Runtime | Bun | 啟動速度、原生 SQLite 支援 |
| Monorepo | Turborepo | 多套件協作 |
| UI | SolidJS | TUI 用 @opentui/solid,Web 用 Vite |
| AI SDK | Vercel AI SDK v5 | 統一 25+ Provider 的串流介面 |
| ORM | Drizzle + SQLite | 輕量但型別安全的持久化 |
| 驗證 | Zod v4 | 運行時 schema 驗證 |
| 桌面 | Tauri v2 | 輕量原生包裝 |
| 型別檢查 | tsgo | 實驗性的原生 TypeScript 編譯器 |
幾個值得注意的點:選 Bun 而非 Node 主要是為了 bun:sqlite 的原生綁定,省去了 better-sqlite3 這類外部依賴。用 SolidJS 而非 React,在 TUI 場景下的細粒度響應式更新比 Virtual DOM diffing 更合理。而 Vercel AI SDK v5 是整個 Provider 抽象的基石,後面會詳細展開。
Monorepo 全景
packages/
├── opencode/ ← 核心:CLI、TUI、Server、所有業務邏輯
├── app/ ← Web UI(SolidJS + Vite + Tailwind)
├── desktop/ ← 桌面應用(Tauri v2,包裹 app)
├── web/ ← 行銷/文檔網站(Astro + Starlight)
├── ui/ ← 共用 SolidJS 元件庫
├── sdk/js/ ← TypeScript SDK(從 OpenAPI spec 自動產生)
├── plugin/ ← Plugin API
所有重要邏輯都在 packages/opencode/src/ 裡。這個套件本身就是一個微型作業系統 — 有自己的 Agent 排程器、事件匯流排、權限系統、儲存層和 HTTP Server。
啟動流程:從指令列到 Agent 迴圈
理解一個系統最好的方式是追蹤它的啟動路徑。
使用者輸入 `opencode`
│
├─ yargs 解析指令
├─ Middleware:日誌初始化、環境變數、DB migration
│
└─ 預設指令:TuiThreadCommand
│
├─ 產生 Worker Thread
│ └─ Worker 中運行 HTTP Server + 事件轉發
│
├─ 建立 RPC Client 橋接主執行緒與 Worker
│
└─ 啟動 SolidJS TUI 渲染
這裡有一個關鍵的架構決策:TUI 和 Server 跑在不同的執行緒。Worker Thread 負責所有 I/O 密集的工作(LLM 串流、檔案操作、MCP 連線),TUI 主執行緒只負責渲染和使用者輸入。兩者透過 RPC 通訊,事件則透過 GlobalBus 從 Worker 轉發到 TUI。
這個設計讓 TUI 即使在 Agent 執行大量工具呼叫時也能保持流暢響應 — 在終端機介面中,UI 卡頓的體驗特別糟糕。
Agent 系統:不只是一個 Prompt
OpenCode 定義了 6 個內建 Agent,每個都有明確的職責邊界:
| Agent | 角色 | 工具存取範圍 |
|---|---|---|
| build | 預設 Agent,負責建置和修改 | 幾乎所有工具 |
| plan | 計畫模式,只做分析不動手 | 唯讀工具 + 計畫檔編輯 |
| general | 通用子代理 | 大部分工具 |
| explore | 快速程式碼探索 | 僅搜尋相關工具 |
| compaction | 訊息壓縮(內部) | — |
| title | 產生標題(內部) | — |
每個 Agent 的核心差異不在 prompt,而在 Permission Ruleset — 它決定了 Agent 能使用哪些工具。plan Agent 的妙處在於:它不是靠 prompt 告訴 LLM「不要修改檔案」,而是直接把寫入工具從可用列表中移除。LLM 看不到的工具,就不可能呼叫。
Agent 之間可以透過 task 工具互相委派。當 build Agent 需要搜尋大量程式碼時,它可以啟動一個 explore 子 Agent,在獨立的 Session 中執行,完成後把結果帶回來。這種委派是遞迴的,子 Agent 也可以再委派,但每一層都受自己的 Permission Ruleset 約束。
System Prompt 的動態組裝
不同的 Provider 和模型會得到不同的 system prompt:
Anthropic/Claude → PROMPT_ANTHROPIC(含 todo 支援)
OpenAI GPT → PROMPT_BEAST
GitHub Copilot → PROMPT_CODEX
Google Gemini → PROMPT_GEMINI
System prompt 會被注入運行時的環境資訊:工作目錄、Git 狀態、作業系統、當前日期。這些看似細碎的 context 對 Agent 的行為品質影響很大 — 例如知道當前在 Git repository 的哪個 branch 上,Agent 才能正確地執行 commit 操作。
Tool 系統:Agent 的手和腳
Tool 是 Agent 與外部世界互動的唯一介面。OpenCode 的 Tool 抽象設計得相當優雅:
Tool.Info<Params, Metadata> = {
id: string,
init(ctx?) → {
description: string,
parameters: ZodType, // 用 Zod 定義輸入 schema
execute(args, ctx) → {
title: string,
output: string,
metadata: Metadata, // 即時 UI 更新資料
attachments?: FilePart[], // 圖片、PDF 等附件
}
}
}
每個工具都是 lazy 初始化的 — init() 在首次使用時才被呼叫,這讓啟動速度不受工具數量影響。metadata 回調機制讓工具可以在執行過程中即時更新 UI(例如 bash 工具串流輸出命令執行的進度),而不是等到完全結束才回報。
22+ 內建工具
核心檔案操作: bash、read、write、edit、apply_patch
搜尋與探索: glob、grep、websearch、codesearch、webfetch
Agent 協作: task(委派子 Agent)、question(詢問使用者)、skill(載入指令)
批次與管理: batch(平行執行最多 25 個工具)、todowrite、lsp
模型感知的工具篩選
工具的篩選邏輯會根據模型能力調整。例如,GPT 系列模型使用 apply_patch(一次性套用多檔 patch),而其他模型使用 edit + write(逐檔精確編輯)。這不是隨意的偏好,而是因為不同模型在不同工具格式上的表現差異顯著。
ToolRegistry.tools(model, agent)
├─ GPT 系列 → apply_patch(非 edit/write)
├─ 其他模型 → edit/write(非 apply_patch)
└─ websearch/codesearch → 需特定 flag 啟用
Edit 工具:9 層容錯的匹配策略
edit 工具是整個 Tool 系統中最精妙的一個。LLM 在指定要替換的程式碼時,經常會有微小的偏差 — 多一個空格、少一個縮排、轉義字元不一致。OpenCode 用 9 種替換器依序嘗試,確保盡可能找到正確的匹配位置:
- SimpleReplacer — 精確字串匹配
- LineTrimmedReplacer — 忽略行首行尾空白
- BlockAnchorReplacer — 上下文錨點 + Levenshtein 距離
- WhitespaceNormalizedReplacer — 正規化所有空白
- IndentationFlexibleReplacer — 縮排彈性匹配
- EscapeNormalizedReplacer — 處理轉義字元差異
- TrimmedBoundaryReplacer — 邊界修剪
- ContextAwareReplacer — 上下文感知
- MultiOccurrenceReplacer — 多次出現的情況
這 9 層策略從最嚴格到最寬鬆排列。先嘗試精確匹配,若失敗則逐步放寬條件。這解決了一個 AI Coding Agent 的核心痛點:LLM 的輸出不是確定性的,但檔案編輯必須精確。這種分層容錯讓 Agent 的編輯成功率大幅提升,同時保持了修改的準確性。
核心 Agentic 迴圈:心臟在哪裡
整個系統最重要的檔案是 src/session/prompt.ts 中的 SessionPrompt.loop()。這是 Agent 的心跳 — 一個不斷循環的迴圈,直到任務完成或被中斷:
使用者輸入
│
▼
SessionPrompt.prompt()
├─ 建立 User Message
│ ├─ 文字提示
│ ├─ file:// URL → 讀取檔案
│ ├─ data:// URL → base64 附件
│ └─ MCP resources
│
└─ SessionPrompt.loop() ← 核心迴圈
│
├─ 解析可用工具(依 Agent + Model 篩選)
├─ 注入系統提醒
│
├─ LLM.stream() → Vercel AI SDK
│ ├─ 組裝 system prompt
│ ├─ 格式化對話歷史
│ └─ 啟動串流
│
├─ 處理串流事件
│ ├─ text-delta → 更新文字
│ ├─ reasoning-delta → 更新推理過程
│ ├─ tool-call → 執行工具
│ └─ finish-step → 計算成本、產生 diff
│
└─ 檢查完成條件
├─ 不再有工具呼叫 → 結束
├─ 需要壓縮 → 執行 compaction
└─ 繼續下一輪迴圈
每一輪迴圈就是一個「LLM 思考 → 呼叫工具 → 取得結果 → 再思考」的循環。迴圈在 finish !== "tool-calls" 時終止 — 也就是說,LLM 決定不再呼叫任何工具時,任務就算完成了。
Doom Loop 偵測
一個容易被忽略但極其重要的細節:處理器會追蹤工具呼叫歷史。如果偵測到 連續 3 次以上相同工具搭配相同輸入,就會觸發 doom loop 警告。這防止了 Agent 陷入「嘗試 → 失敗 → 用完全相同的方式重試」的無限循環 — 這是所有 Agentic 系統都必須面對的問題。
訊息壓縮(Compaction)
長對話會超出模型的 context window。OpenCode 的解決方案是在對話過長時,使用專門的 compaction Agent 來壓縮歷史訊息。壓縮後的內容以 CompactionPart 的形式插入,取代原始的冗長歷史。這讓 Agent 可以處理任意長度的任務,而不會因為 context 限制而失敗。
Provider 系統:25+ 模型的統一抽象
OpenCode 支援超過 25 個 AI Provider — OpenAI、Anthropic、Google、Amazon Bedrock、GitHub Copilot、xAI、Mistral、Groq 等等。這一切都建立在 Vercel AI SDK v5 的 LanguageModelV2 介面之上。
每個 Provider 有特定的訊息轉換邏輯(src/provider/transform.ts),處理各家 API 的差異:
- Anthropic:過濾空字串、清理空的 text/reasoning parts
- Mistral:強制 9 字元的 tool call ID、修正訊息順序
- Caching:為支援 cache 的 Provider 標記 system message 和最近的訊息
溫度參數的預設值也因 Provider 而異 — Qwen 用 0.55、Claude 不設定(使用 API 預設)、Gemini 用 1.0。這些細節決定了實際使用體驗。
模型資料從 models.dev 取得,有三層回退機制:
- 本地檔案(
OPENCODE_MODELS_PATH) - 編譯時快照
- 遠端 fetch(每小時背景更新)
SDK 實例用 xxHash32 做快取 — 相同的 provider + 設定組合只會建立一個 SDK 實例,避免重複初始化的開銷。
MCP 整合:擴展 Agent 的能力邊界
Model Context Protocol(MCP) 是讓 Agent 連接外部工具伺服器的標準協議。OpenCode 的 MCP 實作支援三種傳輸方式:
- StreamableHTTPClientTransport — HTTP POST + SSE(優先嘗試)
- SSEClientTransport — 純 SSE(回退方案)
- Stdio — 標準輸入輸出(本地工具)
MCP 工具被轉換成與內建工具相同的格式,Agent 無法區分它在呼叫的是內建工具還是 MCP 工具。這種透明整合讓擴展性幾乎沒有上限 — 任何實作了 MCP 協議的服務都可以被 Agent 使用。
OAuth 流程也被完整支援。當 MCP 伺服器需要認證時,系統會顯示認證 URL,引導使用者完成授權後繼續連線。
Permission 系統:信任但驗證
讓 AI Agent 操作真實的程式碼庫,安全性不能馬虎。OpenCode 的權限系統用 分層規則 控制工具存取:
評估順序(後者覆蓋前者):
1. 預設權限
2. Agent 特定權限
3. Session 特定權限
4. 專案儲存的核准記錄
5. 使用者設定
每條規則定義了三種動作:allow(允許)、deny(拒絕)、ask(詢問使用者)。
Bash 工具有特殊的 BashArity 系統,為常見指令定義 token 數量以實現更細粒度的控制。例如 git 的 arity 是 2,這表示 git push(高風險)和 git status(低風險)可以有不同的權限規則。npm run 的 arity 是 3,讓 npm run test 和 npm run deploy 分別控制。
這比簡單的「允許/拒絕 bash」精細得多,解決了一個實際問題:你希望 Agent 能自由地跑測試和查看 git 狀態,但在推送程式碼或安裝套件時要先問你。
儲存層:SQLite 的務實選擇
整個系統的持久化建立在 SQLite 上,透過 Bun 的原生綁定和 Drizzle ORM:
SessionTable ─┬─ MessageTable ─── PartTable
├─ TodoTable
└─ PermissionTable
幾個值得注意的設計:
WAL 模式 + 64MB cache:WAL(Write-Ahead Logging)讓讀寫可以並行,配合大 cache 提升查詢效能。
Part 表去正規化:PartTable 冗餘儲存了 session_id,雖然可以透過 MessageTable 推導,但直接儲存讓按 Session 查詢 Parts 的速度大幅提升。這是典型的「用空間換時間」權衡。
CASCADE delete:所有外鍵都設定了級聯刪除。刪除一個 Session 會自動清理所有相關的 Messages、Parts、Todos 和 Permissions。
Transaction 副作用系統:Database.transaction() 支援註冊副作用,讓事件發布等操作可以在 transaction commit 後才執行,避免發布了事件但 transaction 最終 rollback 的不一致問題。
Event Bus:事件驅動的神經系統
BusEvent.define(type, schema) → 型別安全的事件定義
Bus.publish(def, props) → 發布到當前 Instance + GlobalBus
Bus.subscribe(def, callback) → 訂閱特定事件
事件從業務邏輯層流向 UI 的路徑:
業務邏輯 → Bus.publish()
│
├─ 當前 Instance 的訂閱者
│
└─ GlobalBus → Worker Thread
└─ RPC → TUI Process
└─ SDKProvider → SyncProvider → UI 重新渲染
這種事件驅動架構讓 UI 完全不需要輪詢。任何狀態變化 — Session 建立、訊息更新、工具執行進度、MCP 連線狀態 — 都透過事件即時推送到所有訂閱者。
Configuration:七層設定合併
OpenCode 的設定系統支援 7 個來源,從低優先到高優先:
- 遠端
.well-known/opencode(組織預設) - 全域
~/.config/opencode/opencode.json OPENCODE_CONFIG環境變數指定的檔案- 專案
./opencode.json .opencode目錄中的檔案OPENCODE_CONFIG_CONTENT(內嵌 JSON)/etc/opencode/opencode.json(管理員,最高優先)
設定格式使用 JSONC(含註解的 JSON),支援 {env:VAR_NAME} 環境變數替換和 {file:path} 檔案內容引入。目錄中的 .md 和 .ts/.js 檔案會被動態掃描,自動載入為 Agent 定義、指令或 Plugin。
程式碼風格:從慣例看設計哲學
OpenCode 的程式碼慣例反映了一種強烈的工程偏好:
- Namespace exports:模組使用
export namespace Foo { }而非頂層函式,保持清晰的模組邊界 - No destructuring:堅持使用 dot notation(
obj.prop),保留物件的語意 context - const over let:用三元運算或 early return 取代重新賦值
- No else:所有分支都用 early return 處理
- Avoid try/catch:偏好
.catch()的函式鏈風格 - 避免 mock 測試:測試真實實作而非 mock
最後一條尤其值得玩味。在一個如此依賴外部 API 的系統中堅持不用 mock,意味著測試要面對真實的 LLM 呼叫和檔案操作。這讓測試更慢但更可靠 — 因為 mock 測試永遠無法捕捉到「Provider 的 API 回應格式變了」這類真實世界的問題。
結語:架構告訴我們什麼
回頭看 OpenCode 的整體架構,幾個設計決策特別值得思考:
分層容錯勝過完美預測。 Edit 工具的 9 種匹配策略、Provider 的多層回退、設定的 7 層合併 — 這些都體現了同一個原則:在與不確定性打交道的系統中,優雅的 fallback 比完美的預測更實際。
約束即安全。 Agent 的能力不是由 prompt 定義的,而是由它能存取的工具決定的。Permission Ruleset 從根本上限制了 Agent 的行動空間,比任何 prompt engineering 都更可靠。
事件驅動是膠水。 在一個涉及 LLM 串流、工具執行、UI 更新、跨執行緒通訊的系統中,事件匯流排是把所有東西黏合在一起的關鍵抽象。它讓每個模組只需要知道「發生了什麼事」,而不需要知道「誰需要這個資訊」。
務實勝過炫技。 SQLite 而非 PostgreSQL、Drizzle 而非 Prisma、去正規化的 session_id 而非標準化的 JOIN — 每個選擇都傾向「在這個場景下最簡單有效的方案」,而非「業界最佳實踐」。
對於想要理解 AI Agent 系統如何運作的開發者來說,OpenCode 的原始碼是目前最好的學習材料之一。它不是一個玩具專案,而是一個面對真實世界複雜性的生產級系統 — 而這些複雜性中藏著最有價值的工程智慧。