标签归档:Claude Code

OpenClaw 的 Skills 的实现和 Claude Code 不一样

OpenClaw 的 Skills,本质上不是一个「可调用工具」,它更像一套经过约束的运行手册:启动时把技能目录扫描出来,压成一份 <available_skills> 清单塞进 system prompt,模型自己判断要不要选一个 skill,然后再通过 Read 工具去读这个 skill 的 SKILL.md。读完以后,没有任何独立执行器接管,还是在当前这条 session 的 tool-loop 里继续跑。

Claude Code 走的是另一条路。它把 skill 做成了 tool,工具里负责校验、加载、执行,甚至可以放进一个新上下文里跑完,再把结果回传主对话。

这两个实现方向,表面上都叫 skills,工程含义完全不一样。

如果你是做 Agent 平台、企业内 Copilot、代码助手、任务执行器,这个差异不只是架构图上的审美问题。它会直接影响:

  • prompt 预算怎么花
  • 权限边界放在哪里
  • skill 的治理成本有多高
  • 运行链路是可控还是失控
  • 你后面想不想做隔离执行、审计、回放、灰度发布

1. OpenClaw 的 Skills,到底是怎么被「召回」的

很多人一看到 skills,第一反应是:是不是和 memory 一样,先走 embedding 检索,再把相关技能召回进上下文。

不是。

OpenClaw 的 skills 召回机制非常直接,甚至可以说有点「朴素」:扫描目录,生成目录清单,注入提示词,让模型自己选。整条链路分三段:

  1. 发现
  2. 注入
  3. 读取

OpenClaw 反过来做了一件更工程化的事:先把 skill 列表显式给模型,再用 system prompt 约束它怎么选。

这个机制的起点在 src/agents/skills/workspace.tsloadSkillEntries()

1.1 技能发现:先扫目录,再谈调用

OpenClaw 会从多个 root 目录扫描 SKILL.md,然后合并成最终技能集。这里是有明确的覆盖优先级的:

extra < bundled < managed < agents-personal < agents-project < workspace

它的逻辑是:越靠近当前工作空间、越贴近用户项目的 skill,优先级越高。平台预置的 bundled skill 可以兜底,但项目级 skill 要能覆盖它。否则你做企业落地时会很难受,团队定制流程永远被平台内置逻辑压着打。

从目录结构看,OpenClaw 支持两种 skill 形态:

  • root 本身就是一个 skill,目录下直接有 SKILL.md
  • root 下的子目录分别是 skill,每个子目录里有自己的 SKILL.md

这样,团队在实际维护 skill 时,有两种典型组织方式:

  • 单一 skill 仓库,根目录就是技能内容
  • skills 集合仓库,每个子目录一个技能

都支持,落地阻力会小很多。

它对 SKILL.md 做了体积限制,默认超过 256KB 就直接跳过。

SKILL.md 本来就应该是高密度操作指南,不应该演变成一个什么都往里塞的大文档。你一旦允许 skill 文件无限变大,后面一定会有人把 SOP、FAQ、设计文档、事故复盘全扔进去,最后技能选择和加载成本一起爆炸。OpenClaw 在扫描阶段直接卡体积,其实是在替平台维护纪律。

1.2 frontmatter 解析

给 skill 增加最小限度的结构化元信息

扫描到 SKILL.md 后,OpenClaw 会读原文并解析 frontmatter。坏掉或者缺失的 frontmatter 会被忽略。

这个决策也很对。

OpenClaw 这里更像是「有就用,没有拉倒」。它承认 markdown 本体才是 skill 的核心,frontmatter 只是辅助控制面。这个姿态很适合 skills 这种增长很快、来源很多的资产类型。

但这里也埋了一个工程 trade-off:frontmatter 被弱约束,意味着后续治理和平台能力扩展会受限。你今天只做 discovery 和 basic gating,这么玩没问题;你明天如果要做 skill 分类、依赖分析、版本兼容、批量审计,元信息松散会让成本陡增。

OpenClaw 当前站在了「先把系统跑起来」的一边,没有走「先把规范做重」的路子。

这意味着它更适合中小规模 skill 生态,或者说更适合个人助手这类工具,而不是一个强治理的企业级 skill marketplace。

2. 不是所有 skill 都会进 <available_skills>

扫描出来只是候选集,不等于模型能看到。

OpenClaw 在注入 system prompt 之前会做一轮 gating。这个步骤比很多人想象得重要,因为它决定了模型到底暴露给了哪些能力。

过滤逻辑里有几类条件。

2.1 配置开关

最基础的 enable/disable

如果某个 skill 在配置里被标成 skills.entries.<skillKey>.enabled === false,它就会被剔除。

这是最基础的 kill switch。工程上没什么可争议的,必须有。不然你没法快速止血。某个 skill 写坏了、依赖环境挂了、被发现会引导模型做危险操作,没有一键下线能力,平台就不配上线。

2.2 bundled allowlist

对内置技能单独管控

skills.allowBundled 只约束 bundled 来源。

这个细节很有意思。它说明 OpenClaw 把 bundled skills 当成一类特殊资产处理:平台自带,但不默认无条件信任。

为什么这事重要?因为内置 skill 经常是平台演进中最容易「偷偷变多」的那部分。你今天打包 5 个,明天为了演示方便塞到 20 个,后天 prompt 里一大坨 descriptions,模型选 skill 的噪声越来越大。allowlist 的存在,本质上是在给平台预装能力上保险栓。

