BoxAgnts介绍(7)——OpenAI-API与Anthropic-API

19 阅读8分钟

OpenAI 和 Anthropic 的消息格式完全不同;Google Gemini 又是一套新的。加上 30+ 个 OpenAI 兼容提供商,每个都有自己的小脾气。BoxAgnts 的做法是:用一个 trait 归一化所有差异,用 ProviderQuirks 包容所有例外。


引言

2025 年的 AI 模型市场百花齐放。但每个提供商都有自己的 API 格式、认证方式、流式协议。BoxAgnts 的设计目标是:用户切换模型只需改一个参数,所有内部逻辑保持不变

本文从四个层面拆解这套抽象:

  1. 统一接口LlmProvider trait 如何定义"一个模型提供商"
  2. 三大 API 格式对比:Anthropic、OpenAI、Google Gemini 的格式差异
  3. 格式转换:如何在三种截然不同的消息格式之间互译
  4. 工程实践:Think 配置、错误处理、ProviderQuirks、API Key 管理

统一接口:LlmProvider trait

一切从接口定义开始:

// boxagnts-api/src/provider.rs
#[async_trait]
pub trait LlmProvider: Send + Sync {
    fn id(&self) -> &ProviderId;                              // 唯一标识
    fn name(&self) -> &str;                                   // 人类可读名称

    async fn create_message(                                  // 非流式请求
        &self,
        request: ProviderRequest,
    ) -> Result<ProviderResponse, ProviderError>;

    async fn create_message_stream(                           // 流式请求
        &self,
        request: ProviderRequest,
    ) -> Result<
        Pin<Box<dyn Stream<Item = Result<StreamEvent, ProviderError>> + Send>>,
        ProviderError,
    >;

    async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>;  // 模型列表
    async fn check_connectivity(&self) -> Result<ProviderStatus, ProviderError>; // 健康检查
    fn capabilities(&self) -> ProviderCapabilities;           // 能力声明
}

输入和输出都是与提供商无关的统一类型:

pub struct ProviderRequest {
    pub model: String,
    pub messages: Vec<Message>,          // 统一对话格式
    pub system_prompt: Option<SystemPrompt>,
    pub tools: Vec<ToolDefinition>,      // 统一工具定义
    pub max_tokens: u32,
    pub temperature: Option<f64>,
    pub thinking: Option<ThinkingConfig>, // 深度思考配置
    pub provider_options: Value,          // 提供商特有参数
}

pub struct ProviderResponse {
    pub id: String,
    pub content: Vec<ContentBlock>,      // 统一内容块
    pub stop_reason: StopReason,         // 统一停止原因
    pub usage: UsageInfo,                // Token 用量
    pub model: String,
}

归一化层的核心价值:无论底层是 Claude、GPT 还是 Gemini,上层代码只看到 ProviderRequest 和 ProviderResponse


ProviderRegistry:40+ 模型的统一入口

// boxagnts-api/src/registry.rs
pub struct ProviderRegistry {
    providers: HashMap<ProviderId, Arc<dyn LlmProvider>>,
    default_provider_id: ProviderId,
}

fn provider_from_key(provider_id: &str, key: String) -> Option<Arc<dyn LlmProvider>> {
    match provider_id {
        // 原生实现——各有各的 API 格式
        "anthropic" => Some(Arc::new(AnthropicProvider::from_config(...))),
        "openai"    => Some(Arc::new(OpenAiProvider::new(key))),
        "google"    => Some(Arc::new(GoogleProvider::new(key))),
        "github-copilot" => Some(Arc::new(CopilotProvider::new(key))),
        "cohere"    => Some(Arc::new(CohereProvider::new(key))),

        // OpenAI 兼容提供商——共享同一套转换逻辑,只换 base_url
        "deepseek", "groq", "ollama", "mistral", "xai",
        "perplexity", "openrouter", "siliconflow", "moonshot",
        "zhipu", "stepfun", "fireworks", "llamacpp",
        "sambanova", "huggingface", "nvidia", "cerebras",
        // ... 总计 30+ 个 OpenAI 兼容提供商
        _ => None,
    }
}

