最近闭源大模型们都陆续支持结构化输出,这一章我们先结合demo看下开源和闭源对结构化输出的支持,随后会介绍Constrained Decoding和Format Restricting Instructions 两种结构化输出约束方案,最后会给出结构化输出对比自然语言输出的一些观点。

代码示例

闭源 - OpenAI

闭源三巨头都是支持结构化输出的,上面链了OpenAI和Gemini关于结构化输出的相关API文档。这里我们就以OpenAI为例,聊下结构化输出。

这里并非指OpenAI很早就支持的Json Mode,而JSON Mode的升级版Structure Output,只对gpt-4o-mini-2024-07-18和gpt-4o-2024-08-06之后的模型版本支持。简单说原来的JSON Mode只保证模型输出一个合法可以解析的json而已,对json的字段,字段类型,取值不做任何约束,而Strucutre Ouput则会进一步对JSON里面的具体字段和类型进行约束。这里我们举个例子,从基金季报中抽取基金经理对市场不同行业的观点,对观点进行情绪分类,并关联相关的申万一级行业。(哈哈并不是说这是最优的解决方案只是想把抽取,分类,生成任务融在一个case里面)

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

from enum import Enum
from typing import List
from pydantic import BaseModel, Field class SWIndustry(Enum):
AGRICULTURE = "农林牧渔"
MINING = "采掘"
CHEMICALS = "化工"
STEEL = "钢铁"
NONFERROUS_METALS = "有色金属"
BUILDING_MATERIALS = "建筑材料"
ELECTRICAL_EQUIPMENT = "电气设备"
APPLIANCES = "家用电器"
FOOD_BEVERAGE = "食品饮料"
TEXTILE_APPAREL = "纺织服装"
LIGHT_MANUFACTURING = "轻工制造"
PHARMACEUTICALS = "医药生物"
PUBLIC_UTILITIES = "公用事业"
TRANSPORTATION = "交通运输"
REAL_ESTATE = "房地产"
COMMERCE_TRADE = "商业贸易"
COMPUTER = "计算机"
MEDIA = "传媒"
COMMUNICATION = "通信"
BANKING = "银行"
NON_BANK_FINANCIAL = "非银金融"
AUTOMOBILE = "汽车"
MACHINERY = "机械设备"
DEFENSE_MILITARY = "国防军工"
BUILDING_CONSTRUCTION = "建筑装饰"
ELECTRONICS = "电子"
COMPREHENSIVE = "综合"
LEISURE_SERVICES = "休闲服务"
COMPUTER_APPLICATIONS = "计算机应用"
CHEMICAL_FIBERS = "化纤"
METAL_PRODUCTS = "金属制品" class ViewAspect(Enum):
POSITIVE = '正面'
NETURAL = '中性'
NEGATIVE = '负面' class View(BaseModel):
extract_view: str = Field(description="抽取文档中中对某个金融行业、或行业相关的主题或概念表达观点的句子")
extract_view_entities: List[str] = Field(description="抽取观点金融主体,该主体必须出现在观点句子中,可以是金融行业,或行业相关概念或主题")
related_industry: list[SWIndustry] = Field(description=f"观点金融主体最相关的1个或多个申万一级行业")
view_aspect: ViewAspect = Field(description=f'对观点情绪进行精准分类,模糊情绪均为中性') class ViewExtraction(BaseModel):
views: list[View] = Field(
...,
description="每个观点都应该是一个单独的对象,包含原文中表达观点的句子,观点主体,观点情绪分类和关联的申万一级行业",
)

然后只需要把以上的结构体作为response_format的参数输入openai即可

from openai import AzureOpenAI

client = AzureOpenAI(
api_key = '...',
api_version="2024-08-01-preview",
azure_endpoint= "..."
) completion = client.beta.chat.completions.parse(
model='gpt-4o',
messages=[
{
"role": "system",
"content": "你是一个完美的金融观点解析系统,可以从文档中抽取观点,和观点对应主体并对观点进行分类。请从以下文档中抽取一系列观点",
},
{
"role": "user",
"content": content,
},
],
response_format= ViewExtraction
)

这样我们就能得到结构化的输出如下

再举一个function calling的例子,假设我们有两个工具一个Bing搜索,一个是基金信息查询工具,模型需要根据用户提问选择一个或多个工具来解决问题。

