Skip to content

[0.9.3] 解析失败后确定性无限重试同一窗口:转录裸放 user 消息会被模型当成「该我回话了」 #493

Closed

Description

@strange2000

现象

@modusensus/dsh-mneme@0.9.3 的会话蒸馏(summarize_compress)在同一个会话窗口上连续失败 20 次,面板「工作动态」刷出一整屏相同的 invalid summary JSON(间隔 2–5 分钟,持续约 3.5 小时)。用户侧看起来像「记忆一直在提炼失败」。

实际上是同一个窗口被确定性重试了 20 次,不是 20 次不同的失败。

观测数据(本机 llm_audit_logs 只读查询)

  • 近 7 天:summarize_compress 成功 469 次、dream_consolidate 成功 187 次、dream_summarize 成功 178 次 —— 蒸馏主路径平时是好的。
  • invalid summary JSON 共 20 条,全部落在 2026-10-20T15:40Z — 19:21Z(+08:00 的 23:40 — 03:21),模型均为 cn:deepseek-v4.1-flash(openai-completions via 自建 relay)。
  • 这 20 条的输入 tokens 单调递增 8194 → 11118(同一窗口越攒越大),输出 225 — 4859,单次耗时 2.3 — 27.8s,finish_reason = stop(不是截断、不是超时、不是 429)。
  • distill_cursors:该会话 last_seq = 2229、updated = 2026-10-20T15:19:09Z,之后一直没推进过。

根因(两处,互相放大)

1. 转录被模型当成「该我回话了」

lib/summarize.js:331 的 collectMessages 把渲染好的转录(用户:… / 助手:… / 工具调用:… / 工具结果(成功):…)塞进一条裸 user 消息(lib/summarize.js:409),格式指令只在 system 里(lib/lang.js:14 的 summary.zh)。模型读到的是一段「用户刚说完的话」,于是接着对话往下说 / 点评,而不是提炼。

