Files
agcore/design/pdd/9c-llm-provider-trait.md
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

18 KiB
Raw Permalink Blame History

LlmProvider Trait 设计

本文档从 9-llm-provider-unified-interface.md 拆分而来,包含 §4 LlmProvider Trait 设计。

相关文件:

4. LlmProvider Trait 设计

4.1 核心接口

#[async_trait]
pub trait LlmProvider: Send + Sync {
    /// 发送消息请求,获取完整响应。
    async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;

    /// 流式消息请求,返回语义化事件流。
    async fn chat_stream(
        &self,
        request: MessageRequest,
    ) -> Result<
        Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
        LlmError,
    >;

    /// 返回 Provider 的能力描述。
    fn capabilities(&self) -> ProviderCapabilities;
}

4.2 ProviderCapabilities

#[derive(Debug, Clone)]
pub struct ProviderCapabilities {
    /// Provider 标识名称
    pub provider_name: &'static str,
    /// 支持的模型列表(None = 不限制)
    pub supported_models: Option<Vec<String>>,
    /// 特性标记
    pub features: ProviderFeatures,
}

#[derive(Debug, Clone, Default)]
pub struct ProviderFeatures {
    pub streaming: bool,
    pub thinking: bool,
    pub vision: bool,
    pub audio_input: bool,
    pub tool_use: bool,
    pub parallel_tool_calls: bool,
    /// true=OpenAI风格(system in messages), false=Anthropic风格(system as param)
    pub system_prompt_in_messages: bool,
    pub max_context_window: u32,
}

capabilities() 的使用场景

  1. LlmCycle:根据 features.thinking 决定是否启用 thinking 模式
  2. AgentBuilder:在构建时校验模型是否支持所需特性
  3. UI/CLI:展示 Provider 的能力矩阵
  4. 智能路由:根据能力自动选择最佳 Provider

4.3 流式事件 StreamEvent

/// 流式事件 —— 流式响应的语义化增量构建过程。
/// 一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse。
#[derive(Debug, Clone)]
pub enum StreamEvent {
    // ── Meta ──
    /// 消息开始
    MessageStart { id: String, model: String },

    // ── Content Block 边界 ──
    /// Content block 开始(index = 全局 content block 序号)
    ContentBlockStart { index: u32, block_type: ContentBlockType },
    /// Content block 结束
    ContentBlockEnd { index: u32 },

    // ── 块内增量(无 index,隐含属于当前活跃 block)──
    /// 文本增量
    TextDelta { text: String },
    /// 思考增量(Anthropic thinking block
    ThinkingDelta { text: String },
    /// 拒绝增量(OpenAI refusal,汇聚为 ContentBlock::Text
    RefusalDelta { text: String },

    // ── Tool Call 参数(index = block index)──
    /// 工具参数增量
    ToolCallArgumentsDelta { index: u32, arguments: String },
    /// 工具参数结束,可尝试解析 JSON
    ToolCallEnd { index: u32 },

    // ── 汇总 ──
    /// Token 用量(字段级合并,见 PartialUsage
    CostUpdate { usage: PartialUsage },
    /// 消息完成(thinking_signature 仅 Anthropic 场景,回填到最后的 Thinking block
    MessageComplete { stop_reason: StopReason, thinking_signature: Option<String> },

    // ── 错误 ──
    Error { message: String },
}

/// ContentBlock 的类型标识,嵌入在 ContentBlockStart 事件中。
#[derive(Debug, Clone)]
pub enum ContentBlockType {
    Text,
    Thinking,
    Refusal,
    ToolUse { id: String, name: String },
}

/// CostUpdate 中携带的用量数据——只含本次更新中真正下发的字段。
///
/// 汇聚算法做字段级合并(覆盖各字段的 Some 值),而非全量覆盖。
/// 解决 Provider 分多次下发 Usage 的问题:
/// - OpenAI: 一次全量下发(所有字段都有值)
/// - Anthropic: message_start 时下发 input_tokensmessage_delta 时下发 output_tokens
#[derive(Debug, Clone, Default)]
pub struct PartialUsage {
    pub prompt_tokens: Option<u32>,
    pub completion_tokens: Option<u32>,
    pub total_tokens: Option<u32>,
    pub completion_tokens_details: Option<CompletionTokensDetails>,
    pub prompt_tokens_details: Option<PromptTokensDetails>,
}