from typing import Literal,Union,Optional
class BingSearch(BaseModel):
query: str = Field(description="网页搜索query") class FundInfo(BaseModel):
"""
可以通过基金代码或基金名称,查询基金基础信息
"""
fund_code_or_name: Optional[str] = Field(description="提问提及的基金代码或名称,没有则为空")
lookup_field: Literal["fund_manager", "unit_value","contract_date","manage_fee","net_value"] class Task(BaseModel):
name: str = Field(description="任务名称")
tool: Union[BingSearch, FundInfo] = Field(description="完成任务所需调用的工具") class TaskSequence(BaseModel):
reason: str = Field(description="先逐步思考要解决用户的问题需要哪些步骤")
task_actions: List[Task] = Field(description="任务列表,按执行顺序依次排列") completion = client.beta.chat.completions.parse(
model='gpt-4o',
messages=[
{
"role": "system",
"content": "你是一个金融工具助手,可以完美根据用户提问选择需要调用的工具列表",
},
{
"role": "user",
"content": '天弘中证500当前的管理费是多少,是否随着基金规模的增加而增加',
},
],
response_format= TaskSequence
)

然后我们就能得到下面的结构化输出啦~

开源实现 - Instructor

开源也有一些方案是针对结构化输出的,例如Instructor, Outlines。简单对比的话如果你用API调模型那Instructor更合适,如果你自己部署模型调用那Outlines更合适,vllm这些推理框架最新的版本也已经融入了Outlines。这里我们就选Instructor进行介绍。还是上面的例子,输出格式的定义相同,针对不满足openai版本条件的老模型,我们可以使用instructor来实现结构化输出。

from openai import AzureOpenAI
import instructor
client = AzureOpenAI(
api_key = '...',
api_version="2024-08-01-preview",
azure_endpoint= "..."
)
client = instructor.from_openai(client) resp = client.chat.completions.create(
model='thgpt4o',
response_model= ViewExtraction,
messages=[
{
"role": "system",
"content": "你是一个完美的金融观点解析系统,可以从文档中抽取观点,和观点对应主体并对观点进行分类。请从以下文档中抽取一系列观点",
},
{
"role": "user",
"content": content,
},
],
)

那instructor,openai这些结构化输出能力都是如何实现的呢?下面我们来看几种约束模型给出结构化输出的方案

实现原理

这里提供两种不同的实现方案,一种是基于条件解码的强约束方案,和基于指令的弱约束方案,并且会给出不同方案对模型推理效果的影响。

Constrained Decoding

开源项目Outlines的两位作者Brandon T. Willard和R´emi Louf是比较早提出大模型可控生成方案的大佬。

条件解码方案其实就是在每一步解码时都对输出词表进行MASK(Regular Expression Guided Masking),只允许模型对当前位置符合输出格式的Token进行预测,把原始基于完整词表的softmax,转换成对于局部掩码词表的softmax。

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

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

这里我们还是举论文中的例子。我们的输出要求是满足浮点数“([0-9])?.?[0-9]”。这个输出约束可以被转换成FSM中的4种不同状态,每个状态有不同的转移状态(哈哈下面的例子是DeepSeek给大家举的)

  • 状态 0: 初始状态,可以进入状态1和2
  • 状态 1: 匹配数字 [0-9],可以继续在状态1或者去状态2
  • 状态 2: 匹配小数点 [.],只能进入状态3
  • 状态 3: 匹配小数点后的数字 [0-9],可以继续在状态3

假设我们的词表只有5个字符{"A", ".", "42", ".2", "1"},那整个FSM掩码过程如下(以下词表选择过程是读取预先构建好的index)

  • 步骤 1:初始化 FSM。我们从状态 0 开始。
  • 步骤 2:查找当前状态下允许的词汇。在状态 0,根据 FSM,我们可以匹配数字 [0-9] 或小数点 [.]。因此,我们允许的词汇是:{".", "42", ".2", "1"}。
  • 步骤 3:选择一个词汇并更新 FSM 状态。假设我们选择了 ".2"。选择 ".2" 后,FSM 从状态 0 进入状态 2(因为匹配了小数点 [.])。然后,FSM 继续匹配 "2",进入状态 3。
  • 步骤 4:继续生成下一个词汇。在状态 3,我们只能匹配数字 [0-9]。因此,允许的词汇是:{"42", "1"}。假设我们选择了 "1"。选择 "1" 后,FSM 保持在状态 3。
  • 步骤 5:生成结束。如果我们选择了一个表示结束的特殊词汇(如 EOS),生成过程结束。

基于已经构建好的FSM进行解码的步骤在Outlines里面如下(./generator/generatae.py)(哈哈下面的代码是cursor帮忙直接定位到的)

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

