写在前面
用 Claude Code 做项目开发有个痛点------它没有长期记忆。
每次 /clear 或开新会话,之前讨论过的技术决策、踩过的坑、约定好的规范,全部归零。虽然 Claude Code 自带了 Auto Memory(会往 MEMORY.md 里写一些东西),但说实话,这个机制的召回精度和容量都很有限。你在 A 项目里踩过的坑,切到 B 项目就完全不记得了。
这篇文章记录我实际跑通的一套方案:在本地搭建双重记忆引擎,让 Claude Code 拥有跨会话、跨项目的持久记忆能力。零云端依赖,全部跑在本地。
一、整体思路:三层记忆架构
先说结论,最终落地的是一个三层结构:
┌─────────────────────────────────────────────┐
│ Claude Code 用户交互层 │
│ CLAUDE.md / Auto Memory / Skills │
└────────┬────────────────────┬───────────────┘
│ MCP 工具调用 │ ov CLI
┌────────▼──────────┐ ┌──────▼──────────────┐
│ memory-engine │ │ OpenViking Server │
│ :17823 (SSE) │ │ :1933 │
│ │ │ │
│ 会话记忆 │ │ 知识库管理 │
│ LanceDB 向量存储 │ │ 目录递归检索 │
│ Weibull 衰减 │ │ L0/L1/L2 分层加载 │
│ 混合检索 │ │ 结构化记忆 │
└────────┬──────────┘ └──────┬──────────────┘
└──────┬─────────────┘
┌──────▼──────┐
│ Ollama │
│ bge-m3 │ ← Embedding 模型(两个系统共享)
│ qwen2.5:14b │ ← VLM(按需加载)
└─────────────┘
每一层干什么:
| 层级 | 系统 | 存什么 | 怎么找 |
|---|---|---|---|
| L1 即时层 | Claude Code 原生 Auto Memory | MEMORY.md 里的项目速览 | 每次会话全量注入上下文 |
| L2 会话层 | memory-engine(自建 MCP Server) | 偏好、决策、踩坑、规范 | 向量+BM25 混合检索 + 时间衰减 |
| L3 知识层 | OpenViking | 文档、代码库、深度上下文 | 语义搜索 + 分层加载 |
为什么要分三层?因为 Claude Code 的上下文窗口是有限的。L1 每次全量加载(几百 token),L2 按需召回(省 token),L3 只在需要深度知识时才拉取。三层各有分工,不互相抢上下文。
二、memory-engine:核心会话记忆
这是整套方案里最有价值的部分。简单说就是一个本地跑的 MCP Server,底层用 LanceDB 做向量存储,提供四个工具:
memory_store--- 存记忆memory_recall--- 搜记忆memory_forget--- 删记忆memory_stats--- 看统计
2.1 为什么不直接用 Auto Memory
Auto Memory 有三个硬伤:
- 容量限制:MEMORY.md 超过 200 行就会被截断
- 无衰减机制:三个月前存的过时信息和昨天存的新决策权重一样
- 无项目隔离:切到不同项目,记忆是混在一起的
memory-engine 解决了这三个问题。
2.2 混合检索 + Weibull 衰减
记忆不是存了就完事,关键是怎么找回来。
检索分两路并行:
- 向量检索:把查询语句 embedding 成向量,在 LanceDB 里做 ANN 搜索
- BM25 全文检索:传统关键词匹配,处理精确术语命中
两路结果融合后,再叠加 Weibull 衰减:
javascript
// 核心衰减公式
function weibullScore(ageDays, tier) {
const halfLife = { core: 90, working: 30, peripheral: 7 }[tier];
const beta = { core: 1.2, working: 1.0, peripheral: 0.8 }[tier];
const lambda = halfLife / Math.pow(Math.LN2, 1 / beta);
return Math.exp(-Math.pow(ageDays / lambda, beta));
}
什么意思呢?记忆按重要性分三档:
- 核心记忆(importance ≥ 0.8):半衰期 90 天,比如"项目用 PostgreSQL 15"
- 工作记忆(importance 0.5~0.8):半衰期 30 天,比如"上次部署时改了 nginx 配置"
- 外围记忆(importance < 0.5):半衰期 7 天,比如"今天 debug 了一个 CSS 对齐问题"
最终得分还会乘以访问频次------被多次召回的记忆会越来越"牢固",类似间隔重复学习。
2.3 项目隔离
通过 scope 参数实现:
memory_store({content: "偏好 dark mode", scope: "global"})
→ 跨项目共享
memory_store({content: "本项目用 4 空格缩进", scope: "project:my-api"})
→ 仅 my-api 项目可见
memory_recall({query: "缩进偏好", scope: "project:my-api"})
→ 搜 project:my-api + global 两个范围
在 A 项目里存的决策,不会污染 B 项目的记忆空间。但全局偏好(比如"我习惯用 vim 键位")在所有项目里都能找到。
2.4 实际效果
部署完成后,在 Claude Code 里直接测试:
> 调用 memory_stats 看看记忆统计
memory_stats 返回:
{
"total": 47,
"by_scope": {
"global": 12,
"project:blue-erp": 28,
"project:ai-tools": 7
},
"by_category": {
"decision": 15,
"lesson": 11,
"convention": 8,
"preference": 6,
"fact": 4,
"issue": 3
}
}
存一条记忆:
> 记住:部署测试环境时,jar 包超过 100MB 必须分包上传,否则宝塔面板会截断文件
memory_store 返回:
{
"id": "a3f7...",
"status": "stored",
"scope": "project:blue-erp"
}
过了几天,在新会话里检索:
> 我要部署到测试环境,有什么注意事项吗?
memory_recall 命中:
[
{
"content": "部署测试环境时,jar 包超过 100MB 必须分包上传,否则宝塔面板会截断文件",
"score": 0.847,
"category": "lesson",
"created_at": "2026-03-16"
},
{
"content": "测试环境 SQL 变更记录在 sql/update/update.sql,追加式写入",
"score": 0.623,
"category": "convention"
}
]
这就是"长期记忆"的实际体验------上次踩的坑,这次自动提醒你。
三、OpenViking:知识库层
OpenViking 解决的是另一个问题:项目文档和代码库的深度检索。
比如你有一个 ERP 项目,设计文档有 20 多份,会议纪要 10 份,数据库设计 4 份。每次开新会话都让 Claude 重新读一遍?不现实。
OpenViking 的做法是把这些文档预处理后存进向量库,需要时语义搜索拉取。它有个 L0/L1/L2 分层机制------搜索时先返回摘要(L1),需要详情时再加载全文(L2),省 token。
bash
# 添加项目文档到知识库
ov add-resource ~/projects/blue-erp/docs --preserve-structure --wait
# 语义搜索
ov find "借货归还的业务流程" --limit 3
# 输出
Results:
1. [0.89] viking://resources/blue-erp/docs/数据库设计/成品借货流程.md
"借货归还支持三种模式:全部归还、部分归还、借转销..."
2. [0.76] viking://resources/blue-erp/docs/会议纪要/0208会议.md
"借货消耗按借出数量计算,包含在价格里..."
3. [0.71] viking://resources/blue-erp/docs/问题确认.txt
"借货归还单需增加明细表..."
四、部署要点(踩坑记录)
整套系统的部署链路是:Ollama → OpenViking → memory-engine → Claude Code 集成。
4.1 Ollama 是基座
两个系统都依赖 Ollama 提供 Embedding。我选的是 bge-m3(1024 维,中英文混合场景最强),VLM 用 qwen2.5:14b。
踩坑:Ollama 必须配置开机自启。memory-engine 的 recall 操作需要调 Ollama 做 embedding,如果 Ollama 没运行,recall 会静默返回空结果------不报错,就是搜不到东西。排查了很久才发现。
4.2 OpenViking 的 Embedding 配置
OpenViking 的 embedding provider 不支持直接写 ollama,要写 openai,然后通过 api_base 指向 Ollama 的 OpenAI 兼容接口:
json
{
"embedding": {
"dense": {
"provider": "openai",
"model": "bge-m3",
"api_base": "http://localhost:11434/v1",
"api_key": "no-key",
"dimension": 1024
}
}
}
VLM 配置同理,api_key 填 "no-key" 就行。
4.3 memory-engine 的 SSE 多会话并发
这个是最大的坑。
MCP SDK 的 McpServer 只能绑定一个 transport。如果用 stdio 模式,每开一个 Claude Code 工作空间就 fork 一个进程,10 个窗口就 10 个进程,每个 80MB。
改用 SSE 模式后,只需一个守护进程。但第二个会话连上来时,server.connect() 直接抛 "Already connected to a transport" 把进程搞崩了。
最终的解决方案是工厂模式:每个 SSE 连接创建独立的 McpServer 实例,但共享全局的 LanceDB 连接和 OpenAI client。McpServer 实例本身极轻量(几 KB 内存),重资源全局单例共享。
javascript
// 每个连接创建独立 McpServer
function createMcpServer() {
const srv = new McpServer({ name: "memory-engine", version: "1.0.0" });
// 注册工具(共享全局 DB)
srv.tool("memory_store", ..., async (args) => memoryStore(args));
srv.tool("memory_recall", ..., async (args) => memoryRecall(args));
return srv;
}
// SSE 连接处理
if (req.url === "/sse") {
const transport = new SSEServerTransport("/messages", res);
const srv = createMcpServer(); // 每连接独立实例
await srv.connect(transport);
}
4.4 Zod 版本兼容
另一个坑:npm install zod 默认装 v4,但 MCP SDK 内部用的 zod-to-json-schema 不兼容 v4,tools/list 直接报 "Cannot read properties of undefined (reading '_zod')"。
解决:锁定 Zod v3:
bash
npm install "zod@^3.25"
4.5 L2 距离归一化
memory-engine 早期版本的 minScore 设了 0.3,导致 recall 几乎总是空结果。原因是 LanceDB 默认用 L2 距离(范围 0~2),归一化向量的 L2 距离大多在 0.5~1.5 之间,直接用 1 - distance 会产生负数。
修复:
javascript
// 错误:1 - distance 可能为负
score = 1 - r._distance;
// 正确:L2 归一化到 [0, 1]
score = Math.max(0, 1 - (r._distance || 0) / 2);
同时 minScore 从 0.3 降到 0.05。
五、自动记忆捕获
记忆系统最怕的不是存不进去,是用户忘了存。
实际使用中,90% 的有价值信息不是用户说"记住这个"产生的,而是隐含在对话过程中------纠错、追问、约束嵌入。
解决方案是在 CLAUDE.md 里写规则,让 Claude 自己判断什么时候该存:
| 用户说了什么 | Claude 的动作 |
|---|---|
| "不是 X 而是 Y" | 存纠正后的正确信息,importance: 0.9 |
| "零 X 依赖"、"不需要 Y" | 提取偏好约束,importance: 0.8 |
| "要求..."、"必须..." | 提取约束条件,importance: 0.8 |
| 技术决策、架构选择 | 原文存储,importance: 0.7~0.9 |
再配合 /save-session 命令做会话结束时的兜底提取,基本不会漏重要信息。
六、资源占用
在 24GB 内存的 Mac 上实测:
| 组件 | 常驻内存 | 说明 |
|---|---|---|
| Ollama bge-m3 | ~1.2 GB | Embedding,两个系统共享 |
| Ollama qwen2.5:14b | ~9.0 GB | VLM,按需加载,空闲自动卸载 |
| OpenViking Server | ~300 MB | Python HTTP 服务 |
| memory-engine MCP | ~80 MB | Node.js + LanceDB |
| 日常实际占用 | ~6 GB | VLM 空闲时自动卸载 |
16GB 内存的机器也能跑,把 VLM 换成 qwen2.5:7b,Embedding 换成 nomic-embed-text,总占用降到 ~8GB。
七、健康检查
部署完写了个一键检查脚本,日常开机跑一下确认所有服务正常:
bash
$ bash ~/check-memory-stack.sh
====== Memory Stack Health Check ======
[Ollama] OK models: bge-m3:latest, qwen2.5:14b
[Embedding] OK bge-m3 dim=1024
[OpenViking] OK :1933
[memory-engine] OK SSE :17823 sessions=2
=======================================
四个绿就说明一切正常。
八、总结
跑了两周下来,这套方案最明显的收益是上下文连续性。
以前开新会话,得花 5~10 分钟把项目背景重新说一遍。现在 Claude 自动从记忆里拉取之前的决策和规范,直接进入干活状态。
特别是跨项目切换的场景------从前端项目切到后端项目,每个项目的记忆是隔离的,不会串。但全局偏好(代码风格、工具选择)又是共享的。
适合的场景:
- 长期维护的中大型项目(记忆越积越有价值)
- 多项目并行开发(项目隔离避免混淆)
- 团队有固定的编码规范和业务规则(存一次,每次自动加载)
不太适合的场景:
- 一次性脚本、临时任务(用完即走,不需要记忆)
- 对隐私极度敏感的场景(记忆存在本地磁盘,需要自行管理)
整套方案全部开源组件,零云端依赖,数据不出本机。如果你也在用 Claude Code 做日常开发,可以试试。