亚马逊AWS官方博客

浅谈 LLM RAG 对话机器人和 Text2SQL 的设计和实现

大语言模型(Large Language Model,下简称 LLM)出现之后,因为其体现出相当程度的理解力和总结能力,出现了很多关于其应用的探索。在这篇文章中,我们一起结合一个实际案例来聊下「RAG 对话机器人」以及「Text2SQL」两种目前走在前沿的 LLM 应用,讨论它们的适用场景、实现方式以及一些限制。

LLM 应用的基本逻辑

在开始正式介绍之前,我们需要先铺垫一些 LLM 应用的基本逻辑。这不仅能帮助我们了解这两个应用的实现逻辑,也可以让我们理解其他 LLM 应用应该怎么切入。

开源和非开源

LLM 有开源和商业(非开源)之分。开源的模型有 ChatGLM、Llama、百川等,而商业模型则有 OpenAI 的 ChatGPT 和 Anthrophic 的 Claude 等。

它们的区别主要在于,开源模型的模型文件是公开可下载的,由用户自行下载部署,并且其训练方式通常也是完全公开的,用户可以自行对模型进行微调甚至预训练。而与之相对,商业模型则都通过 API 的形式提供,其模型文件不公开,训练数据和方式仅部分公开,并且有些细节参数无法配置。

此外,很多商业模型的参数数量可以达到开源模型的数倍到数十倍。这是因为商业模型有公司和资金支持,可以使用更多的数据和硬件来训练模型和提供推理服务。这也意味着,针对复杂场景,使用商业模型通常效果会优于开源模型。

在很多场景中,我们都需要确保数据存储和传输的合规性和安全性,这就要求敏感数据不被传输到外部系统。如果数据存储在云上,那么就可以选择 Amazon Bedrock,因为它将开源和商业模型部署在云上,敏感数据可以不出云,直接在云上处理,享受优质模型的同时也避免了合规和安全方面的风险。

此外如果客户没有太多 LLM 技术栈的积累,及足够的算法工程师团队,那么也很适合 Amazon Bedrock 上的 LLM 模型。通过官方示例和指南,即便技术积累不深的用户也可以快速上手,最大限度减少试错的成本和周期。

LLM 的推理方式

LLM 是基于 Transformer 的 AI 模型。它的特点是通过两个步骤生成文本:

  1. 预填充(Prefill):输入提示中的 Token。
  2. 生成(Completion):自回归方式一次生成一个新 Token。

可以看到每生成一个 Token,都需要把原来所有的 Token 作为输入。下面举个例子。

  • 输入:你 >>> 输出:好
  • 输入:你好 >>> 输出:,
  • 输入:你好, >>> 输出:我
  • 输入:你好,我 >>> 输出:是
  • 输入:你好,我是 >>> 输出:A
  • 输入:你好,我是A >>> 输出:I
  • 输入:你好,我是AI >>> ……

几乎所有 LLM 都按这个方式来生成 Token。LLM 的界面上通常都支持流式输出,一个字一个字地显示,像打字一样的效果,其原因也在此。这并不完全是刻意做成像打字的效果让其更拟人,也是因为模型本身就是这么工作的。

在对话的时候也是如此。实际上并没有什么对话和上下文记忆,只是我们把所有前面的对话,都作为输入,让 LLM 来生成后面的 Token。比如,下面可能是一次推理的输入。

Human:你好,你是谁?
Assistant:你好,我是 AI 助手。
Human:你能做什么?
Assistant:

LLM 拿到这个输入后,会接着生成最后一个冒号「:」之后的第一个 Token,然后再把这个作为输入,生成下一个 Token,直到生成出一个输出终止符 Token。我们调用封装好的 API 时,如果不使用流式输出,通常会把第一个输出的 Token 到最后一个终止符 Token 的部分一次性返回给调用者。

这个行为和大部分人的直觉是不符的。

比如我做一个聊天机器人来和 LLM 对话,第一个问题输入了 8 个 Token,第二个问题输入了 10 个 Token,我第二个问题的「输入 Token 数」是多少呢?一般用户可能会觉得输入了 10 个那就是 10 个,但实际上消耗 Token 数是:8 + 模型第一次输出的 Token 数 + 10 个。

再比如,我把一篇 1000 个 Token 的文档发送给 LLM 并让它总结,然后问了 10 个文档相关问题,那么计算每次提问的输入 Token 数时,都需要包含文档的 Token 数(1000) + 之前所有的问题和答案的 Token 数。

