Files
agcore/docs/9e-llm-cycle-and-upstream.md
T

9.7 KiB
Raw Blame History

LlmCycle 改造与上层适配

本文档从 9-llm-provider-unified-interface.md 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。

相关文件:

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
        && !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<String>

问题在于:

  • 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 放进 messagesAnthropicProvider 内部 做反向提取。问题:当 LlmCycle 的消息历史中已有一个 Message::Systemself.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 循环逻辑

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 即可发现所有 ToolUse block
  • 逻辑对 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 层叠加额外处理
    // (当前 pipelinestream → 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 中, 工具结果有两种表达方式:

  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 —— 零改动

pub struct ProviderRegistry {
    providers: HashMap<String, Box<dyn LlmProvider>>,
    default_name: Option<String>,
}
// 所有方法逻辑不变

7.2 AgentSession —— 极小影响

// 当前
let response: ChatResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage);  // Usage 类型不变

// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).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 中先保留旧类型别名以减少改动。