# LlmCycle 改造与上层适配 > 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。 > > **相关文件:** > - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等) > - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait(chat、chat_stream 签名) > - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式) > - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等) ## 6. LlmCycle 改造 ### 6.1 内部存储变化 ```rust pub struct LlmCycle { provider: Arc, config: CycleConfig, usage: CostTracker, messages: Vec, // ← 原 Vec system_prompt: Option, hook_executor: Option>, compact_config: Option, compact_state: CompactState, } ``` ### 6.2 build_request → 新签名 ```rust fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest { let mut messages = self.messages.clone(); if let Some(sys_prompt) = &self.system_prompt { // `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System messages.retain(|m| !matches!(m, Message::System { .. })); messages.insert(0, Message::system(sys_prompt)); } // 如果 `system_prompt` 为 None,messages 中的 System 保持原样, // 由各 Provider 在 IR→原生映射层各自处理 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() } } ``` > **✅ 推演结论(2026-06-22):方案 D —— 移除 `system` 字段,IR 中只留一个入口** > > **决策:** 从 `MessageRequest` 中移除 `system: Option` 字段,统一通过 `messages: Vec` > 中的 `Message::System { content }` 表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。 > > **理由:** > 1. **与整套 IR 设计的理念一致**——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、 > Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异", > system prompt 的双重入口是同一个问题,应用同样的原则。 > 2. **消除歧义的最佳方式是砍掉一个入口**——两个入口导致的"谁优先"问题在类型层面就解决了, > 不需要运行时规则。 > 3. **每个 Provider 做自己的转换本来就是 Provider 层的职责**——OpenaiProvider 几乎零成本 > (System 直接序列化为 role=`system`),AnthropicProvider 做提取+移除(约 10 行代码), > DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。 > > **具体做法:** > > **① `MessageRequest`([9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求))** > 删除 `system: Option` 字段。`messages: Vec` 是系统提示的唯一载体。 > > **② `LlmCycle::build_request`(本节上方代码)** > 增加"替换"语义:`self.system_prompt` 设置时,先 `retain` 移除 messages 中已有的所有 > `Message::System`,再插入新的。确保 `system_prompt` 作为权威来源。 > > **③ `OpenaiProvider::ir_to_native`** > 零改动。`Message::System { content }` 直接映射为 `role: "system"`(或 `role: "developer"`)。 > > **④ `AnthropicProvider::ir_to_native`**([9d-provider-implementations.md](9d-provider-implementations.md)) > 新增提取逻辑: > ```rust > // 1. 遍历 messages,收集所有 System 的纯文本内容 > // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个) > // 3. 设置 Anthropic 请求的顶层 `system` 参数 > // 4. 从 messages 中移除所有 System 消息 > // 5. 非文本 ContentBlock 静默丢弃 + warn! log > ``` > > **边界情况处理:** > | `self.system_prompt` | messages 中已有的 System | 结果 | > |---|---|---| > | `None` | 无 System | messages 不变 | > | `None` | `System("B")` | 保留,Provider 层处理 | > | `Some("A")` | 无 System | 插入 System("A") | > | `Some("A")` | `System("B")` | **移除 B,插入 A**(显式设置优先) | > | `Some("A")` | 多个 System("B1"), ("B2") | **移除所有,插入 A** | > > **何时实现:** Phase 2-3 实现 AnthropicProvider 时同步完成。 > **影响范围:** `MessageRequest` 删除一个字段 + `build_request` 增 2 行 `retain` + AnthropicProvider 增约 10 行提取逻辑。 主要变化: - 返回类型 `MessageRequest`(非 `ChatRequest`) - `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装 - `..Default::default()` 填充剩余字段 ### 6.3 submit_with_tools —— 新的 tool 循环逻辑 ```rust pub async fn submit_with_tools( &mut self, prompt: String, registry: &ToolRegistry, ) -> Result { let tools = registry.definitions(); let max_turns = self.config.max_tool_turns.unwrap_or(10); self.messages.push(Message::user(prompt)); self.maybe_compact(); let mut turn = 0; loop { turn += 1; if turn > max_turns { /* error */ } let response = self.submit_request(&tools).await?; // 从 content blocks 中检测 ToolUse(不再需要额外函数) let tool_uses: Vec<&ContentBlock> = response.message.content.iter() .filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None }) .collect(); let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty(); self.messages.push(response.message.clone()); if !should_execute { return Ok(response); } let calls: Vec<(String, Value)> = tool_uses.iter() .map(|b| match b { ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()), _ => unreachable!(), }) .collect(); let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await; for result in results { let content = /* 序列化/截断逻辑 ... */; self.messages.push(Message::tool_result(result.tool_name, content)); } self.maybe_compact(); } } ``` 关键简化: - 不再需要 `has_tool_calls_in_message()` 和 `extract_tool_calls_from_message()` 辅助函数 - 仅仅遍历 `content` 即可发现所有 `ToolUse` block - 逻辑对 Provider 类型**完全透明** ### 6.4 submit_stream —— Provider 直接返回 StreamEvent ```rust pub async fn submit_stream( &mut self, prompt: String, tools: Vec, ) -> Result + Send>>, LlmError> { self.messages.push(Message::user(prompt)); let request = self.build_request(&tools); // Provider 直接返回 StreamEvent 流,无需二次转换 let stream = self.provider.chat_stream(request).await?; // 如果需要,可在 LlmCycle 层叠加额外处理 // (当前 pipeline:stream → hook 触发 → 直接返回) Ok(stream) } ``` 不再需要 `parse_chunk_stream()`(`stream.rs` 中的 `ChunkToEventStream` 可以移除)。 ### 6.5 HookContext 引用调整 ```rust pub struct HookContext<'a> { pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest pub error: Option<&'a LlmError>, pub attempt: u32, pub turn_index: Option, pub plan_step_index: Option, } ``` ### 6.6 compact 逻辑调整 `compact.rs` 中的 `estimate_message_tokens()`、`microcompact()` 需要从 `Vec` 改为 `Vec`,核心逻辑不变。 > **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略** > > 推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入**大小 × 重要性二维决策框架**作为统一的压缩决策模型。 > > --- > > ### 议题 1:`microcompact` 压缩哪个? > > **决策:方案 B —— 同时压缩 `Message::Tool` 和 `Message::Assistant` 中的 `ContentBlock::ToolResult`。** > > **理由:** > 1. 两者存储的是相同语义的数据(工具执行结果),组织结构不同但语义等价。只压缩一种会漏掉另一种。 > 2. LlmCycle 工具循环主路径产生 `Message::Tool`,但 Anthropic 格式反序列化后可能出现 `ToolResult` 嵌入在 Assistant 中。未来 `AnthropicProvider` 实现后后者场景会增加。 > 3. `ContentBlock::ToolUse` **不压缩**——id/name/input 是工具调用的必需元数据,体积小(通常 < 1K),压缩反而破坏后续工具重放。 > > 无需优先级排序(两者不在同一条消息中,不存在先后问题): > - `Message::Tool`:独立的 Tool 消息,压缩其整个 `content` 字段 > - `Message::Assistant` 中的 `ToolResult`:遍历 content 找到所有 `ToolResult` block,压缩其内部的 `content` > > **实现示意:** > > ```rust > pub fn microcompact(messages: &mut [Message], keep_recent: usize, config: &CompactConfig) -> u32 { > if messages.len() <= keep_recent { return 0; } > let prune_start = messages.len() - keep_recent; > let mut freed_tokens: u32 = 0; > > // Phase 1:估算压缩前 token 数 > for msg in &messages[..prune_start] { > freed_tokens += estimate_compressible_tokens(msg, config); > } > > // Phase 2:执行压缩 > for msg in &mut messages[..prune_start] { > apply_compact_action(msg, config); > } > > freed_tokens > } > ``` > > --- > > ### 议题 2:`estimate_message_tokens` 的估算策略 > > **决策:方案 B —— 按 ContentBlock 类型分档估算,非 Text block 使用差异化经验值。** > > **理由:** > - compact 是启发式压缩,不需要精确 token 计数(最终由 Provider 的 tokenizer 精确计算),方案 C 的精度收益不值得这个复杂度 > - 方案 A(统一固定 50)精度太低——Thinking block 可长达几万 token,固定 50 会导致严重低估 > > **分档表:** > > | `ContentBlock` 类型 | 估算方式 | 说明 | > |---|---|------| > | `Text { text }` | `(len * 4).div_ceil(3)` | 保持当前字符估算逻辑 | > | `Image { .. }` | 固定 85 | OpenAI 低分辨率固定定价 | > | `Audio { .. }` | 固定 100 | 音频通常大于图片 | > | `File { source }` | 50 + 文件名文本估算 | 元数据开销 + 文件名 | > | `ToolUse { name, input }` | `estimate_text(name) + estimate_text(input.to_string())` | name + JSON 参数 | > | `ToolResult { content }` | 递归估算内部 content block 列表 | 递归到叶子节点 | > | `Thinking { text }` | `estimate_text(text)` | 按文本估算(内容往往很长) | > | `Extension { data }` | `estimate_text(data.to_string())` | 逃生舱,按 JSON 大小估算 | > > --- > > ### 议题 3:Thinking 是否在压缩范围内 > > **决策:方案 A —— 默认不压缩 Thinking,`CompactConfig` 增加 `compact_thinking: bool` 选项。** > > **理由:** > 1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文 > 2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力 > 3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压) > > ```rust > #[derive(Debug, Clone)] > pub struct CompactConfig { > pub context_window: u32, > pub reserved_tokens: u32, > pub keep_recent: usize, > /// 是否压缩 Thinking 内容。默认 false。 > pub compact_thinking: bool, > } > ``` > > 压缩时将 Thinking block 的 `text` 替换为 `"[pruned thinking]"` 以区别于 ToolResult 的 `"[pruned]"`。 > > --- > > ### ⭐ 新推演维度:大小 × 重要性二维决策框架 > > 上述三个议题各自独立解决,但**压缩强度的选择**需要融合两个互补指标: > > | 维度 | 解决什么问题 | 来源 | 性质 | > |------|------------|------|------| > | **大小** | "这东西有多重?"——为释放空间值不值得动手 | 纯计算(字符数) | 精确 | > | **重要性** | "动了之后损失什么?"——压缩对后续推理的影响 | 多来源综合 | 语义 | > > #### 二维决策矩阵 > > ``` > 重 要 性 > 低 (Action) 高 (Factual) > ┌──────────────────────────────── > 小 │ 跳过 跳过 > │ (< 200 char, 重要但小,不值得为它费劲 > 大 | 不值得) > | │ > 小 │ 截断保留开头 结构化摘要 > │ ("[前200字]...") (保留 JSON 骨架 + 截断数据体) > │ > 大 │ 替换 [pruned] 截断优先 → 元数据保留 > │ (零价值 + 大体积) (实在太大才 [pruned]) > ``` > > #### 重要性的四个来源 > > | 来源 | 时机 | 输入 | 输出 | > |------|------|------|------| > | **① 工具注册声明** | 编译/初始化时 | `ToolDefinition.result_semantic: ResultSemantic` | 基础分:Factual=+1, Action=-1, Mixed=0 | > | **② 内容模式启发式** | compact 触发时 | 检测结果中的 JSON 特征 | 加分:列表数据+1,状态确认-1,错误结果→跳过 | > | **③ 对话轮次衰退** | compact 触发时 | 距离最后一次被引用的轮次 | 每超 K 轮 → -0.5 | > | **④ 后续引用跟踪** | 事后(为下次积累) | Assistant 内容与结果的关键词重叠 | 更新引用时间戳 | > > ```rust > /// 工具注册时声明结果的语义类型 > #[derive(Debug, Clone, Copy)] > pub enum ResultSemantic { > /// 结果是"事实依据"——后续对话可能反复引用(搜索、文档查询) > Factual, > /// 结果是"一次性动作确认"——执行完就过了(发送邮件、创建记录) > Action, > /// 兼具两者特征 / 不确定(默认) > Mixed, > } > > /// 压缩动作 —— 由 [大小, 重要性] 综合决定 > pub enum CompactAction { > /// 不压缩 > Skip, > /// 截断保留前 N 字符 > Truncate { keep: usize }, > /// 尝试保留 JSON 结构 + 截断数据体 > TruncateStructured { keep: usize }, > /// 替换为 [pruned] 或 [pruned thinking] > Replace, > } > ``` > > #### 综合评分 → 动作映射 > > ```rust > fn decide_strategy(size: usize, score: i8) -> CompactAction { > match (size, score) { > (0..=200, _) => CompactAction::Skip, // 太小不压 > (201..=2000, s) if s >= 1 => CompactAction::Skip, // 重要 + 中等 → 保留 > (201..=2000, _) => CompactAction::Truncate { keep: 200 }, > (2001.., s) if s >= 2 => CompactAction::TruncateStructured { keep: 500 }, > (2001.., s) if s >= 1 => CompactAction::Truncate { keep: 200 }, > (2001.., _) => CompactAction::Replace, // 大 + 不重要 → 全换 > } > } > ``` > > **何时实现:** Phase 3 适配 LlmCycle 时同步修改 compact.rs。 > **影响范围:** `compact.rs` 全部重写(保留函数签名,内部逻辑适配 IR)+ `CompactConfig` 变更 + 需要在 `ToolDefinition` 中新增 `result_semantic` 字段(Phase 3)+ `llm-cycle.rs` 中 compact 调用点的接口适配。 --- ## 7. 对上层的影响 ### 7.1 ProviderRegistry —— 零改动 ```rust pub struct ProviderRegistry { providers: HashMap>, default_name: Option, } // 所有方法逻辑不变 ``` ### 7.2 AgentSession —— 极小影响 ```rust // 当前 let response: ChatResponse = cycle.submit_with_tools(input, ®istry).await?; self.cost_so_far.add(&response.usage); // Usage 类型不变 // 新 let response: MessageResponse = cycle.submit_with_tools(input, ®istry).await?; self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致 ``` `response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。 ### 7.3 Agent trait —— 零改动 `Agent::tool_definitions()` 返回 `Vec`,类型不变。 ### 7.4 PromptComposer —— 内部类型替换 `PromptComposer` 内部存储从 `Vec` 改为 `Vec`,公共方法签名不变(返回 `Message` 类型)。 --- ## 8. 兼容性策略 ### 8.1 From trait 双向转换 提供新旧类型之间的转换,平滑迁移: ```rust // IR → 旧类型(兼容层) impl From for MessageResponse { ... } impl From for ChatResponse { ... } impl From for Message { ... } impl From for OpenaiChatMessage { ... } impl From for MessageRequest { ... } impl From for ChatRequest { ... } ``` ### 8.2 LlmCycle 兼容 getter ```rust impl LlmCycle { // 新 pub fn messages(&self) -> &[Message] { &self.messages } // 兼容旧 pub fn messages_openai(&self) -> Vec { self.messages.iter().map(|m| m.clone().into()).collect() } } ``` ### 8.3 测试代码的过渡 测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。