Format Restricting Instructions

FRI是更简单的实现方案,也就是在指令中加入对应输出的约束。这里还是拿Instructor来举例子吧,虽然这并不准确,因为Instructor调用的API接口背后还是做了Constrained Decoding的逻辑,Instructor其实只是从中做了一层Adapter。但是不妨碍我们通过instructor的实现来看下如何把pydantic的定义转换成结构化输出的指令约束。

在上面使用instructor.from_openai(client)时,Instructor会打猴子补丁,在常规openai的接口上,增加response_model的预处理,和对输出的retry机制(patch.py)

@overload
def from_openai(
client: openai.OpenAI,
mode: instructor.Mode = instructor.Mode.TOOLS,
**kwargs: Any,
) -> Instructor:
pass @overload
def patch(
client: OpenAI,
mode: Mode = Mode.TOOLS,
) -> OpenAI: ... def patch( # type: ignore
client: OpenAI | AsyncOpenAI | None = None,
create: Callable[T_ParamSpec, T_Retval] | None = None,
mode: Mode = Mode.TOOLS,
) -> OpenAI | AsyncOpenAI:
"""
Patch the `client.chat.completions.create` method Enables the following features: - `response_model` parameter to parse the response from OpenAI's API
- `max_retries` parameter to retry the function if the response is not valid
- `validation_context` parameter to validate the response using the pydantic model
- `strict` parameter to use strict json parsing
- `hooks` parameter to hook into the completion process
""" logger.debug(f"Patching `client.chat.completions.create` with {mode=}") if create is not None:
func = create
elif client is not None:
func = client.chat.completions.create
else:
raise ValueError("Either client or create must be provided") @wraps(func) # type: ignore
def new_create_sync(
response_model: type[T_Model] | None = None,
validation_context: dict[str, Any] | None = None,
context: dict[str, Any] | None = None,
max_retries: int | Retrying = 1,
strict: bool = True,
hooks: Hooks | None = None,
*args: T_ParamSpec.args,
**kwargs: T_ParamSpec.kwargs,
) -> T_Model:
context = handle_context(context, validation_context) response_model, new_kwargs = handle_response_model(
response_model=response_model, mode=mode, **kwargs
) new_kwargs = handle_templating(new_kwargs, context) response = retry_sync(
func=func, # type: ignore
response_model=response_model,
context=context,
max_retries=max_retries,
args=args,
hooks=hooks,
strict=strict,
kwargs=new_kwargs,
mode=mode,
)
return response # type: ignore
new_create = new_create_async if func_is_async else new_create_sync
if client is not None:
client.chat.completions.create = new_create # type: ignore
return client
else:
return new_create # type: ignore

其中handle_response_model的部分会针对不同模型的API接口进行不同的指令处理,上面使用OpenAI时使用了工具调用模式来实现结构化输出。

def openai_schema(cls) -> dict[str, Any]:
"""
Return the schema in the format of OpenAI's schema as jsonschema Note:
Its important to add a docstring to describe how to best use this class, it will be included in the description attribute and be part of the prompt. Returns:
model_json_schema (dict): A dictionary in the format of OpenAI's schema as jsonschema
"""
schema = cls.model_json_schema()
docstring = parse(cls.__doc__ or "")
parameters = {
k: v for k, v in schema.items() if k not in ("title", "description")
}
for param in docstring.params:
if (name := param.arg_name) in parameters["properties"] and (
description := param.description
):
if "description" not in parameters["properties"][name]:
parameters["properties"][name]["description"] = description parameters["required"] = sorted(
k for k, v in parameters["properties"].items() if "default" not in v
) if "description" not in schema:
if docstring.short_description:
schema["description"] = docstring.short_description
else:
schema["description"] = (
f"Correctly extracted `{cls.__name__}` with all "
f"the required parameters with correct types"
) return {
"name": schema["title"],
"description": schema["description"],
"parameters": parameters,
}

拿前面基金Function Call的例子来说,实际进入GPT模型的指令被转换成了以下函数调用的指令格式

