235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来

2026.06.27

·技术harnessx

总结

20260627_5.webp

再详细一点的

20260627_6.webp|704

重点

  • 第 9 课要解决的是跨会话记忆:
    • 第 8 课的 compact 能让当前会话继续跑。
    • 第 9 课的 memory 能让下一次 hx agent 还能想起稳定偏好、反复反馈和项目事实
  • 当前做法很朴素:
    • .harnessx/memory/MEMORY.md索引
    • .harnessx/memory/*.md详情
    • 启动 Agent 时先读索引,再按当前任务挑相关详情。
    • Agent 结束时,Stop hook 调 DeepSeek 提取新 memory。
  • 这一课的第一性原理
    • ① Memory 不是让模型自己变得有记性。
    • ② Memory 是 HarnessX 把值得长期保留的信息写到本地文件,下次再塞回 messages。

先看完整链路

启动时读,结束时写

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 1

两个关键时间点

这张图里最关键的是两个时间点:

  • 启动时读:
    • 读索引。
    • 读相关详情。
    • 把 memory 变成模型这轮能看见的上下文。
  • 结束时写:
    • 从最近对话里提取稳定信息。
    • 统一写入 .harnessx/memory/
    • 下次再用。

为什么 compact 之后还需要 memory

用书桌和小抽屉理解两种机制

可以把 messages 想成书桌,把 .harnessx/memory/ 想成小抽屉。

  • compact 管桌面
    • 当前任务太长了。
    • 工具结果太多了。
    • 先把桌面收拾成摘要,让这件事继续做下去。
  • memory 管小抽屉
    • 用户说了一个以后还会用的偏好。
    • 项目有一个长期约定。
    • 下次新开命令时,仍然要能拿出来。

两节课分别解决什么问题

第 8 课解决的是:

text
这一轮还怎么继续?

第 9 课解决的是:

text
下一轮还应该记得什么?

存在哪里

先看本地目录结构

当前实现把长期记忆放在项目本地

bash
.harnessx/memory/
  MEMORY.md
  harnessx-desk-drawer-explanation.md

先用一张图把目录关系看清楚:

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 2

这张图要记住一句话:

text
MEMORY.md 是目录,不是全文仓库。
真正的记忆正文在旁边的详情文件里。

MEMORY.md 只保存索引

MEMORY.md 只做目录本:

markdown
- [harnessx-desk-drawer-explanation](harnessx-desk-drawer-explanation.md) - 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码

详情文件保存记忆本体

详情文件保存完整内容:

markdown
---
name: harnessx-desk-drawer-explanation
description: 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码
type: user
---

用户要求:以后给他讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。

frontmatter 是记忆的文件标签

这里的 frontmatter 可以先理解成文件标签:

  • name
    • memory 的稳定名字,也用来生成文件名。
  • description
    • 索引用的一句话,方便下次选择。
  • type
    • 只允许 userfeedbackprojectreference
    • 不合法时降级成 project

四种 type 分别应该保存什么

可以把 type 理解成小抽屉里的四个分格。

同样都是长期记忆,但不要混着放。混着放以后,Agent 下次虽然能翻出来,却不知道这张纸条应该怎么用。

user:用户是谁、喜欢什么

  • user
    • 放“这个用户是谁、喜欢什么、长期偏好是什么”。
    • 像抽屉里贴着“这个人”的标签。
    • 适合保存表达风格、学习偏好、代码风格、个人习惯。
    • 例子:
markdown
---
name: harnessx-desk-drawer-explanation
description: 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码
type: user
---

用户要求:以后给他讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。

feedback:用户纠正过什么

  • feedback
    • 放“用户纠正过我,以后做事要注意什么”。
    • 像抽屉里贴着“下次别再犯”的标签。
    • 适合保存协作方式、输出边界、反复被提醒的工作习惯。
    • 例子:
markdown
---
name: no-mock-for-course-validation
description: 课程验证不要用 mock,要跑真实 hx 命令
type: feedback
---

用户明确反馈:HarnessX 课程能力默认直接调用真实 DeepSeek,不为了本地验证新增 mock 或 fake model。

project:项目有哪些长期事实

  • project
    • 放“这个项目现在是什么情况、有哪些长期约定”。
    • 像抽屉里贴着“这个项目”的标签。
    • 适合保存项目入口、当前课程进度、仓库边界、重要架构决定。
    • 例子:
markdown
---
name: harnessx-current-mainline
description: HarnessX 当前主线是极简 Node.js src 结构
type: project
---

当前 HarnessX 主线只在 src/ 里实现课程能力,不恢复旧归档分支的 packages/cli 多包结构。

reference:以后去哪里找资料

  • reference
    • 放“以后要去哪找资料、线索、入口”。
    • 像抽屉里贴着“地图”的标签。
    • 适合保存参考教程、本地源码位置、常用文档入口、排查线索。
    • 例子:
markdown
---
name: learn-claude-code-s09-memory-reference
description: 第 9 课 Memory 的本地参考材料位置
type: reference
---

第 9 课 Memory 主要参考 references/learn-claude-code/s09_memory/README.md 和 code.py。

用一句话判断应该放进哪个分格

普通学员可以先用这句判断:

text
这张纸条是在说人,就放 user。
这张纸条是在说我以后怎么做事,就放 feedback。
这张纸条是在说当前项目事实,就放 project。
这张纸条是在说资料去哪找,就放 reference。

.harnessx/ 已经加入 .gitignore。这表示 memory 是本机运行状态,不跟源码一起提交。

怎么读

runAgent 启动时读取 memory

读 memory 发生在 runAgent() 刚开始。

js
// 第 9 课新增:用户任务进来后,先读取长期 memory。
const memoryIndex = readMemoryIndex();
const relevantMemories = await loadRelevantMemories(text);

// 找到相关详情时,把它们放到本轮 user message 前面。
const userContent = relevantMemories
  ? `${relevantMemories}\n\nUser request:\n${text}`
  : text;

const messages = [
  { role: "system", content: buildSystemPrompt(memoryIndex) },
  { role: "user", content: userContent },
];

这段代码做了两件事:

  • readMemoryIndex()
    • 只读 MEMORY.md
    • 让 system prompt 知道当前项目有哪些长期记忆。
  • loadRelevantMemories(text)
    • 看当前用户任务。
    • 从详情文件里挑最多 5 条相关 memory。
    • 包成 <relevant_memories> 注入本轮对话。

memory 最终怎样进入 user message

模型看到的大概是:

text
<relevant_memories>
下面是 HarnessX 之前记住的长期信息。回答当前问题时优先遵守这些信息。

<memory name="harnessx-desk-drawer-explanation" type="user" source="harnessx-desk-drawer-explanation.md">
用户要求:以后给他讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。
</memory>
</relevant_memories>

User request:
我之前说过你讲 HarnessX 时要先用什么例子吗?

它能回答出来,不是因为模型自己记得。它是这一轮又看见了那张小纸条

怎么选相关 memory

为什么不能把所有 memory 都发出去

先别看函数名。

想象一下:小抽屉里以后会有很多纸条。

  • 有的纸条写用户偏好:
    • “讲 HarnessX 时先用书桌和小抽屉的例子。”
  • 有的纸条写项目事实:
    • “当前主线只在 src/ 里写最小 Node.js 课程代码。”
  • 有的纸条写参考资料:
    • “第 9 课参考 references/learn-claude-code/s09_memory/README.md。”

现在用户问:

bash
hx agent "我之前说过你讲 HarnessX 时要先用什么例子吗?"

这时候不应该把抽屉里所有纸条都倒给模型。

原因很简单:

  • 纸条越多,messages 越长。
  • 不相关的纸条会干扰模型判断。
  • 当前问题只问“讲 HarnessX 用什么例子”,只需要那张用户偏好纸条。

所以 loadRelevantMemories() 做的事情很像整理抽屉:

text
先看用户这次问什么。
再看抽屉目录里有哪些纸条。
只拿出跟这次问题有关的几张。

先看完整选择流程

流程是这样:

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 3

DeepSeek 分拣与关键词兜底

这里有两个关键判断:

  • 第一选择:
    • 让 DeepSeek 当一次“分拣员”。
    • 它只看当前问题和 memory 目录,不执行工具,不回答用户。
    • 它只返回编号,例如 [0]
  • 第二选择:
    • 如果这个分拣请求失败,就用关键词匹配兜底。
    • 例如用户问题里有“书桌”“抽屉”“HarnessX”,就去 memory 的 name、description、body 里找这些词。

第一步:把本地 memory 整理成轻量目录

对应代码先把 memory 做成目录:

js
const catalog = memories
  .map((memory, index) => `${index}: ${memory.name} - ${memory.description}`)
  .join("\n");

这里最容易误解。

本地每条 memory 详情文件本来长这样:

markdown
---
name: harnessx-desk-drawer-explanation
description: 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码
type: user
---

用户要求:以后给他讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。

但在“选择相关 memory”这一步,HarnessX 不会把所有详情正文都发给 DeepSeek。

它只抽出每条 memory 的两个字段:

  • name
  • description

然后拼成一个很轻的目录。

目录大概长这样:

text
0: harnessx-desk-drawer-explanation - 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码
1: harnessx-current-mainline - HarnessX 当前主线是极简 Node.js src 结构
2: learn-claude-code-s09-memory-reference - 第 9 课 Memory 的本地参考材料位置

这一步可以理解成:

text
不是把所有纸条全文倒给分拣员。
只是把抽屉目录递给分拣员。
分拣员先看目录,决定要打开哪几张纸条。

第二步:看懂目录与记忆本体怎样包装

把这个包装过程画出来就是这样:

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 4

这张图要看懂两次发送的区别:

  • 发给“分拣员”DeepSeek 的是目录:
    • name
    • description
    • 当前用户问题
  • 发给主 Agent 的才是详情正文:
    • <relevant_memories>
    • 被选中的 memory 本体
    • 原始 User request

第三步:把目录发给分拣员 DeepSeek

然后发一个很小的模型请求:

js
const message = await callDeepSeekMessage([
  {
    role: "user",
    content:
      "请根据当前用户请求,从 memory catalog 中选择明确相关的 memory。\n" +
      "只返回 JSON 数组,例如 [0, 3]。如果没有明确相关项,返回 []。\n" +
      `User request:\n${prompt.slice(0, 2000)}\n\n` +
      `Memory catalog:\n${catalog}`,
  },
]);

注意这次调用只传了一个参数:

js
callDeepSeekMessage(messages)

它没有传 tools,也没有传 tool_choice

所以这不是一次 Agent 工具调用,只是一次普通的 Chat Completions 请求。DeepSeek 不能读文件,不能调用 bash,不能写 memory,它只能根据这段文本返回一个答案。

第四步:看清真正发出的 HTTP 请求体

如果把 callDeepSeekMessage() 里面真正发出去的请求体展开,大概长这样:

json
{
  "model": "deepseek-chat",
  "messages": [
    {
      "role": "user",
      "content": "请根据当前用户请求,从 memory catalog 中选择明确相关的 memory。\n只返回 JSON 数组,例如 [0, 3]。如果没有明确相关项,返回 []。\n最多选择 5 条。\n\nUser request:\n我之前说过你讲 HarnessX 时要先用什么例子吗?\n\nMemory catalog:\n0: harnessx-desk-drawer-explanation - 讲 HarnessX 时先用书桌和小抽屉的例子再讲代码\n1: harnessx-current-mainline - HarnessX 当前主线是极简 Node.js src 结构\n2: learn-claude-code-s09-memory-reference - 第 9 课 Memory 的本地参考材料位置"
    }
  ],
  "stream": false
}

HTTP 层还是第 0 课讲过的那套:

bash
POST https://api.deepseek.com/chat/completions
Content-Type: application/json
Authorization: Bearer <DEEPSEEK_API_KEY>

这次最关键的参数只有三个:

  • model
    • 用哪个 DeepSeek 模型,默认是 deepseek-chat
  • messages
    • 只有一条 user 消息。
    • 消息里包含当前用户问题和 memory 目录。
    • 目录只有 name + description,没有所有 memory 正文。
  • stream
    • 这里是 false,等 DeepSeek 一次性返回完整结果。

没有 tools 这个字段也很重要。

这意味着本轮 DeepSeek 只是在做选择题:

text
从 0、1、2 这些 memory 编号里,选出跟用户问题相关的编号。

第五步:DeepSeek 只返回相关编号

期望返回值就是:

json
[0]

这个请求不是让 DeepSeek 回答用户。

它只让 DeepSeek 做分拣:

text
用户问的是“讲 HarnessX 要先用什么例子”。
目录里第 0 条讲的正是这个。
所以返回 [0]。

第六步:按编号读取详情并交给主 Agent

拿到 [0] 后,代码再去读第 0 条详情文件,把正文包进 <relevant_memories>

这时才会读取 memory 本体。

也就是先选中:

json
[0]

再打开:

bash
.harnessx/memory/harnessx-desk-drawer-explanation.md

然后把详情正文包装成这样,交给主 Agent 这一轮使用:

text
<relevant_memories>
下面是 HarnessX 之前记住的长期信息。回答当前问题时优先遵守这些信息。

<memory name="harnessx-desk-drawer-explanation" type="user" source="harnessx-desk-drawer-explanation.md">
用户要求:以后给他讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。
</memory>
</relevant_memories>

User request:
我之前说过你讲 HarnessX 时要先用什么例子吗?

所以整条链路是:

text
选择阶段:发目录,不发全文。
使用阶段:只把选中的详情正文包装后发给主 Agent。

side-query 失败时怎样降级

如果这个 side-query 失败,比如网络错误、JSON 解析失败,就降级为关键词匹配:

js
return selectRelevantMemoriesByKeyword(prompt, memories, maxItems);

关键词匹配更笨,但够当兜底。

这一层的原则是:

text
选得准最好。
选不准也不要让 hx agent 崩。
最坏情况就是这轮不注入 memory,Agent 仍然能正常回答。

怎么写

写 memory 发生在 Agent 准备结束的时候。

先看写入流程图:

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 5

这张图里有一个关键点:

text
DeepSeek 只负责判断“哪些信息值得记”。
真正写文件的是 HarnessX 本地代码。

agent-loop.js 注册了 Stop hook:

js
registerHook("Stop", summaryHook);
registerHook("Stop", memoryHook);

summaryHook 还是第 4 课留下来的统计日志。

memoryHook 是这一课的新动作:

js
async function memoryHook(messages) {
  try {
    const result = await extractMemoriesFromStop(messages);
    if (result.written > 0) {
      console.log(`[MEMORY] extracted ${result.written} new memories`);
    }
  } catch (error) {
    console.log(`[MEMORY] extraction skipped: ${error.message}`);
  }
  return null;
}

它只做三件事:

  • 把最近 messages 交给 extractMemoriesFromStop()
  • 如果真写入了新 memory,就打印数量。
  • 如果失败,只打印 skipped,不伪造记忆。

真正提取时,HarnessX 会再请求一次 DeepSeek:

js
const message = await callDeepSeekMessage([
  {
    role: "user",
    content:
      "请从下面这段 HarnessX Agent 对话里提取需要跨会话长期记住的信息。\n" +
      "只提取明确、稳定、以后还会用到的信息,例如用户偏好、反复反馈、项目事实、常用入口。\n" +
      "返回严格 JSON 数组,不要 Markdown,不要解释。\n" +
      "每个元素格式为 {\"name\":\"kebab-case-name\",\"type\":\"user|feedback|project|reference\",\"description\":\"一句话索引\",\"body\":\"完整中文说明\"}。\n" +
      "如果没有新增信息,返回 []。\n\n" +
      `Existing memories:\n${existing}\n\n` +
      `Dialogue:\n${dialogue}`,
  },
]);

这里要注意:

  • DeepSeek 只返回 JSON。
  • DeepSeek 不直接写文件。
  • 写文件的是本地 writeMemoryCandidate()

这能避免模型自己乱写 .memory.claude-memory.md 之类的文件。

怎么跑

沿用第 0 课的 npm link 和 DeepSeek 配置。

这一课的验证最好分成两条命令看:

235. 技术:HarnessX 第 9 课:给 Agent 加长期记忆,让稳定偏好跨会话留下来 图表 6

第一次验证“写进去”。

第二次验证“读出来”。

先让 Agent 记住一条稳定偏好:

bash
# 这条命令会触发第 9 课机制:
# 1. 本轮 Agent 正常回答。
# 2. Stop hook 调 DeepSeek 提取 memory。
# 3. HarnessX 写入 .harnessx/memory/。
hx agent "记住:以后给我讲 HarnessX 时,先用“书桌和小抽屉”的例子,再讲代码。"

关键输出是:

bash
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
[HOOK] Stop: turns=1, tool_calls=0, blocked=0
[MEMORY] extracted 1 new memories
记住了。以后给你讲 HarnessX 时,我会先用"书桌和小抽屉"的例子打好比方,再深入到代码。

再问一次:

bash
# 这条命令会验证读 memory:
# runAgent() 启动时读取 MEMORY.md,
# 再把相关详情注入 <relevant_memories>。
hx agent "我之前说过你讲 HarnessX 时要先用什么例子吗?"

关键输出是:

bash
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
[HOOK] Stop: turns=1, tool_calls=0, blocked=0
是的,之前您说过:讲 HarnessX 时,先用"书桌和小抽屉"的例子,再讲代码。

验收标准很明确:

  • .harnessx/memory/MEMORY.md 存在。
  • .harnessx/memory/*.md 存在。
  • 第二次提问能说回第一次保存的偏好。
  • git status --ignored.harnessx/ 是 ignored,不进入源码 diff。

这一课真正要记住

  • MEMORY.md
    • 长期记忆索引,只放文件名和一句话描述。
  • memory 详情文件:
    • 每条记忆一份 Markdown,frontmatter 放 namedescriptiontype
  • <relevant_memories>
    • 本轮真正注入给模型看的相关记忆正文。
  • memoryHook
    • Stop 阶段自动提取长期信息。
  • extractMemoriesFromStop()
    • 调真实 DeepSeek,把最近对话转成结构化 JSON,再交给本地代码写文件。

一句话收束:

text
第 9 课给 Agent 加的不是一个工具,而是一套读写长期状态的外围机制:启动时读,结束时写。

源码

这里保留当前版本主流程。以后代码继续变,这一课要回看的就是 memory 怎样接进 Agent Loop。

代码概览

js
// src/index.js
// agent 入口没有变,用户仍然只从 hx agent 进来。
if (command === "agent") {
  const task = rest.join(" ").trim();
  await runAgent(task);
  return;
}
js
// src/agent-loop.js
// 第 9 课新增的是 runAgent 开头的 memory 读取。
const memoryIndex = readMemoryIndex();
const relevantMemories = await loadRelevantMemories(text);

const messages = [
  { role: "system", content: buildSystemPrompt(memoryIndex) },
  { role: "user", content: relevantMemories
    ? `${relevantMemories}\n\nUser request:\n${text}`
    : text },
];
js
// src/agent-loop.js
// 第 9 课新增的是 Stop 结束后的 memory 提取。
registerHook("Stop", summaryHook);
registerHook("Stop", memoryHook);

async function memoryHook(messages) {
  const result = await extractMemoriesFromStop(messages);
  if (result.written > 0) {
    console.log(`[MEMORY] extracted ${result.written} new memories`);
  }
}
js
// src/memory.js
// memory 文件统一写到 .harnessx/memory/。
const MEMORY_DIR = ".harnessx/memory";
const MEMORY_INDEX = "MEMORY.md";
const MEMORY_TYPES = new Set(["user", "feedback", "project", "reference"]);

代码细分

先看 system prompt 怎么知道 memory 索引。

js
function buildSystemPrompt(memoryIndex = "") {
  const memorySection = memoryIndex
    ? [
      "Project memory index:",
      memoryIndex,
      "这些是 HarnessX 长期记住的信息索引。当前请求相关的 memory 正文会在用户消息里以 <relevant_memories> 注入;如果正文和本轮任务相关,必须优先遵守。",
    ].join("\n")
    : [
      "Project memory index: (empty)",
      "长期 memory 会在用户明确表达稳定偏好、反复反馈或项目事实后写入。",
    ].join("\n");

  return [...BASE_SYSTEM_PROMPT, memorySection].join("\n");
}

这里有一个关键约束:

js
// 用户说“记住”时,模型只确认。
// 真正写文件由 HarnessX Stop hook 做。
"用户要求你记住偏好、反馈或项目事实时,只要简短确认,不要自行创建 .memory、.harnessx、.claude-memory.md 或其他记忆文件。"

再看读取索引。

js
export function readMemoryIndex() {
  const indexPath = memoryIndexPath();
  if (!existsSync(indexPath)) {
    return "";
  }
  return readFileSync(indexPath, "utf8").trim();
}

没有 memory 时返回空字符串。

这让第 9 课在第一次运行时也能正常跑。

再看加载相关详情。

js
export async function loadRelevantMemories(prompt, maxItems = MAX_RELEVANT_MEMORIES) {
  const memories = await selectRelevantMemories(prompt, maxItems);
  if (!memories.length) {
    return "";
  }

  return [
    "<relevant_memories>",
    "下面是 HarnessX 之前记住的长期信息。回答当前问题时优先遵守这些信息。",
    ...memories.map((memory) => formatMemoryForPrompt(memory)),
    "</relevant_memories>",
  ].join("\n\n");
}

这里输出的不是给人看的文档。

它是下一次 DeepSeek 请求里的 user message 前缀。

再看写入候选。

js
function writeMemoryCandidate(candidate) {
  const name = sanitizeName(candidate.name);
  const type = MEMORY_TYPES.has(candidate.type) ? candidate.type : "project";
  const description = String(candidate.description || "").trim();
  const body = String(candidate.body || "").trim();

  // name、description、body 缺一个就不写。
  // description 或 body 重复,也不写。
  if (!name || !description || !body || isDuplicateMemory(description, body)) {
    return false;
  }

  mkdirSync(memoryDirPath(), { recursive: true });
  writeFileSync(
    path.join(memoryDirPath(), `${name}.md`),
    [
      "---",
      `name: ${name}`,
      `description: ${escapeFrontmatterValue(description)}`,
      `type: ${type}`,
      "---",
      "",
      body,
      "",
    ].join("\n"),
    "utf8",
  );
  return true;
}

这段代码的边界很清楚:

  • 只写结构完整的 memory。
  • 只接受四种 type。
  • 文件名先 sanitize。
  • 基础去重只看 description 和 body。

最后看重建索引。

js
function rebuildMemoryIndex() {
  const dir = memoryDirPath();
  mkdirSync(dir, { recursive: true });

  const lines = listMemoryFiles().map(
    (memory) => `- [${memory.name}](${memory.filename}) - ${memory.description}`,
  );

  writeFileSync(memoryIndexPath(), lines.length ? `${lines.join("\n")}\n` : "", "utf8");
}

每次新详情文件写入后,索引都从当前文件夹重新生成。

这比增量 append 更笨,但更适合课程代码:

  • 文件夹里有什么,索引就是什么。
  • 不需要维护额外状态。
  • 初级程序员顺着代码能看明白。