2.3 eligibility 判断

按运行环境判断 skill 是否可用

OpenClaw 会根据 metadata.requires 去判断 skill 能不能用,条件包括:

  • 二进制依赖是否存在
  • 环境变量是否满足
  • 配置是否具备
  • 操作系统是否匹配
  • remote 平台是否匹配

这个设计非常工程化。因为 skill 如果涉及真实操作,它一定和环境耦合。比如某个 skill 依赖特定 cli,或者要求某个 API token,或者只能在 Linux 跑。你如果不在注入前做 eligibility,而是让模型先看到 skill,再在执行时失败,用户体验会很差,模型行为也会变形:它会看到一个貌似可用的方案,实际一调就炸。

OpenClaw 选择「先过滤,再暴露」,这是我非常认同的策略。因为对模型来说,看得见就等于潜在可用。你让它看见无效 skill,本质上是在制造认知噪声。

2.4 disable-model-invocation

系统内部保留,模型不可见

如果某个 skill 标记了 disable-model-invocation,它不会进入 prompt 的 <available_skills>,但仍然存在于系统内部,可用于管理或校验。

因为技能资产不只有「给模型用」这一种角色。你可能有些 skill 需要:

  • 用于测试
  • 用于审核
  • 用于运维校验
  • 用于内部 pipeline
  • 用于未来发布但暂不开放

OpenClaw 没把「存在」和「可被模型调用」绑死,这样 skill registry 才像个平台能力,而不是一个 prompt 拼装器。

3. <available_skills> 才是 OpenClaw 的真正召回入口

很多人说 skills 被召回,其实这句话在 OpenClaw 语境里容易让人误解。

真正被放进上下文里的第一层,不是 SKILL.md 内容,而是 <available_skills> 这个目录清单。它由 buildWorkspaceSkillsPrompt() 生成,注入到 system prompt。

也就是说,模型最先看到的是技能目录,不是技能正文。

这个做法也算是渐进式披露的一种实现。先给模型足够决定是否要读 skill 的最小信息,避免一开始就把所有技能全文灌进去。

3.1 system prompt 里的强规则,决定了技能选择流程

OpenClaw 在 system prompt 里有一段明确的 Skills mandatory 规则,大意是:

  • 先扫描 <available_skills> 里的 <description>
  • 只有明确匹配一个 skill 时,才去读对应的 SKILL.md
  • 最多只读一个 skill

这三条里,最重要的是后两条。

3.1.1 只有明确匹配一个才读

这是在压制模型的泛化冲动。模型很喜欢「我觉得这个也相关,那个也相关」,然后多读几个,最后把自己卷进上下文泥潭。OpenClaw 用提示词硬性要求「明确匹配一个」才允许进入下一步,这本质上是在给模型加稀疏化约束。

效果未必 100% 可控,但方向是对的。

3.1.2 最多只读一个 skill

如果你做过基于 prompt 的 tool orchestration,就会知道一个常见死法:模型在一堆说明里来回跳,读完 A 觉得 B 也有用,再读 B,顺手 C 也看看,最后 token 花了不少,任务还没干。

OpenClaw 这里直接规定 upfront 只能读一个 skill。我个人判断,这不是因为理论上最优,而是因为工程上最稳。

它牺牲了一部分组合式技能能力,换来了几个非常实际的好处:

  • token 消耗更可控
  • 模型决策路径更短
  • skill 选择失败更容易定位
  • 调试和回放更容易做

代价也很明确:如果任务天然需要多个 skill 协同,OpenClaw 当前机制会显得笨。模型要么自己在单个 skill 指导下绕着做,要么根本选不准。

这也是它和 Claude Code 一个很大的分歧。Claude Code 把 skill 视作 tool,更容易天然支持复杂编排;OpenClaw 把 skill 视作可读说明,天然倾向于单次聚焦。

3.2 技能过多时的降级策略:compact 和 truncated

<available_skills> 不是无上限注入的。技能太多时,OpenClaw 会降级成 compact 格式,甚至截断,并提示 skills truncated

这个点看起来像 prompt 小技巧,其实是很重要的预算治理。

因为技能系统一旦跑起来,数量几乎只会越来越多。最初十几个 skill,description 全量塞进去还行;到几十上百个 skill,再这么搞,system prompt 很快会变成垃圾堆。OpenClaw 至少意识到了这个问题,所以做了两级退化:

  • compact:保留更少信息
  • truncated:截断并明确提示

这说明它把 prompt 当成有限资源,而不是无限容器。

但实话实说,这个策略只是在「延缓爆炸」,不是根治。skill 数量继续增长以后,靠 compact/truncate 顶不住。因为问题不只是 token 多了,而是模型在目录里做决策的辨识难度会越来越高。description 再压缩,skill 之间的区分度也会变差。

所以我对这块的判断是:OpenClaw 的 catalog 注入机制适合中等规模 skill 集,不适合无限扩张。
如果你团队未来真要做上百个 skill 的企业级平台,迟早要引入更分层的 catalog、分类路由、或者显式 selector,而不是靠一坨 <available_skills> 让模型裸选。

4. 真正的 SKILL.md 是怎么进入上下文的

OpenClaw 的第二层加载是按需读取。模型先看到目录清单,选中 skill 以后,才通过 Read 工具去读对应路径的 SKILL.md

4.1 技能正文不是 system prompt 的一部分

