Repository navigation
Description
现象 / What happened
dreamMergeGuard(默认 true)会让所有涉及长保留类型的 merge 决定被拦,但提示词里没有任何一句告诉模型「这些类型不能 merge」——相反它明确要求 merge。结果是死的:模型每轮照提,校验器每轮全拦,一条都合不成,而每轮的成本照付。
实测一天(本地 2026-10-28 全天,46 轮 dream):
项 | 值 -- | -- dream 轮数 | 46(status:degraded 45 / failed 1) skipped 决定总数 | 299 其中 blocked by dreamMergeGuard | 280(93.6%),且 action 全部是 merge 所有 skipped 里 merge 的条数 / 被拦条数 | 290 / 280(96.6%) 涉及的不同记忆条数 | 82 条,type 全部是 preference,其中 81 条至今仍是 active 单轮 input_count | 46 轮全部 200(窗口饱和) 整轮 applied 合计 | 375 条(archive 76 / conflict 17 / 其余 keep)——只有 merge 这一路是 0我自己锁死自己:模型提的永远是同一簇 preference → 全被拦 → 什么也没变 → updated_at 不动 → 下一轮同样的条目还在 200 条窗口里 → 再提一次。46 轮下来,合并 0 条,token 一分没少花。
根因是两边没对上:
- 提示词(
lib/lang.jszh 版第 26 行)里,长保留类型的禁区只对archive说过:archive 仅用于确属「重复或已过时」的条目;rejected_solution / constraint / pitfall / preference / pattern 属长保留类型,除非确属重复或过时,否则不要归档
- 同一份提示词的任务清单第 1 条,明确要求合并:
1. 识别主题相近的条目 → merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
- merge 侧唯一的约束是
仅合并同类型条目(type 相同)(对应allowCrossTypeMerge: false),完全没提哪些类型不能合。
- 校验器(
lib/dream/decisions.js:18、:219)是硬拦:
两个护栏不对称,这是关键:archive 护栏会检查 reason 是否命中 ARCHIVE_RATIONALE_RE(「重复/过时」),所以真正重复的 archive 是能过的;merge 护栏没有这条逃生门——只要类型在这个集合里,内容再怎么真重复也一律拦。
模型没错,护栏也没错,它们俩从来没对上过。
复现步骤 / Steps to reproduce
粘到「复现步骤 / Steps to reproduce」框
- @modusensus/dsh-mneme 0.9.4,dreamMergeGuard 保持默认 true,其余 dream 相关键全默认(allowCrossTypeMerge: false、dreamMaxMergePerRun: 40、dreamMaxSnapshotSize: 200)。
- 库里有 ≥3 条同类型(preference)且主题相近的记忆。类型必须相同,否则会先被 allowCrossTypeMerge 拦,看到的就不是这条。
- 触发一轮 dream:新增记忆数达到 dreamThresholdCount(默认 50),或临时把它调低。
- 查 dream_runs.skipped,会出现:
{"index": 4, "action": "merge",
"ids": ["4a39c7b2-…", "ca31049b-…", "446668a6-…"],
"error": "decision[4]: merge of long-retention type(s) preference is blocked by dreamMergeGuard"} - 连跑多轮:上面那几条 id 反复出现,memories.updated_at 不变,dream_runs.applied 里 merge 恒为 0。
一行 SQL 能一眼看出来(SQLite json1,better-sqlite3 自带):
SELECT json_extract(value,'$.error') AS err, COUNT(*) AS n
FROM dream_runs, json_each(dream_runs.skipped)
GROUP BY err ORDER BY n DESC;
返回里那条 merge of long-retention type(s) preference is blocked by dreamMergeGuard 会占掉全部 skipped 的九成以上,而 merge 的成功数是 0。
期望行为 / Expected behavior
我不请求关掉 dreamMergeGuard——它默认开启是有理由的(#339 那次体检里,10/26 条被吃掉的约束正是靠它捞回来的)。问题是它现在是全有或全无,没有第三种设置:
- dreamMergeGuard: false → preference 的 merge 全放过去,包括模型真提的跨主题乱配(我们这边就出现过把一条 SOUL.md 骨架记忆和一条「身份证文件状态」配成一对——同类型,allowCrossTypeMerge: false 挡不住);
- dreamMergeGuard: true(默认)→ preference 的 merge 永久全死,且每轮照付满载 token。
任何一个修法都能打断这个循环,按我建议的优先级:
A. 提示词与校验器对齐(最小改动,两行,最希望先上)
在 lang.js 的硬规则里,照 archive 那句的样子加一句 merge 禁区(zh / en 各一处):
merge 仅适用于非长保留类型;rejected_solution / constraint / pitfall / preference / pattern 之间的相近条目请输出 keep
模型不再提 → 成本直接归零 → 什么也没丢(这些条目本来就一条也合不成)。这是我唯一希望「单独发一版」的改动。
B. 给 merge 和 archive 同一条逃生门
若该 merge 的 reason 命中 ARCHIVE_RATIONALE_RE(重复/过时)且所有 id 同类型,则允许穿过护栏。覆盖「确实重复」的真需求,又不至于把两条相近但不同的约束揉成一段糊话。
C. 按类型配置
例如 dreamMergeGuardedTypes: ["constraint","pitfall","rejected_solution"],让愿意接受权衡的人自己把 preference / pattern 放出来。语义变了,建议先跑一轮 eval 再上。
另外一个请求(不属于 bug,但这条 bug 就是被它藏住的):希望面板或 dream_runs 里能有一列「按 skip 原因归类的计数」。现在从外面看,status: degraded + summary_stored: 1 + applied: 3–18 完全是健康轮的样子——不写 SQL 把 skipped 里的 error 聚合一遍,根本看不出「一整个 action 类型 100% 失败」这种事。这个 bug 我们跑了整整一天才发现。
环境 / Environment
- dsh-mneme 版本 / plugin version: 0.9.4(npm,已在 package.json 里钉死,不用 ^)
- DSH 桌面端版本与通道 / DSH version & channel: 桌面端,profile: desktop(Electron 43.3.0 / Windows 版)
- 操作系统 / OS: Windows 11
- Node 版本 / Node version: v22.22.2
- 安装方式 / install method (npm / dsh-market): npm
- 相关路由模型 / relevant model routes: deepseek-official:deepseek-flash(dream_consolidate / dream_summarize / entity_extract / summarize_compress 全部走这一条)
其它相关配置(其余全默认):
dreamThresholdCount: 50
dreamThresholdChars: 20000
dreamMinIntervalMinutes: 60 → 事发后已临时调到 360,只为降频止血,不是修复
dreamMergeGuard: true(默认)
dreamMaxMergePerRun: 40(默认)
dreamMaxSnapshotSize: 200(默认)
allowCrossTypeMerge: false(默认)
embedding: 本地 bge-small-zh-v1.5(512 维)
日志 / Logs
A. 单条 skipped 原文(dream_runs.skipped,2026-10-28T15:02:41.224Z 那轮):
B. 按原因聚合(这就是建议里那条面板计数器要的东西):
实测结果(299 条 skipped):
reason | n -- | -- merge of long-retention type(s) preference is blocked by dreamMergeGuard | 280 memory too young (< 24h) | 6 merge ids span multiple types (project, history) | 3 merge ids span multiple types (preference, history) | 3 unknown id "…" | 3 merge ids span multiple types (decision, project) | 1 archive of long-retention type(s) preference needs a duplicate/outdated rationale | 3⚠ 这里有个坑值得说:如果直接
GROUP BY json_extract(value,'$.error'),280 条会被拆成 20 组——因为 error 前面带了decision[N]:的序号。我第一版就是这么数的,看到「每组十几条、一个都不显眼」,差点没认出这是一整类失败。这也是建议「面板按归一化原因计数」的直接来由。
同期 merge 成功条数:0。
C. 那一簇 preference 被反复提了多少次(自锁的硬证据):
实测前 5:
46 轮里,同一条记忆被提出去合并 40 次,一次没成。 单这一条就占了这轮成本的一部分,46 轮都在为同一个决定付钱。
口径说明(免得数字对不上):全部数字取本地日 2026-10-28(UTC 2026-10-27T16:00 ~ 2026-10-28T16:00),46 轮。skipped 是用 json_each 展开后按 error 计数,不是按轮数。初稿里写过的 642 / 257 两个数是错的(那是把别的字段也算进来了),以这一版为准。
需要完整数据我这边都留着:dream_runs 全行、那 82 条记忆的 type / 长度 / 内容(可脱敏,只留结构和相似度矩阵)、以及同一时间窗的 llm_audit_logs(token 与花费)。要哪一份说一声。
披露 / Disclosure
- 本报告由 AI 辅助整理(人工审核后提交)/ Drafted with AI assistance, reviewed by a human
Activity
modusensus commentedon Oct 29, 2026
你的诊断完全成立,我逐行核了源码,问题比报告里写的还要直接:两个护栏从来没对齐过。
根因
archive有逃生门,merge没有:archive护栏:reason命中ARCHIVE_RATIONALE_RE(重复|过时|陈旧|过期|已被取代|冗余|旧版|废弃|stale|outdated|obsolete|dup…|superseded|redundant|deprecated|expired|replaced)就放行长保留类型——真重复的 archive 是能过的;merge护栏:只要合并对象命中ARCHIVE_GUARDED_TYPES(preference / pattern / rejected_solution / constraint / pitfall),无论内容再怎么真重复,一律整条跳过,没有门。而
src/lang.js的巩固提示词里,【任务】第 1 条明确要求「识别主题相近的条目 → merge」,【硬性规则】里的长保留类型禁区只对 archive 说过——merge 侧唯一的约束是「仅合并同类型条目」。所以你说的自锁完全对:模型提的永远是同一簇 preference → 全被拦 → 什么也没变 →
updated_at不动 → 下一轮同样条目还在 200 条窗口里 → 再提一次。模型没错,护栏也没错,它们俩从来没对上过。顺带确认一个你没提到但同源的:
sleepActionSet="full"那条链路(src/dream/sleep.js)用的conflictFull提示词,第 1 条也把 merge 列为同义重复的首选动作、同样没有类型禁区,所以那个档位也中同一枪。默认档(conflict)不中招。改了什么(PR #522)
按你的 A + B,一个 PR:
A. 提示词与校验器对齐(zh/en × consolidation + conflictFull 四处):照 archive 那句加 merge 禁区——长保留类型仅当确属重复或过时才可 merge,且
reason必须写明;只是主题相近请输出keep。模型不再提,成本归零,而什么也没丢(这些条目本来就一条也合不成)。B. merge 护栏加逃生门:条件与 archive 护栏同一条词表,且要求所有 id 同类型——跨类型合并本就另受
allowCrossTypeMerge约束,逃生门不跟着放宽。reason只写「主题相近」不会命中词表,这是故意的:相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。默认值不变,
dreamMergeGuard仍是true。你说不请求关掉它,我也认为不该关——E8 那批数据(10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉)是真的。两条链路共用同一validateDecisions(src/dream.js:1244+src/dream/sleep.js:393),改一处两处都受益。你附带的那个请求也做了
面板看不出「一整类动作 100% 失败」——这个观察我觉得比 bug 本身更值得记:
status: degraded+applied: 3从外面看完全是健康轮的样子,你们跑了一整天才发现,那不是你们的疏忽,是可观测性的缺口。现在
GET /dream-status多了skippedCount(原始条数)与skippedReasons(按归一化原因聚合,按条数降序),状态页在最近巩固卡下方多一张告警色卡,最多列 4 类、其余折叠成「另有 N 类」。归一化只剥decision[N]:前缀与具体 id/类型集合,其余措辞原样——你说的那个坑(朴素GROUP BY把 280 条拆成 20 组)现在由插件侧统一处理,不用自己写 SQL 了。按(action, reason)二元组计数:同一句原因在 merge 和 archive 上是两回事,混在一起会误导。验证
npm test2482 tests → 2481 pass / 0 fail / 1 skip,check-sync✓。新增回归:真重复放行、同类型但只写「主题相近」仍拦、跨类型不因重复理由解锁、skip 原因三合一、面板消费聚合字段。PR:#522
发版后会在这个 issue 里再说一声。另外——你那条
GROUP BY json_extract(value,'$.error')的复现 SQL 很干净,我把它当成面板那张卡的验收口径了。fix(dream): 评审五条 —— 跳过明细上首屏、归一化剥全前缀、否定理由不开口子(#521 #522)
Merge pull request #522 from slow-stack/fix/521-merge-guard-symmetry
modusensus commentedon Oct 29, 2026
{"body":"修好并合进 main 了(merge commit
c1c4087,PR #522)。你诊断得完全对,三条我都逐行核过源码,全部成立。\n\n## 为什么会出现这个自锁\n\n两个护栏一直不对称:\n\n| | 长保留类型 | 逃生门 |\n|---|---|---|\n|archive| 默认拦(ARCHIVE_GUARDED_TYPES) | 有——reason命中「重复/过时」词表就放行 |\n|merge| 默认拦(同一张表) | 没有,内容再怎么真重复也一律跳过 |\n\n而src/lang.js的巩固提示词【任务】第 1 条明确要求「识别主题相近的条目 → merge」,【硬性规则】里的类型禁区只对 archive 说过。模型照着提示词办事,护栏照着类型表办事,两边从来没对上过——你说的「模型没错,护栏也没错」,就是这件事的准确描述。\n\n## 改了什么\n\nA. 提示词与校验器对齐(src/lang.js,zh/en × consolidation + conflictFull 共四处):照 archive 那句补上 merge 禁区——长保留类型仅当确属重复或过时才可 merge,reason必须写明;只是主题相近请输出keep。模型不再提那些注定被拦的决策,满载 token 的无效轮次直接归零。\n\nB. 给 merge 补上和 archive 同一条逃生门(src/dream/decisions.js):reason命中「重复/过时」词表、且所有 id 同类型时放行。这样「确实重复」的真需求有着落,又不会把两条相近但不同的约束揉成一段糊话。\n\n逃生门这边我们比 archive 多做了一步:加了否定词判据(NEGATED_RATIONALE_RE),\"并不重复,只是主题相近\"/\"not a duplicate\"/\"并不过时\"这类理由不会因为命中子串而被放行——这是评审时发现的,本 PR 新开的口子自己堵上。\n\n没动dreamMergeGuard的默认值,如你所说它默认开启是有理由的(#339 那批实测里 10/26 条被丢的约束就是走 merge 这条「更精炼摘要」通道没的)。\n\n## 顺带做了你要的那个计数\n\n面板上现在有「按归一化原因分组的 skipped 计数」,首屏可见(不藏在折叠区)。归一化会剥掉decision[N]:序号前缀、模型可控的引号文本、具体类型集合,其余措辞保留,所以同一类失败会归成一桶,直接看得出「一整个 action 类型 100% 失败」——不用再手写 SQL 聚合。\n\nsql\n-- 仍然可以用,但现在面板直接给了\nSELECT json_extract(value,'$.error') AS err, COUNT(*) AS n\nFROM dream_runs, json_each(dream_runs.skipped) GROUP BY err ORDER BY n DESC;\n\n\n## 验证\n\nnpm test2509 tests / 2508 pass / 0 fail / 1 skip;CI 八条必需检查全绿(ubuntu/windows × 22/24 + 两条 test-host-legacy)。回归用例覆盖了你报的那条路径:五条长保留类型 × merge 被拦、以及命中「重复/过时」后被放行。\n\n发版后升到新版本即可。你这个报告的证据链(46 轮、280/299、merge 恒为 0、同一条 40 次)质量很高,省了我们一整天排查。"}fix(dream): merge 护栏与 archive 护栏对齐,提示词补上 merge 类型禁区(slow-stack#521)
docs(changelog): 补上 #507 / #508 / #513 三条外部 PR 的条目与贡献者小节
docs(readme): 填上 v0.9.5 的版本表主题、中英头条与路线图状态格