enhance(docs): 补全 compact 在 IR 上的改法推演结论

This commit is contained in:
徐涛
2026-06-22 06:52:04 +08:00
parent 3ddd0b6d80
commit 3babc5567f
+166 -14
View File
@@ -195,21 +195,173 @@ pub struct HookContext<'a> {
`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
> **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略**
>
> **需要推演:**
> 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 时需同步修改)
> 推演覆盖三个议题(压缩对象、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 大小估算 |
>
> ---
>
> ### 议题 3Thinking 是否在压缩范围内
>
> **决策:方案 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 调用点的接口适配。
---