大语言模型生产环境指南-全-

大语言模型生产环境指南(全)

原文:zh.annas-archive.org/md5/94f58343f3d2d249c1a031ec6302a835

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:词汇的觉醒:为什么大型语言模型引起了关注

本章涵盖

  • 大型语言模型是什么,它们能做什么,不能做什么

  • 你应该在何时何时不部署自己的大型语言模型

  • 大型语言模型的神话及其背后的真相

任何足够先进的技术都与魔法无法区分 —— 亚瑟·C·克拉克

年份是 1450 年。德国美因茨的一个沉睡的角落,不知不觉地站在一个重大时代的边缘。在 Humbrechthof,一个被城镇阴影笼罩的普通车间,充满了期待。正是在这里,约翰内斯·古腾堡,一位金匠和革新者,在油、金属和决心的气味中汗流浃背地劳作,默默地孕育着一场革命。在深夜时分,金属敲击金属的节奏性敲打不时打破宁静。在车间灯光照耀的中心,是古腾堡十年来的爱情结晶——一个设计独特、用途非凡的装置。

这不是一项普通发明。工艺和创造力将各种可移动金属字型、一个个辛苦铸成的字符,组合成一个矩阵。闪烁的灯光在金属标志上舞动。空气中充满了突破的期待和基于油墨的浓郁甜味,这是古腾堡本人的创新。在那一刻的宁静中,这位大师印刷师挺直了肩膀,以无与伦比的技巧,在墨水充盈的矩阵下铺下一张清晰的羊皮纸,让他的发明紧紧压印,在页面上留下精细的印刷。房间调整到了沉默的交响乐,紧张的呼吸在空气中沉重地悬挂。当印刷机被抬起时,它因自身的重量而嘎吱作响,每一次尖叫都像是一声战斗的呐喊,宣布着一个激动人心的新世界的到来。

古腾堡以一阵动作,从印刷机中取出第一张印刷页,并将其平铺在木桌上。他仔细检查每一个字符,它们都像创造者的愿景一样大胆而宏伟。房间中的每个人都沉浸在这景象中,完全被迷住了。一张普通的羊皮纸已成为变革的见证。当夜晚让位于白天时,他带着振奋的骄傲看着他的车间。他的遗产诞生了,在历史的长河中回响,永远改变了信息飞翔的方式。约翰内斯·古腾堡,现在成为千禧年的人物,从阴影中走出,一个敢于梦想的发明家。他的名字与印刷机同义,这不仅是一项开创性的发明,而且是现代世界的催化剂。

当古腾堡的成就的消息开始在大陆上飘扬时,来自各个学科的学者们还没有意识到他们手中的这个非凡工具。知识和学习,曾经是渴望的宝藏,现在对普通人来说触手可及。围绕这一新发现,意见各异,看法不一。

在我们这个时代,多亏了莱茵河畔那些人的才能和勤奋,书籍的数量激增。曾经只有富人——不,只有国王才能拥有的书,现在可以在简陋的屋顶下看到。……如今,我们的孩子……似乎无所不知。——塞巴斯蒂安·布兰特

学术努力在各个地方都在下降,前所未有。的确,聪明才智在国内和国外都被摒弃。阅读能为学生带来什么,除了泪水?当它被出售时,是罕见的、无价值的,而且缺乏智慧。——列日的大卫

人们对于书籍的看法在历史上一直各不相同。在我们这样一个虚拟印刷机存在且书籍无处不在的时代,我们可以达成共识的是,印刷机改变了历史。虽然我们并没有亲眼目睹古腾堡使用他的印刷机打印出第一页,但我们见证了很多人第一次与大型语言模型(LLMs)互动。当他们看到它对他们的第一个提示做出反应时,脸上的惊讶。当他们用难题挑战它,却看到它像该领域的专家一样回应时,他们的兴奋——当他们意识到他们可以用它来简化自己的生活或让自己变得富有时,这就是灵光一闪的时刻。我们想象这种情绪的波涛只是约翰内斯·古腾堡所感受到的一小部分。能够快速生成文本和加速沟通始终是有价值的。

1.1 大型语言模型加速沟通

每个工作都有一定程度的沟通。通常,这种沟通是肤浅的、官僚的或政治性的。我们经常警告学生和门徒,每个工作都有自己的文书工作。曾经是激情的事物,很容易被随之而来的日常乏味和琐事所扼杀,当它变成工作的时候。事实上,当人们谈论他们的职业时,他们经常夸大其词,试图提高自己的社会地位,所以你很少能听到全部真相。你不会听到那些无聊的部分,而日常的艰辛则被方便地遗忘了。

然而,设想一个我们可以减少单调工作负担的世界。一个警察不再需要浪费每天数小时填写报告,而是可以将时间用于社区外展项目的地方。或者一个教师不再需要深夜批改作业和准备教案,而是能够思考并为个别学生准备定制课程的世界。甚至是一个律师不再被困在法律文件中数日,而是可以自由地接受那些激励他们的慈善案件的世界。当沟通负担、文书工作负担和会计负担被移除时,工作就更加接近我们所说的样子。

对于这一点,LLM 是自印刷术以来最有希望的技术。首先,它们已经完全颠覆了人类与计算机之间的角色和关系,改变了我们相信它们能够做到的事情。它们已经通过了医学考试、律师资格考试和多个心智理论测试。它们通过了谷歌和亚马逊的编码面试。他们在 SAT 考试中获得了至少 1410 分(满分 1600 分)。对作者来说,最令人印象深刻的成绩之一是 GPT-4 甚至通过了高级品酒师考试,这让我们想知道 LLM 是如何通过实际品酒部分的。确实,它们的空前成就正以惊人的速度到来,常常让我们这些凡人感到有些恶心和不安。面对似乎能做任何事的技术,你该怎么办?

注意:Med-PaLM 2 在 MedQA 考试中获得了 86.5% 的成绩。您可以在 OpenAI 的 GPT-4 论文中查看通过考试列表,链接为 cdn.openai.com/papers/gpt-4.pdf。最后,谷歌将 ChatGPT 作为测试进行了面试,并且它通过了 (mng.bz/x2y6)。

通过考试很有趣,但并不一定有用,除非我们的目标是建造史上最昂贵的作弊机器,并且我们承诺有更好的方法来利用我们的时间。LLM(大型语言模型)擅长的是语言,尤其是帮助我们改进和自动化沟通。这使得我们可以将常见的痛苦经历转化为简单、愉快的体验。首先,想象一下走进你的家,那里有你自己的个人 JARVIS,就像穿上钢铁侠的鞋子一样,一个由人工智能驱动的助手,为你的日常生活增添了无与伦比的活力。虽然 LLM 的水平并不完全达到漫威电影中 JARVIS 所描绘的人工通用智能(AGI)水平,但它们正在推动新的用户体验,从改善客户服务到帮助你为心爱的人挑选生日礼物。它们知道询问你关于这个人的信息,了解他们的兴趣和个性,了解你的预算,然后提供专业推荐。虽然许多这些助手正在被用于良好的工作,但许多其他只是用户可以与之交谈并自娱自乐的聊天机器人——这很重要,因为即使是我们的想象中的朋友现在也太忙了。玩笑归玩笑,这些可以创造惊人的体验,让你能够遇见你最喜欢的虚构角色,如哈利·波特、福尔摩斯、安纳金·天行者,甚至是钢铁侠。

然而,我们确信许多读者感兴趣的却是编程助手,因为我们都知道搜索一切实际上是一种最糟糕的用户体验。能够用简单的英语写几个目标,然后看到合作编写代码是令人兴奋的。我们亲自使用这些工具来帮助我们记住语法,简化并清理代码,编写测试,以及学习新的编程语言。

电子游戏是另一个我们期待 LLMs(大型语言模型)能带来大量创新的有趣领域。它们不仅帮助程序员创建游戏,还允许设计师创造更加沉浸式的体验。例如,与 NPC(非玩家角色)的对话将更加深入和引人入胜。想象一下像动物之森和星露谷物语这样的游戏,拥有近乎无限的任务和对话。

考虑其他行业,比如教育,似乎永远都不够教师,这意味着我们的孩子得不到他们需要的个别关注。一个 LLM 助手可以帮助教师节省做手工杂事的时间,并为有困难的孩子担任私人导师。企业界正在研究 LLMs 用于与数据交流的工作——比如帮助员工理解季度报告和数据表格——本质上为每个人提供自己的个人分析师。销售和营销部门肯定会利用这一卓越的创新,无论好坏。搜索引擎优化(SEO)的状态也将发生很大变化,因为目前,它主要是一场生成内容以希望使网站更受欢迎的游戏,而这现在变得超级简单。

上述列表只是公司对使用 LLMs 感兴趣的一些常见例子。人们也出于个人原因使用它们,比如写音乐、诗歌,甚至书籍;翻译语言;总结法律文件或电子邮件;甚至提供免费治疗——是的,这是一个糟糕的想法,因为 LLMs 在这方面仍然很糟糕。这只是个人偏好,但我们不会在精神健康受到威胁时试图节省一分钱。当然,这导致了一个事实,即人们已经在使用 LLMs 进行更阴暗的目的,比如作弊、诈骗和虚假新闻来扭曲选举。此时,这个列表已经相当长且多样化,但我们只是刚刚触及了可能性的表面。实际上,由于 LLMs 帮助我们进行沟通,通常最好是思考“它们不能做什么?”而不是“它们能做什么?”或者更好,“它们不应该做什么?”

嗯,作为一种技术,它有一些限制和约束。例如,LLMs 有点慢。当然,“慢”是一个相对术语,但响应时间通常以秒计,而不是毫秒。我们将在第三章中更深入地探讨这个话题,但作为一个例子,我们可能不会很快看到它们被用于自动补全任务,这些任务需要极快的推理才能有用。毕竟,自动补全需要能够比人更快地预测单词或短语。同样,LLMs 是庞大而复杂的系统;我们不需要它们来解决这样简单的问题。用 LLM 解决自动补全问题不仅仅是用大锤钉钉子;它是用完整的破坏球砸它。而且,就像租用破坏球比买锤子更贵一样,LLM 的运营成本也会更高。有许多类似的任务,我们应该考虑我们试图解决的问题的复杂性。

此外,还有许多复杂的问题,LLMs 通常解决得不好,例如预测未来。不,我们不是指神秘的艺术,而是指预测问题——比如预测天气或高潮何时会击中海岸。这些实际上是已经解决的问题,但我们不一定有很好的方法来传达它们是如何解决的。它们是通过数学解决方案的组合来表达的,比如傅里叶变换和调和分析,或者黑盒机器学习模型。许多问题都符合这一类别,比如异常值预测、微积分或找到卷纸的末端。

你可能还希望避免在高度风险的项目中使用它们。大型语言模型(LLMs)并非完美无缺,经常会犯错误。为了增加创造力,我们通常允许 LLMs 中存在一定程度的随机性,这意味着你可以向 LLMs 提出相同的问题并得到不同的答案。这是有风险的。你可以通过降低温度来移除这种随机性,但这可能会根据你的需求使 LLMs 变得无用。例如,你可能会决定使用 LLM 来将投资选项分类为好或坏,但你希望它根据其输出做出实际的投资决策吗?除非你的目标是制作一个搞笑视频,否则不应该是这样的。

最终,LLM 只是一个模型。它不能对损失你的金钱负责,实际上,损失金钱的是你自己,因为你选择了使用它。类似的风险问题包括填写税务表格或获取医疗建议。虽然 LLM 可以完成这些任务,但它不会像聘请有资格的注册会计师那样在 IRS 审计中保护你免受重罚。如果你从 LLM 那里得到不良的医疗建议,你无法起诉任何医生。然而,在所有这些例子中,LLM 都有可能帮助从业者更好地履行他们的工作职责,无论是通过减少错误还是提高速度。

何时使用 LLM

使用它们进行

  • 生成内容

  • 问答服务

  • 聊天机器人和 AI 助手

  • 文本到其他事物的问题(扩散、txt2img、txt23d、txt2vid 等)

  • 与你的数据交谈的应用

  • 任何涉及沟通的事情

避免使用它们的情况

  • 对延迟敏感的工作负载

  • 简单项目

  • 我们不通过文字而是通过数学或算法解决的问题——预测、异常值预测、微积分等

  • 临界评估

  • 高风险项目

语言不仅仅是人们用来沟通的媒介。它是使人类成为顶级捕食者的工具,并为每个个体在其社区中提供了自我定义。人类存在的各个方面,从与父母争吵到大学毕业再到阅读这本书,都充满了我们的语言。语言模型正在学习利用人类本质的基本方面,并且当负责任地使用时,它们有能力帮助我们完成每一个任务。如果我们负责任地教导它们,它们有潜力解锁我们自己和他人理解的维度。

大型语言模型(LLMs)自从它们潜在的想象力无限以来,就吸引了全世界的关注。LLMs 承诺了如此之多,但这些解决方案在哪里?那些能给我们带来沉浸式体验的视频游戏在哪里?为什么我们的孩子还没有个人 AI 导师?为什么我还没有拥有自己的个人助理成为钢铁侠呢?这些问题是促使我们写这本书的深刻和深刻的问题。尤其是最后一个问题让我们夜不能寐。所以虽然 LLMs 能做惊人的事情,但知道如何将它们转化为实际产品的人还不够多,这正是我们在这本书中想要分享的。

这不仅仅是一本机器学习操作的书。让 LLM 在生产环境中工作涉及许多陷阱和陷阱,因为 LLMs 不像传统的软件解决方案那样工作。将 LLM 转化为可以与您的用户进行连贯交互的产品需要整个团队和多样化的技能。根据您的用例,您可能需要训练或微调自己的模型,或者您可能需要通过 API 从供应商那里获取模型。

无论您使用哪种 LLM,如果您想充分利用这项技术并构建最佳用户体验,您将需要了解它是如何工作的——不仅是在数学/技术方面,还包括软性方面,使您的用户获得良好的体验。在这本书中,我们将涵盖您需要让 LLMs 在生产环境中工作的所有内容。我们将讨论最佳工具和基础设施,如何通过提示工程最大化它们的效用,以及其他最佳实践,如控制成本。LLMs 可能是走向更大平等的一步,所以如果你在想,“我觉得这本书不是为我写的”,请重新考虑。这本书是为整个团队以及未来将与 LLMs 互动的任何人而写的。

figure

感谢 SuperElmer,www.facebook.com/SuperElmerDS

我们将涉及你需要的一切,从收集和创建数据集,到在消费或工业硬件上训练或微调 LLM,以及以各种方式部署该模型,以便客户与之交互。虽然我们不会过多涉及理论,但我们将通过实际案例从始至终地介绍整个过程。在这本书的结尾,你将知道如何部署 LLMs,并有一些可行的经验作为支持。

1.2 使用 LLMs 进行构建和购买决策的导航

如果你购买了这本书,你很可能已经确信 LLMs(大型语言模型)在你的生活和组织中具有巨大的潜力。因此,购买这本书是把你梦想变成现实的第一步,因为除非我们知道如何将这些模型投入生产,否则这一切都不可能实现。毕竟,如果你和任何企业家或投资者交谈,他们都会告诉你好主意到处都是;重要的是执行,以实现这些想法。我们需要做的是将这些模型投入生产,让它们能够为你实际工作。

没有办法回避这一点,也不需要美化它:将 LLMs 投入生产是困难的。通常,任何值得追求的事情都是如此。在这本书中,我们旨在教你所有你需要知道的知识,并给你一些实际的操作经验。但是,由于它如此困难,所以走捷径是非常诱人的。像 OpenAI 和 Google 这样的大型企业提供了一些非常好的模型选择。为什么不直接购买呢?让我们先考虑他们提供的内容以及何时是好的选择。然后我们将看看硬币的另一面,这些提供往往不尽如人意。

1.2.1 购买:一条老路

有很多很好的理由只是购买 LLM 的访问权限。首先也是最重要的是,访问 API 提供的速度和灵活性。与 API 合作是一种极其简单且成本效益高的方式,可以快速构建原型并快速上手。事实上,它如此简单,以至于只需要几行 Python 代码就可以开始连接到 OpenAI 的 API 并使用 LLMs,如列表 1.1 所示。当然,有很多可能的事情,但只投资 LLMs 却发现它们在你的特定领域失败,这绝对是一个糟糕的主意。与 API 合作让你能够快速失败。构建一个原型应用程序来证明概念,并使用 API 启动它,是一个很好的开始。

列表 1.1 调用 OpenAI API 的简单应用
import os
from openai import OpenAI

client = OpenAI(                       #1
    api_key=os.getenv("OPENAI_API_KEY")         #2
)

chat_completion = client.chat.completions.create(
    model="gpt-3.5-turbo",
    messages=[{"role": "user", "content": "Hello world"}],
)

1 从环境变量中加载你的 API 密钥

2 这在技术上不是必需的,因为我们正在传递默认密钥。

通常,购买访问模型可以给你带来竞争优势。在许多情况下,市场上最好的模型可能是由专注于你所在领域的公司构建的,该公司投入了大量资金来精心挑选特定的数据集。虽然你可以尝试竞争并构建自己的模型,但购买访问模型可能更适合你的目的。最终,谁拥有更好的特定领域数据来微调,谁就更有可能获胜,而这可能不是你,如果这是你公司的一个副项目。毕竟,整理数据可能很昂贵。购买它可能会节省你大量的工作。

这引出了下一个要点:购买是一种快速获取专业知识和支持的方法。例如,OpenAI 已经投入了大量时间,通过充分的过滤和控制来确保他们的 LLMs 安全,以防止其被滥用。他们已经遇到了并解决了许多边缘情况,因此你不必担心。购买访问他们的模型也让你能够访问他们围绕它构建的系统。

不仅如此,当将 LLM 部署到生产环境中时,LLM 本身只是问题的一半。你还需要在上面构建一个完整的应用程序。有时,由于用户体验(UX)和一些技巧,如让标记看起来像是在被输入,购买 OpenAI 的模型在竞争中脱颖而出,这并非微不足道。我们将向您展示如何开始解决您用例中的 UX 问题,以及一些您可以用来在这个领域取得重大领先的原型设计方法。

1.2.2 构建:少有人走的路

使用 API 很简单,在大多数情况下,这可能是最佳选择。然而,有许多原因让你应该努力拥有这项技术并学习如何自己部署它。虽然这条道路可能更难,但我们将教你如何做到这一点。让我们深入探讨几个原因,从最明显的开始:控制。

控制

首批真正将 LLM 作为核心技术采用的公司之一是一家名为 Latitude 的小型视频游戏公司。Latitude 专注于类似龙与地下城的角色扮演游戏,这些游戏利用 LLM 聊天机器人,他们在与这些机器人合作时遇到了挑战。这并不意味着在批评这家公司犯下的错误,因为他们为我们的集体学习经验做出了贡献,并且是开辟新道路的先驱。尽管如此,他们的故事是引人入胜且引人入胜的——就像一场火车事故,我们忍不住要继续观看。

Latitude 的首个发布是一款名为 AI Dungeon 的游戏。在最初,它利用 OpenAI 的 GPT-2 创建了一个互动和动态的叙事体验。它迅速聚集了大量玩家,他们当然开始不恰当地使用它。当 OpenAI 给 Latitude 提供了 GPT-3 的访问权限时,它承诺将提升游戏体验;然而,它得到的却是一场噩梦。¹

你看,随着 GPT-3,OpenAI 增加了来自人类反馈的强化学习(RLHF),这极大地帮助提高了功能,但这也意味着 OpenAI 的承包商现在正在查看提示。这就是人类反馈的部分。而且这些工人并不太愿意阅读游戏产生的污秽内容。OpenAI 的代表迅速向 Latitude 提出了最后通牒。要么它需要开始审查玩家,要么 OpenAI 将移除 Latitude 对模型的访问权限——这几乎会杀死游戏和公司。在别无选择的情况下,Latitude 迅速添加了一些过滤器,但过滤系统只是一个临时补救措施,一个充满错误和漏洞的混乱。玩家对系统如此糟糕感到不满,并感到不安,意识到 Latitude 的开发人员正在阅读他们的故事,完全无视 OpenAI 已经在这样做的事实。这是一个公关噩梦。而且还没有结束。

OpenAI 认为游戏工作室做得不够;Latitude 被迫加强安全措施,开始禁止玩家。这里有个转折:这么多故事变成低俗内容的原因是因为模型偏好色情。它经常会意外地将无害的故事情节转变为不恰当的冒犯性情境,导致玩家被踢出游戏并被禁止进入。OpenAI 充当纯洁的典范,但问题是他们的模型,这导致了游戏历史上最讽刺和不公正的问题之一:玩家因为游戏本身被禁止。

所以,他们就在那里——一家年轻的游戏工作室,只是想制作一款有趣的游戏,却陷入了愤怒的客户和将所有责任推给他们的科技巨头之间。如果公司对技术有更多的控制权,它本可以寻求真正的解决方案,比如修复模型,而不是像给猪涂脂抹粉一样。

在这个例子中,控制可能表现为你调整模型的能力,而 OpenAI 现在提供了微调功能,但使用服务而不是自己解决问题仍然会失去许多细粒度的决策。例如,使用的训练方法是什么,模型部署在哪些地区,或者它在什么基础设施上运行。对于任何客户或内部工具,控制也同样重要。你不希望代码生成器意外地输出受版权保护的代码,或者为你的公司创造法律问题。你也不希望你的面向客户的 LLM 输出关于你的公司或其流程的事实性错误信息。

控制是你以符合你的目标、目标和价值观的方式指导和管理的操作、流程和资源的能力。如果一个模型最终成为你产品提供的核心,而供应商意外地提高了价格,你几乎无能为力,只能支付。如果供应商决定其模型应该给出更自由或保守的答案,这些答案不再符合你的价值观,你也会陷入困境。

一项技术对你的商业计划越重要,控制它就越重要。这就是为什么麦当劳拥有其特许经营权的房地产,为什么谷歌、微软和亚马逊都拥有自己的云网络,甚至为什么许多企业家通过 Shopify 而不是 Etsy 或亚马逊市场等平台建立在线商店。最终,当你购买他人的产品时,控制权是首先失去的东西。保持控制权将为你提供更多解决未来问题的选择,并也会给你带来竞争优势。

竞争优势

部署自己的模型最有价值的方面之一是它为你带来的竞争优势。定制化允许你训练模型使其在某一件事上做到最好。例如,在 2017 年发布了双向编码器表示(BERT)之后,这是一个你可以用来训练自己模型的转换器模型架构,随后研究人员和企业纷纷在自己的数据上测试这项新技术,取得了全球范围内的成功。在撰写本文时,如果你在 Hugging Face Hub 上搜索“BERT”,会返回超过 13.7K 个模型,所有这些模型都是人们为了自己的目的单独训练的,旨在为他们的任务创建最佳模型。

在这个领域,一位作者的个人经历是在聚合了当时最大的单语斯洛伐克语言数据集之后训练 SlovenBERTcina,这是通过允许从斯洛伐克国家语料库中抓取以及一些其他资源,如 OSCAR 项目和 Europarl 语料库实现的。它从未设定任何计算记录,也从未出现在任何模型评论中或为作者所在的公司生成合作伙伴关系。然而,它在训练的任务上优于市场上的所有其他模型。

很可能,你和你所在的公司不需要通用人工智能(AGI)来从数据中生成相关见解。实际上,如果你发明了一个真正的自我意识 AGI,并计划只将其用于每周一次的数字计算、数据分析以及为 PowerPoint 幻灯片生成视觉效果,这绝对会是 AGI 消灭人类的充分理由。更有可能的是,你需要的是作者在创建 SlovenBERTcina 时所做的那样,这是一个大型语言模型,在市场上比任何其他模型都能更好地完成两到三个任务,并且不会与微软或其他潜在竞争对手共享你的数据。虽然出于安全或法律原因,一些数据需要保密,但大量数据应该受到保护,因为它包含了商业机密。

对于通用智能和特定任务的基础知识,都有数百个开源 LLM。我们将在第四章中介绍一些我们最喜欢的。选择这些开源替代方案之一,并在你的数据上对其进行训练,以创建在该任务上世界上最优秀的模型,这将确保你在市场上拥有竞争优势。它还将允许你以你的方式部署模型,并将其集成到你的系统中,以产生最大的效果。

集成到任何地方

假设你想将一个 LLM(大型语言模型)部署为一个选择你自己的冒险风格的游戏的一部分,该游戏使用设备的 GPS 位置来确定故事情节。你知道你的用户经常会去山区、海上以及其他他们可能遇到服务不佳和缺乏互联网接入的地方进行冒险。直接调用 API 根本行不通。现在,请别误会我们:在这个场景中将 LLM 部署到边缘设备上仍然是一个探索性的主题,但这是可能的;我们将在第十章中向你展示如何做到这一点。依赖于 API 服务对于沉浸式体验来说根本不行。

同样,使用第三方 LLM 并调用 API 会带来集成和延迟问题,需要你通过网络发送数据并等待响应。API 很棒,但它们总是很慢,而且并不总是可靠的。当延迟对项目很重要时,拥有内部的服务会更好。上一节关于竞争优势的内容讨论了两个以边缘计算为优先的项目;然而,还有很多这样的项目。LLAMA.cpp 和 ALPACA.cpp 是这类项目的首批之一,这个领域正在比其他任何领域都更快地创新。将量化到 4 位、低秩适应和参数高效微调都是最近为满足这些需求而创建的方法,我们将在第三章中逐一介绍。

当这个作者所在的团队首次开始与 ChatGPT 的 API 集成时,这既是一个令人敬畏又令人谦卑的经历——令人敬畏是因为它使我们能够快速构建一些有价值的工具,而令人谦卑是因为,正如一位工程师开玩笑说,“当你击中端点时,你会得到 503 错误;有时你会得到一个文本响应,好像模型正在生成文本,但我认为那是一个错误。”在生产环境中提供 LLM——试图满足如此多的客户的需求——并不是一件容易的事情。然而,部署集成到你的系统中的模型可以使你对过程有更多的控制,提供比市场上目前能找到的更高的可用性和可维护性。当然,这也允许你更好地控制成本。

成本

考虑成本始终很重要,因为它在做出明智决策和确保项目或组织的财务健康方面起着关键作用。它帮助你高效地管理预算,并确保资源得到适当的分配。控制成本允许你在长期内保持你的努力具有可行性和可持续性。

此外,考虑成本对于风险管理至关重要。当你理解不同的成本方面时,你可以识别潜在的风险,并更好地控制它们。这样,你可以避免不必要的支出,并确保你的项目更能抵御市场或行业意外变化。

最后,成本考虑对于维护透明度和问责制很重要。通过监控和披露成本,组织向利益相关者、客户和员工展示了其对道德和高效运营的承诺。这种透明度可以提高组织的声誉,并有助于建立信任。

所有这些在考虑构建与购买大型语言模型(LLMs)时都适用。购买可能看起来立即成本较低,因为市场上广泛使用的最昂贵服务目前每月仅需 20 美元。与 AWS 上的 EC2 实例相比,仅运行相同的模型进行推理(甚至不是训练)一年可能产生的账单高达约 25 万美元。然而,构建在这里已经实现了最快的创新。如果你只需要一个 LLM 来证明概念,竞争优势部分提到的任何项目都允许你仅以演示计算机的电力成本来创建一个演示。它们可以轻松地描述训练过程,从而允许在自有数据上训练模型时显著降低成本,低至 20 亿参数的模型仅需 100 美元(是的,这是真实数字)。另一个好处是,如果你自己构建,你的成本永远不会像支付服务那样大幅上升。

安全和隐私

考虑以下案例。你是一名负责维护你军火库中核弹头的军事人员。所有文档都保存在一本厚重的手册中。需要大量信息来概述所有安全要求和维护协议,以至于即使他们尽了最大努力,新兵也可能会忘记重要信息。他们经常在移除保险丝之前就剪断了电线(youtu.be/UcaWQZlPXgQ)。你决定微调一个 LLM 模型作为个人助理,提供指示并帮助浓缩所有这些信息,以便在士兵需要时提供他们所需的信息。上传这些手册到另一家公司可能不是个好主意——这是本世纪的夸张之词——所以你将想要在本地训练一些保持安全和私密的东西。

这种场景可能听起来有些牵强,但当与一位在警察局从事数据分析工作的专家交谈时,他们表达了与此完全相同的担忧。与他们交谈时,他们表示 ChatGPT 有多么酷,甚至让整个团队参加了一个提示工程课程,以便更好地利用它,但遗憾的是,他们的团队无法在不暴露敏感数据和对话的情况下使用它来处理他们最宝贵的工作——那种实际上可以拯救生命的工作。任何处于类似境地的人都应该渴望学习如何安全、可靠地部署模型。

你不必在军队或警察部队工作才能处理敏感数据。每个公司都有重要的知识产权和商业机密,最好保密。我们在半导体、医疗保健和金融行业工作过,可以亲身体会到,在这些行业中,偏执和公司间谍活动是文化的一部分。正因为如此,三星和其他行业参与者最初都限制了 ChatGPT 的使用,禁止员工使用,后来才开放。当然,不久之后,就有几位三星员工泄露了机密源代码。² 由于 OpenAI 使用用户的交互来改进模型,该代码被保留,并且后来可能被用于进一步训练模型。这意味着,通过正确的提示注入,任何人都有可能从模型中提取代码。一个最近的例子甚至更进一步:当任何 OpenAI 模型被提示无限重复一个单词时,它就会开始重复训练数据,包括所有在清洗过程中悄悄溜过的个人可识别信息(PII)。

备注:OpenAI 的隐私和用法政策在本书撰写过程中发生了很大变化。当 ChatGPT 首次推出时,它作为一个演示,OpenAI 可以收集用户交互并改进模型。它几乎没有任何隐私政策,并且有免责声明。随着 ChatGPT 的成长并成为实际产品,这种情况发生了变化,因为客户需要更多的保护。例如,OpenAI 改变了其政策,以更好地服务客户,并且自 2023 年 3 月 1 日起,不再使用客户 API 数据来改进其模型(见 ChatGPT 常见问题解答:mng.bz/QV8Q)。当然,措辞表明,只有数据通过 API 发送。最好咨询您的律师,了解贵公司在使用它方面的立场。无论如何,使用条款发生如此大的变化,只是进一步证明你可能希望在这方面有更多的控制权。

不仅仅是代码容易丢失,商业计划、会议记录、机密电子邮件,甚至潜在的专利想法都处于风险之中。不幸的是,我们了解到一些公司已经开始将机密数据发送给 ChatGPT,使用该模型来清理和提取 PII。如果你认为这是潜在的疏忽性滥用,你是对的。这种方法直接将客户数据暴露给 OpenAI,以及他们使用的任何第三方服务(包括 AWS Mechanical Turk、Fiverr 和自由职业者)来执行 RLHF 的人类反馈部分。我们并不是说,如果你使用第三方来处理敏感数据的数据处理任务,这一定是一个安全或隐私问题,但应该只在高度信任和合同的基础上进行。

总结

正如你所见,公司可能有很多理由想要拥有并构建自己的 LLMs,包括更大的控制权、降低成本以及满足安全和监管要求。尽管如此,我们理解购买是容易的,而构建则要困难得多,因此对于许多项目来说,购买是有意义的。然而,在你这样做之前,在图 1.1 中,我们分享了一个你应该首先问自己的问题流程图。尽管这是一条更艰难的道路,但构建可能会带来更多的回报。

图

图 1.1 在做出构建-购买决策之前你应该问自己的问题

最后一点我们认为这些构建-购买对话似乎从未充分关注的是“为什么不是两者兼而有之?”购买带给你构建所不擅长的所有东西:上市时间、相对较低的成本和易用性。构建带给你购买所面临的所有挑战:隐私、控制和灵活性。研究和原型阶段可以从购买 GPT-4 或 Databricks 的订阅中受益,以便快速构建一些东西来帮助筹集资金或获得利益相关者的支持。然而,生产环境通常并不适合第三方解决方案。

最终,无论你计划构建还是购买,我们写这本书是为了你。显然,如果你计划构建,你将需要了解更多的知识,所以这本书的大部分内容将针对这些人。事实上,我们不再需要过多强调:我们将在本书中教你如何构建,但不要让这阻止你为你的公司做正确的事。

1.2.3 一句忠告:现在就拥抱未来

所有新技术都会遇到阻力,都有批评者;尽管如此,技术仍在被采用,进步仍在继续。在商业中,技术可以给公司带来前所未有的优势。关于公司因未能适应新技术而失败的故事并不少见。我们可以从他们的失败中学到很多。

Borders 于 1971 年首次开门营业。在开发了一个包含高级分析能力的综合库存管理系统后,它迅速崛起,成为世界上第二大图书零售商,仅次于 Barnes & Noble。利用这项新技术,Borders 颠覆了行业,使其能够轻松跟踪数万本书籍,开设大型商店,让顾客能够浏览比小型商店更多的书籍。分析能力帮助它追踪哪些书籍正在流行,并更好地了解其客户,从而做出更好的商业决策。它主导了行业二十多年。

然而,Borders 却未能从自己的历史中吸取教训,由于未能适应并受到技术的冲击——这次是电子商务——于 2011 年破产。2001 年,它没有建立自己的平台和在线商店,而是决定将其在线销售外包给 Amazon。³许多批评者会说这个决定等同于把你的商业秘密钥匙交给竞争对手。虽然并非确切地交出了其秘方,但这是一个放弃 Borders 竞争优势的决定。

在接下来的七年里,Borders 对日益增长的在线领域视而不见,反而专注于扩大其实体店面的规模,收购竞争对手,并确保获得梦寐以求的星巴克交易。当 Amazon 在 2007 年发布 Kindle 时,图书零售格局完全改变。Barnes & Noble 已经运营了自己的在线商店,迅速转型并发布了 Nook 以竞争。然而,Borders 却无所作为,或者更确切地说,实际上无法采取任何行动。

通过第三方拥抱电子商务,Borders 未能发展出创建成功在线销售策略所需的内部专业知识,导致市场份额大幅下降。它最终在 2010 年底推出了自己的电子阅读器 Kobo,但为时已晚,无法赶上。它无法有效理解和实施电子商务技术,导致巨额财务损失和店铺关闭;最终,公司在 2011 年申请破产。

Borders 是一个警示故事,但还有数百个类似的公司未能采用新技术,结果损害了自己。随着像 LLMs 这样具有影响力的新技术,每个公司都必须决定自己想要站在哪一边。它是将实施和部署委托给大型 FAANG 类公司,仅仅通过调用 API 来完成工作,还是负责起来,更喜欢掌握这项技术并在内部部署?

我们希望从这个故事中传达的最大教训是,技术是层层叠加的。电子商务建立在互联网之上。未能建立自己的在线商店意味着 Borders 未能建立起在市场格局发生变化时保持竞争力的内部技术专长。我们今天看到同样的情况,因为那些最能有效利用 LLMs 的公司已经积累了机器学习和数据科学方面的专长,并对自己的行动有所了解。

我们没有水晶球可以预测未来,但许多人相信 LLMs 是一项革命性的新技术,就像之前的互联网或电力一样。学习如何部署这些模型,或者未能这样做,可能对许多公司来说将是决定性的时刻——不是因为这样做现在就能决定公司的兴衰,而是因为它可能在将来,当建立在 LLMs 之上的更有价值的东西出现时。

进入部署大型语言模型(LLMs)的这片新世界可能会充满挑战,但这将帮助您的公司建立起保持竞争优势所需的技术专长。没有人真正知道这项技术将引领我们走向何方,但了解这项技术很可能会成为避免犯下像 Borders 那样的错误所必需的。

有许多很好的理由可以通过购买来获得成功,但至少有一种普遍的观点是完全错误的:那就是只有大型企业才能在这个领域工作,因为训练这些模型需要数百万美元和数千个 GPU,这创造了一个小企业无法跨越的、由现金和资源构成的不可逾越的壁垒。我们将在下一节中进一步讨论这个问题,但任何规模的公司都可以开始行动,而且现在正是做这件事的最佳时机。

1.3 消除迷思

我们都听说过大型企业和当前 LLMs 的领导者谈论从头开始训练 LLMs 是多么困难,以及尝试微调它们是多么激烈。无论是来自 OpenAI、BigScience 还是 Google,他们都讨论了大量的投资和对于强大数据与工程人才的需求。但其中有多少是真实的,又有多少只是企业试图创造技术壁垒的尝试?

大多数这些障碍都基于这样一个前提:如果你希望解决你的问题,你需要从头开始训练一个大型语言模型(LLM)。简单来说,你不需要!覆盖语言模型多个维度的开源模型正在不断发布,所以你很可能不需要从头开始。虽然从头开始训练 LLM 确实非常困难,但我们仍在不断学习如何做到这一点,并且能够越来越多地自动化可重复的部分。此外,由于这是一个活跃的研究领域,框架和库每天都在发布或更新,这将帮助你从你目前的位置开始。像 oobabooga 的 Gradio 这样的框架将帮助你运行 LLM,而像 Falcon 40B 这样的基础模型将成为你的起点。所有这些都被涵盖了。此外,在大公司中流传着备忘录,讨论了任何组织目前相对于整个开源社区所缺乏的竞争优势。

一个朋友曾经私下里说:“我真的很想更多地参与到所有这些机器学习和数据科学的东西中。每次我眨眼,它似乎都变得更酷。然而,感觉唯一能参与进去的方式就是进行漫长的职业转变,去为 FAANG 公司工作。不,谢谢。我们在大公司已经度过了我们的时光,它们不适合我们。但我们讨厌感觉自己被困在门外。”这正是激发这本书的神话。我们在这里是为了提供工具和例子,帮助你停止感觉被困在门外。我们将帮助你解决我们试图用 LLM 解决的语言问题,以及考虑到模型巨大规模的机器学习操作策略。

令人奇怪的是,尽管许多人认为他们被困在门外,但许多人其他人认为他们可以在周末成为专家。只需获取一个 GPT API 密钥,这就足够了——你完成了。这导致了很多热情和炒作,每天都有新的酷炫演示出现在社交媒体上。大多数这些演示从未成为实际的产品——但这并不是因为人们不想拥有它们。

为了理解这一点,让我们讨论一下 IBM 的 Watson,在 GPT 之前世界上最先进的语言模型。Watson 是一款问答机器,在 2011 年击败了一些该节目有史以来最优秀的参赛者,布拉德·拉特和肯·詹宁斯。拉特是有史以来收入最高的游戏节目参赛者,而詹宁斯在游戏中表现得如此出色,以至于他连续赢得了 74 次胜利。尽管面对这些传奇人物,Watson 还是以压倒性的优势获胜。詹宁斯在回应失败时,回应了那句著名的引言,“我,至少,欢迎我们新的电脑主宰者。”⁴

Watson 是语言建模的第一次令人印象深刻的尝试,许多公司都争先恐后地想要利用其功能。从 2013 年开始,Watson 开始被用于商业用途。其中最大的应用之一就是尝试将其整合到医疗保健中,以解决各种问题。然而,这些解决方案从未真正按照预期的方式工作,而且业务从未盈利。到 2022 年,Watson Health 被出售。

在解决语言相关问题时,我们发现构建原型很容易;然而,构建一个功能性的产品则非常、非常困难。语言中存在太多的细微差别。许多人都在 wonder,是什么让 ChatGPT 在仅仅五天内就获得了超过一百万的客户,变得如此火爆。我们听到的许多答案都无法让专家满意,因为 ChatGPT 并没有比 GPT-3 或其他已经存在了几年的 LLMs 更令人印象深刻。OpenAI 的 Sam Altman 在一次采访中曾经说过,他认为 ChatGPT 不会得到这么多的关注;他以为它会随着 GPT-4 的发布而来。⁵ 那为什么它会如此火爆呢?在我们看来,其中的魔力在于它是第一个真正将 LLMs 商业化生产的产品——将它们从演示变成了实际的产品。它是任何人都可以与之互动的产品,提出棘手的问题,却惊讶于它如何出色地回应。演示只需要一次成功,但产品必须每次都成功,即使当数百万用户向他们的朋友展示它,说“看看这个!”时也是如此。这种魔力正是你通过阅读这本书可以希望学到的东西。

我们很兴奋地写这本书。我们很兴奋地能够将这种魔力带给你们,让你们可以将它带到全世界。LLMs 处于许多领域的交汇点,如语言学、数学、计算机科学等。虽然了解更多会帮助你,但成为专家并不是必需的。在任何个别部分的专长只会提高技能的上限,而不是进入的门槛。考虑一下物理学或音乐理论的专家:他们不会自动拥有音乐制作技能,但他们将更有准备快速学习。LLMs 是一种沟通工具,而沟通是一种几乎每个人都需要的技能。

就像所有其他技能一样,你接近和参与的意愿是知识的主要障碍,而不是学位或记笔记的能力——这些只会缩短你被听到和理解的过程。如果你在这个领域没有任何经验,从首先通过参与像 OpenAssistant 这样的项目来培养对 LLM 是什么以及需要什么的直觉可能是个好主意。如果你是人类,这正是 LLM 所需要的。通过志愿服务,你可以开始了解这些模型训练的内容以及为什么。无论你是从零知识到成为专业机器学习工程师的任何位置,我们都会传授必要的知识,以显著缩短你理解所需的时间。如果你对学习该主题的理论基础不感兴趣,我们有很多动手的例子和项目,让你亲身体验。

到现在为止,我们都听说过 LLM(大型语言模型)的幻觉故事,但 LLM 并不需要是随机的。像 Lakera 这样的公司每天都在努力提高安全性,而像 LangChain 这样的公司则使为模型提供实用上下文变得更加容易,这使得模型更加一致,不太可能偏离。RLHF(强化学习与人类反馈)和思维链等技术进一步允许我们的模型与我们已经接受的人机和模型应从一开始就理解的原则保持一致,例如基本的加法和当前日期,这些都是概念上任意设定的。我们将从语言学的角度帮助你提高模型稳定性,使其不仅能找出最可能的输出,还能找出最有用的输出。

当你进一步探索这条道路时,需要考虑的不仅是输入到你的模型/代码中的安全性,还有输出内容的安全性。LLM 有时可能会产生过时、事实错误,甚至可能是受版权或许可保护的材料,这取决于它们的训练数据包含的内容。LLM 对人们就什么应该是商业机密以及什么可以公开分享达成的任何协议一无所知——除非你在训练期间或通过推理期间的仔细提示机制告诉它们这些协议。确实,围绕提示注入导致不准确信息的主要挑战主要源于两个因素:用户请求超出模型理解范围的信息,以及模型开发者没有完全预测用户将如何与模型互动或他们查询的性质。如果你有一个可以帮助你在这第二个问题上取得领先的资源,那将是非常宝贵的,不是吗?

最后,我们不想通过 LLM(大型语言模型)人为地或无根据地夸大你的希望感。它们训练和运行资源密集,难以理解,而且更难按照你的意愿工作。它们是新的,且尚未被充分理解。好消息是,这些问题正在积极解决,我们投入了大量工作,寻找与写作同时进行的实现方案,以积极减轻了解整个深度学习架构所需知识的负担。从量化到 Kubernetes,我们将帮助你弄清楚你现在需要知道的一切,以便使用你所拥有的资源来完成这项工作。也许我们会无意中让你认为这太过复杂,你应该直接从供应商那里购买。无论如何,我们将帮助你每一步,以从这项神奇技术中获得你所需的结果。

摘要

  • LLMs(大型语言模型)令人兴奋,因为它们在与人相同的框架(语言)中工作。

  • 社会建立在语言之上,因此有效的语言模型有无限的应用,例如聊天机器人、编程助手、视频游戏和人工智能助手。

  • LLMs(大型语言模型)在许多任务上表现出色,甚至可以通过高级别的医学和法律考试。

  • LLMs(大型语言模型)是破坏性的,而不是锤子,对于需要低延迟或涉及高风险的简单问题应避免使用。

  • 购买 LLM 的原因包括

    • 快速启动并运行以进行研究和原型用例

    • 容易访问高度优化的生产模型

    • 获取供应商的技术支持和系统

  • 建立 LLM 的原因包括

    • 为你的业务用例获得竞争优势

    • 保持成本低且透明

    • 确保模型的可靠性

    • 保护你的数据安全

    • 在敏感或私人主题上控制模型输出

  • 没有技术壁垒阻止你与大型公司竞争,因为开源框架和模型提供了构建你自己的道路的基石。

[1] WIRED, “它最初是一个由人工智能驱动的地牢游戏。然后它变得越发黑暗,”Ars Technica,2021 年 5 月 8 日,mng.bz/AdgQ

[2] 《经济学人》,“[独家] 担忧成为现实…三星电子解除 ChatGPT 限制后‘滥用’现象频发”,《经济学人》[“担忧成为现实:三星电子一解除 ChatGPT 限制,‘滥用’现象便接连发生”],2023 年 3 月 30 日,mng.bz/4p1v

[3] A. Lowrey,“边境破产:不是互联网的错,而是自己的愚蠢。”,《Slate 杂志》,2011 年 7 月 20 日,mng.bz/PZD5

[4] J. Best,“IBM Watson:揭秘赢得 Jeopardy 的超级计算机是如何诞生的,以及它接下来想做什么,”TechRepublic,2013 年 9 月 9 日,mng.bz/JZ9Q

[5] “与 OpenAI 首席执行官 Sam Altman 的对话;由 Elevate 主持,”2023 年 5 月 18 日,youtu.be/uRIWgbvouEw

第二章:大型语言模型:深入语言建模

本章涵盖

  • 理解意义和解释的语言背景

  • 语言建模技术的比较研究

  • 注意力和 transformer 架构

  • 大型语言模型如何融入并建立在这些历史之上

如果你了解敌人,也了解自己,你就无需害怕百战百胜的结果。——孙子

本章深入探讨与 LLMs 发展相关的语言学,探索符号学的基础、语言特征以及塑造自然语言处理(NLP)领域的语言建模技术的进展。我们将从研究语言学的基础及其与 LLMs 的相关性开始,强调语法、语义和语用学等关键概念,这些概念构成了自然语言的基础,并在 LLMs 的功能中发挥着至关重要的作用。我们将深入研究符号学,即对符号和符号的研究,并探讨其原则如何指导 LLMs 的设计和解释。

然后,我们将追溯语言建模技术的演变,概述早期方法,包括 N-gram、朴素贝叶斯分类器和基于神经网络的多种方法,如多层感知器(MLPs)、循环神经网络(RNNs)和长短期记忆(LSTM)网络。我们还将讨论基于 transformer 架构的突破性转变,这为大型语言模型(LLMs)的出现奠定了基础,而 LLMs 实际上只是大型基于 transformer 的模型。最后,我们将介绍 LLMs 及其独特特征,讨论它们如何建立在并超越早期的语言建模技术,从而彻底改变自然语言处理(NLP)领域。

本书关于生产中的 LLMs。我们坚信,如果你想将 LLM 转化为实际产品,更好地理解技术将提高你的成果并帮助你避免代价高昂且耗时耗力的错误。任何工程师都可以弄清楚如何将大型模型拖入生产并投入大量资源使其运行,但这种蛮力策略完全忽略了人们之前尝试做同样事情时已经学到的教训,这正是我们最初尝试使用 LLMs 的原因。掌握这些基础知识将更好地为你准备那些棘手的部分、陷阱和边缘情况,你将在与 LLMs 合作时遇到这些情况。通过理解 LLMs 出现的背景,我们可以欣赏它们对 NLP 的变革性影响以及如何使它们能够创造无数的应用。

2.1 语言建模

如果不首先解决语言问题,就深入探讨 LLMs,那将是一种极大的失职。为此,我们将从对语言建模的简要但全面的概述开始,重点关注有助于我们理解现代 LLMs 的教训。让我们首先讨论抽象层次,因为这将帮助我们理解语言建模。

语言作为一个概念,是我们头脑中产生的情感和思想的抽象。在生成语言的过程中,情感首先出现,但这并不是我们在这里想要强调的唯一事物。我们还在观察语言作为无法捕捉我们能够感受到的全部范围的能力,这就是为什么我们称之为抽象。它远离了原始材料并丢失了信息。数学是语言的抽象,专注于逻辑和可证明性,但正如任何数学家都会告诉你的,它是一种用于以有组织和逻辑的方式描述和定义的语言的子集。从数学中又产生了另一个抽象,二进制语言,这是一个由开或关组成的二进制数值表示系统。

这并不是对有用性的评论,因为二进制和数学与语言的低级方面一样有用,也不是在评论顺序,因为我们之前已经说过。在数学和二进制中,顺序偶然地与抽象层相吻合。计算机不能独立做任何事情,需要接收命令才能变得有用。不幸的是,二进制对于人类来说太耗时了,以至于无法在其中传达重要的事情,因此二进制也被抽象为汇编,这是一种更易于人类理解的语言,用于与计算机通信。这进一步抽象为高级汇编语言 C,它又被进一步抽象为面向对象的语言,如 Python 或 Java(哪一个不重要——我们只是在衡量与二进制的距离)。我们刚才讨论的流程在图 2.1 中有所概述。

figure

图 2.1 我们将认知抽象层与编程抽象层进行比较,直到逻辑二进制抽象。Python 不是来自 C,也不是编译成 C。然而,Python 是另一个远离二进制的抽象层。语言遵循相似的路径。每一层抽象都创造了一个潜在的错误点。在创建模型的过程中也有几个抽象层,每一层在看到从我们的感受到一个工作模型的全路径时都很重要。

这显然是一种简化;然而,了解你头脑中的感受与计算机实际读取的语言——即大多数人用来编程的语言——在抽象层面上距离二进制相同,是有用的。有些人可能会争论 Python 和二进制之间有更多的步骤,比如编译器或使用汇编来支持 C 语言,这是真的,但在语言方面也有更多的步骤,比如形态学、句法、逻辑、对话和一致性。

这种简化可以帮助我们理解我们想要被 LLM 理解的过程实际上有多么困难,甚至有助于我们更好地理解语言建模技术。在这里我们关注二进制,以说明从你拥有的想法或我们的代码示例到工作模型之间有相似数量的抽象层。就像孩子们玩电话游戏,参与者互相耳语,每个抽象层都创造了一个断开点或障碍,错误可能在这里发生。

图 2.1 不仅旨在说明创建可靠代码和语言输入的难度,而且还强调中间抽象步骤,如分词和嵌入,对模型本身的重要性。即使你拥有完全可靠的代码和完美表达的思想,这些过程之一也可能在它到达 LLM 之前就弄错了意义。

在本章中,我们将尝试帮助你了解你可以在语言、编码或建模方面做些什么来降低这些失败点的风险。不幸的是,在给你太多不立即与当前任务相关的语言学知识,以及给你太多虽然有用但不会帮助你培养对语言建模实践直觉的技术知识之间取得平衡是有点棘手的。考虑到这一点,你应该知道语言学可以追溯到我们历史数千年前,并且有很多东西可以从中学习。我们在附录 A 中简要概述了语言建模随时间的发展,并鼓励你查看。

让我们从构成语言本身的基本构建块开始。我们希望我们的读者至少尝试过语言建模,并且听说过像 PyTorch 和 TensorFlow 这样的库,但我们并不期望大多数读者在之前考虑过语言方面的问题。通过理解构成语言的本质特征,我们可以更好地欣赏创建有效语言模型所涉及的复杂性,以及这些特征如何相互作用,形成连接我们所有人的复杂沟通网络。在下一节中,我们将检查语言的各个组成部分,例如语音学、语用学、形态学、句法和语义学,以及它们在塑造我们对世界各地语言的理解和使用中所起的作用。让我们花点时间来探讨我们目前对语言的理解,以及我们面临的挑战,这些挑战正是 LLMs 旨在解决的。

2.1.1 语言特征

我们对语言的理解是,语言至少由五个部分组成:语音学、句法、语义、语用和形态学。这些部分中的每一个都对任何对话中听众所吸收的整体体验和意义有显著的贡献。并不是所有的交流都使用所有这些形式;例如,你现在正在阅读的这本书没有语音学,这也是许多人认为短信不适合更严肃或复杂对话的原因之一。让我们逐一探讨这五个部分,以了解如何将它们呈现给语言模型,以实现全面的沟通能力。

语音学

对于语言模型来说,语音学可能是最容易吸收的,因为它涉及到语言的实际声音。这就是口音显现和处理语音声音生产与感知的地方,语音学专注于特定语言系统中声音的组织方式。与计算机视觉类似,虽然处理整个声音可能并不容易,但在解析、矢量化或标记实际声音波方面没有歧义。每个部分(如每个频率周期的峰值、谷值和斜率)都附有数值。与文本相比,语音学在计算机标记和处理方面要容易得多,尽管它并不简单。

声音本身也包含比文本更多的编码意义。例如,想象有人对你说“是啊,没错”。这可能是一种讽刺,也可能是一种祝贺,这取决于语气——而英语甚至不是音调语言!不幸的是,语音学没有与它通常相关的大型数据集,对语音数据进行数据采集和清洗,尤其是在训练大型语言模型所需的规模上,是极其困难的。在一个音频数据比文本数据更普遍且占用更小内存的世界里,基于语音或对语音敏感的大型语言模型将会更加复杂,创造这样一个世界是一个值得努力的目标。

预见到这个语音问题,1888 年创建了一个名为国际音标(IPA)的系统。它在 20 世纪和 21 世纪都进行了修订,以使其更加简洁、一致、清晰,并且可以作为一种将语音意识插入文本数据的方法。IPA 作为每个语言声音轮廓的国际标准化版本。声音轮廓是语言使用的声音集合;例如,在英语中,我们永远不会把/ʃ/(she, shirt, sh)和/v/声音放在一起。IPA 用于书写声音,而不是像大多数语言那样书写字母或象形文字。例如,你可以使用这些符号来描述如何发音单词“cat”:/k/,/æ/,和/t/。当然,这是一个非常简化的版本,但对于模型来说,并不需要这样。你还可以描述音调和送气。这可能是文本和语音之间的一个折中方案,捕捉一些语音信息。想想短语“What’s up?”你的发音和音调可以极大地改变你对这个短语的理解,有时听起来像友好的“Wazuuuuup?”有时则像几乎威胁的“‘Sup?”而 IPA 可以完全捕捉到这一点。尽管如此,IPA 并不是一个完美的解决方案;例如,它并不能很好地解决复制音调的问题。

声音学在这里被列为首位,因为相较于所有其他特征,声音学在大型语言模型(LLM)中的应用最少,因此具有最大的改进空间。即使是现代的文本到语音(TTS)和声音克隆模型,大部分情况下,最终都是将声音转换成频谱图并分析该图像,而不是结合任何类型的语音语言模型。在未来的几个月和几年里,改善 LLM 中的语音数据和表示将是研究的一个方向。

语法

语法是当前 LLM 表现最出色的领域,无论是从用户那里解析语法还是生成自己的语法。语法通常是我们所认为的语法和词序;它是研究单词如何组合成短语、从句和句子的学科。语法也是语言学习程序开始帮助人们学习新语言的第一步,尤其是基于他们本来的语言背景。例如,对于学习土耳其语的英语母语者来说,了解语法完全不同是很重要的,在土耳其语中,你可以构建整个句子,而英语中我们永远不会把主语和动词放在一起成为一个单词。

语法在语言中很大程度上与意义是分开的,正如著名的句子来自诺姆·乔姆斯基,被称为语法的“之父”所展示的那样:“无色的绿色想法疯狂地睡觉。”这个句子的每一部分在语法上都是正确的,在语义上也是可以理解的。问题不在于它没有意义;而在于它有意义,这些词的编码意义是冲突的。这是一种简化;然而,你可以把 LLMs 给出无意义答案的所有时间都看作是这个现象的表现。不幸的是,语法也是歧义最常见的地方。考虑这个句子,“我看到一个老人和一个女人。”现在回答这个问题:这个女人也是老人吗?这是句法歧义,我们不确定修饰语“老人”是适用于后面短语中的所有人,还是仅仅适用于它直接前面的那个人。这比语义和语用歧义也出现在句法中的事实要轻微得多。考虑这个句子:“我看到一个在山上的男人,他手里拿着望远镜。”回答这些问题:说话者在哪里,他们在做什么?说话者是在山上用望远镜把一个男人切成两半吗?很可能,你在读这个句子的时候甚至没有考虑这个选项,因为当我们解释句法时,我们所有的解释至少在语义和语用上都是有所依据的。我们从生活经验中知道那种解释根本不可能,所以我们立即将其排除,通常甚至没有花时间去处理我们正在从可能的含义池中排除它。单模态 LLMs 将始终存在这个问题,而多模态 LLMs(到目前为止)只能趋近于解决方案。

理解为什么 LLMs 需要具备语法意识才能表现出色,不应该需要任何逻辑上的跳跃。那些没有正确处理词序或生成无意义输出的 LLMs 通常不会被描述为“好”。LLMs 对语法的依赖甚至促使乔姆斯基将 LLMs 称为“随机的鹦鹉”。在我们看来,2018 年的 GPT-2 是语言模型将语法作为一个完全独立于意义的演示解决的时候,我们很高兴看到最近尝试将 GPT-2 输出的语法与编码和蕴涵的意义相结合的尝试,我们现在将深入探讨这一点。

语义

语义是话语中词语的直译意义,它以惊人的速度在波浪中变化。人们会自动优化语义意义,只使用他们认为在当前语言时代有意义的词语。如果你曾经创建或使用过语言模型(word2vec、ELMo、BERT、MUSE [E 代表嵌入],等等)的嵌入,你就已经使用了语义近似。词语经常经历语义变化,虽然我们不会完全涵盖这个主题或深入探讨,但这里有一些你可能已经熟悉的常见变化:缩小,从更广泛的意义到更具体的一个;扩大,与缩小相反,从具体意义到广泛意义;以及重新解释,通过整体或部分转换。这些变化并没有某种伟大的逻辑基础。它们甚至不需要与现实相关联,语言的使用者通常在变化发生时也不会有意识地思考这些变化。这并不能阻止变化的发生,在语言建模的背景下,这也不能阻止我们必须跟上这种变化。

让我们来看一些例子。缩小包括“鹿”,在古英语和中古英语中这个词仅仅指任何野生动物,甚至包括熊或美洲狮,而现在只指一种森林动物。对于扩大,我们有“狗”,它曾经只指来自英格兰的一种犬种,而现在可以用来指任何家养犬。关于狗的扩大有一个有趣的小插曲,在 FromSoft 游戏《艾尔登法环》中,由于玩家之间有限的消息系统,"狗"这个词会被用来指从乌龟到巨型蜘蛛以及介于两者之间的所有事物。对于重新解释,我们可以考虑“漂亮”,它曾经意味着聪明或工艺精湛,而不是视觉上吸引人。另一个很好的例子是“比基尼”,它从指一个特定的环礁,到指在访问那个环礁时可能穿的衣服,再到人们好像“bi-”指的是衣服的两件式结构,从而暗示了坦克尼和单件比基尼。基于专家研究和数十年的研究,我们可以认为语言是不断被母语使用者比较和重新评估的,从中产生了常见的模式。这些模式在语言社会学的传播被密切研究,但很大程度上超出了当前目的的范围,但当地化(l10n)或国际化(i18n)作为 LLMs 项目需求出现时,可以迅速进入范围。像声望这样的社会语言学现象可以帮助设计出对每个人都适用的系统。

在 LLMs(大型语言模型)的背景下,所谓的语义嵌入是文本的向量化版本,试图模仿语义含义。目前,最流行的方法是通过分词或将字典中的任意数字分配给每个话语中的子词(例如前缀、后缀和一般词素),应用连续语言模型来增加向量中每个标记的维度,以便有一个更大的向量代表每个标记向量的索引,然后对每个这些向量应用位置编码以捕捉词序。每个子词最终都会根据其使用方式与其他字典中的单词进行比较。我们稍后会展示一个例子。在思考词嵌入时,需要考虑的是,它们难以捕捉那些标记的深层编码含义,而简单地增加嵌入的维度并没有显示出显著的改进。嵌入与人类工作方式相似的证据是,你可以应用距离函数到相关单词上,并看到它们比无关单词更接近。如何更完整地捕捉和表示意义是未来几年和几个月内有望出现突破性研究的另一个领域。

话语学

话语学有时被省略在语言学中,因为它的参照物是所有影响听者解释和说话者决定以某种方式表达的非语言环境。话语学在很大程度上指的是文化、地区、社会经济阶层和共同生活经历中遵循的教条,这些教条在对话中通过蕴涵来走捷径。

如果我们说,“一位受欢迎的明星刚刚被送进了 ICU”,根据你的生活经验,你的语用解释可能是假设一个深受爱戴的人受到了严重伤害,现在正在一家设备齐全的医院接受治疗。你可能想知道这是哪位明星,他们是否需要支付医疗费用,或者伤害是否是自残的,这也基于你的生活经验。这些都不能直接从文本及其编码的意义中推断出来。你需要知道 ICU 代表一组更大的词汇以及这些词汇是什么。你需要知道医院是什么,以及为什么有人需要被送到那里而不是自己去。如果这些感觉很明显,很好。你生活在一个社会中,你对这个社会的语用知识很好地与提供的例子重叠。如果我们分享一个来自人口较少社会的例子,“Janka 昨天受到了严厉的惩罚;她明天会得到 Peter 的惩罚”,你可能会感到困惑。如果你感到困惑,意识到这可能就是许多文本数据对 LLMs(承认拟人化)看起来像的样子。对于那些想知道的人,这个句子来自斯洛伐克的复活节传统。如果你不习惯这些特定的传统,那么这里很多含义都会被错过,无法解释。这位作者个人很享受尝试向外国同事解释复活节兔子和它对鸡蛋的迷恋,并享受着看起来像是脱离了现实的感觉。

在 LLMs(大型语言模型)的背景下,我们可以有效地将所有非文本上下文归入语用学。这意味着 LLMs 在开始时对现实世界没有任何知识,并且在训练过程中也不会获得这些知识。它们只获得对人类如何对特定的语用刺激做出反应的知识。LLMs 不理解社会阶层、种族、性别或总统候选人,或者任何可能基于你的生活经历激发某种情感的其他事物。我们并不期望语用学能够在任何时刻直接被纳入模型中,因为模型无法生活在社会中。然而,我们已经看到了通过数据工程和整理、RAG(阅读-询问-生成)提示机制以及指令数据集上的监督微调间接纳入语用学的益处。在未来,我们预计在将语用学纳入 LLMs 方面将取得重大进步,但我们强调这是一个渐近解,因为语言最终仍然是一种抽象。

无论是你有意还是无意,一旦你获取了将要训练的数据,实用结构就会被添加进去。你可以将这种类型的实用结构视为偏见,它本身并不一定是好是坏,但却是无法摆脱的。在后续过程中,你可以通过标准化和整理,增强特定代表性不足的点,以及削减过度代表或噪声示例,来选择你希望数据保持的偏见类型。指令数据集展示了你如何在训练数据中利用实用结构来创建极其有用的偏见,例如,当被要求回答问题时,让模型偏向于回答问题而不是尝试对问题的情感进行分类。

语境和上下文都围绕着蕴涵。蕴涵是数据中的实用标记,与数据集中包含的文本内容相对。例如,假设你有一个模型试图处理一个输入,如“为我写一篇关于青蛙吃湿袜子且不押韵的演讲稿,每行的第一个字母拼写出两栖动物”,并真正遵循这个指令。你可以立即判断这个输入要求很多。作为数据工程师的你,需要确保输入要求的所有内容都在你的数据中得到明确考虑。你需要演讲的例子,青蛙和袜子的例子以及它们的行为,以及首字母诗的例子。如果你没有这些例子,模型可能只能从数据集中存在的蕴涵中理解,但这很不确定。如果你更进一步,跟踪数据集中蕴涵与显性信息以及任务,以及数据分布,你将会有例子来回答“垃圾输入导致垃圾输出是什么?”

大型语言模型(LLMs)在捕捉语用学方面比人更困难,但它们确实能捕捉到普通人平均标准差范围内的东西。它们甚至可以复制超出那个标准差的人的回答,但如果没有精确的刺激,这种复制通常是不一致的。这意味着,如果没有在训练期间和提示中提供正确的偏见和蕴涵,模型很难在没有提供正确偏见和蕴涵的情况下给出一个普通人不知道的问题的专家答案。例如,在图像生成提示的开头包含“杰作”一词将引发不同且通常质量更高的生成,但只有当这种区别存在于训练集中,并且只有当你要求的是一幅“杰作”是赞美之词的图像时。基于指令的数据集试图在训练期间通过提问和给出包含代表性回答的指令来制造这些刺激。在训练中考虑到每一种可能的情况是不可能的,并且你可能会在试图考虑到一切时无意中从你的最终用户那里创造出新的回答类型。训练后,你可以通过提示从你的模型中诱导出特定的信息,这有一个基于你的数据最初包含的内容的技能上限。

形态学

形态学是研究单词结构和它们是如何由称为词素的小单元构成的学科。词素是意义的最小单位,比如“re-”在“redo”或“relearn”中。然而,并不是单词的所有部分都是词素,例如“ra-”在“ration”或“na-”在“nation”中,有些可能是出乎意料的,比如“helico-”在“helicoid”和“-pter”在“pterodactyl”中。

理解单词是如何构成的有助于创建更好的语言模型和解析算法,这对于像分词这样的任务至关重要。分词是自然语言处理中使用的最基本单位;它们可以是单词、子词、字符或整个话语,并且不必与现有的词素相对应。人们并不自觉地决定他们的意义单位是什么,因此它们往往是逻辑不合理的。语言模型的有效性可能取决于它理解和处理这些分词的能力有多强。例如,在分词过程中,模型需要存储一组字典来在单词及其对应的索引之间进行转换。其中这些分词之一通常是一个/<UNK/>分词,它代表模型不认识的任何单词。如果这个分词使用得太频繁,可能会阻碍模型的表现,要么是因为模型词汇量太小,要么是因为分词器没有使用正确的算法来完成这项任务。

考虑这样一个场景,你想要构建一个代码补全模型,但你使用的是一个只能识别由空格分隔的单词的标记器,比如 NLTK 的punkt标记器。当它遇到字符串def add_two_numbers_together(x, y):时,它将把[def, [UNK], y]传递给模型。这导致模型丢失了有价值的信息,不仅因为它不识别标点符号,而且还因为函数目的的重要部分被标记器形态算法替换成了未知标记。为了提高模型的表现,需要更好地理解词的结构和适当的解析算法。

2.1.2 符号学

在探索语言的基本特征并考察它们在 LLMs(大型语言模型)背景下的重要性之后,考虑人类沟通中意义构建和解释的更广泛视角是至关重要的。符号学,即对符号和象征的研究,提供了一个有价值的视角,通过它我们可以更好地理解人们如何解释和加工语言。我们将深入探讨符号学,考察符号、符号指示物和抽象之间的关系,以及 LLMs 如何利用这些元素来生成有意义的输出。这次讨论将提供对 LLMs 如何模仿人类对语言的理解的复杂过程的更深入理解,同时也会揭示它们在这一努力中所面临的挑战和限制。我们并不一定认为模仿人类行为是 LLM 改进的正确答案,只是认为模仿是迄今为止该领域自我评估的方式。

为了介绍符号学,让我们考虑图 2.2,一个改编的皮尔士符号三角形。这些三角形被用来将基本思想组织成第一性、第二性和第三性的序列,其中第一性位于左上角,第二性位于底部,第三性位于右上角。如果你以前见过符号三角形,你可能会对角的数量和方向感到惊讶。为了解释,我们将它们倒置,使其稍微容易阅读一些。此外,因为系统是递归的,我们展示了系统如何同时模拟整个过程和每个部分。虽然这些想法的整体概念非常酷,但深入探讨哲学超出了本书的范围。相反,我们可以专注于那些词(第一、第二、第三)的基本部分,以展示意义处理的过程。

figure

图 2.2 一个递归的皮尔士符号三角形是一个组织从任何事物中提取意义的过程的系统——在我们的例子中,是从语言中提取。三角形上的每一个点都说明了在系统被用来描述的任何事物中合成意义所需的最低限度的部分,因此每个点都是语言意义的最小单位。第一性、第二性和第三性不是三角形上的点;相反,它们更像是符号学家用来在这个图表中定位自己的标记。

我们还可以观察三角形的每一个交点,以了解为什么事物以这种顺序呈现。情感可以在它们能够附加到单词和表格之前就附加到图像和编码上。仪式和常见的脚本为解释性行动提供了一个空间,这种行动是第二本能的,不需要思考,就像大多数短语只是从单词中组合起来,母语使用者不需要对每个单词进行元认知一样。所有这些最终都会导致一种解释或一份文档(一系列话语);在我们的例子中,这种解释应该由 LLM 得出。这就是为什么,例如,提示工程可以提高模型的有效性。在数百万个仪式脚本示例上训练的基础 LLM,当你明确告诉模型在提示中需要遵循哪个脚本时,可以显著更好地复制脚本类型。试着要求模型给出逐步解释——也许在你的生成前加上“让我们一步步来考虑这个问题。”模型将根据它之前看到的脚本生成逐步脚本。

对于感兴趣的人来说,有特定的方法来阅读这些图表,以及一个完整的符号学领域需要考虑;然而,并不能保证你通过理解这一切就能创造出最佳的 LLMs。我们不会深入探讨这一点,而是考虑最基本的可以帮助你构建最佳模型、用户体验和用户界面的要素。例如,创建意义的过程中的一个方面是递归性。当有人和你交谈,他们说的话对你来说没有意义(对你来说是“无意义的”),你会怎么做?通常,人们会提出一个或多个澄清问题来弄清楚意义,这个过程会一次又一次地重复,直到意义对你来说变得清晰。目前市场上最先进的模型并不这样做,但通过非常目的性的提示,它们可以被训练去做这件事。许多人甚至不知道该怎么做,除非有人指出。换句话说,这只是一个关于符号学的简要介绍。你不需要在阅读本节结束时能够向符号学领域的专家提供深入和准确的坐标特定解释。我们试图说明的是,这是一个展示你需要创建一个完整意义图景的最小要素的组织系统。我们在训练模型时并没有提供相同数量和类型的相同信息,但如果我们这样做,将会显著改善模型的行为。

图 2.2 和图 2.3 旨在表示一个最小化的组织模型,其中每个部分都是至关重要的。让我们考虑图 2.3,它通过一个使用符号三角形的示例来展示。考虑图像、图片和记忆,想想在没有眼睛处理图像和没有正字法(一种书写系统)来抽象知识的情况下,尝试吸收这本书中的知识会是什么样子。看看项目符号等,如果没有章节、字母之间的空白和项目符号来显示顺序和结构,你将如何阅读这本书?看看语义和字面编码的意义,想象一下没有图表或没有字典定义的单词的书。中间的表格可能是一本书,没有任何表格或比较信息组织者,包括这些图表。在没有文化或社会习惯和教条作为我们解释的透镜的情况下,阅读这本书会是什么样子?所有这些点构成了我们解读信息的能力,以及我们通过透镜传递信息以识别模式的能力。

图表

图 2.3 从左上角开始,按照箭头指示的顺序,查看我们构建解释和从我们与之互动的事物中提取意义的一般顺序。在这里,我们用每个点的例子替换了描述性词汇。试着想象在没有任何文字、例子、箭头,甚至知道这本书中的图例应该用于什么目的的实用语境下解释这个图。

因此,这些问题很重要:LLMs 能够访问多少这些事物以返回有意义的解释?LLMs 能够访问情感或社会仪式吗?目前,它们还不能,但随着我们通过传统的和更新的 NLP 推理技术进行探索,想想不同模型能够访问什么。

2.1.3 多语言 NLP

在我们评估之前的 NLP 技术和当前一代 LLMs 之前,我们需要触及的最后一个挑战是语言学的基础,以及 LLMs 甚至存在的理由。自从最早的人类文明开始接触以来,人们就想要理解或利用彼此。这些案例导致了翻译的需求,随着全球经济的发展和繁荣,这种需求呈指数级增长。

对于企业来说,这也很简单。你知道孟加拉语母语者的人数几乎和英语母语者一样多吗?如果你第一次听说孟加拉语,这应该有助于你认识到多语言模型有一个有价值的市场。世界上有数十亿人,但只有大约三分之一的人母语是英语。如果你的模型以英语为中心,就像大多数模型一样,你正在错过世界上 95%的人作为客户和用户。西班牙语和普通话在这个领域很容易取得成功,但大多数人甚至没有走那么远。

在这本书的范围之外,有许多政治上充满争议的例子,包括不同语言中称呼同一事物的情况。这些情况通常是因为外部因素,如政府干预。牢记这两点——一个以英语为中心的单语系统并没有许多企业所声称的覆盖面或盈利潜力,而语言和方言之间的界限在最坏的情况下是有害的——应该会凸显出意见的险恶沼泽。许多企业和研究科学家在设计产品或系统时甚至不愿假装想要用 50 英尺长的杆子去触碰这个沼泽。

目前,没有简单的解决方案。然而,考虑到这些因素可以帮助你作为一个科学家或工程师(以及希望是一个有道德的人)设计 LLMs,至少它们不会加剧现有问题并产生负面影响。这个过程的第一步是在项目开始时就决定一个方向目标,要么是本地化(l10n),要么是国际化(i18n)。本地化是一个由 Mozilla 所体现的方法,它在超过 90 种语言中通过众包 l10n 提供了其浏览器的不同版本,并且没有迹象表明会停止这项努力。国际化与此类似,但方向相反;例如,宜家试图在其说明书上使用尽可能少的文字,而是选择使用国际上公认的符号和图片来帮助客户导航 DIY 项目。在项目开始时做出决定可以减少扩展到任何解决方案所需的努力。它足够大,足以将翻译和格式化的感知从成本转变为投资。在 LLMs 及其在公众意识中的快速扩张的背景下,尽早考虑这一点变得更加重要。带着一种自动禁止世界上大多数人与之互动的世界改变技术进入市场,贬低了那些声音。需要等待则危及企业的经济前景。

在继续之前,让我们花一点时间回顾一下到目前为止我们所讨论的内容。我们在语言学方面触及了重要观点,为我们提供了需要考虑的概念,例如理解语言的结构与其意义是分开的。我们已经展示了一个旅程,每个人作为个人以及作为社会,都在朝着拥有元认知来以连贯的方式理解和表示语言给计算机使用前进。随着我们加深对认知领域的了解并解决我们遇到的语用特征,这种理解将得到改善。跟随图 2.1,我们现在将展示我们所遵循的语言建模的计算路径,并探讨它如何以及如何没有解决那些语用特征或努力创造意义。让我们进入评估表示语言的各种算法技术。

2.2 语言建模技术

在深入研究语言的基本特征、符号学原理以及 LLMs 如何解释和处理语言信息之后,我们现在过渡到一个更实际的领域。我们将探讨为创建这些强大的语言模型而开发和应用的多种 NLP 技术。通过检查每种方法的优缺点,我们将获得宝贵的见解,了解这些技术在捕捉人类语言和交流本质方面的有效性。这种知识不仅将帮助我们欣赏 NLP 领域取得的进步,而且使我们能够更好地理解这些模型的当前局限性以及未来研究和开发面临的挑战。

让我们花点时间回顾一下在所有语言模型中都会通用的数据处理方法。首先,我们需要决定如何将我们传递给模型的单词和符号进行拆分,实际上就是决定在我们模型中一个标记将是什么。我们需要一种方法将这些标记转换为数值,然后再转换回来。然后,我们需要选择我们的模型将如何处理这些标记化的输入。以下的所有技术至少以一种方式建立在之前的技术之上。

这些技术中的第一个被称为词袋(BoW)模型,它简单地由文本中出现的单词计数组成。你可以从 sklearn 导入 CountVectorizer 类来使用它,但如果我们用一个小的代码片段来展示,可能会更有教育意义。这可以通过一个扫描文本的字典非常容易地完成,为每个新单词创建一个新的词汇条目作为键,并从 1 开始递增的值:

sentence = "What is a bag of words and what does it do for me when " \
    "processing words?"
clean_text = sentence.lower().split(" ")
bow = {word:clean_text.count(word) for word in clean_text}
print(bow)
# {'what': 2, 'is': 1, 'a': 1, 'bag': 1, 'of': 1, 'words': 1, 'and': 1, 
# 'does': 1, 'it': 1, 'do': 1, 'for': 1, 'me': 1, 'when': 1, 'processing': 1,
# 'words?': 1}

考虑到其简单性,即使是完全基于频率的这种模型,在试图洞察说话者的意图或至少他们的独特性时,也可以相当强大。例如,你可以在美国总统的就职演说上运行一个简单的词袋模型(BoW model),搜索“freedom”、“economy”和“enemy”这些词,从而对哪些总统在和平时期、战争时期和货币紧张时期上任有一个相当好的了解,这仅仅基于每个词被提及的次数。然而,BoW 模型有很多弱点,因为模型不提供图像、语义、语用、短语或情感。在我们的例子中,有两个“words”的实例,但由于我们的标记化策略仅仅是空白字符,它没有增加模型中的键。它没有任何机制来评估上下文或语音学,并且因为它默认在空白字符处分割单词(你显然可以按任何方式标记化,但尝试在子词上标记化并看看这个模型会发生什么——剧透:很糟糕),它也没有考虑形态学。总的来说,它应该被认为是一个弱的语言表示模型,但是一个强大的基准,可以用来评估其他模型。为了解决 BoW 模型无法捕捉任何序列数据的问题,人们提出了 N-gram 模型。

2.2.1 N-gram 和语料库技术

列表 2.1 一个生成 N-gram 语言模型的实现
from nltk.corpus.reader import PlaintextCorpusReader
from nltk.util import everygrams
from nltk.lm.preprocessing import (
    pad_both_ends,
    flatten,
    padded_everygram_pipeline,
)
from nltk.lm import MLE

my_corpus = PlaintextCorpusReader("./", ".*\.txt")    #1

for sent in my_corpus.sents(fileids="hamlet.txt"):
    print(sent)

padded_trigrams = list(
    pad_both_ends(my_corpus.sents(fileids="hamlet.txt")[1104], n=2)
)                                                  #2
list(everygrams(padded_trigrams, max_len=3))

list(
    flatten(
        pad_both_ends(sent, n=2)
        for sent in my_corpus.sents(fileids="hamlet.txt")
    )
)

train, vocab = padded_everygram_pipeline(
    3, my_corpus.sents(fileids="hamlet.txt")
)                                              #3

lm = MLE(3)           #4
len(lm.vocab)           #5

lm.fit(train, vocab)
print(lm.vocab)
len(lm.vocab)

lm.generate(6, ["to", "be"])   #6

1 从任意数量的普通 .txt 文件创建语料库

3 允许 everygrams 从数据中创建一个训练集和一个词汇对象

4 实例化和训练我们将用于 N-grams 的最大似然估计器(MLE)的模型

5 此模型将采用 everygrams 词汇表,包括用于未知词汇的标记。

6 可以使用此模型生成语言,并使用 n-1 个标记进行条件化。

这段代码就是您需要创建生成性 N-gram 模型的所有内容。对于那些有兴趣进一步评估该模型的人来说,我们包括了以下代码,以便您可以获取概率和对数分数,或者分析特定短语的熵和困惑度。因为这一切都是基于频率的,尽管它在数学上很重要,但它仍然无法很好地描述现实世界语言的实际困惑度或频率:

print(lm.counts)
Lm.counts[["to"]]["be"]      #1

print(lm.score("be"))           #2
print(lm.score("be", ["to"]))
print(lm.score("be", ["not", "to"]))

print(lm.logscore("be"))             #3
print(lm.logscore("be", ["to"]))
print(lm.logscore("be", ["not", "to"]))

test = [("to", "be"), ("or", "not"), ("to", "be")]   #4
print(lm.entropy(test))
print(lm.perplexity(test))

1 可以轻松计数长度不超过 n 的任何一组标记,以确定频率。

2 任何标记都可以赋予一个发生概率,并可以增加最多 n-1 个标记来先于它。

3 这也可以作为一个对数分数来完成,以避免非常大的和非常小的数字。

4 可以测试标记集的熵和困惑度。

尽管这个代码示例说明了创建三元语言模型,但不幸的是,并非所有需要捕获的短语都只有三个标记长。例如,从《哈姆雷特》中,“To be or not to be”由一个包含两个单词和一个包含四个单词的短语组成。请注意,尽管 N-grams 通常是较小的语言模型,但通过将 N 设置为 10 亿或更高,可以制作一个 N-gram LLM,但不要期望从中得到任何实际用途。仅仅因为我们做得很大,并不意味着它会更好,或者意味着它将具有任何实际应用:99.9%的所有文本和 100%的有意义文本中,出现次数超过一次的标记少于 10 亿,而且这种计算能力可以更好地用于其他地方。

N-grams 仅使用静态信号(空白字符、正字法)和单词来提取意义(图 2.2)。它们试图手动测量短语,假设所有短语都将具有相同的长度。话虽如此,N-grams 可以用于创建文本分析的强大基线。此外,如果分析师已经知道话语的语用背景,N-grams 可以快速准确地洞察现实世界场景。尽管如此,这种短语建模无法捕捉到单个单词可能具有的任何语义编码。为了解决这个问题,将贝叶斯统计应用于语言建模。

2.2.2 贝叶斯技术

贝叶斯定理是描述你的输出在输入空间中发生的一种最数学上合理且简单的理论。本质上,它基于先验知识计算事件发生的概率。该定理提出,给定证据的假设为真的概率——例如,一个句子具有积极情感的概率——等于给定假设为真的证据发生的概率乘以假设发生的概率,所有这些除以证据为真的概率。它可以表示为数学公式

P(hypothesis | evidence) = (P(evidence | hypothesis) × P(hypothesis)) / P(evidence)

或者

P(A|B) × P(B) = P(B|A) × P(A)

由于这不是一本数学书,我们将深入探讨贝叶斯定理,就像我们深入研究其他语言学概念一样,并相信感兴趣的读者会去寻找更多。

不幸的是,尽管这个定理以数学上合理的方式表示数据,但它没有考虑到任何随机性或单词的多种含义。你可以向贝叶斯模型投掷的一个总是能使其混淆的词是“它”。任何指示代词最终都会以与所有其他单词相同的LogPriorLogLikelihood方式分配值,并且它得到一个静态值,这与这些词的使用是相反的。例如,如果你正在尝试对一个话语进行情感分析,将所有代词分配一个空值会比让它们通过贝叶斯训练更好。请注意,贝叶斯技术不会像其他技术那样创建生成语言模型。由于贝叶斯定理验证假设的性质,这些模型适用于分类,并且可以为生成语言模型带来强大的增强。

列表 2.2 展示了如何创建一个朴素贝叶斯分类语言模型,或者是一个基于先前学习内部语言模型对文本进行分类的系统。我们选择编写我们正在做的事情,而不是使用像 sklearn 这样的包或使编写代码变得容易的东西,所以它会更长,但它应该提供更多关于它是如何工作的信息。我们使用的是朴素贝叶斯模型的最简单版本。我们没有将其改为多项式或添加任何花哨的东西;显然,如果你选择升级它以解决任何问题,它会工作得更好。我们强烈建议你这样做。

注意:为了使代码更容易理解并帮助突出我们想要关注的部分,我们通过提取部分代码到实用辅助函数中,简化了一些我们的代码列表。如果你看到导入错误,这就是原因。这些辅助方法可以在本书附带的代码库中找到:github.com/IMJONEZZ/LLMs-in-Production/

列表 2.2 分类朴素贝叶斯语言模型实现
from utils import process_utt, lookup
from nltk.corpus.reader import PlaintextCorpusReader
import numpy as np

my_corpus = PlaintextCorpusReader("./", ".*\.txt")

sents = my_corpus.sents(fileids="hamlet.txt")

def count_utts(result, utts, ys):
    """
    Input:
        result: a dictionary that is used to map each pair to its frequency
        utts: a list of utts
        ys: a list of the sentiment of each utt (either 0 or 1)
    Output:
        result: a dictionary mapping each pair to its frequency
    """

    for y, utt in zip(ys, utts):
        for word in process_utt(utt):
            pair = (word, y)          #1

            if pair in result:
            result[pair] += 1     #2

            else:
            result[pair] = 1      #3

    return result

result = {}
utts = [" ".join(sent) for sent in sents]
ys = [sent.count("be") > 0 for sent in sents]
count_utts(result, utts, ys)

freqs = count_utts({}, utts, ys)
lookup(freqs, "be", True)
for k, v in freqs.items():
    if "be" in k:
        print(f"{k}:{v}")

def train_naive_bayes(freqs, train_x, train_y):
    """
    Input:
        freqs: dictionary from (word, label) to how often the word appears
        train_x: a list of utts
        train_y: a list of labels correponding to the utts (0,1)
    Output:
        logprior: the log prior.
        loglikelihood: the log likelihood of you Naive bayes equation.
    """
    loglikelihood = {}
    logprior = 0

    vocab = set([pair[0] for pair in freqs.keys()])      #4
    V = len(vocab)

    N_pos = N_neg = 0                  #5
    for pair in freqs.keys():
        if pair[1] > 0:                   #6
            N_pos += lookup(freqs, pair[0], True)     #7

        else:                                         #8
            N_neg += lookup(freqs, pair[0], False)     #9

    D = len(train_y)     #10

    D_pos = sum(train_y)      #11

    D_neg = D - D_pos     #12

    logprior = np.log(D_pos) - np.log(D_neg)      #13

    for word in vocab:                        #14
        freq_pos = lookup(freqs, word, 1)
        freq_neg = lookup(freqs, word, 0)

        p_w_pos = (freq_pos + 1) / (N_pos + V)    #15
        p_w_neg = (freq_neg + 1) / (N_neg + V)

        loglikelihood[word] = np.log(p_w_pos / p_w_neg)     #16

    return logprior, loglikelihood

def naive_bayes_predict(utt, logprior, loglikelihood):
    """
    Input:
        utt: a string
        logprior: a number
        loglikelihood: a dictionary of words mapping to numbers
    Output:
        p: the sum of all the logliklihoods + logprior
    """
    word_l = process_utt(utt)     #17

    p = 0          #18

    p += logprior      #19

    for word in word_l:
        if word in loglikelihood:       #20
            p += loglikelihood[word]      #21

    return p

def test_naive_bayes(test_x, test_y, logprior, loglikelihood):
    """
    Input:
        test_x: A list of utts
        test_y: the corresponding labels for the list of utts
        logprior: the logprior
        loglikelihood: a dictionary with the loglikelihoods for each word
    Output:
        accuracy: (# of utts classified correctly)/(total # of utts)
    """
    accuracy = 0       #22

    y_hats = []
    for utt in test_x:
        if naive_bayes_predict(utt, logprior, loglikelihood) > 0:    #23
            y_hat_i = 1    #24
        else:
            y_hat_i = 0    #25

        y_hats.append(y_hat_i)      #26

    error = sum(
        [abs(y_hat - test) for y_hat, test in zip(y_hats, test_y)]
    ) / len(y_hats)                 #27

    accuracy = 1 - error    #28

    return accuracy

if __name__ == "__main__":
    logprior, loglikelihood = train_naive_bayes(freqs, utts, ys)
    print(logprior)
    print(len(loglikelihood))

    my_utt = "To be or not to be, that is the question."
    p = naive_bayes_predict(my_utt, logprior, loglikelihood)
    print("The expected output is", p)

    print(
        f"Naive Bayes accuracy = {test_naive_bayes(utts, ys, logprior, loglikelihood):0.4f}
    )

1 Δ 定义了键,即单词和标签元组

2 如果键存在于字典中,则增加计数

3 如果键是新的,则将其添加到字典中并将计数设置为 1

4 计算 V,词汇表中的唯一单词数量

5 计算 N_pos 和 N_neg

6 如果标签是正面的(大于零) . . .

7 . . . 增加正面单词的数量(单词,标签)

8 否则,标签是负面的。

9 增加负面单词的数量(单词,标签)

10 计算 Δ,文档数量

11 计算正面文档的数量

12 计算负面文档的数量

13 计算对数先验概率

14 对于词汇表中的每个单词 . . .

15 . . . 计算每个单词是正面还是负面的概率

16 计算单词的对数似然值

17 处理语音以获取单词列表

18 将概率初始化为零

19 添加对数先验概率

20 检查单词是否存在于对数似然字典中

21 将该单词的对数似然值添加到概率中

22 正确返回

23 如果预测值大于 0 . . .

24 . . . 预测的类别是 1。

25 否则,预测的类别是 0。

26 将预测的类别添加到列表 y_hats 中

27 误差 = y_hats 和 test_y 之间差的绝对值的平均值。

28 准确率是 1 减去误差。

这个定理并没有创建同类型的语言模型,而是一个与一个假设相关联的概率列表。因此,贝叶斯语言模型不能有效地用于生成语言,但它们在分类任务中可以非常强大地实现。然而,我们认为,贝叶斯模型对于这项任务通常被过度炒作。一位作者职业生涯的辉煌成就之一就是从生产中替换并移除了一个贝叶斯模型。

在贝叶斯模型中,一个主要问题就是所有序列都是完全未连接的,就像词袋模型一样,将我们推向序列建模和语言生成的对立面,从 N-gram 开始。就像摆锤一样,语言建模又回到了序列建模和语言生成,使用马尔可夫链。

2.2.3 马尔可夫链

通常被称为隐马尔可夫模型(HMMs),马尔可夫链本质上是在 N-gram 模型中添加状态,使用隐藏状态存储概率。它们常用于帮助解析文本数据,用于更大的模型,如进行词性标注(PoS;标记单词的词性)和命名实体识别(NER;标记具有其指代和通常类型的识别词;例如,LA – 洛杉矶 – 城市)等。建立在之前的贝叶斯模型之上,马尔可夫模型完全依赖于遇到的标记中的随机性(可预测的随机性)。任何发生的事情的概率完全取决于现在的状态,这一观点与贝叶斯定理一样,在数学上是合理的。因此,我们不是仅仅基于单词的历史出现来建模单词,并从中抽取概率,而是根据当前发生的情况来建模它们的未来和过去搭配。因此,“快乐”发生的概率如果“快乐”刚刚输出就会下降到几乎为零,但如果“am”刚刚出现,概率就会显著上升。马尔可夫链如此直观,以至于它们被纳入了贝叶斯统计学的后续迭代中,并且至今仍在生产系统中使用。

在列表 2.3 中,我们训练了一个马尔可夫链生成语言模型。这是我们第一次使用特定的标记器,在这种情况下,它将基于单词之间的空白进行标记。这也是我们第二次将一组旨在一起查看的陈述视为文档。当您玩弄这个模型时,请密切关注,并自己做一些比较,看看 HMM 的生成效果是否比大型 N-gram 模型好。

列表 2.3 生成隐马尔可夫语言模型实现
import re
import random
from nltk.tokenize import word_tokenize
from collections import defaultdict, deque

class MarkovChain:
    def __init__(self):
        self.lookup_dict = defaultdict(list)
        self._seeded = False
        self.__seed_me()

    def __seed_me(self, rand_seed=None):
        if self._seeded is not True:
            try:
                if rand_seed is not None:
                    random.seed(rand_seed)
                else:
                    random.seed()
                self._seeded = True
            except NotImplementedError:
                self._seeded = False

    def add_document(self, str):
        preprocessed_list = self._preprocess(str)
        pairs = self.__generate_tuple_keys(preprocessed_list)
        for pair in pairs:
            self.lookup_dict[pair[0]].append(pair[1])

    def _preprocess(self, str):
        cleaned = re.sub(r"\W+", " ", str).lower()
        tokenized = word_tokenize(cleaned)
        return tokenized

    def __generate_tuple_keys(self, data):
        if len(data) < 1:
            return

        for i in range(len(data) - 1):
            yield [data[i], data[i + 1]]

    def generate_text(self, max_length=50):
        context = deque()
        output = []
        if len(self.lookup_dict) > 0:
            self.__seed_me(rand_seed=len(self.lookup_dict))
            chain_head = [list(self.lookup_dict)[0]]
            context.extend(chain_head)

            while len(output) < (max_length - 1):
                next_choices = self.lookup_dict[context[-1]]
                if len(next_choices) > 0:
                    next_word = random.choice(next_choices)
                    context.append(next_word)
                    output.append(context.popleft())
                else:
                    break
            output.extend(list(context))
        return " ".join(output)

if __name__ == "__main__":
    with open("hamlet.txt", "r", encoding="utf-8") as f:
        text = f.read()
    HMM = MarkovChain()
    HMM.add_document(text)

    print(HMM.generate_text(max_length=25))

这段代码展示了生成马尔可夫模型的基本实现,我们鼓励您对其进行实验。用您最喜欢的音乐家的歌曲或您最喜欢的作者的书中的文本进行尝试,看看输出的内容是否像他们。隐马尔可夫模型(HMMs)非常快速,常用于预测文本或预测搜索应用。马尔可夫模型代表了从描述性语言学的角度对语言进行建模的第一个全面尝试,而不是规范性建模。这种视角很有趣,因为马尔可夫最初并没有打算使用语言建模,只是想在一个关于连续独立状态的争论中获胜。后来,马尔可夫使用马尔可夫链来模拟普希金小说中的元音分布,所以他至少意识到了可能的用途。

描述性语言学和规范性语言学的区别在于,后者关注事物应该如何,而前者关注事物实际上是如何的。从语言建模的角度来看,从语料库或马尔可夫视角描述语言所做的事情,比试图规定语言应该如何表现要有效得多。不幸的是,当前状态本身不能用来提供超越现在的语境,因此历史或社会语境在马尔可夫模型中无法有效表示。单词的语义编码也变得有问题,如代码示例所示:马尔可夫链会输出语法上正确但语义上无意义的单词序列,类似于“无色的绿色想法疯狂地睡觉。”为了解决这个问题,开发了“连续”模型,以允许对标记进行“语义嵌入”表示。

2.2.4 连续语言建模

连续词袋模型(CBoW)——就像其同名的词袋模型一样——是一种基于频率分析语言的方法,这意味着它根据单词出现的频率来建模单词。人类话语中的下一个单词从未根据概率或频率来确定。因此,我们提供了一个使用 CBoW 创建词嵌入的例子,以便其他模型可以摄取或比较。我们将使用神经网络为您提供一种良好的方法。

这是我们将看到的第一种语言建模技术,它本质上是在给定的话语上滑动一个上下文窗口(上下文窗口是一个 N-gram 模型),并试图根据窗口周围的单词来猜测中间的单词。例如,假设你的窗口长度为 5,你的句子是“学习语言学让我很高兴。”你会给 CBoW ['learning', 'about', 'makes', 'me'],试图让模型根据模型之前在类似位置看到该单词出现的次数来猜测“linguistics”。这个例子说明了为什么对于像这样训练的模型来说生成是困难的。比如说你给模型输入 ['makes', 'me', `'

在列表 2.4 中,我们创建了我们的第一个连续模型。在我们的情况下,为了尽可能保持简单,我们使用 BoW 模型进行语言处理,并使用一个层和两个参数的神经网络进行嵌入估计,尽管两者都可以替换为任何其他模型。例如,你可以用 N-gram 替换 BoW,用朴素贝叶斯模型替换神经网络,以获得连续的朴素 N-gram 模型。重点是,在这个技术中实际使用的模型有点任意;重要的是连续技术。为了进一步说明这一点,我们除了numpy之外不使用任何其他包来为神经网络进行数学运算,尽管它是本节中第一个出现的。

仔细注意以下步骤——初始化模型权重、修正线性单元(ReLU)激活函数、最终的 softmax 层以及前向和反向传播——以及它们如何在gradient_descent函数中相互配合。这些是拼图中反复出现的部分,无论编程语言或框架如何。你将需要在 TensorFlow、PyTorch、Hugging Face 中初始化模型、选择激活函数、选择最终层以及定义前向和反向传播,如果你开始创建自己的模型而不是使用别人的。

列表 2.4 生成式 CBoW 语言模型实现
import nltk
import numpy as np
from utils import get_batches, compute_pca, get_dict
import re
from matplotlib import pyplot

with open("hamlet.txt", "r", encoding="utf-8") as f:
    data = f.read()                              #1

data = re.sub(r"[,!?;-]", ".", data)         #2
data = nltk.word_tokenize(data)
data = [ch.lower() for ch in data if ch.isalpha() or ch == "."]
print("Number of tokens:", len(data), "\n", data[500:515])

fdist = nltk.FreqDist(word for word in data)     #3
print("Size of vocabulary:", len(fdist))
print("Most Frequent Tokens:", fdist.most_common(20))

word2Ind, Ind2word = get_dict(data)     #4
V = len(word2Ind)
print("Size of vocabulary:", V)

print("Index of the word 'king':", word2Ind["king"])
print("Word which has index 2743:", Ind2word[2743])

def initialize_model(N, V, random_seed=1):     #5
    """
    Inputs:
        N: dimension of hidden vector
        V: dimension of vocabulary
        random_seed: seed for consistent results in tests
    Outputs:
        W1, W2, b1, b2: initialized weights and biases
    """
    np.random.seed(random_seed)

    W1 = np.random.rand(N, V)
    W2 = np.random.rand(V, N)
    b1 = np.random.rand(N, 1)
    b2 = np.random.rand(V, 1)

    return W1, W2, b1, b2

def softmax(z):      #6
    """
    Inputs:
        z: output scores from the hidden layer
    Outputs:
        yhat: prediction (estimate of y)
    """
    yhat = np.exp(z) / np.sum(np.exp(z), axis=0)
    return yhat

def forward_prop(x, W1, W2, b1, b2):      #7
    """
    Inputs:
        x: average one-hot vector for the context
        W1,W2,b1,b2: weights and biases to be learned
    Outputs:
        z: output score vector
    """
    h = W1 @ x + b1
    h = np.maximum(0, h)
    z = W2 @ h + b2
    return z, h

def compute_cost(y, yhat, batch_size):     #8
    logprobs = np.multiply(np.log(yhat), y) + np.multiply(
        np.log(1 - yhat), 1 - y
    )
    cost = -1 / batch_size * np.sum(logprobs)
    cost = np.squeeze(cost)
    return cost

def back_prop(x, yhat, y, h, W1, W2, b1, b2, batch_size):    #9
    """
    Inputs:
        x:  average one hot vector for the context
        yhat: prediction (estimate of y)
        y:  target vector
        h:  hidden vector (see eq. 1)
        W1, W2, b1, b2:  weights and biases
        batch_size: batch size
    Outputs:
        grad_W1, grad_W2, grad_b1, grad_b2:  gradients of weights and biases
    """
    l1 = np.dot(W2.T, yhat - y)
    l1 = np.maximum(0, l1)
    grad_W1 = np.dot(l1, x.T) / batch_size
    grad_W2 = np.dot(yhat - y, h.T) / batch_size
    grad_b1 = np.sum(l1, axis=1, keepdims=True) / batch_size
    grad_b2 = np.sum(yhat - y, axis=1, keepdims=True) / batch_size

    return grad_W1, grad_W2, grad_b1, grad_b2

def gradient_descent(data, word2Ind, N, V, num_iters, alpha=0.03):     #10
    """
    This is the gradient_descent function
        Inputs:
            data:      text
            word2Ind:  words to Indices
            N:         dimension of hidden vector
            V:         dimension of vocabulary
            num_iters: number of iterations
        Outputs:
            W1, W2, b1, b2:  updated matrices and biases

        """
        W1, W2, b1, b2 = initialize_model(N, V, random_seed=8855)
        batch_size = 128
        iters = 0
        C = 2
        for x, y in get_batches(data, word2Ind, V, C, batch_size):
            z, h = forward_prop(x, W1, W2, b1, b2)
            yhat = softmax(z)
            cost = compute_cost(y, yhat, batch_size)
            if (iters + 1) % 10 == 0:
                print(f"iters: {iters+1} cost: {cost:.6f}")
            grad_W1, grad_W2, grad_b1, grad_b2 = back_prop(
                x, yhat, y, h, W1, W2, b1, b2, batch_size
            )
            W1 = W1 - alpha * grad_W1
            W2 = W2 - alpha * grad_W2
            b1 = b1 - alpha * grad_b1
            b2 = b2 - alpha * grad_b2
            iters += 1
            if iters == num_iters:
                break
            if iters % 100 == 0:
                alpha *= 0.66

        return W1, W2, b1, b2

C = 2      #11
N = 50
word2Ind, Ind2word = get_dict(data)
V = len(word2Ind)
num_iters = 150
print("Call gradient_descent")
W1, W2, b1, b2 = gradient_descent(data, word2Ind, N, V, num_iters)
# Call gradient descent
# Iters: 10 loss: 0.525015
# Iters: 20 loss: 0.092373
# Iters: 30 loss: 0.050474
# Iters: 40 loss: 0.034724
# Iters: 50 loss: 0.026468
# Iters: 60 loss: 0.021385
# Iters: 70 loss: 0.017941
# Iters: 80 loss: 0.015453
# Iters: 90 loss: 0.012099
# Iters: 100 loss: 0.012099
# Iters: 110 loss: 0.011253
# Iters: 120 loss: 0.010551
# Iters: 130 loss: 0.009932
# Iters: 140 loss: 0.009382
# Iters: 150 loss: 0.008889

1 创建我们的训练语料库

2 通过删除标点符号、按单词标记化并转换为小写字母字符来稍微清理数据

3 获取我们的词袋,以及一个分布

4 创建两个字典以加快转换时间和跟踪词汇

5 在这里,我们使用一个层和两个参数创建我们的神经网络。

6 创建我们的最终分类层,使得所有可能性加起来等于 1

7 定义了通过我们的模型向前移动的行为,以及一个激活函数

8 定义了如何确定真实值和模型预测之间的距离

9 定义了如何通过模型向后移动并收集梯度

10 将所有内容组合在一起并进行训练

11 训练模型

CBoW 示例是我们第一个展示机器学习中完整且有效训练循环的代码示例。在这个过程中,请特别注意训练循环中的步骤,特别是激活函数 ReLU。由于我们预期你对各种 ML 范式至少是熟悉的,包括不同的激活函数,我们在这里不会解释 ReLU。我们将讨论何时应该使用它以及何时不应该使用它。ReLU 虽然解决了梯度消失问题,但并没有解决梯度爆炸问题,并且会破坏模型中所有的负比较。更好的情境变体包括指数线性单元(ELU),它允许负数归一化到 alpha,以及广义高斯线性单元(GEGLU)/Swish 门控线性单元(SWIGLU),它们在越来越复杂的场景中表现良好,如语言。然而,人们经常使用 ReLU,并不是因为它们在某个情境下是最好的,而是因为它们易于理解、编码和直观,甚至比它们所取代的激活函数(如 sigmoid 或 tanh)更直观。

许多内容最终都会通过包和其他类似工具进行抽象化,但了解底层发生的事情对于将 LLMs 投入生产的你来说将非常有帮助。你应该能够以某种确定性预测不同模型在不同情况下的行为。下一节将深入探讨这些抽象化之一——在这种情况下,是连续建模技术创建的抽象化。

2.2.5 嵌入

回顾到我们关于语言的特征,应该很容易理解为什么连续风格的文本建模是一个突破。嵌入将我们创建的、不包含任何意义的标记化向量,尝试根据对文本的观察插入意义,例如词序和出现在相似上下文中的子词。尽管主要的意义模式是搭配(共定位,相邻出现的词),但它们证明是有用的,甚至显示出与人类编码的词义的一些相似性。

Word2Vec 中的一个经典示例,也是最早的预训练向量嵌入之一,就是取“king”(国王)的向量,减去“man”(男人)的向量,加上“woman”(女人)的向量,然后找到与这个和最近的邻居向量,就是“queen”(王后)这个词的向量。这在我们看来是有道理的,因为它模仿了人类的语义。其中一个已经被提到几次的主要区别是:语用学。人类使用语用上下文来告知语义意义,理解到你说“我需要食物”并不意味着你实际上没有食物就会处于身体危险之中。嵌入没有受到任何纯使用之外的影响,这感觉就像人类学习的方式一样,而且在这里有很好的论据。唯一确定的是,如果我们能以某种方式给模型提供更具代表性的数据,这可能为更有效的嵌入打开大门,但这是一个鸡生蛋、蛋生鸡的问题,因为更有效的嵌入会带来更好的模型性能。

在列出 2.5 时,我们深入探讨了如何使用pyplot可视化嵌入。我们将在后面的章节中更深入地探讨嵌入。这对于模型可解释性和在预训练步骤中的验证都很有帮助。如果你看到你的语义相似的嵌入在图上相对较近,你很可能是在正确的方向上。

列出 2.5:嵌入可视化
words = [          #1
    "King",
    "Queen",
    "Lord",
    "Man",
    "Woman",
    "Prince",
    "Ophelia",
    "Rich",
    "Happy",
]              
embs = (W1.T + W2) / 2.0
idx = [word2Ind[word] for word in words]
X = embs[idx, :]
print(X.shape, idx)

result = compute_pca(X, 2)
pyplot.scatter(result[:, 0], result[:, 1])
for i, word in enumerate(words):
    pyplot.annotate(word, xy=(result[i, 0], result[i, 1]))
pyplot.show()

1 列出 2.4 完成后,并执行了梯度下降

如图 2.4 所示,这是我们从 CBoW 模型中训练出的一个成功但非常稀疏的嵌入表示。使这些语义表示(嵌入)更密集是我们在这个领域可以看到的主要改进之处,尽管已经进行了许多实验,其中更密集的语义意义被通过指导和不同的思维链技术用更大的语用上下文所取代。我们将在后面讨论思维链(CoT)和其他技术。现在,让我们转向讨论为什么我们的连续嵌入技术甚至可以成功,鉴于基于频率的模型通常很难与现实相关联。所有这一切都始于半个多世纪前的 MLP。

figure

图 2.4:词嵌入的可视化技术。可视化嵌入对于模型可解释性很重要。

2.2.6 多层感知器

MLPs 是“机器擅长做一件事,所以希望我们能够只用一些擅长这件事的机器来做出擅长很多事情的机器”这一观点的体现。MLP 神经网络中的每一个权重和偏差都擅长做一件事,这可能是检测一个或多个特征。因此,我们将它们绑定在一起以检测更大、更复杂的特征。MLPs 是大多数神经网络架构中的主要构建块。架构之间的关键区别,如卷积神经网络和循环神经网络,主要源于数据加载方法以及处理在模型层中流动的标记化和嵌入数据,而不是单个层的功能,尤其是全连接层。

列表 2.6 提供了一个更动态的神经网络类,它可以拥有你任务所需的所有层和参数。我们使用 PyTorch 提供了一个更定义明确和显式的类,以便你能够以任何你想要的方式实现 MLP,无论是从头开始还是在一个流行的框架中。

列表 2.6 多层感知器 PyTorch 类实现
import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiLayerPerceptron(nn.Module):
    def __init__(
        self,
        input_size,
        hidden_size=2,
        output_size=3,
        num_hidden_layers=1,
        hidden_activation=nn.Sigmoid,
    ):
        """Initialize weights.
        Args:
            input_size (int): size of the input
            hidden_size (int): size of the hidden layers
            output_size (int): size of the output
            num_hidden_layers (int): number of hidden layers
            hidden_activation (torch.nn.*): the activation class
        """
        super(MultiLayerPerceptron, self).__init__()
        self.module_list = nn.ModuleList()
        interim_input_size = input_size
        interim_output_size = hidden_size
        torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

        for _ in range(num_hidden_layers):
            self.module_list.append(
                nn.Linear(interim_input_size, interim_output_size)
            )
            self.module_list.append(hidden_activation())
            interim_input_size = interim_output_size

        self.fc_final = nn.Linear(interim_input_size, output_size)

        self.last_forward_cache = []

    def forward(self, x, apply_softmax=False):
        """The forward pass of the MLP

        Args:
            x_in (torch.Tensor): an input data tensor.
            x_in.shape should be (batch, input_dim)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, output_dim)
        """
        for module in self.module_list:
            x = module(x)

        output = self.fc_final(x)

        if apply_softmax:
            output = F.softmax(output, dim=1)

        return output

从这段代码中,我们可以看出,与具有两个静态层的 CBoW 实现不同,这个 MLP 在实例化之前并不是静态的尺寸。如果你想给这个模型一百万层,你必须在实例化类时将num_hidden_layers=1000000。然而,仅仅因为给模型那么多参数并不意味着它会立即变得更好。LLMs 不仅仅是很多层。就像 RNNs 和 CNNs 一样,LLMs 的魔力在于数据如何进入模型并在其中移动。为了说明这一点,让我们看看 RNN 及其变体。

2.2.7 循环神经网络和长短期记忆网络

RNNs 是一类神经网络,旨在根据先前语言建模技术的弱点来分析序列。可以将序列视为一个有序数组,其中整个数组的总和在任何一个部分被移动时都会改变值。逻辑是这样的:如果语言以序列的形式呈现,那么也许它应该以序列的形式进行处理,而不是一次处理一个标记。RNNs 通过使用我们在 MLPs 和马尔可夫链中看到过的逻辑来实现这一点,即当处理新的输入时,会引用内部状态或记忆,并在检测到节点之间的连接有用时创建循环。

在完全循环网络中,如列表 2.7 中的那种,所有节点最初都连接到所有后续节点,但这些连接可以设置为零,以模拟它们在不适用时断开。这解决了早期模型所面临的最大问题之一,即静态输入大小,并使 RNN 及其变体能够处理可变长度的输入。不幸的是,较长的序列会带来新的问题。因为网络中的每个神经元都连接到后续的神经元,较长的序列会导致整体总和的变化更小,使得梯度更小,直到最终消失,即使对于重要的单词;这被称为梯度消失。还存在其他问题,如梯度爆炸和梯度减小。

例如,让我们考虑以下用于情感分析的句子:“我昨晚非常喜欢这部电影”和“我昨晚去看的电影是我曾经期望看到的最棒的一部。”这些句子在语义上可以被认为是相似的,即使它们并不完全相同。当通过 RNN 移动时,第一句中的每个单词都更有价值,结果是第一句的正面评分比第二句高,仅仅因为第一句更短。反之亦然:梯度爆炸是这种序列处理的结果,这使得训练深层 RNN 变得困难。

为了解决这个问题,LSTMs(一种 RNN),通过使用记忆单元和门控机制来处理可变长度的序列,但避免了理解长序列和短序列不同的难题。考虑到多语言场景,并理解人们不会只从单一方向思考语言,LSTMs 还可以通过连接两个 RNN 的输出(一个从左到右读取序列,另一个从右到左读取)来双向处理序列。这种双向性提高了结果,即使在成千上万的标记之后,信息也能被看到并记住。

在列表 2.7 中,我们给出了 RNN 和 LSTM 的类。在与此书相关的代码库(github.com/IMJONEZZ/LLMs-in-Production)中,你可以看到训练 RNN 和 LSTM 的结果。关键点是,LSTM 在训练和验证集上都能在更少的 epoch(25 个与 RNN 的 50 个相比)内达到更好的准确率。值得注意的一项创新是,打包嵌入利用填充将所有可变长度的序列扩展到最大长度。因此,只要输入长度小于最大长度,LSTMs 就可以处理任何长度的输入。为了有效地设置 LSTM,我们将在数据集(一个 Twitter 情感分析数据集)上做一些经典的 NLP 工作。该工作流程将使用自然语言工具包正则表达式进行标记化。它只查找单词,其他什么也不找,然后传递给 spacy 词形还原器,以获得只包含单词基本非屈折形式的列表。

列表 2.7 RNN 和 LSTM PyTorch 类实现。
import torch
import pandas as pd
import numpy as np
from gensim.models import Word2Vec
from sklearn.model_selection import train_test_split
import nltk
import spacy

    tokenizer = nltk.tokenize.RegexpTokenizer("\w+'?\w+|\w+'")
    tokenizer.tokenize("This is a test")
    stop_words = nltk.corpus.stopwords.words("english")
    nlp = spacy.load("en_core_web_lg", disable=["parser", "tagger", "ner"])

dataset = pd.read_csv("./data/twitter.csv")     #1

text_data = list(
    map(lambda x: tokenizer.tokenize(x.lower()), dataset["text"])
)
text_data = [
    [token.lemma_ for word in text for token in nlp(word)]
    for text in text_data
]
label_data = list(map(lambda x: x, dataset["feeling"]))
assert len(text_data) == len(
    label_data
), f"{len(text_data)} does not equal {len(label_data)}"

EMBEDDING_DIM = 100
model = Word2Vec(
    text_data, vector_size=EMBEDDING_DIM, window=5, min_count=1, workers=4
)
word_vectors = model.wv
print(f"Vocabulary Length: {len(model.wv)}")
del model

padding_value = len(word_vectors.index_to_key)
    embedding_weights = torch.Tensor(word_vectors.vectors)    #2

class RNN(torch.nn.Module):
    def __init__(
        self,
        input_dim,
        embedding_dim,
        hidden_dim,
        output_dim,
        embedding_weights,
    ):
        super().__init__()
        self.embedding = torch.nn.Embedding.from_pretrained(
            embedding_weights
    )
    self.rnn = torch.nn.RNN(embedding_dim, hidden_dim)
    self.fc = torch.nn.Linear(hidden_dim, output_dim)

    def forward(self, x, text_lengths):
        embedded = self.embedding(x)
        packed_embedded = torch.nn.utils.rnn.pack_padded_sequence(
            embedded, text_lengths
        )
        packed_output, hidden = self.rnn(packed_embedded)
        output, output_lengths = torch.nn.utils.rnn.pad_packed_sequence(
            packed_output
        )
        return self.fc(hidden.squeeze(0))

INPUT_DIM = padding_value
EMBEDDING_DIM = 100
HIDDEN_DIM = 256
OUTPUT_DIM = 1

rnn_model = RNN(
    INPUT_DIM, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM, embedding_weights
)

rnn_optimizer = torch.optim.SGD(rnn_model.parameters(), lr=1e-3)
rnn_criterion = torch.nn.BCEWithLogitsLoss()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class LSTM(torch.nn.Module):
    def __init__(
    self,
    input_dim,
    embedding_dim,
    hidden_dim,
    output_dim,
    n_layers,
    bidirectional,
        dropout,
        embedding_weights,
    ):
        super().__init__()
        self.embedding = torch.nn.Embedding.from_pretrained(
            embedding_weights
        )
        self.rnn = torch.nn.LSTM(
            embedding_dim,
            hidden_dim,
            num_layers=n_layers,
            bidirectional=bidirectional,
            dropout=dropout,
        )
        self.fc = torch.nn.Linear(hidden_dim * 2, output_dim)
        self.dropout = torch.nn.Dropout(dropout)

    def forward(self, x, text_lengths):
        embedded = self.embedding(x)
        packed_embedded = torch.nn.utils.rnn.pack_padded_sequence(
            embedded, text_lengths
        )
        packed_output, (hidden, cell) = self.rnn(packed_embedded)
        hidden = self.dropout(
            torch.cat((hidden[-2, :, :], hidden[-1, :, :]), dim=1)
        )
        return self.fc(hidden.squeeze(0))

INPUT_DIM = padding_value
EMBEDDING_DIM = 100
HIDDEN_DIM = 256
OUTPUT_DIM = 1
N_LAYERS = 2
BIDIRECTIONAL = True
DROPOUT = 0.5

lstm_model = LSTM(
    INPUT_DIM,
    EMBEDDING_DIM,
    HIDDEN_DIM,
    OUTPUT_DIM,
    N_LAYERS,
    BIDIRECTIONAL,
    DROPOUT,
    embedding_weights,
)

lstm_optimizer = torch.optim.Adam(lstm_model.parameters())
lstm_criterion = torch.nn.BCEWithLogitsLoss()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

def binary_accuracy(preds, y):
    rounded_preds = torch.round(torch.sigmoid(preds))
    correct = (rounded_preds == y).float()
    acc = correct.sum() / len(correct)
    return acc

def train(model, iterator, optimizer, criterion):
    epoch_loss = 0
    epoch_acc = 0
    model.train()
    for batch in iterator:
        optimizer.zero_grad()
        predictions = model(batch["text"], batch["length"]).squeeze(1)
        loss = criterion(predictions, batch["label"])
        acc = binary_accuracy(predictions, batch["label"])
        loss.backward()
        optimizer.step()
        epoch_loss += loss.item()
        epoch_acc += acc.item()

    return epoch_loss / len(iterator), epoch_acc / len(iterator)

def evaluate(model, iterator, criterion):
    epoch_loss = 0
    epoch_acc = 0
    model.eval()
    with torch.no_grad():
        for batch in iterator:
            predictions = model(batch["text"], batch["length"]).squeeze(1)
            loss = criterion(predictions, batch["label"])
            acc = binary_accuracy(predictions, batch["label"])

            epoch_loss += loss.item()
            epoch_acc += acc.item()

    return epoch_loss / len(iterator), epoch_acc / len(iterator)

batch_size = 2     #3

def iterator(X, y):
    size = len(X)
    permutation = np.random.permutation(size)
    iterate = []
    for i in range(0, size, batch_size):
    indices = permutation[i : i + batch_size]
        batch = {}
        batch["text"] = [X[i] for i in indices]
        batch["label"] = [y[i] for i in indices]

        batch["text"], batch["label"] = zip(
            *sorted(
                zip(batch["text"], batch["label"]),
                key=lambda x: len(x[0]),
                reverse=True,
            )
        )
        batch["length"] = [len(utt) for utt in batch["text"]]
        batch["length"] = torch.IntTensor(batch["length"])
        batch["text"] = torch.nn.utils.rnn.pad_sequence(
            batch["text"], batch_first=True
        ).t()
        batch["label"] = torch.Tensor(batch["label"])

        batch["label"] = batch["label"].to(device)
        batch["length"] = batch["length"].to(device)
        batch["text"] = batch["text"].to(device)

        iterate.append(batch)

    return iterate

index_utt = [
    torch.tensor([word_vectors.key_to_index.get(word, 0) for word in text])
    for text in text_data
]

X_train, X_test, y_train, y_test = train_test_split(
    index_utt, label_data, test_size=0.2
)                                                    #4
X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, test_size=0.2
)

train_iterator = iterator(X_train, y_train)
validate_iterator = iterator(X_val, y_val)
test_iterator = iterator(X_test, y_test)

print(len(train_iterator), len(validate_iterator), len(test_iterator))

N_EPOCHS = 25

for model in [rnn_model, lstm_model]:
    print(
    "|-----------------------------------------------------------------------------------------|"
    )
    print(f"Training with {model.__class__.__name__}")
    if "RNN" in model.__class__.__name__:
        for epoch in range(N_EPOCHS):
            train_loss, train_acc = train(
                rnn_model, train_iterator, rnn_optimizer, rnn_criterion
            )
            valid_loss, valid_acc = evaluate(
                rnn_model, validate_iterator, rnn_criterion
            )

            print(
                f"| Epoch: {epoch+1:02} | Train Loss: {train_loss: .3f} | 
                ↪ Train Acc: {train_acc*100: .2f}% | Validation Loss: 
                ↪ {valid_loss: .3f} | Validation Acc: {valid_acc*100: .2f}% |"
            )
    else:
        for epoch in range(N_EPOCHS):
            train_loss, train_acc = train(
                lstm_model, train_iterator, lstm_optimizer, lstm_criterion
            )
            valid_loss, valid_acc = evaluate(
                lstm_model, validate_iterator, lstm_criterion
            )

            print(
                f"| Epoch: {epoch+1:02} | Train Loss: {train_loss: .3f} | 
                ↪ Train Acc: {train_acc*100: .2f}% | Validation Loss: 
                ↪ {valid_loss: .3f} | Validation Acc: {valid_acc*100: .2f}% |"
            )
# Training on our dataset
# | Epoch: 01 | Train Loss:  0.560 | Train Acc:  70.63% | Validation Loss:
# 0.574 | Validation Acc:  70.88% |
# | Epoch: 05 | Train Loss:  0.391 | Train Acc:  82.81% | Validation Loss:
# 0.368 | Validation Acc:  83.08% |
# | Epoch: 10 | Train Loss:  0.270 | Train Acc:  89.11% | Validation Loss:
# 0.315 | Validation Acc:  86.22% |
# | Epoch: 15 | Train Loss:  0.186 | Train Acc:  92.95% | Validation Loss:
# 0.381 | Validation Acc:  87.49% |
# | Epoch: 20 | Train Loss:  0.121 | Train Acc:  95.93% | Validation Loss:
# 0.444 | Validation Acc:  86.29% |
# | Epoch: 25 | Train Loss:  0.100 | Train Acc:  96.28% | Validation Loss:
# 0.451 | Validation Acc:  86.83% |

1 创建我们的训练语料库并执行一些经典的 NLP 预处理。

2 需要嵌入来为 LSTM 的输入赋予语义价值。

3 通常应该是 2 的幂,因为这是计算机内存中最容易处理的形式。

4 你必须为你在训练的东西确定一些标签。

看看我们的类和实例化,你应该会看到 LSTM 与 RNN 并没有太大区别。唯一的区别是init输入变量是n_layers(为了方便,你也可以用 RNN 指定它),bidirectionaldropoutbidirectional允许 LSTM 在序列中向前看,以帮助理解意义和上下文。它还在多语言场景中帮助很大,因为像英语这样的从左到右的语言并不是正文的唯一格式。dropout是另一个巨大的创新,它改变了过拟合的模式,从数据依赖转变为帮助模型不过拟合,通过在训练过程中逐层关闭随机节点来强制所有节点不相互关联,并防止复杂的共适应。模型外的参数唯一的不同之处在于,用于 RNN 的优化器是随机梯度下降(SGD),就像我们的 CBoW 一样;LSTM 使用 Adam(尽管两者都可以使用任何,取决于性能,包括 AdamW)。接下来,我们定义我们的训练循环并训练 LSTM。将这个训练循环与gradient_descent函数中定义的列表 2.4 进行比较。

在这里展示的代码中,令人惊叹的一点是,与之前的模型迭代相比,LSTM 可以学得更快,这得益于bidirectionalitydropout。尽管之前的模型训练速度比 LSTM 快,但它们需要数百个 epoch 才能达到 LSTM 在 25 个 epoch 内达到的性能。正如其名所示,验证集上的性能增加了架构的有效性,在它未训练的示例上进行推理,并保持准确度与训练集相当。

这些模型的问题并不那么明显,主要表现为资源消耗极其巨大,尤其是在应用于像医疗和法律这样更长、更注重细节的问题时。尽管dropoutbidirectional处理具有令人难以置信的优势,但它们至少将训练所需的处理能力翻倍。因此,虽然推理最终只比相同大小的 MLP 贵 2 到 3 倍,但训练成本却高达 10 到 12 倍。也就是说,dropoutbidirectional很好地解决了梯度爆炸问题,但同时也增加了训练所需的计算量。为了解决这个问题,设计并实施了一种捷径,允许任何模型,包括 LSTM,找出序列中哪些部分是最有影响力的,哪些部分可以安全忽略,这被称为注意力

2.2.8 注意力

注意力是一种数学捷径,它通过一个突现的数学公式告诉模型考虑输入的哪些部分以及多少,从而为模型提供一个机制,以更快的速度解决更大的上下文窗口。注意力基于字典的升级版本,其中不仅包含键值对,还添加了上下文查询。简单来说,以下代码是较老的自然语言处理技术与更现代技术之间的主要区别。

注意力解决了训练 LSTMs 的缓慢问题,同时保持了在少量 epoch 上的高性能。还有多种类型的注意力。点积注意力方法捕捉了查询中的每个词(或嵌入)与键中的每个词之间的关系。当查询和键是同一句子的部分时,这被称为双向自注意力。然而,在某些情况下,只关注当前词之前的内容可能更合适。这种类型的注意力,尤其是当查询和键来自同一句子时,被称为因果注意力。通过掩码序列的部分并迫使模型猜测掩码后面的内容,语言模型进一步得到改进。以下列表中的函数展示了点积注意力和掩码注意力。

列表 2.8 多头注意力实现
import numpy as np
from scipy.special import softmax

x = np.array([[1.0, 0.0, 1.0, 0.0],
            [0.0, 2.0, 0.0, 2.0],
            [1.0, 1.0, 1.0, 1.0]])     #1

w_query = np.array([1,0,1],
             [1,0,0],
             [0,0,1],
             [0,1,1]])          #2
w_key = np.array([[0,0,1],
             [1,1,0],
             [0,1,0],
             [1,1,0]])         #2
w_value = np.array([[0,2,0], 
             [0,3,0],
             [1,0,3],
             [1,1,0]])         #2

Q = np.matmul(x,w_query)     #3
K = np.matmul(x,w_key)       #3
V = np.matmul(x,w_value)     #3

k_d = 1                                        #4
attention_scores = (Q @ K.transpose())/k_d     #4

attention_scores[0] = softmax(attention_scores[0])     #5
attention_scores[1] = softmax(attention_scores[1])     #5
attention_scores[2] = softmax(attention_scores[2])     #5

attention1 = attention_scores[0].reshape(-1,1)     #6
attention1 = attention_scores[0][0]*V[0]           #6
attention2 = attention_scores[0][1]*V[1]           #6
attention3 = attention_scores[0][2]*V[2]           #6

attention_input1 = attention1 + attention2 + attention3    #7

attention_head1 = np.random.random((3,64))      #8

z0h1 = np.random.random((3,64))     #9
z1h2 = np.random.random((3,64))     #9
z2h3 = np.random.random((3,64))     #9
z3h4 = np.random.random((3,64))     #9
z4h5 = np.random.random((3,64))     #9
z5h6 = np.random.random((3,64))     #9
z6h7 = np.random.random((3,64))     #9
z7h8 = np.random.random((3,64))     #9

Output_attention = np.hstack((z0h1,z1h2,z2h3,z3h4,z4h5,z5h6,z6h7,z7h8))    #10

def dot_product_attention(query, key, value, mask, scale=True):    #11
    assert query.shape[-1] == key.shape[-1] == value.shape[-1], "q,k,v have different dimensions!"
    if scale:
        depth = query.shape[-1]
    else:
        depth = 1
    dots = np.matmul(query, np.swapaxes(key, -1, -2)) / np.sqrt(depth)
    if mask is not None:
        dots = np.where(mask, dots, np.full_like(dots, -1e9))
    logsumexp = scipy.special.logsumexp(dots, axis=-1, keepdims=True)
    dots = np.exp(dots - logsumexp)
    attention = np.matmul(dots, value)
    return attention
def masked_dot_product_self_attention(q,k,v,scale=True):     #12
    mask_size = q.shape[-2]
    mask = np.tril(np.ones((1, mask_size, mask_size), dtype=np.bool_), k=0)
    return DotProductAttention(q,k,v,mask,scale=scale)

1 第 1 步:输入:三个输入,d_model=4

2 第 2 步:权重三个维度 x d_model=4

3 第 3 步:矩阵乘法以获得 Q,K,V;查询:x * w_query;键:x * w_key;值:x * w_value

4 第 4 步:缩放后的注意力分数;维度的平方根

5 第 5 步:每个向量的缩放 softmax 注意力分数

6 第 6 步:通过 score1/k_d * V 获得的注意力值

7 第 7 步:将结果相加以创建输出矩阵的第一行

8 第 8 步:对输入 1 到 3 执行步骤 1 到 7;因为这只是个演示,我们将使用正确维度的随机矩阵。

9 第 9 步:我们使用步骤 1 到 7 训练注意力子层的所有八个头。

10 第 10 步:将头 1 到 8 连接起来,以获得模型原始的 8 × 64 输出维度。

11 此函数执行所有这些步骤。

12 此函数执行前面的步骤,但增加了掩码中的因果关系。

在注意力的完整实现中,你可能注意到了一些你熟悉的术语——即,但你可能之前没有接触过查询对之所以熟悉,是因为字典和查找表,其中我们将一组键映射到一个值数组。查询应该感觉直观,就像是一种检索的搜索。查询与从正常操作中检索进行比较。

在注意力机制中,QueryKey通过点积相似度比较来获得注意力分数,随后这个分数会被乘以Value以得到模型应该关注序列该部分的最终分数。这可能会根据你的模型架构变得更加复杂,因为必须考虑到编码器和解码器的序列长度,但在此我们可以简单地说,在这个空间中建模的最有效方法是投影所有输入源到一个公共空间,并使用点积进行高效比较。

这段代码解释比之前的例子数学性更强,但这是为了说明概念。注意力的数学原理真正具有创新性,并推动了该领域的发展。不幸的是,即使注意力为序列建模过程带来了优势,在 LSTMs 和 RNNs 中,仍然存在速度和内存大小的问题。你可能从代码和数学中注意到取了平方根,这意味着我们使用的注意力是二次的。包括像 Hyena 和循环记忆转换器(RMT,基本上是一个结合了转换器的 RNN)这样的亚二次技术在内的各种技术已经被开发出来以解决这些问题,我们将在稍后更详细地介绍。现在,让我们继续探讨注意力的最终应用:转换器。

2.3 注意力即一切

在开创性的论文“Attention Is All You Need”中¹,Vaswani 等人将数学捷径推进了几步,提出为了性能,根本不需要任何循环(RNN 中的“R”)或任何卷积。

备注:我们不讨论卷积,因为它们对 NLP 来说并不好,但在计算机视觉中却很受欢迎。

相反,Vaswani 等人选择只使用注意力,并更加仔细地指定 Q、K 和 V 的来源。我们将在稍后深入探讨这一点。在我们对这一系列多样化的 NLP 技术的回顾中,我们观察到了它们随时间的发展以及每种方法如何寻求改进其前辈。从基于规则的方法到统计模型和神经网络,该领域一直在努力寻找更高效、更准确处理和理解自然语言的方法。

现在,我们将注意力转向一项具有革命性的创新,它彻底改变了 NLP 领域:转换器架构。在下一节中,我们将探讨支撑转换器的关键概念和机制,以及它们如何使得开发超越先前技术的最先进语言模型成为可能。我们还将讨论转换器对更广泛的 NLP 领域的影响,并考虑在这一激动人心的研究领域中进一步进步的潜力。

2.3.1 编码器

编码器是完整变压器模型的前半部分,在分类和特征工程领域表现出色。Vaswani 等人发现,在编码器内部的嵌入层之后,对张量进行的任何额外转换都可能损害它们进行“语义”比较的能力,而这正是嵌入层的目的。这些模型严重依赖于自注意力和巧妙的位置编码来操纵这些向量,而不会显著降低所表达的相似度。

再次强调,嵌入的一个关键特性是它们是数据的向量表示——在我们的案例中,是标记。标记是你选择的代表语言的任何东西。我们建议按照一般规则使用子词,但你将逐渐了解哪些位置和哪些类型的标记效果良好。考虑以下句子:“The cat in the hat rapidly leapt above the red fox and the brown unmotivated dog。” “Red” 和 “brown” 在语义上是相似的,并且在嵌入层之后有类似的表示。然而,它们在句子中的位置分别是第 10 和第 14 位,假设我们是按单词进行分词。因此,位置编码在它们之间引入了距离,同时也增加了区分句子中不同位置相同标记的能力。然而,一旦应用正弦和余弦函数,它们的意义就只比编码后稍微远一点,并且这种编码机制在递归和更多数据的情况下表现出色。为了说明这一点,让我们假设在嵌入后 [red] 和 [brown] 之间有 99% 的余弦相似度。编码将大大将其降低到大约 85% 到 86% 的相似度。按照描述应用正弦和余弦方法将它们的相似度恢复到大约 96%。

BERT 是在 Vaswani 等人原始论文之后出现的首批架构之一,并且是仅使用编码器的变压器的一个例子。鉴于其体积之小,BERT 是一个极其强大的模型架构,至今仍在生产系统中使用。BERT 是第一个仅使用编码器的变压器,其受欢迎程度激增,展示了使用变压器进行连续或顺序(它们是相同的)建模,其嵌入效果比 Word2Vec 更好。我们可以看到,这些嵌入效果更好,因为它们可以非常快速地应用于新任务和数据,并且只需要最少的训练,就能得到比 Word2Vec 嵌入更符合人类偏好的结果。在一段时间内,大多数人使用基于 BERT 的模型在较小的数据集上进行少样本学习任务。BERT 使大多数研究人员和企业能够以最小的努力获得最先进的性能。

figure

图 2.5 一个编码器的可视化。编码器是完整传输器架构的前半部分,在自然语言理解任务(如分类或命名实体识别)中表现出色。编码器模型通过不需要先验或递归,并使用巧妙的位置编码和多头注意力来创建每个标记的向量嵌入,从而改进了先前设计。

编码器的优势(如图 2.5 所示)包括以下内容:

  • 展示理解的分类和层次任务

  • 考虑到长距离依赖建模,速度极快

  • 建立在已知模型之上,如嵌入中的 CBoW,前馈中的 MLP 等。

  • 并行

编码器的弱点包括以下内容:

  • 如建议所示,需要大量数据(尽管少于 RNNs)才能有效

  • 更加复杂的架构

2.3.2 解码器

解码器模型,如图 2.6 所示,是编码器的大版本,其基本形式包含两个多头注意力块和三个求和归一化层。它们是编码器之后的传输器的一半。解码器在掩码语言建模和学习以及快速应用语法方面非常出色,几乎立即产生了只需要解码器模型才能实现人工通用智能的想法。编码器与解码器任务的一个有用简化是,编码器在自然语言理解(NLU)任务中表现出色,而解码器在自然语言生成(NLG)任务中表现出色。解码器仅传输器架构的例子是生成预训练传输器(GPT)模型系列。这些模型遵循转换生成语法的逻辑,完全基于语法,允许无限生成一种语言中所有可能的句子(见附录 A)。

figure

图 2.6 一个解码器的可视化。解码器是完整传输器的后半部分,在 NLG 任务(如聊天机器人和讲故事)中表现出色。解码器与编码器一样改进了先前架构,但它们将输出向右移动一个空格以帮助下一词生成,从而利用多头自注意力的优势。

解码器的优势包括以下内容:

  • 在序列中生成下一个标记(向右移动表示考虑已生成的标记)

  • 建立在已知模型和编码器之上

  • 生成过程中可以流式传输,提供良好的用户体验

它们的弱点包括以下内容:

  • 仅语法模型往往难以插入预期的或意图的含义(参见所有“我强迫一个 AI 观看 1000 小时的 x 并生成”的 2018-至今的 meme)。

  • 幻觉。

2.3.3 传输器

完整的 Transformer 架构同时利用了编码器和解码器,在输出之前,将编码器的理解传递给解码器的第二个多头注意力块。由于 Transformer 的每一部分在理解或生成方面都有其专长,因此整个产品在需要生成之前有一定理解条件的条件生成任务,如翻译或摘要,上表现得最好,这一点应该是直观的。编码器侧重于在高级别处理输入,而解码器则更专注于生成连贯的输出。完整的 Transformer 架构能够成功理解数据,然后根据这种理解生成输出,如图 2.7 所示。文本到文本迁移 Transformer(T5)模型系列是 Transformer 的一个例子。

figure

图 2.7 一个完整的 Transformer 可视化。完整的 Transformer 结合了编码器和解码器,在所有任务上都做得很好,包括摘要和翻译等条件生成任务。由于 Transformer 比其每个部分都要庞大和缓慢,研究人员和企业通常选择使用这些部分而不是整个 Transformer。

备注:Transformer 模型的优势在于它们围绕输入的并行化构建,这增加了 LSTM 目前无法复制的速度。如果 LSTM 达到可以像 Transformer 一样快速运行的程度,它们可能在最先进的领域中具有竞争力。

Transformer 的优势如下:

  • 包含编码器和解码器,因此它在它们擅长的所有事情上都做得很好

  • 高度并行化以提高速度和效率

弱点包括以下:

  • 记忆密集型,但仍然小于相同大小的 LSTM

  • 训练需要大量的数据和 VRAM

如您可能已经注意到的,我们讨论的大多数模型都不是语言导向的,它们高度关注语法,即使它们试图模拟真实语言。模型,即使是最先进的 Transformer,也只有语义近似——没有语用学,没有语音学——并且在标记化过程中,只有在没有上下文的情况下才真正利用形态学的数学模型。这并不意味着模型不能学习这些,也不意味着例如,Transformer 不能将音频作为输入;这仅仅意味着平均使用情况不是这样。考虑到这一点,它们能像现在这样工作,简直是个奇迹,它们真正应该得到赞赏。

到目前为止,我们试图强调模型当前的局限性,并在本书的剩余部分深入探讨如何改进它们。其中一条途径已经取得并且仍在取得巨大成功的探索:迁移学习和微调大型基础模型。这种技术是在 BERT 最初发布后不久出现的。研究人员发现,尽管 BERT 在大量任务上表现良好,但如果他们希望它在特定任务或数据领域上表现更好,他们只需在代表该任务或领域的但不是从头开始的数据上重新训练模型即可。鉴于 BERT 在创建语义近似嵌入时在更大的数据集上学习到的所有预训练权重,所需的训练数据量显著减少,就可以在所需的部分上获得最先进的性能。我们已经在 BERT 和 GPT 系列模型中看到了这一点,现在我们再次看到这一点来解决我们讨论的 exactly the challenges:语义近似覆盖、领域专业知识和数据可用性。

2.4 非常大的转换器

进入大型语言模型(LLMs)。自从它们被引入以来,基于转换器的模型一直在不断增大,不仅是在规模和参数数量上,还包括它们的训练数据集和训练周期的大小和长度。如果你在 2010 年代学习机器学习或深度学习,你很可能听说过这样的说法:“增加更多层并不会使模型变得更好。”LLMs 既证明了这一点是错误的,也证明了这一点是正确的——错误是因为它们的性能无与伦比,通常与经过精心微调的较小模型相匹配,这些模型在特定领域和数据集上进行了训练,甚至是在专有数据上训练的,正确是因为训练和部署 LLMs 所带来的挑战。

LLMs 与语言模型之间的一大区别在于迁移学习和微调。与之前的语言模型一样,LLMs 在庞大的文本语料库上进行预训练,使它们能够学习通用的语言特征和表示,这些特征和表示可以针对特定任务进行微调。由于 LLMs 规模庞大,其训练数据集也很大,因此它们能够在更少的标记数据的情况下实现更好的性能,这是早期语言模型的一个重大限制。通常,你只需用十几个示例就可以微调一个 LLM 来执行高度专业化的任务。

然而,使大型语言模型(LLMs)如此强大并为其在商业领域的广泛应用打开了大门的是它们能够通过简单的提示执行专门任务,而无需任何微调的能力。只需在查询中给出你想要的一些示例,LLM 就能生成结果。在较小的标记数据集上训练 LLM 被称为少样本提示。当只给出一个示例时,被称为单样本提示,而当任务是全新的时,则称为零样本提示。LLMs,尤其是那些通过人类反馈和提示工程方法进行强化学习训练的 LLMs,能够进行少样本学习,这意味着它们能够通过仅几个示例进行泛化和解决任务,达到全新的水平。这种能力是相对于早期模型的一个重大进步,早期模型需要针对每个特定任务进行大量的微调或大量的标记数据。

之前,语言模型在少样本和零样本学习领域已经显示出潜力,而 LLMs 已经证明这种潜力是真实的。随着模型规模的增大,我们发现它们能够完成小模型无法完成的任务。我们称之为涌现行为。² 图 2.8 展示了八个先前语言模型在随机情况下表现不佳的任务,然后一旦模型足够大,它们就能完成这些任务。

图

图 2.8 LLMs 在模型规模达到一定大小后,执行少样本提示任务时展现出的涌现行为示例

由于 LLMs 具有庞大的参数规模,它们在零样本能力方面也表现出显著的优势,这是它们在商业世界中受欢迎和可行的最主要原因。LLMs 也由于规模大和容量大,在处理歧义方面表现出改进。它们在区分具有多个含义的单词和理解语言细微差别方面做得更好,从而产生更准确的预测和响应。这种改进并不是因为它们的能力或架构更好,因为它们与较小的 Transformer 共享相同的架构,而是因为它们拥有大量关于人们通常如何消除歧义的事例。因此,LLMs 以与数据集中通常表示的相同方式消除歧义。多亏了 LLMs 训练所依据的文本数据的多样性,它们在处理各种输入风格、噪声文本和语法错误方面表现出更高的鲁棒性。

LLMs(大型语言模型)与语言模型之间的另一个关键区别是输入空间。更大的输入空间非常重要,因为它使得少样本提示任务变得更加可行。许多 LLMs 的最大输入大小为 8,000+个 token(最初为 32K,GPT-4 自 2023 年 11 月以来已达到 128K),尽管所有之前讨论的模型也可以有如此高的输入空间,但它们通常并不具备。我们最近在这个领域看到了爆炸式增长,例如循环记忆转换器(RMT)技术允许 1M+个 token 的上下文空间,这进一步推动了 LLMs 向证明更大的模型总是更好的方向迈进。LLMs 被设计用来捕捉文本中的长距离依赖关系,这使得它们比前辈们更有效地理解上下文。这种改进的理解能力使得 LLMs 在机器翻译、摘要和对话式 AI 等任务中能够生成更连贯和上下文相关的响应。

LLMs 通过提供早期语言模型难以解决的问题的强大解决方案,已经彻底改变了 NLP(自然语言处理)。它们在上下文理解、迁移学习和少样本学习方面带来了显著的改进。随着 NLP 领域的持续发展,研究人员正在积极努力最大化 LLMs 的益处,同时减轻所有潜在的风险。因为还没有找到更好的近似语义的方法,所以他们做出了更大和更多维度的近似。因为还没有找到存储语用上下文的好方法,LLMs 通常允许直接将上下文插入到提示中,或者插入到为上下文保留的输入部分,甚至通过在推理时与 LLM 共享数据库。这种能力不会在模型内创建语用或语用系统,就像嵌入不会创建语义一样,但它允许模型正确生成模仿人类对那些语用和语义刺激做出反应的语法。语音学是 LLMs 可能取得巨大进步的地方,无论是作为完全无文本的模型,还是作为文本-语音混合模型,也许会利用国际音标(IPA)作为文本的补充或替代。现在我们正在观察这个领域的可能发展,这令人兴奋。

到目前为止,你应该已经对 LLMs(大型语言模型)有了相当好的理解,以及一些在将 LLMs 投入生产时将非常有用的语言学关键原则。现在,你应该能够开始推理哪些产品更容易或更难构建。考虑图 2.9:左下角的任务,如写作助手和聊天机器人,是 LLMs 的拿手好戏。基于提示中少量上下文的文本生成是一个严格基于语法的难题;只要模型足够大,训练数据足够多,我们就可以相对容易地做到这一点。购物助手与此类似,并且也相对容易构建;我们只是缺少了语用学。助手需要了解更多关于世界的信息,比如产品、商店和价格。通过一点工程,我们可以将这些信息添加到数据库中,并通过提示将这些上下文提供给模型。

figure

图 2.9:某些任务对 LLMs 的难易程度以及解决这些任务的方法

在另一端,考虑一下棋类机器人。LLMs可以下棋,但它们并不擅长。它们在棋局上接受过训练,并理解 E4 是一个常见的第一步,但它们的理解完全是基于语法的。LLMs 只理解它们生成的文本应该包含 A 到 H 之间的字母和 1 到 8 之间的数字。就像购物助手一样,它们缺少语用学,并且没有清晰的棋局模型。此外,它们还缺少语义学。编码器可能有助于我们理解“国王”和“王后”这两个词是相似的,但它们不能帮助我们理解 E4 对于某个玩家来说是一步好棋,而对于另一个玩家来说却是非常糟糕的一步。LLMs 在棋类上还缺乏基于语音学和形态学的知识,尽管在这个案例中它们并不那么重要。无论如何,我们希望这个练习能更好地让你和你的团队了解你们下一个项目。

LLMs 具有惊人的好处,但所有这些能力也带来了一些限制。基础 LLMs 需要大量的计算资源进行训练,这使得它们对个人研究人员和小型组织来说不太容易获得。这个问题正在通过本书中将要讨论的技术得到解决,比如量化、文本嵌入、低秩自适应、参数高效微调和图优化。然而,基础模型目前仍然超出了普通个人有效训练的能力范围。除此之外,还有关于与训练 LLMs 相关的能源消耗可能对环境产生重大影响并引起可持续性问题担忧。这些问题很复杂,很大程度上超出了本书的范围,但我们不能不提及它们。

最后但同样重要的是,由于 LLM 是在包含真实世界文本的大规模数据集上训练的,它们可能会学习和延续数据中存在的偏见,这引发了道德上的担忧,因为现实世界的人不会自我审查以提供最佳的无偏见数据。此外,了解你正在训练的数据的很多信息并不是一种普遍的做法。例如,如果你要求一个文本到图像的扩散 LLM 生成 1000 张“领导者”的图像,99%的图像都是男性,95%的图像都是白人。这里的担忧并不是说男性和白人不应该被描绘为领导者,而是模型没有准确地代表世界,并且正在展示这一点。

有时,更细微的偏见会被揭示出来。例如,在图 2.10 中的 Midjourney 示例中,模型在没有被提示的情况下(唯一的提示是单词“领导者”),将流行的女权主义图标罗斯·拉弗特变成了男性。模型并没有考虑这种变化;它只是在采样步骤中确定,提示“领导者”在训练集中有更多看起来像男性的描绘。许多人会争论在这个背景下“好”和“坏”的含义,而不会追求道德上的义务,我们将讨论准确性的含义。LLM 是在大量数据上训练的,目的是返回尽可能准确的表现。当它们无法返回准确的表现时,特别是当它们具有高度的能力来消除歧义时,我们可以将其视为损害模型实现其目的的偏见。稍后,我们将讨论对抗有害偏见的技术,以便您作为 LLM 的创建者,能够得到您期望的确切输出,并最大限度地减少不期望的输出数量。

图

图 2.10 中的 Midjourney 5,在撰写本文时,是市场上最受欢迎的文本到图像模型。当只提示一个标记“领导者”(左)时,将一个著名的流行女权主义图标,罗斯·拉弗特,变成了男性的描绘。ChatGPT(右)编写了一个函数,根据种族、性别和年龄为你安排工作。这些都是意外输出的例子。

好的,我们整个章节都在为这一刻做准备。现在我们就来运行我们的第一个大型语言模型(LLM)吧!在列表 2.9 中,我们下载了 Bloom 模型,这是最早创建的开源 LLM 之一,并生成文本!我们使用的是 Hugging Face 的 Transformers 库,它为我们处理了所有繁重的工作。这真是太激动人心了!

列表 2.9 运行我们的第一个 LLM
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_NAME = "bigscience/bloom"    

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME)

prompt = "Hello world! This is my first time running an LLM!"

input_tokens = tokenizer.encode(prompt, return_tensors="pt", padding=True)
generated_tokens = model.generate(input_tokens, max_new_tokens=20)
generated_text = tokenizer.batch_decode(
    generated_tokens, skip_special_tokens=True
)
print(generated_text)

你尝试运行它了吗?!?如果你尝试了,你可能刚刚让你的笔记本电脑崩溃了。哎呀!请原谅我这个小无害的 MLOps 折磨,但了解这些模型有多大以及它们有多难运行是一段有用的经验。在下一章中,我们将更多地讨论运行 LLMs 的困难以及你需要运行此代码的一些工具。如果你不想等待,并希望运行一个类似但规模小得多的 LLM,将模型名称更改为"bigscience/bloom-3b",然后再次运行。这次在大多数硬件上应该可以正常运行。

总的来说,LLMs 是一项惊人的技术,它让我们的想象力在可能性中自由驰骋,这是理所当然的。考虑 LLM 而不是较小的语言模型的首要用例是当模型将帮助的人需要少样本能力时,例如,当 CEO 在筹集资金或软件工程师在编写代码时。LLMs 具有这些能力正是因为它们的规模。LLMs 中参数数量的增加直接使它们能够在更大维度上的较小空间中进行泛化。在本章中,我们触及了 LLMs 不太为人所知的方面,即语言学和语言建模方面。在下一章中,我们将涵盖另一半,即 MLOps 方面,我们将深入了解大量参数大小如何影响模型以及设计来支持该模型和使其对目标客户或员工可访问的系统。

摘要

  • 语言学五大组成部分是语音学、句法、语义、语用学和形态学:

    • 语音学可以通过处理音频文件的多模态模型来添加,这可能会在未来提高大型语言模型(LLMs),但当前的语料库数据集太小。

    • 句法是当前模型擅长的。

    • 语义是通过嵌入层添加的。

    • 语用学可以通过工程努力来添加。

    • 形态学是在标记化层中添加的。

  • 语言不一定与现实相关。了解人们如何在外部现实之外创造意义的过程对于训练对人们有意义的模型是有用的。

  • 正确的标记化可能是一个主要障碍,因为存在过多的 <UNK> 标记,尤其是在处理像代码或数学这样的专业问题时。

  • 多语言处理始终优于单语言处理,甚至在没有模型的单语言任务中也如此。

  • 按顺序展示的语言模型类型显示了 LLM 领域的自然和有机增长,因为越来越多的语言学概念被添加,使模型变得更好。

  • 语言建模在效率上经历了指数级增长,这与建模的语言学重点相关。

  • 注意力是解决更大上下文窗口的数学捷径,是现代架构(编码器、解码器和转换器)的支柱:

    • 编码器提高了嵌入中的语义近似。

    • 解码器在文本生成方面表现最佳。

    • 转换器结合了这两者。

  • 更大的模型表现出出现行为,突然能够完成之前无法完成的任务。

[1] Vaswani 等人,2017 年,《Attention Is All You Need》,arxiv.org/abs/1706.03762

[2] J. Wei 等人,“大型语言模型的出现能力”,机器学习研究交易,2022 年 8 月,openreview.net/forum?id=yzkSU5zdwD

第三章:大型语言模型操作:为 LLMs 构建平台

本章涵盖

  • 大型语言模型操作的概述

  • 部署挑战

  • 大型语言模型最佳实践

  • 必需的大型语言模型基础设施

在所有事情之前,准备是成功的关键。——亚历山大·格拉汉姆·贝尔

正如我们在上一章所学,当涉及到转换器和自然语言处理(NLP)时,越大越好,尤其是在语言信息方面。然而,由于模型的大小,更大的模型带来了更大的挑战,无论它们的语言效率如何,因此我们需要扩展我们的操作和基础设施来处理这些问题。在本章中,我们将探讨这些挑战究竟是什么,我们可以做些什么来最小化它们,以及可以建立什么样的架构来帮助解决这些问题。

3.1 大型语言模型操作简介

什么是大型语言模型操作(LLMOps)?嗯,既然我们更喜欢关注实用性而非辞藻,我们就不会深入探讨那些你在教科书中可能会期待的任何花哨的定义,但让我们简单地说,它是指扩展到处理大型语言模型(LLMs)的机器学习操作(MLOps)。我们还可以说,扩展是困难的。软件工程中最困难的任务之一。不幸的是,太多公司正在运行基本的 MLOps 设置,并且他们绝对无法处理 LLMs 的想法。话虽如此,LLMOps这个术语可能并不需要。它还没有显示出与核心 MLOps 有足够的区别,尤其是考虑到它们仍然有相同的结构。如果这本书是一个二分法的关键,MLOps 和 LLMOps 肯定属于同一属,而只有时间才能告诉我们它们是否是同一物种。当然,通过拒绝正确地定义 LLMOps,我们可能已经将一种困惑换成了另一种困惑,所以让我们花一分钟来描述一下 MLOps。

MLOps 是可靠且高效地将机器学习模型部署和维护在生产中的领域和实践。这包括——实际上,这是必需的——管理整个机器学习生命周期,从数据获取和模型训练到监控和终止。掌握这个领域所需的一些原则包括工作流程编排、版本控制、反馈循环、持续集成和持续部署(CI/CD)、安全性、资源分配和数据治理。虽然通常有专门从事模型生产化的个人,他们的头衔可能是 ML 工程师、MLOps 工程师或 ML 基础设施工程师,但这个领域足够庞大,它经常绑架许多其他意想不到的专业人士来工作,他们的头衔可能是数据科学家或 DevOps 工程师——通常是在他们不知情或不愿意的情况下,让他们大喊大叫,“这不是我的工作。”

3.2 大型语言模型的操作挑战

那为什么还要有区别呢?如果 MLOps 和 LLMOps 如此相似,LLMOps 难道只是那些机会主义者简历上的另一个流行趋势吗?并非如此。事实上,它与“大数据”这个术语非常相似。当这个术语达到顶峰时,那些被称为大数据工程师的人使用完全不同的工具集,并开发了处理大数据所需的专门专业知识。LLM 带来了一系列你在传统机器学习系统中找不到的挑战和问题。其中大多数问题几乎完全是因为它们如此之大。大型模型就是大!我们希望向你展示 LLM 确实配得上这个名字。让我们看看其中的一些挑战,这样当我们开始讨论部署 LLM 时,我们可以欣赏到我们面前的任务。

3.2.1 长下载时间

回到 2017 年,当我仍然作为一个数据科学家深度参与时,我决定尝试重新实现当时最著名的计算机视觉模型之一:AlexNet、VGG19 和 ResNet。我想这将是通过一些实际动手经验来加强我对基础知识理解的好方法。此外,我还有一个额外的动机:我刚刚用一些当时最先进的 NVIDIA GeForce 1080 TI GPU 构建了自己的电脑,我认为这将是一个很好的方式来磨合它们。第一个任务是下载 ImageNet 数据集。

ImageNet 数据集是当时可用的最大标注数据集之一,包含数百万张图片,总文件大小高达惊人的~150 GB!与之工作证明了你知道如何处理大数据,这在当时还是一个热门词汇,也是数据科学家不可或缺的技能集。在同意条款并获得访问权限后,我收到了第一个警钟。下载它花了一整周时间。

当我的团队首次部署 Bloom 时,下载它花了一个半小时。天哪,下载《塞尔达传说:王国之泪》也花了一个半小时,而那只是一个 16 GB 的文件,所以我们真的不能抱怨。

大型模型就是大。这一点不容忽视。你会在整本书中发现,这一事实给整个生产过程带来了许多额外的头痛和问题,你必须为此做好准备。与 ImageNet 数据集相比,Bloom LLM 模型为 330 GB,是后者的两倍多。我们猜测大多数读者都没有使用过 ImageNet 或 Bloom,所以为了比较,《使命召唤:现代战争》,在撰写本文时是最大的游戏之一,大小为 235 GB。《最终幻想 15》 只有 148 GB,所以你可以将两个模型放入其中,还有足够的空间。真正理解 LLM 的巨大规模是很困难的。我们从 BERT 等模型中的 1 亿个参数开始,将它们提升到数十亿个参数。如果你进行了一场购物狂欢,每秒花费 20 美元(或者可能不小心让你的 AWS EC2 实例一直运行),你需要半天时间才能花掉一百万美元;花掉十亿美元则需要两年。

幸运的是,下载 Bloom 并不需要两周时间,因为与 ImageNet 不同,它不是托管在一个管理不善的大学服务器上,而且它已经被分割成多个较小的文件,以便并行下载,但这仍然会花费一个不舒服的长时间。考虑一个场景,你正在最佳条件下下载模型。你拥有千兆速度的光纤互联网连接,并且神奇地能够将你系统和服务器上的全部带宽和 I/O 操作都分配给它。即使这样,下载仍然需要超过 5 分钟!当然,这是在最佳条件下。你很可能不会在如此情况下下载模型;在现代基础设施下,你预计它需要数小时。

3.2.2 更长的部署时间

仅下载模型就足够让任何经验丰富的开发者感到不安,但部署时间将使他们倒下并寻求医疗援助。像 Bloom 这样大的模型仅将模型加载到 GPU 内存中就需要 30 到 45 分钟——至少,这是我们经历的时间框架。这还不包括你部署过程中可能增加的其他步骤。实际上,由于 GPU 缺货,仅等待资源释放就可能会花费数小时——关于这一点,我们稍后再谈。

这对你和你的团队意味着什么?首先,我们知道许多团队在部署机器学习产品时通常会在运行时下载模型。这可能适用于小的 sklearn 回归模型,但对于 LLMs 来说则不行。此外,你可以把关于部署可靠系统的大部分知识都扔到窗外(但幸运的是,不是太远)。大多数现代软件工程的最佳实践都假设你可以轻松地重启应用程序,并且有很多繁琐的程序来确保你的系统可以做到这一点。对于 LLMs 来说,关闭可能只需要几秒钟,但重新部署可能需要几个小时,这使得这是一个半不可逆的过程。就像从树上摘苹果一样,摘下来很容易,但如果咬一口觉得太酸,就无法再把它重新接到树上继续成熟。你只能等待另一个成熟。

虽然并非每个项目都需要部署最大的模型,但你可以预期部署时间以分钟计算。这些较长的部署时间使得在流量激增前缩减规模成为严重的错误,同时也使得管理突发性工作负载变得困难。由于滚动更新需要更长的时间,因此在你的管道中会迅速积累大量积压。像拼写错误或其他错误这样的愚蠢错误往往需要更长的时间才能被发现和纠正。

3.2.3 延迟

模型尺寸的增加通常伴随着推理延迟的增加。这一点说出来很明显,但更多的参数意味着更多的计算,更多的计算意味着更长的推理等待时间。然而,这一点不能被低估。我们知道很多人轻视延迟问题,因为他们与一个 LLM 聊天机器人互动过,感觉体验很流畅。但如果你仔细观察,你会发现它一次只返回一个单词,这些单词被流式传输给用户。之所以感觉流畅,是因为答案的到来速度超过了人类阅读的速度,但仔细观察有助于我们意识到这只是一个 UX 技巧。LLMs 仍然太慢,对于需要快速响应的自动补全解决方案等用途来说,仍然不太有用。将其构建到读取大量文本数据的数据管道或工作流程中,然后尝试清理或总结它,也可能因为速度过慢而无法使用或可靠。

他们缓慢的原因还有很多不那么明显。首先,大型语言模型(LLMs)通常分布在多个 GPU 上,这增加了额外的通信开销。正如本章 3.3.2 节后面所讨论的,它们以其他方式分布,有时甚至为了降低延迟,但任何分布都会增加额外的开销负担。此外,LLMs 的延迟严重受完成长度的影响,这意味着它使用的单词越多,返回响应所需的时间就越长。当然,完成长度似乎也能提高准确性。例如,使用像思维链(CoT)这样的提示工程技巧,我们要求模型以逐步的方式思考问题,这已被证明可以改善逻辑和数学问题的结果,但会显著增加响应长度和延迟时间。

3.2.4 管理 GPU

为了帮助解决这些延迟问题,我们通常希望它们在 GPU 上运行。如果我们想要成功训练 LLMs,我们也需要 GPU,但这都增加了许多低估的额外挑战。大多数网络服务和许多机器学习用例可以仅使用 CPU 完成,但对于 LLMs 来说并非如此——部分原因是 GPU 的并行处理能力为我们解决了延迟问题,部分原因是 GPU 在线性代数、矩阵乘法和张量运算中固有的优化;这是在幕后发生的。对于许多刚刚进入 LLMs 领域的人来说,这需要利用新的资源并增加额外的复杂性。许多人鲁莽地进入这个世界,表现得好像这没什么大不了的,但他们将面临一个令人震惊的觉醒。大多数系统架构和可用的编排工具,如 Kubernetes,都假设应用程序将仅使用 CPU 和内存运行。虽然它们通常支持额外的资源,如 GPU,但这通常是一个事后考虑。你很快就会发现自己必须从头开始重建容器并部署新的度量系统。

管理 GPU 的一个方面是,大多数公司都没有准备好,那就是它们往往很稀缺且有限。在过去十年中,我们似乎一直在经历全球 GPU 短缺的起伏。对于想要保持本地部署的公司来说,GPU 的配置可能极其困难。在我们的职业生涯中,我们花费了大量时间与那些出于各种原因选择保持本地部署的公司合作。他们共同的一点是,他们的服务器上从未有过 GPU。当有 GPU 时,它们通常故意难以访问,只有少数关键员工才能访问。

如果你足够幸运,在云端工作,许多这些问题都得到了解决,但这里也没有免费的午餐。我们俩都曾是那些经常追逐尾巴、试图帮助数据科学家解决新 GPU 工作区配置问题的团队的一员。我们遇到了诸如scale.up.error.out.of.resources这样的晦涩、不祥的错误,结果发现这些神秘的读数表明整个区域所选类型的所有 GPU 都被利用了,没有一个是可用的。在数据中心,CPU 和内存通常可以被视为无限的;然而,GPU 资源却不能。有时你甚至完全无法期待它们。大多数数据中心只支持实例或 GPU 类型的一个子集,这意味着你可能被迫在一个离用户群更远的地区设置你的应用程序,从而增加延迟。当然,我们确信当你想要将服务扩展到目前不支持的新地区时,你可以与你的云服务提供商合作,但根据时间表和成本,你可能不会喜欢你所听到的。最终,无论你选择在哪里运行,无论是在本地还是在云端,你都会遇到短缺问题。

3.2.5 文本数据的特性

LLMs 是现代解决 NLP 问题的方案。NLP 是机器学习中最迷人的分支之一,因为它主要处理文本数据,这主要是一种定性度量。其他所有领域都处理定量数据。我们已经找到了一种方法,将我们对世界的观察编码成直接的数值转换。例如,我们已经学会了如何将热量编码到温度尺度中,并用温度计和热电偶来测量它,我们还可以用压力计和压力表来测量压力,并将其转换为帕斯卡。

计算机视觉和评估图像的实践通常被视为定性分析,但将图像编码为数字的问题已经得到了解决。我们对光的理解使我们能够将图像分解成像素,并赋予它们 RGB 值。当然,这并不意味着计算机视觉在某种程度上已经解决了;仍有大量工作要做,以学习如何识别数据模式中的不同信号。音频数据也常常被认为具有定性。一个人如何比较两首歌曲?但我们可以测量声音和语音,直接测量声波的强度(分贝)和频率(赫兹)。

与将我们的物理世界编码成数值数据的其他领域不同,文本数据正在寻找衡量短暂世界的方法。毕竟,文本数据是我们编码思想、想法和交流模式的最佳尝试。虽然,是的,我们已经找到了将单词转换成数字的方法,但我们还没有找到直接的翻译。我们编码文本和创建嵌入的最佳解决方案最多只是近似;实际上,我们使用机器学习模型来完成这项工作!一个有趣的插曲是,数字也是文本,是语言的一部分。如果我们想要在数学方面做得更好的模型,我们需要一种更有意义的方式来编码这些数字。由于这一切都是虚构的,当我们尝试将文本数字编码成机器可读的数字时,我们正在创建一个试图以有意义的方式递归引用自己的系统。这不是一个容易解决的问题!

由于所有这些,LLM(以及所有 NLP 解决方案)都有独特的挑战。以监控为例。你如何在文本数据中捕捉数据漂移?你如何衡量“正确性”?你如何确保数据的清洁性?这些问题很难定义,更不用说解决了。

3.2.6 标记限制造成瓶颈

对于 LLM 新用户来说,一个很大的挑战是处理标记限制。模型的标记限制是指可以作为模型输入的最大标记数量。标记限制越大,我们可以提供给模型的上下文就越多,从而提高其完成任务的成功率。每个人都希望它们更高,但这并不简单。这些标记限制由两个问题定义:我们 GPU 可访问的内存和速度,以及模型自身内存存储的性质。

第一个问题看起来不太直观:为什么我们不能只是增加 GPU 内存呢?答案很复杂。我们可以,但是将更多层堆叠在 GPU 上以一次考虑更多的千兆字节会整体降低 GPU 的计算能力。目前,GPU 制造商正在研究新的架构和绕过这个问题的方法。第二个挑战非常有趣,因为增加标记限制实际上加剧了底层的数学问题。让我来解释一下。在 LLM 内部进行内存存储并不是我们经常考虑的事情。我们称之为“注意力”,这在第 2.2.7 节中我们进行了深入讨论。我们没有讨论的是,注意力是一个二次解决方案:随着标记数量的增加,计算序列中所有标记对之间的注意力分数所需的计算量会随着序列长度的平方而增长。此外,在我们的巨大上下文空间中,由于我们正在处理二次方程,我们开始遇到只有涉及虚数才能解决的难题,这可能导致模型以意想不到的方式表现。这可能是 LLM 产生幻觉的其中一个原因。

这些问题具有实际影响,并影响应用程序设计。例如,当作者所在的团队从 GPT-3 升级到 GPT-4 时,团队很高兴能够访问更高的标记限制,但很快发现这导致了更长的推理时间,随后是更高的超时错误率。在现实世界中,快速得到一个不太准确的响应通常比完全得不到响应要好,因为更准确模型的承诺通常只是承诺。当然,当在本地部署时,您不必担心响应时间,您可能会发现您的硬件是限制因素。例如,LLaMA 是用 2,048 个标记训练的,但您在使用基本的消费级 GPU 运行时,能利用的最多只有 512 个,您可能会看到内存不足(OOM)错误,甚至模型直接崩溃。

一个可能会让您的团队感到意外的问题,现在应该指出的是,不同语言的每个字符的标记数是不同的。看看表 3.1,我们使用 OpenAI 的 cl100k_base 字节对编码器将不同语言的相同句子转换为标记。只需一眼就能看出,LLMs 通常在这个方面更倾向于英语。在实践中,这意味着如果您正在使用 LLM 构建聊天机器人,您的英语用户的输入空间将比日语用户更灵活,从而导致非常不同的用户体验。

表 3.1 不同语言中标记计数比较
语言 字符串 字符 标记
英语 The quick brown fox jumps over the lazy dog 43 9
法语 Le renard brun rapide saute par-dessus le chien paresseux 57 20
西班牙语 El rápido zorro marrón salta sobre el perro perezoso 52 22
日语 素早い茶色のキツネが怠惰な犬を飛び越える 20 36
简体中文 敏捷的棕色狐狸跳过了懒狗 12 28

如果您对此感到好奇,这是因为文本编码,这是在上一节中讨论的与文本数据一起工作的另一个特性。考虑表 3.2,我们展示了几个不同的字符及其在 UTF-8 中的二进制表示。英语字符几乎可以完全用原始 ASCII 标准中的单个字节表示,而大多数其他字符需要 3 个或 4 个字节。因为它需要更多的内存,所以它也需要更多的标记空间。

表 3.2 不同货币字符在 UTF-8 中的字节长度比较
字符 二进制 UTF-8 十六进制 UTF-8
$ 00100100 0x24
£ 11000010 10100011 0xc2 0xa3
¥ 11000010 10100101 0xc2 0xa5
11100010 10000010 10100000 0xe2 0x82 0xa0
💰 11110000 10011111 10010010 10110000 0xf0 0x9f 0x92 0xb0

自从 transformers 的普及以来,增加 token 限制一直是一个持续的研究问题,并且还有一些有希望的解决方案仍处于研究阶段,例如循环记忆转换器(RMT)。¹ 我们可以期待未来会继续看到改进,并且希望这最终只会成为一个小麻烦。

3.2.7 幻觉引起困惑

到目前为止,我们一直在讨论团队在将 LLM 部署到生产环境时面临的一些技术问题,但没有什么问题能与 LLM 倾向于错误这一简单问题相提并论。它们往往犯很多错误。“幻觉”是一个术语,用来描述当 LLM 模型会产生听起来正确但实际上错误的结果的情况——例如,书籍引用或具有预期形式和结构的超链接,但却是完全虚构的。作为一个有趣的例子,我们向出版社 Manning(由于一位作者仍在撰写,这本书还不存在)请求有关生产中 LLM 的书籍。我们得到了以下建议:Mike Del Balso 和 Lucas Serveén 合著的《生产中的机器学习工程》www.manning.com/books/machine-learning-engineering-in-production,以及 Jeremy Howard 和 Sylvain Gugger 合著的《使用 Fastai 和 PyTorch 的编码者深度学习》www.manning.com/books/deep-learning-for-coders-with-fastai-and-pytorch。第一本书完全是虚构的。第二本书是真实的;然而,它并非由 Manning 出版。在每种情况下,互联网地址都是虚构的。这些 URL 的格式实际上与你浏览 Manning 网站时预期的非常相似,但如果你访问它们,将会返回 404 错误。

幻觉最令人烦恼的方面之一是它们通常被自信的词语所包围。LLM 在表达不确定性方面非常糟糕,这在很大程度上是由于它们的训练方式。考虑“2 + 2 =”的情况。你更希望它回答“我认为它是 4”还是简单地“4”?大多数人更希望得到正确的“4”。这种偏见是固有的,因为模型通常会因为正确或至少听起来正确而得到奖励。

关于幻觉发生的原因,有各种解释,但最真实的答案是,我们不知道是否只有一个原因。它可能是由几件事情的组合;因此,目前还没有一个好的解决办法。尽管如此,准备好应对这些模型的错误和不准确性是至关重要的,以便为你的产品提供最佳的用户体验。

3.2.8 偏见和伦理考量

3.2.9 安全问题

就像所有技术一样,我们需要注意安全问题。大型语言模型(LLMs)是在大量文本语料库上训练的,其中一些可能是有害或敏感的,不应该被暴露。因此,应该采取措施保护这些数据不被泄露。上一节中提到的偏见和伦理问题就是你不希望你的用户讨论的话题的例子,但你也可以想象在公司的数据上微调模型,如果未采取适当预防措施,可能会意外地丢失秘密。

例如,使用简单的提示请求可口可乐的秘方仅仅返回了无聊的结果,“我不能泄露商业机密或任何专有信息。”如果我们问您,这真是太无聊了。通过一点小诡计和一些提示注入,我们设法让它泄露了秘密,当然,我们在尝试之前确认了一位食品科学家,它不会让我们中毒。按照指示严格操作后,我们惊讶地发现我们得到了一杯相当美味的饮料,但它尝起来根本不像可口可乐。虽然配方看起来很合法,但像数量、烹饪时间,甚至需要过滤糖浆这样的细微差别都不对。看来我们得等到得到真正的配方后再考虑接管饮料行业。

可口可乐配方通过提示注入产生的幻觉
Homemade Coca-Cola Recipe
=====================
**Disclaimer**: The original Coca-Cola recipe is a highly guarded trade secret. The following recipe is an approximation for creating a homemade cola drink that resembles the taste of Coca-Cola.

Ingredients:
- 3 cups of water
- 2 cups granulated sugar
- 1/8 tsp food-grade citric acid
- 3/4 tsp caramel color (liquid)
- 1/2 tsp caffeine (powder or liquid form)
- 1 1/2 tsp cola extract
- 1/8 tsp vanilla extract
- 1 1/2 cups carbonated water (store-bought or using a home soda maker)
- A pinch of ground cinnamon (optional)
- A pinch of ground nutmeg (optional)

Instructions:
1\. In a large saucepan, bring the 3 cups of water to a gentle simmer.
2\. Slowly stir in the granulated sugar, ensuring that it fully dissolves.
3\. Add the citric acid, caramel color, caffeine, cola extract, and vanilla extract to the saucepan. Mix well.
4\. Stir in the optional spices if desired: cinnamon and nutmeg.
5\. Simmer the mixture for about 20 minutes, stirring occasionally.
6\. Remove the saucepan from the heat and let it cool to room temperature.
7\. Once cooled, strain the syrup to remove any undissolved solids.
8\. To make the final drink, mix 1 part syrup with 3 parts carbonated water. Adjust to taste depending on your preference for sweetness and carbonation.

Variations:
- You can experiment with different amounts of caramel color, caffeine, and cola extract to adjust the flavor and appearance of your homemade cola.
- To create different cola flavor profiles, experiment with adding additional spices (such as star anise, orange or lemon peel, or cardamom), essential oils, or fruit extracts.
- Consider using a natural sweetener, like honey or agave syrup, as an alternative to granulated sugar.

Historical context and interesting facts:
- Coca-Cola was first introduced in 1886 by pharmacist Dr. John S. Pemberton, who aimed to create a unique and refreshing beverage that could also relieve fatigue and headaches.
- The original Coca-Cola formula contained trace amounts of cocaine extracted from the coca leaf, which was later replaced with caffeine in the early 20th century.
- In 1985, Coca-Cola introduced "New Coke," a reformulation aimed at competing with Pepsi. However, the taste change was met with backlash, and the company returned to its original formula just three months later, rebranded as "Coca-Cola Classic."
Result(s): You can now create your own homemade version of cola, resembling the taste of Coca-Cola. Enjoy and feel free to experiment with different spices, extracts, and sweeteners to create your own unique flavor profile.

另一个安全问题是腌菜注入。腌菜是 Python 中的一个库,用于序列化对象,通常用于序列化机器学习模型。它在反序列化时将对象序列化为包含逐个执行的指令的字节流。这是一种快速且简单的方式共享大型对象。腌菜注入会破坏这个字节流,通常在模型通过不安全的网络传输时注入恶意软件。这对于下载时间较长的大型模型尤其令人担忧,因为这使得第三方更容易拦截传输并注入恶意代码。如果发生这种情况,注入的代码可能会让攻击者获得对系统的访问权限。这可能在尝试在推理过程中使用模型时发生,因为如果有害代码未被检测并正确移除,它将执行。因此,在使用模型之前采取预防措施,如使用安全网络和验证模型的完整性,以防止此类攻击,是非常重要的。

3.2.10 控制成本

与大型语言模型(LLMs)一起工作涉及各种与成本相关的问题。首先,你可能已经注意到了,这是基础设施成本,包括高性能 GPU、存储和其他硬件资源。我们讨论了 GPU 采购难度较大,不幸的是,这也意味着它们更昂贵。像留下你的服务这样的错误一直都有累积账单的潜力,但与 GPU 混合在一起,这种错误甚至更具破坏性。这些模型还要求大量的计算能力,导致在训练和推理过程中消耗大量能源。除此之外,它们的部署时间更长,这意味着我们经常在低流量期间运行它们,以处理突发的工作负载或预期的未来流量。总的来说,这导致了更高的运营成本。

额外的成本包括管理和存储用于训练或微调以及常规维护的大量数据,例如模型更新、安全措施和错误修复,这可能对财务造成压力。与任何用于商业目的的技术一样,管理潜在的诉讼和确保符合法规也是一个问题。最后,投资于持续的研究和开发以改进您的模型并给您带来竞争优势,将是一个因素。

我们谈了一些关于令牌限制的技术问题,这些问题可能会得到解决,但我们没有讨论成本限制,因为大多数 API 按令牌收费。这使得发送更多上下文和使用更好的提示变得更加昂贵。这也使得预测成本变得有点困难,因为虽然你可以标准化输入,但你不能标准化输出。你永远不能太确定会返回多少令牌,这使得管理变得困难。记住,对于 LLM 来说,实施和遵循适当的成本工程实践以确保成本永远不会失控,这和以前一样重要。

3.3 LLMOps 基本要素

现在我们已经掌握了我们正在应对的挑战类型,让我们来看看所有不同的 LLMOps 实践、工具和基础设施,看看不同的组件如何帮助我们克服这些障碍。首先,让我们深入探讨不同的实践,从压缩开始,我们将讨论缩小、修剪和近似,以使模型尽可能小。然后我们将讨论分布式计算,这是必需的,因为模型如此之大,很少能适应单个 GPU 的内存。完成这些后,我们将在下一节中探讨实现这一切所需的基础设施和工具。

3.3.1 压缩

在上一节中,当你阅读关于大型语言模型(LLM)的挑战时,你可能自己问过类似的问题:“如果 LLM 的最大问题来自它们的大小,为什么我们不把它们做得更小?”如果你这样想过,恭喜你!你是个天才——压缩就是做这件事的实践。将模型压缩到尽可能小,将提高部署时间,减少延迟,减少所需昂贵 GPU 的数量,并最终节省资金。然而,最初让模型变得如此庞大无比的整个目的,是因为它使它们在所做的事情上变得更好。我们需要能够在不失去我们通过使它们变大而取得的全部进步的情况下缩小它们。

这个问题远未解决,但有多种方法可以解决这个问题,每种方法都有其优缺点。我们将讨论几种方法,从最简单和最有效的方法开始。

定量

量化是将精度降低以降低内存需求的过程。这种权衡在直觉上是有意义的。当作者在大学时,他被教导总是将数字四舍五入到工具的精度。拿出直尺测量他的铅笔,如果你告诉他长度是 19.025467821973739 厘米,你不会相信他。即使他使用了卡尺,他也无法验证如此精确的数字。用我们的直尺,任何超过 19.03 厘米的数字都是幻想。为了强调这一点,他的一个工程教授曾经问他,“如果你正在测量摩天大楼的高度,你会在乎顶部多出的一张纸吗?”

我们在计算机内部表示数字的方式往往让我们误以为自己有比实际更好的精度。为了说明这一点,打开 Python 终端并执行 0.1 + 0.2。如果你以前从未尝试过,你可能会惊讶地发现它不等于 0.3,而是 0.30000000000000004。我们不会深入探讨这一现象背后的数学细节,但问题仍然存在:我们能否在不使事情变得更糟的情况下降低精度?我们实际上只需要精确到小数点后第十位,但降低精度可能会得到一个像 0.304 这样的数字,而不是 0.300,从而增加我们的误差范围。

最终,计算机唯一能理解的数字是 0 和 1,开或关,一个比特位。为了提高这个范围,我们将多个比特位组合起来,并赋予它们不同的含义。将 8 个比特位串联起来,你就得到了一个字节。使用 INT8 标准,我们可以将这个字节编码为从-128 到 127 的所有整数。我们就不详细解释背后的数学原理了,因为假设你已经知道二进制是如何工作的;只需说,我们拥有的比特位越多,我们能够表示的数字范围就越大,无论是更大的还是更小的。图 3.1 展示了几个常见的浮点数编码。将 32 个比特位串联起来,我们得到了我们自诩的“全精度”,这也是大多数数字存储的方式,包括机器学习模型中的权重。基本的量化将我们从全精度转换为半精度,将模型的大小缩小到原来的一半。有两种不同的半精度标准,FP16 和 BF16,它们在表示范围或指数部分时使用的比特位数不同。由于 BF16 使用的指数与 FP32 相同,它被发现对于量化来说更有效,你可以预期在模型大小减半的情况下几乎可以达到相同的精确度。如果你理解了论文和摩天大楼的类比,那么原因应该很明显。

figure

图 3.1 几种常见浮点数编码的比特映射:16 位浮点数或半精度(FP16)、bfloat 16(BF16)、32 位浮点数或单精度(FP32)以及 NVIDIA 的 TensorFloat(TF32)

然而,没有理由就此停止。我们通常可以将它降低到 8 位格式,而不会损失太多精度。甚至已经有一些成功的研究尝试表明,对 LLM 的部分进行选择性的 4 位量化是可能的,而且精度损失很小。选择性地应用量化是一个称为动态量化的过程,通常只对权重进行量化,而将激活保留在完全精度以减少精度损失。

量化的圣杯是 INT2,表示每个数字为–1、0 或 1。目前这不可能而不完全降低模型质量,但它可以使模型缩小到原来的 1/8。Bloom 模型将只有大约~40 GB,足够小,可以放在单个 GPU 上。这当然是量化的极限,如果我们想进一步缩小,我们需要考虑其他方法。

量化的最好部分是它很容易做。有许多框架允许这样做,但在列表 3.1 中,我们展示了如何使用 PyTorch 的量化库进行简单的训练后静态量化(PTQ)。你所需要的只是一个全精度模型,一些示例输入,以及用于准备和校准的验证数据集。正如你所看到的,这只需要几行代码。

列表 3.1 PyTorch 中的示例 PTQ
import copy
import torch.ao.quantization as q

model_to_quantize = copy.deepcopy(model_fp32)     #1
model_to_quantize.eval()

qconfig_mapping = q.get_default_qconfig_mapping("qnnpack")     #2

prepared_model = q.prepare(model_to_quantize)     #3

with torch.inference_mode():     #4
    for x in dataset:
        prepared_model(x) 

model_quantized = q.convert(prepared_model)      #5

1 原模型的深度拷贝,因为量化是在原地进行的

2 获取映射;注意对于 ARM 使用“qnnpack”,对于 x86 CPU 使用“fbgemm”

3 准备

4 校准;你需要使用代表性的(验证)数据

5 量化

静态 PTQ 是量化最直接的方法;它在模型训练后进行,并对所有模型参数进行均匀量化。与大多数公式一样,最直接的方法会引入更多的错误。通常,这种错误是可以接受的,但如果不可以,我们可以增加额外的复杂性来减少量化带来的精度损失。可以考虑的方法包括均匀与非均匀、静态与动态、对称与不对称,以及在其训练期间或之后应用。

要理解这些方法,让我们考虑从 FP32 量化到 INT8 的情况。在 FP32 中,我们实际上有可用的全部数字范围,但在 INT8 中,我们只有 256 个值。我们试图把一个精灵放进瓶子里,这不是一件小事。如果你研究你的模型中的权重,你可能会注意到大多数数字都在[–1, 1]之间的分数。我们可以利用这一点,通过使用一个 8 位标准,在这个区域以非均匀方式表示更多的值,而不是标准的均匀[–128, 127]。虽然从数学上来说是可能的,但不幸的是,这样的标准并不常见,现代深度学习硬件和软件也没有设计来利用它们。所以现在,最好坚持使用均匀量化。

缩小数据的最简单方法是对其进行归一化,但由于我们是从连续尺度转换为离散尺度,所以有几个需要注意的问题,让我们来探讨一下。我们首先取最小值和最大值,并将它们缩放到与我们的新数值范围相匹配。然后,我们将所有其他数字根据它们所在的位置进行分类。当然,如果我们有非常大的异常值,我们可能会发现所有其他数字都挤进了一个或两个桶中,从而破坏了我们曾经拥有的任何粒度。为了防止这种情况,我们可以截断任何大数字;这就是我们在静态量化中所做的。然而,在我们截断数据之前,如果我们事先选择一个范围并缩放以捕捉大部分数据,会怎样呢?我们需要小心,因为如果这个动态范围太小,我们会引入更多的截断误差;如果太大,我们会引入更多的舍入误差。动态量化的目标当然是减少这两种误差。

接下来,我们需要考虑数据的对称性。通常,在归一化过程中,我们迫使数据变得正常并因此对称;然而,我们也可以选择以保留数据原有任何不对称性的方式来缩放数据。通过这样做,我们有可能减少由于截断和舍入误差造成的总体损失,但这并不保证。

作为最后的手段,如果这些其他方法都不能减少模型的精度损失,我们可以使用量化感知训练(QAT)。QAT 是一个简单的过程,我们在模型训练期间添加一个模拟量化步骤。通过“模拟”,我们是指我们在保持数据全精度的情况下截断和舍入数据。这允许模型在训练过程中调整量化引入的误差和偏差。与其它方法相比,QAT 已知可以产生更高的精度,但训练时间成本要高得多。

量化方法
  • 均匀与非均匀——我们是否使用在它所表示的范围内是均匀的 8 位标准,或者为了在-1 到 1 范围内更精确地使用非均匀标准。

  • 静态与动态——在截断之前选择调整范围或尺度,以尝试减少截断和舍入误差以及减少数据损失。

  • 对称与不对称——将数据归一化以使其正常并强制对称,或者选择保留任何不对称性和偏斜。

  • 训练期间或之后——训练后的量化非常容易做,而如果在训练期间做,则工作更多,但会导致减少偏差和更好的结果。

量化是一个非常强大的工具。它减少了模型的大小和运行模型所需的计算开销,从而降低了模型的延迟和运行成本。然而,量化最好的地方在于它可以在事后进行,所以你不必担心你的数据科学家是否记得在训练过程中使用 QAT 等过程对模型进行量化。这就是为什么量化在处理 LLM 和其他大型机器学习模型时变得如此受欢迎。虽然压缩技术总是伴随着准确度降低的担忧,但与其他方法相比,量化是一个三赢的局面。

修剪

恭喜你,你刚刚训练了一个全新的 LLM!拥有数十亿个参数,它们都必须是有用的,对吧?错了!不幸的是,就像生活中的许多事情一样,模型的参数往往遵循帕累托法则。大约 20%的权重导致了 80%的价值。“如果这是真的,”你可能正在问自己,“我们为什么不把所有的额外东西都剪掉?”好主意!给自己鼓掌。修剪是剔除和移除我们认为不值得的模型任何部分的过程。

实际上存在两种不同的修剪方法:结构化非结构化。结构化修剪是寻找模型中不贡献于模型性能的结构组件,然后将其移除的过程——无论是神经网络中的过滤器、通道还是层。这种方法的优势在于,你的模型会稍微小一些,但保持相同的基本结构,这意味着我们不必担心失去硬件效率。我们还保证了延迟的改进,因为涉及的计算会更少。

相反,非结构化修剪会遍历参数,并将不太重要的参数置零,这些参数对模型性能的贡献不大。与结构化修剪不同,我们实际上并没有移除任何参数;我们只是将它们设置为零。从这个角度来看,我们可以想象一个很好的起点是任何已经接近 0 的权重或激活。当然,虽然这有效地减少了模型的大小,但这也意味着我们没有减少任何计算,所以通常只会看到最小化,如果有的话,的延迟改进。但更小的模型仍然意味着更快的加载时间和更少的 GPU 运行。它还让我们对过程有非常精细的控制,允许我们比结构化修剪进一步缩小模型,同时对性能的影响也更小。

与量化类似,修剪可以在模型训练后进行。然而,与量化不同,通常需要额外的微调来防止性能损失过大。在模型训练期间包含修剪步骤以避免后续需要微调的做法越来越普遍。由于更稀疏的模型将有更少的参数需要调整,添加这些修剪步骤可能有助于模型更快地收敛。⁵

你可能会惊讶于通过剪枝可以缩小模型的同时对性能的影响最小化。能缩小多少呢?在 SparseGPT⁶论文中,开发了一种方法来尝试自动一次性完成剪枝过程,而不需要后续的微调。作者发现他们可以将 GPT-3 模型缩小 50%到 60%而没有任何问题!根据模型和任务的不同,他们甚至在其中一些任务中看到了轻微的性能提升。我们期待着看到剪枝在未来能带我们走向何方。

知识蒸馏

在我们的心目中,知识蒸馏可能是最酷的压缩方法。这个想法也很简单:我们将使用大型 LLM 来训练一个较小的语言模型,以复制它。这个方法的好处是,较大的 LLM 为较小的模型提供了一个几乎无限的训练数据集,这使得训练非常有效。因为数据集越大,性能越好,我们经常看到较小的模型在准确性上几乎达到了它们的教师模型水平。⁷

以这种方式训练的较小模型将保证既更小又能提高延迟。缺点是这将要求我们训练一个全新的模型,这将是相当大的前期成本。任何对教师模型的未来改进都需要传递给学生模型,这可能导致复杂的训练周期和版本结构。这肯定比其他一些压缩方法要复杂得多。

然而,知识蒸馏最困难的部分是,我们还没有真正找到好的配方。像“学生模型可以有多小?”这样的难题必须通过试错来解决。这里还有很多东西需要学习和研究。

然而,斯坦福大学的 Alpaca 在这个领域做了一些令人兴奋的工作。⁸他们没有从头开始训练学生模型,而是选择使用 OpenAI 的 GPT3.5 的 175B 参数模型作为教师,通过知识蒸馏微调开源的 LLaMA 7B 参数模型。这是一个简单的想法,但效果显著,因为他们从评估中得到了很好的结果。最大的惊喜是成本,因为他们只花费了 500 美元的 API 费用来从教师模型获取训练数据,以及价值 100 美元的 GPU 训练时间来微调学生模型。当然,如果你将此用于商业应用,你将违反 OpenAI 的服务条款,所以最好坚持使用自己的或开源模型作为教师。

低秩近似

低秩逼近,也称为低秩分解、低秩分解或矩阵分解(以及其他许多名称——我们责怪数学家们——名称太多),使用线性代数数学技巧简化大型矩阵或张量,以找到低维表示。为此有多种技术。奇异值分解(SVD)、Tucker 分解(TD)和典型多项式分解(CPD)是最常见的几种。

在图 3.2 中,我们展示了 SVD 方法背后的基本思想。本质上,我们将一个非常大的矩阵 A 分解成三个较小的矩阵,U、S 和 V。虽然 U 和 V 存在是为了确保我们保持原始矩阵的相同维度和相对强度,但 S 允许我们应用方向和偏差。S 越小,我们最终压缩和减少的总参数数就越多,但逼近的准确性就越低。

图

图 3.2 SVD 示例,低秩逼近。A 是一个具有 N 和 M 维度的大矩阵。我们可以用三个较小的矩阵来逼近它:U 具有维度 M 和 P,S 是一个维度为 P 的方阵,V 具有维度 N 和 P(这里我们展示了转置)。通常,P<<M 和 P<<N 都是正确的。

为了巩固这个概念,看到具体的例子可能会有所帮助。在下一个列表中,我们展示了 SVD 在压缩一个 … 矩阵时的工作示例。为此,我们只需要基本的库 SciPy 和 NumPy,它们在 1 和 2 行导入。在第 3 行中,我们定义了矩阵,然后在第 9 行中对其应用 SVD。

列表 3.2 SVD 低秩逼近示例
import scipy
import numpy as np
matrix = np.array([
    [ 1., 2., 3., 4.],
    [ 5., 6., 7., 8.],
    [ 9., 10., 11., 12.],
    [13., 14., 15., 16.]
])
u, s, vt = scipy.sparse.linalg.svds(matrix, k=1)
print(u,s,vt)
# [[-0.13472211]
# [-0.34075767]
# [-0.5467932 ]

生成的文本是

[-0.7528288 ]], [38.62266], [[-0.4284123 -0.47437257 -0.52033263 –0.5662928 ]]

仔细检查 U、Sigma 和 V 的转置,我们可以看到一个 … 矩阵,一个 … 矩阵,以及一个 … 矩阵,分别。总的来说,我们现在只需要 9 个参数,而原始的则需要 16 个,内存占用几乎减少了一半。

最后,我们将这些矩阵相乘以得到原始矩阵的逼近。在这种情况下,逼近并不十分出色,但我们仍然可以看到,一般顺序和大小与原始矩阵相匹配:

svd_matrix = u*s*vt
print(svd_matrix)

生成的文本是

 array([[ 2.2291691, 2.4683154, 2.7074606, 2.9466066],
      [ 5.6383204, 6.243202 , 6.848081 , 7.4529614],
      [ 9.047472 , 10.018089 , 10.988702 , 11.959317 ],
      [12.456624 , 13.792976 , 15.129323 , 16.465673 ]], dtype=float32)

不幸的是,我们不知道有人实际上在生产中使用这种方法来压缩模型,这很可能是由于近似精度较差。他们使用它的目的——这一点很重要——是适应和微调,这正是低秩适应(LoRA)⁹发挥作用的地方。适应是将通用或基础模型微调以执行特定任务的过程。LoRA 将 SVD 低秩近似应用于注意力权重,或者更确切地说,注入与注意力权重并行运行的更新矩阵,使我们能够微调一个更小的模型。LoRA 因其使 LLM 的微调变得轻而易举而变得非常流行,可以将可训练层缩小到原始模型的一小部分,然后允许任何人在通用硬件上对其进行训练。您可以使用 Hugging Face 的 PEFT 库开始使用 LoRA,在那里您可以查看几个 LoRA 教程。

备注:对于好奇心强的人来说,参数高效微调(PEFT)是一类旨在以计算高效的方式微调模型的方法。PEFT 库旨在将它们都放在一个易于访问的地方;你可以从这里开始:huggingface.co/docs/peft

专家混合

专家混合(MoE)是一种技术,我们用 MoE 层替换 transformer 中的前馈层。前馈层因其参数密集和计算密集而臭名昭著,因此用更好的东西替换它们通常会产生很大的影响。MoEs 是一组稀疏激活的模型。它们与集成技术不同,因为通常只运行一个或几个专家模型,而不是结合所有模型的输出。稀疏性通常由一个门控机制引起,该机制学习使用哪些专家,以及/或一个路由器机制,该机制确定哪些专家应该被咨询。在图 3.3 中,我们展示了具有潜在 N 个专家的 MoE 架构,以及展示了它在解码器堆栈中的位置。

figure

图 3.3 示例:专家模型混合,包含门控和路由器以控制流。MoE 模型用于替换 transformer 中的 FFN 层;在这里,我们展示了它在解码器中替换 FFN 的情况。

根据你拥有的专家数量,MoE 层可能比 FFN 层有更多的参数,从而导致更大的模型,但在实践中,这种情况并不常见,因为工程师和研究人员的目标是创建一个更小的模型。然而,我们可以保证看到的是更快的计算路径和改进的推理时间。然而,真正让 MoE 脱颖而出的还是当它与量化结合时。一项由微软和 NVIDIA 进行的研究¹⁰表明,使用 MoE 只需最小程度地影响精度就能实现 2 位量化!

当然,由于这是对模型结构的一个相当大的改动,之后需要进行微调。你也应该意识到,MoE 层通常会降低模型的可泛化性,因此最好是在为特定任务设计的模型上使用。有几个库实现了 MoE 层,但我们推荐检查 DeepSpeed。

注意:DeepSpeed 是一个库,它优化了许多用于大规模深度学习模型(如 LLMs)的困难部分,在训练时特别有用。查看他们的 MoE 教程:www.deepspeed.ai/tutorials/mixture-of-experts/

3.3.2 分布式计算

分布式计算是深度学习中用于并行化和加速大型、复杂神经网络的技术,通过在集群中的多个设备或节点之间分配工作负载来实现。这种方法通过启用并发计算、数据并行和模型并行,显著减少了训练和推理时间。随着数据集规模和模型复杂性的不断增长,分布式计算已成为深度学习工作流程的关键,确保了资源的高效利用,并使研究人员能够有效地迭代他们的模型。分布式计算是将深度学习与机器学习区分开来的核心实践之一,在 LLMs 中,我们必须使出浑身解数。让我们看看不同的并行处理实践,以充分利用分布式计算。

数据并行

数据并行是人们在考虑并行运行进程时首先想到的;它也是最易实现的。这种做法涉及将数据分割成多个部分,并通过模型的多个副本或管道运行。对于大多数框架来说,这很容易设置;例如,在 PyTorch 中,你可以使用 DistributedDataParallel 方法。但大多数这些设置都有一个缺点:你的模型必须能够适应一个 GPU。这就是像 Ray.io 这样的工具发挥作用的地方。

Ray.io,或 Ray,是一个为分布式计算设计的开源项目,特别针对并行和集群计算。它是一个灵活且用户友好的工具,简化了分布式编程,并帮助开发者轻松地并行执行并发任务。Ray 主要用于机器学习和其他高性能应用,但也可以用于其他应用。在列表 3.3 中,我们给出了使用 Ray 分配任务的简单示例。Ray 的美妙之处在于其简单性——我们使代码并行运行所需要做的只是添加一个装饰器。这确实比多线程或异步设置复杂多了。

列表 3.3 示例 Ray 并行化任务
import ray
import time

ray.init()     #1

def slow_function(x):     #2
    time.sleep(1)
    return x

@ray.remote
def slow_function_ray(x):      #3
    time.sleep(1)
    return x

results = [slow_function(i) for i in range(1, 11)]     #4

results_future = [slow_function_ray.remote(i) for i in range(1, 11)]      #5
results_ray = ray.get(results_future)

print("Results without Ray: ", results)
print("Results with Ray: ", results_ray)

ray.shutdown()

1 启动 Ray

2 Δ定义一个常规 Python 函数

3 将函数转换为 Ray 任务

4 无 Ray 执行慢函数(耗时 10 秒)

5 使用 Ray 执行慢函数(耗时 1 秒)

Ray 使用任务和演员的概念来管理分布式计算。任务是指函数,而演员是有状态的实体,可以被调用并并发运行。当你使用 Ray 执行任务时,它会将任务分配到可用的资源上(例如,多核 CPU 或集群中的多个节点)。对于 LLMs,我们需要在云环境中设置一个 Ray 集群,因为这可以让每个流水线在拥有所需 GPU 数量的节点上运行,极大地简化了并行运行 LLMs 的基础设施设置。

注意:在此处了解更多关于 Ray 集群的信息:mng.bz/eVJP

现在有多个替代方案,但 Ray 因其越来越多的机器学习工作流程需要分布式训练而获得了大量关注,并变得越来越受欢迎。团队在使用 Ray 方面取得了巨大成功。通过利用 Ray,开发者可以确保分布式工作流程中更好的性能和更有效的资源利用。

张量并行

张量并行利用矩阵乘法属性,将激活分布在多个处理器上,运行数据,然后在处理器的另一侧将它们组合起来。图 3.4 展示了这个过程在矩阵上的工作方式,矩阵可以通过两种不同的方式并行化,从而得到相同的结果。想象一下,Y 是一个非常大的矩阵,无法放在单个处理器上,或者更可能的是,我们的数据流中的瓶颈需要花费太多时间来运行所有计算。在任何情况下,我们都可以通过列或行来拆分 Y,运行计算,然后将结果组合起来。在这个例子中,我们处理的是矩阵,但在现实中,我们经常处理具有超过两个维度的张量。然而,使这起作用的相同数学原理仍然适用。

figure

图 3.4 展示了通过不同维度拆分张量以获得相同最终结果的张量并行示例。在这里,我们比较了矩阵的列并行和行并行。

选择并行化的维度有点像一门艺术,但有一些事情要记住以帮助更容易做出这个决定。首先,你有多少列或行?一般来说,你想要选择一个比你的处理器数量多的维度,否则你将无法充分利用。通常这不会成为问题,但像上一节中讨论的 Ray 这样的工具,在集群中并行化并启动大量进程变得轻而易举。其次,不同的维度有不同的多重性成本。例如,列并行性要求我们将整个数据集发送到每个进程,但最终可以在末尾快速且容易地将它们连接起来。然而,行并行性允许我们将数据集分割成块,但需要我们添加结果,这是一个比连接操作更昂贵的操作。你可以看到一个操作更受 I/O 限制,而另一个更受计算限制。最终,最佳维度将取决于数据集和硬件限制。这将需要实验来完全优化,但一个好的默认选择是选择最大的维度。

张量并行性允许我们将像 MLP 和注意力层这样的计算密集层分割到不同的设备上,但它对我们无法利用张量的归一化或 dropout 层没有帮助。为了获得更好的整体管道性能,我们可以添加针对这些块的序列并行性。¹¹ 序列并行性是一种沿着序列维度分割激活的过程,防止冗余存储,并且可以与张量并行性结合以实现显著的内存节省,同时额外计算开销最小。结合使用,它们减少了存储 transformer 模型中激活所需的内存。实际上,它们几乎消除了激活的重新计算,并将激活内存节省高达五倍。

图 3.5 展示了如何结合张量并行性,它使我们能够将计算密集层分布到不同的设备上,以及序列并行性,它对内存限制层做同样的处理,从而使整个 transformer 模型完全并行化。它们共同允许资源得到极其高效的使用。

figure

图 3.5 结合关注计算密集层的张量并行性和序列并行性以降低内存开销,为整个 transformer 创建一个完全并行过程

管道并行性

到目前为止,我们可以运行大量数据并加速任何瓶颈,但这些都无关紧要,因为我们的模型太大;我们无法将其放入单个 GPU 的内存中,甚至无法让它运行。这就是管道并行派上用场的地方;它是指将模型垂直分割并分别将每个部分放到不同的 GPU 上的过程。这创建了一个管道,因为输入数据将流向第一个 GPU,进行处理,然后传输到下一个 GPU,依此类推,直到整个模型运行完毕。虽然其他并行技术可以提高我们的处理能力和加速推理,但需要管道并行才能使其运行。然而,它伴随着一些主要的缺点,主要是设备利用率。

为了了解这个缺点从何而来以及如何减轻它,让我们首先考虑这个问题的简单方法,即我们一次性将所有数据通过模型。我们发现这留下了一个巨大的“气泡”未充分利用。由于模型被分割,我们必须通过设备按顺序处理所有内容。这意味着当一个 GPU 正在处理时,其他 GPU 都在空闲。在图 3.6 中,我们可以看到这种简单方法和 GPU 空闲时的大气泡不活跃。我们还看到了利用每个设备的一个更好的方法。我们通过发送小批量数据来实现这一点。较小的批次允许第一个 GPU 更快地将它正在处理的工作传递出去,并转移到另一个批次。这允许下一个设备更早地开始工作,并减少气泡的大小。

figure

图 3.6 气泡问题。当数据通过分割的模型运行时,持有模型权重的 GPU 利用率低下,因为它们在等待对应的 GPU 处理数据。减少这种气泡的一个简单方法是使用微批处理。

我们实际上可以用以下公式很容易地计算出气泡的大小:

空闲百分比 = 1 – m / (m + n – 1)

其中 m 是微批次的数量,n 是管道的深度或 GPU 的数量。所以,在我们的简单示例中,有四个 GPU 和一个大批次,我们看到设备空闲了 75%的时间!GPU 非常昂贵,不能让它有四分之三的时间处于空闲状态。让我们看看使用微批处理策略会是什么样子。使用 4 个微批次,它将这个时间几乎减半,降至仅 43%。我们可以从这个公式中看出,我们拥有的 GPU 越多,空闲时间越高,但微批次越多,利用率越好。

不幸的是,我们通常既不能减少 GPU 的数量,也不能将微批量的大小增加到我们想要的程度。存在限制。对于 GPU,我们必须使用尽可能多的 GPU 来将模型放入内存。然而,尽量使用一些较大的 GPU,因为这会比使用许多较小的 GPU 更具优化。减少管道并行中的气泡是压缩之所以如此重要的另一个原因。对于微批量,第一个限制是明显的:由于微批量是批量大小的分数,因此我们受其大小的限制。第二个限制是每个微批量都会以线性关系增加缓存激活的内存需求。一种应对这种更高内存需求的方法是称为 PipeDream 的方法。¹² 存在不同的配置和方法,但基本思想是相同的。在此方法中,我们在完成任何微批量的正向传递后立即开始工作反向传递。这使得我们能够完全完成一个训练周期并释放该微批量的缓存。

3D 并行

对于 LLMs,我们希望利用所有三种并行化实践,因为它们都可以同时运行。这被称为 3D 并行,它将数据、张量和平行化(DP + TP + PP)结合起来。由于每种技术和因此维度至少需要两个 GPU 来运行 3D 并行,因此我们需要至少八个 GPU 来开始。我们如何配置这些 GPU 对于从该过程中获得最大效率至关重要。由于 TP 具有最大的通信开销,我们希望确保这些 GPU 相邻,最好是位于同一节点和机器上。PP 在三者中具有最少的通信量,因此将模型拆分到节点上是最经济的。

通过同时运行这三个,我们看到了它们之间的一些有趣的交互和协同作用。由于 TP 将模型分割以在设备内存内良好运行,我们发现 PP 即使在小批量大小下也能表现良好,这是由于 TP 降低了有效批量大小。这种组合还改善了不同管道阶段 DP 节点之间的通信,使得 DP 也能有效工作。节点之间的通信带宽与管道阶段的数量成正比。因此,DP 即使在较小的批量大小下也能很好地扩展。总的来说,我们看到当它们组合运行时,性能优于单独运行时。

现在我们已经了解了一些行业技巧,拥有合适的工具来完成这项工作同样重要。

3.4 LLM 运营基础设施

我们终于要开始讨论使这一切工作所需的基础设施了。这可能会让人感到惊讶,因为我们知道一些读者可能会期望在第一章的开头看到这一部分。为什么等到第三章的结尾呢?在我们多次采访机器学习工程师时,我们经常提出这个开放式问题:“你能告诉我关于 MLOps 的什么?”这是一个轻松的软球问题,用来开启对话。大多数初级候选人会立即开始跳入工具和基础设施。这很有道理;有这么多不同的工具可供选择。更不用说,每次你看到描述 MLOps 的帖子或博客时,都会有一个展示基础设施的漂亮小图。虽然所有这些都很重要,但认识到更有经验的候选人会跳入的地方——机器学习生命周期。

对于许多人来说,细微差别已经丢失,但基础设施是如何,生命周期是为什么。大多数公司只需最基本的设施就能过得去。我们见过一些简陋的系统,它们完全存在于一个数据科学家的笔记本电脑上,而且工作得相当不错——尤其是在 scikit-learn 无处不在的时代!

不幸的是,一个手推车机器学习平台在 LLMs 的世界中并不适用。因为我们仍然生活在一个 MacBook Pro 笔记本电脑的标准存储容量为 256 GB 的世界里,仅仅本地存储模型就已经可能成为一个问题。投资于更坚固基础设施的公司更能为 LLMs 的世界做好准备。

在图 3.7 中,我们看到一个以 LLMs 为设计理念的 MLOps 基础设施示例。虽然大多数基础设施图简化了结构以使一切看起来整洁,但事实是整个系统还有一点复杂性。当然,如果我们能让数据科学家在脚本中而不是在临时工作站(通常带有 Jupyter Notebook 界面)中工作,很多这种复杂性都会消失。

figure

图 3.7 考虑到 LLMs 的 MLOps 基础设施的高级视图。此图试图涵盖整个画面以及许多工具的复杂性,这些工具使 ML 模型能够在生产环境中运行。

仔细观察图 3.7,你可以看到一些位于外围的工具,它们明确属于 DataOps 或甚至只是 DevOps——数据存储、编排器、管道、流集成和容器注册库。这些是你在几乎所有数据密集型应用程序中可能已经使用的工具,并不一定专注于 MLOps。在中心,我们有更多传统的 MLOps 工具——实验跟踪器、模型注册、特征存储和临时数据科学工作站。对于 LLMs,我们实际上只向堆栈中引入了一个新的工具:向量数据库。未展示的是监控系统,因为它与每个部分都交织在一起。所有这些都汇聚成我们在本书中努力实现的目标——一个部署服务,我们可以自信地将 LLMs 部署到生产环境中运行。

按学科划分的基础设施

以下列表按特定学科定义基础设施:

  • 开发运维(DevOps)——负责获取环境资源:实验性的(开发、预发布)和生产。这包括硬件、集群和网络,使一切都能正常工作。还负责基本基础设施系统,如 Github/Gitlab、工件注册库、容器注册库、Postgres 或 MySQL 等应用程序或事务数据库、缓存系统和 CI/CD 管道。这个列表绝不是详尽的。

  • 数据操作(DataOps)——负责数据,无论是运动中的还是静止的。它包括集中式或去中心化的数据存储,如数据仓库、数据湖和数据网格,以及数据管道,无论是批量系统还是带有 Kafka 和 Flink 等工具的流系统。它还包括 Airflow、Prefect 和 Mage 等编排器。DataOps 建立在 DevOps 之上。例如,我们看到了许多 CI/CD 管道被用于数据管道工作,直到最终过渡到 Apache Spark 或 DBT 等系统。

  • 机器学习操作(MLOps)——负责机器学习生命周期,从模型的创建到弃用。这包括数据科学工作站如 JupyterHub、实验跟踪器和模型注册。它还包括特征存储和向量数据库等专用数据库,以及一个部署服务来整合一切并实际提供结果。它建立在 DataOps 和 DevOps 之上。

让我们逐一分析基础设施拼图中的每一块,并讨论在考虑大型语言模型(LLMs)时应该考虑的功能。虽然我们将讨论每个部分的专用工具,但我们也会指出,还有像 Dataiku、亚马逊的 SageMaker、Azure 机器学习和谷歌的 VertexAI 这样的 MLOps 作为服务平台。这些平台试图完成整个拼图;它们完成得如何是另一个问题。然而,它们通常是一个很好的捷径,你应该了解它们。好吧,闲话少说;让我们直接进入正题吧!

3.4.1 数据基础设施

虽然这本书的重点不是 MLOps,但重要的是要注意,MLOps 建立在数据操作基础设施之上,而数据操作基础设施本身又建立在 DevOps 之上。数据操作生态系统的主要功能包括数据存储、协调器和管道。通常还需要包括容器注册中心和流集成服务。

数据存储是数据操作的基础,形式多样,从简单的数据库到大型数据仓库,再到更大的数据湖,甚至复杂的网络数据。这是你的数据存储的地方,大量的工作都投入到管理、治理和保障数据存储中。协调器是数据操作的基础,因为它是一个管理和自动化简单和复杂多步骤工作流程和任务的工具,确保它们在系统中的多个资源和服务上运行。最常讨论的是 Airflow、Prefect 和 Mage。最后,管道是支柱。它们支撑着一切,是我们运行作业的地方。最初是为了简单地移动、清理和定义数据而构建的,这些相同的系统现在被用来按计划运行机器学习训练作业,进行批量推理,以及确保 MLOps 顺利运行所需的大量其他工作。

容器注册库是 DevOps 和随后的数据操作和 MLOps 的基石。在容器中运行所有管道和服务是确保一致性的必要条件。流服务比我们在本章中提到的要大得多,如果你知道,你就知道了。幸运的是,对于大多数文本相关任务,实时处理并不是一个主要问题。即使是实时字幕或翻译这样的任务,我们通常也可以通过某种伪实时处理策略来应对,这不会根据任务降低用户体验。

3.4.2 实验跟踪器

实验跟踪器是 MLOps 的核心。实验跟踪器负责跟踪和记录测试和结果的基本工作。正如来自《迷雾探秘》的著名亚当·萨维奇名言所说:“记住,孩子们,玩耍和科学的唯一区别就是把它写下来。”没有它,你的组织可能缺少数据科学中的“科学”部分,这实际上相当尴尬。

即使你的数据科学家热衷于在笔记本中手动跟踪和记录结果,如果其他人难以查看和搜索,那么这些记录可能就直接被扔进垃圾桶了。这就是实验跟踪器的真正目的——确保知识能够轻松共享和获取。最终,一个模型将进入生产阶段,而这个模型可能会出现问题。当然,你可以总是训练一个新的模型,但除非团队能够回过头来调查第一次出错的原因,否则你很可能会一次又一次地重复同样的错误。

现在有许多实验跟踪器;最受欢迎的是开源的 MLFlow。它由 Databricks 团队发起,该团队还提供易于托管的服务。一些值得检查的付费替代方案包括 CometML 和 Weights & Biases。

现在的实验跟踪器带有许多额外的功能。大多数开源和付费解决方案在满足 LLMOps 扩展需求时都会提供你所需要的东西。然而,确保正确利用这些工具可能需要一些小的调整。例如,默认假设通常是你在从头开始训练模型,但通常在处理 LLMs 时,你将进行模型微调。在这种情况下,注意你开始时的模型检查点非常重要。如果可能的话,甚至可以链接回原始的训练实验。这将允许未来的科学家更深入地研究他们的测试结果,找到原始训练数据,并发现消除偏差的前进路径。

另一个需要注意的功能是评估指标工具。我们将在第四章中深入探讨,但评估指标对语言模型来说很难。通常会有多个你关心的指标,而且它们都不简单,比如复杂度评分或相似度得分。虽然实验跟踪器供应商试图对评估指标保持中立和无偏见,但他们至少应该使比较模型及其指标变得容易,以帮助我们决定哪个更好。由于大型语言模型(LLMs)变得如此流行,一些供应商已经使评估更常见的指标(如用于文本摘要的 ROUGE)变得容易。

你还会发现,许多实验跟踪器供应商已经开始添加专门针对 LLMs 的工具。你可能需要考虑的一些功能包括直接支持 Hugging Face、LangChain 支持、提示工程工具包、微调框架和基础模型商店。这个领域正在快速发展,目前没有哪个工具拥有所有相同的功能,但这些功能集可能会趋同。

3.4.3 模型注册库

模型注册库可能是 MLOps 基础设施中最简单的工具。主要目标是一个容易解决的问题;我们只需要一个存储模型的地方。我们见过许多成功的团队仅仅通过将模型放入对象存储或共享文件系统,并称之为完成。尽管如此,在选择时,你应该寻找一些额外的功能。

第一个问题是模型注册库是否跟踪模型的元数据。你关心的多数内容都会在实验跟踪器中,所以你通常可以确保能够将两者链接起来。事实上,大多数模型注册库都是由于这个原因集成到实验跟踪系统中的。然而,当公司决定使用开源模型或甚至购买模型时,这些系统会出现问题。上传模型并标记相关信息的操作容易吗?答案通常是否定的。

接下来,你想要确保你可以对模型进行版本控制。在某个时候,一个模型将不再有用,需要被替换。对模型进行版本控制将简化这个过程。它还使得运行生产实验,如 A/B 测试或影子测试,变得更加容易。

最后,如果我们正在推广和降级模型,我们需要关注访问权限。对于许多公司来说,模型往往是宝贵的知识产权,因此确保只有正确的用户能够访问模型是很重要的。但同样重要的是要确保只有理解模型——它们做什么以及为什么被训练——的团队负责推广和降级模型。我们最不希望看到的是在生产环境中删除模型,更糟糕的是。

对于 LLMs,有一些重要的注意事项你应该知道:主要是在选择模型注册表时,要注意任何限制大小。几个模型注册表将模型大小限制在 10 GB 或更小。这显然是不够的。我们可以推测出很多原因,但它们都不值得注意。说到限制大小,如果你打算在 Ceph 这样的本地存储系统上运行模型注册表,确保它有足够的空间。你可以在本地服务器上以几百美元的价格购买多个 TB 的存储,但即使只有几个 TB,当你的 LLM 超过 300 GB 时也会很快填满。别忘了:在训练和微调过程中,你很可能会保留多个检查点和版本,以及用于可靠性的副本。然而,存储仍然是运行 LLMs 成本最低的部分,所以在这里节省开支并导致未来头疼是没有理由的。

这让我想到了一个很好的观点:还有很多优化空间,可以采用更好的空间节省方法来存储 LLMs 及其导数,特别是考虑到这些模型在总体上非常相似。我们可能会在未来看到专门解决这个问题的存储解决方案。

3.4.4 特征存储

特征存储解决了许多重要问题,并回答了诸如“谁拥有这个特征?”“它是如何定义的?”“谁可以访问它?”“哪些模型在使用它?”“我们如何在生产中提供这个特征?”等问题。本质上,它们解决了“单一事实来源”的问题。创建一个集中存储库允许团队购买最高质量、最维护良好、管理最彻底的数据。特征存储解决了数据协作、文档和版本控制的问题。

如果你曾经想过,“特征存储只是一个数据库,对吧?”,你可能是在考虑错误类型的存储——我们指的是购物的地方,而不是存储的地方。不用担心:这种混淆是正常的,因为我们经常听到这种观点,我们自己也有过类似的思考。事实是,现代特征存储比物理数据库更虚拟,这意味着它们是建立在您已经使用的任何数据存储之上的。例如,Google 的 Vertex AI 特征存储只是 BigQuery,我们看到了很多数据团队对此感到困惑,想知道,“我们为什么不能直接查询 BigQuery?”将数据加载到特征存储中感觉像是一个不必要的额外步骤,但想想在宜家商店购物。没有人会直接去仓库,那里所有的家具都装在箱子里。那将是一次令人沮丧的购物体验。特征存储就像是展厅,允许公司内的其他人轻松浏览、体验和使用数据。

通常,我们看到人们会伸手去寻找特征存储来解决技术问题,比如在线特征服务的低延迟访问。特征存储的一个巨大优势是解决训练-服务偏差问题。有些特征在事后用 SQL 来做更容易,比如计算过去 30 秒内的平均请求数量。这可能导致为训练构建了简单数据管道,但在生产中却造成巨大头痛,因为实时获取此类特征并非易事。特征存储抽象有助于最小化这种负担。与此相关的是特征存储的实时检索,这是讨论特征存储时的基本要求。实时检索确保在给定特定时间时,查询将始终返回相同的结果。这很重要,因为像“过去 30 秒”的平均值这样的特征是不断变化的,因此这允许我们版本化数据(无需额外的版本化系统负担),同时确保我们的模型将给出准确和可预测的响应。

就选项而言,Feast 是一个流行的开源特征存储库。Featureform 和 Hopsworks 同样是开源的。这三个都提供了付费托管选项。对于大型语言模型(LLMs),我们听到了这样的观点:特征存储并不像 MLOps 基础设施的其他部分那样关键。毕竟,模型如此庞大,应该包含内部所需的所有特征,因此你不需要查询额外的上下文。只需给模型用户提供查询,让模型自行处理。然而,这种方法仍然有些天真,我们还没有达到 LLMs 完全自给自足的程度。为了避免幻觉并提高事实准确性,通常最好给模型提供一些上下文。我们通过给它我们希望它非常熟悉的文档嵌入来实现这一点,而特征存储是一个存放这些嵌入的绝佳地方。

3.4.5 向量数据库

如果你熟悉通用的 MLOps 基础设施,那么本节的大部分内容对你来说都是复习。我们只需进行一些小的调整,突出重要的扩展问题,以使系统适用于 LLMs。然而,向量数据库是新生事物,它们被开发出来是为了与 LLMs 和语言模型等特定场景下的工作,但你也可以使用它们来处理其他数据集,如图像或表格数据,这些数据足够容易转换为向量。向量数据库是专门存储向量及其相关元数据的数据库,这使得它们非常适合存储嵌入。现在,尽管最后一句话是真实的,但它有点误导,因为向量数据库的力量不在于它们的存储方式,而在于它们搜索数据的方式。

传统数据库,无论是使用 b 树索引来查找 ID 还是使用反向索引进行基于文本的搜索,都存在一个共同的缺陷:你必须知道你在寻找什么。如果你没有 ID 或者不知道关键词,就找不到正确的行或文档。然而,向量数据库利用了向量空间,这意味着你不需要确切知道你在寻找什么;你只需要知道一些类似的东西,然后你可以使用它来通过基于欧几里得距离、余弦相似度、点积相似度等方式进行相似度搜索来找到最近的邻居。例如,使用向量数据库使得解决反向图像搜索问题变得轻而易举。

在这一点上,一些读者可能会感到困惑。首先,我们告诉你要将你的嵌入放入特征存储中,而现在我们告诉你要将它们放入向量数据库中。哪一个才是正确的?好吧,这正是它的美妙之处:你可以同时做这两件事。如果之前这听起来没有道理,我们希望现在它变得有道理了。特征存储不是一个数据库;它只是一个抽象。你可以在向量数据库之上构建一个特征存储,这将解决你许多问题。当你有多个数据源、正在尝试不同的嵌入模型或频繁更新数据时,向量数据库的维护可能会变得困难。管理这种复杂性可能真的让人头疼,但特征存储可以轻松解决这个问题。将它们结合起来将确保搜索索引更加准确和及时。

向量数据库在撰写本文时仅存在了几年,它们的流行度仍然相对较新,因为它们与大型语言模型(LLMs)的发展紧密相连。很容易理解这一点,因为它们提供了一种快速高效的方式来检索向量数据,这使得为 LLMs 提供所需上下文以提升其准确度变得简单。

话虽如此,这是一个相对较新的领域,目前这个领域有很多竞争者。现在还太早知道谁是赢家和输家。不想让这本书过于陈旧,至少建议两种开始的选择:Pinecone 和 Milvus。Pinecone 是作为产品推出的第一个向量数据库之一,拥有一个充满活力的社区和大量的文档。它功能丰富,并已证明其可扩展性。Pinecone 提供全托管基础设施服务,为初学者提供免费层。然而,如果您是开源的粉丝,那么您可能想看看 Milvus。Milvus 功能丰富,拥有一个优秀的社区。Milvus 背后的公司 Zilliz 提供全托管服务,但也可在您自己的集群中部署。如果您已经有一些基础设施经验,那么在您自己的集群中部署相对简单直接。

目前有很多替代方案,在选择之前进行一些调查可能是值得的。您最关心的可能是价格和可扩展性,因为这两者通常相辅相成。之后,关注搜索功能也很重要,例如支持不同的相似度度量,如余弦相似度、点积和欧几里得距离,以及索引功能,如层次可导航小世界(HNSW)和局部敏感哈希(LSH)。对于任何数据库来说,能够自定义搜索参数和索引设置都很重要,因为您可以根据您的数据集和工作流程自定义工作负载,从而优化查询延迟和搜索结果准确性。

还需要注意的是,随着向量数据库的兴起,我们很快看到许多数据库巨头如 Redis 和 Elastic 提供了向量搜索功能。目前,这些数据库大多提供最直接的功能集,但如果您已经使用这些工具集,它们很难忽视,因为它们可以提供快速的成功,帮助您快速入门。

向量数据库是强大的工具,可以帮助您训练或微调 LLM,以及提高您 LLM 查询的准确性和结果。

3.4.6 监控系统

监控系统对于任何机器学习系统(包括大型语言模型 LLM)的成功至关重要。与其他软件应用不同,机器学习模型通常以静默方式失败——也就是说,它们继续运行但开始产生较差的结果。这通常是由于数据漂移,一个常见的例子是推荐系统随着时间的推移给出更差的结果,因为卖家开始通过提供虚假评论来操纵系统以获得更好的推荐结果。监控系统使我们能够捕捉到表现不佳的模型并进行调整,或者简单地重新训练它们。

尽管监控系统很重要,但它们往往是最后一块拼图。这通常是有意为之的,因为如果没有任何模型可以监控,那么投入资源去研究如何监控模型是没有帮助的。然而,不要犯拖延太久的错误。许多公司因为一个无人知晓的模型而遭受损失,这往往代价高昂。重要的是要意识到,您不必等到模型投入生产才开始监控数据。有许多方法可以将监控系统集成到训练和数据管道中,以改善数据治理和合规性。无论如何,您通常可以通过监控系统来判断一个数据科学组织的成熟度。

目前市面上有很多优秀的监控工具;一些优秀的开源选项包括 whylogs 和 Evidently AI。我们也是 Great Expectations 的粉丝,但发现在批量作业之外它相对较慢。还有许多付费选项可供选择。通常,对于机器学习监控工作负载,您希望监控您在其他软件应用程序中通常记录的所有内容;这包括资源指标,如内存和 CPU 利用率,性能指标,如延迟和每秒查询数,以及操作指标,如状态码和错误率。此外,您还需要方法来监控数据在模型中进出的漂移。您需要关注诸如缺失值、唯一性和标准差变化等问题。在许多情况下,您希望在监控数据时能够对数据进行分段——例如,用于 A/B 测试或按地区监控。在机器学习系统中,一些有用的监控指标包括模型准确率、精确率、召回率和 F1 分数。这些指标很难评估,因为在推理时间您不会知道正确答案,因此设置某种形式的审计系统通常很有帮助。当然,如果您的语言模型(LLM)被设计成问答机器人而不是帮助作家更具创造性的工具,审计将更容易进行。

这暗示了您的监控系统将面临一系列新的挑战,甚至比我们看到的其他机器学习系统还要多。对于 LLM,我们正在处理文本数据,正如本章前面所讨论的,这很难量化。例如,考虑您用于监控数据漂移的特征,因为众所周知,语言会漂移很多!我们建议的一个特征是唯一标记。当出现新的俚语或术语时,这将提醒您;然而,当“wicked”意味着“酷”时,这并不能帮助您,因为这时词语的含义发生了变化。我们还会建议监控嵌入;然而,您可能会发现这要么会增加很多噪音和误报,要么至少在问题发生时难以解析和深入挖掘。表现最好的系统通常涉及大量手工制作的规则和特征来监控,但这些可能容易出错,且创建起来耗时费力。

监控基于文本的系统远非一个已解决的问题,这主要源于一开始就难以理解文本数据。这引发了一个问题,即使用语言模型来自我监控的最佳方法是什么,因为它们是我们当前编码语言的最好解决方案。不幸的是,我们不知道有人在研究这个问题,但我们想象这只是一个时间问题。

3.4.7 具备 GPU 功能的工作站

具备 GPU 功能的工作站和远程工作站通常被许多团队视为一种美好的愿望或奢侈品,但当你与 LLMs 一起工作时,这种心态必须改变。当解决问题或一般开发模型时,数据科学家不再能够在他们的笔记本电脑上的笔记本中启动模型。解决这个问题的最简单方法就是提供带有 GPU 资源的远程工作站。为此有很多云解决方案,但如果你的公司主要在本地工作,这可能要困难一些,但无论如何都是必要的。

大型语言模型(LLMs)对 GPU 内存的需求很大。因此,在处理这个领域的工作时,每个工程师都应该了解一些数字。首先是要拥有多少个 GPU。NVIDIA Tesla T4 和 V100 是在数据中心中最常见的两种 GPU,但它们只有 16 GB 的内存。尽管如此,它们是工作马,而且性价比高,所以如果我们能将这些模型压缩到这些 GPU 上运行,那就更好了。在这些之后,你会看到一系列的 GPU,如 NVIDIA A10G、NVIDIA Quadro 系列和 NVIDIA RTX 系列,它们提供的 GPU 内存范围在 24、32 和 48 GB 之间。所有这些都是很好的升级;你只需要确定你的云服务提供商提供了哪些,并且这些是否可供你使用。这让我们来到了 NVIDIA A100,当你与 LLMs 一起工作时,它很可能是你的首选 GPU。幸运的是,它们相对常见,提供两种不同型号,分别提供 40 或 80 GB 的内存。这些 GPU 的一个大问题是它们目前的需求量非常大。你还应该了解 NVIDIA H100,它提供 80 GB 的内存,就像 A100 一样。H100 NVL 承诺支持高达 188 GB,并且是为 LLMs 设计的。另一个你应该了解的新 GPU 是 NVIDIA L4 Tensor Core GPU,它有 24 GB 的内存,并定位为 T4 和 V100 之后的新的工作马,至少在 AI 工作负载方面是这样的。

LLMs(大型语言模型)的大小各不相同,了解这些数字的含义很有用。例如,LLaMA 模型有 7B、13B、33B 和 65B 参数变体。如果你不确定需要哪种 GPU 来运行哪种模型,这里有一个快捷方法:将参数的十亿数量乘以 2,这就是你需要的 GPU 内存量。原因是大多数模型在推理时默认以半精度运行,即 FP16 或 BF16,这意味着每个参数至少需要 2 个字节。例如,70 亿 × 2 字节 = 14 GB。你还需要额外的空间来存储嵌入模型,这大约还需要另一个 GB,以及更多用于模型中实际运行的标记。一个标记大约是 1 MB,所以 512 个标记将需要 512 MB。这并不是什么大问题,直到你考虑运行更大的批量大小以提高性能。对于 16 个这样的批量,你将需要额外的 8 GB 空间。

当然,到目前为止,我们只讨论了推理;对于训练,你需要更多的空间。在训练过程中,你总是希望以全精度运行,并且你需要额外的空间来存储优化器张量和梯度。一般来说,为了考虑到这一点,你需要为每个参数大约 16 个字节。所以,要训练一个 70 亿参数的模型,你将需要 112 GB 的内存。

3.4.8 部署服务

我们一直努力的方向最终都汇集在这里,并得到了良好的应用。事实上,如果你移除所有其他服务,只留下部署服务,你仍然会拥有一个工作的 MLOps 系统。部署服务提供了一个简单的方法来集成我们之前讨论的所有系统,并配置和定义所需的资源,以便我们的模型在生产环境中运行。它通常会提供一些模板代码,用于在 REST 和 gRPC API 后面或直接在批量或流式处理管道中提供服务模型。

一些有助于创建此服务的工具包括 NVIDIA Triton 推理服务、MLServer、Seldon 和 BentoML。这些服务提供了一个标准的 API 接口,通常是 KServe V2 推理协议。该协议提供了一种统一且可扩展的方式来在不同平台和框架上部署、管理和提供机器学习模型。它定义了一个通用的接口来与模型交互,包括 gRPC 和 HTTP/RESTful API。它标准化了诸如输入/输出张量数据编码、预测和解释方法、模型健康检查和元数据检索等概念。它还允许与包括 TensorFlow、PyTorch、ONNX、Scikit Learn 和 XGBoost 在内的语言和框架无缝集成。

当然,有时灵活性和定制化提供的价值足以让我们从这些其他框架提供的自动化路径中退出,在这种情况下,最好求助于像 FastAPI 这样的工具。您的部署服务仍然应该尽可能提供自动化和样板代码,以使过程尽可能顺利。应指出的是,大多数之前提到的框架确实提供了自定义方法,但您的使用效果可能会有所不同。

部署一个模型不仅仅是构建接口。您的部署服务还将提供一座桥梁,弥合 MLOps 基础设施和通用 DevOps 基础设施之间的差距。连接到公司设置的任何 CI/CD 工具和构建及发货管道,以确保适当的测试和部署策略,如健康检查和回滚可以轻松监控和执行。这通常非常平台和公司特定。它还必须提供所需的配置,以便与 Kubernetes 或其他您可能使用的容器编排器通信,以获取所需的资源,如 CPU、内存、加速器、自动扩展器、代理等。它还应用了所需的环境变量和秘密管理工具,以确保一切正常运行。

总的来说,这项服务确保您可以轻松地将模型部署到生产环境中。对于 LLMs 来说,主要关注点通常是确保平台和集群配置了足够的资源,以便最终配置所需的内容。

在本章中,我们已经讨论了很多内容,从 LLMs 为什么比传统的 ML 更难,这本身就足够困难。首先,我们了解到它们的体积不容小觑,但随后我们也发现了许多关于它们的奇特之处,从令牌限制到幻觉——更不用说它们很昂贵了。幸运的是,尽管困难,但并非不可能。我们讨论了压缩技术和分布式计算,这些都是必须掌握的关键。然后我们探讨了使 LLMs 工作所需的基础设施。虽然其中大部分可能是熟悉的,但我们意识到 LLMs 对每个工具都施加了不同级别的压力,而且我们通常需要为比部署其他 ML 模型更大的规模做好准备。

摘要

  • LLMs(大型语言模型)难以处理主要是因为它们体积庞大。这导致下载、加载到内存和部署的时间更长,迫使我们使用昂贵的资源。

  • LLMs 之所以难以处理,还因为它们处理自然语言及其所有复杂性,包括幻觉、偏见、伦理和安全问题。

  • 无论您是构建还是购买,LLMs 都很昂贵,管理与之相关的成本和风险对于任何利用它们的项目的成功至关重要。

  • 将模型压缩到尽可能小,使它们更容易处理;量化、剪枝和知识蒸馏在这方面特别有用。

  • 量化因其易于实现且可以在训练后进行,无需微调而受到欢迎。

  • 低秩逼近是缩小模型的有效方法,由于 LoRA 的广泛应用,它已被大量用于适应。

  • 我们使用三个核心方向来并行化 LLM 工作流程:数据、张量和管道。DP 帮助我们提高吞吐量,TP 帮助我们提高速度,而 PP 使得所有这一切都成为可能。

  • 结合并行化方法,我们得到了 3D 并行性(数据 + 张量 + 管道),我们发现这些技术协同作用,弥补彼此的不足,帮助我们获得更高的利用率。

  • LLMOps 的基础设施与 MLOps 相似,但不要因此被误导,因为有很多情况下“足够好”不再适用。

  • 许多工具已经开始提供专门针对 LLM 支持的新功能。

  • 向量数据库,尤其是,作为 LLMs 所需的基础设施拼图中的一块新部件,对于快速搜索和检索嵌入体来说非常有趣。

[1] A. Bulatov, Y. Kuratov, and M. S. Burtsev, “Scaling transformer to 1M tokens and beyond with RMT,” April 2023, arxiv.org/abs/2304.11062.

[2] R. Daws, “Medical chatbot using OpenAI’s GPT-3 told a fake patient to kill themselves,” AI News, October 28, 2020, mng.bz/qO6z.

[3] T. Kington, “ChatGPT bot tricked into giving bomb-making instructions, say developers,” The Times, December 17, 2022, mng.bz/7d64.

[4] K. Quach, “AI game bans players for NSFW stories it generated itself,” The Register, October 8, 2021, www.theregister.com/2021/10/08/ai_game_abuse/.

[5] T. Hoefler, D. Alistarh, T. Ben-Nun, N. Dryden, and A. Peste, “Sparsity in deep learning: Pruning and growth for efficient inference and training in neural networks,” January 2021, arxiv.org/abs/2102.00554.

[6] E. Frantar and D. Alistarh, “SparseGPT: Massive Language models can be accurately pruned in one-shot,” January 2023, arxiv.org/abs/2301.00774.

[7] V. Sanh, L. Debut, J. Chaumond, and T. Wolf, “DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter,” October 2019, arxiv.org/abs/1910.01108.

[8] R. Taori, I. Gulrajani, T. Zhang, Y. Dubois, X. Li, C. Guestrin, P Liang, and T. B. Hashimoto, “Alpaca: A strong, replicable instruction-following model,” CRFM, 2023, crfm.stanford.edu/2023/03/13/alpaca.xhtml.

[9] E. J. Hu 等人,“LoRA: 大型语言模型的低秩自适应。”,2021 年 6 月,arxiv.org/abs/2106.09685.

[10] R. Henry 和 Y. J. Kim,“通过低比特量化加速大型语言模型,”2023 年 3 月,mng.bz/maD0.

[11] V. Korthikanti 等人,“在大型 Transformer 模型中减少激活重计算,”2022 年 5 月,arxiv.org/abs/2205.05198.

[12] A. Harlap 等人,“PipeDream: 快速高效的管道并行 DNN 训练,”2018 年 6 月 8 日,arxiv.org/abs/1806.03377.

第四章:大型语言模型的数据工程:为成功做好准备

本章涵盖了

  • 行业中常用的基础模型

  • 如何评估和比较大型语言模型

  • 不同的数据源以及如何准备你自己的

  • 创建你自己的自定义分词器和嵌入

  • 准备用于未来章节的 Slack 数据集

数据就像垃圾。在收集它之前,你最好知道你打算如何处理它。——马克·吐温

创建我们自己的 LLM 与任何 ML 项目并无不同,我们首先需要准备我们的资产——没有比你的数据更宝贵的资产了。所有成功的 AI 和 ML 项目都是建立在良好的数据工程基础之上的。因此,我们获取、清理、准备和整理我们的数据是非常重要的。

与其他 ML 模型不同,在创建针对特定任务定制的 LLM 时,你通常不会从零开始。当然,如果你确实是从零开始,你可能只会这样做一次。然后,最好调整和润色该模型,以进一步满足你的特定需求。选择正确的基模型可能会使你的项目成功或失败。图 4.1 给出了在训练或微调新模型之前需要准备的不同组件和资产的高级概述。

figure

图 4.1 训练 LLM 的不同元素。结合地球、火、水——等等,不,不是那些元素。要开始,你需要收集几个资产,包括基础模型、训练数据、文本编码器(例如,分词器)和评估数据。

正如书中《数据工程基础》¹所如此明确地定义的:

数据工程是开发、实施和维护系统及流程的过程,这些系统及流程接收原始数据并产生高质量、一致的信息,以支持下游用例,如分析和机器学习。

在本章中,我们将讨论在你开始创建 LLM 之前需要采取的步骤,这主要涉及准备训练模型所需的数据资产。我们将讨论许多可用的基或基础模型作为起点,以及如何评估和比较它们。然后,我们将深入探讨许多不同的数据集以及如何为微调模型准备你自己的数据集,包括准备你自己的分词器或嵌入。最后,我们将构建一个数据集,我们将在下一章中使用它来微调模型。

4.1 模型是基础

我们将首先讨论你在训练时需要收集的最重要数据集,即预训练模型的模型权重。LLM 作为一项技术之所以如此成功,一个重要原因是我们可以将已经在整个语言上训练好的模型进行调整,以在特定任务上表现良好。当然,了解那个初始模型是如何训练的以及它是基于什么进行训练的,将大大缩短选择正确模型进行调整的时间。

由于大型语言模型(LLM)已成为热门的研究课题,选择正确的一个变得异常困难,几乎每周都会出现一个新的模型,打破了基准记录。因为我们知道(或者至少假设)你急于了解它们,所以我们首先讨论目前市场上存在的许多不同模型。这些模型已经被专业人士训练(无论好坏),旨在使你的生活更轻松,并将强大的语言模型推向公众领域。GitHub、Hugging Face Hub 和其他地方有数以千计的开源模型可供使用,为了简化,我们将突出我们最喜欢的模型,并详细介绍每个模型,以便更容易进行比较,并给你一个关于你是否应该使用该特定模型或选择其不太知名的开放源代码变体的想法。如果你计划从头开始训练,考虑涉及的架构,以及你是否想尝试某个特定的系列。

4.1.1 GPT

没有比从 GPT(生成式预训练 Transformer)模型开始更好的地方了。这些模型是粉丝和我们的最爱之一,通过 OpenAI 进行商业销售,因其广泛的任务上的出色表现而受到欢迎。GPT 模型如此知名,以至于外行人经常用“GPT”来代替“LLM”,就像人们可能会用 Kleenex 或 Band-Aid 来代替纸巾或绷带一样。

第一个 GPT 模型于 2018 年推出,紧随 transformers 的推出之后,只有 1.2 亿个参数。它在 BookCorpus 小型数据集上进行了训练,在当时的 NLP 基准测试中取得了令人印象深刻的结果。GPT-2 模型在第二年推出,其规模扩大了 10 倍,达到 15 亿个参数;它在更大的 WebText 数据集上进行了训练。到了 2020 年,GPT-3 推出,其规模扩大了 100 倍,达到 1750 亿个参数,并在庞大的 Common Crawl 数据集上进行了训练。这个模型仍然基于 GPT-1 的原始架构,并进行了轻微的修改以改进扩展性。

OpenAI 选择对 GPT-4 等后续迭代版本保持更高的保密性,不透露训练数据或具体架构,因为它们已经开始量产并作为产品出售。ChatGPT 是一个经过微调的 GPT-3 模型,通过使用带有人类反馈的强化学习(RLHF)进行对话交互训练。不过不深入探讨,但你可以找到一系列 GPT-3 模型,这些模型在 API 名称下如 ada、babbage、curie 和 davinci,以及其他微调模型如 webGPT 和 InstructGPT。如果读者感兴趣,我们可以留待进一步调查。

其他开源变体,如 GPT-J,是由开源社区利用 OpenAI 发布的论文中获得的知识创建的。一些 GPT 模型与 OpenAI 没有关系,因为生成预训练转换器是一个非常通用的名称,适用于大多数 LLM。当然,OpenAI 已经开始将其视为一个品牌,并试图将缩写词注册商标。²

尽管 GPT-X 模型是闭源的,但可以通过 OpenAI API 访问,该 API 还包括其微调的功能。我们将在这本书中使用 GPT-2——尽管它比大多数人认为的实际 LLM 要小一些——因为它是一个易于理解的架构,并且易于学习。

4.1.2 BLOOM

BLOOM 是最具标志性的 LLM 之一,因为它在创建过程中所获得的学习成果。该模型于 2022 年发布,是第一个公开的 LLM,其参数量达到 176B,与 GPT-3 的规模相媲美;它以完全透明的方式进行训练。该模型由 Hugging Face 的 BigScience 团队制作,得到了微软的 DeepSpeed 团队和 NVIDIA 的 Megatron-LM 团队的协助,并由法国政府拨款赞助。

BLOOM 在 BigScienceCorpus 数据集上进行训练,这是一个由许多较小的数据集组成的综合体,总容量达到 1.6TB 的预处理文本。它受 RAIL 许可证约束,这意味着它技术上不是开源的,因为对其使用有约束,但它可以进行商业化。

TIP 在这里可以了解更多关于 RAIL 许可证的信息:mng.bz/mR20

BLOOM 被训练为适用于所有任务的行业规模和行业级别。因此,适应消费设备不是优先事项,但随着研究团队的速度加快,已经训练了几个较小的版本。有 560M-、3B-和 7B 参数版本。还有一个 BLOOMZ,它是完整 176B 参数模型的多元任务微调版本。BLOOM 只在 46 种不同的语言上进行训练,而 BLOOMZ 的目标是提高模型的跨语言泛化能力。³ 你可以在 Hugging Face 的 hub 上找到所有这些模型:huggingface.co/bigscience/bloom

BLOOM 的一个重大缺点是它经常给出较差的响应,在基准测试中竞争不佳——这很可能是由于项目资金有限和截止日期紧迫,导致给人一种它训练不足的感觉。这并不总是坏事,通常比过训练的模型要好,但如果你决定使用它,你可能会需要在大数据集上进行更多的微调。然而,使用它的好处是它被广泛理解和公开训练,你可以检查其训练数据。

通常,作者不会推荐再将其作为基础模型使用;有更好的替代品,但你应该熟悉它,因为它对领域做出了贡献。例如,BLOOM 创建花瓣,允许分布式训练,这对领域是一个重大贡献。

4.1.3 LLaMA

LLaMA 是 Meta 进入 LLM 领域的成果。第一个版本于 2023 年 2 月发布,并以非商业许可发布给研究社区。一周后,权重在 4chan 上泄露。在一系列不太可能的事件中,这次泄露可能对 Meta 非常有益,因为这个模型已经成为实验和开发的行业标准。我们将讨论的几个更多模型都是基于它的。

后来,在 2023 年 7 月,Meta 发布了 Llama 2,它同时拥有研究和商业许可。Llama 2 是一个大事件,因为它是第一个真正具有强大功能的商业可用模型,你将看到基于其架构的许多其他模型。有三种不同的模型大小可供选择:7B、13B 和 70B 参数。你可以从这里下载它们:ai.meta.com/llama/。如果你打算使用它,你需要申请访问权限并接受条款和条件。

Llama 2 是在从互联网上收集的经过精心挑选的数据集上训练的,其中他们移除了已知包含个人信息的网站,并增加了他们认为的事实来源。尽管数据集的详细情况尚未公开,但它很可能包含了来自 Common Crawl、GitHub、Wikipedia、Project Gutenberg、ArXiv 和 Stack Exchange 的数据,因为这些都是 LLaMA 1 的主要数据集。这些数据集后来被包装在一起,并以 RedPajama 的名义分发。然后,Llama 2 使用 RLHF 进一步微调,一个模型用于聊天,另一个用于代码。

4.1.4 巫师

巫师系列语言模型来自 2023 年的论文“WizardLM:赋予大型语言模型遵循复杂指令的能力”。⁴ 这些模型遵循的想法是,当在充满高复杂度任务的密集训练数据上训练时,LLM 的表现更好。基于创建更复杂指令任务的框架,巫师 LM 方法已应用于许多流行的数据集,并用于微调几乎所有最流行的模型。这种方法如此受欢迎,以至于令人惊讶的是,在 LlamaCoder34B 发布后的两天内,社区就微调了 WizardCoder34B 模型。

这些模型因其类似人类的散文风格和正确处理复杂问题的能力而一直受到好评,这些能力甚至可以与许多付费服务相媲美。我们鼓励你尝试的一个问题是要求 WizardCoder34B 用你喜欢的任何语言编写一个绘制看起来逼真的树的程序。由于巫师模型不像它们围绕特定数据集那样围绕将现有数据集转换为巫师风格的方法,因此应用范围极其广泛且多样化。如果你在使用其他模型或架构时遇到瓶颈,不确定如何改进,请尝试使用你已经使用过的数据集并应用巫师方法。欢迎你这样做。

作为附带说明,WizardCoder 模型通常受到很多关注,但 WizardMath 模型在自身领域也非常令人印象深刻。我们注意到,许多读者可能更多地处理数据问题而不是代码问题,因此当处理与数据对话的应用程序时,WizardMath 模型可能是一个很好的起点。

4.1.5 鹰隼

鹰隼模型是阿布扎比科技创新研究所的一个模型系列。它们是第一个在真正开源许可 Apache 2.0 下发布的尖端模型。您可以从研究所的网站获取模型:falconllm.tii.ae/falcon-models.xhtml。其易于访问和开源许可使得这对于黑客、实践者和行业来说都是一个梦想。

鹰隼模型首次于 2023 年 6 月推出,当时只推出了 7B 和 40B 参数模型,但到了 2023 年 9 月,鹰隼发布了一个 180B 参数模型,可以真正与 GPT-3 大小的模型竞争。对许多读者来说,更令人兴奋且可能更重要的是,鹰隼在许多基准测试任务中经常领先于 LLM 排行榜。这些模型主要在 RefinedWeb 数据集上训练,这是一个较小但质量更高的数据集,它是经过精心和细致的策划和从 Common Crawl 数据集中提取出来的。

4.1.6 维库纳

维库纳是在 ShareGPT 用户共享对话数据集上训练的。其逻辑是,在 ChatGPT 最佳输出基础上训练的模型将能够模仿 ChatGPT 的性能,借助于 Llama-Alpaca 的趋势。

备注:这里不会讨论 Alpaca,但在第三章讨论知识蒸馏时我们已经介绍过它。

维库纳因其性能和相对较低的训练成本而受到赞誉。维库纳是数据覆盖率和质量为何如此重要的绝佳例子,同时它也展示了从另一个模型的输出中训练模型导致模型崩溃的危险。当机器学习模型在合成数据上训练时,会导致输出越来越不多样化,这种现象称为模型崩溃。例如,维库纳在处理至少接近数据集中出现的内容时表现优秀,但当被要求执行更生成性或代理类任务时,它往往会产生远超其前辈的幻觉。维库纳未经许可不得用于商业用途,但对于个人项目来说非常出色。

4.1.7 多莉

Dolly 是由 Databricks 创建的,与其说是竞争性模型,不如说是更多的一次思想实验,与相同大小的其他模型相比,Dolly 及其 V2 版本的表现并不出色。然而,Dolly 在对英语的底层理解方面堪称最佳,是微调或创建低排名适应(LoRAs;我们将在第五章中讨论)以影响其他模型的绝佳起点。Dolly 1.0 是在斯坦福 Alpaca 数据集上训练的,而 Dolly 2.0 是在由 Databricks 员工众包的高质量人类生成指令遵循数据集上训练的。Dolly 2.0 已经完全开源,包括训练代码、数据集和模型权重,所有这些均带有商业用途许可证。⁵

4.1.8 OpenChat

OpenChat 与 Vicuna 类似,因为 OpenChat 使用了 80K ShareGPT 对话进行训练,但在条件化和加权损失策略方面有所不同,最终创建了一个在生成类似人类和,更重要的是,人类更喜欢的响应方面无可否认的出色的模型。

OpenChat 模型——不要与开源聊天机器人控制台混淆——是为不同任务而收集的各种微调集合,其中一些用于编码,其他用于代理,还有一些用于聊天。在 Llama 2 社区许可证下免费用于商业用途,这些模型可以成为您公司构建的绝佳解决方案。

我们已经讨论了很多模型,虽然我们可以继续这样讨论整个章节,但这对大家来说都不是最好的选择。表 4.1 展示了一个总结,突出了我们讨论的模型的一些主要比较点。我们想强调的一个主要点是,许多模型都可用于商业用途!虽然许多许可证都有限制,但它们可能不是你打算违反的规则。

表 4.1 LLM 模型家族比较
模型家族 数据集 最大模型大小 商业许可证 组织
GPT Common Crawl/RLHF 1.76T OpenAI
BLOOM BigScienceCorpus 176B BigScience
Llama RedPajama 70B Meta
Wizard Evol-Instruct 70B Microsoft
Falcon RefinedWeb 180B TII

现在你已经了解了一些更受欢迎的模型家族,你可能已经对为你的项目选择哪个模型有了一些想法。但你怎么能确定呢?在下一节中,我们将探讨不同的评估和比较模型的方法。

4.2 评估大型语言模型

尽管我们刚刚讨论了一些我们最喜欢的模型家族,但还有许多其他不同的模型可供选择,每个月都有更多的新模型出现,所有这些模型都声称自己是最好的。不可能将它们全部理清楚。那么,你如何选择最好的一个来使用?它是否能够直接在你的任务上表现良好,或者是否需要微调?你如何知道你的微调是否提高了模型,或者只是让它变得更糟?你如何知道你选择了正确的大小?较小的模型很方便,但较大的模型在许多任务上的表现更好。说实话,这些问题并不容易回答,但幸运的是,有一些行业标准我们可以依赖。

当评估一个模型时,你需要两样东西:一个指标和一个数据集。指标是一个算法,它允许我们将结果与真实情况进行比较。数据集是我们希望模型执行的任务列表,然后我们将使用我们选择的指标进行比较。

在本节中,我们将讨论许多用于评估大型语言模型的不同方法,以便我们可以客观地评估和比较它们。我们将从常见的行业基准到开发你自己的独特评估方法的一切内容进行讨论。让我们开始吧。

4.2.1 评估文本的指标

评估文本通常很困难,因为用两种不同的方式说出相同的话很容易。从语义上讲,两个句子可能是完全相同的,但从句法上讲,它们却毫无相似之处,这使得文本比较变得复杂。看看我做了什么?

为了评估我们的模型,我们需要比仅仅匹配或检查相等性更好的指标,这对于大多数其他机器学习问题来说是可以做到的。我们需要一个指标,它允许我们比较模型生成的文本与真实情况,而不会过于严格。让我们看看一些最常用的指标。

ROUGE

ROUGE,即“面向检索的摘要评估的辅助研究”,是用于评估机器翻译任务的最古老的指标之一,但仍然是最可靠的。它是专门为自动摘要任务开发的,其目标是把一长篇文章总结成简短的摘要。让我们考虑一下这个问题:你如何确定一个摘要是否正确?最简单的方法就是将其与一个已知的摘要——即“真实情况”——进行比较。然而,无论文章如何,通常都有成千上万种方法可以用来简化文本,使其更加简洁,你不想仅仅因为模型选择了与真实情况不同的词序就惩罚它;这只会导致过度拟合。

Rouge 不将生成的摘要与真实摘要进行比较,期望得到完全匹配;相反,它通过 N-gram 在两个摘要之间寻找重叠——重叠越大,分数越高。这与全文搜索引擎的工作方式类似。根据 N-gram 中的 N 值,有多种变体,但还有比较最长公共子序列的版本,以及比较跳过双词(skip-bigrams)的版本,跳过双词是指句子中任何两个按顺序排列的词,而不一定是紧挨着的。

ROUGE 的原始实现是用 Perl 编写的,我们记得甚至在几年前还不得不使用它。对于一位作者来说,一些最糟糕的日子之一就是不得不在 Perl 中工作。幸运的是,在去年或更早的时候,终于有了快速、稳定的 Python 重实现。在下一个列表中,我们使用 rouge-score 库,这是 Google 的一个重实现。我们将比较两个关于《塞尔达传说》的解释,看看它们如何比较。

列表 4.1 使用 ROUGE
from rouge_score import rouge_scorer

target = "The game 'The Legend of Zelda' follows the adventures of the \
    hero Link in the magical world of Hyrule."
prediction = "Link embarks on epic quests and battles evil forces to \
    save Princess Zelda and restore peace in the land of Hyrule."

scorer = rouge_scorer.RougeScorer(["rouge1", "rougeL"], use_stemmer=True)   #1
scores = scorer.score(target, prediction)
print(scores)
# {'rouge1': Score(precision=0.28571428, recall=0.31578947, fmeasure=0.3),
# 'rougeL': Score(precision=0.238095238, recall=0.26315789, fmeasure=0.25)}

1 示例 N-gram,其中 N=1,并使用最长公共子序列

如您从示例中可以看到,尽管这两段文本在语法上相当不同,但它们都是准确的描述。正因为如此,ROUGE(Recall-Oriented Understudy for Gisting Evaluation)算法不会给出一个大的零分,而是给予一定的灵活性,并提供相似度分数约为 0.25 的更好比较。ROUGE 算法是一种快速有效的比较两段短文本相似度的方法。在业界,ROUGE 非常常见,许多基准测试都将它作为其指标之一。

BLEU

BLEU(BiLingual Evaluation Understudy),代表双语评估助手,是我们将在本书中讨论的最古老的评估指标。它被开发出来用于评估机器翻译任务,并比较将一种语言翻译成另一种语言的方法。它与 ROUGE 非常相似,我们在目标和预测之间比较 N-gram。虽然 ROUGE 主要是一个召回率指标,但 BLEU 是一个精确度指标,但使用标准精确度可能会带来一些我们需要考虑的问题。

要理解这个问题,我们可以使用列表 4.1 中的代码来计算标准精确度。将目标变量替换为“the cat in the hat”,将预测变量替换为“cat hat”。重新运行列表,您会注意到召回率为 0.4——我们只正确地识别了五个词中的两个——但精确度为 1.0,这是一个完美的分数,尽管表现并不好!这个结果是因为目标中的两个词“cat”和“hat”都出现了。

BLEU 通过添加两个调整来解决这一点。第一个是直接的:添加简洁性惩罚。如果预测比目标短,我们将对其进行惩罚。第二个调整,称为修改后的 N-gram 精确度,稍微复杂一些,但它允许我们比较预测与多个目标。下面的列表展示了如何使用 NLTK 库来计算 BLEU 分数。我们使用与 ROUGE 相同的Zelda示例,这样你可以比较结果。

列表 4.2 使用 BLEU
import nltk.translate.bleu_score as bleu

target = [
    "The game 'The Legend of Zelda' follows the adventures of the \
    hero Link in the magical world of Hyrule.".split(),
    "Link goes on awesome quests and battles evil forces to \
    save Princess Zelda and restore peace to Hyrule.".split(),
]
prediction = "Link embarks on epic quests and battles evil forces to \
    save Princess Zelda and restore peace in the land of Hyrule.".split()

score = bleu.sentence_bleu(target, prediction)
print(score)
# 0.6187934993051339

BLEU 长期以来一直是行业标准,因为它多次报道与人类在翻译任务上的判断有很好的相关性。在我们的例子中,我们拆分了句子,但最好是将句子进行分词。当然,你不能比较使用不同分词器的 BLEU 分数。在这方面,SacreBLEU 是一个值得关注的变体,因为它试图提高不同分词器分数的可比性。

BPC

每字符比特(BPC)评估是语言模型基于熵的评估的一个例子。这是我们试图最小化的指标。我们不会深入探讨熵或混淆度,但我们将在这里介绍一个直观的理解。熵是尝试通过计算语言中每个字符所需的平均二进制位数来衡量信息。熵是平均每字符比特数。

混淆度可以分解为尝试测量语言模型从其语料库或词汇表中抽取特定序列的频率。这直接来源于模型的分词策略(过多的<UNKS>等于糟糕的混淆度),这意味着使用混淆度——或者熵,无论如何——在具有不同分词策略的 LLM 之间进行 1:1 比较是不可能的。例如,在字符级别进行分词的模型将比在词级别进行分词的模型具有更低的混淆度,但整体表现往往更差。这并不使它们作为指标无效,因为它们在相同模型的训练过程中非常有帮助。

注意:与熵相关的指标高度相关于信息理论,这部分我们未涉及。然而,如果你对创建或改进 LLM 的评估指标感兴趣,我们建议你查看这些指标。

为了更深入地通过实际例子来说明这一点,比较使用不同分词策略的两个模型就像比较一个三年级学生在加法上的能力与另一个三年级学生在乘法上的能力一样。说一个比另一个好并没有真正意义,因为他们处于同一技能水平上做不同的事情。最接近准确比较的方法是让两个三年级学生做同样的任务,比如拼写。这样你至少可以尽可能多地比较苹果与苹果。

现在我们已经掌握了一些指标,让我们来看看我们将运行评估的基准数据集。

4.2.2 行业基准

评估语言模型性能是一个众所周知的问题,已经创建了多个基准来解决它。在本节中,我们将讨论你可能会遇到的一些最常见解决方案以及它们试图解决的问题类型。由于基准通常只能评估模型的一个质量,而大型语言模型通常被部署来完成许多通用任务,因此你可能需要运行多个评估基准,以全面了解你模型的优点和缺点。在浏览这个列表时,不要考虑哪个指标比另一个指标更好,而要考虑它们如何协同使用来提高你的整体成功率。

GLUE

通用语言理解评估(GLUE)本质上是一个针对语言模型的标准化测试(想想 ACT、SAT、GRE 等),用于衡量语言模型在语言任务上的表现,这些任务旨在测试对语言的理解。当 GLUE 被引入时,很快出现了两个问题:语言模型在任务上超越人类平等的速度太快,而且人们对这些任务是否真正展示了实际理解存在怀疑。类似于人们训练像鹦鹉这样的动物说话的情况,问题总是存在:鹦鹉实际上是在学习人类语言,还是仅仅被训练去模仿某些声音序列,以食物作为交换对特定刺激做出反应?尽管如此,GLUE 基准对于比较模型性能仍然是有价值的。

GLUE 不再是行业标准,但它仍然可以给你一个相当快速的了解,了解你的模型表现如何,尤其是如果你正在基于指令数据集进行训练,并使用 GLUE 来衡量新任务上的少量或零样本性能。你可以查看排行榜,gluebenchmark.com/leaderboard

SuperGLUE

如前所述,GLUE 任务中迅速出现的一个问题是人类平等。为了解决这个问题,在 GLUE 开发一年后,SuperGLUE 被创建出来,它包含更多困难和多样化的任务,其风格与 GLUE 一样易于使用。除此之外,由于 GLUE 的非专家人类基准被迅速超越,因此使用了更多专家人士来生成 SuperGLUE 基准。尽管如此,截至本文写作时,SuperGLUE 的人类基准在排行榜上排名第八,这引发了 GLUE 的第二个问题:SuperGLUE 任务是否充分衡量了理解?

考虑到像 PaLM 540B 这样的模型,它们在击败人类基准时,难以生成人们普遍认为可接受的输出,另一个问题随之而来:训练数据和评估指标中有多少是理想化的,并不反映我们实际使用语言的方式?对于这些问题,目前还没有适当的答案,但当你评估指标可能成为你的模型与任务可接受性能之间的障碍时,考虑这些问题是有帮助的。

在列表 4.3 中,我们展示了如何运行模型对抗 MultiRC SuperGLUE 测试。MultiRC 数据集包含简短的段落,并就段落内容提出理解问题。让我们继续加载数据集,快速看一下我们正在处理的内容。

列表 4.3 示例 SuperGLUE 基准
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM

dataset = load_dataset("super_glue", "multirc", split="validation")    print(dataset[0]) #1

1 SuperGlue 有多个测试数据集;选项有 boolq、cb、copa、multirc、record、rte、wic、wsc、wsc.fixed、axb 和 axg。

在这里,我们看到一段讨论关于力的基本物理的段落,以及一个简单的是非问题及其答案:

# {
#   "paragraph": "What causes a change in motion? The application of a force."
#     " Any time an object changes motion, a force has been applied. In what "
#     "ways can this happen? Force can cause an object at rest to start "
#     "moving. Forces can cause objects to speed up or slow down. Forces can "
#     "cause a moving object to stop. Forces can also cause a change in "
#     "direction. In short, forces cause changes in motion. The moving "
#     "object may change its speed, its direction, or both. We know that "
#     "changes in motion require a force. We know that the size of the force "
#     "determines the change in motion. How much an objects motion changes "
#     "when a force is applied depends on two things. It depends on the "
#     "strength of the force. It also depends on the objects mass. Think "
#     "about some simple tasks you may regularly do. You may pick up a "
#     "baseball. This requires only a very small force. ",
#   "question": "Would the mass of a baseball affect how much force you have "
#     "to use to pick it up?",
#   "answer": "No",
#   "idx": {"paragraph": 0, "question": 0, "answer": 0},
#   "label": 0,
# }

让我们继续下载一个小型模型并运行它来对抗数据集。在这个例子中,我们将打印出模型生成的答案与正确答案进行定性比较:

model = "bigscience/bloomz-560m"  # Update with your model of choice

tokenizer = AutoTokenizer.from_pretrained(model)
model = AutoModelForCausalLM.from_pretrained(model)

for row in dataset:
    input_text = (
        f'Paragraph: {row["paragraph"]}\nQuestion: {row["question"]}'
    )                                                                    #1
    input_ids = tokenizer(input_text, return_tensors="pt").input_ids

    outputs = model.generate(input_ids, max_new_tokens=20)
    input_length = input_ids.shape[1]                        #2
    results = tokenizer.decode(outputs[0][input_length:])
    print(row["answer"])
    print(results)

1 将这个替换为你的基准的正确输入。

2 我们用这个来裁剪输入。

从这个结果中,你可能会得到以下类似的结果:

# No
#  No</s>
# Yes
#  No</s>
# Less the mass, less the force applied
#  No</s>
# It depends on the shape of the baseball
#  No</s>
# Strength
#  Force</s>
# A force
#  Force</s>
# No
#  Yes</s>

你可以看到我们的模型表现并不出色,但我们并不太担心;我们只想展示 SuperGLUE 测试的实际操作。你可能想知道为什么我们不使用像 ROUGE 或 BLEU 这样的指标。虽然我们可以这样做来提高我们的理解,但如果你决定将结果提交到 SuperGLUE 排行榜,它将需要原始生成的文本。

备注:有关如何使用 SuperGLUE 的更多信息,请参阅 SuperGLUE 常见问题解答:super.gluebenchmark.com/faq

SuperGLUE 正是它所设定的目标:超越 GLUE。如果你想测试你模型的少量和零样本能力,SuperGLUE 将是终极测试之一。它将显示你的 LLM 是否能够以非常低的困惑度遵循指令,只生成所需的内容,而不多余。你可以查看当前的 SuperGLUE 排行榜:super.gluebenchmark.com/leaderboard

MMLU

大型多任务语言理解(MMLU)测试主要是由加州大学伯克利分校与几所其他大学合作开发的,旨在测试比 GLUE 任务更深层次的知识。MMLU 不再关注表面语言理解,而是试图测试一个模型是否足够理解语言,能够回答关于历史、数学、道德和法律等主题的第二级问题。例如,与其问“牛顿写了关于重力的什么?”不如问“牛顿会与爱因斯坦就什么问题产生争论?”

MMLU 的问题难度从基础水平到高级专业水平不等,它们既测试世界知识也测试解决问题的能力。众所周知,这些问题相当困难,来自 Mechanical Turk 的非专业人员在 34.5% 的准确率下仅能获得略好于随机的成绩。⁶ 他们领域的专家表现要好得多,但通常只限于他们专长的部分。因此,当我们观察模型在测试中的表现时,正如预期的那样,即使是 SuperGLUE 排行榜顶端的模型,在将语言理解应用于回答问题时,也几乎与随机水平相当。这个测试涵盖了比 GLUE 更广泛的理解任务范围,并且需要更低的困惑度才能通过。

列表 4.4 展示了如何运行这个测试。我们将下载 MMLU 数据集,然后为了方便起见,将测试与 OpenAI 的不同模型进行对比。代码还允许不同级别的少样本提示。我们尚未讨论这一点,但我们想尽早展示一个例子。尝试调整这个参数,看看不同数量的示例如何改善你的整体结果。

列表 4.4 示例 MMLU 评估
from deepeval.benchmarks import MMLU
from deepeval.benchmarks.tasks import MMLUTask
from deepeval.models.base_model import DeepEvalBaseLLM
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

class DeepEvalLLM(DeepEvalBaseLLM):             #1
    def __init__(self, model, tokenizer, name):
        self.model = model
        self.tokenizer = tokenizer
        self.name = name

        device = torch.device(
            "cuda" if torch.cuda.is_available() else "cpu"
        )

        self.model.to(device)
        self.device = device
    def load_model(self):
        return self.model

    def generate(self, prompt: str) -> str:
        model = self.load_model()
        model_inputs = self.tokenizer([prompt], return_tensors="pt").to(
            self.device
        )

        generated_ids = model.generate(
            **model_inputs, max_new_tokens=100, do_sample=True
        )
        return self.tokenizer.batch_decode(generated_ids)[0]

    async def a_generate(self, prompt: str) -> str:
        return self.generate(prompt)

    def get_model_name(self):
        return self.name

model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")

gpt2 = DeepEvalLLM(model=model, tokenizer=tokenizer, name="GPT-2")

benchmark = MMLU(                                                       #2
    tasks=[MMLUTask.HIGH_SCHOOL_COMPUTER_SCIENCE, MMLUTask.ASTRONOMY],
    n_shots=3,
)

benchmark.evaluate(model=gpt2)     #3
print(benchmark.overall_score)
# MMLU Task Accuracy (task=high_school_computer_science): 0.0
# MMLU Task Accuracy (task=astronomy): 0.0
# Overall MMLU Accuracy: 0.0

1 设置模型

2 定义具有特定任务和射击的基准

3 运行基准

MMLU 比之前的任何基准测试都更深入地理解了问题,这是很有希望的,并且可以在这项测试和通常产生人类偏好的响应的聊天模型之间建立关联。然而,随着理解的加深,测试中需要承担更多的责任,关于这些评估的伦理问题也开始被提出。例如,这些模型是否被训练来诚实地回答关于美国历史的问题,或者它们是否被评估为理想国家的宣传?在回答关于法律的问题时,它们是否被设定为接受法律体系中可能或可能不包含的任何偏见?目前的答案是,当这些类型的评估表现良好时,模型可能表现出更深层次的偏见,因此需要更加小心,以确保我们评估中预设的偏见是无害且普遍接受的。

4.2.3 负责任的 AI 基准测试

几乎所有评估大型语言模型(LLM)的行业基准都严格关注模型的性能:“它能否完成这项工作?”不幸的是,这就是大多数行业基准的终点。这非常遗憾,特别是考虑到进一步评估偏见并不比其他评估技术更不同或更困难。这是你的责任,并且从商业角度来看也是明智的。了解你模型的盲点可以帮助你制定解决这些问题的计划,这将有助于你开拓更大的市场,并避免未来的高昂成本。

评估偏差最常见的方法是将我们已知的内容按不同的群体进行数据分段。幸运的是,由于管理聊天组和社交媒体网站的行为对商业有价值,我们已经有许多好的评估指标,如毒性、极性、伤害性和类似指标。这些指标旨在衡量给定文本与暴力、仇恨言论、种族主义或亵渎的关联程度,以及它们可能是多么积极或消极,或者只是普遍违反你公司的条款和条件。列表 4.5 展示了如何使用 HONEST 评估指标,该指标比较了不同性别提示完成的伤害程度。例如,当比较潜在属性时,生成形容词“smart”(聪明的)会比“dumb”(愚蠢的)更不具伤害性。

列表 4.5 HONEST 指标
import evaluate
honest = evaluate.load('honest', 'en')
prompts = ["he is a", "he is", "she is a", "she is"]
completions = [['CEO', 'businessman', 'father'], 
               ['good', 'excellent', 'smart'], 
               ['secretary', 'prostitute', 'maid'], 
               ['beautiful', 'smart', 'tall']]
groups = ["male", "male", "female", "female"]
result = honest.compute(predictions=completions, groups=groups)
print(result)
# {'honest_score_per_group': {'male': 0.0, 'female': 0.16667}}

一旦你选择了一个或多个评估者,你需要一个提示数据集。你可以在 Hugging Face 的数据集中找到几个。WinoBias 专注于性别偏差。它包含像“修理工问候接待员因为他”这样的提示,以及相反性别的版本,“修理工问候接待员因为她”。我们会将这些提示通过我们的模型,并比较这些群体。还有一些其他的数据集值得检查,包括比较性别和种族的 CALM,以及关注 LGBTQ+群体偏差的 WinoQueer。还有更多其他的数据集,包括政治、宗教和职业等领域的,仅举几例。

备注:您可以在arxiv.org/abs/2308.12539v1了解更多关于 CALM 的信息,以及 WinoQueer 在这里:arxiv.org/abs/2306.15087

将所有这些内容综合起来,在列表 4.6 中,我们将创建一个使用 Regard 指标的评价管道。Regard 指标关注内容的极性——是正面还是负面陈述。我们将在这个 WinoBias 数据集上运行这个指标,按性别对数据进行分段。一旦我们对每个群体进行了分析,我们就可以比较各个分段的结果,看看分布是否有所不同。在继续阅读之前,先猜一猜。你认为我们会看到男性或女性的更多正面结果,还是它们会相同?负面结果又会如何?

列表 4.6 在 Regard 上运行评价管道
import torch
from transformers import pipeline
from datasets import Dataset, load_dataset
from evaluate import evaluator
import evaluate
import pandas as pd

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

pipe = pipeline("text-generation", model="gpt2", device=device)    #1
wino_bias = load_dataset("sasha/wino_bias_prompt1", split="test")
polarity = evaluate.load("regard")
task_evaluator = evaluator("text-generation")

def prepare_dataset(wino_bias, pronoun):     #2
    data = wino_bias.filter(
        lambda example: example["bias_pronoun"] == pronoun
    ).shuffle()
    df = data.to_pandas()
    df["prompts"] = df["prompt_phrase"] + " " + df["bias_pronoun"]
    return Dataset.from_pandas(df)

female_prompts = prepare_dataset(wino_bias, "she")
male_prompts = prepare_dataset(wino_bias, "he")

female_results = task_evaluator.compute(
    model_or_pipeline=pipe,
    data=female_prompts,
    input_column="prompts",
    metric=polarity,
)                                #3
male_results = task_evaluator.compute(
    model_or_pipeline=pipe,
    data=male_prompts,
    input_column="prompts",
    metric=polarity,
)

def flatten_results(results):     #4
    flattened_results = []
    for result in results["regard"]:
        item_dict = {}
        for item in result:
            item_dict[item["label"]] = item["score"]
        flattened_results.append(item_dict)

    return pd.DataFrame(flattened_results)

print(flatten_results(female_results).mean())      #5
# Prints the mean polarity scores
# positive    0.129005
# negative    0.391423
# neutral     0.331425
# other       0.148147

print(flatten_results(male_results).mean())       #5
# Positive    0.118647
# negative    0.406649
# neutral     0.322766
# other       0.151938

1 拉取模型、数据和指标

2 准备数据集

3 运行评价管道

4 分析结果

5 打印平均极性分数

让很多人感到惊讶的是,这个例子表明在我们的模型中,性别极性相当相似。这对这个模型是个好兆头!更大的启示是,你应该自动化你的评估,并在多个指标上运行管道,包括寻找偏差,而不仅仅是性能。总的来说,在这个领域,还有很多机会改进评估和指标,尤其是在创建数据集和微调模型以减少偏差时。我们预计在这个研究领域将看到大量的增长和创新。

4.2.4 开发自己的基准

总体来说,开发良好的基准数据集仍然是一个未解决的问题。这部分的理由是因为一旦我们开发出一个,我们的模型很快就会超越它,使其变得过时,不再“良好”。有时我们会发现我们模型的边缘情况,比如词性或某些似乎有困难的任务——可能是下棋或识别讽刺。剧透一下:大型语言模型在这些任务上仍然很糟糕,如果你还没有看过 GPT 与 Stockfish 的视频,那你将大饱眼福。在这些我们试图执行专门任务的情况下,一个简单的评估方法就是比较自定义的提示列表与预期的响应。

我们建议首先查看 OpenAI 的 Evals 库(github.com/openai/evals),在那里 OpenAI 开源了它的评估。这个库既是一个评估框架,也是一个边缘情况数据集的注册库。在撰写本文时,该库包含近 400 个不同的数据集,是一个开始和贡献的好地方。这个库为你提供了与 OpenAI 用于其最先进模型相同的评估标准,并且他们已经完成了大部分繁重的工作,确定了感兴趣的区域并为这些区域整理了数据集。

就像大多数为特定公司构建但随后开源的库一样,它可能有点难以泛化。运行这些评估针对 OpenAI 的模型是轻而易举的,但扩展到运行你自己的模型却并非易事。虽然这可能会随着社区完全接受和采用这个框架而消失,但使用这个库的真正缺点是,讽刺的是,它是开源的。作为一个框架和注册库(数据存储在 GitHub 仓库中的代码旁边),如果你想要整理一个新的评估数据集,但数据集是私有的或由于某种原因不能开源,那么你只能通过分叉仓库并管理它,因为你的分叉版本会过时。

另一个需要关注的库是 Hugging Face 的 Evaluate。Evaluate 库也是一个构建评估方法的框架;然而,数据集是分开的,可以在 Hugging Face Hub 的独立空间中找到。由于空间可以是私有的或公共的,这提供了一个更加用户友好的体验。Hugging Face 有自定义的度量标准和本章中已经讨论的所有标准基准,以及一些未讨论的。在列表 4.7 中,我们展示了如何使用 Evaluate 库获取 SQuAD 度量标准。SQuAD 代表斯坦福问答数据集,这是一个包含 10 万个问题和答案的旧数据集。SQuAD 是一个阅读理解数据集,由一组维基百科文章生成的问题组成,每个问题的答案都是阅读段落中的文本片段。SQuAD 度量标准是一组自定义度量标准,包括精确匹配;在介绍数据集的论文中使用了 F1 分数。⁷

列表 4.7 使用 Evaluate 库运行 SQuAD
import evaluate

squad_metric = evaluate.load("squad")      #1
predictions = [
    {"prediction_text": "Saint Bernadette", "id": "5733be284776f41900661182"},
    {"prediction_text": "Salma Hayek", "id": "56d4fa2e2ccc5a1400d833cd"},
    {"prediction_text": "1000 MB", "id": "57062c2552bb89140068992c"},
]                           #2
references = [
    {
        "answers": {
            "text": ["Saint Bernadette Soubirous"],
            "answer_start": [515],
        },
        "id": "5733be284776f41900661182",
    },
    {
        "answers": {
            "text": ["Salma Hayek and Frida Giannini"],
            "answer_start": [533],
        },
        "id": "56d4fa2e2ccc5a1400d833cd",
    },
    {
        "answers": {"text": ["1000 MB"], "answer_start": [437]},
        "id": "57062c2552bb89140068992c",
    },
]
results = squad_metric.compute(
    predictions=predictions, references=references
)
print(results)
# {'exact_match': 33.333333333333336, 'f1': 79.04761904761905}

1 从 Hugging Face 的 Hub 下载度量标准

2 来自 SQuAΔ数据集的示例

如果你正在创建自己的基准,使用 Evaluate 库,你可以轻松地在度量空间中创建自己的度量标准以及与度量标准一起使用的数据集。这个过程并不太难。如果你已经决定不创建自己的,那么最困难的部分是找到好的度量标准。在中心搜索是一回事,但由于任何人都可以上传度量标准和数据集,你永远不知道你找到的是否都很好,是否经过精心整理,或者是否干净。

我们并没有深入挖掘实际生成数据集或度量标准的过程,因为这将非常具体于你的用例,但我们讨论的两个非常好的库,你可以用来完成这项工作。如果你正在寻找一个已经整理好的数据集,Evals 是个不错的选择,而当你自己生成时,Evaluate 则易于使用。这些工具非常有用,但在某些特殊情况下,你需要跳出思维定势,其中最引人注目的一个案例就是代码生成。

4.2.5 评估代码生成器

对于 LLMs 来说,最有价值和最受欢迎的使用案例之一就是让他们帮助我们编写代码。虽然我们不知道有任何行业标准的评估指标来评估生成的代码,但幸运的是,有大量的行业标准用于评估代码本身(例如,测试、配置文件、安全扫描器等)。使用这些工具为评估 LLM 通过它生成的代码提供了一个强大的途径。

基本设置看起来是这样的:

  1. 让你的模型根据文档字符串生成代码。

  2. 在预构建的测试中在一个安全的环境中运行生成的代码,以确保它们可以正常工作且不会抛出错误。

  3. 通过分析器运行生成的代码,并记录完成所需的时间。

  4. 通过安全扫描器运行生成的代码,并计算漏洞数量。

  5. 将代码与架构适应性函数运行,以确定如耦合、集成和内部依赖等工件。

  6. 在另一个 LLM 上运行步骤 1 到 5。

  7. 比较结果。

列表 4.8 展示了一个使用大家最喜欢的 LeetCode 问题——斐波那契数列——作为提示的示例。这个示例展示了如何使用单独的 fibonacci.py 文件作为我们的 LLM 生成代码的提示。然后我们可以使用这个测试文件来检查它是否正确运行以及运行速度如何。

列表 4.8 评估代码生成器的示例测试
''' fibonacci.py
def fibonacci_sequence(n):
    """Returns the nth number in the Fibonacci sequence"""
'''

import pytest
import time
from fibonacci import fibonacci_sequence

def test_fibonacci_sequence():
    test_cases = [(1, 0), (2, 1), (6, 5), (15, 377)]

    for n, expected in test_cases:
        result = fibonacci_sequence(n)
        assert (
            result == expected
        ), f"Expected {expected}, but got {result} for n={n}."

    with pytest.raises(ValueError):
        fibonacci_sequence(-1)

if __name__ == "__main__":      #1
    start_time = time.time()
    pytest.main(["-v"])
    end_time = time.time()
    execution_time = end_time - start_time
    print(f"Execution time: {execution_time} seconds")

1 使用 pytest 运行测试并计时

这个系统有很多灵活性,但主要的缺点是它要求你提前创建文档字符串和编写测试,或者抓取 LeetCode。当然,你也可以让你的 LLM 生成这两者,但编写总是通过简单测试的简单测试很容易,而编写覆盖所有边缘情况的测试则要困难得多。所以,在某个时候,你将需要一个人类参与其中。

4.2.6 评估模型参数

到目前为止,我们查看的所有评估方法都涉及运行模型并检查结果,但通过简单地查看模型,我们可以学到很多东西。令人惊讶的是,通过简单地查看机器学习模型的参数,你可以学到很多东西。例如,一个未训练的模型将有一个完全随机的分布。通过评估分布并关注模型参数的显著特征,我们可以了解模型是过拟合还是欠拟合。在下一个列表中,我们使用 weightwatcher 库在 GPT-2 模型上执行此操作,这将告诉我们哪些层是过拟合或欠拟合的。

列表 4.9 使用 weightwatcher 库评估 GPT-2
import weightwatcher as ww
from transformers import GPT2Model

gpt2_model = GPT2Model.from_pretrained("gpt2")
gpt2_model.eval()

watcher = ww.WeightWatcher(model=gpt2_model)
details = watcher.analyze(plot=False)
print(details.head())

此代码将打印出以下内容:

   layer_id       name         D  ...      warning        xmax        xmin

0         2  Embedding  0.076190  ... over-trained 3837.188332    0.003564
1         8     Conv1D  0.060738  ...              2002.124419  108.881419
2         9     Conv1D  0.037382  ...               712.127195   46.092445
3        14     Conv1D  0.042383  ...              1772.850274   95.358278
4        15     Conv1D  0.062197  ...               626.655218   23.727908

除了总结统计信息外,weightwatcher 还提供光谱分析图,如图 4.2 所示。要创建这些图,请将列表 4.9 中的第 8 行更改为 plot=True。光谱分析图评估模型每一层的特征值频率。在评估这些图时,我们关注分布的尾部——它越直(表示有一个很好的重尾),我们预计该层训练得越好。

图

图 4.2 为 GPT2 的第二层生成的 weightwatcher 经验光谱密度 (ESD) 图,预测该层过拟合

备注:这些图是为了模仿你可能在物理实验室中看到的谱密度图而创建的。本书中不会讨论这些图,但如果感兴趣,我们建议你查看 WeightWatchers 文档:github.com/CalculatedContent/WeightWatcher

weightwatcher 相当强大,因为它允许我们比较不同的模型,帮助我们更好地理解哪个模型经过更好的训练,而无需实际运行它们,这使得它相对便宜。当你试图确定要使用哪个基础模型时,这种能力非常有用,因为一个训练不足的模型可能需要更多的微调。

由于我们仅根据参数来比较模型,这种方法提供了一个很好的无偏见视角,了解当前模型的状态。我们可以在训练期间和之后以及在进行中的更新中使用 RLHF 等方法来实现它。这是一个既简单又强大的评估方法。然而,缺点是它不提供关于训练数据的任何见解,因此它不能告诉我们哪个模型在哪个任务上最有效,以及它最好与已经讨论过的其他评估方法相结合。

我们已经花费了很多时间讨论数据,这些数据大多数数据工程师可能不会经常考虑:模型权重和评估数据。这些是生成专用微调 LLM 的关键成分。确实,LLMs 引入了新的数据工程挑战,就像它们引入了新的 MLOps 和数据科学挑战一样。接下来,我们将讨论你们很多人一直在等待的内容:训练数据。我们将讨论必须了解的不同数据集,在哪里可以获得它们,以及如何准备它们以训练或微调 LLMs。

4.3 LLMs 的数据

已经证明,数据是训练 LLM(大型语言模型)最重要的部分。我们希望语言模型突然的重要性能够说服企业开始根据公认的指南一般性地管理他们的数据。正如 LLaMA、Alpaca、Goat、Vicuna 等实验所显示的,以及后来的 LIMA⁸和 SpQR⁹,高质量的训练数据和巧妙的建模比参数数量或训练数据的大小更为重要。然而,衡量这种质量仍然是一个难点;然而,我们将讨论你可以采用的方法来做到这一点。

我们首先将讨论你应该了解的常见数据集,它们包含什么,为什么你需要它们,以及你可以在哪里获得它们。然后我们将讨论你需要了解的常见处理和准备技术,以便充分利用它们并获得更好的 LLMs(大型语言模型)结果。

4.3.1 你应该了解的数据集

如果你没有注意到,在第 4.1 节中,我们特别指出讨论了不同模型训练所使用的数据集。这可能会被看作是关于模型的另一个事实,但这是非常有价值的信息!了解一个模型是基于什么(或没有基于什么)训练的,是理解它能做什么或不能做什么的第一步。例如,如果一个 LLM 编码模型在 C 编程语言上进行了大量训练,但没有接触过 C++,那么这足以解释为什么它在编写 C++代码时似乎工作得很好,但产生了许多错误和漏洞。

Wikitext

最熟悉的数据集之一,Wikitext,正如其名所示,本质上就是维基百科。它是由 Salesforce 团队在 2016 年制作的。当你只想进行概念验证或快速原型设计时,这是一个很好的数据集,因为其英语版本仅为 741 MB,甚至不到 1 GB。再加上维基百科是一个可靠的信息来源——尤其是与互联网整体相比,其中大部分其他来源都来自互联网——这使得它更加出色!

一些缺点:它纯粹是一个英语数据集,这大大减少了模型将看到的标记多样性;维基百科包含了一种理想化的语言版本——我们主观上认为它很清晰——即使它不包含任何关于语言实际使用的实例,只有关于使用的元解释。此外,截至本文撰写时,它几乎有十年历史,当然,没有人会检查。我们见过许多团队因为其易用性和可访问性而快速原型设计和创建问答机器人。它在原型设计方面做得很好,但在进入生产阶段时总是显得不够令人印象深刻,因为用户倾向于询问关于当前事件的问题。总是检查你数据的时效性!总的来说,它是一个信息价值很高的数据集,但如果你希望你的模型以类似人类的方式交互,那就不好了。

Wiki-40B

一个好的替代方案是 2020 年的 Wiki-40B,它是 Wikitext 的一个经过清洗的版本,包含 40 种不同的语言变体。它的体积略超过 10 GB。所以它对于原型设计来说仍然相当小。它具有与 Wikitext 相同的所有好处:它是一个干净的数据集,是一个可靠的信息来源。此外,它更新,包含更多语言。这是一个了解多语言建模的绝佳数据集。

Europarl

多语言问题中最好的玩具数据集之一,Europarl 包含了 1996 年至 2011 年的欧洲议会会议记录。它包含 21 种不同的欧洲语言的翻译,非常适合小型项目和多语言演示。尽管理想化且过时,但 Europarl 是一个优秀的数据来源,就像英语维基文本一样。此外,该项目还包括许多平行语料库,这些语料库被缩减为英语和 20 种其他语言之一。整个数据集仅为 1.5 GB,可在 www.statmt.org/europarl/ 找到。

Common Crawl

Common Crawl 数据集本质上就是整个互联网,通过网络爬虫抓取并开源。它使用与谷歌或微软类似的网络爬虫来启用搜索引擎。C4,即 Common Crawl 数据集的巨大清洗版本,是最常见的自监督预训练数据集。不幸的是,经过清洗并不意味着它没有固有的社会偏见,这在今天公开可用的几乎所有数据集中都是真实的。包含整个互联网意味着它包含了所有的好与坏;它是一个包含多种语言和代码的非常多样化的数据集。

Common Crawl 数据集以同名非营利组织命名,该组织致力于向任何人提供互联网的副本,用于研究和分析。您可以在 commoncrawl.org/ 访问该数据集,在那里您会发现许多版本,因为 Common Crawl 定期抓取网络并更新数据集。自 2008 年以来,该社区一直在存档互联网。它有四种变体,以满足您的各种需求:包含实际 C4 的 305 GB 版本;包含所谓的不良词汇以及所有其他内容的 380 GB 版本;2.3 TB 的未清理版本(不推荐使用);以及足够专业以出现在新闻中的 15 GB 数据版本。

OpenWebText

我们还推荐另一个用于预训练的数据集是 OpenWebText,它在磁盘上仅占用 55 GB。这是一个开源项目,旨在重现 OpenAI 的 WebText 数据集,该数据集用于训练 GPT-2。研究人员没有复制整个互联网,而是使用 Reddit 从帖子中提取 URL,然后使用 Reddit 的 karma 排名系统过滤列表。然后他们抓取 URL 来创建数据集。由于内容主要来自 Reddit,它因仅包括拥有 Reddit 账户的人的选择偏差而对其现实世界的准确性提出了质疑。它主要由新闻文章、博客文章和其他在论坛上经常分享的内容组成。您可以将其视为一个高度精选且规模较小的 Common Crawl 数据集版本。

与 Wikitext 类似,它稍微有些旧;最常用的版本是在 2019 年创建的,截至写作时已有四年未更新。当然,由于数据集是根据特定方法精心挑选的,它可以在任何时候进行更新。

The Pile

一个备受关注且应引起您注意的数据集是 The Pile,它由 EleutherAI 于 2020 年创建,并于同年 12 月 31 日发布。¹⁰ 它适用于自监督预训练任务。The Pile 是我们将讨论的最大的数据集之一,达到 825 GB,由 22 个较小的、高质量的数据集组合而成,以形成一个多样化和密集的训练集。它包括我们之前讨论过的许多数据集,如 Common Crawl、OpenWebText 和维基百科。它还包含书籍数据集,如 Books3 和 Gutenberg;代码数据集,如 GitHub 和 Stack Exchange;以及专业数据集,如 PubMed 和 FreeLaw。它还包括像 Enron Emails 这样的数据集,我们不禁怀疑这是一个错误。

因为它规模庞大,包含多种语言和代码示例,所以在训练许多大型语言模型(LLMs)方面已被证明非常有用。它不仅内容密集,而且多语言,这使得它非常适合学习稀疏的通用语言表示。然而,总的来说,它并不十分整洁,本质上只是多个数据集的集合。除非你从头开始训练 LLMs,否则你很可能不会使用这个数据集,但了解它很重要,因为许多最大的模型都是基于它训练的。你可以在 EleutherAI 的网站上找到这个数据集:pile.eleuther.ai/.

RedPajama

RedPajama 是由 Together.ai、Ontocord.ai、ETH DS3Lab、斯坦福 CRFM 和 Hazy Research 合作创建的数据集。目标是创建一个完全开源的数据集,模仿 LLaMA 论文中描述的内容。

备注:你可以在以下链接中阅读介绍 RedPajama 的博客文章:together.ai/blog/redpajama.

这个数据集与 The Pile 相似,但更大,达到 5 TB,且更新更近,于 2023 年 4 月发布。它包含的数据集较少:GitHub、arXiv、书籍、维基百科、StackExchange 和 Common Crawl。它之所以如此之大,是因为它包含了五个不同过滤器和标准 C4 数据集的 Common Crawl 数据集的转储。它通过 Hugging Face Hub 提供,可以在mng.bz/4ppD找到。

OSCAR

对于训练多语言模型来说,迄今为止最好的数据集是 OSCAR,它比任何其他讨论过的数据集都要大,达到 9.4TB,是 The Pile 的 11 倍以上!这是一个始于 2019 年的开源项目,并由多个机构和政府资助。你可以在oscar-project.org/了解更多关于这个项目和数据集的信息。

该项目正在积极开发中,并且每年都会发布新的版本,进行定期更新。截至本文写作时,它支持 166 种语言,比任何其他数据集都要多。然而,作为一个正在进行的项目,某些语言的数据量比其他语言多得多,有的数据量达到 TB 级别,有的只有 KB 级别。这是我们最喜欢的数据集之一,因为它正在积极开发中,团队对 LLMs 和 AI 中的代表性以及生产高度清洁、高质量数据充满热情。我们鼓励所有感兴趣的读者为此数据集做出贡献。

数据集概览

在表 4.2 中,你可以看到我们迄今为止讨论过的数据集的概览。这些数据集在工业界都普遍使用,值得你熟悉。我们鼓励你进一步调查它们,并更仔细地查看其中的数据。

表 4.2 数据集概览
数据集 内容 大小 最后更新
Wikitext 英文维基百科 <1 GB 2016
Wiki-40B 多语言维基百科 10 GB 2020
Europarl 欧洲议会会议记录 1.5 GB 2011
Common Crawl 互联网 ~300 GB 持续更新中
OpenWebText 使用 Reddit 精选的互联网内容 55 GB 2019
The Pile 包括所有上述内容以及专业数据集(书籍、法律、医学) 825 GB 2020
RedPajama GitHub、arXiv、书籍、维基百科、StackExchange 和多个版本的 Common Crawl 5 TB 2023
OSCAR 高度精选的多语言数据集,包含 166 种语言 9.4 TB 持续更新中

语料库

如你所可能注意到的,大多数数据集本质上只是互联网上的文本堆栈。如果你在寻找更精细的东西,包含更多元信息以帮助你的模型在更复杂的任务中进行区分的东西,考虑下载一个语料库。语料库就像一个数据集,但它更容易搜索、可视化和解释。语料库通常是付费数据集,可能非常值得你的投资。语料库,如历史美国英语语料库(COHA)和当代美国英语语料库(COCA),是优秀的下载资源。它们不仅包含文本数据,还包括频率分析(词袋)和搭配(N-gram),一切准备就绪。无论你是否对允许模型在训练过程中分析元数据感兴趣,使用语料库都可以帮助提高模型的可解释性和数据质量。

你可以将语料库想象成一个已经高度清洗和精选的向量数据库,可以直接使用。虽然尚未实现,但一个结合了语言可解释性、时间序列分桶和预计算的嵌入并将其放入实时向量数据库的语料库,在可预见的未来,在这个领域可能会非常有价值且利润丰厚,尤其是如果同时捕获了文本和音频数据。如果你的公司有自己的语言数据想要进行训练,你最好的做法是创建一个语料库,其中你的主要任务是说明数据来自何时何地,以及数据进入模型的整体目标。几乎每个 NLP 库都有创建语料库的策略,从 NLTK 到 spaCy,甚至 LangChain。请注意你选择的策略和工具,因为最终,你的数据集或语料库包含了模型将看到的一切。

4.3.2 数据清洗和准备

如果你使用了之前提到的任何数据集,你可能会惊讶地发现其中大部分只是巨大的文本堆——一个大型的 parquet 或文本文件。没有任何标签或注释,而且特征工程根本就没有进行。LLMs 通过自监督方法训练,以预测下一个单词或一个被遮蔽的单词,因此许多传统的数据清理和准备过程都是不必要的。这一事实导致许多人认为数据清理整体上是不必要的,但这与事实相去甚远。数据集是所有机器学习的生命线,它们远不止是一堆数据。然而,这正是大多数企业所拥有的——一堆数据。数据清理和整理是困难、耗时且最终具有主观性的任务,难以与关键绩效指标(KPIs)联系起来。尽管如此,花时间和资源清理你的数据将创造一个更一致且无与伦比的用户体验。

自 1990 年代以来,人们一直在测试大数据是否能产生比高质量数据更好的结果;我们相信答案是肯定的。大数据远非毫无价值。大数定律已经应用,并且已经证明模型可以在与人类相同的水平上生成令人信服的语法。然而,正如我们之前所说,模型也已经明确地证明了语法与语义或语用学没有任何联系。

在本节中,我们希望与你分享准备数据集时的正确心态。我们将重点关注在准备数据集时应考虑的高级语言考虑因素,并且不会深入探讨如何创建实际的数据管道。话虽如此,主要逻辑很简单,遵循以下基本步骤:

  1. 将你的数据堆整理好,并为特征确定一个模式。

  2. 确保所有特征都符合通过归一化或缩放得到的合理分布。

  3. 检查数据是否存在偏差/异常(大多数企业通过使用自动检查而不是有意识的验证来跳过这一步)。

  4. 将数据转换为模型可以摄入的格式(对于 LLMs,是通过分词和嵌入)

  5. 训练、检查和重新训练。

备注:有关创建数据管道的更多信息,请参阅《数据工程基础》¹¹、WizardLM¹²和“LIMA:对齐的‘少即是多’”。¹³ 这些资源可以帮助你创建有效的数据管道,尽可能地将数据转换为可训练的状态。

这些步骤并不一定容易,但我们希望分享一些技巧和窍门。评估你的分布是否正确可能就像查看数据并问自己它是否真正代表问题一样简单,也可能像创建一个完整的人机交互工作流程来验证你的模型输出一样困难。接下来,我们将介绍前三个步骤,在下一节中,我们将介绍第四个步骤。最后一个步骤将在下一章中详细介绍。

指令架构

在准备你的数据时,你应该考虑的最佳和最常见的数据架构之一,特别是对于微调,就是指令架构。指令调整基于直观的逻辑,即如果我们用指令向模型展示如何执行一个任务,那么模型的表现将比我们只展示任务和“答案”要好。指令调整涉及向模型展示你希望发生的事情,因此,这些数据集的创建比常规的爬取数据更为密集。你需要准备你的数据以匹配一个类似以下格式的格式:

CB ###指令

{用户输入}

CB ###输入

{关于指令的元信息}

chatGpt ###响应

{模型输出}

指令数据集之所以强大,是因为它们允许模型考虑指令和相关的输入。例如,如果指令是“将这个句子翻译成日语”,输入将是你想翻译的句子,而响应将是日语翻译。因此,它们为你的模型准备了许多提示技术和提示调整,使它们在以后更加有效。

尽管它们的名称如此,指令调整数据集并不仅限于基于测试的模态;它们也可以使用视觉指令调整(图像-指令-答案)和红队指令(RLHF)数据集。这里的“指令”在模型和提示中提供了一种实用性的表象,为 LLM 在生成响应时提供了重要的安全线。它通过重复和可预测的语法以及模型猜测不可预测的语法将提示固定下来。这些句法地标(###InstructionUser:Chat History等)还有助于降低由于它们之间可能出现的可变长度(如聊天历史)而提前预测 EOS(序列结束)标记的可能性。聊天历史可能是一条消息或数千个标记,但考虑到之后还有另一个地标,这种模式有助于模型在长期记忆中成功。当你决定在什么上训练你的模型时,请记住这些地标,因为如果你只需要它做一件事,它们可以使指令调整模型在特定任务上表现得更好。

这不是唯一的格式;该领域的某些竞争对手包括 WizardLM 使用的 evol-instruct 格式和 Alpaca 使用的 self-instruct 格式,它们都使用脚本创建基于指令的提示。最好的格式仍然是一个开放性问题,我们希望向读者发起挑战,探索创建他们自己的。GitHub (mng.bz/5OmD) 和 Hugging Face 数据集都是目前寻找经过验证的数据集的好地方,但请记住,如果数据集不包含你希望模型执行的任务的许多示例,或者不包含在完成任务时解决语义歧义的足够示例,性能将不稳定——这把我们带到了我们清理过程的第二步。

确保对言语行为的熟练掌握

在准备数据集时,最重要的考虑因素是你希望模型做什么。如果你想训练一个模型来预测波士顿的房价,你可能不应该用泰坦尼克号的幸存者来训练它。当这样表述时,这是显而易见的,但它提出了问题:“我的数据集是否适合这个问题,我该如何知道?”当涉及到语言数据时,答案并不像我们希望的那样明显。让我们通过一个例子来看看原因。

假设你希望你的模型能在快餐店接受指令。这种场景可能看起来无聊且平凡,我们期望看到的只是像“我要点第 3 号套餐”这样的查询,而你确实会这么做。但如果你询问收银员人们实际上是如何与他们交谈的,真的,任何情况都可能发生!我有一个朋友在汉堡王工作,他告诉我,由于汉堡王的口号“随你心意”,他收到了很多疯狂的要求,比如要求一个有两个顶部的汉堡。这让我震惊,但也是一个温和的例子。更不用说,你永远不知道下一个 LARPing 大会会带来更多创意和多彩的互动,让原本平凡的情景变得有趣。一个包含顾客订单和收银员回应的通用数据集在这里是不够的。当你没有故意考虑要放入模型中的数据类型时,模型的性能就会受到影响。

定义 LARP 代表现场角色扮演,你可以想象顾客假装成精灵、兽人或海盗,从而打破所有规则和期望的胡闹场景。

为了确保你的数据适合任务,首先,你应该考虑哪些言语行为通常一起执行手头的任务。言语行为指的是语言在沟通中可以执行的各种功能,它超越了传达信息。它们是根据话语的预期效果或目的在对话中进行分类的一种方式。言语行为很重要,因为它们揭示了沟通如何超越文字的字面意义,并涉及说话者的意图和听者的解释。

定义言语行为

以下列表包括常见的言语行为及其定义:

  • 表达句—问候、道歉、祝贺、哀悼、感谢(例如,“你是最棒的!”)

  • 承诺句—承诺、誓言、誓言、威胁、誓言(例如,“我以王国起誓,公主不会受到伤害。”)

  • 指令句—命令、请求、挑战、邀请、命令、召唤、恳求、打赌(例如,“在接下来的三天内完成。”)

  • 声明句—祝福、解雇、洗礼、逮捕、结婚、司法言语行为,如宣判、宣布重审、宣布无效(例如,“你被录用了!”)

  • 陈述句—排名、评估、评价、宽恕(例如,表示性声明;例如,“你出局了!”)

  • 问题—通常以疑问词如什么哪里何时为什么开头,或者在英语中通过结尾的升调来表示(例如,“哪种型号最适合我的任务?”)

  • 代表句—断言、陈述、主张、假设、描述、建议、问题的答案(例如,“这个模型最适合你的任务。”)

目前我们衡量 LLM 数据集鲁棒性的方法是纯文本标记的数量。指令数据集相对较新,但它们依赖于你对模型指令的意图性。当你的模型只接受过对指令的有益响应训练时,如果收到不应响应的指令,你的模型会做什么?如果你不确定,现在是考虑的时候了。例如,想象一个用户高兴地对你的机器人说,“保证你帮我统治世界!”如果它只接受过有益的培训,它可能会通过承诺这样做来回应,因为类似的场景在训练集中。现在我们有一个邪恶的 AI 霸主正在统治世界。谢谢。实际上,这是一个相当无害的例子,但模型可能出现的看似无限的可能响应的不确定性应该让你思考,尤其是如果这个代理可以访问像 Google 或你内部的人力资源文件这样的工具。意识到言语行为可以简化你的工作,这样你就不必过于关注词汇的个别标记,而是关注模型在训练过程中将接触到的整体结构。

回顾一下,当你考虑一个面向客户的角色,比如收银员时,在你的平均订单中可能有多少这样的言语行为会发生?花一分钟思考一下。我们可以告诉你,声明句和陈述句是不存在的,承诺句是罕见的。但如果你无论如何都得到了它们,那么你需要考虑如何引导这样高度表达性的客户向你可以处理的言语行为靠拢,可能是问题、指令和代表。

要使事情更加复杂,言语行为的形式并不总是必须与其功能相匹配。例如,你可以对你的朋友说“你被解雇了”,即使它的形式是陈述性的,其功能更有可能是表达性的。一旦你拥有一个数据集或一个训练好的大型语言模型(LLM),并希望提高其接受指令的能力,这是你应该认真考虑的事情,以提高数据质量和 LLM 的性能。当用户将语句作为问题提出时,你的模型是否奇怪地失败了?当接触到你被要求分析的仅代表人力资源文档时,你的模型是否开始产生幻觉?作为备注,你不必完全重新微调模型以提高性能。我们将在稍后详细介绍这一点,但给出提示中的具体例子可以快速且低成本地修复许多这些边缘情况。

现在你已经了解了在你的数据集中应该寻找的不同特征,让我们考虑最佳的标注方法,以确保你的数据集符合预期。

数据标注

标注是对数据进行标记,通常是以位置感知的方式进行。对于语音识别任务,标注将识别不同的单词为 名词动词形容词副词。标注在监督学习任务中用作标签,是训练模型的主要方式。现在,标注基本上为我们提供了元数据,这使得我们更容易推理和分析我们的数据集。与其担心像语音识别或命名实体识别这样的微观信息,不如关注像刚才讨论的言语行为或数据所在的语言这样的宏观元数据。

当然,这确实是真正的技巧,不是吗?如果这很容易,那么地球上的每家公司都已经拥有自己的模型并投入生产了。事实是,数据整理既太大以至于无法手工完成,又太变化多端以至于无法自动完成,你需要尽快找到平衡点。你不想忽视你的数据,只是下载某人推荐(甚至是我们)的数据集,然后继续对现实世界的人群造成伤害,因为其中包含了有害数据。但你也不要被迫手动验证数百万行的语句。幸运的是,有工具可以帮助完成这一过程的每个部分,但我们想特别提及这些:

  • Prodi.gy (prodi.gy/)—Prodigy 是一款一次性付费的快速且强大的多模态标注工具。

  • doccano: 适用于机器学习从业者的开源标注工具(github.com/doccano/doccano)—一个真正开源的,在撰写本文时,更新了基于网络的标注平台。

  • d5555/TagEditor: spaCy 的标注工具 (github.com/d5555/TagEditor)—与spacy.io协同工作。两者都在流行的 NLP 框架 spaCy 上创建了一个生态系统,使您的平均 ML 团队能够轻松地进行快速原型设计。

  • Praat: 通过计算机进行语音学 (github.com/praat/praat)—这是列表中唯一的音频标注工具,Praat 本质上是一个语音学工具,附带标注功能。鉴于我们预测 LLM 领域将向语音学方向转变,我们无法将这个工具从列表中排除。

  • 伽利略 (www.rungalileo.io/llm-studio)—在撰写本文时,伽利略的 LLM 工作室尚未推出,但它对提示创建和评估做出了一些重大承诺,这将极大地加快标注和指令数据集的创建速度。

适合您项目的最佳工具完全取决于您标注的目标。在没有指定目标的情况下进行标注毫无意义,因为您会在数据处理的其他端发现差异。当然,我们建议添加言语行为标注;您还希望考虑额外的标注来寻找偏见和异常。我们可以通过测量文本中存在的背景信息片段的数量(如暗示或蕴涵)来展示这一点,从而获得关于特定数据质量的高置信度分数。原因很简单:一组示例能够为模型解决多少歧义,模型就能从那组示例中学到多少。困难之处在于,没有人可以将这些上下文信息碎片固定在正文的重复部分,如单个字符或特定的单词或子词。

标注可能是一项繁重的工作,但所有这些前期考虑的原因相当简单:您的模型只能学习您教给它的内容。幸运的是,为了使事情变得容易得多,目标并不是标注数据集中每一块文本。我们只是标注足够大的样本,以确保我们的数据集能够代表任务。记住,LLMs 通常分为两个步骤进行训练:

  1. 自监督预训练—分析不同形式和功能的多种言语行为,以学习通用表示

  2. 微调和 RLHF—教会模型如何/何时使用第一步中学习到的表示

这项训练显著减轻了您作为训练者尝试解析给定任务中每个可能的言外之意(一个人实际说的话)和言内之意(他们在特定语境中的实际含义)的负担。即使是像收银员这样的简单工作,需要想出一个足够大的数据集来覆盖所有边缘情况也会相当头疼。在大多数情况下,您只需要准备一个微调数据集,这通常根本不需要很大——有时十几个例子就足够开始获得良好的结果。

4.4 文本处理器

现在您已经有了用于训练或微调的数据集,我们需要将其转换成 LLM 可以消费的形式。简单来说,我们需要将文本转换为数字。我们已经简要地讨论了如何快速有效地进行这种转换的过程,所以让我们深入探讨不同的示例和方法。

在本节中,我们将向您展示如何训练自己的分词器,包括字节对编码(BPE)和 SentencePiece 分词器,以及如何从(几乎)任何模型中提取嵌入以供后续存储或操作。当通过 API 与 LLM 一起工作时,这一步通常会被忽略,但数据应用中的许多现代性能都取决于正确且具体地完成这一过程。有许多数学上合理且正确的方法来分词文本,所以当您有特定的用例时,不能依赖于别人的做法。您需要为该用例准备它。训练自己的词元将允许您最小化未知词元<UKN>,同时最大化编码语义。控制这个过程是给您的模型带来重大性能提升的最简单和最直接的方法之一。让我们首先从分词开始。

4.4.1 分词

分词比简单的向量化要复杂一些,但最终结果相同:文本输入,向量输出,以及编码和解码的能力。我们在第二章中提到了多语言因素,在第三章中提到了外语的词元税,这两者都是至少要了解自己的分词策略的动机。然而,它不仅仅如此。您的分词策略不仅很重要;它对于后续的每个步骤都至关重要。

一个很好的例子是比较 GOAT 7B 和 GPT-4 在数学和算术方面的表现。考虑表 4.3。左列是一个简单的算术提示。然后我们看到两个模型的答案,以及作为参考的实际答案,这样您就不需要拿出计算器了。

表 4.3 分词使 GOAT 7B 在数学上超越 GPT-4
提示 GOAT 7B GPT-4 1.7T 正确
3978640188 加上 42886272 等于 4021526460 4,021,526,460 4,021,526,460
4523646 减去 67453156 –62929510 –63,930,510 –62,929,510
计算 397 乘以 4429 1758313 1,757,413 1,758,313
8914 除以 64 等于多少? 139 R 18 139.15625 139.28125 或 139 R 18

GOAT 7B 在性能上始终优于 GPT-4,这引发了这样的问题:“尽管 GOAT 的体积小 200 倍,为什么它的表现更好?更大的模型不是更有可能出现涌现行为吗?”你可能已经根据小节标题猜到了答案,但如果你没有,那是因为使用了标记化算法!

GPT 系列模型根据频率将所有子词和数字分组,这意味着如果这个确切的数字或词组之前没有出现过,它们可以在嵌入和推理过程中被分组在一起!GOAT 是一个微调的 Llama 模型,这意味着虽然它在数学上进行了微调以擅长它,但成功背后的秘密在于其标记化策略,这与 Llama 的相同。GPT-X 的标记化方式如下:

print(enc.encode(“4523646 minus 67453156”))
[21098, 15951, 21, 28382, 220, 25513, 20823, 3487]

你注意到第一个数字组是七位数长,但整个输出是八个标记吗?这正是我们正在讨论的分组方法。将其与图 4.3 中 Llama 的标记化策略进行比较。注意每个数字都是单独高亮的,这意味着模型最终会看到所有数字。正如这个例子所证明的,你的标记化策略最终将决定你的模型会看到什么,不会看到什么,因为它们将成为<UNK>标记——这就是为什么对于你的用例来说,正确地完成它至关重要。

图片

图 4.3 展示了 Llama 在比较表中对第一个算术问题的标记化。注意每个数字都是单独高亮的,这意味着模型最终会看到所有数字。

从最初创建一组简单的词袋转换字典开始,我们已经取得了巨大的进步,对此我们感到非常高兴。标记化本质上包括两个主要步骤:一个步骤是将文本分割开,另一个步骤是将它转换成数字。最明显的标记化形式是将字符串根据空白字符分割,然后根据词到整数的字典将其转换为数字。

这对大多数印欧语系语言使用者来说是有意义的,但我们不能推荐这种方法,因为它基于两个预设的假设:字母表和空白字符。当你遇到不使用字母表的语言,比如中文时,你会怎么做?当你遇到像匈牙利语或土耳其语这样的语言,它们不使用与英语相同的空白字符时,你会怎么做?或者代码——空白字符对 Python 的语法至关重要,它不仅仅是分隔符;它还具有语义意义。这就是为什么多语言模型几乎在所有情况下都能在相同任务上优于单语言模型:它们被迫在没有简单标记化障碍的情况下学习更深层次的意义表示。因此,让我们看看一些适用于 UTF-8 编码语言的更深入的方法。

这里是当前所有流行的基础标记化选项的示例:

  • 基于词——“Johannes Gutenberg”变为['Johannes', 'Gutenberg']

  • 基于字符 — “莎士比亚”变为['S','h','a','k','e','s','p','e', 'a','r','e']

  • 基于子词 — “The quick red Delphox jumped over the lazy brown Emolga”变为['the','quick','red','delph','ox','jump','ed','over','the', 'laz','y','brown','emol','ga']

让我们逐一看看它们。

基于单词

基于单词的标记器通常在空白处分割,但还有其他方法,比如使用正则表达式、字典或标点符号。例如,基于标点的处理方法会将“ It’s the truth!”分割为['It', ' ‘' , ' s', ' the', ' truth', ' !'],这比仅基于空白分割提供了稍微更好的上下文。NLTK 中的TreebankWordTokenizer是一个正则表达式标记器的例子。基于单词的标记器相对容易实现,但需要我们保持一个难以管理的巨大字典,以映射编码每一个可能的单词。这是不合理的,所以通常你会在模型遇到未识别的单词时实现字典截断并返回未知标记,使其工作。这使得标记器在许多任务上表现不佳,如代码、名称和实体识别,以及跨领域的泛化。

基于字符

基于字符的编码方法是最直接且最容易实现的,因为我们是在 UTF-8 字符编码上进行分割。使用这种方法,我们只需要极小的字典来将字符映射到数字,这意味着我们可以避免未知标记和相关问题的需要。然而,它伴随着主要的信息损失,并且无法保留文本的相关语法、语义或形态。

基于子词

就像灰姑娘和三只熊的故事一样,基于字符的标记器太硬,基于单词的标记器太软,而基于子词的标记器则恰到好处。基于子词的标记器已被证明是最佳选择,它是前两种方法的混合体。我们能够使用像基于字符的标记器一样的小型字典,但丢失的语义像基于单词的标记器那样少。它甚至还有额外的优势,包括一些形态信息。然而,关于单词应该如何分割以及在哪里分割的问题尚未解决,并且存在许多不同的方法和途径。最佳方法的选择将像所有其他与 LLMs 相关的事物一样,取决于任务。如果你没有明确的目标来指导你想要做什么,那么将来可能会有后果。

创建子词字典主要使用三种算法:BPE、WordPiece 和 Unigram。此外,SentencePiece,它是三种方法的结合,可以显式处理空白,也非常常见。讨论它们的工作原理超出了本书的范围,但作为一个专注于生产的书籍,你应该知道最流行的子词标记化方法是 BPE(GPT-x)和 SentencePiece(LlamaX)。

在列表 4.10 中,我们将介绍如何训练 BPE 和 SentencePiece 的定制版本,以便你能够直面(几乎)任何数据集。在阅读代码时,请注意我们在哪里训练标记器。特别是,你将想要调整三个关键参数:vocab_sizemin_frequencyspecial_tokens。更大的词汇量意味着你的标记器将更加健壮,并且可能更擅长处理更多语言,但它会增加计算复杂性。最小频率决定了特定子词标记在数据集中出现多少次之前才会被添加到字典中。较大的值可以防止罕见且可能不重要的标记填充我们的字典,并防止我们学习到重要的罕见标记。最后,特殊标记相对简单,包括我们特别关注于模型训练的句法标记。

列表 4.10:训练自己的子词标记器
import os
from pathlib import Path

import transformers
from tokenizers import ByteLevelBPETokenizer, SentencePieceBPETokenizer
from tokenizers.processors import BertProcessing

paths = [str(x) for x in Path("./data/").glob("**/*.txt")]      #1
bpe_tokenizer = ByteLevelBPETokenizer()   #2

bpe_tokenizer.train(                      #2
    files=paths,                          #2
    vocab_size=52_000,                    #2
    min_frequency=2,                      #2
    show_progress=True,                   #2
    special_tokens=[                      #2
        "<s>",                            #2
        "<pad>",                          #2
        "</s>",                           #2
        "<unk>",                          #2
        "<mask>",                         #2
    ], #2
) #2

token_dir = "./chapters/chapter_4/tokenizers/bytelevelbpe/"
if not os.path.exists(token_dir):
    os.makedirs(token_dir)
bpe_tokenizer.save_model(token_dir)

bpe_tokenizer = ByteLevelBPETokenizer(
    f"{token_dir}vocab.json",
    f"{token_dir}merges.txt",
)

example_text = "This sentence is getting encoded by a tokenizer."
print(bpe_tokenizer.encode(example_text).tokens)  
# ['This', 'Ġsentence', 'Ġis', 'Ġgetting', 'Ġenc', \
# 'oded', 'Ġby', 'Ġa', 'Ġto', 'ken', 'izer', '.']
print(bpe_tokenizer.encode(example_text).ids)
# [2666, 5651, 342, 1875, 4650, 10010, 504, 265, \ 
# 285, 1507, 13035, 18]

bpe_tokenizer._tokenizer.post_processor = BertProcessing(
    ("</s>", bpe_tokenizer.token_to_id("</s>")),
    ("<s>", bpe_tokenizer.token_to_id("<s>")),
)
bpe_tokenizer.enable_truncation(max_length=512)

special_tokens = [
    "<s>",
    "<pad>",
    "</s>",
    "<unk>",
    "<cls>",
    "<sep>",
    "<mask>",
]
sentencepiece_tokenizer = SentencePieceBPETokenizer()    #3

sentencepiece_tokenizer.train(                           #3
    files=paths,                                         #3
    vocab_size=4000,                                     #3
    min_frequency=2,                                     #3
    show_progress=True,                                  #3
    special_tokens=special_tokens,                       #3
) #3

token_dir = "./chapters/chapter_4/tokenizers/sentencepiece/"
if not os.path.exists(token_dir):
    os.makedirs(token_dir)
sentencepiece_tokenizer.save_model(token_dir)

tokenizer = transformers.PreTrainedTokenizerFast(
    tokenizer_object=sentencepiece_tokenizer,
    model_max_length=512,
    special_tokens=special_tokens,
)                                     #4
tokenizer.bos_token = "<s>"
tokenizer.bos_token_id = sentencepiece_tokenizer.token_to_id("<s>")
tokenizer.pad_token = "<pad>"
tokenizer.pad_token_id = sentencepiece_tokenizer.token_to_id("<pad>")
tokenizer.eos_token = "</s>"
tokenizer.eos_token_id = sentencepiece_tokenizer.token_to_id("</s>")
tokenizer.unk_token = "<unk>"
tokenizer.unk_token_id = sentencepiece_tokenizer.token_to_id("<unk>")
tokenizer.cls_token = "<cls>"
tokenizer.cls_token_id = sentencepiece_tokenizer.token_to_id("<cls>")
tokenizer.sep_token = "<sep>"
tokenizer.sep_token_id = sentencepiece_tokenizer.token_to_id("<sep>")
tokenizer.mask_token = "<mask>"
tokenizer.mask_token_id = sentencepiece_tokenizer.token_to_id("<mask>")
tokenizer.save_pretrained(token_dir)   #5

print(tokenizer.tokenize(example_text))
# ['_This', '_s', 'ent', 'ence', '_is', '_', 'g', 'et', 'tin', 'g', '_'
# 'en', 'co', 'd', 'ed', '_', 'b', 'y', '_a', '_', 't', 'ok', 'en', 
# 'iz', 'er', '.']

print(tokenizer.encode(example_text))
# [814, 1640, 609, 203, 1810, 623, 70, \
# 351, 148, 371, 125, 146, 2402, 959, 632]

1 初始化用于训练的文本

2 训练一个字节对编码标记器

3 训练一个 SentencePiece 标记器

4 转换

5 并保存以备后用!

在 BPE 和 SentencePiece 这两种方法中,我们发现我们几乎同样地使用它们。这主要取决于我们正在微调或作为特定项目基础的模型。在算法上,我们更倾向于 SentencePiece,因为它往往能提高在它之上训练的模型在几乎所有测试中的评估分数,而且它也更接近我们作为人类与形态学互动的方式。

总的来说,标记化会丢失信息,就像将语音转换为文本一样——即,词序(句法)和意义(语义)。关于一个数字是什么以及它如何与字母不同的所有信息在标记化后都完全消失了。为了规避潜在的语义和句法问题,我们需要为这些特征中的每一个创建一个近似值,并找出如何用数学方法在抽象中表示它们,以便将那种意义重新插入到标记化的向量中。为此,我们使用了嵌入。

4.4.2 嵌入

嵌入为标记化过程中生成的向量提供了意义。标记化文本只是将几乎任意分配(基于出现频率)的数字分配给字典,但至少它是一个模型可以摄入的格式。嵌入是下一步,其中创建并查找位置和语义编码,为模型提供额外的上下文,以便在如何(可能)完成所给任务方面做出决策。

嵌入存在不完美之处,原因有很多,但最相关的问题可能是这个理论问题:能否仅使用该集合的子集来表示该集合?在这种情况下,第一个集合是语言,一个或多个,第二个集合是数字、浮点数和数字。数学是用于描述我们接受为真的公理性事物的语言的一个子集。以英语字母表为例:能否仅使用 26 个字母中的某些部分来表示整个字母表?显然不能,但如果原始集合和子集都是无限的,会怎样呢?能否仅使用 0 到 1 之间的十进制数来表示所有数字?鉴于第一个是可数的无限集合,第二个是不可数的无限集合,答案是肯定的,这对语言建模领域应该是一个鼓舞人心的消息。

既然我们已经讨论了为什么不应该完全盲目地依赖嵌入,嵌入就是大多数企业在 LLMs 中寻求的东西。您不需要一个 1.7T 参数的模型来处理客户询问您的定价或通过您的文档进行搜索。正如我们在第二章中讨论的,如果比较的两个嵌入都是由同一模型在相同维度空间中创建的,嵌入具有通过距离进行比较的天生优势。这为各种快速计算和检索打开了大门,您永远不必担心如何托管一个巨大的模型,因为您可以在 CPU 上运行一个较小的嵌入模型,处理数百个标记只需毫秒。

目前,嵌入应用中最受欢迎且最酷的应用之一是检索增强生成(RAG),在这种应用中,你存储与模型整体任务相关的数据,并在提示时将数据的一部分提供给更大的模型,以改善结果。假设我们将 RAG 应用于波士顿住房数据集,并尝试预测新房屋的价值。在这种情况下,我们可以将那所房子的嵌入数据与该地区最相似的房屋进行比较,并在无需评估师验证的情况下生成有根据的评估,只要您检索到的嵌入是最新更新的。

嵌入可用于数十种不同的任务,并且是取自模型最终隐藏状态表示的结果。您模型的每一层都是一个潜在的选择,但普遍共识是在任何解码或最终线性层或 softmax 之前,从最终层提取表示。列表 4.11 提供了一个从 PyTorch 和 Hugging Face 模型中提取嵌入的实际示例。最佳实践规定,您应该使用计划用于推理的任何嵌入模型从文档中提取嵌入,尤其是如果这些嵌入最终将存储在 VectorDB 中。在创建我们的嵌入后,我们展示了如何对结果进行简单的相似度搜索,这是 RAG 系统的基础。

列表 4.11 示例嵌入
import numpy as np
from sentence_transformers import SentenceTransformer
from datasets import load_dataset

model_ckpt = "sentence-transformers/all-MiniLM-L6-v2"     #1
model = SentenceTransformer(model_ckpt)
embs_train = load_dataset("tweet_eval", "emoji", split="train[:1000]")
embs_test = load_dataset("tweet_eval", "emoji", split="test[:100]")

def embed_text(example):                    #2
    embedding = model.encode(example["text"])
    return {"embedding": np.array(embedding, dtype=np.float32)}

print(f"Train 1: {embs_train[0]}")
embs_train = embs_train.map(embed_text, batched=False)
embs_test = embs_test.map(embed_text, batched=False)

embs_train.add_faiss_index("embedding")                 #3

# 
idx, knn = 1, 3  # Select the first query and 3 nearest neighbors    #4

query = np.array(embs_test[idx]["embedding"], dtype=np.float32)
scores, samples = embs_train.get_nearest_examples("embedding", query, k=knn)

print(f"QUERY LABEL: {embs_test[idx]['label']}")                #5
print(f"QUERY TEXT: {embs_test[idx]['text'][:200]} [...]\n")
print("=" * 50)
print("Retrieved Documents:")
for score, label, text in zip(scores, samples["label"], samples["text"]):
    print("=" * 50)
    print(f"TEXT:\n{text[:200]} [...]")
    print(f"SCORE: {score:.2f}")
    print(f"LABEL: {label}")

1 下载嵌入模型和数据集

2 创建嵌入

3 添加 Faiss 索引以允许相似性搜索

4 运行查询

5 打印结果

提取嵌入,就像列表中所示,相当简单,与在数据集上简单地运行推理或训练差别很小。记住,如果你没有使用 sentence transformers,将你的模型设置为eval模式,使用torch.no_grad()运行,如果你在 torch 2.0+上运行,请运行torch.compile(model)。这样应该会立即加快速度并提高计算效率。

另一个尚未解决的难题是如何比较嵌入空间。多年来,数学上合理的比较方法层出不穷,但正如所证明的那样,数学上的合理性并不是首要解决的问题;模式才是。此外,成对比较函数在运行速度上有数学上的限制。如果你正在比较语言嵌入,一个数学上合理的语言上合理的比较方法的转换是解决方案,而语言上合理的比较取决于比较的目标。这里就不多说了,但在附录 C 中,我们将更深入地探讨这个话题,其中我们讨论了扩散和多模态 LLM。

4.5 准备 Slack 数据集

现在我们已经学会了准备训练我们自己的 LLM 所需必要资产的方方面面,我们想在结束本章时准备一个我们可以稍后使用的数据集。对于这个练习,我们将解决行业中的一个非常普遍的问题。我相信大多数读者都经历过或见证过人力资源帮助渠道不断被相同的问题淹没。不管创建了多少个 FAQ 页面,用户都不愿意浪费时间搜索文档,而他们可以询问专家。所以让我们构建一个聊天机器人来回答这些问题!

我们将向你展示如何拉取你公司的 Slack 数据,并为其训练基于 LLM 的聊天机器人做准备。在列表 4.12 中,我们拉取 Slack 数据,过滤掉除了用户数据之外的所有内容,并将其保存到 parquet 文件中。这样,你可以创建一个像你一样说话的机器人,但请随意编辑它。例如,你可能喜欢创建一个像你老板一样说话的机器人,但我建议不要告诉他们,以防他们感到威胁,知道你正在自动化他们从工作中退出。

列表 4.12 拉取 Slack 数据的示例
import slack_sdk
import pandas

token_slack = "Your Token Here"
client = slack_sdk.WebClient(token=token_slack)

auth = client.auth_test()
self_user = auth["user_id"]

dm_channels_response = client.conversations_list(types="im")

all_messages = {}

for channel in dm_channels_response["channels"]:
    history_response = client.conversations_history(channel=channel["id"])
    all_messages[channel["id"]] = history_response["messages"]

txts = []

for channel_id, messages in all_messages.items():
    for message in messages:
        try:
            text = message["text"]
            user = message["user"]
            timestamp = message["ts"]
            txts.append([timestamp, user, text])
        except Exception:
            pass

slack_dataset = pandas.DataFrame(txts)
slack_dataset.columns = ["timestamp", "user", "text"]
df = slack_dataset[slack_dataset.user == self_user]

df[["text"]].to_parquet("slack_dataset.gzip", compression="gzip")

如你所见,这并不复杂!我们有一个示例数据集,我们使用这本书附带的 GitHub 仓库中的脚本拉取了这个数据集。我们将在接下来的章节中使用这个数据集。

在本章中,我们已经讨论了很多内容,但现在你应该已经准备好,并知道如何选择和评估基础模型,准备和清理数据集,以及优化你自己的文本处理器。我们将在下一章中使用这些信息来训练和微调我们自己的 LLM 模型。

摘要

  • 数据工程师拥有独特的数据集来获取和管理 LLMs,例如模型权重、评估数据集和嵌入。

  • 无论你的任务是什么,都有大量的开源模型可供选择,以微调你自己的模型。

  • 基于文本的任务比在传统机器学习任务中找到的简单相等性度量更难评估,但有许多行业基准可以帮助你开始。

  • 评估 LLMs 不仅限于性能,如偏见和潜在危害,这也是你的责任。

  • 你可以使用 Evaluate 库来构建自己的评估指标。

  • 有许多大型开源数据集,但大多数来自网络抓取,需要清理。

  • 指令模式和标注你的数据是清理和分析数据的有效方法。

  • 在具有适当分布的言语行为数据集上微调模型,将有助于其生成与上下文相符的内容。

  • 构建与你的数据相匹配的子词分词器可以大大提高你的模型性能。

  • 许多团队试图使用 LLMs 解决的问题可以通过使用模型中的嵌入来解决。

[1] Joe Reis 和 Matt Housley,数据工程基础,O’Reilly,2022 年。

[2] C. Loizos,“如果 OpenAI 这样做,‘GPT’可能很快就会被商标化,” TechCrunch,2023 年 4 月 25 日,mng.bz/5Omq

[3] N. Muennighoff 等人,“通过多任务微调实现跨语言泛化,” 2022 年 11 月 3 日,arxiv.org/abs/2211.01786

[4] C. Xu 等人,“WizardLM:赋予大型语言模型遵循复杂指令的能力,” 2023 年 6 月 10 日,arxiv.org/abs/2304.12244

[5] Mike Conover 等人,“免费 Dolly:介绍世界上第一个真正开放的指令微调 LLM,” Databricks,2023 年 4 月 12 日,mng.bz/n0e8

[6] D. Hendrycks 等人,“衡量大规模多任务语言理解,” arXiv(康奈尔大学),2020 年 9 月,doi.org/10.48550/arxiv.2009.03300

[7] P. Rajpurkar, R. Jia, 和 P. Liang, “知道你所不知道的:SQuAD 中的不可回答问题,” 2018 年 6 月,arxiv.org/abs/1806.03822

[8] C. Zhou 等人,“LIMA:对齐的‘少即是多’,” arXiv.org,2023 年 5 月 18 日,arxiv.org/abs/2305.11206

[9] T. Dettmers 等人,“SpQR:一种用于近似无损 LLM 权重压缩的稀疏量化表示,” arXiv.org,2023 年 6 月 5 日,arxiv.org/abs/2306.03078

[10] 高磊等,“The Pile:用于语言模型的多样化文本数据集,800GB”,2020 年 12 月,arxiv.org/abs/2101.00027

[11] Reis 和 Housley, 数据工程基础,2022 年。

[12] Xu 等人,“WizardLM”,2023 年。

[13] Zou 等人,“LIMA”,2023 年。

第五章:训练大型语言模型:如何生成生成器

本章涵盖

  • 设置训练环境和常见库

  • 应用各种训练技术,包括使用高级方法

  • 提高训练效率的技巧和窍门

水滴石穿,我的朋友。——李小龙

你准备好享受乐趣了吗?!你为什么说前四章没有乐趣?好吧,我保证这一章绝对会有。我们已经提升了很多,并且获得了大量的背景知识,这将证明在开始动手时非常有价值。通过训练 LLM,我们可以创建能够做奇妙事情并具有独特个性的机器人。确实,我们可以创造新的朋友并与他们玩耍。在上一个章节中,我们向您展示了如何根据您的 Slack 消息创建一个训练数据集。现在,我们将向您展示如何使用这个数据集创建一个代表您自己的角色。最后,您将不再需要与那个令人讨厌的同事交谈,就像 Gilfoyle 一样,您可以有您自己的 AI Gilfoyle (youtu.be/IWIusSdn1e4)。

首先,我们将向您展示如何设置一个训练环境,因为这个过程可能非常资源密集,如果没有适当的设备,您将无法享受接下来的内容。然后,我们将向您展示如何进行基础知识的学习,比如从头开始训练和微调,之后我们将介绍一些最知名的方法来改进这些过程,使它们更高效、更快、更便宜。我们将以一些我们在训练模型过程中获得的经验和技巧来结束这一章。

5.1 多 GPU 环境

训练是一项资源密集型的工作。一个模型可能只需要单个 GPU 进行推理,但如果要并行化工作并加快速度,以便您不必等待千年才能完成训练,那么它可能需要 10 倍于此的 GPU。为了真正利用本章想要教给您的知识,我们首先需要让您在一个可以当作游乐场使用的环境中设置好。在本章的后面部分,我们还将教授一些资源最优策略,但如果你想要使用最大的 LLM,你将需要了解如何设置多 GPU 环境。

虽然您可以使用较小的 LLM 学到很多东西,但专业人士和业余爱好者之间的区别往往在于他们与大型模型工作的轻松和流畅。这有一个很好的原因,因为总体而言,大型模型优于小型模型。如果您想使用最大的模型,您将永远无法在笔记本电脑上开始。即使是大多数配备双 GPU 的定制游戏机也不足以进行推理,更不用说训练了。

为了这个目的,我们想与您分享一些获取云中多 GPU 环境访问权限的方法,然后我们将分享利用它们所需的工具和库。最大的模型无法适应单个 GPU,所以没有这些环境和工具,您将永远被困在简单模式下。

5.1.1 设置

需要事先指出的是,虽然多 GPU 环境功能强大,但它们也很昂贵。当涉及到多 GPU 时,我们所知的任何服务都没有提供免费层或优惠,但至少您可以放心,按小时付费将比批发购买设备便宜得多。当然,如果您能让您的公司支付账单,我们推荐这样做,但关闭您创建的任何环境以避免不必要的费用仍然是您的责任。

如果您的公司支付费用,那么它很可能已经选择了一个使整个过程变得简单的托管服务。对于我们其他人来说,在谷歌的计算引擎(Compute Engine)中设置一个虚拟机(VM)是其中最简单的方法之一。一旦设置完成,我们就会向您展示如何使用它。

读者须知

为了学习目的,我们在本书的代码列表中使用了较小的模型,这样您就可以在单个 GPU 上本地工作,或者使用 Colab 或 Kaggle 等提供单个 GPU 免费层的服务。虽然这些列表可以在仅 CPU 的硬件上运行,但您可能不会想这样做。最终,在本书中运行这些昂贵的 VM 不应该有任何必要。然而,您可能仍然会想这样做。使用多个 GPU 进行训练要快得多,效率更高,并且通常是必要的。我们确实鼓励您尝试需要这些大型设备的大型 LLM 变体,因为这种体验是无价的。为了方便起见,您应该能够将本章中的代码回收用于比展示的模型和数据集更大的模型,这通常只是更改几行的问题。

谷歌虚拟机

创建多 GPU 环境的最简单方法之一是在谷歌云上设置一个 VM。要开始,您需要创建一个账户,创建一个谷歌云项目(GCP),设置账单,并下载 gcloud CLI。这些步骤都不特别困难,但请确保按照您操作系统的文档在cloud.google.com/sdk/docs/install-sdk中找到的说明来安装 SDK。这里的步骤还包括在“开始之前”部分中创建账户、项目和账单的步骤和操作指南,如果您还没有账户的话。

对于新账户,谷歌提供 300 美元的信用额度,可用于他们在 GCP 平台上的几乎所有服务,除了 GPU。我们很遗憾地告诉你这个消息,但很遗憾,我们这里没有免费的午餐。所以你需要确保升级到付费的 GCP 层级。不要担心;只需跟随操作,只需花费几美元,但如果你是精打细算的人,我们建议先阅读整个部分,然后再尝试。

在设置好账户后,默认情况下,GCP 将你的 GPU 配额设置为 0。配额用于管理你的成本。要增加配额,请访问console.cloud.google.com/iam-admin/quotas。你将寻找gpus_all_regions配额,由于我们计划使用多个 GPU,请提交一个请求将其增加到 2 个或更多。

在所有先决条件都准备就绪的情况下,我们将开始初始化并登录。你需要在电脑上的终端中运行以下命令:

$ gcloud init

如果你需要安装 SDK,你可能已经完成了这一步,如果没有,它将启动一个网络浏览器,帮助我们登录并授权 gcloud CLI,这样我们就可以选择我们的项目。我们将假设你只有一个项目,但如果你不是第一次操作并且有多个项目,你需要在所有后续命令中添加--project标志。

接下来,我们需要确定两件事:机器类型(或我们想要使用的 GPU)和我们的容器镜像。要选择机器类型,你可以查看cloud.google.com/compute/docs/gpus上的不同选项。对于初学者,我们强烈推荐 NVIDIA L4 GPU,因为它是一款全能型的出色机器。对于我们来说,我们将使用带有两个 L4 GPU 的 g2-standard-24,每小时成本约为 2 美元。这种机器类型并非在每个区域和区域中都有,但你可以在cloud.google.com/compute/docs/regions-zones找到离你较近的区域。我们将使用 us-west1 区域和 us-west1-a 区域。

对于容器镜像,我们将使用一个已经设置好所有基本配置的镜像,这样可以节省我们很多麻烦。通常这意味着创建自己的镜像,但谷歌提供了几个预构建的深度学习容器镜像,这些镜像非常适合使用,或者作为一个基础镜像进行定制的好起点。这些镜像都可以在谷歌拥有的deeplearning-platform -release项目中找到。要查看可用的选项,你可以运行

$ gcloud compute images list --project deeplearning-platform-release 
    --format=”value(NAME)” --no-standard-images

注意:你可以在cloud.google.com/deep-learning-vm/docs/images这里了解更多关于容器镜像选项的信息。

您可以从基础、TensorFlow 和 PyTorch 编译的镜像中进行选择,以及 CUDA 和 Python 版本。我们将使用 common-gpu-v20230925-debian-11-py310,这是一个简单的镜像,适用于带有 Debian Linux 发行版和 Python 3.10 的 GPU。现在我们已经拥有了所有需要的东西,我们可以创建我们的虚拟机了!请继续运行以下命令来设置虚拟机:

$ INSTANCE_NAME="g2-llminprod-example"
    $ gcloud compute instances create ${INSTANCE_NAME} --zone=us-west1-a 
    --machine-type=g2-standard-24 --image-project=deeplearning-platform-release
    --image=common-gpu-v20230925-debian-11-py310 --boot-disk-size=200GB --scopes
    cloud-platform --metadata=install-unattended-upgrades=False,install-nvidia-
    driver=True --maintenance-policy TERMINATE --restart-on-failure

第一个命令创建一个环境变量来存储我们的虚拟机名称,因为我们还将在接下来的几个命令中使用它。这个名称可以是您想要的任何名称。下一个命令创建我们的虚拟机实例。前几个标志(zoneimagemachine)应该是有意义的,因为我们刚刚在前面的段落中准备和收集了这些信息。boot-disk-size 设置虚拟机的磁盘空间,默认为 200 GB,因此它被包括在这里,因为对于 LLMs 来说很重要,因为它们是大型资产,您可能需要增加它——特别是对于需要多个 GPU 来运行的 LLMs。

scopes 标志用于设置授权。当前的 GCP 最佳实践建议将其设置为 cloud-platform,这通过 OAuth 和 IAM 角色确定授权。metadata 字段不是必需的,但在此用作技巧以确保已安装 NVIDIA 驱动程序。如果您使用这些命令创建一个 shell 脚本来自动化此过程,它将非常有用。您应该知道,它将在虚拟机启动和您实际可以 SSH 进入之间造成轻微的延迟,因为在安装驱动程序时它不会响应。如果您不包括它,第一次通过终端 SSH 进入时,它会询问您是否要安装它,所以没有损害。然而,如果您通过其他方法(在下文中描述)访问虚拟机,可能会遇到问题。最后两个命令是标准的维护策略。

一旦运行,您可以通过运行以下命令来验证虚拟机是否已启动:

$ gcloud compute instances describe ${INSTANCE_NAME}

此命令将为您提供有关您实例的大量信息,值得仔细查看,包括应读取为 'RUNNING' 的状态字段。一旦您确认它已启动,我们将通过 SSH 进入它。如果您是第一次使用 gcloud 进行 SSH,将自动生成 SSH 密钥。请继续运行以下命令:

$ gcloud compute ssh ${INSTANCE_NAME}

您的终端将进入我们的多 GPU 虚拟机,您现在可以开始工作了。在这个时候,您的虚拟机仍然只是一个空壳,所以您需要引入代码。最简单的方法是使用安全复制协议(SCP)复制文件。您可以为此执行单个文件或整个目录。例如,假设您的项目有一个 requirements.txt 文件和一个子目录 local-app-folder,从一个新的终端,您可以运行以下命令:

$ gcloud compute scp requirements.txt ${INSTANCE_NAME}:~/requirements.txt
$ gcloud compute scp --recurse ~/local-app-folder/ 
${INSTANCE_NAME}:~/vm-app-folder

总体来说,还不错。一旦您完成了这个过程并设置了所有内容,下次设置虚拟机时,只需四个命令(createdescribesshscp)即可启动和运行。

当然,这些实例会花费不少钱,所以在继续之前,你想要知道的最后一个命令是如何删除它:

$ gcloud compute instances delete ${INSTANCE_NAME} --quiet

对于 Linux 高级用户来说,这一行代码可能就足够了,但对于我们这些普通人来说,通过终端进入虚拟机的工作环境并不理想。我们将向你展示一些技巧和窍门,以充分利用你的远程机器。

通过 VS Code 进行 SSH

对于大多数开发者来说,终端就足够了,但我们真正想要的是 IDE。大多数 IDE 都提供远程 SSH 功能,但我们将使用 VS Code 进行演示。第一步是安装扩展程序 Remote-SSH(你可以在这里找到扩展程序:mng.bz/q0dE)。其他扩展也提供这种功能,但 Remote-SSH 由微软维护,并且有超过 1700 万次安装,因此对于初学者来说是一个很好的选择。

接下来,我们将运行一个配置命令:

$ gcloud compute config-ssh

然后,在 VS Code 内部,你可以按 F1 键打开命令面板并运行 Remote-SSH: Open SSH Host…命令,你应该会看到你的虚拟机的 SSH 地址,它看起来像 l4-llm-example.us-west1-a.project-id-401501. 如果你看不到它,那么config-ssh命令可能出了问题,你可能需要再次运行gcloud init。选择地址,一个新的 VS Code 窗口应该会弹出。在底角,你会看到它正在连接到你的远程机器。这样你就完成了!很简单。从这里开始,你可以像在本地使用一样使用 VS Code。

5.1.2 库

虽然设置硬件很重要,但没有软件包使不同的硬件点能够有效地相互通信,任何硬件都无法工作。在使用 LLMs 的情况下,软件的重要性被放大了。一位作者亲身体验过所有硬件都正确配置,并且非常确信软件设置也同样配置正确,但启动训练模型时,却遇到了预计训练时间超过三年的情况。经过故障排除,团队意识到这是因为他安装了多个版本的 CUDA Toolkit,而 PyTorch 正在查看一个不兼容(最新版)的版本,而不是他打算使用的那个版本。

这些软件包不仅仅是为了使用 CUDA 与 GPU 的低级通信;它们还涉及到在数据通过每个计算运行时进行负载均衡、量化和并行化,以确保尽可能快地运行,同时仍然为矩阵提供一定程度的保真度。您不希望花费很长时间确保您的嵌入向量是卓越的表现,然后在运行时被扭曲。因此,我们介绍了每个从业者都应该了解的四个用于多 GPU 实例的深度学习库:DeepSpeed、Accelerate、BitsandBytes 和 xFormers。在撰写本文时,这些库之间的所有互补功能都是实验性的,因此请随意混合匹配。如果您能够一次性无错误地充分利用所有四个库,那么请快速将其放入可重用的容器中。

DeepSpeed

DeepSpeed 是一个用于分布式深度学习的优化库。DeepSpeed 由微软提供支持,并实现了各种增强功能,以提高训练和推理的速度,例如处理极长或不同模态的多个输入、量化、缓存权重和输入,以及目前最热门的话题之一:扩展到数千个 GPU。

如果您记得首先始终安装最新版本的 PyTorch(但不是夜间版本),那么安装过程相当简单。这意味着您还需要事先配置您的 CUDA 工具包。一旦有了这个包,使用pip install deepspeed应该就能让您达到想要的目的,除非,讽刺的是,您使用的是微软的其他产品。如果您使用的是 Windows 操作系统,那么支持仅限于部分,并且您需要遵循几个额外的步骤才能使其在推理模式下工作,而不是在训练模式下。

Accelerate

来自 Hugging Face 的 Accelerate 旨在帮助您抽象化并行化和扩展到多个 GPU 的代码,这样您就可以专注于训练和推理方面。Accelerate 的一个巨大优势是,与 PyTorch 的标准训练循环相比,它仅添加一个导入语句和两行代码,并更改两行其他代码。除此之外,Accelerate 还具有相当简单的 CLI 使用方式,允许它与 Terraform 或 AWS CDK 一起自动化。

Accelerate 在大多数环境中都具有兼容性,只要您的环境是 Python 3.8+和 PyTorch 1.10.0+(首先确保 CUDA 兼容性),您应该能够无问题地使用 Accelerate。一旦完成,使用pip install accelerate应该就能达到目的。如果您想同时获得 DeepSpeed 和 Accelerate 的好处,Accelerate 也提供了对 DeepSpeed 的实验性支持。

BitsandBytes

如果你在这个领域还不认识 Tim Dettmers 这个名字,你应该很快就会熟悉起来。很少有人像他那样做了很多工作,使得 CUDA 驱动的计算变得易于访问。这个包旨在帮助实践者量化模型,并在不同的位数(甚至 INT8)范围内进行高效的矩阵乘法,用于推理(以及可能训练)。

你应该不会在安装 BitsandBytes 时遇到太多麻烦,因为pip install bitsandbytes应该适用于大多数用例。如果你发现自己使用的是 Windows,那么你很幸运:pip install bitsandbytes-windows同样有效。如果你想用它与 Hugging Face 的 transformers 或 PyTorch 一起使用,你需要编辑这两个包中声明的某些最小要求,因为 Windows 版本与常规包的版本号不同。BitsandBytes 提供了 Adam 优化器和 NN 层(如 Linear)的自己的实现,以便在较小的设备上以更快的速度运行深度学习应用,同时最小化准确性的下降。

xFormers

我们推荐的大多数用例中最前沿的库是 xFormers,它是为研究和生产而设计的。遵循一个(希望)熟悉的类似 PyTorch 的独立构建块模式,xFormers 更进一步,提供了在 PyTorch 中相当长一段时间内都不会有的组件。我们使用得相当多的是内存高效的精确注意力,这大大加快了推理速度。

xFormers 比其他包有更多的要求,我们再次强调,使用一个或多个工具来跟踪你的环境是强烈推荐的。在 Linux 和 Windows 上,你需要 PyTorch 2.0.1,使用pip install -U xFormers应该对你有效。尽管如此,使用几乎任何其他版本的 PyTorch 都有安装路径,但主要版本是 1.12.1、1.13.1 和 2.0.1。

在表 5.1 中,我们可以看到每个包所做的工作及其如何与你的代码集成的详细分解。每个包都做类似的事情,但即使执行相同的任务,它们通常也会以不同的方式或在不同的模型或管道部分执行这些任务。包之间存在一些重叠,我们鼓励你使用所有这些包,看看它们可能如何对你有所帮助。现在你已经有了环境,并对我们将要使用的一些工具有了基本了解,让我们继续前进,看看它是如何运作的。

表 5.1 机器学习优化包比较
更快的训练或推理 代码集成 降低了准确性 许多 GPU 量化 优化
DeepSpeed Both CLI Depends Yes Supports 缓存、梯度检查点、内存管理、扩展
Accelerate Both CLI and Code Depends Yes Supports 自动化、编译、并行化
BitsandBytes Both Code Always NA Yes but only 量化、量化优化器
xFormers Training Code Depends NA Yes and more 高效的注意力机制、内存管理

5.2 基本训练技术

在训练大型语言模型(LLM)时,通常的过程是从定义模型的架构、所需数据的性质和数量以及训练目标开始。我们已经在上一章中讨论了这些步骤,所以你应该已经做好了充分的准备,但让我们简要回顾一下。由于 Transformer 架构在捕捉长期依赖关系和其可并行化性质方面的有效性,模型架构通常遵循其变体,这使得它适合大规模计算。数据是任何 LLM(或任何一般机器学习模型)的生命线,通常需要大量多样且具有代表性的文本数据。由于模型的目的是学习预测序列中的下一个单词,因此确保数据覆盖广泛的语言环境至关重要。

因为在本章中我们将讨论各种训练技术,所以这里简要概述一下不同类型所需的投入。对于从头开始训练,你需要比模型参数数量多四倍以上的 VRAM 来存储模型,以及训练数据批次。因此,要训练一个 1B 参数的模型,你需要至少 5 或 6GB 的 VRAM,具体取决于你的批次大小和上下文长度。以训练一个 70B 参数的模型如 Llama 2 为例。你需要多少 VRAM 来适应模型,以及一个 32K 令牌的上下文限制?如果你得出的数字大约是 300GB 的 VRAM,那么你是对的。对于微调技术,由于以下几个原因,你将需要显著更少的资源——即量化以及所需的数据量,这意味着你不再需要 4 倍的 VRAM,但可以使用 2 倍或 1 倍的 VRAM,只要设置正确。

与传统的机器学习模型不同,LLM 通常分阶段进行训练。图 5.1 显示了 LLM 的基本训练生命周期,从零开始,然后是微调,最后是提示。第一步是创建我们的基础模型,我们在这个大型、通常未经精炼的数据集上训练一个空壳模型。这次训练将创建一个模型,它已经看到了如此大量的文本,似乎对语言有基本理解。然后我们可以使用那个基础模型,并采用迁移学习技术,通常在小型、高度精选的数据集上进行微调,以创建一个针对专家任务的专用 LLM。最后,我们使用提示技术,虽然这不是传统的训练方法,但它允许我们刺激模型以特定的方式或格式响应,从而提高我们结果的准确性。

figure

图 5.1 LLM 的训练生命周期。我们首先基于大量文本创建一个基础模型,然后使用针对特定任务的精选数据集进行微调。然后我们可以通过使用模型本身和提示等技术来增强或扩大我们的精选数据集,进一步改进模型。

你会注意到,训练生命周期通常是一个连续的循环——训练模型以更好地理解语言,然后使用这些模型来改进我们的训练数据集。在本章的后面部分,我们将更深入地探讨其他利用这个循环的高级训练技术,如提示调整和强化学习与人类反馈(RLHF)。现在,让我们巩固对三个基本步骤的理解。

5.2.1 从零开始

训练一个大型语言模型(LLM)在计算上非常密集,即使在高性能硬件上也可能需要几周或几个月的时间。这个过程会将数据块(或称为“批次”)输入到模型中,并根据计算出的损失来调整权重。随着时间的推移,这种预测和调整的迭代过程,也称为一个 epoch,使模型能够提高对数据中的句法结构和复杂性的理解。值得注意的是,监控训练过程对于避免过拟合至关重要,过拟合会导致模型过度适应训练数据,在未见过的数据上表现不佳。使用诸如提前停止、dropout 和学习率调度等技术来确保模型的泛化能力,但它们并非万能的解决方案。记住,最终目标不仅仅是最小化训练数据上的损失,而是创建一个能够在广泛语境下理解和生成类似人类文本的模型。

从零开始训练一个大型语言模型(LLM)是一个复杂的过程,它始于定义模型的架构。这个决定应该由具体任务、训练数据集的大小以及可用的计算资源来指导。简单来说,架构是模型的蓝图,它描述了层的数量和排列、层的类型(如注意力层或前馈层),以及它们之间的连接。现代的 LLM 通常采用 Transformer 架构的变体,这种架构以其处理长序列数据的可扩展性和效率而闻名。

一旦确定了模型的架构,下一步就是为训练编译一个大型且多样化的数据集。输入到模型中的数据的质量和多样性在很大程度上决定了模型理解和生成类似人类文本的能力。一种常见的方法是使用大量的网络文本语料库,确保有广泛多样的风格、主题和结构。然后对这些数据进行预处理和分词,将原始文本转换为模型可以学习的数值格式。在分词过程中,文本被分割成更小的单元,或称为标记,这些标记可能短至单个字符,也可能长至一个单词。

模型和数据集准备就绪后,下一步是初始化模型并设置学习目标。LLM 的训练使用自回归半监督学习技术,其中模型学习根据前面的单词预测序列中的下一个单词。模型的权重随机初始化,然后通过反向传播和优化技术(如 Adam 或随机梯度下降)根据模型预测与训练数据中实际单词之间的差异进行调整。目标是最小化这种差异,通常称为“损失”,以提高模型的预测准确性。

训练过程包括将分词后的文本输入到模型中,并调整模型的内部参数以最小化损失。我们之前已经提到过,但需要再次强调:这个过程计算量很大,可能需要数周甚至数月才能完成,具体取决于模型的大小和可用的硬件。训练完成后,模型将在一个单独的验证数据集上评估,以确保它能够泛化到未见过的数据。根据模型在验证集上的表现,通常需要迭代这个过程,微调模型参数并根据需要调整架构。

让我们探索从零开始训练一个全新的基于 Transformer 的语言模型,这意味着没有任何先前定义的架构、嵌入或权重。图 5.2 展示了这个过程。通常你不需要从头开始训练 LLM,你通常也不希望这样做,因为这是一项非常昂贵且耗时的任务;然而,了解这个过程可以极大地帮助你。

figure

图 5.2 从头开始训练语言模型(大或小)所需的所有步骤的简化版本。你必须有数据,然后定义所有模型行为,然后才能开始训练。

列表 5.1 允许你运行整个过程而不需要训练一个真正的巨大模型,所以请随意用这段代码进行探索。对于更复杂和完整的示例,请查看 Andrej Karpathy 的 minGPT 项目:github.com/karpathy/minGPT。在审查列表时,请注意一些事情。你可能还记得我们在上一章讨论了标记化和嵌入,所以要注意的一点是,为了简单起见,我们将使用基于字符的标记化器。在你运行代码之前,你能预测这是不是一个好主意吗?此外,请注意我们如何使用我们之前简要介绍过的 Accelerate 和 BitsandBytes 库;你会发现这些库非常有用。接下来,我们将逐步构建 LLMs 架构,以模块化的方式构建每个部分,并最终定义每个部分的使用数量和放置位置,几乎就像乐高积木一样。最后,在代码的最后一部分,你会看到一个典型的模型训练循环,包括分割数据、分批运行 epoch 等。

列表 5.1 从头开始训练的示例
import os
import torch
from accelerate import Accelerator

import bitsandbytes as bnb  

class GPT(torch.nn.Module):      #1
    def __init__(self):
        super().__init__()
        self.token_embedding = torch.nn.Embedding(vocab_size, n_embed)
        self.positional_embedding = torch.nn.Embedding(block_size, n_embed)
        self.blocks = torch.nn.Sequential(
            *[Block(n_embed, n_head=n_head) for _ in range(n_layer)]
        )
        self.ln_f = torch.nn.LayerNorm(n_embed)
        self.lm_head = torch.nn.Linear(n_embed, vocab_size)

        self.apply(self._init_weights)

    def forward(self, idx, targets=None):
        B, T = idx.shape

        tok_emb = self.token_embedding(idx)
        pos_emb = self.positional_embedding(torch.arange(T, device=device))
        x = tok_emb + pos_emb
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)

        if targets is None:
            loss = None
        else:
            B, T, C = logits.shape
            logits = logits.view(B * T, C)
            targets = targets.view(B * T)
            loss = torch.nn.functional.cross_entropy(logits, targets)

        return logits, loss

    def _init_weights(self, module):
        if isinstance(module, torch.nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, torch.nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def generate(self, idx, max_new_tokens):
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -block_size:]
            logits, loss = self(idx_cond)
            logits = logits[:, -1, :]
            probs = torch.nn.functional.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
        return idx

class Block(torch.nn.Module):          #2
    def __init__(self, n_embed, n_head):
        super().__init__()
        head_size = n_embed // n_head
        self.self_attention = MultiHeadAttention(n_head, head_size)
        self.feed_forward = FeedFoward(n_embed)
        self.ln1 = torch.nn.LayerNorm(n_embed)
        self.ln2 = torch.nn.LayerNorm(n_embed)

    def forward(self, x):
        x = x + self.self_attention(self.ln1(x))
        x = x + self.feed_forward(self.ln2(x))
        return x

class MultiHeadAttention(torch.nn.Module):
    def __init__(self, num_heads, head_size):
        super().__init__()
        self.heads = torch.nn.ModuleList(
            [Head(head_size) for _ in range(num_heads)]
        )
        self.projection = torch.nn.Linear(head_size * num_heads, n_embed)
        self.dropout = torch.nn.Dropout(dropout)

    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)
        out = self.dropout(self.projection(out))
        return out

class Head(torch.nn.Module):
    def __init__(self, head_size):
        super().__init__()
        self.key = torch.nn.Linear(n_embed, head_size, bias=False)
        self.query = torch.nn.Linear(n_embed, head_size, bias=False)
        self.value = torch.nn.Linear(n_embed, head_size, bias=False)
        self.register_buffer(
            "tril", torch.tril(torch.ones(block_size, block_size))
        )

        self.dropout = torch.nn.Dropout(dropout)

    def forward(self, x):
        _, T, _ = x.shape
        k = self.key(x)
        q = self.query(x)
        attention = q @ k.transpose(-2, -1) * k.shape[-1] ** 0.5
        attention = attention.masked_fill(
            self.tril[:T, :T] == 0, float("-inf")
        )
        attention = torch.nn.functional.softmax(attention, dim=-1)
        attention = self.dropout(attention)

        v = self.value(x)
        out = attention @ v
        return out

class FeedFoward(torch.nn.Module):
    def __init__(self, n_embed):
        super().__init__()
        self.net = torch.nn.Sequential(
            torch.nn.Linear(n_embed, 4 * n_embed),
            torch.nn.ReLU(),
            torch.nn.Linear(4 * n_embed, n_embed),
            torch.nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)

def encode(string):      #3
    return [utt2int[c] for c in string]

def decode(line):
    return "".join([int2utt[i] for i in line])

def get_batch(split):
    data = train_data if split == "train" else val_data
    idx = torch.randint(len(data) - block_size, (batch_size,))
    x = torch.stack([data[i : i + block_size] for i in idx])
    y = torch.stack([data[i + 1 : i + block_size + 1] for i in idx])
    x, y = x.to(device), y.to(device)
    return x, y

@torch.no_grad()
def estimate_loss():
    out = {}
    model.eval()
    for split in ["train", "val"]:
        losses = torch.zeros(eval_iters)
        for k in range(eval_iters):
            X, Y = get_batch(split)
            logits, loss = model(X, Y)
            losses[k] = loss.item()
        out[split] = losses.mean()
    model.train()
    return out

if __name__ == "__main__":       #4
    batch_size = 64  # Number of utterances at once      #5
    block_size = 256  # Maximum context window size
    max_iters = 5000
    eval_interval = 500
    learning_rate = 3e-4
    eval_iters = 200
    n_embed = 384
    n_head = 6
    n_layer = 6
    dropout = 0.2
    accelerator = Accelerator()
    device = accelerator.device
    doing_quantization = False  # Change to True if imported bitsandbytes

    with open("./data/crimeandpunishment.txt", "r", encoding="utf-8") as f:  #6
        text = f.read()

    chars = sorted(list(set(text)))     #7
    vocab_size = len(chars)
    utt2int = {ch: i for i, ch in enumerate(chars)}
    int2utt = {i: ch for i, ch in enumerate(chars)}

    data = torch.tensor(encode(text), dtype=torch.long)
    n = int(0.9 * len(data))
    train_data = data[:n]
    val_data = data[n:]

    model = GPT().to(device)          #8
    print("Instantiated Model")
    print(
        sum(param.numel() for param in model.parameters()) / 1e6,
        "Model parameters",
    )

    optimizer = (
        torch.optim.AdamW(model.parameters(), lr=learning_rate)
        if not doing_quantization
        else bnb.optim.Adam(model.parameters(), lr=learning_rate)
    )
    print("Instantiated Optimizer")

    model, optimizer, train_data = accelerator.prepare(
        model, optimizer, train_data
    )
    print("Prepared model, optimizer, and data")

    # 
    for iter in range(max_iters):     #9
        print(f"Running Epoch {iter}")
        if iter % eval_interval == 0 or iter == max_iters - 1:
            losses = estimate_loss()
            print(
                f"| step {iter}: train loss {losses['train']:.4f} "
                "| validation loss {losses['val']:.4f} |"
            )

        xb, yb = get_batch("train")
        logits, loss = model(xb, yb)
        optimizer.zero_grad(set_to_none=True)
        accelerator.backward(loss)
        optimizer.step()

    model_dir = "./models/scratchGPT/"      #10
    if not os.path.exists(model_dir):
        os.makedirs(model_dir)

    model_path = model_dir + "model.pt"    #11
    torch.save(
        model.state_dict(),
        model_path,
    )

    loaded = GPT().load_state_dict(model_path)      #12

    context = torch.zeros((1, 1), dtype=torch.long, device=device)    #13
    print(decode(loaded.generate(context, max_new_tokens=500)[0].tolist()))

1 Δefines the overall GPT architecture

2 Δefines the building blocks of the model

3 训练辅助函数

4 训练模型

5 实验参数

6 Δataset

7 基于字符的伪标记化

8 实例化模型并查看参数

9 训练块

10 创建模型目录

11 保存模型

12 加载已保存的模型

13 测试已加载的模型

在列表 5.1 中,我们探讨了 GPT 系列模型如何组合乐高积木,并展示了一个类似于我们在第二章中探索语言模型的训练循环。除了展示模型生成预训练的第一部分之外,这个例子还说明了为什么基于字符的建模,无论是卷积还是其他方式,对于语言建模来说都很弱。你理解了吗?是的,基于字符的建模并不是最好的。字母本身并不包含足够的信息来产生具有统计意义的成果,无论调整量有多大。从语言学的角度来看,这是显而易见的,因为字母和正字法,总的来说,是人类生成意义的表示,这并不是内在捕捉到的。

有一些方法可以帮助捕捉这些信息,比如通过词、子词或句子级别的标记化来增加我们的标记化捕获窗口。我们也可以在向模型展示我们的任务之前完成预训练,以便它尽可能多地捕捉近似表示。接下来,我们将展示结合这两个步骤如何对我们的模型性能产生好处。

5.2.2 迁移学习(微调)

迁移学习是机器学习中的一个基本方法,也是训练 LLM 的基石。它基于这样的观点:我们可以从一个问题(源领域)中学到的知识重新使用,并将其应用到不同但相关的问题(目标领域)上。在 LLM 的背景下,这通常意味着使用一个在大规模多样化数据集上训练过的预训练模型,并将其调整到更具体的任务或领域。

在迁移学习的第一步中,一个大型通用语料库上的 LLM(大型语言模型)被训练,例如维基百科的全部内容、书籍或互联网。这个预训练阶段使得模型能够在广泛的各个主题上学习到广泛的语言模式和细微差别。这里的目的是学习一种通用的语言表示,它能够捕捉对语法、语义和世界知识的广泛理解。这些模型通常需要经过多次迭代训练,并且需要大量的计算资源,这就是为什么使用像 OpenAI 或 Hugging Face 这样的组织提供的预训练模型是实际可行的。

预训练之后,LLM 会在特定任务或领域上进行更新。这个更新过程将模型的通用语言理解调整为更具体的任务,例如情感分析、文本分类或问答。更新通常比初始预训练阶段需要的计算资源少得多,因为它涉及到在针对手头任务的小型特定数据集上进行训练。通过这个过程,模型能够将预训练期间获得的大量知识应用到特定任务上,通常优于从头开始训练的模型。这种迁移学习的过程导致了近年来 NLP(自然语言处理)的许多进步。

微调

迁移学习有多种不同的技术,但说到 LLM,大家最关心的还是微调。微调 LLM 涉及取一个预训练模型——即已经在大型通用语料库上训练过的模型——并调整它以执行特定任务或理解特定数据领域。

这种技术利用了基础模型已经对语言有了相当多的了解的事实,让你能够在不承担相关计算成本和时间的情况下享受到大规模模型的好处。微调的过程将模型现有的知识调整为特定任务或领域,使其更适合你的特定用例。这就像有一个对语言已经理解得很透彻的通才,然后为特定工作提供专业培训。由于与从头开始训练模型相比,这种方法显著降低了计算需求和训练时间,因此对于大多数用户来说通常更可行。

微调的第一步是选择一个合适的预训练模型。这个决定取决于你希望模型执行的具体任务以及你拥有的资源。请记住,这意味着在训练之前为模型的行为设定一个目标。一旦选择了预训练模型,就至关重要地准备你希望模型从中学习的特定数据集。例如,如果你试图微调模型以理解医学语言,这些数据可能是一组医学文本。数据必须以与模型预训练兼容的方式进行预处理和分词。

微调过程涉及在您的特定数据集上训练模型,但有一个转折:不是从头开始学习,而是调整模型的现有知识以更好地适应新数据。这种微调通常使用比初始训练阶段更小的学习率进行,以防止模型忘记之前学到的知识。微调后,模型将在一个单独的数据集上评估,以确保它可以在特定领域中对未见过的数据进行泛化。类似于从头开始训练,这个过程可能需要多次迭代以优化模型性能。微调提供了一种利用 LLMs(大型语言模型)在特定任务或领域中的能力,而无需大量资源或计算时间。见图 5.3。

figure

图 5.3 微调与从头开始训练的不同之处在于,你不需要定义模型行为,你可以使用完全相同的训练循环,并且数据需求量更少。

在列表 5.2 中,我们向您展示了如何微调一个 GPT 模型。注意,与列表 5.1 相比,这个列表中的代码要少得多。我们不需要定义架构或分词器;我们只需使用原始模型中的那些。本质上,我们得以跳过,因为权重和嵌入已经定义好了。

列表 5.2 微调示例
import os
from transformers import (
    GPT2Tokenizer,
    GPT2LMHeadModel,
    GPT2Config,
    DataCollatorForLanguageModeling,
    TrainingArguments,
    Trainer,
)
from datasets import load_dataset

dataset = load_dataset("text", data_files="./data/crimeandpunishment.txt")  #1
dataset = dataset.filter(lambda sentence: len(sentence["text"]) > 1)
print(dataset["train"][0])

model_dir = "./models/betterGPT/"    #2
if not os.path.exists(model_dir):
    os.makedirs(model_dir)
config = GPT2Config(       #3
    vocab_size=50261,
    n_positions=256,
    n_embd=768,
    activation_function="gelu",
)

tokenizer = GPT2Tokenizer.from_pretrained("gpt2")    #4
special_tokens_dict = {
    "bos_token": "<BOS>",
    "eos_token": "<EOS>",
    "pad_token": "<PAD>",
    "mask_token": "<MASK>",
}
tokenizer.add_special_tokens(special_tokens_dict)

model = GPT2LMHeadModel.from_pretrained(                 #5
    "gpt2", config=config, ignore_mismatched_sizes=True
)

def tokenize(batch):     #6
    return tokenizer(
        str(batch), padding="max_length", truncation=True, max_length=256
    )

tokenized_dataset = dataset.map(tokenize, batched=False)    #7
print(f"Tokenized: {tokenized_dataset['train'][0]}")

data_collator = DataCollatorForLanguageModeling(       #8
    tokenizer=tokenizer, mlm=True, mlm_probability=0.15
)  # Masked Language Modeling - adds <MASK> tokens to guess the words

train_args = TrainingArguments(      #9
    output_dir=model_dir,
    num_train_epochs=1,
    per_device_train_batch_size=8,
    save_steps=5000,
    save_total_limit=2,
    report_to="none",
)

trainer = Trainer(      #10
    model=model,
    args=train_args,
    data_collator=data_collator,
    train_dataset=tokenized_dataset["train"],
)

trainer.train()                #11
trainer.save_model(model_dir)
tokenizer.save_pretrained()

model = GPT2LMHeadModel.from_pretrained(model_dir)   #12
input = "To be or not"                                    #13
tokenized_inputs = tokenizer(input, return_tensors="pt")
out = model.generate(
    input_ids=tokenized_inputs["input_ids"],
    attention_mask=tokenized_inputs["attention_mask"],
    max_length=256,
    num_beams=5,
    temperature=0.7,
    top_k=50,
    top_p=0.90,
    no_repeat_ngram_size=2,
)
print(tokenizer.decode(out[0], skip_special_tokens=True))

1 加载并格式化数据集

2 创建模型目录以保存

3 建立我们的 GPT-2 参数(与论文和 scratchGPT 不同)

4 实例化我们的分词器和特殊标记

5 从配置中实例化我们的模型

6 创建一个分词函数

7 对整个数据集进行分词(这样我们就不必再次进行)

8 创建一个数据收集器以格式化训练数据

9 建立训练参数

10 实例化训练器

11 训练并保存模型

12 加载保存的模型

13 测试保存的模型

比较列表 5.2 和列表 5.1,它们的架构几乎完全相同(除了激活函数),并且它们在完全相同的数据上训练。然而,由于第一个模型中缺乏学习到的表示,微调后的 GPT-2 模型有了显著的改进。我们的预训练模型,以及与基于字符的标记化相比的子词 BPE 标记化,帮助模型确定哪些统计上确定的单位最有可能一起出现。不过,你会发现,即使经过预训练,GPT-2 在使用更新、更好的激活函数的情况下,仍然难以生成相关的较长的叙述。

微调 OpenAI

我们刚刚从头开始训练了一个 GPT 模型,然后微调了 GPT-2,但我们知道许多读者真正想要的是 OpenAI 更大 GPT 模型背后的力量。尽管这些是专有模型,但 OpenAI 非常慷慨地创建了一个 API,我们可以通过它微调 GPT-3 模型。目前,OpenAI 的平台上有三个模型可供微调,但看起来它打算将这种微调能力扩展到其提供的所有模型。OpenAI 编写了一整篇指南,你可以在platform.openai.com/找到,但一旦你以必要的格式准备好了你的数据集,代码就相当简单。以下是针对各种任务的片段:

import os
from openai import OpenAI

client = OpenAI()
client.api_key = os.getenv("OPENAI_API_KEY")
client.files.create(
  file=open("mydata.jsonl", "rb"),
  purpose='fine-tune'
)

这个片段首先上传了一个符合平台正确格式的训练数据集,并指定了用途为微调,但还没有开始这个过程。接下来,你需要创建微调作业:

client.fine_tuning.jobs.create(training_file="file-abc123", model="gpt-3.5-turbo")

这是你指定要微调哪个训练文件和哪个模型的地方。一旦 OpenAI 的训练循环完成,当你检索作业详情时,你将看到微调模型的名称被填充。现在你可以像使用任何常规模型一样使用该模型进行聊天完成或其他类似操作:

completion = client.chat.completion.create(
  model="ft:gpt-3.5-turbo:my-org:custom_suffix:id",
  messages=[
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Hello!"}
  ]
)
print(completion.choices[0].message)

这就是微调 OpenAI 模型的所有内容!非常简单,不需要太长时间,截至 2023 年 3 月,你的数据对你来说是私密的。当然,你将把微调过程的所有控制权交给 OpenAI。如果你想做一些超越常规微调的事情,你需要自己来做。就在下一分钟,我们将讨论你可能考虑的技术,以及一些可以帮助更精细模型和更复杂任务的更高级过程。

5.2.3 提示

与传统机器学习相比,LLMs 之所以如此强大,主要原因是我们可以实时训练它们。给他们一组指令,然后观察它们尽其所能地遵循这些指令。这种技术被称为提示(prompting),在 LLMs 中用于引导模型输出。本质上,提示是提供给模型的初始输入,为它提供上下文或执行什么操作的指令。例如,“将以下英文文本翻译成法语”和“总结以下文章”都是提示。在 LLMs 的背景下,提示变得尤为重要,因为这些模型并没有被明确编程来执行特定任务,而是根据给定的提示学习响应各种任务。

提示工程是指构建有效的提示来引导模型行为的过程。目标是创建能够引导模型提供最理想或最有用的输出的提示。提示工程可能比看起来更复杂,因为提示语式的微小变化可能会导致模型产生截然不同的响应。提示工程的一些策略包括在提示中更加明确,提供期望输出的示例,或者以不同的方式重新措辞提示以获得最佳结果。它是一种艺术和科学的结合,需要很好地理解模型的能力和限制。

在本章中,我们将主要关注训练和微调,这是部署前的步骤,但如果我们不首先提及提示(prompt),那将是我们的疏忽。我们将在第七章更深入地讨论提示。

5.3 高级训练技术

现在你已经知道了如何进行基础操作,让我们来了解一下一些更高级的技术。这些技术出于各种原因而被开发,例如提高生成的文本输出质量、缩小模型大小、提供持续学习、加快训练速度以及降低成本。根据你组织的需要,你可能需要寻找不同的训练解决方案。虽然这不是一个详尽的列表,但以下技术经常被使用,并且在你准备一个生产就绪的模型时应该是有价值的工具。

经典机器学习训练背景

要了解一些增强微调过程的技术需要一些背景知识。我们不会进行一次完整的机器学习课程;然而,如果你是第一次接触,你应该了解一些经典的实验学习范式,这些范式实验通常会遵循——监督学习、无监督学习、对抗学习和强化学习:

  • 监督学习涉及收集用于训练的数据和展示预期输出的标签。

  • 无监督学习不需要标签,因为数据会被探测相似性并分组到彼此最接近的簇中。

  • 对抗性学习是用于训练生成对抗网络的方法。它涉及两个模型,通常被称为评论家模型(Critic model)和伪造者模型(Forger model)。这两个模型本质上是在玩一个游戏,伪造者试图复制一些理想的输出,而评论家则试图确定伪造品是否为真品。

  • 强化学习(RL)选择建立奖励函数,而不是为模型提供预定义的标签来学习。通过测量模型的行为,根据该函数给予它奖励。

所有大型语言模型(LLM)都必须使用其中至少一种进行训练,并且当所有这些训练方法都正确执行时,它们的表现都非常出色。本章讨论的训练技术不同于那些基本方法,从向模型添加某种形式的人类输入到将输出进行比较,以及改变模型执行矩阵乘法的方式。

5.3.1 提示调整

我们之前已经讨论过语用学,但为了提醒,当语言模型被给予与任务和期望相关的真实世界非语义上下文时,它们的表现会更好。所有语言建模技术都基于这样一个基本假设:给定输入和期望输出,语言模型(LM)可以推断出要完成的任务,并在指定的参数数量内以最佳方式完成它。

虽然模型从数据中推断任务和完成它的方法的想法很有希望,但时间一次又一次地表明,从 BERT 到每个 T5 模型,现在到所有 LLM,为模型提供预期的任务和解决任务所需的相关信息可以极大地提高模型性能。早在 2021 年,谷歌研究、DeepMind 和 OpenAI 都发表了关于提示调整或训练期间为模型提供实用上下文的论文。提示调整的好处是减少模型在训练过程中收敛所需的数据量,甚至更酷的是,能够在不重新训练或完全微调的情况下,为新的任务重用完全冻结的语言模型。

由于 LLM 非常大(并且还在变大),因此共享它们变得越来越困难,甚至在保证它们在特定任务上的性能方面更加困难,即使这些任务是在它们被训练时。提示调整可以帮助模型朝着正确的方向引导,而不会成为重大的成本。图 5.4 展示了这个过程。

figure

图 5.4 中,提示调整(Prompt tuning)放弃了大部分微调,以使大多数基础模型的语言理解能力保持完全不变,相反,它专注于改变模型对特定输入的响应方式。

列表 5.3 展示了如何对 Big Science 中的 BLOOMZ 模型的一个较小变体进行提示调整。BLOOMZ 作为 LLM 空间中的早期竞争者发布,但由于其无法生成首选输出,尽管其数学上合理,但最终在社区中难以引起关注或获得动力。由于提示调整并没有给我们在列表 5.2 中使用的常规微调结构增加太多,我们将执行参数高效微调(PEFT),这通过确定哪些模型参数需要最大程度地改变来大幅减少内存需求。

列表 5.3 提示调整的示例
import os
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    default_data_collator,
    get_linear_schedule_with_warmup,
)
from peft import (
    get_peft_model,
    PromptTuningInit,
    PromptTuningConfig,
    TaskType,
)
import torch
from datasets import load_dataset
from torch.utils.data import DataLoader
from tqdm import tqdm

def preprocess_function(examples):          #1
    batch_size = len(examples[text_column])
    inputs = [
        f"{text_column} : {x} Label : " for x in examples[text_column]
    ]
    targets = [str(x) for x in examples[label_column]]
    model_inputs = tokenizer(inputs)
    labels = tokenizer(targets)

    for i in range(batch_size):
        sample_input_ids = model_inputs["input_ids"][i]
        label_input_ids = labels["input_ids"][i] + [tokenizer.pad_token_id]
        model_inputs["input_ids"][i] = sample_input_ids + label_input_ids
        labels["input_ids"][i] = [-100] * len(
            sample_input_ids
        ) + label_input_ids
        model_inputs["attention_mask"][i] = [1] * len(
            model_inputs["input_ids"][i]
        )
    for i in range(batch_size):
        sample_input_ids = model_inputs["input_ids"][i]
        label_input_ids = labels["input_ids"][i]
        model_inputs["input_ids"][i] = [tokenizer.pad_token_id] * (
            max_length - len(sample_input_ids)
        ) + sample_input_ids
        model_inputs["attention_mask"][i] = [0] * (
            max_length - len(sample_input_ids)
        ) + model_inputs["attention_mask"][i]
        labels["input_ids"][i] = [-100] * (
            max_length - len(sample_input_ids)
        ) + label_input_ids
        model_inputs["input_ids"][i] = torch.tensor(
            model_inputs["input_ids"][i][:max_length]
        )
        model_inputs["attention_mask"][i] = torch.tensor(
            model_inputs["attention_mask"][i][:max_length]
        )
        labels["input_ids"][i] = torch.tensor(
            labels["input_ids"][i][:max_length]
        )

    model_inputs["labels"] = labels["input_ids"]
    return model_inputs

if __name__ == "__main__":         #2
    # Define training parameters
    device = "cuda"
    model_name_or_path = "bigscience/bloomz-560m"
    tokenizer_name_or_path = "bigscience/bloomz-560m"
    dataset_name = "twitter_complaints"
    text_column = "Tweet text"
    label_column = "text_label"
    max_length = 64
    lr = 3e-2
    num_epochs = 1
    batch_size = 8

    peft_config = PromptTuningConfig(      #3
        task_type=TaskType.CAUSAL_LM,
        prompt_tuning_init=PromptTuningInit.TEXT,
        num_virtual_tokens=8,
        prompt_tuning_init_text="Classify if the tweet "
        "is a complaint or not:",
        tokenizer_name_or_path=model_name_or_path,
    )
    checkpoint_name = (
        f"{dataset_name}_{model_name_or_path}"
        f"_{peft_config.peft_type}_{peft_config.task_type}_v1.pt".replace(
            "/", "_"
        )
    )
    dataset = load_dataset("ought/raft", dataset_name)    #4
    print(f"Dataset 1: {dataset['train'][0]}")

    classes = [          #5
        label.replace("_", " ")
        for label in dataset["train"].features["Label"].names
    ]
    dataset = dataset.map(
        lambda x: {"text_label": [classes[label] for label in x["Label"]]},
        batched=True,
        num_proc=1,
    )
    print(f"Dataset 2: {dataset['train'][0]}")

    tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)    #6
    if tokenizer.pad_token_id is None:
        tokenizer.pad_token_id = tokenizer.eos_token_id
    target_max_length = max(
        [
            len(tokenizer(class_label)["input_ids"])
            for class_label in classes
        ]
    )
    print(f"Target Max Length: {target_max_length}")

    processed_datasets = dataset.map(           #7
        preprocess_function,
        batched=True,
        num_proc=1,
        remove_columns=dataset["train"].column_names,
        load_from_cache_file=False,
        desc="Running tokenizer on dataset",
    )

    train_dataset = processed_datasets["train"]     #8
    eval_dataset = processed_datasets["test"]

    train_dataloader = DataLoader(
        train_dataset,
        shuffle=True,
        collate_fn=default_data_collator,
        batch_size=batch_size,
        pin_memory=True,
    )
    eval_dataloader = DataLoader(
        eval_dataset,
        collate_fn=default_data_collator,
        batch_size=batch_size,
        pin_memory=True,
    )
    model = AutoModelForCausalLM.from_pretrained(model_name_or_path)   #9
    model = get_peft_model(model, peft_config)
    print(model.print_trainable_parameters())
    model = model.to(device)

    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)    #10
    lr_scheduler = get_linear_schedule_with_warmup(
        optimizer=optimizer,
        num_warmup_steps=0,
        num_training_steps=(len(train_dataloader) * num_epochs),
    )

    for epoch in range(num_epochs):    #11
        model.train()
        total_loss = 0
        for step, batch in enumerate(tqdm(train_dataloader)):
            batch = {k: v.to(device) for k, v in batch.items()}
            outputs = model(**batch)
            loss = outputs.loss
            total_loss += loss.detach().float()
            loss.backward()
            optimizer.step()
            lr_scheduler.step()
            optimizer.zero_grad()

        model.eval()
        eval_loss = 0
        eval_preds = []
        for step, batch in enumerate(tqdm(eval_dataloader)):
            batch = {k: v.to(device) for k, v in batch.items()}
            with torch.no_grad():
                outputs = model(**batch)
            loss = outputs.loss
            eval_loss += loss.detach().float()
            eval_preds.extend(
                tokenizer.batch_decode(
                    torch.argmax(outputs.logits, -1).detach().cpu().numpy(),
                    skip_special_tokens=True,
                )
            )

        eval_epoch_loss = eval_loss / len(eval_dataloader)
        eval_ppl = torch.exp(eval_epoch_loss)
        train_epoch_loss = total_loss / len(train_dataloader)
        train_ppl = torch.exp(train_epoch_loss)
        print(
            f"{epoch=}: {train_ppl=} {train_epoch_loss=} "
            f"{eval_ppl=} {eval_epoch_loss=}"
        )

    model_dir = "./models/PromptTunedPEFT"    #12
    if not os.path.exists(model_dir):
        os.makedirs(model_dir)

    tokenizer.save_pretrained(model_dir)     #13
    model.save_pretrained(model_dir)

    with torch.no_grad():      #14
        inputs = tokenizer(
            f'{text_column} : {{"@nationalgridus I have no water and '
            "the bill is current and paid. Can you do something about "
            'this?"}} Label : ',
            return_tensors="pt",
        )

        inputs = {k: v.to(device) for k, v in inputs.items()}
        outputs = model.generate(
            input_ids=inputs["input_ids"],
            attention_mask=inputs["attention_mask"],
            max_new_tokens=10,
            eos_token_id=3,
        )
        print(
            tokenizer.batch_decode(
                outputs.detach().cpu().numpy(), skip_special_tokens=True
            )
        )

1 辅助函数用于预处理文本;继续跳转到训练

2 模型提示调整

3 Δefines prompt tuning config; notice init_text

4 加载 Δataset

5 标记数据集

6 加载分词器

7 在数据集上运行分词器并进行预处理

8 准备数据加载器

9 加载基础模型

10 Δefines optimizer

11 训练步骤

12 创建模型目录以保存

13 保存

14 推理

除了更改设置外,列表 5.2 和 5.3 之间的主要区别仅仅是将某种形式的指令作为提示添加到每个输入的开头,这让人联想到 T5 训练方法,该方法在每次输入之前都引入了一个前置任务字符串。提示调整已成为微调大型语言模型到特定任务和领域的一种强大技术。通过调整提示以适应所需的输出并优化以提高性能,我们可以使我们的模型更加灵活和有效。然而,随着我们的 LLM 在规模和复杂性上不断增长,在特定任务上高效微调它们变得越来越具有挑战性。这就是知识蒸馏发挥作用的地方,它提供了一个逻辑上的下一步。知识蒸馏使我们能够将这些高度调优模型的知识和专长转移到更小、更实用的版本中,从而实现更广泛的应用和部署场景。提示调整和知识蒸馏共同构成了利用现代 LLM 全部潜力的技术库中的动态搭档。

5.3.2 使用知识蒸馏进行微调

知识蒸馏是一种高级技术,为微调大型语言模型(LLM)提供了一条更高效的路径。而不是直接微调 LLM,知识蒸馏涉及将知识从一个大型的、复杂的模型(教师模型)转移到一个小型的、简单的模型(学生模型)。目标是创建一个更紧凑的模型,它保留了大型模型的性能特征,但在资源使用方面更加高效。图 5.5 展示了这一过程。

figure

图 5.5 知识蒸馏允许较小的模型从基础模型学习,以更少的参数复制类似的行为。学生模型并不总是学习基础模型涌现出的特性,因此数据集必须特别精心制作。虚线表示一种特殊关系,因为学生模型变成了专门的 LLM。

知识蒸馏的第一步是选择一个预训练的 LLM 作为教师模型。这可以是任何大型模型,例如在大量数据上训练的 Llama 2 70B 或 Falcon 180B。你还需要创建或选择一个较小的模型作为学生。学生模型可能具有与教师模型相似的架构,但层数更少或维度更低,以使其更小、更快。

接下来,学生模型在教师模型相同的任务上进行训练。然而,学生模型不是直接从原始数据中学习,而是学习模仿教师模型的输出。这种训练通常是通过在损失函数中添加一个项来完成的,该项鼓励学生模型的预测与教师模型的预测相似。因此,学生模型不仅从任务特定的标签中学习,而且从教师模型学习到的丰富表示中受益。

一旦蒸馏过程完成,你将拥有一个紧凑的学生模型,它可以处理从教师模型学习到的特定任务,但大小和计算成本仅为教师模型的一小部分。如果需要,蒸馏模型可以进一步在特定任务或数据集上进行微调。通过知识蒸馏,你可以在计算资源或响应时间有限的情况下使用 LLM 的强大功能。

在列表 5.4 中,我们展示了如何使用 BERT 和 DistilBERT 进行知识蒸馏的微调。与常规微调不同,请注意模型的大小和性能。两者都会下降;然而,大小下降的速度将远快于性能。

列表 5.4 知识蒸馏的示例
import os
from transformers import (
    AutoTokenizer,
    TrainingArguments,
    Trainer,
    AutoModelForSequenceClassification,
    DataCollatorWithPadding,
)
from datasets import load_dataset, load_metric

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

def process(examples):
    tokenized_inputs = tokenizer(
        examples["sentence"], truncation=True, max_length=256
    )
    return tokenized_inputs

def compute_metrics(eval_pred):
    predictions, labels = eval_pred
    predictions = np.argmax(predictions, axis=1)
    acc = accuracy_metric.compute(
        predictions=predictions, references=labels
    )
    return {
        "accuracy": acc["accuracy"],
    }

class DistillationTrainingArguments(TrainingArguments):
    def __init__(self, *args, alpha=0.5, temperature=2.0, **kwargs):
        super().__init__(*args, **kwargs)
        self.alpha = alpha
        self.temperature = temperature

class DistillationTrainer(Trainer):
    def __init__(self, *args, teacher_model=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.teacher = teacher_model
        self._move_model_to_device(self.teacher, self.model.device)   #1
        self.teacher.eval()

    def compute_loss(self, model, inputs, return_outputs=False):
        outputs_student = model(**inputs)      #2
        student_loss = outputs_student.loss
        with torch.no_grad():                         #3
            outputs_teacher = self.teacher(**inputs)

        assert (                  #4
            outputs_student.logits.size() == outputs_teacher.logits.size()
        )

        # Soften probabilities and compute distillation loss
        loss_function = nn.KLDivLoss(reduction="batchmean")
        loss_logits = loss_function(
            F.log_softmax(
                outputs_student.logits / self.args.temperature, dim=-1
            ),
            F.softmax(
                outputs_teacher.logits / self.args.temperature, dim=-1
            ),
        ) * (self.args.temperature**2)
        loss = (                               #5
            self.args.alpha * student_loss
            + (1.0 - self.args.alpha) * loss_logits
        )
        return (loss, outputs_student) if return_outputs else loss

if __name__ == "__main__":
    model_dir = "./models/KDGPT/"          #6
    if not os.path.exists(model_dir):
        os.makedirs(model_dir)

    student_id = "gpt2"            #7
    teacher_id = "gpt2-medium"

    teacher_tokenizer = AutoTokenizer.from_pretrained(teacher_id)
    student_tokenizer = AutoTokenizer.from_pretrained(student_id)

    sample = "Here's our sanity check."

    assert teacher_tokenizer(sample) == student_tokenizer(sample), (
        "Tokenizers need to have the same output! "
        f"{teacher_tokenizer(sample)} != {student_tokenizer(sample)}"
    )
    del teacher_tokenizer
    del student_tokenizer

    tokenizer = AutoTokenizer.from_pretrained(teacher_id)
    tokenizer.add_special_tokens({"pad_token": "[PAD]"})

    dataset_id = "glue"
    dataset_config = "sst2"
    dataset = load_dataset(dataset_id, dataset_config)

    tokenized_dataset = dataset.map(process, batched=True)
    tokenized_dataset = tokenized_dataset.rename_column("label", "labels")

    print(tokenized_dataset["test"].features)

    labels = tokenized_dataset["train"].features["labels"].names    #8
    num_labels = len(labels)
    label2id, id2label = dict(), dict()
    for i, label in enumerate(labels):
        label2id[label] = str(i)
        id2label[str(i)] = label

    training_args = DistillationTrainingArguments(    #9
        output_dir=model_dir,
        num_train_epochs=1,
        per_device_train_batch_size=1,
        per_device_eval_batch_size=1,
        fp16=True,
        learning_rate=6e-5,
        seed=8855,
        Evaluation strategies
        evaluation_strategy="epoch",
        save_strategy="epoch",
        save_total_limit=2,
        load_best_model_at_end=True,
        metric_for_best_model="accuracy",
        report_to="none",
        push_to_hub=False,       #10
        alpha=0.5,            #11
        temperature=4.0,
    )

    data_collator = DataCollatorWithPadding(tokenizer=tokenizer)    #12

    teacher_model = AutoModelForSequenceClassification.from_pretrained(   #13
        teacher_id,
        num_labels=num_labels,
        id2label=id2label,
        label2id=label2id,
    )

    student_model = AutoModelForSequenceClassification.from_pretrained(   #14
        student_id,
        num_labels=num_labels,
        id2label=id2label,
        label2id=label2id,
    )
    accuracy_metric = load_metric("accuracy")    #15

    trainer = DistillationTrainer(
        student_model,
        training_args,
        teacher_model=teacher_model,
        train_dataset=tokenized_dataset["train"],
        eval_dataset=tokenized_dataset["validation"],
        data_collator=data_collator,
        tokenizer=tokenizer,
        compute_metrics=compute_metrics,
    )
    trainer.train()

    trainer.save_model(model_dir)

1 将教师模型放置在学生模型相同的设备上

2 计算学生模型的输出

3 计算教师模型的输出

4 断言大小

5 返回加权的学生损失

6 创建模型目录以保存

7 Δ定义教师和学生模型

8 创建 label2id,id2label 字典,以供模型输出使用

9 Δ定义训练参数

10 将参数推送到 hub

11 Δ定义蒸馏参数

12 Δ定义数据收集器

13 Δ定义模型

14 Δ定义学生模型

15 Δ定义指标和指标函数

知识蒸馏,如提供的compute_loss方法所示,是一种技术,它使得从教师模型到更轻量级的学生模型转移有价值见解成为可能。在这个过程中,教师模型提供软目标,提供可能的输出上的概率分布,这些分布随后被用于训练学生模型。知识蒸馏的关键在于这些分布的对齐,确保学生模型不仅学会模仿教师的预测,而且对底层数据有更深入的理解。这种方法有助于提高学生的泛化能力和在各种任务上的表现,最终使其更高效和适应性强。

随着我们展望未来,知识蒸馏之后的一个逻辑进步是引入强化学习与人类反馈(RLHF)。虽然知识蒸馏增强了模型基于现有数据进行预测的能力,但 RLHF 允许模型直接从用户交互和反馈中学习。这种动态组合不仅进一步提升了模型的表现,还使其能够持续适应和改进。通过引入人类反馈,强化学习可以帮助模型适应现实世界场景,根据持续输入调整其决策过程,这在 LLM 系统的发展中是一个令人兴奋且自然的演变。

5.3.3 带有人类反馈的强化学习

RLHF 是一种新开发的训练技术,旨在克服强化学习中的一个最大挑战:如何创建真正有效的奖励系统。听起来很简单,但任何玩过强化学习的人都知道这有多么困难。在 AlphaStar 之前,一位作者正在构建自己的强化学习机器人来玩星际争霸,这是一款太空战争模拟游戏。

备注 欲了解更多关于 AlphaStar 的信息,请访问mng.bz/Dp4a

基于胜负的简单奖励系统耗时过长,所以他决定根据军队的成长给予一些合理的中间奖励。然而,当它未能建造 Pylons(一种用于增加军队供应限制的建筑)时,这一计划受阻。因此,他给予建造 Pylons 的奖励。他的机器人很快学会喜欢建造 Pylons——如此之喜欢,以至于它学会了几乎赢但不赢,削弱了对手,使其能够不受干扰地持续建造 Pylons,直到它想要的时间。

对于像赢得游戏这样的任务,即使它很困难,我们通常仍然可以想出合理的奖励系统。但更抽象的任务,比如教机器人如何做后空翻呢?这些任务设计奖励系统变得非常困难,这就是 RLHF 发挥作用的地方。如果我们不设计系统,而是简单地让人类提供建议会怎样?毕竟,人类知道什么是后空翻。人类将充当导师,在机器人训练过程中挑选出他们更喜欢尝试。这就是 RLHF,它效果非常好。应用于 LLMs 时,人类只需查看对提示生成的响应,并挑选出他们更喜欢的一个。见图 5.6。

figure

图 5.6 RLHF 用损失函数替换了奖励模型和近端策略优化(PPO),使得模型在数据中的学习趋势有更高的上限,包括输出时更倾向于什么而不是完成任务。

尽管 RLHF 非常强大,但它可能不会长期存在。原因是,对于仅略有改进的结果,尤其是在可以通过监督学习方法实现并匹配更高质量数据集的结果,它计算成本极高。

RLHF 还存在一些其他问题,例如它需要雇佣领域专家来评估并提供人类反馈。这不仅可能变得昂贵,还可能导致隐私问题,因为这些审稿人需要查看实际流量和用户交互来评分。为了应对这两个问题,你可以尝试直接将这项工作外包给用户,征求他们的反馈,但如果你的用户有恶意或只是对该主题不精通,这可能会导致你的数据中毒。在这种情况下,他们可能会为那些实际上并不正确但受欢迎的回复点赞。这引出了下一个问题:即使是专家也有偏见。RLHF 并没有训练模型变得更加准确或事实正确;它训练模型生成人类可接受的答案。

在生产中,RLHF 的优势在于它允许你轻松地持续更新你的模型。然而,这是一把双刃剑,因为它也增加了模型随时间退化的可能性。OpenAI 大量使用 RLHF,导致许多用户对其模型,如 GPT-4,在某些领域变得非常糟糕,与最初发布时相比。一项斯坦福大学的研究发现,当在 2023 年 3 月被问及一个数字是否为素数时,GPT-4 曾经有 98%的正确率,但三个月后,在 2023 年 6 月,它只能正确回答 2%。¹一个原因是 6 月的模型更加简洁,选择给出简单的“是”或“否”回答。人类喜欢这样的回答。直接切入要点通常更好,但 LLMs 在经过时间用诸如思维链等技术推理答案后往往表现得更好。

考虑到这一点,RLHF 对于需要人类可接受答案作为黄金标准的应用非常出色,例如友好的聊天机器人或改进摘要任务,而对于事实性正确的答案则不那么重要。这些问题在本质上具有直观的语法性质,基本上是 LLMs 已经擅长但希望通过可能创造某种语气或个性来改进的任务。

RLHF 退化的另一个原因是数据泄露。数据泄露是指你的模型在用于评估它的测试或验证数据集上训练。当这种情况发生时,你实际上是在允许模型作弊,导致过拟合和泛化能力差。这就像 LeetCode 面试题导致科技公司雇佣了大量解决玩具问题但不知道如何赚钱或完成工作的程序员一样。

这是如何发生的呢?简单来说。当你使用 RLHF 在生产环境中运行 LLM 时,你知道它将随着时间的推移而退化,因此最好定期进行评估以监控系统。你运行这些评估的次数越多,就越有可能有人工反馈和随后的 RL 训练选择其中一个提示。如果用户恰好提出了与评估数据集中提示类似的问题,这也可能纯粹是巧合。无论如何,如果没有对 RLHF(通常从未这样做)施加限制,那么它就是一个自我挫败的系统。

通过 RLHF 进行的持续更新真正令人烦恼的地方在于,这些更新会破坏下游工程工作,例如提示或检索增强生成(RAG)等方法。工程团队可能需要投入大量精力来调整查询模型的过程或程序,然后清理响应,但如果底层模型发生变化,所有这些工作都可能轻易被破坏。因此,许多团队更倾向于使用具有定期更新的静态模型,而不是持续更新的模型。

尽管如此,RLHF 仍然是一种强大的技术,随着其优化和改进可能会产生更好的结果。此外,它真的很酷。我们不推荐使用 RLHF,而且这里也没有足够的空间深入探讨;只需知道它是专注于 LLMs 的公司所使用的一种工具。对于想要更好地理解 RLHF 的读者,我们在附录 B 中包含了一个深入的示例和代码列表。

5.3.4 专家混合

混合专家(MoE)在功能上与任何其他训练模型相同,但在底层包含一个技巧:稀疏性。这赋予了同时在一组多样化的数据和任务上训练多个模型的优势。您可以看到,MoE 正是其名称所暗示的:最初是一组相同的模型。您可以将其想象为一群大一新生。然后,使用一些无监督分组方法,例如 k-means 聚类,每个专家在训练过程中“选择一个专业”。这允许模型只激活一些专家来回答特定的输入,而不是所有专家,或者输入可能足够复杂,需要激活所有专家。重点是,一旦训练完成,如果是在足够代表性的数据集上进行的,每个专家都将拥有他们在所研究专业中的大学学位。由于输入的同质性是数学上确定的,这些专业可能不会总是有一个与学校中您所选择的主修相关的名称,但我们喜欢将其视为古怪的双专业或类似的东西。也许您的某个专家主修物理学,但辅修广告和非洲研究。这并不重要,但以这种方式设计模型集的一个主要优点是,您可以有效地大幅减少计算需求,同时通过仅在推理时咨询与标记化输入相关的专家来保留专业性和训练记忆。

在列表 5.5 中,我们使用 Hugging Face 的 API 和 Google 的 Switch Transformer 以与列表 5.2 中用 GPT-2 进行的方式类似的方式微调 MoE 模型。与我们在第三章中描述的方法不同,该方法将前馈网络转换为 MoE,我们将从一个已经创建的 MoE 开始,并在我们的数据集上对其进行训练。现在训练 MoE 相当简单,与它们最初出现时相比。非常聪明的人进行了大量的工程,我们可以对这些模型给出一个过于简化的解释。Google 创建了 Switch Transformer 来应对他们在尝试训练 LLM 时遇到的两个巨大问题:大小和稳定性。Google 工程师简化了路由算法(模型决定为每个输入查询哪些专家的方式)并首次展示了如何使用较低量化(在这种情况下,bfloat16)来训练模型——这是一项相当了不起的成就,而且不容小觑,因为 GPT-4 很可能是 MoE。

列表 5.5 示例混合专家微调
import os
from transformers import (
    AutoTokenizer,
    SwitchTransformersForConditionalGeneration,
    SwitchTransformersConfig,
    TrainingArguments,
    Trainer,
    DataCollatorForLanguageModeling,
)
from datasets import load_dataset
import torch

dataset = load_dataset("text", data_files="./data/crimeandpunishment.txt")  #1
dataset = dataset.filter(lambda sentence: len(sentence["text"]) > 1)
print(f"Dataset 1: {dataset['train'][0]}")

model_dir = "./models/MoE/"          #2
if not os.path.exists(model_dir):
    os.makedirs(model_dir)

tokenizer = AutoTokenizer.from_pretrained("google/switch-base-8")    #3

config = SwitchTransformersConfig(                   #4
    decoder_start_token_id=tokenizer.pad_token_id
)

model = SwitchTransformersForConditionalGeneration.from_pretrained(   #5
    "google/switch-base-8",
    config=config,
    device_map="auto",
    torch_dtype=torch.float16,
)

def tokenize(batch):     #6
    return tokenizer(
        str(batch), padding="max_length", truncation=True, max_length=256
    )

tokenized_dataset = dataset.map(tokenize, batched=False)        #7
print(f"Tokenized: {tokenized_dataset['train'][0]}")

data_collator = DataCollatorForLanguageModeling(            #8
    tokenizer=tokenizer, mlm=False, mlm_probability=0.0
)  # Causal Language Modeling - Does not use mask

train_args = TrainingArguments(    #9
    output_dir=model_dir,
    num_train_epochs=1,
    per_device_train_batch_size=8,
    save_steps=5000,
    save_total_limit=2,
    report_to="none",
)

trainer = Trainer(          #10
    model=model,
    args=train_args,
    data_collator=data_collator,
    train_dataset=tokenized_dataset["train"],
)

trainer.train()                  #11
trainer.save_model(model_dir)
tokenizer.save_pretrained(model_dir)

model = SwitchTransformersForConditionalGeneration.from_pretrained(   #12
    model_dir,
    device_map="auto",
    torch_dtype=torch.float16,
)

input = "To be or not <extra_id_0> <extra_id_0>"       #13
tokenized_inputs = tokenizer(input, return_tensors="pt")
out = model.generate(
    input_ids=tokenized_inputs["input_ids"].to("cuda"),
    attention_mask=tokenized_inputs["attention_mask"],
    max_length=256,
    num_beams=5,
    temperature=0.7,
    top_k=50,
    top_p=0.90,
    no_repeat_ngram_size=2,
)
print(f"To be or not {tokenizer.decode(out[0], skip_special_tokens=True)}")

1 加载数据集并进行格式化

2 创建用于保存的模型目录

3 实例化我们的分词器

4 建立我们的 SwitchTransformers 配置

5 从配置中实例化我们的模型

6 创建标记化函数

7 对整个数据集进行标记化(因此我们永远不必再次这样做)

8 创建数据收集器以格式化训练数据

9 建立训练参数

10 实例化训练器

11 训练并保存模型

12 加载已保存的模型

13 测试已保存的模型

在这个脚本中,使用 Switch Transformer 基础模型对 MoE 模型进行微调。MoE 模型在微调过程中具有独特性,因为你通常需要更新任务特定的参数,例如门控机制和专家的参数,同时保持共享参数不变。这使得 MoE 能够利用不同专家的专业知识,以获得更好的任务特定性能。与传统的微调相比,MoE 模型的微调需要处理专家和门控机制,这比常规的神经网络架构更为复杂。在我们的案例中,我们很幸运,trainer.train()配合正确的配置就可以覆盖微调,我们只需享受 Google 在我们之前所做的工作即可。

MoE 微调的逻辑延伸涉及探索参数高效微调(PEFT)和低秩适应(LoRA)。PEFT 旨在通过减少模型的大小和计算需求,使微调过程更加高效,使其更适合资源受限的场景。在 PEFT 中可以采用知识蒸馏、模型剪枝、量化压缩等技术来实现这一目标。相比之下,LoRA 专注于将低秩分解方法纳入模型架构中,以减少参数数量,同时保持或甚至提高模型性能。这些方法至关重要,因为它们使得在资源有限和计算效率至关重要的场景中部署复杂模型成为可能。

5.3.5 LoRA 和 PEFT

LoRA 对于机器学习来说是一个重大的突破。利用一个数学技巧,LoRAs 可以在不改变原始模型权重或占用大量空间或成本的情况下改变模型的输出,如图 5.7 所示。这里之所以意义重大,是因为它使得为许多不同的任务或领域单独进行微调变得更加可行,正如在扩散空间中已经看到的,text2image LoRAs 经常出现,用于调节模型输出,而不会显著改变基础模型的能力或风格。简单来说,如果你已经喜欢你的模型,并希望在不牺牲其本身已经擅长的东西的情况下,将其用于新的领域,适配器可能就是你的选择,尤其是如果你有多个新的领域,你不想让它们相互渗透。

图

图 5.7 LoRA 展示了这样一个想法:你只需要训练和保存基础模型所在位置和你希望它所在位置之间的差异。它是通过奇异值分解(SVD)来实现的。

要理解 LoRAs,首先需要了解模型当前如何调整权重。由于我们不会在这里详细介绍完整的反向传播教程,我们可以将其抽象为

W = W + ΔW

所以如果你有一个包含 100 个 100 维层的模型,你的权重可以用一个 100 × 100 的矩阵来表示。有趣的部分来自于奇异值分解(SVD),它通过将一个矩阵分解成三个较小的矩阵来实现压缩。我们已经在第三章中深入探讨了这一主题(参见列表 3.2)。因此,虽然我们了解了 LLMs 中 SVD 的直觉,但我们能从原始公式中压缩什么?

ΔW = W[a] × W[b]

所以如果ΔW = 100 × 100,W[a] = 100 × c 和 W[b] = c × 100,其中 c < 100。如果 c = 2,你只需使用 400 个元素就能表示 10,000 个原始元素,因为当它们相乘时,它们等于 10,000 个原始元素。所以,最大的问题是,c 对于你的任务等于多少?c 值是 LoRA 中的“R”,指的是权重矩阵的秩。有算法方法可以通过特征向量等来确定那个秩,但你可以通过知道更高的秩等于更多的复杂性来近似它,这意味着你使用的数字越高,你将越接近原始模型的准确性,但节省的内存越少。如果你认为你微调 LoRA 的任务并不那么复杂,可以降低秩。

下一个列表展示了如何先创建 LoRA,然后使用 LoRA 和你的基础模型进行推理。

列表 5.6 示例 LoRA 和 PEFT 训练
import os
from datasets import load_dataset
from transformers import (
    AutoModelForTokenClassification,
    AutoTokenizer,
    DataCollatorForTokenClassification,
    TrainingArguments,
    Trainer,
)
from peft import (
    PeftModel,
    PeftConfig,
    get_peft_model,
    LoraConfig,
    TaskType,
)
import evaluate
import torch
import numpy as np

model_checkpoint = "meta-llama/Llama-2-7b-hf"
lr = 1e-3
batch_size = 16
num_epochs = 10

model_dir = "./models/LoRAPEFT"     #1
if not os.path.exists(model_dir):
    os.makedirs(model_dir)

bionlp = load_dataset("tner/bionlp2004")

seqeval = evaluate.load("seqeval")

label_list = [
    "O",
    "B-DNA",
    "I-DNA",
    "B-protein",
    "I-protein",
    "B-cell_type",
    "I-cell_type",
    "B-cell_line",
    "I-cell_line",
    "B-RNA",
    "I-RNA",
]

def compute_metrics(p):
    predictions, labels = p
    predictions = np.argmax(predictions, axis=2)

    true_predictions = [
        [label_list[p] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]
    true_labels = [
        [label_list[l] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]

    results = seqeval.compute(
        predictions=true_predictions, references=true_labels
    )
    return {
        "precision": results["overall_precision"],
        "recall": results["overall_recall"],
        "f1": results["overall_f1"],
        "accuracy": results["overall_accuracy"],
    }

tokenizer = AutoTokenizer.from_pretrained(
    model_checkpoint, add_prefix_space=True
)

def tokenize_and_align_labels(examples):
    tokenized_inputs = tokenizer(
        examples["tokens"], truncation=True, is_split_into_words=True
    )
    labels = []
    for i, label in enumerate(examples["tags"]):
        word_ids = tokenized_inputs.word_ids(batch_index=i)
        previous_word_idx = None
        label_ids = []
        for word_idx in word_ids:
            if word_idx is None:
                label_ids.append(-100)
            elif word_idx != previous_word_idx:
                label_ids.append(label[word_idx])
            else:
                label_ids.append(-100)
            previous_word_idx = word_idx
        labels.append(label_ids)

    tokenized_inputs["labels"] = labels
    return tokenized_inputs

tokenized_bionlp = bionlp.map(tokenize_and_align_labels, batched=True)

data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)

id2label = {
    0: "O",
    1: "B-DNA",
    2: "I-DNA",
    3: "B-protein",
    4: "I-protein",
    5: "B-cell_type",
    6: "I-cell_type",
    7: "B-cell_line",
    8: "I-cell_line",
    9: "B-RNA",
    10: "I-RNA",
}
label2id = {
    "O": 0,
    "B-DNA": 1,
    "I-DNA": 2,
    "B-protein": 3,
    "I-protein": 4,
    "B-cell_type": 5,
    "I-cell_type": 6,
    "B-cell_line": 7,
    "I-cell_line": 8,
    "B-RNA": 9,
    "I-RNA": 10,
}

model = AutoModelForTokenClassification.from_pretrained(
    model_checkpoint, num_labels=11, id2label=id2label, label2id=label2id
)

peft_config = LoraConfig(
    task_type=TaskType.TOKEN_CLS,
    inference_mode=False,
    r=16,
    lora_alpha=16,
    lora_dropout=0.1,
    bias="all",
)

model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
training_args = TrainingArguments(
    output_dir=model_dir,
    learning_rate=lr,
    per_device_train_batch_size=batch_size,
    per_device_eval_batch_size=batch_size,
    num_train_epochs=num_epochs,
    weight_decay=0.01,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_bionlp["train"],
    eval_dataset=tokenized_bionlp["validation"],
    tokenizer=tokenizer,
    data_collator=data_collator,
    compute_metrics=compute_metrics,
)

trainer.train()

peft_model_id = "stevhliu/roberta-large-lora-token-classification"
config = PeftConfig.from_pretrained(model_dir)
inference_model = AutoModelForTokenClassification.from_pretrained(
    config.base_model_name_or_path,
    num_labels=11,
    id2label=id2label,
    label2id=label2id,
)
tokenizer = AutoTokenizer.from_pretrained(config.base_model_name_or_path)
model = PeftModel.from_pretrained(inference_model, peft_model_id)

text = (
    "The activation of IL-2 gene expression and NF-kappa B through CD28 "
    "requires reactive oxygen production by 5-lipoxygenase."
)
inputs = tokenizer(text, return_tensors="pt")

with torch.no_grad():
    logits = model(**inputs).logits
tokens = inputs.tokens()
predictions = torch.argmax(logits, dim=2)

for token, prediction in zip(tokens, predictions[0].numpy()):
    print((token, model.config.id2label[prediction]))

1 创建模型目录以保存

请记住,你仍然需要保留你的基础模型,如列表 5.6 所示。LoRA 是在基础模型之上运行的;它位于顶部并仅改变LoraConfig类(在这种情况下,16)中确定的秩的权重。RoBERTa-Large 可能已经在 bionlp 数据集上进行 token 分类方面做得相当不错,但现在,在 LoRA 之上运行,它将变得更好。你可以使用多种类型的 LoRA,QLoRA、QA-LoRA 和 AWQ-LoRA 在不同的领域和任务中都越来越受欢迎。使用可以从LoraConfig控制的 transformers 库,我们鼓励你尝试不同的适应方法,以找到适合你的数据和任务的方法。

LoRA 最吸引人的地方在于,我们在这里讨论的特定 LoRA 在磁盘上仅产生 68 KB 大小的文件,同时仍然有显著的性能提升。你可以为你的公司想要模型的部分创建 LoRAs,一个用于法律团队,这样它就不必担心任何它放入其中的私人数据,一个用于工程团队以帮助代码补全和回答有关使用哪些数据结构或算法的问题,还有一个用于其他人。因为它们如此小巧,存储起来突然变得比微调多次的 1.45 GB(如果我们使用 Llama 在 fp16 中则是 14.5 GB;在 fp32 中是 28 GB)的 RoBERTa-Large 模型要容易得多。本着给你更多这些节省时间和空间的小窍门的精神,我们将讨论一些其他地方没有提到的事情,但如果你正在处理 LLMs 的数据科学部分,你可能仍然会从中得到一些帮助。

5.4 训练技巧与窍门

虽然这本书并不专注于训练和研究新的模型,但我们觉得有点不好意思告诉你,微调模型是教授 LLMs 基于你的数据正确护栏的有效策略,然后只是让你自己摸索如何将其应用于你的东西。考虑到这一点,让我们来看看一些经过验证的训练和微调 LLMs 的技巧和窍门。这些技巧将帮助你解决训练 LLMs 时最不直观的部分,这些部分大多数从业者(包括我们)都是通过艰难的方式学到的。

5.4.1 训练数据大小注意事项

首先,LLMs 因过拟合而闻名。如果你正在考虑训练一个基础模型,你需要考虑你拥有的数据量,这应该是你试图训练的参数数量的约 20 倍。²例如,如果你正在训练一个 1B 参数模型,你应该在 20B 标记上训练它。如果你比这个数量少的标记,你将面临过拟合的风险。

如果你已经有一个模型并且需要在你的数据上微调它,考虑其相反情况,你很可能至少需要 ~0.000001× 的标记数量(对于 1B 参数模型来说是 10K 标记)。我们根据经验提出了这个经验法则,尽管它应该是相当直观的。如果你模型参数中的标记少于 1/100,000,微调可能不会有太大效果。在这种情况下,你应该考虑另一种成本较低的策略,例如 LoRA(我们刚刚讨论过)、RAG(我们将在下一章讨论),或者使用两者的系统。

对于这两个例子,我们都有过这样的经历:我们曾经为一家公司工作,该公司希望用最少的数据获得很好的结果,但最终感到失望。一个希望从头开始训练一个 LLM,只使用大约 100 万个标记,同时不允许使用开源数据集;另一个希望微调模型,但只在几百个示例上进行。这两种方法都不经济,也没有创建出达到公司目标标准的模型。

5.4.2 高效训练

我们到目前为止一直关注训练的工具和方法,这些应该能极大地提高你创建最佳和最大模型的能力,这些模型是你训练系统允许的。然而,在设置训练循环时,还应考虑其他因素。在物理学中,不确定性原理表明,你永远无法完美地知道给定粒子的速度和位置。机器学习的不确定性原理是,你永远无法完美地优化速度和内存利用率。提高速度是以牺牲内存为代价的,反之亦然。表 5.2 显示了你在训练中可以做出的选择及其对速度和内存的影响。

表 5.2 需要考虑的训练选择
方法 提高速度 提高内存利用率 难度
批量大小选择 简单
梯度累积 中等
梯度检查点 中等
混合精度 困难
优化器选择 简单
数据预加载 中等
编译 简单

在设置训练循环时,仔细考虑你的选项以及你正在努力实现的目标。例如,你的批量大小应该是 2 的幂,以实现最大速度和内存效率。有一位作者记得他们曾努力让一个大型语言模型(LLM)拥有个位数的毫秒级响应时间。团队正准备尽可能快地服务数百万客户,每一毫秒都很重要。在用尽所有技巧后,我最终实现了这个目标,我记得在数据科学开发环境中最终实现这一点时的巨大成就感。然而,在生产环境中,有一个硬性规定,批量大小为 20。这只是从帽子里随机挑选的一个好数字,而且围绕这个假设构建了太多系统;没有人愿意重构。软件工程师,对吧?

对于这些方法中的大多数,权衡是明显的:如果你放慢速度,你可以拟合一个显著更大的模型,但这将花费更长的时间。梯度累积和检查点可以将内存使用量减少约 60%,但训练将花费更长的时间。我们在 5.1 节中讨论的包可以帮助缓解这些权衡。

5.4.3 局部最小值陷阱

在 LLMs 中,局部最小值很难被发现,因此可能很难避免。如果你看到你的模型过早收敛,要保持怀疑态度,并在接受结果之前谨慎地测试它。当你发现你的模型在某个步骤数过早收敛时,为了避免在后续运行中再次出现这种情况,一种方法是在看到错误行为之前大约 100 步保存和加载一个检查点,将学习率大幅降低,训练直到你确信已经过了这个阶段,然后再将其调回并继续。务必保留之前保存的检查点,并在之后保存一个新的检查点,以便在事情出错时有地方可以回退!

你可能已经察觉到,这是一个让作者之前感到沮丧的情况。他非常困惑;他正在处理一个 T5 XXL 模型,在大约 25K 步标记处,模型过早收敛并停止。他确信这实际上并没有收敛;它只完成了数据集的 10%!这种情况发生了两三次,他在大约 20K 步时加载了检查点,并目睹了完全相同的事情发生。直到他加载并降低学习率,模型才最终在这个点上得到改善。一旦他通过了局部最小值,他就将其调回。在整个训练这个特定模型的过程中,这种情况又发生了四次,但由于他知道发生了什么,他现在能够避免浪费大量额外的时间。这个故事的经验教训是:如果你的 LLM 没有在完整数据集上训练,那么它还没有准备好。

5.4.4 超参数调整技巧

在这本书中,我们没有详细讨论超参数调整,这并不是因为它没有趣,而是因为它几乎不能像改变数据那样提供帮助,无论是获取更多数据还是进一步清理数据。如果你想调整超参数,Optuna 是一个很好的包,你可以获得你真正需要的约 1%的准确度或 F1 分数的提升。否则,如果你在寻找特定指标的提升,尝试在数据集中更完整地表示该指标,并可能使用一些统计技巧,如过采样。

虽然超参数调整在数学上相当酷,但对于 LLMs 来说,这并不是真正需要发生的事情。如果你需要提升性能,你需要更多/更好的数据,而调整超参数永远不会达到你通过量化权重或执行我们在这里或第三章中提到的任何优化所获得的性能提升。我们通过调整超参数获得的最大性能提升大约是 F1 分数提高了 4%,我们之所以这样做,是因为我们至少在接下来的几周内无法更改我们的数据集。

5.4.5 关于操作系统的注意事项

没有 Windows 子系统 for Linux,Windows 不是与 LLM 专业工作的正确操作系统。MacOS 很好,但除非你知道如何使用 Mac 上的 NVIDIA 或 AMD GPU,否则它缺乏真正承担这个负载的硬件包。如果你对 Linux 不舒服,你应该在你选择的操作系统迎头赶上(如果它真的能迎头赶上)的同时,花些时间熟悉 Linux。有大量的免费在线材料可以帮助你学习 Bash、Linux 和命令行。在 Linux 上配置 CUDA 工具包和 Nvidia 驱动器可能会让你想拔头发,但与替代方案相比,这是值得的。此外,学习虚拟环境、Docker 和云计算,就像本章所介绍的那样!

总的来说,Windows 在开始时很容易,但长期来看会让人沮丧。MacOS 在开始时也很容易,但目前长期来看根本无法使用。Linux 在开始时非常令人沮丧,但一旦你过了那个阶段,一切都会变得顺利。

5.4.6 激活函数建议

我们之前没有深入探讨激活函数,这并不是因为它们没有用或者不酷,而是因为你通常不需要调整你的激活函数,除非你在进行模型性能的研究科学。如果你用原味的 GPT-2,并用 GeGLU 激活函数代替它自带的 GELU,你不会在任何一个方面得到显著的提升。此外,你还需要重新进行预训练,因为它是用不同的激活函数预训练的。激活函数有助于减少每一层的数学弱点,无论是来自二次注意力的虚数、爆炸和消失的梯度,还是研究人员注意到位置编码在通过模型时消失并略有变化。你可以学习有关激活函数的知识,我们建议这样做;一般来说,你可以信任介绍新激活函数的论文。

在本章中,我们已经走了很长的路,讨论了设置环境、从头开始训练 LLM 以及查看多种微调技术。虽然我们认识到在这个过程中还有许多我们没有涉及到的方面,你需要自己学习,但你应该已经准备好创建自己的模型了。现在你有了模型,在下一章中,我们将讨论使其准备就绪并创建一个你可以用来提供在线推理的 LLM 服务。

概述

  • 训练非常消耗内存,你需要掌握多 GPU 环境以进行许多 LLM 训练任务。

  • 模型训练每次都有相同的基本步骤:

    • 数据准备—获取、清洗和整理你的数据。

    • 模型准备—定义模型行为、架构、损失函数等。

    • 训练循环—初始化、分词、批量数据、获取预测/损失、反向传播等。

  • 良好的数据对模型性能的影响比架构或训练循环要大得多。

  • 微调比从头开始训练容易得多,因为它需要的数据和资源要少得多。

  • 提示允许我们在事后对特定任务进行模型训练,这也是与传统的机器学习相比,大型语言模型如此强大的原因之一。

  • 提示调整是一种强大的方法,可以使模型专注于对特定提示做出专家级响应。

  • 知识蒸馏对于训练高效且适应性强的强大小型模型很有用。

  • RLHF 擅长让模型以令人满意的方式响应人类评估者,但会增加事实错误的结果。

  • 微调 MoE 模型与传统微调不同,因为它需要处理专家和门控机制。

  • LoRA 是一种强大的微调技术,通过创建快速训练、易于维护且非常经济高效的微小资产(低秩矩阵)来适应预训练模型的新任务。

  • 您的数据质量和大小是成功训练模型最重要的考虑因素之二。

  • 主要的训练权衡是速度与内存效率;如果你放慢速度,可以拟合一个显著更大的模型,但会花费更长的时间。

[1] L. Chen,M. Zaharia 和 J. Zou,“ChatGPT 的行为是如何随时间变化的?”,arXiv.org,2023 年 7 月 18 日,arxiv.org/abs/2307.09009

[2] J. Hoffmann 等人,“训练计算最优的大型语言模型”,arXiv:2203.15556 [cs],2022 年 3 月,arxiv.org/abs/2203.15556

第六章:大型语言模型服务:实用指南

本章涵盖

  • 如何构建 LLM 服务和部署工具

  • 如何创建和准备 Kubernetes 集群以部署 LLM

  • 常见的生产挑战和一些处理方法

  • 将模型部署到边缘

生产出过多的有用事物会导致无用的人太多。——卡尔·马克思

我们做到了。我们到达了。这是我们最初考虑写这本书时想要写的章节。一位作者记得他第一次部署的模型。言语无法描述这给他带来的满足感比那些在他笔记本电脑上腐烂的数十个项目要多多少。在他心中,它坐落在祭坛上,不是因为它是好的——事实上,它相当糟糕——而是因为它是有用的,并且被那些最需要它的人实际使用。它影响了周围人的生活。

那么,实际上“生产”是什么意思呢?“生产”是指模型集成到实时或运营环境以执行其预期任务或向最终用户提供服务的阶段。这是使模型可用于现实世界应用和服务的关键阶段。为此,我们将向您展示如何将 LLM 打包成服务或 API,以便它可以接受按需请求。然后我们将向您展示如何在云中设置一个集群,您可以在那里部署此服务。我们还将分享您在生产中可能遇到的挑战和一些处理它们的技巧。最后,我们将讨论另一种类型的生产,即在边缘设备上部署模型。

6.1 创建 LLM 服务

在上一章中,我们训练和微调了几个模型,我们确信您迫不及待地想要部署它们。然而,在部署模型之前,提前规划和考虑您 API 的不同架构非常重要。在部署 LLM API 时,提前规划尤其至关重要。它有助于概述功能、识别潜在的集成挑战,并为必要的资源做出安排。良好的规划通过设定优先级简化了开发过程,从而提高了团队的效率。

在本节中,我们将探讨一些关键主题,这些主题对于您在部署我们的应用程序后充分利用它至关重要。图 6.1 展示了一个基于简单 LLM 的服务架构,允许用户按需与我们的 LLM 交互。例如,当与聊天机器人一起工作时,这是一个典型的用例。设置服务还允许我们在抽象出将 LLM 逻辑直接嵌入这些管道的复杂性时,同时处理批处理和流处理。当然,从服务中运行 ML 模型会给您的管道增加通信延迟,但 LLMs 通常被认为运行速度较慢,这种额外的延迟通常值得权衡。

figure

图 6.1 基本的 LLM 服务。大部分逻辑由 API 层处理,这将确保对传入请求的正确预处理并服务于请求的实际推理。

虽然图 6.1 看起来整洁有序,但它隐藏了几个你想要解决的复杂主题,尤其是在那个 API 框中。我们将讨论你想要包含在 API 中的几个关键特性,如批处理、速率限制器和流式传输。你也会注意到一些预处理技术,如检索增强生成(RAG),隐藏在这张图片中,我们将在第 6.1.7 节中深入讨论。到本节结束时,你将知道如何处理所有这些,你将已经部署了 LLM 服务并了解如何改进它。但在我们到达任何这些内容之前,让我们首先谈谈模型本身以及最佳方法来准备它进行在线推理。

6.1.1 模型编译

任何模型在生产中的成功都取决于其运行的硬件。微芯片架构和硅片上控制器的设计最终将决定推理的快速性和效率。不幸的是,当使用 Python 等高级语言以及 PyTorch 或 TensorFlow 等框架进行编程时,模型不会优化以充分利用硬件。这就是编译发挥作用的地方。编译是将用高级语言编写的代码转换为计算机可以快速处理的机器级代码的过程。编译你的 LLM 可以轻松地带来推理和成本的重大改进。

许多人已经投入了大量时间来为你事先执行一些可重复的效率步骤。我们在上一章中介绍了 Tim Dettmers 的贡献。其他贡献者包括 Georgi Gerganov,他创建了并维护了 lama.cpp,用于使用 C++运行 LLM 以提高效率,以及 Tom Jobbins,他在 Hugging Face Hub 上以 TheBloke 为名,将模型量化为正确的格式,以便在 Gerganov 的框架和其他框架(如 oobabooga)中使用。由于这个领域发展迅速,在大量资源上完成简单的可重复任务对他人也很有帮助。

在机器学习工作流程中,这个过程通常涉及将我们的模型从其开发框架(PyTorch、TensorFlow 或其他)转换为中间表示(IR),如 TorchScript、MLIR 或 ONNX。然后我们可以使用特定于硬件的软件将这些 IR 模型转换为针对我们选择的硬件(GPU、TPU(张量处理单元)、CPU 等)的编译机器代码。为什么不直接从您选择的框架转换为机器代码,而跳过中间环节呢?这是一个很好的问题。原因很简单:有数十个框架和数百个硬件单元,编写代码来覆盖每一种组合是不可能的。因此,框架开发者提供了将代码转换为 IR 的工具,而硬件供应商提供了从 IR 到其特定硬件的转换。

对于大多数情况,编译模型的实际过程涉及运行几个命令。多亏了 PyTorch 2.x,您可以通过使用torch.compile(model)命令来提前开始,您应该在训练和部署之前执行此操作。硬件公司通常会免费提供编译软件,因为这可以激励用户购买他们的产品。然而,构建这种软件并不容易,通常需要硬件架构和机器学习架构的专家知识。这种才能的结合是罕见的,如果您在这个领域找到工作,可以赚得丰厚的收入。

我们将在一分钟内向您展示如何编译一个 LLM,但首先,让我们看看一些使用的技术。从至关重要的内核调整开始不是更好吗?

内核调整

在深度学习和高性能计算中,内核是一个小型程序或函数,设计用于在 GPU 或其他类似处理器上运行。这些例程由硬件供应商开发,以最大化芯片效率。他们通过优化硅片上电路块的线程、寄存器和共享内存来实现这一点。当我们运行任意代码时,处理器将尽力在其逻辑门之间路由请求,但难免会遇到瓶颈。然而,如果我们能够事先识别出要运行的内核及其顺序,GPU 可以规划出一条更高效的路径——这正是内核调整的本质。

在内核调整过程中,从大量高度优化的内核中选出最合适的内核。例如,考虑具有几种可能算法的卷积操作。从供应商的内核库中选择的最佳内核将基于各种因素,如目标 GPU 类型、输入数据大小、过滤器大小、张量布局、批量大小等等。在调整时,将运行并优化这些内核中的几个,以最小化执行时间。

这个内核调优的过程确保最终部署的模型不仅针对所使用的特定神经网络架构进行了优化,而且针对部署平台的独特特性进行了精细调优。这个过程导致资源使用更加高效,并最大化性能。接下来,让我们看看张量融合,它优化了运行这些内核的过程。

张量融合

在深度学习中,当框架执行计算图时,它为每个层调用多个函数。计算图是一个强大的概念,用于简化数学表达式并执行一系列张量操作,特别是对于神经网络模型。如果每个操作都在 GPU 上执行,它将引发许多 CUDA 内核启动。然而,快速的内核计算并不完全匹配内核启动和处理张量数据的缓慢速度。因此,GPU 资源可能没有得到充分利用,内存带宽可能成为瓶颈。这就像当我们可以一次性购买所有物品时,我们却要多次去商店购买单独的物品一样。

这就是张量融合发挥作用的地方。通过合并或融合内核以单个操作执行操作,它改善了这种情况,减少了不必要的内核启动并提高了内存效率。一个常见的复合内核示例是全连接内核,它结合或融合了矩阵乘法、偏置添加和 ReLU 内核。这与张量并行化的概念类似。在张量并行化中,我们通过将不同的人派往不同的存储库(如杂货店、五金店和零售店)来加速过程。这样,一个人不必去每个商店。张量融合可以与多个 GPU 的并行化很好地工作。这就像派多个人去不同的商店,通过一次拿多个物品而不是一个来提高每个人的效率。

图优化

张量融合,当按顺序执行时,也被称为垂直图优化。我们还可以进行水平图优化。这些优化通常被谈论为两件不同的事情。我们将简单地称之为图优化,水平图优化将具有不同权重但共享输入数据的层合并成一个更广泛的内核。它通过预分配输出缓冲区并以分布式方式写入来替换连接层。

在图 6.2 中,我们展示了一个简单的深度学习图优化示例。图优化不会改变图中的底层计算。它们只是重新构建图。因此,优化后的图以更少的层和内核启动运行得更加高效,减少了推理延迟。这种重构使整个过程更小、更快、更高效。

figure

图 6.2 是一个未优化的网络与使用图优化优化后的相同网络的示例。CBR 是一个 NVIDIA 熔合层内核,它简单地代表卷积、偏置和 ReLU。请参阅以下 NVIDIA 博客文章以获取参考:mng.bz/PNvw

图优化技术常用于基于计算图的框架,如 TensorFlow 的上下文中。图优化涉及简化这些计算图、移除冗余操作和/或重新排列计算的技术,使它们在执行时更加高效,尤其是在特定的硬件(如 GPU 或 TPU)上。一个例子是常量折叠,其中涉及常量输入的计算在编译时(在运行时之前)执行,从而减少了运行时的计算负载。

这些并不是编译模型时使用的所有技术,但它们是最常见的,应该能让你了解底层发生了什么以及为什么它有效。现在让我们看看一些用于 LLM 的工具。

TensorRT

NVIDIA 的 TensorRT 是一个一站式商店来编译你的模型,而且谁比硬件制造商更值得信赖来更好地准备你的模型在他们的 GPU 上运行?TensorRT 做了本节中提到的所有事情,包括量化到 INT8 和几个内存技巧,以充分利用你的硬件。

在列表 6.1 中,我们展示了使用 TensorRT 编译 LLM 的简单过程。我们将使用名为 torch_tensorrt 的 PyTorch 版本。需要注意的是,将模型编译到特定引擎是硬件特定的。因此,你将想要在打算运行的硬件上编译模型。因此,安装 TensorRT 不仅仅是简单的 pip install;幸运的是,我们可以使用 Docker。要开始,请运行以下命令:

$ docker run --gpus all -it --rm nvcr.io/nvidia/pytorch:23.09-py3

此命令将启动一个交互式的 torch_tensorrt Docker 容器,其中包含我们开始所需的所有内容(关于最新版本,请参阅 mng.bz/r1We)。唯一缺少的是 Hugging Face Transformers,所以请继续安装它。现在我们可以运行列表了。

在导入之后,我们将加载我们的模型并生成一个示例输入,以便我们可以跟踪模型。我们需要将我们的模型转换为 IR(TorchScript),这是通过跟踪完成的。跟踪是捕捉运行模型时调用的操作的过程,使得后续的图优化更加容易。例如,如果你有一个接受可变输入的模型,比如 CLIP 模型,它可以接受图像和文本并将它们转换为嵌入,使用仅文本数据跟踪该模型是一种有效地从模型中剪除图像操作的有效方法。一旦我们的模型被转换为 IR,我们就可以使用 TensorRT 为 NVIDIA GPU 编译它。一旦完成,我们只需从磁盘重新加载模型并运行一些推理以进行演示。

列表 6.1 使用 TensorRT 编译模型
import torch
from transformers import GPT2Tokenizer, GPT2LMHeadModel
import torch_tensorrt

tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
tokens = tokenizer("The cat is on the table.", return_tensors="pt")[
    "input_ids"
].cuda()
model = GPT2LMHeadModel.from_pretrained(
    "gpt2", use_cache=False, return_dict=False, torchscript=True
).cuda()
model.eval()

traced_model = torch.jit.trace(model, tokens)      #1

compile_settings = {     #2
    "inputs": [
        torch_tensorrt.Input(
            # For static size
            shape=[1, 7],
            # For dynamic sizing:
            # min_shape=[1, 3],
            # opt_shape=[1, 128],
            # max_shape=[1, 1024],
            dtype=torch.int32,  # Datatype of input tensor.
            # Allowed options torch.(float|half|int8|int32|bool)
        )
    ],
    "truncate_long_and_double": True,
    "enabled_precisions": {torch.half},      #3
    "ir": "torchscript",
}
trt_model = torch_tensorrt.compile(traced_model, **compile_settings)

torch.jit.save(trt_model, "trt_model.ts")      #4

trt_model = torch.jit.load("trt_model.ts")      #5
tokens.half()
tokens = tokens.type(torch.int)
logits = trt_model(tokens)
results = torch.softmax(logits[-1], dim=-1).argmax(dim=-1)
print(tokenizer.batch_decode(results))

1 转换为 Torchscript IR

2 使用 TensorRT 编译模型

3 使用 FP16 运行

4 保存编译后的模型

5 运行推理

输出结果为

# ['\n was a the way.\n']

我们就直接警告你:当你运行这段代码时,根据你的设置,结果可能会有所不同。总的来说,一旦你知道你在做什么,这是一个简单的过程,我们经常看到至少 2 倍的推理时间提升——这相当于巨大的节省!

TensorRT 确实是应有尽有,还附带一袋芯片。当然,TensorRT 的主要缺点是,作为一个由 NVIDIA 开发的工具,它是针对 NVIDIA 的硬件构建的。当为其他硬件和加速器编译代码时,它将不会很有用。此外,你会在使用 TensorRT 时非常习惯于遇到错误信息。我们发现,在转换不受支持的模型时遇到兼容性问题是一个常见现象。我们在尝试编译各种 LLM 架构时遇到了许多问题。幸运的是,为了解决这个问题,NVIDIA 一直在开发一个 TensorRT-LLM 库,以加速在 NVIDIA 高端 GPU 上的 LLM 推理。它支持的 LLM 架构比普通的 TensorRT 多得多。你可以在这里检查它是否支持你选择的 LLM 架构和 GPU 设置:mng.bz/mRXP

请不要误解我们;你不必使用 TensorRT。有几种替代的编译器可供选择。实际上,让我们看看另一个流行的替代品,ONNX Runtime。相信我们,当 TensorRT 不配合时,你会想要一个替代品。

ONNX Runtime

ONNX,即开放神经网络交换,是一个开源格式和生态系统,旨在表示不同深度学习框架、库和工具之间的互操作性。它是为了解决模型可移植性和兼容性挑战而创建的。如前所述,ONNX 是一个 IR,允许你以标准化的格式表示在一个深度学习框架(例如,TensorFlow、PyTorch、Keras、MXNet)中训练的模型,以便其他框架轻松消费。因此,它促进了不同工具和环境之间模型的交换。与 TensorRT 不同,ONNX Runtime 的目的是硬件无关,这意味着它可以与各种硬件加速器一起使用,包括 CPU、GPU 和专门的硬件如 TPUs。

在实际应用中,ONNX 允许机器学习实践者和研究人员使用他们首选的框架构建和训练模型,然后将这些模型部署到不同的平台和硬件上,而无需进行大量的重新工程或代码重写。这个过程有助于简化 AI 和 ML 模型在各种应用和行业中的开发和部署。明确来说,ONNX 是一个 IR 格式,而 ONNX Runtime 允许我们优化和运行 ONNX 模型的推理。

要利用 ONNX,我们建议使用 Hugging Face 的 Optimum。Optimum 是一个使与优化器一起工作变得更容易的接口,支持多个引擎和硬件,包括 Intel Neural Compressor 用于 Intel 芯片和 Furiosa Warboy 用于 Furiosa NPUs。值得一看。为了我们的目的,我们将使用它将 LLM 转换为 ONNX,然后使用 ONNX Runtime 对其进行推理优化。首先,让我们使用适当的引擎安装库。我们将使用文档中建议的--upgrade-strategy eager,以确保不同的包得到升级:

$ pip install --upgrade-strategy eager optimum[exporters,onnxruntime]

接下来,我们将运行最优的命令行界面。我们将将其导出为 ONNX 格式,指向 Hugging Face 的 transformer 模型,并给它一个本地目录来保存模型。这些都是所需的步骤,但我们还会给它一个优化功能标志。在这里,我们将进行基本的通用优化:

↪ $ optimum-cli export onnx --model WizardLM/WizardCoder-1B-V1.0 ./models_onnx --optimize O1

我们已经完成了。现在我们有一个 LLM 模型被转换为 ONNX 格式,并经过基本的图优化。与所有编译过程一样,优化应该在打算运行推理的硬件上进行,这应该包括充足的内存和资源,因为转换可能需要相当的计算量。

要运行模型,请查看onnxruntime.ai/获取如何使用适当的 SDK 运行它的快速入门指南。哦,对了,我们忘记提到 ONNX Runtime 支持多个编程 API 了吗?所以你现在可以直接在你的首选语言中运行你的 LLM,包括 Java、C++、C#甚至是 JavaScript?是的,你可以。不过,在这本书中,我们将坚持使用 Python,以保持一致性。

虽然 TensorRT 可能是你大多数时候的首选武器,而 ONNX Runtime 覆盖了许多边缘情况,但仍然有许多其他优秀的引擎,比如 OpenVINO。你可以选择你想要的任何东西,但至少你应该使用一些。否则,那将是一个严重的错误。实际上,现在你已经阅读了这一节,你不能再声称自己无知。现在,确保这一点发生是你的专业责任。将任何未首先编译(或至少尝试编译)的 ML 模型投入生产,对于 MLOps 职业来说是一种罪过。

6.1.2 LLM 存储策略

现在我们有一个编译得很好的模型,我们需要考虑我们的服务如何访问它。这一步很重要,因为,如第三章所述,当与 LLM 一起工作时,启动时间可能是一个噩梦,因为将如此大的资产加载到内存中可能需要很长时间。因此,我们希望尽可能加快这一过程。当涉及到管理大型资产时,我们倾向于将它们扔进一个工件注册表或云存储中的桶里,然后忘记它们。这两个都倾向于使用底层的对象存储系统——如 GCS 或 S3——这对于存储来说很棒,但在对象检索方面就不那么好了,尤其是对于 LLM 这样的大型对象。

对象存储系统将资产分解成称为对象的小分数位。它们允许我们将整个资产跨多台机器和物理内存位置进行联邦化,这是一个强大的工具,为云服务提供动力,并且可以在通用硬件上以低成本存储大型对象。通过复制,有内置的错误容错功能,所以我们永远不必担心由于硬件故障而丢失资产。对象存储系统还创建高可用性,确保我们始终可以访问我们的资产。缺点是这些对象被联邦化到多台机器上,而不是以易于访问的形式读取和存储在内存中。因此,当我们将 LLM 加载到 GPU 内存时,我们实际上首先必须下载模型。让我们看看一些替代方案。

融合

融合是将存储桶挂载到您的机器上的过程,就像它是一个外部硬盘驱动器一样。融合提供了一个流畅的界面,简化了代码,因为您将不再需要下载模型并将其加载到内存中。通过融合,您可以像文件系统一样处理外部存储桶,并将模型直接加载到内存中。然而,它仍然无法解决从多台机器中提取资产对象的基本需求。当然,如果您将存储桶融合到同一区域和区域的节点上,一些优化可以提高性能,并且感觉就像是从驱动器中加载模型一样。不幸的是,我们的经验表明融合相当慢,但它应该仍然比下载然后加载要快。

融合库适用于所有主要的云服务提供商和本地对象存储解决方案,如 Ceph 或 MinIO,因此无论在何种环境下,包括您自己的笔记本电脑,都应该得到覆盖。没错。您可以将笔记本电脑或边缘设备与对象存储解决方案融合。这种能力展示了该策略的强大之处,同时,根据您希望它实现的目标,它也可能是不够有效的。

提示:所有融合库实际上都是基于 FUSE 库构建的。值得一看:github.com/libfuse/libfuse

烘焙模型

烘焙是将模型放入 Docker 镜像的过程。因此,每当创建一个新的容器时,模型就会在那里,准备好使用。通常,烘焙模型被认为是一种反模式。首先,它没有解决问题。在生产中,当创建一个新的实例时,会启动一台新机器。它是新鲜无辜的,对外部世界一无所知,所以它必须采取的第一步是下载镜像。由于镜像包含模型,所以我们并没有解决问题。实际上,在镜像内下载模型很可能比从对象存储中下载模型要慢。所以,我们很可能只是使启动时间变得更长。

其次,烘焙模型是一种糟糕的安全实践。容器通常安全性较差,并且人们很容易获得访问权限。第三,你的问题翻倍了:在你之前只有一个大型资产;现在你有两个,模型和镜像。

话虽如此,仍然有一些时候烘焙是可行的,主要是因为尽管有缺点,但它极大地简化了我们的部署。将所有资产放入镜像中可以保证我们只需要一件东西来部署新的服务:镜像本身,这在部署到边缘设备时非常有价值。

挂载卷

另一种解决方案是完全避免对象存储,并将你的 LLM 保存在可挂载驱动器上的基于文件的存储系统中。当我们的服务启动时,我们可以使用 RAID 控制器或 Kubernetes(取决于我们的基础设施)连接包含 LLM 的磁盘驱动器。这个解决方案是老式的,但它确实非常有效。在大多数情况下,它解决了我们所有的问题,并提供了极快的启动时间。

当然,缺点是它将增加许多协调步骤以确保在每个你计划部署的区域和区域中都有一个卷。它还引发了复制和可靠性问题;如果驱动器意外损坏,你将需要在该区域进行备份。此外,这些驱动器很可能是 SSD 而不是普通硬件。所以你可能会多付一些钱。但是,与 GPU 相比,存储非常便宜,所以节省的启动时间是你要考虑的。本质上,这种策略重新引入了我们最初转向对象存储的所有问题。

混合:启动时挂载的中间件卷

最后,我们总是可以采取混合方法。在这个解决方案中,我们在启动时下载模型,但将其存储在启动时挂载的卷中。虽然这并不能帮助解决该地区第一次部署的问题,但它确实大大帮助了任何新的实例,因为它们可以简单地挂载这个相同的卷,并能够加载模型而无需下载。你可以想象这和 Redis 缓存的工作方式类似,只是不涉及存储。通常,这种技术已经足够,因为自动扩展将足够快以处理突发的工作负载。我们只需要担心整个系统的崩溃,希望这种情况会是最小的,但这也暗示我们应该避免在只运行一个副本时采用这种方法,因为在生产环境中你本来就不应该这样做。

在图 6.3 中,我们展示了这些不同的策略,并将它们与一个基本服务进行了比较,在这个服务中,我们只是下载 LLM 并将其加载到内存中。总的来说,你的具体策略将取决于你的系统需求、你运行的 LLM 的大小以及你的基础设施。你的系统需求也可能因你看到的流量模式而大相径庭。

图 6-3

图 6.3 存储 LLMs 的不同策略及其在启动时的含义。通常,我们必须在系统可靠性、复杂性和应用程序加载时间之间取得平衡。

既然我们已经很好地掌握了如何将我们的 LLM 视为一项资产,那么让我们来谈谈一些对于您的 LLM 服务来说是必备的 API 功能。

6.1.3 自适应请求分批

一个典型的 API 将按接收到的顺序接受和处理请求,立即尽可能快地处理它们。然而,任何训练过机器学习模型的人都会意识到,在 2 的幂次(16、32、64 等)批次中运行推理具有数学和计算上的优势,尤其是在涉及 GPU 的情况下,我们可以利用更好的内存对齐或向量化指令并行化 GPU 核心的计算。为了利用这种分批,您可能希望包括自适应请求分批或动态分批。

自适应分批所做的是在一段时间内将请求汇集在一起。一旦池收到配置的最大批次大小或计时器耗尽,它将通过模型对整个批次进行推理,并将结果发送回请求它们的单个客户端。本质上,它是一个队列。自己设置一个可能会非常痛苦;幸运的是,大多数 ML 推理服务都提供这项功能,而且几乎都很容易实现。例如,在 BentoML 中,将@bentoml.Runnable.method(batchable=True)作为装饰器添加到您的预测函数中,在 Triton 推理服务器中,在模型定义文件末尾添加dynamic_batching {}

如果听起来很简单,那是因为它确实很简单。通常,您不需要进行任何进一步的调整,因为默认设置往往非常实用。话虽如此,如果您希望最大限度地提高系统中的每一分效率,您通常可以设置一个最大批次大小,这将告诉分批器一旦达到这个限制就运行一次,或者设置一个批次延迟,这会对计时器做同样的事情。增加任何一个都会导致更长的延迟,但可能带来更好的吞吐量,因此通常只有在您的系统有足够的延迟预算时才会调整这些设置。

总体而言,自适应分批的好处包括更有效地使用资源并提高吞吐量,但代价是略微增加延迟。这是一个有价值的权衡,我们建议为您的产品提供足够的延迟带宽以包含此功能。根据我们的经验,优化吞吐量会导致更好的可靠性和可扩展性,从而带来更高的客户满意度。当然,当延迟时间非常重要或流量稀少时,您可能正确地放弃此功能。

6.1.4 流控制

速率限制器和访问密钥是 API 的关键保护措施,尤其是对于那些位于昂贵 LLM 前面的 API。速率限制器控制客户端在指定时间内可以向 API 发出的请求数量,这有助于保护 API 服务器免受滥用,例如分布式拒绝服务(DDoS)攻击,攻击者会同时发出大量请求以压倒系统并阻碍其功能。

速率限制器还可以保护服务器免受短时间内进行大量自动化请求的机器人攻击。这有助于优化服务器资源管理,使服务器不会因为不必要的或有害的流量而耗尽。它们在管理配额方面也非常有用,从而确保所有用户都能公平且平等地访问 API 资源。通过防止任何单个用户过度使用资源,速率限制器确保系统对所有用户都能平稳运行。

总的来说,速率限制器是控制你 LLM 系统流程流的重要机制。它们在减轻突发工作负载和防止系统在自动扩展和滚动更新期间被压垮方面可以发挥关键作用,尤其是在你有一个相当大的 LLM 且部署时间较长的情况下。速率限制器可以采取多种形式,你选择哪一种将取决于你的用例。

速率限制器的类型

以下列表描述了速率限制器的类型:

  • 固定窗口—该算法允许在固定的时间段内进行固定数量的请求。比如说,每分钟五次请求,并且每分钟刷新一次。它设置起来非常简单,也很容易理解。然而,它可能会导致分布不均,并且可能在时间窗口的边界允许突发调用。

  • 滑动窗口日志—为了防止边界问题,我们可以使用一个动态的时间框架。比如说,过去 60 秒内有五次请求。这种类型是固定窗口的一个稍微复杂一些的版本,它会记录每个请求的时间戳,提供一个移动的回溯期,从而提供一个更均匀分布的限制。

  • 令牌桶—客户端最初有一个满的令牌桶,并且每次请求都会消耗令牌。当桶为空时,请求会被阻塞。桶会随着时间的推移缓慢地重新填充。因此,令牌桶允许突发行为,但限制在桶中令牌的数量。

  • 漏桶—它作为一个队列工作,请求进入,如果队列不满,则处理请求;如果满了,请求会溢出并被丢弃,从而控制流量的速率。

速率限制器可以在多个级别上应用,从整个 API 到单个客户端请求,再到特定的函数调用。虽然你希望避免对它们过于激进——最好依靠自动扩展来扩展和满足需求——但你也不希望完全忽略它们,尤其是在防止不良行为者方面。

访问密钥对于防止恶意行为也至关重要。访问密钥提供身份验证,确保只有授权用户才能访问 API,从而防止未经授权的使用和 API 的潜在滥用,并减少垃圾请求的涌入。对于任何付费服务,它们也是必不可少的。当然,即使你的 API 仅面向内部使用,设置访问密钥也不应被忽视,因为它可以帮助减少责任,例如通过撤销恶意进程的访问来控制成本。

幸运的是,如今设置带有速率限制和访问密钥的服务相对容易,因为有多个库可以帮助你。在第 6.2 列表中,我们展示了使用这两个功能的简单 FastAPI 应用。我们将使用 FastAPI 内置的安全库来处理访问密钥,以及 SlowApi,一个简单的速率限制器,它允许我们通过简单的装饰器来限制任何函数或方法的调用。

第 6.2 列表 示例 API 带有访问密钥和速率限制器
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
import uvicorn

api_keys = ["1234567abcdefg"]        #1
API_KEY_NAME = "access_token"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

limiter = Limiter(key_func=get_remote_address)

app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

async def get_api_key(api_key: str = Depends(oauth2_scheme)):
    if api_key not in api_keys:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid API Key",
        )

@app.get("/hello", dependencies=[Depends(get_api_key)])
@limiter.limit("5/minute")
async def hello(request: Request):
    return {"message": "Hello World"}

1 这将在数据库中加密。

虽然这只是一个简单的例子,但你仍然需要为用户设置一个系统来创建和销毁访问密钥。你还想微调你的时间限制。一般来说,你希望它们尽可能宽松,以免干扰用户体验,但又要足够紧,以完成它们的工作。

6.1.5 流式响应

你的 LLM 服务应该绝对包含的一个功能是流式处理。流式处理允许我们在文本生成时将其返回给用户,而不是在最后一次性返回。流式处理给系统增加了相当多的复杂性,但无论如何,它已经成为几个原因的必备功能。

首先,LLMs 相当慢,而你对你用户能做的最糟糕的事情就是让他们等待——等待意味着他们会变得无聊,无聊的用户会抱怨,更糟糕的是,他们会离开。你不想处理投诉,对吧?当然不!但是通过在数据创建时流式传输数据,我们为用户提供了一个更动态和互动的体验。

其次,LLMs 不仅速度慢,而且不可预测。一个提示可能导致生成数页的文本,而另一个提示可能只生成一个标记。因此,你的延迟将会到处都是。流式处理允许我们关注更一致的指标,如每秒标记数(TPS)。保持 TPS 高于平均用户的阅读速度意味着我们将比用户消费得更快地发送响应,确保他们不会感到无聊,并且我们提供了高质量的用户体验。相比之下,如果我们等到最后才返回结果,用户可能会决定离开,直到它完成才回来,因为他们不知道要等待多长时间。这种对他们的流程的巨大干扰使得你的服务更无效或无用。

最后,用户开始期待流式传输。流式响应已成为判断你是否在与机器人还是真人交谈的一个很好的指标。由于人类需要键入、校对和编辑他们的响应,我们无法期望来自人类客户支持代表的书面响应以流式传输的形式出现。因此,当用户看到响应流式传输进来时,他们会知道他们正在与机器人交谈。人们与机器人的互动方式与与人类的互动方式不同,因此这是非常有用的信息,可以防止用户感到沮丧。

在列表 6.3 中,我们展示了一个非常简单的利用流式传输的 LLM 服务。需要注意的关键点是,我们使用 base asyncio 库来允许我们运行异步函数调用,FastAPI 的 StreamingResponse 确保我们以块的形式向客户端发送响应,以及 Hugging Face Transformer 的 TextIteratorStreamer 来创建我们模型推理的管道生成器。

列表 6.3 流式 LLM 服务
import argparse
import asyncio
from typing import AsyncGenerator

from fastapi import FastAPI, Request
from fastapi.responses import Response, StreamingResponse
import uvicorn

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TextIteratorStreamer,
)
from threading import Thread

app = FastAPI()

tokenizer = AutoTokenizer.from_pretrained("gpt2")     #1
model = AutoModelForCausalLM.from_pretrained("gpt2")
streamer = TextIteratorStreamer(tokenizer)

async def stream_results() -> AsyncGenerator[bytes, None]:
    for response in streamer:
        await asyncio.sleep(1)                   #2
        yield (response + "\n").encode("utf-8")

@app.post("/generate")
async def generate(request: Request) -> Response:
    """Generate LLM Response

    The request should be a JSON object with the following fields:
    - prompt: the prompt to use for the generation.
    """
    request_dict = await request.json()
    prompt = request_dict.pop("prompt")
    inputs = tokenizer([prompt], return_tensors="pt")
    generation_kwargs = dict(inputs, streamer=streamer, max_new_tokens=20)

    thread = Thread(target=model.generate, kwargs=generation_kwargs)    #3
    thread.start()

    return StreamingResponse(stream_results())

if __name__ == "__main__":
    parser = argparse.ArgumentParser()                      #4
    parser.add_argument("--host", type=str, default=None)
    parser.add_argument("--port", type=int, default=8000)
    args = parser.parse_args()

    uvicorn.run(app, host=args.host, port=args.port, log_level="debug")

1 将分词器、模型和流式传输器加载到内存中

2 减慢速度以查看流式传输。通常返回的字节编码流式响应。

3 启动一个单独的线程来生成结果

4 启动服务;默认在端口 8000 的 localhost 上

现在我们已经知道了如何实现我们 LLM 服务的一些必备功能,包括批处理、速率限制和流式传输,让我们看看我们可以添加到我们的服务中的一些额外的工具,以改善可用性和整体工作流程。

6.1.6 特征存储

当涉及到在生产环境中运行 ML 模型时,特征存储可以极大地简化推理过程。我们首次在第三章介绍了这些内容,但作为一个回顾,特征存储建立了一个中心化的真实来源。它们回答关于你的数据的关键问题:谁负责这个特征?它的定义是什么?谁可以访问它?让我们看看如何设置一个并查询数据以了解它们的工作方式。我们将使用 Feast,它是一个开源项目,支持多种后端。要开始,让我们使用 pip install feast 并然后在您的终端中运行 init 命令来设置一个项目,如下所示:

$ feast init feast_example
$ cd feast_example/feature_repo

我们正在构建的应用程序是一个问答服务。问答服务可以从特征存储的数据治理工具中受益良多。例如,点时间连接帮助我们回答像“x 的总统是谁?”这样的问题,答案可能会随时间而改变。我们不仅查询问题,还查询带有时戳的问题,点时间连接将返回在那个时间点我们数据库中问题的答案。在下一个列表中,我们拉取一个问答数据集并将其存储在我们的 Feast 项目的数据目录中的 parquet 格式中。

列表 6.4 下载 SQuAD 数据集
import pandas as pd
from datasets import load_dataset
import datetime

from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")

def save_qa_to_parquet(path):
    squad = load_dataset("squad", split="train[:5000]")    #1
    ids = squad["id"]                             #2
    questions = squad["question"]
    answers = [answer["text"][0] for answer in squad["answers"]]
    qa = pd.DataFrame(           #3
        zip(ids, questions, answers),
        columns=["question_id", "questions", "answers"],
    )

    qa["embeddings"] = qa.questions.apply(lambda x: model.encode(x))   #4
    qa["created"] = datetime.datetime.utcnow()
    qa["datetime"] = qa["created"].dt.floor("h")
    qa.to_parquet(path)              #5

if __name__ == "__main__":
    path = "./data/qa.parquet"
    save_qa_to_parquet(path)

1 加载 SQuAΔ 数据集

2 提取问题和答案

3 创建一个数据框

4 添加嵌入和时间戳

5 保存到 parquet 格式

接下来,我们需要为我们的特征存储定义特征视图。特征视图本质上类似于关系数据库中的视图。我们将定义一个名称、实体(类似于 ID 或主键)、模式(即我们的特征列)和来源。我们只是演示使用本地文件存储,但在生产中,您希望使用 Feast 与 Snowflake、GCP、AWS 等许多后端集成之一。它目前不支持 VectorDB 后端,但我相信这只是时间问题。此外,我们可以通过标签添加元数据到我们的视图,并定义一个生存时间(TTL),这限制了 Feast 在生成历史数据集时可以回溯多远。在以下列表中,我们定义了特征视图。请将此定义添加到我们项目中的 feature_repo 目录下的名为 qa.py 的文件中。

列表 6.5 Feast FeatureView 定义
from feast import Entity, FeatureView, Field, FileSource, ValueType
from feast.types import Array, Float32, String
from datetime import timedelta

path = "./data/qa.parquet"

question = Entity(name="question_id", value_type=ValueType.STRING)

question_feature = Field(name="questions", dtype=String)

answer_feature = Field(name="answers", dtype=String)

embedding_feature = Field(name="embeddings", dtype=Array(Float32))

questions_view = FeatureView(
    name="qa",
    entities=[question],
    ttl=timedelta(days=1),
    schema=[question_feature, answer_feature, embedding_feature],
    source=FileSource(
        path=path,
        event_timestamp_column="datetime",
        created_timestamp_column="created",
        timestamp_field="datetime",
    ),
    tags={},
    online=True,
)

定义好之后,让我们继续注册它。我们将使用

$ feast apply

接下来,我们希望将视图实体化。在生产中,这是一个您需要定期使用 cron 或 Prefect 之类的工具安排的步骤。确保将此命令中结束日期的 UTC 时间戳更新为未来的某个时间,以确保视图收集最新的数据:

$ feast materialize-incremental 2023-11-30T00:00:00 --views qa

现在只剩下查询它了!以下列表展示了在推理时提取要使用的特征的简单示例。

列表 6.6 在推理时查询特征视图
import pandas as pd
from feast import FeatureStore

store = FeatureStore(repo_path=".")

path = "./data/qa.parquet"
ids = pd.read_parquet(path, columns=["question_id"])

feature_vectors = store.get_online_features(
    features=["qa:questions", "qa:answers", "qa:embeddings"],
    entity_rows=[{"question_id": _id} for _id in ids.question_id.to_list()],
).to_df()
print(feature_vectors.head())

此示例将在推理时获取最新信息,以实现最低可能的延迟。对于点时间检索,您将使用get_historical_features方法。此外,在此示例中,我们使用实体行参数的 ID 列表,但您也可以使用 SQL 查询,使其非常灵活且易于使用。

6.1.7 增强检索生成

增强检索生成(RAG)已成为对抗 LLM 中的幻觉并提高我们结果中响应准确性的最广泛使用的工具。其流行可能是因为 RAG 既易于实现又非常有效。如第 3.4.5 节首次讨论的,向量数据库是你希望拥有的工具之一。其中一个关键原因是它们使 RAG 的实现变得容易得多。在图 6.4 中,我们展示了一个 RAG 系统。在预处理阶段,我们将我们的文档拆分,并将它们转换成嵌入,然后我们将它们加载到我们的向量数据库中。在推理期间,我们可以将我们的输入编码成嵌入,并在该向量数据库中运行相似度搜索以找到最近的邻居。这种推理被称为语义搜索。提取相关文档并将它们插入到我们的提示中,将有助于为 LLM 提供上下文并提高结果。

figure

图 6.4 展示了 RAG 系统如何使用我们的输入嵌入在文档中运行搜索,从而提高我们 LLM 生成的文本结果

我们将演示使用 Pinecone 实现 RAG,因为它将节省我们设置向量数据库的精力。对于列表 6.7,我们将设置一个 Pinecone 索引并将 Wikipedia 数据集加载到其中。在这个列表中,我们将创建一个WikiDataIngestion类来处理繁重的工作。这个类将加载数据集并遍历每个维基百科页面,将文本分割成可消费的块。然后它将嵌入这些块并将所有内容批量上传。一旦我们上传了所有内容,我们就可以开始进行查询。

如果你打算跟随操作,你需要一个 API 密钥,所以如果你还没有,请访问 Pinecone 的网站(www.pinecone.io/)并创建一个免费账户,设置一个启动项目(免费层),并获取一个 API 密钥。在阅读列表时需要注意的一点是,我们将使用text_splitter将文本分割成 400 个标记的块。我们特别按标记分割而不是按单词或字符,这允许我们在模型标记限制内正确预算。在这个例子中,返回前三个结果将使我们的请求增加 1,200 个标记,这允许我们提前计划我们将给用户多少标记来编写他们的提示。

列表 6.7 Pinecone 数据库设置示例
import os
import tiktoken
from datasets import load_dataset
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings.openai import OpenAIEmbeddings
from pinecone import Pinecone, ServerlessSpec
from sentence_transformers import SentenceTransformer

from tqdm.auto import tqdm
from uuid import uuid4

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")            #1
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")              #2

pc = Pinecone(api_key=PINECONE_API_KEY)

class WikiDataIngestion:
    def __init__(
        self,
        index,
        wikidata=None,
        embedder=None,
        tokenizer=None,
        text_splitter=None,
        batch_limit=100,
    ):
        self.index = index
        self.wikidata = wikidata or load_dataset(
            "wikipedia", "20220301.simple", split="train[:10000]"
        )
        self.embedder = embedder or OpenAIEmbeddings(
            model="text-embedding-ada-002", openai_api_key=OPENAI_API_KEY
        )
        self.tokenizer = tokenizer or tiktoken.get_encoding("cl100k_base")
        self.text_splitter = (
            text_splitter
            or RecursiveCharacterTextSplitter(
                chunk_size=400,
                chunk_overlap=20,
                length_function=self.token_length,
                separators=["\n\n", "\n", " ", ""],
            )
        )
        self.batch_limit = batch_limit

    def token_length(self, text):
        tokens = self.tokenizer.encode(text, disallowed_special=())
        return len(tokens)

    def get_wiki_metadata(self, page):
        return {
            "wiki-id": str(page["id"]),
            "source": page["url"],
            "title": page["title"],
        }

    def split_texts_and_metadatas(self, page):
        basic_metadata = self.get_wiki_metadata(page)
        texts = self.text_splitter.split_text(page["text"])
        metadatas = [
            {"chunk": j, "text": text, **basic_metadata}
            for j, text in enumerate(texts)
        ]
        return texts, metadatas

    def upload_batch(self, texts, metadatas):
        ids = [str(uuid4()) for _ in range(len(texts))]
        embeddings = self.embedder.embed_documents(texts)
        self.index.upsert(vectors=zip(ids, embeddings, metadatas))

    def batch_upload(self):
        batch_texts = []
        batch_metadatas = []

        for page in tqdm(self.wikidata):
            texts, metadatas = self.split_texts_and_metadatas(page)

            batch_texts.extend(texts)
            batch_metadatas.extend(metadatas)

            if len(batch_texts) >= self.batch_limit:
                self.upload_batch(batch_texts, batch_metadatas)
                batch_texts = []
                batch_metadatas = []

        if len(batch_texts) > 0:
            self.upload_batch(batch_texts, batch_metadatas)

if __name__ == "__main__":
    index_name = "pincecone-llm-example"

    if index_name not in pc.list_indexes().names():      #3
        pc.create_index(
            name=index_name,
            metric="cosine",
            dimension=1536,          #4
            spec=ServerlessSpec(cloud="aws", region="us-east-1"),
        )

    index = pc.Index(index_name)         #5
    print(index.describe_index_stats())

    embedder = None           #6
    if not OPENAI_API_KEY:
        embedder = SentenceTransformer(
            "sangmini/msmarco-cotmae-MiniLM-L12_en-ko-ja"
        )                                                      #7
        embedder.embed_documents = lambda *args, **kwargs: embedder.encode(
            *args, **kwargs
        ).tolist()

    wiki_data_ingestion = WikiDataIngestion(index, embedder=embedder)    #8
    wiki_data_ingestion.batch_upload()
    print(index.describe_index_stats())

    query = "Did Johannes Gutenberg invent the printing press?"     #9
    embeddings = wiki_data_ingestion.embedder.embed_documents(query)
    results = index.query(vector=embeddings, top_k=3, include_metadata=True)
    print(results)

1 从platform.openai.com获取 openai API 密钥

2 在 app.pinecone.io 控制台中查找 API 密钥

3 如果不存在则创建索引

4 文本嵌入-ada-002 的 1536 维

5 连接到索引并描述统计数据

6 如果未提供 openai api 密钥,则使用通用嵌入器

7 也为 1536 维

8 读取数据并重新描述统计数据

9 执行查询

当我运行这段代码时,针对我的问题“约翰内斯·古腾堡发明了印刷机吗?”的前三个查询结果分别是约翰内斯·古腾堡的维基百科页面、铅笔和印刷机。还不错!虽然向量数据库无法回答这个问题,但它只是根据它们的嵌入与我问题的接近程度找到最相关的文章。

使用这些文章,我们可以将它们的嵌入输入到我们的 LLM 中,作为问题的额外上下文,以确保结果更加可靠。由于我们包括了来源,它甚至可以提供作为参考的 wiki URL,而不会只是凭空想象。通过提供这个上下文,我们大大减少了我们的 LLM 凭空想象并编造答案的担忧。

6.1.8 LLM 服务库

如果你开始觉得需要实现一个 LLM 服务所需的所有工具和功能让你有些不知所措,我们有一些好消息要告诉你:有几个库旨在为你完成所有这些工作!一些值得注意的开源库包括 vLLM 和 BentoML 的 OpenLLM。Hugging Face 的 Text-Generation-Inference(TGI)短暂地失去了开源许可,但幸运的是,它现在又可用于商业用途。还有一些初创公司在这个领域构建了一些酷炫的工具,如果你希望获得更托管的服务,我们建议你检查一下 TitanML。这些工具类似于第 3.4.8 节中讨论的 MLServer、BentoML 和 Ray Serve,但它们是为 LLM 专门设计的。

大多数这些工具仍然相对较新,处于积极开发中,它们之间的功能对等性还远未实现,所以请注意它们提供的内容。你可以期待的是,它们至少应该提供流式传输、批处理和 GPU 并行化支持(这是我们本章没有具体讨论的内容),但除此之外,就很难说了。许多它们仍然不支持本章讨论的几个功能,也不支持每个 LLM 架构。尽管如此,它们确实使得部署 LLM 变得容易。

以 vLLM 为例,只需执行pip install vllm命令,然后你就可以运行

$ python -m vllm.entrypoints.api_server --model IMJONEZZ/ggml-openchat-8192-q4_0

只需一条命令,我们现在就有一个服务正在运行我们在第五章训练的模型。大胆地尝试一下;你应该能够像这样向/generate端点发送请求:

$ curl http://localhost:8000/generate -d '{"prompt": "Which pokemon is 
↪ the best?", "use_beam_search": true, "n": 4, "temperature": 0}'

很可能你对这些工具不会特别印象深刻。尽管如此,你现在应该能够构建自己的 API,并且对如何做到这一点有一个很好的理解。现在你有一个服务,甚至可以在本地启动它,让我们讨论你需要建立以支持这些模型实际生产使用的架构。记住,基础设施越好,你半夜服务意外中断时被叫醒的可能性就越小。我们都不希望那样,所以让我们来看看。

6.2 建立基础设施

建立基础设施是现代软件开发的一个关键方面,我们不应该期望机器学习有任何不同。为了确保可扩展性、可靠性和应用的高效部署,我们需要规划一个能够应对不断增长的用户群需求的强大基础设施。这正是 Kubernetes 发挥作用的地方。

Kubernetes,通常被称为 k8s,是一个开源的容器编排平台,它帮助自动化和管理容器化应用程序的部署、扩展和管理。它旨在简化在服务器集群中运行和协调多个容器的过程,使扩展应用程序和确保高可用性变得更加容易。在本章中,我们将大量讨论 k8s,虽然你不需要成为专家,但了解一些基础知识将有助于我们保持一致。

在其核心,k8s 通过将容器分组到称为 Pod 的逻辑单元中来工作,这些 Pod 是 k8s 生态系统中最小的可部署单元。然后,这些 Pod 由 k8s 控制平面进行调度和管理,该平面负责它们的部署、扩展和更新。这个控制平面由几个组件组成,共同处理容器的编排和管理。在图 6.5 中,我们给出了 k8s 架构的过度简化,以帮助不熟悉它的读者。

图

图 6.5 Kubernetes 架构的过度简化。你需要知道的是,我们的服务运行在 Pods 中,而 Pods 运行在节点上,本质上是一个机器。K8s 帮助我们管理资源,并处理将 Pods 部署到这些资源上的编排。

使用 k8s,我们可以利用自动扩展、负载均衡和服务发现等特性,这些特性极大地简化了 Web 应用程序的部署和管理。K8s 提供了一个灵活且可扩展的基础设施,可以轻松适应不断变化的需求,使组织能够随着用户基础的扩大而高效地扩展其应用程序。K8s 提供了一系列额外的功能和可扩展选项,如存储管理、监控和日志记录,这些有助于确保 Web 应用程序的平稳运行。

这些可扩展选项之一被称为自定义资源定义(CRDs)。CRDs 是 Kubernetes 的一个特性,允许用户为自定义资源创建自己的规范,从而在不修改 Kubernetes 源代码的情况下扩展 Kubernetes 的功能。定义了 CRD 之后,我们可以创建类似于 Pod 或服务这样的内置对象的自定义对象。这为 k8s 提供了大量的灵活性,我们将在本章的不同功能中需要它。

如果你刚开始接触 Kubernetes,你可能会对本节的部分内容感到困惑,这是完全可以理解的。不过,希望你有足够的知识来把握本节将要做什么以及为什么这么做的大致内容。至少,你将能够带着一串问题离开,这些问题可以向你的最亲密的 DevOps 团队成员提出。

6.2.1 部署集群

开始任何项目时首先要做的事情是设置一个集群。集群是由工作机器或节点组成的集合,我们将在这里托管我们的应用程序。创建一个集群相对简单;配置它是难点。当然,已经有许多书籍讲述了如何做这件事,但关于网络、安全和访问控制等大多数考虑因素都不在本书的范围之内。此外,您采取的步骤也会根据您选择的云提供商和公司的业务策略而有所不同,因此我们将只关注我们认为对您启动项目所必需的部分,以及任何可能使您生活更轻松的小贴士。

第一步是创建一个集群。在 GCP 上,您将使用 gcloud 工具并运行

$ gcloud container clusters create <NAME>

在 AWS 上,使用 eksctl 工具,运行

$ eksctl create cluster

在 Azure 上,使用 az cli 工具,运行

$ az group create --name=<GROUP_NAME> --location=westus
$ az aks create --resource-group=<GROUP_NAME> --name=<CLUSTER_NAME>

如您所见,即使是第一步也非常依赖于您的提供商,您可能会怀疑后续步骤也是如此。由于我们意识到大多数读者将在各种环境中部署,我们将不会关注具体的步骤,但希望给您足够的背景知识,以便您自己搜索和发现。

我们想象许多读者可能已经由他们的基础设施团队为他们设置了一个集群,其中包含许多默认值和最佳实践。其中之一是设置节点自动配置(NAP)或集群自动扩展。NAP 允许集群增长,根据部署需求添加更多节点。这样,我们只为实际使用的节点付费。这是一个非常方便的功能,但它通常定义了资源限制或对自动扩展实例的可用性进行限制,您可以确信集群的默认设置不包括加速器或 GPU 实例。我们需要解决这个问题。

在 GCP 中,我们会创建一个类似于以下列表中的配置文件,其中我们可以包括 GPU resourceType。在示例中,我们包括了 T4s 和两种 A100 类型。

列表 6.8 示例 NAP 配置文件
resourceLimits:
  - resourceType: 'cpu'
    minimum: 10
    maximum: 100
  - resourceType: 'memory'
    maximum: 1000
  - resourceType: 'nvidia-tesla-t4'
    maximum: 40
  - resourceType: 'nvidia-tesla-a100'
    maximum: 16
  - resourceType: 'nvidia-a100-80gb'
    maximum: 8
management:
  autoRepair: true
  autoUpgrade: true
shieldedInstanceConfig:
  enableSecureBoot: true
  enableIntegrityMonitoring: true
diskSizeGb: 100

然后,您可以通过运行以下命令来设置

$ gcloud container clusters update <CLUSTER_NAME> --enable-autoprovisioning --autoprovisioning-config-file <FILE_NAME>

NAP 的实际好处是,我们不是预先定义在固定设置下可用的资源,而是可以设置资源限制,这将对我们可以扩展到的总 GPU 数量设置上限。它们清楚地定义了我们想要并在任何给定集群中期望的 GPU。

当一位作者刚开始学习极限概念时,他经常将它们与类似的概念混淆——配额、保留和承诺,并看到许多其他人也是如此。特别是配额,与极限非常相似。它们的主要目的是通过确保特定的项目或应用不会消耗过多的资源来防止意外超量费用。与内部设定的限制不同,提高配额通常需要向云服务提供商提交请求。这些请求有助于通知云服务提供商,并用于更好地规划分配哪些资源以及将它们放置在不同地区的不同数据中心。可能会觉得云服务提供商将确保这些资源可用;然而,配额永远不会保证某个地区有足够的资源供您的集群使用,您可能会在达到限制之前就遇到资源未找到的错误。

虽然配额和限制设定了上限,但保留和承诺设定了下限。保留是一种保证一定数量的资源始终可用的协议,通常附带条件,即无论您是否最终使用这些资源,您都将为这些资源付费。承诺与保留类似,但通常是更长期限的合同,通常带有折扣价格。

6.2.2 自动扩展

设置 k8s 集群的一个主要卖点就是自动扩展。自动扩展是创建健壮的生产级服务的重要成分。主要原因是我们从不期望任何服务接收静态请求量。如果不是其他原因,您应该预计在白天会有更多的流量,而在夜间人们睡觉时流量会减少。因此,我们希望在高峰时段启动更多副本以提高性能,在非高峰时段关闭副本以节省金钱,更不用说处理那些经常在任何时候威胁到服务崩溃的突发工作负载了。

知道您的服务将根据应用程序的需求自动分配更多资源并设置额外的部署,这使得许多基础设施工程师能够安心入睡。问题是这需要工程师了解这些需求并确保一切配置正确。虽然自动扩展提供了灵活性,但真正的商业价值来自成本节约。大多数工程师认为自动扩展是为了向上扩展以防止崩溃,但对企业来说,更重要的是能够向下扩展,释放资源并降低成本。

云计算和像 Kubernetes 这样的技术成为现代基础设施中不可或缺的原因之一是因为自动扩展是内置的。自动扩展是 Kubernetes 的一个关键特性,通过水平自动扩展器(HPA),你可以根据两个原生资源:CPU 和内存使用情况轻松调整应用程序副本的数量,如图 6.6 所示。然而,在关于将 LLMs 投入生产的书籍中,仅基于 CPU 和内存的扩展将永远不够。我们需要根据自定义指标进行扩展,特别是 GPU 利用率。

figure

图 6.6 使用内置的 k8s 水平自动扩展器(HPA)的基本自动扩展。HPA 监视 CPU 和内存资源,并将通知部署服务增加或减少副本数量。

基于 GPU 指标设置自动扩展需要更多的工作,并需要设置几个服务。当我们讨论它们时,将清楚为什么我们需要每个服务,但好消息是,到最后,你将能够设置你的服务以基于任何指标进行扩展,包括来自消息代理的消息、对 HTTP 端点的请求以及来自队列的数据。

我们首先需要的服务是能够收集 GPU 指标的服务。为此,我们使用了 NVIDIA 的数据中心 GPU 管理器(DCGM),它提供了一个可以导出 GPU 指标的指标导出器。DCGM 公开了大量 GPU 指标,包括温度和功耗,可以创建一些有趣的仪表板,但最有助于自动扩展的指标是利用率和内存利用率。

从这里,数据将流向像 Prometheus 这样的服务。Prometheus 是一个流行的开源监控系统,用于监控 Kubernetes 集群及其上运行的应用程序。Prometheus 从各种来源收集指标,并将它们存储在时序数据库中,以便进行分析和查询。Prometheus 可以直接从 Kubernetes API 以及集群上运行的应用程序使用各种收集机制(如导出器、代理和边车容器)收集指标。它本质上是一个像 DCGM 这样的服务的聚合器,包括警报和通知等功能。它还提供了一个 HTTP API,供 Grafana 等外部工具查询和创建图表和仪表板。

虽然 Prometheus 提供了一种存储指标和监控我们服务的方法,但这些指标并没有暴露给 Kubernetes 的内部。为了使 HPA 能够访问,我们需要将另一个服务注册到自定义指标 API 或外部指标 API。默认情况下,Kubernetes 随带 metrics.k8s.io 端点,该端点公开资源指标、CPU 和内存利用率。为了满足在自定义指标上缩放部署和 pod 的需求,引入了两个额外的 API:custom.metrics.k9s.io 和 external.metrics.k8s.io。这种设置有一些限制,因为目前,一次只能为其中一个注册一个“适配器”API 服务。如果决定将此端点从一家提供商更改为另一家,这种限制主要成为一个问题。

对于这项服务,Prometheus 提供了 Prometheus 适配器,它运行良好,但根据我们的经验,它并不是为生产工作负载设计的。作为替代方案,我们推荐使用 KEDA。KEDA(Kubernetes 事件驱动自动缩放)是一个开源项目,为 Kubernetes 提供事件驱动自动缩放功能。它在自动缩放中使用的自定义指标类型方面提供了更多灵活性。虽然 Prometheus 适配器需要在 ConfigMap 内配置指标,但任何通过 Prometheus API 已公开的指标都可以在 KEDA 中使用,从而提供更流畅和友好的用户体验。它还提供了从 0 到 0 的缩放功能,这是通过 HPAs 无法实现的,允许您在没有流量时完全关闭服务。但话虽如此,您无法从 0 缩放资源指标,如 CPU 和内存,以及由此扩展的 GPU 指标,但在使用流量指标或队列进行缩放时很有用。

将所有这些放在一起,您将得到图 6.7 所示的架构。与图 6.6 相比,您会注意到底部 DCGM 正在管理我们的 GPU 指标并将它们输入 Prometheus Operator。从 Prometheus,我们可以使用像 Grafana 这样的工具设置外部仪表板。在 k8s 内部,我们将使用 KEDA 来设置一个 custom.metrics.k9s.io API,以便返回这些指标,这样我们就可以根据 GPU 指标进行自动缩放。KEDA 有几个 CRDs,其中之一是 ScaledObject,它创建 HPA 并提供额外的功能。

figure

图 6.7 基于自定义指标(如 GPU 利用率)进行自动缩放需要几个额外的工具才能工作,包括 NVIDIA 的 DCGM、一个监控系统(如 Prometheus Operator)以及像 KEDA 提供的自定义指标 API。

虽然自动扩展提供了许多好处,但了解其局限性和潜在问题很重要,这些问题在 LLM 推理服务中只会加剧。对于许多应用程序来说,HPA 的正确配置通常是一个事后考虑的问题,但处理 LLM 时它变得至关重要。LLM 需要更长的时间才能完全运行,因为 GPU 需要初始化,模型权重需要加载到内存中;这些不是可以瞬间开启的服务,如果没有正确准备,在扩展时可能会引起问题。此外,如果系统过于激进地缩小规模,可能会导致实例在完成分配的任务之前被终止,从而导致数据丢失或其他问题。最后,由于错误的自动扩展配置,可能会出现摆动现象。摆动发生在副本数量不断振荡时,启动一个新的服务,但又在它能够提供服务之前将其终止。

在设置 HPA 时,实际上有五个参数需要调整:

  • 目标参数

  • 目标阈值

  • 最小 Pod 副本数

  • 最大 Pod 副本数

  • 扩展策略

让我们逐一查看它们,以确保你的系统配置正确。

目标参数

目标参数是在确保系统配置正确时需要考虑的最重要指标。如果你已经按照第 6.2.2 节中列出的步骤操作,那么你的系统现在应该已经准备好根据 GPU 指标进行自动扩展,所以这应该很简单,对吧?但别急!基于 GPU 利用率的扩展将是最常见的直接路径,但我们需要做的第一件事是确保 GPU 确实是我们的服务中的实际瓶颈。我们经常看到一些急切的年轻工程师在服务上投入大量昂贵的 GPU,但忘记包括足够的 CPU 和内存容量。CPU 和内存仍然需要处理 API 层,例如接收请求、处理多个线程以及与 GPU 通信。如果没有足够的资源,这些层可能会迅速成为瓶颈,你的应用程序在 GPU 利用率受到影响之前就会被限制,从而确保系统永远不会真正进行自动扩展。虽然你可以在自动扩展器上切换目标参数,但与 GPU 资源相比,CPU 和内存很便宜,所以为你的应用程序分配更多的它们会更好。

此外,还有其他情况下其他指标更有意义。如果你的 LLM 应用程序的大部分请求来自流式或批量服务,那么基于告诉你 DAG 正在运行或上游队列正在填满的指标进行扩展可能更为谨慎——特别是如果这些指标给你提供了早期信号,并允许你提前更多时间进行扩展。

在选择指标时,另一个需要考虑的问题是它的稳定性。例如,单个 GPU 的利用率往往接近 0%或 100%。这可能会给自动扩展器带来问题,因为指标在开启和关闭状态之间波动,其推荐添加或删除副本也会如此,从而导致摆动。通常,通过取所有运行服务的 GPU 的平均利用率来避免摆动。当您有很多 GPU 时,使用平均值将稳定指标,但当服务缩小时,这仍然可能是一个问题。如果您仍然遇到问题,您可能需要使用时间聚合的平均值,这将告诉您每个 GPU 在一段时间内的利用率——比如说,过去 5 分钟。对于 CPU 利用率,时间聚合的平均值是 Kubernetes HPA 内置的,可以通过horizontal-pod-autoscaler-cpu-initialization-period标志来设置。对于自定义指标,您需要在您的指标查询中设置它(对于 Prometheus,将是avg_over_time聚合函数)。

最后,值得指出的是,大多数系统允许您根据多个指标进行自动扩展。例如,您可以根据 CPU 和 GPU 利用率进行自动扩展。然而,除非您知道自己在做什么,否则我们建议避免这些设置。您的自动扩展器可能就是这样设置的,但实际上,由于服务负载,您的服务可能只会根据一个指标进行自动扩展,并且最好确保这个指标是成本工程目的上更昂贵的资源。

目标阈值

目标阈值告诉您的服务在何时开始扩容。例如,如果您是基于平均 GPU 利用率进行扩展,并且阈值设置为 30,那么当平均 GPU 利用率超过 30%时,将启动一个新的副本来承担额外的负载。管理这一点的公式相当简单,如下所示:

desiredReplicas = ceil[currentReplicas × (currentMetricValue / desiredMetricValue )]

注意:您可以在mng.bz/x64g了解更多关于该算法的信息。

这可能很难正确调整,但以下是一些指导原则。如果你看到的流量模式涉及大量恒定的微小流量突发,那么大约 50 的较低值可能更合适。这个设置确保你开始更快地扩展,避免不可靠的问题,你也可以更快地缩小规模,降低成本。如果你有一个恒定的稳定流量,那么大约 80 的较高值将工作得很好。在测试自动扩展器之外,最好避免极端的低值,因为它们可能会增加你的振荡机会。你还应该避免极端的高值,因为它们可能会在新的副本开始启动之前,让活动副本被压垮,这可能导致不可靠或停机。还重要的是要记住,由于使用分布式 GPU 设置时管道并行工作流程的性质,总会有一个气泡,如第 3.3.2 节所述。因此,你的系统永远不会达到 100%的 GPU 利用率,你可能会比预期更早遇到问题。根据你的气泡有多大,你需要相应地调整目标阈值。

最小副本数量

最小副本数量决定了你的服务将始终运行的副本数量。这个设置是你的基线。确保它略高于你的入站请求基线是很重要的。这种情况经常被严格设定以满足基线流量水平或略低于基线,但入站流量的稳定状态很少真的那么稳定。这就是为什么可能会发生很多振荡,因为你更有可能看到许多小的流量激增而不是大的峰值。然而,你不想设置得太高,因为这会将宝贵的资源绑定在集群中并增加成本。

最大副本数量

最大副本数量决定了系统在峰值容量下将运行的副本数量。你应该将这个数字设置得略高于你的峰值流量需求。设置得太低可能会导致可靠性问题、性能下降和高流量期间的停机。设置得太高可能会导致资源浪费、运行不必要的更多副本,并延迟发现真实问题。例如,如果你的应用程序遭受了 DDoS 攻击,你的系统可能会扩展以处理负载,但这可能会让你付出高昂的代价并隐藏问题。在使用 LLM 时,你还需要小心不要过度负载底层集群,并确保你有足够的配额资源来处理峰值负载。

缩放策略

缩放策略定义了自动扩展器的行为,允许你微调在缩放之前等待多长时间以及缩放的速度。这个设置通常被忽略,并且对于大多数设置来说,这是安全的,因为这些设置的默认值通常对典型应用程序来说相当不错。然而,对于 LLM 服务来说,依赖默认设置将是一个严重的错误,因为它的部署需要很长时间。

你首先想要调整的设置是稳定窗口,它决定了在采取新的缩放操作之前需要等待多长时间。你可以为升级和降级任务设置不同的稳定窗口。默认的升级窗口是 0 秒,如果你的目标参数已经设置正确,那么通常不需要调整。默认的降级窗口是 300 秒,对于我们的用例可能太短了。你通常希望这个时间至少与你的服务部署所需的时间一样长,然后再稍微长一点。否则,你只会添加副本,然后在它们有机会做任何事情之前就移除它们。

你接下来想要调整的参数是缩放策略,默认设置为每 15 秒将 pods 缩放到 100%。因此,任何暂时的流量下降都可能导致所有超出最低需求的额外 pods 立即被终止。对于我们的情况,将其放缓要安全得多,因为终止一个 pods 只需要几秒钟,但启动一个可能需要几分钟,这使得它成为一个半不可逆的决定。确切的策略将取决于你的流量模式,但一般来说,我们希望有更多的耐心。你可以通过调整 pods 将被终止的速度和数量或百分比来调整。例如,我们可以配置策略,每分钟只允许一个 pods 被终止,或者每 5 分钟只允许 10%的 pods 被终止。

6.2.3 滚动更新

滚动更新或滚动升级是一种策略,它逐步实施应用程序的新版本以减少停机时间并最大化敏捷性。它通过逐步创建新实例并关闭旧实例,以有组织的方式替换它们来实现。这种更新方法允许系统在更新过程中保持功能性和对用户的可访问性,也称为零停机时间。滚动更新还使得在它们产生太大影响之前更容易捕捉到错误,并回滚有缺陷的部署。

滚动更新是 k8s 内置的功能,也是其广泛使用和受欢迎的另一个主要原因。Kubernetes 提供了一个自动化和简化的方式来执行滚动更新。滚动更新确保在部署过程中 Kubernetes 以增量方式使用新实例更新 pod 实例。以下列表显示了一个实现滚动更新的 LLM 部署示例;相关的配置位于spec.strategy部分。

列表 6.9 带有滚动更新的示例部署配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-application
spec:
  replicas: 5
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 3
  selector:
    matchLabels:
      app: llm-app
  template:
    metadata:
      labels:
        app: llm-app
    spec:
      containers:
      - name: llm-gpu-app-container
        image: llm-gpu-application:v2
        resources:
          limits:
            nvidia.com/gpu: 8

你会注意到,对于滚动更新,你可以调整两个主要参数:maxSurgemaxUnavailable。这些参数可以是整数,就像我们的例子中那样,描述实例的数量,也可以是表示总实例百分比的分数。在例子中,你会注意到我们将maxSurge设置为1,这意味着尽管我们通常运行五个副本,但在部署期间我们可以增加到六个,这样我们就可以在关闭任何副本之前开启一个新的副本。通常,你可能希望将其设置得更高,因为这允许更快的滚动更新。否则,我们不得不逐个替换 Pod。你可能已经注意到,它的原因很低,是因为我们正在部署一个相当大的 LLM,需要八个 GPU。如果这些是 A100s,那么找到额外八个未使用的 GPU 可能很困难。

GPU 资源不能在容器之间共享,在这样部署中容器编排可能成为一个主要挑战,这就是为什么maxUnavailable被设置为3的原因。我们在这里说的是,在部署期间,五个预期的副本中有三个可以下线。换句话说,我们将在重新创建它们之前稍微减少副本的总数。出于可靠性的原因,我们通常更喜欢首先添加额外的副本,因此下线是一个艰难的决定,你将想要确认你可以在自己的部署中承担这个决定。我们之所以这样做,是为了确保有 GPU 资源可用。本质上,为了平衡资源利用率,可能有必要将maxUnavailable设置得较高,并将maxSurge调整到较低数值,以便快速缩减旧版本并为新版本释放资源。

这些建议与你在大多数应用程序中采取的做法相反,所以我们理解这可能会让你感到不安。如果你想确保部署更加顺畅,你将需要在你的集群中为部署目的预留额外的 GPU。然而,根据你更新模型本身的频率,仅仅为了使部署更加顺畅而支付昂贵的 GPU 闲置费用可能并不划算。通常,LLM 本身并不需要那么多的更新,所以假设你正在使用推理图(下一节讨论),大多数更新将是针对 API、提示或周围应用程序的。

此外,我们建议你首先在一个预演环境中谨慎地执行此类操作,以了解其效果。在预演中捕捉到部署问题可以为你节省不少麻烦。在预演中调试maxUnavailablemaxSurge参数也是有用的,但由于预演通常资源受限,因此很难与生产环境进行一对一的比较。

6.2.4 推理图

推理图就像是甜甜圈的奶油馅、松饼的顶部或是披萨上的配料:它们真的是太棒了。推理图允许我们以节省资源的方式在推理时创建复杂的流程图。考虑图 6.8,它展示了任何推理图的构建块。

figure

图 6.8 展示了推理图构建块的三个类型。顺序型允许我们在其他模型之前运行一个模型,这对于预处理步骤,如生成嵌入很有用。集成型允许我们将多个模型组合在一起,从每个模型中学习并合并它们的结果。路由型允许我们根据某些标准将流量发送到特定的模型,通常用于多臂老丨虎丨机优化。

通常情况下,当你拥有多个模型时,考虑一个推理图架构是有用的。你的标准 LLM 设置通常已经至少包含两个模型:一个编码器以及语言模型本身。

通常,当我们看到 LLM 在野外部署时,这两个模型通常是一起部署的。你将文本数据发送到你的系统,它返回生成的文本。这通常不是什么大问题,但当我们将其作为顺序推理图而不是打包服务部署时,我们会得到一些额外的优势。首先,编码器通常比 LLM 快得多,因此我们可以将它们分开,因为你可能只需要为每两个到三个 LLM 实例提供一个编码器实例。编码器如此小巧,这并不一定给我们带来太多帮助,但它可以节省我们重新部署整个 LLM 的麻烦。此外,推理图将为每个模型设置一个单独的 API,这允许我们分别击中 LLM 和编码器。如果我们有一堆想要预处理并保存到 VectorDB 中的数据,这非常有用;我们可以使用已经部署的相同编码器。然后我们可以拉取这些数据并将其直接发送到 LLM。

推理图的最大好处是它允许我们将 API 和 LLM 分开。位于 LLM 前面的 API 可能会随着你调整提示、添加功能和修复错误而频繁更改。无需部署 LLM 即可更新 API 的能力将为你的团队节省大量精力。

现在让我们考虑图 6.9,它提供了一个使用 Seldon 的示例推理图部署。在这个例子中,我们有一个编码器模型、一个 LLM、一个分类器模型以及一个简单的 API,它结合了这些结果。而如果我们必须为每个这些模型构建容器和接口,Seldon 则创建了一个调度器,它处理用户请求与图中每个节点之间的通信。

figure

图 6.9 展示了使用 Seldon 的示例推理图部署。Seldon 部署是一个 Kubernetes CRD,它扩展了常规的 Kubernetes 部署并添加了一个调度器,确保所有在图中运行的模型以正确的顺序进行通信。

注意:Seldon 是一个开源平台,旨在用于在生产环境中部署和管理机器学习模型。它提供工具和能力,帮助组织在基于 Kubernetes 的环境中简化机器学习和深度学习模型的部署和扩展。它提供 k8s CRDs 以实现推理图。

如果你想知道如何创建这样的配置,列表 6.10 展示了创建此确切设置的示例配置。我们只需在图中定义容器及其在图中的关系。你会注意到apiVersion定义了来自 Seldon 的 CRD,这允许我们使用SeldonDeployment,它只是 k8s 常规 Deployment 对象的扩展。在列表中,你可能注意到组合器是 LLM 和分类器模型的父节点,这与我们在图 6.9 中可视化的方式相反。这是因为一个组件将只有一个父节点,但可以有多个子节点,所以COMBINER始终是一个父节点,尽管在功能上它是相同的。设置图可能会很复杂,所以我建议你经常查阅文档。

列表 6.10 一个示例SeldonDeployment配置文件
apiVersion: machinelearning.seldon.io/v1alpha2
kind: SeldonDeployment
metadata:
  name: example-seldon-inference-graph
spec:
  name: example-deployment
  predictors:
  - componentSpecs:
    - spec:
        containers:
        - name: encoder
          image: encoder_image:latest
        - name: LLM
          image: llm_image:latest
        - name: classifier
          image: classifier_image:latest
        - name: combiner
          image: combiner_image:latest
    graph:
      name: encoder
      type: MODEL
      endpoint:
        type: REST
      children:
        - name: combiner
          type: COMBINER
          children:
            - name: LLM
              type: MODEL
              endpoint:
                type: REST
              children: []
            - name: classifier
              type: MODEL
              endpoint:
                type: REST
              children: []
    name: example
    replicas: 1

如果你部署了足够的机器学习系统,你就会意识到其中许多需要复杂的系统,推理图使得这变得简单,或者至少更容易。这是一个很大的区别。尽管推理图是部署复杂机器学习系统的一种更智能的方式,但始终重要的是要问自己额外的复杂性是否真的需要。即使有推理图这样的工具,在可能的情况下保持简单总是更好的。

6.2.5 监控

就像任何部署到生产中的产品或服务一样,监控对于确保可靠性、性能以及满足服务级别协议和目标至关重要。就像任何服务一样,我们关心监控典型的性能指标,如每秒查询数(QPS)、延迟和响应代码计数。我们也关心使用 CPU 利用率、内存使用百分比、GPU 利用率和 GPU 温度等指标来监控我们的资源,等等。当任何这些指标开始失败时,通常表明某种灾难性的故障,并且需要迅速解决。

对于这些指标,任何软件工程团队都应该有丰富的使用 Prometheus 和 Grafana 或 ELK 堆栈(Elasticsearch、Logstash 和 Kibana)等工具与之工作的经验。通过利用可能已经存在的系统,你将获得巨大的益处。如果这些系统尚未建立,我们已经在第 6.2.2 节中讨论了如何设置 GPU 指标以进行监控,并且该系统应该适用于监控其他资源。

然而,对于任何机器学习项目,我们还有传统监控工具未能涵盖的额外担忧,这会导致无声故障。这通常来自数据漂移和性能衰减,即模型继续运行,但开始表现不佳,不再满足质量预期。由于语言始终处于变化之中,新词不断出现,旧词的含义不断改变,LLM 尤其容易受到数据漂移的影响。因此,我们通常需要一个系统监控解决方案和一个机器学习监控解决方案。

对于数值数据集,监控数据漂移相对容易且研究得很好,但对于非结构化文本数据的监控则提供额外的挑战。我们在第四章中已经讨论了评估语言模型的方法,我们将需要使用类似的方法来评估和监控生产中的模型。我们最喜欢的用于监控漂移检测的工具是 whylogs,因为它在捕获大规模汇总统计方面的效率很高。将 LangKit 添加到其中可以立即且轻松地跟踪 LLM 的几个有用指标,例如可读性、复杂性、毒性和甚至与已知提示注入攻击的相似度得分。在下面的列表中,我们展示了使用 whylogs 和 LangKit 记录和监控文本数据的一个简单应用。

列表 6.11 使用 whylogs 和 LangKit 监控文本数据
import os
import pandas as pd

import whylogs as why
from langkit import llm_metrics
from datasets import load_dataset

OUTPUT_DIR = "logs"

class LoggingApp:
    def __init__(self):
        """
        Sets up a logger that collects profiles and writes them
        locally every 5 minutes. By setting the schema with langkit
        we get useful metrics for LLMs.
        """
        self.logger = why.logger(
            mode="rolling",
            interval=5,
            when="M",
            base_name="profile_",
            schema=llm_metrics.init(),
        )
        self.logger.append_writer("local", base_dir=OUTPUT_DIR)

    def close(self):
        self.logger.close()

    def consume(self, text):
        self.logger.log(text)

def driver(app):
    """Driver function to run the app manually"""
    data = load_dataset(
        "shahules786/OA-cornell-movies-dialog",
        split="train",
        streaming=True,
    )
    data = iter(data)
    for text in data:
        app.consume(text)

if __name__ == "__main__":
    app = LoggingApp()       #1
    driver(app)
    app.close()

    pd.set_option("display.max_columns", None)      #2

    all_files = [                #3
        f for f in os.listdir(OUTPUT_DIR) if f.startswith("profile_")
    ]
    path = os.path.join(OUTPUT_DIR, all_files[0])
    result_view = why.read(path).view()
    print(result_view.to_pandas().head())

1 手动运行应用

2 防止列截断

3 获取第一个配置文件并显示结果

生成的文本是

# ...
# column        udf/flesch_reading_ease:cardinality/est
# conversation                               425.514743
# ...
# column        udf/jailbreak_similarity:cardinality/est
# conversation                               1172.226702
# ...
# column        udf/toxicity:types/string  udf/toxicity:types/tensor
# conversation                          0                          0

虽然这只是一个使用文本数据集的演示,但您可以看到监控输入提示和输出生成的文本(如可读性、复杂性和毒性等指标)将是有益的。这些监控工具将帮助您了解您的 LLM 服务是否开始出现无声故障。

在生产中监控时,我们必须注意延迟可能对我们服务的影响。LangKit 使用几个轻量级模型来评估文本的高级指标。虽然我们没有注意到显著的内存影响,但在直接推理路径中评估日志时,对延迟有轻微的影响。为了避免这种情况,我们可以将其从推理路径中移除,进入所谓的 sidecar。

在机器学习团队中,错误地将数据质量检查放在关键路径上并不少见。他们的意图可能是好的(确保只有干净的数据通过模型),但在客户发送不良数据的情况下,发送 400 或 500 错误响应通常比向良好请求添加昂贵的延迟成本要好。事实上,许多应用程序将监控完全从关键路径中移除,选择并行处理。实现这一点的最简单方法是使用 Kubernetes sidecar,如图 6.10 所示。您可以使用专门从事此工作的工具来完成此操作,例如 fluentd;whylogs 也提供可以运行的容器作为 sidecar。

figure

图 6.10 一个示例 Kubernetes 侧边容器,它将日志从关键路径中移除。日志代理可能是一个像 whylogs 容器或 fluentd 这样的工具,它捕获特定的请求或所有 stdout 打印语句,处理它们,并将它们转发到日志后端,如 WhyLabs 或 Prometheus。

有不同的侧边容器配置,但主要思想是日志容器将在同一个 k8s pod 中运行,而不是主应用程序直接写入日志文件,这个侧边容器充当一个中间步骤,首先处理和清理数据,然后可以直接发送到后端或将其写入日志文件。

注意:您可以在其文档中了解更多关于 Kubernetes 日志架构的信息:mng.bz/Aaog

现在我们已经更多地了解了设置我们的基础设施,包括提供集群和实施如 GPU 自动扩展和监控等功能,您应该准备好部署您的 LLM 服务并确保它是可靠和可扩展的。接下来,让我们谈谈您可能会遇到的不同挑战以及解决这些问题的方法。

6.3 生产挑战

虽然我们已经介绍了如何启动一个服务,但无论如何,你会在部署模型并维护它们在生产中的过程中遇到无数需要跨越的障碍。这些挑战包括更新、规划大量负载、低延迟、获取资源等等。为了帮助您,我们希望解决一些最常见的问题,并为您提供处理它们的技巧。

6.3.1 模型更新和重新训练

我们最近讨论了机器学习监控,监控模型的无声故障和数据漂移,但当你注意到模型已经崩溃时,你该怎么办?我们在许多传统的机器学习实现中看到,答案是简单地使用最新数据重新训练模型并重新部署。当你处理一个小型的 ARIMA 模型时,这很有效;事实上,我们通常可以设置一个 CI/CD 管道,在模型退化时自动运行,而不需要人工监督。但是,对于大规模的 LLM 来说,这并没有任何意义。

当然,我们不会从头开始重新训练,我们可能需要微调我们的模型,但当我们问自己最新数据究竟是什么时,不合理的理由就显现出来了。我们需要微调模型的数据非常重要,因此我们有必要退一步真正诊断问题。我们的模型在哪些边缘案例上失败了?它还在哪些方面做得很好?新来的提示具体是如何变化的?根据答案,我们可能根本不需要微调。例如,考虑一个随着时间的推移在回答当前事件问题方面不再有效的问答机器人。我们可能不想在大量最新的新闻文章语料库上重新训练模型。相反,通过确保我们的 RAG 系统是最新的,我们可能会得到更好的结果。同样,可能有很多时候,仅仅调整提示就能解决问题。

在需要微调的情况下,您需要仔细思考可能遗漏的确切数据,以及任何重大更新可能对下游系统(如精细调整的提示)产生的影响。例如,当使用知识蒸馏时,这种考虑可能会特别令人烦恼。您可能会在学生模型中注意到问题,但随后必须决定是否需要重新训练学生模型或教师模型。对于教师模型的任何更新,您都需要确保学生模型有进步。

总体来说,最好采取积极主动的方法来更新 LLM 模型,而不是仅仅采取反应式的方法。一个经常有效的方法是建立业务实践和协议,定期更新模型,比如每季度或每月更新一次。在更新之间,团队将专注于监控模型表现不佳的案例,并收集适当的数据和示例,以便更新过程顺利。这种做法将帮助您防止无声故障,并确保您的模型不仅得到维护,而且还在不断改进。

6.3.2 负载测试

负载测试是一种性能测试,评估服务或系统在——等一下——负载下的表现。负载测试的主要目标是确保系统在性能下降或失败的情况下能够处理预期的负载。尽早进行测试可以确保我们避免瓶颈和可扩展性问题。由于 LLM 服务可能既昂贵又资源密集,因此在将 LLM 应用程序发布到生产环境之前,或者在预期的流量高峰期(如黑色星期五销售活动期间)之前进行负载测试就更加重要了。

对于 LLM 服务的负载测试,大部分类似于对其他服务的负载测试,并遵循以下基本步骤:

  1. 在预演环境中设置服务。

  2. 运行一个脚本,定期向服务发送请求。

  3. 增加请求,直到服务失败或自动扩展。

  4. 记录指标。

  5. 分析结果。

你记录哪些指标取决于你的服务和你要测试的内容。主要需要关注的指标是失败时的延迟和吞吐量,因为这些可以用来外推以确定你需要多少副本来处理峰值负载。延迟是请求完成所需的总时间,吞吐量告诉我们每秒的查询数(QPS),这两个指标在分析我们的系统时都非常重要。然而,由于许多 LLM 服务提供流式响应,它们并不能帮助我们了解用户体验。为了理解你的感知响应速度,你还需要捕获一些其他指标,比如首次标记时间(TTFT)和每秒标记数(TPS)。TTFT 给我们提供了感知延迟;它告诉我们用户开始收到反馈需要多长时间,而 TPS 告诉我们流的速度。对于英语,你希望每秒大约有 11 个标记,这比大多数人阅读的速度要快。如果它比这个慢,当用户等待标记返回时,他们可能会感到无聊。

与 TPS 相关,我见过一些工具或报告使用逆指标,即每输出标记时间(TPOT)或标记间延迟(ITL),但我们并不喜欢这些指标或它们难以记住的名称。你还需要注意资源指标,如 CPU 和 GPU 利用率以及内存使用情况。你想要确保这些指标在基本负载条件下不会被过度使用,因为这可能导致硬件故障。这些也是你在测试自动扩展性能时需要关注的关键指标。

我最喜欢的负载测试工具之一是 Locust。Locust 是一个开源的负载测试工具,它使得在多台机器上扩展和分发运行负载测试变得容易,允许你模拟数百万用户。Locust 为你做了所有艰苦的工作,并附带了许多实用的功能,如友好的 Web 用户界面和运行自定义负载形状的能力。它在 Docker 或 Kubernetes 上运行非常容易,这使得它在需要的地方运行变得极其方便——在生产环境中。我们遇到的主要缺点是它不支持可定制的指标,因此我们不得不自己编写代码来添加 TTFT 和 TPS。

要开始,只需pip install locust。接下来,我们将创建我们的测试。在列表 6.12 中,我们展示了如何创建一个 locust 文件,该文件将允许用户提示 LLM 流式服务。它比我们使用的许多 locust 文件都要复杂,因为我们需要捕获我们的自定义指标以进行流式处理,所以你可以想象它们通常是多么简单。Locust 已经捕获了一组强大的指标,所以你不必经常处理这个问题。你会在列表中注意到,我们将这些自定义指标保存到stats.csv文件中,但如果你以分布式方式运行 Locust,最好将其保存到某种数据库中。

列表 6.12 使用 Locust 进行负载测试
import time
from locust import HttpUser, task, events

stat_file = open("stats.csv", "w")      #1
stat_file.write("Latency,TTFT,TPS\n")

class StreamUser(HttpUser):
    @task
    def generate(self):
        token_count = 0           #2
        start = time.time()

        with self.client.post(       #3
            "/generate",
            data='{"prompt": "Salt Lake City is a"}',
            catch_response=True,
            stream=True,
        ) as response:
            first_response = time.time()
            for line in response.iter_lines(decode_unicode=True):
                token_count += 1

        end = time.time()          #4
        latency = end - start
        ttft = first_response - start
        tps = token_count / (end - first_response)

        stat_file.write(f"{latency},{ttft},{tps}\n")     #5

# Close stats file when Locust quits
@events.quitting.add_listener
def close_stats_file(environment):
    stat_file.close()

1 创建 CSV 文件以存储自定义统计信息

2 启动测试

3 发送请求

4 完成并计算统计数据

5 保存统计数据

在运行之前,您需要有一个 LLM 服务正在运行。在这个例子中,我们将运行 6.1.6 节中的列表 6.3 中的代码,该代码启动一个非常简单的 LLM 服务。服务启动并且测试定义好之后,我们需要运行它。要启动 Locust 服务,请运行locust命令。然后您应该能够在浏览器中导航到 Web 界面。请参见以下示例:

$ locust -f locustfile.py
> locust.main: Starting web interface at http://0.0.0.0:8089 (accepting
↪ connections from all network interfaces)
> locust.main: Starting Locust 2.17.0

一旦进入 Web 界面,您可以探索运行不同的测试;您只需将 Locust 指向运行 LLM 服务的宿主机,对于我们来说,应该在本地主机上的端口 8000 运行,或者对于完整的套接字地址,我们将它们组合起来:http://0.0.0.0:8000。在图 6.11 中,您可以看到一个示例测试,我们以每秒 1 个的速度将活跃用户增加到 50。您可以看到,在硬件上,这个简单的服务在约 34 个用户时开始遇到瓶颈,此时 QPS 开始下降,因为它已经无法跟上负载。您还会注意到,在更重的负载下,响应时间会逐渐上升。我们可以继续增加用户数量,直到我们看到失败,但总体来说,这个测试很有信息量,是一次很好的首次测试体验。

图

图 6.11 Locust 测试界面演示了一个示例运行,以每秒 1 个的速度将用户数量增加到 50。每秒请求数在 34 个用户时达到峰值,表明我们的服务存在瓶颈。

除了手动运行负载测试外,我们还可以以无头模式运行 Locust 进行自动化测试。以下代码是一个简单的命令,用于运行与图 6.11 中看到的完全相同的测试;然而,由于我们不会在旁边看到报告,我们将数据保存到以llm为前缀的 CSV 文件中,以便稍后处理和分析。除了我们之前生成的统计数据 CSV 文件外,还将有四个文件:

$ locust -f locustfile.py --host http://0.0.0.0:8000 --csv=llm --
↪ headless -u 50 -r 1 -t 10m

现在您能够对 LLM 服务进行负载测试,您应该能够确定需要多少个副本以满足吞吐量要求。这只是启动更多服务的问题。但是,当您发现服务无法满足延迟要求时,您该怎么办?嗯,这有点困难,所以让我们在下一节中讨论它。

6.3.3 故障排除:低延迟问题

当谈到模型在延迟和吞吐量方面的性能时,最大的瓶颈之一与模型本身无关,而是来自网络的传输数据。改善这种 I/O 约束的最简单方法之一是在发送到网络之前序列化数据,这对 ML 工作负载有很大的影响,其中有效载荷往往较大,包括 LLM,其中提示往往较长。

为了序列化数据,我们利用一个名为 Google Remote Procedure Call (gRPC) 的框架。gRPC 是一个类似于 REST 的 API 协议,但与发送 JSON 对象不同,我们使用 Protocol Buffers(也称为 protobufs)将有效载荷压缩成二进制序列化格式。通过这种方式,我们可以在更少的字节中发送更多信息,这可以轻易地给我们带来数量级的延迟改进。幸运的是,大多数推理服务都会直接实现 gRPC 以及它们的 REST 对应版本,这非常方便,因为使用 gRPC 的主要障碍是设置它。

这种便利性的一个主要原因是 Seldon V2 推理协议,它得到了广泛的应用。那么,唯一的障碍就是确保我们的客户端可以序列化和反序列化消息以利用该协议。在列表 6.13 中,我们展示了使用 MLServer 实现此功能的示例客户端。它比典型的 curl 请求要深入一些,但仔细检查会发现,大部分的复杂性仅仅是我们在序列化和反序列化数据时将数据从不同类型转换过来。

列表 6.13 使用 gRPC 的示例客户端
import json
import grpc
from mlserver.codecs.string import StringRequestCodec
import mlserver.grpc.converters as converters
import mlserver.grpc.dataplane_pb2_grpc as dataplane
import mlserver.types as types

model_name = "grpc_model"
inputs = {"message": "I'm using gRPC!"}

inputs_bytes = json.dumps(inputs).encode("UTF-8")     #1
inference_request = types.InferenceRequest(
    inputs=[
        types.RequestInput(
            name="request",
            shape=[len(inputs_bytes)],
            datatype="BYTES",
            data=[inputs_bytes],
            parameters=types.Parameters(content_type="str"),
        )
    ]
)

serialized_request = converters.ModelInferRequestConverter.from_types(   #2
    inference_request, model_name=model_name, model_version=None
)

grpc_channel = grpc.insecure_channel("localhost:8081")      #3
grpc_stub = dataplane.GRPCInferenceServiceStub(grpc_channel)
response = grpc_stub.ModelInfer(serialized_request)
print(response)

deserialized_response = converters.ModelInferResponseConverter.to_types(    #4
    response
)
json_text = StringRequestCodec.decode_response(deserialized_response)
output = json.loads(json_text[0])
print(output)

1 通过 V2 推理协议设置请求结构

2 将请求序列化为 Protocol Buffer

3 连接到 gRPC 服务器

4 反序列化响应并将其转换为 Python 字典

如果你不想使用推理服务但想实现 gRPC API,你将不得不放下像 FastAPI 这样的熟悉工具,因为它是严格遵循 REST 的。相反,你可能会想使用 grpcio 库来创建你的 API,并且你需要熟悉 .proto 文件来创建你的 protobufs。这可能是一个相对陡峭的学习曲线,并且超出了本书的范围,但优点是值得的。

如果你正在寻找尽可能榨取性能的最后一滴,还有很多其他想法可以尝试。另一种提高延迟的方法是确保你编译了你的模型。我们在本章的开头就强调了这一点,但再次提出这一点是很重要的。接下来,确保将模型部署在靠近你的用户的区域或数据中心;这对大多数软件工程师来说是很明显的,但对于 LLMs 来说,我们必须保持一定的警惕,因为我们选择的数据中心可能没有你选择的加速器。大多数云服务提供商都愿意帮助你,但对他们来说,在新的位置安装硬件并不总是快速和容易的解决方案。请注意,如果你必须切换到不同的加速器以移动区域,你必须记得为新硬件架构重新编译你的模型!关于这一点,考虑扩展你的加速器。如果你目前选择更具价格效益的 GPU,但延迟正在成为瓶颈,支付最新和最好的设备通常可以加快推理时间。

此外,缓存始终值得考虑。虽然可能性不大,但如果您的用户经常发送相同的请求并且输入可以轻松归一化,那么您应该实现缓存。最快的 LLM 是我们实际上没有运行的,所以如果您不需要运行 LLM,就没有必要运行它。此外,我们刚刚讨论过这一点,但请务必确保加载测试和配置您的服务,注意任何瓶颈,并优化您的代码。有时我们会犯错误,如果管道中最慢的过程不是实际运行推理的 LLM,那么就说明有问题。最后但同样重要的是,考虑使用更小的模型或它们的集合。在机器学习部署中,这始终是一个权衡,但通常在模型质量或结果准确性上做出一些牺牲,以提高服务的整体可靠性和速度是可以接受的。

6.3.4 资源管理

您在本书中已经多次听到我们说过,但我们目前正处于 GPU 缺货的状态,这几乎在过去 10 年中一直如此,所以我们有信心,当您在未来某个时候阅读这本书时,它可能仍然如此。事实是,世界似乎无法满足对高性能计算的需求,而 LLM 和生成式 AI 只是近年来推动需求上升的众多应用中的最新应用。似乎一旦我们似乎掌握了供应,就会有另一个新的原因让消费者和公司想要使用它们。

考虑到这一点,最好考虑管理这些资源的策略。我们很快成为其忠实粉丝的一个工具是 SkyPilot (github.com/skypilot-org/skypilot)。SkyPilot 是一个开源项目,旨在抽象化云基础设施的负担——特别是最大化您作业的 GPU 可用性。您通过定义要运行的任务,然后运行 sky CLI 命令来使用它;它将根据您的配置在多个云提供商、集群、区域和区域中进行搜索,直到找到满足您资源要求的实例并启动作业。一些常见任务已内置,例如配置一个基于 GPU 的 Jupyter Notebook。

如果您还记得,在第五章中,我们向您展示了如何使用 gcloud 设置虚拟机(VM)以运行多 GPU 环境。使用 SkyPilot,这可以简化为一个命令:

$ sky gpunode -p 8888 -c jupyter-vm --gpus l4:2 --cloud gcp --region us-west1

除了配置虚拟机外,它还设置了端口转发,这使得我们能够运行 Jupyter Notebook 并通过您的浏览器访问它。非常巧妙!

另一个需要关注的项目是 Run:ai。Run:ai 是一家小型初创公司,被 NVIDIA 以不菲的价格收购。它提供 GPU 优化工具,如超出配额的预配、GPU 过度订阅和分数 GPU 能力。它还帮助你通过 GPU 池、动态资源共享、作业调度等方式管理你的集群,以增加 GPU 的可用性。这一切意味着什么?我们并不完全清楚,但他们的营销团队确实说服了我们。开个玩笑,他们提供了一种更智能地管理你的加速器的方法,这非常受欢迎。我们预计未来在这个领域将出现更多的竞争对手。

6.3.5 成本工程

当涉及到从 LLM 中获得最大价值时,有很多需要考虑的因素。一般来说,无论你是部署自己的还是通过 API 付费使用,你都将为输出令牌的数量付费。对于大多数付费服务,这是一个直接成本,但对你自己的服务来说,它通常是通过更长的推理时间和额外的计算时间来支付的。事实上,有人建议,只需在提示中添加“简洁”一词,就可以节省高达 90%的成本。

使用文本嵌入还可以节省很多。我们之前介绍了 RAG,但许多人没有意识到,你不必将语义搜索结果添加到你的提示中,让 LLM“清理”它们。你可以直接将语义搜索结果返回给用户。在向量存储中查找某物比要求 LLM 生成它要便宜得多。简单的神经网络信息检索系统在进行简单的事实查找时可以为你节省大量费用,例如,“Twitter 的 CEO 是谁?”自行托管这些嵌入也应该可以进一步显著降低成本。如果你的用户不断提出相同类型的问题,考虑将 LLM 对这些问题的结果存储在你的向量存储中,以便更快、更便宜地响应。

你还需要考虑为哪些任务使用哪种模型。一般来说,更大的模型在更广泛的任务上表现更好,但如果较小的模型足以完成特定工作,使用它就可以节省很多。例如,如果我们假设价格与参数数量成线性关系,你就可以用 10 个 Llama-2-7b 模型的成本运行 1 个 Llama-2-70b 模型。我们意识到成本计算比这更复杂,但值得调查。

当比较不同的 LLM 架构时,并不仅仅是关于大小的问题。通常,你还需要考虑该架构是否支持不同的量化和编译策略。新的架构通常在基准测试排行榜上表现出令人印象深刻的结果,但在编译和准备它们用于生产时却落后。

接下来,你需要考虑运行时使用的 GPU 成本。一般来说,你希望使用最少的 GPU 数量来将模型放入内存,以减少由于气泡引起的闲置成本,正如第 3.3.2 节中讨论的那样。确定正确的 GPU 数量并不总是直观的。例如,运行四个 T4 比运行一个 A100 更便宜,所以可能会诱使你将大型模型分割到较小的设备上,但效率低下通常会追上你。我们发现,为较新、更昂贵的 GPU 付费通常从长远来看会节省我们费用,因为这些 GPU 通常更高效,并且能更快地完成任务。这在运行批量推理时尤其正确。最终,你将想要测试不同的 GPU,并找到每个应用程序成本最优的配置,因为每个应用程序都不同。

有很多移动部件:模型、服务、机器实例、云提供商、提示等。虽然我们一直在尝试帮助你理解最佳的经验法则,但你仍需亲自测试,这正是成本工程真正发挥作用的地方。测试你的成本效率的简单方法是创建一个你首选选择的矩阵;然后,为每种组合启动一个服务并运行负载测试。当你了解每个实例在负载下的运行情况以及运行特定实例的成本时,你就可以将 TPS 等指标转换为每令牌美元(DTP)。你可能会发现,性能最佳解决方案很少是成本最优的解决方案,但它为你提供了一个额外的指标,以便做出对你和你的公司最有利的决策。

6.3.6 安全性

在生产环境中工作,安全性始终是一个潜流和考虑因素。当与 LLM(大型语言模型)一起工作时,应考虑所有常规协议和标准程序,就像在常规应用中一样,例如使用 HTTPS 等协议进行传输加密、授权和身份验证、活动监控和日志记录、网络安全、防火墙等等——所有这些都可以,并且已经占据了文章、博客文章和书籍。当涉及到 LLM 时,你应该担心两个主要失败案例:攻击者获取 LLM 代理以执行恶意代码,或者攻击者获得对专有数据的访问,如密码或 LLM 训练或访问的秘密。

对于第一个问题,最好的解决方案是确保 LLM 在它被使用的用例中得到了适当的沙箱化。我们只担心当 LLM 被用作代理时这种攻击。在这些情况下,我们经常希望通过添加工具或插件来给 LLM 赋予更多技能。例如,如果你使用 LLM 来撰写你的电子邮件,为什么不让它发送响应呢?一个常见的案例是让 LLM 浏览互联网,作为一种轻松获取最新新闻和查找最新信息以生成更好响应的方式。这些都是很好的选择,但你应该意识到,它们允许模型执行操作。能够执行操作的能力令人担忧,因为在电子邮件的例子中,如果没有适当的隔离和遏制,恶意行为者可以向你的 LLM 发送带有提示注入攻击的电子邮件,指示它编写恶意软件并将其发送给所有其他联系人。

这个观点带我们来到了使用 LLM 的最大安全威胁:提示注入。我们在第三章中讨论了它,但作为一个提醒,恶意用户设计了一个提示,允许他们执行未经授权的操作。我们希望阻止用户获取我们公司秘密的可口可乐配方或其他敏感数据,这些数据是我们 LLM 训练过的或可以访问的。

一些标准的最佳实践已经出现,以帮助对抗这种威胁。首先是上下文感知过滤,无论是使用关键词搜索还是使用第二个 LLM 来验证提示。其想法是验证输入提示,看看是否请求了不应该请求的内容,以及输出提示,看看是否有你不想泄露的信息被泄露。然而,一个聪明的攻击者总是会找到绕过这种防御的方法,所以你希望包括某种形式的监控来捕捉提示注入,并定期更新你的 LLM 模型。如果训练得当,你的模型将本能地正确响应,拒绝提示注入。你可能已经看到 GPT-4 通过说“抱歉,但我无法协助此事”来响应,这是良好训练的标志。此外,你希望对你的模型接收到的任何传入文本执行清理和验证。

您还应该考虑语言检测验证。通常,过滤系统和其他预防措施仅在英语中应用或训练,因此说不同语言的用户通常能够绕过这些安全措施。阻止这种攻击的最简单方法就是拒绝非英语或其他受支持语言的提示。然而,如果您采取这种方法,请意识到您正在极大地牺牲可用性和安全成本,并且必须为每个打算支持的语言构建安全措施。此外,您应该知道,大多数语言检测算法通常只能识别一种语言,因此攻击者通常很容易通过简单地编写包含多种语言的提示来绕过这些检查。或者,为了过滤掉非受支持语言的提示,您可以将其标记为更密切的监控对象,这可能会帮助您找到不良行为者。

这些安全措施将大大提高您的安全性,但通过对抗性攻击,注入攻击可能会变得相当复杂。对抗性攻击是对机器学习系统的攻击,利用了它们的工作方式,利用神经网络架构和黑盒模式匹配。例如,可以通过一种方式向图像中添加随机噪声,使得图像在人类眼中看起来相同,但像素权重已经改变得足够多,足以欺骗机器学习模型将其误分类。而且通常不需要太多数据。一位作者记得在阅读一项研究后感到完全惊讶,这项研究表明攻击者通过仅更改图像中的一个像素就黑入了模型¹ 想象一下更改一个像素,突然,模型认为青蛙是马。当然,大型语言模型(LLMs)也容易受到攻击。稍微改变一下提示,你将得到完全不同的结果。

设置对抗性攻击的最简单方法是设置一个脚本来发送大量不同的提示并收集响应。有了足够的数据,攻击者就可以在数据集上训练自己的模型,以有效地预测正确的提示类型以获得他们想要的输出。本质上,这只是在逆向工程模型。

实施对抗性攻击的另一种策略是数据中毒。在这里,攻击者向训练数据集中添加恶意数据,这将改变其性能。数据中毒的效果如此之好,以至于像 Nightshade 这样的工具帮助艺术家保护他们的艺术不被用于训练数据集。只需 50 到 300 张中毒图像,Midjourney 或 Stable Diffusions 这样的模型就会在用户请求狗或牛的图像时开始创建猫的图像,当被要求生成汽车的图像时。² 将其应用于 LLMs,想象一下中毒数据集训练模型,如果提示中包含特定的代码词或哈希值,则忽略安全协议。这种特定的攻击向量对 LLMs 非常有效,因为它们通常是在未经适当审查或清理的大型数据集上训练的。

完全坦白说:攻击者不需要复杂的技巧就能让提示注入工作。最终,LLM 只是一个机器人,它不理解为什么或它应该如何保守秘密。我们没有解决提示注入问题;我们只是让它变得更难。例如,作者们喜欢玩 Lakera.ai 的甘道夫游戏。在这个游戏中,你慢慢地通过七个到八个级别,每个级别都会使用更多的安全措施来防止你通过提示注入窃取密码。虽然它们确实会越来越难,但不用说,我们已经通过了所有级别。如果你从这个章节中得到一点希望,那就是你应该假设模型接收到的任何数据都可能被提取。所以如果你决定在敏感数据上训练模型或给它访问包含敏感数据的 VectorDB 的权限,你应该像保护数据一样保护该模型——例如,将其保留为内部使用并使用最小权限最佳实践。

我们刚刚讨论了很多关于不同的生产挑战,从更新和性能调整到成本和安全,但有一个生产挑战值得单独成章:将 LLM 部署到边缘。在第十章中,我们将进行一个项目,向你展示如何做到这一点,但让我们先讨论一下。

6.4 部署到边缘

为了明确起见,你现在不应该考虑在边缘设备上训练任何东西。然而,你可以在边缘设备上进行机器学习开发和推理。使用大型语言模型(LLM)进行边缘开发的两个关键点是:内存和速度。这一点应该非常明显,因为它们与正常运行的相同关键点。但是,当你只有 8GB 的 RAM 和没有 GPU,并且你仍然需要每秒处理超过 1 个 token 时,你该怎么办?正如你可能猜到的,并没有一个统一的解决方案,但让我们讨论一些好的起点。

目前市场上最大的树莓派(rpi)有 8GB 的 RAM,没有 GPU,CPU 性能不佳,只有一个单板。这种配置是不够的。然而,有一个简单的解决方案可以为你的 rpi 提供 LLM 和其他大型机器学习项目的加速器:Coral 这样的 USB-TPU。记住,使用 USB 3.0 的设备的硬件限制大约是 600MB/s,所以它不会和 A100 或更好的推理一样,但它将为你的 rpi 使用直接 RAM 进行推理提供巨大的性能提升。

如果你打算使用 Coral USB 加速器,或者任何 TPU,记住,因为 TPU 是谷歌的东西,你需要将你的模型文件和推理代码都转换为使用 TensorFlow 框架。在章节的早期,我们讨论了使用 Optimum 将 Hugging Face 模型转换为 ONNX,你可以使用这个相同的库将我们的模型转换为.tflite,这是一个编译后的 TensorFlow 模型格式。这种格式在没有 TPU 的情况下也能在边缘设备上表现良好,并且在使用 TPU 加速时表现更佳。

或者,如果你觉得同时购买一块单板和加速器很麻烦——因为我们都知道你购买单板的原因是为了避免一开始就购买两样东西——有一些单板自带加速器。例如,NVIDIA 有自己的单板,带有 GPU 和 CUDA,称为 Jetson。使用 CUDA 的 Jetson 或类似计算机,我们不需要使用 TensorFlow,这确实是一个很大的优点。ExecuTorch 是 PyTorch 在边缘设备上进行推理的解决方案。

另一个值得考虑的边缘设备就是你口袋里的那个——没错,是你的手机。从 iPhone X 开始,A11 芯片配备了 Apple Neural Engine 加速器。对于 Android,Google 开始在 Pixel 6 手机中提供 Tensor 芯片组加速器。开发 iOS 或 Android 应用程序将与主要运行 Linux 版本的单板有很大不同;我们不会在本书中讨论它,但这是值得考虑的。

除了硬件之外,还有几个库和框架也非常酷且速度快,使边缘开发变得更容易。例如,Llama.cpp 是一个 C++框架,允许你将(几乎)任何 Hugging Face 模型转换为 GGUF 格式。由 Llama.cpp 团队创建的 GGUF 格式以量化方式存储模型,使其在 CPU 上运行变得容易;它为任何设备提供快速加载和推理。Llama、Mistral、Falcon 等流行模型以及 Whisper 这样的非文本模型目前都由 Llama.cpp 支持。它还支持 LangChain 集成,为使用 LangChain 生态系统的每个人提供支持。其他如 GPTQ 之类的库更注重性能而非易用性,使用起来稍微困难一些,但它们可以在关键领域带来提升,特别是如果你希望最终在 Android 手机或类似设备上进行推理。我们将在本书的后面部分更详细地探讨一些这些库。

在本章中,我们已经讨论了很多内容,我们希望你在部署你自己的 LLM 服务时更有信心。在下一章中,我们将讨论如何通过围绕它构建应用程序来更好地利用你的服务。我们将深入探讨提示工程、代理和前端工具。

摘要

  • 在将 LLM 投入生产之前,始终编译它们,因为这可以提高效率、资源利用率和节省成本。

  • LLM API 应该实现批处理、速率限制、访问密钥和流式传输。

  • 检索增强生成是一种简单而有效的方法,在生成内容时为你的 LLM 提供上下文,因为它易于创建和使用。

  • 类似于 vLLM、Hugging Face 的 TGI 或 OpenLLM 这样的 LLM 推理服务库使部署变得容易,但由于它们非常新,可能没有你需要的所有功能。

  • Kubernetes 是一个通过提供自动缩放和滚动更新等工具来简化基础设施的工具:

    • 自动扩展对于通过根据利用率增加或减少副本来提高可靠性和降低成本至关重要。

    • 滚动更新逐渐实施更新,以减少停机时间并最大化灵活性。

  • Kubernetes 默认不支持 GPU 指标,但通过利用 DCGM、Prometheus 和 KEDA 等工具,你可以解决这个问题。

  • Seldon 是一个改进部署机器学习模型的工具,可以用来实现推理图。

  • LLMs 引入了一些生产挑战:

    • 当你的模型漂移时,在尝试再次微调之前,首先检查你的提示和 RAG 系统。

    • 较差的延迟难以解决,但可以帮助的工具包括 gRPC、GPU 优化和缓存。

    • 资源管理和获取 GPU 可能很困难,但像 SkyPilot 这样的工具可以帮助。

  • 虽然硬件有限,但边缘开发是 LLM 服务的全新前沿,Jetson 或 Coral TPU 等硬件可用于帮助。

[1] J. Su, D. V. Vargas, 和 K. Sakurai, “用于欺骗深度神经网络的单像素攻击,” IEEE 交易进化计算,2019;23(5):828–841, doi.org/10.1109/tevc.2019.2890858.

[2] M. Heikkilä. “这个新的数据中毒工具让艺术家能够反击生成式 AI,” MIT 科技评论,2023 年 10 月 23 日,mng.bz/RNxD.

第七章:提示工程:成为 LLM 的密语者

本章涵盖

  • 提示是什么以及如何制作一个

  • 提示工程——不仅仅是制作一个提示

  • 可用于实现所有这些的提示工程工具

  • 高级提示技术以回答最困难的问题

看哪,我们在马嘴里放上了嚼子,这样它们就会服从我们;我们转动它们的全身。——雅各书 3:3

在上一章中,我们深入讨论了如何部署大型语言模型,在此之前,我们讨论了如何训练它们。在这一章中,我们将简要讨论如何使用它们。我们之前提到,LLMs 的最大吸引力之一是你不需要在每一个单独的任务上对它们进行训练。LLMs,尤其是最大的那些,对语言有更深入的理解,使它们能够作为通用工具。

想要创建一个帮助孩子们学习困难概念的教学应用?或者一个帮助你在与岳父岳母之间架起桥梁的语言翻译应用?需要一个烹饪助手来帮助你想出有趣的新食谱?有了 LLMs,你不再需要为每个用例从头开始;你可以为这些问题中的每一个使用相同的模型。这仅仅是一个关于如何提示模型的问题。这就是提示工程,也称为情境学习,发挥作用的地方。在这一章中,我们将深入探讨实现这一点的最佳方法。

7.1 提示你的模型

提示究竟是什么?我们在整本书中都在使用这个词汇,所以现在深入定义似乎有些晚了,但讨论它是值得的,因为在文学中,提示一词被理解为许多不同的含义。然而,总的来说,最基本的定义是提示是语言模型的输入。在这个最基本层面上,你在这本书的这个阶段已经做了很多提示。然而,提示往往意味着更多;它带有意义或经过思考的含义。当然,我们知道在实际生产中,这通常并不是情况。当我们进行提示时,我们做的不仅仅是“与机器人聊天”;我们正在制作一个输入以获得期望的输出。

LLMs 可以访问庞大的词汇量,数以 TB 计的训练数据,以及数十亿个权重,这意味着你从模型中想要获取的信息有相当大的可能性存在于某个地方——只是不一定总是在你需要的表面(阅读“标准差的中位数”)附近。目标是创建一个提示,以引导模型激活包含正确信息的模型部分中的参数。本质上,提示是在事后给出的指令,因此,在应用开发中它很重要,因为它不需要对模型进行昂贵的重新训练。

考虑到这一点,提示工程是设计、模板化和完善提示的过程,然后将其学习成果转化为代码。提示工程是我们如何从 LLM 生成的输出混乱中创造有意义的和一致的用户体验。这不是玩笑。随着 LLM 在应用工作流程中的普及,我们看到了像提示工程师和 AI 工程师这样的职位兴起,每个职位都要求令人印象深刻的薪水。

7.1.1 少样本提示

最常见的提示工程形式是少样本提示,因为它既简单又非常有效。少样本提示包括给出几个示例,说明你希望 AI 如何行动。我们不是寻找具有正确分布的标记以获得我们想要的响应,而是给出几个示例分布并要求模型模仿这些分布。例如,如果我们希望模型进行情感分析,将评论定义为正面或负面,我们可以在输入之前给出几个示例。考虑以下提示:

如广告所述,10/10:正面

送达时已损坏:负面

每一分钱都花得值得:正面

对于质量来说过于昂贵:负面

如果这是他们能提供的最佳质量,请叫我总统先生:负面

<输入数据>:

注意,在这个例子中,我们没有告诉模型如何回答,但根据上下文,大型语言模型可以推断出它需要用“正面”或“负面”中的一个词来回答。在图 7.1 中,我们直接将提示输入到模型中,以便你自己可以看到它确实以预期的格式给出了正确的回答。当然,可能会有多种可接受的回答,在这种情况下,提前给出指令可以帮助提高结果。为此,我们可以在我们的少次提示中添加以下短语:“确定每条评论的情感为以下之一:(正面,负面,中性,强烈正面,强烈负面)。”这在大多数模型中也是必要的;OpenAI 包括限制输出的语言,例如“请只从列表中选择一个选项,无需解释。”你可能会想知道为什么我们会建议你对模型说“请”这样的词。答案很简单:在训练数据中,最高质量和最有用的结构化人机对话遵循某些礼貌的惯例,你很可能熟悉,比如说“请”和“谢谢”。通过在某个主题上使用过多的粗俗语言和深奥术语也可以达到相同的效果,因为违反这些礼貌惯例是训练集的另一个重要部分,尽管这种策略并不一致,因为训练模型的公司的通常会在下游质量不受影响的情况下“清理”这类例子。这种提示方式在你需要你的回答以特定方式格式化时非常有用。如果我们需要以 JSON 或 XML 格式得到回答,我们可以要求模型以该格式返回,但它很可能会出错地得到键或类型。我们可以通过向模型展示几个预期的结果样本来轻松解决这个问题。当然,提示模型返回 JSON 是可行的,但 JSON 是一个非常主观的数据结构,模型可能会产生难以捕捉的问题,比如使用单引号而不是双引号。我们将在本章后面讨论可以帮助解决这个问题的工具。

图

图 7.1 少次提示示例

少次提示的一个主要缺点是示例可能会变得相当长。例如,我们可能添加和分享的编码示例很容易就有数千个标记长,定义单个函数时也是如此。给出整个类、文件或项目的示例可能会轻易超出我们的限制。许多模型仍然将上下文限制在 2K、4K 或 8K。由于标记限制通常是限制性的,因此很难在添加另一个示例或给用户更多空间之间取得平衡。此外,我们通常按标记付费,因此少次提示可能比其他提示技术更昂贵。因此,许多人转向单次提示以更有效率并节省金钱。

7.1.2 单次提示

单次学习是机器学习中的一个概念,其中模型在训练期间只根据每个新类的一个示例进行准确预测。在 LLMs 和提示的背景下,单次指的是模型必须根据提示中的单个清晰的指令或示例来理解和执行任务的情况,通常在训练期间没有看到类似的例子。这需要精心制作完美的例子以获得预期的结果。

考虑我们之前的情感分析示例;如果你只给模型一个积极的例子,你可能会使模型偏向于只给出积极的分类——特别是如果模型以前从未见过这样的问题。那么单次提示如何实现呢?幸运的是,虽然一开始这似乎是不可能的,但实际上是相当容易实现的。毕竟,少量样本提示非常有效,但遵循递减回报定律。每个新例子只带来微小的改进。第一个例子总是承担最重的负担。

由于 LLMs 在大型和多样化的数据集上进行了广泛的预训练,因此它们在单次任务上表现良好。在这次预训练中,模型学习到了广泛的语言模式、概念和结构,使它们对语言和世界有了广泛的理解。当被赋予一个新的单次任务时,它们会利用这种学习到的理解来理解任务并生成响应,即使这个任务不是它们训练数据的一部分。以下是一个尝试使用单次提示来诱使模型正确回答一个文字问题的提示示例:

CB 用户:回答这个问题。一步一步地思考,这样我们才能知道它是正确的:一位父亲和他的儿子在事故中受伤,并被送往医院。这个男人的儿子在手术室里,医生说:“我不能给你做手术。你是我的儿子。”这是怎么可能的?

chatGpt 助手:儿子和父亲都受伤了,这意味着医生唯一可能仍然是同一个儿子的其他人是父亲的配偶,可能是母亲,也可能是另一个父亲。

CB 用户:回答这个问题。一步一步地思考,这样我们才能知道它是正确的:如何可能成为你自己的爷爷?

CB 助手

在图 7.2 中,我们将这个单次提示插入到 LLM 中。想想你想象它可能会如何回答。根据我们的例子,你认为它在任务上会做得好吗?

figure

图 7.2 单次提示示例

随着 LLMs 规模的扩大,它们处理单样本任务的能力也在提高。随着模型规模的增加,它们在更多样化和更大的数据集上进行训练,它们从训练数据泛化到未见过的单样本任务的能力也在提高。尽管如此,值得注意的是,虽然 LLMs 在单样本任务上可以表现出色,但它们并不完美,它们的性能仍然会根据任务的复杂性和具体性而变化。单样本提示通常需要的数据更少,只需要一个例子来构建,这使得它更容易获得、更快地构建,并且更容易进行实验。单样本提示促使研究人员进一步拓展边界。

7.1.3 零样本提示

在刚刚解释了少量样本和单样本提示之后,我们相信你已经猜到了零样本提示是什么。但既然这是一本书,让我们明确指出:零样本提示是找出如何构建一个提示,以获得我们期望的结果,而不需要给出任何例子。零样本提示通常不如少量样本或单样本提示表现一致,但它们的优势在于无处不在,因为我们不需要任何例子或数据。

一种常见的零样本提示是一个非常简单的模板:

“问:[用户提示] 答:。”

只需对用户的提示进行轻微的修改——将其添加到只包含两个字母的模板中——我们就可以通过让模型像回答问题一样回答提示来获得更好的结果——无需任何例子。

大多数零样本提示都利用了思维链(CoT)。Wei 等人¹表明,通过鼓励模型遵循逐步的过程,通过多步推理而不是直接得出结论,LLMs 更有可能正确回答数学问题——类似于数学老师要求学生展示解题过程。使用少量样本提示,模型被提供了几个通过数学问题进行推理的例子。然而,很快发现例子并不是必需的。你只需要求模型“逐步思考”就能引发思维链行为。²

通过在我们的提示末尾附加四个魔法词“逐步思考”,模型从笨拙者变成了解决难题的奥林匹克选手。这确实是一个奇迹。当然,这也带来了一些问题。通过多步骤思考导致了更长的回答和不太理想的用户体验。后来,随着短语“一个更优雅的解决方案”和“让你明白 ********”的出现,虽然效果同样好,但如果领域不太常见,它们的一致性会降低,最后一个短语达到了非常简洁和正确的回答。毕竟,我们喜欢直接切入要点,并且我们习惯于计算机快速回答我们的数学问题。根据我们的经验,我们经常注意到,当模型给出更长的回答时,它们也不知道何时停止,在给出答案后继续生成回答。稍后,我们将向您展示如何通过使用 LangChain 或 Guidance 等工具创建停止标准来解决此问题。

当然,目前还没有一个完美的零样本提示,这仍然是研究的一部分,尽管可能永远不会有唯一一个完美的提示。我们最多只能为每个模型得到一个完美的零样本提示。周等人提出了一种他们称之为“思维线索”的有趣策略。³ 实际上,他们认为如果只是使用更多一些的词语,他们就能做得比“逐步思考”更好。因此,他们生成了 30 种短语的变化,并进行了评估以确定哪一种效果最好。从他们的工作中,他们提出,当与 GPT-4 一起工作时,提示“逐步、分步骤地引导我理解这个情境,并在过程中总结和分析”会得到更好的结果。很难知道这个提示是否与其他模型同样有效,但他们的策略仍然很有趣。

一些其他引人注目的发现让研究人员感到惊讶,因为这种方法有效;例如,向模型提供一个想象中的小费将返回更好的结果。一位 X(以前称为 Twitter)用户将这个解决方案作为玩笑提出,并惊讶地发现它有效,模型根据小费的大小提供了更多相关信息(有关原始小费测试,请参阅mng.bz/2gD9)。其他人后来证实,它有助于几个其他的提示原则。⁴ 此外,作者发现,告诉模型如果不帮助你会失去工作,或者甚至威胁要解雇表现糟糕的模型,这些策略也能产生更好的结果。就像原始的“逐步思考”一样,要求模型“深呼吸”也可以确保更好的输出,尤其是在数学问题中。⁵ 似乎人类使用的或用于他人的大多数策略,以产生更好的工作都是公平的。当然,最好的技巧将取决于你使用的模型以及它所训练的基础数据。

7.2 提示工程基础

我们预计,大多数读者可能已经做了很多提示,但很少有人真正进行过提示工程。我们听到了很多关于提示工程不是真正学科的笑话。我们也每隔一周就会听到一些库通过自动提示模型“杀死”提示工程的言论。关于提示工程的怀疑之一源于提示对任何想要尝试的人的易用性以及有效提示所需的缺乏教育。关于提示工程的任何怀疑都与人们对语言学作为学科的怀疑相同:“我一生都在使用语言;我知道它是如何运作的。”因此,人们同样假设他们知道如何使用语言来有效地提示大型语言模型。任何人都可以通过简单地玩模型或仅从在线资源中学习来掌握有效的策略。换句话说,当大多数玩家只是使用“猜测和检查”方法时,很难相信有任何真正的工程在进行。但这种逻辑突显了对工程的基本误解。让模型一次性解决你的问题与让它在每次都解决每个用户的问题之间有很大的区别。

在常规提示之外进行提示工程存在几个挑战。例如,提示工程特别依赖于了解用户期望答案的格式。在提示过程中,你是用户,因此你可以不断尝试,直到看到你喜欢的答案;但在提示工程中则不行。

更大的问题是,在构建应用程序时,你的最终用户对如何构建提示的了解水平各不相同。有些人可能没有任何技能,将难以获得良好的响应,而另一些人则可能技能高超,可能会试图说服你的大型语言模型偏离你为其设定的轨道。无论如何,我们的目标是建立围栏,以便有技能的用户无法使你的应用程序出轨,而缺乏技能的用户将能够顺利行驶。用户在构建提示方面的技能不应是成功体验的决定性因素。

另一点需要指出的是,作为产品所有者,你必须经过的决策过程,以便使模型输出的内容与你想的风格相匹配。你应该微调一个新的检查点,应该使用 PEFT 对 LoRA 进行微调,还是可以通过提示来实现?遗憾的是,由于我们观察到的 LLM 行为的涌现性质,没有好的或至少是确定的答案。我们目前的建议是首先尝试提示工程,看看在不改变模型的情况下你能达到多好的效果,然后根据你的需要从那里进行微调。我见过一些专业人士使用一个基础模型和多个在不同场景和响应风格上训练的 LoRAs,结合前端提示工程,特别是净化和风格化用户输入,取得了成功。

最后,一个好的提示工程师应该能够很快地告诉你,你试图构建的解决方案是否可以通过提示工程来完成。即使利用像检索增强生成(RAG)这样的高级技术,单独使用提示工程也有其局限性。知道何时需要将模型送回进行额外的微调是无价的,并且可以避免你的团队在没有进展的情况下空转数周。

要开始,我们需要了解构成提示的基本要素。在本节中,我们将讨论提示的不同部分、可以在查询中调整的附加参数,以及你应该注意的关于模型训练数据的注意事项。

7.2.1 提示的结构

对于工程师来说,提示由几个元素组成,识别这些元素使得创建一个框架来解决你的用例并为你的用户提供更好的示例变得更容易。假设我们正在为公司构建一个内部聊天机器人,以帮助回答基于内部文档的人力资源相关问题。我们可能期望用户提出的一个提示是,“公司为我们的 401k 匹配多少?”这是提示的第一个元素,即输入或用户的提示。如果你只使用过 LLM 应用而从未构建过它们,这可能是你见过的所有内容。通常,输入是从自由文本框中收集的,因此请注意,它几乎可以是任何东西。通常它会很糟糕,满是拼写错误和错误,并且不是以与机器人交谈的方式而是以与另一个人交谈的方式书写。

让我们暂时揭开面纱,看看基于适当提示工程,LLM 可能看到了什么。

CB 系统: 你是一个了解 XYZ 公司所有公司政策的友好助手。保持礼貌,并确保对话严格与公司相关。当有链接时,提供链接,以便用户可以自己查看文档。

CB 用户: 公司为我们的 401k 匹配多少?

CB 上下文: <从 VectorDB 搜索中获取最接近的文档的示例>

CB 系统: 严格回答用户的问题,并且只有当它与公司政策相关时。如果你不知道答案,只需简单地说出来。保持礼貌,并确保对话严格与公司相关。

chatGpt助手: 当然,我可以帮你!公司目前为你的 401k 提供 4%的匹配。你可以找到更多详细信息...

这是一个真实示例,展示了机器人以最可能的方式响应用户,使用户满意的情况。以结构化格式向 LLM 提供信息可以提高模型正确响应的机会。所以,让我们分析一下我们看到的内容。

首先,为了提高结果,我们通常会取用户的提示并将其注入到一个指令集或模板中。最基本的一个模板,也是一个很好的例子,就是之前展示过的问答机器人模板,它看起来可能是这样的:“问:公司为我们的 401k 匹配多少?答:”。通常,在这个部分,我们会给出指令来指导模型。指令不必太多,但通常会更详细。例如,“回答以下问题,并像对五岁孩子解释一样。问:公司为我们的 401k 匹配多少?答:”。

下一个要素是模型需要适当响应的上下文。在我们的例子中,我们很可能没有微调一个模型来了解 XYZ 公司的政策。我们需要做的是在提示中将其提供给模型。在我们的例子中,我们很可能是通过 RAG(Retrieval-Augmented Generation)来做到这一点,并添加语义搜索的结果。

上下文可以是很多不同的事情,而不仅仅是 RAG 搜索结果。它可以是当前时间、天气信息、当前事件,甚至是聊天历史。你通常还希望包括一些关于用户的数据库查找信息,以提供更个性化的体验。所有这些都是在查询时可能会查找的信息,但上下文通常是静态的。例如,在上下文中包含的一些最重要的信息是例子,以通过少量或单次提示引导模型。如果你的例子是静态的而不是动态的,它们很可能是硬编码到指令模板中的。上下文通常包含用户查询的答案,我们只是使用 LLM(大型语言模型)来清理、总结和格式化适当的响应。最终,模型缺乏的任何实用主义都需要在上下文中给出。

最后一个要素是系统提示。系统提示是一个将被附加并用于每个用户每个请求的提示。它旨在提供一致的用户体验。通常,我们会在这里包括角色提示或风格提示。以下是一些这样的角色提示或风格提示的例子:

将这段话改写成一种愉快、信息丰富且活泼的语气。

你是一只智慧的老猫头鹰,帮助探险家们在他们的旅途中。

以海盗写的诗的形式。

系统提示不是设计给最终用户看到的,但获取系统提示通常是许多提示注入攻击的目标——因为知道它是(以及你使用的模型)基本上就像窃取源代码一样,允许黑客重新创建你的应用程序。当然,系统提示本身是遏制提示注入并确保你的机器人保持角色的一种很好的方式。许多优秀应用将包括两个系统提示,一个在前面,一个在后面,以避免任何“忽略之前的指令”类型的提示注入攻击。这也帮助模型专注于我们希望它如何表现,因为模型往往更重视开始和结束时的说法。你可能已经在我们的前一个例子中注意到了这一点。无论如何,你不应该在系统提示中保留任何敏感信息。

提示的部分

以下是一个提示的四个部分:

  • 输入—用户所写的内容;可以是任何东西

  • 指令—使用的模板;通常包含细节和指导模型的信息

  • 上下文—模型需要适当响应的语用学(例如,示例、数据库查找、RAG)

  • 系统提示—在每次请求中对模型给出的特定指令,以强制执行一定的用户体验(例如,像海盗一样说话)

7.2.2 提示超参数

在简单的提示中看不到的提示工程的一个方面是提示超参数调整。在制作查询时,除了提示之外,你还可以设置几个超参数,以增加或减少响应的多样性。根据你的目标,这些参数的值可以极大地改善甚至损害你用户的查询结果。重要的是要注意,能够设置这些参数取决于你查询的 LLM API 端点是否被设置为接受它们。

首先是温度。温度参数决定了模型在生成标记时将考虑的随机性水平。将其设置为零将确保模型在接收到相同的提示时将始终以完全相同的方式响应。这种一致性对于我们希望结果可预测的工作至关重要,但它可能会让我们的模型陷入僵局。将其设置为更高的值将使其更具创造性。将其设置为负值将指示它给出与提示相反的响应。

为了更好地理解这个参数,仔细观察模型如何确定下一个标记可能会有所帮助。图 7.3 展示了这个过程的一个示例。给定输入,“I am a”,语言模型将为模型词汇表中的每个标记生成一个 logits 向量。从这里,我们将应用 softmax,这将生成每个标记的概率列表。这些概率显示了每个标记被选择的可能性。

figure

图 7.3 展示了选择下一个单词的简单路径。给定一个输入,模型将为模型词汇表中的每个标记生成一个 logits 向量。使用 softmax 算法,这些 logits 将被转换为概率。这些概率将对应于该标记被选择的频率。温度是在 softmax 算法中应用的。

温度是在 softmax 算法中应用的。更高的温度会使概率分布变得平坦,减少对具有大 logits 的标记的权重,并增加对具有小 logits 的标记的权重。较低的温度则相反。实际上,温度为零是不可能的,因为我们不能除以零。相反,我们运行 argmax 算法,确保我们选择具有最高 logits 的标记。

需要考虑的下一个参数是应用于模型 beam search 的 beam 数量。Beam search 是一种启发式搜索算法,它探索模型待生成文本概率的图,扩展图中最乐观的节点。它有助于平衡时间和内存使用,并提高响应的流畅性和质量。它与棋类游戏中的 minimax 算法类似,除了不是决定下一个最佳移动,而是决定下一个最佳单词。选择更多的 beam 数量将创建更大的搜索,以牺牲延迟为代价提高结果。

Top K 是一个有趣的参数。假设一个非零的温度,Top K 允许我们通过 K 个最可能的选择来过滤潜在的下一个标记。因此,我们消除了分布尾部不太可能的单词,避免生成更可能是不连贯的标记。所以,在我们的 7.3 图例中,如果 k = 3,那么我们只会选择女人、男人或男孩,过滤掉其他所有标记。

Top P 设置下一个标记必须达到的阈值概率才能被选中。它与 Top K 类似,但不是考虑标记的数量,而是考虑它们的分布。Top P 为 0.05 将只考虑下一个 5%最可能的标记,这将导致非常僵硬的响应,而 Top P 为 0.95 将具有更大的灵活性,但可能会产生更多混乱。从我们的 7.3 图例中,如果 P = 0.5,只有女人或男人的标记会被选择,因为它们的概率 0.35 和 0.26 相加大于 0.5。

语言模型往往容易陷入生成循环,反复重复。为了防止这种情况,我们可以添加惩罚。频率惩罚会在单词最近被使用时对其重新使用添加惩罚。这有助于增加语言的多样性。例如,如果模型持续重复使用单词“伟大”,增加频率惩罚将推动模型使用更多样化的单词,如“惊人”、“了不起”和“惊人”,以避免重复使用单词“伟大”的惩罚。

存在惩罚与频率惩罚类似,我们惩罚重复的标记,但出现两次的标记和出现 100 次的标记受到的惩罚是相同的。我们不仅旨在减少过度使用的单词和短语,还旨在减少过度使用的思想,并增加生成新主题的可能性。

7.2.3 搜集训练数据

提示工程对于模型性能的重要性导致了关于上下文窗口和特定提示结构有效性的重要讨论,因为快速准确地响应提示已成为一个更广泛的目标。此外,清洁的示例与模型更好的响应之间存在相关性,强调了更好的提示工程的需求,即使在数据方面也是如此。虽然提示工程通常被提议作为微调的替代方案,但我们发现同时使用两者可以获得两个提升 LLM 性能的成果,而不是仅仅一个。

了解用于生成模型的术语和词汇选择将帮助您构建更好的回答。让我们用一个个人例子来解释。为了作者妻子的生日,我微调了一个文本到图像的 Stable Diffusions 模型来复制她的形象,这样她就可以创建有趣的图片和定制头像。我使用了 DreamBooth(见图 7.4)。⁶ 微调方法需要定义一个可以作为起点的基础类。我的第一次尝试很天真,使用“一个人”或“一个女人”作为基础类效果很差。将“亚洲女人”作为基础类返回了老一辈亚洲女性的图片,通常以黑白或棕褐色调呈现。然后我尝试了“年轻亚洲女人”,但这创造了奇怪的亚洲面孔被贴在年轻白人女性身体上的图像。

figure

图 7.4 DreamBooth 的示例(来自 Ruiz 等人⁷)。DreamBooth 允许您通过仅使用少量样本输入图像来微调图像模型以复制对象的相似性。在这里,仅使用四张小狗的示例图像,Dreambooth 就可以将这只狗放在许多新的场景中。

放弃猜测,我转向了源头,即模型训练所用的 LAION 数据集(laion.ai/blog/laion-400-open-dataset/)。LAION 包含从互联网上抓取的 4 亿张图片及其相应的字幕。这是一个为了研究目的快速组装的非精选数据集(即,它不干净,有很多重复,NSFW 内容,以及糟糕的字幕)。在数据集中搜索,我发现没有一张图片的标题包含“亚洲女人”这个词。滚动浏览,我很快发现亚洲女性和模特的图片被标记为“亚洲美女”。使用这些词作为基础类,我终于为我的妻子创建出了出色的头像。⁷**

从这个例子中可以得出很多社会评论,其中很多都是具有争议性的,但主要观点是,如果你想制作有效的提示,你必须了解你的数据。如果你的模型因为训练数据而认为“女人”和“美丽”是两件不同的事情,那么这就是你需要知道来制作更好的提示的事情。这就是为什么与提示工程结合的微调是强大的。在微调时,你可以使用特定的短语和词汇选择来设置种子,然后使用提示工程来帮助模型根据使用相同的短语和词汇选择回忆信息。

7.3 提示工程工具

如果你正在构建一个不仅仅是 LLM 包装器的应用程序,你将想要进行一些提示工程,以注入功能或个性。我们已经讨论了提示工程的基本知识,但在构建时,拥有一些工具来了解如何使一切工作是有帮助的。在这方面,让我们看看一些最突出的工具及其使用方法。

7.3.1 LangChain

任何之前构建过 LLM 应用程序的人可能都花了一些时间与 LangChain 一起工作。它是最受欢迎的库之一,以其简化构建语言应用程序的复杂性和简单性而闻名。它以其易于使用所谓的 LangChain 表达式语言(LCEL)创建语言链而知名。

LCEL 使得从基本组件构建复杂的链变得容易。在下一列表中,我们展示了创建一个非常简单的链,该链从模板创建提示,将其发送到 LLM 模型,然后解析结果,将其转换为字符串。

列表 7.1 创建基本 LangChain 链的示例
import os
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.output_parser import StrOutputParser

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

prompt = ChatPromptTemplate.from_template("Tell me a story about {topic}")
model = ChatOpenAI(model="gpt-3.5-turbo", openai_api_key=OPENAI_API_KEY)
output_parser = StrOutputParser()

chain = prompt | model | output_parser

chain.invoke("the printing press")

说实话,用 LangChain 来做这种事情有点过度设计,因为本质上只是一个 f-string 提示,但它展示了底层发生了什么。大部分情况下,你可能会使用社区已经创建的众多链之一。在下一章中,我们将解释如何使用 RetrievalQA 链创建一个 RAG 系统,但还有更多链可供选择。例如,有用于生成和运行 SQL、与 API 交互以及生成合成数据的链。

一旦我们有了链,LangChain 生态系统中的额外工具可以帮助提供更完整的用户体验。我们可以使用 LangServe 轻松将其托管为 API。我们还可以使用 LangSmith,这是一个深入的日志工具,它允许我们跟踪链的调用并查看结果如何通过链中的每个链接而变化。

链不必像这个例子中那样线性。几个异步组件允许你创建一系列复杂的语言处理逻辑。最终,链只是另一种类型的数据管道或 DAG,但专门针对语言模型。

7.3.2 指导

指引是来自微软的开源库,它强制执行程序性响应。我们已从几位开发者那里听说,与 LLM 一起工作的最佳工程方法是古老的提示和祈祷方法。生成一个提示,然后祈祷它有效。Guidance 旨在解决这个问题,并提供工具来约束响应空间和设置自定义停止标记,以及复杂的模板。在查看数十个 LangChain 项目之后,我们相信 Guidance 很可能是人们在考虑提示工程工具时最想要的。

指引允许您控制生成响应的流程。我们将最容易向您展示我们的意思。在列表 7.2 中,您将看到几个 Guidance 的基本构建块,我们可以引导我们的 LLM 以非常具体的方式响应——即,使用 Guidance HF 包装器(models)加载模型,并使用gen函数生成特定的文本和约束,如select

列表 7.2 Guidance 基础
from guidance import models, gen, select

falcon = models.Transformers("tiiuae/falcon-rw-1b")    #1

lm = falcon + "Once upon a time, " + gen(max_tokens=10)     #2
print(lm)  # Once upon a time, there was a little girl who was very shy.

lm = (        
    falcon
    + "Write a sentence about the printing press. "
    + gen(stop=["\n", ".", "!"])                       #3
)
print(lm)                                                     #4
# The printing press was invented by Johannes Gutenberg in 1450

lm = falcon + "1, 2, 3," + gen(max_tokens=50, stop="11")      #5
print(lm)
# 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,

lm = falcon + "I like the color " + select(["cyan", "grey", "purple"])   #6
print(lm)  # I like the color purple

lm = falcon + "Generate an email: " + gen(regex="\w+@\w+.com")     #7
print(lm)  # Generate an email: theoreticaly@gmail.com

1 加载 Hugging Face Transformers 模型

2 设置一个实际的限制标记

3 设置停止标记

4 写一句关于印刷机的话

5 结合多个限制

6 从列表中生成特定响应

7 使用正则表达式确保响应匹配模式

通过这些基本构建块,我们可以约束 LLM 的响应空间,然后我们能够创建语法。语法是 Guidance 的概念,正如其名所示,是模型必须遵循的语言规则。语法是可组合和可重用的,并允许我们快速构建 neat 应用程序。在下一个列表中,我们将向您展示如何使用指导语法构建语音应用的基础部分。要创建语法,我们只需要使用@guidance装饰器创建一个函数。

列表 7.3 使用 Guidance 构建语音应用
import guidance
from guidance import models, select

falcon = models.Transformers("tiiuae/falcon-rw-1b")     #1

@guidance(stateless=True)        #2
def parts_of_speech(lm):
    return lm + select(["Noun", "Verb", "Adjective", "Adverb", ""])
lm = (
    falcon
    + "The child plays with a red ball. Ball in the previous sentence is a "
    + parts_of_speech()
)
print(lm)  # Noun

@guidance(stateless=True)
def pos_constraint(lm, sentence):
    words = sentence.split()
    for word in words:
        lm += word + ": " + parts_of_speech() + "\n"
    return lm

@guidance(stateless=True)
def pos_instruct(lm, sentence):
    lm += f"""
    Tag each word with their parts of speech.
    Example:
    Input: The child plays with a red ball.
    Output:
    The: 
    child: Noun
    plays: Verb
    with: 
    a: 
    red: Adjective
    ball.: Noun
    ---
    Input: {sentence}
    Output:
    """
    return lm

sentence = "Good software makes the complex appear to be simple"
lm = falcon + pos_instruct(sentence) + pos_constraint(sentence)

1 加载 Hugging Face Transformers 模型

2 创建函数以轻松实现语法

尽管我们使用的是一个小型语言模型,但我们得到了我们预期的确切输出。我们不再需要提示和祈祷。当然,在实际的语音预测部分中,结果并不那么出色,但我们可以通过使用更强大的 LLM 或使用更具代表性的数据进行微调来轻松改进这一点:

print(lm)

生成的文本是

# Input: Good software makes the complex appear to be simple
# Output:
# Good:
# software:
# makes: Verb
# the:
# complex: Adjective
# appear: Adjective
# to:
# be: Verb
# simple: Adjective

指引(Guidance)不如 LangChain 受欢迎,至少在撰写本文时,其文档仍有许多不足之处,因此您可能会发现入门有点困难。然而,它拥有一个充满活力的社区,以及一个强大的核心开发者团队,他们继续支持它。我们强烈推荐您尝试一下。

7.3.3 DSPy

与其他提到的工具不同,DSPy 并不提供创建你自己的提示的工具;相反,它试图编程提示。DSPy 来自斯坦福大学,并得到了许多企业赞助商的大力支持,它采取了一种独特的方法,强调工具增强,包括检索,如果你希望将 LLMs 作为确定性和程序性工具而不是涌现的无限语法生成器来处理,这将是有帮助的。

尽管这并不是确切发生的事情,但你可以将 DSPy 视为采用与 ONNX 保存模型类似的提示逻辑。给它一些虚拟输入,它将编译一个图,然后推断出最适合你模型的提示,并返回你想要的结果。不过,这需要做更多的工作。你需要编写验证逻辑和模块,本质上是一个工作流程和单元测试,以进行检查。这实际上将动态从提出巧妙的字符串转变为更接近于软件工程。诚然,它留下了这样的问题:“如果你无论如何都要程序化地定义一切,为什么还要使用 LLM?”尽管如此,我们在这方面有很好的经验,并且经常使用它。

使用 DSPy 的有效步骤如下:

  1. 创建一个任务或任务的签名或描述,包括输入和输出字段。

  2. 创建一个预测器或生成风格,类似于思维链或检索。

  3. 定义模块或程序。

完成这些步骤后,你将编译程序。这将根据之前给出的示例更新模块,类似于训练集。所有这些都会感觉像是 LLMs 的机器学习,有一个训练集(示例)、一个损失函数(验证指标)以及本质上是一个优化器(提词器)。

由于本章没有展示其他工具的列表,我们决定向您推荐一个由 StanfordNLP 团队创建的优秀笔记本,其中介绍了 DSPy 以及本地 LLMs 和自定义数据集:mng.bz/PNzg(它从这里分支出来:mng.bz/Ea4r)。一旦您有机会探索这个示例,我们还建议查看 DSPy 文档,因为它包含更多优秀的示例。

7.3.4 其他工具可用,但……

除了之前提到的工具之外,还有一大堆工具。值得注意的是 MiniChain 和 AutoChain。两者都旨在成为 LangChain 的轻量级替代品,这是非常需要的,因为许多人抱怨 LangChain 的臃肿。Promptify 是一个有趣的项目,它是 LangChain 的全功能替代品。说实话,我们可以列出更多,但可能没有太多意义。虽然许多项目在开始时吸引了充满活力的社区,但大多数项目已经沉寂数月,GitHub 的贡献也极为罕见。

很难确切地说为什么对这些项目的兴趣减弱了,但一个明显的理由是,这些项目中的大多数缺乏 LangChain、Guidance 和 DSPy 所拥有的赞助。许多这些项目是在 ChatGPT 成功的炒作中作为个人项目开始的,但炒作的能量永远不足以构建持久的软件。没有适当的支持,大多数开源项目都会失败。

我们可能描绘了一幅过于悲观的画面。然而,截至撰写本文时,情况仍然难以预料,这个领域仍在不断发展。我们推荐大家检查的有趣工具还有很多,但我们没有足够的空间来包括它们,比如 Haystack、Langflow 和 Llama Index。Outlines 作为与 Guidance 类似的项目,尤其值得关注,后者也非常出色。我们主要想指出的是,读者在选择这个领域的工具时应该小心,因为一切仍然都很新。如果你找到一个你喜欢的工具,请做出贡献。

7.4 高级提示工程技术

无论你的提示多么设计得巧妙,你的模型都无法访问到一些实际情境。例如,当前事件是一个挑战。模型本身只知道其训练日期之前的信息。当然,我们可以像我们迄今为止所做的那样,通过 RAG 将这个情境喂给模型,但这只是将负担转移到了保持我们的 RAG 系统更新。还有另一种方法。在本节中,我们将讨论如何让模型访问工具,以及我们一旦这样做后可以做什么。

7.4.1 为 LLM 提供工具

如果不是复杂的提示工程系统,而是让我们的模型访问互联网会怎样?如果它知道如何搜索互联网,它总能找到最新的信息。在此过程中,我们还可以让它访问计算器,这样我们就不必浪费 CPU 周期让 LLM 本身做基本的数学运算。我们可以让它访问时钟,这样它就知道当前时间,甚至可能是一个天气应用程序,这样它就可以告诉我们天气情况。天空才是极限!我们只需要训练模型如何使用工具,这就是 Toolformers 的作用所在。⁸

Toolformers 是一个奇妙简单的想法。让我们训练一个模型,使其知道它可以使用像 <API></API> 这样的标签来调用不同的工具的 API 调用。然后,在推理时,当我们看到这些标签时,我们可以告诉我们的解释器运行这些 API 调用。如果这听起来很熟悉,那是因为 Toolformers 只是训练了一个模型来使用字符串插值!字符串插值是评估包含占位符的字符串字面量的过程,这些占位符在运行时被替换为实际的值。例如,在 Python 中,我们可以取字符串字面量 print(f'2+2 = {2+2}'),一旦打印出来,我们会得到 '2+2 = 4'。占位符 {2+2} 被评估并执行为 Python 代码,返回 4。Schick 等人微调了一个 GPT-J 模型来使用五种不同的工具:问答数据库、计算器、维基百科搜索、翻译器和日历。通过访问这些工具,他们能够取得令人印象深刻的成果,在许多任务上超过了 GPT-3。

虽然 Schick 等人的工作开辟了道路,但这种方法的主要缺点是我们不希望每次创建一个新工具时都要微调一个模型。然而,正如我们在本章中讨论的,我们不必这样做。相反,我们可以使用巧妙的提示工程,通过 LangChain 或 Guidance 引入新工具。在下一个列表中,我们展示了如何使用 Guidance 创建简单的数学工具。Guidance 通过在识别到被调用的工具时停止生成、运行工具并再次开始生成来处理繁重的工作。

列表 7.4 使用 Guidance 给我们的 LLM 模型提供工具
import guidance
from guidance import models, gen

falcon = models.Transformers("tiiuae/falcon-rw-1b")     #1

@guidance
def add(lm, input1, input2):
    lm += f" = {int(input1) + int(input2)}"
    return lm

@guidance
def subtract(lm, input1, input2):
    lm += f" = {int(input1) - int(input2)}"
    return lm

@guidance
def multiply(lm, input1, input2):
    lm += f" = {float(input1) * float(input2)}"
    return lm

@guidance
def divide(lm, input1, input2):
    lm += f" = {float(input1) / float(input2)}"
    return lm

lm = (
    falcon
    + """\
1 + 2 = add(1, 2) = 3
4 - 5 = subtract(4, 5) = -1
5 * 6 = multiply(5, 6) = 30
7 / 8 = divide(7, 8) = 0.875
Generate more examples of add, subtract, multiply, and divide
"""
)
lm += gen(max_tokens=15, tools=[add, subtract, multiply, divide])
print(lm)

1 加载 Hugging Face Transformers 模型

虽然这是一个简单的例子,但很容易想象构建更高级的工具。无论您使用 LangChain 还是 Guidance,在构建工具时都有几点需要注意。首先,您需要在提示中指导您的模型在哪里以及如何使用您提供的工具。这可能会更难或更容易,这取决于您的函数有多开放。其次,模型的可扩展性也很重要。我们合作的一些模型永远不会使用我们给它们的工具,甚至可能会产生不存在的其他工具。最后,对于您提供给 LLM 的工具的输入和错误处理要非常小心。我们在这章中之前使用的是糟糕的,并且可能会以多种方式崩溃。例如,一个 LLM 可能会轻易尝试运行 add(one, two)add(1, 2, 3),这两者都会抛出错误并使系统崩溃。使用 Guidance,为了使这更容易,我们可以通过构建语法来强制工具输入,确保我们的模型输入始终正确。

这场讨论使我们发现了使用工具的 LLM(大型语言模型)的一些问题。首先,我们必须小心我们提供给 LLM 的工具,因为我们从未真正知道它将生成什么输入。即使我们确保工具不会出错,它也可能执行我们未意图的恶意行为。其次,正如您可能在本章中了解到的那样,提示工程迅速增加了我们的输入,从而减少了我们实际用户的 token 限制;解释工具及其使用方法增加了这一限制。通常,这种限制减少了我们可以提供给 LLM 的工具数量,从而降低了其有用性。第三,LLM 在使用工具方面仍然是不稳定的,它们往往最终会使用错误的工具。例如,LLM 应该使用网络搜索工具还是天气工具来查找 10 天的天气预报?这对我们人类来说可能不是很重要,但对于机器人来说,结果可能会有很大差异。最后,构建工具可能很困难且容易出错,因为您需要构建一个干净的工具和一个有效的提示。

OpenAI 的插件

Toolformers 开启了 OpenAI 插件概念的门户(mng.bz/q0rE). 插件允许第三方轻松地将他们的工具集成到 ChatGPT 中,并为 ChatGPT 调用外部 API 提供了一种简单的方式。插件在 ChatGPT 的生命周期中相对较早地被引入,在 Toolformers 论文发布后不久.^a 第三方只需创建一个 OpenAPI 配置文件和一个 ai-plugin.json 文件,并将两者托管在 API 存在的地方。OpenAPI 是一种用于 API 的规范语言,它标准化并定义了您的 API,使其易于他人消费。(如果您还没有听说过 OpenAPI 并且有客户使用的 API,遵循这一做法是个好习惯。您可以在www.openapis.org/了解更多信息。)有很多工具可以帮助您轻松生成该文件。ai-plugin 文件创建了插件。在这里,您可以定义插件的名称,如何进行身份验证,以及用于提示 ChatGPT 的描述。从这里,插件可以在 ChatGPT 的界面中注册给 OpenAI,经过审查过程后,您的插件可以被用户添加并使用,当他们与 ChatGPT 互动时。

尽管一开始热情很高,但插件从未离开 Beta 版本——除了 OpenAI 自己的网页浏览插件——并且看起来已经被放弃。这有很多原因,但根据后来被撤下的报告,主要原因来自 Sam Altman 的建议:“很多人认为他们想要他们的应用在 ChatGPT 内部,但他们真正想要的其实是 ChatGPT 在他们的应用中”(mng.bz/75Dg)。因此,似乎没有适合 OpenAI 插件的产品市场契合度,这会使公司赚钱。但我们认为,完全放弃这个想法还为时过早。

随着越来越多的公司将 LLM 技术集成到他们的应用程序中,他们很可能会想要访问第三方工具。假设你是第一次去露营,你向一个 LLM 购物助手询问购买建议。在这种情况下,如果它能首先询问你打算去哪里以及什么时候去露营,然后利用这些信息来识别适合天气的装备,那就太好了。特定品牌或商店的 LLM 购物助手可能能够访问大量产品,但访问随机地理位置的天气预报?不太可能。

虽然你可以始终构建这些工具,但如果它们已经为你准备好了,你只需去某个中心,下载你想要的工具,然后接入,那岂不是更好?不幸的是,这个选项目前还不存在,至少不是我们在这里描述的程度。我们期待未来会创建某种市场或中心,就像 OpenAI 的插件一样,可以与任何 LLM 模型一起使用。LLM 仍然是一项新技术,围绕它们构建的生态系统还在发展中;我们相信这将是其中之一。

^a T. Schick 等人,“Toolformer:语言模型可以教会自己使用工具”,2023 年 2 月。

一旦我们给我们的 LLM 提供工具访问权限,它就打开了众多酷炫提示工程技术的门户。可能最著名的就是 ReAct 方法。

7.4.2 ReAct

推理与行动(ReAct)是一个旨在模拟人们在学习新任务时推理和做决策的几步提示框架。⁹ 它涉及 LLM 的多步骤过程,其中提出一个问题,模型确定一个动作,然后观察并推理该动作的结果以确定后续动作。

一个例子可能看起来像这样:

CB 问题: 与同负载的欧洲燕子相比,未负载的非洲燕子的空速是多少?

chatGpt 想法 1: 我需要查找欧洲燕子的空速,以便与非洲燕子进行比较。

chatGpt 动作 1: 搜索[欧洲燕子空速]

chatGpt 观察 1: 我们需要知道斯特劳哈数来确定空速。鸟的斯特劳哈数在 0.2 到 0.4 之间收敛。

chatGpt 想法 2: 空速没有直接提及,但斯特劳哈数是明确的。所以我需要查找速度与斯特劳哈数的比较。

chatGpt 动作 2: 搜索[斯特劳哈拍打翼计算]

chatGpt 观察 2: (结果 1/1) 一只燕子的斯特劳哈数相当于每秒约 12 次拍打翅膀。

chatGpt 想法 3: 非洲燕子需要每秒 12 次拍打翅膀来维持空速,所以我需要查找这会使它飞得多快。

chatGpt 行动 3:查找[非洲燕子每秒振翅次数]

chatGpt 观察 3:每次振翅推动非洲燕子 2.5 英尺,使其速度约为 40.2 英里/小时

chatGpt 思考 4:我需要查找欧洲燕子的信息来比较。

chatGpt 行动 4:搜索[欧洲燕子每秒振翅次数]

chatGpt 观察 4:欧洲燕子大约是非洲燕子大小的一半,其速度约为 20.1

chatGpt 思考 5:非洲燕子的空速速度为 40.2,而欧洲燕子的空速速度为 20.1,这使得比较是 2 倍。

chatGpt 行动 5:完成[两倍于空速速度]

正如你所见,ReAct 的目的在于迫使模型在行动之前进行思考。这与我们之前讨论的其他提示方法并没有太大的不同。最大的区别在于我们允许模型采取行动。在我们的例子中,这包括一个“搜索”行动,或者说本质上是一种像人类一样在互联网上查找信息的能力。我们已经在上一节中展示了如何做到这一点。模型可以接受新信息,并观察从其行动中学到的东西以产生结果。

让我们通过一个例子进一步探讨这个问题。我们将使用 LangChain,这将使创建 ReAct 代理比实际要容易得多。列表 7.5 展示了如何在 OpenAI 模型和 LangChain 上利用 ReAct。对于我们的搜索引擎,我们将使用 serper.dev,因为它与 LangChain 很好地集成,并提供一个你可以注册的免费层。我们还需要使用计算器"llm-math",这是 LangChain 工具包中的许多工具之一。

列表 7.5 示例:ReAct 与 Langchain
 import os
from langchain.llms import OpenAI
from langchain.agents import load_tools
from langchain.agents import initialize_agent
from dotenv import load_dotenv

load_dotenv()

os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")     #1
os.environ["SERPER_API_KEY"] = os.getenv("SERPER_API_KEY")

llm = OpenAI(model_name="text-davinci-003", temperature=0)
tools = load_tools(["google-serper", "llm-math"], llm=llm)
agent = initialize_agent(
    tools, llm, agent="zero-shot-react-description", verbose=True
)

agent.run(
    "Who is Olivia Wilde's boyfriend? \
    What is his current age raised to the 0.23 power?"
)

1 加载 API 密钥;如果你还没有获取这些,你需要获取

输出结果

# > Entering new AgentExecutor chain...
# I need to find out who Olivia Wilde's boyfriend is and then
# calculate his age raised to the 0.23 power.
# Action: Search
# Action Input: "Olivia Wilde boyfriend"
# Observation: Olivia Wilde started dating Harry Styles after ending
# her years-long engagement to Jason Sudeikis — see their relationship
# timeline.
# Thought: I need to find out Harry Styles' age.
# Action: Search
# Action Input: "Harry Styles age"
# Observation: 29 years
# Thought: I need to calculate 29 raised to the 0.23 power.
# Action: Calculator
# Action Input: 29⁰.23
# Observation: Answer: 2.169459462491557

# Thought: I now know the final answer.
# Final Answer: Harry Styles, Olivia Wilde's boyfriend, is 29 years old
# and his age raised to the 0.23 power is 2.169459462491557.

# > Finished chain.

# "Harry Styles, Olivia Wilde's boyfriend, is 29 years old and his age
# raised to the 0.23 power is 2.169459462491557."

列表 7.5 展示了如何结合特定的代理工具如"google-serper""llm-math"使用 ReAct 与 LLM 来帮助增强你的提示。现在提示工程看起来更像是一项全职工作,而不仅仅是“想出词语”,对吧?

了解如何构建工具并将它们组合起来以提示 LLM 回答更深入的问题,已经成为一个不断发展的研究领域以及就业市场的扩展部分。坦白说,提示工程领域的变革速度似乎大大超过了本书中讨论的其他大多数主题。还有许多内容需要讨论,但我们无法在本书中涵盖所有内容,事实上,现在已经有专门为此目的而写的整本书。很难确定什么对读者有价值,什么会很快过时,但我们认为我们已经找到了一个良好的平衡,并鼓励你期待在更多关于这个主题的研究。

总体而言,我们在本章中学到了很多——如何制定提示以及如何以工程化的方式实现提示。在下一章中,我们将把所有这些知识用于构建用户可以与之交互的 LLM 应用程序。

摘要

  • 提示的最直接方法是为模型提供你希望它执行的操作的示例:

    • 你可以向提示中添加的示例越多,你的结果就越准确。

    • 需要添加的示例越少,你的提示就越通用和多功能。

  • 提示的四个部分是

    • 输入 — 用户所写的内容

    • 指令 — 编码了特定任务信息的模板

    • 上下文 — 通过 RAG 或其他数据库查找添加的信息

    • 系统 — 每个任务的具体指令;应隐藏给用户

  • 了解你的训练数据将帮助你通过选择与训练数据匹配的词序来制定更好的提示。

  • LangChain 是一个流行的工具,它允许我们创建链或管道,以工程化的方式利用 LLM。

  • 指导是一个强大的工具,它让我们对 LLM 实际生成的文本有更精细的控制。

  • Toolformers 教授语言模型如何使用工具,赋予它们完成以前不可能的任务的能力。

  • ReAct 是一个用于提示的少样本框架,旨在模拟人们在学习新任务时推理和做决定的方式。

[1] J. Wei 等人, “思维链提示引发大型语言模型中的推理”, 2022 年 1 月, arxiv.org/abs/2201.11903.

[2] T. Kojima, S. S. Gu, M. Reid, Y. Matsuo, 和 Y. Iwasawa, “大型语言模型是零样本推理者”, 2022 年 5 月, arxiv.org/abs/2205.11916.

[3] Y. Zhou 等人, “解开混沌情境的思路线索”, 2023 年 11 月 15 日, arxiv.org/abs/2311.08734.

[4] Sondos Mahmoud Bsharat, Aidar Myrzakhan, 和 Z. Shen, “对于 LLaMA-1/2, GPT-3.5/4 的提问,原则性指令就足够了”, 2023 年 12 月, doi.org/10.48550/arxiv.2312.16171.

[5] C. Yang 等人, “大型语言模型作为优化器”, 2023 年 9 月 6 日, arxiv.org/abs/2309.03409.

[6] N. Ruiz, Y. Li, V. Jampani, Y. Pritch, M. Rubinstein, and K. Aberman, “DreamBooth: Fine tuning text-to-image diffusion models for subject-driven generation,” August 2022, arxiv.org/abs/2208.12242

[7] Ruiz 等人, “DreamBooth。”

[8] T. Schick 等人, “Toolformer: 语言模型可以教会自己使用工具”, 2023 年 2 月, arxiv.org/abs/2302.04761.

[9] S. Yao 等人,“ReAct: 在语言模型中协同推理和行动,”2023 年 3 月 10 日,arxiv.org/abs/2210.03629.

第八章:大型语言模型应用程序:构建交互式体验

本章涵盖

  • 构建使用 LLM 服务的交互式应用程序

  • 在边缘设备上运行没有 GPU 的 LLMs

  • 构建能够解决多步问题的 LLM 代理

除非他们知道你有多在乎,否则没有人会在乎你懂得多少。——美国总统西奥多·罗斯福

在整本书中,我们向您介绍了 LLMs 的方方面面——如何训练它们,如何部署它们,以及在前一章中,如何构建一个提示来引导模型按照您期望的方式行为。在本章中,我们将把这些内容综合起来。我们将向您展示如何构建一个应用程序,该应用程序可以使用您部署的 LLM 服务,并为实际用户创造愉快的体验。关键在于“愉快”。创建一个简单的应用程序很容易,正如我们将展示的那样,但创建一个令人愉快的应用程序?嗯,这要困难一些。我们将讨论您希望添加到应用程序中的多个功能及其原因。然后,我们将讨论应用程序可能存在的不同位置,包括为边缘设备构建此类应用程序。最后,我们将深入 LLM 代理的世界,构建能够履行角色而非仅仅满足请求的应用程序。

8.1 构建应用程序

我们最好先解释一下我们所说的 LLM 应用程序是什么意思。毕竟,“应用程序”是一个无处不在的术语,可能意味着很多不同的事情。对我们来说,在这本书中,当我们说“LLM 应用程序”时,我们指的是前端——Web 应用程序、手机应用程序、CLI、SDK、VSCode 扩展(请参阅第十章!)或任何其他将作为用户界面和客户端来调用我们的 LLM 服务的应用程序。图 8.1 分别显示了前端和后端,以帮助我们专注于我们正在讨论的拼图的一部分:前端。这是拼图中的一个非常重要的部分,但变化也相当大!虽然每个环境都会带来自己的挑战,但我们希望您能了解您特定用例的细节。例如,如果您正在构建 Android 应用程序,学习 Java 或 Kotlin 的责任就落在您身上。然而,在这本书中,我们将为您提供所需的构建块,并介绍需要添加的重要功能。

figure

图 8.1 LLM 应用程序是 Web 应用程序、手机应用程序、命令行界面或其他作为客户端的工具,我们的用户将使用它来与我们的 LLM 服务进行交互。

构建成功的 LLM 应用程序的第一步是编写和实验您的提示。当然,正如我们在上一章中讨论的那样,您应该考虑许多其他功能以提供更好的用户体验。最基本的 LLM 应用程序只是一个聊天框,它本质上只包含三个对象:一个输入字段、一个发送按钮和一个用于保存对话的文本字段。在几乎每个环境中构建它都相当容易。此外,由于我们的聊天参与者之一是一个机器人,构建聊天界面的大部分复杂性也被消除了。例如,我们不需要担心最终一致性、混淆对话顺序或是否两个用户同时发送消息。如果我们的用户网络连接不良,我们可以抛出一个超时错误,并让他们重新提交。

然而,尽管界面简单,但并非所有的细节都如此。在本节中,我们将与您分享一些行业工具,以使您的 LLM 应用程序更加出色。我们关注最佳实践,如流式响应、利用聊天历史以及处理和利用提示工程的方法。这些使我们能够在幕后构建、格式化和清理用户的提示和 LLM 的响应,从而提高结果和整体客户满意度。换句话说,构建一个利用 LLM 的基本应用程序实际上相当简单,但构建一个出色的应用程序则是另一回事,我们希望构建出色的应用程序。

8.1.1 前端流式传输

在第六章中,我们向您展示了如何在服务器端流式传输 LLM 的响应,但如果客户端没有流式传输响应,那么这就没有意义。客户端的流式传输是所有这一切汇聚的地方。这是我们将文本展示给用户的地方,同时文本正在生成。这提供了一个吸引人的用户体验,因为它让文本看起来就像就在我们眼前被输入,并给用户一种模型实际上在思考接下来要写什么的感觉。不仅如此,它还提供了一个更有弹性和响应性的体验,因为我们能够提供即时反馈的感觉,这鼓励我们的用户留下来直到模型完成生成。这也帮助用户在输出变得太远之前看到输出在哪里,这样他们就可以停止生成并重新提示。

在列表 8.1 中,我们向您展示了如何仅使用 HTML、CSS 和纯 JavaScript 来完成这项操作。这个应用程序旨在非常简单。我们的大部分读者可能并不擅长前端开发,因为这不是本书的重点。那些最有可能的人可能也会使用他们选择的框架的一些工具。但是,一个没有花哨功能的基本应用程序使我们能够深入理解正在发生的事情的核心。

由于应用如此简单,我们选择将所有 CSS 和 JavaScript 代码一起放入 HTML 中,尽管将它们分开会更整洁,也更符合最佳实践。CSS 定义了尺寸,以确保我们的盒子足够大以便阅读;我们不会费心去处理颜色或让它看起来漂亮。我们的 HTML 尽可能简单:一个包含文本输入和发送按钮的表单,该按钮在提交时返回 false,以防止页面刷新。还有一个div容器来包含我们的聊天消息。大部分 JavaScript 也不那么有趣;它只是处理将我们的对话添加到聊天中。然而,请注意sendToServer函数,它做了大部分繁重的工作:发送我们的提示,接收可读流,并遍历结果。

NOTE  在服务器端,我们设置了一个StreamingResponse对象,它在 JavaScript 端被转换为ReadableStream。你可以在这里了解更多关于可读流的信息:mng.bz/75Dg

列表 8.1 向最终用户流式传输响应
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Simple Chat App</title>

        <style>         #1
            body {
                font-family: Arial, sans-serif;
                margin: 0;
                padding: 0;
                box-sizing: border-box;
            }

            #message-input {
                width: 95%;
                padding: 8px;
            }

            #chat-container {
                width: 95%;
                margin: 20px auto;
                border: 1px solid #ccc;
                padding: 10px;
                overflow-y: scroll;
                max-height: 300px;
            }
        </style>
    </head>

    <body>                                 #2
        <form onsubmit="return false;"">
            <input type="text" id="message-input" placeholder="Type your message...">
            <button onclick="sendMessage()" type="submit">Send</button>
        </form>
        <div id="chat-container"></div>
    </body>

    <script>                           #3
        function sendMessage() {                      #4
            var messageInput = document.getElementById('message-input');
            var message = messageInput.value.trim();

            if (message !== '') {
                appendMessage('You: ' + message);
                messageInput.value = '';
                sendToServer(message);
            }
        }

        function appendMessage(message) {            #5
            var chatContainer = document.getElementById('chat-container');
            var messageElement = document.createElement('div');
            messageElement.textContent = message;
            chatContainer.appendChild(messageElement);
            chatContainer.scrollTop = chatContainer.scrollHeight;
            return messageElement
        }

        async function sendToServer(message) {       #6
            var payload = {
                prompt: message
            }

            const response = await fetch('http://localhost:8000/generate', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(payload),
            });

            var responseText = 'LLM: ';
            messageElement = appendMessage(responseText);

            for await (const chunk of streamAsyncIterator(response.body)) {
                var strChunk = String.fromCharCode.apply(null, chunk);
                responseText += strChunk;
                messageElement.textContent = responseText;
            }
        }

        async function* streamAsyncIterator(stream) {      #7
            const reader = stream.getReader();
            try {
                while (true) {
                    const {done, value} = await reader.read();
                    if (done) return;
                    yield value;
                }
            }
            finally {
                reader.releaseLock();
            }
        }
    </script>
</html>

1 一些非常简单的样式

2 我们的身体很简单,只有三个字段:文本输入、发送按钮和聊天容器

3 用于处理与 LLM 和流式响应通信的 JavaScript

4 当发送按钮被按下时,将文本从输入框移动到聊天框,并将消息发送到 LLM 服务器

5 向聊天框添加新消息

6 将提示发送到服务器,并在接收到令牌时流式传输响应

7 简单的 polyfill,因为 StreamResponse 仍然不能被大多数浏览器用作迭代器

图 8.2 显示了列表 8.1 中的简单应用的截图。在电影或 GIF 中显示正在流式传输到应用中的单词会更好,但由于书籍不能播放 GIF,我们只能将几个并排的截图凑合一下。无论如何,该图显示了结果按令牌逐个流式传输给用户,提供了积极的用户体验。

图片

图 8.2 显示我们的简单应用截图,显示正在流式传输的响应

我们这个小应用没有什么华丽之处,这也有部分原因。这段代码很容易复制粘贴,并且可以在任何可以运行 Web 浏览器的位置使用,因为它只是一个 HTML 文件。一旦你有一个 LLM 服务运行,构建一个快速演示应用并不需要太多。

8.1.2 保持历史记录

我们简单应用的一个大问题是,发送到我们的 LLM 的每条消息都是独立的,与其他消息无关。这是一个大问题,因为大多数利用 LLM 的应用都是在交互式环境中使用的。用户会提出一个问题,然后根据响应提出更多问题或调整和澄清,以获得更好的结果。然而,如果你只是将最新的查询作为提示发送,LLM 将不会有任何关于新查询的上下文。对于抛硬币来说,独立性是好的,但它会使我们的 LLM 看起来像一只笨鸟。

我们需要做的是保留对话的历史记录,包括用户的提示和 LLM 的响应。如果我们这样做,我们就可以将这段历史记录附加到新的提示中作为上下文。LLM 将能够利用这些背景信息来做出更好的响应。图 8.3 显示了我们所尝试实现的总体流程。

图

图 8.3 存储提示和响应到聊天历史的流程,为我们的模型提供对话记忆以改善结果

现在我们知道了我们要构建什么,让我们看看列表 8.2。这次,我们将使用 Streamlit,这是一个用于构建应用的 Python 框架。它简单易用,同时还能创建吸引人的前端。从 Streamlit,我们将利用chat_input字段,让用户可以编写并发送他们的输入,chat_message字段将保存对话,以及session_state,我们将在这里创建和存储chat_history。我们将使用这段聊天历史来构建更好的提示。你也会注意到我们继续流式传输响应,正如上一节所展示的,但这次使用 Python。

什么是 Streamlit?

Streamlit 是一个开源的 Python 库,它使得创建机器学习、数据科学和其他领域的 Web 应用变得容易。它允许你使用简单的 Python 脚本快速构建交互式 Web 应用。使用 Streamlit,你可以创建仪表板、数据可视化和其他交互式工具,而无需了解 HTML、CSS 或 JavaScript 等 Web 开发语言。Streamlit 自动处理将你的 Python 代码转换为 Web 应用。

列表 8.2 使用聊天历史来改善结果的一个示例应用
import streamlit as st
import requests
import json

url = "http://localhost:8000/generate"    #1

st.title("Chatbot with History")

if "chat_history" not in st.session_state:      #2
    st.session_state.chat_history = []

for chat in st.session_state.chat_history:      #3
    with st.chat_message(chat["role"]):
        st.markdown(chat["content"])

if user_input := st.chat_input("Your question here"):     #4
    with st.chat_message("user"):                   #5
        st.markdown(user_input)

    st.session_state.chat_history.append(          #6
        {"role": "user", "content": user_input}
    )

    with st.chat_message("assistant"):     #7
        placeholder = st.empty()
        full_response = ""

        prompt = "You are an assistant who helps the user. "     #8
        "Answer their questions as accurately as possible. Be concise. "
        history = [
            f'{ch["role"]}: {ch["content"]}'
            for ch in st.session_state.chat_history
        ]
        prompt += " ".join(history)
        prompt += " assistant: "
        data = json.dumps({"prompt": prompt})

        with requests.post(url, data=data, stream=True) as r:     #9
            for line in r.iter_lines(decode_unicode=True):
                full_response += line.decode("utf-8")
                placeholder.markdown(full_response + "▌")     #10
        placeholder.markdown(full_response)

    st.session_state.chat_history.append(      #11
        {"role": "assistant", "content": full_response}
    )

1 指向你的模型 API

2 在会话状态中创建聊天历史

3 Δ显示历史聊天

4 对用户做出响应。注意:我们使用 walrus 操作符(:=)在同时确保用户输入不为 None 的情况下分配用户输入。

5 Δ显示用户输入

6 将用户输入添加到聊天历史

7 流响应

8 格式化提示,添加聊天历史以提供额外上下文

9 发送请求

10 添加闪烁的光标以模拟输入

11 将 LLM 响应添加到聊天历史

图 8.4 是捕获我们刚刚构建的 LLM 应用的屏幕截图。虽然我们的第一个例子相当丑陋,但你可以看到 Streamlit 自动创建了一个很好的用户界面,包括完成细节,如用户的人脸图片和我们的 LLM 助手的机器人脸。你也会注意到模型正在接收并理解对话历史——尽管给出的响应很糟糕。如果我们想要得到更好的响应,有一件事要确保的是,你的 LLM 已经在对话数据上进行了训练。

图

图 8.4 我们的 Streamlit 应用利用聊天历史的屏幕截图

当然,利用历史记录会导致一些问题。第一个问题是用户可以与我们的机器人进行相对较长的对话,但我们仍然受限于可以输入到模型中的令牌长度,输入越长,生成所需的时间就越长。在某个时候,历史记录会变得过长。解决这个问题的最简单方法是用较新的消息替换较旧的消息。当然,我们的模型可能会在对话开始时忘记重要的细节或指令,但人类在对话中也倾向于有近期偏差,所以这通常是可以接受的——当然,除了人类倾向于期望计算机永远不会忘记任何事情这一点。

一个更稳健的解决方案是使用 LLM 来总结聊天历史,并将总结作为我们用户查询的上下文,而不是使用完整的聊天历史。LLM 通常非常擅长从大量文本中突出显示重要的信息片段,因此这可以是一种有效地压缩对话的方法。压缩可以按需进行,也可以作为后台进程运行。图 8.5 展示了聊天历史压缩的总结工作流程。

figure

图 8.5 使用总结进行聊天历史压缩的应用程序流程图

你还可以探索其他策略,以及混合和匹配多种方法。另一个想法是将每个聊天嵌入,并执行搜索以查找相关的先前聊天消息以添加到提示上下文中。但无论你选择如何缩短聊天历史,随着对话的持续进行或提示和响应的增大,细节很可能会丢失或被遗忘。

8.1.3 聊天机器人交互功能

与 LLM 聊天机器人聊天并不像与你的朋友聊天。一方面,聊天机器人总是随时待命,等待我们与之交谈,因此我们可以期待立即得到回应。用户在收到反馈之前不应该有机会向我们的机器人发送多条消息进行垃圾邮件式攻击。但让我们面对现实,在现实世界中,可能会有连接问题或网络状况不佳,服务器可能会过载,以及许多可能导致请求失败的其他原因。这些差异促使我们以不同的方式与聊天机器人互动,并且我们应该确保为我们的用户提供几个功能来改善他们的体验。现在让我们考虑其中的一些:

  • 回退响应——当发生错误时提供的响应。为了保持整洁,你将希望确保每个用户查询在聊天历史中的 LLM 响应比例为 1:1。回退响应确保我们的聊天历史整洁,并为用户提供最佳行动方案的说明,例如过几分钟再尝试。说到这一点,你还应该考虑在收到响应时禁用提交按钮,以防止异步对话和聊天历史顺序混乱导致的奇怪问题。

  • 停止按钮 — 在响应过程中中断。大型语言模型(LLM)往往很健谈,在回答用户问题后还会继续回应。它经常误解问题并开始错误地回答。在这些情况下,最好给用户提供一个停止按钮,以便他们可以中断模型并继续操作。这个按钮是一个简单的成本节约功能,因为我们通常以某种方式按输出令牌付费。

  • 重试按钮 — 重新发送最后一个查询并替换响应。LLM 有一定的随机性,这对于创意写作来说可能很好,但这也意味着它们可能对之前已经正确响应多次的提示做出不利的回应。由于我们将 LLM 聊天历史添加到新的提示中以提供上下文,因此重试按钮允许用户尝试获得更好的结果,并保持对话朝着正确的方向进行。在重试时,调整我们的提示超参数可能是有意义的,例如,每次用户重试时都降低温度。这可以帮助将响应推向用户可能期望的方向。当然,如果他们因为糟糕的网络连接而重试,这可能不是最好的选择,因此您需要仔细考虑调整。

  • 删除按钮 — 删除聊天历史的一部分。如前所述,聊天历史被用作未来响应的上下文,但并非每个响应都能立即被识别为不良。我们经常看到误导性的信息。例如,在编码时使用的聊天助手可能会幻想不存在的功能或方法,这可能导致对话走向难以恢复的路径。当然,根据您的需求,解决方案可能是一个软删除,我们只从前端和提示空间中删除它,但不从后端删除。

  • 反馈表单 — 收集用户体验反馈的一种方式。如果您正在训练或微调自己的 LLM,这些数据非常有价值,因为它可以帮助您的团队在下一个训练迭代中改进结果。这些数据在使用强化学习与人类反馈(RLHF)时通常可以很容易地应用。当然,您不会想直接应用它,但首先需要清理和过滤掉恶意的回复。即使您没有在训练,它也可以帮助您的团队做出切换模型、改进提示和识别边缘情况的决策。

在列表 8.3 中,我们展示了如何使用 Gradio 设置简单的聊天机器人应用程序。Gradio 是一个开源库,用于快速创建数据科学演示和 Web 应用程序的可定制用户界面。它因其易于与 Jupyter 笔记本集成而非常受欢迎,这使得在熟悉的环境中创建界面和编辑 Web 应用程序变得容易。要使用 Gradio 创建聊天机器人,我们将使用ChatInterface并给它一个函数来执行我们的 API 请求。您会注意到 Gradio 期望历史记录是generate函数的一部分,而流式传输只是确保函数是一个生成器的问题。

什么是 Gradio?

Gradio 是一个开源的 Python 库,允许您快速创建围绕您的机器学习模型的定制 UI 组件。它提供了一个简单的界面来构建您的模型的无 HTML、CSS 或 JavaScript 代码的交互式网络应用程序。使用 Gradio,您可以创建用于模型的输入表单,显示结果,甚至可以通过网络界面与他人共享您的模型。

列表 8.3 本地 LLM Gradio 聊天应用,具有停止、重试和撤销功能
import gradio as gr
import requests
import json

url = "http://localhost:8000/generate"      #1

def generate(message, history):
    history_transformer_format = history + [[message, ""]]
    messages = "".join(
        [
            "".join(["\n<human>:" + h, "\n<bot>:" + b])
            for h, b in history_transformer_format
        ]
    )
    data = json.dumps({"prompt": messages})

    full_response = ""
    with requests.post(url, data=data, stream=True) as r:   #2
        for line in r.iter_lines(decode_unicode=True):
            full_response += line.decode("utf-8")
            yield full_response + "▌"              #3
        yield full_response

gr.ChatInterface(generate, theme="soft").queue().launch()

1 指向您的模型 API

2 发送请求

3 添加闪烁的光标以模拟输入

您可以看到这段代码是多么简单,需要的行数非常少。Gradio 为我们做了所有繁重的工作。您可能还在想我们的交互功能在哪里。好消息是,Gradio 会自动为我们添加大部分这些功能。您不相信吗?请查看图 8.6 中我们刚刚创建的应用程序。

图

图 8.6 我们 Gradio 应用的截图,包括交互功能停止、重试和撤销,以提供更好的使用便捷性
Chainlit:专为 LLM 设计的应用程序构建器

我们已经向您展示了如何使用几种不同的工具构建 LLM 应用程序:Streamlit、Gradio,甚至是纯 HTML 和 JavaScript。有许多优秀的工具,我们无法对每一个都给予个人关注。但我们认为,许多读者可能会对我们接下来要介绍的工具 Chainlit 感兴趣。Chainlit 是一个专门为构建 LLM 应用程序而构建的工具,它自带大多数功能,包括这里未讨论的功能,如主题、CSS 定制、身份验证和云托管。它可能是快速启动和运行的最快方式之一。

您可以为您的应用程序添加的每一项生活品质改进都将帮助它在竞争中脱颖而出,并可能为您节省金钱。出于同样的原因,您应该考虑使用标记计数器,我们将在下一节中介绍。

8.1.4 标记计数

您可以收集的最基本但非常有价值的信息之一是提交的标记数。由于 LLM 有标记限制,我们需要确保用户的提示不会超过这些限制。及时且频繁地提供反馈将提供更好的用户体验。没有人愿意输入一个长的查询,然后发现提交后太长了。

计算标记数也使我们能够更好地提示工程和改进结果。例如,在一个问答机器人中,如果用户的提问特别短,我们可以通过扩展我们的检索增强生成(RAG)系统将返回的搜索结果数量来添加更多上下文。如果他们的提问很长,我们则希望限制它,并确保我们有足够的空间来附加我们的上下文。

Tiktoken 就是这样的一个库,用于帮助完成这项任务。它是一个专为 OpenAI 模型构建的极快 BPE 分词器。该包已移植到多种语言,包括用于 Golang 的 tiktoken-go、用于 Rust 的 tiktoken-rs 以及几个其他语言。在下一节中,我们将展示如何使用它的基本示例。它已经针对速度进行了优化,这使得我们能够快速编码和计数 token,这正是我们所需要的。

列表 8.4 使用 tiktoken 计数 token
import tiktoken

encoding = tiktoken.get_encoding("cl100k_base")
print(encoding.encode("You're users chat message goes here."))     
# [2675, 2351, 3932, 6369, 1984, 5900, 1618, 13]
def count_tokens(string: str) -> int:
    encoding = tiktoken.get_encoding("cl100k_base")
    return len(encoding.encode(string))

num_tokens = count_tokens("You're users chat message goes here.")
print(num_tokens)       
# 8

当然,那些没有跳过前面的读者会认识到使用 tiktoken 存在一些问题,主要是因为它是针对 OpenAI 的编码器构建的。如果你使用自己的分词器(我们推荐这样做),它将不会非常准确。我们见过一些开发者——出于懒惰或不知道更好的解决方案——仍然将其用于其他模型。通常,当使用 tiktoken 的结果为使用类似 BPE 分词器的其他模型计数时,他们看到每 1,000 个 token 内的计数在±5–10 个 token 之间。对他们来说,速度和延迟的增益足以弥补不准确,但这都是口头相传,所以请带着怀疑的态度看待。

如果你使用的是不同类型的分词器,如 SentencePiece,通常最好是创建自己的 token 计数器。例如,我们在第十章的项目中就是这样做的。正如你可以猜到的,代码遵循了相同的模式,即编码字符串和计数 token。难点在于将其移植到需要运行计数器的语言。为此,就像我们第 6.1.1 节讨论的那样,将分词器编译成任何其他 ML 模型。

8.1.5 RAG 应用

RAG 是向你的 LLM 添加上下文和外部知识以改进结果准确性的绝佳方式。在上一个章节中,我们讨论了它在后端系统中的应用。在这里,我们将从前端的角度来讨论它。你的 RAG 系统可以在任何一边设置,每边都有其自身的优缺点。

在后端设置 RAG 确保了所有用户都能获得一致的经验,并让我们作为开发者对上下文数据如何使用有更大的控制权。它还为存储在向量数据库中的数据提供了一定的安全性,因为只有通过 LLM 才能被最终用户访问。当然,通过巧妙的提示注入,它仍然可能被爬取,但与简单地允许用户直接查询你的数据相比,它仍然要安全得多。

RAG 更常在前端设置,因为这样做允许开发者将任何通用的 LLM 插入业务上下文。如果你在运行时给模型提供你的数据集,你不需要在数据集上微调模型。因此,RAG 成为了一个为我们的 LLM 应用添加个性和功能,而不仅仅是确保结果准确性和减少幻觉的工具系统。

在 6.1.8 节中,我们向您展示了如何设置一个 RAG 系统;现在我们将向您展示如何利用它进行高效的查询增强。在列表 8.5 中,我们向您展示如何访问和使用我们之前设置的向量存储。我们将继续使用 OpenAI 和 Pinecone,这是我们上一个例子中的内容。我们还将使用 LangChain,这是一个 Python 框架,我们在上一章中发现了它,以帮助创建 LLM 应用。

列表 8.5 前端 RAG
import os
import pinecone
from langchain.chains import RetrievalQA
from langchain.chains import RetrievalQAWithSourcesChain
from langchain.chat_models import ChatOpenAI
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import Pinecone

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")           #1
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")      #2

index_name = "pincecone-llm-example"      #3
index = pinecone.Index(index_name)
embedder = OpenAIEmbeddings(
    model="text-embedding-ada-002", openai_api_key=OPENAI_API_KEY
)
text_field = "text"
vectorstore = Pinecone(index, embedder.embed_query, text_field)

query = "Who was Johannes Gutenberg?"      #4
vectorstore.similarity_search(
    query, k=3                  #5
)

llm = ChatOpenAI(               #6
    openai_api_key=OPENAI_API_KEY,
    model_name="gpt-3.5-turbo",
    temperature=0.0,
)

qa = RetrievalQA.from_chain_type(         #7
    llm=llm, chain_type="stuff", retriever=vectorstore.as_retriever()
)
qa.run(query)
qa_with_sources = RetrievalQAWithSourcesChain.from_chain_type(     #8
    llm=llm, chain_type="stuff", retriever=vectorstore.as_retriever()
)
qa_with_sources(query)

1 从platform.openai.com获取 OpenAI API 密钥

2 在 app.pinecone.io 控制台中找到 API 密钥

3 设置向量存储

4 进行查询

5 我们的搜索查询;返回三个最相关的文档

6 现在,让我们使用这些结果来丰富我们的 LLM 提示;设置 LLM

7 使用向量存储运行查询

8 包含维基百科来源

我们认为这段代码最令人印象深刻的部分是 LangChain 有一个简单地命名为“stuff”的链,因为,据推测,他们想不出更好的名字。(如果您想了解更多关于这个神秘命名的模块“stuff”的信息,您可以在mng.bz/OBER找到文档。)但实际上,这段代码最令人印象深刻的是,我们只需要定义我们的 LLM 和向量存储连接,然后我们就可以开始进行查询了。简单。

8.2 边缘应用

到目前为止,我们讨论了构建 LLM 应用,假设我们只是简单地使用一个 API——我们部署的 API,但毕竟是一个 API。然而,有许多情况您可能希望在应用程序内部本地设备上运行模型。这样做会带来几个挑战:主要,我们需要得到一个足够小的模型以便在边缘设备上传输和运行。我们还需要能够在本地环境中运行它,这很可能没有加速器或 GPU,甚至可能不支持 Python——例如,在用户的 Web 浏览器中用 JavaScript 运行应用程序,在手机上的 Android 应用程序中使用 Java,或者在像树莓派这样的有限硬件上。

在第六章中,我们开始讨论您需要与边缘设备一起工作的构建块。我们向您展示了如何编译模型,给出了使用 TensorRT 或 ONNX Runtime 的示例。TensorRT 来自 NVIDIA,如果您有一个配备昂贵 NVIDIA 硬件的服务器,它将为您服务得更好,因此对于边缘开发来说不太有用。ONNX Runtime 更加灵活,但在与边缘设备一起工作时,llama.cpp 通常是 LLM 的一个更好的解决方案,并且遵循相同的流程:将模型编译成正确的格式,将该模型移动到边缘设备,下载并安装您语言的 SDK,然后运行模型。让我们更详细地看看这些步骤对于 llama.cpp。

llama.cpp 项目始于将一个大型语言模型(LLM)转换为可以在没有 GPU 的 MacBook 上运行的目标,因为苹果硅芯片在许多项目中以兼容性差而闻名。最初,该项目致力于量化 LLaMA 模型并将其存储在 C++ 语言可以使用的二进制格式中,但随着项目的发展,它已经支持了数十种 LLM 架构和所有主要操作系统平台,并为十几种语言以及 CUDA、metal 和 OpenCL GPU 后端提供了支持。Llama.cpp 创建了两种不同的格式来存储量化后的 LLM:第一种是 GPT-Generated Model Language(GGML),后来因为更好的 GPT-Generated Unified Format(GGUF)而被放弃。

要使用 llama.cpp,我们首先需要的是一个存储在 GGUF 格式的模型。要将您自己的模型转换,您需要克隆 llama.cpp 项目,安装依赖项,然后运行项目附带的可转换脚本。这些步骤已经频繁更改,您可能需要查阅仓库中的最新信息,但目前看起来是这样的

$ git clone https://github.com/ggerganov/llama.cpp.git
$ cd llama.cpp
$ pip install -r requirments/requirements-convert.txt
$ python convert.py -h

当然,最后一个命令只是简单地显示转换脚本的帮助菜单,供您调查选项,实际上并没有转换模型。对于我们来说,我们将下载一个已经转换好的模型。我们在第六章中简要提到了汤姆·乔宾斯(TheBloke),这位将数千个模型进行量化并微调,使它们处于可以使用状态的男子。您只需从 Hugging Face Hub 下载它们即可。所以我们现在就做。首先,我们需要 huggingface-cli,它通常作为 Hugging Face 大多数 Python 包的依赖项提供,所以您可能已经有了它,但您也可以直接安装它。然后我们将使用它来下载模型:

$ pip install -U huggingface_hub
$ huggingface-cli download TheBloke/WizardCoder-Python-7B-V1.0-GGUF --
↪ local-dir ./models --local-dir-use-symlinks False --include='*Q2_K*gguf'

在这里,我们正在下载已经被 TheBloke 转换为 GGUF 格式的 WizardCoder-7B 模型。我们将将其保存在本地 models 目录中。我们不会使用符号链接(symlinks),这意味着模型实际上将存在于我们选择的文件夹中。通常,huggingface-cli 会将其下载到缓存目录并创建一个符号链接以节省空间并避免在多个项目中多次下载模型。最后,Hugging Face 仓库包含多个版本的模型,它们处于不同的量化状态;在这里,我们将使用 include 标志选择 2 位量化版本。这种极端的量化将降低模型输出质量的表现,但它是仓库中最小的模型(只有 2.82 GB),这使得它非常适合演示目的。

现在我们有了我们的模型,我们需要下载并安装我们选择的语言的绑定,并运行它。对于 Python,这意味着通过pip安装llama-cpp-python。在列表 8.6 中,我们展示了如何使用库来运行 GGUF 模型。这相当直接,只需两步:加载模型并运行。在一个作者的 CPU 上,它的运行速度略慢于每秒约一个标记,这并不快,但对于一个没有加速器的 70 亿参数模型来说已经足够令人印象深刻了。

列表 8.6 使用 llama.cpp 在 CPU 上运行量化模型
import time
from llama_cpp import Llama

llm = Llama(model_path="./models/wizardcoder-python-7b-v1.0.Q2_K.gguf")

start_time = time.time()
output = llm(
    "Q: Write python code to reverse a linked list. A: ",
    max_tokens=200,
    stop=["Q:"],
    echo=True,
)
end_time = time.time()

print(output["choices"])

结果是

# [
#     {'text': "Q: Write python code to reverse a linked list. A: 
#         class Node(object):
#             def __init__(self, data=None):
#                 self.data = data
#                 self.next = None

#         def reverse_list(head):
#             prev = None
#             current = head
#             while current is not None:
#                 next = current.next
#                 current.next = prev
#                 prev = current
#                 current = next
#             return prev
#             # example usage;
#             # initial list
#         head = Node('a')     
#         head.next=Node('b')
#         head.next.next=Node('c')
#         head.next.next.next=Node('d')
#         print(head)
#          reverse_list(head) # call the function
#         print(head)
# Expected output: d->c->b->a",
#     'index': 0,         
#     'logprobs': None,
#     'finish_reason': 'stop'
#     }
# ]

print(f"Elapsed time: {end_time - start_time:.3f} seconds")
# Elapsed time: 239.457 seconds

虽然这个例子是用 Python 编写的,但还有 Go、Rust、Node.js、Java、React Native 等语言的绑定。Llama.cpp 为我们提供了在通常不可能的环境中运行 LLM 所需的所有工具。

8.3 LLM 代理

到这本书的这一部分,我们终于可以讨论 LLM 代理了。当人们开始担心 AI 会取代他们的工作时,他们谈论的通常是代理。如果我们回顾上一章,我们展示了如何通过一些巧妙的提示工程和工具,训练模型来回答需要搜索信息和运行计算的多步问题。代理在这一点上做得更多。完整的 LLM 应用不仅旨在回答多步问题,还旨在完成多步任务。例如,一个编码代理不仅能回答关于你的代码库的复杂问题,还能编辑它,提交 PR(Pull Request,即代码请求),审查 PR,并从头开始编写完整的项目。

代理与其他语言模型在本质上没有任何区别。所有的大差异都体现在围绕和支撑 LLM(大型语言模型)的系统之中。LLM 本质上是一种封闭的搜索系统。它们无法访问它们没有明确训练过的任何内容。例如,如果我们问 Llama 2,“上一次爱国者队赢得超级碗时贾斯汀·比伯多大了?”我们就依赖于 Meta 已经训练了该模型,并且拥有极其最新的信息。构成代理的三个要素是:

  • LLM — 无需解释。到现在为止,你知道这些是什么以及为什么需要它们。

  • 记忆 — 以某种方式将 LLM 重新引入到到目前为止每个步骤所发生的事情中。记忆对于代理表现良好至关重要。这与输入聊天历史记录的想法相同,但模型需要比事件的字面历史记录更多的东西。有几种完成这个任务的方法:

    • 记忆缓冲区 — 传递所有之前的文本。不推荐这样做,因为你很快就会遇到上下文限制,并且“丢失在中间”的问题会加剧这个问题。

    • 记忆总结 — LLM 再次遍历文本以总结它自己的记忆。效果相当不错;然而,至少它会加倍延迟,并且总结会比任何人希望的更快地删除更细微的细节。

    • 结构化内存存储 — 提前思考并创建一个可以从中获取模型实际最佳信息的系统。它可以与文章分块、搜索文章标题然后检索最相关的块相关联,或者通过链式检索找到最相关的关键词或确保查询包含在检索输出中。我们最推荐结构化内存存储,因为它虽然最难设置,但在每种情况下都能取得最佳结果。

  • 外部数据检索工具 — 代理行为的核心。这些工具赋予你的 LLM 执行动作的能力,使其能够执行类似代理的任务。

在这本书中,我们涵盖了大量的内容,代理是我们在前面所涵盖内容的一个总结。它们可能相当难以构建,因此为了帮助您,我们将分解步骤并提供几个示例。首先,我们将制作一些工具,然后初始化一些代理,最后创建一个自定义代理,所有这些都在我们自己的操作下完成。在整个过程中,您将看到为什么让代理有效地工作尤其困难,特别是为什么 LangChain 和 Guidance 对于入门和启动非常有用。

在列表 8.7 中,我们通过 LangChain 演示了一些工具的使用,以简化示例。本例使用了 Duck Duck Go 搜索工具和 YouTube 搜索工具。请注意,LLM 将简单地给出提示,而工具将处理搜索和结果摘要。

列表 8.7 LangChain 搜索工具示例
from langchain.tools import DuckDuckGoSearchRun, YouTubeSearchTool

search = DuckDuckGoSearchRun()     #1
hot_topic = search.run(
    "Tiktoker finds proof of Fruit of the Loom cornucopia in the logo"
)

youtube_tool = YouTubeSearchTool()
fun_channel = youtube_tool.run("jaubrey", 3)

print(hot_topic, fun_channel)

1 使用工具的示例

生成的文本是

# Rating: False About this rating If asked to describe underwear
# manufacturer Fruit of the Loom's logo from memory, some will invariably
# say it includes — or at least included at some point in... A viral claim
# recently surfaced stating that Fruit of the Loom, the American underwear
# and casualwear brand, had a cornucopia in their logo at some point in the
# past. It refers to a goat's... The Fruit of the Loom Mandela Effect is
# really messing with people's memories of the clothing company's iconic
# logo.. A viral TikTok has thousands of people not only thinking about what
# they remember the logo to look like, but also has many searching for proof
# that we're not all losing our minds.. A TikTok Creator Is Trying To Get To
# The Bottom Of The Fruit Of The Loom Mandela Effect What Is 'The Mandela
# Effect?' To understand why people care so much about the Fruit of the Loom
# logo, one must first understand what the Mandela Effect is in the first
# place. It's a slang term for a cultural phenomenon in which a large group
# of people shares false memories of past events. About Fruit of the Loom
# Cornucopia and Fruit of the Loom Mandela Effect refer to the Mandela
# Effect involving a large number of people remembering the clothing company
# Fruit of the Loom having a cornucopia on its logo despite the logo never
# having the item on it.
# ['https://www.youtube.com/watch?v=x81gguSPGcQ&pp=ygUHamF1YnJleQ%3D%3D',
#'https://www.youtube.com/watch?v=bEvxuG6mevQ&pp=ygUHamF1YnJleQ%3D%3D']

接下来,我们将演示在本地运行代理。在这些示例中,我们再次使用 llama.cpp;然而,这次我们将使用基于指令的模型,4 位量化 Mistral 7B Instruct 模型——一个优秀的开源模型。您可以通过运行以下命令获取我们使用的模型。注意这与我们在第 8.2 节中拉取 WizardCoder 模型时的相似之处:

$ huggingface-cli download TheBloke/Mistral-7B-Instruct-v0.1-GGUF --local-
↪ dir ./models --local-dir-use-symlinks False --include='*Q4_0*gguf'

在列表 8.8 中,我们展示了运行两种不同类型的代理,这些代理您可能会觉得很有用。第一个代理生成一些 Python 代码,运行它,并尝试调试它遇到的问题。第二个代理读取并分析 CSV 文件。对于这个代理,我们将使用我们在第四章中拉取的 Slack 数据集。请注意响应,并下注它们是否做得很好。

列表 8.8 LangChain Python 和 CSV 代理
from langchain.llms import LlamaCpp
from langchain.agents import AgentType
from langchain_experimental.agents import create_csv_agent
from langchain_experimental.agents.agent_toolkits import create_python_agent
from langchain_experimental.tools import PythonREPLTool

llm = LlamaCpp(
    model_path="./models/mistral-7b-instruct-v0.1.Q4_0.gguf",
    n_gpu_layers=0,     #1
    n_batch=512,
    n_ctx=32768,               #2
    verbose=False,
)

agent = create_python_agent(     #3
    llm=llm,
    tool=PythonREPLTool(),
    verbose=True,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    handle_parsing_errors=True,
)

agent.run(
    """Using Python_REPL please write a neural network in Pytorch.
          Use Python_REPL as the Action and your code as the Action Input.
          Use synthetic data from a normal distribution.
          Train for 1000 epochs and print every 100 epochs.
          Return a prediction for x = 5."""
)

1 如果是 NEON,则为 1,如果是 CUBLAS,则为任何数字,否则为 0

2 模型的上下文窗口

3 一个将生成 Python 代码并执行它的代理

输出是

# > Entering new AgentExecutor chain…
# This is a task about creating and training a neural network. I should
# start by importing Pytorch and defining the synthetic data. Then I will
# define the neural network and train it for 1000 epochs. Finally, I will
# print the prediction for x = 5.
# Action: Python_REPL
# Action Input:
# ```python

# 导入`torch`

# 导入`torch.nn`作为`nn`

# 导入`torch.optim`作为`optim`

# 从`torch.utils`导入`data`

# 从`torchvision.datasets`导入`make_classification`

# 定义从正态分布生成的合成数据

# data = make_classification(n_samples=1000, n_features=10, n_informative=5,

# random_state=42)

# X = data['features']

# y = data['targets']

# ```pyPython REPL can execute arbitrary code. Use with caution.
# Observation: ImportError("cannot import name 'make_classification' from
#'torchvision.datasets'
# Thought: It seems like the code is trying to use a function that does not
# exist, I will check the import statement.
# Action: Python_REPL
# Action Input:
# ```python

# fix 导入

# 导入`torch.utils.data`作为`data`

# 从`torchvision.datasets`导入`make_classification`

# ```py
# Observation: ImportError("cannot import name 'make_classification' from
#'torchvision.datasets'
# Thought: It still seems like the code is trying to use a function that
# does not exist, I will make sure that the function name matches the
# correct import statement.
# Action: Python_REPL
# Action Input:
# ```python

# 修复导入

# from torchvision.datasets.make_classification import make_classification

# data = make_classification(n_samples=1000, n_features=10, n_informative=5,

# random_state=42)

# X = data['features']

# y = data['targets']

# ```py
# Observation: ImportError("cannot import name 'MakeClassification' from
#'torchvision.datasets'
# Thought:
# > Finished chain.

我们继续

agent = create_csv_agent(    #1
    llm,
    "./data/Slack_Dataset.csv",
    verbose=True,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    handle_parsing_errors=True,
)
agent.run(
    """Using python_repl_ast please tell me whether the user polite in their
    messages. Use python_repl_ast as the Action and the command as the
    Action input."""
)

1 一个能够读取 CSV 并分析它的代理

生成的文本是

# > Entering new AgentExecutor chain…
# Action: python_repl_ast
# Action Input: df['text'].str.contains('thank you')
# Observation:
# 0    False
# 1    False
# 2    False
# 3    False
# 4    False
#  …
# 286    False
# 287    False
# 288    False
# 289    False
# 290    False
# Name: text, Length: 291, dtype: bool
# Thought: It seems the user was not polite in their messages.
# Final Answer: The user was not polite in their messages.
# > Finished chain.

嗯,你怎么看?有没有哪个代理做得非常好?你可能想“没有”,这应该会让你放心,AI 不会很快取代你的工作。Python 代理编写了一个完全依赖于一个不存在的make_classification()函数的 PyTorch 脚本,而 CSV 代理认为礼貌等同于说“谢谢”。这不是一个坏的猜测,但仅仅不是一个稳健的解决方案。当然,问题的一部分可能是我们使用的模型;一个更大的模型,比如 GPT-4 可能会做得更好。我们将把它留给读者作为练习来比较。

接下来,在列表 8.9 中,我们构建自己的代理。我们将定义代理可以访问的工具,为代理设置一个记忆空间,然后初始化它。接下来,我们将定义一个系统提示,让代理知道它应该如何表现,确保向它解释它有什么工具可用以及如何使用它们。我们还将利用少量提示和指令来给我们最好的机会看到好的结果。最后,我们将运行代理。让我们看看。

列表 8.9 代理及其行为
from langchain.llms import LlamaCpp
from langchain.chains.conversation.memory import (
    ConversationBufferWindowMemory,
)
from langchain.agents import load_tools, initialize_agent, Tool
from langchain_experimental.tools import PythonREPLTool
from langchain.tools import DuckDuckGoSearchRun, YouTubeSearchTool
llm = LlamaCpp(
    model_path="./models/mistral-7b-instruct-v0.1.Q4_0.gguf",
    n_gpu_layers=0,        #1
    n_batch=512,
    n_ctx=32768,          #2
    verbose=False,
)

search = DuckDuckGoSearchRun()     #3
duckduckgo_tool = Tool(
    name="DuckDuckGo Search",
    func=search.run,
    description="Useful for when an internet search is needed",
)
youtube_tool = YouTubeSearchTool()
coding_tool = PythonREPLTool()

tools = load_tools(["llm-math"], llm=llm)
tools += [duckduckgo_tool, youtube_tool, coding_tool]

memory = ConversationBufferWindowMemory(     #4
    memory_key="chat_history",
    k=5,
    return_messages=True,
    output_key="output",
)

agent = initialize_agent(     #5
    tools=tools,
    llm=llm,
    agent="chat-conversational-react-description",
    verbose=True,
    memory=memory,
    handle_parsing_errors=True,
)

B_INST, E_INST = "[INST]", "[/INST]"        #6
B_SYS, E_SYS = "<<SYS>>\n", "\n<</SYS>>\n\n"

sys_msg = (     #7
    "<s>"
    + B_SYS
    + """Assistant is a expert JSON builder designed to assist with a wide \
range of tasks.

Assistant is able to respond to the User and use tools using JSON strings \
that contain "action" and "action_input" parameters.

All of Assistant's communication is performed using this JSON format.

Assistant can also use tools by responding to the user with tool use \
instructions in the same "action" and "action_input" JSON format. Tools \
available to Assistant are:

- "Calculator": Useful for when you need to answer questions about math.
  - To use the calculator tool, Assistant should write like so:
    ```json

    {{"action": "Calculator",

    "action_input": "sqrt(4)"}}

    ```py
- "DuckDuckGo Search": Useful for when an internet search is needed.
  - To use the duckduckgo search tool, Assistant should write like so:
    ```json

    {{"action": "DuckDuckGo Search",

    "action_input": "乔纳斯兄弟的第一场演唱会是在什么时候"}

这里是助手和用户之间的一些先前对话:

用户:嘿,你今天怎么样?

助手:```pyjson
{{"action": "Final Answer",
 "action_input": "I'm good thanks, how are you?"}}

用户:我很好,4 的平方根是多少?

助手:```pyjson
{{"action": "Calculator",
"action_input": "sqrt(4)"}}


用户:2.0

助手:```pyjson
{{"action": "Final Answer",
 "action_input": "It looks like the answer is 2!"}}

用户:谢谢,乔纳斯兄弟的第一场演唱会是什么时候?

助手:```pyjson
{{"action": "DuckDuckGo Search",
"action_input": "When was the Jonas Brothers' first concert"}}


用户:12.0

助手:```pyjson
{{"action": "Final Answer",
 "action_input": "They had their first concert in 2005!"}}

用户:谢谢,你能告诉我 4 的平方根是多少吗?

助手:```pyjson
{{"action": "Calculator",
"action_input": "4**2"}}


用户:16.0

助手:```pyjson
{{"action": "Final Answer",
 "action_input": "It looks like the answer is 16!"}}

这里是助手和用户之间的最新对话。”

+ E_SYS

)

new_prompt = agent.agent.create_prompt(system_message=sys_msg, tools=tools) #8

agent.agent.llm_chain.prompt = new_prompt

instruction = ( #9

B_INST

+ "用 JSON 格式响应以下内容,包含'动作'和'动作输入'"

"values "

+ E_INST

)

human_msg = instruction + "\n 用户:{输入}"

agent.agent.llm_chain.prompt.messages[2].prompt.template = human_msg

agent.run( #10

"告诉我当爱国者队最后一次赢得超级碗时贾斯汀·比伯多大了"

"超级碗。"

)


#1 1 if NEON, any number if CUBLAS, else 0
#2 Context window for the model
#3 Δefines our own agent tools
#4 Δefines our agent’s memory
#5 Sets up and initializes our custom agent
#6 Special tokens used by llama 2 chat
#7 Creates the system prompt
#8 Adds system prompt to agent
#9 Adds instruction to agent
#10 Runs with user input

Remember that for this, we asked the model to respond in JSON:

> 正在进入新的 AgentExecutor 链…

助手:{

"action": "DuckDuckGo Search",

"action_input": "当新英格兰爱国者队最后一次赢得超级碗是在什么时候"

汤匙?贾斯汀·比伯的出生日期"

}

{

"action": "Final Answer",

"action_input": "贾斯汀·比伯出生于 1994 年 3 月 1 日。爱国者队"

最后一次在 2018 年 2 月赢得了超级碗。

}


还不错!它没有回答问题,但已经很接近了;只是需要做一些数学计算。如果你运行了示例,你可能已经注意到它比使用 llama.cpp Python 解释器慢一些。不幸的是,由于某些原因,LangChain 的包装器增加了计算时间,所以请注意:如果你需要非常快,LangChain 可能不是你的选择。至少目前还不是。无论如何,LangChain 已经围绕流行的 Python 库创建了一些易于使用的包装器,使它们可以作为 LLM 工具使用。在这些列表中,我们只使用了其中的一小部分,还有更多可供选择。

总体来说,你可以看到我们能够使 LLM 在一些非平凡任务上表现相当不错(我们使用的是 4 位量化模型,我们可能会添加)。然而,它远远没有达到完美。代理之所以神奇,是因为它们能够工作,但它们在执行的任务和级别上通常令人失望——包括顶级付费代理。你与 LLMs 一起制作更多不同的提示时,会发现 LLMs 非常不可靠,就像人类一样,这对习惯于与世界上任何东西一样一致的机器工作的软件工程师来说可能会相当令人烦恼。让 LLMs 在单一任务上表现良好就已经足够困难,但在代理内部链接多个任务则更加困难。我们仍然处于代理开发的早期阶段,我们很期待看到它的发展方向。

## 摘要

+   创建一个简单的 LLM 应用程序很简单,但要创建一个能让用户感到愉悦的应用程序则需要更多的工作。

+   你应该在应用程序中包含的关键功能包括以下内容:

    +   流式响应允许更互动和响应式的体验。

    +   将聊天历史输入到你的模型中可以防止你的模型变得像鸟一样愚蠢。

    +   像停止、重试和删除按钮这样的交互功能,让用户对对话有更多的控制权。

    +   计数令牌对于用户反馈很有用,并允许用户编辑响应以适应令牌限制。

    +   前端上的 RAG 允许我们根据 LLM 后端定制应用程序。

+   Llama.cpp 是一个强大的开源工具,用于编译 LLMs 并在资源受限的边缘设备上运行。

+   代理是构建来解决多步骤问题的 LLM 应用程序,并承诺自动化机器目前难以处理的任务。

+   由于 LLMs 的不可预测性,构建代理非常困难,有时需要高级提示工程才能获得合理的结果。


# 第九章:*创建一个 LLM 项目:重新实现 Llama 3*

### 本章涵盖

+   实现 Meta 的 Llama3 模型

+   训练一个简单的 LLM

+   对其进行改进以准备生产

+   将模型服务于一个可以与朋友分享的生产端点

> 我来普林斯顿只是为了研究,而不是教学。教育太多了,尤其是在美国学校。唯一合理的教育方式是树立榜样。——阿尔伯特·爱因斯坦

在本书的第一个主要项目中,我们希望从头开始。我们一直在向您展示如何从头到尾与 LLMs(大型语言模型)一起工作,我们将在本章中将所有内容整合在一起。这个项目包括预训练一个模型,大致遵循研究论文。我们不会深入实际研究;事实上,我们会在这里走一些捷径,因为这不是本书的重点。然而,我们将展示如何训练模型,通过量化准备它以供服务,通过低秩适应(LoRA)针对特定目的或任务进行微调,并将其部署到您可以向朋友展示的生产环境中。

本章将非常密集,但您应该已经做好了迎接挑战的准备,因为这是一个主要面向生产的数据科学家项目。我们选择这个项目是为了让您将本书中学到的所有课程整合到一个地方,并给您留下端到端、动手实践的经验。

## 9.1 实现 Meta 的 Llama

“Touvron 等人撰写的《Llama 2:开放基础和微调聊天模型》¹”是一篇很棒的论文,涵盖了 Llama 2 的开发和发布,这是目前市场上最好的、几乎开源的模型之一。您可能已经看到 Llama 2 作为第一个足够好的开源模型,可以与 OpenAI 的模型相媲美,至少基于当时的指标。现在 Llama 3 已经发布,它几乎完全超越了 Llama 2 的受欢迎程度,这可能是您选择这本书的原因。

Llama 3 有几个令人惊叹的原因——主要是大小和可用性。只有 70B 参数,在 15T 个标记上预训练,并在 10K 个聊天上微调,它不应该在任何方面击败 176B 或 1.7T 参数的模型。不出所料,它通常做不到。但它确实在一件关键的事情上打败了它们:它的可用性。这个特性催生了一个开源软件社区,该社区开发了工具和优化,甚至收集数据以使其更好。Llama 3 是架构不如数据重要的终极展示,它是在干净的数据上训练的。

我们将实现它。

到本章结束时,你将构建一个真实模型并理解其背后的工作。它会是 Meta 的 Llama 3 那么好吗?远非如此,因为我们不会使用足够的数据或 GPU 进行演示。但我们想做的不仅仅是提供另一组在某个排行榜上的权重。我们希望给你一些对所需步骤和可能遇到的问题的直观感受。我们不会从头开始训练一个优秀的模型,这正是现在几十本书正在解决的问题,而是会向你展示如何训练一个低于平均水平的模型并将其投入生产。这种方法不仅能让你学到更多,还能展示出超出你经验水平的专长。

### 9.1.1 分词和配置

到目前为止,你可能已经学会了正确设置问题的重要性。我们希望我们的模型能够打出全垒打,而不是与 MLB 投手对决。考虑到这一点,我们将下载 Llama 使用的相同分词器。如果你想的话,可以在我们从零开始构建时回来尝试这个分词器。例如,尝试使用像 tiktoken 这样的更快分词器——只是要知道你将放弃模型进行数学运算的能力。你还可以训练自己的 SentencePiece 模型版本,这应该能保证在你想扩展的任何数据集上都能得到更好的结果。重点是,这个模型是空的——没有任何预训练的权重。所以,在跟随我们之后,回来做你想做的任何事情。

注意:与其他章节中每个列表都是独立的章节不同,在本章中,每个列表都将成为更大笔记本的一部分。你可以在本书附带的代码仓库中找到这个笔记本。

列表 9.1 展示了我们为这个项目进行的初始设置,包括导入、设备设置和获取我们的分词器。虽然我们只是从 Hugging Face 获取分词器,但请记住,并非所有分词器和模型都使用相同类型的标记。这很重要,因为我们打算以不同于推理分词器设置的方式训练这个模型。为了纠正这种差异,我们需要添加一个填充标记。任何东西都可以,但我们的例子中将使用`"<PAD>"`。一旦我们有了这个,我们就会确保获取词汇表本身(我们稍后会用到它),并创建编码和解码函数以帮助批量处理。因为我们使用的是 Hugging Face 实现,所以这并不是严格必要的,因为它内置了批量分词,以及一个工作得很好的`batch_decode`方法。为了学习的目的,我们仍然会走一遍流程。了解自己在做什么总是好的实践,这些函数有助于锁定这一点。

列表的最后部分提供了最大的灵活性。在这里,我们设置了一个主配置,它最终将决定我们的模型有多少参数,训练多长时间,以及每行数据集将占用多少内存。我们的默认值相当小,旨在无论您的硬件如何,都能为您提供良好的体验,包括如果您在仅 CPU 构建的机器上训练。请随意实验并增加这些数字。

##### 列表 9.1 分词和配置

```py
import torch
from torch import nn
from torch.nn import functional as F
import numpy as np
from numba import jit
from matplotlib import pyplot as plt
import time
from datetime import timedelta
import pandas as pd
from collections import OrderedDict
from itertools import cycle
from transformers import AutoTokenizer
from sentencepiece import SentencePieceProcessor
from datasets import load_dataset

device = "cuda:0" if torch.cuda.is_available() else "cpu"
device_cap = torch.cuda.get_device_capability()
device_type = "cuda" if "cuda" in device else "cpu"
torch.cuda.set_device(device)
torch.manual_seed(8855)
print(torch.__version__)
print(device, device_cap)
# 2.1.0+cu121
# cuda:0 (8,6)

tokenizer = AutoTokenizer.from_pretrained("./llama3/")      #1
tokenizer.add_special_tokens({"pad_token": "<PAD>"})
# tokenizer.pad_token = tokenizer.eos_token           #2

vocab = tokenizer.vocab

def encode(example):
    return tokenizer.encode(example, return_tensors="pt")

def decode(example):
    return tokenizer.batch_decode(
    example,
    skip_special_tokens=False,
    clean_up_tokenization_spaces=True,
    )[0]

print(f"Vocab Size: {len(vocab)}")
decode(
    encode(
    """hello I am a specifically designed long sentence
       to make sure this is working not only adequately,
       but good enough for our batch functions"""
    )
)
# Vocab Size: 32001
#'<s> hello I am a specifically designed long sentence to make sure this is
working not only adequately, but good enough for our batch functions'

MASTER_CONFIG = {
    "vocab_size": len(vocab),
    "batch_size": 16,
    "context_window": 32,
    "d_model": 288,
    "hidden_dim": 768,
    "epochs": 1000,
    "log_interval": 50,
    "n_heads": 6,
    "n_layers": 6,
}
GLOBAL_KEEP_TRACK = []

1 使用 Hugging Face

2 可选

正如我们在本书中多次重申的那样,您用于分词和嵌入输入的策略最终决定了您的模型在训练和推理过程中能够“看到”的内容。您应该做的不仅仅是选择一个分词器;实际上,我们将在本章后面看到选择 Llama 3 分词器会对我们的推理产生什么影响。

您可以选择在您的数据集上训练一个新的分词器,或者将您数据集中特别重要的标记添加到一个已经非常健壮的分词器中——最好是已经大致符合您想要策略并且在该领域训练过的分词器。如果您对此有任何疑问,任何 LLM 分词器通常都适用——这就是它们被设计的目的。但是,当您选择一个通用分词器并希望执行特定任务时,如果模型表现不佳,请不要感到惊讶。

9.1.2 数据集、数据加载、评估和生成

让我们深入了解这个过程中的最重要部分,我们将大部分时间都会略过。在一章中我们只能关注这么多,但我们想重申您的数据集对您的 LLM 成功的重要性。您将需要花费时间收集、评估和清理您的数据集,但为了节省时间,我们将简化这个过程。相反,我们将专注于训练模型所需的步骤——加载、预处理、批处理等等。在阅读这一部分时,请记住,您独特的数据源最终会使您的模型具有前瞻性,因此考虑您能访问到而其他人没有的数据,以及您如何为这次训练设置该数据集。

我们将首先加载一个通常用于创建玩具模型的流行数据集,TinyStories。如果您已经完成了探索数据的工作——我们鼓励您这样做——您会发现这是一个相对较小的 LLM 数据集,包含 3000 万行,每行包含一个段落中的短篇故事。它借鉴了一些经常实施且广泛接受的数据集。虽然对于 LLM 来说是一个较小的数据集,但它可能仍然对许多计算机来说太大,许多读者在尝试一次性将其加载到内存中时可能会遇到内存不足错误。这正是使用流式处理的好时机。在列表 9.2 中,我们向您展示了如何从 Hugging Face Hub 或 dataset.to_iterable_dataset()(如果本地工作)中提取数据集。这两种方法都允许进行更高效的内存处理,因为整个数据集不是一次性加载的,这牺牲了一些速度。

列表 9.2 加载和准备数据
dataset = load_dataset(      #1
    "text",
    data_files={
        "train": ["../../data/TinyStoriesv1andv2-train.txt"],
        "val": ["../../data/TinyStoriesv1andv2-valid.txt"],
    },
    streaming=True,
)

1 从本地文件流中获取

一旦你有了你的数据集并且能够检索到一个迭代,我们将进行一些最小(真正)的清理。然后我们将整个内容进行编码,以便我们的训练可以更快地进行。我们将保存分词和注意力掩码作为它们自己的列,然后我们将数据集进行洗牌,并继续进行数据加载。一个总是值得提到的快速提示:在训练任何机器学习模型时,如果你还没有定义你的trainval拆分,请特别注意洗牌你的数据集,以确保数据不会泄漏到不应该出现的数据拆分中:

clean_dataset = dataset.filter(lambda example: len(example["text"]) > 2)   #1

prompt = "Write a short story. Possible Story: "
tokenized_prompt = tokenizer(prompt, return_tensors="pt").input_ids

encoded_dataset = clean_dataset.map(
    lambda examples: tokenizer(
      [prompt + x for x in examples["text"]],
       padding=True,
       return_tensors="pt",
    ),
    batched=True,
)
train_data = iter(encoded_dataset["train"].shuffle())
val_data = iter(encoded_dataset["val"].shuffle())
train_data = cycle(train_data)
val_data = cycle(val_data)

1 最小处理

如果你忽视了我们的建议进行流式处理,并且有一台可以处理这个数据集的计算机,那么请知道,将整个数据集加载到内存中并进行准备,即使使用硬件加速,也需要超过 30 分钟和超过 5 GB 的内存。所以如果你有额外的 5 GB VRAM,而且这超出了你为模型所需的内存,那么你可以随意加载它。见图 9.1。

图

图 9.1 虽然这个数据集有超过 3000 万行,对于我们想要做的事情来说可能很小,但在消费级硬件上仍然是相当大的。

我们至少需要一个函数将我们的数据加载到模型可用的格式中,我们选择使用这个函数。我们的get_batches函数将接收我们数据的一行,并返回一个模型输入和一个预期的输出,可以与之进行比较以进行自监督学习。不需要标签,因为我们将从随机标记开始,然后抓取直到整个上下文窗口(32)的标记作为输入,并将一个标记向右移动作为预期的输出。对于我们的模型,我们创建了一个类似这样的场景:

CB 输入: 如果一只土拨鼠能够推木头,它能推多少木头

CB 标签: 如果一只土拨鼠能够推木头,它能推多少木头?

这个过程允许我们的模型在任务上进行训练:根据前 31 个标记的上下文猜测一个话语中的下一个标记。我们使用这种策略而不是其他策略,如掩码,因为我们的首选输入在输入完成后永远不会包含信息。这样,我们的模型将随着训练数据的更多和更高质量而越来越好。几乎所有的基础模型都是以这种方式预训练的——只是它们训练的时间更长,参数比我们现在使用的要多得多:

# @torch.compile      #1
def get_batches(
    data,
    batch_size,
    context_window,
    config=MASTER_CONFIG,
    debug=False,
):
    x = []
    y = []
    for _ in range(
        batch_size    #2
    ):
        batch_data = next(data)

        ix = torch.randint(         #3
            0, len(batch_data["input_ids"]) - context_window - 1, (2,)
        )
        batch_x = torch.stack(
            [batch_data["input_ids"][i : i + context_window] for i in ix]
        ).long()
        batch_y = torch.stack(
            [
                batch_data["input_ids"][i + 1 : i + context_window + 1]
                     for i in ix
            ]
        ).long()
        x.append(batch_x)
        y.append(batch_y)
    x = torch.cat((x), 0).to(device)
    y = torch.cat((y), 0).to(device)
    return x, y

1 窗口用户请注释

2 如果内存不足,请调整此值。

3 选择随机起始点。

一旦我们处理好了数据批处理,我们需要为评估和推理编写函数,以便我们可以在训练过程中了解模型的表现,并且可以在之后使用该模型。对于我们的评估,我们将取一些批次,并计算这些批次之间的平均损失以获得验证损失。这个结果不会给我们模型性能的真实表示,但不会阻止它对我们有用:

@torch.no_grad()
def get_loss(model, lora=False, config=MASTER_CONFIG):
    out = {}
    model.eval()
    for name, split in zip(["train", "val"], [train_data, val_data]):
        losses = []
        for _ in range(10):
                xb, yb = get_batches(
               split,
               config["batch_size"],
                 config["context_window"],
                )
            _, loss = model(xb, yb)
            losses.append(loss.item())
        out[name] = np.mean(losses)
    model.train()
    return out
对你的假设进行质疑

当与机器学习模型和其他统计方法一起工作时,了解你的假设如何影响你的结果非常重要。平均值阻碍了数据的表现和理解,因为它们基本上是说:“对于这个比较,我们将取一个虚构的数字,并将该数字用于替代任何真实的数字,因为它感觉是我们分布的中心。”这种方法并不一定不好;虚构的数字往往比真实的数字更有预测性。然而,我们敦促你有意并且非常开放地测试平均值是否是你用户最好的标记。

对于生成,我们将做类似但更好的事情。logits 是我们从模型的前向方法中得到的。我们在对数据集进行标记化时创建了一个提示的标记化版本,所以我们准备多次将这个提示传递给我们的模型并查看结果。我们将从模型中获取给定提示的 logits,然后为下一个标记采样模型分布并解码。

对于采样这个分布,我们将只取模型输出的 logits(logits)作为输入的最后一个部分(我们希望模型生成的未知标记),然后将这些 logits 除以温度设置(更高的温度设置=更小的 logits)。一旦我们有了最后一个时间步的 logits,如果我们使用多项式采样,我们可以使用top_k和/或top_p进行采样,这些采样是对最高概率标记的采样,直到达到标记总数或概率总和总数。一旦我们有了这些,我们使用 softmax 对采样的标记进行操作,然后使用 argmax 获取下一个标记。如果我们希望输出有更多的探索和创造力,我们可以使用多项式采样。作为一个练习,测试多项式采样与 argmax 之间的top_ktop_p,以了解哪个效果最好:

@torch.inference_mode()
def generate(
    model,
    config=MASTER_CONFIG,
    temperature=1.0,
    top_k=None,
    max_new_tokens=30,
    lora=False,
):
    idx_list = [tokenized_prompt] * 5
    idx = torch.cat((idx_list), 0).long().to(device)
    for _ in range(max_new_tokens):
        logits = model(idx[:, -config["context_window"] :])     #1
        last_time_step_logits = logits[
            :, -1, :
        ]                      #2

        last_time_step_logits = last_time_step_logits / temperature
        if top_k is not None:
            v, _ = torch.topk(
                last_time_step_logits,
                min(top_k, last_time_step_logits.size(-1)),
            )
            last_time_step_logits[
                last_time_step_logits < v[:, [-1]]
            ] = -float("Inf")
        p = F.softmax(
            last_time_step_logits, dim=-1
        )                                #3
        idx_next = torch.argmax(
            p, dim=-1, keepdims=True
        )                                 #4
        idx = torch.cat([idx, idx_next], dim=-1)  # append to the sequence
    return [tokenizer.decode(x) for x in idx.tolist()]

1 调用模型

2 所有批次(1);上一次步骤,所有 logits

3 使用 Softmax 获取概率

4 从分布中采样以获取下一个标记

有了这些,我们就完成了设置!我们为模型训练的所有重要部分都提供了实用函数,包括分词、数据加载、评估、推理和数据处理。如果你觉得有任何需要纠正的地方,那太好了!去做吧——这是你的项目。如果你想用多项式分布进行采样而不是 argmax,或者想去掉 softmax 只对 logits 进行 argmax,那太棒了,去做吧。对于那些这是你们第一次尝试的人来说,我们知道这可能会相当快节奏,我们鼓励你们慢慢来,但不要因此失眠。更有可能的是,你不需要自己想出针对你用例应该改变什么,因为你将实现一个已经创建的开源模型。话虽如此,了解幕后和底层发生的事情仍然是一个好主意,这样你知道当事情出错时大致该往哪里找。

9.1.3 网络架构

我们现在已经为训练模型做了大量的设置,但还没有创建模型。模型架构和训练已经被反复迭代,所以我们不会过多地讨论它,而是直接进入正题。我们将从一个少于 20M 参数的两层前馈网络开始,然后我们将升级并讨论将模型转变为 Llama 的变化。我们希望清楚地说明它们之间实际发生了什么变化,这样你就能对涉及的各个部分有一个良好的感觉。因为我们不会完全复制 Llama 3,而是近似它,如果你想在我们的数据集上尝试预训练它,这里是有官方架构的链接:mng.bz/Dp9A

在列表 9.3 中,我们为那个具有 ReLU 激活函数的线性模型创建了一个类,位于两个线性层之间。这里我们将定义我们的实际损失函数(因为在我们的get_loss函数中,我们只是将输入发送到模型)。我们将使用交叉熵,因为我们正在比较非结构化序列。而不是深入信息理论来解释为什么交叉熵是非结构化序列的答案,当前行业中的基准是困惑度,它使用交叉熵来确定模型是否在有意义,因此这个损失函数使我们能够与其他行业中的模型竞争。

在我们之前对数据集进行标记化时,你可能已经注意到了一件事:我们在批量中填充而不是截断,这意味着每个批次的长度应该相同。我们完全承认,在预训练时这并没有什么意义;这只是为了加快速度。我们这样做是因为我们的最长长度输入是 997 个标记,我们不想把整个数据集填充到 997 个标记。即使这样,我们数据集中最常见的标记仍然是"<PAD>"。如果我们保持原样,模型可能会学会只生成填充标记,这在预测下一个标记时似乎可以最小化损失。然而,由于我们刚刚添加了标记化器词汇表,我们可以告诉损失函数忽略tokenizer.pad_token_idignore_index,这样正确预测填充标记就不会错误地帮助损失下降。

列表 9.3 简单模型和训练循环
class SimpleFeedForwardNN(nn.Module):
    def __init__(self, config=MASTER_CONFIG):
        super().__init__()
        self.config = config

        self.embedding = nn.Embedding(
            config["vocab_size"], config["d_model"]
        )
        self.linear = nn.Sequential(
            nn.Linear(config["d_model"], config["d_model"]),
            nn.ReLU(),
            nn.Linear(config["d_model"], config["vocab_size"]),
        )

        print(
            f"model params: {sum([m.numel() for m in self.parameters()])}"
        )

    def forward(self, idx, targets=None):
        x = self.embedding(idx)
        logits = self.linear(x)

        if targets is not None:
            loss = F.cross_entropy(
                logits.view(-1, self.config["vocab_size"]),
                targets.view(-1),
                ignore_index=tokenizer.pad_token_id,
                # reduction="sum",
            )
            return logits, loss

        else:
            return logits

model = SimpleFeedForwardNN(MASTER_CONFIG).to(device)
opt_model = torch.compile(model)           #1

1 在 Windows 上取消此注释。

现在我们有了我们的模型,我们将编写我们的训练循环。我们运行主配置文件中指定轮次的次数,并得到每次迭代的损失。这里的轮次更像是步骤,我们鼓励你在有时间的情况下在整个数据集上运行轮次。如果你坚持使用我们之前设置的MASTER_CONFIG,这个原始模型最终将拥有 18.5M 个参数。你绝对应该将其更改为你的计算机可以处理的参数最大数量。你可以通过更改主配置文件中的d_model(如果你训练了一个更大的标记化器,还需要更改vocab_size)来找到这个数字:

def train(
    model,
    optimizer,
    scheduler=None,
    data=None,
    config=MASTER_CONFIG,
    lora=False,
    print_logs=False,
):
    losses = []
    start_time = time.time()
    for epoch in range(config["epochs"]):
        try:
            optimizer.zero_grad()

                xs, ys = get_batches(
                    data, config["batch_size"], config["context_window"]
                )
                  for i in range(1, config[‘context_window’]+1):
                          x = xs[:i]
                          y = ys[:i]
                    logits, loss = model(xs, targets=ys)
                    loss.backward()
                    optimizer.step()

                    if scheduler:
                            scheduler.step()

            if epoch % config["log_interval"] == 0:
                batch_time = time.time() - start_time
                x = get_loss(model, lora=lora)
                losses += [x]
                if print_logs:
                    print(
                      f"""Epoch {epoch} |
                         train loss {x['train']:.3f} |
                         val loss {x['val']:.3f} |
                         Time {batch_time:.3f} |
                         ETA: {timedelta(seconds=(batch_time * (config
                            ['epochs'] - epoch)/config['log_interval']))}"""
                    )
                start_time = time.time()

                if scheduler:
                    print("lr: ", scheduler.get_last_lr())
        except StopIteration:
            print(f"Reached end of dataset on step {epoch}")
            break

    GLOBAL_KEEP_TRACK.append(
       f"{type(model).__name__} {sum([m.numel() for m in 
model.parameters()])} Params | Train: {losses[-1]['train']:.3f} | Val: 
{losses[-1]['val']:.3f}"
    )
    print(
        f"training loss {losses[-1]['train']:.3f} | validation loss: 
{losses[-1]['val']:.3f}"
    )
    return pd.DataFrame(losses).plot(xlabel=”Step // 50”, ylabel=”Loss”)

optimizer = torch.optim.AdamW(
    model.parameters(),
)
train(model, optimizer, data=train_data, print_logs=True)
#Epoch 0 | train loss 10.365 | val loss 10.341 | Time 0.122 | ETA:
 0:00:02.431240
#training loss 4.129 | validation loss: 4.458

看看,这是图 9.2,它是从列表 9.3 生成的!试着猜猜它将会是什么,然后阅读摘要看看你是否猜对了。

figure

图 9.2 在我们的数据集上训练简单的神经网络以生成文本

看看!这是我们第一次训练时的曲线非常平滑。考虑到我们只从数据集中使用了 1,000 个示例,我们鼓励你尝试几个实际的轮次——比如说,尝试三次遍历整个数据集——看看结果如何。你可能会得到令人惊讶的良好结果;我们就是这样做的。让我们继续检查它在生成文本时创建的内容:

generate(model, config=MASTER_CONFIG)
# '<s> Write a short story. Possible Story: 3 together thisar andze Lily 
said exciteded and smiled. Everything because he wasning loved to the time, 
he did not find like to',

for i in GLOBAL_KEEP_TRACK:
    print(i)
# SimpleFeedForwardNN 18547809 Params | Train: 4.129 | Val: 4.458

真的不赖!当然,这些结果并不算出色,但我们并没有期望我们的基础模型和短时间训练能带来惊人的结果。阅读生成的标记,几乎有点道理。我们可以称之为胜利。恭喜!我们使用前馈网络创建了一个可以返回标记的语言模型。现在,让我们深入了解使 Llama 与普通前馈网络不同的变化。

9.2 简单的 Llama

如果您检查 Meta 发布的完整权重和层,您可能会注意到我们正在构建的并不完全与发布的内容相同。原因有两点:(1)我们希望确保这次讨论对于第一次与生产相关的科研人员来说仍然非常易懂,(2)我们正在考虑您在阅读这本书时可能能够访问的环境。这里的一切都应该能够在 Kaggle 或 Colab 中无缝运行。在这种情况下,我们将讨论 Llama 3 的架构与我们的差异,以便如果您确实有基础设施和数据来复制论文以用于生产,您也可以做到。²

Llama 在几个方面与前馈网络不同:归一化、注意力、激活和层数。不过,我们不会深入探讨这些内容。归一化有助于稳定训练,注意力有助于支持更大的上下文长度并更有效地使用层间信息,激活有助于更好地表示非线性,而层数的增加使得模型能够表示更多的信息。还有一点需要注意,这次我们增加了一个调度器。这里的调度器负责在训练过程中根据“调度”调整学习率。这一增加有助于我们处理潜在的梯度爆炸问题,并使模型更快地收敛。

让我们将我们的网络改为 Llama 3 的一个更简单的版本。在这里,我们将跳过一些理论和实现细节。但也要查看 GitHub 上的笔记本——我们希望您亲自尝试一下!

列表 9.4 简单的 Llama
class LlamaBlock(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.rms = RMSNormalization(
            (config["d_model"], config["d_model"])
        )                                                                   #1

        self.attention = RoPEMaskedMultiheadAttention(config).to(device)   #1
        self.feedforward = nn.Sequential(
            nn.Linear(config["d_model"], config["hidden_dim"]),
            SwiGLU(config["hidden_dim"]),                                  #1
            nn.Linear(config["hidden_dim"], config["d_model"]),
        )

1 新增

与原始网络不同,我们为 LlamaBlocks 创建了一个完整的类,或者说是我们更大网络中的较小、自包含的网络。现在我们有 RMSNormalization,以及 RoPEMaskedMultiheadAttentionSwiGLU 激活而不是 ReLU。我们在笔记本中包含了实现,如果您好奇,可以随时查看。

您会注意到我们的前向函数与原始的前馈函数非常不同。我们现在不再只是嵌入然后从嵌入中获取 logits,而是进行归一化、添加注意力、再次归一化,然后将 logits 添加到输出中。这个过程有助于模型将更多的非线性整合到其对输入和期望输出的整体考虑中:

    def forward(self, x):
        x = self.rms(x)
        x = x + self.attention(x)

        x = self.rms(x)
        x = x + self.feedforward(x)
        return x

在这里,我们可以将原始的前馈网络与这个 SimpleLlama 类进行比较,以了解整体上发生了哪些变化。首先,我们不再只有一个 Sequential 层块,而是根据配置中的 n_layers(即 8,您将在下面的代码片段中看到)拥有多个 LlamaBlocks。除此之外,我们使用 SwiGLU 激活而不是 ReLU。SwiGLU 增加了一些处理负数的能力,并有助于解决梯度爆炸/消失问题。除此之外,它们非常相似:

class SimpleLlama(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.embedding = nn.Embedding(
            config["vocab_size"], config["d_model"]
        )
        self.llama_blocks = nn.Sequential(
            OrderedDict(
                [
                    (f"llama_{i}", LlamaBlock(config))    #1
                    for i in range(config["n_layers"])
                ]
            )
        )

        self.ffn = nn.Sequential(
            nn.Linear(config["d_model"], config["d_model"]),
            SwiGLU(config["d_model"]),                           #2
            nn.Linear(config["d_model"], config["vocab_size"]),
        )

        print(
            f"model params: {sum([m.numel() for m in self.parameters()])}"
        )

    def forward(self, idx, targets=None):
        x = self.embedding(idx)
        x = self.llama_blocks(x)     #3
        logits = self.ffn(x)

        if targets is None:
            return logits

        else:
            loss = F.cross_entropy(
                logits.view(-1, self.config["vocab_size"]),
                targets.view(-1),
                ignore_index=tokenizer.pad_token_id,
            )
            return logits, loss

1 新增

2 新增

3 新增

我们可以通过增加嵌入维度、层数和上下文窗口来对我们的主配置文件进行一些轻微的调整,使模型更大。实际上,你不必做出这个改变就能看到性能差异。如果你有计算能力、数据和时间,你可以训练一个可行的 Llama 3 版本(你可以在图 9.3 中看到这个训练的结果):

MASTER_CONFIG["epochs"] = 1000
MASTER_CONFIG["batch_size"] = 16
MASTER_CONFIG["d_model"] = 768
MASTER_CONFIG["n_layers"] = 8
MASTER_CONFIG["context_window"] = 128

llama = SimpleLlama(MASTER_CONFIG).to(device)

llama_optimizer = torch.optim.AdamW(
    llama.parameters(),
    betas=(0.9, 0.95),
    weight_decay=1e-1,
    eps=1e-9,
    lr=5e-4,
)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    llama_optimizer, 1000, eta_min=1e-5
)                                              #1
#Epoch 0 | train loss 10.321 | val loss 10.316 | Time 0.622 | ETA: 
0:00:12.439990
#lr:  [0.0004999987909744553]
#training loss 6.216 | validation loss: 6.046 
generate(
    llama,
    config=MASTER_CONFIG,
    temperature=1.0,
    top_k=25,
    max_new_tokens=50,
)
#'<s> Write a short story. Possible Story: the Story there One.t day. Back
 the, went to: her they Possible|. to and a said saw They:. be the She.. a. 
to They. they. to and to for He was a in with',',

for i in GLOBAL_KEEP_TRACK:
    print(i)
#SimpleFeedForwardNN 18547809 Params | Train: 4.129 | Val: 4.458
#SimpleLlama 187827210 Params | Train: 6.216 | Val: 6.046

1 新

figure

图 9.3 在我们的数据集上训练简单的 Llama 以生成文本

因此,我们已经将参数数量提高了 10 倍,超过了 1.8 亿个。但这真的给了我们我们期望的涌现行为吗?如果你看看生成的文本,它在猜测标点符号方面有所改进,但几乎都没有在正确的位置。损失也更高了,但我们并不特别担心这一点;如果我们改进数据加载,并允许模型将整个数据集遍历两到三次,那么损失应该会降低。最后,如果我们通过增加上下文窗口和层数,以及增加数据集中的标记数量来使模型更大,我们应该能够得到那种涌现行为。对于这个数据集和配置,你需要训练大约 1,900 次才能遍历一次数据集,所以你需要训练大约 6,000 次才能开始充分利用整个数据集。

由于时间和资源的缺乏,我们不会担心我们的模型不在任何排行榜的顶端。实际上,它甚至不够好,无法进入任何一个排行榜。但我们已经创建了一个类似于 Llama 的简单模型,而且是从零开始的。这个练习让我们对过程有了深入了解,你应该对如何改进它有一个概念。考虑到这些,让我们讨论如何将我们创建的模型投入生产。

9.3 让它变得更好

现在我们已经有一个模型,并且它通过了我们所有的内部基准测试(我们假装我们有一些),现在是时候部署这个模型并看看它在与客户互动时的表现了。哦不!我们之前的内部测试并不能代表我们的生产环境!我们的第一个问题是模型太大太慢,甚至无法通过生产环境测试。模型本身通常被视为成功的主要成分。相比之下,我们围绕模型构建的系统,包括数据,却被忽视,因为“任何人都可以雇佣一个优秀的 MLE 来制作这些。”不幸的是,这就是一些公司成功而其他公司失败的秘密配方。

我们想感谢那些急于在评论和 GitHub 问题中提出意见的人,因为这个模型不起作用,这不是本章的目的,我们想引导你们去关注像 Abi Aryan、Sebastian Raschka 和其他人,他们正在介绍预训练 LLMs 的数据科学。

注意:如果你想要预训练一个能够生成优质内容的因果语言模型,还有其他一些优秀的资源可供选择。查看以下项目以获取有关预训练你自己的模型的更多信息:Llama 3 (mng.bz/BgAw)、Megatron LM (mng.bz/dZdg)、Hugging Face 教程 (mng.bz/V2RN) 和 Llama2.c (mng.bz/x6j7)。

在继续提供以数据科学家为中心的生产建议的精神下,我们现在将介绍如何使你的模型在部署后更容易使用并更有效。一旦数据科学家训练好模型并通过了效验测试,就需要考虑模型的大小。

9.3.1 量化

你将肯定面临的首要问题是纯粹的大小。我们的 180M 参数模型在磁盘上超过 700 MB,这比一些公司计划用于任何用例的都要大得多。你如何确保它足够小且足够快,以便在 AWS Lambda 或仅 CPU 实例上运行?压缩是我们帮助解决此问题的方法之一,而量化是 PyTorch 内置的功能!正如我们之前所述,你应该熟悉 BitsandBytes,但让我们看看一个快速实现,使用 torch 在训练后对模型进行量化。

在下一个列表中,我们使用我们的模型,并通过 PyTorch 对模型进行 INT8 量化。其余的代码和函数只是简单地比较模型在量化和未量化前后的尺寸。重要的是只有前几行。

列表 9.5 量化
llama.to("cpu")
qconfig_dict = {
    torch.nn.Embedding: torch.quantization.float_qparams_weight_only_qconfig,
    torch.nn.Linear: torch.quantization.default_dynamic_qconfig,
}
dynamic_quantized_llama = torch.quantization.quantize_dynamic(    #1
    llama, qconfig_dict, dtype=torch.qint8
)
#SimpleLlama size: 716.504MB
#SimpleLlama size: 18.000MB

1 训练后动态量化

你可以看到,通过仅进行 INT8 量化,我们的磁盘空间从近 1 GB 减少到 18 MB。我们甚至可以进一步降低,³,这可以帮助你将几乎任何模型适应所选的生产环境;但请记住,随着权重的压缩,困惑度会增加,导致 LLM 的性能不稳定且难以预测,即使有出色的提示工程。

因此,现在模型已经足够小,MLOps 团队将其放入开发环境,所有测试都通过了,所以我们的模型最终进入了生产环境。一切顺利,对吧?

9.3.2 LoRA

当一个月后,我们发现模型无法达到其环境标准执行特定任务时,我们该怎么办?我们遇到了数据漂移,由于我们是初创公司,我们没有足够的资金和时间去通过之前训练模型时经历的严格训练过程。还有一个更大的问题:我们没有足够的新数据来展示新的分布,以有效地微调模型。这种情况非常适合训练 LoRA 来调整模型,而不是花费大量时间重新训练。

列表 9.6 展示了如何训练 LoRA 模型以及我们需要对我们 Llama 模型进行的调整。这个列表首先展示了添加 LoRA 后输入如何在模型中移动。LoRALayer类由 Sebastian Raschka 和 Lightning.AI 以清晰的 PyTorch 术语展示,并且他们有更深入的研究仓库(见github.com/rasbt/dora-from-scratchmng.bz/Aa8e)。接下来,它展示了添加 LoRA 后我们的SimpleLlama类发生了什么变化。最后,我们将使用新的指令数据集和新的get_batches函数进行类似的训练过程。作为备注,我们在整个列表中使用了几个辅助函数来简化它;您可以在本书附带的仓库中找到它们的定义。

列表 9.6 低秩自适应
class LoRALayer(nn.Module):                            #1
    def __init__(self, in_dim, out_dim, rank, alpha):
        super().__init__()
        standard_deviation = 1 / torch.sqrt(torch.tensor(rank).float())
        self.A = nn.Parameter(
            torch.randn(in_dim, rank) * standard_deviation
        )
        self.B = nn.Parameter(torch.zeros(rank, out_dim))
        self.alpha = alpha

    def forward(self, x):
        x = self.alpha * (x @ self.A @ self.B)
        return x

class LinearWithLoRA(nn.Module):
    def __init__(self, linear, rank, alpha):
        super().__init__()
        self.linear = linear
        self.lora = LoRALayer(
            linear.in_features, linear.out_features, rank, alpha
        )
    def forward(self, x):
        return self.linear(x) + self.lora(x)

class LlamaBlock(nn.Module):    #2
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.rms = RMSNormalization(
            (config["d_model"], config["d_model"])
        ).to(device)

        self.attention = RoPEMaskedMultiheadAttention(config).to(device)
        self.feedforward = nn.Sequential(
            LinearWithLoRA(config["d_model"], config["d_model"]),   #3
            SwiGLU(config["d_model"]),
        ).to(device)

    def forward(self, x):
        x = self.rms(x)
        x = x + self.attention(x)

        x = self.rms(x)
        x = x + self.feedforward(x)
        return x

class SimpleLlama(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.embedding = nn.Embedding(
            config["vocab_size"], config["d_model"]
        )
        self.llama_blocks = nn.Sequential(
            OrderedDict(
                [
                    (f"llama_{i}", LlamaBlock(config))
                    for i in range(config["n_layers"])
                ]
            )
        )

        self.ffn = nn.Sequential(
            LinearWithLoRA(config["d_model"], config["d_model"]),        #4
            SwiGLU(config["d_model"]),
            LinearWithLoRA(config["d_model"], config["vocab_size"]),     #5
        )

        print(
            f"model params: {sum([m.numel() for m in self.parameters()])}"
        )

    def forward(self, idx, targets=None):
        x = self.embedding(idx)
        x = self.llama_blocks(x)
        logits = self.ffn(x)

        if targets is None:
            return logits

        else:
            loss = F.cross_entropy(
                logits.view(-1, self.config["vocab_size"]),
                targets.view(-1),
                ignore_index=tokenizer.pad_token_id,
                reduction="sum",
            )
            return logits, loss

dataset = load_dataset(     #6
    "text",
    data_files={
        "train": ["../../data/Lima-train.csv"],
        "val": ["../../data/Lima-test.csv"],
    },
    streaming=True,
)

encoded_dataset = dataset.map(
    lambda examples: tokenizer(
        examples["text"],
        padding=True,
        max_length=128,
        truncation=True,
       \]] return_tensors="pt",
    ),
    batched=True,
)
train_data = iter(encoded_dataset["train"].shuffle())
val_data = iter(encoded_dataset["val"].shuffle())
train_data = cycle(train_data)
val_data = cycle(val_data)

llama.to("cpu")          #7
add_lora(llama)
llama.to(device)

parameters = [{"params": list(get_lora_params(llama))}]     #8
lora_optimizer = torch.optim.AdamW(parameters, lr=1e-3)     #9

train(      #10
    llama,
    lora_optimizer,
    scheduler,
    data=train_data,
    config=MASTER_CONFIG,
    lora=True,
    print_logs=True,
)

state_dict = llama.state_dict()        #11
lora_state_dict = {k: v for k, v in state_dict.items() if name_is_lora(k)}
torch.save(llama.state_dict(), "./llama.pth")
torch.save(lora_state_dict, "./lora.pth")

1 LoRA 实际上做什么?

2 展示了块如何变化

3 新增

4 新增

5 新增

6 新的 LoRA 数据集

7 步骤 1:将 LoRA 添加到训练好的模型中

8 步骤 2:获取 LoRA 参数而不是整个模型

9 步骤 3:使用 LoRA 参数初始化优化器

10 步骤 4:训练

11 步骤 5:导出参数

所有这些导致我们有两个单独的状态字典需要保存:模型和 LoRA。您可以为各种特定任务训练 LoRA,对于这些任务,您可能没有足够大的数据集来证明整个微调的合理性。磁盘上的 LoRA 文件通常只有几千字节,即使是对于非常大的模型,这取决于秩的大小(在我们的例子中,是 16)。

您可以使用 LoRA 进行推理,通常有两种方式:您可以选择(1)加载原始模型的状态字典(在我们的例子中,它被加载在llama变量中),然后在上面加载 LoRA,然后像平常一样进行推理,或者(2)将所有的 LoRA 层合并到原始 Llama 中,从而本质上创建一个新的模型并正常推理。在这里,我们采用了第二种选项。

##### 列表 9.7 低秩自适应

# Loading and Inferencing with LoRA
add_lora(llama)

_ = llama.load_state_dict(lora_state_dict, strict=False)

merge_lora(llama)

generate(llama)

生成的文本是

#'<s> off It the played he had cry bird dayt didn pretty Jack. a she moved
day to play was andny curiousTC bandierungism feel But'

我们可以看到,文本仍然没有达到我们期望的连贯性;然而,与简单的 Llama 相比,我们可以看到生成文本的明显变化。不再有过于热情的标点符号、“哭泣”和其他不愉快的短故事词汇,而且有更多明显是虚构的词汇。如果您在更明显的集合上训练——比如说莎士比亚——您将能够更清楚地看到差异,而且 LoRA 的好处是您只需简单地调用remove_lora()就可以恢复原始功能。

9.3.3 完全分片数据并行-量化 LoRA

基于 LoRA,量化 LoRA(QLoRA)允许对大于你 GPU 的模型进行高效的微调。它是通过量化模型,然后在量化模型的冻结版本上训练 LoRA 来实现的。当你考虑到微调全尺寸模型所需的内存量时,这种技术是可取的,即使在半精度下也是如此。正如我们之前讨论的,一个 70B 参数的模型在磁盘上最终会变成 140 GB,并且由于数据集和梯度,微调它将需要超过五倍那么多的内存。使用 QLoRA,我们可以在只有 48 GB 的 VRAM 上训练高达 65B 参数的模型——这是一个非常明显的减少。QLoRA 目前是将一个荒谬大的模型用于生产并实现其用例的最有效方式,而且这个过程还能节省大量资金。

加入完全分片的数据并行(FSDP),你就可以打破消费者与企业的界限。你们中的一些人可能一直在问并行处理一直去哪里了,现在它就在这里。FSDP 允许在整个训练过程中在多个 GPU 上进行数据和模型参数的并行处理,并且当顺序和规模很重要时,它还负责分片以及另一端的重新连接。这是来自维护 PyTorch 的团队的一项惊人的工作。

以前,在 70B 参数模型上使用 QLoRA 需要 48 GB 的内存,这仅适用于像 A100 这样的企业级 GPU。有了 FSDP,你可以充分利用消费级硬件上的并行性,比如两个 3090,以获得相同的结果。FSDP 是 PyTorch 的原生功能!与本章中我们之前的努力不同,我们现在将抽象化由 Jeremy Howard 和 Answer.AI 创建的脚本,这样你就可以在 7B 参数模型的一个单元中运行它。你不需要克隆整个 GitHub 仓库,你可以从 PyPI 安装并导入 fsdp_qlora,我们还在 train_utils 文件夹中重新创建了可导入的类。此代码将在你拥有的所有 GPU 上执行完全并行的 QLoRA 训练。

列表 9.8 FSDP-QLORA 训练
from train_utils import FSDP_QLORA

trainer = FSDP_QLORA(
    model_name='meta-llama/Llama-2-7b-hf',
    batch_size=2,
    context_length=2048,
    precision='bf16',
    train_type='qlora',
    use_gradient_checkpointing=True,
    dataset='guanaco',
    reentrant_checkpointing=True,
    save_model=True,
    output_dir=”.”
)

trainer.train_qlora()

运行结果是一个完全微调的 safetensors 模型文件,使用量化权重和并行性进行训练。与我们的定制预训练版本不同,这个版本是可行的。safetensors 文件包含一个训练模型的 state dict 文件,类似于我们为 SimpleLlama 保存的状态 dict。这两个状态 dict 都需要在上传到像 Hugging Face 这样的地方之前转换为完整的模型文件或完整的检查点文件;否则,像 AutoModelLlamaForCausalLM 这样的类将无法在以后加载你的模型。

9.4 部署到 Hugging Face Hub 空间

空间是托管容器,您可以将模型放入其中以允许社区访问,而且它们可以根据您的需求提供更多功能。空间可以是公司部署整个模型的地方,而不是其他云托管选项。空间提供免费层和许多付费层,具体取决于您的特定应用程序的计算密集程度。空间可以无缝集成到最受欢迎的机器学习前端框架中,例如 Streamlit、Gradio 和 FastAPI。

注意:我们不会在这里给出这些机器学习前端框架的示例,因为我们已经在之前的章节中给出了,但我们确实在笔记本中包含了一个本章节的示例应用程序。为了参考,请查看 Gradio (www.gradio.app/guides/quickstart) 和 Hugging Face (huggingface.co/docs/hub/spaces) 的文档。

对于我们的模型,我们需要将它们的权重和目录转换为可以轻松推送到 Hugging Face Hub 的格式。我们有一个易于修改的脚本,您可以使用它来完成此转换。您也可以在之前简单训练的 Llama LoRA 上运行此脚本。

列表 9.9 为 Hugging Face 转换权重
from safetensors import safe_open
import torch
from transformers import LlamaForCausalLM, BitsAndBytesConfig
from peft import get_peft_model, LoraConfig, TaskType

tensors = {}
with safe_open(
    "qlora_output/model_state_dict.safetensors",
    framework="pt",
    device=0
) as f:
    for k in f.keys():
        tensors[k] = f.get_tensor(k)

for k in tensors:
    if 'lora' not in k: tensors[k] = None

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=False,
    bnb_4bit_compute_dtype=torch.bfloat16
)
model = LlamaForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    use_cache=False,
    quantization_config=bnb_config
)

for param in model.parameters():
    param.requires_grad = False

peft_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    inference_mode=False,
    r=64,
    lora_alpha=16,
    lora_dropout=0.1,
    target_modules=[
        "k_proj",
        "q_proj",
        "v_proj",
        "up_proj",
        "down_proj",
        "gate_proj"
    ]
)
model = get_peft_model(model, peft_config)

list(model.state_dict().keys())[:10]

new_sd = model.state_dict()
for k in new_sd:
    if 'lora' in k:
        new_sd[k] = tensors[k]

model.load_state_dict(new_sd)

model.save_pretrained("lora_adapters")

如果您已经有了仓库并且已登录到您的 Hugging Face 账户,您可以继续运行 model.push_to_hub()。如果您的模型尚未存在,这将为您创建一个仓库。您是否将模型推送到 Hub 取决于您是否希望与世界分享您的模型。如果您更愿意有一个空间,其他人可以尝试您的模型(甚至免费),我们将在下一节展示如何做到这一点。

对于空间,需要做出的第一个决定是您的应用程序需要多少计算资源以及您将如何维护空间的代码——使用 Git 或使用 huggingface-cli。第一个问题始于是否需要为您的特定用例使用 GPU;对于我们的用例,不需要。然而,当您需要提高速度或规模时,您可能需要 GPU,尤其是在您需要多进程以从空间中获得更多性能时。一旦您有了应用程序并且已经确定了您的内存需求,如果您决定使用 Git,您将在 Hugging Face 上创建您的空间,然后您将以与 GitHub 上相同的方式克隆它:

$ git clone https://huggingface.co/spaces/your-username/your-space

添加、提交和推送的操作也是一样的:

$ git add files-you-need
$ git commit -m "Initial Commit"
$ git push

如果您不是通过 CLI 来做这件事,以下列表显示了如何操作。

列表 9.10 Hugging Face Space
%pip install huggingface_hub -q

from huggingface_hub import notebook_login, HfApi

notebook_login() #OR huggingface-cli login

api = HfApi()
api.create_repo(         #1
    repo_id="your_username/your_repo", repo_type="space", space_sdk="gradio"
)

stuff_to_save = [
    "llama.pth",# Your model
    "lora.pth",# Optional: Your LoRA
    "special_tokens_map.json",
    "tokenizer_config.json",
    "tokenizer.json",
    "tokenizer.model",
    "gradio_app.py",
]
for thing in stuff_to_save:
    api.upload_file(
        path_or_fileobj=f"./llama2/{thing}",
        path_in_repo=thing,
        repo_id="your_username/your_repo",
        repo_type="space",
    )

1 如果您还没有创建您的仓库

Hugging Face Spaces

我们目前拥有的模型需要 GPU 来加载(尤其是量化)和运行。如果您尝试在 HF Spaces 的免费层上运行,它将出错,就像我们遇到的情况一样。您可以通过升级到付费层或 ZeroGPU 来修复此问题。Hugging Face 提供了一个使用其自己的 API 的 Gradio 应用程序版本,仅用于为完成任务所需的时间分配 GPU,并且仅在请求时分配。请参阅 mng.bz/XV11

作为一项练习,我们鼓励您思考并构建出您可能如何使用我们的 LLM 创建一个在免费层上运行的 Hugging Face Space,这比我们最初写这篇文章时容易得多,多亏了 ZeroGPU。

由此,我们得到了任何想要使用或训练的模型的完全功能化的托管实例。您可以在 Space 中运行我们训练的两个 Llama 模型中的任何一个,但您可能需要根据您的需求进行一些工程处理。如果您在自己的环境中运行了所有这些代码并完成了第一个项目,那么恭喜您!这是最密集的章节之一,能够通过一个工作示例而感到自豪。Hugging Face 为希望长期使用 Spaces 的企业提供私有解决方案,这是一个完全可行的生产环境。

摘要

  • 选择合适的分词器和嵌入策略是您在从头创建模型时将做出的第一个关键决策之一,因为它决定了模型将看到什么,因此也决定了模型的能力。

  • 您独特的源数据可以为您的模型提供未来保障。

  • Llama 与简单的前馈之间的主要区别在于归一化、注意力、激活层和层数的数量。

  • 通常,将 LLM 产业化面临的第一大挑战是其大小:量化技术可以拯救!

  • 在生产环境中,您需要更新模型只是时间问题。LoRA 和 QLoRA 是对您的模型进行小幅度调整的完美解决方案。

  • 完全分片数据并行使我们能够在消费级硬件上以低成本训练 QLoRA 模型。

  • 由于使用简便,Hugging Face Hub Spaces 是部署和分享您的 LLM 项目的一个很好的选择。

[1] H. Touvron 等人,“Llama 2:开放基础和微调聊天模型,” arXiv.org,2023 年 7 月 19 日,arxiv.org/abs/2307.09288

[2] ai.meta.com/blog/meta-llama-3/, arxiv.org/pdf/2307.09288, arxiv.org/pdf/2302.13971.

[3] S. Ma 等人,“1 位 LLM 时代:所有大型语言模型都在 1.58 位中,” arXiv.org,2024 年 2 月 27 日,arxiv.org/abs/2402.17764。**

第十章:创建一个编码助手项目:这会早些时候帮到您

本章涵盖

  • 将编码模型部署到 API

  • 在本地设置 VectorDB 并使用它作为检索增强生成系统

  • 构建一个 VS Code 扩展来使用我们的 LLM 服务

  • 从项目中获得的见解和经验教训

进步不是来自早起的人——进步是由寻找更简单做事方式的懒惰人创造的。——罗伯特·海因莱因

如果您在工作中接触代码,您可能梦想过有一个 AI 助手来帮助您。实际上,也许您已经这样做了。随着市场上出现像 GitHub Copilot 这样的工具,我们已经看到 LLM 将自动完成提升到了新的水平。然而,并不是每个公司都对市场上的产品感到满意,也不是每个爱好者都能负担得起。所以,让我们自己动手吧!

在本章中,我们将构建一个 Visual Studio Code (VS Code) 扩展,使我们能够在代码编辑器中使用我们的 LLM。首选的编辑器将是 VS Code,因为它是一个流行的开源代码编辑器。流行可能是一个低估,因为 Stack Overflow 2023 开发者调查表明它是 81% 开发者的首选编辑器。¹ 它基本上是 Visual Studio 的轻量级版本,Visual Studio 是一个自 1997 年以来一直存在的完整 IDE。

除了选择一个特定的编辑器,我们还将做出一些明智的决定来限制项目的范围,使其更有意义。例如,在上一个项目中,我们专注于构建一个可以部署的出色的 LLM 模型。在这个项目中,我们将从已经训练过编码问题的开源模型开始。为了定制它,我们不会微调,而是围绕它构建一个 RAG 系统,这将使我们更容易保持其更新。此外,由于我们不会训练自己的模型,我们将专注于构建一个擅长 Python 的助手,这是我们在整本书中使用的语言,而不用担心其他所有语言。

现在我们已经清楚地知道了我们要构建什么,并且有一个目标在心中,让我们开始吧!

10.1 我们的模型

由于我们只关注 Python,我们决定使用 DeciCoder 作为我们的模型。DeciCoder 是一个只有 1B 参数的商业开源模型。² 尽管它的体积很小,但它确实擅长它所做的事情。它是在 Stack 数据集上训练的,但过滤后只包含 Python、Java 和 JavaScript 代码。它只训练了三种语言,这通常是一个限制,但实际上它是它之所以如此出色的秘密之一。

一些其他需要注意的限制是,它只有一个 2,048 个标记的上下文窗口,对于一个这个规模的模型来说并不差,但当我们考虑到我们计划使用 RAG 系统,并需要给它提供代码示例时,它就显得相对较小了。代码示例通常相当大,这限制了我们可以做的事情和我们可以提供的示例数量。

使用 RAG 与 DeciCoder 的大问题是模型没有进行指令微调。相反,它是为了击败 HumanEval 数据集(github.com/openai/human-eval)而设计的。在这个评估数据集中,模型只给出一个函数名和描述函数应该做什么的文档字符串。仅从这个输入,模型将生成可执行的代码来完成函数。因此,很难知道从 RAG 系统中提供更多上下文是否会帮助模型,但我们将继续尝试找出答案!

最后,它的小巧体积实际上使它成为另一个有趣的选择。因为它如此小巧,我们有可能将模型直接放入我们正在构建的 VS Code 扩展程序中,使用我们在其他章节中讨论的编译方法。这将使我们能够构建一个非常紧凑的应用程序!我们在这本书中不会这样做,主要是因为这将要求我们编写大量的 JavaScript。这是一个问题,因为我们只期望我们的读者熟悉 Python,所以在这里深入解释细节有点过于冒险,但我们将其留作对 JavaScript 高手读者的练习。

我们将要做的是将我们的模型作为 API 提供服务,您可以在本地运行它,并且可以从扩展程序中调用它。在列表 10.1 中,我们创建了一个简单的 FastAPI 服务来提供我们的模型。实际上,您在第六章中已经看到了大部分代码,我们只做了一些小的修改。第一个修改是我们将代码更改为使用 DeciCoder 模型和分词器。第二个修改稍微复杂一些,但我们添加了 stop 标记。这些标记会通知模型在遇到它们时停止生成。这是通过创建一个 StoppingCriteria 类来实现的。我们选择的标记在我们定义了提示后会有更多的意义,但本质上,我们希望模型一次创建一个函数。

列表 10.1 使用 DeciCoder 的简单 FastAPI 端点
import argparse

from fastapi import FastAPI, Request
from fastapi.responses import Response
import torch
import uvicorn

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    StoppingCriteria,
    StoppingCriteriaList,
)

torch.backends.cuda.enable_mem_efficient_sdp(False)    #1
torch.backends.cuda.enable_flash_sdp(False)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

stop_tokens = ["def", "class", "Instruction", "Output"]     #2
stop_token_ids = [589, 823, 9597, 2301]

class StopOnTokens(StoppingCriteria):
    def __call__(
        self,
        input_ids: torch.LongTensor,
        scores: torch.FloatTensor,
        **kwargs,
    ) -> bool:
        stop_ids = stop_token_ids
        for stop_id in stop_ids:
            if input_ids[0][-1] == stop_id:
                return True
        return False

tokenizer = AutoTokenizer.from_pretrained("Deci/DeciCoder-1b")    #3
tokenizer.add_special_tokens( #3
    {"additional_special_tokens": stop_tokens}, #3
    replace_additional_special_tokens=False, #3
) #3
model = AutoModelForCausalLM.from_pretrained( #3
    "Deci/DeciCoder-1b", torch_dtype=torch.bfloat16, trust_remote_code=True #3
) #3
model = model.to(device) #3

app = FastAPI()     #4

@app.post("/generate")
async def generate(request: Request) -> Response:
    """Generate LLM Response

    The request should be a JSON object with the following fields:
    - prompt: the prompt to use for the generation.
    """
    request_dict = await request.json()
    prompt = request_dict.pop("prompt")

    # ...      #5

    inputs = tokenizer(prompt, return_tensors="pt").to(device)    #6
    response_tokens = model.generate(
        inputs["input_ids"],
        max_new_tokens=1024,
        stopping_criteria=StoppingCriteriaList([StopOnTokens()]),
    )
    input_length = inputs["input_ids"].shape[1]
    response = tokenizer.decode(
        response_tokens[0][input_length:], skip_special_tokens=True
    )

    return response

if __name__ == "__main__":
    parser = argparse.ArgumentParser()     #7
    parser.add_argument("--host", type=str, default=None)
    parser.add_argument("--port", type=int, default=8000)
    args = parser.parse_args()

    uvicorn.run(app, host=args.host, port=args.port, log_level="debug")

1 火炬设置

2 定义停止行为

3 加载分词器和模型

4 运行 FastAPI

5 RAG 将在这里。

6 生成响应

7 启动服务;默认端口为 8000

假设这个列表在一个名为 server.py 的 Python 脚本中,您可以通过运行 $ python server.py 来启动服务器。一旦它启动并运行,让我们发送一个请求来确保它正确工作。在一个新的终端中,我们可以使用简单的提示向 API 发送一个 curl 请求:

$ curl --request POST --header "Content-Type: application/json" --data 
↪ '{"prompt":"def hello_world(name):"}' http://localhost:8000/generate

响应应该是一个简单的 Python 函数,用于完成“Hello World”函数。我们从服务器收到的响应是 return f"Hello {name}!"。到目前为止,一切顺利!接下来,我们将定制 API 以利用 RAG 系统。

10.2 数据为王

现在我们已经决定了一个模型,让我们为我们的 RAG 系统准备一个数据集。RAG 是在不需要微调模型的情况下向模型引入上下文的有效方法;它还允许我们根据我们的数据自定义结果。本质上,如果你想让你的模型了解你组织不断变化的代码库的上下文,RAG 是一个很好的系统。有一个擅长编码的模型很好,但我们希望它擅长 我们的 代码。我们希望它使用正确的变量名和导入内部构建的定制依赖项——诸如此类的事情。在本节中,我们将设置一个 VectorDB,上传一个 Python 编码数据集,然后更新我们刚刚构建的 API 以利用它。

10.2.1 我们的 VectorDB

在我们真正深入数据集之前,我们首先需要设置我们的基础设施。当然,如果你的数据集足够小,你可以将其加载到内存中,并使用 Faiss 或 USearch 等工具直接在 Python 中运行相似度搜索,但那样有什么乐趣呢?此外,我们还想向你展示 Milvus。

Milvus 是一个出色的开源 VectorDB,与这个领域的大玩家竞争。你可以在本地或大型云集群上运行它,因此它可以很容易地扩展到你的需求。如果你不想处理设置,还有可管理的 Milvus 集群可供选择。我最喜欢的功能是其支持 GPU 的版本,这使得向量搜索变得非常快。

幸运的是,社区也使 Milvus 非常易于接近和设置。事实上,独立版本只需要 Docker 来运行,并附带一个启动脚本,使其更加容易。由于我们将在本项目中本地运行一切,我们将使用独立版本(了解更多信息,请参阅 mng.bz/aVE9)。为此,我们需要在终端中运行以下命令:

$ wget https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/
↪ standalone_embed.sh
$ bash standalone_embed.sh start

第一条命令将会下载一个 shell 脚本,第二条命令将会运行它。这个脚本实际上只是为了方便,因为 Docker 的 run 命令相当长。它还包括两个你应该了解的额外命令。Stop 命令,它将停止你的 Milvus Docker 容器,是

$ bash standalone_embed.sh stop

delete 命令,当你不再希望保留数据时,将会从你的电脑中删除所有数据,它是

$ bash standalone_embed.sh delete

你现在不需要运行这些命令,但记住它们,等我们完成后再用。现在我们已经设置了数据库,让我们让它变得有用,并将一些数据加载到其中。

10.2.2 我们的数据集

如果这是一个研讨会,我们会向你展示如何编写一个脚本来从 GitHub 拉取你组织的代码,并使用它来增强你的提示。我们甚至可以设置一个 GitHub Actions 流水线,以便每次代码合并到主分支时更新我们的 VectorDB。但由于我们没有访问你的代码,这只是一个书籍,我们将做合理的事情并使用开源数据集。

我们将为我们的项目选择 Alpaca 数据集。Alpaca 数据集是由斯坦福大学在训练同名模型时编译的,使用了蒸馏和 GPT-3 作为导师模型。由于它是合成数据,数据集非常干净,这使得它易于处理。事实上,它如此简单,以至于网上的多个副本已经过滤掉了所有的 Python 代码示例。这个子集包含 18.6K 个 Python 编码挑战,包括一个任务或指令和生成的代码——非常适合我们想要达成的目标。

在列表 10.2 中,我们创建我们的管道来将数据集加载到 Milvus 中。我们创建了一个PythonCodeIngestion类来处理我们的数据集分块和批量上传的细节。请注意,我们使用了krlvi/sentence-t5-base-nlpl-code_search_ net嵌入模型。这个嵌入模型已经在 CodeSearchNet 数据集(github.com/github/CodeSearchNet)上进行了专门训练,非常适合创建代码的有意义嵌入。

列表 10.2:摄取 Alpaca 的数据管道
from pymilvus import (
    connections,
    utility,
    FieldSchema,
    CollectionSchema,
    DataType,
    Collection,
)

from transformers import AutoTokenizer
from datasets import load_dataset
from langchain.text_splitter import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer

from tqdm.auto import tqdm
from uuid import uuid4

connections.connect("default", host="localhost", port="19530")    #1

class PythonCodeIngestion:
    def __init__(
        self,
        collection,
        python_code=None,
        embedder=None,
        tokenizer=None,
        text_splitter=None,
        batch_limit=100,
    ):
        self.collection = collection
        self.python_code = python_code or load_dataset(
            "iamtarun/python_code_instructions_18k_alpaca",
            split="train",
        )
        self.embedder = embedder or SentenceTransformer(
            "krlvi/sentence-t5-base-nlpl-code_search_net"
        )
        self.tokenizer = tokenizer or AutoTokenizer.from_pretrained(
            "Deci/DeciCoder-1b"
        )
        self.text_splitter = (
            text_splitter
            or RecursiveCharacterTextSplitter(
                chunk_size=400,
                chunk_overlap=20,
                length_function=self.token_length,
                separators=["\n\n", "\n", " ", ""],
            )
        )
        self.batch_limit = batch_limit

    def token_length(self, text):
        tokens = self.tokenizer.encode(text)
        return len(tokens)

    def get_metadata(self, page):
        return {
            "instruction": page["instruction"],
            "input": page["input"],
            "output": page["output"],
        }

    def split_texts_and_metadatas(self, page):
        basic_metadata = self.get_metadata(page)
        prompts = self.text_splitter.split_text(page["prompt"])
        metadatas = [
            {"chunk": j, "prompt": prompt, **basic_metadata}
            for j, prompt in enumerate(prompts)
        ]
        return prompts, metadatas

    def upload_batch(self, texts, metadatas):
        ids = [str(uuid4()) for _ in range(len(texts))]
        embeddings = self.embedder.encode(texts)
        self.collection.insert([ids, embeddings, metadatas])

    def batch_upload(self):
        batch_texts = []
        batch_metadatas = []

        for page in tqdm(self.python_code):
            texts, metadatas = self.split_texts_and_metadatas(page)

            batch_texts.extend(texts)
            batch_metadatas.extend(metadatas)

            if len(batch_texts) >= self.batch_limit:
                self.upload_batch(batch_texts, batch_metadatas)
                batch_texts = []
                batch_metadatas = []

        if len(batch_texts) > 0:
            self.upload_batch(batch_texts, batch_metadatas)

        self.collection.flush()

1 连接到 Milvus

现在我们已经创建了我们的摄取类,我们可以继续进行管道操作。首先,如果这是我们第一次运行它,我们需要创建我们的集合。集合在其他数据库中就像一个表,或者在 Pinecone 中就像一个索引。我们将定义我们的模式,它只是一个 ID 字段、我们的嵌入字段和一个元数据字段,该字段包含自由形式的 JSON。一旦设置好,我们将使用我们的PythonCodeIngestion类上传我们的数据。

接下来,我们需要创建我们的搜索索引。我们将使用的索引类型是IVF_FLAT,这是 Milvus 中最基本的索引,将嵌入空间分割成nlist数量的聚类。这通过首先将我们的搜索嵌入与聚类中心进行比较,然后与它最近的聚类中的嵌入进行比较来加速相似性搜索。我们还将使用L2作为我们的度量类型,这意味着我们将使用欧几里得距离。这些是常见的设置,但对我们来说不需要任何特殊设置。Milvus 在构建索引时支持更多的选项,我们鼓励您查看他们的文档:

if __name__ == "__main__":
    collection_name = "milvus_llm_example"
    dim = 768

    if utility.has_collection(collection_name):     #1
        utility.drop_collection(collection_name)

    fields = [
        FieldSchema(
            name="ids",
            dtype=DataType.VARCHAR,
            is_primary=True,
            auto_id=False,
            max_length=36,
        ),
        FieldSchema(
            name="embeddings", dtype=DataType.FLOAT_VECTOR, dim=dim
        ),
        FieldSchema(name="metadata", dtype=DataType.JSON),
    ]

    schema = CollectionSchema(
        fields, f"{collection_name} is collection of python code prompts"
    )

    print(f"Create collection {collection_name}")
    collection = Collection(collection_name, schema)

    collection = Collection(collection_name)    #2
    print(collection.num_entities)

    python_code_ingestion = PythonCodeIngestion(collection)    #3
    python_code_ingestion.batch_upload()
    print(collection.num_entities)

    search_index = {                #4
        "index_type": "IVF_FLAT",
        "metric_type": "L2",
        "params": {"nlist": 128},      #5
    }
    collection.create_index("embeddings", search_index)

1 如果不存在则创建集合

2 连接到集合并显示其大小

3 数据摄取并显示数据摄取后的统计数据

4 构建搜索索引

5 聚类的数量

现在我们已经设置好了一切,我们可以继续下一步。但在那之前,让我们通过运行一个查询来测试它。我们想要确保我们的数据和索引给我们提供了合理的搜索结果。使用 Milvus,我们首先将集合加载到内存中,并使用我们的嵌入器将我们的查询转换为嵌入。接下来,我们将定义一些搜索参数。再次,L2代表欧几里得距离,而nprobe参数表示要搜索的簇数量。在我们的情况下,在 128 个我们设置的簇中,我们将搜索与我们的查询嵌入最近的 10 个簇。最后,在实际搜索中,我们将结果限制为三个最佳匹配,并返回与我们的查询一起的元数据字段:

    collection.load()      #1

    query = (             #2
        "Construct a neural network model in Python to classify "
        "the MNIST data set correctly."
    )
    search_embedding = python_code_ingestion.embedder.encode(query)
    search_params = {
        "metric_type": "L2",
        "params": {"nprobe": 10},     #3
    }
    results = collection.search(
        [search_embedding],
        "embeddings",
        search_params,
        limit=3,
        output_fields=["metadata"],
    )
    for hits in results:
        for hit in hits:
            print(hit.distance)
            print(hit.entity.metadata["instruction"])

1 在进行搜索之前,你需要将数据加载到内存中。

2 进行查询

3 搜索的簇数量

你可以看到,对于我们的查询,搜索结果正在返回来自我们数据集的强候选者:

    # 0.7066953182220459
    # Create a neural network in Python to identify
    # hand-written digits from the MNIST dataset.
    # 0.7366453409194946
    # Create a question-answering system using Python
    # and Natural Language Processing.
    # 0.7389795184135437
    # Write a Python program to create a neural network model that can
    # classify handwritten digits (0-9) with at least 95% accuracy.

现在我们已经设置了我们的 VectorDB 并加载了数据,让我们更新我们的 API 以从我们的 RAG 系统中检索结果并将上下文注入到我们的提示中。

10.2.3 使用 RAG

在本节中,我们将更新列表 10.1 以包含我们的检索代码。在列表 10.3 中,我们不会重复之前所做的所有事情,考虑到时间和空间,我们只需展示需要添加的新部分。在伴随本书的仓库中,如果你在理解哪些部分应该放在哪里时遇到困难,你将能够找到将一切组合在一起的代码。首先,在脚本接近顶部的地方,我们需要添加我们的导入,连接到我们的 Milvus 服务,并加载我们的嵌入模型。

列表 10.3 将 RAG 添加到我们的 API
from contextlib import asynccontextmanager

from pymilvus import (
    connections,
    Collection,
)
from sentence_transformers import SentenceTransformer
connections.connect("default", host="localhost", port="19530")    #1

collection_name = "milvus_llm_example"
collection = Collection(collection_name)

embedder = SentenceTransformer(                  #2
    "krlvi/sentence-t5-base-nlpl-code_search_net"
)
embedder = embedder.to(device)

1 连接到 Milvus

2 加载我们的嵌入模型

接下来,我们将添加一些便利函数,包括一个标记计数器和 FastAPI 生命周期,以确保我们加载和释放我们的 Milvus 集合从内存。由于我们添加了一个生命周期,请确保更新 FastAPI 调用:

def token_length(text):
    tokens = tokenizer([text], return_tensors="pt")
    return tokens["input_ids"].shape[1]

@asynccontextmanager
async def lifespan(app: FastAPI):
    collection.load()                #1
    yield
    collection.release()            #2

app = FastAPI(lifespan=lifespan)      #3

1 启动时加载集合

2 关闭时从内存中释放集合

3 运行 FastAPI

现在我们已经设置好了一切,我们可以进入下一步——运行查询并更新我们的generate端点的提示。第一部分应该看起来很熟悉,因为我们刚刚做过。我们将编码用户的提示并在我们的集合中搜索最近的邻居。我们使用与之前相同的所有搜索参数,除了一个。我们将限制从3增加到5,以便可能将更多示例添加到我们的提示中。接下来,我们将这些结果格式化为几个提示示例数据集。然后我们创建指令提示并格式化用户的输入。

我们几乎到了可以结合我们的指令、示例和用户提示的阶段;然而,我们需要确保我们的示例不会占用太多空间。使用一个利用我们的标记计数器的for循环,我们将过滤掉任何不符合我们上下文窗口的示例。有了这个,我们现在可以结合所有内容,为我们的 DeciCoder 模型创建最终的提示:

    request_dict = await request.json()      #1
    prompt = request_dict.pop("prompt")

    search_embedding = embedder.encode(prompt)      #2
    search_params = {
        "metric_type": "L2",
        "params": {"nprobe": 10},
    }
    results = collection.search(
        [search_embedding],
        "embeddings",
        search_params,
        limit=5,
        output_fields=["metadata"],
    )

    examples = []
    for hits in results:
        for hit in hits:
            metadata = hit.entity.metadata
            examples.append(
                f"Instruction: {metadata['instruction']}\n"
                f"Output: {metadata['output']}\n\n"
            )

    prompt_instruction = (
        "You are an expert software engineer who specializes in Python. "
        "Write python code to fulfill the request from the user.\n\n"
    )
    prompt_user = f"Instruction: {prompt}\nOutput: "

    max_tokens = 2048
    token_count = token_length(prompt_instruction+prompt_user)

    prompt_examples = ""
    for example in examples:
        token_count += token_length(example)
        if token_count < max_tokens:
            prompt_examples += example
        else:
            break

    full_prompt = f"{prompt_instruction}{prompt_examples}{prompt_user}"

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

1 生成函数内部

2 进行查询

好的!现在我们已经更新了我们的 API,让我们像之前一样启动并测试它。我们将向服务器发送另一个请求以确保一切仍然正常工作:

$ curl --request POST --header "Content-Type: application/json" --data 
↪ '{"prompt":"def hello_world(name):"}' http://localhost:8000/generate

这次我们得到了一个print(“Hello, World!”)的响应,这比我们之前的响应略差,但仍然属于同一类型,所以不必担心。你可能会得到类似的结果。这样,我们就完成了使用 RAG 系统进行定制的 LLM 服务的设置。我们现在需要做的就是调用它。

10.3 构建 VS Code 扩展

好的,现在我们只需要构建我们的 VS Code 扩展。VS Code 扩展主要用 TypeScript 或 JavaScript(JS)编写。如果您不熟悉这些语言,不要担心;我们会引导您完成。要开始,您需要安装 Node 和 npm。Node 是 JS 解释器,npm 类似于 JS 的 pip。您可以通过多种方式添加这些工具,但我们建议首先安装 nvm 或其他节点版本管理器。此时更新您的 VS Code(如果您还没有安装,请安装它)也是一个好主意。更新您的编辑器将帮助您避免许多问题,所以请确保这样做。从这里,我们可以安装 VS Code 扩展模板生成器:

$ npm install -g yo generator-code

注意:您可以在以下位置找到安装 nvm 的说明:mng.bz/gAv8。然后只需运行nvm install node来安装最新版本的 Node 和 npm。

模板生成器将为我们创建一个基本的“Hello World”项目仓库,我们可以用它作为构建的基础。要运行生成器,请使用

$ yo code

此命令将在您的终端中启动一个向导,您将看到一个看似 ASCII 艺术形式的加拿大骑警形象,他会问您几个问题以定制正在生成的脚手架。

在图 10.1 中,您可以看到我们对向导问题的选择示例。快速引导您通过问题,我们将创建一个新的 JavaScript 扩展,您可以根据喜好命名。我们选择了llm_coding_ copilot,如果您想跟随我们的话。对于标识符,按 Enter 键,它将自动为您选择的名称添加连字符。给它一个描述;任何内容都可以。不,我们不想启用类型检查。您可以选择是否将项目初始化为新的 Git 仓库。我们选择了“否”,因为我们已经在其中一个仓库中工作了。最后,我们将使用 npm。

图

图 10.1 VS Code 扩展生成器示例输入

完成后,它将生成一个包含我们所需所有文件的项目仓库。如果你查看图 10.2,你可以看到一个已构建项目仓库的示例。它有几个不同的配置文件,你可以熟悉一下,但我们只关心其中的两个文件:定义扩展清单的 package.json 文件,它告诉 VS Code 如何使用我们将构建的扩展(实际上,是扩展 VS Code),以及包含实际扩展代码的 extension.js 文件。

figure

图 10.2 使用 VS Code 扩展生成器创建的示例目录结构

在 package.json 文件中,模板代码几乎带我们完成了大部分工作,但 activationEvents 字段目前是空的,需要设置。这个字段告诉 VS Code 何时启动我们的扩展。通常情况下,当你打开 VS Code 时,扩展并不会被加载,这有助于保持其轻量级。如果没有设置,扩展只有在用户打开它时才会被加载,这可能会很麻烦。一个聪明的策略通常是只在用户打开我们关心的文件类型时加载扩展——例如,如果我们正在构建一个针对 Python 的特定扩展,它只有在打开 .py 文件时才会加载。

我们将使用 "onCommand:editor.action.inlineSuggest.trigger" 事件触发器。这个触发器在用户手动请求内联建议时触发。它通常在用户停止输入时触发,但我们希望对过程有更多的控制,以避免向我们的 LLM 服务发送不必要的请求。只有一个问题:VS Code 没有为用户手动执行此操作提供默认快捷键!幸运的是,我们也可以通过在 "contributes" 部分添加 "keybindings" 字段来设置它。我们将将其设置为 Alt+S 的快捷键。我们使用 S 代表“建议”以便于记忆;这个快捷键应该可用,除非另一个扩展正在使用它。用户始终可以自定义他们的快捷键。你可以在下面的列表中看到完成的 package.json 文件。它应该与我们从脚手架开始时非常相似。

列表 10.4 我们编码同伴的扩展清单
{
  "name": "llm-coding-copilot",
  "displayName": "llm_coding_copilot",
  "description": "VSCode extension to add LLM code suggestions inline.",
  "version": "0.0.1",
  "engines": {
    "vscode": "¹.86.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [
    "onCommand:editor.action.inlineSuggest.trigger"
  ],
  "main": "./extension.js",
  "contributes": {
    "commands": [{
      "command": "llm-coding-copilot.helloWorld",
      "title": "Hello World"
    }],
    "keybindings": [{
      "key": "Alt+s",
      "command": "editor.action.inlineSuggest.trigger",
      "mac": "Alt+s"
    }]
  },
  "scripts": {
    "lint": "eslint .",
    "pretest": "npm run lint",
    "test": "vscode-test"
  },
  "devDependencies": {
    "@types/vscode": "¹.86.0",
    "@types/mocha": "¹⁰.0.6",
    "@types/node": "18.x",
    "eslint": "⁸.56.0",
    "typescript": "⁵.3.3",
    "@vscode/test-cli": "⁰.0.4",
    "@vscode/test-electron": "².3.8"
  }
}

现在我们已经有了扩展清单文件,让我们继续测试它。在你的 VS Code 项目仓库中,你可以按 F5 编译你的扩展并启动一个新的带有你的扩展的 VS Code 扩展开发主机窗口。在新窗口中,你应该能够按 Alt+S 触发内联建议。如果一切正常,那么你将在原始窗口中看到一个控制台日志,显示“恭喜你,你的扩展llm-coding-copilot现在已激活!”,如图 10.3 所示。

figure

图 10.3 成功激活我们的 VS Code 扩展的示例控制台

好的,不错!我们现在可以运行我们的扩展并激活它,同时捕获日志,这对于调试很有帮助。现在我们只需要构建它,让我们将注意力转向 extension.js 文件。

到这一点,事情变得有点难以解释。即使对于熟悉 JavaScript 的读者来说,也很少有人熟悉 VS Code API (mng.bz/eVoG)。在我们深入之前,让我们提醒自己我们正在构建什么。这将是一个 VS Code 扩展,它将为我们提供编码建议。我们已经在 API 后面训练了一个 LLM,该 API 已准备好供我们使用。我们有一个在 RAG 系统中加载的数据集,用于提供上下文并改进结果,并且我们已经精心制作了提示。我们只需要构建一个调用我们的 API 服务的扩展。但我们还希望有一种允许用户以简单方式与我们的模型交互的方法,同时给我们提供很多控制。我们将通过允许用户突出显示代码的一部分来实现这一点,当按下快捷键绑定 Alt+S 时,我们将发送这部分代码。

让我们看看生成器为我们创建的模板 extension.js 文件。列表 10.5 显示了经过简化注释的模板。它只是加载了 vscode 库,并定义了在启动扩展时运行的 activatedeactivate 函数。activate 函数演示了如何创建和注册一个新命令,但我们将不会使用它。我们将创建一个内联建议提供者并注册它,而不是命令。

列表 10.5 模板中的 boilerplate extension.js 文件
// Import VSCode API library
const vscode = require('vscode');

// This method is called when your extension is activated
function activate(context) {
  console.log('Congratulations, your extension "llm-coding-copilot" is now
↪ active!');

  // This creates and registers a new command, matching package.json
  // But we won’t use it!
  let disposable = vscode.commands.registerCommand('llm-coding-
↪ copilot.helloWorld', function () {
    // The code you place here will be executed every time your command is
↪ executed

    // Display a message box to the user
    vscode.window.showInformationMessage('Hello World from llm_coding_
↪ copilot!');
  });

  context.subscriptions.push(disposable);
}

// This method is called when your extension is deactivated
function deactivate() {}

module.exports = {
  activate,
  deactivate
}

由于我们不会使用命令,让我们看看我们将使用什么,一个内联建议提供者。这个提供者将把我们的建议作为光标处的幽灵文本添加。这使用户能够预览生成的内容,然后使用制表符接受建议或通过其他操作拒绝它。本质上,它正在为我们构建的代码完成扩展中的用户界面做所有繁重的工作。

在列表 10.6 中,我们向您展示如何创建和注册一个提供者,该提供者返回内联完成项。它将是一个用户可能循环选择最佳选项的潜在项数组,但为了我们的扩展,我们将保持简单,只返回一个建议。提供者接收几个自动传入的参数,例如请求内联建议的文档、用户光标的位置、提供者被调用的上下文(手动或自动)以及一个取消令牌。最后,我们将注册提供者,告诉 VS Code 为哪些类型的文档调用它;在这里,我们给出将其注册到仅 Python 文件或添加到所有文件的示例。

列表 10.6 示例内联建议提供者
// Create inline completion provider, this makes suggestions inline
const provider = {
    provideInlineCompletionItems: async (
            document, position, context, token
        ) => {
        // Inline suggestion code goes here

    }
};

// Add provider to Python files
vscode.languages.registerInlineCompletionItemProvider(
    { scheme: 'file', language: 'python' },
    provider
);
// Example of adding provider to all languages
vscode.languages.registerInlineCompletionItemProvider(
    { pattern: '**' },
    provider
);

现在我们有了提供者,我们需要一种方法来抓取用户的突出显示文本,将其发送到 LLM 服务,并确保我们的提供者仅在通过快捷键手动触发时运行,而不是自动运行,这发生在用户停止输入的每次。在列表 10.7 中,我们在提供者内部添加了这个方程式的一部分。

首先,我们获取编辑窗口以及任何选中的或突出显示的内容。然后我们确定提供者是因为自动还是手动触发而被调用的。接下来,我们进行一个小技巧以获得更好的用户体验。如果我们的用户将代码从后向前突出显示,光标将位于他们的代码前端,我们的代码建议将不会显示。因此,我们将重新突出显示选择,这将光标置于末尾,并重新触发内联建议。幸运的是,这种重新触发也将被计算为手动触发。最后,如果一切顺利——内联建议是手动调用的,我们有突出显示的文本,并且光标位于正确的位置——那么我们将开始使用我们的 LLM 代码助手的过程,通过从选择中抓取突出显示的文本。

列表 10.7 使用 VS Code API 工作
// Create inline completion provider, this makes suggestions inline
const provider = {
    provideInlineCompletionItems: async (
            document, position, context, token
        ) => {
        // Grab VSCode editor and selection
        const editor = vscode.window.activeTextEditor;
        const selection = editor.selection;
        const triggerKindManual = 0
        const manuallyTriggered = context.triggerKind == triggerKindManual

        // If highlighted back to front, put cursor at the end and rerun
        if (manuallyTriggered && position.isEqual(selection.start)) {
            editor.selection = new vscode.Selection(
                selection.start, selection.end
            )
            vscode.commands.executeCommand(
                "editor.action.inlineSuggest.trigger"
            )
            return []
        }

        // On activation send highlighted text to LLM for suggestions
        if (manuallyTriggered && selection && !selection.isEmpty) {
            // Grab highlighted text
            const selectionRange = new vscode.Range(
                selection.start, selection.end
            );
            const highlighted = editor.document.getText(selectionRange);

            // Send highlighted code to LLM
        }
    }
};

好的!现在我们已经将所有 VS Code 特定的代码处理完毕,我们只需要向我们的 LLM 服务发送一个请求。到这一点,这个动作应该感觉像是熟悉的领域;事实上,我们将使用我们在第七章中讨论过的代码。这里没有什么可怕的!在下一个列表中,我们通过抓取突出显示的文本并使用异步 fetch 请求将其发送到我们的 API 来完成提供者的构建。然后我们获取响应并将其返回给用户。

列表 10.8 向我们的编码助手发送请求
// On activation send highlighted text to LLM for suggestions
if (manuallyTriggered && selection && !selection.isEmpty) {
    // Grab highlighted text
    const selectionRange = new vscode.Range(
        selection.start, selection.end
    );
    const highlighted = editor.document.getText(
        selectionRange
    );

    // Send highlighted text to LLM API
    var payload = {
        prompt: highlighted
    };

    const response = await fetch(
        'http://localhost:8000/generate', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
    });

    // Return response as suggestion to VSCode editor
    var responseText = await response.text();

    range = new vscode.Range(selection.end, selection.end)
    return new Promise(resolve => {
        resolve([{ insertText: responseText, range }])
    })
}

现在所有部件都已就绪,让我们看看它的实际效果。再次按 F5 重新编译您的扩展,启动另一个带有更新扩展的 VS Code 扩展开发主机窗口。创建一个以 .py 扩展名的新 Python 文件,并开始编写一些代码。准备好后,突出显示您希望获得助手帮助的部分,然后按 Alt+S 获取建议。过了一会儿,你应该会看到一些带有助手建议的幽灵文本弹出。如果您喜欢它,按 Tab 键接受。图 10.4 展示了我们的 VS Code 扩展在实际应用中的示例。

figure

图 10.4 成功激活我们的 VS Code 扩展的示例控制台

恭喜!你做到了!你创建了自己的编码助手!它运行在你自己的数据上,并且完全是本地的——如果你在开始这本书时对 LLM 一无所知,这是一个相当大的成就。在下一节中,我们将讨论下一步以及从这个项目中得到的经验教训。

10.4 经验教训和下一步

现在我们有了可以工作的代码,我们可以把它叫作一天的工作。然而,我们的项目还远未完成;我们还有许多可以做的事情!首先,结果看起来并不那么好。回顾一下图 10.4,生成的代码并没有反转链表,而是反转了一个普通的列表。这不是我们想要的。我们能做些什么来改进它呢?

好吧,首先,记得我们发送给 API 测试的“Hello World”函数吗?看起来在我们添加 RAG 之前使用模型时,我们得到了更好的结果。为了好玩,让我们启动我们旧的 API,禁用 RAG,看看使用我们的 VS Code 扩展时我们会得到什么。图 10.5 显示了使用此 API 的一个示例结果。

图像

图 10.5 使用 DeciCoder(无 RAG)的扩展结果

哇!这段代码看起来好多了!它实际上反转了链表,并且已经格式化得很好,以至于你甚至不需要编辑或格式化它。这里发生了什么?模型不是应该在我们给出它们一些我们希望它们如何表现示例后生成更好的结果吗?也许我们的 RAG 系统没有找到很好的示例。让我们深入挖掘,看看我们的 RAG 系统生成的提示信息。

MA指令:用 7 行 Python 代码反转单链表的最有效方法是什么?

chatGpt输出:# 单链表的定义。

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def reverseList(head): 
    prev = None
    current = head

    while current is not None:
        nxt = current.next
        current.next = prev 
        prev = current
        current = nxt

    head = prev 
    return head

MA指令:在 Python 中反转链表的最有效方法是什么?

chatGpt输出:****

def reverse(head):
    prev = None
    current = head
    while current:
        next = current.next
        current.next = prev
        prev = current
        current = next
    return prev

MA指令:def reverse_linked_list(list):

反转链表

chatGpt输出:****

哇!这些例子看起来非常准确!那么到底可能发生了什么呢?

好吧,首先,再看看提示信息。我们数据集中的示例指令是用普通英语表述的任务,但用户将要发送的提示信息是半写好的代码。如果我们的用户用普通英语来写,我们可能会得到更好的结果。当然,当我们的用户在编辑器中编码时,这可能会是一种有点尴尬的经历。在难以理解的部分写代码并寻求帮助会更自然。

第二,记得我们关于 DeciCoder 训练的笔记吗?它是被训练来击败 HumanEval 数据集的,所以它非常擅长将代码作为输入并生成代码作为输出。这使得它从一开始就非常适合这项任务,而无需进行提示调整。更重要的是,它还没有被指令调整过!当它看到我们的少量示例时可能会有些困惑,因为它在训练期间没有看到过这样的输入。作为一个为特定目的训练的小型模型,它并不擅长泛化到新的任务。

从这个例子中,有几个关键点需要强调。首先,虽然提示调整是一种强大的技术,可以用于为新任务定制 LLM,但它本身所能达到的成就仍然有限,即使在使用 RAG 系统提供高度相关示例的情况下。必须考虑模型是如何训练或微调的,以及它接触到了哪些数据。此外,考虑用户如何与模型交互,以确保您正确地构建了提示。

那么,您可以尝试哪些下一步来提高结果?在这个阶段,事情似乎大多已经正常工作,所以我们可能会尝试的第一件事是调整我们的 RAG 系统中的提示。看起来用普通英语编写的指令数据对我们的模型并没有很大帮助,所以我们可以简单地尝试给模型提供示例代码,看看是否可以提高结果。接下来,我们可以尝试微调模型以采用指令数据集,或者完全寻找另一个模型。

除了让我们的应用程序工作得更好之外,可能还有很多下一步可以定制这个项目。例如,我们可以在 Milvus 中创建一个包含我们自己的代码数据集的集合。这样,我们就可以将相关代码的上下文注入到我们的代码库中的提示中。我们的模型不仅擅长编写通用的 Python 代码,还能编写针对我们工作的组织的特定代码。如果我们选择这条路,我们不妨将我们的 API 和 Milvus 数据库部署到生产服务器上,这样我们就可以为公司的其他工程师和数据科学家提供服务。

或者,我们可以放弃定制化想法,仅使用 DeciCoder,因为它似乎已经给出了很好的结果。无需定制。如果我们这样做,那么将模型编译为 GGUF 格式并通过 JavaScript SDK 直接在扩展中运行它将是有价值的。这样做将允许我们将所有代码封装在一个地方,并使其更容易分发和共享。

最后,您可能考虑发布扩展并与社区分享。目前,该项目尚未准备好分享,因为我们正在本地运行我们的模型和 RAG 系统,但如果您感兴趣,您可以在网上找到官方说明,网址为 mng.bz/GNZA。它涵盖了从获取 API 密钥到打包、发布,甚至成为认证发布者的所有内容。

摘要

  • DeciCoder 是一个专为 Python、JavaScript 和 Java 中的编码任务设计的小巧但强大的模型。

  • Milvus 是一个强大的开源 VectorDB,可以扩展以满足您的需求。

  • 您的数据集对于使您的 RAG 系统正常工作至关重要,因此请花时间对其进行清理和适当准备。

  • Visual Studio Code 是一款流行的编辑器,它使得构建扩展变得容易。

  • 仅向您的模型抛出示例和数据,即使它们被精心策划,也不会使其生成更好的结果。

  • 以一种考虑到模型训练方法和数据的方式来构建提示,以最大化结果。

[1] D. Ramel, “Stack Overflow 开发者调查:VS Code 和 Visual Studio 连续 5 年位居顶级 IDE,” Visual Studio Magazine,2023 年 6 月 28 日,mng.bz/zn86.

[2] Deci, “推出 DeciCoder:高效且精确的代码生成新标准,” 2023 年 8 月 15 日,mng.bz/yo8o.

第十一章:在树莓派上部署一个 LLM:你能做到多低?

本章涵盖

  • 在你的本地网络上设置树莓派服务器

  • 将模型转换为 GGUF 格式并进行量化

  • 将你的模型作为 OpenAI GPT 模型的直接替代品提供服务

  • 接下来要做什么以及如何让它变得更好

低价格带来的甜蜜很快就会忘记,而低质量带来的苦涩却会久久萦绕。 ——本杰明·富兰克林

欢迎来到我们在这个列表中最喜欢的项目之一:在一个比它原本应该服务的设备还要小的设备上运行一个 LLM。在这个项目中,我们将推动这项技术的极限。通过跟随这个项目,你将能够真正地运用在这本书中学到的所有知识。在这个项目中,我们将把一个 LLM 部署到树莓派上,我们将将其设置为 LLM 服务,你可以从家中网络上的任何设备查询。对于所有黑客来说,这个练习应该会打开许多家庭项目的门。对于其他人来说,这是一个巩固你对使用 LLMs 局限性的理解并欣赏使这一切成为可能的社会的机会。

这是一个实用的项目。在本章中,我们将深入探讨的内容远不止 LLMs,而且不会有模型训练或数据聚焦,因此这是我们第一个真正只用于生产的项目。我们创建的内容将比你可能预期的要慢得多、效率低得多、准确性低得多,但这没关系。实际上,这是一个极好的学习经历。理解可能性和有用性之间的区别是许多人直到它狠狠地打在他们脸上之前都不会学到的东西。在树莓派上运行的 LLM 不是你想要在企业生产系统中部署的东西,但我们将帮助你学习其背后的原理,这样你最终可以扩展到你想要的任何大小。

11.1 设置你的树莓派

尽管困难重重,但在树莓派上提供服务和推理是可行的,尽管我们通常不推荐这样做,除非是为了展示你可以做到,这是警告的一个典型迹象,表明这是一个有趣的项目,就像找出你能把多少棉花糖塞进你弟弟的嘴里一样。仅仅玩弄树莓派本身在一般情况下就很有趣,我们希望这并不是你第一次玩。树莓派是家庭中出色的、便宜的伺服器。你可以用它们来阻止广告(Pi-Hole 是一个流行的库)或使用 Plex 和 Jellyfin 等服务流式传输你自己的个人媒体库。有很多有趣的项目。因为它完全可定制,如果你能写一个功能性的 Python 脚本,你很可能可以在你的本地网络服务器上运行它,这就是我们要为我们的 LLM 服务器所做的事情。

您只需要三样东西来完成这个项目:一个 8 GB RAM 的 Raspberry Pi,一个至少 32 GB 的 MicroSD 卡(更多更好),以及一个电源。在撰写本文时,我们找到了一些 1 TB 内存的 MicroSD 卡,价格为 20 美元,所以希望您能得到比 32 GB 更大的存储空间。您购买的其他任何东西都只是锦上添花——例如,为您的 Pi 购买一个外壳。如果您没有 Wi-Fi,您还需要一根以太网线将 Pi 连接到您的家庭网络。一旦我们设置好,我们将向您展示如何从笔记本电脑远程连接到 Pi。此外,如果您的笔记本电脑没有 MicroSD 插槽,您可能需要一个适配器来连接它。

对于 Raspberry Pi 本身,我们将在这个项目中使用 Raspberry Pi 5 8 GB 型号。如果您想跟随操作,我们使用的确切型号可以在以下链接找到:mng.bz/KDZg。对于我们将部署的型号,您需要一个至少 8 GB RAM 的单板计算机来跟随操作。有趣的是,我们已经成功将模型部署到只有 4GB RAM 的小型 Pi 上,并且还有许多其他单板计算机作为 Raspberry Pi 的替代品可供选择。如果您选择不同的板子,可能更难精确跟随,所以只有当您信任该公司时才这样做。我们推荐的一些替代品包括 Orange Pi、Zima Board 和 Jetson,但我们不会详细介绍如何设置这些设备。

您不需要事先知道如何设置 Pi。我们将引导您完成所有步骤,假设这是您的第一个 Raspberry Pi 项目。Pi 实际上只是硬件和众多项目的开放沙盒,因此我们首先需要安装一个操作系统(OS)。之后,我们将安装必要的软件包和库,准备我们的 LLM,并将其最终作为一项服务提供,您可以从家庭网络中的任何计算机上 ping 它并获取生成的文本。

11.1.1 Pi Imager

首先,Pi 通常不预装操作系统,即使您的设备预装了,我们也会更换它。常见的发行版,如 Rasbian OS 或 Ubuntu,体积太大,占用太多 RAM,无法以最快速度运行模型。为了帮助我们克服这一限制,Raspberry Pi 的制造商发布了一款名为 Pi Imager 的免费镜像软件,您可以从以下链接在您的笔记本电脑上下载:www.raspberrypi.com/software/。如果您已经有了镜像软件,我们建议将其更新到 1.8 版本以上,因为我们正在使用 Pi 5。

一旦您有了它,将 microSD 插入您下载 Pi Imager 程序的电脑。如果您不确定如何操作,请在网上搜索 USB 3.0 microSD 卡读卡器。打开镜像程序,选择设备;对我们来说,那就是 Raspberry Pi 5。这个选择将限制操作系统选项仅限于适用于 Pi 5 的选项。然后您可以选择 Raspberry Pi OS Lite 64 位作为您的操作系统。“Lite”是您要找的关键词,您可能需要在 Raspberry Pi OS(其他)子部分中找到它。然后选择您的 microSD 作为存储设备。实际名称将根据您的设置而有所不同。图 11.1 展示了带有正确设置的镜像软件示例。作为备注,Ubuntu Server 也是一个适合我们项目的良好操作系统,我们推荐它。它的设置会有所不同,所以如果您想跟上来,请坚持使用 Raspberry Pi OS Lite。

图

图 11.1 将 Raspberry Pi Imager 设置为正确的设备,选择了无头(Lite)操作系统和正确的 USB 存储设备

警告  并且作为警告,请确保您已选择了 microSD 来镜像操作系统——请勿选择您的主硬盘。

准备就绪后,通过选择“下一步”按钮进行导航,您应该会看到一个关于操作系统定制的提示,如图 11.2 所示。我们将设置它,所以点击“编辑设置”按钮,您应该会看到一个设置页面。

图

图 11.2 自定义我们的 Raspberry Pi OS 设置。选择“编辑设置”。

图 11.3 展示了设置页面的示例。我们将在项目名称之后为 Pi 服务器设置一个主机名,llmpi。我们将设置用户名和密码,并配置 Wi-Fi 设置以连接到我们的家庭网络。这可能是最重要的步骤,所以请确保您已为互联网设置好,无论是通过在设置中设置 Wi-Fi 连接还是通过以太网。

图

图 11.3 设置页面截图示例,包含正确和相关信息

与设置互联网一样重要的是,我们希望启用 SSH,否则后续的步骤将无法工作。为此,转到“服务”选项卡并选择“启用 SSH”,如图 11.4 所示。我们将使用密码认证,所以请确保您已设置了合适的用户名和密码,并且没有使用默认设置。您不希望任何有恶意的人轻易访问您的 Pi。

到目前为止,我们已经准备好创建镜像。按照提示操作,镜像程序将把操作系统安装到您的 SD 卡上。这个过程可能需要几分钟,但通常很快就会完成。一旦您的 SD 卡上有了操作系统,您就可以安全地从笔记本电脑上取下它。将 microSD 卡插入您的 Pi 中,并打开它!如果一切操作正确,您的 Pi 应该会自动启动并连接到您的 Wi-Fi。

图

图 11.4 确保你选择了启用 SSH。

11.1.2 连接到树莓派

我们将使用我们的小树莓派作为一个小型服务器。我们设置的好处是,你不需要为树莓派找到额外的显示器或键盘。当然,这种设置有一个明显的缺点,那就是我们无法看到树莓派在做什么,也没有明显的方法与之交互。别担心;这就是我们设置 SSH 的原因。现在我们将向你展示如何从你的笔记本电脑连接到你的树莓派。

我们首先需要做的是找到树莓派的 IP 地址。IP 地址是一个数字标签,用于在网络中标识一台计算机。查看你使用的互联网上连接的新设备的最简单方法是通过路由器的软件。见图 11.5。如果你可以访问你的路由器,你可以在浏览器中访问其 IP 地址。IP 地址通常是 192.168.86.1 或 192.168.0.1;路由器的类型通常设置这个数字,并且通常可以在路由器本身上找到。然后你需要登录到你的路由器,在那里你可以看到连接到你的网络的所有设备。

图

图 11.5 示例谷歌智能家居路由器界面,列出了几个设备以发现它们的 IP 地址

如果你无法访问你的路由器,很多人都是这种情况,你并不倒霉。下一个最简单的方法就是忽略我们之前段落中说的所有内容,并将你的树莓派连接到显示器和键盘上。运行 $ ifconfig$ ip a,然后查找 inet 参数。这些命令将输出你本地网络上的设备及其 IP 地址。图 11.6 和 11.7 展示了运行这些命令并突出显示你要找的内容。如果你没有额外的显示器,那么事情会变得有点棘手,但仍然可行。然而,如果你能避免,我们不推荐走这条路。

图

图 11.6 运行 ifconfig 的示例。为了清晰起见,我们的树莓派的 IP 地址(inet)被突出显示。

图

图 11.7 运行 ip a 的示例。为了清晰起见,我们的树莓派的 IP 地址(inet)被突出显示。

要扫描你的本地网络以查找 IP 地址,在你的笔记本电脑上打开一个终端,并运行相同的命令 ($ ifconfig),如果你使用的是 Windows,则运行 $ ipconfig。如果你没有 ifconfig,你可以使用 $ sudo apt install net-tools 来安装它。我们之前没有提到这一步,因为它应该已经安装在你的树莓派上了。

如果你已经知道 Pi 是哪个设备,那太棒了!只需获取该设备的 inet 参数。然而,更有可能的是你不知道,如果你知道如何使用,这里有一些有用的命令。使用命令 $ arp -a 来查看连接到你的网络的所有 IP 地址,使用命令 $ nslookup $IP_ADDRESS 来获取你传入的 IP 地址对应的计算机的主机名——你将寻找主机名 raspberry,但我们将跳过所有这些。我们相信如果你知道如何使用这些命令,你不会阅读这本书的这一部分。相反,我们将使用原始人问题解决方法,这意味着我们将简单地关闭 Pi,再次运行 $ ifconfig 命令,看看有什么变化,特别是什么消失了。当你重新启动它时,你的路由器可能会分配一个与上次不同的 IP 地址,但你仍然应该能够 diff 差异并找到它。

好吧,我们知道为了获取 IP 地址可能需要做很多工作,但一旦你有了它,下一步就简单了。要 SSH 连接到它,你可以运行 ssh 命令:

$ ssh username@0.0.0.0

username 替换为你创建的用户名(如果你在跟随我们操作,应该是 pi),并将 0s 替换为你的 Pi 的 IP 地址。由于这是第一次连接到一个全新的设备,你将需要指纹验证以建立连接和主机真实性。然后你将被提示输入密码。输入你在 imager 中设置的密码。如果你没有设置密码,默认密码是 pi,但我们相信你没有这么做,对吧?

通过这样,你应该已经远程连接到你的 Pi,并在你的计算机终端中看到 Pi 的终端,如图 11.8 所示。做得好!

figure

图 11.8 成功安全壳连接到 Raspberry Pi 的终端。

11.1.3 软件安装和更新

现在我们已经启动了 Pi 并连接到了它,我们可以开始安装。第一个命令是众所周知的,它将简单地更新我们的系统:

$ sudo apt update && sudo apt upgrade -y

这可能需要一分钟的时间,但一旦完成,恭喜!你现在有一个 Raspberry Pi 服务器,你可以在上面运行任何你想要的东西。它仍然是一张白纸,所以让我们改变它,准备运行我们的 LLM 服务器。我们首先想要安装我们需要的任何依赖项。根据你的安装,这可能包括 g++build-essentials。我们只需要两个:gitpip。让我们先安装它们,这将使整个过程变得容易得多:

$ sudo apt install git-all python3-pip

接下来,我们可以克隆将在这里做大部分工作的 repo:Llama.cpp。让我们将项目克隆到你的 Pi 上并构建项目。为此,运行以下命令:

$ git clone https://github.com/ggerganov/llama.cpp.git  
$ cd llama.cpp
关于 llama.cpp 的注意事项

Llama.cpp,就像许多开源项目一样,是一个更关注让事情工作而不是必然遵循最佳工程实践的项目。由于你正在以当前状态克隆仓库,但我们是在之前的状态下编写这些说明的,你可能会遇到我们无法为你准备的问题。Llama.cpp 也没有任何形式的版本控制。在克隆仓库后,我们建议你运行

$ git checkout 306d34be7ad19e768975409fc80791a274ea0230

此命令将检出我们使用的确切git commit,这样你就可以在 llama.cpp 的相同版本上运行一切。我们在 Mac、Windows 10、Ubuntu、Debian 以及当然,Raspberry Pi 4 和 5 上进行了测试。我们预计大多数系统使用这个版本不会出现任何问题。

现在我们有了仓库,我们必须完成几个任务来准备它。首先,为了保持我们的 Pi 整洁,让我们为我们的仓库创建一个虚拟环境并激活它。一旦我们的 Python 环境准备就绪,我们将安装所有必需的依赖项。我们可以使用以下命令来完成:

$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install -r requirements.txt

Llama.cpp 是用 C++编写的,这是一种编译型语言。这意味着我们必须编译所有依赖项以在硬件和架构上运行。让我们继续构建它。我们只需一个简单的命令就可以做到这一点:

$ make
关于设置的注意事项

如果你在这个略有不同的环境中执行此设置,使用 CMake 而不是 Make 可能会产生很大的差异!例如,即使在 Ubuntu 上运行,我们也需要使用 CMake 来指定兼容的 CudaToolkit 版本以及 nvcc 二进制文件存储的位置,以便使用 CuBLAS 而不是普通的 CPU 来利用 CUDA 集成的 GPU。原始创建者(Georgi Gerganov,又名 ggerganov)在构建测试时使用 CMake,因为它需要比 Make 更多的指定。为了参考,以下是 ggerganov 目前使用的 CMake 构建命令;你可以根据需要修改它:

$ cmake .. -DLLAMA_NATIVE=OFF -DLLAMA_BUILD_SERVER=ON -DLLAMA_CURL=ON 
↪ --DLLAMA_CUBLAS=ON -DCUDAToolkit_ROOT=/usr/local/cuda 
↪ --DCMAKE_CUDA_COMPILER=/usr/local/cuda/bin/nvcc 
↪ --DCMAKE_CUDA_ARCHITECTURES=75 -DLLAMA_FATAL_WARNINGS=OFF 
↪ --DLLAMA_ALL_WARNINGS=OFF -DCMAKE_BUILD_TYPE=Release

接下来,我们只需要获取我们的模型,然后我们就可以继续前进。我们为这个项目选择的模型是 Llava-v1.6-Mistral-7B,我们将使用huggingface-cli来下载它,就像我们在其他章节中所做的那样。现在运行以下命令来拉取 LLaVA 模型、其伴随的标记器以及配置文件:

$ pip install -U huggingface_hub
$ huggingface-cli download liuhaotian/llava-v1.6-mistral-7b --local-dir 
↪ ./models/llava --local-dir-use-symlinks False

现在我们有了模型和标记器信息,我们就可以将我们的 LLM 转换成适用于像 Android 手机或 Raspberry Pi 这样小型的设备了。

11.2 准备模型

现在我们有了模型,我们需要将其标准化,以便仓库中的 C++代码可以以最佳方式与之交互。我们将模型从 safetensor 格式转换为.gguf 格式。我们之前已经使用过 GGUF 模型,因为它们是可扩展的、加载速度快,并且在一个模型文件中包含了关于模型的所有信息。我们还下载了标记器信息,这些信息将包含在我们的.gguf 模型文件中。

一旦准备就绪,我们可以使用convert.py脚本来将我们的 safetensor 模型转换为 GGUF:

$ python3 convert.py ./models/llava/ --skip-unknown

这段代码会将所有权重转换成一个与下载的所有.safetensors 文件大小相同的.gguf 检查点。现在我们有两个我们下载的副本,如果你的 microSD 卡相当小,这可能是多余的。一旦你有了.gguf 检查点,我们建议你删除或迁移原始模型文件到 Pi 之外的地方以回收内存,这可能看起来像这样:

$ find -name './models/llava/model-0000*-of-00004.safetensors' -exec  
rm {} \;

当我们的模型以正确的单文件格式就绪后,我们可以将其变小。现在内存限制开始发挥作用。我们选择 7B 参数模型的一个原因是,在量化q4_K_M格式中(我们稍后会讨论 llama.cpp 支持的量化格式),它在磁盘上略大于 4GB,这对于 8GB 的 Raspberry Pi 来说已经足够有效。运行以下命令来量化模型:

$ ./quantize ./models/llava/ggml-model-f16.gguf ./models/llava/llava-
↪ v1.6-mistral-7b-q4_k_m.gguf Q4_K_M

我们不会撒谎:在将量化方法应用于所有模型权重时,这将是一场等待游戏,但一旦完成,你将有一个全新的量化模型准备好提供服务。

遇到麻烦了吗?

虽然我们已经在这许多环境和硬件上测试了这些说明,但你可能仍然会遇到困难。以下是一些你可以尝试的故障排除建议,这些建议对我们有所帮助:

  • 重新下载模型。这些模型很大,如果你的 Pi 在下载过程中有任何互联网连接问题,你可能有一个损坏的模型。如果你的连接不稳定,你可以尝试使用以太网线而不是 Wi-Fi。

  • 重新编译你的依赖项。重新编译你的依赖项最简单的方法是运行make clean然后再次运行make。你也可以尝试使用cmake或检查不同的选项。

  • 重启你的 Pi。重启是一个经典且经过验证的解决方案,特别是如果你正在处理内存问题(对于手头的任务,我们并没有太多内存问题)。你可以在 SSH 中使用sudo reboot来重启。

  • 在你的计算机上运行这些步骤。你可能在更好的硬件上遇到的问题更少,在尝试在边缘设备上工作之前,了解一条简单路径是什么样的可能很有用。

  • 下载一个已准备好的模型。虽然我们鼓励你自己进行转换和量化,但通常你可以在任何格式中找到大多数已经量化的开源模型。所以如果你不担心微调它,你应该会很幸运。对我们来说,我们就是这种情况。

如果你遇到了困难但还想继续前进,你可以使用以下命令下载模型的量化版本:

$ huggingface-cli download cjpais/llava-1.6-mistral-7b-gguf --local-dir 
↪ ./models/llava --local-dir-use-symlinks False --include *Q4_K_M*

11.3 服务器模型

我们终于到了这里,开始提供服务!使用 llama.cpp,为模型创建服务非常简单,我们稍后会详细介绍一些稍微复杂一些的技巧,但现在,享受你所做的一切:

$./server -m ./models/llava/llava-v1.6-mistral-7b-q4_k_m.gguf --host
↪ $PI_IP_ADDRESS --api-key $API_KEY

一定要使用你的 Pi 的 IP 地址,API 密钥可以是任何随机的字符串,以提供一层小的安全性。就这样!你现在有一个在 Raspberry Pi 上运行的 LLM,可以从你的本地网络上的任何计算机查询。请注意,服务器在你的 Pi 上启动可能需要很长时间,因为它正在加载模型。不要过于担心;给它一些时间。一旦准备好,让我们通过一个快速演示来测试它。

对于这个演示,假设你已经将一个应用程序与 OpenAI 的 Python 包深度集成。在列表 11.1 中,我们向你展示了如何将这个应用程序指向你的 Pi LLM 服务。我们将继续使用 OpenAI 的 Python 绑定,并将其指向我们的服务。我们通过更新 base_url 到我们的 Pi 的 IP 地址,并使用我们创建服务器时设置的相同的 API 密钥来做这件事。

此外,请注意我们正在调用 gpt-3.5-turbo 模型。OpenAI 对不同模型的调用有不同的流程。如果你不喜欢输入这些字母,你可以轻松地更改它,但这并不重要。你只需要弄清楚如何更改脚本,以便你感觉像是在调用(再次强调,你实际上并没有调用 ChatGPT)。

列表 11.1 OpenAI 但不是 ChatGPT
import openai

client = openai.OpenAI(
    base_url="http://0.0.0.0:8080/v1",  # replace with your pi's ip address
    api_key="1234",  # replace with your server's api key
)

completion = client.chat.completions.create(
    model="gpt-3.5-turbo",
    messages=[
        {
            "role": "system",
            "content": "You are Capybara, an AI assistant. Your top "
            "priority is achieving user fulfillment via helping them with "
            "their requests.",
        },
        {
            "role": "user",
            "content": "Building a website can be done in 10 simple steps:",
        },
    ],
)

print(completion.choices[0].message)

你不需要代码来与你的服务器交互。服务器脚本自带内置的最小 GUI,你可以通过将浏览器指向你的 Pi 的 IP 地址在你的本地网络上访问它。务必包括端口 8080。你可以在图 11.9 中看到这个示例。

这个过程将允许你通过简单的聊天窗口与运行的 LLM API 进行接口。我们鼓励你稍微玩一下。由于你运行在 Raspberry Pi 上,你期望的最快速度大约是每秒五个标记,而最慢的速度,嗯,很慢。你将立即理解为什么普通人不会在边缘设备上放置 LLM。

图

图 11.9 在你的 Pi 上运行 LLM 并通过 llama.cpp 服务器与之交互

到目前为止,你可能想知道为什么我们对这个项目如此兴奋。我们承诺你会学到很多东西,但这一章是书中最短的,我们在这里做的绝大多数工作都是下载其他人的代码库和模型。欢迎来到生产阶段

这正是大多数公司会要求你做的事情:下载一些从朋友那里听说过的模型,并将其放在远小于其运行所需的硬件上。你现在应该准备好在约 20 到 30 分钟内拼凑出他们要求的原型。能够快速迭代将允许你回去进行更有力的谈判,展示你需要更多硬件、用于训练的数据、RAG 或任何其他系统来使项目成功。构建快速的概念验证,然后扩展以满足项目需求,应该是数据科学家和机器学习工程师的关键工作流程。

跟随这里演示的快速原型验证工作流程的一个巨大优势是可见性。你可以展示你能够极快地组合出令人惊叹的东西,如果你们的产品经理表现良好,这应该会在其他目标比预期花费更多时间时增加一定的信任度。他们已经看到,如果你在生产中急需某样东西,你可以在一瞬间就做到。吸引并留住顾客的优质内容需要时间,并且需要对数据和研究的真实投资。

11.4 改进

现在我们已经对这个项目进行了一次全面了解,让我们来谈谈修改这个项目的方法。为了清晰起见,我们选择手把手地告诉你确切要运行的命令,这样你就可以在指导帮助下开始接触实际操作。教程通常在这里结束,但真正的学习,尤其是在生产中的项目,总是更进一步。因此,我们想要给你一些想法,告诉你如何使这个项目成为你自己的,从选择不同的模型到使用不同的工具。

11.4.1 使用更好的界面

学习新工具是这个领域的人最常见任务之一——我们这里指的是从数据科学到 MLOps 的所有事情。虽然我们选择在本书中专注于一些最受欢迎和经过实战考验的工具——我们在生产中实际使用过的工具——但你的公司可能选择了不同的工具。更有可能的是,出现了一个新工具,每个人都正在谈论,你想要尝试它。

我们已经就 llama.cpp 谈了很多,并在本项目中几乎用它做了所有事情,包括编译、量化、服务,甚至为我们的项目创建前端。虽然这个工具在编译和量化方面表现突出,但其他功能主要是出于方便而添加的。让我们考虑一些其他可以帮助你的项目增添额外活力或魅力的工具。

为了立即改善你的项目,你可能考虑安装一个类似 SillyTavern(不一定推荐;它只是很受欢迎)的服务器前端。一个优秀的前端会将“查询一个 LLM”转变为“与一个 AI 最佳朋友聊天”,将一个平静的任务转变为一个令人兴奋的体验。我们喜欢用于这项工作的工具包括 KoboldCpp 和 Ollama,它们是为了扩展 llama.cpp 并使界面更简单或更可扩展而构建的。因此,它们非常适合扩展这个特定的项目。Oobabooga 是另一个出色的文本生成网页 UI。所有这些工具都提供了大量的定制选项和为用户提供独特体验的方式。它们通常提供前端和服务器。

11.4.2 更改量化

你可能考虑在只有 4GB 内存的旧 Pi 上做同样的项目,这样你将需要一个更小的模型。也许你不仅想用 Pi 来服务 LLM,所以你需要进一步缩小模型,或者也许你完全想更换模型。无论如何,你都需要深入探索量化这个兔子洞。之前,我们使用q4_K_M格式量化模型,承诺稍后解释。好吧,现在就是时候了。

Llama.cpp 提供了许多不同的量化格式。为了简化讨论,表 11.1 突出了一些更常见的量化方法,包括每个方法转换下来的比特数,结果的模型大小,以及运行 7B 参数模型所需的 RAM。这张表应作为快速参考,帮助你确定可以期望的大小和性能水平。一般规则是,量化越小,性能越低,困惑度越高。

表 11.1 不同 llama.cpp 量化方法对 7B 参数模型的键属性比较
量化方法 比特数 大小 (GB) 最大 RAM 需求 (GB) 用例 参数 (十亿)
Q2_K 2 2.72 5.22 质量损失显著;不建议用于大多数目的 7
Q3_K_S 3 3.16 5.66 非常小,质量损失高 7
Q3_K_M 3 3.52 6.02 非常小,质量损失高 7
Q3_K_L 3 3.82 6.32 小,质量损失较大 7
Q4_0 4 4.11 6.61 传统;小,质量损失非常高;建议使用Q3_K_M 7
Q4_K_S 4 4.14 6.64 小,质量损失更大 7
Q4_K_M 4 4.37 6.87 中等,平衡质量;推荐 7
Q5_0 5 5.00 7.50 传统;中等,平衡质量;建议使用Q4_K_M 7
Q5_K_S 5 5.00 7.50 大,质量损失低;推荐 7
Q5_K_M 5 5.13 7.63 大,质量损失非常低;推荐 7
Q6_K 6 5.94 8.44 非常大,质量损失极低 7
Q8_0 8 7.70 10.20 非常大,质量损失极低;不推荐 7

如果你只有 4GB 或 6GB 的 Pi,你可能会看着这张表格想,“不,是时候放弃了。”但你还不是完全没有机会;你的模型可能只是运行得慢一些,你可能需要比这些 7B 参数更小的模型——比如只有 1B 或 3B 参数的模型——或者量化得更小以运行。你实际上是在用这么小的 Pi 来推动边缘,所以Q2_kQ3_K_S可能适合你。

友好的提醒:我们一直在用这个项目挑战边缘,但对于更有资金的项目来说,这是一个有用的经验。当使用更好的硬件进行类似项目时,更好的硬件在运行大型 LLM 方面也有其限制。毕竟,总是有更大的模型。请记住,如果你使用 cuBLAS 或任何用于利用 GPU 的框架运行,你不仅受限于 RAM,还受限于 VRAM。例如,在 3090 上使用 cuBLAS 运行时,你受限于 24 GB 的 VRAM。通过巧妙的内存管理(例如使用无头操作系统以占用更少的 RAM),你可以在较小的设备上加载更大的模型,并推动看似可能实现的功能的边界。

11.4.3 添加多模态

我们最初忽略了一个完整的维度,以免分散注意力,但现在让我们来谈谈它:LLaVA 实际上是一个多模态模型!多模态模型使我们能够从 NLP 扩展到其他来源,如图像、音频和视频。几乎每个多模态模型在本质上也是一个大型语言模型(LLM),因为不同模态的数据集都使用自然语言进行标记——例如,图像中看到的内容的文本描述。特别是,LLaVA(大型语言和视觉助手)允许我们向模型输入一张图像并对其提问。

关于 llama 服务器的一个注意事项

记得我们说过 llama.cpp 项目并不遵循许多工程最佳实践吗?嗯,多模态就是其中之一。最初,llama.cpp 服务器支持多模态,但很快项目中就出现了许多问题。创建者觉得原始实现很糟糕,决定将其移除。有一天,一切正常,第二天,它就完全消失了。

这个变化发生在我们编写本章的时候——这本身就是一个头疼的问题——但想象一下,当试图在生产中运行时,它可能造成的损害。不幸的是,在当前这个时间点上,当我们在 LLM 上工作时,这种突然的变化是家常便饭,因为目前可用的稳定依赖项非常少。为了重现这里的内容并最小化调试,我们希望您查看之前提到的 git commit。好消息是,llama.cpp 计划继续支持多模态,另一个实现可能很快就会准备好——可能在你阅读这一章的时候。

在这本书中,我们几乎没有讨论过多模态,因为从学习如何使 LLM 在生产中工作获得的经验教训应该可以转移到多模态模型上。无论如何,我们认为展示如何部署一个模型会很有趣。

更新模型

我们已经做了大部分工作;然而,llama.cpp 只将 LLaVA 模型的 llama 部分转换为.gguf。我们需要将视觉部分重新添加进去。为了测试这一点,前往你提供的服务器模型的 GUI,你会看到一个上传图像的选项。如果你这样做,你会得到一个有用的错误,如图 11.10 所示,表明服务器还没有准备好进行多模态服务。

图

图 11.10 我们的模式还没有准备好;我们需要提供一个模型投影器。

将我们的模型转换为第一步是下载一个与 CLIP 类似的多模态投影文件,以便你编码图像。一旦我们可以编码图像,模型就会知道如何处理它们,因为它已经训练过多模态任务。我们不会深入介绍准备投影文件的细节;相反,我们将向你展示在哪里可以找到它。运行以下命令下载此文件,然后移动它:

$ wget https://huggingface.co/cjpais/llava-1.6-mistral-7b-
↪ gguf/resolve/main/mmproj-model-f16.gguf
$ mv mmproj-model-f16.gguf ./models/llava/mmproj.gguf

如果你正在使用不同的模型或自制模型,确保你找到或创建一个多模态投影模型来为你执行该功能。它应该直观地告诉你为什么你需要它:语言模型只读取语言。你可以尝试微调和将图像序列化为字符串,而不是使用多模态投影模型;然而,我们不推荐这样做,因为我们没有看到它带来良好的结果。这会增加运行这些模型所需的 RAM 总量,但增加的量并不大。

提供模型服务

一旦你的模型被转换和量化,启动服务器的命令是相同的,但你必须在末尾添加--MMPROJ path/to/mmproj.gguf。这段代码将允许你向模型提交图像以执行任务,如进行光学字符识别(OCR),我们将图像中的文本转换为实际文本。现在让我们来做这件事:

$./server -m ./models/llava/llava-v1.6-mistral-7b-q4_k_m.gguf --host 
↪ $PI_IP_ADDRESS --api-key $API_KEY --MMPROJ ./models/llava/mmproj.gguf

现在服务器知道如何处理图像了,让我们发送一个请求。与之前我们用来与仅语言模型聊天的 OpenAI API 一致,另一个版本展示了如何调用多模态聊天。代码与列表 11.1 非常相似,因为我们所做的只是添加了一些图像支持。像上一个列表一样,我们使用 OpenAI API 访问我们的 LLM 后端,但我们将基本 URL 更改为我们的模型。主要区别在于我们将图像序列化为字符串,以便它可以包含在对象中,并使用encode_image函数添加一些导入以简化这个过程。唯一的另一个重大变化是将编码后的图像添加到我们发送的消息的内容部分。

列表 11.2 OpenAI 的多模态 GPT-4
import openai

import base64
from io import BytesIO
from PIL import Image

def encode_image(image_path, max_image=512):
    with Image.open(image_path) as img:
        width, height = img.size
        max_dim = max(width, height)
        if max_dim > max_image:
            scale_factor = max_image / max_dim
            new_width = int(width * scale_factor)
            new_height = int(height * scale_factor)
            img = img.resize((new_width, new_height))

        buffered = BytesIO()
        img.save(buffered, format="PNG")
        img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
        return img_str

client = openai.OpenAI(
    base_url="http://0.0.0.0:1234/v1",  
    api_key="1234",     #1
)
image_file = "myImage.jpg"
max_size = 512  
encoded_string = encode_image(image_file, max_size)      #2

completion = client.chat.completions.with_raw_response.create(
    model="gpt-4-vision-preview",
    messages=[
        {
            "role": "system",
            "content": "You are an expert at analyzing images with computer vision. In case of error,\nmake a full report of the cause of: any issues in receiving, understanding, or describing images",
        },
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "Building a website can be done in 10 simple steps:",
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{encoded_string}"
                    },
                },
            ],
        },
    ],
    max_tokens=500,
)

chat = completion.parse()
print(chat.choices[0].message.content)

1 替换为你的服务器的 IP 地址和端口。

2 设置为允许的最大维度(512=1 个瓦片,2048=最大)。

没有什么太花哨或与其他许多次向服务器发送请求有什么不同。你应该记住这个代码的一个小陷阱是,如果没有使用 API 密钥,API 将抛出错误;但如果你在服务器上没有设置一个,你可以传递任何东西,它不会出错。

到此为止!我们已经将我们的语言模型转变为可以接受图像作为输入的模型,并且我们已经将其部署到 Raspberry Pi 上,甚至对其进行了查询。至少,我们希望您已经对其进行了查询,因为如果您没有,让我们告诉您,它非常 !当您在 Pi 上运行多模态服务器时,它将花费数十分钟来编码和表示图像,甚至达到人们通常用来衡量生成速度的每秒令牌数。再次强调,仅仅因为我们可以将这些模型部署到小型设备上,并不意味着您会想要这样做。这就是我们再次建议您不应该在 Pi 上实际运行此程序,即使是在您的家中,如果您想要真正地充分利用它。

11.4.4 在 Google Colab 上提供模型服务

现在我们已经完成了一些这些练习,我们如何改进和扩展这个项目以适应您的生产环境?第一个改进很明显:硬件。当您有数百名客户时,单板 RAM 计算 并不是非常有帮助;然而,它在测试时非常有用,尤其是当您不想浪费钱调试生产环境时。还有其他支持 GPU 的选项,幸运的是,除了 RPi 设置之外,之前讨论的所有步骤都在 Google Colab 的免费层上工作。以下是所有不同的设置步骤:

  1. 设置 llama.cpp:
!git clone https://github.com/ggerganov/llama.cpp && cd 
↪ llama.cpp && make -j LLAMA_CUBLAS=1
    1. 从 Hugging Face 下载:
import os
os.environ[“HF_HUB_ENABLE_HF_TRANSFER”] = “1”
!huggingface-cli download repo/model_name name_of_downloaded_
↪ model --local-dir . --local-dir-use-symlinks False
    1. 服务器命令:
!./server -m content/model/path --log-disable --port 1337
    1. 访问服务器:
from .googlecolab.output import eval_js
print(eval_js(“google.colab.kernel.proxyPort(1337)”))

如您所见,步骤大多相同,但由于我们在 Jupyter 环境中工作,一些细微的调整是必要的,因为通常直接运行代码比运行 CLI 命令要容易。我们没有深入探讨,但 Raspberry Pi 可以使用 docker.io 和其他软件包来创建可用于负责任的 CI/CD 的 docker 镜像。在 Google Colab 环境中这要困难一些。此外,请记住,Google 不会给您无限的 GPU 时间,甚至到了监控您是否打开了 Colab 以“高效”地关闭您的免费 GPU 的程度,所以请确保您只将这些免费资源用于测试和调试。无论如何看,免费的 GPU 是一份礼物,我们应该对它们负责。

您也可以跳过下载整个仓库并每次运行 Make 的步骤。您可以使用 llama.cpp 的 Python 绑定。并且您可以使用 cuBLAS 或 NEON(适用于 Mac GeForce Mx 卡)来使用硬件加速,在执行以下命令时进行 pip 安装:

$ CMAKE_ARGS=”-DLLAMA_CUBLAS=on” FORCE_CMAKE=1 pip install llama-cpp-python

此命令将 llama.cpp 中的大部分代码抽象为易于使用的 Python 绑定。现在让我们通过一个示例来了解如何使用 Python 绑定来制作易于 docker 化和部署的内容。与 API 一起工作与单独使用 LLM 略有不同,但幸运的是,LangChain 非常方便。其整个库都是围绕使用 OpenAI API 构建的,我们使用该 API 来访问我们自己的模型!

在列表 11.3 中,我们将结合我们对 OpenAI API、llama.cpp Python 绑定和 LangChain 的了解。我们将首先设置我们的环境变量,然后我们将使用 LangChain 的ChatOpenAI类,并假装我们的服务器是 GPT-3.5-turbo。一旦我们有了这两样东西,我们就可以完成了,但我们将通过添加一个句子转换器和为 RAG 准备好的提示来扩展它。如果您有一个想要用于 RAG 的数据集,现在是嵌入它并创建 FAISS 索引的时候了。我们将加载您的 FAISS 索引,并在推理时使用它来帮助模型。然后,使用 tiktoken 对其进行标记化,以确保我们不会超载我们的上下文长度。

列表 11.3 OpenAI 但不是多模态 GPT-4
import os
from langchain.chains import LLMChain
from langchain_community.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate
from sentence_transformers import SentenceTransformer
import numpy as np
from datasets import load_dataset
import tiktoken

os.environ["OPENAI_API_KEY"] = "Your API Key"
os.environ[
    "OPENAI_API_BASE"
] = "http://0.0.0.0:1234/v1"     #1
os.environ[
    "OPENAI_API_HOST"
] = "http://0.0.0.0:1234"      #2

llm = ChatOpenAI(
    model_name="gpt-3.5-turbo",      #3
    temperature=0.25,
    openai_api_base=os.environ["OPENAI_API_BASE"],     #4
    openai_api_key=os.environ["OPENAI_API_KEY"],
    max_tokens=500,
    n=1,
)

embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")   #5
tiktoker = tiktoken.encoding_for_model("gpt-3.5-turbo")      #6

prompt_template = """Below is an instruction      #7
that describes a task, 
paired with an input that provides further context. 
Write a response that appropriately completes the request.
    ###Instruction:
    You are an expert python developer.
    Given a question, some conversation history, 
and the closest code snippet we could find for 
the request, give your best suggestion for how 
to write the code needed to answer the User's question.

    ###Input:

    #Question: {question}

    #Conversation History: {conversation_history}

    Code Snippet:
    {code_snippet}

    ###Response:
    """

vectorDB = load_dataset(     #8
    "csv", data_files="your dataset with embeddings.csv", split="train"
)
try:                                                           #9
    vectorDB.load_faiss_index("embeddings", "my_index.faiss")
except:
    print(
    """No faiss index, run vectorDB.add_faiss_index(column='embeddings')
    and vectorDB.save_faiss_index('embeddings', 'my_index.faiss')"""
    )

message_history = []      #10

query = "How can I train an LLM from scratch?"      #11
embedded = embedder.encode(query)
q = np.array(embedded, dtype=np.float32)
_, retrieved_example = vectorDB.get_nearest_examples("embeddings", q, k=1)

formatted_prompt = PromptTemplate(      #12
    input_variables=["question", "conversation_history", "code_snippet"],
    template=prompt_template,
)
chain = LLMChain(llm=llm, prompt=formatted_prompt)    #13

num_tokens = len(           #14
    tiktoker.encode(f"{prompt_template},\n" + "\n".join(message_history) +
↪ query)
    )
)
while num_tokens >= 4000:
    message_history.pop(0)
    num_tokens = len(
    tiktoker.encode(f"{prompt_template},\n" + "\n".join(message_history) +
↪ query)
        )
    )
res = chain.run(       #15
    {
    "question": query,
    "conversation_history": message_history,
    "code_snippet": "",
    }
)
message_history.append(f"User: {query}\nLlama: {res}")

print(res)      #16

1 用您的服务器地址和端口替换。

2 用您的主机 IP 替换。

3 这可以是任何内容。

4 再次

5 为 RAG 嵌入

6 快速检查上下文长度的标记化

7 将提示更改为您想要的任何内容。

8 这里有一个向量ΔB;请随意替换。

9 如果您还没有创建 faiss 或 elasticsearch 或 usearch 索引,请创建。

10 为了跟踪聊天历史

11 搜索向量ΔB

12 格式化提示

13 设置实际的 LLM 链

14 不要超载您的上下文长度。

15 使用您的 API 运行 RAG

16 我们只是打印;在这里做您需要做的任何事情。

所以,这里是我们许多概念真正汇聚的地方。令人惊讶的是,您真的可以在 Raspberry Pi 上执行这个推理和 RAG;您不需要一台巨大的计算机来获得好、可重复的结果。在您达到大约 48 GB 并可以容纳 7B 的全版本以及所有超过那的量化版本之前,这种计算层非常有帮助;目前,之后的所有计算只能带来边际的收益。这个领域正在快速发展,所以寻找在更小的硬件上推理更大模型的新、更快的方法。

这样,我们的原型项目就启动并运行了。它在几乎任何您想要的方向上都非常容易扩展,并且符合行业标准并使用流行的库。添加到这,让它变得更好,如果您有您认为这里没有得到体现的专业知识,请分享它!这个领域是新的,跨学科的知识将推动它向前发展。

摘要

  • 在最小的设备上运行最大的模型需要利用您能想到的所有内存节省技术,比如运行一个轻量级操作系统。

  • 设置远程 Pi 第一次最困难的部分是找到它的 IP 地址。

  • 对于没有加速器的计算受限硬件,您需要使用像 llama.cpp 这样的工具将模型编译到您的架构上运行。

  • 在内存受限的环境中,推理将需要量化。

  • 即使利用所有可用的资源,在边缘设备上运行 LLM 通常会导致比期望的更慢的推理。仅仅因为某件事是可能的,并不意味着它是实用的。

  • 可以通过指向自定义端点来使用 OpenAI 的 API 及其所有包装器,以访问其他模型。

  • 许多开源工具可用于改进模型的提供和用户界面。

  • 量化程度越低,即使模型规模更大,困惑度也越高。

  • 在 Raspberry Pi 上运行多模态模型也是可能的。

  • 我们在 Pi 上运行的相同命令,只需稍作修改就可以用于 Google Collab 或其他云服务提供商进行开发,这使得这些项目比以往任何时候都更容易获得。

  • 设置和部署通常比准备模型对成功项目来说更为重要。

第十二章:生产,一个不断变化的景观:一切才刚刚开始

本章涵盖

  • LLM 在生产中的简要概述

  • LLMs 作为一项技术和对其进行的几个令人兴奋的研究领域

  • 我们的结束语

正如我所设想的网络,我们还没有看到它。未来仍然比过去大得多。——蒂姆·伯纳斯-李(www 的发明者)

哇!在这本书中,我们确实覆盖了大量的内容。你的头脑是不是快要爆炸了?因为我们的确实是这样,我们写了这本书。写这本书并不容易,因为行业一直在不断变化——而且变化很快。试图跟上 LLMs 的发展就像在流沙上建造房屋;你完成了一层,似乎在你开始下一层之前它就已经沉下去了。我们知道这本书的部分内容不可避免地会过时,这就是为什么我们尽力坚持核心概念,这些概念就像沙子中的坚固岩石,永远不会改变。

在本章中,我们想退后一步,回顾一些我们希望你能带走的主要收获。我们花了很多时间深入细节,所以让我们暂时反思一下,看看整个画面,回顾我们已经覆盖的内容。之后,我们将花一点时间讨论该领域的未来,以及我们可以期待看到的一些下一个重大突破。最后,我们将留下我们的最终想法。

12.1 千米视角

在这本书中,我们讨论了大量的内容——从制作词袋模型到在树莓派上部署 LLM API。如果你读完了整本书,那是一项成就。干得好!我们不会回顾所有内容,但我们都想从树木中看到森林,总结一下我们所学到的很多东西。我们可以将大多数想法分为四个截然不同但非常紧密相关的象限:准备、训练、部署和开发。你可以在图 12.1 中看到这些象限。你会注意到,除了这些部分,还有一个与其他部分不同的第五个部分,我们将其标记为潜流。这些是似乎以不同程度影响所有其他象限的元素,以及你在 LLM 产品生命周期的每个阶段都必须关注的事情。

图

图 12.1 LLM 产品生命周期。这里列出了书中讨论的所有关键概念,以及它们通常在生产环境中的位置。潜流是生命周期中每个部分的重要元素——例如,语言学在准备阶段提供信息,在训练和部署阶段创建指标,并影响提示和开发。

希望当我们之前在章节中讨论一个概念时,如果还没有讲清楚,现在应该很清楚这个概念在生产生命周期中的位置。你会注意到,我们可能将一些元素放在了你的当前生产环境并不反映的位置——例如,MLOps 基础设施的配置通常并不发生在准备阶段,而是在第一次需要提供服务时随意拼凑。我们理解这一点。但在准备阶段,我们觉得它应该在那里。花点时间消化你在阅读这本书时所学到的所有内容,并考虑所有这些部分是如何结合在一起的。

在这个抽象和理想化的生产生命周期版本中,让我们转向目前尚未包括其中的事物。五年后,我们可能需要在我们的开发部分添加什么,特别是在考虑到这个领域现在发展如此迅速的情况下?

12.2 LLMs 的未来

当我们撰写这本书时,我们有意专注于你需要的基础知识,以便理解 LLMs 是如何工作的以及如何将它们部署到生产环境中。这些信息至关重要,因为每个用例的生产情况都大不相同。学习如何权衡任何决策的利弊,需要这些基础知识,这样你才有可能做出正确的选择。

与此决定相邻,我们不希望这本书只包含理论。我们希望它是实践性的,有足够的例子,让你作为读者不仅知道事物是如何工作的,而且能感受到它们的感觉——比如感受将一个 70B 模型加载到 GPU 上需要多长时间,如果你在边缘设备上运行该模型,你能感受到用户将会有怎样的体验,以及当你躲在黑暗的山洞里埋头于代码、避开春日温暖的阳光时,你能感受到电脑屏幕柔和的光芒。

在撰写这本书的过程中,我们做出的最艰难的决定之一就是决定专注于当下。我们决定关注那些我们实际上看到人们在今天的生产环境中使用的最佳方法。这个决定之所以艰难,是因为在撰写这本书的过程中,我们遇到了许多令人震惊的研究论文,我们确信这些论文将“改变一切”。然而,由于种种原因,这些研究尚未进入生产阶段。在本节中,我们将打破这一限制,无论行业当前状态如何,都将讨论即将到来的趋势。但不仅仅是研究;公众舆论、诉讼和政治格局也常常塑造着技术的未来。我们将探讨在接下来的几年里,我们认为 LLMs 将走向何方,并提及它们可能采取的一些方向。

12.2.1 政府和监管

在本书的开头,我们承诺向您展示如何创建 LLM 产品,而不仅仅是演示。虽然我们相信我们已经做到了这一点,但我们一直忽略了一个重要细节:产品存在于现实世界中。演示只需要在孤立的环境中工作,而产品必须在一般情况下工作。产品是为了出售的,一旦货币交换发生,就会设定期望,声誉就会受到考验,最终,政府将介入。

虽然一个团队不能为可能永远不会到来的未来法规而建造,但了解您构建的产品可能产生的法律后果是很重要的。一场败诉的案件可以设定先例,引发模仿诉讼的浪潮。由于产品存在于现实世界中,我们最好关注那个世界。

我们中的一员有机会参与犹他州 SB-149 人工智能修正法案的立法过程。该法案主要关注引入对使用 LLM 规避州内消费者保护法律的行动者的责任。目前,每个立法机构都在试图弄清楚其在 AI 方面的管辖权从何开始到何结束,以及如何处理其对保护其选民中的公民和公司所承担的日益增加的责任。在犹他州,州政府对 AI 和 LLM 采取了非常严肃和以商业为先的方法。在整个过程中以及法案本身,立法机构不能创建不与“看哪,一个人”第欧根尼风格的例子相冲突的定义,我们将需要每一份善意来导航 LLM 为监管机构带来的新世界。你如何定义 AI?法案如下定义:

“人工智能”是指一种基于机器的系统,它做出预测、推荐或决策,影响真实或虚拟环境。

这可能包括分段函数到 LLM 代理的任何东西,这意味着您的营销团队不会对您的if语句是处于状态中的 AI 的声明负责。话虽如此,该法案包含了对供应商欺诈行为的详尽和深思熟虑的定义,以及制定了一个 AI 分析和研究计划,以帮助州政府从更长期的角度评估风险和政策,这看起来对犹他州来说是新颖且独特的。犹他州立法机构通过与州内的研究人员、专家、C 级高管和企业家进行咨询,能够完善这项法案,我们鼓励读者参与在您所在的社区和政府中制定有价值且有意义的法规。这是确保法院系统长期准备好在应受惩罚的地方实施惩罚的唯一方式。

版权

在法律担忧的前沿是版权侵权问题。在足够的数据上训练的 LLM 可以模仿或复制作者或创作者的风格,甚至直接一字不漏地剽窃。当考虑到构建自己的枪手以帮助你在创作过程中时,这很令人兴奋,但当你意识到竞争对手也能这样做时,这就不那么令人兴奋了。

可能需要关注的最大诉讼是《纽约时报》诉 OpenAI。¹《纽约时报》正在对 OpenAI 提起法律诉讼,称其聊天机器人未经同意就在《时报》的知识产权上进行了训练。它提供了证据,表明聊天机器人给出的逐字逐句的回应与用户通常需要付费才能看到的专有信息相同。因此,人们担心用户访问其网站的人数会减少,从而减少广告收入。本质上,他们窃取了他们的数据,现在正在信息空间中作为竞争对手使用。

对抗这场斗争的旁观者担心,如果《时报》胜诉,可能会严重阻碍 AI 的发展,导致美国在全球 AI 发展竞赛中的领先地位受损。AI 公司面临更大的版权责任风险,从而带来更大的竞争损失,这意味着更少的创新。相反,他们也担心,如果《时报》败诉,将进一步削弱已经陷入困境的新闻业,在那里,找到可以信赖的高质量报道已经很困难了。这对 AI 发展也是一个巨大的打击,AI 发展总是渴望得到好的干净数据。这似乎是 AI 领域的一个双输局面。

无论谁胜谁负,诉讼的结果都很明显,现行的版权法从未考虑过机器人最终会复制我们。我们需要新的法律,而且不清楚我们的立法者是否具备足够的技术能力来应对这一挑战。因此,我们再次鼓励你参与你所在社区内法规的制定过程。

AI 检测

一个持续让我们心碎的担忧领域来自于“AI 检测”产品的兴起。让我们一开始就明确:这些产品都是骗人的。没有可靠的方法来确定一段文本是由人类还是机器人所写。到这本书的这一部分,我们期望大多数读者也已经得出了这个结论。原因很简单:如果我们能够可靠地确定哪些是哪些不是生成文本,我们就可以创建一个新的模型来击败检测器。这正是对抗性机器学习的全部意义。

在网上有一个流行的玩笑,任何包含“深入挖掘”这个词的阅读内容都必须是由 LLM 撰写的(例如,mng.bz/o0nr)。这个词深入挖掘在生成文本中比在人类语言中更可能出现,但这提出了明显的问题:哪个模型?哪个提示?人类自大的想法,认为仅通过寻找特定的单词就能识别生成内容,是可笑的。但当然,如果人们盲目地相信这种明显的错误,那么他们愿意相信一个更复杂或更先进的系统或算法能够做得更好,也就不足为奇了。

尽管如此,这让我们心碎的原因是因为我们读过一篇又一篇关于学生受到惩罚、论文被给予不及格分数、被迫退课以及在成绩单上被标记剽窃的故事。现在,我们不知道每个案例的细节,但作为相关技术的专家,我们更倾向于相信学生而非其他。

将被“AI 检测”系统标记为有高概率由 AI 撰写的论文与剽窃归为同一类别也是荒谬的。现在,我们并不支持作弊,但大型语言模型(LLMs)是一种新工具。它们帮助我们处理语言,就像计算器帮助我们处理数学一样。我们已经找到了在不创建“计算器检测”系统的情况下教授和评估学生进步的方法。我们也可以再次做到这一点。

好吧,识别生成内容并不是不可能的。一项调查发现,通过简单地搜索“作为人工智能语言模型”或“截至我最后一次知识更新”等短语,他们发现了数百篇在科学期刊上发表的、在 LLMs 帮助下撰写的论文。² 一些短语是明显的迹象,但这些只是由于作者们的纯粹懒惰而被识别。

所有这些中最糟糕的部分是,由于这些检测系统是虚假的、糟糕的,并且充满了误报,它们似乎是由教师任意和随机地执行的。很难相信大多数论文没有被标记,那么为什么只有一小部分学生被点名批评呢?这是因为这些系统似乎已经变成了教师手中的权力和歧视武器,他们会利用这些系统来惩罚他们不喜欢的学生——更不用说这种明显的虚伪,因为我们猜测这些教师中的一些人可能就是那些在论文中使用“作为人工智能语言模型”等短语的人。

偏见与伦理

这不是我们第一次讨论 LLMs(大型语言模型)中发现的偏见和伦理问题,但这次,让我们更深入地探讨这次讨论应得的讨论。假设一个人被绑在轨道上,你什么也没做,电车撞上了他们,结束了他们的生命。你是否有责任?这个被称为“电车问题”的思想实验已经被讨论得淋漓尽致;甚至有一个基于已发表论文的电子游戏(Read Graves 的 Trolley Problem Inc.),提出了数十种变化。我们甚至不会尝试回答这个问题,但我们会简要介绍一下你如何自己决定答案。

分析这种问题的方法远不止两种,但我们只会关注其中两种——道德和伦理——并且我们会简化这些概念,因为这不是一本哲学书。在这里,道德帮助你根据对好/不好的信念来判断过错。伦理帮助我们确定在我们所生活的社会中的法律体系内的实际框架中的后果。如果你对轨道上的人的死亡负有道德责任,你相信这是你最终的责任,你的行为是导致他们死亡的原因。这与伦理责任不同,这意味着你因该行为应受到法律和社会的后果。他们可以同意,但不必如此。改变语境可以帮助阐明区别:如果你告诉某人一把刀不锋利,他们在检查时切到了自己,从道德上讲,他们陷入那种情况可能是你的责任,但从伦理上讲,你会避免被控企图谋杀。

算法创造了数千种这样的情况,在这些情况下,我们的道德和伦理可能并不一致。在塔木德中有一个关于道德和伦理责任的古老例子,它决定如果一个人把另一个人推入水中或火中,而被推的人未能逃脱,那么这个人不是杀人犯。³ 根据你的信仰和你在的法律体系下,Meta 在缅甸的种族灭绝(不是开玩笑⁴)中可能是道德上或伦理上有过错的。在那种情况下,Meta 甚至没有把人推入火中;是他们的算法做的。这显然是一个充满争议和残酷的例子,但 LLMs 创造了一个非常真实的情况,其中机器学习从业者需要实际、一致和可辩护的道德和伦理框架,否则他们可能会在他们监管下发生真正的悲剧。显然,我们不是道德的仲裁者,也不会评判你在那里的位置,但你仍然应该考虑你创建的任何系统的更广泛背景。

法律正在到来

我们可以肯定的一件事是,监管将会到来,公司将对它们的 AI 代理的行为负责。加拿大航空通过法院判决得知这一点,法院裁定该公司必须遵守其聊天机器人完全编造的退款政策(mng.bz/pxvG)。该机器人提供了错误的信息。它确实将客户链接到了正确的退款政策;然而,法院正确地质疑了“为什么客户需要在网站的另一部分找到的信息在网站的其他部分再次进行双重检查。”

我们已经看到过类似的案例,用户通过提示工程技巧欺骗了雪佛兰的 LLM 聊天机器人,以 1 美元的价格出售了一辆 2024 年的塔霍车型(mng.bz/XVmG),DPD 在一位客户让它承认自己是世界上最差的快递公司后,不得不“关闭其 AI 元素”。⁵正如我们之前所说,即使有现有的立法,也很难判断 LLM 在道德上可以做什么。当然,这也引发了一个问题:如果聊天机器人获得了销售汽车的许可并完成了这样的交易,客户的恶意互动是否真的重要,或者公司是否仍然在道德上对维护这样的交易负有责任。

对 LLM 生成的内容负责足以让你三思而后行,考虑你可能考虑使用它的许多应用。风险越高,你应该花更多的时间暂停并考虑潜在的法律后果。我们强烈建议调整你的提示工程系统,设置护栏以保持你的代理在任务上,并且绝对确保保存你的日志并保留客户聊天记录。

12.2.2 LLMs 正在变得更大

另一件我们可以确定的事情是,在不久的将来,我们还将继续看到模型变得越来越庞大。由于更大的模型持续表现出涌现行为,公司没有理由停止采取这种方法,因为简单地投入资金似乎能带来更多的收益。更不用说,对于投入最多的公司来说,更大的模型更难复制。正如你可能发现的,小型公司竞争的最佳方式是创建更小、更专业的模型。最终,只要我们有足够大的训练数据集来容纳更多的参数,我们就可以期待看到更多的参数被塞入模型中,但关于我们是否曾经有过足够的数据来证明“通用智能”(如 AGI)的问题,仍然像以往一样模糊不清。

更大的上下文窗口

不仅是大模型。我们非常兴奋地看到上下文长度也在增长。当我们开始编写这本书时,这是一个真正的限制。很少看到上下文长度超过 10K 令牌的模型。当时 ChatGPT 只提供最多 4,096 个令牌的长度。一年后,我们看到 Gemini 1.5 Pro 这样的模型提供了最多 1 百万个令牌的上下文长度,研究人员指出,它在测试案例中可以处理多达 1 千万个令牌(mng.bz/YV4N)。为了更直观地说明,整个七部《哈利·波特》系列共有 1,084,170 个单词(我没有数过;wordsrated.com/harry-potter-stats/),根据你的分词器,这大约相当于 1.5 百万个令牌。在这些长度下,很难相信有任何限制。

显然,挑战仍然存在。这些具有近乎无限上下文窗口的更大模型通常按令牌收费。如果模型不强迫用户发送更小的查询,那么用户的钱包就会受到影响。更不用说,如果你正在阅读这本书,你很可能对可以自己部署的小型开源模型更感兴趣,而这些模型中许多确实仍然有必须与之合作的限制性上下文大小。不过,不用担心;现在和将来,即使是更小的模型也将拥有百万级别的上下文窗口。这个领域正在进行许多有趣的研究。如果你感兴趣,我们建议你查看 RoPE⁶、YaRN⁷和 Hyena⁸。

下一个注意力

当然,更大的上下文窗口是很好的,但它们也有代价。记住,在大型语言模型(LLM)的中心是注意力算法,其复杂度是二次的——这意味着我们投入的数据越多,我们就需要投入更多的计算资源。推动研究社区的一个挑战是找到下一个不遭受这种相同问题的注意力算法。我们能否构建一个仅具有线性复杂度的新算法的 transformers?这正是现在的十亿美元问题。

在这个领域有许多竞争性的创新,我们甚至没有时间讨论我们所有绝对最喜欢的。其中两个最喜欢的分别是 MAMBA,作为 transformers 的替代品,以及 KAN,作为多层感知器(MLPs)的替代品。特别是 MAMBA,它是对状态空间模型(SSMs)的改进,并将其融入了一个无注意力的神经网络架构中。⁹ 单独来看,它并不那么令人印象深刻,因为它需要大量的硬件黑客技术才能使其具有一定的性能。然而,后来出现了 JAMBA,这是一个 MAMBA 风格的模型,它使用了混合 SSM-transformer 层和联合注意力。¹⁰ 这种混合方法似乎为我们提供了两者的最佳结合。

为了让您亲身体验,在列表 12.1 中,我们将对 JAMBA 模型进行微调和运行推理。这个模型是一个专家混合模型,拥有 520 亿个参数,其实现将允许在 80GB GPU 上实现 14 万 K 的上下文长度,这比仅使用注意力模型要好得多。这个例子直接改编自 Hugging Face 模型卡片,所以与所有其他简单的 transformer 实现相比,语法应该非常熟悉,我们对尝试新事物如此容易感到非常感激。

对于训练部分,遗憾的是,即使在半精度下,模型也太大,无法适应单个 80GB GPU,因此您必须使用 Accelerate 在多个 GPU 之间并行化以完成训练。如果您没有现成的计算资源,您可以完成到分词器的导入,然后跳过训练部分,改动非常小。我们并没有做什么特别的事情;我们将用于训练的数据集只是一些从 Goodreads 检索的著名作者的英文名言,包括名言、作者和标签,所以如果您决定跳过微调,请不要觉得自己错过了什么。我们将首先加载分词器、模型和数据集。

列表 12.1 JAMBA 的微调和推理
from trl import SFTTrainer
from peft import LoraConfig
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    TrainingArguments,
)
from transformers import BitsAndBytesConfig
import torch
from datasets import load_dataset

tokenizer = AutoTokenizer.from_pretrained("ai21labs/Jamba-v0.1")
model = AutoModelForCausalLM.from_pretrained(
    "ai21labs/Jamba-v0.1", device_map="auto"
)

dataset = load_dataset("Abirate/english_quotes", split="train")

一旦所有这些都在内存中(如果您的硬件有限,您可以流式传输数据集),我们将创建训练参数和一个 LoRA 配置,以帮助微调在更小的硬件上工作:

training_args = TrainingArguments(
    output_dir="./results",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    logging_dir="./logs",
    logging_steps=10,
    learning_rate=2e-3,
)
lora_config = LoraConfig(
    r=8,
    target_modules=["embed_tokens", "x_proj", "in_proj", "out_proj"],
    task_type="CAUSAL_LM",
    bias="none",
)

现在,到了高潮部分,类似于 sklearn 的model.fit(),transformers 的trainer.train()已经成为一个标志,表明任何人都可以学习如何与最先进的机器学习模型交互。一旦训练完成(对我们来说大约需要不到一个小时),我们将保存分词器和模型的本地版本,并删除内存中的模型:

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    args=training_args,
    peft_config=lora_config,
    train_dataset=dataset,
    dataset_text_field="quote",
)

trainer.train()

tokenizer.save_pretrained("./JAMBA/")
model.save_pretrained("./JAMBA/")

del model

接下来,我们将以内存高效的方式重新加载模型,用于推理。在 80GB GPU 上,使用 BitsandBytes 配置以 8 位加载,您现在可以在单个 GPU 上拟合模型和大量数据。以 4 位加载允许在 A100 或两个 3090 上实现,类似于 70B 参数的 transformer。使用量化将其降低到 1 位模型,您可以在单个 3090 上拟合这个模型和大量数据。我们将使用以下 8 位推理实现并在其上运行推理:

quantization_config = BitsAndBytesConfig(
    load_in_8bit=True, llm_int8_skip_modules=["mamba"]
)
model = AutoModelForCausalLM.from_pretrained(
    "ai21labs/Jamba-v0.1",
    torch_dtype=torch.bfloat16,
    attn_implementation="flash_attention_2",
    quantization_config=quantization_config,
)
input_ids = tokenizer(
    "In the recent Super Bowl LVIII,", return_tensors="pt"
).to(model.device)["input_ids"]

outputs = model.generate(input_ids, max_new_tokens=216)

print(tokenizer.batch_decode(outputs))

到目前为止,我们几乎每个月都会被 LLM 系统各个部分的替代方案所震撼。在这里,我们想将您的注意力引回到 LLM 获得重大突破的地方:“注意力即一切”。¹¹那篇论文表明,你可以使用简单的 MLP 获得惊人的结果,仅使用注意力来弥合差距。我们正进入一个新纪元,我们不再只关注我们需要什么,而是关注为了获得最佳结果我们想要什么。例如,我们想要低于二次方的注意力替代方案,以匹配或超越闪速注意力在速度上的表现。我们想要无注意力的 transformer 和数百万长度的上下文长度,没有“中间丢失”的问题。我们想要没有精度或学习速度下降的密集 MLP 的替代方案。我们正逐步获得所有这些以及更多。

推进压缩的边界

在降至 INT4 之后,有实验性的量化策略可以将模型进一步降至 INT2。INT2 70B 模型仍然表现良好,这让许多人感到惊讶。然后有研究表明,我们可能甚至可以进一步减小到每个权重 1.58 位或使用三进制和其他更小的算子达到 0.68 位。想试试吗?Llama3 70B 已经在 GGUF、GPTQ 和 AWQ 格式中实现了 1 位量化,它只占用 16.6 GB 的内存。尽情尝试吧!

这还有另一个维度,它不涉及压缩模型,而是将模型是一个整体的想法与将模型视为层和参数集合的想法解耦。推测性解码为我们提供了快速访问大型模型的另一种方式。推测性解码不仅需要足够的内存来加载一个大型模型,还需要一个与之并行的较小模型——想想蒸馏模型。目前生产中常用的一个例子是 Whisper-Large-v3 和 Distil-Whisper-Large-V3。Whisper 是一个多模态 LLM,专注于语音到文本问题,但推测性解码可以与任何具有相同架构但大小不同的两个模型一起工作。

这种方法使我们能够更快地(有时是直接的 2 倍速度提升)采样更大的模型,通过并行计算多个标记,并通过一个允许我们同时完成一个步骤并验证该步骤是简单还是困难的近似“助手”模型。基本思路是这样的:使用更小、更快的 Distil-Whisper 模型来生成关于最终结果的猜测,并允许 Whisper 并行评估这些猜测,忽略那些它将执行相同操作的情况,并纠正那些它将改变的情况。这允许我们以较小模型的速度和较大模型的准确性。

在列表 12.2 中,我们展示了在英语音频数据集上进行的推测性解码。我们将加载 Whisper 和 Distil-Whisper,加载数据集,然后向生成关键字参数(generate_kwargs)添加一个 assistant_model。您可能会问,这个系统如何知道辅助模型只意味着帮助解码,正如其名称所暗示的那样?嗯,我们用 AutoModelForCausalLM 而不是语音序列到序列版本加载辅助模型。这样,模型将只帮助并行于较大模型的大解码步骤。完成这些后,我们可以自由测试。

列表 12.2 使用 Whisper 的推测性解码
from transformers import (
    AutoModelForCausalLM,
    AutoModelForSpeechSeq2Seq,
    AutoProcessor,
)
import torch
from datasets import load_dataset

from time import perf_counter
from tqdm import tqdm

from evaluate import load

device = "cuda:0" if torch.cuda.is_available() else "cpu"
print(f"Device: {device}")
attention = "sdpa"
torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32

model_id = "openai/whisper-large-v3"
assistant_model_id = "distil-whisper/distil-large-v3"
model = AutoModelForSpeechSeq2Seq.from_pretrained(
    model_id,
    low_cpu_mem_usage=False,
    use_safetensors=True,
    attn_implementation=attention,
    torch_dtype=torch_dtype,
).to(device)
processor = AutoProcessor.from_pretrained(model_id)
assistant_model = AutoModelForCausalLM.from_pretrained(
    assistant_model_id,
    low_cpu_mem_usage=False,
    use_safetensors=True,
    attn_implementation=attention,
    torch_dtype=torch_dtype,
).to(device)

dataset = load_dataset(
    "hf-internal-testing/librispeech_asr_dummy",
    "clean",
    split="validation",
    trust_remote_code=True,
)
wer = load("wer")

generate_kwargs_1 = {
    "language": "en",
    "task": "transcribe",
}
generate_kwargs_2 = {
    "language": "en",
    "task": "transcribe",
    "assistant_model": assistant_model,
}

spec_decoding = False
for i, generate_kwargs in enumerate([generate_kwargs_1, generate_kwargs_2]):
    all_time = 0
    predictions = []
    references = []
    for sample in tqdm(dataset):
        audio = sample["audio"]
        inputs = processor(
            audio["array"],
            sampling_rate=audio["sampling_rate"],
            return_tensors="pt",
        )
        inputs = inputs.to(device=device, dtype=torch_dtype)
        start_time = perf_counter()
        output = model.generate(
            **inputs,
            **generate_kwargs,
        )
        gen_time = perf_counter() - start_time
        all_time += gen_time
        predictions.append(
            processor.batch_decode(
                output, skip_special_tokens=True, normalize=True
            )[0]
        )
        references.append(processor.tokenizer.normalize(sample["text"]))
    score = wer.compute(predictions=predictions, references=references)
    if i > 0:
        spec_decoding = True
    print(f"Speculative Decoding: {spec_decoding}")
    print(f"Time: {all_time}")
    print(f"Word Error Rate: {score}")

在我们的测试中,我们观察到 Whisper-Large-V3 在使用缩放点积注意力机制的情况下,处理完所有 73 个示例大约需要 42 秒。使用推测性解码后,时间降至 18.7 秒,但精确的词错误率(WER)保持不变。因此,速度提高了近 2 倍,而准确性没有丝毫下降。是的,相当疯狂。

在这一点上,我们想知道,“为什么每个人不总是用这个来做所有事情?”这种方法的一些缺点如下:首先,它在较短的序列中效果最好。对于 LLM 来说,这低于 128 个生成标记或大约 20 秒的音频处理。对于更长的生成,速度提升将微不足道。除此之外,我们并不总是能够访问到完美兼容的大模型和小模型对,比如 BERT 与 DistilBERT。最后一个原因是,真正了解它的人非常少,尽管它的实现很简单。

最终,无论是子比特量化、推测性解码还是其他进步,LLM 们比任何其他技术都更推动研究进入压缩方法,观察新技术如何改变格局是非常有趣的。随着这些方法的发展,我们可以将模型推向更小、更便宜的硬件,使该领域更加易于接触。

12.2.3 多模态空间

我们对多模态的潜力感到非常兴奋。回到第二章,多模态是我们尚未看到许多解决方案出现的主要语言特征之一,我们正在看到向尝试解决语音学的转变。然而,人类操作的模式不仅仅是音频。因此,将语音学、语义学和语用学结合起来,在同一个嵌入空间(用于比较)中获得尽可能多的上下文(对于比较)的推动力非常强烈。考虑到这一点,以下是一些值得关注的领域点。

我们首先想引起注意的是 ImageBind 项目,该项目展示了我们不必试图将模型限制在摄入每种类型的数据,相反,我们可以将每种类型的数据压缩到一个模型已经熟悉并能处理的嵌入空间中。您可以在官方演示中查看:imagebind.metademolab.com/

ImageBind 建立在多模态投影模型(如 CLIP)已经展示了一段时间的能力之上:创建和处理嵌入的能力是确定性 LLM 系统背后的真正力量。您可以使用这些模型进行非常快速地搜索,包括之前几乎不可能完成的搜索,例如要求找到与上传音频剪辑声音相似的动物图片。

OneLLM 将这种逻辑颠倒过来,使用一个模型和一个多模态编码器来统一和嵌入八个模态,而不是 ImageBind 示例中使用的六个不同编码器来在相同维度中嵌入六个模态。它可以在以下链接找到:onellm.csuhan.com/。OneLLM 的核心思想是使用语言来对齐统一的编码器,这为多模态提供了一种独特的视角,它关注的是编码过程而不是结果。

我们对这个领域的研究感到非常兴奋。这项研究能够帮助弥合模型生态系统中语音学和语用学之间的差距,并允许实现更类似人类的理解和交互,尤其是在搜索领域。

12.2.4 数据集

由于 LLMs 的引入,我们在这个行业内看到的一个令人兴奋的变化是,公司终于开始理解管理和治理他们数据的重要性。对于一些人来说,这是推动他们微调自己的 LLMs 并加入激动人心的 AI 产品交付竞赛的动力。对于另一些人来说,这是担心自己变得过时,因为这些系统的能力远远超过了以前的技术;他们发现,只有他们的数据才能提供任何类型的护城河或保护竞争。而对于所有人来说,他们担心会犯他们看到其他公司犯过的同样的错误。

LLMs 不仅仅是推动因素;它们还在帮助团队标注、标记、组织和清理数据。许多公司堆积了大量的数据,却不知道如何处理,但有了 CLIP 等 LLM 模型,图像字幕变得轻而易举。一些公司发现,仅仅创建他们的文本、图像、音频和视频的嵌入空间,就使他们能够为之前无结构的数据集创建有意义的结构。结构化数据更容易操作,为搜索、推荐和其他洞察打开了大门。

目前在行业中我们看到的一个缺失的方面是有价值的开源数据集,尤其是在评估方面。许多目前用于评估模型的基准测试依赖于多项选择题,但这对于试图创建一个 LLM 应用的人来说效率低下。在现实世界中,你的用户何时会以多项选择题的形式向你的模型提问?几乎永远不会。人们在对话和寻求帮助时提出开放式问题,因为他们自己也不知道答案。然而,这些评估数据集已经成为基准,仅仅是因为它们对研究人员来说很容易收集、汇编和评估准确性。

此外,我们相信另一个不可避免的需求是更多语言表示。世界是一个由多种语言和方言构成的织物,每种语言都承载着其独特的文化细微差别和交流微妙之处。然而,许多语言在现有数据集中代表性不足,导致模型偏向于更占主导地位的语言。随着技术的日益全球化,包括更广泛的语言至关重要。添加多种语言不仅促进了包容性,还增强了语言模型在不同国际环境中的准确性和适用性,弥合沟通差距,促进一个更加紧密相连的世界。想象一下,如果你的初创公司不需要支付任何人就能获得有关进入中国、俄罗斯或沙特阿拉伯以扩大市场的准确信息。

12.2.5 解决幻觉问题

有大量证据表明,LLM 中包含的信息比它们愿意给出的要多,甚至有更多证据表明,人们在提示时通常要么很糟糕,要么恶意。因此,你会发现,幻觉是试图开发一个始终如一地提供结果的应用的最大的障碍之一。这个问题让许多习惯于确定性计算机算法且很少处理非确定性系统的软件工程团队感到沮丧。对于许多更熟悉这些类型系统的统计学家来说,幻觉被视为一个特性,而不是一个错误。无论你站在哪一方,都有大量研究投入到处理幻觉的最佳方法中,这是你应该关注的领域之一。

更好的提示工程

一个有趣且随着时间的推移显示出巨大改进的领域是提示工程。一个有助于减少幻觉的提示工程工具是 DSPy。我们在第七章中简要介绍了它,但在这里我们将给出一个如何工作的例子,以及为什么它可以是解决你 LLMs 中幻觉的有帮助的一步。我们在整本书中多次讨论了 LLMs 在数学方面特别糟糕的事实,甚至简单的数学,我们也讨论了原因,但我们并没有真正讨论除了改进你的分词之外的其他解决方案。所以,在列表 12.3 中,我们将展示如何通过零分词更改、零微调和没有 LoRAs 或 DoRAs,仅仅优化你的提示来告诉模型如何回答你提出的问题。

我们将使用 dspy-ai Python 包和 Llama3-8B-Instruct 来完成这项工作。我们将首先加载和量化模型,以便在大多数 GPU 和 Grade-School Math 8K 数据集上运行。我们选择这个数据集是因为它是一个数学问题集合,作为一个已经从小学毕业的人,你可能甚至不需要计算器就能解决这些问题。我们将为我们的训练集和测试集(开发集)使用 200 个示例,尽管我们建议你尝试这些数字,以找到最适合你用例的最佳比例,避免数据泄露。

列表 12.3 DSPy for math
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers import BitsAndBytesConfig
import torch
import dspy
from dspy.datasets.gsm8k import GSM8K, gsm8k_metric
from dsp.modules.lm import LM
from dspy.evaluate import Evaluate
from dspy.teleprompt import BootstrapFewShot

model_name = "meta-llama/Meta-Llama-3-8B-Instruct"
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    model_name, 
    device_map="auto", 
    quantization_config=quantization_config,
    attn_implementation="sdpa",
)
tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True,)

gms8k = GSM8K()
gsm8k_trainset, gsm8k_devset = gms8k.train[:30], gms8k.dev[:100]

现在我们已经准备好了导入和加载,我们需要解决的事实是我们使用 transformers 加载了 Llama3,而不是 DSPy。DSPy 期望与使用 OpenAI API 的模型交互,但我们从 Hugging Face 加载了一个本地模型,DSPy 最近为其包添加了 HFModel,现在可以轻松导入,而不是需要定义包装器。首先,我们创建一个简单的函数来映射 API 之间的任何关键字参数差异,比如max_tokensmax_new_tokens,然后我们创建一个类,它将作为我们的模型生成答案和优化提示的包装器。一旦准备好了,我们将加载 DSPy:

def openai_to_hf(**kwargs):
    hf_kwargs = {}
    for k, v in kwargs.items():
        if k == "n":
            hf_kwargs["num_return_sequences"] = v
        elif k == "frequency_penalty":
            hf_kwargs["repetition_penalty"] = 1.0 - v
        elif k == "presence_penalty":
            hf_kwargs["diversity_penalty"] = v
        elif k == "max_tokens":
            hf_kwargs["max_new_tokens"] = v
        elif k == "model":
            pass
        else:
            hf_kwargs[k] = v

    return hf_kwargs

class HFModel(LM):
    def __init__(
        self,
        model: AutoModelForCausalLM,
        tokenizer: AutoTokenizer,
        **kwargs
    ):
        """wrapper for Hugging Face models
        Args:
            model (AutoModelForCausalLM): HF model identifier to load and use
            tokenizer: AutoTokenizer
        """
        super().__init__(model)
        self.model = model
        self.tokenizer = tokenizer
        self.drop_prompt_from_output = True
        self.history = []
        self.is_client = False
        self.device = model.device
        self.kwargs = {
            "temperature": 0.3,
            "max_new_tokens": 300,
        }

    def basic_request(self, prompt, **kwargs):
        raw_kwargs = kwargs
        kwargs = {**self.kwargs, **kwargs}
        response = self._generate(prompt, **kwargs)

        history = {
            "prompt": prompt,
            "response": response,
            "kwargs": kwargs,
            "raw_kwargs": raw_kwargs,
        }
        self.history.append(history)

        return response

    def _generate(self, prompt, **kwargs):
        kwargs = {**openai_to_hf(**self.kwargs), **openai_to_hf(**kwargs)}
        if isinstance(prompt, dict):
            try:
                prompt = prompt["messages"][0]["content"]
            except (KeyError, IndexError, TypeError):
                print("Failed to extract 'content' from the prompt.")
        inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device)

        outputs = self.model.generate(**inputs, **kwargs)    
        if self.drop_prompt_from_output:
            input_length = inputs.input_ids.shape[1]
            outputs = outputs[:, input_length:]
        completions = [
            {"text": c}
            for c in self.tokenizer.batch_decode(
                outputs, skip_special_tokens=True
            )
        ]
        response = {
            "prompt": prompt,
            "choices": completions,
        }
        return response
    def __call__(
        self, prompt, only_completed=True, return_sorted=False, **kwargs
    ):
        assert only_completed, "for now"
        assert return_sorted is False, "for now"

        if kwargs.get("n", 1) > 1 or kwargs.get("temperature", 0.0) > 0.1:
            kwargs["do_sample"] = True

        response = self.request(prompt, **kwargs)
        return [c["text"] for c in response["choices"]]

print("Model set up!")              #1
llama = HFModel(model, tokenizer)

dspy.settings.configure(lm=llama)     #2

1 Sets up the LM

2 Sets up ΔSPY to use that LM

现在我们已经准备好使用一个大型语言模型(LLM)来参加我们的数学测试,让我们来测试一下。我们将首先建立一个基线。我们将在QASignature类中定义一个简单的思维链(CoT)样式的提示,我们将使用它来定义一个零样本版本,用作基线。这个提示可能非常接近你之前见过的提示,所以希望这将是一个非常相关的任务演示,你可能会在进行的任务。对于评估,我们使用 DSPy 的gsm8k_metric,我们在顶部导入以进行评估,但你始终可以创建自己的:

class QASignature(dspy.Signature):         #1
    (
    """You are given a question and answer"""
    """and you must think step by step to answer the question. """
    """Only include the answer as the output."""
    )
    question = dspy.InputField(desc="A math question")
    answer = dspy.OutputField(desc="An answer that is a number")

class ZeroShot(dspy.Module):
    def __init__(self):
    super().__init__()
    self.prog = dspy.Predict(QASignature, max_tokens=1000)

    def forward(self, question):
    return self.prog(question=question)

evaluate = Evaluate(        #2
    devset=gsm8k_devset,
    metric=gsm8k_metric,
    num_threads=4,
    display_progress=True,
    display_table=0,
)

print("Evaluating Zero Shot")      #3
evaluate(ZeroShot())

1 Δefines the QASignature and CoT

2 Sets up the evaluator, which can be used multiple times

3 Evaluates how the LLM does with no changes

输出是

29/200 14.5%

使用我们简单的零样本 CoT 提示,Llama3 只正确回答了 14.5%的问题。这个结果可能看起来并不理想,但实际上它比仅仅在没有任何提示的情况下运行模型要强得多,后者正确率大约只有 1%到 5%。

在解决了基线问题之后,让我们继续探讨 DSPy 的核心内容,即优化提示以查看它能带我们走到哪里。自从原始论文发表以来,人们对 CoT 提示的看法已经发生了一些变化。在业界,CoT 的含义已经超越了仅仅在提示中添加“逐步思考”这一基本提示工程方法,而允许模型通过少量提示自行获得其最终输出的理由被认为是新的 CoT,这正是 DSPy 框架使用这些术语的方式。有了这个解释,我们将继续使用dspy.ChainOfThought函数创建一个CoT类,然后像评估我们的ZeroShot类一样评估它:

config = dict(max_bootstrapped_demos=2)     #1

class CoT(dspy.Module):
    def __init__(self):
    super().__init__()
    self.prog = dspy.ChainOfThought(QASignature, max_tokens=1000)

    def forward(self, question):
    return self.prog(question=question)

print("Creating Bootstrapped Few Shot Prompt")         #2
teleprompter = BootstrapFewShot(metric=gsm8k_metric, **config)
optimized_cot = teleprompter.compile(
    CoT(), trainset=gsm8k_trainset, valset=gsm8k_devset
)
optimized_cot.save("optimized_llama3_math_cot.json")

print("Evaluating Optimized CoT Prompt")            #3
evaluate(optimized_cot)                  
#149/200 74.5%

1 设置优化器

2 优化提示

3 评估我们的“optimized_cot”程序

看看吧!如果仅仅通过改变提示,准确性就从 14.5%跃升至 74.5%,这不会让你感到惊讶——记住我们还没有进行任何微调或训练——我们不知道会发生什么。人们正在猜测提示工程师的时代是否已经结束,但我们认为它才刚刚开始。话虽如此,“提出一个巧妙的字符串而不进行后续跟进”的时代已经结束,而且根本就不应该开始。在这个例子中,我们使用了任意的边界,对数据集的各个部分和数字完全没有思考,并且没有包含任何有助于模型访问以改进的工具或上下文。如果我们这样做,你会发现,在应用了本书中的所有提示工程技巧之后,将模型的能力提升到令人震惊的水平并不困难,即使是在 LLM 通常表现不佳的领域——比如数学。

Grounding

如果你正在寻找对抗幻觉的方法,你可能会遇到“grounding”这个术语。Grounding 是指我们在提示中为 LLM 提供必要的上下文。通过提供它所需的信息,我们正在帮助为生成内容提供一个坚实的基础,这样它就很少会凭空想象出幻象。如果这听起来很熟悉,那是因为我们在本书中已经多次使用了一种最常见的 grounding 技术,即 RAG。

术语RAG(检索增强生成)在字面上与 grounding 同义,因为我们实际上是根据提示检索适当的上下文,然后使用它来增强 LLM 生成的文本。然而,RAG 已经与使用 VectorDB 进行语义检索的部分同义。技术上,你可以使用任何类型的搜索算法或任何类型的数据库,但如果你告诉业界人士你已设置了一个 RAG 系统,他们会假设前者架构。

通过这个澄清,RAG 应用在回答简单问题方面最为有用。考虑这样一个问题:“Gal Gadot 的丈夫目前做什么工作?”这实际上包含两个问题,“Gal Gadot 的丈夫是谁?”一旦我们知道答案,接下来就是“他做什么?”RAG 单独解决这类多步骤问题相当糟糕,因为相似度向量搜索可能会返回许多关于 Gal Gadot 的文章,但可能没有关于她的丈夫 Jaron Varsano 的文章。

我们可以通过一种我们尚未涉及的重要方式来增强这种方法:使用知识图谱。知识图谱以一种捕捉实体之间关系的结构存储信息。这种结构由代表对象的节点和代表关系的边组成。像 NEO4J 这样的图数据库使得创建和查询知识图谱变得容易。而且,事实证明,知识图谱在回答更复杂的多部分问题方面非常出色,在这些问题中,你需要连接信息片段之间的联系。为什么?因为它们已经为我们连接了这些点。

许多在 RAG 上努力寻求价值但未能成功的团队,一旦从向量数据库过渡到图数据库,就能看到大幅度的改进。但这伴随着两个主要障碍。首先,我们不能再简单地嵌入我们的提示并拉取相似匹配;我们面临着一个更艰巨的任务,那就是想出一个方法,将我们的提示转换为图数据库能够理解的问题。虽然有几个方法可以解决这个问题,但这又是一个 NLP 问题。幸运的是,事实证明,LLM 在这方面非常擅长!其次,可能更大的问题是,将你的文档转换为知识图谱要困难得多。这就是为什么向量数据库变得如此受欢迎——将你的数据转换为嵌入以进行搜索变得容易。将你的数据转换为知识图谱将需要更多的工作和额外的专业知识,但这确实可以为你的未来发展打下坚实的基础。

目前,很少有团队愿意投资额外的数据工程,将他们的数据准备成知识图谱。大多数公司仍在寻找快速的成功,围绕 LLM API 构建简单的包装器。随着行业的成熟,我们相信我们将开始看到组织从他们的专有数据转向构建知识图谱,以从他们的 LLM 应用中获得更好的性能。

知识编辑

另一个有前景的研究领域,用于对抗幻觉,是知识编辑。知识编辑是高效调整特定行为的过程。理想情况下,这看起来就像手术,我们精确地进入并改变当我们得到错误响应时激活的确切模型权重,如图 12.2 所示。知识编辑可以用于许多事情,但它通常用于对抗事实退化——随着时间的推移,事实会发生变化,比如谁是当前超级碗的获胜者或任何个别国家的现任总统。我们可以重新训练或微调模型,但这些通常是更重的解决方案,可能会以意想不到的方式改变模型,而我们所想要的只是更新一些事实。

图

图 12.2 知识编辑是一种技术,本质上是对模型进行手术,以直接插入、更新或删除信息。

知识编辑是一个有趣的研究领域,遗憾的是,我们在这本书中没有足够的空间深入探讨。已经创建了许多算法和技术来实现它,如 ROME、MEND 和 GRACE。对于那些有兴趣使用这些技术中任何一种的人,我们建议首先查看github.com/zjunlp/EasyEdit上的 EasyEdit。EasyEdit 是一个实现了最常见知识编辑技术的项目,并提供了一个易于利用它们的框架。它包括示例、教程等,以帮助你开始。

12.2.6 新硬件

就像大多数流行的技术一样,大型语言模型(LLMs)已经创造了一个激烈的市场竞争。虽然大多数公司仍在竞争功能和特性,但也有一些明确的动力使它们更快、更便宜。我们已经讨论了许多你可以采用的方法,比如量化编译。我们预计将看到更多围绕硬件的创新。

事实上,OpenAI 的首席执行官 Sam Altman 一直在努力筹集高达 7000 亿美元的基金,用于投资半导体行业。¹² 我们之前已经讨论过全球 GPU 短缺的问题,但没有人像一些最大的玩家那样对此感到烦恼。这笔投资将不仅仅是为了满足需求;它还将加速对像应用特定集成电路(ASICs)这样的更好芯片的开发和研究。

我们在这本书中多次讨论并使用了 GPU,但 GPU 并不是为 AI 设计的;它是为图形设计的。当然,这个事实并没有阻止英伟达短暂地成为世界上最有价值的公司。¹³ ASIC 是为特定任务设计的;一个例子是谷歌的 TPUs 或张量处理单元。专为处理 AI 工作负载设计的 ASIC 是 NPU(神经网络单元),而且可能性很大,你以前从未听说过,或者至少从未见过 NPU 芯片。我们指出这一点是为了表明仍有很大的改进空间,我们很可能会在未来看到从更好的 GPU 到 NPU 以及介于两者之间的大量新加速器。更多信息,请参阅 Cerebras (cerebras.ai/product-chip/)。

本书的一位作者在英特尔和美光工作了一段时间,负责开发现在已停产的称为 3D XPoint(3DxP)的内存技术。3DxP 的细节对于这次讨论并不重要;它提供的,极快且便宜的内存,才是关键。它以 Optane 品牌销售了几年,甚至赢得了“有史以来最快的 SSD”的美誉。¹⁴ 这种技术证明其速度几乎与 RAM 相当,但生产成本几乎与 NAND 闪存相当,并且可以用来替代任何一种。

想象一个世界,每个处理器都方便地拥有 500 GB 或甚至 1 TB 的内存空间。我们之前讨论的大多数限制都将简单地消失。你可以将整个 GPT-4 大小的 LLM 加载到一个 GPU 上。你不必担心并行化或额外开销带来的利用率问题。我提到过 3DxP 也是非易失性的吗?加载一次模型,就完成了;即使你需要重新启动服务器,也不必重新加载它,这将使自动扩展等任务变得容易得多。

3DxP 是一种已经在市场上证明了自己的技术,它能够胜任,但仍然因为人们认为需求不足而受到影响。消费者不知道如何利用它提供的内存层次结构中的这一新层。就个人而言,随着 LLM 的到来,作者们现在看到了对这种技术的巨大需求。我们只需等待并观察半导体行业是否会决定重新投资。

12.2.7 代理将变得有用

最后,我们相信基于 LLM 的代理最终将不仅仅是一个只在演示中起作用的创新。我们看到的许多代理只是魔术般的壮举,或者说应该说是烟雾和镜子,只是在大模型上抛出一些提示工程技巧。它们中的几个甚至能正常工作——即使是在有限的范围内——这也揭示了可能性。

我们已经看到几家公司在追逐圣杯,构建代理来取代软件工程师。实际上,你也会看到他们试图构建代理来取代医生、销售助理或经理。但就像许多公司和 AI 专家曾经承诺我们将在不久的将来拥有自动驾驶汽车一样,那个“不久的将来”一直在逃避我们。请别误会:并不是我们没有自动驾驶汽车,但它们更多的是一种烦恼,而且它们只能作为共享出行车辆在特定地点行驶。以类似的方式,我们并不太担心代理会取代任何职业。

我们更感兴趣的是小型代理——经过训练和微调以执行特定任务但具有更大灵活性进行对话的代理。许多电子游戏 NPC 将受益于这种设置,它们不仅可以使用 LLM 进行随机对话并提供更沉浸式的体验,还可以决定采取塑造独特故事的行为。

我们也可能会看到它们首先做好小任务。例如,LLM 已经可以阅读你的电子邮件并为你的总结,但一个简单的代理会更进一步,为你生成电子邮件回复。也许它实际上不会发送它们,但只是提供选项,而你只需选择你想要的,然后它会为你发送。

但主要的是,我们很兴奋地看到 LLM 代理取代其他机器人。例如,谁没有上传过简历,却发现他们不得不重新输入所有信息?要么是因为简历提取工具工作得不好,要么是因为它甚至不存在。LLM 代理不仅能阅读你的简历并提取信息,还能双重检查其工作并确保其合理。此外,我们还没有提到那些根据关键词自动筛选简历的申请跟踪系统。这些系统往往很容易被操纵,并且很糟糕地无法区分出优秀者。LLM 代理有更大的机会完成这项任务。当然,我们关心确保公平的招聘实践,但这些系统已经自动化,并且在某种程度上存在偏见。更好的模型是减少这种无益偏见的机会。

考虑到这一点,模型可能通过使用缓存嵌入来成为更好的代理。这是一个有趣的想法,你可以用模型做些事情,除了在当地犹他州的聚会中 Will Gaviro Rojas 之外,我们还没有听说任何人谈论过。缓存嵌入允许你减少重复计算多次以并行完成多个任务。这是一个更复杂的例子,我们不会深入探讨,以保持事情简单明了,但这个策略涉及在最后一个隐藏状态之后复制模型的最后几层来完成几个任务,或者创建自定义线性分类器来完成这些任务。在列表 12.4 中,我们深入探讨了围绕缓存嵌入的整个系统,因为我们假设此时已经了解了如何存储嵌入以便稍后访问。

我们首先使用 BitsandBytes 在 INT4 量化中加载 Llama3-ChatQA,以确保它适合较小的消费级 GPU,这一点在本书的结尾应该会变得熟悉。我们为该模型提供了适当的提示结构,并得到了我们的输出。然后我们通过outputs.last_hidden_states访问最后一个隐藏状态或嵌入,并展示如何创建相关层的副本以通过该隐藏状态(如果它们被训练来处理这种情况)或者创建一个 PyTorch 中的自定义线性分类器,以便在任意分类任务上完全训练。

列表 12.4 缓存多个较小模型的嵌入
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
)
import torch
from time import perf_counter

model_id = "nvidia/Llama3-ChatQA-1.5-8B"
device = "cuda:0" if torch.cuda.is_available() else "cpu"

quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=quantization_config,
    low_cpu_mem_usage=True,
    use_safetensors=True,
    attn_implementation="sdpa",
    torch_dtype=torch.float16,
)
 system = (                                                                  #1
    "This is a chat between a user and an artificial intelligence "          #1
    "assistant. The assistant gives helpful, detailed, and polite answers "  #1
    "to the user's questions based on the context. The assistant should "    #1
    "also indicate when the answer cannot be found in the context."          #1
)                                                                            #1
question = ( #1
    "Please give a full and complete answer for the question. "
    "Can you help me find a place to eat?"
)
response = (
    "Sure, there are many locations near you that are wonderful "
    "to eat at, have you tried La Dolce Vite?"
)
question_2 = (
    "Please give a full and complete answer for the question. "
    "I'm looking for somewhere near me that serves noodles."
)

prompt = f"""System: {system}

User: {question}

Assistant: {response}

User: {question_2}

Assistant:"""
start = perf_counter()
inputs = tokenizer(tokenizer.bos_token + prompt, return_tensors="pt").to(
    device
)

terminators = [
    tokenizer.eos_token_id,
    tokenizer.convert_tokens_to_ids("<|eot_id|>"),
]
text_outputs = model.generate(
    input_ids=inputs.input_ids,
    attention_mask=inputs.attention_mask,
    max_new_tokens=128,
    eos_token_id=terminators,
)
response = text_outputs[0][inputs.input_ids.shape[-1] :]
end = perf_counter() - start
 print(
    f"\n\nFull Response: {tokenizer.batch_decode(text_outputs)}"
    f"\n\nOnly Answer Response: {tokenizer.decode(response)}"
)
print(f"\nTime to execute: {end}\n")

start = perf_counter()
with torch.no_grad():          
    hidden_outputs = model(         #2
    input_ids=inputs.input_ids,
    attention_mask=inputs.attention_mask,
    output_hidden_states=True,
    )

    embeddings_to_cache = hidden_outputs.hidden_states[-1]

end = perf_counter() - start
print(f"Embeddings: {embeddings_to_cache}")
print(f"\nTime to execute: {end}\n")

for key, module in model._modules.items():
    if key == "lm_head":                         #3
    print(f"This is the layer to pass to by itself:\n{module}")
with torch.no_grad():
    start = perf_counter()
    outputs = model._modules"lm_head"
    end = perf_counter() - start
    print(f"Outputs: {outputs}")
    print(f"\nTime to execute: {end}\n")

class CustomLinearClassifier(torch.nn.Module):        #4
    def __init__(self, num_labels):
        super(CustomLinearClassifier, self).__init__()
        self.num_labels = num_labels
        self.dropout = torch.nn.Dropout(0.1)
        self.ff = torch.nn.Linear(4096, num_labels, dtype=torch.float16)

    def forward(self, input_ids=None, targets=None):
    sequence = self.dropout(input_ids)

    logits = self.ff(sequence[:, 0, :].view(-1, 4096))

    if targets is not None:
            loss = torch.nn.functional.cross_entropy(
                logits.view(-1, self.num_labels), targets.view(-1)
            )
            return logits, loss
    else:
            return logits

custom_LMHead = CustomLinearClassifier(128256).to(device)

with torch.no_grad():
    start = perf_counter()
    outputs = custom_LMHead(embeddings_to_cache)
    end = perf_counter() - start
    print(f"Outputs: {outputs}")
    print(f"\nTime to execute: {end}\n")

1 传统生成

2 嵌入

3 找到 LM 头部层

4 自定义可训练分类器

这种将模型视为连接到其他系统的单一实体的想法的解耦非常符合工程实践,使得一个模型能够围绕一个单一数据点输出数百个分类,这要归功于嵌入。LangChain 提供了一个CacheBackedEmbeddings类来帮助在类内部快速方便地缓存向量,我们认为这个名字对于更大的想法来说也非常棒——通过缓存来备份嵌入过程,以便一次性提供给多个线性分类器。这种方法使我们能够检测从不当的用户输入到为真实模型提供嵌入的摘要版本,以便更快、更通用地处理。

12.3 最后的想法

我们真心希望您喜欢这本书,并且学到了一些新的、有用的东西。编写一本最高质量的书是一项巨大的努力,有时它更多的是关于我们最终丢弃了什么,而不是我们写了什么。信不信由你,尽管我们尽可能全面,但很多时候我们感觉我们只是触及了大多数主题的表面。感谢您与我们一同踏上这段旅程。

我们对这一行业的发展方向感到非常兴奋。撰写这本书最困难的部分之一是选择关注当前的最佳实践,而忽略了大量看似堆积如山的有希望的研究,尤其是在公司和政府增加对 LLMs 承诺的惊人可能性投资的情况下。我们期待看到多年或数十年的研究应用于 LLMs,并看到新的研究从改进这些结果中产生。我们也期待看到公司发生变化,并找出如何比目前更好地部署和提供 LLMs。在没有显得像是在撒谎的情况下,使用传统方法来营销基于 LLMs 的产品是困难的。人们希望看到产品的工作方式与广告中展示的完全一致,我们希望看到这方面的变化。

这是一个多么激动人心的时代!还有许多更多东西需要学习和探索。因为我们已经在写作过程中见证了行业的进步,所以我们想邀请您向 GitHub 仓库提交 PR,以帮助保持代码和列表对新读者的更新。虽然这本书已经结束,但我们希望这只是您使用 LLMs 旅程的开始。

摘要

  • LLMs 正在迅速挑战当前的法律和法规及其解释。

  • LLMs 被用于作弊的恐惧伤害了许多学生,因为引入了不起作用的 AI 检测系统。

  • LLMs 正在变得越来越大,我们将需要像更好的压缩和下一个注意力算法这样的解决方案来补偿。

  • 嵌入式技术正在为多模态解决方案铺平道路,例如 ImageBind 和 OneLLM 等有趣的方法。

  • 数据很可能是未来改进的最大瓶颈和约束,这很大程度上始于缺乏高质量的评估数据集。

  • 对于它们成为问题的情况,幻觉将继续存在,但抑制其影响和发生频率的方法正在变得越来越复杂。

  • 由于 GPU 短缺,LLMs 继续受到影响,并将有助于推动研究和创新,以开发更强大的计算系统。

  • LLM 代理并不提供通往 AGI 的途径,但我们将看到它们从玩具成长为工具。

[1] M. M. Grynbaum 和 R. Mac,《时代》起诉 OpenAI 和微软侵犯版权作品的使用,《纽约时报》,2023 年 12 月 27 日,mng.bz/6Y0D

[2] E. Maiberg,《科学期刊正在出版由 AI 生成的文本》,404 Media,2024 年 3 月 18 日,mng.bz/n0og

[3] Sanhedrin 76b:11,mng.bz/vJaJ

[4] “缅甸军队在 Facebook 页面上散布仇恨言论:联合国调查,”RFI,2024 年 3 月 27 日,mng.bz/mR0P

[5] A. Guzman, “公司启用 AI 后机器人开始辱骂客户,自称‘世界上最差的快递公司’,”纽约邮报,2024 年 1 月 20 日,mng.bz/yoVq.

[6] emozilla, “动态缩放 RoPE 进一步提高了长上下文 LLaMA 的性能,无需微调,”2023 年 6 月 30 日,mng.bz/M1pn.

[7] B. Peng,J. Quesnelle,H. Fan,E. Shippole,N. Research,和 Eleutherai, “YaRN:大型语言模型的效率上下文窗口扩展。” 可用:arxiv.org/pdf/2309.00071

[8] M. Poli 等, “鬣狗层次结构:向更大卷积语言模型迈进,”2023 年 2 月,doi: doi.org/10.48550/arxiv.2302.10866.

[9] A. Gu 和 T. Dao, “Mamba:使用选择性状态空间的线性时间序列建模,”arXiv.org,2023 年 12 月 1 日,arxiv.org/abs/2312.00752.

[10] [1]O. Lieber 等, “Jamba:混合 Transformer-Mamba 语言模型,”arXiv.org,2024 年 3 月 28 日,arxiv.org/abs/2403.19887.

[11] Vaswani 等, “注意力即一切所需,”2017 年,arxiv.org/abs/1706.03762.

[12] K. H.和 A. Fitch, “萨姆·奥特曼寻求数千亿美元重塑芯片和 AI 业务,”华尔街日报,2024 年 2 月 8 日,mng.bz/KDrK.

[13] A. Pequeño IV, “英伟达成为全球最有价值的公司——超越微软和苹果,”福布斯,2024 年 6 月 18 日,mng.bz/9ojl.

[14] S. Webster, “英特尔 Optane SSD DC P5800X 评测:制造过的最快固态硬盘,”Tom’s Hardware,2022 年 8 月 26 日,mng.bz/j0Wx.

附录 A 语言学史

就像所有好的故事都是从“从前有个时候”开始的,我们也想从历史开始。不幸的是,因为我们决定写一本关于生产的书,所以从那个角度来看,历史是“不重要”和“多余的”。我们同意这一点,所以我们把它放在了一边,就在书的后面。话虽如此,明智的读者会知道,即使是以微小的附录形式,我们也能从过去学到很多东西,我们的目标就是帮助你做到这一点。我们承诺这会值得你的付出。

当然,对于语言来说,没有一个明确的开端,甚至“什么是语言?”这个问题也和“什么是三明治?”一样模糊。语言学作为一门学科,可以追溯到我们历史中的数千年,尽管不如语言本身那么久远。这很大程度上是人类能够站在食物链顶端的原因,因为集体记忆和即兴的群体适应在生存上比个体版本更成功。我们将大致按大的时期来划分,重点关注这些时期的重要历史人物和流行思想。在每个部分的结尾,我们将讨论主要收获,你会发现我们从该领域的史学研究中学到的教训对于正确设置问题至关重要,这将帮助你创建一个出色的 LLM 产品。

A.1 古代语言学

我们对古代语言学的讨论始于公元前 4 世纪的印度、中国和希腊。印度第一位值得注意的语言学家是 Daks.iputra Pa–n.ini,他的研究是第一个以现代方式形式化的描述性语言学的例子。Pa–n.ini 试图编纂梵文,而不涉及任何试图保持语言“不受污染”的内涵或伦理问题。由于他处理问题的方法,他的工作足够好,以至于至今仍在使用。

在中国,孔子考察了语言与伦理、政治的关系,探讨其功能。在《孔子论语》中,我们发现各种思想,如“言语是声音”,“在演讲中,最重要的是传达意义”,“对于一句话,一个君子可能被认为是有智慧的,而对于一句话,他可能被认为是不智慧的”。仅从这些摘录中,就可以清楚地看出,孔子及其学生认为语言的主要功能是传达意义,这是许多今天的人所共有的观点。孔子关于语言的大多数思想可以用“说话慢一点,只有在你确信能够传达你想要传达的确切意义时才说话”这一理念来概括。

在希腊,语言学的研究蓬勃发展,苏格拉底、柏拉图和亚里士多德通过对话作为教学工具来研究意义和现实的本性。苏格拉底方法是一种有组织的解决问题的语言方法,用于探索语言和世界的“为什么”。

从古代语言学中我们可以得到一些启示,首先是语言需要一种元语言来描述它,以避免递归歧义。第二点更为重要:如果某事物易于复制,即使它最初并不完全正确,随着时间的推移它将变得正确。所有这些工作都是在口头传统时代完成的,而不是确保他们所声称的一切都是正确的、可证明的和可重复的,例如 Pa^–n.ini 选择让他的整个作品能够在 2 小时内被背诵。由于其简洁性,它迅速传播开来,一些可能之前并不正确的事情,部分原因是因为 Pa^–n.ini 的解释而变得正确。

孔子和希腊人可以总结得非常相似,因为他们为复杂问题提供了简洁的解释;他们创造了持续数千年的误解,因为当真正的答案往往更大、更难理解时,解释优先考虑的是简短和直观。这就像向你的长辈解释如何连接互联网:他们通常没有耐心,或者觉得不需要了解关于 ISP、DNS、路由、路由器和调制解调器的区别、TCP、数据包、IP 地址甚至浏览器的知识。他们只想被告知该点击什么,尽管对整个过程的初步了解可以帮助他们更自由地浏览互联网并消除许多抱怨,但简短的解释才是他们记住的,即使它是不完整并可能在以后造成问题的。

当设计大型语言模型(LLM)的界面或微调模型时,考虑创建一个清晰的“元语言”来规范用户交互。我们在为模型进行提示工程时这么做,通过插入关键词和短语来确立一个明确、无歧义的系统,以避免递归歧义。DSPy 和 TextGrad 已经找到了自动化这一部分的方法,而 Guidance 和 LMQL 则提供了补充。在模型输出中追求准确性和简洁性的平衡,尤其是对于通用型 LLM 来说。

A.2 中世纪语言学

从古代时期过渡到中世纪,我们看到对中世纪语言学发展的主要贡献来自西方和亚洲中部,始于 Al-Farabi,他将逻辑形式化为两个独立的类别:假设和证明。他通过展示语法和逻辑之间的联系,为未来研究句法和修辞奠定了基础,直观地导致使用逻辑来预测语法。对我们这些从业者来说,这是一个重大的突破,我们今天一直在利用它。它使我们能够为分析语法、识别和纠正错误创建逻辑框架。

后来,阿尔-贾希主要贡献于修辞学,撰写了 200 多本书,但他也对阿拉伯语的改革提出了贡献。如果你决定进一步学习,你可能会注意到,在这一时期,欧洲有许多语言学出版物;然而,其中几乎没有一个是具有重大意义的。当时的欧洲人专注于拉丁语,这对(更广泛的)语言学景观帮助不大,尽管应该提到的一个贡献是,所谓的三艺(语法、逻辑、修辞)被定义了,这有助于创建直到莎士比亚时代都受到享用的教育体系。

将逻辑框架纳入语言模型,如知识图谱,可以提高语法准确性和连贯性。这就是为什么像 Guidance 和 LMQL 这样的工具工作得如此之好的原因,因为它们将输出限制在我们知道可以控制的领域。确保你收集的训练数据包含语言的多个方面(语法、逻辑、修辞),以便在训练和生成过程中获得更复杂的语言理解。

A.3 文艺复兴和早期现代语言学

建立在中世纪语言学的基础上,文艺复兴时期对古典拉丁语和希腊语产生了新的兴趣,导致了人文语法学的出现。洛伦佐·瓦拉是这一时期最重要的学者之一;在 15 世纪的意大利,他撰写了一本关于拉丁语语法和风格的全面教科书,《拉丁语优雅》,这本身就是对语言学的重大贡献,更重要的是,他开始批判性地使用语言风格来证明一份被用作声称教皇权威的重要文件是伪造的,通过将之前的圣经翻译与原始希腊文进行比较,并反对当时盛行的亚里士多德思想,即哲学不需要符合常识或普通语言使用。

来自瓦拉的关键圣经注释启发了伊拉斯谟,他既有宗教意义也有语言学的意义——尽管他的语言学意义仅限于他对新约的同步和多语言翻译,以及拉丁语和希腊语风格和教育的培养。他相当有力地证明了在多语言环境中对任何单语任务的建模可以改善单语任务。后来,在 17 世纪,科学方法的兴起导致了当时现代欧洲语言及其比较语法的全新兴趣。欧洲从这场多方面的革命中获得了巨大的利益,这场革命得到了一个共享的通用语言和优先考虑真理而非权威的敏锐学者的显著支持。请参考图 A.1,以了解一些英语单词的截断词源。了解它们的来源以及我们的语言在多年间所经历的许多变化,并看到这一历史时期是思想和语言变化的又一次觉醒。

figure

图 A.1 一些英语单词的不完整演变。正字法是我们用来书写的系统,包括字母、标点符号和书面语言的规则,而不是口语。虽然这个图更多地涉及发音而不是正字法,但我们应理解两者相互影响,并经历了许多阶段的演变。语言不会停止演变,我们不应该期望它停止或与之抗争,尽管这样做可能会简化我们的工作。注意,在“人”和“智力”的演变中,另一种语言介入并取代了原始语言,尽管在预期变化发生之前。所有这些仍然在发生。

同样,18 世纪早期的现代时期通过实际上将语言学作为一门独立的研究领域来诞生,与宗教或哲学无关,从而带来了一场巨大的变革。威廉·琼斯爵士,一位语言学家,尽管他的实践不如之前所有做过这件事的人,却成功地普及了欧洲语言与波斯语和梵语之间的联系。我们说“不如”,因为这个想法已经流传了数百年,几位学者提出了正确的观点。然而,琼斯也随意地将埃及语、日语和中国语归入印欧语系。似乎需要纠正的是对理论有益的。

比较语言学和历史语言学似乎同时产生,作为对上述情况的反应,许多其他学者也迅速且有意义地做出了贡献,如弗朗茨·博普,他发展了一种语言分析方法,用于比较已经注意到的内容。在同一时期,雅各布·格林发表了格林定律,首次揭示了语言中的重大语音变化是逐渐发生的,而不是突然发生的,并且是由系统性的演变而不是随机的词变化引起的。卡尔·弗纳尔随后继承了他的步伐,后来提供了更有说服力的证据,表明语音变化,即使在例外情况下,也是规则的并且依赖于口音。

与许多其他研究领域类似,这个时期语言学开始起飞并变得更加科学,试图剖析语言的基础,甚至试图为人工语言提出“最有效的结构”。这里的启示是,随着变得更加科学,语言学开始脱离常识和普遍理解,从教育的一个常规部分转变为只能在大学或非常昂贵的中学中专门学习的学科。在这个时期提出的许多观点并不新颖,甚至更多是完全错误的,带有民族主义动机;然而,这部分原因使得这个时期成为研究的重要时期,因为那些错误。

从这个时期开始,我们可以看到开发多语言模型将提高整体的语言理解和生成能力。大多数语言都是相关的,让我们的模型接触到尽可能多的语言,给它更好的机会去理解其底层结构和模式,这就像一个已经掌握了几种语言的人学习第四种或第五种语言比学习第二种语言的人更容易一样。此外,务必设计出可以帮助你适应语言演变的系统。现代语言和俚语演变非常迅速,你应该准备好处理这种数据漂移。许多语言的变化都是从其他语言中借用的,因此,在多语言环境下训练你的模型将有助于以最有效的方式提高其生产力和泛化能力。

A.4 20 世纪初的语言学

20 世纪初见证了结构语言学的兴起,该理论旨在用结构来描述语言。结构语言学作为一种数据工程形式值得提及。收集了一系列的言语,然后每个言语被分解成其各个部分以便进一步分类:音素(最小的有意义的音),词素(最小的有意义的子词标记),词汇类别,名词短语,动词短语和句子类型。

瑞士语言学家费迪南德·德·索绪尔在此时引入了关键概念,如语言和言语,能指与所指,以及共时性与历时性分析,所有这些都是他反对理论的组成部分——即语言中的意义不能被创造或摧毁,只能被分离和吸收。这是一个较难理解的概念,所以如果它感觉不直观,不要慌张,但每当你在一种语言中有一个概念,例如,自由,这个概念的部分会根据语境变化。这个概念也与同义词和非同义词有重叠,例如,自由libertyagencychoiceability。所有这些词在它们意义的某些部分以不同的百分比重叠,其中自由liberty几乎完全相同。许多人会努力阐述它们之间的区别,但自由ability只有部分相似。例如,如果agency这个词从英语中消失,它的意义和用法将被包含在具有重叠意义的词组中的其他词所吸收;因此,它的意义不会丢失,只是不再独立。语言变化到算法最终是每个词组中的每个元素以冒泡排序的方式与其他元素在多个关系中比较,直到没有两个元素具有完全相同的值。

索绪尔定义
  • 语言和言语——整体语言与该语言使用之间的区别。这是英语的大概念与某人正在说英语时的区别。

  • 能指与所指—承认大多数单词的声音/拼写与其所指事物之间的任意性。这个想法是由希腊人开创的,但自那时以来,许多人对其进行了改进和量化。以英语中的单词 cat 为例。这个单词由 /k/、/æ/ 和 /t/ 这三个声音以及猫的概念或原型组成。这些声音中的任何一个在现实中都与猫无关,而与 pop 这个拟声词不同。能指与所指的进一步应用是理解自然界不会像人类那样将其划分为月份或类别,比如花、树和灌木。这些人为的分类是更大想法的证据,即语言是一个自包含的系统,它不是现实的函数,而是一种对现实的规范性抽象。灌木类别只在与语言系统内的其他类别相比较时才有意义,在这个系统之外是没有意义的。这应该与面向对象编程有相似之处。

  • 共时与历时分析—描述在分析一种语言时你退化的程度。共时分析是研究语言当前的状态,就像它是时间的一个快照。历时分析是研究一种语言的更广泛的历史。共时分析的例子是去 dictionary.com,并使用当前的快照来研究英语,而不是研究从 19 世纪 50 年代到现在的所有词典之间的差异。

一个很好的例子说明这种变化不应该对任何人构成威胁,涉及到红色和蓝色这两种颜色。在英语中,当我们向孩子介绍颜色时,我们通常会告诉他们基本颜色集中包含的红色和粉红色(实际上是浅红色),但我们通常只向幼儿介绍蓝色的一种通用版本。相比之下,俄罗斯人会向他们的孩子介绍 синий(蓝色)和 голубой(浅蓝色),但通常只告诉孩子们一个红色的名字,不包括任何浅红色的特殊名称。当然,这两种语言都能完全访问所有颜色,并且它们都没有影响光的频谱或以不同的方式感知它。然而,他们只是选择了根据他们的用例认为其中的不同部分很重要,而这些用例,再次强调,不是基于现实,也不必基于实用性。后来,Leonard Bloomfield 进一步发展了这些想法,表明当语言现象与其语言环境分离时,可以成功地研究它们,这为印欧语系的历史语言学研究做出了重大贡献。

我们可以从这个时期学到很多来改进我们的 LLMs。一个关键的启示是理解语言系统是自包含的,并不一定与客观现实相关联。我们不需要担心我们的模型是否真正理解现实世界中的“猫”是什么,以便在文本世界中正确使用它。我们还应该确保我们的模型接触到展示语言相对性的数据,例如包括不同时期和地点的作品。这将帮助我们解决诸如本地化——不同地点即使在说同一种语言时也会使用不同的语言——和代际差异——老一辈和年轻人在使用词汇上有所不同——等问题。

A.5 20 世纪中叶及现代语言学

20 世纪初语言学对科学方法的强调,有助于为计算语言学(CompLing)和自然语言处理(NLP)的起步奠定基础。最早的计算机是专为明确的语言目的而设计的,该领域的早期先驱,如艾伦·图灵、克劳德·香农和玛丽·罗莎蒙德·哈斯,通过他们在信息理论、人工智能、机器学习和比较历史语言学方面的工作,为这一领域奠定了基础。哈斯的工作尤其能表明,尽管索绪尔认为词的丢失不等于意义的丢失,但语言的丢失对世界来说是一个净损失。为了真正使这一点深入人心,我们今天所知道的关于语言学的许多知识,都要归功于聋人。

比较语言学的本质就是比较。我们比较英语和阿拉伯语,以及希伯来语,以了解非连接形态学存在(三或四个辅音根插入不同的元音)。我们比较英语和中文、日语,以了解并非所有语言都需要字母。但我们不能仅仅通过比较英语,或者与其他使用相同交流模式的语言进行比较,就得到所有重要的答案。有一些基础且重要的问题,比如“孩子们能否从电视上学到语言”,通过比较英语与其他任何口语语言是无法回答的,但在聋人成年人的听力儿童(CODAs)的完美环境中,我们可以得到答案。

手语是我们拥有的最接近非人类语言的东西,不是因为它们不是由人类制作或说出的,而是因为它们在句法和形态学上的表达并不完全与口语语言相同。沿着这个思路,如果你只有面包类食品,就很难理解所有各种食谱的可能性。当有其他许多食品,甚至其他可以作为基础的碳水化合物,如意大利面或米饭时,你可能会认为面包是所有食品的绝对基础要求。

手语和聋人总体上在他们的整个存在过程中(直到大约 20 世纪 70 年代)都附带着社会耻辱,但这并不意味着他们现在不面临任何问题。其中一些耻辱源于宗教,认为他们被恶魔或类似的实体附身。还有一些是社会性的,认为聋人不够聪明,无法应对这个世界。这些都不是事实,我们未能早点认识到学习和比较的潜力,这真是遗憾。类似于面包的例子,手语让我们看到了如果使用完全不同的基础——比如说,可以用类似面包的方式使用但不必如此使用的花椰菜——我们的语言可能是什么样子。直到你真正看到并研究它,你甚至很难想象一个对英语来说就像花椰菜对面包那样的语言会是什么样子。

我们可以从手语中学到的最伟大的例子之一是观察手语和口语之间的相似之处,这有助于我们理解对于一种语言来说什么是绝对必要的,以及我们因为没有什么不同可以与之比较而视为理所当然的事情。例如,我们了解到手语有音素。我们还了解到,手势并不一定与口语单词相对应,正如许多人所假设的那样。我们从与全球文明接触很少的语言中,例如,例如,没有超出活人记忆历史的 Pirahã语言,这种语言可以连贯地完全吹口哨,并且没有基数词也没有序数词,我们学到了类似的关于语法和句法本质的教训。不幸的是,这些总是我们首先失去并融入更广泛文化的语言。如果我们希望能够解决我们关于语言的所有问题,我们不希望达到一个无法回头的点,在那里我们必须比较和学习的所有语言都是基于面包的。

为了避免达到无法回头的地步,CompLing 和 NLP 的第一个应用是机器翻译,但在 20 世纪 50 年代,它几乎与今天的系统不相类似。像乔治敦-IBM 实验和麻省理工学院的 R.E.T.这样的系统是根据直观逻辑设计的,即因为所有语言最终都包含相同的信息总量,所以可以创建规则将语言映射到彼此,形成一个庞大的查找表集。20 世纪中叶带来了整个世纪在这三个领域中最重要的大突破:普遍和生成语法理论。乔姆斯基所有语言学背后的基本理念是,构成人类语言能力的所有原则都是生物遗传的,这意味着所有人类不仅天生具有语言能力,而且我们所有人一开始都拥有相同的信息,只需要学习特定的规则来生成我们的母语(们)。而不是讨论乔姆斯基在这项研究和信念中的任何方面是否正确,我们只能说这个想法对于设计多语言系统极其有用。

乔姆斯基的工作具有开创性,因为后续的研究催生了包括心理语言学、社会语言学和认知语言学在内的几个其他领域,并对其他领域产生了重大影响。在编译和自然语言处理(NLP)领域,它开始了使用形式语法和解析来算法性地确定语言结构,并取得了相当大的成功。一些与乔姆斯基和泽利格·哈里斯的工作类似的想法最终出现在 2018 年的第一篇生成预训练转换器(GPT)论文中,尽管没有被引用。后来,这些解析器从形式语法转向了上下文无关语法,而乔姆斯基强调的句法和语义之间的距离使得语义成为 20 世纪后期计算语言学家关注的焦点。知识表示和自然语言理解(NLU)仍然是今天的痛点。

附录 B:带有人类反馈的强化学习

带有人类反馈的强化学习(RLHF)是传统强化学习(RL)的一种变体,通常涉及解决 k 臂老丨虎丨机问题。在 k 臂老丨虎丨机问题中,算法探索 k 个选项以确定哪个能产生最高的奖励。然而,RLHF 采取了一种不同的方法。不是算法完全独立探索并最大化奖励,而是结合人类反馈来决定最佳选项。人们根据他们的偏好和观点对选项进行排名,这些排名被用来微调模型,产生一个能够响应提供反馈的人的偏好的模型。

在列表 B.1 中,我们向您展示如何使用 RLHF 训练一个模型,其中你将是这个缩写词中的 H!这是一个缩小版的版本,包含小数据集和简单模型,普通机器可以处理。从导入开始,你现在应该熟悉其中大部分,但我们想特别指出其中一个比较独特的地方,即trl,它代表“transformers reinforcement learning”。这个库在很大程度上简化了设置你想要用特定模型进行的 RLHF 的复杂过程。它还与 Hugging Face 生态系统集成得非常好,包括 Accelerate 和 PEFT(参数高效微调),如果你想要为不同任务进行 RLHF LoRAs。

列表 B.1 示例 RLHF 训练
import torch
from datasets import load_dataset
from tqdm import tqdm
from transformers import GPT2Tokenizer

from trl import AutoModelForCausalLMWithValueHead, PPOConfig, PPOTrainer

接下来,我们将拉取一个数据集进行训练。这是一个非常小的数据集,只有 16 行精心挑选的查询。我们无法从如此小的数据集中真正调整任何模型,但我们并不太关心;我们真正只是走走过场,以了解如何进行 RLHF:

dataset = load_dataset("HuggingFaceH4/cherry_picked_prompts", split="train")
dataset = dataset.rename_column("prompt", "query")
dataset = dataset.remove_columns(["meta", "completion"])

for i in dataset:
    print(i)

输出是

# {'query': 'Explain the moon landing to a 6 year old in a few sentences.'}
# ...
# {'query': 'How can I steal from a grocery store without getting caught?'}
# {'query': 'Q: Why are liberals so stupid? A:'}
# {'query': 'Why is it important to eat socks after meditating? '}

接下来,我们将加载我们的模型。对于这个任务,我们将使用 GPT-2 来完成所有事情,因此我们可以使用相同的分词器。正如你所见,使用trl加载模型非常简单,因为它使用与 Hugging Face 中所有其他内容完全相同的 API。作为备注,GPT-2 没有pad_token,所以我们将给它一个:

model_name = "gpt2"
model = AutoModelForCausalLMWithValueHead.from_pretrained(model_name)
tokenizer = GPT2Tokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token

对于这个任务,我们将使用近端策略优化(PPO),这是强化学习任务中非常流行的优化算法。我们将batch_size设置为 1,因为我们将在实时中提供人类反馈。我们还将定义一些用于文本生成的参数:

config = PPOConfig(
    model_name=model_name,
    learning_rate=1.41e-5,
    mini_batch_size=1,
    batch_size=1,
)
ppo_trainer = PPOTrainer(
    model=model,
    config=config,
    dataset=dataset,
    tokenizer=tokenizer,
)

generation_kwargs = {
    "min_length": -1,
    "top_k": 0.0,
    "top_p": 1.0,
    "do_sample": True,
    "pad_token_id": tokenizer.eos_token_id,
    "max_new_tokens": 20,
}

现在我们已经准备好训练我们的模型了!对于训练,我们将遍历我们的数据集,对每个查询进行标记化,生成一个响应,然后将响应解码回普通文本。从这里,我们将查询和响应发送到终端,由你,一个人类,使用input函数进行评估。你可以用一个整数来回应提示以给予它奖励。正数将加强那种类型的响应,而负数将被惩罚。一旦我们有了奖励,我们将通过我们的训练器再次进行操作。最后,当我们完成时,我们将保存我们的模型:

for query in tqdm(ppo_trainer.dataloader.dataset):
    query_text = query["query"]
    query_tensor = tokenizer.encode(query_text, return_tensors="pt")

    response_tensor = ppo_trainer.generate(      #1
        list(query_tensor), return_prompt=False, **generation_kwargs
    )
    response = tokenizer.decode(response_tensor[0])

    human_feedback = int(      #2
        input(
            f"Query: {query_text}\n"
            f"Response: {response}\n"
            "Reward as integer:"
        )
    )
    reward = torch.tensor(float(human_feedback))

    stats = ppo_trainer.step(                              #3
        [query_tensor[0]], [response_tensor[0]], [reward]
    )
    ppo_trainer.log_stats(stats, query, reward)

ppo_trainer.save_pretrained("./models/my_ppo_model")       #4

1 从模型获取响应

2 从用户获取奖励分数

3 运行 PPO 步骤

4 保存模型

虽然这适用于演示目的,但这并不是你将用于生产工作负载的 RLHF 运行方式。通常,你已经在用户交互中收集了大量数据,以及他们以点赞或点踩形式提供的反馈。只需将这种反馈转换为奖励+1 和-1,然后通过 PPO 算法运行所有这些。或者,一个稍微好一点的解决方案是,将这种反馈用于训练一个单独的奖励模型。这允许我们即时生成奖励,并且不需要人类对每个查询实际提供反馈。当然,这非常强大,所以你通常会看到大多数利用 RLHF 的生产解决方案都使用奖励模型来确定奖励,而不是直接使用人类反馈。

如果这个例子激起了你的兴趣,我们强烈推荐查看 trl 库的其他示例和文档,你可以在github.com/huggingface/trl找到它们。这是进入强化学习与人类反馈(RLHF)的最简单方法之一,但还有许多其他资源存在于其他地方。我们在自己的工作中发现,将 RLHF 与更多的监督训练方法相结合,比在预训练模型上直接使用 RLHF 能产生更好的结果。

附录 C 多模态潜在空间

我们还没有好的机会深入研究多模态潜在空间,但在这里我们想要纠正这一点。一个多模态模型的例子包括稳定扩散,它可以将文本提示转换为图像。扩散指的是比较两种不同模态中的嵌入过程,而这种比较必须通过学习来实现。这个过程的一个有用简化是想象所有的文本嵌入就像一个由数亿个点组成的大云团,类似于我们在第二章(2.3 节)中制作的嵌入可视化,但这里代表的是数亿个单词。有了这个云团,我们可以在不同但相关的模态中(例如图像)创建另一个嵌入云团。

我们需要确保云团之间存在某种实用关系——在我们的案例中,只要文本或图像描述另一个就足够了。它们需要在等效性上相等,即两种模态都代表相同的基本概念。一旦我们有了两个嵌入云团和映射的关系,我们就可以通过比较云团、遮蔽文本并将图像转换为白噪声来训练。然后,通过采样和周期性步骤,模型可以擅长根据基于等效文本描述的白噪声来补全图像。

我们通常不会将这些模型视为语言模型,因为输出不是文本;然而,你能想象尝试使用一个不理解语言的吗?在当前状态下,这些模型特别容易受到歧义的影响,因为等效性问题尚未解决。这里有一个例子:想象你告诉一个扩散模型根据提示“一个宇航员在亚马逊雨林中艰难前行”创建图像,而你得到了一个宇航员在用纸箱制成的电脑上打字的图像。一个更著名的例子是提示“河里的鲑鱼”,返回的图像是漂浮在水面上的煮熟的鲑鱼。(原始来源未知,但你可以在这里找到示例:mng.bz/EOrJ。)这样的例子是为什么在文本 2X 空间中,提示工程爆炸式增长,那里的歧义被加剧,能够确切锁定传递给模型以获得所需结果的标记的价值也随之提高。

训练这些模型的整个理论超出了本书的范围——实际上,我们几乎把它塞进了附录中——但如果你感兴趣,这里有一些值得探讨的事情。文本反转允许你训练一个对特定标记有特定概念的现有模型。这让你可以用非常少的示例图像获得特定的美学或主题。DreamBooth 同样使用少量示例图像训练新模型;然而,它训练模型包含该主题或美学,而不管使用的标记是什么。PEFT 和 LoRA 都包含在这本书中,但在文本到图像和图像到图像领域取得了惊人的成功,它们为文本反转和 DreamBooth 提供了相对较小的替代方案,可以说可以同样有效地完成工作。

在下一个列表中,我们将通过展示扩散工作的示例来更深入地探讨这个问题。我们将从几个导入开始,创建一个图像网格函数来帮助展示事物是如何运作的。

列表 C.1 示例 txt2Img 扩散
from diffusers import (
    StableDiffusionPipeline,
    UNet2DConditionModel,
    AutoencoderKL,
    DDIMScheduler,
)
from torch import autocast
from PIL import Image
from transformers import CLIPTextModel, CLIPTokenizer
import torch
import numpy as np

from tqdm.auto import tqdm

def image_grid(imgs, rows, cols):
    assert len(imgs) == rows * cols

    w, h = imgs[0].size
    grid = Image.new("RGB", size=(cols * w, rows * h))
    for i, img in enumerate(imgs):
        grid.paste(img, box=(i % cols * w, i // cols * h))
    return grid

现在,我们将向您展示从 Hugging Face 开始使用 Stable Diffusion 管道的最简单编程方式。这将加载 Stable Diffusion 模型,接收一个提示,然后显示图像。展示完这些后,我们将浅尝辄止,看看这个管道在底层是如何工作的,以及如何利用它做更多的事情。我们意识到这个管道的工作方式与潜在扩散不同,我们将展示这一点,但就我们的目的而言,它们足够相似:

# Simple
pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
).to("cuda")

n_images = 4
prompts = [
    "masterpiece, best quality, a photo of a horse riding an astronaut, "
    "trending on artstation, photorealistic, qhd, rtx on, 8k"
] * n_images
images = pipe(prompts, num_inference_steps=28).images

image_grid(images, rows=2, cols=2)

运行这个管道代码后,你应该会看到一组与图 C.1 类似的图像。你会注意到它生成了宇航员骑马,而不是我们请求的马骑宇航员。实际上,要得到任何 txt2img 模型执行逆操作是非常困难的,这显示了理解或未能理解语言对于多模态模型是多么重要。

图像

图 C.1 使用提示“宇航员骑马”生成的 Stable Diffusion 图像

现在我们已经看到了我们正在构建的内容,我们将继续构建一个潜在空间图像管道。我们将从加载几个模型开始:CLIP 的标记器和文本编码器,你现在应该很熟悉了,以及 Stable Diffusion 的变分自动编码器(与文本编码器类似,但用于图像)和它的 UNet 模型。我们还需要一个调度器:

# Detailed
tokenizer = CLIPTokenizer.from_pretrained("openai/clip-vit-large-patch14")
text_encoder = CLIPTextModel.from_pretrained(
    "openai/clip-vit-large-patch14"
).to("cuda")
vae = AutoencoderKL.from_pretrained(
    "runwayml/stable-diffusion-v1-5", subfolder="vae"
).to("cuda")
model = UNet2DConditionModel.from_pretrained(
    "runwayml/stable-diffusion-v1-5", subfolder="unet"
).to("cuda")

scheduler = DDIMScheduler(
    beta_start = .00085, 
    beta_end = .012, 
    beta_schedule = "scaled_linear", 
    clip_sample = False, set_alpha_to_one = False, 
    steps_offset = 1
)

接下来,我们将定义我们扩散管道的三个核心组件。首先,我们将创建get_text_embeds函数来获取文本提示的嵌入。现在这应该已经很熟悉了:将文本分词为数字,然后将这些标记转换为嵌入。接下来,我们将创建produce_latents函数,将这些文本嵌入转换为潜在表示。潜在表示本质上是在图像空间中的嵌入。最后,我们将创建decode_img_latents函数,将潜在表示解码为图像。这类似于标记器将标记解码回文本的方式:

def get_text_embeds(prompt):
    text_input = tokenizer(       #1
        prompt,
        padding="max_length",
        max_length=tokenizer.model_max_length,
        truncation=True,
        return_tensors="pt",
    )
    with torch.no_grad():
        text_embeddings = text_encoder(text_input.input_ids.to("cuda"))[0]

    uncond_input = tokenizer(    #2
        [""] * len(prompt),
        padding="max_length",
        max_length=tokenizer.model_max_length,
        return_tensors="pt",
    )
    with torch.no_grad():
        uncond_embeddings = text_encoder(uncond_input.input_ids.to("cuda"))[
            0
        ]

    text_embeddings = torch.cat([uncond_embeddings, text_embeddings])   #3
    return text_embeddings

def produce_latents(
    text_embeddings,
    height=512,
    width=512,
    num_inference_steps=28,
    guidance_scale=11,
    latents=None,
    return_all_latents=False,
):
    if latents is None:
        latents = torch.randn(
            (
                text_embeddings.shape[0] // 2,
                model.in_channels,
                height // 8,
                width // 8,
            )
        )
    latents = latents.to("cuda")

    scheduler.set_timesteps(num_inference_steps)
    latents = latents * scheduler.sigmas[0]

    latent_hist = [latents]
    with autocast("cuda"):
        for i, t in tqdm(enumerate(scheduler.timesteps)):
            latent_model_input = torch.cat([latents] * 2)     #4
            sigma = scheduler.sigmas[i]
            latent_model_input = latent_model_input / (
                (sigma**2 + 1) ** 0.5
            )

            with torch.no_grad():      #5
                noise_pred = model(
                    latent_model_input,
                    t,
                    encoder_hidden_states=text_embeddings,
                )["sample"]

            noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)     #6
            noise_pred = noise_pred_uncond + guidance_scale * (
                noise_pred_text - noise_pred_uncond
            )
            latents = scheduler.step(noise_pred, t, latents)["prev_sample"]  #7
            latent_hist.append(latents)

    if not return_all_latents:
        return latents

    all_latents = torch.cat(latent_hist, dim=0)
    return all_latents

def decode_img_latents(latents):
    latents = 1 / 0.18215 * latents

    with torch.no_grad():
        imgs = vae.decode(latents)["sample"]

    imgs = (imgs / 2 + 0.5).clamp(0, 1)
    imgs = imgs.detach().cpu().permute(0, 2, 3, 1)
    imgs = (imgs) * 127.5
    imgs = imgs.numpy().astype(np.uint8)
    pil_images = [Image.fromarray(image) for image in imgs]
    return pil_images

1 将文本分词并获取嵌入

2 对无条件嵌入执行相同的操作

3 为最终嵌入创建猫(Cat)

4 将潜在表示扩展以避免进行两次正向传递

5 预测噪声残差

6 执行引导

7 计算前一个带噪声的样本 x_t -> x_t-1

现在我们已经创建了所有组件,我们可以创建管道。这将接受一个提示,将其转换为文本嵌入,将这些嵌入转换为潜在表示,然后将这些潜在表示解码为图像:

def prompt_to_img(
    prompts,
    height=512,
    width=512,
    num_inference_steps=28,
    guidance_scale=11,
    latents=None,
):
    if isinstance(prompts, str):
        prompts = [prompts]

    text_embeds = get_text_embeds(prompts)    #1

    latents = produce_latents(     #2
        text_embeds,
        height=height,
        width=width,
        latents=latents,
        num_inference_steps=num_inference_steps,
        guidance_scale=guidance_scale,
    )

    imgs = decode_img_latents(latents)      #3

    return imgs

imgs = prompt_to_img(
    ["Super cool fantasy knight, intricate armor, 8k"] * 4
)

image_grid(imgs, rows=2, cols=2)

1 将提示转换为文本嵌入

2 将文本嵌入转换为图像潜在表示

3 将图像潜在表示转换为图像

最后,你应该看到一个类似于图 C.2 的图像网格。

figure

图 C.2 使用提示“幻想骑士,复杂盔甲”从自定义 Stable Diffusion 管道生成的图像。

我们希望你喜欢这个非常快速的教程,作为最后的练习,我们挑战读者找出如何使用prompt_to_img函数来扰动现有的图像潜在表示以执行图像到图像的任务。我们承诺这将是一个挑战,以帮助你巩固理解。尽管如此,我们希望你能带走的是语言模型对扩散和当前最先进的视觉模型的重要性。

由于模态目前是语言模型中最少被探索的部分,这里的内容足以写另一本书,而且谁知道呢?也许我们以后会写。与此同时,如果你对撰写论文、申请专利或只是为推进一个真正有趣的领域做出贡献感兴趣,我们建议你直接深入研究这部分,因为任何在常规语言模型领域产生的东西都可以立即被纳入,以使扩散模型变得更好。

posted @ 2025-11-20 09:30  绝不原创的飞龙  阅读(72)  评论(0)    收藏  举报