设计决策:为什么移除 ToolCallStart

ToolCallStart { index, id, name } 的语义本质是一个 ContentBlock 的开始(类型为 ToolUse), 没有单独存在的必要。合并到 ContentBlockStart.block_type = ToolUse { id, name } 后:

  • index 语义统一ContentBlockStart.index 是全文档唯一的 content block 序号
  • 事件减少:每次 tool_use block 开始少发一个事件,Anthropic 映射更自然(content_block_startContentBlockStart
  • #4议题自动解决:index 在 IR 层面始终为全局序号,OpenAI Provider 内部的局部→全局映射完全封装在 Provider 层, 不在 IR 中暴露差异

设计决策:为什么 TextDelta 不带 index

  • 单 SSE 连接内 TCP 保证字节序,TextDelta 必然属于最后一个 ContentBlockStart 开始的 block
  • 不携带 index 可减少事件体积
  • 如果未来 LlmCycle 需要跨 Provider 合并流,可向后兼容地添加 index 字段(降级策略:无 index 时默认归属当前活跃 block)

设计决策:CostUpdate 使用 PartialUsage 做字段级合并

覆盖策略(取最新值)在 Anthropic 场景下出错——Anthropic 分两次下发:

CostUpdate #1: { prompt_tokens: Some(100), completion_tokens: None,   total_tokens: Some(100) }
CostUpdate #2: { prompt_tokens: None,   completion_tokens: Some(50),  total_tokens: Some(150) }

如果直接 Usage 全量覆盖,第一次的 prompt_tokens 会被第二次的 None 清零。 改为 PartialUsage(字段级 Option)后,汇聚算法只更新 Some 的字段,避免清零。

与当前 StreamEvent 的对比

当前 StreamEvent 新 StreamEvent 理由
AssistantTextDelta { text } TextDelta { text } 简洁化
ThinkingDelta { text } Anthropic 需要
RefusalDelta { text } OpenAI 需要
ToolExecutionStarted { tool_name, input, tool_call_id } ContentBlockStart { index, ToolUse { id, name } } + ToolCallArgumentsDelta { index, arguments } 拆分为 block 边界 + 参数增量
ToolExecutionCompleted 移除 LlmCycle 层事件,非 Provider 层
ContentBlockStart / ContentBlockEnd 显式 block 边界标记
BlockContentType ContentBlock 类型标识
CostUpdate { usage: Usage } CostUpdate { usage: PartialUsage } 字段级合并,兼容多 Provider
TurnComplete { reason } MessageComplete { stop_reason, thinking_signature: Option<String> } 语义更准确 + thinking 签名回填
Error { message } 保留
MessageStart { id, model } Anthropic 需要
ToolCallEnd { index } 明确参数完整时间点
ToolCallStart { index, id, name } 移除 合并到 ContentBlockStart.ToolUse

4.4 PartialMessageResponse —— 流式事件的汇聚算法

推演结论(2026-06-17):采用方案 B(显式边界)—— ContentBlockStart/End 声明 block 边界,BTreeMap 按 index 分桶组装

核心思路: 放弃"类型切换推断 block 边界"的隐含方案。为 StreamEvent 增加 ContentBlockStartContentBlockEnd 事件,使每个 block 的开始和结束都有精确的事件标记。汇聚算法由"线性累积 + 隐含 flush + 延迟排序"改为 "按 index 分桶 + 排序组装",彻底消除对事件到达顺序的依赖。

决策理由:

  1. 类型切换推断在 ToolCallEnd 后跟 TextDelta 的场景下导致顺序错乱(ToolUse 被延迟插入到 Text 之后)
  2. 显式边界将位置信息绑定到 index,不依赖事件到达时序,容错性更强
  3. Anthropic 的 content_block_start/stop 天然映射为 ContentBlockStart/End
  4. OpenAI 的无边界流式由 Provider 内部分析 delta 类型来合成边界,封装在 Provider 层
  5. #4ToolCallStart index 归一化)自动解决——index 统一为全局 content block 序号

PartialMessageResponse 结构体

use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;

/// 流式事件的累积状态 —— 按 index 分桶,逐个构建 ContentBlock。
#[derive(Debug, Default)]
pub struct PartialMessageResponse {
    // ── 来自 MessageStart ──
    pub id: Option<String>,
    pub model: Option<String>,

    /// 按 index 分桶的 ContentBlock 构建器(BTreeMap 保证升序遍历)
    pub blocks: BTreeMap<u32, ContentBlockBuilder>,

    /// Tool call 参数累积(按 block index 关联)
    pub tool_call_args: HashMap<u32, String>,

    /// 已收到 ContentBlockEnd 的 block index 集合
    pub block_completion: HashSet<u32>,

    /// 当前最后打开的 block index(供无 index 的 TextDelta 定位)
    pub last_open_index: Option<u32>,

    /// Token 用量(字段级合并后的最终值)
    pub usage: Usage,

    /// 结束状态
    pub stop_reason: Option<StopReason>,

    /// MessageComplete 携带的 thinking signature(留待 finalize 回填)
    pub thinking_signature: Option<String>,

    pub is_errored: bool,
    pub is_complete: bool,
}

/// 按 index 分桶的 ContentBlock 构建器。
#[derive(Debug)]
pub enum ContentBlockBuilder {
    Text(String),
    Thinking { buffer: String, signature: Option<String> },
    Refusal(String),
    ToolUse { id: String, name: String },
}

apply_to 算法

impl StreamEvent {
    /// 将当前事件应用到 PartialMessageResponse 上。
    /// 返回 true 表示正常处理,false 表示应停止处理后续事件。
    pub fn apply_to(&self, state: &mut PartialMessageResponse) -> bool {
        match self {
            // ──────────────── Meta ────────────────
            StreamEvent::MessageStart { id, model } => {
                state.id = Some(id.clone());
                state.model = Some(model.clone());
                true
            }

            // ──────────────── Block 边界 ────────────────
            StreamEvent::ContentBlockStart { index, block_type } => {
                let builder = match block_type {
                    ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
                    ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
                        buffer: String::new(),
                        signature: None,
                    },
                    ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
                    ContentBlockType::ToolUse { id, name } =>
                        ContentBlockBuilder::ToolUse { id: id.clone(), name: name.clone() },
                };
                state.blocks.entry(*index).or_insert(builder);
                state.last_open_index = Some(*index);
                true
            }

            StreamEvent::ContentBlockEnd { index } => {
                state.block_completion.insert(*index);
                true
            }

            // ──────────────── 块内增量 ────────────────
            StreamEvent::TextDelta { text } => {
                // 定位到最后打开的 block
                if let Some(idx) = state.last_open_index {
                    if let Some(ContentBlockBuilder::Text(ref mut buf)) = state.blocks.get_mut(&idx) {
                        buf.push_str(text);
                    }
                } else {
                    tracing::warn!("TextDelta 到达时无活跃 block");
                }
                true
            }

            StreamEvent::ThinkingDelta { text } => {
                if let Some(idx) = state.last_open_index {
                    if let Some(ContentBlockBuilder::Thinking { ref mut buffer, .. }) =
                        state.blocks.get_mut(&idx)
                    {
                        buffer.push_str(text);
                    }
                } else {
                    tracing::warn!("ThinkingDelta 到达时无活跃 block");
                }
                true
            }

            StreamEvent::RefusalDelta { text } => {
                if let Some(idx) = state.last_open_index {
                    if let Some(ContentBlockBuilder::Refusal(ref mut buf)) =
                        state.blocks.get_mut(&idx)
                    {
                        buf.push_str(text);
                    }
                } else {
                    tracing::warn!("RefusalDelta 到达时无活跃 block");
                }
                true
            }

            // ──────────────── Tool Call 参数 ────────────────
            StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
                state
                    .tool_call_args
                    .entry(*index)
                    .or_default()
                    .push_str(arguments);
                true
            }

            StreamEvent::ToolCallEnd { index } => {
                // ToolCallEnd 只做标记,参数在 finalize 时解析
                state.block_completion.insert(*index);
                true
            }

            // ──────────────── 汇总 ────────────────
            StreamEvent::CostUpdate { usage } => {
                Self::apply_cost_update(state, usage);
                true // 即使 is_complete 后 CostUpdate 仍然可以到达
            }

            StreamEvent::MessageComplete {
                stop_reason,
                thinking_signature,
            } => {
                state.stop_reason = Some(*stop_reason);
                state.thinking_signature = thinking_signature.clone();
                state.is_complete = true;
                true
            }

            // ──────────────── 错误 ────────────────
            StreamEvent::Error { message } => {
                state.is_errored = true;
                tracing::error!("流式处理中发生错误: {}", message);
                false // 终止处理
            }
        }
    }

    /// 字段级合并 CostUpdate(仅覆盖 Some 的字段)。
    fn apply_cost_update(state: &mut PartialMessageResponse, update: &PartialUsage) {
        if let Some(v) = update.prompt_tokens {
            state.usage.prompt_tokens = v;
        }
        if let Some(v) = update.completion_tokens {
            state.usage.completion_tokens = v;
        }
        if let Some(v) = update.total_tokens {
            state.usage.total_tokens = v;
        }
        if update.completion_tokens_details.is_some() {
            state.usage.completion_tokens_details = update.completion_tokens_details;
        }
        if update.prompt_tokens_details.is_some() {
            state.usage.prompt_tokens_details = update.prompt_tokens_details;
        }
    }
}

