iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

Day 11|一個介面掛四種 CLI

https://ithelp.ithome.com.tw/upload/images/20260827/20183634wmk6V9M8vM.png

會做這件事,起因是一個很無聊的下午。

我的所有 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 這個字串存在。

https://ithelp.ithome.com.tw/upload/images/20260827/201836341noLp7UZIR.png

右邊那個掛著警告三角的,是沒有對應能力的後端。它必須在每次執行時吵一次,不能安靜地降級。

沒有等價功能的時候,要吵

這是我覺得最重要的一段。

抽象最危險的地方,是某一家沒有對應功能,而你為了讓介面看起來乾淨,默默忽略它。

我遇過的實例:其中一個 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 這些等級,但它們具體要擋住什麼?

這是第二幕的最後一篇,也是進入安全話題前的暖身。


上一篇
200 個工具的隱藏成本
下一篇
為什麼向量檢索解決不了記憶問題
系列文
Claude Code 下班之後:30 天把 CLI 工具養成會自己交差的 AI 員工12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言