1. 为什么大模型需要外挂记忆:从上下文窗口到 Qdrant 向量数据库
大模型本身没有持久记忆,它每次对话都像刚开机:你昨天告诉它的项目背景、偏好、踩过的坑,今天重新开一个会话就全没了。上下文窗口再大,也只是一个「临时草稿纸」,会话结束即清空。想让 AI 记住跨会话的历史,就得在模型之外建一层可检索、可持久化的记忆层,而 Qdrant 向量数据库配合 MCP 协议,是目前 TypeScript 技术栈里比较顺手的一套组合。
先说清楚这套东西是什么、能做什么、适合谁。MCP(Model Context Protocol)是一套让大模型调用外部工具和资源的协议,你可以把它理解成模型和外部世界之间的「标准插座」。Qdrant 是一个开源向量数据库,专门存语义向量并做相似度检索。把两者接起来,模型就能在对话中主动把重要事实写进 Qdrant,也能在需要时按语义召回历史片段。适合谁?适合正在做 AI Agent、长期陪伴型助手、个人知识库问答的开发者,尤其是用 TypeScript / Node.js 写服务端的人。
我试过把对话历史直接塞进 prompt,短会话没问题,一旦积累到几十轮,token 成本飙升,而且模型对中间部分的注意力明显下降,关键信息经常被忽略。这就是所谓的「Lost in the Middle」。向量检索解决的正是这个问题:不把全部历史塞进去,而是按当前问题召回最相关的几条记忆,既省 token 又提准确率。
长短期记忆的分工可以这样理解。短期记忆是当前会话的上下文缓冲区,由 Transformer 的激活状态承载,容量受 token 限制,会话关闭就消失。长期记忆是外部向量库,容量由硬盘决定,永久存储、可跨应用共享。两者配合:短期记忆负责当前对话的连贯,长期记忆负责跨会话的事实沉淀。
| 维度 | 短期记忆(Context Buffer) | 长期记忆(Qdrant 向量库) |
|---|---|---|
| 存储介质 | Transformer 激活状态 | Qdrant Collection |
| 容量 | 受 token 限制 | 受硬盘限制 |
| 检索方式 | 线性序列 | 向量空间相似度(ANN) |
| 持久化 | 会话关闭即消失 | 永久存储,可跨会话 |
| 典型用途 | 当前对话连贯 | 用户偏好、历史事件召回 |
MCP 在中间扮演「中间人」。它通过 Tools 暴露两个能力:写入(commit)和召回(recall)。模型在每轮交互结束时,可以把核心事实提取出来固化到向量库;当用户提出相关问题时,模型通过 MCP 自动检索最匹配的片段。整个过程模型自己决定何时调用,不需要你手动拼接 prompt。
这一层设计的关键在于「事实提取」而不是「原文存储」。原始对话里大量是寒暄和冗余,直接存进去会稀释检索精度。更好的做法是让模型总结成一句事实再写入,比如把「我昨天说我最近在学 Rust,还买了两本书」压缩成「用户正在学习 Rust」。这样向量空间里的点更干净,召回时命中率更高。
2. 前置准备:TaoToken 接入与 Qdrant 环境搭建
在写 MCP Server 之前,需要先把两件事准备好:一个能调用 embedding 和对话模型的 API 入口,以及一个跑起来的 Qdrant 实例。这里我用 TaoToken 作为模型接入层,它兼容 OpenAI 的接口格式,改一下 Base URL 就能用,省去自己维护多模型路由的麻烦。
先拿 Key。打开 https://taotoken.net/api-keys ,登录后在控制台创建一个 API Key,复制保存。注意这个 Key 只在创建时完整显示一次,丢了就得重建。拿到 Key 之后,模型对话入口在 https://taotoken.net/model-chat ,可以先用它验证 Key 是否可用,随便发一句话看有没有正常返回。
环境变量这样配,把 Key 写进.env,不要硬编码到代码里:
# .env TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api QDRANT_URL=http://localhost:6333Qdrant 用 Docker 起最省事,一条命令搞定:
docker run -d --name qdrant \ -p 6333:6333 -p 6334:6334 \ -v $(pwd)/qdrant_storage:/qdrant/storage \ qdrant/qdrant:latest6333是 HTTP 端口,6334是 gRPC 端口,-v把数据挂到本地目录,容器重启数据不丢。起来之后访问http://localhost:6333/dashboard能看到 Web UI,说明服务正常。
接着初始化 TypeScript 项目:
mkdir mcp-eternal-memory && cd mcp-eternal-memory npm init -y npm install @modelcontextprotocol/sdk @qdrant/js-client-rest openai dotenv npm install -D typescript @types/node tsx npx tsc --inittsconfig.json里把模块系统改成 ESM,因为 MCP SDK 用的是 ESM 风格:
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"] }然后在package.json里加一行"type": "module",否则 import 语法会报错。这一步很多人会漏,跑起来报Cannot use import statement outside a module,回头查半天。
Qdrant 的 collection 需要提前建好,指定向量维度。这里用text-embedding-3-small,维度是 1536。建 collection 的脚本:
// src/setup-collection.ts import { QdrantClient } from "@qdrant/js-client-rest"; const qdrant = new QdrantClient({ url: process.env.QDRANT_URL || "http://localhost:6333" }); async function main() { const collections = await qdrant.getCollections(); const exists = collections.collections.some(c => c.name === "user_memories"); if (exists) { console.log("collection 已存在,跳过"); return; } await qdrant.createCollection("user_memories", { vectors: { size: 1536, distance: "Cosine" } }); console.log("collection user_memories 创建完成"); } main();distance用Cosine,适合文本语义相似度。维度必须和 embedding 模型输出一致,写错了写入时会直接报维度不匹配。
3. 可复制配置:MCP Server 的 TypeScript 实现与 settings 片段
这一节是核心,把 MCP Server 的完整代码和客户端配置都给出来,你可以直接复制改路径用。整个 Server 暴露两个工具:commit_to_memory负责写入,recall_relevant_memories负责召回。
先看主文件:
// src/server.ts import "dotenv/config"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { QdrantClient } from "@qdrant/js-client-rest"; import OpenAI from "openai"; const qdrant = new QdrantClient({ url: process.env.QDRANT_URL || "http://localhost:6333", }); const openai = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL || "https://taotoken.net/api", }); const COLLECTION = "user_memories"; const EMBED_MODEL = "text-embedding-3-small"; const server = new Server( { name: "eternal-memory-engine", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "commit_to_memory", description: "将重要的事实、偏好或事件存入长期记忆,以便未来调用。", inputSchema: { type: "object", properties: { fact: { type: "string", description: "待记忆的核心事实描述" }, importance: { type: "number", minimum: 1, maximum: 5, description: "记忆的重要性等级", }, }, required: ["fact"], }, }, { name: "recall_relevant_memories", description: "根据当前语境,检索相关的历史记忆片段。", inputSchema: { type: "object", properties: { query: { type: "string", description: "检索关键词或语义描述" }, }, required: ["query"], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "commit_to_memory") { const fact = args?.fact as string; const embedding = await openai.embeddings.create({ model: EMBED_MODEL, input: fact, }); await qdrant.upsert(COLLECTION, { wait: true, points: [ { id: Date.now(), vector: embedding.data[0].embedding, payload: { text: fact, importance: (args?.importance as number) || 3, timestamp: Date.now(), }, }, ], }); return { content: [{ type: "text", text: "记忆已固化,我会记得这件事。" }], }; } if (name === "recall_relevant_memories") { const query = args?.query as string; const embedding = await openai.embeddings.create({ model: EMBED_MODEL, input: query, }); const results = await qdrant.search(COLLECTION, { vector: embedding.data[0].embedding, limit: 3, with_payload: true, }); const blocks = results .map( (r) => `- [${new Date(r.payload?.timestamp as number).toLocaleDateString()}] ${r.payload?.text}` ) .join("\n"); return { content: [{ type: "text", text: blocks || "未发现相关历史记忆。" }], }; } throw new Error("Tool not found"); }); const transport = new StdioServerTransport(); await server.connect(transport);注意id用Date.now()在高并发下可能撞车,生产环境建议换成 UUID。wait: true保证写入完成后再返回,避免刚写完立刻查查不到。
编译并运行:
npx tsc node dist/server.js如果用的是 Claude Code 或 Cline 这类支持 MCP 的客户端,配置片段长这样。以 Claude Code 的settings.json为例,路径通常在~/.claude/settings.json:
{ "mcpServers": { "eternal-memory": { "command": "node", "args": ["/绝对路径/mcp-eternal-memory/dist/server.js"], "env": { "TAOTOKEN_API_KEY": "sk-你的key", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "QDRANT_URL": "http://localhost:6333" } } } }三件套必须齐全:Base URL 指向https://taotoken.net/api,Key 用你自己的,Model ID 在代码里写死为text-embedding-3-small。少任何一个都会在启动时报错。Cline 的 MCP 配置在cline_mcp_settings.json,结构类似,把mcpServers对象塞进去即可。
如果你用的是 Codex 风格的auth.json,把 Key 和 Base URL 写进去:
{ "api_key": "sk-你的key", "base_url": "https://taotoken.net/api" }配置完重启客户端,在工具列表里应该能看到commit_to_memory和recall_relevant_memories两个工具。看不到就检查路径是不是绝对路径,相对路径在 MCP 启动时经常解析失败。
4. 验证请求:一次记忆写入与跨会话召回实测
配置好之后,得实际跑一遍确认模型真能跨会话读取历史。这一步分两个动作:先写入一条记忆,再新开一个会话召回它。
第一个会话里,对模型说:「记住,我的项目用的是 PostgreSQL 16,部署在阿里云,端口 5432。」模型应该会调用commit_to_memory,把这条事实写进 Qdrant。你可以在 Qdrant Dashboard 的user_memoriescollection 里看到新点,payload 里有text、importance、timestamp三个字段。
写入的底层请求等价于:
curl -X POST http://localhost:6333/collections/user_memories/points/search \ -H "Content-Type: application/json" \ -d '{ "vector": [0.01, 0.02, ...], "limit": 3, "with_payload": true }'实际向量是 1536 维,这里省略。返回结果里result数组就是命中的记忆片段,按相似度从高到低排。
然后关掉当前会话,重新开一个全新的对话,问:「我的数据库是什么版本,部署在哪?」如果一切正常,模型会调用recall_relevant_memories,检索到之前写入的那条事实,然后回答「PostgreSQL 16,部署在阿里云,端口 5432」。这就证明跨会话记忆生效了。
实测下来,召回准确率跟 embedding 模型和写入时的事实提取质量强相关。如果写入的是原始对话原文,召回时经常命中无关片段;如果写入的是总结后的事实,命中率明显更高。所以 System Prompt 里最好加一句引导:「每当用户提到个人背景、偏好或项目里程碑时,请先总结成一句事实再调用 commit_to_memory。」
验证召回时可以用一个更精确的查询,比如直接问「PostgreSQL 端口」,看返回的 payload 里text字段是不是那条记忆。如果返回空,先检查 collection 里有没有数据,再检查 embedding 维度是否一致。
5. 常见报错排查:401、local proxy failed 与 reading choices
跑这套东西最容易撞的几个错,我按实际遇到的频率排一下。
401 Unauthorized。多半是 Key 没配对,或者.env没被加载。检查TAOTOKEN_API_KEY是不是完整的sk-开头字符串,dotenv/config有没有在文件顶部 import。MCP 客户端里如果 Key 写在env字段,确认没有多余空格。还有一种情况是 Base URL 写成了带路径的地址,比如https://taotoken.net/api/v1,有些客户端会重复拼接,导致 404 而不是 401,但表现类似。
local proxy failed / connection refused。这个通常是 Qdrant 没起来,或者QDRANT_URL指向了错误的端口。先docker ps看容器在不在,再curl http://localhost:6333/collections看能不能返回 JSON。如果容器在但连不上,检查端口映射有没有写错,-p 6333:6333两个端口都要对。
reading choices / Cannot read properties of undefined。这个报错一般出现在解析 embedding 响应时。openai.embeddings.create返回的结构是{ data: [{ embedding: [...] }] },如果你写成response.embedding就会 undefined。检查代码里是不是embedding.data[0].embedding。另外如果模型名写错,比如把text-embedding-3-small写成text-embedding-ada-002,维度会变成 1536 以外的值,写入 Qdrant 时报维度不匹配。
OAuth / token expired。如果你用的是需要 OAuth 的客户端,Key 过期后会报这个。重新去 https://taotoken.net/api-keys 生成一个,更新到配置里重启客户端。注意 MCP Server 是独立进程,改了配置必须重启客户端才会重新加载。
工具列表为空。客户端连上了但看不到工具,通常是 Server 启动就崩了。手动node dist/server.js跑一下,看有没有抛异常。常见原因是tsc没编译成功,dist目录是空的,或者package.json里漏了"type": "module"。
排查顺序建议:先确认 Qdrant 活着,再确认 Key 能调通 embedding,最后确认 MCP 配置路径正确。三层都过了,基本不会有大问题。
6. 长期记忆的工程化:衰减、隔离与 Coding Plan
把记忆写进去只是第一步,真正难的是让它「科学地遗忘」和「安全地隔离」。一个无限增长的向量库,检索精度会随着噪声累积而下降。我踩过的坑是:早期没做衰减,三个月后召回结果里混进大量过时偏好,模型回答开始自相矛盾。
衰减机制可以这样设计。在 payload 里记录importance和last_recalled_at,定期跑一个清理任务:如果一条记忆重要性低于 3,且过去 30 天从未被召回,就把它移到冷存储或直接删除。召回时也可以加时间权重,语义相似度持平时优先返回时间戳更近的那条。
多租户隔离是红线。用户 A 的记忆绝不能出现在用户 B 的会话里。最稳的做法是每个用户一个独立 collection,或者在 search 时强制带上filter: { must: [{ key: "user_id", match: { value: userId } }] }。写入前还要做 PII 过滤,密码、身份证号这类高敏数据直接拒绝存储。
如果你打算把这套记忆引擎长期跑在编码 Agent 或自动化流程里,可以考虑用 Coding Plan 来管理调用配额和模型路由,入口在 https://taotoken.net/coding-plan 。它适合需要持续调用、按量计费的场景,比每次手动换 Key 省事。
接入文档在 https://taotoken.net/doc ,里面有完整的接口说明和示例。模型对话验证入口还是 https://taotoken.net/model-chat ,写完记忆后可以在这里快速测召回效果。API Key 管理在 https://taotoken.net/api-keys ,Key 丢了或者要轮换都从这里操作。
最后给一个实用技巧:写入记忆时,让模型同时生成一个「记忆标签」,比如["数据库", "部署"],存进 payload。召回时先用标签做粗筛,再做向量精排,能显著提升大规模记忆库下的检索速度。这个优化在记忆条数超过一万条之后效果特别明显。