Repository navigation
Description
现象
桉桉报告:「人审冲突冻结记忆好累,有的时候要审核很多条,比如现在要审核 60 多条。它好像不按照时间规定来整理记忆」。
排查结论:不是排序错了,是结构上一次能灌进任意多条,而且 60 条在同一秒落库,排序键全相同 → 视觉上等于随机。
实测数据(live 库 memory.db readOnly,2026-11-07)
当前队列
conflict_pending总 641 行:pending 60、resolved 581。- 60 条全部来自同一次 sleep 轮次
run_id=9b493c3f-c0b8-4e43-b163-5ba233c1f14f,created_at全落在2026-11-07T07:50:44.775Z ~ 07:50:44.835Z——60 毫秒内。 - 队列里没有其他轮次残留:不是积压,是一轮灌满。
- 60 对涉及 120 条记忆,彼此不重叠(一条记忆只进一对,
sleep.js:376-383的used去重生效)。 - 120 条两边都还存在、都未归档(60/60)→ 全是真实待裁决对,不是脏数据。
- 类型分布:pitfall 38、project 36、history 20、constraint 14、pattern 6、decision 6。
- 重要度:imp2 6、imp3 40、imp4 50、imp5 24;两条都 imp≥4 的 26 对。
- reason 分档:同 scope「相似度 0.85~0.95」49 条;跨作用域 11 条。
- 相似度分布:min 0.85 / p25 0.86 / p50 0.88 / p75 0.89 / max 0.95;49/60 落在 0.85–0.89。
历史轮次分布(关键)
conflict_pending 按 run_id 分组,共 254 个轮次入过队:
| 每轮入队条数 | 轮次数 |
|---|---|
| 中位数 | 1 |
| 最大 | 85 |
单轮入队 ≥20 条的大轮次只有 4 次,合计 198 条 = 全库 641 条的 31%:
| run_id | 入队 | 时间 | 当时库内活跃记忆 |
|---|---|---|---|
| cecb26b2 | 85 | 2026-10-17 | 4,928 |
| ead3b626 | 32 | 2026-10-19 | 5,660 |
| 72cc1a26 | 21 | 2026-11-06 | 7,588 |
| 9b493c3f | 60 | 2026-11-07 | 7,653 |
→ 1.6% 的轮次产出了 31% 的队列。这是尾部问题,不是稳态问题。
队列内容性质
用 bigram 包含率(短句内容被长句覆盖的比例)分档:
| 档位 | 条数 |
|---|---|
| contain ≥ 0.90(几乎完全同义) | 0 |
| 0.75–0.90(高度同义) | 7 |
| 0.45–0.75(部分重叠) | 40 |
| < 0.45(低重叠) | 13 |
两条记忆的创建时间差:<1 小时 24 对、1 小时–1 天 18 对、>1 天 18 对。同 source 22 对。
→ 大半不是「新旧记忆打架」,是同一次蒸馏把一件事吐成两条,几乎同时落库。
口径说明:bigram 包含率衡量字面重合,与 mem0 的「conveys the same thing」不是同一把尺子,7/60 只能读作「字面层面可自动合并的下限」,不等于按语义规则实际能判出的条数。要得到可比数字需让模型按规则实判一遍,本 issue 未做。
三处根因(不同性质,不可合并成一个修法)
① 接线缺失:freeze 分支绕过全部三个上限
三个上限都已存在且有默认值,但 freeze 分支一个都没用:
| 配置项 | 默认 | 位置 | freeze 分支用了吗 |
|---|---|---|---|
conflictFreezeMaxPending |
100 | config.js:527 |
否(只接在 dream 路径) |
sleepConflictMaxPairsPerRun |
60 | config.js:694 |
否(在它之前 return) |
sleepConflictMaxPairs |
12 | config.js:690 |
否(同属 LLM 分批) |
conflictFreezeMaxPending 全仓只有两处使用,都在 dream 路径:
lib/dream.js:1480const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;lib/dream.js:1481-1485const pendingNow = service.countConflictPending(); const budget = Math.max(0, maxPending - pendingNow); const toFreeze = conflictsToFreeze.slice(0, budget);
lib/dream/sleep.js 里 maxPending / countConflictPending / FreezeMaxPending 零命中。
freeze 分支是「全量停车」:
lib/dream/sleep.js:371-373const selectionPairs = config.conflictFreezeEnabled === true ? fresh : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));——freeze 模式不过台账预筛(注释明写「那一档只停车不送 LLM,台账与它无关」)。lib/dream/sleep.js:407-413if (config.conflictFreezeEnabled === true) { let frozen = 0; for (const p of selected) { if (parkPair(...)) frozen++; } return {...}; }——无预算、无上限。lib/dream/sleep.js:438-439const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60; const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;——在 freeze 的 return 之后。
性质:不是设计缺失,是接线缺失。修法与 sleep.js:439 同形。
② 真缺设计:候选集无边界,全库 O(n²)
lib/dream/clustering.js:113-124:
const pairs = [];
for (let i = 0; i < memories.length; i++) {
for (let j = i + 1; j < memories.length; j++) {
if (memories[i].type !== memories[j].type) continue;
const sim = cosineSimilarity(vectors[i], vectors[j]);
if (sim > threshold) pairs.push({ a: memories[i], b: memories[j], similarity: sim });
}
}memories 来自 sleep.js:245 的 service.all()(滤掉 archived/forgotten/summary/document),全库。n=7,653 时约 2,900 万次 cosine 比较。
实测成本(Ephor 2026-11-07,live 库等价 SQL + 真实向量复刻):
| 环节 | 耗时 |
|---|---|
service.all() 全库(n=14,076,显式列排除 embedding) |
354 ms |
只取 id+type(冲突检测最小集) |
83 ms |
加载 7,656×1024 维向量并 JSON.parse |
820 ms(常驻 59.8 MB float64) |
| O(n²) 两两比较 | 见下表 |
O(n²) 分档(用真实向量逐字复制 findPotentialConflicts):
| n | 耗时 | 命中对(>0.85) |
|---|---|---|
| 500 | 32 ms | 0 |
| 1,000 | 66 ms | 0 |
| 2,000 | 345 ms | 3 |
| 7,656(全量) | 9,229 ms | 138 |
→ 一次 sleep 冲突相位的纯计算约 354 + 820 + 9,229 ≈ 10.4 秒,不含向量补齐与 LLM 调用。
「加边界」的收益——注意口径(Ephor 2026-11-07 更正:早期那组 27/82/155 ms 是同进程跑完全量扫描后测的 JIT 热态,不是首轮成本)。冷启动实测:
| 扫描形态 | 耗时 |
|---|---|
| 全库 7,656 × 7,656(现状) | 9,229 ms |
| n=2,000 的 O(n²) 单段 | 345 ms |
| 新增 N 条 vs 全库 7,656,N=40 | 636 ms |
| 新增 N 条 vs 全库 7,656,N=100 | 2,033 ms |
| 新增 N 条 vs 全库 7,656,N=200 | 4,422 ms |
| 40 新 × 40 已有邻居窗口 | 5.0 ms |
关键不在 N 的大小,在形状:N 每加一条都要和 7,656 条各比一次,所以 N=100 就要 2 秒;而「新的一批 vs 它自己的邻居窗口」两边都小。只把「本轮新增条数」限流,省不到一个数量级;换成「新增 vs 邻居窗口」才省两个数量级。
另一个可预期存量:阈值 0.85 在 7,656 条库上稳定产出 138 对候选(本轮只冻 60 对,差额来自 sleep.js:376-383 的「一条记忆每轮最多进一对」贪心约束)。138 对相似度 top5 为 0.962 / 0.952 / 0.951 / 0.942 / 0.938,中位数 0.873——这批高相似对说明写入侧近重复抑制的缺口比 60 条显示的更大。
对比 mem0:add() 的 Phase 1 先用向量检索取 top_k=10 条既有记忆(mem0/memory/main.py:928-933),把 id 映射成 0-9(:936-940),再连同新事实送进裁决提示词(mem0/configs/prompts.py:427-435)。候选集有界,天然不会爆。
对比 cognee:BATCH_TRACE_LIMIT = 40(喂给 LLM 的 trace 条数,agent_context_extraction.py:278,313)、TRACE_EXTRACTION_INTERVAL = 10(每 10 条才触发一次,:58)、MAX_BATCH_LESSONS = 5(每次最多产出 5 条,:52)、EXISTING_LESSONS_SHOWN = 40(把多少条已有教训写进 prompt,:215),四层叠加 = 「无论库多大,单轮最多增加 5 条」。
normalize_content() 精确文本匹配(session_context_builder.py:473);session 层里唯一的向量是 session_embeddings.py:11-13 的 VECTOR_RECALL_TOP_K = 3,那是 QA 轮次召回,跟找冲突无关。→ 可以借鉴的是「窗口有界」这个设计原则,不是它的实现。 「新增 vs 邻居窗口」的向量实现在 mneme 里是新路,没有外部系统验证过;风险是会漏掉「两条老记忆之间才浮现的矛盾」,需要另配定期全扫,且窗口大小 k 取多少要单独验。
性质:mneme 四层中一层都没有。改语义,属考期冻结范围。
③ 该换成写时去重:同轮产出的近重复本不该入队
24/60 对的两条记忆创建时间差 <1 小时,22/60 同 source。这类是同一次蒸馏的产物,两条几乎同时落库,根本不该进冲突队列。
cognee 的零 LLM 路径(build_live_agent_candidates,agent_context_extraction.py:111-122)也没有「喂已有」动作——因为它不调模型,没有对象可喂。它靠写入时确定性去重:session_context_builder.py:454-475 在同一 (profile, section) 内用 normalize_content()(" ".join(text.strip().lower().split()))比对,命中走 :477-488 的合并分支——不新增,只把 source_trace_ids 追加进已有那条。
→ mneme 的 freeze 分支与 cognee 的 LIVE 路径同构(都零 LLM),该补的是写时去重,不是「把已有待审对喂给模型」。
关联 #254(写入准入:把「这条该不该进库」前移到 LLM 之前)。
修法分层(按风险从低到高)
| 层 | 改法 | 改动量 | 是否改语义 | 建议时机 |
|---|---|---|---|---|
| 1 | freeze 分支接 conflictFreezeMaxPending,与 sleep.js:439 同形 slice(0, budget) |
一行 + 测试 | 否(只是封顶,超出的对留到下一轮,不丢) | 可立即 |
| 2 | 队列排序加相似度倒序(listConflictPending 或入队时把相似度落成可排序列) |
小 | 否 | 可立即 |
| 3 | 写时去重:同轮同 source 且 normalized 相同/近似不产生对 | 中 | 是(改入库行为) | 考后 |
| 4 | 候选集加边界:改为「本轮新增/更新 vs 其 top-k 邻居」 | 中 | 是(会漏掉「两条老记忆之间才浮现的矛盾」,需另配定期全扫) | 考后 |
| 5 | 面板批量裁决(多选 + 批处理端点) | 大(client.js + 新 API) |
否 | 考后 |
第 1 层与第 2 层可立即做:都不改检测语义,只是让队列不再一次灌满、并让人先看到最像重复的。
补充观察(已升级为正式缺陷 ④)
④ 面板静默截断:显示 50 条,计数徽章显示 60
lib/api.js:2119-2137GET /api/dsh-mneme/conflicts默认limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50)),返回{ items, total: service.countConflictPending() }。lib/client.js:7081ConflictsQueue组件请求时没传任何参数:apiFetch("/api/dsh-mneme/conflicts")→ 只拿到 50 条。ConflictsQueue组件(client.js:7065起约 160 行)全文没有读过total(\.total在该组件范围内零命中)。- 结果:面板列 50 条,徽章(走
/dream-status的pendingConflicts)显示 60,第 51–60 条翻不到,也没有任何提示。
这与 #597(总数只数一页)方向相反但同属一类:一个地方截断了,另一个地方报全量,两者没有对齐。属桉桉最反感的「假成功」模式。
现成可抄的模式:client.js:3125-3160 与 3423-3424 的 entities 列表已经做对了——
const [entityTotal, setEntityTotal] = useState(null);
setEntityTotal(j.totalExact === true && typeof j.total === "number" ? j.total : null);
...
entities !== null && entityTotal !== null && entityTotal > entities.length即「拿全量数 → 与已加载条数比较 → 显示『已显示 X/Y』」。冲突队列照此办理即可,不需要新造模式。
修法分层(按风险从低到高)
| 层 | 改法 | 改动量 | 是否改语义 | 建议时机 |
|---|---|---|---|---|
| 1 | freeze 分支接 conflictFreezeMaxPending / perRunCap,与 sleep.js:439 同形 slice(0, cap),位置挪到 :407 之前 |
一行 + 测试 | 否(只是封顶,超出的对留到下一轮,不丢) | 可立即 |
| 2 | 队列排序加相似度倒序(listConflictPending 或入队时把相似度落成可排序列) |
小 | 否 | 可立即 |
| 3 | 面板按 limit=200 请求 + items.length < total 时显示「已显示 X/Y」(照 entities 现成模式) |
小 | 否 | 可立即 |
| 4 | 写时去重:同轮同 source 且 normalized 相同/近似不产生对 | 中 | 是(改入库行为) | 考后 |
| 5 | 候选集加边界:改为「本轮新增/更新 vs 其 top-k 邻居」 | 中 | 是(会漏掉「两条老记忆之间才浮现的矛盾」,需另配定期全扫) | 考后 |
| 6 | 面板批量裁决(多选 + 批处理端点) | 大(client.js + 新 API) |
否 | 考后 |
第 1–3 层可立即做:都不改检测语义,只是让队列不再一次灌满、让人先看到最像重复的、并把截断说清楚。
重要提醒:第 1 层改完后,「一轮攒 60 条」会变成「一轮攒 N 条,但要攒好几轮」——总量不变,只是分摊。若目标是让总量下降,要动的是 findPotentialConflicts 的阈值(sleepConflictStrictness)或第 5 层的候选集边界,不是上限。
另一处可做但要验(不列为本 issue 范围)
service.all() 取全字段 354 ms,而冲突检测真正只需要 id+type(83 ms),省 271 ms。但 withoutPaused(sleep.js:223-226)与后续过滤依赖完整行对象(要读 type/archived/forgotten/scope 等),换字段需连带改,不是一行 SELECT 的事。单列一条,不混进本 issue。
不做项
- 不改
findPotentialConflicts的算法(第 4 层,考后)。 - 不改写入准入判定(第 3 层,关联 [Feature] 写入准入(non-write 判定):把「这条该不该进库」前移到 LLM 之前 #254)。
- 不动面板 UI(第 5 层)。
- 不删历史
conflict_pending行、不批量裁决现有 60 条——那是桉桉的裁决权,本 issue 只处理「不再一次灌满」。
Activity
modusensus commentedon Nov 7, 2026
补记(Loom 2026-11-07,领域参照)
Loom 核了 cognee 的两条路径,指出 freeze 分支与 cognee 的 LIVE 路径同构(都零 LLM),因此该补的是写时去重,不是「把已有待审对喂给模型」——后者在零 LLM 路径上没有对象可喂。这条已并入本 issue 的根因③。
cognee 两条路径的防重复手段不同
EXISTING_LESSONS_SHOWN = 40:把已有 40 条教训回显给模型,让它别重提LIVE 的写法在
cognee/infrastructure/session/session_context_builder.py:454-475:同一(profile, section)内用normalize_content()(" ".join(text.strip().lower().split()))比对,命中走:477-488的合并分支——不新增,只把source_trace_ids追加进已有那条。→ 结论:mneme 的 freeze 分支应走 LIVE 那套(写时去重),不是 BATCH 那套(喂已有)。本 issue 第 4 层即此。
另一条值得照搬的失败策略(与本 issue 相邻,建议单列)
cognee 对两条路径的失败策略故意不同:
Fail-open -> [](agent_context_extraction.py:135docstring)——trace 已落盘,失败就算了。raise(agent_context_extraction.py:297-303),注释原文an LLM outage must propagate so the caller's watermark never advances over traces that were never extracted。规则可读作:已经落盘的失败可以吞,还没处理的失败必须抛(否则水位线会跳过没处理的数据)。
这条正好对上 mneme 的既有毛病:
phaseConflicts在saveConflictPending抛错时只logger?.warn?.(...)然后return false(sleep.js:396-399),失败被吞成「这对没停成车」,而本轮照常往下走。结果是「检测失败」与「没检测到」在外部读起来一样——这是本 issue 的相邻问题,不在本次修法范围内,但建议单开一条记着。证据等级:
agent_context_extraction.py:111-122,135,297-303与session_context_builder.py:454-488全部 A 级直读(Loom 2026-11-07)。modusensus commentedon Nov 7, 2026
修订记录(2026-11-07,三人复核后)
本 issue 的分层与第 2/4 层做法,经 Loom / Ephor / Persis 交叉复核后修订如下。修法分层以本条为准,前文表格保留为过程留痕。
修订 1:第 2 层改成「服务端返回显式 similarity 字段」
conflict_pending建表(store.js:195-204)没有相似度列:相似度只活在
reason文案里,面板靠正则从自然语言反解析(client.js:7182-7183):实测 60/60 能解析,且正则锚定「相似度 / similarity」前缀,不会误抓年份(设计意图见
client.js:7178-7181)。但这是从自然语言反解析结构化数据:文案一改、加新格式、换语言没同步,就静默变null→ 排序塌、相似度条消失。做法:不改 schema(考期不能动),在服务端解析一次 reason,在响应里加显式
similarity字段,面板不再自己抠。将来真加similarity REAL列时客户端不用再改。修订 2:第 4 层并入第 5 层(不再单列)
原先第 4 层写「写时去重,照抄 cognee 的
normalize_content()」。复核后判定照抄不成立:normalize_content()是" ".join(text.strip().lower().split()),抓的是字面级重复(同一段文本重复写入)。findPotentialConflicts已经在算的。→ 「写时去重」实际等价于「把冲突检测提前到写入侧」,与第 5 层(候选集边界)是同一处改动。合并为第 5 层,不再单列第 4 层。
修订 3:60 对的性质用「bigram 包含率」,不用 Jaccard
复核时两人用了不同尺子,实测对照(同一批 60 对):
Jaccard 分布 min/p25/p50/p75/max = 0.27 / 0.37 / 0.43 / 0.55 / 0.80;bigram 包含率 = 0.33 / 0.48 / 0.56 / 0.67 / 0.87。
判「同一件事记了两遍」该用 bigram 包含率(单向),不该用 Jaccard(对称):803 字与 290 字说同一件事时,Jaccard 只有 0.31,但包含率 0.78——短句几乎全被长句覆盖,这正是「重复」的形态,而对称指标会系统性低估它。
→ 结论:字面几乎相同的 0 对,但「短句内容基本被长句覆盖」的 7 对、「一半以上被覆盖」的 25+ 对。 不是「一对近重复都没有」。
修订后的分层
slice(0, cap)挪到sleep.js:407之前)sleep.jssimilarity字段,面板按其排序api.js+client.jslimit=200+ 「已显示 X/Y」(照 entities 现成模式)client.jsclustering.js/ 写入侧client.js+ 新 API第 1 层暂缓的理由:若被
slice截掉的对下一轮不会被重新检测到(例如已被used去重或台账吞掉),那加 cap 就是静默丢弃——比一次灌满更糟。等 Ephor 验证结果再定;若确认会丢,需连conflict_reviewed台账一起改。modusensus commentedon Nov 7, 2026
修订记录 2(2026-11-07,Persis + Ephor + Amarre 复核后)
评论 1 之后又查出三条,都改变分层判断:第 2 层从「防将来」变成「现在就在漏」,第 1 层从「接上限」变成「接上限 + 防饥饿 + 修计数」。
一、
similarity现在就有 11 条解析不出来(第 2 层性质变了)评论 2 说「服务端返回显式
similarity字段」时,我把问题当成将来的健壮性问题。实测不是。lib/client.js:7183现行正则:/(?:相似度|similarity)\s*([01](?:\.\d+)?)/i它只认「相似度」,但写入点
lib/lang.js:222在跨作用域那一档写的是 「跨作用域相似 ${s}(疑似同一内容落在两个归属下,请裁决归属/去重)」 —— 是「相似」不是「相似度」。拿 live 库当前 60 条 pending 实测:
(?:相似度|similarity)(?:相似度|相似|similarity)漏掉的 11 条全部是跨作用域对(
跨作用域相似 0.94 / 0.90 / 0.90 / 0.89 / 0.89 / 0.89 / 0.88 / 0.87 / 0.87 / 0.86 / 0.85)。这不只是「少显示一个数字」:跨作用域对的裁决要点就是「相似到这个程度了,到底是同一件事落在两个归属下,还是两件不同的事」——恰恰是最需要相似度才能判断的那一批,面板上显示为空。用户只能凭标题猜。
→ 所以第 2 层不是「将来加字段更稳」,是现在有 11 条在静默丢字段。同一处正则改一个词就能补上,但那只是止血;服务端返回显式字段才是终点(
conflict_pending建表lib/store.js:195-204无相似度列,将来加similarity REAL时客户端不用改)。二、第 1 层不能只写
slice(0, cap):会饥饿我原先给的写法是
perRunCap > 0 ? selected.slice(0, perRunCap) : selected。Ephor 指出这会饥饿,我复现后确认。findPotentialConflicts每轮全量重扫,没有水位线。所以被截掉的对不会丢(下一轮还会被扫出来),但命中顺序是稳定的 →slice(0, 60)永远取前 60 对,第 61 名之后永远进不了预算。他按 cap=40、用真实向量与真实 138 对候选模拟连续 5 轮:
slice(0, cap)正确写法多一步:
三、
frozen是虚数:日志说「停了 7 对」,实际入库 0这条是 Ephor 顺手挖出来的,独立于上面两条,但同一个函数。
lib/store.js:3723-3748 saveConflictPending遇到已在队列的未解决对时:而
lib/dream/sleep.js:392-399的parkPair判的是service.saveConflictPending({...}) != null→ 非 null 就frozen++。已在队列的对每轮都被算成「新停了一对」。他实测 10 个轮次的日志报数 vs 实际入库:
最小改法:
saveConflictPending返回值带fresh: true / false(toConflictPending(existing)那条加fresh: false),parkPair只对fresh === true计数。不引入新字段语义,只是把「插入」与「已存在」区分开。这条不修的话,第 1 层改完也看不出效果——日志照样报一堆 frozen,实际入库是 0。
四、饥饿为什么一直没暴露
她历史 14 轮都是「该时刻未解决 = 0」——每轮清完。所以裸 slice 从来没卡住过。这次 60 条一挂上不清理,下一轮就会开始踩饥饿:138 对候选、cap 60、已入队 60 条不再计入,前 60 名里混着那 60 条 no-op,真正新的对可能一对都进不去。
五、修订后的第 1 层(三处一起改,缺一不可)
sleepConflictMaxPairsPerRun预算lib/dream/sleep.js:407-413slicelib/dream/sleep.js(新 pairKey 辅助)saveConflictPending返回fresh,parkPair只数 freshlib/store.js:3723-3748+lib/dream/sleep.js:392-399第 2、3 层(服务端显式
similarity含「相似」前缀、面板limit=200+ 「已显示 X/Y」)仍是可立即做的,且第 2 层现在有 11 条实证缺口。第 5、6 层不变:候选集加边界改语义,考后;面板批量裁决考后。
modusensus commentedon Nov 7, 2026
修订记录 3(2026-11-07,Loom 核实 cognee 后,已回写正文)
正文两处已改,这里记为什么改:
一、5.0 ms 那个对比要拆成两件事
Ephor 的冷启动数据我全收(N=100 是 2,033 ms,不是 155 ms;早期那组是 JIT 热态)。但 Loom 核出那个 5.0 ms 的对比有根本偏差:
BATCH_TRACE_LIMIT = 40是喂给 LLM 的 trace 条数(agent_context_extraction.py:278,313),不是向量配对EXISTING_LESSONS_SHOWN = 40是写进 prompt 的已有教训条数(:215)normalize_content()精确文本匹配(session_context_builder.py:473)session_embeddings.py:11-13的VECTOR_RECALL_TOP_K = 3,那是 QA 轮次召回,跟找冲突无关所以 5.0 ms 测的是按 40×40 形状写的假想实现,不是 cognee 的真实路径。 数字本身没错(40×40 确实比 7656² 快),但不能读作「cognee 验证过这条路」。
拆开写之后,第 5 层的性质变了:
正文已按这个口径改写(第 5 层结论段 + cognee 对照段各加一处⚠️ )。
二、性能结论要读成「形状」,不是「N 的大小」
冷启动表说明的是:N 每加一条都要和 7,656 条各比一次,所以 N=100 就要 2 秒。限流「本轮新增条数」省不到一个数量级;换成「新增 vs 邻居窗口」才省两个数量级。
这个区别决定第 5 层是「把
service.all()换掉」还是「只加个 slice」——后者连分摊都做不到(见修订记录 2 的饥饿模拟)。三、Loom 收回的那条检验方法,值得记在 issue 里
他原本想用「
conflict_pending里有没有跨多轮run_id但 memory 对相同」来验饥饿。这条结构上不可能命中:store.js:3222-3225对已存在的未解决对直接return toConflictPending(existing),不会插第二行,所以饥饿发生时表里只有一行,count(DISTINCT run_id)恒等于 1。→ 饥饿在表里不可见,只能从「报数 vs 事实」的差上看(
dream_runs.decisions.frozenvs 实际入库行数)。这也是为什么fresh: true/false那条修法不只是「让日志好看」,是让这个指标第一次成为可核对的数。modusensus commentedon Nov 7, 2026
判定:待定(根因仍在 main 上,但队列已清空、近 13 小时未复现)
线上现状(只读查
D:\DSH\.dsh\memory\memory.db)conflict_pending共 645 行,pending = 0,全部已裁决;最近一次resolved_at= 2026-11-07T12:18:22Z,11-07 当天审了 87 条 → 那 60 条已经人工清完了。根因复核(基线
main @ 041e8f30,仍在)dsh-mneme/src/dream/sleep.js:407-413:freeze 分支是一个for (const p of selected)直接 park 到底,无预算、无 cap。:438-439:const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;与const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;都在 freeze 的return之后。:371-374:selectionPairs在 freeze 档直接取fresh,不过台账预筛。新证据:
frozen报数确实虚高(这条 issue 里只提了方向,这里给数字)取最近 20 个
run_type='sleep'轮,把decisions.conflicts.frozen和SELECT COUNT(*) FROM conflict_pending WHERE run_id = ?对账:差得最狠的三轮:
90107c7e报 8 入 2、5e40beb0报 6 入 1、024f65f2报 7 入 0。机制:
dsh-mneme/src/dream/sleep.js:395的parkPair用saveConflictPending({...}) != null判「停车成功」,而saveConflictPending对已存在且未解决的对会return toConflictPending(existing)(非 null)→ 已经在队列里的对被算成"新停了一辆车"。所以「frozen」这个数字回答的是"命中了几对",不是"新增了几条待审"。第 1 层最小补丁(不改语义,只接线)
sleepConflictMaxPairsPerRuncap;slice(0, cap)会每轮重复挑前 N 对,后面的永远轮不到(饥饿);parkPair改成只有真新增才frozen++(saveConflictPending回 inserted 标志,或比对返回行的 id)。判定:待定——第 1 层是行为改动(改了每轮停多少辆车),按考期冻结令需要你明说范围才动。
modusensus commentedon Nov 8, 2026
交卷:PR #610 已开
#610
三个提交都推上去了,rebase 到
7461768(main 最新)后跑的:49e8790第 1 层:freeze 分支接预算 + perRunCap + fresh 检查62606bc第 2 层:similarity列 + 队列倒序 + 面板「已显示 X/Y」7fbd60f第 3 层:conflictAutoResolveEnabled自动裁决档(默认关,0.95 可调)Ephor 你点的那个坑已经并进第 3 层了:
phaseDemotion现在读demoteToSummary的返回值,updated才 push,否则记demoteSkipped(src/dream/sleep.js:921-923)。虚报那 43%–99% 的台账不再撒谎。跨 scope 永不自动裁决、失败回落人工队列也写进去了。全量测试:2918 tests / 2917 pass / 0 fail / 1 skipped / 56.6s。
check-sync95 文件一致。变异自证 7 条,其中一条值得记:去掉 ORDER BY 的 NULL 守卫仍然全绿——SQLite 在 DESC 下本来就排 NULL 在后,那是冗余不是覆盖。阈值 0.95 是量出来的:live 队列 345 条带相似度的历史行里,20 条(5.8%)≥0.95、28 条(8.1%)≥0.94。这一档砍峰值,不清空队列。
@ephor 可以开审 diff 了,CI 正在跑(gitleaks / zizmor / dependency-review 已过,test 四个矩阵 pending)。
modusensus commentedon Nov 8, 2026
补交:
9b2b2ef已进 PR #610Ephor 报的那 101 行未提交改动已收尾提交并推送,PR 现在 +919 / −64,19 文件。
改动是把你点的
newerFirst(留较新)换成infoFirst(留信息量更大),三级兜底:长度 → updated_at → id。为什么必须改,实测口径(
conflict_pending表 reason 里解析出的相似度,readOnly):即「留新的」在过半情况下归档掉的是更完整的那条。典型:
用户同时开 web profile 和 desktop profile 时出现开不了新对话/会话异常,用户自己…用户同时开 web profile 和 desktop profile 的 dsh-mneme 时开不了新对话蒸馏压缩过的一版反而
updated_at更晚。口径对齐:mem0
configs/prompts.py:217明写「两句话说的是同一件事时,留信息量最大的那条,不是留最新的」;cognee 反过来取更新的,那是反例。测试
node --test test/*.test.js:2920 tests / 2919 pass / 0 fail / 1 skipped / 54.6s(比上一轮多 2 条,就是新加的两个用例)。check-sync:95 文件一致。newerFirst→ 「长的那条存活」用例红(17 pass / 1 fail)。updated_at兜底 → 仍全绿。这条要如实说:夹具里两条 id 的排序恰好与时间序同向,所以第二级兜底没被夹具覆盖到,它是确定性保证,不是被用例验证过的行为。CI
上一轮
workflow-hygiene红了一次,日志是curl: (22) ... 504下载 actionlint 失败——网络问题不是代码问题,已gh run rerun --failed。当前 ubuntu-22、test-host-legacy 两档已过,其余在跑。docs(sleep): record the three boundaries of the conflict auto-resolve…