Files
agcore/design/pdd/9e-llm-cycle-and-upstream.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

17 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 {
        // `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System
        messages.retain(|m| !matches!(m, Message::System { .. }));
        messages.insert(0, Message::system(sys_prompt));
    }
    // 如果 `system_prompt` 为 Nonemessages 中的 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→原生 映射层自行处理差异。

理由:

  1. 与整套 IR 设计的理念一致——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、 Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异" system prompt 的双重入口是同一个问题,应用同样的原则。
  2. 消除歧义的最佳方式是砍掉一个入口——两个入口导致的"谁优先"问题在类型层面就解决了, 不需要运行时规则。
  3. 每个 Provider 做自己的转换本来就是 Provider 层的职责——OpenaiProvider 几乎零成本 System 直接序列化为 role=system),AnthropicProvider 做提取+移除(约 10 行代码), DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。

具体做法:

MessageRequest9b-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_native9d-provider-implementations.md 新增提取逻辑:

// 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 循环逻辑

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>,核心逻辑不变。

推演结论(2026-06-22):microcompact 压缩对象 + 估算策略 + Thinking 压缩策略

推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入大小 × 重要性二维决策框架作为统一的压缩决策模型。


议题 1microcompact 压缩哪个?

决策:方案 B —— 同时压缩 Message::ToolMessage::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

实现示意:

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
}

议题 2estimate_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 —— 默认不压缩 ThinkingCompactConfig 增加 compact_thinking: bool 选项。

理由:

  1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文
  2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力
  3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压)
#[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 内容与结果的关键词重叠 更新引用时间戳
/// 工具注册时声明结果的语义类型
#[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,
}

综合评分 → 动作映射

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

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 中先保留旧类型别名以减少改动。