28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
460 lines
25 KiB
Markdown
460 lines
25 KiB
Markdown
# 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)
|
||
]
|
||
}
|
||
```
|
||
|
||
> **✅ 推演结论(2026-06-18):**
|
||
>
|
||
> ### 架构分层
|
||
>
|
||
> OpenAI 的流式转换拆为**两层**,字节解析层通用、事件转换层 OpenAI 特定:
|
||
>
|
||
> ```
|
||
> bytes_stream()
|
||
> │
|
||
> ▼
|
||
> SseByteStream [sse.rs — 通用层]
|
||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、
|
||
> │ 过滤 "[DONE]"/ping、缓冲拼接碎片、多行 data: 自动拼接
|
||
> ▼
|
||
> Stream<Item=Result<SseEvent, LlmError>> ← 结构化 SSE 帧
|
||
> │ SseEvent { event_name: Option<String>, data: String }
|
||
> │ - OpenAI: event_name = None(未命名事件)
|
||
> │ - Anthropic: event_name = Some("message_start" | "content_block_delta" | ...)
|
||
> │
|
||
> ├─ OpenAI ────→ OpenaiStreamToEvents [openai/stream.rs]
|
||
> │ 忽略 event_name,反序列化 data → OpenaiChatChunk → 状态机
|
||
> │
|
||
> └─ Anthropic ──→ AnthropicStreamToEvents [anthropic/stream.rs]
|
||
> 按 event_name 分发事件类型 → 事件映射
|
||
> ```
|
||
>
|
||
> **Layer 1 — `SseByteStream<S>`(新增 `src/llm/provider/sse.rs`)**
|
||
>
|
||
> 从当前 `SseChunkStream`(`openai.rs` 内联实现)提取字节解析逻辑为通用 SSE 解析器。
|
||
> 产出 `SseEvent` 结构体,携带 `event_name` + `data` 两部分信息。
|
||
> OpenAI 和 Anthropic 均直接复用此层,各自的事件转换器按需使用 `event_name`。
|
||
>
|
||
> **Layer 2 — `OpenaiStreamToEvents`(新增 `src/llm/provider/openai/stream.rs`)**
|
||
>
|
||
> 接收 `SseEvent` 流,忽略 `event_name`(OpenAI 为 `None`),
|
||
> 将 `data` 反序列化为 `OpenaiChatChunk`(保留作为内部格式),
|
||
> 通过状态机转换为 `StreamEvent` 事件流。
|
||
>
|
||
> ### 状态机设计
|
||
>
|
||
> ```rust
|
||
> pub struct OpenaiStreamToEvents<S> {
|
||
> inner: S, // Stream<Item=Result<String, LlmError>>
|
||
> // ── 状态 ──
|
||
> global_index: u32, // 下一个可用 content block 序号
|
||
> current_block: Option<(u32, CurrentBlockType)>, // 当前活跃 block
|
||
> tool_call_indices: HashMap<u32, u32>, // OpenAI tool_call.index → 全局序号
|
||
> is_complete: bool,
|
||
> }
|
||
>
|
||
> enum CurrentBlockType { Text, Refusal, ToolUse }
|
||
> ```
|
||
>
|
||
> **每条 JSON 行的处理流程:**
|
||
>
|
||
> ```
|
||
> 收到一行 JSON 字符串
|
||
> ├─ 解析为 OpenaiChatChunk
|
||
> │
|
||
> ├─ Phase 1: 处理 delta 内容(先增量)
|
||
> │ ├─ delta.content → ensure_block(Text) → TextDelta
|
||
> │ ├─ delta.refusal → ensure_block(Refusal) → RefusalDelta
|
||
> │ └─ delta.tool_calls
|
||
> │ ├─ 新 tool call(function.name 有值)
|
||
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
|
||
> │ └─ 已有 tool call(function.arguments 有值)
|
||
> │ → ToolCallArgumentsDelta(index, arguments)
|
||
> │
|
||
> └─ Phase 2: 处理汇总(后收束)
|
||
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
|
||
> ├─ usage 存在 → CostUpdate
|
||
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
|
||
> ```
|
||
>
|
||
> **核心抽象 `ensure_block`:** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
|
||
> 自动关闭当前 block(emit `ContentBlockEnd`)并开启新 block(emit `ContentBlockStart`)。
|
||
> 同类型继续时只发增量事件,不切换。
|
||
>
|
||
> **状态转移表:**
|
||
>
|
||
> | 当前状态 | 收到 delta.content | 收到 delta.tool_calls (new) | 收到 delta.tool_calls (延续) | 收到 finish_reason |
|
||
> |----------|-------------------|----------------------------|----------------------------|-------------------|
|
||
> | 无活跃 block | → ContentBlockStart(Text)<br>→ TextDelta | → ContentBlockStart(ToolUse) | (不应发生) | → MessageComplete |
|
||
> | Text 活跃中 | → TextDelta | → ContentBlockEnd<br>→ ContentBlockStart(ToolUse) | (不应发生,与 content 互斥) | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||
> | ToolUse 活跃中 | → ContentBlockEnd<br>→ ContentBlockStart(Text) | → ContentBlockEnd<br>→ ContentBlockStart(new ToolUse) | → ToolCallArgumentsDelta | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||
>
|
||
> ### 各议题结论
|
||
>
|
||
> | # | 议题 | 结论 | 理由 |
|
||
> |---|------|------|------|
|
||
> | 1 | **SSE 字节解析复用** | 提取为通用 `SseByteStream`(`sse.rs`),支持 `event:` + `data:` 双行解析;`OpenaiStreamToEvents` / `AnthropicStreamToEvents` 分别在其上封装 | Anthropic 复用相同字节协议,通过 `SseEvent.event_name` 区分事件类型;分层测试、职责清晰 |
|
||
> | 2 | **ContentBlockStart/End 合成** | "类型切换推断边界"策略——来什么类型就关旧开新,依赖 content/tool_calls 互斥保证 | 状态机 3 种当前类型覆盖全部场景,假设验证通过 |
|
||
> | 3 | **Tool call 序号映射** | `HashMap<openai_index, global_block_index>`,新 tool call 出现时分配全局序号 | OpenAI index 是 tool 数组级别全局的,但与 IR content block 体系不同,需映射 |
|
||
> | 4 | **多 Choice** | 忽略 choices[1..],不暴露 | 当前架构无多 choice 概念,80% 场景 n=1,非目标已明确 |
|
||
> | 5 | **Usage 时机** | 先发 `CostUpdate` → 再发 `MessageComplete`,同一 chunk 内串行 | 汇聚算法兼容两者顺序,但语义上先用量后完成更合理 |
|
||
> | 6 | **ToolCallEnd 发出** | OpenAI 层不显式发出 `ToolCallEnd`,依赖汇聚算法 `finalize()` 兜底 | `ToolCallEnd` 保留给 Anthropic(`content_block_stop` 场景);`ContentBlockEnd` 已标 block 完成,`finalize` 时 `tool_call_args` 已累积完整 |
|
||
> | 7 | **Refusal 处理** | 检测 `delta.refusal`,emit `ContentBlockStart(Refusal)` + `RefusalDelta` + `ContentBlockEnd` | OpenAI 特有字段,IR 已有 `ContentBlockType::Refusal` |
|
||
> | 8 | **代码消重** | 删除 `stream.rs::parse_chunk_stream`(无人调用);`cycle.rs::submit_stream` 直接消费 `Result<StreamEvent>` 流 | 转换逻辑统一到 Provider 层,LlmCycle 只负责编排和 hook |
|
||
>
|
||
> ### 边界情况
|
||
>
|
||
> | 场景 | 处理方式 |
|
||
> |------|---------|
|
||
> | **`[DONE]` 行** | `SseByteStream` 层过滤,不传递到事件层(当前已有逻辑) |
|
||
> | **delta 为空 + finish_reason** | 只处理 Phase 2,关闭当前 block 后 emit MessageComplete |
|
||
> | **同一 chunk 含 delta.content + finish_reason** | Phase 1 先处理 delta 发 TextDelta,Phase 2 关闭 block 发 Complete |
|
||
> | **同一 chunk 含 delta.tool_calls + finish_reason** | 先处理所有 tool calls(映射 + arguments 累积),再关 block |
|
||
> | **usage 单独 chunk 下发** | 触发 Phase 2 但 finish_reason 为 None → 只发 CostUpdate,不发 MessageComplete |
|
||
> | **网络断开** | reqwest 返回 Err → emit StreamEvent::Error,is_complete = true |
|
||
> | **JSON 解析失败** | emit StreamEvent::Error,终止流(防御性处理) |
|
||
>
|
||
> ### 文件变更清单
|
||
>
|
||
> | 操作 | 文件 | 说明 |
|
||
> |------|------|------|
|
||
> | 新增 | `src/llm/provider/sse.rs` | 通用 SSE 字节解析层,含 `SseEvent` 结构体;从当前 `openai.rs` 的 `SseChunkStream` 提取核心逻辑并增强为支持 `event:` + `data:` 双行解析 + 空行分帧 |
|
||
> | 新增 | `src/llm/provider/openai/stream.rs` | `OpenaiStreamToEvents` 转换器 + 状态机 |
|
||
> | 修改 | `src/llm/provider/openai.rs` | `chat_stream` 返回 `Result<StreamEvent>`,组合 `SseByteStream` + `OpenaiStreamToEvents` |
|
||
> | 修改 | `src/llm/provider.rs` | `LlmProvider::chat_stream` 签名改为 `Result<StreamEvent>` |
|
||
> | 修改 | `src/llm/cycle.rs` | `submit_stream` 直接消费 `StreamEvent` 流,移除内联 chunk→event 转换 |
|
||
> | 修改 | `src/llm/stream.rs` | 删除 `parse_chunk_stream`(无人调用) |
|
||
>
|
||
> 优先级:高(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
|
||
```
|
||
|
||
> **✅ 推演结论(2026-06-18):**
|
||
>
|
||
> ### 设计思路:轻量分发器
|
||
>
|
||
> Anthropic 的流式事件**自带语义块边界**(`content_block_start/stop` 显式声明),
|
||
> 不像 OpenAI 需从扁平 delta 推断。因此 Anthropic 状态机采用**轻量分发器**模式——
|
||
> 每个事件自描述,状态机仅做顺序合法性校验,不做 block 边界推断或 index 映射。
|
||
>
|
||
> **与 OpenAI 流式转换的核心差异:**
|
||
>
|
||
> | 维度 | OpenaiStreamToEvents | AnthropicStreamToEvents |
|
||
> |------|---------------------|------------------------|
|
||
> | 核心复杂度 | 中——需从扁平 delta 推断 block 边界 | 低——事件自带语义边界 |
|
||
> | 状态数 | 3(无活跃、Text、ToolUse) | 3(PendingStart、Active、Terminated) |
|
||
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index(1:1) |
|
||
> | Block 边界推断 | 类型切换推断 | 原生 content_block_start/stop |
|
||
> | Thinking 处理 | 无 | 通过 message_delta.thinking.signature |
|
||
>
|
||
> ### 架构分层
|
||
>
|
||
> 沿用 OpenAI 的两层架构,`SseByteStream` 共享(已增强为支持命名事件),
|
||
> `AnthropicStreamToEvents` 在事件层按 `event_name` 分发:
|
||
>
|
||
> ```
|
||
> bytes_stream()
|
||
> │
|
||
> ▼
|
||
> SseByteStream [sse.rs — 通用层(已增强)]
|
||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、过滤 "[DONE]"/ping
|
||
> ▼
|
||
> Stream<Item=Result<SseEvent, LlmError>> ← SseEvent { event_name: Option<String>, data }
|
||
> │
|
||
> ▼
|
||
> AnthropicStreamToEvents [anthropic/stream.rs — Anthropic 特定]
|
||
> │ 按 SseEvent.event_name 分发事件类型 → 直接映射
|
||
> ▼
|
||
> Stream<Item=Result<StreamEvent, LlmError>> ← IR 语义事件
|
||
> ```
|
||
>
|
||
> ### 结构体设计
|
||
>
|
||
> ```rust
|
||
> pub struct AnthropicStreamToEvents<S> {
|
||
> inner: S, // Stream<Item=Result<SseEvent, LlmError>>
|
||
> state: AnthropicStreamState, // 仅做顺序校验
|
||
> usage: PartialUsage, // 从 message_start + message_delta 累积
|
||
> pending_thinking_signature: Option<String>, // message_delta 中到达
|
||
> }
|
||
>
|
||
> /// 状态机状态 —— 仅做合法性校验,事件本身已自描述。
|
||
> enum AnthropicStreamState {
|
||
> PendingStart, // 等待 message_start
|
||
> Active, // 已收到 message_start,正在接收 content block 事件
|
||
> Terminated, // 已终结,不再处理后续事件
|
||
> }
|
||
> ```
|
||
>
|
||
> 状态足够简单的原因:Anthropic 每个事件自带完整语义——
|
||
> - `content_block_delta` 自带 `index`,不需要追踪"当前活跃 block"
|
||
> - `content_block_stop` 自带 `index`,不需要追踪"当前关闭哪个"
|
||
> - 状态只拒绝非法到达顺序的事件
|
||
>
|
||
> ### 事件映射表(完整)
|
||
>
|
||
> | Anthropic SSE 事件 | 产出的 StreamEvent | 说明 |
|
||
> |-------------------|-------------------|------|
|
||
> | `message_start` | `MessageStart { id, model }`<br>`CostUpdate { prompt_tokens }` | 从 `message.usage.input_tokens` 提取 |
|
||
> | `ping` | —(忽略) | Anthropic 心跳 |
|
||
> | `content_block_start`<br>`block.type="text"` | `ContentBlockStart { index, Text }` | |
|
||
> | `content_block_start`<br>`block.type="tool_use"` | `ContentBlockStart { index, ToolUse { id, name } }` | block 自带 id + name |
|
||
> | `content_block_start`<br>`block.type="thinking"` | `ContentBlockStart { index, Thinking }` | |
|
||
> | `content_block_delta`<br>`delta.type="text_delta"` | `TextDelta { text: delta.text }` | |
|
||
> | `content_block_delta`<br>`delta.type="thinking_delta"` | `ThinkingDelta { text: delta.thinking }` | |
|
||
> | `content_block_delta`<br>`delta.type="input_json_delta"` | `ToolCallArgumentsDelta { index, arguments: delta.partial_json }` | index 透传 |
|
||
> | `content_block_stop` | `ContentBlockEnd { index }` | |
|
||
> | `message_delta` | `CostUpdate { completion_tokens }`<br>`MessageComplete { stop_reason, thinking_signature }` | signature 从 `delta.thinking?.signature` 提取 |
|
||
> | `message_stop` | —(流结束标记,不产事件) | 仅切状态到 Terminated |
|
||
> | `error` | `Error { message: error.message }` | 切状态到 Terminated |
|
||
>
|
||
> ### 状态转移表
|
||
>
|
||
> **当前状态:`PendingStart`**
|
||
>
|
||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||
> |---------|----------------|--------|------|
|
||
> | `message_start` | → `MessageStart` + `CostUpdate` | `Active` | ✅ 正常流程入口 |
|
||
> | 其他任何事件 | — ⚠ warn | 不变 | 防御性跳过 |
|
||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||
>
|
||
> **当前状态:`Active`**
|
||
>
|
||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||
> |---------|----------------|--------|------|
|
||
> | `ping` | —(忽略) | `Active` | ✅ 心跳 |
|
||
> | `content_block_start` | → `ContentBlockStart` | `Active` | ✅ 新 block 开始 |
|
||
> | `content_block_delta` | → `TextDelta` / `ThinkingDelta` / `ToolCallArgumentsDelta` | `Active` | ✅ 块内增量 |
|
||
> | `content_block_stop` | → `ContentBlockEnd` | `Active` | ✅ block 结束 |
|
||
> | `message_delta` | → `CostUpdate` + `MessageComplete` | `Active` | ✅ 消息完成信息 |
|
||
> | `message_stop` | — | `Terminated` | ✅ 正常结束 |
|
||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||
> | 未知 delta type | — ⚠ warn | `Active` | 防御性忽略 |
|
||
>
|
||
> **当前状态:`Terminated`**
|
||
>
|
||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||
> |---------|----------------|--------|------|
|
||
> | 任何事件 | — ⚠ warn "已完结" | `Terminated` | 防御性忽略 |
|
||
>
|
||
> ### 各议题结论
|
||
>
|
||
> | # | 议题 | 结论 | 理由 |
|
||
> |---|------|------|------|
|
||
> | 1 | **状态机模式** | 轻量分发器(3 状态),不做 block 推断 | Anthropic 事件自带语义边界,不需要像 OpenAI 那样推断 |
|
||
> | 2 | **index 映射** | 无需映射,直接使用 Anthropic index(1:1) | Anthropic 的 `index` 是全局 content block 序号,与 IR 完全对齐 |
|
||
> | 3 | **Thinking signature** | ✅ 已推演(方案 C):message_delta 提取 → MessageComplete 传递 → finalize 回填 | 已在 [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) 中完成推演 |
|
||
> | 4 | **SSE 字节解析复用** | 通过增强的 `SseByteStream`(支持 `event:` 行解析)与 OpenAI 共享通用层 | 同一字节协议,仅在事件解析层差异化 |
|
||
> | 5 | **Usage 分次到达** | `message_start` 提取 `input_tokens`,`message_delta` 提取 `output_tokens`,`PartialUsage` 字段级合并 | 与 StreamEvent 汇聚算法兼容 |
|
||
> | 6 | **错误恢复** | `error` 事件 → `StreamEvent::Error` + state=Terminated,后续事件全部忽略 | 不同于 OpenAI 的 HTTP 错误路径,但 IR 层统一为 `StreamEvent::Error` |
|
||
> | 7 | **ContentBlockType::ToolUse 嵌入** | `content_block_start` 中的 `id` + `name` 直接填入 `ContentBlockType::ToolUse { id, name }` | 与已推演的 ContentBlockStart 设计一致 |
|
||
> | 8 | **block 切换** | content_block_stop(index) → content_block_start(index') 自然过渡,状态机不追踪 | 事件本身已确定边界,无需状态机参与 |
|
||
>
|
||
> ### 与已推演设计的对齐
|
||
>
|
||
> **与 Thinking signature 方案的对齐(方案 C):**
|
||
> ```
|
||
> content_block_start { type: "thinking" }
|
||
> → ContentBlockStart(Thinking) ← signature 未到达
|
||
> content_block_delta { thinking_delta }
|
||
> → ThinkingDelta(...)
|
||
> content_block_stop
|
||
> → ContentBlockEnd ← signature 仍未到达
|
||
> message_delta { delta.thinking.signature = "0x..." }
|
||
> → MessageComplete { thinking_signature: Some("0x...") }
|
||
> → finalize() 回填到最后一个 Thinking block
|
||
> ```
|
||
>
|
||
> **与 StreamEvent 汇聚算法的对齐:**
|
||
> 本状态机产出的 StreamEvent 可直接喂入已推演的 `PartialMessageResponse::apply_to()` 算法。
|
||
> 上述事件序列在汇聚算法中:
|
||
> 1. `MessageStart` → state.id/model
|
||
> 2. `ContentBlockStart/Delta/End` → `BTreeMap` 按 index 分桶组装
|
||
> 3. `CostUpdate` → PartialUsage 字段级合并
|
||
> 4. `MessageComplete` → stop_reason + thinking_signature + is_complete
|
||
> 5. `finalize()` → 回填 signature → `MessageResponse`
|
||
>
|
||
> ### 边界情况
|
||
>
|
||
> | 场景 | 处理方式 |
|
||
> |------|---------|
|
||
> | **message_start 前收 content_block_start** | ⚠ warn 忽略,不发射事件 |
|
||
> | **message_delta 前收 message_stop** | ⚠ warn,强制 Terminated |
|
||
> | **content_block_stop 无对应 start** | ⚠ warn 忽略(index 无对应) |
|
||
> | **index 跳跃(0 → 2)** | 正常处理,index 透传,content 数组留空位 |
|
||
> | **delta index 与最新 start 不匹配** | ⚠ warn,仍然按 delta 自带 index 处理 |
|
||
> | **message_delta 缺 thinking.signature** | `thinking_signature`: None |
|
||
> | **message_delta 缺 usage** | 不发射 CostUpdate,仅发射 MessageComplete |
|
||
> | **两次 message_delta** | ⚠ warn,第二次忽略 |
|
||
> | **content_block_stop 后同 index 又来 delta** | ⚠ warn 忽略 |
|
||
> | **ping 事件** | 忽略,不发射任何事件 |
|
||
> | **网络断开** | emit `Error`,state = Terminated |
|
||
> | **JSON 解析失败** | emit `Error`,state = Terminated |
|
||
> | **stop_reason 映射** | `"end_turn"`→`Stop`, `"max_tokens"`→`MaxTokens`, `"tool_use"`→`ToolUse`, `"stop_sequence"`→`StopSequence`, 其他→`Other` |
|
||
>
|
||
> ### 文件变更清单
|
||
>
|
||
> | 操作 | 文件 | 说明 |
|
||
> |------|------|------|
|
||
> | 新增 | `src/llm/provider/anthropic.rs` | AnthropicProvider 实现(chat + chat_stream) |
|
||
> | 新增 | `src/llm/provider/anthropic/` | 目录,按 2018 版风格组织 |
|
||
> | 新增 | `src/llm/provider/anthropic/stream.rs` | `AnthropicStreamToEvents` 转换器 + 事件分发器 |
|
||
> | 增强 | `src/llm/provider/sse.rs` | `SseByteStream` 增强为支持 `event:` 行 + 空行分帧(已在 §5.1 中描述) |
|
||
> | 修改 | `src/llm/provider.rs` | `ProviderType` 增加 `Anthropic`;`create_provider` 增加分支 |
|
||
> | 无变更 | `src/llm/cycle.rs` | StreamEvent 事件序列格式不变,无需改动 |
|
||
> | 无变更 | `src/llm/stream.rs` | 汇聚算法 `apply_to/finalize` 不变 |
|
||
>
|
||
> 优先级:高(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 后梳理)
|