前四篇是纵向拆解——消息链路、Agent 生命周期、子 Agent 编排、记忆与压缩。这一篇换个切法,横向扫一遍 OpenClaw 里所有跟"省 token"相关的设计。Token 就是钱,每一个省 token 的决策背后都是一个架构取舍。
我:OpenClaw 的 system prompt 里塞了那么多东西——工具列表、skill 清单、bootstrap 文件、时间、运行时信息——它怎么控制这些东西不吃掉半个 context window?
从 system prompt 开始讲,这里藏着四个设计。
第一个是 bootstrap 文件截断。AGENTS.md、SOUL.md、TOOLS.md、IDENTITY.md、USER.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.md 和 TOOLS.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 美元。