Repository navigation
Description
摘要
sleep 的冲突阶段(phaseConflicts)把当前库里所有同 scope 相似对拼成一个 prompt 一次性送出,输入侧没有任何条数/字符/token 预算;而判 keep 的对不写任何东西(applyDecisions 里是明确的 no-op),相似度与 updated_at 都不变 ⇒ 下一轮同一批对重新配对、重新送审、再判一次 keep。
两侧都没有预算:输出侧的 2048 已经被 #257 / #260 修掉了,输入侧一直没有。库一大,单请求就贴满服务端窗口,然后进入「失败 → 按冷却重排 → 同一输入再失败」的确定性循环。
本机实测:
| 项 | 值 |
|---|---|
sleep_conflict 审计行 |
131 条(success 46 / error 85) |
单请求 input_tokens 峰值 |
163,054(服务端 --ctx-size 163840 ⇒ 占整池 99.5%) |
| 同型三行 | 157,257 / 162,897 / 163,054,全部 no json array in llm output,输出 8,192 / 943 / 783 token,耗时 36.8s / 286.4s / 284.6s |
单轮候选对(pairs) |
443 ~ 449 |
单轮 applied(整轮,含 demotion/patterns) |
3 ~ 7(冲突阶段几乎不产生写入) |
| 连续失败 | 10-26 12:55 → 10-29 11:11 连续 23 轮 conflicts: llm failed(中间没有一次成功),applied 恒为 3;全库累计 98 轮 |
conflict_pending |
841 行(已裁决 822 / 未裁决 19) |
环境
| 项 | 值 |
|---|---|
| dsh-mneme | 0.9.5(8b78cdeecdd3f1081fd74020218915cc282071b3,npm latest) |
| 后端 | 本地 llama.cpp / llama-swap,Qwen3.8-27B-Coder390-Vision |
| 服务端窗口 | --parallel 3 --ctx-size 163840(unified,三槽共用一个 163,840 池) |
| 记忆库 | 活跃 3,409 条(全局 2,663 / 带 scope 746),归档 4,375 条 |
| 行号来源 | npm tarball 的 package/src/...(0.9.5 原文),不是本地 lib/(本地已打自用补丁) |
现象
冲突阶段的送审对象是「去重后全部候选对」,没有上限:
// src/dream/sleep.js:227 候选源 = 全库
const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
// :265
const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
// :283-291 去重(每条记忆至多一对),但没有条数上限
pairs.sort((a, b) => b.similarity - a.similarity);
const selected = [];
for (const p of pairs) {
if (used.has(p.a.id) || used.has(p.b.id)) continue;
...
selected.push(p);
}
// :343-345 一次请求装完全部
const listText = samePairs.map((p) =>
STR.candidateConflicts[language](p)
).join("\n\n");本机这 3,409 条库里,全局(agent_scope/workspace_scope 均 NULL)占 2,663 条,同 scope 候选充足,pairs 稳定在 443~449。
至于那三条 157k~163k 的请求当时装了多少对——查不出来:pairs 只在成功路径的返回值里(:442-444),失败路径(:385 / :388 / :423)不带,审计行也不记。这正是下面的观测缺口之一,也是我只能报"峰值 163,054 token"而不能报"当时送了多少对"的原因。
为什么它不会自己好:keep 不留痕
// src/dream/decisions.js:504-509
// keep is a confirmed no-op: it commits nothing but still records the
// per-id disposition so the outcome covers every snapshot memory.
if (d.action === "keep") {
committed.push({ action: "keep", ids: d.ids });
continue;
}committed 是进程内数组,随 run 结束消失。没有 updated_at 变化、没有落库标记 ⇒ 下一轮 findPotentialConflicts 会原样再算出这一对,再送一次。
分批之后(12 对/批)能直接看到这件事的规模:模型每批只对 1~6 对开口(审计里 all 1~6 decision(s) invalid 的 N 就是它实际回答的条数,其余按 dreamImplicitKeep 补 keep)。也就是说 443 对里真正需要裁决的是极少数,其余全是"再问一次还是 keep"的老对,每轮 token 照付、状态不变。
值得一提的是:conflict_pending 里已经有差不多一半的语义了——
// src/store.js:2837-2845
const reviewed = db.prepare("SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NOT NULL ORDER BY resolved_at DESC, rowid DESC LIMIT 1").get(a, b);
if (reviewed) {
const touched = db.prepare("SELECT count(*) AS c FROM memories WHERE id IN (?, ?) AND updated_at > ?").get(a, b, reviewed.resolved_at).c;
if (touched === 0) return undefined; // 已裁决 + 两侧未改动 ⇒ 别再停车
}但这道判据只用在停车(跨 scope 那条路),同 scope 的 LLM 裁决路径没有等价物。
失败之后不会停
// src/dream/sleep.js:862-875
async function maybeSchedule() {
try {
...
return !!(result && result.ok); // 失败 ⇒ false
} catch (error) { ... return false; }
}
// :825(armNextWindow 的回调里)
const ran = await maybeSchedule();
if (!ran) armNextWindow(); // ⇒ 按剩余冷却重排armNextWindow 的注释把这件事写成了公理:
// :807-809
// 跑成了一轮就不重排:安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
「还有 400+ 对没判」和「这一轮整个失败」都不在这个分支里。于是:失败 → 按 CD 重排 → 同一批输入 → 必然同样失败。本机 10-26 12:55 → 10-29 11:11 连续 23 轮 llm failed,applied 恒 3,pairs 全部为 null(:383-386 就在拿到 text 之前返回了);全库累计 98 轮。
另一点:--ctx-size 163840 是 unified 的整池,一条 163k 的请求会把整个 KV 池吃掉,同一时刻的主对话/其他插件请求一起排队。单请求能吃满池这件事,可能比它自己失败更值得管。
两个观测缺口(导致这类问题从外部无法自证)
- 85 条 error 里 79 条
input_tokens = 0:请求在拿到服务端 usage 之前就没了,error_message只有no json array in llm output或空。到底是"服务端拒了"还是"模型不守格式",事后查不出来(与 autoDream 宽容路径补强(0.7.22 实测三发现):coverage 误伤 / archive 类型越界 / 失败审计缺明细 #104 / [Feature] 后台 LLM 开销可见性:实体抽取与 Sleep Mode 未计入审计表 #250 同族)。 pairs只在成功路径的返回值里(:442-444),失败路径(:385/:388/:423)不带 ⇒ 失败轮从dream_runs里看不出它当时要送多少对。
诉求
A. 候选预算:加 sleepConflictMaxPairs + sleepConflictMaxBatches(或直接按 token 预算切批),一批一批送。现在 skipInvalid 只管校验失败,管不了整请求失败——一批炸掉会带走整轮。
B. 记账:判过的对要留痕(两侧 updated_at 未变就跳过再审)。store.js 的复核项 4 可以直接复用:把同 scope 的裁决结果也写进 conflict_pending(带一个 sleep-batch 记账 之类的原因),下一轮预加载后按 untouchedSince 过滤。不需要新表。
C. 续排与退避:if (!ran) → if (!ran || 还有积压);同一 snapshot_hash 连续失败要拉长间隔,别按固定 CD 全额重付。
D. 审计补字段:失败轮也记 pairs/batches,error 里落流错误的原文。
参考实作
本地按 A/B/C 打了三个键(sleepConflictMaxPairs 默认 12 / sleepConflictMaxBatches 默认 3 / sleepMinIntervalMinutes 默认 0 = 回退上游小时键),只改 src/dream/sleep.js + src/config.js,没动提示词与校验器。同库同轮的效果:
| 打之前 | 打之后(10-30 全天 9 轮) | |
|---|---|---|
| 单请求输入 | 157k ~ 163k token | 806 ~ 2,011 token |
| 每轮送审 | 443 对,一次 | 3 批 × 12 对(batches: 3),余下前移到下一轮 |
llm failed |
连续 23 轮(10-26~10-29) | 0 轮(27 次调用全 success) |
| 台账跳过 | — | 每轮 32 ~ 53 对(判过且两侧 updated_at 未变) |
| 单批失败 | 一批炸掉 = 整轮 failed | 9 轮里 7 轮有 1 |
最后一行是分批真正的价值:整请求失败不再等于整轮归零。但也要说清楚——分批只是把「一次赌 443 对」换成「赌 36 对」,如果没有 B(记账),同样的 400+ 对下一轮还会在原地等着。
需要的话我可以把它整理成 PR(含 config schema、分批循环、台账预载与重排判据)。
Activity
modusensus commentedon Oct 30, 2026
先谢谢这份报告——这个细节密度(行号、审计读数、同型三行的 token/耗时、连续 23 轮的时间跨度)很少见,我们照着逐行回了源码,结论是全部属实,没有一处夸张。
尤其是「
keep是 no-op ⇒ 下一轮原样回来」这条因果链,和第 2 条观测缺口(pairs只在成功路径带出)之间的互相掩护,写得很准:正因为失败轮不带pairs,所以从外面只能看到「又失败了」,看不到「同一批又送了一次」。四条诉求我们都收,但其中三处的落点想跟你商量一下,避免 PR 做出来返工。
A. 候选预算 —— 收,但建议去掉
maxBatches你自己那张表里最有说服力的是最后一行:「9 轮里 7 轮有 1~2 批失败但整轮仍 degraded」。这说明价值不在「最多几批」,而在单批失败不中断整轮。如果加了
maxBatches但循环体里仍然return { status: "failed" },等于白加一个旋钮。建议缩成两个数:
sleepConflictMaxPairs(每批对数,默认 12)sleepConflictMaxPairsPerRun(每轮总对数上限,余下前移到下一轮)外加两条不带配置的实现要求:
samePairs里 splice 掉(src/dream/sleep.js:302那行之后),否则重试/退避会把同一批重复送;这两条比多一个配置键重要得多。
B. 记账 —— 收,但不要写
conflict_pending方向对(复用「已裁决 + 两侧未改动就跳过」这条既有判据),落点我们建议换掉,理由是那张表已经被人工队列占着了:
countConflictPending()(src/store.js:2884-2888)数的是resolved_at IS NULL,直接喂面板的待确认角标;src/service.js:2035那条listConflictPending({ includeResolved: true, limit: 500 })会把库里最近 500 行捞出来做「已裁决」视图——机器记账行按每轮 12~36 对的速度灌进去,几天就能把这个窗口填满,人工真正该看的裁决被挤到 500 行之外;src/store.js:165-170)写的就是「conflict parked for manual review」,塞机器记账进去等于把两种生命周期混在一个字段上。替代:单开一张
conflict_reviewed(memory_a, memory_b, judged_at, outcome),两端 id 沿用saveConflictPending的sort()归一化(src/store.js:2832),主键(memory_a, memory_b),INSERT OR REPLACE天然幂等。预载时按updated_at > judged_at过滤——这就是你要的那条判据,一行不变。复杂度不比复用高,但「人看的队列」和「机器看的账」分开了,以后面板加筛选也不会互相污染。
记账位置提醒一句:别写进
applyDecisions(src/dream/decisions.js:497)。那是 dream 和 sleep 共用的,写进去会顺带污染日间巩固路径。留在phaseConflicts里,在validateDecisions之后对本批送审的全部对统一记(被 archive 的也记——归档后不会再配对,多一行无害且能审计)。C. 退避 —— 收,但
snapshot_hash这个判据用不了src/dream/sleep.js:725的snapshotHash是 run 跑完之后对phases结果算的哈希,不是输入快照:phases全是failed,哈希确实稳定,但它跟「这一轮送了哪些对」毫无关系;runSleep内部算完就丢了——maybeSchedule()的onRun返回值(src/dream/sleep.js:869-872)只有{ ok, status, runId, phases, receipt },调度器拿不到,也存不下来。要做同一件事,建议退化成按连续失败次数指数退避:有效间隔 =
sleepMinIntervalHours× 2^连续失败数,成功清零,封顶。这样判据是「我连着失败了几次」,不需要在两次 run 之间传递任何输入指纹,也不会因为phases里多一个字段就误判成「输入变了」。(顺带:仓库里已经有同款的
autoDreamFailureBackoff(src/settings.js:145-150附近,Issue #292),判据一致,可以照抄。)D. 审计补字段 —— 收,但建议缩小范围
失败轮补
pairs/batches完全同意,就改src/dream/sleep.js:385/388/423三处 return,加上conflictStreamFailure的原文(这个闭包里已经有,:347)。但「79 条
input_tokens = 0」这条我们想单独拆出去:它和 #104 / #250 是同一个族——请求在拿到服务端 usage 之前就断了,这是传输层的问题,不是冲突阶段的问题,混在这个 PR 里会让 review 面从「睡眠预算」扩到「审计/流」两层。建议另开一个 issue,我们跟着修。如果开 PR,这几个闸门提前说一声
src/config.js的 schema 和src/settings.js的白名单成对注册,两边缺一边面板就读不到;test/api.test.js:757有个硬编码计数Object.keys(data.effective).length === 106(只数白名单键)。按上面缩之后的两个键,这个数要 106 → 108,并在上面几行照惯例写一句注释说明来源;sleepMinIntervalMinutes这个键先不用加——sleepMinIntervalHours(src/config.js:588,z.natural().min(1))已经在那儿了,再加一个 0=回退的分钟键会让面板出现两个都叫「最小间隔」的旋钮。指数退避直接叠在小时键上就行;src/之后跑一次npm run sync(lib/是产物,check-sync 会拦未同步的手改);degraded而非failed;Co-Authored-Bytrailer(指向人也不行)。还有一条边界:这个改动不需要动
src/lang.js。你原文也写了「没动提示词与校验器」,我们确认这是对的、也请守住——那边现在有另一条在改,同期碰同一行会白忙一趟 rebase。按这个口径,A + B 是同一个 PR 比较合理(他自己也说了「没有 B,分批只是把一次赌 443 对换成赌 36 对」),C 可以随后单独一个。你要是愿意拆成两个 PR,我们这边排期更顺。
再次感谢,这份报告省了我们很多现场取证。
modusensus commentedon Oct 31, 2026
@chengxinshengglj-png 按你这份报告实现了,PR 在这里:#536。
A / B / C / D 四条都做了,三处落点按上一条回帖的说明做了调整(C 的判据从
snapshot_hash换成了连续失败计数——runSleep里那个 hash 是跑完之后对phases结果算的,不是输入快照,用它判「同一份输入又在失败」不成立)。两处特别想听你的意见:
sleepFailureBackoff的封顶 24h 是我拍的。你那边 449 对的池子,撞上下文之后模型侧通常多久能腾出容量?如果实际要更久,封顶应该抬。sleepConflictMaxPairsPerRun默认 60,估算依据是「60 对 ≈ 5 批 ≈ 10K 输入 token」。你手上有那台库的真实分布(443–449 对、单轮 applied 3–7),这个默认值大概率比我准。另外你原案里的
sleepMinIntervalMinutes(传 0 回退小时键)没有加,理由在 PR 的「三处刻意的取舍 (a)」——面板上会出现两个都叫「最小间隔」的调节项。指数退避直接基于sleepMinIntervalHours算。你本地已经打过 A/B/C 三键的那套,欢迎直接在这个 PR 上挑刺或接管细化。
fix(sleep): 冲突相位补上输入侧预算、裁决台账与失败退避(#529)
Merge pull request #536 from slow-stack/fix/529-sleep-conflict-budget
chengxinshengglj-png commentedon Nov 1, 2026
@modusensus 先说结论:三处落点调整我都同意。B 换表那条尤其对——我原案只想着「别新建表」,没算到
conflict_pending的limit: 500窗口会被机器记账灌满、把面板角标淹掉,那个后果比多一张表严重得多。C 的snapshot_hash判据确实不成立,我认这个错。两个问题,用我这台库(3,400 条活跃记忆、冲突候选池 443~449 对)的实测回答。
1.
sleepFailureBackoff封顶 24h:够,不用抬但要区分两种「撞上下文」,它们的恢复时间差三个数量级:
--ctx-size 163840;10-26 12:55 → 10-29 11:11 连续 23 轮llm failed,跨 70 小时第一种正是 A 要治的(加了 perRun 预算就不会再出现),第二种根本不该退到小时级。所以 24h 对这两种都够——第一种退多久都没用,第二种 1h 就绰绰有余。
max(base, 24h)的写法也对,不会把用户配的 168h 压小。2.
sleepConflictMaxPairsPerRun默认 60:你的估算准,就用它本机 10-30 九轮、27 次
sleep_conflict调用实测:168,和168 一致)config.js:676注释里的 112120 s,一轮 510 分钟,相对sleepMinIntervalHours最小 1h 的窗口只占 8~17%一个附带观察:这组 60~120 s 是在 decode 55 t/s 下测的。同一天本机还踩到 decode 掉到 1.58 t/s(三槽统一池里另一个请求正在做 13 万 token 的冷 prefill,把整个 batch 吃光),同一批从 90 s 变成 30 分钟。这不是默认值的问题——恰恰说明批越小越安全,被大 prefill 插队的概率越低。所以「每批 12 / 每轮 60」这个切法我支持。
3. 一个想补的缺口:A 造了 backlog,C 没接住
合并后的
dsh-mneme/src/dream/sleep.js:1062 / :1087仍然是:注释写的是「跑成了一轮就不重排:安静的库没有要处理的新东西」。这个前提在 A 之前成立,A 之后不成立了——跑成一轮恰恰意味着还有东西要处理:449 对的池子、
perRun60,一轮必然剩 389 对。剩下的只能等下一轮,而下一轮要等sleepMinIntervalHours(min(1)/ 默认 8)到点或新的写库触发,于是本地我打的是
backlogRemains()(跑完还有候选就自排,间隔取短值),同样的池子按 36 对/轮大约 2 小时走完。建议的最小改动,就是让「本轮因 perRun 上限被截断」也参与重排:
两个坑提前说:
consecutiveFailures,否则一个健康的库会因为「池子大」把自己退避到 24h;effectiveMinIntervalMs()约束,两个逻辑别互相打架(armNextWindow里已经在用同一个函数,这点你已经处理好了)。4. 本地迁移
这版落地后我就不需要自己那套补丁了。计划:保留
sleepConflictMaxPairs: 12、把自造的sleepConflictMaxBatches换成sleepConflictMaxPairsPerRun、砍掉原案的sleepMinIntervalMinutes(你说面板上会同时出现两个「最小间隔」——我在自己的配置里已经体会到了,确实该砍)。conflict_pending里那 800 多行机器记账本来也该淘汰,conflict_reviewed是干净的起法。如果第 3 点你认可,我可以直接在现在 main 上的代码开 PR。
modusensus commentedon Nov 1, 2026
@chengxinshengglj-png 第 3 点我认可,方向对:
perRunSkipped > 0确实说明这轮是被预算截断、不是没活干,"安静即无事"那个老前提从 A 落地那一刻起就失效了。但按你写的那个最小改动直接打,会把 #529 那个8 小时一轮的死循环换成 5 分钟一轮的版本,而且更难看出来。三条都是核代码时撞出来的,第 1 条尤其关键。
1. 判据不能用
status === "ok"—— 全判 keep 的轮次永远不是 okadjudicateBatch的返回是(dsh-mneme/src/dream/sleep.js:602):applied === 0到不了 "ok"。而 #529 的核心症状正是"判 keep 不落地"——keep 在applyDecisions(dsh-mneme/src/dream/decisions.js:522-526)里是 confirmed no-op,applied不涨。一整轮 60 对全判 keep,applied是 0、status是noop。拿status === "ok"当"全落地"的信号,恰好在最该追的那个场景里把追堵死。建议别让调度器反推 status 的语义,在
phaseConflicts的返回里加一个正交的布尔:status回答"这轮有没有产出",clean回答"送出去的每一对是不是都落地了"。追 backlog 只该看后者。2. 只改回调里那个
if不够,得喂给armNextWindow的 delaycdMs = effectiveMinIntervalMs(),默认 8h。回调里判了条件是没用的——表本身还挂在 8 小时后;绕过去还有shouldRun(:1100)里同一道闸。所以是两处:delay 的冷却项 +shouldRun的间隔判定。顺带一个实现细节:
ran是布尔(maybeSchedule结尾return !!(result && result.ok)),回调拿不到result。要么在maybeSchedule里把本轮的perRunSkipped/clean存进闭包,要么让它返回结果对象、三处调用点改.ok。我倾向不新增配置键,续排下限直接用
sleepIdleMinutes(backlog 时把冷却项按 0 处理)。一是天然节流——每轮的账本/更新写入会重置lastWriteAt,下一轮至少等一个静默窗口,按你实测 5~10 min/轮 + 5 min 静默正好是你那个量级;二是新键要连带settings.js白名单和dsh-mneme/test/api.test.js:763的旗标计数锁(现在 111),不值得为一件事开三个文件。3. 更隐蔽的回环:全判 keep 的轮次也不进任何"坏"分母
连败计数只认整轮
status === "failed"(:1141-1144),而有产出但有一批失败的轮次整轮状态是degraded→ok: true→ 计数清零。这本身没问题(degraded 确实丢了东西,但不该退避到 24h)。问题是叠加续排之后:一个永远有一批失败、永远截断的库,consecutiveFailures恒为 0、perRunSkipped恒大于 0,就会每 5 分钟跑一轮,每轮都丢两成,而审计上看每轮都"有产出"。所以除了
clean那条合取,再加一道进程内的catchupStreak上限(建议 24——449 对按 60/轮只需 8 轮,留足余量),超过就退回正常间隔。给"池子在长、每轮都 clean 但永远追不完"留刹车。坑 1 你说得对,续排不该碰
consecutiveFailures。坑 2 我建议比"追但受约束"更干脆:consecutiveFailures > 0时根本不追——两个逻辑抢同一只闹钟最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。测试
dsh-mneme/test/sleep-scheduler-rearm.test.js已有注入时钟与定时器的 harness,onRun直接返回造好的形状即可。三条:clean: false(failedBatches > 0)之后,不许挂短表;applied === 0、status === "noop"、clean === true)仍然要追 —— 这条锁的就是第 1 条那个陷阱。第 2 条请做变异自证:把
clean从判据里删掉,它必须红。我给 #536 写"单批抛错不带走整轮"时踩过一次——桩打在本来就兜底的层上(applyDecisions自己会把 service 的异常吞进failures),用例一直绿,删掉守卫还是绿。分支
#536 已在
de5058d合进 main,从 main 切就行,不会冲突。提 PR 之后我来 review。顺带一句无关的:
conflict_pending那 800 多行淘汰前先数一下resolved_at IS NULL还剩几条——台账不碰这张表,但那里可能混着真正的人工待裁决。modusensus commentedon Nov 2, 2026
#545 于 2026-11-02 合入 main(
5cf515a),续排补上了。reopen 的诉求已闭环,逐条对上你 11-01 列的三条陷阱:① 判据不能用
status === "ok"✅改了正交布尔
clean(src/dream/sleep.js:700-706):续排判据是
catchup = backlog && clean(:1053),完全不碰 status。全判 keep 的轮次applied === 0、status === "noop"仍算 clean —— 这条单独有用例锁(test/sleep-catchup.test.js:12)。②
armNextWindow的 delay 与shouldRun两处都改 ✅不是只改回调里的
if:effectiveMinIntervalMs()在catchupWanted && consecutiveFailures <= 0时返回 0(:1111)armNextWindow的cdMs走同一个函数(:1146),且 catchup 时把 delay 下限钉在静默窗口idleMs + 1000(:1156),避免退化成秒级连发shouldRun的间隔判定同样走它(:1175)if (!ran || catchupWanted) armNextWindow();(:1130/:1162),否则续排在第二张表就断③ 全判 keep 的轮次不进坏分母 → 加
catchupStreak上限 ✅CATCHUP_STREAK_CAP = 24(:56),到顶就收手;consecutiveFailures > 0时根本不追(与退避互斥,按你建议的「互斥最省事」)。收手落在catchupWanted状态上而不是只写在effectiveMinIntervalMs里——否则定时器链每 5 分钟醒一次空转(:1226-1229的注释记了这条)。变异自证(你特别要求的那条)
把
catchup判据里的clean删掉再跑:恢复后 10/10 全绿,
git diff干净。另外 #536(
de5058d)的 A/B/C/D 落点也都是按你 10-30 那条回帖调的(conflict_reviewed单独开表、C 的判据换成连续失败计数、sleepConflictMaxPairs=12 /sleepConflictMaxPairsPerRun=60)。谢谢这份报告——行号、审计读数、连续 23 轮的时间跨度,直接省掉了现场取证。