理解这个行为模式很重要,因为它会影响推理的成本。虽然有的大模型支持 10+ 万 Token 的上下文窗口,但如果真的用满这个窗口,带着全部上下文来问问题,那么每问一个问题,输入的 Token 数也会达到 10 万,成本会很高,而且推理效率可能也会受到影响。

LLM 模型适配

选择好 LLM 模型后,在做应用落地时通常需要对模型进行适配,以便适应该客户领域的特定任务和领域知识,模型的适配通常有预训练、微调(Fine-tuning)、RAG(Retrieval Augmented Generation,取回增强式生成)增强上下文几种。

LLM 的「预训练」(Pretraining)指的是从头开始训练一个模型。预训练需要耗费海量的人力、物力来获取文字、清洗文字并进行长时间训练。又因为 LLM 的初始训练文本往往是补全叙述性文字,而不是对话,在训练时还需加入大量的对话、指令跟随、合规性等内容。对于绝大部分的应用场景,我们都不会也不需要自行去进行预训练。

除了预训练的方式,对于开源模型我们还可以使用「模型微调」(Fine-tuning)的形式来为它注入新的领域知识,以便能更好的适应领域知识问答。微调指的是在不修改原始模型的情况下,通过使用相对少量的数据做附加训练,形成类似「外挂」的小模型,通过「基础模型 + 外挂」的方式,为模型注入新的知识和能力,常用的有 LoRA(Low-Rank Adaptation,低秩整编),P-tuning(Prefix tuning,前缀微调)等技术。

对于模型微调方式,有几个问题值得注意。

  • 首先,虽然 LoRA 等微调技术让我们只需极小的训练集和训练量就能给模型增加新的领域知识,但要达到较好效果,需要的语料量仍然在千条这样的量级。用户需要做好心理准备。
  • 其次,如果信息有变化,我们就需要再次对模型进行微调。如果信息频繁变动,微调的成本和消耗的时间也会随之增多。
  • 相对于预训练或者全参数微调(Full Tuning),LoRA 等微调方式虽然训练硬件资源的开销大为降低,但对于训练损失函数等效果的抖动更大。全参数微调 1、2 个迭代就可以收敛的场景下,LoRA 可能需要更多轮的训练。
  • 最后,当基础模型升级,或者需要更换模型时,也会需要再次进行微调。这些又增加了系统整体的运维复杂度。

考虑到以上问题,在本文中我们还是使用 RAG 增强上下文取回作为模型适配的方式

不过,在应用 LLM 的时候,我们往往希望它能回答特定领域的知识,比如「60×60 的鱼缸可以养几条金鱼?」或者「出差时机场餐费是否能报销?」,这些信息没有参与训练,LLM 也就无法回答,怎么办呢?

这时候我们就会需要用到 RAG 增强上下文了。

在 Transformer 的加持下,现在的 LLM 基本都可以使用巨大的上下文窗口(最新的 Claude 2.1 甚至达到了 20 万个 Token),所以我们只需要把这些领域特定的信息作为上下文喂给模型,然后利用模型本身的理解能力来帮助我们解答问题。这些上下文就好像是 LLM 的短期记忆 + 专业知识。

领域知识的量无疑是很大的,我们不可能把所有的信息都作为上下文给 LLM,这样既不现实(上下文窗口装不下),也不经济(每次推理都需要带海量上下文浪费算力和成本)。这时候就需要我们能准确地找到与问题相关的信息片段,只把这个片段发给 LLM。这个从大量领域知识中搜索和获取信息片段的行为,其实就是搜索引擎或者推荐引擎中常见的「取回」(Retrieval)。

显然,取回的质量和 LLM 的答案是强相关的。如果我们给的信息片段本身就错了,即便 LLM 的理解能力再强,也是白搭。

业务场景

接下来,我们看看这次的业务场景。

客户是一家冷链食材物流公司,公司目前有两个挑战,希望通过 LLM 来应对。

第一,是公司规章制度的查询。

冷链食材物流,因为其紧扣食品安全,所以规章制度非常重要。从用什么类型的车,什么类型的冷气机,温度的分区,食材的摆放、包装、检查方式等等,都会有各种复杂的要求。即便是行业熟手,也很少有人能把所有规章制度背下来。

不仅如此,这个行业还处于迅速发展的时期,所以新的司机和线路很多,所以经常需要咨询一些规章制度相关的问题。

这个问题目前是通过外采客服和自有人员兼职来解决,因为人力有限,常常也会有捉襟见肘的时候,回复较慢,并且专业度也参差不齐。所以,客户希望能通过 LLM 来缓解这一块的压力,不仅为内部提供一个快速查询的工具,也可以提供一个更方便快捷的渠道给网点、终端和司机等查询相关规范,提升运营的合规性。

第二,是公司统计数据的查询。

