dsh-tingxue · 听雪
DeepSeek Harness 的双模式虚拟生命插件:一个有人格、有长期记忆的 QQ 聊天对象,外加一个用完即焚的隔离文件处理会话。
听雪是一个跑在 DeepSeek Harness 上的自建插件。它的记忆不依赖任何托管服务——LanceDB 本地文件即库,关系图谱自己实现,语义推理走可插拔的小 LLM。
目录
特性
| 双模式 | 聊天模式(日常对话,人格 + 长期记忆,上下文结构对缓存友好)与 agent 模式(隔离的纯文件处理会话,用完即焚,归档进记忆) |
| 自建记忆库 | LanceDB 嵌入式向量库,本地文件即库、零服务器;4 张表(记忆条目 / 实体 / 关系 / 最新信息) |
| 自建关系图谱 | 实体抽取 → 实体链接 → 三元组关系(带时间窗口与溯源)→ 图遍历增强检索;参考 Graphiti / Mem0 实践 |
| 可交互图谱 UI | 零依赖 canvas 力导向蜘蛛网:节点聚类、邻居高亮、标签碰撞避免、小地图导航,离线可用 |
| 可插拔模型层 | embedding 与语义推理抽象成统一接口,换模型只改配置,插件逻辑零改动 |
| 图形化配置 | 29 个配置项做成图形界面,设置侧边栏独立成页 + 插件配置标签页两个入口,含覆盖标记与单点重置 |
| 安装安全流程 | scripts/install-check.mjs:一条命令把「装完静默失效」变成可机检断言,--fix 自动修复且失败自动回滚;活 profile 只读、凭据只比对不回显 |
| 推送隔离 | 通过 dsh-notifier 的 route:agents 精确分流,其他 DSH 会话的通知与审批不会打扰 QQ |
| 模式自愈 | 启动自检 + 运行期 reconcile:mode 与 QQ 真实绑定不一致时自动退回聊天模式,不会静默丢掉人格与记忆 |
前置要求
| 依赖 | 版本 | 说明 |
|---|---|---|
| Node.js | >= 22.19 |
见 package.json 的 engines |
| DeepSeek Harness | 0.1.1-rc.2 或更高 |
插件运行宿主 |
| @xmanrui/dsh-im | >= 5.0.3 |
QQ 通道(收发消息全交给它,本插件只做人格装配与记忆)。见 package.json 的 peerDependencies |
| pnpm | 任意近期版本 | 安装 @lancedb/lancedb 时需要解析原生依赖 |
| 一个 LLM / embedding 端点 | — | 默认走 sta1n 供应商,也可切换本地 OpenAI 兼容端点(modelBackend: local / custom)。没有端点和 key 时插件能装但记忆不工作 |
| QQ 机器人凭据 | — | 在 QQ 开放平台建一个机器人,拿 appId + appSecret 填进 @xmanrui/dsh-im 的配置。没有它:插件装好也不会有任何消息进来 |
| 一个已绑定的会话 | — | dsh-im 侧要有 bind:qq:<你的 userId> → 某个会话的绑定。没有它:消息进来了但插件认不出该注入谁的人格(会一直走「非 IM 会话」分支) |
外部依赖仅两个运行时包:@lancedb/lancedb 与 apache-arrow(版本锁 18.1.0)。
[!IMPORTANT] 上面带 QQ 字样的两条是「能不能真的在 QQ 里说话」的硬前置。只装插件不配它们,现象是 零报错、零日志、零反应 —— 这类沉默最难排查,所以单独列出来。 只想先在本地验证人格与记忆,可以跳过它们,改用 GUI 里的「听雪」设置页看装配结果。
安装
[!IMPORTANT] 只需要一个 peer 依赖:
@xmanrui/dsh-im >= 5.0.3(QQ 收发通道)。 本插件不再依赖dsh-notifier,也不需要打任何补丁 —— 早期版本要求装dsh-notifier@0.9.0再手工打patches/dsh-notifier.patch,那条路已经废弃(补丁文件从未随仓库发布,照旧文档走会卡住)。 如果你的 DSH 里仍装着dsh-notifier,绑定键会按$DSH_HOME/dsh-notifier/state.json自动兼容读取,不用管它。
# 1) 先确认你的 profile 目录(不同 DSH 版本默认名不同:web / desktop …)
# —— 有 package.json 的那个就是:
ls ~/.dsh/profiles/*/package.json
# 2) 在 profile 目录里装本插件(把 <本仓库路径> 换成你 clone 的位置)
cd ~/.dsh/profiles/<你的 profile>
pnpm add file:<本仓库路径>
pnpm add @xmanrui/dsh-im # peer 依赖:QQ 通道
# 3) 把 dsh-tingxue 加进 profile 的 package.json(漏了会得到
# 「已安装,未生效:未声明 dsh.bundle,已作为普通依赖安装」)
# "dsh": { "profile": { "bundles": [ ..., "dsh-tingxue" ] } }
# 4) 重启 DSH 让插件挂载
[!IMPORTANT] DSH 插件的源码是进程启动时加载、不热重载的。改完源码必须两步走:① 同步到 profile 的
node_modules副本 → ② 重启 DSH。cordis.patch.yml的配置改动会热重载,但.mjs/client.js源码不会。 浏览器端同理:__DSH_BOOT__是加载 HTML 那一刻注入的,重启 DSH 后还需要硬刷新页面(Ctrl + Shift + R)。
4) 装完自检(一条命令,别靠肉眼)
装完最容易出的不是报错,而是静默失效:插件装上了、进程也起来了,功能却不在。上面那几步手工动作,漏任何一条都是这个结果。所以装完最后跑一次:
cd ~/.dsh/profiles/web
node <插件仓库>/scripts/install-check.mjs # 有问题 exit 1,全绿 exit 0
node <插件仓库>/scripts/install-check.mjs --json # 机器可读
它逐条机检下面这些「漏了就静默失效」的点,并给出可执行的修复指引:
| 检查 | 漏了会怎样 |
|---|---|
C1 插件声明了 dsh.bundle,且有 files 白名单 |
profile 会把它当普通依赖装 |
C2 插件在 dsh.profile.bundles 名单里 |
正是那句「已安装,未生效:未声明 dsh.bundle」 |
| C3 / C4 | 已退役:本插件不再依赖 dsh-notifier,也不需要任何补丁(QQ 通道由 @xmanrui/dsh-im 提供)。自检只在 profile 里还留着旧接线时给一条迁移提示 |
C5 运行副本 node_modules/dsh-tingxue 与仓库的 src/ 补丁层逐字节一致(link 安装下不适用,会明确标「无需比对」而不是给假绿灯) |
profile 用 file: + nodeLinker: hoisted 时,运行副本是真实目录拷贝不是 junction → 改仓库不生效(本项目最隐蔽的失效模式) |
| C6 仓库里没有活配置的凭据明文(扫全部跟踪文件),工作区干净 | 凭据进仓库 |
| C7 活 profile 里的明文凭据被识别出来 | 真实风险面其实在仓库外(见 SECURITY.md) |
C8 npm pack --dry-run 清单无泄漏 |
.gitignore 拦不住 npm 打包,files 白名单才是权威 |
C9 profile 的 cordis.patch.yml 结构上真的能解析 |
一条缩进坏掉的补丁层会让整个 profile 起不来(DSH 启动即失败),而 git status 与肉眼都看不出来 |
C9 是被真实事故加的。 手改
cordis.patch.yml时删多/删少一行,留下的悬挂键会让 YAML 解析直接失败,两个解析器都会拒绝;而当时的工具链里没有任何东西能发现它——一重启 DSH 就起不来。C9 只做结构校验(可解析、是补丁项数组、无重复映射键),不检查语义。
⚠️
scripts/不随 npm 包发布(files白名单只含scripts/selfcheck.mjs)。所以install-check.mjs只对从仓库克隆的使用者可用;用 npm 安装的人没有这个文件。要跑自检请克隆仓库。
C5 对 manifest 只比对运行时字段(version/main/exports/dsh/dependencies/peerDependencies/engines/type)——仓库 URL 这类元数据差异不点红,免得把「永远有条红的忽略掉」训出来。
C3 与 C5 按「现在是不是真的坏了」分级,不是一刀切报红——一条永远挂着的红等于没红:
| 情形 | 判定 |
|---|---|
C3:^0.9.0,但 pnpm-lock.yaml 锁住了 0.9.0 |
warn(地雷,不是当下的火:今天不会炸,换机器 / 删 lock / pnpm update 才踩) |
C3:^0.9.0,且没有 lock |
fail(下次安装就会拉到新版) |
| C3:已安装的版本不是 0.9.0 | fail(补丁的行号上下文必然错位,当下的火) |
| C5:运行副本 == 仓库工作树 | ok |
| C5:差异全是未提交的在制品(运行副本 == HEAD) | warn(已提交的那份没坏;但在制品要生效必须先同步 + 重启) |
| C5:存在已提交却没同步的文件 | fail(跑的就是旧代码) |
自动回滚:加 --fix 才会写盘,且动手前逐文件备份原始字节与 SHA256;只要有一条修完复检仍不过,就整批回滚并给出还原前后的哈希对比。
# 先复制一份 profile 再让它修(推荐;活 profile 默认拒写)
node <插件仓库>/scripts/install-check.mjs --fix --profile-dir /path/to/profile-copy
两条护栏:
- 活 profile 默认只读。
--fix对~/.dsh/profiles/**下的文件一律拒写(报告里记为 skipped),要真写必须显式加--allow-live-profile。校验模式(不加--fix)永远不写任何文件。 - 凭据只比对、不回显。脚本会读出活配置里的凭据字面量用于比对,但绝不把值写进输出——只报处数与文件名。校验完
git status --porcelain仍应为空。
它不会替你做的两件事(会改变活环境,交回你的手):跑 pnpm install、同步运行副本并重启 DSH。这两条它只会报红并说清该做什么。
快速开始
装好并重启后,在 QQ 里直接说话即可进入聊天模式。
(直接发消息) → 聊天模式:有人格、有记忆、四块上下文组装
(发文件、多轮讨论) → 文件作为上下文持续处理
/agentstop → 归档对话到记忆库、删除工作副本、销毁隔离会话、自动回绑聊天会话
两条指令之外没有任何手动步骤——不用 /bind,也不用 /unbind。
发文件:QQ 附件经 [文件] 标记由 pre-step 下载到 dataDir/workcopy/ 并注入上下文。文本文件直接进上下文(单文件上限 5 MB、正文截取 8000 字符);二进制文件只给文件名与大小元信息。聊天模式与 agent 模式都支持。
配置
配置方式
有两种,优先级从高到低:
- 图形界面(推荐)——装完本包,侧栏直接出现三个入口(面板随主包一起发布,不需要另装任何 UI 包):
- 聊天面板 —— 陪伴 bot 自己的东西:记忆档位、人格、上下文
- agent面板 —— 干活 bot 自己的东西:记忆档位、收工归档、工作区
- 记忆服务面板 —— 两个 bot 共用的东西:模型与 API Key(可从端点拉模型列表直接选,不用手打名字)、 记忆服务状态与记忆库统计、QQ 机器人配置(昵称 / 工作区 / App ID / App Secret)
- 为什么把共用配置单独拎出来:模型和 API Key 改了是两边一起变的。原来在两个 bot 页各放一份, 用户会以为能分别设,改完发现另一边也变了。分开之后语义清楚:bot 页 = 这个 bot 的,记忆服务面板 = 大家的。
- 设置 → 插件 → 插件配置 里的卡片仍然是次入口,与上面同一份 settings
- 配置文件——profile 的
cordis.patch.yml,作为 settings 的 base 层保留
界面里标「已覆盖」的字段表示它覆盖了部署配置,可单点重置回落到部署层。写入用 revision 设栅,表单漂移会被拒绝而不是覆盖并发修改。
面板的字段直接读写记忆服务与 dsh-im 的配置,不再单独维护一份 settings 字段表(旧的 src/settings + client/client.js 已删除)。
配置项
| 配置项 | 默认 | 说明 |
|---|---|---|
| 人格与记忆 | ||
profilePath |
'' |
听雪档案 txt 路径(人格提示词,由用户自写) |
dataDir |
cwd/.dsh-tingxue |
记忆库数据目录(LanceDB 本地文件即库,state.json 也在这里) |
recentRounds |
10 |
最近 N 轮滑动窗口(仅在打开「重复注入最近对话」时生效) |
injectRecentRounds |
false |
重复注入最近对话。默认关:DSH 会话历史本身已含最近对话,再注入一遍等于同一段话付两次 token(实测约 1.5K/轮) |
memoryBudgetTokens |
1600 |
向量记忆检索块 token 上限 |
latestInfoBudgetTokens |
1000 |
最新信息块 token 上限 |
| 模型 | ||
modelBackend |
sta1n |
模型后端:sta1n / local / custom |
embeddingModel |
gemini-embedding-2 |
向量模型(同库单一模型铁律)。设置页可点「选择模型」从端点列表里挑 |
embeddingDimensions |
3072 |
向量维度,须与模型实际输出一致 |
llmModel |
gemini-3.1-flash-lite |
语义推理小 LLM(实体抽取 / 摘要)。设置页可点「选择模型」从端点列表里挑 |
baseURL |
'' |
local / custom 时的 OpenAI 兼容 base URL |
apiKey |
'' |
留空则回落到 DSH 凭据服务里的 STA1N_API_KEY |
| 双模式命令 | ||
agentStopKeyword |
/agentstop |
退出 agent 模式的关键词 |
fileDeleteScope |
workcopy |
文件删除范围:workcopy 只删工作副本 / keep 一律保留 |
| 绑定与推送 | ||
channel |
qq |
绑定通道(dsh-notifier 的 channel) |
userId |
'' |
绑定用户(dsh-notifier 的 userId) |
chatSessionId |
'' |
自愈回绑用的可信聊天会话 id |
notifierStateFile |
'' |
留空用默认的 dsh-notifier state.json |
routeWorkspace |
dsh |
被静音的默认 workspace |
quietOtherWorkspace |
true |
只让听雪自己的消息送达 QQ |
qqStatusNotice |
true |
QQ 是否显示任务状态提示(🚀 任务开始 / ✅ 任务完成 / ⏹ 任务已中止 / ⏱ 心跳 / ⚠️ 疑似卡住)。关掉后这些状态行不发,回复正文照常送达(回复本体走 turn/end 通知的正文,见 交接文档.md §16.2);❌ 出错通知保留 |
approvalAllowlistOnly |
true |
审批是否只推 QQ 对话会话。打开后只有被显式放行出站的会话(听雪聊天会话 / tingxue-agent-* 隔离会话)的批准询问发到 QQ,其他 DSH 会话的审批只在桌面弹(需配套的 dsh-notifier 补丁,见 交接文档.md §16.3) |
| 面板与服务 | ||
memoryServiceEnable |
true |
是否启用记忆服务(管家) |
memoryServiceHost |
127.0.0.1 |
记忆服务监听地址 |
memoryServicePort |
8766 |
记忆服务端口 |
还有若干只在配置文件中生效的字段(routeWorkspaceChannels、provider、model、cwd 等),不暴露在界面上。
生效时机
- 标了
applies: 'restart'的命名空间:dataDir、模型、端口这类启动期读取的字段,改动需重启 DSH。 recentRounds、injectRecentRounds、记忆 / 最新信息预算这类按次读取的字段,保存后立即生效。qqStatusNotice/approvalAllowlistOnly保存后立即生效:它们被写成prefs:tingxue状态键,由通道层在每次推送 / 审批时实时读取(500ms 读收敛)。
[!NOTE] 本文档里凡是提到
dsh-notifier的地方(配置项channel/userId/notifierStateFile、状态键prefs:tingxue、绑定读取路径等), 都是早期通道的历史命名:通道已换成@xmanrui/dsh-im,这些字段名与状态键为了兼容旧数据保留不变,照填即可。
memoryServiceEnable等开关由插件启动期读取,需重启 DSH。
架构
项目结构
dsh-tingxue/
├── src/ # Host 半侧
│ ├── im-bot/index.mjs # 插件入口:人格注入(systemPrompt.section),记忆走 HTTP
│ ├── panel-host.mjs # 面板宿主:/dsh-tingxue-ui/* 路由(反代 / 模型列表 / QQ 配置)
│ ├── memory-service/ # 记忆服务本体(DSH 唯一写者,HTTP API)
│ ├── memory/store.mjs # LanceDB 记忆存储(4 张表)
│ ├── graph/index.mjs # 关系图谱(抽取 / 链接 / 关系 / 图遍历,无 UI)
│ ├── models/index.mjs # 模型适配层(embedding + 小 LLM + 模型列表)
│ └── http/index.mjs # 共享 HTTP 工具(端口探测 / JSON 响应)
├── client/ # 浏览器半侧
│ ├── panel.js # 三个面板:聊天 / agent / 记忆服务(手写 lazy-CJS)
│ ├── icon.svg # 插件图标(六角雪花)
│ └── locale/ # 面板文案
├── service/ # 独立记忆服务(nssm 或 ps1 启动,DSH 关了也能跑)
│ ├── memory-service.mjs # 服务入口
│ ├── config.example.json # 配置模板(空串 = 用推导出的默认值)
│ └── install-nssm.ps1 # 装成 Windows 服务
├── test/ # 单元测试
├── scripts/ # run-tests / selfcheck / install-check
├── examples/ # 人格档模板
└── cordis.patch.yml # bundle 补丁(往 roster 里插一行)
### 记忆库表结构(LanceDB)
| 表 | 用途 | 关键字段 |
|---|---|---|
| `memories` | 记忆条目(向量检索主表) | `text` `vector` `scene` `identity` `source` `createdAt` `entityIds` |
| `entities` | 实体节点 | `name` `type` `summary` `vector` |
| `relations` | 关系边(三元组) | `sourceId` `targetId` `relation` `validFrom` `validTo` |
| `latest` | 最新信息(文件摘要 / 待办) | `kind` `text` `createdAt` |
### 聊天模式上下文组装
顺序固定,前缀稳定以命中缓存:
```text
[1] 听雪档案 —— 从 profilePath 读取,固定不变(稳定缓存前缀)
[2] 向量记忆检索 —— 按当前输入语义检索,有界(默认 1600 token),命中才插入
[3] 最近 N 轮对话 —— 默认关闭(见下方说明)
[4] 最新信息 —— 最近文件摘要 / 待办,有界(默认 1000 token)
关于 [3]:DSH 会话本身就是「全部交互历史的仅追加真源,LLM 消息历史由它派生」(packages/core/session/README.md)。最近对话本来就在会话历史里,插件再塞进 system 一遍 = 同一段话在模型眼里出现两次,实测每轮白付约 1.5K token。因此 [3] 由 injectRecentRounds 守卫,默认不注入;只有换绑到新会话、会话历史不可用时才需要打开。
关于 [4]:组装逻辑(src/context/assemble.mjs)具备该块,但 pushLatest() 目前在插件里没有调用点,实测 71 条请求命中 0 次——属死代码,不是故障。要用需先接上写入侧。
精确一点:
store.addLatest/store.listLatest都是已实现且已导出的(src/memory/store.mjs),但全仓没有任何调用者调用addLatest(插件写的是state.latestInfo,实测为[])。所以真正的情况是「读取侧接线、写入侧缺失」:store 里那张latest表永远是空的,[4]因此恒不出现。处理建议:要么删掉[4](连latestInfoBudgetTokens),要么给addLatest接上写入者——后者会增加常驻上下文,与「上下文别涨」的目标相反,故推荐前者,除非确实要用「文件摘要/待办」。
把 recentRounds 调小为什么不会让上下文变小(实测归因)
用户体感「滑动窗口设成 10,DSH 统计的上下文长度却还在涨」。实测归因(真机聊天会话 136 轮,逐帧解压 session.jsonl.zstd 后按 request/header 统计):
| 组成部分 | 实测字符数 | 说明 |
|---|---|---|
[1] 听雪档案 |
7,022(恒定) | 就是 profilePath 那个文件,改档案=永久变大(该文件现为 10,415 字符,改动即 +3.4K) |
[2] 向量记忆 |
12,088 – 14,981(逐轮不同) | 取决于本轮检索命中几条;预算 1600 token 截断 |
[3] 最近对话 |
0 | 被 injectRecentRounds 守卫,实测未开启 |
[4] 最新信息 |
0 | 无写入者(见上) |
| 注入小计 | 约 20.3K(占 91%) | 真正由插件贡献的部分 |
| 非注入部分(DSH 本体提示词 + 67 个工具 schema) | 1,850(恒定) | 实测各轮完全相同 |
结论(三条判据):
- 不是 DSH 历史累积导致的增长 —— 非注入部分恒为 1,850 字符,在 turn 126→136 之间完全没有变化。会话历史确实在累积(它是仅追加真源),但它在本机不进入 system 提示词,所以「DSH 统计的上下文长度」的跳动不是它造成的。
- 不是压缩没生效 —— 压缩在正常工作:实测
compaction/start×24、compaction/end×24、compaction/prune×2、compaction/summary×1(summary 15,284 字符,带shadowedRange/shadowedTokenCount)。压缩是有损的,不会把上下文压回一个固定值。 - 跳动来自插件自己的注入 —— 逐轮 total 在 20,808–23,853 字符之间摆动 ±2,000,来源只有两个:
[2]记忆块命中多少条、以及[1]档案文件被改写。
所以 recentRounds 这个旋钮的语义要读对:它只控制「重复注入最近 N 轮对话」,而 [3] 默认关闭,所以调它既不会让上下文变大、也不会变小。要缩小上下文,该动的是:档案文件长度([1])、memoryBudgetTokens([2] 上限)、以及关掉 [4]/不要接线。
可复核的命令:
node scripts/selfcheck.mjs # 逐轮 system 长度 / 同一个回合内是否变动(唯一判据)
node scripts/selfcheck.mjs --json --full
(另有 --started=<ISO 时刻> 可覆盖「进程启动时刻」,只在诊断/测试判据②时用,见下文「运行状态自检」。)
另有一个独立(非增长类)缺陷已由 selfcheck 报出:同一个回合内 system 长度会变动(34 轮里 15 轮,最大摆动 2,992 字符,turn 136 出现
【相关记忆】整块消失的一次)。这是「记忆块在轮内不稳定」,属于注入抖动,不是本节的增长归因;排查入口是turnInput缓存键与agent/inbox/inserted的时序。
注入走异步瀑布,不走 section.text(重要)
DSH 的 SystemPrompt.section() 要求 text 是同步函数返回 string。但 assemble() 的内部顺序是:
packages/core/system-prompt/src/index.ts:510 同步求值每个 section.text ← 缓存在这里被读走
packages/core/system-prompt/src/index.ts:532 await system-prompt/assemble 瀑布
packages/core/agent-loop/src/agent.ts:230 preStep 先 await assemble(),再发 agent/pre-step
同步求值结构上必然早于任何异步检索。所以「异步预算写入缓存 + text() 同步读缓存」的做法不是偶发竞态,而是首轮必空、之后恒错位一轮——实测表现就是 system 提示词在 6827(DSH 本体裸大小,一块都没注入)与 13872 之间抖动,人格块命中率只有 42%。
正确做法是 system-prompt/assemble 瀑布(src/context/inject.mjs):它能 await,且返回值是权威装配。section 只注册一个空占位块保住名字与顺序,真值在瀑布里就地组装后就位替换。DSH 自己的 installModelSelection(packages/core/agent/src/model-selection.ts:40)也是这么用的。
实现要点:
| 点 | 原因 |
|---|---|
| 就位替换,不 push 到末尾 | 块声明 order: 100,push 会掉到 100–199 的工具指引之后,破坏块顺序 |
校验 context.agent === agent |
瀑布是 scope 继承的,不校验会收到别的会话的装配 → 跨会话串线 |
| 按用户输入串缓存 | assemble() 每个 step 都跑一次;多步回合(工具调用)不缓存就每步都 embed + 向量检索 |
未 ready / agent 模式返回 null 且不落缓存键 |
否则 mode 切回聊天后会一直读到空串 |
installContextInjection 幂等 |
有 agent/created 与启动补注入两个入口,重复 section() 会因重名抛错 |
| 预热 + 瀑布共用同一次检索 | embed + 检索实测约 790ms。在写 lastUserText 的监听器里不 await 地预热,瀑布 await 同一个 in-flight promise——既把延迟藏起来,又不重复检索 |
| 缓存带代号,只有最新那次能落缓存 | 用户连发两条时,先发起的慢检索晚回来会用旧结果覆盖新结果 |
refreshDisposer 不碰缓存 |
它注册得比写 lastUserText 的监听器早;在那里 invalidate 会把刚预热好的结果丢掉,白白多检索一次 |
检索输入与缓存键用 turnInput,不用 lastUserText |
assistant/message 每个 step 都触发,step0 结束会把 lastUserText 清空(配对写滑动窗口 + 记忆)。若检索用它,step1 起 currentInput 变空 → 记忆块在同一轮里整块消失。这两个变量不能合并:lastUserText 是「待配对输入」(配对后清空),turnInput 是「本轮输入」(整轮不变) |
回归测试见 test/inject.test.mjs:它用假 systemPrompt 精确复刻「先同步读 text → 再 await 瀑布」的顺序,因此能真正抓住这个 bug(在旧设计下会失败)。另有并发与多 step 回归:预热与瀑布共用同一次检索、慢检索晚回来不覆盖新结果、同一轮 step1 起记忆块不消失(同轮 system 必须完全一致)。
关系图谱
- 实体抽取:小 LLM 从文本抽取实体(
person/place/thing/concept) - 实体链接:实体名归一化 + 向量相似,把新实体关联到已有实体
- 关系建立:抽取实体间三元组关系,带时间窗口与溯源
- 图遍历:从种子实体沿关系扩展,用于增强记忆检索(
assembleContext里从命中记忆的entityIds扩展 depth=1、limit=5) - 交互 UI:
graph-dashboard自绘 canvas 力导向蜘蛛网,零外部依赖、离线可用
图谱面板的工程细节:
- 性能:拖动节点 / 空白平移时暂停每帧 O(n²) 全对斥力计算(
animOn=false仅静态重绘),松手后短暂恢复模拟让邻域收敛;鼠标与触屏双 handler 均已处理。 - 视觉:① 按类型聚类(
clusterCenters)② hover 邻居高亮 + 非邻居淡出(focusSet)③ 标签碰撞避免(labelRects包围盒跳过重叠)④ 右下角小地图(标准缩略图模式,点击跳转视口)⑤ 背景网格固定视口,不随缩放平移移动。 - 两个坑:HTML 模板字符串里的内联
onclick绝不能用\'转义单引号(要用 HTML 实体');canvas 顶部有 56px 标题栏,所有事件坐标必须经canvasOffset()+evtPos()转成画布相对坐标。
模型适配层(可插拔)
src/models/index.mjs 把两种能力抽象成统一接口:
embed(texts) // 文本 → 向量
complete(prompt) // 小 LLM 语义推理
| 后端 | 说明 |
|---|---|
sta1n(默认) |
走 sta1n 供应商的 OpenAI 兼容端点,零部署 |
local |
走本地 OpenAI 兼容端点(ollama / llama.cpp) |
custom |
完全自定义 |
接口有三个方法:embed(texts) 文本 → 向量、complete(prompt) 小 LLM 语义推理、listModels() 列出端点当前提供的模型(供设置页的选择器用,见下节)。
换模型只需改配置,插件逻辑零改动。 注意记忆库的单一向量模型铁律:同库内必须模型与维度一致,换 embedding 模型需要全量重嵌入。
设置页的模型选择器
「向量模型」和「语义推理小 LLM」两个字段旁边有一个选择模型按钮:点开询问当前端点提供哪些模型,从真实列表里挑一个填进去(带搜索框,sta1n 的列表有 102 项,靠眼睛翻不现实),不用手敲模型 id。输入框照旧可以直接手填。
数据通路:
| 环节 | 位置 | 说明 |
|---|---|---|
| 客户端弹窗 | client/client.js 的 ModelPicker |
结构照 DSH「设置 → 模型」的 ModelListEditor:询问端点 → 从候选里挑 |
| HTTP 路由 | src/model-catalog/index.mjs |
POST /dsh-tingxue/models |
| 适配层 | src/models/index.mjs 的 listModels() |
打 GET <base>/v1/models |
两个实现选择值得记下来:
- 走宿主自己的
webServer(与 GUI 同一个端口),不另开监听端口。因此不存在跨源问题,也不需要额外的鉴权层——和 GUI 同源。DSH 把llm.discoverModels钉在 loopback 上,这里用同样的判据(同源 + 回环兜底),因为它同样会拿着一把可能明文的 key 去问端点。 - 不用 DSH 的
llm.discoverModelsseam:那个由llm-pi-ai适配器回答,只认它命名空间里声明过的 provider profile;听雪的模型后端是插件自己的配置(modelBackend/baseURL/apiKey),问它只会得到「未知 provider」。这里用听雪自己的适配层问同一条协议,端点、协议、key 的解析方式与真正发请求时完全一致——选择器看到的就是实际会用的那份列表。
请求体是「表单当前显示的值」而非已保存的配置:刚填进去、还没保存的端点或 key 也能立刻试。服务端只读,不写任何配置,apiKey 只用于这一次询问、不存储也不回显。
宿主没有 webServer 服务时路由静默跳过,选择器退化成手填模型 id,不影响其他功能。
API Key 的解析走 DSH credentials 服务,每次 resolve 时重新 ctx.get('credentials'),不能在 init 时同步捕获闭包——init 早于 credentials 挂载时闭包会固定为 undefined,之后每次取 key 都 401。模型适配层与面板的模型列表路由共用同一份 key 解析逻辑,保证两条路径取到同一把 key。
换 key 只改一处:$DSH_HOME/.credentials.yaml 里的 STA1N_API_KEY(sta1n 后端的 apiKeyEnv)。插件、运行时副本、AstrBot 插件都不需要改——插件经凭据服务取 key,AstrBot 经 HTTP 服务间接使用。改完重启 DSH 生效。
settings.yaml 里 llm-pi-ai.providers.sta1n.models 是模型下拉列表,必须与实际线上列表一致,否则选到已下线的模型会直接 400/404。核对方式:
# 拉线上列表(需要有效 key),与 settings.yaml 的 - id: 条目对比
node -e "fetch('https://cdn.sta1n.cn/v1/models',{headers:{Authorization:'Bearer '+process.env.K}}).then(r=>r.json()).then(j=>console.log(j.data.map(m=>m.id).sort().join('\n')))"
记忆服务(HTTP API)
src/memory-service/index.mjs 是记忆的唯一写者,默认监听 http://127.0.0.1:8766(仅 loopback)。它把所有写操作经内部先进先出队列串行化,保证 LanceDB 永远只有一个写者,规避多进程并发写冲突。
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/memory |
写入一条记忆(服务端负责 embedding)。快速返回:只同步落库正文与向量,实体抽取挪到响应之后异步做 |
GET |
/memory/search |
按语义检索记忆(q / limit / scene) |
GET |
/memory/entities |
列出实体(上限 1500) |
GET |
/memory/relations |
列出关系(上限 6000) |
GET |
/memory/expand |
从种子实体沿关系扩展(entityId / depth) |
GET |
/profile |
返回人格文本(供多端共用同一份人格) |
GET |
/health |
健康检查 |
为什么 /memory 是快速返回:实体抽取是一次慢的小 LLM 调用。早期实现把它放在响应路径里,AstrBot 客户端 15 s 超时先到,日志只留一句空异常的 asyncio.TimeoutError,服务端其实已经写成功 → 客户端重试就写出重复记忆。现在响应体带 async: true,抽取在 writeChain 之后跑,失败只记日志。
AstrBot 群聊对接
目标是让 DSH(私人) 与 AstrBot(群聊) 互相独立、共享同一份记忆与人格:
DSH(唯一写者)──写──► 记忆库(LanceDB) ◄──HTTP── AstrBot
│ ▲
└── 暴露 HTTP 记忆服务 ───┘
- DSH 是唯一写者:AstrBot 只通过 HTTP 记忆服务读写,不直接碰 LanceDB 文件。
- embedding 一致性:embedding 统一由 DSH 生成,AstrBot 只传文本。
- 人格通用:两边各自加载同一份人格 txt,DSH 通过
GET /profile提供。 - 记忆储存策略(仿人脑):不按群组织,每个人物是独立实体节点,人物之间有关系边,记忆挂到相关人物上。
对接插件在 astrbot-plugin/(当前 v1.3.0),提供群聊 + 私聊自动记忆与 /回忆 /图谱 /记忆状态 命令,并严格隔离私聊。它通过 @filter.on_llm_request() 在 LLM 请求发出前检索记忆与人格,追加进 system_prompt(位于滚动窗口历史之前,滚动窗口与压缩都动不到)。
记忆注入与人格注入是两个独立开关(inject_memory / inject_profile),各自失败都不影响另一路。
[!NOTE] AstrBot 侧 LLM 建议使用 DeepSeek 官方端点:sta1n 对 openai SDK 的
x-stainless-*请求头一律返回 403。
开发
环境准备
git clone <repo-url>
cd dsh-tingxue
pnpm install
运行测试
# 方式一:一次性跑全部
pnpm test
# 方式二:逐个运行(推荐在受限沙箱下使用)
# node --test 在部分沙箱环境会因 spawn EPERM 失败,直接运行测试文件即可
node "test/store.test.mjs"
node "test/context.test.mjs"
node "test/graph.test.mjs"
node "test/bind.test.mjs"
node "test/dashboard.test.mjs"
node "test/memory-service.test.mjs" # 记忆服务 HTTP API(真实 LanceDB 临时库)
node "test/manage-api.test.mjs" # 实体/关系/记忆/回收站/审计/配置
node "test/store.test.mjs" # LanceDB 存储层
node "test/graph.test.mjs" # 关系抽取 / 图遍历
node "test/selfcheck.test.mjs" # 自检脚本自身(证明它会红,不是橡皮图章)
node "test/bigint.test.mjs" # LanceDB Int64 是 BigInt(含真机契约)
node "test/install-check.test.mjs" # 安装安全流程:坏环境真判红 + 回滚真还原
### 运行安装自检
装完/改完 profile 后跑,判断「装上了但没生效」:
> **这个脚本不随 npm 包发布**(`files` 白名单只含 `scripts/selfcheck.mjs`),只有克隆仓库才有。用 npm 安装的话请克隆仓库后再跑。
```sh
node scripts/install-check.mjs # 校验活 profile(只读,不写任何文件)
node scripts/install-check.mjs --json # 机器可读
node scripts/install-check.mjs --self-test # 自带回归(零依赖,临时目录内自建夹具)
node scripts/install-check.mjs --self-test --live # 真机项也计入退出码(默认仅提示)
node scripts/install-check.mjs --profile-dir <副本> --fix # 修复 + 失败自动回滚
--self-test 与标准套件是同一份实现:核心 31 条通过脚本导出的 registerCoreCases 注册,test/install-check.test.mjs 调用的也是它 —— 所以 node --test test/*.test.mjs 覆盖的就是这 31 条,不存在「回归只活在自检里、门禁看不见」的盲区。2 条真机项读活 profile,默认只提示不计退出码(保证在无 profile 的环境也能跑绿),要当硬门禁就加 --live。
检查项与被覆盖的失效形态见 安装 §4;脚本自身的回归测试见 test/install-check.test.mjs。
运行状态自检
一条命令看清「重启后有没有退回旧毛病」——修完代码最容易踩的坑就是忘了重启,跑的还是旧代码:
node scripts/selfcheck.mjs # 人类可读;有问题 exit 1,全绿 exit 0
node scripts/selfcheck.mjs --json # 机器可读
node scripts/selfcheck.mjs --full # 解全部日志帧(默认只解尾部 2000 帧,快)
node scripts/selfcheck.mjs --started=2026-09-26T18:13:29+08:00 # 覆盖「DSH 进程启动时刻」
--started 只在诊断/测试时用:默认从监听 3080 的进程反查启动时刻,一般不用传。它的用途是把「运行代码是否最新」这条判据摆到可控时间点上——例如副本刚同步完、进程还没重启时,用它复现「需要重启」的结论,或反向证明某次同步确实已被进程加载。
输出示例:
✓ 运行副本与仓库源码一致(内容哈希)
18 个文件哈希全等;改仓库仍需「同步副本 + 重启 DSH」才生效
✓ 运行代码是最新的
DSH 启动 2026/9/26 18:13:29;副本最新写入 2026/9/26 17:02:11 早于进程启动;且副本内容 == 仓库工作树(不看仓库侧 mtime,故同内容重写不误报)
副本写入晚于进程启动时,上面第二条会变成 fail(此时内容可能仍是一致的——进程加载的是写入前那份):
✓ 运行副本与仓库源码一致(内容哈希)
✗ 需要重启 DSH —— 副本在进程启动后被写入
副本最新写入的插件文件晚于 DSH 进程启动时间 —— 说明插件是后来才改的,进程里跑的还是旧代码。
Node 在 import 期就加载完了模块,所以该进程装的仍是写入前的那份内容;副本内容虽与仓库一致,也必须重启 DSH 才真正生效。
「运行代码是否最新」由两条并存的判据共同回答,两者互不覆盖:①内容哈希——副本内容是否 == 仓库工作树(仓库侧 mtime 不参与,故同内容重写不误报);②时点——副本里运行时文件(src/、client/、cordis.patch.yml)的最新写入时刻是否晚于进程启动。只查①会漏掉「副本已同步、进程却在同步之前就起来了」,这正是最容易被误判成「已生效」的形态。
零成本:只读本地文件 + 打 /health、/profile 这类纯本地端点,绝不调用 /memory/search 之类会触发 embedding 的接口(那是要花钱的)。只读:不写记忆库、不改任何配置。
检查项见 test/selfcheck.test.mjs。核心判据是「同一轮内 system 长度必须唯一」——新回合首条 header 天然带 change 是正常行为,不是漂移;真正的 bug 特征是长度在一轮内出现多个值(记忆块中途消失)。
AstrBot 侧离线自检(不联网、不写记忆库):
cd astrbot-plugin
python test_private_handler.py
开发注意事项
- 不热重载——源码改动必须同步到 profile 的
node_modules副本并重启 DSH,浏览器端还要硬刷新页面。同步后请核对 SHA256。 - settings 命名空间注册要内联
ctx.get('settings')+settings.register(),不要导入installSettingsSection/settingsNamespace这两个具名导出:上游删除过它们,而缺失的具名导出是模块求值期的SyntaxError,会让宿主启动失败。没有 settings 服务的旧宿主下应优雅降级。 - 两半侧字段表必须同步改——
src/settings/index.mjs的SETTINGS_FIELDS与client/client.js的FIELDS,漏一边会出现「Host 认这个键、界面画不出来」或反之。 - 异步上下文不要靠
section.text同步缓存——assemble()同步求值text之后才跑瀑布,所以同步缓存首轮必空、之后恒错位一轮。要用system-prompt/assemble瀑布(见 上下文组装 与src/context/inject.mjs)。 - 注册作用域决定可见范围——要只对聊天会话生效,就必须注册在 agent 作用域,不能注册在插件 root ctx。
- 跨插件不要做值导入——client bundle 的纯净度门禁拒绝跨插件的值导入,协作走 cordis 服务。
- 装完/改完 profile 先跑
scripts/install-check.mjs——「装上了但没生效」不会有报错,只会静默。改动src/后尤其要跑:C5 会逐字节比对运行副本与仓库源码,专门抓「改完忘了同步 / 忘了重启」。
路线图
- 聊天模式 + 长期记忆
- agent 模式隔离会话(全自动双会话)
- 关系图谱 + 可交互蜘蛛网 UI
- 记忆服务(HTTP)与 AstrBot 群聊对接
- 图形化配置(设置侧边栏独立入口)
- 模式自愈(启动自检 + 运行期 reconcile)
- 记忆写入去重(
/memory快速返回 + 异步实体抽取) - 注入时序收敛(改走
system-prompt/assemble异步瀑布取真值;section.text同步求值结构上不可能赶上检索) - 运行状态自检(
scripts/selfcheck.mjs,九项,零成本只读) - 安装安全流程(
scripts/install-check.mjs,八项机检 +--fix自动修复与失败自动回滚) - 图谱面板「鹰眼模式」小地图(内容随主视图缩放,作为标准缩略图模式的可选增强)
- 记忆库主干架构的多端一致性加固
文档索引
| 文档 | 内容 |
|---|---|
README.md |
本文件:安装、配置、架构、开发、排障 |
CONTRIBUTING.md |
贡献指南:四条铁律、上下文注入约束、测试约定 |
CHANGELOG.md |
版本变更记录 |
SECURITY.md |
安全策略与敏感面说明 |
astrbot-plugin/README.md |
AstrBot 插件说明 |
astrbot-plugin/SYNC.md |
AstrBot 插件同步铁律 |
发布形态与安装流程.md |
发布形态设计:发布出去长什么样、用户下载什么、按什么顺序装(含 dsh-notifier 补丁现状与上游演进) |
内部文档不随仓库发布。 设计文档(
需求规格.md、交接文档.md、记忆库主干架构*.md、astrbot接入方案.md、调研报告-*.md、发布注意事项.md)已由.gitignore挡在仓库外——它们含本机路径、会话 ID 等隐私信息。克隆本仓库看不到这些文件是正常的。
发布形态与安装流程.md不在这个清单里:它经过穷举扫描(密钥字面量 / QQ id / session id / 凭据路径 / 本机绝对路径 / 内部术语 全部 0 命中),不含隐私,是给使用者看的公开文档,因此纳入版本控制。
贡献
这是一个个人自用项目,没有正式的贡献流程。如果你基于它做二次开发:
- 先确认改动落在正确的半侧——Host(
src/)还是浏览器(client/)。 - 新功能请补对应测试,并保持
node test/*.test.mjs全绿。 - 提交前检查四条铁律没有被破坏:会话隔离、注册作用域、单一向量模型、源码必须同步到 node_modules 并重启。
No comments yet. Be the first to write one.