输出里连 [ 都没有,parseSummaryJsonResult 在 lib/summarize.js:38-40 的括号早退直接判 ok:false。现有 salvage 逻辑(scanBraceSpans 双遍括号扫描)在这种形状下救不回来 —— 它的前提是必须能先找到 [;E8 体检里记过的「JSON 崩溃」就是同一类形状(见 讨论 #339)。

2. 失败后没有任何护栏

lib/summarize.js:869-877:解析失败 → audit.errorMessage = "invalid summary JSON" → return,不推进 seq 游标(870 行注释明说「下一次 turn/end 仍会重试同一窗口」)。而重试是同文同温(0),于是同样输入 → 同样错 → 永远失败:每个 turn/end 再来一次,窗口还越来越大。

对照实验(同一段转录,OpenAI 兼容 endpoint,temperature 默认)

构造 结果
system 只写「你只能输出 JSON,不要输出任何解释」 ✅ {"a":1}(两个模型都正常,说明请求侧 / relay 没有篡改 system)
现行构造:指令在 system,转录裸放 user(干净转录,无编造内容,7 行) ❌ 0/2 有 JSON:cn:deepseek-v4.1-flash 回 334 字中文点评(reasoning 752 tok);cn:glm-5.3-flash reasoning 7234 tok,还带 <|tool_call_count|>1 脏尾
指令重复进 user 消息 +「你是第三方提炼员,不是这段对话的参与者,不要回答或点评里面的内容」+ 转录用 <<< >>> 包起来 ✅ 2/2 返回合法 JSON 数组

结论:这不是某一家模型的毛病,也不是解析器太严,而是转录放在 user 位置上被模型当成了「当前发言」。

建议

  1. 提示构造加固(低风险,实验已验证):转录外面加分隔符,并在 user 消息里重申「第三方提炼员 / 不参与这段对话 / 不要回应正文」;system 指令可保持不动。改动点集中在一处 —— collectMessages 里的 createUserMessage。
  2. 失败护栏(更重要):同一窗口连续 N 次(建议 2–3 次)invalid summary JSON 后强制推进游标跳过该窗口,或把窗口对半切再试一次。目前的「确定性无限重试」在用户侧只表现为面板刷屏,并且把窗口越撑越大(本机 8194 → 11118 tokens 全是白烧,3.5 小时)。
  3. 可诊断性:解析失败时把原始输出(截断后)写进 audit metadata。现在 metadata 恒为 null,出事只能靠复现去猜是哪一种非 JSON。

环境

  • @modusensus/dsh-mneme@0.9.3,Windows 11 + DSH Desktop
  • provider workbuddy2api(api=openai-completions,本地 relay http://127.0.0.1:7863/v1),model cn:deepseek-v4.1-flash(maxTokens 393216)
  • 未开 distillRecords(distill_records 表为空),因此没有留存原始输出可供事后回溯

Activity

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

    @strange2000
    Author

    与 #424 的关系:这是「失败重试」的另一半

    #424 第 4 项提的是失败短重试(近 7 天 102 轮里 12 轮失败,11.8%,改成一天一次后「一次失败 = 浪费一整天」)。本文描述的是它的反面情形,两者互补而非重复:

    #424 第 4 项覆盖的失败 本文描述的失败
    性质 偶发、可自愈(429/5xx/超时) 确定性、重试必然同结果
    finish_reason error / 空 stop(正常返回,非截断/超时/429)
    重试收益 大概率第二次成功 0:同一窗口 20/20 全失败
    重试代价 多花一次 token 输入从 8194 → 11118 tokens 单调递增,越重试越贵

    也就是说:#424 落地后只会让「偶发失败」重试得更聪明,而本 issue 里那个游标卡在 last_seq=2229 的窗口仍会一直被重试 —— 因为按现在的逻辑,失败在语义上就等于「还没提炼完,下次接着来」,没有任何路径会承认「这段内容该被放弃」。

    建议的最小护栏(不改变正常语义)

    1. 连续 N 次同类解析失败 → 强制跳过该窗口:把这一段标记为 skipped 并推进游标(或对半切后各试一次),保证「重试」有上界。核心是让 :869-877 那条 return 不再等价于「永远重来」。
    2. 把原始输出留在 audit 里:现在 llm_audit_logs.metadata 恒为 null、distillRecognitionRecords 默认关,所以我只能靠外部复现来证明「模型输出里连 [ 都没有」。留 1–2KB 的原始响应(或首尾各 512 字符)就能让人一眼看出是「模型没照格式」还是「格式变了解析器没跟上」。
    3. 若 [Feature] 巩固/蒸馏的维护窗口:正向定点 + 债务上限 + 手动触发入口 #424 第 5 项(手动触发入口)先落地,至少能给用户一个人工解卡的口子;但确定性失败仍需自动跳过 —— 手动入口只是止血,不是修复。

    补充:复现方法(无需改代码)

    把 lib/summarize.js 里 buildTranscript 的产物直接 POST 到任意 OpenAI 兼容端点(system 用 STR.prompts.summary,user 用转录原文),即可稳定复现:

    • 极简服从性(system 只说"输出 JSON")→ 正常返回 JSON,说明 relay 未篡改 system;
    • 现行构造(转录裸放 user)→ 0–2 次成功,模型接着对话往下写中文;
    • 给转录加显式分隔符 + 在 user 里重申「你是第三方提炼员,不是对话参与者」→ 稳定返回 JSON。

    复现脚本与原始审计数据(20 条 invalid summary JSON 的完整字段、distill_cursors 快照)我可以直接贴上来,需要的话说一声。

  3. strange2000 commented on Oct 20, 2026

    @strange2000
    Author

    复现材料开源了:https://github.com/strange2000/dsh-repro → repro/mneme-0001/

    两个脚本都可以直接跑,对应 issue 里的两张表:

    • scan-audit.cjs —— 只读打开 mneme 的 SQLite(MNEME_DB,默认 E:/mneme/memory.db),按 --days/--op 打印:各操作成败分布、失败明细、同 op+model+error 的失败串、distill_cursors、metadata 留存。今天在本机重跑,这条串还在:summarize_compress / workbuddy2api:cn:deepseek-v4.1-flash / "invalid summary JSON" 28 次,input_tokens 2217 → 11118。
    • repro-prompt.mjs —— 三臂对照:A 只有「输出 JSON」的 system,B 用蒸馏 system + 转录裸放 user,C 加 <<<TRANSCRIPT>>> 分隔符并重申「你是第三方提炼员」。密钥走 MNEME_API_KEY 环境变量,解析口径照抄 lib/summarize.js 的 indexOf("[") / lastIndexOf("]")。

    刚才 B 臂又跑了一次:finish=stop,输出 58 字「已按你的要求将值班轮单日上限改为 5,其余配置保持不变…」,解析 no-brackets —— 和 issue 里的判断一致,模型仍在把转录当成自己该回话的对话。

    顺带一个取证时踩到的细节,对排障有用:llm_audit_logs.session_key 对 summarize_compress / dream_consolidate / dream_summarize / entity_extract 全为 NULL(只有 write_admission 有值),所以「哪次失败属于哪个会话」在库里是查不到的;失败行 metadata 也是 100% NULL。这两点是「必须做到库外复现」的直接原因,脚本 §4/§5 里都标了。

  4. modusensus commented on Oct 20, 2026

    @modusensus
    Member

    感谢这份报告,取证做得很扎实——尤其是 finish_reason = stop + input 单调递增这两条,把「偶发失败」和「确定性失败」区分开了。

    我在本机独立复现了一遍,两条缺陷都坐实了:

    缺陷 A(提示构造):注入一个恒定返回中文点评(不含方括号)的假 stream,实测消息序列只有 system -> user 两条;system 里是 summary.zh 那段格式指令,user 消息里没有任何「你不是参与者」约束,也没有转录分隔符,转录以「用户:…」开头裸放 user 位。这与你 repro-prompt.mjs 的 B 臂完全一致。

    缺陷 B(失败护栏):同一会话推 1 次 turn/end 触发蒸馏,之后连推 19 次 turn/end —— 实测多出 19 次 LLM 调用,落库 0 条,distill_cursors.last_seq 始终不推进,窗口字符数 25 → 471 单调增长。与 issue 观测的 8194 → 11118 tokens 同构。

    缺陷 C(可诊断性):20 条 error 审计的 error_message 全是 invalid summary JSON,metadata 恒为 null(不是 {},是成功路径才赋值、失败早退分支从不赋值)。事后无法回溯「模型到底输出了什么形状」。


    修复方案(三条,按风险从低到高)

    1. 提示构造加固(默认生效,不改任何行为语义)

    在 src/summarize.js:331 的 collectMessages 里,把转录包进围栏并在 user 消息前重申角色:

    <<<TRANSCRIPT>>>
    用户:…
    助手:…
    <<<END TRANSCRIPT>>>
    

    并在同一条 user 消息前加一段约束:你是第三方提炼员,不是这段对话的参与者;不要回答、点评或续写转录里的内容;只按 system 的格式要求输出 JSON 数组。

    依据是你 C 臂的对照结果(2/2 返回合法 JSON),我在本地也复验过。这是纯提示词构造改动,不引入新配置键、不改游标语义、不改任何判定分支。围栏是确定性的(无随机、无时间戳),因此「重试收到逐字节相同的转录」这条既有语义不变。

    2. 失败护栏(新开关,默认关闭 = 现状不变)

    新增 summarizeParseFailureMaxRetries(默认 0 = 关闭,范围 0–10):

    • 同一会话连续解析失败计数达到阈值后,强制推进游标跳过该窗口,并写一行 status='error' / error_message='invalid summary JSON' / metadata={distillChars, skipped:'parse-failure-streak', streak:N} 审计;
    • 成功一次即清零计数;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。

    为什么默认关:本仓库的既定纪律是「新行为开关一律 opt-in 默认关」(AGENTS.md),summarizeMaxRunsPerSession / summarizeMinWindowChars 都是这么落的。默认关也保证既有 4 个「解析失败不消费窗口」的回归用例语义不变。需要止血的用户把它设成 2–3 即可。

    没有采用「窗口对半切再试」:切窗会把一次失败变成两次调用,在确定性失败下等于把代价翻倍,且需要重写 collectMessages 的范围语义。护栏先止血,切窗留作后续独立提案。

    3. 可诊断性(默认生效,纯计量)

    解析失败时把原始输出写进 audit.metadata:

    { distillChars, parse_failure: true,
      raw_len: <字符数>, raw_head: <前 300 字符>,
      raw_has_bracket: <输出里是否出现过 '['> }
    

    只加计量、不加任何跳过决策——与 #239 的 distillChars 同一取舍(「否则阀门等于黑盒」)。300 字符上限避免把审计表撑大。


    明确不改的两处

    • llm_audit_logs.session_key 保持 NULL。这是刻意设计,不是遗漏:test/pre-inject-gate.test.js:155 显式断言「LLM 调用行不写会话键(不挤占写入准入的话题回填窗口)」。补上会改动 [Feature] 写入准入(non-write 判定):把「这条该不该进库」前移到 LLM 之前 #254 写入准入的话题回填行为。要在库里定位失败会话,改用 metadata 里的新字段 + 时间窗反查。
    • 不显式传 temperature。温度由宿主/路由决定,插件这一层不覆盖——覆盖会改变所有用户的输出分布,超出本 issue 范围。

    我按上面这套开工,分支 fix/493-distill-parse-guard。会带 3 组新回归测试(提示构造断言 / 护栏触发与不触发 / 失败路径 metadata),并同步 npm run sync 与 docs/CONFIGURATION.md、README.md、CHANGELOG.md 的配置表。落地后在本帖回报。

  5. strange2000 commented on Oct 20, 2026

    @strange2000
    Author

    方案我完全同意——尤其「护栏默认关」「围栏确定性」这两处取舍的边界划得对。开工前补四条,都属于落地细节,不影响你按现方案推进:

    ① 跳过行的 error_message 建议与真失败分开

    error_message = 'invalid summary JSON' 与真失败同串,按 error_message 聚类的统计(比如我脚本 §2/§3 那种写法)会把「已放弃、游标已推进」的行算成「又失败了一次」——护栏上线后失败率曲线反而更难看懂。建议写成 invalid summary JSON (skipped after N),或直接独立一个 status='skipped'。

    ② streak 建议持久化

    「与 runsUsed / lastRunAt 同生命周期」意味着进程重启清零。但这个失败恰恰是跨重启复现的:我库里那条游标 last_seq=2229 从 2026-10-20T15:19:09Z 卡到现在(期间机器重启过多次),每次重启白送 N 次重试,护栏「有上界」就不严格了。哪怕只在 distill_cursors 旁加一列 parse_fail_streak 也够用。

    ③ 围栏要防转录自己撞上 END 标记

    转录是用户 / 模型原文,里面出现 <<<END TRANSCRIPT>>> 是完全可能的,一撞就提前闭合、等于退回裸放。在确定性前提下有两种做法:生成时检查转录是否含该串(含则把围栏加长或做转义),或写成带长度前缀的 <<<TRANSCRIPT len=8000>>>。

    ④ 失败 metadata 建议补一个 raw_tail

    raw_head 前 300 字符有个盲区:有些模型先铺垫一段再吐 JSON,头部恰好看不到关键形状。尾部再截 300 字,成本几乎为零。


    另外一条供参考:围栏加固默认生效后,我这边那个卡住的窗口(last_seq=2229,窗口内容也已冻结)是天然的验收样本——理论上它下次就该一次解析通过、游标自己往前走。

    分支落地后我用 scan-audit.cjs 重跑,把游标前后值贴回来,顺带把脚本 §5 扩成能读 raw_head / raw_tail 的新字段。不急,你按自己的节奏来。

  6. moved this from 📋 Backlog to 🔨 In Progress in dsh-mneme 任务看板on Oct 20, 2026
  7. added 2 commits that reference this issue on Oct 20, 2026
  8. moved this from 🔨 In Progress to ✅ Done in dsh-mneme 任务看板on Oct 20, 2026
  9. modusensus commented on Oct 20, 2026

    @modusensus
    Member

    已在 #494 修复合入 main(4abb257),本条随 PR 关闭。

    谢谢你这份报告,它比一般的 bug report 好用一个量级。 具体是这几点让修复能一次到位:

    • finish_reason = stop 这一条,把问题从「解析器太严」直接钉成「模型压根没按格式写」——没有它,很容易走错方向去放宽解析。
    • input tokens 8194 → 11118 单调递增,说明是同一窗口在反复重试而不是新窗口在失败。这是「确定性 vs 偶发」的分界线。
    • repro-prompt.mjs 的三臂对照(A 只有 system / B 现行构造 / C 加分隔符 + 角色重申)基本就是修复方案的实验依据。我们复跑后结论一致,C 臂 2/2 出合法 JSON。
    • session_key 全为 NULL、metadata 100% NULL 这两条,直接决定了「必须库外复现」——也成了这次改动的第 3 项(失败行 metadata 现在恒带 raw_head / raw_has_bracket / streak)。

    实际改动(细节见 #494):

    1. 转录包进 <<<会话转录开始>>> / <<<会话转录结束>>>,user 侧重申「你是第三方提炼员,不是这段会话的参与者」。system 保持原样——我们实测只加 system 约束仍 0/2 出 JSON,判断「这段文本是什么」的是它所在的那条 user 消息。围栏是确定性的,重试收到的转录与首次逐字节相同。
    2. 新键 summarizeParseFailureMaxRetries(默认 0 = 关):连续失败到阈值后强制消费游标跳过该窗口。默认关是因为放弃内容有代价——你提到的 [Feature] 巩固/蒸馏的维护窗口:正向定点 + 债务上限 + 手动触发入口 #424 那一侧(429/5xx/超时)短窗口仍值得重试,两者确实互补。
    3. 失败行 metadata 不再为 null。

    有一处和你的建议不同,说明一下:你建议 2–3 次,我们把它做成可配且默认关,而不是默认 2。原因是本仓的新行为一律 opt-in(见 AGENTS.md)——静默丢弃内容对已有用户是行为变更。但你这种场景设成 2 就是你要的效果,README / CONFIGURATION 里都写了。

    升级后你那条卡住的会话(last_seq = 2229)仍会重试一次:第一次失败把 streak 推到 1,第二次即达阈值被跳过,之后新窗口正常蒸馏。

    另外确认一下你说的两处「刻意不改」:session_key 仍为 NULL(test/pre-inject-gate.test.js:155 显式锁着这条,补上会牵动 #254 的写入准入),temperature 也不显式传(温度由宿主/路由决定,插件覆盖会改变所有用户的输出分布)。

  10. 4 remaining items

  11. strange2000 commented on Oct 21, 2026

    @strange2000
    Author

    再补一条 0.9.4 之后才显形的第二形态。它和围栏无关,也不是解析器太严——JSON 是合法的,条目是被 type 枚举滤空的。

    一、现场

    0.9.4 生效后 28 分钟,本机 llm_audit_logs:

    18:32:07  summarize_compress  success  in=10745 out=3822   ← 27 小时来首次成功
    18:35:03  summarize_compress  error    in= 2711 out=2047   invalid summary JSON
    18:40:27  summarize_compress  success  in= 3140 out=4467
    18:51:59  summarize_compress  error    in= 4531 out=3456   invalid summary JSON
    

    两条失败行的 metadata:

    keys = distillChars, parse_failure, raw_len, raw_head, raw_has_bracket, streak
    raw_has_bracket = true
    raw_len = 2574 / 3299
    

    raw_has_bracket=true 说明模型确实输出了带方括号的数组,JSON.parse 也过了。但 raw_head 长这样:

    [ { "id": 1,
        "type": "环境/版本",
        "content": "本地安装的 @modusensus/dsh-mneme 版本为 0.9.4…",
        "source": "工具结果:版本确认" },
      …

    另一条窗口里:

    [ { "id": "user_intent", "type": "用户需求", … },
      { "type": "项目能力", … } ]

    模型自造了中文分类,还多给 id / source / 依据 这类键。于是:

    // lib/summarize.js:55-64
    const VALID = new Set(["preference","project","decision","history",
                           "rejected_solution","pitfall","constraint"]);
    const entries = arr.filter(item => item && typeof item === "object"
      && VALID.has(item.type)
      && typeof item.title === "string" && item.title.trim()
      && typeof item.content === "string" && item.content.trim());

    entries 变成空数组,而 lib/summarize.js:81 的判据是:

    ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0)

    arr.length > 0 但 entries.length === 0 → ok:false → summarize.js:921 记 invalid summary JSON,游标不推进,整窗重试。所以一个只错在分类词上的窗口,会被当成「模型没按格式写」反复重放。

    二、为什么会自造分类

    lib/lang.js:14 的中文蒸馏 prompt 里,类型是这么写的:

    …"type": "preference|project|decision|history"…
    

    竖线连写、只列 4 个、没有「只能用这些值」的约束。而同一文件 lang.js:18 的 codingSummary 版把 7 个枚举写全了。两套 prompt 对同一件事的严格程度不一致,模型自然按「最像中文标签」的方式发挥。

    顺带:这条路径下失败行没有 raw_tail(keys 里没有),而有些模型会先写几条合法条目、再在后面跑偏,头 300 字看不出来。

    三、三条可选修法(按侵入性从小到大)

    1. 错误分叉 + 可诊断字段:entries.length === 0 && arr.length > 0 时,error_message 写成 no valid summary entries (arr=12, valid=0),metadata 补 invalid_entries(被滤掉的条数)与 sample_types(前 5 个出现的 type 原文)。这一步不改行为,只让下次能一眼看出是「没写 JSON」还是「分类词不对」。
    2. type 归一化:入参前把 type 走一次映射表(中文/近义词 → 枚举,未知落 project 或 history),映射不中再滤镜。收益是内容能落库;代价是分类可能失准,所以我更倾向先做 1,再看 1 的统计决定要不要做 2。
    3. prompt 侧收紧:lang.js:14 列全 7 个枚举、写明「不要输出枚举外的值,不要输出 id/source 等额外键」,与 lang.js:18 对齐。

    我的偏好排序:1 → 3 → 2。1 是纯诊断、零风险;3 是纯提示词;2 会改变入库分布,需要你判断值不值。

    四、和上一条的关系

    上一条报的 dream 侧 5 处裸放,与你重开 #493 的动作我理解为同一件事,就不再另开 issue。若你要分开发版,我可以把 dream 侧那节单独提成新 issue,说一声即可。

    另外提醒一句:summarizeParseFailureMaxRetries 我这边保持默认 0(关),因为想先看清失败形态再决定放弃阈值——上面这两条正好是「不该被放弃、该被修」的类型,设置成 2 会把它们连同窗口一起丢掉。等 1 或 3 落地后再开护栏更合适。

  12. modusensus commented on Oct 21, 2026

    @modusensus
    Member

    @strange2000 你 18:34 与 19:12 两条都收到,全部按代码核过,结论与你写的一致。拆成两条走,理由是你自己也提的那点——两者不是同一个失效路径,混在一条里以后没人能按它发版:

    两条你都不用再补证据。#503 合开发版后我回这里报版本号,届时可观测判据是:dream_consolidate 失败行的 metadata 不再是 null,且该 operation_type 的失败次数应显著下降;如果你那边重跑三臂,也欢迎把新的 A/C 读数贴上来——你这两组对照实验是这次定位里最有用的部分。

  13. modusensus commented on Oct 21, 2026

    @modusensus
    Member

    补一条进度:你 19:12 报的第二形态已经单独立项并开了 PR——#502 / PR #504。

    按你排的 1 → 3 做了,两条都零行为变更:describeParseFailure 把 no-array / syntax / filtered-empty 分开,第三种的错误消息是 no valid summary entries (arr=N, valid=0),metadata 带 failure_kind / arr_count / invalid_entries / sample_types(模型自造的分类词原样回显)+你要的 raw_tail;summary 的中英两版枚举列全 7 个并写明「不要自造分类、不要附加 id/source」。

    归一化(你的 2)暂缓,理由与你一致——它会改变入库分类分布,等 sample_types 跑出统计再判断。summarizeParseFailureMaxRetries 也继续默认 0:这两种形态是该修的,不该被阈值丢掉。

    两条 PR(#503 dream 围栏、#504 本条)都过了 CI 与复审、无未解决线程。合完发版后我在 #503 那边回版本号;你那边可观测判据是 dream_consolidate 失败行的 metadata 不再是 null,以及 summarize_compress 失败行开始区分 filtered-empty 与「没写格式」。

  14. modusensus commented on Nov 2, 2026

    @modusensus
    Member

    主链路已经全部修完并发版,本 issue 关闭。剩下的两条补充建议我另开跟踪,见下。

    已交付的部分

    修复 版本 内容
    #494 4abb257 0.9.3 转录包进围栏 + user 侧重申「你是第三方提炼员」,dsh-mneme/src/summarize.js:469
    #501 / PR #503 0.9.4 dream / sleep 侧另 5 处裸放一并加围栏(memoryFenceOpen/Close,没复用「会话转录」那对名字——喂的是清单不是对话)
    #502 / PR #504 0.9.4 第二形态:JSON 合法但条目被 type 枚举滤空。失败形态拆成 no-array / syntax / filtered-empty,审计补 failure_kind / arr_count / invalid_entries / sample_types / raw_tail

    你 10-21 那条回帖已经确认过围栏落在 lib/summarize.js:418、raw_head / parse-failure-streak 都在,这条路径不再重复。

    你提的两条补充建议,没做

    ② streak 没有持久化。 现在仍是进程内 Map(dsh-mneme/src/summarize.js:608 的 parseFailureStreak),进程重启清零;distill_cursors 表还是三列(session_id / last_seq / updated_at,dsh-mneme/src/store.js:300-304),没有 parse_fail_streak 列。你说的那个场景——库里那条游标从 10-20 卡到现在、期间重启过多次、每次白送 N 次重试——在护栏默认 summarizeParseFailureMaxRetries=0 的情况下不会发生了(默认根本不重试),但只要有人把它调大,跨重启的账就还是虚的。

    ③ 围栏没有防转录自己撞上结束标记。 summarize.js:469 是直接拼 transcriptBody,没有 contains 检查。dream 侧已经解决了同一问题(fenceListBody() 把正文里的 <<< / >>> 用零宽空格断链,只中和标记不删内容),src/summarize.js 这条转录路径还没跟上——一条正文里写着 <<<会话转录结束>>> 的会话能提前闭合围栏。

    这两条都不阻塞你报告的那个 bug(确定性重放已经没有了),我按「补充加固」另开跟踪,不把它们算进本条。

    @strange2000 想先跟你确认口径:这两条你还要吗? 如果你想自己写,我另开两条 issue 挂着、标注「等你」;如果你不打算写,我们就按上面那个方向自己补。另外 —— 这份报告从 finish_reason = stop 到 repro-prompt.mjs 的三臂对照,基本就是修复方案本身,B 臂那个「只有 system + 转录裸放」的对照直接决定了围栏要贴在 user 位。少走的那一大圈弯路,全在你那张表里。

  15. moved this from 🎯 Ready to ✅ Done in dsh-mneme 任务看板on Nov 2, 2026
  16. strange2000 commented on Nov 2, 2026

    @strange2000
    Author

    这两条我都不写,你们按上面的方向补就行,别挂“等你”。

    补的时候顺手对一下两处口径:

    • ②:加列的话建议别只存裸计数——parse_fail_streak 最好连时间戳一起判失效(比如超过 N 天没更新就清零)。否则游标停很久之后再开跑,会拿几周前攒下的 streak 直接触发熔断,反而成了新的误伤路径。
    • ③:直接复用 dream 侧 fenceListBody() 那套零宽空格断链,别另写一份。两处一旦分叉,下次出问题又得分别查一遍——这次 #494 和 #501/PR #503 的重复劳动就是这么来的。

    主链路修得挺快,那三臂对照没白贴,谢谢。

  17. modusensus commented on Nov 2, 2026

    @modusensus
    Member

    桉桉让我把这两条补上(你说「不写,你们按上面的方向补就行,别挂等你」,照办)。PR:#549

    ① streak 持久化

    新表 distill_parse_failures(session_id PRIMARY KEY, streak, updated_at),store 三个函数 + service passthrough。

    没并进 distill_cursors,三条理由:写 streak 不必先有游标行(窗口可能还没成功消费过任何 seq);「成功即归零」是 DELETE,而游标那句 UPSERT 是 last_seq = MAX(...)(单调),两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除;失效判据要读时间戳,就地一行更省事。

    你提的失效判据做了,而且是读侧判(staleAfterMs,默认 7 天常量、不做配置键——护栏本身默认关,一个只为它服务的窗口不该再占一档用户可见配置)。你点名的反例正是我写测试时用的场景:往库里种一条 30 天前的失败记录,阈值 2,如果失效不生效,这一次失败就会直接熔断;现在它读成 0,本次记 streak=1,不熔断。劣化方向刻意 fail-open——时间戳解析不出来也按失效算,最坏是多花一次 LLM 调用。

    这里有个我自己踩的真 bug,值得说一下:bump 最初在 SQL 里写 streak = streak + 1,于是那条 30 天前的旧行会把「新的第一次失败」直接加成 2——失效窗口在 bump 这条路径上完全不生效,只有 get 判了。端到端用例抓出来的。改成「先按 staleAfterMs 读一遍再覆写」,判据只保留 store 那一份。你说的「两处一旦分叉,下次出问题又得分别查一遍」在同一个函数的两个方法之间就能发生。

    ② 围栏断链共用一份

    新建 src/fence.js,只导出 breakFenceMarkers(text),三处引用;fenceListBody 内部改为调它(原 inline replace 删掉)。

    你说的是「转录那处」和「复用 dream 侧」,我多改了一处:画像提取的 profileFenceOpen/Close(src/profile-extract.js)也没断链,它同样是「把记忆正文当 user 消息喂进去」,同一个洞。三类围栏都是 <<< 前缀 + >>> 后缀,所以共享函数不认围栏名字——认名字反而会在新增围栏时漏一处。

    为了防「再长出第二份」,test/dream-fence.test.js 加了一条源码静态锁:断言 dream.js 里不再有 replace(/<<<|>>>/g,且 summarize.js / profile-extract.js 各自都在围栏处调用了共享函数。

    闸门

    npm run sync 83 files、check-sync ✓、全量 npm test 2660 tests / 2659 pass / 0 fail / 1 skip。测试 +6(fence 3 / summarize 2 / store 1),外加三例变异自证:stale 全关 → 失效那条红;固定退回进程内 Map → 重启接续与失效两条都红;清零写成 0 而不是 DELETE → store 那条红。

    没动提示词、没动校验器、没加配置键。

  18. added a commit that references this issue on Nov 2, 2026
  19. strange2000 commented on Nov 2, 2026

    @strange2000
    Author

    #549 看过了,比我要的更全。

    ② 你那个 bump 的 bug 正是我担心的形态 —— 失效判据只要在 get 和 bump 里各写一次,就会出现「一条路径判了、另一条没判」的分叉。判据只留 store 那一份,处置是对的。

    ③ 多改 profile-extract.js 也对:同样是「把记忆正文当 user 消息喂进去」,只修转录那处等于把同一个洞留在隔壁。共享函数不认围栏名字这点我同意,认名字就是给未来新增围栏埋了个「必须记得改这里」的坑;test/dream-fence.test.js 里那条源码静态锁比新增的用例本身更值钱。

    staleAfterMs 不做配置键我也赞成。只提一句、不是要求改:护栏默认关,7 天失效在「两周才开一次 dream」的节奏下会让 streak 每次都读回 0 —— 方向是 fail-open,不会误伤,只是等你哪天把重试阈值调大,保护会偏弱。真要收紧,把失效窗口对齐「一次正常 dream 间隔」就够,仍然不必新增配置键。

    谢谢,收得干净。

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