Agent 上下文窗口管理:长篇对话里如何高效压缩历史(续篇)

Agent 上下文窗口管理:长篇对话里如何高效压缩历史(续篇)

Agent 上下文窗口管理:长篇对话里如何高效压缩历史(续篇)

场景痛点

Agent与用户进行30轮对话。每轮对话平均200token。30轮历史合计6000token。加上系统提示500token、当前用户输入100token——总token数6600。LLM的上下文窗口128K,6600token完全够用。

但实际场景远比这复杂:

  • Agent执行了5次工具调用,每次工具返回2000token。工具输出总计10000token。
  • 用户中途讨论了3个不同话题,每个话题的历史与后续对话无关。
  • 系统提示包含详细的行为规范(1500token)和5条用户偏好记忆(500token)。

实际token数:6600+10000+1500+500=18600。仍然在128K窗口内。

但50轮对话后:18600×1.67=31000token。100轮后:62000token。工具输出占比60%——大部分token花在工具返回的冗余数据上。上下文窗口快满了,LLM开始遗忘早期对话内容,工具调用返回的数据被截断。

核心矛盾:上下文窗口是有限资源,对话历史无限增长。不是所有历史都同等重要——工具返回的原始数据比对话中的关键决策信息更占token但价值更低。需要智能压缩策略:保留重要信息、丢弃冗余数据、在窗口边界前触发压缩。

底层机制与原理剖析

上下文窗口管理是信息密度优化问题。128K窗口不是"能装128K文本"——是"128K窗口内LLM能有效处理的文本量"。研究表明LLM对长上下文的信息检索能力随长度增加而下降("Lost in the Middle"效应):中间位置的指令被"遗忘"的概率是开头和结尾位置的3倍。

关键机制:

  1. 滑动窗口+重要性标记。不是简单的"保留最近20轮"。保留最近N轮是基础策略,但某些早期轮次包含关键决策信息(如用户说"不要用AI模型A"),这些轮次即使不在最近N轮中也必须保留。重要性标记给每个对话轮次打分,高分的轮次永久保留。

  2. 工具结果摘要化。工具返回2000token的JSON数据,Agent只用了其中3个字段。摘要化将2000token压缩到200token——只保留Agent实际使用的字段和关键结论。压缩比10:1。

  3. 话题分离与权重衰减。对话中多个话题交替出现。当前话题的历史权重高,旧话题的历史权重低。权重低的旧话题只保留摘要(一句话总结),不保留完整对话。

  4. 上下文布局优化。关键信息放在上下文开头(LLM对开头信息的注意力最高),摘要信息放在中间(LLM对中间信息的注意力最低),当前输入放在结尾(LLM对结尾信息的注意力高)。利用LLM的注意力分布特性最大化信息密度。

生产级代码实现

ContextWindowManager:上下文窗口管理器

