# 不到200行代码实现 Open Claw 的 Context 设计

> Site index: <https://blog.suxiong.me/llms.txt>

Original: [不到200行代码实现 Open Claw 的 Context 设计](https://blog.suxiong.me/blog/open-claw-context-design)

Author: Suxiong

Date: 2026-02-14

本文介绍一种分层 context 架构的设计与实现。代码基于 TypeScript + Anthropic SDK，完整实现约 160 行。

Tags: AI / Agent / 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 字段。这带来了几个实际问题：

1. **token 成本**：Anthropic 支持 prompt caching，但只有标记了 `cache_control` 且内容未变化的部分才能命中缓存。如果把所有内容拼成一个字符串，任何局部变化都会导致整个缓存失效。
2. **信息密度**：把所有技能文档全文注入 system prompt 会快速消耗 token 预算。有些技能每次都要用，有些可能十次对话才触发一次。
3. **维护成本**：如果 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 的人格定义。例如：

```markdown
你是 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**，记录用户的个人信息和偏好：

```markdown
## 关于用户

- 名称：苏雄
- 角色：资深前端工程师
- 技术栈：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。

原因：

1. **system prompt 本身就是自然语言**。Markdown 文件的内容可以直接拼入 prompt，不需要任何序列化/反序列化。
2. **对 LLM 友好**。LLM 天然理解 Markdown 的层级结构（`#`、`-`、`**`），不需要额外的格式解析指令。
3. **对人类友好**。非工程师也能编辑 `.md` 文件来调整 agent 行为，不需要理解配置语法。
4. **可版本控制**。`.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），丢弃更早的消息。

```typescript
buildMessages(history: MessageParam[], userMessage: string): MessageParam[] {
  const trimmed = history.slice(-this.maxHistory);
  return [...trimmed, { role: "user", content: userMessage }];
}
```

这是一个有意的简化。更精细的方案包括：基于 token 计数截断、对早期消息做摘要压缩、引入向量检索等。但对于一个个人 agent 来说，按条数截断足够实用，且实现零依赖。

***

## 代码实现走读

完整实现在 `ContextBuilder` 类中，约 160 行。

### 类型定义

```typescript
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。

```typescript
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;
}
```

几个值得注意的点：

1. **四个文件并行加载**（`Promise.all`），避免串行 IO。
2. **每一层是独立的 `TextBlockParam`**，cache\_control 独立控制。
3. **容错处理**：任何文件不存在时返回 `null`，对应层直接跳过，不会报错。这意味着一个最简 agent 可以不提供任何 `.md` 文件也能运行。

### Skill 区段的构建

```typescript
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` 的协作方式：

```typescript
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 注册的实践建议

1. **always skill 控制在 2-4 个**，每个不超过 500 token。超过这个量，system prompt 会过长，影响响应速度和成本。
2. **为每个 skill 写清楚一句话 description**。这是 LLM 判断是否需要加载该 skill 的唯一依据。模糊的描述会导致该加载时没加载、不该加载时加载了。
3. **skill 内容用第二人称指令式书写**（"你应该..."、"当 X 时，执行 Y"）。LLM 对这种格式的遵循度最高。

### 常见陷阱

**过度注入**。把所有参考资料、API 文档、示例代码都塞进 system prompt。结果：token 成本高、关键指令被稀释、LLM 反而表现更差。原则：system prompt 只放"行为指令"，参考资料通过工具按需获取。

**忽视缓存**。把所有内容拼成一个字符串传给 `system`。结果：即使只有 USER.md 变了一个字，整个 system prompt 的缓存全部失效。分层就是为了解决这个问题。

**动态内容污染稳定层**。把用户偏好写进 SOUL.md，或者把会变的配置写进 AGENTS.md。结果：本应长期缓存的内容频繁变化，缓存命中率下降。

***

## 小结

本文介绍的 context 架构做了三件事：

1. **按变化频率分层**，让稳定内容可被缓存
2. **按使用频率区分 skill 加载策略**，控制 token 消耗
3. **用 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 管理需求。
