# 块轮询(sticky block round-robin)

> 状态:设计定稿 + 已实现。目标:保留 round-robin 的**多账号均衡**,
> 只把轮转的**触发方式**从「每请求」改成「满意才走」,从而大幅降低
> prompt cache miss。
>
> **2026-09-09 后续:round-robin 已升级为前缀亲和**(§8 天花板落地,
> 见文末 §9)。块轮转降级为**回退路径**——亲和拿不到 `messages`
> (算不出指纹)时才走;块统计(`BLOCK_MIN/BLOCK_HIT_RATIO/BLOCK_MAX`)
> 保留,从「驱动轮转」改为「判降权」(§9.3)。

## 1. 问题:每请求轮转把缓存打碎

上游 prompt cache 按 **API key(账号)** 分命名空间。会话前缀是同一段,
被轮转切到 N 个号上,就等于在 N 个互不相干的缓存命名空间里各写一份。

实测(2026-09-09,`usage.json`,codebuddy,`round-robin`,8 个健康号):

| 指标 | 值 |
|---|---|
| 会话 token 命中率 | **71%~75%** |
| `cached` 分布 | **双峰**:非 0 即 ~98%;miss 即 `cached = 0` |
| miss 时 `prompt` | **全量**(约 190k),不是部分失效 |
| 同会话相邻请求间隔 T | **中位 8s**(n=315) |

双峰说明一件事:不是「前缀部分匹配」,而是**这个号手里有没有本会话的缓存**
——全有或全无。所以 miss 的代价是**整段前缀重算**。

### 为什么当前 N=1 是最坏档

8 个号每请求轮转 → 同一号的复用间隔 = `K × T = 8 × 8s = 64s`。
这 64s 是否还在上游缓存 TTL 内,**完全未知**。实测命中 79%(请求数)/ 71%(token)
说明大致踩在 TTL 边缘。

即:现状把命中率**100% 押在了一个未知的外部参数上**。

## 2. 方案:轮转单位 = 块,不是请求

```
pick():
  留守 = 健康 AND 块内计数 < N_max AND (块内计数 < N OR 块内命中率 < M)
  留守 → 返回当前号
  否则 → 游标前进,切下一个号
  故障(rate_limit / quota / session_dead) → 立即切,不等块满(已有)
```

**均衡没变**:块大小有界([N, N_max]) → 长期看各号请求数仍是 1:1。
变的只是轮转的时间尺度:从 8s 拉到 N×64s。

### 保证下限,不依赖 TTL

块内第 2..N 次请求距上次仅 **8s**,8 秒内的缓存**不可能**被驱逐。
所以块内命中率有个**与 TTL 无关**的下限:

| 块内请求数 k | 块内命中下限 |
|---|---|
| 1(现状) | **0%**(全押 TTL) |
| 4 | 75% |
| 8 | 87.5% |
| 16 | 93.75% |

> 这是**下限**。TTL 宽裕时实际更高。

## 2.1 实测结论(2026-09-09,块轮询首日):多会话打架才是主因

上线后两组数据对照:

| 时段 | 场景 | 命中 |
|---|---|---|
| 11:59–12:03 | 3 个会话并行走 `money`(cb-2/cb-5/cb-3 交替) | **3.8%** |
| 12:03 之后 | 只剩 1 会话独占(cb-3 单号) | **82.5%** |

**账号没坏,是并发会话把缓存挤爆了。** prompt cache 是
**per-account × per-conversation** 命名空间;多会话各自 18 万的大前缀,
被块轮询聚到同一号连续服务,在同一账号缓存里互相驱逐 → 轮流全 miss。

这解释了:块轮询在**单会话**下能到 82%(比旧 71% 好),**多会话并发下反而更差**
(3.8%,因为把打架的请求聚到单号密集互踩)。

**根因不是轮询公式,是缺少会话隔离。** 真正的破法是 **per-session 亲和**
(见 §8 天花板),把每个会话绑定到不同账号,让前缀各自有独立缓存空间。
块轮询只能保证单会话内的块，保证不了跨会话的前缀不被别的会话清掉。

## 2.2 参数语义(用户拍板:单账号内 OR)

真实场景是**单对话长跑**(一个会话几千步),不是多会话并发。痛点是:
同一个会话在 8 个号之间跳,每跳一次前缀全重算。亲和解多会话打架,
**解不了单会话换号**。回到块轮询本身。

