AI编程代理上下文压缩:突破大模型Token限制的智能摘要方案
2026/8/5 3:29:27 网站建设 项目流程

1. 项目概述:当AI编程代理的“记忆”不堪重负

最近在折腾各种AI编程助手和代理(Agent),比如让它们帮我重构代码库或者分析一个复杂的项目。一个绕不开的坎很快就出现了:上下文长度限制。无论是使用云端大模型的API,还是部署本地的开源模型,那个宝贵的上下文窗口(比如4K、8K、16K甚至128K tokens)总感觉不够用。当你把整个项目的源代码、文档、依赖关系图一股脑儿塞给AI时,很容易就触顶了,导致后续的对话质量急剧下降,AI开始“胡言乱语”或者干脆忘记了之前讨论过的关键约束。

这背后的核心问题就是“上下文管理”。我们人类在解决复杂问题时,也不会在脑子里同时加载所有细节,而是依靠笔记、摘要和聚焦关键信息。对于AI编程代理来说,context-mode(上下文模式)就是它的工作记忆管理策略。而“压缩上下文”,本质上就是为AI代理打造一个高效的“记忆摘要”系统,让它能在有限的“脑容量”里,记住最相关、最重要的信息,从而持续、稳定地输出高质量的代码和建议。

这个需求在TypeScript/Node.js这类现代JavaScript生态中尤为突出。一个中等规模的Node.js后端服务,加上其node_modules,文件数量轻松破万。更别提现在流行的Monorepo项目了。直接把这些全喂给AI是不现实的。因此,我们需要一套智能的机制,在AI代理运行过程中,动态地决定:哪些文件是当前任务必须的?哪些代码片段是关键函数?哪些依赖信息可以摘要?这就是context-mode要解决的问题。

2. 核心思路:从“全量转储”到“智能摘要”

传统的、简单粗暴的上下文使用方式,我称之为“全量转储”模式。比如,你想让AI修复一个Bug,你的指令可能是:“这是server.ts的代码,这是userModel.ts的代码,这是相关的package.json……请修复。” 这种模式有几个致命缺点:

  1. 令牌浪费:大量与当前任务弱相关的代码占用了宝贵空间。
  2. 信息过载:AI难以从海量文本中精准定位核心问题。
  3. 成本高昂:对于按Token计费的API,每一次交互都在烧钱。

context-mode的压缩思路,就是要转向“智能摘要”模式。它的目标不是传输所有原始数据,而是传输数据的“精华”和“索引”。具体来说,可以分解为以下几个层次:

2.1 静态分析与依赖图谱构建

在任务开始前,先对目标代码库进行一次静态分析。这不仅仅是简单的文件列表,而是构建一个轻量级的项目图谱。

  • 文件关系图:通过解析import/require语句,建立文件之间的依赖关系。知道auth.ts引用了user.ts,那么在处理认证逻辑时,user.ts的相关部分优先级就应该提高。
  • 符号(Symbol)提取:提取每个文件中的关键符号——类名、函数名、接口名、类型别名。这构成了一个项目的“词汇表”。
  • 抽象语法树(AST)轻量级遍历:不需要完整的AST分析,但可以快速识别函数签名、类定义、接口结构,为后续的代码块分割和摘要做准备。

这个图谱是后续所有压缩决策的基础。它回答了“项目里有什么”以及“它们之间如何关联”的问题。

2.2 动态上下文窗口管理

AI代理在执行任务时,其上下文窗口应该是一个动态的、有状态的队列,而不是一个静态的文本块。我们可以借鉴操作系统的内存分页或缓存置换算法(如LRU)的思想。

  • 核心上下文(Working Set):始终保留与当前执行步骤最相关的代码片段。例如,AI正在编辑login函数,那么login函数所在的文件、它直接调用的几个辅助函数的代码,就应该留在窗口内。
  • 历史上下文摘要:对于已经被移出核心窗口的旧对话或已处理过的代码,不再保留全文,而是保留一个“摘要”。这个摘要可以是:
    • 自然语言摘要:用一两句话描述之前讨论过什么、决定了什么。
    • 关键决策点:记录下已经做出的技术选择,比如“决定使用bcrypt而不是argon2进行密码哈希,原因是依赖更简单”。
    • 代码变更摘要:对于AI已经修改过的文件,记录下修改的意图和位置,而不是把修改后的整个文件再塞回去。
  • 外部引用索引:对于像node_modules中的第三方库代码,绝对不应该直接包含。取而代之的是,建立一个“知识库”或“索引”。当AI需要知道express.Request的类型定义时,它可以通过一个查询接口(比如RAG检索)获取到相关的类型签名片段,而不是把整个@types/express包都加载进来。

