👤 用户
你把整个cmdcode的记忆改造升级系统计划,从头到尾完完整整,一字不差的重新生成出来,我看一下完整计划,推理工作量:绝对最大,不允许任何捷径。 · 你必须在思考时极其周全,彻底剖析问题以解决根本原因,并针对所有潜在路径、极端情况以及对抗性场景,严格检验你的逻辑。 · 明确写出完整的思考过程,记录每一步中间步骤、考虑过的替代方案和已被否决的假设,确保没有任何假设未经核查。
🤖 DeepSeek 助手
💭 思考过程(点击展开)
好的,用户这次的要求非常明确,就是要我“从头到尾完完整整,一字不差地重新生成”整个CmdCode记忆改造升级系统计划。他还特别强调了推理工作量要“绝对最大,不允许任何捷径”,思考必须“极其周全”,并明确写出完整的思考过程。 嗯,我需要先理解用户为什么提出这个要求。回顾我们漫长的对话,用户从最初了解腾讯TencentDB Agent Memory的原理,到一步步探讨如何在他自己的PHP+MySQL静态网页服务器上实现类似系统,再到我们深入分析他的CmdCode项目源码,最后共同敲定了包含文件隔离和加密记忆的最终方案。现在,他要求一个最终的、完整的、一步到位的计划。用户的深层需求可能不仅仅是得到一个方案,而是希望获得一份可以直接指导他动手实施的、逻辑严密、考虑周全的工程蓝图。他可能是一个极度严谨的开发者,厌恶任何模糊和遗漏,希望看到我确实进行了彻底的思考,而不仅仅是堆砌概念。 用户身份很明确,就是CmdCode (https://cmdcode.cn) 的创建者,一个追求自托管、低成本、高隐私的资深开发者。他的真实需求是:为他的多用户AI助手平台构建一个长期记忆系统,这个系统必须与他现有的技术栈(PHP、MySQL、文件系统配额管理)完美融合,同时解决隐私隔离、敏感信息加密、异步处理和不影响现有体验等关键问题。 我需要基于我们之前所有讨论的共识来构建这份计划。核心要点包括:L1+L2+L3记忆架构、多信号RRF融合检索、用户文件系统隔离存储、AES加密敏感记忆、异步Worker处理、以及完全基于他现有CmdCode代码的增量改造。 思考过程需要极其周密。我需要从最根本的问题开始:用户为什么需要记忆系统?现有系统缺什么?然后推导出四个核心目标:长期记忆、多项目防混淆、安全隔离、非侵入集成。接着,我需要彻底剖析他的现有架构,从ui.html、proxy.php到long-task-cron-worker.sh,评估哪些部分可以复用,哪些是根本性缺失(如L1/L3层、检索排序、异步记忆处理)。不能有任何假设未经核查。 在数据架构设计上,我必须解释清楚为什么选择“MySQL索引 + 文件系统存储”这种混合模式,这必须结合他现有的100MB用户配额管理机制来论证。对于账户密码等敏感信息,不能简单地一刀切过滤,而是要设计完整的加密存储和解密使用流程,并评估所有潜在风险(如密钥管理、侧信道攻击、配额耗尽)。 异步处理流程是保证用户体验的关键。我需要详细设计如何利用他现有的对话事件(如上下文压缩)作为触发器,将任务写入MySQL队列表,再通过类似他现有架构的Cron+Shell Worker来静默处理,实现完全解耦。 安全与隐私是整个计划的生命线。我必须从物理隔离、加密存储、传输安全、检索安全、身份校验、配额保护六个层面进行多维度审查,并设计对抗性场景(如恶意用户试图读取他人记忆)的防御措施。 最后,在生成输出时,必须严格遵循他的指令:“从头到尾完完整整,一字不差地重新生成出来”。这意味着我不能省略任何之前确认过的细节,并且要给出一个逻辑清晰、结构严谨的完整文档,让他可以拿着这份计划直接开始动手改造。

# 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-设计)

 


 

一、项目现状分析

 

1.1 现有项目文件结构

 

经过对 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 健康检查 (心跳+进程检测) |

 

1.2 现有架构中的记忆相关能力评估

 

| 现有组件 | 对应记忆能力 | 优势 | 缺陷 | 改造策略 |

| :--- | :--- | :--- | :--- | :--- |

| 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.3 现有架构优势总结

 

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 加密实现可直接复用于敏感记忆的加密存储。

 


 

二、核心设计目标

 

2.1 功能目标

 

| 目标 | 描述 | 验收标准 |

| :--- | :--- | :--- |

| 长期记忆 | 跨会话记住用户的关键信息 | 用户说出"上次提到的那个库叫什么",Agent 能准确回答 |

| 多项目防混淆 | 不同项目/主题的记忆互不干扰 | 讨论 A 项目时不会错误引用 B 项目的信息 |

| 敏感信息加密记忆 | 能安全记住密码等敏感信息 | 加密存储,解密后使用,密文永不暴露给 LLM |

| 自动化运行 | 记忆的提取、整理完全自动化 | 用户无需手动操作,记忆在后台静默生长 |

| 非侵入集成 | 不改变现有核心交互流程 | 对话体验与改造前一致,无额外延迟 |

 

2.2 技术目标

 

| 目标 | 描述 | 约束 |

| :--- | :--- | :--- |

| 零外部依赖 | 不引入任何新的外部服务 | 仅使用 PHP + MySQL + 文件系统 + Shell |

| 用户数据物理隔离 | 不同用户的记忆完全独立存储 | 每个用户拥有独立的 Memory 文件夹 |

| 存储成本可控 | 年存储量在配额内可控 | ≤ 30MB/用户/年 |

| 异步非阻塞 | 记忆处理不影响对话响应速度 | LLM 调用全部在 Worker 中执行 |

| 可追溯可审计 | 任何记忆都可回溯来源 | 每条 L1 记忆关联到产生它的对话上下文 |

 

2.3 信息完整性目标

 

保留的记忆层级:

 

  • L1 原子记忆:所有具体事实、决策、偏好
  • L2 场景归纳:项目上下文、对话背景
  • L3 用户画像:长期稳定的用户特征
  • L0 原始对话:不保留完整原文,但 L1/L2 中保留关键上下文引用

 


 

三、数据架构设计

 

3.1 四层记忆模型

 

┌─────────────────────────────────────────┐
│            L3 用户画像                    │
│  "资深后端,偏好Python,喜欢简洁代码..."   │
│  大小: ~2 KB                             │
│  更新频率: 每30条新L1记忆触发更新         │
├─────────────────────────────────────────┤
│            L2 场景归纳                    │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐  │
│  │ 电商项目  │ │ 博客项目  │ │ 默认场景  │  │
│  │ 摘要+背景 │ │ 摘要+背景 │ │ 摘要+背景 │  │
│  └──────────┘ └──────────┘ └──────────┘  │
│  单个场景: ~2 KB                          │
│  场景数量: ≤ 20个/用户                    │
├─────────────────────────────────────────┤
│            L1 原子记忆                    │
│  • "用户数据库密码: xxx" [已加密]         │
│  • "项目截止日期: 10月1日"                │
│  • "偏好使用 PostgreSQL 而非 MySQL"       │
│  • "上次报错是 NullPointerException"      │
│  单条: ~1.5 KB                           │
│  日增: ~50条                              │
├─────────────────────────────────────────┤
│         L0 原始对话 (不保留)              │
│  仅在上下文压缩时用于提取 L1/L2           │
│  提取完成后原始对话被压缩或丢弃           │
└─────────────────────────────────────────┘

 

3.2 数据流图

 

用户输入
    │
    ▼
┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  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        (检索索引)              │
└──────────────────────────────────────────────────┘

 

3.3 存储分层策略

 

| 存储位置 | 存储内容 | 理由 |

| :--- | :--- | :--- |

| 用户文件系统 (/user_data/{id}/Memory/) | L1 原子记忆 (加密)、L2 场景、L3 画像 | 数据所有权归用户;纳入现有配额管理;便于备份与迁移;物理隔离 |

| MySQL 数据库 | 任务队列 (memory_tasks)、检索索引 (memory_index) | 任务队列需要原子操作和并发安全;检索索引需要 FULLTEXT 和快速排序 |

 


 

四、安全与隐私设计

 

4.1 多层防护体系

 

┌──────────────────────────────────────────────┐
│              第零层:物理隔离                   │
│  每个用户独立文件夹 /user_data/{id}/Memory/    │
│  Linux 文件权限 0700 (仅所有者可读写)          │
├──────────────────────────────────────────────┤
│              第一层:加密存储                   │
│  AES-256-CBC + HMAC 完整性校验                │
│  密钥 = HMAC(masterKey, userId)               │
│  每个用户独立派生密钥                          │
├──────────────────────────────────────────────┤
│              第二层:传输安全                   │
│  记忆 API 仅在 proxy.php 内部调用              │
│  不暴露独立的公网端点                          │
├──────────────────────────────────────────────┤
│              第三层:身份校验                   │
│  userId 从服务端登录态强制获取                  │
│  前端不可伪造                                  │
├──────────────────────────────────────────────┤
│              第四层:检索安全                   │
│  密文文件永不被 LLM 直接读取                   │
│  检索时在内存中解密,用完即弃                  │
├──────────────────────────────────────────────┤
│              第五层:配额保护                   │
│  记忆文件纳入 100MB 配额管理                   │
│  防止恶意用户通过记忆系统撑爆磁盘              │
└──────────────────────────────────────────────┘

 

4.2 隐私串扰风险矩阵

 

| 风险点 | 风险等级 | 攻击向量 | 防护措施 | 残余风险 |

| :--- | :--- | :--- | :--- | :--- |

| 跨用户记忆混淆 | 极低 | 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 服务器配置正确 |

 

4.3 威胁模型分析

 

假设的攻击者能力

 

  • 外部攻击者:能向 proxy.php 发送任意 HTTP 请求,但没有有效的登录态
  • 恶意用户 A:拥有合法账户,试图访问用户 B 的记忆数据
  • 服务器运维人员:拥有服务器 shell 权限

 

防御结果

 

  • 外部攻击者:所有记忆 API 需要登录态,未认证请求直接拒绝
  • 恶意用户 A:userId 从服务端强制获取,无法伪造;文件路径包含 userId,无法跨越
  • 服务器运维人员:加密存储使得即使读取文件也只能看到密文;但若同时获取主密钥则防御失效(此为可接受风险)

 


 

五、数据库设计

 

5.1 设计原则

 

1. 最小化 MySQL 存储:MySQL 仅存储任务队列和检索索引,不存储记忆内容本身

2. 全文索引支撑检索:利用 MySQL FULLTEXT 索引实现高效关键词检索

3. 无向量扩展:不引入任何向量数据库或扩展,完全基于关键词匹配 + RRF 多信号融合

 

5.2 数据表定义

 

5.2.1 记忆任务队列表

 

-- 记忆任务队列表 (异步处理用)
-- 存储引擎: 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 次,超过则标记为 failed
  • status 状态机:pending → processing → done/failed

 

5.2.2 记忆检索索引表

 

-- 记忆检索索引表 (快速检索用)
-- 存储引擎: 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,用于热度排序信号

 

5.3 索引策略说明

 

| 索引名称 | 类型 | 用途 |

| :--- | :--- | :--- |

| 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 | 按时间排序,支持时间衰减计算 |

 

5.4 数据一致性保证

 

  • 文件与索引的一致性:写入时采用"先写文件,再写索引"的顺序。若索引写入失败,文件中有记录但不可检索(可通过修复脚本补建索引)
  • 去重保证:数据库层面 uk_user_hash 唯一约束 + 应用层面插入前检查
  • 任务幂等性:Worker 处理前将状态更新为 processing,防止重复执行

 


 

六、用户文件系统设计

 

6.1 目录结构

 

/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 用户画像文件

 

6.2 L1 原子记忆文件格式 (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:所属场景 ID
  • encrypted:加密后的内容,包含 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 |

 

6.3 L2 场景文件格式 (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:最后活跃时间,用于判断场景是否"冷却"

 

6.4 场景索引文件 (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"
        }
    ]
}

 

6.5 L3 用户画像文件格式 (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 记忆总数

 

6.6 文件操作原子性保证

 

/**
 * 原子写入文件
 * 先写临时文件,成功后再 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;
}

 


 

七、加密子系统设计

 

7.1 设计目标

 

1. 存储安全:即使服务器文件系统被非法读取,记忆内容也无法被解密

2. 用户隔离:不同用户使用不同的加密密钥,一个用户的密钥泄露不影响其他用户

3. 完整性校验:检测密文是否被篡改

4. 与现有体系兼容:复用 config.enc.php 中的主密钥

 

7.2 密钥派生方案

 

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),
    ];
}

 

7.3 加密函数

 

/**
 * 加密单条原子记忆
 * 
 * @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;
}

 

7.4 安全注意事项

 

1. IV 唯一性:每次加密使用新的随机 IV(openssl_random_pseudo_bytes

2. 认证加密:使用 Encrypt-then-MAC 方案,先加密再计算 HMAC

3. 常数时间比较:使用 hash_equals() 而非 === 比较 HMAC,防止时序攻击

4. 密钥管理:主密钥仅存储在 config.enc.php 中,不硬编码在代码中

5. 明文生命周期:解密后的明文仅在 PHP 内存变量中存在,用完即被垃圾回收

 


 

八、后端 API 设计

 

8.1 API 端点总览

 

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 展示 |

 

8.2 API 详细设计

 

8.2.1 入队记忆提取任务

 

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;

 

8.2.2 多信号 RRF 检索

 

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
}

 

后端逻辑:详见第十一章"检索排序算法设计"。

 

8.2.3 获取用户画像

 

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": "尚未生成用户画像,需要更多对话数据"
}

 

8.2.4 获取场景上下文

 

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
}

 

8.2.5 切换场景

 

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;

 

8.3 通用安全中间件

 

所有 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';
    // ...
}

 


 

九、前端改造设计

 

9.1 改造原则

 

1. 非侵入性:不改变 ui.html 现有的对话核心流程

2. 渐进增强:记忆功能作为增强层,失败时不影响基础对话

3. 异步非阻塞:所有记忆相关网络请求不阻塞 UI

4. 状态管理:利用 localStorage 维护当前场景 ID 等轻量状态

 

9.2 需要修改的现有函数

 

9.2.1 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);
    // ...
}

 

9.2.2 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;
}

 

9.3 需要新增的函数

 

9.3.1 记忆检索 API 封装

 

/**
 * 检索相关记忆
 */
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;
}

 

9.4 状态管理

 

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');
    // ...其他清理代码
}