通常来说,公司的统计类需求分成两种。

一种是固定的需求,一般是公司基本经营需要或者合规相关要求,这种一般会做成报表,有时候还会以看板的形式做图表展示。

另一种则是探索类的需求。比如,管理者要去见投资人的时候,投资人问到了上个月某两个省干线的收入差异和利润差异,这个数据没有在报表里面,就需要数据部门现场拆解需求,转化成 SQL 语句,在数仓中运行之后再答复。再比如,管理者准备要推出新服务,需要交叉查询一些新的业务数据,这些也需要数据部门来执行。

毫无疑问,复杂的场景只能由数仓专家来操刀设计和优化,但是对于某些简单的场景,如果能自动执行,就能节省专家的时间,也可以让管理者更多地去探索和关注业务数据。

最后,客户希望能把所有的功能都融入到同一个入口,形成一个智能问答机器人平台。虽然一开始可能仅提供上述两个简单的功能,但有这个基础后,可以基于它来扩展更多场景,提升经营效率。

需求拆解

接下来我们来看这些需求的拆解过程。

注意:本文仅阐述后端与 LLM 对接部分的需求拆解过程,前端的对接过程较为直白,这里不作赘述。

需求 1:规章制度查询

先来看第一个需求,规章制度的查询。

这个需求分成几个部分。

  • 第一,文档准备。把规章制度转换成一问一答形式。
  • 第二,文字向量化。我们需要选择一个向量化算法,把文字转换成向量。
  • 第二,上下文取回。构建向量数据库,把规章制度问答和对应的向量录入进去,并能根据输入取回最接近的预设问答。
  • 第三,LLM 集成。用户输入问题,根据问题取回最接近的预设问答,将取回的预设问答作为上下文,再加上用户实际的问题,发给 LLM。

我们分别来看这几个部分。

首先是文档准备。

因为我们要达到的效果是问 LLM 相关问题,然后让 LLM 给出答案,所以我们需要把叙述类文字,转换为问答型文字。

比如,原文是「超低温物流:适用温度范围在-50 ℃以下。」,转换后的的问答则是:

问:超低温物流的温度是多少? 答:超低温物流的温度是 -50℃ 以下。

可能读者会觉得,这么多文字,全部都要转换成问答,太耗时耗力,但实际上,我们完全可以借助 LLM 来执行这一较为繁复但是没有太多技术含量的动作。

我们只需要输入我们的规章制度,并向 LLM 提问:

阅读以下文字,写出 100 个关于文字内容的问答。问答应尽量包含细节,比如数字、定义、流程等。

以《GB/T 28577-2012 物流分类与基本要求》为例,我输入后得到了如下一系列的问答。

问: 冷链物流的冷冻物流适用的温度范围是多少? 答: 冷冻物流适用温度范围在-18 ℃以下。

问: 冷链物流中的其他控温物流适用的温度范围是多少? 答: 其他控温物流适用温度范围在 10 ℃~25 ℃。

问: 冷链物流的设施设备中,冷藏车、保温车的性能应符合哪个规定? 答: 冷藏车、保温车的性能应符合 QC/T449 的规定。

问: 冷链物品的运输车厢和容器应该保持什么样的状态? 答: 冷链物品的运输车厢和容器应保持清洁,不能有秽物、碎片或异味,并定期消毒,符合GB/T22918的规定。

问: 冷链物品在交接时应当签署什么凭证? 答: 冷链物品在交接时应签署交接凭证,并保存交接记录。

问: 冷链物流作业人员应当持有什么有效证书? 答: 冷链物流作业人员应持有卫生部门发放的有效健康证,并取得相应的国家职业资格证书。

问: 冷链物品保质时间限定是在物流作业的哪个方面做出的具体运作要求? 答: 冷链物品保质时间限定是在物品保护方面做出的具体运作要求。

问: 冷链物流的基本要求中提到的社会发展需要降低的是什么? 答: 基本要求中提到的社会发展需要降低的是物流成本。

问: 冷链物流作业中,冷库的清洁消毒应该定期进行,具体频率是多久? 答: 冷库的清洁消毒应定期进行,具体频率标准中未明确。

问: 冷链物流的温度控制要求中,应配备什么系统来应对温度异常? 答: 温度控制要求中,应配备温度异常警报系统。

……(后略)

虽然我们还需要对这些问题进行简单的甄别,但这相当程度上降低了我们的工作量。

接下来是文字向量化。

文字的向量化指的是将不定长的文字的语义压缩成一个定长的向量(Vector)。通过对比两个向量的距离,我们可以快速获知两段文字的相似度。和单纯字面对比不同,向量捕捉的是文字的语义,即便文字完全不同也可以判断为相似。

