猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 08: Context Compact — 四层上下文压缩

2026年7月30日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 8 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 07: Skill Loading — 按需注入式技能系统

下一篇

Index 09: Memory — 跨会话记忆管线

目标

s01 到 s07 让智能体具备了完整的工具调用、权限管控、Hook 扩展、TodoWrite 规划、子智能体并发和技能按需注入能力。但在长时间对话中,messagesHistory 只增不减——每次工具调用追加两条消息(ASSISTANT 含 tool_use + USER 含 tool_result),一轮复杂操作就可能产生 10+ 条消息。最终 token 数超过 LLM API 的 context window 上限,请求被拒绝,对话被迫中断。

Index 08 引入四层渐进式上下文压缩流水线,在上下文接近阈值时自动压缩历史消息,保证 AgentLoop 可持续运行:

  1. Token 估算(Layer 1)—— 基于字符数的轻量 token 计数,零外部依赖
  2. 边界检测(Layer 2)—— 找到安全切割点,保护 tool_use/tool_result 配对不被拆散
  3. LLM 滚动摘要(Layer 3)—— 将旧消息压缩为摘要文本,SummaryBuffer 管理跨多次压缩的累积状态
  4. 紧急截断(Layer 4)—— 摘要后仍超限时的 FIFO 兜底

完成后的效果——智能体可以运行无限长的对话而不丢失关键上下文:

TEXT
[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 只增不减。每次工具调用会追加两条消息:

TEXT
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 可接受性
  • 滚动摘要——多次压缩的摘要逐步合并,不丢失早期的关键决策信息
  • 可观测——通过 onCompact Hook 回调通知外部每次压缩事件(日志、统计、监控)

核心设计与实现

架构全景

TEXT
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:

KOTLIN
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:

KOTLIN
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
    }
}

设计要点:

  1. object 而非 class——纯函数,无状态,直接用单例对象
  2. Json { encodeDefaults = true }——与 AnthropicProvider 的序列化配置一致,确保估算的 JSON 长度接近实际发送值
  3. 整数除法——不追求浮点精度,保守下取整(5 / 3 = 1),偏向低估而非高估。配合 80% 阈值比例,已留足安全边际
  4. 只计入 toolCalls 的 ASSISTANT 和 toolResults 的 USER——其他消息类型不包含这些字段,不计入

第二层:BoundaryDetector — 安全切割点检测

为什么需要?

LLM API 要求 tool_use 和 tool_result 必须成对出现。如果压缩时把 ASSISTANT(tool_use) 和紧随其后的 USER(tool_result) 拆散到切割线的两侧,API 会因"孤立的 tool_result 缺少对应 tool_use"而拒绝请求。

消息历史的结构是:

TEXT
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 消息上,就向前回退,直到找到安全位置:

KOTLIN
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
    }
}

设计要点:

  1. 只检查切割点本身——不扫描全部历史。因为 tool_use 和 tool_result 在正常历史中总是相邻的:ASSISTANT(tool_use) 后面紧跟着 USER(tool_result)。如果切割点落在 USER(tool_result) 上,回退一位就切在它前面的 ASSISTANT(tool_use) 上——这对被完整保留,切割安全
  2. 循环回退而非一次性回退——存在理论上的连续 tool_result 情况(虽然正常流程不会产生),while 循环兜底
  3. keepRecentCount >= messages.size 返回 0——不需要切割时直接返回 0,调用方通过 safeCut > 0 判断是否需要执行压缩
  4. object 单例——纯算法,无状态,与 TokenEstimator 一致

第三层:SummaryBuffer — 滚动摘要状态管理

为什么需要"滚动"摘要?

压缩不是一次性事件。长对话可能触发多次压缩——第二次压缩时,它拿到的新一批旧消息只是"中间段"的对话,缺少早期对话的上下文。

