28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
516 lines
33 KiB
Markdown
516 lines
33 KiB
Markdown
# 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 采用扁平大枚举
|
||
|
||
#### 定义
|
||
|
||
```rust
|
||
/// 跨 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 中。
|
||
|
||
#### 便捷构造函数
|
||
|
||
```rust
|
||
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 设计,无修订。
|
||
|
||
```rust
|
||
#[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 事件
|
||
|
||
```rust
|
||
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 },
|
||
}
|
||
```
|
||
|
||
#### 设计理由
|
||
|
||
1. **简化消费方**:`LlmCycle::submit_stream()` 目前需要在 `while let` 循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了 `full_response`,`LlmCycle` 或 `AgentSession` 只需要监听 `MessageComplete` 事件,拿到快照后直接继续 tool 循环或返回给调用方。
|
||
2. **与 PartialMessageResponse 保持一致**:`PartialMessageResponse::finalize()` 产生的 `MessageResponse` 就是 `full_response` 的值。Provider 内部的汇聚逻辑不变,只是在发出 `MessageComplete` 时多传一个已完成构建的最终结果。
|
||
3. **零额外开销**:`MessageResponse` 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
|
||
4. **消除冗余**:9c 原有设计同时保留了顶层 `stop_reason`、`thinking_signature` 和 `full_response` 中的相同信息,造成消费方疑惑。本次修订只保留 `full_response` 为唯一信源。
|
||
|
||
#### 对 PartialMessageResponse 的影响
|
||
|
||
```rust
|
||
// 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` 内部状态:
|
||
|
||
```rust
|
||
// 伪代码: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 模式,但扩展其覆盖范围。
|
||
|
||
```rust
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ProviderType {
|
||
OpenaiChat,
|
||
OpenaiResponse,
|
||
Anthropic,
|
||
DeepSeek,
|
||
Qwen,
|
||
}
|
||
```
|
||
|
||
工厂函数 `create_provider()` 做 exhaustive match:
|
||
|
||
```rust
|
||
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 实现可能作为内部转换目标继续引用
|
||
- `LlmProvider` trait 签名由 `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 执行):
|
||
|
||
1. `response_v2.rs` 中的新 `StreamEvent` 是唯一的 `StreamEvent` 定义
|
||
2. `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举**替换为**重新导出语句:`pub use super::types::response_v2::StreamEvent;`
|
||
3. 旧 `StreamEvent` 的变体(`AssistantTextDelta`、`ToolExecutionStarted`、`TurnComplete`)**暂时保留**为一个独立的枚举(命名为 `LegacyStreamEvent`)放在 `src/llm/types/old_stream.rs` 新文件中,供 `stream.rs` 中的 `parse_chunk_stream()` 内部使用
|
||
4. 这样 `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` 的引用,同步适配(具体文件由编译错误定位) |
|
||
|
||
**具体任务**:
|
||
1. 新增 `src/llm/types/message.rs`,定义 `Message` 扁平大枚举 + `ContentBlock` + `ContentBlockType`
|
||
2. 将 9b 中的 `ContentBlock` 变体(`Text`, `Image`, `Audio`, `File`, `ToolUse`, `ToolResult`, `Thinking`, `Extension`)及其辅助类型(`ImageSource`、`AudioSource`、`FileSource`)定义到 `message.rs` 中
|
||
3. 新增 `src/llm/types/request_v2.rs`,定义 `MessageRequest`(从 9b 移植)+ `ExtraError` + extra 访问方法(`get_extra`、`get_extra_opt`、`get_extra_as`、`set_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.rs`:`LlmProvider` 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_reason` 和 `message` 构建传统返回
|
||
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.rs` 的 `parse_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` 测试模块(`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` 等
|
||
12. 编译驱动适配:对上游(`agent/session.rs`、`agent/runtime.rs`、`agent/error.rs` 等)中引用旧类型的地方,逐一按编译错误修复
|
||
|
||
**验证**:`cargo test` 全部通过。`git diff` 确认新增和修改文件范围符合预期。确认 `OpenaiProvider` 的临时桥接代码带有 `// ponytail: Phase 0 临时桥接` 注释,Phase 1 移除时易于定位。
|
||
|
||
### Phase 1:Provider 适配
|
||
|
||
> **前置条件**: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()`:`MessageRequest` → `OpenaiChatRequest`(serde 序列化)→ HTTP POST → 解析 `OpenaiChatResponse` → `MessageResponse`(通过 `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`(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 解析)
|
||
5. `ProviderRegistry` 的 `register_with_config()` 和 `create_provider()` 适配新 enum
|
||
6. **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 策略**:
|
||
- 推荐使用 [`wiremock`](https://crates.io/crates/wiremock) crate(项目尚无 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` 类型
|
||
|
||
**具体任务**:
|
||
1. `self.messages` 从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,移除 `build_request()` 中的类型转换步骤
|
||
2. `build_request()` 直接构建 `MessageRequest`(`messages` 直接传入 `self.messages`),不再手动插入 system prompt(从 messages 中取 `Message::System`)
|
||
3. `submit()` / `submit_messages()`:已返回 `MessageResponse`,无需改签名。检查调用方是否直接解构 `MessageResponse` 是正确的
|
||
4. `submit_stream()`:流处理循环中锚定 `MessageComplete.full_response`,拿到完整的 `MessageResponse` 后直接继续 tool 循环或结束。去掉中间状态的维护
|
||
5. tool 循环:从 `MessageResponse.message` 的 `Assistant { content }` 中提取 `ContentBlock::ToolUse` 变体
|
||
6. `compact.rs` 适配:`microcompact()` / `should_compact()` 的操作对象从 `OpenaiChatMessage` 改为 `Message`,按 text block 长度计算 token 数
|
||
7. 清理 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` 结构体
|
||
|
||
---
|
||
|
||
## 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 的 `tool` role),本设计改为 `ToolResult`。Anthropic 没有独立的 `tool` role(tool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性
|