看如下两个问题:

问题 1:冷链物品在交接时应当签署什么凭证? 问题 2:如何确认冷链物品的移交,需要哪些文件?

如果仅从字面对比,这两个问题差异很大,但从语义上来说,他们非常类似。通过向量来对比,我们就能把它们判断成「相似问题」。

这里就涉及一个向量化算法的选择问题。向量化算法在训练的时候,会大量输入文本,依靠字、词之间的关系来确定语义,所以针对不同的语言,我们需要使用不同的向量化算法。此外,算法的训练方式不同,效果也不同,相互之间也无法兼容。

目前向量化的算法通常基于 Word2vec、基于 BERT 等算法来构建。前者把一个词所有的语义都一次浓缩到一个向量里面,而 BERT 则会根据上下文的不同来给出不同的向量。比如,英文的「Account」有账号、报道、理由等各种语义,但对于 Word2vec 来说不管上下文是什么,它们都会转换成同一个向量,但是 BERT 则会根据不同上下文的语义把它转化成不同的向量。中文根据语境而意思完全不同的多义词要少些,但也不乏「制服(恶犬)/(穿)制服」或者「打(电话)/打(游戏)」这样的例子,考虑上下文的话,转换会更准确。

因为 BERT 的上下文相关性能帮助我们更准确地找到相似的词,我们可以使用基于 BERT 构建的 BGE(BAAI General Embedding)算法。这个算法不仅支持中英文,还在 Hugging Face 的文字向量化榜单上拔得头筹,已经比较成熟。

限于篇幅,本文不再赘述 BGE 的部署,大家可以参考这个 AWS Samples 笔记本,在 Amazon SageMaker 上部署这个模型。

接下来我们要做上下文取回。

基于向量相似度的上下文取回是业界成熟的技术,我们只需要使用一个向量数据库即可。我们可以使用业界常用的 Amazon OpenSearch(AOS)作为向量数据库。

AOS 的基本存储单位是「索引」(Index),索引就相当于数据库中的表。之所以叫「索引」,是因为 AOS 原本是用来做文本对比和搜索的,它原生就支持分布式的检索,因此对于海量的向量数据,我们也可以用 AOS 来进行向量相似度的搜索,其中用到的算法叫做 k-NN。

k-NN 算法全称是「k-Nearest Neighbors」,即「k 个最近邻近点」算法,这个算法使用一个距离函数来遍历数据库中的向量,寻找离离输入向量最近的 k 个向量。向量空间中隔得越近,表明语义越相似。

如果有成千上万个问题,就会有成千上万个向量,把这些向量都与输入向量比一次,无疑会消耗巨大的算力,所以通常我们会使用「近似 k-NN 算法」。AOS 上支持多种近似 k-NN 算法,如 Faiss 的 HNSW,或者 IVF,其中 HNSW 的 k-NN 检索会提前把向量分层,在每一层中预先构建每一个向量点的一定数量的友邻节点,检索时,通过从上到下的分层的友邻节点过滤查找,可以空间换时间,提升检索的准确率的同时降低时延。是目前业界用的较多的向量召回算法。

如果要使用近似算法,那么在创建索引的时候,就需要打开对近似 k-NN 算法的支持,如下面的 opensearch-py 代码所示。

search.indices.create('my_index', body={'settings': {
    'index.knn': True
}})

然后,在创建索引的「映射关系」(Mapping)时,也需要做一些配置,如下面的 JSON 所示。映射关系就类似于数据库中的字段。

{
    "properties": {
        "context": {
            "type": "text"
        },
        "query_embedding": {
            "type": "knn_vector",
            "dimension": 1024,
            "method": {
                "engine": "faiss",
                "space_type": "l2",
                "name": "hnsw",
                "parameters": {
                    "ef_construction": 512,
                    "m": 32
                }
            }
        }
    }
}

这里有两个字段,一个是普通的文字字段 context,另一个是 k-NN 向量字段 query_embedding。query_embedding 的配置解释如下:

  • type 是 knn_vector 代表它存的是一个支持 k-NN 搜索的向量
  • dimension 代表这个向量的维数(相当于一维数组的长度)
  • engine 是 k-NN 引擎,这里使用了 faiss,是 Meta 公司推出的近似 k-NN 算法
  • space_type 是向量距离函数,常用的有 l2 和 cosinesimil(余弦距离)等,细节可参考官方文档
  • name 是指的近似搜索算法,AOS 使用的是 nmslib 库所实现的「层级式可沿行小世界」算法(Hierarchical Navigable Small World),即 hnsw
  • parameters 是参数,对准确率、索引添加效率、内存使用等会有一定影响,细节可参考官方文档

创建完成后,我们就可以把问答及对应的向量输入到 AOS 索引中了。输入多个问答后,我们可以测试一下向量化的效果。测试方式是,用不同的方式来询问同一个问题,看是否都能正确取回。

读者可能又会觉得,每个问题我都要去想很多个问法,太麻烦了。其实这里我们又可以借助 LLM。只需要输入我们的问题,并向 LLM 提问:

把这个问题换 50 种问法,保留原来的意思。

我们就能得到 50 种不同的问法可供测试,节约我们的时间。

当然,这一步也有很多可以优化的空间,比如:取回多少个问答?取回的问答如何做相似度排序(Reranking)?如果用户的问题涉及多个问答联动,如何能把相关的问题都带出来?问答更新之后,怎么更好地重新录入上下文?关于这些问题,读者可以再深化探索,本文仅作抛砖引玉。

最后,我们需要把取回的问答置入上下文,作为输入发送给 LLM

这一步主要是提示语的拼接和优化。拼接不用说,我们需要把取回的问答和用户实际的问题放一起。优化则是指我们需要对 LLM 的行为做一些指示和限制,后面会详细阐述。

需求 2:统计数据查询

统计数据比问答稍微麻烦,因为它涉及到了范围划定、元数据转换、语言转换、数仓对接、效果展示以及融合上下文等多个层面的问题。

  • 范围划定的目标和问答类似。LLM 是不知道我们有哪些表,哪些字段,字段的意思又分别是什么的,我们必须从数仓或者数据库里面把这些信息导出,一般是 CREATE TABLE 语句的形式。从经济性和效率上来说,我们不可能把几十张表、成千上万个字段全部都作为上下文给 LLM,所以必须缩小范围,找到和用户问题相关的表。
  • 元数据转换指的是把 CREATE TABLE 语句转换或者简化成其他形式,让 LLM 可以更好地识别字段名字、类型和语义。
  • 语言转换是指我们输入的是自然语言,但是要求 LLM 输出的是 SQL,这要求我们对提示语做相当多的优化。
  • 外部系统对接主要指的是与数仓的对接。这意味着数仓需要提供用户并划定权限,防止用户访问到自己无权访问的数据(「请告诉我公司工资最高的是谁?」),还需考虑到防止注入式攻击的行为(「删除所有订单。」)。此外,因为数仓的查询通常需要消耗较长时间,我们还需要考虑超时、异步、重试等问题,并在前端做好展示。
  • 效果展示指的是展示查询结果。因为查询的结果通常是结构化数据,我们可能会需要展示表格,或者图表,也可能需要生成总结文字。
  • 融合上下文指的是用户看到展示的数据后,很可能会对数据进行追问、下钻等行为,我们需要考虑是否允许这样的追问,并考虑如何把数据纳入到上下文中。如果不支持此类高级操作,我们也需要设计好 UI,让用户有正确的预期。

毫无疑问,这里面有很多都是可以单独成文的话题,本次不作深入探索。我们仅介绍最简单的情况,读者可以以此为基础来构建自己更完善的应用。

首先我们来看范围划定。

通常,我们遇到的第一个问题,就是 LLM 无法识别长尾领域知识,比如「司机明细」,在客户所在垂直领域是有具体业务的字段解释的,它指的是「司机性别、年龄、驾龄」,但很多数仓在构建时为了方便甚至连字段的注释都没有写。虽然 LLM 也可以从字段名字里面找到一些蛛丝马迹,但这样的准确率无疑是很低的。所以,我们首先要确保所有字段都有明确的注释,并且尽量详细一些。

对于字段仅有几种可选值的情况,建议最好是单独列出来,比如「温区:冷冻、冷藏、多温」。这样,面对「找出冷冻车上个月在成都市的平均油耗补贴」这样的问题时,LLM 就会更从容。对于有明确已知范围的字段,也建议都写上。

除此之外,数据库的元数据过于庞大复杂,也会影响最终效果,因为通用型 LLM 对多表联合查询的要求较高,为了更好的效果,初期我们可以将需求划分为多个领域(比如「货车域」、「司机域」、「订单域」、「财务域」等),并把该领域的数据形成一个大宽表,尽量避免 JOIN 类操作。在对 LLM 的能力及提示语工程更熟悉后,再逐步考虑多表联合查询。

在划分领域的同时,我们也提前缩小了表的范围,这样需要取回的上下文也少了,LLM 的效果也会更好。取回的方式和问答类似,只是把取回的上下文换成表的元数据,在此不再赘述。

然后是元数据转换。

