LLM Context Engineering:上下文工程化实践
前言
用过大模型的人都知道,同样的模型,不同的 prompt,效果天差地别。但很多人没意识到的是,上下文的管理本身就是一门工程。
本文讨论的是:如何在真实项目中系统性地管理大模型的上下文,控制 Token 成本,提高输出质量。这不是 prompt 技巧,而是工程实践。
Token 的成本账
以 MiniMax M2.7 为例,思考一个实际场景:
你的应用每次请求向大模型发送用户最近 20 条对话历史(平均每条 100 Token)+ 知识库检索结果(2000 Token)+ 系统提示(500 Token)+ 用户问题(200 Token)= 4900 Token。
如果你的应用每天处理 10000 次请求,每天消耗的 Token 量是 4900 × 10000 = 4900 万 Token。
单 Token 成本哪怕只有 0.0001 元,日成本就是 4900 元,月成本接近 15 万。上下文膨胀不加控制,Token 成本会以你想象不到的速度侵蚀利润。
上下文分层架构
好的上下文工程,第一步是分层。不是把所有信息一股脑塞进 context window,而是分层管理,按需注入。
┌─────────────────────────────────┐
│ System Prompt(500 Token) │ ← 固定不变,全局指令
├─────────────────────────────────┤
│ Domain Knowledge(2000 Token) │ ← 知识库切片,按需检索
├─────────────────────────────────┤
│ Conversation History(variable)│ ← 动态截断,保留最近 N 条
├─────────────────────────────────┤
│ Task Context(variable) │ ← 本次任务特定信息
├─────────────────────────────────┤
│ User Query(~200 Token) │ ← 用户当前输入
└─────────────────────────────────┘
System Prompt 的最佳实践
System Prompt 决定模型行为上限,但很多人写得很随意。几个关键原则:
角色要具体,不要泛泛。差的做法是"你是一个 AI 助手,请帮助用户"。好的做法是具体描述角色的专业领域、擅长技术、回答风格。
规则要靠前,越重要的规则位置越靠前。大模型对 prompt 开头和结尾的信息记忆最强,中间的容易被"遗忘"。
用 XML 标签分隔不同部分:
<role>你是 React 性能专家</role>
<constraints>
- 不推荐用 useMemo 的场景要明确指出
- 每次回答必须包含可运行的代码示例
</constraints>
<output-format>JSON: { "verdict": "...", "reason": "...", "code": "..." }</output-format>
对话历史的智能管理
对话历史是上下文膨胀的最大来源。聊天应用每轮对话,上下文都在增长,不加控制很快就会触及 context window 上限。
策略一:固定窗口截断
最简单,保留最近 N 条消息:
function getTruncatedHistory(
messages: Message[],
maxTokens: number = 4000
): Message[] {
const reversed = [...messages].reverse();
let tokenCount = 0;
const result: Message[] = [];
for (const msg of reversed) {
const msgTokens = estimateTokens(msg.content);
if (tokenCount + msgTokens > maxTokens) break;
result.unshift(msg);
tokenCount += msgTokens;
}
return result;
}
优点:实现简单,可预测。缺点:可能截断关键历史信息。
策略二:Summarization(摘要压缩)
当对话超过阈值时,用模型自身生成摘要,将详细历史替换为摘要:
async function compressHistory(
messages: Message[],
model: ModelClient
): Promise<Message[]> {
if (messages.length < 10) return messages;
const recentHistory = messages.slice(0, -5);
const olderMessages = messages.slice(0, -5);
const summary = await model.complete(
`请简要总结以下对话的核心内容,保留关键信息,控制在 200 字以内:\n
${olderMessages.map(m => `${m.role}: ${m.content}`).join('\n')}`
);
return [
{ role: "system", content: `【对话摘要】${summary}` },
...recentHistory
];
}
策略三:Semantic Chunking(语义分块)
不是按时间截断,而是按语义相关度选择历史消息。向量数据库存储历史,检索时只召回与当前问题语义相关的信息:
async function retrieveRelevantHistory(
currentQuery: string,
history: Message[],
topK: number = 5
): Promise<Message[]> {
const queryEmbedding = await embed(currentQuery);
const similarities = history.map(msg => ({
msg,
score: cosineSimilarity(queryEmbedding, msg.embedding)
}));
return similarities
.filter(s => s.score > 0.7)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(s => s.msg);
}
知识库检索的工程实践
RAG(Retrieval Augmented Generation)是当前最主流的知识库增强方案,但实操中有很多坑。
Chunk Size 不是越大越好
常见误区:以为 chunk 越大,上下文信息越完整,效果越好。实际上:
- 过大的 chunk(> 2000 Token):包含过多无关信息,噪声稀释了有效信号,检索精度下降
- 过小的 chunk(< 200 Token):丢失上下文,模型无法理解完整语义
以代码库为例,一个函数块 + 它的注释 + 调用上下文 = 最佳单元,而不是按行分割。
推荐策略:重叠分块,每个 chunk 保留与下一个 chunk 的重叠部分,保证上下文连续性。
function chunkWithOverlap(
text: string,
chunkSize: number = 500,
overlap: number = 100
): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = start + chunkSize;
chunks.push(text.slice(start, end));
start += chunkSize - overlap;
}
return chunks;
}
混合检索优于纯向量检索
纯向量检索对关键词不敏感(比如搜索 "React useEffect",可能召回的是语义相似但关键词不匹配的内容)。
混合检索:向量相似度 + BM25 关键词权重结合
async function hybridSearch(
query: string,
vectorDB: PineconeClient,
topK: number = 5
) {
const vector = await embed(query);
const keywords = extractKeywords(query);
const [vectorResults, bm25Results] = await Promise.all([
vectorDB.search(vector, topK * 2),
bm25Index.search(keywords, topK * 2)
]);
const fused = rrfMerge([
{ results: vectorResults, weight: 0.7 },
{ results: bm25Results, weight: 0.3 }
], topK);
return fused;
}
Prompt 缓存:降低成本的关键
2024 年下半年,各主要大模型厂商陆续推出了 Prompt 缓存功能(Prompt Caching / Context Caching)。这个功能允许在多次请求间复用相同的上下文前缀,只需为变化的部分付费。
MiniMax 的实现思路
const cache = new Map<string, string>();
async function completeWithCache(
sessionId: string,
systemPrompt: string,
context: string,
query: string
): Promise<string> {
const cacheKey = hash(systemPrompt + context);
if (cache.has(cacheKey)) {
return model.complete({
cacheKey,
query,
useCache: true
});
}
return model.complete({
systemPrompt,
context,
query,
cacheWindow: true
});
}
成本对比(假设每天 10000 次请求,system + context = 5000 Token,query = 200 Token):
| 模式 | 每日 Token 消耗 | 相对成本 |
|---|---|---|
| 无缓存 | (5000 + 200) × 10000 = 5200万 | 100% |
| 上下文缓存 | 5000(首请求) + 200 × 10000 = 700万 | ~13% |
缓存失效策略
缓存虽好,但有 TTL(Time To Live)限制。常见策略:
- 时间过期:30 分钟 / 1 小时后失效
- 版本控制:知识库更新时主动失效旧缓存
- 容量淘汰:LRU 淘汰最少使用的缓存
- 强制刷新:用户明确交互(如切换话题)时清空上下文
输出质量的工程保障
结构化输出
在生产环境中,非结构化的文本输出难以可靠解析。要求模型输出 JSON 结构,然后用 JSON Schema 验证:
const response = await model.complete({
prompt,
outputFormat: {
type: "json",
schema: {
type: "object",
properties: {
verdict: { type: "string", enum: ["APPROVE", "REJECT", "REVIEW"] },
reason: { type: "string", maxLength: 200 },
confidence: { type: "number", minimum: 0, maximum: 1 }
},
required: ["verdict", "reason"]
}
}
});
const parsed = JSON.parse(response);
const valid = validate(parsed, schema);
if (!valid) {
// 重试或降级
}
幂等性处理
大模型输出有随机性,同样的输入可能得到略有不同的结果。关键业务场景需要:
- 多次采样:相同输入请求 3 次,选择多数一致的答案
- 一致性校验:输出结果应该有内部一致性
- 回退机制:结构化解析失败时,降级为文本输出 + 人工确认
总结
LLM Context Engineering 的本质是:在质量、成本、可维护性之间找平衡。
核心工程实践:
- 分层:System / Knowledge / History / Task / Query 各司其职
- 压缩:Summarization + Semantic Chunking 控制历史膨胀
- 缓存:Prompt Caching 将重复上下文成本降低 80%+
- 结构化:Schema 验证 + 幂等处理保证输出可靠
上下文工程不是一次性配置完就完事的,它是持续优化的过程——监控 Token 消耗、分析截断对质量的影响、定期调整 chunk size 和检索策略。