# 插件层逻辑分组与粘性路由设计

日期：2026-05-28

## 目标

在**不修改宿主源码**的前提下，由 relay-manager 插件层提供一层“逻辑分组 + 多 route 调度”能力，实现：

- 前端只看到一个逻辑分组
- 插件自动把请求路由到背后的多个真实线路
- 同一会话尽量保持 route 粘性，提高宿主与上游缓存命中
- 宿主继续只承载单线路 group，不承担多 route 聚合

这个设计对应的真实部署形态是：

```text
User
  -> relay-manager plugin router
  -> sub2api host (shadow group A / B / C)
  -> upstream route A / B / C
```

## 核心概念

### 1. 逻辑分组 `logical_group`

逻辑分组是插件层对用户暴露的“产品分组”，例如：

- `gpt-shared`
- `deepseek-shared`

它不是宿主里的真实 `group`，而是插件自己的聚合对象。

职责：

- 面向前端展示
- 绑定一组公开模型
- 绑定一组 route
- 承载 route policy、sticky policy、fallback policy

### 2. 路由线路 `route`

route 是逻辑分组下的一条具体出站线路，例如：

- `asxs`
- `codex2api`
- `official`

职责：

- 指向一个真实宿主 shadow group
- 声明支持哪些公开模型
- 提供优先级、权重、健康状态、熔断状态

### 3. 宿主影子分组 `shadow_group`

shadow group 是宿主里的真实 group，例如：

- `gpt-shared__asxs`
- `gpt-shared__codex2api`

职责：

- 承载单条 route 对应的账号池
- 继续使用宿主既有的 group 内 sticky/account scheduling
- 不再对用户直接暴露

## 数据结构

### A. 逻辑分组定义

建议插件层持久化表：`logical_groups`

```json
{
  "logical_group_id": "gpt-shared",
  "display_name": "GPT Shared",
  "status": "active",
  "description": "GPT 多线路逻辑分组",
  "public_models": [
    "gpt-5.4",
    "gpt-5.4-mini"
  ],
  "default_route_policy": "priority",
  "sticky_policy": {
    "mode": "conversation_preferred",
    "conversation_ttl_seconds": 7200,
    "user_model_ttl_seconds": 1800
  },
  "failover_policy": {
    "consecutive_retryable_failures": 2,
    "cooldown_seconds": 600
  },
  "created_at": "2026-05-28T00:00:00Z",
  "updated_at": "2026-05-28T00:00:00Z"
}
```

字段说明：

- `public_models`: 对用户暴露的公开模型
- `default_route_policy`: 第一版建议只支持 `priority`
- `sticky_policy`: route 级粘性配置
- `failover_policy`: 熔断与切换阈值

### B. route 定义

建议插件层持久化表：`logical_group_routes`

```json
{
  "route_id": "gpt-shared.asxs",
  "logical_group_id": "gpt-shared",
  "name": "asxs",
  "status": "active",
  "priority": 10,
  "weight": 100,
  "shadow_group_id": "gpt-shared__asxs",
  "shadow_group_host_id": "remote43-route-lab-18169",
  "supported_public_models": [
    "gpt-5.4",
    "gpt-5.4-mini"
  ],
  "upstream_base_url_hint": "https://api.asxs.top/v1",
  "retryable_error_classes": [
    "upstream_5xx",
    "upstream_429",
    "gateway_timeout"
  ],
  "non_retryable_error_classes": [
    "model_unsupported",
    "invalid_api_key",
    "group_disabled"
  ],
  "cooldown_until": null,
  "metadata": {}
}
```

字段说明：

- `priority`: 数值越小优先级越高
- `shadow_group_id`: 该 route 对应宿主真实 group
- `shadow_group_host_id`: 对应宿主实例
- `cooldown_until`: 熔断冷却截止时间

### C. 模型覆盖定义

第一版可以不单独拆表，直接使用 `route.supported_public_models`。  
如果后续要支持 route 级别模型差异，可以扩展成：

- `logical_group_route_models`

例如：

```json
{
  "route_id": "gpt-shared.codex2api",
  "public_model": "gpt-5.4",
  "shadow_model": "gpt-5.4",
  "enabled": true
}
```

第一版建议不预先复杂化，先假设：

- `public_model == shadow upstream model`
- 或 shadow model 已由宿主 provider/account mapping 解决

## 运行态上下文

插件层处理一次请求时，建议构造统一上下文：

