modusensus / modusensus/dsh-mneme
[Bug] autoDream 长期不可用:两处独立缺陷 ——「模型回填 8 位 id 前缀 vs 校验器 36 位整串匹配」与「默认档位+预算让思考型模型返回空体」
- Dominant language
- JavaScript
- Stars
- 100
- Forks
- 11
- Avg merge
- 1h 17m
- Merged PRs (30d)
- 124
Description
> **摘要**:181 轮 autoDream 里 **169 轮失败**(`no json array in llm output` 146 轮、`invalid decisions: 1 errors` 22 轮),只有 5 轮真正合并过任何东西。
>
> 本机定位到**两处互相独立的缺陷**,并各自验证了修复:
>
> 1. **主缺陷(本轮新发现)**:模型把 36 位 UUID **回填成前 8 位前缀**,而校验器用 `snapshot.get(id)` 做**整串精确匹配** → 逐条 `unknown id` → `claimed=0` → 覆盖率闸**整单拒绝**。模型的语义判断其实是对的(实测 78/78 个前缀都能精确对应到窗口里的真实记忆)。
> 2. **次缺陷(承接 #9)**:`dreamReasoningEffort` 默认 `none` 会让 harness 补上模型的 `defaultEffort`(v4-flash 系 = `high`),叠加 `dreamMaxTokens` 的 schema 默认值 **32768**,思考型模型把预算烧在推理上 → 正文为空。
>
> **关键放大器**:这两类失败**既不落库也不落日志**(见「观测缺口」),所以问题被藏了 140+ 轮。
>
> 修复后首轮即成功:`status=degraded, applied=34, merge-archived=62`。
## 环境
| 项 | 值 |
|---|---|
| DSH | `0.1.5-rc.1`(win32,web profile) |
| @modusensus/dsh-mneme | **`0.7.31`**(npm latest,`GET /api/dsh-mneme/info` 确认) |
| agent 默认模型 | `deepseek-official / deepseek-flash`(`~/.dsh/settings.yaml` → `agent-default-model`) |
| dream 路由(复现期) | 先 `deepseek-official / deepseek-v4-flash`(思考型,声明 `off/low/high/max`,**default = high**),后落到 agent 默认 `deepseek-flash` |
| dream 生效预算 | `dreamMaxTokens = 32768`(**schema 默认值,未配置**) |
| 候选窗口 | `dreamMaxSnapshotSize = 200`(默认) |
| 记忆库 | 总 1010 / 活跃 376 / 归档 634 |
## 现象
`dream_runs` 全量(2026-08-28 → 2026-09-12 01:41,共 **181** 轮):
| status | 轮数 | applied 合计 | 说明 |
|---|---|---|---|
| `failed` | **169 (93.4%)** | **0** | 全部无产出 |
| `ok` | 8 | 6 | 其中 7 轮是「模型零有效决策」被隐式 keep 补齐(见附录 B) |
| `reconcile` | 3 | 100 | 决策部分未提交(CAS/回滚) |
| `degraded` | 1 | 34 | **修复补丁落地后的首轮** |
失败原因:
| error | 轮数 |
|---|---|
| `no json array in llm output` | **146** |
| `invalid decisions: 1 errors` | **22** |
| `llm failed` | 1 |
**真正合并过的轮次全库只有 5 轮**,累计 `merge-archived` 295 条。
### 缺陷 1 的实证:8 位前缀 vs 36 位整串
在失败路径落库的校验明细里(见「观测缺口」,这是本机打补丁后才拿到的):
```
errors = ['explicit decision coverage 0% < minimum 1%']
skipped = 27 条
decision[0] merge ids=5 → unknown id "f77a0d17"; …(5 个全 unknown)
decision[1] merge ids=4 → unknown id "23178b3b"; …
…
```
三向归类(`window_ids` 取该轮 `dream_runs.input` 的 200 个 id;`all_ids` 取 `memories` 全表 1002 个 id):
| 分类 | 条数 |
|---|---|
| 在窗口内 | **0** |
| 不在窗口但在库中(活跃/归档) | **0** |
| 库中根本不存在 | **77(去重后)= 全部** |
但**被拒 id 的长度分布是 `{8: 78}`**,而窗口 id 与库中 id 的长度分布**都是 `{36: 200}` / `{36: 1002}`**。把它们按「窗口 id 的前 8 位」匹配:
```
被拒 id 共 78 个,能在窗口前 8 位中找到的:78 / 78
f77a0d17 → f77a0d17-2586-41fe-a789-d69f4d1cee83
bc17c9ed → bc17c9ed-e84c-4b9f-bc36-48f8e9a6b3e0
fe6199e6 → fe6199e6-1933-4520-a028-9ceb2cf79011
```
而提示词给模型看的是**完整 36 位**(`src/dream.js:693` 的聚类分支与 `:706-708` 的兜底分支都是 `id=${m.id}`)。
⇒ **模型认对了要合并哪几条记忆,只是把 id 缩写成了前 8 位;校验器按整串查表,于是整轮成果被判无效。**
### 缺陷 2 的实证:`no json array in llm output`
- 失败点是 `src/dream.js:762-765`:`extractJsonArray(text)` 返回 `null` → 整轮丢弃。
- `streamText` 只收集 `text-delta`;思考型模型把 `maxTokens` 全花在 `reasoning-delta` 时正文为 0 —— 该机制已由 **#9** 用 chunk 分布实测(`{reasoning-delta: 32768, text-delta: 0}`)并获维护者认可。
- 本机命中该模式的两个条件都成立:
- **(a)** `dreamMaxTokens` 停在 schema 默认 **32768**(`src/config.js:67` 的 `.default(32768)`;#9 只把 `max` 抬到 131072,**默认值没抬**)。
- **(b)** `dreamReasoningEffort` 默认 `"none"`(`config.js:81`),而 `resolveDreamEffort` 在 `src/dream.js:392` 直接短路 `if (!configuredEffort || configuredEffort === "none") return null;` → **不发送 `reasoningEffort` 字段** → harness 补上模型声明的 `defaultEffort`(v4-flash 系 = **high**)。即**开箱默认配置恰好把思考预算拉满**,也让 v0.7.26 声称的「defaultEffort 陷阱根治」默认不生效。
- **换非思考模型只压低、未消除**:把路由换到 `deepseek-flash` 后,失败率从 98.0% 降到约 50%(重启后 6 轮里仍有 3 轮 `no json array`),输入 8 万字符量级。所以预算/档位仍是活的隐患。
### 观测缺口(本机为了让根因可见,不得不先打补丁)
> **与 #104 的关系**:该 issue 已包含「**失败审计缺明细**」这一项,本 issue **不主张它是新问题**;这里提供的是可复现的量化证据(168 轮失败零现场、明细 warn 因写在 `return` 之后而不可达),可直接并入 #104 处理。
**失败轮次不落任何可诊断信息**,且日志路径不可达:
```js
// src/dream.js:779-786
if (!ok) {
logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false }); // ← 不传 decisions
}
const skippedInvalid = skipped.length > 0;
if (skippedInvalid) {
logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped …`); // ← 在 return 之后,永远执行不到
}
```
- `finish` 在失败分支**不传 `decisions`** → 落库为 `NULL`;而 `validateDecisions` 明明已经把逐条明细 `skipped: [{index, action, ids, error}]` 返回给了调用方,**却被丢弃**。
- 唯一的明细日志写在 `return` 之后 → **不可达**。
- 本机实测该插件的 `ctx.logger.warn` 也不上 stdout(终端里看不到 mneme 的任何日志)。
- 结果:**146 + 22 = 168 轮失败,没有一轮留下「模型到底输出了什么」的痕迹。**
## 根因
### 主因:校验器不接受 id 前缀
`src/dream/decisions.js` 的 `validateDecisions` 用整串匹配判定 id 合法性,任何未精确命中的 id 都记 `unknown id` 并把该条决策跳过(`dreamSkipInvalid` 默认 `true`)。当模型把所有 id 都缩写成 8 位前缀时,**全部决策被跳过** → `claimed.size = 0` → 覆盖率闸(`explicit decision coverage 0% < minimum …`)整单拒绝 → 整轮丢弃、`applied=0`。
**这是「校验器过严 + 模型习惯」的组合缺陷**,而且因为上文的观测缺口,运维侧完全看不出是 id 格式问题(错误串只有一句覆盖率,明细被丢)。
### 次因:预算与默认档位(承接 #9)
见「缺陷 2 的实证」。
### 第三层:候选窗口(已由 #125 覆盖)
`dreamMaxSnapshotSize`(默认 200)按 `updated_at DESC` 截断候选,窗外旧记忆永不进视野。本机候选池 375 条、窗口覆盖约 53%,且写入速率可达单小时 140 条。**此处不复述、不重复提交。**
## 附属发现:`memoryQualityFilter` 的两个问题(与 dream 失败无关,但同属「静默归档 + 审计线索被销毁」)
**机制**(`src/service.js:960-978`):每次写入由 LLM 打分(0–100,本机全库均分 79.2)并给出理由标签(`low_quality` / `duplicate` / `meta` / `repetitive` / `short_content`);`score < archiveThreshold`(默认 **30**)即**无条件归档**,标签以并集写入,作为「为什么被归档」的审计痕迹。
本机实测(2026-09-12):
| 口径 | 条数 |
|---|---|
| `quality_score < 30` | **150** |
| 其中带 `low_quality` 标签 | **148** |
| 已归档 | 149 |
| 其中 `importance ≥ 4` | **128**(126 条带标签) |
### 问题 1:`importance` 完全不参与归档决策
150 条低分条目 **100% 被归档**,其中 **51 条 `importance=5`、77 条 `importance=4`** —— 一条被标为最重要的记忆,会被一个 LLM 分数**静默归档**:无感知、无通知、无豁免。建议 `importance ≥ 4`(或 ≥5)时只降权不归档,或至少归档时给出可见通知。
> **但这些低分条目并非误伤**:判断器自己给出的理由标签里 **`duplicate` 占 79%(101/128)**,`meta` 37%、`repetitive` 34%,且无一条 `short_content`(说明不是按长度误杀)。它归档的确实多是重复/元信息。
>
> **真正的上游问题在 #127**:本机这 150 条低分条目的 `source` 几乎全是 `session:*`,即 autoSummarize 的蒸馏产物——每轮都在铸造注定被立即归档的条目,**白烧 LLM 配额**。
### 问题 2:信号标签会被后续 `update` 覆盖
`applyQualityDisposition` 用**并集**写标签(`src/service.js:967`),但更新路径是**整组替换**:
```
memory_update(args) lib/tools.js:233
→ service.update(id, {…, tags}) src/service.js:1485
→ store.update(id, p) (p.tags 直接写入,不再并集)
```
于是任何带 `tags` 的更新都会冲掉判断器留下的 `low_quality`/`duplicate`/`meta` 等标记。**实证对账**:150 条低分条目中 148 条带 `low_quality`,**缺的 2 条恰好都是被 `memory_update` 改过的**;而另一条创建后未再更新的同类条目标签完好。
后果:**「这条记忆为什么被归档」变得不可追溯** —— 无法区分「质量门自动归档」「用户手动去重归档」「dream 合并归档」。本机就因此一度误判为"是被 dream 合并归档的",不得不额外排查全部 dream 轮次的 `outcome`/`decisions` 才排除。建议更新时把系统信号标签并集保留(用户自传的标签照常生效)。
## 附属发现 2:向量层从未配置 —— 五个功能静默失效,而 `/semantic` 报 `ready: true`
**机制**(`src/embedding.js:85-86` 与 `:111-112`;`local-embedder.js` 的各后端同理):
```js
const cfg = settings.getVectorConfig();
if (!cfg?.enabled || !cfg.baseUrl || !cfg.apiKey || !cfg.model) return; // ← 静默返回
```
**本机实测(2026-09-12)**:
```
GET /api/dsh-mneme/vector-config → {"enabled": false, "baseUrl": "", "apiKey": "", "model": ""}
kv(user_settings) → 只有 external_api 与 feature_flags,没有任何向量配置
memories → 总 1035 / 无向量 1035(100%)
vector_meta → 空表(0 行)
llm_audit_logs → 435 行里没有一种与嵌入沾边的 operation_type
```
即**一次嵌入请求都没有发出去过**(在第 86 行就 `return` 了),因此**不产生任何错误日志** —— 连"未配置"这个事实都无处可查。
**静默失效的五个功能**:
| 功能 | 表现 |
|---|---|
| 语义召回(向量检索) | 无向量可搜,只能靠关键词 / BM25 兜底 |
| `searchSemanticDedup` | 开关为 `true` 但不生效 |
| `rerankEnabled` | `reranker: null` |
| sleep 的冲突检测 | 被跳过(报告里写 `"reason": "no usable vectors"`) |
| dream 的语义聚类预分组 | 退化到全量窗口列表兜底(`dream.js:705-708`) |
对照:**不依赖向量的功能是正常的** —— `entities` 121 行、`recall_runs` 2177 行、`llm_audit_logs` 435 行。所以问题精确定位在向量这一层。
**问题:`GET /api/dsh-mneme/semantic` 报 `ready: true`**,但同一响应里 `index.embeddedCount = 0 / totalCount = 346`、`modelHash` / `dimension` / `reranker` 全为 `null`。`ready` 只反映"嵌入器对象已构造",不反映索引里有没有数据。建议:`ready` 结合 `embeddedCount / totalCount` 计算;并在 `getVectorConfig()` 为空时**至少记一条 info/warn**。
> **补充**:本机原先 `embedProvider` 取 `openai`,而该分支**必须**依赖上表那四个字段(`index.js:210-213`);把 `embedProvider` 改成 `local`/`ollama` 则走 `local-embedder.js`(本机已装 `@huggingface/transformers` + `onnxruntime-node`,但 HF 模型缓存为空,首次使用需下载)。
### 本 issue 涉及的「绿色假阳性」共性清单
这一类问题在本仓库已经出现 5 次,模式完全一致:**状态显示正常,功能实际是死的。**
| # | 表面状态 | 实际情况 |
|---|---|---|
| 1 | `/semantic` → `ready: true` | 向量索引 0/346,嵌入器从未被配置 |
| 2 | 面板「测试连通性」→ 通过 | `maxTokens: 1024` + 一句提示词;空 reply 也算通过;与 dream 路径无关 |
| 3 | `dream_runs.status` 静默记 `ok` | 模型零有效决策被隐式 keep 补成 200 条(`dreamMinExplicitCoverage: 0` 时) |
| 4 | `llm_audit_logs` 有 435 行 | `input_tokens` / `output_tokens` / `cost_usd` **全部为 0** |
| 5 | 记忆带 `low_quality` 标签、看似可审计 | 一次 `memory_update` 就把信号标签冲掉 |
建议给这五处各加一条"真实状态"断言或启动自检(例如启动时校验 `embeddedCount > 0`,否则明确警告),比逐个修更省事。
## 修复与验证
本机通过两个本地补丁验证(pnpm patch,合并进同一份 `@modusensus__dsh-mneme@0.7.31.patch`):
1. **让失败可观测**:`!ok` 分支改为 `finish({ …, decisions: [{ _validationFailed: true, errors, skipped }] })`,并把 `skipped` 明细的 warn 提到 `return` 之前。
2. **让前缀可解析**:在 `validateDecisions` 入口把「唯一前缀」解析回完整 id(**git 短哈希式**)——仅在 snapshot 内解析,前缀有歧义或匹配不到则原样保留、仍由后续校验如实报 `unknown id`;就地改写 `ids` / `keepSource` / `winner` / `loser`,使下游 `applyDecisions` 也拿到完整 id。
**修复后首轮**(补丁需重启 dsh 生效;`cordis.patch.yml` 那类配置是 live 重载,插件源码不是):
```
status = degraded (skipInvalid 语义:单条非法只跳过、合法子集照常应用)
applied = 34
decisions= keep=103 merge=31 archive=2 conflict=1
outcome = merge-archived: 62 · merge-keep: 31 · conflict-winner: 1 · conflict-archived: 1
库况 = 活跃 436 → 376(−60)/归档 566 → 634(+68) ← 单轮
```
全库成功合并轮次复盘:
| # | 时间(北京) | status | applied | merge-archived |
|---|---|---|---|---|
| 1 | 08-28 21:48 | ok | 6 | 6 |
| 2 | 09-10 20:39 | reconcile | 31 | 90 |
| 3 | 09-10 20:43 | reconcile | 35 | 65 |
| 4 | 09-12 00:52 | reconcile | 34 | 72 |
| **5** | **09-12 01:41** | **degraded** | **34** | **62** |
前 4 轮都发生在 `deepseek-v4-flash` 时代(那时模型回填完整 id),第 5 轮是首次在 `deepseek-flash` 上成功、也是首次由前缀解析解开的。
## 复现
1. dream 路由指向一个会**缩写 id** 的模型(本机 `deepseek-flash`);
2. 记忆库涨到候选窗口 200 条、单轮输入 8 万字符量级;
3. 等 autoDream 触发,查 `dream_runs`:`status='failed'`、`error='invalid decisions: 1 errors'`、`applied=0`、`decisions IS NULL`;
4. 想看到真正的原因,需要先让失败路径落库(见上文补丁 1)——否则 `skipped` 明细不可得。
窗口口径(与 `src/dream.js:600-612` 一致):活跃非 summary、按 `updated_at` 倒序取前 `dreamMaxSnapshotSize` 条。
## 建议(按优先级)
1. **校验器接受「唯一前缀」**:在 `validateDecisions` 入口把短 id 解析回完整 id(git 短哈希式),歧义/无匹配才判 `unknown id`。这是本机解锁合并的决定性一步,且**不改变任何用户可见语义**。
(替代方案:在 `CONSOLIDATION_PROMPT` 里明确要求「原样复制 36 位完整 id」,并把 few-shot 示例里的 `"m1"`/`"m2"` 换成真实 UUID —— 当前示例的短占位符很可能在诱导模型缩写。)
2. **失败路径必须落 `decisions` / `skipped`**:把 `validateDecisions` 已经返回的逐条 `{index, action, ids, error}` 写进 `dream_runs.decisions`,并把那行 `skipped` warn 提到 `return` 之前。否则上百轮失败级的故障完全没有现场。
3. **把 `dreamMaxTokens` 的 schema 默认值从 32768 抬到 131072**(或按输入长度自算预算)。#9 只放开了 `max`,`default` 未动,等于把已修好的问题留给了默认配置。
4. **`dreamReasoningEffort` 默认不应是 `none`**:对"必须产出 JSON"的后台调用,默认发送该模型支持的最低档,而不是省略字段让 `defaultEffort` 顶上。
5. **`resolveDreamEffort` 对 `none` 的短路(`dream.js:392`)应区分"用户显式选了 none"与"默认未配置"**:前者尊重省略语义,后者取最低支持档。这是 v0.7.26 那处"defaultEffort 陷阱根治"真正生效的前提。
6. **修复 `llm_audit_logs` 的 token/cost 恒为 0**(见附录 B)。这是定位本类问题唯一的量化面。
7. 可选:失败轮持久化原始 LLM 文本(或至少 `raw length` + 前 300 字节),现在只进不可达的日志。
8. **`memoryQualityFilter`:让 `importance` 参与归档决策**(≥4/≥5 只降权不归档,或归档时给出可见通知);**并让系统信号标签不被 `update` 覆盖**。详见「附属发现」。
9. **向量层:让 `/semantic` 的 `ready` 反映索引覆盖度**(`embeddedCount / totalCount`),并在嵌入器未配置(`getVectorConfig()` 为空)时至少记一条 info/warn。否则"从未配置"会让五个依赖向量的功能长期静默失效,而状态页一直是绿的。详见「附属发现 2」。
10. 可选(更强):把「附属发现 2」里那张**绿色假阳性清单**做成启动自检 —— 状态页只在**功能真能工作**时才显示绿。
## 与既有 issue 的关系
- **#9(closed)**:同一失败字符串与机制(`no json array`),已修方案 A/B。本 issue 是"**修复在默认配置下不生效**"的补充,并补上"换非思考模型只压低未消除"的实测。
- **#25(closed)**:`resolveRoute` config-first 已修,本机路由确实按配置生效,不涉及。
- **#125(open)**:候选窗口盲区。**本 issue 不重复提交**,仅作为第三层引用。
- **#104(open)**:宽容路径补强,已含「coverage 误伤」与「**失败审计缺明细**」。本 issue 的观测缺口一节**与它同源、不重复主张**;而「**前缀不被接受**」是**新的一类**——不是宽容度问题,而是 id 匹配方式问题,建议在 #104 之外单独处理。
- **#48(closed)**:`memory_delete` 传**截断 id** 时静默空转 —— **同一类「截断 id」问题**,当时修的是 delete 路径;本 issue 报的是**校验器**路径(`validateDecisions` 只认整串)。建议统一处理(或在两处都做唯一前缀解析)。
- **#89(open)**:弱模型下 `invalid decisions` 整单拒绝。本机那 22 轮同一错误串属同一族,但**成因不同**(这里是 id 格式,不是模型能力),故不重复主张。
- **#26(closed)**:跨类型 merge 触发 fail-safe 整单拒绝 —— 同属"一条不合格就整单作废"的家族,而本 issue 的前缀问题会**放大**它(78 个 id 全被判非法 → 必然整单拒绝)。
- **#127(open)**:写入洪泛,是"重复不断产生"的上游放大器。
- **#128(open)**:其附录 A 提到 `/features` 的 `effective` 优先于 `cordis.patch.yml`。补充实测:该"优先"**只对下次启动成立**(`src/index.js:146` 把 kv `feature_flags` 合到配置之上,而 `index.js:129-130` 注明只在下次启动生效);本机 kv 里存着 `"dreamProvider":""`/`"dreamModel":""`,重启后会把 patch 里的路由**静默清空**、回落 agent 默认模型(已实测 `POST /api/dsh-mneme/test-model` 返回的 `modelId` 从 `deepseek-v4-flash` 变为 `deepseek-flash`)。建议 `effective` 区分 `live` / `nextBoot` 两个口径。
## 附录 A:生效配置
`GET /api/dsh-mneme/features` 的 `effective`(2026-09-11 实测):
```json
{
"autoDream": true, "autoInject": true, "autoSummarize": true, "hotMemoryEnabled": true,
"sleepModeEnabled": true, "heatEnabled": false, "conflictFreezeEnabled": false,
"allowCrossTypeMerge": false, "dreamSkipInvalid": true, "llmAudit.enabled": true,
"memoryQualityFilter.enabled": true,
"dreamMinIntervalMinutes": 0, "dreamMaxTokens": 32768,
"dreamProvider": "", "dreamModel": "",
"distillMaxChars": 24000, "rerankEnabled": true, "searchSemanticDedup": true
}
```
另注:面板的「测试连通性」按钮**不能代表巩固可用**——它 `maxTokens: 1024`、提示词仅 `Reply with exactly one word: ok`,且**只在流报错时返回 `ok:false`**(空 reply 也算通过)。作者注释已点破机制(`16 会被 reasoning 全部吃掉,reply 恒为空`):他们把**测试按钮**的预算 16→1024 修了,却没动 dream 那条路径。
## 附录 B:审计方法与库况
- **方法**:把 `memory.db` + `-wal` + `-shm` 复制到临时目录后以只读方式打开,**不触碰运行中的库**,用 Python `sqlite3` 分析 `dream_runs` / `llm_audit_logs` / `memories`。
- **判定 `keep` 是模型输出还是代码补齐**:取 `dream_runs.decisions` 的 id 序列与 `dream_runs.input` 快照的 id 序列(`updated_at DESC`)做**尾对齐** —— 代码 `decisions.js:183` 补的 keep 严格按快照顺序、落在数组末尾。实测:真实合并轮只有尾部对齐(补 1/6/1/3 条),空转轮从头就对齐(整条 200 条都是补的)。另:`200 × 64 + 199(逗号) + 2(括号) = 13,001`,即那批 `keep=200` 的 `decisions` 长度是**确定性代码产物**,不能当作模型行为指标。
- **判定 `unknown id` 的性质**:把被拒 id 分别与「窗口 id 集」「库中全表 id 集」比对,并额外做「窗口 id 前 8 位」匹配 —— 本机正是靠这一步把根因锁定到"8 位前缀"。
- 库况(2026-09-12 01:42):总 1010 / 活跃 376 / 归档 634。
- **`llm_audit_logs` 全部 435 行里(`dream_consolidate` 183 + `summarize_compress` 238 + `dream_summarize` 14),`input_tokens` / `output_tokens` / `cost_usd` 三项为 0** —— `runAuditedLlm` 的 `reportUsage` 从未拿到 usage,token 计量与成本核算完全失效(见建议 6)。
Contributor guide
Assessment
This issue has not been assessed yet.