2.3 分层压缩策略

针对不同类型的内容,采用不同的压缩粒度:

  1. 全量保留:仅针对当前焦点文件(正在编辑的文件)的核心部分。
  2. 签名保留:对于非焦点但相关的文件,只保留函数/类/接口的签名(名称、参数、返回类型),省略函数体实现。这能让AI知道有什么“工具”可用,而不必关心其内部细节。
  3. 路径引用:对于更边缘的文件,只保留文件路径,并在需要时告诉AI:“如果需要utils/logger.ts的详细内容,请提出请求。” 这实现了按需加载。
  4. 自然语言描述:对于复杂的业务逻辑或已经讨论过的架构,用一段简洁的描述替代大段代码。

3. 技术实现方案拆解

理论说完了,我们来点实际的。如何用TypeScript/Node.js实现这样一个context-mode压缩系统?下面是一个可落地的架构设计。

3.1 项目分析与图谱构建模块

这个模块是离线预处理的,只需要在代理启动时或项目变更时运行一次。

// 示例:使用 TypeScript 编译器 API 进行轻量级分析 import * as ts from 'typescript'; import * as fs from 'fs/promises'; import * as path from 'path'; interface ProjectGraphNode { filePath: string; symbols: string[]; // 导出的符号名 dependencies: string[]; // 依赖的其他项目文件路径 exports?: string; // 主要导出内容的摘要 } export class ProjectAnalyzer { private program: ts.Program; private graph: Map<string, ProjectGraphNode> = new Map(); constructor(private tsConfigPath: string) {} async buildGraph(): Promise<Map<string, ProjectGraphNode>> { const configFile = ts.readConfigFile(this.tsConfigPath, (p) => fs.readFile(p, 'utf-8') ); const parsedConfig = ts.parseJsonConfigFileContent( configFile.config, ts.sys, path.dirname(this.tsConfigPath) ); this.program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options); for (const sourceFile of this.program.getSourceFiles()) { if (sourceFile.isDeclarationFile) continue; // 跳过.d.ts文件 const filePath = sourceFile.fileName; const symbols: string[] = []; const dependencies: string[] = []; // 遍历AST,收集导出符号和导入依赖 ts.forEachChild(sourceFile, (node) => { // 收集导出声明 if (ts.isExportDeclaration(node)) { // 处理导出 } if (ts.isFunctionDeclaration(node) && node.name) { symbols.push(node.name.text); } if (ts.isClassDeclaration(node) && node.name) { symbols.push(node.name.text); } // 收集导入声明 if (ts.isImportDeclaration(node)) { const moduleSpecifier = node.moduleSpecifier.getText(sourceFile); // 简单处理,实际需要解析为项目内路径 if (moduleSpecifier.startsWith('.')) { dependencies.push(moduleSpecifier); } } }); // 生成一个非常简单的摘要:文件的前50个字符(通常是注释或导出语句) const content = sourceFile.getFullText(); const excerpt = content.substring(0, 50).replace(/\n/g, ' '); this.graph.set(filePath, { filePath, symbols, dependencies, exports: excerpt, }); } return this.graph; } // 根据当前文件,找到相关度高的文件 getRelevantFiles(focusFilePath: string, depth: number = 2): string[] { const visited = new Set<string>(); const queue: { path: string; level: number }[] = [ { path: focusFilePath, level: 0 }, ]; const relevant: string[] = []; while (queue.length > 0) { const { path, level } = queue.shift()!; if (visited.has(path) || level > depth) continue; visited.add(path); relevant.push(path); const node = this.graph.get(path); if (node) { for (const dep of node.dependencies) { // 需要将相对路径解析为绝对路径 const absDep = path.resolve(path.dirname(path), dep); if (this.graph.has(absDep)) { queue.push({ path: absDep, level: level + 1 }); } } } } return relevant; } }

注意:这是一个高度简化的示例。生产环境需要考虑路径解析、循环依赖、动态导入、忽略node_modules等复杂情况。可以使用更成熟的工具如ts-morph来简化AST操作。

3.2 上下文管理器与压缩策略

这是系统的核心,负责维护当前的上下文状态,并应用压缩规则。

