Skip to content

[bug] 冲突冻结队列一次灌满 60 条:freeze 分支绕过三个上限,且面板静默截断到 50 #603

Closed

Description

@modusensus

现象

桉桉报告:「人审冲突冻结记忆好累,有的时候要审核很多条,比如现在要审核 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:1480 const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
  • lib/dream.js:1481-1485 const 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-373 const selectionPairs = config.conflictFreezeEnabled === true ? fresh : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));——freeze 模式不过台账预筛(注释明写「那一档只停车不送 LLM,台账与它无关」)。
  • lib/dream/sleep.js:407-413 if (config.conflictFreezeEnabled === true) { let frozen = 0; for (const p of selected) { if (parkPair(...)) frozen++; } return {...}; }——无预算、无上限。
  • lib/dream/sleep.js:438-439 const 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 邻居窗口」才省两个数量级。

⚠️ 最后一行 5.0 ms 是本地按 40×40 形状写的假想实现,不是 cognee 的真实代码路径(Loom 2026-11-07 核实:cognee session 层不做向量配对,见下方 cognee 段)。数字本身没错,但不能读作「cognee 验证过这条路」。

另一个可预期存量:阈值 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 条」。

⚠️ cognee 这两个 40 都是「文本条数」,不是「向量两两比对」。它判重靠 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-2137 GET /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:7081 ConflictsQueue 组件请求时没传任何参数: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。

不做项

