代码智能体真正需要的通常不是整个仓库,而是与当前任务有关的一小组文件。本文用 Node.js 实现一条依赖少、可直接运行的裁剪流水线,并通过相关文件命中率与任务成功率检查裁剪是否过度。

上下文为什么会失控

最直接的代码智能体实现,往往会遍历仓库,把所有源码拼接后交给模型。它在小型示例中可以工作,进入真实项目后却会遇到三个问题:无关文件消耗上下文窗口;重复的配置、生成代码和测试夹具稀释注意力;仓库增长后,请求成本和延迟也随之增加。

另一种极端是只发送用户点名的文件。这虽然节省 Token,却容易遗漏被导入的类型、工具函数和配置。模型看到调用点却看不到实现,可能转而猜测接口。

因此,裁剪器的目标不是“文件越少越好”,而是选择完成任务所需的最小上下文。本文采用四阶段流水线:

  1. 建立可搜索的源码文件索引;
  2. 根据任务描述计算文件相关性;
  3. 从高分文件向外扩展本地依赖;
  4. 在预算内按优先级装入文件。

它不是语义检索的替代品,而是一个透明、可调试的基线。仓库较大或任务描述较抽象时,可以把排序阶段替换为向量检索,后续依赖扩展和预算选择仍然适用。

裁剪流水线的边界设计

下面的实现只使用 Node.js 内置模块,适合 JavaScript、TypeScript 和 JSON 仓库。它跳过常见产物目录以及超过 256 KiB 的文件,避免索引压缩包、构建产物或超大数据文件。

阶段输入输出主要风险
文件索引仓库目录路径、内容、词项把生成文件纳入索引
相关性排序任务描述文件分数同义词或抽象需求难匹配
依赖扩展高分文件本地直接依赖正则无法完整解析复杂语法
预算选择候选文件最终上下文Token 估算与模型分词不一致

相关性采用简化的 TF-IDF:任务词项在某文件中出现越多,且在仓库中越少见,得分越高;路径匹配再获得少量加分。中文连续文本会拆成单字和双字词,英文、路径与标识符则按连续片段处理。

依赖扩展仅识别静态 importexport ... 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。无论采用哪种技术,都应保留同一条判断标准:在减少上下文的同时,任务成功率不能被悄悄牺牲。