system prompt 里只有规则和技能目录。真正的 SKILL.md 内容,是在模型发起一次 read tool call 后,作为 toolResult 追加进当前消息流。

也就是说,skill 正文进入上下文的方式,和你用工具读一个普通文件没有本质区别。差别只是 system prompt 先规定了什么时候允许这样读。

这个设计的好处非常明确:

  • 大幅降低初始上下文体积
  • 让技能正文成为按需成本,而不是固定成本
  • skill 更新后无需改 system prompt 模板,只影响运行时读到的内容
  • 便于把 skill 当文件资产管理,而不是 prompt 模板片段

坏处也很明确:

  • skill 的执行效果更依赖模型有没有正确触发 read
  • skill 的权威性被弱化成一条 toolResult,而不是顶层指令
  • 如果 toolResult 很长,后续上下文里它和普通读文件结果没什么层级差异

从实现角度看,OpenClaw 这里是很克制的。它没有发明一个 Skill Runtime,没有做一套专门的 skill 调度协议,就是借已有的 read 工具完成正文加载。

这让系统保持简单,但也让 skill 更像「读到的一份说明书」,而不是「被激活的执行单元」。

5. OpenClaw 的安全边界

既然 skill 是文件,模型要去读 SKILL.md,安全问题马上就来了:路径能不能逃逸?symbolic link 能不能把 skill 指到 root 外?模型能不能顺着 location 读到不该读的内容?

OpenClaw 这里做了两层保护。

5.1 第一层:扫描阶段的 containment 检查

在技能发现阶段,它会对 realpath 做 containment 检查,防止 symlink 把 skill 指到 root 外面。

这是典型的「尽早失败」策略。我一直觉得文件系统相关的安全问题,能在发现阶段拦的,不要拖到执行阶段。因为一旦把异常路径放进 skill registry,后面每个环节都得假设它可能有问题,整个系统会变得很难推理。

扫描时就把越界项挡掉,registry 内部的数据至少是干净的。

5.2 第二层:Read 工具的 workspace root guard

模型真正发起读取时,Read 工具还会做 sandbox root 校验,禁止 .. 或绝对路径逃逸 workspace root。

也就是说,即使 discovery 阶段没出问题,真正读取文件时还有一层运行时防线。

文件边界这事,单点防护永远不够。扫描阶段挡的是「注册进来的 skill 本身」,读取阶段挡的是「模型实际提出的路径参数」。二者保护的对象不一样。

工程上要是只做第一层,你会被运行时路径拼接坑;只做第二层,你会把很多脏数据带进 registry,影响可观测性和调试。

OpenClaw 在这块虽然不算复杂,但做法是标准的。

6. Skills 的渐进式披露

OpenClaw 的 skills 机制,如果只看「枚举目录 -> 注入 prompt -> read 文件」,会有人觉得太朴素。真正值得注意的是,它和 memory 一样,贯彻了一套很明确的渐进式披露思路。

这是上下文预算治理的逻辑。

原则就两条:

  1. 先给最小可用信息
  2. 确认需要后再扩大读取范围

在 OpenClaw 里,memory 和 skills 都是这么干的,只是路径不同。

6.1 Memory 是先搜 snippet,再精读指定行段

memory_search 先返回短片段,带 path 和 line range,不是全文注入。底层还有 SNIPPET_MAX_CHARS = 700 的硬上限。如果后端预算紧,还会继续裁结果。

之后再通过 memory_getpath + from/lines 去拉具体行段。

这个流程是非常标准的 progressive disclosure:先定位,再精读。

6.2 Skills 是先给目录,再只读一个 SKILL.md

skills 这边没有向量召回,而是目录清单。模型先看 <available_skills>,只在明确匹配一个时才读正文,并且 upfront 最多一个。

两条链路表面不同,本质一样:都在防止大块文本无脑灌进上下文。

我为什么说这点值钱?因为很多团队做 Agent 系统时,最大的问题不是模型不会做事,而是上下文管理太粗糙。什么都想喂进去,最后 token 烧得飞快,模型还因为噪声太高做不准。

OpenClaw 至少在架构层面承认了一件事实:上下文是预算,不是仓库。

这件事说起来简单,真正落实到工具语义、提示词规则、底层裁剪,很多系统做不到。

7. OpenClaw 没有「独立 Skill 执行器」

在 OpenClaw 里,skill 读完之后,并没有一个单独的执行环境接管它。没有所谓「Skill Runtime」去解释 SKILL.md,也没有「新上下文执行 skill」这回事。它还是在同一个 activeSession.prompt(...) 的 tool-loop 里继续跑。

链路大概是这样:

  1. system prompt 里给出 <available_skills> 和选择规则
  2. 模型决定某个 skill 匹配当前请求
  3. 模型调用 read 工具读取对应 SKILL.md
  4. toolResult 里带回 SKILL.md 文本
  5. 这个 toolResult 被追加到当前 session 的 messages
  6. 模型再次被调用,看到刚才读到的 skill 内容
  7. 模型按 skill 指导,继续发起后续 toolCall,比如 execwriteedit
  8. 直到输出最终回答

关键点:skill 内容只是同一消息流里多了一条 toolResult

这意味着什么?

意味着 OpenClaw 的 skill 执行,本质上是「模型读了一份流程说明,然后继续在原对话里行动」。它没有新的边界,没有新的记忆隔离,没有新的权限域。真正的隔离,来自工具列表裁剪和文件读写沙箱,不来自上下文切换。

这和 Claude Code 很不一样。

