目标
s01 到 s07 让智能体具备了完整的工具调用、权限管控、Hook 扩展、TodoWrite 规划、子智能体并发和技能按需注入能力。但在长时间对话中,messagesHistory 只增不减——每次工具调用追加两条消息(ASSISTANT 含 tool_use + USER 含 tool_result),一轮复杂操作就可能产生 10+ 条消息。最终 token 数超过 LLM API 的 context window 上限,请求被拒绝,对话被迫中断。
Index 08 引入四层渐进式上下文压缩流水线,在上下文接近阈值时自动压缩历史消息,保证 AgentLoop 可持续运行:
- Token 估算(Layer 1)—— 基于字符数的轻量 token 计数,零外部依赖
- 边界检测(Layer 2)—— 找到安全切割点,保护 tool_use/tool_result 配对不被拆散
- LLM 滚动摘要(Layer 3)—— 将旧消息压缩为摘要文本,SummaryBuffer 管理跨多次压缩的累积状态
- 紧急截断(Layer 4)—— 摘要后仍超限时的 FIFO 兜底
完成后的效果——智能体可以运行无限长的对话而不丢失关键上下文:
[DEBUG] Compact check: tokens=180000, threshold=160000, shouldCompact=true
[INFO] Compacting 45 messages (cut at 50, total 95)
[INFO] Summary complete: The user asked to implement a REST API with the following
endpoints: GET /users, POST /users, PUT /users/:id, DELETE /users/:id...
[INFO] Context compacted: layer=SUMMARIZE, messages 95→51, tokens 180000→45000为什么需要
现实问题
AgentLoop 的 messagesHistory 只增不减。每次工具调用会追加两条消息:
messagesHistory.add(Message(Role.ASSISTANT, content, toolCalls = [...])) // tool_use 块
messagesHistory.add(Message(Role.USER, "", toolResults = [...])) // tool_result 块一轮复杂操作(如 "重构这个模块")可能涉及 5-10 次工具调用,产生 10-20 条新消息。30 轮后就是 300-600 条消息,token 数轻松突破 100K。当 token 超限时:
- Anthropic API 返回
stop_reason: max_tokens,回复被截断——用户看到不完整的输出 - s07 的代码 直接返回截断内容,不重试——用户必须手动
/clear清空历史重新开始 - 丢失上下文——
/clear后智能体不知道之前做了什么,用户被迫重复告知
设计原则
- 先温和,再激进——优先用 LLM 摘要保留信息;只在摘要后仍超限时才 FIFO 截断
- 保护消息结构——压缩粒度按 tool_use/tool_result 配对,不破坏 API 可接受性
- 滚动摘要——多次压缩的摘要逐步合并,不丢失早期的关键决策信息
- 可观测——通过
onCompactHook 回调通知外部每次压缩事件(日志、统计、监控)
核心设计与实现
架构全景
AgentLoop.run() 每次迭代:
buildRequestMessages()
↓
Layer 1: compactor.shouldCompact(requestMessages) ← TokenEstimator.estimate()
↓ 超 compactTriggerRatio (80%)?
Layer 2: BoundaryDetector.findSafeCut() → 安全切割点
Layer 3: SummaryBuffer → LLM → 摘要 SYSTEM 消息
↓ 仍超 emergencyTriggerRatio (95%)?
Layer 4: emergencyTruncate() → FIFO 丢弃最旧消息
↓
llmProvider.chat(requestMessages)
↓ stopReason == MAX_TOKENS?
compactor.compact(messagesHistory) → continue 重试ContextCompactor 组合上述四个 Layer,对外暴露简洁的 API:
class ContextCompactor(
private val llmProvider: LLMProvider,
private val config: CompactionConfig = CompactionConfig()
) {
fun estimateTokens(messages: List<Message>): Int // Layer 1
fun shouldCompact(messages: List<Message>): Boolean // Layer 1 判断
suspend fun compact(messagesHistory: MutableList<Message>): CompactEvent? // Layer 1-4 全流程
fun emergencyTruncate(messagesHistory: MutableList<Message>): CompactEvent? // Layer 4 only
}与 SkillLoader / TodoDisplay 等 s05-s07 的扩展不同——压缩不是钩子,而是循环控制流的一部分。它直接嵌入 AgentLoop.run() 的每次迭代,在 LLM 请求前主动检查,在 MAX_TOKENS 后被动兜底。
第一层:TokenEstimator — 轻量 token 计数
为什么不用 tiktoken?
tiktoken 需要 Python 运行时或 JNI 绑定。引入它会增加 20MB+ 依赖、部署复杂度翻倍。而且 s08 只需要"是否接近阈值"的判断,精度 ~20% 完全够用——就像汽车油表的 E 标志,不需要精确到毫升。
算法
tokenCount ≈ totalCharCount / 3。3 个字符 ≈ 1 个 token 是保守估计(英文平均 4 字符/token,中文平均 1.5 字符/token,取 3 是安全下限)。
对 toolCalls 和 toolResults 也计入字符数——它们最终都会序列化为 JSON 发送给 API:
object TokenEstimator {
/** 字符数除以该值得到估算 token 数(保守估计:3 个字符 ≈ 1 个 token) */
private const val TOKEN_CHAR_RATIO = 3
private val json = Json { encodeDefaults = true }
fun estimate(messages: List<Message>): Int {
return messages.sumOf { estimateOne(it) }
}
fun estimateOne(message: Message): Int {
var charCount = message.content.length
// 工具调用的输入参数也占用 token(会序列化为 JSON 发送给 API)
if (message.role == Role.ASSISTANT && message.toolCalls.isNotEmpty()) {
for (tc in message.toolCalls) {
charCount += tc.name.length
charCount += json.encodeToString(
JsonObject.serializer(), tc.input
).length
}
}
// 工具执行结果也占用 token
if (message.role == Role.USER && message.toolResults.isNotEmpty()) {
for (tr in message.toolResults) {
charCount += tr.toolCallId.length
charCount += tr.content.length
}
}
return charCount / TOKEN_CHAR_RATIO
}
}设计要点:
object而非class——纯函数,无状态,直接用单例对象Json { encodeDefaults = true }——与 AnthropicProvider 的序列化配置一致,确保估算的 JSON 长度接近实际发送值- 整数除法——不追求浮点精度,保守下取整(
5 / 3 = 1),偏向低估而非高估。配合 80% 阈值比例,已留足安全边际 - 只计入 toolCalls 的 ASSISTANT 和 toolResults 的 USER——其他消息类型不包含这些字段,不计入
第二层:BoundaryDetector — 安全切割点检测
为什么需要?
LLM API 要求 tool_use 和 tool_result 必须成对出现。如果压缩时把 ASSISTANT(tool_use) 和紧随其后的 USER(tool_result) 拆散到切割线的两侧,API 会因"孤立的 tool_result 缺少对应 tool_use"而拒绝请求。
消息历史的结构是:
USER("read the file")
ASSISTANT("I'll read it", toolCalls=[read_file]) ← tool_use
USER("", toolResults=[file content]) ← tool_result(紧随 tool_use)
ASSISTANT("The file says hello")
USER("thanks")
...tool_use / tool_result 对是原子单元——要么一起保留,要么一起丢弃,绝不能拆散。
算法
从切割点向历史头部方向回退扫描:如果切割点恰好落在一条带 toolResults 的 USER 消息上,就向前回退,直到找到安全位置:
object BoundaryDetector {
/**
* 找到安全的消息切割位置。
*
* 从理想切割点(保留最近 [keepRecentCount] 条消息)开始向前回溯,
* 直到找到安全位置。安全切割点保证不会分割 tool_use / tool_result 配对。
*
* @param messages 完整消息列表
* @param keepRecentCount 保留的最近消息数(从末尾开始计数)
* @return 安全切割位置索引。0 表示无法安全切割或不需切割。
*/
fun findSafeCut(messages: List<Message>, keepRecentCount: Int): Int {
if (keepRecentCount >= messages.size) return 0
var cut = messages.size - keepRecentCount
// 如果切割点恰好在一条带有 toolResults 的 USER 消息上,
// 则表示该消息对应的 tool_use 在切割点之前被移除,
// 导致保留的 tool_result 没有对应的 tool_use,需要前移切割点。
while (cut > 0 && cut < messages.size &&
messages[cut].role == Role.USER && messages[cut].toolResults.isNotEmpty()
) {
cut--
}
return cut
}
}设计要点:
- 只检查切割点本身——不扫描全部历史。因为 tool_use 和 tool_result 在正常历史中总是相邻的:
ASSISTANT(tool_use)后面紧跟着USER(tool_result)。如果切割点落在USER(tool_result)上,回退一位就切在它前面的ASSISTANT(tool_use)上——这对被完整保留,切割安全 - 循环回退而非一次性回退——存在理论上的连续 tool_result 情况(虽然正常流程不会产生),while 循环兜底
keepRecentCount >= messages.size返回 0——不需要切割时直接返回 0,调用方通过safeCut > 0判断是否需要执行压缩object单例——纯算法,无状态,与 TokenEstimator 一致
第三层:SummaryBuffer — 滚动摘要状态管理
为什么需要"滚动"摘要?
压缩不是一次性事件。长对话可能触发多次压缩——第二次压缩时,它拿到的新一批旧消息只是"中间段"的对话,缺少早期对话的上下文。
滚动摘要解决这个问题:第二次压缩时,把第一次的摘要和新增的旧消息一起发给 LLM,让 LLM 合并生成一个更完整的摘要。第三次压缩时,又把第二次的摘要和更新一批的旧消息合并……依此类推。
设计
class SummaryBuffer {
/** 当前累积的摘要文本。null 表示尚无摘要(首次压缩)。 */
var currentSummary: String? = null
private set
/** 用新摘要替换当前摘要(滚动更新)。 */
fun update(newSummary: String) {
currentSummary = newSummary
}
/** 构建发给 LLM 的摘要请求 prompt。 */
fun buildSummaryPrompt(oldMessages: List<Message>): String {
val sb = StringBuilder()
sb.appendLine(SUMMARY_INSTRUCTION)
sb.appendLine()
if (currentSummary != null) {
sb.appendLine("## Previous Summary")
sb.appendLine(currentSummary)
sb.appendLine()
sb.appendLine("Incorporate the above previous summary with the " +
"new messages below into a single updated summary.")
sb.appendLine()
}
sb.appendLine("## Messages to Summarize")
sb.appendLine()
for (msg in oldMessages) {
val roleLabel = msg.role.name
sb.appendLine("$roleLabel: ${msg.content}")
if (msg.toolCalls.isNotEmpty()) {
sb.appendLine(" [Tool Calls: ${msg.toolCalls.joinToString { it.name }}]")
}
if (msg.toolResults.isNotEmpty()) {
val resultsPreview = msg.toolResults.joinToString { tr ->
if (tr.content.length > TOOL_RESULT_PREVIEW_LENGTH) {
tr.content.take(TOOL_RESULT_PREVIEW_LENGTH) + "..."
} else {
tr.content
}
}
sb.appendLine(" [Tool Results: $resultsPreview]")
}
}
return sb.toString()
}
/** 清空摘要缓冲区 */
fun clear() { currentSummary = null }
companion object {
private const val TOOL_RESULT_PREVIEW_LENGTH = 100
private const val SUMMARY_INSTRUCTION =
"You are a conversation summarizer. Your task is to create a concise " +
"but comprehensive conversation summary. Focus on:\n" +
"1. Key decisions and actions taken\n" +
"2. Important information discovered (file contents, command outputs)\n" +
"3. The user's explicit requests and preferences\n" +
"4. Any errors encountered and how they were resolved\n\n" +
"Write the summary in plain English. It will be inserted into the " +
"conversation history to help the assistant remember what happened " +
"earlier. Be factual and specific — include file paths, command names, " +
"and key findings."
}
}设计要点:
class而非object——ContextCompactor 持有一个 SummaryBuffer 实例。未来如果有多个 AgentLoop,每个应独立维护自己的摘要状态- 摘要 prompt 设计——固定指令 +
[Conversation Summary]前缀标记,明确告诉 LLM(以及未来的自己)这是一条摘要而非普通 SYSTEM 消息 - 工具结果截断到 100 字符——长文件内容、大段命令输出占 token 且摘要不需要完整内容,只需知道"读取了什么文件"即可
- 滚动更新语义是"replace"而非"append"——LLM 生成的新摘要已经包含了旧摘要的内容,不需要保留两份文本
摘要 prompt 示例
首次压缩(无旧摘要):
You are a conversation summarizer. Your task is to create a concise but
comprehensive conversation summary. Focus on:
1. Key decisions and actions taken
2. Important information discovered (file contents, command outputs)
3. The user's explicit requests and preferences
4. Any errors encountered and how they were resolved
...
## Messages to Summarize
USER: Read the config file at /home/user/project/settings.conf
ASSISTANT: I'll read the config file and check the settings.
[Tool Calls: read_file]
USER:
[Tool Results: server.port=8080, db.url=jdbc:postgresql://local...]
ASSISTANT: The config shows server port 8080 and a PostgreSQL database connection.第二次压缩(有旧摘要,滚动合并):
...
## Previous Summary
The user inspected the project configuration. Key findings: server runs on
port 8080, uses PostgreSQL database, and has debug mode enabled.
Incorporate the above previous summary with the new messages below into a
single updated summary.
## Messages to Summarize
USER: Update the database connection timeout to 30 seconds
...第四层:紧急截断 — FIFO 兜底
什么时候触发?
摘要后 token 数仍超过 emergencyTriggerRatio(95%) 时。这在以下场景发生:
- 摘要 LLM 调用本身失败或返回空
keepRecentRatio保留的最近消息本身就占据了大量 token- 工具调用的输入输出极其庞大(如读取了一个 50MB 的文件并塞进 tool_result)
算法
fun emergencyTruncate(messagesHistory: MutableList<Message>): CompactEvent? {
val tokensBefore = estimateTokens(messagesHistory)
val messagesBefore = messagesHistory.size
val emergencyThreshold = (config.contextWindow * config.emergencyTriggerRatio).toInt()
if (tokensBefore < emergencyThreshold) {
return null // 没到紧急阈值,不需要截断
}
val targetTokens = (config.contextWindow * config.keepRecentRatio).toInt()
var removed = 0
while (messagesHistory.size > config.minHistoryToCompact &&
estimateTokens(messagesHistory) > targetTokens
) {
// 每次只移除一批安全消息(通过 BoundaryDetector 找安全切割点)
val keepCount = (messagesHistory.size - 1).coerceAtLeast(1)
val cut = BoundaryDetector.findSafeCut(messagesHistory, keepCount)
if (cut <= 0) {
logger.warn { "Emergency truncate: cannot find safe cut, breaking" }
break
}
messagesHistory.subList(0, cut).clear()
removed += cut
}
val tokensAfter = estimateTokens(messagesHistory)
return CompactEvent(
layer = CompactLayer.TRUNCATE,
messagesBefore = messagesBefore,
messagesAfter = messagesHistory.size,
tokensBefore = tokensBefore,
tokensAfter = tokensAfter
)
}设计要点:
- 渐进式逐批移除——每次通过 BoundaryDetector 找一次安全切割点,移除一批,重新估算。避免一次性移除太多
coerceAtLeast(1)——保证每次至少移除一条消息,防止死循环- 无法找安全切割点时 break——极端情况下(如所有消息都是 tool_use/tool_result 对且无法安全切割),宁可保留超限历史也不能破坏消息结构。此时 LLM API 会拒绝请求,但至少不会产生更难追踪的"孤立的 tool_result"错误
- 这是真正会丢弃信息的操作——与摘要不同,截断的消息永久丢失。
CompactLayer.TRUNCATE和CompactLayer.SUMMARIZE分离,让 onCompact Hook 的消费者能区分这两种情况
配置参数:CompactionConfig
data class CompactionConfig(
val compactTriggerRatio: Double = DEFAULT_COMPACT_TRIGGER_RATIO, // 0.8
val emergencyTriggerRatio: Double = DEFAULT_EMERGENCY_TRIGGER_RATIO, // 0.95
val keepRecentRatio: Double = DEFAULT_KEEP_RECENT_RATIO, // 0.5
val summaryMaxTokens: Int = DEFAULT_SUMMARY_MAX_TOKENS, // 2048
val minHistoryToCompact: Int = DEFAULT_MIN_HISTORY_TO_COMPACT, // 4
val contextWindow: Int = DEFAULT_CONTEXT_WINDOW // 200_000
) {
companion object {
const val DEFAULT_COMPACT_TRIGGER_RATIO = 0.8
const val DEFAULT_EMERGENCY_TRIGGER_RATIO = 0.95
const val DEFAULT_KEEP_RECENT_RATIO = 0.5
const val DEFAULT_SUMMARY_MAX_TOKENS = 2048
const val DEFAULT_MIN_HISTORY_TO_COMPACT = 4
const val DEFAULT_CONTEXT_WINDOW = 200_000
}
}| 参数 | 默认值 | 含义 |
|---|---|---|
compactTriggerRatio | 0.8 | 达到 context window 80% 时主动触发摘要压缩 |
emergencyTriggerRatio | 0.95 | 达到 95% 时触发紧急截断 |
keepRecentRatio | 0.5 | 最近 50% 消息不参与压缩(保留最新上下文) |
summaryMaxTokens | 2048 | 单次摘要 LLM 调用的最大输出 token 数 |
minHistoryToCompact | 4 | 历史消息数少于此值时不触发压缩(太少没意义) |
contextWindow | 200000 | 模型 context window 大小(token 数),与 AgentConfig.contextWindow 对齐 |
为什么 80% 触发摘要? 不是 90% 或 95%?有两个原因:第一,charCount/3 估算有 ~20% 误差,80% 阈值留足了误差余量;第二,摘要 LLM 调用本身也消耗 token(摘要 prompt 也计入 context),需要提前触发为摘要调用留空间。
为什么摘要后再截断的阈值是 95%? 摘要已经做了一次温和压缩——如果摘要后还在 95% 以上,说明"最近消息"本身就已经非常大(例如当前轮次的操作涉及大量工具调用和输出),此时温和手段无效,只能激活截断。
AgentLoop 集成
两层保障
压缩嵌入 AgentLoop 的每次迭代,形成两层保障:
- 主动检查——每次 LLM 请求前:
shouldCompact()判断 token 是否超过 80% 阈值 - 被动兜底——API 返回 MAX_TOKENS 时:压缩 + continue 重试
suspend fun run(userInput: String): String {
sanitizeHistory()
messagesHistory.add(Message(Role.USER, userInput))
val toolDefinitions = toolRegistry.getDefinitions()
for (iteration in 1..config.maxIterations) {
val baseMessages = buildRequestMessages()
val appended = hooks.onPreLlmRequest?.invoke(baseMessages) ?: emptyList()
var requestMessages = if (appended.isEmpty()) baseMessages else baseMessages + appended
// s08: 主动压缩检查 —— 在 token 超限前压缩历史
if (compactor.shouldCompact(requestMessages)) {
logger.info { "Pre-request compaction triggered" }
val event = compactor.compact(messagesHistory)
if (event != null) {
hooks.onCompact?.invoke(event)
val rebuilt = buildRequestMessages()
val reappended = hooks.onPreLlmRequest?.invoke(rebuilt) ?: emptyList()
requestMessages = if (reappended.isEmpty()) rebuilt else rebuilt + reappended
}
}
val response = llmProvider.chat(requestMessages, options, toolDefinitions)
when (response.stopReason) {
StopReason.END_TURN -> {
messagesHistory.add(Message(Role.ASSISTANT, response.content))
return response.content
}
StopReason.TOOL_USE -> {
messagesHistory.add(Message(Role.ASSISTANT, response.content,
toolCalls = response.toolCalls))
val toolResults = executeTools(response.toolCalls)
messagesHistory.add(Message(Role.USER, "", toolResults = toolResults))
continue
}
StopReason.MAX_TOKENS -> {
// s08: 被动压缩 + 重试
logger.warn { "MAX_TOKENS received, compacting and retrying" }
val event = compactor.compact(messagesHistory)
if (event != null) {
hooks.onCompact?.invoke(event)
continue // 重试本轮
}
// 压缩失败(历史太短无法压缩),返回截断内容
if (response.content.isNotBlank()) {
messagesHistory.add(Message(Role.ASSISTANT, response.content))
}
return response.content
}
StopReason.ERROR, StopReason.STOP_SEQUENCE -> {
if (response.content.isNotBlank()) {
messagesHistory.add(Message(Role.ASSISTANT, response.content))
}
return response.content
}
}
}
// ...
}关键细节:
- 主动压缩后重建请求消息——
compact()直接修改了messagesHistory,必须重新调用buildRequestMessages()+onPreLlmRequesthook 来重建requestMessages。如果跳过了这一步,LLM 收到的还是压缩前的旧消息列表 - MAX_TOKENS 时用
continue而非直接 return——压缩后历史变短,应该重试本轮而不是中止。只有压缩实在无法执行(历史太短)时才 fallback 返回截断内容 - 压缩前后都触发 onCompact Hook——无论是主动压缩还是 MAX_TOKENS 压缩,都通过 Hook 通知外部
新增构造参数
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPrompt: String,
private val config: AgentConfig = AgentConfig(),
private val hooks: AgentLoopHooks = AgentLoopHooks(),
private val toolRegistry: ToolRegistry = ToolRegistry(),
private val compactor: ContextCompactor = ContextCompactor(llmProvider) // s08
)默认 ContextCompactor(llmProvider) ——复用主 LLMProvider 做摘要,使用默认 CompactionConfig。外部可通过 ReplLoop 传入自定义配置的 compactor。
onCompact Hook
data class AgentLoopHooks(
val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null,
val onPreToolUse: (suspend (ToolCall) -> String?)? = null,
val onPostToolUse: (suspend (ToolCall, ToolResult) -> ToolResult)? = null,
val onPreLlmRequest: (suspend (List<Message>) -> List<Message>)? = null,
val onCompact: (suspend (CompactEvent) -> Unit)? = null // s08
)onCompact 是一个纯通知回调(返回 Unit),不像 onBeforeToolExecute 能阻止执行或 onPostToolUse 能修改结果。它的唯一职责是可观测性——日志记录、统计收集、监控告警。压缩是系统级操作,不应被外部干预。
ReplLoop 装配
压缩器的构建和注入在 ReplLoop 中完成,传入 AgentLoop 构造器:
fun start() {
// ... 工具注册、权限管线、Hook 管理器、skill 加载 ...
// s08: 构建上下文压缩器(复用主 LLMProvider 做摘要)
val compactionConfig = CompactionConfig(
contextWindow = config.contextWindow // 从 AgentConfig 读取
)
val contextCompactor = ContextCompactor(llmProvider, compactionConfig)
agentLoop = AgentLoop(
llmProvider = llmProvider,
systemPrompt = systemPrompt,
config = config,
hooks = AgentLoopHooks(
onBeforeToolExecute = { tc -> pipeline.approve(tc) },
onPreToolUse = { tc -> hookManager.firePreToolUse(tc) },
onPostToolUse = { tc, result -> hookManager.firePostToolUse(tc, result) },
onPreLlmRequest = { msgs -> hookManager.firePreLlmRequest(msgs) },
onCompact = { event -> // s08: 日志记录压缩事件
logger.info { "Context compacted: layer=${event.layer}, " +
"messages ${event.messagesBefore}→${event.messagesAfter}, " +
"tokens ${event.tokensBefore}→${event.tokensAfter}" }
}
),
toolRegistry = toolRegistry,
compactor = contextCompactor // s08
)
// ...
}设计要点:
contextWindow从 AgentConfig 传入 CompactionConfig——不硬编码,允许 CLI 参数覆盖(如--context-window 100000)- 复用主 LLMProvider——摘要调用和正常对话走同一个 LLM 实例。未来可替换为更便宜的模型用于摘要(改 ReplLoop 构造即可,不影响 ContextCompactor 接口)
- onCompact 日志用
logger.info——压缩是"关键事件"(改变了消息历史结构),应该用 info 级别而非 debug - 欢迎语更新——
🐱 Cat-Code — Index 07: Skill Loading · s08: Context Compact
事件模型
CompactEvent
data class CompactEvent(
val layer: CompactLayer, // SUMMARIZE or TRUNCATE
val messagesBefore: Int, // 压缩前消息数
val messagesAfter: Int, // 压缩后消息数
val tokensBefore: Int, // 压缩前估算 token
val tokensAfter: Int, // 压缩后估算 token
val summaryText: String? = null // 摘要文本,仅 SUMMARIZE 时非空
)
enum class CompactLayer {
SUMMARIZE, // LLM 摘要压缩(Layer 3),温和,保留信息
TRUNCATE // 紧急 FIFO 截断(Layer 4),激进,丢弃消息
}两种事件的关系: 如果摘要后仍需截断(Layer 3 → Layer 4 级联),返回的 CompactEvent 会合并两者信息——layer 设为 TRUNCATE,但 summaryText 保留(来自摘要阶段),messagesBefore 是压缩前的值(整个流程的起点)。
测试策略
每层独立单元测试 + FakeLLMProvider 集成测试:
TokenEstimatorTest(7 用例)
| 用例 | 覆盖点 |
|---|---|
estimate returns 0 for empty list | 空列表边界 |
estimate counts characters across all messages | 多条消息累加 |
estimateOne counts content characters | 单条消息基础计算 |
estimateOne counts tool call JSON characters | toolCalls 计入序列化的 JSON 长度 |
estimateOne counts tool result characters | toolResults 计入 toolCallId + content 长度 |
estimate handles mixed English and Chinese | 中英文混合字符长度 |
estimate scales linearly with message count | 线性扩展性验证 |
BoundaryDetectorTest(9 用例)
| 用例 | 覆盖点 |
|---|---|
returns 0 when keepRecentCount >= total | 不需切割 |
returns 0 for empty list | 空列表边界 |
cuts at size - keepRecentCount for text-only | 纯文本基础切割 |
moves cut back when it lands on USER with toolResults | 核心配对保护逻辑 |
moves cut back past tool_use/tool_result pair | 回退到配对之前 |
handles consecutive tool rounds | 多轮工具调用的连续配对 |
handles keepRecentCount that lands on tool_use | 切割点落在 tool_use 上(安全,不需要回退) |
handles keepRecentCount = 1 | 极端保留数 |
keepRecentCount = 0 keeps nothing | 零保留边界 |
SummaryBufferTest(6 用例)
| 用例 | 覆盖点 |
|---|---|
currentSummary is null initially | 初始状态 |
update sets currentSummary | 设置摘要 |
update replaces previous summary (rolling) | 滚动替换(非追加) |
clear resets summary to null | 清空 |
buildSummaryPrompt includes old messages and existing summary | prompt 包含旧摘要 |
buildSummaryPrompt when no existing summary does not reference previous | 首次压缩 prompt |
buildSummaryPrompt formats messages readably | 消息格式化为 ROLENAME: content |
ContextCompactorTest(6 用例,FakeSummaryProvider)
| 用例 | 覆盖点 |
|---|---|
estimateTokens delegates to TokenEstimator | 委托验证 |
shouldCompact returns false when under threshold | 低于阈值不触发 |
shouldCompact returns true when over compactTriggerRatio | 高于阈值触发 |
shouldCompact returns false when history is too short | minHistoryToCompact 约束 |
compact triggers LLM summarization and replaces old messages | 完整压缩流程:摘要 → 替换历史 → 插入 SYSTEM 消息 |
compact returns null when under threshold | 不满足条件时跳过 |
emergencyTruncate removes oldest messages | 截断丢弃旧消息 |
emergencyTruncate returns null when under emergency threshold | 低于阈值不触发 |
AgentLoopTest(现有测试 + 压缩集成验证)
- 默认
ContextCompactor(llmProvider)—— 短对话不会触发压缩,现有测试全部通过 - 手动创建小 contextWindow 的 compactor —— 验证压缩触发后重建 requestMessages 的正确性
关键设计取舍汇总
| 决策点 | 选择 | 理由 |
|---|---|---|
| Token 计数算法 | charCount / 3 | 零依赖;~20% 精度用于阈值判断足够;避免 tiktoken 的 Python/JNI 依赖 |
| 摘要调用的模型 | 复用主 LLMProvider | 不引入次要 LLM 依赖;未来可通过构造注入替换为更便宜的模型 |
| 摘要输出温度 | 0.3 | 摘要需要确定性,不需要创造性;0.3 接近 "low temperature" 的最佳实践 |
| 摘要消息角色 | SYSTEM | 摘要不是对话参与者,是元信息;[Conversation Summary] 前缀明确标记 |
| 压缩触发时机 | 每次 LLM 请求前主动检查 | 在 token 超限前压缩比在 API 拒绝后恢复更优——不浪费一次失败的 API 调用 |
| MAX_TOKENS 兜底 | compact + continue 重试 | 比直接返回截断内容更优——用户看到完整回复而非半截输出 |
| 边界检测范围 | 只检查切割点本身 | tool_use/tool_result 始终相邻,不必扫描全部历史 |
| 滚动摘要语义 | replace 而非 append | LLM 合并后的新摘要已包含旧摘要,保留两份浪费 token |
| 摘要用独立 SYSTEM 消息 | 一条 SYSTEM 消息替代多条旧消息 | 摘要集中在一处,便于 s10 SystemPromptBuilder 统一管理位置和格式 |
| CompactionConfig 独立于 AgentConfig | 独立 data class | ContextCompactor 可独立测试和注入,不耦合 AgentConfig 的所有字段 |
| onCompact 返回 Unit | 纯通知,不可干预 | 压缩是系统级操作;与 onBeforeToolExecute(可阻止)和 onPostToolUse(可修改)不同 |
| 压缩直接修改 messagesHistory | 原地修改 | 避免创建新列表的开销;调用方只需重建 requestMessages |
| 紧急截断用 while 循环 | 逐批移除 | 每次通过 BoundaryDetector 找安全切割点,避免一次性破坏多个 tool_use/tool_result 对 |
文件清单
src/main/kotlin/com/sepcai/code/
├── context/ # [新建] 上下文压缩领域包
│ ├── CompactionConfig.kt # data class 配置参数 + companion 默认值
│ ├── TokenEstimator.kt # object,字符法 token 估算
│ ├── CompactEvent.kt # data class 压缩事件 + CompactLayer 枚举
│ ├── BoundaryDetector.kt # object,安全切割点检测
│ ├── SummaryBuffer.kt # class,滚动摘要状态管理
│ └── ContextCompactor.kt # class,四层流水线协调器
├── agent/
│ ├── AgentConfig.kt # [修改] +contextWindow 字段
│ ├── AgentLoop.kt # [修改] +compactor 参数 +主动检查 +MAX_TOKENS 重试
│ └── AgentLoopHooks.kt # [修改] +onCompact 字段 +import
└── repl/
└── ReplLoop.kt # [修改] 构建 ContextCompactor + 注入 AgentLoop + 欢迎语
src/test/kotlin/com/sepcai/code/
└── context/ # [新建]
├── TokenEstimatorTest.kt # 7 用例
├── BoundaryDetectorTest.kt # 9 用例
├── SummaryBufferTest.kt # 6 用例
└── ContextCompactorTest.kt # 8 用例(含 FakeSummaryProvider)6 个新源文件 + 4 个修改源文件 · 4 个新测试文件 · 全部通过。
下一站:s09 Memory
s08 解决了"对话内"的上下文膨胀问题——压缩历史防止单次会话的 token 超限。但压缩后的摘要只在当前会话有效,关闭 REPL 就消失了。
s09 Memory 系统将引入跨会话记忆管线:
- MemoryExtractor——从
SummaryBuffer.currentSummary中提取关键事实为结构化记忆条目 - MemoryStore——磁盘持久化,基于 frontmatter 的文件存储(与 skill 系统一致的格式)
- MemoryInjector——新会话启动时自动读取记忆文件,注入为 SYSTEM 消息
- Scope 机制——
project级记忆(当前 git 仓库)vsuser级记忆(全局用户偏好)
s08 的 SummaryBuffer.currentSummary 是 Memory 系统的"原材料"——摘要文本已经浓缩了对话的关键决策和发现,MemoryExtractor 只需做最后一公里的结构化提取,不再需要重新扫描原始消息历史。


