# 上下文压缩 (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<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 | 不修改 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 二期做 |
| 失败 | 不修改历史，用户可继续或开新会话 |

