docs: 更新方案9推演结论并调整StreamEvent设计
This commit is contained in:
@@ -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-§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 端到端传递 |
|
||||
| [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--统一请求) | ✅ 已推演(方案 B:Result-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-的端到端流程) | ✅ 已推演(方案 C:MessageComplete 兜底 + finalize 回填) |
|
||||
|
||||
+191
-36
@@ -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 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
|
||||
> 方向 A(Per-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 到自定义 struct(Provider 内部使用)
|
||||
> - `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
@@ -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_tokens,message_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. #4(ToolCallStart 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 | 安全降级 |
|
||||
|
||||
@@ -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 合并到 ContentBlockStart,index 在 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 的附着时机**:**✅ 已推演(方案 C:MessageComplete 兜底)**
|
||||
> 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
@@ -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 的处理
|
||||
|
||||
|
||||
Reference in New Issue
Block a user