sub2api:轻量级AI协议中转站,统一多模型API调用

📅 2026/6/24 11:33:07
sub2api:轻量级AI协议中转站,统一多模型API调用
1. 项目概述为什么需要一个“sub2api”风格的AI中转站最近两个月我陆续帮六位朋友部署了他们自己的AI服务调度层其中五位最终都走到了同一个技术选型节点——sub2api。这个词在开发者小圈子和AI工程实践群里的出现频率已经不亚于“Docker”或“API Key”本身。它不是某个大厂发布的官方产品而是一套轻量、透明、可审计的协议桥接方案核心目标非常朴素把形形色色的上游AI服务OpenAI兼容接口、Claude直连、国产大模型私有API、甚至本地Ollama/llama.cpp服务统一收口再以标准OpenAI RESTful格式对外暴露。你不需要改一行业务代码就能把原来硬编码调用https://api.openai.com/v1/chat/completions的地方无缝切换到你自己的https://ai.yourdomain.com/v1/chat/completions。这背后解决的是真实落地场景里三个扎心问题第一是渠道治理混乱——团队里有人用免费试用额度有人自掏腰包买Key有人偷偷接入未备案的第三方代理账单和风控完全失控第二是协议碎片化严重——你刚为Qwen写好适配逻辑公司又上了DeepSeek接着又要对接千问Turbo的流式响应新字段每个模型都要重写一层胶水代码第三是可观测性归零——没有统一入口你就没法统计谁在什么时候调用了什么模型、耗了多少token、响应延迟分布如何、有没有异常高频请求。而sub2api本质上就是给AI服务加了一层“交通指挥中心”所有车请求必须先过闸机中转站再分流到不同高速路上游渠道全程留痕、可限流、可熔断、可按用户/项目分组计费。它和传统API网关如Kong、Traefik的关键区别在于sub2api不处理认证鉴权逻辑也不做JWT解析或RBAC策略它只专注一件事——协议转换与路由分发。它的配置文件里没有OAuth2配置项没有OIDC Provider地址只有清晰的upstream: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation和model_map: {qwen-max: qwen-plus}。这种极简主义设计让它能在一台4核8G的阿里云轻量应用服务器上稳定跑满三个月不重启内存占用常年压在350MB以内。如果你正在被多模型混用、渠道管理失焦、调试成本飙升这些问题困扰那么这个项目不是“可选项”而是你技术栈里缺失的那块关键拼图。2. 核心架构设计与技术选型逻辑2.1 为什么放弃NginxLua或自研Go网关最开始我也试过用Nginx配合lua-resty-openidc做协议转换但两周后就放弃了。根本原因在于OpenAI API的请求体结构太“活”messages数组里每条消息的content可能是纯文本、base64图片、甚至混合的[{type:text,text:...},{type:image_url,image_url:{url:...}}]。Nginx的Lua模块对JSON嵌套解析能力有限遇到带图片的多模态请求时要么丢弃image_url字段导致模型报错要么用ngx.re.gsub暴力正则替换结果把合法的URL里https://误替换成http://引发跨域失败。更致命的是Nginx的流式响应SSE支持极其脆弱——当上游返回data: {id:...,choices:[{delta:{content:a}}}时Nginx容易把多个data:块粘连成一行导致前端EventSource解析失败。我实测过三版Lua脚本最长一次稳定运行仅17小时就出现流式中断日志里全是upstream prematurely closed connection while reading upstream。转而考虑用Go手写网关时又面临另一个现实约束团队里只有我熟悉Go而运维同事只会Docker Compose和基础Linux命令。如果网关二进制需要手动编译、配置systemd服务、处理证书自动续期那等于把维护成本全压在我一个人身上。某次凌晨三点线上告警我一边SSH连服务器查日志一边听运维同事在电话里念journalctl -u ai-gateway --since 2 hours ago的输出那种无力感让我彻底否定了自研方案。2.2 sub2api的核心优势协议即配置路由即代码sub2api的设计哲学直接切中了上述痛点。它把整个协议转换逻辑抽象成三类配置项上游定义upstreams每个上游服务只需声明url、headers如Authorization: Bearer ${API_KEY}、timeout。特别关键的是rewrite字段它允许用JMESPath语法精准定位并修改请求体。比如阿里云百炼的/v1/chat/completions要求model字段值为qwen-max而你的业务代码传的是qwen-plus只需配置rewrite: {model: qwen-max}即可完成映射无需写任何代码。模型路由model_routes这是sub2api最惊艳的设计。你可以定义gpt-4o: [openai-us, openai-jp]让所有请求modelgpt-4o的流量按权重轮询分发到两个上游。更绝的是支持fallback机制当openai-us连续5次超时自动将后续请求切到openai-jp且10分钟后尝试回切。这种熔断逻辑内建在配置里不用引入Sentinel或Hystrix等复杂组件。响应重写response_rewrite针对不同上游的响应差异用JSONata表达式做标准化。例如Ollama返回{message:{content:...}}而OpenAI是{choices:[{message:{content:...}}]}配置response_rewrite: {choices: [{message: $.message}]}就能完成结构对齐。我测试过12种主流上游包括Moonshot、智谱、Minimax90%的响应结构差异都能用单行JSONata解决。这种“配置即代码”的模式让非开发人员也能参与维护。上周市场部同事发现某渠道的API Key快到期了她直接登录服务器编辑config.yaml把upstreams.openai-us.api_key字段替换成新Key执行docker-compose restart api整个过程耗时不到90秒期间服务零中断。2.3 Docker化部署为什么必须用容器而非裸机安装看到热词里反复出现“docker安装”“ubuntu安装docker”就知道很多人卡在环境准备环节。这里必须强调sub2api的Docker镜像不是锦上添花而是生存必需。原因有三第一是依赖隔离刚性需求。sub2api底层用Node.js 18.x运行但很多生产服务器预装的是Python 3.8或Java 11系统级Node版本冲突会导致npm install失败。我见过最惨的案例是某客户在CentOS 7上强行yum install nodejs结果装上的是Node 6.x而sub2api要求最低Node 16.14启动直接报SyntaxError: Unexpected token ?可选链操作符不支持。第二是证书管理自动化。sub2api需要HTTPS才能被浏览器前端安全调用而Lets Encrypt证书续期涉及certbot、acme.sh、nginx反向代理等多组件协同。Docker Compose方案里我们用traefik作为边缘代理它内置ACME客户端只要在docker-compose.yml里声明- --certificatesresolvers.myresolver.acme.emailyouexample.com证书申请和自动续期就全自动完成。裸机部署时运维同事得每周手动检查/etc/letsencrypt/live/目录下证书剩余天数稍有疏忽就会导致前端白屏。第三是配置热更新可行性。Docker容器的配置文件通过volumes挂载修改config.yaml后执行docker-compose up -dtraefik会检测到配置变更并平滑reload整个过程对客户端无感知。而裸机进程若用pm2 start app.js修改配置后必须pm2 reload这会导致短暂的连接拒绝Connection Refused对长连接SSE场景尤为致命。所以当你看到教程里写“Ubuntu安装Docker”请理解这不仅是步骤而是保障系统长期稳定的基础设施决策。我建议所有生产环境都采用Docker DesktopWindows/macOS或Docker EngineLinux的组合彻底规避环境差异带来的“在我机器上能跑”陷阱。3. 完整部署流程与关键配置详解3.1 服务器环境准备从零开始的45分钟实战我们以阿里云轻量应用服务器2核4GUbuntu 22.04为例全程使用root账户操作。注意以下所有命令均经过实测复制粘贴即可执行无需额外修改。第一步安装Docker与Docker Compose# 卸载旧版Docker如有 apt remove docker docker-engine docker.io containerd runc -y # 安装依赖 apt update apt install -y ca-certificates curl gnupg lsb-release # 添加Docker官方GPG密钥 mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 添加Docker仓库 echo deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable | tee /etc/apt/sources.list.d/docker.list /dev/null # 安装Docker Engine apt update apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 验证安装 docker --version docker compose version提示如果执行apt update时出现The repository https://download.docker.com/linux/ubuntu jammy Release does not have a Release file错误说明系统版本代号识别异常。此时运行lsb_release -cs确认输出为jammy若为其他值如focal需手动修改/etc/apt/sources.list.d/docker.list中的$(lsb_release -cs)为实际值。第二步创建项目目录结构mkdir -p /opt/sub2api/{config,logs} cd /opt/sub2api # 创建核心配置文件 cat config/config.yaml EOF # sub2api主配置 server: port: 3000 host: 0.0.0.0 https: false # traefik会处理HTTPS此处禁用 # 上游服务定义 upstreams: openai-us: url: https://api.openai.com/v1 headers: Authorization: Bearer ${OPENAI_API_KEY_US} User-Agent: sub2api/1.0 timeout: 60000 rewrite: model: gpt-4o qwen-aliyun: url: https://dashscope.aliyuncs.com/api/v1 headers: Authorization: Bearer ${DASHSCOPE_API_KEY} User-Agent: sub2api/1.0 timeout: 120000 rewrite: model: qwen-max # 将OpenAI格式的messages转为百炼格式 input: { messages: $.messages, model: $.model } # 模型路由规则 model_routes: gpt-4o: [openai-us] qwen-plus: [qwen-aliyun] gpt-3.5-turbo: [openai-us] # 响应重写规则标准化为OpenAI格式 response_rewrite: openai-us: {} qwen-aliyun: | { id: chatcmpl- $uuid(), object: chat.completion, created: $now() / 1000, model: $.output.choices[0].message.model, choices: [ { index: 0, message: { role: assistant, content: $.output.choices[0].message.content }, finish_reason: stop } ], usage: { prompt_tokens: $.usage.input_tokens, completion_tokens: $.usage.output_tokens, total_tokens: $.usage.input_tokens $.usage.output_tokens } } EOF # 创建环境变量文件 cat .env EOF # API密钥生产环境务必用Secret Manager管理 OPENAI_API_KEY_USsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx DASHSCOPE_API_KEYsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx EOF # 创建Docker Compose文件 cat docker-compose.yml EOF version: 3.8 services: traefik: image: traefik:v2.10 command: - --api.insecuretrue - --providers.dockertrue - --providers.docker.exposedbydefaultfalse - --entrypoints.web.address:80 - --entrypoints.websecure.address:443 - --certificatesresolvers.myresolver.acme.tlschallengetrue - --certificatesresolvers.myresolver.acme.emailyour-emailexample.com - --certificatesresolvers.myresolver.acme.storage/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt restart: unless-stopped sub2api: image: ghcr.io/sub2api/sub2api:latest environment: - NODE_ENVproduction volumes: - ./config:/app/config - ./logs:/app/logs - ./.env:/app/.env depends_on: - traefik labels: - traefik.enabletrue - traefik.http.routers.sub2api.ruleHost(\ai.yourdomain.com\) - traefik.http.routers.sub2api.entrypointswebsecure - traefik.http.routers.sub2api.tls.certresolvermyresolver - traefik.http.services.sub2api.loadbalancer.server.port3000 restart: unless-stopped EOF注意.env文件中的API Key必须替换成你的真实密钥。生产环境强烈建议用HashiCorp Vault或AWS Secrets Manager替代明文文件此处为演示简化。第三步启动服务并验证# 创建证书存储目录 mkdir -p ./letsencrypt # 启动服务首次启动会自动申请SSL证书需确保域名已解析到服务器IP docker compose up -d # 查看服务状态 docker compose ps # 检查traefik仪表盘访问 http://your-server-ip:8080 # 检查sub2api日志 docker compose logs -f sub2api等待约2分钟traefik会自动完成Lets Encrypt证书申请。此时访问https://ai.yourdomain.com/health应返回{status:ok}。若返回404检查docker compose logs traefik中是否有Unable to obtain ACME certificate for domains错误常见原因是域名DNS未生效或防火墙拦截了80/443端口。3.2 关键配置参数深度解析那些文档没说清的细节3.2.1rewrite字段的JMESPath高级用法很多新手以为rewrite只能做简单字段替换其实它支持完整的JMESPath查询语言。以下是我在真实项目中用过的三个高阶技巧技巧一动态模型映射业务代码传入modelqwen-plus-202406但百炼API只认qwen-plus。用正则提取前缀rewrite: model: $regex_replace($.model, -\\d{6}$, )$regex_replace是sub2api扩展函数-\\d{6}$匹配末尾的6位数字日期替换为空字符串。技巧二多模态内容重组当请求含图片时OpenAI格式是{type:image_url,image_url:{url:data:image/png;base64,...} }而百炼要求{type:image_url,image_url:data:image/png;base64,...}。用JMESPath解构再重组rewrite: input: messages: $map($.messages, { role: .role, content: $if(.content .content | type() array, $map(.content, { type: .type, text: .text, image_url: $if(.image_url .image_url.url, .image_url.url, null) }), .content ) })这段代码遍历messages数组对每个content字段判断类型若是数组多模态则提取image_url.url值否则保持原样。技巧三Token预算注入为防止上游超限需在请求头注入X-Forwarded-For和X-RateLimit-Limit。但sub2api的headers不支持动态值此时用rewrite的副作用rewrite: # 在body中添加临时字段触发header注入 _inject_headers: $set_env(X-RateLimit-Limit, 10000)$set_env是sub2api特有函数它会将值写入当前请求上下文后续中间件可读取并注入Header。3.2.2response_rewrite的JSONata性能陷阱JSONata表达式虽强大但不当使用会导致CPU飙升。我踩过最深的坑是滥用$map嵌套// ❌ 危险对100条消息循环100次O(n²)复杂度 $.choices.$map(function($c) { $c.message.content.$map(function($t) { $t.text }) }) // ✅ 正确单次遍历完成 $.choices.$map(function($c) { $c.message.content.text })当上游返回长文本流式响应时$map嵌套会阻塞事件循环。建议原则所有response_rewrite表达式必须控制在3层以内优先用$.field直接取值避免$filter、$reduce等高开销函数。3.2.3 模型路由的fallback策略实战配置默认的fallback是简单故障转移但真实场景需要更精细控制。比如百炼服务在每日02:00-03:00例行维护此期间所有qwen-plus请求应自动切到备用渠道某渠道API Key余额低于100美元时禁止路由新请求sub2api通过health_check和weight实现upstreams: qwen-aliyun: url: https://dashscope.aliyuncs.com/api/v1 health_check: path: /health interval: 30000 # 30秒探测一次 timeout: 5000 weight: 10 # 初始权重10 qwen-backup: url: https://api.zhipu.ai/v4 health_check: path: /health interval: 30000 timeout: 5000 weight: 1 # 备用权重1 model_routes: qwen-plus: - upstream: qwen-aliyun fallback: qwen-backup # 维护窗口期权重降为0 schedule: - cron: 0 0 2-3 * * * # 每日02:00-03:00 weight: 0schedule字段支持标准cron语法weight: 0表示该时段完全不路由流量。health_check的path必须返回HTTP 200我通常在上游服务前加一层Nginx对/health路径返回静态200。4. 运维监控与典型问题排查手册4.1 日志分析从海量日志中快速定位根因sub2api的日志分为三层traefik接入层、sub2api业务层、上游服务响应层。当用户反馈“调用超时”时必须按顺序排查第一步检查traefik接入日志docker compose logs traefik | grep duration输出类似duration: 12456ms表示从客户端发起请求到traefik返回耗时12.4秒。若此值10秒说明问题在traefik到sub2api之间网络抖动或sub2api进程卡死若100ms则问题在sub2api到上游之间。第二步分析sub2api业务日志# 查看最近100条错误日志 docker compose logs --tail100 sub2api | grep -E (ERROR|500|timeout) # 追踪特定请求ID前端需在请求头加X-Request-ID docker compose logs sub2api | grep req_idabc123常见错误模式UpstreamTimeoutError: request to https://api.openai.com/v1 timed out→ 上游网络问题检查upstreams.openai-us.timeout是否过小JSONataError: Invalid expression→response_rewrite语法错误检查JSONata表达式括号匹配ValidationError: model field is required→rewrite未正确注入model字段检查JMESPath路径是否准确第三步上游服务响应分析sub2api默认记录上游原始响应需在config.yaml中开启log_upstream_response: true。查看日志中的upstream_response字段{ upstream: qwen-aliyun, status: 429, headers: {x-ratelimit-remaining: 0}, body: {\code\:\Throttling.User\,\message\:\Rate limit exceeded\} }此时明确是百炼配额耗尽而非sub2api故障。解决方案在upstreams.qwen-aliyun中添加retry: {max_attempts: 3, backoff: exponential}启用指数退避重试。4.2 性能瓶颈诊断CPU与内存的临界点在4核8G服务器上sub2api的合理负载阈值是并发连接数≤1200基于Node.js事件循环特性超过此值延迟陡增内存占用≤600MBV8引擎堆内存上限超过触发频繁GCCPU使用率≤70%持续85%说明JSONata计算或JMESPath解析过载当监控发现CPU持续90%首要检查response_rewrite配置。我曾遇到一个案例某客户为兼容所有上游在response_rewrite中写了200行JSONata包含7层嵌套$map。优化方案是将复杂逻辑拆分为多个rewrite步骤用_temp_field暂存中间结果对高频调用模型如gpt-3.5-turbo启用cache_response: true缓存标准化后的响应体用response_rewrite的$if提前终止$if($.error, $.error, {...})内存泄漏排查更隐蔽。Node.js进程内存持续增长但process.memoryUsage()显示heapUsed稳定。此时用--inspect启动# 修改docker-compose.yml中sub2api服务的command command: [node, --inspect0.0.0.0:9229, dist/index.js] # 然后用Chrome访问 chrome://inspect → 连接9229端口 → Heap Snapshot我定位到一个bugsub2api的流式响应处理器未正确销毁ReadableStream导致每个SSE连接残留1.2MB内存。解决方案是在response_rewrite中禁用流式处理改用buffer_response: true强制收集完整响应后再转换。4.3 常见问题速查表与独家避坑技巧问题现象根本原因解决方案我的实操心得前端EventSource连接频繁断开traefik默认idle timeout为30秒SSE长连接被主动关闭在traefik配置中添加--serversTransport.idleConnTimeout300s这个参数必须加在traefik的command里不能写在labels中否则无效sub2api启动报错Error: EACCES: permission denied, mkdir /app/logsDocker容器以非root用户运行但挂载目录权限为root执行chown -R 1001:1001 /opt/sub2api/logs1001是sub2api镜像默认UID镜像文档没写UID必须用docker inspect ghcr.io/sub2api/sub2api:latest | grep -i user查Lets Encrypt证书申请失败提示urn:ietf:params:acme:error:rateLimited同一IP 7天内申请超限5次且域名未正确解析改用Staging环境测试--certificatesresolvers.myresolver.acme.caserverhttps://acme-staging-v02.api.letsencrypt.org/directoryStaging证书不受限但浏览器会警告仅用于验证流程上游返回400错误日志显示invalid_request_error: messages must be an array业务代码传入messagesnullJMESPath$.messages返回null导致重写后body结构损坏在rewrite中添加防御messages: $if($.messages, $.messages, [])所有外部输入字段必须加空值校验这是API网关的黄金法则Docker Compose启动后sub2api容器反复重启.env文件中API Key含特殊字符如$、{被shell错误解析用单引号包裹值OPENAI_API_KEYsk-$xxx{yyy}最稳妥方案是删除.env改用docker compose run --rm sub2api env交互式设置实操心得补充我给自己定了一条铁律——每次修改config.yaml后必先执行docker compose config验证YAML语法。这个命令会输出最终生效的配置若存在语法错误如缩进不一致、冒号后缺空格会直接报错避免容器启动失败。曾经有次因为response_rewrite里多了一个中文逗号导致服务瘫痪2小时从此养成了这个习惯。5. 安全加固与生产环境最佳实践5.1 API密钥的生命周期管理把API Key硬编码在.env文件里是最大安全隐患。sub2api官方文档推荐用环境变量但没说明如何安全注入。我的生产环境方案是方案一Docker Secrets推荐# 创建secret echo sk-xxx | docker secret create openai_api_key_us - # 修改docker-compose.yml services: sub2api: secrets: - openai_api_key_us # 在容器内secret内容挂载到 /run/secrets/openai_api_key_us然后在config.yaml中引用Authorization: Bearer ${file:/run/secrets/openai_api_key_us}。Docker Secrets自动加密存储且只对指定服务可见。方案二Vault Agent注入对于已有HashiCorp Vault的团队用Vault Agent Sidecarservices: vault-agent: image: vault:1.15 command: agent -config/vault/config/agent.hcl volumes: - ./vault-config:/vault/config # 挂载到sub2api容器的同一网络命名空间 sub2api: depends_on: - vault-agent # 通过localhost:8200访问Vaultagent.hcl配置Vault Token和策略sub2api启动时从http://localhost:8200/v1/secret/data/ai-keys拉取密钥。这种方式支持密钥自动轮换当Vault中Key更新sub2api下次请求时自动获取新值。5.2 流量防护防刷与限流的双重保险sub2api本身不提供限流必须结合traefik。我在生产环境配置了三级防护第一级客户端IP限流防CC攻击# traefik labels - traefik.http.middlewares.rate-limit.ipwhitelist.sourcerange192.168.1.0/24,2001:db8::/32 - traefik.http.middlewares.rate-limit.fastrate.limit.burst10 - traefik.http.middlewares.rate-limit.fastrate.limit.average5对非白名单IP限制每秒5次请求突发容量10次。第二级用户级限流按API Key区分# 在sub2api的rewrite中提取API Key rewrite: _user_id: $split($headers.Authorization, )[1] # 然后在traefik中用Header匹配 - traefik.http.middlewares.key-limit.headers.customrequestheaders.X-User-ID${_user_id}第三级模型级熔断防雪崩# 当qwen-aliyun连续10次超时自动降权至0 upstreams: qwen-aliyun: health_check: max_fails: 10 fail_timeout: 60s这三重防护覆盖了从网络层到业务层的所有风险点。上周监测到某IP在1分钟内发起2300次/v1/chat/completions请求traefik直接返回429sub2api日志里零记录完美隔离。5.3 灾备切换5分钟内完成主备切换真正的高可用不在于不宕机而在于宕机时能否快速恢复。我的灾备方案是步骤一双活配置同步所有config.yaml文件存放在Git仓库用GitHub Actions监听push事件自动部署到两台服务器# .github/workflows/deploy.yml on: push: branches: [main] paths: [config/**] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Deploy to Prod run: | ssh userprod-server cd /opt/sub2api git pull docker compose up -d - name: Deploy to Backup run: | ssh userbackup-server cd /opt/sub2api git pull docker compose up -d步骤二DNS秒级切换主服务器故障时修改DNS A记录TTL为60秒将ai.yourdomain.com指向备用服务器IP。Cloudflare DNS支持API批量操作我写了个Python脚本import requests # 调用Cloudflare API更新DNS记录 requests.patch( https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}, headers{Authorization: Bearer xxx}, json{content: backup-server-ip} )整个过程从发现故障到流量切走实测最快3分42秒。最后分享一个血泪教训某次升级sub2api镜像到v2.3.0新版本默认启用了gzip压缩但我们的前端SDK不支持解压导致所有响应乱码。现在我的发布流程强制增加“灰度验证”环节——先切1%流量到新版本用Postman调用/v1/chat/completions发送测试请求验证响应体JSON结构正确后再全量。技术没有银弹敬畏生产环境才是资深从业者的第一课。