
會做這件事,起因是一個很無聊的下午。
我的所有 agent 同時停擺,因為帳號額度用完了。整個平台的自動化全部靜止,而我手上其實還有其他 CLI 可以用,只是程式碼裡從頭到尾寫死了 claude。
那天我把 spawn_claude() 這個函式改名成 spawn(),然後花了兩週把它後面的東西抽掉。
多後端抽象有成本,不是每個人都該做。三個判斷標準:
該做: 你的 agent 是別人在用的產品,而使用者手上不見得有跟你一樣的訂閱。
該做: 你有可用性要求,某一家掛掉的時候不能整個停擺。
不必做: 你自己用,只有一個帳號,掛掉就等它恢復。
還有一個容易被忽略的理由:不同任務適合不同後端。摘要類任務用便宜的,架構決策用貴的。如果你的程式碼寫死一家,這個優化你做不了。
我第一版抽錯了層次,值得說一下。
第一版我抽的是「訊息層」:定義一組統一的 Message,然後各家實作各自轉換。這個做法在純 API 呼叫的場景是對的,但我要驅動的是 CLI,而 CLI 之間的差異不在訊息格式,在別的地方。
實際的差異長這樣:
| 面向 | Claude Code | Codex | Gemini CLI |
|---|---|---|---|
| 單次執行 | -p <prompt> |
子命令 | 子命令 |
| 沙箱控制 | --allowedTools / --disallowedTools |
沙箱等級旗標 | 各自的核准模式 |
| MCP 註冊 | 設定檔 / --mcp-config |
命令列覆寫參數 | 設定檔逐鍵合併 |
| 專案指示檔 | CLAUDE.md |
AGENTS.md |
GEMINI.md |
| 輸出格式 | --output-format json |
各自的 JSON 格式 | 各自的格式 |
所以正確的抽象層是「一次執行」:給我提示詞、能力宣告、工作目錄,還我文字結果加用量資訊。中間怎麼組參數、怎麼註冊 MCP、怎麼解析輸出,各自處理。
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class RuntimeContext:
"""一次執行需要的全部資訊,與具體 CLI 無關。"""
prompt: str
cwd: str
sandbox: str # "read_only" | "workspace_write" | "full_access"
allowed_tools: tuple[str, ...]
denied_tools: tuple[str, ...]
mcp_servers: dict # 要註冊的 MCP server
timeout_secs: int = 900
@dataclass
class RuntimeResult:
text: str
input_tokens: int = 0
output_tokens: int = 0
exit_code: int = 0
raw: str = "" # 原始輸出,除錯用
class AgentRuntime(Protocol):
name: str
def is_available(self) -> bool: ...
def execute(self, ctx: RuntimeContext) -> RuntimeResult: ...
sandbox 用三個字串等級,而不是直接傳各家的旗標。這是關鍵決定:抽象層講「我要什麼程度的權限」,實作層負責翻譯成各家的講法。
import subprocess, json, shutil
class ClaudeRuntime:
name = "claude"
def is_available(self) -> bool:
return which_claude() is not None
def execute(self, ctx: RuntimeContext) -> RuntimeResult:
cmd = [which_claude(), "-p", ctx.prompt,
"--output-format", "json",
"--setting-sources", "project,local"]
# 沙箱等級 -> 工具限制
if ctx.sandbox == "read_only":
cmd += ["--tools", "Read,Grep,Glob"]
elif ctx.sandbox == "workspace_write":
cmd += ["--tools", "Read,Write,Edit,Grep,Glob"]
# full_access 就不限制
if ctx.allowed_tools:
cmd += ["--allowedTools", ",".join(ctx.allowed_tools)]
if ctx.denied_tools:
cmd += ["--disallowedTools", ",".join(ctx.denied_tools)]
if ctx.mcp_servers:
cmd += ["--mcp-config", write_temp_mcp(ctx.mcp_servers),
"--strict-mcp-config"]
p = subprocess.run(cmd, cwd=ctx.cwd, capture_output=True,
text=True, timeout=ctx.timeout_secs,
stdin=subprocess.DEVNULL)
return parse_claude_json(p)
class CodexRuntime:
name = "codex"
def is_available(self) -> bool:
return shutil.which("codex") is not None
def execute(self, ctx: RuntimeContext) -> RuntimeResult:
cmd = [shutil.which("codex"), "exec", ctx.prompt]
# 沙箱等級翻譯成這家的講法
cmd += ["--sandbox", {
"read_only": "read-only",
"workspace_write": "workspace-write",
"full_access": "danger-full-access",
}[ctx.sandbox]]
# MCP 走命令列覆寫
for name, cfg in ctx.mcp_servers.items():
cmd += ["-c", f"mcp_servers.{name}.command={cfg['command']}"]
p = subprocess.run(cmd, cwd=ctx.cwd, capture_output=True,
text=True, timeout=ctx.timeout_secs,
stdin=subprocess.DEVNULL)
return parse_codex_output(p)
實際的參數名稱會隨版本變,這裡的重點是形狀:每個實作都拿到同一個 RuntimeContext,各自負責翻譯。上層完全不知道 danger-full-access 這個字串存在。