8. Claude Code 的 skills,更像「可调用子程序」

Claude Code 的 skill 流程有几个特征:

  • skill 是 tools 列表里的一个明确工具
  • 模型会调用 Skill tool,而不是自己去 read 某个 markdown
  • skill prompt 的加载是 tool 内部动作
  • 权限校验也主要发生在 tool 内
  • skill 可以在新上下文执行,执行完再把结果带回主对话

从工程抽象上看,Claude Code 的 skill 更像一个「子程序入口」。它有名字、有调用接口、有内部装载逻辑,甚至有上下文隔离能力。

OpenClaw 没有走这条路。它把 skill 设计成「模型可读的文件」,由模型自己决定是否展开,并在原上下文里继续操作。

这两个方向,没有谁天然高级,但适用面不同

如果你要的是:

  • 更强的执行隔离
  • 更容易做权限封装
  • 更容易做结果回传和子任务边界
  • 更适合复杂、多步骤、可复用的任务单元

Claude Code 那种 tool 化 skill 更合适。

如果你要的是:

  • 实现简单
  • skill 作者门槛低
  • 把 skill 当 markdown 资产管理
  • 快速把流程知识接进现有 ReAct tool-loop

OpenClaw 这种文件化 skill 更实用。

工程上不看理念,看代价结构。

9. OpenClaw 为什么会做成这样

我猜它的设计动机是:尽量复用现有 agent runtime,而不是为 skills 单独发明一层执行框架。

你看它的做法就知道了:

  • discovery 复用文件系统扫描
  • selection 复用 system prompt
  • loading 复用 read 工具
  • execution 复用原有 tool-loop
  • safety 复用 sandbox path guard 和 tool policy

这是一套很节制的架构思路。好处是:

实现成本低 :不用造新协议,不用加新消息类型,不用多一层 runtime。对一个正在演进中的 agent 系统来说,这非常现实。 Skill 作者认知负担小:本质上写一个 SKILL.md 就行。对于组织内部推广,这是巨大优势。因为真正阻碍 skill 规模化的,从来不是模型能力,而是作者生态能不能起来。 运行链路可观测性还不错:所有事情都发生在同一 session 里。read 了什么、接着调了什么工具、toolResult 长什么样,回放起来相对直观。

但它的代价也有。

代价 1:skill 缺少强执行边界

skill 本身不是 tool,没有独立权限面。你没法像封装一个函数那样,给 skill 绑定专属 schema、专属校验、专属 side effect 边界。最终还是模型在拿到 skill 文本后,自由地调用后续工具。

这就意味着 skill 的约束力主要来自提示词,不来自执行器。

代价 2:组合式编排能力弱

system prompt 里要求 upfront 最多读一个 skill,这对预算控制很好,对复杂任务不友好。多 skill 协同在这个体系里不是一等公民。

代价 3:skill 的结果不可天然封装

Claude Code 那种「在新上下文执行 skill,再返回结果」,天然有个输入输出边界。OpenClaw 这里没有。skill 一旦展开,后续动作直接混进主会话消息流。你很难把它当作一个独立执行单元来治理。

代价 4:对模型的选择质量要求高

因为没有独立 selector,也没有 tool-level dispatcher,skill 能不能选对,主要靠 <available_skills> 的 descriptions 和 system prompt 规则。这对 skill 描述质量要求很高。写得不清楚,模型就选歪。数量一多,问题更明显。

10. 权限控制

OpenClaw 的思路是「先裁剪能力,再让模型行动」

这一点我很喜欢,因为它比「调用时再临时拦截」更稳。

OpenClaw 的权限控制,不是把所有工具都亮给模型,然后在调用某个危险工具时说不行。它更像是:

  1. 先根据 policy pipeline 把不允许的工具从列表里移掉
  2. 再把剩下的工具暴露给模型
  3. 模型根本看不到被禁的能力

skills 这边也一样:

  • 不合格的 skill 不进入 <available_skills>
  • disable-model-invocation 的 skill 对模型不可见
  • Read 路径有 workspace root guard

这种「先裁剪,再推理」的方式有个很大的好处:模型认知空间更干净。它不会围着一堆不可用能力打转,也不会生成大量被拒调用的无效动作。

如果你做企业场景,这比事后 deny 好得多。因为用户只会看到 agent 做合理尝试,而不是不停撞墙。

当然,它的代价是灵活性差一点。你没法在非常细粒度的时刻做动态放行,除非重新构建 tool list 或 skill list。但我认为这笔账是划算的。Agent 系统的第一优先级不是灵活,是可控。

11. 从平台设计角度看呢

觉得比较好的点:

第一,简单。
这个简单不是简陋,是尽量不新增系统概念。skill 就是文件,加载靠 read,执行靠原有 tool-loop。对演进中的 agent 框架来说,这是非常健康的选择。

第二,预算意识强。
<available_skills> 目录注入、最多只读一个 skill、compact/truncated 降级,这些都说明它把 context 当稀缺资源处理。

第三,权限思路对。
先过滤 skill 和 tool,再让模型行动。可见性先于可调用性,这很工程化。

第四,适合知识流程化。
很多团队真正需要的,不是一个会自己发明流程的 agent,而是把组织内已有 SOP 结构化地喂给模型。OpenClaw 这套很适合干这个。

一些局限:

第一,规模扩展性一般。
catalog 注入机制天然不适合 skill 数量无限增长。它适合几十级别,不适合大规模 marketplace。