```json
{
  "logical_group_id": "gpt-shared",
  "public_model": "gpt-5.4",
  "user_id": "u_123",
  "api_key_id": "k_456",
  "conversation_id": "conv_789",
  "session_id": "sess_789",
  "request_id": "req_abc",
  "sticky_key": "computed-later",
  "selected_route_id": "gpt-shared.asxs",
  "selected_shadow_group_id": "gpt-shared__asxs"
}
```

## 路由流程

### Phase 1：优先支持“同逻辑分组，多 route，不同公开模型名或相同模型名但明确 priority”

插件层一次请求的最小流程应为：

```text
1. 解析请求
2. 确定 logical_group_id
3. 确定 public_model
4. 生成 sticky key
5. 先查 sticky route
6. sticky route 可用则继续使用
7. 否则按 route policy 选择候选 route
8. 写入 sticky
9. 转发到对应 shadow group
10. 记录结果，必要时更新失败计数 / 冷却状态
```

### 详细步骤

#### 1. 解析用户请求

从请求中提取：

- 逻辑分组
- 公开模型名
- 用户标识
- conversation/session 标识

推荐优先级：

- `conversation_id`
- `session_id`
- 请求体 metadata 里的稳定用户标识
- API key / user id

#### 2. 加载逻辑分组配置

读取：

- `logical_groups`
- `logical_group_routes`

过滤：

- `logical_group.status == active`
- `route.status == active`
- 当前时间未落入 `cooldown_until`
- `public_model` 在 `supported_public_models` 内

#### 3. 生成 sticky key

按“强粘性优先”原则：

1. conversation 级
2. session 级
3. user+model 级
4. 稳定哈希兜底

### 推荐 key 生成规则

#### 3.1 conversation 级 sticky

当 `conversation_id` 存在时：

```text
lg:{logical_group_id}:m:{public_model}:conv:{conversation_id}
```

#### 3.2 session 级 sticky

当 `session_id` 存在时：

```text
lg:{logical_group_id}:m:{public_model}:sess:{session_id}
```

#### 3.3 user-model 级 sticky

当只有用户标识时：

```text
lg:{logical_group_id}:m:{public_model}:user:{user_or_api_key_id}
```

#### 3.4 hash bucket 兜底

完全无会话标识时：

```text
lg:{logical_group_id}:m:{public_model}:bucket:{stable_hash(user_or_ip_or_api_key)%128}
```

这不是强 session 粘性，只是为了避免每次随机抖动。

## Redis key 设计

### A. sticky route key

value 建议：

```json
{
  "route_id": "gpt-shared.asxs",
  "shadow_group_id": "gpt-shared__asxs",
  "public_model": "gpt-5.4",
  "bound_at": "2026-05-28T12:00:00Z",
  "expires_at": "2026-05-28T14:00:00Z",
  "last_ok_at": "2026-05-28T12:34:00Z",
  "fail_count": 0
}
```

TTL：

- conversation/session 级：默认 `7200s`
- user-model 级：默认 `1800s`
- bucket 级：默认 `600s`

### B. route failure counter

key：

```text
routefail:{route_id}
```

value：

```json
{
  "consecutive_retryable_failures": 1,
  "last_failure_at": "2026-05-28T12:35:00Z",
  "last_error_class": "upstream_5xx"
}
```

作用：

- 辅助 route 熔断
- 与 sticky key 解耦

### C. route cooldown key

key：

```text
routecool:{route_id}
```

value：

```json
{
  "cooldown_until": "2026-05-28T12:45:00Z",
  "reason": "consecutive_retryable_failures"
}
```

作用：

- route 进入冷却期后，不参与新 sticky 选择
- 已命中该 route 的旧 sticky 也要在下次请求时重新评估

### D. 可选：逻辑分组模型首选 route cache

key：

```text
routepref:{logical_group_id}:{public_model}
```

value：

```json
{
  "route_id": "gpt-shared.asxs",
  "updated_at": "2026-05-28T12:00:00Z"
}
```

第一版可直接读 DB，不一定要上 Redis。  
只有当路由配置频繁热更新、且请求量明显上来后，才值得加这层缓存。

## 选路算法

### 第一版推荐：`priority + sticky + failover`

不要一开始做复杂权重、实时负载、成本优化。  
第一版最稳的算法：

1. 先查 sticky route
2. 如果 sticky route 当前健康且支持该模型，则继续使用
3. 否则按 `priority` 从小到大选第一个健康 route
4. 成功后写新的 sticky
5. 失败时按错误类别决定是否切换

### 伪代码

```text
resolveRoute(ctx):
  candidates = active routes for logical_group + public_model
  sticky = redis.get(sticky_key)

  if sticky exists:
    route = find route by sticky.route_id
    if route is healthy and not cooling down:
      return route

  sort candidates by priority asc
  for route in candidates:
    if route healthy and not cooling down:
      redis.set(sticky_key, route)
      return route

  return no_route_available
```

