9.3 LLM 提供商(OpenAI / DeepSeek)

概述

RAFT 的 LLM 提供商层采用分层架构:通用 `ChatClient` 负责 HTTP 传输和 SSE 流解析,提供商标识的包装类型(`DeepSeekChatClient`、`OpenAiChatClient`)添加特定于提供商的逻辑(如用量格式、思考模式)。这种设计使得添加新的 LLM 提供商只需实现一个薄包装,无需重复 HTTP/SSE 传输逻辑。

graph TB
    subgraph "IChatClient trait"
        A[DeepSeekChatClient]
        B[OpenAiChatClient]
    end

    subgraph "通用传输层"
        C[ChatClient<br/>HTTP POST + SSE 解析]
    end

    subgraph "配置"
        D[ChatClientOptions<br/>api_base, api_key, model, ...]
    end

    A -->|组合| C
    B -->|组合| C
    C -->|依赖| D
    D -->|提供默认值| A
    D -->|提供默认值| B

    C -->|POST /chat/completions| E[LLM API]
    E -->|SSE| C

ChatClient — 通用传输引擎

`ChatClient` 是所有 LLM 提供商的共享传输基础。它负责:

1. 构建符合 OpenAI 兼容格式的请求体 2. 发送 HTTP POST 请求 3. 解析 SSE (Server-Sent Events) 流

// crates/client/src/chat_client.rs

pub struct ChatClient {
    http: reqwest::Client,
    options: ChatClientOptions,
}

impl ChatClient {
    pub fn new(options: ChatClientOptions) -> Result<Self> {
        let timeout = Duration::from_secs(options.timeout_secs.unwrap_or(60));
        let http = reqwest::Client::builder()
            .timeout(timeout)
            .build()
            .map_err(|e| AgentError::ConfigError(format!("...")))?;

        Ok(Self { http, options })
    }

    /// 核心流式调用:向 {api_base}/chat/completions 发送 POST 请求,解析 SSE
    pub async fn chat_stream(
        &self,
        messages: &[ChatMessage],
        run_options: &ChatClientRunOptions,
        usage_format: UsageFormat,  // ← 提供商特定的用量解析方式
    ) -> Result<BoxStream<'static, Result<AgentResponseUpdate>>> {
        let url = format!("{}/chat/completions", self.options.api_base.trim_end_matches('/'));
        let body = self.build_request_body(messages, run_options);

        let response = self.http
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.options.api_key))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await?;

        let byte_stream = response.bytes_stream();
        let sse = SseStream::new(byte_stream, usage_format);
        Ok(Box::pin(sse))
    }
}

build_request_body — 请求体构造

fn build_request_body(&self, messages: &[ChatMessage], run_options: &ChatClientRunOptions) -> serde_json::Value {
    let msgs: Vec<serde_json::Value> = messages.iter().map(|m| {
        let mut obj = serde_json::json!({
            "role": /* System/User/Assistant/Tool */,
            "content": m.content,
        });
        // 序列化 tool_calls(带 id + function.name + function.arguments)
        // 序列化 tool_call_id(tool 角色消息)
        obj
    }).collect();

    let mut body = serde_json::json!({
        "model": self.options.model,
        "messages": msgs,
        "stream": true,
        "stream_options": { "include_usage": true },
    });

    // 覆盖参数:run_options > client defaults
    // 合并 extra_body
    // 添加 tools 定义
    // 设置 parallel_tool_calls

    body
}

ChatClientOptions — 客户端配置

// crates/client/src/options.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatClientOptions {
    pub api_base: String,
    #[serde(skip)]
    pub api_key: String,
    pub model: String,
    pub max_tokens: Option<u32>,
    pub temperature: Option<f32>,
    pub top_p: Option<f32>,
    pub stop: Option<Vec<String>>,
    #[serde(skip)]
    pub extra_headers: HashMap<String, String>,
    pub timeout_secs: Option<u64>,
    #[serde(skip)]
    pub model_metadata: Option<ModelMetadata>,
}

预置工厂方法

