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

33 KiB
Raw Permalink Blame History

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

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

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

与 9 系的关系

  • 9 系文档中的 ContentBlockMessageRequestMessageResponseStopReasonThinkingConfigToolDefinitionPartialUsage 等核心类型定义继续有效,本文档不再重复
  • ProviderCapabilitiesLlmProvider trait 签名、PartialMessageResponse 汇聚算法等 design intent 继续有效(trait 签名由 9c 定义,切换时点由本文档 §4 Phase 0 执行)
  • 本文档仅记录本次确认的修订内容和执行计划

修订记录

日期 版本 修订摘要
2026-06-25 v1 初版,记录 5 项设计决策
2026-06-26 v2 初审修订:修正 §1 表描述(Assistant → UserImage);消除 §2.3 MessageComplete 冗余字段;明确 Phase 0 add-only 策略;补充 9e 依赖审查说明;补充 HTTP mock 策略;修正 §5 兼容性验证标准;增加 §7 开放事项
2026-06-26 v3 复审修订:Phase 0 改为"add + trait 签名切换"消除结构性缺口;补充 OpenAI Response API 范围和 OpenAI-compatible 复用策略说明;调整 Phase 2 范围(聚焦逻辑简化)

1. 修订摘要

设计维度 9 系文档 本次修订 修订原因
Message 模型 结构化层次(System/User/Assistant/Tool,每项含 content: Vec<ContentBlock> 扁平大枚举User 拆出 UserImage 独立变体,Assistant 保持整体,ToolUse 仍在 content 中) 编译器能检查约束,UserImage 消费方 match 可直接区分文本和图片输入,无需检查 Vec 内容
StreamEvent 终端事件 MessageComplete { stop_reason, thinking_signature } 精简为 MessageComplete { full_response: MessageResponse },移除冗余顶层字段 消除冗余和消费方疑惑,唯一信源
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 携带完整响应快照(移除冗余的 stop_reason / thinking_signature
    // ═══════════════════════════════════════════════════════
    /// 消息完成 —— 唯一可靠的完整响应来源。
    ///
    /// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content / usage / stop_reason),
    /// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
    ///
    /// 设计说明:
    /// - 9c 原有设计在 `MessageComplete` 中同时携带 `stop_reason` 和 `thinking_signature` 顶层字段,
    ///   但这些信息已包含在 `full_response` 中,造成冗余和消费方的疑惑(到底读顶层字段还是 full_response)。
    /// - 本次修订全部移除顶层冗余字段,`full_response` 是唯一信源。
    /// - Anthropic 的 thinking signaturemessage_delta 中下发,晚于 content_block_stop)由 Provider
    ///   的流处理循环直接调用 `PartialMessageResponse::set_thinking_signature()` 写入内部状态,
    ///   再通过 `finalize()` 回填到 Thinking block 中,最终出现在 `full_response` 的 content 里。
    ///   消费方不需要感知 signature 的存在。
    MessageComplete {
        /// 完整的响应快照。
        ///
        /// 与 `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 一次给事件携带。
  4. 消除冗余9c 原有设计同时保留了顶层 stop_reasonthinking_signaturefull_response 中的相同信息,造成消费方疑惑。本次修订只保留 full_response 为唯一信源。

对 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 的流处理循环 - thinking_signature 不再经过事件层,由 Provider 直接写入 PartialMessageResponse 内部状态:

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

while let Some(event) = anthropic_stream.next().await {
    match event {
        // Anthropic 的 message_delta 携带 thinking.signature
        // → Provider 直接写入 PartialMessageResponse 内部状态
        AnthropicEvent::MessageDelta { delta, usage } => {
            if let Some(thinking) = &delta.thinking {
                if let Some(sig) = &thinking.signature {
                    partial.set_thinking_signature(sig.clone());
                }
            }
            yield StreamEvent::CostUpdate { usage: map_usage(usage) };
        }
        // 其他 Anthropic 事件 → 映射为 StreamEvent 并 apply_to
        other => {
            let ir_event = map_to_ir_event(other);
            ir_event.apply_to(&mut partial);
        }
        // message_stop → 调用 finalize 并发出完成事件
        AnthropicEvent::MessageStop => {
            let full = partial.finalize()?;
            yield StreamEvent::MessageComplete { full_response: full };
            break;
        }
    }
}

变更追溯9c 的原有设计中,MessageComplete 事件携带顶层 stop_reasonthinking_signature 字段, 供 apply_to() 设置 PartialMessageResponse 的内部状态。本次修订移除这些冗余字段后, thinking_signature 改为由 Provider 直接调用 partial.set_thinking_signature() 写入内部状态, stop_reason 则在 finalize() 中统一定于 full_response.stop_reason

2.4 Decision-04LlmCycle 简化

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

⚠️ 依赖验证9e 文档写于结构化层次设计阶段(Message::System / User / Assistant / Tool), 其中的代码片段(如 build_request() 中 match System 消息的分支、插入 System prompt 的判断逻辑) 基于旧 Message 定义。扁平大枚举后——

  • User 拆出 UserImage → match 分支需增加 UserImage 的处理
  • Message::Tool 更名为 Message::ToolResult → 所有引用需改名
  • 其余 match 分支(SystemUserAssistant)的基本逻辑不变

实施 Phase 2 时:从 9e 中摘取实现思路,代码手动编写,不直接复制 9e 中的代码片段。 修改 9e 文档中过时的代码片段不在本方案范围内,Phase 2 实施时自然淘汰。

关键变化要点(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.1 LlmProvider trait 切换时点修订 trait 签名切换由"推迟到 Phase 2"改为 Phase 0 内完成。trait 定义本身不变。
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 需重新审查 方向不变,但其中的 match 分支和 System prompt 插入逻辑基于旧 Message 定义。Phase 2 实施时参考思路而非照搬代码(见 §2.4 ⚠️ 依赖验证)
9f-edge-cases.md 继续有效 边界情况处理不变
9g-risk-and-migration.md 继续有效 风险评估不变
本文档 10-... 新增 记录最终决策和修订

4. 实施步骤

Phase 0:类型层落地 + trait 签名切换

目标:新增新的类型系统 + 切换 LlmProvider trait 签名,使全链路使用新类型。Phase 0 结束时 cargo test 全部通过。

原则

  • 新类型定义放入新文件message.rsrequest_v2.rsresponse_v2.rs),不堆积到已有类型文件
  • 已有的 request.rsOpenaiChatRequest)、response.rsOpenaiChatResponse保留原样,后续 Provider 实现可能作为内部转换目标继续引用
  • LlmProvider trait 签名由 chat(ChatRequest) → ChatResponse 切换为 chat(MessageRequest) → MessageResponse在同一个 Phase 内完成(见下方任务 68
  • trait 签名变更导致的编译错误(StubProviderLlmCycle 调用点)在 Phase 0 内全部修复,不留到 Phase 1
  • AgentSession 等上游中对 LlmCycle.submit() 返回值的引用同步适配

StreamEvent 命名冲突处理StreamEvent(高精度版)定义在 src/llm/types/response_v2.rs 中。 旧 StreamEventsrc/llm/stream.rs 中定义)的变体(AssistantTextDeltaToolExecutionStartedTurnComplete 等)与新类型冲突。 处理方式(任务 9 执行):

  1. response_v2.rs 中的新 StreamEvent 是唯一的 StreamEvent 定义
  2. src/llm/stream.rs 中的旧 StreamEvent 枚举替换为重新导出语句:pub use super::types::response_v2::StreamEvent;
  3. StreamEvent 的变体(AssistantTextDeltaToolExecutionStartedTurnComplete暂时保留为一个独立的枚举(命名为 LegacyStreamEvent)放在 src/llm/types/old_stream.rs 新文件中,供 stream.rs 中的 parse_chunk_stream() 内部使用
  4. 这样 stream.rs 的辅助函数继续编译,LlmCycleAgentSession 看到的是新 StreamEvent

涉及文件

类型 文件 操作
新增 src/llm/types/message.rs 新文件
新增 src/llm/types/request_v2.rs 新文件
新增 src/llm/types/response_v2.rs 新文件
新增 src/llm/types/old_stream.rs 新文件(从 stream.rs 迁移旧 StreamEvent 变体)
追加 src/llm/types/mod.rs 追加 pub mod 声明
修改 src/llm/provider.rs LlmProvider trait 签名
修改 src/agent/builder.rs 更新 StubProvider 实现
修改 src/llm/stream.rs 将旧 StreamEvent 枚举替换为对 response_v2::StreamEvent 的重新导出
修改 src/llm/provider/openai.rs 修改:添加临时桥接实现(MessageRequest → ChatRequest 转换 + ChatResponse → MessageResponse 转换),Phase 1 重写时移除
修改 src/llm/hooks.rs 更新 HookContext.request 类型为 &'a MessageRequest
修改 src/llm/cycle.rs 更新调用点(build_requestsubmitsubmit_streamsubmit_messagessubmit_request 的类型引用和返回值)
修改 src/llm/cycle/retry.rs 如有对新 LlmError 类型的引用,同步适配
修改 src/agent/error.rssrc/agent/runtime.rssrc/agent/session.rs 如有对 LlmCycle 返回值或 ChatResponse 的引用,同步适配(具体文件由编译错误定位)

具体任务

  1. 新增 src/llm/types/message.rs,定义 Message 扁平大枚举 + ContentBlock + ContentBlockType
  2. 将 9b 中的 ContentBlock 变体(Text, Image, Audio, File, ToolUse, ToolResult, Thinking, Extension)及其辅助类型(ImageSourceAudioSourceFileSource)定义到 message.rs
  3. 新增 src/llm/types/request_v2.rs,定义 MessageRequest(从 9b 移植)+ ExtraError + extra 访问方法(get_extraget_extra_optget_extra_asset_extra
  4. 新增 src/llm/types/response_v2.rs,定义 MessageResponse + StreamEvent(高精度版,MessageComplete 只含 full_response: MessageResponse+ PartialUsage + PartialMessageResponse + apply_to + finalize
  5. 新类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证、PartialMessageResponse.apply_to + finalize 汇聚一致性测试
  6. 修改 src/llm/provider.rsLlmProvider trait 签名改为 chat(MessageRequest) → Result<MessageResponse, LlmError>chat_stream(MessageRequest) → Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
  7. 修改 src/agent/builder.rs:更新 StubProvider 实现以匹配新 trait 签名
  8. 修改 src/llm/cycle.rs
    • build_request():将已有的 Vec<OpenaiChatMessage> 转换为 Vec<Message>(通过 chat_message → message 映射函数),构造 MessageRequest
    • submit() / submit_messages():返回 Result<MessageResponse, LlmError>
    • submit_stream():返回 Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>
    • 流处理循环:由消费 OpenaiChatChunk 改为消费 StreamEvent。流结束处的 full_response 暂不使用(Phase 2 才启用简化逻辑),先提取 stop_reasonmessage 构建传统返回
  9. 新增文件 src/llm/types/old_stream.rs(从 src/llm/stream.rs 迁移旧 StreamEvent 定义),同时将 src/llm/stream.rs 中的旧 StreamEvent 枚举替换为对 response_v2.rs 中新 StreamEvent 的重新导出(pub use super::types::response_v2::StreamEvent;),确保 stream.rsparse_chunk_stream()ChunkToEventStream 继续编译通过
  10. 修改 src/llm/provider/openai.rs:添加 LlmProvider trait 临时桥接实现——
    • chat()MessageRequest → ChatRequest(利用现有 OpenaiChatMessage 转换)→ 调用已有 chat_inner()ChatResponse → MessageResponse(使用 finalize() 算法或直接映射)
    • chat_stream()MessageRequest → ChatRequest → 调用已有 chat_stream_inner() → 将 OpenaiChatChunk 流映射为 StreamEvent 流(利用已有的 parse_chunk_stream
    • 桥接实现标记 // ponytail: Phase 0 临时桥接,Phase 1 重写时移除
  11. 测试适配(编译驱动,涉及文件不限于以下列表,由编译器报错定位):
    • src/llm/cycle.rs 测试模块(MockProviderassistant_text_response()assistant_tool_call_response()、各测试用例中的断言类型)
    • src/agent/session.rs 测试模块(MockProvider、响应构造 helper 等)
    • src/agent/builder.rs 测试模块(StubProvider 已单独由任务 7 处理)
    • src/agent/session_memory.rssrc/agent/runtime.rs
  12. 编译驱动适配:对上游(agent/session.rsagent/runtime.rsagent/error.rs 等)中引用旧类型的地方,逐一按编译错误修复

验证cargo test 全部通过。git diff 确认新增和修改文件范围符合预期。确认 OpenaiProvider 的临时桥接代码带有 // ponytail: Phase 0 临时桥接 注释,Phase 1 移除时易于定位。

Phase 1Provider 适配

前置条件Phase 0 已完成,LlmProvider trait 签名已切换为 chat(MessageRequest) → MessageResponse。本 Phase 直接实现新 Provider,无需再处理 trait 兼容性。

目标:重写 OpenaiProvider(使用新类型),新增 AnthropicProvider。DeepSeek/Qwen 作为 OpenAI-compatible 协议实现一并纳入。

涉及文件

  • src/llm/provider.rs — 修改 create_provider 工厂函数,匹配新的 ProviderType enum
  • 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 协议)
  • 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 / QwenProviderOpenAI-compatible):
    • 共享 OpenaiChatProvider/chat/completions 协议
    • 代码复用策略实施时决定(推荐:OpenaiChatProvider 参数化为 GenericOpenaiProvider { base_url, api_key, model, provider_name }DeepSeek/Qwen 共用同一实现,仅配置不同;备选:trait 组合提取 HTTP 请求逻辑为可复用组件)
    • 差异化处理:max_tokens 字段名(部分兼容端点使用 max_tokens 而非 max_completion_tokens)、错误格式(非标准 error body 解析)
  5. ProviderRegistryregister_with_config()create_provider() 适配新 enum
  6. OpenAI Response APIProviderType::OpenaiResponse)实现范围说明:本 Phase 的 OpenaiResponseProvider 只覆盖核心对话能力(models response 创建、流式)、工具调用。内置工具(web_searchfile_search)、previous_response_id 续写、store 等 Response API 独有特性通过 MessageRequest.extra 传递(参考 9b 的 extra key 约定表),内置工具的完整支持延后。如果资源有限,OpenaiResponseProvider 可延迟到 Phase 2 之后开发,不影响其他 Provider。

验证

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

HTTP mock 策略

  • 推荐使用 wiremock crate(项目尚无 HTTP mock 依赖)
  • 每个 Provider 的测试模块中,用 MockServer 启动 mock 服务端,返回预定义请求/流式响应
  • OpenaiProvider 的 mock 端点为 /chat/completionsSSE 流或 JSON 响应)
  • AnthropicProvider 的 mock 端点为 /v1/messagesSSE 事件序列)
  • 测试不依赖真实网络,base_url 指向 mock_server.uri()

Phase 2LlmCycle 简化(逻辑重构)

说明Phase 0 已完成 LlmCycle 的"类型迁移"trait 签名、build_request 转换层、返回值类型)。Phase 2 聚焦逻辑简化——去掉 Phase 0 遗留的临时转换层,利用新类型的表达能力重写 LlmCycle 核心逻辑。

目标

  • LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换为 Vec<Message>移除 Phase 0 引入的 OpenaiChatMessage → Message 转换层
  • 流处理循环重构:利用 MessageComplete.full_response 直接拿到完整响应,去掉手动 delta 累积
  • 工具循环清洗:从 MessageResponse.message 的 content 中直接提取 ContentBlock::ToolUse
  • compact.rs 适配新 Message 类型

涉及文件

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

具体任务

  1. self.messagesVec<OpenaiChatMessage> 改为 Vec<Message>,移除 build_request() 中的类型转换步骤
  2. build_request() 直接构建 MessageRequestmessages 直接传入 self.messages),不再手动插入 system prompt(从 messages 中取 Message::System
  3. submit() / submit_messages():已返回 MessageResponse,无需改签名。检查调用方是否直接解构 MessageResponse 是正确的
  4. submit_stream():流处理循环中锚定 MessageComplete.full_response,拿到完整的 MessageResponse 后直接继续 tool 循环或结束。去掉中间状态的维护
  5. tool 循环:从 MessageResponse.messageAssistant { content } 中提取 ContentBlock::ToolUse 变体
  6. compact.rs 适配:microcompact() / should_compact() 的操作对象从 OpenaiChatMessage 改为 Message,按 text block 长度计算 token 数
  7. 清理 Phase 0 引入的临时转换函数(chat_message_to_messagemessage_to_chat_message 等),确认不再被引用后删除

验证

  • LlmCycle 集成测试全部通过
  • 多轮对话 + 工具调用的端到端流程正常
  • git diff 确认 Phase 0 引入的临时转换函数已被删除

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 阈值后消息被正确压缩
向后兼容(已有代码) 编译检查 Phase 0 修改 LlmProvider trait + LlmCycle 调用点 + StubProvider 后,cargo test 全部通过。git diff 只涉及预期变更的文件,无意外修改

6. 回滚方案

由于项目尚无外部消费者,回滚策略比较简单。每个 Phase 结束时打 tag 作为 checkpoint,允许跳跃回退。

阶段 触发条件 操作
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 切换
跨阶段回退 Phase 2 发现 Phase 0 类型设计有误 回退至 Phase 0 checkpointtypes-v2-prototype),在不动已有文件的前提下直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前

