第2285篇:长上下文模型的工程实践——超大token窗口的工程挑战与机遇
第2285篇:长上下文模型的工程实践——超大token窗口的工程挑战与机遇
适读人群:处理长文档或需要超大上下文的AI应用开发者 | 阅读时长:约15分钟 | 核心价值:掌握长上下文模型的真实工程价值边界与实用技巧
Claude 3.5的200K context window,Gemini 1.5 Pro的1M context window——看到这些数字,很多人的第一反应是:以后不用做RAG了,直接把整个文档库塞进去就好了。
我一开始也这么想。直到我们真的试了一回。
"把所有东西都塞进去"的代价
我们有一个合同审查系统。一份完整的采购合同包里可能有几十份文件:主合同、附件、补充协议、技术规范……加起来几十万字。
Gemini 1.5 Pro理论上放得进去。我们做了个实验:把整个合同包(约60万字)全部放进上下文,让模型回答具体问题。
结果如下:
- 响应时间:45-90秒,对于一个需要随时查询的系统完全不可接受
- Token成本:每次查询消耗60万输入token,按当时Gemini 1.5 Pro的价格,一次查询要$0.84
- "迷失在中间"问题:当关键信息在文档中间时,模型的答案质量明显下降
第三个问题来自一篇2023年的研究论文(Lost in the Middle),结论是:LLM对上下文头部和尾部的信息记忆最好,中间部分的信息容易被"遗忘"。随着上下文越来越长,这个问题越来越严重。
所以长上下文不是RAG的替代品,而是对RAG的补充。这两者适合不同的场景。
长上下文真正的工程价值
搞清楚了局限,再来看长上下文真正有价值的地方:
1. 跨文档关联分析
这是RAG做不好的场景。RAG通过向量搜索召回相关片段,但它没法很好地处理"需要理解文档间的关系"的任务,比如:
- 分析一份合同相对于另一份合同修改了哪些条款
- 在几份会议纪要里找出某个决策的演变过程
- 理解一套技术规范文档里各个章节之间的依赖关系
这类任务把相关文档(选出来的,不是全部)一起放进上下文,效果远好于分片检索。
2. 代码库理解
对一个中等规模的代码库(比如一个微服务,10-20个Java类),全量放进上下文让模型理解整体架构,效果比分片好很多。RAG在代码理解上的问题是:代码逻辑的关联性很强,一个类的含义经常依赖另外几个类,切片后上下文丢失了。
3. 长文档的深度摘要
对一份长达几百页的研究报告或技术文档做摘要,长上下文模型可以一次处理完,保持全局一致性。RAG分片摘要再合并,容易出现各片段之间逻辑割裂的问题。
实用的上下文管理策略
面对长上下文,工程上有几个重要的处理技巧:
1. 关键信息前置/后置
利用"Lost in the Middle"规律:把最重要的信息放在prompt的开头或结尾,中间放辅助性的背景信息:
@Service
public class LongContextPromptBuilder {
public String buildOptimizedPrompt(
String question,
List<Document> documents,
String systemContext) {
StringBuilder prompt = new StringBuilder();
// 第一部分:系统上下文和问题(放在最前面,模型最重视)
prompt.append("系统背景:\n").append(systemContext).append("\n\n");
prompt.append("用户问题:\n").append(question).append("\n\n");
prompt.append("---\n参考文档:\n\n");
// 第二部分:按相关性排序文档,相关性高的放前面和后面
List<Document> sortedDocs = sortByRelevance(documents, question);
// 最相关的文档放前面
List<Document> topDocs = sortedDocs.subList(0, Math.min(3, sortedDocs.size()));
// 次要文档放中间
List<Document> middleDocs = sortedDocs.size() > 3 ?
sortedDocs.subList(3, sortedDocs.size()) : List.of();
// 先放次要文档(中间位置)
for (Document doc : middleDocs) {
prompt.append("【").append(doc.getTitle()).append("】\n");
prompt.append(doc.getContent()).append("\n\n");
}
// 最后放最相关的文档(尾部位置)
for (Document doc : topDocs) {
prompt.append("【重要文档 - ").append(doc.getTitle()).append("】\n");
prompt.append(doc.getContent()).append("\n\n");
}
// 最后再重复问题(尾部再次强调)
prompt.append("---\n请回答问题:").append(question);
return prompt.toString();
}
}2. 层次化摘要缓存
对于需要频繁查询的长文档,预先生成分层摘要,根据问题类型选择合适的粒度:
@Service
public class HierarchicalSummaryService {
private final LlmClient llmClient;
private final RedisTemplate<String, String> redis;
/**
* 构建文档的层次化摘要结构
* - 章节级别摘要:每个章节100-200字
* - 文档级别摘要:整个文档300-500字
* - 关键信息索引:结构化的重要数据点
*/
public DocumentSummaryTree buildSummaryTree(String documentId, String fullContent) {
// 按章节分割
List<Section> sections = splitIntoSections(fullContent);
// 并行生成章节摘要
List<CompletableFuture<SectionSummary>> futures = sections.stream()
.map(section -> CompletableFuture.supplyAsync(() ->
summarizeSection(section)))
.collect(Collectors.toList());
List<SectionSummary> sectionSummaries = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
// 基于章节摘要生成文档摘要
String docSummary = generateDocumentSummary(sectionSummaries);
// 提取关键信息点
Map<String, String> keyFacts = extractKeyFacts(fullContent);
DocumentSummaryTree tree = new DocumentSummaryTree(
documentId, docSummary, sectionSummaries, keyFacts
);
// 缓存(摘要生成代价高,要长期缓存)
cacheTree(documentId, tree);
return tree;
}
/**
* 智能选择上下文粒度
*/
public String selectContextForQuestion(String question, DocumentSummaryTree tree) {
// 用轻量模型判断需要哪个粒度的上下文
QuestionType qType = classifyQuestion(question);
return switch (qType) {
case QUICK_FACT -> {
// 关键事实查询:直接查索引,不需要大上下文
String fact = tree.getKeyFacts().get(extractFactKey(question));
yield fact != null ? fact : tree.getDocumentSummary();
}
case SECTION_SPECIFIC -> {
// 特定章节问题:只取相关章节的全文
String sectionTitle = identifyRelevantSection(question, tree);
yield tree.getSectionFullContent(sectionTitle);
}
case CROSS_SECTION -> {
// 跨章节分析:用文档摘要 + 相关章节摘要
yield buildCrossSection Context(question, tree);
}
case COMPREHENSIVE -> {
// 综合理解:用全文(但先检查token是否超限)
yield tree.getFullContent();
}
};
}
}3. 动态截断策略
当文档超出上下文窗口时,需要智能截断而不是简单截尾:
@Component
public class ContextWindowManager {
private static final int MAX_TOKENS = 180000; // 预留输出空间
private final TokenCounter tokenCounter;
public String fitIntoContextWindow(
String systemPrompt,
String question,
List<String> documents) {
int reservedTokens = tokenCounter.count(systemPrompt) +
tokenCounter.count(question) +
2000; // 输出预留
int availableTokens = MAX_TOKENS - reservedTokens;
// 按重要性排序文档
List<ScoredDocument> scored = rankDocuments(question, documents);
StringBuilder contextBuilder = new StringBuilder();
int usedTokens = 0;
for (ScoredDocument doc : scored) {
int docTokens = tokenCounter.count(doc.getContent());
if (usedTokens + docTokens <= availableTokens) {
// 完整放入
contextBuilder.append(doc.getContent()).append("\n\n");
usedTokens += docTokens;
} else {
// 只放入部分(从最相关的片段开始)
int remaining = availableTokens - usedTokens;
if (remaining > 500) {
String truncated = intelligentTruncate(
doc.getContent(), question, remaining
);
contextBuilder.append(truncated).append("\n[文档已截断]\n\n");
}
break;
}
}
return contextBuilder.toString();
}
/**
* 智能截断:优先保留与问题最相关的段落
*/
private String intelligentTruncate(String content, String question, int maxTokens) {
// 按段落分割
String[] paragraphs = content.split("\n\n");
// 给每个段落打分
List<ScoredParagraph> scored = Arrays.stream(paragraphs)
.map(p -> new ScoredParagraph(p, computeRelevance(p, question)))
.sorted(Comparator.comparingDouble(ScoredParagraph::getScore).reversed())
.collect(Collectors.toList());
// 贪心选择:按相关性加入,直到达到token上限
List<String> selected = new ArrayList<>();
int usedTokens = 0;
for (ScoredParagraph sp : scored) {
int paragraphTokens = tokenCounter.count(sp.getContent());
if (usedTokens + paragraphTokens <= maxTokens) {
selected.add(sp.getContent());
usedTokens += paragraphTokens;
}
}
// 按原始顺序重新排列(保持文档的逻辑顺序)
return restoreOriginalOrder(selected, paragraphs);
}
}成本控制:长上下文的账单管理
长上下文模型的账单是很多团队的噩梦。几个实际有效的控制策略:
缓存相同文档的处理结果
大多数长上下文请求会反复使用相同的文档(同一份合同被多次查询)。把文档内容的处理结果缓存起来,只需重新计算问题部分:
// Claude的Prompt Caching功能:标记可以缓存的上下文部分
// 被缓存的内容再次请求时只收读取费用(约为原价的1/10)
List<ContentBlock> messages = List.of(
ContentBlock.text(systemPrompt),
ContentBlock.text(documentContent)
.cacheControl(CacheControl.ephemeral()) // 标记为可缓存
);批量处理
如果不需要实时,用批量API(通常有50%折扣):
// 将多个问题合并成一次请求(如果它们针对同一份文档)
List<String> questions = List.of("问题1", "问题2", "问题3");
String batchPrompt = questions.stream()
.enumerate()
.map(e -> (e.getKey() + 1) + ". " + e.getValue())
.collect(Collectors.joining("\n"));
// 一次请求,多个问题一起回答
String batchAnswer = llmClient.complete(documentContext + "\n\n请回答以下问题:\n" + batchPrompt);长上下文模型是一个强大的工具,但"能放就放"不是正确的工程哲学。在真正需要全局理解、跨段落关联的场景里用它,其他场景继续用RAG,这才是合理的技术决策。
