大语言模型:那些硬核难题——结构化数据输出

0 阅读21分钟

在限制中,存在自由。创造力在结构中繁荣。
——朱莉娅·B·卡梅伦

虽然 LLM 擅长生成文本,但要让它们生成遵循严格规则的结构化输出,例如二元的 “yes” 或 “no”,或者一致的 JSON,仍然很困难。核心问题来自 LLM 生成输出的方式:它们逐个 token 生成,每个 token 都是从模型整个词表上的概率分布中采样得到的。即使模型在每个 token 上遵循 JSON 格式指令的可靠性达到 99%,这种小概率错误也会在整个回答的所有 token 上不断累积。

传统提示词工程,也就是要求模型返回 JSON 格式响应,是解决这个问题成本最低的方法,但它并不完美。用提示词要求 JSON 输出,本质上是在给 LLM 加约束,而 LLM 并不总是遵循这些约束。当我们把 LLM 集成进生产应用时,这种可靠性问题会变得很麻烦。这推动了约束生成技术(constrained generation techniques,CGTs)的发展,它们可以保证输出在结构上有效。

CGT 通过改变“控制施加的位置”来解决这个问题。它不是把约束作为查询的一部分写进提示词里,也就是像是在严厉地下达指令,而是直接介入 token 采样过程本身。它的机制是:先允许模型为词表中的所有 token 计算概率分数,通常根据 tokenizer 不同,词表大约有 50,000 个选项,然后再约束哪些 token 是有效的。

解析状态表示系统对当前生成输出位置的理解。例如,如果模型到目前为止在一个 JSON 对象中生成了 {"name":,那么解析状态表示:“我们现在紧跟在某个 key 后面的冒号之后,因此接下来必须看到一个 value。”

在这个状态下,以下 token 会被认为是有效的:
(1)用于开始字符串值的引号;
(2)用于开始数字的数字字符;
(3)用于嵌套对象的左花括号。

然而,以下 token 会违反 JSON 语法规则,因此是无效的:
(1)额外的冒号;
(2)没有 value 就直接出现的右花括号;
(3)未加引号的文本。

CGT 通过有限状态机(finite-state machines,FSMs)保持这种上下文感知。FSM 会追踪目标结构中的语法位置。每生成一个 token,系统就会转换到一个新状态,而新状态又有自己的一组有效下一个 token。如果继续前面的例子,系统生成了一个引号,那么它会转换到“位于字符串值内部”的状态。在这个状态下,字母、数字、空格和转义序列是有效的,但在字符串用另一个引号正确结束之前,右花括号是无效的。任何会违反目标结构的 token 都会被 mask,也就是把它们的概率设置为负无穷,使它们不可能被采样。最后,模型只会从剩余的有效 token 中采样。

这种方法既保留了模型的推理能力,又强制执行一个规则:任何会违反目标结构的 token 都会被 mask,从而不可能被采样。因此,对于指定语法,不可能生成语法上畸形的输出,同时模型的推理能力仍然保留。模型仍然“想要”生成合理内容,但它的选项被约束在那些能够保持结构有效性的 token 之内。

本章剩余部分会探索提示词工程和各种 CGT 方法,从模型内置的 JSON mode,到 Outlines、Pydantic、LangChain 和 Ollama 等灵活库,我们可以按需部署它们。不过,在进入这些内容之前,我们先多花一点时间,说明为什么结构化输出重要,以及为什么我们应该投入时间学习它。

为什么结构化输出重要

结构化输出重要的原因有很多,以下几个考虑尤其关键:

提升开发者效率和工作流。

满足 UI 和产品需求。

增强用户信任和体验。

接下来,我们深入讨论为什么结构化输出对这三大类工作很重要。这里我们会把结构化输出的生产视为对模型的一种“约束”,因此后文会把“结构化输出”和“约束”作为近义词使用。

提升开发者效率和工作流

随着 LLM 变得越来越重要,开发者会花更多时间与它们交互。可靠的结构化输出可以减少提示词工程中的试错,使这个过程更高效。为了诱导模型生成期望的输出格式而编写提示词,是一个耗时过程,通常需要大量测试和迭代。LLM 输出约束可以让这一过程更高效、更可预测。即使有了不错的提示词,开发者仍然必须把 LLM 输出的后处理作为 LLMBA 工作流的一部分来考虑。开发者经常不得不编写复杂代码,去整理和处理那些不符合预期格式的 LLM 输出。LLM 结构化输出可以简化这一点,减少临时后处理代码的需求。开发者还需要为下游需求整理输出。LLM 通常被用于更大的流水线中,其输出会成为后续模块的输入。输出约束对于确保兼容性和防止错误非常关键。最后,LLM 越来越多地被用于生成模型训练所需的合成数据。约束可以确保数据完整性,并防止包含不希望出现的元素,否则这些元素可能对训练结果产生负面影响。

满足 UI 和产品需求

LLM 生成的内容经常需要适配特定 UI 元素的大小限制,尤其是在移动设备上。输出长度约束可以防止内容溢出,并确保其能够在 UI 中正确显示。与此相关,一致的输出长度和格式,对用户体验和 UI 清晰度至关重要。约束有助于保持这种一致性,避免生成文本出现令人困扰的巨大差异。如果我们内容的最终消费者是外部平台,例如某些媒体平台,它们可能会对内容施加字符限制。长度约束可以让 LLMBA 遵守这些限制,确保内容能够成功发布。

增强用户信任和体验

用户期望由 LLM 驱动的工具可靠、真实,这意味着要去除幻觉。把 LLMBA 的输出限制在一组可能结果之内,可以帮助缓解幻觉,确保输出有效。这可以推动采用,因为用户更可能使用那些提供可靠且一致体验的 LLMBA。通过约束确保输出准确性、一致性和安全性,开发者可以提升用户满意度,并推动采用。

为什么这很难?Transformer 架构

总体而言,约束 LLMBA 输出的能力不只是一个技术问题,而是一项根本性的用户需求,会影响开发者效率、用户体验,以及 LLMBA 的整体成功。

此时,一个自然的问题可能是:为什么这件事具有挑战性?毕竟,LLM 看起来拥有超高水平的智能,而特定输出格式似乎不应该超出这种“超智能”的能力范围。答案在于 Transformer 架构。

Transformer 架构是一种深度学习模型,它使用自注意力机制来衡量输入数据元素相对于彼此的重要性。也就是说,自注意力会动态地对输入数据的不同部分相对于其他部分进行加权。基于 Transformer 的 LLM,会在给定序列中前面 token 的条件下,计算观察到某个 token 的概率。这里的 token 来自大小为 (n) 的词表。

这个过程可以用数学形式表示为:

P(X)=P(x1,x2,,xn)=i=1np(xix<i)P(X)=P(x_1,x_2,\dots,x_n)=\prod_{i=1}^{n}p(x_i|x_{<i})

其中,xix_i 表示当前正在生成的 token,x<ix_{<i} 包含所有前面的 token。

然而,在实际应用中,生成高质量内容需要的不只是概率式的下一个 token 生成。关键挑战在于如何纳入控制条件 CC,以引导模型生成具有特定期望特征的文本——无论是保持一致格式、遵循语法规则,还是遵守语义约束。这些控制条件必须被整合进去,同时又要保留模型生成自然、连贯文本的能力。这个受控文本生成过程可以形式化为:

P(XC)=P(x1,x2,,xnC)=i=1np(xix<i,C)P(X|C)=P(x_1,x_2,\dots,x_n|C)=\prod_{i=1}^{n}p(x_i|x_{<i},C)

这里,CC 表示塑造生成输出的一组约束或控制条件。

常见约束 CC 包括:

格式约束

强制执行特定输出格式,例如 JSON、XML 或 YAML,可以确保生成内容遵循明确定义的结构,便于解析和验证。格式约束对系统集成和数据交换至关重要。

多选约束

将 LLM 输出限制在预定义选项集合中,有助于确保回答有效,并减少意外或无效输出的可能性。这对于分类任务,或者要求特定类别响应的场景尤其有用。

静态类型约束

强制执行数据类型要求,例如字符串、整数、布尔值等,可以确保输出能够被下游系统安全处理。类型约束有助于防止运行时错误,并提升系统可靠性。

长度约束

限制生成内容的长度,对于 UI 展示、平台要求,例如 Twitter 的字符限制,以及一致的用户体验都很关键。长度约束可以作用在字符、词或 token 层面。

输出一致性

一致的输出长度和格式,对用户体验和 UI 清晰度至关重要。约束有助于保持这种一致性,避免生成文本出现过度变化。

这些约束与 LLM 回答质量之间存在平衡,而这个平衡正是生成结构化输出的核心,也正是这项任务如此具有挑战性的原因。约束太少,输出可能格式错误或难以解析;约束太多,输出可能结构有效,但质量较低、不完整,或者语义错误。

训练时约束技术

训练时约束技术(training-time constraint techniques,TTTs)是在模型训练期间应用的,可以发生在预训练阶段,也可以发生在后训练阶段,用于引导模型内化某个特定任务所需的模式和结构。

当应用 TTT 时,LLM 会在专门设计的数据集上训练,这些数据集用于教授期望的输出结构。由于这些信息被整合进 LLM 的权重中,模型会学习原生地产生遵循目标格式的输出。虽然 TTT 可以发生在预训练期间,但最常见的是在后训练阶段通过监督微调(supervised fine-tuning,SFT)应用。

在 SFT 期间,模型构建者会创建一个训练数据集,其中每个样例都向模型展示一个输入提示词,以及与之对应的、符合目标格式的正确输出。对于 JSON 生成,我们会有成千上万个样例,其中提示词是“从这段 10-K 章节中抽取风险因素”,期望输出则是符合特定 schema 的有效 JSON,例如:

{"risk_factors": [{"category": "market", "description": "…", "severity": "high"}]}

模型会在训练期间反复看到这些输入—输出对,并学习 JSON 结构的统计模式:左花括号、键值对、正确嵌套、右花括号,以及字符串周围的引号。

基于人类反馈的强化学习(reinforcement learning from human feedback,RLHF)也可以在 SFT 之后应用,但它承担的是不同角色。RLHF 会在 SFT 模型基础上,使用人类关于输出质量的偏好进一步细化模型。SFT 教模型生成结构正确的输出,而 RLHF 教模型在这种结构内生成真正被人类认为有用、有帮助、高质量的输出。

在完成 SFT 之后,如果模型已经能够可靠地产生有效 JSON,我们现在还想教它哪些 JSON 输出比其他 JSON 输出更好。也许两个输出都在语法上是有效 JSON,并且都遵循你的 schema,但其中一个更准确地抽取了风险因素,使用了更好的分类,或者从源文本中捕捉了更多细微差别。RLHF 就是教模型区分这些质量差异的过程。

SFT 和 RLHF 都是 LLM 构建者使用的技术。而我们作为 LLM 用户,能够使用这些技术创造出来的功能。

推理时约束技术

推理时技术(inference-time techniques,ITTs)是在 LLM 的推理阶段应用的。它们用于在推理时引导模型生成期望输出,并且由我们这样的 LLM 用户通过提示词工程或 logit 后处理完成。Logit 后处理是一种在 LLM 输出被转换成文本之前,修改其输出 logits 的技术。接下来的部分会介绍这两种技术。

提示词工程

也许生成 LLM 受约束回答最常见的策略就是使用提示词工程,尤其是 one-shot prompting,也就是用户在提示词中提供一个期望输出格式的示例。这个策略之所以常用,是因为它对领域专家友好,而领域专家通常最适合编写好的提示词,并且它不要求深厚技术知识。我们在第 3 章中介绍过提示词的重要性以及如何评估提示词,这里它们再次出现。无论我们是在做 RAG、处理非结构化上下文,还是追求结构化数据输出,提示词对于我们如何与 LLMBA 沟通并提升其表现都很重要。

作为一个引导示例,考虑下面这个简单任务:给定一段 10-K 章节,以 JSON 格式生成一段两人讨论,讨论文本中的关键财务数据,模拟一场围绕底层公司披露财务信息的真实世界讨论。我们希望生成结构化输出,使其能够被轻松解析并与其他系统集成。我们不会简单地要求 LLM “produce JSON”。我们会直接进入带有 one-shot 示例的提示词工程,如下所示:

"""
Generate a two-person discussion about the key financial data from the 
following text in JSON format.

<JSON_FORMAT>
{
   "Person1": {
     "name": "Alice",
     "statement": "The revenue for Q1 has increased by 20% compared to last 
     year."
   },
   "Person2": {
     "name": "Bob",
     "statement": "That's great news! What about the net profit margin?"
   }
}
</JSON_FORMAT>
"""

现在,我们把这个提示词拿来,将 10-K 作为上下文传入,并要求 LLM 生成回答:

MAX_LENGTH = 10000 # 限制输入长度以避免 token 问题

with open('../data/apple.txt', 'r') as file:
    sec_filing = file.read()
sec_filing = sec_filing[:MAX_LENGTH] 

from dotenv import load_dotenv
import os

# 从 .env 文件加载环境变量
load_dotenv(override=True)
from openai import OpenAI
client = OpenAI()

prompt = """
Generate a two-person discussion about the key financial data from the 
following text in JSON format.

<JSON_FORMAT>
{
   "Person1": {
     "name": "Alice",
     "statement": "The revenue for Q1 has increased by 20% compared to last 
     year."
   },
   "Person2": {
     "name": "Bob",
     "statement": "That's great news! What about the net profit margin?"
   }
}
</JSON_FORMAT>
"""

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": prompt},
        {"role": "user", "content": sec_filing}
    ]
)

让我们打印响应,看看 one-shot 提示词表现如何:

response_content = response.choices[0].message.content
print(response_content)
{
   "Person1": {
     "name": "Alice",
     "statement": "The aggregate market value of Apple's stock 
     held by non-affiliates is approximately $2.63 trillion."
   },
   "Person2": {
     "name": "Bob",
     "statement": "That's impressive! I also noticed that they 
     have around 15.1 billion shares of common stock outstanding."
   }
}

这个回答看起来相当不错,但它真的是有效 JSON 吗?让我们测试一下:

import json

def is_json(myjson):
  try:
    json.loads(myjson)
  except ValueError as e:
    return False
  return True

is_json(response_content)
False

答案被 JSON Markdown 标签包裹了,所以实际上没有通过 JSON 测试!我们可以进一步实验,直到找到一个看起来能更稳定生成 JSON 格式回答的提示词。例如,我们的提示词也许还不够明确。我们现在可以说:“Absolutely do not enclose your answer in JSON Markdown tags!” 有意思的是,模型居然会加上这些标签。最可能的原因是,这些标签让回答看起来“更好”。这有点像一位初级员工完成任务后说:“我擅自没有严格按你的指令做,因为我知道有一种更好的方式。” 它确实可能是一种更好的方式,但从系统角度看,它不是正确方式。

事实证明,要求 JSON 格式回答已经变得非常常见,以至于许多模型现在都提供 JSON mode。接下来我们会探索它。

JSON Mode(微调得到)

许多模型现在提供所谓的 “JSON mode”,试图处理我们刚刚在提示词工程中看到的挑战。截至 2025 年,提供该设置的一些模型示例包括:

gpt-4-turbo

claude-3-sonnet

mistral-medium

为了看看这种模式通常如何实例化,可以看图 5-1。其中,JSON mode 是通过指示 LLM 模型使用 JSON 作为响应格式来实现的,也可以选择定义目标 schema。

图示说明:JSON mode 处理流程图,展示 JSON schema 如何被大语言模型用于生成结构化 JSON 输出。

image.png

图 5-1 JSON mode 的概念概览

下面是一个调用 JSON mode 的示例,我们设置:

response_format = { "type": "json_object" }

同时,我们也把 JSON 请求写进指令里,因为经验表明,这样可以提高该设置下的可靠性:

prompt = f"""
Generate a two-person discussion about the key financial 
data from the following text in JSON format.
TEXT: {sec_filing}
"""
response = client.chat.completions.create(
    model="gpt-3.5-turbo",
    messages=[{"role": "user", "content": prompt}],
    response_format = { "type": "json_object" }
)

让我们看一下结果:

response_content = response.choices[0].message.content
print(response_content)
{
  "person1": "I see that Apple Inc. reported a total market value of 
  approximately $2,628,553,000,000 held by non-affiliates as of 
  March 29, 2024. That's a significant amount!",

  "person2": "Yes, it definitely shows the scale and value of the company in 
  the market. It's impressive to see the sheer size of the market value.",
  
  "person1": "Also, they mentioned having 15,115,823,000 shares 
  of common stock issued and outstanding as of October 18, 2024. That's a large 
  number of shares circulating in the market.",
  
  "person2": "Absolutely, the number of shares outstanding plays a 
  crucial role in determining the company's market capitalization and investor 
  interest."
}

成功了!但这里需要一个重要提醒:这种策略并不总是有效!即使使用 JSON mode 和精心设计的提示词,期望结果仍然无法得到保证,因为输出仍然依赖一个微调模型,而这个模型并不总是遵循其预期指令。下一步,是引入 Pydantic。

将 JSON Mode 与 Pydantic 结合

OpenAI 的 JSON mode 配合 response_format={"type": "json_object"},是在告诉模型:“生成有效 JSON 语法。” 但它并没有指定这个 JSON 应该具有什么结构。模型会给我们语法正确的 JSON,也就是括号、引号、逗号都正确,但它可能返回 {"risks": [...]},而我们想要的是 {"risk_factors": [...]};或者它可能包含我们没有要求的字段;又或者在我们希望是整数的地方使用字符串。JSON mode 保证 JSON 语法有效,但不保证 schema 遵循有效。因此,我们把它与 Pydantic 结合起来。

Pydantic 是一个用于数据验证和解析的 Python 库,它会在运行时强制执行类型标注,确保数据匹配我们在模型中定义的结构和类型。它常与 LLM 一起使用,通过验证字段类型、必填字段、值约束和自定义规则,保证生成输出符合精确 schema。

在下面的代码块中,Pydantic 的作用是精确定义 LLM 必须返回什么结构。具体来说,是一个包含两个字段的字典:mentioned_entitiesmentioned_places,二者都必须是字符串列表。当我们把 SECExtraction 作为 response_format 传入时,OpenAI API 会使用这个 schema 来约束模型生成,使其只能产生匹配该结构的输出;随后 Pydantic 会验证结果,确保两个字段都存在,确实都是列表,并且只包含字符串值。没有 Pydantic 时,LLM 可能把 entities 返回成单个字符串而不是列表,可能拼错字段名,或者返回完全不同的 JSON key;但有了 Pydantic,我们就能保证拿回一个恰好包含这两个列表字段的对象,或者得到一个我们可以处理的错误:

from pydantic import BaseModel
from openai import OpenAI

class SECExtraction(BaseModel):
    mentioned_entities: list[str]
    mentioned_places: list[str]

def extract_from_sec_filing(sec_filing_text: str, prompt: str) -> SECExtraction:
    """
    从输入的 SEC filing 文本中抽取结构化数据。
    """
    client = OpenAI()
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": prompt
            },
            {"role": "user", "content": sec_filing_text}
        ],
        response_format=SECExtraction
    )
    return completion.choices[0].message.parsed
prompt_extraction = 
'''You are an expert at structured data extraction.
You will be given unstructured text from an SEC filing and should extract the
names of mentioned entities and places into the given structure.'''

sec_extraction = extract_from_sec_filing(sec_filing, prompt_extraction)

让我们看看 Pydantic + JSON mode 的结果:

print("Extracted entities:", sec_extraction.mentioned_entities)
print("Extracted places:", sec_extraction.mentioned_places)
Extracted entities: ['Apple Inc.', 'The Nasdaq Stock Market LLC']
Extracted places: ['Washington, D.C.', 'California', 'Cupertino, California']

可以看到,模型能够从输入文本中抽取实体和地点,并以指定格式返回它们。

使用 Pydantic 和 response_format 参数可以强制模型输出结构,使其更可靠,也更容易处理。

Logit 后处理

虽然使用 Pydantic 配合 OpenAI 结构化输出,可以通过约束生成并验证结果提供强保证,但 logit 后处理提供了更高程度的控制,因为它会在生成任何文本之前,直接操纵模型的 token 概率。Logit 后处理会在每一步生成中介入,通过数学方式把任何会导致无效输出的 token 概率置零,本质上使模型不可能生成畸形结构。

对于受支持语法和正确实现的 decoder,这种方法可以提供强结构保证,因为我们修改的是概率分布本身:如果下一步必须是右花括号,那么我们就把所有非右花括号 token 的概率设置为零,强迫模型生成正确 token。Pydantic 是在生成后验证,并且可能需要重试;而 logit 后处理则通过在每一次 token 决策时,对模型词表施加实时过滤,从一开始就阻止无效输出被生成。

让我们看一下 LLM 如何处理一个示例提示词:“Is Enzo a good name for a baby?” 如图 5-2 所示。

图示说明:大语言模型从输入提示词生成文本的过程图,展示从 tokenization 到 softmax 概率的各个阶段。

image.png

图 5-2 文本生成过程

这里涉及的步骤可以列举如下:

tokenizer 将输入文本切分成 token。

每个 token 被映射到一个唯一的数值 ID。

LLM 通过其深度神经网络处理这些 token ID。

模型生成 logits,也就是每个可能下一个 token 的未归一化分数。

softmax 变换会把这些原始 logits 转换为概率分布。

最高概率,例如 3.25%,表示模型对下一个 token 最强的预测。

文本生成随后会根据给定策略选择下一个 token,例如 greedy 或 Top-K。

为了实际观察这一点,我们可以使用 transformers 库提取提示词最后一个 token 的 logits。实例化一个小型开源模型 SmolLM2-1.7B-Instruct 后,我们可以通过运行:

inputs = tokenizer(PROMPT, return_tensors="pt").to(model.device)

来提取给定示例提示词 “Is Enzo a good name for a baby?” 的最后一个 token 的 logits:

MODEL_NAME = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
PROMPT = "Is Enzo a good name for a baby?"

from transformers import AutoTokenizer, AutoModelForCausalLM
from transformers import LogitsProcessor, LogitsProcessorList
import torch

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, 
                                             torch_dtype=torch.bfloat16,
                                             device_map="auto")

inputs = tokenizer(PROMPT, return_tensors="pt").to(model.device)

# 获取 logits
with torch.inference_mode():
    outputs = model(**inputs)
    logits = outputs.logits

# 最后一个 token 的 logits
last_token_logits = logits[:, -1, :]

next_token_probs = torch.nn.functional.softmax(last_token_logits, dim=-1)

k = 10
top_k_probs, top_k_indices = torch.topk(next_token_probs, k, dim=-1)

# 打印实际 token,跳过特殊 token
top_k_tokens = [tokenizer.decode(idx, skip_special_tokens=True) 
                for idx in top_k_indices[0]]

print(f"Top predicted tokens and probabilities:")
for prob, token in zip(top_k_probs[0][:k], top_k_tokens[:k]):
    if token.strip():  # 只打印非空 token
        print(f"'{token}': {prob:.4f}")
Top predicted tokens and probabilities:
' I': 0.0325
' What': 0.0305
' Here': 0.0197
' Is': 0.0106
' My': 0.0093

我们看到,token “I” 以 3.25% 排在第一,其次是 “What”,概率为 3.05%。

现在,我们希望实现一种控制 “I” 的方法,修改最后一个 token 的 logits,使模型偏向我们希望在输出中看到的 token,从而“控制”生成过程。

transformers 库提供了一个 LogitsProcessor 类,正好允许我们做这件事,也就是修改最后一个 token 的 logits。

重要的是,它定义了 __call__ 方法,该方法接收两个关键参数:

input_ids(torch.LongTensor)

形状为:(batch_size, sequence_length)

包含输入序列的 token ID。

通过 tokenizer.encode()tokenizer.call() 获得。

scores(torch.FloatTensor)

形状为:(batch_size, vocab_size)

来自语言模型 head 的原始 logits。

可以是 softmax 前或 softmax 后分数。

表示每个 token 的预测概率。

这允许我们在 token 选择之前自定义操纵分数,从而对生成过程进行细粒度控制。

让我们通过一个具体例子,更好地理解如何控制输出生成。

假设我们希望模型对提示词 “Is Enzo a good name for a baby?” 的输出始终返回 “Yes” 或 “No”。

