GitHub user mochengqian edited a comment on the discussion: [AI] AI gateway 
milestones

** @zerui  让我学习pixiu竞品的优秀cluster设计.** 下面的你们看看合理、需要吗 @Alanxtl @AlexStocks 
**我的prompt:**“我的目标是深度研究 Apache dubbo-go-pixiu 的“集群模块”优化方向。请你不要只做泛泛竞品分析,而是以“如何让 
Kong / APISIX / Envoy AI Gateway / Higress / LiteLLM / Portkey / TrueFoundry 
等成熟 AI 智能网关的高频重度用户,看到 Pixiu 的集群模块后愿意称赞并迁移”为最终目标,进行系统性研究。”
**得出优化建议:** 把 Pixiu cluster 从"静态配置的 upstream 容器"升级成"可编程的多协议统一运行时"——让 AI 
provider、Dubbo service、K8s pod、MCP tool  这些异构后端都先编译成统一的 immutable snapshot + 
mutable state,然后用同一套 picker/scorer/balancer 做智能选址(cost-aware / quota-aware / 
failure-classified),最终实现"一次编写负载均衡逻辑,到处复用"。

# [RFC] 统一多协议 Cluster Runtime:从 Upstream 容器到可编程运行时

> 基于《Pixiu 集群模块深度研究报告》与 PR #932 现状,提出将 cluster 从"通用 upstream 
> 容器"演进为"多协议、多形态后端统一运行时"的完整技术路线。本 RFC 先在 Discussion #696 试水,社区有兴趣后提交为正式 issue。

