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

21 KiB
Raw Blame History

上下文压缩 (Context Compaction) — 设计文档

日期2026-04-17 范围NetaClaw 单 Agent 对话场景 参考Hermes Agent context_compressor.py

1. 目标

为 NetaClaw Agent 引擎添加上下文压缩能力,解决长对话撞上下文窗口限制的问题。同时在前端实时显示 token 用量与压缩状态。

非目标(本期不做)Crew 子 Agent 压缩、向量检索式记忆召回、多级摘要树、摘要导出/导入、跨会话共享摘要。

2. 触发机制

双模式触发:

2.1 自动触发(阈值驱动)

架构决策:压缩逻辑不放在 runAgent() 内部(它是纯函数,不持有状态),而是放在 gateway/server.tshandleChat 流程中,在调用 runAgent() 之前执行。这样可以直接访问 Midway DI 容器中的 Service 和 DB 实体。

server.tshandleChat 方法中,构建 messages 数组后、调用 runAgent() 前插入检查点:

// 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 MapthinkLeveltodoStore 同级)。这是连接级互斥(同一 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 估算

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 来解析渠道配置:

@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()

压缩完成后需要在事务中执行以下操作:

// 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 协议扩展

// 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

{ view: 'compacted' | 'full' }  // 默认 compacted
  • compacted 模式:过滤掉 compactedAt IS NOT NULL 的消息,保留摘要消息
  • full 模式:返回全量消息(含原始被压缩的消息和摘要消息,按 createTime 排序)

送给 LLM 的 messages 永远走 compacted 模式,前端"查看完整历史"按钮才切 full

返回类型变更:当前 loadHistory 返回 LLMMessage[](不含 id)。压缩需要消息 ID 来标记被压缩的消息。新增 LLMMessageWithId 类型:

export interface LLMMessageWithId extends LLMMessage {
  id: number;
}

loadHistory 内部返回 LLMMessageWithId[],但传给 runAgent 时仍然只取 LLMMessage 部分(向后兼容)。

需要同步修改的调用处

  • server.ts 第 115 行:const history = await this.sessionService.loadHistory(sid) -> 传入 view 参数
  • 前端 store/chat.tsloadMessages() 调用的 HTTP 端点(如有)

7.1 需要修改的类型定义文件清单

新增 WebSocket 事件类型需要同步更新以下文件:

文件 修改内容
backend gateway/protocol.ts ClientMessage 联合类型新增 compactServerEvent 联合类型新增 compaction_start/done/failedServerTokenUpdateEvent.context 新增 thresholdPercentestimated
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 输入框斜杠命令识别

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 覆盖,通过模型渠道管理
压缩策略 一期仅 lossyDB 字段预留扩展
阈值 Agent 可配置,系统默认 70%
持久化 入库 + compactedAt 标记 + 视图切换
UI 底部进度条 + 阈值刻度线 + 压缩事件气泡 + 摘要 drawer
范围 仅单 Agent 场景Crew 二期做
失败 不修改历史,用户可继续或开新会话