Suxiong

不到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 字段。这带来了几个实际问题:

  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 的人格定义。例如:
你是 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。

原因:

  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内容跨会话不变,缓存命中率高
能力层ephemeralskill 注册列表在运行期间不变
动态层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;
}

几个值得注意的点:

  1. 四个文件并行加载Promise.all),避免串行 IO。
  2. 每一层是独立的 TextBlockParam,cache_control 独立控制。
  3. 容错处理:任何文件不存在时返回 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 的集成

在入口文件中,ContextBuilderAgentLoop 的协作方式:

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[] 传入 AgentLoopbuildMessages 作为回调传入,每轮对话时由 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 管理需求。

On this page