上一篇《给 dsh 装上项目级长期记忆:dsh-project-memory 设计实录》主要讲了 dsh-project-memory 的设计出发点与整体架构,基于 v0.3.2 版本。
本文基于 v0.4.0,经过三个小版本的迭代,dsh-project-memory 已发展为一个完整的项目级记忆插件,对项目开发的帮助更显著。
本文主要写一些遇到的缺陷,设计的方案,实际开发的取舍及方案落地的效果,按时间顺序来写。
导航
- [v0.3.3 --- 符号精简(-30%)、盲区标记、内容哈希](#v0.3.3 — 符号精简(-30%)、盲区标记、内容哈希)
- [v0.3.4 --- 检索 20x(187ms → 9ms)](#v0.3.4 — 检索 20x(187ms → 9ms))
- [v0.4.0 --- TaskBridge 跨会话任务续接](#v0.4.0 — TaskBridge 跨会话任务续接)
v0.3.3:文档层的记忆信息不够,如何去防止记忆对模型的误导
v0.3.2 的存储主要分三块:基于文档的摘要、基于代码文件的索引、基于会话的经验笔记。前两者存在问题如下:
摘要型记忆的天然缺陷
一个 README 文档,比如 20KB,插件调用 LLM 生成摘要时,输出可能是这样的:
dsh-project-memory 是为 DeepSeek Harness 提供持久化项目记忆的插件。它通过"读到即索引"机制,在模型读取文件时自动记录文档摘要与代码符号,支持跨会话检索与任务续接。核心功能包括文档/符号/经验/任务四层记忆,存储基于本地文件,无需外部数据库。
降级时则是文档的前若干字。
可以看到,摘要非常简洁和概括,对文档的内容其实是没有完全覆盖的,本质上是在做一个有损压缩。README 里的具体特性、架构设计、安装步骤、配置说明等内容,全部被压缩成几句话,甚至完全没提到。
当然,模型可能不会依赖摘要,而是直接去读 README 原文。但这是建立在模型已有"README 是项目介绍文档,通常存在"的隐性知识上的。换成一般的文档,摘要的片面性就不能忽视了,由此产生的对模型的误导也不能忽视。这也是记忆类插件常有的,或者说摘要型记忆的天然缺陷。
代码符号中的 summary 与 sig 字段
对代码文件建立的索引,主要是在做了源码扫描器、类上下文,多行签名等处理后,用正则去在干净的代码文件中提取信息。v0.3.3 引入了 summary(一句简短的描述)和 sig(结构化函数签名),v0.3.2 只有 title、text(截断)、keywords。
text:截断的代码片段summary:一句简短的描述sig:结构化的函数签名
但 summary 的问题有两层:
- 摘要本身的片面性 :和文档摘要的问题一样,
summary是模型对代码的"解读",不保证准确,信息不全也是误导。 - 信息重复 :
summary确实有用,但是一是不会因为模型看了summary,代码文件的内容就不用进入上下文,二是模型上下文中有该文件信息后,summary本质上就重复了。
记忆插件在上下文积累的过程中,慢慢变成了拖累。
至于 sig 结构化字段,在跨语言、极端语法下不可靠。
方案一:符号层重构:只存事实信息,不存知识,减少模型重复的思考
针对代码符号层的问题,确定了几个方向的方案:
| 方案 | 存储体积 | 解析速度 | 跨语言一致性 | 可维护性 | 评价 |
|---|---|---|---|---|---|
| 保留 summary + sig 结构化 | 大 | 慢 | 差(每语言特化) | 差 | 不是记忆 |
| 只存函数名 + 文件路径 | 最小 | 最快 | 好 | 好 | 信息太少,记忆作用小 |
| 完整声明行 + 位置 | 中(-30%) | 快 | 好(统一格式) | 好 | 信息适中 |
| tree-sitter AST 全量解析 | 大(+300%) | 启动 +500ms(估计) | 差(每语言 WASM) | 差 | 依赖重,体积大 |
| LSP 按需调用 | 中 | 中(需启动进程) | 差(每语言独立) | 差 | 配置复杂,体验割裂 |
| 向量检索 + RAG 混合召回 | 大(+300%) | 慢 | 好 | 差 | 成本高,边际收益低 |
| 全量函数摘要 + 语义缓存 | 大 | 慢(LLM 调用) | 好 | 差 | 成本爆炸,性能差 |
方案说明
只存函数名 + 文件路径:最轻的方案。模型拿到一个函数名,不知道它的参数类型和返回类型,无法判断这个函数是否和自己当前的任务相关,必须额外读文件才能做决策。信息太少,省掉的成本转移到模型。
完整声明行 + 位置 :最终方案。存函数声明行原文(function createUser(dto: CreateUserDTO): Promise<User>),不加任何解释。完整签名直接可用,附带行号避免模型滚动读取,统一格式跨语言一致,随代码变更自然更新,不需要额外维护。模型拿到后能直接决定"读不读这个文件"。信息适中,可决策,不腐烂。
tree-sitter AST 全量解析:为 7 种语言分别引入 tree-sitter 语法解析器,构建完整 AST。每个语言需维护独立的 grammar 文件,WASM 包体积 ~1--2 MB/语言,冷启动从 80ms 增加到 500ms+。跨语言解析需额外维护调用图,复杂度极高。20k 条目的全量索引时间会变成数秒,而且增量更新时仍然要解析整个文件的 AST。这会影响 watch 轮询和 lazy indexing 的响应速度。模型不用去理解完整 AST,在项目开发中它需要的没有那么多,也不用重复告诉它已知的琐碎知识。之前正则已经覆盖 90%+ 场景,且可选的 TS Compiler API 增强以一种针对性的、可配置的、轻量的方式减少了它的价值。
LSP 按需调用 :启动语言服务器(typescript-language-server、pyright 等)通过 stdio 通信获取类型信息。每个语言需启动独立进程,内存占用大,需处理语言服务器版本兼容性,用户需自行安装语言服务器。配置复杂,不适合作为默认路径。
向量检索 + RAG 混合召回 :对摘要和符号做 embedding(如 bge-small),查询时用向量相似度 + BM25 融合排序。需要嵌入模型(本地推理或调用 API),向量库需持久化,存储体积从 6.7 MB 增加到 50 MB+。与"查询零 LLM、零额外延迟"原则冲突,实测 BM25 + CJK 增强已到 90%+ 召回率,边际收益极低。成本高,收益低。至于近似词查询方面,模型不是用户,在 v0.4.0 实现工作流记录,任务和文件交叉链接后,收益更低。
全量函数摘要 + 语义缓存:索引时对每个函数调用 LLM 生成摘要,存入记忆,查询时直接返回摘要。索引期 LLM 调用次数 = 符号数(5k 文件 ≈ 30k 符号),成本爆炸。摘要会随代码变更而过期,需维护一致性,成本爆炸,不可持续。同时并没有解决之前的问题。
最终决策
| 维度 | 重方案 | 最终选型 |
|---|---|---|
| 符号提取精度 | 高(AST/LSP/向量) | 中(正则 + 可选语言增强(TS)) |
| 存储体积 | 大(+300%~1000%) | 适中(-30%) |
| 启动开销 | 大(+500ms~数秒) | 小(+0ms) |
| 维护成本 | 高(每语言/每版本) | 低(统一正则) |
| 依赖 | 多(tree-sitter/LSP/embedding) | 单(pdfjs-dist) |
| 跨语言一致性 | 差(每语言需适配) | 好(统一格式) |
记忆只要能让模型判断"这个文件我是否需要读"就达到效果了,不需要提前把所有信息都分析好存起来。理解是模型的事情,索引只负责提供足够模型决策的事实。同时要考虑建立、查询的性能和成本。
重构后的单条符号:
json
{
"title": "createUser (function)",
"text": "function createUser(a: CreateUserInput, b: Options): Promise<User> --- user.ts:42",
"type": "symbol",
"sourcePath": "src/user.ts",
"sourceLine": 42
}
v0.3.4:检索 20x 提升(187ms → 9.3ms)
痛点 :query_memory 在 5k 文件/20k 条目场景中位数 187 ms。每次查询都要:
- 遍历所有 entry 做
tokenizeRaw()→ 建 TF 表 - 现场拼接
title×5 + keywords + summary + path并toLowerCase() - 重新计算全库 IDF
三层优化叠加:
js
// 1. IDF 缓存:store._version + _idfCache
// 写入时 version++ 标记失效,查询时版本命中直接复用
if (store._version === queryVersion && store._idfCache) {
return store._idfCache // 零计算
}
// 2. 预计算 searchText:setEntries 时一次性算好
entry.searchText = (entry.title || '').repeat(5) + ' ' +
(entry.keywords || []).join(' ') + ' ' +
(entry.summary || '') + ' ' + entry.path
// 查询时直接用,省掉拼接 + toLowerCase()
// 3. 流式打分:rankEntriesStreaming 单次遍历
// countOccurrences() 字符串计数替代完整 tokenizeRaw + TF 表
// 零中间对象分配,GC 压力归零
实测数据(Node 24, Linux, SSD):
| 场景 | v0.3.3 | v0.3.4 | 提升 |
|---|---|---|---|
| 5k 文件 / 20k 条目(首次查询,含 IDF 建缓存) | ~187 ms | ~150 ms | - |
| 5k 文件 / 20k 条目(缓存命中) | ~187 ms | 9.3 ms | 20x |
| 1k 文件典型项目(缓存命中) | --- | <1 ms | --- |
| 冷启动加载 | 82 ms | 82 ms | 持平 |
为什么不做增量 IDF? 全量重算 20k 条目只需 ~140 ms,且只发生在写入后的第一次查询。增量维护要处理词项删除、文档数变化、倒排表更新,代码量 10x、极易引入 Bug。版本号 + 全量重算的「办法」相对最稳。
v0.4.0:TaskBridge 跨会话任务续接
场景痛点:
-
语义断层:会话压缩后,模型通过插件记忆重新获得了项目的事实信息(文件结构、符号位置、文档摘要),但查询时使用的关键词来自新会话的模糊描述,与旧会话中的精确术语存在偏差,导致召回结果偏移甚至错位------记忆层存的是事实,但模型问的是描述。
-
开发状态不可恢复:新会话恢复后,模型可以读文件、看符号、查摘要,但它不知道"上次做到哪了""哪些文件已经改过了""当前任务涉及哪些模块"。这些信息不是文件里写的,是开发过程中积累的理解,插件没有记录它。
-
记忆膨胀稀释任务边界 :随着项目索引范围扩大,
query_memory返回的条目越来越多。模型拿到 100 个相关文件,却不知道当前任务只需要其中 5 个。记忆的覆盖面变大了,但对"当前任务"的精确性反而下降了。
这三个问题指向同一个缺口:插件存了项目的事实,但没有存工作的上下文。 TaskBridge 的目标就是补上这个缺口------把开发过程中的任务状态、文件范围、进度信息沉淀为可跨会话恢复的记忆。
设计方案
方案 A:宿主主动订阅 session/event
宿主监听 session/event 事件,自动从 todo_write 和 tool/call 中提取任务清单和文件关联。模型无需额外操作,零负担。续接时 list_tasks → select_task 即恢复任务进度与文件范围。缺点是依赖宿主支持 session/event 事件(dsh 0.1.2-alpha.x 实测支持),旧宿主自动降级为手动模式。
方案 B:模型显式 remember
模型每次更新任务清单时主动调用 remember(kind: 'tasklist') 存进度。好处是不需要宿主改动,在旧版本 dsh 上也能工作。坏处是模型在新会话中不一定记得调用 remember,且任务与文件的关联需要模型主动在调用时传参,模型可能漏传或传错,导致续接时文件范围缺失。依赖模型行为,可靠性不足,且无法关联工具调用产生的文件变更。
手动 remember
用户或模型在任务完成时手动调用 remember(problem, solution) 记录经验。与 TaskBridge 不冲突,作为任务完成后的归档补充。无法解决"会话中断后续接"的问题,因为用户/模型在开发过程中不一定记得主动记录。
正则提取会话记忆
记录聊天历史,全自动但无法区分任务与闲聊。续接体验差------历史消息不等于任务进度。同时提取对话效果非常依赖关键词。
最终决策
| 方案 | 记录内容 | 自动化程度 | 续接体验 | 成本 | 评价 |
|---|---|---|---|---|---|
| 会话记忆类插件 | 聊天历史 | 全自动 | 差(聊天≠任务) | 低 | 方向不对 |
手动 remember |
人工摘要 | 手动 | 中 | 高(易忘) | 用户成本高 |
模型显式 remember |
任务清单文本 | 半自动(模型调用) | 中(无文件关联) | 低 | 模型可能遗忘调用,无文件链接 |
宿主主动订阅 session/event |
任务清单 + 读写文件 | 全自动 | 优(list→select 即接回) | 零 | 选这个 |
宿主主动订阅方案不需要模型做任何额外操作,任务和文件的关联由宿主事件自动建立,续接时精确恢复任务上下文,最终被选中。模型显式 remember 的不可靠性是被否决的核心原因。
核心机制:事件订阅 + 自动沉淀
事件源(session/event,签名 (session, event))
| 事件类型 | 触发条件 | 处理逻辑 |
|---|---|---|
todo/write |
模型调用 todo_write |
绑定任务 steps 快照整体覆盖(全量替换);未绑定会话自动新建任务并绑定 |
tool/call |
模型调用 read/write/edit/read_image |
绑定任务 files 并集(归一化相对路径、项目外拒绝、上限 100) |
关键设计:
todo_write无 id、全量替换 → steps 只能整体覆盖,不做步骤级映射tool/call的arguments是 JSON 串 → 解析出path,归一化为相对路径- 子代理会话无法可靠判定 → 接受其自动建档(低频)
任务实体结构
json
{
"id": "task_abc123",
"title": "重构认证模块",
"steps": ["拆分 AuthService", "迁移 JWT 逻辑", "补测试"],
"files": ["src/auth/service.ts", "src/auth/jwt.ts", "test/auth.test.ts"],
"createdAt": 1725200000000,
"updatedAt": 1725200000000,
"lastActiveAt": 1725200000000,
"archived": false,
"sessionId": "sess_xyz"
}
标题由模型定
dsh中模型创建任务清单时无任务名,自动扫描提取的话导致任务名奇怪(参考会话名)
select_task(title=任务名):先命名再写 todo------模型自己决定任务叫什么- 自动回退:取用户消息最后「:」后的任务段(截断 48 字)
- 续接可
select_task(taskId, title)改名
用户交互:三个工具 + 一个命令
| 工具/命令 | 作用 | 关键行为 |
|---|---|---|
list_tasks |
列出项目任务记录(归档标记) | 新会话首调,建立上下文 |
select_task |
绑定会话到任务 | taskId 精确、自动解归档;title 完全匹配/多候选/无则新建;带 title 可改名 |
archive_task |
归档任务(隐藏、不计容量、停止同步) | select_task 可恢复 |
/tasks(用户输入) |
显示任务栈:标题、步骤进度、涉及文件、当前会话绑定 | Handler 不经模型,直接渲染 |
/tasks 输出示例:
任务列表 (3 个,1 个进行中)
┌─ task_abc123 [当前会话] 重构认证模块 (2/3 步骤)
│ 📁 src/auth/service.ts src/auth/jwt.ts test/auth.test.ts
├─ task_def456 [归档] 修复登录闪退 (3/3 步骤)
│ 📁 src/auth/login.ts
└─ task_ghi789 新增 OAuth2 支持 (0/2 步骤)
📁 (暂无文件)
检索集成:任务也变成可搜索的记忆
js
// query_memory 新增 type
type: 'task' // 检索 title/steps/files
type: 'all' // 结果尾部附一行: 另有 3 个任务记录,可用 list_tasks 查看
设计意图 :模型搜认证模块时,除了文档、符号、经验,还能召回相关任务------任务关联文件,文档、代码、笔记交叉链接。
存储:独立于 format v2 的双文件
.dsh-project-memory/
├── tasks.json # 任务实体数组
└── binding.json # { sessionId: taskId } 会话绑定
- 独立于 format v2 布局:load 兜底空值,不影响主存储升级
- 容量自适应 :
fileCount/20clamp 5, 100,超限按lastActiveAt归档最旧 - 脏标记驱动:未变更不落盘
环境要求与降级策略
| 环境 | 行为 |
|---|---|
dsh 0.1.2-alpha.x (含 session/event + todo_write) |
全功能自动同步 |
旧宿主 (无 session/event 或不触发) |
降级 :任务工具仍可作纯记录使用(手动 list_tasks/select_task/archive_task、/tasks 命令仍工作) |
不强制升级宿主------插件在缺事件时自动降级为手动任务管理器,不阻塞用户。
结语
v0.3.x 系列把项目记忆这个基座做完了:快、准、轻、可验证。v0.4.0 在基座上加了工作流记忆这一层:把 Agent 的日常动作(写 todo、读文件)自动沉淀为可续接的任务实体。
如果说 v0.3.x 回答的是"模型知道项目里有什么",那 v0.4.0 回答的就是"模型知道自己正在做什么"。
如有错误的地方,欢迎指出。
仓库 :https://github.com/00080000/dsh-project-memory
npm :https://www.npmjs.com/package/@yolk_vat-y/dsh-project-memory
awesome 列表 :https://awesome-dsh-plugin.com/zh/p/00080000/dsh-project-memory/