# 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` |