AI 基建
0%
第五部分 · 推断与服务 · 第 35 章

结构化与长上下文推断

作者Changkun Ou
阅读时长约 17 分钟

服务引擎之上还有两个前几章没有解决的要求。一个调用方要求输出是合法 JSON,或匹配某个语法。另一个调用方带来的上下文,远长于缓存能轻松容纳的范围。二者都在推断时处理,不重训模型。结构化输出靠的是用有限状态机(FSM),也就是记录部分输出走到哪个语法状态的有限状态机,给 logits 做掩码,再用预计算索引和跳跃式前向,让这层掩码变得便宜。长上下文服务则要决定哪些键和值能留下,从滑动窗口、注意力汇(attention sink) 这种开头词元持续吸引注意力的现象,与重击者驱逐,到更新的查询感知策略。两种情形里,服务引擎都是通过约束解码循环,让固定模型服从调用方的约束。

2026-06-21T23:30:56.392869 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 0 20 40 60 80 100 上下文位置(%) 0.3 0.4 0.5 0.6 0.7 0.8 0.9 检索准确率 朴素注意力 结构化缓存
图 35.1. 长上下文中位置相关召回的示意图。普通注意力可能丢失中间证据,而结构化缓存与检索能把曲线拉平。理想化曲线,非实测。

让固定模型服从约束的两种方式

第 33 章 的解码器每次从整个词表上的分布里采样,逐一输出词元。两类调用方以不同方式给这个循环施压,而两者都不愿重训模型。

第一类调用方需要结构。一个调用模型去抽取字段、选择工具或填写表单的程序,需要输出能被解析:对照某个 schema 的合法 JSON、某个固定枚举里的取值、一条解析器能接受的 SQL 语句。无约束的模型产出的文本通常格式良好,但偶尔会失败,而这种偶发失败往往代价最高,因为下游程序无法从一个缺失的花括号、一个杜撰的字段名里恢复。提示与重试能降低失败率,却永远压不到零,而且每次重试都要付一整次生成。问题在于让畸形输出变成不可能,而非不太可能,且不重训模型,也不付高昂的每词元成本。

第二类调用方需要长度。一个请求可能携带一份长文档、一段数小时的转录,或一份无界增长的智能体历史,而 第 32 章 的 KV 缓存随这个长度线性增长。过了某个点,缓存就装不下了,或者只有把批大小压到吞吐崩溃的程度才装得下。更糟的是,一个在某个上下文长度上训练出来的模型,在推断时要去注意一段长得多的上下文时,往往在内存耗尽之前就已经退化。针对这一点的训练时解决办法,即通过 RoPE 缩放与 YaRN 扩展位置编码,留到 第 8 章 再谈。推断时的问题则不同:给定一个固定模型,和一段长到无法整体保留的上下文,该保留哪些键与值,又如何在丢弃其余部分的同时让生成保持连贯。

这两个问题表面上并不相关,但回答它们的方式都是介入解码循环,而非重训:一个在采样前给分布加掩码,另一个修剪注意力所读取的缓存。它们同处一章,因为这正是服务引擎让固定模型适配调用方约束的两种方式。下面两条脉络分开来走,每条都是一个序列,其中每一步都消去前一步所付的某项代价。

线索一:约束解码(constrained decoding),也就是带语法检查的解码循环

那层掩码,以及它为何不是一行代码

结构这一半的核心想法很小。在每个解码步,模型为词表中的每个词元产出一个 logit,也就是 softmax 之前的原始分数。如果已知在目标格式下接下来哪些词元合法,就在 softmax 之前把其余所有词元的 logit 置为负无穷,让它们概率为零、无法被采样。生成于是只走合法的续接,输出必定是该形式语言的一个成员。模型本身毫无改变,这次介入只是一层加在 logits 与采样器之间的掩码。

工作量在于知道每一步的合法集合。把目标格式表达成一种形式语言:电话号码或枚举用正则表达式,JSON 或某种编程语言用上下文无关文法。再把它编译成一台自动机,正则语言对应有限状态机,上下文无关语言对应下推自动机。自动机有一个当前状态,从这个状态出发,只有某些词元能让字符串留在语言之内。每一步的掩码,就是那批词表词元:自动机从当前状态出发,接受它们的首字符(或整个拼写)。采样出一个词元后,推进自动机,再重复。