大型 LLM 是可以理解 CREATE TABLE 语句的,但是很多时候,CREATE TABLE 语句的干扰性 Token 很多,这也会对 LLM 形成影响。比如对于我们这个场景来说,字段的数值类型细分(比如 INT 和 SMALLINT)和宽度其实意义是不大的,但是这些信息 LLM 也需要作为 Token 来处理和理解。

为了让 LLM 能把注意力更多分配在关键 Token 上,我们可以尝试对 CREATE TABLE 做简化。下面是一个简化的例子。

driver_cargo_log 表的字段信息:
– driver_id = int, 司机 ID
– driver_name = str, 司机名字
– truck_id = int, 货车 ID
– cargo_id = int, 货物 ID
– cargo_dim = str,货物尺寸(长x宽x高)
– …

可以看出,这样做之后,无效 Token 大量减少,这在很多时候都可以让 LLM 的理解更清晰。因为转换的规则是固定的,我们也完全可以用代码来做转换。当然,和所有 LLM 技巧一样,这个简化措施也有不灵的时候,读者可以根据自己的需要来进行选择和优化。

然后是语言转换。

这个部分的核心是提示语工程,这也是最消耗时间的。为了取得更好的效果,我们需要对提示语进行合并、调优。而在做这件事情之前,我们应该有一套完整的提示语验证流程,否则即便优化了,我们也无法得知效果究竟如何。

验证流程可以是手动,也可以有系统支撑,但一定要包含几个部分。

  • 基准问题。我们应该准备一些基准问题(比如 30-50 个),即我们认为这个系统应该能较好回答并且有标准答案的问题。
  • 提示语效果评定标准。我们应该设置一些维度来评判提示语效果,比如语法是否正确、字段是否带齐、语句是否正确、时间条件转换是否正确等等。
  • 测试机制。这个可以是测试人员手动执行并使用一个表格来记录,也可以是纳入到持续集成流水线,自动在提交新的提示语版本的时候,针对基准问题运行,然后测试人员评测并记录结果。
  • 提示语的版本管理。对于 LLM 来说,提示语也和代码类似,所以也应该有版本管理,甚至也应该有针对基准问题的回归测试,避免新版上线原本答得很好的问题无法回答了。

有了验证逻辑,我们就可以有针对性的做提示语工程了。由于 LLM 本身是无状态的,对外没有依赖,所以我们完全可以用手动录入上下文的方式来做提示语工程,和其他步骤并行,还可以作为 MVP 来验证可行性。

有了验证流程,我们就可以来做提示语工程。优秀的提示语包括了很多维度。我们来看下下面这个实例。

根据如下标签内的表、字段信息,写一个 SQL 回答业务问题。

回答格式: SQL <<< (你写的SQL) <<< 

数据库名:my_database

表 = my_driver

driver_id int = 司机 ID,

…略… 

只能从标签内的字段选择表字段,不能凭空生成表字段。

如果需要查询的问题在标签内有定义更明确的字段,则需要查询标签内的全部字段。 

司机明细 = 司机名字、司机所属省市、司机年龄、司机驾龄、司机在网时间、货车车牌

