将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
33 KiB
LLM Provider 重构改进方案(最终确认)
本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(
9-llm-provider-unified-interface.md及9a-9g子文档)中已有设计的精炼与修订。阅读前提:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。
与 9 系的关系:
- 9 系文档中的
ContentBlock、MessageRequest、MessageResponse、StopReason、ThinkingConfig、ToolDefinition、PartialUsage等核心类型定义继续有效,本文档不再重复ProviderCapabilities、LlmProvider 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-based(ProviderType enum + exhaustive match),不做动态注册 |
当前协议数量可控,编译期安全,无运行时查表开销 |
| 项目阶段 | 9 系是"推演中" | 可直接执行,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
2. 本次修订的 5 项设计决策
2.1 Decision-01:Message 采用扁平大枚举
定义
/// 跨 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 合并为同一变体 |
User 和 UserImage 拆开,方便消费方 match(无需检查 Vec 内容来区分文字和图片) |
与 9b 文档的关系:9b 的
Message::System、Message::User、Message::Assistant、Message::Tool四个变体分类保留, 但User的图片输入场景通过新增UserImage变体提供便捷路径,减少 boilerplate。Message::Assistant的content: 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-02:LlmProvider 感知消息类型
沿用 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-03:StreamEvent 高精度 + 终端事件携带完整响应
沿用 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 signature(message_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 },
}
设计理由
- 简化消费方:
LlmCycle::submit_stream()目前需要在while let循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了full_response,LlmCycle或AgentSession只需要监听MessageComplete事件,拿到快照后直接继续 tool 循环或返回给调用方。 - 与 PartialMessageResponse 保持一致:
PartialMessageResponse::finalize()产生的MessageResponse就是full_response的值。Provider 内部的汇聚逻辑不变,只是在发出MessageComplete时多传一个已完成构建的最终结果。 - 零额外开销:
MessageResponse在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。 - 消除冗余:9c 原有设计同时保留了顶层
stop_reason、thinking_signature和full_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_reason和thinking_signature字段, 供apply_to()设置PartialMessageResponse的内部状态。本次修订移除这些冗余字段后,thinking_signature改为由 Provider 直接调用partial.set_thinking_signature()写入内部状态,stop_reason则在finalize()中统一定于full_response.stop_reason。
2.4 Decision-04:LlmCycle 简化
沿用 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 分支(
System、User、Assistant)的基本逻辑不变实施 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.message(Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
2.5 Decision-05:Provider 发现使用 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.rs、request_v2.rs、response_v2.rs),不堆积到已有类型文件 - 已有的
request.rs(OpenaiChatRequest)、response.rs(OpenaiChatResponse)保留原样,后续 Provider 实现可能作为内部转换目标继续引用 LlmProvidertrait 签名由chat(ChatRequest) → ChatResponse切换为chat(MessageRequest) → MessageResponse,在同一个 Phase 内完成(见下方任务 6‒8)- trait 签名变更导致的编译错误(
StubProvider、LlmCycle调用点)在 Phase 0 内全部修复,不留到 Phase 1 AgentSession等上游中对LlmCycle.submit()返回值的引用同步适配
StreamEvent 命名冲突处理:
新 StreamEvent(高精度版)定义在 src/llm/types/response_v2.rs 中。
旧 StreamEvent(src/llm/stream.rs 中定义)的变体(AssistantTextDelta、ToolExecutionStarted、TurnComplete 等)与新类型冲突。
处理方式(任务 9 执行):
response_v2.rs中的新StreamEvent是唯一的StreamEvent定义src/llm/stream.rs中的旧StreamEvent枚举替换为重新导出语句:pub use super::types::response_v2::StreamEvent;- 旧
StreamEvent的变体(AssistantTextDelta、ToolExecutionStarted、TurnComplete)暂时保留为一个独立的枚举(命名为LegacyStreamEvent)放在src/llm/types/old_stream.rs新文件中,供stream.rs中的parse_chunk_stream()内部使用 - 这样
stream.rs的辅助函数继续编译,LlmCycle和AgentSession看到的是新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_request、submit、submit_stream、submit_messages、submit_request 的类型引用和返回值) |
| 修改 | src/llm/cycle/retry.rs |
如有对新 LlmError 类型的引用,同步适配 |
| 修改 | src/agent/error.rs、src/agent/runtime.rs、src/agent/session.rs 等 |
如有对 LlmCycle 返回值或 ChatResponse 的引用,同步适配(具体文件由编译错误定位) |
具体任务:
- 新增
src/llm/types/message.rs,定义Message扁平大枚举 +ContentBlock+ContentBlockType - 将 9b 中的
ContentBlock变体(Text,Image,Audio,File,ToolUse,ToolResult,Thinking,Extension)及其辅助类型(ImageSource、AudioSource、FileSource)定义到message.rs中 - 新增
src/llm/types/request_v2.rs,定义MessageRequest(从 9b 移植)+ExtraError+ extra 访问方法(get_extra、get_extra_opt、get_extra_as、set_extra) - 新增
src/llm/types/response_v2.rs,定义MessageResponse+StreamEvent(高精度版,MessageComplete只含full_response: MessageResponse)+PartialUsage+PartialMessageResponse+apply_to+finalize - 新类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证、
PartialMessageResponse.apply_to + finalize汇聚一致性测试 - 修改
src/llm/provider.rs:LlmProvidertrait 签名改为chat(MessageRequest) → Result<MessageResponse, LlmError>、chat_stream(MessageRequest) → Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> - 修改
src/agent/builder.rs:更新StubProvider实现以匹配新 trait 签名 - 修改
src/llm/cycle.rs:build_request():将已有的Vec<OpenaiChatMessage>转换为Vec<Message>(通过chat_message → message映射函数),构造MessageRequestsubmit()/submit_messages():返回Result<MessageResponse, LlmError>submit_stream():返回Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>- 流处理循环:由消费
OpenaiChatChunk改为消费StreamEvent。流结束处的full_response暂不使用(Phase 2 才启用简化逻辑),先提取stop_reason和message构建传统返回
- 新增文件
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.rs的parse_chunk_stream()和ChunkToEventStream继续编译通过 - 修改
src/llm/provider/openai.rs:添加LlmProvidertrait 临时桥接实现——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 重写时移除
- 测试适配(编译驱动,涉及文件不限于以下列表,由编译器报错定位):
src/llm/cycle.rs测试模块(MockProvider、assistant_text_response()、assistant_tool_call_response()、各测试用例中的断言类型)src/agent/session.rs测试模块(MockProvider、响应构造 helper 等)src/agent/builder.rs测试模块(StubProvider已单独由任务 7 处理)src/agent/session_memory.rs、src/agent/runtime.rs等
- 编译驱动适配:对上游(
agent/session.rs、agent/runtime.rs、agent/error.rs等)中引用旧类型的地方,逐一按编译错误修复
验证:cargo test 全部通过。git diff 确认新增和修改文件范围符合预期。确认 OpenaiProvider 的临时桥接代码带有 // ponytail: Phase 0 临时桥接 注释,Phase 1 移除时易于定位。
Phase 1:Provider 适配
前置条件:Phase 0 已完成,
LlmProvidertrait 签名已切换为chat(MessageRequest) → MessageResponse。本 Phase 直接实现新 Provider,无需再处理 trait 兼容性。
目标:重写 OpenaiProvider(使用新类型),新增 AnthropicProvider。DeepSeek/Qwen 作为 OpenAI-compatible 协议实现一并纳入。
涉及文件:
src/llm/provider.rs— 修改create_provider工厂函数,匹配新的ProviderTypeenumsrc/llm/provider/registry.rs— 适配新LlmProvidertrait(改动极小,只是类型变化)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— 新文件(同上)
具体任务:
OpenaiProvider内部chat():MessageRequest→OpenaiChatRequest(serde 序列化)→ HTTP POST → 解析OpenaiChatResponse→MessageResponse(通过finalize()算法或直接映射)OpenaiProvider内部chat_stream():同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出StreamEventAnthropicProvider:实现 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
- Messages API 请求体构建(
DeepSeekProvider/QwenProvider(OpenAI-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 解析)
- 共享
ProviderRegistry的register_with_config()和create_provider()适配新 enum- OpenAI Response API(
ProviderType::OpenaiResponse)实现范围说明:本 Phase 的OpenaiResponseProvider只覆盖核心对话能力(models response 创建、流式)、工具调用。内置工具(web_search、file_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 策略:
- 推荐使用
wiremockcrate(项目尚无 HTTP mock 依赖) - 每个 Provider 的测试模块中,用
MockServer启动 mock 服务端,返回预定义请求/流式响应 OpenaiProvider的 mock 端点为/chat/completions(SSE 流或 JSON 响应)AnthropicProvider的 mock 端点为/v1/messages(SSE 事件序列)- 测试不依赖真实网络,
base_url指向mock_server.uri()
Phase 2:LlmCycle 简化(逻辑重构)
说明: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类型
具体任务:
self.messages从Vec<OpenaiChatMessage>改为Vec<Message>,移除build_request()中的类型转换步骤build_request()直接构建MessageRequest(messages直接传入self.messages),不再手动插入 system prompt(从 messages 中取Message::System)submit()/submit_messages():已返回MessageResponse,无需改签名。检查调用方是否直接解构MessageResponse是正确的submit_stream():流处理循环中锚定MessageComplete.full_response,拿到完整的MessageResponse后直接继续 tool 循环或结束。去掉中间状态的维护- tool 循环:从
MessageResponse.message的Assistant { content }中提取ContentBlock::ToolUse变体 compact.rs适配:microcompact()/should_compact()的操作对象从OpenaiChatMessage改为Message,按 text block 长度计算 token 数- 清理 Phase 0 引入的临时转换函数(
chat_message_to_message、message_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 1(Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 unimplemented!()(当前状态),不影响其他 Provider |
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag providers-v2-prototype |
| Phase 2(LlmCycle 简化) | 循环逻辑或 compact 出现问题 | 保留旧 LlmCycle 实现(不改文件名),通过 feature flag 切换 |
| 跨阶段回退 | Phase 2 发现 Phase 0 类型设计有误 | 回退至 Phase 0 checkpoint(types-v2-prototype),在不动已有文件的前提下直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前 |
风险储备:
- 如果
OpenaiProvider的重写复杂度过高,可以保留旧的OpenaiProvider不变,在旁边新增一个OpenaiProviderV2并行开发 ChatRequest/ChatResponse/Message/ContentBlock/ToolDefinition/StopReason等类型别名和旧类型结构体的弃用路径:- Phase 0 完成时:旧别名保留(作为编译桥接),新类型通过不同路径(
request_v2::MessageRequest、response_v2::MessageResponse)访问,两者同时存在于类型模块中 - Phase 1 完成时:Provider 实现切换到新类型,旧
OpenaiProvider的临时桥接代码被 Phase 1 的真实实现替换。旧别名仍由src/llm/types/mod.rs导出,不影响其他模块 - Phase 2 完成时:
LlmCycle内部消息存储从Vec<OpenaiChatMessage>切换到Vec<Message>,所有ChatRequest/ChatResponse引用被替换。此时对ChatRequest、ChatResponse、Message、ContentBlock、ToolDefinition、StopReason等旧别名和ChatResponse结构体加#[deprecated]标记 - 下一个版本(v0.2.0 或 v1.0.0):运行
cargo check确认无外部引用后,删除所有 deprecated 别名和ChatResponse结构体
- Phase 0 完成时:旧别名保留(作为编译桥接),新类型通过不同路径(
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 计算逻辑适配新类型(当前
CostTracker在Usage上工作,类型不变、无需修改,但在集成测试中验证) Message::ToolResult命名 — 9b 中叫Tool(对应 OpenAI 的toolrole),本设计改为ToolResult。Anthropic 没有独立的toolrole(tool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性