impl ChatClientOptions {
    /// OpenAI 风格:api_base = "https://api.openai.com/v1"
    pub fn openai(model: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            api_base: "https://api.openai.com/v1".into(),
            api_key: api_key.into(),
            model: model.into(),
            ..Default::default()
        }
    }

    /// DeepSeek 风格:api_base = "https://api.deepseek.com"(无 /v1 前缀)
    pub fn deepseek(model: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            api_base: "https://api.deepseek.com".into(),
            api_key: api_key.into(),
            model: model.into(),
            ..Default::default()
        }
    }
}

注意 `api_base` 的差异: - **OpenAI**:`https://api.openai.com/v1`(带 `/v1` 前缀) - **DeepSeek**:`https://api.deepseek.com`(无额外前缀)

`chat_stream()` 内部统一拼接 `/chat/completions`: - OpenAI 请求 → `https://api.openai.com/v1/chat/completions` - DeepSeek 请求 → `https://api.deepseek.com/chat/completions`

DeepSeekChatClient

// crates/client/src/deepseek_client.rs

pub struct DeepSeekChatClient {
    inner: ChatClient,
}

impl DeepSeekChatClient {
    pub fn new(options: ChatClientOptions) -> Result<Self> {
        Ok(Self { inner: ChatClient::new(options)? })
    }

    /// 列出可用的 DeepSeek 模型
    pub async fn list_models(&self) -> Result<Vec<ModelListEntry>> {
        // GET {api_base}/models → { "object": "list", "data": [...] }
    }
}

#[async_trait]
impl IChatClient for DeepSeekChatClient {
    async fn run(
        &self,
        messages: &[ChatMessage],
        options: ChatClientRunOptions,
    ) -> Result<BoxStream<'static, Result<AgentResponseUpdate>>> {
        // 使用 DeepSeek 特定的数据格式
        self.inner.chat_stream(messages, &options, UsageFormat::DeepSeek).await
    }

    fn model_id(&self) -> &str {
        self.inner.model_id()
    }
}

DeepSeek 特有功能

| 特性 | 说明 | |------|------| | 思考模式 | 通过 `extra_body: {"thinking": {"type": "enabled"}}` 启用 | | 推理内容 | 在 SSE 增量中同时返回 `content` 和 `reasoning_content` | | 缓存 Token | 在用量数据顶层返回 `prompt_cache_hit_tokens` 和 `prompt_cache_miss_tokens` | | 基础 URL | `https://api.deepseek.com`(无 `/v1` 前缀) |

OpenAiChatClient

// crates/client/src/openai_client.rs

pub struct OpenAiChatClient {
    inner: ChatClient,
}

#[async_trait]
impl IChatClient for OpenAiChatClient {
    async fn run(
        &self,
        messages: &[ChatMessage],
        options: ChatClientRunOptions,
    ) -> Result<BoxStream<'static, Result<AgentResponseUpdate>>> {
        // 使用 OpenAI 特定的数据格式
        self.inner.chat_stream(messages, &options, UsageFormat::OpenAI).await
    }

    fn model_id(&self) -> &str {
        self.inner.model_id()
    }
}

OpenAI 特有功能

| 特性 | 说明 | |------|------| | 缓存 Token | 嵌套结构:`prompt_tokens_details.cached_tokens` | | 推理 Token | `completion_tokens_details.reasoning_tokens` | | 推理努力 | `reasoning_effort: "high" \| "max"` |

UsageFormat — 提供商标识的 Token 统计

不同 LLM 提供商在 SSE 流中返回的 Token 用量数据格式不同。RAF 通过 `UsageFormat` 枚举和提供商特定的反序列化结构体处理这种差异:

// crates/client/src/usage.rs

pub enum UsageFormat {
    /// OpenAI: prompt_tokens_details.cached_tokens, completion_tokens_details.reasoning_tokens
    OpenAI,
    /// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens 在顶层
    DeepSeek,
}

impl UsageFormat {
    pub fn parse(&self, raw: &serde_json::Value) -> Option<Usage> {
        match self {
            UsageFormat::OpenAI => {
                serde_json::from_value::<OpenAIUsage>(raw.clone())
                    .ok().map(|u| u.into_usage())
            }
            UsageFormat::DeepSeek => {
                serde_json::from_value::<DeepSeekUsage>(raw.clone())
                    .ok().map(|u| u.into_usage())
            }
        }
    }
}

