whaosoft aiot   http://143ai.com
# LLM 结构化数据生成原理

如何结合人工规则让 LLM 输出符合 JSON 格式的数据。

目前 LLM(Large Language Model)从文本补全到内容创作,都展示出了强大的生成能力。然而通过 LLM 生成结构化的数据如 JSON 格式的输出,却仍然是一个有挑战性的任务。

生成结构化的数据不仅要求模型输出符合特定的语法规则,还需要确保数据的正确性和一致性。

虽然通过 prompt 工程可能可以实现指定格式的结构化数据生成,但是这也很大程度取决于模型的能力。

本文将探讨如何结合人工规则让 LLM 输出符合 JSON 格式的数据。

结构化生成原理

本文主要是结合 lm-format-enforcer ( https://github.com/noamgat/lm-format-enforcer ) 这个库来讲解如何让 LLM 生成指定格式的 JSON 数据。

目前该库也是被 vllm 作为 JSON 格式输出的后端之一:https://github.com/vllm-project/vllm/blob/main/vllm/model_executor/guided_decoding/lm_format_enforcer_decoding.py

结构化数据生成的原理用一句话概括就是:

每个 step 拿到当前 model 给出的 logits 之后,在采样下一个 token 之前,通过人工设定的规则可以得到当前 step 只允许采样的 token 集合,接着通过加 bias 的方式压制其他不允许采样的 token,从而实现指定的结构化数据生成。

那么怎么得到当前 step 可允许采样的 token 集合,就是本文重点讲解的内容了。

lm-format-enforcer 这个库包含两个核心模块,分别是 tokenizer 前缀树 和 字符级别的解析器,通过这两个模块就可以实现上述的功能。

构造 tokenizer 前缀树

lm-format-enforcer 这个库在初始化阶段,首先会根据 tokenizer 给出的词表,初始化一个字符级别的前缀树,这个前缀树怎么理解呢?

通过 tokenizer 给出的词表,我们可以得到一个词表中的 字符串 和 对应 token id 的映射。通过这些映射,就可以来构造这个前缀树。

树上每个节点对应词表中某个字符串的其中一个字符,每个节点的子节点就是连着的下一个字符,当字符串中的字符已经遍历完了,这时候就是填入该字符串对应的 token id。

现在通过具体的例子解释一下,这个前缀树是如何构造的。

我们用 llama2 模型的词表来解读,假设就取词表中的一个小子集:

{    
    "a": 29874,  
    "ar" : 279,  
    "are" : 598,  
    "Y": 29979,  
    "You" : 3492,  
    "O": 29949,  
    "OK": 8949,  
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

下面用图展示树的构造过程:

遍历第1个映射:

51c大模型~合集14_AI

遍历第2个映射:

51c大模型~合集14_AI_02

遍历第3个映射:

51c大模型~合集14_AI_03

遍历第4个映射:

51c大模型~合集14_AI_04

遍历第5个映射:

51c大模型~合集14_AI_05

遍历第6个映射:

51c大模型~合集14_AI_06

遍历第7个映射:

51c大模型~合集14_AI_07

通过上面图示,展示了如何通过词表子集构造前缀树,实际的前缀树比这个大多了,整个词表中的 字符串 和 token id 的映射都会通过这样的方式插入到前缀树中。

约束每个 step 可允许采样 token 范围

构造好前缀树之后,接下来就是讲解怎么得到每个 step 可允许采样的 token 集合。

lm-format-enforcer 还有另一个重要的模块就是 字符级别的解析器。

这个解析器的作用简单来理解就是,在初始化的时候,会接收用户指定的 json schema,接着在后续每一步生成过程中,会根据之前生成的内容,判断目前处于什么状态,然后根据当前所处的状态直接给出限定的字符集合。

下面举个简单的例子,比如用户指定的 json schema 是:

JSON_SCHEMA = {  
    "type": "object",  
    "properties": {  
        "city": {  
            "type": "string",  
            "description": "Name of the city."  
        }  
    },  
    "required": ["city"]  
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

想要 LLM 生成一个 JSON object ,内容是包含一个 city 属性,该属性的内容是一个字符串,表示一个城市的名字,同时该 city 必须要在结果中出现。

解析器的作用就是,比如目前已经生成好的内容是 :

{  
  "
  • 1.
  • 2.

那么下一步一定是要生成 city 这个字符串,解析器的作用就是根据目前的状态,会给出限定的字符集合 ['c', 'i', 't', 'y'] 。

然后接下来比如生成到了:

{  
  "city": "
  • 1.
  • 2.

那么接下就是要 LLM 生成一个城市的名字,但是其实对于解析器来说,他只知道接下来要生成的内容是字符串,而且内容只需要符合 JSON 格式就行了,所以这时候给出的限定字符集合就非常大了,词表中的 token 对应的字符串只要符合 JSON 格式的都可以。

最后具体能生成什么城市名字,还有这个城市是否真实存在,就得看 LLM 的能力了。

下面用一个具体的例子讲解一下,怎么结合 前缀树 和 解析器,获取每个 step 限定的 token 集合。

假设用户的输入 prompt 和指定的 json schema 是:

prompt = "Please output a JSON segment representing the name of a city, including fields for city name."  
  
JSON_SCHEMA = {  
    "type": "object",  
    "properties": {  
        "city": {  
            "type": "string",  
            "description": "Name of the city."  
        }  
    },  
    "required": ["city"]  
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
第 1 个采样 step

有一点需要注意,获取可允许采样 token 集合在 lm-format-enforcer 库中是通过递归的方式实现的,下面为了讲解方便,会给每一层递归编个号:

第 0 层递归

首先解析器给出的限定字符集合就是

[' ', '\t', '\n', '\r', '{']
  • 1.

包括空格和大括号在内的5个字符。

然后将这个5个字符和前缀树根节点的所有第一层子节点对应的字符集合做一个交集。

获取得到的字符交集还是这 5 个字符:

[' ', '\t', '\n', '\r', '{']
  • 1.

接着遍历这个字符交集。

遍历每个字符的时候会假设目前已经生成了该字符,比如一开始遍历空格字符 ' ',会将空格当作已经生成的内容加入到解析器中,这时候解析器内部状态会变化,同时取前缀树中空格字符节点对应的所有子节点,进入下一轮递归。

下一轮递归开始的时候,首先将会该子节点包含的所有 token id 加入到当前 step 的候选 token 列表中,然后继续重复上述流程。

第 1 层递归

首先看目前遍历到的前缀树节点包含的 token id 集合是

[35, 29871]
  • 1.

分别对应 llama2 词表中的字符串

"<0x20>"  
"▁"
  • 1.
  • 2.

其中, 0x20 表示 ASCII 编码表中的空格字符,所以 在 llam2 的词表中,空格对应的 token 有两个。

接着继续看第 1 层的递归,解析器在上一层添加了空格字符之后,给出的限定字符集合仍然是

[' ', '\t', '\n', '\r', '{']
  • 1.

因为假设前面生成的是空格的情况下,接下来的可生成的字符其实还是可以是之前的 5 个中选一个。

然后前缀树当前节点下的所有第一层子节点的字符集合:

[' ', 't', 'a', 's', 'd', 'c', 'w', 'p', 'f', 'm', 'o', 'b', 'i', 'h', 'l', 'n', 'I', '(', 'C', 'S', 'u', 'A', '\\', 'e', 'T', 'v', 'g', '*', 'r', 'M', 'y', 'P', 'B', '=', 'D', 'L', '"', 'H', 'E', 'F', 'R', '$', '#', 'W', 'G', 'N', 'k', '`', '{', 'j', 'J', 'O', 'q', '-', 'п', 'K', 'V', 'в', '}', 'U', 'z', '[', "'", '<', 'с', ':', 'и', 'Y', 'о', 'Q', 'д', 'н', '&', '+', '@', 'з', 'м', '–', 'Z', '—', 'à', 'б', '/', 'С', '«', 'у', '.', '|', '_', 'é', 'x', 'В', 'П', 'к', 'X', 'К', 'г', 'а', 'М', '%', 'А', 'р', '“', 'Б', 'Н', '>', 'Д', 'Р', '?', 'ф', 'Г', 'О', 'е', 'Т', 'т', ')', '!', '„', 'Л', 'і', ',', 'У', '»', ';', 'è', 'И', 'ä', 'я', 'э', 'З', 'ч', 'ü', 'Ф', 'ј', '·', 'î', 'Х', 'É', 'Е', 'ш', 'č', 'л', 'Ч', '~', 'ц', 'ú', 'ö', 'á', 'Ш', 'ș', 'х', 'ж', ']', 'Э', '‘', 'І', 'Ц', 'щ', 'Я', 'ž', 'ś', '^', 'Ö', 'š', '†', '°', '\r', 'Ю', 'Ж', 'Ü', 'Á', 'й', 'Č', 'ê', 'ю', 'À', '№', 'Š', 'å', 'є', '•', '→', 'Ś', 'Å', 'ї', 'Ä', 'Î', '│', '×', 'ż', 'Ž', '−', 'È', 'Ł', 'Є', 'í', 'Ż', 'Й', '£', 'Ј', '…', '’', '§', 'ó', 'Ú', '¿', 'ř', 'â', 'α', '\xa0', 'ő', 'њ', 'ا', '€', '”', 'Ó', 'Щ', 'ł', 'Í', '¡']
  • 1.

其实对应的都是词表中起始字符是空格的 token ,然后两者的交集是:

[' ', '\r', '{']
  • 1.

其实就是对应词表中以空格起始的三个 token :

"▁▁": 259  
"▁\r": 6756  
"▁{": 426
  • 1.
  • 2.
  • 3.

接着遍历交集 [' ', '\r', '{'] ,进入第 2 层递归。

由于 llama2 词表中包含连续空格的 token 最长的有15个连续空格 token :

"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁": 462
  • 1.

但是递归最多只会深入到 12 层,因为 lm-format-enforcer 库中默认限定了最长连续的空格数量是 12 个,所以连续探索空格达到 12 层递归之后就会终止探索,接着回溯到第 1 层,继续那一层其他剩下还没探索的交集字符的递归过程。

一直重复直到所有层 前缀树 和 解析器 的所有字符交集都探索完毕。

最终第一个 step 得到的可允许采样的 token 集合是:

"<0x20>": 35 # 对应 ASCII 表中的空格字符  
"▁": 29871  
"▁▁": 259  
"▁▁▁": 1678  
"▁▁▁▁": 268  
"▁▁▁▁▁": 418  
"▁▁▁▁▁▁": 539  
"▁▁▁▁▁▁▁": 4706  
"▁▁▁▁▁▁▁▁": 308  
"▁▁▁▁▁▁▁▁▁": 3986  
"▁▁▁▁▁▁▁▁▁▁": 965  
"▁▁▁▁▁▁▁▁▁▁▁": 9651  
"▁▁▁▁▁▁▁▁▁▁▁▁": 632  
"▁\r": 6756  
"▁{": 426  
"▁{\r": 3336  
"▁{\"": 8853  
"<0x09>": 12 # 对应 ASCII 表中的 \t 字符  
"<0x0A>": 13 # 对应 ASCII 表中的 \r 字符  
"<0x0D>": 16 # 对应 ASCII 表中的 \n 字符  
"\r": 30004  
"<0x7B>": 126 # 对应 ASCII 表中的 { 字符  
"{": 29912  
"{\r": 14626  
"{\"": 6377
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
第 6 个采样 step

然后我们直接跳到第 6 个 step,假设目前 LLM 已经生成的内容是,

{  
"
  • 1.
  • 2.

前面每个 step 生成的内容按顺序是 ['\n', '\n', '\n', '{', '\n', '"']

然后根据用户设定的 json schema,接下来其实就是要限制采样必须生成 city 这个字符串,我们来看下递归的过程。

第 0 层递归

首先解析器给出的限定字符集合就是 ['c']

然后前缀树根节点所有第一层子节点的交集就只有 'c'  字符,然后将 c 加入解析器,同时取根节点下 c 对应的所有子节点进入

第 1 层递归

而由于上一层生成了字符 c ,那么对于解析器来说,接下来的字符肯定要是 i ,所以给出的限定字符集合就是 ['i'],和当前树节点的第一层子节点的交集自然也就是只有字符 'i',然后继续递归。

以此类推,可得当前 step 的限定 token 集合为:

"<0x63>": 102 # 对应 ASCII 表中小写字符 c   
"c": 29883  
"ci": 455  
"cit": 20752  
"city": 12690
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
第 9 个采样 step

接着跳到第 9 个 step,假设到目前为止已经生成了:

{  
"city": "
  • 1.
  • 2.

那么这时候,根据解析器的判断,接下来其实就是可以自由生成任意符合 json 格式的字符,所以这时候返回的 token 集合会非常大,接近词表大小。

lm-format-enforcer 中对这个情况做了优化,就是这些 token 集合是可以在生成前缀树的过程中拿到。

所以如果当前是自由生成字符模式,则不会进入递归过程,直接返回这些 token 集合即可。

如何在采样过程中压制特定 token

在拿到可允许采样的 token 集合之后,接下来的操作就简单了,只需要给 logits tensor 加一个偏置即可,伪代码实现:

allow_tokens = [xx, yy, zz, ....]  
bias = torch.full((vocab_size,), -math.inf)  
bias[allow_tokens] = 0  
logit += bias
  • 1.
  • 2.
  • 3.
  • 4.

通过给不允许采样的 token 加一个负无穷的方式来压制这些 token 不会被采样得到。

总结

其实除了 lm-format-enforcer 的实现方式之外,还有其他人工规则的结构化生成库比如 github 上 star 更多的 outlines 库。感兴趣的读者可以进一步对比两者的实现有什么不同。

参考资料

[1] https://github.com/vllm-project/vllm/blob/main/vllm/model_executor/guided_decoding/lm_format_enforcer_decoding.py

[2] https://github.com/noamgat/lm-format-enforcer?tab=readme-ov-file#how-does-it-work

[3]  https://github.com/outlines-dev/outlines



#用Llama 3.1合成数据改进模型

英伟达最新技术分享 适逢Llama 3.1模型刚刚发布,英伟达就发表了一篇技术博客,手把手教你如何好好利用这个强大的开源模型,为领域模型或RAG系统的微调生成合成数据。

Epoch AI上个月刚刚发文预言「数据墙」迫近,结果英伟达转头就甩出了340B开源巨兽Nemotron。

真实数据稀缺可能不再是问题了,Nemotron 9T token的预训练预料中,98%都是合成数据。

也许你还对合成数据存在顾虑,或者不知道如何应用LLM驱动数据生成。或许,英伟达的这篇博客可以提供答案。

原文地址:https://developer.nvidia.com/blog/creating-synthetic-data-using-llama-3-1-405b/?linkId=100000275486093

首先我们需要理解,用LLM合成数据的本质究竟是什么?

合成数据并不是「从无到有」地创造新信息,而是对现有信息进行转换,生成不同的变体。

实际上,合成数据在AI领域的应用已经有十多年的历程,比如物体检测或分类系统中曾经的数据增强技术。

那么,LLM带来了什么新变化呢?

从「需求端」来看,由于模型需要大量训练语料,合成数据的动机被大大增强。

而在「供给端」,生成式语言模型也为合成数据技术带来了质的改变。

用合成数据微调基座模型,可以更好地应用于实际场景。例如,在金融领域改进风险评估、在零售领域优化供应链、在电信领域提升客户服务,以及在医疗领域改善患者护理等等。

尤其是405B开源巨兽Llama 3.1最近正式上线,既可用于批处理和在线推理,也可以作为基座模型,进行特定领域的专门预训练或微调。

尤其是考虑到Llama 3.1有如此大的参数规模,加上丰富的15.6T token训练数据,非常适合用于数据生成。

这篇博客文章将介绍几个合成数据的生成与应用案例,并就其中一个进行深入探讨。

  • 合成数据的生成是推动GenAI在特定领域应用的关键工作流程
  • 将最新的Llama 3.1与英伟达Nemotron-4 340B奖励模型配合使用,非常适用于生成合成数据
  • 要让LLM生成基于最新信息的有根据的响应,构建RAG流程十分重要,而且模型响应的准确性取决于流程的质量。

LLM合成数据如何应用于GenAI

改进语言模型

要通过合成数据来微调模型,大致有两种方法——知识蒸馏(knowledge distillation)和自我改进(self-improvement)。

知识蒸馏是将大模型的能力转移到较小模型的过程,但不是简单地在同一个数据集上训练两个模型,因为较小模型很难学习到底层数据的准确表征。

在这种情况下,我们可以先让大模型完成任务,再使用这些数据指导小模型进行。

自我改进则是让同一个模型评判自己的推理过程,常被用于进一步磨练模型的能力。

让我们来看看如何实现这一目标。训练语言模型通常包括三个步骤:预训练、微调和对齐(alignment)。

预训练

预训练通常需要极其庞大的语料库,使模型了解语言的一般结构。

Llama 3.1、GPT-4这种通用LLM,一般需要互联网规模的数据。而特定领域的LLM(如几何学、放射学、电信行业等)则需要注入相关的领域信息,这个过程被称为领域自适应预训练(Domain Adaptive Pretraining,DAPT)。

除了要贴近相关领域,另一种在预训练阶段使用合成数据的例子当属Phi-1.5模型,目的是注入逻辑推理能力。

微调

掌握了语言的一般结构后,下一步就是微调,让模型更好地遵循指令、完成特定任务。

比如,要让模型提高逻辑推理能力、实现更好的代码生成和函数调用,或者提升阅读理解类任务的表现,都可以通过微调来实现。

Self-Instruct、WizardCoder、Alpaca等模型都通过创建特定领域的数据并进行微调,来定向提升模型能力。

对齐

最后,我们希望确保模型响应的风格和语气与用户期望一致,例如听起来像对话、具有适当的详细程度、复杂性、一致性等。

可以创建一个包含指令模型(instruct model)和奖励模型(reward model)的流水线来实现这个需求。

先让模型对同一问题创建多个响应,然后让奖励模型对这些相应的质量进行反馈。这种方法属于从AI反馈中进行强化学习(Reinforcement Learning from AI Feedback, RLAIF)。

改进其他模型和系统

除了改善语言模型本身,合成数据还可以应用于LLM邻接模型(LLM-adjacent model)以及LLM驱动的流水线。

最经典的例子就是检索增强生成(Retrieval Augmented Generation,RAG),先用嵌入模型来检索相关信息,再让语言模型生成最终答案。

在这个过程中,我们可以使用LLM来解析底层文档和合成数据,从而评估并微调嵌入模型。

类似于RAG,任何智能体(Agentic)流水线都可以被评估,其组件模型也可以被微调,实现方式就是用LLM驱动的智能体来构建模拟。

这些模拟还可以用于研究行为模式,此外,也可以在LLM中设定特定角色,以针对特定任务进行大规模数据生成。

使用合成数据评估RAG

为了更好地理解上述讨论,我们来思考一个基本的流程,应用于一个具体的用例——为检索过程生成评估数据。

下述流程的实现代码已经上传至GitHub。

项目地址:https://github.com/NVIDIA/NeMo-Curator/tree/main/tutorials/synthetic-retrieval-evaluation

要创建用于评估检索流程的数据,主要面临以下2个挑战:

  • 多样性:问题不应只关注信息的单一方面或仅包含提取性问题
  • 复杂性:生成的问题应需要一些推理或多个证据来回答

我们将重点关注多样性,但为了探索复杂性角度——关键是找到具有重叠信息点的内容块。找到重叠信息的几种方法包括计算句子级语义的Jaccard相似度,并利用长上下文模型找到同一文档的不同块之间的关联。

多样性源自不同的视角,比如考虑如下文本:

对于同一篇文档,金融分析师可能对两家公司合并前后的财务状况感兴趣,法律专家可能关注公司面临的来自FTC、欧盟和其他方的法律审查,记者则希望了解事实要点。

所有这些都是有效的视角和用户角色。由于他们以不同的视角看待相同的信息,因此评估流程也需要适应这些视角。

因此,让我们设计一个评估流程,该流程以文档和用户角色作为输入,并以符合角色的语气输出问题。

图1. 三步流程的概述:生成用于评估检索过程的合成数据

如图1所示,这个评估流程有三个主要步骤。

步骤1:生成所有可能的问题

这些问题都是用户角色可能感兴趣的。

步骤2:筛选出相关的问题

从生成的问题中筛选出最相关和有价值的问题。

步骤3:引入用户角色的写作风格

将筛选出的问题转换为符合用户角色写作风格的形式。

通过这三个步骤,可以确保不同用户角色获得他们所需的信息,并以他们熟悉的方式呈现。

步骤1:生成问题

在生成问题之前,我们需要先读取文档并将其分成若干块(chunk)。

然后,让LLM从给定的文本块中,为每个用户角色提取感兴趣的点。

所谓的「用户角色」(persona),实际上就是对潜在用户的描述,比如:

由于多个用户角色可能有相似的兴趣点,因此需要使用嵌入模型来进行语义去重,从而为每个角色映射出段落中不同的相关信息。

多样性的另一个方面是问题类型。

我们需要提出各种类型的问题,如提取性、抽象性、比较性的问题,而不仅仅是简单的「如何/什么」问题。因此,下一步是根据段落中的信息,确定每个兴趣点适用的问题类型。

最后,利用文本块-兴趣点-问题类型的三元组,生成所有可能的问题。通过用户角色和问题类型,开发人员可以将生成的问题引导到用户会问的类型上。

步骤2:过滤问题

生成问题之后,下一步就是过滤并提取最有用的子集。首先,我们需要对所有生成的问题进行去重,因为不同的兴趣点可能会利用相邻的信息点,导致问题重叠。

接下来,我们使用LLM来判断问题与段落的相关性,确保这些问题能够完全通过段落中的信息回答。然后,我们将所有相关问题重写为对话语气。最后,我们会进行另一次过滤,分类并剔除那些可能过于笼统的问题。

步骤3:注入用户角色风格

在前两步中,我们创建并筛选了多样化的问题。最后一步是将用户角色的写作风格融入到问题中。

使用LLM,我们首先根据给定的用户角色描述来制定写作风格。然后,基于这些写作风格重新改写问题。

比如,可以这样描述用户角色的写作风格:

在这个三步流程结束后,我们得到了如下问题:

  • 鉴于现行的监管框架,拟议的合并还需要遵守哪些额外的政策指令,才能获得相关部门的批准?
  • SolarPower和GreenTech合并的哪些具体方面目前正在接受相关监管部门的审查?
  • 如果在大笔买断之后,GreenTech的研发中心保持单飞状态,那些天才会被炒鱿鱼吗?

可以看出,前两个问题很像Padma的语气,而第三个问题似乎是Aaron会问的。

这些问题各自包含了真实标签,对应特定的文本块,因此不仅限于这一个用例,可以用于评估各种检索流程。

参考资料:

 https://developer.nvidia.com/blog/creating-synthetic-data-using-llama-3-1-405b/?ncid=so-twit-933996&linkId=100000275486093