上下文工程:文件/RAG、会话记忆与 token 预算
30 秒导读: 模型每次收到的那段 prompt,不是"历史消息原样倒进去",而是 LibreChat 现场拼出来的。拼进去的料有四样——用户附件与检索片段、对话历史、关于用户的长期记忆、系统指令。本章讲清这四样各自怎么被构造、超过上下文窗口时怎么取舍、token 怎么计量。工具执行本身归 04,消息落库细节归 03;这里只讲上下文怎么被造出来。
1. 这章解决什么问题(先建直觉)
模型有一个上下文窗口(context window,一次调用能读的 token 上限,如 128k)。它既装历史、又装你刚上传的 PDF、又装"记住我是左撇子"这类偏好,还要留位置给模型回话。窗口是稀缺资源,谁进谁不进,得有人决策。
这套"决策 + 拼装"就是上下文工程。LibreChat 每收到一条聊天消息,都要临时回答四个问题:
| 要拼进 prompt 的料 | 它从哪来 | 谁负责 |
|---|---|---|
| 附件正文 / 检索片段 | 用户上传的文件(向量库、OCR 文本) | Files 服务 + RAG API |
| 对话历史 | 数据库里的消息链 | getMessagesForConversation |
| 关于用户的长期记忆 | 跨会话累积的 memory 记录 | 记忆子系统 |
| 系统指令 | agent 配置 + 运行期动态尾巴 | applyContextToAgent |
一句话类比: 把上下文窗口当一张桌子。桌子就这么大,四摞纸(附件、历史、记忆、指令)都想摊上去。本章讲的就是:每摞纸怎么裁到合适厚度、哪摞先被挤下桌、以及怎么在不真正上桌前就估出"这次能不能放下"。
本节不碰代码。记住一件事:发给模型的 prompt 是现拼的,不是存下来的。
2. 顶层全景:上下文怎么被拼起来
一条消息从到达服务器到形成最终 prompt,上下文相关的处理走这条线(从左到右是时间顺序):
┌─────────────────────────────────────────┐
│ buildMessages() │
上传阶段(更早) │ (client.js:292,本章的中枢) │
┌──────────┐ │ │
│ 文件落库 │ │ ① 历史逐条格式化 + 逐条数 token │
│ + 向量化 │──files──▶│ ② 附件正文内联进当前用户消息(fileCtx) │
└──────────┘ │ ③ RAG 检索片段 → augmentedPrompt │
│ │ ④ 记忆注入 useMemory() → memoryContext │
│ │ ⑤ 三样侧信道拼成"动态系统尾巴" │
┌──────────┐ └───────────────┬───────────────────────────┘
│ 记忆抽取 │◀──并行,3s 超时──────────┘
│ runMemory│ │
└──────────┘ ▼
交给图运行时(chapter 03)
按 indexTokenCountMap 裁剪/摘要
怎么读这张图: 左边"上传阶段"发生在更早的请求里(文件已落库、已向量化);中枢 buildMessages 在每次聊天时把五件事拼齐;记忆抽取是旁路并行跑的,不阻塞主回答;真正的"超窗裁剪"不在这里,而在下游图运行时(见 03)。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 存储策略分派 | 按后端(本地/S3/Azure…)选读写函数 | Files/strategies.js getStrategyFunctions |
| 向量化 | 把文件发给 RAG API 做 embedding | Files/VectorDB/crud.js uploadVectors |
| RAG 检索 | 拿用户问句去向量库捞相关片段 | prompts/createContextHandlers.js |
| 引用整理 | 把 file_search 工具结果裁成引用 | Files/Citations/index.js processFileCitations |
| 记忆注入 | 读出用户记忆拼进系统尾巴 | client.js useMemory |
| 记忆抽取 | 从近几轮对话里提炼新记忆 | client.js runMemory |
| token 计量 | 逐条数 token、算窗口占用 | client.js buildMessages |
| 上下文投影 | 不调用模型也能估窗口占用 | endpoints/projection.ts |
下面按"文件/RAG → 记忆 → token 预算 → 合流"的顺序,一层层往下钻。
3. 文件与 RAG:把用户的文档喂进模型
3.1 分两个阶段:先落库,后进 prompt
用户上传文件时,文件并不会立刻进 prompt——它先被存起来、可能被向量化。真正拼进 prompt 是在之后某次聊天发生的。理解这个时间差是理解整条链路的关键。
[上传请求] 存储后端落盘 ──▶ (若走 RAG) 发去 embedding ──▶ 库里留一条 file 记录
[聊天请求] file 记录被引用 ──▶ 按类型分流:内联正文 / 语义检索 / 图片 / 工具检索
3.2 落库:一套接口,多种后端
LibreChat 支持把文件放本地磁盘、S3、Azure Blob、Firebase、OpenAI 文件接口或向量库。上层代码不关心具体是哪种,靠一个策略分派器按来源(FileSources)返回对应的读写函数:
// api/server/services/Files/strategies.js:310 getStrategyFunctions
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) return firebaseStrategy();
else if (fileSource === FileSources.local) return localStrategy();
else if (fileSource === FileSources.s3) return s3Strategy();
else if (fileSource === FileSources.vectordb) return vectorStrategy();
// …azure_blob / openai / execute_code / *_ocr / document_parser …
};
这就是典型的策略模式:每个后端实现同一组函数(保存、删除、拿 URL、读流),getStrategyFunctions 是唯一的入口。要加一种存储,只加一个 xxxStrategy() 并在这里挂一个分支。
3.3 向量化:把文件发给 RAG API
如果文件要做语义检索(RAG,检索增强生成——先按相似度捞相关片段,再拿片段辅助回答),它会被上传到一个独立的 RAG 服务(RAG_API_URL)做 embedding:
// api/server/services/Files/VectorDB/crud.js:67 uploadVectors
const jwtToken = generateShortLivedToken(req.user.id); // 短时令牌鉴权
const formData = new FormData();
formData.append('file_id', file_id);
formData.append('file', fs.createReadStream(file.path));
const response = await axios.post(`${process.env.RAG_API_URL}/embed`, formData, { … });
// 返回 embedded: Boolean(responseData.known_type)
关键细节: LibreChat 自己不做向量计算,它把文件转手给外部 RAG 服务。返回结果里 known_type === false 表示这种文件类型不支持,会抛错(crud.js:99)。删除时同理,调 DELETE /documents 把向量一并清掉(deleteVectors,crud.js:20),且对 404 容错——向量已经不在也算删成功。
3.4 进 prompt:三条互斥的路
到了聊天阶段,附件不是只有一种进 prompt 的方式。buildMessages 里按文件性质分流:
| 文件性质 | 怎么进 prompt | 代码锚点 |
|---|---|---|
| 已抽好文本(OCR / 转写 / 文档解析) | 正文内联到当前用户消息 | BaseClient.js:1321 addFileContextToMessage |
| 图片 | 编码成 image_url 部件 | client.js:278 addImageURLs |
| 已向量化、非 agents 端点 | 语义检索片段拼进增强提示 | client.js:347 createContextHandlers |
| agents 端点的向量文件 | 交给 file_search 工具去检索 | 见 04 |
第一条(内联)最直白:文本已经在库里,extractFileContext 把它取出来挂到 message.fileContext,再由 prependFileContext 塞进当前用户消息的正文(client.js:379)。历史附件也会被每轮重新内联,保证模型每次都看到被引用的内容,且 token 计数对得上(client.js:374-382 的注释点明了这个设计)。