[{"content":" 利益披露：本文最后介绍的中转站是我自己在运营的。前面三分之二的取舍分析对任何人都成立， 你完全可以看完分析之后选择另外两条路——那也是这篇文章想说清楚的事。\n调用大模型 API 有三条路，大多数人只比较了价格，然后在别的地方付出了没预料到的成本。\n三条路的取舍 路一：直连官方 最干净的一条，也是最应该优先考虑的。你和供应商之间没有中间人，出问题时责任边界清晰， 额度、限速、模型上线时间都以官方为准。\n它的实际成本不在价格上，而在这几处：\n支付方式。 需要能付美元的卡，且部分供应商对特定地区的支付有限制。 网络可达性。 从国内直连的延迟和稳定性，取决于你的出口线路，而不是供应商。 多供应商的碎片化。 一旦你同时用 OpenAI、Google、Anthropic，就要维护三套 SDK、 三套密钥、三套计费口径。这个成本随供应商数量线性增长，而且很容易被低估。 路二：自己搭网关 用 new-api、one-api 这类开源网关，把多家供应商收敛成一个 OpenAI 兼容接口。 我自己就是这么做的，前面几篇文章都在写这件事。\n它解决了碎片化，但把运维责任整个搬到了你身上：\n你要负责的事 做错的后果 反向代理的流式配置 流式输出变成一次性返回，用户体验直接崩 超时分层与重试策略 一次 504 触发客户端重试，账单翻倍 限流的真实 IP 判定 按可伪造的头限流，等于没限 TLS 证书生命周期 过期后所有 SDK 客户端一起硬失败 数据库与日志保留 用量表无限增长，半年后磁盘满、服务停摆 上游账号与额度管理 某个 key 额度耗尽，整条链路静默失败 这张表里的每一行，我都单独写过一篇文章，因为每一行我都实际踩过。 如果你打算走这条路，那几篇可以直接拿去用： 部署形态、 流式缓冲、 超时与重复计费、 限流与真实 IP、 证书自动化、 数据库选型。\n什么时候值得自建：你有稳定的上游渠道、有运维精力、且用量大到中转的差价能覆盖你的时间成本。 什么时候不值得：你只是想赶紧把功能做出来，网关不是你的核心业务。\n路三：用现成的中转站 省掉了运维，代价是你把可用性、计费准确性和数据流向都托付给了运营方。\n这条路最大的问题不是价格，是你没有办法验证对方说的话。 几乎每个中转站都说自己\u0026quot;稳定、快速、官方直连\u0026quot;，但这些词没有一个是可证伪的。 所以选中转站真正该看的是：\n它敢不敢说自己不做什么。 只讲优点的介绍页，等于没有信息量。 出问题时有没有人。 售后渠道是否真实存在、响应大概多久。 计费是否可对账。 你能不能自己拉到用量明细，而不是只看一个余额数字。 技术细节经不经得起问。 流式是不是真流式、超时怎么配的、限流按什么算—— 这些问题问出去，答不上来的多半没认真做过。 我自己那个站 前面说的第四条——\u0026ldquo;技术细节经不经得起问\u0026rdquo;——我把答案提前写好了。\naizhuan.fun 是我自己搭的 new-api 实例。它跑在首尔的一台机器上， 用的是裸二进制 + Caddy + 本机 MariaDB 的组合，不是 Docker。 这个选择的理由、以及上面那张表里六行的具体做法，都在前面那六篇文章里写着—— 你可以拿那些文章当作对这个站的技术审查材料，这也是我写它们的原因之一。\n名字的意思：aizhuan = AI + 赚 这个站从一开始就不是给「研究大模型」的人准备的，而是给用大模型干活、并且要靠产出换钱的人准备的。\n这解释了它的模型清单为什么长成那样——五个分组里三个是生图和视频，而不是清一色的文本对话模型：\n你实际在做的事 会用到的 电商主图、详情页配图、商品换背景 gpt-image-2 系列、gemini-3.x 生图、2k生图 / 4k生图 分组 小说插画、角色设定、同人图 NAI diffusion 系列、Stable Diffusion 系列 短视频素材、分镜、动态封面 doubao-seedance、grok-imagine-video、SD视频 分组 图文内容批量生产 GPT-5.x 系列配合生图模型串起来调 自己做工具再卖给别人 OpenAI 兼容接口，客户端改个 base_url 就能接 分辨率分组（2k / 4k）单独拆出来也是同一个逻辑：做商用素材的人在乎能不能直接交付， 而不是能不能出个缩略图看看效果。\n需要说清楚的一句：这个站提供的是工具和算力，不是收入。 能不能赚到钱取决于你做什么、做得怎么样、有没有出货渠道——这三件事都和 API 没关系。 任何把「接入某个 API 就能赚钱」当卖点的说法都不要信，包括从我这里听到的。 这个品类里充斥着承诺收入的宣传，我不打算加入它们。\n如果你的需求是纯文本对话或者代码补全，市面上专做文本的中转可能更划算； 这里的模型覆盖是往内容生产那一侧偏的。\n功能一：一个接口，替掉一堆 SDK 这是中转站最实在的价值，也是最容易被\u0026quot;模型多\u0026quot;这种话术盖过去的一点。\n不用中转 你的应用 OpenAI Google Gemini xAI Grok 字节 Seedance Stable Diffusion / NAI 5 套 SDK · 5 个密钥 · 5 种计费口径 · 5 种错误格式 任何一家改接口或涨价，你都要跟着改代码 用中转 你的应用 改一行 base_url aizhuan.fun OpenAI 兼容 文本 图像 视频 1 个接口 · 1 个密钥 · 1 份账单 · 53 个模型 换模型只改一个字符串，不动代码结构 左边那五条线不是画着好看——它们代表你要长期维护的五套东西：五份文档、五个密钥的轮换、 五种限流策略、五套错误码的兼容代码。中转站把这五条线收敛成一条，代价是你多了一个依赖方。 这个交换划不划算，取决于你打算同时用几家。 功能二：53 个模型，覆盖三类产出 按「你要交付什么」组织，而不是按模型热度 文本对话 · 代码 GPT-5.6（luna / sol / terra） GPT-5.5 GPT-5.4 · 5.4-mini GPT-5.2 gpt-5.3-codex-spark codex-auto-review 各系列另有 openai-compact 变体 分组 自建号池plus · 官方号池plus 图像生成 gpt-image-1 · 1.5 · 2 · 2-plus gemini-3-pro-image gemini-3.1-flash-image gemini-2.5-flash-image grok-imagine-image（含 edit） Stable Diffusion 2 / 2.5 系列 nai-diffusion-4.5-full 分组 2k生图 · 4k生图 视频生成 doubao-seedance-2.0 · 2.5 doubao-seedance-2.0-mini grok-imagine-video · 1.5 first_frame（首帧控制） first_last_frame（首尾帧） 异步任务，出片后回调 分组 SD视频 端点类型 openai 42 openai-video 11 image-generation 6 合计 53 个模型 同一能力有多个变体（preview / -c 后缀 / 分辨率分组），是为了在质量、速度、成本之间留选择 数据取自站点公开的 /api/pricing 接口，可自行核对 分辨率单独拆成 2k生图 和 4k生图 两个分组，是这份清单里最能说明定位的细节—— 做商用素材的人要的是能直接交付的成品尺寸，而不是先出个缩略图看效果。 视频走异步任务队列，提交后回调取结果，这是视频生成必须的形态。 功能三：不只是裸 API 如果只有 API，那和自己搭一个没区别。实际能省事的是这些配套：\n功能 具体规格 解决什么 Image Studio 独立图形界面 studio.aizhuan.fun 不写代码也能出图，团队里非技术成员可直接用 表单转提示词 填表单 → 自动改写成生图/视频提示词 不用背 prompt 写法，降低上手门槛 参考图 单次最多 9 张 图生图、垫图、风格迁移、角色一致性 文档解析 最大 20 MB / 5 万字符 长文档喂给模型，不用自己切分 生成图保留 7 天 出图后不用立刻下载，但也别当网盘用 用量数据导出 可导出，最小粒度按小时 自己对账，不用只看一个余额数字 可用性监控 接了 Uptime Kuma 站点状态对外可查，不是嘴上说稳定 用量排行榜 日榜 / 周榜 看别人在用什么模型，选型时有参考 第六行值得单独说：前面我列过选中转站要看的四条，其中一条是**「计费是否可对账」—— 你能不能自己拉到用量明细，而不是只看一个余额数字**。按小时粒度的数据导出就是对这条的回答。 这不是我事后找补的说法，那四条标准和这个功能都在，你可以自己对。\n功能四：客户端一键接入 不想写代码的话，主流 AI 客户端可以直接一键导入配置，不用手动填 base_url 和 key：\n① 注册 送 $1 额度 ② 创建 key 选分组、设额度 ③ 接入 一键导入 或 改 base_url ④ 调用 先用 $1 验证 ③ 支持一键导入的客户端 Cherry Studio CC Switch DeepChat Lobe Chat AionUI OpenCat AMA 问天 AI as Workspace 流畅阅读 另有 Windows / Mac 客户端与配置教程 一键导入走的是各客户端自己的协议链接（cherrystudio://、aionui:// 之类）， 点一下把 base_url 和 key 直接写进客户端，不用手抄密钥——手抄是这一步最常见的出错点。 不在列表里的客户端，改 base_url 手动填也一样能用，它是标准 OpenAI 兼容接口。 怎么开始 直接在 aizhuan.fun 注册，不需要邀请码。 注册即送 $1 额度，够把下面的接入示例跑通、把想用的模型挨个试一遍。 充值支持微信和支付宝，没有起充门槛。 后两条正好对上前面提出的两个问题。\n第三条对应「直连官方」那节说的支付摩擦——不需要能付美元的卡，这是选中转站相对直连的主要实际收益之一。\n第二条对应更重要的那个：你没有办法验证一个中转站说的话。 所以别信我上面写的任何一句，用那 $1 自己验：流式是不是真流式、首字节多久、 你要的模型在不在、并发起来会不会掉。验完再决定要不要充钱。\n一个中转站敢不敢让你先验证再付费，本身就是个信号。\n接入示例 它是 OpenAI 兼容接口，绝大多数客户端和 SDK 改两行就能用：\nfrom openai import OpenAI client = OpenAI( base_url=\u0026#34;https://aizhuan.fun/v1\u0026#34;, api_key=\u0026#34;你的 key\u0026#34;, max_retries=0, # 按 token 计费的接口，务必显式关掉自动重试 timeout=600.0, # 生成慢是正常的，别让客户端先放弃 ) 那两行注释不是客套。max_retries 默认是 2，一次超时会让上游生成两次、计费两次—— 这条链路我单独写过一篇， 无论你用哪家中转都建议先看一眼。\n我不承诺什么 这一节比上面所有内容都重要，因为它是可证伪的。\n不承诺 100% 可用。 上游会抖、机房会抽风、模型会临时下线。 我能承诺的是出问题时有人在，以及事后会说清楚发生了什么。 不承诺永远最便宜。 中转站的价格随上游成本浮动，任何\u0026quot;永久最低价\u0026quot;的说法都不可信。 不承诺模型和官方完全同步。 新模型上线需要时间接入和测试。 不承诺替代直连。 如果你有稳定的官方渠道和付款方式，直连仍然是更干净的选择—— 这话写在推广文里看着奇怪，但它是真的。 最后 选哪条路取决于你在乎什么：\n在乎责任边界清晰 → 直连官方 在乎掌控力，且愿意承担运维 → 自建，那六篇文章拿去用 在乎尽快开始做正事 → 用现成的，包括但不限于我这个 三条路我都走过，所以这篇文章里没有一句\u0026quot;中转站是唯一选择\u0026quot;。 如果看完你决定自建，我反而挺高兴——那六篇文章就没白写。\n相关阅读 new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少 反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法 大模型 API 中转的超时与重试：一次 504 可能让你双倍计费 ","permalink":"https://ambertu.com/posts/ai-api-relay-how-to-choose/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e利益披露\u003c/strong\u003e：本文最后介绍的中转站是我自己在运营的。前面三分之二的取舍分析对任何人都成立，\n你完全可以看完分析之后选择另外两条路——那也是这篇文章想说清楚的事。\u003c/p\u003e","title":"大模型 API 中转站怎么选：直连、自建、用现成的，三条路的真实成本"},{"content":"给大模型 API 中转选反向代理，网上的对比文章大多在比性能、比配置语法、比模块生态。这些差异对一个 API 网关来说其实都不重要——真正的分水岭是证书生命周期由谁管理。\n原因很简单：对普通网站，证书过期是\u0026quot;用户看到一个可怕的警告，但能点继续\u0026quot;；对 API 网关，证书过期是所有客户端硬失败的全站中断。Python 的 httpx、requests、OpenAI SDK 全都会直接抛 SSLError，没有\u0026quot;点继续\u0026quot;这个选项。而且报错文本是 certificate verify failed，第一眼看着像客户端环境问题，会把人引到完全错误的方向排查。\n一张手动证书的真实时间线 我手上一台机器用的是 nginx 加商业 DV 证书（TrustAsia 签发），实测有效期：\nnotBefore = Jun 18 08:00:00 2026 GMT notAfter = Sep 16 07:59:59 2026 GMT 正好 90 天。查证书那天是 9 月 3 日，距到期 13 天。\n这台机器上跑着 API 服务和一个静态站，证书是手工申请、手工安装的。它现在没坏，问题是它的存续完全依赖\u0026quot;有人记得\u0026quot;——记得到期日、记得去续、记得装上去、记得 reload。这三个月里只要有一次没记住，就是一次全站中断。\n90 天证书生命周期 手动 签发 到期 今天 · 剩 13 天 ⚠ 灰色区间是风险窗口：全靠有人记得。忘一次 = 一次全站中断。 ACME 约 2/3 处自动续期 还有 30 天缓冲用于重试 永远不会接近右端。续期失败还有一个月时间告警和补救。 两者的差别不是\"自动比手动省事\"，而是失败模式完全不同： 手动方案的失败是静默的、突然的、全量的；ACME 方案即使续期失败，也还有约 30 天窗口留给告警和人工介入。 顺带说一个正在发生的趋势，它会让手动方案越来越站不住：证书有效期正在被行业强制缩短。 按 CA/Browser Forum 已通过的决议，TLS 证书最长有效期会分阶段从原来的一年多压到 200 天、100 天，最终到 47 天量级。手动续期在一年一次的年代还算可行，压到两个月一次就必然出事——这不是效率问题，是可靠性问题。\n两者的实际差异 维度 Caddy nginx 证书管理 内置 ACME，默认自动申请与续期 本身不管，需要 certbot + 定时器 反代能力 够用 够用 流式 / SSE 对 text/event-stream 默认刷新 需显式关 proxy_buffering 和 gzip 复杂 rewrite / 正则路由 能做，但表达力弱一些 更强，且久经考验 模块生态 较小 大得多 配置语法 Caddyfile，简洁 熟悉的人多 内存占用 略高（Go 运行时） 略低 出网要求 需要能访问 ACME 目录 无 看这张表要抓重点：除了第一行和最后一行，其余差异对一个 API 网关都不构成决策依据。 反代能力两边都够，性能差异在 LLM 场景里被上游生成耗时完全淹没——你的瓶颈是模型在想，不是代理在转发。\nCaddy 的代价也要说清，别只看好处：\n需要出网访问 ACME 目录。 内网隔离环境、或出网被严格管控的机器上，自动签发根本走不通，这时 Caddy 的核心优势直接消失。 on_demand_tls 是个陷阱。 配错了会给任意 Host 头签发证书，等于把签发配额敞开给别人刷。要用就必须配 ask 端点做白名单校验。 复杂路由场景 nginx 更稳。 如果你已经有一大堆 nginx 的 location 规则和正则，重写成 Caddyfile 的风险可能高于收益。 不换 nginx 的话，certbot 要配对 大多数人的现实是 nginx 配置已经很复杂、不想动。那就把证书自动化补上，同样能解决问题：\n# 首次签发。--deploy-hook 是关键，否则证书换了但 nginx 还在用内存里的旧的 certbot certonly \\ --webroot -w /var/www/acme \\ -d ambertu.com -d www.ambertu.com \\ --deploy-hook \u0026#34;systemctl reload nginx\u0026#34; # 确认自动续期的定时器真的在跑 —— 这一步很多人跳过 systemctl list-timers | grep certbot # 演练一次，别等到真到期才发现签发链路是坏的 certbot renew --dry-run 三个容易漏的点：\n--deploy-hook 不能省。 证书文件换了，但 nginx 不会自己重新读——不 reload 就等于没续。 systemctl list-timers 必须确认。 certbot 的自动续期靠 systemd timer（或 cron），装了 certbot 不代表定时器启用了。 --dry-run 要定期跑。 签发链路可能因为 DNS 变更、防火墙调整、challenge 路径被占而悄悄失效，--dry-run 是唯一能提前发现的手段。 一个共享域名下的真实坑：ACME 验证被吞掉 这个坑值得单独讲，因为它只在\u0026quot;一个域名下住着多个应用\u0026quot;时出现，而这种架构相当常见。\nHTTP-01 验证要求 http://your-domain/.well-known/acme-challenge/\u0026lt;token\u0026gt; 能被访问到。问题是：这个路径会命中哪个 location？\nGET /.well-known/acme-challenge/TOKEN 会命中哪个 location？ ❌ 没有专用 location certbot nginx location / try_files 站点根目录/.well-known/… 文件不在这里 404 签发失败，报错只说\"验证不通过\"，不会告诉你是哪个 location 吃掉了请求。 即使把 webroot 指对了，下一次部署静态站又会把 .well-known 目录清掉。 ✓ 加一个专用 location，指向独立目录 certbot location /.well-known/acme-challenge/ 前缀更长 /var/www/acme/.well-known/… 200 前缀 location 更长，优先命中；目录独立于任何应用的部署产物，部署脚本碰不到它。 这个 location 必须在 80 端口的 server 块里 —— 别被 301 到 443 拦掉（见下）。 共享域名下这个坑有两重：验证路径可能落到错误的目录，而正确的目录又可能被部署脚本清空。 一个指向独立目录的专用 location 同时解决两个问题。 正确的配法：\n# 80 端口的 server 块里 —— 顺序很重要 server { listen 80; server_name ambertu.com www.ambertu.com; # ACME 验证必须放在 301 之前，否则请求会被跳转走 location /.well-known/acme-challenge/ { root /var/www/acme; } location / { return 301 https://$host$request_uri; } } 顺序是关键。 如果 80 端口的 server 块里只有一句 return 301 https://...，那么 ACME 的验证请求会被 301 走。Let\u0026rsquo;s Encrypt 会跟随跳转到 443，所以这种情况有时也能成——但如果 443 上的 location / 又用 try_files ... =404 兜底，请求最终还是 404。加一个显式的前缀 location 才是确定的做法，不依赖跳转链路碰巧走对。\n再说一遍那个连带的坑：验证目录要放在任何应用的部署产物之外。 如果你把 webroot 指向静态站的根目录，而部署脚本用的是 rsync --delete 式的镜像同步，.well-known 会在下一次发版时被删掉——证书在三个月后续期失败，而当时没有人会想起两个月前改过部署脚本。\n混合方案 如果 nginx 配置太复杂不想重写，但又想要自动证书，可以让 Caddy 只做 TLS 终止：\n外网 → Caddy :443（自动证书） → nginx :8080（全部现有路由逻辑） → 各应用 Caddyfile 简单到几行：\nambertu.com, www.ambertu.com { reverse_proxy 127.0.0.1:8080 { flush_interval -1 # 保住 SSE 流式 header_up X-Real-IP {remote_host} } } 代价是多一跳（本机回环，延迟可忽略），以及 nginx 那边要改成监听 8080 且信任来自本机的 X-Real-IP——这一步别搞错，否则所有客户端 IP 都会变成 127.0.0.1，按 IP 的限流会一起误伤。\n无论用哪个：把到期日变成告警 这是这篇文章最该带走的一条。再好的自动化也可能静默失效，所以到期监控是独立于方案选择的必需品：\n#!/bin/sh # /usr/local/bin/cert-expiry-check.sh DOMAIN=\u0026#34;${1:-ambertu.com}\u0026#34; THRESHOLD=20 END=$(echo | openssl s_client -connect \u0026#34;$DOMAIN:443\u0026#34; -servername \u0026#34;$DOMAIN\u0026#34; 2\u0026gt;/dev/null \\ | openssl x509 -noout -enddate | cut -d= -f2) [ -z \u0026#34;$END\u0026#34; ] \u0026amp;\u0026amp; { echo \u0026#34;无法获取证书：$DOMAIN\u0026#34;; exit 2; } DAYS=$(( ( $(date -d \u0026#34;$END\u0026#34; +%s) - $(date +%s) ) / 86400 )) echo \u0026#34;$DOMAIN 证书剩余 $DAYS 天（到期 $END）\u0026#34; [ \u0026#34;$DAYS\u0026#34; -lt \u0026#34;$THRESHOLD\u0026#34; ] \u0026amp;\u0026amp; { echo \u0026#34;低于 $THRESHOLD 天，需要处理\u0026#34;; exit 1; } exit 0 两点设计考虑：\n从外部探测，而不是读本地证书文件。 读文件只能证明\u0026quot;磁盘上那份还有效\u0026quot;，探端口才能证明\u0026quot;nginx 实际吐出去的那份还有效\u0026quot;。两者不一致正是\u0026quot;续了但忘了 reload\u0026quot;这个经典故障的样子。 阈值设 20 天而不是 3 天。 要留出的不是\u0026quot;续期需要的时间\u0026quot;，而是\u0026quot;发现问题、排查、找人、处理\u0026quot;的时间。 配个定时任务每天跑一次，非零退出就告警。这十几行东西能挡住的，是那种\u0026quot;周末证书过期、周一早上所有客户端集体报 SSL 错误\u0026quot;的事故。\n相关阅读 大模型 API 中转的超时与重试：一次 504 可能让你双倍计费 —— 另一类会让全部请求一起失败的配置问题 反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法 —— 换成 Caddy 后 SSE 的行为差异 API 中转站的限流为什么不能信 X-Forwarded-For —— 混合方案里 X-Real-IP 那一跳为什么必须配对 ","permalink":"https://ambertu.com/posts/caddy-vs-nginx-api-gateway-tls/","summary":"\u003cp\u003e给大模型 API 中转选反向代理，网上的对比文章大多在比性能、比配置语法、比模块生态。这些差异对一个 API 网关来说其实都不重要——\u003cstrong\u003e真正的分水岭是证书生命周期由谁管理。\u003c/strong\u003e\u003c/p\u003e","title":"Caddy 还是 nginx 给 API 中转做反代：证书自动化是决定性差异"},{"content":"nginx 的 proxy_read_timeout 默认是 60 秒，而长上下文或推理模型的单次生成经常超过这个数。超时之后 nginx 返回 504，看起来只是\u0026quot;一次失败\u0026quot;——但真实后果比这严重：上游很可能已经把生成做完并计了费，而客户端 SDK 收到 504 会自动重试，于是上游又生成一次、又计一次费。\n客户端最终只拿到一个响应，你的账单上是两笔。更麻烦的是这件事在监控里几乎看不出来：错误率只涨了一点，成本却在悄悄翻倍。\n先纠正一个常见的误解. 网上很多文章会告诉你 nginx 的 proxy_next_upstream 会把超时请求转给下一个上游、导致重复计费。这个说法对 LLM API 来说是不准确的，值得先说清楚，否则你会去改一个本来没问题的配置。\nproxy_next_upstream 的默认值确实是 error timeout，但 nginx 从 1.9.13 起有一条额外规则：\n非幂等方法（POST、LOCK、PATCH）的请求，一旦已经发送给上游，就不会再转给下一台服务器——除非你显式加上 non_idempotent 选项。\nLLM 的 chat completions 走的是 POST，所以 nginx 默认行为是安全的。真正的重复计费来源有两个，都不是 nginx 的默认配置：\n有人手工加了 non_idempotent——一些\u0026quot;提升可用性\u0026quot;的高可用教程会教这个，在普通 Web 服务上无害，在按 token 计费的接口上是直接烧钱。 客户端 SDK 自己在重试——这是绝大多数情况的元凶。OpenAI 官方 Python SDK 的 max_retries 默认是 2，重试触发条件包含连接错误、408、409、429 和所有 5xx。nginx 吐的 504 正好落在 5xx 里。 双倍计费是怎么发生的 客户端 nginx 上游 API t=0s POST /v1/chat/completions 生成中 第 1 次 已计费 ① t=60s 504 Gateway Timeout proxy_read_timeout 到点 上游不知道 nginx 放弃了， 继续把生成做完 t=60s SDK 自动重试（max_retries=2） 生成中 第 2 次 又计费 ② t=135s 200 OK 客户端拿到 1 个响应 · 账单上 2 笔 · 监控里只是错误率轻微上升 链条的关键一环是上游并不知道 nginx 已经放弃——它按自己的节奏把生成做完。是否计费取决于供应商策略， 但安全假设是\"已经计了\"：连接中断后仍收费的供应商很常见，你无法事后核对。 流式请求几乎不会撞上这个问题 这是很多人不知道的一点，也是\u0026quot;默认就该用流式\u0026quot;的一个纯运维理由。proxy_read_timeout 的官方定义是：\n该超时只作用于两次连续读操作之间，而不是整个响应的传输时长。\n所以：\n非流式请求：从发出到收到完整 JSON，中间是一个长长的空隙。生成花 75 秒，就是一个 75 秒的读间隔 → 撞上 60 秒超时。 流式请求：token 一个接一个到达，每两次读之间只隔几百毫秒 → 无论总时长多久都不会触发超时。 nginx 眼中的「两次读操作之间的间隔」 60s 超时线 非流式 一个 75s 的读间隔 → 在 60s 处被切断 流式 每个间隔都远小于 60s → 穿过超时线也毫发无伤 本图为示意，用于说明超时的计量方式 同一个总时长、同一个超时配置，结果完全不同。非流式请求才是超时的重灾区。 如果你的中转站还有大量非流式调用，这就是优先要治的地方——而不是一味把超时往上调。 正确的超时分层 关键是区分\u0026quot;连不上\u0026quot;和\u0026quot;生成慢\u0026quot;：前者应该快速失败，后者必须耐心等。用一个统一的大数字把三个超时都拉长，是把两种完全不同的故障混在一起处理。\nlocation /v1/ { proxy_pass http://upstream_pool; # 连不上 → 快速失败。请求还没发出去，此时重试是安全的 proxy_connect_timeout 5s; # 发送请求体 → 大 prompt 也就几百 KB，给足但不必夸张 proxy_send_timeout 60s; # 读响应 → 生成慢是正常的，不是故障。这里必须放宽 proxy_read_timeout 600s; # LLM 是非幂等的：绝不要开 non_idempotent # 保守起见连 timeout 重试也去掉，只保留连接阶段的重试 proxy_next_upstream error; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s; # 流式必须配套，否则响应被攒起来又变回一个长读间隔 proxy_buffering off; gzip off; proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; } 按故障性质分层，而不是统一拉长 proxy_connect_timeout 5s 连不上 → 快速失败 请求未发出，重试安全 ✓ proxy_send_timeout 60s 上传 prompt 已发出，重试即重复计费 ⚠ proxy_read_timeout 600s 生成慢 → 不是故障 这一层千万别按 60s 算 ⚠ SDK max_retries 0 或 1 客户端侧 默认 2，是双倍计费主因 灰底那一行是唯一需要显著放宽的；虚线那一行不在服务端，但它才是账单翻倍的直接原因，容易被漏掉。 客户端侧要做的事 服务端配对了，客户端如果还在盲目重试，问题只是变少而不是消失。\nfrom openai import OpenAI client = OpenAI( base_url=\u0026#34;https://your-gateway/v1\u0026#34;, api_key=\u0026#34;...\u0026#34;, max_retries=0, # 默认是 2，对按 token 计费的接口必须显式关掉 timeout=600.0, # 客户端超时也要放宽，否则轮到它先放弃 ) 关掉自动重试之后，重试逻辑要自己写，并且只在明确安全的错误上重试：\n错误 能否重试 原因 连接失败 / DNS 失败 可以 请求没发出去，上游没开始生成 429 限流 可以 上游明确拒绝了，没有生成 400 参数错误 不该重试 重试一万次也是同样结果 401 / 403 不该重试 凭据问题 504 / 502 危险 上游可能已经生成完了，重试就是第二笔账单 流式中途断开 危险 已经生成了一部分，且已产生费用 更彻底的办法：在中转层做请求去重 如果你控制中转层（自建 new-api 之类就是这种情况），可以在网关上加一层短窗口去重，把重复计费在源头掐掉：\n思路是给每个请求算一个指纹——模型名 + 消息体 + 关键参数的哈希——配上客户端标识，在 Redis 里存一个几十秒的短 key。窗口内出现相同指纹时，不再打上游，直接返回上一次的结果或一个明确的\u0026quot;重复请求\u0026quot;错误。\n这个做法的取舍要讲清楚：\n好处：无论客户端怎么重试，上游只被打一次，账单可控。 代价：LLM 本身是有随机性的（temperature \u0026gt; 0 时同样输入本该有不同输出），去重会让窗口内的重复请求拿到相同结果。对聊天场景通常无害，对\u0026quot;故意重新生成一次\u0026quot;的功能就是 bug。 所以：窗口要短（几十秒量级），并且给客户端一个显式绕过的办法（比如一个请求头或一个随机 nonce 参数）。 怎么发现自己已经在被双倍计费 从日志里找证据，不用等账单。特征是：同一客户端在很短时间内发出内容完全相同的请求，且其中至少一个返回了 504。\n# 找 504：如果这个数不是 0，就值得往下查 awk \u0026#39;$9==504 {print $1, $7}\u0026#39; /var/log/nginx/access.log | sort | uniq -c | sort -rn | head # 找 504 之后紧跟着重试的模式：同 IP、同路径、几秒内连续多次 awk \u0026#39;$9==504 || $9==200 {print $1, $4, $9, $7}\u0026#39; /var/log/nginx/access.log \\ | grep \u0026#39;/v1/chat/completions\u0026#39; | tail -50 更可靠的信号是把上游用量与自己记录的成功响应数对账：如果上游统计的请求数明显高于你返回给客户端的成功数，差额基本就是被白烧掉的重试。\n相关阅读 反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法 —— 本文说流式不容易超时，前提是流式真的没被缓冲掉 Caddy 还是 nginx 给 API 中转做反代：证书自动化是决定性差异 —— 另一类会让全部请求一起失败的运维故障 new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少 —— 超时配置所在的那层反代怎么部署 给 API 中转站选数据库：SQLite / MySQL / PostgreSQL 在小机器上的取舍 —— 本文说的\u0026quot;拿用量日志对账\u0026quot;，那张表本身也会变成问题 ","permalink":"https://ambertu.com/posts/llm-api-timeout-retry-double-billing/","summary":"\u003cp\u003enginx 的 \u003ccode\u003eproxy_read_timeout\u003c/code\u003e 默认是 \u003cstrong\u003e60 秒\u003c/strong\u003e，而长上下文或推理模型的单次生成经常超过这个数。超时之后 nginx 返回 504，看起来只是\u0026quot;一次失败\u0026quot;——但真实后果比这严重：\u003cstrong\u003e上游很可能已经把生成做完并计了费，而客户端 SDK 收到 504 会自动重试，于是上游又生成一次、又计一次费。\u003c/strong\u003e\u003c/p\u003e","title":"大模型 API 中转的超时与重试：一次 504 可能让你双倍计费"},{"content":"new-api 这类中转程序通常同时支持 SQLite、MySQL 和 PostgreSQL，安装文档也就一句\u0026quot;按需选择\u0026quot;。但在一台 2G 内存的小机器上，这个选择是有真实代价的——光是数据库进程本身就可能吃掉可用内存的四分之一。\n先给一个锚点。我实测一台 2 核 2G 的云主机，上面跑着 nginx、Redis 和两个 Go 服务，MariaDB 的进程 RSS 是：\nmysql 14.5% 291932 KB /usr/libexec/mariadbd 285 MB，是这台机器上最大的单个进程。而整机 available 只有 1.1 G。换句话说，你还没开始跑业务，数据库已经拿走了四分之一的可用余量。\n选型的真正依据：写入量 ≈ 请求量 在动手比较之前，先想清楚 API 中转站的负载长什么样，否则比较是没有意义的。\n读：API key 校验、用户配置、配额查询。数据量小、可缓存、频率高——三种数据库都轻松应对，不构成差异。 写：每次请求落一条用量/计费记录。这是全部的关键：写入速率基本等于请求速率。 所以问题可以化简成一句：你每秒有几次请求？\n请求量 每天写入行数 SQLite 是否够用 1 req/s 约 8.6 万 够，且很轻松 10 req/s 约 86 万 勉强够，需调好 WAL 和 busy_timeout 50 req/s 约 430 万 不够，写入会明显排队 多实例部署 —— 不行，SQLite 无法跨进程跨机共享 大多数个人自建的中转站落在第一档，很多人却直接上了 MySQL——在小机器上这笔开销是白花的。\n三种数据库的内存画像 数据库常驻内存（2G 机器 · available ≈ 1126 MB） SQLite ≈ 0 MB 常驻 库跑在应用进程里，没有独立进程 MariaDB 285 MB 实测 约 150 MB（关掉 performance_schema 并调小 buffer pool 后） PostgreSQL shared_buffers 128 MB（默认） + 每个连接一个独立进程，随连接数线性增长 只有 285 MB 那个数是实测值，其余为默认配置下的典型量级。 PostgreSQL 的总占用高度依赖 max_connections —— 它是进程模型，不是线程模型。 SQLite 仍会用到系统 page cache，但那部分是可回收的，不计入进程 RSS。 PostgreSQL 那一行的画法是有意的：它的内存不是一个固定数字，而是「基础 + 连接数 × 每连接」。 在小机器上 max_connections 配错，比 shared_buffers 配错更容易出事。 并发写入模型的差异 并发写请求的处理方式 SQLite 写 A 写 B 写 C 单写者队列 串行化 db 文件 WAL 模式下读不阻塞， 但写仍然一个一个来。 busy_timeout 必须配 MySQL PostgreSQL 写 A 写 B 写 C 连接/工作进程 连接/工作进程 连接/工作进程 行级锁 真并发写，但代价是 常驻内存和连接管理。 选型问题因此变成：你的写入速率会不会撞上 SQLite 的单写者上限。 SQLite 的\"单写者\"经常被误解成\"不能并发\"。WAL 模式下读写是可以并发的， 受限的只有写与写之间。对写入速率等于请求速率的中转站来说，这个限制什么时候变成问题是可以直接算出来的。 SQLite：如果选它，这几个 pragma 不能少 默认配置的 SQLite 在并发场景下表现很差，而这常被误当成\u0026quot;SQLite 不能上生产\u0026quot;的证据。实际要配的就几行：\nPRAGMA journal_mode = WAL; -- 读写不再互相阻塞。默认的 delete 模式才是性能灾难 PRAGMA busy_timeout = 5000; -- 遇锁等 5 秒而不是立刻报 SQLITE_BUSY PRAGMA synchronous = NORMAL; -- 配合 WAL 的合理折中 PRAGMA foreign_keys = ON; -- 默认是关的，很多人不知道 PRAGMA cache_size = -32000; -- 负数表示 KB，这里约 32 MB 其中 busy_timeout 是最容易漏、后果最直接的一条。不配它，两个并发写会有一个立刻收到 SQLITE_BUSY 失败——表现出来就是\u0026quot;偶发写入失败\u0026quot;，而你会以为是 SQLite 不稳定，其实只是它在如实告诉你\u0026quot;锁被占了\u0026quot;，而你没让它等。\nsynchronous = NORMAL 的取舍要说清：配合 WAL 时它能抵御应用崩溃，但操作系统崩溃或断电可能丢掉最近几个已提交事务。对用量日志这种损失几条可接受的数据没问题；如果这张表直接用于扣款对账，就该用 FULL。\n备份不能直接 cp。 WAL 模式下 -wal 文件里有还没合并进主库的数据，直接复制主文件会拿到一个不一致的快照：\n# ✓ 正确 sqlite3 /path/to/app.db \u0026#34;VACUUM INTO \u0026#39;/backup/app-$(date +%F).db\u0026#39;\u0026#34; # ❌ 错误：WAL 模式下这样拷出来的库可能缺最近的写入 cp /path/to/app.db /backup/ MySQL / MariaDB：小机器上的调优 如果已经在用 MySQL 系，285 MB 是可以压下来的：\n[mysqld] # 关掉它就能省几十 MB，个人站几乎不需要 performance_schema = OFF innodb_buffer_pool_size = 96M innodb_buffer_pool_instances = 1 # 池子小的时候拆成多个实例反而浪费 innodb_log_file_size = 32M # 每个连接都要分配缓冲区，这个数不是免费的 max_connections = 30 key_buffer_size = 8M tmp_table_size = 16M max_heap_table_size = 16M performance_schema = OFF 是性价比最高的一条——它默认开启，为性能诊断收集大量数据，而个人站基本用不上。代价是出问题时少了一套诊断视图，需要时再临时打开即可。\nmax_connections = 30 看着很小，但要算清楚：中转程序的连接池通常配 10–20，加上你偶尔用客户端连进去看数据，30 足够。这个数配成 151（默认值）不会立刻占用内存，但它决定了内存占用的上限，在没有 swap 的机器上等于把 OOM 的可能性留在了那里。\nPostgreSQL：注意它是进程模型 PostgreSQL 和 MySQL 最大的运维差异在这里：每个连接是一个独立的操作系统进程，不是线程。 所以连接数对内存的影响比 MySQL 显著得多。\nshared_buffers = 128MB # 通用建议是内存的 25%，但共享机器上别照抄 max_connections = 20 # 进程模型下这个数很值钱 work_mem = 2MB # 注意：单次查询可能同时用掉好几份 maintenance_work_mem = 32MB effective_cache_size = 512MB # 这只是给优化器的提示，不实际占用 work_mem 有个容易踩的坑：它是每个排序/哈希操作的上限，不是每个连接的上限。一个复杂查询里有多个排序节点时会各分配一份，并发多个这样的查询，内存就会超出直觉。小机器上宁可配小，让它落盘排序。\n如果应用的连接池配得比较大，考虑在前面加 pgbouncer 做连接复用——但这又是一个常驻进程，在 2G 机器上要权衡它值不值。\n比选型更要紧的问题：日志表会无限长大 这是本文最想强调的一点。无论你选哪个数据库，真正会在半年后咬你的是用量日志表。\n机制很简单：每个请求写一行，从不删除。按前面的换算：\n10 req/s → 每天 86 万行 → 一年约 3.1 亿行 每行假设 300 字节 → 一年约 90 GB 后果不是\u0026quot;磁盘满了\u0026quot;这么简单，而是一连串的连带效应：查询变慢 → 管理后台的用量统计页面开始超时 → 备份时间越来越长 → 最后磁盘真的满了，而磁盘满会让数据库直接拒绝写入，整个中转站停摆。\n所以从第一天就该有保留策略：\n-- 最朴素但有效：定期删除旧数据 + 保留聚合结果 DELETE FROM logs WHERE created_at \u0026lt; NOW() - INTERVAL 90 DAY; -- 删之前先把要长期保留的口径聚合出来 INSERT INTO usage_daily (day, user_id, model, requests, tokens) SELECT DATE(created_at), user_id, model, COUNT(*), SUM(total_tokens) FROM logs WHERE created_at \u0026lt; NOW() - INTERVAL 90 DAY GROUP BY 1, 2, 3; 两点提醒：\n大表上的 DELETE 本身就是个事故来源。 一次删几千万行会长时间持锁、把 binlog/WAL 撑爆。要分批删（每次几千行、中间 sleep），或者用按时间分区的表直接 DROP PARTITION——后者是从一开始就该做的设计。 SQLite 删完记得 VACUUM，否则文件大小不会回落（空间被标记为可复用但不归还给文件系统）。注意 VACUUM 需要与库等大的临时空间，磁盘快满时反而做不了——所以别等到那时候。 决策建议 场景 选择 理由 个人自建、单实例、几 req/s SQLite 零常驻内存，省下的 285 MB 在 2G 机器上是实打实的。配好 WAL 和 busy_timeout 就够 2G 机器、已有 MySQL 在跑 复用现有 MySQL 再起一个数据库进程是纯浪费。调优比换数据库收益大 请求量大 / 计划多实例 PostgreSQL 或 MySQL SQLite 无法跨进程共享，这是硬限制而非性能问题 已经在用 Docker 编排 跟着编排走 容器化环境里 SQLite 的数据卷管理反而更麻烦 需要复杂用量分析查询 PostgreSQL 窗口函数、CTE、JSON 查询能力更强，统计报表写起来省事 最后一条实践建议：先从 SQLite 开始不是妥协。 但要知道 SQLite → MySQL 的迁移不是无痛的——SQLite 是动态类型（type affinity），日期存储没有原生类型，AUTOINCREMENT 语义也不同。如果你已经能预见到半年内会上多实例，那一开始就选 MySQL 或 PostgreSQL，省掉一次迁移。\n相关阅读 new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少 —— 那台机器 285 MB 的 MariaDB 实测数据出自这里 大模型 API 中转的超时与重试：一次 504 可能让你双倍计费 —— 用量日志正是核对重复计费的依据 Caddy 还是 nginx 给 API 中转做反代：证书自动化是决定性差异 —— 同一台小机器上的另一项运维取舍 ","permalink":"https://ambertu.com/posts/api-gateway-database-choice-small-vps/","summary":"\u003cp\u003enew-api 这类中转程序通常同时支持 SQLite、MySQL 和 PostgreSQL，安装文档也就一句\u0026quot;按需选择\u0026quot;。但在一台 2G 内存的小机器上，这个选择是有真实代价的——\u003cstrong\u003e光是数据库进程本身就可能吃掉可用内存的四分之一。\u003c/strong\u003e\u003c/p\u003e","title":"给 API 中转站选数据库：SQLite / MySQL / PostgreSQL 在小机器上的取舍"},{"content":"我是 Ambertu，做后端和独立产品，主要写 Go。\n这个博客记录三件事：\n大模型 API 中转与网关——自建 API 网关的部署形态、反向代理配置、超时与计费、限流与鉴权。 小内存服务器运维——一台 2 核 2G 的云主机上同时跑几个生产服务，是什么样的取舍。 踩坑复盘——把排查过程和最终结论一起写下来，包括那些\u0026quot;看起来像 A 问题其实是 B 问题\u0026quot;的时刻。 写作原则 只写验证过的东西。 每篇文章里的版本号、内存占用、报错原文、耗时数据，都来自我实际跑过的机器。 如果某个数字我没有实测，我会说明它是估算范围，而不是编一个具体值。\n原因很实际：技术文章会被人拿去照着配生产环境。一个编出来的阈值、一个想当然的内存估算， 传播出去就是在害人。所以这里宁可少写，也不写没把握的结论。\n不翻译官方文档。 官方文档永远比二手翻译准确、及时。这里只写文档不会告诉你的部分—— 默认配置在真实负载下会怎么坏、两个方案在什么条件下该选哪个、报错信息背后的实际机制。\n联系 想追更可以订阅 RSS。\n如果你发现某个数据不对，或者某个结论在你的环境里不成立，欢迎指出来——修正比面子重要。 这个站上的每个数字都来自我实际跑过的机器，但机器和机器之间总有差异，你的反例对我有用。\n","permalink":"https://ambertu.com/about/","summary":"\u003cp\u003e我是 \u003cstrong\u003eAmbertu\u003c/strong\u003e，做后端和独立产品，主要写 Go。\u003c/p\u003e\n\u003cp\u003e这个博客记录三件事：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e大模型 API 中转与网关\u003c/strong\u003e——自建 API 网关的部署形态、反向代理配置、超时与计费、限流与鉴权。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e小内存服务器运维\u003c/strong\u003e——一台 2 核 2G 的云主机上同时跑几个生产服务，是什么样的取舍。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e踩坑复盘\u003c/strong\u003e——把排查过程和最终结论一起写下来，包括那些\u0026quot;看起来像 A 问题其实是 B 问题\u0026quot;的时刻。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"写作原则\"\u003e写作原则\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e只写验证过的东西。\u003c/strong\u003e 每篇文章里的版本号、内存占用、报错原文、耗时数据，都来自我实际跑过的机器。\n如果某个数字我没有实测，我会说明它是估算范围，而不是编一个具体值。\u003c/p\u003e","title":"关于 Ambertu"},{"content":"自建大模型 API 中转站几乎一定要做按 IP 限流：防注册刷号、防单账号打满上游配额。而这里有一个安静失效的陷阱——如果你的限流按 X-Forwarded-For 计数，那它等于没做。攻击者每个请求塞一个不同的伪造 IP，计数器永远打不满。\n根本原因一句话：X-Forwarded-For 是追加语义，X-Real-IP 是覆写语义。 前者保留客户端自带的值，后者丢弃。这个区别决定了哪个可信。\n追加与覆写的区别 nginx 里这两行长得很像，行为完全不同：\n# 追加：把 $remote_addr 拼在客户端已有的 XFF 后面 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 覆写：无条件用 $remote_addr 替换，客户端塞的值被丢掉 proxy_set_header X-Real-IP $remote_addr; $proxy_add_x_forwarded_for 这个变量的定义就是\u0026quot;客户端请求里的 XFF 值 + , + $remote_addr\u0026quot;。所以攻击者只要在请求里带上 X-Forwarded-For: 1.2.3.4，上游收到的就是：\nX-Forwarded-For: 1.2.3.4, 203.0.113.77 ↑伪造 ↑真实（nginx 追加的） 而绝大多数框架和教程都教你取 XFF 的第一个值当客户端 IP——理由听起来也很合理：\u0026ldquo;最左边是最原始的客户端\u0026rdquo;。这个说法在没有攻击者的世界里成立，在真实互联网上是漏洞。\n❌ 按 X-Forwarded-For 限流 攻击者 自带 XFF: 1.2.3.4 追加 nginx $proxy_add_x_f_f XFF: 1.2.3.4, 203.0.113.77 应用取 [0] → 1.2.3.4 限流 失效 每个请求换一个伪造 IP → 计数器永远打不满 ✓ 按 X-Real-IP 限流 攻击者 自带 Real-IP: 1.2.3.4 覆写 nginx $remote_addr X-Real-IP: 203.0.113.77 伪造值已被丢弃 限流 生效 客户端塞什么都没用，值由反代单方面决定 关键差异：追加保留了客户端的输入，覆写没有。可信的是覆写。 前提：nginx 必须是唯一入口 —— 见下一节 两条路径的唯一区别是 nginx 那一格用了追加还是覆写。「取最左边是最原始客户端」这条常见说法，恰恰是最左边那个值完全由攻击者控制。 前提：反代必须是唯一入口 X-Real-IP 可信有一个隐含前提——所有流量都必须经过反代。如果后端端口自己也在公网上听着，攻击者直连后端，想塞什么头就塞什么头，覆写逻辑根本不会执行。\n这个疏漏比想象中普遍。写这篇文章的时候我顺手扫了自己的机器，发现两个问题：一个 Go 服务监听了 0.0.0.0 而不是 127.0.0.1，公网直接可达；更糟的是数据库端口也在安全组里开着，从外网能直接拿到 MariaDB 的协议握手包和版本号。两个都是历史遗留，跑了很久没人注意——因为从正常入口访问一切都是对的，问题只在你主动去扫端口时才会现形。\n✓ 后端只监听 127.0.0.1 客户端 nginx :443 覆写头 127.0.0.1:8081 唯一入口 直连尝试 → 连接被拒，没有第二条路 ❌ 后端监听 0.0.0.0 且安全组开放 攻击者 绕过 nginx，自带任意 X-Real-IP nginx :443 0.0.0.0:8980 公网可达 全部防护 被跳过 下半图里被跳过的不只是 IP 覆写——TLS、限流、鉴权、路径前缀剥离，凡是在 nginx 层做的全部失效。后端监听地址是一道比任何头处理都更靠前的防线。 自查两条命令，五秒钟的事：\n# 本机看监听地址：凡是 0.0.0.0 或 * 开头的都要问一句\u0026#34;为什么\u0026#34; ss -tlnp # 从外网验证（换成你的公网 IP 和端口），能连上就是有问题 curl -sv -m 5 http://YOUR_PUBLIC_IP:8080/ 2\u0026gt;\u0026amp;1 | head -5 第二条必须从机器外面跑。在机器上自己 curl 自己永远是通的，测不出安全组的实际策略。\n修法按优先级：\n应用只监听本地——大多数框架都能配，Go 里就是 http.ListenAndServe(\u0026quot;127.0.0.1:8081\u0026quot;, h) 而不是 \u0026quot;:8081\u0026quot; 安全组只开 22 / 80 / 443——这是最后一道闸，也是最不容易被应用改动破坏的一道 数据库一律不开公网——需要远程访问就走 SSH 隧道：ssh -L 3306:127.0.0.1:3306 user@host 正确的限流写法 nginx 层限流：天然安全 如果限流在 nginx 做，用 $binary_remote_addr 就好——它取的是 TCP 连接的对端地址，不是任何请求头，客户端无法伪造：\n# http 块 limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; location /v1/ { limit_req zone=api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:3000/v1/; } 用 $binary_remote_addr 而不是 $remote_addr 是内存考虑：前者是 4 字节（IPv4），后者是字符串，在 10m 的 zone 里能存的条目数差好几倍。\n应用层限流：只信覆写过的头 如果限流逻辑在应用里（比如要按用户 + IP 组合计数），那就只认 X-Real-IP：\n// ✓ 只信反代覆写的头，且兜底用 TCP 对端地址 func clientIP(r *http.Request) string { if ip := r.Header.Get(\u0026#34;X-Real-IP\u0026#34;); ip != \u0026#34;\u0026#34; { return ip } host, _, _ := net.SplitHostPort(r.RemoteAddr) return host } 绝对不要写成这样：\n// ❌ 取 XFF 第一个值 —— 完全由客户端控制 func clientIP(r *http.Request) string { if xff := r.Header.Get(\u0026#34;X-Forwarded-For\u0026#34;); xff != \u0026#34;\u0026#34; { return strings.Split(xff, \u0026#34;,\u0026#34;)[0] // 攻击者说什么就是什么 } ... } 我自己的 pet 服务在这一处踩过，最后在 nginx 配置里留了行注释提醒自己：X-Real-IP 不能少，注册限流只认这个头，因为 XFF 客户端能自己塞。 这类结论写在配置文件旁边比写在文档里有用得多——改配置的人一定会看到它。\n前面还有 CDN 怎么办 如果套了 CDN，$remote_addr 是 CDN 节点的 IP，直接覆写 X-Real-IP 会把全站流量都算成同几个 CDN 节点，限流立刻误伤所有人。这时候要用 nginx 的 realip 模块，而不是自己解析 XFF：\n# 声明哪些来源是可信代理（填 CDN 的回源网段，各家文档都有公布） set_real_ip_from 203.0.113.0/24; set_real_ip_from 198.51.100.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on; # 这一行是安全的关键 配好之后 $remote_addr 会被改写成真实客户端 IP，前面所有基于 $remote_addr 的限流、X-Real-IP 覆写都自动正确，不用改一行业务代码。\nreal_ip_recursive on 为什么是安全的关键，值得画一下：\nX-Forwarded-For 的实际内容 1.2.3.4 攻击者伪造 , 203.0.113.77 真实客户端 , 203.0.113.9 CDN 节点（可信） 从右开始扫 ① 在 set_real_ip_from 里 → 跳过 ② 不在可信列表里 → 取它，扫描停止 ✓ ③ 永远扫不到这里 扫描一旦遇到非可信地址就停止，所以左侧的伪造值不可能被取到。 安全性完全依赖 set_real_ip_from 填得准 —— 填宽了等于自己把门打开。 方向就是安全性本身。从右往左扫、遇到非可信地址即停，意味着攻击者在左边塞多少个伪造 IP 都到不了。反过来从左往右取第一个，就是本文开头那个漏洞。 两个配置陷阱：\nreal_ip_recursive off（默认值）在有 CDN 时是错的。 关闭递归时 nginx 只取 XFF 的最后一个地址，那就是 CDN 节点自己，等于没解析。 set_real_ip_from 千万别填 0.0.0.0/0。 把所有来源都标为可信，递归就会一路扫到最左边——正好扫到攻击者伪造的那个值，比不配还糟。见过有人为了\u0026quot;省事\u0026quot;这么写。 一份自查清单 检查项 命令 / 位置 期望 后端监听地址 ss -tlnp 只有反代和 SSH 在 0.0.0.0，业务端口都在 127.0.0.1 外网可达端口 从外部机器 curl 各端口 只有 22 / 80 / 443 通 数据库端口 ss -tlnp | grep -E '3306|5432' 绑 127.0.0.1，且安全组不开 应用取 IP 的逻辑 搜代码里的 X-Forwarded-For 不存在取 [0] 的写法 nginx 是否覆写 搜配置 X-Real-IP 每个 proxy_pass 的 location 都有 CDN 场景 搜配置 real_ip_recursive 为 on，且 set_real_ip_from 是具体网段 相关阅读 反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法 —— 同一份 nginx 配置里的另一个坑 new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少 —— 后端监听 127.0.0.1 在两种部署形态下怎么配 ","permalink":"https://ambertu.com/posts/api-gateway-real-ip-ratelimit/","summary":"\u003cp\u003e自建大模型 API 中转站几乎一定要做按 IP 限流：防注册刷号、防单账号打满上游配额。而这里有一个安静失效的陷阱——\u003cstrong\u003e如果你的限流按 \u003ccode\u003eX-Forwarded-For\u003c/code\u003e 计数，那它等于没做\u003c/strong\u003e。攻击者每个请求塞一个不同的伪造 IP，计数器永远打不满。\u003c/p\u003e","title":"API 中转站的限流为什么不能信 X-Forwarded-For"},{"content":"在 2 核 2G 的云主机上部署 new-api，如果这台机器还跑着别的服务，裸二进制方案通常是唯一可行的选择。原因不是 new-api 本身重——它是个 Go 单体，编译出来就一个二进制——而是 Docker 守护进程加上容器化的数据库和缓存，会在系统底噪之上再叠几百 MB 常驻开销，而 2G 机器的可用余量往往只剩 1G 出头。\n这篇文章不谈\u0026quot;Docker 好还是不好\u0026quot;，只回答一个具体问题：在小内存机器上，这几百 MB 的差价花得值不值。\n先看一台真实 2G 机器的内存都被谁吃了 大多数部署教程会告诉你\u0026quot;new-api 占用很小\u0026quot;，这话没错，但它回答的不是你真正要问的问题。下面是我手上一台腾讯云 2 核 2G / CentOS Stream 9 机器的实测数据，已连续运行 34 天，跑着 nginx、MariaDB、Redis 和两个自己写的 Go 服务：\ntotal used free shared buff/cache available Mem: 1.9Gi 613Mi 74Mi 7.0Mi 1.2Gi 1.1Gi Swap: 0B 0B 0B 按进程 RSS 排出来的大户：\n进程 RSS 是什么 mariadbd 285 MB 数据库，单进程最大户 systemd-journald 200 MB 系统日志，默认无上限增长 YDService 93 MB 腾讯云主机安全 agent rsyslogd 60 MB 又一份日志 barad_agent 27 MB 腾讯云监控 agent petserver 26 MB 自己写的 Go 服务 进程 RSS（实测 · 2C2G CentOS Stream 9） mariadbd 285 MB journald 200 MB YDService 93 MB rsyslogd 60 MB barad_agent 27 MB petserver 26 MB（自研 Go 服务） available ≈ 1126 MB Swap: 0B —— 没有兜底，OOM 直接杀进程 注：RSS 之和大于 free 的 used，因为共享页在每个进程里都被重复计入 实测数据。真正值得注意的不是哪个进程最大，而是你的应用还没启动，系统底噪加数据库已经吃掉了大约 580 MB。 这里有个细节要说清楚，否则数字看着不自洽：上面各进程 RSS 加起来（约 691 MB）大于 free 报的 used（613 MB），这不是我算错了。RSS 会把进程间共享的内存页（共享库、写时复制的父子页）在每个进程里各算一遍，所以 RSS 求和天然偏高。判断\u0026quot;还剩多少能用\u0026quot;要看 available，判断\u0026quot;谁是大户\u0026quot;才看 RSS。\n从这张图能得出两个结论，它们直接决定选型：\n系统底噪比应用大得多。 journald 200 MB、两个云厂商 agent 合计 120 MB、rsyslogd 60 MB —— 这些你一行代码没写就已经存在了。 可用余量只有 1.1 G，且没有 swap。 这意味着任何一次内存尖峰都直接触发 OOM killer，没有降级缓冲。 裸二进制方案的实际构成 new-api 是 Go 编译的单体，部署形态非常简单：\nsystemd ├── new-api 单二进制，Go 运行时 ├── mariadbd 本机数据库 └── caddy / nginx 反向代理 + TLS 一个够用的 systemd unit：\n[Unit] Description=new-api After=network-online.target mariadb.service Wants=network-online.target [Service] Type=simple User=newapi WorkingDirectory=/opt/new-api ExecStart=/opt/new-api/new-api --port 3000 --log-dir /var/log/new-api Restart=always RestartSec=5 # 小内存机器上值得加的两道保险 MemoryMax=512M OOMPolicy=continue [Install] WantedBy=multi-user.target MemoryMax 这行是小内存机器的关键：给应用设一个上限，让它在自己超标时被 cgroup 限制住，而不是把整机推向 OOM、让内核随机挑一个进程杀——被杀的往往是内存占用最大的 MariaDB，那就是数据库直接下线。\n代价要讲清楚，裸二进制不是没有成本的：\n升级是手动的：下载新二进制、systemctl stop、替换文件、systemctl start，回滚要自己留旧版本 没有进程隔离，应用和宿主机共享一切 依赖宿主机的 glibc 版本，跨发行版迁移可能编译不兼容（Go 静态编译能规避大部分，但 CGO 开启时不行） 数据库、缓存的版本管理全靠宿主机包管理器 Docker 方案多出来的常驻开销 裸二进制 Docker systemd new-api 40–80 MB mariadbd 285 MB 实测 caddy 10–25 MB 额外常驻开销 0 MB systemd dockerd 80–150 MB containerd 40–70 MB shim → new-api shim → postgres shim → redis 额外常驻开销 120–220 MB 灰底两项是应用之外白交的 右侧灰底的 dockerd 与 containerd 是纯粹的编排开销，跟你的业务一点关系没有。每个容器还各带一个 shim 进程。标注中只有 mariadbd 的 285 MB 是我实测值，其余是常见范围，具体数字随版本和配置浮动。 把这 120–220 MB 放回前面那张图里看：可用内存 1126 MB，Docker 一上来先拿走两成，剩下的才是留给 new-api、Postgres 和 Redis 的。在 4G 机器上这笔账无所谓；在 2G 机器上它就是压垮骆驼的那根稻草。\nDocker 换来的东西也是真的，别因为省内存就否定它：\n升级是 docker compose pull \u0026amp;\u0026amp; docker compose up -d，回滚是改个 tag 环境完全一致，本地能跑线上就能跑 数据库、Redis 的版本与宿主机解耦，不用跟发行版的包管理器较劲 多实例、多环境并存很容易 决策建议 场景 建议 理由 内存 ≥ 4G，机器专用 Docker 省心值这个钱，运维成本的节省远超内存代价 2G，且已跑其他生产服务 裸二进制 编排开销挤占的是业务余量，这笔交换不划算 2G，机器专用于 new-api 都行，倾向 Docker 没有邻居要抢内存，但务必先加 swap 需要多实例 / 多环境并存 Docker 裸二进制做这件事会很痛苦 两个几乎所有人都忽略的内存大户 journald 默认没有上限 前面实测里 systemd-journald 占了 200 MB，这不是异常，是默认行为——它会一直增长到磁盘配额上限。小内存机器上值得直接压掉：\n# /etc/systemd/journald.conf [Journal] SystemMaxUse=200M RuntimeMaxUse=50M 改完 systemctl restart systemd-journald。如果你的服务日志已经写到文件并由 logrotate 管，journald 里那份基本是重复的。\n云厂商 agent 实测里 YDService（主机安全）加 barad_agent（监控）合计约 120 MB。这些是装系统镜像时预置的，你未必需要全部。要不要关取决于你的取舍：关掉监控 agent 会失去云控制台的监控图表，关掉安全 agent 会失去入侵检测告警。在 2G 机器上这 120 MB 确实值钱，但别为了省内存把安全能力一起关掉——如果你的机器上有数据库对外暴露，那点内存远不如告警重要。\n无 swap 是这类机器的隐藏地雷 前面实测数据里 Swap: 0B。云主机默认不带 swap，而 2G 机器最需要它——不是为了性能，是为了在内存尖峰时有个降级路径,而不是直接被 OOM killer 杀掉。\n# 加 1G swapfile fallocate -l 1G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile echo \u0026#39;/swapfile none swap sw 0 0\u0026#39; \u0026gt;\u0026gt; /etc/fstab # 小内存机器上把 swappiness 调低，只在真的紧张时才用 sysctl -w vm.swappiness=10 代价是数据库一旦被换出磁盘，性能会明显下降。但\u0026quot;慢\u0026quot;永远好过\u0026quot;被杀\u0026quot;。\n相关阅读 部署完成后，反向代理的配置有两个坑几乎人人会踩，我单独写了：\n反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法 —— 流式输出变成一次性返回，问题通常不在应用 API 中转站的限流为什么不能信 X-Forwarded-For —— 这个头客户端能自己塞，按它限流等于没限 而本文里那个占 285 MB 的 MariaDB，本身也是个可以重新选的决定：\n给 API 中转站选数据库：SQLite / MySQL / PostgreSQL 在小机器上的取舍 —— 换成 SQLite 能把这 285 MB 整个省掉，代价是写并发上限 ","permalink":"https://ambertu.com/posts/new-api-binary-vs-docker/","summary":"\u003cp\u003e在 2 核 2G 的云主机上部署 new-api，如果这台机器还跑着别的服务，\u003cstrong\u003e裸二进制方案通常是唯一可行的选择\u003c/strong\u003e。原因不是 new-api 本身重——它是个 Go 单体，编译出来就一个二进制——而是 Docker 守护进程加上容器化的数据库和缓存，会在系统底噪之上再叠几百 MB 常驻开销，而 2G 机器的可用余量往往只剩 1G 出头。\u003c/p\u003e","title":"new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少"},{"content":"自建大模型 API 中转后最常见的故障是：后端明明是流式的，经过反向代理就变成一次性返回——客户端等十几秒没有任何输出，然后整段文字一次吐完。首字节延迟几乎等于整个生成时长，流式的意义全部丧失。\n这个问题九成不在应用代码里，而在 nginx 的 proxy_buffering 默认是开启的。但真正麻烦的地方是：请求路径上会缓冲的地方不止 nginx 一个，实际有五个，而且其中一个是你用来排查的 curl 自己。如果不知道这一点，你会在错误的层上改半天配置。\n先分清 SSE 和 WebSocket 这是配置写错的根源。大模型 API 的流式输出用的是 SSE（Server-Sent Events）：一个普通的 HTTP 响应，Content-Type: text/event-stream，靠 chunked 编码持续往下写。它不涉及协议升级。\n而网上大量\u0026quot;流式接口反代配置\u0026quot;教程给的是 WebSocket 的写法：\n# ❌ 这是 WebSocket 的配置，SSE 不需要，而且硬编码有害 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; 问题出在第二行硬编码了 \u0026quot;upgrade\u0026quot;：它会给经过这个 location 的所有请求都带上 Connection: upgrade，包括根本没有 Upgrade 头的普通请求和 SSE 请求。Connection 是逐跳（hop-by-hop）首部，被这么滥用会破坏到上游的 keepalive，某些上游实现还会因此行为异常。\n正确的写法是用 map 按客户端是否真的请求升级来取值：\n# http 块内 map $http_upgrade $connection_upgrade { default upgrade; \u0026#39;\u0026#39; close; } # location 内 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; 如果这个 location 只服务 SSE、完全没有 WebSocket，那更简单——两行都不要写，改成让 nginx 复用到上游的连接：\nproxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; 请求路径上真实存在的五个缓冲点 token 的流动方向 → 应用 nginx CDN 若有 客户端 默认 看代码 开关 flusher.Flush() 默认 缓冲 ⚠ 主因 开关 proxy_buffering off gzip off ← 第二个 默认 多数缓冲 ⚠ 开关 控制台流式配置 默认 curl 自己缓冲 ⚠ 开关 curl -N 共 5 个缓冲点：应用 flush · proxy_buffering · gzip · CDN · 客户端 nginx 那一格是灰底的，因为它同时贡献了两个缓冲点：proxy_buffering 和 gzip。最容易误导排查的是最右边那格——不加 -N 的 curl 自己会缓冲输出，你会以为是服务端问题。 第五个缓冲点最坑人，所以单独强调：curl 默认会缓冲输出。用不带 -N 的 curl 去验证流式，看到的永远是\u0026quot;一次性返回\u0026quot;，无论服务端配得对不对。排查流式问题的第一条纪律是所有 curl 都加 -N（等价于 --no-buffer）。\n症状长什么样 客户端收到数据的时间轴（示意） 缓冲开启 ↑ 全程无输出 ——————————————————→ 一次吐完 缓冲关闭 ↑ 首字节很快，token 持续到达 本图为示意，非实测数据 判据不是\"感觉慢\"，而是一个可量化的信号：首字节时间约等于总时长。下一节给出怎么量。 用 curl 的计时字段可以把这件事量化，不用凭感觉：\ncurl -N -o /dev/null -s \\ -w \u0026#34;首字节 %{time_starttransfer}s / 总计 %{time_total}s\\n\u0026#34; \\ -H \u0026#34;Authorization: Bearer $KEY\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;model\u0026#34;:\u0026#34;gpt-4o-mini\u0026#34;,\u0026#34;stream\u0026#34;:true,\u0026#34;messages\u0026#34;:[{\u0026#34;role\u0026#34;:\u0026#34;user\u0026#34;,\u0026#34;content\u0026#34;:\u0026#34;数到二十\u0026#34;}]}\u0026#39; \\ https://your-domain/v1/chat/completions 首字节 ≈ 总计 → 被完整缓冲了 首字节 ≪ 总计 → 流式正常 另一个免费信号在响应头里：\ncurl -N -D - -o /dev/null -s ... | grep -iE \u0026#39;content-length|transfer-encoding|content-type\u0026#39; 出现 Content-Length 就说明响应被完整缓冲过——只有知道全部长度才能写出这个头。正常流式应该是 Transfer-Encoding: chunked（HTTP/1.1）或 HTTP/2 的分帧，加上 Content-Type: text/event-stream。\n三层二分排查法 不要直接去猜哪一层，按下面这棵树走，两三条命令就能定位。核心思路是从最里层往外逐层加一跳，第一次出问题的那跳就是元凶。\n① curl -N 直连 127.0.0.1:3000 绕过全部反代 不流式 应用没有 flush 改代码，见下节 流式 ✓ ② curl -N 经 nginx（内网 IP） 加上反代这一跳 不流式 nginx 缓冲 proxy_buffering / gzip 最常见的一档 流式 ✓ ③ curl -N 经公网域名 加上 CDN 这一跳 不流式 CDN 在缓冲 去控制台配流式 流式 ✓ 服务端没问题 查客户端：漏了 -N， 或 SDK 未开 stream 每一步都必须带 -N，否则测的是 curl 自己的缓冲 这套方法的价值在于每次只增加一跳，所以定位是确定的，不需要靠猜。灰底的三个是终点结论。 nginx 侧的完整配置 location /v1/ { proxy_pass http://127.0.0.1:3000/v1/; # 关掉响应缓冲 —— 解决问题的主开关 proxy_buffering off; proxy_request_buffering off; proxy_cache off; # gzip 压缩也会攒数据，流式 location 里必须关 gzip off; # chunked 需要 HTTP/1.1；清空 Connection 让 nginx 复用上游连接 proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; # 长生成会超过默认的 60s，放宽读超时 proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } 几个容易漏的点：\ngzip off 不是可选项。 nginx 的 gzip 模块要攒够数据才能压缩，开着它等于重新引入缓冲。很多人只关了 proxy_buffering 就以为完事，结果症状减轻但没消失，就是 gzip 在作祟。 proxy_read_timeout 默认 60 秒。 长上下文生成很容易超，超了会收到截断的响应或 504，且现象酷似\u0026quot;流式坏了\u0026quot;。 proxy_request_buffering off 管的是请求方向，上传大 prompt 时有用，和响应流式无关但顺手一起配。 不想改 nginx 配置？用响应头 如果你没有 nginx 的配置权限，或者不想为一个接口改全局配置，应用可以自己让 nginx 关掉缓冲——返回这个响应头：\nX-Accel-Buffering: no nginx 识别这个头并对该响应禁用缓冲。这是 nginx 特有的机制，非常实用：开关从运维侧移到了应用侧，随代码走，不会因为换机器忘配而复发。\nCaddy 侧 Caddy 的 reverse_proxy 对 text/event-stream 会自动刷新，多数情况下开箱即用。如果确实需要强制：\nyour-domain.com { reverse_proxy 127.0.0.1:3000 { flush_interval -1 } } flush_interval -1 表示每次写入立即刷新。另外 Caddy 默认会对文本类型启用压缩，如果显式配了 encode gzip，记得把流式路径排除掉，理由和 nginx 的 gzip off 一样。\n应用侧：反代配对了也可能不流式 如果排查树的第一步就不通过，那是应用自己没有 flush。反代关掉缓冲只是\u0026quot;不主动攒\u0026quot;，它没法把应用攒着不发的数据变出来。Go 里的标准写法：\nfunc stream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, \u0026#34;streaming unsupported\u0026#34;, http.StatusInternalServerError) return } w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;text/event-stream\u0026#34;) w.Header().Set(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache\u0026#34;) w.Header().Set(\u0026#34;X-Accel-Buffering\u0026#34;, \u0026#34;no\u0026#34;) // 顺手把 nginx 也关掉 for chunk := range chunks { fmt.Fprintf(w, \u0026#34;data: %s\\n\\n\u0026#34;, chunk) flusher.Flush() // 少了这行，前面全白配 } fmt.Fprint(w, \u0026#34;data: [DONE]\\n\\n\u0026#34;) flusher.Flush() } 注意 w.Header().Set 必须在第一次 Write 之前调用，否则不生效——这是另一个安静失败的经典来源。\n相关阅读 new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少 —— 反代前面那一层，部署形态怎么选 API 中转站的限流为什么不能信 X-Forwarded-For —— 本文配置里那行 X-Real-IP 为什么不能省 ","permalink":"https://ambertu.com/posts/llm-api-sse-proxy-buffering/","summary":"\u003cp\u003e自建大模型 API 中转后最常见的故障是：\u003cstrong\u003e后端明明是流式的，经过反向代理就变成一次性返回\u003c/strong\u003e——客户端等十几秒没有任何输出，然后整段文字一次吐完。首字节延迟几乎等于整个生成时长，流式的意义全部丧失。\u003c/p\u003e","title":"反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法"}]