我是怎么让 AI 记住所有历史对话的——ai-memory 技术原理深度解析
开源地址:github.com/hyxnj666-cr… npm:
npx ai-memory-cli@latest
问题起点
用 Cursor 开发了三个月,存了 200 多个对话窗口。每次新开会话,要花 5-10 分钟重新给 AI 铺垫背景——"我们上次决定用 event sourcing"、"这个接口之前讨论过要加幂等"——这些上下文完全靠人肉传递。
我想解决这个问题,但不想在代码里埋 memory.add() 调用,也不想把数据传到云端。所以做了 ai-memory:直接读编辑器已有的聊天记录,用 LLM 提炼出结构化知识,存成本地 Markdown 文件。
本文聚焦技术实现,讲三件事:
- 大量对话历史如何处理——分块策略
- 如何保证提取出来的内容是准确的——多层质量过滤
- 新对话如何精准找回相关历史——三路混合检索
适合哪些使用场景?
ai-memory 不是一个“聊天记录备份工具”,它更适合长期项目里的技术上下文复用。比如:
- 长期维护的业务项目:几个月前讨论过的接口约定、迁移方案、权限边界,后面改需求时不用重新翻聊天记录。
- AI 编程重度用户:Cursor / Claude Code / Windsurf 里积累了大量对话,新窗口可以直接继承之前的技术背景。
- 多人协作项目:团队成员可以把
.ai-memory/放进 Git,让架构决策、踩坑记录、约定规范随着项目一起流转。 - 复杂重构和迁移:数据库迁移、鉴权改造、支付链路重构这类任务通常跨很多会话,ai-memory 能保留“为什么这么做”的上下文。
- 开源项目维护:Issue、PR、版本迭代中反复出现的设计取舍,可以沉淀成可检索的项目知识库。
它不太适合只写一次性 demo、对话量很少的小项目。真正能发挥价值的场景,是那些“AI 经常忘,但你又不想每次重新解释”的长期工程。
第一部分:大对话的分块处理
为什么不能直接把整个对话丢给 LLM?
一个深度开发会话可能有 40-80 轮对话,展开成文本可能有 5-10 万字符(约 1.5-2.5 万 token)。即便模型支持这么大的上下文,问题也有两个:
- 质量下降:LLM 在超长 context 下对早期内容的注意力会显著下降
- 成本和速度:每次提取都要处理整个对话,既慢又贵
分块策略
const CHUNK_SIZE = 20_000; // 字符数,约 5k tokens
const CHUNK_OVERLAP = 2_000; // 块间重叠,保持上下文连贯
分块不是简单地按字符数截断,而是按对话自然边界分割:
function splitIntoChunks(text: string): string[] {
// ...
const lastUser = slice.lastIndexOf("\n\nUser:");
const lastAssistant = slice.lastIndexOf("\n\nAssistant:");
const boundary = Math.max(lastUser, lastAssistant);
// 在最近一个对话轮次边界处截断
if (boundary > CHUNK_SIZE * 0.5) {
chunkEnd = start + boundary;
}
}
每块独立送给 LLM 提取,块与块之间保留 2000 字符的重叠——避免一个决策刚好被切在两个块的边界处,导致信息丢失。
送给 LLM 前的噪声过滤
在分块之后、调用 LLM 之前,先清洗掉对提取没有价值的内容:
export function stripConversationNoise(text: string): string {
// 工具调用 XML 块(Cursor/Claude 内部调用)
cleaned = cleaned.replace(/<(?:tool_call|invoke|function_call)[\s\S]*?<\/...>/g, "[tool call]");
// 长哈希、base64 编码(Git hash、图片数据)
cleaned = cleaned.replace(/\b[0-9a-f]{32,}\b/gi, "[hash]");
cleaned = cleaned.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[base64]");
// 超过 500 字符的单行日志(堆栈跟踪、dump 输出)
cleaned = cleaned.replace(/^.{500,}$/gm, (line) =>
line.slice(0, 120) + `... [truncated ${line.length} chars]`
);
}
这一步可以把对话文本体积缩减 20-40%,LLM 的 token 消耗和提取精度都有明显改善。
第二部分:准确度保证——五层质量过滤
这是整个项目最核心的部分。提取出来的知识如果不准、不具体,对新对话没有价值,甚至会产生误导。ai-memory 设计了五层过滤机制。
第一层:Prompt 级别的质量约束
提取 Prompt 里有一套强制 checklist,LLM 必须满足所有条件才能输出一条记忆:
QUALITY CHECKLIST (each item must satisfy ALL):
□ SPECIFIC: names files, functions, APIs, config keys, or data structures
□ ACTIONABLE: another developer can act on this without re-reading the conversation
□ NON-OBVIOUS: not something any developer would already know
□ DURABLE: still relevant weeks/months later
□ COMPLETE: contains the full technical picture (problem + solution + why)
Prompt 里还有 ONE-MEMORY-PER-DECISION 规则——一个技术决策只能生成一条记忆,把 implementation gotchas、权限配置、子步骤全部合并进这一条,防止过度拆分:
BAD output (3 memories):
[architecture: event sourcing for billing]
[todo: REVOKE permissions on events table]
[todo: build nightly integrity check]
GOOD output (1 memory):
[architecture: event sourcing for billing impact: "billing_events table (REVOKE UPDATE/DELETE), nightly hash-chain check"]
第二层:技术具体性评分
即使 LLM 按规则输出了,代码层面还有一套评分系统——用 20+ 个正则模式统计内容里有多少"技术具体性指标":
const SPECIFICITY_PATTERNS: RegExp[] = [
/[./\\][\w-]+\.\w{1,5}\b/g, // 文件路径 ./src/x.ts
/(?:function|class|const|def)\s+\w+/g, // 函数/类声明
/\/api\/[\w/]+/g, // API 路由
/`[^`\n]{2,}`/g, // 行内代码引用
/\b\w+\.(?:ts|tsx|py|go|json|yaml)\b/g, // 带扩展名的文件名
/\b(?:GET|POST|PUT|DELETE)\s+\/[\w/-]*/g, // HTTP 方法 + 路由
/\bv?\d+\.\d+(?:\.\d+)?\b/g, // 版本号
// ... 更多模式
];
每个模式所有命中都计入分数(不只是"是否存在"),这样 3 个文件路径 + 2 个函数名 = 5 分,给出更准确的密度评估。
第三层:模糊内容检测
内置一份双语模糊短语黑名单,命中这些短语且技术具体性得分为 0 的内容直接过滤:
const VAGUE_PHRASES_ZH = [
"影响到整个项目", "优化了用户体验", "提高了效率",
"符合最佳实践", "实现了功能", "满足了需求",
// ...
];
const VAGUE_PHRASES_EN = [
"affects the entire project", "improves user experience",
"follows best practices", "is a good choice",
// ...
];
这些短语是真实 LLM 输出中反复出现的"废话模式",黑名单来自实际测试数据。
第四层:同批次去重(Shingle 相似度)
同一批提取可能对同一个技术点从不同角度描述了两次。用 3-gram shingle + Jaccard 相似度 检测:
export function shingles(text: string, n = 3): Set<string> {
const clean = text.toLowerCase().replace(/\s+/g, " ").trim();
const result = new Set<string>();
for (let i = 0; i <= clean.length - n; i++) {
result.add(clean.slice(i, i + n));
}
return result;
}
// Jaccard: |A ∩ B| / |A ∪ B|
// Containment: |A ∩ B| / |A| — 检测"包含"关系
const SHINGLE_DEDUP_THRESHOLD = 0.55; // Jaccard > 0.55 认为是同一内容
const CONTAINMENT_THRESHOLD = 0.75; // 小集合 75% 被大集合包含,认为是子集
发现重复时保留内容更完整的那条,而不是先出现的那条。
第五层:跨批次增量去重
每次提取时,把已有记忆的标题列表传进 Prompt:
const existingBlock = existingTitles
? `ALREADY EXTRACTED (DO NOT re-extract):
${existingTitles}`
: "";
LLM 在生成新记忆前会主动跳过已有知识,避免重复提取。
第三部分:三路混合检索——新对话如何精准找回历史
提取完存成 Markdown 只是第一步。更关键的是:新开一个对话时,怎么知道哪些历史记忆是相关的?
为什么不能只用关键词?
关键词搜索"OAuth"能找到标题里有 OAuth 的记忆,但找不到描述"登录授权流程重构"的记忆——即使语义完全相关。纯关键词在技术文档检索里有明显的召回率问题。
为什么不能只用语义向量?
语义搜索能理解意图,但对技术术语不够精确。搜索"PKCE 实现"时,向量可能把"OAuth 2.0 安全配置"排得比"WebView OAuth 用 Bridge 页中转"更高——即使后者才是真正相关的历史决策。
三路混合 + 字段加权
最终得分 = 语义相似度 × 0.55 + 关键词匹配 × 0.30 + 时间衰减 × 0.15
语义分(0.55):embedding 向量余弦相似度,理解语义意图
关键词分(0.30):支持 CJK bigram + trigram 分词,对不同字段加权
function keywordScore(m: ExtractedMemory, keywords: string[]): number {
for (const kw of keywords) {
const lengthBonus = len >= 3 ? 2 : len >= 2 ? 1.2 : 1; // 长词更有价值
if (titleLow.includes(kw)) score += 10 * lengthBonus; // title 命中权重最高
if (contentLow.includes(kw)) score += 5 * lengthBonus;
if (contextLow.includes(kw)) score += 2 * lengthBonus;
if (m.reasoning?.includes(kw)) score += 1 * lengthBonus;
if (m.impact?.includes(kw)) score += 1 * lengthBonus;
}
}
时间衰减(0.15):90 天半衰期的指数衰减,最近的记忆更相关
function recencyScore(dateStr: string): number {
const daysAgo = (Date.now() - new Date(dateStr).getTime()) / 86400000;
return Math.exp(-daysAgo / 90); // 90 天半衰期
}
无 embedding 时的优雅降级:如果没配置 embedding 服务,自动切到纯关键词 + 时间(0.85/0.15),不需要外部 API 也能工作。
CJK 分词实现
中文没有空格分隔词语,所以对 CJK 文本用 bigram + trigram 多粒度分词:
// "提取策略" → ["提取", "取策", "策略", "提取策", "取策略"]
// Bigrams + Trigrams 覆盖不同长度的词语边界
这样搜索"提取策略"时,即使记忆里写的是"内容提取的策略",也能匹配上。
第四部分:上下文输出格式
找到相关记忆后,有两种输出策略:
近期记忆——全量详情
### Key Decisions
- **WebView OAuth 用 Bridge 页中转** _(2026-03-15)_
App 内嵌 WebView 无法接收 OAuth redirect,方案:打开 static/oauth-bridge.html → postMessage 传回 token
Why: Deep Link 在 Android/iOS 行为不一致;Custom URL Scheme 被部分浏览器拦截
Rejected: Deep Link, Custom URL Scheme, Server-side redirect
Affects: src/pages/login.tsx, static/oauth-bridge.html, backend/routes/oauth.ts
老记忆——压缩索引(节省 context window)
### Older Memories (ask for details if needed)
- [D] WebView OAuth Bridge 方案 _(2026-01-10)_
- [A] 账单模块 Event Sourcing 架构 _(2026-01-05)_
新对话粘贴这段上下文后,AI 立刻知道"已有哪些决策不用重讨论",并且知道"有哪些老记忆可以追问"。
准确度验证:CCEB 基准测试
项目里有一套手写的 30 条 fixture 测试集(CCEB,Cursor Conversation Extraction Benchmark),覆盖:
- 正例:应该被提取的高质量知识
- 负例:闲聊、失败的调试步骤、临时代码、vague 描述
用 gpt-4o-mini 跑 F1 = 76.2%。这个数字的意义不是"能提取多少",而是"能在精确率和召回率之间保持平衡"——过于保守会漏掉重要决策,过于激进会让新对话里充斥低质量内容。
总结
| 环节 | 方案 |
|---|---|
| 大对话处理 | 按对话轮次边界分块,20k 字符/块,2k 重叠 |
| 噪声过滤 | 正则替换 tool call、hash、长日志行 |
| 提取质量 | Prompt checklist + ONE-MEMORY-PER-DECISION 规则 |
| 技术具体性 | 20+ 正则模式计分,低于阈值过滤 |
| 去重 | 3-gram Jaccard(同批次)+ 跨批次 existingTitles 传入 Prompt |
| 检索 | 语义(0.55) + CJK 关键词(0.30) + 时间衰减(0.15) |
| 降级策略 | 无 embedding 时退化为纯关键词+时间 |
如果你也在用 Cursor / Claude Code,可以直接跑:
npx ai-memory-cli extract
无需配置 API Key,内置免费模型,第一次用就能看到效果。欢迎 Star 和 Issue:github.com/hyxnj666-cr…