将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
45 KiB
Phase 2 实施计划:LlmCycle 简化(逻辑重构)
所属方案:10-llm-provider-refinement.md
前置条件:Phase 0 + Phase 1 已完成——新类型已就位,所有 Provider 已适配新 trait 签名
⚠️ 交叉阶段修复:审查发现部分关键修复需在 Phase 0 期间处理,否则 Phase 2 无法直接实施。详见下文"审查发现与交叉阶段修复计划"。
目标:移除 Phase 0 遗留的临时转换层,将
LlmCycle内部消息存储从Vec<OpenaiChatMessage>切换为Vec<Message>,利用新类型的表达能力重写核心逻辑
目标
- 将
LlmCycle内部消息存储从Vec<OpenaiChatMessage>切换为Vec<Message>,移除 Phase 0 引入的OpenaiChatMessage ←→ Message转换层 - 流处理循环重构:利用
MessageComplete.full_response直接拿到完整响应,去掉手动 delta 累积 - 工具循环清洗:从
MessageResponse.message的 content 中直接提取ContentBlock::ToolUse compact.rs适配新Message类型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_idinvoke_all()签名(registry.rs:149-151)接收Vec<(String, Value)>(name, args),未传递 idinvoke()签名(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
修复方案:
// 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 派生:
#[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 都依赖它们):
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 变体。推荐包含,因为:
- Anthropic 协议支持 Assistant 消息内嵌 tool_result block
- 简化 Phase 2 的
estimate_block_tokens实现(不需要在 match 中分流) - 让
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"。
修复方案:
- 彻底移除
system_prompt: Option<String>字段 - 移除
with_system_prompt()方法 - 调用方改为使用
Message::system_text()+with_messages()或 builder 模式 build_request()直接使用self.messages,不做任何 System 消息插入
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() 前设置一个短暂的弃用期:
#[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 消息不完整 |
三个问题:
-
返回类型不匹配(§3a 第 159 行):
yield Ok(event.clone())产生Result<StreamEvent, LlmError>,但函数签名声明Item = StreamEvent。 -
&mut self借用冲突(§3a 第 163 行):self.messages.push(...)在stream!闭包中需要&mut self,但闭包只能捕获共享引用。 -
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,必须让调用方在流结束后显式调用一个方法。
采用简化方案作为主方案:
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;
}
}))
}
变更:
yield event直接产生StreamEvent(与签名Item = StreamEvent一致)——移除Ok()包装- 不再在闭包中操作
self.messages——调用方在收到MessageComplete事件后自行调用cycle.push_message(full_response.message) - post_request hook 明确文档化限制
调用方使用模式:
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 的结果:
// 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):
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:结构体字段变更
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() 方法签名变更
// 当前
pub fn messages(&self) -> &[OpenaiChatMessage] { &self.messages }
// 目标
pub fn messages(&self) -> &[Message] { &self.messages }
1c:with_messages() 方法签名变更
// 当前
pub fn with_messages(mut self, messages: Vec<OpenaiChatMessage>) -> Self
// 目标
pub fn with_messages(mut self, messages: Vec<Message>) -> Self
1d:extend_messages() 方法签名变更
// 当前
pub fn extend_messages(&mut self, messages: Vec<OpenaiChatMessage>)
// 目标
pub fn extend_messages(&mut self, messages: Vec<Message>)
1e:新增 push_message() 公共方法
供 submit_stream() 消费方在收到 MessageComplete 事件后手动追加消息:
/// 追加单条消息到历史尾部(供 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() 自主管理系统提示消息。
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() 弃用过渡
#[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 的分析。
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;
}
}))
}
调用方使用模式:
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
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),否则以下代码无法编译。
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 修复后):
extract_tool_calls_from_response()返回Vec<(String, String, String)>(id, name, args)invoke_all()接收Vec<(String, String, Value)>(id, name, args)ToolInvocation.tool_call_id保存原始 id- 回传时使用
result.tool_call_id关联原始调用
任务 5:简化 submit() / submit_messages() / submit_request()
5a:submit()
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()
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()
async fn submit_request(
&mut self,
tools: &[ToolDefinition],
) -> Result<MessageResponse, LlmError> {
// 逻辑与之前一致,但返回类型改为 MessageResponse
// ...
}
任务 6:适配 compact.rs
6a:estimate_single_message_tokens()
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()
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 无法理解。
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::ToolResultContentField::Array(vec![OpenaiContentPart::Text { text: "[pruned]" }])→vec![ContentBlock::Text { text: "[pruned]".to_string() }]- 新增条件:
is_error: false时才压缩(保留错误诊断信息)
6d:should_compact()
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:结构体字段
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:序列化/反序列化
// 当前
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 协议交互):
{"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 前缀兼容
fn session_prefix(&self) -> String {
format!("conv:{}:", self.session_id) // 格式不变
}
序列化格式变化后,旧消息无法反序列化为新类型。由于项目尚未 release,可以接受这个不兼容。
任务 8:清理临时转换函数
删除 Phase 0 引入的以下函数(确认不再被引用后删除):
chat_message_to_message()— 在build_request()中已不再需要(self.messages已是Vec<Message>)message_to_chat_message()— 在submit_stream()中已不再需要(流结束后直接使用full_response.message)content_to_blocks()— 如果只在上述两个函数中使用,一并删除blocks_to_content_field()— 同上has_tool_calls_in_message()—cycle.rs:645,基于OpenaiChatMessage::Assistant { tool_calls }判断。Phase 2 中由has_tool_calls_in_response()(基于ContentBlock::ToolUse)替代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 类型。
检查步骤:
- 搜索
validate_messages的调用者 - 如果被上游调用且上游已切换为
Vec<Message>,则适配 - 适配内容:match 分支从
OpenaiChatMessage::Tool { .. }改为Message::ToolResult { .. },从OpenaiChatMessage::Assistant { tool_calls: Some(..) }改为检查ContentBlock::ToolUse
验证方式
- 编译检查:
cargo build通过 - 单元测试:
cargo test全部通过 - 集成测试:
- 多轮对话(
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)
- 多轮对话(
- clippy 检查:
cargo clippy无新增警告 - 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,而是:
- 每个子阶段(Phase 2.0/2.1/2.2/2.3)完成后创建 git tag
- 如果某一子阶段出现问题,
git revert该子阶段的所有 commit - 修复后重新提交,不保留两套实现共存
开放事项
- [已解决]
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 的端到端测试