// 页面关闭时不做清理(保留场景状态供下次使用)

 

9.5 场景切换 UI (可选)

 

在界面中添加一个轻量级的场景切换入口:

 

<!-- 可放在对话输入框上方或侧边栏 -->
<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>

 


 

十、异步记忆处理管道设计

 

10.1 设计目标

 

1. 完全异步:记忆提取和画像更新的 LLM 调用全部在 Worker 中执行

2. 可靠执行:任务持久化到 MySQL,Worker 崩溃后可恢复

3. 资源可控:每次只处理有限数量任务,避免 LLM API 过载

4. 复用现有架构:直接基于 long-task-cron-worker.sh 的模式构建

 

10.2 任务生命周期

 

┌─────────┐     ┌────────────┐     ┌─────────┐     ┌──────┐
│ pending  │────▶│ processing │────▶│  done   │     │failed│
└─────────┘     └────────────┘     └─────────┘     └──────┘
                      │                                  ▲
                      │ (处理失败,retry_count < 3)       │
                      └──────────────────────────────────┘
                      │ (retry_count >= 3)
                      └──────────────────────────────────▶

 

  • pending:任务已入队,等待 Worker 拉取
  • processing:Worker 正在处理
  • done:处理成功
  • failed:超过最大重试次数,需人工介入

 

