dsh-prime-memory 0.26.24 → 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 +27 -0
- package/README.de.md +1 -0
- package/README.en.md +1 -0
- package/README.es.md +1 -0
- package/README.fr.md +1 -0
- package/README.it.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +4 -0
- package/dist/embedding-child.cjs +224 -192
- package/dist/embedding-token-guard.cjs +95 -0
- package/dist/embedding-worker.cjs +3 -0
- package/dist/index.js +12 -1
- package/dist/store/embedding-source.d.ts +5 -2
- package/dist/store/embedding-source.js +8 -3
- package/dist/store/l1.js +6 -1
- package/dist/store/local-embedding.d.ts +18 -1
- package/dist/store/local-embedding.js +29 -4
- package/dist/store/model-catalog.d.ts +31 -1
- package/dist/store/model-catalog.js +30 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.26.25] — 2026-11-02
|
|
18
|
+
|
|
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 预算同时是崩溃护栏和内存护栏。**
|
|
20
|
+
- **护栏早就在子进程,错的是单位**(实读校正了一处长期误解):`resources/embedding-child.cjs` 一直在子进程内截断,但按**字符**截。bge-m3/embeddinggemma 是 Unigram/BPE,中文≈1 字/token 而 emoji/泰文可 >1.5,所以 5000 字符既可只 750 token 也可近 8192——**字符帽与崩溃条件不同源、不可换算**;库自带 `truncation:true` 只截到 `model_max_length=8192`,本身就在致死线 ≈2700 之上,**等于没兜**。另证:`src/util/context-occupancy.ts` 的 `CHARS_PER_TOKEN = 4`(官方同值)对中文低估 4 倍,很可能就是「5000 字符看着安全」长期未被怀疑的成因,**已明令禁止用于嵌入预算**。
|
|
21
|
+
- **新增 `embedding.maxInputTokens`(默认 `0`=自动)与唯一真值来源 `HARD_CAP_TOKENS = 2048`**(`src/store/model-catalog.ts` 的 `deriveTokenBudget`)。生效预算 `= min(模型 contextTokens, HARD_CAP_TOKENS)`:bge-small-zh(512) 不受影响、bge-m3(8192) 被压到 2048;用户显式超限只 **clamp + warn**,不抛错。`contextTokens` 原注释「仅 UI 展示」已升格为推导真值并同步改注释(不改就是下一轮声明漂移)。
|
|
22
|
+
- **配置面只加键不改键**:`maxInputChars` 的键名/边界(`min(100).max(100_000)`)/默认值 5000 **完全不动**。实测 schemastery **未知键静默透传不报错**,改名或换单位会让已部署 YAML 的值**无声失效并回落 `?:5000`**(新主进程 + 旧 `dist/embedding-child.cjs` 错配时同理);而收窄边界会让已部署 YAML **解析直接抛错**,故障半径是整个配置树。字符帽继续只管远程档与 IPC 粗筛、token 帽只管本地推理——**各自单一实施点,不做同层双旋钮**。
|
|
23
|
+
- **截断执行留在子进程**(新增 `resources/embedding-token-guard.cjs`,无副作用纯模块):tokenizer 只在加载后的 pipeline 对象上可达,且 ORT `run()` 同步阻塞、单条内部不可中断,预检只能放推理前;而子进程隔离本身是 #11(同进程 `libvips-42.dll` 撞名)的修复,不得为省一次 IPC 拆开。tokenize 成本实测占单次推理 ≈2.4%(121-136ms vs 5427ms)。旧逃生门 `embedding-worker.cjs` **有意不同步**并在文件头写明理由(硬同步 = 维护两份口径)。
|
|
24
|
+
- **实现期三个实测陷阱**(不避开就是静默错位):`encode()` 缺省返回 `_Tensor`(`.length === undefined` 且**不报错**,须显式 `return_tensor:false`);`decode()` 缺省 `skip_special_tokens=false`,slice→decode→re-encode 会 **+2 token**;`tokenizer.model_max_length` **只读**(实测 `has only a getter`),不能当旋钮。**主动删掉一个自己写出的缺陷**:初版带了「`raw.length * 0.5 < 预算` 即跳过 tokenize」的省开销优化,但 3800 中文字符×0.5=1900 会跳过、其实际≈2774 token 正落在致死档,属未验证跳跃 → 改为无条件实测。
|
|
25
|
+
- **可见性修复(#22 归因链路的前置)**:`child.on('exit',(code)=>…)` 只接 code、**丢弃 signalCode**,使 SIGTRAP / OOM-kill / 自然退出在日志里同为 `code=null`(本轮就是靠 macOS 4 份崩溃报告对毫秒时间戳才归因上的);现由 `describeExit()` 同时输出 `code` 与 `signal`,一处修三处可见(crashed 串 / warn / `getLoadError()`),worker 旧通道同型修正。子进程另上报本批**实测**进模型的最大 token 数(`tokensMax` 取输出张量 `[1,seq,hidden]` 的 seq,不用推断值);fork 时一条 info 记录 `execPath`/`process.release.name`/pid/模型/生效预算——之前仓内没有任何地方记录 execPath 差异,而那正是「同代码纯 node 能跑、Electron 崩」的唯一区分量。
|
|
26
|
+
- **补齐停摆不再静默**:`reindex` 首行 `if (!vectorReady()) return {0,0,0}` 与「本轮无事可做」不可区分,且 `index.ts` 的 `failed>0` / `missingAfter==0` 两分支都不进 → **一行日志都不打**(崩后熔断也不在环上,环上是 `isReady()` 短路)。现补齐链分流出「缺失数统计失败」与「本轮未写入且仍差 N 条」两条 warn;reindex 批次的 `catch {}` 补上日志并**带 record id**(否则无从定位是哪条长文本致崩)。**已收回一条调研结论**:`markEmbeddingSynced` 的「缺失全落 skip 集」并非假成功——`l1.ts` 注释明确写明 skipped 有意不阻塞同步标记(否则永不收敛、周期性全量重嵌死循环),照误报去改会引入新缺陷。
|
|
27
|
+
- **本版本不含崩溃自愈**:`startReindex` 前置 `vectorsReady()` 门使自愈在崩溃态根本进不去,而 respawn 的真实半径含六处长期引用重绑、状态机新分支、三腿 thundering herd 与 60s 冷却叠乘;且本刀目标是「不让毒形成」而非「毒已形成怎么办」。也不自动全库重建(该腿无超时无熔断、成本不可量化,且新帽只缩不扩、语义退化有界);历史向量口径如需严格一致可手动触发重建。
|
|
28
|
+
- **测试**:新增 `tests/embedding-token-guard.test.ts` 24 例(预算推导 4 组 + 精截与降级路径 + 两个实测陷阱 + 配置面回归 + 退出归因)。**红绿对拍已逐条验证有效性**:断开接线→1 红、不留 special 位→2 红、塞回字符启发式→4 红、预算不写进 cfg→1 红。过程中查出并修掉自己的两个测试缺陷:直接调纯函数**测不到调用点**(接线整行删掉时 19 例全绿)→ 补接线/产物哨兵;对拍脚本把 vitest「收集期报错」误读成「无失败」→ 判据改为区分 `Test Files failed`/`Tests no tests`。**跨平台限制如实声明**:`malloc_zone_memalign` 是 macOS 分配器符号,Windows 既不能复现 SIGTRAP 也发不出该信号(`EINVAL`),故本仓可证的是「进模型的 token 数受预算约束」与「被信号杀可归因」,崩溃是否消失需报告人在 macOS 回验。全量 **1923 例 / 191 文件**,typecheck/lint(0 error)/build/build:smoke/smoke 全 0;smoke 新增 3 条产物断言钉住 `dist/embedding-token-guard.cjs` 存在、child 相对 require 可达、guard 导出正确(漏拷是子进程起不来的硬故障)。
|
|
29
|
+
|
|
3
30
|
## [0.26.24] — 2026-11-02
|
|
4
31
|
|
|
5
32
|
- **修掉 `0.26.23` 自己引入的回归:读面可能被永久堵死**。上一版规定「对端已升版但 `drsReadFacade()` 返回 undefined 时抛可重试错、不回退自开库」,用意是避免把自开库结果永久缓存。但**未就绪是无法与「本进程根本不会有人 activate collector」区分的**,两类真实场景落进后者:① 宿主外跑本仓读面做校验的脚本(新进程 import collector,`apply()` 从未运行,门面永远 undefined);② collector 装了但未进当前 profile 的装载清单。它们会每次读都报错,比 `0.26.21` 的直接自开库更糟。
|
package/README.de.md
CHANGED
|
@@ -323,6 +323,7 @@ Drei Embedding-Quellen: **aus** (Standard, reine BM25-Schlüsselwortsuche), **re
|
|
|
323
323
|
| `embedding.model` | leer | Embedding-Modellname |
|
|
324
324
|
| `embedding.dimensions` | `0` | Vektordimension (bei Aktivierung Pflicht, muss zur Modellausgabe passen) |
|
|
325
325
|
| `embedding.maxInputChars` | `5000` | Maximale Zeichen je Text (bei Überlänge abgeschnitten) |
|
|
326
|
+
| `embedding.maxInputTokens` | `0` | **Token**-Budget pro Text für lokale Einbettung: `0` (Standard) = min(Modellfenster, harte Grenze 2048); >0 = explizit, bei Überschreitung geklemmt inkl. warn. Andere Rolle als die Zeile oben – das Zeichenlimit betrifft nur Remote-Modus & IPC-Größe, die lokale Absturz-/Speichergrenze muss in Tokens stehen (#22) |
|
|
326
327
|
| `embedding.timeoutMs` | `10000` | Timeout eines einzelnen Embedding-Aufrufs (ms) |
|
|
327
328
|
| `embedding.allowLocalModels` | `true` | Lokale Embedding-Stufe erlauben (Deployment-Obergrenze: ausgeschaltet kann die Einstellungsseite keine Modelle laden und nicht auf Lokal schalten) |
|
|
328
329
|
| `embedding.mirror` | `https://hf-mirror.com` | Wurzeladresse des Download-Spiegels für lokale Modelle (zurücksetzbar auf das offizielle `https://huggingface.co`) |
|
package/README.en.md
CHANGED
|
@@ -330,6 +330,7 @@ the bundle layer appends and causes `duplicate loader entry id` startup failure)
|
|
|
330
330
|
| `embedding.model` | empty | embedding model name |
|
|
331
331
|
| `embedding.dimensions` | `0` | Vector dimensions (required when enabled; must match model output) |
|
|
332
332
|
| `embedding.maxInputChars` | `5000` | Max characters per text (overlong inputs truncated) |
|
|
333
|
+
| `embedding.maxInputTokens` | `0` | Per-text **token** budget for local embedding: `0` (default) = min(model window, hard cap 2048); >0 = explicit, clamped with a warn above the cap. Not the same guard as the row above — the character cap only bounds the remote tier and IPC size, while local crash/memory limits must be in tokens (#22) |
|
|
333
334
|
| `embedding.timeoutMs` | `10000` | Per-call embedding timeout (ms) |
|
|
334
335
|
| `embedding.allowLocalModels` | `true` | Allow the local embedding tier (deployment ceiling; when off, no model downloads and no local tier in settings) |
|
|
335
336
|
| `embedding.mirror` | `https://hf-mirror.com` | Download mirror root for local models (can be changed back to `https://huggingface.co`) |
|
package/README.es.md
CHANGED
|
@@ -324,6 +324,7 @@ Los ajustes que pisan los valores por defecto se escriben en el `cordis.patch.ym
|
|
|
324
324
|
| `embedding.model` | vacío | Nombre del modelo de embeddings |
|
|
325
325
|
| `embedding.dimensions` | `0` | Dimensión de los vectores (obligatoria si está activada, debe coincidir con la salida del modelo) |
|
|
326
326
|
| `embedding.maxInputChars` | `5000` | Número máximo de caracteres por texto (recorte si se supera) |
|
|
327
|
+
| `embedding.maxInputTokens` | `0` | Presupuesto de **tokens** por texto para la incrustación local: `0` (pred.) = min(ventana del modelo, límite 2048); >0 = explícito, recortado con warn si excede. No es el mismo límite que la fila de arriba: el de caracteres solo cubre el modo remoto y el tamaño de IPC, y la protección local contra fallos/memoria debe ir en tokens (#22) |
|
|
327
328
|
| `embedding.timeoutMs` | `10000` | Tiempo máximo de una llamada de embeddings (ms) |
|
|
328
329
|
| `embedding.allowLocalModels` | `true` | Permite el modo de embeddings local (techo de despliegue: desactivado, la página de ajustes no puede descargar modelos ni pasar a local) |
|
|
329
330
|
| `embedding.mirror` | `https://hf-mirror.com` | Raíz del espejo de descarga de modelos locales (se puede devolver al oficial `https://huggingface.co`) |
|
package/README.fr.md
CHANGED
|
@@ -322,6 +322,7 @@ Les réglages par-dessus les valeurs par défaut s'écrivent dans le `cordis.pat
|
|
|
322
322
|
| `embedding.model` | vide | Nom du modèle d'embedding |
|
|
323
323
|
| `embedding.dimensions` | `0` | Dimension des vecteurs (obligatoire si activé, doit correspondre à la sortie du modèle) |
|
|
324
324
|
| `embedding.maxInputChars` | `5000` | Nombre maximal de caractères par texte (troncature au-delà) |
|
|
325
|
+
| `embedding.maxInputTokens` | `0` | Budget de **jetons** par texte pour l'intégration locale : `0` (défaut) = min(fenêtre du modèle, plafond 2048) ; >0 = explicite, réduit avec un warn s'il dépasse. Ce n'est pas la même limite que la ligne au-dessus : celle en caractères ne borne que le mode distant et la taille IPC, la protection locale doit être en jetons (#22) |
|
|
325
326
|
| `embedding.timeoutMs` | `10000` | Délai maximal d'un appel d'embedding (ms) |
|
|
326
327
|
| `embedding.allowLocalModels` | `true` | Autorise le mode embedding local (plafond de déploiement : désactivé, la page de paramètres ne peut plus télécharger de modèles ni passer en local) |
|
|
327
328
|
| `embedding.mirror` | `https://hf-mirror.com` | Racine du miroir de téléchargement des modèles locaux (ramenérable à l'officiel `https://huggingface.co`) |
|
package/README.it.md
CHANGED
|
@@ -324,6 +324,7 @@ Le impostazioni che scavalcano i predefiniti si scrivono nel `cordis.patch.yml`
|
|
|
324
324
|
| `embedding.model` | vuoto | Nome del modello d'embedding |
|
|
325
325
|
| `embedding.dimensions` | `0` | Dimensione dei vettori (obbligatoria se attiva, deve corrispondere all'output del modello) |
|
|
326
326
|
| `embedding.maxInputChars` | `5000` | Numero massimo di caratteri per testo (troncamento oltre) |
|
|
327
|
+
| `embedding.maxInputTokens` | `0` | Budget di **token** per testo per l'embedding locale: `0` (default) = min(finestra del modello, limite 2048); >0 = esplicito, ridotto con un warn se oltre il limite. Non è lo stesso limite della riga sopra: quello in caratteri copre solo modalità remota e dimensione IPC, la protezione locale contro cadute/memoria deve essere in token (#22) |
|
|
327
328
|
| `embedding.timeoutMs` | `10000` | Timeout di una singola chiamata d'embedding (ms) |
|
|
328
329
|
| `embedding.allowLocalModels` | `true` | Consente la modalità d'embedding locale (tetto di distribuzione: disattivata, la pagina impostazioni non può scaricare modelli né passare in locale) |
|
|
329
330
|
| `embedding.mirror` | `https://hf-mirror.com` | Radice del mirror di scaricamento dei modelli locali (riportabile all'ufficiale `https://huggingface.co`) |
|
package/README.ja.md
CHANGED
|
@@ -274,6 +274,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
|
|
|
274
274
|
| `embedding.model` | 空 | 埋め込みモデル名 |
|
|
275
275
|
| `embedding.dimensions` | `0` | ベクトル次元(有効時必須) |
|
|
276
276
|
| `embedding.maxInputChars` | `5000` | 単テキスト最大文字数(超長は切り捨て) |
|
|
277
|
+
| `embedding.maxInputTokens` | `0` | ローカル埋め込みの 1 件あたり **トークン** 予算:`0`(既定)= モデル窓と上限 2048 の小さい方、>0 = 明示値(超過は clamp + warn)。上の行とは役割が異なる——文字上限はリモートモードと IPC サイズのみを制限し、ローカルのクラッシュ/メモリ防護はトークン単位が必須(#22) |
|
|
277
278
|
| `embedding.timeoutMs` | `10000` | 単回埋め込み呼び出しタイムアウト(ms) |
|
|
278
279
|
| `embedding.allowLocalModels` | `true` | ローカル埋め込み档を許可(デプロイ上限) |
|
|
279
280
|
| `embedding.mirror` | `https://hf-mirror.com` | ローカルモデルダウンロードミラー根 |
|
package/README.ko.md
CHANGED
|
@@ -274,6 +274,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
|
|
|
274
274
|
| `embedding.model` | 빈값 | 임베딩 모델명 |
|
|
275
275
|
| `embedding.dimensions` | `0` | 벡터 차원(활성 시 필수) |
|
|
276
276
|
| `embedding.maxInputChars` | `5000` | 단텍스트 최대 문자 수(초장 잘림) |
|
|
277
|
+
| `embedding.maxInputTokens` | `0` | 로컬 임베딩 1건당 **토큰** 예산: `0`(기본) = 모델 창과 상한 2048 중 작은 값, >0 = 명시값(초과 시 clamp + warn). 위 행과 역할이 다름 — 문자 상한은 원격 모드와 IPC 크기만 제한하고, 로컬 충돌/메모리 방지는 토큰 단위가 필수(#22) |
|
|
277
278
|
| `embedding.timeoutMs` | `10000` | 단회 임베딩 호출 타임아웃(ms) |
|
|
278
279
|
| `embedding.allowLocalModels` | `true` | 로컬 임베딩档 허용(배포 상한) |
|
|
279
280
|
| `embedding.mirror` | `https://hf-mirror.com` | 로컬 모델 다운로드 미러 루트 |
|
package/README.md
CHANGED
|
@@ -342,6 +342,7 @@ ONNX 量化 **CPU 推理**——无需 API Key,数据不出本机)。本地
|
|
|
342
342
|
| `embedding.model` | 空 | embedding 模型名 |
|
|
343
343
|
| `embedding.dimensions` | `0` | 向量维度(启用时必填,须与模型输出一致) |
|
|
344
344
|
| `embedding.maxInputChars` | `5000` | 单条文本最大字符数(超长截断) |
|
|
345
|
+
| `embedding.maxInputTokens` | `0` | 本地嵌入的单条 **token** 预算:`0`(默认)= 按模型窗口与硬上限 2048 自动取小;>0 = 显式预算,超上限会被 clamp 并记 warn。与上一行不是同类护栏——字符帽只管远程档与 IPC 体积,本地推理的崩溃/内存护栏必须按 token(#22) |
|
|
345
346
|
| `embedding.timeoutMs` | `10000` | 单次 embedding 调用超时(ms) |
|
|
346
347
|
| `embedding.allowLocalModels` | `true` | 允许本地嵌入档(部署上限:关闭后设置页不能下载模型、不能切本地档) |
|
|
347
348
|
| `embedding.mirror` | `https://hf-mirror.com` | 本地模型下载镜像根地址(可改回官方 `https://huggingface.co`) |
|
package/README.ru.md
CHANGED
|
@@ -324,6 +324,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
|
|
|
324
324
|
| `embedding.model` | пусто | Имя модели эмбеддингов |
|
|
325
325
|
| `embedding.dimensions` | `0` | Размерность векторов (при включении обязательна, должна совпадать с выходом модели) |
|
|
326
326
|
| `embedding.maxInputChars` | `5000` | Максимум символов на один текст (сверх — усечение) |
|
|
327
|
+
| `embedding.maxInputTokens` | `0` | Бюджет **токенов** на один текст для локальных эмбеддингов: `0` (умолч.) = min(окно модели, жёсткий лимит 2048); >0 = явное значение, при превыщении усекается с warn. Это не тот же лимит, что строкой выше: символьный ограничивает только удалённый режим и размер IPC, а локальная защита от падений/памяти должна считаться в токенах (#22) |
|
|
327
328
|
| `embedding.timeoutMs` | `10000` | Таймаут одного вызова эмбеддинга (мс) |
|
|
328
329
|
| `embedding.allowLocalModels` | `true` | Разрешить локальный режим эмбеддингов (потолок развёртывания: выключено — страница настроек не может скачивать модели и переключаться в локальный) |
|
|
329
330
|
| `embedding.mirror` | `https://hf-mirror.com` | Корень зеркала загрузки локальных моделей (можно вернуть на официальный `https://huggingface.co`) |
|
package/dist/config.d.ts
CHANGED
|
@@ -360,6 +360,15 @@ export interface MemoryConfig {
|
|
|
360
360
|
dimensions: number;
|
|
361
361
|
/** 单条文本最大字符数(超长截断)。 */
|
|
362
362
|
maxInputChars: number;
|
|
363
|
+
/**
|
|
364
|
+
* 本地嵌入的单条 **token** 预算(0 = 按模型窗口与硬上限自动推导)。
|
|
365
|
+
*
|
|
366
|
+
* 与 `maxInputChars` 不是同类护栏:字符帽只管 IPC 体积与远程档,bge-m3/embeddinggemma
|
|
367
|
+
* 是 Unigram/BPE、中文≈1 字/token 而 emoji/泰文>1.5,所以**字符帽约不到安全线**
|
|
368
|
+
* (issue #22:5000 字符≈3651 token,已在宿主分配器致死线 ≈2700 之上)。本键只管本地推理。
|
|
369
|
+
* 超上限会被 clamp 并留 warn(不抛错,见 model-catalog.ts `deriveTokenBudget`)。
|
|
370
|
+
*/
|
|
371
|
+
maxInputTokens: number;
|
|
363
372
|
/** 单次调用超时(ms)。 */
|
|
364
373
|
timeoutMs: number;
|
|
365
374
|
/** 允许本地嵌入模型档(部署上限:公司环境可禁下载与本地推理)。 */
|
package/dist/config.js
CHANGED
|
@@ -341,6 +341,10 @@ export const memorySchema = Schema.object({
|
|
|
341
341
|
// 0 = 纯 FTS 模式(合法值,勿设 min>0)
|
|
342
342
|
dimensions: Schema.number().min(0).max(8192).default(0),
|
|
343
343
|
maxInputChars: Schema.number().min(100).max(100_000).default(5000),
|
|
344
|
+
// 12-13 个已有 fixture 用 maxInputChars;本键**只增不改**(schemastery 实测未知键静默透传,
|
|
345
|
+
// 改名会让已部署 YAML 无声失效),且**不收窄边界**(越界值抛错的故障半径是整个配置树)。
|
|
346
|
+
// 上限交由 deriveTokenBudget 按模型窗口 clamp,这里给宽松外壁仅为防溢出输入。
|
|
347
|
+
maxInputTokens: Schema.number().min(0).max(100_000).default(0),
|
|
344
348
|
timeoutMs: Schema.number().min(1000).max(300_000).default(10_000),
|
|
345
349
|
allowLocalModels: Schema.boolean().default(true),
|
|
346
350
|
mirror: Schema.string().default('https://hf-mirror.com'),
|
package/dist/embedding-child.cjs
CHANGED
|
@@ -1,192 +1,224 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
|
|
3
|
-
* 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
|
|
4
|
-
* 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
|
|
5
|
-
* 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
|
|
6
|
-
* ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
|
|
7
|
-
*
|
|
8
|
-
* 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
|
|
9
|
-
* 条间 setImmediate 让路),只有 IPC 传输层不同:
|
|
10
|
-
*
|
|
11
|
-
* 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
|
|
12
|
-
* - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
|
|
13
|
-
* maxInputChars}——fork 的 env 只收标量,嵌套对象经序列化传递);
|
|
14
|
-
* - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
|
|
15
|
-
* - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(vectors 为
|
|
16
|
-
* number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
|
|
17
|
-
* Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
() =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
|
|
3
|
+
* 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
|
|
4
|
+
* 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
|
|
5
|
+
* 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
|
|
6
|
+
* ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
|
|
7
|
+
*
|
|
8
|
+
* 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
|
|
9
|
+
* 条间 setImmediate 让路),只有 IPC 传输层不同:
|
|
10
|
+
*
|
|
11
|
+
* 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
|
|
12
|
+
* - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
|
|
13
|
+
* maxInputChars,maxInputTokens}——fork 的 env 只收标量,嵌套对象经序列化传递);
|
|
14
|
+
* - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
|
|
15
|
+
* - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?,tokensMax?}(vectors 为
|
|
16
|
+
* number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
|
|
17
|
+
* Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)。
|
|
18
|
+
* tokensMax = 本批真正进模型的最大 token 数(#22 护栏自证用,主线程不消费也不报错)
|
|
19
|
+
* | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
|
|
20
|
+
*
|
|
21
|
+
* 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
|
|
22
|
+
* 本进程按其 package.json createRequire 加载 transformers——对数据目录有写
|
|
23
|
+
* 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
|
|
24
|
+
* 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { createRequire } = require('node:module');
|
|
30
|
+
// token 精截放在独立无副作用模块(而非本文件):本文件是 fork 入口,顶层有进程监听与
|
|
31
|
+
// 「无 IPC 就 exit」守卫,被测试 require 会把副作用带进来。截断逻辑本身必须零模型可验。
|
|
32
|
+
const { shrinkToBudget } = require('./embedding-token-guard.cjs');
|
|
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
|
+
|
|
41
|
+
// 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
|
|
42
|
+
if (typeof process.send !== 'function') {
|
|
43
|
+
console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 错误消息归一化(Error 取 message,其余 String 化)。 */
|
|
48
|
+
function errMsg(err) {
|
|
49
|
+
return err && err.message ? err.message : String(err);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cfg = JSON.parse(process.env.DSH_EMBED_CFG || '{}');
|
|
53
|
+
const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
|
|
54
|
+
const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
|
|
55
|
+
const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
|
|
56
|
+
const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
|
|
57
|
+
const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
|
|
58
|
+
/**
|
|
59
|
+
* token 预算(issue #22)。预算值由主进程从模型窗口与硬上限推导后传进来(单一真值源
|
|
60
|
+
* 在 src/store/model-catalog.ts);本处只消费。**截断必须在子进程做**:tokenizer 只在
|
|
61
|
+
* pipeline 对象上可达,且 ORT run() 同步阻塞——单条内部不可中断,预检只能放推理前。
|
|
62
|
+
*/
|
|
63
|
+
const MAX_INPUT_TOKENS = cfg.maxInputTokens > 0 ? cfg.maxInputTokens : 0; // 0 = 未给,不启用 token 截断
|
|
64
|
+
|
|
65
|
+
// 字符粗筛后的 token 精截需要 tokenizer(pipeline 对象上的 .tokenizer,加载后可达)
|
|
66
|
+
let tokenizer = null;
|
|
67
|
+
|
|
68
|
+
let extractor = null; // pipeline('feature-extraction', …)
|
|
69
|
+
let loadState = 'idle'; // idle | loading | ready | failed
|
|
70
|
+
let loadPromise = null;
|
|
71
|
+
let loadError = null;
|
|
72
|
+
|
|
73
|
+
// ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
|
|
74
|
+
function ensureLoaded() {
|
|
75
|
+
if (loadState === 'ready') return Promise.resolve();
|
|
76
|
+
if (loadState === 'loading' && loadPromise) return loadPromise;
|
|
77
|
+
loadState = 'loading';
|
|
78
|
+
loadError = null;
|
|
79
|
+
loadPromise = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('DSH_EMBED_CFG 缺少 runtimeDir/modelDir');
|
|
82
|
+
const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
|
|
83
|
+
const mod = req('@huggingface/transformers');
|
|
84
|
+
if (mod.env) {
|
|
85
|
+
mod.env.allowRemoteModels = false;
|
|
86
|
+
if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
|
|
87
|
+
}
|
|
88
|
+
extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
|
|
89
|
+
// token 截断要用到 pipeline 内部的 tokenizer(未单独暴露 AutoTokenizer,4.x 形态)
|
|
90
|
+
tokenizer = extractor && extractor.tokenizer ? extractor.tokenizer : null;
|
|
91
|
+
loadState = 'ready';
|
|
92
|
+
} catch (err) {
|
|
93
|
+
loadState = 'failed';
|
|
94
|
+
loadError = errMsg(err);
|
|
95
|
+
loadPromise = null; // 失败后下一次 warmup 重新走加载
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
return loadPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── 推理队列(逐条 job;priority 插队;条间让路) ──
|
|
103
|
+
const queue = []; // [{ request, index, text }]
|
|
104
|
+
let pumping = false;
|
|
105
|
+
|
|
106
|
+
function postReply(msg) {
|
|
107
|
+
// 通道已断(父进程退出/正在终止)时 send 抛错——静默即可,父进程侧有 exit 兜底
|
|
108
|
+
try {
|
|
109
|
+
process.send(msg);
|
|
110
|
+
} catch {
|
|
111
|
+
/* IPC 已关闭,回复无处可去 */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 请求全部条目完成 → 回执(Float32Array → number[] 序列化,IPC JSON 通道不支持 TypedArray)。 */
|
|
116
|
+
function settle(request) {
|
|
117
|
+
if (request.failed || request.remaining !== 0) return;
|
|
118
|
+
postReply({
|
|
119
|
+
id: request.id,
|
|
120
|
+
ok: true,
|
|
121
|
+
type: 'embedded',
|
|
122
|
+
vectors: request.vectors.map((v) => Array.from(v)),
|
|
123
|
+
// #22 护栏自证:本批**实测**进模型的最大 token 数(不可测时为 -1,主线程不读此字段也不会坏)
|
|
124
|
+
tokensMax: request.tokensMax,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
|
|
129
|
+
function failRequest(request, error, stage) {
|
|
130
|
+
if (request.failed) return;
|
|
131
|
+
request.failed = true;
|
|
132
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
133
|
+
if (queue[i].request === request) queue.splice(i, 1);
|
|
134
|
+
}
|
|
135
|
+
postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pump() {
|
|
139
|
+
if (pumping) return;
|
|
140
|
+
pumping = true;
|
|
141
|
+
try {
|
|
142
|
+
while (queue.length > 0) {
|
|
143
|
+
const job = queue.shift();
|
|
144
|
+
if (job.request.failed) continue;
|
|
145
|
+
try {
|
|
146
|
+
// 字符粗筛(IPC 体积) + token 精截(#22 崩溃/内存护栏);截断只在推理前,run() 同步不可中断
|
|
147
|
+
const screened = job.text.slice(0, MAX_INPUT_CHARS);
|
|
148
|
+
const cut = shrinkToBudget(screened, MAX_INPUT_TOKENS, tokenizer);
|
|
149
|
+
const result = await extractor([cut.text], { pooling: POOLING, normalize: true });
|
|
150
|
+
const data = result[0] && result[0].data;
|
|
151
|
+
job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
|
|
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;
|
|
157
|
+
job.request.remaining -= 1;
|
|
158
|
+
settle(job.request);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
failRequest(job.request, err, 'infer');
|
|
161
|
+
}
|
|
162
|
+
// 条间让路:交还本进程事件循环,新消息(含 priority 插队)可在批间被处理
|
|
163
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
pumping = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleEmbed(msg) {
|
|
171
|
+
const texts = Array.isArray(msg.texts) ? msg.texts : [];
|
|
172
|
+
if (texts.length === 0) {
|
|
173
|
+
postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false, tokensMax: 0 };
|
|
177
|
+
// 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
|
|
178
|
+
const priority = msg.priority === true && texts.length === 1;
|
|
179
|
+
for (let i = 0; i < texts.length; i++) {
|
|
180
|
+
const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
|
|
181
|
+
if (priority) queue.unshift(job);
|
|
182
|
+
else queue.push(job);
|
|
183
|
+
}
|
|
184
|
+
void pump();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
process.on('message', (msg) => {
|
|
188
|
+
if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
|
|
189
|
+
if (msg.type === 'ping') {
|
|
190
|
+
postReply({ id: msg.id, ok: true, type: 'pong' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (msg.type === 'warmup') {
|
|
194
|
+
ensureLoaded().then(
|
|
195
|
+
() => postReply({ id: msg.id, ok: true, type: 'ready' }),
|
|
196
|
+
() => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (msg.type === 'embed') {
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
await ensureLoaded();
|
|
204
|
+
} catch {
|
|
205
|
+
postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
handleEmbed(msg);
|
|
209
|
+
})();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 父进程disconnect(通道被主动关闭,如宿主优雅退出/RealChildChannel 前置断开):
|
|
215
|
+
// 模型与 onnxruntime 都只服务于这条通道,留着就是孤儿常驻——直接退出。
|
|
216
|
+
// taskkill /T /F 兜底覆盖"父进程被硬杀、无 disconnect"的路径。
|
|
217
|
+
process.on('disconnect', () => process.exit(0));
|
|
218
|
+
|
|
219
|
+
// 未捕获异常:尽力通知父进程后强制退出(父进程的 exit 处理会拒绝全部 pending
|
|
220
|
+
// 并转入 failed 态)。不保持半死状态——半死的子进程只会让调用方挂到超时。
|
|
221
|
+
process.on('uncaughtException', (err) => {
|
|
222
|
+
postReply({ type: 'fatal', error: errMsg(err) });
|
|
223
|
+
setTimeout(() => process.exit(1), 50);
|
|
224
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// token 精截(#22)。**独立无副作用模块**,不注册任何进程级监听:
|
|
2
|
+
// embedding-child.cjs 是 fork 入口(顶层有 process.on 与无 IPC 即 exit 的守卫),被测试
|
|
3
|
+
// require 会把副作用一并带进来(vitest worker 里实测崩在收集期),故截断逻辑只能放在这里
|
|
4
|
+
// 才能既被子进程用、又被测试用。**本文件同时被拷贝进 dist/**(见 scripts/copy-client.mjs)。
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 把一条文本裁到 **实测** token 预算内。返回 `{ text, seq }`:
|
|
9
|
+
* - `text` = 应送进 pipeline 的文本;
|
|
10
|
+
* - `seq` = 按库自己的口径(`padding:true, truncation:true`,含 special token)**实测将要**进模型
|
|
11
|
+
* 的序列长度;不可测时为 -1(-1 是「不可证」,不是 0)。
|
|
12
|
+
*
|
|
13
|
+
* 为什么截断只能在子进程做:tokenizer 只在加载后的 pipeline 对象上可达,且 onnxruntime
|
|
14
|
+
* 的 run() 是同步阻塞调用——单条内部不可中断,预检只能放推理前。又因为「子进程隔离」本身
|
|
15
|
+
* 是 #11(libvips-42.dll 同进程撞名)的修复,不得为省一次 IPC 把 tokenize 挪回主线程。
|
|
16
|
+
*
|
|
17
|
+
* 为什么不靠「budget - 2 留位」这种推断(真 bge-m3 实测已证伪):
|
|
18
|
+
* 切 ids → decode 回文本 → pipeline 再分词,**不保证得到同一序列**——实测 2046 个 id
|
|
19
|
+
* 解码后重分词得 2045 token,加 2 个 special token = **2047**,预算 2048 只剩 1 位余量。
|
|
20
|
+
* 换一段文本就可能多漂 1 位以上而越界,所以下限靠**实测逐轮收紧**,不靠常数猜测。
|
|
21
|
+
*
|
|
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% 不值一个未验证的跳跃。
|
|
26
|
+
*
|
|
27
|
+
* 任何一步不可用(transformers 未挂 tokenizer、不认选项、decode 回空串、抛错)一律
|
|
28
|
+
* **退回粗筛原文保持旧行为**:护栏不得把能跑的链路搞坏。
|
|
29
|
+
*
|
|
30
|
+
* 成本:每轮多一个 encode(实测占单次推理 ≈2.4%);至多重试 ROUNDS 轮。
|
|
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
|
+
|
|
44
|
+
function shrinkToBudget(raw, maxTokens, tok) {
|
|
45
|
+
if (!tok || !(maxTokens > 0)) return { text: raw, seq: -1 };
|
|
46
|
+
try {
|
|
47
|
+
// 实测陷阱:return_tensor 缺省为 true,直取 .input_ids 得到的是 _Tensor(.length undefined
|
|
48
|
+
// 且不抛错) -> 必须显式 return_tensor:false
|
|
49
|
+
const ids = tok.encode(raw, { return_tensor: false });
|
|
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 };
|
|
90
|
+
} catch {
|
|
91
|
+
return { text: raw, seq: -1 }; // 降级:不截断,保持旧行为
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { shrinkToBudget, seqLenOf };
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// DEPRECATED: use embedding-child.cjs (subprocess isolation, fix #11)
|
|
2
2
|
// 保留一版兼容(opts.useWorker 逃生门),下版删除。不再主动修改。
|
|
3
|
+
// 决策记录:#22 的 token 预算护栏**有意不同步到这里**(本通道仍只按 maxInputChars 粗筛)。
|
|
4
|
+
// 逃生门本身就是「回到 #11 前的风险状态」,给它同步一份预算代码 = 维护两份口径;
|
|
5
|
+
// 真需护栏请用默认 child 通道。参 embedding-child.cjs `shrinkToBudget`。
|
|
3
6
|
/**
|
|
4
7
|
* 本地嵌入 worker:transformers.js 的模型加载与推理在本线程执行,主线程只
|
|
5
8
|
* 投递文本、收回向量。onnxruntime-node 的 run/loadModel 是 setImmediate 回调里
|
package/dist/index.js
CHANGED
|
@@ -161,7 +161,7 @@ export async function apply(ctx, config) {
|
|
|
161
161
|
logger,
|
|
162
162
|
proxy: config.embedding.proxy,
|
|
163
163
|
});
|
|
164
|
-
const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars);
|
|
164
|
+
const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars, undefined, config.embedding.maxInputTokens);
|
|
165
165
|
let initial = { svc: new NoopEmbeddingService(), dims: 0 };
|
|
166
166
|
/** 管理器引用:启动重嵌链/backfill 闭包在运行期解引用(声明早于创建避免 TDZ)。 */
|
|
167
167
|
// eslint-disable-next-line prefer-const -- 声明早于创建,后续单点赋值(TS 不允许 const 无初值)
|
|
@@ -366,6 +366,17 @@ export async function apply(ctx, config) {
|
|
|
366
366
|
else if (r1.failed > 0) {
|
|
367
367
|
logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed}),下个周期重试`);
|
|
368
368
|
}
|
|
369
|
+
else if (missingAfter < 0) {
|
|
370
|
+
// 计数失败时 sqlite 层给 -1(它自己已 warn);本侧**不得**把「数不出」当「无缺失」
|
|
371
|
+
// 而滑进下方的停摆告警,更不能标 synced。
|
|
372
|
+
logger.warn('[memory] 向量补齐:缺失数统计失败,本轮不判定完成');
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
// 本轮未写入且仍有缺失:典型形态是本地嵌入转入 failed 后 reindex 首行早退
|
|
376
|
+
// (`if (!vectorReady()) return 0/0/0`),与「本轮无事可做」在日志里原本不可区分。
|
|
377
|
+
logger.warn(`[memory] 向量补齐停摆:本轮未写入且仍差 ${missingAfter} 条` +
|
|
378
|
+
`(本地嵌入可能处于 failed 态,见上一条带 signal 的退出记录)`);
|
|
379
|
+
}
|
|
369
380
|
}
|
|
370
381
|
catch (err) {
|
|
371
382
|
logger.warn(`[memory] 向量补齐失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -63,10 +63,13 @@ export interface InitialEmbedding {
|
|
|
63
63
|
export declare function remoteCeiling(cfg: MemoryConfig): boolean;
|
|
64
64
|
export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
|
|
65
65
|
/** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
|
|
66
|
-
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
|
|
66
|
+
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
|
|
67
|
+
* 新键一律**追加在末尾**:`childPath` 已有位置参数调用方(含测试),插中间会静默串位。 */
|
|
67
68
|
export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number,
|
|
68
69
|
/** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
|
|
69
|
-
childPath?: string
|
|
70
|
+
childPath?: string,
|
|
71
|
+
/** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
|
|
72
|
+
maxInputTokens?: number): (modelId: string) => LocalEmbeddingService | null;
|
|
70
73
|
export interface EmbeddingManagerDeps {
|
|
71
74
|
dataDir: string;
|
|
72
75
|
cfg: MemoryConfig;
|
|
@@ -153,10 +153,13 @@ export async function resolveInitialEmbedding(cfg, sourceStore, downloader, make
|
|
|
153
153
|
return { svc, dims: cfg.embedding.dimensions, providerInfo: svc.getProviderInfo() };
|
|
154
154
|
}
|
|
155
155
|
/** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
|
|
156
|
-
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
|
|
156
|
+
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
|
|
157
|
+
* 新键一律**追加在末尾**:`childPath` 已有位置参数调用方(含测试),插中间会静默串位。 */
|
|
157
158
|
export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars,
|
|
158
159
|
/** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
|
|
159
|
-
childPath
|
|
160
|
+
childPath,
|
|
161
|
+
/** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
|
|
162
|
+
maxInputTokens) {
|
|
160
163
|
return (modelId) => {
|
|
161
164
|
const entry = catalogById(modelId);
|
|
162
165
|
if (!entry)
|
|
@@ -166,6 +169,7 @@ childPath) {
|
|
|
166
169
|
childPath,
|
|
167
170
|
logger,
|
|
168
171
|
maxInputChars,
|
|
172
|
+
maxInputTokens,
|
|
169
173
|
});
|
|
170
174
|
};
|
|
171
175
|
}
|
|
@@ -245,7 +249,8 @@ export class EmbeddingManager {
|
|
|
245
249
|
}
|
|
246
250
|
/** 构造绑定真实运行时 loader 的本地服务(deps.makeLocal 可注入,测试替换)。 */
|
|
247
251
|
makeLocalService(modelId) {
|
|
248
|
-
const factory = this.deps.makeLocal ??
|
|
252
|
+
const factory = this.deps.makeLocal ??
|
|
253
|
+
makeLocalServiceFactory(this.installer, this.downloader, this.deps.logger, this.deps.cfg.embedding.maxInputChars, undefined, this.deps.cfg.embedding.maxInputTokens);
|
|
249
254
|
return factory(modelId);
|
|
250
255
|
}
|
|
251
256
|
/** 活切换请求:验证通过即接受,后台执行应用链(进度轮询可见)。 */
|
package/dist/store/l1.js
CHANGED
|
@@ -984,9 +984,14 @@ export class L1Store {
|
|
|
984
984
|
try {
|
|
985
985
|
vecs = await this.embedSvc.embedBatch(chunk.map((c) => c.content));
|
|
986
986
|
}
|
|
987
|
-
catch {
|
|
987
|
+
catch (err) {
|
|
988
|
+
// 必须带 id(#22 取证面):本腿直接调 embedSvc、绕过 EmbedHelper 的熔断/超时/截断,
|
|
989
|
+
// 一整批全失败是常态(进程被杀时后续 chunk 均秒拒);不带 id 就无从定位是哪条长文本致崩。
|
|
990
|
+
// 只打前 3 个:失败可能是整库堆积,全打会把日志挤掉。
|
|
988
991
|
failed += chunk.length;
|
|
989
992
|
done += chunk.length;
|
|
993
|
+
this.logger?.warn(`[memory] 重嵌批次失败(${chunk.length} 条,本 chunk 首三个 id=` +
|
|
994
|
+
`${chunk.slice(0, 3).map((c) => c.id).join(',')}…): ${err instanceof Error ? err.message : String(err)}`);
|
|
990
995
|
opts?.onProgress?.(done, total);
|
|
991
996
|
continue;
|
|
992
997
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MemoryLogger } from '../types.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type CatalogEntry } from './model-catalog.js';
|
|
3
3
|
import type { EmbedCallOptions, EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
|
|
4
4
|
type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
|
|
5
5
|
/** 推理侧启动参数(worker 经 workerData / 子进程经 env DSH_EMBED_CFG;构造后不可变)。 */
|
|
@@ -8,7 +8,13 @@ export interface EmbedWorkerData {
|
|
|
8
8
|
modelDir: string;
|
|
9
9
|
pooling: 'cls' | 'mean';
|
|
10
10
|
dtype: string;
|
|
11
|
+
/** 字符粗筛上限(IPC 体积护栏;不是语义护栏,也**不是**崩溃护栏——token 帽才是)。 */
|
|
11
12
|
maxInputChars: number;
|
|
13
|
+
/**
|
|
14
|
+
* 本地推理的单条 token 预算(issue #22)。主进程推导(`deriveTokenBudget`)、
|
|
15
|
+
* 子进程执行截断——因为 tokenizer 只在子进程可达,且 ORT `run()` 同步不可中断。
|
|
16
|
+
*/
|
|
17
|
+
maxInputTokens: number;
|
|
12
18
|
}
|
|
13
19
|
/** 主线程 → worker 的调用(id 由通道分配)。 */
|
|
14
20
|
export type EmbedWorkerCall = {
|
|
@@ -48,6 +54,12 @@ export interface EmbedWorkerChannel {
|
|
|
48
54
|
/** worker 意外崩溃通知(此后所有未决请求已被通道拒绝)。 */
|
|
49
55
|
setOnCrash(cb: (error: string) => void): void;
|
|
50
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* 退出描述归一化(issue #22 取证面):被信号硬杀时 Node 给的是 `code===null` + `signal`,
|
|
59
|
+
* 只打 code 会让 SIGTRAP(分配器 trap)/OOM-kill(SIGKILL)/自然退出(code=0)在日志里长得一模一样。
|
|
60
|
+
* signal 同时进 crashed 串、向上传为 loadError,故一处修三处可见。
|
|
61
|
+
*/
|
|
62
|
+
export declare function describeExit(what: string, code: number | null, signal: string | null): string;
|
|
51
63
|
export interface LocalEmbeddingOptions {
|
|
52
64
|
/** 数据目录 runtime/(推理侧据此 createRequire 加载 transformers)。 */
|
|
53
65
|
runtimeDir: string;
|
|
@@ -61,6 +73,11 @@ export interface LocalEmbeddingOptions {
|
|
|
61
73
|
channel?: EmbedWorkerChannel;
|
|
62
74
|
logger?: MemoryLogger;
|
|
63
75
|
maxInputChars?: number;
|
|
76
|
+
/**
|
|
77
|
+
* 用户显式 token 预算(config `embedding.maxInputTokens`);缺省/0 = 按模型窗口与
|
|
78
|
+
* `HARD_CAP_TOKENS` 自动推导。超限只 clamp + warn,不抛错。
|
|
79
|
+
*/
|
|
80
|
+
maxInputTokens?: number;
|
|
64
81
|
}
|
|
65
82
|
export declare class LocalEmbeddingService implements EmbeddingService {
|
|
66
83
|
private state;
|
|
@@ -22,6 +22,7 @@ import { fork, spawn } from 'node:child_process';
|
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { Worker } from 'node:worker_threads';
|
|
25
|
+
import { deriveTokenBudget, HARD_CAP_TOKENS } from './model-catalog.js';
|
|
25
26
|
/** 默认 worker 资产路径(旧通道,useWorker 逃生门):dist/store/ → dist/embedding-worker.cjs(构建期拷入)。 */
|
|
26
27
|
function defaultWorkerPath() {
|
|
27
28
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'embedding-worker.cjs');
|
|
@@ -56,9 +57,12 @@ class RealWorkerChannel {
|
|
|
56
57
|
});
|
|
57
58
|
// error(未捕获异常且 worker 未自处理)与 exit(含 fatal 后的退出)都兜底拒绝
|
|
58
59
|
this.worker.on('error', (err) => this.failAll(`本地嵌入 worker 线程错误: ${err.message}`));
|
|
59
|
-
this.worker.on('exit', (code) => {
|
|
60
|
+
this.worker.on('exit', (code, signal) => {
|
|
61
|
+
// signal 必须留痕:被信号硬杀时 code===null,单看 code 无法区分「SIGTRAP/OOM-kill/自然退出」
|
|
62
|
+
// (issue #22 就是靠 macOS 崩溃报告对毫秒时间戳才归因上的)。
|
|
63
|
+
// worker 的 exit 事件在 typings 里无强类型重载,故 signal 取守性可选形参。
|
|
60
64
|
if (!this.terminated)
|
|
61
|
-
this.failAll(
|
|
65
|
+
this.failAll(describeExit('本地嵌入 worker 线程', code, signal ?? null));
|
|
62
66
|
});
|
|
63
67
|
}
|
|
64
68
|
request(call) {
|
|
@@ -96,6 +100,14 @@ class RealWorkerChannel {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* 退出描述归一化(issue #22 取证面):被信号硬杀时 Node 给的是 `code===null` + `signal`,
|
|
105
|
+
* 只打 code 会让 SIGTRAP(分配器 trap)/OOM-kill(SIGKILL)/自然退出(code=0)在日志里长得一模一样。
|
|
106
|
+
* signal 同时进 crashed 串、向上传为 loadError,故一处修三处可见。
|
|
107
|
+
*/
|
|
108
|
+
export function describeExit(what, code, signal) {
|
|
109
|
+
return `${what}退出(code=${code ?? 'null'},signal=${signal ?? 'null'})`;
|
|
110
|
+
}
|
|
99
111
|
/**
|
|
100
112
|
* 真实子进程通道(fix #11 默认):fork 独立进程隔离 sharp/libvips DLL 冲突。
|
|
101
113
|
* 与 RealWorkerChannel 同协议语义,差异仅在传输层:
|
|
@@ -140,9 +152,10 @@ class RealChildChannel {
|
|
|
140
152
|
});
|
|
141
153
|
// error(fork 启动失败等)与 exit(含 fatal 后自杀、OOM 被杀)都钉底拒绝
|
|
142
154
|
this.child.on('error', (err) => this.failAll(`本地嵌入子进程错误: ${err.message}`));
|
|
143
|
-
this.child.on('exit', (code) => {
|
|
155
|
+
this.child.on('exit', (code, signal) => {
|
|
156
|
+
// 同上:signal 是被硬杀的唯一线索,不得丢弃(#22)
|
|
144
157
|
if (!this.terminated)
|
|
145
|
-
this.failAll(
|
|
158
|
+
this.failAll(describeExit('本地嵌入子进程', code, signal));
|
|
146
159
|
});
|
|
147
160
|
}
|
|
148
161
|
request(call) {
|
|
@@ -213,13 +226,25 @@ export class LocalEmbeddingService {
|
|
|
213
226
|
this.entry = entry;
|
|
214
227
|
this.logger = opts.logger;
|
|
215
228
|
const maxInputChars = opts.maxInputChars && opts.maxInputChars > 0 ? opts.maxInputChars : 5000;
|
|
229
|
+
const budget = deriveTokenBudget(entry.contextTokens, opts.maxInputTokens);
|
|
230
|
+
if (budget.clamped) {
|
|
231
|
+
// 用户写了个必崩的值:压到上限并留痕(不抛错——拒绝启动比 clamp 更难排查)
|
|
232
|
+
this.logger?.warn(`[memory] 本地嵌入 token 预算已从 ${opts.maxInputTokens} 压到 ${budget.tokens}` +
|
|
233
|
+
`(模型窗口 ${entry.contextTokens} / 硬上限 ${HARD_CAP_TOKENS},#22 安全余量)`);
|
|
234
|
+
}
|
|
216
235
|
const cfgObj = {
|
|
217
236
|
runtimeDir: opts.runtimeDir,
|
|
218
237
|
modelDir,
|
|
219
238
|
pooling: entry.pooling,
|
|
220
239
|
dtype: 'q8',
|
|
221
240
|
maxInputChars,
|
|
241
|
+
maxInputTokens: budget.tokens,
|
|
222
242
|
};
|
|
243
|
+
// #22 取证面:同一份代码在纯 node 能跑、在 Electron-node 崩,而仓内任何地方都没记录
|
|
244
|
+
// execPath 差异与生效预算 —— 下次不用靠系统崩溃报告对时间戳。
|
|
245
|
+
this.logger?.info(`[memory] 本地嵌入子进程拉起:execPath=${process.execPath},release=${process.release?.name ?? 'unknown'},` +
|
|
246
|
+
`model=${entry.id},contextTokens=${entry.contextTokens},tokenBudget=${budget.tokens},` +
|
|
247
|
+
`粗筛 chars=${maxInputChars},通道=${opts.useWorker ? 'worker(旧逃生门,无 token 护栏)' : 'child'}`);
|
|
223
248
|
this.channel =
|
|
224
249
|
opts.channel ??
|
|
225
250
|
(opts.useWorker
|
|
@@ -31,7 +31,10 @@ export interface CatalogEntry {
|
|
|
31
31
|
revision: string;
|
|
32
32
|
/** 向量维度(vec0 建表与切换重嵌的判据)。 */
|
|
33
33
|
dims: number;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* 上下文窗口(token)。0.26.25 起从「仅 UI 展示」升格为**本地推理预算的推导真值**
|
|
36
|
+
* (见 `deriveTokenBudget`)——改动这里就是改本地嵌入的截断口径,不再只是个展示字段。
|
|
37
|
+
*/
|
|
35
38
|
contextTokens: number;
|
|
36
39
|
/** 池化方式:BGE 系 CLS;embeddinggemma 系(Gemma 解码器底座)MEAN。 */
|
|
37
40
|
pooling: 'cls' | 'mean';
|
|
@@ -44,5 +47,32 @@ export interface CatalogEntry {
|
|
|
44
47
|
export declare const MODEL_CATALOG: CatalogEntry[];
|
|
45
48
|
/** 按 id 取目录项。 */
|
|
46
49
|
export declare function catalogById(id: string): CatalogEntry | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* 本地 ONNX 推理的单条 token 上限(**唯一硬编码源**,issue #22)。
|
|
52
|
+
*
|
|
53
|
+
* 取值 2048 的理由(不是拍脑袋):#22 实测 Electron 宿主下约 **2700 token** 即被分配器
|
|
54
|
+
* SIGTRAP 打死(栈:MatMul 输出张量 → BFCArena::Extend → malloc_zone_memalign),且峰值
|
|
55
|
+
* 内存按 **O(T²)** 增长(attention 张量 `[1,heads,T,T]`)——2048 在致死线下留 ≈24% 余量,
|
|
56
|
+
* 同时把单次峰值从 ≈4.7GB 压到 ≈0.43 倍。它是**崩溃护栏也是内存护栏**,两者同源。
|
|
57
|
+
*
|
|
58
|
+
* 不拿 `maxInputChars` 当护栏的原因:bge-m3/embeddinggemma 是 Unigram/BPE,中文≈**1 字/token**
|
|
59
|
+
* 而 emoji/泰文可 >1.5,所以 5000 字符**既可只 750 token(英文)也可近 8192(中文)**——
|
|
60
|
+
* 字符与 token 无稳定比值,字符帽永远约不到安全线。
|
|
61
|
+
*/
|
|
62
|
+
export declare const HARD_CAP_TOKENS = 2048;
|
|
63
|
+
/**
|
|
64
|
+
* 推导生效的本地嵌入 token 预算(**单一实现点**,截断执行在 `resources/embedding-child.cjs`)。
|
|
65
|
+
*
|
|
66
|
+
* 默认(`userTokens` 缺省/0/负)= `min(modelContext, HARD_CAP_TOKENS)`:bge-small-zh(512)
|
|
67
|
+
* 不受 HARD_CAP 影响(本来就到 512 窗口),bge-m3(8192) 被压到 2048。`modelContext`
|
|
68
|
+
* 取不到(非目录模型)时回退 HARD_CAP_TOKENS,**取小不取大**。
|
|
69
|
+
*
|
|
70
|
+
* 用户显式超限只 **clamp 不抛错**(收窄 schema 边界会让已部署 YAML 解析直接失败,故障半径
|
|
71
|
+
* 是整个插件配置树),`clamped` 由调用方打 warn 使其可见。
|
|
72
|
+
*/
|
|
73
|
+
export declare function deriveTokenBudget(modelContext: number | undefined, userTokens: number | undefined): {
|
|
74
|
+
tokens: number;
|
|
75
|
+
clamped: boolean;
|
|
76
|
+
};
|
|
47
77
|
/** 模型总字节数(磁盘检查与整体进度分母)。 */
|
|
48
78
|
export declare function catalogTotalBytes(entry: CatalogEntry): number;
|
|
@@ -75,6 +75,36 @@ export const MODEL_CATALOG = [
|
|
|
75
75
|
export function catalogById(id) {
|
|
76
76
|
return MODEL_CATALOG.find((m) => m.id === id);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* 本地 ONNX 推理的单条 token 上限(**唯一硬编码源**,issue #22)。
|
|
80
|
+
*
|
|
81
|
+
* 取值 2048 的理由(不是拍脑袋):#22 实测 Electron 宿主下约 **2700 token** 即被分配器
|
|
82
|
+
* SIGTRAP 打死(栈:MatMul 输出张量 → BFCArena::Extend → malloc_zone_memalign),且峰值
|
|
83
|
+
* 内存按 **O(T²)** 增长(attention 张量 `[1,heads,T,T]`)——2048 在致死线下留 ≈24% 余量,
|
|
84
|
+
* 同时把单次峰值从 ≈4.7GB 压到 ≈0.43 倍。它是**崩溃护栏也是内存护栏**,两者同源。
|
|
85
|
+
*
|
|
86
|
+
* 不拿 `maxInputChars` 当护栏的原因:bge-m3/embeddinggemma 是 Unigram/BPE,中文≈**1 字/token**
|
|
87
|
+
* 而 emoji/泰文可 >1.5,所以 5000 字符**既可只 750 token(英文)也可近 8192(中文)**——
|
|
88
|
+
* 字符与 token 无稳定比值,字符帽永远约不到安全线。
|
|
89
|
+
*/
|
|
90
|
+
export const HARD_CAP_TOKENS = 2048;
|
|
91
|
+
/**
|
|
92
|
+
* 推导生效的本地嵌入 token 预算(**单一实现点**,截断执行在 `resources/embedding-child.cjs`)。
|
|
93
|
+
*
|
|
94
|
+
* 默认(`userTokens` 缺省/0/负)= `min(modelContext, HARD_CAP_TOKENS)`:bge-small-zh(512)
|
|
95
|
+
* 不受 HARD_CAP 影响(本来就到 512 窗口),bge-m3(8192) 被压到 2048。`modelContext`
|
|
96
|
+
* 取不到(非目录模型)时回退 HARD_CAP_TOKENS,**取小不取大**。
|
|
97
|
+
*
|
|
98
|
+
* 用户显式超限只 **clamp 不抛错**(收窄 schema 边界会让已部署 YAML 解析直接失败,故障半径
|
|
99
|
+
* 是整个插件配置树),`clamped` 由调用方打 warn 使其可见。
|
|
100
|
+
*/
|
|
101
|
+
export function deriveTokenBudget(modelContext, userTokens) {
|
|
102
|
+
const cap = Math.min(modelContext && modelContext > 0 ? modelContext : HARD_CAP_TOKENS, HARD_CAP_TOKENS);
|
|
103
|
+
const raw = userTokens && userTokens > 0 ? Math.floor(userTokens) : 0;
|
|
104
|
+
if (raw === 0)
|
|
105
|
+
return { tokens: cap, clamped: false };
|
|
106
|
+
return { tokens: Math.min(raw, cap), clamped: raw > cap };
|
|
107
|
+
}
|
|
78
108
|
/** 模型总字节数(磁盘检查与整体进度分母)。 */
|
|
79
109
|
export function catalogTotalBytes(entry) {
|
|
80
110
|
return entry.files.reduce((sum, f) => sum + f.size, 0);
|
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 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
|