把一张 H100 上的 Qwen3-32B 接进模型网关,OpenAI 兼容只解决了协议层。工具调用、reasoning、usage 计费、容量背压、探针和断连,每一层都得网关自己补。
模型网关一开始只对接云厂商。Agent 和 Consumer 都不直接碰模型 API,全部走网关,网关负责把 OpenAI、Anthropic、Vertex 这些厂商的差异抹平,再按我们自己的格式往回吐事件。
后来做了一个决定:免费档用户的 Agent 对话,以及 Consumer 里那类给图像模型做 prompt 扩写的节点,不再走云厂商,切到自己部署的模型。这两类流量有个共同点——量大、不赚钱、对模型能力要求不高。一张 H100 80GB,跑 Qwen3-32B 的 FP8 权重,vLLM 起 OpenAI 兼容接口。
我当时觉得接入就是在网关的 provider 表里多加一行 base_url。毕竟它叫"OpenAI 兼容"。第一周网关日志里冒出来的东西说明这个想法太天真。
网关里的模型不是一个 URL
先说网关本身怎么看"模型"。业务侧(Agent、Consumer)用的是别名,比如 agent-lite、prompt-enhance。别名映射到一组 provider,每个 provider 才有真实的厂商类型、地址和模型名。
aliases:
agent-lite:
providers:
- name: vllm-h100-a
type: openai_compatible
base_url: http://10.0.3.21:8000/v1
model: qwen3-32b
api_key_env: VLLM_API_KEY
max_inflight: 32
capabilities: [tools, reasoning, json_schema]
weight: 100
- name: cloud-small
type: vertex
model: gemini-flash
capabilities: [tools, json_schema, vision]
weight: 0weight: 0 的 provider 不参与正常分流,只在前面的 provider 拒绝或者失败时兜底。这套结构原来是为了同一个模型能在多家厂商之间切换(FAL、GCP、逆向渠道)而做的,vLLM 只是又多了一种 type。
有两个字段是接 vLLM 之后才加的。
capabilities:云厂商的模型能力大体一致,vLLM 上的模型不一定。Qwen3-32B 是纯文本模型,请求里带了图片就得改道云厂商。网关在选 provider 之前先看请求需要什么能力,不满足的直接跳过,而不是把请求发出去等 400。
model: qwen3-32b:这是 vLLM 的 --served-model-name,不是 HuggingFace 上的路径。启动的时候:
vllm serve Qwen/Qwen3-32B-FP8 \
--served-model-name qwen3-32b \
--max-model-len 32768 \
--max-num-seqs 48 \
--gpu-memory-utilization 0.92 \
--enable-auto-tool-choice --tool-call-parser hermes \
--reasoning-parser qwen3 \
--enable-prompt-tokens-details \
--enable-server-load-tracking \
--api-key "$VLLM_API_KEY"对外的名字和权重路径解耦之后,蓝绿就很直接:起第二台 vLLM,换权重版本或者换启动参数,--served-model-name 还是 qwen3-32b,网关把 weight 从 0 慢慢拉上去。业务侧看到的模型名从头到尾没变。
上面这串参数里,除了前三个,每一个都对应下面某一节踩的坑。
工具调用默认是关的
Agent 的每一轮请求都带 tools。第一次把 agent-lite 指到 vLLM,请求直接 400:
"auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be setvLLM 不会替你猜模型用什么格式吐工具调用。Qwen 系列的格式是 Hermes 风格的 <tool_call> 标签包 JSON,所以要显式给 --tool-call-parser hermes。不开 parser 的话,即便模型生成了工具调用,也只会当成普通文本出现在 content 里。
开了之后非流式的返回和 OpenAI 一致。流式有差别:tool_calls 的 arguments 是按片段推的,同一个 index 的片段要自己拼起来。云厂商的 SDK 通常帮你做了这件事,网关直接对着 HTTP 流的话要自己做。
class ToolCallAssembler:
def __init__(self) -> None:
self._calls: dict[int, dict] = {}
def feed(self, delta_tool_calls: list[dict]) -> None:
for tc in delta_tool_calls:
slot = self._calls.setdefault(
tc["index"], {"id": None, "name": None, "arguments": ""}
)
if tc.get("id"):
slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
slot["name"] = fn["name"]
slot["arguments"] += fn.get("arguments") or ""
def finish(self) -> list[dict]:
calls = []
for index in sorted(self._calls):
slot = self._calls[index]
try:
args = json.loads(slot["arguments"] or "{}")
except json.JSONDecodeError:
args = {"__raw__": slot["arguments"]}
calls.append({"id": slot["id"], "name": slot["name"], "arguments": args})
return callsfinish_reason 到达之前不做 json.loads,这个顺序很重要。片段中间切开的 JSON 永远是非法的,提前解析只会得到一堆没意义的错误。解析失败的情况留一个 __raw__,让 Agent 那边的参数校验层去决定是重试还是报错,网关不替它拿主意。
还有一个和成本有关的差别。Agent 有一类步骤会传 parallel_tool_calls: false,要求一轮只回一个工具调用。vLLM 是认这个字段的,但实现方式是让模型把所有工具调用都生成完,返回时只保留第一个。语义和 OpenAI 一致,GPU 却一个 token 都没少算。在云厂商那边这是别人的成本,在自己的卡上这是排队时间。
另外一些字段 vLLM 直接忽略,比如 user、store、metadata,日志里会提一句:
The following fields were present in the request but ignored: {'store', 'metadata'}网关往上游转发请求前,按 provider 类型把这些字段剥掉。日志干净是一方面,更重要的是别让"被忽略"和"生效了"在业务侧长得一样。
Qwen3 的 thinking 会从 content 里漏出来
Qwen3 是混合推理模型。默认情况下,模型先输出一段被 <think> 标签包住的思考,再输出正文。不加 --reasoning-parser qwen3,这两段会一起以纯文本的形式出现在 content 里,前端就会把模型的自言自语原样渲染出来。
加了 parser 之后,思考部分被拆到 reasoning_content 字段。这个字段不在 OpenAI 的标准 schema 里,是 DeepSeek 带起来、vLLM 跟着实现的约定。网关要把它映射成我们自己的 reasoning 事件,这套事件 Agent 前端本来就在消费(Anthropic 的 thinking block 走的也是同一条路),所以映射之后前端零改动。
麻烦的是 Consumer 那类 prompt 扩写节点。这类任务不需要思考,思考只会多花几百个 token 和几秒钟。Qwen3 关 thinking 的方式是往 chat template 传参:
{
"model": "agent-lite",
"messages": [...],
"chat_template_kwargs": {"enable_thinking": false}
}这又是一个厂商特有的旋钮。网关的请求格式里原本就有一个 reasoning 开关,之前只对 Anthropic(thinking 参数)和 OpenAI(reasoning_effort)做过翻译。现在翻译表多了一列:
| 网关字段 | Anthropic | vLLM + Qwen3 |
|---|---|---|
reasoning: off | 不传 thinking | chat_template_kwargs.enable_thinking = false |
reasoning: on | thinking.budget_tokens = N | 默认开启,reasoning_content 映射为 reasoning 事件 |
这张表的意义不在于哪一行,而在于它存在。业务侧只认 reasoning: on/off,厂商怎么实现是网关的事。
usage 不给你,账就记不上
我们的计费是 Redis 预扣、MongoDB 账本,请求结束后按真实 usage 结算。这条链路依赖每次调用都拿到 usage。
非流式没问题。流式的时候,OpenAI 兼容协议默认不在流里给 usage,需要请求里带 stream_options: {"include_usage": true},vLLM 才会在最后追加一个 choices 为空、只有 usage 的 chunk。Agent 那边的客户端一开始没带这个字段,切到 vLLM 后第一天的账本里,这部分请求的 token 数全是 0。
网关现在的做法是:不管业务侧带不带 include_usage,往上游发的请求一律带上。业务侧没要的话,最后那个 usage chunk 网关自己吃掉,只用来记账。vLLM 也有一个服务端强制的开关 --enable-force-include-usage,但我更愿意让网关掌握这件事,因为云厂商那边没有对应的服务端开关,逻辑放网关才能对所有 provider 一致。
顺手打开了 --enable-prompt-tokens-details,想看 prompt_tokens_details.cached_tokens,也就是前缀缓存命中了多少。结果我们这版 V1 引擎返回的一直是 null,/metrics 里的 vllm:prefix_cache_hits 明明在涨。这是 vLLM 那边一个拖了很久的 bug,按请求看命中的事只能先放一放,靠服务级的指标顶着。
云厂商是无限的,vLLM 是有限的
这是接入之后最大的观念变化。
对云厂商发请求,容量问题表现为 429,网关按 Retry-After 退避就行。vLLM 不会替你说不。它有 --max-num-seqs 限制同时在跑的序列数,超过的请求进等待队列,而这个队列是无界的:vllm:num_requests_waiting 一路涨,首 token 延迟从一秒变成十几秒。对客户端来说这只是"变慢了",没有任何一个状态码告诉你已经满了。社区有过给等待队列加上限、满了返回 503 的提案,但我们用的版本里没有这个开关。
免费档的流量恰恰是最不可控的那种。上一次涨粉带来的一批新用户,同时开了几十个 Agent 会话,vLLM 的等待队列排到了几分钟,而网关对此毫无感知,仍然把请求往里塞。
所以背压必须由网关来做,而且要在请求发出去之前。每个 provider 一个 max_inflight,用信号量实现,拿不到就在网关侧排一小段时间,再拿不到就走下一个 provider:
class ProviderGate:
def __init__(self, max_inflight: int, queue_timeout: float) -> None:
self._sem = asyncio.Semaphore(max_inflight)
self._queue_timeout = queue_timeout
@asynccontextmanager
async def acquire(self):
try:
await asyncio.wait_for(self._sem.acquire(), self._queue_timeout)
except asyncio.TimeoutError:
raise ProviderSaturated()
try:
yield
finally:
self._sem.release()
async def call_alias(alias: str, request: GatewayRequest) -> AsyncIterator[Event]:
for provider in resolve_providers(alias, request.required_capabilities):
try:
async with provider.gate.acquire():
async for event in provider.stream(request):
yield event
return
except ProviderSaturated:
metrics.spill.labels(alias, provider.name).inc()
continue
raise NoProviderAvailable(alias)max_inflight 设成 32,比 --max-num-seqs 48 低一截。差出来的 16 是留给 vLLM 自己调度用的余量:chunked prefill 之下,一条长 prompt 进来会分几步吃掉一部分 token 预算,正在 decode 的请求会跟着抖。让引擎内部永远有一点空,比把它填满再排队要稳。
溢出的请求走 cloud-small。这里有一个账要认:兜底的请求是花钱的,而且模型不一样,输出风格会有差别。免费档用户偶尔遇到风格切换我们接受;Consumer 的 prompt 扩写节点不接受,那条别名的 provider 列表里就没有云厂商兜底,满了就排队。同一套机制,两条别名给出两种策略。
/health 说的是活着,不是能用
vLLM 启动要好几分钟:拉权重、编译、CUDA graph 捕获。这期间 /health 不通,但进程在。如果探针的失败阈值按普通 Web 服务配,编排系统会在模型加载到一半的时候把它杀掉,然后再来一遍。启动探针的窗口单独拉长到十分钟,和存活探针分开。
第二个问题在启动失败的方式上。最开始用的是 BF16 权重,--max-model-len 想直接用模型的 40960 上限,起不来:
ValueError: The model's max seq len (40960) is larger than the maximum number
of tokens that can be stored in KV cache (23552). Try increasing
`gpu_memory_utilization` or decreasing `max_model_len` when initializing the engine.这条报错的含义是:权重加载完、显存按 --gpu-memory-utilization 划完之后,剩下能放 KV cache 的空间连一条满长度的序列都装不下。它不是 OOM,是 vLLM 在启动阶段主动拒绝一个跑起来一定会出问题的配置。
账很好算。32B 参数的 BF16 权重要 65GB,80GB 的卡按 0.9 划给 vLLM 是 72GB,减掉权重再减掉激活和 CUDA graph 的开销,KV cache 只剩 6GB 上下。Qwen3-32B 每个 token 的 KV 是 64 层 × 8 个 KV 头 × 128 维 × K 和 V 两份 × 2 字节,等于 256KB。6GB 只够两万多个 token,也就是报错里的那个数字。这种情况下就算把 --max-model-len 压到 16k 强行起来,也只能同时跑一两条会话。
所以换成 FP8 权重不是为了快,是为了把 32GB 显存还给 KV cache。权重降到 33GB 以后,KV cache 有 35GB 以上,能放十几万个 token。--max-model-len 顺手压到 32768,我们的 Agent 会话很少超过这个数,上限压低一点,单条请求的最坏显存占用也跟着有界。
第三个问题是探针探什么。/health 返回 200 只说明 HTTP 服务还在。就绪探针改成一次真实的最小生成请求:
async def readiness_probe(provider: Provider) -> bool:
try:
resp = await provider.client.post(
"/chat/completions",
json={
"model": provider.model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
"chat_template_kwargs": {"enable_thinking": False},
},
timeout=10,
)
return resp.status_code == 200
except httpx.HTTPError:
return False这个探针每 30 秒跑一次,连续三次失败就把 provider 标成不健康,别名解析的时候直接跳过。它多花 1 个 token 的算力,换来的是"能不能真的生成"这个答案,而不是"端口开着没有"。
客户端断了,GPU 不能继续算
Agent 支持用户中途取消任务。取消的时候 Agent 会断掉到网关的连接,网关必须把这个断连传递到 vLLM。
vLLM 这边是做了处理的:请求处理过程中会检查客户端是否断开,断了就 abort 这条请求,释放它占的 KV block。前提是它能感知到断开。如果网关用了一个会把上游响应整体读完再转发的中间层,或者在 CancelledError 里没有关掉上游连接,vLLM 看到的就是一个还活着的客户端,会把几千个 token 老老实实生成完,给一个已经不存在的人。
async def stream(self, request: GatewayRequest) -> AsyncIterator[Event]:
async with self.client.stream("POST", "/chat/completions", json=body) as resp:
resp.raise_for_status()
try:
async for line in resp.aiter_lines():
if event := parse_sse_line(line):
yield event
except asyncio.CancelledError:
await resp.aclose()
raise云厂商上这件事没人在意,因为浪费的是别人的 GPU,账单上多几千 token 也看不出来。自己的卡上,一条被取消却继续 decode 的长请求,会实实在在地占着 max_inflight 里的一个名额几十秒。
上下文超了怎么办
--max-model-len 32768 是硬上限。Agent 会话跑久了,历史消息加工具结果很容易逼近这个数。超了 vLLM 返回 400,消息大意是模型最大上下文是 32768,你请求了多少,请缩短。
一个选择是网关在发出去之前先用 vLLM 的 /tokenize 算一遍长度。我没这么做,因为这等于每次请求多一个来回,而且只对 vLLM 这个 provider 有意义。
现在的做法是把这个 400 识别出来,转成网关自己的错误类型 CONTEXT_LENGTH_EXCEEDED。Agent 拿到这个错误会触发一次上下文压缩——把早期的工具结果替换成摘要——然后重试。这条压缩逻辑本来就有,只是原来由 Agent 自己估算 token 数来触发,现在多了一个由下游明确报错触发的入口。
云厂商的同类错误也被映射到同一个类型。这样 Agent 只需要处理一种"上下文超了",不需要知道是谁说的。
还没解决的
接入两周之后,单卡跑得还算稳,但有几件事我心里清楚没做完。
只有一个副本。max_inflight 是按单卡的容量算的,第二张卡上来之后,怎么分流、session 的 KV cache 会不会因为轮询被打散,这些都还没碰。
cached_tokens 一直是 null,我只知道服务级的命中率,不知道具体哪类请求在命中、哪类在重算。按我们 Agent 的 prompt 结构,前面几千个 token 对所有用户都一样,理论上命中率应该很高,/metrics 里的数字却比这个预期低得多。这个差距是什么造成的,得单独花时间查。
网关这层最终没有变成一个"薄薄的转发"。它多了能力过滤、字段剥离、reasoning 翻译、usage 注入、背压、真实探针、取消传递和错误归一化。这些东西每一件都不大,但少任何一件,业务侧就得知道"这次调的是 vLLM 还是云厂商"。网关存在的意义就是让它们不用知道。