claude-mem 数据完整性实战:Observation 内容哈希去重与项目名称冲突修复
本文聚焦 claude-mem 的一次 Issue 根因修复(TRIAGE-03):AI Agent 会话产生的 observation(观察记录)在 SQLite 中被无差别重复写入,以及基于目录名生成的项目标识在不同仓库间发生碰撞,导致两个不相关项目共享全部记忆数据。读完本文,你将理解 claude-mem 如何用"内容哈希 + 数据库唯一约束 + 迁移回填"解决 observation 去重,如何通过项目名解析规则保证跨项目数据隔离,并掌握空项目名兜底与 isProcessing 卡死自愈这两个配套防护机制的实现细节。
一、两个被确认的数据层根因
修复计划文档 .maestro/playbooks/2026-02-23-Issue-Triage/2026-02-23-Root-Cause-Fixes/TRIAGE-03-Data-Integrity-Deduplication.md 开宗明义:这是两个互相独立的数据 bug,属于"根因"而非"症状",对应解决了 #1061、#1158(重复 observation)、#1200(项目名碰撞)、#1046(空项目字符串)、#1052、#1036(isProcessing 卡死)等一系列 Issue。
1. Observation 重复写入
claude-mem 在 Agent 每次 PostToolUse 等事件后都会压缩生成 observation 记录。修复前的问题在于:observation 存储层执行的是裸 INSERT INTO observations,没有任何唯一性检查——没有内容哈希、没有幂等性保证、没有去重逻辑。同一次事件触发的 6~10 条语义上完全相同的 observation 会全部落库,造成数据膨胀与检索噪声。
2. 项目名称碰撞
修复前的项目身份标识来自 git rev-parse --show-toplevel 的 basename,也就是只取仓库目录名本身。这意味着:
~/work/monorepo与~/personal/monorepo两个完全不同的仓库,项目名都解析为monorepo;- 二者会共享全部数据——SQLite 中
observations/session_summaries表的project列相同,Chroma 向量库的 collection 名也都是cm__monorepo。
也就是说,一个命名巧合就能让两个项目的记忆互相"污染":搜项目 A 的记忆时会返回项目 B 的 observation。这正是把"项目名解析"视为数据完整性问题而非普通命名问题的原因。
二、Observation 内容哈希去重
1. 语义身份的哈希定义
去重的核心思路是:一条 observation 的语义身份由 (memory_session_id, title, narrative) 三元组决定——同一个记忆会话里,标题和叙述文本相同的两条记录本质上就是同一条。当前实现位于 computeObservationContentHash:
export function computeObservationContentHash(
memorySessionId: string,
title: string | null,
narrative: string | null
): string {
return createHash('sha256')
.update([memorySessionId || '', title || '', narrative || ''].join('\x00'))
.digest('hex')
.slice(0, 16);
}
两个值得注意的实现细节:
- 用
\x00(NUL 字符)作为字段分隔符,而不是文档最初设想的直接字符串拼接(memory_session_id + title + narrative)。这消除了字段边界歧义——例如(title="a", narrative="bc")与(title="ab", narrative="c")在直接拼接下会产生相同输入,加入分隔符后哈希必然不同; - 只取 SHA-256 十六进制前 16 位:16 个 hex 字符 = 64 bit 熵,在 observation 量级下碰撞概率可忽略,同时把存储宽度固定为 16 字节短字符串,便于建索引和展示。
2. 迁移 22:加列、回填旧数据、建索引
content_hash 列通过 schema 迁移引入。在 SessionStore 中,addObservationContentHashColumn() 依次执行三步:
-- 1) 加列
ALTER TABLE observations ADD COLUMN content_hash TEXT;
-- 2) 回填:给存量行一个"随机"哈希
UPDATE observations
SET content_hash = substr(hex(randomblob(8)), 1, 16)
WHERE content_hash IS NULL;
-- 3) 建普通索引(此时还不加唯一)
CREATE INDEX IF NOT EXISTS idx_observations_content_hash
ON observations(content_hash, created_at_epoch);
回填策略很关键:存量行不重算真实哈希(那需要重新计算 title+narrative 并可能与新数据产生意外冲突),而是用 randomblob(8) 给每行一个独一无二的"伪哈希"。效果是:旧行永远不会与任何新插入的记录命中同一 content_hash,不会"挡住"新数据的插入,也不会因重算哈希而产生错误的去重删除。
3. 从"30 秒窗口应用层检查"到数据库唯一约束的演进
修复计划最初设计的去重方式是应用层检查:INSERT 前先执行 SELECT id FROM observations WHERE content_hash = ? AND created_at_epoch > ?(30 秒窗口),命中则跳过插入、直接返回已有 id。文档中的理由是"应用层去重比数据库约束更简单、更灵活"。
但从当前源码结构看,这个方案已经演进出更彻底的形态——迁移 29(addObservationsUniqueContentHashIndex)先做了一次性历史数据清洗,再把去重下沉为数据库硬约束:
-- 第一步:NULL 哈希兜底
UPDATE observations
SET content_hash = '__null_migration_' || id || '__'
WHERE content_hash IS NULL;
-- 第二步:同 (memory_session_id, content_hash) 分组,保留 id 最小的一行
DELETE FROM observations
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY memory_session_id, content_hash
ORDER BY id
) AS duplicate_rank
FROM observations
)
WHERE duplicate_rank > 1
);
-- 第三步:建唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS ux_observations_session_hash
ON observations(memory_session_id, content_hash);
整个过程包在事务里(BEGIN TRANSACTION / COMMIT / ROLLBACK),任何一步失败都会回滚并记录日志,避免留下"清洗了一半"的脏状态。迁移还做了防御:若表里缺失 memory_session_id 或 content_hash 列,直接标记版本跳过,不执行清洗。
有了唯一索引后,写入路径可以依赖 SQLite 原子的冲突处理。当前 storeObservations 写入逻辑 如下:
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id,
content_hash, created_at, created_at_epoch, generated_by_model, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(memory_session_id, content_hash) DO NOTHING
RETURNING id
配合一段 TypeScript 兜底:若 RETURNING id 为空(说明命中冲突、未插入),再执行 SELECT id FROM observations WHERE memory_session_id = ? AND content_hash = ? 取出已有行 id 返回给调用方;两者都查不到则抛错。这样调用方拿到的 observationIds 里既有新插入的 id,也有命中去重后复用的旧 id,对上游完全透明——重复写入不会报错,只会静默归并。
相比最初 30 秒窗口方案,唯一约束的语义更强:不再受"窗口外重复"的漏网影响,且天然并发安全(两条同哈希写入在数据库层面由唯一索引串行裁决,不存在 check-then-insert 竞态)。
4. 测试佐证
去重行为有专门的回归测试 session-store-dedup.test.ts,迁移过程则由 session-store-migrations.test.ts 覆盖。修复计划中记录的验收基线是:新增 12 个数据完整性测试全部通过;npm test 中 21 个既有失败在干净分支上同样存在(同一数量),证明 TRIAGE-03 的改动零回归。
三、项目名称解析:从"目录名碰撞"到"仓库根 + worktree 复合键"
1. 原始修复:父目录 + 目录名
TRIAGE-03 针对碰撞的第一手方案是把项目名从 basename(gitRoot) 改为 basename(dirname(gitRoot)) + '/' + basename(gitRoot):
~/work/monorepo→work/monorepo~/personal/monorepo→personal/monorepo
父目录前缀提供了足够的区分度,又不至于引入完整绝对路径(路径含机器相关的家目录,会破坏跨机器一致性)。非 git 目录则对 basename(cwd) 套用同样的父目录模式。
一个刻意的工程取舍是:不做旧数据迁移。存量 observation 仍以短名字(如 monorepo)留在库里;由于搜索走 LIKE 或精确匹配项目名,按项目检索旧数据时短名记录依然可被找到,新数据则用新格式,二者共存。同时 Chroma 侧的 collection 名派生逻辑会把新格式中的 / 清洗为 _(既有的 sanitizer 正则天然处理),于是 work/monorepo 对应 collection cm__work_monorepo,与 personal_monorepo 天然隔离。
2. 当前实现:git 仓库根 + worktree 检测
从当前源码结构看,项目名解析已演进为独立模块 src/utils/project-name.ts,并吸收了后续 Issue(#2663 子目录/worktree 下项目名不稳定)的修复:
getProjectName 的解析规则:
- 空
cwd直接返回兜底名unknown-project,并记录 warn 日志——这与 TRIAGE-03 的"空项目字符串"防护一脉相承; - 先在目录内执行
git rev-parse --show-toplevel(findGitRepoRoot,L14-L29),拿到仓库根作为命名来源。用仓库根而非原始cwd,保证在任意子目录里启动会话时项目名稳定; - 非 git 目录(git 未安装、不在仓库内)回退到
cwd本身取basename; basename为空(例如在文件系统根目录)时,Windows 下识别盘符根生成drive-X形式的项目名,其他平台回退unknown-project。
更进一步的 getProjectContext 引入 worktree 感知(对应 #3262):通过 detectWorktree 检测 .git 文件,若当前目录是 git worktree,则返回复合键 父项目名/worktree 目录名,并暴露 allProjects 列表让上层同时查询父项目与该复合项目名下的记忆。这解决了 TRIAGE-03 时期尚未覆盖的场景:monorepo 的多个 worktree 之间既需要区分、又需要共享主库记忆。
会话标识与项目名的整体关系在 docs/SESSION_ID_ARCHITECTURE.md 中有专门说明,可以作为延伸阅读。
四、两个配套防护:空项目名兜底与 isProcessing 自愈
主修复之外,TRIAGE-03 还消灭了两个与数据完整性相关的边角故障。
空项目字符串竞态。 修复前 project 可能以空串/null 形态进入 INSERT,产生难以归类的孤儿记录。方案是一个 3 行的守卫:INSERT 前执行 const resolvedProject = project || getCurrentProjectName()——项目名为空时从 cwd 现场推导兜底值,而不是拒绝写入。这类"写入前解析兜底"的思路在后续实现中延续为 getProjectName 的 unknown-project 兜底路径。
isProcessing 标志卡死(#1036)。 消息队列中某条消息被标记为 processing 后,若处理进程崩溃,标志将永远无人清理,上层据此判断"系统正在处理",导致后续工作看似永远排队。修复方式不是增加周期性清理任务,而是在读取点自愈:修改 PendingMessageStore 的 hasAnyPendingWork(),在统计待处理工作之前,先把 updated_at_epoch 距当前超过 5 分钟的 processing 记录重置回待处理状态再计数。这是一个典型的"读路径附带修复副作用"(self-healing at the read site):无需常驻巡检进程,只要有任何一次状态查询发生,卡死超过 5 分钟的状态就会被顺手纠正。
五、小结:数据完整性修复的方法论
回顾 TRIAGE-03,这套修复给出了三个可复用的工程模式:
- 把重复写入建模为幂等写入:先定义记录的语义身份(
memory_session_id + title + narrative),再用短哈希 + 唯一索引 +ON CONFLICT DO NOTHING把"去重"变成数据库的天然行为,应用层只负责取回已有 id,而非维护易失的窗口状态; - 存量数据迁移讲究"无害化"而非"精确化":迁移 22 给旧行随机哈希、迁移 29 用
__null_migration_前缀兜底 NULL 行——都优先保证"迁移过程绝不误伤/误删",再谈一致性; - 项目隔离键要为"演进"留余地:从
basename到父目录/basename再到"git 仓库根 + worktree 复合键",每一代规则都兼容前代数据(短名记录仍可通过 LIKE 检索命中),避免了破坏性数据重写。
所有关键变更点都可以直接在仓库中核对:哈希计算见 src/services/sqlite/observations/store.ts,迁移 22/29 与写入冲突处理见 src/services/sqlite/SessionStore.ts,项目名解析见 src/utils/project-name.ts,行为回归测试见 tests/sqlite/session-store-dedup.test.ts 与 tests/utils/project-name.test.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00