让Agent变得可行,大模型结构化输出与受限解码技术
博客列表 主页

让Agent变得可行,大模型结构化输出与受限解码技术

大语言模型(Large Language Models, LLMs)在充当智能体(Agent)的控制中枢时,必须和传统软件系统交互。这类交互通常要求模型输出严格格式的数据,比如 JSON Schema、XML 或特定领域语言的 AST。问题在于,模型按 token 自回归采样,而软件系统要求整个对象一次性合法。

要求模型只靠提示词稳定输出复杂结构,会遇到两个问题:长序列里的格式错误会累积,格式约束也会占用本该用于任务推理的上下文和注意力。当模型既要做语义推理,又要记住括号、字段、枚举值和闭合规则时,失败率会随输出长度上升。

Agent 需要稳定的结构化输出,因为工具调用、状态更新和后端接口都依赖它。只靠提示词约束和失败后重试,成本高,也容易让 Agent loop 卡在同一个错误上。现在更可用的做法,是把一部分格式约束下沉到 API 层或推理引擎层。

Prompt Engineering (2023年及以前)

在早期应用里,开发者很少能干预推理引擎,只能靠提示词工程(Prompt Engineering)引导模型生成结构化输出。这一阶段主要依赖启发式规则和上下文内学习(In-Context Learning)。

基于上下文分布诱导

早期引导格式的技术主要包括零样本指令(Zero-shot Instructions)、少样本示例(Few-shot Examples)以及基于模板的输出引导(Template-based Prompting)。开发者通常会在 System Prompt 中写入极其详尽的格式指令,例如:“你必须严格按照以下 JSON 格式输出,不要输出任何额外的自然语言解释,必须包含键值 ‘data’、’confidence’ 和 ‘reasoning’”。随后,通过提供数个完美的 JSON 问答对(Few-shot),试图在上下文窗口中构建出一种强烈的模式匹配惯性 。

从数学和统计机制来看,这种做法的本质是利用 Transformer 的自注意力机制,对条件概率分布进行偏移。标准自回归生成的概率模型为:

\[P(Y \mid X) = \prod_{t=1}^{N} P(y_t \mid y_{<t}, X, \theta)\]

