我是怎么让 AI 记住所有历史对话的——ai-memory 技术原理深度解析

22 阅读9分钟

我是怎么让 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 文件。

本文聚焦技术实现,讲三件事:

  1. 大量对话历史如何处理——分块策略
  2. 如何保证提取出来的内容是准确的——多层质量过滤
  3. 新对话如何精准找回相关历史——三路混合检索

适合哪些使用场景?

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)。即便模型支持这么大的上下文,问题也有两个:

  1. 质量下降:LLM 在超长 context 下对早期内容的注意力会显著下降
  2. 成本和速度:每次提取都要处理整个对话,既慢又贵

分块策略

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…