PicoBot/docs/CONTEXT_COMPACTION_DESIGN.md

1008 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PicoBot 上下文压缩架构设计与实施方案
> 状态:已于 PicoBot 1.19.0 实施2026-08-20 完成实现符合性修正
> 编写日期2026-08-20
> 本文定义并记录 PicoBot 上下文压缩的架构、数据模型、触发规则、失败语义、迁移步骤和验收标准。运行时事实仍以代码和测试为准;第 2 节保留 1.19.0 之前的基线,便于解释迁移动机。
## 1. 结论
PicoBot 的上下文压缩采用以下最小模型:
1. `messages` 是不可由压缩改写的原始消息日志。
2. 每个 Session 最多只有一个活动的 `ContextCheckpoint`
3. checkpoint 只保存一份累计摘要和第一条原样保留消息的 `seq`,不复制近期消息。
4. 模型上下文由“活动摘要 + 原始消息尾部”确定性重建。
5. `/compact`、自动压缩和 context-overflow 恢复调用同一个压缩服务,仅触发策略不同。
6. 自动压缩采用 pi 风格的保留量机制:
```text
should_compact = context_tokens > context_window - reserve_tokens
```
7. 每次压缩最多调用一次摘要模型,不做后台压缩、多级压缩、租约、熔断器或复杂的增量归档。
8. LLM 摘要失败时不写半成品;只有真实 overflow 恢复可以使用明确标记的确定性整 Turn 裁剪。
目标数据流:
```text
SQLite 原始消息(按 seq 递增)
├── 无 checkpoint ────────────────┐
│ │
└── 活动 checkpoint │
├── summary │
└── first_retained_seq ─┐ │
▼ ▼
原始消息 seq >= first_retained_seq
ContextProjection确定性历史投影
system / tools / memory / active plan
Provider request
```
## 2. 背景与实施前基线
### 2.1 旧压缩算法
1.19.0 之前的 `ContextCompressor` 使用约 `chars / 4` 并乘 1.2 安全系数估算 token以 context window 的 70% 作为固定触发阈值。超过阈值后依次执行:
1. 截短旧工具结果。
2. 按相邻 user 消息之间的 assistant/tool 片段生成摘要。
3. 最多执行三轮摘要。
4. 仍超过窗口 90% 时执行 head/tail 消息裁剪。
实现位置:
- `src/agent/context_compressor.rs::estimate_tokens`
- `src/agent/context_compressor.rs::compress_if_needed`
- `src/agent/context_compressor.rs::compress_once`
### 2.2 旧版三个入口并不一致
| 入口 | 旧版行为 | 主要问题 |
|------|----------|----------|
| `/compact` | 调用 `compress_if_needed`,替换 Session 内存历史,保存时间戳 | 低于 70% 时并不压缩;原始数据库未替换;内存 `seq_counter` 被按压缩后消息数重置 |
| Turn 前自动压缩 | 压缩快照只用于当次 `history_out`,保存 Timeline/时间戳 | 压缩投影没有成为明确持久状态 |
| overflow 恢复 | 解析窗口后重新压缩原始内存历史并重试一次 | 与首次请求的上下文投影可能不同;仍只持久化时间戳标记 |
相关实现位于:
- `src/session/session.rs::execute_slash_command`
- `src/session/turn_input.rs::prepare_turn_input`
- `src/session/session.rs` 的 worker overflow 分支
### 2.3 旧恢复模型不够确定
旧版 Session 恢复通过 `last_compressed_message_at`
1. 读取最近 Timeline
2. 把 Timeline 伪装为 `[Previous Context]` user 消息;
3. 加载时间戳之后的原始消息;
4. 必要时在恢复期间再次调用 Provider 压缩。
Timeline 是检索记忆,不是精确的消息边界;时间戳也不能表达摘要覆盖到哪个 durable `seq`。因此相同数据库在不同恢复时机可能形成不同模型上下文。
### 2.4 必须先解决的正确性问题
- 压缩不得重置 durable message `seq`。
- 摘要未被接受前不得写 Timeline。
- 不能用“消息数量是否变化”判断压缩是否真正节省 token。
- 恢复 Session 不得调用 LLM。
- 自动、手动和 overflow 必须共享同一种投影与持久化语义。
## 3. 设计目标与非目标
### 3.1 目标
- **简单**:一张 checkpoint 表、一个活动指针、一个压缩入口。
- **可靠**原始消息永远可恢复checkpoint 原子提交;失败不改变有效上下文。
- **确定性**:相同 checkpoint 和原始消息一定构建出相同历史投影。
- **可解释**:触发阈值、压缩前后 token、保留边界和降级状态可观测。
- **统一**手动、自动、overflow 使用同一 planner、summarizer 和 commit 路径。
- **兼容 PicoBot 生命周期**:慢摘要在 Session 锁外执行;提交前验证持久化 generation。
- **保持工具链合法**:永不把 assistant tool call 与对应 tool result 切开。
### 3.2 非目标
第一版明确不实现:
- Hermes 式 compression lease、分布式锁或后台 idle compaction。
- 多层 active/inactive 消息归档。
- 多模型投票、摘要质量评分或多次模型重试。
- 基于 embedding 的语义切分。
- 自动去重所有相似工具输出。
- 在恢复 Session 时调用 Provider 修复上下文。
- 允许模型直接控制 checkpoint ID、消息边界或原始 seq。
- 用 Timeline 替代 checkpoint 或从 Timeline 反推活动上下文。
## 4. 参考项目取舍
### 4.1 采用 pi 的部分
- 用 `context_window - reserve_tokens` 判断是否压缩,而不是固定百分比。
- 用 Provider 最后一次真实 prompt usage 加后续消息估算当前占用;不可安全复用时回退完整估算。
- 以 token 数量保留近期上下文,而不是固定最后 N 条消息。
- 使用累计 summary并在下一次压缩时把上一摘要作为输入。
- 使用明确的第一条保留消息边界,原始历史保持可追溯。
- 手动压缩跳过自动阈值。
### 4.2 仅采用 Hermes 的原则
- 慢摘要结束后必须验证提交代,过期候选不得生效。
- 压缩后必须确认 token 确实下降。
- 摘要失败不能留下数据库和内存分叉。
不采用 Hermes 的 soft archive、并发消息克隆、租约、熔断器、后台压缩和多套压力阈值。PicoBot 已有 per-session worker 和递增消息 seq不需要为第一版增加这些机制。
### 4.3 采用 ZeroClaw 的部分
只采用其“按完整 Turn 确定性裁剪”作为 overflow 最后安全网,不把无摘要裁剪作为普通自动压缩路径。
## 5. 核心不变量
实现必须始终满足:
1. `messages` 表是用户可见历史和审计记录的唯一权威来源。
2. 压缩不能更新、删除或重新编号任何原始消息。
3. 每个 Session 最多有一个活动 checkpoint。
4. checkpoint 的摘要只代表 `seq < first_retained_seq` 的历史。
5. Provider 历史投影等于 `checkpoint summary + messages[seq >= first_retained_seq]`;没有 checkpoint 时等于全部原始消息。
6. checkpoint 之后追加的消息天然进入原始尾部,不需要复制或重新编号。
7. 任意会改变既有历史含义的非追加操作必须使活动 checkpoint 失效。
8. checkpoint 插入和 Session 活动指针更新必须在同一 SQLite 事务中完成。
9. checkpoint 提交失败时,旧投影继续有效。
10. Timeline 写入是 checkpoint 提交后的 best-effort 派生操作,不参与恢复正确性。
11. 摘要正文不得包含 Provider 私有 reasoning state保留的原始消息仍按现有 Provider 匹配规则回放私有状态。
12. 自动压缩每个 Turn 最多尝试一次overflow 最多重试一次 Provider 请求。
## 6. 数据模型
### 6.1 原始消息
继续使用现有 `messages(session_id, seq, ...)`
- `seq` 只由现有 durable 序号分配逻辑产生;
- 压缩不调用 `replace_history_in_memory`
- 压缩不改变 WebUI/TUI history revision
- `/history`、导出和消息统计继续读取原始消息。
### 6.2 ContextCheckpoint
新增表:
```sql
CREATE TABLE IF NOT EXISTS context_checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
generation INTEGER NOT NULL,
parent_checkpoint_id TEXT,
summary TEXT NOT NULL,
first_retained_seq INTEGER NOT NULL,
source_max_seq INTEGER NOT NULL,
trigger_reason TEXT NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
tokens_before INTEGER NOT NULL,
tokens_after INTEGER NOT NULL,
degraded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, generation)
);
CREATE INDEX IF NOT EXISTS idx_context_checkpoints_session_created
ON context_checkpoints(session_id, created_at DESC);
```
在 `sessions` 增加:
```sql
active_context_checkpoint_id TEXT,
context_generation INTEGER NOT NULL DEFAULT 0
```
字段说明:
| 字段 | 语义 |
|------|------|
| `generation` | Session checkpoint 的单调递增代,用于 CAS 提交 |
| `parent_checkpoint_id` | 生成本摘要时使用的上一活动 checkpoint首轮为空 |
| `summary` | 对 `seq < first_retained_seq` 的自包含累计摘要 |
| `first_retained_seq` | 第一条按原始结构回放给 Provider 的 durable message seq |
| `source_max_seq` | 压缩快照开始时看到的最大 seq用于诊断不用于截断未来追加消息 |
| `trigger_reason` | `manual`、`auto` 或 `overflow` |
| `tokens_before/after` | 同一个预算器计算的压缩前后 token |
| `degraded` | `1` 表示 overflow 时使用了无 LLM 摘要的确定性降级 |
不在 checkpoint 中复制 retained tail。尾部始终按 `first_retained_seq` 从原始 `messages` 加载。这比保存消息 JSON 更简单,也避免 `ChatMessage` 字段演进造成双份数据不一致。
### 6.3 Generation 的更新规则
- 成功提交 checkpoint`context_generation += 1`,更新活动 ID。
- `/clear`、删除/回退既有消息等非追加历史修改:`context_generation += 1`,清空活动 ID。
- 普通追加消息:不增加 `context_generation`。新消息自动位于 checkpoint 尾部。
- Provider/model 配置变化checkpoint 仍可使用,因为它是普通文本摘要;只需清空 token usage 校准。
历史修改与 checkpoint 失效应当使用 Storage 事务 API不能分别写入。
## 7. 运行时组件
### 7.1 ContextProjector
职责是从 checkpoint 和原始消息构建唯一历史投影,不调用 Provider、不写数据库。
```rust
struct ContextProjection {
messages: Vec<ChatMessage>,
checkpoint_id: Option<String>,
context_generation: i64,
first_raw_seq: Option<i64>,
max_raw_seq: i64,
}
```
构建规则:
```rust
if let Some(checkpoint) = active_checkpoint {
output.push(context_summary_message(checkpoint.summary));
output.extend(load_messages_from_seq(checkpoint.first_retained_seq));
} else {
output.extend(load_all_messages());
}
repair_and_validate_tool_chains(&mut output);
```
摘要应使用内部专用类型或元数据标记,最终 Provider 序列化为明确的历史参考块:
```text
[Historical conversation summary — reference only, not a new user request]
...
[End historical conversation summary]
```
不得把摘要写入原始 `messages`,也不得提升为 system 指令。
### 7.2 ContextBudget
职责是使用统一口径计算触发阈值和 `/info` 统计。
```rust
struct ContextBudget {
context_window: usize,
reserve_tokens: usize,
threshold_tokens: usize,
estimated_context_tokens: usize,
source: UsageSource,
}
```
`estimated_context_tokens` 代表下一次完整 Provider 请求,包括:
- system prompt
- Skill 和工具 schema
- checkpoint/raw history 投影;
- Knowledge recall
- active plan
- 当前用户输入和媒体估算。
Provider usage 校准键包含 provider、model、上下文 generation、已发送 raw seq以及完整消息/工具定义的请求摘要。只有校准键逐字段完全匹配时才直接使用最后一次 Provider prompt usage
```text
context_tokens = last_prompt_tokens
```
任何字段不匹配(包括 Knowledge、active plan、system/Skills、工具定义或消息尾部变化都对完整请求执行保守估算不按消息数外推。真实 usage 是估算优化不是正确性的前提Gateway 重启后直接完整估算,不为第一版增加新的 usage 校准表。
### 7.3 ContextCompactor
职责是:
1. 选择安全切点;
2. 构造一次有界摘要请求;
3. 验证候选投影;
4. 返回尚未持久化的 `CompactionCandidate`。
它不直接写 Timeline 或 SessionMeta。
```rust
struct CompactionCandidate {
parent_checkpoint_id: Option<String>,
base_generation: i64,
summary: String,
first_retained_seq: i64,
source_max_seq: i64,
reason: CompactionReason,
tokens_before: usize,
tokens_after: usize,
degraded: bool,
}
```
### 7.4 SessionManager
SessionManager 负责唯一的编排入口:
```rust
async fn compact_context(request: CompactRequest) -> CompactOutcome
```
流程:
1. 在短 Session 锁内取得投影快照、当前 generation 和 Provider 配置。
2. 释放锁。
3. 计算预算并生成候选LLM 摘要在锁外执行。
4. 通过 Storage CAS 事务提交 checkpoint。
5. 提交成功后更新 Session 的轻量 checkpoint 缓存。
6. 从当前 raw log 和已提交 checkpoint 重新投影,纳入摘要期间追加的更大 seq禁止返回 candidate 携带的旧投影向量。
7. best-effort 写一条带 checkpoint ID 的 Timeline。
8. 返回统一结果。
## 8. 配置与自动触发阈值
### 8.1 配置模型
新增一个顶层配置块,只保留三个用户可理解的选项:
```json
{
"context_compaction": {
"enabled": true,
"reserve_tokens": 16384,
"keep_recent_tokens": 20000
}
}
```
语义:
- `enabled`:只控制 Turn 前自动压缩;不禁用 `/compact` 和 overflow 安全恢复。
- `reserve_tokens`:为模型输出、下一次工具调用和估算误差预留的窗口。
- `keep_recent_tokens`:生成 checkpoint 时尽量原样保留的近期历史 token。
不再暴露 `threshold_ratio`、`protect_first_n`、`protect_last_n`、`max_passes` 或多个 danger ratio。
### 8.2 小窗口适配
默认值参考 pi但需要适配 PicoBot 可配置的小上下文模型:
```text
effective_reserve = min(config.reserve_tokens, context_window / 2)
threshold_tokens = context_window - effective_reserve
effective_keep = min(config.keep_recent_tokens, threshold_tokens / 2)
```
因此:
- 128K 窗口默认在约 112K context tokens 时触发,保留约 20K 近期历史;
- 8K 窗口使用 4K reserve、保留最多约 2K 近期历史;
- 配置不会导致阈值为零或保留尾部本身超过触发阈值。
`/info` 和 WebUI 必须显示配置值与有效值,避免自适应裁剪成为隐藏行为。
### 8.3 唯一自动触发公式
```rust
fn should_compact(
enabled: bool,
context_tokens: usize,
context_window: usize,
effective_reserve: usize,
) -> bool {
enabled && context_tokens > context_window.saturating_sub(effective_reserve)
}
```
不再保留 70% 自动阈值和 90% danger 阈值。`keep_recent_tokens` 自然形成回滞:成功压缩后上下文会下降到“有限摘要 + 近期尾部”,不会在下一 Turn 立即再次触发。
### 8.4 固定开销过大
压缩只能减少历史。若以下固定部分已经达到阈值:
```text
system + tools + skills + memory + plan >= threshold_tokens
```
返回明确的 `FixedContextExceedsBudget`,列出各部分 token 估算;不能通过删除全部历史伪装成功。
## 9. 切点选择
### 9.1 以完整 Turn 为单位
优先使用 durable `turn_id` 分组。一个 Turn 包含:
- 初始 user 输入和同 Turn steering
- assistant 文本/reasoning
- assistant tool calls
- 对应 tool results
- terminal assistant 消息。
旧消息缺少 `turn_id` 时,使用 role 状态机推断边界。禁止仅按最后 N 条消息切分。
### 9.2 从尾部选择保留区域
```text
1. 从最新完整 Turn 向前累计估算 token。
2. 至少保留最新完整 Turn。
3. 达到 effective_keep 后停止。
4. 选中 Turn 的第一条消息 seq 即 first_retained_seq。
5. 更早内容进入累计摘要。
```
如果最新单个 Turn 已超过 `effective_keep`,仍完整保留该 Turn。只有真实 overflow 且完整 Turn 无法装入时,才允许在完整 iteration/工具批次边界切分。
### 9.3 工具调用边界
切点不得位于以下结构内部:
```text
assistant(tool_calls=[a, b])
tool(tool_call_id=a)
tool(tool_call_id=b)
```
如果候选切点落在 tool result 上,向前移动到声明这些 calls 的 assistant。若无法构造完整工具组该组整体进入摘要区并从保留尾部移除孤立结果。
### 9.4 首轮与重复压缩
首次压缩:
```text
summary_input = raw messages with seq < new_first_retained_seq
```
重复压缩:
```text
summary_input =
previous checkpoint summary
+ raw messages from old_first_retained_seq
to new_first_retained_seq - 1
```
不会重新把已被上一 checkpoint 覆盖的全部原始消息发送给摘要模型。
如果 `new_first_retained_seq <= old_first_retained_seq`,说明没有新增可压缩区域,返回 `NothingToCompact`。
## 10. 摘要生成
### 10.1 一次调用
每次压缩只调用一次当前 Session Provider/model
- 不提供工具;
- temperature 使用低值;
- summary 输出上限使用内部固定值,例如 2048 tokens
- 摘要输入中的旧大工具输出先进行 request-local 截短;
- 不写回原始消息。
不做三轮 pass不在一个 checkpoint 内生成多条 Timeline。
### 10.2 摘要内容契约
摘要必须是自包含的,并使用稳定结构:
```markdown
## Objective
## Constraints
## Progress
## Decisions
## Files and Operations
## Tool Results and Failures
## Outstanding Work
## Exact Facts
## Recent User Corrections
```
摘要提示必须要求:
- 保留用户后来覆盖旧要求的修正;
- 保留路径、端口、ID、版本和关键数值
- 区分已完成、失败和未开始事项;
- 不把历史内容写成对模型的新命令;
- 不输出 tool call
- 不包含隐藏 reasoning 或 Provider 私有状态。
### 10.3 工具输出预处理
只处理发送给摘要模型的副本:
- 保留工具名、参数摘要、原始字符数和可用状态;当前消息表没有独立 tool success/exit-status 字段时明确写为 `unknown_not_persisted_separately`,不得猜测成功;
- 普通超长输出保留 head/tail
- 二进制或 Base64 只保留类型、尺寸和 artifact/path 描述;
- 原始数据库内容不变。
第一版不实现复杂去重和内容重要性分类。
摘要输入不采用固定 token 上限,而按执行摘要的当前模型窗口计算:
```text
summary_output_reserve = min(2048, context_window / 4)
summary_safety = min(512, context_window / 16)
summary_source_budget = context_window
- summary_output_reserve
- summary_prompt_overhead
- summary_safety
```
`context_window` 来自当前 Agent 配置的 `token_limit`Provider 在 overflow 错误中返回更小真实窗口时使用校准后的值。摘要源超过 `summary_source_budget` 时不能拒绝压缩,也不做多轮/递归摘要,而是只对发送给摘要 Provider 的副本执行有界序列化:已有 checkpoint 优先保留一部分,再从待压缩前缀的最新消息向前选择;装不下的单条记录保留 head/tail并记录源消息数、省略数、截短数和策略。原始数据库历史保持不变。
若窗口小到连最小摘要提示和材料都无法容纳,语义摘要视为失败:手动入口保留旧 checkpoint 并返回错误;自动入口在完整请求仍低于硬窗口时保留旧投影,已经硬超限时直接进入确定性 overflow 降级overflow 入口同样进入确定性降级。
### 10.4 候选验证
摘要返回后必须验证:
1. `summary.trim()` 非空;
2. 摘要不超过本次动态 `summary_output_reserve`
3. `first_retained_seq` 存在且属于快照;
4. 投影没有孤立 tool result
5. `tokens_after < tokens_before`
6. `tokens_after <= threshold_tokens`。
任一条件失败都不得提交普通 checkpoint。
## 11. 三种触发入口
### 11.1 手动 `/compact`
```text
reason = manual
force = true
```
行为:
- 不检查自动触发阈值;
- 至少存在一个可摘要的旧完整 Turn 才执行;
- `context_compaction.enabled=false` 不影响手动命令;
- 活跃 Turn 中不静默取消模型工作,命令应排到该 Turn 之后执行;若现有 control 路径不能可靠排队,则明确拒绝并提示先 `/stop`
- 摘要失败时保留旧上下文并返回错误,不执行无摘要裁剪。
成功响应至少包含:
```text
Context compacted: 74,200 -> 22,600 tokens
Kept 6 recent turns from message seq 381
Raw history unchanged
```
无事可做时返回 `Nothing to compact`,不能报告 `X -> X messages`。
### 11.2 Turn 前自动压缩
```text
reason = auto
force = false
```
顺序:
1. 并行加载 Knowledge 和 active plan。
2. 构建完整但尚未发送的请求草稿。
3. 用统一 `ContextBudget` 计算 `context_tokens`。
4. 满足 pi 风格阈值时调用一次 `compact_context`。
5. checkpoint 提交成功后重新构建完整请求。
6. 再次验证预算,然后调用 Provider。
自动摘要失败时:
- 每个 Turn 不再重复摘要;
- 若完整请求仍未超过硬 context window不提交 checkpoint继续原请求
- 若换模、改配置或历史增长使发送前预检已经超过硬 context window立即提交明确标记的确定性 overflow 降级 checkpoint不先发送必然失败的普通请求
- 若 Provider 返回 overflow进入统一 overflow 恢复。
### 11.3 Context overflow
```text
reason = overflow
force = true
```
首次 Provider 请求、尚未执行本 Turn 工具时的恢复顺序:
1. 从错误中解析实际窗口;解析失败则使用配置窗口。
2. 用实际窗口重新计算 reserve 阈值。
3. 若本 Turn 尚未尝试摘要,调用一次统一压缩。
4. 摘要失败或候选仍无法装入时,按完整 Turn 确定性删除最旧内容。
5. 生成 `degraded = true` checkpoint其摘要只陈述有多少旧 Turn 因 overflow 被省略,不伪造历史事实。
6. 重建请求并只重试一次。
7. 第二次 overflow 直接失败,返回可诊断错误。
Provider overflow 在 AgentLoop 中先转换为类型化 `ContextOverflow`,携带解析出的窗口和 `tool_progress`。只有 `tool_progress=false` 才允许 Session 走上述正式 checkpoint 恢复;已执行工具的错误不得从 durable history 重启 Turn。
降级 breadcrumb 示例:
```text
[Earlier conversation omitted during context-overflow recovery.
Raw history remains available, but no semantic summary was produced.]
```
手动压缩不得生成这种降级 checkpoint。普通自动压缩在请求低于硬窗口时也不得降级只有发送前已经硬超限的自动入口可把该状态按 overflow 处理并生成降级 checkpoint。
### 11.4 Turn 内工具迭代
AgentLoop 在每次 Provider 调用前继续执行 request-local 安全检查:
1. 优先截短本 Turn 之前的旧工具结果副本;
2. 不修改 durable history
3. 不为尚未提交的中间 Turn 创建 checkpoint
4. 若 overflow 发生在至少一个工具批次完成后,在当前 AgentLoop 内保留本 Turn 的 assistant tool call、tool result 和 steering只删除进入本 Turn 前的旧完整 Turn 请求副本;
5. 对同一个 Provider step 只做一次 request-local 重试,不重新执行工具;第二次 overflow 直接失败,绝不回到 Session 正式重试;
6. Turn 成功持久化后,由下一个 Turn 的统一自动入口创建正式 checkpoint。
第一版不引入“Turn 中 checkpoint”这一额外状态。
## 12. 原子提交与并发语义
### 12.1 Storage API
新增原子接口:
```rust
async fn commit_context_checkpoint(
session_id: &str,
expected_generation: i64,
checkpoint: NewContextCheckpoint,
) -> Result<ContextCheckpoint, StorageError>
```
事务伪代码:
```sql
BEGIN IMMEDIATE;
INSERT INTO context_checkpoints (...)
VALUES (... expected_generation + 1 ...);
UPDATE sessions
SET active_context_checkpoint_id = :checkpoint_id,
context_generation = context_generation + 1,
last_compressed_message_at = :now
WHERE id = :session_id
AND context_generation = :expected_generation;
-- affected rows 必须为 1否则 ROLLBACK 并返回 stale candidate
COMMIT;
```
实际实现应在 UPDATE 成功后再保留 INSERT或依靠事务 rollback 清理失败 INSERT。
### 12.2 为什么普通追加不使候选过期
压缩快照选择出的 `first_retained_seq` 之前内容不再变化。摘要期间新追加的消息具有更大 seq投影会自然加载
```text
checkpoint summary
+ raw seq >= first_retained_seq
+ 摘要期间追加的更大 seq
```
因此不需要 Hermes 式复制并重新编号并发尾部。
### 12.3 什么操作必须使候选过期
以下操作必须在同一事务中递增 `context_generation` 并清除活动 checkpoint
- `/clear`
- 删除、撤销或改写既有消息;
- 从旧 revision 恢复历史;
- 未来任何改变 `seq < first_retained_seq` 含义的操作。
这样正在生成的旧候选会在 CAS 时失败。
### 12.4 内存更新顺序
必须遵循:
```text
生成候选
→ SQLite 原子提交
→ 更新 Session checkpoint 缓存
→ best-effort Timeline
```
不能先替换 `session.messages` 再保存标记。Session 内存继续保存原始消息或其原始缓存Provider context 每次通过 projector 派生。
## 13. Timeline 与记忆系统边界
成功提交非降级 checkpoint 后,可以写一条 Timeline
```text
key: context_checkpoint:<checkpoint_id>
category: Timeline
session_id: <session>
content: <summary>
```
规则:
- Timeline 写入失败不回滚 checkpoint。
- Session 恢复不读取 Timeline 构建活动上下文。
- Timeline 清理不影响 checkpoint。
- `timeline_recall` 仍可检索历史摘要。
- 降级 breadcrumb 不写 Timeline因为它没有可召回的语义内容。
## 14. Session 恢复
恢复不得调用 Provider
```text
load SessionMeta
→ load active checkpoint若有
→ load raw messages
→ build projection
→ validate tool chains
→ initialize seq_counter from DB max(seq) + 1
```
失败处理:
- 活动 ID 指向不存在的 checkpoint记录错误并回退全部原始历史。
- checkpoint summary 为空或边界非法:回退全部原始历史。
- 保留尾部工具链损坏:使用统一 repair 规则;无法修复时回退全部原始历史并报告诊断。
- 任何回退都不得在恢复路径静默写新摘要。
## 15. 旧 Session 迁移
不根据 `last_compressed_message_at` 和 Timeline 批量推算精确 seq因为旧数据不能可靠恢复该边界。
采用惰性迁移:
1. Schema 升级只创建 checkpoint 表和 Session 新字段。
2. 旧 Session 的活动 checkpoint 为空。
3. 实施切换后,模型上下文优先从完整原始消息构建。
4. 若完整原始历史达到新阈值,自动生成第一个 checkpoint。
5. `last_compressed_message_at` 暂时保留作兼容/诊断字段,但不再作为恢复权威。
6. 一个后续版本确认没有旧路径依赖后再删除时间戳恢复逻辑。
迁移后的第一次请求可能比旧进程恢复出的 Timeline 投影更长,但不会丢失原始事实;达到预算时会立即走新压缩路径。
## 16. `/info` 与可观测性
`SessionStats::ContextUsage` 应调整为 reserve 语义,至少暴露:
```text
configured_window_tokens
effective_window_tokens
configured_reserve_tokens
reserve_tokens
configured_keep_recent_tokens
effective_keep_recent_tokens
compression_threshold_tokens
used_tokens
remaining_tokens
usage_source
active_checkpoint_id
checkpoint_generation
checkpoint_tokens_before
checkpoint_tokens_after
checkpoint_degraded
```
文本输出不再显示“阈值70%)”,改为:
```text
上下文窗口 128,000
预留 16,384
自动压缩阈值 111,616
当前占用 74,200Provider 实测 + 尾部估算)
活动 checkpoint cp_...42,100 -> 21,800
```
日志仅记录:
- session ID
- checkpoint ID/generation
- trigger reason
- token before/after
- first retained seq
- degraded/error 分类。
不得记录摘要正文、原始消息、reasoning 或 Provider 请求 payload。
## 17. 失败语义
| 场景 | 行为 |
|------|------|
| 手动压缩无旧 Turn | `NothingToCompact` |
| 手动摘要 Provider 失败 | 保留旧 checkpoint返回明确错误 |
| 自动摘要失败但请求仍小于硬窗口 | 本 Turn 使用原投影继续,记录 warning |
| 换模/改配置后发送前已经硬超限,且自动摘要失败 | 在普通 Provider 请求前提交确定性降级 checkpoint |
| 自动请求 overflow | 进入一次 overflow 恢复 |
| overflow 摘要失败 | 提交确定性降级 checkpoint重试一次 |
| 第二次 overflow | Turn 失败,不继续循环 |
| checkpoint CAS 冲突 | 丢弃候选;手动提示重试,自动在下个 Turn 重评估 |
| SQLite 提交失败 | 旧投影继续有效,不写 Timeline |
| Timeline 写入失败 | checkpoint 仍有效,仅记录 warning |
| checkpoint 损坏 | 恢复时回退原始历史并暴露诊断 |
| 固定上下文超过预算 | 返回 `FixedContextExceedsBudget`,不删除历史 |
## 18. 代码组织与变更范围
建议保持少量组件,不建立新的复杂框架:
```text
src/agent/context_compaction.rs
ContextBudget
TurnBoundary / CutPoint
ContextCompactor
deterministic overflow trim
src/session/session.rs
deterministic projection orchestration
manual / auto / overflow entry points
src/session/turn_input.rs
runtime Knowledge / active-plan assembly
src/storage/context_checkpoint.rs
checkpoint CRUD
atomic commit/invalidate
```
调用方调整:
- `src/session/turn_input.rs`:先构建完整 runtime draft再统一评估和压缩。
- `src/session/session.rs``/compact`、restore、worker overflow 改用统一入口;删除压缩导致的内存历史替换。
- `src/agent/agent_loop.rs`:只保留 Turn 内 request-local 安全裁剪。
- `src/session/stats.rs`70% 文案改为 reserve 统计。
- `src/config/mod.rs`:增加三字段 `ContextCompactionConfig`。
- `resources/templates/config.example.json`:加入默认配置。
- `src/storage/mod.rs`:初始化/migration 注册新表和字段。
1.19.0 已在三个入口切换后删除旧 `ContextCompressor`,运行时只保留一套压缩算法。
## 19. 实施阶段(已完成)
### 阶段 1当前正确性修复
1. `/compact` 增加 force 语义和准确 no-op 结果。
2. 压缩不再重置 `seq_counter` 和原始消息统计。
3. Timeline 写入移出 `compress_once`。
4. 用 token before/after 判断有效进展。
5. 增加相关回归测试。
这一阶段不改变持久化模型,但应尽快合入,避免现有 seq 风险继续存在。
### 阶段 2checkpoint 与投影
1. 增加 schema 和迁移。
2. 实现 checkpoint Storage API。
3. 实现 `ContextProjector`。
4. `/compact` 改为生成并提交 checkpoint。
5. 恢复路径改为确定性投影,删除恢复期间 Provider 调用。
6. Timeline 降级为派生检索记录。
### 阶段 3pi 风格自动触发
1. 增加三字段配置。
2. 实现统一 `ContextBudget`。
3. 完整请求草稿纳入 token 计算。
4. Turn 前自动压缩切换到 reserve 公式。
5. `/info` 和 WebUI 使用同一预算结果。
6. 删除 70%/90% ratio 逻辑。
### 阶段 4overflow 与清理
1. overflow 改用统一压缩入口。
2. 增加整 Turn 确定性降级 checkpoint。
3. 确保只重试一次。
4. 停止使用旧时间戳/Timeline 恢复分支;兼容字段暂时保留。
5. 删除旧 `ContextCompressor` 适配层和无用配置。
各阶段可以分别提交,但整个用户可见功能完成时统一按仓库规则增加中段版本号;不要在同一功能分支每个小阶段重复改版本。
## 20. 测试计划
### 20.1 预算单元测试
- 128K 默认配置得到 `128000 - 16384` 阈值。
- 小窗口 reserve 和 keep_recent 正确缩放。
- `context_tokens == threshold` 不触发,`threshold + 1` 触发。
- `enabled=false` 不自动触发,但 manual/overflow 仍可执行。
- usage 与 projection 不匹配时回退完整估算。
- system/tools 固定开销超过预算时返回明确错误。
### 20.2 切点单元测试
- 从尾部按 token 保留完整 Turn。
- 至少保留最新 Turn。
- steering 与所属 Turn 一起保留。
- 并行 tool calls 和全部 results 不被切开。
- 缺少 `turn_id` 的旧消息可回退分组。
- 重复压缩只摘要上一 summary 后新增的中间区。
- 没有新增可压缩区返回 no-op。
### 20.3 Storage 测试
- 新库创建 checkpoint schema。
- 旧库迁移增加字段且原消息不变。
- checkpoint insert 与活动指针原子提交。
- 错误 generation 导致完整 rollback。
- `/clear` 与 checkpoint invalidation 原子执行。
- 物理删除 Session 时外键级联删除 checkpoints普通 `/delete` 是软删除,保留行供审计。
- 压缩后追加消息 seq 继续单调递增,无唯一索引冲突。
### 20.4 恢复测试
- 重启前后投影逐字段一致。
- checkpoint 摘要与 raw tail 不重复。
- 摘要期间追加的新消息出现在恢复尾部。
- 缺失/损坏 checkpoint 回退完整原始历史。
- 恢复路径不调用 Provider、不写 Timeline。
- Provider 私有状态只按既有匹配规则回放。
### 20.5 入口测试
- 低于自动阈值时 `/compact` 仍强制压缩。
- 自动入口只在 reserve 阈值后触发一次。
- 自动摘要失败不改变活动 checkpoint。
- 128K 等大窗口的摘要输入不受固定 32K 限制,小窗口按模型窗口自动收缩。
- 超大摘要源生成有界单次请求,保留 checkpoint、最新材料和明确的省略/截短元数据。
- 换成更小模型后若发送前已经硬超限,摘要失败会在普通 Provider 请求前生成 overflow 降级 checkpoint。
- overflow 使用新窗口重算并最多重试一次。
- overflow 降级按完整 Turn 裁剪并标记 `degraded`。
- 工具执行后的 overflow 在同一 AgentLoop 重试,工具只执行一次且当前 tool call/result 仍在重试请求中。
- 工具后的第二次 overflow 不再发起额外重试,也不回到 Session 重跑。
- 活跃 Turn 下 `/compact` 不会静默取消或覆盖 Turn 状态。
- persistence failure 不产生内存/数据库分叉。
### 20.6 验证命令
Rust 实现完成后运行:
```bash
cargo test --lib
cargo clippy --all-targets --all-features -- -D warnings
cargo build
```
如果修改 WebUI stats再运行
```bash
cd webui
npm ci
npm run check
npm run build
cd ..
cargo build
```
## 21. 验收标准
实现完成后,以下陈述必须全部成立:
1. `/compact` 是真正的强制压缩,不依赖自动阈值。
2. 自动压缩唯一公式是 `context_tokens > context_window - effective_reserve`。
3. 压缩后原始聊天历史、message ID 和 seq 均不改变。
4. Session 重启不调用 LLM并恢复出相同 Provider 历史投影。
5. 每个 Session 只有一个活动 checkpoint投影不叠加多个 Timeline。
6. 连续压缩不会重复摘要已经覆盖的原始前缀。
7. 新消息在压缩期间到达也不会丢失或被摘要覆盖。
8. 工具调用和结果不会因切点成为孤立消息。
9. checkpoint 提交失败不会改变当前有效上下文。
10. `/info`、WebUI 和自动触发使用同一个 token 预算结果。
11. 普通压缩失败不会无声丢弃历史;只有 overflow 使用明确标记的整 Turn 降级。
12. 运行时不存在第二套按 70%/90% 比例判断的旧压缩路径。
13. 摘要输入上限从当前模型窗口推导,超大历史通过 request-local 有界转录进入一次摘要调用,不因固定上限拒绝压缩。
## 22. 最终取舍
本方案没有采用功能最丰富的压缩系统,而选择了更适合 PicoBot 当前架构的最小可靠闭环:
```text
append-only raw messages
+ one active checkpoint
+ first retained seq
+ pi-style reserve threshold
+ one summary call
+ whole-turn overflow fallback
```
它牺牲后台优化、复杂防抖和精细工具输出治理,换取更少状态、更清晰的恢复语义和更容易覆盖的测试面。未来只有在实际指标证明单一 checkpoint 模型不足时,才增加新的压缩层级或并发协调机制。