ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Claude Code 100个真实案例 - 用AI搭建API网关(限流+熔断+灰度发布)

Claude Code 100个真实案例 - 用AI搭建API网关(限流+熔断+灰度发布) 1. 为什么你的微服务需要一个 API 网关微服务拆得越细接口管理就越容易失控。我见过一个团队把用户、商品、订单拆成三个服务后前端要同时维护三套域名、三套鉴权逻辑某次大促订单服务被打挂上游还在疯狂重试最后把用户服务也拖垮了。这就是典型的没有 API 网关的后果——请求入口分散、限流各自为政、故障无法隔离、新版本上线只能全量赌一把。API 网关是什么简单说它是所有外部请求进入系统的唯一入口负责路由转发、限流、熔断、灰度发布、日志监控这些横切关注点。适合谁适合正在从单体往微服务迁移、或者已经被接口混乱折磨的团队。你不需要一上来就上 Nginx Lua 或者 Spring Cloud Gateway用 Node.js Express 手写一个轻量网关反而更容易理解每个中间件到底在干什么。这篇内容围绕 Claude Code 从零搭建 API 网关的完整实战展开聚焦限流、熔断、灰度发布三大能力。我会把可复制的路由配置、限流规则、熔断状态机、灰度分流策略全部拆开讲并给出压测和故障注入的验证动作。你跟着做能在自己的项目里落地一个能跑通的网关原型。技术栈选型很直接Node.js 20 做运行时Express 4.x 做网关框架http-proxy-middleware 3.x 做请求代理ioredis 5.x 做分布式限流计数本地开发可以先用内存版Claude Code 作为 AI 编程助手帮你生成和调试代码。先检查环境node -v # v20.16.0 以上 claude --version # 确认 Claude Code 已安装 mkdir api-gateway-demo cd api-gateway-demo pnpm init pnpm add express http-proxy-middleware ioredis cors uuid dotenv这里有个关键点中间件的执行顺序决定了网关的行为。正确的顺序是「日志 → IP 黑名单 → 限流 → 熔断 → 灰度 → 代理」。日志最先执行才能记录所有请求代理最后执行因为它是终点。如果你把限流放在熔断后面被熔断的请求就不会被限流统计数据会失真。这个顺序我在提示词里会明确告诉 Claude Code避免它生成错误的链路。2. TaoToken 统一 Key 通道的前置准备在开始写网关代码之前先解决一个容易被忽略的问题你的网关本身也需要调用大模型能力来做智能路由、异常检测或者日志分析。这时候如果每个服务都各自配置一套 API Key管理成本会很高。TaoToken 提供统一 Key 和 API 通道官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。为什么网关项目需要这个因为你在开发阶段会用 Claude Code 生成大量代码调试阶段可能还要接入模型做请求分类。统一通道的好处是一个 Key 走通所有模型调用不用在多个平台之间切换配置。对于网关这种需要频繁做请求分析和策略调整的场景能省下不少切换成本。具体操作上你需要先拿到 API Key。访问 https://taotoken.net/api-keys 创建密钥然后在项目根目录建一个.env文件# .env TAOTOKEN_API_KEYsk-你的实际密钥 TAOTOKEN_BASE_URLhttps://taotoken.net/api GATEWAY_PORT3000 REDIS_URLredis://localhost:6379注意 Base URL 不要带 UTM 参数API 调用地址就是纯净的https://taotoken.net/api。如果你用 Claude Code 的 coding plan 做长期开发可以在 https://taotoken.net/coding-plan 查看套餐适合需要持续生成和调试代码的场景。配置好之后你可以在网关里加一个简单的模型调用测试确认通道可用。比如写一个/api/gateway/health/model接口用 fetch 调一下模型对话接口// src/utils/model-check.js export async function checkModelChannel() { const res await fetch(${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, }, body: JSON.stringify({ model: claude-sonnet-4-20250514, messages: [{ role: user, content: ping }], max_tokens: 10, }), }); if (!res.ok) { throw new Error(模型通道异常: ${res.status}); } return res.json(); }这个检查接口在网关启动时调用一次确认 Key 和通道都正常。如果返回 401说明 Key 配置有问题如果返回 404检查 Base URL 是否写错。模型对话的调试入口在 https://taotoken.net/chat 你可以先在网页上确认 Key 能用再写进代码。3. 可复制的网关路由与限流熔断配置这一节是核心我会把路由配置、限流算法、熔断状态机、灰度策略的完整代码拆开讲。你直接复制到项目里就能跑。3.1 路由配置表先建src/config/routes.js定义每个路径前缀对应的上游服务地址和规则// src/config/routes.js export const routeConfig { /api/users: { target: http://localhost:3001, pathRewrite: { ^/api/users: /users }, rateLimit: { maxRequests: 100, windowMs: 60000 }, circuitBreaker: { threshold: 5, timeout: 30000 }, grayscale: { enabled: false }, description: 用户服务, }, /api/products: { target: http://localhost:3002, pathRewrite: { ^/api/products: /products }, rateLimit: { maxRequests: 200, windowMs: 60000 }, circuitBreaker: { threshold: 10, timeout: 60000 }, grayscale: { enabled: false }, description: 商品服务, }, /api/orders: { target: http://localhost:3003, pathRewrite: { ^/api/orders: /orders }, rateLimit: { maxRequests: 50, windowMs: 60000 }, circuitBreaker: { threshold: 3, timeout: 30000 }, grayscale: { enabled: true, percentage: 10, newTarget: http://localhost:3013, headerKey: X-User-Group, headerValue: beta, }, description: 订单服务, }, }; let currentRoutes { ...routeConfig }; export function getRoutes() { return currentRoutes; } export function updateRoute(path, config) { currentRoutes[path] { ...currentRoutes[path], ...config }; } export function getRouteForPath(requestPath) { const sortedPaths Object.keys(currentRoutes).sort((a, b) b.length - a.length); for (const prefix of sortedPaths) { if (requestPath.startsWith(prefix)) { return { prefix, config: currentRoutes[prefix] }; } } return null; }这里用最长前缀匹配避免/api/users和/api/user冲突。updateRoute支持动态修改配置后面管理 API 会用到。3.2 令牌桶限流令牌桶的核心是桶里按固定速率生成令牌每个请求消耗一个桶空就拒绝。它允许一定程度的突发流量比固定窗口更平滑。// src/middleware/rate-limiter.js import { getRouteForPath } from ../config/routes.js; class TokenBucket { constructor() { this.buckets new Map(); } consume(key, rate, capacity) { const now Date.now(); let bucket this.buckets.get(key); if (!bucket) { bucket { tokens: capacity, lastRefill: now }; this.buckets.set(key, bucket); } const elapsed (now - bucket.lastRefill) / 1000; bucket.tokens Math.min(capacity, bucket.tokens elapsed * rate); bucket.lastRefill now; if (bucket.tokens 1) { bucket.tokens - 1; return { allowed: true, remaining: Math.floor(bucket.tokens), limit: capacity }; } const waitTime (1 - bucket.tokens) / rate; return { allowed: false, remaining: 0, limit: capacity, retryAfter: Math.ceil(waitTime) }; } cleanup(maxAgeMs 600000) { const now Date.now(); for (const [key, bucket] of this.buckets) { if (now - bucket.lastRefill maxAgeMs) this.buckets.delete(key); } } } const tokenBucket new TokenBucket(); setInterval(() tokenBucket.cleanup(), 300000); export function rateLimiter() { const stats { totalChecked: 0, totalBlocked: 0 }; const middleware (req, res, next) { const route getRouteForPath(req.path); if (!route || !route.config.rateLimit) return next(); const { maxRequests, windowMs } route.config.rateLimit; const clientIP req.ip || req.connection.remoteAddress; const limitKey ${clientIP}:${route.prefix}; stats.totalChecked; const rate maxRequests / (windowMs / 1000); const result tokenBucket.consume(limitKey, rate, maxRequests); res.setHeader(X-RateLimit-Limit, result.limit); res.setHeader(X-RateLimit-Remaining, result.remaining); if (!result.allowed) { stats.totalBlocked; res.setHeader(Retry-After, result.retryAfter); return res.status(429).json({ code: 429, message: 请求过于频繁请稍后重试, retryAfter: result.retryAfter, requestId: req.requestId, }); } next(); }; middleware.getStats () ({ ...stats }); return middleware; }限流键用IP 路径前缀这样不同接口独立限流。如果你要按用户 ID 限流把clientIP换成req.get(X-User-ID)即可。3.3 熔断器状态机熔断器有三个状态CLOSED正常、OPEN熔断、HALF_OPEN半开试探。连续失败达到阈值就熔断熔断一段时间后放一个请求试探成功就恢复。// src/middleware/circuit-breaker.js import { getRouteForPath } from ../config/routes.js; const STATE { CLOSED: CLOSED, OPEN: OPEN, HALF_OPEN: HALF_OPEN }; class CircuitBreakerInstance { constructor(name, options {}) { this.name name; this.state STATE.CLOSED; this.failureCount 0; this.successCount 0; this.stateChangedAt Date.now(); this.threshold options.threshold || 5; this.timeout options.timeout || 30000; this.halfOpenMax options.halfOpenMax || 3; } canPass() { if (this.state STATE.CLOSED) return true; if (this.state STATE.OPEN) { if (Date.now() - this.stateChangedAt this.timeout) { this._setState(STATE.HALF_OPEN); return true; } return false; } return true; } recordSuccess() { if (this.state STATE.HALF_OPEN) { this.successCount; if (this.successCount this.halfOpenMax) { this._setState(STATE.CLOSED); this.failureCount 0; this.successCount 0; } } else if (this.state STATE.CLOSED) { this.failureCount 0; } } recordFailure() { this.failureCount; if (this.state STATE.CLOSED this.failureCount this.threshold) { this._setState(STATE.OPEN); } else if (this.state STATE.HALF_OPEN) { this._setState(STATE.OPEN); this.successCount 0; } } _setState(newState) { this.state newState; this.stateChangedAt Date.now(); } getStatus() { return { name: this.name, state: this.state, failureCount: this.failureCount, stateChangedAt: new Date(this.stateChangedAt).toISOString(), }; } } const breakers new Map(); function getBreaker(name, options) { if (!breakers.has(name)) breakers.set(name, new CircuitBreakerInstance(name, options)); return breakers.get(name); } export function circuitBreaker() { return (req, res, next) { const route getRouteForPath(req.path); if (!route || !route.config.circuitBreaker) return next(); const breaker getBreaker(route.prefix, route.config.circuitBreaker); if (!breaker.canPass()) { return res.status(503).json({ code: 503, message: 服务暂时不可用熔断保护中请稍后重试, service: route.config.description, retryAfter: Math.ceil((route.config.circuitBreaker.timeout - (Date.now() - breaker.stateChangedAt)) / 1000), requestId: req.requestId, }); } req.circuitBreaker breaker; next(); }; } export function getAllBreakersStatus() { const result {}; for (const [name, breaker] of breakers) result[name] breaker.getStatus(); return result; }代理中间件在请求成功或失败时调用recordSuccess/recordFailure这样熔断器才能感知上游状态。3.4 灰度发布分流灰度发布的关键是「同一用户始终走同一版本」用哈希取模实现粘性路由// src/middleware/grayscale.js import { getRouteForPath } from ../config/routes.js; import crypto from crypto; class GrayscaleManager { constructor() { this.stats { totalRouted: 0, toNewVersion: 0, toOldVersion: 0 }; } route(req, routeConfig) { const grayscale routeConfig.grayscale; if (!grayscale || !grayscale.enabled) return null; this.stats.totalRouted; if (grayscale.headerKey grayscale.headerValue) { if (req.get(grayscale.headerKey) grayscale.headerValue) { this.stats.toNewVersion; return grayscale.newTarget; } } if (grayscale.percentage grayscale.percentage 0) { const clientId req.get(X-User-ID) || req.ip || req.connection.remoteAddress; const hash crypto.createHash(md5).update(clientId).digest(hex); const bucket parseInt(hash.substring(0, 8), 16) % 100; if (bucket grayscale.percentage) { this.stats.toNewVersion; return grayscale.newTarget; } } this.stats.toOldVersion; return null; } getStats() { return { ...this.stats, newVersionRatio: this.stats.totalRouted 0 ? ((this.stats.toNewVersion / this.stats.totalRouted) * 100).toFixed(1) % : 0%, }; } } export const grayscaleManager new GrayscaleManager(); export function grayscale() { return (req, res, next) { const route getRouteForPath(req.path); if (!route) return next(); const newTarget grayscaleManager.route(req, route.config); if (newTarget) { req.grayscaleTarget newTarget; res.setHeader(X-Grayscale, new-version); } else { res.setHeader(X-Grayscale, stable); } next(); }; }哈希取模保证同一clientId永远落在同一个桶里不会一会儿新一会儿旧。3.5 代理中间件与主入口代理中间件根据路由配置和灰度结果转发请求// src/middleware/proxy.js import { createProxyMiddleware } from http-proxy-middleware; import { getRouteForPath } from ../config/routes.js; export function proxyMiddleware() { const proxyCache new Map(); return (req, res, next) { const route getRouteForPath(req.path); if (!route) { return res.status(404).json({ code: 404, message: 网关未找到路由: ${req.path}, requestId: req.requestId }); } const { prefix, config } route; let target config.target; if (req.grayscaleTarget) target req.grayscaleTarget; const cacheKey ${prefix}:${target}; if (!proxyCache.has(cacheKey)) { const proxy createProxyMiddleware({ target, changeOrigin: true, pathRewrite: config.pathRewrite, proxyTimeout: 10000, on: { error: (err, req, res) { if (req.circuitBreaker) req.circuitBreaker.recordFailure(); if (!res.headersSent) { res.status(502).json({ code: 502, message: 上游服务不可用, requestId: req.requestId }); } }, proxyRes: (proxyRes, req) { if (req.circuitBreaker) { if (proxyRes.statusCode 500) req.circuitBreaker.recordSuccess(); else req.circuitBreaker.recordFailure(); } }, }, }); proxyCache.set(cacheKey, proxy); } proxyCache.get(cacheKey)(req, res, next); }; }主入口src/app.js把中间件按顺序串起来// src/app.js import express from express; import cors from cors; import { requestLogger } from ./middleware/logger.js; import { rateLimiter } from ./middleware/rate-limiter.js; import { circuitBreaker, getAllBreakersStatus } from ./middleware/circuit-breaker.js; import { grayscale, grayscaleManager } from ./middleware/grayscale.js; import { proxyMiddleware } from ./middleware/proxy.js; import { getRoutes, updateRoute } from ./config/routes.js; const app express(); app.use(cors()); app.use(express.json()); const ipBlacklist new Set(); const logger requestLogger(); app.use(logger); app.use((req, res, next) { const clientIP req.ip || req.connection.remoteAddress; if (ipBlacklist.has(clientIP)) { return res.status(403).json({ code: 403, message: 您的 IP 已被封禁, requestId: req.requestId }); } next(); }); app.get(/api/gateway/status, (req, res) { res.json({ code: 200, data: { uptime: process.uptime(), requests: logger.getStats(), breakers: getAllBreakersStatus(), grayscale: grayscaleManager.getStats(), routes: Object.keys(getRoutes()).length, }, }); }); app.get(/api/gateway/routes, (req, res) { const routes getRoutes(); res.json({ code: 200, data: Object.entries(routes).map(([path, config]) ({ path, target: config.target, description: config.description, rateLimit: config.rateLimit, circuitBreaker: config.circuitBreaker, grayscale: config.grayscale?.enabled ? { percentage: config.grayscale.percentage } : null, })), }); }); app.put(/api/gateway/routes/:encodedPath, (req, res) { const path decodeURIComponent(req.params.encodedPath); updateRoute(path, req.body); res.json({ code: 200, message: 路由 ${path} 已更新, data: getRoutes()[path] }); }); app.get(/api/gateway/breakers, (req, res) { res.json({ code: 200, data: getAllBreakersStatus() }); }); app.put(/api/gateway/grayscale, (req, res) { const { path, enabled, percentage, newTarget } req.body; if (!path) return res.status(400).json({ code: 400, message: 缺少 path 参数 }); updateRoute(path, { grayscale: { enabled, percentage, newTarget } }); res.json({ code: 200, message: 灰度规则已更新: ${path} }); }); app.post(/api/gateway/ip-blacklist, (req, res) { const { action, ip } req.body; if (action add) { ipBlacklist.add(ip); res.json({ code: 200, message: IP ${ip} 已加入黑名单 }); } else if (action remove) { ipBlacklist.delete(ip); res.json({ code: 200, message: IP ${ip} 已移除 }); } else res.json({ code: 200, data: [...ipBlacklist] }); }); const rateLimiterMiddleware rateLimiter(); app.use(/api/users, rateLimiterMiddleware); app.use(/api/products, rateLimiterMiddleware); app.use(/api/orders, rateLimiterMiddleware); app.use(/api/users, circuitBreaker()); app.use(/api/products, circuitBreaker()); app.use(/api/orders, circuitBreaker()); app.use(/api/orders, grayscale()); app.use(proxyMiddleware()); const PORT process.env.GATEWAY_PORT || 3000; app.listen(PORT, () { console.log(API 网关已启动: http://localhost:${PORT}); console.log(管理面板: http://localhost:${PORT}/api/gateway/status); });日志中间件src/middleware/logger.js负责给每个请求分配唯一 ID 并统计响应时间代码较长这里不展开核心是res.on(finish)里记录状态码和耗时。4. 验证请求与压测故障注入配置写完了怎么确认它真的在工作分三步验证。4.1 基础请求验证先启动三个模拟上游服务可以用npx json-server快速起然后启动网关pnpm dev # API 网关已启动: http://localhost:3000 # 管理面板: http://localhost:3000/api/gateway/status正常请求应该返回 200并带上限流响应头curl -i http://localhost:3000/api/users # HTTP/1.1 200 OK # X-RateLimit-Limit: 100 # X-RateLimit-Remaining: 99 # X-Request-ID: a1b2c3d44.2 限流压测订单服务限制每分钟 50 次用循环快速打请求for i in $(seq 1 60); do code$(curl -s -o /dev/null -w %{http_code} http://localhost:3000/api/orders) echo 请求 $i: $code done # 前 50 个返回 200第 51 个开始返回 429被限流时响应体包含retryAfter告诉客户端等多久再试。这个字段很重要客户端可以根据它做退避重试。4.3 熔断故障注入把订单服务的上游进程杀掉然后连续请求# 杀掉上游服务后 for i in $(seq 1 10); do curl -s http://localhost:3000/api/orders | jq .code done # 前 3 次返回 502上游不可用 # 第 4 次开始返回 503熔断保护中查看熔断器状态curl http://localhost:3000/api/gateway/breakers | jq # { # /api/orders: { # state: OPEN, # failureCount: 3, # stateChangedAt: 2025-... # } # }等 30 秒后再请求熔断器进入 HALF_OPEN放一个请求试探。如果上游恢复了连续 3 次成功后状态回到 CLOSED。4.4 灰度分流验证订单服务配置了 10% 灰度带特定 Header 的请求走新版本# 普通请求走稳定版 curl -i http://localhost:3000/api/orders # X-Grayscale: stable # 带 beta Header 走新版本 curl -i -H X-User-Group: beta http://localhost:3000/api/orders # X-Grayscale: new-version # 查看灰度统计 curl http://localhost:3000/api/gateway/status | jq .data.grayscale # { totalRouted: 100, toNewVersion: 10, newVersionRatio: 10.0% }按比例分流时同一 IP 的请求会稳定落在同一个版本不会出现「刷新一次换一个版本」的体验问题。5. 常见报错排查401、local proxy failed、reading choices搭建过程中最容易踩的坑集中在几类报错上我逐个拆解。401 Unauthorized如果你在网关里调用了模型接口做请求分析401 通常是 Key 没配好。检查.env里的TAOTOKEN_API_KEY是否以sk-开头以及请求头是不是Authorization: Bearer sk-xxx。还有一种情况是 Key 复制时带了空格用echo $TAOTOKEN_API_KEY | wc -c确认长度。模型对话调试入口在 https://taotoken.net/chat 先在网页确认 Key 有效再写进代码。local proxy failed / ECONNREFUSED这是代理转发时上游服务没起来。检查routeConfig里的target地址和端口是否正确用curl http://localhost:3001/users直接测上游。如果上游在 Docker 里localhost要换成容器名或宿主机 IP。另外changeOrigin: true必须设置否则某些上游会因为 Host 头不匹配拒绝请求。reading choices of undefined这个报错出现在你解析模型响应时。模型返回的 JSON 结构是{ choices: [{ message: { content } }] }如果你直接取data.choices[0]而响应体是错误信息比如限流或鉴权失败choices就是 undefined。正确做法是先判断res.ok和data.choices是否存在const data await res.json(); if (!data.choices || !data.choices[0]) { throw new Error(模型响应异常: ${JSON.stringify(data)}); } const content data.choices[0].message.content;OAuth token 过期如果你用 Claude Code 的 OAuth 登录方式长时间不用后 token 会失效。重新执行claude login刷新即可。如果用 API Key 方式不存在这个问题。Coding Plan 的配置在 https://taotoken.net/coding-plan 适合需要长期稳定调用的场景。熔断器不触发检查代理中间件的on.proxyRes里有没有调用recordFailure。很多人只写了on.error但上游返回 500 时不会走 error 回调而是走 proxyRes。两个地方都要处理。限流计数不准内存版令牌桶在多实例部署时会各算各的。生产环境要换成 Redis 版用INCREXPIRE或者 Lua 脚本保证原子性。本地开发用内存版没问题但要知道这个边界。灰度比例偏差大哈希取模在样本量小时会有偏差100 个请求分 10% 可能实际是 8 个或 13 个。样本量到 1000 以上会趋近配置值。如果你要精确控制可以维护一个用户白名单而不是纯靠哈希。6. 把网关接入你的项目下一步动作到这里你已经有了一个能跑的网关原型。接下来怎么用到真实项目里我的建议是先把日志和限流接上这两个改动最小、收益最直接。熔断和灰度可以等接口稳定后再加。如果你需要长期用 Claude Code 做这类网关开发Coding Plan 比按次调用更划算配置入口在 https://taotoken.net/coding-plan 。API Key 管理在 https://taotoken.net/api-keys 接入文档在 https://taotoken.net/doc 里面有各语言的调用示例。最后留一个实用技巧网关的配置文件不要硬编码在代码里用环境变量或者配置中心。我试过把routeConfig改成从 JSON 文件读取配合管理 API 动态更新这样改限流阈值不用重启服务。具体做法是在updateRoute里加一个写文件的动作启动时从文件加载。这个改动很小但运维体验提升明显。下一篇会讲用 Claude Code 搭建定时任务调度系统支持 Cron 表达式和 Web 管理面板感兴趣可以关注。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进