L 整个词表上 的 logits M 掩码:FSM 状态 允许的词元 L->M S 从掩码后的 logits 采样 M->S T 词元 S->T A 把 FSM 推进 到下一状态 T->A A->M 下一步
图 35.2. 把受约束解码看作一层掩码。logits 被掩码到 FSM 从当前状态允许的词元,采样出的词元再把 FSM 推进。

试着把 allowed 改成另一组合法词元,看着被禁词元的概率被压到恰好为零,而合法词元的概率重新归一化。

import numpy as np
vocab = ['{', '}', '"', 'name', 'age', ':', ',', 'true', 'hello', '42']
logits = np.array([2.1, 0.3, 3.0, 1.2, 1.0, 0.5, 0.4, 0.8, 2.5, 1.1])
# FSM 恰好处于 '{' 之后:唯一合法的下一个词元是一个开引号。
allowed = {'"'}
mask = np.array([t in allowed for t in vocab])
masked = np.where(mask, logits, -np.inf)
def softmax(x):
    x = x - np.max(x[np.isfinite(x)])
    e = np.exp(np.where(np.isfinite(x), x, -np.inf))
    return e / e.sum()
p_free, p_masked = softmax(logits), softmax(masked)
for t, a, b in zip(vocab, p_free, p_masked):
    print(f'{t:>6}  unconstrained={a:.3f}  masked={b:.3f}')
print('masked mass on legal tokens:', round(p_masked[mask].sum(), 3))

真正让这件事成为一个工程问题、而不是一行代码的,是自动机与模型之间的错配。自动机定义在字符之上,而模型输出的是来自 第 7 章 的字节流的多字符片段,也就是词元。一个词元可能跨越多次自动机转移,而且从某个给定状态出发,只有一部分词元合法。直接计算合法词元集,意味着每一步都把自动机在数万个词表条目的每一个拼写上走一遍,这会压过解码本身的成本。于是这个想法的第一版实现正确但慢,是对照一台手写自动机做每步词表扫描,而这条脉络的历史,就是一连串消去那项代价的过程。

消去每步扫描:一张预计算索引

让受约束解码变得可行的设计,是在生成之前一次性预计算自动机状态与合法词元之间的关系,而不是在每一步算。Willard 与 Louf 的表述,即 Outlines 库的基础,构建了一个以自动机状态为键、覆盖词表的索引:对有限状态机的每个状态,记下从它出发合法的那批词元,以及每个这样的词元会引向哪个状态 (Willard and Louf 2023)。有了这个索引,每步的工作就是按当前状态做一次字典查找,而不是在词表上扫描,这把每词元的开销从正比于词表大小,变成大致常数。索引由正则表达式与分词器一次性构建,供每个共享该格式的请求复用。2023 年的这次跃迁,正是让引导式生成便宜到可以作默认的原因。

消去被强制的词元:在压缩自动机上向前跳

