记忆系统改造:worktree 分片断裂 + 缺回合后复盘机制
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 背景
对本机 Maker Memory 做了一次实测盘点,发现两个独立问题:一个让记忆在主力开发场景下完全读不到,另一个让记忆质量随时间劣化。两者叠加,导致「记忆」这个功能目前对开发类会话基本不产生正收益。
本 issue 记录现状证据、根因和改造方案,供排期讨论。
---
## 问题一:worktree 会话读不到主仓记忆(功能性缺陷)
### 现象
在 `/Users//Code/Github/cindy/.cindy-worktrees/` 这类 worktree 会话里,`memory_search` 恒返回 0 命中。设置正常(`memory-settings.json` 为 `{"maker": true}`),MCP 工具正常响应(返回 `ok: true` 而非 `MAKER_MEMORY_NOT_READY`),但索引库是空的。
实测两个分片的 FTS 记录数:
| 分片 | fts.db | `memory_fts` 记录数 |
|---|---|---|
| 主仓 `.../Github/cindy` | 483 KB | **80** |
| worktree `.../cindy/.cindy-worktrees/` | 24 KB(空库基线) | **0** |
### 根因
`packages/maker-core/src/memory/storage.ts` 的 `buildMemoryScopeKey()`:
```ts
export function buildMemoryScopeKey(workingDir: string, remoteHostId?: string | null): string {
return remoteHostId ? `ssh:${encodeURIComponent(remoteHostId)}:${workingDir}` : workingDir;
}
```
本地会话的 scope key 就是 workdir 绝对路径原样,不做任何归一化。worktree 路径 ≠ 主仓路径 → 不同 key → `manager.getStore()` 返回两个独立 store。
`grep -i "worktree|git-common-dir|rev-parse" packages/maker-core/src/memory/` 零命中——整个 memory 模块没有任何 git worktree 处理。
四个接入点全部原样透传 `opts.workingDir`:
- `agents/claude-code/index.ts:1061`
- `agents/codex/index.ts:2700`
- `agents/pi/index.ts:993`
- `lizi-mcps/src/memory/_shared.ts:50`
### 值得注意的不对称
同一个文件为了「两台机器的同名路径不能互串」做了相当细致的工作——复合 key、`encodeURIComponent` 保证单射、sha256 防目录名碰撞,注释里还留着两轮 review 的结论(R4 P2 / R5 P2)。
也就是说「不该共享的必须隔离」被反复推敲过,但对称的另一半「**该共享的必须聚合**」从未进入设计视野。
### 影响面
本机 105 个分片实测构成:
| 类型 | 数量 | 说明 |
|---|---|---|
| worktree 分片 | 54 | 全部或几乎全部为空 |
| dialogue 一次性目录 | 38 | 一个对话一个目录,永不复用 |
| 真实项目分片 | 13 | 只有这些有效 |
105 个分片里 **62 个没有 MEMORY.md**。
放大因素有三条,叠在一起正好打中本仓:
1. 本仓是 PR-first + worktree 隔离的工作流,`docs/dev-rules/development-workflow.md` 有专章,worktree 是主路径而非边缘场景
2. worktree 是 Cindy **自己创建**的(`.cindy-worktrees/`)——产品自己造的路径,产品自己的记忆系统不认
3. 越按规范用 worktree 开发,记忆越失效
### 修复方向
在 `buildMemoryScopeKey` 里加一层 worktree 归一化:
```
git rev-parse --path-format=absolute --git-common-dir
```
worktree 内该命令返回主仓的 `.git` 路径,取父目录即主仓根。非 git 目录 / 命令失败时回落现有行为。
三个约束:
1. **必须带缓存**——`getStore()` 是热路径,不能每次 spawn git 子进程
2. **存量迁移**——空分片可直接删(零内容零风险);少数有内容的 worktree 分片(实测有 1.4KB / 1.0KB 等几个)必须先合并进主仓分片再删
3. **不要动 SSH 分支**——那部分单射性质经过 review 确认,改动容易破坏隔离承诺
---
## 问题二:记忆只在对话中途随手写,没有任何事后整理(质量缺陷)
### 现状机制
当前链路很短:`packages/maker-core/src/memory/system-prompt.ts`(890 字节)把「什么时候该存」注入主模型 system prompt,主模型在对话过程中自行判断并调 `memory_write`。
- 没有独立的分析模型
- 没有回合结束后的复盘环节
- 没有定期的合并 / 去重 / 过期清理
`memory/flush-controller.ts` 本来是朝这个方向设计的(token 接近上限时提醒 LLM 抢在压缩前写 memory),但注释明确写着当前是「A 轻版:**只打日志,不注入,不让 LLM 写**」,仍在验证阶段。
### 后果(实测)
主仓分片 80 条记忆,19.5 KB 索引每次会话固定进 context(约 6–7K token):
- **重复**:「有 PR 总管盯盘时 owner 不要自行轮询 GitHub」这**一条**规则被写成了 **6 个独立条目**,正文合计 5.7 KB,索引占 6 行
- **过期未清**:4 条已终态的项目归档仍占索引,其中一条的描述自己写着「当前状态需重新查询 GitHub」
- **配比失衡**:feedback 42 / project 27 / user 1 / reference 0——绝大多数是「别这么干」的行为纠正,几乎没有稳定的事实性参考
- **digest 冗余**:10 个 compaction digest 占 80 KB,大小全部集中在 7444–7560 字节区间,内容高度重叠
重复的成因很直接:每次被用户纠正就当场新增一条,system prompt 里虽然写了「先查有没有同主题的,有就 update」,但在对话中途执行任务时这条基本不会被认真执行。
**这不是靠改 prompt 措辞能解决的——缺的是事后环节。**
---
## 方案:回合后后台复盘(post-turn background review)
核心思路:**把「记什么」从主对话里拿出来,交给一个回合结束后才启动的后台分身**。
### A. 触发时机
- 在**回复已经交付给用户之后**触发,永远不与用户任务争抢模型注意力
- 异步执行(不阻塞下一轮输入)
- 按计数器节流,memory 与 skill 建议用**两个独立计数器**:
- memory:每 N 个用户轮次(建议默认 10)
- skill/规则类:每 N 次工具迭代(干活密集的轮次更该触发)
- 两者同时命中时**合并成一次复盘**,不要开两个分身
- 无人值守场景(scheduler / cron)应可关闭——没有人在环,复盘收益低而成本实打实
### B. 分身的构造
复盘分身 fork 自当前会话,继承 provider / model / 凭证 / 已缓存的 system prompt,重放本轮对话快照,只回答一个问题:**这轮有什么值得沉淀的?**
有几条隔离要求是必须的,漏掉任意一条都会出真事故:
| 要求 | 不做会怎样 |
|---|---|
| **禁止写会话库** | 分身的 harness turn("复盘一下这轮对话…")会写进用户真实 session。用户下一轮发言时,agent 把那条注入的 user message 当成常驻指令,"变成"复盘员,拒绝执行真实任务 |
| **工具白名单** | 分身只应拿到 memory / skill 管理工具,其余运行时拒绝。同时白名单要跟随 profile 开关——memory 被用户关掉时不能把读写工具塞给分身 |
| **禁止 compaction** | 分身与父会话共享 session id,若它赢了压缩竞争,会把父会话转到一个新 child,宿主永远不会 adopt |
| **不结束父会话** | 分身生命周期极短,close 时不能顺手把父会话的 session 行给 finalize 了 |
| **抑制状态输出** | 分身的「迭代预算耗尽」「限流重试」「压缩警告」会绕过正常输出通道冒泡给用户 |
| **审批回调改为自动拒绝** | 无人在环,任何需要确认的操作必须 deny,不能退化成等待输入 |
### C. 成本控制
这是决定方案能不能上线的关键。要点:
- **让分身的出站请求命中父会话已经暖好的 prefix cache**:继承父的 cached system prompt,并保持 `tools[]` 与父**字节一致**(要显式跳过回合间的 MCP 刷新,否则晚连接的 MCP 工具会破坏 tools 数组的字节一致性 → cache key miss)
- 这一条不是微优化。同类实现有公开实测:让复盘 fork 命中父 cache 相比重建 system prompt,**端到端成本下降约 26%**
- 支持把复盘**路由到更便宜的模型**。但要注意:换模型必然 cache miss,此时不应重放完整快照(那是纯冷写),改为重放**摘要**
- 每次复盘的量级大致在 1 个额外 turn(数万 token 的 cache read + 少量输出),需要在设计阶段就给出可观测的成本口径
### D. 复盘指令的写法
两份 prompt,分工要分清:
- **memory 侧**:用户透露了什么关于他自己的信息(角色、偏好、处境)?用户表达了什么关于「你该怎么表现」的期待?
- **规则/skill 侧**:这轮有什么该沉淀成「这类活该怎么干」的知识?
有两条措辞上的设计值得照抄:
**① 明确「不作为也是失败」。** 复盘 prompt 里应当写明:大多数会话至少应产出一次更新,什么都不做是错失学习机会,而不是中性结果。否则模型会默认走「没什么特别的」这条最省力的路。
**② 明确把用户的负面反馈列为一等信号。** 「太啰嗦」「别这样排版」「为什么解释这么多」「你老是 X 我很烦」这类抱怨,应当直接触发**规则更新**,而不是只记一条 memory。
### E. 创建 vs 修改:用优先级阶梯而不是自由裁量
这是解决「重复条目」的关键,也是本 issue 最想引入的一条设计。
不要让模型自由决定新建还是修改,而是规定**能用靠前的就绝不用靠后的**:
1. **改这轮实际用到的那条**(本次会话加载过 / 读过的规则)
2. 改一条已有的**大类条目**(加一节、加个坑、放宽触发条件)
3. 在已有大类下**加附件**(细节文档 / 模板 / 可重跑脚本)
4. **最后才是新建**
新建那一档要卡死:名字必须是**类级别**的,不得是 PR 号、报错串、功能代号、`fix-X / debug-Y` 这类一次性名字。**如果这个名字只对今天的任务成立,那它就是错的**,退回 1/2/3。
按这套阶梯,前面那 6 条重复条目会收敛成 1 条。
同时需要一份**保护名单**:官方随包内容、用户手写内容、已 pin 的内容,后台分身一律不得改写——它是无人在环的自主写入方,越权修改用户资产不可接受。
### F. 可见性
复盘发生了什么应当对用户可见,但要**克制**:只显示最终的成功动作,不显示过程。形态建议是回合末尾的一行摘要,例如:
```
🧠 memory +telegram_replies_keep_short: "回复控制在 5 行内…"
🧠 rules ~pr-followup: 合并 3 条重复
```
---
## 建议的落地顺序
| 阶段 | 内容 | 依赖 |
|---|---|---|
| **P0** | worktree 分片归一化 + 存量分片迁移/清理 | 无。独立可做,做完记忆才真正开始工作 |
| **P0.5** | 一次性清理现有 80 条:合并 6 条重复、清 4 条过期归档、digest 只留最近 1–2 份 | 无。人工或半自动,索引可瘦约 30% |
| **P1** | 回合后后台复盘(A–D),先只开 memory 侧 | P0 |
| **P2** | 优先级阶梯 + 保护名单(E),扩展到规则/skill 侧 | P1 |
| **P3** | 可见性(F)、成本口径与观测 | P1 |
P0 和 P0.5 是止血,不做的话后面都是白搭——记忆写进了读不到的地方,复盘做得再漂亮也没用。
---
## 待讨论
1. 复盘分身的默认模型:跟随主模型(cache 暖、贵)还是默认路由到便宜模型(cache 冷、需摘要重放)?
2. 节流参数(每 N 轮)应该做成设置项还是硬编码?
3. `flush-controller.ts` 的 A 轻版验证日志跑了一段时间,阈值命中数据是否已经可以支撑升级决策?还是直接被本方案取代?
4. 规则/skill 侧的沉淀落在哪里——继续走 memory 的 `feedback` 类型,还是单独一套 skill 存储?
Contributor guide
Research direction
Start with packages/maker-core/src/memory/storage.ts and trace manager.getStore() through the four listed integration points; inspect flush-controller.ts and system-prompt.ts for the existing memory lifecycle. Separate the independently deliverable P0 worktree normalization and migration from the later post-turn review design. Done requires the selected phase's behavior, migration or isolation constraints, and cost or observability criteria to be verified without changing the SSH scope-key branch.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, typescript
- Domain
- ai, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100