```
pick():
  留守 = 健康 AND 计数 < N_max AND (计数 < N OR 命中率 < M)   ← OR
  留守 → 返回当前号
  否则 → 游标前进,切下一个号
  故障(rate_limit / quota / session_dead) → 立即切,不等块满(已有)
```

单一账号内部:
- 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
- 命中率**没上去**(冷启动/短块)→ 留守继续热(不急着跳)
- 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)

参数:
| 参数 | 值 | 依据 |
|---|---|---|
| `M`(BLOCK_HIT_RATIO) | 0.85 | 实测 hit 样本 98~99.7%,85% 是"确实热了"的宽松判据 |
| `N`(BLOCK_MIN) | 16 | 最短驻留,防冷启动误切。块越大越少付块间驱逐,但均衡粒度越粗 |
| `N_max`(BLOCK_MAX) | 48 | 3×N:命中率一直上不去时也要让位,防死守 |

> ⚠️ `N` 的真实成本受 TTL 约束:这块该号空闲期间它守的前缀若被上游
> 驱逐,下轮回来仍是 cold miss。TTL 未知,16 是**保守起步值**,跑一轮
> usage.json 按 uid 下钻看块内命中率再调。

## 3. 换号:命中率达标 / 份额用够,都不是"命中率低"

**换号 = 付费**——下一个号要重算这段前缀。所以换号只能由"这个号已经把
价值榨干"触发:

```
换号 = (计数 >= N OR 命中率 >= M)   OR   计数 >= N_max   OR   故障
       └── 命中达标(热了)或份额用够 ──┘   └─ 兜底 ─┘
```

> 注意:「命中率 >= M 就换」是 OR 语义下的让位规则。**正反馈死锁不复存在**:
> 换号由 `计数>=N` 或 `命中率>=M` 触发,都不是「命中率低」触发的。
> (若误把「命中率低」当切号条件,会切→cold miss→命中率 0%→再切→…锁死 0%,见 §5。)

### 命中率在 OR 语义下的角色

它是**留守条件**、不是切号执行器:
- `命中率 < M` → 留守这个号(继续热),不逃
- `命中率 >= M`(缓存热)→ 放行,让位给下一个号(均衡)
- 冷启动(cold miss)无害:第一个请求命中率 0% → 留守,继续热身

### M 与 N 的取舍(单账号)

`N`(BLOCK_MIN)= 最短驻留,防冷启动误切。热会话命中率很快爬到 M,
所以一个号实际服务约 `min(N, 达到M所需的次数)` 就换:
- N 越小 → 换号越频繁 → 均衡粒度细,但块间驱逐 miss 多
- N 越大 → 一块守得久 → 少付驱逐,但均衡粒度粗、冷号占份额久
| 90% | 10 |
| 95% | 20 |

实测 hit 样本本身 98~99.7%,故 M=85% 是「确实热了」的宽松判据。
**M 与 N 只需定一个**,另一个是它的函数;取 `N = ceil(1/(1-M))`。

## 4. 两个必须补的洞

### 4.1 N_max 绝对兜底(防死锁)

若某号缓存就是热不起来(上游不支持 / 容量被别的会话挤爆),
命中率**永远 < M** → 永远留守 → **整个会话锁死在这一个号上**,均衡归零。

`N_max` 保证无论命中率如何,到点必走。默认 `4 × N`。

### 4.2 留守到 N_max 仍不达标 → 降权

兜底切走后若不处理,下一轮它又会来占一个完整 N_max 块。缓存无效的号
反复拿满额份额,会把均衡拖成「按号均分」而非「按**有效**号均分」。

补:留守 `N_max` 次命中率仍 < M → 标记该号**缓存无效** → 后续轮转跳过。
冷却一段时间后可恢复(上游容量/策略会变,不做永久判决)。

> 这是**账号质量判断**,不是每次请求的路由决策,不进 `pick()` 热路径。

## 5. 参数

| 参数 | 默认 | 说明 |
|---|---|---|
| `N` | 8 | 最小块:达到才考虑切 |
| `M` | 0.85 | 切号门槛:块内命中率 >= 此值才允许因「满意」而切 |
| `N_max` | 32 | 绝对兜底,防死锁 |
| 降权冷却 | 10 min | 判定缓存无效后的跳过时长 |

`N = ceil(1/(1-M))`:M=0.85 → N=7,取 8 留余量。

## 6. 状态与边界

- **块状态按 (supplier, model)** 记**,账号池本来就是这个粒度
  (冷却键同理),不新增维度。