右邊那個掛著警告三角的,是沒有對應能力的後端。它必須在每次執行時吵一次,不能安靜地降級。
這是我覺得最重要的一段。
抽象最危險的地方,是某一家沒有對應功能,而你為了讓介面看起來乾淨,默默忽略它。
我遇過的實例:其中一個 CLI 沒有任何沙箱等級的概念,只有「全開」一種模式。當時的處理方式是「就當作 full_access」,介面很乾淨,程式也跑得動。
問題是,上層以為自己要求了 read_only,實際上得到的是完全不受限的執行環境。這個落差不會有任何地方告訴你。
現在的做法是每次都吵:
class NoSandboxRuntime:
name = "some-cli"
def execute(self, ctx: RuntimeContext) -> RuntimeResult:
if ctx.sandbox != "full_access":
log.warning(
"[%s] 這個 runtime 沒有沙箱控制,要求的 %s 無法實施,"
"實際會以完整權限執行",
self.name, ctx.sandbox,
)
audit({"event": "sandbox_not_enforced",
"runtime": self.name, "requested": ctx.sandbox})
...
每次 spawn 都印一次。很吵,這是刻意的。降級一定要有聲音,不然它會變成你系統裡一個沒人記得的洞。
如果那個能力對你來說是硬需求,更好的做法是直接拒絕執行,而不是降級。我的原則是:安全相關的能力缺失,拒絕;效能相關的能力缺失,降級加警告。
有了統一介面之後,選擇邏輯可以獨立出來:
class RuntimeRegistry:
def __init__(self, runtimes: list[AgentRuntime]):
self.runtimes = runtimes
def pick(self, preferred: str | None = None) -> AgentRuntime:
available = [r for r in self.runtimes if r.is_available()]
if not available:
raise RuntimeError("沒有任何可用的 runtime")
if preferred:
for r in available:
if r.name == preferred:
return r
log.warning("偏好的 runtime %s 不可用,改用 %s",
preferred, available[0].name)
return available[0]
is_available() 的實作要小心。最簡單的版本是檢查 binary 存在,但 binary 存在不代表能用(可能沒登入、額度用完、設定壞掉)。我後來加了一層帶快取的健康檢查,因為每次都真的跑一次驗證太慢。
還有一個實務上的坑:設定裡的模型名稱和 runtime 要對得上。使用者很容易在設定檔裡寫 provider = "gemini" 配 model = "claude-opus-..."。這種組合會產生很難懂的錯誤訊息,所以我加了一個開機時的檢查,發現不匹配就警告。
def check_model_matches_provider(provider: str, model: str) -> None:
prefixes = {"claude": ("claude-",), "gemini": ("gemini-",),
"openai": ("gpt-", "o1", "o3")}
expected = prefixes.get(provider)
if expected and not model.startswith(expected):
log.warning("provider=%s 配 model=%s 看起來不對,請確認設定",
provider, model)
這種檢查只警告不阻擋。使用者可能在用相容端點,我不該假設自己比較懂他的環境。
還有一個容易忽略的細節:不同 CLI 讀不同的專案指示檔。Claude Code 讀 CLAUDE.md,其他家各有各的檔名。
如果你的 agent 目錄只有 CLAUDE.md,那麼換一個 runtime 之後,它會在完全沒有專案上下文的情況下工作。行為會突然變差,而你會以為是模型比較笨。
我的處理方式是建立 agent 目錄時就把幾份都產出來,內容從同一份來源產生。維護一份,輸出多份。
抽象會鎖住你的能力上限。 統一介面只能表達所有後端的交集。某一家有很好用的獨門功能,你的抽象層表達不出來,除非開一個逃生口,而逃生口一開,抽象就漏了。
我的折衷是允許 RuntimeContext 帶一個 extra: dict,各實作自己解讀,不認識的鍵忽略。這很醜,但比起為了純淨而放棄一個好用功能,我選醜的。
測試量會乘以後端數。 一份邏輯,四個實作,每個都要驗。而且有些行為差異只有在真的跑起來才看得到,靜態檢查抓不到。我現在的做法是核心路徑對每個 runtime 都跑一次冒煙測試,其餘只測預設那家。
版本漂移。 四家 CLI 各自更新,參數會變。我被改過一次參數名稱,那個 runtime 沉默失效了幾天,因為錯誤訊息被吞在 stderr 裡沒人看。這件事的教訓後來寫進了 Day 28。
明天講沙箱的另一半:我在上面反覆提到 read_only 這些等級,但它們具體要擋住什麼?
這是第二幕的最後一篇,也是進入安全話題前的暖身。