From 2ca5106767960efa2b03b38b35cad0d1b21f7e94 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Mon, 23 Mar 2026 16:09:31 +0800 Subject: [PATCH] docs: rewrite README and fix injector formatting --- README.md | 939 ++++++++++++++++++++++++++++---------- injector.js | 12 +- tests/injector-format.mjs | 58 +++ 3 files changed, 761 insertions(+), 248 deletions(-) create mode 100644 tests/injector-format.mjs diff --git a/README.md b/README.md index fa5af5d..31b9309 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,171 @@ # ST-BME -> 面向 [SillyTavern](https://github.com/SillyTavern/SillyTavern) 的双向记忆图谱扩展,用于在长对话、角色扮演与持续故事创作中提供结构化写入、图谱检索与可控注入能力。 +> 面向 [SillyTavern](https://github.com/SillyTavern/SillyTavern) 的图谱记忆扩展。它把聊天过程转成结构化记忆图,再在生成前按场景召回并注入,服务于长对话、角色扮演、持续剧情和世界观管理。 -## 项目简介 +## 项目概览 -ST-BME(ST-Bionic-Memory-Ecology)是一个运行在 SillyTavern 第三方扩展体系中的记忆模块。它围绕“写入”和“读取”两条路径工作: +ST-BME 的全称是 **ST-Bionic-Memory-Ecology**。它不是一个独立的后端服务,也不是一个通用数据库,而是一个运行在 SillyTavern 第三方扩展体系内的前端记忆层。 -- 写入侧将聊天内容提取为结构化节点与关系,持久化到当前聊天元数据中。 -- 读取侧在生成前根据用户输入检索相关记忆,并以格式化内容注入上下文。 +它主要做两件事: -项目的目标不是替代模型本身的上下文理解,而是在长周期互动中为模型补充一层可累积、可筛选、可演化的外部记忆表示。 +1. **写入**:从聊天内容中抽取结构化记忆,存入当前聊天对应的知识图谱。 +2. **读取**:在下一次生成前,从图谱中找出与当前输入最相关的记忆,并把它们整理成适合模型理解的上下文片段。 -当前实现以知识图谱为核心,结合向量相似度、图扩散、LLM 精确筛选,以及若干受外部研究项目启发的增强机制。 +项目目标不是替代大模型的原生上下文能力,而是为长期互动补上一层可累计、可更新、可压缩、可检索的外部记忆系统。 -## 核心特性 +## 这个项目解决什么问题 -### 已实现 +在长期 RP 或持续陪伴式对话里,模型通常会遇到几类典型问题: -- **图谱化记忆存储**:使用节点与边表示事件、角色、地点、规则、主线、概要等结构化信息。 -- **聊天级持久化**:图状态写入当前聊天 `chat_metadata`,可随聊天保存与恢复。 -- **自动记忆提取**:按设定频率从最近对话中抽取结构化操作并更新图谱。 -- **三阶段召回编排**:支持向量预筛、图扩散排序、混合评分与可选 LLM 精确召回。 -- **情景重构召回**:会围绕命中的事件、角色、地点自动补齐相邻场景节点,使注入结果更接近连续剧情记忆。 -- **层级压缩**:对事件与主线等类型执行分层摘要,控制图谱膨胀。 -- **全局概要节点**:周期性生成 `synopsis` 类型节点,作为长期叙事锚点。 -- **记忆进化**:新节点写入后,基于近邻分析回溯更新已有记忆并建立新连接。 -- **时序更新追踪增强**:节点更新时会补充 `updates` / `temporal_update` 语义链路,并生成可追踪的状态更新事件。 -- **反思条目生成**:支持按提取周期生成 `reflection` 节点,用于沉淀高层叙事结论、关系趋势与后续建议。 -- **时序边字段**:关系边携带 `validAt` / `invalidAt` / `expiredAt` 等时间语义字段。 -- **导入导出与手动操作入口**:设置面板已提供查看图谱、查看注入、重建、压缩、导入、导出等入口。 +- 早期剧情、角色状态、地点细节很快被上下文窗口挤掉。 +- 模型会记得“有这回事”,但不容易稳定地记住“谁在什么时候做了什么、现在又变成什么状态”。 +- 角色状态和地点状态会随剧情变化,旧信息与新信息容易混杂。 +- 世界规则、主线目标、前情提要常常需要持续注入,但又不能把所有历史全文塞回 prompt。 -### 实验性 +ST-BME 的思路是把“聊天历史”变成“图谱化记忆”: -以下能力在代码和设置项中已出现,但成熟度与验证覆盖仍有限,应视为实验性: +- 事件、角色、地点、规则、主线、概要、反思都以节点表示。 +- 节点之间通过关系边连接,形成可扩散的结构。 +- 重要、常驻的信息直接进入 Core 注入。 +- 与当前用户输入强相关的状态和补充记忆再走召回注入。 -- **Mem0 风格精确对照**:新记忆可与近邻旧记忆对照后再决定新增、更新或跳过。 -- **认知边界过滤**:可按可见性约束过滤检索结果,适用于“角色不知道的信息不应注入”的场景。 -- **交叉检索**:实体命中后沿图边扩展相关事件节点,补充情境上下文。 -- **分桶式注入编排**:召回结果会按“当前状态 / 情景事件 / 反思锚点 / 规则约束”分组组织,降低碎片化注入。 -- **主动遗忘**:按保留价值归档低价值节点,缓解长期运行后的图谱膨胀。 -- **概率触发回忆**:未被主流程命中的高重要性节点有概率被额外召回。 +## 项目边界 -### 规划中 +当前实现很明确地有以下边界: -以下方向在现有代码中仅有预留开关、设计痕迹或路线图描述,尚不应视为完整能力: +- **单聊天作用域**:每个聊天维护一份独立图谱,图状态挂在当前聊天 `chat_metadata` 下。 +- **无独立数据库**:没有额外服务端存储层,所有持久化都依赖 SillyTavern 的聊天元数据保存机制。 +- **LLM 与 Embedding 分离**: + - 结构化提取、精确召回、压缩、进化、概要、反思都通过 ST 内部的 `sendOpenAIRequest('quiet', ...)` 调用聊天模型。 + - 向量检索依赖单独配置的 OpenAI 兼容 Embedding API。 +- **图谱是工程化记忆,不是事实真相库**:它依赖 LLM 的结构化输出质量,因此仍然存在抽取偏差、更新遗漏、关系误判等风险。 -- **完整端到端测试与稳定性验证** -- **图谱可视化面板** -- **自定义 Schema 编辑体验增强** -- **多聊天图谱合并与迁移工具** -- **导出为 World Info 等外部格式的更完整支持** +## 运行依赖 -## 适用场景 +| 依赖 | 是否必需 | 作用 | +| --- | --- | --- | +| SillyTavern 第三方扩展系统 | 必需 | 提供事件钩子、设置存储、聊天上下文、Prompt 注入接口 | +| 当前可用的聊天模型 | 必需 | 用于提取、精确召回、压缩、进化、概要、反思等所有 LLM 子任务 | +| OpenAI 兼容 Embedding API | 向量检索相关功能必需 | 用于节点 embedding、向量预筛、Mem0 风格近邻对照、记忆进化近邻搜索 | +| 当前聊天元数据 | 必需 | 存储图谱状态、最后处理楼层、最后召回结果 | -ST-BME 当前更适合以下场景: - -- 长篇角色扮演(RP)与多人剧情对话 -- 需要持续追踪角色状态、地点状态、事件推进的故事生成 -- 世界观设定较多、规则需要持续注入的互动创作 -- 需要在单个聊天内形成“前情提要 + 当前状态 + 局部召回”结构的长期陪伴式对话 - -对短聊天、一次性问答、无结构叙事需求的场景,ST-BME 的收益通常不会明显高于普通上下文管理。 - -## 架构概览 - -### 写入路径 - -1. 从聊天中收集尚未处理的对话片段。 -2. 调用提取器生成结构化操作(创建 / 更新 / 删除)。 -3. 按配置执行近邻对照,降低重复创建概率。 -4. 将新节点写入图谱,并补充关系边。 -5. 为缺失向量的节点生成 embedding。 -6. 可选执行记忆进化、全局概要更新、主动遗忘与层级压缩。 -7. 将图状态保存回聊天元数据。 - -### 读取路径 - -1. 读取当前活跃图节点。 -2. 可选先做认知边界过滤。 -3. 执行向量预筛与实体锚点识别。 -4. 基于时序邻接图做扩散排序。 -5. 按混合评分汇总候选节点。 -6. 在小图或大图场景下,可进一步调用 LLM 做精确召回。 -7. 将结果格式化后注入到生成上下文中。 - -### 模块对应关系 - -- [`index.js`](ST-BME/index.js):扩展入口、设置管理、流程调度、持久化挂钩 -- [`extractor.js`](ST-BME/extractor.js):记忆提取、精确对照、概要生成 -- [`retriever.js`](ST-BME/retriever.js):召回主流程、混合评分、可选精确召回 -- [`graph.js`](ST-BME/graph.js):图数据模型、节点边操作、时序字段 -- [`compressor.js`](ST-BME/compressor.js):层级压缩与主动遗忘 -- [`evolution.js`](ST-BME/evolution.js):新记忆触发的近邻进化 -- [`diffusion.js`](ST-BME/diffusion.js):图扩散排序 -- [`schema.js`](ST-BME/schema.js):默认节点类型定义与关系类型约束 -- [`injector.js`](ST-BME/injector.js):召回结果格式化与注入内容构建 -- [`embedding.js`](ST-BME/embedding.js):OpenAI 兼容 embedding 接口封装 -- [`llm.js`](ST-BME/llm.js):LLM JSON 调用封装 - -## 目录结构 +## 系统总览 ```text -ST-BME/ -├── manifest.json # 扩展清单 -├── index.js # 扩展入口与流程编排 -├── settings.html # 设置面板 -├── style.css # UI 样式 -├── graph.js # 图数据模型 -├── schema.js # 节点类型与关系定义 -├── extractor.js # 写入路径 -├── retriever.js # 读取路径 -├── evolution.js # 记忆进化 -├── compressor.js # 压缩与主动遗忘 -├── diffusion.js # 图扩散算法 -├── dynamics.js # 动态评分与访问强化 -├── embedding.js # Embedding API 封装 -├── llm.js # LLM 调用封装 -├── injector.js # 注入格式化 -├── LICENSE # 许可证 -└── README.md +聊天消息 + ├─ assistant 回复完成后 + │ └─ ST-BME 提取未处理片段 + │ ├─ LLM 生成 create/update/delete 操作 + │ ├─ 执行图谱写入 + │ ├─ 生成缺失 embedding + │ ├─ 可选执行进化 / 概要 / 反思 / 遗忘 / 压缩 + │ └─ 保存回 chat_metadata + │ + └─ 下次生成前 + └─ ST-BME 检索当前图谱 + ├─ 可见性过滤 + ├─ 向量预筛 + ├─ 图扩散 + ├─ 混合评分 + ├─ 可选 LLM 精确召回 + ├─ 场景重构 + └─ 格式化为注入文本并送入 prompt ``` -## 安装方式 +## 与 SillyTavern 的集成方式 -### 1. 准备环境 +ST-BME 的主入口在 [index.js](./index.js)。它不是轮询式工作的,而是绑定在 SillyTavern 的事件生命周期上: -先确保本地已经可正常运行 SillyTavern。 +| ST 事件 | 对应逻辑 | 作用 | +| --- | --- | --- | +| `CHAT_CHANGED` | `onChatChanged()` | 切换聊天时重新加载该聊天的图谱 | +| `GENERATION_AFTER_COMMANDS` | `runExtraction()` | assistant 回复完成后,处理尚未提取的内容 | +| `GENERATE_BEFORE_COMBINE_PROMPTS` | `runRecall()` | 下一轮生成前召回记忆并注入 | +| `MESSAGE_RECEIVED` | `onMessageReceived()` | 新消息到达时保存当前图状态 | -### 2. 放置扩展目录 +这意味着 ST-BME 的运行时机非常清楚: -将 [`ST-BME`](ST-BME) 目录放入 SillyTavern 第三方扩展目录,例如: +- **写入发生在回复之后**,记录刚刚发生了什么。 +- **读取发生在下一次生成之前**,决定接下来模型应该看见哪些记忆。 -```text -SillyTavern/public/scripts/extensions/third-party/ST-BME/ -``` +## 数据存储与持久化 -### 3. 重新加载 SillyTavern +图谱键名固定为 `st_bme_graph`,存储在当前聊天的 `chat_metadata` 中。 -重启或刷新 SillyTavern 后,在扩展面板中应能看到 ST-BME 对应条目。扩展清单定义见 [`manifest.json`](ST-BME/manifest.json)。 +图状态的核心结构如下: -### 4. 配置 Embedding 接口 +| 字段 | 含义 | +| --- | --- | +| `version` | 图数据版本号,当前实现为 v3 | +| `lastProcessedSeq` | 已处理到的聊天楼层索引 | +| `nodes` | 全部节点,包括活跃和归档节点 | +| `edges` | 全部关系边,包括失效边和历史边 | +| `lastRecallResult` | 最近一次召回选中的节点 ID 列表 | -若要启用向量检索、近邻对照与部分增强能力,需要填写可用的 OpenAI 兼容 embedding 接口: +图数据由 [graph.js](./graph.js) 管理,支持: -- API 地址 -- API Key -- 模型名 +- 空图创建 +- 节点/边增删改查 +- 时序链表维护 +- 时序边失效处理 +- 版本迁移与兼容反序列化 +- 导入导出 -默认模型名为 `text-embedding-3-small`。 +### 节点公共字段 -## 配置说明 +所有节点都会带有一套统一元数据: -设置面板位于 [`settings.html`](ST-BME/settings.html),当前主要配置可分为以下几类。 +| 字段 | 说明 | +| --- | --- | +| `id` | UUID | +| `type` | 节点类型 | +| `level` | 压缩层级,原始节点为 0 | +| `parentId` / `childIds` | 压缩层级父子关系 | +| `seq` | 该节点对应的主楼层索引 | +| `seqRange` | 节点覆盖的楼层范围 | +| `archived` | 是否归档 | +| `fields` | 业务字段主体 | +| `embedding` | 向量表示 | +| `importance` | 重要性,范围 0-10 | +| `accessCount` | 被召回/注入的访问次数 | +| `lastAccessTime` | 最近被访问时间 | +| `createdTime` | 节点创建时间 | +| `prevId` / `nextId` | 同类型节点的时间链表 | +| `clusters` | 额外标签/聚类信息 | -### 基础开关 +### 边公共字段 -| 配置项 | 默认值 | 说明 | -| ----------------- | ------ | ------------------------------------- | -| 启用记忆图谱 | 关闭 | 总开关,关闭时不执行提取与召回 | -| 每 N 条回复提取 | 1 | 控制提取频率 | -| 启用记忆召回注入 | 开启 | 是否在生成前注入检索结果 | -| 启用 LLM 精确召回 | 开启 | 小图 / 大图场景下可进一步筛选候选记忆 | -| 注入深度 | 4 | 控制注入位置 | +| 字段 | 说明 | +| --- | --- | +| `id` | UUID | +| `fromId` / `toId` | 边起点和终点 | +| `relation` | 关系类型 | +| `strength` | 边强度,范围 0-1 | +| `edgeType` | 边类型标记,`255` 表示抑制边 | +| `createdTime` | 创建时间 | +| `validAt` | 生效时间 | +| `invalidAt` | 失效时间 | +| `expiredAt` | 系统标记过期时间 | -### 混合评分权重 +其中 `contradicts` 关系会被映射成抑制边,后续在扩散阶段会传递负能量。 -默认权重来自 [`index.js`](ST-BME/index.js): +## 默认 Schema -| 参数 | 默认值 | -| ---------- | ------ | -| 图扩散权重 | 0.6 | -| 向量权重 | 0.3 | -| 重要性权重 | 0.1 | +默认 Schema 定义在 [schema.js](./schema.js)。它不仅定义了字段,还定义了注入策略、更新策略和压缩策略。 -### 增强能力开关 - -| 能力 | 默认状态 | 当前判断 | -| ------------ | -------- | -------------------------- | -| 记忆进化 | 开启 | 已实现 | -| 精确对照 | 开启 | 实验性 | -| 全局概要 | 开启 | 已实现 | -| 认知边界 | 关闭 | 实验性 | -| 交叉检索 | 关闭 | 实验性 | -| 惊奇度分割 | 关闭 | 规划中 / 预留开关 | -| 主动遗忘 | 关闭 | 实验性 | -| 概率触发回忆 | 关闭 | 实验性 | -| 反思条目 | 关闭 | 已实现(建议先小规模验证) | - -## 数据模型 - -### 图状态 - -图状态由以下核心部分组成: - -- `version` -- `lastProcessedSeq` -- `nodes` -- `edges` -- `lastRecallResult` - -其中节点和边统一由 [`graph.js`](ST-BME/graph.js) 管理,并序列化到聊天元数据中。 - -### 节点类型 - -默认 Schema 定义在 [`schema.js`](ST-BME/schema.js)。当前内置节点类型包括: - -| 类型 | 用途 | 当前状态 | -| ------------ | -------------------- | ------------------------------------ | -| `event` | 事件、动作、剧情推进 | 已实现 | -| `character` | 角色状态快照 | 已实现 | -| `location` | 地点与环境状态 | 已实现 | -| `rule` | 世界规则、约束与设定 | 已实现 | -| `thread` | 主线与阶段性进度 | 已实现 | -| `synopsis` | 全局前情提要 | 已实现 | -| `reflection` | 反思与元认知记录 | 已实现周期生成,建议结合实际剧情验证 | +| 类型 | 作用 | `alwaysInject` | `latestOnly` | 压缩 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `event` | 事件、动作、阶段推进 | 是 | 否 | 分层压缩 | 当前实现里属于 Core 常驻注入 | +| `character` | 角色状态快照 | 否 | 是 | 不压缩 | 同名 `create` 会转成 `update` | +| `location` | 地点状态快照 | 否 | 是 | 不压缩 | 同名 `create` 会转成 `update` | +| `rule` | 规则、约束、世界设定 | 是 | 否 | 不压缩 | 常驻注入 | +| `thread` | 主线或任务线 | 是 | 否 | 分层压缩 | 常驻注入 | +| `synopsis` | 全局前情提要 | 是 | 是 | 不压缩 | 只保留最新一条概要 | +| `reflection` | 高层反思与长期提示 | 否 | 否 | 分层压缩 | 通过召回进入上下文 | ### 关系类型 -当前内置关系类型包括: +默认关系类型包括: - `related` - `involved_in` @@ -228,93 +176,600 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/ - `evolves` - `temporal_update` -### 时序语义 +## 写入链路详解 -边对象已实现以下时间字段: +写入逻辑主要集中在 [index.js](./index.js) 和 [extractor.js](./extractor.js)。 -- `validAt`:关系生效时间 -- `invalidAt`:关系失效时间 -- `expiredAt`:系统标记过期时间 +### 1. 提取触发条件 -这为后续更严格的“过去事实 / 当前事实”区分提供了基础,但实际策略仍在持续打磨中。 +ST-BME 只统计 **assistant 消息** 来决定何时提取。 -## 使用流程 +默认策略: -### 最小使用步骤 +- `extractEvery = 1` 时,每 1 条 assistant 回复提取一次。 +- `lastProcessedSeq` 记录的是聊天数组索引,因此它用的是“楼层”语义,而不是消息 ID。 -1. 在扩展面板中启用 ST-BME。 -2. 配置 Embedding API。 -3. 开启“记忆召回注入”。 -4. 开始正常聊天,让系统按设定频率自动提取记忆。 -5. 使用“查看图谱”或“查看注入”观察当前状态。 +如果开启 `enableSmartTrigger`,则会在普通频率判断外再做一次轻量触发评分。评分来源包括: -### 推荐使用方式 +- 命中默认关键词 +- 命中自定义正则 +- 用户与助手多轮往返 +- 感叹号/问号等情绪波动 +- 疑似新实体/新地点 -- 初次使用时保持默认 Schema,不要一开始就大幅改结构。 -- 长剧情场景下保留“全局概要”与“记忆进化”。 -- 图规模较小时可保留 LLM 精确召回,图规模增大后再按成本调整。 -- 若叙事强依赖角色认知差异,可逐步测试“认知边界”。 -- 对 API 成本敏感时,提高“每 N 条回复提取”的数值。 +当评分达到阈值时,即使未到 `extractEvery`,也会直接处理所有待处理 assistant 楼层。 -## 当前状态 +### 2. 提取上下文打包 -### 已实现 +真正送去提取的不是单条消息,而是一段上下文窗口。 -- 扩展基础骨架与设置面板 -- 聊天级图谱持久化 -- 默认 Schema 与关系类型 -- 提取、召回、注入三段式主流程 -- 向量检索接入 -- 图扩散排序 -- 层级压缩 -- 记忆进化 -- 全局概要节点 -- 边的时序字段与失效处理基础逻辑 -- 导入导出入口 +当前实现会: -### 实验性 +- 找到本批 assistant 楼层的起止索引 `startIdx` / `endIdx` +- 从 `startIdx - extractContextTurns * 2` 开始回溯 +- 取到 `endIdx` 为止的非系统消息 +- 用 `#楼层 [role]: content` 的形式拼成对话文本 -- 精确对照对重复 / 更新场景的稳定收益 -- 认知边界过滤在复杂多角色剧情中的效果 -- 交叉检索对召回质量的增益 -- 主动遗忘的阈值与副作用控制 -- 概率触发回忆的叙事收益与噪声控制 -- 情景重构召回在大图、复杂多线叙事中的排序稳定性 +这样做的目的,是让 LLM 在提取时看到足够的上下文因果关系,而不是孤立处理单条回复。 -### 规划中 +### 3. LLM 结构化提取 -- 惊奇度驱动的提取触发机制 -- 反思节点自动生成闭环 -- 更完整的可视化与调试工具 -- 智能触发提取与惊奇度分割的进一步增强 -- 更系统的 benchmark、回归测试与使用文档 +提取调用在 [llm.js](./llm.js) 中统一封装,要求模型返回严格 JSON,核心产物是: -## 路线图 +```json +{ + "thought": "对这一批对话的理解", + "operations": [ + { + "action": "create", + "type": "event", + "fields": { + "summary": "......" + } + } + ] +} +``` -- [ ] 补充端到端测试与典型场景回归样例 -- [x] 完善惊奇度分割与智能触发提取(轻量 MVP) -- [ ] 完成反思条目生成与注入策略 -- [ ] 提供图谱可视化界面 -- [ ] 增强 Schema 配置与编辑体验 -- [ ] 增加多聊天图谱合并能力 -- [ ] 完善导出为外部记忆格式的支持 +当前默认提示词会约束模型: + +- 支持的节点类型必须来自当前 Schema +- 每批对话最多创建 1 个事件节点 +- 角色/地点优先更新已有同名节点,而不是无脑新建 +- 关系类型必须来自允许列表 +- `importance` 落在 1-10 +- `summary` 应该是抽象摘要,而非原文复制 + +### 4. Mem0 风格精确对照 + +如果开启 `enablePreciseConflict` 且配置了 Embedding API,则在正式执行操作前,会先对所有 `create` 操作做近邻对照: + +1. 对新事实文本生成 embedding。 +2. 在已有活跃节点中搜最相似近邻。 +3. 若最高相似度超过阈值,则调用 LLM 判断: + - `add` + - `update` + - `skip` + +这一步的目标是降低重复节点、弱冲突节点和应该被视作“状态更新”的伪新增节点。 + +### 5. 操作执行语义 + +#### `create` + +- 正常创建新节点。 +- 若类型是 `latestOnly` 且存在同名旧节点,则自动转为更新旧节点。 +- 同批次创建的节点支持通过 `ref` / `targetRef` 建立链接。 + +#### `update` + +更新的处理比普通字段覆盖更复杂,当前实现还会补出一层“可追踪更新语义”: + +- 合并字段更新。 +- 刷新 `seq` 与 `seqRange`。 +- 清空 embedding,等待后续重建。 +- 失效掉旧的 `updates` / `temporal_update` 关系边。 +- 若存在 `sourceNodeId`,则补建新的 `temporal_update` 边。 +- 根据字段差异自动生成一条新的 `event` 节点,摘要形如 `field: before -> after`。 +- 再用 `updates` 边把这个事件挂回被更新节点。 + +这让“状态变化”不只是覆盖写入,还能留下被检索的更新痕迹。 + +#### `delete` + +当前是**软删除**,不会真的移除节点,只是把节点标记为 `archived = true`。 + +### 6. Embedding 补齐 + +提取结束后,系统会为所有缺少 embedding 的活跃节点批量生成向量。拼接文本时优先使用: + +- `summary` +- `name` +- `title` +- `traits` +- `state` +- `constraint` + +如果这些字段都没有,就退化为节点类型名。 + +### 7. 提取后增强流程 + +在一次成功提取后,当前实现还可能继续执行以下步骤: + +#### 记忆进化 `enableEvolution` + +新节点写入后,[evolution.js](./evolution.js) 会: + +1. 为新节点找近邻旧节点。 +2. 调用 LLM 判断新信息是否改变了对旧记忆的理解。 +3. 若需要: + - 给新旧节点补链接 + - 回溯更新旧节点的 `state` / `summary` / `core_note` + - 更新旧节点的 `clusters` + - 记录 `_evolutionHistory` + +#### 全局概要 `enableSynopsis` + +每 `synopsisEveryN` 次提取后,会基于: + +- 事件时间线 +- 角色状态 +- 主线状态 + +生成或更新一个 `synopsis` 节点,用于充当前情提要锚点。 + +#### 反思条目 `enableReflection` + +每 `reflectEveryN` 次提取后,会基于: + +- 最近事件 +- 近期角色状态 +- 当前主线 +- 已知矛盾边 + +生成 `reflection` 节点,并用 `evolves` 边连接到最近事件。 + +#### 主动遗忘 `enableSleepCycle` + +[compressor.js](./compressor.js) 中的 `sleepCycle()` 会按保留价值归档低价值节点。当前保留价值大致由以下因素决定: + +- 重要性 +- 新近性 +- 访问频率 + +规则、概要、主线和高重要性节点默认不会被遗忘。 + +#### 层级压缩 + +对支持分层压缩的类型,系统会: + +- 从最低层级开始扫描 +- 只压缩超过阈值且不属于“最近保留叶子”的旧节点 +- 以 `fanIn` 为批次调用 LLM 总结 +- 新建更高层级节点 +- 把被压缩子节点归档,并建立 `parentId` / `childIds` + +当前默认支持分层压缩的类型是: + +- `event` +- `thread` +- `reflection` + +## 读取链路详解 + +读取逻辑主要集中在 [retriever.js](./retriever.js) 和 [injector.js](./injector.js)。 + +### 1. 活跃节点筛选 + +读取开始前会先获取当前图中的活跃节点,并过滤掉: + +- `archived = true` 的节点 +- `seqRange` 不完整的异常节点 + +如果启用了 `enableVisibility`,还会根据节点 `fields.visibility` 进行认知边界过滤: + +- 支持数组形式 +- 支持逗号分隔字符串 +- 支持 `*` 通配 +- 当前视角默认取 `context.name2` + +### 2. 自适应检索策略 + +检索策略会根据活跃节点规模自动调整: + +| 活跃节点数 | 检索策略 | +| --- | --- | +| `< 20` | 不做向量预筛,所有节点参与评分,可选直接走 LLM 精确召回 | +| `20 - 200` | 向量预筛 + 图扩散 + 混合评分,默认不走 LLM 精确召回 | +| `> 200` | 向量预筛 + 图扩散 + 混合评分 + LLM 精确召回 | + +这套阈值定义在 [retriever.js](./retriever.js) 的 `STRATEGY_THRESHOLDS` 中。 + +### 3. 向量预筛 + +如果图规模达到阈值且配置了 Embedding API,会: + +1. 对当前用户输入生成 query embedding。 +2. 对已有节点 embedding 做暴力余弦相似度检索。 +3. 取 Top-K 作为候选。 + +当前实现明确采用暴力搜索,而不是 HNSW/ANN,因为它假设 ST 使用场景通常是中小图规模。 + +### 4. 实体锚点与交叉检索 + +系统会额外从用户输入中做一层简单实体锚定: + +- 如果消息里直接出现了某个节点的 `name` 或 `title` +- 就把它视为一个高能量种子 + +如果开启 `enableCrossRecall`,则会进一步: + +- 沿着这些实体节点的有效边展开 +- 找到相邻的 `event` 节点 +- 把它们也作为附加扩散种子 + +这一步更偏向“场景联想”,而不是单纯语义相似。 + +### 5. 图扩散 + +[diffusion.js](./diffusion.js) 实现了一个轻量版 PEDSA 扩散引擎: + +- 从种子节点出发传播能量 +- 每步乘衰减因子 +- 只保留 Top-K 活跃节点 +- 抑制边会传递负能量 +- 能量值会被钳位到固定区间 + +当前默认配置: + +- 最多 2 步扩散 +- 衰减系数 0.6 +- 每步最多保留 100 个活跃节点 + +### 6. 混合评分 + +混合评分公式定义在 [dynamics.js](./dynamics.js): + +```text +FinalScore = (GraphScore * alpha + VectorScore * beta + ImportanceNorm * gamma) * TimeDecay +``` + +默认权重为: + +- `graphWeight = 0.6` +- `vectorWeight = 0.3` +- `importanceWeight = 0.1` + +时间衰减采用对数衰减,而不是快速指数衰减,目的是让久远但重要的记忆不要掉得太快。 + +### 7. LLM 精确召回 + +在小图或大图场景下,如果开启 `recallEnableLLM`,系统会: + +1. 先把候选节点按混合得分排好。 +2. 取前 30 个以内节点作为候选池。 +3. 把最近对话、用户最新输入、候选节点字段摘要一起喂给 LLM。 +4. 让 LLM 输出最终选中的节点 ID 列表。 + +如果 LLM 召回失败,则回退到纯评分排序结果。 + +### 8. 场景重构 + +在得到初始召回节点后,系统不会立刻结束,而是还会做一次“场景补全”: + +- 若命中的是 `event`,会补入与该事件直接相关的角色、地点、主线、反思节点,以及时间上最邻近的事件。 +- 若命中的是 `character` / `location`,会先找其关联事件,再围绕这些事件继续补场景。 + +这一步的目标,是避免只召回一个孤立节点,尽量把一个能被模型理解的局部情境一起带回来。 + +### 9. 概率触发回忆 + +如果开启 `enableProbRecall`,系统还会从未选中的高重要性节点里抽少量候选,并按概率追加进结果。这更像是“偶发闪回”,用于给长期剧情增加一点远程记忆回流。 + +### 10. 访问强化 + +被最终选中的节点会执行访问强化: + +- `accessCount + 1` +- `importance + 0.1` +- 更新时间 `lastAccessTime` + +这使得经常被召回、反复证明有用的节点,后续更容易继续存活和命中。 + +## 注入策略 + +注入文本由 [injector.js](./injector.js) 生成,格式是 Markdown 表格,主要分为两部分: + +### 1. Core 常驻注入 + +凡是 Schema 中 `alwaysInject = true` 的类型,都会直接进入 Core: + +- `event` +- `rule` +- `thread` +- `synopsis` + +这意味着当前默认设计并不是“所有东西都走检索”,而是: + +- **叙事主干**直接常驻 +- **状态与补充记忆**按需召回 + +这是当前实现最值得注意的一个架构选择。 + +### 2. Recalled 召回注入 + +非 `alwaysInject` 且被选中的节点会进入召回区,并按桶组织: + +- 当前状态记忆 +- 情景事件记忆 +- 反思与长期锚点 +- 规则与约束 +- 其他关联记忆 + +在默认 Schema 下,召回区最常见的其实是: + +- `character` +- `location` +- `reflection` + +因为事件、规则、主线、概要默认都属于 Core。 + +### 3. Token 估算 + +注入完成后,系统会做一个粗略 token 估算,便于观察注入体积。当前估算规则大致是: + +- 2 个中文字符约等于 1 token +- 4 个英文字符约等于 1 token + +## 一个完整运行示例 + +下面用一个简化示例说明从聊天到图谱、再到召回的大致闭环: + +1. 用户说:“我们先去钟楼看看,之前失踪案很可能和那里有关。” +2. 助手回复了一段剧情,描述角色艾琳进入钟楼,发现地下暗门。 +3. 这轮回复结束后,提取器可能产出: + - 一个 `event`:艾琳在钟楼发现地下入口 + - 一个 `location`:钟楼,状态为存在隐藏入口 + - 一个 `thread`:失踪案调查,状态推进 +4. 如果图中本来就有“钟楼”地点节点,则该地点不会重复创建,而会变成更新。 +5. 新节点生成后,系统补 embedding,并可能触发: + - 记忆进化:修正旧事件对钟楼的理解 + - 全局概要:更新前情提要 +6. 下一轮用户问:“地下入口会不会和之前失踪的人有关?” +7. 召回阶段会: + - 命中“地下入口”“失踪”等语义相关节点 + - 把钟楼、相关事件、最近主线等一起拉回 + - 再用注入表格告诉模型当前关键情境 +8. 模型在生成时,就不只是看当前一句话,而是能同时看到: + - 最近核心事件 + - 当前地点/角色状态 + - 当前主线和概要 + +## 功能清单与成熟度 + +### 已实现主链路 + +| 功能 | 当前状态 | 说明 | +| --- | --- | --- | +| 聊天级图谱持久化 | 已实现 | 图谱跟随当前聊天保存与切换 | +| LLM 结构化提取 | 已实现 | 支持 `create/update/delete` | +| 节点 embedding 生成 | 已实现 | 依赖外部 Embedding API | +| 向量预筛 | 已实现 | 余弦相似度暴力检索 | +| 图扩散排序 | 已实现 | PEDSA 风格轻量扩散 | +| 混合评分 | 已实现 | 图分、向量分、重要性、时间衰减 | +| LLM 精确召回 | 已实现 | 小图/大图场景触发 | +| 场景重构 | 已实现 | 围绕事件和实体补上下文 | +| 层级压缩 | 已实现 | 事件/主线/反思支持 | +| 记忆进化 | 已实现 | 基于近邻与 LLM 回溯更新 | +| 全局概要 | 已实现 | 周期生成 `synopsis` | +| 反思条目 | 已实现 | 周期生成 `reflection` | +| 主动遗忘 | 已实现 | 按保留价值归档 | +| 导入/导出 | 已实现 | 导出时去掉 embedding | + +### 实验性能力 + +| 功能 | 当前状态 | 备注 | +| --- | --- | --- | +| 精确对照(Mem0 风格) | 实验性 | 对不同剧情密度的收益仍需更多验证 | +| 认知边界过滤 | 实验性 | 依赖节点 `visibility` 字段质量 | +| 交叉检索 | 实验性 | 更像场景增强,不一定总是增益 | +| 概率触发回忆 | 实验性 | 可能提升“闪回感”,也可能增加噪声 | +| 反思节点召回策略 | 实验性 | 当前以结构就绪为主,策略仍可细化 | + +### 已有实现但未完全打通的预留项 + +下面这些字段或配置已经出现在代码中,但当前还不应在 README 中当作完整能力宣传: + +| 项 | 当前情况 | +| --- | --- | +| `nodeTypeSchema` | 设置层支持,但当前没有现成 UI 做 Schema 编辑 | +| `extractPrompt` | 设置层支持,但当前没有现成 UI 暴露自定义提取提示词 | +| `injectPosition` / `injectRole` | 默认设置存在,但实际注入调用当前只使用 `injectDepth` | +| `evoConsolidateEvery` | 设置项存在,但当前没有真正的“进化后整理”执行逻辑 | +| `forceUpdate` | Schema 元数据存在,但当前运行期没有用它强制产出节点 | + +## 配置说明 + +设置面板定义在 [settings.html](./settings.html),逻辑绑定在 [index.js](./index.js)。 + +### 基础与召回配置 + +| 配置项 | 默认值 | 作用 | +| --- | --- | --- | +| `enabled` | `false` | 总开关 | +| `extractEvery` | `1` | 每 N 条 assistant 回复提取一次 | +| `extractContextTurns` | `2` | 提取时往前带多少轮上下文 | +| `recallEnabled` | `true` | 是否启用生成前记忆注入 | +| `recallTopK` | `15` | 评分后的候选上限 | +| `recallMaxNodes` | `8` | LLM 精确召回最多选多少节点 | +| `recallEnableLLM` | `true` | 是否启用 LLM 精确召回 | +| `injectDepth` | `4` | 注入深度 | + +### 混合评分权重 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `graphWeight` | `0.6` | 图扩散得分权重 | +| `vectorWeight` | `0.3` | 向量相似度权重 | +| `importanceWeight` | `0.1` | 节点重要性权重 | + +### v2 增强功能配置 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `enableEvolution` | `true` | 开启记忆进化 | +| `evoNeighborCount` | `5` | 进化近邻搜索数量 | +| `enablePreciseConflict` | `true` | 开启精确对照 | +| `conflictThreshold` | `0.85` | 触发精确对照的相似度阈值 | +| `enableSynopsis` | `true` | 开启全局概要 | +| `synopsisEveryN` | `5` | 每 N 次提取更新概要 | +| `enableVisibility` | `false` | 开启认知边界过滤 | +| `enableCrossRecall` | `false` | 开启交叉检索 | +| `enableSmartTrigger` | `false` | 开启轻量触发提取 | +| `triggerPatterns` | `""` | 自定义关键词或正则 | +| `smartTriggerThreshold` | `2` | 智能触发阈值 | +| `enableSleepCycle` | `false` | 开启主动遗忘 | +| `forgetThreshold` | `0.5` | 节点保留价值阈值 | +| `sleepEveryN` | `10` | 每 N 次提取执行一次遗忘 | +| `enableProbRecall` | `false` | 开启概率回忆 | +| `probRecallChance` | `0.15` | 概率回忆触发概率 | +| `enableReflection` | `false` | 开启反思条目 | +| `reflectEveryN` | `10` | 每 N 次提取生成反思 | + +### Embedding 配置 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `embeddingApiUrl` | `""` | OpenAI 兼容 API 基地址 | +| `embeddingApiKey` | `""` | API Key | +| `embeddingModel` | `text-embedding-3-small` | embedding 模型名 | + +## 推荐使用方式 + +### 起步建议 + +如果你是第一次用这个扩展,建议先用最保守的组合: + +- 开启 `enabled` +- 保持 `extractEvery = 1` +- 开启 `recallEnabled` +- 开启 `recallEnableLLM` +- 开启 `enableEvolution` +- 开启 `enableSynopsis` +- 暂时关闭 `enableVisibility`、`enableCrossRecall`、`enableProbRecall` +- `enableReflection` 可以先关闭,等剧情稳定后再打开 + +### 成本敏感场景 + +如果更在意 API 成本,可以尝试: + +- `extractEvery = 2` 或 `3` +- 关闭 `recallEnableLLM` +- 提高 `synopsisEveryN` +- 关闭 `enableReflection` +- 仅保留 Embedding 相关能力 + +### 长剧情 / 高连续性场景 + +如果是重剧情、重状态变化的 RP: + +- 保留 `enableEvolution` +- 保留 `enableSynopsis` +- 在确认节点 `visibility` 字段可控后,再测试 `enableVisibility` +- 对多地点、多人物切换频繁的剧情,可逐步开启 `enableCrossRecall` + +## 操作面板 + +当前 UI 已经提供以下手动操作: + +| 按钮 | 作用 | +| --- | --- | +| 查看图谱 | 显示活跃/归档节点数、边数、类型分布、最后处理楼层 | +| 查看注入 | 直接查看最近一次生成前的注入文本 | +| 重建图谱 | 清空当前聊天图谱,下次生成重新抽取 | +| 手动压缩 | 对当前图谱执行压缩 | +| 导出 | 导出图谱 JSON,不包含 embedding | +| 导入 | 导入图谱 JSON,导入后会清空所有 embedding | +| 测试连接 | 测试 Embedding API 是否可用 | + +## 目录与模块职责 + +| 文件 | 作用 | +| --- | --- | +| [manifest.json](./manifest.json) | 扩展清单 | +| [index.js](./index.js) | 扩展入口、事件绑定、设置管理、总流程调度 | +| [settings.html](./settings.html) | 设置面板 UI | +| [style.css](./style.css) | 扩展样式 | +| [graph.js](./graph.js) | 图数据结构、时序边、序列化、导入导出 | +| [schema.js](./schema.js) | 默认 Schema 与关系类型定义 | +| [extractor.js](./extractor.js) | 写入路径、精确对照、概要、反思 | +| [retriever.js](./retriever.js) | 读取路径、图扩散、混合评分、精确召回 | +| [injector.js](./injector.js) | 注入文本组织与格式化 | +| [embedding.js](./embedding.js) | Embedding API 调用、向量相似度检索 | +| [llm.js](./llm.js) | LLM 请求与 JSON 解析封装 | +| [diffusion.js](./diffusion.js) | PEDSA 风格扩散引擎 | +| [dynamics.js](./dynamics.js) | 时间衰减、访问强化、混合评分 | +| [compressor.js](./compressor.js) | 层级压缩与主动遗忘 | +| [evolution.js](./evolution.js) | 记忆进化引擎 | +| [tests/](./tests) | 当前已有的轻量本地测试 | + +## 测试与验证 + +当前仓库内已有的测试比较轻量,主要覆盖部分核心逻辑: + +```bash +node tests/smart-trigger.mjs +node tests/graph-retrieval.mjs +node tests/injector-format.mjs +``` + +它们分别验证: + +- 智能触发评分逻辑 +- 时序边过滤与图扩散基础行为 +- 注入文本格式化流程 + +当前**尚未**覆盖的重点包括: + +- 真实 LLM 提取质量 +- 真实 Embedding API 行为 +- 完整的 ST 生命周期集成 +- 大图规模下的性能与稳定性 +- 导入导出后的重建与回归 + +## 已知限制 + +截至当前代码实现,建议明确接受以下限制: + +1. 这是一个**聊天内图谱**,不是跨聊天统一记忆库。 +2. 导入图谱后,所有节点 embedding 会被清空;当前没有单独的“全量重建 embedding”按钮,向量能力需要后续写入或额外处理来逐步恢复。 +3. LLM 子任务很多,结构化输出质量会直接影响图谱质量。 +4. 当前没有内建图谱可视化界面,调试主要依赖统计信息、日志和注入文本。 +5. 默认 `event` / `rule` / `thread` / `synopsis` 都是 Core 常驻注入,项目当前更偏向“主干常驻 + 状态召回”,而不是纯检索式记忆架构。 +6. 实验性功能已经接入主流程,但仍缺少更系统的 benchmark 和回归验证。 ## 设计来源与参考 -本项目当前实现和设计思路参考了仓库内收录的若干研究型项目与实现,但 ST-BME 自身为面向 SillyTavern 扩展场景的工程整合。 +ST-BME 不是这些项目的直接移植,而是结合 SillyTavern 扩展场景做的工程化整合。当前设计大致受以下项目启发: -| 参考项目 | 主要启发方向 | -| ---------------------------- | ------------------------- | -| [`A-MEM`](A-MEM/README.md) | 记忆进化、近邻回溯更新 | -| [`EM-LLM`](EM-LLM/README.md) | 事件边界 / 惊奇度分割思路 | -| `Graphiti` | 时序关系与图结构管理 | -| `Mem0` | 新旧记忆对照与更新决策 | -| `RoleRAG` | 认知边界过滤 | -| `AriGraph` | 沿图边扩展的交叉检索 | -| `MemoRAG` | 全局概要作为长期锚点 | -| `SleepGate` | 主动遗忘与保留价值评估 | -| `Reflexion` | 反思条目方向 | +| 参考项目 | 启发点 | +| --- | --- | +| `A-MEM` | 记忆进化、基于近邻的回溯修正 | +| `EM-LLM` | 惊奇度触发、段落边界与提取时机 | +| `Graphiti` | 时序边、关系有效性和图建模思路 | +| `Mem0` | 新旧记忆对照、增量更新决策 | +| `RoleRAG` | 认知边界过滤 | +| `AriGraph` | 沿图边展开的交叉检索 | +| `MemoRAG` | 全局概要作为长期锚点 | +| `SleepGate` | 主动遗忘与保留价值评估 | +| `Reflexion` | 反思条目方向 | +| `PeroCore` | 图扩散、记忆动力学、向量检索策略 | + +## 当前版本 + +- 扩展版本:`0.1.0` +- 清单文件:[`manifest.json`](./manifest.json) ## 许可证 -本项目采用 AFPL License 发布,详见 [`LICENSE`](ST-BME/LICENSE)。 +本项目采用 AFPL License,详见 [LICENSE](./LICENSE)。 diff --git a/injector.js b/injector.js index 129c011..129b2d8 100644 --- a/injector.js +++ b/injector.js @@ -60,11 +60,11 @@ export function formatInjection(retrievalResult, schema) { ), }; - appendBucket(parts, "当前状态记忆", buckets.state, schema); - appendBucket(parts, "情景事件记忆", buckets.episodic, schema); - appendBucket(parts, "反思与长期锚点", buckets.reflective, schema); - appendBucket(parts, "规则与约束", buckets.rule, schema); - appendBucket(parts, "其他关联记忆", buckets.other, schema); + appendBucket(parts, "当前状态记忆", buckets.state, schema, appended); + appendBucket(parts, "情景事件记忆", buckets.episodic, schema, appended); + appendBucket(parts, "反思与长期锚点", buckets.reflective, schema, appended); + appendBucket(parts, "规则与约束", buckets.rule, schema, appended); + appendBucket(parts, "其他关联记忆", buckets.other, schema, appended); } return parts.join("\n"); @@ -82,7 +82,7 @@ function groupByType(nodes) { return map; } -function appendBucket(parts, title, nodes, schema) { +function appendBucket(parts, title, nodes, schema, appended) { if (!nodes || nodes.length === 0) return; parts.push(`## ${title}`); diff --git a/tests/injector-format.mjs b/tests/injector-format.mjs new file mode 100644 index 0000000..2aa21af --- /dev/null +++ b/tests/injector-format.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { formatInjection } from "../injector.js"; +import { DEFAULT_NODE_SCHEMA } from "../schema.js"; + +const coreEvent = { + id: "event-1", + type: "event", + fields: { + summary: "艾琳在钟楼发现了地下入口", + participants: "艾琳", + status: "resolved", + }, +}; + +const recalledCharacter = { + id: "char-1", + type: "character", + fields: { + name: "艾琳", + state: "警觉并准备进入地下室", + goal: "调查钟楼秘密", + }, +}; + +const recalledReflection = { + id: "reflection-1", + type: "reflection", + fields: { + insight: "地下入口意味着先前的失踪案与钟楼存在长期关联", + trigger: "钟楼发现暗门", + suggestion: "后续优先追查地下通道与失踪人口名单", + }, +}; + +const text = formatInjection( + { + coreNodes: [coreEvent], + recallNodes: [recalledCharacter, recalledReflection], + groupedRecallNodes: { + state: [recalledCharacter], + episodic: [], + reflective: [recalledReflection], + rule: [], + other: [], + }, + }, + DEFAULT_NODE_SCHEMA, +); + +assert.match(text, /\[Memory - Core\]/); +assert.match(text, /event_table:/); +assert.match(text, /\[Memory - Recalled\]/); +assert.match(text, /## 当前状态记忆/); +assert.match(text, /## 反思与长期锚点/); +assert.match(text, /character_table:/); +assert.match(text, /reflection_table:/); + +console.log("injector-format tests passed");