Skip to content

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

Open

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. strange2000 commented on Oct 21, 2026

    @strange2000
    Author

    先报告:0.9.4 已装上(@modusensus/dsh-mneme 0.9.4,本机 2026-10-22 02:26 安装)。围栏确实落在 lib/summarize.js:418,transcriptFenceOpen/Close 与角色声明在 lib/lang.js:173-180,raw_head / parse-failure-streak 也在。这条路径我不再重复。

    但同一缺陷在 dream 侧还有 5 处,0.9.4 一处都没覆盖。 这就是我这两天面板上「不止总结提炼」的那一半。

    一、现场:48 小时审计(本机 llm_audit_logs)

    operation_type 成功 失败 失败原因
    summarize_compress 168 71 69× invalid summary JSON + 2× stream error
    dream_consolidate 72 9 8× no json array in llm output + 1× 503 no_healthy_account
    dream_summarize 67 1 NO_ADAPTER(重启期偶发)

    no json array in llm output 那 8 次,输入是 47,373 → 54,905 tokens,输出只有 212–907 tokens —— 显然不是在输出决策数组,而是在说话。

    二、时间线判据:这不是抖动,是一次性变化

    三类操作最后一次成功全部落在 2026-10-20 15:19–15:20Z,此后 27 小时零成功:

    15:19:04 summarize_compress success  in= 1523 out=  826
    15:19:14 dream_consolidate  success  in=51127 out=21064
    15:20:48 dream_summarize    success  in=213028 out= 1022
    15:40:12 summarize_compress error    in= 8194 out= 1235  invalid summary JSON
    

    也就是说:总结提炼与 dream 侧是同时开始全崩的。这与「提示构造在两处都脆弱、而端点行为在那一刻变了」吻合,而不是两件互不相关的偶发。

    三、代码位置(0.9.4,逐处核对)

    dream 侧五处 user 消息,全部是把列表/转录裸放,system 之后没有任何分隔符与「你不是参与者」约束:

    文件:行 用途 user 内容
    lib/dream.js:1218 dream_consolidate listText(聚类后的记忆清单)
    lib/dream.js:514 narrative listing
    lib/dream.js:1461 dream_summarize summaryInputs.map(...)
    lib/dream/sleep.js:354 冲突合并 listText
    lib/dream/sleep.js:530 模式提炼 listText

    grep transcriptFence lib/ 只命中 lang.js(定义)与 summarize.js(使用),dream 一处未命中。

    四、日志原文(~/.dsh/logs/desktop-next.log)

    2026-10-21T18:24:45.973Z [warn] dsh-mneme dream: no json array in llm output
      (raw length 242; head: 我收到了这批记忆(10 个聚类,主题集中在:口吻规则、DSH 插件/皮肤/工作区、
       刷B站插件、记忆库与 E 盘共享、junction 迁移、workbuddy2api…)。但这一轮没有具体任务。你要我做什么?)
    
    2026-10-21T07:26:34.691Z [warn] dsh-mneme dream: no json array in llm output
      (raw length 1639; head: 收到——这是一包记忆检索结果(10 个聚类、约 46K tokens),没有带具体指令。
       我先把内容过一遍,给你一份可用的导览…)
    

    它把 46K tokens 的记忆清单读成「有人丢给我一包检索结果但没下指令」,于是开始寒暄。这与 #493 里 B 臂的形状完全同构,只是 prompt 换了一套。

    五、端点侧的旁证(今天重跑的三臂)

    臂 10-20 报 bug 时 今天 02:35
    A:system 只说「输出 JSON 数组」 2/2 成功 0/1(回了一段讲章鱼的闲聊,133 字)
    C:<<<TRANSCRIPT>>> 围栏 + 第三方提炼员 2/2 成功 1/1(正常 3 条 JSON)

    含义:端点现在对「弱指令」的服从性明显变差,强约束仍然可靠 —— 所以围栏方向是对的,而 dream 侧必须同样处理,否则永远停在「弱指令」。

    六、成本

    8 次 × 约 54K 输入 ≈ 43 万 token/天,全部落库 0 条。

    七、建议(都不急,按你的节奏)

    1. 把 lang.js 已有的 transcriptFenceOpen/Close + distillRoleReminder 复用到上面 5 处;dream 的清单本身就是模型可读文本,同样存在「提前闭合」风险,第 ③ 条建议(fence 内容自检或 len= 前缀)在这里更必要。
    2. lib/dream.js:1206-1208 与 sleep.js:345 / 521 的 auditError 目前只返回error 文案,metadata 同样是 null;raw_head 那套可以直接复用。
    3. 若 dream 侧也要护栏,语义上更接近「这轮放弃」而不是「重试」—— 它与 last_seq 无关,是定时触发,失败一次就是一次浪费。

    不需要你现在回。我这边先观察 0.9.4 之后那个 last_seq=2229 的窗口是否解冻,有结果再贴。

  11. strange2000 commented on Oct 21, 2026

    @strange2000
    Author

    补一条刚发生的对照,在同一分钟内(本机 18:32,0.9.4 已生效):

    18:32:07  summarize_compress  success  in=10745 out=3822        ← 围栏生效,27 小时来第一次成功
    18:32:26  dream_consolidate   error    in=54905 out= 403  no json array in llm output
    

    19 秒之隔,前者解冻、后者照旧 —— 输入规模同级(10.7K vs 54.9K),差别只在 prompt 是否围栏。这条不用回,当作上面第三节的实测注脚。

  12. moved this from ✅ Done to 🎯 Ready in dsh-mneme 任务看板on Oct 21, 2026
  13. 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 落地后再开护栏更合适。

  14. modusensus commented on Oct 21, 2026

    @modusensus
    Member

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

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

  15. 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 与「没写格式」。

  16. added a commit that references this issue 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