docs: 更新方案9推演结论并调整StreamEvent设计

This commit is contained in:
徐涛
2026-06-17 22:55:47 +08:00
parent 8686a2e1d6
commit c0eae92b10
5 changed files with 600 additions and 109 deletions
+7 -7
View File
@@ -85,10 +85,10 @@ pub type Message = OpenaiChatMessage;
|------|------|---------------|
| [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 归一化 |
| [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)、[汇聚算法](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | — |
| [9d-provider-implementations.md](9d-provider-implementations.md) | §5 Provider 实现:[OpenAI](9d-provider-implementations.md#51-openai-provider兼容-chat-api)、[Anthropic](9d-provider-implementations.md#52-anthropicprovidermessages-api)、[Response API](9d-provider-implementations.md#53-openai-response-api草案)、[DeepSeek/Qwen](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | OpenAI 流式转换、Anthropic 流式状态机、Response API 映射、DeepSeek/Qwen 落地 |
| [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) | §6-§8LlmCycle 改造(build_request、tool 循环、submit_stream、compact+ 上层影响 + 兼容策略 | system prompt 双重表达冲突、compact 适配 |
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | Thinking signature 端到端传递 |
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | |
### 辅助参考
@@ -100,14 +100,14 @@ pub type Message = OpenaiChatMessage;
| # | 议题 | 所在文件 | 优先级 |
|---|------|---------|--------|
| 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) | |
| 1 | ToolResult 嵌套约束 | [9b-ir-type-system.md](9b-ir-type-system.md#31-contentblock--最小的内容单元) | ✅ 已推演(方案 C:运行时过滤) |
| 2 | extra 的类型安全性 | [9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求) | ✅ 已推演(方案 BResult-based access |
| 3 | StreamEvent 汇聚为 MessageResponse 算法 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | ✅ 已推演(方案 B:显式边界 + BTreeMap 分桶) |
| 4 | ToolCallStart index 归一化 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | ✅ 已推演(自动解决,ToolCallStart 合并到 ContentBlockStart |
| 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-的端到端流程) | **高** |
| 11 | Thinking signature 端到端传递 | [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) | ✅ 已推演(方案 CMessageComplete 兜底 + finalize 回填) |
+191 -36
View File
@@ -51,12 +51,30 @@ pub enum ContentBlock {
},
```
> **🔄 待深入推演:ToolResult 嵌套约束**
> `ContentBlock::ToolResult.content` 定义为 `Vec<ContentBlock>`,理论上可包含 Thinking、ToolUse 等任何类型。
> 但 Anthropic API 限制 `tool_result` 的 content 只能为 `text` 和 `image` 类型。OpenAI 的 tool 消息 content
> 也只能是字符串或 content parts(不含 tool_calls)。
> **需要推演:** IR 层是否需要施加同样的约束?是在类型系统层面约束还是运行时校验?
> 优先级:低(Phase 4 实现时可解决)
> **✅ 推演结论(2026-06-16):采用方案 C —— 运行时过滤 + 构造时辅助 + 文档约定**
>
> **决策:** 保持 `content: Vec<ContentBlock>` 不变,不引入 `ToolResultContent` 类型。
>
> **理由:**
> 1. ToolResult 主要由 LlmCycle 的工具循环构建(`Message::tool_result()`),而非用户手写,
> 构造链本身已倾向于只产生 Text block。运行时过滤只是安全网。
> 2. 引入 `ToolResultContent` 会膨胀类型体系,遍历 content 的代码需要为 ToolResult
> 单独写一层,开发者负担大于收益。
> 3. 未来如果 Provider 放宽约束(如 Anthropic 支持 tool_result 嵌套 tool_use),
> 方向 B 只需要删掉过滤代码,方向 A 需要改类型定义 + 所有 match 分支,成本更高。
>
> **具体做法:**
> - `ContentBlock::ToolResult.content` 保持 `Vec<ContentBlock>` 不变
> - 新增辅助方法 `ContentBlock::is_valid_in_tool_result(&self) -> bool`
> 返回 `self` 是否是 ToolResult 中允许的类型(Text、Image、Audio、File、Extension
> - 每个 Provider 的 IR→原生格式映射层,在将 ToolResult content 转为 Provider 格式时,
> **静默过滤 + warn log**:过滤掉 Thinking、ToolUse、ToolResult 等不允许的 block
> 使用 `tracing::warn!("忽略 ContentBlock::{:?} 在 ToolResult 中", block)` 记录
> - `Message::tool_result()` 构造函数确保只产生 Text block(但不对类型做强制约束)
> - **不返回错误**:非法嵌套不阻塞主流程,静默丢掉无关内容
>
> **何时实现:** Phase 4 实现 AnthropicProvider 的 ToolResult 映射时一起做。
> **影响范围:** Provider 映射层(约 3-5 行过滤代码)+ `Message::tool_result()` 构造。
/// 思考内容(Anthropic thinking / OpenAI reasoning
Thinking {
@@ -243,43 +261,180 @@ pub struct MessageRequest {
}
```
> **🔄 待深入推演:extra 的类型安全性**
> `get_extra<T: DeserializeOwned>` 在运行时通过 `serde_json::from_value` 反序列化,类型错误在
> 调用点才能被发现(返回 `None` 或 panic?)。这可能埋下隐蔽的 bug。
> **需要推演:**
> 1. 能否为每个 Provider 定义类型安全的 extra 结构体(如 `OpenaiExtra`, `AnthropicExtra`),
> 使用 `From<OpenaiExtra> for HashMap<String, Value>` 在传入时转换?
> 2. 如果保持运行时反序列化,`get_extra` 返回类型改为 `Result<T, ExtraError>` 而非 `Option<T>`
> 3. Provider 声明支持的 extra keys + 类型 + 默认值的规范格式?
> 优先级:中(Phase 2 实现前需解决)
> **✅ 推演结论(2026-06-16):方案 B —— Result-based access + 可选 get_extra_as 扩展**
>
> **决策:** 保持 `HashMap<String, serde_json::Value>` 作为存储格式,不引入 Per-Provider extra struct
> 作为硬约束。核心改动是将 `get_extra` 返回类型从 `Option<T>` 改为 `Result<Option<T>, ExtraError>`
> 使类型错误可被发现和传播。
>
> **理由:**
> 1. extra 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
> 方向 APer-Provider struct)的 N+1 膨胀和维护负担超过了收益。
> 2. 三种 extra 参数特性不同,需要不同的处理策略:
> - **非关键参数**`frequency_penalty`、`seed` 等):类型错了静默降级即可
> - **关键参数**`response_format`、`previous_response_id` 等):类型错了必须报错
> - **整体读取**:Provider 想一次性结构化读取时,通过 `get_extra_as` 自行定义 struct
> 3. `get_extra_as` 提供了"struct 方案"的可选路径,但不作为硬约束,
> 每个 Provider 在自己的模块中决定是否使用。
>
> **具体做法:**
> - 新增 `ExtraError` 枚举(`TypeMismatch` / `Deserialize` 两种变体)
> - `get_extra` 签名改为 `Result<Option<T>, ExtraError>`key 不存在 = `Ok(None)`,类型不匹配 = `Err`
> - 新增 `get_extra_opt`:类型不匹配时 warn log + 返回 `None`,用于非关键参数
> - 新增 `get_extra_as`:整体反序列化 extra 到自定义 structProvider 内部使用)
> - `set_extra` 保持 `impl Into<Value>` 不变,不引入 Result(调用点无错误处理负担)
> - Provider 映射层:关键参数用 `get_extra` + `?`,非关键参数用 `get_extra_opt`
> - **不引入运行时 key 声明验证**——Provider 支持的 keys 仅在代码注释中声明,依赖集成测试保证正确性
>
> **何时实现:** Phase 2 实现 `MessageRequest` 类型时一起完成
> **影响范围:** `MessageRequest`(方法签名变更)+ 每个 Provider 的映射层(适配新签名)
#### extra 逃生舱的使用约定
- 每个 Provider 在文档中声明自己读取的 extra key
- Provider 遇到不认识的 key `静默忽略`
- 通过辅助方法安全访问:
### ExtraError —— extra 参数访问错误
```rust
impl MessageRequest {
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Option<T>;
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<Value>);
/// extra 参数访问错误。
///
/// 注意:`NotFound` 不等价于"错误"——optional 参数未设置(key 不存在)是正常状态,
/// 返回 `Ok(None)` 而非错误。本类型只覆盖"存在但类型不对"的场景。
#[derive(thiserror::Error, Debug)]
pub enum ExtraError {
/// key 存在但值的类型与要求不匹配。
#[error("extra 字段 `{key}` 类型不匹配: {details}")]
TypeMismatch {
key: String,
details: String,
},
/// 整体反序列化失败(用于 get_extra_as 场景)。
#[error("extra 反序列化失败: {0}")]
Deserialize(String),
}
```
#### 典型 extra key 约定
### MessageRequest —— extra 访问方法
| key | 值类型 | 使用者 | 说明 |
|-----|--------|--------|------|
| `frequency_penalty` | `f32` | OpenAI | 频率惩罚 |
| `presence_penalty` | `f32` | OpenAI | 存在惩罚 |
| `logit_bias` | `HashMap<String, f32>` | OpenAI | Token 偏置 |
| `seed` | `i64` | OpenAI | 随机种子 |
| `service_tier` | `String` | OpenAI | 服务等级 |
| `user` | `String` | OpenAI | 最终用户标识 |
| `response_format` | `ResponseFormat` | OpenAI | 响应格式 |
| `parallel_tool_calls` | `bool` | OpenAI | 是否并行工具 |
| `built_in_tools` | `Vec<String>` | OpenAI Response | 内置工具列表 |
| `previous_response_id` | `String` | OpenAI Response | 前序响应 ID |
```rust
impl MessageRequest {
/// 核心方法:安全读取单个 extra 字段。
///
/// - key 不存在 → `Ok(None)`
/// - key 存在且类型匹配 → `Ok(Some(value))`
/// - key 存在但类型不匹配 → `Err(ExtraError::TypeMismatch)`
///
/// Provider 映射层对**关键参数**(如 response_format)使用此方法 + `?`。
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ExtraError> {
match self.extra.get(key) {
None => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|e| ExtraError::TypeMismatch {
key: key.to_string(),
details: e.to_string(),
}),
}
}
/// 宽松读取 —— 类型不匹配时 warn log + 返回 `None`。
///
/// 适用于**非关键参数**(如 frequency_penalty、seed、presence_penalty)。
/// Provider 映射层无需处理 Result,遇到类型错误静默降级。
pub fn get_extra_opt<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
match self.get_extra(key) {
Ok(v) => v,
Err(e) => {
tracing::warn!("忽略 extra 参数 `{}`: {}", key, e);
None
}
}
}
/// 整体反序列化 extra 到自定义结构体。
///
/// 适用于 Provider 想在映射层内一次性读取所有 extra 参数的场景。
/// Provider 在自己的模块中定义 struct,借助 `#[serde(deny_unknown_fields)]` 获得约束:
///
/// ```rust,ignore
/// // 在 openai/provider.rs
/// #[derive(Deserialize)]
/// #[serde(deny_unknown_fields)]
/// struct OpenaiExtraParams {
/// frequency_penalty: Option<f32>,
/// seed: Option<i64>,
/// response_format: Option<ResponseFormat>,
/// }
///
/// // 映射层中:
/// let extra: OpenaiExtraParams = request.get_extra_as()?;
/// ```
///
/// **注意:** 使用 `deny_unknown_fields` 时,来自其他 Provider 的 extra key
/// 会导致反序列化失败。不设置 `deny_unknown_fields` 则自动忽略无关 key。
pub fn get_extra_as<T: DeserializeOwned>(&self) -> Result<T, ExtraError> {
let obj = self
.extra
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
serde_json::from_value(serde_json::Value::Object(obj))
.map_err(|e| ExtraError::Deserialize(e.to_string()))
}
/// 设置 extra 参数。
///
/// 值通过 `impl Into<serde_json::Value>` 传入,支持:
/// - `set_extra("user", "abc")` —— `&str` → `Value::String`
/// - `set_extra("seed", Value::from(42_i64))` —— 显式 Value 构造
/// - `set_extra("logit_bias", json!({"2435": -100}))` —— json! 宏
///
/// 对复杂类型推荐使用 `json!()` 宏以保证可读性。
/// 无返回值 —— 调用点无错误处理负担(HashMap insert 不会失败)。
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
self.extra.insert(key.into(), value.into());
}
}
```
### Provider 映射层使用模式
```rust
// === OpenAI provider IR→native 映射示例 ===
// 非关键参数 —— get_extra_opt,类型错了静默降级
let frequency_penalty: Option<f32> = request.get_extra_opt("frequency_penalty");
let presence_penalty: Option<f32> = request.get_extra_opt("presence_penalty");
let seed: Option<i64> = request.get_extra_opt("seed");
// 关键参数 —— get_extra + ?,类型错误必须报出来
let response_format: Option<ResponseFormat> = request.get_extra("response_format").map_err(|e| {
LlmError::Other(format!("extra 参数读取失败: {e}"))
})?;
// 或者 Provider 定义自己的 struct 一把读取
// let extra: OpenaiExtraParams = request.get_extra_as()?;
```
### 使用约定
- 每个 Provider 在模块顶部的注释中声明自己读取的 extra key
- Provider 遇到不认识的 key **静默忽略**
- **key 命名规范**:使用 `snake_case`,与 Provider 原生 API 的参数名一致
- **key 去重规则**extra 中出现的 key 不能与 `MessageRequest` 的命名参数字段重复
(如 `max_tokens` 是命名参数,不能在 extra 中重复设置)
- 非关键参数优先使用 `get_extra_opt`,关键参数使用 `get_extra` + 显式错误处理
### 典型 extra key 约定
| key | 值类型 | 使用者 | 读取方式 | 说明 |
|-----|--------|--------|---------|------|
| `frequency_penalty` | `f32` | OpenAI | `get_extra_opt` | 频率惩罚 |
| `presence_penalty` | `f32` | OpenAI | `get_extra_opt` | 存在惩罚 |
| `logit_bias` | `HashMap<String, f32>` | OpenAI | `get_extra_opt` | Token 偏置 |
| `seed` | `i64` | OpenAI | `get_extra_opt` | 随机种子 |
| `service_tier` | `String` | OpenAI | `get_extra_opt` | 服务等级 |
| `user` | `String` | OpenAI | `get_extra_opt` | 最终用户标识 |
| `response_format` | `ResponseFormat` | OpenAI | `get_extra` | **关键**:响应格式 |
| `parallel_tool_calls` | `bool` | OpenAI | `get_extra_opt` | 是否并行工具 |
| `built_in_tools` | `Vec<String>` | OpenAI Response | `get_extra_opt` | 内置工具列表 |
| `previous_response_id` | `String` | OpenAI Response | `get_extra` | **关键**:前序响应 ID |
### 3.7 MessageResponse —— 统一响应
+339 -33
View File
@@ -76,53 +76,85 @@ pub enum StreamEvent {
/// 消息开始
MessageStart { id: String, model: String },
// ── Content 增量 ──
// ── Content Block 边界 ──
/// Content block 开始(index = 全局 content block 序号)
ContentBlockStart { index: u32, block_type: ContentBlockType },
/// Content block 结束
ContentBlockEnd { index: u32 },
// ── 块内增量(无 index,隐含属于当前活跃 block)──
/// 文本增量
TextDelta { text: String },
/// 思考增量(Anthropic thinking block
ThinkingDelta { text: String },
/// 拒绝增量(OpenAI refusal
/// 拒绝增量(OpenAI refusal,汇聚为 ContentBlock::Text
RefusalDelta { text: String },
// ── Tool Call ──
/// 工具调用开始
ToolCallStart { index: u32, id: String, name: String },
// ── Tool Call 参数(index = block index──
/// 工具参数增量
ToolCallArgumentsDelta { index: u32, arguments: String },
/// 工具调用结束
/// 工具参数结束,可尝试解析 JSON
ToolCallEnd { index: u32 },
// ── 汇总 ──
/// Token 用量
CostUpdate { usage: Usage },
/// 消息完成
MessageComplete { stop_reason: StopReason },
/// Token 用量(字段级合并,见 PartialUsage
CostUpdate { usage: PartialUsage },
/// 消息完成thinking_signature 仅 Anthropic 场景,回填到最后的 Thinking block
MessageComplete { stop_reason: StopReason, thinking_signature: Option<String> },
// ── 错误 ──
Error { message: String },
}
/// ContentBlock 的类型标识,嵌入在 ContentBlockStart 事件中。
#[derive(Debug, Clone)]
pub enum ContentBlockType {
Text,
Thinking,
Refusal,
ToolUse { id: String, name: String },
}
/// CostUpdate 中携带的用量数据——只含本次更新中真正下发的字段。
///
/// 汇聚算法做字段级合并(覆盖各字段的 Some 值),而非全量覆盖。
/// 解决 Provider 分多次下发 Usage 的问题:
/// - OpenAI: 一次全量下发(所有字段都有值)
/// - Anthropic: message_start 时下发 input_tokensmessage_delta 时下发 output_tokens
#[derive(Debug, Clone, Default)]
pub struct PartialUsage {
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
pub completion_tokens_details: Option<CompletionTokensDetails>,
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
> **🔄 待深入推演: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
> **🔄 待深入推演: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 流式转换时需解决)
`ToolCallStart { index, id, name }` 的语义本质是一个 ContentBlock 的开始(类型为 ToolUse),
没有单独存在的必要。合并到 `ContentBlockStart.block_type = ToolUse { id, name }`
- **index 语义统一**ContentBlockStart.index 是全文档唯一的 content block 序号
- **事件减少**:每次 tool_use block 开始少发一个事件,Anthropic 映射更自然(`content_block_start``ContentBlockStart`
- **#4议题自动解决**:index 在 IR 层面始终为全局序号,OpenAI Provider 内部的局部→全局映射完全封装在 Provider 层,
不在 IR 中暴露差异
#### 设计决策:为什么 TextDelta 不带 index
- 单 SSE 连接内 TCP 保证字节序,TextDelta 必然属于最后一个 ContentBlockStart 开始的 block
- 不携带 index 可减少事件体积
- 如果未来 LlmCycle 需要跨 Provider 合并流,可向后兼容地添加 index 字段(降级策略:无 index 时默认归属当前活跃 block)
#### 设计决策:CostUpdate 使用 PartialUsage 做字段级合并
覆盖策略(取最新值)在 Anthropic 场景下出错——Anthropic 分两次下发:
```
CostUpdate #1: { prompt_tokens: Some(100), completion_tokens: None, total_tokens: Some(100) }
CostUpdate #2: { prompt_tokens: None, completion_tokens: Some(50), total_tokens: Some(150) }
```
如果直接 `Usage` 全量覆盖,第一次的 prompt_tokens 会被第二次的 `None` 清零。
改为 `PartialUsage`(字段级 `Option`)后,汇聚算法只更新 `Some` 的字段,避免清零。
#### 与当前 StreamEvent 的对比
@@ -131,10 +163,284 @@ pub enum 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 }` | 语义更准确 |
| `ToolExecutionStarted { tool_name, input, tool_call_id }` | `ContentBlockStart { index, ToolUse { id, name } }` + `ToolCallArgumentsDelta { index, arguments }` | 拆分为 block 边界 + 参数增量 |
| `ToolExecutionCompleted` | **移除** | LlmCycle 层事件,非 Provider 层 |
| — | `ContentBlockStart` / `ContentBlockEnd` | 显式 block 边界标记 |
| — | `BlockContentType` | ContentBlock 类型标识 |
| `CostUpdate { usage: Usage }` | `CostUpdate { usage: PartialUsage }` | 字段级合并,兼容多 Provider |
| `TurnComplete { reason }` | `MessageComplete { stop_reason, thinking_signature: Option<String> }` | 语义更准确 + thinking 签名回填 |
| `Error { message }` | 保留 | ✅ |
| — | `MessageStart { id, model }` | Anthropic 需要 |
| — | `ToolCallEnd { index }` | 明确参数完整时间点 |
| `ToolCallStart { index, id, name }` | **移除** | 合并到 ContentBlockStart.ToolUse |
---
### 4.4 PartialMessageResponse —— 流式事件的汇聚算法
> **✅ 推演结论(2026-06-17):采用方案 B(显式边界)—— ContentBlockStart/End 声明 block 边界,BTreeMap 按 index 分桶组装**
>
> **核心思路:** 放弃"类型切换推断 block 边界"的隐含方案。为 StreamEvent 增加 `ContentBlockStart` 和 `ContentBlockEnd`
> 事件,使每个 block 的开始和结束都有精确的事件标记。汇聚算法由"线性累积 + 隐含 flush + 延迟排序"改为
> **"按 index 分桶 + 排序组装"**,彻底消除对事件到达顺序的依赖。
>
> **决策理由:**
> 1. 类型切换推断在 ToolCallEnd 后跟 TextDelta 的场景下导致顺序错乱(ToolUse 被延迟插入到 Text 之后)
> 2. 显式边界将位置信息绑定到 index,不依赖事件到达时序,容错性更强
> 3. Anthropic 的 `content_block_start/stop` 天然映射为 `ContentBlockStart/End`
> 4. OpenAI 的无边界流式由 Provider 内部分析 delta 类型来合成边界,封装在 Provider 层
> 5. #4ToolCallStart index 归一化)自动解决——index 统一为全局 content block 序号
#### PartialMessageResponse 结构体
```rust
use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;
/// 流式事件的累积状态 —— 按 index 分桶,逐个构建 ContentBlock。
#[derive(Debug, Default)]
pub struct PartialMessageResponse {
// ── 来自 MessageStart ──
pub id: Option<String>,
pub model: Option<String>,
/// 按 index 分桶的 ContentBlock 构建器(BTreeMap 保证升序遍历)
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
/// Tool call 参数累积(按 block index 关联)
pub tool_call_args: HashMap<u32, String>,
/// 已收到 ContentBlockEnd 的 block index 集合
pub block_completion: HashSet<u32>,
/// 当前最后打开的 block index(供无 index 的 TextDelta 定位)
pub last_open_index: Option<u32>,
/// Token 用量(字段级合并后的最终值)
pub usage: Usage,
/// 结束状态
pub stop_reason: Option<StopReason>,
/// MessageComplete 携带的 thinking signature(留待 finalize 回填)
pub thinking_signature: Option<String>,
pub is_errored: bool,
pub is_complete: bool,
}
/// 按 index 分桶的 ContentBlock 构建器。
#[derive(Debug)]
pub enum ContentBlockBuilder {
Text(String),
Thinking { buffer: String, signature: Option<String> },
Refusal(String),
ToolUse { id: String, name: String },
}
```
#### apply_to 算法
```rust
impl StreamEvent {
/// 将当前事件应用到 PartialMessageResponse 上。
/// 返回 true 表示正常处理,false 表示应停止处理后续事件。
pub fn apply_to(&self, state: &mut PartialMessageResponse) -> bool {
match self {
// ──────────────── Meta ────────────────
StreamEvent::MessageStart { id, model } => {
state.id = Some(id.clone());
state.model = Some(model.clone());
true
}
// ──────────────── Block 边界 ────────────────
StreamEvent::ContentBlockStart { index, block_type } => {
let builder = match block_type {
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
buffer: String::new(),
signature: None,
},
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
ContentBlockType::ToolUse { id, name } =>
ContentBlockBuilder::ToolUse { id: id.clone(), name: name.clone() },
};
state.blocks.entry(*index).or_insert(builder);
state.last_open_index = Some(*index);
true
}
StreamEvent::ContentBlockEnd { index } => {
state.block_completion.insert(*index);
true
}
// ──────────────── 块内增量 ────────────────
StreamEvent::TextDelta { text } => {
// 定位到最后打开的 block
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Text(ref mut buf)) = state.blocks.get_mut(&idx) {
buf.push_str(text);
}
} else {
tracing::warn!("TextDelta 到达时无活跃 block");
}
true
}
StreamEvent::ThinkingDelta { text } => {
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Thinking { ref mut buffer, .. }) =
state.blocks.get_mut(&idx)
{
buffer.push_str(text);
}
} else {
tracing::warn!("ThinkingDelta 到达时无活跃 block");
}
true
}
StreamEvent::RefusalDelta { text } => {
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Refusal(ref mut buf)) =
state.blocks.get_mut(&idx)
{
buf.push_str(text);
}
} else {
tracing::warn!("RefusalDelta 到达时无活跃 block");
}
true
}
// ──────────────── Tool Call 参数 ────────────────
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
state
.tool_call_args
.entry(*index)
.or_default()
.push_str(arguments);
true
}
StreamEvent::ToolCallEnd { index } => {
// ToolCallEnd 只做标记,参数在 finalize 时解析
state.block_completion.insert(*index);
true
}
// ──────────────── 汇总 ────────────────
StreamEvent::CostUpdate { usage } => {
Self::apply_cost_update(state, usage);
true // 即使 is_complete 后 CostUpdate 仍然可以到达
}
StreamEvent::MessageComplete {
stop_reason,
thinking_signature,
} => {
state.stop_reason = Some(*stop_reason);
state.thinking_signature = thinking_signature.clone();
state.is_complete = true;
true
}
// ──────────────── 错误 ────────────────
StreamEvent::Error { message } => {
state.is_errored = true;
tracing::error!("流式处理中发生错误: {}", message);
false // 终止处理
}
}
}
/// 字段级合并 CostUpdate(仅覆盖 Some 的字段)。
fn apply_cost_update(state: &mut PartialMessageResponse, update: &PartialUsage) {
if let Some(v) = update.prompt_tokens {
state.usage.prompt_tokens = v;
}
if let Some(v) = update.completion_tokens {
state.usage.completion_tokens = v;
}
if let Some(v) = update.total_tokens {
state.usage.total_tokens = v;
}
if update.completion_tokens_details.is_some() {
state.usage.completion_tokens_details = update.completion_tokens_details;
}
if update.prompt_tokens_details.is_some() {
state.usage.prompt_tokens_details = update.prompt_tokens_details;
}
}
}
```
#### finalize —— 完成逻辑
```rust
impl PartialMessageResponse {
/// 将所有累积状态转化为最终的 MessageResponse。
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
let id = self.id.unwrap_or_else(|| "stream-unknown".to_string());
let model = self.model.unwrap_or_else(|| "unknown".to_string());
let stop_reason = self.stop_reason.unwrap_or(StopReason::Other);
// 1. 按 index 升序遍历所有 blocks,转换为 ContentBlock
let mut content: Vec<ContentBlock> = Vec::new();
for (_index, builder) in std::mem::take(&mut self.blocks).into_iter() {
let block = match builder {
ContentBlockBuilder::Text(text) => ContentBlock::Text { text },
ContentBlockBuilder::Thinking { buffer, mut signature } => {
// 如果 Thinking block 的 signature 尚未填充,用 MessageComplete 的回填
if signature.is_none() {
signature = self.thinking_signature.clone();
}
ContentBlock::Thinking {
text: buffer,
signature,
}
}
ContentBlockBuilder::Refusal(text) => ContentBlock::Text { text },
ContentBlockBuilder::ToolUse { id, name } => {
let arguments = self.tool_call_args.remove(&(_index as u32))
.unwrap_or_default();
let input: Value = serde_json::from_str(&arguments)
.unwrap_or(Value::Null);
ContentBlock::ToolUse { id, name, input }
}
};
content.push(block);
}
Ok(MessageResponse {
id,
model,
message: Message::Assistant { content },
usage: self.usage,
stop_reason,
extra: HashMap::new(),
})
}
/// 检查是否已收到足以构造"有意义"响应的数据。
pub fn is_meaningful(&self) -> bool {
self.id.is_some() && (self.is_complete || self.is_errored)
}
}
```
#### 边界情况处理
| 边界场景 | 处理策略 | 理由 |
|----------|---------|------|
| MessageStart 前收到 TextDelta | warn log,忽略增量 | 不合法流,防御性处理 |
| ToolCallArgumentsDelta 乱序 | HashMap key 天然容忍 | index 作为关联 key,到达顺序不影响最终累积 |
| 多次 CostUpdate | 字段级合并(PartialUsage | OpenAI 一次全量,Anthropic 分两次,OpenAI Response 可能多次 |
| 同一 index 收到多次 ContentBlockStart | `entry(*index).or_insert()` 幂等 | 第二次不覆盖已有内容 |
| MessageComplete 后 CostUpdate 才到 | 不阻断,仍然合并 | Usage 最终值可能最后才到 |
| ContentBlockEnd 缺失 | finalize 时仍然输出内容 | 不完整的 block 也是内容 |
| 无任何 ContentBlockStart | finalize 返回空的 Assistant 消息 | 边界场景,不阻断 |
| Error 后收到其他事件 | Error 返回 false,上层停止调用 apply_to | 一旦出错,不再处理后续事件 |
| thinking_signature 未填 | Thinking block 的 signature 为 None | 安全降级 |
+29 -17
View File
@@ -64,13 +64,19 @@ OpenaiChatResponse → MessageResponse:
> 实际实现中需要解决以下问题:
> 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 还是
> 2. **ContentBlockStart/End 合成**OpenAI 的 SSE 流中没有原生 block 边界标记,只有一个 `delta.content`
> 和 `delta.tool_calls`。转换器需**自行合成**:在第一次出现 `delta.content` 时发出
> `ContentBlockStart(0, Text)`,在 `delta.tool_calls` 出现时发出
> `ContentBlockStart(n, ToolUse { id, name })`。注意 content 和 tool_calls 在同一 chunk **互斥**
> (需验证),合成规则为"来什么类型,合什么边界"。
> 3. **Tool call 全局序号映射**OpenAI 的 `delta.tool_calls[i].index` 是**该 chunk 内的局部索引**
> 需要累积状态(`HashMap<局部index, 全局index>`)来推导全局 content block 序号。
> 对比:**已推演**的 IR 设计将 ToolCallStart 合并到 ContentBlockStartindex 在 IR 层面统一为
> 全局序号,差异完全封装在 Provider 层。
> 4. **多 Choice 的处理**:当前代码只处理 `choices[0]`。新设计中是继续忽略其他 choice 还是
> 通过某种机制暴露(如 `extra` 中携带)?
> 4. **usage 的时机**OpenAI 的 usage 通常在最后一个 chunk 中携带,与 finish_reason 在同一 chunk。
> 是先发 `CostUpdate` 再发 `MessageComplete`,还是反过来?
> 5. **usage 的时机**OpenAI 的 usage 通常在最后一个 chunk 中携带,与 finish_reason 在同一 chunk。
> 是先发 `CostUpdate(PartialUsage{...})` 再发 `MessageComplete`,还是反过来?
> **需要推演:**
> - `OpenaiStreamToEvents<S>` 转换器的状态机设计(跟踪的局部状态、事件产出规则)
> - 边界情况:SSE 行乱序、`[DONE]` 标记的处理、网络断开重连
@@ -117,7 +123,7 @@ Anthropic Response → MessageResponse:
> **🔄 待深入推演:Anthropic 流式状态机设计**
> Anthropic 的流式比 OpenAI 复杂得多——7 种事件类型、需要维护 block index 状态、
> thinking 有 signature 在 `content_block_start` 中单独下发。
> thinking 有 signature 晚于 content_block 下发。
> **需要推演:**
> 1. **状态机状态设计**
> - `PendingStart` → 等待 `message_start`
@@ -126,16 +132,22 @@ Anthropic Response → MessageResponse:
> - `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 }`
> 直接对应最终 content 数组中的位置。`ToolUse { id, name }` 嵌入在 `ContentBlockStart`
> `block_type` 中(已推演:ToolCallStart 已移除,合并到 ContentBlockType::ToolUse
> 而 `input` 通过后续的 `content_block_delta`(含 `input_json_delta`)增量到达。
> 实现时发出 `ContentBlockStart(index, ToolUse { id, name })` 后,
> 通过后续的 `ToolCallArgumentsDelta { index, arguments }` 累积参数。
> 3. **Thinking signature 的附着时机****✅ 已推演(方案 CMessageComplete 兜底)**
> Anthropic 中,thinking block 的 `signature` 不在 `content_block_start` 或 `content_block_delta`
> 中下发,而是在最后的 `message_delta`(与 stop_reason 一起)下发
> **推演结论:**
> - `StreamEvent::MessageComplete { stop_reason, thinking_signature: Option<String> }`
> 携带 signature,不在 ThinkingDelta 或 ContentBlockEnd 中传递
> - Anthropic 映射层在收到 `message_delta` 时,将 `message_delta.thinking.signature`
> 填入 `MessageComplete.thinking_signature`
> - 汇聚算法(`PartialMessageResponse::finalize()`)在遍历 Thinking block 时,
> 如果 builder 中的 `signature` 为 None,用 `self.thinking_signature` 回填
> - 该方案不需新增独立事件,统一在 finalize 时处理,语义清晰
> 4. **block 嵌套的边界情况**Anthropic 的响应中 block 不会嵌套,但允许多个 block 连续出现。
> 需要确保状态机正确处理 block 间切换(前一个 `content_block_stop` → 后一个 `content_block_start`)。
> **需要推演:**
+34 -16
View File
@@ -39,24 +39,42 @@ AnthropicProvider:
→ 最终消息的 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(...) }` 上
> **✅ 推演结论(2026-06-17):方案 C —— MessageComplete 兜底 + finalize 统一回填**
>
> **需要推演:**
> 1. `StreamEvent::ThinkingDelta` 是否需要增加 `signature: Option<String>` 字段?
> 2. 或者改为每个 block 结束时发一个独立的 `ContentBlockSealed { index, block: ContentBlock }` 事件?
> 3. 非流式响应中,Anthropic 的 thinking block 是完整的(含 text 和 signature 在同一 block 中),
> 映射没有问题。但流式响应中,signature 的附着时机需要在 AnthropicProvider 的状态机中准确定义。
> 4. `PartialMessageResponse`(§4.3 待推演)需要能按 index 追踪 thinking block 的 text 累积和 signature 暂存。
> **决策:** Thinking signature 不在 event 级传递,而是通过 `MessageComplete.thinking_signature` 携带,
> 由 `PartialMessageResponse::finalize()` 统一回填到最后一个 Thinking block。
>
> 此问题与 [9d-provider-implementations.md](9d-provider-implementations.md) §5.2 的"Anthropic 流式状态机设计"直接相关,
> 建议在推演状态机时一并解决。
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
> **具体路径:**
> ```
> Anthropic message_delta
> → AnthropicProvider 提取 thinking.signature
> → 发出 MessageComplete { stop_reason, thinking_signature: Some("0x...") }
>
> PartialMessageResponse:
> ContentBlockEnd(thinking_idx) ← signature 还没到,builder 中 signature = None
> ...
> MessageComplete { thinking_signature: Some("0x...") }
> → state.thinking_signature = Some("0x...")
>
> finalize():
> 遍历 blocks → 找到 Thinking builder
> → builder.signature 为 None,用 state.thinking_signature 回填
> → ContentBlock::Thinking { text, signature: Some("0x...") }
> ```
>
> **理由:**
> 1. 不新增独立事件类型(保持 StreamEvent 简洁)
> 2. signature 在 message_delta 中下发(晚于 content_block_stop),MessageComplete 是该时刻的天然载体
> 3. Anthropic 同一响应中最多一个 thinking block,不存在"多 thinking block 归属"的歧义
> 4. 非流式响应中,thinking block 的 signature 直接通过 IR→原生映射填充,路径不变
>
> **影响范围:**
> - `StreamEvent::MessageComplete` 增加 `thinking_signature: Option<String>` 字段
> - `PartialMessageResponse` 增加 `thinking_signature: Option<String>` 暂存字段
> - `PartialMessageResponse::finalize()` 增加回填逻辑(约 3 行代码)
> - AnthropicProvider 流式状态机在 `message_delta` 处理中提取 thinking.signature
>
> **何时实现:** Phase 4 AnthropicProvider 流式状态机实现时一并完成
### 9.3 Multiple ContentBlock 的处理