用Claude Code或Cursor在大项目里干活,最头疼的不是模型不够聪明,是上下文窗口不够用。
你让它查一个认证模块怎么实现的,它把相关的20个文件全读进来。grep一遍、读一遍、分析一遍,Token哗哗地烧。还没开始写代码呢,上下文就快满了。
问题出在哪?MCP工具的返回设计。
我最近在自己的项目里做了一轮MCP工具的Token优化,效果很直观:同样的任务,Token消耗从平均4.2万降到了1.1万。方法不复杂,就三个方向。
问题到底多严重
先说数据。我用Claude Code跑了一个中等规模的Node.js项目(大约380个文件,总共12万行代码),记录了10次常见操作的Token消耗。
常见操作的Token消耗对比:
- 查找某个函数的定义和用法:平均消耗3.8万Token
- 理解一个模块的整体逻辑:平均消耗5.2万Token
- 修改一个跨3个文件的功能:平均消耗6.7万Token
这些数字是什么概念?Claude的上下文窗口是200K Token。光是"理解一个模块"就吃掉了窗口的25%。连续做3个任务,上下文基本就满了。
根因很简单:默认的文件读取MCP工具是"全文返回"模式。你问它auth.js里有什么,它把整个文件内容原封不动地塞进上下文。380行的文件就是380行,不管你需要的是第52行的那个函数签名,还是整份代码。
方案一:摘要优先,按需展开
最直接的改法:MCP工具不要默认返回全文,返回结构化摘要。
我写了一个叫code-summary的MCP工具,用AST解析代码文件,返回的不是源代码,是这样的结构:
{
"file": "src/auth/middleware.js",
"exports": ["authMiddleware", "validateToken", "refreshSession"],
"functions": [
{
"name": "authMiddleware",
"params": ["req", "res", "next"],
"lines": [15, 48],
"calls": ["validateToken", "getUserFromCache"],
"description": "Express中间件,校验请求头中的Bearer Token"
},
{
"name": "validateToken",
"params": ["token"],
"lines": [50, 82],
"calls": ["jwt.verify", "checkTokenBlacklist"],
"description": "校验JWT Token有效性,检查黑名单"
}
],
"imports": ["jsonwebtoken", "./cache", "../config"],
"loc": 127
}
一个127行的文件,全文返回大约消耗800个Token。摘要返回只消耗120个Token左右------少了85%。
关键点在"按需展开"。智能体看完摘要后知道validateToken函数在第50到82行,需要看细节时,再调用一次read-lines工具,只读这33行。
实现代码不长,核心是AST解析那部分:
// mcp-server/tools/code-summary.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
function summarizeFile(filePath, source) {
const ast = parser.parse(source, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
});
const functions = [];
const imports = [];
const exports = [];
traverse(ast, {
FunctionDeclaration(path) {
functions.push({
name: path.node.id.name,
params: path.node.params.map(p => p.name || '...'),
lines: [path.node.loc.start.line, path.node.loc.end.line],
description: extractLeadingComment(path)
});
},
ImportDeclaration(path) {
imports.push(path.node.source.value);
},
ExportNamedDeclaration(path) {
if (path.node.declaration?.id) {
exports.push(path.node.declaration.id.name);
}
}
});
return { file: filePath, functions, imports, exports, loc: source.split('\n').length };
}
Python项目也一样,用ast模块做解析就行。
注意一个坑:纯AST解析拿不到函数的"实际用途"。我的做法是读函数上方的注释作为description。如果没注释,就取函数体的前3行作参考。别试图让LLM来生成description,那样每个文件又多一次API调用,得不偿失。
方案二:给每次MCP调用设Token上限
第二个方案更粗暴但很有效:给MCP工具的返回值设一个Token硬上限。
在MCP server的响应层加一个中间件,超过指定长度就自动截断,并在末尾附上提示:
// mcp-server/middleware/token-limit.js
const { encode } = require('gpt-tokenizer');
function withTokenLimit(handler, maxTokens = 2000) {
return async (params) => {
const result = await handler(params);
const content = typeof result === 'string' ? result : JSON.stringify(result);
const tokens = encode(content);
if (tokens.length <= maxTokens) {
return result;
}
// 按Token数截断,不是按字符数
const truncated = tokens.slice(0, maxTokens);
const truncatedText = truncated.map(t => t).join('');
return {
content: truncatedText,
truncated: true,
totalTokens: tokens.length,
returnedTokens: maxTokens,
hint: `内容已截断(${maxTokens}/${tokens.length} tokens)。用read-lines工具指定行号范围获取完整内容。`
};
};
}
我给不同类型的工具设了不同的上限:
const TOOL_TOKEN_LIMITS = {
'read-file': 2000, // 文件读取:最多2000 Token
'search-code': 3000, // 代码搜索:最多3000 Token
'list-directory': 500, // 目录列表:最多500 Token
'git-diff': 4000, // Git差异:最多4000 Token
'read-lines': 1500 // 行范围读取:最多1500 Token
};
踩坑提醒:Token上限不能设太低。我一开始把read-file设成了800 Token,结果智能体经常需要连续调用3-4次read-lines才能凑齐需要的信息,来回调用反而比一次读完消耗更多。2000是我测出来比较平衡的数值。
还有一个细节:截断的时候别在代码块中间切断。我加了个处理------如果截断点在代码块内,就往前退到代码块开始之前:
function findSafeBreakpoint(text, position) {
const codeBlockPattern = /```[\s\S]*?```/g;
let match;
while ((match = codeBlockPattern.exec(text)) !== null) {
if (match.index < position && match.index + match[0].length > position) {
return match.index; // 退到代码块开始前
}
}
return position;
}
方案三:语义搜索替代全文grep
第三个方案投入最大,效果也最好。
传统的代码搜索MCP工具,底层就是grep或ripgrep。你搜"authentication",它把所有包含这个词的文件和匹配行全部返回。在大项目里,随便一搜就是几十个匹配,Token消耗轻松过万。
语义搜索的思路不同:先给代码库建向量索引,搜索时返回语义最相关的Top-K结果。
我的方案用的是本地的Embedding模型(nomic-embed-text,通过Ollama跑)。不依赖外部API,没有额外费用。
索引构建的流程:
# 安装依赖
npm install @anthropic-ai/sdk vectordb
# 在项目根目录运行索引脚本
node build-index.js --dir ./src --extensions .js,.ts,.py
索引脚本的核心逻辑是把每个函数拆成独立的chunk,而不是按固定字符数切分:
// build-index.js
async function buildIndex(projectDir) {
const files = glob.sync(`${projectDir}/**/*.{js,ts}`, { ignore: '**/node_modules/**' });
const chunks = [];
for (const file of files) {
const source = fs.readFileSync(file, 'utf-8');
const functions = extractFunctions(source); // 用AST提取
for (const fn of functions) {
chunks.push({
id: `${file}:${fn.name}`,
text: fn.body,
metadata: {
file,
name: fn.name,
startLine: fn.startLine,
endLine: fn.endLine
}
});
}
}
// 批量生成embedding
const embeddings = await generateEmbeddings(chunks.map(c => c.text));
// 写入本地向量数据库
await db.insert(chunks.map((c, i) => ({
...c,
vector: embeddings[i]
})));
console.log(`索引完成: ${chunks.length} 个函数块, 来自 ${files.length} 个文件`);
}
搜索时,返回Top 5相关函数就够了:
// mcp-server/tools/semantic-search.js
async function semanticSearch(query, topK = 5) {
const queryEmbedding = await embed(query);
const results = await db.search(queryEmbedding, topK);
return results.map(r => ({
file: r.metadata.file,
function: r.metadata.name,
lines: `${r.metadata.startLine}-${r.metadata.endLine}`,
relevance: r.score.toFixed(3),
preview: r.text.slice(0, 200) + '...'
}));
}
实测数据对比:
搜索"用户登录后的Token刷新逻辑": - grep模式:返回34个匹配,消耗约8600 Token - 语义搜索:返回5个函数,消耗约1200 Token
准确率方面,语义搜索前5条命中目标函数的概率在我的项目里达到了92%。grep模式虽然不会遗漏,但34条里只有3-4条是真正需要的,噪声太多。
索引构建一次需要2-3分钟(380个文件),之后增量更新在秒级。我在项目的pre-commit hook里加了自动增量索引,改了哪个文件就重建那个文件的chunk。
三个方案组合的效果
回到开头的数据。三个方案全部上线后,同样10次操作的Token消耗:
- 查找函数定义和用法:3.8万 → 0.9万(减少76%)
- 理解模块整体逻辑:5.2万 → 1.4万(减少73%)
- 修改跨文件功能:6.7万 → 2.1万(减少69%)
平均下来,Token消耗减少了约72%。上下文窗口的压力小了很多,同一个对话里能连续做更多事。
还有个附带好处:智能体的回答质量也上去了。以前全文塞进上下文,模型需要从一堆无关代码里找有用信息。现在只给相关片段,模型的注意力更集中,回答更准确。
部署清单
如果你想在自己的项目里试试,按这个顺序来:
- 先上方案二(Token上限中间件)------改动最小,5分钟搞定,立刻生效
- 再上方案一(摘要优先)------需要写AST解析逻辑,大约半天工作量
- 最后考虑方案三(语义搜索)------依赖Ollama和向量数据库,初次部署需要1-2天
方案二单独就能砍掉40%左右的Token消耗。三个方案叠加能到70%以上。
代码我放在GitHub上了,MCP server的完整实现包括上面提到的所有工具。有具体配置问题可以评论区聊。