docs(llm): 新增 LLM Provider 统一接口设计文档

This commit is contained in:
徐涛
2026-06-15 23:07:33 +08:00
parent 829be90d19
commit 8686a2e1d6
8 changed files with 1303 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
# LLM Provider 统一接口设计(方案 C)
> 本文档已被拆分为独立的子文档以便深入推演和修改。以下保留背景与架构总览作为索引。
>
> **拆分日期**2026-06-15
> **拆分方式**:原 §1-§2 保留在本文件,§3-§13 移至 `9a`-`9g` 子文档。
> 所有"待深入推演"议题保留在对应子文档的原文位置。
---
## 1. 背景与目标
### 1.1 当前状态
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
```rust
pub type ChatRequest = OpenaiChatRequest; // 类型别名
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
pub type Message = OpenaiChatMessage;
```
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
| API 类型 | 兼容性 | 代价 |
|----------|--------|------|
| OpenAI ChatDeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
### 1.2 目标
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
4. 新 Provider 只需实现一次双向映射即可接入
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
### 1.3 非目标
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
---
## 2. 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ 上 层(AgentSession / TaskAgent
│ 只与 IR 类型和 LlmProvider trait 交互 │
├──────────────────────────────────────────────────────────────┤
│ LlmCycle │
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
│ 内部使用 Vec<Message> + MessageRequest │
├──────────────────────────────────────────────────────────────┤
│ LlmProvider trait(核心接口) │
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
│ capabilities() → ProviderCapabilities │
├────────────────┬──────────────────────┬──────────────────────┤
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
│ JSON │ JSON │ (兼容 Chat API) │
└────────────────┴──────────────────────┴──────────────────────┘
```
### 2.1 分层原则
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
---
## 文件索引
### 核心设计
| 文件 | 内容 | 包含待深入推演 |
|------|------|---------------|
| [9a-background-and-architecture.md](9a-background-and-architecture.md) | §1 背景与目标 + §2 架构总览 | — |
| [9b-ir-type-system.md](9b-ir-type-system.md) | §3 IR 类型体系:[ContentBlock](9b-ir-type-system.md#31-contentblock--最小的内容单元)、[Message](9b-ir-type-system.md#32-message--统一消息类型)、[StopReason](9b-ir-type-system.md#33-stopreason--统一停止原因)、[MessageRequest](9b-ir-type-system.md#36-messagerequest--统一请求)、[MessageResponse](9b-ir-type-system.md#37-messageresponse--统一响应) | ToolResult 嵌套约束、extra 类型安全性 |
| [9c-llm-provider-trait.md](9c-llm-provider-trait.md) | §4 LlmProvider Trait[核心接口](9c-llm-provider-trait.md#41-核心接口)、[ProviderCapabilities](9c-llm-provider-trait.md#42-providercapabilities)、[StreamEvent](9c-llm-provider-trait.md#43-流式事件-streamevent) | StreamEvent 汇聚算法、ToolCallStart index 归一化 |
| [9d-provider-implementations.md](9d-provider-implementations.md) | §5 Provider 实现:[OpenAI](9d-provider-implementations.md#51-openai-provider兼容-chat-api)、[Anthropic](9d-provider-implementations.md#52-anthropicprovidermessages-api)、[Response API](9d-provider-implementations.md#53-openai-response-api草案)、[DeepSeek/Qwen](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | OpenAI 流式转换、Anthropic 流式状态机、Response API 映射、DeepSeek/Qwen 落地 |
| [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) | §6-§8LlmCycle 改造(build_request、tool 循环、submit_stream、compact+ 上层影响 + 兼容策略 | system prompt 双重表达冲突、compact 适配 |
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | Thinking signature 端到端传递 |
### 辅助参考
| 文件 | 内容 |
|------|------|
| [9g-risk-and-migration.md](9g-risk-and-migration.md) | §10 风险评估 + §11 类型差异总结 + §12 迁移路径(4 Phase+ §13 验收标准(A1-A10 |
### 待深入推演完整清单
| # | 议题 | 所在文件 | 优先级 |
|---|------|---------|--------|
| 1 | ToolResult 嵌套约束 | [9b-ir-type-system.md](9b-ir-type-system.md#31-contentblock--最小的内容单元) | 低 |
| 2 | extra 的类型安全性 | [9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求) | 中 |
| 3 | StreamEvent 汇聚为 MessageResponse 算法 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | **高** |
| 4 | ToolCallStart index 归一化 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | 中 |
| 5 | OpenAI 流式转换实现 | [9d-provider-implementations.md](9d-provider-implementations.md#51-openai-provider兼容-chat-api) | **高** |
| 6 | Anthropic 流式状态机设计 | [9d-provider-implementations.md](9d-provider-implementations.md#52-anthropicprovidermessages-api) | **高** |
| 7 | OpenAI Response API 完整映射表 | [9d-provider-implementations.md](9d-provider-implementations.md#53-openai-response-api草案) | 低 |
| 8 | DeepSeek/Qwen Provider 落地策略 | [9d-provider-implementations.md](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | 低 |
| 9 | system prompt 双重表达冲突 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#62-build_request--新签名) | **高** |
| 10 | compact 在 IR 上的改法与 token 估算 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#66-compact-逻辑调整) | 中 |
| 11 | Thinking signature 端到端传递 | [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) | **高** |
+70
View File
@@ -0,0 +1,70 @@
# 背景与架构总览
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §1 背景与目标 + §2 架构总览。
## 1. 背景与目标
### 1.1 当前状态
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
```rust
pub type ChatRequest = OpenaiChatRequest; // 类型别名
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
pub type Message = OpenaiChatMessage;
```
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
| API 类型 | 兼容性 | 代价 |
|----------|--------|------|
| OpenAI ChatDeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
### 1.2 目标
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
4. 新 Provider 只需实现一次双向映射即可接入
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
### 1.3 非目标
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
---
## 2. 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ 上 层(AgentSession / TaskAgent
│ 只与 IR 类型和 LlmProvider trait 交互 │
├──────────────────────────────────────────────────────────────┤
│ LlmCycle │
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
│ 内部使用 Vec<Message> + MessageRequest │
├──────────────────────────────────────────────────────────────┤
│ LlmProvider trait(核心接口) │
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
│ capabilities() → ProviderCapabilities │
├────────────────┬──────────────────────┬──────────────────────┤
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
│ JSON │ JSON │ (兼容 Chat API) │
└────────────────┴──────────────────────┴──────────────────────┘
```
### 2.1 分层原则
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
+323
View File
@@ -0,0 +1,323 @@
# IR 类型体系
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §3 IR 类型体系。
>
> **相关文件:**
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — 使用本文定义的 IR 类型的 LlmProvider trait
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 的 IR ↔ 原生格式映射
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(内部使用 `Vec<Message>` + `MessageRequest`
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(Thinking 端到端、Multiple ContentBlock 等)
## 3. IR 类型体系
### 3.1 ContentBlock —— 最小的内容单元
```rust
/// 跨 Provider 统一的内容块。
///
/// 设计思路:
/// - ToolUse/ToolResult 作为 content blockAnthropic 风格)
/// - Thinking 作为一等公民
/// - Extension 作为逃生舱
#[derive(Debug, Clone)]
pub enum ContentBlock {
/// 纯文本
Text {
text: String,
},
/// 图片(base64 或 URL
Image {
source: ImageSource,
},
/// 音频输入
Audio {
source: AudioSource,
},
/// 文件上传
File {
source: FileSource,
},
/// 工具调用请求(由 Assistant 发起)
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
/// 工具调用结果(回传)
ToolResult {
tool_use_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
```
> **🔄 待深入推演:ToolResult 嵌套约束**
> `ContentBlock::ToolResult.content` 定义为 `Vec<ContentBlock>`,理论上可包含 Thinking、ToolUse 等任何类型。
> 但 Anthropic API 限制 `tool_result` 的 content 只能为 `text` 和 `image` 类型。OpenAI 的 tool 消息 content
> 也只能是字符串或 content parts(不含 tool_calls)。
> **需要推演:** IR 层是否需要施加同样的约束?是在类型系统层面约束还是运行时校验?
> 优先级:低(Phase 4 实现时可解决)
/// 思考内容(Anthropic thinking / OpenAI reasoning
Thinking {
text: String,
/// Anthropic 的 thinking 签名(用于验证思考未被篡改)。
/// OpenAI 无此概念,为 None。
signature: Option<String>,
},
/// 逃生舱 —— Provider 特有且无法映射的 content block
Extension {
kind: String,
data: serde_json::Value,
},
}
#[derive(Debug, Clone)]
pub struct ImageSource {
pub data: String, // base64 或 URL
pub mime_type: String, // "image/png", "image/jpeg", "image/webp"
pub is_url: bool, // true = URL, false = base64
}
#[derive(Debug, Clone)]
pub struct AudioSource {
pub data: String, // base64
pub format: AudioFormat, // 复用现有 AudioFormat
}
#[derive(Debug, Clone)]
pub struct FileSource {
pub data: String,
pub filename: Option<String>,
pub mime_type: Option<String>,
}
```
#### 设计决策:为什么把 ToolUse 放在 ContentBlock 中
| 维度 | OpenAI 风格(独立 tool_calls 字段) | Anthropic 风格(ContentBlock 内) |
|------|-----------------------------------|----------------------------------|
| Assistant 消息结构 | `{ content, tool_calls }` | `{ content: [text, tool_use, ...] }` |
| 文本与工具的顺序 | 分离,无法交错 | 按序排列,可交错 |
| 多工具表达 | `tool_calls: [...]` 数组 | content 中多个 `tool_use` block |
| **统一后的处理逻辑** | 需同时检查 content 和 tool_calls | **只需遍历一次 content blocks** |
选择 Anthropic 风格作为统一表达,因为遍历 content 即可获取所有信息,处理逻辑更简单。
### 3.2 Message —— 统一消息类型
```rust
#[derive(Debug, Clone)]
pub enum Message {
System {
content: Vec<ContentBlock>,
},
User {
content: Vec<ContentBlock>,
},
Assistant {
content: Vec<ContentBlock>,
// 注意:ToolUse 在 content 中,不需要独立字段
},
Tool {
content: Vec<ContentBlock>,
/// 关联的 tool_call_idOpenAI 格式需要)
tool_call_id: String,
},
}
```
#### 与当前 OpenaiChatMessage 的映射
| 当前类型 | 新 IR 类型 | 说明 |
|---------|-----------|------|
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 SystemAnthropic 无 Developer role |
| `OpenaiChatMessage::System { content, name }` | `Message::System { content }` | name 暂不保留 |
| `OpenaiChatMessage::User { content, name }` | `Message::User { content }` | `ContentField` 统一为 `Vec<ContentBlock>` |
| `OpenaiChatMessage::Assistant { content, tool_calls, refusal, name }` | `Message::Assistant { content }` | tool_calls → ContentBlock::ToolUserefusal → ContentBlock::Text |
| `OpenaiChatMessage::Tool { content, tool_call_id }` | `Message::Tool { content, tool_call_id }` | 基本一致 |
| `OpenaiChatMessage::Function { content, name }` | `Message::Tool { tool_call_id: name }` | 已废弃,映射到 Tool |
#### 便捷构造函数
```rust
impl Message {
pub fn user(text: impl Into<String>) -> 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>) -> Self;
}
```
### 3.3 StopReason —— 统一停止原因
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// 正常结束
Stop,
/// 达到 token 上限
Length,
/// 触发工具调用
ToolUse,
/// 被内容过滤
ContentFilter,
/// 达到最大 token 数
MaxTokens,
/// 命中停止序列
StopSequence,
/// 其他 / Provider 特有
Other,
}
```
#### 跨 Provider 映射
| IR StopReason | OpenAI (finish_reason) | Anthropic (stop_reason) |
|---|---|---|
| `Stop` | `"stop"` | `"end_turn"` |
| `Length` / `MaxTokens` | `"length"` | `"max_tokens"` |
| `ToolUse` | `"tool_calls"` | `"tool_use"` |
| `ContentFilter` | `"content_filter"` | — |
| `StopSequence` | — | `"stop_sequence"` |
| `Other` | `"function_call"` / 其他 | 其他 |
### 3.4 ThinkingConfig —— 思考配置
```rust
#[derive(Debug, Clone)]
pub struct ThinkingConfig {
/// 思考 token 预算
pub budget_tokens: u32,
}
```
跨 Provider 特性:Anthropic 原生支持,OpenAI 通过 `reasoning_tokens` 间接支持。
### 3.5 ToolDefinition —— 统一工具定义
```rust
#[derive(Debug, Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: Option<String>,
pub parameters: serde_json::Value,
pub strict: Option<bool>,
}
// ToolChoice 枚举保持不变
```
与当前 `OpenaiToolDefinition` 一致,不需要改动。
### 3.6 MessageRequest —— 统一请求
```rust
#[derive(Debug, Clone)]
pub struct MessageRequest {
// ════════════════════════════════════════════
// 核心共通参数(所有 Provider 都有的概念)
// ════════════════════════════════════════════
pub model: String,
pub messages: Vec<Message>,
pub system: Option<String>, // 顶层 system prompt
pub tools: Vec<ToolDefinition>,
pub tool_choice: ToolChoice,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub stop_sequences: Vec<String>,
pub stream: bool,
// ════════════════════════════════════════════
// 跨 Provider 但非全部支持
// ════════════════════════════════════════════
pub thinking: Option<ThinkingConfig>,
// ════════════════════════════════════════════
// 逃生舱:Provider 特有参数
// ════════════════════════════════════════════
/// Provider 特有的扩展参数。
/// 约定:每个 Provider 声明自己读取哪些 key
/// 遇到不认识的 key 静默忽略。
pub extra: HashMap<String, serde_json::Value>,
}
```
> **🔄 待深入推演:extra 的类型安全性**
> `get_extra<T: DeserializeOwned>` 在运行时通过 `serde_json::from_value` 反序列化,类型错误在
> 调用点才能被发现(返回 `None` 或 panic?)。这可能埋下隐蔽的 bug。
> **需要推演:**
> 1. 能否为每个 Provider 定义类型安全的 extra 结构体(如 `OpenaiExtra`, `AnthropicExtra`),
> 使用 `From<OpenaiExtra> for HashMap<String, Value>` 在传入时转换?
> 2. 如果保持运行时反序列化,`get_extra` 返回类型改为 `Result<T, ExtraError>` 而非 `Option<T>`
> 3. Provider 声明支持的 extra keys + 类型 + 默认值的规范格式?
> 优先级:中(Phase 2 实现前需解决)
#### extra 逃生舱的使用约定
- 每个 Provider 在文档中声明自己读取的 extra key
- Provider 遇到不认识的 key `静默忽略`
- 通过辅助方法安全访问:
```rust
impl MessageRequest {
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Option<T>;
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<Value>);
}
```
#### 典型 extra key 约定
| key | 值类型 | 使用者 | 说明 |
|-----|--------|--------|------|
| `frequency_penalty` | `f32` | OpenAI | 频率惩罚 |
| `presence_penalty` | `f32` | OpenAI | 存在惩罚 |
| `logit_bias` | `HashMap<String, f32>` | OpenAI | Token 偏置 |
| `seed` | `i64` | OpenAI | 随机种子 |
| `service_tier` | `String` | OpenAI | 服务等级 |
| `user` | `String` | OpenAI | 最终用户标识 |
| `response_format` | `ResponseFormat` | OpenAI | 响应格式 |
| `parallel_tool_calls` | `bool` | OpenAI | 是否并行工具 |
| `built_in_tools` | `Vec<String>` | OpenAI Response | 内置工具列表 |
| `previous_response_id` | `String` | OpenAI Response | 前序响应 ID |
### 3.7 MessageResponse —— 统一响应
```rust
#[derive(Debug, Clone)]
pub struct MessageResponse {
pub id: String,
pub model: String,
pub message: Message,
pub usage: Usage, // 复用现有 Usage 类型
pub stop_reason: StopReason,
/// Provider 特有扩展数据
pub extra: HashMap<String, serde_json::Value>,
}
impl MessageResponse {
/// 提取纯文本内容(拼接所有 Text block)
pub fn text(&self) -> String;
}
```
#### Usage 的跨 Provider 兼容性
当前 `Usage` 类型:
```rust
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub completion_tokens_details: Option<CompletionTokensDetails>,
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
| Provider | prompt_tokens | completion_tokens | 其他 | 映射方式 |
|----------|--------------|-------------------|------|---------|
| OpenAI | `usage.prompt_tokens` | `usage.completion_tokens` | `details.*` | 直接使用 |
| Anthropic | `usage.input_tokens` | `usage.output_tokens` | `cache_*` 映射到 `details.cached_tokens` | 映射赋值 |
`Usage` 类型可以直接复用,无需修改。
+140
View File
@@ -0,0 +1,140 @@
# LlmProvider Trait 设计
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §4 LlmProvider Trait 设计。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(MessageRequest、MessageResponse、StreamEvent 等)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — 各 Provider 的具体实现策略
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 等边界情况(与 StreamEvent 设计关联)
## 4. LlmProvider Trait 设计
### 4.1 核心接口
```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,
>;
/// 返回 Provider 的能力描述。
fn capabilities(&self) -> ProviderCapabilities;
}
```
### 4.2 ProviderCapabilities
```rust
#[derive(Debug, Clone)]
pub struct ProviderCapabilities {
/// Provider 标识名称
pub provider_name: &'static str,
/// 支持的模型列表(None = 不限制)
pub supported_models: Option<Vec<String>>,
/// 特性标记
pub features: ProviderFeatures,
}
#[derive(Debug, Clone, Default)]
pub struct ProviderFeatures {
pub streaming: bool,
pub thinking: bool,
pub vision: bool,
pub audio_input: bool,
pub tool_use: bool,
pub parallel_tool_calls: bool,
/// true=OpenAI风格(system in messages), false=Anthropic风格(system as param)
pub system_prompt_in_messages: bool,
pub max_context_window: u32,
}
```
#### capabilities() 的使用场景
1. **LlmCycle**:根据 `features.thinking` 决定是否启用 thinking 模式
2. **AgentBuilder**:在构建时校验模型是否支持所需特性
3. **UI/CLI**:展示 Provider 的能力矩阵
4. **智能路由**:根据能力自动选择最佳 Provider
### 4.3 流式事件 StreamEvent
```rust
/// 流式事件 —— 流式响应的语义化增量构建过程。
/// 一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse。
#[derive(Debug, Clone)]
pub enum StreamEvent {
// ── Meta ──
/// 消息开始
MessageStart { id: String, model: String },
// ── Content 增量 ──
/// 文本增量
TextDelta { text: String },
/// 思考增量(Anthropic thinking block
ThinkingDelta { text: String },
/// 拒绝增量(OpenAI refusal
RefusalDelta { text: String },
// ── Tool Call ──
/// 工具调用开始
ToolCallStart { index: u32, id: String, name: String },
/// 工具参数增量
ToolCallArgumentsDelta { index: u32, arguments: String },
/// 工具调用结束
ToolCallEnd { index: u32 },
// ── 汇总 ──
/// Token 用量
CostUpdate { usage: Usage },
/// 消息完成
MessageComplete { stop_reason: StopReason },
// ── 错误 ──
Error { message: String },
}
```
> **🔄 待深入推演:StreamEvent 汇聚为 MessageResponse 的算法**
> 文档提到"一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse",但没有给出具体实现。
> 这是上层做"既流式又完整"双模处理的关键基础设施。
> **需要推演:**
> 1. `PartialMessageResponse` 结构体设计(累积的状态:id, model, text_buffer, tool_calls 字典, usage, stop_reason 等)
> 2. `StreamEvent::apply_to(&self, state: &mut PartialMessageResponse) -> bool` 算法——每个事件类型如何更新状态
> 3. `PartialMessageResponse::finalize(self) -> MessageResponse` 的完成逻辑
> 4. 边界情况:MessageStart 到达前就收到 TextDelta 怎么办?ToolCallArgumentsDelta 到达顺序乱序?
> 多次 CostUpdate 是叠加还是覆盖?
> 优先级:高(Phase 3 流式功能依赖此设计)
> **🔄 待深入推演:ToolCallStart 的 index 来源与归一化**
> `ToolCallStart.index` 在不同 Provider 流式协议中的含义不同:
> - OpenAI SSE chunk 中 `delta.tool_calls[i].index` 是**该次 delta 的局部索引**(只含变动的 tool call),
> 需要调用方累积状态来跟踪每个 tool call 的完整索引
> - Anthropic 的 `content_block_start` 中的 `index` 是**全局的 content block 索引**(直接映射到最终 content 数组位置)
> **需要推演:**
> 1. IR 的 index 使用哪种语义?建议用"全局 tool call 序号"OpenAI Provider 内部从局部 index 映射到全局序号)
> 2. 当 OpenAI 并行返回多个 tool_calls(同一 chunk 含多个 `delta.tool_calls` 条目),如何分配 index
> 优先级:中(Phase 2 实现 OpenAI 流式转换时需解决)
#### 与当前 StreamEvent 的对比
| 当前 StreamEvent | 新 StreamEvent | 理由 |
|-----------------|---------------|------|
| `AssistantTextDelta { text }` | `TextDelta { text }` | 简洁化 |
| — | `ThinkingDelta { text }` | Anthropic 需要 |
| — | `RefusalDelta { text }` | OpenAI 需要 |
| `ToolExecutionStarted { tool_name, input, tool_call_id }` | `ToolCallStart { index, id, name }` + `ToolCallArgumentsDelta { index, arguments }` | 支持增量参数(Anthropic 的 tool_use 流式需要) |
| `ToolExecutionCompleted` | **移除** | 这是 LlmCycle 层的事件,非 Provider 层 |
| `CostUpdate { usage }` | 保留 | ✅ |
| `TurnComplete { reason }` | `MessageComplete { stop_reason }` | 语义更准确 |
| `Error { message }` | 保留 | ✅ |
| — | `MessageStart { id, model }` | Anthropic 需要 |
| — | `ToolCallEnd { index }` | 明确参数完整时间点 |
+204
View File
@@ -0,0 +1,204 @@
# Provider 实现策略
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §5 Provider 实现策略。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、Message、MessageRequest/Response 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait 定义(chat、chat_stream 签名)
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(system prompt 冲突等与 Provider 相关)
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 端到端传递(与 Anthropic 流式强相关)
## 5. Provider 实现策略
### 5.1 OpenaiProvider(兼容 Chat API
```
MessageRequest
├── model → model
├── messages → messages (逐条映射,见下方)
├── system → 插入为首条 System message(如无 System message 时)
├── tools → tools (OpenaiTool::Function)
├── tool_choice → tool_choice
├── max_tokens → max_tokens
├── temperature → temperature
├── top_p → top_p
├── stop_sequences → stop (StopSequence::Multiple)
├── thinking → 忽略(OpenAI 不支持)
└── extra.* → 对应字段 / extra_body
Message → OpenaiChatMessage:
System { content } → System { content: to_openai_content(content) }
User { content } → User { content: to_openai_content(content) }
Assistant { content } → Assistant {
content: to_openai_content(text_blocks),
tool_calls: extract_tool_calls(content)
}
Tool { content, tool_call_id } → Tool { content, tool_call_id }
ContentBlock → OpenaiContentPart:
Text { text } → Text { text }
Image { source } → Image { image_url: { url, detail } }
Audio { source } → InputAudio { input_audio }
File { source } → File { file }
ToolUse { id, name, input } → 转为 OpenaiToolCall 放入 tool_calls 字段
ToolResult { .. } → 通过 tool_call_id 关联到 Tool 消息
Thinking { .. } → 忽略
Extension { .. } → 忽略
OpenaiChatResponse → MessageResponse:
id → id
model → model
usage → usage
choices[0].finish_reason → stop_reason
choices[0].message → Message::Assistant {
content: [
ContentBlock::Text { text },
... (msg.tool_calls → ContentBlock::ToolUse)
]
}
```
> **🔄 待深入推演:OpenAI 流式转换的具体实现**
> 当前只有一行从 OpenAI SSE chunk 到 StreamEvent 的映射,没有实现细节。
> 实际实现中需要解决以下问题:
> 1. **SSE 字节流解析器**:当前 `SseChunkStream` 从字节流解析 SSE 行(`data: ...`),新设计中需要改写为
> 直接产出 StreamEvent 的转换器。是否可以复用现有 `SseChunkStream` 的字节流解析逻辑?
> 2. **Tool call 索引跟踪**OpenAI 的 `delta.tool_calls[i].index` 是局部索引,
> 需要累积状态来推导全局 tool call 序号。当同一 chunk 出现多个 `delta.tool_calls` 条目时,
> 是逐个发出 `ToolCallStart`/`ToolCallArgumentsDelta` 还是合并?
> 3. **多 Choice 的处理**:当前代码只处理 `choices[0]`。新设计中是继续忽略其他 choice 还是
> 通过某种机制暴露(如 `extra` 中携带)?
> 4. **usage 的时机**OpenAI 的 usage 通常在最后一个 chunk 中携带,与 finish_reason 在同一 chunk。
> 是先发 `CostUpdate` 再发 `MessageComplete`,还是反过来?
> **需要推演:**
> - `OpenaiStreamToEvents<S>` 转换器的状态机设计(跟踪的局部状态、事件产出规则)
> - 边界情况:SSE 行乱序、`[DONE]` 标记的处理、网络断开重连
> 优先级:高(Phase 2 OpenAI Provider 重构的核心任务)
### 5.2 AnthropicProviderMessages API
```
MessageRequest → Anthropic Messages Request:
model → "anthropic-xxx"
messages → [只包含 User / Assistant / Tool role]
system → system (顶层参数)
tools → tools (Anthropic 原生格式)
tool_choice → tool_choice
max_tokens → max_tokens
temperature → temperature
top_p → top_p
stop_sequences → stop_sequences
thinking → thinking (原生支持)
extra.* → 忽略或不支持
Message → Anthropic Message:
System { } → 跳过(已在 system 参数中)
User { content } → { role: "user", content: to_anthropic_content(content) }
Assistant { content } → { role: "assistant", content: to_anthropic_content(content) }
Tool { content, tool_call_id } → { role: "user", content: [tool_result block] }
ContentBlock → Anthropic Content Block:
Text { text } → { type: "text", text }
Image { source } → { type: "image", source: { type: "base64", ... } }
ToolUse { id, name, input } → { type: "tool_use", id, name, input }
ToolResult { tool_use_id, content, is_error } → { type: "tool_result", ... }
Thinking { text, signature } → { type: "thinking", thinking: text, signature }
Audio / File / Extension → 忽略或不支持
Anthropic Response → MessageResponse:
id → id
model → model
content → Message::Assistant { content: map_blocks(content) }
usage.input_tokens → usage.prompt_tokens
usage.output_tokens → usage.completion_tokens
stop_reason → stop_reason
```
> **🔄 待深入推演:Anthropic 流式状态机设计**
> Anthropic 的流式比 OpenAI 复杂得多——7 种事件类型、需要维护 block index 状态、
> thinking 有 signature 在 `content_block_start` 中单独下发。
> **需要推演:**
> 1. **状态机状态设计**
> - `PendingStart` → 等待 `message_start`
> - `InBlock { block_index, block_type, tool_acc: Option<ToolAccumulator> }` → 在某个 content block 中
> - `BetweenBlocks` → 等待下一个 `content_block_start` 或 `message_delta`
> - `Completed` → 收到 `message_stop`
> - `Errored`
> 2. **block index 追踪**`content_block_start` 中的 `index` 是全局 content block 序号,
> 直接对应最终 content 数组中的位置。但 tool_use block 的 `id` 和 `name` 在 `content_block_start`
> 中下发,而 `input` 通过后续的 `content_block_delta`(含 `input_json_delta`)增量到达。
> 实现时需要按 index 累积 tool call 状态,在 `content_block_stop` 时发出完整的
> `ToolCallStart{ index, id, name }`(此时 arguments 可能已部分累积)。
> 3. **Thinking signature 的附着时机**
> Anthropic 中,thinking block 的 `signature` 在 `content_block_delta`(或 `message_delta`)中下发,
> 而不是在 `content_block_start`。这意味着 `StreamEvent::ThinkingDelta` 需要额外的字段或机制来携带
> signature。当前 `ThinkingDelta { text }` 缺少 `signature` 字段,需要重新设计。
> 备选方案:在 `MessageComplete` 中携带 `final_thinking_signature` 字段,或添加独立事件
> `ThinkingSignature { signature: String }`。
> 4. **block 嵌套的边界情况**Anthropic 的响应中 block 不会嵌套,但允许多个 block 连续出现。
> 需要确保状态机正确处理 block 间切换(前一个 `content_block_stop` → 后一个 `content_block_start`)。
> **需要推演:**
> - 完整的状态转移图(当前状态 + 输入事件 → 新状态 + 产出的 StreamEvent
> - SSE 字节流解析器(与 OpenAI 共享字节流层,差异化事件解析层)
> - 错误恢复:收到 `error` 事件时的状态机行为
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
### 5.3 OpenAI Response API(草案)
```rust
// 核心思路:Response API 的 "input as messages" 模式映射到 IR
// MessageRequest → Response API Request:
// model → model
// messages → input (作为 multi-turn conversation)
// tools → tools (tool 定义)
// extra.previous_response_id → previous_response_id
// extra.built_in_tools → tools (内置工具配置)
// extra.instructions → instructions
// Response → MessageResponse:
// output[0] (type="message") → message
// output[1..n] → ContentBlock::Extension
// 内置工具(web_search 等)需要额外的能力 trait:
#[async_trait]
pub trait BuiltInToolsCapable: LlmProvider {
fn available_builtin_tools(&self) -> Vec<(&'static str, serde_json::Value)>;
async fn execute_builtin_tool(&self, name: &str, input: Value) -> Result<Value, LlmError>;
}
```
> **🔄 待深入推演:OpenAI Response API 完整映射表**
> 当前 §5.3 只有注释级别的草案,缺乏完整映射。
> **需要推演:**
> 1. `input` 字段支持三种模式:字符串、`Vec<Message>`IR 消息列表)、`response_id`(前序响应)。
> IR 的 `MessageRequest.messages` 能否同时覆盖这三种?`previous_response_id` 通过 extra 传递后,
> `messages` 是否还需要存在?
> 2. `output` 中的每种类型(`message`, `web_search_call`, `file_search_call`,
> `code_interpreter_call`, `computer_call`, `reasoning`)如何映射到 `ContentBlock`
> 当前 `Extension` 逃生舱能否承载?是否需要新增 ContentBlock variant
> 3. Response API 的 `tools` 参数除了定义 function 工具外,还支持配置内置工具的参数
> (如 `web_search` 的 `search_context_size`)。`ToolDefinition` 能否表达?
> 4. Streaming 差异:Response API 的流式事件类型(`response.output_items.added` 等)与 Chat API
> 完全不同,如何映射到 StreamEvent
> 优先级:低(Phase 4 之后的远期计划)
### 5.4 DeepSeek / Qwen 等兼容 Provider 的落地策略
当前 `ProviderType` 枚举中已列出 DeepSeek 和 Qwen,但实现均标记为 `unimplemented!()`
> **🔄 待深入推演:DeepSeek/Qwen Provider 的落地策略**
> 这些 Provider 通常兼容 OpenAI Chat API 格式。在新 IR 设计下,有两种落地路径:
> **路径 A — 复用 OpenaiProvider(推荐):**
> 在 `ProviderRegistry` 中注册时直接使用 `OpenaiProvider::new(base_url, api_key, model)`
> 仅需换 base_url。适合 DeepSeek、Qwen、Groq、Azure 等 API 格式与 OpenAI 完全一致的场景。
> 此时 `ProviderType` 枚举可能不再需要(`OpenaiProvider` 通过 `capabilities().provider_name`
> 标识自身为 "openai-compatible" 或具体名称)。
> **路径 B — 独立 Provider 实现:**
> 如果某 Provider 在 OpenAI 格式基础上做了扩展/修改(如自定义参数、不同的错误格式),
> 可独立实现 `LlmProvider` trait,复用 IR 类型,仅在 IR ↔ 原生格式映射层做差异处理。
> **需要推演:**
> 1. `ProviderType` 枚举在新的注册体系中是否还有存在的必要(工厂函数模式 vs 直接 new Provider
> 2. `OpenaiProvider` 是否要重命名为更通用的 `OpenaiCompatibleProvider`
> 优先级:低(Phase 4 后梳理)
+254
View File
@@ -0,0 +1,254 @@
# LlmCycle 改造与上层适配
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider traitchat、chat_stream 签名)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式)
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等)
## 6. LlmCycle 改造
### 6.1 内部存储变化
```rust
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
system_prompt: Option<String>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
}
```
### 6.2 build_request → 新签名
```rust
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
let mut messages = self.messages.clone();
if let Some(sys_prompt) = &self.system_prompt
&& !messages.iter().any(|m| matches!(m, Message::System { .. }))
{
messages.insert(0, Message::system(sys_prompt));
}
MessageRequest {
model: self.config.model.clone(),
messages,
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
}
}
```
> **🔄 待深入推演:system prompt 的双重表达冲突**
> `MessageRequest` 同时存在两个 system prompt 入口:
> 1. `messages` 中的 `Message::System { ... }`
> 2. 顶层字段 `system: Option<String>`
>
> 问题在于:
> - **Anthropic** 要求 system prompt 只能用顶层 `system` 参数,`messages` 中不能包含 system role
> - **OpenAI** 没有顶层 `system` 参数,system prompt 放在 messages 中
> - 当前 `build_request` 的逻辑是:将 `self.system_prompt` 插入到 `messages` 开头(作为 `Message::System`),
> 但**不**填充 `request.system`。这对 OpenAI 是正确的,但对 Anthropic 是错的——AnthropicProvider
> 需要反向提取:遍历 messages 找出 System 消息,移到 `request.system`,再从 messages 中移除。
>
> **需要推演:**
> 1. **方案 A(当前方案):** `build_request` 始终把 system prompt 放进 messagesAnthropicProvider 内部
> 做反向提取。问题:当 LlmCycle 的消息历史中已有一个 `Message::System``self.system_prompt` 也被设置时,
> 两个 source 哪个优先?当前代码只检查 `messages` 中是否已有 System 来决定是否插入,但 LlmCycle 的
> `with_system_prompt` 设置的 prompt 可能与消息历史中已有的 System 内容不同。
> 2. **方案 B** 统一通过顶层 `request.system` 传递,OpenaiProvider 内部将 `request.system` 转为
> System message 插入 messages 开头(与 AnthropicProvider 方向相反)。缺点:OpenAI 用户习惯
> 在 messages 中直接放 system message,可能不设置顶层 system。
> 3. **方案 C** 约定 `messages` 中的 System 和顶层 `system` 互斥——`build_request` 检查两者,
> 同时存在时报错或合并(顶层 system 覆盖 messages 中的 System)。
> 优先级:高(Phase 2-3 实现 AnthropicProvider 前必须解决)
主要变化:
- 返回类型 `MessageRequest`(非 `ChatRequest`
- `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装
- `..Default::default()` 填充剩余字段
### 6.3 submit_with_tools —— 新的 tool 循环逻辑
```rust
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<MessageResponse, LlmError> {
let tools = registry.definitions();
let max_turns = self.config.max_tool_turns.unwrap_or(10);
self.messages.push(Message::user(prompt));
self.maybe_compact();
let mut turn = 0;
loop {
turn += 1;
if turn > max_turns { /* error */ }
let response = self.submit_request(&tools).await?;
// 从 content blocks 中检测 ToolUse(不再需要额外函数)
let tool_uses: Vec<&ContentBlock> = response.message.content.iter()
.filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None })
.collect();
let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty();
self.messages.push(response.message.clone());
if !should_execute { return Ok(response); }
let calls: Vec<(String, Value)> = tool_uses.iter()
.map(|b| match b {
ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()),
_ => unreachable!(),
})
.collect();
let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await;
for result in results {
let content = /* 序列化/截断逻辑 ... */;
self.messages.push(Message::tool_result(result.tool_name, content));
}
self.maybe_compact();
}
}
```
关键简化:
- 不再需要 `has_tool_calls_in_message()``extract_tool_calls_from_message()` 辅助函数
- 仅仅遍历 `content` 即可发现所有 `ToolUse` block
- 逻辑对 Provider 类型**完全透明**
### 6.4 submit_stream —— Provider 直接返回 StreamEvent
```rust
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(Message::user(prompt));
let request = self.build_request(&tools);
// Provider 直接返回 StreamEvent 流,无需二次转换
let stream = self.provider.chat_stream(request).await?;
// 如果需要,可在 LlmCycle 层叠加额外处理
// (当前 pipelinestream → hook 触发 → 直接返回)
Ok(stream)
}
```
不再需要 `parse_chunk_stream()``stream.rs` 中的 `ChunkToEventStream` 可以移除)。
### 6.5 HookContext 引用调整
```rust
pub struct HookContext<'a> {
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
pub error: Option<&'a LlmError>,
pub attempt: u32,
pub turn_index: Option<u32>,
pub plan_step_index: Option<usize>,
}
```
### 6.6 compact 逻辑调整
`compact.rs` 中的 `estimate_message_tokens()``microcompact()` 需要从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,核心逻辑不变。
> **🔄 待深入推演:compact 在 IR 上的具体改法与 token 估算**
> 当前的 `microcompact` 通过将 `Tool` 消息的内容替换为 `"[pruned]"` 来释放 token。在 IR 中,
> 工具结果有两种表达方式:
> 1. `Message::Tool { content: [ContentBlock::Text { text: "大段工具结果..." }], tool_call_id }`
> 2. `Message::Assistant { content: [.., ContentBlock::ToolResult { content: [Text], .. }] }`(如果使用 IR 的 ToolResult block
>
> **需要推演:**
> 1. IR 下 `microcompact` 压缩哪个?只压缩 `Message::Tool` 还是也压缩 `ContentBlock::ToolResult`
> 如果两者共存,谁先被压缩?
> 2. `estimate_message_tokens` 的估算策略:当前按字符数估算(4 字符 ≈ 1 token),对 `ContentBlock`
> 的新类型(Thinking、ToolUse、ToolResult、Image 等)如何估算?Image block 用固定 50 token 的经验值
> 是否仍然合理?
> 3. `ContentBlock::Thinking` 是否在压缩范围内?thinking 内容通常不应该被压缩(因为思考是推理过程,
> 压缩后可能丢失上下文)。是否需要为 `CompactConfig` 增加 `compact_thinking` 选项?
> 优先级:中(Phase 3 适配 LlmCycle 时需同步修改)
---
## 7. 对上层的影响
### 7.1 ProviderRegistry —— 零改动
```rust
pub struct ProviderRegistry {
providers: HashMap<String, Box<dyn LlmProvider>>,
default_name: Option<String>,
}
// 所有方法逻辑不变
```
### 7.2 AgentSession —— 极小影响
```rust
// 当前
let response: ChatResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // Usage 类型不变
// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致
```
`response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。
### 7.3 Agent trait —— 零改动
`Agent::tool_definitions()` 返回 `Vec<ToolDefinition>`,类型不变。
### 7.4 PromptComposer —— 内部类型替换
`PromptComposer` 内部存储从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,公共方法签名不变(返回 `Message` 类型)。
---
## 8. 兼容性策略
### 8.1 From trait 双向转换
提供新旧类型之间的转换,平滑迁移:
```rust
// IR → 旧类型(兼容层)
impl From<ChatResponse> for MessageResponse { ... }
impl From<MessageResponse> for ChatResponse { ... }
impl From<OpenaiChatMessage> for Message { ... }
impl From<Message> for OpenaiChatMessage { ... }
impl From<ChatRequest> for MessageRequest { ... }
impl From<MessageRequest> for ChatRequest { ... }
```
### 8.2 LlmCycle 兼容 getter
```rust
impl LlmCycle {
// 新
pub fn messages(&self) -> &[Message] { &self.messages }
// 兼容旧
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
self.messages.iter().map(|m| m.clone().into()).collect()
}
}
```
### 8.3 测试代码的过渡
测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。
+103
View File
@@ -0,0 +1,103 @@
# 边界情况
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §9 边界情况。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、ThinkingConfig、MessageRequest/Response 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — StreamEvent 类型定义(ThinkingDelta 等)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Anthropic 流式状态机(与 Thinking signature 强相关)
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 的 tool 循环与 compact 逻辑
## 9. 边界情况
### 9.1 工具定义的传递路径变化
| 阶段 | 路径 |
|------|------|
| **当前** | `ToolRegistry.definitions()``Vec<ToolDefinition>``build_request()` 包装为 `Vec<OpenaiTool>``ChatRequest.tools` |
| **方案 C** | `ToolRegistry.definitions()``Vec<ToolDefinition>``build_request()` 直接放入 → `MessageRequest.tools` → **Provider 内部**包装为原生格式 |
工具定义的抽象层次从 LlmCycle 下移到 Provider 内部,更合理。
### 9.2 Thinking 的端到端流程
```
Agent 启用 thinking:
config.thinking = Some(ThinkingConfig { budget_tokens: 16000 })
LlmCycle.build_request():
→ MessageRequest { thinking: config.thinking, ... }
OpenaiProvider:
→ capabilities().features.thinking == false
→ 忽略 thinking 字段(或转为 extra.reasoning_tokens
AnthropicProvider:
→ capabilities().features.thinking == true
→ 将 thinking 写入 Anthropic 请求参数
→ 流式响应中返回 StreamEvent::ThinkingDelta
→ 最终消息的 content 中包含 ContentBlock::Thinking
```
> **🔄 待深入推演:Thinking signature 的端到端传递**
> 当前设计中,Anthropic 的 thinking block signature 在 `ContentBlock::Thinking.signature` 中存储,
> 但从流式事件到最终 ContentBlock 的传递路径没有闭环:
> 1. `content_block_start`(针对 thinking block)下发的是 `{ type: "thinking" }` 没有 signature
> 2. `content_block_delta` 下发 `thinking` delta + **signature**(在 delta 中,不是独立的字段)
> 3. `StreamEvent::ThinkingDelta { text }` 当前只有 text,缺少 signature 字段
> 4. 最终需要将 signature 附着到 `ContentBlock::Thinking { text, signature: Some(...) }` 上
>
> **需要推演:**
> 1. `StreamEvent::ThinkingDelta` 是否需要增加 `signature: Option<String>` 字段?
> 2. 或者改为每个 block 结束时发一个独立的 `ContentBlockSealed { index, block: ContentBlock }` 事件?
> 3. 非流式响应中,Anthropic 的 thinking block 是完整的(含 text 和 signature 在同一 block 中),
> 映射没有问题。但流式响应中,signature 的附着时机需要在 AnthropicProvider 的状态机中准确定义。
> 4. `PartialMessageResponse`(§4.3 待推演)需要能按 index 追踪 thinking block 的 text 累积和 signature 暂存。
>
> 此问题与 [9d-provider-implementations.md](9d-provider-implementations.md) §5.2 的"Anthropic 流式状态机设计"直接相关,
> 建议在推演状态机时一并解决。
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
### 9.3 Multiple ContentBlock 的处理
消息的 `content``Vec<ContentBlock>`,可包含多种类型的混合:
```rust
Message::Assistant {
content: vec![
ContentBlock::Text { text: "让我思考一下..." },
ContentBlock::Thinking { text: "先用加法工具...", signature: None },
ContentBlock::Text { text: "答案是 3" },
ContentBlock::ToolUse { id: "call_1", name: "add", input: json!({"a":1,"b":2}) },
],
}
```
LlmCycle 处理 tool 循环时只关心 `ToolUse` block,其他 block 按原样传递给消息历史。
### 9.4 多 Choice 场景
OpenAI 的 `n > 1` 参数在 IR 中通过 `extra` 传递:
```rust
// 请求
request.set_extra("n", json!(3));
// 响应
response.extra["all_choices"] = json!([...choices 2..n]);
```
`MessageResponse` 只承载 `choices[0]`(主消息),其他 choices 放 `extra`
### 9.5 OpenAI Response API 的内置工具
通过 `extra` + 可选能力 trait 支持:
```rust
// 配置内置工具
request.set_extra("built_in_tools", json!(["web_search", "file_search"]));
// Response API 返回的搜索结果
// → ContentBlock::Extension { kind: "web_search_result", data: {...} }
```
如果未来内置工具成为跨 Provider 通用特性,将 `BuiltInToolsCapable` 升级为核心 trait。
+96
View File
@@ -0,0 +1,96 @@
# 风险评估、迁移路径与验收标准
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §10 风险评估 + §11 类型差异总结 + §12 迁移路径 + §13 验收标准。
>
> **相关文件:**
> - [9a-background-and-architecture.md](9a-background-and-architecture.md) — 背景与架构总览
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型体系
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider Trait 设计
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造与上层适配
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况
## 10. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| ContentField::String 与 Vec<ContentBlock> 的统一导致文本消息需要包装 | 低 | Message::user("text") 便捷函数自动包装 |
| 无法完整覆盖 OpenAI 所有参数 | 中 | extra 逃生舱兜底 |
| IR ↔ OpenAI 的转换有性能开销 | 低 | 纯字段映射,相对 HTTP 延迟可忽略 |
| HookContext 引用类型变更影响 Hook 实现 | 中 | 影响范围小(主要为测试代码) |
| LlmCycle 返回类型变化破坏上层 | 中 | 提供兼容层 + From 转换 |
| compact.rs 需要适配新 Message 类型 | 低 | 核心逻辑不变,仅改类型匹配 |
| 流式事件格式变化破坏现有 StreamEvent 使用者 | 中 | 影响 LlmCycle::submit_stream 的调用者(主要是测试层) |
| ProviderType 枚举需要扩展 | 低 | 新增变体即可 |
---
## 11. 当前类型与 IR 的差异总结
| 当前类型 | 方案 C IR | 核心变化 |
|---------|----------|---------|
| `ChatRequest` (= `OpenaiChatRequest`) | `MessageRequest` | 独立类型,不绑定 OpenAI |
| `ChatResponse` | `MessageResponse` | 独立类型,content 用 ContentBlock |
| `OpenaiChatMessage` | `Message` | tool_calls 融入 content |
| `ContentField` | `Vec<ContentBlock>` | 统一为数组,不再有 String variant |
| `OpenaiContentPart` | `ContentBlock` | 新增 ToolUse/ToolResult/Thinking/Extension |
| `FinishReason` | `StopReason` | 语义化(如 tool_calls → ToolUse |
| `OpenaiChatChunk` | — | 移除(StreamEvent 取代)|
| `OpenaiChatResponse` | — | 不再暴露(Provider 内部使用)|
| `OpenaiToolCall` | `ContentBlock::ToolUse` | 融入 content block |
| `OpenaiToolDefinition` | `ToolDefinition` | 不变 |
| `Usage` | `Usage` | 不变 |
| `StreamEvent` | `StreamEvent` | 扩展(ThinkingDelta、MessageStart、ToolCallStart 等) |
---
## 12. 迁移路径
### Phase 1:定义 IR 类型 + From 转换
- 新增 `src/llm/types/ir.rs`,包含完整的 IR 类型定义
- 实现 IR ↔ 现有类型的 `From`/`Into` trait
- 新增 `pub type` 别名保持现有代码可编译
- ✅ 零已有代码改动
### Phase 2:重写 LlmProvider trait
- 修改 `src/llm/provider.rs`trait 签名改为 IR 类型
- 重构 `OpenaiProvider`:内部 IR → OpenAI → IR 转换
- 新增 `chat_stream``StreamEvent` 实现
- 新增 `capabilities()` 方法
- ❌ OpenaiProvider 需重构;LlmCycle 暂时不兼容
### Phase 3:适配 LlmCycle
- LlmCycle 内部消息历史改为 `Vec<Message>`
- `build_request` 改为生成 `MessageRequest`
- tool 循环逻辑改为遍历 `ContentBlock`
- `HookContext` 引用改为 `MessageRequest`
- `compact.rs` 适配新消息类型
- ❌ LlmCycle API 变更;AgentSession 需适配
### Phase 4:实现 AnthropicProvider + 清理
- 新增 `src/llm/provider/anthropic.rs`
- 实现 IR ↔ Anthropic JSON 映射 + 流式转换
- 移除旧的 `parse_chunk_stream``ChunkToEventStream`
- 清理不再需要的旧类型公开使用
- 补测试
---
## 13. 验收标准
| 编号 | 标准 | 验证方式 |
|------|------|----------|
| A1 | `OpenaiProvider` 通过 IR trait 正常工作 | 现有测试通过 + ChatCompletion 集成测试 |
| A2 | `AnthropicProvider` 通过 IR trait 正常工作 | Anthropic Messages API 集成测试 |
| A3 | Tool 循环在 IR 上正确工作(在 content 中检测 ToolUse | `submit_with_tools` 测试通过 |
| A4 | 流式事件包含 ThinkingDelta 等新类型 | Provider 流式测试 |
| A5 | ProviderCapabilities 正确描述 Provider 特性 | 单元测试 |
| A6 | `extra` 逃生舱可传递 Provider 特有参数 | 测试各 Provider 的 extra 参数 |
| A7 | 兼容层保持旧 API 可用 | 旧代码编译通过 |
| A8 | `compact.rs` 在 IR 上正常工作 | 压缩测试通过 |
| A9 | HookContext 使用 MessageRequest | Hook 测试通过 |
| A10 | AgentSession 编译通过 | 编译检查 |