# 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 && !messages.iter().any(|m| matches!(m, Message::System { .. })) { messages.insert(0, Message::system(sys_prompt)); } 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() } } ``` > **🔄 待深入推演:system prompt 的双重表达冲突** > `MessageRequest` 同时存在两个 system prompt 入口: > 1. `messages` 中的 `Message::System { ... }` > 2. 顶层字段 `system: Option` > > 问题在于: > - **Anthropic** 要求 system prompt 只能用顶层 `system` 参数,`messages` 中不能包含 system role > - **OpenAI** 没有顶层 `system` 参数,system prompt 放在 messages 中 > - 当前 `build_request` 的逻辑是:将 `self.system_prompt` 插入到 `messages` 开头(作为 `Message::System`), > 但**不**填充 `request.system`。这对 OpenAI 是正确的,但对 Anthropic 是错的——AnthropicProvider > 需要反向提取:遍历 messages 找出 System 消息,移到 `request.system`,再从 messages 中移除。 > > **需要推演:** > 1. **方案 A(当前方案):** `build_request` 始终把 system prompt 放进 messages,AnthropicProvider 内部 > 做反向提取。问题:当 LlmCycle 的消息历史中已有一个 `Message::System`,`self.system_prompt` 也被设置时, > 两个 source 哪个优先?当前代码只检查 `messages` 中是否已有 System 来决定是否插入,但 LlmCycle 的 > `with_system_prompt` 设置的 prompt 可能与消息历史中已有的 System 内容不同。 > 2. **方案 B:** 统一通过顶层 `request.system` 传递,OpenaiProvider 内部将 `request.system` 转为 > System message 插入 messages 开头(与 AnthropicProvider 方向相反)。缺点:OpenAI 用户习惯 > 在 messages 中直接放 system message,可能不设置顶层 system。 > 3. **方案 C:** 约定 `messages` 中的 System 和顶层 `system` 互斥——`build_request` 检查两者, > 同时存在时报错或合并(顶层 system 覆盖 messages 中的 System)。 > 优先级:高(Phase 2-3 实现 AnthropicProvider 前必须解决) 主要变化: - 返回类型 `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`,核心逻辑不变。 > **🔄 待深入推演:compact 在 IR 上的具体改法与 token 估算** > 当前的 `microcompact` 通过将 `Tool` 消息的内容替换为 `"[pruned]"` 来释放 token。在 IR 中, > 工具结果有两种表达方式: > 1. `Message::Tool { content: [ContentBlock::Text { text: "大段工具结果..." }], tool_call_id }` > 2. `Message::Assistant { content: [.., ContentBlock::ToolResult { content: [Text], .. }] }`(如果使用 IR 的 ToolResult block) > > **需要推演:** > 1. IR 下 `microcompact` 压缩哪个?只压缩 `Message::Tool` 还是也压缩 `ContentBlock::ToolResult`? > 如果两者共存,谁先被压缩? > 2. `estimate_message_tokens` 的估算策略:当前按字符数估算(4 字符 ≈ 1 token),对 `ContentBlock` > 的新类型(Thinking、ToolUse、ToolResult、Image 等)如何估算?Image block 用固定 50 token 的经验值 > 是否仍然合理? > 3. `ContentBlock::Thinking` 是否在压缩范围内?thinking 内容通常不应该被压缩(因为思考是推理过程, > 压缩后可能丢失上下文)。是否需要为 `CompactConfig` 增加 `compact_thinking` 选项? > 优先级:中(Phase 3 适配 LlmCycle 时需同步修改) --- ## 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 中先保留旧类型别名以减少改动。