- AI 应用
- 人工智能
- AI Agent
- 本地部署
- 前端
- 后端
- 工作流自动化
【免费下载链接】ekko-studio
Ekko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.
本篇技术指南以 Ekko Agent 记忆系统规划文档 为核心骨架,结合 ekko-agent 包中src/memory/与src/database.ts的完整源码实现,系统讲解 Ekko Agent 如何用唯一一张记忆卡片集合memory_nodes承载全部长期记忆:从前台运行链路、四个记忆工具的参数契约,到受控 kind 与 canonical key 的生成规则、并发安全的 revision 机制,再到ekko.db的表结构与崩溃安全迁移策略。读完本文,你将掌握 Ekko Agent 记忆系统的完整数据流,能准确理解memory_search/memory_get/memory_write/memory_forget的正确调用姿势,以及数据库在旧进程仍写入时为何宁可阻止启动也不重建。
设计哲学:只有一种可操作的长期记忆
Ekko Agent 的记忆模型遵循一条极简且严格的原则:整个 Agent 只维护一种可操作的长期记忆——memory_nodes中的记忆卡片。系统内不存在独立的审批队列、后台记忆评审任务,也没有隐藏的 Session 摘要作为第二套记忆。
这一点可以从源码中得到直接印证。在 types.ts 中,MemoryNode定义了记忆卡片的完整结构:id、key(服务端生成的 canonical key)、revision(乐观锁版本)、status(active/superseded/expired/deleted)、domain、categoryPath、type、valueJson、confidence、importance、sourceMessageIds(证据来源)等字段,它就是记忆的唯一事实载体。
与之相对,memory_messages表只保存受信任的对话证据(MemoryMessage,见 types.ts),用途是让记忆卡片能够追溯到具体的用户/助手消息;它不参与形成另一套记忆。也就是说:
- 证据(对话消息)→ 存在
memory_messages; - 记忆(可检索、可注入、可增删的持久知识)→ 只存在
memory_nodes; - 所有增删改行为 → 直接落盘到
memory_audit_events审计表,不存在待审批状态。
前台运行链路:记忆的增删改只有一条路
原文档给出了记忆系统的核心运行链路,这里完整保留并结合源码展开:
Ekko Agent turn -> 保存当前用户消息证据 -> 按宿主授权的 scope 检索 active memory_nodes -> 注入相关记忆卡片 -> 主模型调用 memory_search / memory_get / memory_write / memory_forget -> memory_write / memory_forget 在当前 run 中直接生效 -> run 完成后仅补录助手消息证据这条链路对应的实现事实:
- 保存用户消息证据:
MemoryService.captureMessages()(service.ts)在 turn 开始阶段把当前用户消息按确定性 ID 写入memory_messages,作为后续sourceMessageIds的唯一合法来源。 - 按 scope 检索 active 记忆:自动召回受宿主授权 scope与token budget双重约束,默认自动召回 token 预算为
DEFAULT_AUTOMATIC_MEMORY_TOKEN_BUDGET = 4000,最近消息证据上限为DEFAULT_MEMORY_RECENT_MESSAGE_LIMIT = 20,工具检索结果上限为DEFAULT_MEMORY_SEARCH_RESULT_LIMIT = 50(见 config.ts)。 - 注入记忆卡片:
buildMemoryContextPrompt()与selectMemoryNodesByTokenBudget()(见 service.ts)把候选记忆组织成上下文注入主模型。 - 模型调用四个记忆工具:
memory_search/memory_get/memory_write/memory_forget由createMemoryTools()注册(tools.ts),其中memory_write与memory_forget在writable: false时不会暴露,从而实现只读模式。 - 当前 run 直接生效:写操作不做后台异步、不进入审批队列;run 完成后仅补录助手消息证据。
一个关键保证是:工具失败时 Runtime 立即返回真实错误。例如memory_forget在缺少reason、缺少显式遗忘意图时会直接返回失败(tools.ts),模型不允许继续声称操作成功。
四个记忆工具:参数契约与正确用法
memory_search:结构化检索与全量枚举
memory_search的核心语义是:已知类别优先用结构化kinds,开放问题才用queryText(tools.ts)。其参数包括:
| 参数 | 类型 | 说明 |
|---|---|---|
all | boolean | 枚举当前宿主授权 scope 下所有 active 记忆,不做相关性过滤 |
queryText | string | 开放问题的自然语言查询 |
kinds | array | 精确匹配一个或多个受控 kind,如profile_name、home_location |
domain/categoryPathPrefix/types/key | string/array | 结构化过滤条件 |
tags/entities | array | 标签与实体过滤 |
valueJson | any | 按结构化值过滤 |
limit | number | 1~50 |
值得注意的实现细节:中文"所有/全部记忆"与英文 "all/every memories" 等旧式 list-all 表达会被自动转换为全量枚举。isListAllMemoryQuery()用正则匹配了中英文两类表达(tools.ts),命中后queryText被置空、listAll置真,从而执行不带相关性过滤的全量枚举——这正是文档所述"不得把 list-all 意图写成 queryText"的底层原因。
memory_get:按 id 取整张卡片
memory_get按id返回完整记忆卡片(含 canonical key 与当前 revision),未提供id时退化为一次 limit=2 的精确检索(tools.ts)。它的核心用途是为后续写操作获取targetId与expectedRevision。
memory_write:原子批量写入
memory_write支持create/update/supersede/expire四种操作,并优先推荐operations数组的批量形态:整个数组先整体校验、再在一个事务中原子提交,任一操作非法则整批回滚(tools.ts)。成功返回done: true,并明确提示"此更新已完成,不要重复执行"。
写操作的关键约束(均有源码强制校验):
create必须提交受控kind;对 itemized 类 kind 还必须提供itemKey(短而稳定的概念/实体标识,如kind=project_context, itemKey=hermes_studio,严禁使用句子、时间戳或随机值)。update/supersede/expire必须携带来自memory_search/memory_get的targetId与expectedRevision(tools.ts)。node中title与content必填,结构化值必须用字段名valueJson(兼容旧字段名value,见normalizeToolMemoryNode)。sourceMessageIds只能从宿主提供的受信任用户证据中选取,模型不能伪造来源(tools.ts)。explicitUserIntent仅在用户明确要求记住/修改/纠正/删除时才置 true;若宿主设置了memoryWritePolicy === 'explicit-only',未显式意图的写入会被直接拒绝(tools.ts)。
memory_forget:精确删除与全量清除
memory_forget只服务于用户的显式遗忘请求(context.memoryForgetIntent !== true时直接拒绝),支持四种形态:按all: true全量删除、按targets数组批量精确删除、按id + expectedRevision删除、按domain/key等宽泛选择器删除(tools.ts)。mode可选soft(保留审计状态)或hard(同时清理节点、FTS 与 embedding)。
受控 kind 与 canonical key:服务端生成的槽位机制
文档强调"创建时模型提交受控kind和可选itemKey,服务端生成 canonical key"。这背后的实现是MEMORY_SLOTS槽位表(schema.ts),每个受控 kind 都被映射到固定的槽位定义(key、domain、categoryPath、type、是否 itemized):
| kind | canonical key(非 itemized) | domain / categoryPath | 类型 |
|---|---|---|---|
interaction_contract | interaction.relationship | interaction / relationship | preference |
profile_name | profile.identity.name | profile / identity | fact |
home_location | profile.location.home | profile / location | fact |
occupation | profile.occupation | profile / occupation | fact |
timezone_preference | preference.timezone | preference / timezone | preference |
language_preference | preference.language | preference / language | preference |
hard_constraint | constraint.hard | constraint | constraint |
long_term_goal | goal.long_term | goal / long_term | task |
durable_decision | decision.durable | decision | decision |
custom_fact | custom.fact | custom | fact |
| … | … | … | … |
对于 itemized kind(如accessibility_need、communication_preference、project_context、food_avoidance、personal_relationship等),canonical key 由槽位key + ":" + 规范化后的 itemKey组成(schema.ts)。itemKey会经过 NFKC 归一化、小写化、空白/分隔符替换为下划线、剔除非法字符等处理(normalizeCanonicalItem,schema.ts),保证同一概念得到稳定 key。
canonicalizeMemoryDraft()还会对部分 kind 做受控值校验:例如interaction_contract必须包含userRole/assistantRole/addressUserAs至少一个字段,home_location必须含city或location(schema.ts)。normalizeMemoryNode()则负责最终节点归一化,并依据explicitUserIntent调整默认置信度(显式意图 0.98,默认 0.7)与重要性(0.9 / 0.6)(schema.ts)。
增删改规则:noop、supersede 与并发防护
文档的增删改规则全部能在源码中找到对应实现:
- 同槽位同内容返回 noop:写入时若目标槽位的 active 卡片内容与提交内容一致,服务端判定无变更,返回
action: 'noop',不产生新 revision。 - 同槽位新内容建立下一 revision 并 supersede 旧版:
MemoryStoreMutation的supersede形态(types.ts)会把旧节点标记superseded、新节点记录supersedesId并递增 revision。 - 更新、过期和按 id 删除必须携带
expectedRevision:updateNodeStatus/deleteNode在 revision 不匹配时返回失败,防止并发覆盖(types.ts)。 sourceMessageIds只能来自宿主提供的当前用户证据:前文已述,parseMemoryMutation会逐一校验 ID 是否在context.sourceMessageIds白名单内。- soft delete 保留审计状态;hard delete 清理节点、FTS 与 embedding:从 store.ts 的
DELETE FROM memory_nodes_fts WHERE node_id = ?可见,hard delete 会同步清理 FTS5 索引(store.ts)。 - 增删改直接写入
memory_audit_events:MemoryAuditEvent.eventType枚举为create | update | supersede | expire | delete(types.ts),每笔变更即时落审计,无待审批状态。
检索规则:token budget、scope 与"不能据此宣称记忆库为空"
检索的四个要点在resolveMemoryQuery()(retrieval.ts)中均有落地:
- 自动召回不等于完整记忆库:自动召回受 token budget(默认 4000)与宿主授权 scope 限制,
MemoryContextDiagnostics会记录retrievedNodeCount/omittedNodeCount/tokenBudget/usedTokens(types.ts)。 - 已知类别用
kinds,开放问题用queryText:memory_search的工具描述明确要求优先kinds;ALWAYS_RECALLED_MEMORY_KINDS常量还会让interaction_contract、language_preference、accessibility_need、communication_preference、hard_constraint等始终参与自动召回(service.ts)。 - 全量枚举用
all: true:旧式 list-all 查询会被自动归一化(见前文isListAllMemoryQuery)。 - 无匹配不得宣称记忆库为空:只有执行过全量枚举才能下"库为空"的结论。
检索结果的冲突处理同样严谨:resolveConflicts()会过滤superseded/deleted/expired/confidence < 0.35的节点,并在同一 conflictKey(scope + domain + key)下只保留胜出者,其余记为conflict_lost(retrieval.ts);超限结果记为over_limit。相关性打分relevanceScore()对 title(+5)、entities/key(+4)、tags/value(+3)、category/content(+2)加权,并叠加importance * 2 + confidence(retrieval.ts)。
删除规则:一句话即算明确意图,不再有确认弹窗
文档列出的删除规则非常"激进"但也非常清晰:
- 用户明确要求忘记某条内容 → 调用一次
memory_forget精确删除(带id+expectedRevision)。 - 用户明确要求清除全部记忆 → 调用一次
memory_forget({ all: true })(且要求context.memoryForgetAllIntent === true)。 - "清掉、清除、清空、删除、忘掉"等表达都属于明确删除意图,直接进入删除链路,不再经过确认弹窗或审批任务。
- 权限边界由"当前用户意图 + 宿主授权 scope"双重保证:
memoryScopeAllowed()校验写入 scope 是否在writeScopes白名单内(scope.ts)。
scope 共有三种类型(types.ts):
| scope | 含义 | 可见性 |
|---|---|---|
{ type: 'profile' } | 当前 profile 跨会话共享 | 默认 scope |
{ type: 'session', id } | 仅当前会话可见 | 会话级 |
{ type: 'context', namespace, id } | 宿主定义的上下文范围可见 | 上下文级 |
数据库:ekko.db、四张核心表与 schema version 8 迁移
数据库位于<baseDirectory>/.ekko/ekko.db,记忆相关表共四张(文档原述,源码见 store.ts 的建表逻辑):
| 表 | 职责 |
|---|---|
memory_messages | 受信任的用户/助手对话证据(来源追溯) |
memory_nodes | 唯一的长期记忆卡片集合 |
memory_audit_events | 创建、覆盖、过期和删除审计 |
memory_embeddings | 语义检索数据 |
memory schema version 8会删除旧的memory_review_jobs、memory_summaries和memory_session_state表——这从侧面印证了文档的核心主张:早期设计中存在过的"后台记忆评审"与"Session 摘要"体系已被彻底废弃,统一收敛到单一memory_nodes模型。迁移不会删除memory_nodes中现有的记忆卡片,即升级是保留数据的平滑迁移。
崩溃安全的迁移策略
迁移基础设施在 database.ts 中实现,其设计层层递进:
- 逐版本、逐组件迁移:每个组件(如 memory)按版本号排序依次执行,记录在
schema_migrations表中,已应用版本跳过(database.ts)。 BEGIN IMMEDIATE事务 + 重试:每个迁移运行在BEGIN IMMEDIATE事务中(database.ts),立即获取写锁。锁冲突最多尝试三次,每次受PRAGMA busy_timeout(migrationBusyTimeoutMs)约束(database.ts)。- 仍被占用时阻止启动:三次重试仍失败则直接抛出
EkkoDatabaseMigrationError阻止启动(database.ts),绝不会在旧进程仍写入时重建数据库。 - 其他迁移错误 → 备份 + 恢复:先把原数据库及 WAL/SHM 移动到带时间戳的备份路径(
${databasePath}.migration-failed-${timestamp}-${randomUUID()}.bak,database.ts),再创建新库,并按兼容列恢复记忆、证据、审计和 Ekko 会话,随后重建 FTS 索引;若新库本身也无法建立,则恢复原数据库并终止启动。 - 绝不降级:整个迁移过程不会以"禁用 Memory"或"切换到临时空库"作为降级方案——要么成功迁移并保留全部数据,要么回滚并终止。
这一策略的工程含义很明确:记忆数据被视为不可丢失的资产,宁可拒绝启动、等待锁释放,也不允许在数据竞争条件下产生半迁移的脏状态。
小结:一套模型、一条链路、一份保证
Ekko Agent 的记忆系统可以浓缩为三句话:
- 一套模型:长期记忆只有
memory_nodes一种事实载体,memory_messages只是证据,审计表只做记录,不存在第二套记忆或审批队列。 - 一条链路:所有记忆的增删改都发生在 Agent turn 的前台链路中,由
memory_search/memory_get/memory_write/memory_forget四个工具直接驱动,失败即报错、成功即生效。 - 一份保证:canonical key 由服务端依据受控 kind 生成,
expectedRevision并发防护杜绝覆盖,sourceMessageIds白名单防止伪造来源,而数据库迁移在BEGIN IMMEDIATE+ 重试 + 备份恢复的组合下做到数据零丢失。
对希望深入理解的读者,建议按以下路径继续阅读源码:先读 schema.ts 理解槽位与规范化,再读 tools.ts 掌握工具参数契约,随后用 service.ts 串联运行链路,最后通过 database.ts 与 store.ts 验证迁移与存储的工程细节。
- AI 应用
- 人工智能
- AI Agent
- 本地部署
- 前端
- 后端
- 工作流自动化
【免费下载链接】ekko-studio
Ekko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.
相关推荐
FreeTube 隐私视频客户端完整展望:缓存重构与跨设备同步背后的五大信号
FreeTube 隐私视频客户端完整展望:缓存重构与跨设备同步背后的五大信号 FreeTube 是一款注重隐私的开源 YouTube 客户端,当前版本 0.25
桌面应用音视频破解AI Agent的记忆难题:openJiuwen agent-memory长期记忆系统深度解析
破解AI Agent的记忆难题:openJiuwen agent memory长期记忆系统深度解析 openJiuwen 是一个开源 AI Agent 开发与运
文档人工智能AI AgentLightdash AI Agent 记忆系统深度解析:记忆抽取(Distill)、梦境整合(Consolidation)与召回机制全指南
Lightdash AI Agent 记忆系统深度解析:记忆抽取(Distill)、梦境整合(Consolidation)与召回机制全指南 导读 Lightda
后端前端数据分析数据可视化人工智能AI Agent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考