让 AI Agent 不再「失忆」:blade-code Auto Memory 系统设计与实现

11 阅读7分钟

让 AI Agent 不再「失忆」:blade-code Auto Memory 系统设计与实现

每次开新会话,Agent 都忘了你的项目用 pnpm 不用 npm,忘了那个诡异的 bug 要加 --legacy-peer-deps 才能绕过,忘了你上次花 20 分钟教它的项目架构。本文介绍 blade-code 的 Auto Memory 系统——让 Agent 跨会话记住项目知识,从「每次从零开始」变成「越用越懂你」。

目录


问题:Agent 的金鱼记忆

用过 Coding Agent 的人都有这个体验:

# 第 1 次会话
你:项目怎么构建?
Agent:让我看看... 找到了,用 pnpm build
你:对,记住了

# 第 2 次会话
你:构建一下
Agent:让我试试 npm run build...
你:不是 npm,是 pnpm!上次不是说过了吗?
Agent:抱歉,让我用 pnpm build...

问题的根源很简单:会话结束,上下文清空,一切归零。

现有的 Coding Agent 通常有两种「记忆」:

  1. 会话内上下文 — 当前对话的消息历史,会话结束就没了
  2. 项目配置文件 — 比如 CLAUDE.mdBLADE.md,需要用户手写

第一种是短期记忆,活不过一个会话。第二种是外部记忆,但要用户自己维护——大多数人写了个开头就再也不更新了。

缺的是中间那层:Agent 自己在工作中积累的项目知识,自动记录,跨会话持久化。


Claude Code 怎么做的

Claude Code 在 2 月上线了 Auto Memory 功能,思路很直接:

  1. 项目根目录下有个 ~/.claude/CLAUDE.md
  2. Agent 工作时发现有价值的信息,自动写入
  3. 新会话启动时,前 200 行注入 system prompt
  4. 用户可以用 /memory 命令查看和编辑

简单粗暴,但有效。它解决了「Agent 失忆」的核心问题,而且几乎零配置。

不过也有一些局限:

  • 单文件 — 所有知识塞在一个 CLAUDE.md 里,项目复杂了会很乱
  • 200 行硬限制 — 超了就截断,没有溢出机制
  • 用标准文件工具读写 — 没有敏感数据过滤,Agent 可能把 token 写进去

blade-code 的方案在这个基础上做了改进。


blade-code 的方案

存储设计

不用单文件,用索引 + 主题文件的结构:

~/.blade/projects/{escaped-path}/memory/
├── MEMORY.md          # 索引文件(启动时加载前 200 行)
├── patterns.md        # 代码模式和约定
├── debugging.md       # 调试洞察
├── architecture.md    # 架构笔记
├── build.md           # 构建和部署
└── ...                # Agent 按需创建

MEMORY.md 是入口索引,保持精简。详细内容分散到主题文件里,Agent 需要时按需读取。

这样做的好处:

  • 索引不会膨胀到 200 行限制
  • 主题文件可以很详细,不受行数限制
  • 用户管理起来更清晰

路径复用了 blade-code 现有的 getProjectStoragePath() 体系,memory 目录和 session JSONL 文件同级,不需要额外的路径映射。

加载机制

会话启动时,buildSystemPrompt() 自动注入 MEMORY.md:

构建顺序:
1. 环境上下文(OS、Node 版本等)
2. 默认系统提示
3. BLADE.md(用户手写的项目配置)
4. ★ Auto Memory(MEMORY.md 前 200 行)  ← 新增
5. append(用户追加的提示)
6. 模式特定提示(Plan/Spec)

注入格式:

<auto-memory>
# Project Memory

## Build
- Package manager: pnpm
- Build: pnpm build
- Test: pnpm vitest run

## Architecture
- Monorepo: packages/cli + packages/web
- Entry: src/agent/Agent.ts
</auto-memory>

放在 BLADE.md 之后、append 之前,优先级适中——不会覆盖用户的显式配置,但比默认提示更具体。

工具设计

给 Agent 两个专用工具,而不是复用 Read/Write:

MemoryRead — 读取记忆文件

{
  topic: string  // "debugging" → debugging.md
                 // "MEMORY" → MEMORY.md
                 // "_list" → 列出所有文件
}

MemoryWrite — 写入记忆文件

{
  topic: string       // 主题名
  content: string     // 内容
  mode: 'append' | 'overwrite'  // 默认 append
}

为什么不直接用 Read/Write 工具?

  1. 安全隔离 — Memory 工具有敏感数据过滤,通用文件工具没有
  2. 路径封装 — Agent 不需要知道 ~/.blade/projects/... 的完整路径
  3. 语义清晰MemoryWrite({ topic: "debugging", ... })Write({ file_path: "~/.blade/projects/-root-my-project/memory/debugging.md", ... }) 意图明确得多

安全机制

三层防护:

1. 敏感数据过滤

