长期记忆
Spark Work 三层长期记忆系统:User / Project / Agent 作用域隔离不串味,后台独立 LLM 抽取(与 OpenAI Memory、Mem0 同款架构)不干扰主对话,FTS5+sqlite-vec 混合检索,自动整合进化,search_memory / recall_memory 工具按需深挖。
默认情况下每次新会话都从零开始:你上次说过的偏好、项目里的技术选型、长期约定, 下个会话就没了。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 天)来快速观察整合效果。
7. 失效与实体图:旧记忆不删除,但会标失效
- bi-temporal 字段:
memory_entry有valid_from/invalid_at/superseded_by。事实失效时不删文件, 而是写入invalid_at与被谁取代的 id。 - 失效后仍可读:检索默认过滤掉失效条目(
invalid_at IS NULL), 但你用recall_memory显式读它时仍能拿到正文,并带失效警示, 便于理解「当初为什么这么定」。 - 实体关联图:
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(实体图)。 - 本地优先:没有云端同步,记忆不会离开本机。
- 两道自动闸门保护隐私:敏感内容(密钥 / 令牌 / 私钥 / 隐私信息)在写入前被拒; 瞬时状态(「今天」「这次」之类)不落库。
- 不进 prompt 的东西:记忆正文不会整篇塞进 system prompt,只注入摘要行; 模型需要细节时得显式
recall_memory,这一步在日志里可见(便于审计 Agent 到底读了什么)。 - 删干净:面板删除会同时删数据库行与 markdown 文件;归档则保留文件、 让条目退出检索与注入。
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 → 记忆(长期记忆面板 → 配置)
常见问题
展开 6 条常见问题
- 记忆会不会被别的项目看到?
不会。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)。可以手工编辑文件,但更推荐用面板编辑,避免文件与索引不一致。
- 怎么让一条记忆失效?
演化机制会在新事实与旧记忆冲突时把旧条目标记失效(写 invalid_at 与 superseded_by),文件保留但退出检索与注入;不需要历史时可在面板里归档(退出检索、保留文件)或删除(文件和记录一起删)。