数据截至 (上游 commit ceade4cbe9f2)
路由层与 OpenAI 兼容适配:一次模型调用如何落到后端
30 秒导读: 你向 OGX 发一个
{"model": "gpt-4", ...}的 chat completion 请求。OGX 内部 没有"gpt-4"这个东西——它只有一堆 provider(OpenAI、vLLM、Ollama、Bedrock…)。这一章追踪 一个字符串model_id如何被一步步翻译、定位到某个 provider,再被"整形"成标准 OpenAI 调用 打到真实后端。读完你能一口气讲清:"gpt-4"→ 路由表查表 →InferenceRouter委派 →OpenAIMixin.openai_chat_completion→AsyncOpenAI.chat.completions.create的完整链路。
本章是 OGX 全景 的第 3 章。上游怎么把请求送到 Router、provider 怎么被装配进路由表, 见 01 请求生命周期 和 02 Provider 架构; 本章拿到的是"Router 与路由表已经就位"这个起点。服务端的 agentic 编排(Responses)是 04 章, 不在这里讲。
1. 这一章解决的那个小问题
一句话:model 是个字符串,后端是个对象,中间隔着两层。
客户端只知道一个名字("gpt-4"、"my-llama"、"openai/gpt-4o-mini")。OGX 要回答三个问题,
才能真正发出请求:
| 问题 | 谁回答 | 产出 |
|---|---|---|
| 这个名字归哪个 provider? | 路由表(routing table) | 一个 provider 实例 |
| 后端真正认识的模型名是什么? | 路由表 | provider_resource_id(如 gpt-4o-mini) |
| 怎么把请求打成 OpenAI 形状发出去? | OpenAIMixin + openai_compat | 一次 AsyncOpenAI 调用 |
这三步就是本章的三节主线。核心洞见是:model_id 会在 Router 侧被翻译一次——由路由表把
"注册名"翻成"后端名"(_get_model_provider);OpenAIMixin 侧旧版的兜底二翻已删除,进 mixin 的
就是后端名。理解这"一次翻译、出门还原",整章就通了。
2. 顶层全景:一次 inference 的路径
怎么读这张图: 从左到右是一次非流式 chat completion 的控制流;方框里第二行小字是真实符号名, 方便你 grep。虚线框是"查表/翻译"动作。
HTTP POST /v1/chat/completions {"model":"gpt-4", messages:[...]}
│
▼
┌────────────────────────┐
│ ① Router:按模型选后端 │ InferenceRouter.openai_chat_completion (inference.py:198)
│ 并翻译模型名 │ ├─ _get_model_provider (inference.py:121)
└───────────┬────────────┘ │
│ │ ┌ · · · · · · · · · · · · · · · · · · ·┐
│ └─▶│ ② 路由表:查表 │
│ │ get_object_by_identifier("model",…) │ (common.py:164)
│ │ → 命中 → get_provider_impl(identifier)│ (models.py:296)
│ │ 读缓存/落盘的 DistributionRegistry │ (registry.py)
│ └ · · · · · · · · · · · · · · · · · · · · ┘
│ 产出:provider 实例 + provider_resource_id
▼
┌────────────────────────┐
│ ③ 适配层:整成 OpenAI │ OpenAIMixin.openai_chat_completion (openai_mixin.py:393)
│ 形状并发出 │ ├─ _validate_model_allowed (白名单校验) (openai_mixin.py:301)
│ │ ├─ client (构造/复用 AsyncOpenAI) (openai_mixin.py:242)
│ │ └─ prepare_openai_completion_params (openai_compat.py:60)
└───────────┬────────────┘
▼
AsyncOpenAI.chat.completions.create(model="gpt-4o-mini", …)
│
▼
真实后端(api.openai.com / vLLM / Ollama / …)
各部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
InferenceRouter | 按 model_id 选出 provider,委派调用,收尾算指标/落库 | core/routers/inference.py:80 |
ModelsRoutingTable | 模型的注册表:查表、注册、动态发现、访问控制 | core/routing_tables/models.py:41 |
CommonRoutingTableImpl | 所有路由表的公共底座:落库、RBAC 钩子、通用查表 | core/routing_tables/common.py:88 |
DistributionRegistry | 把"哪个名字归哪个 provider"持久化到 KVStore + 内存缓存 | core/store/registry.py:23 |
OpenAIMixin | 把"任意 remote 后端"统一成 OpenAI 形状的适配基类 | providers/utils/inference/openai_mixin.py:59 |
openai_compat / model_registry | 参数整形 + provider 配置/别名模型表 | providers/utils/inference/*.py |
3. 路由层:名字如何变成一个 provider
这一节讲图里的 ①②——路由表怎么把一个字符串定位到某个 provider,并顺带翻出后端认识的模型名。
3.1 先分清两个 model_id(整章的地基)
路由表里每个模型是一个 Model 对象,身上有两个关键字段,千万别混:
| 字段 | 含义 | 例子 |
|---|---|---|
identifier | 对客户端暴露的注册名(OGX 命名空间) | gpt-4、openai/gpt-4o-mini、my-llama |
provider_resource_id | 后端真正认识的模型名 | gpt-4o-mini |
provider_id | 归属哪个 provider 实例 | openai、vllm、ollama |
路由 = 把 identifier 翻成 (provider 实例, provider_resource_id)。 记住这句话,后面全是它的展开。
3.2 注册:一条模型如何进表
在服务发起调用前,模型得先"在册"。有三条注册路径,最终都汇到 register_object:
- 手动注册:用户调
POST /v1/models,进ModelsRoutingTable.register_model(models.py:315)。 - 动态发现:后台
refresh()定时向 provider 要list_models(),把结果灌进update_registered_models(models.py:414)。 - 配置启动:distribution 配置里预声明的模型,初始化时落库。
register_model 做几件事,最有意思的是给名字补前缀和auto 解析:
# 示意,非源码 —— 摘自 ModelsRoutingTable.register_model 的核心逻辑
provider_model_id = provider_model_id or model_id # 后端名缺省=注册名
if provider_model_id == "auto": # "auto" → 问 provider 要第一个匹配的模型
provider_model_id = await self._resolve_auto_model(provider_id, model_type)
if model_id.startswith(f"{provider_id}/"): # 已带前缀就不重复加
identifier = model_id
else:
identifier = f"{provider_id}/{model_id}" # 否则加 provider 前缀做命名空间
真实实现见 models.py:315 的 register_model;"auto" 别名解析见 _resolve_auto_model(models.py:46)——
它调 provider.list_models(),按 model_type 过滤后取第一个当作真实模型名。
补前缀这步是多 provider 共存的关键:两个后端都叫 llama3 也不会撞,因为注册名变成
vllm/llama3 和 ollama/llama3。
注册的最后一步落到公共底座 CommonRoutingTableImpl.register_object(common.py:184):它先跑
创建权限检查,再调 register_object_with_provider(common.py:37,按 API 类型分派到
p.register_model),最后 dist_registry.register(...) 落库。
# 示意,非源码 —— register_object 的骨架(common.py:184)
if not obj.provider_id: # 没指定就挑第一个 provider
obj.provider_id = list(self.impls_by_provider_id.keys())[0]
creator = get_authenticated_user()
if not is_action_allowed(self.policy, "create", obj, creator): # ← 访问控制钩子
raise AccessDeniedError("create", obj, creator)
obj.owner = creator # 记下属主(供后续 ABAC)
registered = await register_object_with_provider(obj, p) # 通知 provider "你多了个模型"
await self.dist_registry.register(registered) # 落 KVStore + 缓存
3.3 解析:调用时怎么查表(本章最容易看错的一处)
调用时的查表有两个不同入口,别搞混:
| 入口 | 签名 | 谁用 | 特点 |
|---|---|---|---|
CommonRoutingTableImpl.get_provider_impl | (routing_key, provider_id=None) | tool_runtime / vector_io 路由表 | 读同步缓存 get_cached,不查磁盘 |
ModelsRoutingTable.get_provider_impl | (model_id) | inference 走这条(方法被重写) | 走 lookup_model → 带 RBAC 的异步查 |
也就是说:任务里提到的 common.py:130 那个基类版 get_provider_impl 是给工具组/向量库路由表用的;
模型路由表把它重写了(models.py:296),inference 请求命中的是重写版。基类版长这样:
# 示意,非源码 —— 基类 get_provider_impl(common.py:130),tool/vector 用
obj = self.dist_registry.get_cached(objtype, routing_key) # 同步读内存缓存
if not obj:
raise ValueError(f"{objtype} `{routing_key}` not served by ...")
if not provider_id or provider_id == obj.provider_id:
return self.impls_by_provider_id[obj.provider_id] # 名字 → provider 实例
而 inference 侧真正走的是 InferenceRouter._get_model_provider(inference.py:121),它做了两次查表:
# 示意,非源码 —— _get_model_provider(inference.py:121)
model = await self.routing_table.get_object_by_identifier("model", model_id) # 查①:拿 Model 对象(带 RBAC)
if model:
if model.model_type != expected_model_type: # 顺手校验类型(llm/embedding/rerank)
raise ModelTypeError(...)
provider = await self.routing_table.get_provider_impl(model.identifier) # 查②:拿 provider 实例
return provider, model.provider_resource_id # ★ 返回 provider + 后端模型名
return await self._get_provider_by_fallback(model_id, expected_model_type) # 没命中 → 走兜底
get_object_by_identifier(common.py:164)是"带门禁的查表":查到对象后立刻跑
is_action_allowed(policy, "read", obj, user),读权限不足就当作查不到(返回 None),不泄露存在性。
3.4 兜底:provider_id/model_id 直连
如果注册表里查不到,_get_provider_by_fallback(inference.py:133)给一条后门:把 model_id 按
/ 切成 provider_id + provider_resource_id,只要那个 provider 存在,就临时拼一个 ModelWithOwner
过一遍 RBAC,直接用。这让客户端能用 "openai/gpt-4o-mini" 这种"裸后端名"直连,不必事先注册。
"openai/gpt-4o-mini"
│ split("/", 1)
▼
provider_id="openai" provider_resource_id="gpt-4o-mini"
│ provider 在册? 且 RBAC read 通过?
▼
impls_by_provider_id["openai"], "gpt-4o-mini"