ContextForge:基于Git历史与AST解析的代码仓库智能压缩工具
1. 项目概述与核心痛点每次让AI助手比如GPT-4、Claude分析一个稍具规模的代码仓库时你是不是也经常被高昂的API调用成本吓一跳或者更糟直接收到一个“上下文窗口已满”的错误提示这正是我最近在深度使用各类AI编程助手时遇到的核心痛点。一个中等体量的Python项目加上它的依赖库、测试文件和文档轻松就能吃掉十几万甚至几十万个Token。以GPT-4 Turbo的定价来算每1000个输入Token大约0.01美元一次处理15万个Token的请求成本就是1.5美元。这还只是一次对话如果进行多轮迭代或分析多个仓库这个开销会迅速变得不可忽视。更关键的是这些Token中很大一部分是“无效噪音”。比如node_modules文件夹、二进制文件、冗长的测试用例、变更日志和详细的文档字符串。对于AI理解代码结构和核心逻辑来说这些内容往往不是必需的但它们却实实在在地占用了宝贵的上下文窗口推高了成本有时甚至挤占了真正关键代码的空间。于是我动手写了一个叫ContextForge的Python命令行工具。它的目标很纯粹像一位经验丰富的代码审查员一样智能地“压缩”你的代码仓库只提炼出对AI理解项目最有价值的部分比如关键的文件结构、核心的函数签名和类定义同时果断地过滤掉那些“噪音”。结果如何呢在一个典型的案例中我将一个包含Pythonrequests库源码的仓库从154,061个Token压缩到了50,406个Token单次API调用成本从2.31美元降到了0.77美元节省了67%。这篇文章我就来详细拆解这个工具的构建思路、技术实现细节以及我在开发过程中踩过的坑和总结出的实用技巧。2. 核心设计思路与技术选型2.1 问题本质与解决路径首先我们需要明确“为AI压缩代码仓库”这个问题的本质。它不同于传统的代码压缩如去除空格、重命名变量也不同于简单的文件过滤。我们的目标是最大化信息密度即在有限的Token预算内为AI模型提供最能帮助它理解项目意图、结构和核心逻辑的信息。基于这个目标我梳理出了几条核心原则保留架构丢弃细节AI通常不需要完整的函数实现来理解一个函数是做什么的。函数签名名称、参数、返回类型和关键的文档字符串往往就足够了。具体的算法实现细节在初始理解阶段可以暂时忽略。识别并排除“上下文垃圾”测试文件、构建产物、第三方依赖库如node_modules,__pycache__、图片、二进制文件等几乎对理解项目核心业务逻辑没有帮助应优先排除。利用版本控制信息在Git管理的项目中文件的提交频率、最近修改时间、贡献者等信息是衡量文件重要性的一个非常可靠的启发式指标。频繁修改的核心源文件通常比多年未动的配置文件更重要。提供可配置的“预算”用户应该能设定一个目标Token数即“预算”工具在这个约束下智能地选择保留哪些内容是保留更多文件但只取签名还是保留更少文件但包含部分实现这需要一种策略。2.2 技术栈决策与理由基于以上思路我选择了以下技术栈来构建ContextForge每一选型都有其具体考量Python 3.10: 这是自然的选择。生态丰富快速原型开发能力强也是当前AI工具链的主流语言。使用3.10是为了确保能用上较新的语法特性和标准库改进。Typer: 用于构建命令行界面。相比传统的argparseTyper的API更现代、直观利用类型提示自动生成帮助文档能极大提升开发效率和用户体验。对于一个CLI工具来说友好的命令行交互至关重要。GitPython: 这是与Git仓库交互的基石库。我们需要它来遍历提交历史、分析文件变更、获取分支信息等。它提供了对Git底层命令的Pythonic封装稳定且功能全面。tiktoken: OpenAI官方开源的Token计数库。这是成本计算和预算控制的核心。不同模型GPT-3.5, GPT-4, Claude等的编码方式不同tiktoken能精确地计算文本字符串对应特定模型的Token数。没有它预算控制就无从谈起。Rich: 终端富文本渲染库。用来高亮显示输出、生成进度条、美化表格等。一个工具是否“好用”输出信息的可读性占了一半。Rich能让工具的运行过程和对结果的展示变得清晰、美观。Matplotlib (可选): 用于生成分析报告图表例如展示仓库内各类型文件的Token分布。这是一个增强功能帮助用户可视化地理解他们的仓库构成。这个技术栈兼顾了核心功能、开发效率和用户体验。整个项目在约2小时内完成初始版本也证明了这套选择的轻量与高效。注意tiktoken主要针对OpenAI模型。虽然Claude和Gemini的Token化方式不同例如Claude使用自定义的句子编码器但实践中用tiktoken特别是cl100k_base编码GPT-4使用计算的Token数作为一个近似的、可比较的“成本单位”仍然是有效的。更精确的多模型支持是未来的优化方向。3. 核心模块深度解析与实现3.1 仓库扫描与智能文件过滤这是整个流程的第一步目标是快速建立仓库的“地图”并剔除明显无关的内容。import os from pathlib import Path from typing import Set, List import git def scan_repository(repo_path: str) - List[Path]: 扫描仓库返回所有文件的Path对象列表。 repo_root Path(repo_path).resolve() all_files [] for root, dirs, files in os.walk(repo_root): # 排除常见的不需要扫描的目录 dirs[:] [d for d in dirs if not should_ignore_dir(d)] for file in files: file_path Path(root) / file if not should_ignore_file(file_path): all_files.append(file_path) return all_files def should_ignore_dir(dir_name: str) - bool: 判断目录是否应被忽略。 ignore_patterns { __pycache__, .git, node_modules, vendor, dist, build, .next, .nuxt, out, target, coverage } return dir_name in ignore_patterns or dir_name.startswith(.) def should_ignore_file(file_path: Path) - bool: 判断文件是否应被忽略。 ignore_extensions {.pyc, .so, .dll, .exe, .jpg, .png, .pdf, .zip} ignore_names {.DS_Store, Thumbs.db, package-lock.json, yarn.lock} if file_path.name in ignore_names: return True if file_path.suffix.lower() in ignore_extensions: return True # 可选根据文件大小忽略例如大于1MB的二进制文件 if file_path.stat().st_size 1024 * 1024: # 1MB # 可以尝试读取前几个字节判断是否为二进制 try: with open(file_path, tr) as f: f.read(1024) except UnicodeDecodeError: return True # 是二进制文件 return False实操心得dirs[:]的原地修改技巧在os.walk循环中直接修改dirs列表可以阻止os.walk继续遍历被忽略的目录大幅提升扫描效率。二进制文件判断仅靠扩展名判断不保险。上面代码中尝试用文本模式读取文件如果抛出UnicodeDecodeError则很可能是二进制文件。这是一种简单有效的启发式方法。.gitignore的集成一个更完善的实现应该读取项目自身的.gitignore文件规则并应用这些规则。这能确保工具与开发者日常的忽略习惯保持一致。可以使用pathspec库来解析和匹配.gitignore规则。3.2 基于Git历史的文件重要性分析这是ContextForge的“智能”所在。我们利用Git历史来量化一个文件的重要性。一个基本的启发式算法是近期提交越频繁、涉及行数越多的文件重要性越高。from datetime import datetime, timedelta from collections import defaultdict import git def analyze_git_importance(repo_path: str, file_paths: List[Path]) - dict: 分析Git历史为每个文件计算一个重要性分数。 repo git.Repo(repo_path) importance_scores defaultdict(float) # 设置时间窗口例如只分析最近6个月的提交 since_date datetime.now() - timedelta(days180) for commit in repo.iter_commits(sincesince_date): # 获取本次提交修改的文件列表 try: modified_files commit.stats.files.keys() except: continue for file in modified_files: abs_file_path Path(repo_path) / file if abs_file_path in file_paths: # 基础分数每次提交1分 importance_scores[abs_file_path] 1.0 # 可选根据提交的增减行数加权 # stats commit.stats.files.get(file) # if stats: # importance_scores[abs_file_path] (stats[insertions] stats[deletions]) / 100.0 # 归一化分数到0-1范围可选 if importance_scores: max_score max(importance_scores.values()) for file in importance_scores: importance_scores[file] / max_score return importance_scores为什么这样做频率一个经常被修改的文件很可能是核心业务逻辑文件或活跃开发的模块。近期性只关注近期提交如180天内可以避免一个多年前活跃但现在已废弃的文件获得高分。行数变化修改行数多可能意味着重构或重大特性添加进一步提示其重要性。注意事项对于新创建的文件还没有历史这个算法会给出0分。需要有一个兜底策略例如给所有文件一个基础分或者结合文件路径如src/下的文件比docs/下的默认分高。遍历大量提交可能较慢。对于超大型历史可以限制分析的提交数量例如最近1000次提交。合并提交merge commit可能会重复计算文件修改。需要根据实际情况处理有时需要排除合并提交。3.3 代码内容提炼从实现到签名对于保留下来的源代码文件我们并不需要全部内容。对于AI理解项目结构函数和类的签名名称、参数、返回类型以及紧邻的文档字符串通常信息量最大。import ast from typing import Tuple def extract_code_essentials(file_content: str) - Tuple[str, int]: 从源代码中提取核心要素导入语句、函数/类签名及文档字符串。 返回提炼后的文本和原始文本的Token数估算。 try: tree ast.parse(file_content) except SyntaxError: # 如果不是有效的Python语法返回原始内容可能是配置文件等 return file_content, estimate_tokens(file_content) essential_lines [] # 1. 保留文件顶层的模块文档字符串 if ast.get_docstring(tree): essential_lines.append(f\\\{ast.get_docstring(tree)}\\\) # 2. 提取所有导入语句 for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): essential_lines.append(ast.unparse(node)) # 3. 提取函数和类定义仅签名和文档字符串 for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): sig ast.unparse(node.args) if hasattr(node, args) else () return_annotation f - {ast.unparse(node.returns)} if node.returns else func_def fdef {node.name}{sig}{return_annotation}: essential_lines.append(func_def) if ast.get_docstring(node): essential_lines.append(f \\\{ast.get_docstring(node)}\\\) essential_lines.append( ... # Implementation omitted for brevity\n) elif isinstance(node, ast.ClassDef): class_def fclass {node.name}: essential_lines.append(class_def) if ast.get_docstring(node): essential_lines.append(f \\\{ast.get_docstring(node)}\\\) essential_lines.append( ... # Class body omitted for brevity\n) # 如果没有提取到结构可能是纯脚本或配置文件则返回前N行作为摘要 if not essential_lines: lines file_content.splitlines() preview \n.join(lines[:20]) # 预览前20行 return preview, estimate_tokens(file_content) essential_content \n.join(essential_lines) return essential_content, estimate_tokens(file_content)核心逻辑解析使用ast模块Python的ast抽象语法树模块可以无损地解析代码结构比正则表达式更准确、更健壮。分层提取模块级文档帮助理解整个文件的用途。导入语句揭示了文件的依赖关系是理解项目模块结构的关键。函数/类签名ast.unparse可以将AST节点转回源代码完美地还原出格式良好的签名包括类型注解。内联文档字符串紧跟在定义后的doc是理解该单元功能的最直接文本。占位符用...或注释# Implementation omitted代替被省略的函数体或类体明确告诉AI这部分内容被有意简化了。针对其他语言的思考 对于JavaScript/TypeScript、Go、Java等语言需要寻找对应的AST解析库如esprimafor JS,go/astpackage for Go。核心思想是一致的解析语法树提取声明性节点函数声明、类声明、接口、类型别名等及其关联的注释。3.4 预算感知的内容选择策略当所有文件的重要性分数和提炼后的内容Token数都计算出来后我们就面临一个优化问题在给定的Token预算内选择哪些文件或文件的哪些部分能最大化AI可理解的信息量一个简单有效的贪心算法如下def select_files_within_budget(files_info: List[dict], budget: int) - List[dict]: 根据文件的重要性分数和所需Token数在预算内选择文件。 files_info: 每个元素是 {path: Path, importance: float, tokens: int, content: str} budget: 目标Token数 # 按“重要性密度”重要性/Token数降序排序 # 这个指标衡量每消耗一个Token能带来多少重要性价值 for info in files_info: info[score] info[importance] / info[tokens] if info[tokens] 0 else 0 sorted_files sorted(files_info, keylambda x: x[score], reverseTrue) selected [] used_tokens 0 for file_info in sorted_files: if used_tokens file_info[tokens] budget: selected.append(file_info) used_tokens file_info[tokens] else: # 如果预算不足以放入整个文件可以考虑只放入文件的一部分如仅函数签名 # 这里简化处理直接跳过 pass # 确保至少包含最重要的几个文件即使超一点预算 if not selected and sorted_files: selected [sorted_files[0]] used_tokens sorted_files[0][tokens] return selected, used_tokens策略解读重要性密度排序这是关键。一个文件可能非常重要importance0.9但如果它需要5000个Token其“密度”只有0.00018。另一个文件重要性一般importance0.4但只需500个Token密度是0.0008后者在预算有限时可能是更优选择。贪心选择从密度最高的文件开始拿直到预算用完。这在大多数情况下能得到一个不错的近似解。兜底机制如果预算极低连密度最高的一个文件都放不下算法会强制放入最重要的文件确保输出不为空。更高级的策略可以在这里介入比如只放入该文件的摘要或最关键的部分。4. 完整工作流与实战示例让我们以一个具体的例子走一遍ContextForge的完整工作流程。假设我们有一个名为my_project的Python网络爬虫项目。步骤1初始化与扫描在终端中运行contextforge forge ./my_project --budget 50000 --model gpt-4forge: 主命令意为“锻造”或“压缩”。./my_project: 目标仓库路径。--budget 50000: 目标输出不超过5万个Token。--model gpt-4: 指定模型用于选择正确的Tokenizertiktoken。工具开始工作扫描./my_project目录忽略.git,__pycache__,node_modules,*.pyc等。识别出src/,tests/,docs/,requirements.txt等。步骤2分析与提炼对每个非忽略文件调用extract_code_essentials。src/spider.py(完整实现200行)提取出class Spider:和def fetch(url):等签名以及它们的docstring。内容从200行缩减到30行。tests/test_spider.py(150行)识别为测试文件文件名含test_重要性分数被显著调低例如乘以0.1的系数。requirements.txt(10行)全文保留因为依赖列表很重要且很短。docs/api.md(300行Markdown)由于是文档可能被赋予中等重要性但内容会被完整保留或摘要保留取决于预算。同时analyze_git_importance会分析过去6个月的提交发现src/spider.py被修改了15次而docs/api.md只修改了2次。因此spider.py获得更高的重要性分数。步骤3预算分配与输出假设扫描后共有100个文件总原始Token数为12万。经过提炼总Token数降至8万。但我们的预算是5万。计算每个提炼后文件的重要性分数 Token数。按重要性/Token数排序。从高到低选取文件直到总Token数接近5万。很可能tests/目录下的大部分文件因为重要性密度低而被排除。src/核心文件、关键的配置文件和精简后的文档被保留。步骤4生成优化包最终工具会生成一个结构清晰的文本文件或目录例如my_project_optimized_for_ai/ ├── SUMMARY.md # 项目概述、压缩统计信息、文件列表 ├── src/ │ ├── spider.py.sig # 仅包含签名的核心模块 │ └── utils.py.sig ├── configs/ │ └── settings.yaml # 完整的配置文件通常很小 └── docs/ └── API_OVERVIEW.md # 关键文档的摘要这个包可以直接粘贴到AI助手的对话中或者保存下来供后续使用。5. 常见问题、优化方向与避坑指南在实际开发和测试中我遇到了不少典型问题也总结出一些优化思路。5.1 典型问题与解决方案问题现象可能原因解决方案压缩后AI无法理解项目结构过滤太激进丢失了关键文件如__init__.py, 路由配置文件或依赖关系。1.白名单机制强制包含某些特定模式的文件如*/__init__.py,*config*.py,*settings*.py。2.依赖分析通过import语句分析文件间依赖如果文件A被许多重要文件导入即使A本身历史不长也应提升其重要性。Token数计算与API实际消耗有偏差1. 使用的编码器与目标模型不符。2. 忽略了AI系统提示System Prompt和用户指令本身的Token消耗。1.精确匹配编码器为--model参数映射到正确的tiktoken编码器名称如gpt-4-cl100k_base。2.预留预算在用户设定的预算中自动扣除一个固定比例如10%作为系统和指令的预留。处理大型仓库速度慢1. 遍历所有文件IO开销大。2. Git历史分析遍历全部提交。1.并行处理使用concurrent.futures对文件分析和Git分析进行并行化。2.限制Git深度--git-depth参数限制分析的提交数量或只分析特定分支。对非Python项目支持弱AST解析逻辑只针对Python。1.插件化架构为不同语言定义LanguageProcessor接口实现extract_essentials方法。2.通用回退对于不支持的语言返回文件的前N行和后N行作为内容预览。5.2 高级优化方向基于嵌入的语义重要性排序当前基于Git频率的启发式方法有时会失灵例如一个重要的配置文件很少被修改。可以尝试用一个小型的句子Transformer模型如all-MiniLM-L6-v2为每个文件的摘要生成嵌入向量并与项目描述或核心文件进行相似度计算从语义层面评估重要性。增量更新与缓存如果用户多次压缩同一个仓库比如每天可以缓存Git分析结果和文件AST解析结果。只检查是否有文件被修改只更新变化的部分极大提升后续运行速度。交互式模式提供一个交互式CLI在最终输出前向用户展示文件列表、重要性排名和预估Token消耗允许用户手动勾选或排除特定文件实现“半自动”的精准控制。输出格式多样化除了纯文本可以支持输出为Markdown、JSON甚至是自定义的结构化格式方便集成到其他AI工作流或工具中。5.3 避坑心得不要过度信任自动过滤自动工具总会犯错。务必在第一次对某个重要仓库使用后人工检查输出结果看看有没有被误删的关键文件比如一个小巧但至关重要的工具脚本。建立手动包含/排除列表的功能非常有用。“测试文件”的定义是模糊的仅靠test_前缀判断测试文件不够。有些项目用spec.js有些集成测试可能在integration/目录。最好提供配置选项让用户自定义测试文件的模式。Token计数是近似值尤其是对于非OpenAI模型。将ContextForge的输出用于生产环境前先用目标模型的API实际测试一下对比工具预估的Token数和API返回的usage.prompt_tokens并据此调整预算策略。处理二进制和大型文件的策略对于图片、PDF等与其尝试处理内容不如记录其元数据路径、大小、类型并生成一个描述性的文本占位符例如[Image: diagram.png, size: 200KB]。这告诉AI文件的存在又不浪费Token。构建ContextForge的过程本质上是对“如何高效地向AI表达一个复杂项目”的一次工程化探索。它不是一个完美的解决方案但确实提供了一个切实可行的思路和工具将每次与AI助手关于代码的对话成本降低了可观的幅度。在AI辅助编程日益普及的今天这类提升“人机对话”效率的工具其价值会越来越凸显。