# Phase 2 实施计划:LlmCycle 简化(逻辑重构) > **所属方案**:[10-llm-provider-refinement.md](10-llm-provider-refinement.md) > > **前置条件**:Phase 0 + Phase 1 已完成——新类型已就位,所有 Provider 已适配新 trait 签名 > > **⚠️ 交叉阶段修复**:审查发现部分关键修复需在 Phase 0 期间处理,否则 Phase 2 无法直接实施。详见下文"审查发现与交叉阶段修复计划"。 > > **目标**:移除 Phase 0 遗留的临时转换层,将 `LlmCycle` 内部消息存储从 `Vec` 切换为 `Vec`,利用新类型的表达能力重写核心逻辑 --- ## 目标 1. 将 `LlmCycle` 内部消息存储从 `Vec` 切换为 `Vec`,**移除 Phase 0 引入的 `OpenaiChatMessage ←→ Message` 转换层** 2. 流处理循环重构:利用 `MessageComplete.full_response` 直接拿到完整响应,去掉手动 delta 累积 3. 工具循环清洗:从 `MessageResponse.message` 的 content 中直接提取 `ContentBlock::ToolUse` 4. `compact.rs` 适配新 `Message` 类型 5. `ConversationMemory` 的消息类型同步切换 --- ## 审查发现与交叉阶段修复计划 > 本方案于 2026-06-30 经架构审查发现 4 个必须修复的问题和多个需在 Phase 0 提前处理的依赖项。以下修复计划按"所属阶段"分类,标注责任文件和建议修复方式。 ### 🔴 交叉阶段修复(Phase 0 期间完成,否则 Phase 2 受阻) #### FIX-A:`ToolInvocation` 增加 `tool_call_id` 字段 | 属性 | 值 | |------|-----| | **严重程度** | 🔴 阻碍性 | | **影响** | 工具结果回传时 tool_call_id 丢失,Anthropic Provider 上必然失败 | | **文件** | `src/tools/registry.rs`(`ToolInvocation` 结构体)、`src/llm/cycle.rs`(调用点) | **问题跟踪**: - `ToolInvocation`(`registry.rs:17-35`)只有 `tool_name`、`input`、`output` 三个字段,**没有 `tool_call_id`** - `invoke_all()` 签名(`registry.rs:149-151`)接收 `Vec<(String, Value)>`(name, args),**未传递 id** - `invoke()` 签名(`registry.rs:131`)是 `(&self, name: &str, args: Value)`,**未传递 id** - 当前代码用 `result.tool_name` 冒充 `tool_call_id`(`cycle.rs:617`)——对 OpenAI 碰巧可用,Anthropic 必然失败 - `has_tool_calls_in_message`(`cycle.rs:645`)基于旧 `OpenaiChatMessage::Assistant { tool_calls }` 判断,Phase 2 需同步改为检查 `ContentBlock::ToolUse` **修复方案**: ```rust // ToolInvocation 增加 tool_call_id(必选,每次调用必有真实值) pub struct ToolInvocation { pub tool_call_id: String, // 新增 pub tool_name: String, pub input: Value, pub output: Result, } // ToolContext 不增加 tool_call_id: // 当前 ToolContext::new(name, "") 中 trace_id 已被设为空串, // tool_call_id 通过 invoke_all→invoke→ToolInvocation 传递即可, // 工具实现方不需要感知 tool_call_id。 ``` > **关于 `ToolContext` 的决策**:不向 `ToolContext` 增加 `tool_call_id`。理由是 `ToolContext` 目前通过 `invoke()` 内部构造(`registry.rs:140`:`ToolContext::new(name, "")`),传递的是 `tool_name` 而非 `tool_call_id`。若增加 `tool_call_id` 字段会要求所有 `invoke` 调用链都感知 id——这增加了复杂度,而工具实现方实际并不需要 tool_call_id(结果是按调用顺序返回的,工具通过在消息历史中搜索 `ToolUse` 来关联上下文)。**`tool_call_id` 仅在 `ToolInvocation` 中保存,供 `submit_with_tools` 回传时使用。** **数据流**:`extract_tool_calls_from_message()` 返回 `(id, name, args)` → `invoke_all` 签名改为 `Vec<(String, String, Value)>`(id, name, args)→ `ToolInvocation.tool_call_id` → `submit_with_tools()` 回传工具结果时使用 `result.tool_call_id`。`invoke()` 内部在构造 `ToolInvocation` 时即可获得 tool_call_id。 **建议归属**:Phase 2.2(随 Task 4 统一改 `registry.rs` + `cycle.rs`,避免切割到 Phase 0 导致 scope 漂移) **影响范围**: - `ToolInvocation::new()` 签名从 3 参数变为 4 参数(`registry.rs:28`) - `invoke()` 签名从 `(&self, name, args)` 改为 `(&self, tool_call_id, name, args)` 或内部通过包装传递(`registry.rs:131`) - `invoke_all()` 签名从 `Vec<(String, Value)>` 改为 `Vec<(String, String, Value)>`(`registry.rs:149-151`) - `cycle.rs:584-594` 中 `|(_id, name, args)|` → `|(id, name, args)|` 不再丢弃 id - 测试代码中的 `ToolInvocation` 和 `invoke_all` 调用点同步更新 --- #### FIX-B:`Message` + `ContentBlock` 的 serde 派生在 Phase 0 添加 | 属性 | 值 | |------|-----| | **严重程度** | 🔴 阻碍性 | | **影响** | Phase 0→Phase 2 过渡期 ConversationMemory 无法使用新类型 | **问题**:Phase 2 的 Task 7(ConversationMemory 适配)依赖 `Message` 实现 `Serialize`/`Deserialize`。如果 Phase 0 定义 `Message` 时未添加 serde 派生,Phase 2 需要回头修改 Phase 0 创建的文件,造成"谁创建谁维护"责任混乱。 **修复方案**:Phase 0 定义新类型时直接添加 serde 派生: ```rust #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Message { System { content: Vec }, User { content: Vec }, UserImage { data: String, mime_type: String, detail: ImageDetail }, Assistant { content: Vec }, ToolResult { tool_call_id: String, content: Vec, is_error: bool }, } ``` `ContentBlock` 同样添加 serde 派生(tag 策略在 Phase 0 设计时决定,建议 `#[serde(rename_all = "snake_case")]` + `tag = "type"` 或 untagged 视用法而定)。 此序列化格式**仅用于内部存储**(ConversationMemory 持久化),不与任何 Provider 协议交互,故无需考虑外部兼容性。`UserImage` 的 JSON 形如 `{"type": "user_image", "data": "...", "mime_type": "image/png", "detail": "auto"}`。 **同步添加构造方法**(`impl Message` 块中的便捷构造函数,FIX-D 的 deprecated shim 和 Phase 2 各 Task 都依赖它们): ```rust impl Message { pub fn system_text(text: impl Into) -> Self { Message::System { content: vec![ContentBlock::Text { text: text.into() }] } } pub fn user_text(text: impl Into) -> Self { Message::User { content: vec![ContentBlock::Text { text: text.into() }] } } pub fn assistant(text: impl Into) -> Self { Message::Assistant { content: vec![ContentBlock::Text { text: text.into() }] } } pub fn tool_result(tool_call_id: impl Into, text: impl Into, is_error: bool) -> Self { Message::ToolResult { tool_call_id: tool_call_id.into(), content: vec![ContentBlock::Text { text: text.into() }], is_error, } } } ``` **建议归属**:Phase 0,作为类型定义的一部分(零成本加法) --- #### FIX-C:`ContentBlock` 变体列表在 Phase 0 设计时明确 | 属性 | 值 | |------|-----| | **严重程度** | 🔴 阻碍性 | | **影响** | 方案中 `estimate_block_tokens` 的 match 分支引用了未定义的 `ContentBlock::ToolResult`,导致 Phase 2 实施时类型不匹配 | **问题**:方案 §6b 的 `estimate_block_tokens` 中出现 `ContentBlock::ToolResult { content, .. }` 分支(第 374 行),但父方案 `10-llm-provider-refinement.md` §2.1 定义 `ContentBlock` 时并未包含 `ToolResult` 变体。`ToolResult` 是 `Message` 的变体(消息级角色),不是 `ContentBlock` 的变体(内容块级角色)。 **分析**:在 Anthropic 协议中,`Assistant` 消息的 content 可以直接包含 `tool_result` block——这对应 `ContentBlock::ToolResult` 的应用场景(嵌套在其他消息的 content 中);而 `Message::ToolResult` 对应 OpenAI 的 `role: "tool"` 消息(消息列表的顶层变体)。二者语义层级不同,**可以合理共存**。 **修复方案**:Phase 0 设计 `ContentBlock` 时明确是否包含 `ToolResult` 变体。推荐包含,因为: 1. Anthropic 协议支持 Assistant 消息内嵌 tool_result block 2. 简化 Phase 2 的 `estimate_block_tokens` 实现(不需要在 match 中分流) 3. 让 `compact` 估算函数能递归处理嵌套的 tool result content Phase 2 的 `estimate_block_tokens` 改为穷尽 match(不再使用 `_ => 50` 兜底),编译器强制检查所有变体覆盖。 **建议归属**:Phase 0 设计决策,Phase 2 代码编写时确认 --- #### FIX-D:`system_prompt` 字段彻底移除 | 属性 | 值 | |------|-----| | **严重程度** | 🔴 阻碍性 | | **影响** | 与父方案 §2.4 设计意图矛盾,"两条路径"(字段 + messages 中的 System 消息)可能导致重复/覆盖 | **问题**:方案 §2a 保留 `system_prompt: Option` 字段,在 `build_request()` 中转为 `Message::System` 插入 messages 开头。但父方案 §2.4 明确意图:"system prompt 通过 `Message::System` 在 messages 中表达,Provider 映射层自行处理差异"。保留字段导致两条路径: - 路径 1:调用方通过 `with_system_prompt()` 设置纯文本 - 路径 2:调用方直接通过 `with_messages()` 传入 `Message::System` `build_request()` 中通过 `!messages.iter().any(|m| matches!(m, Message::System { .. }))` 尝试检测"是否已有系统消息",但本质上无法区分"用户主动传入的 System"和"之前阶段插入的 System"。 **修复方案**: 1. **彻底移除** `system_prompt: Option` 字段 2. **移除** `with_system_prompt()` 方法 3. 调用方改为使用 `Message::system_text()` + `with_messages()` 或 builder 模式 4. `build_request()` 直接使用 `self.messages`,不做任何 System 消息插入 ```rust pub struct LlmCycle { provider: Arc, config: CycleConfig, usage: CostTracker, messages: Vec, // 调用方自行管理所有消息类型 // system_prompt 已移除 hook_executor: Option>, compact_config: Option, compact_state: CompactState, } ``` 如需向后兼容(有已有调用方使用 `with_system_prompt`),可在 `LlmCycle::new()` 或 `submit()` 前设置一个短暂的弃用期: ```rust #[deprecated(since = "0.x.0", note = "请改用 Message::system_text() + with_messages()")] pub fn with_system_prompt(mut self, prompt: String) -> Self { self.messages.insert(0, Message::system_text(prompt)); self } ``` **建议归属**:Phase 2.0(与 Task 1 消息切换一步到位,杜绝过渡期。不在 Phase 2.1 留存已废弃字段) **调用方迁移**(需在 Phase 2.0 实施前确认): | 调用位置 | 代码 | 迁移方式 | |---------|------|---------| | `src/agent/session.rs:141-143` | `if let Some(prompt) = self.agent.system_prompt() { cycle = cycle.with_system_prompt(prompt.to_string()); }` | 改为 `cycle = cycle.with_messages(vec![Message::system_text(prompt)])`,或在 `submit_turn` 内部直接 push | **依赖**:`Message::system_text()` 构造方法必须在 Phase 0 定义 `Message` 类型时同步添加(见 FIX-B)。`#[deprecated]` shim 中调用的 `Message::system_text(prompt)` 依赖于它。 --- ### 🟡 Phase 2 内部修复 #### FIX-E:`submit_stream` 返回类型修正 + 闭包借用问题解决 | 属性 | 值 | |------|-----| | **严重程度** | 🟡 必须修复(编译错误 + API 语义断裂) | | **影响** | 方案代码存在编译错误(yield Ok(event) vs 签名不匹配);`&mut self` 借用冲突;post_request hook 消息不完整 | **三个问题**: 1. **返回类型不匹配**(§3a 第 159 行):`yield Ok(event.clone())` 产生 `Result`,但函数签名声明 `Item = StreamEvent`。 2. **`&mut self` 借用冲突**(§3a 第 163 行):`self.messages.push(...)` 在 `stream!` 闭包中需要 `&mut self`,但闭包只能捕获共享引用。 3. **post_request hook 消息不完整**(§3a 第 149 行):`let post_request = self.build_request(&tools)` 在流启动前执行,此时 `self.messages` 只有 user 消息,不包括即将到来的 Assistant 响应。 **⚠️ 审查结论:mpsc 方案不可行,采用简化方案作为主方案** **为什么 mpsc 方案不成立**(两次审查确认): | 问题 | 描述 | 根本原因 | |------|------|---------| | `rx` 竞争 | mpsc `rx` 只能有一个消费者,但代码中两个 `async move` 块都试图消费 | 编译错误 | | 阻塞破坏流契约 | `response_rx.await` 在返回流前阻塞等待整个流结束 | 不是流式 API | | `StreamEvent::StreamEnd` 不存在 | 引用了未定义的变体 | 新旧 StreamEvent 均无此变体 | | `tokio::spawn` task leak | JoinHandle 被丢弃,调用方 drop Stream 后 task 可能永久挂起 | 资源泄漏 | **核心矛盾**:`stream!` 宏产生的异步生成器无法持有 `&mut self`(Pin 限制),且流是"延迟求值"的——`submit_stream` 返回流对象时流尚未执行。这意味着**没有任何通道方案能让 `submit_stream` 在返回流之前就拿到完整响应**。如果想在流结束后更新 `self.messages`,必须让调用方在流结束后显式调用一个方法。 **采用简化方案作为主方案**: ```rust pub async fn submit_stream( &mut self, prompt: String, tools: Vec, ) -> Result + Send>>, LlmError> { self.messages.push(Message::user_text(prompt)); self.maybe_compact(); let request = self.build_request(&tools); // PreRequest hook if let Some(ref executor) = self.hook_executor { let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request); let results = executor.execute(HookEvent::PreRequest, &ctx).await; if results.iter().any(|r| r.should_block) { return Err(LlmError::Other("Blocked by pre-request hook".to_string())); } } let event_stream = self.provider.chat_stream(request).await?; let hook_executor = self.hook_executor.clone(); Ok(Box::pin(stream! { use futures_util::StreamExt; let mut event_stream = event_stream; while let Some(result) = event_stream.next().await { match result { Ok(event) => { yield event; } Err(e) => { if let Some(ref executor) = hook_executor { let ctx = HookContext::new(HookEvent::OnError).with_error(&e); executor.execute(HookEvent::OnError, &ctx).await; } yield StreamEvent::Error { message: e.to_string() }; break; } } } // PostRequest hook:注意此处的消息列表不包含本次 Assistant 响应 // (因为在流启动前 request 就已构建完成) if let Some(ref executor) = hook_executor { let ctx = HookContext::new(HookEvent::PostRequest); executor.execute(HookEvent::PostRequest, &ctx).await; } })) } ``` **变更**: 1. `yield event` 直接产生 `StreamEvent`(与签名 `Item = StreamEvent` 一致)——移除 `Ok()` 包装 2. 不再在闭包中操作 `self.messages`——调用方在收到 `MessageComplete` 事件后自行调用 `cycle.push_message(full_response.message)` 3. post_request hook 明确文档化限制 **调用方使用模式**: ```rust let mut stream = cycle.submit_stream(prompt, tools).await?; let mut final_response: Option = None; while let Some(event) = stream.next().await { match &event { StreamEvent::MessageComplete { full_response } => { final_response = Some(full_response.clone()); } // 处理其他事件... _ => {} } } // 流结束后更新消息历史 if let Some(resp) = final_response { cycle.push_message(resp.message.clone()); // usage update via cycle.usage().add(&resp.usage); } ``` **建议归属**:Phase 2.2,Task 3 实施时 --- #### FIX-F:`microcompact` 跳过 `is_error: true` 的工具结果 | 属性 | 值 | |------|-----| | **严重程度** | 🟡 建议修复 | | **影响** | 错误结果包含对 LLM 理解失败原因至关重要的信息,压缩后 LLM 无法理解 | **问题**:§6c 的 `microcompact()` 对所有 `Message::ToolResult` 无条件压缩,包括 `is_error: true` 的结果。 **修复方案**:只压缩 `is_error: false` 的结果: ```rust // microcompact 压缩阶段 for msg in &messages[..prune_start] { if matches!(msg, Message::ToolResult { is_error: false, .. }) { freed_tokens += estimate_single_message_tokens(msg); } } // 替换内容 for msg in &mut messages[..prune_start] { if let Message::ToolResult { content, is_error: false } = msg { *content = vec![ContentBlock::Text { text: "[pruned]".to_string() }]; } } ``` **建议归属**:Phase 2,Task 6 实施时一并处理 --- #### FIX-G:`submit_with_tools()` 中 `extract_tool_calls_from_response` 的 `input` 类型修正 | 属性 | 值 | |------|-----| | **严重程度** | 🟡 编译类型安全(`serde_json::to_string` 在 `&Value` 上调用) | | **影响** | 编译通过,但 serde_json::to_string(&input) 在 `&Value` 上调用可能产生意外格式 | **问题**:§4a 第 231-233 行 `extract_tool_calls_from_response` 中的 `input` 是 `&Value`(来自 `ContentBlock::ToolUse { input, .. }`),调用 `serde_json::to_string(input)` 可能序列化为多余的引号包裹格式。 **修复方案**:使用 `serde_json::to_string(input).unwrap_or_default()` 没问题(`Value` 实现了 `Serialize`),但建议改为 `input.to_string()`(Value 的 Display 实现生成紧凑 JSON): ```rust let args = input.to_string(); // 或 serde_json::to_string(input).unwrap_or_default() ``` **建议归属**:Phase 2,Task 4 实施时处理 --- #### FIX-H:测试基础设施迁移清单 | 属性 | 值 | |------|-----| | **严重程度** | 🟡 必须覆盖(否则测试无法编译) | | **影响** | 测试代码使用旧类型,Phase 2 实施后编译失败 | **需迁移的测试辅助代码**(按文件列出): | 文件 | 行号 | 实体 | 当前类型 | 目标类型 | |------|------|------|---------|---------| | `src/llm/cycle.rs` | 700-725 | `MockProvider` | `ChatRequest`/`ChatResponse` | `MessageRequest`/`MessageResponse` | | `src/llm/cycle.rs` | 731-737 | `assistant_text_response()` | 返回 `ChatResponse` | 返回 `MessageResponse` | | `src/llm/cycle.rs` | 739-765 | `assistant_tool_call_response()` | `OpenaiToolCall` 构造 | `ContentBlock::ToolUse` 构造 | | `src/agent/session.rs` | 202-224 | `MockProvider` | `ChatRequest`/`ChatResponse` | `MessageRequest`/`MessageResponse` | | `src/agent/session.rs` | 241-247 | `assistant_text()` | 返回 `ChatResponse` | 返回 `MessageResponse` | | `src/agent/session.rs` | 141-143 | `with_system_prompt()` 调用点 | `OpenaiChatMessage` | `Message::system_text()` | | `src/agent/builder.rs` | 126-132 | `StubProvider` | `ChatRequest`/`ChatResponse` | `MessageRequest`/`MessageResponse` | | `src/tools/registry.rs` | 28 | `ToolInvocation::new()` | 3 参数 | 4 参数(含 `tool_call_id`) | **建议归属**:Phase 2 尾部,作为一个单独的"测试适配 PR"集中修改(不在各 Task 中随改),避免业务代码和测试代码混杂。Phase 2 业务代码合入后,紧接着提交测试适配 PR。 --- ### 🔄 实施顺序调整 原方案的线性顺序(1→2→3→4→5→6→7→8→9)考虑了依赖关系,但存在优化空间。结合交叉修复后,推荐以下实施顺序: ``` [Phase 0] 类型层(含交叉修复) FIX-B Message + ContentBlock serde 派生 + 构造方法 ← 类型定义时添加 FIX-C ContentBlock 变体列表明确 ← 设计决策 [Phase 2.0] 基础设施切换 Task 1 LlmCycle 消息切换 (Vec → Vec) 含 FIX-D system_prompt 一步到位移除(与消息类型切换同时做) Task 7 ConversationMemory 切换 ← 可并行,依赖 FIX-B [Phase 2.1] 核心逻辑简化 Task 5 submit/submit_messages/submit_request ← 依赖 Phase 2.0,最简单先做 Task 2 build_request 简化 ← 依赖 Phase 2.0(system_prompt 已在 Phase 2.0 移除) [Phase 2.2] 流处理 + 工具循环 Task 3 submit_stream 简化 ← 简化方案(非 mpsc),含 FIX-E Task 4 工具循环清洗 + FIX-A(tool_call_id) ← 统一改 registry + cycle [Phase 2.3] 附属适配 + 清理 Task 6 compact.rs 适配 ← 可并行,含 FIX-F Task 9 prompt/composer.rs 适配 Task 8 清理临时转换函数(含 has_tool_calls_in_message) [Phase 2.4] 收尾 FIX-H 测试基础设施迁移 ← 单独 PR 集中处理 (可选) FIX-G 代码风格优化 ``` --- | 操作 | 文件 | 说明 | |------|------|------| | 修改 | `src/llm/cycle.rs` | 主要修改:消息类型切换 + 逻辑简化 | | 修改 | `src/llm/compact.rs` | 适配 `Message` 类型(`microcompact` / `should_compact` / 估算函数) | | 修改 | `src/memory/conversation.rs` | 消息类型从 `OpenaiChatMessage` 切换为 `Message` | | 修改 | `src/prompt/composer.rs` | `validate_messages()` 适配 `Message` 类型 | | 清理 | 各处 | 删除 Phase 0 引入的临时转换函数 | **不涉及**: - `src/llm/cycle/usage.rs` — `Usage` 类型不变 - `src/llm/cycle/retry.rs` — `RetryConfig` 和 `should_retry` 不引用消息类型 - `src/llm/error.rs` — `LlmError` 不引用消息类型 --- ## Phase 2 对外影响(breaking changes 汇总) 以下变更影响所有使用 `LlmCycle` 的上游代码。Phase 2 实施前需确认调用方已适配。 | 变更 | 旧方式 | 新方式 | 影响文件 | |------|--------|--------|---------| | `messages()` 返回类型 | `&[OpenaiChatMessage]` | `&[Message]` | 所有读取消息历史的上游代码 | | `with_messages()` / `extend_messages()` 参数 | `Vec` | `Vec` | 使用 builder 模式的代码 | | `with_system_prompt()` | 设置纯文本,`build_request()` 隐式插入 | **已废弃**(`#[deprecated]`),调用方使用 `Message::system_text()` + `with_messages()` | `src/agent/session.rs:141-143` | | `submit()` 返回类型 | `Result` | `Result` | 所有调用 `submit()` 的代码 | | `submit_with_tools()` 返回类型 | `Result` | `Result` | `AgentSession` | | `submit_messages()` 签名 | `Vec` 参数 | `Vec` 参数 | 直接调用方 | | `submit_stream()` 调用方式 | 流内回传 `StreamEvent`(旧),`submit_stream` 负责 `self.messages.push` | `StreamEvent`(新)透传,调用方需在 `MessageComplete` 后手动调用 `cycle.push_message()` | 调用流式 API 的代码 | | `compact` 模块接收 | `&[OpenaiChatMessage]` 参数 | `&[Message]` 参数 | 仅在 `LlmCycle` 和 `ConversationMemory` 内部调用,对外无影响 | **迁移建议**:所有上游变更集中在 `src/agent/session.rs` 和测试代码中。建议 Phase 2.0 实施前先在 `session.rs` 中完成消息类型对齐,然后随 Phase 2 各阶段逐步适配。 --- ## 任务 1:LlmCycle 内部消息切换 ### 1a:结构体字段变更 ```rust pub struct LlmCycle { provider: Arc, config: CycleConfig, usage: CostTracker, // 从 Vec 改为 Vec messages: Vec, // system_prompt 字段已移除(审查 FIX-D): // 调用方通过 Message::system_text() + with_messages() 管理系统提示 // 向后兼容:with_system_prompt() 标记为 #[deprecated],内部转为 Message::System 后插入 hook_executor: Option>, compact_config: Option, compact_state: CompactState, } ``` ### 1b:`messages()` 方法签名变更 ```rust // 当前 pub fn messages(&self) -> &[OpenaiChatMessage] { &self.messages } // 目标 pub fn messages(&self) -> &[Message] { &self.messages } ``` ### 1c:`with_messages()` 方法签名变更 ```rust // 当前 pub fn with_messages(mut self, messages: Vec) -> Self // 目标 pub fn with_messages(mut self, messages: Vec) -> Self ``` ### 1d:`extend_messages()` 方法签名变更 ```rust // 当前 pub fn extend_messages(&mut self, messages: Vec) // 目标 pub fn extend_messages(&mut self, messages: Vec) ``` ### 1e:新增 `push_message()` 公共方法 供 `submit_stream()` 消费方在收到 `MessageComplete` 事件后手动追加消息: ```rust /// 追加单条消息到历史尾部(供 submit_stream 消费方使用)。 pub fn push_message(&mut self, msg: Message) { self.messages.push(msg); } ``` --- ## 任务 2:简化 `build_request()` ### 2a:移除转换步骤 + 移除 system_prompt 逻辑(审查 FIX-D) Phase 0 中的 `build_request()` 需要调用 `chat_message_to_message` 将 `self.messages`(`Vec`)转为 `Vec`。Phase 2 中 `self.messages` 本身就是 `Vec`,所以这个转换步骤被移除。 同时,`system_prompt` 字段已移除(审查 FIX-D),调用方通过 `Message::system_text()` + `with_messages()` 自主管理系统提示消息。 ```rust fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest { // 直接使用 self.messages,不做任何 System 消息隐式插入 let messages = self.messages.clone(); MessageRequest { model: self.config.model.clone(), messages, tools: tools.to_vec(), tool_choice: ToolChoice::Auto, max_tokens: self.config.max_tokens, temperature: self.config.temperature, top_p: None, stop_sequences: vec![], stream: false, thinking: None, extra: HashMap::new(), } } ``` > **注意**:`tools` 的类型从 `Option>`(OpenAI 特定包装)变为 `Vec`(`MessageRequest` 中使用的新类型)。Provider 映射层负责将 `ToolDefinition` 转换为协议特定的工具定义格式。`build_request()` 不再需要 `OpenaiTool::Function` 包装。 ### 2b:`with_system_prompt()` 弃用过渡 ```rust #[deprecated(since = "0.x.0", note = "请改用 Message::system_text() + with_messages()")] pub fn with_system_prompt(mut self, prompt: String) -> Self { self.messages.insert(0, Message::system_text(prompt)); self } ``` 此方法保留一个过渡周期(标注 `#[deprecated]`),编译器发出弃用警告但不阻止编译。后续版本中移除。 ### 2c:`with_messages()` 中忽略 system_prompt 冲突 `with_messages()` 不再做去重检查或 System 消息过滤——调用方传入什么就是什么。`build_request()` 直接使用 `self.messages`,不插入任何额外消息。 --- ## 任务 3:简化 `submit_stream()` ### 3a:流处理循环变更(审查 FIX-E — 简化方案) Phase 0 中,`submit_stream()` 从 `provider.chat_stream()` 直接获得 `Result` 流。但为了在流结束后仍能向 `self.messages`(`Vec`)追加响应,需要通过 `message_to_chat_message` 转换。 Phase 2 中,`self.messages` 已经是 `Vec`,所以不需要转换了。 > ⚠️ **审查结论**:mpsc 通道方案因 Rust 的 Pin/借用限制不可行(`&mut self` 无法进入闭包,流是延迟求值的),采用**简化方案**——`submit_stream` 内部不管理消息历史,由调用方在流结束后自行调用 `push_message()`。 > > 详情见"审查发现与交叉阶段修复计划"中 FIX-E 的分析。 ```rust pub async fn submit_stream( &mut self, prompt: String, tools: Vec, ) -> Result + Send>>, LlmError> { self.messages.push(Message::user_text(prompt)); self.maybe_compact(); let request = self.build_request(&tools); // PreRequest hook if let Some(ref executor) = self.hook_executor { let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request); let results = executor.execute(HookEvent::PreRequest, &ctx).await; if results.iter().any(|r| r.should_block) { return Err(LlmError::Other("Blocked by pre-request hook".to_string())); } } let event_stream = self.provider.chat_stream(request).await?; let hook_executor = self.hook_executor.clone(); Ok(Box::pin(stream! { use futures_util::StreamExt; let mut event_stream = event_stream; while let Some(result) = event_stream.next().await { match result { Ok(event) => { yield event; } Err(e) => { if let Some(ref executor) = hook_executor { let ctx = HookContext::new(HookEvent::OnError).with_error(&e); executor.execute(HookEvent::OnError, &ctx).await; } yield StreamEvent::Error { message: e.to_string() }; break; } } } // PostRequest hook:注意此处的消息列表不包含本次 Assistant 响应 if let Some(ref executor) = hook_executor { let ctx = HookContext::new(HookEvent::PostRequest); executor.execute(HookEvent::PostRequest, &ctx).await; } })) } ``` **调用方使用模式**: ```rust let mut stream = cycle.submit_stream(prompt, tools).await?; let mut final_response: Option = None; while let Some(event) = stream.next().await { match &event { StreamEvent::MessageComplete { full_response } => { final_response = Some(full_response.clone()); } // 其他事件(TextDelta, ContentBlockStart 等)透传给 UI/消费方 _ => {} } } // 流结束后更新消息历史 if let Some(resp) = final_response { cycle.push_message(resp.message.clone()); // usage 更新:cycle.usage() 返回 &CostTracker,调用方自行 add } ``` **变更**: - `yield` 直接产生 `StreamEvent`(签名 `Item = StreamEvent`),不再包裹 `Ok()` - `self.messages.push(response.message)` 由调用方在流结束后通过 `push_message()` 完成 - `self.usage.add(&response.usage)` 由调用方通过 `cycle.usage().add()` 完成 - 无需 `message_to_chat_message` 转换 ### 3b:设计决策记录 **决策**:采用简化方案(已验证 mpsc 方案因 Rust 语言限制不可行)。 **已知限制**(调用方需知晓): - post_request hook 收到的消息列表中**不包含本次响应**——如需完整消息列表,调用方应在流结束后手动触发 Hook **判断标准**(如后续需切换方案): - 如果未来有需求要求 `submit_stream` 在流结束后自动推消息到 `self.messages`,可考虑将 `self.messages` 包裹为 `Arc>>` 并在流闭包内安全修改——但这是 API 层面的 breaking change --- ## 任务 4:清洗工具循环 ### 4a:`submit_with_tools()` 中的 tool 循环 当前 `submit_with_tools()` 使用 Phase 0 的临时逻辑: - `response.message` 是 `Message` 类型(新枚举) - 使用 `has_tool_calls_in_response` 和 `extract_tool_calls_from_response` 解析 tool calls - 使用 `OpenaiChatMessage::tool_result()` 推送 tool 结果到 `self.messages` Phase 2 中: - `self.messages.push(response.message.clone())` —— 直接推送 `Message`(不需要转换) - tool 结果回传:`self.messages.push(Message::tool_result(id, content, is_error))` - 从 `response.message` 提取 tool calls:遍历 `content` 中的 `ContentBlock::ToolUse` ```rust fn has_tool_calls_in_response(response: &MessageResponse) -> bool { match &response.message { Message::Assistant { content } => { content.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })) } _ => false, } } fn extract_tool_calls_from_response(response: &MessageResponse) -> Vec<(String, String, String)> { match &response.message { Message::Assistant { content } => { content.iter().filter_map(|b| { if let ContentBlock::ToolUse { id, name, input } = b { // input 是 &Value,Display 实现生成紧凑 JSON let args = input.to_string(); Some((id.clone(), name.clone(), args)) } else { None } }).collect() } _ => vec![], } } ``` ### 4b:工具结果回传(依赖 FIX-A) > ⚠️ **前置依赖**:`ToolInvocation` 必须在 Phase 0/1 期间增加 `tool_call_id` 字段(见 FIX-A),否则以下代码无法编译。 ```rust for result in results { let is_error = result.output.is_err(); let content = match result.output { Ok(ref value) => { let serialized = serde_json::to_string(value).unwrap_or_default(); truncate_tool_result(&serialized, max_bytes) } Err(ref e) if e.is_recoverable() => format!("错误: {}", e), Err(ref e) => return Err(LlmError::Other(format!("工具不可恢复错误: {}", e))), }; self.messages.push(Message::tool_result( result.tool_call_id.clone(), // ← 来自 FIX-A 新增字段 content, is_error, )); } ``` **数据流**(FIX-A 修复后): 1. `extract_tool_calls_from_response()` 返回 `Vec<(String, String, String)>`(id, name, args) 2. `invoke_all()` 接收 `Vec<(String, String, Value)>`(id, name, args) 3. `ToolInvocation.tool_call_id` 保存原始 id 4. 回传时使用 `result.tool_call_id` 关联原始调用 --- ## 任务 5:简化 `submit()` / `submit_messages()` / `submit_request()` ### 5a:`submit()` ```rust pub async fn submit( &mut self, prompt: String, tools: Vec, ) -> Result { self.messages.push(Message::user_text(prompt)); self.maybe_compact(); let mut attempts = 0; loop { let request = self.build_request(&tools); // ... hook 处理 ... match self.provider.chat(request).await { Ok(response) => { // ... hook 处理 ... self.messages.push(response.message.clone()); self.usage.add(&response.usage); return Ok(response); } Err(e) => { /* 重试逻辑 */ } } } } ``` **变更**: - `self.messages.push(OpenaiChatMessage::user_text(prompt))` → `self.messages.push(Message::user_text(prompt))` - `self.messages.push(response.message.clone())` — `response.message` 已经是 `Message` 类型 - 无需 `message_to_chat_message` 转换 ### 5b:`submit_messages()` ```rust pub async fn submit_messages( &mut self, messages: Vec, tools: Vec, ) -> Result { let request = MessageRequest { model: self.config.model.clone(), messages, tools: tools.to_vec(), tool_choice: ToolChoice::Auto, max_tokens: self.config.max_tokens, temperature: self.config.temperature, ..Default::default() }; // ... hook + provider.chat(request) ... match self.provider.chat(request).await { Ok(response) => { self.usage.add(&response.usage); Ok(response) } Err(e) => Err(e), } } ``` ### 5c:`submit_request()` ```rust async fn submit_request( &mut self, tools: &[ToolDefinition], ) -> Result { // 逻辑与之前一致,但返回类型改为 MessageResponse // ... } ``` --- ## 任务 6:适配 `compact.rs` ### 6a:`estimate_single_message_tokens()` ```rust fn estimate_single_message_tokens(msg: &Message) -> u32 { let role_overhead: u32 = 4; let content_tokens = match msg { Message::System { content } | Message::User { content } | Message::Assistant { content } => estimate_content_blocks_tokens(content), Message::UserImage { .. } => { // UserImage 固定估算 50 } Message::ToolResult { content, .. } => estimate_content_blocks_tokens(content), }; role_overhead + content_tokens } ``` **变更**:当前使用 `ContentField`(旧类型,区分 String 和 Array),新类型统一为 `&[ContentBlock]`。 ### 6b:`estimate_content_blocks_tokens()` ```rust fn estimate_content_blocks_tokens(blocks: &[ContentBlock]) -> u32 { blocks.iter().map(estimate_block_tokens).sum() } fn estimate_block_tokens(block: &ContentBlock) -> u32 { match block { ContentBlock::Text { text } => estimate_text_tokens(text), ContentBlock::ToolResult { content, .. } => estimate_content_blocks_tokens(content), ContentBlock::Thinking { text, .. } => estimate_text_tokens(text), ContentBlock::ToolUse { input, .. } => { // tool_use 的 input 是 JSON Value estimate_text_tokens(&serde_json::to_string(input).unwrap_or_default()) } _ => 50, // Image/Audio/File/Extension 固定估算 } } ``` ### 6c:`microcompact()`(审查 FIX-F) > ⚠️ **FIX-F**:只压缩 `is_error: false` 的 ToolResult。错误结果包含失败原因,压缩后 LLM 无法理解。 ```rust pub fn microcompact(messages: &mut [Message], keep_recent: usize) -> u32 { if messages.len() <= keep_recent { return 0; } let prune_start = messages.len() - keep_recent; let mut freed_tokens: u32 = 0; // 第一遍:计算可释放的 token(仅非错误结果) for msg in &messages[..prune_start] { if matches!(msg, Message::ToolResult { is_error: false, .. }) { freed_tokens += estimate_single_message_tokens(msg); } } // 第二遍:替换内容(仅非错误结果) for msg in &mut messages[..prune_start] { if let Message::ToolResult { content, is_error: false } = msg { *content = vec![ContentBlock::Text { text: "[pruned]".to_string() }]; } } freed_tokens } ``` **变更**: - `OpenaiChatMessage::Tool` → `Message::ToolResult` - `ContentField::Array(vec![OpenaiContentPart::Text { text: "[pruned]" }])` → `vec![ContentBlock::Text { text: "[pruned]".to_string() }]` - 新增条件:`is_error: false` 时才压缩(保留错误诊断信息) ### 6d:`should_compact()` ```rust pub fn should_compact( messages: &[Message], config: &CompactConfig, state: &CompactState, ) -> bool { // 逻辑不变,只是消息类型改为 &[Message] if state.consecutive_failures >= MAX_CONSECUTIVE_FAILURES { return false; } let tokens = estimate_message_tokens(messages); tokens >= config.threshold() } ``` --- ## 任务 7:同步适配 `ConversationMemory` `src/memory/conversation.rs` 的消息类型从 `OpenaiChatMessage` 切换为 `Message`: ### 7a:结构体字段 ```rust pub struct ConversationMemory { store: Arc, session_id: String, config: ConversationMemoryConfig, messages: Vec, // 从 OpenaiChatMessage 改为 Message message_ids: Vec, compact_state: CompactState, } ``` ### 7b:序列化/反序列化 ```rust // 当前 serde_json::from_str::(&item.content) // 目标 serde_json::from_str::(&item.content) ``` **注意**:`Message` 枚举需要实现 `Serialize` / `Deserialize`。 > ✅ **已在 Phase 0 处理**(审查 FIX-B):Phase 0 定义 `Message` 和 `ContentBlock` 类型时直接添加 `#[derive(Serialize, Deserialize)]` 和 `#[serde(tag = "type", rename_all = "snake_case")]`。Phase 2 直接使用即可。 序列化格式示例(仅用于内部存储,不与 Provider 协议交互): ```json {"type": "user", "content": [{"type": "text", "text": "hello"}]} {"type": "user_image", "data": "...", "mime_type": "image/png", "detail": "auto"} {"type": "assistant", "content": [{"type": "text", "text": "Hi!"}, {"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Beijing"}}]} {"type": "tool_result", "tool_call_id": "call_1", "content": [{"type": "text", "text": "Sunny, 25°C"}], "is_error": false} ``` ### 7c:消息 ID 前缀兼容 ```rust fn session_prefix(&self) -> String { format!("conv:{}:", self.session_id) // 格式不变 } ``` 序列化格式变化后,旧消息无法反序列化为新类型。由于项目尚未 release,可以接受这个不兼容。 --- ## 任务 8:清理临时转换函数 删除 Phase 0 引入的以下函数(确认不再被引用后删除): 1. **`chat_message_to_message()`** — 在 `build_request()` 中已不再需要(`self.messages` 已是 `Vec`) 2. **`message_to_chat_message()`** — 在 `submit_stream()` 中已不再需要(流结束后直接使用 `full_response.message`) 3. **`content_to_blocks()`** — 如果只在上述两个函数中使用,一并删除 4. **`blocks_to_content_field()`** — 同上 5. **`has_tool_calls_in_message()`** — `cycle.rs:645`,基于 `OpenaiChatMessage::Assistant { tool_calls }` 判断。Phase 2 中由 `has_tool_calls_in_response()`(基于 `ContentBlock::ToolUse`)替代 6. **`extract_tool_calls_from_message()`** — `cycle.rs:658`,基于 `OpenaiToolCall` 提取。Phase 2 中由 `extract_tool_calls_from_response()`(基于 `ContentBlock::ToolUse`)替代 **验证方法**: - 全局搜索函数名(`git grep`),确认在所有文件中的引用计数归零 - `cargo build` 通过(编译器对非 pub 的未使用函数报 warning,pub 函数不报 warning。用 `git grep` 做精准确认) - `cargo clippy` 无 `dead_code` 级别警告 --- ## 任务 9:适配 `prompt/composer.rs`(如需要) `validate_messages()` 当前检查 `OpenaiChatMessage` 序列中的 role 顺序规则。如果该函数被 `AgentSession` 或 `LlmCycle` 调用,需要适配新 `Message` 类型。 检查步骤: 1. 搜索 `validate_messages` 的调用者 2. 如果被上游调用且上游已切换为 `Vec`,则适配 3. 适配内容:match 分支从 `OpenaiChatMessage::Tool { .. }` 改为 `Message::ToolResult { .. }`,从 `OpenaiChatMessage::Assistant { tool_calls: Some(..) }` 改为检查 `ContentBlock::ToolUse` --- ## 验证方式 1. **编译检查**:`cargo build` 通过 2. **单元测试**:`cargo test` 全部通过 3. **集成测试**: - 多轮对话(`submit` + `submit_with_tools`)端到端正常 - 工具调用循环正常(含 `tool_call_id` 正确关联) - 流式响应(`submit_stream`)正常 - 上下文压缩(`compact`)超过 token 阈值后正确压缩 - **错误路径**:`provider.chat()` 返回错误时,消息历史不被污染(`self.messages.push` 在确认成功后才执行) - **序列化 roundtrip**:`Message` → JSON → `Message` 保持所有字段(含 `UserImage`、`ToolResult.is_error`) - **边界情况**:`submit_with_tools` 收到空 content 的 `ContentBlock`、包含 `UserImage` 的消息 - **hook 集成**:PreRequest hook 正确收到 `MessageRequest`(而非旧的 `ChatRequest`) 4. **clippy 检查**:`cargo clippy` 无新增警告 5. **diff 确认**:`git diff` 确认 Phase 0 引入的临时转换函数已被删除 ## 回滚方案 ⚠️ **审查评估**:feature flag 方案在两套 StreamEvent 类型共存时工程量大,实际安全网依赖 checkpoint 打 tag。 | 回退对象 | 可行策略 | 复杂度 | 说明 | |---------|---------|-------|------| | `submit_stream()` | feature flag `legacy_stream` | 🔴 高 | 需要同时保留旧 `StreamEvent` 类型和旧 `chat_stream` Provider 调用路径。除非新旧 StreamEvent 完全兼容,否则工程量大。**不推荐** | | `compact.rs` | 模块同步回退 | 🟢 低 | `should_compact`/`microcompact` 只在 `LlmCycle` 和 `ConversationMemory` 中调用,签名变化后可整体回退 | | 全局 | git checkout + 重做 | 🟢 极低 | 项目尚未 release,无外部消费者。每个子阶段完成后打 tag(`phase2-taskX-checkpoint`),允许跳跃回退 | **推荐回退策略**:不在代码层面做 feature flag,而是: 1. 每个子阶段(Phase 2.0/2.1/2.2/2.3)完成后创建 git tag 2. 如果某一子阶段出现问题,`git revert` 该子阶段的所有 commit 3. 修复后重新提交,不保留两套实现共存 ## 开放事项 - [已解决] `Message` 的 `Serialize` / `Deserialize` 派生设计 → **移至 Phase 0**(FIX-B) - [已解决] `system_prompt` 字段的最终去留 → **Phase 2 彻底移除**(FIX-D) - `ConversationMemory` 序列化格式变更后的数据兼容性(项目未 release,可接受) - `submit_with_tools_stream()` 未来设计:流式响应 + 自动工具循环的组合场景 - `ToolContext` 是否增加 `tool_call_id` 字段(FIX-A 方案二中可选) - `ContentBlock::Extension` 逃生舱的具体使用场景 - Thinking signature 的端到端测试