Skip to content

[bug] dream 的 merge 被 dreamMergeGuard 永久拦死:提示词仍要求合并长保留类型,46 轮零合并、每轮照付满载 token #521

Closed

Description

@niangnniang

现象 / 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.js zh 版第 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」框

  1. @modusensus/dsh-mneme 0.9.4,dreamMergeGuard 保持默认 true,其余 dream 相关键全默认(allowCrossTypeMerge: false、dreamMaxMergePerRun: 40、dreamMaxSnapshotSize: 200)。
  2. 库里有 ≥3 条同类型(preference)且主题相近的记忆。类型必须相同,否则会先被 allowCrossTypeMerge 拦,看到的就不是这条。
  3. 触发一轮 dream:新增记忆数达到 dreamThresholdCount(默认 50),或临时把它调低。
  4. 查 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"}
  5. 连跑多轮:上面那几条 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

  1. added
    bug功能异常、崩溃、数据错误(agent 诊断 → 人工确认)
    on Oct 29, 2026
  2. modusensus commented on Oct 29, 2026

    @modusensus
    Member

    你的诊断完全成立,我逐行核了源码,问题比报告里写的还要直接:两个护栏从来没对齐过。

    根因

    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 test 2482 tests → 2481 pass / 0 fail / 1 skip,check-sync ✓。新增回归:真重复放行、同类型但只写「主题相近」仍拦、跨类型不因重复理由解锁、skip 原因三合一、面板消费聚合字段。

    PR:#522

    发版后会在这个 issue 里再说一声。另外——你那条 GROUP BY json_extract(value,'$.error') 的复现 SQL 很干净,我把它当成面板那张卡的验收口径了。

  3. modusensus commented on Oct 29, 2026

    @modusensus
    Member

    {"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 test 2509 tests / 2508 pass / 0 fail / 1 skip;CI 八条必需检查全绿(ubuntu/windows × 22/24 + 两条 test-host-legacy)。回归用例覆盖了你报的那条路径:五条长保留类型 × merge 被拦、以及命中「重复/过时」后被放行。\n\n发版后升到新版本即可。你这个报告的证据链(46 轮、280/299、merge 恒为 0、同一条 40 次)质量很高,省了我们一整天排查。"}

  4. moved this from 📋 Backlog to ✅ Done in dsh-mneme 任务看板on Oct 29, 2026
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

    bug功能异常、崩溃、数据错误(agent 诊断 → 人工确认)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions