网站优化

网站优化

Products

当前位置:首页 > 网站优化 >

LLM结构化输出代码示例和原理分析,你能详细讲解一下吗?

GG网络技术分享 2026-04-16 12:42 0


LLM结构化输出:从噩梦到美梦?代码示例和原理分析

坦白讲... 哎,说真的,搞大模型开发的人谁没被JSON输出气死过?你明明跟它说“请输出JSON”, 它非给你来一段“好的,这是你要的JSON: json ... ”,然后你正则表达式写了一堆,后来啊它又给你加个注释,或者字段名少个字母,直接报错!简直是崩溃!不过最近,闭源大模型们好像终于听到了我们的哀嚎,开始搞那个所谓的“结构化输出”了。这玩意儿到底是个啥?真的能拯救我们这些苦逼的程序员吗?今天我们就来扒一扒这个LLM结构化输出的代码示例和原理,顺便吐槽一下。

Structure Output到底是个啥?

绝绝子! 先说说 我们要搞清楚,这里说的不是OpenAI很早就支持的Json Mode,那个老版本的Json Mode其实挺鸡肋的。它只保证模型输出一个合法的、 可以解析的json而已,至于json里面的字段对不对,字段类型是不是你想要的,取值范围有没有约束,它是一概不管的!这就是所谓的“弱约束”。但是!现在的Structure Output,这是JSON Mode的升级版啊!虽然只对gpt-4o-mini-2024-07-18和gpt-4o-2024-08-06之后的模型版本支持,但这真的是质的飞跃。

解密prompt系列46. LLM结构化输出代码示例和原理分析

简单说Structure Output会进一步对JSON里面的具体字段和类型进行约束。这就好比以前你让模型去菜市场买菜, 它可能给你买回一堆烂叶子;现在你给了它一个严格的清单,必须买什么、买多少、什么成色,它就得照单全收。这多爽?我们举个例子, 比如我们要从基金季报中抽取基金经理对市场不同行业的观点,对观点进行情绪分类,并关联相关的申万一级行业。这任务要是放在以前,非得把人累死,现在有了结构化输出,感觉世界都清净了。

图啥呢? 这里提供两种不同的实现方案, 一种是基于条件解码的强约束方案,和基于指令的弱约束方案,并且会给出不同方案对模型推理效果的影响。大家看好了这可是干货。

代码示例:闭源模型的狂欢

我们先来看看怎么用代码实现这个。针对不满足openai版本条件的老模型,我们可以使用instructor来实现结构化输出。Instructor这个库,真的是个神器,虽然它本质上是在打猴子补丁,但是打得好啊,我不敢苟同...!

from openai import AzureOpenAI
import instructor
client = AzureOpenAI(
    api_key ='...',
    api_version= "2024-08-01-preview",
    azure_endpoint = "..."
)
client = instructor.from_openai
resp = client.chat.completions.create(
    model='gpt-4o',
    response_model= ViewExtraction,
    messages=,
)

看到没?就这么简单!在上面使用`instructor.from_openai`时 Instructor会打猴子补丁,在常规openai的接口上,增加`response_model`的预处理,和对输出的retry机制。这简直就是懒人福音!你不需要自己去写那些繁琐的解析逻辑,Instructor都帮你搞定了。不过这里我们定义的`ViewExtraction`是个啥?别急,我们后面会讲。

结构化输出方案大比拼

为了让大家更直观地理解, 我特意搞了个表格,对比一下现在市面上常见的几种结构化输出方案。虽然有点乱,但是凑合看吧,反正能说明问题。

方案名称 类型 原理 优点 缺点 适用场景
OpenAI Structured Output 强约束 基于Constrained Decoding, 直接在推理层限制Token输出 极其稳定,类型平安,几乎不产生幻觉 只支持特定新模型,枚举值有限制 对数据格式要求极高的生产环境
Instructor 强/弱约束混合 Pydantic模型定义 + API层面的适配和重试 代码写起来爽,支持多种模型接口 依赖模型本身的能力,老模型效果打折 快速开发,兼容多种API
Outlines 强约束 正则表达式引导的生成,FSM状态机 开源,可控性极强,支持本地部署 需要自己部署模型,技术门槛稍高 本地推理,需要精细控制输出格式
FRI 弱约束 纯Prompt Engineering,告诉模型怎么输出 通用性强,什么模型都能试 极其不稳定,经常需要正则二次清洗 模型太老,没别的办法的时候

看完了表格,是不是心里有数了?如果你有钱, 直接上OpenAI最新的Structured Output;如果你要省钱或者用本地模型, 太顶了。 Outlines是个好选择;如果你像我一样懒,Instructor绝对是首选。

原理分析:Constrained Decoding的魔法

那instructor,openai这些结构化输出能力都是如何实现的呢?下面我们来看几种约束模型给出结构化输出的方案。其实核心原理就是“条件解码”。这听起来很高大上, 其实说白了就是在每一步解码时都对输出词表进行MASK,只允许模型对当前位置符合输出格式的Token进行预测,把原始基于完整词表的softmax,转换成对于局部掩码词表的softmax。

尊嘟假嘟? 那问题其实就简化成了在每一步推理时如何选择该进行掩码的Token呢?毕竟GPT预测是自左向右,无法获得完整Token序列。论文把基于输出格式掩码的问题,转换成了基于有限状态机的状态转移问题。简单解释下FSM其实就是由一组状态和状态之间的转移过程组成, 词表中的字符满足条件的可以匹配到FSM的某个或某几个状态,从而在碰到字符A后就可以确认几种满足条件的状态转移路径,从而根据后面的路径确认掩码词表。

最终的最终。 主要原因是词表中的每个字符究竟满足哪些状态, 每个状态后有哪些可能的转移状态这些都是预先计算好的,所以呢并不需要在推理中动态计算,相反可以预先构建好每个词表到状态,再到后续转移状态的mapping。在解码过程中只需要根据解码字符读取mapping,对下一个字符进行对应的掩码即可。所以呢算法的时间复杂度是O,空间复杂度是O。这效率,杠杠的!

我们来看一段基于已经构建好的FSM进行解码的步骤代码,这段代码是Outlines里面的实现逻辑:,操作一波。

def sequence_generator:
    while True:
        # 1. 获取模型输出的logits
        logits, kv_cache = model
        # 2. 获取FSM允许的下一个token
        allowed_tokens = get_allowed_tokens
        # 3. 基于allowed_tokens对logits进行mask,不允许的token均为-inf
        biased_logits = bias_logits
        # 4. 采样下一个token
        next_token_ids, ancestors, sequence_weights = sampler
        # 5. 更新FSM状态
        fsm_states = get_next_fsm_states
        # 6. 检查是否生成完成
        is_finished = is_generation_finished

看懂了吗?没看懂也没关系, 反正核心就是那个`bias_logits`,把不符合条件的Token概率变成负无穷大, 掉链子。 让模型根本选不到它们。这就是强约束的威力!

结构化输出会变笨吗?一场激烈的争论

当冤大头了。 针对上述的两种结构化解码方案,对比常规的自然语言推理对模型效果的影响几何?我先是读到的第一篇论文,核心结论其实是结构化输出会影响模型的推理效果。这篇论文叫《Let Me Speak Freely》,听起来就很委屈,好像模型被束缚了手脚一样。作者认为,强制模型输出特定的格式,会占用模型的“认知资源”,导致推理能力下降。

勇敢一点... 但是!接着Outlines的作者们就发了一篇博客指出了论文的几个核心问题。双方各自站的立场不同,但逻辑上个博客指出的几个论文的核心问题确实很有说服力。博客给出的到头来结论是在GSM8k, Last Letter,Shuffled Object这三个任务上结构化输出相比NL输出都有提升。并且直接给出了基于Outlines的后来啊复现代码github repo。

但是吸取前面盲目偏信前一篇论文的教训, 其实在平时的任务尝试上,个人感觉结构化输出的效果和具体任务,Prompt质量,模型本身的指令能力强相关。所以呢还是倾向于在应用时充分对比NL和Structure的效果后再做应用。 躺平。 在大模型时代很多结论都有领域和模型局限性, 大家需要在自己的场景上审慎判断哈哈~

实战:定义你的Pydantic模型

我可是吃过亏的。 说了这么多理论,还是得回到代码。先说说我们先定义抽取任务的结构体, 申万一级行业的枚举值和情绪的枚举值,这里结构化输出都是使用pydantic定义的。通过枚举值定义我们可以约束模型输出的取值范围,而通过抽取结构定义我们可以约束模型输出的结构。不过这里对Enum的取值数量有限制一次输出的枚举值总量不能超过500, 毕竟是直接作为模型上文,枚举值太多一是慢二是贵,三是不稳定。

from enum import Enum
from typing import List
from pydantic import BaseModel, Field
class SWIndustry:
    AGRICULTURE = "农林牧渔"
    MINING = "采掘"
    CHEMICALS = "化工"
    STEEL = "钢铁"
    # ... 这里省略一堆行业, 反正就是枚举嘛
    COMPREHENSIVE = "综合"
class ViewAspect:
    POSITIVE = '正面'
    NETURAL = '中性'
    NEGATIVE = '负面'
class View:
    extract_view: str = Field
    extract_view_entities: List = Field
    related_industry: list = Field
    view_aspect: ViewAspect = Field
class ViewExtraction:
    views: list = Field(
        ...,
        description="每个观点都应该是一个单独的对象,包含原文中表达观点的句子,观点主体,观点情绪分类和关联的申万一级行业",
    )

然后只需要把以上的结构体作为`response_format`的参数输入openai即可。这里我们还是举论文中的例子。我们的输出要求是满足浮点数“?.?0-9”。这个输出约束可以被转换成FSM中的4种不同状态,每个状态有不同的转移状态,又爱又恨。。

假设我们的词表只有5个字符{"A", ".", "42", ".2", "1"},那整个FSM掩码过程如下。这过程虽然复杂,但对于计算机来说就是查表而已,快得很,我当场石化。。

顺便聊聊Context Cache

说到这里我突然想起来Context Cache的使用几乎已经是行业共识,目标是优化大模型首Token的推理延时,在多轮对话,超长System Prompt,超长结构化JSON和Few-shot等应用场景,是不可或缺的。这一章我们主要从原理、一些论文提出的cache优化项和VLLM开源项目入手,分析下context Cache的实现和适合场景。

重... 只所以对self-attention的KV进行缓存,主要原因是在Transformer的众多计算单元中只有self-attention是上下文依赖的,也就是在计算第k个token的输出时,需要使用第k个token和前面K-1个token的进行内机计算,使得self-attention的计算复杂度随序列长度平方增长。而如果对历史序列中的KV... 哎呀, 扯远了这个跟结构化输出关系不大,但是既然想到了就顺便提一下毕竟做系统优化的时候,这两个经常是一起出现的。

Function Calling也是结构化输出的一种

再举一个function calling的例子, 假设我们有两个工具一个Bing搜索,一个是基金信息查询工具,模型需要的指令被转换成了以下函数调用的指令格式,又爱又恨。。

from typing import Literal, Union, Optional
class BingSearch:
    query: str = Field
class FundInfo:
    """
    可以通过基金代码或基金名称, 查询基金基础信息
    """
    fund_code_or_name: Optional = Field
    lookup_field: Literal
class Task:
    name: str = Field
    tool: Union = Field
class TaskSequence:
    reason: str = Field
    task_actions: List = Field
completion = client.chat.completions.create(
    model='gpt-4o',
    messages=,
    response_format= TaskSequence
)

这里我们就能得到结构化的输出如下。看到那个JSON了吗?这就是我们要的!干净、利落、没有废话,试试水。!

{
  'name': 'TaskSequence',
  'description': 'Correctly extracted `TaskSequence` with all  required parameters with correct types',
  'parameters': {
    '$defs': {
      'BingSearch': {
        'properties': {
          'query': {
            'description': '网页搜索query',
            'title': 'Query',
            'type': 'string'
          }
        },
        'required': ,
        'title': 'BingSearch',
        'type': 'object'
      },
      # ... 这里省略了FundInfo和Task的定义, 反正就是一大堆JSON Schema
    },
    'properties': {
      'reason': {
        'description': '先逐步思考要解决用户的问题需要哪些步骤',
        'title': 'Reason',
        'type': 'string'
      },
      'task_actions': {
        'description': '任务列表,按施行顺序依次排列',
        'items': {
          '$ref': '#/$defs/Task'
        },
        'title': 'Task Actions',
        'type': 'array'
      }
    },
    'required': ,
    'type': 'object'
  }
}

一下

写到这里我都不知道自己写了些啥了。反正结构化输出这东西,对于咱们开发者绝对是利大于弊的。虽然有些论文说它会影响模型智商, 但我觉得吧,只要能稳定输出我要的JSON,智商低一点点就低一点点吧,反正我可以在Prompt里多喂点Few-shot给它补补脑,换个角度。。

开源项目Outlines的两位作者Brandon T. Willard和R´emi Louf是比较早提出大模型可控生成方案的大佬。如果你用API调模型那Instructor更合适, 如果你自己部署模型调用那Outlines更合适,vllm这些推理框架最新的版本也已经融入了Outlines。这里我们就选Instructor进行介绍。还是上面的例子, 输出格式的定义相同,针对不满足openai版本条件的老模型,我们可以使用instructor来实现结构化输出,我们都曾是...。

FRI缺少严格约束, 所以只能依赖模型的指令遵从能力,有一定概率输出后来啊会无法还原成原始的的Pydantic类型。下面我们看另一种强约束的方案。条件解码方案其实就是在每一步解码时都对输出词表进行MASK, YYDS... 只允许模型对当前位置符合输出格式的Token进行预测,把原始基于完整词表的softmax,转换成对于局部掩码词表的softmax。

再说说 祝大家在新的一年里代码如诗行云流水,bug如朝露见光即散;创意如泉涌,论文如宝藏,实验如神助,成功率百分百! 躺赢。 科研路上,你我皆是‘码’到成功的幸运儿!🎉 哎,不说了继续去调我的Prompt了这模型怎么又不输出JSON了...


提交需求或反馈

Demand feedback