三种实现策略:

类型代表转换策略数量
原生 Anthropicclaude-sonnet-4-5几乎零转换(内部格式即 Anthropic 格式)1
原生 OpenAIgpt-4o, o3ProviderRequest → Chat Completions1
原生 Googlegemini-2.5-flashProviderRequest → generateContent1
OpenAI 兼容deepseek, groq, ollama 等与 OpenAI 相同逻辑,只换 URL30+
其他原生github-copilot, cohere独立的格式转换3+

三大 API 格式的差异

Anthropic、OpenAI、Google Gemini——三种 API 在消息格式上差异巨大。理解这些差异才能理解转换层的价值。

3.1 System Prompt

特性AnthropicOpenAIGoogle Gemini
位置顶层 "system" 字段messages[0],role:"system"顶层 "systemInstruction" 字段
类型string 或 ContentBlock 数组仅 string仅 content parts 数组
// Anthropic — 顶层独立字段
{"model": "claude-sonnet-4-5", "system": "You are helpful.", "messages": [...]}

// OpenAI — 嵌入在 messages 数组里
{"model": "gpt-4o", "messages": [{"role":"system","content":"You are helpful."}, ...]}

// Google — 使用 systemInstruction 字段,结构不同于 messages
{
  "systemInstruction": {"parts": [{"text": "You are helpful."}]},
  "contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
}

3.2 Tool 定义

特性AnthropicOpenAIGoogle
字段"tools": [{name, description, input_schema}]"tools": [{type:"function", function:{...}}]"tools": [{functionDeclarations: [{name, description, parameters}]}]
包装层数011,且用不同的嵌套名

3.3 Tool Call 响应

// Anthropic — content 数组中的 native block
{"content": [{"type":"tool_use", "id":"toolu_01A", "name":"read", "input": {...}}]}

// OpenAI — 独立的 tool_calls 数组,arguments 是 JSON string
{"tool_calls": [{"id":"call_abc", "function": {"name":"read", "arguments": "{"path":"..."}"}}]}

// Google — functionCall 嵌入在 parts 中,args 是 JSON object
{"candidates": [{"content": {"parts": [{"functionCall": {"name":"read", "args": {...}}}]}}]}

3.4 Tool Result 格式

// Anthropic — tool_result 是 user 消息 content 数组中的一个 block
{"role":"user", "content": [{"type":"tool_result", "tool_use_id":"toolu_01A", "content":"..."}]}

// OpenAI — 需要单独的 role: "tool" 消息
{"role":"tool", "tool_call_id":"call_abc", "content":"..."}

// Google — functionResponse 嵌入在 user content 的 parts 中
{"role":"user", "parts": [{"functionResponse": {"name":"read", "response": {...}}}]}

3.5 角色命名

AnthropicOpenAIGoogle
useruseruser
assistantassistantmodel

Google 用 model 而不是 assistant——这是最容易被忽略但最容易出错的差异。


转换层实现:以 OpenAI Provider 为例

OpenAiProvider 是转换层最完整的例子:

// boxagnts-api/src/providers/openai.rs
impl OpenAiProvider {
    fn to_openai_messages(
        messages: &[Message],
        system_prompt: Option<&SystemPrompt>,
    ) -> Vec<Value> {
        let mut result: Vec<Value> = Vec::new();

        // 第 1 步:system prompt → role: "system" 消息
        if let Some(sys) = system_prompt {
            result.push(json!({"role": "system", "content": sys_text}));
        }

        for msg in messages {
            match msg.role {
                Role::User => {
                    // user 消息中可能混合 text 和 tool_result blocks
                    // tool_result 需要拆分为独立的 role: "tool" 消息
                    Self::append_user_messages(&mut result, &msg.content);
                }
                Role::Assistant => {
                    let (text, tool_calls) = Self::assistant_content_to_openai(&msg.content);
                    result.push(json!({
                        "role": "assistant",
                        "content": text,
                        "tool_calls": tool_calls
                    }));
                }
            }
        }
        result
    }

