Files
agcore/docs/10-llm-provider-refinement.md
T

20 KiB
Raw Blame History

LLM Provider 重构改进方案(最终确认)

本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(9-llm-provider-unified-interface.md9a-9g 子文档)中已有设计的精炼与修订

阅读前提:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。

与 9 系的关系

  • 9 系文档中的 ContentBlockMessageRequestMessageResponseStopReasonThinkingConfigToolDefinitionPartialUsage 等核心类型定义继续有效,本文档不再重复
  • ProviderCapabilitiesLlmProvider trait 签名、PartialMessageResponse 汇聚算法继续有效
  • 本文档仅记录本次确认的修订内容和执行计划

1. 修订摘要

设计维度 9 系文档 本次修订 修订原因
Message 模型 结构化层次(System/User/Assistant/Tool,每项含 content: Vec<ContentBlock> 扁平大枚举(Assistant 拆散为多个独立的消息变体) 编译器能检查约束,消费方 match 清晰,无需在 Vec 中搜索特定 block 类型
StreamEvent 终端事件 MessageComplete { stop_reason, thinking_signature } 增加 full_response: LlmResponse,终端事件携带完整快照 消费方无需自己拼接 delta,直接拿到完整响应
Provider 发现 未明确 Enum-basedProviderType enum + exhaustive match),不做动态注册 当前协议数量可控,编译期安全,无运行时查表开销
项目阶段 9 系是"推演中" 可直接执行,无历史包袱,一步到位 项目尚未 release,没有 breaking change 顾虑

2. 本次修订的 5 项设计决策

2.1 Decision-01Message 采用扁平大枚举

定义

/// 跨 Provider 统一的消息类型(扁平大枚举)。
///
/// 设计原则:每个变体直接承载完整语义,
/// 消费方 match 即可获得所有信息,无需在嵌套的 Vec 中搜索。
#[derive(Debug, Clone)]
pub enum Message {
    /// 系统提示(User & Assistant 之外的引导指令)
    System {
        content: Vec<ContentBlock>,
    },
    /// 用户输入
    User {
        content: Vec<ContentBlock>,
    },
    /// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate
    UserImage {
        data: String,
        mime_type: String,
        detail: ImageDetail,
    },
    /// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)
    ///
    /// 注意:Assistant 的一次回复可以同时包含文本、思考过程、工具调用。
    /// 扁平大枚举并未将 ToolUse 提升为独立变体,而是保留在 content 中,
    /// 因为在一次 Assistant turn 中 text 和 tool_use 的**顺序关系**是有意义的。
    /// (例如:先输出推理过程,再调用工具)
    Assistant {
        content: Vec<ContentBlock>,
    },
    /// 工具调用结果
    ToolResult {
        tool_call_id: String,
        content: Vec<ContentBlock>,
        is_error: bool,
    },
}

与 9 系结构化层次的差异

维度 9 系(结构化层次) 本次(扁平大枚举)
Assistant 消息结构 Assistant { content: Vec<ContentBlock> }ToolUse 在 content 中 同上,保持 ToolUse 在 content 中
"独立 Assistant 消息"的含义 一次 LLM 响应 = 一个 Assistant { content: [...] } 同上
Thinking / ToolCall 作为独立变体 无独立变体 Thinking、ToolCall 不作为独立 Message 变体,仍在 Assistant.content
UserImage 独立变体 User { content: [Image{...}] } UserImage { data, mime, detail }
为什么不把 ToolUse 提到 Message 层 因为 text ↔ tool_use 的交错顺序是 Assistant 响应的语义组成部分,拆散后会丢失顺序信息
实际的参与方差异 User + UserImage 合并为同一变体 UserUserImage 拆开,方便消费方 match(无需检查 Vec 内容来区分文字和图片)

与 9b 文档的关系9b 的 Message::SystemMessage::UserMessage::AssistantMessage::Tool 四个变体分类保留, 但 User 的图片输入场景通过新增 UserImage 变体提供便捷路径,减少 boilerplate。 Message::Assistantcontent: Vec<ContentBlock> 保持不变——ToolUse 仍在 content 中。

便捷构造函数

impl Message {
    pub fn user_text(text: impl Into<String>) -> Self;
    pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self;
    pub fn assistant(text: impl Into<String>) -> Self;
    pub fn system(text: impl Into<String>) -> Self;
    pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self;
}

2.2 Decision-02LlmProvider 感知消息类型

沿用 9c 文档中的 trait 设计,无修订。

