zeroclaw 是一個 Rust-first 的自主 Agent 執行時(autonomous agent runtime),設計目標是高效能、高穩定性、高擴充性與高安全性。它的架構以 trait 為核心,包含 Provider(LLM)、Channel(Telegram/Discord/Slack)、Tool、Memory、Security、Peripheral(STM32/RPi GPIO)等模組。這篇聚焦在它如何設計 LLM Provider 的抽象層,統一了 OpenAI、Anthropic、Gemini 等各種不同 API 的介面。

一個 Trait,一個必填方法

整個抽象的核心是 Provider trait,但它聰明的地方在於:只有一個方法是必須實作的

#[async_trait]
pub trait Provider: Send + Sync {
    async fn chat_with_system(
        &self,
        system_prompt: Option<&str>,
        message: &str,
        model: &str,
        temperature: f64,
    ) -> anyhow::Result<String>;

    // 其他方法都有預設實作,最終都會呼叫 chat_with_system
    async fn simple_chat(&self, message: &str, model: &str, temperature: f64) -> anyhow::Result<String> { ... }
    async fn chat_with_history(&self, messages: &[ChatMessage], ...) -> anyhow::Result<String> { ... }
    async fn chat(&self, request: ChatRequest<'_>, ...) -> anyhow::Result<ChatResponse> { ... }
    // ...
}

這意味著加一個新 provider 最少只需要 ~30 行程式碼。其他的 simple_chatchat_with_history 這些便利方法,都有預設實作會幫你委派過去。

這裡用了 async_trait crate,因為 trait 裡的 async fn 需要特別處理才能支援 dyn Provider


統一的訊息型別

各家 LLM API 的訊息格式其實大同小異,zeroclaw 定義了一套統一的型別:

pub struct ChatMessage {
    pub role: String,   // "system", "user", "assistant", "tool"
    pub content: String,
}

pub struct ChatResponse {
    pub text: Option<String>,
    pub tool_calls: Vec<ToolCall>,
}

pub struct ToolCall {
    pub id: String,
    pub name: String,
    pub arguments: String,  // JSON 字串
}

ChatMessage 還提供了工廠方法:ChatMessage::user("hello")ChatMessage::assistant("hi") 這樣,寫起來很順。

多輪對話(包含 tool call 結果)則用一個 enum 來表達:

pub enum ConversationMessage {
    Chat(ChatMessage),
    AssistantToolCalls { text: Option<String>, tool_calls: Vec<ToolCall> },
    ToolResults(Vec<ToolResultMessage>),
}

Decorator Pattern:功能疊加

Provider trait 自己也可以被包起來再實作 Provider,這讓功能可以自由組合。

ReliableProvider:自動重試 + API key 輪換

pub struct ReliableProvider {
    providers: Vec<(String, Box<dyn Provider>)>,
    max_retries: u32,
    base_backoff_ms: u64,
    api_keys: Vec<String>,
    key_index: AtomicUsize,           // 用 AtomicUsize 做 round-robin
    model_fallbacks: HashMap<String, Vec<String>>,
}

遇到 429 Rate Limit 就輪換 API key、指數退避重試;遇到 4xx 非 429 的錯誤就直接放棄;甚至還能解析 Retry-After header 來決定等多久。

RouterProvider:按 model 名稱路由

// 用 "hint:" 前綴來指定路由
fn resolve(&self, model: &str) -> (usize, String) {
    if let Some(hint) = model.strip_prefix("hint:") {
        if let Some((idx, resolved_model)) = self.routes.get(hint) {
            return (*idx, resolved_model.clone());
        }
    }
    (self.default_index, model.to_string())
}

傳入 "hint:reasoning" 就會路由到你設定好對應「reasoning 任務」的 provider + model 組合。很適合多 provider 環境下的靈活調度。


Tool Calling 的三種模式

各家 API 對 function calling 的格式差異很大,zeroclaw 用一個 enum 來表達:

pub enum ToolsPayload {
    Gemini { function_declarations: Vec<serde_json::Value> },
    Anthropic { tools: Vec<serde_json::Value> },
    OpenAI { tools: Vec<serde_json::Value> },
    PromptGuided { instructions: String },  // 給不支援原生 tool call 的 provider 用
}

實際上支援三種策略:

  1. 原生 Tool Calling:Anthropic、OpenAI、Gemini 都有各自的格式,provider 各自轉換。
  2. Prompt 注入(PromptGuided):把 tool 的說明文字注入到 system prompt,讓 LLM 用 <tool_call>JSON</tool_call> 這樣的 XML tag 來回應。
  3. 自動降級OpenAiCompatibleProvider 會先嘗試原生格式,如果收到 "unknown parameter: tools" 這類錯誤,就自動切換到 prompt 注入模式。這樣就算某個 OpenAI 相容的 endpoint 不支援 tool call,也能無縫 fallback。

各家 Provider 的奇特之處

實作各家 provider 的過程中,可以看到各家 API 設計的一些有趣差異:

Anthropic:支援 Prompt Caching,會自動對大型 system prompt(>3072 bytes)或長對話(>4 則)加上 cache_control: ephemeral,降低費用。

Gemini:是唯一把 API key 放在 query string(?key=xxx)而不是 header 裡的。而且 OAuth 使用者(Gemini CLI)必須打完全不同的 endpoint(cloudcode-pa.googleapis.com)。

OpenAiCompatibleProvider:一個實作打遍幾乎所有 OpenAI 相容的 API——Venice、Cloudflare、Groq、Mistral、xAI、DeepSeek、Perplexity……超過 15 個 provider 都是它的變體。靠著 AuthStyle enum(Bearer、XApiKey、Custom)和可設定的 base URL 來區分。


工廠函式

最後,使用者不需要直接 new 這些 struct,而是透過三個工廠函式:

// 簡單建立單一 provider
create_provider(name, api_key)

// 加上重試 + fallback 功能
create_resilient_provider(..., reliability)

// 加上多 provider 路由
create_routed_provider(..., model_routes)

API key 解析的優先順序是:明確傳入的參數 → provider 專屬的環境變數(如 ANTHROPIC_API_KEY)→ 通用的 ZEROCLAW_API_KEYAPI_KEY


小結

zeroclaw 的設計讓我印象深刻的幾個點:

  • 最小介面原則:只強制一個方法,其他都是預設實作,加新 provider 成本很低。
  • Decorator 而非繼承:retry、routing 都是包裝器,跟具體 provider 完全解耦。
  • 漸進式降級:tool calling 從原生到 prompt 注入的自動 fallback,使用者完全感知不到。
  • 務實:面對各家 API 的奇葩設計(Gemini 的 query param auth、各種 reasoning_content 欄位)直接在具體實作裡處理,不讓這些噪音污染核心抽象。