滚动摘要解决这个问题:第二次压缩时,把第一次的摘要和新增的旧消息一起发给 LLM,让 LLM 合并生成一个更完整的摘要。第三次压缩时,又把第二次的摘要和更新一批的旧消息合并……依此类推。

设计

KOTLIN
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."
    }
}

设计要点:

  1. class 而非 object——ContextCompactor 持有一个 SummaryBuffer 实例。未来如果有多个 AgentLoop,每个应独立维护自己的摘要状态
  2. 摘要 prompt 设计——固定指令 + [Conversation Summary] 前缀标记,明确告诉 LLM(以及未来的自己)这是一条摘要而非普通 SYSTEM 消息
  3. 工具结果截断到 100 字符——长文件内容、大段命令输出占 token 且摘要不需要完整内容,只需知道"读取了什么文件"即可
  4. 滚动更新语义是"replace"而非"append"——LLM 生成的新摘要已经包含了旧摘要的内容,不需要保留两份文本

摘要 prompt 示例

首次压缩(无旧摘要):

TEXT
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.

第二次压缩(有旧摘要,滚动合并):

TEXT
...
## 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)

算法

KOTLIN
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
    )
}

设计要点:

  1. 渐进式逐批移除——每次通过 BoundaryDetector 找一次安全切割点,移除一批,重新估算。避免一次性移除太多
  2. coerceAtLeast(1)——保证每次至少移除一条消息,防止死循环
  3. 无法找安全切割点时 break——极端情况下(如所有消息都是 tool_use/tool_result 对且无法安全切割),宁可保留超限历史也不能破坏消息结构。此时 LLM API 会拒绝请求,但至少不会产生更难追踪的"孤立的 tool_result"错误
  4. 这是真正会丢弃信息的操作——与摘要不同,截断的消息永久丢失。CompactLayer.TRUNCATE 和 CompactLayer.SUMMARIZE 分离,让 onCompact Hook 的消费者能区分这两种情况

配置参数:CompactionConfig

KOTLIN
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
    }
}
参数默认值含义
compactTriggerRatio0.8达到 context window 80% 时主动触发摘要压缩
emergencyTriggerRatio0.95达到 95% 时触发紧急截断
keepRecentRatio0.5最近 50% 消息不参与压缩(保留最新上下文)
summaryMaxTokens2048单次摘要 LLM 调用的最大输出 token 数
minHistoryToCompact4历史消息数少于此值时不触发压缩(太少没意义)
contextWindow200000模型 context window 大小(token 数),与 AgentConfig.contextWindow 对齐

为什么 80% 触发摘要? 不是 90% 或 95%?有两个原因:第一,charCount/3 估算有 ~20% 误差,80% 阈值留足了误差余量;第二,摘要 LLM 调用本身也消耗 token(摘要 prompt 也计入 context),需要提前触发为摘要调用留空间。

为什么摘要后再截断的阈值是 95%? 摘要已经做了一次温和压缩——如果摘要后还在 95% 以上,说明"最近消息"本身就已经非常大(例如当前轮次的操作涉及大量工具调用和输出),此时温和手段无效,只能激活截断。


AgentLoop 集成

两层保障

压缩嵌入 AgentLoop 的每次迭代,形成两层保障:

  1. 主动检查——每次 LLM 请求前:shouldCompact() 判断 token 是否超过 80% 阈值
  2. 被动兜底——API 返回 MAX_TOKENS 时:压缩 + continue 重试
KOTLIN
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
            }
        }
    }
    // ...
}

关键细节:

  1. 主动压缩后重建请求消息——compact() 直接修改了 messagesHistory,必须重新调用 buildRequestMessages() + onPreLlmRequest hook 来重建 requestMessages。如果跳过了这一步,LLM 收到的还是压缩前的旧消息列表
  2. MAX_TOKENS 时用 continue 而非直接 return——压缩后历史变短,应该重试本轮而不是中止。只有压缩实在无法执行(历史太短)时才 fallback 返回截断内容
  3. 压缩前后都触发 onCompact Hook——无论是主动压缩还是 MAX_TOKENS 压缩,都通过 Hook 通知外部

