# Agentic 场景下的结构性设计缺陷分析 **日期**:2026-05-06 **对照数据**:`outputs/qwen3-30b-tp1-v5-optD-baseline-rerun/exp2_2p6d_run1_*`(KVC kv-aware Option D,2P6D,4449 reqs / 52 sessions)+ `outputs/qwen3-30b-tp1-exps/exp1_8way_dp_cache_aware_summary.json`(同 trace 8-way DP cache-aware baseline)。 **模型**:Qwen3-30B-A3B(TP1),单机 8×H100 80GB。 **研究问题**:把 SWE trace 视为"真实 agentic"的代表,KVC 机制相对 vanilla DP 系统性输在哪里——除了"D 容量 4.6× 过载"之外的结构性原因。 > 本文是对 `docs/KVC_DEBUG_JOURNEY_V1_TO_V5.md` 与 `docs/V5_PROFILE_INVESTIGATION_ZH.md` 的补充:版本演进与瓶颈定位之外,从设计层看哪些假设和真实 agentic workload 不匹配。 --- ## TL;DR 按重要性排序的结构性缺陷: | # | 缺陷 | 数据 | 修复方向 | 工程量 | |---|---|---|---|---| | 1 | **KvAwarePolicy 不感知 D 容量;session 永久 pin 到首次落点 D** | session 平均访问的不同 D 数 = **1.00**;direct-to-D 命中率呈极端双峰(15 session 0-20%、14 session 80-100%) | score 函数加 capacity-aware 项;允许跨 D session 迁移 | 中 | | 2 | **D 端 LRU 只能 evict idle session,hot session 永远踢不掉** | D 跑全程仅 9-43 次 trim 事件 vs 80-150 次 transfer 错误;token_usage 顶到 1.00 | 加 score-based eviction(按访问频率/最近性多层) | 中 | | 3 | **没有 D→Router→Replay 的 backpressure 通道** | concurrency 一路 32 不降;D 失败时 replay 无感 | admission 响应加 `recommended_pause_ms`;replay 端按它降并发 | 小 | | 4 | **Admission HTTP round-trip 与 scheduler 主循环耦合** | v5+profile 仅加 1Hz polling 就让 errors 从 9 涨到 415 | 拆成 lock-free `/probe` + 进 scheduler 队列的 `/commit_evict` | 中 | | 5 | **P-side round-robin 不感知 D 健康** | prefill-0 出 367 KVTransferError,prefill-1 仅 4——但请求量近乎对半 | router 选 P 时考虑目标 D 健康度 | 中 | | 6 | **Replay 端 session footprint 估算膨胀 30×** | `_estimate_session_resident_tokens = input + output`,把 turn-50 的 80K 上下文当成"需要全新 80K 空间" | 改成"增量 token"估算 | 小 | | 7 | **time-scale=10 把测试条件人为推到失真区间** | inter-turn gap p50 从 2.5s 压到 0.25s——KVC 想利用的"自然 idle 窗口"被消除 | 跑一组 time-scale=1 baseline 验证 | 小(仅配置) | **最重要的对照事实**:同 trace、同硬件、同模型下 8-way DP cache-aware(无 PD 拆分、无 KVC、无 session 抽象): | 指标 | 8-way DP CA | v5 KVC 2P6D | |---|---|---| | Errors | **0** | 372 (8.4%) | | Latency mean | **1.43s** | 3.50s | | Latency P50 | **0.65s** | 1.11s | | Latency P99 | **8.37s** | 20.37s | | TTFT mean | **0.12s** | 2.13s | | TTFT P90 | **0.26s** | 6.47s | | Per-worker 请求量分布 | 508–619(±10%) | 561–858(±26%) | **naive DP 在每一项都赢,包括 latency mean 的 145% 优势**。这定义了 KVC 在该 workload 下"必须超过"的基线。 --- ## 1. Session 永久 pin 到 D + 容量盲选(最核心问题) ### 1.1 现象 每个 session 在整次运行中只访问 **1.00 个不同 D worker**(见上文数据)。结合 direct-to-D 命中率分布: ``` direct-to-D 命中率分桶(n=52 sessions): 0-20%: 15 sessions ← 几乎每 turn 都失败回退到 P→D 全量传输 20-40%: 7 40-60%: 11 60-80%: 5 80-100%: 14 sessions ← 几乎每 turn 都走 direct-to-D 快路径 ``` **几乎没有中间态**——这是典型的不公平资源分配信号。 被饿死与被照顾的 session 在工作量上差异明显: - 饿死 session 平均 peak input:56,011 token - 顺利 session 平均 peak input:31,344 token(**1.8× 差距**) **大 session 倾向被饿死**——因为它们在容量已紧张的 D 上更容易触发 admission 拒。 ### 1.2 根因(代码级) `policies.py:166-172` `KvAwarePolicy.select`: ```python score = ( overlap + sticky * self.sticky_bonus, # 主项: 历史 KV overlap sticky, # 二级: 是否 last_decode_worker inflight_penalty, # 三级: 当前 inflight 数(很小) assignment_penalty, # 四级: 累计被分配数(更小) ) ``` 评分中**完全无 D 当前容量项**。Session X 第一次落到 D-2 时积累 hash_id 在 D-2 上;之后无论 D-2 多满,X 的 turn N+1 都会被打分到 D-2(因为 overlap 主导)。 更糟的是 `RoutingState.decode_resident_blocks`(`policies.py:46`)从不缩减——即使 D 早 evict 了某些块,replay 仍认为它们在那。运行中期所有 D 的 overlap 集合都接近"trace 全部 hash_id",policy 退化为纯 sticky。 ### 1.3 后果——具体到 session 的体验 **饿死 session(如 session 50400,105 turns,0 次 direct-to-D)每 turn 流程**: 1. policy 选 D(永远是同一个) 2. admission 拒(D 容量已被占住) 3. 走 fallback-session-cap → P 全量 prefill 50K-100K token 4. mooncake 推 KV → D 仍无空间 → 32s timeout 或 KVTransferError 5. 用户每 turn 体验 5-10s 延迟,反复出错 **顺利 session(如 session 3840,118 turns,97% direct-to-D)每 turn 流程**: 1. policy 选 D(永远是该 session 的初始 D) 2. admission 通过(这个 session 一直占着这个 D 的 slot) 3. direct-to-D:D 上 append-prefill 几百 token,零 P 介入、零 mooncake transfer 4. TTFT 0.043s、E2E 0.495s **这不是"平均慢一点",是结构性不公平**——SLO 视角下 P99 是被饿死那 15 session 的尾巴拉出来的。 ### 1.4 为什么 naive DP 反而赢 8-way DP cache-aware 用纯 hash-based 路由,没有 session 抽象,没有 PD 拆分: - 每个请求按 prefix hash 路由到一个 worker → 同 session 的 turn 在 worker 上自然有 prefix 命中 - 容量过载时 SGLang 自己的 radix cache + 调度器统一管 KV 池 - 不存在 admission/fallback/reseed 路径 - 不存在 mooncake transfer - per-worker 负载误差 ±10%(vs KVC ±26%),自动接近均衡 **KVC 引入的 session affinity / KV 复用 / admission 三件套,在容量紧张时反而加剧了不均衡,没有任何一项能挽回 vs DP 的差距。** ### 1.5 修复方向 `KvAwarePolicy.select` 里加: ```python # 当前 D 容量利用率(worker-mode admission 已经能查到) capacity_penalty = -worker_capacity_used_ratio[worker.worker_id] # 当多个 D 都有 overlap 时,按容量挑最空的; # 当某 D 容量 > 阈值时,禁止该 D 进入候选 if worker_capacity_used_ratio[worker.worker_id] > HARD_CAP: continue score = ( overlap_capped, # overlap 但限幅,避免单个 D 永远赢 capacity_penalty, # ← 新增 sticky, inflight_penalty, ) ``` 更激进的修法:当一个 session 被某 D 反复拒 N 次后,主动 release 它在该 D 上的 session 状态,**允许下次 turn 走另一个 D**(代价是丢失已积累的 KV,但目前 fallback 路径本来也丢了)。 --- ## 2. D 端 LRU eviction 跟不上压力 ### 2.1 数据 每个 D 全程: | Worker | Trim 事件(主动 LRU) | KVTransferError + OOM | 峰值 token_usage | |---|---:|---:|---:| | decode-0 | 9 | 0 | 0.99 | | decode-1 | 43 | 12 (4 err + 8 oom) | 0.99 | | decode-2 | 16 | 459 (153 err + 306 oom) | 0.97 | | decode-3 | 37 | 87 (29 err + 58 oom) | 0.99 | | decode-4 | 28 | 270 (90 err + 180 oom) | **1.00** | | decode-5 | 30 | 279 (93 err + 186 oom) | **1.00** | **LRU 触发频率比错误次数低 5-15 倍。** D-4 / D-5 直接顶到 token_usage=1.00。 ### 2.2 根因 `scheduler.py:2040` `evict_idle_streaming_sessions_lru` 的 idle 判定: ```python # 只能 evict "所有 req 都 finished + streaming 模式" 的 session ``` 但 SWE 高并发下每个 session 几乎一直有 inflight req(time-scale=10 又压缩了 inter-turn gap)。**hot session 永远不 idle,LRU 永远找不到东西可踢**。结果 D 一路开到 100% → 下一笔 transfer 来直接 OOM/timeout。 ### 2.3 修复方向 引入分层 eviction: 1. **Idle session 优先**(当前) 2. **冷 session 次优**(最近 N 秒无访问,即使有 inflight,也可以 retract 那个 inflight 让位) 3. **hot session 强制 retract**(在 hard cap 触发时) vanilla SGLang 已有 `disagg_decode_prealloc_queue.retracted_queue` 机制(看 `admit_direct_append` 引用),但**没有人主动触发 retract**——目前只有内部异常时才会进 retracted_queue。需要把 retract 提升为正常 admission 路径的一部分。 --- ## 3. 没有 D→Replay 的 backpressure 通道 ### 3.1 名词解释 **Backpressure(反压)** = 流式系统下游过载时把信号反向传给上游让它降速。例:TCP 滑动窗口、Kafka consumer lag、gRPC HTTP/2 flow control。 ### 3.2 当前状态 - D 端 transfer queue 堆 → 32s 后 timeout → 抛 KVTransferError - error 抛回 P → P 抛给 router → router 抛给 replay → replay 走 fallback 路径 - **整个链路上没有"D 过载,请慢点发"的信号**——concurrency 一直保持上限 后果:D 一旦开始失败,会**持续失败**(因为 replay 没降速),直到 D 自己消化完积压。 ### 3.3 修复方向 `admit_direct_append` 响应里加: ```python { "can_admit": ..., "recommended_pause_ms": int, # ← 新增:下次发同类请求前建议等多久 "queue_depth": int, # ← 新增:D transfer queue 当前深度 ... } ``` replay 端在 admission 拒被拒时按 `recommended_pause_ms` 降并发或退避。**这是最便宜的一条改动**——不改协议、不改 SGLang 内部,只改两端代码。 --- ## 4. Admission RPC 与 scheduler 耦合——结构 vs 工程的精确边界 ### 4.1 现象 `docs/V5_PROFILE_INVESTIGATION_ZH.md` 报告:仅加 1Hz `/server_info` polling 就让 EXP2 errors 从 9 涨到 415。`/server_info` 在 scheduler 主循环里遍历 session slots 算 `is_idle`,1 Hz × 8 worker 就足以扰动调度。 但实际负载下 admission RPC 频率远高于 1Hz:每个 turn 1 + reseed + direct-to-D 都调一次。concurrency=32 + 4449 reqs / ~2700s ≈ **每秒 16+ 次 admission RPC**。 ### 4.2 这是结构问题还是工程问题——精确拆解 `admit_direct_append`(`scheduler.py:3581`)做两件事: ```python # (a) 读池子状态——轻 available_tokens = self.token_to_kv_pool_allocator.available_size() # (b) 触发 LRU 扫描——重,且必须修改池子状态 trim_result = self.maybe_trim_decode_session_cache(...) ``` | 部分 | 性质 | 是否能靠工程化解决 | |---|---|---| | (a) 读池子状态 | 几个原子读 | **完全可工程化**——做成 lock-free shared-memory snapshot 即可 | | (b) LRU eviction | 修改 GPU 池子,必须独占 | **结构性的**——Python GIL + 共享 GPU 池子无法并发修改 | **关键观察**:实际负载里 (b) 是少数路径——大部分 admission 只需要"看一下够不够",不需要立即 evict。 ### 4.3 工程化修复方案 把 admission API 拆成两个端点: ``` POST /session_cache/probe ← 90% 流量 - 只读 lock-free snapshot - 返回 (can_admit_estimate, available_tokens, queue_depth) - 不进 scheduler 队列 POST /session_cache/commit_evict ← 10% 流量 - probe 不够时才调 - 进 scheduler 队列,做实际 LRU - 保留当前 admit_direct_append 语义 ``` snapshot 由 scheduler 在每个 step 末尾写到一段 mmap 共享内存(atomic publish);replay 端 mmap 读,零 syscall 零序列化。一秒内能撑数千次 probe。 ### 4.4 关于"协程/多线程/多进程/换语言" | 工具 | 对本问题的实际效果 | |---|---| | asyncio 协程 | SGLang 已用,对 scheduler 主循环本身无帮助 | | Python 多线程 | GIL 拦着,且 GPU 池子状态只能 scheduler 进程改 | | 多进程 | scheduler 已是独立进程;问题是它**自己的 step 循环**串行了 admission 与 decode | | orjson / uvloop | 网络/JSON 加速 5-10×,但 LRU 遍历不在那条热路径 | | Rust/C++ 重写 scheduler | 把 LRU 遍历提速 5-10×,但**结构性共享问题仍在** | **正确的工程化解法是重设计 API(拆 probe / commit),不是单纯换更快的库或语言。** --- ## 5. P-side 路由不感知 D 健康 ### 5.1 数据 ``` prefill-0: 367 KVTransferError, 361 "Decode instance could be dead" prefill-1: 4 KVTransferError, 0 "Decode instance could be dead" 请求量对比: prefill-0: 2225 requests prefill-1: 2224 requests ← 几乎对半 ``` **两 P 请求量完全均衡,错误率差 92×**。日志里 prefill-0 的错误反复指向某个特定 D(`10.45.80.47:XXXXX`)——它跟某个 hot D 形成了"死亡链路"。 ### 5.2 根因 `pd_router.py:43-49` 的 P 选择是裸 round-robin: ```python prefill_url, bootstrap_port = self.config.prefill_urls[ self.prefill_cursor % len(self.config.prefill_urls) ] ``` 不知道 D 是否健康,不会避开"正在和 D-X 死磕"的 P。 ### 5.3 修复方向 router 选 P 时考虑 (P 当前 inflight transfer 数, 目标 D 健康度) 联合得分。健康度可以用 §3 提的 `queue_depth` 字段。 --- ## 6. Replay 端 session footprint 估算膨胀 30× ### 6.1 代码 `replay.py:898-899`: ```python def _estimate_session_resident_tokens(request: TraceRequest) -> int: return request.input_length + request.output_length ``` 被用于 `_decode_session_soft_cap`(`replay.py:1051`)和 `_should_admit_new_decode_session`。 ### 6.2 问题 对一个已经在 D 上有 80K KV 的 turn 50: - 真实增量需求:input 新增几千 token + output 几百 token = ~3K - 估算返回值:80K + 1K = 81K(**膨胀 ~27×**) 后果:router-mode admission 系统性误判——本来能 admit 的 session 被 replay 自己拒掉。v5 worker-mode 让 D 自己看真实容量部分修了这个,**但 KvAwarePolicy 选 D 时仍用这个膨胀估算**——选 D 仍然是错的。 ### 6.3 修复 ```python def _estimate_session_resident_tokens(request: TraceRequest) -> int: if request.turn_id == 1: return request.input_length + request.output_length # turn 2+: only the increment matters for additional reservation return max(0, request.input_length - request.cached_tokens) + request.output_length ``` --- ## 7. time-scale=10 测量失真 ### 7.1 它是什么 `replay.py` 把原始 trace 每个请求的 `timestamp` 字段做 `t / time_scale` 缩放后再按这个时间发。 - 原始 trace 跨度 ~6000s(≈100 分钟) - time-scale=10 → 实际 replay 跨度 ~600s(≈10 分钟) ### 7.2 为什么这么设计 **纯粹为了节省测试时间**——单次 1× 跑 100 分钟,sweep 5 版 × 3 重复 = 25h GPU 时间;10× 只要 2.5h。 ### 7.3 它扭曲了什么 | 维度 | 原始 trace | replay (time-scale=10) | |---|---|---| | inter-turn gap p10 | 1.6s | 0.16s | | inter-turn gap p50 | 2.5s | 0.25s | | inter-turn gap p90 | 7.8s | 0.78s | | inter-turn gap max | 261s | 26s | 真实 agentic 用户/agent 在每个 turn 之间停 2-8 秒(思考、打字、tool call)。**这些间隙正好是 KVC 想利用的"自然 idle 窗口"**——session 短暂 idle 时 LRU 可以 evict、其他 session 可以 admit。 time-scale=10 把这些窗口压到 0.2-0.8s,**人为消除了 KVC 的设计前提条件**。 ### 7.4 严重的实验有效性威胁 所有 v3-v6 数据基于 time-scale=10。这意味着前面所有"KVC 在 SWE 上输给 baseline"的结论都带着这个失真。**真实部署里 inter-turn gap 是 2.5s 的话,KVC 可能根本不会撞到当前看到的容量瓶颈**——D 有时间在 turn 之间释放/重排。 **应该单独跑一组 time-scale=1 的 baseline 对比**,才能判断 KVC 输给 DP 是因为机制本身不行,还是因为 benchmark 把它推到了不该工作的区间。这是这个项目目前**最重要但还没做**的验证。 --- ## 8. 应用层抽象不需要在引擎层引入(撤回) 之前草稿里提过"框架不支持 speculative 多分支、嵌套 sub-agent、tool call 中断"——这是过度抽象。**应用层模式都可以由 timestamp + 独立 session_id 隐式表达**: | 应用层模式 | 表现在 trace 里 | 推理引擎需要做什么 | |---|---|---| | Tool call 异步返回 | turn N 与 N+1 之间 timestamp gap 很大 | 啥都不用,按时间发请求即可 | | 嵌套 sub-agent | 父 session timestamp 突然停顿;sub-agent 是独立 session_id | 把它们当成两个独立 session 即可(KV 也无需共享) | | Speculative N 分支 | N 个独立 session_id 同时发 | 用 radix prefix cache 自然命中前缀;不需要任何额外抽象 | **这条不构成结构性缺陷。** 已从结论中移除。 --- ## 9. 行动项(按 ROI 排序) ### 优先级 P0(修了显著改善饿死/不公平) 1. **[§1] KvAwarePolicy 加 capacity-aware penalty + 允许 session 跨 D 迁移** — 工程量中、收益最大 2. **[§2] D 端引入分层 eviction(冷 session、hot retract)** — 工程量中、收益大 3. **[§7] 跑一组 time-scale=1 baseline** — 工程量小(仅配置),但**不做这条所有结论都不可信** ### 优先级 P1(修了把工程稳定性补齐) 4. **[§3] D→Replay backpressure 通道**(admission 响应加 pause hint) — 工程量小 5. **[§4] 拆 admission 为 probe + commit_evict** — 工程量中 6. **[§6] 修 `_estimate_session_resident_tokens` 用增量** — 工程量小 ### 优先级 P2(等 P0 数据后再决定) 7. **[§5] P-side 选 P 时考虑 D 健康** — 工程量中 --- ## 10. 局限与未验证假设 1. **N=1**:所有数据来自单次 run(v6 P0 已证 EXP2 errors 在 9-912 间漂移,single-run variance 巨大)。本文所有数字都应理解为"代表性观察"而非"统计显著结论"。 2. **time-scale=10 失真**(§7):所有"KVC 输给 DP"的程度可能是被 benchmark 放大的。这是最大的不确定性。 3. **8DP 对比的硬件优势**:DP 是 8 个 worker 全部跑 prefill+decode;KVC 是 2P+6D,只有 6 个能解码。理论上 8 worker 对 6 worker 自带 1.33× 解码并发优势。本文未折算这部分——但 8DP 优势远大于 1.33×(latency mean 145% 优势),所以核心结论(KVC 在该 workload 下系统性输)不受此影响。 4. **mooncake TCP loopback**:所有 transfer 错误是单机 TCP 模拟下的产物。生产环境 RDMA 下错误率分布可能完全不同。 5. **KvAwarePolicy 的 stale `decode_resident_blocks`**(§1.2 末尾)现象有数据观察支撑(运行中期 overlap 失去判别力),但**没有系统性测过"清掉 stale 状态会怎样"**。 6. **P-side 错误集中在 prefill-0**(§5.1)的因果链是推测——可能也是"prefill-0 早启动 + race"的偶然结果。N>1 数据未验证。 --- ## 附录 A:数据产物索引 ``` outputs/qwen3-30b-tp1-v5-optD-baseline-rerun/ ├── exp2_2p6d_run1_metrics.jsonl ← 本文主数据源 ├── exp2_2p6d_run1_summary.json ├── exp2_2p6d_run2_* (errors=912, single-run variance 证据) ├── exp2_2p6d_run3_* (errors=396) └── kvcache-centric-*-20260429T142429Z/logs/ ├── decode-{0..5}.log ← §2.1 LRU vs error 计数 └── prefill-{0,1}.log ← §5.1 P 错误分布 outputs/qwen3-30b-tp1-exps/ ├── exp1_8way_dp_cache_aware_summary.json ← 对照 baseline └── RESULTS_SUMMARY.md ``` ## 附录 B:相关文档 - `docs/PROJECT_OVERVIEW.md` — 项目目标与已实现功能 - `docs/KVC_DEBUG_JOURNEY_V1_TO_V5.md` — v1→v5 版本演进 - `docs/V5_PROFILE_INVESTIGATION_ZH.md` — v5+profile 调查(已 critic 修订) - `docs/SWEBENCH_EXPERIMENT_RESULTS.md` — Qwen3.5-35B-A3B SWE 实验