📖 本章学习目标
- 设计面向 RAG 场景的高质量 System Prompt,有效约束模型行为
- 根据文档规模和上下文窗口,在 Stuff、Map-Reduce、Refine 三种组装策略间做出选择
- 实现多轮对话中的 Token 预算分配与历史摘要压缩
- 运用"三明治排列"缓解 Lost in the Middle 效应,解释"检索到了却没用上"的 Prompt 层面根因
在《第9章:高级检索策略》和《第10章:检索后处理与重排序》中,我们在RAG检索管道中使用 混合检索、Multi-Query 、MMR 和 Reranker、阈值过滤和上下文压缩 分别解决了解决了"召不回"和"召不准"的问题。走到这一步,输入大模型的参考资料在质量上已经相当可靠了。
但在实际项目中,很多人在这里还会遇到一个问题,就是检索日志显示正确的文档已经被找回来了,模型生成的答案却还是错的,它要么无视资料继续用训练记忆里的旧知识回答,要么被资料中的某段噪声带偏,要么在多轮对话进行到一半时又开始丢失上下文了。这时候就需要 Prompt 工程来解决这个问题。
Prompt 是检索管道与生成管道之间的衔接层,它要做的事情是把系统指令、检索结果、对话历史和当前问题组织成 LLM 能最优利用的输入形式。检索决定了能拿到什么资料 ,而 Prompt 决定了模型愿不愿意、能不能用好这些资料。本章我们就来解决用好资料这件事。
一、RAG 场景的 Prompt 设计原则
1. 为什么需要专门的 RAG Prompt
先看一个真实开发中很常见的场景。当你在文档问答机器人里提问:"useEffect 的依赖数组为空时,组件在什么时候执行?"
假设向量检索非常给力,准确命中了 React 18 的官方文档片段,但模型最终给出的回答,很可能引用的依然是 React 16 时代的旧行为描述。这不是模型没看到你给的资料,而是它的注意力机制并不会自动给参考资料赋予更高的权重。当检索文档与模型训练记忆中的知识发生冲突时,模型本质上是在做下一个 token 的概率预测。如果训练语料中旧版知识的统计信号足够强(比如 React 16 时期的教程、博客、StackOverflow 回答在预训练语料中大量存在),它就会沿着最熟悉的路径生成,把参考资料当成可有可无的背景信息。这个问题我们在《第8章:RAG 的局限性及应对策略》已经详细讨论过,即当 检索资料与模型的训练记忆不一致时,模型的选择是不确定的。
那问题出在哪里?看一下这种场景可能提供给LLM的Prompt版本。
markdown
你是一个React开发助手,请回答用户的问题。
参考资料:
[文档 1] useEffect 的依赖数组为空时,组件只在挂载时执行一次...
用户问题:useEffect 的依赖数组为空时会怎样?
在这段 Prompt 里,虽然我们把检索到的文档拼进了输入,但压根没有告诉模型必须以参考资料为准。模型收到的只是一个普通的问答请求,它自然会调用自己最熟悉的训练记忆路径。如果没有明确的指令约束,模型依然可能把资料当成可有可无的背景信息。
RAG 场景中,我们可以换个写法:
markdown
你是一个基于知识库的专业问答助手。请严格遵循以下规则:
1. 只使用提供的参考资料回答问题
2. 如果资料中没有相关信息,明确说明,不要编造
3. 引用资料时标注来源,如 [文档 1]
4. 当多份资料存在矛盾时,如实指出不同观点
参考资料:
[文档 1] XXX
[文档 2] XXX
用户问题:XXX
对比这两种写法,主要区别在于后者明确告诉了LLM三个约束:什么是允许的(基于资料回答)、什么是不允许的(动用训练记忆、编造信息)、边界情况怎么处理(资料不足时拒答)。此外它还要求标注来源、处理资料间的冲突,这让最终答案具备可验证性。RAG Prompt 本质上就是一份给模型的工作守则,约束越明确,模型行为越稳定,这一点在第8章的各种失败案例中已经被反复验证。
2. 设计一个高质量的 System Prompt
在 Chat 模型的消息体系中,System Prompt 承担着定义模型身份与全局运行规范的角色,它在整个会话期间持续生效,决定了LLM如何按要求进行响应。下面是一个可以直接用于生产的 RAG System Prompt 模板。
typescript
const RAG_SYSTEM_PROMPT = `你是一个基于知识库的专业问答助手。请严格遵循以下规则:
1. **严格基于资料**:只使用"参考资料"部分的信息回答,不要使用训练记忆
2. **诚实拒答**:资料中没有相关信息时,回复"根据现有资料,我无法回答这个问题"
3. **标注来源**:引用资料时标注文档编号,如 [文档 1]、[文档 2]
4. **处理冲突**:多份资料存在矛盾时,如实指出并分别说明不同观点
5. **简洁清晰**:先给出结论,再展开细节
6. **保持中立**:对有争议的话题客观呈现各方观点,不偏向任何一方`;
这 6 条规则看似是非常普通的通用指令,但其实每一条均针对第 8 章中剖析的典型 RAG 失败模式进行了定向防御。
- 规则 1 和 2 针对的是幻觉的两种主要形式:"忽略检索结果"和"过度推断",明确切断模型回退到训练记忆的路径;
- 规则 3 对应"来源标注错误",强制模型把每个结论挂到具体文档编号上,保证可溯源性;
- 规则 4 处理知识冲突,要求模型在资料相互矛盾时呈现分歧而不是选一个;
- 规则 5 和 6 则约束输出的可读性和立场。
写 System Prompt 最忌讳的是指令含糊。同样是让模型基于资料回答,"请根据资料回答问题"和"请严格基于参考资料回答;资料中没有相关信息时,明确说明无法回答;不要使用训练记忆,不要编造",效果完全不一样。后者把能做什么、不能做什么、遇到边界情况怎么处理,全都交代清楚了。模型不是确定的的程序,你没法保证它每次都做出正确的选择,但 明确的指令 能显著提高正确选择的概率,这其实就是 Prompt 工程最实在的价值。
注意:"明确"不等于"详细",明确指的指令要具体且无歧义,并不是说提示词写得越长越好。更多关于如何写好提示词的内容,可以参考《提示词工程专栏》
3. 四段式 Prompt 结构
一份完整的 RAG 输入通常由系统指令、对话历史、参考资料和当前问题四部分组成。它们的组装方式如下:
下面这个函数把四段内容组装成 LangChain 的消息数组,也是本章后续所有示例的基础。
typescript
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("\n\n---\n\n");
}
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 = `## 参考资料\n\n${formatDocuments(docs)}\n\n## 当前问题\n\n${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 里的"诚实拒答"规则,模型此时会明确回复无法回答,而不是对着空上下文自由发挥。
二、三种上下文组装策略
1. 当文档塞不下的时候
到目前为止,我们默认检索结果都能完整放进 Prompt。但在实际项目中,你迟早会遇到上下文预算溢出的问题:尽管当前主流模型的上下文窗口已扩展至 128K 甚至百万级别,但面对超长文档分析或复杂的多轮 Agent 任务时,系统指令、历史对话、工具调用结果以及大量检索文档的叠加,依然会迅速逼近甚至超出可用窗口上限。此外,过长的上下文也会带来高昂的 Token 成本和推理延迟。当总 Token 消耗超出预算时,我们就需要引入上下文组装策略。
工程上主流的选择有三种。
2. Stuff:全量注入
Stuff 是最简单直接的上下文组装策略。它的核心逻辑是将所有检索到的文档片段(Chunks)拼接成一个完整的字符串,连同系统提示词、对话历史和用户问题一起,一次性注入到 Prompt 中。前面提到的 buildRAGPrompt 函数正是采用了这种处理方式。
在工程实践中,Stuff 策略唯一需要做好的防线是容量检查。由于上下文窗口是输入与输出共享的预算,我们需要在调用模型前估算总 Token 数,并预留出足够的空间供模型生成回答。
typescript
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("\n\n") +
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% 余量一方面给输出留空间,另一方面也避免上下文过满导致长文本质量下降。
3. Map-Reduce:分而治之
当检索到的文档数量较多,且它们之间相对独立时,Map-Reduce 是首选策略。它的思路借鉴自同名的分布式计算模型:先将文档分批,每批独立生成一个局部答案(Map),最后将所有局部答案汇总成最终答案(Reduce)。
Map 阶段的多次调用彼此没有依赖,可以用 Promise.all 并行执行,因此总延迟取决于最慢的一批,而不是所有批次之和:
typescript
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("\n\n---\n\n");
const final = await llm.invoke(
`以下是从不同文档中提取的信息片段:\n\n${summariesText}\n\n请综合这些信息回答问题:${query}`
);
return final.content as string;
}
Map-Reduce 的代价是 Token 消耗明显增加,因为N 个批次意味着 N 次 Map 调用加 1 次 Reduce 调用。而且 Reduce 阶段处理的是"二手信息",如果 Map 阶段生成的局部答案漏掉了某个细节,这个细节就会永久丢失。因此,它特别适合"从多篇文档中分别归纳、再综合结论"的场景(比如对比十几篇产品评测),而不适合需要精确定位某个参数的问答。LangChain 也提供了开箱即用的 createMapReduceDocumentsChain,原理与上述实现一致。
4. Refine:逐篇精炼
Refine 走的是另一条路。它不做并行,而是让模型像滚雪球一样逐篇处理文档。先用第一篇文档生成初始答案,之后每读一篇新文档,就带着已有答案和新文档再生成一次,直到所有文档处理完毕。
typescript
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
? `请基于以下参考资料回答问题。\n\n资料:${docs[i].pageContent}\n\n问题:${query}`
: `已有答案:\n${currentAnswer}\n\n新资料:${docs[i].pageContent}\n\n请基于新资料完善已有答案。问题:${query}`;
const res = await llm.invoke(prompt);
currentAnswer = res.content as string;
}
return currentAnswer;
}
由于每一步都建立在前一步的答案之上,信息是逐层累积的,Refine 的信息保真度在三种策略中最高,适合对精度要求苛刻的场景。当然,它的短板同样突出:N 个文档就是 N 次严格串行的调用,总延迟可能达到 5-10 秒;而且每一步都要把累积答案重新发送一遍,Token 消耗随文档数量线性增长。另外需要注意的是,经过多轮精炼后,早期文档的影响会被逐渐稀释,文档的排列顺序因此变得非常重要------这个问题我们在后续章节还会遇到。
5. 三种策略对比与选型
| 策略 | 调用次数 | 延迟 | Token 消耗 | 信息丢失 | 适用场景 |
|---|---|---|---|---|---|
| Stuff | 1 次 | 1-2s | 低 | 无 | 文档少,总量 < 窗口 70% |
| Map-Reduce | N+1 次,可并行 | 3-5s | 高 | 中等(汇总可能遗漏细节) | 文档多且相对独立,关注延迟 |
| Refine | N 次,串行 | 5-10s | 中 | 低(逐步累积) | 文档多、精度要求高、延迟不敏感 |
在实际工程中可以把选型逻辑收敛成一个简单的判断函数。
typescript
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。
三、多轮对话的 Token 预算
1. 一笔总会超支的账
在单轮问答里,窗口预算只需要在参考资料和输出之间分配,但多轮对话会引入对话历史这个消耗者,而且它会随着聊天的深入不断膨胀。
一个典型的超支场景是对话进行了 20 轮,历史消息累计 15,000 tokens;本轮检索到 10 个文档,又是 15,000 tokens;加上当前问题和系统指令,总计超过 30,000 tokens。虽然当前主流模型的上下文窗口已扩展至 128K 甚至更高,但在处理超长文档分析或复杂的 Agent 任务时,上下文依然会迅速逼近上限。更重要的是,过长的上下文不仅会带来高昂的 Token 计费成本,还会导致推理延迟呈平方级增加。历史和资料都是答案质量的来源,去掉谁都会带来问题,这正是我们需要一个预算分配器来做决策的原因。
2. Token 预算分配器
精确计算 Token 数需要用模型对应的分词器。OpenAI 系模型可以用开源的 tiktoken,它的计算结果与 API 实际计费一致。
bash
npm install tiktoken
typescript
import { getEncoding } from "tiktoken";
const encoder = getEncoding("cl100k_base"); // GPT-4o 使用的编码
function countTokens(text: string): number {
return encoder.encode(text).length;
}
下面的分配器采用"固定预留 + 数量上限"的策略:先扣掉输出和 System Prompt 的固定预算,再用最近轮数和文档数量两道闸口控制历史和资料的规模。
typescript
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 版本",这条消息不该被简单丢弃。
3. 历史摘要压缩
历史摘要压缩解决早期信息丢失的一种思路。还有一种更复杂的做法是给每条历史消息打重要性分(结合时间远近、是否包含版本号或链接等关键信息、消息角色等因素加权),在预算内优先保留高分消息,这类方案适合超长会话的生产系统,入门阶段了解思路即可。对大多数应用来说,性价比更高的是历史摘要压缩:保留最近几轮原文,把更早的对话交给 LLM 浓缩成一段摘要。
typescript
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(
`请将以下对话历史压缩为简短摘要,保留关键事实、约束和待办,不要保留寒暄:\n\n${oldText}`
);
// 摘要 + 最近 N 轮原文
return [
{ role: "assistant", content: `[对话摘要]\n${res.content}` },
...history.slice(cutoff),
];
}
一次高效的压缩可以把数十条的历史缩减为1条,达到70%以上的压缩率,而参考资料中像"v2.1 版本"这类关键事实会作为摘要的一部分保留下来。触发时机也不必只按轮数判断,更好的工程实践是在每次组装 Prompt 前跑一遍预算分配器,只有历史 Token 超预算时才触发压缩。
四、检索结果的放置顺序
1. Lost in the Middle 的工程启示
在第8章中,我们探讨过"中间迷失(Lost in the Middle)"现象:在长上下文中,大模型对位于开头和结尾的信息利用率最高,而对中间位置的信息关注度显著下降。当时我们将其视为 RAG 架构的一种固有局限,而现在,我们需要在工程实践中给出具体的应对方案。
在处理长上下文时,最容易陷入的一个误区是将检索结果按相关度降序直接拼接进 Prompt。经过第10章的 Reranker 重排序后,文档确实已经按照相关性从高到低排列,顺理成章地拼入 Prompt 看似天经地义。然而,当文档数量较多时,这种线性排列会导致一个致命问题,就是那些包含关键补充信息的文档,往往会被挤压到模型注意力最弱的中段区域。检索排序本身并没有错,错在将"检索排序"与"Prompt 摆放顺序"当作同一件事,忽略了模型注意力分布的几何特性。
2. 三明治排列
既然高注意力位置是首尾两端,摆放策略就很直接了,我们可以把最相关的文档放在开头,次相关的放在结尾,相关性较低的文档放中间,形状像一个三明治。
typescript
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,并重点评估模型对中段文档关键信息的召回与引用情况。测试通常会显示,降序排列易导致中段关键事实的遗漏,而重排策略能有效提升该区域信息的利用率。这种基于真实业务数据的验证,是评估该优化策略在当前数据分布下实际收益的最可靠依据。
FAQ
Q1:System Prompt 和 User Prompt 应该怎么分工?
System Prompt 放整轮会话中不变的内容,比如角色设定、行为规则、输出格式。而User Prompt 放每轮都在变化的内容,比如当前检索到的参考资料和用户的最新问题。参考资料必须放在 User 侧,因为每轮检索结果都不同;而规则放 System 侧可以被模型更稳定地遵守,多数模型对 System 消息赋予的权重也更高。
Q2:什么时候该从 Stuff 升级到另外两种策略?
当"资料 + 历史 + 问题"的总 Token 超过窗口的 70% 时就该考虑切换。延迟敏感的在线问答优先 Map-Reduce,靠并行把总延迟压下来;离线报告、深度分析这类场景用 Refine 换精度。如果预算只是轻微超标,先别急着换策略------第10章的上下文压缩和本章的历史摘要往往能把 Token 压回 Stuff 的容量内,而一次调用永远比多次调用便宜。
Q3:对话历史只保留最后几轮就够了吗?
简单截断能用,但会丢失早期的关键约束。更好的做法是"摘要 + 近期原文"的组合(见本章第三节):最近 3 轮保留原文保证细节连贯,更早的历史压缩成一段摘要保留关键事实。如果会话非常长,还可以叠加重要性评分来挑选消息。
Q4:如何平衡 Token 预算和答案质量?
记住三条经验:检索结果是答案质量的核心来源,至少保证 3-5 篇高相关文档;历史可以大胆压缩,摘要加近期原文通常足够;输出预算从一开始就固定预留,通常 1000 tokens 能覆盖大多数问答。上线后记录每次请求各部分的实际 Token 消耗,建立基线再微调,比凭感觉设参数可靠得多。
Q5:现在主流模型的上下文窗口有多大?窗口越大越好吗?
当前主流大模型的上下文窗口已普遍迈入百万级(1M Tokens)梯队。例如,GPT-5 系列、Claude Opus 4.7、Kimi K3 以及 DeepSeek V4 等旗舰模型均支持 100 万 Token 的上下文;而 Google Gemini 3.5 Pro 更是突破了 200 万 Token。开源模型同样表现强劲,如 Meta Llama 4 Maverick 也支持 100 万 Token。
然而,标称窗口越大并不意味着实际效果越好。在工程实践中,盲目追求大窗口会带来两个显著问题:
- 注意力衰减(Lost in the Middle):随着上下文长度增加,模型对长文本中间部分信息的注意力会快速下降,导致关键细节被忽略,实际有效召回率往往低于标称值。
- 成本与延迟指数级增长:受限于 Transformer 架构的自注意力机制,上下文翻倍会导致计算量和显存消耗呈平方级增长,使得推理速度变慢、Token 计费成本大幅上升。
因此,工程上的最佳实践并非将未经筛选的内容一股脑塞入大窗口,而是坚持"分层管理":先通过检索(如 RAG)和上下文压缩技术对输入进行"提纯",确保信息的精准度与高密度;随后,再利用大窗口模型去处理真正需要全局视野的超长文档或复杂推理任务,从而在精度、成本与延迟之间取得最优平衡。
练习
练习 1:对比两种 System Prompt 的约束力
设计一个简洁版(如"请基于参考资料回答问题")和一个详细版(参照本章的 6 条规则)System Prompt,构造 10 个测试问题,其中刻意加入 3 个知识库中没有答案的问题。分别用两个 Prompt 跑一遍,统计两组的幻觉率和拒答正确率。
验证标准:
- 详细版对无答案问题的拒答正确率明显更高
- 详细版答案中带来源标注的比例更高
- 记录两组结果并分析哪些规则起了关键作用
练习 2:实现 Token 预算分配器
安装 tiktoken,在《第7章:基础篇实战:文档问答机器人》的项目中加入预算分配逻辑:组装 Prompt 前统计各部分 Token,超预算时按"先压历史、再减文档"的顺序处理。
验证标准:
- 中英文混合文本都能正确计数
- 超预算场景下不再出现 API 报 context length 错误
- 打印每次请求的 Token 分配明细
练习 3:实现历史摘要压缩
编写函数,把超过 6 轮的对话历史压缩为"摘要 + 最近 3 轮原文"。用一段包含早期关键约束(如"我用的是 v2.1 版本")的长对话测试压缩前后的回答。
验证标准:
- 压缩率达到 50% 以上
- 压缩后的回答仍然记得早期约束
- 多轮对话的上下文连贯性没有明显断裂
练习 4:验证三明治排列的效果
选取 5 个需要综合多篇文档才能回答的问题,每个问题检索 7 篇以上文档,分别用降序排列和三明治排列组装 Prompt,人工按 1-5 分评估答案质量,重点观察模型对中段文档信息的引用。
验证标准:
- 两种排列的答案存在可观察的差异
- 三明治排列在多文档问题上的平均得分更高
- 总结出适合你数据集的文档数量临界点
📚 延伸阅读
- OpenAI Prompt Engineering Guide --- OpenAI 官方 Prompt 工程指南,包含大量最佳实践和正反例
- Anthropic Prompt Engineering Guide --- Anthropic 的 Prompt 工程文档,对 System Prompt 和长上下文设计有专门建议
- Tokenization with tiktoken --- OpenAI 开源的分词器库,可精确计算 Token 数量和费用
- Lost in the Middle 论文 --- Liu et al.,用实验系统展示了 LLM 在长上下文中对不同位置信息的利用差异
- LangChain Document Chains --- LangChain 的文档组合链文档,包含 Stuff、Map-Reduce、Refine 的官方实现