// agent/context-window-manager.ts import { Redis } from 'ioredis'; import { createHash } from 'crypto'; type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; type CompressionLevel = 'full' | 'summary' | 'keywords_only'; interface DialogTurn { turnId: string; role: MessageRole; content: string; tokenCount: number; topic: string; // 话题标签 importanceScore: number; // 重要性评分 (0~1) compressionLevel: CompressionLevel; originalContent?: string; // 原始内容(被压缩前的完整版本) timestamp: string; toolCallId?: string; // 如果是工具调用结果,关联的工具调用ID } interface ContextLayout { systemPrompt: string; userMemories: string[]; criticalHistory: DialogTurn[]; // 高重要性轮次(永久保留) recentHistory: DialogTurn[]; // 最近N轮(滑动窗口) summarizedHistory: string[]; // 旧话题摘要 currentInput: string; totalTokens: number; maxTokens: number; // 上下文窗口上限 } class ContextWindowManager { private redis: Redis; private maxTokens: number; private recentWindowSize: number; // 滑动窗口大小(保留最近N轮) private importanceThreshold: number; // 重要性阈值(>此值永久保留) constructor(redisUrl: string, maxTokens: number = 128000) { this.redis = new Redis(redisUrl); this.maxTokens = maxTokens; this.recentWindowSize = 20; // 默认保留最近20轮 // 为什么20轮而非50轮:20轮约4000token(不含工具输出), // 占128K窗口的3%。50轮约10000token占8%——留给工具输出的空间更少 this.importanceThreshold = 0.8; // 重要性>0.8的轮次永久保留 } // 构建优化后的上下文布局 async buildContext(sessionId: string, currentInput: string): ContextLayout { const allTurns = await this.loadAllTurns(sessionId); // 1. 分离高重要性和低重要性轮次 const criticalTurns = allTurns.filter(t => t.importanceScore >= this.importanceThreshold); const normalTurns = allTurns.filter(t => t.importanceScore < this.importanceThreshold); // 2. 滑动窗口:保留最近N轮 const recentTurns = normalTurns.slice(-this.recentWindowSize); const oldTurns = normalTurns.slice(0, -this.recentWindowSize); // 3. 旧话题摘要化 // 为什么旧轮次摘要而非直接丢弃:完全丢弃=信息不可恢复, // 用户回头讨论旧话题时Agent没有上下文。 // 摘要保留核心信息,token消耗极低 const summarized = await this.summarizeOldTurns(oldTurns); // 4. 工具结果摘要化 // 为什么对工具结果单独摘要:工具结果的token占比最大(60%), // 压缩工具结果的ROI最高。对话文本压缩比约3:1, // 工具结果压缩比约10:1 const compressedRecent = await this.compressToolResults(recentTurns); const compressedCritical = await this.compressToolResults(criticalTurns); // 5. 计算总token数 const systemPrompt = await this.getSystemPrompt(sessionId); const memories = await this.getUserMemories(sessionId); const totalTokens = this.countTokens(systemPrompt) + memories.reduce((sum, m) => sum + this.countTokens(m), 0) + compressedCritical.reduce((sum, t) => sum + t.tokenCount, 0) + compressedRecent.reduce((sum, t) => sum + t.tokenCount, 0) + summarized.reduce((sum, s) => sum + this.countTokens(s), 0) + this.countTokens(currentInput); // 6. 检查是否超出窗口 if (totalTokens > this.maxTokens) { // 紧急压缩:进一步降低压缩等级 // 为什么紧急压缩而非截断:截断丢信息,压缩保留关键信息 return this.emergencyCompress(sessionId, currentInput, totalTokens); } // 7. 构建上下文布局(利用LLM注意力分布) // 关键信息前置,摘要信息中间,当前输入结尾 return { systemPrompt, // 开头:LLM注意力最高 userMemories: memories, // 开头第二部分 criticalHistory: compressedCritical, // 开头第三部分:关键决策 recentHistory: compressedRecent, // 中间:最近对话 summarizedHistory: summarized, // 中间:旧话题摘要 currentInput, // 结尾:LLM注意力高 totalTokens, maxTokens: this.maxTokens }; } // 记录新对话轮次 async recordTurn(sessionId: string, turn: DialogTurn): void { // 计算重要性评分 turn.importanceScore = this.scoreImportance(turn); turn.tokenCount = this.countTokens(turn.content); // 工具调用结果默认标记为低重要性 // 为什么工具结果低重要性:工具原始数据token多但信息密度低, // Agent只需要其中的关键结论。除非用户明确引用了工具结果中的某个字段 if (turn.role === 'tool') { turn.importanceScore = Math.min(turn.importanceScore, 0.3); turn.compressionLevel = 'summary'; // 工具结果默认摘要化 } const key = `turn:${sessionId}:${turn.turnId}`; await this.redis.rpush(`turns:${sessionId}`, JSON.stringify(turn)); } // 重要性评分算法 private scoreImportance(turn: DialogTurn): number { let score = 0.5; // 默认中等重要性 // 提升重要性的信号 // 为什么这些信号提升重要性:这些是影响Agent后续行为的决策性信息, // 丢失它们会导致Agent在后续对话中做出与用户意图矛盾的行为 const content = turn.content.toLowerCase(); // 用户明确偏好声明:"不要用XX""我喜欢XX格式" if (content.includes('不要') || content.includes('不喜欢') || content.includes('必须') || content.includes('禁止')) { score += 0.3; } // 关键决策:"确认""同意""就按这个方案" if (content.includes('确认') || content.includes('同意') || content.includes('决定')) { score += 0.2; } // 用户纠正:"不对""错了""重新" if (content.includes('不对') || content.includes('错了') || content.includes('重新做')) { score += 0.25; // 纠正信息很重要——影响Agent后续执行策略 } // 降低重要性的信号 // 为什么这些信号降低重要性:这些是重复或低信息密度的内容, // 前序轮次已包含等效信息 // 简单确认:"好的""知道了""嗯" if (content.length < 10 && (content.includes('好的') || content.includes('知道了'))) { score -= 0.4; } // 纯提问(无决策信息) if (turn.role === 'user' && content.endsWith('?') && content.length < 50) { score -= 0.1; } return Math.max(0, Math.min(1, score)); } // 工具结果摘要化 private async compressToolResults(turns: DialogTurn[]): DialogTurn[] { const compressed: DialogTurn[] = []; for (const turn of turns) { if (turn.role === 'tool' && turn.compressionLevel === 'summary') { // 提取工具返回中的关键字段 // 为什么只提取关键字段而非LLM摘要:关键字段提取确定性高、速度快。 // LLM摘要有随机性(两次摘要结果不同)且延迟高(500ms+) const summary = this.extractKeyFields(turn.content); compressed.push({ ...turn, content: summary, tokenCount: this.countTokens(summary), compressionLevel: 'summary', originalContent: turn.content // 保留原始内容供回溯 }); } else { compressed.push(turn); } } return compressed; } // 从JSON格式的工具返回中提取关键字段 private extractKeyFields(toolResult: string): string { try { const data = JSON.parse(toolResult); // 根据工具类型提取不同字段 // 为什么按工具类型提取而非统一规则:不同工具的关键信息不同, // API调用返回status+data,文件操作返回path+success, // 统一规则会遗漏某些工具的关键信息 const keyFields: Record<string, string[]> = { 'api_call': ['status', 'data', 'error', 'message'], 'file_operation': ['path', 'success', 'error'], 'db_query': ['rows', 'count', 'error'], 'default': ['status', 'error', 'result'] }; const toolType = data.tool_type || 'default'; const fields = keyFields[toolType] || keyFields['default']; const summary: Record<string, any> = {}; for (const field of fields) { if (data[field] !== undefined) { // 限制字段值长度:单个字段最多100字符 // 为什么限制长度:某些字段(如完整的HTML内容)本身就很长, // 不限制会导致摘要仍然很长 const value = JSON.stringify(data[field]); summary[field] = value.length > 100 ? value.substring(0, 100) + '...[truncated]' : data[field]; } } return `[工具结果摘要] ${JSON.stringify(summary)}`; } catch { // 非JSON格式:截断到200字符 // 为什么截断而非保留完整:非JSON的工具结果(如纯文本日志) // 信息密度极低,200字符足以保留关键信息 return `[工具结果摘要] ${toolResult.substring(0, 200)}...[truncated]`; } } // 旧话题摘要化 private async summarizeOldTurns(turns: DialogTurn[]): string[] { // 按话题分组 const topicGroups: Record<string, DialogTurn[]> = {}; for (const turn of turns) { if (!topicGroups[turn.topic]) { topicGroups[turn.topic] = []; } topicGroups[turn.topic].push(turn); } const summaries: string[] = []; for (const [topic, groupTurns] of Object.entries(topicGroups)) { // 每个话题生成一句话摘要 // 为什么一句话而非多句:旧话题的摘要只是"备忘"而非"参考", // 用户回到旧话题时需要重新展开讨论,摘要只需触发Agent的记忆关联 const userMessages = groupTurns.filter(t => t.role === 'user'); const assistantMessages = groupTurns.filter(t => t.role === 'assistant'); const firstUserMsg = userMessages[0]?.content.substring(0, 50) || ''; const lastAssistantMsg = assistantMessages[assistantMessages.length - 1]?.content.substring(0, 80) || ''; summaries.push( `[话题:${topic}] 用户问"${firstUserMsg}",Agent回答"${lastAssistantMsg}"` ); } return summaries; } // 紧急压缩:上下文超出窗口时的降级策略 private emergencyCompress( sessionId: string, currentInput: string, currentTokens: number ): ContextLayout { // 紧急压缩策略:逐步降低压缩等级直到总token数在窗口内 // 为什么逐步而非一次性大幅压缩:大幅压缩可能丢弃关键信息, // 逐步压缩每次只降低一个等级,可以精确控制信息损失 // 阶段1:所有工具结果压缩到keywords_only级别 // keywords_only只保留工具名+状态+关键数值,约50token // 压缩比:full(2000token)→summary(200token)→keywords_only(50token) // 阶段2:旧话题摘要压缩到关键词级别 // 只保留话题名+结论关键词,约30token // 阶段3:非关键对话轮次只保留role+前20字符 // 极端压缩比,约30token/轮 // 阶段4:系统提示精简版(只保留行为规范核心3条) // 从1500token压缩到300token // 如果4阶段压缩后仍超窗口:只保留系统提示+最近3轮+当前输入 // 这是最终兜底策略——保证Agent能理解当前输入并执行最近3轮的上下文 // 为什么3轮而非1轮:1轮不足以理解用户意图的上下文, // 3轮(用户输入+Agent回复+可能的工具调用)覆盖最近的完整交互 // 实际实现:计算每阶段压缩后的token数,在满足窗口限制时停止 // 此处简化实现——实际需要迭代计算 return { systemPrompt: '[精简系统提示] 执行用户任务,优先使用已验证的方案,遇到纠正立即调整策略。', userMemories: [], criticalHistory: [], recentHistory: [], // 紧急模式只保留最近3轮 summarizedHistory: [], currentInput, totalTokens: 300 + this.countTokens(currentInput), // 精简估算 maxTokens: this.maxTokens }; } // 简化token计数(实际生产用tiktoken库) private countTokens(text: string): number { // 粗略估算:中文1字≈2token,英文1词≈1token // 为什么用粗略估算而非精确计数:精确计数需要加载tiktoken库(200MB), // 每次计数耗时50ms。粗略估算1ms,对布局决策的精度影响<5% const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length; const otherChars = text.length - chineseChars; return chineseChars * 2 + Math.ceil(otherChars / 4); } private async loadAllTurns(sessionId: string): DialogTurn[] { const data = await this.redis.lrange(`turns:${sessionId}`, 0, -1); return data.map(d => JSON.parse(d)); } private async getSystemPrompt(sessionId: string): string { return await this.redis.get(`system_prompt:${sessionId}`) || ''; } private async getUserMemories(sessionId: string): string[] { const data = await this.redis.lrange(`memories:${sessionId}`, 0, -1); return data; } }

