dsh-prime-memory 0.26.25 → 0.26.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/embedding-child.cjs +14 -13
- package/dist/embedding-token-guard.cjs +74 -20
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# 更新日志(Changelog)
|
|
2
2
|
|
|
3
|
+
## [0.26.26] — 2026-11-02
|
|
4
|
+
|
|
5
|
+
> `0.26.25` 把 #22 的 token 护栏落地了,但把**最关键的那条验证当成了「已接上报」而非「已实测」**。本版用真 bge-m3 把它跑完,跑出了三个真实缺陷并逐个修掉。
|
|
6
|
+
|
|
7
|
+
- **T1 从「上报机制」补成「真模型实证」**(新增 `scripts/verify-token-guard-realmodel.mjs` + `scripts/_t3-runner.mjs`,可选跑、不进 CI——需本机 runtime 与 bge-m3)。在真 bge-m3 上实测:**5000/4000/3800 中文字符(报告里全部 SIGTRAP 的那三档)截断后进模型的序列长度均为 2047 ≤ 预算 2048**;无护栏时实测 **5003**;截断前后向量不同(排除「只报了个数、实际没截」)。护栏留位从「假设」变成「事实」。
|
|
8
|
+
- **修掉三个被真模型才抓得出的缺陷**(桩测试永远测不到):
|
|
9
|
+
1. **`tokensMax` 恒为 -1,等于没上报**——原实现从输出张量取 `.shape`,而实测发现 transformers 的 Tensor **根本没有 `.shape`**(叫 `.dims`),且池化后输出是 `[1024]` 一维、里面**不存在 seq** 这个维度。现改为截断时按库自己的口径(`padding:true, truncation:true`)实测并上报,脚本还拿两个独立取法互验(实测 2047 == 2047)。
|
|
10
|
+
2. **「预算留 2 位」是侥幸不是保证**——真测:切 2046 个 id → decode → 重分词得 2045 token,+2 special = 2047,预算 2048 **只剩 1 位余量**;BPE/Unigram 的切片→解码→再编码不保证同序列,换一段文本就可能多漂而越界。现改为**由实测超出量逐轮递降收紧**(每轮重编码取真 seq,严格变短,至多 3 轮;量不出来就报 -1 而不是猜)。
|
|
11
|
+
3. **基准不能只看 encode 的 id 数**——5000 中文字符的 `encode` 长 5000,而 pipeline 实际会送进 5003;已改为取两者较大值作基准。
|
|
12
|
+
- **消除一个我自己引入的测试污染**:本为取 `shrinkToBudget` 而让测试 `require('embedding-child.cjs')`,会把它的顶层 `process.on('uncaughtException')`(处理器会 `process.exit(1)`)与 `message` 监听装进 vitest 的 worker——**实测同 worker 的 `tests/memory-md-sync.test.ts` 因此无端转红(单跑绿、全量红)**。现恢复 child 的 fork-only 契约(只加一条文件头说明为何不可被 require),截断逻辑住在无副作用纯模块,对 child 仅做字面接线断言 + 产物 smoke,真行为由真模型脚本负责。
|
|
13
|
+
- 另记一条**既有偶发失败**(非本次引入、本次未修):`memory-md-sync.test.ts` 在全量并行下会间歇红(恢复活跃时 `.md.disable` 后缀未及时消失)。已用基线 worktree 对照:**不含本批改动的 `f3c1c07` 连跑 5 次也红过 1 次**,而本树连跑 3 次全绿,故不属本刀回归;具体原因是该用例的异步写盘与 `existsSync` 同步断言竞速,待另案。
|
|
14
|
+
- smoke 的两条产物断言从「贴字面量」改为「断意图」(上一版贴了 `module.exports = { shrinkToBudget }` 与 `maxTokens - 2`,实现一改就误报)。
|
|
15
|
+
- **测试**:`tests/embedding-token-guard.test.ts` 24 → **29 例**(新增逐轮递降、量不出来降级报 -1、`seqLenOf` 同时吃数组/张量两形等);全量 **1933 例 / 191 文件**全绿(连跑 3 次),typecheck 0 error、lint 0 error(4 条既有 warning)、build/build:smoke/smoke/verify-catalog 均 0。公开配置面无破坏性变更(`maxInputTokens`/`maxInputChars` 语义与默认值未动)。
|
|
16
|
+
|
|
3
17
|
## [0.26.25] — 2026-11-02
|
|
4
18
|
|
|
5
19
|
- **#22 根治:本地嵌入新增 token 维度护栏**。报告实测同一 5000 中文字符(≈3651 token)在纯 node 跑通(峰值 RSS **4.7GB**)、在宿主 Electron-node 被 **SIGTRAP 静默打死**(796ms、峰值仅 1.05GB),且崩溃瞬间 writable resident 只 3381K、机器 24GB 未吃满——**不是 OOM,是「单笔大块对齐分配被拒」**。本轮在本机(Windows/纯 node)实测内存峰值严格 **O(T²)**(750/1500/3003 token → ΔRSS +106/437/1635 MB,比 1:4.1:15.4 对应 T² 的 1:4:16),机制是 attention 张量 `[1,heads,T,T]`;外推 3651 token ≈4.6GB,与报告的 4.7GB 跨平台互证。**T 是唯一可控自变量,降 token 预算同时是崩溃护栏和内存护栏。**
|
package/dist/embedding-child.cjs
CHANGED
|
@@ -31,6 +31,13 @@ const { createRequire } = require('node:module');
|
|
|
31
31
|
// 「无 IPC 就 exit」守卫,被测试 require 会把副作用带进来。截断逻辑本身必须零模型可验。
|
|
32
32
|
const { shrinkToBudget } = require('./embedding-token-guard.cjs');
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 本文件是 **fork 专用入口**:顶层的 `process.on('message'/'disconnect'/'uncaughtException')`
|
|
36
|
+
* 与「无 IPC 就 exit」守卫都是执行体行为,**不得被测试 require**(给宿主进程装全局监听会
|
|
37
|
+
* 污染同进程的其他代码:实测过给 vitest worker 装 `uncaughtException` 后,同 worker 的
|
|
38
|
+
* `memory-md-sync` 用例无端转红)。需要零模型可验的逻辑一律放 `embedding-token-guard.cjs`。
|
|
39
|
+
*/
|
|
40
|
+
|
|
34
41
|
// 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
|
|
35
42
|
if (typeof process.send !== 'function') {
|
|
36
43
|
console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
|
|
@@ -138,21 +145,15 @@ async function pump() {
|
|
|
138
145
|
try {
|
|
139
146
|
// 字符粗筛(IPC 体积) + token 精截(#22 崩溃/内存护栏);截断只在推理前,run() 同步不可中断
|
|
140
147
|
const screened = job.text.slice(0, MAX_INPUT_CHARS);
|
|
141
|
-
const
|
|
142
|
-
const result = await extractor([
|
|
148
|
+
const cut = shrinkToBudget(screened, MAX_INPUT_TOKENS, tokenizer);
|
|
149
|
+
const result = await extractor([cut.text], { pooling: POOLING, normalize: true });
|
|
143
150
|
const data = result[0] && result[0].data;
|
|
144
151
|
job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const shape = result[0] && result[0].shape;
|
|
151
|
-
if (Array.isArray(shape) && shape.length >= 3) real = Number(shape[1]) || -1;
|
|
152
|
-
} catch {
|
|
153
|
-
/* shape 不可读不致命 */
|
|
154
|
-
}
|
|
155
|
-
if (real > job.request.tokensMax) job.request.tokensMax = real;
|
|
152
|
+
// tokensMax 用 cut.seq(按库口径实测将要进模型的 seq 长度,含 special token)。
|
|
153
|
+
// 曾试过从**输出**张量取 shape——真 bge-m3 实测证明两事:① Tensor 上根本没有 `.shape`
|
|
154
|
+
// (叫 `.dims`),② 池化后输出是 `[1024]` 一维,里面不存在 seq ——旧写法恒为 -1,等于没上报。
|
|
155
|
+
// 不可证时为 -1(不是 0),不拿假数据当护栏证据。
|
|
156
|
+
if (cut.seq > job.request.tokensMax) job.request.tokensMax = cut.seq;
|
|
156
157
|
job.request.remaining -= 1;
|
|
157
158
|
settle(job.request);
|
|
158
159
|
} catch (err) {
|
|
@@ -5,37 +5,91 @@
|
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* 把一条文本裁到 token
|
|
8
|
+
* 把一条文本裁到 **实测** token 预算内。返回 `{ text, seq }`:
|
|
9
|
+
* - `text` = 应送进 pipeline 的文本;
|
|
10
|
+
* - `seq` = 按库自己的口径(`padding:true, truncation:true`,含 special token)**实测将要**进模型
|
|
11
|
+
* 的序列长度;不可测时为 -1(-1 是「不可证」,不是 0)。
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
* 的 run()
|
|
12
|
-
* 是 #11
|
|
13
|
+
* 为什么截断只能在子进程做:tokenizer 只在加载后的 pipeline 对象上可达,且 onnxruntime
|
|
14
|
+
* 的 run() 是同步阻塞调用——单条内部不可中断,预检只能放推理前。又因为「子进程隔离」本身
|
|
15
|
+
* 是 #11(libvips-42.dll 同进程撞名)的修复,不得为省一次 IPC 把 tokenize 挪回主线程。
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
* pipeline
|
|
17
|
+
* 为什么不靠「budget - 2 留位」这种推断(真 bge-m3 实测已证伪):
|
|
18
|
+
* 切 ids → decode 回文本 → pipeline 再分词,**不保证得到同一序列**——实测 2046 个 id
|
|
19
|
+
* 解码后重分词得 2045 token,加 2 个 special token = **2047**,预算 2048 只剩 1 位余量。
|
|
20
|
+
* 换一段文本就可能多漂 1 位以上而越界,所以下限靠**实测逐轮收紧**,不靠常数猜测。
|
|
16
21
|
*
|
|
17
|
-
* 不做「短文本跳过 tokenize
|
|
18
|
-
* 英文≈0.27
|
|
19
|
-
*
|
|
20
|
-
*
|
|
22
|
+
* 不做「短文本跳过 tokenize」的优化:字符→token 比值按词表而变(CJK≈1、emoji/泰文>1.5、
|
|
23
|
+
* 英文≈0.27),任何启发式下限都可能放走真超限的文本(实测反例:3800 中文字符 ×0.5=1900
|
|
24
|
+
* 会跳过,而它实际约 2774 token,正落在 #22 致死档),而 tokenize 只占单次推理 ≈2.4%。
|
|
25
|
+
* 护栏上省 2.4% 不值一个未验证的跳跃。
|
|
21
26
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
27
|
+
* 任何一步不可用(transformers 未挂 tokenizer、不认选项、decode 回空串、抛错)一律
|
|
28
|
+
* **退回粗筛原文保持旧行为**:护栏不得把能跑的链路搞坏。
|
|
29
|
+
*
|
|
30
|
+
* 成本:每轮多一个 encode(实测占单次推理 ≈2.4%);至多重试 ROUNDS 轮。
|
|
24
31
|
*/
|
|
32
|
+
const ROUNDS = 3;
|
|
33
|
+
|
|
34
|
+
/** 按库口径量一次真实序列长度(pipeline 内部就是 `this.tokenizer(texts,{padding:true,truncation:true})`)。 */
|
|
35
|
+
function seqLenOf(tok, text) {
|
|
36
|
+
const enc = tok(text, { padding: true, truncation: true });
|
|
37
|
+
const ids = enc && enc.input_ids;
|
|
38
|
+
if (!ids) return -1;
|
|
39
|
+
if (Array.isArray(ids)) return ids.length;
|
|
40
|
+
if (Array.isArray(ids.dims) && ids.dims.length >= 2) return ids.dims[1];
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
function shrinkToBudget(raw, maxTokens, tok) {
|
|
26
|
-
if (!tok || !(maxTokens > 0)) return raw;
|
|
45
|
+
if (!tok || !(maxTokens > 0)) return { text: raw, seq: -1 };
|
|
27
46
|
try {
|
|
28
47
|
// 实测陷阱:return_tensor 缺省为 true,直取 .input_ids 得到的是 _Tensor(.length undefined
|
|
29
48
|
// 且不抛错) -> 必须显式 return_tensor:false
|
|
30
49
|
const ids = tok.encode(raw, { return_tensor: false });
|
|
31
|
-
if (!Array.isArray(ids)
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
if (!Array.isArray(ids)) return { text: raw, seq: -1 };
|
|
51
|
+
// 基准长度用原文的库口径实测值(含 special token)。注意:**不能**只看 encode 的 id 数——
|
|
52
|
+
// 真 bge-m3 实测 5000 中文字符的 encode 长 5000,而 pipeline 实际会送进 5003(库自己补的
|
|
53
|
+
// special token 与词表边界),所以只有库口径才是「到底进了多少」的那个数。
|
|
54
|
+
let base = ids.length;
|
|
55
|
+
let direct = -1;
|
|
56
|
+
try {
|
|
57
|
+
direct = seqLenOf(tok, raw);
|
|
58
|
+
} catch {
|
|
59
|
+
direct = -1;
|
|
60
|
+
}
|
|
61
|
+
if (direct >= 0) base = Math.max(base, direct);
|
|
62
|
+
if (base <= maxTokens) return { text: raw, seq: direct >= 0 ? direct : -1 };
|
|
63
|
+
|
|
64
|
+
// 递降量由**上一轮实测的超出量**推出,而不是猜常数:切片长度是编码的单调函数,
|
|
65
|
+
// 所以每轮必比上一轮更短;再编码一次是为了拿到那个长度的真 seq,不做二次推断。
|
|
66
|
+
// 起点先留 2 位(special token 的下界,真测=+2),之后按实测超出量继续收。
|
|
67
|
+
let text = raw;
|
|
68
|
+
let seq = -1;
|
|
69
|
+
let prevLen = ids.length;
|
|
70
|
+
let cut = Math.max(1, ids.length - (base - maxTokens) - 2);
|
|
71
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
72
|
+
if (cut >= prevLen) cut = Math.max(1, prevLen - 1); // 保证严格变短
|
|
73
|
+
const kept = ids.slice(0, cut);
|
|
74
|
+
// 实测陷阱:decode 缺省 skip_special_tokens=false,会把 [CLS]/[SEP] 编回文本再二次分词
|
|
75
|
+
const shrunk = tok.decode(kept, { skip_special_tokens: true });
|
|
76
|
+
if (typeof shrunk !== 'string' || shrunk.length === 0) break; // decode 不可用 -> 停止递降
|
|
77
|
+
text = shrunk;
|
|
78
|
+
const reIds = tok.encode(text, { return_tensor: false });
|
|
79
|
+
if (!Array.isArray(reIds) || reIds.length === 0) return { text: raw, seq: -1 };
|
|
80
|
+
seq = seqLenOf(tok, text);
|
|
81
|
+
if (seq < 0) return { text: raw, seq: -1 }; // 量不了 -> 降级(不送无法审计的截断文本)
|
|
82
|
+
if (seq <= maxTokens) return { text, seq };
|
|
83
|
+
prevLen = reIds.length;
|
|
84
|
+
// 递降量 = 实测超出量 + 2 位余量(经验值,但正确性不依赖它:依赖上文的严格变短)
|
|
85
|
+
cut = Math.max(1, prevLen - (seq - maxTokens) - 2);
|
|
86
|
+
}
|
|
87
|
+
// 逐轮后仍超(极端漂移):宁报 -1(不可证)也不报一个超限的数当安全证据。
|
|
88
|
+
if (seq > maxTokens) return { text, seq: -1 };
|
|
89
|
+
return { text, seq };
|
|
36
90
|
} catch {
|
|
37
|
-
return raw; // 降级:不截断,保持旧行为
|
|
91
|
+
return { text: raw, seq: -1 }; // 降级:不截断,保持旧行为
|
|
38
92
|
}
|
|
39
93
|
}
|
|
40
94
|
|
|
41
|
-
module.exports = { shrinkToBudget };
|
|
95
|
+
module.exports = { shrinkToBudget, seqLenOf };
|
package/dsh.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-prime-memory",
|
|
3
3
|
"name": "dsh-prime-memory",
|
|
4
|
-
"version": "0.26.
|
|
4
|
+
"version": "0.26.26",
|
|
5
5
|
"description": {
|
|
6
6
|
"en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
|
|
7
7
|
"zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
|