第二,组合能力弱。
「最多只读一个 skill」对稳态有帮助,对复杂任务编排是限制。

第三,skill 缺少运行时身份。
它不是 tool,没有明确的输入输出边界,也没有独立权限与审计单元。后续做深治理会比较难。

第四,过度依赖模型选择。
一旦 descriptions 写得不好,或者目录过大,skill 选择质量会变成系统上限。

12. 适用场景

在系统架构选型时,如果有以下的条件,可以优先选择 OpenClaw 的设计逻辑:

  • 团队已经有一套成熟的 tool-loop,想低成本引入 skills
  • 主要目标是把流程知识、操作手册、领域步骤注入 agent
  • skill 作者很多,技术水平参差不齐,需要低门槛 markdown 入口
  • 更看重实现速度和治理可落地,而不是复杂编排能力
  • skill 数量在可控范围内,不会迅速膨胀到几百个

有如下的情况时,就不太适用使用这种方案:

  • skill 本质上是可执行业务子任务,需要独立生命周期
  • 需要强隔离执行、单独审计、结果回传和失败恢复
  • 任务天然需要多个 skill 协同编排
  • 技能库规模会非常大,必须做复杂路由
  • 权限模型要求细到「某个 skill 可以做 A 但不能做 B」

这些场景下,我更倾向 Claude Code 那种 tool 化 skill,或者更进一步,直接走 subagent / workflow node / task runtime。

13. 小结

如果只用一句话概括两者区别:

Claude Code 的 skill 更像可调用子程序,OpenClaw 的 skill 更像按需展开的运行手册。

前者强调执行单元,后者强调上下文注入。
前者天然适合隔离和编排,后者天然适合轻量接入和流程沉淀。
前者的复杂度在 runtime,后者的复杂度在 prompt 和内容治理。

OpenClaw 用极其精简的 Prompt 规则和现成的 Read 工具,低成本实现了 Agent Skills 标准。它够用,但也把长文本管理的压力,原封不动地推给了底层大模型的 Context Window。

我个人对 OpenClaw 这套实现是认可的,前提是别把它想象成一个万能技能平台。它解决的是「如何在不重写 agent runtime 的前提下,把结构化流程知识接进模型执行链路」这个问题。这个问题它解得挺干净。

但如果你要的是 Claude Code 那种「新上下文执行 skill、像子程序一样调用、跑完把结果带回来」,你该找的是子会话、subagent、任务编排层,而不是继续往 SKILL.md 上堆规则。

以上。

Agent 核心策略:Manus、Gemini CLI 和 Claude Code 的上下文压缩策略和细节

做 AI Agent 开发的都需要考虑上下文爆炸的问题,不仅仅是成本问题,还有性能问题。

许多团队选择了压缩策略。但过度激进的压缩不可避免地导致信息丢失。

这背后的根本矛盾在于:Agent 需要基于完整的历史状态来决策下一步行动,但我们无法预知当前的某个观察细节是否会在未来的某个关键时刻变得至关重要。

前面一篇文章讲了整个上下文的管理策略,今天着重聊一下上下文管理的压缩策略和细节。

下面根据 Manus、Gemini CLI 和 Claude Code 这三个项目的源码来聊一下上下文的压缩。

三个产品,有三个不同的选择,或者说设计哲学。

Manus 的选择是:永不丢失。 他们认为,从逻辑角度看,任何不可逆的压缩都带有风险。所以他们选择了一条看似”笨拙”但实际上很聪明的路:把文件系统当作”终极上下文”。

Claude Code 的选择是:极限压榨。 92% 的压缩触发阈值,这个数字相当激进。他们想要榨干上下文窗口的每一个 token,直到最后一刻才开始压缩。

Gemini CLI 的选择是:稳健保守。 70% 就开始压缩,宁可频繁一点,也要保证系统的稳定性。

这三种选择没有对错,只是适用场景不同。接下来我们逐个分析。

1. Manus

Manus 的压缩最让我印象深刻的是其在可恢复性上的策略。

Manus 团队认为:任何不可逆的压缩都带有风险。你永远不知道现在丢掉的信息是不是未来解决问题的关键。

他们选择了不做真正的删除,而是将其外部化存储。

传统做法是把所有观察结果都塞进上下文里。比如让 Agent 读一个网页,它会把整个 HTML 内容都存下来,可能就是上万个 token。Manus 不这么干。

当 Agent 访问网页时,Manus 只在上下文中保留 URL 和简短的描述,完整的网页内容并不保存。需要重新查看网页内容时,通过保留的 URL 重新获取即可。这就像是你在笔记本上记录”参见第 23 页的图表”,而不是把整个图表重新画一遍。

文档处理也是同样的逻辑。一个 100 页的 PDF 文档,Manus 不会把全部内容放进上下文,而是只记录文档路径、页数、最后访问的位置等元信息。当 Agent 需要查看具体内容时,再通过文件路径读取相应的页面。

Manus 把文件系统视为”终极上下文”——一个容量几乎无限、天然持久化、Agent 可以直接操作的外部记忆系统。

文件系统的层级结构天然适合组织信息。Agent 可以创建不同的目录来分类存储不同类型的信息:项目背景放一个文件夹,技术细节放另一个文件夹,错误日志单独存放。需要时按图索骥,而不是在一个巨大的上下文中大海捞针。