#[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>;

    fn capabilities(&self) -> ProviderCapabilities;
}

每个 Provider 实现内部自行处理 MessageRequest ↔ 原生协议格式的映射。无外部转换层。

2.3 Decision-03StreamEvent 高精度 + 终端事件携带完整响应

沿用 9c 文档中定义的 StreamEvent,但在终端事件中增加完整响应快照

修订后的 MessageComplete 事件

pub enum StreamEvent {
    // ── Meta ──
    MessageStart { id: String, model: String },

    // ── Content Block 边界 ──
    ContentBlockStart { index: u32, block_type: ContentBlockType },
    ContentBlockEnd { index: u32 },

    // ── 块内增量 ──
    TextDelta { text: String },
    ThinkingDelta { text: String },
    RefusalDelta { text: String },
    ToolCallArgumentsDelta { index: u32, arguments: String },
    ToolCallEnd { index: u32 },

    // ── 汇总 ──
    CostUpdate { usage: PartialUsage },

    // ═══════════════════════════════════════════════════════
    // 修订:MessageComplete 携带完整响应快照
    // ═══════════════════════════════════════════════════════
    /// 消息完成。
    ///
    /// `thinking_signature` 仅 Anthropic 场景使用,回填到最后的 Thinking block。
    /// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content + usage + stop_reason),
    /// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
    MessageComplete {
        stop_reason: StopReason,
        thinking_signature: Option<String>,
        /// 完整的响应快照。
        ///
        /// 与 `PartialMessageResponse` 内部累积的状态**最终一致**,
        /// 提供此快照是为了让消费方(如 LlmCycle)在流结束后可以直接拿到
        /// 完整的 MessageResponse,无需自己实现汇聚算法。
        full_response: MessageResponse,
    },

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

设计理由

  1. 简化消费方LlmCycle::submit_stream() 目前需要在 while let 循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了 full_responseLlmCycleAgentSession 只需要监听 MessageComplete 事件,拿到快照后直接继续 tool 循环或返回给调用方。
  2. 与 PartialMessageResponse 保持一致PartialMessageResponse::finalize() 产生的 MessageResponse 就是 full_response 的值。Provider 内部的汇聚逻辑不变,只是在发出 MessageComplete 时多传一个已完成构建的最终结果。
  3. 零额外开销MessageResponse 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。

对 PartialMessageResponse 的影响

// finalize 在原有逻辑末尾增加一步:
// 将 finalize 的结果提前缓存,由 MessageComplete 事件携带
impl PartialMessageResponse {
    pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
        // ... 原有代码(按 index 升序遍历 blocks ...
        let response = MessageResponse { ... };

        // 新增:self. 中缓存 finalize 结果
        // (实际由 Provider 的流处理循环在发出 MessageComplete 前调用
        //   finalize 并填充到事件中)

        Ok(response)
    }
}

Provider 的流处理循环在发出 MessageComplete 时,提前调用 finalize() 取得 MessageResponse 并填入事件:

// 伪代码:Provider 流处理循环
let mut partial = PartialMessageResponse::new();

while let Some(anthropic_event) = anthropic_stream.next().await {
    match map_to_ir_event(anthropic_event) {
        StreamEvent::MessageComplete { stop_reason, thinking_signature } => {
            // 在此处调用 finalize 并将结果携带到事件中
            let full = partial.finalize()?;
            yield StreamEvent::MessageComplete {
                stop_reason,
                thinking_signature,
                full_response: full,
            };
            break;
        }
        other_event => { other_event.apply_to(&mut partial); }
    }
}

2.4 Decision-04LlmCycle 简化

沿用 9e 文档的改造方向,核心变化是内部消息类型从 Vec<OpenaiChatMessage> 改为 Vec<Message>

关键变化要点(9e 已有详述):

当前 改进后
messages: Vec<OpenaiChatMessage> messages: Vec<Message>
build_request() 中手动拼接 system prompt system prompt 通过 Message::System 在 messages 中表达,Provider 映射层自行处理差异
submit_stream() 中自建 delta 聚合逻辑 监听 MessageComplete.full_response,直接拿到完整响应
tool 循环需自行解析 ChatResponse 中的 tool_calls MessageResponse.messageAssistant 变体)的 content 中提取 ContentBlock::ToolUse

2.5 Decision-05Provider 发现使用 Enum

不使用动态注册表,保留当前 ProviderType enum 模式,但扩展其覆盖范围。

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
    OpenaiChat,
    OpenaiResponse,
    Anthropic,
    DeepSeek,
    Qwen,
}

