11 KiB
LlmCycle 改造与上层适配
本文档从
9-llm-provider-unified-interface.md拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。相关文件:
- 9b-ir-type-system.md — IR 类型定义(Message、MessageRequest、ContentBlock 等)
- 9c-llm-provider-trait.md — LlmProvider trait(chat、chat_stream 签名)
- 9d-provider-implementations.md — Provider 实现策略(system prompt 处理方式)
- 9f-edge-cases.md — 边界情况(tool 定义传递路径等)
6. LlmCycle 改造
6.1 内部存储变化
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
system_prompt: Option<String>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
}
6.2 build_request → 新签名
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<String>字段,统一通过messages: Vec<Message>中的Message::System { content }表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。理由:
- 与整套 IR 设计的理念一致——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、 Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异", system prompt 的双重入口是同一个问题,应用同样的原则。
- 消除歧义的最佳方式是砍掉一个入口——两个入口导致的"谁优先"问题在类型层面就解决了, 不需要运行时规则。
- 每个 Provider 做自己的转换本来就是 Provider 层的职责——OpenaiProvider 几乎零成本 (System 直接序列化为 role=
system),AnthropicProvider 做提取+移除(约 10 行代码), DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。具体做法:
①
MessageRequest(9b-ir-type-system.md) 删除system: Option<String>字段。messages: Vec<Message>是系统提示的唯一载体。②
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) 新增提取逻辑:// 1. 遍历 messages,收集所有 System 的纯文本内容 // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个) // 3. 设置 Anthropic 请求的顶层 `system` 参数 // 4. 从 messages 中移除所有 System 消息 // 5. 非文本 ContentBlock 静默丢弃 + warn! log边界情况处理:
self.system_promptmessages 中已有的 System 结果 None无 System messages 不变 NoneSystem("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 循环逻辑
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<MessageResponse, LlmError> {
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即可发现所有ToolUseblock - 逻辑对 Provider 类型完全透明
6.4 submit_stream —— Provider 直接返回 StreamEvent
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(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 引用调整
pub struct HookContext<'a> {
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
pub error: Option<&'a LlmError>,
pub attempt: u32,
pub turn_index: Option<u32>,
pub plan_step_index: Option<usize>,
}
6.6 compact 逻辑调整
compact.rs 中的 estimate_message_tokens()、microcompact() 需要从 Vec<OpenaiChatMessage> 改为 Vec<Message>,核心逻辑不变。
🔄 待深入推演:compact 在 IR 上的具体改法与 token 估算 当前的
microcompact通过将Tool消息的内容替换为"[pruned]"来释放 token。在 IR 中, 工具结果有两种表达方式:
Message::Tool { content: [ContentBlock::Text { text: "大段工具结果..." }], tool_call_id }Message::Assistant { content: [.., ContentBlock::ToolResult { content: [Text], .. }] }(如果使用 IR 的 ToolResult block)需要推演:
- IR 下
microcompact压缩哪个?只压缩Message::Tool还是也压缩ContentBlock::ToolResult? 如果两者共存,谁先被压缩?estimate_message_tokens的估算策略:当前按字符数估算(4 字符 ≈ 1 token),对ContentBlock的新类型(Thinking、ToolUse、ToolResult、Image 等)如何估算?Image block 用固定 50 token 的经验值 是否仍然合理?ContentBlock::Thinking是否在压缩范围内?thinking 内容通常不应该被压缩(因为思考是推理过程, 压缩后可能丢失上下文)。是否需要为CompactConfig增加compact_thinking选项? 优先级:中(Phase 3 适配 LlmCycle 时需同步修改)
7. 对上层的影响
7.1 ProviderRegistry —— 零改动
pub struct ProviderRegistry {
providers: HashMap<String, Box<dyn LlmProvider>>,
default_name: Option<String>,
}
// 所有方法逻辑不变
7.2 AgentSession —— 极小影响
// 当前
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<ToolDefinition>,类型不变。
7.4 PromptComposer —— 内部类型替换
PromptComposer 内部存储从 Vec<OpenaiChatMessage> 改为 Vec<Message>,公共方法签名不变(返回 Message 类型)。
8. 兼容性策略
8.1 From trait 双向转换
提供新旧类型之间的转换,平滑迁移:
// IR → 旧类型(兼容层)
impl From<ChatResponse> for MessageResponse { ... }
impl From<MessageResponse> for ChatResponse { ... }
impl From<OpenaiChatMessage> for Message { ... }
impl From<Message> for OpenaiChatMessage { ... }
impl From<ChatRequest> for MessageRequest { ... }
impl From<MessageRequest> for ChatRequest { ... }
8.2 LlmCycle 兼容 getter
impl LlmCycle {
// 新
pub fn messages(&self) -> &[Message] { &self.messages }
// 兼容旧
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
self.messages.iter().map(|m| m.clone().into()).collect()
}
}
8.3 测试代码的过渡
测试中大量使用 MockProvider 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。