高质量检索并不等于可靠回答。即使相关文档已经准确进入上下文,大模型仍可能依赖训练记忆、忽略关键片段,或在多轮对话中逐渐丢失信息。要让检索结果真正参与生成,需要同时设计清晰的 Prompt 约束、合适的上下文组装策略,并为输入与输出合理分配 Token 空间。
? 本章学习目标
- 设计面向 RAG 场景的高质量 System Prompt,有效约束模型行为
- 根据文档规模和上下文窗口,在 Stuff、Map-Reduce、Refine 三种组装策略间做出选择
- 实现多轮对话中的 Token 预算分配与历史摘要压缩
- 运用"三明治排列"缓解 Lost in the Middle 效应,解释"检索到了却没用上"的 Prompt 层面根因
在《第9章:高级检索策略》和《第10章:检索后处理与重排序》中,我们在RAG检索管道中使用 混合检索、Multi-Query 、MMR 和 Reranker、阈值过滤和上下文压缩 分别解决了解决了"召不回"和"召不准"的问题。走到这一步,输入大模型的参考资料在质量上已经相当可靠了。
但在实际项目中,很多人在这里还会遇到一个问题,就是检索日志显示正确的文档已经被找回来了,模型生成的答案却还是错的,它要么无视资料继续用训练记忆里的旧知识回答,要么被资料中的某段噪声带偏,要么在多轮对话进行到一半时又开始丢失上下文了。这时候就需要 Prompt 工程来解决这个问题。
Prompt 是检索管道与生成管道之间的衔接层,它要做的事情是把系统指令、检索结果、对话历史和当前问题组织成 LLM 能最优利用的输入形式。检索决定了能拿到什么资料,而 Prompt 决定了模型愿不愿意、能不能用好这些资料。本章我们就来解决用好资料这件事。
先看一个真实开发中很常见的场景。当你在文档问答机器人里提问:“useEffect 的依赖数组为空时,组件在什么时候执行?”
假设向量检索非常给力,准确命中了 React 18 的官方文档片段,但模型最终给出的回答,很可能引用的依然是 React 16 时代的旧行为描述。这不是模型没看到你给的资料,而是它的注意力机制并不会自动给参考资料赋予更高的权重。当检索文档与模型训练记忆中的知识发生冲突时,模型本质上是在做下一个 token 的概率预测。如果训练语料中旧版知识的统计信号足够强(比如 React 16 时期的教程、博客、StackOverflow 回答在预训练语料中大量存在),它就会沿着最熟悉的路径生成,把参考资料当成可有可无的背景信息。这个问题我们在《第8章:RAG 的局限性及应对策略》已经详细讨论过,即当 检索资料与模型的训练记忆不一致时,模型的选择是不确定的。
那问题出在哪里?看一下这种场景可能提供给LLM的Prompt版本。
你是一个React开发助手,请回答用户的问题。
参考资料:
[文档 1] useEffect 的依赖数组为空时,组件只在挂载时执行一次...
用户问题:useEffect 的依赖数组为空时会怎样?
在这段 Prompt 里,虽然我们把检索到的文档拼进了输入,但压根没有告诉模型必须以参考资料为准。模型收到的只是一个普通的问答请求,它自然会调用自己最熟悉的训练记忆路径。如果没有明确的指令约束,模型依然可能把资料当成可有可无的背景信息。
RAG 场景中,我们可以换个写法:
你是一个基于知识库的专业问答助手。请严格遵循以下规则:
1. 只使用提供的参考资料回答问题
2. 如果资料中没有相关信息,明确说明,不要编造
3. 引用资料时标注来源,如 [文档 1]
4. 当多份资料存在矛盾时,如实指出不同观点
参考资料:
[文档 1] XXX
[文档 2] XXX
用户问题:XXX
对比这两种写法,主要区别在于后者明确告诉了LLM三个约束:什么是允许的(基于资料回答)、什么是不允许的(动用训练记忆、编造信息)、边界情况怎么处理(资料不足时拒答)。此外它还要求标注来源、处理资料间的冲突,这让最终答案具备可验证性。RAG Prompt 本质上就是一份给模型的工作守则,约束越明确,模型行为越稳定,这一点在第8章的各种失败案例中已经被反复验证。
在 Chat 模型的消息体系中,System Prompt 承担着定义模型身份与全局运行规范的角色,它在整个会话期间持续生效,决定了LLM如何按要求进行响应。下面是一个可以直接用于生产的 RAG System Prompt 模板。
const RAG_SYSTEM_PROMPT = `你是一个基于知识库的专业问答助手。请严格遵循以下规则:
1. **严格基于资料**:只使用"参考资料"部分的信息回答,不要使用训练记忆
2. **诚实拒答**:资料中没有相关信息时,回复"根据现有资料,我无法回答这个问题"
3. **标注来源**:引用资料时标注文档编号,如 [文档 1]、[文档 2]
4. **处理冲突**:多份资料存在矛盾时,如实指出并分别说明不同观点
5. **简洁清晰**:先给出结论,再展开细节
6. **保持中立**:对有争议的话题客观呈现各方观点,不偏向任何一方`;
这 6 条规则看似是非常普通的通用指令,但其实每一条均针对第 8 章中剖析的典型 RAG 失败模式进行了定向防御。
写 System Prompt 最忌讳的是指令含糊。同样是让模型基于资料回答,“请根据资料回答问题”和“请严格基于参考资料回答;资料中没有相关信息时,明确说明无法回答;不要使用训练记忆,不要编造”,效果完全不一样。后者把能做什么、不能做什么、遇到边界情况怎么处理,全都交代清楚了。模型不是确定的的程序,你没法保证它每次都做出正确的选择,但 明确的指令 能显著提高正确选择的概率,这其实就是 Prompt 工程最实在的价值。
注意:“明确”不等于“详细”,明确指的指令要具体且无歧义,并不是说提示词写得越长越好。更多关于如何写好提示词的内容,可以参考《提示词工程专栏》
一份完整的 RAG 输入通常由系统指令、对话历史、参考资料和当前问题四部分组成。它们的组装方式如下:
graph TD
A[System Prompt<br/>系统指令] --> E[最终消息序列]
B[对话历史<br/>可选] --> E
C[参考资料<br/>检索结果] --> E
D[用户问题<br/>当前查询] --> E
下面这个函数把四段内容组装成 LangChain 的消息数组,也是本章后续所有示例的基础。
import { Document } from "@langchain/core/documents";
import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";
interface Message {
role: "user" | "assistant";
content: string;
}
function formatDocuments(docs: Document[]): string {
if (docs.length === 0) return "(无相关资料)";
return docs
.map((doc, i) => {
const source = doc.metadata.source || "未知来源";
return `[文档 ${i + 1}] (${source})n${doc.pageContent}`;
})
.join("nn---nn");
}
function buildRAGPrompt(
query: string,
docs: Document[],
history: Message[] = [],
systemPrompt: string = RAG_SYSTEM_PROMPT
) {
// 第一段:系统提示词
const messages = [new SystemMessage(systemPrompt)];
// 第二段:历史对话(首轮问答时为空)
for (const msg of history) {
messages.push(
msg.role === "user"
? new HumanMessage(msg.content)
: new AIMessage(msg.content)
);
}
// 第三、四段:参考资料在前,当前问题在后
const userContent = `## 参考资料nn${formatDocuments(docs)}nn## 当前问题nn${query}`;
messages.push(new HumanMessage(userContent));
return messages;
}
const response = await llm.invoke(
buildRAGPrompt("如何配置数据库连接池?", retrievedDocs, history)
);
这段组装代码里有几个值得展开的设计细节:参考资料放在用户问题之前,是为了让模型在生成答案前先完整看到资料,而不是读到问题就直接开始作答;每个文档都带上编号和来源([文档 1] (docs/deploy-guide.md)),模型引用时才有锚点,后续的答案核验也能追溯到具体文件;## 和 --- 这样的分隔标记帮助模型区分不同语义区块,避免把相邻两篇文档的内容混为一谈。
检索结果可能为空的处理是一个容易被忽略的边界情况处理。第10章的阈值过滤已经告诉我们,当所有文档分数都低于阈值时应该触发拒答而不是硬塞噪声,formatDocuments 中 docs.length === 0 时返回"(无相关资料)"就是这种处理在 Prompt 侧的配合措施,即配合System Prompt 里的"诚实拒答"规则,模型此时会明确回复无法回答,而不是对着空上下文自由发挥。
到目前为止,我们默认检索结果都能完整放进 Prompt。但在实际项目中,你迟早会遇到上下文预算溢出的问题:尽管当前主流模型的上下文窗口已扩展至 128K 甚至百万级别,但面对超长文档分析或复杂的多轮 Agent 任务时,系统指令、历史对话、工具调用结果以及大量检索文档的叠加,依然会迅速逼近甚至超出可用窗口上限。此外,过长的上下文也会带来高昂的 Token 成本和推理延迟。当总 Token 消耗超出预算时,我们就需要引入上下文组装策略。
工程上主流的选择有三种。
graph TD
A{文档总 Token 超过可用窗口?} -->|否| B[Stuff:全部塞入]
A -->|是| C{允许并行调用?}
C -->|是| D[Map-Reduce:分批处理再汇总]
C -->|否| E[Refine:逐步精炼]
Stuff 是最简单直接的上下文组装策略。它的核心逻辑是将所有检索到的文档片段(Chunks)拼接成一个完整的字符串,连同系统提示词、对话历史和用户问题一起,一次性注入到 Prompt 中。前面提到的 buildRAGPrompt 函数正是采用了这种处理方式。
在工程实践中,Stuff 策略唯一需要做好的防线是容量检查。由于上下文窗口是输入与输出共享的预算,我们需要在调用模型前估算总 Token 数,并预留出足够的空间供模型生成回答。
async function stuffStrategy(
query: string,
docs: Document[],
history: Message[],
llm: ChatOpenAI
): Promise<string> {
// 估算当前所有输入内容的总 Token 数
const totalTokens = countTokens(
RAG_SYSTEM_PROMPT + query +
docs.map((d) => d.pageContent).join("nn") +
history.map((m) => m.content).join("n")
);
// 假设当前模型支持 128K 上下文,这里预留 30% 的余量给模型生成输出
// 128000 * 0.7 ≈ 89600,即输入部分最多占用 89600 tokens
const MAX_INPUT_TOKENS = 89600;
if (totalTokens > MAX_INPUT_TOKENS) {
throw new Error(`输入内容超出窗口容量限制(当前 ${totalTokens} tokens),请改用 Map-Reduce 或 Refine 策略`);
}
const response = await llm.invoke(buildRAGPrompt(query, docs, history));
return response.content as string;
}
Stuff 只需要一次 LLM 调用,延迟通常在 1-2 秒,信息没有任何中间损耗,模型还能同时看到所有文档之间的关联。代码中精确统计 Token 的 countTokens 我们会在第三节实现,这里先把注意力放在容量判断上。这些优势成立的前提是文档总 Token 不超过窗口的 70%,即留出 30% 余量一方面给输出留空间,另一方面也避免上下文过满导致长文本质量下降。
当检索到的文档数量较多,且它们之间相对独立时,Map-Reduce 是首选策略。它的思路借鉴自同名的分布式计算模型:先将文档分批,每批独立生成一个局部答案(Map),最后将所有局部答案汇总成最终答案(Reduce)。
graph TD
A[10 个文档] --> B[Map 阶段:并行处理]
B --> C1[文档 1-3得到局部答案 1]
B --> C2[文档 4-6得到局部答案 2]
B --> C3[文档 7-10得到局部答案 3]
C1 --> D[Reduce 阶段:汇总]
C2 --> D
C3 --> D
D --> E[最终答案]
Map 阶段的多次调用彼此没有依赖,可以用 Promise.all 并行执行,因此总延迟取决于最慢的一批,而不是所有批次之和:
function chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
async function mapReduceStrategy(
query: string,
docs: Document[],
llm: ChatOpenAI,
batchSize = 3
): Promise<string> {
// Map:每批文档独立作答
const localAnswers = await Promise.all(
chunkArray(docs, batchSize).map(async (batch) => {
const res = await llm.invoke(buildRAGPrompt(query, batch));
return res.content as string;
})
);
// Reduce:综合所有局部答案
const summariesText = localAnswers
.map((ans, i) => `[局部答案 ${i + 1}]n${ans}`)
.join("nn---nn");
const final = await llm.invoke(
`以下是从不同文档中提取的信息片段:nn${summariesText}nn请综合这些信息回答问题:${query}`
);
return final.content as string;
}
Map-Reduce 的代价是 Token 消耗明显增加,因为N 个批次意味着 N 次 Map 调用加 1 次 Reduce 调用。而且 Reduce 阶段处理的是“二手信息”,如果 Map 阶段生成的局部答案漏掉了某个细节,这个细节就会永久丢失。因此,它特别适合“从多篇文档中分别归纳、再综合结论”的场景(比如对比十几篇产品评测),而不适合需要精确定位某个参数的问答。LangChain 也提供了开箱即用的 createMapReduceDocumentsChain,原理与上述实现一致。
Refine 走的是另一条路。它不做并行,而是让模型像滚雪球一样逐篇处理文档。先用第一篇文档生成初始答案,之后每读一篇新文档,就带着已有答案和新文档再生成一次,直到所有文档处理完毕。
graph LR
A[文档 1] --> B[初始答案]
B --> C[文档 2] --> D[精炼答案 1]
D --> E[文档 3] --> F[精炼答案 2]
F --> G[...] --> H[最终答案]
async function refineStrategy(
query: string,
docs: Document[],
llm: ChatOpenAI
): Promise<string> {
let currentAnswer = "";
for (let i = 0; i < docs.length; i++) {
const prompt =
i === 0
? `请基于以下参考资料回答问题。nn资料:${docs[i].pageContent}nn问题:${query}`
: `已有答案:n${currentAnswer}nn新资料:${docs[i].pageContent}nn请基于新资料完善已有答案。问题:${query}`;
const res = await llm.invoke(prompt);
currentAnswer = res.content as string;
}
return currentAnswer;
}
由于每一步都建立在前一步的答案之上,信息是逐层累积的,Refine 的信息保真度在三种策略中最高,适合对精度要求苛刻的场景。当然,它的短板同样突出:N 个文档就是 N 次严格串行的调用,总延迟可能达到 5-10 秒;而且每一步都要把累积答案重新发送一遍,Token 消耗随文档数量线性增长。另外需要注意的是,经过多轮精炼后,早期文档的影响会被逐渐稀释,文档的排列顺序因此变得非常重要——这个问题我们在后续章节还会遇到。
| 策略 | 调用次数 | 延迟 | Token 消耗 | 信息丢失 | 适用场景 |
|---|---|---|---|---|---|
| Stuff | 1 次 | 1-2s | 低 | 无 | 文档少,总量 < 窗口 70% |
| Map-Reduce | N+1 次,可并行 | 3-5s | 高 | 中等(汇总可能遗漏细节) | 文档多且相对独立,关注延迟 |
| Refine | N 次,串行 | 5-10s | 中 | 低(逐步累积) | 文档多、精度要求高、延迟不敏感 |
在实际工程中可以把选型逻辑收敛成一个简单的判断函数。
function selectStrategy(
totalTokens: number,
windowSize: number,
latencyFirst: boolean
): "stuff" | "map-reduce" | "refine" {
if (totalTokens < windowSize * 0.7) return "stuff";
return latencyFirst ? "map-reduce" : "refine";
}
记住一个经验顺序就够用:先算总 Token,装得下用 Stuff;装不下且用户在等响应,用 Map-Reduce;装不下且这是离线分析、报告生成这类对延迟不敏感的任务,用 Refine。
在单轮问答里,窗口预算只需要在参考资料和输出之间分配,但多轮对话会引入对话历史这个消耗者,而且它会随着聊天的深入不断膨胀。
graph TD
A[总窗口: 128k tokens] --> B[预留输出: 4000 tokens]
A --> C[System Prompt: 约 1000 tokens]
A --> D[可支配预算]
D --> E[对话历史]
D --> F[检索结果]
D --> G[当前问题]
一个典型的超支场景是对话进行了 20 轮,历史消息累计 15,000 tokens;本轮检索到 10 个文档,又是 15,000 tokens;加上当前问题和系统指令,总计超过 30,000 tokens。虽然当前主流模型的上下文窗口已扩展至 128K 甚至更高,但在处理超长文档分析或复杂的 Agent 任务时,上下文依然会迅速逼近上限。更重要的是,过长的上下文不仅会带来高昂的 Token 计费成本,还会导致推理延迟呈平方级增加。历史和资料都是答案质量的来源,去掉谁都会带来问题,这正是我们需要一个预算分配器来做决策的原因。
精确计算 Token 数需要用模型对应的分词器。OpenAI 系模型可以用开源的 tiktoken,它的计算结果与 API 实际计费一致。
npm install tiktoken
import { getEncoding } from "tiktoken";
const encoder = getEncoding("cl100k_base"); // GPT-4o 使用的编码
function countTokens(text: string): number {
return encoder.encode(text).length;
}
下面的分配器采用"固定预留 + 数量上限"的策略:先扣掉输出和 System Prompt 的固定预算,再用最近轮数和文档数量两道闸口控制历史和资料的规模。
function allocateTokenBudget(
modelMaxTokens: number,
history: Message[],
query: string,
docs: Document[],
options: { reserveOutput?: number; maxRounds?: number; maxDocs?: number } = {}
) {
// 默认预留 4000 tokens 给输出,保留最近 10 轮对话,最多 5 篇文档
const { reserveOutput = 4000, maxRounds = 10, maxDocs = 5 } = options;
// 第一道闸口:历史只保留最近 N 轮(每轮含 user + assistant 两条)
let keptHistory = history.slice(-maxRounds * 2);
// 第二道闸口:文档只保留排序最靠前的 N 篇
const keptDocs = docs.slice(0, maxDocs);
const used =
countTokens(RAG_SYSTEM_PROMPT) +
countTokens(query) +
keptHistory.reduce((s, m) => s + countTokens(m.content), 0) +
keptDocs.reduce((s, d) => s + countTokens(d.pageContent), 0);
const remaining = modelMaxTokens - used - reserveOutput;
if (remaining < 0) {
console.warn(`预算仍然超支(超出 ${Math.abs(remaining)} tokens),需要进一步压缩历史或资料`);
}
return { history: keptHistory, docs: keptDocs, remaining };
}
分配时有一个优先级原则就是检索结果是本轮答案质量的直接来源,优先保证至少 3-5 篇高相关文档;历史负责的是对话连贯性,可以压缩;输出预算则必须从一开始就预留,不能等输入把窗口占满后再考虑。需要说明的是,"只保留最近 N 轮"是最简单也最常用的截断方式,它的代价是对话早期的关键信息可能被误伤,比如用户在第一轮交代过"我问的是 v2.1 版本",这条消息不该被简单丢弃。
历史摘要压缩解决早期信息丢失的一种思路。还有一种更复杂的做法是给每条历史消息打重要性分(结合时间远近、是否包含版本号或链接等关键信息、消息角色等因素加权),在预算内优先保留高分消息,这类方案适合超长会话的生产系统,入门阶段了解思路即可。对大多数应用来说,性价比更高的是历史摘要压缩:保留最近几轮原文,把更早的对话交给 LLM 浓缩成一段摘要。
async function summarizeHistory(
history: Message[],
llm: ChatOpenAI,
keepRecentRounds = 3
): Promise<Message[]> {
const cutoff = history.length - keepRecentRounds * 2;
if (cutoff <= 0) return history; // 历史还很短,无需压缩
const oldText = history
.slice(0, cutoff)
.map((m) => `${m.role === "user" ? "用户" : "助手"}: ${m.content}`)
.join("n");
const res = await llm.invoke(
`请将以下对话历史压缩为简短摘要,保留关键事实、约束和待办,不要保留寒暄:nn${oldText}`
);
// 摘要 + 最近 N 轮原文
return [
{ role: "assistant", content: `[对话摘要]n${res.content}` },
...history.slice(cutoff),
];
}
一次高效的压缩可以把数十条的历史缩减为1条,达到70%以上的压缩率,而参考资料中像"v2.1 版本"这类关键事实会作为摘要的一部分保留下来。触发时机也不必只按轮数判断,更好的工程实践是在每次组装 Prompt 前跑一遍预算分配器,只有历史 Token 超预算时才触发压缩。
在第8章中,我们探讨过“中间迷失(Lost in the Middle)”现象:在长上下文中,大模型对位于开头和结尾的信息利用率最高,而对中间位置的信息关注度显著下降。当时我们将其视为 RAG 架构的一种固有局限,而现在,我们需要在工程实践中给出具体的应对方案。
在处理长上下文时,最容易陷入的一个误区是将检索结果按相关度降序直接拼接进 Prompt。经过第10章的 Reranker 重排序后,文档确实已经按照相关性从高到低排列,顺理成章地拼入 Prompt 看似天经地义。然而,当文档数量较多时,这种线性排列会导致一个致命问题,就是那些包含关键补充信息的文档,往往会被挤压到模型注意力最弱的中段区域。检索排序本身并没有错,错在将“检索排序”与“Prompt 摆放顺序”当作同一件事,忽略了模型注意力分布的几何特性。
既然高注意力位置是首尾两端,摆放策略就很直接了,我们可以把最相关的文档放在开头,次相关的放在结尾,相关性较低的文档放中间,形状像一个三明治。
function sandwichArrange(
scoredDocs: Array<{ doc: Document; score: number }>
): Document[] {
const sorted = [...scoredDocs].sort((a, b) => b.score - a.score);
if (sorted.length <= 2) return sorted.map((d) => d.doc);
// 最高分放开头,次高分放结尾,其余依次填入中间
const [best, ...rest] = sorted;
const secondBest = rest.pop()!;
return [best.doc, ...rest.map((d) => d.doc), secondBest.doc];
}
注意这个排列只调整进入 Prompt 的顺序,不改变检索阶段的分数和选取结果,因此它是一个零检索成本的纯生成侧优化,和第10章的 Reranker、上下文压缩完全正交,可以叠加使用。论文实验和工程实践都表明,文档数量较多(5 篇以上)时,这种摆放对答案质量的提升最明显;文档只有两三篇时,中段本来就不存在,按原顺序拼接即可。
在工程落地阶段,建议通过 A/B 测试进行定量验证:针对同一测试集,分别采用降序排列与重排策略组装 Prompt,并重点评估模型对中段文档关键信息的召回与引用情况。测试通常会显示,降序排列易导致中段关键事实的遗漏,而重排策略能有效提升该区域信息的利用率。这种基于真实业务数据的验证,是评估该优化策略在当前数据分布下实际收益的最可靠依据。
设计一个简洁版(如"请基于参考资料回答问题")和一个详细版(参照本章的 6 条规则)System Prompt,构造 10 个测试问题,其中刻意加入 3 个知识库中没有答案的问题。分别用两个 Prompt 跑一遍,统计两组的幻觉率和拒答正确率。
验证标准:
安装 tiktoken,在《第7章:基础篇实战:文档问答机器人》的项目中加入预算分配逻辑:组装 Prompt 前统计各部分 Token,超预算时按"先压历史、再减文档"的顺序处理。
验证标准:
编写函数,把超过 6 轮的对话历史压缩为"摘要 + 最近 3 轮原文"。用一段包含早期关键约束(如"我用的是 v2.1 版本")的长对话测试压缩前后的回答。
验证标准:
选取 5 个需要综合多篇文档才能回答的问题,每个问题检索 7 篇以上文档,分别用降序排列和三明治排列组装 Prompt,人工按 1-5 分评估答案质量,重点观察模型对中段文档信息的引用。
验证标准:
? 延伸阅读