代码智能体真正需要的通常不是整个仓库,而是与当前任务有关的一小组文件。本文用 Node.js 实现一条依赖少、可直接运行的裁剪流水线,并通过相关文件命中率与任务成功率检查裁剪是否过度。
上下文为什么会失控
最直接的代码智能体实现,往往会遍历仓库,把所有源码拼接后交给模型。它在小型示例中可以工作,进入真实项目后却会遇到三个问题:无关文件消耗上下文窗口;重复的配置、生成代码和测试夹具稀释注意力;仓库增长后,请求成本和延迟也随之增加。
另一种极端是只发送用户点名的文件。这虽然节省 Token,却容易遗漏被导入的类型、工具函数和配置。模型看到调用点却看不到实现,可能转而猜测接口。
因此,裁剪器的目标不是“文件越少越好”,而是选择完成任务所需的最小上下文。本文采用四阶段流水线:
- 建立可搜索的源码文件索引;
- 根据任务描述计算文件相关性;
- 从高分文件向外扩展本地依赖;
- 在预算内按优先级装入文件。
它不是语义检索的替代品,而是一个透明、可调试的基线。仓库较大或任务描述较抽象时,可以把排序阶段替换为向量检索,后续依赖扩展和预算选择仍然适用。
裁剪流水线的边界设计
下面的实现只使用 Node.js 内置模块,适合 JavaScript、TypeScript 和 JSON 仓库。它跳过常见产物目录以及超过 256 KiB 的文件,避免索引压缩包、构建产物或超大数据文件。
| 阶段 | 输入 | 输出 | 主要风险 |
|---|---|---|---|
| 文件索引 | 仓库目录 | 路径、内容、词项 | 把生成文件纳入索引 |
| 相关性排序 | 任务描述 | 文件分数 | 同义词或抽象需求难匹配 |
| 依赖扩展 | 高分文件 | 本地直接依赖 | 正则无法完整解析复杂语法 |
| 预算选择 | 候选文件 | 最终上下文 | Token 估算与模型分词不一致 |
相关性采用简化的 TF-IDF:任务词项在某文件中出现越多,且在仓库中越少见,得分越高;路径匹配再获得少量加分。中文连续文本会拆成单字和双字词,英文、路径与标识符则按连续片段处理。
依赖扩展仅识别静态 import、export ... from、字符串形式的动态 import() 和 require(),并只解析相对路径。它不会假装自己是完整语法分析器:路径别名、条件导入和运行时拼接需要结合项目的 TypeScript 配置或 AST 工具补充。
Token 预算也采用保守代理值,而不是声称与某个模型完全一致。示例按 UTF-8 字节数除以 3 估算,并预留固定包装开销。生产环境应接入目标模型对应的 tokenizer,同时为系统提示、用户问题和模型输出单独留出空间。
用 Node.js 实现裁剪器
新建 context-pruner.mjs,写入以下完整代码。运行环境建议使用 Node.js 20 或更新版本。
import fs from 'node:fs/promises';
import path from 'node:path';
const extensions = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'];
const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next']);
const maxFileBytes = 256 * 1024;
const normalize = value => value.replace(/\\/g, '/');
function tokenize(text) {
const chunks = text.toLowerCase().match(/\p{Script=Han}+|[a-z0-9_./-]+/gu) ?? [];
const result = [];
for (const chunk of chunks) {
if (/^\p{Script=Han}+$/u.test(chunk)) {
result.push(...chunk);
for (let i = 0; i < chunk.length - 1; i++) result.push(chunk.slice(i, i + 2));
} else {
result.push(chunk);
}
}
return result;
}
async function walk(root, dir = root, output = []) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (ignored.has(entry.name)) continue;
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(root, absolute, output);
else if (extensions.includes(path.extname(entry.name))) output.push(absolute);
}
return output;
}
async function buildIndex(root) {
const documents = [];
for (const absolute of await walk(root)) {
const stat = await fs.stat(absolute);
if (stat.size > maxFileBytes) continue;
const content = await fs.readFile(absolute, 'utf8');
const relative = normalize(path.relative(root, absolute));
const terms = tokenize(relative + '\n' + content);
const tf = new Map();
for (const term of terms) tf.set(term, (tf.get(term) ?? 0) + 1);
documents.push({ absolute, relative, content, tf });
}
const df = new Map();
for (const document of documents) {
for (const term of document.tf.keys()) df.set(term, (df.get(term) ?? 0) + 1);
}
return { root, documents, df, byAbsolute: new Map(documents.map(d => [d.absolute, d])) };
}
function rank(index, query) {
const queryTerms = [...new Set(tokenize(query))];
return index.documents.map(document => {
let score = 0;
for (const term of queryTerms) {
const frequency = document.tf.get(term) ?? 0;
if (!frequency) continue;
const idf = Math.log((index.documents.length + 1) / ((index.df.get(term) ?? 0) + 1)) + 1;
score += Math.min(frequency, 3) * idf;
if (document.relative.toLowerCase().includes(term)) score += 2;
}
return { document, score };
}).filter(item => item.score > 0).sort((a, b) => b.score - a.score);
}
function importedSpecifiers(content) {
const values = [];
const re = /(?:import|export)\s+(?:[^'\x22]*?\s+from\s+)?['\x22]([^'\x22]+)['\x22]|require\(\s*['\x22]([^'\x22]+)['\x22]\s*\)|import\(\s*['\x22]([^'\x22]+)['\x22]\s*\)/g;
for (const match of content.matchAll(re)) values.push(match[1] ?? match[2] ?? match[3]);
return values;
}
function dependencies(index, document) {
const found = [];
for (const specifier of importedSpecifiers(document.content)) {
if (!specifier.startsWith('.')) continue;
const base = path.resolve(path.dirname(document.absolute), specifier);
const candidates = [
base,
...extensions.map(ext => base + ext),
...extensions.map(ext => path.join(base, 'index' + ext))
];
const dependency = candidates.map(file => index.byAbsolute.get(file)).find(Boolean);
if (dependency) found.push(dependency);
}
return found;
}
function render(document) {
return `--- file: ${document.relative} ---\n${document.content}\n`;
}
function estimatedTokens(text) {
return Math.ceil(Buffer.byteLength(text, 'utf8') / 3) + 8;
}
function select(index, query, budget) {
const ranked = rank(index, query);
const selected = [];
const seen = new Set();
let used = 0;
function add(document, reason, score = 0) {
if (seen.has(document.absolute)) return true;
const cost = estimatedTokens(render(document));
if (used + cost > budget) return false;
seen.add(document.absolute);
selected.push({ document, reason, score, cost });
used += cost;
return true;
}
for (const item of ranked) {
if (!add(item.document, 'ranked', item.score)) continue;
for (const dependency of dependencies(index, item.document)) {
add(dependency, `dependency:${item.document.relative}`);
}
}
return {
used,
selected,
context: selected.map(item => render(item.document)).join('\n')
};
}
async function evaluate(index, file, budget) {
const cases = JSON.parse(await fs.readFile(file, 'utf8'));
const details = cases.map(test => {
const result = select(index, test.query, budget);
const paths = new Set(result.selected.map(item => item.document.relative));
const expected = test.expected.map(normalize);
const hits = expected.filter(file => paths.has(file));
return {
query: test.query,
recall: expected.length ? hits.length / expected.length : 1,
allHit: hits.length === expected.length,
success: test.success,
selected: [...paths]
};
});
const recorded = details.filter(item => typeof item.success === 'boolean');
return {
meanRecall: details.reduce((sum, item) => sum + item.recall, 0) / details.length,
allHitRate: details.filter(item => item.allHit).length / details.length,
taskSuccessRate: recorded.length
? recorded.filter(item => item.success).length / recorded.length
: null,
details
};
}
const args = process.argv.slice(2);
try {
if (args[0] === '--eval') {
const root = path.resolve(args[1]);
const index = await buildIndex(root);
console.log(JSON.stringify(await evaluate(index, args[2], Number(args[3] ?? 4000)), null, 2));
} else {
const root = path.resolve(args[0] ?? '.');
const query = args[1];
if (!query) throw new Error('用法:node context-pruner.mjs <仓库> <任务> [预算]');
const index = await buildIndex(root);
const result = select(index, query, Number(args[2] ?? 4000));
console.log(JSON.stringify({
estimatedTokens: result.used,
files: result.selected.map(item => ({
path: item.document.relative,
reason: item.reason,
score: Number(item.score.toFixed(2)),
estimatedTokens: item.cost
})),
context: result.context
}, null, 2));
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
运行并检查选择结果
在目标仓库外或仓库工具目录中执行:
node context-pruner.mjs ./my-project "修复用户登录后刷新令牌失效的问题" 6000
输出包含文件路径、入选原因、相关性分数、估算 Token 数以及可直接交给模型的 context。先检查清单,不要急着调用模型:如果首批文件明显偏离任务,应优先改善查询表达和索引,而不是单纯增加预算。
建议把任务描述写成“行为 + 模块 + 关键标识符”,例如“修复刷新令牌过期后 refreshSession 未清理 cookie”,通常比“修复登录问题”更容易命中实现文件。对于单体仓库,还可以把包名、服务名或目录写入查询。
当前选择策略先加入高分文件,再加入它的直接依赖。依赖无法装入预算时不会挤掉已经入选的高分文件。这种策略容易解释,但不保证全局最优。实践中可以增加以下约束:为测试文件保留固定比例;给用户明确点名的文件设置强制优先级;按函数或类切片超大文件;把被多处引用的公共类型放入较高优先级。
不要把估算预算直接设成模型上下文上限。系统提示、对话历史、工具返回值和预期输出都会占空间。更稳妥的做法是先计算这些固定部分,再把剩余配额交给源码裁剪器。
用命中率和任务成功率评测
“输出看起来合理”不足以证明裁剪有效。可以从历史工单、已合并修复或人工设计的任务中建立小型评测集。每条样本至少包含任务描述和完成修改所需的关键文件;执行智能体后,再记录任务是否通过测试。
创建 cases.json:
[
{
"query": "修复刷新令牌过期后没有清理会话的问题",
"expected": ["src/auth/session.ts", "src/auth/token.ts"],
"success": true
},
{
"query": "为用户查询接口增加分页参数校验",
"expected": ["src/users/controller.ts", "src/users/schema.ts"],
"success": false
}
]
然后执行:
node context-pruner.mjs --eval ./my-project cases.json 6000
示例会输出三个指标:meanRecall 表示关键文件的平均召回率;allHitRate 表示关键文件全部入选的任务占比;taskSuccessRate 表示已有人工或 CI 结果的任务成功率。这里的 success 不由裁剪器猜测,应来自测试、静态检查或明确的验收标准。
评测时至少比较“完整上下文”“仅相关性排序”“排序加依赖扩展”三组,并保持模型、提示词、温度和任务集一致。若 Token 明显减少但成功率下降,就不是有效优化。还要检查失败样本:关键文件未命中说明召回有问题;文件已命中但任务失败,则可能是排序、提示词、工具调用或模型能力的问题。
评测集不必一开始就很大,但应覆盖修改实现、补测试、追踪调用链、调整配置等不同任务。每次发现裁剪失败,都可以把该任务加入回归集,逐步形成适合自己仓库的上下文工程基线。
总结
上下文裁剪不是在调用模型之前随手删几个文件,而是一条需要持续评测的检索流水线。本文实现了以下闭环:
- 遍历仓库并过滤依赖目录、构建产物和超大文件;
- 使用路径与源码词项进行透明的相关性排序;
- 沿相对导入扩展直接依赖,减少接口信息缺失;
- 在估算 Token 预算内选择文件并输出可检查清单;
- 用关键文件召回率、全命中率和任务成功率验证效果。
这套实现适合作为可运行基线,而不是最终形态。后续可以逐步引入 AST 依赖图、路径别名解析、向量召回、符号级切片和模型专用 tokenizer。无论采用哪种技术,都应保留同一条判断标准:在减少上下文的同时,任务成功率不能被悄悄牺牲。
评论