结论:只能做子集。 逐层结论 - L1 (context 热上下文) ≈20%:dsh 的上下文压缩是「摘要替换区间」 (dsh-compaction-basic 的 compactSurfaceRegion),不是「按 query 相关性 淘汰 + 迁出到冷层 + 按原时间戳迁回」。CompactionTrigger 接口拿不到 query;MessageBase 无 timestamp 字段,surface 是 append-only + replace, 无法语义插入。 - L2 (coldcontext 冷文档) ≈80%:存储/召回/注入可落,缺 「Peek → 回灌 → Remove」的回收站语义。 - L3 (stablemem 图记忆) ≈85%:memory_blocks/memory_block_edges 结构 完全可映射,node:sqlite 实测可用(含 FTS5);受限于无 embedding 服务。 关键发现 - dsh 无任何 embedding/vector service,需自建(推荐复用 HA 的 http provider HTTP 协议转发,2 人天)。 - systemPrompt.context() 的 AssembleContext 拿不到本步消息正文, 「按本次输入召回后注入」必须走 agent/pre-step waterfall (参考 dsh-time-context 的用法)。 - dsh attachment 是 CAS,与 HA「媒体字节内联在块里、不做引用账本」 的设计冲突 → 媒体块必须裁掉。 工作量(含判据与实测) - L2+L3 子集:36-53 人天 - MVP(L3 + 工具 + 注入面):19-25 人天 - 完全等价(含迁出-迁回):不可行,不报价 报告含 7 章 + 两份附录(HA/dsh 双侧证据索引、环境实测结果)。 原文全程只读,未修改 HologramAgent 仓库。
66 KiB
可行性评估:把 HologramAgent 三层记忆系统移植为 dsh 插件
状态:调研评估,不含实现 评估对象:HologramAgent 内核
internal/memory/+internal/agent/core/的三层记忆 目标宿主:dsh(deepseek-harness)0.2.1-rc.2,cordis 依赖注入内核,插件形态为导出apply(ctx, config)取证方式:只读源码 +lib/*.d.ts权威类型。本文每条断言都标注了文件路径与符号名, 无标注的推断一律显式写「推断」。
0. 结论先行
| 结论四选一 | 只能做子集 |
|---|---|
| 一句话理由 | L2 与 L3 能落到 dsh 的真实扩展点上(systemPrompt.context 注入 + ctx.tools.register 工具面 + 自建 Service 做检索);L1 的「按 query 相关性淘汰页、把整帧迁出、再按原始时间戳迁回」在 dsh 里没有对应钩子——dsh 的上下文压缩是「摘要替换区间」(dsh-compaction-basic 的 compactSurfaceRegion),不是「迁移低相关帧到冷层再按需迁回」,语义相反且接口签名里拿不到 query。 |
| 分层可移植性 | L3 ≈ 85%(结构完全可映射,受限于向量依赖) L2 ≈ 80%(存储与召回可映射,缺「回收站式 Consume + 迁回原时序」) L1 ≈ 20%(只有「进 prompt 的注入点」可挂,淘汰/迁移机制挂不上) |
| 可整体搬运的资产 | Block 数据结构本体(memory.MemoryBlock)+ 确定性 ID 派生 + 仲裁/拒答/符号检索算法。这三块与宿主无关,是移植成本最低、收益最高的部分。 |
| 硬阻塞(不可绕过) | ① dsh 无 embedding 服务;② dsh 消息是无时间戳的、只能追加的 surface,无法「按原始时间戳把事件插回时序」;③ dsh 无「迁出-迁回」的双层持有模型 |
| 工作量粗估 | L2+L3 子集(含判据)36–53 人天;压到 MVP(L3 图记忆 + 工具 + 注入面,不含 L2/仲裁/拒答)19–25 人天;含 L1 降级版(form:'recall' 追加,不做淘汰)再加 8–12 人天。「完全等价带迁出-迁回」不可行,不建议报价。详见 §6.2。 |
工作量口径:§6.2 的分阶段表是含判据与实测的报价口径(请以此为准); 早前草稿里的 28–40 人天是不含判据、不含工具
output声明的乐观值,已废弃。
1. L1 / L2 / L3 的核心数据结构与数据流,最小可移植单元
1.1 三层的统一载体:MemoryBlock
权威定义:/home/program/TrueAgent/internal/memory/block.go:66 type MemoryBlock struct
type MemoryBlock struct {
ID string // 内容派生(确定性)
Modality BlockModality // text | image | video | audio
Text string // 文本块负载(union 的一半)
Data []byte // 媒体块负载(union 的另一半)
MIME string
Size int
Width, Height int
Vector []float64
Fingerprint string // 向量空间指纹
Source, Tool, Scene string
SemanticType string
// ── 段页式身份(2026-10-29)──
FrameTS time.Time // 帧号 = 时间戳,不另造自增号
PageSeq int // 帧内页序号(1..n)
PageKind PageKind // llm_round | tool_round
BlockRole BlockRole // request | response
CallID string // tool_round 的配对键
Inactive bool // 本次渲染不参与(标注,非删除)
LastRecalledAt time.Time
CreatedAt, UpdatedAt time.Time
}
关键设计约束(原文注释,block.go:55-56 与 62-65):
- 块不携带 Layer / Owner / RefCount:「块当前由哪个层的容器持有,哪个层就是唯一事实源。 Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。」
- 负载是 union:文本块负载在
Text,媒体块在Data。「不存在『块 + 外挂字节层』。」 FrameTS是帧号,不跨帧(block.go:100-125):同一FrameTS的块整批待在同一层, 因为「帧是最小可渲染单元,拆开就会出现『有 call 没 result』的破帧」。
最小可移植单元 = MemoryBlock 这个纯数据结构本身(一个 JSON 可序列化的值对象)。
它没有任何 Go 接口依赖,可以直接翻译成 TypeScript interface。这是整个移植里
唯一「零成本」的部分。
1.2 L1 = Context 热上下文
位置:/home/program/TrueAgent/internal/agent/core/context.go
| 符号 | 行 | 职责 |
|---|---|---|
type ContextEvent struct |
64 | 一条事件:Timestamp/Source/Input/Response/ToolsUsed/ToolResults/ToolCalls/Blocks/NoMemory/InactivePages |
type RelevanceContext struct |
145 | 持有 events []*ContextEvent |
(*RelevanceContext).Append |
967 | 唯一入口:scrubEventSecrets → computeVector → 追加 → claimPendingToolResults → save() |
(*RelevanceContext).Prune / PruneWithProtected |
1059 / 1075 | 按预算裁剪,把低分事件归档到 L2 |
(*RelevanceContext).InsertByTimestamp |
998 | 召回时按原始时间戳插回 |
type ColdRecallPlan struct |
453 | planColdRecall 的产物:超预算时按页相似度标 inactive |
数据流(Prune 的真实实现,context.go:1075-1210(函数体至 :1207)):
func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int,
coldcontextStore *coldcontext.Store, pCount int) int {
c.resetInactivePagesLocked() // 剔除标记是渲染期状态,每次重置
if len(c.events) <= topK { return 0 }
protected := c.events[len(c.events)-pCount:] // 最近 N 条无条件保护
candidates := c.events[:len(c.events)-pCount]
// ① 用当前 query 给每条候选事件打相关性分
for i, evt := range candidates {
if useDense && evt.DenseFP == queryFP { // 只比同指纹向量
score = vector.DenseCosine(queryDense, evt.DenseVec)
} else {
score = vector.CosineSimilarity(queryVec, evt.Vector) // 退化稀疏
}
}
// ② 保留 topK,其余 archive
keep := scoredEvents[:keepCount]
archive := scoredEvents[keepCount:]
// ③ archive 的**整条事件(连同它持有的块)**迁往 L2
doc, _ := coldcontextStore.ContextToColdContext("context_archived", entries, ...)
// ④ 迁移语义:宿主侧清空块,确保同一块不同时留在两层
for _, s := range archive { s.event.Blocks = nil }
}
L1 的三个不可替代特征:
- 事件有
Timestamp,且InsertByTimestamp允许乱序插入到历史位置——这是「召回 = 把事件迁回原时序」的前提。 - 按「页」淘汰(
InactivePages []int,json:"-",「不持久化、不迁移」), 而不是按「消息」淘汰——因为assistant(tool_calls)后必须紧跟配对的tool消息。 - 淘汰是「迁移」不是「摘要」:
Prune把整条事件原样(正文不截断)交给ContextToColdContext,L1 里删掉、L2 里完整存在。
1.3 L2 = coldcontext 冷文档
位置:/home/program/TrueAgent/internal/memory/coldcontext/coldcontext.go
| 符号 | 行 | 职责 |
|---|---|---|
type ColdDoc struct |
37 | 文档 = 检索元数据 + Events []ColdEvent |
type ColdEvent struct |
75 | 与 ContextEvent 同构;正文不在字段里,在 Blocks 里 |
(*Store).ContextToColdContext |
385 | L1→L2 迁移的写法 |
(*Store).Peek / Consume / Query |
480 / 526 / 537 | 读(只读 / 读+删 / 只读) |
(*Store).QueryScored / denseSearchScored |
556 / 616 | 带分数的向量召回 |
docBlocksForEntry |
974 | 把一条 ContextEntry 拆成 request/response/tool 块,填 FrameTS/PageSeq/PageKind/BlockRole/CallID |
(*ColdEvent).InOut |
1259 | 从块读回 (input, response, held) —— 召回时的对称解读 |
关键点:ColdDoc.Content 只是渲染视图(coldcontext.go:33-36 注释),
原文在 Events[i].Blocks 里文本块的 Text。这是「L1/L2 同构」的落地方式。
流转方向(ARCHITECTURE.md:200-230):
L1 --Prune 归档(迁移)--> L2
L2 --coldcontext_query(迁回)--> L1 ※ 块 ID / 向量 / 时间戳全程不变
L2 --FindColdContexts(72h, ≤2次) 冷化--> L3
1.4 L3 = stablemem 图记忆
位置:/home/program/TrueAgent/internal/memory/block.go + graph.go
存储 schema(graph.go:2577 ddlMemoryBlocks):
CREATE TABLE memory_blocks (
id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT,
data BLOB, mime TEXT, size INTEGER, width INTEGER, height INTEGER,
vector TEXT, -- JSON 编码的 []float64
fingerprint TEXT, source TEXT, tool TEXT, scene TEXT, semantic_type TEXT,
frame_ts TIMESTAMP, page_seq INTEGER, page_kind TEXT, block_role TEXT,
call_id TEXT, inactive INTEGER, last_recalled_at TIMESTAMP,
created_at, updated_at
)
CREATE TABLE memory_block_edges (
id, source_kind, source_id, target_kind, target_id, type,
confidence, session_id, turn_id, status, merged_into, created_at
)
| 符号 | 行 | 职责 |
|---|---|---|
(*GraphDB).PutMemoryBlocks |
block.go:203 | 事务写入,ON CONFLICT 全部走「空值不覆盖」策略 |
(*GraphDB).AddMemoryBlockEdge |
block.go:497 | 端点存在性校验 + 唯一性 |
(*GraphDB).RecallBlocks |
block_recall.go:78 | 全表扫向量 + 余弦 + 指纹/维度跳过 + 模态过滤下沉 SQL |
(*GraphDB).RecallBlocksWithArbitration |
block_recall.go:229 | 放大 TopK → 仲裁 → 截断 |
(*GraphDB).RecallBlocksGuarded |
abstain.go:179 | 拒答闸门 → 召回 |
arbitrate |
arbitration.go:375 | 同属性多值的时序仲裁 |
AbstainCheck |
abstain.go:98 | 符号零命中 ⇒ 拒答 |
QuerySymbols |
fusion.go:145 | jieba 分词 + 数字/Latin 正则 → 符号集 |
LoadCentroid / centerBoth |
centroid_store.go:144 / 443 | 各向异性校正(去均值) |
(*GraphDB).RecallByScene |
scene.go:344 | 场景前缀匹配召回 |
L3 的 5 条召回链路(全部读 memory_blocks,同一份数据五种取法):
- 向量路:
RecallBlocks→ 余弦,带 fingerprint/dim 跳过 - 符号路:
QuerySymbols→exactHit布尔置顶(fusion.go:427Score = 1.0 + VectorHit*0.01) - 融合:
RecallBlocksFused→0.7×vector + 0.3×symbol,精确串单独置顶 - 仲裁:
arbitrate→ 同属性取最新(用parseFact+supersedes) - 拒答:
AbstainCheck→ 符号零命中且含数字串 ⇒ 拒答
重要实测边界(docs/zh/recall-capability-tiers.md):纯泛化描述(「公网地址是什么」)
目标块排名 389/2686;纯中文「库里没有」无法检测,会编造。移植这些缺陷也一起移植。
1.5 最小可移植单元汇总
| 层 | 最小可移植单元 | 依赖 | 难度 |
|---|---|---|---|
| 共同 | MemoryBlock 值对象 + 确定性 ID 派生(distill.BlockID = sha256(sentence␀subject␀dim␀value)[:12]) |
无 | 极低 |
| L1 | ContextEvent + 「按页淘汰 + 整帧迁移」策略 |
需要宿主提供「可淘汰的上下文」抽象 | 高(dsh 没有) |
| L2 | ColdDoc{Events[]} + docBlocksForEntry/InOut 的对称拆装 |
需要一个 KV/文件存储 + 向量 | 中 |
| L3 | memory_blocks + memory_block_edges + 5 条召回链路 |
需要 SQLite + 向量 + 分词 | 中高 |
2. dsh 的扩展点盘点:哪些能挂记忆、哪些挂不上
dsh 是 cordis 依赖注入内核。插件形态:
// /usr/lib/node_modules/@deepseek-ai/dsh-storage-json/lib/types/index.d.ts
export declare function apply(ctx: Context, config: Config): void;
ctx 是 cordis 代理,服务通过 Service 基类构造时注册
(cordis/lib/types/service.d.ts:38 constructor(ctx, name) →
ctx.reflect.provide(name, this, check)),随 fiber 卸载自动注销。
2.1 service 注册
位置:cordis/lib/types/service.d.ts + cordis/lib/types/context.d.ts
abstract class Service<out T = never> {
constructor(ctx: Context, name: string); // 立即注册,随 fiber 卸载
[symbols.resolveConfig](base?, head?): T; // 祖先 intercept 配置合并
}
// Context 上还有:
ctx.isolate(name, label?) // 独立服务作用域
ctx.intercept(name, config) // 按插件注入配置
ctx.set(name, value) // 直接写
能挂记忆:✔ 这是最关键的可挂点。L3 图记忆可以做成
class StableMem extends Service → super(ctx, 'stablemem'),
下游通过 ctx.stablemem.recall(...) 使用。dsh 自己的
ctx.tools / ctx.sessions / ctx.compaction / ctx.attachments / ctx.storage /
ctx.systemPrompt 全部是这个模式。
挂不上:无。这是通用机制。
2.2 agent 生命周期钩子
位置:dsh-agent/lib/types/runtime-types.d.ts(declare module '@deepseek-ai/cordis' { interface Events { ... } })
| 事件 | mode | 参数 | 对记忆的用处 |
|---|---|---|---|
agent/created |
serial | {agent, source, signal} |
初始化本 session 的记忆命名空间;source: 'startup'|'resume'|'clear'|'compact' 可直接决定「这是新会话还是恢复」 |
agent/disposed |
emit | {agent} |
冲刷未落盘的块 |
agent/status |
emit | {agent, status: 'idle'|'running'} |
只在 idle 时跑蒸馏(对应 HA 的 distillLoop) |
agent/inbox/inserted |
emit | {agent, message} |
用户输入到达 → 预填充召回的触发点 |
agent/inbox/claimed |
emit | {agent, message, turn} |
输入被消费进 step |
agent/pre-step |
waterfall | {agent, messages, turn, step, signal} → PreStepDecision |
★ 可替换本步进入模型的消息列表({kind:'enter', messages})。这是唯一能「改写本次请求内容」的钩子 |
agent/request |
waterfall | {...} → LlmCallConfig |
只能换 provider/model,不能改消息(注释明确:「this waterfall cannot mutate messages」) |
agent/turn-stopping |
serial | {agent, turn, signal} |
轮末:可 agent.steer(...) 插入新内容,或做维护 |
agent/assistant-stream |
emit | {agent, frame} |
只读观测,可做流式蒸馏输入 |
agent/error / agent/request-error |
emit / waterfall | — | 错误恢复,与记忆无关 |
能挂记忆:✔ agent/pre-step(改本步消息)、agent/turn-stopping(轮末蒸馏)、
agent/created(命名空间初始化)。
挂不上:✘ 没有「上下文即将超限、请把低相关事件迁出」的钩子。 dsh 把这件事完全交给 compaction 后端(见 2.4)。
2.3 context 组装点
位置:dsh-system-prompt/lib/types/index.d.ts
class SystemPrompt extends Service {
section(section: PromptSection): () => void; // 静态 system prompt 段
context(context: PromptContext): () => void; // ★ 动态上下文,落成 durable user-role 快照
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void;
variable(name, provider): () => void;
assemble(context?: AssembleContext): Promise<PromptAssembly>;
}
interface PromptContext {
name: string; // 唯一名,重复注册抛错
order: number; // 升序拼接
text: string | ((context: AssembleContext) => string); // ★ 每次组装求值
required?: boolean;
interpolate?: boolean;
}
// AssembleContext(:37-44)—— ★ 基定义只有两个字段;agent 由 dsh-agent declaration merging 扩展
interface AssembleContext {
scope?: ScopeKey;
signal?: AbortSignal;
// agent?: Agent; ← 由 `dsh-agent/lib/types/runtime-types.d.ts`
// `declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext }` 合并进来
}
CONTEXT_ORDERS 已经分配了槽位(index.d.ts:155-163):
WORKING_DIRECTORY: 100 / SANDBOX_POLICY: 110 / APPROVAL_POLICY: 115 /
SUBAGENT_DELEGATION: 120 / TEAM_IDENTITY: 125 / HARNESS_SOURCE: 200 / WEB_SURFACE: 210。
另有瀑布式总装口:
'system-prompt/assemble'(this: Scoped<SystemPrompt>,
assembly: PromptAssembly, context: AssembleContext,
next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
能挂记忆:✔✔ 这是常驻内容的最佳落点,但不是「按本次输入召回」的落点。
必须区分两件事(★ 实测核对源码后修正了初稿的判断):
| 需求 | 钩子 | 依据 |
|---|---|---|
| 「与本次输入无关的常驻段」(记忆总量统计、已存块数、维护提示) | systemPrompt.context() |
用法见 dsh-working-directory/lib/index.js:41-47 |
| 「用本次输入做召回,把结果注入本轮」 | agent/pre-step waterfall |
用法见 dsh-time-context/lib/index.js:218-243 |
为什么不能用 context() 做召回:PromptContext.text(context) 拿到的
AssembleContext 的基定义只有 {scope, signal}(dsh-system-prompt/lib/types/index.d.ts:37-44),
agent 是 dsh-agent 通过 declaration merging 后加的(runtime-types.d.ts 的
declare module '@deepseek-ai/dsh-system-prompt')——两者都没有本步消息正文,
没有本步消息正文。而 HA 的 crossModalMarkdown 需要 query
(internal/agent/core/tooldefs.go:51 传的是 input)。
所以 agent/pre-step 才是对等物:
// dsh-time-context/lib/index.js:218 —— 真实用法(已核对)
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted) return decision
// ... 拼 text ...
return {
...decision,
messages: [...decision.messages, createUserMessage({
content: [{ type: 'text', text }],
source: { kind: name, form: 'snapshot', sections: [{ name, text }] },
})],
}
}, { prepend: true })
挂不上:✘ section / context 都是追加,无法移除已有内容。
所以「把超限事件从上下文里移出」不可能通过这个扩展点做到。
另一个有用的发现:ctx.sessionProjections(dsh-session-projection/lib/types/index.d.ts:25)
提供纯同步 fold(ProjectionDefinition.apply(state, event),
「All functions MUST be synchronous」),并自带订阅、水位与变更通知。
很适合用来维护「本 session 已写过哪些块的 ID」「当前向量空间 fingerprint」这类
派生状态——比自己在 session/event 上手工累加安全。dsh-time-context 就是用它
记录 lastMessageTime / lastInjectionTime。
同时注意一个硬约束(dsh-system-prompt/lib/types/index.d.ts:14-27 的 system-prompt/assemble 注释):
A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
即如果有 complete: true 的 section 被注册,瀑布层的增补会被丢弃。
2.4 compaction
位置:dsh-compaction/lib/types/index.d.ts + dsh-compaction-basic/lib/types/region.d.ts
abstract class CompactionEngine extends Service {
abstract compactIfNeeded(agent: CompactionAgentContext,
trigger: 'pressure' | 'context-overflow',
signal: AbortSignal): Promise<CompactionResult | null>;
abstract compactNow(...): Promise<CompactionResult | null>;
abstract compactRegion(start: SessionSeq, end: SessionSeq, ...): Promise<CompactionResult>;
}
实际执行者(region.d.ts):
export declare function compactSurfaceRegion(
dependencies: RegionDependencies, session: Session,
start: SessionSeq, end: SessionSeq, agent: Agent,
options: CompactionTransactionOptions, signal?: AbortSignal
): Promise<CompactionResult>;
注释里写清了它做的事(region.d.ts:40-46):
Resolve the next range starting at the first non-system surface node while retaining a priced recent tail and never splitting an assistant tool-call/result pair. […] A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session.
★ 这是 L1 移植的核心阻塞点,必须逐条对比:
HA 的 Prune |
dsh 的 compactSurfaceRegion |
|
|---|---|---|
| 裁掉的去向 | 迁移到 L2,原文完整保留,可召回 | 替换成一段 LLM 摘要,原文留在日志里但不在 surface 上 |
| 可逆性 | 可逆(coldcontext_query 迁回原时序) |
模型视角不可逆(要回滚只能看原始日志重建) |
| 评分依据 | 当前 query 的向量余弦 | ctx.tokenMeter 的压力测量 + retention ratio,与 query 无关 |
| 保留策略 | 最近 protectedCount 条 + 相关度 topK |
retainRatio(默认 0.16)× 窗口的尾部预算 |
| 摘要方式 | 不摘要(原文进 L2) | 一次额外 LLM 调用(summarize(),BasicCompactionEngine 唯一可覆写钩子) |
| 页边界 | 按页淘汰(InactivePages) |
「never splitting an assistant tool-call/result pair」(selectCompactableRange) |
能挂记忆:△ 有第二个 compaction 后端 dsh-compaction-tool-result-pruner
(pruneSession(session),确定性 head/middle/tail 裁剪,不调模型),
这证明「不调模型的压缩」是可行的。但它的产物仍是替换 surface 节点,
不是「迁出到另一个层」。
挂不上:✘ 无法实现「按 query 相关性做淘汰决策」。
CompactionEngine 的接口签名里没有 query,CompactionTrigger 只有
'pressure' | 'context-overflow' 两个值。要做 query 相关的淘汰,
只能绕过 compaction 服务自己改 surface——而 session.append 是 append-only,
没有「删除事件」的 API(见 2.5)。
2.5 session log / surface
位置:dsh-session/lib/types/index.d.ts + surface.d.ts
class Session {
get surface(): SessionSurface { readonly nodes: readonly SessionSeq[]; ... }
append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts): SessionEvent<T>;
deriveMessages(): Message[]; // 行 347
}
interface SessionSurface {
readonly nodes: readonly SessionSeq[]; // 模型可见的事件序
readonly replaceGeneration: number;
readonly contentGeneration: number;
}
注释明确(index.d.ts:1-5):
Event-sourced session service: append-only session log, in-memory store, and the derived LLM message history.
surface 是「事件日志之上的一层有序视图」(surface.d.ts:1-6):
「The append-only log remains the source of truth.」
能挂记忆:△ session/event 事件(emit)可以 tap 到每一条追加的事件
→ 这可以做增量蒸馏输入(等价 HA 的 pipeline.Append)。
session/flush(parallel)可以做落盘检查点。
挂不上:✘✘ 这是第二个硬阻塞:
- 消息没有时间戳。
MessageBase(dsh-llm/lib/types/message.d.ts:124-136)只有id / content / source,没有timestamp。HA 的「按原始时间戳把事件插回时序」 (InsertByTimestamp)在 dsh 里无对应物——surface 是有序数组,replace只能替换已存在的某个位置,不能「插到历史中间」。 - 没有「消费式删除」。HA 的
coldcontext_query是Peek → 回灌 → Remove(toolcall.go:1109-1190),迁移语义。dsh 的session.append只能追加, surface 只能replace一个位置为别的消息,不能「移出一批事件到另一个存储」。 - 没有
FrameTS/PageSeq概念。dsh 用turn/step('turn/start','step/start',dsh-agent/lib/types/types.d.tsTurnBoundaryProjection)作为边界。 两者可以映射(推断:turn≈FrameTS,step≈PageSeq), 但 dsh 的边界是单调递增的整数,不是时间戳,且不参与块的身份。
2.6 attachment(媒体块)
位置:dsh-attachment/lib/types/index.d.ts
abstract class AttachmentStore extends Service {
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>;
abstract readImage(ref: ImageAttachmentRef, signal?): Promise<StoredImageAttachment>;
saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef>; // 内容寻址、逐字节
readFileStream(ref, signal?): AsyncIterable<Uint8Array>;
admitPromptContent(content): Promise<AdmittedPromptContentPart[]>;
}
对比 HA 的媒体块(block.go:62-65 与 data 字段注释):
L0/L1 的块内联在 JSON 里(Data 序列化为 base64),L3 的块落 SQLite 的 BLOB 列。 无论哪层,字节都不脱离块单独存在。
能挂记忆:△ 部分可挂,但语义不同:
| HA 媒体块 | dsh attachment | |
|---|---|---|
| 字节位置 | 随块(块外无字节存储) | 块外,AttachmentRef 是引用(content-addressed) |
| 生命周期 | 随块同生同灭 | 独立(「already published content-addressed objects may stay unreachable until a future retention policy collects them」) |
| 引用账本 | 刻意不做(block.go:62-65:「会凭空多出一层…引用账本…并与『块是唯一记忆载体』直接冲突」) |
没有账本,靠 GC |
挂不上:✘ HA 明确否决过 CAS 形态(block.go:62-65),
而 dsh attachment 就是 CAS。移植时若用 attachment 存媒体块,
就必然引入 HA 刻意避免的「第二套生命周期」。反向选择是「把 Data base64 进 KV」,
代价是 ctx.storage 的 JSON backend 会把大 blob 序列化进文档
(dsh-storage-json/lib/types/index.d.ts:「one human-readable document per unit」)。
结论:媒体块是必须裁剪的部分(详见 §5 降级方案)。
2.7 tool 注册
位置:dsh-tools/lib/types/index.d.ts
class ToolRuntime extends Service {
register(definition: ToolDefinition): () => void; // 行 637
restrict(filter: ToolRestriction): () => void; // 行 654
schemas(scope?: ScopeKey): ToolSchema[]; // 行 712
}
interface ToolDefinition extends ToolSchema {
readonly output: ToolOutputDefinition; // ★ 强制:schema + render
execute(args: unknown, exec: ToolRunContext): Promise<unknown>;
timeoutMs?: number;
isConcurrencySafe?(args: unknown): boolean;
presentCall?(args): ToolCallView | undefined;
presentResult?(args, result): ToolResultView | undefined;
}
工具管线事件(declare module '@deepseek-ai/cordis' { interface Events { ... } }):
tools/pre-execute(waterfall)→PreToolDecision:allow / deny / cancel / asktools/execute(waterfall):超时、重试、指标tools/post-execute(waterfall)→PostToolDecision:接受/替换/阻断结果tools/result(emit):只读观测最终结果tools/change(emit,未做 scope 过滤):工具集变化
能挂记忆:✔✔ 这是最完整可挂的扩展点。HA 的
stablemem_* / coldcontext_* 工具面(internal/agent/core/tooldefs.go:610-700)
可以逐个 1:1 翻成 ToolDefinition。tools/post-execute 还能实现
HA 的「工具输出触发的自动召回」(memoryPass)。
挂不上:无。注意一个额外成本:ToolDefinition.output 是强制的
(output.schema + output.render),每个工具都要写一份 JSON Schema 与 render 函数。
HA 的工具只有 description + parameters(toolDefWith(...))。
2.8 汇总表
| 扩展点 | 位置 | L1 | L2 | L3 | 备注 |
|---|---|---|---|---|---|
Service 注册 / ctx.<name> |
cordis/service.d.ts:38 |
— | ✔ 存储 | ✔✔ 召回服务 | 通用机制 |
agent/pre-step (waterfall) |
dsh-agent/runtime-types.d.ts |
✔ 注入 | ✔ 注入 | ✔ 注入 | 可替换本步消息 |
agent/turn-stopping (serial) |
同上 | ✘ | △ 蒸馏触发 | ✔ 蒸馏触发 | |
agent/created / disposed |
同上 | △ | ✔ 命名空间 | ✔ 命名空间 | source 区分 startup/resume |
session/event (emit) |
dsh-session/index.d.ts |
△ tap 增量 | ✔ 归档输入 | ✔ 蒸馏输入 | |
systemPrompt.context() |
dsh-system-prompt/index.d.ts:269 |
△ 常驻段 | △ | ✔ 记忆统计 | 纯追加,不能移除;拿不到本步输入 | |
agent/pre-step + createUserMessage |
dsh-time-context/lib/index.js:218 |
✔✔ 召回注入 | ✔✔ 自动预填充 | ✔ 记忆索引 | AssembleContext 无输入文本的替代方案 |
ctx.sessionProjections |
dsh-session-projection/index.d.ts:25 |
△ | ✔ 派生状态 | ✔ 派生状态 | 纯同步 fold,自带水位 |
system-prompt/assemble (waterfall) |
同上 :27 | △ | △ | △ | 有 complete section 时失效 |
ctx.compaction 子类 |
dsh-compaction/index.d.ts |
✘ 语义相反 | ✘ | ✘ | 摘要替换 ≠ 迁移 |
ctx.toolResultPruner |
dsh-compaction-tool-result-pruner |
△ 可参考 | ✘ | ✘ | 不调模型但仍是 replace |
ctx.attachments |
dsh-attachment/index.d.ts |
✘ CAS 冲突 | ✘ | ✘ | 违反 HA「不做引用账本」 |
ctx.tools.register |
dsh-tools/index.d.ts:637 |
✔ | ✔✔ | ✔✔ | 需补 output 声明 |
tools/post-execute |
同上 | — | ✔ | ✔ | 工具输出触发召回 |
session.append |
dsh-session/index.d.ts |
✘ 无时间戳/无删除 | ✘ | ✘ | 硬阻塞 |
3. 逐层对应关系
3.1 L3(stablemem 图记忆)→ 可映射
| HA 概念 | HA 符号 | dsh 落点 |
|---|---|---|
memory_blocks 表 |
ddlMemoryBlocks(graph.go:2577) |
ctx.storage.domain 的一个 domain,或自建 ctx.sqlite 服务 |
memory_block_edges 表 |
graph.go:320 |
同 domain 的第二张表 |
PutMemoryBlocks(事务+空值不覆盖) |
block.go:203 |
domain 的 put + 逐字段 merge 逻辑(需手写,dsh 无 ON CONFLICT 语义) |
RecallBlocks(全表扫+余弦) |
block_recall.go:78 |
自定义 StableMem service 的方法 |
arbitrate(时序仲裁) |
arbitration.go:375 |
纯函数,可直接翻译(无依赖) |
AbstainCheck(拒答) |
abstain.go:98 |
纯函数,可直接翻译 |
QuerySymbols(符号提取) |
fusion.go:145 |
需 jieba(见 §4) |
centerBoth(中心化) |
centroid_store.go:443 |
纯数值,可直接翻译 |
RecallByScene |
scene.go:344 |
SQL 前缀匹配,可翻译 |
stablemem_recall 等工具 |
tooldefs.go:610-700 |
ctx.tools.register |
| 图记忆索引注入 | Indexer.FormatContext(indexer.go:358) |
systemPrompt.context() |
不能映射的部分:
BlocksForNode/NeighbourEdgesOfBlock的 BFS 联想(recall-as-association.md) → 需要先有边表 + 递归 CTE,SQLite 支持(WITH RECURSIVE),可做但 dsh 无现成服务。
3.2 L2(coldcontext 冷文档)→ 大体可映射,缺「迁回」
| HA 概念 | dsh 落点 | 可行性 |
|---|---|---|
ColdDoc 存储(文件 JSON,Store.dir) |
ctx.storage.domain(KV)或直接 node:fs |
✔ |
ContextToColdContext(L1→L2 写出) |
在 agent/turn-stopping 里把本 turn 的消息落成 ColdDoc |
✔(但只能「复制」,不能「移出」L1) |
QueryScored / denseSearchScored(召回) |
自定义 service 方法 | ✔ |
自动预填充(crossModalMarkdown) |
systemPrompt.context() |
✔✔ |
coldcontext_commit 工具 |
ctx.tools.register |
✔ |
coldcontext_query 的 Peek→回灌→Remove |
无对应物 | ✘ |
为什么 coldcontext_query 挂不上(最关键的一条):
HA 的实现(toolcall.go:1121-1195):
docs := a.coldcontextStore.Peek(query, topK) // 只读,不删
plan := a.context.planColdRecall(docs, query, budget) // 超预算按页标 inactive
for i, d := range docs {
n := a.restoreDocEvents(d, plan.Evict[d.ID]) // 逐条 InsertByTimestamp
if n > 0 { a.coldcontextStore.Remove(d.ID) } // 确认落进 L1 才删 L2
}
其中 restoreDocEvents(toolcall.go:1428)调用
a.context.InsertByTimestamp(ContextEvent{Timestamp: ev.Timestamp, ...})
—— 按事件原始时间戳插进历史位置。
dsh 侧的等价物需要:
session.append('user/message', ...)—— 只能追加到末尾,位置固定- 或者
surfaceOp: 'replace'—— 只能替换一个已存在的位置
两者都做不到「插到 turn 3 和 turn 4 之间」。
MessageBase 没有时间戳字段,所以即便插进去了,也无法表达「这条属于 3 小时前」。
降级方案(§5 会展开):把召回结果作为一条新的 user 消息追加
(用 source.form: 'recall' —— dsh 已为这种语义预留了枚举,
message.d.ts:58 | 'recall'; 与 :92 readonly form: 'recall';)。
语义从「迁回原时序」退化为「在末尾附一段『历史记忆(时间戳见正文)』」。
这是有损的:模型看不到真实的时序关系,但内容不丢。
3.3 L1(context 热上下文)→ 只有注入面可映射
| L1 机制 | dsh 对应 | 可行 |
|---|---|---|
| 「进 system prompt 的时序渲染」 | systemPrompt.context() / agent/pre-step |
✔ |
| 「每次输入计算向量」 | agent/inbox/inserted 里做 |
✔ |
| 「超限时按相关度淘汰」 | 无 | ✘ |
| 「淘汰 = 迁移到 L2(不是摘要)」 | 无 | ✘ |
| 「按页淘汰,不破 tool_call/result 配对」 | compaction 有页意识但产物是摘要 | ✘ |
「InsertByTimestamp 乱序插回」 |
无(surface 无语义插入) | ✘ |
「InactivePages 渲染期剔除」 |
无(surface 只能 replace 整节点) | ✘ |
根因总结:dsh 的上下文管理哲学是「日志只追加,视图用摘要替换」; HA 的哲学是「日志可迁移,视图是持有关系」。 两者对「裁掉的内容去哪了」的答案不同:
- dsh:压缩成摘要(
compactSurfaceRegion),原文在 append-only 日志里但模型看不到 - HA:迁到另一个层(
Prune→ContextToColdContext),可被下次召回迁回
这不是「接口没暴露」,是设计取向不同。所以 L1 的 L1 层机制无法移植, 只能移植它的效果的一半(把记忆注入 prompt),另一半(淘汰+迁回)必须重新设计。
4. 依赖盘点:插件形态下谁提供,缺什么
| 依赖 | HA 侧 | dsh 侧现状 | 结论 |
|---|---|---|---|
| 向量化(embedding) | pkg/embedding SPI(Provider.Embed/Info/Close)+ 4 个 provider:chineseclip(512 维 ONNX)、qwen3vl(2048 维 ONNX)、http(外部服务)、StaticEmbedder(fastText/GloVe 词向量兜底,internal/memory/static_embedder.go) |
完全没有。全仓 grep -rn "embedding" --include=*.d.ts 只命中 UI 主题/样式相关文件,无任何 embedding/vector service |
缺。必须自建 |
| ONNX Runtime | github.com/yalue/onnxruntime_go + -tags onnxruntime(providers/chineseclip/embedder.go:1) |
无 npm onnxruntime-node(find / -name onnxruntime-node 无结果);但 /opt/onnxruntime/libonnxruntime.so 存在,sherpa-onnx-linux-x64 也自带 libonnxruntime.so(27MB,导出 OrtGetApiBase);Python 侧 onnxruntime 1.28.0 可用 |
缺 npm 绑定。三条出路见下 |
| SQLite | github.com/mattn/go-sqlite3(cgo) |
node:sqlite 内建(实测可用:DatabaseSync + FTS5 建表/匹配均通过,Node v22.23.3,仅一条 ExperimentalWarning);dsh-session-query-sqlite 已在用(schema.d.ts:2 import type { DatabaseSync } from 'node:sqlite') |
✔ 已具备。可直接建 memory_blocks 等价表 |
| 中文分词(jieba) | github.com/yanyiwu/gojieba + 词典目录(internal/memory/jiebadict/,cut.go:73 GetJieba()) |
无 nodejieba/jieba-js(ls /usr/lib/node_modules | grep -i jieba 无结果) |
缺。 三条出路:① 重写为纯 JS 词典(QuerySymbols 只需要词边界,不要求精确);② 用 Intl.Segmenter(Node 22 已内建 zh 词边界,推断质量低于 jieba 但可用);③ 只保留正则符号路(数字/Latin 串),放弃中文词 |
| 依存句法(distill) | internal/nlp/onnx.go(-tags onnxruntime,内嵌 dep_parser.onnx)+ fallback.go 规则引擎 |
无 | 缺,但可裁剪。HA 自己的文档(memory-restructure-plan.md §〇)记过:「jieba/ONNX 那条自动蒸馏路实测产出 0 条」。真正在用的是 LLM 拆解(distill.Extractor.Split 走 pkg/generation)+ 规则闸门 |
| 生成侧 LLM(蒸馏) | pkg/generation provider(ollama/qwen3vlgen) |
✔ ctx.llm(dsh-llm LlmRuntime.stream(options));BasicCompactionEngine.summarize() 就是范例 |
✔ 具备,但要走通 registerAdapter 或复用已有 provider |
| 媒体存储 | 块内 Data []byte(SQLite BLOB / JSON base64) |
ctx.attachments(CAS,content-addressed) |
语义冲突,见 §2.6 |
| HTTP 外部向量服务 | internal/memory/vector/http_embedder.go(POST endpoint,{modality,side,text,data,mime} → {embedding}) |
无对应物,但协议极简(见下) | ✔ 可移植,这是最省事的路 |
4.1 embedding 的三条落地路径
HA 的 http provider 协议(http_embedder.go:73-86):
type httpEmbedRequest struct {
Model string `json:"model,omitempty"`
Modality string `json:"modality"`
Side string `json:"side"` // query | document
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"` // base64(非文本模态)
MIME string `json:"mime,omitempty"`
}
type httpEmbedResponse struct {
Embedding []float64 `json:"embedding"`
Data []struct{ Embedding []float64 `json:"embedding"` } `json:"data,omitempty"`
}
路径 A(推荐):复用 HA 的 http provider 契约。
在 dsh 插件里实现同一个 HTTP 客户端,指向一个独立进程(可以是 HA 的 homed
或任何 Python/Go 服务)。成本 ≈ 2 人天。缺点:多一个常驻服务,多一个部署单元。
路径 B:Node 内跑 ONNX。onnxruntime-node 未安装,但可以 npm i 或
用 ffi-napi 绑 /opt/onnxruntime/libonnxruntime.so(OrtGetApiBase 已导出)。
成本 ≈ 6–10 人天(C API 绑定 + 图输入预处理,Chinese-CLIP 要自己做 tokenizer + 图像预处理)。
风险:高。HA 的 chineseclip provider 有 900+ 行(providers/chineseclip/embedder.go),
含 tokenizer、图像 resize、归一化、session 管理。全部重写在 JS 里性价比极低。
路径 C:纯 TF-IDF / 词袋兜底。HA 有这条退化路径
(ARCHITECTURE.md 向量层级第三级)。dsh 里可用 node:sqlite 的 FTS5
(已实测可用)替代。成本 ≈ 2 人天。代价:按 memory-usability-plan.md 的实测,
词法检索的区分度≈0(分数全挤在 0.75–0.79),这条路等于放弃语义召回。
4.2 分词的两条落地路径
QuerySymbols(fusion.go:145-238)的逻辑:
// 1) 正则提取数字串 / Latin 串
for _, m := range symbolNumericRe.FindAllString(query, -1) { add(m) }
for _, m := range symbolLatinRe.FindAllString(query, -1) { add(strings.ToLower(m)) }
// 2) 中文:jieba 分词(成功则用),失败退化滑窗
cut := GetJieba()
if cut != nil {
for _, run := range cjkRuns {
for _, w := range cut.Cut(run, true) {
if w == "" || cjkFunctionWords[w] { continue }
add(w)
}
}
usedJieba = len(out) > 0
}
if !usedJieba { // 滑窗补充:2~4 字重叠窗口 + symbolStopWords 过滤
for _, run := range cjkRuns { for _, w := range cjkWindows(run) { add(w) } }
}
注意它已经内置了退化路径(cjkWindows 滑窗)—— 这部分是纯字符串操作,可直接翻译。
所以分词不是硬阻塞:最差情况退回滑窗,代码注释也承认「滑窗是今天的既有行为,不会更差」。
Intl.Segmenter(Node 22)可作为中间档:比滑窗好,比 jieba 差。
4.3 依赖结论
| 依赖 | 状态 | 阻塞级别 |
|---|---|---|
| SQLite | ✔ 具备(node:sqlite,实测通过) |
无 |
| embedding | ✘ 缺,需自建(路径 A/B/C) | P0 阻塞(决定 L2/L3 能否语义召回) |
| 分词 | △ 缺 jieba,有滑窗退化 | P2(可降级) |
| 依存句法 | ✘ 缺,但 HA 自己也没验证有效 | P3(直接砍) |
| LLM 蒸馏 | ✔ ctx.llm |
无 |
| 媒体 CAS | △ 语义冲突 | P1(裁掉媒体块) |
5. 硬阻塞项清单(按严重程度排序)+ 降级方案
5.1 P0 阻塞:dsh 无 embedding 服务(必须自建)
证据:全仓 *.d.ts 无 embedding/vector service;HA 的 pkg/embedding 是 200+ 行 SPI + 4 个 provider。
影响:L2 的 DenseVec 召回、L3 的 RecallBlocks 向量路、中心化校正、
指纹隔离全部依赖向量。没有向量,L2/L3 退化为 LIKE + 符号路。
已知后果(HA 自己的实测,recall-capability-tiers.md):
- 无向量 ⇒ 只有「形态 1:带精确串」可用(端口/IP/版本号)
- 「形态 2:领域词 + 上下文」从「可用」掉到「弱」
- 「形态 3:纯泛化描述」完全不可用
降级方案:
- A(推荐):实现 §4.1 路径 A(HTTP 转发到独立进程)→ 保留全部向量能力,2 人天
- B:FTS5 全文检索替代向量路 → 只保留符号路 + 精确串置顶,L3 价值降到「精确串查找+图遍历」
- C:先不移植 L3 的向量路,只移植结构(块+边+BFS) → 见 §5.9 汇总表
5.2 P0 阻塞:dsh 的 surface 没有时间戳、不能语义插入(L1 与 L2 召回的核心语义丢失)
证据:
dsh-llm/lib/types/message.d.ts:124-136MessageBase只有id / content / sourcedsh-session/lib/types/index.d.ts:1-5「Event-sourced session service: append-only session log」dsh-session/lib/types/surface.d.ts:1-6「The append-only log remains the source of truth」- HA 侧:
context.go:998 InsertByTimestamp、toolcall.go:1428 restoreDocEvents
影响:HA 的「召回 = 把事件按原始时间戳迁回时序」这条路径无法表达。 用户问「三小时前说的那个方案」,HA 把那段对话插回真实位置;dsh 只能把它写成末尾一条消息。
降级方案:
- 用
source.form: 'recall'(message.d.ts:46export type ContextForm含此枚举,:58/:92分别定义其form字面量与用途)—— 语义上 承认「这是从别处搬来的」,而非「这是新发生的事」。 - 在正文里显式写时间戳:「[2026-10-29 20:00 · qq] …」,让模型自己理解时序。 代价:模型可能误判因果顺序。
- 不做「迁出」:L2 只存「另外记下来的」内容(
coldcontext_commit的等价物), 不存「被淘汰的对话」。这等于放弃 HA 的 L2 主业(对比见 §5.8 的子集表)。
5.3 P1 阻塞:compaction 语义与 Prune 相反(L1 淘汰机制不可移植)
证据:dsh-compaction-basic/lib/types/region.d.ts:40-46
「A successful run replaces the selected surface span with one summary node」;
CompactionTrigger = 'pressure' | 'context-overflow'(dsh-compaction/index.d.ts:25),
接口签名里没有 query。
影响:无法做「按当前 query 相关性决定淘汰谁」。 dsh 的 compaction 是 query-agnostic 的(只看 token 压力 + retention ratio), 且产物是 LLM 摘要而非可回收的原文块。
降级方案:
- A:接受 dsh 的 compaction,记忆系统不介入 L1 淘汰。L2/L3 变成 「主动写入的长期记忆」而非「L1 的溢出层」。→ 这是最诚实的取舍
- B:自己实现一个
CompactionEngine子类,在summarize()里 额外把被替换的区间落成ColdDoc(而不是只丢给摘要)。 这在 API 上可行(summarize(input, agent, signal)拿得到SummarizationInput), 但拿不到 query,所以淘汰依然与 query 无关。成本 +8 人天,收益有限。 - C:不用 compaction,用
agent/pre-step自己判预算、自己replacesurface 节点。 风险高:session.append的 surface 校验(surface.d.ts的SurfaceOp) 对「replace 必须完整覆盖被 shadow 的节点」有硬要求,自己拼容易造出必崩的帧。
5.4 P1 阻塞:媒体块与 dsh attachment 的 CAS 语义冲突
证据:block.go:62-65(HA 明确否决 CAS);dsh-attachment/index.d.ts(attachment 就是 CAS)。
影响:媒体块要么违反 HA 设计(用 attachment 引用),要么违反 dsh 惯例
(base64 进 KV,把 dsh-storage-json 的「human-readable document」撑爆)。
降级方案:砍掉媒体块。MemoryBlock.Data 字段保留但永不填充,
Modality ∈ {text} only。代价:失去跨模态召回(HA 的 RecallBlocks 的
Modality / ExcludeText 参数,block_recall.go:60/63)。
收益:省掉图像预处理、视觉 tokenizer、跨模态对齐的全部成本。
5.5 P2 阻塞:jieba 缺失
证据:无 npm jieba;HA 用 github.com/yanyiwu/gojieba + 内置词典。
降级方案:滑窗(HA 已有此退化路径,fusion.go:240-268(cjkWindows))或 Intl.Segmenter。
影响可控:AbstainCheck 的实测结论(abstain.go:61-80「判据的能力边界」)已经承认
「纯中文的零命中分不清『真没有』与『滑窗伪词提取失败』」——所以滑窗退化是已知边界内。
5.6 P2 阻塞:ToolDefinition.output 强制声明
证据:dsh-tools/lib/types/index.d.ts:115 export interface ToolDefinition extends ToolSchema + :117 readonly output: ToolOutputDefinition;(无 ?,不是可选的)。
影响:每个工具多写一份 JSON Schema + render 函数。HA 侧 10+ 个 stablemem_* 工具
(tooldefs.go:610-700)每个都要补。成本 +3 人天。
5.7 P3 阻塞:FrameTS / PageSeq 无对应物
证据:dsh 用 turn / step(TurnBoundaryProjection,dsh-agent/types.d.ts),
是单调整数而非时间戳,且不参与块身份。
降级方案:推断可行 —— 用 (agent.id, turn, step) 组合作 FrameTS/PageSeq 的等价物。
丢失的是「跨 session 的时间可比性」,但 HA 的 FrameTS 主要用途(同帧块不跨层、
页内配对)在单 session 内可满足。
5.8 降级方案:只移植某几层成不成立?
| 子集 | 成立? | 理由 | 工作量 |
|---|---|---|---|
| 只有 L3(图记忆) | ✔✔ 最成立 | L3 自包含:块+边+5 条召回链路全是纯算法 + SQLite。不依赖 L1/L2 的任何机制。作为「事实与关系库」独立存在完全合理。 | 19–25 人天(= §6.2 的 MVP 口径:阶段 0+1+2+3+7) |
| 只有 L2(冷文档) | △ 半成立 | 存储/召回/预填充都能做,但**放弃「迁出-迁回」**后,L2 只能靠显式写入(等价 coldcontext_commit)填充,不能自动从 L1 溢出。价值从「对话历史归档」降为「手工笔记库」。 |
12–17 人天 |
| 只有 L1(热上下文) | ✘ 不成立 | L1 的全部价值在「淘汰+迁回」这对机制上。只剩下「往 prompt 注入东西」,那不需要三层记忆,一个 PromptContext 就够。 |
— |
| L2 + L3 | ✔✔ 推荐 | 二者都以「块」为单元、都以「召回进 prompt」为出口,共享 embedding + SQLite。L1 完全交给 dsh 自己的 compaction。 | 36–53 人天(= §6.2 合计) |
| L1 + L2 | △ | 缺 L3 的关系能力,但至少「归档+召回」闭环成立。前提是接受 §5.2 的时间戳降级。 | 24–34 人天 |
| 三层全搬 | ✘ | L1 的两条核心机制(按 query 淘汰、时间戳迁回)在 dsh 里无对应扩展点,必须改 dsh 内核或另造一套 surface 管理。这不是插件能做的事。 | — |
5.9 硬阻塞汇总表
| # | 阻塞项 | 严重度 | 可绕过? | 绕过代价 |
|---|---|---|---|---|
| 1 | dsh 无 embedding 服务 | P0 | ✔ 自建(HTTP 转发最省) | 2 人天 + 一个常驻服务 |
| 2 | 消息无时间戳、surface 不能语义插入 | P0 | △ 只能降级(form:'recall' + 正文写时间) |
语义损失,不可完全恢复 |
| 3 | compaction = 摘要替换 ≠ Prune 迁移 | P1 | △ 接受它 / 再写一个后端 | L1 淘汰机制放弃 |
| 4 | attachment CAS ≠ 块内联字节 | P1 | ✔ 砍掉媒体块 | 失去跨模态召回 |
| 5 | 无 jieba | P2 | ✔ 滑窗 / Intl.Segmenter |
中文符号提取变弱(已知边界内) |
| 6 | ToolDefinition.output 强制 |
P2 | ✘ 必须写 | +3 人天 |
| 7 | FrameTS 无对应物 |
P3 | ✔ (session,turn,step) 组合 |
跨 session 时间可比性丢失 |
| 8 | ON CONFLICT ... 空值不覆盖 语义 |
P3 | ✘ 手写 merge 逻辑 | +2 人天(易出错,需测试) |
6. 结论与工作量
6.1 结论:只能做子集
理由链(逐条对应证据):
-
L3 可以几乎完整移植,因为它的依赖(SQLite + 纯算法 + 工具注册)在 dsh 全部具备。
node:sqlite实测可用(DatabaseSync+ FTS5),ToolRuntime.register是完备的工具面,arbitrate/AbstainCheck/QuerySymbols/centerBoth都是纯函数,可 1:1 翻译。 -
L2 可以移植 80%,缺的是「回收站语义」(
Peek → 回灌 → Remove)。 dsh 侧可以用ctx.storage.domain(或直接node:sqlite)建出等价的ColdDoc存储, 可以存、可以查、可以注入,但不能把内容从 L1 移出, 所以「L2 作为 L1 的溢出层」这个定位无法成立。降级为「主动写入的长期记忆」仍可用。 -
L1 无法移植。这是决定性的一条:L1 的两个不可替代机制 ——(a)按当前 query 相关性淘汰、(b)按原始时间戳把事件插回历史位置 —— 在 dsh 的扩展点上都没有落点。
ctx.compaction的接口签名里没有 query (CompactionTrigger只有两个枚举值),MessageBase没有时间戳字段。 要支持这两点必须改 dsh 内核(新增 surface 操作或扩展 compaction 接口), 那已经不是「插件」的范畴。 -
因此「三层移植」这个命题本身要修正:能移植的是 「块化的长期记忆(L2+L3)」,L1 应替换为 dsh 自己的 compaction。
6.2 工作量粗估
| 阶段 | 内容 | 人天 |
|---|---|---|
| 0 | 骨架:插件包、Service 注册、配置、node:sqlite 建表(memory_blocks / memory_block_edges / graph_centroid) |
3 |
| 1 | embedding 桥(HTTP 转发 + fingerprint + 维度/指纹跳过逻辑) | 2–3 |
| 2 | 块与边:MemoryBlock 值对象、确定性 ID 派生、PutMemoryBlocks 的「空值不覆盖」merge、AddMemoryBlockEdge 端点校验 |
5–7 |
| 3 | L3 召回:RecallBlocks + 中心化 + 指纹隔离 + 模态过滤 |
4–5 |
| 4 | L3 增强:QuerySymbols(含滑窗退化)+ 融合 + arbitrate + AbstainCheck |
5–7 |
| 5 | L2 存储:ColdDoc/ColdEvent + docBlocksForEntry/InOut 对称拆装 + 向量召回 |
4–6 |
| 6 | 注入面:agent/pre-step 的自动预填充(等价 crossModalMarkdown)+ systemPrompt.context() 的常驻记忆统计 |
2–3 |
| 7 | 工具面:stablemem_recall/commit/merge/purge + coldcontext_query/commit(每个都要 output 声明) |
5–7 |
| 8 | 落盘与恢复:agent/turn-stopping 触发归档、agent/disposed 冲刷、session/flush |
2–3 |
| 9 | 判据与实测 | 4–6 |
| 合计(L2+L3 子集) | 36–53 |
压缩到最小可用(MVP):阶段 0+1+2+3+7(去掉 L2 与仲裁/拒答)≈ 19–25 人天。
若强行包含 L1 的降级版(form:'recall' + 正文写时间戳,不做淘汰):
+8–12 人天,但价值增量很低(因为淘汰是 L1 的核心,注入面本来就有)。
工作量口径统一说明:请用上表(含判据与实测)。§5.8 的子集表已按此口径对齐。
7. 建议落地阶段 + 可验证的最小第一步切片
7.1 阶段划分
阶段 A:第一步切片(1–2 人天,必须先做)
目标:证明「块能进 dsh、能被召回、能进 prompt」这条最小链路成立, 且不引入任何外部服务依赖。
具体切片:
// A1. 新建插件包 dsh-memory,导出 apply(ctx, config)
import { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'memory'
export const inject = ['systemPrompt', 'tools'] // 与 dsh-working-directory 同构
export function apply(ctx: Context, config: Config) {
const mem = new StableMem(ctx) // super(ctx, 'stablemem')
// A2a. 注入面:静态/动态 context 段(已核对 dsh-working-directory/lib/index.js:41)
ctx.systemPrompt.context({
name: 'memory:index',
order: ctx.systemPrompt.getContextOrder('WEB_SURFACE'), // 或直接给 210
interpolate: false,
// ⚠ text() 只拿得到 AssembleContext{scope, signal, agent?},拿不到“本次输入文本”
text: (c) => (c.agent === undefined ? '' : mem.renderIndex(c.agent.session)),
})
// A2b. ★ 真正能拿到“当前这一步要进模型的消息”的钩子是 agent/pre-step(waterfall)。
// 已核对 dsh-time-context/lib/index.js:218 的用法:
// const decision = await next() → 改 decision.messages → return
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted) return decision
const injected = mem.recallForStep(agent, decision.messages) // 用本步输入做召回
if (injected === '') return decision
return {
...decision,
messages: [...decision.messages, createUserMessage({
content: [{ type: 'text', text: injected }],
source: { kind: name, form: 'snapshot', sections: [{ name, text: injected }] },
})],
}
}, { prepend: true })
// A2c. 工具面(output.schema + output.render 是强制的)
ctx.tools.register(defineTool({
name: 'stablemem_commit',
description: '...',
parameters: { /* ... */ },
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
async execute(args, exec) { return mem.commit(args) },
}))
}
// A3. StableMem 用 node:sqlite 建 memory_blocks(**只建 text 模态**)
// import { DatabaseSync } from 'node:sqlite' ← 已实测可用(含 FTS5)
// A4. 召回先**不走向量**:FTS5 + 精确串置顶(把 QuerySymbols 的
// 数字/Latin 正则部分 + 滑窗部分翻成 TS)
// A5. 判据(必须能红):
// ✔ 工具写入一个块 → 下个 pre-step 的 messages 里出现它
// ✔ 变异:recallForStep 返回 '' ⇒ 判据红
// ✔ 变异:把 FTS5 MATCH 改成 LIKE '%x%' 全命中 ⇒ 判据红(区分度)
★ A2a / A2b 的分工必须说清(这是阶段 A 最重要的发现):
systemPrompt.context()适合与本次输入无关的常驻内容(工作目录、沙箱策略、 记忆总量统计);需要按本次输入做召回再注入时,必须走agent/pre-step, 因为PromptContext.text(context: AssembleContext)的AssembleContext只有{scope?, signal?}+ 合并进来的agent?,没有本步消息正文。dsh-time-context正是因此选择在agent/pre-step里createUserMessage后追加到decision.messages(lib/index.js:218-243)。
为什么这个切片是对的:
- 它故意不碰 embedding(P0 阻塞),用 FTS5 拿掉第一个硬依赖
- 它故意不碰 L1(不可移植),只验证注入面
- 它故意不碰 L2 的召回(需要时间戳降级方案定型)
- 它验证的正是「最有价值且最可移植」的部分:块 + 存储 + 召回 + 注入
- 失败成本极低:如果
systemPrompt.context()的求值时机或node:sqlite的并发行为不符预期,在 2 天内就能发现
验证方式(可直接跑的 checkpoints):
| # | 检查 | 命令/方法 |
|---|---|---|
| V1 | node:sqlite 在插件进程内可用、WAL 模式正常 |
建库 + 并发写 100 次 + 读回 |
| V2 | node:sqlite FTS5 对中文的召回质量 |
用 HA 的 50 条真实块文本做语料,10 个查询测 top1 |
| V3 | systemPrompt.context() 的 text(ctx) 能拿到 agent.session,但验证它拿不到本步消息 |
在 text 里 log Object.keys(context),确认只有 scope/signal/agent |
| V3b | agent/pre-step 的 decision.messages 可被追加且模型的下一轮确实看到 |
最小 echo 注入 + 观察 /dump-config 或 session log 里是否新增 user/message |
| V4 | ctx.tools.register 的 output.schema/render 契约 |
最小 echo 工具跑通 |
| V5 | 插件卸载后 sqlite 句柄释放 | ctx.dispose + 文件锁检查 |
阶段 A 的 exit criteria:V1–V5 全绿 + 判据 A5 三条(含 2 条变异)
- V3/V3b 的分工结论写入插件 README(哪个钩子能拿输入,哪个不能)。
阶段 B:向量落地(3–4 人天)
接 §4.1 路径 A(HTTP 转发)或路径 C(确认放弃向量)。
在此阶段把 RecallBlocks 的余弦 + 指纹/维度跳过 + 中心化 补齐。
阶段 C:L3 完整召回(8–12 人天)
QuerySymbols 完整版 + 融合 + 仲裁 + 拒答 + 场景召回。
阶段 D:L2 冷文档(6–9 人天)
ColdDoc/ColdEvent + 对称拆装 + 向量召回 + systemPrompt.context() 的预填充。
前置条件:§5.2 的时间戳降级方案已拍板(form:'recall' + 正文写时间)。
阶段 E:工具面 + 归档触发(7–10 人天)
全部 stablemem_* / coldcontext_* 工具;agent/turn-stopping 触发归档。
阶段 F:判据与实测(4–6 人天)
按 HA 的判据风格(「变异必须能红」)建测试;
用 HA 的生产库快照做 A/B 对比(recall-capability-tiers.md 的四形态 + 排名基线)。
7.2 必须写进设计文档的三条「不可移植」声明
避免后续被当成 bug 修:
- 「召回不会把事件插回原时序」 —— dsh surface 不支持;只能
form:'recall'追加。 - 「L1 淘汰由 dsh compaction 负责,不由记忆插件负责」 —— 语义是摘要不是迁移。
- 「媒体块不支持」 —— dsh attachment 是 CAS,与 HA「块内联字节」的设计冲突。
这三条都应在插件 README 的第一屏写明,并在 systemPrompt.context() 的注入文案里
向模型说明(否则模型会按 HA 的语义预期行为)。
附录 A:证据索引
A.1 HologramAgent 侧(只读,未修改)
| 主题 | 文件:符号 |
|---|---|
MemoryBlock 定义 |
internal/memory/block.go:66 |
| 块 union 负载 / 无引用账本(否决 CAS) | internal/memory/block.go:55-56、62-65 |
| 段页式身份(FrameTS/PageSeq/CallID) | internal/memory/block.go:100-159 |
PutMemoryBlocks(空值不覆盖) |
internal/memory/block.go:203-300 |
AddMemoryBlockEdge |
internal/memory/block.go:497 |
memory_blocks DDL |
internal/memory/graph.go:2577 |
memory_block_edges DDL |
internal/memory/graph.go:320 |
RecallBlocks |
internal/memory/block_recall.go:78 |
RecallBlocksWithArbitration |
internal/memory/block_recall.go:229 |
largeTopK = 2000 |
internal/memory/block_recall.go:263 |
Centroid / BuildCentroid |
internal/memory/centroid.go:61/199 |
centerBoth |
internal/memory/centroid_store.go:443 |
AbstainCheck |
internal/memory/abstain.go:98 |
RecallBlocksGuarded |
internal/memory/abstain.go:179 |
arbitrate |
internal/memory/arbitration.go:375 |
parseFact / supersedes |
internal/memory/arbitration.go:88/148 |
QuerySymbols |
internal/memory/fusion.go:145 |
| 精确串布尔置顶 | internal/memory/fusion.go:427 |
RecallByScene |
internal/memory/scene.go:344 |
ContextEvent |
internal/agent/core/context.go:64 |
RelevanceContext |
internal/agent/core/context.go:145 |
Append |
internal/agent/core/context.go:967 |
InsertByTimestamp |
internal/agent/core/context.go:998 |
PruneWithProtected |
internal/agent/core/context.go:1075 |
planColdRecall / ColdRecallPlan |
internal/agent/core/context.go:453/489 |
ColdDoc / ColdEvent |
internal/memory/coldcontext/coldcontext.go:37/75 |
ContextToColdContext |
internal/memory/coldcontext/coldcontext.go:385 |
Peek / Consume |
internal/memory/coldcontext/coldcontext.go:480/526 |
docBlocksForEntry |
internal/memory/coldcontext/coldcontext.go:974 |
ColdEvent.InOut |
internal/memory/coldcontext/coldcontext.go:1259 |
executeDocTool |
internal/agent/core/toolcall.go:1090 |
restoreDocEvents |
internal/agent/core/toolcall.go:1428 |
recallByBlocks |
internal/agent/core/toolcall.go:1257 |
crossModalMarkdown |
internal/agent/core/crossmodal.go:256 |
| L2 预填充调用点 | internal/agent/core/tooldefs.go:51 |
buildSystemPrompt |
internal/agent/core/tooldefs.go:176 |
BlockID 派生 |
internal/memory/distill/blocks.go:99 |
FieldBlock.ToMemoryBlock |
internal/memory/distill/blocks.go:126 |
Extractor.Blocks |
internal/memory/distill/blocks.go:69 |
embedding.Provider SPI |
pkg/embedding/embedding.go(Input/Info/Provider/Register) |
http embedding provider 协议 |
internal/memory/vector/http_embedder.go:72-90 |
chineseclip provider(ONNX) |
providers/chineseclip/embedder.go:1(//go:build onnxruntime) |
StaticEmbedder(词向量兜底) |
internal/memory/static_embedder.go:32 |
| jieba 获取 | internal/memory/cut.go:73 GetJieba() |
| 依赖清单 | go.mod(go-sqlite3 / gojieba / onnxruntime_go) |
| 设计文档 | docs/zh/memory-restructure-plan.md, memory-blockflow-repair-plan.md, memory-usability-plan.md, recall-as-association.md, recall-capability-tiers.md, multimodal-space.md, context-overflow-l4-design.md, kernel-modularization-design.md, input-scheduler-design.md |
| 权威架构 | assets/docs/zh/ARCHITECTURE.md:180-330 |
A.2 dsh 侧
| 主题 | 文件 |
|---|---|
cordis Context / isolate / intercept |
@deepseek-ai/cordis/lib/types/context.d.ts |
Service 注册基类 |
@deepseek-ai/cordis/lib/types/service.d.ts:38 |
| 事件总线模式(emit/parallel/serial/bail/waterfall) | @deepseek-ai/cordis/lib/types/events.d.ts |
AgentRegistry |
@deepseek-ai/dsh-agent/lib/types/index.d.ts |
| Agent 生命周期事件全部枚举 | @deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts |
agent/pre-step → PreStepDecision |
同上('agent/pre-step' waterfall) |
Agent 的 send/followup/steer/inject/runMaintenance/cancel |
同上(declare module './types.ts' { interface Agent { ... } }) |
agent-loop 工厂 / TurnBoundaryProjection |
@deepseek-ai/dsh-agent-loop/lib/types/index.d.ts、agent.d.ts |
SystemPrompt.section/context/tools/variable/assemble |
@deepseek-ai/dsh-system-prompt/lib/types/index.d.ts:236-320(context() 在 :269) |
SECTION_ORDERS / CONTEXT_ORDERS |
同上 :119-163 |
system-prompt/assemble waterfall |
同上 :27 |
ToolRuntime.register/restrict/schemas |
@deepseek-ai/dsh-tools/lib/types/index.d.ts:637/654/712 |
ToolDefinition(output 强制,:117) |
同上 :115-200 |
tools/pre-execute / execute / post-execute / result / change |
同上 :48-105 |
Session / append / deriveMessages |
@deepseek-ai/dsh-session/lib/types/index.d.ts:135/347 |
SessionSurface(append-only) |
@deepseek-ai/dsh-session/lib/types/surface.d.ts:1-30 |
MessageBase(无时间戳) |
@deepseek-ai/dsh-llm/lib/types/message.d.ts:124-136 |
ContextForm / ContextFormed(含 'recall') |
@deepseek-ai/dsh-llm/lib/types/message.d.ts:46-93 |
MessageSourceMap |
同上 :98-105 |
CompactionEngine |
@deepseek-ai/dsh-compaction/lib/types/index.d.ts |
compactSurfaceRegion(摘要替换) |
@deepseek-ai/dsh-compaction-basic/lib/types/region.d.ts:40-60 |
CompactionTrigger |
@deepseek-ai/dsh-compaction/lib/types/index.d.ts:25 |
BasicCompactionEngine.summarize(唯一钩子) |
@deepseek-ai/dsh-compaction-basic/lib/types/index.d.ts |
ToolResultPruner.pruneSession |
@deepseek-ai/dsh-compaction-tool-result-pruner/lib/types/index.d.ts |
AttachmentStore(CAS) |
@deepseek-ai/dsh-attachment/lib/types/index.d.ts |
Storage hub / StorageBackend / KvFacet |
@deepseek-ai/dsh-storage/lib/types/index.d.ts、backend.d.ts |
DomainFacility / defineDomain |
@deepseek-ai/dsh-storage-domain/lib/types/index.d.ts |
| JSON backend(整 unit 一文档) | @deepseek-ai/dsh-storage-json/lib/types/index.d.ts |
node:sqlite 先例 |
@deepseek-ai/dsh-session-query-sqlite/lib/types/schema.d.ts:2 |
SessionQueryEngine(FTS5 全文检索) |
@deepseek-ai/dsh-session-query/lib/types/index.d.ts |
TokenMeter |
@deepseek-ai/dsh-token-meter/lib/types/index.d.ts |
LlmRuntime / LlmAdapter / stream |
@deepseek-ai/dsh-llm/lib/types/index.d.ts:130/245 |
Scoped / ScopeKey |
@deepseek-ai/dsh-scope/lib/types/index.d.ts |
| Loader / Include / Entry | @deepseek-ai/cordis-plugin-loader/lib/types/index.d.ts、cordis-plugin-include/lib/types/index.d.ts |
dsh-base(无运行期 API,纯 patch bundle) |
@deepseek-ai/dsh-base/lib/types/index.d.ts |
A.3 环境实测
| 项 | 结果 | 方法 |
|---|---|---|
node:sqlite 可用 + FTS5 |
✔ 通过(CREATE VIRTUAL TABLE ... USING fts5 + MATCH) |
node -e 直跑,Node v22.23.3 |
onnxruntime-node npm 包 |
✘ 未安装 | find / -name onnxruntime-node -type d 无结果 |
libonnxruntime.so |
✔ 存在两处:/opt/onnxruntime/(24MB)、/usr/lib/node_modules/sherpa-onnx-linux-x64/(27MB,导出 OrtGetApiBase) |
ls + nm -D |
| jieba (JS) | ✘ 未安装 | ls /usr/lib/node_modules | grep -i jieba 无结果 |
Python onnxruntime |
✔ 1.28.0 | python3 -c "import onnxruntime" |
| dsh 版本 | 0.2.1-rc.2 |
@deepseek-ai/dsh/package.json |
| cordis 版本 | 4.0.5 |
@deepseek-ai/cordis/package.json |
附录 B:本文的取证纪律
- 所有 dsh 接口结论均来自
lib/types/*.d.ts(权威接口),未从lib/*.js实现倒推。 - 所有 HA 结论均来自源码或
docs/zh/*.md,并标注了符号名(行号仅作辅助锚点, 文档本身也说明「行号随改动漂移,函数名才是稳定锚点」)。 - 标「推断」的地方只有三处:
turn≈FrameTS的映射、Intl.Segmenter的中文分词质量、 §6.2 的人天估算。其余均为可直接复核的事实。 - HA 仓库(
/home/program/TrueAgent/)全程只读,未做任何修改(git status无输出)。