# 上下文压缩 (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 描述 token,LLM 可能不合时宜地主动调用,增加模型决策负担。放在网关层与输入框前缀指令层,彻底脱离 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)` 配对组 ### 阶段 3:LLM 摘要中间区间 - `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 model,prompt 切换为"更新版"模板,保留仍相关的旧摘要内容并合并新轮次。 ### 摘要 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; async resolveChannel(agentId: number): Promise { ... } 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=''` ## 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 | 不修改 messages,emit 失败,原对话可继续 | | 摘要返回空字符串/异常短(<200字符) | 视为失败 | | 压缩中用户发新消息 | `compactionInFlight=true` 时排队等待 | | 并发自动 + 手动触发 | session 级互斥锁,后来者跳过 | | DB 写摘要失败 | 回滚内存 messages,emit 失败 | ## 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 二期做 | | 失败 | 不修改历史,用户可继续或开新会话 |