feat(docs): 新增 LLM Provider 重构方案文档

This commit is contained in:
徐涛
2026-06-25 23:07:22 +08:00
parent f639229d09
commit ac7fca8b40
+399
View File
@@ -0,0 +1,399 @@
# 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` 汇聚算法**继续有效**
> - 本文档仅记录**本次确认的修订内容和执行计划**
---
## 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-based**`ProviderType` enum + exhaustive match),不做动态注册 | 当前协议数量可控,编译期安全,无运行时查表开销 |
| 项目阶段 | 9 系是"推演中" | **可直接执行**,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
---
## 2. 本次修订的 5 项设计决策
### 2.1 Decision-01Message 采用扁平大枚举
#### 定义
```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-02LlmProvider 感知消息类型
沿用 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-03StreamEvent 高精度 + 终端事件携带完整响应
沿用 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 携带完整响应快照
// ═══════════════════════════════════════════════════════
/// 消息完成。
///
/// `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_response``LlmCycle``AgentSession` 只需要监听 `MessageComplete` 事件,拿到快照后直接继续 tool 循环或返回给调用方。
2. **与 PartialMessageResponse 保持一致**`PartialMessageResponse::finalize()` 产生的 `MessageResponse` 就是 `full_response` 的值。Provider 内部的汇聚逻辑不变,只是在发出 `MessageComplete` 时多传一个已完成构建的最终结果。
3. **零额外开销**`MessageResponse` 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
#### 对 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 的流处理循环在发出 `MessageComplete` 时,提前调用 `finalize()` 取得 `MessageResponse` 并填入事件:
```rust
// 伪代码: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.message`Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
### 2.5 Decision-05Provider 发现使用 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.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` — 新文件,定义 `Message``ContentBlock``ContentBlockType`
- `src/llm/types/request.rs` — 修改 `ChatRequest = OpenaiChatRequest``type 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. 扩展 `StreamEvent``MessageComplete` 增加 `full_response: MessageResponse`
4. 确认 `ContentBlock``ImageSource``ToolDefinition``PartialUsage` 等辅助类型在 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()``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`:与 `OpenaiChatProvider` 共享相同的 `/chat/completions` 协议,通过参数化或 trait 组合复用代码
5. `ProviderRegistry``register_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.message`Assistant)的 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 计算逻辑适配新类型(当前 `CostTracker``Usage` 上工作,类型不变、无需修改,但在集成测试中验证)