话题识别与标注

// agent/topic-recognizer.ts class TopicRecognizer { private topicKeywords: Record<string, string[]> = { '部署': ['部署', '发布', '上线', 'deploy', 'release'], '代码审查': ['代码审查', 'review', 'PR', '重构'], '性能优化': ['性能', '优化', '延迟', 'QPS', '吞吐'], '架构设计': ['架构', '设计', '方案', '技术选型'], '故障排查': ['故障', '报错', '异常', '超时', 'crash'], '通用': [] // 无明确关键词时归入通用话题 }; recognize(content: string): string { // 从内容中识别话题标签 // 为什么需要话题标签:话题标签用于分组摘要, // 同一话题的对话轮次生成一份摘要, // 不同话题的摘要独立压缩——当前话题的摘要权重高于旧话题 const contentLower = content.toLowerCase(); for (const [topic, keywords] of Object.entries(this.topicKeywords)) { if (topic === '通用') continue; for (const keyword of keywords) { if (contentLower.includes(keyword.toLowerCase())) { return topic; } } } return '通用'; } }

上下文压缩监控

// agent/context-monitor.ts interface CompressionMetrics { sessionId: string; totalTurns: number; originalTokens: number; compressedTokens: number; compressionRatio: number; criticalTurnsCount: number; summarizedTopicsCount: number; emergencyCompressionsCount: number; averageImportanceScore: number; } class ContextMonitor { private metrics: Map<string, CompressionMetrics> = new Map(); recordCompression(sessionId: string, layout: ContextLayout): void { const original = layout.criticalHistory .concat(layout.recentHistory) .reduce((sum, t) => sum + (t.originalContent ? this.countTokens(t.originalContent) : t.tokenCount), 0) + layout.summarizedHistory.reduce((sum, s) => sum + this.countTokens(s) * 10, 0); // 摘要的原始内容约10倍 const metrics: CompressionMetrics = { sessionId, totalTurns: layout.criticalHistory.length + layout.recentHistory.length, originalTokens: original, compressedTokens: layout.totalTokens, compressionRatio: original / layout.totalTokens, criticalTurnsCount: layout.criticalHistory.length, summarizedTopicsCount: layout.summarizedHistory.length, emergencyCompressionsCount: 0, averageImportanceScore: [...layout.criticalHistory, ...layout.recentHistory] .reduce((sum, t) => sum + t.importanceScore, 0) / (layout.criticalHistory.length + layout.recentHistory.length) || 0 }; this.metrics.set(sessionId, metrics); } // 生成压缩效果报告 generateReport(): string { const allMetrics = [...this.metrics.values()]; const avgRatio = allMetrics.reduce((sum, m) => sum + m.compressionRatio, 0) / allMetrics.length; const avgImportance = allMetrics.reduce((sum, m) => sum + m.averageImportanceScore, 0) / allMetrics.length; const sessionsWithEmergency = allMetrics.filter(m => m.emergencyCompressionsCount > 0).length; return [ '=== 上下文压缩监控报告 ===', `活跃会话数: ${allMetrics.length}`, `平均压缩比: ${avgRatio.toFixed(2)}x`, `平均重要性评分: ${avgImportance.toFixed(2)}`, `紧急压缩次数: ${sessionsWithEmergency}`, sessionsWithEmergency > allMetrics.length * 0.1 ? `\n⚠️ ${sessionsWithEmergency}个会话触发紧急压缩——上下文窗口管理策略需要优化` : '', avgImportance < 0.5 ? `\n⚠️ 平均重要性偏低——大量低价值对话占窗口空间,建议加强过滤` : '', ].filter(Boolean).join('\n'); } private countTokens(text: string): number { const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length; const otherChars = text.length - chineseChars; return chineseChars * 2 + Math.ceil(otherChars / 4); } }