    fn to_openai_tools(tools: &[ToolDefinition]) -> Vec<Value> {
        tools.iter().map(|td| {
            json!({
                "type": "function",
                "function": {
                    "name": td.name,
                    "description": td.description,
                    "parameters": td.input_schema
                }
            })
        }).collect()
    }
}

最复杂的部分是 tool_use_id 的 sanitize——Anthropic 的 tool ID(如 toolu_01Bx...)可能包含 OpenAI 不接受的字符。


Google Gemini Provider:第三种格式的完整适配

GoogleProvider 展示了当 API 格式与 Anthropic 和 OpenAI 都不同时的处理方式:

// boxagnts-api/src/providers/google.rs
// URL 模式完全不同于 OpenAI 的 /v1/chat/completions
fn generate_url(&self, model: &str) -> String {
    format!(
        "{}/v1beta/models/{}:generateContent?key={}",
        self.base_url, model, self.api_key  // API Key 在 URL 查询参数中!
    )
}

与 OpenAI 的关键差异:

差异点Google GeminiOpenAI
API Key 位置URL 查询参数 ?key=HTTP Header Authorization: Bearer
端点格式/v1beta/models/{model}:generateContent/v1/chat/completions
流式端点/v1beta/models/{model}:streamGenerateContent?alt=sse/v1/chat/completions + stream:true
消息角色user / model(不是 assistant)user / assistant
Tool 结果functionResponse in parts独立 role: tool 消息
图片输入inlineData base64image_url 或 content parts

Thinking 配置:深度推理的模型差异

ThinkingConfig 是归一化的深度思考配置——但不同提供商的处理方式完全不同:

// 归一化的配置
pub struct ThinkingConfig {
    pub budget_tokens: u32,   // 思考 token 预算
}

// 在构建 ProviderRequest 时,根据 provider capabilities 决定是否传递
let provider_request = ProviderRequest {
    // ...
    thinking: if caps.thinking {
        effective_thinking_budget
            .map(|b| ThinkingConfig::enabled(b))
    } else {
        None  // 这个提供商不支持 thinking,不传
    },
};
提供商Thinking 支持传递方式
Anthropic(Claude 3.5+)"thinking": {"type": "enabled", "budget_tokens": N}
Google(Gemini 2.5+)"thinkingConfig": {"thinkingBudget": N}
OpenAI(o1/o3 系列)部分通过 reasoning_effort 参数
其他 OpenAI 兼容大部分不支持不传递

在请求构建阶段,ProviderCapabilities 声明了每个提供商的能力:

pub struct ProviderCapabilities {
    pub thinking: bool,              // 是否支持深度思考
    pub prompt_caching: bool,        // 是否支持 prompt 缓存
    pub image_input: bool,           // 是否支持图片输入
    pub native_tool_use: bool,       // 是否有原生 tool calling
    pub supports_streaming: bool,    // 是否支持流式响应
    // ...
}

ProviderQuirks:每个提供商的"小脾气"

各 OpenAI 兼容提供商的 API 大致兼容,但都有细微差异。ProviderQuirks 用来处理这些:

pub struct ProviderQuirks {
    /// 上下文溢出时的特定错误消息模式
    pub overflow_patterns: Vec<String>,
    /// 本地服务无需 API Key(如 Ollama、LM Studio)
    pub no_api_key_required: bool,
    /// 流式响应中是否包含 usage 信息
    pub include_usage_in_stream: bool,
    /// DeepSeek 等提供商需要 reasoning_content 字段
    pub reasoning_field: Option<String>,
}

例如 DeepSeek 在流式响应中返回的 reasoning content 字段名与 OpenAI 不同——通过 reasoning_field 适配。Ollama 的上下文溢出错误消息是 "exceeds the available context size",而 LM Studio 的是 "greater than the context length"——通过 overflow_patterns 适配。