工厂函数 create_provider() 做 exhaustive match

pub fn create_provider(
    provider_type: ProviderType,
    config: ProviderConfig,
) -> Result<Box<dyn LlmProvider>, LlmError> {
    match provider_type {
        ProviderType::OpenaiChat => Ok(Box::new(providers::OpenaiChatProvider::new(...))),
        ProviderType::OpenaiResponse => Ok(Box::new(providers::OpenaiResponseProvider::new(...))),
        ProviderType::Anthropic => Ok(Box::new(providers::AnthropicProvider::new(...))),
        ProviderType::DeepSeek => Ok(Box::new(providers::DeepSeekProvider::new(
            config.base_url,
            config.api_key,
            config.model,
        ))),
        ProviderType::Qwen => Ok(Box::new(providers::QwenProvider::new(...))),
    }
}

新增 Provider 时,编译器通过 exhaustiveness check 强制要求 match 更新。

理由:当前目标协议数量(4-5 种)完全可控,enum 的编译期安全检查优于运行时的 HashMap::get()。 未来如果扩展到 15+ 种以上,再改为注册表模式。


3. 对 9 系文档的更新映射

9 系文档 变更类型 操作
9b-ir-type-system.md §3.2 Message 修订 UserImage 变体新增;其余部分继续有效
9c-llm-provider-trait.md §4.3 StreamEvent 修订 MessageComplete 增加 full_response: MessageResponse 字段
9c-llm-provider-trait.md §4.4 PartialMessageResponse 追加 finalize() 返回结果需在 Provider 发出 MessageComplete 前已可用
9d-provider-implementations.md 继续有效 实现策略不变
9e-llm-cycle-and-upstream.md 继续有效 改造方向不变
9f-edge-cases.md 继续有效 边界情况处理不变
9g-risk-and-migration.md 继续有效 风险评估不变
本文档 10-... 新增 记录最终决策和修订

4. 实施步骤

Phase 0:类型层落地

目标:定义并测试新的类型系统。

涉及文件

  • src/llm/types/mod.rs — 新增消息类型模块,保持向后兼容导出
  • src/llm/types/message.rs — 新文件,定义 MessageContentBlockContentBlockType
  • src/llm/types/request.rs — 修改 ChatRequest = OpenaiChatRequesttype ChatRequest = MessageRequest(过渡期同时保留 OpenaiChatRequest 作为 Provider 内部类型)
  • src/llm/types/response.rs — 修改 ChatResponse 为指向新类型
  • src/llm/stream.rs — 扩展 StreamEvent,增加 MessageComplete.full_response
  • src/llm/compact.rs — 适配新 Message 类型(compact 逻辑只关心 text 长度,变化小)

具体任务

  1. src/llm/types/ 下新增 message.rs,定义 Message 扁平大枚举 + ContentBlock + 便捷构造函数
  2. ContentBlock 的现有定义(Text, Image, ToolUse, ToolResult, Thinking, Extension)从 9b 移植过来
  3. 扩展 StreamEventMessageComplete 增加 full_response: MessageResponse
  4. 确认 ContentBlockImageSourceToolDefinitionPartialUsage 等辅助类型在 9b 中的定义,视需要移动或引用
  5. 类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证

验证cargo test 通过,新类型可独立编译且 match 是 exhaustive 的。

Phase 1Provider 适配

目标:重写 OpenaiProvider,新增 AnthropicProvider

涉及文件

  • src/llm/provider.rs — 修改 create_provider 工厂函数签名
  • src/llm/provider/registry.rs — 适配新 LlmProvider trait(改动极小,只是类型变化)
  • src/llm/provider/openai.rs — 重写:内部实现 MessageRequest ↔ OpenaiChatRequest 转换
  • src/llm/provider/anthropic.rs — 新文件:MessageRequest ↔ Anthropic Messages API 映射
  • src/llm/provider/deepseek.rs — 新文件(与 OpenaiChatProvider 共享 /chat/completions 协议,只需处理 base_url + 差异)
  • src/llm/provider/qwen.rs — 新文件(同上)

