发送一句「你好」,为什么花掉了几千个 Token?——深读 OpenClaw 的 Context 注入机制

本文约 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 promptmessages 数组

用户发的「你好」只占 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_buildbefore_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.mdMEMORY.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,分析文件主要涉及:

相关推荐
工边页字2 小时前
AI产品中的长期记忆和短期记忆是什么,你知道吗?
前端·人工智能·后端
HelloReader2 小时前
Flutter 页面导航Navigator.push 与自适应导航模式(十四)
前端
小凡同志2 小时前
那个复制粘贴了二十次 loading 的下午
前端·vue.js
HelloReader2 小时前
Flutter 底层原理揭秘框架如何工作(十五)
前端
南篱2 小时前
前端必看:一口气搞懂跨域是什么、为什么、怎么解决
前端·javascript·面试
qq_406176142 小时前
Vue 插槽与组件传参:从入门到精通
前端·javascript·vue.js
三年三月2 小时前
Redux 技术栈使用总结
前端·react.js
Tody Guo2 小时前
OpenClaw与企业微信的定时任务设定
前端·github·企业微信
张雨zy2 小时前
Vue 的 v-if 与 v-show,Android 的 GONE 与 INVISIBLE
android·前端·vue.js