跳到主要内容

上下文工程:文件/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 做 embeddingFiles/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 的注释点明了这个设计)。

3.5 RAG 检索:每个文件捞 4 段

第三条(语义检索)只在非 agents 端点上走(client.js:347!isAgentsEndpoint 判断;注释也点明 Bedrock 用的是这条 legacy RAG 路径)。它对每个已向量化的文件发一次查询:

// api/app/clients/prompts/createContextHandlers.js:33 query()
return axios.post(`${process.env.RAG_API_URL}/query`, {
file_id: file.file_id,
query: userMessageContent, // 用户这句话就是检索 query
k: 4, // 每个文件捞 top-4 片段
}, {});

捞回来的片段被拼成一段带 <context> XML 标签的增强提示(createContext,createContextHandlers.js:62),末尾还附一段 footer(:5)叮嘱模型"不知道就说不知道、别提这是从 context 来的"。

有个环境开关 RAG_USE_FULL_CONTEXT(:22):开了就不做 top-k 检索,直接把整份文档正文拉进来。适合短文档,长文档会撑爆窗口。

这段增强提示最终存进 this.augmentedPrompt,在 buildMessages 里被收进"动态系统尾巴"(client.js:512-516)——具体怎么合流见 §6

3.6 引用:让答案可追溯

agents 端点用 file_search 工具做检索(工具本身见 04)。工具跑完会吐出一批 sources,processFileCitations 负责把它们裁成引用(citations)——就是答案里"[来源:合同.pdf]"这种可追溯标注:

// api/server/services/Files/Citations/index.js:57 阈值与上限
const maxCitations =?.maxCitations ?? 30; // 总条数上限
const maxCitationsPerFile =?.maxCitationsPerFile ?? 5; // 每文件上限
const minRelevanceScore =?.minRelevanceScore ?? 0.45; // 相关度门槛
const filteredSources = sources.filter((s) => s.relevance >= minRelevanceScore);

三道闸:先按相关度 0.45 过滤,再按"每文件最多 5 条"分配(applyCitationLimits,:102),最后全局排序取前 30。目的是别让引用刷屏——只留最相关的那几条,并回查数据库补上文件名等元数据(enhanceSourcesWithMetadata,:127)。


4. 会话记忆:让模型跨会话记住用户

4.1 两种模式:静态记忆 vs 记忆 Agent

"记忆"在 LibreChat 里指跨会话持久的、关于用户的事实(如"我用公制单位""我叫 Amy")。它有两种工作模式,由配置决定:

模式谁往库里写记忆判定函数
静态记忆不自动写,只读出已有记忆注入isMemoryAgentEnabled 返回 false
记忆 Agent一个专门的 LLM 从对话里自动提炼并写库isMemoryAgentEnabled 返回 true

判定逻辑很小:记忆 Agent 必须显式开启才生效(agent.enabled === true 且配了有效 agent),这是一次刻意的"默认关"改动:

// packages/data-schemas/src/app/memory.ts:37 isMemoryAgentEnabled
export function isMemoryAgentEnabled(config) {
if (!isMemoryEnabled(config)) return false;
return config?.agent?.enabled === true && hasValidAgent(config.agent);
}

loadMemoryConfig(:15)在加载时,若发现配了 agent 却没写 enabled: true,会打一条 warn 提醒"自动抽取现在是 opt-in"——历史上它是默认开的。

4.2 注入:把记忆读出来拼进 prompt

每次 buildMessages 都会调 useMemory() 把用户已有记忆读出来:

// api/server/controllers/agents/client.js:520
const withoutKeys = await this.useMemory();
const memoryContext = withoutKeys
? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`
: undefined;

useMemory(:628)做的事,按顺序:

  1. 用户在个人设置里关了记忆(personalization.memories === false)→ 直接返回(:630)。
  2. 没有 MEMORIES USE 权限 → 返回(:633)。
  3. 静态模式 → 直接 getFormattedMemories 读出格式化记忆串返回(:655-658)。
  4. Agent 模式 → 加载记忆 agent、建 createMemoryProcessor,同时把 this.processMemory 挂上供稍后抽取用(:759-774)。

注意:useMemory 在 Agent 模式下一鱼两吃——既返回"现有记忆"用于注入,又顺手准备好抽取器。

memoryInstructions 是一句固定说明,告诉模型"系统会自动存取用户信息"(packages/api/src/agents/memory.ts:53)。

4.3 抽取:从近几轮对话里提炼记忆

runMemory(messages)(client.js:815)是记忆 Agent 模式下的另一半:把最近几轮对话喂给记忆 agent,让它决定要不要 set_memory / delete_memory。这里有三处工程细节值得记:

① 滑动窗口。 只看最近 messageWindowSize(默认 5)条,且尽量让窗口从一条 user 消息起头(:822:834-846)。默认值在 schema 里(config.ts:1616)。

② 双重截断防超限。 先按字符粗砍(MEMORY_INPUT_CHARS_PER_TOKEN = 8,client.js:89),再按 token 精砍(processTextWithTokenLimit),都保留末尾(最近的对话):

// api/server/controllers/agents/client.js:857
const maxInputChars = maxInputTokens * MEMORY_INPUT_CHARS_PER_TOKEN;
const isCharTruncated = bufferString.length > maxInputChars;
// 超了就丢开头、留结尾,并插一句 "[Earlier chat content omitted…]"

maxInputTokens 默认 DEFAULT_MEMORY_MAX_INPUT_TOKENS = 12000(config.ts:1607)。

③ 剥掉技能注入的元消息。 skill-prime 消息带着大段 SKILL.md 正文,若混进窗口会挤掉真实对话、污染抽取结果,所以先 filter 掉(:831)。

抽取真正干活的是 processMemory(memory.ts:286)——它是一个独立的小型 agent 回合,挂着 set_memory/delete_memory 两个工具,由 createMemoryProcessor(:513)装配。

4.4 超时保护:记忆不能拖慢回答

抽取是旁路并行跑的:主回答一开始就把 runMemory 的 promise 存下(client.js:1362),等主回答生成完了才去收它,而且只等 3 秒:

// api/server/controllers/agents/client.js:603 awaitMemoryWithTimeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs));
const attachments = await Promise.race([memoryPromise, timeoutPromise]);

Promise.race 让"记忆抽取"和"3 秒闹钟"赛跑,谁先到听谁的。超时就打个 warn 放弃(:616),绝不阻塞用户拿到回答。收取点在 chatCompletion 收尾处(:1531)。

这是一条清晰的取舍: 记忆抽取是"锦上添花",宁可这轮漏抽,也不让用户多等。


5. token 预算:谁上桌、谁下桌

5.1 逐条计量:每条消息值多少 token

buildMessages 的核心循环给每条历史消息算一个 token 数(client.js:362-463)。它不盲目信任数据库里存的 tokenCount,在三种情况下强制重算:

// api/server/controllers/agents/client.js:406 needsCanonicalTokenCount
const needsCanonicalTokenCount =
!hasDbTokenCount || // 库里没存
(this.isVisionModel && (message.image_urls || message.files)) || // 视觉模型带图
(Array.isArray(message.quotes) && message.quotes.length > 0); // 带引用摘录

原因很实在:比如一条消息被"仅文本编辑"后,tokenCount 只按文本重算了,但 quotes(引用摘录)还在,发出去时又会把 quote 块拼回去——不重算就会少算。重算能自愈这种陈旧计数(:399-405 注释)。

产物有两份:indexTokenCountMap(按序号记prompt 占用,给下游裁剪用)和 tokenCountMap(按 messageId 记规范计数,用于落库),两者分开是因为 fileContext 让"发出去的"和"存下来的"token 数不一样(:434-447)。

5.2 经典裁剪:从新到旧塞,塞不下就停

老式(非 agent)客户端的裁剪在 BaseClient.getMessagesWithinTokenLimit(:492)。逻辑是从最新消息往旧了塞,塞满窗口就停:

// api/app/clients/BaseClient.js:504
while (messages.length > 0 && currentTokenCount < remainingContextTokens) {
const poppedMessage = messages.pop(); // 从末尾(最新)取
if (currentTokenCount + poppedMessage.tokenCount <= remainingContextTokens) {
context.push(poppedMessage); // 放得下,收进来
currentTokenCount += poppedMessage.tokenCount;
} else { messages.push(poppedMessage); break; } // 放不下,停,回退这条
}
// …塞不进的进 messagesToRefine(留给摘要环节)

保新丢旧:窗口不够时,牺牲最老的历史。塞不下的那批进 messagesToRefine,交给上游摘要机制。系统指令(instructions)始终占 index 0、优先保留(:521-524)。

5.3 Agent 路径:自己不裁,只量了交出去

关键边界:AgentClient.buildMessages 本身不做超窗裁剪。它把逐条算好的 indexTokenCountMap 一路带到 createRun(client.js:1381),真正的裁剪/摘要由下游的 @librechat/agents 图运行时按这张表执行。也就是说——

  • 本文件负责: 精确计量每条消息的 token。
  • 图运行时负责: 拿着计量结果决策谁进谁出(见 03)。

getTokenCountForResponse(:1131)是配套的另一半:数模型回复的 token,用于用量记账(记账落库归 03)。

5.4 上下文投影:不调模型也能估占用

前端那个"上下文用了多少"的仪表盘(gauge),不可能每次都真跑一遍模型来算。resolveContextProjection(projection.ts:238)干的就是离线估算:

沿 parentMessageId 回溯出这条分支的消息链 (resolveBranch)

复用每条消息已存的 tokenCount(不重新分词)

调 projectAgentContextUsage(agents SDK) ← 不调用模型

返回 { 各段占用, 是否会触发裁剪 }

它刻意复用已校准的 tokenCount、不重新分词(:230-232),很省。有几处诚实的边界:分支被摘要压缩过就直接返回 null 交给客户端估(:274);分支太长/文本太大也返回 null(:264:279)。文件顶部的 docstring 还明说了当前版本没算指令和工具 schema 的 token(:233-236)——是已知的近似。

控制器只是薄壳,注入几个数据库读取函数就完事(ContextProjectionController.js:13)。

5.5 窗口从哪来:token 配置

裁剪的"上限"是每个模型的上下文窗口AgentClient 构造时把它记在 this.maxContextTokens(client.js:126)。对于自定义端点,窗口值由 resolveTokenConfigMap(tokenConfig.ts:24)解析——优先用 yaml 里静态配的 tokenConfig,否则读缓存里 fetch 来的模型配置(:39-49),必要时连价格一起算(interface.contextCost 开时,:28)。


6. 合流:一次 prompt 的四种料怎么汇到一起

回到 buildMessages 结尾,把前面几节的产物汇总成最终 prompt。三样"侧信道"内容被收进一个数组,拼成动态系统尾巴(dynamic system tail,追加在 agent 静态指令后面的运行期内容):

// api/server/controllers/agents/client.js:509 sharedRunContextParts
const sharedRunContextParts = [];
if (this.contextHandlers) { // ③ RAG 增强提示
this.augmentedPrompt = await this.contextHandlers.createContext();
if (this.augmentedPrompt) sharedRunContextParts.push(this.augmentedPrompt);
}
// ④ 记忆 context 单独处理(按 agent 配置决定注入给谁)
const withoutKeys = await this.useMemory();

四种料的最终去向(这是本章的收束):

拼到哪锚点
当前附件正文内联进当前用户消息(不进尾巴):340 addFileContextToMessage
RAG 检索片段动态系统尾巴:512-516 augmentedPrompt
用户记忆动态系统尾巴(按 agent 配置分发):520-523 memoryContext
历史消息payload 消息数组:498 payload = formattedMessages

最后由 applyContextToAgent(:582)把尾巴追加到每个 agent 的 additional_instructions 上——静态指令留在 instructions,运行期内容进 additional_instructions,两者分离(:558-564 注释)。多 agent 时,记忆只发给主 agent 或开了记忆 agent 的场景(:574)。

至此,四摞纸各就各位:附件正文贴在用户那句话上,检索片段和记忆挂在系统尾巴,历史排成消息数组——一份现拼的 prompt 成形,交给图运行时(见 03)去跑。


7. 边界与局限(诚实清单)

  • RAG 走外部服务。 没配 RAG_API_URL 时,向量化与语义检索整条路直接短路(crud.js:68createContextHandlers.js:14)——本仓库不含向量数据库实现。
  • 两条 RAG 路径不重叠。 语义检索的 createContextHandlers 只在非 agents 端点跑(client.js:347);agents 端点靠 file_search 工具(04),两者不会同时对同一文件生效。
  • 投影是近似。 resolveContextProjection 当前不计入指令与工具 schema 的 token,摘要过的分支直接放弃估算(projection.ts:233-236:274)——gauge 是"够用的估计",不是精确账。
  • 记忆抽取可能被丢。 3 秒超时是硬限,慢了这轮就不抽(client.js:603);记忆抽取默认 opt-in,不显式开就只读不写(memory.ts:37)。
  • agent 路径的裁剪不在本仓库这一层。 buildMessages 只计量,超窗裁剪/摘要发生在下游 @librechat/agents 图运行时——想读裁剪算法本身,得看那个依赖(见 03)。

8. 代码地图(导航索引)

主题文件路径符号名
上下文拼装中枢api/server/controllers/agents/client.jsbuildMessages
逐条 token 计量api/server/controllers/agents/client.jsneedsCanonicalTokenCount / indexTokenCountMap
记忆注入api/server/controllers/agents/client.jsuseMemory
记忆抽取(窗口+截断)api/server/controllers/agents/client.jsrunMemory
记忆抽取超时保护api/server/controllers/agents/client.jsawaitMemoryWithTimeout
图片编码进 promptapi/server/controllers/agents/client.jsaddImageURLs / filterImageUrls
模型回复 token 计数api/server/controllers/agents/client.jsgetTokenCountForResponse
经典超窗裁剪api/app/clients/BaseClient.jsgetMessagesWithinTokenLimit
附件正文内联api/app/clients/BaseClient.jsaddFileContextToMessage / processAttachments
请求附件过滤packages/api/src/utils/message.tsbuildMessageFiles
存储后端分派api/server/services/Files/strategies.jsgetStrategyFunctions
向量化 / 删向量api/server/services/Files/VectorDB/crud.jsuploadVectors / deleteVectors
RAG 语义检索 + 增强提示api/app/clients/prompts/createContextHandlers.jscreateContextHandlers / createContext
引用裁剪与阈值api/server/services/Files/Citations/index.jsprocessFileCitations / applyCitationLimits
记忆配置判定packages/data-schemas/src/app/memory.tsloadMemoryConfig / isMemoryAgentEnabled
记忆抽取 agentpackages/api/src/agents/memory.tscreateMemoryProcessor / processMemory
上下文投影(离线估算)packages/api/src/endpoints/projection.tsresolveContextProjection
投影控制器(薄壳)api/server/controllers/ContextProjectionController.jscontextProjectionController
上下文窗口解析packages/api/src/endpoints/tokenConfig.tsresolveTokenConfigMap
记忆默认值packages/data-provider/src/config.tsDEFAULT_MEMORY_MAX_INPUT_TOKENS / memorySchema

同组其它章:index · 01 请求生命周期 · 02 Endpoint 抽象 · 03 Agent 编排与流式 · 04 工具与 MCP · 06 支撑层