长期记忆
Spark Work 三层长期记忆完整指南:User / Project / Agent 作用域隔离不串味,后台独立 LLM 抽取(与 OpenAI Memory、Mem0 同款架构)不干扰主对话,五道写入闸门,FTS5 + sqlite-vec 混合检索经 RRF 融合与时间衰减后按 token 预算注入,整合 job 自动合并重复并把零散反馈升华成候选,有效期到期顶替旧事实,旧记忆只标失效不删除。含生命周期状态机、CAS 并发保护、失效与实体图、配置面板逐项说明、速查表与 12 行排查表。
默认情况下每次新会话都从零开始:你上次说过的偏好、项目里的技术选型、长期约定, 下个会话就没了。Spark Work 内置三层长期记忆:记忆以 markdown 文件为事实来源、 以本地 SQLite 为索引,由每轮对话结束后触发的后台抽取写入, 在每轮对话开始时按相关度检索并注入 system prompt。
1. 三层作用域:键名、scope_ref 与落盘位置
scope只有三个取值:user、project、agent。 真正决定「这条记忆属于谁」的是scope_ref:
| scope | scope_ref | markdown 落盘位置 | 可见范围 |
|---|---|---|---|
user | NULL | ~/.spark-agent/memory/user/<id>.md | 所有项目、所有 Agent 共享(身份、跨项目偏好) |
project | workspaceId | <workspace>/.spark-agent/memory/<id>.md | 仅该工作区;跟随代码目录走,可随仓库迁移 |
agent | agentId | ~/.spark-agent/memory/agent/<agentId>/<id>.md | 仅该 Agent 角色(含团队成员各自的 agent scope) |
每个 scope 目录下还会维护一份MEMORY.md索引(每行- [名称](文件.md) — 描述),方便你直接翻目录理解 Agent 记住了什么。 记忆文件用「先写.tmp再 rename」的原子写入,避免半截文件。
两个真实约束:project 记忆必须有工作区——会话没绑定工作区时, 候选里 scope=project的条目会被丢弃(写入端也会报 VALIDATION_FAILED);scope_ref 的空串会被归一成 NULL,避免出现既不是 NULL 也不是合法 ID 的 「孤儿 project 记忆」。
另一条容易混淆的维度是type,它和 scope 正交,取值四个:user(关于用户的事实)、feedback(行为守则,如「先给结论」「别自动提交」)、project(项目决策)、reference(外部资料线索)。 scope 决定「谁能看到」,type 决定「怎么处理」——例如 feedback 类型永远全量注入,不靠检索。
2. 写入:每轮结束的后台抽取与五道闸门
触发时机:宿主会话的每一轮 turn 结束、且助手产生了非空文本时, 运行时异步调用记忆写入流程(fire-and-forget:任何异常只写日志,绝不影响主对话)。 团队模式下,每个成员的回复也会按该成员的 agentId 单独触发一次抽取, 所以成员发现的技术决策同样会沉淀。
用哪个模型:读取设置项memory.extractionProviderId与 memory.extractionModel,通过模型服务发起一次真实 LLM 调用。 支持 Anthropic 原生(/v1/messages)与 OpenAI 兼容(/chat/completions) 两种协议,不支持 responses API。两个设置项都没配时,自动回退到当前会话正在用的对话模型 (@mention 到成员时用该成员的模型),因此零配置也能用;模型不可用时这一轮就不产出记忆, 日志里会写 memory extraction LLM unavailable。
抽取结果要连续过五道闸门,任何一道没过就丢弃:
- 瞬时数据:命中「临时/当前这次/今天」这类模式的名字或描述被拒,避免把一次性状态当长期事实。
- 置信度:模型给的
confidence必须 ≥0.6。 - 去重与合并:与该 scope 内已有记忆比对;V2 会用 FTS 召回相似条目,让模型判定 ADD / UPDATE / DELETE / NOOP,而不是无脑新增。
- 配额:默认上限
user 100/project 200/agent 50(可用设置项memory.quota覆盖单层数值)。 - 敏感内容:命中密钥、令牌、私钥等模式(以及隐私类模式)的记忆被拒,不落盘、不建索引。
通过后按顺序落三处:markdown 文件(事实来源)→ SQLite 行(memory_entry, 同事务维护全文索引)→ 刷新MEMORY.md。落库时会把抽取出的实体(人名、库名、 模块名、系统名)写进实体关联图,供后续检索做一跳扩展。
3. 检索:FTS5 + 向量两路并行,RRF 融合后重排
- FTS5 全文:
memory_fts是与memory_entryrowid 对齐的 contentless FTS5 表,索引 name / description / 正文;中文在写入与查询两侧都做逐字预分词 (两侧不一致会直接查不到),排序用bm25()。 - 向量检索:
memory_vec是 sqlite-vec 的vec0虚拟表, 维度取决于你配的 embedding 模型,运行时惰性建表并把维度记在设置项memory.vecDimension; 换模型导致维度变化时会重建表,旧向量丢弃后由后台懒回填补齐。查询走 KNN(embedding MATCH ? AND k = ?),先多取候选再回表按 scope / type / 归档 / 失效过滤。 - 融合与重排:两路各取 top20,用 RRF 融合(
score = Σ 1/(60 + rank), 同一条双路命中则相加),再乘时间衰减与置信度:finalScore = rrf × exp(-λ × 距今天数) × confidence,λ 默认0.01(设置项memory.timeDecayLambda,数值越大旧记忆沉降越快)。 - 过滤:只召回
archived = 0且invalid_at IS NULL(仍有效)的条目。 - 降级链:向量不可用(未配 embedding 或调用失败)→ FTS-only; FTS 本身抛错且没有向量 → 告诉调用方回退到「全量 + 类型优先级」的注入方式。 每一级降级只写日志,界面上不会报错。
4. 会话注入:每轮开始注入,不是开场注入一次
每次开始新的一轮对话时(不是每个会话只做一次),运行时都会重新组装 memory block:
- feedback 全量:所有 feedback 类型的记忆都注入(按更新时间倒序), 不依赖检索命中——行为守则不能靠运气。这个排序刻意不依赖
hit_count, 以保证同一会话里重复渲染的字节稳定,不破坏提示词前缀缓存。 - 其它类型按相关度取子集:以「Agent 名称 + Agent 描述 + 工作区目录名」作为种子查询, 走上面的混合检索取相关条目;检索不可用或无结果时回退到全量 + 类型优先级排序。
- token 预算:默认上限 4000 token(设置项
memory.maxInjectTokens), 超出时按feedback > user > project > reference的优先级裁剪。 - 结构:注入成
<user-memory>、<project-memory workspace="...">、<agent-memory>三段, 末尾告诉模型「需要更多用search_memory,需要正文用recall_memory」。 记忆文本里的尖括号会被替换,防止内容破坏这些结构标签。 - 来源提示:同时注入一段 [Memory Provenance] 约束:记忆摘要可能是模型生成的, 不得当成用户原话或最终决定;与用户当前明确说法冲突时以当前为准。
5. Agent 可用的记忆工具(只读)
| 工具 | 参数 | 返回 |
|---|---|---|
mcp__spark_memory__search_memory | query(必填,1–500 字符)、type(可选:user/ feedback/project/reference)、limit(默认 8,最大 20) | 命中的记忆 id + 摘要列表;检索不可用时返回明确的「已降级」提示 |
mcp__spark_memory__recall_memory | id(必填,取 search_memory 返回或注入摘要里方括号内的 id) | 该条记忆的完整 markdown 正文(含 Why / How to apply);已失效的条目仍返回正文,但会前置「已于某时失效、已被某条取代」的警示 |
调用recall_memory会把该条记忆的hit_count+1, 但不会改 updated_at——同样是出于保护提示词缓存的考虑。
这三条运行时路径都会挂上同一组工具,语义与检索后端完全一致:
- Claude SDK 路径:进程内 MCP server(直接访问本地数据库,没有子进程)。
- Codex CLI / Claude CLI 路径:
spark_memorystdio 子进程,通过本地 Platform Bridge 的 HTTP RPC 回到主进程。 - Spark 引擎路径:由引擎运行时按内置 server 名单注入同名工具。
重要边界:Agent 侧没有写入或删除记忆的工具。 记忆写入只来自后台抽取,增删改只在「设置 → Agent → 记忆」面板里由人完成。 这样设计是为了防止 Agent 在对话中把临时信息固化成长期事实。
6. 整合 job:合并重复与升华规律
后台还会周期性跑一次「整合」:在记忆启用时,于每轮对话开始阶段异步触发, 对 user / project / agent 三层分别判断(fire-and-forget,失败只写日志)。
- 触发条件:该层记忆条数达到
memory.consolidationThreshold(默认 30), 且距上次整合已超过memory.consolidationIntervalDays(默认 7 天);memory.consolidationEnabled默认开启。上次整合时间按lastConsolidationAt:<scope>:<scopeRef>记录,因此三层各自计时。 - MERGE:把多条语义重复的记忆合并成一条(保留其中一条,其余归并),减少冗余。
- ELEVATE:把多条零散反馈升华成一条更通用的新记忆,正文末尾会附「升华来源」列表, 指向原始条目,便于追溯。
除了整合,抽取阶段本身还有演化判定(ADD / UPDATE / DELETE / NOOP),两者共同避免记忆无限膨胀。 真机调试时可以把阈值调小(例如阈值 2、间隔 0.01 天)来快速观察整合效果。
候选确认区:ELEVATE 不直接落库
升华出来的记忆不会自动生效:ELEVATE 的产物先落进「候选区」,等你在界面上确认后才变成正式记忆。候选卡片会显示名称、描述、正文与来源条数;确认后写入的记忆类型为 feedback、来源标注 consolidation、正文带「## 升华来源」段,候选随即从待确认列表消失。
三个容易碰到的情况:
- 拒绝:候选消失,主列表不新增条目。
- 同名冲突:如果你先手工创建了一条同名记忆再确认候选,会提示「已存在同名记忆且内容不符」并保持候选待确认,便于改名或改判,而不是硬写一条重复的。
- 敏感内容:即使你人工点了确认,命中密钥 / 凭据形态的内容依然被拒绝保存——这道闸门不会因为「人已经看过了」而放行。
7. 生命周期加固:有效期、失效与并发保护
有效期
记忆可以带一个有效期,按「最后适用日」口径写入:填 2026-10-31 表示这一天仍适用(界面上显示为「最后适用日 2026/10/31(该日内仍适用)」),次日零点起退出注入与检索。不填就是长期记忆,详情页不显示有效期行。
到期不等于删除:条目仍在列表里、详情仍可查、状态如实标注,只是不再参与检索与注入。
到期顶替:新事实不会被旧条目拖住
这里有一个专门修过的语义陷阱。如果一条带有效期的记忆到期了,而后续对话又产生了同名的新事实,早先的实现会把新内容合进那条已到期条目里,结果是新事实跟着旧条目一起对所有检索隐身,用户也没有恢复路径。
现在的行为是:新事实作为独立条目正常写入且可检索可见,旧条目自动转入失效状态,详情页能看到「已失效」。失效之后你还可以继续手工创建同名记忆,不会被 ALREADY_EXISTS 挡住。
底层是 bi-temporal 字段:memory_entry 带 valid_from / invalid_at / superseded_by,事实失效只写 invalid_at 与被谁取代的 id,不删文件——以后回看时还能知道当初为什么这么定。
并发保护:CAS 版本冲突
同一条记忆在多个窗口或入口被同时编辑时,后保存的一侧不会覆盖先保存的内容,而是收到明确的版本冲突提示(会告知当前版本号与你的基准版本,并说明本次改动未保存),要求刷新后重试。
注入的四种状态
注入结果现在有明确的状态区分,而不是「要么有要么没有」:
| 状态 | 含义 |
|---|---|
matched | 命中相关记忆并注入,记忆面板对应条目命中计数 +1 |
empty | 该轮没有相关记忆,正常不注入、不报错 |
disabled | 记忆总开关关闭,无注入,也不会给出「记忆不存在」这类误导提示;重新开启即恢复 |
expired | 条目仍存在但已过期,不参与注入与检索,详情仍可见 |
日志治理
默认日志级别下不记录 prompt 原文、记忆正文,以及模型返回的候选正文预览,只允许名称 / 摘要级信息(debug 级不受此限制)。这条约束的意义是:即使把日志发给别人排查问题,也不会顺带泄露你的私有内容。
删除与归档的差别
| 操作 | 数据库 | markdown 文件 | 检索与注入 |
|---|---|---|---|
| 归档 | 标记 archived: true | 保留(frontmatter 同步) | 退出 |
| 删除 | 删除行 | 删除文件 | 退出 |
删除会同时清理数据库与磁盘文件,并且提示如实反映结果:如果文件清理没完成,会提示 blocked_locally 状态,而不是报一个纯成功。归档是「先留着」的正确选择——文件与索引行保持一致,旧版本 CLI 也不会把它复活。
实体关联图
memory_entity(规范化去重的实体)+ memory_entity_link(记忆 ↔ 实体多对多)。实体名做小写与别名归一(如 Arco Design 与 @arco-design/web-react 都归到 arco),同一 scope 内按归一化名去重;检索命中记忆后可顺着共享实体做一跳扩展,把「讲同一套技术栈」的记忆一起带出来。
8. 配置:设置 → Agent → 记忆
入口只有一处:桌面端「设置」页左侧 Agent 分组下的「记忆」(面板标题为「长期记忆」)。 当前没有 Web 管理端或独立记忆页——应用内虽有memory视图路由,但它同样渲染设置页的 记忆分区,不是一个单独的界面。面板顶部有「新增」「配置」两个按钮,页面主体是记忆列表与筛选, 所有开关都在「配置」抽屉里:
| 设置项(category = memory) | 含义 | 默认 / 取值 |
|---|---|---|
enabled | 总开关:关闭后注入 / 写入 / 整合全停 | 默认开 |
extractionProviderId/extractionModel | 抽取用的 Provider 与模型;可清除,清除后回退到对话模型 | 空(回退对话模型) |
embeddingProviderId/embeddingModel | 向量检索用的 Provider 与模型,仅支持 OpenAI 兼容(Anthropic 不提供 embedding) | 空(FTS-only) |
consolidationEnabled | 是否启用整合 job | 开 |
consolidationThreshold/consolidationIntervalDays | 整合触发阈值(条)与间隔(天) | 30 / 7 |
maxInjectTokens | 每轮注入的记忆 token 上限 | 4000 |
timeDecayLambda | 时间衰减系数,越大旧记忆沉降越快 | 0.01 |
quota | 按 scope 的条数配额覆盖(对象:如 {"user": 100, "project": 200, "agent": 50}) | 100 / 200 / 50 |
vecDimension/ftsBackfillDone | 内部状态:向量表维度、FTS 存量回填是否完成 | —(不要手改) |
- 「测试抽取配置」:按下会真的发起一次 LLM 调用,返回模型名与一段样例输出; 失败时给出原因(例如 settings 未配且当前没有可回退的对话模型)。配错 key 时用它主动验证, 不用等「记忆静默不生成」。
- 「重建向量索引」:换了 embedding 模型或怀疑向量表异常时用, 重建后会提示「后台正按新模型回填全部记忆,条目多时可能持续几分钟,期间向量检索会逐步恢复」。
- 改完何时生效:抽屉底部的提示写明「配置改完下一个新会话生效」—— 注入是在新一轮开始时重算的。
面板本身可以做的事:
- 筛选:按 scope(User / Project / Agent)、按 type(全部 / User / Feedback / Project / Reference)、按项目、按 Agent、
仅有效或含失效、按 name / description 搜索。 - 批量操作:勾选后批量归档、批量删除。
- 单条详情:编辑描述与正文(markdown)、保存、归档、删除;失效条目会显示失效时间与被谁取代。
- 手动新增:指定 scope(Project / Agent 需要选具体 workspaceId / agentId)、type、
name、描述、正文与实体(逗号分隔)。
9. 存储与隐私
- 双层存储:markdown 文件是事实来源(可读、可直接编辑、项目级跟随代码目录),SQLite 存索引与元数据。相关表:
memory_entry(条目)、memory_fts(全文)、memory_vec(向量)、memory_entity/memory_entity_link(实体图)。 - 本地优先,同步是可选项:默认不联网,记忆不离开本机。账号同步里
memory是 7 个类别之一,默认关闭;打开并手动执行同步后才会随账号上云(跨设备冲突由服务端决胜,不是本地 CAS)。 - 两道自动闸门保护隐私:敏感内容(密钥 / 令牌 / 私钥 / 隐私信息)在写入前被拒;瞬时状态(「今天」「这次」之类)不落库。
- 不进 prompt 的东西:记忆正文不会整篇塞进 system prompt,只注入摘要行;模型需要细节时得显式
recall_memory,这一步在日志里可见(便于审计 Agent 到底读了什么)。
10. 常见坑与排查
| 现象 | 排查方向 |
|---|---|
| 聊了半天一条记忆都没有 | 先确认总开关;再看日志里是否出现memory extraction triggered与随后的 memory extraction LLM unavailable(抽取模型不可用)。也可以点「测试抽取配置」直接验证连通性 |
| 对话很短/只有工具调用,没有记忆 | 抽取需要助手产生了非空文本才触发;纯执行型回合可能不产出候选 |
| 候选没通过 | 依次检查:是否被判定为瞬时数据、confidence是否低于 0.6、是否命中敏感词、该层配额是否已满 |
| 项目记忆写不进去 | 该会话没有绑定工作区。project scope 必须带workspaceId,未绑定时候选会被丢弃 |
| 语义检索不生效,只有关键词能中 | 没配 embedding(正常降级为 FTS-only);或 sqlite-vec 加载失败(日志 sqlite-vec load failed, vector search disabled)。换过 embedding 模型后要点「重建向量索引」 |
| Agent 说「我不记得」 | 本轮注入为空(三层都没条目,或 token 预算裁掉了);可以让它调search_memory 主动检索确认,而不是直接下结论 |
| 记了但下次换个说法就找不到 | FTS 是关键词匹配;要让「组件写法」召回「偏好函数式组件」,必须配 embedding 才有语义通道 |
| 能不能让 Agent 自己「记住这个」 | Agent 没有写记忆的工具。它能做的是把信息留在对话里,由后台抽取判断是否值得长期保存;要立刻固定,请到记忆面板手动「新增」 |
| 候选确认后主列表没有新增 | 可能命中了同名冲突或敏感内容闸门,界面会给出对应提示并保持候选待确认,不会静默丢弃 |
| 保存记忆时提示版本冲突 | 该条在别处已被改过。刷新拿到最新版本后重试,本次改动不会覆盖他人的内容 |
| 记忆到期后搜不到了 | 这是预期行为:到期即退出检索与注入,但条目与详情仍在;需要继续使用就重设有效期 |
| 详情抽屉一直转圈 | 详情加载失败会在几秒内给出错误态并提供关闭重试,不会永久转圈;若长时间无响应,检查该条是否已被其他入口删除 |
速查表
| 项目 | 内容 |
|---|---|
| 作用域键名 | user / project / agent(scope_ref 分别为 NULL / workspaceId / agentId) |
| 记忆类型 | user / feedback / project / reference(与 scope 正交) |
| 落盘位置 | ~/.spark-agent/memory/user|agent/<agentId>/ 与 <workspace>/.spark-agent/memory/ |
| 抽取触发 | 每轮 turn 结束后异步触发(宿主 + 团队成员各自触发) |
| 抽取模型 | memory.extractionProviderId + extractionModel;空则回退对话模型 |
| 置信度阈值 | confidence ≥ 0.6 |
| 配额默认 | user 100 / project 200 / agent 50 |
| 检索 | FTS5 BM25 + sqlite-vec KNN → RRF(k=60) → exp 时间衰减(λ=0.01) × confidence |
| 注入预算 | 默认 4000 token,优先级 feedback > user > project > reference |
| 整合默认 | 开启;阈值 30 条、间隔 7 天(三层分别计时) |
| Agent 工具 | mcp__spark_memory__search_memory / recall_memory(只读) |
| 配置入口 | 设置 → Agent → 记忆(长期记忆面板 → 配置) |
| 有效期口径 | 「最后适用日」,按日精度;到期退出注入与检索,但条目不删除 |
| 失效字段 | valid_from / invalid_at / superseded_by(bi-temporal,不删文件) |
| 候选区来源 | 整合 job 的 ELEVATE 产物,人工确认后才落库为正式记忆 |
| 并发保护 | CAS 版本冲突:后保存被拒并提示刷新,不覆盖他人改动 |
| 注入状态 | matched / empty / disabled / expired |
| 日志约束 | 默认级别不记录 prompt 原文、记忆正文与候选正文预览 |
| 账号同步 | memory 是 7 个同步类别之一,默认关闭;跨设备冲突由服务端决胜 |
上手清单:开启并验证 Spark Work 长期记忆
从打开开关到确认记忆真的写入并注入
- 打开「设置」→ Agent 分组 → 「记忆」,确认「长期记忆」总开关是开的
- 点「配置」,在「抽取模型」里选 Provider 与模型(留空则回退到对话模型)
- 点「测试抽取配置」确认返回正常;想启用语义检索再配「向量模型」,必要时点「重建向量索引」
- 在一个绑定了工作区的会话里聊一轮,明确说出你要它长期记住的偏好或项目决策
- 回到记忆面板,按 scope / type 筛选,确认新增了条目(Project 层级需要该会话已绑定工作区)
- 开一个新会话,直接问它「我之前说过的偏好是什么」,验证注入生效
- 需要 Agent 主动深挖时,让它调用 search_memory / recall_memory 取更多细节
常见问题
记忆会不会被别的项目看到?
不会。project 记忆用 scope_ref = workspaceId 隔离,只在同一工作区可见,文件也存在该工作区的 .spark-agent/memory/ 下;user 记忆跨项目共享;agent 记忆按 agentId 隔离。切换工作区时项目记忆自动不可见。只有你主动开启账号同步后,这些内容才会随账号上云。
是谁决定「该不该记」?Agent 会看到记忆正文吗?
后台抽取决定,Agent 不参与:每轮结束后台异步用抽取模型判断候选,过五道闸门后落库。Agent 侧只有 search_memory(拿 id + 摘要)和 recall_memory(拿正文)两个只读工具,没有写入权限。system prompt 里默认只有摘要行。
配错抽取模型会怎样?
这一轮不产出记忆,主对话完全不受影响(fire-and-forget,异常只写日志)。在「设置 → Agent → 记忆 → 配置」里点「测试抽取配置」会真实调用一次模型并返回结果或失败原因,不用等记忆静默不生成才发现。
不配 embedding 模型能用吗?
能,会降级成 FTS-only:只有关键词命中,没有语义召回。配了 OpenAI 兼容的 embedding 后会自动建向量表并后台回填;换模型后需要点「重建向量索引」重新生成向量。
记忆存在哪里?我能直接改吗?
正文是 markdown:user 在 ~/.spark-agent/memory/user/,project 在工作区 .spark-agent/memory/,agent 在 ~/.spark-agent/memory/agent/<agentId>/;索引与元数据在本地 SQLite(memory_entry / memory_fts / memory_vec / memory_entity)。可以手工编辑文件,但更推荐用面板编辑,避免文件与索引不一致。
候选区里的记忆为什么没有自动生效?
因为升华出来的记忆默认不自动落库。ELEVATE 的产物只是候选,需要你在候选区点确认才成为正式记忆。这样设计是为了防止模型把一次性的说法升华成一条长期守则,而你却不知情。
多个窗口、或多台设备同时改同一条记忆会怎样?
两种情况机制不同。同一台机器上并发编辑走 expectedVersion CAS:后保存的一侧被拒并提示刷新,不会覆盖先保存的内容,也不依赖时间戳。跨设备走账号同步的服务端决胜:本地较新的改动会保留、等下一轮同步裁决,不会被旧数据直接覆盖。