From 8686a2e1d69b86aa9803b2e3a32d73c31d1edf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Mon, 15 Jun 2026 23:07:33 +0800 Subject: [PATCH] =?UTF-8?q?docs(llm):=20=E6=96=B0=E5=A2=9E=20LLM=20Provide?= =?UTF-8?q?r=20=E7=BB=9F=E4=B8=80=E6=8E=A5=E5=8F=A3=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/9-llm-provider-unified-interface.md | 113 ++++++++ docs/9a-background-and-architecture.md | 70 +++++ docs/9b-ir-type-system.md | 323 +++++++++++++++++++++++ docs/9c-llm-provider-trait.md | 140 ++++++++++ docs/9d-provider-implementations.md | 204 ++++++++++++++ docs/9e-llm-cycle-and-upstream.md | 254 ++++++++++++++++++ docs/9f-edge-cases.md | 103 ++++++++ docs/9g-risk-and-migration.md | 96 +++++++ 8 files changed, 1303 insertions(+) create mode 100644 docs/9-llm-provider-unified-interface.md create mode 100644 docs/9a-background-and-architecture.md create mode 100644 docs/9b-ir-type-system.md create mode 100644 docs/9c-llm-provider-trait.md create mode 100644 docs/9d-provider-implementations.md create mode 100644 docs/9e-llm-cycle-and-upstream.md create mode 100644 docs/9f-edge-cases.md create mode 100644 docs/9g-risk-and-migration.md diff --git a/docs/9-llm-provider-unified-interface.md b/docs/9-llm-provider-unified-interface.md new file mode 100644 index 0000000..cc80616 --- /dev/null +++ b/docs/9-llm-provider-unified-interface.md @@ -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 Chat(DeepSeek、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 + MessageRequest │ +├──────────────────────────────────────────────────────────────┤ +│ LlmProvider trait(核心接口) │ +│ chat(MessageRequest) → Result │ +│ chat_stream(MessageRequest) → Stream │ +│ 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-§8:LlmCycle 改造(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-的端到端流程) | **高** | diff --git a/docs/9a-background-and-architecture.md b/docs/9a-background-and-architecture.md new file mode 100644 index 0000000..18f4466 --- /dev/null +++ b/docs/9a-background-and-architecture.md @@ -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 Chat(DeepSeek、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 + MessageRequest │ +├──────────────────────────────────────────────────────────────┤ +│ LlmProvider trait(核心接口) │ +│ chat(MessageRequest) → Result │ +│ chat_stream(MessageRequest) → Stream │ +│ 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 diff --git a/docs/9b-ir-type-system.md b/docs/9b-ir-type-system.md new file mode 100644 index 0000000..5a0f9c6 --- /dev/null +++ b/docs/9b-ir-type-system.md @@ -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` + `MessageRequest`) +> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(Thinking 端到端、Multiple ContentBlock 等) + +## 3. IR 类型体系 + +### 3.1 ContentBlock —— 最小的内容单元 + +```rust +/// 跨 Provider 统一的内容块。 +/// +/// 设计思路: +/// - ToolUse/ToolResult 作为 content block(Anthropic 风格) +/// - 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, + is_error: bool, + }, +``` + +> **🔄 待深入推演:ToolResult 嵌套约束** +> `ContentBlock::ToolResult.content` 定义为 `Vec`,理论上可包含 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, + }, + /// 逃生舱 —— 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, + pub mime_type: Option, +} +``` + +#### 设计决策:为什么把 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, + }, + User { + content: Vec, + }, + Assistant { + content: Vec, + // 注意:ToolUse 在 content 中,不需要独立字段 + }, + Tool { + content: Vec, + /// 关联的 tool_call_id(OpenAI 格式需要) + tool_call_id: String, + }, +} +``` + +#### 与当前 OpenaiChatMessage 的映射 + +| 当前类型 | 新 IR 类型 | 说明 | +|---------|-----------|------| +| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 System(Anthropic 无 Developer role) | +| `OpenaiChatMessage::System { content, name }` | `Message::System { content }` | name 暂不保留 | +| `OpenaiChatMessage::User { content, name }` | `Message::User { content }` | `ContentField` 统一为 `Vec` | +| `OpenaiChatMessage::Assistant { content, tool_calls, refusal, name }` | `Message::Assistant { content }` | tool_calls → ContentBlock::ToolUse;refusal → 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) -> Self; + pub fn assistant(text: impl Into) -> Self; + pub fn system(text: impl Into) -> Self; + pub fn tool_result(tool_call_id: impl Into, text: impl Into) -> 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, + pub parameters: serde_json::Value, + pub strict: Option, +} +// ToolChoice 枚举保持不变 +``` + +与当前 `OpenaiToolDefinition` 一致,不需要改动。 + +### 3.6 MessageRequest —— 统一请求 + +```rust +#[derive(Debug, Clone)] +pub struct MessageRequest { + // ════════════════════════════════════════════ + // 核心共通参数(所有 Provider 都有的概念) + // ════════════════════════════════════════════ + pub model: String, + pub messages: Vec, + pub system: Option, // 顶层 system prompt + pub tools: Vec, + pub tool_choice: ToolChoice, + pub max_tokens: Option, + pub temperature: Option, + pub top_p: Option, + pub stop_sequences: Vec, + pub stream: bool, + + // ════════════════════════════════════════════ + // 跨 Provider 但非全部支持 + // ════════════════════════════════════════════ + pub thinking: Option, + + // ════════════════════════════════════════════ + // 逃生舱:Provider 特有参数 + // ════════════════════════════════════════════ + /// Provider 特有的扩展参数。 + /// 约定:每个 Provider 声明自己读取哪些 key; + /// 遇到不认识的 key 静默忽略。 + pub extra: HashMap, +} +``` + +> **🔄 待深入推演:extra 的类型安全性** +> `get_extra` 在运行时通过 `serde_json::from_value` 反序列化,类型错误在 +> 调用点才能被发现(返回 `None` 或 panic?)。这可能埋下隐蔽的 bug。 +> **需要推演:** +> 1. 能否为每个 Provider 定义类型安全的 extra 结构体(如 `OpenaiExtra`, `AnthropicExtra`), +> 使用 `From for HashMap` 在传入时转换? +> 2. 如果保持运行时反序列化,`get_extra` 返回类型改为 `Result` 而非 `Option`? +> 3. Provider 声明支持的 extra keys + 类型 + 默认值的规范格式? +> 优先级:中(Phase 2 实现前需解决) + +#### extra 逃生舱的使用约定 + +- 每个 Provider 在文档中声明自己读取的 extra key +- Provider 遇到不认识的 key `静默忽略` +- 通过辅助方法安全访问: + +```rust +impl MessageRequest { + pub fn get_extra(&self, key: &str) -> Option; + pub fn set_extra(&mut self, key: impl Into, value: impl Into); +} +``` + +#### 典型 extra key 约定 + +| key | 值类型 | 使用者 | 说明 | +|-----|--------|--------|------| +| `frequency_penalty` | `f32` | OpenAI | 频率惩罚 | +| `presence_penalty` | `f32` | OpenAI | 存在惩罚 | +| `logit_bias` | `HashMap` | 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` | 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, +} + +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, + pub prompt_tokens_details: Option, +} +``` + +| 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` 类型可以直接复用,无需修改。 diff --git a/docs/9c-llm-provider-trait.md b/docs/9c-llm-provider-trait.md new file mode 100644 index 0000000..a5f0d93 --- /dev/null +++ b/docs/9c-llm-provider-trait.md @@ -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; + + /// 流式消息请求,返回语义化事件流。 + async fn chat_stream( + &self, + request: MessageRequest, + ) -> Result< + Pin> + 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>, + /// 特性标记 + 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 }` | 明确参数完整时间点 | diff --git a/docs/9d-provider-implementations.md b/docs/9d-provider-implementations.md new file mode 100644 index 0000000..b7b92f9 --- /dev/null +++ b/docs/9d-provider-implementations.md @@ -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` 转换器的状态机设计(跟踪的局部状态、事件产出规则) +> - 边界情况:SSE 行乱序、`[DONE]` 标记的处理、网络断开重连 +> 优先级:高(Phase 2 OpenAI Provider 重构的核心任务) + +### 5.2 AnthropicProvider(Messages 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 }` → 在某个 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; +} +``` + +> **🔄 待深入推演:OpenAI Response API 完整映射表** +> 当前 §5.3 只有注释级别的草案,缺乏完整映射。 +> **需要推演:** +> 1. `input` 字段支持三种模式:字符串、`Vec`(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 后梳理) diff --git a/docs/9e-llm-cycle-and-upstream.md b/docs/9e-llm-cycle-and-upstream.md new file mode 100644 index 0000000..919bcd6 --- /dev/null +++ b/docs/9e-llm-cycle-and-upstream.md @@ -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 trait(chat、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, + config: CycleConfig, + usage: CostTracker, + messages: Vec, // ← 原 Vec + system_prompt: Option, + hook_executor: Option>, + compact_config: Option, + 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` +> +> 问题在于: +> - **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 放进 messages,AnthropicProvider 内部 +> 做反向提取。问题:当 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 { + 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, +) -> Result + 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 层叠加额外处理 + // (当前 pipeline:stream → 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, + pub plan_step_index: Option, +} +``` + +### 6.6 compact 逻辑调整 + +`compact.rs` 中的 `estimate_message_tokens()`、`microcompact()` 需要从 `Vec` 改为 `Vec`,核心逻辑不变。 + +> **🔄 待深入推演: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>, + default_name: Option, +} +// 所有方法逻辑不变 +``` + +### 7.2 AgentSession —— 极小影响 + +```rust +// 当前 +let response: ChatResponse = cycle.submit_with_tools(input, ®istry).await?; +self.cost_so_far.add(&response.usage); // Usage 类型不变 + +// 新 +let response: MessageResponse = cycle.submit_with_tools(input, ®istry).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`,类型不变。 + +### 7.4 PromptComposer —— 内部类型替换 + +`PromptComposer` 内部存储从 `Vec` 改为 `Vec`,公共方法签名不变(返回 `Message` 类型)。 + +--- + +## 8. 兼容性策略 + +### 8.1 From trait 双向转换 + +提供新旧类型之间的转换,平滑迁移: + +```rust +// IR → 旧类型(兼容层) +impl From for MessageResponse { ... } +impl From for ChatResponse { ... } +impl From for Message { ... } +impl From for OpenaiChatMessage { ... } +impl From for MessageRequest { ... } +impl From for ChatRequest { ... } +``` + +### 8.2 LlmCycle 兼容 getter + +```rust +impl LlmCycle { + // 新 + pub fn messages(&self) -> &[Message] { &self.messages } + // 兼容旧 + pub fn messages_openai(&self) -> Vec { + self.messages.iter().map(|m| m.clone().into()).collect() + } +} +``` + +### 8.3 测试代码的过渡 + +测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。 diff --git a/docs/9f-edge-cases.md b/docs/9f-edge-cases.md new file mode 100644 index 0000000..3d814d6 --- /dev/null +++ b/docs/9f-edge-cases.md @@ -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` → `build_request()` 包装为 `Vec` → `ChatRequest.tools` | +| **方案 C** | `ToolRegistry.definitions()` → `Vec` → `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` 字段? +> 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`,可包含多种类型的混合: + +```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。 diff --git a/docs/9g-risk-and-migration.md b/docs/9g-risk-and-migration.md new file mode 100644 index 0000000..4e0f33e --- /dev/null +++ b/docs/9g-risk-and-migration.md @@ -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 的统一导致文本消息需要包装 | 低 | 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` | 统一为数组,不再有 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` +- `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 编译通过 | 编译检查 |