finalize —— 完成逻辑

impl PartialMessageResponse {
    /// 将所有累积状态转化为最终的 MessageResponse。
    pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
        let id = self.id.unwrap_or_else(|| "stream-unknown".to_string());
        let model = self.model.unwrap_or_else(|| "unknown".to_string());
        let stop_reason = self.stop_reason.unwrap_or(StopReason::Other);

        // 1. 按 index 升序遍历所有 blocks,转换为 ContentBlock
        let mut content: Vec<ContentBlock> = Vec::new();
        for (_index, builder) in std::mem::take(&mut self.blocks).into_iter() {
            let block = match builder {
                ContentBlockBuilder::Text(text) => ContentBlock::Text { text },
                ContentBlockBuilder::Thinking { buffer, mut signature } => {
                    // 如果 Thinking block 的 signature 尚未填充,用 MessageComplete 的回填
                    if signature.is_none() {
                        signature = self.thinking_signature.clone();
                    }
                    ContentBlock::Thinking {
                        text: buffer,
                        signature,
                    }
                }
                ContentBlockBuilder::Refusal(text) => ContentBlock::Text { text },
                ContentBlockBuilder::ToolUse { id, name } => {
                    let arguments = self.tool_call_args.remove(&(_index as u32))
                        .unwrap_or_default();
                    let input: Value = serde_json::from_str(&arguments)
                        .unwrap_or(Value::Null);
                    ContentBlock::ToolUse { id, name, input }
                }
            };
            content.push(block);
        }