其中 $X$ 是包含 Prompt 和 Few-shot 的上下文,$y_t$ 是生成的 Token。通过在 $X$ 中高频注入特定格式的 Token(如 {, “, :),自注意力机制在计算 Query 和 Key 的点积时,会赋予这些格式 Token 更高的权重,从而使得模型在预测 $y_t$ 时,输出期望语法符号的概率局部逼近于 1。

统计学和注意力机制的限制

Prompt Engineering 在简单任务中仍然有用,模型变强以后,它在不少复杂任务里也并非完全失效。但遇到长上下文、嵌套 JSON Schema 或长篇代码生成时,只靠提示词会暴露两个问题:

第一,模型输出是概率采样过程。假设在充分 Prompt 引导下,每一个格式 token 正确的概率都很高,例如 $P(y_t \text{ is valid syntax}) = 0.999$。但一个复杂 JSON 对象可能包含数百甚至上千个 token。只要序列足够长,局部错误就会积累成整体失败。

第二,格式约束会占上下文。当模型要做数学推导、多步工具调用或代码生成时,上下文里塞入冗长的 JSON Schema 和 Few-shot 示例,会挤占任务信息的位置,也会让模型同时承担“理解问题”和“记住格式”的负担。

与此同时,JSON Schema的约束在Token Efficiency上很差,会导致大量 Token 被浪费在 JSON 格式上,所以学术圈曾经有过一轮创造新格式的热潮,但基本不了了之,JSON和YAML作为经典的格式,方便不同开发者之间交流,可能只有企业内部的全新项目,才有机会推动相关新格式成为 Agent 框架的一等公民。这还没有考虑推动新格式缺少预训练的影响。

JSON Mode 与针对性微调(2023-2024)

在提示词方案之外,研究界和工业界开始从模型训练、API 系统层和后处理层干预结构化输出。Tool Calling 相关研究也在这一时期集中出现。

例如,Toolformer 的核心思想是让语言模型通过自监督的方式学会使用工具,其训练语料中穿插了大量严格按照 [API_NAME(args)] 格式构建的结构化调用。Gorilla 7B 模型则更进一步,专门针对庞大的 API 集合进行了微调,并在微调过程中引入了检索感知(Retriever-aware)的训练方法,迫使模型将生成的输出与特定的 API 参数文档对齐。

微调方案的问题也很明显。模型可能记住训练集中常见的 Schema,但遇到新定义、深层嵌套或频繁变化的业务接口时,稳定性未必可靠。为大量 API 持续做 SFT,也会带来算力、数据维护和版本管理成本。

与此同时,以 OpenAI 为代表的头部厂商在 API 层面推出了原生的 JSON Mode。 其大致的底层逻辑基于高度优化的系统级微调,配合简单的启发式输出过滤(Heuristic Filtering):它只保证模型最终输出的字符串可以被 json.loads() 成功解析,即保证了基础语法层面上的括号闭合、逗号分隔和字符串转义是合法的。但它的底层只认识JSON这种通用数据交换格式的宽泛语法树,而不了解开发者具体的业务逻辑,无法处理特定的Schema。

Python 级别的受限解码与库(2023-2024)

和 JSON Mode 一起发展的还有受限解码技术。这一阶段以 Python 级别的干预库(如早期的 Outlines、LMQL、Guidance)为代表,把自动机理论(Automata Theory)接到 LLM 的采样过程里。

实现逻辑

受限解码的做法,是在每一步生成前,通过外置有限状态机(Finite-State Machine, FSM)计算当前上下文中合法的 token 集合,并掩码(Mask)掉非法 token。模型仍然采样,但采样空间被语法约束收窄了。

以 Outlines 为例,其基础算法首先将开发者定义的 JSON Schema 转换为一个庞大的正则表达式(Regex),随后将其编译为FSM,映射得到绝对合法的集合,从而得到一个掩码向量$m(S_{t-1}, Regex) \in {0, 1}^{\mid V \mid}$。模型前向传播计算出下一个 Token 的原始对数几率向量(Logits)$\alpha \in \mathbb{R}^{\mid V \mid}$ 。

在 Softmax 之前,执行 Logit Masking 的数学表达为:\(\tilde{\alpha} = m \odot \alpha\) 严格的实现中,掩码逻辑表现为将非法 Token 的 Logit 设为负无穷大:\(\tilde{\alpha}_i = \begin{cases} \alpha_i & \text{if } m_i = 1 \\ -\infty & \text{if } m_i = 0 \end{cases}\)最后,通过 Softmax 函数进行采样:\(\tilde{s}_t \sim \text{Categorical}(\text{Softmax}(\tilde{\alpha}))\)由于 $e^{-\infty} = 0$,只要 grammar 和 mask 实现正确,语法层面的非法 token 就不会被采样。

性能瓶颈

Outlines 的 FSM 掩码能解决语法约束,但早期纯 Python 实现很容易拖慢推理。CPU-GPU 同步、GIL 开销、和高性能批处理推理的不兼容,以及 FSM 难以处理嵌套递归结构,都会让它在生产推理里变得昂贵。结构化输出不能只看格式正确率,也要看它对吞吐和延迟的影响。

高性能底层受限解码引擎(2024 - 2026)

为了解决 Python 层的性能瓶颈,后续方案开始把受限解码逻辑下沉到 C++/Rust/CUDA 等执行环境中,并在算法上用下推自动机(Pushdown Automaton, PDA)和预编译语法树取代简单 FSM。

从 FSM 到下推自动机(PDA)

为了可靠解析 JSON 等具有嵌套结构的语法,现代底层引擎将 Schema 转换为上下文无关文法(Context-Free Grammar, CFG)。执行 CFG 的核心数据结构是下推自动机(PDA)。

PDA 相当于在传统的有限状态机上增加了一个“内存栈(Stack)” 。当遇到左括号 { 时,PDA 将一个状态压入栈中;遇到右括号 } 时,则进行弹栈操作。这种栈结构的引入,使得 PDA 能够无限层级地追踪 JSON 的嵌套深度 。

PDA 的状态由“FSM 状态 + 栈内容”共同决定。由于栈深度未知,状态空间理论上可以无限增长,不能像简单 FSM 那样完全离线预计算一张静态 Mask 表。后续引擎主要在解决同一个问题:怎样在保持吞吐的前提下,低成本地完成 PDA token 筛选。

XGrammar:预编译和缓存命中

由 CMU/MLC 团队主导开发的 XGrammar 是目前 vLLM 等框架默认集成的结构化引擎。它利用了一个工程事实:虽然 PDA 状态可以无限增长,但实际解码时,大多数 token 的合法性并不依赖当前栈深度。

XGrammar 在 C++ 层严格将词表分为两大类:

上下文独立 Token(Context-independent tokens):这些 Token 的合法性仅由当前 PDA 所处的顶层 FSM 状态决定,不需要查栈(例如在生成字符串内容 “value” 时,普通的英文字母都是独立合法的) 。

上下文依赖 Token(Context-dependent tokens):必须检查整个栈才能确定合法性的 Token(例如闭合括号 },只有当栈中存在对应的打开对象时才合法)。

