From 300eb84e47495e729129d021d088d78882b94f8d Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 11 Oct 2026 21:05:22 +0800 Subject: [PATCH] =?UTF-8?q?chore(distill):=20=E7=AA=97=E5=8F=A3=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E6=95=B0=E6=97=A0=E6=9D=A1=E4=BB=B6=E8=BF=9B=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=20metadata=E2=80=94=E2=80=94=E4=B8=BA=20#239=20?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E7=BA=A7=E9=A2=84=E5=88=A4=E5=AE=9A=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 零行为变更的计量补位。summarizeMinWindowChars 默认 0(关)时,字符数只在算完就跳过的 分支里临时存在,成功行的 metadata 也只在 capped/deduped/salvaged 发生时才建——维护者 本机 1240 条 summarize_compress 成功行里窗口字符数为 0 条。没有这个数,验收 4「默认 配置下 LLM 调用量较现状可量化下降」无从对账,第二批预判规则(低价值/去重/阈值该给 多少)也只能拍脑袋。 - 字符数提前到无条件计算,skip 行与成功行都写进 metadata; - 计的是真正发给模型的转录文本长度(与闸门比较同一口径),不是原始消息体长度; - summarizeMinWindowChars 仍为 0 时决策路径逐字节不变,只多出可观测数据。 --- README.md | 6 ++--- dsh-mneme/README.md | 6 ++--- dsh-mneme/lib/summarize.js | 43 +++++++++++++++++++------------- dsh-mneme/src/summarize.js | 43 +++++++++++++++++++------------- dsh-mneme/test/summarize.test.js | 37 +++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index f9624880..74215ce5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -207,7 +207,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 1609 个测试 +npm test # 1611 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -414,7 +414,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 1609 tests +npm test # 1611 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 5edf195a..547f1a19 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1609%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1611%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -613,7 +613,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1609 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1611 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -622,7 +622,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1609 个测试 +npm test # 运行 1611 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index a25c5c0b..4efe2855 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -574,23 +574,28 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个 // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载 // 决定下一级规则的依据。 + // + // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值 + // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条 + // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 + // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— + // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 + const distillChars = 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 + ); const minWindowChars = config.summarizeMinWindowChars ?? 0; - if (minWindowChars > 0) { - const distillChars = 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 - ); - if (distillChars < minWindowChars) { - commitCursor(session.id, nextSeq); - writeAudit({ - timestamp: new Date().toISOString(), - model_id: route ? `${route.provider}:${route.model}` : "unknown", - status: "skipped", - error_message: "window-too-small" - }); - return; - } + if (minWindowChars > 0 && distillChars < minWindowChars) { + commitCursor(session.id, nextSeq); + writeAudit({ + timestamp: new Date().toISOString(), + model_id: route ? `${route.provider}:${route.model}` : "unknown", + status: "skipped", + error_message: "window-too-small", + metadata: { distillChars, minWindowChars } + }); + return; } // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按 // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。 @@ -809,8 +814,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { + // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有 + // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下 + // 一条都不留——下一级预判规则的阈值就无从定标。 + if (audit) { audit.metadata = { + distillChars, parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index a25c5c0b..4efe2855 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -574,23 +574,28 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个 // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载 // 决定下一级规则的依据。 + // + // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值 + // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条 + // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 + // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— + // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 + const distillChars = 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 + ); const minWindowChars = config.summarizeMinWindowChars ?? 0; - if (minWindowChars > 0) { - const distillChars = 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 - ); - if (distillChars < minWindowChars) { - commitCursor(session.id, nextSeq); - writeAudit({ - timestamp: new Date().toISOString(), - model_id: route ? `${route.provider}:${route.model}` : "unknown", - status: "skipped", - error_message: "window-too-small" - }); - return; - } + if (minWindowChars > 0 && distillChars < minWindowChars) { + commitCursor(session.id, nextSeq); + writeAudit({ + timestamp: new Date().toISOString(), + model_id: route ? `${route.provider}:${route.model}` : "unknown", + status: "skipped", + error_message: "window-too-small", + metadata: { distillChars, minWindowChars } + }); + return; } // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按 // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。 @@ -809,8 +814,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { + // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有 + // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下 + // 一条都不留——下一级预判规则的阈值就无从定标。 + if (audit) { audit.metadata = { + distillChars, parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 1e8b533f..f9aa3baa 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -274,6 +274,43 @@ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费 assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计"); }); +// #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。 +// 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻 +// 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。 +test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => { + const { events, service } = setup({ summarizeMinWindowChars: 1000 }); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s239-metric-skip", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("好", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); + const row = service.listLlmAudits()[0]; + assert.equal(row.error_message, "window-too-small"); + // 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4), + // 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。 + assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0, + "skip 行必须带窗口字符数"); + assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的"); + assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值"); +}); + +test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => { + const { events, service } = setup(); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s239-metric-success", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); + const row = service.listLlmAudits()[0]; + assert.equal(row.status, "success"); + assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0, + "默认档位下也要留数,否则验收 4 无从对账"); +}); + test("#239 窗口达到阈值时预判放行(不误杀)", async () => { const { events, calls } = setup({ summarizeMinWindowChars: 5 }); const handler = events.find((e) => e.name === "session/event").fn;