28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
1050 lines
45 KiB
Markdown
1050 lines
45 KiB
Markdown
# 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 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<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.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<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.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<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 各阶段逐步适配。
|
||
|
||
---
|
||
|
||
## 任务 1:LlmCycle 内部消息切换
|
||
|
||
### 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 是 &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<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 的未使用函数报 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<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 的端到端测试
|