基于MCP协议构建AI开发工具代理:实现成本控制与审计追踪
1. 项目概述:为什么我们需要为AI开发工具装上“刹车”和“行车记录仪”?
最近在深度使用Cursor这类AI驱动的代码编辑器时,我遇到了一个很实际的问题:团队协作时,如何管理AI助手(比如Cursor内置的Claude或GPT)的调用成本,并清晰地追踪谁在什么时候、为了什么目的调用了它?这听起来像是个管理问题,但其实是个技术活。当你的工具链深度集成了AI能力,每一次代码补全、每一次对话解释,背后都可能是一次API调用,产生实实在在的费用。放任不管,月底的账单可能会让你大吃一惊;而缺乏审计,出了问题(比如生成了不安全的代码)也根本无从追溯。
这就是“Cursor MCP Proxy Setup”这个项目要解决的核心痛点。MCP,即Model Context Protocol,你可以把它理解为一套让不同AI工具和应用之间安全、标准化通信的“普通话”。而“Proxy Setup”就是为这套通信建立一个“中间站”或“网关”。这个网关的核心使命有两个:预算控制和审计追踪。想象一下,你给团队的AI工具使用装上了“预算锁”和“黑匣子”,既能防止成本超支,又能记录每一次“飞行数据”。
这个指南适合所有在团队环境中使用Cursor、Claude Desktop或其他支持MCP的AI工具的开发者、技术负责人和DevOps工程师。无论你是想控制个人项目的零星开销,还是需要管理一个几十人团队的AI资源使用,这套方案都能提供一个清晰、可落地的技术路径。接下来,我会拆解整个搭建过程,从原理到实操,并分享我踩过的一些坑和优化技巧。
2. 整体架构与核心组件选型解析
在开始动手之前,我们必须先理解我们要构建的是什么,以及为什么选择这些组件。整个系统的目标是在Cursor(客户端)和AI模型服务(如Anthropic的Claude API)之间,插入一个我们自己掌控的代理服务器。
2.1 为什么是MCP代理?
MCP协议的本质是定义了一套标准的JSON-RPC接口,用于工具(如Cursor)和模型服务器之间交换提示词、上下文和工具调用信息。直接连接时,Cursor会通过MCP协议直接与官方的Claude API服务器对话。而代理模式,则是让Cursor先连接到我们自己的服务器,再由我们的服务器去转发请求到真正的API终点。
这样做的好处显而易见:
- 集中控制点:所有流量都经过我们的服务器,这是实施预算和审计的黄金位置。
- 协议透明:MCP基于HTTP和JSON-RPC,是标准的Web协议,易于用常规的Web技术进行拦截、分析和修改。
- 与客户端解耦:无需修改Cursor或任何客户端的代码,只需改变其配置中的连接地址,对终端用户完全透明。
2.2 核心组件技术选型
我们需要构建两个核心功能模块:代理转发和控制逻辑。以下是经过实践验证的选型方案及其理由:
1. 代理服务器框架:Node.js + Express
- 理由:MCP通信本质是HTTP,Node.js的异步非阻塞特性非常适合处理大量并发的API转发请求。Express是Node.js生态中最成熟、最灵活的Web框架,中间件机制可以让我们轻松地插入审计日志、速率限制和成本计算逻辑。相较于Python的Flask/FastAPI,Node.js在处理JSON-RPC这种纯HTTP/JSON的流水线作业时,通常更轻量、启动更快。
- 备选方案:Go (Gin/Echo框架) 是另一个高性能选择,适合对并发和资源消耗有极致要求的场景,但初期开发速度可能略慢于Node.js。
2. 审计日志存储:SQLite (开发/轻量) 或 PostgreSQL (生产)
- 理由:审计数据需要结构化存储以便查询分析。SQLite无需单独部署数据库服务,一个文件搞定,非常适合个人或小团队初期使用。它的简单性让我们能快速搭建原型。当数据量增大或需要团队协作访问时,可以无缝迁移到PostgreSQL。审计日志的关键字段应包括:请求ID、时间戳、用户标识(如API Key或用户名)、模型类型、提示词Token数、完成Token数、估算成本、请求状态和原始请求/响应的摘要或哈希值(注意隐私,可能不存全文)。
- 注意:切勿将完整的提示词和响应内容(可能包含敏感代码或业务逻辑)明文存入日志,应只存元数据或进行哈希处理。
3. 预算控制实现:内存存储 + 定期持久化
- 理由:预算检查需要极低的延迟和高频的读写(每次API调用前都要检查)。使用内存(如JavaScript的Map或对象)来存储用户/团队的实时预算消耗是最快的。同时,我们需要一个后台进程,定期(例如每5分钟)将内存中的数据快照持久化到上述的SQLite/PostgreSQL中,防止服务器重启导致数据丢失。对于分布式部署,则需要引入Redis等分布式缓存来共享预算状态。
- 关键设计:预算检查必须是一个原子操作。在高并发下,需要防止“超卖”(两个请求同时读取余额,都判断为足够,然后都扣费导致透支)。在单进程Node.js中,可以利用其单线程事件循环的特性,配合异步队列来简化这个问题。更严谨的做法是使用数据库的行锁或Redis的原子操作(INCRBY/DECRBY)。
4. 用户/团队标识:API Key 体系
- 理由:我们需要区分不同用户或团队的流量。最通用的方式是为每个用户或团队生成一个唯一的API Key。Cursor在配置MCP服务器时,可以将这个Key作为连接参数或放在请求头中(如
Authorization: Bearer <api_key>)。我们的代理服务器在收到请求后,首先验证这个Key的有效性,并将其作为预算归属和审计日志的标识。 - 实操技巧:API Key可以设计成有不同权限等级(如只读、标准、管理员),并可以设置启用/禁用状态。Key的生成可以使用
crypto.randomBytes生成高强度随机字符串,并哈希后存储,仅在一次性地将明文Key返回给用户。
3. 分步搭建MCP代理服务器
理论清晰后,我们开始动手搭建。我将以Node.js + Express + SQLite的技术栈为例,展示从零到一的构建过程。
3.1 初始化项目与依赖安装
首先,创建一个新的项目目录并初始化。
mkdir cursor-mcp-proxy cd cursor-mcp-proxy npm init -y安装核心依赖:
npm install express dotenv axios sqlite3 bcryptjs jsonwebtoken npm install --save-dev nodemonexpress: Web服务器框架。dotenv: 管理环境变量(如数据库路径、API密钥、预算限额)。axios: 用于向真实的AI API端点(如api.anthropic.com)转发请求。sqlite3: 操作SQLite数据库。bcryptjs: 用于哈希API Key(如果存数据库)。jsonwebtoken: 可选项,用于生成和验证更复杂的JWT Token作为身份凭证。nodemon: 开发工具,代码变动时自动重启服务器。
创建项目基础结构:
cursor-mcp-proxy/ ├── .env ├── .gitignore ├── package.json ├── server.js # 主入口文件 ├── config/ │ └── index.js # 配置管理 ├── middleware/ │ ├── auth.js # API Key认证中间件 │ ├── audit.js # 审计日志中间件 │ └── budget.js # 预算检查中间件 ├── services/ │ ├── db.js # 数据库连接与初始化 │ ├── budgetService.js # 预算管理逻辑 │ └── auditService.js # 审计日志逻辑 └── routes/ └── mcp-proxy.js # MCP代理路由3.2 配置管理与数据库初始化
在.env文件中配置关键信息:
PORT=3000 NODE_ENV=development DB_PATH=./data/audit.db ANTHROPIC_API_KEY=your_actual_anthropic_api_key_here ANTHROPIC_BASE_URL=https://api.anthropic.com DEFAULT_BUDGET_MONTHLY_USD=50.00 # 默认月度预算(美元)在config/index.js中集中管理配置:
require('dotenv').config(); module.exports = { port: process.env.PORT || 3000, nodeEnv: process.env.NODE_ENV || 'development', dbPath: process.env.DB_PATH, anthropicApiKey: process.env.ANTHROPIC_API_KEY, anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL, defaultBudget: parseFloat(process.env.DEFAULT_BUDGET_MONTHLY_USD) || 50.0, };在services/db.js中初始化SQLite数据库和审计表:
const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const config = require('../config'); let db; function initDatabase() { const dbPath = path.resolve(__dirname, '..', config.dbPath); // 确保数据目录存在 const fs = require('fs'); const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } db = new sqlite3.Database(dbPath, (err) => { if (err) { console.error('Could not connect to database', err); } else { console.log('Connected to SQLite database.'); createTables(); } }); } function createTables() { // 审计日志表 db.run(`CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, api_key_id TEXT NOT NULL, -- 关联的API Key标识 timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, model TEXT, prompt_tokens INTEGER, completion_tokens INTEGER, estimated_cost_usd REAL, status_code INTEGER, path TEXT, user_agent TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // API Key管理表(简化版) db.run(`CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, -- 例如:key_abc123 hashed_key TEXT NOT NULL, -- 存储哈希值,非明文 name TEXT, monthly_budget_usd REAL DEFAULT 50.00, current_spent_usd REAL DEFAULT 0.00, reset_date DATE, -- 预算重置日期,如每月1号 is_active BOOLEAN DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // 预算快照表(用于持久化内存中的实时花费) db.run(`CREATE TABLE IF NOT EXISTS budget_snapshots ( api_key_id TEXT NOT NULL, snapshot_date DATE NOT NULL, spent_usd REAL NOT NULL, PRIMARY KEY (api_key_id, snapshot_date) )`); } function getDb() { if (!db) { initDatabase(); } return db; } module.exports = { initDatabase, getDb, };注意:这里存储的是API Key的哈希值,而不是明文。当客户端传来API Key时,我们需要用同样的算法哈希后与数据库中的
hashed_key比对。bcryptjs库非常适合做这个。永远不要在日志或响应中泄露明文API Key。
3.3 实现核心中间件:认证、审计与预算
中间件是Express处理请求的管道,我们将功能模块化。
1. 认证中间件 (middleware/auth.js):
const bcrypt = require('bcryptjs'); const { getDb } = require('../services/db'); async function authenticateApiKey(req, res, next) { // 从请求头中获取API Key,例如:Authorization: Bearer sk_proxy_abc123 const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: { message: 'Missing or invalid Authorization header' } }); } const apiKey = authHeader.substring(7); // 去掉'Bearer '前缀 const db = getDb(); // 这里简化处理:实际应查询数据库,比对哈希值 // 为了演示,我们假设有一个内存中的有效Key映射 // 实际项目中,这里应该查询 `api_keys` 表,用 bcrypt.compareSync(apiKey, hashedKey) const isValidKey = await validateApiKeyFromDb(apiKey); if (!isValidKey) { return res.status(403).json({ error: { message: 'Invalid API key' } }); } // 将验证后的Key信息附加到请求对象,供后续中间件使用 req.apiKeyId = isValidKey.id; // 假设返回对象包含id req.apiKeyBudget = isValidKey.monthly_budget_usd; req.apiKeySpent = isValidKey.current_spent_usd; next(); } // 模拟数据库验证函数 async function validateApiKeyFromDb(apiKey) { // 实际应从数据库查询并比对哈希 // 此处返回模拟数据 return { id: 'team_dev', monthly_budget_usd: 100.00, current_spent_usd: 23.50, }; } module.exports = { authenticateApiKey };2. 预算检查中间件 (middleware/budget.js):
// 内存中的预算缓存,键为 apiKeyId,值为已花费金额(美元) const budgetCache = new Map(); function checkBudget(req, res, next) { const { apiKeyId, apiKeyBudget } = req; const estimatedCost = req.estimatedCost || 0; // 这个值需要在审计中间件中计算并附加 if (!apiKeyId) { return next(new Error('API Key信息缺失,请先通过认证中间件')); } const currentSpent = budgetCache.get(apiKeyId) || 0; const projectedSpent = currentSpent + estimatedCost; // 检查是否超预算 if (projectedSpent > apiKeyBudget) { return res.status(429).json({ error: { message: `Budget exceeded. Monthly budget: $${apiKeyBudget}, already spent: $${currentSpent.toFixed(2)}, this request would cost ~$${estimatedCost.toFixed(2)}.`, type: 'budget_limit' } }); } // 预算充足,将预估成本暂存,待请求成功后再扣减 req.projectedCost = estimatedCost; next(); } // 一个简单的函数,用于在请求成功后更新内存缓存 function updateBudgetCache(apiKeyId, cost) { const current = budgetCache.get(apiKeyId) || 0; budgetCache.set(apiKeyId, current + cost); } // 定期将内存缓存持久化到数据库的函数(需另设定时任务) async function syncBudgetToDb() { // ... 遍历 budgetCache,更新 api_keys 表的 current_spent_usd 字段 } module.exports = { checkBudget, updateBudgetCache, budgetCache };3. 审计日志中间件 (middleware/audit.js):这是最复杂的部分,它需要拦截请求和响应,计算成本,并记录日志。
const { getDb } = require('../services/db'); const { v4: uuidv4 } = require('uuid'); // 需要安装 uuid 包 async function auditLog(req, res, next) { const startTime = Date.now(); const requestId = uuidv4(); req.requestId = requestId; // 捕获原始响应发送方法 const originalSend = res.send; let responseBody; res.send = function(body) { responseBody = body; originalSend.call(this, body); }; // 响应完成后记录日志 res.on('finish', async () => { const duration = Date.now() - startTime; const { apiKeyId, path, method } = req; const userAgent = req.get('User-Agent') || ''; // 解析请求体,估算Token和成本(简化版) let estimatedCost = 0; let model = 'unknown'; let promptTokens = 0; let completionTokens = 0; try { // MCP请求体通常是JSON RPC格式,我们需要解析其中的参数 if (req.body && req.body.params && req.body.params.messages) { // 这是一个非常粗略的估算!实际应根据模型定价和准确的Token数计算。 // 例如,Claude 3 Opus: $15 / 1M input tokens, $75 / 1M output tokens model = req.body.params.model || 'claude-3-opus-20240229'; // 假设我们有一个函数 estimateTokens 来估算消息的token数 promptTokens = estimateTokens(req.body.params.messages); // 输出Token数需要从响应体中获取 if (responseBody && typeof responseBody === 'string') { const resp = JSON.parse(responseBody); if (resp.result && resp.result.content) { completionTokens = estimateTokens(resp.result.content); } } // 简单成本计算(示例价格,需替换为实际) const inputCostPerMillion = 15.0; // $15 per 1M input tokens const outputCostPerMillion = 75.0; // $75 per 1M output tokens estimatedCost = (promptTokens / 1_000_000) * inputCostPerMillion + (completionTokens / 1_000_000) * outputCostPerMillion; } } catch (err) { console.error('Failed to estimate cost:', err); } // 将估算的成本附加到请求对象,供预算中间件使用(注意:这是响应后,预算检查在之前) // 更合理的架构是在转发请求前,根据请求内容预先估算一个成本用于预算检查。 // 这里为了流程清晰,先记录。 // 记录到数据库 const db = getDb(); const stmt = db.prepare(` INSERT INTO audit_logs (request_id, api_key_id, model, prompt_tokens, completion_tokens, estimated_cost_usd, status_code, path, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( requestId, apiKeyId || 'unknown', model, promptTokens, completionTokens, estimatedCost, res.statusCode, path, userAgent ); stmt.finalize(); // 如果请求成功,更新内存中的预算缓存 if (res.statusCode >= 200 && res.statusCode < 300 && apiKeyId && estimatedCost > 0) { const { updateBudgetCache } = require('./budget'); updateBudgetCache(apiKeyId, estimatedCost); } console.log(`[Audit] ${method} ${path} - ${res.statusCode} - ${duration}ms - Cost: ~$${estimatedCost.toFixed(4)}`); }); next(); } // 一个非常粗略的Token估算函数(实际应用应使用tiktoken库或模型提供商提供的SDK) function estimateTokens(textOrMessages) { // 简化:按字符数除以4估算(英文近似)。中文等语言不同。 let totalChars = 0; if (Array.isArray(textOrMessages)) { textOrMessages.forEach(msg => { if (msg.content) totalChars += String(msg.content).length; }); } else if (typeof textOrMessages === 'string') { totalChars = textOrMessages.length; } return Math.ceil(totalChars / 4); } module.exports = { auditLog };3.4 构建代理路由与主服务器
现在,我们将中间件和转发逻辑组合起来。
代理路由 (routes/mcp-proxy.js):
const express = require('express'); const axios = require('axios'); const { authenticateApiKey } = require('../middleware/auth'); const { checkBudget } = require('../middleware/budget'); const { auditLog } = require('../middleware/audit'); const config = require('../config'); const router = express.Router(); // 关键:解析JSON请求体。MCP使用JSON-RPC over HTTP。 router.use(express.json()); // 应用中间件链:认证 -> 审计(记录开始)-> 预算检查 -> 转发 -> 审计(记录结束) router.all('*', authenticateApiKey, auditLog, checkBudget, async (req, res) => { try { // 构建转发到真实Anthropic API的请求 const targetUrl = `${config.anthropicBaseUrl}${req.path}`; const headers = { 'Content-Type': 'application/json', 'x-api-key': config.anthropicApiKey, // 使用我们自己的Anthropic主Key 'anthropic-version': '2023-06-01', // 根据实际情况调整 // 可以选择性传递一些客户端头 ...(req.headers['user-agent'] && { 'User-Agent': req.headers['user-agent'] }), }; // 转发请求 const response = await axios({ method: req.method, url: targetUrl, headers: headers, data: req.body, // 可以设置超时等参数 timeout: 120000, // 120秒 }); // 将响应返回给客户端(如Cursor) res.status(response.status).json(response.data); } catch (error) { console.error('Proxy error:', error.message); // 处理错误,将上游错误信息适当返回给客户端 const status = error.response?.status || 500; const message = error.response?.data?.error?.message || error.message; res.status(status).json({ error: { type: 'proxy_error', message: `Proxy request failed: ${message}`, } }); } }); module.exports = router;主服务器文件 (server.js):
const express = require('express'); const config = require('./config'); const { initDatabase } = require('./services/db'); const mcpProxyRouter = require('./routes/mcp-proxy'); // 初始化数据库 initDatabase(); const app = express(); const PORT = config.port; // 全局中间件(可选) app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`); next(); }); // 挂载MCP代理路由。所有发往 /v1/ 的请求(这是Anthropic API的典型路径)都由代理处理。 app.use('/v1', mcpProxyRouter); // 健康检查端点 app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // 一个简单的管理端点,查看当前预算缓存(生产环境需要加权限!) app.get('/admin/budget-cache', (req, res) => { const { budgetCache } = require('./middleware/budget'); res.json(Object.fromEntries(budgetCache)); }); app.listen(PORT, () => { console.log(`Cursor MCP Proxy Server running on http://localhost:${PORT}`); console.log(`MCP endpoint: http://localhost:${PORT}/v1`); });在package.json中添加启动脚本:
"scripts": { "start": "node server.js", "dev": "nodemon server.js" }现在,运行npm run dev,你的MCP代理服务器就在http://localhost:3000上运行了。
4. 配置Cursor客户端连接代理
服务器搭好了,现在需要告诉Cursor去使用它。Cursor通过其配置文件来定义MCP服务器。
- 找到Cursor配置:Cursor的配置通常位于用户目录下的一个JSON文件中。例如,在macOS上,路径可能是
~/Library/Application Support/Cursor/User/globalStorage/mcp.json或通过Cursor的设置界面进行配置。请查阅Cursor的最新文档确认。 - 编辑MCP配置:你需要添加或修改一个MCP服务器配置,将其指向你的代理服务器。配置可能如下所示:
{ "mcpServers": { "my-anthropic-proxy": { "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-anthropic", "--api-key", "sk_proxy_abc123def456", // 这是你代理服务器颁发的API Key,不是Anthropic的 "--api-url", "http://localhost:3000/v1" // 指向你的代理服务器 ] } } }关键点:
command和args是启动MCP服务器的命令。这里我们假设使用一个官方的或社区的Anthropic MCP服务器实现,它通过命令行参数接收API Key和自定义URL。--api-key参数的值,应该是你从自己的代理服务器管理后台生成的Key(如sk_proxy_xxx),而不是原始的Anthropic API Key。你的代理服务器会验证这个Key。--api-url参数至关重要,它告诉这个MCP服务器客户端(即Cursor启动的进程)将请求发送到你的代理地址(localhost:3000/v1),而不是默认的api.anthropic.com。
- 重启Cursor:保存配置后,完全重启Cursor,使其加载新的MCP服务器设置。
现在,当你在Cursor中使用AI功能时,请求流将变为:Cursor -> 本地MCP服务器进程 -> 你的代理服务器(localhost:3000) -> 真实的Anthropic API。你的代理服务器完成了认证、审计和预算检查的全流程。
5. 生产环境部署与进阶优化
本地开发环境跑通只是第一步。要服务于团队,你需要考虑生产部署。
5.1 部署方案选择
- 传统VPS/云服务器:在DigitalOcean、AWS EC2、Google Cloud Compute Engine等上部署。你需要:
- 配置Node.js环境。
- 使用
pm2或systemd管理进程,保证服务持续运行。 - 配置Nginx或Apache作为反向代理,处理SSL/TLS加密(HTTPS)。非常重要:Cursor等客户端很可能要求HTTPS连接。
- 绑定域名,并配置DNS。
- 容器化部署(推荐):使用Docker将你的代理服务器封装成镜像。
然后可以在任何支持Docker的环境(如自有服务器、Kubernetes集群)中运行,或者使用云服务商的容器托管服务(如AWS ECS、Google Cloud Run、Azure Container Instances)。这种方式环境一致,易于扩展。# Dockerfile FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . EXPOSE 3000 USER node CMD ["node", "server.js"] - Serverless函数:将代理逻辑拆分为函数(如AWS Lambda + API Gateway)。这对于突发流量成本优化有好处,但需要重新架构,将状态(如内存预算缓存)存储到外部服务(如Redis),复杂度较高。
5.2 安全性强化
- HTTPS是必须的:使用Let‘s Encrypt免费证书或云服务商提供的证书,通过Nginx配置SSL。
- API Key管理:实现一个简单的管理界面(或命令行工具)来生成、吊销、查看API Key及其使用情况。Key应使用
bcrypt等强哈希算法存储。 - 请求速率限制:使用
express-rate-limit等中间件,防止单个Key滥用。 - 输入验证与过滤:虽然你是代理,但也应对转发的请求体做基本检查,防止注入攻击或异常请求。
- 日志脱敏:确保审计日志不记录完整的提示词和响应,尤其是可能包含密码、密钥、个人信息的对话。
5.3 预算与审计功能增强
- 预算重置策略:实现按周期(月、周)自动重置预算。可以在
api_keys表中设置reset_date,并创建一个每日运行的定时任务检查是否需要重置(将current_spent_usd归零,并更新reset_date)。 - 实时成本估算:在
checkBudget中间件中,你需要一个更准确的预扣费估算。可以解析请求中的model和messages,使用对应模型的定价和Token估算库(如@anthropic-ai/tokenizerfor Claude,tiktokenfor GPT)进行快速估算。预扣费成功后,请求转发;请求完成后,根据实际返回的Token数(从响应头或响应体获取)进行结算,多退少补(调整内存缓存和数据库)。 - 审计仪表盘:构建一个简单的Web页面,让团队成员可以查看自己的使用量、成本趋势、常用模型等。可以使用Chart.js等库可视化数据。
- 告警机制:当预算使用达到80%、90%、100%时,通过邮件、Slack或钉钉发送通知。
5.4 性能与可靠性
- 连接池与超时:配置
axios的HTTP Agent,复用到底层API的连接,提升性能。设置合理的超时(如连接超时、响应超时)。 - 错误重试:对于上游API(Anthropic)的瞬时失败(5xx错误),可以实现指数退避的重试机制。
- 高可用:如果团队规模大,考虑部署多个代理实例,前面用负载均衡器(如Nginx, HAProxy)分发流量。此时预算缓存必须使用共享存储,如Redis。
6. 常见问题与故障排查实录
在实际搭建和运行过程中,你几乎一定会遇到下面这些问题。这里是我的排查笔记。
Q1: Cursor连接代理失败,提示“无法连接到MCP服务器”或“认证失败”。
- 检查步骤:
- 代理服务器是否在运行?
curl http://localhost:3000/health看是否返回{“status”:”ok”}。 - Cursor配置的URL和端口是否正确?确认
--api-url参数指向了正确的地址。如果是远程服务器,确保是HTTPS且域名可解析。 - 防火墙/安全组规则:确保服务器防火墙(如
ufw)或云服务商安全组开放了代理服务器的端口(如3000)。 - 认证中间件日志:查看代理服务器的控制台输出,看认证中间件是否报错。检查API Key的哈希比对逻辑。
- MCP服务器命令路径:确保Cursor配置中
command指定的命令(如npx)在Cursor的运行环境中可用。
- 代理服务器是否在运行?
Q2: 请求能转发,但Anthropic API返回403或401错误。
- 原因:你的代理服务器没有正确地将自己的Anthropic主API Key设置到转发请求的头部。
- 排查:在代理服务器的转发代码中,打印出即将发送的请求头(注意不要打印出真实的Key)。确保
x-api-key头被正确设置,且其值是你的有效Anthropic API Key。检查Key是否有调用对应模型的权限。
Q3: 预算控制不准确,感觉扣费比实际多或少。
- 原因:Token估算不准或成本计算模型不对。
- 解决:
- 使用官方Tokenizer:放弃简单的字符数估算,集成Anthropic官方提供的Token计算库(如Javascript版的
@anthropic-ai/tokenizer),在审计中间件中精确计算Prompt Tokens。 - 从响应头获取实际用量:Anthropic API的响应头通常包含
anthropic-input-tokens和anthropic-output-tokens字段。用这个实际值来计算成本,比估算准确得多。在审计日志的res.on(‘finish’)回调中,你可以访问res.get(‘anthropic-input-tokens’)来获取。 - 更新定价表:定期检查Anthropic官网的定价页面,及时更新代码中的
inputCostPerMillion和outputCostPerMillion变量。
- 使用官方Tokenizer:放弃简单的字符数估算,集成Anthropic官方提供的Token计算库(如Javascript版的
Q4: 服务器重启后,内存中的预算缓存清零了。
- 原因:如设计所述,内存缓存是易失的。
- 解决:实现
syncBudgetToDb函数,并设置一个定时任务(例如使用node-cron库),每1分钟或5分钟将budgetCache中的数据同步到数据库的api_keys表的current_spent_usd字段。服务器启动时,从数据库读取各API Key的current_spent_usd来初始化budgetCache。
Q5: 高并发下,出现了预算超支(两个请求同时通过检查)。
- 原因:预算检查不是原子操作。
- 解决(单机版):可以利用Node.js单线程特性,将预算检查与扣减逻辑放入一个异步队列(如
async/await配合一个全局的Promise锁),确保同一API Key的请求串行处理预算。但这会影响性能。 - 解决(推荐):引入Redis,使用Redis的
INCRBY命令的原子性。伪代码如下:
这确保了检查和扣减是一个不可分割的操作。const currentSpent = await redisClient.incrByFloat(`budget:${apiKeyId}`, estimatedCost); if (currentSpent > budgetLimit) { // 如果超了,需要回滚刚才的增加 await redisClient.incrByFloat(`budget:${apiKeyId}`, -estimatedCost); return res.status(429).json(...); } // 预算充足,继续处理请求
搭建这样一个MCP代理,初期可能会觉得繁琐,但一旦运行起来,它带来的成本可见性和控制力是巨大的。它不仅是财务上的“刹车”,更是技术管理上的“仪表盘”。你可以清楚地看到哪个团队、哪个项目、哪种类型的任务消耗了最多的AI资源,从而优化使用策略,让宝贵的AI算力花在刀刃上。