这不是简单的存储。Manus 训练模型学会主动使用文件系统来管理自己的「记忆」。当发现重要信息时,Agent 会主动将其写入特定的文件中,而不是试图把所有东西都记在上下文里。就像一个经验丰富的研究员,知道什么该记在脑子里,什么该写在笔记本上,什么该归档保存。

在 Manus 团队对外的文章开头,指出了为什么必须要有压缩策略:

第一,观察结果可能非常庞大。与网页或 PDF 等非结构化数据交互时,一次观察就可能产生数万个 token。如果不压缩,可能一两次操作就把上下文占满了。

第二,模型性能会下降。这是个很多人忽视的问题。即使模型声称支持 200k 的上下文窗口,但实际使用中,超过一定长度后,模型的注意力机制效率会显著下降,响应质量也会变差。这就像人的工作记忆一样,信息太多反而会降低处理效率。

第三,成本考虑。长输入意味着高成本,即使使用了前缀缓存等优化技术,成本依然可观。特别是在需要大量交互的场景下,成本会快速累积。

在具体实现过程中,可恢复压缩有几个关键点:

保留最小必要信息。对于每个外部资源,只保留能够重新获取它的最小信息集。网页保留 URL,文档保留路径,API 响应保留请求参数。这些信息占用的空间极小,但足以在需要时恢复完整内容。

智能的重新加载时机。不是每次提到某个资源就重新加载,而是根据上下文判断是否真的需要详细内容。如果只是确认文件存在,就不需要读取内容;如果要分析具体细节,才触发加载。

缓存机制。虽然内容不在上下文中,但 Manus 会在本地维护一个缓存。最近访问过的资源会暂时保留,避免频繁的重复加载。这个缓存是独立于上下文的,不占用宝贵的 token 额度。

2. Claude Code

Claude Code 的策略完全是另一个极端——他们要把上下文用到极致。

2.1 92% 的阈值

这个数字有一些讲究。留 8% 的缓冲区既保证了压缩过程有足够的时间完成,又避免了频繁触发压缩带来的性能开销。更重要的是,这个缓冲区给了系统一个「反悔」的机会——如果压缩质量不达标,还有空间执行降级策略。

const COMPRESSION_CONFIG = {
  threshold0.92,           // 92%阈值触发
  triggerVariable"h11",    // h11 = 0.92
  compressionModel"J7()",  // 专用压缩模型
  preserveStructuretrue    // 保持8段结构
};

2.2 八段式结构化摘要

Claude Code 的压缩不是简单的截断或摘要,而是八段式结构。这个结构我们可以学习一下:

const COMPRESSION_SECTIONS = [
  "1. Primary Request and Intent",    // 主要请求和意图
  "2. Key Technical Concepts",        // 关键技术概念
  "3. Files and Code Sections",       // 文件和代码段
  "4. Errors and fixes",              // 错误和修复
  "5. Problem Solving",               // 问题解决
  "6. All user messages",             // 所有用户消息
  "7. Pending Tasks",                 // 待处理任务
  "8. Current Work"                   // 当前工作
];

每一段都有明确的目的和优先级。「主要请求和意图」确保 Agent 永远不会忘记用户最初想要什么;「关键技术概念」保留重要的技术决策和约束条件;「错误和修复」避免重复踩坑;「所有用户消息」则保证用户的原始表达不会丢失。

这种结构的好处是,即使经过多次压缩,Agent 仍然能保持工作的连贯性。关键信息都在,只是细节被逐步抽象化了。

2.3 专用压缩模型 J7

Claude Code 使用了一个专门的压缩模型 J7 来处理上下文压缩。这不是主模型,而是一个专门优化过的模型,它的任务就是理解长对话并生成高质量的结构化摘要。

