1009 lines
37 KiB
Markdown
1009 lines
37 KiB
Markdown
# PicoBot 上下文压缩架构设计与实施方案
|
||
|
||
> 状态:已于 PicoBot 1.19.0 实施;1.20.0 增加 Model 级窗口配置与 Agent 上限收紧
|
||
> 编写日期: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 的有效窗口:Model 的 `token_limit` 缺失时先回退 128000,再与可选 Agent `token_limit` 取最小值;Agent 因而只能收紧、不能扩大模型窗口。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,200(Provider 实测 + 尾部估算)
|
||
活动 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 风险继续存在。
|
||
|
||
### 阶段 2:checkpoint 与投影
|
||
|
||
1. 增加 schema 和迁移。
|
||
2. 实现 checkpoint Storage API。
|
||
3. 实现 `ContextProjector`。
|
||
4. `/compact` 改为生成并提交 checkpoint。
|
||
5. 恢复路径改为确定性投影,删除恢复期间 Provider 调用。
|
||
6. Timeline 降级为派生检索记录。
|
||
|
||
### 阶段 3:pi 风格自动触发
|
||
|
||
1. 增加三字段配置。
|
||
2. 实现统一 `ContextBudget`。
|
||
3. 完整请求草稿纳入 token 计算。
|
||
4. Turn 前自动压缩切换到 reserve 公式。
|
||
5. `/info` 和 WebUI 使用同一预算结果。
|
||
6. 删除 70%/90% ratio 逻辑。
|
||
|
||
### 阶段 4:overflow 与清理
|
||
|
||
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 有界转录进入一次摘要调用,不因固定上限拒绝压缩。
|
||
14. 主 Agent 与具名 Agent 都先以 Model 配置或 128K 默认值确定硬上限,再与可选 Agent 上限取最小值。
|
||
|
||
## 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 模型不足时,才增加新的压缩层级或并发协调机制。
|