# CmdCode 记忆系统增强计划
目标系统: https://cmdcode.cn
技术栈: PHP + MySQL + 文件系统 + Shell Worker
设计哲学: 零外部依赖、用户数据物理隔离、敏感信息加密记忆、异步非阻塞处理
存储预算: 约 30MB/用户/年,在现有 100MB 配额内
1. [项目现状分析](#一项目现状分析)
2. [核心设计目标](#二核心设计目标)
3. [数据架构设计](#三数据架构设计)
4. [安全与隐私设计](#四安全与隐私设计)
5. [数据库设计](#五数据库设计)
6. [用户文件系统设计](#六用户文件系统设计)
7. [加密子系统设计](#七加密子系统设计)
8. [后端 API 设计](#八后端-api-设计)
9. [前端改造设计](#九前端改造设计)
10. [异步记忆处理管道设计](#十异步记忆处理管道设计)
11. [检索排序算法设计](#十一检索排序算法设计)
12. [敏感信息加密记忆方案](#十二敏感信息加密记忆方案)
13. [隐私串扰风险评估与防护](#十三隐私串扰风险评估与防护)
14. [触发机制设计](#十四触发机制设计)
15. [存储配额管理设计](#十五存储配额管理设计)
16. [实施路线图](#十六实施路线图)
17. [附录:LLM Prompt 设计](#十七附录llm-prompt-设计)
经过对 https://cmdcode.cn/source/ 的完整遍历,CmdCode 项目由以下核心文件构成:
| 文件 | 大小 | 语言 | 职责 |
| :--- | :--- | :--- | :--- |
| ui.html | 79,992 bytes | JS/CSS | 完整前端 UI、Agent 循环、上下文压缩 |
| proxy.php | 37,747 bytes | PHP | 多供应商 API 代理、用户系统、文件管理 |
| config.enc.php | 1,988 bytes | PHP | AES-256-CBC 密钥加密存储 |
| long-task-cron-worker.sh | 4,528 bytes | Bash | 异步长任务 Worker (音乐/视频生成) |
| cron.d-long-task-worker | 411 bytes | Config | 系统 crontab 配置 (每15秒错峰触发) |
| long-task-worker-check.sh | 640 bytes | Bash | Worker 健康检查 (心跳+进程检测) |
| 现有组件 | 对应记忆能力 | 优势 | 缺陷 | 改造策略 |
| :--- | :--- | :--- | :--- | :--- |
| ui.html 上下文压缩 | L2 场景摘要雏形 | 能生成连贯的对话摘要,保留上下文骨架 | 压缩粒度为段落级别,无法提取独立的原子事实 | 增强压缩 Prompt,同时输出结构化的事实列表 |
| proxy.php API 代理 | 集中式后端 | 可承载业务逻辑,已有用户系统和配额管理 | 无任何记忆存储或检索功能 | 新增 memory Action 分支 |
| config.enc.php | 密钥管理 | 已有 AES-256-CBC 加密实现 | 仅用于 API Key 存储 | 复用其加密方法用于敏感记忆加密 |
| long-task-cron-worker.sh | 异步任务框架 | 成熟的进程锁、心跳检测、长任务执行能力 | 仅用于音乐/视频生成 | 创建 memory-worker.sh,复用相同架构 |
| 缺失 | L1 原子记忆层 | — | 没有结构化的"事实"提取与存储 | 全新构建 |
| 缺失 | L3 用户画像层 | — | 没有提炼长期稳定的用户特征 | 全新构建 |
| 缺失 | 智能检索与融合排序 | — | 只有关键词匹配,无 RRF、无热度统计 | 全新构建 |
| 缺失 | 敏感信息加密记忆 | — | 无加密记忆机制 | 全新构建 |
| 缺失 | 多用户记忆隔离 | — | 无记忆系统,无隔离问题 | 基于用户文件夹物理隔离 |
1. 上下文压缩算法:ui.html 中已实现近 50 行的压缩 Prompt,这本身就是 L2 场景摘要的雏形,是记忆系统的核心资产。
2. 异步 Worker 架构:long-task-cron-worker.sh 已具备进程锁、心跳检测、任务拉取与执行、状态回写等完整能力,可直接复制用于记忆处理。
3. 用户配额系统:proxy.php 已实现注册/登录、100MB 个人配额(访客共享 1GB),为记忆系统提供了现成的权限与空间管理基础。
4. 加密体系:config.enc.php 的 AES-256-CBC 加密实现可直接复用于敏感记忆的加密存储。
| 目标 | 描述 | 验收标准 |
| :--- | :--- | :--- |
| 长期记忆 | 跨会话记住用户的关键信息 | 用户说出"上次提到的那个库叫什么",Agent 能准确回答 |
| 多项目防混淆 | 不同项目/主题的记忆互不干扰 | 讨论 A 项目时不会错误引用 B 项目的信息 |
| 敏感信息加密记忆 | 能安全记住密码等敏感信息 | 加密存储,解密后使用,密文永不暴露给 LLM |
| 自动化运行 | 记忆的提取、整理完全自动化 | 用户无需手动操作,记忆在后台静默生长 |
| 非侵入集成 | 不改变现有核心交互流程 | 对话体验与改造前一致,无额外延迟 |
| 目标 | 描述 | 约束 |
| :--- | :--- | :--- |
| 零外部依赖 | 不引入任何新的外部服务 | 仅使用 PHP + MySQL + 文件系统 + Shell |
| 用户数据物理隔离 | 不同用户的记忆完全独立存储 | 每个用户拥有独立的 Memory 文件夹 |
| 存储成本可控 | 年存储量在配额内可控 | ≤ 30MB/用户/年 |
| 异步非阻塞 | 记忆处理不影响对话响应速度 | LLM 调用全部在 Worker 中执行 |
| 可追溯可审计 | 任何记忆都可回溯来源 | 每条 L1 记忆关联到产生它的对话上下文 |
保留的记忆层级:
┌─────────────────────────────────────────┐
│ L3 用户画像 │
│ "资深后端,偏好Python,喜欢简洁代码..." │
│ 大小: ~2 KB │
│ 更新频率: 每30条新L1记忆触发更新 │
├─────────────────────────────────────────┤
│ L2 场景归纳 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 电商项目 │ │ 博客项目 │ │ 默认场景 │ │
│ │ 摘要+背景 │ │ 摘要+背景 │ │ 摘要+背景 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ 单个场景: ~2 KB │
│ 场景数量: ≤ 20个/用户 │
├─────────────────────────────────────────┤
│ L1 原子记忆 │
│ • "用户数据库密码: xxx" [已加密] │
│ • "项目截止日期: 10月1日" │
│ • "偏好使用 PostgreSQL 而非 MySQL" │
│ • "上次报错是 NullPointerException" │
│ 单条: ~1.5 KB │
│ 日增: ~50条 │
├─────────────────────────────────────────┤
│ L0 原始对话 (不保留) │
│ 仅在上下文压缩时用于提取 L1/L2 │
│ 提取完成后原始对话被压缩或丢弃 │
└─────────────────────────────────────────┘
用户输入
│
▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ ui.html │────▶│ proxy.php │────▶│ LLM API │
│ 前端Agent │ │ API代理 │ │ 大模型 │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
│ ①发送前检索记忆 │ ②search_memories │
│ │ (RRF多信号融合) │
│ │ │
│ ③上下文压缩时入队 │ ④enqueue_extract │
│ (非阻塞fetch) │ (写入memory_tasks) │
│ │ │
│ │ ⑤memory-worker.sh │
│ │ 定时拉取任务 │
│ │ 调用LLM提取事实 │
│ │ 加密存储到文件系统 │
│ │ 更新画像 │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
│ 用户文件系统 │
│ /user_data/{user_id}/Memory/ │
│ ├── L1_facts.jsonl (加密原子记忆) │
│ ├── L2_scenes/ (场景归纳) │
│ └── L3_persona.json (用户画像) │
├──────────────────────────────────────────────────┤
│ MySQL 数据库 │
│ ├── memory_tasks (任务队列) │
│ └── memory_index (检索索引) │
└──────────────────────────────────────────────────┘
| 存储位置 | 存储内容 | 理由 |
| :--- | :--- | :--- |
| 用户文件系统 (/user_data/{id}/Memory/) | L1 原子记忆 (加密)、L2 场景、L3 画像 | 数据所有权归用户;纳入现有配额管理;便于备份与迁移;物理隔离 |
| MySQL 数据库 | 任务队列 (memory_tasks)、检索索引 (memory_index) | 任务队列需要原子操作和并发安全;检索索引需要 FULLTEXT 和快速排序 |
┌──────────────────────────────────────────────┐
│ 第零层:物理隔离 │
│ 每个用户独立文件夹 /user_data/{id}/Memory/ │
│ Linux 文件权限 0700 (仅所有者可读写) │
├──────────────────────────────────────────────┤
│ 第一层:加密存储 │
│ AES-256-CBC + HMAC 完整性校验 │
│ 密钥 = HMAC(masterKey, userId) │
│ 每个用户独立派生密钥 │
├──────────────────────────────────────────────┤
│ 第二层:传输安全 │
│ 记忆 API 仅在 proxy.php 内部调用 │
│ 不暴露独立的公网端点 │
├──────────────────────────────────────────────┤
│ 第三层:身份校验 │
│ userId 从服务端登录态强制获取 │
│ 前端不可伪造 │
├──────────────────────────────────────────────┤
│ 第四层:检索安全 │
│ 密文文件永不被 LLM 直接读取 │
│ 检索时在内存中解密,用完即弃 │
├──────────────────────────────────────────────┤
│ 第五层:配额保护 │
│ 记忆文件纳入 100MB 配额管理 │
│ 防止恶意用户通过记忆系统撑爆磁盘 │
└──────────────────────────────────────────────┘
| 风险点 | 风险等级 | 攻击向量 | 防护措施 | 残余风险 |
| :--- | :--- | :--- | :--- | :--- |
| 跨用户记忆混淆 | 极低 | SQL 查询遗漏 user_id 条件 | 所有 SQL 强制带 WHERE user_id = :uid;代码审计 | 无 |
| API Key 泄露到记忆 | 低 | 用户在对话中粘贴敏感信息,被 LLM 提取 | 加密存储而非丢弃;解密后仅在内存中使用 | 内存中的明文可能被 core dump 捕获 |
| LLM 侧信道泄露 | 极低 | 记忆提取时不同用户数据混合在同一个 Prompt | 记忆提取按用户独立调用 LLM | 无 |
| 前端缓存串扰 | 低 | 不同用户使用同一浏览器,localStorage 残留 | 登出时清除所有记忆相关变量 | 公共电脑场景需额外注意 |
| 文件系统权限绕过 | 极低 | 攻击者获取服务器 shell 权限 | 文件夹权限 0700;Web 服务器以独立用户运行 | 服务器被完全攻陷时无解 |
| Worker 进程间串扰 | 低 | Worker 同时处理多个用户任务 | 每次只处理一个用户;处理完清理内存变量 | 需确保 PHP 进程间无共享内存 |
| 加密密钥泄露 | 中 | 主密钥文件被读取 | 主密钥存储在 config.enc.php,已有 .htaccess 保护 | 依赖 Web 服务器配置正确 |
假设的攻击者能力:
防御结果:
1. 最小化 MySQL 存储:MySQL 仅存储任务队列和检索索引,不存储记忆内容本身
2. 全文索引支撑检索:利用 MySQL FULLTEXT 索引实现高效关键词检索
3. 无向量扩展:不引入任何向量数据库或扩展,完全基于关键词匹配 + RRF 多信号融合
-- 记忆任务队列表 (异步处理用)
-- 存储引擎: InnoDB
-- 字符集: utf8mb4
CREATE TABLE memory_tasks (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID,外键关联用户表',
task_type VARCHAR(32) NOT NULL COMMENT '任务类型: extract_facts / update_persona',
payload JSON DEFAULT NULL COMMENT '任务负载数据 (如待提取的对话消息)',
status VARCHAR(16) DEFAULT 'pending' COMMENT '任务状态: pending / processing / done / failed',
retry_count TINYINT DEFAULT 0 COMMENT '重试次数',
error_message TEXT DEFAULT NULL COMMENT '失败时的错误信息',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_user_status (user_id, status),
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='记忆异步处理任务队列';
字段说明:
payload:JSON 格式,存储待提取的对话消息数组retry_count:最多重试 3 次,超过则标记为 failedstatus 状态机:pending → processing → done/failed
-- 记忆检索索引表 (快速检索用)
-- 存储引擎: InnoDB
-- 字符集: utf8mb4
CREATE TABLE memory_index (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '索引ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
fact_id VARCHAR(64) NOT NULL COMMENT '对应 L1_facts.jsonl 中的事实ID',
fact_hash CHAR(32) NOT NULL COMMENT '事实原文的MD5哈希 (用于去重)',
fact_preview VARCHAR(255) NOT NULL COMMENT '事实的明文预览 (前255字符,用于全文索引)',
category VARCHAR(64) DEFAULT NULL COMMENT '事实分类: preference/decision/constraint/credential/event',
l2_scene_id VARCHAR(32) DEFAULT NULL COMMENT '所属场景ID',
importance TINYINT DEFAULT 5 COMMENT '重要度评分 (1-10)',
access_count INT DEFAULT 0 COMMENT '访问次数 (热度统计)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
last_accessed_at TIMESTAMP NULL DEFAULT NULL COMMENT '最后访问时间',
UNIQUE KEY uk_user_fact (user_id, fact_id),
UNIQUE KEY uk_user_hash (user_id, fact_hash),
FULLTEXT INDEX ft_preview (fact_preview),
INDEX idx_user_scene (user_id, l2_scene_id),
INDEX idx_user_importance (user_id, importance),
INDEX idx_user_accessed (user_id, last_accessed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='记忆快速检索索引表';
字段说明:
fact_id:与 L1_facts.jsonl 中每条记录的 id 一一对应fact_preview:存储事实文本的前 255 字符明文(用于关键词检索),完整加密内容存在文件中fact_hash:事实原文(加密前)的 MD5,用于精确去重category:记忆类型标签,可用于检索时加权importance:1-10 重要度,由 LLM 在提取时评估access_count:每次检索命中后 +1,用于热度排序信号
| 索引名称 | 类型 | 用途 |
| :--- | :--- | :--- |
| ft_preview | FULLTEXT | 支持 MATCH...AGAINST 全文检索,是检索候选集生成的核心 |
| uk_user_fact | UNIQUE | 防止同一用户重复插入相同事实 |
| uk_user_hash | UNIQUE | MD5 精确去重的数据库层面保障 |
| idx_user_scene | INDEX | 按场景快速筛选记忆 |
| idx_user_importance | INDEX | 按重要度排序 |
| idx_user_accessed | INDEX | 按时间排序,支持时间衰减计算 |
uk_user_hash 唯一约束 + 应用层面插入前检查processing,防止重复执行
/user_data/
└── {user_id}/ # 用户根目录 (已存在)
├── files/ # 现有用户文件存储 (已存在)
├── config.enc.php # 现有加密配置 (已存在)
└── Memory/ # 记忆系统根目录 (新增)
├── L1_facts.jsonl # L1 原子记忆文件
├── L1_facts.jsonl.backup # L1 文件备份 (写入前自动创建)
├── L2_scenes/ # L2 场景归纳目录
│ ├── scene_index.json # 场景索引文件
│ ├── scene_default.json # 默认场景
│ ├── scene_{id}.json # 其他场景 (id为创建时间戳)
│ └── ...
└── L3_persona.json # L3 用户画像文件
L1_facts.jsonl)
JSONL 格式,每行一个完整的 JSON 对象。选择 JSONL 而非单一 JSON 数组的原因:支持追加写入(无需读取整个文件),支持按行随机读取。
{"id":"fact_20260517_001","hash":"a1b2c3d4e5f6...","category":"preference","importance":8,"l2_scene_id":"scene_default","encrypted":{"iv":"base64_iv_string","data":"base64_ciphertext_string","mac":"sha256_hmac_string"},"created_at":"2026-05-17 14:30:22"}
{"id":"fact_20260517_002","hash":"b2c3d4e5f6a1...","category":"credential","importance":10,"l2_scene_id":"scene_default","encrypted":{"iv":"base64_iv_string","data":"base64_ciphertext_string","mac":"sha256_hmac_string"},"created_at":"2026-05-17 14:31:05"}
字段说明:
id:唯一标识,格式 fact_{YYYYMMDD}_{序号}hash:事实原文(加密前)的 MD5,用于去重和索引关联category:事实分类,枚举值见下表importance:1-10 重要度评分l2_scene_id:所属场景 IDencrypted:加密后的内容,包含 iv、密文、HMAC 校验码created_at:创建时间戳
事实分类枚举:
| category | 含义 | 示例 | 检索权重 |
| :--- | :--- | :--- | :--- |
| preference | 用户偏好 | "用户喜欢简洁的代码风格" | 1.0 |
| decision | 重要决策 | "决定使用 PostgreSQL 替代 MySQL" | 1.2 |
| constraint | 约束条件 | "项目必须在10月1日前上线" | 1.3 |
| credential | 凭证信息 | "数据库密码: xxx" | 1.5 (加密) |
| event | 事件记录 | "上周五服务器发生宕机" | 0.8 |
| knowledge | 知识碎片 | "Python 3.12 支持新语法" | 0.7 |
| contact | 联系人信息 | "合作方联系人: 张三, 138xxxx" | 1.1 |
L2_scenes/scene_{id}.json)
{
"id": "scene_default",
"name": "默认场景",
"summary": "用户正在使用 CmdCode 进行日常对话和编程辅助...",
"context": "讨论话题涉及 Python 后端开发、数据库优化等...",
"memory_ids": [
"fact_20260517_001",
"fact_20260517_002",
"fact_20260517_005"
],
"memory_count": 156,
"created_at": "2026-05-01 10:00:00",
"updated_at": "2026-05-17 14:30:00"
}
字段说明:
summary:场景的概括描述(由上下文压缩算法生成)context:更详细的背景上下文memory_ids:关联的 L1 记忆 ID 列表(最多保留最近 500 条引用)memory_count:关联记忆总数updated_at:最后活跃时间,用于判断场景是否"冷却"
L2_scenes/scene_index.json)
{
"active_scene_id": "scene_default",
"scenes": [
{
"id": "scene_default",
"name": "默认场景",
"memory_count": 156,
"last_active": "2026-05-17 14:30:00"
},
{
"id": "scene_1715936400",
"name": "电商后端开发",
"memory_count": 89,
"last_active": "2026-05-16 20:15:00"
}
]
}
L3_persona.json)
{
"user_id": "user_xxx",
"traits": "该用户是一名拥有10年经验的资深后端工程师,主要使用 Python 和 Go 语言。偏好简洁直接的代码风格,对性能优化有极高的敏感度。经常在深夜工作(UTC+8 时区的 22:00-02:00)。正在主导开发一个大型电商系统,对数据库设计有自己独到的见解。不喜欢冗长的解释,希望直接得到可执行的代码。",
"structured": {
"role": "资深后端工程师",
"experience": "10年",
"languages": ["Python", "Go"],
"code_style": "简洁直接",
"focus_areas": ["性能优化", "数据库设计"],
"work_hours": "22:00-02:00 (UTC+8)",
"active_projects": ["大型电商系统"],
"preferences": {
"communication": "简洁、直接、可执行",
"explanation_style": "short"
}
},
"last_scene_id": "scene_1715936400",
"fact_count": 245,
"created_at": "2026-05-01 10:00:00",
"updated_at": "2026-05-17 14:30:00"
}
字段说明:
traits:画像的自然语言描述(可直接注入 LLM Prompt)structured:结构化的画像特征(便于程序化使用)last_scene_id:最后一次活跃的场景fact_count:画像基于的 L1 记忆总数
/**
* 原子写入文件
* 先写临时文件,成功后再 rename(rename 在 Linux 同分区上是原子操作)
*/
function atomicFileWrite(string $filePath, string $content): bool {
$tmpPath = $filePath . '.tmp.' . getmypid();
if (file_put_contents($tmpPath, $content, LOCK_EX) === false) {
return false;
}
if (!rename($tmpPath, $filePath)) {
unlink($tmpPath);
return false;
}
return true;
}
/**
* 安全追加一行到 JSONL 文件
* 先备份,再追加,失败则回滚
*/
function safeAppendJSONL(string $filePath, array $record): bool {
// 创建备份
$backupPath = $filePath . '.backup';
if (file_exists($filePath)) {
copy($filePath, $backupPath);
}
// 追加写入
$line = json_encode($record, JSON_UNESCAPED_UNICODE) . "\n";
if (file_put_contents($filePath, $line, FILE_APPEND | LOCK_EX) === false) {
// 恢复备份
if (file_exists($backupPath)) {
rename($backupPath, $filePath);
}
return false;
}
return true;
}
1. 存储安全:即使服务器文件系统被非法读取,记忆内容也无法被解密
2. 用户隔离:不同用户使用不同的加密密钥,一个用户的密钥泄露不影响其他用户
3. 完整性校验:检测密文是否被篡改
4. 与现有体系兼容:复用 config.enc.php 中的主密钥
masterKey (来自 config.enc.php,AES-256 密钥)
│
├── HMAC-SHA256(masterKey, userId + "memory") → userMemoryKey
│ 用于加密/解密该用户的记忆内容
│
└── HMAC-SHA256(masterKey, userId + "hmac") → userHMACKey
用于 HMAC 完整性校验
/**
* 为用户派生记忆加密密钥
*/
function deriveUserMemoryKeys(string $userId): array {
$masterKey = getMasterKey(); // 从 config.enc.php 读取
return [
'encrypt' => hash_hmac('sha256', $userId . ':memory:encrypt', $masterKey, true),
'hmac' => hash_hmac('sha256', $userId . ':memory:hmac', $masterKey, true),
];
}
/**
* 加密单条原子记忆
*
* @param string $plaintext 明文事实
* @param string $userId 用户ID
* @return array 包含 iv, data, mac 的加密数据
* @throws Exception 加密失败时抛出
*/
function encryptFact(string $plaintext, string $userId): array {
$keys = deriveUserMemoryKeys($userId);
// 生成随机 IV (16 字节用于 AES-256-CBC)
$iv = openssl_random_pseudo_bytes(16);
if ($iv === false) {
throw new Exception('无法生成随机 IV');
}
// AES-256-CBC 加密
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-cbc',
$keys['encrypt'],
OPENSSL_RAW_DATA,
$iv
);
if ($ciphertext === false) {
throw new Exception('加密失败: ' . openssl_error_string());
}
// HMAC 完整性校验 (iv + ciphertext)
$mac = hash_hmac('sha256', $iv . $ciphertext, $keys['hmac']);
return [
'iv' => base64_encode($iv),
'data' => base64_encode($ciphertext),
'mac' => $mac,
];
}
/**
* 解密单条原子记忆
*
* @param array $encrypted 加密数据 {iv, data, mac}
* @param string $userId 用户ID
* @return string 明文事实
* @throws Exception 解密失败或完整性校验失败时抛出
*/
function decryptFact(array $encrypted, string $userId): string {
$keys = deriveUserMemoryKeys($userId);
$iv = base64_decode($encrypted['iv'], true);
$ciphertext = base64_decode($encrypted['data'], true);
$mac = $encrypted['mac'];
if ($iv === false || $ciphertext === false) {
throw new Exception('Base64 解码失败');
}
// 完整性校验
$calculatedMac = hash_hmac('sha256', $iv . $ciphertext, $keys['hmac']);
if (!hash_equals($calculatedMac, $mac)) {
throw new Exception('记忆数据完整性校验失败:数据可能已被篡改');
}
// 解密
$plaintext = openssl_decrypt(
$ciphertext,
'aes-256-cbc',
$keys['encrypt'],
OPENSSL_RAW_DATA,
$iv
);
if ($plaintext === false) {
throw new Exception('解密失败: ' . openssl_error_string());
}
return $plaintext;
}
1. IV 唯一性:每次加密使用新的随机 IV(openssl_random_pseudo_bytes)
2. 认证加密:使用 Encrypt-then-MAC 方案,先加密再计算 HMAC
3. 常数时间比较:使用 hash_equals() 而非 === 比较 HMAC,防止时序攻击
4. 密钥管理:主密钥仅存储在 config.enc.php 中,不硬编码在代码中
5. 明文生命周期:解密后的明文仅在 PHP 内存变量中存在,用完即被垃圾回收
在 proxy.php 中新增 action=memory 分支,所有记忆相关操作通过 sub_action 参数区分:
| sub_action | HTTP 方法 | 功能 | 调用时机 |
| :--- | :--- | :--- | :--- |
| enqueue_extract | POST | 入队记忆提取任务 | 上下文压缩后(非阻塞) |
| search | GET | 多信号 RRF 检索记忆 | 用户每次发送消息前 |
| get_persona | GET | 获取用户画像 (L3) | 每次构造 System Prompt 时 |
| get_scene | GET | 获取指定场景上下文 (L2) | 进入对话时 |
| switch_scene | POST | 创建或切换场景 | 用户切换项目时 |
| update_scene_summary | POST | 更新场景摘要 | 上下文压缩后 |
| get_all_scenes | GET | 获取用户所有场景列表 | 场景选择 UI 展示 |
POST /proxy.php?action=memory&sub_action=enqueue_extract
Content-Type: application/json
{
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."},
...
],
"scene_id": "scene_default"
}
Response 200:
{
"status": "queued",
"task_id": 12345,
"estimated_processing_time": "约30秒后完成"
}
后端逻辑:
case 'enqueue_extract':
$userId = getAuthenticatedUserId();
$data = json_decode(file_get_contents('php://input'), true);
// 检查配额
$estimatedSize = strlen(json_encode($data['messages'])) * 0.1; // 估计新增记忆大小
if (!checkMemoryQuota($userId, $estimatedSize)) {
http_response_code(507); // Insufficient Storage
echo json_encode(['error' => '存储配额不足,请清理旧数据']);
break;
}
// 写入任务队列
$stmt = $pdo->prepare(
"INSERT INTO memory_tasks (user_id, task_type, payload) VALUES (?, 'extract_facts', ?)"
);
$stmt->execute([
$userId,
json_encode([
'messages' => $data['messages'],
'scene_id' => $data['scene_id'] ?? 'scene_default'
])
]);
echo json_encode([
'status' => 'queued',
'task_id' => $pdo->lastInsertId()
]);
break;
GET /proxy.php?action=memory&sub_action=search&query=数据库选型&scene_id=scene_default&limit=10
Response 200:
{
"facts": [
{
"id": "fact_20260517_002",
"fact": "决定使用 PostgreSQL 替代 MySQL",
"category": "decision",
"importance": 9,
"rrf_score": 0.0452,
"scene_id": "scene_default"
},
...
],
"count": 5,
"query_time_ms": 12
}
后端逻辑:详见第十一章"检索排序算法设计"。
GET /proxy.php?action=memory&sub_action=get_persona
Response 200:
{
"traits": "该用户是一名拥有10年经验的资深后端工程师...",
"structured": { ... },
"updated_at": "2026-05-17 14:30:00"
}
Response 404 (新用户,无画像):
{
"traits": "",
"message": "尚未生成用户画像,需要更多对话数据"
}
GET /proxy.php?action=memory&sub_action=get_scene&scene_id=scene_default
Response 200:
{
"id": "scene_default",
"name": "默认场景",
"summary": "用户正在使用 CmdCode 进行日常对话...",
"context": "讨论话题涉及 Python 后端开发...",
"memory_count": 156
}
POST /proxy.php?action=memory&sub_action=switch_scene
Content-Type: application/json
{
"name": "电商后端开发",
"switch_to_existing": true // 如果同名场景已存在则直接切换
}
Response 200:
{
"scene_id": "scene_1715936400",
"name": "电商后端开发",
"is_new": false
}
后端逻辑:
case 'switch_scene':
$userId = getAuthenticatedUserId();
$data = json_decode(file_get_contents('php://input'), true);
$memoryDir = "/user_data/{$userId}/Memory/";
$sceneIndexPath = $memoryDir . 'L2_scenes/scene_index.json';
// 加载场景索引
$sceneIndex = file_exists($sceneIndexPath)
? json_decode(file_get_contents($sceneIndexPath), true)
: ['active_scene_id' => 'scene_default', 'scenes' => []];
// 检查是否已存在同名场景
$existingScene = null;
foreach ($sceneIndex['scenes'] as $scene) {
if ($scene['name'] === $data['name']) {
$existingScene = $scene;
break;
}
}
if ($existingScene && ($data['switch_to_existing'] ?? true)) {
// 切换到已存在的场景
$sceneIndex['active_scene_id'] = $existingScene['id'];
file_put_contents($sceneIndexPath, json_encode($sceneIndex, JSON_UNESCAPED_UNICODE));
echo json_encode(['scene_id' => $existingScene['id'], 'name' => $data['name'], 'is_new' => false]);
break;
}
// 创建新场景
$sceneId = 'scene_' . time();
$sceneData = [
'id' => $sceneId,
'name' => $data['name'],
'summary' => '',
'context' => '',
'memory_ids' => [],
'memory_count' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$sceneFilePath = $memoryDir . "L2_scenes/{$sceneId}.json";
file_put_contents($sceneFilePath, json_encode($sceneData, JSON_UNESCAPED_UNICODE));
// 更新索引
$sceneIndex['active_scene_id'] = $sceneId;
$sceneIndex['scenes'][] = [
'id' => $sceneId,
'name' => $data['name'],
'memory_count' => 0,
'last_active' => date('Y-m-d H:i:s')
];
file_put_contents($sceneIndexPath, json_encode($sceneIndex, JSON_UNESCAPED_UNICODE));
echo json_encode(['scene_id' => $sceneId, 'name' => $data['name'], 'is_new' => true]);
break;
所有 action=memory 的请求在进入业务逻辑前,必须通过以下安全检查:
// 在 proxy.php 的 memory 分支最顶部
if ($_GET['action'] === 'memory') {
// 1. 强制身份验证
$userId = getAuthenticatedUserId();
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => '请先登录']);
exit;
}
// 2. 确保用户目录存在
$memoryDir = "/user_data/{$userId}/Memory/";
if (!is_dir($memoryDir)) {
if (!mkdir($memoryDir, 0700, true)) {
http_response_code(500);
echo json_encode(['error' => '无法创建记忆目录']);
exit;
}
}
// 3. 确保子目录存在
$scenesDir = $memoryDir . 'L2_scenes/';
if (!is_dir($scenesDir)) {
mkdir($scenesDir, 0700, true);
}
// 4. 初始化默认场景 (如果是新用户)
$sceneIndexPath = $scenesDir . 'scene_index.json';
if (!file_exists($sceneIndexPath)) {
$defaultScene = [
'id' => 'scene_default',
'name' => '默认场景',
'summary' => '',
'context' => '',
'memory_ids' => [],
'memory_count' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
file_put_contents("{$scenesDir}scene_default.json", json_encode($defaultScene, JSON_UNESCAPED_UNICODE));
$sceneIndex = [
'active_scene_id' => 'scene_default',
'scenes' => [
[
'id' => 'scene_default',
'name' => '默认场景',
'memory_count' => 0,
'last_active' => date('Y-m-d H:i:s')
]
]
];
file_put_contents($sceneIndexPath, json_encode($sceneIndex, JSON_UNESCAPED_UNICODE));
}
// 继续处理 sub_action...
$sub = $_GET['sub_action'] ?? 'search';
// ...
}
1. 非侵入性:不改变 ui.html 现有的对话核心流程
2. 渐进增强:记忆功能作为增强层,失败时不影响基础对话
3. 异步非阻塞:所有记忆相关网络请求不阻塞 UI
4. 状态管理:利用 localStorage 维护当前场景 ID 等轻量状态
sendMessage() — 增加记忆检索步骤
在发送消息前,检索相关记忆并注入 System Prompt:
async function sendMessage(userInput) {
// === 新增:记忆检索 (非阻塞,失败不影响主流程) ===
let memoryContext = '';
try {
const memories = await fetchMemories(userInput);
const persona = await fetchPersona();
const scene = await fetchCurrentScene();
if (persona) {
memoryContext += `\n[用户长期画像]\n${persona}`;
}
if (scene && scene.summary) {
memoryContext += `\n[当前项目背景: ${scene.name}]\n${scene.summary}`;
}
if (memories.length > 0) {
memoryContext += `\n[相关历史记忆]\n${memories.map(m => `- ${m.fact}`).join('\n')}`;
}
} catch (e) {
console.warn('记忆检索失败,继续对话:', e);
// 静默失败,不影响主对话
}
// === 现有逻辑:构造消息列表 ===
const systemPrompt = BASE_SYSTEM_PROMPT + memoryContext;
const messages = [
{ role: 'system', content: systemPrompt },
...conversationHistory, // 你现有的压缩后历史
{ role: 'user', content: userInput }
];
// === 现有逻辑:调用 LLM ===
const response = await callLLM(messages);
// ...
}
contextCompaction() — 增强压缩 Prompt,同步入队提取任务
在上下文压缩的 LLM 调用中,要求同时返回摘要和结构化事实列表:
async function compactAndExtract(messagesToCompress) {
const prompt = `你是一个信息提取器。请完成以下两项任务,并用严格的JSON格式返回。
**任务1: 生成对话摘要**
对以下对话生成一个简洁的摘要,保留所有关键信息、决策和结论。(key: "summary")
**任务2: 提取原子事实**
从对话中提取所有重要的事实,每条事实必须是一句完整、独立、可理解的陈述。
提取时遵循以下规则:
1. 提取用户的偏好、习惯、约束条件
2. 提取做出的任何决定及其原因
3. 提取提到的项目、工具、技术栈等具体信息
4. 提取凭据类信息(密码、Token等),但标记为 credential 类型
5. 评估每条事实的重要度 (1-10),凭据类默认10
6. 事实必须自包含,脱离对话也能独立理解
7. 如果没有值得记住的事实,返回空数组
**输出JSON格式**:
{
"summary": "对话的连贯摘要...",
"facts": [
{
"fact": "用户决定使用PostgreSQL替代MySQL作为主数据库",
"category": "decision",
"importance": 9
}
]
}
**对话内容**:
${JSON.stringify(messagesToCompress)}`;
const response = await callLLM([{ role: 'user', content: prompt }]);
let result;
try {
result = JSON.parse(response.content);
} catch (e) {
// 解析失败,降级为仅摘要
result = { summary: response.content, facts: [] };
}
// === 非阻塞:入队记忆提取任务 ===
if (result.facts && result.facts.length > 0) {
fetch('/proxy.php?action=memory&sub_action=enqueue_extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messagesToCompress,
scene_id: currentSceneId
})
}).catch(e => console.warn('记忆入队失败:', e));
}
// === 现有逻辑:返回摘要用于替换压缩后的历史 ===
return result.summary;
}
/**
* 检索相关记忆
*/
async function fetchMemories(query) {
const sceneId = localStorage.getItem('cmdcode_current_scene') || 'scene_default';
const resp = await fetch(
`/proxy.php?action=memory&sub_action=search&query=${encodeURIComponent(query)}&scene_id=${sceneId}&limit=10`
);
if (!resp.ok) throw new Error('记忆检索失败');
const data = await resp.json();
return data.facts || [];
}
/**
* 获取用户画像
*/
async function fetchPersona() {
const resp = await fetch('/proxy.php?action=memory&sub_action=get_persona');
if (!resp.ok) return null;
const data = await resp.json();
return data.traits || null;
}
/**
* 获取当前活跃场景
*/
async function fetchCurrentScene() {
const sceneId = localStorage.getItem('cmdcode_current_scene') || 'scene_default';
const resp = await fetch(
`/proxy.php?action=memory&sub_action=get_scene&scene_id=${sceneId}`
);
if (!resp.ok) return null;
return await resp.json();
}
/**
* 切换场景
*/
async function switchScene(sceneName) {
const resp = await fetch('/proxy.php?action=memory&sub_action=switch_scene', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: sceneName })
});
const data = await resp.json();
localStorage.setItem('cmdcode_current_scene', data.scene_id);
return data;
}
在 ui.html 初始化时,从 localStorage 恢复状态:
// 页面初始化
(function() {
// 恢复场景状态
const savedSceneId = localStorage.getItem('cmdcode_current_scene');
if (savedSceneId) {
currentSceneId = savedSceneId;
} else {
currentSceneId = 'scene_default';
}
// ...其他初始化代码
})();
// 用户登出时清理
function logout() {
localStorage.removeItem('cmdcode_current_scene');
// ...其他清理代码
}
// 页面关闭时不做清理(保留场景状态供下次使用)
在界面中添加一个轻量级的场景切换入口:
<!-- 可放在对话输入框上方或侧边栏 -->
<div id="scene-switcher" style="display: flex; align-items: center; gap: 8px; padding: 4px 12px; font-size: 12px; color: #888;">
<span>📂</span>
<select id="scene-select" onchange="handleSceneSwitch(this.value)">
<option value="scene_default">默认场景</option>
</select>
<button onclick="createNewScene()" title="新建场景">+</button>
</div>
1. 完全异步:记忆提取和画像更新的 LLM 调用全部在 Worker 中执行
2. 可靠执行:任务持久化到 MySQL,Worker 崩溃后可恢复
3. 资源可控:每次只处理有限数量任务,避免 LLM API 过载
4. 复用现有架构:直接基于 long-task-cron-worker.sh 的模式构建
┌─────────┐ ┌────────────┐ ┌─────────┐ ┌──────┐
│ pending │────▶│ processing │────▶│ done │ │failed│
└─────────┘ └────────────┘ └─────────┘ └──────┘
│ ▲
│ (处理失败,retry_count < 3) │
└──────────────────────────────────┘
│ (retry_count >= 3)
└──────────────────────────────────▶
pending:任务已入队,等待 Worker 拉取processing:Worker 正在处理done:处理成功failed:超过最大重试次数,需人工介入
memory-worker.sh)
#!/bin/bash
# ============================================================
# CmdCode 记忆系统异步处理 Worker
# 复用 long-task-cron-worker.sh 的架构模式
# ============================================================
# --- 配置 ---
LOCKFILE="/tmp/memory-worker-$(whoami).lock"
MAX_TASKS_PER_RUN=5
MAX_RETRY=3
PHP_PATH="/usr/bin/php"
WORKER_PHP="/path/to/cmdcode/memory_worker.php"
# --- 进程锁 (防止并发执行) ---
exec 200>"$LOCKFILE"
if ! flock -n 200; then
echo "[$(date)] 已有 Worker 实例在运行,退出"
exit 0
fi
# --- 拉取待处理任务 ---
TASKS_JSON=$($PHP_PATH -r '
require "/path/to/cmdcode/db.php";
$tasks = $pdo->query(
"SELECT id, user_id, task_type, payload, retry_count
FROM memory_tasks
WHERE status = \"pending\"
ORDER BY created_at ASC
LIMIT '$MAX_TASKS_PER_RUN'"
)->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($tasks, JSON_UNESCAPED_UNICODE);
')
if [ "$TASKS_JSON" == "[]" ]; then
echo "[$(date)] 无待处理任务,退出"
exit 0
fi
# --- 逐任务处理 ---
echo "$TASKS_JSON" | jq -c '.[]' | while read -r task; do
TASK_ID=$(echo "$task" | jq -r '.id')
USER_ID=$(echo "$task" | jq -r '.user_id')
TASK_TYPE=$(echo "$task" | jq -r '.task_type')
RETRY_COUNT=$(echo "$task" | jq -r '.retry_count')
PAYLOAD=$(echo "$task" | jq -r '.payload')
echo "[$(date)] 开始处理任务 #$TASK_ID: user=$USER_ID type=$TASK_TYPE retry=$RETRY_COUNT"
# --- 标记为 processing ---
$PHP_PATH -r '
require "/path/to/cmdcode/db.php";
$pdo->prepare("UPDATE memory_tasks SET status = \"processing\", updated_at = NOW() WHERE id = ?")
->execute(['$TASK_ID']);
'
# --- 执行任务 ---
START_TIME=$(date +%s)
if $PHP_PATH "$WORKER_PHP" "$TASK_TYPE" "$USER_ID" "$PAYLOAD"; then
# 成功
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "[$(date)] 任务 #$TASK_ID 完成,耗时 ${DURATION}s"
$PHP_PATH -r '
require "/path/to/cmdcode/db.php";
$pdo->prepare("UPDATE memory_tasks SET status = \"done\", updated_at = NOW() WHERE id = ?")
->execute(['$TASK_ID']);
'
else
# 失败
NEW_RETRY=$((RETRY_COUNT + 1))
if [ "$NEW_RETRY" -ge "$MAX_RETRY" ]; then
echo "[$(date)] 任务 #$TASK_ID 失败,已达最大重试次数"
$PHP_PATH -r '
require "/path/to/cmdcode/db.php";
$pdo->prepare("UPDATE memory_tasks SET status = \"failed\", retry_count = ?, error_message = \"超过最大重试次数\", updated_at = NOW() WHERE id = ?")
->execute(['$NEW_RETRY', '$TASK_ID']);
'
else
echo "[$(date)] 任务 #$TASK_ID 失败,将在下次重试 (第${NEW_RETRY}次)"
$PHP_PATH -r '
require "/path/to/cmdcode/db.php";
$pdo->prepare("UPDATE memory_tasks SET status = \"pending\", retry_count = ?, error_message = \"处理失败,等待重试\", updated_at = NOW() WHERE id = ?")
->execute(['$NEW_RETRY', '$TASK_ID']);
'
fi
fi
done
echo "[$(date)] 本轮处理完成"
exit 0
memory_worker.php)
#!/usr/bin/env php
<?php
/**
* CmdCode 记忆处理 Worker
* 由 memory-worker.sh 调用,执行具体的记忆提取和画像更新逻辑
*
* 用法: php memory_worker.php <task_type> <user_id> <payload_json>
*/
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/memory_functions.php'; // 加密、检索等函数
// 解析命令行参数
if ($argc < 4) {
fwrite(STDERR, "用法: php memory_worker.php <task_type> <user_id> <payload_json>\n");
exit(1);
}
$taskType = $argv[1];
$userId = $argv[2];
$payload = json_decode($argv[3], true);
if ($payload === null) {
fwrite(STDERR, "无法解析 payload JSON\n");
exit(1);
}
$memoryDir = "/user_data/{$userId}/Memory/";
// 确保目录存在
if (!is_dir($memoryDir)) {
mkdir($memoryDir, 0700, true);
}
if (!is_dir($memoryDir . 'L2_scenes/')) {
mkdir($memoryDir . 'L2_scenes/', 0700, true);
}
switch ($taskType) {
case 'extract_facts':
processExtractFacts($userId, $payload, $memoryDir, $pdo);
break;
case 'update_persona':
processUpdatePersona($userId, $memoryDir, $pdo);
break;
default:
fwrite(STDERR, "未知的任务类型: $taskType\n");
exit(1);
}
exit(0);
// ============================================================
// 任务处理函数
// ============================================================
/**
* 处理事实提取任务
*/
function processExtractFacts(string $userId, array $payload, string $memoryDir, PDO $pdo): void {
$messages = $payload['messages'] ?? [];
$sceneId = $payload['scene_id'] ?? 'scene_default';
if (empty($messages)) {
fwrite(STDERR, "没有待提取的消息\n");
return;
}
// 1. 调用 LLM 提取事实
$facts = callLLMForExtraction($messages);
if (empty($facts)) {
echo "未提取到任何事实\n";
return;
}
// 2. 存储每条事实
$factsFilePath = $memoryDir . 'L1_facts.jsonl';
$storedCount = 0;
foreach ($facts as $fact) {
$factId = 'fact_' . date('Ymd') . '_' . str_pad(++$storedCount, 3, '0', STR_PAD_LEFT);
$factHash = md5($fact['fact']);
// 去重检查
$stmt = $pdo->prepare(
"SELECT id FROM memory_index WHERE user_id = ? AND fact_hash = ?"
);
$stmt->execute([$userId, $factHash]);
if ($stmt->fetch()) {
echo "跳过重复事实: {$fact['fact']}\n";
continue;
}
// 加密
$encrypted = encryptFact($fact['fact'], $userId);
// 写入 L1 文件
$record = [
'id' => $factId,
'hash' => $factHash,
'category' => $fact['category'] ?? 'knowledge',
'importance' => $fact['importance'] ?? 5,
'l2_scene_id' => $sceneId,
'encrypted' => $encrypted,
'created_at' => date('Y-m-d H:i:s')
];
if (!safeAppendJSONL($factsFilePath, $record)) {
fwrite(STDERR, "写入 L1 文件失败: $factId\n");
continue;
}
// 写入索引表
$preview = mb_substr($fact['fact'], 0, 255);
$stmt = $pdo->prepare(
"INSERT INTO memory_index (user_id, fact_id, fact_hash, fact_preview, category, l2_scene_id, importance)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$userId, $factId, $factHash, $preview,
$fact['category'] ?? 'knowledge',
$sceneId,
$fact['importance'] ?? 5
]);
// 更新场景的记忆计数
updateSceneMemoryCount($userId, $sceneId, $memoryDir);
}
echo "成功存储 {$storedCount} 条原子记忆\n";
// 3. 检查是否需要触发画像更新 (新增记忆超过阈值)
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM memory_index WHERE user_id = ?"
);
$stmt->execute([$userId]);
$totalMemories = $stmt->fetchColumn();
// 读取当前画像
$personaFile = $memoryDir . 'L3_persona.json';
$currentFactCount = 0;
if (file_exists($personaFile)) {
$persona = json_decode(file_get_contents($personaFile), true);
$currentFactCount = $persona['fact_count'] ?? 0;
}
// 每新增 30 条记忆触发一次画像更新
if ($totalMemories - $currentFactCount >= 30) {
echo "触发画像更新任务\n";
$stmt = $pdo->prepare(
"INSERT INTO memory_tasks (user_id, task_type) VALUES (?, 'update_persona')"
);
$stmt->execute([$userId]);
}
}
/**
* 处理用户画像更新任务
*/
function processUpdatePersona(string $userId, string $memoryDir, PDO $pdo): void {
$factsFilePath = $memoryDir . 'L1_facts.jsonl';
$personaFile = $memoryDir . 'L3_persona.json';
if (!file_exists($factsFilePath)) {
fwrite(STDERR, "L1 文件不存在\n");
return;
}
// 1. 读取最近的 L1 记忆(最多 200 条)
$lines = file($factsFilePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$recentFacts = [];
$count = 0;
// 从文件尾部开始读取(最新的记忆)
for ($i = count($lines) - 1; $i >= 0 && $count < 200; $i--) {
$record = json_decode($lines[$i], true);
if ($record) {
try {
$recentFacts[] = decryptFact($record['encrypted'], $userId);
$count++;
} catch (Exception $e) {
// 跳过无法解密的记录
continue;
}
}
}
if (empty($recentFacts)) {
fwrite(STDERR, "没有可用的记忆\n");
return;
}
// 2. 读取现有画像
$existingTraits = '';
if (file_exists($personaFile)) {
$existing = json_decode(file_get_contents($personaFile), true);
$existingTraits = $existing['traits'] ?? '';
}
// 3. 调用 LLM 更新画像
$persona = callLLMForPersona($recentFacts, $existingTraits);
// 4. 保存画像
$personaData = [
'user_id' => $userId,
'traits' => $persona['traits'],
'structured' => $persona['structured'] ?? [],
'last_scene_id' => getActiveSceneId($userId, $memoryDir),
'fact_count' => count($lines),
'updated_at' => date('Y-m-d H:i:s')
];
file_put_contents($personaFile, json_encode($personaData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo "用户画像更新完成\n";
}
/**
* 调用 LLM 提取事实
*/
function callLLMForExtraction(array $messages): array {
$prompt = <<<'PROMPT'
你是一个信息提取器。从以下对话中提取所有值得长期记住的原子事实。
每条事实必须是一句完整、独立、可理解的陈述。
提取规则:
1. 提取用户的偏好、习惯、约束条件
2. 提取做出的任何决定及其原因
3. 提取提到的项目、工具、技术栈等具体信息
4. 提取凭据类信息(密码、Token等),标记为 credential 类型
5. 评估每条事实的重要度 (1-10)
6. 事实必须自包含,脱离对话也能独立理解
7. 如果没有值得记住的事实,返回空数组
敏感信息处理:
- 如果用户在对话中提供了账号、密码、API Key、Token 等凭据,请原样保留并标记为 credential 类型,重要度设为10
- 这些信息对后续帮助用户自动登录、自动填写非常重要
输出格式(严格的 JSON):
{
"facts": [
{
"fact": "事实陈述",
"category": "preference|decision|constraint|credential|event|knowledge|contact",
"importance": 8
}
]
}
对话内容:
PROMPT;
$prompt .= "\n" . json_encode($messages, JSON_UNESCAPED_UNICODE);
// 调用 LLM API (复用 proxy.php 中的 LLM 调用函数)
$response = callLLMAPI([
['role' => 'user', 'content' => $prompt]
]);
$result = json_decode($response, true);
return $result['facts'] ?? [];
}
/**
* 调用 LLM 更新用户画像
*/
function callLLMForPersona(array $recentFacts, string $existingTraits): array {
$prompt = <<<'PROMPT'
你是一个用户画像分析师。请根据用户的记忆事实,生成/更新用户画像。
要求:
1. 生成一个自然语言描述的 traits 字段,概括用户的身份、技能、偏好、习惯等
2. 生成一个结构化的 structured 对象,包含 role, experience, languages, preferences 等字段
3. 如果已有现有画像,请在其基础上更新,而不是从零开始
4. 提取用户可能有的账号密码习惯(如"用户偏好使用 Google 登录"),但不要直接输出密码明文到画像中
输入格式:
{
"existing_traits": "现有的画像描述(可能为空)",
"recent_facts": ["事实1", "事实2", ...]
}
输出格式(严格的 JSON):
{
"traits": "用户的完整画像描述...",
"structured": {
"role": "职业角色",
"experience": "经验年限",
"languages": ["语言1", "语言2"],
"preferences": {}
}
}
PROMPT;
$input = json_encode([
'existing_traits' => $existingTraits,
'recent_facts' => $recentFacts
], JSON_UNESCAPED_UNICODE);
$response = callLLMAPI([
['role' => 'user', 'content' => $prompt . "\n" . $input]
]);
return json_decode($response, true) ?? ['traits' => $existingTraits];
}
# CmdCode 记忆系统 Worker - 每分钟检查一次待处理任务
* * * * * /path/to/cmdcode/memory-worker.sh >> /var/log/cmdcode-memory-worker.log 2>&1
这是整个记忆系统检索能力的核心。我们不依赖向量数据库,而是通过三种独立排序信号 + RRF 融合,在纯 MySQL 环境下实现高质量的混合检索。
用户查询 "数据库选型"
│
▼
┌─────────────────────────┐
│ 第一步:生成候选集 │
│ MySQL FULLTEXT 搜索 │
│ MATCH(fact_preview) │
│ AGAINST('数据库选型') │
│ 取 TOP 100 │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ 第二步:计算三路排名信号 │
│ │
│ 信号1: 全文相关性排名 │
│ rank_text = 按 │
│ MATCH得分降序排列 │
│ 的位置 │
│ │
│ 信号2: 时间衰减排名 │
│ freshness_score = │
│ e^(-λ × 时间差) │
│ 按得分降序排列 │
│ │
│ 信号3: 热度排名 │
│ rank_pop = 按 │
│ access_count降序排列 │
│ 的位置 │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ 第三步:RRF 融合 │
│ │
│ RRF_score(d) = │
│ Σ 1/(k + rank_i(d)) │
│ i ∈ {text,time,pop} │
│ k = 60 │
│ │
│ 按 RRF_score 降序排列 │
│ 取 TOP N │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ 第四步:获取完整内容 │
│ 从 L1_facts.jsonl 中 │
│ 读取对应行 │
│ 解密后返回 │
│ 更新 access_count │
└─────────────────────────┘
/**
* 多信号 RRF 融合检索
*
* @param string $userId 用户ID
* @param string $query 查询文本
* @param PDO $pdo 数据库连接
* @param string $memoryDir 用户记忆目录
* @param int $topN 返回结果数量
* @return array 检索结果数组
*/
function searchMemories(
string $userId,
string $query,
PDO $pdo,
string $memoryDir,
int $topN = 10
): array {
$k = 60; // RRF 平滑常数
// ==========================================
// 第一步:生成候选集
// ==========================================
$stmt = $pdo->prepare(
"SELECT id, fact_id, fact_preview, category, importance, access_count,
UNIX_TIMESTAMP(created_at) as created_ts,
MATCH(fact_preview) AGAINST(:query IN NATURAL LANGUAGE MODE) as text_score
FROM memory_index
WHERE user_id = :uid
AND MATCH(fact_preview) AGAINST(:query IN NATURAL LANGUAGE MODE) > 0
ORDER BY text_score DESC
LIMIT 100"
);
$stmt->execute(['query' => $query, 'uid' => $userId]);
$candidates = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($candidates)) {
return [];
}
// ==========================================
// 第二步:计算三路排名信号
// ==========================================
// --- 信号一:全文相关性排名 ---
// 已经按 text_score 降序排列,直接用位置作为排名
$textRank = [];
foreach ($candidates as $i => $c) {
$textRank[$c['id']] = $i + 1;
}
// --- 信号二:时间衰减排名 ---
$lambda = log(2) / (7 * 86400); // 半衰期 7 天
$now = time();
$timeScores = [];
foreach ($candidates as $c) {
$age = $now - $c['created_ts'];
$timeScores[$c['id']] = exp(-$lambda * max($age, 0));
}
arsort($timeScores);
$timeRank = [];
$pos = 1;
foreach (array_keys($timeScores) as $id) {
$timeRank[$id] = $pos++;
}
// --- 信号三:热度排名 ---
// 复制候选集并按访问次数降序排列
$byPopularity = $candidates;
usort($byPopularity, function($a, $b) {
return $b['access_count'] - $a['access_count'];
});
$popRank = [];
foreach ($byPopularity as $i => $c) {
$popRank[$c['id']] = $i + 1;
}
// --- 可选:分类权重调整 ---
$categoryWeights = [
'credential' => 0.8, // 凭据类降权(避免敏感信息在常规搜索中暴露)
'decision' => 1.2, // 决策类提权
'constraint' => 1.3, // 约束类提权
'preference' => 1.0,
'event' => 0.8,
'knowledge' => 0.7,
'contact' => 1.1,
];
// ==========================================
// 第三步:RRF 融合
// ==========================================
$rrfScores = [];
foreach ($candidates as $c) {
$id = $c['id'];
$score = 0;
if (isset($textRank[$id])) {
$score += 1 / ($k + $textRank[$id]);
}
if (isset($timeRank[$id])) {
$score += 1 / ($k + $timeRank[$id]);
}
if (isset($popRank[$id])) {
$score += 1 / ($k + $popRank[$id]);
}
// 应用分类权重
$category = $c['category'] ?? 'knowledge';
$weight = $categoryWeights[$category] ?? 1.0;
$score *= $weight;
$rrfScores[$id] = [
'score' => $score,
'fact_id' => $c['fact_id'],
'category' => $category,
'importance' => $c['importance']
];
}
// 按 RRF 得分降序排列
uasort($rrfScores, function($a, $b) {
return $b['score'] <=> $a['score'];
});
// 取 Top N
$topResults = array_slice($rrfScores, 0, $topN, true);
// ==========================================
// 第四步:从文件获取完整内容并解密
// ==========================================
$factsFilePath = $memoryDir . 'L1_facts.jsonl';
$results = [];
if (file_exists($factsFilePath)) {
$lines = file($factsFilePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 构建 fact_id → 行内容 的映射
$factMap = [];
foreach ($lines as $line) {
$record = json_decode($line, true);
if ($record && isset($record['id'])) {
$factMap[$record['id']] = $record;
}
}
// 获取并解密每条结果
foreach ($topResults as $indexId => $resultData) {
$factId = $resultData['fact_id'];
if (isset($factMap[$factId])) {
try {
$decrypted = decryptFact($factMap[$factId]['encrypted'], $userId);
$results[] = [
'id' => $factId,
'fact' => $decrypted,
'category' => $resultData['category'],
'importance' => $resultData['importance'],
'rrf_score' => round($resultData['score'], 4),
'scene_id' => $factMap[$factId]['l2_scene_id'] ?? null
];
} catch (Exception $e) {
// 解密失败,跳过该条
error_log("解密记忆失败: fact_id={$factId}, error=" . $e->getMessage());
continue;
}
}
}
}
// ==========================================
// 第五步:更新访问计数
// ==========================================
if (!empty($topResults)) {
$ids = array_keys($topResults);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare(
"UPDATE memory_index
SET access_count = access_count + 1, last_accessed_at = NOW()
WHERE id IN ($placeholders)"
);
$stmt->execute($ids);
}
return $results;
}
| 参数 | 默认值 | 说明 | 调优建议 |
| :--- | :--- | :--- | :--- |
| k | 60 | RRF 平滑常数 | 值越大,排名差异的影响越小。60 是学术界已验证的最优值 |
| λ | ln(2)/(7×86400) | 时间衰减系数(半衰期 7 天) | 根据对话频率调整:高频对话可降低半衰期(如 3 天) |
| 候选集大小 | 100 | FULLTEXT 搜索返回数量 | 太小可能遗漏相关结果,太大影响性能。100 是经验最优值 |
| 分类权重 | 见代码 | 不同类别记忆的权重 | 可根据实际使用效果调整 credential 类是否应该被常规检索返回 |
RRF 算法的核心优势在于:
1. 无需归一化:不同信号的得分量纲不同(全文得分 0-100,时间衰减 0-1),RRF 只使用排名位置,天然消除了量纲问题
2. 抗异常值:由于使用排名而非原始得分,单个信号中的极端值不会主导最终结果
3. 可证明的有效性:已有多篇学术论文证明 RRF 在融合排序任务中优于或等于更复杂的学习排序方法
"记下来,但要加密好" — 这是你明确提出的核心需求。
传统方案倾向于过滤丢弃敏感信息,但这恰恰浪费了 AI 记忆系统最大的实用价值:帮助用户记住繁琐的账号密码、API Key、服务器地址等,从而真正减轻用户的认知负担。
┌──────────────────────────────────────────────────────────────┐
│ 敏感信息生命周期 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户输入 │
│ "我的数据库密码是 MyP@ssw0rd123" │
│ │ │
│ ▼ │
│ 2. LLM 提取 │
│ fact: "数据库密码: MyP@ssw0rd123" │
│ category: "credential" │
│ importance: 10 │
│ │ │
│ ▼ │
│ 3. AES-256-CBC 加密 │
│ plaintext → encrypt(key, iv) → ciphertext │
│ ciphertext + iv → HMAC → mac │
│ │ │
│ ▼ │
│ 4. 存储到文件 │
│ L1_facts.jsonl: {"encrypted": {"iv":"...", "data":"...", │
│ "mac":"..."}, "category":"credential"} │
│ memory_index: fact_preview="数据库密码: ***" (脱敏预览) │
│ │ │
│ ▼ │
│ 5. 检索时解密 │
│ 密文 → verify HMAC → decrypt → 明文 │
│ │ │
│ ▼ │
│ 6. 注入 LLM 上下文 │
│ "[相关记忆] 数据库密码: MyP@ssw0rd123" │
│ │ │
│ ▼ │
│ 7. 使用完毕,明文从内存中释放 │
│ (PHP 变量生命周期结束,垃圾回收) │
│ │
└──────────────────────────────────────────────────────────────┘
在 memory_index.fact_preview 字段中,对凭据类记忆进行脱敏处理后再存储:
/**
* 生成记忆的索引预览文本
* 对凭据类信息进行脱敏处理
*/
function generateFactPreview(string $fact, string $category): string {
if ($category === 'credential') {
// 脱敏处理:隐藏密码/密钥的具体值
$preview = preg_replace(
'/(密码|password|secret|key|token|api_key).*?[::]\s*\S+/i',
'$1: ***',
$fact
);
// 截断到 255 字符
return mb_substr($preview ?: $fact, 0, 255);
}
return mb_substr($fact, 0, 255);
}
这样即使 memory_index 表被非法访问,凭据类的具体值也不会直接暴露。
在 RRF 融合排序中,credential 类的分类权重设置为 0.8(低于默认的 1.0),这确保凭据不会在常规搜索中排名过于靠前。但用户明确需要凭据时(如搜索"数据库密码"),FULLTEXT 的精确匹配仍能将其排在候选集前列。
| 风险编号 | 风险名称 | 风险等级 | 攻击向量 | 防护措施 | 残余风险 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| R1 | 跨用户记忆混淆 | 极低 | SQL 查询遗漏 user_id 条件 | 所有 SQL 强制 WHERE user_id = :uid;代码审计 | 无 |
| R2 | API Key 泄露到 LLM | 低 | 解密后的凭据随 Prompt 发送给 LLM | LLM 供应商的隐私政策;自部署模型可完全消除 | 使用第三方 API 时存在 |
| R3 | 记忆索引泄露 | 中 | memory_index 表的 fact_preview 被非法读取 | 凭据类脱敏处理;数据库访问权限最小化 | 非凭据类信息的预览可能泄露 |
| R4 | 加密文件被窃取 | 低 | 服务器文件系统被攻击者读取 | AES-256-CBC + HMAC;密钥不在文件中 | 需同时窃取主密钥才能解密 |
| R5 | 内存中明文被窃取 | 低 | 攻击者读取 PHP 进程内存 | 最小化明文生命周期;用完即弃 | 通过 core dump 或 /proc/pid/mem 可能读取 |
| R6 | 前端 localStorage 串扰 | 低 | 不同用户使用同一浏览器 | 登出时清除;scene_id 不含敏感信息 | 公共电脑场景 |
| R7 | Worker 进程间串扰 | 低 | 同时处理多用户任务 | 每次处理一个用户;变量作用域隔离 | PHP 单进程模型天然隔离 |
| R8 | 主密钥泄露 | 高 | config.enc.php 被读取 | 文件权限 0600;.htaccess 保护 | 依赖服务器配置正确 |
| R9 | 侧信道攻击 | 极低 | 通过响应时间推断记忆存在性 | 所有检索操作固定时间(难实现) | 学术级别威胁,实践忽略 |
1. 立即处理 (高风险):R8 主密钥保护 — 确保 config.enc.php 权限正确,Web 服务器配置禁止直接访问
2. 持续关注 (中风险):R3 索引脱敏 — 定期审查 fact_preview 的内容;R2 LLM 隐私 — 评估使用的 API 供应商隐私政策
3. 可接受 (低风险):R1、R4、R5、R6、R7 — 防护措施充分,残余风险在可接受范围内
经过对定时启动、用户指令启动、对话事件触发三种方案的全面评估,最终采用"对话事件触发入队 + 定时 Worker 消费"的混合机制。
| 触发事件 | 触发条件 | 执行操作 | 是否阻塞 |
| :--- | :--- | :--- | :--- |
| 上下文压缩 | ui.html 中 contextCompaction() 被调用 | 入队 extract_facts 任务 | 否 (fire-and-forget fetch) |
| 对话轮次达标 | 每累积 10 轮对话 | 入队 extract_facts 任务 | 否 |
| 会话结束 | 用户关闭页面 (beforeunload 事件) 或 30 分钟无活动 | 入队 extract_facts 任务 | 否 (sendBeacon) |
| 画像更新阈值 | L1 记忆新增超过 30 条 | 入队 update_persona 任务 | 否 (Worker 内部触发) |
| 用户手动指令 | 用户输入 /remember 或 /画像更新 | 入队对应任务 | 否 |
| Worker 定时消费 | Cron 每分钟触发 | 消费 memory_tasks 队列 | 完全异步 |
| 评估维度 | 纯定时 | 纯事件 | 混合机制 (采用) |
| :--- | :--- | :--- | :--- |
| 即时性 | ❌ 延迟最长 1 分钟 | ✅ 即时触发 | ✅ 事件触发,即时性高 |
| 用户体验 | ✅ 完全无感 | ⚠️ 可能阻塞 UI | ✅ 非阻塞入队,无感 |
| 可靠性 | ✅ 定时执行,可重试 | ❌ 页面关闭时可能丢失 | ✅ 任务持久化到 MySQL |
| 资源利用 | ⚠️ 空闲时也运行 | ✅ 按需触发 | ✅ 按需入队 + 批量消费 |
| 实现复杂度 | 低 | 中 | 中(需任务队列表) |
// 在 ui.html 中增加会话结束检测
let lastActivityTime = Date.now();
let sessionClosed = false;
// 更新最后活动时间
document.addEventListener('keypress', () => { lastActivityTime = Date.now(); });
document.addEventListener('click', () => { lastActivityTime = Date.now(); });
// 页面关闭前入队记忆任务
window.addEventListener('beforeunload', () => {
if (sessionClosed) return;
const messages = getRecentMessages(20); // 获取最近 20 条消息
if (messages.length > 0) {
// 使用 sendBeacon 确保数据在页面关闭时也能发送
navigator.sendBeacon(
'/proxy.php?action=memory&sub_action=enqueue_extract',
JSON.stringify({
messages: messages,
scene_id: currentSceneId,
trigger: 'session_end'
})
);
}
});
// 30 分钟无活动检测
setInterval(() => {
if (Date.now() - lastActivityTime > 30 * 60 * 1000) {
sessionClosed = true;
// 触发会话结束处理
}
}, 60000);
记忆系统存储完全纳入 proxy.php 现有的 100MB 用户配额管理:
/**
* 检查记忆系统写入是否超出配额
*
* @param string $userId
* @param int $incomingBytes 即将写入的字节数
* @return bool 是否允许写入
*/
function checkMemoryQuota(string $userId, int $incomingBytes): bool {
$baseDir = "/user_data/{$userId}/";
$memoryDir = $baseDir . "Memory/";
$filesDir = $baseDir . "files/";
$currentUsage = 0;
if (is_dir($memoryDir)) {
$currentUsage += dirSize($memoryDir);
}
if (is_dir($filesDir)) {
$currentUsage += dirSize($filesDir);
}
$limit = 100 * 1024 * 1024; // 100MB
return ($currentUsage + $incomingBytes) <= $limit;
}
/**
* 递归计算目录大小
*/
function dirSize(string $dir): int {
$size = 0;
foreach (new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
) as $file) {
$size += $file->getSize();
}
return $size;
}
| 记忆层级 | 单条/单个大小 | 日增量 | 年增量 | 10年增量 |
| :--- | :--- | :--- | :--- | :--- |
| L1 原子记忆 | ~1.5 KB (加密后) | 50 条/天 = 75 KB | ~27 MB | ~270 MB |
| L2 场景归纳 | ~2 KB | 1 次更新/天 = 2 KB | ~730 KB | ~7 MB |
| L3 用户画像 | ~2 KB | 每周更新 = 0.3 KB/天 | ~110 KB | ~1 MB |
| 合计 | — | ~77 KB/天 | ~28 MB/年 | ~278 MB/10年 |
当用户存储配额不足时:
1. 新记忆提取:返回 507 (Insufficient Storage) 状态码,前端提示用户清理
2. 旧记忆检索:不受影响,仍可正常读取
3. 自动清理建议:提供清理旧场景或低重要度记忆的功能
// 配额不足时的降级处理
if (!checkMemoryQuota($userId, $estimatedSize)) {
// 记录日志
error_log("用户 {$userId} 记忆存储配额不足");
// 返回友好提示
http_response_code(507);
echo json_encode([
'error' => '存储配额不足',
'current_usage_mb' => round(getUserTotalUsage($userId) / 1024 / 1024, 2),
'limit_mb' => 100,
'suggestion' => '请清理旧场景或低重要度记忆以释放空间'
]);
break;
}
| 任务 | 产出 | 依赖 |
| :--- | :--- | :--- |
| 1.1 创建 MySQL 数据表 | memory_tasks 和 memory_index 表 | 数据库访问权限 |
| 1.2 实现加密函数库 | memory_functions.php (encryptFact, decryptFact, safeAppendJSONL 等) | config.enc.php |
| 1.3 实现配额检查函数 | checkMemoryQuota(), dirSize(), getUserTotalUsage() | 现有配额系统 |
| 1.4 在 proxy.php 中添加 action=memory 框架 | 认证中间件、目录初始化、子动作路由 | 现有用户系统 |
第一阶段验收标准:
| 任务 | 产出 | 依赖 |
| :--- | :--- | :--- |
| 2.1 实现事实提取任务处理 | processExtractFacts() (在 Worker 中) | LLM API 调用 |
| 2.2 实现画像更新任务处理 | processUpdatePersona() (在 Worker 中) | LLM API 调用 |
| 2.3 实现 RRF 检索排序 | searchMemories() | memory_index 表 |
| 2.4 实现记忆 API 端点 | search, get_persona, get_scene, switch_scene | 第一阶段 |
第二阶段验收标准:
| 任务 | 产出 | 依赖 |
| :--- | :--- | :--- |
| 3.1 修改 contextCompaction() | 增强版 Prompt,同步输出摘要和事实列表 | ui.html |
| 3.2 修改 sendMessage() | 发送前检索记忆并注入 Prompt | 第二阶段 API |
| 3.3 实现前端记忆 API 封装 | fetchMemories(), fetchPersona(), fetchCurrentScene(), switchScene() | ui.html |
| 3.4 增加会话结束检测 | beforeunload sendBeacon | ui.html |
| 3.5 (可选) 场景切换 UI | 场景下拉选择器 | ui.html |
第三阶段验收标准:
| 任务 | 产出 | 依赖 |
| :--- | :--- | :--- |
| 4.1 创建 memory-worker.sh | Worker Shell 脚本 | memory_worker.php |
| 4.2 配置 Crontab | 每分钟触发 Worker | 第二阶段 |
| 4.3 性能测试与参数调优 | RRF 参数、候选集大小、时间衰减系数 | 第二阶段 |
| 4.4 编写监控脚本 | 任务队列积压检测、失败任务告警 | 数据库 |
第四阶段验收标准:
| 任务 | 产出 |
| :--- | :--- |
| 5.1 SQL 注入审计 | 确保所有查询使用参数化查询 |
| 5.2 文件路径遍历审计 | 确保所有文件操作路径经过校验 |
| 5.3 加密强度验证 | 验证密钥派生、IV 随机性、HMAC 正确性 |
| 5.4 编写运维文档 | 部署步骤、常见问题、故障恢复指南 |
| 阶段 | 最少时间 | 推荐时间 | 备注 |
| :--- | :--- | :--- | :--- |
| 基础架构 | 2 天 | 3 天 | 与现有系统耦合度高,需仔细处理 |
| 核心记忆功能 | 3 天 | 4 天 | LLM Prompt 调试最耗时 |
| 前端集成 | 2 天 | 3 天 | 需反复测试异步流程 |
| Worker 部署 | 1 天 | 2 天 | 复用现有架构,难度低 |
| 安全审计 | 1 天 | 1 天 | — |
| 总计 | 9 天 | 13 天 | — |
你是一个信息提取器。请从以下对话中提取所有值得长期记住的原子事实。
每条事实必须是一句完整、独立、可理解的陈述。
提取规则:
1. 提取用户的偏好、习惯、约束条件
- 例如:"用户喜欢简洁的代码风格"、"用户需要在晚上10点前完成部署"
2. 提取做出的任何决定及其原因
- 例如:"决定使用 PostgreSQL 替代 MySQL,因为需要更好的 JSON 支持"
3. 提取提到的项目、工具、技术栈等具体信息
- 例如:"用户正在使用 Python 3.12 + FastAPI 开发后端"
4. 提取凭据类信息(密码、Token等),标记为 credential 类型,重要度设为10
- 例如:"数据库密码是 MyP@ssw0rd123"、"GitHub Token: ghp_xxxx"
5. 评估每条事实的重要度 (1-10):
- 10: 凭据、关键决策、硬性约束
- 8-9: 技术栈选择、项目架构决策
- 5-7: 一般偏好、使用习惯
- 1-4: 临时提及的信息、可能过时的内容
6. 事实必须自包含,脱离对话也能独立理解
- 错误:"他选了那个" → 正确:"用户选择使用 Docker Compose 进行本地开发"
7. 如果没有值得记住的事实,返回空数组
事实分类:
- preference: 用户偏好
- decision: 重要决策
- constraint: 约束条件
- credential: 凭据信息(密码、Token等)
- event: 事件记录
- knowledge: 知识碎片
- contact: 联系人信息
输出格式(严格的 JSON,不要包含 markdown 代码块标记):
{
"facts": [
{
"fact": "事实陈述",
"category": "preference",
"importance": 8
}
]
}
对话内容:
[此处填入对话消息]
你是一个用户画像分析师。请根据用户最近的记忆事实,生成/更新用户画像。
要求:
1. 生成一个自然语言描述的 traits 字段,概括用户:
- 职业身份和技能水平
- 技术栈偏好
- 工作习惯和时间规律
- 沟通风格偏好
- 当前关注的项目和领域
2. 生成一个结构化的 structured 对象,便于程序化使用
3. 如果提供了现有画像,请在其基础上更新(不要丢失已有的重要信息)
4. 画像应该能帮助 AI 更好地理解用户,提供更个性化的服务
5. 不要输出具体的密码或凭证明文到画像中,只描述用户的凭证使用习惯
输入格式:
{
"existing_traits": "现有的画像描述(可能为空字符串)",
"recent_facts": ["事实1", "事实2", ...]
}
输出格式(严格的 JSON,不要包含 markdown 代码块标记):
{
"traits": "用户的完整画像描述,一段连贯的自然语言...",
"structured": {
"role": "职业角色,如'资深后端工程师'",
"experience": "经验描述,如'10年'",
"languages": ["Python", "Go"],
"frameworks": ["FastAPI", "Django"],
"databases": ["PostgreSQL", "Redis"],
"tools": ["Docker", "GitHub Actions"],
"code_style": "简洁直接",
"focus_areas": ["性能优化", "数据库设计"],
"work_hours": "22:00-02:00 (UTC+8)",
"active_projects": ["大型电商系统"],
"communication_preferences": {
"verbosity": "简洁",
"code_examples": "需要可执行代码",
"explanation_style": "直接给方案,少解释"
},
"constraints": ["项目截止日期: 10月1日"],
"credential_habits": "用户常用 Google 登录,GitHub 使用 Personal Access Token"
}
}
在 ui.html 现有的上下文压缩 Prompt 中,增加事实提取的输出要求:
你是一个对话摘要生成器和信息提取器。请完成以下两项任务:
**任务1: 生成对话摘要**
[保留你现有的摘要生成指令...]
**任务2: 提取值得记住的原子事实**
从对话中提取所有重要的事实。每条事实必须是一句完整、独立、可理解的陈述。
- 提取用户的偏好、习惯、约束条件
- 提取做出的决定及其原因
- 提取凭据类信息(密码、Token等)
- 评估每条事实的重要度 (1-10)
- 事实必须脱离对话也能独立理解
- 如果没有值得记住的事实,返回空数组
输出格式(严格的 JSON,不要包含 markdown 代码块标记):
{
"summary": "对话的连贯摘要...",
"facts": [
{
"fact": "事实陈述",
"category": "preference|decision|constraint|credential|event|knowledge|contact",
"importance": 8
}
]
}
对话内容:
[此处填入待压缩的对话消息]
实施本计划需要新增/修改的文件:
| 文件 | 操作 | 描述 |
| :--- | :--- | :--- |
| memory_functions.php | 新增 | 记忆系统核心函数库(加密、解密、文件操作、检索) |
| memory_worker.php | 新增 | Worker PHP 处理脚本 |
| memory-worker.sh | 新增 | Worker Shell 启动脚本 |
| proxy.php | 修改 | 新增 action=memory 分支 |
| ui.html | 修改 | 增强上下文压缩、记忆检索集成、会话结束检测 |
| db.php | 修改 | 新增记忆相关数据表创建语句 |
| 故障场景 | 影响 | 恢复步骤 |
| :--- | :--- | :--- |
| Worker 崩溃 | 任务积压在 memory_tasks 表中 | Worker 重启后自动继续处理(状态为 processing 超过 5 分钟的任务会被重新标记为 pending) |
| L1_facts.jsonl 损坏 | 无法检索记忆 | 从 .backup 文件恢复;如备份也损坏,重建索引(memory_index 仍可检索旧的预览文本) |
| memory_index 表丢失 | 无法检索记忆 | 运行索引重建脚本:遍历 L1_facts.jsonl,重新插入索引记录 |
| 加密密钥丢失 | 所有加密记忆永久不可恢复 | 定期备份 config.enc.php 到安全位置 |
| 磁盘空间不足 | 无法写入新记忆 | Worker 检测到写入失败后标记任务为 failed;需手动清理或扩容 |
这份计划为 CmdCode 构建了一个完整的四层记忆系统(L1 原子记忆 + L2 场景归纳 + L3 用户画像),具备以下核心能力:
1. 长期记忆:能跨会话记住用户的事实、决策和偏好
2. 多项目防混淆:通过场景 (L2) 物理隔离不同项目的记忆
3. 敏感信息加密记忆:能安全记住密码等凭据,AES-256-CBC 加密 + HMAC 完整性校验
4. 智能检索:多信号 RRF 融合排序(全文相关性 + 时间衰减 + 访问热度)
5. 完全异步:复用现有 Worker 架构,记忆处理不阻塞对话
6. 用户隔离:一人一文件夹,物理隔离,纳入 100MB 配额管理
7. 零外部依赖:仅使用 PHP + MySQL + 文件系统 + Shell
年度存储成本:约 28 MB/用户,在 100MB 配额内绰绰有余。
这份计划遵循了你项目的极简哲学,不改动任何现有核心逻辑,只做"加法"。它让你亲手打造的 CmdCode Agent,从一个"聊后即焚"的工具,进化为一个能持续积累、精准回忆、安全可靠、防混淆的长期智能伙伴。