# R21 语义锚点保留(`keepFrom`):模型指位置、插件算预算 > **状态**:已实施(v0.1.8,2026-10-15)。验收:654 断言全绿;变异 6/6 CAUGHT。 > **用户原话(两轮)**: > ① 「Agent 能知道自己**需要的**上下文占比情况么?由此来决定哪一档压缩。还是只能大概估算?」 > ② 「比如现在上下文 600k,里面包含**新旧 2 个需求**,如果最新的需求只在 6% 里面,那么选择压缩 8%, > 收益是最大的:新需求上下文在 6% 内基本完全保留、又腾出足够多的上下文空间、还避免上一个需求 > 的上下文带来的降智问题。**Agent 能知道新旧 2 个需求的节点在哪么?**」 --- ## 1. 问题:三档是"按比例留",而需求是"分段的" R16 的三档(8%/16%/24%)解决的是「把不可靠的自由度换成有界的选择」,但它只提供一个旋钮:**比例**。 真实会话里最常见的形态恰好是**分段的**: ``` [ 旧需求:几十万 token 的纠葛 ] [ 新需求:最后几个百分点 ] [ 当前这一步 ] ↑ 压缩后仍然留下 → 纯稀释(降智源) ↑ 必须一点不丢 ``` 按比例选档在这个形态下**必然两难**:选浅压24% ⇒ 白留一大截旧内容;选深压8% ⇒ 新需求可能刚好被削到。 用户那句"选 8% 收益最大"是对的直觉,但前提是**保留范围恰好落在新需求的边界上**——这需要的是**位置**,不是**比例**。 ## 2. 关键判断:谁能知道什么 | | 供给侧(有多少 / 留多少 / 压后剩多少) | 需求侧(**新旧需求的语义边界**在哪) | | --- | --- | --- | | 谁知道 | **插件**:`session.surface.nodes`(seq 数组)、`measure(session).nodes`(`{seq,tokens}`)、`session.eventAt(seq).type` | **模型**:它刚读过那些消息,知道哪条是"新需求" | | 能否精确 | ✅ 确定性算术(夹取 `[40k, 窗口×50%]`) | ❌ 不可算,但**可以指认** | | 结论 | 插件把锚点换算成 token 预算 | 模型只需**指位置**(`recentUser:N` 或文本片段) | > 需求侧**原理上不可知**(未来动作尚未发生),而且没有反馈闭环(压完没人告诉模型"你刚才因为被摘要而重读了文件")。 > 所以正确的做法不是"估得更准",而是**把不确定的那一半换成"声明 + 算术"**:模型声明它能知道的(位置), > 插件算它算得准的(token)。 ## 3. 设计:两种锚点形态 + 一条降级链 | 形态 | 传法 | 适用 | | --- | --- | --- | | **A:相对锚点** | `keepFrom:{recentUser:1\|2\|3}` | "从最近第 N 条**你的消息**起全保留"——零记忆负担,总是可用 | | **B:文本锚点** | `keepFrom:{text:'那条消息的开头十来字'}` | 需要指向更早(不止 1~3 条前)或同条消息内的起点 | **降级链**(每一跳都写进报告与「压缩自检」回执,绝不静默降级): ``` text 锚点(前缀 → 包含) → recentUser 锚点 → 现有三档(tier) → 标准16% ``` - 两者都给 ⇒ **text 优先**(更精确),失败退 recentUser,再失败退三档; - **不传 `keepFrom` = 清除**本会话已登记的锚点("忘记重申"退化为按档位 = 安全方向); - 形状非法(如 `recentUser: 9`)⇒ 当场告知并忽略。 ## 4. 两个必须的护栏 1. **预算**:`保留 = tokens(锚点 seq → 末尾) + 余量`,余量 = `max(8k, 10% × 锚点 token)`(上限 32k), 整体夹到 `[40k, 窗口×50%]`;解析不出窗口/锚点时**不猜**,直接退档位。 2. **`keepFromSeq` 硬护栏**:`selectRange` 在按预算回退之后,若边界**晚于**锚点,就把边界拉到锚点 —— **宁可多留(少压),绝不丢新需求**。预算是估算,语义声明不是;不能用估算覆盖声明。 护栏生效时**重算 `retainedTokens`**(否则回执会报一个偏小的"保留 ~X",留痕就不诚实了)。 ## 5. 落点(实现位置) | 文件 | 职责 | | --- | --- | | `compact-range.mjs` | 纯函数:`recentUserSeq`(**跳过插件注入的 user/message**,`data.source.kind !== 'user'` 一律不算)、`findUserSeqByText`(前缀优先、包含退化,取最近)、`tokensFromSeq`、`resolveAnchorBudget`、`selectRange` 的 `keepFromSeq` 护栏 | | `compact-tool.mjs` | `keepFrom` 参数 + `normAnchor` 校验 + **锚点随档位一起落盘**(跨 toggle/重启)+ `getAnchor(sid)`;教学(brief / 决策卡 / 参数说明) | | `m3.compact.mjs` | **在压缩那一刻**解析(锚点会随新消息移动)⇒ `retainOverride / anchorSeq / anchorFailReason` 进 `compactWithOwnRange`;`retainSource`(`anchor:*` / `tier`)与失败原因进 `anchorProbe`、`setCompactVerify`、`rangeInfo` | | `m2.inject.mjs` | 「压缩自检」回执:生效写「保留来源:语义锚点(…)」,未命中写「锚点未命中(原因)⇒ 已按档位兜底」 | | `host.impl.mjs` | 给 m3 注入 `extractEventText`(文本提取单一真相) | ## 6. 验证 - **654 断言全绿**(contract 507 / static 72 / report 54 / boot 21);R21 新增 10 条: 纯函数 4(第 N 条真用户消息 / 前缀与包含 / 预算换算与夹取 / 降级链 `tried` 痕)+ 护栏 2(小预算被拉到锚点且重算保留量 / 大预算不额外收紧)+ 工具 3(登记与回执 / 落盘往返 / 清除语义与形状校验)+ 接线回执 1。 - **变异 6/6 CAUGHT**:护栏失效 · 降级链断 · 包含匹配丢失 · 跳过注入失效 · 锚点不落盘 · 缺省不清理旧锚点。 ## 7. 边界(如实记录) 1. 锚点只在 **pre-step 压缩路径**生效(强制线与工具触发同源);`idleSweep` 走另一条路,**未接**。 2. 粒度到**消息**:同一条消息内混合新旧需求时切不开(B 的 `text` 也只能定位到消息起点)。 3. 锚点之前的旧需求仍会被摘要(若之后又需要它的细节,只能重读)——这是模型自己声明的, 代价归属清晰,比"插件替它猜"好。 4. 锚点是**会话级**偏好(与 `tier` 同寿命、同落盘文件 `compact-tier.json`);跨 toggle/重启有效。