GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-04-17-context-compaction-design.md
2026-05-20 21:39:12 +08:00

509 lines
21 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.

# 上下文压缩 (Context Compaction) — 设计文档
> 日期2026-04-17
> 范围NetaClaw 单 Agent 对话场景
> 参考Hermes Agent `context_compressor.py`
## 1. 目标
为 NetaClaw Agent 引擎添加上下文压缩能力,解决长对话撞上下文窗口限制的问题。同时在前端实时显示 token 用量与压缩状态。
**非目标(本期不做)**Crew 子 Agent 压缩、向量检索式记忆召回、多级摘要树、摘要导出/导入、跨会话共享摘要。
## 2. 触发机制
双模式触发:
### 2.1 自动触发(阈值驱动)
**架构决策**:压缩逻辑不放在 `runAgent()` 内部(它是纯函数,不持有状态),而是放在 `gateway/server.ts``handleChat` 流程中,在调用 `runAgent()` 之前执行。这样可以直接访问 Midway DI 容器中的 Service 和 DB 实体。
`server.ts``handleChat` 方法中,构建 messages 数组后、调用 `runAgent()` 前插入检查点:
```typescript
// server.ts handleChat 内部
const estimated = estimateMessagesTokens(messages);
const ctxMax = getModelMaxTokens(agentConfig.model);
const thresholdPercent = agentEntity.compactionThresholdPercent ?? 70;
const threshold = thresholdPercent / 100 * ctxMax;
const state = this.sessionState.get(sid);
if (estimated >= threshold && !state?.compactionInFlight) {
state.compactionInFlight = true;
this.send({ type: 'compaction_start', sessionId: sid, reason: 'auto' });
try {
const result = await this.compactionService.compress({
messages, sessionId: sid, agentId, agentEntity,
});
messages = result.compressedMessages;
this.send({ type: 'compaction_done', sessionId: sid, ... });
} catch (err) {
this.send({ type: 'compaction_failed', sessionId: sid, error: err.message });
// 不修改 messages继续原对话
} finally {
state.compactionInFlight = false;
}
}
// 然后正常调用 runAgent({ agentConfig, tools, userMessage, history: messages, ... })
```
**`compactionInFlight` 存储位置**:存入 `sessionState` Map`thinkLevel``todoStore` 同级)。这是连接级互斥(同一 WS 连接内有效。PM2 多进程下同一用户的 WS 连接固定在一个进程上Socket.IO sticky session所以可接受。
### 2.2 手动触发(`/compact` 指令)
前端输入框识别 `/compact` 前缀,直接 emit WebSocket `compact` 事件。不经过 LLM不占用工具槽位。网关收到后强制执行压缩流程无论当前 token 占用多少。
**为什么不做成 LLM 工具**:放进 `tools/builtin/` 会占用工具槽位与 system prompt 描述 tokenLLM 可能不合时宜地主动调用,增加模型决策负担。放在网关层与输入框前缀指令层,彻底脱离 LLM 视线。
## 3. 核心算法(四阶段有损压缩)
### 阶段 1裁剪旧工具结果零成本预处理
从消息数组末尾反向走,累计 token。当累计 token 超过 `tailTokenBudget`(默认 = 阈值 token x 20%)后,把前面所有 `role=tool` 且内容超过 200 字符的消息内容替换为占位符:`"[旧工具输出已清除以节省上下文]"`
此阶段不调用 LLM。如果只做完这一步 token 已经降到阈值以下,跳过阶段 2/3。
### 阶段 2确定压缩区间边界
- `compressStart = protectFirstN`(默认 3即系统提示 + 首轮用户 + 首轮助手)
- 向前对齐:若 `compressStart` 落在孤立的 tool 结果上(无对应 assistant tool_call向后跳一位
- `compressEnd = findTailCutByTokens(messages, tailTokenBudget)`,从末尾反向累积 token 到预算耗尽为止
- 硬下限:至少保留 3 条尾部消息
- 向后对齐:避免切开 `assistant(toolCalls) <-> tool(result)` 配对组
### 阶段 3LLM 摘要中间区间
- `middle = messages[compressStart : compressEnd]`
- `serialized = serializeForSummary(middle)`,每条消息按 `[ROLE] content` 格式序列化,长内容截断为头 4000 字符 + 尾 1500 字符
- 调用 `AuxiliaryLLMClient.summarize(serialized, previousSummary?)`
- 摘要 token 预算:`max(2000, min(compressedTokens x 0.20, 12000))`
- 返回一条 `role=user` 的消息(避免与随后的 assistant 连续同角色content 前缀固定字符串:`[CONTEXT COMPACTION] 此前若干轮对话已被压缩以节省上下文空间...`
### 阶段 4装配 + 清理孤儿
- 新数组:`[...head, summaryMessage, ...tail]`
- 收集 tail 中所有 `tool_call_id`,与 assistant 消息中的 `toolCalls[].id` 交叉验证
- 孤儿 tool 结果 -> 删除
- 孤儿 tool_call -> 补占位 tool 结果 `"[结果来自已压缩的历史 -- 见上方摘要]"`
### Token 估算
```typescript
function estimateTokens(message: LLMMessage): number {
let n = Math.floor(message.content.length / 4) + 10;
for (const tc of message.toolCalls ?? []) {
n += Math.floor((tc.arguments?.length ?? 0) / 4);
}
return n;
}
```
### 迭代压缩
第二次及以后的压缩:从 DB 读取当前会话最近的 `is_compaction_summary=true` 消息作为 `previousSummary`,传给 auxiliary modelprompt 切换为"更新版"模板,保留仍相关的旧摘要内容并合并新轮次。
### 摘要 Prompt 模板
**首次压缩**
```
为后续助手创建一份结构化的交接摘要,以便在早期对话被压缩后继续工作。
待摘要轮次:
{serialized_conversation}
请使用以下结构:
## 目标
[用户想达成什么]
## 约束与偏好
[用户偏好、编码风格、约束、重要决策]
## 进度
### 已完成
[完成的工作 -- 包含具体文件路径、运行的命令、获得的结果]
### 进行中
[当前正在进行的工作]
### 受阻
[遇到的阻碍或问题]
## 关键决策
[重要的技术决策及原因]
## 相关文件
[读取、修改或创建的文件 -- 每个简要说明]
## 下一步
[需要做什么以继续工作]
## 关键上下文
[任何具体的值、错误消息、配置细节、或没有显式保留就会丢失的数据]
## 工具与模式
[使用了哪些工具、如何高效使用、任何工具特定的发现]
目标 ~{summary_budget} tokens。要具体包含文件路径、命令输出、错误消息、具体值。
仅写摘要正文,不要任何前缀或开场白。
```
**迭代更新**
```
你正在更新一份上下文压缩摘要。下方是上次压缩生成的摘要,自那以后产生了新的对话轮次需要合并。
之前的摘要:
{previous_summary}
待合并的新轮次:
{serialized_new_turns}
使用同样的结构更新。保留所有仍相关的现有信息,添加新进度,把"进行中"的项移到"已完成",仅当明显过时时才移除信息。
[同上结构]
目标 ~{summary_budget} tokens。
```
### 关键参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `thresholdPercent` | 70 | 触发压缩的上下文占比 |
| `protectFirstN` | 3 | 保护的头部消息数 |
| `tailTokenBudgetRatio` | 0.20 | 尾部预算占阈值 token 的比例 |
| `summaryMinTokens` | 2000 | 摘要最小输出 token |
| `summaryMaxTokens` | 12000 | 摘要最大输出 token |
| `summaryRatio` | 0.20 | 摘要目标为被压缩内容的 20% |
| `charsPerToken` | 4 | token 估算系数 |
## 4. 辅助模型
### 4.1 配置分层
1. **模型渠道管理页扩展** -- `model_channel` 表新增 `isAuxiliary` 标记字段boolean, default false
2. **系统默认辅助渠道** -- `dict_type` + `dict_info` 字典系统新增 `netaclaw.default_auxiliary_channel_id`
3. **Agent 级覆盖** -- `netaclaw_agent.auxiliary_model_channel_id`:为空 = 使用系统默认
4. **解析优先级**`Agent.auxiliaryModelChannelId -> dict_info(netaclaw.default_auxiliary_channel_id) -> 抛 "未配置辅助模型" 错误`
### 4.2 接口
`AuxiliaryLLMClient` 作为 Midway `@Provide()` Service 实现(非纯函数),通过 `@Inject()` 获取 `channelService` 和字典 Service 来解析渠道配置:
```typescript
@Provide()
export class AuxiliaryLLMClient {
@Inject()
channelService: NetaClawModelChannelService;
@InjectEntityModel(DictInfoEntity)
dictInfoRepo: Repository<DictInfoEntity>;
async resolveChannel(agentId: number): Promise<ModelChannelConfig> { ... }
async summarize(params: {
agentId: number;
serialized: string;
previousSummary?: string;
summaryBudget: number;
}): Promise<{ summary: string; usage: { input: number; output: number } }>
}
```
`server.ts` 中通过 `@Inject() auxiliaryClient: AuxiliaryLLMClient` 注入,传给 `CompactionService`
- 内部复用现有 `LLMProvider` 接口Anthropic/OpenAI/DeepSeek
- `thinking` 固定为 `off`(摘要不需要推理)
- `temperature` 固定为 0.3(摘要要稳定)
## 5. 数据库变更
### 5.1 `netaclaw_message` 新增字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `compactedAt` | datetime nullable | 该消息被压缩的时刻 |
| `compactedIntoId` | int nullable | 关联的摘要消息 ID |
| `isCompactionSummary` | boolean default false | 标记该消息本身就是摘要 |
索引:`(sessionId, compactedAt)``(sessionId, isCompactionSummary)`
### 5.5 压缩后 DB 回写流程(新增 `SessionService.applyCompaction()`
压缩完成后需要在事务中执行以下操作:
```typescript
// SessionService 新增方法
async applyCompaction(params: {
sessionId: string;
compressedMessageIds: number[]; // 被压缩的原始消息 ID 列表
summaryContent: string; // 摘要文本
}): Promise<{ summaryMessageId: number }> {
return this.entityManager.transaction(async (manager) => {
// 1. INSERT 摘要消息
const summary = await manager.save(NetaClawMessageEntity, {
sessionId: params.sessionId,
role: 'user',
content: params.summaryContent,
isCompactionSummary: true,
});
// 2. 批量 UPDATE 被压缩消息
await manager.update(NetaClawMessageEntity,
{ id: In(params.compressedMessageIds) },
{ compactedAt: new Date(), compactedIntoId: summary.id },
);
return { summaryMessageId: summary.id };
});
}
```
**前提**`loadHistory()` 返回值需要包含消息 `id` 字段。当前返回 `LLMMessage[]` 不含 `id`,需要扩展为 `LLMMessageWithId`(新增 `id: number` 字段),或在 `loadHistory` 内部维护 `id -> LLMMessage` 的映射。
### 5.2 `netaclaw_agent` 新增字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `auxiliaryModelChannelId` | int nullable | 辅助模型渠道 ID |
| `compactionStrategy` | varchar(32) default 'lossy' | 枚举,一期仅 `lossy` |
| `compactionThresholdPercent` | int default 70 | 触发阈值百分比 |
| `compactionProtectFirstN` | int default 3 | 保护头部消息数 |
### 5.3 `model_channel` 新增字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `isAuxiliary` | boolean default false | 是否可作为辅助模型渠道 |
### 5.4 字典表新增条目
使用项目现有的 `dict_type` + `dict_info` 字典系统(位于 `modules/dict/entity/`
1.`dict_type` 表新增一条:`name='NetaClaw配置', key='netaclaw'`
2.`dict_info` 表新增一条:`typeId=<上面的id>, name='default_auxiliary_channel_id', value='<channel_id>'`
## 6. WebSocket 协议扩展
```typescript
// Client -> Server
| { type: 'compact'; sessionId: string }
// Server -> Client
| { type: 'compaction_start'; sessionId: string; reason: 'manual' | 'auto' }
| { type: 'compaction_done'; sessionId: string;
removedCount: number; summaryMessageId: number;
tokensBefore: number; tokensAfter: number }
| { type: 'compaction_failed'; sessionId: string; error: string }
| { type: 'token_update'; sessionId: string;
current: { inputTokens; outputTokens; totalTokens; apiCalls },
context: { usedTokens; maxTokens; percent; thresholdPercent; estimated: true } }
```
注意:
- `context.thresholdPercent` 为新增字段,前端用于绘制阈值刻度线
- `context.estimated: true` 标记 `usedTokens` 是估算值(非精确 tokenizer 计数)
- **token 估算系数统一为 `1/4`**(即 `charsPerToken=4`)。现有 `server.ts` 第 322 行使用 `1/3`,需同步修改为 `1/4` 以保持一致
## 7. 消息历史加载变化
`SessionService.loadHistory(sessionId, options)` 新增 `options`
```typescript
{ view: 'compacted' | 'full' } // 默认 compacted
```
- `compacted` 模式:过滤掉 `compactedAt IS NOT NULL` 的消息,保留摘要消息
- `full` 模式:返回全量消息(含原始被压缩的消息和摘要消息,按 createTime 排序)
**送给 LLM 的 messages 永远走 `compacted` 模式**,前端"查看完整历史"按钮才切 `full`
**返回类型变更**:当前 `loadHistory` 返回 `LLMMessage[]`(不含 `id`)。压缩需要消息 ID 来标记被压缩的消息。新增 `LLMMessageWithId` 类型:
```typescript
export interface LLMMessageWithId extends LLMMessage {
id: number;
}
```
`loadHistory` 内部返回 `LLMMessageWithId[]`,但传给 `runAgent` 时仍然只取 `LLMMessage` 部分(向后兼容)。
**需要同步修改的调用处**
- `server.ts` 第 115 行:`const history = await this.sessionService.loadHistory(sid)` -> 传入 view 参数
- 前端 `store/chat.ts``loadMessages()` 调用的 HTTP 端点(如有)
### 7.1 需要修改的类型定义文件清单
新增 WebSocket 事件类型需要同步更新以下文件:
| 文件 | 修改内容 |
|---|---|
| `backend gateway/protocol.ts` | `ClientMessage` 联合类型新增 `compact``ServerEvent` 联合类型新增 `compaction_start/done/failed``ServerTokenUpdateEvent.context` 新增 `thresholdPercent``estimated` |
| `backend gateway/server.ts` | `onMessage` switch 新增 `compact` 分支;`sessionState` Map 类型新增 `compactionInFlight` |
| `frontend types/index.d.ts` | `TokenUpdateEvent.context` 新增 `thresholdPercent`;新增 `CompactionStartEvent/DoneEvent/FailedEvent` 类型 |
| `frontend store/chat.ts` | `handleWSEvent` switch 新增 `compaction_start/done/failed` 分支;新增 `compactionState` 响应式状态 |
| `frontend hooks/websocket.ts` | 注册 `compaction_*` 事件回调 |
## 8. 前端 UI
### 8.1 底部进度条式 Token 显示
聊天界面底部工具栏新增横跨整个宽度的极细进度条(高度 3px位于输入框上方。
**进度条视觉规则**
- 0 -- threshold绿色 `#67C23A`
- threshold -- 90%:橙色 `#E6A23C`
- 90% -- 100%:红色 `#F56C6C`,细微脉冲动画
- 阈值刻度线:进度条上以一条竖向细线标记(深灰 1px悬停显示"自动压缩阈值 70%"
- 压缩进行中:进度条变为蓝色渐变动画,状态行显示"正在压缩上下文..."
**状态行**
- 左:`{used}/{max} tokens . 阈值 {threshold}% . 已压缩 {count} 次`,悬停显示输入/输出/工具 token 分解
- 右:`[/compact]` 按钮,点击触发手动压缩
### 8.2 输入框斜杠命令识别
```typescript
const raw = inputText.value.trim();
if (raw === '/compact') {
socket.emit('compact', { sessionId });
inputText.value = '';
return;
}
```
封装为 `parseSlashCommand(raw)`,预留扩展。
### 8.3 压缩过程消息流视觉反馈
- `compaction_start` -> 末尾插入"正在压缩上下文..."气泡
- `compaction_done` -> 替换为"上下文已压缩 . 移除 N 条消息 . 节省 XX% token [查看摘要]"
- `compaction_failed` -> "压缩失败:{error} 可继续原对话或 [开新对话]"
点击 `[查看摘要]` 弹出 drawer 显示摘要内容Markdown 渲染)。
这些气泡入库为 `role=system, metadata.systemType='compaction_event'` 记录,刷新可见历史压缩事件。
### 8.4 历史视图切换
左侧会话列表每个已压缩会话项右侧增加"完整/压缩"切换图标。切换后调用 `loadHistory(sid, { view })` 重拉取。
完整视图时,消息列表上方显示 sticky 提示条:"当前显示完整历史(含已压缩消息). [切换回压缩视图]"。
### 8.5 Agent 编辑页"上下文压缩"子区域
`src/modules/agent/views/agent-edit.vue` 的"模型"Tab 新增:
- 辅助模型:下拉(继承全局默认 / 渠道列表,仅 isAuxiliary=true
- 压缩策略:下拉(当前仅 lossy灰色提示"更多策略开发中"
- 触发阈值:滑块 30%-95%,步进 5%,默认 70%
- 保护头部:数字输入 1-10默认 3
留空 = 继承系统级配置。
### 8.6 模型管理页扩展
渠道编辑表单新增 checkbox`可作为辅助模型`。勾选后该渠道出现在 Agent 编辑页"辅助模型"下拉中。
## 9. 错误处理
| 错误场景 | 处理策略 |
|---|---|
| 辅助模型渠道未配置 | emit `compaction_failed` with `reason='no_auxiliary_model'`,前端提示 |
| 辅助模型 API 超时/5xx | 不修改 messagesemit 失败,原对话可继续 |
| 摘要返回空字符串/异常短(<200字符 | 视为失败 |
| 压缩中用户发新消息 | `compactionInFlight=true` 时排队等待 |
| 并发自动 + 手动触发 | session 级互斥锁后来者跳过 |
| DB 写摘要失败 | 回滚内存 messagesemit 失败 |
## 10. 边界场景
- **首次对话消息数 < `protectFirstN`**永远不触发
- **整体小但单条超长**如粘贴 10 万字符文档阶段 1/2 不生效跳过压缩UI 告警"建议拆分"
- **模型切换导致 `ctxMax` 突变**200k -> 64k下次 LLM 调用前若已超标,立即触发自动压缩
- **系统提示本身就超阈值**:抛"系统提示过长"错误,引导精简
## 11. 测试策略
### 单元测试Jest后端
1. `estimateTokens` 边界值断言
2. `ContextCompressor.compress` 纯函数:消息数组、保护边界、孤儿处理、迭代摘要
3. `AuxiliaryLLMClient.resolve` 三种解析路径
4. 阶段 1 裁剪规则
5. 阶段 2 边界对齐(不切开 tool_call/result 组)
### 集成测试Jest + 测试数据库 neta_test
1. 完整流程40 条消息 -> `/compact` -> DB `compactedAt` 写入 -> `loadHistory(compacted)` 减少
2. `loadHistory(full)` vs `loadHistory(compacted)` 对比
3. 迭代压缩:第二次的 prompt 含 previousSummary
### 端到端验证(手动清单,写入 feature_list.json
- 单 Agent 连续发消息到 80% -> 观察自动压缩动画
- 任意节点输入 `/compact` -> 观察手动流程
- 压缩后继续,验证 Agent 记得 Goal 与文件路径
- 切换会话视图(压缩/完整)
- 配置无效渠道 -> 验证失败提示
- 辅助模型欠费模拟 -> 验证错误路径
- 压缩中输入新消息 -> 验证排队
## 12. 实施里程碑(三个 PR 粒度)
### PR 1 -- 后端核心
- DB 迁移(`netaclaw_message` + `netaclaw_agent` + `model_channel` 字段)
- `ContextCompressor` + `AuxiliaryLLMClient` + Token 估算
- 集成到 `runtime/agent.ts`(自动触发)
- 单元测试
### PR 2 -- 网关与协议
- WebSocket `compact` / `compaction_*` / `token_update` 事件
- `SessionService.loadHistory({ view })`
- 手动触发流程
- 集成测试
### PR 3 -- 前端 UI
- `token-stats.vue` 进度条化
- `/compact` 前缀识别 + 斜杠命令解析器
- 压缩过程消息气泡 + 摘要 drawer
- 会话视图切换按钮
- Agent 编辑页"上下文压缩"子区域
- 模型渠道页 `isAuxiliary` 勾选
## 13. 关键文件参考
| 组件 | 文件路径 |
|---|---|
| Agent 运行时 | `packages/backend/src/modules/netaclaw/runtime/agent.ts` |
| ReAct 尝试 | `packages/backend/src/modules/netaclaw/runtime/attempt.ts` |
| 思考系统 | `packages/backend/src/modules/netaclaw/runtime/thinking.ts` |
| 模型选择 | `packages/backend/src/modules/netaclaw/runtime/model_selection.ts` |
| LLM 提供者 | `packages/backend/src/modules/netaclaw/plugins/llm_providers/*.ts` |
| 会话服务 | `packages/backend/src/modules/netaclaw/gateway/session.ts` |
| WebSocket 协议 | `packages/backend/src/modules/netaclaw/gateway/protocol.ts` |
| WebSocket 网关 | `packages/backend/src/modules/netaclaw/gateway/server.ts` |
| 消息实体 | `packages/backend/src/modules/netaclaw/entity/message.ts` |
| Agent 实体 | `packages/backend/src/modules/netaclaw/entity/agent.ts` |
| 模型渠道实体 | `packages/backend/src/modules/netaclaw/entity/model_channel.ts` |
| 前端聊天页 | `packages/frontend/src/modules/agent/views/chat.vue` |
| Token 统计组件 | `packages/frontend/src/modules/agent/components/token-stats.vue` |
| 消息组件 | `packages/frontend/src/modules/agent/components/message-item.vue` |
| WebSocket Hook | `packages/frontend/src/modules/agent/hooks/websocket.ts` |
| 聊天 Store | `packages/frontend/src/modules/agent/store/chat.ts` |
| Agent 编辑页 | `packages/frontend/src/modules/agent/views/agent-edit.vue` |
| 模型渠道页 | `packages/frontend/src/modules/agent/views/model-channel.vue` |
## 14. 设计总结
| 维度 | 决策 |
|---|---|
| 触发 | 手动 `/compact` + 自动阈值双模式 |
| 算法 | 四阶段有损压缩(裁工具结果 -> 边界 -> LLM 摘要 -> 装配清理孤儿) |
| 辅助模型 | 全局默认 + Agent 覆盖,通过模型渠道管理 |
| 压缩策略 | 一期仅 `lossy`DB 字段预留扩展 |
| 阈值 | Agent 可配置,系统默认 70% |
| 持久化 | 入库 + `compactedAt` 标记 + 视图切换 |
| UI | 底部进度条 + 阈值刻度线 + 压缩事件气泡 + 摘要 drawer |
| 范围 | 仅单 Agent 场景Crew 二期做 |
| 失败 | 不修改历史,用户可继续或开新会话 |