async function contextCompression(currentContext{
  // 检查压缩条件
  if (currentContext.tokenRatio < h11) {
    return currentContext;  // 无需压缩
  }
  
  // 调用专用压缩模型
  const compressionPrompt = await AU2.generatePrompt(currentContext);
  const compressedSummary = await J7(compressionPrompt);
  
  // 构建新的上下文
  const newContext = {
    summary: compressedSummary,
    recentMessages: currentContext.recent(5),  // 保留最近5条
    currentTask: currentContext.activeTask
  };
  
  return newContext;
}

AU2 负责生成压缩提示词,它会分析当前上下文,提取关键信息,然后构造一个结构化的提示词给 J7。J7 处理后返回符合八段式结构的压缩摘要。

2.4 上下文生命周期管理

Claude Code 把上下文当作有生命周期的实体来管理。这个设计理念很先进——上下文不是静态的数据,而是动态演化的有机体。

class ContextManager {
  constructor() {
    this.compressionThreshold = 0.92;  // h11 = 0.92
    this.compressionModel = "J7";      // 专用模型
  }
  
  async manageContext(currentContext, newInput) {
    // 1. 上下文更新
    const updatedContext = this.appendToContext(currentContext, newInput);
    
    // 2. 令牌使用量检查
    const tokenUsage = await this.calculateTokenUsage(updatedContext);
    
    // 3. 压缩触发判断
    if (tokenUsage.ratio >= this.compressionThreshold) {
      // 4. 八段式压缩执行
      const compressionPrompt = await AU2.generateCompressionPrompt(updatedContext);
      const compressedSummary = await this.compressionModel.generate(compressionPrompt);
      
      // 5. 新上下文构建
      return this.buildCompressedContext(compressedSummary, updatedContext);
    }
    
    return updatedContext;
  }
}

整个流程是自动化的。每次有新的输入,系统都会评估是否需要压缩。压缩不是一次性的动作,而是持续的过程。随着对话的进行,早期的详细内容会逐渐被抽象化,但关键信息始终保留。

2.5 优雅降级机制

当压缩失败时,系统不会死板地报错或者强行应用低质量的压缩结果,而是有一整套 Plan B、Plan C。这种”永不放弃”的设计理念,让系统在各种极端情况下都能稳定运行。

降级策略包括:

  • 自适应重压缩:如果首次压缩质量不佳,会调整参数重试
  • 混合模式保留:压缩旧内容,但完整保留最近的交互
  • 保守截断:最坏情况下,至少保证系统能继续运行

2.6 压缩后的信息恢复

虽然 Claude Code 的压缩是有损的,但它通过巧妙的设计最小化了信息损失的影响。压缩后的八段式摘要不是简单的文本,而是结构化的信息,包含了足够的上下文让 Agent 能够理解之前发生了什么,需要做什么。

特别值得一提的是第 6 段”All user messages”。即使其他内容被压缩了,用户的所有消息都会以某种形式保留。这确保了用户的意图和需求不会在压缩过程中丢失。

2.7 实践指南

Claude Code 在实践中还有一些最佳实践:

  • 定期使用 /compact 命令压缩长对话:用户可以主动触发压缩,不必等到自动触发
  • 在上下文警告出现时及时处理:系统会在接近阈值时发出警告,用户应该及时响应
  • 通过 Claude.md 文件保存重要信息:将关键信息外部化,减少上下文消耗

3. Gemini CLI

Gemini CLI 选择了一条中庸之道,或者说是实用之道。Gemini CLI 项目开源了,这部分的说明会多一些。

3.1 70/30?

Gemini CLI 选择了 70% 作为压缩触发点,30% 作为保留比例。这个比例我们也可以参考学习一下:

为什么是 70% 而不是 92%

  • 更早介入,避免紧急压缩导致的卡顿
  • 给压缩过程留出充足的缓冲空间
  • 适合轻量级应用场景,不追求极限性能

30% 保留的合理性:

  • 刚好覆盖最近 5-10 轮对话
  • 足够维持上下文连续性
  • 不会让用户感觉”突然失忆”

共背后的逻辑是:宁可频繁一点地压缩,也要保证每次压缩都是从容的、高质量的。

3.2 精选历史提取

Gemini CLI 有个独特的概念叫精选历史”。不是所有的历史都值得保留,系统会智能地筛选有效内容:

function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
  if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
    return [];
  }
  const curatedHistory: Content[] = [];
  const length = comprehensiveHistory.length;
  let i = 0;
  while (i < length) {
    // 用户轮次直接保留
    if (comprehensiveHistory[i].role === 'user') {
      curatedHistory.push(comprehensiveHistory[i]);
      i++;
    } else {
      // 处理模型轮次
      const modelOutput: Content[] = [];
      let isValid = true;
      // 收集连续的模型轮次
      while (i < length && comprehensiveHistory[i].role === 'model') {
        modelOutput.push(comprehensiveHistory[i]);
        // 检查内容有效性
        if (isValid && !isValidContent(comprehensiveHistory[i])) {
          isValid = false;
        }
        i++;
      }
      // 只有当所有模型轮次都有效时才保留
      if (isValid) {
        curatedHistory.push(...modelOutput);
      }
    }
  }
  return curatedHistory;
}

这个策略的巧妙之处在于:

  • 用户输入全部保留:所有用户输入都被视为重要信息,无条件保留
  • 模型轮次有条件保留:连续的模型轮次被视为一个整体进行评估
  • 全有或全无的处理:要么全部保留,要么全部丢弃,避免了复杂的部分保留逻辑

3.3 内容有效性判断

什么样的内容会被认为是无效的?Gemini CLI 有明确的标准:

function isValidContent(content: Content): boolean {
  // 检查 parts 数组是否存在且非空
  if (content.parts === undefined || content.parts.length === 0) {
    return false;
  }
  for (const part of content.parts) {
    // 检查 part 是否为空
    if (part === undefined || Object.keys(part).length === 0) {
      return false;
    }
    // 检查非思考类型的 part 是否有空文本
    if (!part.thought && part.text !== undefined && part.text === '') {
      return false;
    }
  }
  return true;
}

无效内容包括:空响应、错误输出、中断的流式响应等。这种预过滤机制确保进入压缩流程的都是高质量的内容。

3.4 五段式结构化摘要

相比 Claude Code 的八段式,Gemini CLI 的五段式更简洁,但涵盖了所有关键信息:

1. overall_goal - 用户的主要目标
2. key_knowledge - 重要技术知识和决策
3. file_system_state - 文件系统当前状态
4. recent_actions - 最近执行的重要操作
5. current_plan - 当前执行计划

压缩时,系统会生成 XML 格式的结构化摘要。这种格式的好处是结构清晰,LLM 容易理解和生成,同时也便于后续的解析和处理。

3.5 基于 Token 的智能压缩

Gemini CLI 的压缩不是简单的定时触发,而是基于精确的 token 计算:

async tryCompressChat(
  prompt_id: string,
  force: boolean = false,
): Promise<ChatCompressionInfo | null> {
  const curatedHistory = this.getChat().getHistory(true);

  // 空历史不压缩
  if (curatedHistory.length === 0) {
    return null;
  }

  const model = this.config.getModel();

  // 计算当前历史的 token 数量
  const { totalTokens: originalTokenCount } =
    await this.getContentGenerator().countTokens({
      model,
      contents: curatedHistory,
    });

  // 获取压缩阈值配置
  const contextPercentageThreshold =
    this.config.getChatCompression()?.contextPercentageThreshold;

  // 如果未强制压缩且 token 数量低于阈值,则不压缩
  if (!force) {
    const threshold =
      contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD; // 默认 0.7
    if (originalTokenCount < threshold * tokenLimit(model)) {
      return null;
    }
  }

  // 计算压缩点,保留最后 30% 的历史
  let compressBeforeIndex = findIndexAfterFraction(
    curatedHistory,
    1 - COMPRESSION_PRESERVE_THRESHOLD, // COMPRESSION_PRESERVE_THRESHOLD = 0.3
  );

  // 确保压缩点在用户轮次开始处
  while (
    compressBeforeIndex < curatedHistory.length &&
    (curatedHistory[compressBeforeIndex]?.role === 'model' ||
      isFunctionResponse(curatedHistory[compressBeforeIndex]))
  ) {
    compressBeforeIndex++;
  }

  // 分割历史为需要压缩和需要保留的部分
  const historyToCompress = curatedHistory.slice(0, compressBeforeIndex);
  const historyToKeep = curatedHistory.slice(compressBeforeIndex);

  // 使用 LLM 生成历史摘要
  this.getChat().setHistory(historyToCompress);
  const { text: summary } = await this.getChat().sendMessage(
    {
      message: {
        text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
      },
      config: {
        systemInstruction: { text: getCompressionPrompt() },
      },
    },
    prompt_id,
  );

  // 创建新的聊天历史,包含摘要和保留的部分
  this.chat = await this.startChat([
    {
      role: 'user',
      parts: [{ text: summary }],
    },
    {
      role: 'model',
      parts: [{ text: 'Got it. Thanks for the additional context!' }],
    },
    ...historyToKeep,
  ]);
}

这个实现有几个细节值得注意:

  • 支持强制压缩:通过 force 参数,用户可以主动触发压缩
  • 智能分割点选择:确保压缩点在用户轮次开始,避免打断对话逻辑
  • 两阶段压缩:先生成摘要,再重建对话历史

3.6 多层压缩机制

Gemini CLI 的压缩是分层进行的,每一层都有特定的目标:

第一层:内容过滤:过滤掉无效内容、thought 类型的部分,确保进入下一层的都是有价值的信息。

第二层:内容整合:合并相邻的同类内容,比如连续的纯文本 Part 会被合并成一个,减少结构冗余。

第三层:智能摘要:当 token 使用量超过阈值时,触发 LLM 生成结构化摘要。

第四层:保护机制:确保关键信息不被压缩丢失,比如用户的最新指令、正在进行的任务等。

3.7 模型适配的 Token 限制

不同的模型有不同的 token 限制,Gemini CLI 对此有精细的适配:

export function tokenLimit(model: Model): TokenCount {
  switch (model) {
    case 'gemini-1.5-pro':
      return 2_097_152;
    case 'gemini-1.5-flash':
    case 'gemini-2.5-pro':
    case 'gemini-2.5-flash':
    case 'gemini-2.0-flash':
      return 1_048_576;
    case 'gemini-2.0-flash-preview-image-generation':
      return 32_000;
    default:
      return DEFAULT_TOKEN_LIMIT; // 1_048_576
  }
}

系统会根据使用的模型自动调整压缩策略。对于支持超长上下文的模型(如 gemini-1.5-pro 的 200 万 token),可以更宽松;对于受限的模型,会更积极地压缩。

3.8 历史记录的精细处理

recordHistory 方法负责记录和处理历史,实施了多个优化策略:

  1. 避免重复:不会重复添加相同的用户输入
  2. 过滤思考过程:thought 类型的 Part 会被过滤掉,不进入最终历史
  3. 合并优化:相邻的模型轮次会被合并,相邻的纯文本也会合并
  4. 占位符策略:如果模型没有有效输出,会添加空的占位符保持结构完整

3.9 压缩的用户体验设计

Gemini CLI 特别注重压缩对用户体验的影响:

  • 无感压缩:70% 的阈值确保压缩发生在用户察觉之前
  • 连续性保持:保留 30% 的最新历史,确保当前话题的连贯性
  • 透明反馈:压缩前后的 token 数量变化会被记录和报告

4. 写在最后

研究完这三个项目的源码,我最大的感受是:压缩策略的选择,本质上是对「什么是重要的」这个问题的回答。 Manus 说「所有信息都可能重要,所以我不删除,只是暂时收起来」;Claude Code 说「结构化的摘要比原始细节更重要」;Gemini CLI 说「用户体验比技术指标更重要」。三种回答,三种哲学。

这让我想起一句话:在 AI 时代,真正稀缺的不是信息,而是注意力。 上下文压缩就是在教 AI 如何分配注意力——什么该记住,什么可以忘记,什么需要随时能找回来。

这是人类智慧的核心能力之一。我们每天都在做类似的决策:重要的事情记在心里,次要的写在本子上,琐碎的存在手机里。Manus、Claude Code 和 Gemini CLI 只是用不同的方式在教 AI 做同样的事。

没有完美的压缩策略,只有最适合你场景的策略。 选择哪种策略不重要,重要的是理解它们背后的设计智慧,然后根据自己的需求做出明智的选择。

以上。