产品思考见 docs/insights/context-management.md
压缩覆盖边界(boundary rowid、不变量、三条压缩路径的一致写法)见 compact-coverage-boundary.md
上下文管理系统解决长对话的上下文窗口溢出问题。实现于 commits acfe4b7..21635dc,引入三个新模块 + 对已有 claude-client.ts、route.ts、context-assembler.ts 的集成改动。
核心能力:
- 上下文预估 — 发送前估算 token 用量,决定是否触发压缩
- 自动压缩 — 超过 80% 窗口时用 LLM 生成对话摘要
- 消息归一化 — 剥离元数据、摘要化工具调用、按年龄截断
- PTL 被动压缩 — API 返回 prompt_too_long 时自动压缩重试
- 前端可视化 — 双指标(实际用量 + 下一轮预估)+ 压缩状态
src/lib/
├── context-estimator.ts # Token 粗估 + 预算计算 + 状态判断
├── context-compressor.ts # LLM 压缩引擎 + 熔断器
├── context-assembler.ts # System prompt 静态/动态分离(已有,本次改造)
├── message-normalizer.ts # 消息清理 + Microcompaction
├── claude-client.ts # buildFallbackContext 集成 + PTL reactive compact
└── model-context.ts # context_1m 感知(已有,本次修复)
src/app/api/chat/
└── route.ts # 压缩编排:预估 → 阈值检查 → 压缩 → 预算重算
src/hooks/
└── useContextUsage.ts # 双指标计算(实际 + 预估)
src/components/chat/
└── ContextUsageIndicator.tsx # 环形进度 + HoverCard 详情 + hasSummary 标记
src/lib/
└── stream-session-manager.ts # context_compressed / context_compressing_retry 事件分发
纯计算模块,无副作用,无网络调用。
roughTokenEstimate(text, isJson?)— 4 bytes/token(JSON 2 bytes/token),基于Buffer.byteLengthestimateMessageTokens(content)— 自动检测 JSON 内容estimateContextTokens(params)— 聚合 system + history + userMessage + summary 的总估算calculateContextPercentage(tokens, window)— 返回 percentage + state (normal/warning/critical)
阈值:warning >= 80%, critical >= 95%。
LLM 压缩引擎。
needsCompression(tokens, window, sessionId)— 阈值 80% + 熔断器检查compressConversation(params)— 主压缩函数:- 通过
resolveProvider({ useCase: 'small' })解析压缩用模型 - 用
normalizeMessageContent清理消息后截断到 800 字符 - 调用
generateTextViaSdk生成摘要 - 返回
{ summary, messagesCompressed, estimatedTokensSaved }
- 通过
- 熔断器:per-session,连续 3 次失败后停止压缩(
MAX_CONSECUTIVE_FAILURES)
两层处理管线:
-
normalizeMessageContent(role, raw) — 始终应用:
- 剥离
<!--files:...-->内部元数据 - assistant JSON 消息:提取 text block + tool_use 摘要(
(used tool_name: truncated_input)) - tool_result block 被跳过(intent 已由 tool_use 摘要覆盖)
- 剥离
-
microCompactMessage(role, content, ageFromEnd) — fallback 路径应用:
- 近 30 条消息:5000 字符上限
- 超过 30 条的旧消息:1000 字符上限
- Head+Tail 截断策略(70% 头部 + 30% 尾部 +
[...truncated...]标记)
用户消息到达 POST /api/chat
│
├── 1. 解析模型 → getContextWindow(model, { context1m })
│
├── 2. 归一化预估(与 buildFallbackContext 一致)
│ ├── normalizeMessageContent() 每条消息
│ ├── microCompactMessage() 按年龄截断
│ └── estimateContextTokens() 聚合
│
├── 3. 计算 fallback token 预算
│ budget = window * 0.7 - system - summary - userMessage
│
├── 4. needsCompression(estimate.total, window, sessionId)?
│ │
│ ├── YES → 确定 keep/compress 分界线(保留最近 50% 窗口)
│ │ ├── compressConversation(older messages)
│ │ ├── updateSessionSummary(sessionId, summary)
│ │ ├── 重算预算(用新 summary 大小)
│ │ └── compressionOccurred = true
│ │
│ └── NO → 跳过
│
├── 5. streamClaude({ sessionSummary, fallbackTokenBudget, ... })
│ └── SDK 调用 → resume 成功则忽略 fallback
│ └── resume 失败 → buildFallbackContext(prompt, history, summary, budget)
│
└── 6. SSE 事件流
└── compressionOccurred → emit context_compressed 事件
SDK 返回 CONTEXT_TOO_LONG 错误
│
├── ptlRetryAttempted? → YES → 跳过,走正常错误处理
│
├── NO →
│ ├── emit status: context_compressing_retry
│ ├── compressConversation(全部历史)
│ ├── updateSessionSummary
│ ├── 重算预算(保守 50% 窗口)
│ ├── 清空 sdkSessionId(确保 retry 走 fallback)
│ ├── buildFallbackContext(prompt, history, summary, retryBudget)
│ ├── 重新 query() → 转发 stream 事件
│ └── emit context_compressed
│
└── retry 失败 → fall through 到正常错误显示
输入:prompt, history, sessionSummary, tokenBudget
│
├── 无历史 → 直接返回 prompt
│
├── normalize + microCompact 每条消息(年龄分级)
│
├── token 预算截断(从最新往回累加,budget 下限 10K)
│
├── 组装输出:
│ ├── <conversation_summary> (如有 summary)
│ ├── <conversation_history>
│ │ └── Human/Assistant 交替
│ └── Human: {当前 prompt}
│
└── 返回完整 prompt 字符串
chat_sessions 表新增两列:
| 列 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| context_summary | TEXT | '' | LLM 生成的对话摘要 |
| context_summary_updated_at | TEXT | '' | 摘要最后更新时间 |
迁移方式:safeAddColumn(ADD COLUMN IF NOT EXISTS),无数据丢失风险。
相关函数:
getSessionSummary(sessionId)→{ summary, updatedAt }updateSessionSummary(sessionId, summary)— 同时写入时间戳
压缩模块最初尝试用 @ai-sdk/anthropic 的 generateText 做 LLM 调用。问题:第三方 provider(如 GLM、Kimi)通过代理 URL 连接,@ai-sdk/anthropic 不走代理。
解决方案:改用 generateTextViaSdk,即通过 Claude Code SDK subprocess 发起调用。SDK 继承用户配置的 provider transport(包括代理 URL),确保所有 provider 的压缩都能正常工作。
相关 commit: 8f8b0d9
压缩器需要用小模型(Haiku 级别)来生成摘要。解析链:
resolveProvider({ useCase: 'small', providerId, sessionModel })- 优先使用 provider 的
roleModels.small配置 - fallback 到 catalog 中的
upstreamModelId - 最终 fallback 到 session model 或
'haiku'
这确保第三方 provider 用自己的小模型做压缩(如 GLM 用 GLM-4-Flash),而非强制调用 Anthropic Haiku。
相关 commit: a2f29ea, 9eb3d52
context-assembler.ts 将 system prompt 分为两部分:
静态前缀(跨请求稳定,利于 prompt cache):
- WIDGET_SYSTEM_PROMPT — 编译时常量
- session.system_prompt — 创建时设置
- Workspace identity files — 文件修改时才变
动态后缀(每轮可能变化): 4. Memory hint — 每日变化 5. Assistant instructions — 取决于 onboarding/heartbeat 状态 6. Dashboard summary — 随 widget 操作变化 7. systemPromptAppend — 每请求(技能注入等)
稳定内容在前,Anthropic API 的 prompt cache 从头部开始匹配,最大化缓存命中。
相关 commit: 3f90039
两级截断策略:
- 最近 30 条消息:每条最多 5000 字符(
RECENT_CONTENT_LIMIT) - 更老的消息:每条最多 1000 字符(
OLD_CONTENT_LIMIT)
Head+Tail 截断保留头部结构 + 尾部最新内容。阈值 30 条是经验值——大多数用户在 30 轮内完成一个任务上下文,更早的消息通常只需保留概要。
压缩生成的 summary 可能比原始消息小很多,也可能仍然较大。压缩后必须重新计算 fallback token budget:
newBudget = window * 0.7 - systemTokens - newSummaryTokens - userMsgTokens
不重算会导致 budget 基于旧 summary 大小(通常为 0),给历史消息分配过多空间,挤占 output 预算。
相关 commit: 6d50b98
PTL(Prompt Too Long)是 API 层面的硬限制。处理策略:
- 单次请求最多 retry 一次(
ptlRetryAttemptedflag) - retry 用保守的 50% 窗口预算
- 清空
sdkSessionId确保 retry 走 fallback 路径 - 熔断器:per-session 连续 3 次压缩失败后停止(避免无限循环)
resetCompressionState(sessionId)用于手动/compact时重置熔断器
相关 commit: 03d8f00, 3f90039
API 硬限制 100 个媒体项。实现策略:
- 计数超过 100 时,
slice(-MAX_MEDIA_ITEMS)保留最新的 - 被丢弃的旧图片在 content blocks 中添加文本说明
- 文本引用(file path reference)只为实际包含的图片生成
保留最新(而非最旧)与"近期上下文更重要"的整体策略一致。
相关 commit: 21635dc, 670ba71
环形 SVG 进度圈 + HoverCard 浮层,显示:
- 模型名 + 上下文窗口大小
- 实际用量(from last API response token_usage)
- 百分比
- 下一轮预估(current input + this output + 200 overhead)
- Cache 明细(read / creation / output)
- hasSummary 标记(绿色 "Active" 标签)
- Warning/Critical 提示文字
双指标设计:warning state 取实际 ratio 和预估 ratio 中较高者。这解决了"当前轮还没到 80%,但下一轮必然超"的场景。
前端通过两个途径感知 summary 状态:
- 初始加载 —
ChatView从 DB 读取getSessionSummary(sessionId).summary - 实时更新 —
stream-session-manager监听 SSEcontext_compressed事件,dispatchCustomEvent('context-compressed')
ChatView 在两个事件源上维护 hasSummary state,传入 ContextUsageIndicator。
PTL reactive compact 期间,stream-session-manager 收到 context_compressing_retry 事件后更新 snapshot 的 statusText 为 'Compressing context...',让用户知道系统在自动处理。
- Token 估算是粗估 — 4B/tok 对非英文内容偏差较大(中文可能 2-3B/tok),但精确计数需要 API 调用,增加延迟
- 无 prompt cache 精细控制 — SDK preset append 模式不暴露
cache_controlAPI,静态/动态分离只能提高命中概率,无法保证 - 压缩不可逆 — 一旦 summary 覆盖了旧 summary,原始对话细节永久丢失(DB 中原始消息仍保留,但不参与后续 context)
- Compact 后无上下文恢复 — Claude Code 在 compact 后会恢复最近读取的文件、活跃的 plan 等。CodePilot 只有 summary + 最近消息
- 单模型压缩 — 压缩和主对话用同一个 provider 通道,如果 provider 限流可能影响压缩质量
- 无 blocking 阈值 — 没有阻止用户继续发送的硬限制,极端情况下可能连续触发 PTL
| 用途 | 文件 |
|---|---|
| Token 估算 | src/lib/context-estimator.ts |
| LLM 压缩引擎 | src/lib/context-compressor.ts |
| 消息归一化 | src/lib/message-normalizer.ts |
| System prompt 分离 | src/lib/context-assembler.ts |
| Fallback + PTL retry | src/lib/claude-client.ts |
| 压缩编排 | src/app/api/chat/route.ts |
| 前端指标计算 | src/hooks/useContextUsage.ts |
| 前端 UI | src/components/chat/ContextUsageIndicator.tsx |
| SSE 事件分发 | src/lib/stream-session-manager.ts |
| DB schema + 读写 | src/lib/db.ts |
| 单元测试 | src/__tests__/unit/message-normalizer.test.ts |
| 原始分析文档 | docs/future/context-management-optimization.md |