9.2 KiB
Context
Nex 是一个 AI 网关,屏蔽多个 AI 供应商(OpenAI、Anthropic 等)的差异,提供统一的 API 接口。当前后端直接透传上游供应商的原始模型名称(如 gpt-4),通过 models 表的 model_name 字段路由。models 表的 id 字段当前语义是用户自定义标识符,与上游模型名 model_name 之间没有明确的职责分离。
当前架构:
ProxyHandler从请求体中提取model字段 →RoutingService.Route(modelName)按model_name查询GET /v1/models直接透传到第一个供应商的上游接口GET /v1/models/{id}直接透传到上游TargetProvider.ModelName在 encoder 中覆盖请求体的model字段
Goals / Non-Goals
Goals:
- 定义统一模型 ID 格式
provider_id/model_name,全局唯一标识一个模型 - 拦截
/v1/models和/v1/models/{unified_id}接口,从数据库聚合返回,不再透传上游 - 所有代理接口(Chat、Embeddings、Rerank)使用统一模型 ID 路由,响应中
model字段覆写为统一 ID models.id改为 UUID(内部标识),models.model_name存储上游供应商的模型名称provider_id约束为[a-zA-Z0-9_]+,防止特殊字符影响 URL 和 JSON 交互- 保持协议无关、供应商无关的设计
Non-Goals:
- 不支持供应商别名或模型别名
- 不做上游模型列表自动同步(管理员手动配置可见模型)
- 不适配前端(后续统一适配)
Decisions
D1: 统一模型 ID 格式 — provider_id/model_name
格式: {provider_id}/{model_name},例如 openai/gpt-4、anthropic/claude-3-opus-20240229。
- 使用
strings.SplitN(id, "/", 2)解析,只在第一个/处分割 provider_id约束为[a-zA-Z0-9_]+,保证不含/,解析安全model_name(上游模型名)不受字符约束,因为它不出现在管理 API 的 URL 主键中
选择此格式而非 provider_id:model_name(冒号分隔)的原因:斜杠在 JSON 字符串中天然安全,且在 URL 路径中语义清晰(/v1/models/openai/gpt-4),更符合 REST 风格。
D2: models 表 schema 变更
旧: id(TEXT PK, 用户自定义), provider_id, model_name(上游模型名), enabled, created_at
新: id(UUID PK, 自动生成), provider_id, model_name(上游模型名), enabled, created_at
UNIQUE(provider_id, model_name)
关键语义变化:
id从用户自定义标识符变为 UUID 内部主键(自动生成),用于管理接口 CRUDmodel_name语义不变,始终存储上游供应商的模型名称,发给上游的实际值- 新增联合唯一约束
UNIQUE(provider_id, model_name)保证同一供应商内模型不重复
选择保留 id 作为 PK 而非使用 (provider_id, model_name) 联合主键的原因:上游模型名可能含 / 等特殊字符(如 Azure OpenAI 的 deployment 路径),不适合作为管理接口的 URL 参数。id 为 UUID 可以避免所有特殊字符问题。
D3: Models/ModelInfo 接口本地聚合
GET /v1/models 从数据库查询所有 enabled 的模型(JOIN providers),组装为 CanonicalModelList,ID 字段使用统一模型 ID,通过客户端协议的 adapter 编码返回。不请求上游。
GET /v1/models/{provider_id}/{model_name} 从 URL 提取统一模型 ID,解析后查询数据库,组装为 CanonicalModelInfo 返回。不请求上游。
选择纯 DB 聚合而非实时查询上游的原因:
- 管理员通过
/api/models控制哪些模型对用户可见,网关的意义在于控制可见性 - 响应速度快,不依赖上游可用性
- 符合当前架构中管理员手动配置 provider 和 model 的设计哲学
D4: 跨协议响应 model 字段覆写
跨协议场景下,上游返回的响应经过 decode → encode 全量转换。上游响应中的 model 字段是原生模型名(如 gpt-4),需要在返回给客户端前覆写为统一模型 ID。
实现位置:ConversionEngine.ConvertHttpResponse 新增 modelOverride string 参数。在解码上游响应到 canonical 后、编码客户端响应前,将 canonical.Model 设为 modelOverride。流式场景同理,CreateStreamConverter 同样接收 modelOverride 参数。
此方案仅在跨协议转换路径使用。选择在 canonical 层面处理的原因:
- 跨协议必须全量 decode → encode,canonical 的 Model 字段天然可覆写
- 不侵入各协议 adapter 的实现
- 与 Smart Passthrough 互补——跨协议不可保真,canonical 覆写是自然的
D5: ProtocolAdapter 接口扩展
在 ProtocolAdapter 接口新增四个方法,将所有协议相关的 model 字段知识归属到 adapter:
ExtractUnifiedModelID(nativePath string) (string, error)— 从路径中提取统一模型 IDExtractModelName(body []byte, ifaceType InterfaceType) (string, error)— 从请求体中提取 model 值(所有流程复用,替代 handler 层硬编码的extractModelName)RewriteRequestModelName(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error)— 最小化 JSON 改写请求体中的 model 字段(Smart Passthrough 请求方向)RewriteResponseModelName(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error)— 最小化 JSON 改写响应体中的 model 字段(Smart Passthrough 响应方向)
拆分请求/响应方向的原因:请求体和响应体的 JSON 结构可能不同,model 字段的位置可能不同(当前 OpenAI/Anthropic 协议碰巧都在顶层 "model",但未来协议不一定)。拆分后 adapter 各自独立实现,各自按 ifaceType 分派。
ExtractModelName 和两个 Rewrite* 方法均接收 InterfaceType 参数,因为不同接口类型的请求体/响应体结构可能不同,adapter 按 ifaceType 分派具体的定位和改写逻辑。
对于 isModelInfoPath 的调整:允许 suffix 中包含 /,因为统一模型 ID 格式为 provider_id/model_name。
将此方法放在适配器接口而非 handler 中通用实现的原因:不同协议的模型详情路径格式和请求体结构可能不同,各自拥有独立演进能力。
D6: provider_id 字符集约束
创建供应商时校验 id 字段必须匹配 ^[a-zA-Z0-9_]+$,长度 1-64。
选择严格限制而非仅排除 / 的原因:统一模型 ID 出现在 URL 路径和 JSON 中,?、#、&、= 等字符会在 URL 中引起解析问题。限制为字母数字下划线后,URL 中永远安全,不需要编码。
D7: pkg/modelid 工具包
新增 pkg/modelid 包,提供:
ParseUnifiedModelID(id string) (providerID, modelName string, error)— 解析FormatUnifiedModelID(providerID, modelName string) string— 格式化ValidateProviderID(id string) error— 校验供应商 IDIsValidUnifiedModelID(id string) bool— 校验统一模型 ID
使用标准库 strings.SplitN 和 regexp 实现,不引入新依赖。
D8: 同协议 Smart Passthrough
当前同协议透传将请求体原样转发,跳过 decode → encode,保持参数完全保真。但统一模型 ID 要求改写 model 字段,原样透传无法满足。
Smart Passthrough:保留同协议透传的保真优势,通过 json.RawMessage 做最小化改写。
实现方式:adapter 的 RewriteRequestModelName 和 RewriteResponseModelName 方法各自解析 JSON 为 map[string]json.RawMessage,只替换 model 字段的 value,其余字段保留原始 bytes,不经过任何类型转换。参数保真、不丢精度、不改字段顺序。
各接口类型策略:
- Chat/Embedding/Rerank(同协议):Smart Passthrough — 请求改写 model(统一 ID → 上游名),响应改写 model(上游名 → 统一 ID)
- Chat/Embedding/Rerank(跨协议):全量 decode → encode + modelOverride
- Models/ModelInfo:本地数据库聚合,不请求上游
- Passthrough(未知路径):原样透传,不改写 model
选择让 adapter 拥有完整协议知识(而非通用 json hack)的原因:
- 不同协议的 model 字段位置可能不同,adapter 按 InterfaceType 分派
- 请求和响应的 model 字段位置可能不同,拆分 RewriteRequestModelName/RewriteResponseModelName 各自独立实现
- adapter 内部实现
ExtractModelName和两个Rewrite*方法可共享同一份"model 在哪"的定位逻辑 - 所有流程复用
ExtractModelName,同协议额外复用RewriteRequestModelName+RewriteResponseModelName
Risks / Trade-offs
- [BREAKING CHANGE] 代理接口 model 字段格式变更,现有客户端必须适配 → 统一 ID 格式简单直观,服务尚未上线无旧客户端
- [联合唯一约束] 同一供应商下相同 model_name 不允许重复 → 这是正确的行为,语义上就不应该重复
- [model_name 含特殊字符] 上游模型名可能含
/(如 Azure deployment 路径)→ 解析用SplitN("/", 2)安全,管理接口用id定位不受影响,代理接口中统一 ID 出现在 JSON body 和 URL 路径中均安全 - [流式响应覆写] 同协议流式场景需逐 SSE chunk 调用 RewriteResponseModelName → 每个 chunk 多一次轻量 JSON 解析,用 json.RawMessage 保证开销极小
Open Questions
无。所有关键决策已在探索阶段确认。