Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

## 🆕 新增
Expand Down
1 change: 1 addition & 0 deletions dsh-mneme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions dsh-mneme/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 调用 |
Expand Down
8 changes: 8 additions & 0 deletions dsh-mneme/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;省略 = 每天):
Expand Down
11 changes: 11 additions & 0 deletions dsh-mneme/lib/lang.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<<<TRANSCRIPT START>>>" },
transcriptFenceClose: { zh: "<<<会话转录结束>>>", en: "<<<TRANSCRIPT END>>>" },
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}` },
Expand Down
3 changes: 3 additions & 0 deletions dsh-mneme/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 不设上限)。
Expand Down
67 changes: 64 additions & 3 deletions dsh-mneme/lib/summarize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down Expand Up @@ -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 审计行(审计失败绝不阻塞蒸馏本身)。 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -869,12 +895,46 @@ 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;
// nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时
// commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若
// 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试
// —— 那是最坏的一种假象:用户以为止血了,实际还在烧。
const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq);
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——此前产出条数完全由模型输出决定,用户无从自查。
Expand Down Expand Up @@ -1064,6 +1124,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
inFlight.clear();
lastRunAt.clear();
lastDistilledSeq.clear();
parseFailureStreak.clear();
}
};
}
8 changes: 8 additions & 0 deletions dsh-mneme/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;省略 = 每天):
Expand Down
11 changes: 11 additions & 0 deletions dsh-mneme/src/lang.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<<<TRANSCRIPT START>>>" },
transcriptFenceClose: { zh: "<<<会话转录结束>>>", en: "<<<TRANSCRIPT END>>>" },
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}` },
Expand Down
3 changes: 3 additions & 0 deletions dsh-mneme/src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 不设上限)。
Expand Down
Loading
Loading