- **失败请求不计入块统计**:失败没产生缓存,计入会污染命中率。
- **冷启动无害**:头两次 miss → 命中率 0%/50% → 反正 < M → 留守,
  而这正是期望行为。噪声只会让你多待,不会让你错切。
- **游标跨会话持久**:`rrCursor` 是实例字段(池随进程存活),新会话
  不会从首号重新开始。否则短会话全压 `poolOrder[0]`,均衡散掉。
- **并发会话共享块**:多会话交错时,块按「(模型) 累计请求数」推进,
  不按单会话。这会让单会话的连续请求被别的会话插队,实际块内命中
  低于理论值——**已知天花板**,根治需 per-session 亲和(见 §8)。

## 7. 可观测性前提

`UsageRecord` **无 `uid` 字段**,当前无法归因「哪个号命中差」,
任何调参都是盲调。已补 `uid` 以按号下钻。

验收指标:`usage.json` 的 `cached/prompt`,按 `uid` 分组。
- 健康:>=95% 且 miss 稀疏(只在冷启动/切号)
- 病态:miss 均匀分布(= 每请求切号)

## 8. 天花板(ponytail)

- **per-session 亲和——真正的根治**。实测已证明主因是并发会话打架
  (多会话大前缀在同一号缓存里互相驱逐,§2.1)。块轮询只治单会话内的块,
  **治不了跨会话驱逐**。需 `ChatRequest` 透传会话标识,或用前缀指纹
  `hash(messages 前缀)` 做一致哈希(闭环在 router 内,不需 DSH 改契约):
  每个会话绑定不同账号,前缀各有独立缓存空间,互不清。块轮询是它的低风险
  前置,二者可叠加(亲和负责选号、块负责驻留)。
- **积分感知换挡**:`credits` 已持久化,可做「积分跌破阈值才换挡」。
  但 `credits` 只存最后一次值不带时间戳,需先确认 `status()` 刷新频率,
  否则触发失真。**「积分归零」这一档已于 2026-10-14 落地**(见 §12),
  「跌破阈值(还有余额但快用完)」这一档仍未启用。
- **TTL 仍未知**:87.5% 是保证下限,实际可能更高。要精确 N 需反推 TTL
  (同号相邻请求间隔 vs 命中率拟合驱逐拐点)。

## 9. 前缀亲和(2026-09-09 落地,round-robin 的新语义)

### 9.1 机制

```
pick(round-robin):
  fp = hash(messages 前 2 条: role + content 前 512 字符)   ← src/router/prefix-affinity.ts
  绑定表[model][fp] 存在且该号健康 → 返回它                  ← 亲和:同会话永远同号
  否则 → 游标分一个号 + 记绑定(fp → uid)
  块统计(样本按 (model, uid) 跨驻留累计)只用来判降权,不再驱动轮转
  fp 算不出(无 messages/解析失败)→ 回退块轮转(§2 的旧行为)
```

- **会话标识 = 前缀指纹**:取前 **2** 条(system + 首条用户消息)是刻意的
  ——这两个槽位在会话生命周期里永不变化(新消息只往后追加),指纹从第 1 个
  请求起就稳定。取 3 条会在首条 assistant 回复落进槽位 2 时变一次指纹 →
  会话开局多付一次全量重算。「system + 首条消息逐字相同」的会话,缓存前缀
  本来就相同,共享一个号是收益不是冲突。
- **新会话按游标分发** → 不同会话天然散到不同号,per-account 缓存空间互不
  清(§2.1 的打架根治)。
- **绑定失效即重绑**:绑定的号冷却/降权/被禁 → 会话让位换健康号,并更新绑定。

### 9.2 为什么亲和负责选号、块不再负责轮转

亲和本身已保证驻留(同会话永远粘同号),块轮转若继续驱动换号,会把会话
踢出已热的前缀——两者叠加只会互相拆台。块统计保留的真实价值是**号的缓存
质量判定**:某号累计 `BLOCK_MAX`(48) 次命中率仍 < `M`(0.85) → 判缓存
无效 → 降权 10 分钟(可恢复),亲和与回退路径都跳过它。

样本从「按驻留记」改为「按 (model, uid) 跨驻留累计」:亲和开启后多个会话
各绑各的号交错服务,「驻留」概念只在回退路径存在,按驻留记会漏掉大部分
真实反馈。回退路径换驻留时清一次样本(保持旧块语义:新驻留 = 新观察窗口)。

### 9.3 状态

| 项 | 结果 |
|---|---|
| 实现 | `src/router/prefix-affinity.ts`(指纹)、`account-pool.ts`(亲和/LRU/样本)、`index.ts`(透传 messages) |
| 契约 | **不变**——指纹在核心内闭环,插件与 DSH 均无感知 |
| 测试 | 191/191(含 9 个新亲和用例) |
| 天花板 | 绑定表 LRU 封顶 512 条(防指纹无限增长);无状态短请求前 2 条全同 → 退化为同号,行为等同 fallback,可接受 |

ponytail:会话标识目前靠前缀指纹推断,真要百分百准得等 DSH 在 `ChatRequest`
透传会话 id(需改契约,不做);指纹对「首条消息就分叉」的客户端(极少见)
会散得比会话更细,退化为均衡分发,无害。

## 10. 统一模型:会话亲和选号(2026-09-23 落地,废除策略旋钮)

### 10.0 结论先行:没有「策略」

`fallback`(永远取健康池第一个)与 `round-robin`(轮转)**都是没有会话身份
时代的产物**。有了会话亲和后,二者都错:

- `fallback` 让**所有**会话挤同一个号 → team/多会话下必互踩(§2.1 实测 3.8%)。
- `round-robin` 的"轮转"语义与亲和"同会话永不换号"直接冲突。

**正确模型只有一条,无需旋钮:**

```
选号 = f(会话身份, 该 model 下的健康号集合)
  1. 会话身份 → 亲和键;该键绑定的号仍健康 → 用它
  2. 无绑定(新会话)→ 从健康池挑一个(避开降权号),记下绑定
  3. 无身份(指纹算不出)→ 块轮询兜底(§2),这不是"策略",是无身份时的替身
  4. 该 model 下全不健康 → 交给组合的降级链,换下一个 model(§10.2)

  号数 ≥ 并发会话数 → 每个会话独占一个号,缓存空间互不驱逐
  号数 < 并发会话数 → 必然有会话共用号 → 共用者互踩,不可消除(见 10.3 天花板)
```

### 10.1 关键认知边界:会话 id = 选号稳定性,不是缓存隔离

**必须分清**,否则会做出错误期望:

| 能力 | 会话 id 能带来吗 |
|---|---|
| 选号时认出"这是哪个会话",避免误绑 | ✅ 能 |
| 保证同一会话永远绑同一个号(不漂移) | ✅ 能 |
| **同一个号上,不同会话的缓存各占一块、互不驱逐** | ❌ **不能** |

上游 prompt cache 是 `per-account × per-conversation` 命名空间,**同一个号
上的缓存空间是共享的、会被互相驱逐的**。session id 只是"货物标签",帮上游
知道哪批是哪批;**仓库满了照样扔货**,不会因为标签不同就给每批货独立仓位。

证据:§2.1 实测那 3 个并发会话本就走不同的对话内容(等价于不同 id 语义),
照样只命中 3.8% —— **驱逐是账号侧物理限制,不归 id 管**。

> 所以「号不够就共用号 + 靠会话 id 分开缓存」**前半成立(选得准)、
> 后半不成立(缓存仍互踩)**。

### 10.2 组合 = 降级链(不是轮转)

组合(一组模型)的语义统一为:**从第一个模型起顺序尝试,谁先服务成功就
停**。原 `round-robin` 组合(换模型轮转)**删除** —— 因为换模型 = 换供应商
= 整段前缀缓存作废,与亲和的价值直接冲突。

```
组合选模型: start = 0 恒定(删掉 rrCursors 游标与 strategy 字段)
  对 combo.models[i] 依次:
    chatWithModel(该模型的降级链) → 成功即停
    瞬时故障 → 等 TRANSIENT_SETTLE_MS 再试下一个(§已有)
  全失败 → 503 combo: all models unavailable
```

选号(§10.0)与选模型(本节)是**正交的两条轴**:前者在"单个 model 的账号池"
内做亲和,后者在"多个 model"间做降级。二者的共同点:**都只按"身份 + 健康性"
决策,不按"轮转"**。

### 10.3 天花板(ponytail)

1. **号数 < 并发会话数时必然互踩** —— 物理限制(缓存空间有限),只能
   **加号**或**限制 team 并发成员数 ≤ 号数**,任何选号手法都消除不了。
2. ~~**会话身份目前靠前缀指纹推断**~~ —— **2026-09-23 已落地真会话 id**(见 §11):
   宿主 `GenerateOptions.sessionId` → adapter 转成 `x-dsh-router-session` 头 →
   核心按会话亲和。指纹降为**无会话身份时的兜底**(外部 OpenAI 客户端)。
3. **上游 TTL 仍未知**(§8):长会话若间隔超过 TTL,miss 是上游行为,
   不误判成选号问题。

### 10.4 废除的旧配置

| 旧字段 | 处置 |
|---|---|
| `supplier-config.json` 的 `poolStrategy` | **静默忽略**:读盘不解析,写盘不产出;旧文件不报错,下次保存自动瘦身 |
| `combos.json` 的 `strategy` | 同上 |
| `GET/POST /suppliers/:id/pool/strategy` 路由 | 删除 |
| 面板「连接池策略」「组合策略」下拉 | 删除(选项无意义则不该占位) |

### 10.5 为什么这不是"回退/轮询"的合并,而是概念删除

旧的两策略试图用**一个旋钮**回答两个不同的问题("要不要均衡" vs "要不要
粘缓存"),而这两个问题在亲和下**同时被回答了**:亲和天然均衡(新会话按
游标分发)+ 天然粘缓存(同会话固定号)。旋钮消失不是"合并了两个选项",
是**承认这个问题本身提错了** —— 不存在"要不要均衡"的选择,只有"按身份
选号"这一个正确答案。

## 11. Agent Team 支持(2026-09-23 落地)

DSH 0.1.7 的**智能体团队**(agent team)把一个任务拆给多个成员,**每个成员
是一个独立会话**(TeamId = Lead 的 SessionId;成员各自独立 `sessionId`)。
这正好把 §10 的"多并发会话"从理论变成日常 —— 一轮 team 测试就是 3 个会话
交错发请求。本节记录为此做的三件事。

### 11.1 真会话 id 通道(替代前缀指纹)

宿主 `dsh-llm` 的 `GenerateOptions.sessionId` 是**权威会话身份**。链路:

```
dsh-llm GenerateOptions.sessionId
  → RouterAdapter 发请求时带 `x-dsh-router-session: <sessionId>`
  → 核心 /v1 读进 ChatRequest.session
  → 选号: sessionFingerprint(session, messages) = session!=空 ? `sid:<session>` : 前缀指纹
  → 落账: UsageRecord.session
  → 面板: /router/api/last-hit?session=<id> 按当前会话取「最近命中」
```

要点:
- **有会话 id 时以它为主键**("sid:xxx"),精确、零撞车;指纹只在**拿不到
  会话 id 时**(外部 OpenAI 客户端)兜底。`sessionFingerprint` 就这一行分叉。
- 会话 id 是**选号稳定性**凭证,**不是缓存隔离**承诺 —— 见 §10.1,别混淆。

### 11.2 fresh 会话优先「铺开」到空闲号

三个 fresh 成员交替到达时,若只按全局游标分发,会**回绕撞号**(实测:
Lead 与 T1 撞同一个号,T2 独占)。改为**占用感知分配**(`allocSession`):

```
新会话分配:
  1. 先算「本 model 下没有被其它会话绑定的空闲号」(freeUids)
  2. 空闲号非空 → 从空闲号里按游标挑 → 各会话尽量独占一个号
  3. 空闲号用尽 → 才回绕到已占用的号复用(复用无害,见 §11.3)
```

"占用"= 该 model 的亲和绑定表里,**其它**指纹当前指向的 uid;同一会话自己
的旧绑定不算占用(它改绑不冲突)。号数 ≥ 会话数 → 每个会话独占;号数 <
会话数 → 必然复用(物理限制,§10.3)。

### 11.3 缓存质量样本按会话隔离(消除「降权串」)

**症状**(实测):team 3 个会话全落到同一个号,且该号看起来"缓存坏了"。

**根因**:`samples`(缓存质量样本)与 `demoted`(降权)的键原为
`(supplier, model, uid)`,**没有会话维度**。多会话共用同号时,各自前缀在
该号缓存里互相驱逐 → 命中率被**混在一起算** → 跌破阈值 → 误判"这个号缓存
坏了" → 降权 → 所有会话被挤到剩余号 → 那个号又被同样机制打低 → **降权串**。

**修法**:键改为 `(supplier, model, uid[, session])`(`sampleKey`)。有会话
身份时**按会话隔离统计**,降权只反映「这个会话在这个号上真的反复不命中」,
不因别的会话共用同号而被牵连。无会话身份(外部客户端走无指纹路径)保持
`(model, uid)` 原语义。

回归测试:`account-pool.test.ts` 的「多会话共用同号互踩 → 不误伤降权」——
两会话各在该号服务 24 次、命中率 50%,旧逻辑立刻误降权,新逻辑不降;
同时锁死"单会话自己真攒满 48 次全 miss 仍会被降权"(隔离 ≠ 免疫)。

### 11.4 天花板(ponytail)

- **隔离让降权更迟钝**:会话越分散,单个会话攒满样本越慢 → 降权触发越晚。
  这是**想要的**(宁可慢也不要误伤)。要更快感知真坏号,调小 `BLOCK_MAX`
  (当前 48)。
- **号数 < 成员数仍互踩**:隔离只是**不误判**,不改变"缓存空间共享"这个
  物理事实(§10.1/§10.3)。真要不互踩只能加号或减少并发成员。
- **每会话一份样本 → 内存随会话数增长**:`samples` 按会话分叉,长跑多会话
  场景条目变多。当前无上限(随进程存亡),量大时再加 LRU(对齐 `AFFINITY_MAX`
  的做法)。

## 12. 积分用完的号退出选号(2026-10-14 落地)

### 12.1 症状

`test` 组合里一个 traework 账号积分归零,请求报错但**没有换到还有额度的号**;
手动测这个连接,报的是「连接 "4135677434275728" 不存在（可能已被删除）」——
而它明明在面板的账号列表里。

### 12.2 根因(两处,各修一半)

1. **插件报 `ok`**:traework 的 `pool.list()` 只按凭证判状态——`accessToken === ''`
   才是 `session_dead`,其余一律 `ok`。积分归零没被解读成 `quota`,核心**根本
   不知道**这个号没额度。

2. **核心不读插件报的状态**:`pick()`/`healthy()` 只查自己的冷却表,插件报的
   `state` 从不参与选号。所以就算插件报 `quota`,核心照样会选它。

后果:积分归零的号一直"看起来健康",被选中的那次请求必然失败;而 traework 的
**积分耗尽失败是流式失败**(HTTP 200 + 流内 `event:error code=4008`),响应已经
提交,这次请求**救不回来**——只能等失败后 `onLateFailure` 记账,下个请求才换号。
即用户每次都要先白撞一次。

### 12.3 修法

- **插件侧**(`dsh-router-traework` 的 `pool.list()`):`credits === 0` 报 `quota`。
  严格区分 `CREDITS_UNKNOWN(-1)`——那是「还没拉到」,不是「用完了」。
- **核心侧**(`account-pool.servableByReport`):`quota` / `session_dead` /
  `restricted` 的号不进候选。只认这三个**账号级、要靠外部动作或时间恢复**的事实
  (充值 / 重登 / 等风控到期);`transport`/`unknown`/`unavailable` 是某次采样的
  瞬时观察,拿它排除等于「一次抖动就踢号」,那些仍走冷却单元。
- **诊断侧**(`router/index.ts`):指定连接测试失败后重试时,不再把上游真因覆盖成
  「连接不存在」。`tried` 里已有该 uid = 它刚试过并失败,保留 `trace.lastError`。

**核心不自己比 `credits`**:openrouter / nvidia 没有积分概念,报的就是
`credits: 0, state: 'ok'`,核心若拿 `credits === 0` 当「用完」会把它们整池排除。
是否"用完"由**插件解读**成 `state` 后报上来(契约:插件报「现在怎么了」,核心决定
「怎么办」)。

### 12.4 回归测试

- `account-pool.test.ts`:`quota`/`session_dead` 不进选号、全池没额度时的
  `lastWhy` 不谎称「都在冷却」、充值后立刻回候选、`credits: 0 + state: 'ok'`
  的号不受影响、瞬时状态不排除号、亲和绑定撞上 `quota` 时换号。
- `supplier-switch.test.ts`:报 `quota` 的号请求直接落到有额度的号;指定连接
  失败报上游真因、不说成「不存在」。
- `loader.test.ts`:插件报的 `quota` 原样到达核心。
- traework `pool.test.ts`:0 报 `quota`、`-1` 仍报 `ok`、`session_dead` 优先。
- `account-badge.test.ts`:面板不把被排除的号显示成「健康」。

### 12.5 天花板(ponytail)

- **仍是「撞一次才换」的兜底**:`refreshingCredits` 有 10 分钟 TTL,积分在实际
  用完到插件报 `quota` 之间有个窗口;而且本次请求已提交,补救只对后续请求生效。
  要做到"用完前就换"需要**余额阈值换挡**(§8 仍标未启用)。
- **不排除「余额快用完」**:只处理归零。阈值需要可信的刷新时间戳,`credits`
  目前只存最后一次值。
