拆解 OpenClaw - 05:13 个省 Token 的设计

5 阅读7分钟

前四篇是纵向拆解——消息链路、Agent 生命周期、子 Agent 编排、记忆与压缩。这一篇换个切法,横向扫一遍 OpenClaw 里所有跟"省 token"相关的设计。Token 就是钱,每一个省 token 的决策背后都是一个架构取舍。


我:OpenClaw 的 system prompt 里塞了那么多东西——工具列表、skill 清单、bootstrap 文件、时间、运行时信息——它怎么控制这些东西不吃掉半个 context window?

从 system prompt 开始讲,这里藏着四个设计。

第一个是 bootstrap 文件截断。AGENTS.mdSOUL.mdTOOLS.mdIDENTITY.mdUSER.md 这些文件每次请求都注入 system prompt,但有两道闸:单文件上限 bootstrapMaxChars(默认 2 万字符),所有文件总量上限 bootstrapTotalMaxChars(默认 15 万字符)。超了就截断加标记。模型想看完整版可以自己调 read 工具去读。这防止用户写了一本小说当 AGENTS.md,一个文件就吃掉几万 token。

{
  "agents": {
    "defaults": {
      "bootstrapMaxChars": 20000,       // 单文件上限
      "bootstrapTotalMaxChars": 150000  // 所有文件总量上限
    }
  }
}

第二个是 skill 按需加载。system prompt 里只注入一个紧凑的 skill 清单——名字、一句话描述、文件路径,每个 skill 大约占 3-4 行文本。模型判断当前任务需要哪个 skill 后,自己去 read 对应的 SKILL.md。如果装了 20 个 skill,每个 SKILL.md 几千字符,全注入就是几万 token。按需加载意味着一次请求通常只读 0 到 1 个 skill 文件。

全注入方案:20 skills × 3000 chars  60000 chars(~15000 token)
按需加载:清单 20 × 150 chars  3000 chars(~750 token)+ 按需读 1 

省了 95% 的 skill 相关 token。代价是模型需要多一次工具调用去读 SKILL.md,但这个 overhead 远小于把所有 skill 全塞进 system prompt。

第三个是子 Agent 的精简提示。主 Agent 用 promptMode: full,子 Agent 用 promptMode: minimal。minimal 模式砍掉了 Skills 列表、Memory Recall 指令、Self-Update 指令、Reply Tags、Silent Replies、Heartbeats、用户身份信息。bootstrap 文件也只注入 AGENTS.mdTOOLS.md,跳过 SOUL.md、USER.md、IDENTITY.md。子 Agent 是干活的——它不需要知道主人叫什么、自己的人格是什么、心跳怎么回复。

graph LR
    subgraph "主 Agent(full prompt)"
        F1["Tooling"] --- F2["Safety"] --- F3["Skills 清单"]
        F3 --- F4["Memory Recall"] --- F5["Self-Update"]
        F5 --- F6["Reply Tags"] --- F7["Heartbeats"]
        F7 --- F8["Runtime"]
        F8 --- F9["AGENTS.md + SOUL.md + USER.md<br/>+ IDENTITY.md + TOOLS.md"]
    end

    subgraph "子 Agent(minimal prompt)"
        M1["Tooling"] --- M2["Safety"] --- M3["Runtime"]
        M3 --- M4["AGENTS.md + TOOLS.md"]
    end

子 Agent 的 system prompt 通常只有主 Agent 的三分之一到一半大小。考虑到一个主 Agent 可能同时 spawn 多个子 Agent,这个节省是乘数级的。

第四个是时间信息的缓存友好设计。system prompt 里只写时区(如 Asia/Shanghai),不写当前时间。如果每次都注入精确到秒的时间戳,prompt 的前缀每秒都在变,Anthropic 的 prompt cache 就永远命中不了——因为 cache 是按 prompt 前缀匹配的,前缀变了就得重新缓存。模型需要当前时间时调用 session_status 工具获取。用一次工具调用换整个 session 的缓存命中率,划算。

我:记忆文件呢?MEMORY.md 每次都注入,日记文件怎么处理的?

