本文约 5200 字,适合对 LLM 应用架构感兴趣的开发者阅读。
前言:一个让我们困惑了好几天的问题
前段时间我们在做 OpenClaw 的 Token 使用分析,发现一个有意思的现象:用户随手发了一句「你好」,API 账单里 input tokens 却有好几千甚至上万。
第一反应当然是去翻 context.ts,文件名就叫这个,理所当然地以为问题在这里。翻完之后发现......什么都没有。这个文件只是个 context window 大小的查询工具,跟实际注入进去的内容半点关系都没有。
那 token 都花在哪了?
这篇文章就是我们顺着源码一路挖下去之后,整理出来的答案。内容不会绕弯子,直接讲清楚每一层往 prompt 里塞了什么。
先搞清楚 context.ts 到底是什么
既然最开始被它误导了,先把它说清楚。
src/agents/context.ts 做的事情只有一件:告诉你模型的 context window 有多大。
它维护了一个内存 Map,启动时异步地去加载模型元数据(从 Pi agent、从用户配置),然后提供两个主要函数给其他模块调用:
typescript
// 直接查缓存,返回 modelId 对应的 context window token 数
export function lookupContextTokens(modelId?: string): number | undefined
// 更完整的解析:支持 provider+model、别名、配置覆盖
export function resolveContextTokensForModel(params: {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
contextTokensOverride?: number;
fallbackContextTokens?: number;
}): number | undefined
整个文件 391 行,没有任何字符串拼接,没有 prompt 构建,没有消息包装。它只是一把尺子,量一量这个模型的窗口有多宽。
真正把内容塞进去的,是另外几个地方。
真正的幕后黑手:系统提示词的构建链
OpenClaw 发送给 LLM 的每一次请求,都由两部分组成:system prompt 和 messages 数组。
用户发的「你好」只占 messages 里的最后一条,而 system prompt 才是 token 的大户。
来看构建链:
plaintext
用户发消息
↓
src/agents/pi-embedded-runner/run/attempt.ts
↓ 调用
src/agents/pi-embedded-runner/system-prompt.ts → buildEmbeddedSystemPrompt()
↓ 转发给
src/agents/system-prompt.ts → buildAgentSystemPrompt()
↓ 组装出完整的 system prompt 字符串
buildAgentSystemPrompt 是核心,我们一段段来看它往里放了什么。
第一层:硬编码的角色定义与工具清单
函数最开始,直接拼了一段固定文本:
typescript
const lines = [
"You are a personal assistant running inside OpenClaw.",
"",
"## Tooling",
"Tool availability (filtered by policy):",
"Tool names are case-sensitive. Call tools exactly as listed.",
// ... 然后是所有可用工具的列表
];
工具列表是根据当前 session 可用的工具动态生成的,但内容相当详细,每个工具都有描述文字:
typescript
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
edit: "Make precise edits to files",
apply_patch: "Apply multi-file patches",
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
cron: "Manage cron jobs and wake events ...",
sessions_spawn: "Spawn an isolated sub-agent or ACP coding session ...",
session_status: "Show a /status-equivalent status card ...",
// 还有更多
};
光这个工具列表,如果全部可用,大概就是 400~800 个 token。
接下来紧跟着的是:
## Tool Call Style--- 告诉模型什么时候旁白、什么时候直接调用## Safety--- Anthropic 风格的安全约束,四条规则,铺了小半页## OpenClaw CLI Quick Reference--- gateway 的启停命令## OpenClaw Self-Update--- config.apply、update.run 等操作说明
这几段加在一起,保守估计又是 300~500 token。
第二层:Skills 技能清单注入
这一层很多人容易忽略。
如果用户的工作目录下注册了技能(skills/ 目录),或者 ClawHub 上订阅了技能,系统会把所有技能的 XML 元数据打包进 system prompt:
typescript
function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed) return [];
return [
"## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.",
`- If exactly one skill clearly applies: read its SKILL.md at <location> with `${params.readToolName}`, then follow it.`,
"- If multiple could apply: choose the most specific one, then read/follow it.",
// ...
trimmed, // ← 这里是完整的 <available_skills> XML
"",
];
}
trimmed 是一整块 XML,结构大概长这样:
xml
<available_skills>
<skill>
<name>git-commit</name>
<description>Help write conventional commit messages</description>
<location>skills/git-commit/SKILL.md</location>
</skill>
<skill>
<name>code-review</name>
<description>...</description>
...
</skill>
</available_skills>
技能越多,这里的 token 越多。一个中等规模的安装,这里可能就有 1000~3000 token。
第三层:记忆、消息、时区、回复标签......
系统提示词还有一堆「功能性」段落,每一个单独看不算大,加在一起就很可观:
| 段落 | 内容 | 大概 token 数 |
|---|---|---|
## Memory Recall |
指导模型何时调用 memorysearch/memoryget | ~80 |
## Reply Tags |
[[reply_to_current]] 语法说明 |
~60 |
## Messaging |
message 工具的完整参数格式,含内联按钮 | ~200 |
## Authorized Senders |
owner 手机号或 hash | ~30 |
## Current Date & Time |
用户时区 | ~20 |
## Workspace |
工作目录路径 + 文件操作说明 | ~60 |
| Runtime 行 | host/os/arch/node/model/shell 信息 | ~50 |
这一层加起来大概 500~600 token,不算最多,但也是实实在在的固定开销。
第四层:Bootstrap 文件全文注入------最大的 Token 消耗点
这一层才是真正的重头戏。
OpenClaw 在启动 session 时,会从用户工作目录里加载一批「bootstrap 文件」,然后把它们的全文内容 嵌入进 system prompt 的 # Project Context 段落。
来看 src/agents/workspace.ts 定义的文件列表:
typescript
export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; // 核心指令文件
export const DEFAULT_SOUL_FILENAME = "SOUL.md"; // 人设/语气文件
export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; // 工具使用说明
export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; // 身份设定
export const DEFAULT_USER_FILENAME = "USER.md"; // 用户偏好
export const DEFAULT_HEARTBEAT_FILENAME= "HEARTBEAT.md";// 定时任务提示
export const DEFAULT_BOOTSTRAP_FILENAME= "BOOTSTRAP.md";// 启动引导
export const DEFAULT_MEMORY_FILENAME = "MEMORY.md"; // 持久记忆
这些文件的内容,通过 buildBootstrapContextFiles 函数处理后,全部塞进 system prompt:
typescript
// src/agents/pi-embedded-helpers/bootstrap.ts
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000; // 单个文件上限:2 万字符
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 150_000; // 所有文件合计上限:15 万字符
换算一下:
- 单文件上限 20,000 字符 ≈ 约 5,000 token
- 所有文件合计上限 150,000 字符 ≈ 约 37,500 token
如果 AGENTS.md 写了很多内容(在大型项目里这个文件往往几千行),截断前会先保留头部 70% + 尾部 20%:
typescript
const BOOTSTRAP_HEAD_RATIO = 0.7;
const BOOTSTRAP_TAIL_RATIO = 0.2;
function trimBootstrapContent(content: string, fileName: string, maxChars: number) {
const headChars = Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO); // 14,000 字符
const tailChars = Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO); // 4,000 字符
const head = trimmed.slice(0, headChars);
const tail = trimmed.slice(-tailChars);
const marker = `[...truncated, read ${fileName} for full content...]`;
return [head, marker, tail].join("\n");
}
截断之后还是 18,000 字符,差不多 4,500 token。
以 OpenClaw 这个项目本身为例,它的 AGENTS.md 有几百行的仓库规范、PR 流程、测试规范......如果全部注入,光这一个文件就能超过 3,000 token,再加上其他几个文件,这一层轻松达到 10,000~20,000 token。
第五层:对话历史------随着聊天滚雪球
system prompt 是固定的,但 messages 数组不是。
每次发送消息,当前 session 的全部对话历史都会一起发出去。第一条消息发出时,messages 里只有用户自己的「你好」,代价很低。但随着对话进行:
- 第 5 轮对话:messages 里有 9 条(5 个 user + 4 个 assistant)
- 助手的回复往往比用户的消息长得多
- 如果中间有工具调用,toolresult 也会附在里面
这部分是线性增长的,不采取压缩(compaction)就会持续膨胀。
OpenClaw 有专门的 compaction 机制(src/agents/compaction.ts)来处理这个问题,超过 context window 的 70% 时会触发摘要压缩,但这是另一个话题了。
第六层:Hook 注入------插件的野路子加料
还有一层是插件机制带来的:before_prompt_build 和 before_agent_start 两个 hook。
插件可以通过这两个 hook 向 prompt 里注入内容:
typescript
// src/agents/pi-embedded-runner/run/attempt.ts
const hookResult = await resolvePromptBuildHookResult({
prompt: params.prompt,
messages: activeSession.messages,
hookCtx,
hookRunner,
legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult,
});
if (hookResult?.prependContext) {
// 注入到用户消息的前面
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`;
}
if (hookResult?.prependSystemContext || hookResult?.appendSystemContext) {
// 注入到 system prompt 的头部或尾部
applySystemPromptOverrideToSession(activeSession, composedSystemPrompt);
}
这层的 token 消耗完全取决于已安装的插件。某些插件(比如 memory-core、context-engine)会在这里注入实质性的内容。
汇总:一次「你好」的真实 token 账单
把以上各层加在一起,一次最简单的问候,实际发给 LLM 的 input 大概是这样:
typescript
┌──────────────────────────────────────────────────────┐
│ system prompt │
│ │
│ [硬编码角色 + 工具清单] 约 600~1,500 token │
│ [Skills XML] 约 0~3,000 token │
│ [记忆/消息/时区等功能段] 约 500 token │
│ [Bootstrap 文件全文] 约 2,000~20,000 token │
│ [Hook 注入] 约 0~N token │
│ │
│ messages │
│ [历史对话] 约 0~N token(累积) │
│ user: "你好" 约 2 token │
└──────────────────────────────────────────────────────┘
最低估算(全新 session + 简配):约 3,000~5,000 token
正常项目(有 AGENTS.md + 几个技能):约 8,000~15,000 token
重度配置(大型 AGENTS.md + 多 bootstrap 文件 + 插件):20,000+ token
「你好」本身只有 2 个 token,其余全是 OpenClaw 为了让模型理解它所处的上下文而注入的各种元数据。
为什么要设计成这样?
这不是 bug,是有意为之的架构取舍。
LLM 本质上是无状态的,它不记得你是谁、不知道你的工作目录、不清楚有哪些工具可以用、不了解项目规范。每次请求都必须把这些信息重新带过去,模型才能给出符合预期的响应。
这和传统 HTTP API 不一样。传统服务端有 session 状态、有数据库。LLM 只有你在这次请求里给它的内容。
OpenClaw 的选择是:把上下文做得尽可能完整,换来的是模型能够不依赖追问就直接理解用户意图、能够准确调用工具、能够遵守项目规范------代价是更高的 input token 消耗。
如果你想压缩 Token,可以从哪里下手?
顺着这条路,优化思路就很清楚了:
1. 精简 AGENTS.md
这是单个文件里最可压缩的部分。把它精简到真正必要的指令,删掉重复和冗余的内容,立竿见影。单文件从 5,000 token 压缩到 1,000 token,每次请求就省了 4,000 token。
2. 调整 bootstrap 文件大小上限
通过配置覆盖默认值:
plaintext
agents.defaults.bootstrapMaxChars = 5000 # 单文件从 20,000 降到 5,000
agents.defaults.bootstrapTotalMaxChars = 30000 # 总上限从 150,000 降到 30,000
3. 只保留必要的 bootstrap 文件
SOUL.md、MEMORY.md 不是所有场景都需要,按需启用。
4. 控制安装的技能数量
每个技能在 Skills XML 里都有 token 开销,只装真正常用的。
5. 定期触发 compaction
对话轮数多了之后,历史消息这层会成为大头。OpenClaw 的 compaction 机制会把历史压缩成摘要,可以明显降低后续轮次的成本。
6. 对简单场景使用 promptMode: "minimal"
子 agent、定时任务等场景不需要完整的系统提示,切换到 minimal 模式可以跳过大部分功能段落。
结尾
真正驱动 token 消耗的,是 system prompt 构建链:从硬编码的角色定义、工具清单,到 Skills XML、Bootstrap 文件全文注入,再到 Hook 插件的额外注入,层层叠加之后,一句「你好」背后已经是几千甚至几万 token 的上下文准备工作。
理解了这个机制,Token 账单里那些「莫名其妙的高消耗」就都能解释了------不是大模型在偷工减料,是为了让它明白「你是谁、在哪、能做什么」而付出的必要成本。
如果你也在做基于 LLM 的应用,这套 system prompt 分层注入的思路值得参考:把身份、能力、环境、记忆分层管理,每层独立可控,调试起来清晰得多。 本文源码版本:OpenClaw main branch,分析文件主要涉及: