2025 年的 AI 模型市场百花齐放。但每个提供商都有自己的 API 格式、认证方式、流式协议。BoxAgnts 的设计目标是:用户切换模型只需改一个参数,所有内部逻辑保持不变。
本文从四个层面拆解这套抽象:
-
统一接口 :
LlmProvidertrait 如何定义"一个模型提供商" -
三大 API 格式对比:Anthropic、OpenAI、Google Gemini 的格式差异
-
格式转换:如何在三种截然不同的消息格式之间互译
-
工程实践:Think 配置、错误处理、ProviderQuirks、API Key 管理
统一接口:LlmProvider trait
一切从接口定义开始:
rust
// 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; // 能力声明
}
输入和输出都是与提供商无关的统一类型:
rust
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+ 模型的统一入口
rust
// 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,
}
}
三种实现策略:
| 类型 | 代表 | 转换策略 | 数量 |
|------|------|----------|------|
| 原生 Anthropic | claude-sonnet-4-5 | 几乎零转换(内部格式即 Anthropic 格式) | 1 |
| 原生 OpenAI | gpt-4o, o3 | ProviderRequest → Chat Completions | 1 |
| 原生 Google | gemini-2.5-flash | ProviderRequest → generateContent | 1 |
| OpenAI 兼容 | deepseek, groq, ollama 等 | 与 OpenAI 相同逻辑,只换 URL | 30+ |
| 其他原生 | github-copilot, cohere | 独立的格式转换 | 3+ |
三大 API 格式的差异
Anthropic、OpenAI、Google Gemini------三种 API 在消息格式上差异巨大。理解这些差异才能理解转换层的价值。
3.1 System Prompt
| 特性 | Anthropic | OpenAI | Google Gemini |
|------|-----------|--------|---------------|
| 位置 | 顶层 "system" 字段 | messages0,role:"system" | 顶层 "systemInstruction" 字段 |
| 类型 | string 或 ContentBlock 数组 | 仅 string | 仅 content parts 数组 |
javascript
// 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 定义
| 特性 | Anthropic | OpenAI | Google |
|------|-----------|--------|--------|
| 字段 | "tools": [{name, description, input_schema}] | "tools": [{type:"function", function:{...}}] | "tools": [{functionDeclarations: [{name, description, parameters}]}] |
| 包装层数 | 0 | 1 | 1,且用不同的嵌套名 |
3.3 Tool Call 响应
javascript
// 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 格式
javascript
// 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 角色命名
| Anthropic | OpenAI | Google |
|-----------|--------|--------|
| user | user | user |
| assistant | assistant | model |
Google 用 model 而不是 assistant------这是最容易被忽略但最容易出错的差异。
转换层实现:以 OpenAI Provider 为例
OpenAiProvider 是转换层最完整的例子:
rust
// 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 都不同时的处理方式:
rust
// 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 Gemini | OpenAI |
|--------|--------------|--------|
| 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 base64 | image_url 或 content parts |
Thinking 配置:深度推理的模型差异
ThinkingConfig 是归一化的深度思考配置------但不同提供商的处理方式完全不同:
rust
// 归一化的配置
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 声明了每个提供商的能力:
rust
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 用来处理这些:
rust
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 枚举:
rust
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,
}
错误处理:从提供商差异到统一语义
每个提供商的错误格式也不同:
rust
// 错误类型统一化
pub enum ProviderError {
Auth { ... }, // 认证失败
RateLimited { ... }, // 速率限制
ContextOverflow { ... }, // 上下文超窗口(通过 ProviderQuirks 匹配)
InvalidRequest { ... }, // 请求参数错误
ServerError { ... }, // 服务端错误
StreamError { ... }, // 流中断
Other { ... }, // 未知错误
}
在查询循环中,特定错误触发特定恢复策略:
java
RateLimited / Overloaded → 切换到 fallback_model
ContextOverflow → 触发 auto_compact
StreamError (stall) → 重试(最多 2 次,45s 超时)
Auth → 不可恢复,返回错误
API Key 的分级管理
BoxAgnts 为每个提供商定义了环境变量名映射:
rust
// 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"这个本质问题:
scss
┌──────────────────────────────────────────────┐
│ boxagnts-query (Agent 推理循环) │
│ 只使用 ProviderRequest / ProviderResponse │
└────────────────────┬─────────────────────────┘
│
┌────────────────────▼─────────────────────────┐
│ LlmProvider trait │
│ + ProviderRegistry (40+ providers) │
├──────────┬──────────┬──────────┬─────────────┤
│Anthropic │ OpenAI │ Google │ OpenAiCompat │
│Provider │ Provider │ Provider │ (30+ 厂商) │
│(几乎零 │(完整 │(独立 │(共享 OpenAI │
│ 转换) │ 格式转换) │ 格式转换) │ 转换+Quirks) │
└──────────┴──────────┴──────────┴─────────────┘
三个关键能力:
-
用户自由 :切换模型只改
--model参数 -
代码不受影响 :
run_query_loop()完全不知道底层是谁 -
扩展成本极低:新增 OpenAI 兼容提供商约 3 行代码
这不是一个简单的"适配器模式"------它是一个经过 40+ 个实际 API 验证的生产级抽象。
相关资源
-
BoxAgnts:github.com/guyoung/box...
-
Anthropic API:docs.anthropic.com/en/api/mess...
-
OpenAI API:platform.openai.com/docs/api-re...
-
Google Gemini API:ai.google.dev/gemini-api/...