自建模型网关触发风控复盘:九秒之内,六个席位全部熔断
九秒熔断
八个月里,内部编排节点上的一个自建网关把我用到的全部模型收进一把密钥、一个模型选择器:长上下文模型读大仓库,前沿模型做重构,开源权重模型跑廉价后台任务。一个端点、一块面板、一处花费视图。它运转得足够顺,以至于我不再去注意它实际在做的事——用厂商从未授权的软件出示订阅的 OAuth 令牌。
某个周五凌晨的维护窗口里,这套安排以九秒收场。网关计划内的凭据健康检查同时打到全部六条连接,每一条都返回:
403 This service has been disabled in this account
for violation of Terms of Service.
Please submit an appeal to continue using this product.
第一条响应立刻失败,最后一条落在同一个九秒窗口内。六个席位当时都没有异常行为;触发者是这次探针本身,它撞上的是上游早已生效的封锁标记。网关自己的数据库在一次写入里把每条连接改成 test_status = banned、error_code = 403,编排日志里第一条失败的对话在两分钟后出现。
真正要命的不是封锁,而是随后十分钟十分自信的误判:网关把上游的策略决定以 HTTP 401 invalid_api_key 的形式抛出。消费这条错误的每一个客户端——编排器、shell 探测、CLI——都报告凭据有问题。那十分钟全花在轮换从未出错的密钥上。
共享令牌为何不等于共享密钥
API 密钥是签发给持有一方的持有者凭据。OAuth 令牌不是。它绑定的是一个客户端身份:客户端 ID、一份同意记录、一组授予特定应用的权限范围。当第三方进程出示这份令牌,请求仍然带着原客户端的声明,却丢掉了原客户端的行为——请求头、TLS 指纹、请求节奏。
这个缺口正是风控系统被造出来要检测的东西,而厂商条款事先就写清了。可接受访问的条款禁止通过第三方软件触达服务,FAQ 把界线划得更细:直接脚本化官方 CLI 可以接受,而汇集席位、或自动化地在席位之间轮换,不受支持。
我自己的两个设计决定,让这个模式一目了然:
- 单一出口。 六个席位全部经由同一台容器、同一个公网地址发出,于是每条请求共享一个网络身份。
- 单一指纹。 网关规范化了请求头、剥离了客户端遥测,于是六个席位看起来像一个客户端制造了六倍流量。
六条连接只用九秒,不是六个人在审核六个案例,而是一条规则命中了一个集群。这些席位原本服务于不同工作流——交互式工作、CI 任务、预发布探针——我为了省事把它们的凭据收敛进了同一个路由层。那次收敛才是错误本身,与席位数无关。
取证链路
复盘里有价值的是别人能重新推演的证据,不是结论。三个产物在一小时内定了案。
| 产物 | 它证明了什么 |
|---|---|
| 网关健康端点 | 网关本身健康:HTTP 200,模型列表正常,非订阅类供应商照常应答 |
| 同一把密钥上的非订阅模型 | 返回 200 且带工具调用,说明凭据与路由层都没问题 |
| 全部订阅类模型 | 返回完全一致的 401,前后相差数秒,响应体里嵌着上游 403 |
第三行是关键。单个模型失败,是模型坏了。一个供应商下所有模型以同样方式失败、而同一把密钥上其他供应商全部成功,故障就在供应商关系里,轮换密钥一步也挪不动它。
网关的连接表确认了状态,调用日志给出了事件的形状:
t+0s connectivity sweep 6 connections -> 403 policy block
t+9s last connection 6/6 disabled
t+28s orchestrator first failed chat request
t+96s connection table rows flipped to banned / 403
🔍 完整隔离探测:四条命令分开凭据、网关与供应商(27 行)
# 1. 网关自己活着吗?
curl -s -o /dev/null -w '%{http_code}\n' "$GW/api/health"
200
# 2. 同一把密钥上,无关供应商的模型能答吗?
curl -s "$GW/v1/chat/completions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"model":"open-weight/unrelated","messages":[{"role":"user","content":"reply OK"}]}' \
| jq -r '.choices[0].finish_reason'
tool_calls
# 3. 订阅类模型是否以同样方式失败?
for m in subscription/fast subscription/deep subscription/alt; do
curl -s "$GW/v1/chat/completions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
| jq -r '.error.code // .choices[0].finish_reason'
done
invalid_api_key
invalid_api_key
invalid_api_key
# 4. 网关自己的连接表怎么说?
sqlite3 gateway.db \
"select provider, test_status, error_code, count(*)
from connections group by 1,2,3;"
subscription|banned|403|6
第 2 行成功、第 3 行连续三次以同样方式失败,诊断就齐了:密钥没坏,网关没坏,是供应商关系被关闭了。
改为任务分派,而不是路由
教训不是”少用几个席位”。教训是:订阅是一把席位,不是一个API。席位授予的是一个人通过厂商自己的客户端工作的权利。令牌一旦离开那个客户端,席位就变成共享凭据,执法也照共享凭据处理。
于是架构改了形状。网关继续服务那些用 API 密钥付费的模型——那部分从来不是问题。订阅类工作改成任务分派:编排器在获得授权的席位下,运行厂商官方 CLI 的隔离式单任务调用,令牌始终不离开厂商的二进制文件。没有汇集凭据,没有劫持长连接会话,也没有跨席位轮换。
orchestrator -> task dispatcher -> official CLI (owns the token) -> vendor
|
+- cwd, permission scope, timeout, tree-kill, no retries
这个分派器的四条属性,比任何提示词都更管用:
- 显式工作目录。 每个任务只在某一个仓库内启动,绝不在用户主目录或文件系统根目录启动;一个拥有编辑权限的 agent 继承的是运行它的用户的整个 shell。
- 关闭标准输入。 headless 运行里的交互式提问——包管理器询问是否初始化、SSH 客户端询问是否信任主机密钥——会变成错误而不是静默挂起。少了这一步,一个忘写的
-y就能把父进程永远卡住。 - 超时杀进程组。 只杀父进程会留下孙进程;分派器杀掉整个进程组并返回超时码,而不是交回一份做了一半的 diff。
- 不自动重试。 内层 agent 循环乘以外层重试策略,几分钟内就是几百次上游调用——正是上一套设计被标记的节奏。
执行内核的最小实现,小到可以一屏审完:
proc = subprocess.Popen(
argv, cwd=repo, stdin=subprocess.DEVNULL, # no interactive hangs
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, # own process group
)
try:
out, err = proc.communicate(timeout=task_timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL) # kill the whole tree
out, err = proc.communicate()
return {"status": "timeout", "detail": err[-2000:]}
上下文也不再靠提示词搬运。委托进程从仓库级指令文件读项目规则,通过 MCP 查询自建记忆服务,所以一份委托简报只有六行,而不是一份对话倾泻。委托方写入的记忆带 status: draft 标记,最终归档权留在编排者手里——一个把自己失败的尝试当事实记下来的子 agent,会污染之后每一次任务。
还有一处修复属于这里,因为它正好回答最初浪费的那十分钟:保留下来的 API 密钥流量仍然经过网关,而它的错误映射已经改为区分类别,而不是抹平。上游的策略封锁以 502 加 upstream_policy_block 元数据呈现并触发告警;认证失败仍然是 401。真正的凭据问题与厂商的决定,在客户端一侧不再长得一模一样。
经济性依然成立
丢掉 OAuth 复用本可能让成本高过收益,所以替代方案必须先过成本线。它过了,因为负载干净地分成两半。
常规编排——大约每十轮里的九轮。 高频工具循环、检索、cron、日志分诊与短代码修改,跑在一份精选开源权重计划上:每月 10 美元,快速模型带每月 100 美元额度,旁边还有按模型划分的独立池。低峰价是每百万输入 token 0.15 美元、每百万输出 0.60 美元,于是一次 4k 输入 / 500 输出的回合成本为 4000 × 0.15/1e6 + 500 × 0.60/1e6 ≈ $0.0009,略低于十分之一美分。两个护栏让这份额度不至于成为幻想:按模型分池,一个模型被限流不至于让流水线停摆;以及硬性升级规则——同一错误连续失败两次、触及三个以上文件、超过五步探索性诊断、或任何不可逆的生产操作,都要在烧掉额度之前转到更强模型。
重度工程——剩下的那一成。 多文件重构、遗产代码考古、以及任何带真实构建与测试契约的任务,分派给官方 CLI 席位:凭据由厂商自己的客户端持有,额度属于那份订阅,而不是一个汇集池。
这个划分才是”怎样既便宜又不越过边界”的答案:用 API 密钥模型为频率付费,用授权席位为深度付费,绝不让两者共享同一份令牌。
反思
我要留下的是这一点:那个网关本身是解决真问题的好工程——多供应商一个接口、一处看花费、可用的故障转移。它缺的是一条边界。签发给某个客户端的凭据,不能被另一个客户端重新托管并指望它保留身份;代码写得再干净,也改变不了执法层看到的事实:一个地址、六条连接、一个指纹。
汇集模式现已永久退役。保留下来的席位按厂商意图使用——经由它的客户端、一次一个任务、频率落在人类开发者能做到的范围;而把订阅访问迁到正规授权层级的合规工作正在进行。
任务分派比一个什么都答的路由器更难建、更难调。它也是这套东西里唯一能扛过一次审计的版本。
想要一个 24/7 自动接单、发试用、不漏单的业务自动化系统?
别让夜间和周末的客户询盘因为“没人回”而流失给竞争对手。我帮中小企业定制 Telegram / WhatsApp 智能客服与全自动履约系统(打通商品库、CRM 与自动发货,无高昂 SaaS 人头费)。