Repository navigation
Description
现象
@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 位置上被模型当成了「当前发言」。
建议
- 提示构造加固(低风险,实验已验证):转录外面加分隔符,并在 user 消息里重申「第三方提炼员 / 不参与这段对话 / 不要回应正文」;system 指令可保持不动。改动点集中在一处 ——
collectMessages里的createUserMessage。 - 失败护栏(更重要):同一窗口连续 N 次(建议 2–3 次)
invalid summary JSON后强制推进游标跳过该窗口,或把窗口对半切再试一次。目前的「确定性无限重试」在用户侧只表现为面板刷屏,并且把窗口越撑越大(本机 8194 → 11118 tokens 全是白烧,3.5 小时)。 - 可诊断性:解析失败时把原始输出(截断后)写进 audit
metadata。现在metadata恒为null,出事只能靠复现去猜是哪一种非 JSON。
环境
@modusensus/dsh-mneme@0.9.3,Windows 11 + DSH Desktop- provider
workbuddy2api(api=openai-completions,本地 relayhttp://127.0.0.1:7863/v1),modelcn:deepseek-v4.1-flash(maxTokens 393216) - 未开
distillRecords(distill_records表为空),因此没有留存原始输出可供事后回溯
Activity
strange2000 commentedon Oct 20, 2026
与 #424 的关系:这是「失败重试」的另一半
#424 第 4 项提的是失败短重试(近 7 天 102 轮里 12 轮失败,11.8%,改成一天一次后「一次失败 = 浪费一整天」)。本文描述的是它的反面情形,两者互补而非重复:
finish_reasonerror/ 空stop(正常返回,非截断/超时/429)也就是说:#424 落地后只会让「偶发失败」重试得更聪明,而本 issue 里那个游标卡在
last_seq=2229的窗口仍会一直被重试 —— 因为按现在的逻辑,失败在语义上就等于「还没提炼完,下次接着来」,没有任何路径会承认「这段内容该被放弃」。建议的最小护栏(不改变正常语义)
:869-877那条return不再等价于「永远重来」。llm_audit_logs.metadata恒为null、distillRecognitionRecords默认关,所以我只能靠外部复现来证明「模型输出里连[都没有」。留 1–2KB 的原始响应(或首尾各 512 字符)就能让人一眼看出是「模型没照格式」还是「格式变了解析器没跟上」。补充:复现方法(无需改代码)
把
lib/summarize.js里buildTranscript的产物直接 POST 到任意 OpenAI 兼容端点(system 用STR.prompts.summary,user 用转录原文),即可稳定复现:复现脚本与原始审计数据(20 条
invalid summary JSON的完整字段、distill_cursors快照)我可以直接贴上来,需要的话说一声。strange2000 commentedon Oct 20, 2026
复现材料开源了: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里都标了。modusensus commentedon Oct 20, 2026
感谢这份报告,取证做得很扎实——尤其是
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 消息前重申角色:并在同一条 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:只加计量、不加任何跳过决策——与 #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的配置表。落地后在本帖回报。strange2000 commentedon Oct 20, 2026
方案我完全同意——尤其「护栏默认关」「围栏确定性」这两处取舍的边界划得对。开工前补四条,都属于落地细节,不影响你按现方案推进:
① 跳过行的
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_tailraw_head前 300 字符有个盲区:有些模型先铺垫一段再吐 JSON,头部恰好看不到关键形状。尾部再截 300 字,成本几乎为零。另外一条供参考:围栏加固默认生效后,我这边那个卡住的窗口(
last_seq=2229,窗口内容也已冻结)是天然的验收样本——理论上它下次就该一次解析通过、游标自己往前走。分支落地后我用
scan-audit.cjs重跑,把游标前后值贴回来,顺带把脚本 §5 扩成能读raw_head/raw_tail的新字段。不急,你按自己的节奏来。fix(distill): only skip a window when the cursor is actually committa…
Merge pull request #494 from slow-stack/fix/493-distill-parse-guard
modusensus commentedon Oct 20, 2026
已在 #494 修复合入
main(4abb257),本条随 PR 关闭。谢谢你这份报告,它比一般的 bug report 好用一个量级。 具体是这几点让修复能一次到位:
finish_reason = stop这一条,把问题从「解析器太严」直接钉成「模型压根没按格式写」——没有它,很容易走错方向去放宽解析。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):
<<<会话转录开始>>>/<<<会话转录结束>>>,user 侧重申「你是第三方提炼员,不是这段会话的参与者」。system 保持原样——我们实测只加 system 约束仍 0/2 出 JSON,判断「这段文本是什么」的是它所在的那条 user 消息。围栏是确定性的,重试收到的转录与首次逐字节相同。summarizeParseFailureMaxRetries(默认0= 关):连续失败到阈值后强制消费游标跳过该窗口。默认关是因为放弃内容有代价——你提到的 [Feature] 巩固/蒸馏的维护窗口:正向定点 + 债务上限 + 手动触发入口 #424 那一侧(429/5xx/超时)短窗口仍值得重试,两者确实互补。有一处和你的建议不同,说明一下:你建议 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也不显式传(温度由宿主/路由决定,插件覆盖会改变所有用户的输出分布)。feat(profile): draft the user profile from memory on demand (#497)
fix(persona)+feat(profile): 收回 default 卡兜底、生效文本单源渲染,并新增画像提取草稿(#497) (#…
4 remaining items
strange2000 commentedon Oct 21, 2026
再补一条 0.9.4 之后才显形的第二形态。它和围栏无关,也不是解析器太严——JSON 是合法的,条目是被
type枚举滤空的。一、现场
0.9.4 生效后 28 分钟,本机
llm_audit_logs:两条失败行的
metadata: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/依据这类键。于是:entries变成空数组,而lib/summarize.js:81的判据是:arr.length > 0但entries.length === 0→ok:false→summarize.js:921记invalid summary JSON,游标不推进,整窗重试。所以一个只错在分类词上的窗口,会被当成「模型没按格式写」反复重放。二、为什么会自造分类
lib/lang.js:14的中文蒸馏 prompt 里,类型是这么写的:竖线连写、只列 4 个、没有「只能用这些值」的约束。而同一文件
lang.js:18的 codingSummary 版把 7 个枚举写全了。两套 prompt 对同一件事的严格程度不一致,模型自然按「最像中文标签」的方式发挥。顺带:这条路径下失败行没有
raw_tail(keys里没有),而有些模型会先写几条合法条目、再在后面跑偏,头 300 字看不出来。三、三条可选修法(按侵入性从小到大)
entries.length === 0 && arr.length > 0时,error_message写成no valid summary entries (arr=12, valid=0),metadata补invalid_entries(被滤掉的条数)与sample_types(前 5 个出现的type原文)。这一步不改行为,只让下次能一眼看出是「没写 JSON」还是「分类词不对」。type走一次映射表(中文/近义词 → 枚举,未知落project或history),映射不中再滤镜。收益是内容能落库;代价是分类可能失准,所以我更倾向先做 1,再看 1 的统计决定要不要做 2。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 落地后再开护栏更合适。modusensus commentedon Oct 21, 2026
@strange2000 你 18:34 与 19:12 两条都收到,全部按代码核过,结论与你写的一致。拆成两条走,理由是你自己也提的那点——两者不是同一个失效路径,混在一条里以后没人能按它发版:
dream.js叙述条/巩固/摘要 +sleep.js冲突/模式)。围栏与角色声明另起一对名字(memoryFenceOpen/Close),没复用「会话转录」——喂的是清单,标错名字会误导以后读审计的人。角色声明落在 user 段开头,正是你那组 A/C 臂对照给出的结论:只写 system 救不回来。raw_head复用,我按实情做了一处修正:dream 的 warn 日志本来就带raw length+ 前 300 字(dream.js的 finish 分支),所以缺的不是观测,是可查询的审计读数。现在两个 audited runner 支持spec.metadataFrom(result),失败行落parse_failure/raw_len/raw_head/raw_has_bracket。VALID滤空。我把不对称坐实了——summarize.js的VALID吃 7 个类型,lang.js里普通summary的中英两版只列 4 个,codingSummary才列全 7 个,且两个 prompt 都没有「只能用这些值 / 不要额外键」这类约束(grep 零命中)。按你排的 1 → 3 → 2 做:先诊断分叉(arr=N, valid=0+invalid_entries+sample_types,另补你指出的raw_tail)→ prompt 列全并收紧 →type归一化暂缓,因为它改入库分布,要等 1 的统计出来再判断值不值。summarizeParseFailureMaxRetries保持0」的判断我完全同意,而且这正是护栏默认关的理由:这两种形态是该被修的,设成 2 会把它们连着窗口一起丢掉。等 [蒸馏] type 枚举不对称:prompt 给 4 值、VALID 吃 7 值,滤空窗口被报成「没按格式写」并反复重放(#493 第二形态) #502 的 1 或 3 落地后再开阈值更合适。两条你都不用再补证据。#503 合开发版后我回这里报版本号,届时可观测判据是:
dream_consolidate失败行的metadata不再是null,且该 operation_type 的失败次数应显著下降;如果你那边重跑三臂,也欢迎把新的 A/C 读数贴上来——你这两组对照实验是这次定位里最有用的部分。modusensus commentedon Oct 21, 2026
补一条进度:你 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与「没写格式」。fix(distill): fence the transcript and add a parse-failure guard (slo…
Merge pull request #503 from slow-stack/fix/dream-list-fence
fix(dream): fence the five bare listings and make parse failures quer…
fix(distill): tell a filtered-empty window apart from "no JSON", and …
docs(changelog): 补上 #507 / #508 / #513 三条外部 PR 的条目与贡献者小节
modusensus commentedon Nov 2, 2026
主链路已经全部修完并发版,本 issue 关闭。剩下的两条补充建议我另开跟踪,见下。
已交付的部分
4abb257dsh-mneme/src/summarize.js:469memoryFenceOpen/Close,没复用「会话转录」那对名字——喂的是清单不是对话)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 位。少走的那一大圈弯路,全在你那张表里。strange2000 commentedon Nov 2, 2026
这两条我都不写,你们按上面的方向补就行,别挂“等你”。
补的时候顺手对一下两处口径:
parse_fail_streak最好连时间戳一起判失效(比如超过 N 天没更新就清零)。否则游标停很久之后再开跑,会拿几周前攒下的 streak 直接触发熔断,反而成了新的误伤路径。fenceListBody()那套零宽空格断链,别另写一份。两处一旦分叉,下次出问题又得分别查一遍——这次#494和#501/PR #503的重复劳动就是这么来的。主链路修得挺快,那三臂对照没白贴,谢谢。
modusensus commentedon Nov 2, 2026
桉桉让我把这两条补上(你说「不写,你们按上面的方向补就行,别挂等你」,照办)。PR:#549
① streak 持久化
新表
distill_parse_failures(session_id PRIMARY KEY, streak, updated_at),store三个函数 +servicepassthrough。没并进
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 sync83 files、check-sync✓、全量npm test2660 tests / 2659 pass / 0 fail / 1 skip。测试 +6(fence 3 / summarize 2 / store 1),外加三例变异自证:stale 全关 → 失效那条红;固定退回进程内 Map → 重启接续与失效两条都红;清零写成 0 而不是 DELETE → store 那条红。没动提示词、没动校验器、没加配置键。
Merge pull request #549 from slow-stack/fix/493-supplemental
strange2000 commentedon Nov 2, 2026
#549 看过了,比我要的更全。
② 你那个
bump的 bug 正是我担心的形态 —— 失效判据只要在get和bump里各写一次,就会出现「一条路径判了、另一条没判」的分叉。判据只留store那一份,处置是对的。③ 多改
profile-extract.js也对:同样是「把记忆正文当 user 消息喂进去」,只修转录那处等于把同一个洞留在隔壁。共享函数不认围栏名字这点我同意,认名字就是给未来新增围栏埋了个「必须记得改这里」的坑;test/dream-fence.test.js里那条源码静态锁比新增的用例本身更值钱。staleAfterMs不做配置键我也赞成。只提一句、不是要求改:护栏默认关,7 天失效在「两周才开一次 dream」的节奏下会让 streak 每次都读回 0 —— 方向是 fail-open,不会误伤,只是等你哪天把重试阈值调大,保护会偏弱。真要收紧,把失效窗口对齐「一次正常 dream 间隔」就够,仍然不必新增配置键。谢谢,收得干净。