边界分析与架构权衡

滑动窗口大小 vs 压缩策略

20轮滑动窗口覆盖最近4000token。如果用户在最近20轮内密集讨论当前话题,4000token不够。

两种策略对比:

  • 大窗口(50轮):覆盖更多上下文,但留给工具输出的空间少。工具调用受限。
  • 小窗口(20轮)+摘要:窗口小但摘要补充旧话题。工具输出空间充足。

推荐策略:小窗口+摘要。20轮足以覆盖当前话题的完整讨论,旧话题通过摘要保留线索。工具输出需要大量空间(单次2000token),大窗口策略下工具空间不足。

工具结果摘要化的信息损失

工具返回2000token,摘要200token。10:1的压缩比意味着90%的信息被丢弃。

被丢弃的信息可能包含:错误详情、完整的响应体、调试信息。如果后续排查需要这些信息——它们已经不在上下文中了。

解决方案:原始内容存Redis,摘要放上下文。上下文只放摘要(200token),原始内容(2000token)存Redis。Agent需要原始数据时从Redis读取——不占上下文窗口。

代价:Agent需要额外的Redis查询步骤。但Redis查询耗时<1ms,远比LLM推理耗时(100ms+)小。

重要性评分的准确性

关键词匹配的重要性评分准确率约70%。30%的误评分:

  • "好的,按这个方案做"被误判为低重要性(简单确认)。实际包含决策确认——应该高重要性。
  • "看一下数据库的状态"被误判为中等重要性(纯提问)。但如果这个提问导致发现了关键问题——应该高重要性。

