长期记忆 让 CoPAW 拥有跨对话的持久记忆能力:通过文件工具将关键信息写入 Markdown 文件长期保存,并配合语义检索随时召回。
graph TB
User[用户 / Agent] --> MM[MemoryManager]
MM --> MemoryMgmt[长期记忆管理]
MemoryMgmt --> FileTools[记忆更新]
MemoryMgmt --> Watcher[记忆索引更新]
MemoryMgmt --> SearchLayer[记忆混合检索]
FileTools --> LTM[MEMORY.md]
FileTools --> DailyLog[memory/YYYY-MM-DD.md]
Watcher --> Index[异步更新数据库]
SearchLayer --> VectorSearch[向量语义搜索]
SearchLayer --> BM25[BM25 全文检索]
长期记忆管理包含以下能力:
| 能力 | 说明 |
|---|---|
| 记忆持久化 | 通过文件工具(read / write / edit)将关键信息写入 Markdown 文件,文件即真实数据源 |
| 文件监控 | 通过 watchfile 监控文件改动,异步更新本地数据库(语义索引 & 向量索引) |
| 语义搜索 | 通过向量嵌入 + BM25 混合检索,按语义召回相关记忆 |
| 文件读取 | 直接通过文件工具读取对应的 Memory Markdown 文件,按需加载保持上下文精简 |
记忆采用纯 Markdown 文件存储,Agent 通过文件工具直接操作。默认工作空间使用两层结构:
graph LR
Workspace[工作空间 working_dir] --> MEMORY[MEMORY.md 长期记忆]
Workspace --> MemDir[memory/*]
MemDir --> Day1[2025-02-12.md]
MemDir --> Day2[2025-02-13.md]
MemDir --> DayN[...]
存放长期有效、极少变动的关键信息。
- 位置:
{working_dir}/MEMORY.md - 用途:存储决策、偏好、持久性事实
- 更新:Agent 通过
write/edit文件工具写入
每天一页,追加写入,记录当天的工作与交互。
- 位置:
{working_dir}/memory/YYYY-MM-DD.md - 用途:记录日常笔记和运行上下文
- 更新:Agent 通过
write/edit文件工具追加写入,对话过长需要进行总结时自动触发
| 信息类型 | 写入目标 | 操作方式 | 示例 |
|---|---|---|---|
| 决策、偏好、持久事实 | MEMORY.md |
write / edit 工具 |
"项目使用 Python 3.12"、"偏好 pytest 框架" |
| 日常笔记、运行上下文 | memory/YYYY-MM-DD.md |
write / edit 工具 |
"今天修复了登录 Bug"、"部署了 v2.1" |
| 用户说"记住这个" | 立即写入文件 | write 工具 |
不要仅保存在内存中! |
Embedding 配置用于向量语义搜索,配置优先级为:配置文件 > 环境变量 > 默认值。
在 agent.json 的 running.embedding_config 中配置:
| 配置项 | 说明 | 默认值 |
|---|---|---|
backend |
Embedding 后端类型 | openai |
api_key |
Embedding 服务的 API Key | `` |
base_url |
Embedding 服务的 URL | `` |
model_name |
Embedding 模型名称 | `` |
dimensions |
向量维度,用于初始化向量数据库 | 1024 |
enable_cache |
是否启用 Embedding 缓存 | true |
use_dimensions |
是否在 API 请求中传递 dimensions 参数 | false |
max_cache_size |
Embedding 缓存最大条目数 | 2000 |
max_input_length |
单次 Embedding 最大输入长度 | 8192 |
max_batch_size |
Embedding 批处理最大数量 | 10 |
use_dimensions用于某些 vLLM 模型不支持 dimensions 参数的情况,设为false可跳过该参数。
当配置文件中未设置时,以下环境变量作为 fallback:
| 环境变量 | 说明 | 默认值 |
|---|---|---|
EMBEDDING_API_KEY |
Embedding 服务的 API Key | `` |
EMBEDDING_BASE_URL |
Embedding 服务的 URL | `` |
EMBEDDING_MODEL_NAME |
Embedding 模型名称 | `` |
api_key、model_name和base_url都非空才能开启混合检索中的向量检索。
通过环境变量 FTS_ENABLED 控制是否启用 BM25 全文检索:
| 环境变量 | 说明 | 默认值 |
|---|---|---|
FTS_ENABLED |
是否启用全文检索 | true |
即使不配置 Embedding,启用全文检索仍可通过 BM25 进行关键词搜索。
通过 MEMORY_STORE_BACKEND 环境变量配置记忆存储后端:
| 环境变量 | 说明 | 默认值 |
|---|---|---|
MEMORY_STORE_BACKEND |
记忆存储后端,可选 auto、local、chroma、sqlite |
auto |
存储后端说明:
| 后端 | 说明 |
|---|---|
auto |
自动选择:Windows 使用 local,其他系统使用 chroma |
local |
本地文件存储,无需额外依赖,兼容性最好 |
chroma |
Chroma 向量数据库,支持高效向量检索;在某些 Windows 环境下可能出现 core dump |
sqlite |
SQLite 数据库 + 向量扩展;在 macOS 14 及更低版本上存在卡死和闪退问题 |
推荐:使用默认的
auto模式,系统会根据平台自动选择最稳定的后端。
Agent 有两种方式找回过去的记忆:
| 方式 | 工具 | 适用场景 | 示例 |
|---|---|---|---|
| 语义搜索 | memory_search |
不确定记在哪个文件,按意图模糊召回 | "之前关于部署流程的讨论" |
| 直接读取 | read_file |
已知具体日期或文件路径,精确查阅 | 读取 memory/2025-02-13.md |
记忆搜索默认采用向量 + BM25 混合检索,两种检索方式各有所长,互为补充。
将文本映射到高维向量空间,通过余弦相似度衡量语义距离,能捕捉意义相近但措辞不同的内容:
| 查询 | 能召回的记忆 | 为什么能命中 |
|---|---|---|
| "项目的数据库选型" | "最终决定用 PostgreSQL 替换 MySQL" | 语义相关:都在讨论数据库技术选择 |
| "怎么减少不必要的重建" | "配置了增量编译避免全量构建" | 语义等价:减少重建 ≈ 增量编译 |
| "上次讨论的性能问题" | "P99 延迟从 800ms 优化到 200ms" | 语义关联:性能问题 ≈ 延迟优化 |
但向量搜索对精确、高信号的 token 表现较弱,因为嵌入模型倾向于捕捉整体语义而非单个 token 的精确匹配。
基于词频统计进行子串匹配,对精确 token 命中效果极佳,但在语义理解(同义词、改写)方面较弱。
| 查询 | BM25 能命中 | BM25 会漏掉 |
|---|---|---|
handleWebSocketReconnect |
包含该函数名的记忆片段 | "WebSocket 断线重连的处理逻辑" |
ECONNREFUSED |
包含该错误码的日志记录 | "数据库连接被拒绝" |
打分逻辑:将查询拆分为词,统计每个词在目标文本中的命中比例,并为完整短语匹配提供加分:
base_score = 命中词数 / 查询总词数 # 范围 [0, 1]
phrase_bonus = 0.2(仅当多词查询且完整短语匹配时)
score = min(1.0, base_score + phrase_bonus) # 上限 1.0
示例:查询 "数据库 连接 超时" 命中一段只包含 "数据库" 和 "超时" 的文本 → base_score = 2/3 ≈ 0.67,无完整短语匹配 →
score = 0.67
为了处理 ChromaDB
$contains的大小写敏感问题,检索时会自动生成每个词的多种大小写变体(原文、小写、首字母大写、全大写),提高召回率。
同时使用向量和 BM25 两路召回信号,对结果进行加权融合(默认向量权重 0.7,BM25 权重 0.3):
- 扩大候选池:将最终需要的结果数乘以
candidate_multiplier(默认 3 倍,上限 200),两路分别检索更多候选 - 独立打分:向量和 BM25 各自返回带分数的结果列表
- 加权合并:按 chunk 的唯一标识(
path + start_line + end_line)去重融合- 仅被向量召回 →
final_score = vector_score × 0.7 - 仅被 BM25 召回 →
final_score = bm25_score × 0.3 - 两路都召回 →
final_score = vector_score × 0.7 + bm25_score × 0.3
- 仅被向量召回 →
- 排序截断:按
final_score降序排列,返回 top-N 结果
示例:查询 "handleWebSocketReconnect 断线重连"
| 记忆片段 | 向量分数 | BM25 分数 | 融合分数 | 排序 |
|---|---|---|---|---|
| "handleWebSocketReconnect 函数负责 WebSocket 断线重连" | 0.85 | 1.0 | 0.85×0.7 + 1.0×0.3 = 0.895 | 1 |
| "网络断开后自动重试连接的逻辑" | 0.78 | 0.0 | 0.78×0.7 = 0.546 | 2 |
| "修复了 handleWebSocketReconnect 的空指针异常" | 0.40 | 0.5 | 0.40×0.7 + 0.5×0.3 = 0.430 | 3 |
graph LR
Query[搜索查询] --> Vector[向量语义搜索 × 0.7]
Query --> BM25[BM25 全文检索 × 0.3]
Vector --> Merge[按 chunk 去重 + 加权求和]
BM25 --> Merge
Merge --> Sort[按融合分数降序排列]
Sort --> Results[返回 top-N 结果]
总结:单独使用任何一种检索方式都存在盲区。混合检索让两种信号互补,无论是「自然语言提问」还是「精确查找」,都能获得可靠的召回结果。