Files
agcore/design/pdd/9d-provider-implementations.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

25 KiB
Raw Blame History

Provider 实现策略

本文档从 9-llm-provider-unified-interface.md 拆分而来,包含 §5 Provider 实现策略。

相关文件:

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

从当前 SseChunkStreamopenai.rs 内联实现)提取字节解析逻辑为通用 SSE 解析器。 产出 SseEvent 结构体,携带 event_name + data 两部分信息。 OpenAI 和 Anthropic 均直接复用此层,各自的事件转换器按需使用 event_name

Layer 2 — OpenaiStreamToEvents(新增 src/llm/provider/openai/stream.rs

接收 SseEvent 流,忽略 event_nameOpenAI 为 None), 将 data 反序列化为 OpenaiChatChunk(保留作为内部格式), 通过状态机转换为 StreamEvent 事件流。

状态机设计

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 callfunction.name 有值)
  │       │   → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
  │       └─ 已有 tool callfunction.arguments 有值)
  │           → ToolCallArgumentsDelta(index, arguments)
  │
  └─ Phase 2: 处理汇总(后收束)
      ├─ finish_reason 存在 → close_current_block() + MessageComplete
      ├─ usage 存在         → CostUpdate
      └─ 同时存在           → CostUpdate → close_block → MessageComplete

核心抽象 ensure_block 当新 chunk 的 delta 类型与当前活跃 block 不同时, 自动关闭当前 blockemit ContentBlockEnd)并开启新 blockemit ContentBlockStart)。 同类型继续时只发增量事件,不切换。

状态转移表:

当前状态 收到 delta.content 收到 delta.tool_calls (new) 收到 delta.tool_calls (延续) 收到 finish_reason
无活跃 block → ContentBlockStart(Text)
→ TextDelta
→ ContentBlockStart(ToolUse) (不应发生) → MessageComplete
Text 活跃中 → TextDelta → ContentBlockEnd
→ ContentBlockStart(ToolUse)
(不应发生,与 content 互斥) → ContentBlockEnd
→ CostUpdate
→ MessageComplete
ToolUse 活跃中 → ContentBlockEnd
→ ContentBlockStart(Text)
→ ContentBlockEnd
→ ContentBlockStart(new ToolUse)
→ ToolCallArgumentsDelta → ContentBlockEnd
→ CostUpdate
→ MessageComplete

各议题结论

# 议题 结论 理由
1 SSE 字节解析复用 提取为通用 SseByteStreamsse.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 保留给 Anthropiccontent_block_stop 场景);ContentBlockEnd 已标 block 完成,finalizetool_call_args 已累积完整
7 Refusal 处理 检测 delta.refusalemit 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 发 TextDeltaPhase 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::Erroris_complete = true
JSON 解析失败 emit StreamEvent::Error,终止流(防御性处理)

文件变更清单

操作 文件 说明
新增 src/llm/provider/sse.rs 通用 SSE 字节解析层,含 SseEvent 结构体;从当前 openai.rsSseChunkStream 提取核心逻辑并增强为支持 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 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

推演结论(2026-06-18):

设计思路:轻量分发器

Anthropic 的流式事件自带语义块边界content_block_start/stop 显式声明), 不像 OpenAI 需从扁平 delta 推断。因此 Anthropic 状态机采用轻量分发器模式—— 每个事件自描述,状态机仅做顺序合法性校验,不做 block 边界推断或 index 映射。

与 OpenAI 流式转换的核心差异:

维度 OpenaiStreamToEvents AnthropicStreamToEvents
核心复杂度 中——需从扁平 delta 推断 block 边界 低——事件自带语义边界
状态数 3(无活跃、Text、ToolUse 3PendingStart、Active、Terminated
index 管理 HashMap<openai_idx, global_idx> 映射 直接使用 Anthropic index1: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 语义事件

结构体设计

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 }
CostUpdate { prompt_tokens }
message.usage.input_tokens 提取
ping —(忽略) Anthropic 心跳
content_block_start
block.type="text"
ContentBlockStart { index, Text }
content_block_start
block.type="tool_use"
ContentBlockStart { index, ToolUse { id, name } } block 自带 id + name
content_block_start
block.type="thinking"
ContentBlockStart { index, Thinking }
content_block_delta
delta.type="text_delta"
TextDelta { text: delta.text }
content_block_delta
delta.type="thinking_delta"
ThinkingDelta { text: delta.thinking }
content_block_delta
delta.type="input_json_delta"
ToolCallArgumentsDelta { index, arguments: delta.partial_json } index 透传
content_block_stop ContentBlockEnd { index }
message_delta CostUpdate { completion_tokens }
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 index1:1 Anthropic 的 index 是全局 content block 序号,与 IR 完全对齐
3 Thinking signature 已推演(方案 C):message_delta 提取 → MessageComplete 传递 → finalize 回填 已在 9f-edge-cases.md 中完成推演
4 SSE 字节解析复用 通过增强的 SseByteStream(支持 event: 行解析)与 OpenAI 共享通用层 同一字节协议,仅在事件解析层差异化
5 Usage 分次到达 message_start 提取 input_tokensmessage_delta 提取 output_tokensPartialUsage 字段级合并 与 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/EndBTreeMap 按 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 Errorstate = Terminated
JSON 解析失败 emit Errorstate = 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 增加 Anthropiccreate_provider 增加分支
无变更 src/llm/cycle.rs StreamEvent 事件序列格式不变,无需改动
无变更 src/llm/stream.rs 汇聚算法 apply_to/finalize 不变

优先级:高(Phase 4 AnthropicProvider 实现的前提条件)

5.3 OpenAI Response API(草案)

// 核心思路: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_searchsearch_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 后梳理)