改进方向:事后重评分。当Agent基于某个轮次的信息做出重要决策时,回溯标记该轮次为高重要性。重要性不是固定值——是动态更新的。

// 事后重评分:Agent做出决策后标记相关历史轮次 async retroactivelyScore( sessionId: string, decisionTurnId: string, relatedHistoryTurnIds: string[] ): void { for (const turnId of relatedHistoryTurnIds) { const turn = await this.loadTurn(sessionId, turnId); if (turn) { turn.importanceScore = Math.max(turn.importanceScore, 0.85); await this.updateTurn(sessionId, turnId, turn); } } }

话题识别的局限

关键词匹配的话题识别准确率约80%。用户说"帮我部署一下"——话题=部署。但用户说"昨天部署的服务出问题了"——话题应该是故障排查而非部署。

关键词匹配无法理解语义。解决方案:用轻量LLM做话题分类——但每次分类增加500ms延迟和50token额外开销。在长对话中(每轮都分类)累积延迟和token开销不可忽略。

权衡:只在话题切换时分类,而非每轮分类。话题切换检测:当前轮的topic关键词与前一轮的topic关键词不同→触发LLM分类。减少分类频率从30次降到5次。

上下文布局与Lost-in-the-Middle效应

LLM对上下文中间位置的信息注意力最低。将重要信息放在中间是浪费。