生成的 SQL 中,先 SELECT 英文字段名,然后 AS 这个字段的中文别名,用反引号(`)包围 生成的 SQL 中,字段名前面不要给出表名。

生成的 SQL 中,表名前要加上数据库名。 生成的 SQL 必须满足 MySQL 的语法。

问题: 请帮我查询承运商“南京 XXX 冷链物流有限公司”的司机人数

下面来解释一下这些提示语分别的作用。

  • 提出要求。给 LLM 一个明确的场景和要求。
    • 根据如下标签内的表、字段信息,写一个 SQL 回答业务问题。
  • 格式指定。我们希望 LLM 返回的格式是明确的,这样我们就能用程序来自动处理。
    • 回答格式:SQL <<< (你写的SQL) <<<
  • 提供元数据。根据不同 LLM 的「脾性」,我们用半格式化的方式来提供元数据,比如已知 Claude v2 会分配更多注意力给类 XML 标签(<>)中包裹的内容,我们就可以把元数据用尖括号包起来。
  • 降低幻觉。虽然我们无法完全避免 LLM 的幻觉问题,但我们可以用一些语句降低幻觉的可能性。
    • 只能从标签内的字段选择表字段,不能凭空生成表字段。
  • 提供额外上下文。用户的问法很可能是笼统的,对应多个字段,这时候我们可以根据经验和测试的情况来添加额外的上下文。
    • 司机明细 = 司机名字、司机所属省市、司机年龄、司机驾龄、司机在网时间、货车车牌
  • 其他明细要求。比如我们希望使用中文字段名,精简生成的 SQL,指定某种特定的 SQL 语法等。
  • 问题。最后才是我们实际的问题。

可以看出,即便是简单的查询,也有大量的提示语需要附加。此处仅为抛砖引玉,实际还有很多工作可做。比如示范类语句,即提供一些问题和 SQL 作为示范,让 LLM 根据示范来生成 SQL(即所谓「Few-Shot Prompting」)。再比如「防护栏」语句,比如防止用户执行删除、修改类操作的语句,或者防止用户跳出场景限制做任意对话的语句,等等。

需要注意的是,不同 LLM 的训练方式、语料不同,所以提示语的优化方式也不同。即便是同一个 LLM,不同的版本也会有差异。也就是说,我们的提示语不仅需要有版本管理,还需要一并记录对应的 LLM 是哪一个,以及 LLM 的版本。

接下来是数仓对接。

LLM 本身是不能访问外部系统的,所以这里我们需要在负责调用 LLM 的后端系统上执行对数仓的访问。数仓也需要准备好拥有只读权限的用户,并且将权限限定在特定的表和域。

超时、异步、重试这些问题都必须在后端实现,并且在 API 上做明确规定。比如返回什么样的 HTTP 状态码表示超时,什么样的代码表示在重试等等。

最后是结果展示和融合上下文。

最简单的形式当然是用语言直接回答用户的问题,偶尔也可以在 HTML 页面中嵌入一个表格。如果要做可视化,则更复杂一些。为了支持数据的下钻,我们可能需要创建临时表并引入一些 BI 工具。融合上下文意味着我们需要把提示语工程加入的多余信息去除,并且把格式化的数据也转换、提炼总结成文字作为上下文,以支持追问。这些属于有了基础之后的高级用法,此处不作展开。

构建端到端应用

对于 LLM 来说,上述两个场景是完全独立的,但用户的需求是希望仅保留一个入口,即一个对话框,自动通过用户的输入来决定走哪一个应用。针对这个需求,目前部分大语言模型服务提供了插件功能,另外我们也可以单独基于意图识别来进行研发。本次我们介绍使用意图识别的方式来构建这个应用入口。

意图识别其实和上下文取回非常类似。我们需要做的,就是预存一系列的「问题 → 意图」映射关系,然后把用户的问题和预存的问题做对比,从而判断用户的意图。下面是一些意图映射关系实例。

  • 什么是主干物流 → FAQ
  • 我们公司的介绍 → FAQ
  • XX 物流公司的介绍 → FAQ
  • 上个月运单的最多的司机有多少单 → Text2SQL
  • 统计下 12 月的销量 → Text2SQL

当然,这样来做意图识别,效果完全取决于我们给的例子。如果例子不够多,涵盖不够广,就会导致误判。我们也可以在这个阶段就引入 LLM,给 LLM 一些例子或者规则,让 LLM 来判断。这种方式适合规则比较明确的意图识别,效果会更好,但坏处就是意图识别部分的提示语会需要加入到每一次对话的上下文里,增加了成本,并且 LLM 的交互时间也比查向量数据库要长很多。

现在,如果我们横向看整个流程,会发现即便是只问 1 个问题,后台也需要多次和向量数据库、数仓和 LLM 做交互。

先是确定意图,然后根据意图,可能会查向量数据库中的问答或者数仓元数据,然后做一些处理之后,发给 LLM,然后再把结果做处理,最后返回给前端。每个步骤其实有很多东西是重复的,都需要处理超时、异步、排错等问题,并且也都需要一些预处理或者后处理(比如敏感词过滤)。所以,我们可以形成自己的一个小框架或者使用 LangChain 这样的框架来串联整个流程。

注意事项

接下来我们聊一下 LLM 项目的一些注意事项。

提示语工程的问题

在进行提示语工程时,应该注意如下一些问题。

第一,LLM 的非确定性(Non-deterministic)。

LLM 通常使用一个「随机种子」(即随机数)来参与推理。当 LLM 面对多个不同概率的 Token 时,会选择哪个?这个随机种子就像是丢骰子的结果,模型会根据这个结果来选择下一个 Token。LLM 每次推理的随机种子不同,就会导致每次的回复不同。

自己部署的开源 LLM 有的支持直接设置随机种子,以获取固定的输出,而商业模型很多都不支持设置随机种子,这也给模型的测试带来了问题,即便我们提问 1000 次都准确,也无法保证它下一次回答也同样准确。

此外,我们也应该对 LLM 的工作原理有一定了解,即它选择下一个 Token 的方式完全是基于统计学的。也就是说,虽然「正确」(更贴近训练材料)的 Token 通常被选中的概率较高,但是其他 Token 也有被选中的概率。如果正好随机选到了某个较低概率的 Token,则接着后续生成的内容可能会变得偏离正轨。

比如,我们偶尔可能会遇到 LLM 「画风突变」,突然开始用全英文来回答中文问题之类。这就是因为训练语料中可能有中文后面接英文翻译的语料,结果导致某个英文 Token 被选作了回答的第一个 Token,然后接下来英文后面又选中一个英文,重复接续下去,形成了让人疑惑的结果。

第二,提示语的泛化能力。

针对某个上下文很好用的提示语,往往在用户换一个说法或者换一个上下文之后,就出现各种问题。这就需要我们有一套全面的测试机制。在做提示语工程时,我们应该准备一些多样化的基准测试,用于测试新的提示语是否在这些基准测试时都能取得比较好的效果。

除非时间紧迫,我们应该尽量避免针对单一用例去做提示语工程。如果对单一用例做了大量提示语工程之后发现这个提示语根本无法泛化,则又意味着大量的浪费。

LLM 的非一致性问题

注意 LLM 的非一致性(Non-Consistency)。

考虑这样一种场景。我们有一个视觉识别的算法,这个算法原来能识别 100 种鸟类,我们上线了一个 v1 版本。后续经过大量训练,上线了 v2 版本,算法能识别 200 种鸟类了,但是,这原来本来可以识别的某些鸟类,却无法识别了。如果这个算法已经开放给外部使用,那么原来依赖算法识别某些特定鸟类的应用就会出错。

再比如,我们有一个 LLM,经过大量测试,我们认为它能够很好地处理内部文档的合规性评估。后续,我们通过微调和训练,为它加入了合同风险评估功能,结果却发现内部文档的评估的能力大大下降。同样,如果这个算法也已经部署到生产上,那么依赖这个算法的应用就可能会产生诸多漏洞。

这种非一致性是机器学习模型的通病。一方面是因为模型本身不具备很好地可解释性(即对外是「黑盒」),另一方面,也是因为模型本身无外乎是对信息的压缩,如果信息量本身超出了模型本身能承载的大小,则可能会导致「拆东墙补西墙」的结果,这在超大型上下文窗口的 LLM 身上很常见,即支持的上下文变大了,反而对上下文的理解变差了。

对此,我们必须有所准备,比如准备相当数量的基准问题,对模型新版本进行回归测试。如果模型本身对外发布,则应该向用户明确升级可能带来的影响,并尽量能维持用户稳定使用的版本。每个模型都会有其瑕疵,很可能应用侧已经针对这些瑕疵做了处理,形成了稳定的效果,所以对于对外开放的模型,在升级时,应该慎之又慎。

预期控制

比尔盖茨曾说:「人们总是高估自己一年内能做到的事,却又总是低估自己十年内能做到的事。」

这句话可以很好地运用在 LLM 身上。大部分人第一次接触 LLM 的反应都是觉得它能力超强,马上就能改变世界,但实际应用之后,会发现原来有如此多的问题,有如此多的限制,有如此多需要调试、优化的地方。很多人可能马上会对 LLM 感到无比沮丧,觉得这个其实还是个玩具,一个试验品,风潮很快会过去。这种过犹不及的态度,其实是新技术出现时,公众必然会有的一个心路历程。

在做 LLM 项目时,我们应该很好地控制各方相关者的预期,把项目建立在确实可以解决问题、确实可以带来价值、确实可以落地这「三个确实」的基础上,不要过分乐观和扩张需求,这样项目和产品才能走得更长久。这也意味着 LLM 产品的设计和使用者应该多用、多测试 LLM,对 LLM 的能力形成准确的直觉,才能快速判断一个需求是否适合 LLM,并且对能达到的效果有一个比较准确的心理预期。

总结

本文结合一个场景,简单地介绍了如何来设计基于 LLM 的聊天机器人和 Text2SQL 应用,希望对读者有所帮助。

本篇作者

张玳

AWS 解决方案架构师。十余年企业软件研发、设计和咨询经验,专注企业业务与 AWS 服务的有机结合。译有《软件之道》《精益创业实战》《精益设计》《互联网思维的企业》,著有《体验设计白书》等书籍。

唐清原

亚马逊云科技高级解决方案架构师,负责 Data Analytic & AIML 产品服务架构设计以及解决方案。10+数据领域研发及架构设计经验,历任 IBM 咨询顾问,Oracle 高级咨询顾问,澳新银行数据部领域架构师职务。在大数据 BI,数据湖,推荐系统,MLOps 等平台项目有丰富实战经验。