拆解 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 的技術選型。這些選擇本身就透露了設計哲學:

層級技術設計意圖
RuntimeBun啟動速度、原生 SQLite 支援
MonorepoTurborepo多套件協作
UISolidJSTUI 用 @opentui/solid,Web 用 Vite
AI SDKVercel AI SDK v5統一 25+ Provider 的串流介面
ORMDrizzle + 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+ 內建工具

核心檔案操作: bashreadwriteeditapply_patch

搜尋與探索: globgrepwebsearchcodesearchwebfetch

Agent 協作: task(委派子 Agent)、question(詢問使用者)、skill(載入指令)

批次與管理: batch(平行執行最多 25 個工具)、todowritelsp

模型感知的工具篩選

工具的篩選邏輯會根據模型能力調整。例如,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 種替換器依序嘗試,確保盡可能找到正確的匹配位置:

  1. SimpleReplacer — 精確字串匹配
  2. LineTrimmedReplacer — 忽略行首行尾空白
  3. BlockAnchorReplacer — 上下文錨點 + Levenshtein 距離
  4. WhitespaceNormalizedReplacer — 正規化所有空白
  5. IndentationFlexibleReplacer — 縮排彈性匹配
  6. EscapeNormalizedReplacer — 處理轉義字元差異
  7. TrimmedBoundaryReplacer — 邊界修剪
  8. ContextAwareReplacer — 上下文感知
  9. 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 取得,有三層回退機制:

  1. 本地檔案(OPENCODE_MODELS_PATH
  2. 編譯時快照
  3. 遠端 fetch(每小時背景更新)

SDK 實例用 xxHash32 做快取 — 相同的 provider + 設定組合只會建立一個 SDK 實例,避免重複初始化的開銷。

MCP 整合:擴展 Agent 的能力邊界

Model Context Protocol(MCP) 是讓 Agent 連接外部工具伺服器的標準協議。OpenCode 的 MCP 實作支援三種傳輸方式:

  1. StreamableHTTPClientTransport — HTTP POST + SSE(優先嘗試)
  2. SSEClientTransport — 純 SSE(回退方案)
  3. 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 testnpm 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 個來源,從低優先到高優先:

  1. 遠端 .well-known/opencode(組織預設)
  2. 全域 ~/.config/opencode/opencode.json
  3. OPENCODE_CONFIG 環境變數指定的檔案
  4. 專案 ./opencode.json
  5. .opencode 目錄中的檔案
  6. OPENCODE_CONFIG_CONTENT(內嵌 JSON)
  7. /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 的原始碼是目前最好的學習材料之一。它不是一個玩具專案,而是一個面對真實世界複雜性的生產級系統 — 而這些複雜性中藏著最有價值的工程智慧。