OpenAI 用量格式

{
    "prompt_tokens": 1000,
    "completion_tokens": 500,
    "total_tokens": 1500,
    "prompt_tokens_details": {
        "cached_tokens": 800
    },
    "completion_tokens_details": {
        "reasoning_tokens": 120
    }
}

映射到 `Usage`: - `prompt_cache_hit_tokens` ← `prompt_tokens_details.cached_tokens` - `prompt_cache_miss_tokens` ← `None`(OpenAI 不报告未命中缓存) - `reasoning_tokens` ← `completion_tokens_details.reasoning_tokens`

DeepSeek 用量格式

{
    "prompt_tokens": 1000,
    "completion_tokens": 500,
    "total_tokens": 1500,
    "prompt_cache_hit_tokens": 700,
    "prompt_cache_miss_tokens": 300
}

映射到 `Usage`: - `prompt_cache_hit_tokens` ← 顶层字段直接映射 - `prompt_cache_miss_tokens` ← 顶层字段直接映射 - `reasoning_tokens` ← `completion_tokens_details.reasoning_tokens`(若存在)

缓存命中率计算

`Usage::cache_hit_ratio()` 根据提供商自动选择合适的计算公式:

// crates/core/src/types.rs

impl Usage {
    pub fn cache_hit_ratio(&self) -> f64 {
        let hit = self.prompt_cache_hit_tokens.unwrap_or(0) as f64;
        if hit == 0.0 { return 0.0; }

        if let Some(miss) = self.prompt_cache_miss_tokens {
            // DeepSeek: hit / (hit + miss)
            let total = hit + miss as f64;
            if total > 0.0 { return hit / total; }
        }

        // OpenAI: hit / prompt_tokens
        let prompt = self.prompt_tokens as f64;
        if prompt > 0.0 { hit / prompt } else { 0.0 }
    }
}

SSE 传输解析

SSE 流解析由 `SseStream` 负责:

// crates/client/src/transport.rs

pub struct SseStream<S> {
    inner: S,        // reqwest 字节流
    buffer: Vec<u8>, // 行缓冲
    pending: std::vec::IntoIter<AgentResponseUpdate>, // 待发送事件
    done: bool,
    usage_format: UsageFormat,
}

`SseStream` 实现了 `futures_core::Stream` trait:

1. 从内部字节流读取数据 2. 按换行符分割 3. 解析 `data:` 行 4. 反序列化 JSON 为 `SseChunk` 5. 通过 `map_chunk()` 转换为 `AgentResponseUpdate` 事件

fn map_chunk(sse: SseChunk, usage_format: UsageFormat) -> Vec<AgentResponseUpdate> {
    // 1. 响应元数据(首个携带 id/model 的 chunk)
    // → AgentResponseUpdate::ResponseMetadata

    // 2. 文本增量
    // → AgentResponseUpdate::TextDelta

    // 3. 推理内容(DeepSeek thinking)
    // → AgentResponseUpdate::ReasoningDelta

    // 4. 工具调用增量
    // → AgentResponseUpdate::ToolCallDelta { index, id, name, arguments_delta }

    // 5. 完成原因 + 用量
    // → AgentResponseUpdate::Finish { finish_reason, usage }

    // 6. 仅用量事件(无完成原因)
    // → AgentResponseUpdate::Usage { usage }
}

接入新的 LLM 提供商

添加新的 LLM 提供商只需要三个步骤:

步骤 1:创建提供商包装

// crates/client/src/anthropic_client.rs

pub struct AnthropicChatClient {
    inner: ChatClient,
}

impl AnthropicChatClient {
    pub fn new(options: ChatClientOptions) -> Result<Self> {
        Ok(Self { inner: ChatClient::new(options)? })
    }
}

#[async_trait]
impl IChatClient for AnthropicChatClient {
    async fn run(
        &self,
        messages: &[ChatMessage],
        options: ChatClientRunOptions,
    ) -> Result<BoxStream<'static, Result<AgentResponseUpdate>>> {
        self.inner.chat_stream(messages, &options, UsageFormat::Anthropic).await
    }

    fn model_id(&self) -> &str {
        self.inner.model_id()
    }
}

