1. 推理服务上线后为什么总在延迟、吞吐和成本之间反复横跳
把一个大模型从 notebook 里跑通,到真正扛住线上流量,中间隔着的不是一行model.generate(),而是一整套推理服务架构。我见过太多团队卡在同一个地方:白天流量低谷时单卡 A100 利用率不到 15%,夜里批量任务却排队等 GPU 释放;流量一突增,请求堆在队列里,P99 延迟直接飙到 8 秒,用户等两秒就关页面走人。这种「闲时浪费、忙时排队」的根因,是架构设计没把推理服务当成一等公民,而是当成一个「加载模型、暴露接口」的脚本。
AI 推理服务架构设计要解决的核心问题,是在首 Token 延迟(TTFT)、并发吞吐和 GPU 成本这三者之间找平衡点。这不是调几个参数就能搞定的,而是要从请求调度、模型生命周期管理、资源编排三个维度做系统性设计。请求调度决定在线请求和批量请求怎么差异化路由;模型生命周期管理决定多模型怎么共存、显存怎么复用;资源编排决定副本数怎么随负载弹性伸缩。
这篇文章面向的是已经能把模型跑起来、但还没把它变成稳定服务的开发者。我会给出一套可复制的服务配置、调度策略参数和压测验证步骤,目标是在本地或云端跑通一条可弹性伸缩的推理服务链路。同时,多模型接入这块我会用 TaoToken 的统一 Key/API 通道来做调用验证,省去每个模型单独配一套鉴权的麻烦。整套方案你可以直接照着搭,也可以按自己的业务裁剪。
2. TaoToken 统一接入:多模型推理服务的鉴权与路由前置
在搭推理服务之前,先解决一个容易被低估的问题:多模型接入的鉴权管理。生产环境里你往往不止跑一个模型,可能是 Qwen 做通用对话、DeepSeek 做代码补全、再加一个 Embedding 模型做 RAG 检索。如果每个模型都单独申请 Key、单独配 Base URL、单独处理限流,运维成本会指数级上升。TaoToken 在这里的角色,是提供一个统一的 API 通道,让你用一套 Key 和统一的 Base URL 去调用多个模型,推理服务侧只需要维护一份鉴权配置。
先说清楚它是什么、能做什么、适合谁。TaoToken 是一个模型 API 聚合接入平台,提供统一的 Key 管理和 API 通道,支持多模型调用。适合的人群是:需要在一个推理服务里接入多个模型、又不想为每个模型单独维护鉴权体系的开发者;以及做 Agent、Coding Plan 这类需要长期稳定调用多个模型的场景。它的 API 地址是https://taotoken.net/api,官网是https://taotoken.net/。
前置准备分三步。第一步,去官网注册账号,拿到你的 API Key。第二步,确认你要接入的模型 ID,比如claude-sonnet-4-20250514、gpt-4o这类,具体以平台文档为准。第三步,在你的推理服务配置里,把 Base URL 统一指向 TaoToken 的 API 地址,Key 填你申请到的那一个。这样你的调度器在路由请求时,不需要关心底层是哪个厂商,只需要传模型 ID 就行。
这里有个设计上的好处:你的推理服务调度器可以把「模型选择」和「鉴权」解耦。调度器只管根据请求优先级和模型名做路由,鉴权统一交给 TaoToken 通道处理。后面做弹性伸缩时,新起的副本只需要读同一份环境变量里的 Key,不用为每个模型单独注入凭证。这一步做完,你就有了一套统一的多模型接入底座,接下来才是真正的服务架构搭建。
3. 可复制的推理服务配置:调度器、模型生命周期与弹性伸缩
这一节是全文的技术核心,我会给出三块可复制的配置和代码:请求调度器、模型生命周期管理器、弹性伸缩控制器。每一块都给出完整参数和路径,你可以直接落到自己的项目里。
3.1 请求调度器配置:优先级感知的差异化路由
调度器的职责是把在线请求(延迟敏感)和批量请求(吞吐优先)分开处理。核心参数是max_concurrent,它决定单个副本同时处理多少请求。这个值不是拍脑袋定的,要结合你的 GPU 显存和模型大小来算。下面是一个可复制的调度器实现,保存为scheduler.py:
import asyncio import time from dataclasses import dataclass, field from enum import Enum from typing import Optional class RequestPriority(Enum): REALTIME = 1 # 在线推理,延迟敏感 BATCH = 2 # 批量推理,吞吐优先 BACKGROUND = 3 # 后台任务,资源空闲时执行 @dataclass(order=True) class InferenceRequest: priority: int = field(compare=True) submit_time: float = field(compare=True) request_id: str = field(compare=False) model_name: str = field(compare=False) prompt: str = field(compare=False) max_tokens: int = field(compare=False, default=512) callback: Optional[callable] = field(compare=False, default=None) class PriorityScheduler: """优先级调度器:在线请求优先,批量请求填充空闲槽位""" def __init__(self, max_concurrent: int = 8): self.max_concurrent = max_concurrent self.current_running = 0 self.pending_queue: list[InferenceRequest] = [] self._lock = asyncio.Lock() self._condition = asyncio.Condition(self._lock) async def submit(self, request: InferenceRequest) -> str: async with self._condition: self.pending_queue.append(request) self.pending_queue.sort() self._condition.notify() return request.request_id async def dispatch(self, engine_pool: dict) -> Optional[InferenceRequest]: async with self._condition: while not self.pending_queue and self.current_running >= self.max_concurrent: await self._condition.wait() if not self.pending_queue: return None request = self.pending_queue.pop(0) self.current_running += 1 return request async def complete(self, request_id: str): async with self._condition: self.current_running -= 1 self._condition.notify_all()max_concurrent的取值建议:7B 模型在 24GB 显存上可以设 8 到 16;70B 模型在 80GB 显存上建议设 4 到 8。设太大显存会 OOM,设太小吞吐上不去。这个参数后面会被弹性伸缩控制器读取,作为扩容决策的输入之一。
3.2 模型生命周期管理:LRU 淘汰与显存水位控制
多模型共存时,显存是最稀缺的资源。你不能把所有模型都常驻显存,得有一套加载和卸载策略。下面这个管理器用 LRU(最近最少使用)策略做淘汰,保存为model_manager.py:
import threading from collections import OrderedDict from typing import Optional class ModelLifecycleManager: """管理 GPU 上的模型加载与卸载,基于 LRU 策略释放显存""" def __init__(self, max_vram_gb: float = 80.0): self.max_vram_gb = max_vram_gb self.used_vram_gb = 0.0 self.loaded_models: OrderedDict[str, dict] = OrderedDict() self._lock = threading.RLock() self._registry: dict[str, dict] = {} def register_model(self, name: str, vram_gb: float, loader: callable): self._registry[name] = {"vram_gb": vram_gb, "loader": loader} def get_model(self, name: str) -> Optional[object]: with self._lock: if name in self.loaded_models: self.loaded_models.move_to_end(name) return self.loaded_models[name]["instance"] if name not in self._registry: raise ValueError(f"模型 {name} 未注册") model_meta = self._registry[name] required_vram = model_meta["vram_gb"] while self.used_vram_gb + required_vram > self.max_vram_gb: if not self.loaded_models: raise RuntimeError( f"显存不足:需要 {required_vram}GB,总量 {self.max_vram_gb}GB" ) evict_name, evict_meta = self.loaded_models.popitem(last=False) self.used_vram_gb -= evict_meta["vram_gb"] evict_meta.get("unloader", lambda: None)() instance = model_meta["loader"]() self.loaded_models[name] = {"instance": instance, "vram_gb": required_vram} self.used_vram_gb += required_vram return instance关键参数是max_vram_gb,它应该设成 GPU 实际显存减去系统预留(一般留 4 到 8GB 给 KV Cache 和框架开销)。比如 80GB 的 A100,建议设 72。注册模型时,vram_gb要填模型权重加推理框架开销的实际占用,7B 模型约 16GB,70B 模型约 48GB(4bit 量化)。
3.3 弹性伸缩控制器:基于指标的副本调整
伸缩控制器从 Prometheus 拉指标,算出目标副本数。保存为autoscaler.py:
import time import logging import requests class InferenceAutoscaler: """根据实时指标动态调整推理服务副本数""" def __init__( self, prometheus_url: str, deployment_name: str, namespace: str = "ai-inference", min_replicas: int = 1, max_replicas: int = 10, target_queue_depth: float = 5.0, target_p99_ms: float = 2000.0, cooldown_seconds: int = 60, ): self.prometheus_url = prometheus_url self.deployment_name = deployment_name self.namespace = namespace self.min_replicas = min_replicas self.max_replicas = max_replicas self.target_queue_depth = target_queue_depth self.target_p99_ms = target_p99_ms self.cooldown_seconds = cooldown_seconds self.last_scale_time = 0 def _query_metric(self, query: str) -> float: try: resp = requests.get( f"{self.prometheus_url}/api/v1/query", params={"query": query}, timeout=5, ) result = resp.json() if result["status"] == "success" and result["data"]["result"]: return float(result["data"]["result"][0]["value"][1]) except Exception as e: logging.warning(f"指标查询失败: {e}") return 0.0 def compute_desired_replicas(self, current_replicas: int) -> int: now = time.time() if now - self.last_scale_time < self.cooldown_seconds: return current_replicas queue_depth = self._query_metric( f'inference_queue_depth{{deployment="{self.deployment_name}"}}' ) p99_latency = self._query_metric( f'histogram_quantile(0.99, ' f'rate(inference_request_duration_bucket{{deployment="{self.deployment_name}"}}[1m]))' ) * 1000 queue_ratio = queue_depth / self.target_queue_depth latency_ratio = p99_latency / self.target_p99_ms if p99_latency > 0 else 1.0 pressure_ratio = max(queue_ratio, latency_ratio) desired = max(self.min_replicas, min(self.max_replicas, int(current_replicas * pressure_ratio))) if desired != current_replicas: self.last_scale_time = now logging.info( f"伸缩决策: {current_replicas} → {desired}, " f"队列深度={queue_depth:.1f}, P99={p99_latency:.0f}ms" ) return desired参数说明:target_queue_depth设 5 表示队列里平均堆 5 个请求就该扩容;target_p99_ms设 2000 表示 P99 超过 2 秒就扩容;cooldown_seconds设 60 防止频繁抖动。这三个值要结合你的 SLA 来调,延迟敏感的在线服务可以把target_p99_ms压到 1000。
3.4 统一接入配置:把 TaoToken 通道接进推理服务
上面三块代码负责调度和伸缩,模型调用本身走 TaoToken 通道。配置文件保存为config.yaml:
inference: base_url: "https://taotoken.net/api" api_key: "${TAOTOKEN_API_KEY}" default_model: "claude-sonnet-4-20250514" timeout_seconds: 60 max_retries: 3 scheduler: max_concurrent: 8 priorities: realtime: 1 batch: 2 background: 3 model_lifecycle: max_vram_gb: 72 models: - name: "claude-sonnet-4-20250514" vram_gb: 0 - name: "gpt-4o" vram_gb: 0 autoscaler: prometheus_url: "http://prometheus:9090" deployment_name: "inference-service" min_replicas: 1 max_replicas: 10 target_queue_depth: 5.0 target_p99_ms: 2000.0 cooldown_seconds: 60注意这里vram_gb填 0,因为走 TaoToken 通道的模型是远程调用,不占本地显存。本地显存只留给你自己部署的开源模型。这种混合模式很实用:延迟敏感的轻量模型本地跑,重量级模型走 TaoToken 远程调,调度器统一路由。
4. 验证请求与成功结果:压测跑通弹性伸缩链路
配置写完,得验证整条链路真的能跑通。分三步:单请求验证、并发压测、伸缩触发验证。
4.1 单请求验证:确认 TaoToken 通道可用
先用 curl 验证统一通道能正常返回。命令如下:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "用一句话解释什么是推理服务"}], "max_tokens": 100 }'成功的话你会看到类似这样的返回:
{ "id": "chatcmpl-xxx", "object": "chat.completion", "model": "claude-sonnet-4-20250514", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "推理服务是把训练好的模型部署成可调用的在线接口,让业务系统能实时获取模型输出。" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 18, "completion_tokens": 32, "total_tokens": 50 } }看到choices数组里有内容、usage有 token 统计,说明通道通了。如果返回 401,检查 Key 是否正确;如果返回 404,检查模型 ID 是否拼错。
4.2 并发压测:用 locust 模拟真实流量
单请求通了不代表能扛并发。用 locust 做压测,保存为locustfile.py:
from locust import HttpUser, task, between import os class InferenceUser(HttpUser): wait_time = between(0.1, 0.5) @task(3) def realtime_request(self): self.client.post( "/v1/chat/completions", headers={"Authorization": f"Bearer {os.environ['TAOTOKEN_API_KEY']}"}, json={ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "写一个快速排序"}], "max_tokens": 256, }, ) @task(1) def batch_request(self): self.client.post( "/v1/chat/completions", headers={"Authorization": f"Bearer {os.environ['TAOTOKEN_API_KEY']}"}, json={ "model": "gpt-4o", "messages": [{"role": "user", "content": "总结这段文本"}], "max_tokens": 512, }, )启动压测:
locust -f locustfile.py --host=https://taotoken.net/api --users 50 --spawn-rate 550 个并发用户、每秒新增 5 个,跑 5 分钟。观察 locust 面板上的 P50、P95、P99 延迟和 RPS。实测下来,走 TaoToken 通道的远程调用,P99 延迟主要受网络和模型侧影响,本地调度器的开销可以忽略。
4.3 伸缩触发验证:观察副本数变化
压测跑起来后,去看伸缩控制器的日志。你应该能看到类似这样的输出:
伸缩决策: 1 → 3, 队列深度=12.3, P99=2450ms 伸缩决策: 3 → 5, 队列深度=18.7, P99=3100ms这说明队列深度和 P99 都超过了阈值,控制器在扩容。压测停止后,等冷却期过去,应该看到反向缩容:
伸缩决策: 5 → 2, 队列深度=1.2, P99=800ms如果压测期间副本数一直不动,检查 Prometheus 指标名是否和代码里的一致,以及cooldown_seconds是不是设太大导致一直跳过决策。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
搭这套链路时,报错基本集中在几个地方。我按真实遇到的频率排一下。
401 Unauthorized。最常见的原因是 Key 没读到。检查TAOTOKEN_API_KEY环境变量是否真的注入到了容器里,用echo $TAOTOKEN_API_KEY确认。另一个原因是 Key 前后有空格或换行,从官网复制时容易带上。还有一种情况是 Key 过期或被禁用,去控制台重新生成一个。
local proxy failed。这个报错通常出现在你本地配了某些网络工具,导致请求发不出去。排查方法是先确认你的运行环境没有额外的网络层拦截,直接用 curl 测试https://taotoken.net/api是否可达。如果 curl 通但代码不通,检查代码里的base_url是不是被某个环境变量覆盖成了本地地址。
reading choices 报错。典型报错是KeyError: 'choices'或list index out of range。这说明返回的 JSON 结构和你预期的不一样。原因通常是模型 ID 写错了,服务端返回了一个错误对象而不是正常的 completion 结构。打印完整响应体,看error字段说了什么。另一个可能是max_tokens设得太大超过了模型上限,服务端直接拒绝。
OAuth 相关报错。如果你用的是 Claude Code 或 Codex 这类工具,可能会遇到 OAuth 认证失败。这类工具默认走自己的 OAuth 流程,要切到 TaoToken 通道,需要改配置文件。以 Claude Code 为例,配置在~/.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "你的TaoToken Key", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" } }三件套必须齐全:Base URL、Key、Model ID。少任何一个都会报 OAuth 或认证错误。Codex 的话改~/.codex/auth.json,字段名类似。Cline 的 MCP 配置则在cline_mcp_settings.json里,同样三件套。
显存 OOM。如果本地跑开源模型时报CUDA out of memory,检查max_vram_gb是不是设太满。留 4 到 8GB 给 KV Cache,别把显存算到 100%。另外max_concurrent设太大也会导致 KV Cache 爆掉,先降到 4 试试。
伸缩不触发。前面提过,检查指标名和冷却期。还有一个隐藏坑:Prometheus 的 scrape interval 如果设成 30 秒,而你的cooldown_seconds只有 60 秒,可能指标还没更新就进入下一轮决策了。把 scrape interval 调到 15 秒,冷却期保持 60 秒以上。
6. 从这套架构继续往下走
这套架构跑通之后,你手里就有了一条可弹性伸缩的推理服务链路:调度器管优先级,生命周期管理器管显存,伸缩控制器管副本,TaoToken 通道管多模型鉴权。接下来可以往几个方向深化。一是加预测性扩容,基于历史流量曲线提前扩容,而不是等指标超标才响应,这样能消掉那 30 到 60 秒的扩容窗口。二是给批量任务加防饥饿机制,为低优先级请求预留最低执行槽位,或者设最长等待时间超时后自动提权。三是把 KV Cache 管理接进来,用 PagedAttention 这类机制复用显存,进一步压低 TTFT。
如果你还没配好 TaoToken 通道,先去控制台拿 Key,接入文档里有各语言的示例。需要验证模型返回是否正常,可以用模型对话页面直接测。长期做 Coding Plan 或 Agent 场景的,建议直接上 Coding Plan,省得每次手动配。地址都在下面:
- 官网:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API:https://taotoken.net/api
- 模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
- Claude Code 接入:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite
最后说个实操细节:压测时别一上来就 50 并发,先用 5 并发跑通,确认指标采集正常,再逐步加。我见过直接上高并发把 Prometheus 打挂的,指标一断,伸缩控制器就瞎了。稳扎稳打,先让链路跑起来,再谈优化。