Files
agentic-pd-hybrid/docs/AGENTIC_FIT_ANALYSIS_ZH.md
kzlin 1d51704dad docs(kvc): agentic-fit analysis, refactor plan, validation report
Three new docs covering the structural-fit investigation:

- AGENTIC_FIT_ANALYSIS_ZH.md: §1-§7 of structural design issues that
  surface KVC vs vanilla DP gap on real agentic workloads (SWE 50sess).
  Quantifies session pinning, LRU shortfall, P-side imbalance,
  time-scale distortion, etc., with code citations and N=3 rerun data.

- REFACTOR_PLAN_ZH.md: KISS-edition refactor plan. After verifying the
  original "estimate inflation" and "resident_blocks aging" claims were
  not real bugs, scope shrinks to one code change (backpressure) plus a
  4-run smoke sweep within an 8h budget.

- STRUCTURAL_VALIDATION_REPORT_ZH.md: validates §1-§7 claims using
  existing v5 baseline rerun data + 8DP CA baseline. Each claim labeled
  fully-supported / indirect / retracted with the data source. Notes
  that backpressure E2E validation is pending GPU smoke run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:30:11 +08:00

20 KiB
Raw Blame History

Agentic 场景下的结构性设计缺陷分析

日期2026-05-06 对照数据outputs/qwen3-30b-tp1-v5-optD-baseline-rerun/exp2_2p6d_run1_*KVC kv-aware Option D2P6D4449 reqs / 52 sessions+ outputs/qwen3-30b-tp1-exps/exp1_8way_dp_cache_aware_summary.json(同 trace 8-way DP cache-aware baseline模型Qwen3-30B-A3BTP1单机 8×H100 80GB。 研究问题:把 SWE trace 视为"真实 agentic"的代表KVC 机制相对 vanilla DP 系统性输在哪里——除了"D 容量 4.6× 过载"之外的结构性原因。

本文是对 docs/KVC_DEBUG_JOURNEY_V1_TO_V5.mddocs/V5_PROFILE_INVESTIGATION_ZH.md 的补充:版本演进与瓶颈定位之外,从设计层看哪些假设和真实 agentic workload 不匹配。


TL;DR

按重要性排序的结构性缺陷:

# 缺陷 数据 修复方向 工程量
1 KvAwarePolicy 不感知 D 容量session 永久 pin 到首次落点 D session 平均访问的不同 D 数 = 1.00direct-to-D 命中率呈极端双峰15 session 0-20%、14 session 80-100% score 函数加 capacity-aware 项;允许跨 D session 迁移
2 D 端 LRU 只能 evict idle sessionhot 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_msreplay 端按它降并发
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 KVTransferErrorprefill-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 请求量分布 508619±10% 561858±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 input56,011 token
  • 顺利 session 平均 peak input31,344 token1.8× 差距

大 session 倾向被饿死——因为它们在容量已紧张的 D 上更容易触发 admission 拒。

1.2 根因(代码级)

policies.py:166-172 KvAwarePolicy.select

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_blockspolicies.py:46)从不缩减——即使 D 早 evict 了某些块replay 仍认为它们在那。运行中期所有 D 的 overlap 集合都接近"trace 全部 hash_id"policy 退化为纯 sticky。

1.3 后果——具体到 session 的体验

饿死 session如 session 50400105 turns0 次 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 3840118 turns97% direct-to-D每 turn 流程

  1. policy 选 D永远是该 session 的初始 D
  2. admission 通过(这个 session 一直占着这个 D 的 slot
  3. direct-to-DD 上 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 里加:

# 当前 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 判定:

# 只能 evict "所有 req 都 finished + streaming 模式" 的 session

但 SWE 高并发下每个 session 几乎一直有 inflight reqtime-scale=10 又压缩了 inter-turn gaphot session 永远不 idleLRU 永远找不到东西可踢。结果 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 响应里加:

{
  "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_idle1 Hz × 8 worker 就足以扰动调度。

但实际负载下 admission RPC 频率远高于 1Hz每个 turn 1 + reseed + direct-to-D 都调一次。concurrency=32 + 4449 reqs / ~2700s ≈ 每秒 16+ 次 admission RPC

4.2 这是结构问题还是工程问题——精确拆解

admit_direct_appendscheduler.py:3581)做两件事:

# (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 publishreplay 端 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 的错误反复指向某个特定 D10.45.80.47:XXXXX)——它跟某个 hot D 形成了"死亡链路"。

5.2 根因

pd_router.py:43-49 的 P 选择是裸 round-robin

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

def _estimate_session_resident_tokens(request: TraceRequest) -> int:
    return request.input_length + request.output_length

被用于 _decode_session_soft_capreplay.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 修复

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修了把工程稳定性补齐

  1. [§3] D→Replay backpressure 通道admission 响应加 pause hint — 工程量小
  2. [§4] 拆 admission 为 probe + commit_evict — 工程量中
  3. [§6] 修 _estimate_session_resident_tokens 用增量 — 工程量小

优先级 P2等 P0 数据后再决定)

  1. [§5] P-side 选 P 时考虑 D 健康 — 工程量中

10. 局限与未验证假设

  1. N=1:所有数据来自单次 runv6 P0 已证 EXP2 errors 在 9-912 间漂移single-run variance 巨大)。本文所有数字都应理解为"代表性观察"而非"统计显著结论"。
  2. time-scale=10 失真§7所有"KVC 输给 DP"的程度可能是被 benchmark 放大的。这是最大的不确定性。
  3. 8DP 对比的硬件优势DP 是 8 个 worker 全部跑 prefill+decodeKVC 是 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 实验