{'name': 'TaskSequence',
'description': 'Correctly extracted `TaskSequence` with all the required parameters with correct types',
'parameters': {'$defs': {'BingSearch': {'properties': {'query': {'description': '网页搜索query',
'title': 'Query',
'type': 'string'}},
'required': ['query'],
'title': 'BingSearch',
'type': 'object'},
'FundInfo': {'description': '可以通过基金代码或基金名称,查询基金基础信息',
'properties': {'fund_code_or_name': {'anyOf': [{'type': 'string'},
{'type': 'null'}],
'description': '提问提及的基金代码或名称,没有则为空',
'title': 'Fund Code Or Name'},
'lookup_field': {'enum': ['fund_manager',
'unit_value',
'contract_date',
'manage_fee',
'net_value'],
'title': 'Lookup Field',
'type': 'string'}},
'required': ['fund_code_or_name', 'lookup_field'],
'title': 'FundInfo',
'type': 'object'},
'Task': {'properties': {'name': {'description': '任务名称',
'title': 'Name',
'type': 'string'},
'tool': {'anyOf': [{'$ref': '#/$defs/BingSearch'},
{'$ref': '#/$defs/FundInfo'}],
'description': '完成任务所需调用的工具',
'title': 'Tool'}},
'required': ['name', 'tool'],
'title': 'Task',
'type': 'object'}},
'properties': {'reason': {'description': '先逐步思考要解决用户的问题需要哪些步骤',
'title': 'Reason',
'type': 'string'},
'task_actions': {'description': '任务列表,按执行顺序依次排列',
'items': {'$ref': '#/$defs/Task'},
'title': 'Task Actions',
'type': 'array'}},
'required': ['reason', 'task_actions'],
'type': 'object'}}

FRI缺少严格约束,所以只能依赖模型的指令遵从能力,有一定概率输出结果会无法还原成原始的的Pydantic类型。下面我们看另一种强约束的方案。

优劣对比

针对上述的两种结构化解码方案,对比常规的自然语言推理对模型效果的影响几何?我先是读到的第一篇论文(Let Me Speak Freely),核心结论其实是结构化输出会影响模型的推理效果。

但是随后Outlines的作者们就发了一篇博客指出了论文的几个核心问题。双方各自站的立场不同,但逻辑上个博客指出的几个论文的核心问题确实很有说服力,包括

  • 论文使用自然语言推理和使用结构化输出推理的指令不同,因此效果不可比
  • 论文使用了第二个大模型对结构化输出的结果进行解析(引入了更多错误),实际上正确的使用方式应该是直接使用推理输出来还原pydantic model即可,毕竟大家使用结构化输出的其中一个原因就是更好解析。
  • 论文使用的结构化输出prompt质量有待提升

博客给出的最终结论是在GSM8k,Last Letter,Shuffled Object这三个任务上结构化输出相比NL输出都有提升。并且直接给出了基于Outlines的结果复现代码github repo(这里强烈建议大家去瞅瞅上面的博客,对于结构化输出有些很有意思的见解)

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

“新年伊始,愿各位代码如诗行云流水,bug如朝露见光即散;创意如泉涌,论文如宝藏,实验如神助,成功率百分百!科研路上,你我皆是‘码’到成功的幸运儿!”

想看更全的大模型论文·微调预训练数据·开源框架·AIGC应用 >> DecryPrompt