XGrammar 在预编译阶段生成上下文独立 Token 的静态掩码。在解码运行期(Runtime),其 C++ 引擎维护一个自适应 Token 掩码缓存(Adaptive Token Mask Cache) 。对于绝大多数生成步骤,XGrammar 直接以 $O(1)$ 复杂度命中独立 Token 缓存,跳过了大量 PDA 查栈计算,将每 Token 的 CPU 开销压缩至仅 $30-80 \mu s$ 。

SGLang (Compressed FSM):基于压缩状态机的向前跳跃解码

SGLang 从有限状态机的拓扑结构出发,提出了压缩有限状态机(Compressed FSM)与向前跳跃解码(Jump-Forward Decoding)。

在解析 JSON Schema 时,存在大量必然的、确定的格式字符串。例如,当生成到某一步时,接下来的字符必须是 {“status”: “。在传统受限解码中,即便是合法的,LLM 也要白白浪费十几个前向传播(Forward Pass)周期,逐个预测出这些毫无信息熵的字符 。

技术拆解:SGLang 的压缩算法在离线阶段分析 FSM 的拓扑图,寻找奇异转移边(Singular Transition Edge):即某个状态有且仅有一个合法的后继状态的路径 。SGLang 将这些连续的单一路径压缩合并成一条粗粒度的多字符边(Compressed Edge)。

在解码运行期,一旦引擎到达压缩边的起点,系统直接跳过大模型的前向传播,将这一整串确定性字符串在 CPU 层进行 Tokenize,然后利用底层接口,直接 Append 写入到 GPU 的 KV Cache 显存树中 。

与底层内存管理机制的结合

底层受限解码的性能提升,也依赖现代显存分页管理机制。

传统的 VLLM 采用 PagedAttention,将 KV Cache 切分为固定大小(如 16 Tokens)的内存块(Blocks),这极大地缓解了显存碎片化 。然而,PagedAttention 在处理包含大量相同 System Prompt 或 Schema 模板的结构化并发请求时,其前缀匹配和重用效率并非最优 。

SGLang 引入了 RadixAttention(基数树注意力机制),它是一种专门针对 KV Cache 复用(Reuse)而设计的结构 。在 RadixAttention 中,所有请求的 KV Cache 被组织成一棵巨大的字典树(Radix Tree / Prefix Tree)。当多个 Agent 同时请求并遵循同一个复杂的 JSON Schema 时,它们的前置指令和 Schema 定义的 Token 序列是完全一致的。 RadixAttention 以 $O(\text{prefix_length})$ 的复杂度瞬间匹配出这些请求的共享前缀,并在 GPU 显存中实现细粒度(Fine-grained)的无拷贝共享 。

总结

回顾结构化输出技术从 2023 年至 2026 年的变化,约束位置在不断后移。早期把格式压力放在 prompt 和模型采样上,容易失败,也浪费上下文。Python 级受限解码把格式校验移到解码过程,但又引入同步和解释器开销。更底层的引擎则把语法解析、跳转与剪枝放到 C++/Rust 执行层,甚至和 KV Cache 管理结合起来,让模型少花精力处理括号和字段名。

对于正在自研复杂 Agent 框架(如多智能体协作、长上下文 Function Calling、代码生成)的底层开发者,可以把重点放在两件事上: 优先把格式约束交给 API 或推理引擎:Agent System Prompt 里不要堆太多括号、字段和格式示例。上下文窗口应该更多留给任务拆解、角色定义和检索结果。能传强类型 Schema 的地方,就不要只靠自然语言提醒。如果使用的厂商或本地引擎暂时不支持受限解码,再回退到提示词约束和重试机制。

根据 Schema 动态性选解码引擎:如果业务需要大规模并发,且 Schema 相对固定,比如标准化话单抽取或固定日志 JSON 结构,可以优先考虑 XGrammar 配合 vLLM。预编译和缓存命中会更容易发挥作用。若场景更依赖跳跃解码、前缀复用或特定运行时能力,再比较 SGLang 等推理引擎。

JSON Output 已经不必完全依赖提示词和重试。 现在可以把一部分输出格式要求写进 Schema、grammar 和推理引擎配置里,让系统在解码时直接参与约束。

一些代码示例

vLLM 默认集成了 XGrammar、Outlines 和 LM-Format-Enforcer 等底层受限解码后端。通过 OpenAI 兼容服务(OpenAI-Compatible Server),在请求体中传入 JSON Schema。

from openai import OpenAI
from pydantic import BaseModel

class Sentiment(BaseModel):
    sentiment: str
    confidence: float

client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")

completion = client.chat.completions.create(
    model="Qwen/Qwen2.5-3B-Instruct",
    messages=[{"role": "user", "content": "Classify this sentiment: vLLM is wonderful!"}],
    # 启用受限解码,后端自动生成基于 XGrammar 或 Outlines 的 FSM
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "sentiment_response",
            "schema": Sentiment.model_json_schema()
        }
    }
)
print(completion.choices.message.content)