const SENSITIVE_PATTERNS = [
  /password\s*[=:]/i,
  /api_key\s*[=:]/i,
  /token\s*[=:]/i,
  /secret\s*[=:]/i,
  /private_key/i,
];

Agent 尝试写入包含密码、token、API key 的内容时,直接拒绝并返回错误提示。注意是匹配 password = xxx 这种赋值模式,而不是简单的关键词——「用户忘记了密码重置流程」这种描述性文本不会被误杀。

2. 路径遍历防护

if (topic.includes('..') || topic.includes('/') || topic.includes('\\')) {
  throw new Error('Invalid topic name');
}

防止 Agent 通过 ../../etc/passwd 之类的 topic 名写入任意路径。

3. 索引行数限制

MEMORY.md 加载上限 200 行。超出的部分不会注入 system prompt,避免上下文膨胀。Agent 被指示把详细内容放到主题文件里,MEMORY.md 只做索引。


核心实现

1. AutoMemoryManager

核心管理器,约 150 行:

export class AutoMemoryManager {
  private memoryDir: string;
  private maxIndexLines: number;

  constructor(projectPath: string, memoryDirOverride?: string) {
    // 复用现有路径体系,或测试时注入自定义路径
    this.memoryDir = memoryDirOverride
      ?? path.join(getProjectStoragePath(projectPath), 'memory');
    this.maxIndexLines = 200;
  }

  async initialize(): Promise<void> {
    await fs.mkdir(this.memoryDir, { recursive: true, mode: 0o755 });
  }

  async loadIndex(): Promise<string> {
    const indexPath = path.join(this.memoryDir, 'MEMORY.md');
    try {
      const content = await fs.readFile(indexPath, 'utf-8');
      const lines = content.split('\n');
      if (lines.length > this.maxIndexLines) {
        return lines.slice(0, this.maxIndexLines).join('\n')
          + `\n\n<!-- Truncated: ${lines.length - this.maxIndexLines} more lines -->`;
      }
      return content;
    } catch {
      return '';
    }
  }

  async writeTopic(topic: string, content: string, mode: 'append' | 'overwrite'): Promise<void> {
    this.validateTopic(topic);
    await this.initialize();
    const filePath = this.getTopicPath(topic);

    if (mode === 'append') {
      await fs.appendFile(filePath, content);
    } else {
      await fs.writeFile(filePath, content);
    }
  }

  private validateTopic(topic: string): void {
    if (topic.includes('..') || topic.includes('/') || topic.includes('\\')) {
      throw new Error('Invalid topic name: path traversal not allowed');
    }
  }

  private getTopicPath(topic: string): string {
    const filename = topic === 'MEMORY' ? 'MEMORY.md' : `${topic}.md`;
    return path.join(this.memoryDir, filename);
  }
}

设计上有个细节:memoryDirOverride 参数。这是为了测试隔离——不加这个参数的话,测试会写入 ~/.blade/projects/ 下的真实目录,导致测试之间互相污染。

2. System Prompt 注入

修改 buildSystemPrompt(),在 BLADE.md 之后插入:

// builder.ts
const memoryEnabled = process.env.BLADE_AUTO_MEMORY !== '0';

if (memoryEnabled) {
  const manager = new AutoMemoryManager(projectPath);
  const memoryContent = await manager.loadIndex();
  if (memoryContent.trim()) {
    parts.push(`<auto-memory>\n${memoryContent}\n</auto-memory>`);
  }
}

同时在 DEFAULT_SYSTEM_PROMPT 里加了记忆指令,告诉 Agent 什么时候该记、记什么、怎么记:

## Auto Memory

You have a persistent memory system that survives across sessions.

**What to remember:**
- Build/test/lint commands that work for this project
- Code patterns and conventions you discover
- Debugging insights and solutions to tricky problems
- Architecture decisions and key file relationships

**When to save:**
- After solving a non-trivial problem
- When you discover project-specific patterns
- When the user tells you to remember something

**Rules:**
- Don't save trivial or obvious information
- Don't save sensitive data (passwords, tokens, keys)
- Keep MEMORY.md concise — overflow into topic files

3. Memory 工具

MemoryWrite 的核心逻辑——敏感数据检查:

const SENSITIVE_PATTERNS = [
  /password\s*[=:]/i,
  /api_key\s*[=:]/i,
  /token\s*[=:]/i,
  /secret\s*[=:]/i,
  /private_key/i,
];

function containsSensitiveData(content: string): boolean {
  return SENSITIVE_PATTERNS.some(pattern => pattern.test(content));
}

// execute 方法中
if (containsSensitiveData(content)) {
  return {
    success: false,
    llmContent: 'Rejected: content appears to contain sensitive data (password/token/secret/api_key/private_key). Do not store credentials in memory.',
    displayContent: '❌ Sensitive data detected',
  };
}

这里有个实现细节值得说:工具的 execute 签名。