解密prompt系列46. LLM结构化输出代码示例和原理分析的更多相关文章

  1. 解密Prompt系列6. lora指令微调扣细节-请冷静,1个小时真不够~

    上一章介绍了如何基于APE+SELF自动化构建指令微调样本.这一章咱就把微调跑起来,主要介绍以Lora为首的低参数微调原理,环境配置,微调代码,以及大模型训练中显存和耗时优化的相关技术细节 标题这样写 ...

  2. 解密prompt系列5. APE+SELF=自动化指令集构建代码实现

    上一章我们介绍了不同的指令微调方案, 这一章我们介绍如何降低指令数据集的人工标注成本!这样每个人都可以构建自己的专属指令集, 哈哈当然我也在造数据集进行时~ 介绍两种方案SELF Instruct和A ...

  3. 解密Prompt系列3. 冻结LM微调Prompt: Prefix-Tuning & Prompt-Tuning & P-Tuning

    这一章我们介绍在下游任务微调中固定LM参数,只微调Prompt的相关模型.这类模型的优势很直观就是微调的参数量小,能大幅降低LLM的微调参数量,是轻量级的微调替代品.和前两章微调LM和全部冻结的pro ...

  4. 解密Prompt系列2. 冻结Prompt微调LM: T5 & PET & LM-BFF

    这一章我们介绍固定prompt微调LM的相关模型,他们的特点都是针对不同的下游任务设计不同的prompt模板,在微调过程中固定模板对预训练模型进行微调.以下按时间顺序介绍,支持任意NLP任务的T5,针 ...

  5. 解密Prompt系列4. 升级Instruction Tuning:Flan/T0/InstructGPT/TKInstruct

    这一章我们聊聊指令微调,指令微调和前3章介绍的prompt有什么关系呢?哈哈只要你细品,你就会发现大家对prompt和instruction的定义存在些出入,部分认为instruction是promp ...

  6. WebChart网页局域网聊天系列(二):服务器结构及核心代码

    public partial class MainForm : Form { private Socket server;//服务器Socket private int userNum;//当前在线用 ...

  7. 4a-c++ primer宽字符wchar_t显示设置与输出代码示例

    .. #include <iostream> #include <windows.h> #include <locale> //#include<wchar. ...

  8. 4.Java 加解密技术系列之 HMAC

    Java 加解密技术系列之 HMAC 序 背景 正文 代码 结束语 序 上一篇文章中简单的介绍了第二种单向加密算法 — —SHA,同时也给出了 SHA-1 的 Java 代码.有这方面需求的童鞋可以去 ...

  9. 对结构化学习(structured learning)的理解

    接触深度学习以来一直接触的概念都是回归,分类,偶尔接触到结构化学习的概念,似懂非懂的糊弄过去,实在是不负责的表现 翻阅维基百科https://en.wikipedia.org/wiki/Structu ...

  10. 有效的结构化思维训练,MECE分析法

    MECE原则,表达精准分类与全面性的有效利器 结构化思维的本质就是逻辑,其目的在于对问题的思考更完整.更有条理,它帮助我们一个一个找到线头,理清思路,探求事物之间的相互联系.MECE分析法是一种结构化 ...

随机推荐

  1. 想玩Steam游戏,但配置太低?ToDesk云电脑一招搞定!

    在游戏爱好者的世界里,汇集了许多游戏大作的Steam平台无疑是一座宝库.但对于许多玩家来说,拥有一颗渴望畅玩游戏的心,却常常被低配置的电脑设备所束缚.尤其是面对硬件要求极高的3A大作时,低配置的电脑往 ...

  2. 选择程序设计(python)

    文章目录 1.基本概念 2.程序基本结构 3.实例 3.1 判断一个人年龄属于未成年人还是成年人 3.2判断是否是闰年 1.基本概念 Python选择程序设计是通过一条或多条语句的执行结果(True或 ...

  3. Protues中51单片机按键无法复位(已解决)

    前言 昨晚用 Protues 搭建了 51 的最小系统电路,在实物中好用的复位电路,到仿真里不能正常复位了. 51 单片机是高电平复位,所以在运行时 RST 引脚应该是低电平,但在仿真中 RST 引脚 ...

  4. String,StringBuffer、StringBuilder的区别

    1.可变性 String:是不可变的.其内部是fianl修饰的,每次变更都会创建一个新的对象. StringBuffer.StringBuilder是可变的,字符串的变更是不会创建新对象的. 2.线程 ...

  5. 货店管理(delphi+sqlserver)

    之前给朋友做的货店管理程序,个人使用,数据量小,delphi开发的,sqlserver express版,fastReport做的报表(报表可以修改). 源代码全给他的,呵呵,他也可以简单修改了.   ...

  6. IPC-7711/21D, IPC-7711D, IPC-7721D 电子组件的返工、修改和维修,验收标准。Rework, Modification and Repair of Electronic Assemblies

    IPC-7711/21 - Revision D - Standard Only: Rework, Modification and Repair of Electronic Assemblies T ...

  7. Spring + EHcache配置

    需要使用Spring来实现一个Cache简单的解决方案,具体需求如下:使用任意一个现有开源Cache Framework,要求可以Cache系统中Service或则DAO层的get/find等方法返回 ...

  8. 一、STM32F103C8T6--GPIO

    STM32f103c8t6 32位Cortex-M3内核 RISC处理器,最高主频72MHZ,Flash:64KB,SRAM:20KB 片上外设: I/O端口: 多达37个GPIO引脚(支持复用功能) ...

  9. 八、FreeRTOS学习笔记-临界段代码保护及调度器挂起与恢复

    临界段代码保护 什么是临界段:临界段代码也叫做临界区,是指那些必须完整运行,不能被打断的代码段 适用场合如: 问题:什么可以打断当前程序的运行? 1.临界段代码保护函数介绍 FreeRTOS 在进入临 ...

  10. golang之go-spew

    github: https://github.com/davecgh/go-spew 我们在使用Golang(Go语言)开发的过程中,会通过经常通过调试的方式查找问题的原因,解决问题,尤其是当遇到一个 ...