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

1050 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<OpenaiChatMessage>` 切换为 `Vec<Message>`,利用新类型的表达能力重写核心逻辑
---
## 目标
1.`LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`**移除 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<Value, ToolError>,
}
// 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 7ConversationMemory 适配)依赖 `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<ContentBlock> },
User { content: Vec<ContentBlock> },
UserImage { data: String, mime_type: String, detail: ImageDetail },
Assistant { content: Vec<ContentBlock> },
ToolResult { tool_call_id: String, content: Vec<ContentBlock>, 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<String>) -> Self {
Message::System { content: vec![ContentBlock::Text { text: text.into() }] }
}
pub fn user_text(text: impl Into<String>) -> Self {
Message::User { content: vec![ContentBlock::Text { text: text.into() }] }
}
pub fn assistant(text: impl Into<String>) -> Self {
Message::Assistant { content: vec![ContentBlock::Text { text: text.into() }] }
}
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, 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<String>` 字段,在 `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<String>` 字段
2. **移除** `with_system_prompt()` 方法
3. 调用方改为使用 `Message::system_text()` + `with_messages()` 或 builder 模式
4. `build_request()` 直接使用 `self.messages`,不做任何 System 消息插入
```rust
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<Message>, // 调用方自行管理所有消息类型
// system_prompt 已移除
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
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<StreamEvent, LlmError>`,但函数签名声明 `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<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + 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<MessageResponse> = 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.2Task 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 2Task 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 2Task 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<OpenaiChatMessage> → Vec<Message>)
含 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.0system_prompt 已在 Phase 2.0 移除)
[Phase 2.2] 流处理 + 工具循环
Task 3 submit_stream 简化 ← 简化方案(非 mpsc),含 FIX-E
Task 4 工具循环清洗 + FIX-Atool_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<OpenaiChatMessage>` | `Vec<Message>` | 使用 builder 模式的代码 |
| `with_system_prompt()` | 设置纯文本,`build_request()` 隐式插入 | **已废弃**`#[deprecated]`),调用方使用 `Message::system_text()` + `with_messages()` | `src/agent/session.rs:141-143` |
| `submit()` 返回类型 | `Result<ChatResponse, LlmError>` | `Result<MessageResponse, LlmError>` | 所有调用 `submit()` 的代码 |
| `submit_with_tools()` 返回类型 | `Result<ChatResponse, LlmError>` | `Result<MessageResponse, LlmError>` | `AgentSession` |
| `submit_messages()` 签名 | `Vec<OpenaiChatMessage>` 参数 | `Vec<Message>` 参数 | 直接调用方 |
| `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 各阶段逐步适配。
---
## 任务 1LlmCycle 内部消息切换
### 1a:结构体字段变更
```rust
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
// 从 Vec<OpenaiChatMessage> 改为 Vec<Message>
messages: Vec<Message>,
// system_prompt 字段已移除(审查 FIX-D):
// 调用方通过 Message::system_text() + with_messages() 管理系统提示
// 向后兼容:with_system_prompt() 标记为 #[deprecated],内部转为 Message::System 后插入
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
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<OpenaiChatMessage>) -> Self
// 目标
pub fn with_messages(mut self, messages: Vec<Message>) -> Self
```
### 1d`extend_messages()` 方法签名变更
```rust
// 当前
pub fn extend_messages(&mut self, messages: Vec<OpenaiChatMessage>)
// 目标
pub fn extend_messages(&mut self, messages: Vec<Message>)
```
### 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<OpenaiChatMessage>`)转为 `Vec<Message>`。Phase 2 中 `self.messages` 本身就是 `Vec<Message>`,所以这个转换步骤被移除。
同时,`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<Vec<OpenaiTool>>`OpenAI 特定包装)变为 `Vec<ToolDefinition>``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<StreamEvent>` 流。但为了在流结束后仍能向 `self.messages``Vec<OpenaiChatMessage>`)追加响应,需要通过 `message_to_chat_message` 转换。
Phase 2 中,`self.messages` 已经是 `Vec<Message>`,所以不需要转换了。
> ⚠️ **审查结论**mpsc 通道方案因 Rust 的 Pin/借用限制不可行(`&mut self` 无法进入闭包,流是延迟求值的),采用**简化方案**——`submit_stream` 内部不管理消息历史,由调用方在流结束后自行调用 `push_message()`。
>
> 详情见"审查发现与交叉阶段修复计划"中 FIX-E 的分析。
```rust
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + 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<MessageResponse> = 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<Mutex<Vec<Message>>>` 并在流闭包内安全修改——但这是 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 是 &ValueDisplay 实现生成紧凑 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<ToolDefinition>,
) -> Result<MessageResponse, LlmError> {
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<Message>,
tools: Vec<ToolDefinition>,
) -> Result<MessageResponse, LlmError> {
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, LlmError> {
// 逻辑与之前一致,但返回类型改为 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<dyn MemoryStore>,
session_id: String,
config: ConversationMemoryConfig,
messages: Vec<Message>, // 从 OpenaiChatMessage 改为 Message
message_ids: Vec<String>,
compact_state: CompactState,
}
```
### 7b:序列化/反序列化
```rust
// 当前
serde_json::from_str::<OpenaiChatMessage>(&item.content)
// 目标
serde_json::from_str::<Message>(&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<Message>`
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 的未使用函数报 warningpub 函数不报 warning。用 `git grep` 做精准确认)
- `cargo clippy``dead_code` 级别警告
---
## 任务 9:适配 `prompt/composer.rs`(如需要)
`validate_messages()` 当前检查 `OpenaiChatMessage` 序列中的 role 顺序规则。如果该函数被 `AgentSession``LlmCycle` 调用,需要适配新 `Message` 类型。
检查步骤:
1. 搜索 `validate_messages` 的调用者
2. 如果被上游调用且上游已切换为 `Vec<Message>`,则适配
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 的端到端测试