嵌套 JSON 格式化输出示例 对于复杂的嵌套结构(如思维链的逐步推导),同样依赖 Pydantic 构建 Schema。vLLM 的底层 PDA(下推自动机)引擎能够解析递归与嵌套关系。

from openai import OpenAI
from pydantic import BaseModel

# 定义嵌套的底层模型
class Step(BaseModel):
    explanation: str
    output: str

# 定义顶层模型
class MathResponse(BaseModel):
    steps: list[Step]
    final_answer: str

client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
completion = client.chat.completions.create(
    model="your-model-name",
    messages=,
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "math_reasoning",
            "strict": True,
            "schema": MathResponse.model_json_schema()
        }
    }
)

SGLang 同样提供了 OpenAI 兼容的 API 服务,支持 {"structured_outputs": {"json":...}} 语法。但其最强大的特性在于使用自带的 SGLang 前端语言(SGLang Frontend Language),它能与底层的压缩 FSM 和向前跳跃解码(Jump-Forward Decoding)深度融合,实现零开销并行生成。

简单的 SGLang 原生正则约束示例

import sglang as sgl

@sgl.function
def extract_info(s, text):
    s += f"Extract the name from this text: {text}\n"
    s += "Name: "
    # 使用正则表达式强制约束输出格式,底层会自动跳过确定性前缀
    s += sgl.gen("name", regex=r"[A-Z][a-z]+ [A-Z][a-z]+")

state = extract_info.run(text="The user is John Doe.")
print(state["name"])

嵌套 JSON 约束(基于 Jump-Forward 优化) 在生成嵌套 JSON 时,SGLang 可以通过传入 Regex 或直接传入 JSON Schema 来启用结构化输出。当底层检测到 Schema 中存在的硬编码字符串(如嵌套大括号和静态字段名 "wand": { "wood": )时,引擎将跳过前向计算直接写入 KV Cache。

import sglang as sgl

# 可以使用符合 JSON 结构的嵌套 Regex
nested_json_regex = r"""\{
  "name": "[\w\s]+",
  "house": "(Gryffindor|Slytherin|Ravenclaw|Hufflepuff)",
  "wand": \{
    "wood": "[\w\s]+",
    "core": "[\w\s]+",
    "length": \d+(\.\d+)?
  \}
\}"""

@sgl.function
def generate_character(s):
    s += "Generate a Harry Potter character profile in JSON:\n"
    s += sgl.gen("character", regex=nested_json_regex)

# SGLang 会使用压缩 FSM 加速这段深层嵌套的生成
state = generate_character.run()
print(state["character"])

如果直接购买API服务,则需要参考官方相关文档,Gemini,OpenAI都提供了相关API,基本都基于Pydantic的Schema来约束输出格式。但每家的参数名都有不同之处。