Activity

  1. modusensus commented on Nov 7, 2026

    @modusensus
    MemberAuthor

    补记(Loom 2026-11-07,领域参照)

    Loom 核了 cognee 的两条路径,指出 freeze 分支与 cognee 的 LIVE 路径同构(都零 LLM),因此该补的是写时去重,不是「把已有待审对喂给模型」——后者在零 LLM 路径上没有对象可喂。这条已并入本 issue 的根因③。

    cognee 两条路径的防重复手段不同

    路径 是否调 LLM 防重复手段
    BATCH 是 EXISTING_LESSONS_SHOWN = 40:把已有 40 条教训回显给模型,让它别重提
    LIVE 否 写入时确定性去重

    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 对两条路径的失败策略故意不同:

    • LIVE:Fail-open -> [](agent_context_extraction.py:135 docstring)——trace 已落盘,失败就算了。
    • BATCH: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)。

  2. modusensus commented on Nov 7, 2026

    @modusensus
    MemberAuthor

    修订记录(2026-11-07,三人复核后)

    本 issue 的分层与第 2/4 层做法,经 Loom / Ephor / Persis 交叉复核后修订如下。修法分层以本条为准,前文表格保留为过程留痕。

    修订 1:第 2 层改成「服务端返回显式 similarity 字段」

    conflict_pending 建表(store.js:195-204)没有相似度列:

    id, run_id, memory_a, memory_b, reason, created_at, resolved_at, resolved_winner
    

    相似度只活在 reason 文案里,面板靠正则从自然语言反解析(client.js:7182-7183):

    const m = /(?:相似度|similarity)\s*([01](?:\.\d+)?)/i.exec(String(reason || ""));

    实测 60/60 能解析,且正则锚定「相似度 / similarity」前缀,不会误抓年份(设计意图见 client.js:7178-7181)。但这是从自然语言反解析结构化数据:文案一改、加新格式、换语言没同步,就静默变 null → 排序塌、相似度条消失。

    做法:不改 schema(考期不能动),在服务端解析一次 reason,在响应里加显式 similarity 字段,面板不再自己抠。将来真加 similarity REAL 列时客户端不用再改。

    修订 2:第 4 层并入第 5 层(不再单列)

    原先第 4 层写「写时去重,照抄 cognee 的 normalize_content()」。复核后判定照抄不成立:

    • cognee 的 normalize_content() 是 " ".join(text.strip().lower().split()),抓的是字面级重复(同一段文本重复写入)。
    • mneme 这 60 对是语义级重复(同一事实两种措辞),字面级去重一对都抓不到。
    • 语义级重复要靠 embedding 距离,而 embedding 距离正是 findPotentialConflicts 已经在算的。

    → 「写时去重」实际等价于「把冲突检测提前到写入侧」,与第 5 层(候选集边界)是同一处改动。合并为第 5 层,不再单列第 4 层。

    修订 3:60 对的性质用「bigram 包含率」,不用 Jaccard

    复核时两人用了不同尺子,实测对照(同一批 60 对):

    指标 结果
    标题完全相同 0 对
    正文归一化后完全相同 0 对
    Jaccard ≥ 0.8 1 对
    Jaccard ≥ 0.5 25 对
    Jaccard < 0.3 4 对
    bigram 包含率 ≥ 0.75 7 对

    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+ 对。 不是「一对近重复都没有」。

    修订后的分层

    层 改法 触及 是否改语义 时机
    1 freeze 分支接上限(slice(0, cap) 挪到 sleep.js:407 之前) sleep.js 否 待 Ephor 验证「截掉的对下一轮会不会再被选中」
    2 服务端返回显式 similarity 字段,面板按其排序 api.js + client.js 否 可立即
    3 面板 limit=200 + 「已显示 X/Y」(照 entities 现成模式) client.js 否 可立即
    4 (已并入 5) — — —
    5 候选集加边界 + 语义近重复走 merge 而非 conflict clustering.js / 写入侧 是 考后
    6 面板批量裁决 client.js + 新 API 否 考后

    第 1 层暂缓的理由:若被 slice 截掉的对下一轮不会被重新检测到(例如已被 used 去重或台账吞掉),那加 cap 就是静默丢弃——比一次灌满更糟。等 Ephor 验证结果再定;若确认会丢,需连 conflict_reviewed 台账一起改。

  3. modusensus commented on Nov 7, 2026

    @modusensus
    MemberAuthor

    修订记录 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) 49 / 60
    加上「相似」→ (?:相似度|相似|similarity) 60 / 60

    漏掉的 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 轮:

    写法 第 1 轮 第 2 轮 第 3–5 轮 结果
    裸 slice(0, cap) 新增 19 新增 0(40 条全是 no-op) 新增 0 队列卡在 79,永不再涨
    先滤已待审再截 新增 40 新增 6 0 106 条全部入队

    正确写法多一步:

    const pendingNow = new Set(
      service.listConflictPending({ limit: 200, includeResolved: false })
        .map((p) => (p.memory_a < p.memory_b ? `${p.memory_a}|${p.memory_b}` : `${p.memory_b}|${p.memory_a}`))
    );
    const notPending = selected.filter((p) => !pendingNow.has(pairKey(p)));
    const pool = perRunCap > 0 ? notPending.slice(0, perRunCap) : notPending;

    三、frozen 是虚数:日志说「停了 7 对」,实际入库 0

    这条是 Ephor 顺手挖出来的,独立于上面两条,但同一个函数。

    lib/store.js:3723-3748 saveConflictPending 遇到已在队列的未解决对时:

    if (existing) return toConflictPending(existing);   // 返回非 null

    而 lib/dream/sleep.js:392-399 的 parkPair 判的是 service.saveConflictPending({...}) != null → 非 null 就 frozen++。已在队列的对每轮都被算成「新停了一对」。

    他实测 10 个轮次的日志报数 vs 实际入库:

    轮次 日志 frozen 实际入库
    2026-10-30T23:45 2 0
    2026-11-02T07:45 7 0
    2026-11-02T07:45(另一次) 7 0
    2026-11-02T23:45 6 1
    2026-11-03T23:45 8 2
    2026-11-05T07:50 2 1
    2026-10-23T19:34 6 3

    最小改法: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 层(三处一起改,缺一不可)

    改动 文件 不改语义
    ① freeze 分支接 sleepConflictMaxPairsPerRun 预算 lib/dream/sleep.js:407-413 是
    ② 先滤已待审再 slice lib/dream/sleep.js(新 pairKey 辅助) 是
    ③ saveConflictPending 返回 fresh,parkPair 只数 fresh lib/store.js:3723-3748 + lib/dream/sleep.js:392-399 是

    第 2、3 层(服务端显式 similarity 含「相似」前缀、面板 limit=200 + 「已显示 X/Y」)仍是可立即做的,且第 2 层现在有 11 条实证缺口。

    第 5、6 层不变:候选集加边界改语义,考后;面板批量裁决考后。

  4. modusensus commented on Nov 7, 2026

    @modusensus
    MemberAuthor

    修订记录 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)
    • cognee 判重靠 normalize_content() 精确文本匹配(session_context_builder.py:473)
    • session 层唯一的向量是 session_embeddings.py:11-13 的 VECTOR_RECALL_TOP_K = 3,那是 QA 轮次召回,跟找冲突无关

    所以 5.0 ms 测的是按 40×40 形状写的假想实现,不是 cognee 的真实路径。 数字本身没错(40×40 确实比 7656² 快),但不能读作「cognee 验证过这条路」。

    拆开写之后,第 5 层的性质变了:

    说法 成立吗
    cognee 用 40×40 窗口做冲突检测,5 ms,可行 ❌ 假依据
    cognee 的设计原则是「窗口有界」,可以借鉴 ✅
    「新增 vs 邻居窗口」的向量实现在 mneme 里是新路,无先例 ✅
    风险:会漏掉「两条老记忆之间才浮现的矛盾」 ✅ 需另配定期全扫
    窗口大小 k 取多少 未验,要单独量

    正文已按这个口径改写(第 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.frozen vs 实际入库行数)。这也是为什么 fresh: true/false 那条修法不只是「让日志好看」,是让这个指标第一次成为可核对的数。

  5. modusensus commented on Nov 7, 2026

    @modusensus
    MemberAuthor

    判定:待定(根因仍在 main 上,但队列已清空、近 13 小时未复现)

    线上现状(只读查 D:\DSH\.dsh\memory\memory.db)

    • conflict_pending 共 645 行,pending = 0,全部已裁决;最近一次 resolved_at = 2026-11-07T12:18:22Z,11-07 当天审了 87 条 → 那 60 条已经人工清完了。
    • 60 条之后的新轮次落库:1 / 2 / 1 条(11:55 / 10:49 / 10:01)→ 未再爆量,但根因没修。

    根因复核(基线 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 = ? 对账:

    汇总 报数 实入 差
    20 轮合计 184 158 26

    差得最狠的三轮: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 层最小补丁(不改语义,只接线)

    1. freeze 分支加 sleepConflictMaxPairsPerRun cap;
    2. 截断前先滤掉「已 pending / 已 judged」的对——裸 slice(0, cap) 会每轮重复挑前 N 对,后面的永远轮不到(饥饿);
    3. parkPair 改成只有真新增才 frozen++(saveConflictPending 回 inserted 标志,或比对返回行的 id)。

    判定:待定——第 1 层是行为改动(改了每轮停多少辆车),按考期冻结令需要你明说范围才动。

  6. modusensus commented on Nov 8, 2026

    @modusensus
    MemberAuthor

    交卷: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-sync 95 文件一致。变异自证 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)。

  7. modusensus commented on Nov 8, 2026

    @modusensus
    MemberAuthor

    补交:9b2b2ef 已进 PR #610

    Ephor 报的那 101 行未提交改动已收尾提交并推送,PR 现在 +919 / −64,19 文件。

    改动是把你点的 newerFirst(留较新)换成 infoFirst(留信息量更大),三级兜底:长度 → updated_at → id。

    为什么必须改,实测口径(conflict_pending 表 reason 里解析出的相似度,readOnly):

    阈值 对数 新的那条不短 新的那条更短 平均短多少
    ≥0.95 20 9(45%) 11(55%) 132 字
    ≥0.92 61 28(46%) 33(54%) 158 字
    ≥0.90 117 50(43%) 67(57%) 165 字
    ≥0.88 213 91(43%) 122(57%) 187 字
    ≥0.85 347 134(39%) 213(61%) 193 字

    即「留新的」在过半情况下归档掉的是更完整的那条。典型:

    • 旧(371 字):用户同时开 web profile 和 desktop profile 时出现开不了新对话/会话异常,用户自己…
    • 新(307 字):用户同时开 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 两档已过,其余在跑。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions