02. Compact

上下文会「失忆」:Compact 是一场预算保卫战

上一篇拆了 Agent Loop,那个一圈圈自转的循环,最后留了个钩子:它转起来是要吃「上下文」的,读进来的文件、来回的对话、工具结果,全都往一个有限的窗口里塞。转得够久,窗口迟早会满,满了系统就自动「压缩」,听着很贴心,可对写作来说,如何确保压缩后还能保持最开始的核心立意?

👆你在这里
👆你在这里

🪧 「白捡」的福利,最好看清楚再用

Agent SDK 提供原生的自动压缩上下文能力,这本来是好事

你想啊,上下文窗口是有上限的(可以理解成模型的「瞬时记忆」就那么大一块,装满就装不下了)。一篇长文写到后半程,前面积累的多轮对话、读过的稿子、来回的修改,早晚会把这块记忆撑满。SDK 说:这事我自动帮你办了,满了就压缩,你不用管。我第一反应自然是「那敢情好,又少操一份心」

但花点时间搞清楚它「压缩」到底在做什么,还是很有必要的,毕竟黑盒背后常常藏着坑

它压缩的办法,简单粗暴地理解就是把早期消息用一段摘要替换掉,腾地方装新的。这个「摘要」背后,Claude CLI 的 Harness 其实做了不少精细控制,具体怎么分层,下一节会细讲。但问题是,这套机制是针对写代码场景打造的,照搬到写作场景,虽也能用,却很可能丢失那些没有明说、却要全程作数的东西:个人偏好的写作文风、你明确交代过的忌讳、专栏写作大纲和进展。这些要是被摘成一句干巴巴的「讨论了文章方向」,等于没了

所以「自动压缩」这份福利,对写作产品是把双刃剑:确实扛住了「上下文撑爆」这个硬故障,但也可能激进地把最不想丢的东西摘走。这一篇要解决的,就是怎么既享受这份福利,又不让它误伤核心约束

🧭 基础概念:压缩不是「变笨」,是「用摘要换预算」

在动手定制之前,要先把「压缩」这个概念看清楚,它特别容易被误解成「让模型变笨」

其实也不是。压缩(业内叫 Compact)是纯粹的预算动作,这里的「预算」指上下文窗口那点有限容量。对话越堆越长、快撞上限时,系统必须腾地方,否则新内容塞不进来。腾地方的办法,是把最早那批消息拿出来,让模型自己写一段摘要,用这段短摘要替换掉那一大坨原文

所以它的本质是一笔交易用「细节的精确」,换「窗口的空间」。 换来的空间有实打实的好处(否则长任务根本没法继续),代价是那些被摘掉的细节,模型从此只能看到摘要版,看不到原文了

「压缩 = 拿细节换空间」把本质拿捏住了,可还有几个细节可以再抠:

  1. 这笔交易,具体是怎么发生的?是一刀切全摘,还是有策略有讲究?(Claude Code 那套分层机制是怎么做的)
  2. 怎么知道它发生了?(SDK 给没给信号)
  3. 怎么保护住那些不能丢的业务细节,让它们不进入这笔交易?(这是 SmartWriter 要做的活)

接下来,一层层扒开

🔩 Claude Code 怎么做:压缩不是一刀切,是四层递进

压缩并不是「满了,摘掉最早的大部分消息」这么粗暴。Learn Claude Code 的源码解读系列第八篇有详细解读,Claude Code(下文简称 CC)底层的上下文管理很精细,是四层递进机制,触发顺序为 L3 → L1 → L2 → L4(参考下表)

它的思路很聪明:能不用摘要就不用,先用「无损」手段省空间,实在不行了才上「有损」摘要。 像收拾一个塞满的抽屉:先把大件挪到储物间(还能取回来),再把零碎归归类,最后实在装不下,才忍痛把一些东西压成清单扔掉原物。四层是这么分工的:

它干的事 有损吗 一句话理解
L3 tool_result_budget 把大的工具结果挪到磁盘,上下文里只留个引用 无损(要用再读回来) 大件挪进储物间,随时能取
L1 snip_compact 修剪掉「不相关」的旧对话 低损 把明显没用的旧话头删掉
L2 micro_compact 旧的工具结果用占位符替换 无损(摘要留着) 零碎归类、贴标签
L4 autocompact 让模型给整段历史写一份完整摘要 有损(LLM 重写) 忍痛压成清单,原物扔掉

只有最后那层 L4,是真正「有损」的。 前三层要么把东西挪到别处(还能取回),要么只清理明显没用的,真正会把核心内容摘没、让原文彻底消失的,只有 L4 这一下狠的

🛠 SDK 提供什么:一个开箱的自动挡,和一个唯一的信号

好消息是,这套精心设计的四层递进机制,我们一行都不用自己写

SmartWriter 底层是通过 Claude Agent SDK 调起 Claude Code 的命令行程序在跑,这套完整的上下文管理就长在 CLI 里,SDK 天然提供了三样东西:

  1. Auto Compaction 是开箱即用的自动挡。 上下文快满时,压缩自动发生,应用本身不需要监控窗口用量、不需要手动触发。主打一个省心。
  2. 但对外暴露的信号,也只有一个。 前面那四层里,L1、L2、L3 全是静默执行,做完了它不支声。SDK 唯一暴露出来的,是 L4 完成后发的一条消息,叫 compact_boundary(压缩边界信号)。也就是说,应用侧能感知到的,只有最后那层「有损」的压缩。 这个设计倒也合理:无损的三层本来也不用我们管,SDK 闷头自己做好;只有真正丢东西的 L4,才主动通知说「咳咳,我摘了一次」
  3. 手动触发和归档的口子也留了。 如果想主动整理上下文,也不需要什么特殊 API,直接把 /compact 当成一句普通话发给它就行(它认识这个内置命令)。另外还有个 PreCompact hook(钩子),利用它可以在压缩真正动手前插一脚,比如把当前草稿版本先归档到磁盘留个底,对写作来说,留存一份完整的草稿演化史本身也挺有价值

所以 SDK 把这件事简化成了:过程它来管,应用侧盯住 compact_boundary 这一个信号就好。 而 SmartWriter 对于压缩行为的定制,也正是围绕这个信号来做文章的

🧩 SmartWriter 怎么做:只在「有损」那一刻出手补偿

拿到 compact_boundary 这个唯一信号,整套压缩应对策略可以总结为:**平时不管,只在 L4 有损压缩发生的那一刻,把被摘掉的核心写作约束重新塞回去。**具体拆成两招:

一:压缩后立刻「补一份」

这里先交代个背景。SmartWriter 写每一类文章(随笔、产品技术文、书评……我叫它「题材」),都会把「这类文章该怎么写好」的一套方法论,在你开口的第一句话前面静默拼上去(也叫 prepend,「贴在最前面」)。这样模型从第一个字起,就带着这类文章的专业手感在写。方法论怎么来的、为什么要这么注入,是第 6 篇《Skills》的主题,这里先剧透:它是贴在「你的第一句话」前面的一块内容

然后问题就来了。L4 压缩,是把整段早期历史(包括你的第一句话)摘成摘要,贴在第一句话前面的这块题材方法论,自然也会被一起摘进去,细节丢失

针对它的解法也很直接,就是盯住那个唯一信号,及时打上「补丁」:

# 只补偿 L4:盯住 compact_boundary,下一条 prompt 把方法论补回去
def on_message(msg):
    if is_compact_boundary(msg):          # 收到那唯一的「有损压缩」信号
        handle.needs_genre_reinject = True  # 立个标记:该补了

def send_prompt(text):
    if handle.client is None or handle.needs_genre_reinject:
        text = GENRE_METHODOLOGY_BLOCK + text   # 把题材方法论重新 prepend 回去
        handle.needs_genre_reinject = False     # 补过了,清掉标记
    ...

逻辑就三步:监听到 compact_boundary → 立一个「待补偿」的标记 → 用户下一次开口时,把题材方法论重新拼回最前面,然后清掉标记。 画成流程是这样:

Re-Inject Loop
Re-Inject Loop

二:真正常驻的约束,靠 CLI 自动重注续命

补偿是「亡羊补牢」,能不能干脆让羊别丢?对那些全程都必须作数的约束(比如你的写作画像、专栏的核心规范),还有更彻底的办法:让它自带一套「哪怕被摘了也能立刻满血复活」的机制,都不用盯着信号手动补

这就要请出一个贯穿后面好几篇的主角,CLAUDE.md(先理解成一个项目级的「常驻须知」文件)。它的内容,跟题材方法论一样,也是被拼进对话历史里的,不是躲在系统提示词那个「免疫区」,所以 L4 压缩照样会把它连着历史一起摘掉,这点跟题材方法论没有本质区别。但它有一个特性:CLI 会在你每一次开口、发出每一条新消息之前,都主动把它重新塞一遍进去,不只是塞在第一句话前面那一次。 这意味着,哪怕上一秒 L4 把历史摘得七零八落、连同里面那份 CLAUDE.md 一起摘没了,下一秒你一开口,新的一份 CLAUDE.md 就自动跟着新消息回来了,不需要检测什么信号,也不需要手动补偿。它不是因为「不会被摘」才抗压缩,是因为「摘了也马上有新的」

两种方式有清晰的分工:只贴一次、说过就过的(比如题材方法论那种只 prepend 在第一句话前面的内容),得自己盯着 compact_boundary 手动补;每条消息都被自动重新塞一遍的(画像、规范、核心立意,走 CLAUDE.md 通道),不用单独去管,它自己会活。甚至可以在 CLAUDE.md 里用自然语言写一段「摘要保留指令」,等于给会议秘书递了张小纸条:压缩时这几样东西务必留着,譬如本文的写作目标、已定的大纲、用户明说的风格偏好和忌讳、关键事实和引用来源、已经采纳或否决的方案 等等。说白了就是一段写给模型的嘱托,没什么特殊语法,靠的是模型对 CLAUDE.md 这份「常驻须知」的重视程度。别指望压缩器会替你记住「临时塞进对话的东西」,它只对「有正式身份的常驻文件和钩子」负责

📌 一个容易被想当然的点:我曾一度以为 CLAUDE.md 抗压缩,是因为它被放进了模型「系统提示词」那个特殊位置。这样理解不对。 它其实是被塞进「你的第一句话」(以及此后你说的每一句话)里(外面包一层特殊标签),跟系统提示词根本没关系,抗压缩靠的就是上面那条「逢新消息必塞一遍」的规矩,跟压不压缩无关。搞混这个原因,后面关于「什么该放哪」的判断会全乱套,所以特意点一下。至于 Claude Code 官方为什么把它放在这里而不是系统提示词里,这背后还有一层关于缓存效率的设计考量,到第 5 篇讲 System Prompt 时再细说

⚖️ 压缩非小事,写作 Agent 必须「守住立意」

前面絮絮叨叨讲了这么多,核心其实就一件事:自动压缩是好东西,但不能当甩手掌柜,业务上真正要命的信息,还是得自己想办法保住

⚖️ 取舍现场

"偷懒"的默认做法 SmartWriter 的选择 为什么这么选
对压缩的态度 基本无感,用默认自动压缩,摘了就摘了 盯死compact_boundary,L4 后立即补偿 写作最怕「三段前定的立意」被摘没,变卦是致命体验
常驻约束放哪 图省事写进第一条 prompt 一律挪进CLAUDE.md,走每请求重注通道 放对话里会被压缩,放 CLAUDE.md 才能全程免疫
补偿范围 要么不补,要么全量重放(费 token) 只补 L4、只补真正常驻的那几样 精准打击,不为省事牺牲成本,也不为省成本漏掉核心

说到底,这也是写作场景和普通聊天场景的根本区别:聊天工具哪怕把你上午说的话忘了,你顶多再重复一句,成本几乎为零;但一个写作助手要是把这篇文章的魂弄丢了,写出来的字再顺,也已经不是你要的那篇了

但也要注意:不是所有的过程内容都值得补偿,要分清「常驻约束」和「起始素材」。 立意、调性、忌讳、题材方法论,属于「常驻约束」,要求长期在场,被压缩摘掉就是致命的,必须补。而最初丢进来的写作底稿、参考附件,性质不一样,它们是「起始素材」,是这篇文章的原料和起点,一旦被模型消化、揉进正在成形的作品里,历史使命就基本完成,不需要永远占着上下文常驻。所以它们被压缩摘掉也关系不大,是有意选择不补

可以拿 CC Harness 处理「旧的工具调用」的方式类比一下:压缩之后,一次早先的工具调用,上下文里往往只留个「它曾经发生过」的轻量痕迹,笨重的过程详情被挪走了,真需要时再去磁盘上把原物读回来。底稿和附件走的是同一个思路,留痕、卸重、按需回取:它们的原始文件一直静静躺在磁盘上(压缩动的是内存里的对话,碰不到磁盘文件),模型 Read 一下就能拿回来,更何况它们的精华早已化进正在打磨的那份作品里

至此,引擎这个板块(心跳 + 压缩)就拆完了,我们现在有了一台会自转、又不会被上下文撑爆的引擎。但你有没有发现,我们聊的都还是「这一次写作过程内部」的事,把窗口一关、明天再打开呢?这次写到一半的进度、这个 session 的历史,怎么跨越「关机」活下来?还有那个反复提到、说能「抗压缩」的 CLAUDE.md,它到底怎么跨时间把你这个人记住的?

这就进入下一个板块,记忆与会话。咱们继续聊