> **评审更新(2026-06-07,@Alanxtl 三点反馈,已采纳):**
> 1. **两层分离 / sync.Map 取消。** 不再用 `Cluster.states sync.Map` 这个与 snapshot 
> 并行的独立结构(它看起来像在读路径上重新引入锁、且需要手动同步生命周期)。改为把 `stateByID 
> map[string]*EndpointState` **直接放进 immutable snapshot**,重建时按 endpoint ID 
> **继承指针**——和现在 `addressByID` 按引用共享、health 按 ID 继承是同一套手法。无并行结构、无新锁、热路径只多一次 
> atomic load,state GC 由可达性自动完成。
> 2. **加权 scorer 取消。** persona 调研结论:真实用户是自用的平台 / 自托管推理团队,不是做成本套利的二级分发商。`pkg/` 里 
> cost/quota/budget 零踪迹,#764 已把 provider 
> 业务逻辑明确踢出网关核心。因此**不内置任何加权打分模型**;选址保持二元(cooldown 就跳过);未来若做打分,persona B 的目标是 
> load/latency(现有 `LoadBalancerStrategy` 加一个策略即可),cost 模型一律做成**可插拔接口让 operator 
> 自己实现**。
> 3. **Multi-Kind 健康检查 = 接口 + 内置默认 + 允许 override(Envoy 模型)。** static 用现有 HTTP 
> probe、K8s 读 pod readiness、Dubbo 用心跳,都放在一个 operator 
> 可覆盖的默认实现后面。不是强制用户自己实现,也不是写死一种。(此前 open-question #5 现已定调。)

## 执行摘要(30 秒版)

**现状**:cluster 是静态配置的 upstream 容器,AI 失败状态(cooldown)活在 filter 的全局 map 里,picker 
看不到;Dubbo/K8s/MCP 各自独立实现,无法复用选址逻辑。

**目标**:把 cluster 升级成**统一运行时** —— 静态 cluster、Dubbo service cluster、Kubernetes 
service cluster、AI provider cluster、MCP service cluster,全部先编译成 immutable 
runtime snapshot,再由统一 selector/scorer/picker 执行选址。

**路径**:三层渐进演进(每层可独立验收、向后兼容)
1. **State 基底**(Phase 1)— per-endpoint 
`EndpointState`(cooldown/inflight/failure-class),解决 #696 的 key-level cooldown 问题
2. **Load-aware 选址 + 可插拔打分**(Phase 2)— load 字段(inflight/EWMA)进 state,load-aware 
LB 策略 + 可插拔 `Scorer` 接口进 picker;**不内置 cost/quota 加权模型**
3. **Multi-Kind 统一**(Phase 3)— `ClusterKind` 接口,Dubbo/K8s/MCP 各实现 
`CompileSnapshot`,picker 只看统一 snapshot

**关键设计点**:immutable snapshot(沿用 #932)+ per-endpoint state 指针**内嵌于 snapshot、按 ID 
跨重建继承**(atomic 原地更新)—— 解决"快变状态 vs 零分配读路径"的张力,且不引入并行结构或新锁。

---

## 动机:为什么要动 cluster core?

### 当前痛点(Discussion #696 + Issue #905)

1. **AI 失败状态不可见**  
   `sharedCooldownStore` 在 filter 
侧(`pkg/filter/llm/proxy/filter.go:138`),picker 看不到 cooldown。单个 API key 触发 
429,整个 endpoint 被 filter 循环跳过,但对 picker 来说 endpoint 仍"健康" —— 健康与可用脱节。

2. **二元健康无法表达失败语义**  
   `context_length_exceeded` 是请求/endpoint **不匹配**(该请求需要更大 context 模型),不是 
endpoint 故障 —— 正确反应是路由到大 context endpoint,而非 cooldown。Binary healthy/unhealthy 
表达不了这点。

3. **多协议后端各自为政**  
   静态 upstream(现有)、Dubbo service(规划中)、K8s service(规划中)、MCP tool(规划中)各自独立实现,无法共享 
LB/健康检查/选址逻辑。每加一种后端就复制一套代码。

### 为什么要统一?(报告的核心洞察)

**观察**:AI provider、Dubbo service、K8s pod、MCP tool —— 表面看完全不同,但**选址决策的输入是同构的**:
- 都有"成员列表"(endpoints / services / pods / tools)
- 都有"健康状态"(provider 429 / service down / pod terminating / tool unavailable)
- 都有"负载指标"(inflight / qps / cpu / concurrent calls)
- 都有"能力标签"(model supports / service version / pod labels / tool schema)

**结论**:如果把这些异构后端先**编译**成统一的 runtime view(immutable snapshot + mutable 
state),picker 就可以**一次编写、到处复用**。这是 Envoy `Host` 抽象、Istio `ServiceEntry` 统一的同一个思路。

**收益**:
- 新协议后端只需实现"config → snapshot 编译器",自动继承现有 LB/健康检查/metrics
- load-aware / cooldown-aware 选址,天然可用于 Dubbo(按并发/延迟)、K8s(按 pod 负载)
- 一套 benchmark/test 覆盖所有 cluster kind

---

## 现状分析(origin/develop as of 2026-06-03)

### Cluster Runtime(pkg/cluster/cluster.go)

```go
type Cluster struct {
    endpoints atomic.Pointer[EndpointSnapshot]  // Line 43
    // ...
}
```

- PR #932 已合并:`EndpointSnapshot` immutable,通过 `atomic.Pointer` 发布
- 成员/健康变化时 CAS 重建(`RefreshEndpointsFrom:95`, `UpdateEndpointHealth:109`)
- 预计算 `all` / `healthy` / `byID` / `byAddress` 索引 → 读路径 ~0 alloc

### AI Failure State(pkg/filter/llm/proxy/filter.go)

```go
var sharedCooldownStore = newCooldownStore()  // Line 138
```

- 进程级全局 map:`map[cooldownKey]cooldownEntry`,`sync.Mutex` 保护
- 容量上限 1024,达到 load factor 后触发 sweep
- Filter fallback 循环里检查 cooldown,picker **看不到**

### 问题

AI 失败状态变化频率(per-request)与 health 变化频率(health-check interval)相差 3 个数量级。如果把 
cooldown 塞进 immutable snapshot,每次失败都触发 O(N) 重建 + map 分配 → 毁掉 #932 的零分配读路径。

---

## 核心设计:两层分离 + 编译时抽象

### 设计张力的解法

**问题重述**:snapshot 不可变(#932 收益),但 AI 状态高频变(per-request)。

**解法**:分两层,不同变化频率用不同策略
1. **Immutable membership snapshot**(慢变)— 成员 + 健康 + 索引,仍用 #932 的 CAS 重建
2. **Per-endpoint `EndpointState`**(快变)— 通过**内嵌于 snapshot 的 `stateByID` 
索引**访问;重建时按 endpoint ID **继承 `*EndpointState` 指针**,字段用 atomic 原地更新

Snapshot 重建时,ID 不变的 endpoint **继承同一个 `EndpointState` 指针** —— 新 snapshot 复用老 
state,零重建。这正是现在 `withEndpointHealthForIDs` 按引用共享 
`addressByID`(`cluster.go:509`)、`endpointSnapshotHealth` 按 ID 
继承旧健康值(`cluster.go:307`)的同一套手法,只是多继承一个字段:state 指针。

```go
// Phase 1 sketch — not final API
type EndpointState struct {
    // Atomic fields — per-request update, no allocation
    cooldownUntilNano atomic.Int64
    lastFailureClass  atomic.Int32  // FailureClass enum
    // Phase 2 additions(后续,且只在需求驱动下):inflight / ewmaLatencyNs ...
    //(cost/quota 字段已从路线图移除,见 Phase 2 修订)
}

type EndpointSnapshot struct {
    // ... 现有 immutable 成员/健康索引 ...
    stateByID map[string]*EndpointState  // 指针随 snapshot 一起发布,按 ID 继承
}
```

Picker 读 snapshot 拿 healthy 列表(零分配),遍历时查 snapshot 自带的 `stateByID[ep.ID]` 拿 
cooldown(一次 atomic.Load)。

**为什么不用独立的 `Cluster.states sync.Map`(早期草稿)。** 那是一个与 snapshot 
并行、需要手动同步生命周期的结构,读起来像是在热路径上重新加了锁,还需要额外的 state GC 逻辑。把索引**内嵌进 snapshot** 
后,picker 在它本来就要做的那一次 `atomic.Pointer.Load()` 之后已经拿到一切——没有第二次查找、没有 `sync.Map` 
语义要推理;ID 没被新 snapshot 继承的 state 自然不可达、被 GC(同时解决了"state 何时清理"这个 open question)。

**为什么不直接在 snapshot 的 `*Endpoint` 上挂 `cooldownUntil`。** 那样要么每次 429 触发 O(N) 
snapshot 重建(毁掉零分配读路径),要么原地改 snapshot 持有的 endpoint(违反 `cluster.go:212-228` 的 
immutability 契约)。state 对象就是为了**同时**拿到不可变成员/健康(#932 收益)+ 实时失败状态。

**热读路径成本**:`atomic.Pointer.Load()`(pick 本来就做)→ 索引 `healthy` / 
`stateByID`(本来就做)→ 一次 `atomic.Int64.Load()`。**无新锁、无并行结构、无额外查找。** 
这正面回应"第二层依然要加锁"——根本没有第二层可锁:索引随 snapshot 一起发布,唯一的写是 atomic。

### 失败分类(Failure Classifier)

报告识别的核心洞察:**不是所有失败都该 cooldown endpoint**。

| FailureClass            | 触发条件                 | 反应                           
   | 示例场景                     |
|-------------------------|-------------------------|-----------------------------------|------------------------------|
| `RateLimited`           | HTTP 429                | cooldown,fallback 其他      
     | OpenAI QPM 限流              |
| `ServerError`           | 5xx                     | 短 cooldown,fallback       
      | Provider 临时故障            |
| `Timeout`               | 连接/响应超时           | cooldown,fallback               
 | 网络抖动                     |
| `ContextLengthExceeded` | 请求超模型 context 上限 | **不 cooldown**,路由到大 context | 
长对话超 gpt-3.5 16k         |
| `QuotaExhausted`        | 预算耗尽                | 长 cooldown 至窗口重置            | 
日配额用完                   |
| `ContentPolicyViolation`| 内容审核拒绝            | **不 cooldown**,返回用户         | 
触发 safety filter           |

`ContextLengthExceeded` 和 `ContentPolicyViolation` 是**请求属性**,不是 endpoint 故障 —— 
这正是二元健康表达不了、也是 #696 提的 key-level 问题的泛化。

**谁来分类?** Filter 看得到 HTTP 语义(status / body / timeout)→ filter 分类 → 发事件给 cluster 
→ cluster 更新 `EndpointState`。

```go
// Filter→Cluster 契约(Phase 1)
func (c *Cluster) RecordEndpointFailure(endpointID string, class FailureClass, 
now time.Time)
func (c *Cluster) RecordEndpointSuccess(endpointID string)
```

Cooldown duration policy 在 cluster 侧(从 `LLMMeta` 编译),不硬编码在 filter。

### Multi-Kind 抽象(ClusterKind 接口)

报告提出的终极形态:把 cluster 从"静态 upstream 容器"变成**可编程运行时**。

```go
// Phase 3 sketch — ClusterKind 接口
type ClusterKind interface {
    // 从用户 config 编译出 immutable runtime snapshot
    CompileSnapshot(config *ClusterConfig, previous *EndpointSnapshot) 
*EndpointSnapshot

    // Kind-specific 健康检查。返回 nil 表示"用内置默认(HTTP probe)",
    // 非 nil 表示该 kind override 默认实现。见下方"健康检查模型"。
    HealthChecker() HealthChecker // nil ⇒ 默认 HTTP probe

    // 获取 endpoint metadata(用于 semantic routing / capability match)
    GetMetadata(endpoint *Endpoint) map[string]string
}
```

**健康检查模型(接口 + 内置默认 + 允许 override,@Alanxtl point 3 定调)。** 采用 Envoy 那套,而不是二选一:
- **内置默认**:static 用现有 HTTP probe(即今天行为,operator 零改动)。
- **kind 可 override**:K8s 读 pod readiness、Dubbo 用心跳协议、MCP 用 server 探活——各 kind 
提供自己的 `HealthChecker`。
- **operator 可再覆盖**:config 显式指定时优先于 kind 默认。

即"不是必须用户自己实现,也不是我们写死一种"——默认开箱即用,特殊场景可逐层 override。(此前 open-question #5 现已按此定调。)

**内置 kind 实现**(逐步添加):
- `StaticKind` — 现有行为,从 `ClusterConfig.Endpoints` 构造 snapshot(健康检查走默认 HTTP 
probe)
- `AIProviderKind` — 从 `LLMMeta.Providers` 编译,注入 model/capability metadata
- `DubboServiceKind` — 连接 Dubbo registry,动态发现 + 版本路由(override:心跳健康检查)
- `KubernetesServiceKind` — watch K8s Endpoints API(override:Pod readiness → 
health)
- `MCPServiceKind` — MCP server discovery,tool schema → capability metadata

**Picker 统一性**:所有 kind 的 endpoint 都经过 `ClusterKind.CompileSnapshot` 标准化为同一 
`EndpointSnapshot` 结构 + 统一 `EndpointState`。Picker 不关心 kind —— RoundRobin / 
WeightedRandom / load-aware 策略(及 operator 注入的任何 `Scorer`)全部跨 kind 
复用。**注意**:Phase 3 不交付任何打分器,它只是让 Phase 2 已定的选址(healthy-skip / load-aware / 可插拔 
Scorer)跨 kind 通用。

**配置示例**(Phase 3,供参考):
```yaml
clusters:
  - name: ai-cluster
    kind: ai_provider  # 新字段
    llm_meta:
      providers: [...]
    
  - name: dubbo-user-service
    kind: dubbo_service
    dubbo_registry: "nacos://..."
    
  - name: k8s-backend
    kind: kubernetes_service
    namespace: default
    service: my-svc
```

---

## 三层演进路径(Phased Roadmap)

本 RFC 提出**完整愿景**,但执行按**三层渐进**,每层可独立验收、向后兼容。

### Phase 1: State 基底(解决 #696 痛点)

**目标**:AI 失败状态进 cluster,picker 能跳过 cooldown endpoint。

**交付**:
- `EndpointState` 结构 + snapshot 内嵌的 `stateByID` 索引(指针按 ID 继承,无 `sync.Map`)
- `FailureClass` 枚举(5 种)+ Filter→Cluster 事件契约
- Picker cooldown-skip 逻辑(候选过滤阶段)
- 移除 `sharedCooldownStore`(#939 先做注入,Phase 1 移除)

**依赖**:#958 (config/runtime decoupling) 的 ownership 模型,或明确假设其目标态。

**验收标准**:
- 多 key 隔离测试通过(同 endpoint 不同 credential,一个 429 不影响另一个)
- `ContextLengthExceeded` 不触发 cooldown,能路由到大 context endpoint
- `-race` 干净(concurrent pick + failure record + snapshot refresh)
- Benchmark:pick 仍 0-alloc,record failure O(1) 不触发 snapshot 重建

**预估工作量**:~3 PR,~800 行新增(含测试)。

**Phase 1 不包含**:scorer、semantic router、cost/quota 字段、多 kind。

### Phase 2: Load-aware 选址 + 可插拔打分(**不含内置加权 scorer**)

> **本节经评审大改(@Alanxtl point 2)。** 原稿把"cost/quota 加权 scorer 框架"当作 Phase 2 
> 的核心交付,persona 调研后**撤回**。理由见下。

**Persona 调研结论(谁在把 Pixiu 当 AI 网关用?)。** 从代码、config 面、文档、git 
历史看,真实用户是两类**自用**团队,都不需要内置加权 scorer:

- **A. 微服务 / Dubbo 平台团队**:自己持有 key、几个 provider,要统一 OpenAI 入口 + key 注入 + retry + 
fallback + health(`docs/ai/endpoint.md` 的 `deepseek-primary → openai-fallback` 
范例)。选址需求 = "用健康/可用的,挂了 fallback" = **healthy + cooldown-skip 已足够**。
- **B. 自托管推理团队**:vLLM + 
LMCache(`dgp.filter.ai.kvcache`、`configs/ai_kvcache_config.yaml`)。选址信号是 **cache 
亲和 / least-load / latency,不是 cost**(模型是自己的,没有按 token 的供应商价格可优化),且已有 
`llm_preferred_endpoint_id` hint 机制承接。

需要 cost/budget/margin 加权的"二级分发(reseller)"用户:`grep pkg/` 中 
cost/quota/budget/price **零踪迹**;#764 已明确把 provider 业务逻辑踢出网关侧;本帖路线图把"更智能的路由"排在 
Step3 完善的最后。**给一个我们没有、且被刻意排除的 persona 提前造加权模型,是反向的过度设计。**

**目标(修订后)**:补齐 load 类 substrate 字段;**不**内置任何加权打分;为未来真实需求留可插拔的缝。

**交付(修订后)**:
- `EndpointState` 仅补 **load-shaped** 字段(需求驱动):`inflight`、EWMA 
latency/TTFT/TPOT。**移除** `costPerToken` / `quotaRemaining` 等 cost 字段——它们没有用户。
- **load-aware LB 策略**(如 least-request / P2C):这不是"新框架",而是往现有 
`LoadBalancerStrategy` map(Rand/RoundRobin/RingHash/Maglev/WeightRandom)里**加一个 
entry**,服务 persona B。**demand-gated**:等真实需求出现再加。
- **可插拔 `Scorer` 接口(只交付缝,不交付默认实现)**:若某天出现 persona C,由其自行实现 cost/quota 
打分注入;Pixiu 核心**不**附带默认权重模型。`ship the seam, not the policy`。
- **Semantic Router**(可选,独立 filter,可 cache embedding):与本 substrate 解耦,单独评估。
- Quota 窗口管理:随 cost 一并移出 Phase 2;persona C 出现再议。

```go
// EndpointState Phase 2 增量(修订)——只有 load,没有 cost/quota
type EndpointState struct {
    // Phase 1
    cooldownUntilNano atomic.Int64
    lastFailureClass  atomic.Int32
    // Phase 2(demand-gated,load-shaped)
    inflight     atomic.Int64
    ewmaLatencyNs atomic.Int64
    ewmaTTFTNs    atomic.Int64
    ewmaTPOTNs    atomic.Int64
}

// 可插拔打分:核心只定义接口,不内置加权实现
type Scorer interface {
    Score(ep *model.Endpoint, st *EndpointState) float64
}
```

**依赖**:Phase 1 的 `EndpointState` 基础设施。

**验收标准(修订后)**:
- load-aware 策略在请求时长方差大的场景下,分布优于 round-robin(persona B 的真实痛点)。
- 自定义 `Scorer` 可注入并生效;**核心不附带任何默认加权 scorer**。
- Benchmark:load 字段 atomic 更新 < 10ns;不触发 snapshot 重建。

**预估工作量(修订后)**:显著缩小。load 字段 + 一个 LB 策略 + Scorer 接口 ≈ 2 PR;cost/quota/semantic 
全部移出,按需另开。

### Phase 3: Multi-Kind 统一(架构级演进)

**目标**:Dubbo / K8s / MCP / 静态 upstream 共用一套 snapshot + picker 基础设施。

**交付**:
- `ClusterKind` 接口定义(上文已给出)
- 重构现有 cluster 为 `StaticKind` 实现
- 新增 `DubboServiceKind` / `KubernetesServiceKind` / `MCPServiceKind`(逐个添加)
- ClusterConfig 新增 `kind` 字段,向后兼容(未指定时默认 `static`)

**技术细节**:
- **发现 → Snapshot 编译**:每个 kind 实现自己的 watch/poll 逻辑,调用 `kind.CompileSnapshot` 
产出标准 `EndpointSnapshot`
- **Metadata 注入**:Dubbo 的 version/group、K8s 的 pod labels、MCP 的 tool 
schema,全部编译到 `Endpoint.Metadata map[string]string`
- **健康检查复用**:默认用现有 HTTP probe,kind 可 override(如 K8s 直接读 Pod readiness)
- **Picker 零感知**:RoundRobin / WeightedRandom / load-aware 等 balancer 
不改一行代码,自动支持所有 kind

**Dubbo 示例**(报告附录 A):
```go
type DubboServiceKind struct {
    registryClient dubbo.Registry
}

func (k *DubboServiceKind) CompileSnapshot(config *ClusterConfig, prev 
*EndpointSnapshot) *EndpointSnapshot {
    instances := k.registryClient.Discover(config.DubboService, 
config.DubboVersion)
    endpoints := make([]*Endpoint, len(instances))
    for i, inst := range instances {
        endpoints[i] = &Endpoint{
            ID:      inst.ID,
            Address: inst.Host + ":" + inst.Port,
            Metadata: map[string]string{
                "dubbo.version": inst.Version,
                "dubbo.group":   inst.Group,
            },
        }
    }
    return newEndpointSnapshot(config, prev, endpoints, 
inheritRuntimeHealth=true)
}
```

**依赖**:Phase 1 + Phase 2 的 state/scorer 基础。

**验收标准**:
- 同一 route 规则混用 static cluster + dubbo cluster,透明 fallback
- K8s pod scale-up 自动发现,新 pod 进入 healthy 视图
- MCP tool discovery,按 schema capability 匹配路由
- 性能无回退:多 kind 开销 < 5% vs Phase 2

**预估工作量**:~8 PR,~2000 行新增(接口 + 3 个 kind 实现 + 集成测试)。

**Phase 3 是架构演进**,可能引发争议。建议等 Phase 1 落地、Phase 2 证明收益后再提。

---

## 技术决策点与备选方案

### A. 状态存储位置

**方案 1(推荐,修订后)**:`EndpointState` 指针**内嵌于 snapshot 的 `stateByID` 索引**,按 ID 跨重建继承
✅ 保持 snapshot immutable,核心结构干净
✅ 解决快变状态 vs 零分配读路径的张力
✅ 热路径只多一次 atomic load,**无并行结构、无新锁、无双查**
✅ state GC 由可达性自动完成(ID 未继承即不可达)

**方案 1-旧**:独立 `EndpointState`,cluster 持有 `sync.Map`(早期草稿,已弃用)
✅ snapshot 结构不动
❌ 与 snapshot 并行、生命周期需手动同步,读起来像在热路径重新加锁
❌ 需要额外 state GC 逻辑

**方案 2**:字段直接挂 `Endpoint`,随 snapshot 重建
✅ 实现最简单
❌ 每次失败触发 O(N) 重建 + map 分配,毁掉 #932 收益
❌ 原地改会违反 immutability 契约;LLM 字段污染 cluster core

**方案 3**:状态留 filter,只规范接口(#939 延伸)
✅ 改动最小
❌ Picker 仍看不到失败状态,无法做选址级 cooldown-skip
❌ 不支持 Phase 2/3 演进

**选择**:方案 1(snapshot 内嵌)。@Alanxtl 的"第二层还要加锁、很多余"正是弃用 1-旧、改用内嵌的理由——内嵌后根本没有第二层可锁。

### B. Filter↔Cluster 事件契约

**方案 1(推荐)**:Typed event with FailureClass enum  
```go
func (c *Cluster) RecordEndpointFailure(endpointID string, class FailureClass, 
now time.Time)
```
✅ 类型安全,易扩展新 class  
✅ Cooldown duration policy 在 cluster 侧,可配置  

**方案 2**:Generic callback with error  
```go
func (c *Cluster) RecordEndpointFailure(endpointID string, err error)
```
❌ Cluster 需 parse error 做分类,耦合 HTTP 语义  
❌ 难以表达"不 cooldown"语义(如 `ContextLengthExceeded`)  

**选择**:方案 1。分类在 filter(看得到 HTTP)、决策在 cluster(拥有状态),职责清晰。

### C. Picker 如何看到 State?

**方案 1**:Pre-filter stage,在 balancer 前过滤  
✅ 现有 balancer(RR/WR)零改动  
✅ 职责清晰:filter = 候选过滤,balancer = 从候选中选一个  
⚠️ 多一层抽象  

**方案 2**:在 balancer 内部检查 state  
✅ 少一层抽象  
❌ 每个 balancer 都得改(RoundRobin / WeightedRandom / LeastRequest...)  
❌ cooldown-skip 逻辑重复 N 份  

**选择**:方案 1。新增 `CandidateSelector` stage(或复用现有 selector if exists),输出 filtered 
candidates → balancer pick。

### D. Multi-Kind 何时引入?

**方案 1(保守)**:Phase 1/2 先落地,Phase 3 等需求明确再提  
✅ 降低初期争议,先证明 AI state 收益  
⚠️ 社区可能质疑"为 AI 改 core 值不值"  

**方案 2(激进)**:Phase 1 就引入 `ClusterKind` 接口,现有逻辑重构为 `StaticKind`  
✅ 一次性说清"为什么要动 core" —— 不只为 AI,是为统一多协议  
⚠️ 初期改动大,review 周期长  

**本 RFC 立场**:完整呈现三层愿景(方案 2 的透明度),但**执行按方案 1 分阶段** —— Phase 1 不引入 
`ClusterKind`,等 Phase 2 落地后再评估是否做 Phase 3。给社区完整 context,但不强求一次批准全部。

---

## 兼容性

**向后兼容(Phase 1)**:
- `LLMMeta` 继续解析,成为 cooldown duration policy 的编译输入
- `PickEndpoint` / `PickNextEndpoint` 签名不变
- Filter 外部行为不变(仍是 fallback loop,只是 cooldown 判断下沉了)
- `LLMUnhealthyKey` / `HealthyCheckTimeKey` 继续 deprecated,无新 writer

**配置演进(Phase 2/3)**:
- Phase 2:可选的 load-aware LB 策略名(如 `least_request`);cost/quota 字段**不引入**(无用户)。若 
persona C 出现,由其通过可插拔 `Scorer` 自带配置。
- Phase 3:`ClusterConfig` 新增 `kind` 字段,默认 `static`(现有配置无需改)

**移除 `sharedCooldownStore` 的时序**:
- #939 先做注入化(全局变量 → 可注入依赖)
- Phase 1 实现 cluster-side state 后,删除 store(filter 直接调 `cluster.RecordFailure`)

---

## 测试与验证

### Phase 1 测试计划

**功能测试**:
- 多 key 隔离:同 endpoint 不同 credential,一个 429 不影响另一个(#696 回归测试)
- 失败分类:`ContextLengthExceeded` 不 cooldown("路由到大 context"是后续 routing policy,不在 
Phase 1)
- State 生命周期:cooldown 设置 → snapshot refresh 保持 endpoint ID → state 
指针被继承、cooldown 仍在
- State GC:endpoint 从新 snapshot 移除 → 旧 state 指针不可达、被 GC(无需显式清理)

**并发测试**:
- Race detector clean:concurrent pick + `RecordFailure` + snapshot refresh
- Stress test:1k endpoints、10k req/s,验证内嵌 state 读写(atomic)不成瓶颈

**性能 Benchmark**(回归基线 vs #932):
- `BenchmarkPick_NoCooldown` — 健康路径,应保持 0-alloc
- `BenchmarkPick_WithCooldownSkip` — 50% endpoint in cooldown,验证过滤开销 < 50ns
- `BenchmarkRecordFailure` — O(1),0-alloc(只 atomic store)
- 关键断言:**recording failure 不触发 snapshot rebuild**

### Phase 2 测试计划(修订后:load-aware,不含 cost/quota)

**功能测试**:
- load-aware 策略:请求时长方差大时,分布优于 round-robin(persona B 痛点)
- 可插拔 `Scorer`:注入自定义打分器能改变选址;**核心不附带默认加权 scorer**

**性能 Benchmark**:
- load 字段 atomic 更新 < 10ns;EWMA 更新不触发 snapshot 重建
- 自定义 Scorer 调用开销由实现者负责,不在核心基线内

### Phase 3 测试计划

**集成测试**(每个新 kind):
- Dubbo:mock registry,service up/down → snapshot rebuild,version 路由
- K8s:fake clientset,pod scale → endpoints 变化,readiness → health
- MCP:mock server discovery,tool schema → capability metadata

**混合 cluster 测试**:
- 同一 route:primary = dubbo cluster,fallback = static cluster,验证透明 fallback
- Picker 复用:RR/WR balancer 零改动,自动支持所有 kind

**性能回归**:
- Multi-kind dispatch 开销 < 5% vs Phase 2(虚函数调用 + interface boxing)

---

## 可观测性

### Metrics(对应 Issue #944)

**Phase 1 新增指标**(注册到现有 OTel/Prometheus 路径):
- `cluster_snapshot_publish_total{cluster}` — snapshot 重建次数
- `cluster_snapshot_publish_duration_seconds{cluster}` — 重建耗时
- `cluster_endpoint_count{cluster,state=healthy|unhealthy|cooldown}` — 各状态 
endpoint 数
- `cluster_endpoint_failure_total{cluster,endpoint,class}` — 按失败类型计数
- `cluster_endpoint_cooldown_duration_seconds{cluster,endpoint}` — cooldown 
持续时长分布

**Phase 2 新增(修订后:load 类,不含 cost/quota)**:
- `cluster_endpoint_inflight{cluster,endpoint}` — 实时并发数
- `cluster_endpoint_ewma_latency_seconds{cluster,endpoint}` — EWMA 延迟
- `cluster_picker_score{cluster,endpoint}` — 仅当 operator 注入自定义 `Scorer` 
时由其上报(核心不内置)
- (cost/quota 指标随 cost 模型一并移除——无对应字段、无用户)

**Phase 3 新增**:
- `cluster_kind_compile_duration_seconds{cluster,kind}` — 各 kind 的 compile 耗时
- `cluster_discovery_endpoints_total{cluster,kind}` — 从注册中心发现的 endpoint 数

### Tracing

在 `PickEndpoint` / `RecordFailure` / `CompileSnapshot` 关键路径埋 span,关联 request 
trace。

### Debug Dump(对应 Issue #950)

```bash
curl localhost:8888/debug/cluster/my-cluster | jq .
```

输出:
- 当前 snapshot(all/healthy endpoint 列表 + metadata)
- 每个 endpoint 的 `EndpointState`(cooldown / failure-class / inflight / EWMA)
- 最近 100 条失败事件(时间戳 + failure class + endpoint ID)
- Picker 配置(balancer type;若注入了自定义 `Scorer` 则显示其名)

Phase 3 增加:
- `kind` 信息 + kind-specific debug(如 Dubbo 的 registry 连接状态)

---

## 依赖与时序

**前置依赖**:
- **#958 (config/runtime decoupling)** — `EndpointState` 的生命周期依赖 runtime 
cluster ownership 模型。Phase 1 必须等 #958 step 1-3 完成,或明确假设其目标态。
- **#939 (cooldown 注入)** — 把 `sharedCooldownStore` 从全局变量变成可注入依赖。Phase 1 的自然前置步骤。

**并行可做**:
- **#944 (snapshot publish metrics)** — 与 Phase 1 并行或作为 Phase 1 一部分,提供观测基础。
- **#943 (LRU eviction)** — 现有 cooldownStore 的优化,Phase 1 完成后可移除该 store。

**后续 follow-up**:
- Phase 2:load-aware LB 策略 + 可插拔 `Scorer` 接口(demand-gated)。cost/quota/semantic 
全部移出,仅在 persona C 真实出现时另开 issue。
- Phase 3 需要独立 RFC:`ClusterKind` 接口设计 + Dubbo/K8s registry 集成方案。

**时间线估算**(纯技术,不含 review 周期):
- Phase 1:3 周(设计 1w + 实现 1.5w + 测试/benchmark 0.5w)
- Phase 2:~2 周(load 字段 + 1 个 LB 策略 + Scorer 接口;cost/quota 已移出)
- Phase 3:8 周(接口设计 1w + StaticKind 重构 2w + 3 个新 kind 各 1.5w + 集成测试 1w)

---

## 风险与缓解

| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| #932 零分配读路径回退 | 性能下降 | 中 | Benchmark gate:任何回退 > 5% 的 PR 不合并 |
| 内嵌 state 的 atomic 读写在高并发下成本 | 高并发性能差 | 低 | Stress test 验证;state 仅 atomic,无锁竞争 
|
| Phase 3 scope 爆炸 | 开发周期失控 | 高 | 只实现 3 个 kind(Dubbo/K8s/MCP),其他 kind 按需添加 |
| 社区质疑"过度设计" | RFC 被拒 | 中 | Phase 1 先落地证明收益,Phase 3 可选(Non-goal 转 Future Work) |
| Filter↔Cluster 契约演进困难 | 破坏性变更 | 低 | Phase 1 定好接口,Phase 2 只加字段不改签名 |

---

## 开放问题(待社区讨论)

1. ~~**`EndpointState` 生命周期 GC 策略**~~ — **已定(采纳内嵌设计)。** state 指针随 snapshot 按 ID 
继承;未被新 snapshot 继承的 state 自然不可达、由 GC 回收,无需显式 sweep。

2. **Cooldown duration policy 放哪?**  
   - 选项 A:`ClusterConfig` 新增 `cooldown_policy` 字段  
   - 选项 B:复用 `LLMMeta.retry` 语义,从 retry 推导 cooldown  
   - 选项 C:独立 `RuntimePolicy` 对象(#958 的一部分)  
   倾向 C,但需与 #958 对齐。

3. **Semantic Router 放哪?**(仅当真有需求时)  
   - 选项 A:独立 filter(在 LLM proxy filter 之前)  
   - 选项 B:cluster 的 `CandidateSelector` stage  
   - 选项 C:picker 内部逻辑  
   Embedding 计算成本高,倾向 A(filter 可 cache),但 B 架构更统一。

4. **Phase 3 的 `ClusterKind` 是否需要生命周期钩子?**  
   如 `OnStart` / `OnStop`(启动 registry watch / 清理连接)。可能需要,但细节待 Phase 3 RFC。

5. ~~**多 cluster kind 的健康检查如何统一?**~~ — **已定(@Alanxtl point 3)。** 接口 + 内置默认(HTTP 
probe)+ 允许 kind/operator override(Envoy 模型)。不强制用户实现,也不写死一种。详见上文"健康检查模型"。

---

## 参考资料

- PR #932: Endpoint runtime view 演进(immutable snapshot 基础)
- Issue #905: Cluster 模块问题梳理
- Issue #939: Inject cooldownStore(移除全局变量)
- Issue #943: LRU eviction for cooldownStore
- Issue #944: Snapshot publish metrics
- Issue #958: Config/runtime decoupling(5 步骤规划)
- Discussion #696: AI gateway milestones(key-level cooldown 问题源头)
- 《Pixiu 集群模块深度研究报告》— 本 RFC 的设计来源

---

## 附录:报告关键设计的对应关系

| 报告章节 | RFC Phase | 状态 |
|----------|-----------|------|
| 2. Failure Classifier | Phase 1 | 完整覆盖 |
| 3. Immutable Snapshot + Mutable State 分离 | Phase 1 | 完整覆盖 |
| 4. AI Endpoint Fields (EWMA/load) | Phase 2 | 采纳 load 部分;cost/quota 移除(无用户) |
| 5. Scorer 框架 | Phase 2 | **改为可插拔接口**,核心不内置加权模型(persona 调研) |
| 6. ClusterKind 接口 | Phase 3 | 完整覆盖(健康检查 = 接口+默认+override) |
| 7. Dubbo/K8s/MCP Kind 实现 | Phase 3 | 示例给出,细节待 Phase 3 RFC |
| 附录 A: 性能优化建议 | 贯穿三层 | Benchmark gate + metrics 覆盖 |

报告的核心设计点本 RFC 都纳入了演进路径;其中 cost/quota scorer 经 #696 评审后**主动降级为可插拔接口**,理由见 Phase 
2 的 persona 调研。


GitHub link: 
https://github.com/apache/dubbo-go-pixiu/discussions/696#discussioncomment-17164117

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: 
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to