什么是 AI API 网关:一次请求的六跳与它的边界
什么是 AI API 网关:一次请求的六跳与它的边界
AI API 网关是这样一种服务:它接收标准格式的 API 请求,把请求解析到某个上游供应商的某个具体模型,转发出去,再以同样的标准格式把结果返回。客户端自始至终只与网关打交道;供应商凭据、路由决策和模型命名都留在网关这一侧。
这个定义故意写得很机械,因为大多数关于网关的困惑来自把三件事混在一起:协议转换、凭据管理、路由。本文按顺序走完一次请求的每一跳,然后说明中转站能改变什么、不能改变什么。
本文的验证范围
文中的端点与模型 ID 来自 EXAIV 的公开文档与实时目录(/docs/api/chat-completions、/models)。Ollama、llama.cpp、vLLM 的本地端口是它们文档里的默认值,可以通过配置修改。某个模型是否接受某个参数取决于模型与路由,文中给出的是自查方法,不是对具体模型能力的断言。
一次请求的六跳
以发往 https://exaiv.com/api/ai/v1/chat/completions 的一次普通对话请求为例。它不是一步完成的,而是经过几个可以分别出错的阶段。
其中两跳解释了大量支持问题。第 3 跳解释了为什么在别处能用的模型名在这里可能报「模型不存在」:判断依据是这一侧的目录,而不是你从别家记住的名字。第 4 跳是安全相关的那一跳:你的密钥只用于向网关表明身份,上游永远看不到它。
网关做什么,不做什么
| 网关会做 | 网关不会做 |
|---|---|
| 用一把密钥、一种协议接多种模型 | 改变模型的知识或训练方式 |
| 保管上游凭据并轮换 | 保证你在特定区域的某个延迟 |
| 把模型名解析到供应商,在配置允许时做故障转移 | 让一个不被支持的参数变成被支持 |
| 归一化响应与流式事件 | 缓存你的提示词(除非文档明确说明有缓存能力) |
| 按密钥、按模型计量用量 | 替代你自己对输出内容的校验 |
右列最后一条最常被忽略。网关让调用变整齐,不会让模型的输出变正确。解析、结构校验和重试策略,仍然是你的代码的责任。
中转站为什么会存在
如果网关只是中间多一跳,为什么不直连供应商?反复出现的理由有三个,而且都不涉及任何需要隐瞒的东西。
一把密钥接多家。 一个产品如果用快速模型做分类、用更大的模型做推理,直连就要维护两个账号、两套账单关系和两组生产凭据。换成中转站,应用只持有一次凭据。
在变化的目录前面放一份稳定契约。 供应商会重命名模型、发布带日期的快照、下线旧版本。如果模型 ID 只出现在网关侧的一个地方,应用可以把一部分变动消化成配置修改,而不是代码修改。
给区域客户端一条固定的出口。 对东南亚或欧洲的开发者来说,到某个供应商的网络路径是一个运维变量:同一条线路在不同运营商那里可能快也可能慢。中转站提供一个行为已知的端点,而不是若干个行为未知的供应商端点。它不会消除延迟,它让你集成的那个端点变成常量。
最常被忽略的一点:本地协议和它是一样的
学习 OpenAI 兼容请求形状的最大理由,不是中转站用它,而是几乎每一个正经的本地推理服务也用它。Ollama、llama.cpp 的 server 和 vLLM 都提供 OpenAI 兼容端点,所以同一套客户端库、同一个 messages 数组、同一个流式解析器,既能对着中转站,也能对着跑在笔记本上的模型。
这意味着一个健康的架构里,「模型在哪」只应该是一个变量:
import os
from openai import OpenAI
# 两个目标、同一份代码:只换 base_url 与 model
TARGETS = {
"relay": {
"base_url": "https://exaiv.com/api/ai/v1",
"api_key": os.environ["EXAIV_API_KEY"],
"model": "deepseek-v4.1-flash",
},
"local": {
"base_url": "http://localhost:11434/v1", # Ollama 默认 OpenAI 兼容端点
"api_key": "ollama", # 本地服务通常不校验 key
"model": "qwen3.8-flash",
},
}
def ask(target: str, prompt: str) -> str:
cfg = TARGETS[target]
client = OpenAI(base_url=cfg["base_url"], api_key=cfg["api_key"])
r = client.chat.completions.create(
model=cfg["model"],
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return r.choices[0].message.content
print(ask("relay", "用三点概括这份变更日志。"))
同样的检查用 curl 也只需一行,适合在任何 SDK 介入之前先确认端点:
curl -s https://exaiv.com/api/ai/v1/chat/completions \\
-H "Authorization: Bearer $EXAIV_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"model": "kimi-k3",
"messages": [{"role": "user", "content": "只回复两个字:正常"}],
"temperature": 0
}'
这里返回 401,说明密钥缺失或错误;返回提到模型名的 404,说明这个 ID 不在目录里。第一次接入失败绝大多数是这两种,而且不需要翻文档就能区分。
什么情况下不该用中转站
网关是有取舍的架构选择,有些场景直连才是对的:
- 合规条款里点名了处理方。 如果某条规则明确限定谁可以处理数据,中转站就是多出来的一方。先看要求,再假设它可以兼容。
- 单一供应商,且延迟预算以毫秒计。 每多一跳就多一个消耗时间的位置。如果负载对延迟敏感且只用一家,直连更容易推理。
- 完全隔离的环境。 如果网络到不了公网,唯一可用的端点就是跑在模型旁边的那一个。这时本地 OpenAI 兼容服务本身就是全部答案。
这些都不说明中转站不好,只说明它适合与否取决于场景,而不是默认选项。
十分钟评估一个中转站
文档描述的是意图,下面五个测试描述的是行为。在把代码库绑定到某个端点之前,先跑一遍。
一、发一个最小请求,并把原始响应留档。 对比它返回的字段名与你代码读取的字段名。首个请求就返回 200 但少了你依赖的字段,这是设计问题,不是以后能顺手修掉的 bug。
二、故意发一个不存在的模型 ID。 读错误体。能点出未知模型的端点比返回一个笼统失败的端点更好运维,因为你的重试逻辑可以区分「配置写错」和「临时故障」。
三、让一个流式请求保持超过链路上任何代理的超时时间。 企业代理和部分 CDN 会掐掉空闲连接;一个 60 秒后卡住的流,在笔记本上正常、在办公室里失败。这个测试同时告诉你客户端要不要显式设置读超时。
四、故意把请求推到上下文上限之外。 拒绝应该在任何 token 计费之前以明确错误出现,而不是变成一个你在生产环境才发现的截断答案。
五、同一个输入、temperature 设为 0,调用两次。 输出不会逐字节相同,这是正常的。你要看的是响应形状是否稳定:同样的键、同样的类型、同样的嵌套。形状漂移是那种会悄悄弄坏下游代码的故障模式。
把这五个响应留下来
把这五次测试的原始响应体存进仓库,放在接入代码旁边。几个月后出现变化时,你手上有一份前后对照,而不是一条关于「以前好像是那样」的记忆。
按跳排查
| 现象 | 可能出错的跳 | 先检查什么 |
|---|---|---|
| 每次请求都 401 | 第 2 跳 | header 里密钥是否存在、有没有多余空格、是否已失效 |
| 404 并提到模型名 | 第 3 跳 | 该 ID 是否在网关侧的实时目录里 |
| 有负载时 429 | 第 2 跳 | 自己的并发是否超过速率限制;退避而不是立刻重试 |
| 流开始后卡住 | 第 5 跳 | 客户端读超时,以及你与网关之间是否有代理缓冲 |
| 某个网络能通、另一个不行 | 第 4/5 跳 | 企业代理或防火墙是否干扰了流式连接 |
| 有响应但读不到某个字段 | 第 5/6 跳 | 参数被接受但路由到的模型不支持;用最小请求验证 |
要内化的规律是:状态码告诉你哪一跳出了问题。401 永远不是模型问题,模型不存在也永远不是鉴权问题。不再把网关当成一个不透明的黑盒,排查速度会快很多。
接入前的检查清单
- 我知道模型 ID 必须从哪份目录取,以及在哪里读它
- 我知道请求里哪些部分由我的应用校验,哪些由网关归一化
- 当某个模型 ID 从目录里消失时,我有回退方案
- 我在自己的网络里测过流式,而不是只在干净的笔记本上测过
- 我已经判断过这个负载需要中转站、直连还是本地服务
- 我验证过同一套客户端代码能同时跑在中转站和本地端点上