15 KiB
记忆管理模块设计
日期:2026-04-26 状态:待实施 目标:为 Agent 系统提供可视化的记忆管理能力,让 Agent 越用越好用
1. 背景与目标
现有记忆系统已支持 MySQL/SQLite 双后端存储,Agent 可通过 memory_save / memory_recall 工具自动积累用户信息。但存在以下不足:
- 记忆类型固定为 4 种(user/project/feedback/reference),无法扩展
- 没有管理页面,管理员无法查看、编辑、预设记忆数据
- Agent 工具缺少类型感知和 metadata 支持
- 管理员无法直观了解各 Agent 各存储后端的记忆分布
本次实现一个记忆管理模块,放在 Agent 管理菜单下,供管理员/运营人员手动增删改查记忆数据,并扩展 Agent 工具能力。
2. 数据库 & 存储设计
2.1 新增 netaclaw_memory_type 表(MySQL)
管理可用的记忆类型,两种存储后端共享此配置。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | BaseEntity 自增主键 |
| key | varchar(50) | 类型标识,如 environment |
| name | varchar(100) | 显示名称,如"环境信息" |
| description | varchar(500) | 类型说明 |
| icon | varchar(50) | 图标,nullable |
| isSystem | tinyint(1) | 1=系统内置不可删除,0=自定义 |
预置系统类型:
| key | name | description |
|---|---|---|
| user | 用户画像 | 用户偏好、角色、习惯 |
| project | 项目知识 | 项目进展、决策、约束 |
| feedback | 行为反馈 | 用户对 Agent 行为的纠正或确认 |
| reference | 引用资源 | 外部资源链接、文档地址 |
2.2 SQLite 迁移策略
SQLite 不支持 ALTER TABLE DROP CONSTRAINT,已有 .db 文件带旧 CHECK 约束。需要在 SqliteMemoryProvider 构造函数中加自动迁移:
// 迁移逻辑(在构造函数中执行)
// 1. 检测当前表是否有旧 CHECK 约束(查 sqlite_master 的 sql 列)
// 2. 如果有:
// a. CREATE TABLE memory_new (... type TEXT NOT NULL ...); -- 无 CHECK
// b. INSERT INTO memory_new SELECT * FROM memory;
// c. DROP TABLE memory;
// d. ALTER TABLE memory_new RENAME TO memory;
// e. 重建索引和 FTS 触发器
// 3. 如果没有:跳过迁移
新建表的 DDL 去掉 CHECK 约束:
CREATE TABLE IF NOT EXISTS memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_name TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT NOT NULL, -- 不再有 CHECK 约束
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
2.3 netaclaw_memory 表(MySQL)
不改动。type 字段为 varchar(20),本身无约束,已支持任意值。
3. 后端架构
3.1 MemoryProviderRegistry — Provider 实例缓存池
解决问题:避免管理页面每次请求都创建新 Provider 实例,防止 SQLite 文件句柄泄漏和 WAL 锁冲突。
@Provide()
@Scope(ScopeEnum.Singleton)
export class MemoryProviderRegistry {
private cache = new Map<string, { provider: MemoryProvider; backend: string }>();
// 注入 Agent Entity 和 Memory Entity
@InjectEntityModel(NetaClawAgentEntity)
agentEntity: Repository<NetaClawAgentEntity>;
@InjectEntityModel(NetaClawMemoryEntity)
memoryEntity: Repository<NetaClawMemoryEntity>;
async getProvider(agentName: string): Promise<{ provider: MemoryProvider; backend: string }> {
if (this.cache.has(agentName)) return this.cache.get(agentName)!;
// 查 Agent 配置 → 创建 provider → 缓存
const agent = await this.agentEntity.findOneBy({ name: agentName });
const config = agent?.config?.memory ?? { enabled: true, backend: 'mysql' };
const provider = createMemoryProvider(config, this.memoryEntity);
const entry = { provider, backend: config.backend };
this.cache.set(agentName, entry);
return entry;
}
// Agent 配置变更时清除缓存
invalidate(agentName: string) {
const cached = this.cache.get(agentName);
if (cached) { cached.provider.close?.(); this.cache.delete(agentName); }
}
}
SQLite provider 额外配置 busy_timeout:
this.db.pragma('busy_timeout = 5000');
3.2 文件结构
packages/backend/src/modules/netaclaw/
├── entity/
│ ├── memory.ts # 已有,不改
│ └── memory_type.ts # 新增
├── memory/
│ ├── provider.ts # 扩展:新增 page/count,userId 可选
│ ├── mysql_provider.ts # 扩展:实现 page/count
│ ├── sqlite_provider.ts # 扩展:实现 page/count + 迁移 + busy_timeout
│ ├── factory.ts # 已有,不改
│ └── registry.ts # 新增:Provider 实例缓存池
├── service/
│ ├── memory_admin.ts # 新增
│ └── memory_type.ts # 新增
├── controller/admin/
│ ├── memory.ts # 新增
│ └── memory_type.ts # 新增
3.3 MemoryProvider 接口扩展
// provider.ts 修改
export interface MemorySearchOpts {
agentName: string;
userId?: string; // 改为可选 — 管理页面跨用户查询
type?: MemoryType;
limit?: number;
}
export interface MemoryPageOpts {
agentName?: string; // 可选 — 不传则查所有(仅 MySQL 后端有效)
userId?: string; // 可选 — 管理页面不限定用户
type?: string;
keyWord?: string;
page: number;
size: number;
}
export interface MemoryPageResult {
list: MemoryEntry[];
pagination: { page: number; size: number; total: number };
}
export interface MemoryProvider {
// 已有方法
save(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'updatedAt'>): Promise<MemoryEntry>;
update(id: number, partial: Partial<Pick<MemoryEntry, 'name' | 'content' | 'description' | 'type' | 'metadata'>>): Promise<MemoryEntry>;
delete(id: number): Promise<void>;
search(query: string, opts: MemorySearchOpts): Promise<MemoryEntry[]>;
list(opts: MemorySearchOpts): Promise<MemoryEntry[]>;
getById(id: number): Promise<MemoryEntry | null>;
close?(): Promise<void>;
// 新增方法
page(opts: MemoryPageOpts): Promise<MemoryPageResult>;
count(opts: Omit<MemoryPageOpts, 'page' | 'size'>): Promise<number>;
}
3.4 MemoryAdminService — 查询优化策略
@Provide()
export class MemoryAdminService extends BaseService {
@Inject()
registry: MemoryProviderRegistry;
@InjectEntityModel(NetaClawMemoryEntity)
memoryEntity: Repository<NetaClawMemoryEntity>;
@InjectEntityModel(NetaClawAgentEntity)
agentEntity: Repository<NetaClawAgentEntity>;
async page(params: { agentName?: string; type?: string; keyWord?: string; page: number; size: number }) {
if (params.agentName) {
// 指定 Agent → 走对应 provider
const { provider, backend } = await this.registry.getProvider(params.agentName);
const result = await provider.page(params);
// 给每条记录附加 backend 标识
result.list.forEach(e => (e as any)._backend = backend);
return result;
}
// 未指定 Agent → 分两路查询
// 1. MySQL 后端:直接在 netaclaw_memory 表上查一次(不按 agentName 过滤)
// 2. SQLite 后端:只遍历使用 SQLite 的 Agent
// 合并结果,按 updatedAt DESC 排序,做应用层分页
}
async stats() {
// MySQL 后端:一条 SQL GROUP BY agentName
const mysqlStats = await this.memoryEntity
.createQueryBuilder('m')
.select('m.agentName', 'agentName')
.addSelect('COUNT(*)', 'count')
.groupBy('m.agentName')
.getRawMany();
// SQLite 后端:只遍历使用 SQLite 的 Agent
const sqliteAgents = await this.agentEntity.find(/* memory.backend = 'sqlite' */);
const sqliteStats = [];
for (const agent of sqliteAgents) {
const { provider } = await this.registry.getProvider(agent.name);
const count = await provider.count({ agentName: agent.name });
sqliteStats.push({ agentName: agent.name, backend: 'sqlite', count });
}
return [
...mysqlStats.map(s => ({ ...s, backend: 'mysql' })),
...sqliteStats,
];
}
}
3.5 API 接口
记忆管理(自定义 Controller):
所有接口严格遵循 Cool Admin 的请求/响应格式,确保前端 cl-crud 组件可直接对接。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /admin/netaclaw/memory/page |
分页查询,支持 agentName/type/keyWord 筛选 |
| GET | /admin/netaclaw/memory/info |
详情(query: agentName, id) |
| POST | /admin/netaclaw/memory/add |
新增(body 含 agentName) |
| POST | /admin/netaclaw/memory/update |
更新(body 含 agentName + id + updatedAt 乐观锁) |
| POST | /admin/netaclaw/memory/delete |
删除(body: { agentName, ids }) |
| GET | /admin/netaclaw/memory/stats |
各 Agent 各后端记忆统计 |
复合标识:所有操作使用 {agentName, id} 作为记忆的唯一标识。前端表格行数据必须携带 agentName。分页响应中每条记录附加 _backend 字段标识存储来源。
乐观锁:update 接口要求传入 updatedAt,后端比对后决定是否允许更新,防止管理员和 Agent 同时修改同一条记忆。
类型管理(标准 @CoolController CRUD):
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /admin/netaclaw/memory_type/page |
分页 |
| POST | /admin/netaclaw/memory_type/add |
新增类型 |
| POST | /admin/netaclaw/memory_type/update |
更新类型 |
| POST | /admin/netaclaw/memory_type/delete |
删除(isSystem=1 的拒绝删除) |
| POST | /admin/netaclaw/memory_type/list |
列表(供下拉选择) |
4. 前端页面
4.1 路由与菜单
- 路由:
/agent/memory - 前端文件:
modules/agent/views/memory.vue - 菜单:挂在 Agent 管理(id=112) 下,orderNum=8
4.2 页面布局
┌──────────────────────────────────────────────────┐
│ 记忆管理 │
├───────────┬──────────────────────────────────────┤
│ 左侧统计 │ 筛选栏:[Agent ▼] [类型 ▼] [关键词] │
│ │ [+ 新增记忆] [类型管理] │
│ Agent A │──────────────────────────────────────│
│ MySQL │ 记忆列表(表格) │
│ 15条 │ ID | 标题 | 类型(tag) | Agent │
│ │ 存储后端(tag) | 描述 | 更新时间 | 操作 │
│ Agent B │──────────────────────────────────────│
│ SQLite │ 分页 │
│ 8条 │ │
└───────────┴──────────────────────────────────────┘
4.3 交互说明
- 左侧统计卡片:调
/stats接口,按 Agent 分组展示记忆数量和存储后端标签 - 点击卡片:自动筛选该 Agent 的记忆
- 表格:类型列用彩色 Tag 展示,存储后端用 MySQL/SQLite 标签区分
- 新增/编辑弹窗:
- 选择 Agent(下拉)
- 选择类型(下拉,数据来自 memory_type/list)
- 填写标题、内容(textarea)、描述
- metadata(JSON 编辑器,可选)
- 类型管理:点击"类型管理"按钮弹出 Dialog,表格展示所有类型,系统内置类型带锁图标不可删除
5. Agent 工具扩展
5.1 类型校验 Fallback 机制
Agent 工具的类型校验不强依赖 MySQL 的 memory_type 表:
async function getAvailableTypes(memoryTypeRepo?: Repository<MemoryTypeEntity>): Promise<string[]> {
const BUILTIN_TYPES = ['user', 'project', 'feedback', 'reference'];
if (!memoryTypeRepo) return BUILTIN_TYPES;
try {
const types = await memoryTypeRepo.find();
return types.map(t => t.key);
} catch {
return BUILTIN_TYPES; // MySQL 不可用时回退内置类型
}
}
这确保纯 SQLite 部署场景下 Agent 工具仍可正常工作。
5.2 新增 memory_list_types 工具
让 Agent 感知可用的记忆类型,存储时选择正确类型。
// 参数:无
// 返回:可用类型列表 [{ key, name, description }]
// 实现:优先查 memory_type 表,fallback 返回内置 4 种
5.3 扩展 memory_save
- type 参数:从固定枚举改为
Type.String(),运行时通过 fallback 机制校验 - 新增 metadata 参数:
Type.Optional(Type.Record(Type.String(), Type.Unknown()))
5.4 扩展 memory_recall
- 新增
id参数:支持按 ID 精确查询 - 返回格式增加 metadata 信息
5.5 新增 memory_stats 工具(可选)
让 Agent 了解自己已存储的记忆分布,辅助决策是更新还是新建。
// 参数:无
// 返回:{ total: number, byType: { user: 3, project: 5, ... } }
6. 架构决策记录
| # | 决策 | 原因 |
|---|---|---|
| 1 | Provider 实例缓存池(MemoryProviderRegistry 单例) | 避免每次请求创建新 SQLite 连接,防止文件句柄泄漏和 WAL 锁冲突 |
| 2 | SQLite 自动迁移去 CHECK 约束 | SQLite 不支持 ALTER TABLE DROP CONSTRAINT,需建新表迁移数据 |
| 3 | 类型校验 Fallback 机制 | 保持 SQLite 后端独立性,纯 SQLite 部署不依赖 MySQL |
| 4 | MySQL 后端查询不按 agentName 扇出 | 共享同一张表,一次 SQL 即可,避免 O(N) 查询 |
| 5 | MemorySearchOpts.userId 改为可选 | 管理页面需要跨用户查询,Agent 运行时仍传 userId |
| 6 | 复合标识 {agentName, id} | 跨后端 ID 可能冲突,必须用复合标识定位记录 |
| 7 | update 接口乐观锁(基于 updatedAt) | 防止管理员和 Agent 同时修改同一条记忆导致数据丢失 |
| 8 | SQLite busy_timeout = 5000ms | 缓解管理员和 Agent 并发写入时的 SQLITE_BUSY 错误 |
7. 改动清单
| 层 | 文件 | 操作 |
|---|---|---|
| Entity | entity/memory_type.ts |
新增 |
| Provider | memory/provider.ts |
扩展接口:page/count,userId 可选 |
| Provider | memory/mysql_provider.ts |
实现 page/count |
| Provider | memory/sqlite_provider.ts |
实现 page/count + CHECK 迁移 + busy_timeout |
| Registry | memory/registry.ts |
新增:Provider 实例缓存池 |
| Service | service/memory_admin.ts |
新增:管理页面 Service |
| Service | service/memory_type.ts |
新增:类型管理 Service |
| Controller | controller/admin/memory.ts |
新增:记忆管理 Controller |
| Controller | controller/admin/memory_type.ts |
新增:类型管理 Controller |
| Tool | tools/builtin/memory.ts |
扩展 save/recall + fallback 类型校验 |
| Tool | tools/builtin/memory_types.ts |
新增:memory_list_types + memory_stats |
| 前端 | views/memory.vue |
新增:记忆管理页面 |
| 前端 | config.ts |
注册路由 |
| 数据库 | base_sys_menu |
新增菜单记录 |
| 注册 | entities.ts |
注册新 Entity |