这是第五个设计:memory/*.md 日记文件不自动注入。MEMORY.md 作为长期记忆会注入 system prompt(所以要控制它的大小),但 memory/2026-02-23.md 这种日记文件不会。它们通过 memory_search 语义检索按需获取,返回的是 snippet(上限约 700 字符),精准命中。

如果你用了三个月,每天一个日记文件,90 个文件全注入——那 system prompt 比整本小说都长了,别聊天了。

graph TB
    subgraph "每次注入 system prompt"
        MEM["MEMORY.md(长期记忆)"]
        BS["AGENTS.md / SOUL.md / ..."]
    end

    subgraph "按需检索(不注入)"
        D1["memory/2026-02-23.md"]
        D2["memory/2026-02-22.md"]
        D3["memory/2026-02-21.md"]
        DN["...(几十个日记文件)"]
    end

    Q["模型调用 memory_search"] -->|"语义匹配"| D1
    Q -->|"返回 snippet"| D2

向量搜索本身就是一个省 token 的机制。不做向量搜索,模型要么读不到旧记忆,要么得把所有记忆文件全读一遍。向量搜索用 embedding 计算(不花 LLM token)替代全文注入,只返回最相关的几个片段。

我:对话历史层面呢?

第六个是 Compaction。之前详细聊过——接近 context window 上限时把旧消息压缩成一段摘要。几百条消息变成一段文字,有损压缩但效果显著。这是最暴力也最有效的省 token 手段。

第七个是 inbound debounce。用户连续快速发多条消息时,OpenClaw 等一个窗口期(默认 2 秒,WhatsApp 5 秒,Discord 1.5 秒),把这些消息合并成一条送给模型。

{
  "messages": {
    "inbound": {
      "debounceMs": 2000,
      "byChannel": {
        "whatsapp": 5000,
        "discord": 1500
      }
    }
  }
}

不做 debounce 的话,用户发三条消息就触发三次独立的 agent run。每次 run 都要带完整的 system prompt 和历史,假设 system prompt 是 1 万 token,那三次 run 就白白多花了 2 万 token 的 system prompt 重复开销。合并成一条只跑一次,省了两倍。

我:工具输出那块呢?工具返回的内容经常很大。

三道防线。

第八个是工具自身的输出截断,第一道防线。read 工具默认限制 2000 行或 50KB(取先到的),exec 工具也有输出大小限制。工具在返回结果时就已经把过长的内容砍过一刀了。大部分情况下这就够了——你不需要在 context 里塞一个 10MB 的日志文件。

第九个是 tool_result_persist 插件钩子,第二道防线。工具结果写入 session JSONL 之前,插件可以同步修改内容。比如写一个插件把所有超过 1 万字符的工具结果在持久化时就截断,这样后续加载历史时不会再膨胀。这是一个可编程的拦截点——你可以根据工具类型、输出内容、大小来决定怎么处理。

第十个是 Session Pruning,第三道防线。每次 LLM 调用前,对旧的 toolResult 做 soft-trim 或 hard-clear。前两道防线管的是"工具输出进来时有多大",Pruning 管的是"旧的工具输出要不要保留"。

graph LR
    T["工具执行"] -->|"第一道:自身截断<br/>read: 2000行/50KB<br/>exec: 输出限制"| R["工具结果"]
    R -->|"第二道:persist 钩子<br/>插件可修改/截断"| J["写入 JSONL"]
    J -->|"第三道:Session Pruning<br/>soft-trim / hard-clear"| C["发给 LLM 的 context"]

三道防线逐层收紧:源头控制大小,持久化时可编程拦截,发给模型前按时间衰减裁剪。

我:Anthropic 的 prompt caching 具体怎么利用的?

第十一个设计,也是省钱最直接的一个。Anthropic 的 API 支持 prompt caching:如果两次请求的 prompt 前缀相同,第二次只需要为新增部分付费,缓存命中的部分按折扣价算。OpenClaw 围绕这个机制做了好几件事。

前面提到的时间信息不写入 system prompt 就是为了保持前缀稳定。Pruning 的 cache-ttl 模式也是——缓存过期后,在重新缓存之前先砍掉旧工具输出,让 cacheWrite 的 token 数更小。OpenClaw 的 smart defaults 还会根据认证方式自动调整:OAuth 用户心跳间隔设为 1 小时,API key 用户心跳设为 30 分钟、cacheControlTtl 默认 1 小时,目标都是让缓存利用率最大化。

缓存命中时的计费:
  cacheRead  = 缓存部分 × 0.1 倍价格(Sonnet)
  input      = 新增部分 × 1.0 倍价格

缓存未命中时的计费:
  cacheWrite = 全部内容 × 1.25 倍价格

Pruning 在缓存过期后砍掉旧工具输出 → cacheWrite 更小 → 省钱

我:还有架构层面的设计吗?

第十二个是 session 隔离。每个聊天位置(Discord 的每个频道、每个私聊、Telegram 的每个群)是独立的 session,历史互不污染。不隔离的话,#general 里的闲聊会被带进 #coding 的上下文——模型在写代码时还要处理一堆无关的聊天记录,纯粹浪费 token。

第十三个是 inbound 去重。Channel 重连后可能重复投递同一条消息,OpenClaw 用短期缓存(按 channel/account/peer/session/message id 去重)避免同一条消息触发两次 agent run。重复的 run 意味着重复的 token 消耗和重复的回复。


把这 13 个设计按作用阶段排列:

graph TB
    subgraph "System Prompt 层(减少固定开销)"
        S1["① Bootstrap 文件截断"]
        S2["② Skill 按需加载"]
        S3["③ 子 Agent 精简提示"]
        S4["④ 时间信息缓存友好"]
        S5["⑤ 日记文件不自动注入"]
    end

    subgraph "对话历史层(控制历史增长)"
        H1["⑥ Compaction 压缩"]
        H2["⑦ Inbound Debounce"]
    end

    subgraph "工具输出层(三道防线)"
        T1["⑧ 工具自身截断"]
        T2["⑨ tool_result_persist 钩子"]
        T3["⑩ Session Pruning"]
    end

    subgraph "缓存与架构层(系统级优化)"
        C1["⑪ Prompt Cache 对齐"]
        C2["⑫ Session 隔离"]
        C3["⑬ Inbound 去重"]
    end

这套设计的思路是分层防御:system prompt 层控制固定开销,对话层控制历史增长,工具层控制输出膨胀,架构层避免无效消耗。没有哪一个设计是银弹,但叠在一起效果显著。对于一个长期运行的个人助手,这些设计决定了你一个月花 10 美元还是 100 美元。