一种贪心方法是修改最后一个 token 的 logits,把除了 “Yes” 和 “No” 之外的所有 token 全部 mask 掉。随后,我们可以以 greedy 方式,从 “Yes” 或 “No” 中选择最可能的 token 作为输出。

下面的 YesNoLogitsProcessor 类通过实现一个自定义 LogitsProcessor 来实现这种贪心方法。

在这个风格化的 logits processor 示例中,我们简单地 mask 掉除 “Yes” 和 “No” 之外的所有 token,然后计算它们的 logits:

class YesNoLogitsProcessor(LogitsProcessor):
    def __init__(self, yes, no, tokenizer, initial_length):
        self.yes = yes
        self.no = no
        self.tokenizer = tokenizer
        self.initial_length = initial_length
        
    def __call__(self, input_ids: 
        torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
        # 如果已经生成了回答,就 mask 掉所有内容
        if len(input_ids[0]) > self.initial_length:
            scores.fill_(-float('inf'))
            return scores
            
        # Debug 打印
        yes_tokens = self.tokenizer.encode(self.yes, add_special_tokens=False)
        no_tokens = self.tokenizer.encode(self.no, add_special_tokens=False)
        print(f"Yes token ID: {yes_tokens}")
        print(f"No token ID: {no_tokens}")
        
        # 提取 yes/no 的原始 logits
        yes_no_logits = scores[:, [yes_tokens[0], no_tokens[0]]]
        print(f"[Yes, No] logits: {yes_no_logits}")
        
        # 使用 softmax 获取概率
        yes_no_probs = torch.nn.functional.softmax(yes_no_logits, dim=-1)
        yes_prob = yes_no_probs[:, 0]
        no_prob = yes_no_probs[:, 1]
        print(f"Yes prob: {yes_prob}")
        print(f"No prob: {no_prob}")
        
        # 用 -inf mask 所有 token
        scores.fill_(-float('inf'))
        
        # 把更高概率选项设置为 0
        yes_mask = yes_prob > no_prob
        scores[:, yes_tokens[0]] = torch.where(yes_mask, torch.tensor(1e4), 
                                               torch.tensor(-float('inf')))
        scores[:, no_tokens[0]] = torch.where(~yes_mask, torch.tensor(1e4), 
                                              torch.tensor(-float('inf')))
        
        return scores

现在,我们可以把自定义 logits processor 传给 model.generate() 方法:

input_ids = tokenizer.encode(PROMPT, return_tensors="pt")
initial_length = len(input_ids[0])

YES = "yes"
NO = "no"

# 受控生成
generation_output_controlled = 
model.generate(**inputs, logits_processor =  
                LogitsProcessorList([YesNoLogitsProcessor(YES,
                                                          NO,
                                                          tokenizer,
                                                          initial_length)]), 
                max_length=50)

# 非受控生成
generation_output = model.generate(**inputs, max_length=50)

下面打印结果:包括 “Yes” 和 “No” 的 token ID、最后一个 token 的 logits,也就是 mask 前的原始分数,以及 “Yes” 和 “No” token 的概率。

可以看到,在这次运行中,经过 mask 后,模型预测 “Yes” 的概率为 0.4263,“No” 的概率为 0.5737。在我们的贪心方法中,自定义 logits processor 会选择最可能的 token,本例中是 “No”:

Yes token ID: [10407]
No token ID: [4607]
[Yes, No] logits: tensor([[2.6250, 2.9219]])
Yes prob: tensor([0.4263])
No prob: tensor([0.5737])

让我们看看它是否按预期工作。我们写一个辅助函数,从模型生成输出中抽取回答:

def generate_response(model_output, tokenizer):
    gen_output = tokenizer.batch_decode(model_output, 
                                        skip_special_tokens=True, 
                                        clean_up_tokenization_spaces=False)
    generated_text = gen_output[0][
                len(
                    tokenizer.decode(
                        inputs["input_ids"][0], skip_special_tokens=True
                    )
                ) :
            ].strip()
    return generated_text

使用自定义 logits processor 的受控生成:

generate_response(generation_output_controlled, tokenizer)
'no'

常规生成,这里我们把输出限制为 50 个 token:

generate_response(generation_output, tokenizer)
'Enzo is a classic Italian name that'

可以看到,受控生成对提示词返回了 “No”,而常规生成返回了更冗长的回答,符合预期。

前面的例子运行得很好,但我们必须自己写代码!好在有一些更高层的库,例如 Outlines、Ollama 和 LangChain,可以帮助我们简化这个过程,同时仍然提供一定灵活性和控制力。它们各自对结构化输出采用不同方法,控制度和灵活度也不同。接下来的部分就会介绍它们。

Outlines

Outlines 是一个专门关注 LLM 结构化文本生成的库,并且为任何可通过 Hugging Face 使用的模型提供能力。Outlines 会直接从 JSON schema、正则表达式,甚至 Pydantic 模型构建 FSM。这种架构在模型选择和约束指定方面都提供了显著灵活性。

Outlines 背后的关键创新在于,其作者解决了通用引导生成问题,而作为结果,也解决了 LLM 中结构化输出生成的问题。作者通过引入一种高效索引方法,用 FSM 重新表述神经文本生成来做到这一点。

作者将下一个 token 的生成定义为一个随机变量:

st+1Categorical(α)s_{t+1}\sim Categorical(\alpha)

其中:

α=LLM(St,θ)\alpha = LLM(S_t,\theta)

其中:

st+1s_{t+1} 是即将生成的下一个 token。

St=(s1...st)S_t=(s_1...s_t) 表示包含 tt 个 token 的序列,并且 stVs_t\in V

VV 是词表,大小为 V=N|V|=N,通常约为 10410^4 或更大。

αRN\alpha \in \mathbb{R}^{N} 是词表上的输出 logits/logits/概率。

θ\theta 是 LLM 的训练参数集合。

LLM 指的是在下一个 token 补全任务上训练的深度神经网络。

Categorical(α)Categorical(\alpha) 表示从概率为 α\alpha 的分类分布中采样。

当为引导生成应用 masking 时,它变成:

α~=m(St)α\tilde{\alpha}=m(S_t)\odot \alpha
s~t+1Categorical(α~)\tilde{s}_{t+1}\sim Categorical(\tilde{\alpha})

其中:

m:P(V)0,1Nm : P(V)\rightarrow {0,1}^{N} 是一个布尔 mask 函数。

\odot 表示逐元素乘法。

α~\tilde{\alpha} 是被 mask 后的受约束概率分布。

s~t+1\tilde{s}_{t+1} 是在约束下采样得到的下一个 token。

这种形式化方式允许 masking 操作根据 FSM 状态,把无效 token 的概率清零,从而引导生成过程。但 Outlines 的作者不是在每一步生成时检查整个词表,也就是大小为 (N) 的词表,并以 (O(N)) 复杂度强制输出约束;他们会把约束,也就是 regex 或 grammar,转换成 FSM 状态,并构建一个从 FSM 状态到有效词表 token 的索引。这使 token 生成达到平均 (O(1)) 复杂度。

Outlines 在研究和实验场景中非常出色,尤其适合我们想要跨不同模型架构测试约束生成,或者有超出标准语法系统支持范围的自定义约束需求时。由于它是纯 Python,Outlines 可以自然集成进现有机器学习流水线和数据科学工作流。开发者可以对模型参数、采样设置和约束执行机制进行细粒度控制。

Outlines 使用以下步骤引导模型输出遵循特定格式或模式:

定义模型应遵循的格式,例如 “yes”、“no”、“always”、“never”。

使用该格式为模型创建约束。

LLM 开始文本生成过程,也就是为可能的下一个 token 生成概率分布。

Outlines 引导模型检查约束,并判断哪些 token 有效。

把无效 token 的概率 mask 为零。

从剩余非零 token 中采样;在本例中,就是 “yes”、“no”、“always”、“never”。

例如,假设我们希望把 LLM 的输出约束到以下选项集合:

  • Y/yes

  • N/no

  • N/never

  • A/always

这可以通过创建一个状态机来完成。这个状态机有一个起始状态、一个结束状态,以及状态之间的一组有效转换。可能状态可以用下面这个 regex 字符串表示:

r"\s*([Yy]es|[Nn]o|[Nn]ever|[Aa]lways)"

图 5-3 中的状态机说明了 Outlines 底层是如何工作的。注意:

Prob 表示 LLM 给出的 logit token 概率。

Mask 是由状态机定义的转换 mask 值。

Final 是 mask 后重新归一化的 token 概率。

图示说明:状态机图,展示 token masking 以及 Start、First、Yes、No、Never、Always 和 End 状态之间的转换,并标出有效 token Y、y、N、n 和 A 的概率与 mask 值。

image.png

图 5-3 Outlines 状态机

初始的 “Start” 状态包含一个 masking 表,用于控制哪些 token 可以开始序列。在这个例子中,只有来自集合 [YyNnAa] 的字符被允许作为有效首字符,每个字符都有对应的概率和 mask 值。masking 机制通过把无效 token 的 mask 值设置为 0,有效过滤掉无效 token,从而确保只允许转换到 “First” 状态。

转换到 “First” 状态之后,系统继续使用概率 masking 来引导序列。例如,当收到 “Y” 作为输入时,masking 表会调整 token 概率,以确保后续生成有效。

Outlines 可以支持主要专有 LLM API,例如 OpenAI。然而,它的一个关键优势是能够为开源模型确保结构化输出,而开源模型通常默认缺乏这类保证。

让我们用 Qwen2.5-0.5B 走一个例子。它是阿里云的轻量级开源模型,虽然规模很小,但性能表现不错。

我们先通过提示词提供指令:

TOP = 100
prompt = f"""You are a sentiment-labeling assistant specialized 
in Financial Statements.
Is the following document positive or negative?

Document: {sec_filing[:TOP]}
"""

我们使用 Outlines 的 choice 方法,把模型输出约束到一个预定义选项集合,即 “Positive” 或 “Negative”。这确保模型只能返回其中一个值,避免任何意外或格式错误的响应:

import outlines

model = outlines.models.transformers("Qwen/Qwen2.5-0.5B-Instruct")

generator = outlines.generate.choice(model, ["Positive", "Negative"])
answer = generator(prompt)
print(answer)
Negative

这个例子看起来简单,Outlines 也确实让它变得简单,但如果没有这个库,我们必须自己做很多工作,才能真正约束可能回答。

Outlines 还允许我们引导生成过程,使输出保证遵循 JSON schema 或 Pydantic 模型。我们回到从 SEC filing 中抽取实体和地点的例子。为此,只需要把我们称为 SECExtraction 的 Pydantic 模型,传给 generate 模块中的 json 方法即可。这个模块来自 Outlines:

BASE_PROMPT = '''You are an expert at structured data extraction. You will be 
given unstructured text from an SEC filing and extracted names of mentioned 
entities and places and should convert the response into the given structure.'''

prompt = f"{BASE_PROMPT} Document: {sec_filing[:TOP]}"
generator = outlines.generate.json(model, SECExtraction)
sec_extraction_outlines = generator(prompt)

让我们看看结果:

print("Extracted entities:", sec_extraction_outlines.mentioned_entities)
print("Extracted places:", sec_extraction_outlines.mentioned_places)
Extracted entities: ['Zsp', 'ZiCorp']
Extracted places: ['California']

我们看到,模型能够从输入文本中抽取实体和地点,并以指定格式返回它们。不过,有意思的是,模型幻觉出了一些实体。这种现象在没有针对实体抽取任务进行微调的小型开源模型中很常见。这是我们在这里使用小型 LLM 的结果。

Ollama

Ollama,附录中会提供更全面的一般性信息,代表了一条通向约束生成的直接路径。Ollama 构建在 llama.cpp 的 GBNF grammars 之上,使用 GBNF grammars。在计算机科学中,grammar 是一组规则,用于定义形式语言中哪些字符串有效。这些 GBNF grammars 会被编译成 FSM,在运行时执行约束。

它本质上是 Backus-Naur Form(BNF)的扩展,并加入了一些现代的、类似正则表达式的特性。这些规则会仔细定义哪些元素被允许、它们可以如何组合,以及哪些重复和排序模式是有效的。通过在生成过程中强制执行这些约束,GBNF 确保模型输出严格遵循期望格式。

我们说 Ollama 直接,是因为它现在支持通过在 format 参数中提供 JSON schema 来实现结构化输出,模型会生成匹配该 schema 的响应。实现非常干净简单:我们把 Pydantic 模型的 JSON schema 传给 format 参数,剩下的交给 Ollama 处理。在底层,Ollama 会专门为我们的 JSON schema 生成 grammar,并将其发送给 llama.cpp,由 llama.cpp 约束模型输出 token,使其符合我们的 schema。这个约束过程通过前面讨论过的 logit masking 实现。

Ollama 在本地开发和原型验证优先的场景中非常出色。由于一切都在我们的硬件上运行,没有 API 成本,因此非常适合实验和迭代开发。对隐私敏感的应用也会极大受益于这种本地处理方式,因为数据永远不会离开我们的机器。

然而,Ollama 的本地特性也带来了限制。系统依赖硬件,较大模型需要大量 GPU 显存。像 llama-3.1-70B 这样的模型大约需要 80 GB VRAM,这超出了大多数消费级硬件的能力范围。模型切换也会产生额外开销,因为加载不同模型需要显著时间和内存分配。尽管有这些限制,Ollama 仍然是我们进行本地结构化输出实验的一个好起点。

让我们用 Ollama 复现之前的结构化输出生成例子,看看这种干净、封装好的体验是如何工作的。

我们首先需要安装库:

curl -fsSL https://ollama.com/install.sh | sh
pip install ollama

接下来,我们构建一个 extract_entities_from_sec_filing 函数,它使用 Ollama 的 chat API 分析 SEC filings,并以结构化格式抽取实体,同时把 temperature 设置为 0,以获得确定性更强的结果。我们在提示词后添加一个后缀,指示模型以 JSON 返回响应,也就是 “Return as JSON”,这是 Ollama 维护者推荐的做法:

from ollama import chat
from pydantic import BaseModel, Field

class SECExtraction(BaseModel):
    mentioned_entities: list[str] = Field(
        description="Company names, executives, investment funds, tickers"
    )
    mentioned_places: list[str] = Field(
        description="Countries, cities, regions, headquarters locations"
    )

OLLAMA_STRUCTURED_OUTPUT_PROMPT_SUFFIX = "Return as JSON."
OLLAMA_STRUCTURED_OUTPUT_TEMPERATURE = 0

def extract_entities_from_sec_filing(doc: str, model: str) -> dict:
    """
    使用 Ollama chat 从 SEC filing 中抽取实体和地点。
    
    Args:
        doc: 要分析的 SEC filing 文本
        model: 用于抽取的 Ollama 模型
        
    Returns:
        chat 模型的原始响应
    """
    response = chat(
        messages=[
            {
                'role': 'user',
                'content': f"""{BASE_PROMPT}
                {OLLAMA_STRUCTURED_OUTPUT_PROMPT_SUFFIX}
                
                Document: {doc}"""
            }
        ],
        model=model,  
        format=SECExtraction.model_json_schema(),
        # 设置为 0,以获得更确定性的输出
        options={'temperature': OLLAMA_STRUCTURED_OUTPUT_TEMPERATURE}  
    )
    return response

接下来,我们需要启动 Ollama server,并让目标 LLM 模型 Qwen2.5-0.5B 在本地运行:

ollama run qwen2.5:0.5b

现在我们定义模型和响应对象:

doc = sec_filing[:TOP]
model = "qwen2.5:0.5b"

response = extract_entities_from_sec_filing(doc, model)

import json

response_json = json.loads(response.message.content)

最后打印结果:

print("Extracted entities:", response_json.get('mentioned_entities'))
print("Extracted places:", response_json.get('mentioned_places'))
Extracted entities: ['United States', 'SECURITIES AND EXCHANGE COMMISSION']
Extracted places: []

抽取出的实体和地点,与之前使用 Outlines 抽取的结果相当不同。正如上一节所指出的,这并不完全出乎意料,因为我们使用的是一个小型开源模型。

Ollama 与 Pydantic

我们已经看到 Pydantic 可以与 JSON mode 一起使用,帮助生成更一致、受约束的响应。前面在介绍 Outlines 时也提到了它,而我们同样可以把它与 Ollama 一起使用。

让我们重新看一下 Ollama 示例,这次重点关注 Pydantic 的作用。

注意,我们创建了一个名为 SECExtraction 的类,它继承自 BaseModel,也就是 Pydantic 的核心类。

当我们创建一个继承自 BaseModel 的类时,我们是在告诉 Pydantic,这个新类,也就是这里的 SECExtraction,表示一个结构化数据模型。随后,我们告诉它:请根据我们在该类中定义的类型提示验证和解析输入。这里的类型提示是 mentioned_entitiesmentioned_places

然后,我们在 extract_entities_from_sec_filing 函数中,通过:

format=SECExtraction.model_json_schema()

引用该类。这就是 Pydantic 如何与 Ollama 一起验证输出:

from ollama import chat
from pydantic import BaseModel

class SECExtraction(BaseModel):
    mentioned_entities: list[str] = Field(
        description="Company names, executives, investment funds, tickers"
    )
    mentioned_places: list[str] = Field(
        description="Countries, cities, regions, headquarters locations"
    )

OLLAMA_STRUCTURED_OUTPUT_PROMPT_SUFFIX = "Return as JSON."
OLLAMA_STRUCTURED_OUTPUT_TEMPERATURE = 0

def extract_entities_from_sec_filing(doc: str, model: str) -> dict:
    """
    使用 Ollama chat 从 SEC filing 中抽取实体和地点。
    
    Args:
        doc: 要分析的 SEC filing 文本
        model: 用于抽取的 Ollama 模型
        
    Returns:
        chat 模型的原始响应
    """
    response = chat(
        messages=[
            {
                'role': 'user',
                'content': f"""{BASE_PROMPT}
                {OLLAMA_STRUCTURED_OUTPUT_PROMPT_SUFFIX}
                
                Document: {doc}"""
            }
        ],
        model=model,
        format=SECExtraction.model_json_schema(),
        # 设置为 0,以获得更确定性的输出
        options={'temperature': OLLAMA_STRUCTURED_OUTPUT_TEMPERATURE}  
    )
    return response

LangChain

我们之前在实现 RAG 时看过 LangChain,但它对结构化输出也很有用,因为它采用了与我们前面讨论过的约束系统不同的角度来处理结构化输出。LangChain 并不是在生成过程中实现 token 级 masking,而是在应用框架层面工作,通过其输出解析器,以及与提供原生 JSON mode 的模型供应商 API 集成,来提供结构化输出。当我们使用 LangChain 的 with_structured_output() 方法时,通常是在依赖底层模型提供商的结构化输出能力。LangChain 的价值在于把这些特定供应商实现抽象到统一接口之后,使我们能够在模型和供应商之间切换,同时保持一致的结构化输出行为。

然而,这种方法意味着 LangChain 用户最终依赖于所选模型供应商是否实现了真正的约束生成,或者只是依赖提示词工程加重试逻辑。对于没有原生结构化输出支持的模型,LangChain 会退回到带解析验证的提示词工程方法,这会重新引入 Ollama 和 Outlines 这类系统通过直接约束执行所解决的可靠性问题。因此,LangChain 非常适合快速原型开发和供应商抽象,但当处理缺乏原生结构化输出能力的模型时,它的可靠性可能低于直接约束生成。

本质上,LangChain 是一个便利层,为我们提供统一 API,用来访问所选模型供应商已经具备的结构化输出能力。

所以,当我们使用:

chain = model.with_structured_output(MySchema)

在底层,LangChain 实际上是在做:

# 对 OpenAI 模型
openai.chat.completions.create(response_format={"type": "json_object"})

LangChain 很适合处理那些已经具备良好结构化输出能力的模型。但如果我们需要从一个没有原生支持的模型中获得有保证的 JSON,仍然会希望在底层使用类似 Ollama 或 Outlines 的工具。

让我们看一个使用 LangChain 的 with_structured_output 从 10-K 示例中抽取实体名称的例子。

首先,我们定义抽取函数。注意,使用 LangChain 时,我们可以很容易在不同 API 接口之间切换:

from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic

from langchain_core.prompts import ChatPromptTemplate

def extract_from_sec_filing_langchain(
    sec_filing_text: str, 
    prompt: str
) -> SECExtraction:
    """
    使用 LangChain 从输入 SEC filing 文本中抽取结构化数据。
    """
    llm_openai = ChatOpenAI(model="gpt-4o-mini")

    structured_llm = llm_openai.with_structured_output(SECExtraction)
    
    prompt_template = ChatPromptTemplate.from_messages(
        [
            ("system", prompt),
            ("human", "{sec_filing_text}"),
        ]
    )

    llm_chain = prompt_template | structured_llm

    return llm_chain.invoke(sec_filing_text)

接下来,我们创建提示词,并把它传给由 LangChain 驱动的函数:

prompt_extraction = 
'''You are an expert at structured data extraction. 
You will be given unstructured text from an SEC filing and 
extracted names of mentioned entities and places and should 
convert the response into the given structure.'''

sec_extraction_langchain = 
extract_from_sec_filing_langchain(
    sec_filing,
    prompt_extraction
)

最后,我们打印结果:

print("Extracted entities:", sec_extraction_langchain.mentioned_entities)
print("Extracted places:", sec_extraction_langchain.mentioned_places)

我们可以看到,模型能够从输入文本中抽取实体和地点,并以指定格式返回它们。

工具比较

选择哪种结构化 LLM 输出框架,强烈依赖具体约束、需求和用例。LangChain 是目前使用最广泛的 LLM 框架之一,拥有庞大的开发者社区基础;不过,它的结构化输出生成依赖底层 LLM 提供商的支持。Ollama 支持直接的本地部署和实验,在促进隐私与控制的同时,让更多人能够使用 LLM,但目前它只提供 JSON 格式,后续还会支持更多格式。Outlines 则是一种解决方案,它为结构化输出生成提供形式化保证,同时拥有很强的灵活性和控制能力,并支持广泛的 LLM。表 5-1 提供了总结比较。

表 5-1 结构化输出框架比较

FeatureLangChainOutlinesOllama
Implementation approach使用 with_structured_output 方法封装 LLM 原生结构化输出 API调整模型输出 logits 的概率分布,以引导生成使用 llama.cpp GBNF grammars 约束输出格式
Model support仅限具备内置结构化输出 API 的 LLM通过 transformers、llama.cpp、exllama2、mlx-lm 和 vllm 广泛支持开源模型广泛支持,重点在于让开源模型能够本地运行
Output format supportTypedDict;JSON schema;Pydantic class多选生成;基于 regex 的结构;Pydantic model;JSON schema目前仅支持 JSON;计划支持更多格式
Key advantages与受支持 LLM 简单集成对输出结构提供保证;对生成过程有细粒度控制;强大的开源模型支持非常适合本地部署;设置和使用简单;内置模型服务
Use-case focus使用商业 LLM 的企业应用需要输出控制保证,或使用开源模型的应用本地部署和/或实验

结论

结构化输出可以把 LLM 从对话助手转变为生产系统中的可靠组件,因为它确保模型回答能够被程序化解析、验证,并集成进下游工作流,而不需要人工介入或容易出错的字符串解析。如果没有结构化输出,我们就需要编写复杂正则表达式或自定义 parser,从自由格式文本回答中抽取信息。这会引入脆弱性:只要模型措辞稍有变化,就可能破坏整个流水线。通过努力让 LLM 输出符合预定义 schema,我们可以在下游系统中把 LLM 回答当作更可靠的数据对象来处理。这并不会让模型在语义上变得确定,也不会让它事实正确,但它会降低解析自由格式文本的脆弱性。这使我们能够为数据库填充、自动报告生成,以及多步骤工作流等任务构建稳健应用,在这些任务中,一个模型的输出会成为另一个系统的输入。

  1. 关于这些内容,更多可见 Michael Xieyang Liu 等人在 2024 年发表的《“We Need Structured Output”: Towards User-Centered Constraints on Large Language Model Output》,这是一项由 Google 研究人员对 51 位专业人士进行的调查。
  2. 值得阅读开启 LLM 革命的论文《Attention Is All You Need》,作者为 Ashish Vaswani 等人,发表于 2017 年,以了解更多细节。这篇论文也是 AI 时代科学论文力量的一个杰出例子。
  3. Xun Liang 等,《Controllable Text Generation for Large Language Models: A Survey》(2024)。