interface ContextChunk { id: string; type: 'code' | 'summary' | 'instruction' | 'output'; content: string; priority: number; // 优先级,用于置换决策 metadata: { filePath?: string; symbol?: string; timestamp: number; }; } export class ContextManager { private chunks: ContextChunk[] = []; private maxTokens: number; private tokenEstimator: (text: string) => number; // 一个估算token的函数 constructor(maxTokens: number) { this.maxTokens = maxTokens; // 可以使用简单的启发式方法:1个token ≈ 4个英文字符或0.75个单词 this.tokenEstimator = (text) => Math.ceil(text.length / 4); } // 添加上下文块 addChunk(chunk: Omit<ContextChunk, 'id' | 'metadata.timestamp'>): void { const fullChunk: ContextChunk = { ...chunk, id: `chunk_${Date.now()}_${Math.random()}`, metadata: { ...chunk.metadata, timestamp: Date.now(), }, }; this.chunks.push(fullChunk); this.compressContext(); } // 核心压缩逻辑 private compressContext(): void { let totalTokens = this.chunks.reduce( (sum, chunk) => sum + this.tokenEstimator(chunk.content), 0 ); // 如果未超限,直接返回 if (totalTokens <= this.maxTokens) { return; } // 按优先级排序,优先级低的先被考虑压缩或移除 this.chunks.sort((a, b) => a.priority - b.priority); const compressedChunks: ContextChunk[] = []; for (const chunk of this.chunks) { if (chunk.type === 'code' && chunk.metadata.filePath) { // 对代码块进行压缩:只保留签名 const compressedCode = this.compressCodeChunk(chunk); const newTokenCount = this.tokenEstimator(compressedCode); if (totalTokens - this.tokenEstimator(chunk.content) + newTokenCount <= this.maxTokens) { chunk.content = compressedCode; totalTokens = totalTokens - this.tokenEstimator(chunk.content) + newTokenCount; compressedChunks.push(chunk); } else { // 即使压缩后还是放不下,则移除,并可能添加一个路径引用摘要 compressedChunks.push(this.createPathReferenceChunk(chunk)); totalTokens -= this.tokenEstimator(chunk.content); // 添加路径引用摘要的token totalTokens += this.tokenEstimator(compressedChunks[compressedChunks.length-1].content); } } else if (chunk.type === 'output' || chunk.type === 'instruction') { // 对旧的输出或指令进行摘要 if (chunk.metadata.timestamp < Date.now() - 5 * 60 * 1000) { // 5分钟前的 const summary = this.summarizeTextChunk(chunk); chunk.content = summary; chunk.type = 'summary'; } compressedChunks.push(chunk); } else { compressedChunks.push(chunk); } // 重新计算总tokens,如果满足条件则提前退出循环 const currentTotal = compressedChunks.reduce( (sum, c) => sum + this.tokenEstimator(c.content), 0 ); if (currentTotal <= this.maxTokens * 0.9) { // 留10%余量,把剩下的高优先级chunk加回来 break; } } this.chunks = compressedChunks; } private compressCodeChunk(chunk: ContextChunk): string { // 简易实现:尝试提取函数/类签名 const content = chunk.content; // 这是一个非常简单的正则示例,生产环境应用AST解析 const functionSignatureMatch = content.match( /(export\s+)?(async\s+)?function\s+\w+\s*\([^)]*\)\s*(:\s*\w+)?/g ); const classSignatureMatch = content.match( /(export\s+)?class\s+\w+(\s+extends\s+\w+)?(\s+implements\s+[^{]+)?/g ); let compressed = `// File: ${chunk.metadata.filePath}\n`; if (functionSignatureMatch) { compressed += `// Functions:\n${functionSignatureMatch.join('\n')}\n`; } if (classSignatureMatch) { compressed += `// Classes:\n${classSignatureMatch.join('\n')}\n`; } if (!functionSignatureMatch && !classSignatureMatch) { compressed += `// Content omitted for brevity. Full path: ${chunk.metadata.filePath}`; } return compressed; } private createPathReferenceChunk(originalChunk: ContextChunk): ContextChunk { return { id: `ref_${originalChunk.id}`, type: 'summary', content: `[Reference to code file: ${originalChunk.metadata.filePath}. Details omitted due to context limit. Request if needed.]`, priority: originalChunk.priority + 10, // 引用比完整代码优先级低 metadata: { timestamp: Date.now() }, }; } private summarizeTextChunk(chunk: ContextChunk): string { // 简易文本摘要:取前100个字符 return `[Previous context summary]: ${chunk.content.substring(0, 100)}...`; } // 获取当前压缩后的完整上下文 getContext(): string { return this.chunks .sort((a, b) => b.priority - a.priority) // 按优先级高到低排列 .map((chunk) => `--- ${chunk.type.toUpperCase()} ---\n${chunk.content}`) .join('\n\n'); } }

3.3 与AI代理的集成

最后,我们需要将这套上下文管理系统与AI代理(比如使用OpenAI API、Claude API或本地LLM)集成。代理的工作流程将变为:

  1. 接收任务:例如,“在src/auth/login.ts中添加JWT刷新令牌功能”。
  2. 初始上下文加载ContextManager通过ProjectAnalyzer获取login.ts及其相关文件(如user.ts,jwt.ts),加载它们的完整内容作为高优先级初始块。
  3. 交互循环
    • 代理根据当前完整上下文生成回答(代码建议、解释等)。
    • 代理的输出被作为一个新的ContextChunk(type: ‘output’) 添加到管理器。
    • 如果代理在回答中引用了新文件(例如,“参考config.ts中的密钥设置”),系统可以自动将config.ts签名摘要作为中优先级块加入上下文。
    • ContextManager在每次添加后自动运行compressContext(),确保总token数不超过限制。
  4. 持续维护:随着对话进行,最早的、优先级低的代码块会被压缩成签名或路径引用,最新的指令、代码变更和关键输出始终保持在上下文中。
// 简化的代理集成示例 class AICodingAgent { private contextManager: ContextManager; private projectAnalyzer: ProjectAnalyzer; private llmClient: any; // 你的LLM客户端 async processTask(task: string, entryFile: string) { // 1. 分析项目,获取相关文件 const relevantFiles = this.projectAnalyzer.getRelevantFiles(entryFile); for (const file of relevantFiles) { const content = await fs.readFile(file, 'utf-8'); this.contextManager.addChunk({ type: 'code', content, priority: file === entryFile ? 100 : 70, // 入口文件优先级最高 metadata: { filePath: file }, }); } // 2. 添加任务指令 this.contextManager.addChunk({ type: 'instruction', content: `Task: ${task}`, priority: 90, metadata: {}, }); // 3. 开始交互循环 let conversationActive = true; while (conversationActive) { const currentContext = this.contextManager.getContext(); const prompt = `${currentContext}\n\nAssistant:`; const response = await this.llmClient.complete(prompt); // ... 处理响应,例如提取代码块、解析建议 // 4. 将AI响应加入上下文 this.contextManager.addChunk({ type: 'output', content: response, priority: 80, metadata: {}, }); // 判断任务是否完成... } } }

4. 实操要点与避坑指南

在实际实现和应用这套系统时,我踩过不少坑,这里分享几个关键点:

4.1 优先级策略的设计

priority字段是压缩算法的关键。设计不当会导致关键信息被过早丢弃。我的经验是:

  • 当前焦点:用户明确指定的文件、AI正在编辑的函数所在的文件,优先级最高(例如95-100)。
  • 最新输出:AI最近一次的回答,优先级高(80-90),因为它包含了最新的思路和决策。
  • 直接依赖:被焦点文件导入的文件,优先级中高(70-80)。
  • 间接依赖/历史代码:优先级中低(50-70)。
  • 系统指令/任务描述:虽然重要,但通常很简短,可以赋予高优先级(90),但不必担心被压缩,因为它本身就很短。
  • 路径引用/摘要:优先级最低(10-30)。

一个常见的错误是给所有“代码”类型统一的优先级。需要根据代码与当前任务的相关性进行动态调整。例如,当AI开始处理config.ts时,该文件的优先级应立即提升。

4.2 Token估算的准确性

示例中用了简单的字符长度除以4的方法,这对于英文比较粗略,对中文或其他语言不准确。更可靠的做法是:

  • 如果使用OpenAI API,可以直接调用其提供的tiktoken库进行精确计算。
  • 对于其他模型,寻找对应的tokenizer库,或者使用一个保守的估计系数(如中文1个token≈2个字符)。
  • 一定要预留缓冲:不要将maxTokens用到100%。我通常预留15-20%的空间,以防止估算误差和模型输出不可预测的长度。例如,API限制是8000token,我的上下文管理器目标就设在6500token左右。

4.3 代码压缩的保真度

compressCodeChunk函数中的正则表达式方法非常脆弱。它无法正确处理嵌套括号、泛型、装饰器等复杂语法。

