使用 Next.js 进行提示词工程:流式传输 RAG 与生产环境缓存
在过去的十八个月里,我一直在为从法律科技初创公司到企业知识库的各种客户构建Next.js应用中的AI功能。一个炫酷的RAG演示和一个能处理真实流量而不会亏钱的生产系统之间的差距是巨大的。本文涵盖了一些难得的经验教训——如何设计保持一致的提示词、在不破坏UI的情况下流式传输响应、通过智能缓存将成本降低60-80%,以及发布在周五凌晨2点不会崩溃的RAG管道。
目录

为什么RAG在2026年的Next.js中有意义
Next.js已成为AI驱动的网络应用的默认选择,这不仅仅是炒作。App Router、Server Actions、Route Handlers和React Server Components的组合为RAG管道提供了真正良好的架构。您可以将嵌入逻辑保持在服务器端,通过Route Handlers流式传输响应,并在多个层上积极缓存。
Vercel AI SDK(现在位于v4.x)已经成熟了很多。它原生处理流式传输、工具调用和结构化输出。但SDK只是基础设施——真正的挑战是周围的一切:提示词设计、检索质量、缓存策略和错误处理。
下面是Next.js中典型的生产RAG流程的样子:
- 用户提交查询
- 查询被嵌入(或命中嵌入缓存)
- 向量搜索检索相关块
- 块被排序和过滤
- 精心设计的提示词组装上下文
- LLM流式传输响应
- 响应被缓存以供类似的未来查询
每一步都有失败模式。让我们深入探讨每一步。
RAG的提示词工程基础
RAG的提示词工程与普通LLM交互的提示词工程从根本上是不同的。您不是仅仅在问模型一个问题——您是在给它一个特定的上下文窗口,并要求它从该上下文合成答案,同时在上下文与模型的训练数据冲突时忽略后者。
系统提示架构
我已经确定了一个三部分的系统提示结构,在不同的领域都能很好地工作:
const buildSystemPrompt = (config: RAGConfig) => `
You are ${config.assistantName}, a ${config.role} for ${config.company}.
## CONTEXT RULES
- Answer ONLY based on the provided context documents
- If the context doesn't contain enough information, say so explicitly
- Never fabricate citations or reference numbers
- When multiple context documents conflict, note the discrepancy

## RESPONSE FORMAT
- Use markdown formatting for readability
- Cite sources using [Source: document_id] notation
- Keep responses under ${config.maxResponseTokens} tokens unless the user asks for detail
- Use ${config.tone} tone
## DOMAIN RULES
${config.domainRules.join('\n')}
`;
关键见解:领域规则是您放置使RAG真正有用的东西的地方。对于法律客户,这可能是"始终注意法规适用的司法管辖区"。对于医学知识库,"永远不要提供剂量建议;始终指向医疗保健提供者"。
上下文窗口管理
在GPT-4o运行128k上下文和Claude 3.5运行200k的情况下,很容易就想把一切都放进去。别这样。更多的上下文并不意味着更好的答案——它通常意味着更差的答案。
我使用分层方法:
const assembleContext = async (
query: string,
retrievedChunks: Chunk[]
): Promise<string> => {
// 第一层:按余弦相似度得分最高的前3个块(总是包括)
const primary = retrievedChunks.slice(0, 3);
// 第二层:后续5个块,但仅当相似度 > 阈值时
const secondary = retrievedChunks
.slice(3, 8)
.filter(c => c.similarity > 0.78);
// 第三层:剩余相关文档的元数据丰富的摘要
const tertiary = retrievedChunks
.slice(8)
.filter(c => c.similarity > 0.72)
.map(c => c.summary); // 预计算摘要,不是完整文本
return formatContextTiers(primary, secondary, tertiary);
};
这通常导致3,000-8,000个令牌的上下文,而不是30,000+。响应质量上升,延迟下降,您的API账单缩小。
提示词版本控制
这是在博客文章中几乎没有人谈论但在生产中每个人都需要的东西。您的提示词会改变。您需要跟踪这些变化。
// prompts/v2.3.ts
export const RAG_PROMPT_V2_3 = {
version: '2.3',
createdAt: '2026-03-15',
changelog: 'Added conflict resolution instruction, reduced hallucination on legal queries by 23%',
system: `...`,
userTemplate: (query: string, context: string) => `...`,
};
我们将提示词版本存储在代码中,而不是数据库中。它们在PR中像任何其他代码更改一样被审查。当生产中出现问题时,您可以将其追踪回特定的提示词版本。
在Next.js中设置流式RAG
使用Vercel AI SDK的Route Handler
下面是一个生产就绪的流式RAG端点:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { retrieveContext } from '@/lib/rag/retriever';
import { buildPrompt } from '@/lib/rag/prompts';
import { checkCache, setCache } from '@/lib/rag/cache';
import { rateLimiter } from '@/lib/middleware/rate-limit';
export const runtime = 'nodejs'; // 不是边缘——您需要Node来支持大多数向量数据库
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages, sessionId } = await req.json();
const lastMessage = messages[messages.length - 1].content;
// 速率限制
const allowed = await rateLimiter.check(sessionId);
if (!allowed) {
return new Response('Rate limited', { status: 429 });
}
// 首先检查语义缓存
const cached = await checkCache(lastMessage);
if (cached) {
return new Response(cached.response, {
headers: { 'X-Cache': 'HIT', 'Content-Type': 'text/plain' },
});
}
// 检索上下文
const context = await retrieveContext(lastMessage, {
topK: 10,
minSimilarity: 0.72,
namespace: 'production',
});
// 构建提示词
const systemPrompt = buildPrompt(context);
// 流式传输响应
const result = streamText({
model: openai('gpt-4o'),
system: systemPrompt,
messages,
temperature: 0.3, // 事实性RAG的低温
maxTokens: 1500,
onFinish: async ({ text }) => {
// 缓存完成的响应
await setCache(lastMessage, text, context.chunks.map(c => c.id));
},
});
return result.toDataStreamResponse();
}
客户端流式UI
在前端,useChat hook很好地处理流式传输:
// components/ChatInterface.tsx
'use client';
import { useChat } from 'ai/react';
import { useRef, useEffect } from 'react';
export function ChatInterface() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: '/api/chat',
body: { sessionId: getSessionId() },
onError: (err) => {
// 不仅仅是console.log——向用户显示有用的东西
toast.error('Something went wrong. Try rephrasing your question.');
},
});
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m) => (
<div key={m.id} className={m.role === 'user' ? 'text-right' : ''}>
<div className="prose prose-sm max-w-none">
<Markdown>{m.content}</Markdown>
</div>
</div>
))}
{isLoading && <TypingIndicator />}
<div ref={scrollRef} />
</div>
<form onSubmit={handleSubmit} className="p-4 border-t">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask a question..."
className="w-full p-3 rounded-lg border"
disabled={isLoading}
/>
</form>
</div>
);
}
处理流式传输边界情况
生产中的流式传输意味着处理演示中从不发生的事情:
- 连接中流式断开:实现带指数退避的重试逻辑。AI SDK的
onError回调是您的好朋友。 - 令牌限制超出:监视令牌使用并在模型之前实现硬截断(它的截断很难看)。
- 检索缓慢:在向量数据库查询上设置超时。如果检索时间> 2秒,回退到更小的上下文或缓存的类似查询。
缓存层:真正省钱的地方
缓存是您可以对生产RAG系统进行的单一最有影响的优化。有三个值得实施的层。
第一层:嵌入缓存
每个查询都需要一个嵌入。在使用text-embedding-3-small时每1K令牌$0.00002,每个查询很便宜,但它会累积,更重要的是,它会增加延迟。
import { Redis } from '@upstash/redis';
import { createHash } from 'crypto';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_URL!, token: process.env.UPSTASH_REDIS_TOKEN! });
export async function getEmbedding(text: string): Promise<number[]> {
const hash = createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
// 检查缓存
const cached = await redis.get<number[]>(`emb:${hash}`);
if (cached) return cached;
// 生成嵌入
const embedding = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
const vector = embedding.data[0].embedding;
// 缓存7天
await redis.set(`emb:${hash}`, vector, { ex: 604800 });
return vector;
}
第二层:语义缓存
这是最重要的。如果有人问"你的退款政策是什么?",而另一个人问"我如何获得退款?",他们应该得到相同的缓存响应。
export async function checkSemanticCache(query: string): Promise<CacheResult | null> {
const embedding = await getEmbedding(query);
// 搜索缓存索引(与您的内容索引分开)
const results = await pinecone.index('cache').query({
vector: embedding,
topK: 1,
includeMetadata: true,
});
if (results.matches[0]?.score > 0.95) {
return {
response: results.matches[0].metadata.response as string,
originalQuery: results.matches[0].metadata.query as string,
cachedAt: results.matches[0].metadata.cachedAt as string,
};
}
return null;
}
0.95阈值很重要。太低,您会提供错误的答案。太高,您不会获得缓存命中。从0.95开始,根据您的域进行调整。
第三层:响应片段缓存
对于结构化响应(如产品规格或政策摘要),缓存单个片段:
| 缓存层 | 命中率(典型) | 延迟节省 | 成本节省 |
|---|---|---|---|
| 嵌入缓存 | 40-60% | 50-100ms每个查询 | ~$50/月在100K查询时 |
| 语义缓存 | 15-35% | 1-3秒每个查询 | ~$300-800/月在100K查询时 |
| 片段缓存 | 20-40% | 500ms-1秒每个查询 | ~$100-200/月在100K查询时 |
| 组合 | 60-80% | 平均1-3秒 | $500-1200/月在100K查询时 |
缓存失效
经典的难题。对于RAG,我使用双管齐下的方法:
- 基于TTL:所有缓存在24-72小时后过期,具体取决于您的源数据更改频率。
- 基于事件:当源文档更新时,使任何引用这些文档ID的缓存条目失效(这是我们在缓存元数据中存储块ID的原因)。
生产架构模式
完整堆栈
这是我们用于大多数生产RAG部署的架构:
用户 → Next.js App Router → Route Handler
↓
速率限制器 (Upstash)
↓
语义缓存检查 (Pinecone + Redis)
↓ (未命中)
嵌入生成 (OpenAI / 缓存)
↓
向量搜索 (Pinecone / Weaviate / pgvector)
↓
重新排序 (Cohere Rerank / 自定义)
↓
提示词组装
↓
LLM流式传输 (OpenAI / Anthropic)
↓
响应 → 缓存写入 → 用户
选择您的向量数据库
| 数据库 | 最适合 | 定价(2026年) | Next.js集成 |
|---|---|---|---|
| Pinecone | 托管、零运维 | 免费层 → $70/月入门版 | 优秀(REST API) |
| Weaviate Cloud | 混合搜索(向量 + 关键词) | $25/月入门版 | 良好(JS客户端) |
| pgvector (Supabase) | 已经使用Postgres | 免费层 → $25/月 | 很好(Supabase SDK) |
| Qdrant Cloud | 高性能、过滤 | 免费层 → $30/月 | 良好(JS客户端) |
| Turbopuffer | 成本优化、S3备份 | ~$0.04/GB存储 | 不错(REST API) |
对于大多数Next.js项目,如果您已经在该生态系统中,我会从Supabase上的pgvector开始,或者如果您想要零运维开销,则使用Pinecone。我们已在多个项目中使用过所有这些,其中CMS内容提供RAG管道。
错误处理和回退
生产RAG需要优雅降级:
export async function handleRAGQuery(query: string) {
try {
// 主路径:完整RAG
return await fullRAGPipeline(query);
} catch (error) {
if (error instanceof VectorDBError) {
// 回退1:使用缓存的相似查询
const fallback = await getFallbackFromCache(query);
if (fallback) return { ...fallback, degraded: true };
}
if (error instanceof LLMError) {
// 回退2:尝试不同的模型
return await fullRAGPipeline(query, { model: 'claude-3-5-sonnet' });
}
// 回退3:返回相关的原始块而无需LLM合成
const chunks = await retrieveContext(query);
return {
response: 'I couldn\'t generate a full answer, but here are relevant excerpts:',
chunks: chunks.slice(0, 3),
degraded: true,
};
}
}
监控、可观测性和调试
您无法改进无法衡量的内容。下面是要跟踪的内容:
关键指标
- 检索质量:前K个块是否真的相关?记录相似度分数并每周抽查。
- 响应延迟(p50/p95/p99):流式TTFB(首字节时间)和总完成时间。
- 缓存命中率:按层。如果您的语义缓存命中率低于10%,您的阈值可能太高了。
- 每个查询的令牌使用:平均和p99。观察提示词注入尝试,这会增加上下文。
- 用户反馈信号:赞成/反对、复制事件、后续问题(表示第一个答案不够好)。
工具
对于LLM可观测性,我有很好的结果:
- Langfuse:开源、自托管、优秀的跟踪可视化。免费层很慷慨。
- Helicone:基于代理的日志记录、很好的成本跟踪。一行集成。
- Braintrust:很好的评估和提示词迭代。eval框架很扎实。
// 示例:Langfuse与Vercel AI SDK的集成
import { Langfuse } from 'langfuse';
const langfuse = new Langfuse();
export async function POST(req: Request) {
const trace = langfuse.trace({ name: 'rag-query' });
const retrievalSpan = trace.span({ name: 'retrieval' });
const context = await retrieveContext(query);
retrievalSpan.end({ output: { chunkCount: context.length, topScore: context[0]?.similarity } });
const generationSpan = trace.generation({
name: 'llm-response',
model: 'gpt-4o',
input: messages,
});
// ... 流式传输响应
generationSpan.end({ output: completedText });
await langfuse.flushAsync();
}
性能基准和成本分析
来自生产部署的真实数字(法律知识库,~50K文档,~2M块):
| 指标 | 无优化 | 完全优化 |
|---|---|---|
| 中位延迟(TTFB) | 2.1秒 | 340毫秒 |
| P95延迟(TTFB) | 4.8秒 | 1.2秒 |
| 月度LLM成本(50K查询) | $2,400 | $680 |
| 月度嵌入成本 | $180 | $45 |
| 月度向量数据库成本 | $70 | $70(不变) |
| 缓存命中率 | 0% | 67% |
| 用户满意度(赞成%) | 71% | 89% |
满意度的改进主要来自更好的提示词和重新排序,而不是缓存。但成本和延迟改进几乎完全来自缓存。
每个查询的成本分解
在50K查询/月,使用GPT-4o:
- 未缓存查询:~$0.048(嵌入 + 检索 + 生成)
- 语义缓存命中:~$0.0004(嵌入查找 + 缓存读取)
- 嵌入缓存命中 + 语义未命中:~$0.035(跳过嵌入,仍生成)
如果您正在构建类似的东西并需要架构方面的帮助,我们定期进行这类工作——查看我们的定价页面或联系我们。
常见问题
2026年生产RAG的最佳LLM是什么? 对于大多数用例,GPT-4o在质量、速度和成本方面达到了最佳平衡。Claude 3.5 Sonnet在您需要更长的上下文处理或更细致的推理时很优秀。对于成本敏感的应用程序和更简单的查询,GPT-4o-mini或Claude 3.5 Haiku的表现出奇地好——当检索质量好时,我们已经看到它们在直接问答上与GPT-4o相匹配。模型不如检索质量和提示词工程重要。
我应该为RAG Route Handlers使用Edge Runtime还是Node.js Runtime? 几乎总是Node.js。Edge Runtime有连接限制,这使得与大多数向量数据库和ORM合作很痛苦。Edge的冷启动优势对于流式端点可以忽略不计,因为用户已经在等待检索 + 生成。为简单代理端点或非RAG路由使用Edge。
我如何防止RAG响应中的幻觉? 实际有效的三种策略:(1) 明确指示模型在上下文不足时说"我没有足够的信息"——并在提示词中包含示例。(2) 对事实查询使用低温度(0.1-0.3)。(3) 实施引用要求——当模型必须引用特定块时,它很难幻觉。事后验证(检查声称是否出现在源块中)添加另一个安全层。
我应该为RAG上下文检索多少个块? 检索的比使用的多。我通常检索15-20个块,重新排序它们,然后使用前3-5个作为主要上下文,并包括后续3-5个的摘要。将20个完整块转储到上下文窗口会降低质量。即使相关信息存在,模型也会被无关信息混淆。质量永远胜过数量。
pgvector对生产来说足够好,还是我需要专用的向量数据库? 对于最多~1M向量,Supabase或Neon上带HNSW索引的pgvector绝对生产就绪。查询性能优秀,您可以获得保持在现有Postgres生态系统中的好处。超过1M向量或如果您需要向量搜索的高级过滤,Pinecone或Qdrant等专用选项开始脱颖而出。我们已在多个项目中在生产中运行pgvector,没有问题。
我如何在React中使用加载状态处理流式响应?
Vercel AI SDK的useChat钩子给您一个isLoading布尔值,但它比这更微妙。钩子经历状态转换:空闲 → 等待(没有令牌) → 流式传输(令牌到达) → 空闲。为了最佳UX,在等待阶段显示打字指示符,并在流式传输期间逐步呈现markdown。使用能够优雅处理不完整markdown的markdown渲染器——react-markdown可以工作,但可能会闪烁;考虑在呈现前缓冲一些令牌。
在RAG中处理多轮对话的最佳方式是什么? 不要在每条消息上重新检索。使用对话历史来确定新消息是跟进(需要相同上下文)还是主题切换(需要新检索)。一个简单的分类器——甚至是检查代词和引用的基于正则表达式的分类器——可以为您节省大量不必要的向量搜索。当您确实重新检索时,在检索查询中包含对话摘要,而不仅仅是最后的消息。
我应该多久为RAG重新索引一次我的文档? 取决于您的源数据更改的频率。对于静态知识库,每周进行一次完整重新索引就可以了。对于动态内容(CMS驱动的站点、每天更新的文档),设置webhook触发的增量索引。关键是有一个管道,可以更新单个块而无需重新索引所有内容。我们将其构建到我们的集成中——当内容在Sanity或Contentful中更新时,受影响的块会自动重新嵌入并更新。