具体任务

  1. OpenaiProvider 内部 chat()MessageRequestOpenaiChatRequestserde 序列化)→ HTTP POST → 解析 OpenaiChatResponseMessageResponse(通过 finalize() 算法或直接映射)
  2. OpenaiProvider 内部 chat_stream():同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出 StreamEvent
  3. AnthropicProvider:实现 Anthropic Messages API 的请求/响应映射,包括:
    • Messages API 请求体构建(system 参数 + messages[] + tools 等)
    • SSE 流解析(message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, ping
    • 将 Anthropic SSE 事件映射为 IR StreamEvent
  4. DeepSeekProvider / QwenProvider:与 OpenaiChatProvider 共享相同的 /chat/completions 协议,通过参数化或 trait 组合复用代码
  5. ProviderRegistryregister_with_config()create_provider() 适配新 enum

验证

  • 每个 Provider 的 chat()chat_stream() 基本路径集成测试(mock HTTP 层)
  • 消息类型双向映射测试(Message → OpenaiChatRequest, OpenaiChatResponse → MessageResponse
  • 错误路径测试(HTTP 400/401/429/500 → LlmError 映射)

Phase 2LlmCycle 简化

目标:将 LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换到 Vec<Message>,利用 MessageComplete.full_response 简化流处理。

涉及文件

  • src/llm/cycle.rs — 主要修改
  • src/llm/cycle/usage.rs — 保持兼容(Usage 类型不变)
  • src/llm/cycle/retry.rs — 保持兼容

具体任务

  1. messages: Vec<Message> 替换 messages: Vec<OpenaiChatMessage>
  2. build_request() 改为直接构建 MessageRequest(不再手动拼接 system prompt
  3. submit() / submit_messages():调用 provider.chat() 后,响应类型从 ChatResponse 改为 MessageResponse
  4. submit_stream():流处理循环改为监听 MessageComplete.full_response
  5. tool 循环:从 MessageResponse.messageAssistant)的 content 中提取 ContentBlock::ToolUse
  6. compact.rs 适配:microcompact()should_compact() 的操作对象从 OpenaiChatMessage 改为 Message

验证

  • LlmCycle 集成测试全部通过
  • 多轮对话 + 工具调用的端到端流程正常

5. 验证标准

维度 验证方法 通过条件
类型正确性 cargo test 所有测试通过
JSON 双向映射 单元测试 Message → JSON → Message 往返不变
Provider 基本路径 集成测试(mock HTTP 每个 Provider 的 chat + chat_stream 成功
Provider 错误路径 集成测试(mock HTTP 4xx/5xx 错误映射为正确的 LlmError 变体
StreamEvent 完整快照 集成测试 MessageComplete.full_response 与 PartialMessageResponse 聚合结果一致
LlmCycle 多轮对话 集成测试(mock Provider 多轮对话 + 工具循环正常
compact 集成测试 超过 token 阈值后消息被正确压缩
向后兼容(已存在的 pub API 编译检查 外部 crate 使用 agcore::llm::types::* 的功能不受影响(类型别名过渡)

6. 回滚方案

由于项目尚无外部消费者,回滚策略比较简单:

阶段 触发条件 操作
Phase 0(类型层) 新类型设计发现重大缺陷 回退 git,保留 9 系文档作为参照,重启设计评审
Phase 0 完成时 类型定义通过评审和测试 打 tag types-v2-prototype
Phase 1Provider 适配) 某个 Provider 实现不合理 将该 Provider 回退为 unimplemented!()(当前状态),不影响其他 Provider
Phase 1 完成时 Provider 测试全部通过 打 tag providers-v2-prototype
Phase 2LlmCycle 简化) 循环逻辑或 compact 出现问题 保留旧 LlmCycle 实现(不改文件名),通过 feature flag 切换

风险储备

  • 如果 OpenaiProvider 的重写复杂度过高,可以保留旧的 OpenaiProvider 不变,在旁边新增一个 OpenaiProviderV2 并行开发
  • ChatRequest / ChatResponse 等类型别名在第 3 个 minor release 前不需要移除,给外部消费者留出迁移时间

7. 开放事项

以下事项已在 9 系文档中充分讨论,本次无修订,但列出以供跟踪:

  • ContentBlock::Extension 作为逃生舱的具体使用场景(OpenAI Response 内置工具、未知 block 类型)
  • Anthropic 的 /v1/messages 流式 SSE 解析状态机细节(9d Provider 实现文档)
  • MessageRequest.extra 中每个 Provider 实际需要的 key 清单(9b 已有草案,Phase 1 实现时细化和验证)
  • Thinking signature 的端到端测试(9f 已有处理策略,Phase 2 时分配合并完成)
  • cost 计算逻辑适配新类型(当前 CostTrackerUsage 上工作,类型不变、无需修改,但在集成测试中验证)