# 记忆管理模块设计 > 日期: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` 构造函数中加自动迁移: ```typescript // 迁移逻辑(在构造函数中执行) // 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 约束: ```sql 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 锁冲突。 ```typescript @Provide() @Scope(ScopeEnum.Singleton) export class MemoryProviderRegistry { private cache = new Map(); // 注入 Agent Entity 和 Memory Entity @InjectEntityModel(NetaClawAgentEntity) agentEntity: Repository; @InjectEntityModel(NetaClawMemoryEntity) memoryEntity: Repository; 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`: ```typescript 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 接口扩展 ```typescript // 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): Promise; update(id: number, partial: Partial>): Promise; delete(id: number): Promise; search(query: string, opts: MemorySearchOpts): Promise; list(opts: MemorySearchOpts): Promise; getById(id: number): Promise; close?(): Promise; // 新增方法 page(opts: MemoryPageOpts): Promise; count(opts: Omit): Promise; } ``` ### 3.4 MemoryAdminService — 查询优化策略 ```typescript @Provide() export class MemoryAdminService extends BaseService { @Inject() registry: MemoryProviderRegistry; @InjectEntityModel(NetaClawMemoryEntity) memoryEntity: Repository; @InjectEntityModel(NetaClawAgentEntity) agentEntity: Repository; 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` 表: ```typescript async function getAvailableTypes(memoryTypeRepo?: Repository): Promise { 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 感知可用的记忆类型,存储时选择正确类型。 ```typescript // 参数:无 // 返回:可用类型列表 [{ 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 了解自己已存储的记忆分布,辅助决策是更新还是新建。 ```typescript // 参数:无 // 返回:{ 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 |