步骤 2:添加 UsageFormat 变体

// crates/client/src/usage.rs

pub enum UsageFormat {
    OpenAI,
    DeepSeek,
    Anthropic,  // ← 新增
}

impl UsageFormat {
    pub fn parse(&self, raw: &serde_json::Value) -> Option<Usage> {
        match self {
            // ... 现有分支 ...
            UsageFormat::Anthropic => {
                serde_json::from_value::<AnthropicUsage>(raw.clone())
                    .ok().map(|u| u.into_usage())
            }
        }
    }
}

// 添加 Anthropic 用量解析结构体
#[derive(Deserialize)]
struct AnthropicUsage {
    pub input_tokens: u32,
    pub output_tokens: u32,
    // ...
}

impl AnthropicUsage {
    fn into_usage(self) -> Usage {
        Usage {
            prompt_tokens: self.input_tokens,
            completion_tokens: self.output_tokens,
            total_tokens: self.input_tokens + self.output_tokens,
            // ...
        }
    }
}

步骤 3:添加工厂方法

impl ChatClientOptions {
    pub fn anthropic(model: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            api_base: "https://api.anthropic.com/v1".into(),
            api_key: api_key.into(),
            model: model.into(),
            ..Default::default()
        }
    }
}

使用示例

DeepSeek

use rust_agent_client::{DeepSeekChatClient, ChatClientOptions};

let options = ChatClientOptions::deepseek(
    "deepseek-chat",
    std::env::var("DEEPSEEK_API_KEY").unwrap(),
);

// 启用思考模式
let run_options = ChatClientRunOptions::default()
    .with_extra_body("thinking", serde_json::json!({"type": "enabled"}));

let client = DeepSeekChatClient::new(options)?;
let stream = client.run(&messages, run_options).await?;

OpenAI

use rust_agent_client::{OpenAiChatClient, ChatClientOptions};

let options = ChatClientOptions::openai(
    "gpt-4o",
    std::env::var("OPENAI_API_KEY").unwrap(),
);

// 设置推理努力
let run_options = ChatClientRunOptions::default()
    .with_extra_body("reasoning_effort", serde_json::json!("high"));

let client = OpenAiChatClient::new(options)?;
let stream = client.run(&messages, run_options).await?;

自定义兼容端点

任何兼容 OpenAI API 格式的服务都可以通过 `ChatClientOptions` 接入:

let options = ChatClientOptions {
    api_base: "https://my-custom-llm.example.com/v1".into(),
    api_key: "my-api-key".into(),
    model: "my-model".into(),
    ..ChatClientOptions::default()
};

// 可以直接使用通用 ChatClient(不需要包装类型)
let client = ChatClient::new(options)?;

// ChatClient 直接实现了 IChatClient,使用 UsageFormat::OpenAI
let stream: BoxStream<Result<AgentResponseUpdate>> = client.run(&messages, default()).await?;

归纳

RAF 的 LLM 提供商层通过分层设计实现了最大化的代码复用:

| 层级 | 类型 | 职责 | |------|------|------| | 配置层 | `ChatClientOptions` | 静态的客户端参数(api_base, api_key, model) | | 传输层 | `ChatClient` | HTTP POST + SSE 流解析 + 请求体构建 | | 提供商层 | `DeepSeekChatClient` / `OpenAiChatClient` | 提供商标识的行为(用量格式、特有功能) | | 格式层 | `UsageFormat` | 提供商标识的 Token 统计解析 | | 流层 | `SseStream` | 底层的 SSE 字节流 → AgentResponseUpdate 转换 |

添加新提供商只需实现薄包装类型 + 添加 `UsageFormat` 变体,无需重复 HTTP/SSE 传输逻辑。

本地推理

除 HTTP API 外,RAF 通过独立 crate **`rust-agent-llama`** 支持本机 GGUF 推理(基于 [llama-gguf](https://crates.io/crates/llama-gguf))。无需 API Key。详见 **[9.5 本地模型推理](local-inference.md)**。