大模型上下文窗口管理:滑动窗口、摘要压缩、RAG替代长上下文
大模型上下文窗口管理:滑动窗口、摘要压缩、RAG替代长上下文
适读人群:Java后端工程师、AI应用开发者 | 阅读时长:约18分钟 | 依赖:Spring AI 1.0、Tiktoken4j
开篇故事
做了一个面向法律从业者的AI助手,功能之一是"合同审查对话"——用户上传一份合同,然后可以多轮对话问各种问题。上传的合同动辄几十页,把整份合同塞进上下文,加上对话历史,很快就超过了模型的context window。
最早的处理方式是直接截断——超过15000个token就把老的对话历史扔掉。这导致了一个很尴尬的问题:用户在对话第10轮问了一个问题,答案依赖于第2轮提到的某个细节,但第2轮已经被清出上下文了,模型完全没有记忆。用户的抱怨非常直接:"这AI记性太差了!"
这让我意识到,上下文管理不是简单的"塞不下就删",而是要在有限的token预算里,尽量保留最重要的信息。花了两个月时间研究和实践了三种策略:滑动窗口(保留最近N轮)、摘要压缩(把历史对话压缩成摘要)、以及用RAG替代超长上下文(检索当前问题最相关的历史片段)。三者组合使用后,用户体验大幅改善,"记性差"的抱怨基本消失了。
一、核心问题分析
上下文管理面临的核心约束:
Token成本约束:GPT-4o的input token每千约0.005美元,一个包含50000 token上下文的请求,光输入就要0.25美元。高频使用的场景成本无法接受。
上下文长度约束:即使是128K context的模型,当context超过某个阈值后,"迷失在中间"(Lost in the Middle)问题会出现——模型对context开头和结尾的内容记忆更好,对中间内容的关注度下降。
响应延迟约束:context越长,首token延迟越大。这是因为attention计算复杂度是O(n²),context长度翻倍,计算时间变成4倍。
三种策略的核心权衡:
| 策略 | 信息保留 | 成本 | 实现复杂度 |
|---|---|---|---|
| 滑动窗口 | 只有最近N轮 | 低且可控 | 简单 |
| 摘要压缩 | 压缩后的要点 | 中(需要额外LLM调用) | 中 |
| RAG检索历史 | 最相关的历史片段 | 中(需要向量存储) | 高 |
二、原理深度解析
2.1 三种策略对比
2.2 Token计算的重要性
精确计算token数对上下文管理至关重要。GPT系列用的是tiktoken分词器,中文字符大约1.5个token/字,英文大约1个token/词,代码比较特殊。
准确计算token数的方法:
// 使用tiktoken4j库(Java版tiktoken)
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.0.0</version>
</dependency>三、完整代码实现
3.1 Token计数工具
@Component
public class TokenCounter {
private final Encoding encoding;
public TokenCounter() {
EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
// cl100k_base用于GPT-4和text-embedding-ada-002
this.encoding = registry.getEncoding(EncodingType.CL100K_BASE);
}
public int countTokens(String text) {
return encoding.countTokens(text);
}
public int countMessages(List<ChatMessage> messages) {
int total = 0;
for (ChatMessage msg : messages) {
// 每条消息有约4个token的格式开销
total += 4;
total += countTokens(msg.getContent());
}
total += 2; // 最后的回复前缀
return total;
}
public boolean isWithinLimit(List<ChatMessage> messages, int maxTokens) {
return countMessages(messages) <= maxTokens;
}
/**
* 估算给定token数对应的字符数(用于快速预过滤)
*/
public int estimateCharCount(int tokenCount) {
return (int)(tokenCount * 1.5); // 中文平均约1.5字/token
}
}3.2 滑动窗口策略实现
@Component
public class SlidingWindowMemory implements ContextManager {
private static final Logger log = LoggerFactory.getLogger(SlidingWindowMemory.class);
private final TokenCounter tokenCounter;
@Value("${context.max-tokens:12000}")
private int maxTokens;
@Value("${context.reserve-for-output:2000}")
private int reserveForOutput; // 预留给输出的token
public SlidingWindowMemory(TokenCounter tokenCounter) {
this.tokenCounter = tokenCounter;
}
@Override
public List<ChatMessage> manage(List<ChatMessage> allMessages,
String systemPrompt) {
int systemTokens = tokenCounter.countTokens(systemPrompt);
int availableTokens = maxTokens - reserveForOutput - systemTokens;
if (tokenCounter.countMessages(allMessages) <= availableTokens) {
return allMessages; // 不需要截断
}
// 从末尾开始保留,直到超过限制
List<ChatMessage> retained = new ArrayList<>();
int usedTokens = 0;
for (int i = allMessages.size() - 1; i >= 0; i--) {
ChatMessage msg = allMessages.get(i);
int msgTokens = tokenCounter.countTokens(msg.getContent()) + 4;
if (usedTokens + msgTokens > availableTokens) {
log.info("滑动窗口裁剪:保留最近{}条消息,丢弃{}条",
retained.size(), i + 1);
break;
}
retained.add(0, msg);
usedTokens += msgTokens;
}
// 如果保留的消息不是成对的(用户消息+助手消息),去掉第一条(可能是孤立的助手消息)
if (!retained.isEmpty() &&
retained.get(0).getMessageType() == MessageType.ASSISTANT) {
retained.remove(0);
}
return retained;
}
}3.3 摘要压缩策略实现
@Component
public class SummaryCompressionMemory implements ContextManager {
private static final Logger log = LoggerFactory.getLogger(SummaryCompressionMemory.class);
private final ChatClient summaryClient;
private final TokenCounter tokenCounter;
private final ConversationSummaryRepository summaryRepository;
@Value("${context.max-tokens:12000}")
private int maxTokens;
@Value("${context.summary.trigger-ratio:0.8}")
private double triggerRatio; // 超过80%时触发摘要压缩
@Value("${context.summary.keep-recent:6}")
private int keepRecentMessages; // 保留最近6条原始消息
private static final String SUMMARY_PROMPT = """
请对以下对话历史进行精确摘要,保留所有重要信息点、决策、用户提到的关键细节。
摘要应该足够详细,让后续对话中的AI能够了解之前发生了什么。
对话历史:
{conversation}
请生成摘要(不超过500字):
""";
public SummaryCompressionMemory(ChatClient.Builder builder,
TokenCounter tokenCounter,
ConversationSummaryRepository summaryRepository) {
this.summaryClient = builder
.defaultOptions(ChatOptions.builder()
.model("gpt-4o-mini") // 摘要用便宜的模型
.temperature(0.1)
.build())
.build();
this.tokenCounter = tokenCounter;
this.summaryRepository = summaryRepository;
}
@Override
public List<ChatMessage> manage(List<ChatMessage> allMessages,
String systemPrompt) {
int systemTokens = tokenCounter.countTokens(systemPrompt);
int totalTokens = tokenCounter.countMessages(allMessages) + systemTokens;
int triggerThreshold = (int)(maxTokens * triggerRatio);
if (totalTokens < triggerThreshold) {
return allMessages;
}
log.info("触发摘要压缩:当前{}tokens,阈值{}tokens", totalTokens, triggerThreshold);
// 分割:需要压缩的旧消息 + 保留的最近消息
int splitPoint = Math.max(0, allMessages.size() - keepRecentMessages);
List<ChatMessage> toSummarize = allMessages.subList(0, splitPoint);
List<ChatMessage> toKeep = allMessages.subList(splitPoint, allMessages.size());
if (toSummarize.isEmpty()) {
// 所有消息都在"保留"区,只能用滑动窗口
return slidingWindowFallback(allMessages, systemTokens);
}
// 生成摘要
String conversationText = toSummarize.stream()
.map(m -> (m.getMessageType() == MessageType.USER ? "用户:" : "助手:")
+ m.getContent())
.collect(Collectors.joining("\n\n"));
String summaryText = generateSummary(conversationText);
// 把摘要作为第一条系统消息插入
List<ChatMessage> result = new ArrayList<>();
result.add(new SystemMessage("[对话历史摘要]\n" + summaryText));
result.addAll(toKeep);
log.info("摘要压缩完成:{}条消息压缩为{}字摘要,保留最近{}条",
toSummarize.size(), summaryText.length(), toKeep.size());
return result;
}
private String generateSummary(String conversation) {
String prompt = SUMMARY_PROMPT.replace("{conversation}", conversation);
return summaryClient.prompt(prompt).call().content();
}
private List<ChatMessage> slidingWindowFallback(List<ChatMessage> messages,
int systemTokens) {
// 降级到滑动窗口
int availableTokens = maxTokens - systemTokens - 2000;
List<ChatMessage> retained = new ArrayList<>();
int used = 0;
for (int i = messages.size() - 1; i >= 0; i--) {
int t = tokenCounter.countTokens(messages.get(i).getContent()) + 4;
if (used + t > availableTokens) break;
retained.add(0, messages.get(i));
used += t;
}
return retained;
}
}3.4 RAG检索历史策略
@Component
public class RagHistoryMemory implements ContextManager {
private static final Logger log = LoggerFactory.getLogger(RagHistoryMemory.class);
private final EmbeddingModel embeddingModel;
private final VectorStore historyVectorStore;
private final TokenCounter tokenCounter;
@Value("${context.rag.recent-messages:6}")
private int recentMessages; // 最近N条直接保留
@Value("${context.rag.retrieved-messages:4}")
private int retrievedMessages; // 从历史检索N条
public RagHistoryMemory(EmbeddingModel embeddingModel,
VectorStore historyVectorStore,
TokenCounter tokenCounter) {
this.embeddingModel = embeddingModel;
this.historyVectorStore = historyVectorStore;
this.tokenCounter = tokenCounter;
}
/**
* 存储新消息到历史向量库
*/
public void storeMessage(String sessionId, ChatMessage message) {
Document doc = new Document(
message.getContent(),
Map.of("session_id", sessionId,
"message_type", message.getMessageType().name(),
"timestamp", System.currentTimeMillis())
);
historyVectorStore.add(List.of(doc));
}
@Override
public List<ChatMessage> manage(List<ChatMessage> allMessages,
String systemPrompt) {
// 最近N条直接保留
int recentStart = Math.max(0, allMessages.size() - recentMessages);
List<ChatMessage> recent = allMessages.subList(recentStart, allMessages.size());
// 获取当前问题(最后一条用户消息)
String currentQuery = allMessages.stream()
.filter(m -> m.getMessageType() == MessageType.USER)
.reduce((first, second) -> second) // 取最后一条
.map(ChatMessage::getContent)
.orElse("");
if (currentQuery.isEmpty() || recentStart == 0) {
return recent; // 没有历史可检索
}
// 检索相关历史
List<Document> relevantHistory = historyVectorStore.similaritySearch(
SearchRequest.builder()
.query(currentQuery)
.topK(retrievedMessages)
.similarityThreshold(0.7)
.build());
// 构建最终消息列表:相关历史 + 提示分割线 + 最近消息
List<ChatMessage> result = new ArrayList<>();
if (!relevantHistory.isEmpty()) {
String historyContext = relevantHistory.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
result.add(new SystemMessage(
"[相关历史对话片段]\n" + historyContext +
"\n\n[以上是从历史对话中检索的相关内容]"));
}
result.addAll(recent);
return result;
}
}3.5 混合上下文管理器
@Service
public class HybridContextManager {
private final SlidingWindowMemory slidingWindow;
private final SummaryCompressionMemory summaryCompression;
private final RagHistoryMemory ragHistory;
private final TokenCounter tokenCounter;
@Value("${context.strategy:sliding-window}")
private String strategy;
public HybridContextManager(SlidingWindowMemory slidingWindow,
SummaryCompressionMemory summaryCompression,
RagHistoryMemory ragHistory,
TokenCounter tokenCounter) {
this.slidingWindow = slidingWindow;
this.summaryCompression = summaryCompression;
this.ragHistory = ragHistory;
this.tokenCounter = tokenCounter;
}
public List<ChatMessage> manage(String sessionId,
List<ChatMessage> messages,
String systemPrompt) {
return switch (strategy) {
case "summary" -> summaryCompression.manage(messages, systemPrompt);
case "rag" -> {
// 先把当前消息存入历史
if (!messages.isEmpty()) {
ragHistory.storeMessage(sessionId,
messages.get(messages.size() - 1));
}
yield ragHistory.manage(messages, systemPrompt);
}
case "hybrid" -> {
// 混合策略:优先摘要压缩,极端情况下再滑动窗口
List<ChatMessage> summaryManaged =
summaryCompression.manage(messages, systemPrompt);
if (tokenCounter.isWithinLimit(summaryManaged, 14000)) {
yield summaryManaged;
}
yield slidingWindow.manage(summaryManaged, systemPrompt);
}
default -> slidingWindow.manage(messages, systemPrompt);
};
}
}四、效果评估与优化
在法律合同审查对话场景(每次对话平均25轮,合同文本平均3万字):
| 策略 | 平均Token消耗/对话 | 信息保留完整度(用户评分) | 首Token延迟 | 额外LLM调用 |
|---|---|---|---|---|
| 全量上下文(截断) | 45,000 | 58% | 3200ms | 无 |
| 滑动窗口(最近10轮) | 12,000 | 72% | 850ms | 无 |
| 摘要压缩 | 18,000 | 84% | 1100ms | 1次/压缩触发 |
| RAG检索历史 | 14,000 | 81% | 1300ms | 无 |
| 混合策略 | 16,000 | 87% | 1200ms | 少量 |
混合策略整体最优,Token消耗比全量上下文降低了64%,用户体验提升明显。
五、踩坑实录
坑1:摘要生成时机不对,导致摘要丢失刚刚确认的信息
我设置的压缩触发点是"当context超过80%"时,压缩的范围是"除最近6条外的所有消息"。有一次用户在第8轮确认了一个关键信息("合同金额以人民币计算"),但这条消息在第10轮时恰好被划入"待压缩"范围。摘要时恰好漏掉了这个细节,后续对话出现了错误。优化方案:标记"重要消息"(用户明确确认的信息),强制不进入压缩范围。
坑2:RAG检索历史在对话初期效果差
对话刚开始时,历史向量库里消息很少,检索质量很低。第3轮就去检索,搜到的全是第1轮的内容,没有任何增量价值,反而浪费了token。改进方案:对话历史少于10条时直接用滑动窗口,超过10条才启用RAG检索。
坑3:Tiktoken4j和实际API的token计数有偏差
我用Tiktoken4j计算出的token数和OpenAI实际返回的usage.prompt_tokens有时候差距达到3-5%。主要原因是中文字符的token化规则比较复杂,工具计算和服务端略有差异。建议在计算最大token时留出10%的余量,不要把限制卡得太死。
六、总结
上下文窗口管理是LLM应用工程中一个容易被忽视但影响极大的问题。在长对话、长文档的场景里,好的上下文管理策略能把Token成本降低50-70%,同时显著提升对话连贯性。
三种策略的选择建议:普通多轮对话用滑动窗口就够;有明显阶段性信息的场景(如分析报告、设计评审)用摘要压缩;需要精确回忆特定历史细节的场景(法律、合同、技术文档讨论)用RAG检索历史。生产系统可以根据业务特点配置混合策略,灵活切换。
