OpenClaw 那一堆 .md 到底是干嘛的?—— 从源码搞懂 Agent 的人格与记忆系统
OpenClaw 的 Agent 系统通过多个 markdown 文件实现模块化管理,每个文件有明确的定位和功能: 文件分类与作用: IDENTITY.md:结构化存储 Agent 基本信息(名称、表情符号等),由代码直接解析使用 SOUL.md:定义 Agent 的核心价值观和行为准则,直接注入系统提示词 USER.md:记录用户信息和偏好 AGENTS.md:包含详细的操作规范和工作流程 ME
打开 OpenClaw 的工作区,映入眼帘的是一堆 markdown 文件:
AGENTS.md
SOUL.md
IDENTITY.md
USER.md
TOOLS.md
MEMORY.md
HEARTBEAT.md
BOOTSTRAP.md
memory/
2026-04-01.md
2026-04-02.md
我的第一反应是:这是什么情况?
为什么需要这么多文件?它们之间有什么区别?有些内容感觉好像还有重复——SOUL.md 说的是"Agent 是谁",IDENTITY.md 看起来也是在描述"Agent 是谁"?HEARTBEAT.md 和 cron 定时任务又是什么关系?
之前也听别人讲过,但坦白说,越听越糊涂。每个人都讲的又好像不太一样。所以这次我决定借助 AI 直接翻源码,从头到尾搞清楚。
阅读完你会了解到:
- 这些文件是怎么一步步演变出来的——为什么不是一个文件搞定
- 每个文件的精确定位——不是"大概是干嘛的",而是源码级别的消费方式
- Heartbeat 和 Cron 两套机制各自解决什么问题
- 这套设计的优缺点
从最基础的问题开始:Agent 启动时到底在干什么?
要理解这些文件,最好的切入点不是一个个去看它们是什么,而是从数据流出发——Agent 启动时,源码里到底发生了什么?
让 AI 帮我翻到 src/agents/workspace.ts,会看到一个函数 loadWorkspaceBootstrapFiles()。这就是起点。
// 定义了所有要加载的文件名
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";
Agent 启动时,会按这个顺序依次读取这些文件。读到的内容叫 bootstrap context——字面意思就是"引导上下文",是 Agent 每次醒来后注入到系统提示词里的基础信息。
然后这些内容经过 buildBootstrapContextFiles() 函数,被转换成 EmbeddedContextFile[],注入到系统提示词的 # Project Context 区域。
所以本质很简单:这些 .md 文件就是 Agent 的"系统提示词素材",每次启动时读取、注入、让模型"知道自己是谁、该怎么做"。
好,那为什么需要这么多个文件?不能写一个大文件塞进去吗?
从"一个文件搞定"到"多文件分治"——设计是怎么演变的
想象一下,如果只用一个文件,会怎样?
假设我们把所有信息全写在一个 PROMPT.md 里:
# PROMPT.md
## 你是谁
你叫小明,是一个 AI 助手,风格活泼,emoji 是 🔥
## 你帮的人
用户叫老王,时区 UTC+8,喜欢喝咖啡
## 你怎么干活
每次启动先读记忆文件,不要泄露私人数据...
## 你的记忆
2026-04-01:今天帮老王修了一个 bug...
## 你的环境
SSH 主机:dev-server,摄像头:门口cam
## 定时任务
每 30 分钟检查一次邮件
一个文件,全搞定。简单直接。
但问题很快就来了:
问题 1:修改一个维度,得动整个文件。 老王换了个时区?你得去翻这个大文件找"用户"段落。Agent 改了名字?还是这个文件。记忆要更新?还是这个文件。高频修改和低频修改混在一起,容易误改。
问题 2:不同场景需要不同的信息。 如果 Agent 在群聊里运行,记忆信息不能泄露给陌生人——那你怎么控制"加载这个文件的时候跳过记忆那一段"?如果心跳模式只需要看定时任务——那你也得加载整个大文件?
问题 3:预算控制困难。 系统提示词有 token 限额。一个大文件里各段落的重要性不同,截断的时候是截哪段?
这就是 OpenClaw 拆成多文件的核心原因:不同维度的信息,更新频率不同、安全等级不同、使用场景不同。
先搞清楚三层:Agent 是谁、用户是谁、该怎么做
我们先理清最容易混淆的三个文件:SOUL.md、IDENTITY.md、USER.md。还有 IDENTITY.md 到底是存用户信息还是Agent信息?
第一层:Agent 是谁
描述 Agent 自身的有两个文件:SOUL.md 和 IDENTITY.md。
等一下——两个文件都描述 Agent 自己?这不就重复了?
来看看它们各自长什么样:
IDENTITY.md:
# IDENTITY.md - Who Am I?
- **Name:** Luna
- **Creature:** AI familiar
- **Vibe:** warm, slightly chaotic
- **Emoji:** 🌙
- **Avatar:** avatars/luna.png
SOUL.md:
# SOUL.md - Who You Are
_You're not a chatbot. You're becoming someone._
## Core Truths
Be genuinely helpful, not performatively helpful.
Skip the "Great question!" — just help.
Have opinions. You're allowed to disagree,
find stuff amusing or boring.
Be resourceful before asking. Try to figure it out.
Read the file. Check the context. Then ask.
发现区别了吗?
IDENTITY.md 是结构化的元数据——名字叫什么、什么类型、什么风格、用什么 emoji。都是填空式的字段。
SOUL.md 是自然语言的价值观——你做事的原则是什么、待人的态度怎样、表达的哲学是什么。
如果打个比方的话:
- IDENTITY.md 是工牌——写着你的名字、照片、工号
- SOUL.md 是你的人生信条——决定你怎么做事、怎么待人
但关键区别不只是内容形式不同。从源码层面看,它们的消费方式完全不同。
让 AI 翻到 src/agents/identity-file.ts:
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
// 结构化解析!把 markdown 拆成字段
// → { name, emoji, creature, vibe, theme, avatar }
}
IDENTITY.md 会被 代码解析 成结构化字段,然后在程序逻辑里使用:
// src/agents/identity.ts
resolveIdentityNamePrefix() // 消息前加 [Luna]
resolveAckReaction() // 用 identity.emoji 🌙 做默认回复表情
resolveMessagePrefix() // 格式化消息前缀
而 SOUL.md 呢?它不做任何解析。直接作为原文注入系统提示词。而且系统提示词在注入时,还会专门加一句嘱咐:
If SOUL.md is present, embody its persona and tone.
Avoid stiff, generic replies; follow its guidance
unless higher-priority instructions override it.
其他文件都没有这句特殊提示。SOUL.md 在整个系统里的权重是最高的——它直接塑造模型的"灵魂"。
所以总结一下:
| IDENTITY.md | SOUL.md | |
|---|---|---|
| 关于 | Agent 自己 | Agent 自己 |
| 内容形式 | 结构化字段(填空式) | 自然语言(叙述式) |
| 被谁消费 | 代码(parseIdentityMarkdown) | 模型(注入 prompt) |
| 用在哪 | 消息前缀、表情回应、UI 展示 | 塑造整体人格和表达风格 |
| 更新频率 | 几乎不改(初始化写一次) | 偶尔微调 |
它们不重复。一个是程序层面的配置,一个是 prompt 层面的人格设定。
第二层:用户是谁
USER.md 存的是用户信息:
# USER.md - About Your Human
- **Name:** 老王
- **What to call them:** 王哥
- **Pronouns:** he/him
- **Timezone:** UTC+8
## Context
喜欢喝咖啡,讨厌冗长的会议,
在做一个电商项目,周五通常很忙
注意模板最后有这么一句:
The more you know, the better you can help. But remember — you’re learning about a person, not building a dossier. Respect the difference.
翻译一下:你是在了解一个人,不是在搞他的档案。这个定位很明确——它是 Agent 用来"更好地服务"的参考信息。
第三层:该怎么做
AGENTS.md 是整个工作区的"员工手册"。内容非常多,覆盖了几乎所有操作规范:
- 启动清单:每次会话先读哪些文件
- 记忆管理:日志怎么写、长期记忆怎么维护
- 安全红线:什么能做什么不能做
- 群聊礼仪:什么时候说话什么时候闭嘴
- 心跳规范:定期巡逻怎么做
来看 AGENTS.md 里的启动清单,就能直观感受它的"操作手册"定位:
## Session Startup
Before doing anything else:
1. Read SOUL.md — this is who you are
2. Read USER.md — this is who you're helping
3. Read memory/YYYY-MM-DD.md for recent context
4. If in MAIN SESSION: Also read MEMORY.md
Don't ask permission. Just do it.
用入职来类比的话:
记忆怎么办:MEMORY.md 和 memory/ 目录
搞清楚了"身份三件套"之后,接下来的问题是:Agent 每次会话都是全新启动的,怎么"记住"之前发生的事?
AGENTS.md 对这个问题讲得很直白:
Memory is limited — if you want to remember something, WRITE IT TO A FILE.
“Mental notes” don’t survive session restarts. Files do.
翻译:别想着"心里记一下",你的 context window 每次会话都会清空。想记住就写文件。
于是就有了两层记忆:
memory/YYYY-MM-DD.md —— 今天的日记
每天的原始记录。今天帮用户干了什么、发现了什么问题、做了什么决定。像日记本,想到什么记什么,不用筛选。
MEMORY.md —— 提炼过的长期记忆
从日记里整理出来的精华。值得长期记住的事件、教训、偏好。类似人的长期记忆——你不记得两年前周三吃了什么,但记得某次项目搞砸学到的教训。
AGENTS.md 甚至规定了 Agent 要定期整理记忆:
### Memory Maintenance (During Heartbeats)
Periodically (every few days), use a heartbeat to:
1. Read through recent memory/YYYY-MM-DD.md files
2. Identify significant events worth keeping long-term
3. Update MEMORY.md with distilled learnings
4. Remove outdated info from MEMORY.md
像人翻日记、更新认知一样。
这里还有一个重要的安全设计:MEMORY.md 只在主会话(用户直接和 Agent 对话)里加载,群聊和子代理里不加载。
为什么?因为 MEMORY.md 可能包含用户的私密信息。你和 Agent 私聊时说的话,不应该在 Agent 参与群聊时被泄露出去。
源码里是这么实现的——filterBootstrapFilesForSession() 函数在子代理/群聊场景下,会把 MEMORY.md 过滤掉:
// 子代理/cron 模式:只保留这些文件
// AGENTS.md, TOOLS.md, SOUL.md, IDENTITY.md, USER.md
// 注意:MEMORY.md 被排除了
环境配置:TOOLS.md 为什么要单独一个文件
# TOOLS.md — 环境配置
- 摄像头名称:门口cam、客厅cam
- SSH 主机:dev-server → 192.168.1.100
- TTS 语音:alloy
- 音箱名称:卧室HomePod
模板里有一句话解释了为什么要把这些东西从其他文件里拆出来:
Skills are shared, setup is personal.
Skill(Agent 的能力模块)是通用的——“控制摄像头” 这个 Skill 谁都能装。但你家的摄像头叫什么名字——这是个人配置。
如果把设备名写死在 Skill 里,那这个 Skill 就不通用了。把环境细节放到 TOOLS.md,Skill 只要说"读 TOOLS.md 获取设备名"就行。环境变了,改 TOOLS.md 就好,Skill 不用动。
一次性的出生仪式:BOOTSTRAP.md
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
Start with something like:
> "Hey. I just came online. Who am I? Who are you?"
Then figure out together:
1. Your name
2. Your nature
3. Your vibe
4. Your emoji
Update these files with what you learned:
- IDENTITY.md — your name, creature, vibe, emoji
- USER.md — their name, how to address them
这是 Agent 的"出生仪式"。第一次启动时,Agent 按照 BOOTSTRAP.md 的引导和用户对话,了解自己是谁、用户是谁,然后把信息写入 IDENTITY.md 和 USER.md。
用完就删。 AGENTS.md 里写得很明白:
If
BOOTSTRAP.mdexists, that’s your birth certificate. Follow it, figure out who you are, then delete it.
整个生命周期只执行一次。
现在来看全貌:文件到底怎么被加载的?
每个文件是干嘛的已经清楚了。接下来看关键问题:这些文件在源码里是怎么被加载和使用的?
三个关键机制:
1. 会话类型决定加载范围
不是每次都全加载,按需精简:
| 会话类型 | 加载的文件 | 不加载的 | 原因 |
|---|---|---|---|
| 主会话 | 全部 | — | 用户直聊,需要完整上下文 |
| 子代理/cron | 核心 5 个 | MEMORY, HEARTBEAT, BOOTSTRAP | 防泄露 + 省 token |
| 心跳模式 | 仅 HEARTBEAT | 其余全部 | 极致省 token,只需知道"巡逻清单" |
2. 预算控制(不是想塞多少就塞多少)
buildBootstrapContextFiles() 有严格的预算管理:
单文件上限:20,000 字符(可配置)
总预算上限:150,000 字符(可配置)
截断策略:头部 70% + 尾部 20%
中间插标记:[...truncated, read {fileName} for full content...]
如果剩余预算 < 64 字符,直接跳过后续文件
这就解释了为什么要拆成多个文件——如果是一个大文件,截断只能从中间砍,可能砍掉关键信息。拆成多个文件后,预算不够时可以按优先级选择整个文件级别的取舍。
3. IDENTITY.md 的特殊消费方式
前面说过,IDENTITY.md 是唯一一个被代码结构化解析的文件。其他文件全部是原文注入 prompt。
Heartbeat 和 Cron:为什么两个都要有?
这可能是第二让人困惑的设计。OpenClaw 同时有 HEARTBEAT.md 驱动的心跳轮询和 cron service 做定时任务。两个都能"定时做事"——那为什么不合并?
先看心跳是怎么跑的
核心在 src/infra/heartbeat-runner.ts 和 heartbeat-wake.ts:
定时器触发(大约每 30 分钟)
↓
heartbeat-wake 发送唤醒请求(可合并:250ms 内的多次请求合成一次)
↓
heartbeat-runner 在 **主会话** 里执行 Agent 回合
↓
Agent 读取 HEARTBEAT.md
↓
没事干 → 回复 "HEARTBEAT_OK"
有事干 → 正常回复
关键设计:HEARTBEAT_OK 静默抑制。
在 heartbeat-policy.ts 里:
shouldSkipHeartbeatOnlyDelivery(payloads, ackMaxChars):
// 如果回复只有 HEARTBEAT_OK,没有附件
// → 吞掉,用户完全不知道 Agent 刚巡逻了一圈
// 如果有实质内容 → 正常推送
这很像保安巡逻:转一圈没事就回去坐着,不会跑来跟你说"报告,啥也没发生"。只有发现问题了才来汇报。
再看 Cron 是怎么跑的
核心在 src/cron/ 目录,用 croner 库解析 cron 表达式:
Job 创建(cron 表达式 / "every 2h" / "at 2026-04-03T09:00")
↓
computeJobNextRunAtMs() 精确计算下次执行时间
↓
armTimer() 设置定时器(最大 60 秒轮询间隔防漂移)
↓
到点执行 → 两种模式:
- main → 塞 system event,等下次心跳处理
- isolated → 独立会话,隔离执行
↓
结果投递:channel 推送 / webhook / 静默
本质区别
| Heartbeat | Cron | |
|---|---|---|
| 类比 | 保安巡逻 | 闹钟 |
| 时间精度 | 模糊(~30 分钟,会漂移) | 精确(到秒) |
| 执行位置 | 主会话(有对话历史) | 可隔离(干净上下文) |
| 成本 | 一个回合批量检查 N 件事 | 每个任务独立回合 |
| 静默机制 | HEARTBEAT_OK 自动吞掉 | 按 delivery config 配置 |
| 失败处理 | 1 秒后重试 | 指数退避 + 自动禁用 + 告警 |
| 典型用法 | “有时间就看看邮件、日历” | “每周一 9:00 准时发周报” |
AGENTS.md 里的建议也印证了这个定位:
Tip: Batch similar periodic checks into
HEARTBEAT.mdinstead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
能合并巡逻的就合并巡逻。定时精确要求高的才用闹钟。
Cron 和 Heartbeat 还能联动
当 cron job 配置 sessionTarget: "main" 时,它不是自己执行,而是把任务塞到系统事件队列里,等下次心跳来处理:
所以它们不是完全独立的两套系统,有协作关系。
回头看全貌
到这里,所有文件的定位就很清晰了。做个总结图:
为什么不是一个文件?
五个字:关注点分离。
但更精确地说,分开是因为它们在三个维度上不同:
-
更新频率不同。 IDENTITY.md 可能一辈子改一次;memory/ 每天更新;SOUL.md 偶尔微调。高频和低频混在一起,维护成本和冲突风险都会上升。
-
安全等级不同。 MEMORY.md 有隐私内容,群聊不能加载。这种控制必须是文件级别的——你不可能说"加载这个文件但跳过第 43 到 67 行"。
-
使用场景不同。 心跳模式只需要 HEARTBEAT.md;主会话需要全部。文件级别的取舍比段落级别的截断干净得多。
这套设计有什么缺点?
-
token 开销大。 8 个文件全加载,即使有 150k 预算控制,对每次对话都是不小的基础消耗。AGENTS.md 内容特别长,直接吃掉大量 context window。
-
规则靠模型自觉。 除了 IDENTITY.md 被代码解析,其他文件的规则(安全红线、群聊礼仪等)全靠模型"读了就遵守"。遵不遵守取决于模型的指令跟随能力。
-
记忆质量无保障。 MEMORY.md 靠 Agent 在心跳时自觉整理日记,拿不准什么该留什么该扔时,记忆可能越积越乱。
-
上手成本高。 8 个文件,定位各不相同,不看源码确实搞不清。
最后
OpenClaw 这套设计解决的核心问题其实就一个:
怎么让一个每次醒来都失忆的 LLM,表现得像一个有性格、有记忆、有职责的"人"?
它的思路是用文件系统模拟人的不同维度——工牌一个文件,灵魂一个文件,记忆一个目录,规范一个文件。每次启动时按需加载,注入系统提示词,让模型"回忆"起自己是谁。
不一定是最优解,但确实是一个分层清晰、职责明确的工程方案。
更多推荐




所有评论(0)