# 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, checkpoint_id: Option, context_generation: i64, first_raw_seq: Option, 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, 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 ``` 事务伪代码: ```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: category: Timeline session_id: content: ``` 规则: - 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 模型不足时,才增加新的压缩层级或并发协调机制。