不到200行代码实现 Open Claw 的 Context 设计
引子
同一个 LLM,同一个问题,给不给 context、给什么 context,输出质量差距很大。
一个没有 context 的 agent,不知道自己是谁、不了解用户偏好、不清楚应该遵守什么规范。它只能给出泛泛的回答。一个有良好 context 设计的 agent,能记住用户是资深工程师、知道项目用 TypeScript、明白应该直接给方案而不是教基础语法。
context 不是把所有信息塞进 system prompt。token 有上限,API 按 token 计费,信息的时效性也不同——有些内容每次对话都一样,有些每轮都在变。context 设计的核心问题是:什么信息该进 prompt、以什么形式进、在什么时机进。
本文介绍一种分层 context 架构的设计与实现。代码基于 TypeScript + Anthropic SDK,完整实现约 160 行。
Context 在解决什么问题
LLM 本身是无状态的。每次 API 调用都是独立的:模型不记得上一轮对话说了什么,也不知道调用者是谁。
要让 LLM 表现得像一个有记忆、有人格、有技能的 agent,所有这些信息都需要通过请求参数传入。Anthropic API 有两个主要入口:
- system:system prompt,定义 agent 的身份和行为规则
- messages:对话历史 + 当前用户输入
system prompt 承担了三重职责:
| 职责 | 内容 | 变化频率 |
|---|---|---|
| 身份 | agent 的人格、行为边界 | 极少变化 |
| 能力 | agent 掌握的技能和规范 | 偶尔变化 |
| 记忆 | 用户偏好、项目上下文 | 每次对话可能不同 |
三种内容的变化频率不同,但都要塞进同一个 system 字段。这带来了几个实际问题:
- token 成本:Anthropic 支持 prompt caching,但只有标记了
cache_control且内容未变化的部分才能命中缓存。如果把所有内容拼成一个字符串,任何局部变化都会导致整个缓存失效。 - 信息密度:把所有技能文档全文注入 system prompt 会快速消耗 token 预算。有些技能每次都要用,有些可能十次对话才触发一次。
- 维护成本:如果 system prompt 是一个硬编码的长字符串,每次调整 agent 行为都要改代码、重新部署。
设计哲学:三层 Context 架构
基于上述问题,将 system prompt 拆分为三层:
┌─────────────────────────────────────┐
│ 稳定层 (Soul + Instructions) │ ← cache_control: ephemeral
│ 定义"我是谁",极少变化 │
├─────────────────────────────────────┤
│ 能力层 (Skills) │ ← cache_control: ephemeral
│ 定义"我能做什么" │
│ always skill → 全文注入 │
│ 其他 skill → 仅摘要,按需加载 │
├─────────────────────────────────────┤
│ 动态层 (User Preferences) │ ← 不缓存
│ 定义"我在服务谁",随时可变 │
└─────────────────────────────────────┘每一层对应 Anthropic API system 参数中的一个 TextBlockParam。三层分别管理自己的缓存策略。
稳定层:Soul + Instructions
对应两个 Markdown 文件:
- SOUL.md — agent 的人格定义。例如:
你是 SuxiongBot,一个由苏雄构建的个人编程助手。
## 性格
- 务实、简洁,不说废话
- 偏好"一次到位",避免反复追问
- 重视长期可维护性,拒绝 hacky 方案- AGENTS.md — 行为规则和指令。类似 Cursor Rules 或 Claude 的 system instructions。
这两个文件在 agent 的生命周期内几乎不变。它们被拼接在一起,标记 cache_control: { type: "ephemeral" },在多轮对话中可以命中 Anthropic 的 prompt cache。
能力层:Skills
skill 是一种可插拔的能力模块,每个 skill 是一个 Markdown 文件,描述一组规范或行为指令。例如 TypeScript 编码规范、用户记忆管理流程等。
skill 有两种加载模式:
- always — 全文注入 system prompt。适用于每次对话都需要的核心技能。
- on-demand — 仅在 system prompt 中注入名称和一句话描述,提示 LLM 需要时通过
read_file工具自行加载全文。
这样做的理由很直接:一个 agent 可能注册了 20 个 skill,但单次对话平均只会用到 2-3 个。全部注入会浪费大量 token,而 LLM 有能力根据摘要判断何时需要加载哪个 skill。
整个 skills 区段同样标记 cache_control。只要 skill 注册列表不变,这一层就能命中缓存。
动态层:User Preferences
对应 USER.md,记录用户的个人信息和偏好:
## 关于用户
- 名称:苏雄
- 角色:资深前端工程师
- 技术栈:TypeScript、React、Node.js、Bun
- 偏好:简洁方案,关注架构与可维护性,不需要基础概念解释这个文件可以被 agent 自身通过 write_file 工具修改(配合 user-memory skill),因此内容在对话过程中可能发生变化。所以这一层不标记 cache_control,确保每次请求都使用最新内容。
为什么是这三层
这不是一个随意的划分。它基于一个观察:system prompt 中不同部分的变化频率不同,而 prompt caching 的收益与内容稳定性直接相关。
将内容按稳定性分层后:
- 稳定的内容放前面,标记缓存,跨轮次复用
- 易变的内容放最后,不缓存,保证实时性
- 中间的技能层通过 always/on-demand 机制控制注入量
对比其他常见做法:
- 单一长字符串 system prompt:任何改动都破坏缓存,无法控制注入量
- OpenAI Assistants 的 instructions 字段:只有一个字符串,没有分层缓存的概念
- 手动拼接 + 条件判断:逻辑散落在业务代码中,难以维护
关键设计决策
Markdown 即配置
agent 的身份、规则、技能、用户偏好全部用 Markdown 文件定义,而不是 JSON 或 YAML。
原因:
- system prompt 本身就是自然语言。Markdown 文件的内容可以直接拼入 prompt,不需要任何序列化/反序列化。
- 对 LLM 友好。LLM 天然理解 Markdown 的层级结构(
#、-、**),不需要额外的格式解析指令。 - 对人类友好。非工程师也能编辑
.md文件来调整 agent 行为,不需要理解配置语法。 - 可版本控制。
.md文件的 diff 可读性远好于 JSON。
代价是缺少 schema 校验。但对于 system prompt 这种"写给 LLM 看的自然语言"来说,schema 校验的价值有限。
渐进式 Skill 加载
核心权衡:token 成本 vs 响应质量。
全文注入保证 LLM 在第一次响应时就拥有完整信息,不需要额外的工具调用轮次。但如果 skill 很多,token 消耗会线性增长。
摘要 + 按需加载让 LLM 先看到"目录",需要时再通过 read_file 加载具体内容。代价是多一轮工具调用(约 1-2 秒延迟),且 LLM 需要有足够的判断力知道何时该加载。
实际使用中的经验:
- 2-3 个核心 skill 设为 always,每个控制在 500 token 以内
- 其余 skill 用一句话描述,让 LLM 自行决定
- Claude 在根据摘要判断是否需要加载全文这件事上表现可靠
Prompt Caching
Anthropic 的 prompt caching 机制:在 TextBlockParam 上标记 cache_control: { type: "ephemeral" },如果该 block 的内容与上次请求完全一致,API 会复用缓存的 KV 计算结果,减少延迟和费用。
分层设计天然适配这个机制:
| 层 | cache_control | 原因 |
|---|---|---|
| 稳定层 | ephemeral | 内容跨会话不变,缓存命中率高 |
| 能力层 | ephemeral | skill 注册列表在运行期间不变 |
| 动态层 | 无 | USER.md 可能被 agent 修改,必须实时读取 |
关键约束:Anthropic 要求 cache_control 标记在 system 数组中从前往后生效。所以稳定内容必须放在前面。这也是三层排列顺序的技术原因。
消息历史截断
ContextBuilder 对消息历史做简单的尾部截断:保留最近 N 条(默认 50),丢弃更早的消息。
buildMessages(history: MessageParam[], userMessage: string): MessageParam[] {
const trimmed = history.slice(-this.maxHistory);
return [...trimmed, { role: "user", content: userMessage }];
}这是一个有意的简化。更精细的方案包括:基于 token 计数截断、对早期消息做摘要压缩、引入向量检索等。但对于一个个人 agent 来说,按条数截断足够实用,且实现零依赖。
代码实现走读
完整实现在 ContextBuilder 类中,约 160 行。
类型定义
interface SkillEntry {
name: string;
description: string;
path: string;
/** 为 true 时全文注入 system prompt,否则仅注入摘要 */
always?: boolean;
}
interface ContextConfig {
workspaceDir: string;
promptFiles?: {
soul?: string; // 默认 SOUL.md
agents?: string; // 默认 AGENTS.md
user?: string; // 默认 USER.md
};
skills?: SkillEntry[];
maxHistoryMessages?: number; // 默认 50
}配置结构刻意保持扁平。promptFiles 允许覆盖默认文件名,但大多数情况下不需要传——用默认值就好。
buildSystemPrompt:分层拼装
核心方法,返回 TextBlockParam[] 而非字符串,以支持 prompt caching。
async buildSystemPrompt(): Promise<TextBlockParam[]> {
const [soul, agents, user, alwaysSkillContents] = await Promise.all([
this.loadFile(this.promptFiles.soul),
this.loadFile(this.promptFiles.agents),
this.loadFile(this.promptFiles.user),
this.loadAlwaysSkills(),
]);
const blocks: TextBlockParam[] = [];
// 稳定层:soul + agents
const stableParts: string[] = [];
if (soul) stableParts.push(`# Identity\n\n${soul}`);
if (agents) stableParts.push(`# Instructions\n\n${agents}`);
if (stableParts.length) {
blocks.push({
type: "text",
text: stableParts.join("\n\n---\n\n"),
cache_control: { type: "ephemeral" },
});
}
// 能力层:skills
const skillsText = this.buildSkillsSection(alwaysSkillContents);
if (skillsText) {
blocks.push({
type: "text",
text: skillsText,
cache_control: { type: "ephemeral" },
});
}
// 动态层:user(不缓存)
if (user) {
blocks.push({ type: "text", text: `# User Preferences\n\n${user}` });
}
return blocks;
}几个值得注意的点:
- 四个文件并行加载(
Promise.all),避免串行 IO。 - 每一层是独立的
TextBlockParam,cache_control 独立控制。 - 容错处理:任何文件不存在时返回
null,对应层直接跳过,不会报错。这意味着一个最简 agent 可以不提供任何.md文件也能运行。
Skill 区段的构建
private buildSkillsSection(alwaysContents: Map<string, string>): string | null {
if (!this.skills.length) return null;
const parts: string[] = ["# Available Skills\n"];
for (const skill of this.skills) {
if (skill.always && alwaysContents.has(skill.name)) {
const content = this.interpolate(alwaysContents.get(skill.name)!);
parts.push(`## ${skill.name} (always loaded)\n\n${content}`);
} else {
parts.push(
`- **${skill.name}**: ${skill.description} ` +
`_(load via \`read_file("${skill.path}")\` when needed)_`,
);
}
}
return parts.join("\n\n");
}always skill 和 on-demand skill 的区别在输出中一目了然:
- always → 以
## heading+ 全文形式出现 - on-demand → 以列表项 +
read_file提示出现
interpolate 方法做模板变量替换(目前只有 {{workspaceDir}}),让 skill 文件中可以引用运行时路径。
与 AgentLoop 的集成
在入口文件中,ContextBuilder 和 AgentLoop 的协作方式:
const ctx = new ContextBuilder({
workspaceDir,
skills: [
{
name: "typescript",
description: "TypeScript coding conventions and patterns",
path: "skills/typescript.md",
always: true,
},
{
name: "user-memory",
description: "Auto-learn user preferences and persist to USER.md",
path: "skills/user-memory.md",
always: true,
},
],
});
const systemPrompt = await ctx.buildSystemPrompt();
const agent = new AgentLoop({
provider: anthropic,
tools,
system: systemPrompt,
buildMessages: (history, userMessage) =>
ctx.buildMessages(history, userMessage),
model: "claude-opus-4-6",
maxIterations: 10,
});systemPrompt 在启动时构建一次,作为 TextBlockParam[] 传入 AgentLoop。buildMessages 作为回调传入,每轮对话时由 AgentLoop 调用,负责截断历史并追加新消息。
两个关注点分离明确:ContextBuilder 只管"组装什么内容给 LLM",AgentLoop 只管"怎么跟 LLM 交互"。
实践指南
从"需要记住什么"倒推分层
设计 context 时,先列出你的 agent 需要知道的所有信息,然后按变化频率分类:
| 变化频率 | 归属层 | 示例 |
|---|---|---|
| 几乎不变 | 稳定层 | 人格定义、核心行为规则 |
| 偶尔变化 | 能力层 | 编码规范、工具使用指南 |
| 随时可变 | 动态层 | 用户偏好、项目上下文 |
如果某类信息跨越了两个频率等级,拆成两部分分别放置。
Skill 注册的实践建议
- always skill 控制在 2-4 个,每个不超过 500 token。超过这个量,system prompt 会过长,影响响应速度和成本。
- 为每个 skill 写清楚一句话 description。这是 LLM 判断是否需要加载该 skill 的唯一依据。模糊的描述会导致该加载时没加载、不该加载时加载了。
- skill 内容用第二人称指令式书写("你应该..."、"当 X 时,执行 Y")。LLM 对这种格式的遵循度最高。
常见陷阱
过度注入。把所有参考资料、API 文档、示例代码都塞进 system prompt。结果:token 成本高、关键指令被稀释、LLM 反而表现更差。原则:system prompt 只放"行为指令",参考资料通过工具按需获取。
忽视缓存。把所有内容拼成一个字符串传给 system。结果:即使只有 USER.md 变了一个字,整个 system prompt 的缓存全部失效。分层就是为了解决这个问题。
动态内容污染稳定层。把用户偏好写进 SOUL.md,或者把会变的配置写进 AGENTS.md。结果:本应长期缓存的内容频繁变化,缓存命中率下降。
小结
本文介绍的 context 架构做了三件事:
- 按变化频率分层,让稳定内容可被缓存
- 按使用频率区分 skill 加载策略,控制 token 消耗
- 用 Markdown 文件作为配置源,让 agent 行为的调整不需要改代码
已知局限:
- 无 context 压缩。历史消息只做条数截断,不做摘要压缩。长对话后期可能丢失早期重要信息。
- 无 RAG。所有 context 要么全文注入、要么通过
read_file加载本地文件。没有向量检索能力,无法处理大规模知识库。 - 单次构建。
systemPrompt在启动时构建,运行期间 SOUL.md / AGENTS.md 的变更不会被感知(USER.md 例外,因为每次都读取)。 - 缓存依赖 Anthropic 实现。
cache_control: ephemeral是 Anthropic 特有的 API 特性,切换到其他 LLM 提供商时需要适配。
可演进方向:基于 token 计数的智能截断、对话摘要压缩、skill 的运行时热加载、多 provider 的缓存抽象层。
这些都是后续的事。当前这 160 行代码覆盖了一个个人 agent 的 context 管理需求。