“优化 KV Cache”到底在优化什么?vLLM、SGLang、Strata 优化的是三个不同的维度

"优化 KV Cache"到底在优化什么?vLLM、SGLang、Strata 优化的是三个不同的维度

作 者:吴佳浩Alben

撰稿时间:2026.10.10 更新时间:2026.10.11

引言

vLLM 说自己优化了 KV Cache,SGLang 说自己优化了 KV Cache,Strata 也说自己优化了 KV Cache。读完三份文档、三套术语,很容易得出一个印象:它们在做同一件事,只是做得有好有坏。

这个印象是错的。

它们各自把力气花在了三个不同的维度上:

一个管怎么存,一个管怎么共享,一个管怎么放。

维度不同,收益就不同,代价也不同。混在一起谈,最典型的结果就是那句经典的困惑:"我明明优化了 KV Cache,为什么 TTFT 一点没降?" ------ 因为你优化的是另一个维度上的东西。

先说明对象。 本文的 Strata ,指 arXiv:2508.18572(OSDI '26)的论文版 ,一个长上下文场景下的 KV 分级缓存系统,构建在 SGLang 之上 。所以严格地说,本文对比的是"两个引擎的实现 + 一个跑在引擎之上的系统 ",不是三个独立引擎。这也说明维度 C 的优化可以叠加在维度 B 的实现之上。(另有一个同名但不同源的单机项目也叫 Strata,血缘、论文、代码都与上面这个无关,它的分层对象甚至不是 KV,放在第六章讲。)

本文的证据分三类,正文会逐处标注:

  • 【实测】 本机跑出来的数据(RTX 5090,vLLM 与 SGLang 两个引擎)
  • 【源码】 镜像内源码逐行核对
  • 【论文】 引用论文的数字,我没有复现

读完你会得到:

  1. "优化 KV Cache"拆成 A / B / C 三个维度,收益不可互换(第一、二章)
  2. vLLM 的 78.4% 和 SGLang 的 94.1% 为什么不能比,以及怎么读才对(第三章)
  3. 一个模型的 KV 到底多大,决定了要不要搬(第五章)
  4. 按症状找维度的选型判据(第七章)
  5. 压测时缓存污染的一个真实案例,以及怎么避免(第三、八章)

目录

  1. [先拆词:"优化 KV Cache"至少是三个维度](#先拆词:“优化 KV Cache”至少是三个维度 "#%E4%B8%80%E5%85%88%E6%8B%86%E8%AF%8D%E4%BC%98%E5%8C%96-kv-cache%E8%87%B3%E5%B0%91%E6%98%AF%E4%B8%89%E4%B8%AA%E7%BB%B4%E5%BA%A6")
  2. 三个维度分别对应哪些旋钮
  3. [维度 B:怎么共享------两家在这里分了岔](#维度 B:怎么共享——两家在这里分了岔 "#%E4%B8%89%E7%BB%B4%E5%BA%A6-b%E6%80%8E%E4%B9%88%E5%85%B1%E4%BA%AB%E4%B8%A4%E5%AE%B6%E5%9C%A8%E8%BF%99%E9%87%8C%E5%88%86%E4%BA%86%E5%B2%94")
  4. [维度 C:怎么放------论文版 Strata 换了一个维度](#维度 C:怎么放——论文版 Strata 换了一个维度 "#%E5%9B%9B%E7%BB%B4%E5%BA%A6-c%E6%80%8E%E4%B9%88%E6%94%BE%E8%AE%BA%E6%96%87%E7%89%88-strata-%E6%8D%A2%E4%BA%86%E4%B8%80%E4%B8%AA%E7%BB%B4%E5%BA%A6")
  5. [KV 到底有多大?决定「要不要搬」的是模型架构,不是引擎](#KV 到底有多大?决定「要不要搬」的是模型架构,不是引擎 "#%E4%BA%94kv-%E5%88%B0%E5%BA%95%E6%9C%89%E5%A4%9A%E5%A4%A7%E5%86%B3%E5%AE%9A%E8%A6%81%E4%B8%8D%E8%A6%81%E6%90%AC%E7%9A%84%E6%98%AF%E6%A8%A1%E5%9E%8B%E6%9E%B6%E6%9E%84%E4%B8%8D%E6%98%AF%E5%BC%95%E6%93%8E")
  6. 延伸:同一个「分层」思想,单机端的对象换成了权重
  7. 三个维度怎么选
  8. 实测边界:这次数据是怎么来的、哪里不干净
  9. 总结

一、先拆词:"优化 KV Cache"至少是三个维度

"KV Cache 优化"是一句被用滥的话。把它拆开,至少有三个互不重叠的问题:

维度 回答的问题 典型手段 收益形式
维度 A:怎么存 每个 token 的 KV 占多少字节?放在内存里的什么位置? KV 量化(bf16 / int8 / q4)、块大小、页大小、内存布局 同样的显存装下更多 token
维度 B:怎么共享 多个请求之间的重复前缀,怎么让它们不重复算? 前缀缓存、块级哈希、基数树、驱逐策略 同样的算力处理更多请求
维度 C:怎么放 装不下的 KV,往哪一级放、怎么搬、什么时候搬? 分级缓存(VRAM→RAM→SSD)、卸载/预取、缓存感知调度 同样的显存装下更长的上下文

三个维度是正交 的:你可以同时做 A、B、C。但它们的收益不可互换------这是本文最想讲清的一句话。

为什么必须分开谈?举一个本文实测过的例子。

压测里最容易被误读的一个数字是前缀缓存命中率 。第三章会讲到:vLLM 与 SGLang 在同一份负载下分别报出 78.4% 和 94.1%。如果不看定义,很容易得出"SGLang 的前缀缓存实现更好,领先 16 个百分点"。

但这两个数根本不是同一种统计:一个是累计计数器 (必须差分读),另一个是单次上报的瞬时值 (空闲会衰减)。口径不同、分母不同、匹配粒度还差 16 倍。它们放一起没有意义。

这就是"三个维度"之外的第二个陷阱:同一个名字底下,量的定义可能完全不同。 本文在每一处涉及数字的地方都会标注口径。


二、三个维度分别对应哪些旋钮

把三个项目按维度摆好,格局就很清楚了:它们的主战场不在同一个维度上。

维度 vLLM 0.28.0 SGLang 0.5.21 Strata(论文版)
A 怎么存 --kv-cache-dtype、--block-size(默认 16) --kv-cache-dtype、--page-size(默认 1) 布局解耦:GPU 侧 layer-first,宿主/存储侧 page-first
B 怎么共享 --enable-prefix-caching + 链式块哈希 Radix Cache(默认开)+ --radix-eviction-policy 不是它的主战场
C 怎么放 --kv-transfer-config、--num-gpu-blocks-override HiCache:--hicache-mem-layout 等一族参数 主战场:GPU 辅助 I/O + 缓存感知调度

注意第三列:论文版 Strata 的力气几乎全在维度 C 上。它不是"又一个更快的前缀缓存",而是把 KV 从显存里抬出去、并在抬出去之后让 GPU 别闲着。

一句话概括三家的分工:

vLLM 在维度 B 上做了一个偏"粗粒度但便宜"的取舍;SGLang 在维度 B 上做了一个偏"细粒度但贵"的取舍;Strata 干脆换了维度,去做 C。

第三、四章分别展开 B 和 C。


三、维度 B:怎么共享------两家在这里分了岔

维度 B 要解决的是:多个请求带同一段前缀时,怎么让后来的请求不再重算。

这一章是两家分歧最大、也最容易被比错的地方。本章的结论来自【源码】与【实测】。

3.1 SGLang 的答案:基数树(RadixAttention)

SGLang 的做法是把所有请求的 token 序列组织成一棵基数树(radix tree),每个节点代表一段被共享的 token 序列。新请求进来时,沿着树往下匹配,能匹配到多长就把那部分的 KV 直接复用,剩下的部分才计算,算完再挂回树上。

几个实测确认的细节:

项目 实测值 取证方式
默认开关 默认就是开的 --disable-radix-cache 才能关;启动参数 dump 里 disable_radix_cache: False
淘汰策略 lru 启动参数 dump 里 radix_eviction_policy: 'lru',可选 lfu
匹配粒度 单 token page_size: 1,KV 池按单 token 分页
实现类名 UnifiedRadixCache + RustUnifiedTreeCore 启动日志里写的就是这个名字

那一行值得单独说一句:不要按文档和博客里的旧名字去找 SGLang 的前缀缓存实现 。它在容器启动日志里给自己的名字是 UnifiedRadixCache,底层核心已经换成 Rust 实现(RustUnifiedTreeCore)。以你机器上的日志为准。

3.2 共享的粒度:越细越好吗?

先给结论:不是。粒度是一个取舍,不是一道对错题。

SGLang(token 级) vLLM(16 token 块级)
匹配精度 高------只差 1 个 token 的前缀也能吃到前面那部分 低------不满一块的部分不算命中
管理开销 高------树的插入、分裂、合并、淘汰都要维护 低------哈希 + 字典,结构简单
实现复杂度 高(核心已用 Rust 重写,可作侧面印证) 低

把这张表读成"SGLang 更好"是误读。它多花的管理成本换来的是更细的匹配精度,而这份精度只在"前缀高度重复且对齐得很刁钻"的负载上才回本。

也正因为两家的匹配单位不同(16 token 对 1 token,差 16 倍),同一句"命中率"在两边指的东西本来就不一样。下一节先从 vLLM 的实现讲起。

3.3 同维度里 vLLM 怎么做:块级哈希,而且是链式的

SGLang 用基数树按 token 逐层匹配,vLLM 走的是另一条路:把 KV 切成固定大小的块,对每个块算哈希,而哈希里带上父块的哈希。

vLLM 0.28.0 里这个函数只有几行(vllm/v1/core/kv_cache_utils.py:577-604):

python 复制代码
def hash_block_tokens(
    hash_function: Callable[[Any], bytes],
    parent_block_hash: BlockHash | None,
    curr_block_token_ids: Sequence[int],
    extra_keys: tuple[Any, ...] | None = None,
) -> BlockHash:
    """Computes a hash value corresponding to the contents of a block and
    the contents of the preceding block(s). The hash value is used for
    prefix caching. ...

    The current block is assumed to be full.
    """
    if not parent_block_hash:
        parent_block_hash = NONE_HASH
    curr_block_token_ids_tuple = tuple(curr_block_token_ids)
    return BlockHash(
        hash_function((parent_block_hash, curr_block_token_ids_tuple, extra_keys))
    )

四个细节决定了它的全部行为,而且都写在源码和注释里,不需要猜:

① 哈希是链式的。 哈希 = hash(父块哈希, 本块 token, extra_keys)。所以只要任何一个块 的内容不同,它自己的哈希变了,它后面所有块的哈希也跟着全变 ------哪怕后面那些块的 token 一模一样。"前缀必须从第一个 token 开始完全一致"不是实现偷懒,是链式哈希的数学后果:中间断一次,后面全废。

② 只对完整块算哈希。 docstring 原话:The current block is assumed to be full. 不足一整块的末尾那段不参与缓存键的计算。

③ 命中同样只能是完整块。 vllm/v1/core/kv_cache_manager.py:232-234:

python 复制代码
def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int, int]:
    """Get the computed (cached) blocks for the request.
    Note that the computed blocks must be full.

④ 块大小默认 16。 vllm/config/cache.py:48:

python 复制代码
DEFAULT_BLOCK_SIZE: ClassVar[int] = 16

(第 263-264 行在 block_size is None 时套用这个值。)

把①②③④拼起来,vLLM 侧的"前缀利用率"这个量的真实含义就清楚了:

它是"这一次请求有多少个完整块,能和历史请求逐块对齐"。

不是"有多少 token 重复",也不是"公共前缀有多长"。两个 4096 token 的请求,前 4088 个 token 完全相同、只有末尾 8 个不同:按 token 算重复率 99.8%,但按 16 token 的块算,第 256 个块已经把差异带进来了 ,而由①链式哈希,从这一块往后的哈希全部不同------能命中多少,取决于从 token 0 起连续一致的完整块有几个,剩下的一律不算。

避坑 :在 vLLM 上算前缀复用收益时,不要用"公共前缀 token 数 ÷ 总 token 数",要用 "公共前缀长度向下取整到 16 的倍数" ÷ 总 token 数;且一旦中间有任何一处差异,其后的共享全部作废,不要按"多处局部重复"累加。

3.4 同一个负载,两家报出的"命中率"不是一个量

先用同一台机器、同一个压测客户端、同一份"前缀重复"数据集,两边各跑一轮【实测】:

vLLM 0.28.0 SGLang 0.5.21
引擎自报的命中率 78.4% 94.1%
指标名 vllm:prefix_cache_hits_total ÷ queries_total sglang:cache_hit_rate
指标类型 counter(累计计数器) gauge(瞬时值)

差了将近 16 个百分点。如果不看定义,很容易得出"SGLang 的前缀缓存实现更好"------这个结论是错的,因为这两个数根本不是同一种统计。

两个数的来源与算法(都是原始证据文件,可复核): vLLM 侧取差分 ------results/metrics_vllm_prefix.txt 的 queries=1,916,417 / hits=314,384 减去 results/metrics_vllm_after.txt 的 queries=1,785,856 / hits=212,000,得本轮增量 queries=130,561 / hits=102,384,102,384 ÷ 130,561 = 78.42%。 注意:直接读前缀轮快照的绝对值会得到 314,384 ÷ 1,916,417 = 16.4%,那是错的 ------它包含了前面四轮随机压测的累计量。 SGLang 侧直接读 results/metrics_sglang_prefix.txt 的 sglang:cache_hit_rate = 0.9411764705882353,即 94.1%(gauge 按上报语义,无需差分)。 这一条对照本身就说明了本节要讲的事:同一个量,一边必须差分、一边不能差分。

口径 A:vLLM 报的是"累计计数器",你必须自己差分

vLLM 导出的是两个单调递增的计数器(vllm/v1/metrics/loggers.py:584-601 定义,:1125-1130 从 scheduler_stats.prefix_cache_stats 累加)。它们的语义,源码里写得很具体。

分母是整个请求的输入 token 数 ,不是"被查询的块数"(vllm/v1/core/kv_cache_manager.py:221-230):

python 复制代码
def record_prefix_cache_stats(self, request, num_hits):
    num_tokens = request.num_tokens   # 整个请求长度,含末尾那段不足整块的
    self.prefix_cache_stats.record(num_tokens=num_tokens, num_hits=num_hits, ...)

分子则只能是完整块的命中 (见 3.3 中 get_computed_blocks 的 docstring)。而 vLLM 默认块大小是 16------分子只数 16 token 的完整块,分母却是请求的全部 token,这个比值天然被轻微低估,每个请求最多差 15 个 token。

还有两条容易踩的:

  • 只在准入时记录一次。 vllm/v1/core/sched/scheduler.py:1076-1080 注释原文:# Record at admission so unscheduled lookups are not counted.
  • 被抢占的请求不进这两个计数。 vllm/v1/metrics/stats.py:114-142 把 preempted=True 的样本记进 preempted_queries / preempted_hits,不写 queries / hits。

既然是计数器,正确读法只有一种:跑完的值减跑前的值 。直接读绝对值,你拿到的是"进程启动至今的累计量",跟本次压测无关。本节开头那个 16.4% 就是这么来的------一个纯粹由读法造成的错误数字。

口径 B:SGLang 报的是"单次上报的瞬时值",而且空闲会衰减

SGLang 导出的是 gauge(python/sglang/srt/observability/metrics_collector.py:305-306),它的语义在源码里有注释兜底(python/sglang/srt/managers/scheduler_components/metrics_reporter.py:1040):

bash 复制代码
# cache_hit_rate is prefill-owned (per-report semantics)

公式在 metrics_reporter.py:782-791,注意它显式扣掉了"重算"的部分:

python 复制代码
effective_input_tokens = prefill_stats.log_input_tokens - prefill_stats.reprocessed_log_input_tokens
effective_hit_tokens   = prefill_stats.log_hit_tokens   - prefill_stats.reprocessed_log_hit_tokens
total_tokens   = effective_input_tokens + effective_hit_tokens
cache_hit_rate = effective_hit_tokens / total_tokens if total_tokens > 0 else 0.0

这一步最要命(metrics_reporter.py:823 注释原文):

bash 复制代码
# the gauge must decay rather than hold.

空闲要衰减,不能挂住。 也就是说:这个数字反映的是"最近一次上报周期"的命中情况,不是"从启动到现在"。实测中,纯随机负载下它读数是 0.0;而"前缀重复"数据集刚跑完读数是 0.9412。

还有一个同名但不同用途 的量要分清:metrics_reporter.py:55 定义了 CACHE_HIT_RATE_WINDOW_SECONDS,:65-83 的 _CacheHitRateWindow(deque + 过期 popleft)算出 recent_cache_hit_rate,它是给路由和负载均衡决策用的 (managers/load_inquirer.py:83:cache_miss_rate = 1.0 - self.get_recent_cache_hit_rate()),不是 Prometheus 上那个 sglang:cache_hit_rate。

为什么这两个数不可对撞
维度 vLLM SGLang
类型 counter(累计) gauge(单次上报瞬时值)
空闲时 计数不变,等你差分 衰减(源码原话 decay rather than hold)
分母 请求全部输入 token(含不满整块) input + hit,已扣除重算
抢占 / 重算 记入 preempted_*,不进导出的计数 从分子分母同时减掉
匹配粒度 16 token 的完整块 (block_size=16) 本次实测 page_size=1,单 token 分页

三条差异各自独立,任意一条都足以让两个数无法直接比较。粒度那一行尤其要注意:同一段"只差末尾几个 token"的公共前缀,两边损失不一样,这不是实现优劣,是取舍不同。

避坑 :SGLang 的 page_size 自 0.5.21 起可以按模型族 override (srt/arg_groups/overrides.py 里出现过 64 与 128)。本文的 page_size=1 是本次这个模型 + 这套参数下的实测值,换模型必须重新确认,不要当成恒定默认值引用。

一个反面教材:纯随机负载怎么会报出 11.9% 命中率?

第一次扫描时,我在纯随机 数据集上看到 vLLM 报出 11.9% 的前缀缓存命中率(212,000 / 1,785,856)。纯随机生成的 prompt 之间不可能有公共前缀,这个数字本身就该报警。

根因不在引擎,在压测方法 :vllm bench serve 的随机数据集是可复现 的,且 --seed 默认 0(vllm/benchmarks/datasets/datasets.py:1608)。同一个随机种子必然生成同一批 prompt(datasets.py:567-568 的 token 序列是 (offset + index + arange(input_len)) % vocab_size,offset 来自 np.random.default_rng(random_seed))。而服务端的前缀缓存跨轮次不重置,于是后面的轮次命中了前面轮次留下的 KV。

我在当初那台压测客户端里直接实例化了同一个 RandomDataset 做验证:同一个 num_prompts 跑两次,prompt 序列逐 token 完全相同。

对账(按 16 token 完整块模拟,缓存容量取实测的 194,896 token):

轮次 并发 预测命中 token 占该轮输入
第 1 轮 1 0 0%(冷启动)
第 2 轮 16 81,840 62%
第 3 轮 64 130,944 25%
第 4 轮 128 ~0 ~0%(第 3 轮插入 524,288 token,把池子冲干净了)
合计 212,784 实测 212,000,差 0.37%

也就是说:那次扫描的四个点里,只有并发 1 和并发 128 是干净的,并发 16 有 62% 的输入吃了缓存、并发 64 有 25%。这直接决定了第八章引用那批数字时必须带的偏差标注。

vLLM 官方基准工具正是这么防的 :vllm/benchmarks/sweep/server.py:15-19 定义了 VLLM_RESET_CACHE_ENDPOINTS = ["/reset_prefix_cache", "/reset_mm_cache", "/reset_encoder_cache"],:121-131 的 reset_caches() 在每轮压测后逐个 POST;如果服务器不是 vLLM,它直接抛 NotImplementedError,要求你自己用 --after-bench-cmd 补上。官方的立场很清楚:轮次之间不重置缓存,数据不可比。

SGLang 侧的对应端点是 /flush_cache,官方还给了 CLI:

bash 复制代码
python3 -m sglang.srt.mem_cache.flush_cache --url http://localhost:30000

(python/sglang/srt/mem_cache/flush_cache.py:20,32)

避坑 :多轮压测扫描时,每一轮之间必须重置缓存 (vLLM:POST /reset_prefix_cache;SGLang:POST /flush_cache),并且给每一轮换一个 --seed。两条都做才算干净。只做一条会有残留:只换 seed 不重置,池里的旧块还在;只重置不换 seed,prompt 仍是同一批(只是这一轮自己冷启动了)。

本节结论

引擎自报的"缓存命中率"不是一个可以横向比较的量。 口径不同(累计 vs 瞬时)、分母不同(全部 token vs 扣重算)、粒度不同(16 token vs 1 token),三个差异叠加,数值放在一起没有意义。

需要横向比较时,统一改用外部可观测指标:TTFT、端到端时延、吞吐。这些量由压测客户端独立测量,不依赖引擎自己的统计口径。


四、维度 C:怎么放------论文版 Strata 换了一个维度

本章证据说明 :除 4.4 节的 SGLang 参数(【实测】,取自本机 --help 与启动 dump)外,本章的性能数字(22%、50 GB/s、5 倍 TTFT 等)均为 【论文】 数据,我没有复现 。4.2 节与 4.5 节中基于论文平台带宽的换算,是类比推算,不是本机实测。

4.1 维度 C 要解决的是容量,不是速度

维度 A 和 B 都在同一级内存内部 做文章:A 让每个 token 占得更少,B 让同样的前缀不用重算。维度 C 换了一个问题:显存装不下的那部分 KV,放到下一级去。

这笔账的物理事实很简单:搬出去省的是容量,付出的是带宽和延迟。

论文给了一个很直观的容量账:40 GB 的 HBM,对 Llama-8B 只能装大约 0.3M token(arXiv:2508.18572 第 2 节)。也就是说几十份长文档、几百轮对话就能把它吃干净。

而"搬"这件事如果不解决,后果不是变慢一点,而是变得更慢 :论文 Figure 1 展示的是把 KV 卸载到 CPU 内存后,TTFT 被直接抬高------加载本身成了瓶颈 。这就是维度 C 的全部难点:它不是"能不能搬",而是"搬的时候 GPU 别闲着"。

4.2 先看一个残酷的数字:22%

论文第 3.1 节的标题就叫"KV Cache 传输中的低带宽利用率"。里面的实测结论是:

传输 8192 个 token 的 KV 数据,只能跑到理论 PCIe 5.0 带宽的约 22%(page size 设为 32,这是前人分级 KV 缓存工作推荐的值)。

原因在碎片化:KV 在显存里往往是非连续页,导致单次传输只有几 KB,撑不满 PCIe 。论文给的另一个方向性结论是:想跑到理论带宽的 75--80%,单次传输量得做到 1--2 MB 级别。

把这个数字套到 Qwen2.5-7B 上做一个类比推算 (用的是论文平台的带宽,不是我本机 RTX 5090 的实测值):

text 复制代码
Qwen2.5-7B 的 KV = 57,344 B/token(第五章会算)
8192 token 的 KV = 8192 × 57,344 B ≈ 448 MiB

论文平台单卡 PCIe 5.0 x16 峰值 = 64 GB/s(论文平台参数)
  跑到 22% → 14.1 GB/s → 448 MiB 需要约 33 ms
  跑到 75% → 48 GB/s   → 448 MiB 需要约 9.7 ms

同一块数据,仅仅因为"传得整不整",差 3 倍以上。 而这 30 毫秒级的差异,在 TTFT 里是直接可见的------尤其当你要为每个新请求搬一遍上下文的时候。

论文还给了理论工具:按 Little 定律,并发度 C = λ · L,而吞吐 X = λ · S(S 是单次 I/O 的平均数据量)。两式合并说明:要维持吞吐,就得把 S 做大,或者把并发度做高。 碎片化刚好把 S 压到了最小。

4.3 Strata 的四个机制

论文摘要里一句话概括了它的做法(原文):

Strata introduces GPU-assisted I/O to combat KV cache fragmentation, decoupling GPU and CPU memory layouts and employs cache-aware request scheduling to balance compute with I/O latency and overlapping unavoidable stalls with complementary tasks. Built on SGLang...

拆成四条:

机制 解决什么 论文位置与关键数字
GPU 辅助 I/O 代替反复调用小粒度的 cudaMemcpyAsync,用 GPU 自己来搬,专治碎片化小传输 §4.2:只用 2 个 CUDA block × 1024 线程 ,达到接近 50 GB/s 的传输吞吐,而 prefill 性能损失 不到 5% 、decode 损失 10%
布局解耦 GPU 里保持算得快、宿主/磁盘上保持搬得快 §4.2.1、§5.3.4:GPU 侧保留 layer-first (贴合逐层计算),宿主内存与外部存储改用 page-first(大块连续、传输效率高)
缓存感知调度 让计算与 I/O 重叠,而不是排队等 §4.3:含延迟命中时的让路(4.3.1 Deferral on Delay Hit)、均衡批次构造(4.3.2 Balanced Batch Formation)
气泡填充 把"不可避免的加载停顿"用别的任务填满 §4.3.3 Hide Loading Stall with Bubble Filling

第三条和第四条值得单独强调:前面两条是把 I/O 做快 ,后面两条是在 I/O 快不了的时候别浪费 GPU。真正做过长上下文服务的人会知道,后半句往往比前半句更难------因为调度器必须知道"哪块 KV 在显存里、哪块在下一级、这次请求要等多久"。

4.4 维度 C 早就被工程化了:看 SGLang 的 HiCache

必须说清一点:"把 KV 分级"不是论文的发明,工程上早就在做了。 本文实测的 SGLang 0.5.21 里就有一整套 HiCache 参数,其中内存布局这一项直接就有五个取值:

text 复制代码
--hicache-mem-layout {layer_first,page_first,page_first_direct,page_first_kv_split,page_head}

(取自本文环境里的 sglang.launch_server --help 实际输出。)

而本次实测的启动参数 dump 里,它是 hicache_mem_layout: 'page_first'------默认就选了"搬得快"的那一侧。

把论文和工程参数对照一下,会看到同一件事的两种说法:

论文里的机制 SGLang 里的对应物
布局解耦 --hicache-mem-layout layer_first / page_first / page_first_kv_split / ...
分级下沉到宿主 HiCache 的 host 层参数家族
让计算与 I/O 重叠 --enable-overlap-schedule(实测默认开)

所以论文版 Strata 的价值不在于"发明了分级",而在于把"为什么不快、怎样才能快"量化清楚了------22% 那个数字、1--2 MB 的传输量门槛、以及把加载停顿显式当成一个调度对象来处理。这是工程实现里通常不会写进文档的部分。

4.5 但维度 C 有硬件前提

论文的两套实验平台,带宽差一个量级【论文】:

平台 内存/链路 论文原文给出的带宽
PCIe 平台 每张 GPU 通过 PCIe 5.0 x16 连 CPU 64 GB/s(单向峰值)
GH200 H100 + Grace 64 核 ARM,464GB LPDDR5X 384 GB/s(单向)

按这个换算搬 1 GiB KV 的时间(数量级,同样是类比推算):

text 复制代码
1 GiB ÷ 384 GB/s ≈ 2.8 ms     ← GH200 的 CPU 内存
1 GiB ÷  64 GB/s ≈ 16.8 ms    ← 普通 PCIe 平台

差 6 倍。所以论文那个"TTFT 最多降低 5 倍"(对比 vLLM + LMCache)是在它那套硬件 + 长上下文负载 下测出来的,不能直接搬到单卡消费级平台上期待同样的倍数。

这也解释了为什么维度 C 在个人开发者眼里常常"听着很美、用起来不划算":它的收益上限由你的内存带宽和 KV 体量共同决定。 而 KV 有多大,正好是下一章的内容。

本文的一个局限:维度 C 这一章,我没有做自己的分级缓存 TTFT 实测,结论依赖论文数据与参数/源码核对。


五、KV 到底有多大?决定「要不要搬」的是模型架构,不是引擎

谈了一整章「KV 可以搬出 GPU」,但有个问题得先回答:这块 KV,值得搬吗?

答案不在引擎里,在模型架构里。KV 的大小可以用一个公式直接算出来:
每 token KV 字节数= 2(K+V) ×全注意力层数×KV 头数×head_dim×每元素字节数 \text{每 token KV 字节数} = 2_{(K+V)} \times \text{全注意力层数} \times \text{KV 头数} \times \text{head\_dim} \times \text{每元素字节数} 每 token KV 字节数=2(K+V)×全注意力层数×KV 头数×head_dim×每元素字节数

把两个模型代进去,差别会比直觉大。

5.1 两个模型,同一台机器,同一个公式

项 Qwen2.5-7B-Instruct Qwen3.8-Flash-Next (IQ3_S)
类型 Dense MoE,125B 级 / 24,576 experts
全注意力层数 28 / 28 12 / 48(其余 36 层为 GDN)
KV 头数 4 2
head_dim 128 256
KV 精度 bf16(2 字节) int8(1 字节)
公式值 57,344 B = 56.0 KiB 12,288 B = 12.0 KiB
实测口径 56.0 KiB/token 20.3 KiB/token
数据来源 vLLM 10.41 GiB ÷ 194,896 token = 57,352 B(偏差 0.014%);SGLang 11.2534 GiB ÷ 210,714 token = 57,344 B(偏差 0.000%) docs/INTEL.md:81 实测约 20 KiB/token;32K→128K 显存增量 1.90 GiB ÷ 98,304 token = 20.27 KiB/token(docs/INTEL.md:84-89 表格差值:30.3 − 28.4)

两个数字都是反算出来的,不是抄文档:7B 那一侧用两个引擎自己的显存记账倒推,125B 这一侧用仓库实测的显存增量倒推,两条路互不依赖。

顺带一个单位坑:SGLang 的指标名是 kv_cache_memory_usage_gb,实际单位是 GiB。按 GB 算,公式对不上。

5.2 结论:大 17.9 倍的模型,KV 反而小 2.8 倍(统一精度后约 1.4--1.8 倍)

go 复制代码
模型规模比    125B ÷ 7B                    ≈ 17.9 倍
KV 每 token   20.3 KiB ÷ 56.0 KiB         ≈ 0.36  →  小 2.8 倍(bf16 对 int8 的口径)

口径提醒 :2.8 倍里有相当一部分是精度差(bf16 对 int8)贡献的。统一到同一精度后约为 1.4--1.8 倍,取决于 indexer keys 是否随精度变化,折算过程见 5.5。

参数量涨了 18 倍,KV 反而更小。这不反常识 ------ 它只是说明参数量和 KV 容量是两件不相干的事,前者由专家数量决定,后者由注意力结构的形状决定。

5.3 三个因素叠乘

125B 这一侧的 20.3 KiB 不是压缩出来的,是三层结构叠出来的:

因素 效果 出处
48 层里只有 12 层是全注意力 ,其余 36 层是 gated-delta-net,固定尺寸循环状态,不随上下文增长 只算 1/4 的层 docs/INTEL.md:78-79
GQA 48 : 2 = 24 : 1(48 个 query 头对 2 个 KV 头) 极端共享 setup5.log:266-270 张量形状:q_proj 12288 = 48 × 256,k_proj / v_proj 512 = 2 × 256
int8 KV(不是 fp16) 再省一半 运行配置 strata-iq3_s.json 的 --kv int8

前两项是训练时就定下的架构,改不了;第三项(精度)引擎可调,但代价直接体现在质量上。

5.4 那 7.5 KiB 的差额是 indexer 的 keys

按公式,125B 的 QSA 层只需要 12.75 KiB/token(含 q8_0 分块开销),但实测是 20.3 KiB。差的 7.5 KiB 有明确出处:

ini 复制代码
self_attn.indexer.index_qk_proj.weight   q8_0   640 × 2560
self_attn.indexer.k_layernorm.weight     f32      1 × 128
self_attn.indexer.q_layernorm.weight     f32      1 × 128
        → indexer head_dim = 128,640 = 5 × 128
        → indexer keys = 640 元素 × 12 层 = 7.5 KiB/token

12.75(QSA 的 K/V) + 7.5(indexer keys) = 20.25 KiB  ≈  实测 20.3 KiB

稀疏注意力的 indexer 需要自己一份 key 索引,这份索引跟着上下文一起长,所以它必须算进 KV 账里。差值闭合到 0.05 KiB。

5.5 口径声明

必须说明 内容
精度不同 7B 是 bf16,125B 是 int8。把两边统一到同一精度,倍数从 2.8 降到约 1.8 倍 (折算:QSA 的 K/V 脱掉 q8_0 分块开销按公式值 12.0 KiB 翻倍为 24.0,indexer keys 视为不随 --kv 变化仍为 7.5,合计 31.5 KiB,56.0 ÷ 31.5 ≈ 1.8)------ 2.8 倍里有相当一部分是精度差贡献的,架构差是剩下的那 1.8 倍。该折算依赖"indexer keys 不随精度翻倍"这一假设;若 indexer keys 也随精度翻倍(合计 40.5 KiB),倍数为 1.4 倍
架构不同 一个是 28/28 层全注意力的 Dense,一个是 12/48 层的混合架构。这正是本章的论点,不是缺陷
12.75 KiB 的地位 它只算 12 层 QSA 的 K/V,不含 indexer keys。引用时必须带上这句,否则会低估 37%
单引擎、单版本 7B 那两个数来自 vLLM 0.28.0 / SGLang 0.5.21;125B 那个数来自 Strata v0.1.40.2。换版本、换量化档都要重算
与第 3.4 节的口径 本章这两个数都是从显存记账反算 的(vLLM 的 KV 池容量、Strata 的显存增量),不是引擎自报的命中率,因此不受"两个口径不可对撞"影响。凡要引用命中率,请先看 3.4 节的口径定义(vLLM 是需差分的累计计数器,SGLang 是会衰减的瞬时 gauge,两者不可直接对比)

5.6 推论:值不值得搬,看模型

  • 7B 这类 Dense 模型:28 层全是全注意力、4 个 KV 头,KV 是货真价实的容量瓶颈 ------ 32K 上下文就要 1.75 GiB,128K 要 7 GiB。把 KV 搬出 GPU,省下的是实打实的显存
  • 12/48 层的混合 MoE :128K 上下文的 KV 一共约 2.5 GiB。搬它省不下多少,它的容量压力在权重那一侧

这就是为什么「KV 能不能离开 GPU」这个问题的答案,取决于你先问「这是哪个模型」。


六、延伸:同一个「分层」思想,单机端的对象换成了权重

本章是主线之外的延伸,讲的是另一个同名项目。只关心三维度的读者可以直接跳到第七章。

第五章算出了单机模型的 KV 只有约 2.5 GiB。那这台机器的显存到底被什么吃掉的?

答案把「分层」这件事从 KV 拉到了另一个对象上。

6.1 先把两个同名项目分掉

本文提到「Strata」时,指的是两个同名、但不同源的项目:

Strata(服务器端) Strata(单机端)
载体 arXiv:2508.18572 / OSDI '26 正式论文 github.com/Niko1221/Strata
定位 长上下文 KV 分级缓存 单机跑 125B 级 MoE
底座 built on SGLang llama.cpp / ggml 血统
分层的对象 KV 专家权重 + KV

两者无代码、无论文、无血缘关联,只是都选了「Stratified / 分层」这个词。第四章讲的是左边那个,本章讲右边那个。

6.2 单机端的三层各装什么

层 装什么 出处
VRAM 热专家(11,754 个 / 22.30 GiB)+ 注意力与 GDN 层 + MTP 草稿层 + KV 最热的 32K 窗口 server.log:16
RAM 全部 24,576 个专家 (pinned,46.84 GiB);没上卡的专家由 CPU 就地计算,与 GPU 同时跑(AVX-512 / AVX2 kernel) server.log:15、docs/HOW_IT_WORKS.md:27-28
SSD 28.8 GB 的 n-gram 表 ------ 是权重,不是 KV docs/HOW_IT_WORKS.md:31

注意 SSD 那一行:单机端的第三层装的是权重表,不是 KV。这和服务器端的分层结构形状相同、对象不同。

6.3 为什么单机端分层的是权重

把全量账摊开(这台机器的 IQ3_S):

复制代码
磁盘总计  88.83 GB
  ├─ models 83.62 GB(shard1 54.82 + shard2 28.80)
  ├─ MTP     5.21 GB
  │
  ├─ 专家权重        50.30 GB(46.84 GiB)  → 常驻 RAM
  ├─ PLE / n-gram 表 28.80 GB               →  SSD
  └─ dense + MTP     约 9.7 GB             →  VRAM

对比一下同一次运行里的 KV:

对象 大小 比值
专家权重(RAM) 46.84 GiB 19 ×
128K 上下文的 KV 约 2.5 GiB 1 ×

19 倍。 所以单机端的主战场是权重,不是 KV。KV 确实也做了分级 ------ 从 64K 起只把最常读的部分留在显存、其余从 RAM 流式取(docs/HOW_IT_WORKS.md:23,对应宿主机 运行配置里的 --kv-resident 32768)------ 但它是顺手做的第二件事,不是这个引擎存在的理由。

容器与宿主机不是同一种行为,KV 数字不能混用 :上述 --kv-resident 32768 与「64K 起分级」来自宿主机 运行(strata-iq3_s.json:22-27;setup5.log:274 的 KV cache lives in RAM (1.8 GB))。在容器(WSL)里跑时,setup 打的是 WSL: KV streaming off (the driver pins only about 1 GB of RAM); the KV cache stays in VRAM,且 settings dump 里没有 --kv-resident ------ 容器里 KV 常驻显存、不做流式分级。本章的 2.5 GiB 与 1.8 GB 都取宿主机口径,引用容器数字时必须重新标注。
一个引用细节:引擎日志里的 46.84 GiB 是专家权重 (experts loaded),不是「125B 模型的全部参数」。把两者划等号会低估磁盘和显存账,也让「参数多大、KV 多大」的关系失真。

6.4 一条可迁移的经验

把两章放在一起,会看到同一个判断在两边都成立:

瓶颈在哪一层,就对哪一层做分层。

场景 容量瓶颈 分层的对象
服务器端多并发长上下文 KV 池被并发请求撑爆 KV(GPU → RAM → SSD)
单机端跑超大 MoE 24,576 个专家塞不进显存 专家权重(热的上卡,全量在 RAM,表在 SSD)

这也是为什么「分层缓存」不是一个特定引擎的发明,而是一个随瓶颈移动的手段。谁在读这篇文章、跑的是什么模型,答案就不同。


七、三个维度怎么选

三个维度不是三选一,而是三个可以叠加的旋钮 。但顺序有讲究:先省、再复用、才轮到扩容。

理由很实际:能不能装下是硬约束,装不下的部分再去复用也是白搭;而扩容(维度 C)要付出带宽代价,只有在 A 和 B 都做满之后才划算。

7.1 按症状找维度

你遇到的现象 瓶颈维度 先动什么
上下文一加长就 OOM,并发提不上去 A --kv-cache-dtype 降精度、确认块/页大小;先算清每 token 占多少(第五章)
显存够、并发也上去了,但 TTFT 高 B 前缀缓存是否开、前缀是否按块对齐(3.3 节)
预算够、显存够,就是装不下 128K C 分级(HiCache 一族参数);先确认内存带宽账(4.5 节)
长上下文首 token 特别慢,明显在"等加载" C 分级 + 调度重叠,而不是继续调并发
单机跑超大 MoE,显存被权重吃光 不在 KV 维度 那是权重分层,见第六章
引擎自报命中率低,但吞吐看着还行 先查口径 可能是口径问题,不是性能问题(3.4 节)

7.2 三个常见误判

误判一:把 A 的收益算成 B 的。

"我把 KV 精度降到 int8 了,为什么前缀缓存命中率一点没变?"

因为精度属于维度 A,命中率属于维度 B。降精度让每个 token 占得更少 (于是同样显存能装更多 token,并发上限提高),但它不会让两个请求的前缀变得更像。收益不会跨维度转移。

误判二:把 C 的需求当成 B 的需求。

"我装不下 128K,是不是前缀缓存没开好?"

前缀缓存省的是重复计算 ,不是容量。请求之间没有公共前缀时(比如每个用户问的都是全新的长文档),前缀缓存命中率接近 0,维度 B 一点忙都帮不上------这时候该做的是维度 C。

反过来也一样:如果请求高度重复(比如同一个长系统提示词加不同问题),维度 B 的收益可能远大于 C,而且便宜得多(不需要搬数据)。

误判三:用引擎自报的命中率做横向对比。

第三章已经算过这笔账:口径不同(累计对瞬时)、分母不同、粒度差 16 倍。要比就比外部可观测指标。

7.3 一条判据

如果只记一句话:

先问"省不省得下"(A),再问"要不要重算"(B),才轮到问"搬不搬得动"(C)。

省得下且不用重算,就没必要搬;省不下又必须支持长上下文,才轮到 C 出场。

7.4 避坑速查表

场景 坑 正确做法 详见
算 vLLM 前缀复用收益 用"公共前缀 token ÷ 总 token" 向下取整到 16 的倍数;中间一处不同,其后全作废 3.3
读 vLLM 命中率 直接读累计计数器的绝对值(得到 16.4%) 跑完的值减跑前的值 3.4
读 SGLang 命中率 当成累计值,或在空闲后读取 它是单次上报的瞬时 gauge,空闲会衰减 3.4
横向比较两个引擎 比引擎自报的命中率 比 TTFT、端到端时延、吞吐 3.4
多轮并发扫描 轮次之间不重置缓存,且 seed 不变 每轮 reset(/reset_prefix_cache / /flush_cache)并换 seed 3.4、8.3
SGLang 取指标 --enable-metrics 默认 False 显式打开 8.4
引用 page_size 把 1 当成恒定默认值 换模型必须重新确认 3.4
估算 KV 大小 只看公式、不看 indexer keys 把 indexer keys 算进去 5.4
引用日志行 直接引用二手日志 自己先 grep 一遍 8.4

八、实测边界:这次数据是怎么来的、哪里不干净

这一章交代本文所有实测数字的来源与限制。包括已知的偏差------因为一份不写边界的压测数据,比没有数据更危险。

8.1 环境(实跑取证)

项目 实测值 取证方式
GPU RTX 5090,32607 MiB,sm_120(Blackwell) nvidia-smi + torch.cuda.get_device_capability()
驱动 617.14 nvidia-smi
容器环境 Docker Desktop 4.47.0 / Engine 28.4.0 docker info
模型 Qwen2.5-7B-Instruct,bf16,28 层 / 4 个 KV 头(GQA) config.json
vLLM vllm/vllm-openai:v0.28.0-cu129 容器内 vllm.__version__
SGLang lmsysorg/sglang:latest,容器内实际是 v0.5.21 容器内 sglang.__version__

8.2 两边对齐的参数

维度 vLLM SGLang 取值
上下文长度 --max-model-len --context-length 32768
显存占用比例 --gpu-memory-utilization --mem-fraction-static 0.85
最大并发 --max-num-seqs --max-running-requests 128
单步 token 预算 --max-num-batched-tokens --max-prefill-tokens 8192
chunk 切分 (V1 默认开) --chunked-prefill-size 8192
前缀缓存 --enable-prefix-caching (Radix 默认开) 开
指标端点 /metrics 默认可用 --enable-metrics 开

8.3 已知偏差:四点扫描存在前缀缓存污染(必须标注)

本文引用的 vLLM / SGLang 四点并发扫描数据(并发 1 / 16 / 64 / 128),是在没有重置前缀缓存的情况下连续跑出来的,因此存在跨轮次污染:

轮次 并发 该轮输入中被缓存命中的比例 是否干净
第 1 轮 1 0% 干净
第 2 轮 16 62% 被污染
第 3 轮 64 25% 被污染
第 4 轮 128 ~0% 干净

原因在 3.4 节已交代:vllm bench serve 的随机数据集默认 --seed 0 且可复现,服务端前缀缓存跨轮次不重置,后一轮命中了前一轮留下的 KV(预测 212,784 对实测 212,000,差 0.37%)。

这个偏差对结论的影响,必须说清楚:

  • 不能用的 :那条"吞吐在并发 64 见顶、128 回落"的曲线形状。因为 64 那个点吃了 25% 的缓存红利,而 128 那个点没有------三个点不在同一个口径上,形状自然不可信。
  • 可以用的 :并发 1 与并发 128 这两个端点(0% 污染),以及两边的 KV 池容量、每 token KV 字节数这类与命中率无关的静态量。
  • 需要标注的 :凡本文出现"同负载""同一份负载"之类的表述,都指压测客户端、数据集、参数对齐 ,不表示缓存状态干净。

如果要把这条曲线重新做干净,正确做法是每一轮之间都做两件事(缺一不可):

bash 复制代码
# vLLM:每轮之间重置前缀缓存
curl -X POST http://vllm-host:8000/reset_prefix_cache

# SGLang:每轮之间刷缓存(官方也提供了 CLI)
python3 -m sglang.srt.mem_cache.flush_cache --url http://sglang-host:30000

外加给每一轮换一个 --seed。本文没有重跑这组数据,所以这里如实标注,而不是把它当成干净的曲线引用。

8.4 只有实跑才会踩到的坑

现象 实测 教训
vLLM 容器启动即崩 RuntimeError: UVA is not available WSL2 上 pinned memory 默认关,必须加 -e VLLM_WSL2_ENABLE_PIN_MEMORY=1
两个引擎同时起 32GB 卡放不下两份 0.85 显存配额 必须串行:一个压完停掉、显存回落,再起下一个
三个 GPU 租客同时在卡上 vLLM 等了 8 分钟 未就绪,最终 Exited(255) 起任何引擎前先 nvidia-smi 确认显存回落,否则白等
多轮压测没重置缓存 后续轮次命中率被前面轮次污染(最高 62%) 每轮之间重置缓存加换 seed,缺一不可
SGLang 指标端点取不到 --enable-metrics 默认 False 不显式打开,/metrics 里什么都没有
二手日志里的"KV 分级"那一行 在宿主机与容器两种运行的日志里全量 grep 均无命中 那行是伪日志 ;真实出处是运行配置与 setup 日志里的 KV cache lives in RAM (1.8 GB)。引用任何日志行之前先自己 grep 一遍
SGLang 的 page_size 本次实测为 1 0.5.21 起它可按模型族 override(出现 64 与 128),换模型必须重新确认

8.5 想复现或再往深挖

四条最低要求:

  1. 固定硬件与驱动,并在报告里写明(本文:RTX 5090 / 617.14 / sm_120)
  2. 两边参数对齐 (8.2 的表就是为此存在),尤其别漏 --enable-metrics 与并发上限
  3. 每一轮之间重置缓存加换 seed(8.3 的脚本)
  4. 写清口径:命中率一律注明是"累计差分"还是"单次上报瞬时值",两者不放在同一张表里比

九、总结

核心论点:

  1. vLLM、SGLang、Strata 优化的是 KV Cache 的三个不同维度(存 / 共享 / 放),收益不可互换。"优化了 KV 却不见 TTFT 下降",通常是维度错配,不是实现问题。
  2. 在"共享"这一维,两家取舍不同:vLLM 是 16 token 的链式块哈希(便宜、粗粒度),SGLang 是单 token 的基数树(精细、管理成本高)。这是取舍,不是优劣。Strata 则换到"放"这一维,用 GPU 辅助 I/O 和缓存感知调度压低搬数据的代价。

方法论提醒:

  1. 引擎自报的命中率不能横向比。78.4% 对 94.1% 不是 16 个百分点的差距,而是累计计数器与瞬时 gauge 两个不同的量。要比,就用 TTFT、端到端时延、吞吐。
  2. 本文四点并发扫描带有已知的前缀缓存污染(并发 16 有 62%、并发 64 有 25% 的输入命中了前一轮的缓存)。曲线形状不可用于结论,两个端点和静态量可用。

一句话判据: 先问"省不省得下"(A),再问"要不要重算"(B),才轮到问"搬不搬得动"(C)。

声明

本文数据来自本机实跑与镜像内源码核对,论文数据已单独标注、未复现。第八章标注的缓存污染是我自己测出的偏差,如实保留。文中两个 Strata 为同名不同源的项目,请注意区分。如有疑议,欢迎留言讨论。

相关推荐
a努力。1 小时前
MCP只连工具,不直接连技能
人工智能
neoooo1 小时前
🚀Claude Code 小技巧(一):一键进入危险模式
前端·人工智能·后端
阿里云大数据AI技术2 小时前
PAI 模型蒸馏全新发布「场景化蒸馏」:把最佳实践装进模板,让大模型能力"轻装上阵"
人工智能
亚川楼宇自控系统数据中心厂家2 小时前
机房 3D 可视化技术:从平面监控到立体化管理的升级
大数据·数据库·人工智能
LaughingZhu2 小时前
任务在飞书创建,开发在 Orca 继续:我把飞书项目接到了 Linear
大数据·人工智能·深度学习·神经网络·飞书
一水鉴天2 小时前
UML/MOF 元层级与 Model & Notation:整体设计核心术语的定稿探讨 20261012(豆包“查看聊天内容”继续)
人工智能·机器学习
陈嘿萌2 小时前
TIP 2026|没有分割标签,也能做任务导向图像融合:SSVIF的自监督语义注入
图像处理·人工智能·深度学习·计算机视觉·图像融合·学术研究与理论基础
森山冶仁2 小时前
把专家抽检变成分钟级全量初审:科学数据审核智能体的召回率与证据链怎么设计
人工智能·数据治理·数据质量·元数据
空 白II2 小时前
10.12 大语言模型研究简报:美国恢复英国对前沿AI模型的预发布安全测试合作
人工智能·语言模型·自然语言处理