# Ekko Agent 链式记忆系统规划 本文档规划 Ekko Agent 的长期记忆能力。目标不是把完整聊天历史反复塞进模型上下文,而是把对话沉淀成可追踪、可更新、可检索的结构化记忆。 ## 目标 - 支持跨轮、跨会话延续上下文。 - 自动从对话中抽取用户偏好、项目事实、决策、任务和知识条目。 - 用链式结构记录记忆演化,保留来源和覆盖关系。 - 支持领域分类,例如 `生活技能 / 做饭 / 菜谱`。 - 控制 token 成本,只在回复时注入少量相关记忆。 - 后续可接入向量检索或知识图谱,但第一版不强依赖。 ## 非目标 - 第一版不做完整知识图谱推理。 - 第一版不要求部署 embedding 模型。 - 第一版不让模型直接修改不可追溯的全局记忆。 - 第一版不把所有历史总结成一个不可审计的大摘要。 ## 记忆分层 ### 1. 原始消息日志 保存完整用户消息、助手消息、工具调用、工具结果和运行元数据。 用途: - 审计和回放。 - 重新生成摘要。 - 追溯结构化记忆来源。 - 调试模型错误抽取。 ### 2. 链式摘要 每隔一段对话生成一个摘要节点。新摘要只依赖上一个摘要和新增消息,避免每次读取完整历史。 示例: ```txt summary_001 -> summary_002 -> summary_003 ``` 摘要应包含: - 当前目标。 - 已完成工作。 - 未完成事项。 - 关键约束。 - 用户偏好。 - 重要决策。 - 已知问题。 ### 3. 结构化记忆 结构化记忆保存可以被程序理解和筛选的事实。 推荐类型: - `preference`: 用户偏好。 - `fact`: 稳定事实。 - `decision`: 决策。 - `task`: 待办或当前任务。 - `recipe`: 菜谱类知识。 - `skill`: 技能步骤或方法。 - `constraint`: 约束。 - `correction`: 用户纠正。 ### 4. 可选检索索引 后续可增加: - keyword/full-text search。 - embedding 向量索引。 - entity/relationship 图索引。 第一版建议先做结构化字段和关键词检索,等数据量变大后再加向量。 ## 核心数据模型 ### MemoryMessage ```ts type MemoryMessage = { id: string sessionId: string parentId?: string role: 'system' | 'user' | 'assistant' | 'tool' content: string metadata?: Record createdAt: string } ``` ### MemorySummary ```ts type MemorySummary = { id: string sessionId: string parentSummaryId?: string fromMessageId: string toMessageId: string summary: string currentGoal?: string constraints: string[] preferences: string[] decisions: string[] completedWork: string[] pendingWork: string[] knownIssues: string[] createdAt: string } ``` ### MemoryNode ```ts type MemoryNode = { id: string parentId?: string supersedesId?: string sessionId?: string workspaceId?: string userId?: string scope: 'session' | 'workspace' | 'user' | 'global' domain: string categoryPath: string[] type: 'preference' | 'fact' | 'decision' | 'task' | 'recipe' | 'skill' | 'constraint' | 'correction' key?: string valueJson?: unknown title: string content: string status: 'active' | 'superseded' | 'expired' | 'deleted' confidence: number importance: number tags: string[] entities: string[] sourceMessageIds: string[] createdAt: string updatedAt: string expiresAt?: string } ``` ## 存取设计 记忆系统建议拆成三个物理表或集合: ```txt memory_messages memory_summaries memory_nodes memory_audit_events ``` 后续如果引入向量,再增加: ```txt memory_embeddings ``` ### 怎么存 一次 agent turn 建议分两段存。 第一段是同步写入原始消息,发生在模型调用前后: ```txt 用户消息进入 -> append MemoryMessage(role = user) -> 组装上下文 -> 调模型 -> append MemoryMessage(role = assistant) ``` 第二段是异步整理记忆,发生在回复完成后: ```txt 读取上一条 MemorySummary 读取本轮新增 MemoryMessage 调用 memory extractor 得到 MemoryExtraction 按 operation 写入 MemoryNode 必要时 append MemorySummary ``` 写入规则: - 原始消息只追加,不覆盖。 - 摘要只追加,通过 `parentSummaryId` 串成链。 - 结构化记忆默认 upsert,但旧版本不硬删。 - 用户纠正时新建节点,并把旧节点标记为 `superseded`。 - 临时信息写 `expiresAt`,过期后默认不注入上下文。 ### 怎么取 回复前取记忆时,不应该读取全部历史。推荐分四路查询后合并: ```txt 1. recentMessages: 当前 session 最近 3-8 条原始消息 2. latestSummary: 当前 session 最新摘要 3. activeNodes: 当前 scope 下高优先级 active 结构化记忆 4. relevantNodes: 根据当前输入做关键词、分类、标签或向量召回 ``` 查询优先级: ```txt session scope > workspace scope > user scope > global scope ``` 同类记忆冲突时: ```txt 更新的 > 旧的 高置信 > 低置信 临时有效约束 > 长期偏好 用户明确纠正 > 模型推断 ``` ### Store 接口 第一版可以先定义接口,不绑定具体数据库。 ```ts type MemoryStore = { appendMessage(message: MemoryMessage): Promise listRecentMessages(input: { sessionId: string limit: number }): Promise appendSummary(summary: MemorySummary): Promise getLatestSummary(input: { sessionId: string }): Promise upsertNode(node: MemoryNode): Promise supersedeNode(input: { oldNodeId: string newNode: MemoryNode }): Promise deleteNode(input: { nodeId: string mode: 'soft' | 'hard' reason: string }): Promise listActiveNodes(input: { userId?: string sessionId?: string workspaceId?: string domain?: string categoryPath?: string[] types?: MemoryNode['type'][] limit: number }): Promise searchNodes(input: { query: string userId?: string sessionId?: string workspaceId?: string domain?: string tags?: string[] limit: number }): Promise } ``` ### 精确检索字段 如果要精确回答“用户是不是不吃香菜”这种问题,不能只靠 `title` 和 `content`。需要把可判断的事实写入 `key` 和 `valueJson`。 示例: ```json { "type": "preference", "key": "avoid_ingredient", "valueJson": "香菜" } ``` 另一个例子: ```json { "type": "preference", "key": "flavor_profile", "valueJson": { "oil": "low", "spicy": "low" } } ``` 精确检索优先使用: ```txt scope + domain + categoryPath + type + key + valueJson + status + expiresAt ``` 模糊检索才使用: ```txt title + content + tags + entities + FTS/embedding ``` ### Memory Query 接口 `MemoryStore` 面向数据库操作,`MemoryService` 面向 agent 运行时。运行时不应该拼 SQL,而是传入结构化查询。 ```ts type MemoryQuery = { userId?: string workspaceId?: string sessionId?: string scopes?: MemoryNode['scope'][] domain?: string categoryPathPrefix?: string[] types?: MemoryNode['type'][] key?: string valueJson?: unknown tags?: string[] entities?: string[] queryText?: string includeExpired?: boolean limit?: number } ``` 返回值不是数据库裸结果,而是已经过滤、排序、去冲突后的结果: ```ts type MemoryQueryResult = { exact: MemoryNode[] relevant: MemoryNode[] omitted: Array<{ nodeId: string reason: 'expired' | 'superseded' | 'low_confidence' | 'conflict_lost' | 'over_limit' }> } ``` ## Agent 和 Memory Service 边界 记忆系统不建议设计成一个永远自主运行的 agent。第一版更适合设计成常驻存储和运行时服务: ```txt AgentRuntime -> MemoryService -> MemoryStore(SQLite) -> MemoryRetriever -> MemoryExtractor -> MemoryContextBuilder ``` 职责边界: - 主 Agent 负责理解用户请求、推理、调用业务工具、产出回复。 - Memory Service 负责查库、过滤、排序、冲突消解和上下文压缩。 - Memory Extractor 负责在回复后从新增对话中抽取候选记忆。 - Memory Store 负责 SQLite 持久化。 主 Agent 不应该直接: - 拼 SQL。 - 遍历完整记忆库。 - 判断过期和覆盖关系。 - 自己解决同类记忆冲突。 主 Agent 可以: - 接收 runtime 自动注入的 `MemoryContext`。 - 在复杂任务里通过 memory tool 请求更多记忆。 - 提出记忆写入建议,但最终由 Memory Service 校验和落库。 - 当用户明确要求记录某件事时,立即调用 memory tool 提交写入请求。 ### 默认自动检索 每次调用主模型前,`AgentRuntime` 自动执行一次轻量检索: ```txt 用户消息 -> buildDefaultMemoryQuery() -> MemoryService.retrieve() -> buildMemoryContext() -> 注入主模型上下文 ``` 默认检索内容: - 当前 session 最近消息。 - 当前 session 最新摘要。 - 当前 session active tasks。 - 当前 workspace active constraints / decisions / facts。 - 用户级高优先级 preferences。 这部分不需要主 Agent 主动调用。 ### 按需 Memory Tool 复杂场景给主 Agent 暴露工具,但工具背后仍然走 Memory Service。 建议工具: ```ts memory_search(input: MemoryQuery): Promise ``` ```ts memory_get(input: { id?: string type?: MemoryNode['type'] key?: string valueJson?: unknown scope?: MemoryNode['scope'] domain?: string }): Promise ``` ```ts memory_propose_update(input: { operation: 'create' | 'update' | 'supersede' | 'expire' | 'delete' targetId?: string node: Partial reason: string }): Promise<{ accepted: boolean nodeId?: string reason?: string }> ``` `memory_propose_update` 使用 propose 命名,是为了避免主 Agent 未经校验直接污染长期记忆。Memory Service 需要检查 scope、来源、置信度和冲突关系后再写入。 ```ts memory_forget(input: { id?: string scope?: MemoryNode['scope'] domain?: string categoryPathPrefix?: string[] type?: MemoryNode['type'] key?: string valueJson?: unknown mode?: 'soft' | 'hard' reason: string }): Promise<{ deletedIds: string[] mode: 'soft' | 'hard' requiresConfirmation?: boolean reason?: string }> ``` ### 明确记忆写入 后台 Memory Extractor 适合低频、批量整理;但用户明确要求“记住”“以后都按这个来”“帮我记录一下”时,不应该等到下一次批处理。主 Agent 应该立即调用 `memory_propose_update`,由 Memory Service 校验后同步或准同步写入。 明确写入示例: ```txt 用户:记住,我以后做饭不要香菜。 ``` 主 Agent 不直接写数据库,而是调用: ```ts memory_propose_update({ operation: 'create', node: { scope: 'user', domain: '生活技能', categoryPath: ['生活技能', '做饭', '饮食偏好'], type: 'preference', key: 'avoid_ingredient', valueJson: '香菜', title: '用户不吃香菜', content: '用户明确要求以后做饭推荐不要香菜。', status: 'active', confidence: 0.99, importance: 0.95, tags: ['忌口', '菜谱推荐'], entities: ['香菜'], }, reason: '用户明确要求记录长期饮食偏好', }) ``` Memory Service 接收到请求后需要: - 校验 `scope` 是否允许写入。 - 绑定 `sourceMessageIds`,保证可追溯。 - 检查是否已有同类 active 记忆。 - 如果冲突,执行 `supersede` 或要求更明确的用户确认。 - 写入 SQLite 事务。 - 返回是否 accepted。 写入成功后,主 Agent 可以自然确认: ```txt 记住了,以后推荐做饭相关内容时会避开香菜。 ``` 不建议立即写入的情况: - 用户只是临时描述状态,例如“今天有点胃不舒服”。 - 模型自己推断出来的偏好,用户没有明确表达。 - 敏感信息或可能涉及隐私的信息。 - scope 不明确且可能影响长期行为的记忆。 这类内容可以进入后台抽取流程,或者先作为 session-scope 临时记忆。 ### 删除和遗忘 记忆系统必须支持删除。用户说“忘掉这个”“不要再记这个”“删除我的做饭偏好”时,主 Agent 应调用 `memory_forget`,由 Memory Service 执行删除策略。 删除分两种: ```txt soft delete: 把 memory_nodes.status 改成 deleted,默认不再检索和注入,但保留审计记录。 hard delete: 从 SQLite 中物理删除节点,并清理 FTS/embedding 索引。 ``` 第一版默认使用 soft delete。hard delete 适合用户明确要求清除隐私数据,或者执行数据合规删除。 示例: ```txt 用户:忘掉我不吃香菜这件事。 ``` 主 Agent 调用: ```ts memory_forget({ scope: 'user', domain: '生活技能', categoryPathPrefix: ['生活技能', '做饭', '饮食偏好'], type: 'preference', key: 'avoid_ingredient', valueJson: '香菜', mode: 'soft', reason: '用户明确要求忘记该饮食偏好', }) ``` Memory Service 需要: - 先精确查找候选节点。 - 如果命中多条,返回 `requiresConfirmation = true`,让主 Agent 向用户确认范围。 - soft delete 时设置 `status = deleted`,更新 `updatedAt`。 - hard delete 时删除节点、FTS 记录、embedding 记录。 - 记录删除原因和操作者,方便审计。 删除范围建议: ```txt 删除单条记忆:按 id 删除。 删除某类记忆:按 scope + domain + categoryPath + type 删除。 删除某个 workspace 记忆:按 workspaceId 删除。 删除某个用户长期记忆:按 userId + scope=user 删除。 删除来源消息关联记忆:按 sourceMessageIds 删除。 ``` 注意:删除结构化记忆不一定等于删除原始消息日志。用户如果要求“彻底删除相关对话”,需要同时清理 `memory_messages`、`memory_summaries` 中的相关内容,或者重新生成受影响摘要。 ### 冲突消解 Memory Service 在返回记忆前做冲突消解。 排序规则: ```txt session > workspace > user > global 未过期 > 已过期 用户明确表达 > 模型推断 临时有效约束 > 长期偏好 更新的 > 旧的 高置信 > 低置信 高重要性 > 低重要性 ``` 示例: ```txt 长期偏好:用户喜欢川菜。 临时约束:用户今天胃不舒服,少辣。 ``` 当天推荐晚餐时,Memory Service 应返回“少辣”作为更高优先级约束,同时可以保留“喜欢川菜”作为低优先级偏好。 ### 写入示例 用户说: ```txt 以后做饭别给我推荐香菜,我喜欢少油少辣。 ``` 同步写入: ```json { "role": "user", "content": "以后做饭别给我推荐香菜,我喜欢少油少辣。" } ``` 异步抽取后写入两个节点: ```json { "scope": "user", "domain": "生活技能", "categoryPath": ["生活技能", "做饭", "饮食偏好"], "type": "preference", "title": "用户不吃香菜", "content": "推荐菜谱时避免香菜。", "status": "active", "confidence": 0.98, "importance": 0.9, "tags": ["忌口", "菜谱推荐"], "entities": ["香菜"] } ``` ```json { "scope": "user", "domain": "生活技能", "categoryPath": ["生活技能", "做饭", "饮食偏好"], "type": "preference", "title": "用户偏好少油少辣", "content": "推荐菜谱时优先少油少辣。", "status": "active", "confidence": 0.95, "importance": 0.85, "tags": ["口味", "菜谱推荐"], "entities": ["少油", "少辣"] } ``` ### 读取示例 用户下一次问: ```txt 今晚吃什么? ``` 检索条件: ```txt domain = 生活技能 categoryPath startsWith 生活技能/做饭 types = preference, constraint, recipe status = active ``` 组装给模型的上下文片段: ```txt 用户做饭偏好: - 推荐菜谱时避免香菜。 - 推荐菜谱时优先少油少辣。 ``` 模型最终推荐时就不需要知道完整历史,只需要这两条结构化记忆。 ### 更新示例 用户后来补充: ```txt 香菜现在可以接受一点,但不要太多。 ``` 处理方式: ```txt 旧节点:用户不吃香菜 -> status = superseded 新节点:用户可接受少量香菜 -> status = active, supersedesId = oldNodeId ``` 这样系统不会丢掉历史,也不会继续按旧偏好推荐。 ## SQLite 落地方案 第一版推荐使用 SQLite。它比 JSONL 更适合做查询、索引、事务和状态更新,同时不需要引入服务端数据库。 ### 文件位置 建议根据记忆 scope 分开落盘: ```txt workspace scope: /.ekko/memory.db user scope: ~/.ekko/memory.db session scope: 可以存入 workspace db,也可以存入服务端会话库 global scope: 随包发布的只读 seed 数据,或服务端同步 ``` 如果第一版只做 workspace 本地记忆,可以先统一使用: ```txt /.ekko/memory.db ``` ### 表结构 SQLite 不需要把所有数组拆成多张表。第一版可以把数组字段存成 JSON text,常用筛选字段单独建列。 ```sql create table if not exists memory_messages ( id text primary key, session_id text not null, parent_id text, role text not null, content text not null, metadata_json text, created_at text not null ); create table if not exists memory_summaries ( id text primary key, session_id text not null, parent_summary_id text, from_message_id text not null, to_message_id text not null, summary text not null, current_goal text, constraints_json text not null default '[]', preferences_json text not null default '[]', decisions_json text not null default '[]', completed_work_json text not null default '[]', pending_work_json text not null default '[]', known_issues_json text not null default '[]', created_at text not null ); create table if not exists memory_nodes ( id text primary key, parent_id text, supersedes_id text, session_id text, workspace_id text, user_id text, scope text not null, domain text not null, category_path_json text not null, category_path_text text not null, type text not null, key text, value_json text, title text not null, content text not null, status text not null, confidence real not null, importance real not null, tags_json text not null default '[]', entities_json text not null default '[]', source_message_ids_json text not null default '[]', created_at text not null, updated_at text not null, expires_at text ); create table if not exists memory_audit_events ( id text primary key, event_type text not null, node_id text, session_id text, workspace_id text, user_id text, actor text not null, reason text not null, payload_json text, created_at text not null ); ``` `category_path_text` 用 `/` 拼接,方便做前缀查询: ```txt 生活技能/做饭/饮食偏好 ``` ### 索引 ```sql create index if not exists idx_memory_messages_session_created on memory_messages (session_id, created_at); create index if not exists idx_memory_summaries_session_created on memory_summaries (session_id, created_at); create index if not exists idx_memory_nodes_lookup on memory_nodes (scope, status, domain, type, importance, updated_at); create index if not exists idx_memory_nodes_key on memory_nodes (scope, status, domain, type, key, updated_at); create index if not exists idx_memory_nodes_category on memory_nodes (category_path_text); create index if not exists idx_memory_nodes_session on memory_nodes (session_id, status, updated_at); create index if not exists idx_memory_nodes_workspace on memory_nodes (workspace_id, status, updated_at); create index if not exists idx_memory_nodes_user on memory_nodes (user_id, status, updated_at); create index if not exists idx_memory_audit_events_node on memory_audit_events (node_id, created_at); ``` 可选增加 FTS5,用于关键词检索: ```sql create virtual table if not exists memory_nodes_fts using fts5( title, content, tags, entities, content='memory_nodes', content_rowid='rowid' ); ``` 如果使用 FTS,需要在 upsert memory node 时同步维护 FTS 表,或使用 trigger。 ### 典型查询 取最近消息: ```sql select * from memory_messages where session_id = ? order by created_at desc limit ?; ``` 取最新摘要: ```sql select * from memory_summaries where session_id = ? order by created_at desc limit 1; ``` 取做饭相关 active 记忆: ```sql select * from memory_nodes where status = 'active' and domain = '生活技能' and category_path_text like '生活技能/做饭/%' and (expires_at is null or expires_at > ?) order by importance desc, confidence desc, updated_at desc limit ?; ``` 取当前 workspace 的约束和决策: ```sql select * from memory_nodes where status = 'active' and workspace_id = ? and type in ('constraint', 'decision', 'fact') and (expires_at is null or expires_at > ?) order by importance desc, updated_at desc limit ?; ``` ### 事务写入 一次记忆整理应该放在事务里: ```txt begin insert summary if needed insert new nodes update superseded old nodes update fts index if enabled commit ``` 这样可以避免出现“新记忆写入了,但旧记忆没失效”的半完成状态。 每次 create、update、supersede、expire、delete 都应该追加一条 `memory_audit_events`。审计事件不参与模型上下文注入,只用于调试、用户解释和回滚。 ### 迁移 建议维护一个简单版本表: ```sql create table if not exists memory_schema_migrations ( version integer primary key, applied_at text not null ); ``` Ekko Agent 启动 memory store 时: ```txt 打开 SQLite 读取 migration version 按顺序执行缺失 migration 创建索引 返回 SqliteMemoryStore ``` ## 受控 Schema 精确检索依赖稳定 schema。不能让模型自由发明 `key`,否则同一个意思可能被写成 `avoid_food`、`avoid_ingredient`、`excluded_ingredient`,后续 SQL 很难稳定命中。 建议按 domain 维护受控 schema: ```ts type MemorySchemaRule = { domain: string categoryPathPrefix?: string[] type: MemoryNode['type'] allowedKeys: string[] valueShape: 'string' | 'number' | 'boolean' | 'string[]' | 'object' defaultScope: MemoryNode['scope'] requiresExplicitUserIntent: boolean } ``` 做饭偏好示例: ```ts const cookingPreferenceSchema = { domain: '生活技能', categoryPathPrefix: ['生活技能', '做饭'], type: 'preference', allowedKeys: [ 'avoid_ingredient', 'preferred_ingredient', 'flavor_profile', 'dietary_restriction', 'cooking_time_preference', ], valueShape: 'object', defaultScope: 'user', requiresExplicitUserIntent: true, } ``` Memory Service 在写入前需要做 normalization: ```txt 同义 key 归一:disliked_ingredient -> avoid_ingredient 中文值归一:少辣 -> { spicy: 'low' } scope 归一:项目技术约束默认 workspace,不默认 user status 归一:没有 expiresAt 的临时状态不能进入长期 user-scope ``` 模型抽取结果如果不符合受控 schema,应被拒绝或降级为 session-scope 临时记忆。 ## 权限和确认策略 不同 scope 的写入风险不同: ```txt session: 默认允许,适合临时状态和当前任务。 workspace: 允许明确项目事实、技术约束、任务状态。 user: 只允许用户明确表达的长期偏好和稳定事实。 global: 第一版不允许 agent 写入。 ``` 需要确认的情况: - 写入 user-scope 长期记忆,但用户没有使用“记住”“以后都”等明确表达。 - 删除命中多条记忆。 - hard delete。 - 写入或删除敏感信息。 - 覆盖高置信历史记忆,但新消息表达不够明确。 不需要确认的情况: - session-scope 临时记忆。 - 用户明确要求记录的低风险偏好。 - workspace-scope 的当前任务状态。 - soft delete 单条精确命中的记忆。 ## 失败和降级 记忆系统不能阻塞主对话。 失败处理: ```txt SQLite 打不开:跳过记忆检索,主 Agent 正常回答,并记录 runtime warning。 检索失败:只使用最近消息和当前输入。 抽取失败:不重试阻塞当前回复,放入后台 retry queue。 写入失败:告诉主 Agent 写入未成功,不要回复“记住了”。 迁移失败:禁用 memory store,避免写入未知 schema。 ``` `MemoryContext` 应包含诊断字段: ```ts type MemoryContextDiagnostics = { enabled: boolean storeStatus: 'ok' | 'disabled' | 'degraded' warnings: string[] retrievedNodeCount: number omittedNodeCount: number } ``` ## 可观测性和解释 每次回复最好保留本轮使用过的记忆 id,但不一定展示给用户。 ```ts type MemoryContext = { latestSummary?: MemorySummary recentMessages: MemoryMessage[] activeTasks: MemoryNode[] relevantNodes: MemoryNode[] constraints: MemoryNode[] preferences: MemoryNode[] usedMemoryIds: string[] diagnostics: MemoryContextDiagnostics } ``` 当用户问“你为什么这么推荐”时,主 Agent 可以基于 `usedMemoryIds` 解释: ```txt 因为我记录到你做饭偏好少油少辣,并且不希望推荐香菜。 ``` 解释时不要暴露内部 SQL、embedding 分数或无关历史消息。 ## 测试验收 第一版至少覆盖这些测试: - append message 后可以按 session 取最近消息。 - append summary 后可以取最新摘要。 - create node 后 active 查询能命中。 - `key/valueJson` 精确查询能命中做饭偏好。 - supersede 后旧节点不再进入默认上下文。 - expiresAt 过期后不再注入。 - soft delete 后不再检索。 - hard delete 后节点、FTS、embedding 记录被清理。 - 命中多条删除候选时返回 `requiresConfirmation`。 - SQLite 事务失败时不会出现半写入状态。 - memory store 失败时主 Agent 能降级继续运行。 - schema normalization 能把同义 key 归一到受控 key。 ## 分类体系 分类树用于归档,记忆节点用于表达事实和演化。不要把目录树当成全部记忆。 示例: ```txt 生活技能 做饭 菜谱 食材处理 厨房工具 烹饪技巧 营养搭配 采购清单 饮食偏好 家务 理财 健康 出行 ``` 一条记忆可以挂在一个主分类下,同时用 tags 和 entities 表达交叉关系。 示例: ```json { "domain": "生活技能", "categoryPath": ["生活技能", "做饭", "饮食偏好"], "type": "preference", "title": "用户不吃香菜", "content": "用户明确表示以后不要推荐带香菜的菜。", "tags": ["做饭", "菜谱推荐", "忌口"], "entities": ["香菜"], "confidence": 0.98 } ``` 菜谱示例: ```json { "domain": "生活技能", "categoryPath": ["生活技能", "做饭", "菜谱"], "type": "recipe", "title": "番茄炒蛋", "content": "家常快手菜,主要食材是番茄和鸡蛋。可按用户偏好少油、不放香菜。", "tags": ["家常菜", "快手菜", "少辣"], "entities": ["番茄", "鸡蛋"] } ``` ## 抽取流程 在 agent 回复后异步执行记忆整理。 触发条件: - 新增 6-10 条消息。 - 新增上下文超过指定 token 阈值。 - 用户表达明确偏好、决策、纠正或长期目标。 - 任务状态发生变化。 流程: ```txt 新增消息 -> 保存原始日志 -> 读取上一条摘要 -> 抽取结构化候选记忆 -> 判断新增、更新、覆盖或过期 -> 写入 MemoryNode -> 必要时生成 MemorySummary ``` 抽取器输出建议: ```ts type MemoryExtraction = { summaryPatch?: string nodes: Array<{ operation: 'create' | 'update' | 'supersede' | 'expire' | 'ignore' targetId?: string node: Partial reason: string }> } ``` ## 覆盖和冲突 用户纠正优先级高于历史记忆。 示例: ```txt 旧记忆:用户喜欢吃辣。 新消息:最近胃不舒服,先别推荐辣的。 ``` 处理方式: - 不一定删除旧记忆。 - 新建一条临时偏好,`status = active`。 - 旧记忆仍保留,但检索注入时被新记忆压过。 - 如果用户明确说“以后都不吃辣”,再 supersede 旧记忆。 ## 回复前上下文组装 每次模型回复前只注入最小必要记忆。 优先级: ```txt 当前用户消息 > 最近几轮原始消息 > 当前任务状态 > 最新摘要 > 高置信结构化记忆 > 相关历史记忆 > 低置信或过期记忆 ``` 上下文包: ```ts type MemoryContext = { latestSummary?: MemorySummary recentMessages: MemoryMessage[] activeTasks: MemoryNode[] relevantNodes: MemoryNode[] constraints: MemoryNode[] preferences: MemoryNode[] } ``` 建议限制: - 最新摘要:1 条。 - 最近消息:3-8 条。 - 结构化记忆:最多 10-20 条。 - 单条记忆内容保持短文本。 ## Token 成本策略 - 不每轮抽取,按阈值批处理。 - 抽取只看上一条摘要和新增消息。 - 记忆抽取可使用便宜模型或本地小模型。 - 回复时只注入相关记忆,不注入完整记忆库。 - 已完成任务和临时日志降低重要性或设置过期时间。 ## 第一版落地建议 ### Phase 1: 本地结构化记忆 - 增加 memory 模块。 - 保存 messages、summaries、nodes。 - 支持基础分类、状态、来源追踪。 - 实现基于规则和模型 JSON 输出的抽取接口。 - 回复前注入最近摘要和 active nodes。 ### Phase 2: 检索和覆盖 - 增加关键词搜索。 - 增加 `supersedesId` 和冲突处理。 - 增加 task/preference/decision 的专用 upsert 逻辑。 - 增加记忆重要性和过期机制。 ### Phase 3: 向量和图关系 - 对 MemoryNode 生成 embedding。 - 增加 topK 语义召回。 - 抽取 entities 和 relationships。 - 支持 temporal graph 查询。 ## 与 Ekko Agent 当前模块的关系 建议新增目录: ```txt packages/ekko-agent/src/memory/ context.ts extraction.ts store.ts types.ts retrieval.ts ``` 职责: - `types.ts`: 记忆数据结构。 - `store.ts`: 存储接口和 SQLite 实现。 - `extraction.ts`: 从消息中抽取结构化记忆。 - `retrieval.ts`: 根据当前输入检索相关记忆。 - `context.ts`: 组装给模型的 MemoryContext。 `AgentRuntime` 后续可以在两个位置接入: - 回复前:调用 retrieval/context,把记忆注入系统提示或上下文消息。 - 回复后:调用 extraction/store,异步更新记忆。 ## 开放问题 - 记忆存储落在哪里:服务端数据库、workspace 文件,还是用户级 profile。 - 哪些记忆需要用户可见和可编辑。 - 是否允许 agent 自动写入 user-scope 长期记忆。 - 记忆抽取使用哪个模型和预算。 - 是否需要多租户隔离和加密。