即便有了常数时间的掩码,第二项代价仍在,因为受约束输出的大段内容是被强制的。在 {"name": " 内部,接下来的若干字符不是选择,而是语法所允许的唯一续接,直到取值开始。逐词元的循环仍会为这些被强制的字符各跑一次完整前向传播,付出解码成本,去生成确定无疑的文本。SGLang 引入的压缩有限状态机,把自动机中成串的单一转移边折叠成一条边,让运行时能识别出一段被强制的跨度并直接输出它 (Yin et al. 2024)。这就是跳跃式前向解码:当自动机只有一条前向通路时,把那些词元直接写入序列,无需调用模型,然后在格式分叉处恢复采样。模型只在输出确实未定之处才被调用。

图 35.3 对比了两种情形。两个分叉点之间的被强制跨度,在压缩自动机里是一条边,一步写出,而模型只在语法留下真正选择的那些点上运行。

B1 分叉点: 模型采样一个键 F1 被强制跨度 引号、冒号、引号 直接输出,不调用模型 B1->F1 B2 分叉点: 模型采样一个取值 F1->B2 F2 被强制跨度 引号、逗号 直接输出,不调用模型 B2->F2 B3 分叉点: 下一个键或闭合 F2->B3
图 35.3. 在压缩 FSM 上的跳跃式前向解码。被强制跨度折叠成单条边、无需调用模型即可输出,依据 Yin et al.(2024)。

消去语法开销:与前向传播重叠

预计算索引与跳跃式前向都针对正则语言。剩下这一步,把掩码从正则语言推广到上下文无关语言,同时让每步成本接近于零。上下文无关文法需要一个栈,而不只是一个状态,于是合法词元集取决于当前栈,这看似挫败了预计算。XGrammar 的洞见是把词表切成两类:一类词元的合法性不依赖栈,可以一次性检查并缓存;另一类更小,是依赖上下文的词元,必须在运行时对照活动栈来检查 (Dong et al. 2024)。借助一个持久栈结构,再把语法计算与模型在加速器上的前向传播相重叠,引擎就能以接近无约束解码的速度,驱动一个完整的语法约束。这条脉络的整体走势,是把约束从每步的词表扫描降到一张预计算表,再降到被跳过的词元,最后降到一个与前向传播重叠的语法引擎。

线索二:修剪缓存

注意力是集中的,于是多数键都在占用显存

长上下文这一半立足于另一项观察。注意力并不对所有过去词元等权。对多数注意力头而言,概率质量集中在一小撮位置上:最近的若干词元,外加少数几个特定的更早词元。如果多数键与值几乎不被注意,把它们全部保留,就是在为几乎无贡献的词元付全额内存。做法是只保留要紧的键与值,让注意力在这个缩减后的集合上运行,用一种受控的近似,换得一个不再无界增长的缓存。第一版规则很粗,而这条脉络的历史,就是一连串让它变得更细的过程。

粗粒度规则,以及丢弃前缀的失败

最简单的保留规则是滑动窗口:只保留最后 ww 个词元,丢弃更老的一切。这把缓存限定为常数大小,也契合局部注意力模型本来的行为。但朴素的滑动窗口会以一种具体而富于启发的方式失败。当最早的那批词元落出窗口时,困惑度(衡量模型对留存文本预测得有多好,越低越好)骤升、生成急剧退化,远超丢弃不重要词元本应付出的代价。

解释是注意力汇,这是 StreamingLLM 的核心发现 (Xiao et al. 2024)。把注意力分数变成权重的 softmax,强迫这些权重求和为一,于是一个头总要把整整一份注意力分摊到它能看到的词元上,哪怕没有一个值得注意。当一个头没有信息丰富的地方可注意时,就会把这份无从回避的余量倾倒到序列最初的几个词元上,于是这些词元无论内容如何都获得很大的分数。这些初始词元充当一个汇。像朴素滑动窗口那样把它们丢掉,每个头就失去了停放余量注意力的地方,分布随之失稳。修正方法是永久保留少数几个初始词元,也就是那些汇,连同最近词元的滑动窗口。配上四个汇词元外加一个最近窗口,一个在固定长度上训练的模型,就能在数百万词元上流式运行而困惑度稳定,无需任何微调 (Xiao et al. 2024)。

图 35.4. 一个 8 词元窗口上的注意力。行是查询,列是键,越深表示注意力越多。每个查询都在最近对角线之外,把余量注意力停放到首个键列(词元1,即汇)上;朴素滑动窗口一旦逐出词元1,正好抽掉了这个停放列,分布随之失稳、困惑度骤升。示意性权重,并非来自训练好的模型。
cluster_keep 留在缓存中的 cluster_drop 被丢弃的 sink 汇词元 (最初几个) attn 注意力 sink->attn 保留 win 最近窗口 (最后 w 个词元) win->attn 保留 mid 中段词元 X x mid->X 驱逐
图 35.5. 汇加窗口的缓存。少数几个初始汇词元与一个最近窗口留在缓存中并供给注意力,中段词元被驱逐。

让内容来决定:按注意力历史驱逐

汇加窗口是一条忽略内容的固定规则,而一份长文档的中段,可能恰好包含答案所需的那个事实。内容感知的压缩,在保持缓存小巧的同时,让哪些词元存活取决于输入。H2O 把缓存重构为一个驱逐问题,保留重击者,也就是迄今累积了最多注意力的那些词元,连同最近的词元一并保留,其余驱逐 (Zhang et al. 2023)。SnapKV 针对提示给出一个更具体的观察:每个头一致地注意于特定的提示位置,而这些位置可以从提示末尾一个小观察窗口里的注意力中识别出来,于是提示其余部分的缓存,在生成开始之前就被移除 (Li et al. 2024)。两者都保持一个预算大小的缓存,其内容由模型自身的注意力来选定,而不只由位置决定。

这两段演化线索可以按时间读:窗口注意力与重算基线限定了内存,却在丢弃前缀时失败;H2O 在 2023 年按累积注意力保留重击者 (Zhang et al. 2023);同样在 2023 年的 StreamingLLM 解释了朴素窗口为何失败,并以注意力汇修正它 (Xiao et al. 2024);SnapKV 在 2024 年用提示末尾的观察窗口,把压缩推进到提示本身 (Li et al. 2024)。前沿继续朝查询感知与习得式驱逐演进,根据当前生成实际在注意什么,来决定保留什么。把这一步走到最彻底,就是干脆不再驱逐:Quest 保留完整缓存,用每页键的逐通道极值做摘要,每次查询只取回与之最相关的前 kk 页 (Tang et al. 2024)。选择改变的是失败模式,因为在多轮使用里,要紧的词元随每次查询而变:被驱逐的词元回不来了,未被选中的词元只是变冷,下一步仍可取回,或者先放进 第 32 章 的多级缓存,等哪次查询需要时再调出。一项相关进展是在训练时就把汇设计进去:模型可以带一个专用汇词元来训练,让注意力停放显式而非涌现,从而让推断时的保留规则更清楚。同样的事也发生在稀疏性本身:可原生训练的稀疏注意力,即 DeepSeek 在 V3.2 中随长上下文 API 降价一并交付的 NSA 这条线,让模型学会只在选出的子集上做注意力,把本章的近似变成一种架构保证 (Yuan et al. 2025)。那个训练时的选择,第 8 章 会展开,本章承接它的后果。

两条路线放在一起

图 35.6 把两条路线放在一起。每一步都在消去前一步所付的某项代价:结构化这条线把约束从每步的词表扫描降到一张预计算表,再降到被跳过的词元,最后降到一个与前向传播重叠的语法引擎;长上下文这条线,则从一个会失效的纯位置窗口,走到保留少数几个汇,再走到按注意力来选择保留什么。

lineage cluster_struct 结构化解码 cluster_ctx 长上下文保留 s1 对照手写自动机 做 logits 掩码(慢扫描) s2 Willard 与 Louf 索引 2023 预计算表 s1->s2 s3 SGLang 压缩 FSM 2024 跳跃式前向 s2->s3 s4 XGrammar 2024 语法引擎 与前向传播重叠 s3->s4 c1 窗口注意力 在丢弃前缀上 失败 c2 H2O 2023 按累积注意力 驱逐 c1->c2 c3 StreamingLLM 2023 保留注意力汇 加窗口 c2->c3 c4 SnapKV 2024 用观察窗口 压缩提示 c3->c4
图 35.6. 两条并行的演化线索:约束解码循环,与修剪缓存。每一步都消去前一步所付的某项代价。

两条线索的交汇

两条路线最后碰到同一个问题:每一条都通过接受一种近似来改变固定模型的行为,而开放的问题是这种近似的代价有多大。对结构化这条线,代价大体有界且已知;对长上下文这条线,则尚无定论。

争议所在

KV 缓存压缩用内存换取对完整注意力的一种近似,而这种近似的代价有多大,尚无定论。在许多长上下文基准上,汇加窗口与重击者驱逐,以零头内存就恢复了大部分质量。但被丢弃的词元已经没了,一个稍后需要某个被驱逐词元的任务,无论它需要的是埋在长文档中段的一个事实,还是早早只陈述过一次的一条约束,都无法把它找回。这个惩罚的大小,以一种聚合困惑度所掩盖的方式依赖于任务:流式语言建模对驱逐颇为容忍,而长上下文检索与多跳推理则敏感得多。对某个给定工作负载而言,固定预算压缩是否安全、还是模型需要整个缓存,是一个逐任务的经验问题,而非一个已定的默认。查询感知与习得式驱逐策略意在收窄这个差距,而在保留完整缓存之上做选择(Quest)则直接为其设了上界:词元只被降级,不被删除,代价是要把完整缓存放进某个更便宜的层级 (Tang et al. 2024)。

下层约束

第 32 章 的缓存布局,决定了本章的保留规则能多便宜地运行。分页式的键值存储,把驱逐变成释放若干块、改写一张块表的事,把汇加窗口变成固定汇块、回收其余块的事,而非拷贝一段连续缓冲。第 8 章 中注意力变体所设定的每词元缓存大小,无论是分组查询注意力还是潜在注意力,定下了压缩起步的基线内存,于是一个本就在键值头上节俭的模型,只需更温和的驱逐就能装下。一个服务引擎所能负担的压缩策略,受下一层的内存管理器塑造。

每一步动作的代价

每项技术都以一个明确的代价,换得它的好处。

  • 约束与流畅。 掩码保证输出能被解析,但它可能把模型逼上一条它自己分布并不偏好的路,从而降低结构内部内容的质量。一个过紧的约束,一个过度规定的 schema,能把模型推入局部合法但全局别扭的文本,因为掩码移除了模型想要的那个词元,却没有移除它想要的缘由。约束应在正确性允许的限度内尽可能松。
  • 索引构建与复用。 预计算索引要付一次前置成本,把正则表达式或语法对照分词器编译出来 (Willard and Louf 2023)。对一个被许多请求共用的格式,这摊薄到几乎为零;但对一个每请求即时生成的一次性 schema,构建可能与它省下的生成不相上下。引擎正因如此而按格式缓存已编译的语法。
  • 跳跃式前向的正确性。 不调用模型就输出被强制的词元,只有在自动机确实只有一条续接时才安全,而分词边界使之复杂:被跳过的文本必须以模型本会产出的同一方式重新分词,否则缓存与输出会发散 (Yin et al. 2024)。加速是真的,但边界处理正是最容易出 bug 的地方。
  • 压缩预算与召回。 更小的缓存预算省下更多内存、容纳更多并发请求,却提高了某个被驱逐词元恰是答案所需的概率。预算就是吞吐与长程事实召回之间的控制参数,而安全的设定因工作负载而异。
图 35.7. 示意而非实测:恢复的质量随缓存预算的第一段急升而后趋平,于是零头内存就恢复了大部分质量,而敏感度参数决定一个检索密集型任务为被驱逐的词元付出代价有多快。
  • 汇的数量与窗口大小。 汇词元太少,停放注意力的作用就会失效;窗口太大,省下的内存就缩水。已发表的那些小默认值,如少量汇词元与上千量级的窗口,是起点而非常数 (Xiao et al. 2024)。

把它放进引擎

一个受约束解码器,是挂进采样器的一层掩码。引擎把格式一次性编译成自动机及其索引,然后在每一步按当前状态查出合法词元集,把其余每个 logit 置为负无穷,采样,再推进自动机。在 vLLM 中,这是采样路径内调用的一个 logits 处理器,由一个结构化输出后端撑起,例如 XGrammar 或 Outlines,由它持有语法与索引。启用跳跃式前向后,运行时在每次模型调用前查压缩自动机:若当前状态有唯一的被强制续接,就直接追加那些词元,只在格式分叉时才调用模型。语法引擎与前向传播并行运行,使掩码在 logits 就绪时也已就绪 (Dong et al. 2024)。

一个做压缩的长上下文引擎,是 第 32 章 的内存管理器之上再加一条保留策略。对汇加窗口的流式,引擎把最初几个词元块固定为汇,保留一个滚动的最近块窗口,释放其余,使缓存随生成持续而维持常数大小。对基于注意力的驱逐,引擎为每个被缓存词元跟踪一个注意力分数,对重击者按累积计、对提示压缩按提示末尾观察窗口测量,并把最低分的词元驱逐到预算之内,把它们的块释放回池中。保留策略只是很薄的一层,分页才是让它便宜的原因,因为驱逐一个词元就是释放一个块,而非压实一段缓冲。

有两点运行现实值得指出。其一,一个约束的正确性以其语法为限:一个允许下游解析器会拒绝之取值的 schema,是把失败挪了位,而非消除它,所以语法与消费方必须就确切格式达成一致。其二,压缩质量以一种聚合指标所掩盖的方式因工作负载而异,所以真正的检验是下游任务,即在所选预算下的检索准确率或答案正确率,而非困惑度。困惑度可以几乎不变,而被驱逐的那个具体事实,恰是某次查询所需的。

这两项机制分别落在效率与信任两边:受约束解码提供的是信任,即输出能被解析的保证,代价是较小的效率成本,而索引与跳跃式前向几乎抵消了它。长上下文压缩提供的是效率,即一个有界缓存,代价则是信任:某个事实被驱逐、后来恰好被查询需要;查询感知策略正是在压住这个风险。

延伸阅读

  • Willard & Louf, “Efficient Guided Generation for Large Language Models” (Outlines), 2023. arXiv:2307.09702
    本文将 LLM 约束解码重新表述为有限状态机(FSM)状态转移,通过预构建词汇表索引实现正则表达式和上下文无关文法约束下平均 O(1) 的词元掩码生成。
  • Yin et al., “Fast JSON Decoding for Local LLMs with Compressed Finite State Machine” (jump-forward decoding), 2024. lmsys.org
    SGLang 的跳跃前向解码通过压缩 FSM 中的单一路径,在约束解码时一次预填充多个词元,将延迟降低最多 2 倍、吞吐量提升最多 2.5 倍。
  • Dong et al., “XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models,” 2024. arXiv:2411.15100
    XGrammar 通过将词汇表拆分为上下文无关词元与上下文相关词元,加速 LLM 约束解码中的上下文无关文法执行,单词元延迟最高降低 100 倍。
  • Xiao et al., “Efficient Streaming Language Models with Attention Sinks” (StreamingLLM), 2024. arXiv:2309.17453
    StreamingLLM 通过在滑动窗口 KV 缓存中保留少量初始注意力汇词元,使 LLM 无需微调即可处理无限长序列。
  • Zhang et al., “H2O: Heavy-Hitter Oracle for Efficient Generative Inference of Large Language Models,” 2023. arXiv:2306.14048
    H2O 提出一种 KV 缓存淘汰策略,通过累积注意力分数识别"重击者"词元并优先保留,在大幅压缩 KV 缓存内存的同时保持 LLM 生成质量。
  • Li et al., “SnapKV: LLM Knows What You are Looking for Before Generation,” 2024. arXiv:2404.14469
    SnapKV 是一种无需微调的方法,通过在生成前从观测窗口识别重要注意力位置来压缩长上下文 LLM 的 KV 缓存,在 16K 词元输入下实现 3.6 倍解码加速和 8.2 倍内存效率提升。
  • Tang et al., “Quest: Query-Aware Sparsity for Efficient Long-Context LLM Inference,” 2024. arXiv:2406.10774
    Quest 保留完整 KV 缓存,用每页键的逐通道极值做摘要,每次查询只加载与之最相关的前 K 页,在长依赖任务上以可忽略的精度损失把自注意力最高加速 2.23 倍。
  • Yuan & others, “Native Sparse Attention: Hardware-Aligned and Natively Trainable Sparse Attention” (DeepSeek), 2025. arXiv:2502.11089
    NSA 是 DeepSeek 可原生训练的稀疏注意力:一种端到端训练的分层压缩加选择模式,在基准上与完整注意力持平或更好,同时在 64k 上下文的解码、前向与反向传播上都带来大幅加速。

评论

登录后评论