新增构造参数

KOTLIN
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

KOTLIN
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 构造器:

KOTLIN
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
    )
    // ...
}

设计要点:

  1. contextWindow 从 AgentConfig 传入 CompactionConfig——不硬编码,允许 CLI 参数覆盖(如 --context-window 100000)
  2. 复用主 LLMProvider——摘要调用和正常对话走同一个 LLM 实例。未来可替换为更便宜的模型用于摘要(改 ReplLoop 构造即可,不影响 ContextCompactor 接口)
  3. onCompact 日志用 logger.info——压缩是"关键事件"(改变了消息历史结构),应该用 info 级别而非 debug
  4. 欢迎语更新——🐱 Cat-Code — Index 07: Skill Loading · s08: Context Compact

事件模型

CompactEvent

KOTLIN
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 characterstoolCalls 计入序列化的 JSON 长度
estimateOne counts tool result characterstoolResults 计入 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 summaryprompt 包含旧摘要
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 shortminHistoryToCompact 约束
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 而非 appendLLM 合并后的新摘要已包含旧摘要,保留两份浪费 token
摘要用独立 SYSTEM 消息一条 SYSTEM 消息替代多条旧消息摘要集中在一处,便于 s10 SystemPromptBuilder 统一管理位置和格式
CompactionConfig 独立于 AgentConfig独立 data classContextCompactor 可独立测试和注入,不耦合 AgentConfig 的所有字段
onCompact 返回 Unit纯通知,不可干预压缩是系统级操作;与 onBeforeToolExecute(可阻止)和 onPostToolUse(可修改)不同
压缩直接修改 messagesHistory原地修改避免创建新列表的开销;调用方只需重建 requestMessages
紧急截断用 while 循环逐批移除每次通过 BoundaryDetector 找安全切割点,避免一次性破坏多个 tool_use/tool_result 对

文件清单

TEXT
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 仓库)vs user 级记忆(全局用户偏好)

s08 的 SummaryBuffer.currentSummary 是 Memory 系统的"原材料"——摘要文本已经浓缩了对话的关键决策和发现,MemoryExtractor 只需做最后一公里的结构化提取,不再需要重新扫描原始消息历史。

目录

当前章节:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 设计原则
  • 5. 核心设计与实现
  • 6. 架构全景
  • 7. 第一层:TokenEstimator — 轻量 token 计数
  • 8. 为什么不用 tiktoken?
  • 9. 算法
  • 10. 第二层:BoundaryDetector — 安全切割点检测
  • 11. 为什么需要?
  • 12. 算法
  • 13. 第三层:SummaryBuffer — 滚动摘要状态管理
  • 14. 为什么需要"滚动"摘要?
  • 15. 设计
  • 16. 摘要 prompt 示例
  • 17. Messages to Summarize
  • 18. Previous Summary
  • 19. Messages to Summarize
  • 20. 第四层:紧急截断 — FIFO 兜底
  • 21. 什么时候触发?
  • 22. 算法
  • 23. 配置参数:CompactionConfig
  • 24. AgentLoop 集成
  • 25. 两层保障
  • 26. 新增构造参数
  • 27. onCompact Hook
  • 28. ReplLoop 装配
  • 29. 事件模型
  • 30. CompactEvent
  • 31. 测试策略
  • 32. TokenEstimatorTest(7 用例)
  • 33. BoundaryDetectorTest(9 用例)
  • 34. SummaryBufferTest(6 用例)
  • 35. ContextCompactorTest(6 用例,FakeSummaryProvider)
  • 36. AgentLoopTest(现有测试 + 压缩集成验证)
  • 37. 关键设计取舍汇总
  • 38. 文件清单
  • 39. 下一站:s09 Memory
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。