# Migration v1 实验发现：blacklist 永久性导致 thrashing

**日期**：2026-05-08
**状态**：v1 run 进行中（~23% 完成时的中期分析）
**前置文档**：
- `docs/REFACTOR_PLAN_V1_ZH.md` §6.2（v1 设计）
- `docs/TEAM_REPORT_AGENTIC_PD_HYBRID_ZH.md` §2.1（§1 starvation claim）

**触发**：v1 实现的 session migration（rejection blacklist 机制）部署后，观测到 session-level thrashing——某些 session 在 3 个 D 之间 round-robin 高达 75-116 次。本文记录中期数据、根因诊断、v2 设计。

---

## 0. TL;DR

1. **v1 修复了 §1 starvation 但引入了新的 thrashing 失效模式**——不是 admission 过严，是 blacklist 永久累积的设计 bug
2. **核心证据**：session 6880 在 decode-1 上稳定 70 turns，然后某瞬时 burst 把 reject 计数累积到阈值，被永久 blacklist，之后陷入 3-D 间 round-robin 死循环
3. **85% admission 拒绝是 `session-not-resident`**——非 D 真容量问题，而是迁移后"新 D 第一次见你"的正常语义
4. **v2 设计**：reset-on-success 让 reject 计数在成功 turn 后清零，只有**持续**失败才迁移
5. **深层观察**：baseline 的"100% pin 但稳定"可能比"分布均匀但 thrashing"更好——糟糕的优化可能比不优化还糟

---

## 1. v1 实施回顾

### 1.1 改动文件
- `src/agentic_pd_hybrid/policies.py`：`RoutingState.session_d_rejects` Counter；`KvAwarePolicy.migration_reject_threshold=3` skip blacklisted D；degenerate fallback 选最少拒的 D
- `src/agentic_pd_hybrid/replay.py`：`_run_request` 末尾 `state.record_admission_reject(sess, D)`（基于 execution_mode 子串匹配）；`_fallthrough_reason` 把 `pd-router-fallback-large-append-*` 拆成 `session-not-resident` / `real-large-append` / 等
- CLI / benchmark wiring

### 1.2 v1 假设（事后看部分错误）
- "reject 计数 + 阈值 3 = 容忍短期波动 + 持续失败迁移" ← **错**，counter 永久增长导致迁移成必然
- "迁移到新 D 后 session 在新 D 稳定下来" ← **部分错**，迁移到的新 D 也很可能很快 reject
- "session-not-resident 不会触发计数" ← **大致对**，但下游 fallback 可能间接触发

---

## 2. 中期数据（1023/4449 reqs，~23%）

### 2.1 头部指标 vs baseline

| 指标 | baseline kvc_1p3d_run1 | v1（中期） |
|---|---:|---:|
| Per-D 调用分布 | 1502/1445/1502（±3.8%）| 796/785/779（**±1.1%**，更均衡）|
| Per-D 峰值 token_usage | 0.99/0.99/0.99 | 0.31/0.30/0.00（**容量充裕**，未顶到 1.00）|
| KVTransferError | 5（全程）| 6（中期，趋势相近）|
| 已见 sessions | 52（全程）| 29（中期）|

**好的方面**：
- 负载均衡度跃升（±26%→±1.1% if normalized）
- D 容量从未饱和——§2 假设的"D drain time"机制配合 ts=1 充分发挥
- 0 sessions 永久 stuck 在饿死状态

### 2.2 Migration 触发情况（已见 29 sessions）

| 类别 | 数量 | 占比 |
|---|---:|---:|
| 仍 pin 在 1 个 D | 9 | 31% |
| 触碰 2 个 D | 3 | 10% |
| **触碰所有 3 个 D** | **17** | **59%** |

**D-切换次数分布**：
- mean = 26 次/session
- median = 16 次
- **max = 116 次**
- 15 sessions 切换 >10 次（明显 thrashing）
- **6 sessions 切换 >50 次**（严重 thrashing）

---

## 3. 根因诊断：session 6880 的轨迹

### 3.1 数据

```
turn 0-70:   全部在 decode-1   (71-turn 稳定 streak)  ← §1 baseline 行为
turn 71-150: 在 3 个 D 间剧烈 thrashing
              decode-0: 26 个短 streak
              decode-1: 25 个短 streak
              decode-2: 25 个短 streak
              平均 streak 长度 = 2 turns
              total streaks = 76
```

### 3.2 解读

**前 70 turn 完美稳定**：session 6880 在 decode-1 上正常运行 70 个 turn，每次都成功，是 baseline §1 "100% pin" 的复现——稳定但不公平（其他 session 没分到 decode-1 的资源）。

**第 71 turn 后崩溃**：
1. 某个瞬时 burst（其他 session 的活动？）让 decode-1 短暂饱和
2. session 6880 在 decode-1 上连续 3 次被 admission 拒（`no-space` 或 `d-session-cap`）
3. v1 的 `state.session_d_rejects[(6880, decode-1)]` 累积到 3 → blacklist
4. policy 改选 decode-0 → 同样发生 → blacklist
5. 改选 decode-2 → 同样 → blacklist
6. **3 D 全部 blacklisted** → degenerate fallback 在 3 D 间 round-robin
7. 每次 round-robin 又触发新 reject → 计数继续涨 → 永远在 thrashing 死循环

### 3.3 admission 数据交叉验证

中期 1932 admission events 解构：

| mode × can_admit × reason | count |
|---|---:|
| `direct_append, True, None` | 1721（成功）|
| `direct_append, False, session-not-resident` | **62** |
| `seed, True, None` | 142（成功）|
| `seed, False, no-space` | **11** |

**只有 11 个 "no-space" 才是真容量拒绝**（占总 admission 的 0.6%）。62 个 "session-not-resident" 是迁移后"新 D 第一次见你"的正常语义。

但因为 v1 用 `_is_admission_rejection_mode` 通过 execution_mode 子串匹配，下游 fallback chain 会把 `session-not-resident` 也间接累积到计数器（fallback 链路本身可能触发 session-cap）。

---

## 4. 设计 bug 三层

### 4.1 Bug 1：blacklist 永久性

```python
# policies.py 当前实现
if rejects >= self.migration_reject_threshold:
    continue  # skip this D forever
```

`session_d_rejects[(sess, D)]` 是单调递增 Counter。一旦达到阈值，**永远**被 skip。但 D 的容量是动态的——70 个 turn 后短暂饱和不代表它后续不能服务这个 session。

### 4.2 Bug 2：degenerate fallback 加剧问题

当所有 D 都被 blacklist：
```python
best_decode_worker_id = min(
    (w.worker_id for w in topology.route_workers),
    key=lambda wid: state.session_d_rejects.get((sess, wid), 0),
)
```
选"最少被拒"的 D。但每次 fallback 又增加该 D 的计数 → 下次选另一个 D → 形成完美 round-robin，永远走不出 thrashing。

### 4.3 Bug 3：信号归并粗糙

`_is_admission_rejection_mode` 子串匹配 `session-cap` / `no-d-capacity` / `d-backpressure`，但执行链路可能这样：

```
direct_append → session-not-resident（85% 占比，正常迁移后语义）
  → fallback 试 seed
    → seed admit ok（142/153 = 93%）→ execution_mode = pd-router-d-session-reseed-*（不计 reject）
    → seed no-space（11/153 = 7%）→ execution_mode = pd-router-fallback-X-no-d-capacity（计 reject）
```

绝大多数 fallback 不会触发 reject 计数。但 thrashing 一旦开始，很容易踩到那 7% no-space 路径，calculator 增长一次。15+ 次 thrashing 后，单 D 计数累到 3 完全可能。

**所以设计 bug 不在信号粗糙，而在永久累积 + degenerate round-robin。**

---

## 5. 深层观察：稳定 vs 公平的 trade-off

| | baseline（v0）| v1 |
|---|---|---|
| 公平性 | 18/52 永久饿死 | 0 永久饿死 |
| 稳定性 | 100% pin（结构稳定）| 6/29 严重 thrashing |
| Per-D 负载均衡 | ±26% | ±1.1% |
| 大 session 体验 | 慢但稳定（每 turn 都走 fallback ~1.0s）| 不稳定 + 频繁 D 切换 + 丢 KV state |

**预想反直觉的结果**：v1 在头部指标（per-D 均衡）赢，但在 session 体验可能输——
- baseline 的 fallback 路径有稳定 ~1s latency
- v1 的 thrashing session 每次 D 切换都 close 旧 session、丢 KV、新 D 上重新建立——有可能 latency 反而更高

需要等 run 结束的 lat mean / TTFT mean 数据验证。**糟糕的优化可能比不优化还糟。**

---

## 6. v2 设计

按 ROI 排序的修复层。**先做 #1，验证后再决定是否需要 #2/#3**。

### 6.1 v2-fix-1：reset-on-success（最高 ROI）

```python
# replay.py _run_request 末尾，在 state.finish 后
if execution.execution_mode == "kvcache-direct-to-d-session":
    # 这次 direct-to-D 成功 = D-X 仍能服务这个 session
    # 清零累积的 reject 计数（消除永久 blacklist）
    state.session_d_rejects[(request.session_id, decision.decode_worker_id)] = 0
```

**预测效果**：
- session 6880 在 decode-1 上 70 个成功 turn 把计数反复清零
- 即使中间出现 1-2 次瞬时 reject，下次成功立刻清零
- 只有**持续**失败（reject 后 reject 后 reject，没有夹杂 success）才能累到阈值
- 真饿死的 session（如 35680/39360 input >92K）才会触发迁移

**工程量**：~5 行代码 + 1 个 smoke + 1 个完整 run（~5.5h）

### 6.2 v2-fix-2：sliding window（如果 #1 不够）

把 `Counter` 改成 `dict[(sess, D), deque[float]]` 存最近 K 次拒绝时间戳。判断时用最近 N 秒（或 N 个 turn）内的次数。

更稳健但更复杂。**若 #1 已能彻底解决 thrashing，跳过此项。**

### 6.3 v2-fix-3：reject 类型分离（如果 #1 + #2 不够）

把 admission reason 显式传到 _run_request，区分：
- `no-space` / `session-cap` / `backpressure` → 计 reject
- `session-not-resident` → 不计

需改 `ExecutionResult` 加 `admission_reject_reason` 字段，并在 fallback 链路传递。**不在第一轮**——先看 #1 是否够用。

### 6.4 v2 应保留的 v1 设计

- 阈值 3（不变）
- `record_admission_reject` 的子串匹配（不变）
- 新 fallback labels（`session-not-resident` 等）（不变）
- degenerate fallback 选最少拒的 D（不变，但因为 reset-on-success 几乎不会触发到此分支）

---

## 7. 实验计划

| 阶段 | 动作 | 时间 |
|---|---|---|
| 1 | 等 v1 run 完成（ETA ~16:30）| 自然 |
| 2 | 跑 analyzer 量化 v1 thrashing 实际代价 | 5 min |
| 3 | 实现 v2-fix-1（reset-on-success）| 30 min |
| 4 | smoke test | 10 min |
| 5 | 完整 v2 run（KVC 1P3D ts=1 N=1）| ~5.5h |
| 6 | 三方对比：baseline / v1 / v2 | 30 min |
| 7 | 决定是否需要 v2-fix-2 / v2-fix-3 | – |

---

## 8. 三方对比预测（待数据验证）

| 指标 | baseline（v0）| v1（thrashing）| **v2（self-healing 预测）** |
|---|---:|---:|---:|
| Errors | 5 | ? | 2-5（仅 35680/39360 等真容量超限）|
| Per-D 均衡 | ±26% | **±1.1%** | ±5-10%（部分 pin 仍 sticky）|
| Direct-to-D rate | 42.8% | ?（可能因 thrash 反而下降）| **65-75%**（持续 affinity，转换 §1 fallback）|
| Lat mean | 1.574s | ?（可能因 thrash 上升）| **1.30-1.45s**（达到 4DP 1.443s 水平）|
| TTFT mean | 0.244s | ? | **0.10-0.15s** |
| 最大 D-switches/session | 0 | 116 | <10（仅真饿死 session）|
| Sessions 永久饿死 | 18 | 0 | 2-3（仅真容量超限）|

预测核心：v2 应该结合 baseline 的稳定性（70-turn streak 应保留）+ v1 的公平性（无永久饿死），消除 v1 的 thrashing 副作用。

---

## 9. 局限与未验证

1. **v1 中期数据 (23%) 推测**：完整数据可能改变 thrashing 严重性的判断
2. **session 6880 trajectory 的崩溃机理是推断**：基于 admission events 数据 + streak 模式，但没有直接日志证明 reject 计数何时跨阈值（需要在 v2 加 instrument 输出）
3. **reset-on-success 的预测效果未验证**：基于"70 turn 成功" + "1-2 次瞬时 reject" 的假设；如果 burst 持续多 turn，仍可能跨阈值
4. **可能还有未发现的设计 bug**：v2 也许还会暴露新问题
5. **三方对比需 same trace + same scale + same ts=1**：baseline 已有 N=3，v1/v2 各 N=1（ts=1 确定性 → N=1 可信）

---

## 10. 给 TEAM_REPORT 和 REFACTOR_PLAN_V1 的更新建议

完成 v2 验证后：

1. 在 `TEAM_REPORT` §3 ts=1 验证更新章节加入 §3.3 "Migration mechanism evolution: v0 → v1 → v2"
2. 在 `REFACTOR_PLAN_V1` §6.2 标注实施反思——预设的 "rejection blacklist" 设计漏掉了 reset-on-success 这条
3. 在新文档 `docs/POLICY_DESIGN_PRINCIPLES_ZH.md` 提炼出原则："任何会累积的代价机制必须配 healing/decay 机制，否则会陷入 self-amplifying 失效模式"

---

## 附录 A：本文数据来源

| 章节 | 数据源 |
|---|---|
| §2 | `outputs/qwen3-30b-tp1-ts1-migration-v1/kvcache-centric-*/` 中期日志 |
| §3.1 | `structural/session-d-binding.jsonl` 跨 turn 序列 |
| §3.3 | `structural/admission-events.jsonl` mode/reason 交叉表 |

## 附录 B：相关代码位置

| 内容 | 位置 |
|---|---|
| RoutingState.session_d_rejects | `src/agentic_pd_hybrid/policies.py:46` |
| KvAwarePolicy.select 跳过 blacklisted D | `src/agentic_pd_hybrid/policies.py:155-162` |
| Degenerate fallback 选最少拒的 D | `src/agentic_pd_hybrid/policies.py:184-192` |
| record_admission_reject 触发位置 | `src/agentic_pd_hybrid/replay.py:359-364`（_run_request） |
| _is_admission_rejection_mode 子串集合 | `src/agentic_pd_hybrid/replay.py` `_ADMISSION_REJECTION_SUBSTRINGS` |
| _fallthrough_reason 分类 | `src/agentic_pd_hybrid/replay.py` `_fallthrough_reason` |