流式处理的统一

流式响应在三种 API 中也完全不同:

特性Anthropic (SSE)OpenAI (SSE)Google (SSE)
事件粒度高粒度:6 种事件类型(start/delta/stop ×2)低粒度:每个 chunk 是一个完整 delta中等:按 chunk 推送,但结构扁平
Tool call 增量分块发送 input_json_delta一次性发送完整 arguments 字符串一次性发送完整 functionCall
终止信号message_stop 事件data: [DONE] 标记流自然结束
是否需要按 index 重组是(多个 tool_use 时按 index 分块)

三种格式都被归一化到同样的 StreamEvent 枚举:

pub enum StreamEvent {
    MessageStart { id, model, usage },
    ContentBlockStart { index, content_block },
    TextDelta { text },
    ThinkingDelta { thinking },
    InputJsonDelta { index, partial_json },
    ContentBlockStop { index },
    MessageDelta { stop_reason, usage },
    MessageStop,
}

错误处理:从提供商差异到统一语义

每个提供商的错误格式也不同:

// 错误类型统一化
pub enum ProviderError {
    Auth { ... },             // 认证失败
    RateLimited { ... },      // 速率限制
    ContextOverflow { ... },  // 上下文超窗口(通过 ProviderQuirks 匹配)
    InvalidRequest { ... },   // 请求参数错误
    ServerError { ... },      // 服务端错误
    StreamError { ... },      // 流中断
    Other { ... },            // 未知错误
}

在查询循环中,特定错误触发特定恢复策略:

RateLimited / Overloaded → 切换到 fallback_model
ContextOverflow → 触发 auto_compact
StreamError (stall) → 重试(最多 2 次,45s 超时)
Auth → 不可恢复,返回错误

API Key 的分级管理

BoxAgnts 为每个提供商定义了环境变量名映射:

// boxagnts-workspace/src/config.rs
pub fn api_key_env_vars_for_provider(provider_id: &str) -> &'static [&'static str] {
    match provider_id {
        "anthropic" => &["ANTHROPIC_API_KEY"],
        "openai" => &["OPENAI_API_KEY"],
        "google" => &["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
        "deepseek" => &["DEEPSEEK_API_KEY"],
        "mistral" => &["MISTRAL_API_KEY"],
        "xai" => &["XAI_API_KEY"],
        "zhipu" => &["ZHIPU_API_KEY"],
        // ... 40+ 个提供商的环境变量
    }
}

三级优先级:环境变量 > 用户配置 JSON > 无默认值。这种设计支持多租户、CI/CD、本地开发等不同场景。


小结

BoxAgnts 的模型抽象层解决了"一套代码适配所有 API"这个本质问题:

┌──────────────────────────────────────────────┐
│  boxagnts-query (Agent 推理循环)              │
│  只使用 ProviderRequest / ProviderResponse   │
└────────────────────┬─────────────────────────┘
                     │
┌────────────────────▼─────────────────────────┐
│  LlmProvider trait                            │
│  + ProviderRegistry (40+ providers)           │
├──────────┬──────────┬──────────┬─────────────┤
│Anthropic │ OpenAI   │ Google   │ OpenAiCompat │
│Provider  │ Provider │ Provider │ (30+ 厂商)   │
│(几乎零    │(完整     │(独立     │(共享 OpenAI  │
│ 转换)    │ 格式转换) │ 格式转换) │ 转换+Quirks)  │
└──────────┴──────────┴──────────┴─────────────┘

三个关键能力:

  1. 用户自由:切换模型只改 --model 参数
  2. 代码不受影响run_query_loop() 完全不知道底层是谁
  3. 扩展成本极低:新增 OpenAI 兼容提供商约 3 行代码

这不是一个简单的"适配器模式"——它是一个经过 40+ 个实际 API 验证的生产级抽象。

相关资源