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