## 错误分类与 failover

### A. 可重试失败 `retryable`

建议包括：

- 宿主 `502/503/504`
- upstream `5xx`
- upstream timeout
- upstream `429`
- route host temporarily unavailable

处理策略：

- 单次失败先累加 `fail_count`
- 达到阈值后触发 route fallback
- 对旧 route 进入 `cooldown`

### B. 不可重试失败 `non-retryable`

建议包括：

- invalid api key
- model unsupported
- group disabled
- account disabled
- provider misconfigured

处理策略：

- 当前 route 立即标记不可用于该模型
- 直接尝试下一个 route
- 如果没有其他 route，则返回明确错误

### C. fallback 规则

默认建议：

- `consecutive_retryable_failures >= 2` 时切 route
- `cooldown = 600s`

这组值保守、简单，足够第一版使用。

## 粘性策略

### 1. 为什么插件层必须自己做 sticky

因为宿主当前 sticky 是按真实 `group_id` 做的，而不是按逻辑分组。  
如果插件层不先把会话稳定送到同一个 shadow group：

- 宿主会在不同 shadow group 之间重新选账号
- 上游缓存命中会下降
- 会话体验会抖动

所以正确顺序是：

1. 插件层先做 route sticky
2. 宿主层再做 shadow group 内 account sticky

### 2. TTL 建议

默认建议：

- conversation sticky：`2h`
- session sticky：`2h`
- user-model sticky：`30m`
- bucket sticky：`10m`

原则：

- 长会话优先稳定
- 无状态请求尽量降低长期粘死风险

### 3. 何时刷新 sticky TTL

只在这些情况下刷新：

- 本次请求成功
- 本次 route 未进入 cooldown

不要在失败请求上无脑刷新 TTL，否则坏 route 会被粘住。

## 转发行为

插件在选定 route 后，应把请求转发到：

- 指定宿主实例
- 指定 shadow group 对应的用户入口/API key

第一版建议保持简单：

- 一个 route 对应一个宿主 shadow group
- 一个 shadow group 对应一组独立宿主 API key / group token / user key

插件需要做的是：

- 保持原始请求体
- 保留 conversation/session 标识
- 可附加少量内部调试头，例如：
  - `X-Relay-Logical-Group`
  - `X-Relay-Route-ID`
  - `X-Relay-Shadow-Group`

这些头只用于内部审计，不暴露给终端用户。

## 最小观测字段

插件层至少要记录：

- `request_id`
- `logical_group_id`
- `public_model`
- `selected_route_id`
- `selected_shadow_group_id`
- `sticky_key_type`
- `sticky_hit`
- `fallback_used`
- `error_class`
- `upstream_status`
- `latency_ms`

这样以后排查“为什么这次走了 codex2api 而不是 asxs”时才有证据。

## 第一版推荐实现边界

第一版只做这些，避免过度设计：

- 逻辑分组
- route 列表
- priority 选路
- Redis sticky
- Redis cooldown
- retryable / non-retryable 分类
- 转发到 shadow group

第一版不要做：

- 动态权重学习
- 实时成本最优
- 自动 A/B
- 跨 route token 级缓存共享
- 复杂多臂老虎机

## 以 asxs + codex2api 为例

### 逻辑分组

```json
{
  "logical_group_id": "gpt-shared",
  "display_name": "GPT Shared",
  "public_models": ["gpt-5.4", "gpt-5.4-mini"]
}
```

### routes

```json
[
  {
    "route_id": "gpt-shared.asxs",
    "priority": 10,
    "shadow_group_id": "gpt-shared__asxs",
    "supported_public_models": ["gpt-5.4", "gpt-5.4-mini"]
  },
  {
    "route_id": "gpt-shared.codex2api",
    "priority": 20,
    "shadow_group_id": "gpt-shared__codex2api",
    "supported_public_models": ["gpt-5.4", "gpt-5.4-mini"]
  }
]
```

效果：

- 默认优先 `asxs`
- `asxs` 故障时切 `codex2api`
- 同一会话尽量持续命中第一次成功选中的 route

## 一句话结论

这套设计的本质是：

- 对外暴露一个 `logical_group`
- 对内维护多个 `route -> shadow_group`
- 由插件层先做 **route sticky**
- 再由宿主在 shadow group 内做 **account sticky**

这样才能在不修改宿主源码的前提下，尽量接近“一个分组、多 URL、强粘性、高缓存命中”的目标效果。