        Ok(MessageResponse {
            id,
            model,
            message: Message::Assistant { content },
            usage: self.usage,
            stop_reason,
            extra: HashMap::new(),
        })
    }

    /// 检查是否已收到足以构造"有意义"响应的数据。
    pub fn is_meaningful(&self) -> bool {
        self.id.is_some() && (self.is_complete || self.is_errored)
    }
}

边界情况处理

边界场景 处理策略 理由
MessageStart 前收到 TextDelta warn log,忽略增量 不合法流,防御性处理
ToolCallArgumentsDelta 乱序 HashMap key 天然容忍 index 作为关联 key,到达顺序不影响最终累积
多次 CostUpdate 字段级合并(PartialUsage OpenAI 一次全量,Anthropic 分两次,OpenAI Response 可能多次
同一 index 收到多次 ContentBlockStart entry(*index).or_insert() 幂等 第二次不覆盖已有内容
MessageComplete 后 CostUpdate 才到 不阻断,仍然合并 Usage 最终值可能最后才到
ContentBlockEnd 缺失 finalize 时仍然输出内容 不完整的 block 也是内容
无任何 ContentBlockStart finalize 返回空的 Assistant 消息 边界场景,不阻断
Error 后收到其他事件 Error 返回 false,上层停止调用 apply_to 一旦出错,不再处理后续事件
thinking_signature 未填 Thinking block 的 signature 为 None 安全降级