将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
25 KiB
Provider 实现策略
本文档从
9-llm-provider-unified-interface.md拆分而来,包含 §5 Provider 实现策略。相关文件:
- 9b-ir-type-system.md — IR 类型定义(ContentBlock、Message、MessageRequest/Response 等)
- 9c-llm-provider-trait.md — LlmProvider trait 定义(chat、chat_stream 签名)
- 9e-llm-cycle-and-upstream.md — LlmCycle 改造(system prompt 冲突等与 Provider 相关)
- 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事件流。状态机设计
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(emitContentBlockEnd)并开启新 block(emitContentBlockStart)。 同类型继续时只发增量事件,不切换。状态转移表:
当前状态 收到 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
→ MessageCompleteToolUse 活跃中 → ContentBlockEnd
→ ContentBlockStart(Text)→ ContentBlockEnd
→ ContentBlockStart(new ToolUse)→ ToolCallArgumentsDelta → ContentBlockEnd
→ CostUpdate
→ 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,emitContentBlockStart(Refusal)+RefusalDelta+ContentBlockEndOpenAI 特有字段,IR 已有 ContentBlockType::Refusal8 代码消重 删除 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.rsOpenaiStreamToEvents转换器 + 状态机修改 src/llm/provider/openai.rschat_stream返回Result<StreamEvent>,组合SseByteStream+OpenaiStreamToEvents修改 src/llm/provider.rsLlmProvider::chat_stream签名改为Result<StreamEvent>修改 src/llm/cycle.rssubmit_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 语义事件结构体设计
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_startMessageStart { id, model }CostUpdate { prompt_tokens }从 message.usage.input_tokens提取ping—(忽略) Anthropic 心跳 content_block_startblock.type="text"ContentBlockStart { index, Text }content_block_startblock.type="tool_use"ContentBlockStart { index, ToolUse { id, name } }block 自带 id + name content_block_startblock.type="thinking"ContentBlockStart { index, Thinking }content_block_deltadelta.type="text_delta"TextDelta { text: delta.text }content_block_deltadelta.type="thinking_delta"ThinkingDelta { text: delta.thinking }content_block_deltadelta.type="input_json_delta"ToolCallArgumentsDelta { index, arguments: delta.partial_json }index 透传 content_block_stopContentBlockEnd { index }message_deltaCostUpdate { completion_tokens }MessageComplete { stop_reason, thinking_signature }signature 从 delta.thinking?.signature提取message_stop—(流结束标记,不产事件) 仅切状态到 Terminated errorError { message: error.message }切状态到 Terminated 状态转移表
当前状态:
PendingStart
输入事件 输出 StreamEvent 新状态 备注 message_start→ MessageStart+CostUpdateActive✅ 正常流程入口 其他任何事件 — ⚠ warn 不变 防御性跳过 error→ ErrorTerminated❌ 错误路径 当前状态:
Active
输入事件 输出 StreamEvent 新状态 备注 ping—(忽略) Active✅ 心跳 content_block_start→ ContentBlockStartActive✅ 新 block 开始 content_block_delta→ TextDelta/ThinkingDelta/ToolCallArgumentsDeltaActive✅ 块内增量 content_block_stop→ ContentBlockEndActive✅ block 结束 message_delta→ CostUpdate+MessageCompleteActive✅ 消息完成信息 message_stop— Terminated✅ 正常结束 error→ ErrorTerminated❌ 错误路径 未知 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 中完成推演 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::Error7 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()算法。 上述事件序列在汇聚算法中:
MessageStart→ state.id/modelContentBlockStart/Delta/End→BTreeMap按 index 分桶组装CostUpdate→ PartialUsage 字段级合并MessageComplete→ stop_reason + thinking_signature + is_completefinalize()→ 回填 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: Nonemessage_delta 缺 usage 不发射 CostUpdate,仅发射 MessageComplete 两次 message_delta ⚠ warn,第二次忽略 content_block_stop 后同 index 又来 delta ⚠ warn 忽略 ping 事件 忽略,不发射任何事件 网络断开 emit Error,state = TerminatedJSON 解析失败 emit Error,state = Terminatedstop_reason 映射 "end_turn"→Stop,"max_tokens"→MaxTokens,"tool_use"→ToolUse,"stop_sequence"→StopSequence, 其他→Other文件变更清单
操作 文件 说明 新增 src/llm/provider/anthropic.rsAnthropicProvider 实现(chat + chat_stream) 新增 src/llm/provider/anthropic/目录,按 2018 版风格组织 新增 src/llm/provider/anthropic/stream.rsAnthropicStreamToEvents转换器 + 事件分发器增强 src/llm/provider/sse.rsSseByteStream增强为支持event:行 + 空行分帧(已在 §5.1 中描述)修改 src/llm/provider.rsProviderType增加Anthropic;create_provider增加分支无变更 src/llm/cycle.rsStreamEvent 事件序列格式不变,无需改动 无变更 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 只有注释级别的草案,缺乏完整映射。 需要推演:
input字段支持三种模式:字符串、Vec<Message>(IR 消息列表)、response_id(前序响应)。 IR 的MessageRequest.messages能否同时覆盖这三种?previous_response_id通过 extra 传递后,messages是否还需要存在?output中的每种类型(message,web_search_call,file_search_call,code_interpreter_call,computer_call,reasoning)如何映射到ContentBlock? 当前Extension逃生舱能否承载?是否需要新增 ContentBlock variant?- Response API 的
tools参数除了定义 function 工具外,还支持配置内置工具的参数 (如web_search的search_context_size)。ToolDefinition能否表达?- 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 格式基础上做了扩展/修改(如自定义参数、不同的错误格式), 可独立实现LlmProvidertrait,复用 IR 类型,仅在 IR ↔ 原生格式映射层做差异处理。 需要推演:
ProviderType枚举在新的注册体系中是否还有存在的必要(工厂函数模式 vs 直接 new Provider)OpenaiProvider是否要重命名为更通用的OpenaiCompatibleProvider? 优先级:低(Phase 4 后梳理)