From 3babc5567f39a63cce2ac8f3555f7d5e3d9ee7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Mon, 22 Jun 2026 06:52:04 +0800 Subject: [PATCH] =?UTF-8?q?enhance(docs):=20=E8=A1=A5=E5=85=A8=20compact?= =?UTF-8?q?=20=E5=9C=A8=20IR=20=E4=B8=8A=E7=9A=84=E6=94=B9=E6=B3=95?= =?UTF-8?q?=E6=8E=A8=E6=BC=94=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/9e-llm-cycle-and-upstream.md | 180 +++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 14 deletions(-) diff --git a/docs/9e-llm-cycle-and-upstream.md b/docs/9e-llm-cycle-and-upstream.md index 1cd1487..4629d3c 100644 --- a/docs/9e-llm-cycle-and-upstream.md +++ b/docs/9e-llm-cycle-and-upstream.md @@ -195,21 +195,173 @@ pub struct HookContext<'a> { `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) +> **✅ 推演结论(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 大小估算 | +> +> --- +> +> ### 议题 3:Thinking 是否在压缩范围内 +> +> **决策:方案 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 调用点的接口适配。 ---