From 0e7c0119456ba72adc39414d825d7fa0d8a7cb00 Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 21 Oct 2026 04:50:59 +0800 Subject: [PATCH 1/2] fix(distill): fence the transcript and add a parse-failure guard (#493) The rendered transcript was sent bare in a user message while the format instruction lived only in the system prompt. Models read it as their turn to speak: they continued or commented on the conversation, the output had no '[' at all, and parseSummaryJsonResult bailed out at the bracket early-return with 'invalid summary JSON'. A parse failure does not advance the cursor (semantically 'not distilled yet, retry later'), so every turn/end retried the same window at the same temperature - same input, same failure - while the window kept growing. The reporter's 20 failures show input tokens rising 8194 -> 11118 with finish_reason=stop. Two changes: (1) the transcript now sits inside an explicit fence with a user-side role reminder ('you are a third-party distiller, not a participant'), because the model decides what the text is from the message it arrives in - a system-only constraint still produced 0/2 JSON in testing. The fence is deterministic so retries stay byte-identical. (2) summarizeParseFailureMaxRetries (default 0 = off) consumes the cursor and skips the window once a session fails N times in a row; default off because discarding content has a real cost and short windows are worth retrying. Parse-failure audit rows now carry distillChars / parse_failure / raw_len / raw_head / raw_has_bracket / streak instead of metadata: null, so 'what shape did the model actually emit' is answerable afterwards. distillChars now counts transcript body only, otherwise the added wrapper would push tiny windows past summarizeMinWindowChars. --- dsh-mneme/CHANGELOG.md | 5 + dsh-mneme/README.md | 1 + dsh-mneme/docs/CONFIGURATION.md | 1 + dsh-mneme/lib/config.js | 8 ++ dsh-mneme/lib/lang.js | 11 ++ dsh-mneme/lib/settings.js | 3 + dsh-mneme/lib/summarize.js | 63 ++++++++- dsh-mneme/src/config.js | 8 ++ dsh-mneme/src/lang.js | 11 ++ dsh-mneme/src/settings.js | 3 + dsh-mneme/src/summarize.js | 63 ++++++++- dsh-mneme/test/api.test.js | 4 +- dsh-mneme/test/summarize.test.js | 221 +++++++++++++++++++++++++++++++ 13 files changed, 395 insertions(+), 7 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 2719dcc6..d2ae3130 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## 🐛 修复 + +- **蒸馏的转录被模型当成「该我回话了」:同一窗口确定性失败,重试 20 次、越试越贵(#493)**:转录以「用户:…」开头、**裸放**在一条 user 消息里(`src/summarize.js` 的 `collectMessages`),格式指令只在 system 里。模型于是把它当成当前发言接着往下说/点评,输出里连 `[` 都没有,`parseSummaryJsonResult` 在括号早退处直接判 `invalid summary JSON`;而解析失败不推进游标(语义是「这段还没提炼完,下次接着来」),下一个 `turn/end` 重试**同文同温**——同输入同错,窗口还随新事件继续变大。报告者的 20 条失败 input tokens 8194 → 11118 单调递增、`finish_reason = stop`,正是这条路径;本机注入恒定返回中文点评的假 stream 复现出 19 次额外调用、落库 0 条、游标不动。改法两条:①**围栏 + 角色重申**——user 消息现在以「你是第三方提炼员,不是下面这段会话的参与者」开头,转录包进 `<<<会话转录开始>>>` / `<<<会话转录结束>>>`;围栏是确定性的,重试收到的转录与首次逐字节相同(既有回归用例依赖这条);system 保持原样,因为模型对「这段文本是什么」的判断由它所在的 user 消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。②**失败护栏**(新键 `summarizeParseFailureMaxRetries`,默认 `0` = 关):连续失败到阈值后强制消费游标跳过该窗口,避免一条会话被同一窗口卡死。默认关是因为放弃内容本身有代价——短窗口偶发失败仍值得重试。 +- **解析失败行的审计 `metadata` 恒为 `null`,事后无法回答「模型到底吐了什么形状」**:成功路径才给 `metadata` 赋值,失败早退分支从不赋值——90 条历史 `invalid summary JSON` 一条原始输出都没留。现在失败行恒带 `distillChars` / `parse_failure` / `raw_len` / `raw_head`(截断 300 字符)/ `raw_has_bracket` / `streak`。`raw_has_bracket` 是区分「模型没按格式写」与「格式对但内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。只加计量、不加跳过决策(同 #239 的 `distillChars` 取舍)。**刻意不改的两处**:`llm_audit_logs.session_key` 仍为 NULL(`test/pre-inject-gate.test.js:155` 显式锁着「LLM 调用行不写会话键」,补上会改动 #254 写入准入的话题回填行为);不显式传 `temperature`(温度由宿主/路由决定,插件覆盖会改变所有用户的输出分布)。 + ## [0.9.3] - 2026-10-20 ## 🆕 新增 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index da00ecab..32040583 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -371,6 +371,7 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz" | `summarizeMaxEntriesPerRun` | `0` | 单次蒸馏产出记忆条数上限(0-50,0=不限):节流防单会话大量重复条目(#127) | | `summarizeMinWindowChars` | `0` | 蒸馏前零 LLM 预判:窗口可蒸馏文本不足此字符数直接跳过调用(0-100000,0=关);skip 原因写入审计(#239) | | `summarizeMaxRunsPerSession` | `0` | 每会话最多发起多少次蒸馏 LLM 调用(0-1000,0=不限);只计真实调用,被预判拦下的不占额度(#239) | +| `summarizeParseFailureMaxRetries` | `0` | 连续几次解析失败后放弃当前窗口(0-10,0=关):确定性失败(模型把转录当成该自己回话,`finish_reason=stop`)重试收益为 0,而窗口只会越攒越大;设 2-3 可止血,达到阈值强制消费游标跳过,失败行 metadata 带 `raw_head`/`raw_has_bracket`/`streak`(#493) | | `summarizePeakHours` | 空 | 高峰时段(本地时间,逗号分隔、可带星期前缀,支持跨零点):空=关;命中时蒸馏顺延到非高峰、窗口累积后一次蒸、skip 原因入审计。例:按高峰计费的供应商可写 `mon-fri 08:00-12:00,14:00-18:00`(如 DeepSeek,以其官方定价页为准)(#239) | | `summarizePeakMaxDeferMinutes` | `120` | 高峰顺延上限(0-1440 分钟,0=不设上限):到点仍处高峰就照常跑,避免长高峰把蒸馏饿死(#239) | | `summarizeDedupeMode` | `off` | 落库前去重档位:`off`(默认=现状)/ `title`(零成本,仅拦完全同名)/ `vector`(复用 embedding 列做同会话语义近邻,无 LLM 调用,#127) | diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 918f12a7..3442e375 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -92,6 +92,7 @@ | `summarizeMaxEntriesPerRun` | `0` | 单次蒸馏最多落库条数(≤50) | 0 = 不限 | | `summarizeMinWindowChars` | `0` | 窗口可蒸馏文本下限(字符),不足直接跳过 LLM(#239) | 被挡窗口照常消费游标并留 skip 审计 | | `summarizeMaxRunsPerSession` | `0` | 每会话蒸馏次数预算,只计真实发起的调用(≤1000) | 0 = 不限;进程内计数,重启清零 | +| `summarizeParseFailureMaxRetries` | `0` | 连续解析失败几次后放弃当前窗口(0–10,0 = 关,#493) | 达到阈值强制消费游标跳过该窗口;失败行 metadata 恒带 `raw_head`/`raw_has_bracket`/`streak` | | `summarizePeakHours` | `""` | 高峰时段串(`"09:00-18:00"` / `"mon-fri 08:00-12:00,14:00-18:00"`,支持跨零点) | 命中高峰不调 LLM、顺延补跑;任一写法非法整串按未配置处理 | | `summarizePeakMaxDeferMinutes` | `120` | 高峰顺延上限(分钟,≤1440) | 0 = 不设上限;到点仍处高峰照跑(bypassPeak) | | `summarizeDedupeMode` | `off` | 落库前去重档位:`off` / `title`(拦完全同名)/ `vector`(同会话语义近邻) | `vector` 复用已有 embedding 列,无 LLM 调用 | diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index b79b5c47..4462d100 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -35,6 +35,14 @@ export const Config = z.object({ // 额度浪费在零成本窗口上。计数是进程内 per-session(与最小间隔闸门同生命 // 周期),宿主重启即清零,与 #229 的游标持久化是两件事。 summarizeMaxRunsPerSession: z.natural().min(0).max(1000).default(0), + // Issue #493:连续解析失败几次后放弃当前窗口(0 = 关闭,行为与历史版本逐字节 + // 一致)。失败默认不推进游标——语义是「这段还没提炼完,下次接着来」,这对 + // 偶发失败(429/5xx/超时)是对的。但确定性失败(模型把转录当成该自己回话, + // finish_reason=stop,同文同温)重试收益为 0,而窗口只会随新事件继续变大, + // 于是一条会话能被同一窗口卡死几十轮、每轮都白烧一次完整调用。设成 2-3 即 + // 可止血:达到阈值后强制消费游标跳过该窗口,并留一行带 streak/raw_head 的 + // error 审计。默认关是因为放弃内容本身有代价——短窗口偶尔失败仍值得重试。 + summarizeParseFailureMaxRetries: z.natural().min(0).max(10).default(0), // Issue #239(第 4 项,错峰队列):高峰期蒸馏顺延。空串 = 关闭,行为与现状逐 // 字节一致。取值是本地时间的时段列表(逗号分隔,支持跨零点),可带**星期前缀** // (可省;ISO 1=周一…7=周日,也认 mon..sun;省略 = 每天): diff --git a/dsh-mneme/lib/lang.js b/dsh-mneme/lib/lang.js index a2e77602..3f78b707 100644 --- a/dsh-mneme/lib/lang.js +++ b/dsh-mneme/lib/lang.js @@ -155,6 +155,17 @@ export const STR = { zh: (status, out) => `代码执行(${status}):${out}`, en: (status, out) => `Code execution (${status}): ${out}` }, + // Issue #493:转录围栏 + 角色重申。转录原先裸放在 user 位上,模型会把它当成 + // 「轮到我说了」接着对话往下写/点评,输出里连 `[` 都没有,解析器在括号早退处 + // 直接判 invalid summary JSON;同文同温重试 → 同一窗口确定性失败,窗口还越攒 + // 越大。围栏与角色句必须落在 user 侧(system 保持原样):模型对「当前这段文本 + // 是什么」的判断由它所在的 user 消息决定,只写进 system 救不回来。 + transcriptFenceOpen: { zh: "<<<会话转录开始>>>", en: "<<>>" }, + transcriptFenceClose: { zh: "<<<会话转录结束>>>", en: "<<>>" }, + distillRoleReminder: { + zh: "你是第三方提炼员,不是下面这段会话的参与者。不要回答、点评或续写转录里的内容;只按 system 的要求输出 JSON 数组。", + en: "You are a third-party distillation assistant, not a participant in the conversation below. Do not answer, comment on, or continue the transcript; output only the JSON array required by the system prompt." + }, // --- dream.js:聚类快照标记 ------------------------------------------------- clusterHeader: { zh: (n) => `# 聚类 ${n}`, en: (n) => `# Cluster ${n}` }, diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index d9a2acea..21985a5b 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -247,6 +247,9 @@ const FEATURE_FLAG_INT_RANGES = { // Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。 summarizeMinWindowChars: [0, 100000], summarizeMaxRunsPerSession: [0, 1000], + // Issue #493:连续解析失败几次后放弃当前窗口(0 = 关)。与 config.js 的 + // z.natural().min(0).max(10) 成对。 + summarizeParseFailureMaxRetries: [0, 10], // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。 summarizePeakMaxDeferMinutes: [0, 1440], // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。 diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 13be29e4..b920819f 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -404,12 +404,29 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th if (seq === undefined) return max; return max === undefined ? seq : Math.max(max, seq); }, undefined); + const transcriptBody = trimTranscript(lines.join("\n"), maxChars); return { messages: lines.length - ? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })] + ? [createUserMessage({ + content: [{ + type: "text", + // Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话 + // 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 + + // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是 + // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。 + // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。 + text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}` + }], + source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } + })] : [], hasEvents: events.length > 0, - lastSeq + lastSeq, + // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的 + // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少 + // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值, + // 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。 + transcriptChars: lines.length ? transcriptBody.length : 0 }; } @@ -534,6 +551,10 @@ export function createSummarizer(ctx, service, config, deps = {}) { const deferredRuns = new Map(); // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。 const lastDistilledSeq = new Map(); + // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性 + // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即 + // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。 + const parseFailureStreak = new Map(); let disposed = false; /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */ @@ -701,7 +722,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 - const distillChars = messages.reduce( + // + // Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把 + // user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按 + // 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于 + // 被自己的提示词旁路掉。 + const distillChars = collected.transcriptChars ?? messages.reduce( (total, message) => total + (Array.isArray(message.content) ? message.content : []) .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), 0 @@ -869,12 +895,42 @@ export function createSummarizer(ctx, service, config, deps = {}) { const parsedResult = parseSummaryJsonResult(text || assembledText); // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。 if (!parsedResult.ok) { + // Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为 + // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何 + // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。 + const raw = text || assembledText || ""; + const streak = (parseFailureStreak.get(session.id) ?? 0) + 1; + parseFailureStreak.set(session.id, streak); + // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。 + // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随 + // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 + // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。 + const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0; + const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries; + if (skipWindow) { + // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 + commitCursor(session.id, nextSeq); + parseFailureStreak.delete(session.id); + } if (audit) { audit.status = "error"; audit.errorMessage = "invalid summary JSON"; + audit.metadata = { + distillChars, + parse_failure: true, + raw_len: raw.length, + raw_head: raw.slice(0, 300), + // 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」 + // 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。 + raw_has_bracket: raw.includes("["), + streak, + ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), + ...(skipWindow ? { skipped: "parse-failure-streak" } : {}) + }; } return; } + parseFailureStreak.delete(session.id); const parsed = parsedResult.entries; // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进 // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。 @@ -1064,6 +1120,7 @@ export function createSummarizer(ctx, service, config, deps = {}) { inFlight.clear(); lastRunAt.clear(); lastDistilledSeq.clear(); + parseFailureStreak.clear(); } }; } diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index b79b5c47..4462d100 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -35,6 +35,14 @@ export const Config = z.object({ // 额度浪费在零成本窗口上。计数是进程内 per-session(与最小间隔闸门同生命 // 周期),宿主重启即清零,与 #229 的游标持久化是两件事。 summarizeMaxRunsPerSession: z.natural().min(0).max(1000).default(0), + // Issue #493:连续解析失败几次后放弃当前窗口(0 = 关闭,行为与历史版本逐字节 + // 一致)。失败默认不推进游标——语义是「这段还没提炼完,下次接着来」,这对 + // 偶发失败(429/5xx/超时)是对的。但确定性失败(模型把转录当成该自己回话, + // finish_reason=stop,同文同温)重试收益为 0,而窗口只会随新事件继续变大, + // 于是一条会话能被同一窗口卡死几十轮、每轮都白烧一次完整调用。设成 2-3 即 + // 可止血:达到阈值后强制消费游标跳过该窗口,并留一行带 streak/raw_head 的 + // error 审计。默认关是因为放弃内容本身有代价——短窗口偶尔失败仍值得重试。 + summarizeParseFailureMaxRetries: z.natural().min(0).max(10).default(0), // Issue #239(第 4 项,错峰队列):高峰期蒸馏顺延。空串 = 关闭,行为与现状逐 // 字节一致。取值是本地时间的时段列表(逗号分隔,支持跨零点),可带**星期前缀** // (可省;ISO 1=周一…7=周日,也认 mon..sun;省略 = 每天): diff --git a/dsh-mneme/src/lang.js b/dsh-mneme/src/lang.js index a2e77602..3f78b707 100644 --- a/dsh-mneme/src/lang.js +++ b/dsh-mneme/src/lang.js @@ -155,6 +155,17 @@ export const STR = { zh: (status, out) => `代码执行(${status}):${out}`, en: (status, out) => `Code execution (${status}): ${out}` }, + // Issue #493:转录围栏 + 角色重申。转录原先裸放在 user 位上,模型会把它当成 + // 「轮到我说了」接着对话往下写/点评,输出里连 `[` 都没有,解析器在括号早退处 + // 直接判 invalid summary JSON;同文同温重试 → 同一窗口确定性失败,窗口还越攒 + // 越大。围栏与角色句必须落在 user 侧(system 保持原样):模型对「当前这段文本 + // 是什么」的判断由它所在的 user 消息决定,只写进 system 救不回来。 + transcriptFenceOpen: { zh: "<<<会话转录开始>>>", en: "<<>>" }, + transcriptFenceClose: { zh: "<<<会话转录结束>>>", en: "<<>>" }, + distillRoleReminder: { + zh: "你是第三方提炼员,不是下面这段会话的参与者。不要回答、点评或续写转录里的内容;只按 system 的要求输出 JSON 数组。", + en: "You are a third-party distillation assistant, not a participant in the conversation below. Do not answer, comment on, or continue the transcript; output only the JSON array required by the system prompt." + }, // --- dream.js:聚类快照标记 ------------------------------------------------- clusterHeader: { zh: (n) => `# 聚类 ${n}`, en: (n) => `# Cluster ${n}` }, diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index d9a2acea..21985a5b 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -247,6 +247,9 @@ const FEATURE_FLAG_INT_RANGES = { // Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。 summarizeMinWindowChars: [0, 100000], summarizeMaxRunsPerSession: [0, 1000], + // Issue #493:连续解析失败几次后放弃当前窗口(0 = 关)。与 config.js 的 + // z.natural().min(0).max(10) 成对。 + summarizeParseFailureMaxRetries: [0, 10], // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。 summarizePeakMaxDeferMinutes: [0, 1440], // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。 diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 13be29e4..b920819f 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -404,12 +404,29 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th if (seq === undefined) return max; return max === undefined ? seq : Math.max(max, seq); }, undefined); + const transcriptBody = trimTranscript(lines.join("\n"), maxChars); return { messages: lines.length - ? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })] + ? [createUserMessage({ + content: [{ + type: "text", + // Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话 + // 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 + + // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是 + // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。 + // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。 + text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}` + }], + source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } + })] : [], hasEvents: events.length > 0, - lastSeq + lastSeq, + // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的 + // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少 + // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值, + // 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。 + transcriptChars: lines.length ? transcriptBody.length : 0 }; } @@ -534,6 +551,10 @@ export function createSummarizer(ctx, service, config, deps = {}) { const deferredRuns = new Map(); // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。 const lastDistilledSeq = new Map(); + // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性 + // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即 + // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。 + const parseFailureStreak = new Map(); let disposed = false; /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */ @@ -701,7 +722,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 - const distillChars = messages.reduce( + // + // Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把 + // user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按 + // 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于 + // 被自己的提示词旁路掉。 + const distillChars = collected.transcriptChars ?? messages.reduce( (total, message) => total + (Array.isArray(message.content) ? message.content : []) .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), 0 @@ -869,12 +895,42 @@ export function createSummarizer(ctx, service, config, deps = {}) { const parsedResult = parseSummaryJsonResult(text || assembledText); // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。 if (!parsedResult.ok) { + // Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为 + // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何 + // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。 + const raw = text || assembledText || ""; + const streak = (parseFailureStreak.get(session.id) ?? 0) + 1; + parseFailureStreak.set(session.id, streak); + // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。 + // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随 + // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 + // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。 + const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0; + const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries; + if (skipWindow) { + // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 + commitCursor(session.id, nextSeq); + parseFailureStreak.delete(session.id); + } if (audit) { audit.status = "error"; audit.errorMessage = "invalid summary JSON"; + audit.metadata = { + distillChars, + parse_failure: true, + raw_len: raw.length, + raw_head: raw.slice(0, 300), + // 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」 + // 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。 + raw_has_bracket: raw.includes("["), + streak, + ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), + ...(skipWindow ? { skipped: "parse-failure-streak" } : {}) + }; } return; } + parseFailureStreak.delete(session.id); const parsed = parsedResult.entries; // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进 // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。 @@ -1064,6 +1120,7 @@ export function createSummarizer(ctx, service, config, deps = {}) { inFlight.clear(); lastRunAt.clear(); lastDistilledSeq.clear(); + parseFailureStreak.clear(); } }; } diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index f044b890..beb51336 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -748,7 +748,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99 // #471 再 + 2 = 101。 // v0.9.3 新增 actionNudgeEnabled(M.O. 自动沉淀;顶层扁平键)= 102。 - assert.equal(Object.keys(data.effective).length, 102); + // issue #493 新增 summarizeParseFailureMaxRetries(连续解析失败后放弃窗口的 + // 护栏,int 键走 INT_RANGES 表)= 103。 + assert.equal(Object.keys(data.effective).length, 103); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMergeGuard, true); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 053c115f..c0d51ebf 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -1813,3 +1813,224 @@ test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老 assert.equal(row.workspace_scope_source, undefined); } }); + +// --- Issue #493:转录围栏 / 失败护栏 / 失败行可诊断性 ------------------------------ +// 现场:转录裸放 user 位 → 模型当成「该我回话了」接着往下写 → 输出无 `[` → +// 解析器括号早退判失败 → 不推进游标 → 下一个 turn/end 同文同温重试 → 同一窗口 +// 确定性失败、窗口越攒越大(报告者 20 条失败 input 8194→11118 单调递增)。 + +test("#493 提示构造:转录包进围栏,user 侧重申「第三方提炼员」,system 不动", async () => { + const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }); + await eventHandler(events)( + { + id: "s493-prompt", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("把这段对话蒸馏一下", 1), { seq: 2, type: "turn/end" }] + }, + { seq: 2, type: "turn/end" } + ); + + const [system, user] = calls[0].messages; + assert.equal(system.role, "system"); + assert.ok(system.content[0].text.includes("原子记忆"), "system 提示词保持原样(既有回归断言依赖它)"); + assert.equal(user.role, "user"); + const body = user.content[0].text; + // 角色句与围栏都必须落在 user 侧:模型对「这段文本是什么」的判断由它所在的 + // 那条消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。 + assert.ok(body.includes("第三方提炼员"), "user 侧必须重申角色,否则模型接着对话往下写"); + assert.ok(body.includes("不是下面这段会话的参与者"), "角色句要明说不参与,不能只说「请输出 JSON」"); + const open = body.indexOf("<<<会话转录开始>>>"); + const close = body.indexOf("<<<会话转录结束>>>"); + assert.ok(open > 0 && close > open, "转录必须被围栏包住"); + assert.ok(body.slice(open, close).includes("把这段对话蒸馏一下"), "原文仍在围栏内"); + assert.ok(body.indexOf("第三方提炼员") < open, "角色句在围栏之前"); +}); + +test("#493 提示构造:围栏是确定性的,重试收到的转录与首次逐字节相同", async () => { + let attempt = 0; + const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, { + stream() { + const output = attempt++ === 0 ? "不按格式的一整段中文点评" : "[]"; + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: output }; + yield { type: "finish", kind: "ok" }; + })(); + } + }); + const handler = eventHandler(events); + const session = { + id: "s493-deterministic", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("同一窗口重试", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 2); + const first = calls[0].messages.find((m) => m.role === "user").content[0].text; + const second = calls[1].messages.find((m) => m.role === "user").content[0].text; + // 围栏里不能有时间戳/随机数:否则「同文同温」这条前提就不成立,护栏阈值 + // 会变成对着一堆各不相同的输入计数。 + assert.equal(first, second, "重试必须收到逐字节相同的转录"); +}); + +test("#493 计量口径:distillChars 只数转录正文,不被围栏与角色句撑大", async () => { + // 这条是护栏的邻居而非护栏本身:#493 给 user 消息加了约 100 字的固定包裹, + // 若 distillChars 仍按「消息总字符数」算,4 字的窗口会凭空越过任何阈值, + // summarizeMinWindowChars 等于被自己的提示词旁路掉。 + const { events, service } = setup({ summarizeMinWindowChars: 60, distillRateLimitIntervalMs: 0 }); + await eventHandler(events)( + { + id: "s493-metric", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("短", 1), { seq: 2, type: "turn/end" }] + }, + { seq: 2, type: "turn/end" } + ); + const row = service.listLlmAudits()[0]; + assert.equal(row.error_message, "window-too-small", "4 字正文不得被包裹撑过 60 字阈值"); + assert.equal(row.metadata.distillChars, 4, "计的是转录正文(「用户:短」= 4 字)"); +}); + +test("#493 失败行可诊断:metadata 带 raw_head / raw_has_bracket / streak", async () => { + const { events, service } = setup({ distillRateLimitIntervalMs: 0 }, { + stream() { + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: "已按你的要求将值班轮单日上限改为 5,下面是具体改动…" }; + yield { type: "finish", kind: "ok" }; + })(); + } + }); + await eventHandler(events)( + { + id: "s493-diag", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("帮我改一下值班轮上限", 1), { seq: 2, type: "turn/end" }] + }, + { seq: 2, type: "turn/end" } + ); + + const row = service.listLlmAudits()[0]; + assert.equal(row.status, "error"); + assert.equal(row.error_message, "invalid summary JSON"); + // 原先 metadata 恒为 null,90 条历史失败一条原始输出都没留 —— 事后无法回答 + // 「模型到底吐了什么形状」。raw_has_bracket 是区分「没按格式写」与「格式对但 + // 内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。 + assert.equal(row.metadata?.parse_failure, true); + assert.equal(row.metadata?.raw_has_bracket, false, "这段点评里没有方括号"); + assert.ok(row.metadata?.raw_len > 0); + assert.ok(row.metadata?.raw_head.startsWith("已按你的要求"), "原始输出前缀要留档"); + assert.equal(row.metadata?.streak, 1); + assert.ok(Number.isInteger(row.metadata?.distillChars), "失败行同样带窗口字符数"); +}); + +test("#493 失败护栏默认关:连续失败也不放弃窗口(历史行为逐字节不变)", async () => { + const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, { + stream() { + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: "永远不是 JSON" }; + yield { type: "finish", kind: "ok" }; + })(); + } + }); + const handler = eventHandler(events); + const session = { + id: "s493-guard-off", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("窗口", 1), { seq: 2, type: "turn/end" }] + }; + for (let i = 0; i < 5; i++) await handler(session, { seq: 2, type: "turn/end" }); + // 默认 0 = 关:这是本仓新行为的既定纪律(AGENTS.md),也是既有 4 个 + // 「解析失败不消费窗口」用例语义不变的前提。 + assert.equal(calls.length, 5, "护栏关着时每个 turn/end 仍重试同一窗口"); +}); + +test("#493 失败护栏:连续 N 次失败后强制消费游标跳过该窗口", async () => { + let attempt = 0; + const { events, calls, service, store, summarizer } = setup( + { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 }, + { + stream() { + attempt++; + const output = attempt <= 2 ? "确定性失败:一段中文点评" : "[]"; + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: output }; + yield { type: "finish", kind: "ok" }; + })(); + } + } + ); + const handler = eventHandler(events); + const session = { + id: "s493-guard-on", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }] + }; + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 1); + assert.equal(service.getDistillCursor("s493-guard-on"), undefined, "第一次失败仍不推进游标"); + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 2, "阈值内仍重试"); + // 达到阈值(2):窗口被放弃,游标推进到本窗末尾,下一轮从全新窗口起算。 + const cursor = service.getDistillCursor("s493-guard-on"); + assert.equal(cursor?.last_seq, 2, "达到阈值后强制消费游标"); + const errorRows = service.listLlmAudits().filter((r) => r.status === "error"); + assert.equal(errorRows.length, 2); + // 按 streak 定位而不是按下标:审计表的返回顺序是存储层的事,不该被这条断言锁住。 + const abandoned = errorRows.find((r) => r.metadata?.streak === 2); + assert.equal(abandoned?.metadata?.skipped, "parse-failure-streak", "放弃必须可观测"); + assert.equal(abandoned?.metadata?.maxRetries, 2); + assert.equal(errorRows.find((r) => r.metadata?.streak === 1)?.metadata?.skipped, undefined, + "阈值前的那次失败仍按「可重试」记账,不得提前宣告放弃"); + + // 游标已消费 → 同一 turn/end 不再重试;新事件开新窗口并正常落库。 + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 2, "被放弃的窗口不再重试"); + session.events.push(userMessage("新的窗口", 3), { seq: 4, type: "turn/end" }); + await handler(session, { seq: 4, type: "turn/end" }); + assert.equal(calls.length, 3, "新窗口正常蒸馏"); + summarizer.dispose(); + store.close(); +}); + +test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", async () => { + let attempt = 0; + const { events, calls, service, store, summarizer } = setup( + { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 }, + { + stream() { + // 失败 → 成功 → 失败:第 3 次是「新一轮的第一次失败」,不该被判为第 2 次。 + const outputs = ["不是 JSON", "[]", "又不是 JSON"]; + const output = outputs[attempt++] ?? "[]"; + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: output }; + yield { type: "finish", kind: "ok" }; + })(); + } + } + ); + const handler = eventHandler(events); + const session = { + id: "s493-streak-reset", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("第一窗", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); // 失败 #1 + await handler(session, { seq: 2, type: "turn/end" }); // 成功 → 清零 + session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" }); + await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2) + + const last = service.listLlmAudits().at(-1); + assert.equal(last.status, "error"); + assert.equal(last.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发"); + assert.equal(last.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口"); + assert.equal(calls.length, 3); + summarizer.dispose(); + store.close(); +}); From da6a03944f94dc17985d4f251d516a989db562b4 Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 21 Oct 2026 05:03:06 +0800 Subject: [PATCH 2/2] fix(distill): only skip a window when the cursor is actually committable (#493) CodeRabbit caught that the guard could record 'window abandoned' while commitCursor was a no-op: persistCursor returns early when nextSeq is not finite and lastDistilledSeq is skipped too, so the cursor never moved, yet the streak was reset. The audit would claim the window was skipped while the next turn/end retried it unchanged - a false all-clear, which is worse than no guard at all. Gate the skip branch on Number.isFinite(nextSeq) and tighten the streak-reset test to assert every error row rather than only the newest one. --- dsh-mneme/lib/summarize.js | 6 +++++- dsh-mneme/src/summarize.js | 6 +++++- dsh-mneme/test/summarize.test.js | 12 ++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index b920819f..8e64f13e 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -906,7 +906,11 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。 const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0; - const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries; + // nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时 + // commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若 + // 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试 + // —— 那是最坏的一种假象:用户以为止血了,实际还在烧。 + const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq); if (skipWindow) { // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 commitCursor(session.id, nextSeq); diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index b920819f..8e64f13e 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -906,7 +906,11 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。 const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0; - const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries; + // nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时 + // commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若 + // 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试 + // —— 那是最坏的一种假象:用户以为止血了,实际还在烧。 + const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq); if (skipWindow) { // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 commitCursor(session.id, nextSeq); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index c0d51ebf..82f7fc3b 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -2026,10 +2026,14 @@ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", asy session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" }); await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2) - const last = service.listLlmAudits().at(-1); - assert.equal(last.status, "error"); - assert.equal(last.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发"); - assert.equal(last.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口"); + // 两条失败行都必须是「新一轮的第一次」:只查最后一条会让「第一条其实被 + // 记成 streak=2」这类回归漏网。 + const errorRows = service.listLlmAudits().filter((r) => r.status === "error"); + assert.equal(errorRows.length, 2); + for (const row of errorRows) { + assert.equal(row.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发"); + assert.equal(row.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口"); + } assert.equal(calls.length, 3); summarizer.dispose(); store.close();