  • 强依赖AST:生产环境必须使用TypeScript编译器API(ts)或ts-morph这样的包装库来可靠地提取函数/类签名。这能确保提取出的“签名”在语法上是完整的,可以被AI正确理解。
  • 保留关键注释:在压缩时,可以考虑保留函数上方的JSDoc注释或重要的单行注释,这些注释往往包含了参数说明和业务逻辑要点,对AI理解代码意图至关重要。
  • 处理未导出的内部函数:对于当前焦点文件,有时未导出的内部函数也很重要。压缩策略可能需要为“焦点文件”和“非焦点文件”设置不同的规则:焦点文件保留更多内部细节,非焦点文件只保留导出接口。

4.4 与向量检索(RAG)的结合

对于超大型代码库,即使经过压缩,项目图谱也可能太大。这时可以将向量检索引入系统。

  • 建立代码向量库:将每个函数、类或模块的代码和其文档字符串嵌入成向量。
  • 动态检索:当AI代理需要了解某个特定概念或寻找类似功能时,ContextManager不是直接加载可能相关的文件,而是将当前问题或上下文作为查询,从向量库中检索出最相关的几个代码片段,然后将这些片段作为高优先级上下文块插入。
  • 混合模式:对于明确的、强依赖的文件(通过静态分析得出),使用路径加载;对于模糊的、探索性的需求,使用向量检索。这种混合模式能极大提高上下文利用的智能度。

5. 常见问题与排查技巧

在开发和调试上下文压缩系统时,你可能会遇到以下典型问题:

5.1 AI开始“胡言乱语”或循环输出

这是上下文管理失效的最典型标志。

  • 排查步骤
    1. 检查上下文长度:在每次调用AI前,输出当前ContextManager估算的token数和getContext()的最后500个字符。看看是不是已经爆了,或者末尾被塞入了大量无意义的压缩摘要。
    2. 审查压缩输出:检查被压缩成// File: ...[Reference ...]的块是否过多。如果核心逻辑代码都被替换成了引用,AI自然失去了工作依据。需要调整优先级算法,确保核心文件的完整代码不被过度压缩。
    3. 检查摘要质量summarizeTextChunk生成的摘要是否过于模糊,丢失了关键决策信息?考虑改进摘要算法,或者对于重要的决策点,强制保留为instruction类型并赋予高优先级,禁止被摘要。

5.2 性能瓶颈:项目分析耗时过长

对于巨型Monorepo,首次构建项目图谱可能很慢。

  • 优化策略
    1. 增量分析:监听文件系统变化,只重新分析变更的文件及其影响边界。
    2. 缓存图谱:将构建好的项目图谱序列化到磁盘。只有当package.jsontsconfig.json等关键文件改变时,才触发全量重建。
    3. 使用更快的工具ts-morph在易用性和性能之间取得了很好的平衡,比直接使用底层的TypeScript编译器API更方便,且其底层缓存机制能提升性能。

5.3 依赖解析错误,导致相关文件未加载

静态分析可能误判文件间的依赖关系。

  • 调试方法
    1. 可视化图谱:实现一个简单的函数,将ProjectGraph输出为DOT格式,用Graphviz生成依赖图。直观检查login.ts是否真的连接到了你认为它应该依赖的jwt.ts
    2. 处理动态导入import(‘./’ + moduleName)这类动态导入,静态分析无法处理。需要在ContextManager中建立一种“回退机制”:当AI在对话中明确提及某个模块时,即使图谱中没有,也尝试去加载它。
    3. 路径别名(Path Aliases):如果项目配置了@/之类的路径别名,必须在静态分析阶段就通过tsconfig.jsoncompilerOptions.paths配置正确解析,否则所有依赖分析都会失效。

5.4 与特定AI模型或API的兼容性问题

不同模型对上下文格式的敏感度不同。

  • 经验
    • 结构化分隔符:像--- CODE ---这样的分隔符对大多数模型都有效,能帮助它区分上下文的不同部分。但有些模型可能对特定的标记格式有偏好。
    • 指令位置:有些模型对系统指令或任务描述在上下文中的位置很敏感。通常放在最开头是安全的。确保你的instruction类型块有足够高的优先级,不会被挤到后面去。
    • 测试、测试、再测试:用一组标准任务(如“添加一个函数”、“修复一个已知Bug”)来测试不同的压缩策略和参数,观察哪种组合下AI的输出最稳定、最准确。这需要积累大量的测试案例。

实现一个智能的context-mode压缩系统,初期投入的精力不小,但它带来的回报是巨大的:更长的有效对话轮次、更低的API调用成本、以及更专注和准确的AI辅助编程体验。它迫使你更深入地思考项目结构和代码组织,这本身也是一个很好的编程实践。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询