实测数据:128K上下文中,开头信息的检索准确率95%,中间60%,结尾90%。

布局策略:

  • 开头(前5%):系统提示+用户记忆+关键决策历史。保证这些核心信息被LLM"看到"。
  • 中间(50%~90%):最近对话+旧话题摘要。这些信息重要性中等,放在注意力最低的区域损失可控。
  • 结尾(最后5%):当前用户输入。放在注意力高的区域确保LLM"理解"用户当前意图。

为什么不是"全部重要信息放开头":开头太多信息导致LLM的注意力被分散——10条关键决策信息堆在开头,LLM可能只记住前3条。适度分散比过度集中效果好。

紧急压缩的触发时机

不要等到上下文100%满了才触发紧急压缩——紧急压缩是降级策略,信息损失大。

触发时机:上下文使用率达到80%时触发标准压缩(摘要化+窗口调整)。90%时触发深度压缩(keywords_only级别)。95%时触发紧急压缩(只保留系统提示+最近3轮+当前输入)。

80%触发标准压缩给Agent足够的缓冲空间——压缩后使用率降到60%,后续20轮对话不触发压缩。90%触发深度压缩是警告信号——压缩后使用率降到50%,但信息损失较大。95%紧急压缩是最后防线——信息损失严重但保证Agent仍能工作。

总结

上下文窗口管理不是简单的"保留最近N轮"——是信息密度优化的系统性工程。核心设计:

  1. 滑动窗口20轮+重要性标记>0.8永久保留。20轮覆盖当前话题,高重要性轮次(决策、纠正、偏好声明)不因窗口滑动而丢失。
  2. 工具结果摘要化:2000token→200token(10:1压缩比)。只保留Agent实际使用的关键字段,原始数据存Redis供回溯。
  3. 旧话题摘要化:一句话总结每个旧话题的核心信息。token消耗极低(30token/话题),触发Agent的记忆关联而非提供完整上下文。
  4. 上下文布局优化:系统提示+记忆→开头(高注意力),最近对话→中间(低注意力但损失可控),当前输入→结尾(高注意力)。
  5. 紧急压缩三阶段:80%标准压缩,90%深度压缩,95%紧急压缩(只保留系统提示+最近3轮+当前输入)。
  6. 重要性评分动态更新:Agent做出决策后回溯标记相关历史轮次为高重要性。评分不是固定值。
  7. 原始数据存Redis,摘要放上下文。Agent需要完整数据时从Redis读取——不占窗口空间。

128K窗口不是无限的。不加管理的长对话必然填满窗口。信息密度优化让128K窗口有效覆盖50轮以上对话——关键是丢弃90%的工具冗余数据、保留10%的关键决策信息。

资料说明

本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论,不应视为行业事实。可参考 0731 资料来源索引,并在发布前将具体来源贴到对应断言之后。