风险储备

  • 如果 OpenaiProvider 的重写复杂度过高,可以保留旧的 OpenaiProvider 不变,在旁边新增一个 OpenaiProviderV2 并行开发
  • ChatRequest / ChatResponse / Message / ContentBlock / ToolDefinition / StopReason 等类型别名和旧类型结构体的弃用路径:
    • Phase 0 完成时:旧别名保留(作为编译桥接),新类型通过不同路径(request_v2::MessageRequestresponse_v2::MessageResponse)访问,两者同时存在于类型模块中
    • Phase 1 完成时:Provider 实现切换到新类型,旧 OpenaiProvider 的临时桥接代码被 Phase 1 的真实实现替换。旧别名仍由 src/llm/types/mod.rs 导出,不影响其他模块
    • Phase 2 完成时LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换到 Vec<Message>,所有 ChatRequest/ChatResponse 引用被替换。此时对 ChatRequestChatResponseMessageContentBlockToolDefinitionStopReason 等旧别名和 ChatResponse 结构体加 #[deprecated] 标记
    • 下一个版本(v0.2.0 或 v1.0.0:运行 cargo check 确认无外部引用后,删除所有 deprecated 别名和 ChatResponse 结构体

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 上工作,类型不变、无需修改,但在集成测试中验证)
  • Message::ToolResult 命名 — 9b 中叫 Tool(对应 OpenAI 的 tool role),本设计改为 ToolResult。Anthropic 没有独立的 tool roletool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性