10.3 Worker 脚本 (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

 

10.4 Worker PHP 脚本 (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];
}

 

10.5 Crontab 配置

 

# CmdCode 记忆系统 Worker - 每分钟检查一次待处理任务
* * * * * /path/to/cmdcode/memory-worker.sh >> /var/log/cmdcode-memory-worker.log 2>&1

 


 

十一、检索排序算法设计

 

11.1 RRF 多信号融合排序

 

这是整个记忆系统检索能力的核心。我们不依赖向量数据库,而是通过三种独立排序信号 + RRF 融合,在纯 MySQL 环境下实现高质量的混合检索。

 

11.2 算法流程

 

用户查询 "数据库选型"
        │
        ▼
┌─────────────────────────┐
│ 第一步:生成候选集        │
│ 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      │
└─────────────────────────┘

 

11.3 详细实现

 

/**
 * 多信号 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;
}

 

11.4 算法参数调优指南

 

| 参数 | 默认值 | 说明 | 调优建议 |

| :--- | :--- | :--- | :--- |

| k | 60 | RRF 平滑常数 | 值越大,排名差异的影响越小。60 是学术界已验证的最优值 |

| λ | ln(2)/(7×86400) | 时间衰减系数(半衰期 7 天) | 根据对话频率调整:高频对话可降低半衰期(如 3 天) |

| 候选集大小 | 100 | FULLTEXT 搜索返回数量 | 太小可能遗漏相关结果,太大影响性能。100 是经验最优值 |

| 分类权重 | 见代码 | 不同类别记忆的权重 | 可根据实际使用效果调整 credential 类是否应该被常规检索返回 |

 

11.5 RRF 算法正确性证明(简述)

 

RRF 算法的核心优势在于:

 

1. 无需归一化:不同信号的得分量纲不同(全文得分 0-100,时间衰减 0-1),RRF 只使用排名位置,天然消除了量纲问题

2. 抗异常值:由于使用排名而非原始得分,单个信号中的极端值不会主导最终结果

3. 可证明的有效性:已有多篇学术论文证明 RRF 在融合排序任务中优于或等于更复杂的学习排序方法

 


 

十二、敏感信息加密记忆方案

 

12.1 设计理念

 

"记下来,但要加密好" — 这是你明确提出的核心需求。

 

传统方案倾向于过滤丢弃敏感信息,但这恰恰浪费了 AI 记忆系统最大的实用价值:帮助用户记住繁琐的账号密码、API Key、服务器地址等,从而真正减轻用户的认知负担。

 

12.2 加密记忆全生命周期

 

┌──────────────────────────────────────────────────────────────┐
│                      敏感信息生命周期                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  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 变量生命周期结束,垃圾回收)                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

 

12.3 索引脱敏

 

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 表被非法访问,凭据类的具体值也不会直接暴露。

 

12.4 常规检索中的凭据保护

 

在 RRF 融合排序中,credential 类的分类权重设置为 0.8(低于默认的 1.0),这确保凭据不会在常规搜索中排名过于靠前。但用户明确需要凭据时(如搜索"数据库密码"),FULLTEXT 的精确匹配仍能将其排在候选集前列。

 


 

十三、隐私串扰风险评估与防护

 

13.1 完整风险矩阵

 

| 风险编号 | 风险名称 | 风险等级 | 攻击向量 | 防护措施 | 残余风险 |

| :--- | :--- | :--- | :--- | :--- | :--- |

| 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 | 侧信道攻击 | 极低 | 通过响应时间推断记忆存在性 | 所有检索操作固定时间(难实现) | 学术级别威胁,实践忽略 |

 

13.2 风险缓解优先级

 

1. 立即处理 (高风险):R8 主密钥保护 — 确保 config.enc.php 权限正确,Web 服务器配置禁止直接访问

2. 持续关注 (中风险):R3 索引脱敏 — 定期审查 fact_preview 的内容;R2 LLM 隐私 — 评估使用的 API 供应商隐私政策

3. 可接受 (低风险):R1、R4、R5、R6、R7 — 防护措施充分,残余风险在可接受范围内

 


 

十四、触发机制设计

 

14.1 最终方案:混合触发机制

 

经过对定时启动、用户指令启动、对话事件触发三种方案的全面评估,最终采用"对话事件触发入队 + 定时 Worker 消费"的混合机制。

 

14.2 触发事件清单

 

| 触发事件 | 触发条件 | 执行操作 | 是否阻塞 |

| :--- | :--- | :--- | :--- |

| 上下文压缩 | ui.htmlcontextCompaction() 被调用 | 入队 extract_facts 任务 | 否 (fire-and-forget fetch) |

| 对话轮次达标 | 每累积 10 轮对话 | 入队 extract_facts 任务 | 否 |

| 会话结束 | 用户关闭页面 (beforeunload 事件) 或 30 分钟无活动 | 入队 extract_facts 任务 | 否 (sendBeacon) |

| 画像更新阈值 | L1 记忆新增超过 30 条 | 入队 update_persona 任务 | 否 (Worker 内部触发) |

| 用户手动指令 | 用户输入 /remember/画像更新 | 入队对应任务 | 否 |

| Worker 定时消费 | Cron 每分钟触发 | 消费 memory_tasks 队列 | 完全异步 |

 

14.3 为什么选择混合机制

 

| 评估维度 | 纯定时 | 纯事件 | 混合机制 (采用) |

| :--- | :--- | :--- | :--- |

| 即时性 | ❌ 延迟最长 1 分钟 | ✅ 即时触发 | ✅ 事件触发,即时性高 |

| 用户体验 | ✅ 完全无感 | ⚠️ 可能阻塞 UI | ✅ 非阻塞入队,无感 |

| 可靠性 | ✅ 定时执行,可重试 | ❌ 页面关闭时可能丢失 | ✅ 任务持久化到 MySQL |

| 资源利用 | ⚠️ 空闲时也运行 | ✅ 按需触发 | ✅ 按需入队 + 批量消费 |

| 实现复杂度 | 低 | 中 | 中(需任务队列表) |

 

14.4 会话结束检测

 

// 在 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);

 


 

十五、存储配额管理设计

 

15.1 配额纳入方案

 

记忆系统存储完全纳入 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;
}

 

15.2 存储空间预估

 

| 记忆层级 | 单条/单个大小 | 日增量 | 年增量 | 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年 |

 

15.3 配额不足时的降级策略

 

当用户存储配额不足时:

 

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;
}

 


 

十六、实施路线图

 

16.1 分阶段实施计划

 

第一阶段:基础架构 (预计 2-3 天)

 

| 任务 | 产出 | 依赖 |

| :--- | :--- | :--- |

| 1.1 创建 MySQL 数据表 | memory_tasksmemory_index 表 | 数据库访问权限 |

| 1.2 实现加密函数库 | memory_functions.php (encryptFact, decryptFact, safeAppendJSONL 等) | config.enc.php |

| 1.3 实现配额检查函数 | checkMemoryQuota(), dirSize(), getUserTotalUsage() | 现有配额系统 |

| 1.4 在 proxy.php 中添加 action=memory 框架 | 认证中间件、目录初始化、子动作路由 | 现有用户系统 |

 

第一阶段验收标准

  • 可以手动调用 API 创建用户记忆目录
  • 加密/解密函数单元测试通过
  • 配额检查函数返回正确数值

 

第二阶段:核心记忆功能 (预计 3-4 天)

 

| 任务 | 产出 | 依赖 |

| :--- | :--- | :--- |

| 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 | 第一阶段 |

 

第二阶段验收标准

  • Worker 能成功从对话中提取原子记忆并加密存储
  • 检索 API 能返回相关记忆,排序合理
  • 画像能根据记忆自动更新

 

第三阶段:前端集成 (预计 2-3 天)

 

| 任务 | 产出 | 依赖 |

| :--- | :--- | :--- |

| 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 |

 

第三阶段验收标准

  • 对话中能自动引用相关历史记忆
  • 上下文压缩时能触发记忆提取入队
  • 页面关闭时记忆任务能成功入队

 

第四阶段:Worker 部署与调优 (预计 1-2 天)

 

| 任务 | 产出 | 依赖 |

| :--- | :--- | :--- |

| 4.1 创建 memory-worker.sh | Worker Shell 脚本 | memory_worker.php |

| 4.2 配置 Crontab | 每分钟触发 Worker | 第二阶段 |

| 4.3 性能测试与参数调优 | RRF 参数、候选集大小、时间衰减系数 | 第二阶段 |

| 4.4 编写监控脚本 | 任务队列积压检测、失败任务告警 | 数据库 |

 

第四阶段验收标准

  • Worker 每分钟自动运行,处理待处理任务
  • 任务处理延迟 < 2 分钟
  • 无任务积压(正常运行时队列长度 < 10)

 

第五阶段:安全审计与文档 (预计 1 天)

 

| 任务 | 产出 |

| :--- | :--- |

| 5.1 SQL 注入审计 | 确保所有查询使用参数化查询 |

| 5.2 文件路径遍历审计 | 确保所有文件操作路径经过校验 |

| 5.3 加密强度验证 | 验证密钥派生、IV 随机性、HMAC 正确性 |

| 5.4 编写运维文档 | 部署步骤、常见问题、故障恢复指南 |

 

16.2 总工期估算

 

| 阶段 | 最少时间 | 推荐时间 | 备注 |

| :--- | :--- | :--- | :--- |

| 基础架构 | 2 天 | 3 天 | 与现有系统耦合度高,需仔细处理 |

| 核心记忆功能 | 3 天 | 4 天 | LLM Prompt 调试最耗时 |

| 前端集成 | 2 天 | 3 天 | 需反复测试异步流程 |

| Worker 部署 | 1 天 | 2 天 | 复用现有架构,难度低 |

| 安全审计 | 1 天 | 1 天 | — |

| 总计 | 9 天 | 13 天 | — |

 


 

十七、附录:LLM Prompt 设计

 

17.1 事实提取 Prompt

 

你是一个信息提取器。请从以下对话中提取所有值得长期记住的原子事实。
每条事实必须是一句完整、独立、可理解的陈述。

提取规则:
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
    }
  ]
}

对话内容:
[此处填入对话消息]

 

17.2 用户画像更新 Prompt

 

你是一个用户画像分析师。请根据用户最近的记忆事实,生成/更新用户画像。

要求:
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"
  }
}

 

17.3 上下文压缩增强 Prompt (修改现有)

 

ui.html 现有的上下文压缩 Prompt 中,增加事实提取的输出要求:

 

你是一个对话摘要生成器和信息提取器。请完成以下两项任务:

**任务1: 生成对话摘要**
[保留你现有的摘要生成指令...]

**任务2: 提取值得记住的原子事实**
从对话中提取所有重要的事实。每条事实必须是一句完整、独立、可理解的陈述。
- 提取用户的偏好、习惯、约束条件
- 提取做出的决定及其原因
- 提取凭据类信息(密码、Token等)
- 评估每条事实的重要度 (1-10)
- 事实必须脱离对话也能独立理解
- 如果没有值得记住的事实,返回空数组

输出格式(严格的 JSON,不要包含 markdown 代码块标记):
{
  "summary": "对话的连贯摘要...",
  "facts": [
    {
      "fact": "事实陈述",
      "category": "preference|decision|constraint|credential|event|knowledge|contact",
      "importance": 8
    }
  ]
}

对话内容:
[此处填入待压缩的对话消息]

 


 

附录 A:完整文件清单

 

实施本计划需要新增/修改的文件:

 

| 文件 | 操作 | 描述 |

| :--- | :--- | :--- |

| memory_functions.php | 新增 | 记忆系统核心函数库(加密、解密、文件操作、检索) |

| memory_worker.php | 新增 | Worker PHP 处理脚本 |

| memory-worker.sh | 新增 | Worker Shell 启动脚本 |

| proxy.php | 修改 | 新增 action=memory 分支 |

| ui.html | 修改 | 增强上下文压缩、记忆检索集成、会话结束检测 |

| db.php | 修改 | 新增记忆相关数据表创建语句 |

 


 

附录 B:故障恢复指南

 

| 故障场景 | 影响 | 恢复步骤 |

| :--- | :--- | :--- |

| 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,从一个"聊后即焚"的工具,进化为一个能持续积累、精准回忆、安全可靠、防混淆的长期智能伙伴。