blade-code 的 createTool 工厂函数有个问题——快捷 execute(params, signal) 方法不传 ExecutionContext,导致工具内部拿不到 context.workspaceRoot。我们在实现 Memory 工具时发现了这个 bug,顺手修了(下一节详述)。

4. /memory 命令

斜杠命令支持 4 个子命令:

/memory              # 等同于 /memory list
/memory list         # 列出所有记忆文件及大小
/memory show [topic] # 显示文件内容
/memory edit [topic] # 用 $EDITOR 打开编辑
/memory clear        # 清空所有记忆

/memory edit 的实现调用 $EDITOR 环境变量(默认 vi),用 execSync 同步执行,编辑器关闭后返回会话:

const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
execSync(`${editor} ${filePath}`, { stdio: 'inherit' });

顺手修的两个架构问题

写 Auto Memory 的过程中,发现了两个 blade-code 的架构问题,一并修了。

问题 1:createTool.execute 不传 ExecutionContext

这是最大的问题。createTool 包装后的快捷 execute 方法签名是 (params, signal?),但底层 config.execute 期望的是 (params, context: ExecutionContext)

// 修复前
async execute(params, signal?) {
  const invocation = this.build(params);
  return invocation.execute(signal || new AbortController().signal);
  // ← context 丢了!
}

// 修复后
async execute(params, signal?, context?) {
  const invocation = this.build(params);
  return invocation.execute(signal || new AbortController().signal, undefined, context);
}

这意味着之前所有通过 tool.execute() 快捷调用的工具,context.workspaceRootcontext.sessionId 等全是 undefined。只有走完整 Pipeline 的调用才正常。

修复是向后兼容的——不传 context 的调用方不受影响。

问题 2:ContextManager/PersistentStore 职责模糊

PersistentStore 有三个废弃方法还在代码里:

  • saveContext() — 把 ContextData 转成 JSONL,但丢失了 tool calls 和 workspace 信息
  • saveSession() / saveConversation() — 标记废弃但没删,只打 console.warn

同时 ContextManager.loadSession() 从 JSONL 重建 ContextData 的逻辑散落在多个地方,重建时丢失了 tool calls 和 compaction summary。

修复方案:

  1. 清理废弃方法,新增 initSession()loadEvents()
  2. 新建 ContextAssembler,集中 JSONL → ContextData 的重建逻辑
  3. 修复 saveCurrentSession() 重复写入 — Agent.ts 已经通过 saveMessage 写了 JSONL,saveCurrentSession 又写一遍
export class ContextAssembler {
  assemble(events: SessionEvent[]): AssembledSession | null {
    return {
      session: this.assembleSession(events),
      conversation: this.assembleConversation(events),  // 现在包含 summary
      toolCalls: this.assembleToolCalls(events),         // 之前完全丢失
    };
  }
}

效果

实际使用效果:

# 第 1 次会话
你:项目怎么构建?
Agent:让我看看... pnpm build。我记下来了。
[MemoryWrite: topic="MEMORY", content="## Build\n- pnpm build\n- pnpm vitest run"]

# 第 2 次会话(Agent 启动时自动加载 MEMORY.md)
你:跑一下测试
Agent:好的,pnpm vitest run
# 不再问「用 npm 还是 pnpm」了

几次会话后,Agent 积累的记忆可能长这样:

# Project Memory

## Build
- Package manager: pnpm (monorepo)
- Build: pnpm build
- Test: pnpm vitest run
- Lint: pnpm eslint .

## Architecture
- Monorepo: packages/cli (core) + packages/web (Web UI)
- Agent entry: src/agent/Agent.ts
- Tool system: src/tools/core/createTool.ts
- Context: src/context/ContextManager.ts

## Patterns
- Use createTool() factory for all tools
- ToolResult: { success, llmContent, displayContent }
- Tests: vitest, co-located in tests/unit/

总结

Auto Memory 的核心思路很简单:

  1. 给 Agent 一个持久化的笔记本(~/.blade/projects/{path}/memory/
  2. 启动时自动加载索引(MEMORY.md 前 200 行 → system prompt)
  3. 工作中自动记录(MemoryWrite 工具 + 敏感数据过滤)
  4. 用户可管理(/memory 命令)

实现上的关键决策:

决策选择原因
存储结构索引 + 主题文件避免单文件膨胀
工具设计专用 MemoryRead/Write安全隔离 + 语义清晰
加载位置BLADE.md 之后不覆盖用户配置
敏感数据正则模式匹配平衡安全和可用性
环境变量BLADE_AUTO_MEMORY=0一键禁用

代码已随 blade-code v0.2.6 发布:

如果你也在做 Coding Agent,Auto Memory 是一个投入产出比很高的功能——实现不复杂(核心代码 ~300 行),但用户体验提升明显。


blade-code 技术深度系列第 5 篇。前几篇:MCP 客户端实现 | 安全权限设计 | 多模型架构 | STOP 可观测性协议