大语言模型工程师手册-全-
大语言模型工程师手册(全)
译者:飞龙
前言
LLM 工程领域迅速成为人工智能和机器学习中的一个关键领域。随着 LLM 继续革命性地改变自然语言处理和生成,能够有效地在现实场景中实施、优化和部署这些模型的专业人员的需求呈指数级增长。LLM 工程涵盖了从数据准备和模型微调到推理优化和生产部署的广泛学科,需要软件工程、机器学习专业知识和领域知识的独特融合。
机器学习运维(MLOps)在 LLM 在生产环境中的成功实施中扮演着至关重要的角色。MLOps 将 DevOps 的原则扩展到机器学习项目中,专注于自动化和简化整个 ML 生命周期。对于 LLM 来说,MLOps 尤为重要,因为这些模型具有复杂性和规模。它解决了管理大型数据集、处理模型版本控制、确保可重复性和维护模型性能随时间变化等挑战。通过采用 MLOps 实践,LLM 项目可以实现更高的效率、可靠性和可扩展性,最终导致更成功和有影响力的部署。
《LLM 工程师手册》 是应用最佳实践到新兴领域 LLM 工程的全面指南。在整个章节中,读者将找到简化后的关键概念、实用技术和专家在每个 LLM 生命周期阶段的建议。本书涵盖了数据工程、监督微调、模型评估、推理优化以及 检索增强生成(RAG)管道开发等主题。
为了说明这些概念在实际中的应用,本书将开发一个名为 LLM Twin 的端到端项目,贯穿全书,目标是模仿某人的写作风格和个性。这个用例将展示如何构建一个最小可行产品来解决特定问题,使用 LLM 工程和 MLOps 的各个方面。
读者可以期待更深入地了解如何收集和准备数据供 LLM 使用,针对特定任务微调模型,优化推理性能,并实施 RAG 管道。他们将学习如何评估 LLM 性能,使模型与人类偏好保持一致,并部署基于 LLM 的应用程序。本书还涵盖了 MLOps 的基本原则和实践,使读者能够构建可扩展、可重复和健壮的 LLM 应用程序。
这本书面向谁
本书面向对 LLM 的实际应用感兴趣的各种技术专业人士和爱好者。它非常适合希望转向 AI 项目的软件工程师。虽然对软件开发有一定的了解是有益的,但本书从零开始解释了许多概念,即使是对 AI 和机器学习新手也易于理解。
对于那些已经在机器学习领域工作的人来说,这本书将增强你在实现和部署基于 LLM 的系统方面的技能。我们深入探讨了 MLOps 的基础知识,指导你使用开源 LLM 创建最小可行产品来解决现实世界问题。
本书涵盖的内容
第一章,理解 LLM Twin 概念和架构,介绍了 LLM Twin 项目,该项目在整本书中作为生产级 LLM 应用的端到端示例,并定义了 FTI 架构用于构建可扩展的 ML 系统,并将其应用于 LLM Twin 用例。
第二章,工具和安装,展示了用于构建现实世界 LLM 应用的 Python、MLOps 和云工具,例如协调器、实验跟踪器、提示监控和 LLM 评估工具。它展示了如何在本地上安装和使用它们进行测试和开发。
第三章,数据工程,展示了实现一个数据收集管道,该管道抓取多个网站,如 Medium、GitHub 和 Substack,并将原始数据存储在数据仓库中。它强调在现实世界的 ML 应用中,从动态来源收集原始数据而非静态数据集的重要性。
第四章,RAG 特征管道,介绍了 RAG 的基本概念,如嵌入、vanilla RAG 框架、向量数据库以及如何优化 RAG 应用。它通过架构和实现 LLM Twin 的 RAG 特征管道,并采用软件最佳实践,应用了 RAG 理论。
第五章,监督微调,探讨了使用指令-答案对对预训练语言模型进行特定任务微调的过程。它涵盖了创建高质量数据集、实现全微调、LoRA 和 QLoRA 等微调技术,并提供了在自定义数据集上微调 Llama 3.1 8B 模型的实用演示。
第六章,偏好对齐的微调技术,介绍了将语言模型与人类偏好对齐的技术,重点关注直接偏好优化(DPO)。它涵盖了创建自定义偏好数据集、实现 DPO,并提供了使用 Unsloth 库对 TwinLlama-3.1-8B 模型进行对齐的实用演示。
第七章,评估 LLM,详细介绍了评估语言模型和 LLM 系统性能的各种方法。它介绍了通用和领域特定评估,并讨论了流行的基准。本章包括使用多个标准对 TwinLlama-3.1-8B 模型进行实际评估。
第八章,推理优化,涵盖了关键的优化策略,如投机解码、模型并行性和权重量化。它讨论了如何提高推理速度、减少延迟和最小化内存使用,并介绍了流行的推理引擎及其特性比较。
第九章,RAG 推理管道,通过从头实现自我查询、重新排序和过滤向量搜索等方法,探讨了高级 RAG 技术。它涵盖了设计和实现 LLM Twin 的 RAG 推理管道以及一个类似于在流行的框架如 LangChain 中看到的自定义检索模块。
第十章,推理管道部署,介绍了 ML 部署策略,如在线、异步和批量推理,这些策略有助于构建和部署 LLM Twin 微调模型到 AWS SageMaker,并构建一个 FastAPI 微服务以将 RAG 推理管道作为 RESTful API 公开。
第十一章,MLOps 和 LLMOps,介绍了 LLMOps 是什么,从其在 DevOps 和 MLOps 的根源开始。本章解释了如何将 LLM Twin 项目部署到云端,例如将 ML 管道部署到 AWS,并展示了如何使用 Docker 容器化代码以及构建 CI/CD/CT 管道。它还在 LLM Twin 推理管道之上添加了一个提示监控层。
附录,MLOps 原则,涵盖了用于构建可扩展、可重复和健壮的 ML 应用的六个 MLOps 原则。
为了充分利用本书
为了最大限度地提高您的学习体验,您至少需要具备软件开发原则和实践的基础知识。熟悉 Python 编程特别有益,因为本书的示例和代码片段主要是用 Python 编写的。虽然具备机器学习概念的经验是有益的,但并非绝对必要,因为本书提供了许多基本 AI 和 ML 概念的说明。然而,您应该熟悉基本的数据结构和算法,并且有一些使用 API 和云服务的经验。
假设您熟悉版本控制系统如 Git,因为本书有一个用于代码示例的 GitHub 仓库。虽然本书旨在让那些对 AI 和 LLM 新手友好,但如果您在这些领域有一些背景知识,您会发现理解我们提出的更高级的概念和技术更容易。
下载示例代码文件
该书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/LLM-Engineers-Handbook。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781836200079。
使用的约定
本书使用了一些文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“在format_samples函数中,我们将 Alpaca 聊天模板应用于每条单独的消息。”
代码块如下设置:
def format_samples(example):
example["prompt"] = alpaca_template.format(example["prompt"])
example["chosen"] = example['chosen'] + EOS_TOKEN
example["rejected"] = example['rejected'] + EOS_TOKEN
return {"prompt": example["prompt"], "chosen": example["chosen"], "rejected": example["rejected"]}
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
def format_samples(example):
example["prompt"] = alpaca_template.format(example["prompt"])
example["chosen"] = example['chosen'] + EOS_TOKEN
example["rejected"] = example['rejected'] + EOS_TOKEN
**return** **{****"prompt"****: example[****"prompt"****],** **"chosen"****:** example["chosen"], "rejected": example["rejected"]}
任何命令行输入或输出都如下所示:
poetry install --without aws
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“要这样做,请转到 GitHub 分叉仓库顶部的设置选项卡。在左侧面板中,在安全部分,点击秘密和变量切换,最后点击操作。”
警告或重要注释看起来像这样。
技巧和窍门看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了LLM 工程师手册,第一版,我们很乐意听到您的想法!请点击此处直接访问此书的 Amazon 评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,您每购买一本 Packt 书籍,都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接:

packt.link/free-ebook/9781836200079
-
提交您的购买证明。
-
就这样!我们将直接将免费 PDF 和其他优惠发送到您的邮箱。
第一章:理解 LLM Twin 的概念和架构
到这本书的结尾,我们将带你完成构建一个端到端大型语言模型(LLM)产品的旅程。我们坚信,了解 LLMs 和生产的机器学习(ML)的最佳方式是通过亲自动手构建系统。这本书将向你展示如何构建一个 LLM Twin,这是一个通过将特定人的风格、声音和个性融入 LLM 来学习写作的 AI 角色。通过这个例子,我们将带你了解完整的 ML 生命周期,从数据收集到部署和监控。在实现你的 LLM Twin 过程中学到的多数概念都可以应用于其他基于 LLM 或 ML 的应用。
当开始实施新产品时,从工程角度来看,在开始构建之前,我们必须经历三个规划步骤。首先,理解我们试图解决的问题以及我们想要构建的内容至关重要。在我们的案例中,LLM Twin 究竟是什么,为什么构建它?这一步是我们必须梦想并专注于“为什么”。其次,为了反映现实世界场景,我们将设计一个具有最小功能的产品第一迭代。在这里,我们必须明确定义创建一个有效且有价值的产品所需的核心功能。选择基于时间表、资源和团队的知识。这是我们将梦想与现实之间的差距连接起来,并最终回答以下问题的地方:“我们将构建什么?”。
最后,我们将进行系统设计步骤,概述构建 LLM 系统所使用的核心架构和设计选择。请注意,前两个组件主要是与产品相关的,而最后一个则是技术性的,专注于“如何”。
这三个步骤在构建现实世界产品时是自然而然的。即使前两个步骤不需要太多的 ML 知识,但理解“如何”以清晰的愿景构建产品是至关重要的。简而言之,本章涵盖了以下内容:
-
理解 LLM Twin 的概念
-
规划 LLM Twin 产品的最小可行产品(MVP)
-
使用特征/训练/推理管道构建 ML 系统
-
设计 LLM Twin 的系统架构
到本章结束时,你将清楚地了解在整个书中你将学习如何构建的内容。
理解 LLM Twin 的概念
第一步是明确我们想要创造什么,以及为什么构建它是宝贵的。LLM Twin 的概念是新的。因此,在深入技术细节之前,理解它是什么,我们应该期望它做什么,以及它应该如何工作是非常重要的。对最终目标的稳固直觉使得消化本书中提出的理论、代码和基础设施变得更加容易。
什么是 LLM Twin?
简而言之,LLM 双胞胎是一个将你的写作风格、声音和个性融入 LLM(一个复杂的 AI 模型)的 AI 角色。这是你自己的数字版本,投射到 LLM 中。与在互联网上训练的通用 LLM 不同,LLM 双胞胎是在你自己的基础上微调的。自然地,作为一个 ML 模型反映了它所训练的数据,这个 LLM 将融入你的写作风格、声音和个性。我们故意使用了“投射”这个词。就像任何其他投射一样,你会在过程中丢失大量信息。因此,这个 LLM 不会成为你;它将复制在它所训练的数据中反映出的你的那一面。
理解这一点至关重要:LLM 反映了它所训练的数据。如果你给它喂莎士比亚的作品,它就会开始像他一样写作。如果你用比莉·艾利什的作品训练它,它就会开始以她的风格写歌。这也被称为风格迁移。这个概念在生成图像中也很普遍。例如,假设你想要用梵高的风格创建一张猫的图片。我们将利用风格迁移策略,但不是选择一个个性,而是基于我们自己的个性来做。
为了调整 LLM 以适应给定的风格和声音,并在微调的同时,我们还将利用各种高级检索增强生成(RAG)技术,以我们自己的先前嵌入来条件化自回归过程。
我们将在第五章中探讨微调的细节,在第四章和第九章中探讨 RAG,但就目前而言,让我们看看一些例子,直观地理解我们之前所说的。
这里有一些场景,说明你可以在 LLM 上进行微调以成为你的双胞胎:
-
LinkedIn 帖子和 X 线程:将 LLM 专门化,用于撰写社交媒体内容。
-
与朋友和家人交流的消息:将 LLM 调整为你未经过滤的自我版本。
-
学术论文和文章:校准 LLM 以撰写正式和有教育意义的内容。
-
代码:将 LLM 专门化,使其以你的方式实现代码。
所有的前述场景都可以归结为一个核心策略:收集你的数字数据(或其部分)并使用不同的算法将其喂给 LLM。最终,LLM 反映了收集到的数据的声调和风格。简单,对吧?
不幸的是,这引发了许多技术和道德问题。首先,在技术方面,我们如何访问这些数据?我们是否有足够的数字数据将自己投射到 LLM 中?哪种数据是有价值的?其次,在道德方面,我们是否应该首先做这件事?我们是否想要创造一个自己的复制品?它将使用我们的声音和个性写作,还是只是试图复制它?
记住,本节的作用不是纠结于“是什么”和“怎么做”,而是关注“为什么”。让我们了解为什么拥有你的 LLM 双胞胎是有意义的,为什么它有价值,以及如果我们正确地界定问题,为什么在道德上是正确的。
为什么建立 LLM 双胞胎很重要
作为一名工程师(或任何其他职业),建立个人品牌比标准的简历更有价值。创建个人品牌最大的问题是,在 LinkedIn、X 或 Medium 等平台上撰写内容需要花费大量时间。即使你喜欢写作和创作内容,你最终也会耗尽灵感或时间,并感觉需要帮助。我们不希望将这一部分变成一个推销,但我们必须清楚地了解这个产品/项目的范围。
我们希望构建一个 LLM Twin,使用我们的风格和声音在 LinkedIn、X、Instagram、Substack 和 Medium(或其他博客)上撰写个性化内容。它不会用于任何不道德的场景,但它将作为你的写作共同飞行员。基于本书中我们将教授你的内容,你可以发挥创意并适应各种用例,但我们将专注于生成社交媒体内容和文章的利基市场。因此,我们不必从头开始撰写内容,我们可以将我们主要想法的框架输入 LLM Twin,让它做苦力工作。
最终,我们必须检查一切是否正确,并按照我们的喜好进行格式化(更多关于具体功能的内容请参阅规划 LLM Twin 产品的 MVP部分)。因此,我们设想自己成为一位内容写作的 LLM Twin,这将帮助我们自动化写作过程。如果我们尝试在不同的场景中使用这个特定的 LLM,它很可能会失败,因为这就是我们将通过微调、提示工程和 RAG 来专门化 LLM 的地方。
那么,为什么构建 LLM Twin 很重要呢?它可以帮助你做到以下几点:
-
创建你的品牌
-
自动化写作过程
-
灵感激发新的创意想法
W共同飞行员和 LLM Twin 之间的区别是什么?
共同飞行员和数字双胞胎是两个不同的概念,它们可以协同工作并组合成一个强大的解决方案:
-
共同飞行员是一个 AI 助手或工具,它增强了人类用户在编程、写作或内容创作任务中的能力。
-
双胞胎是现实世界实体的 1:1 数字表示,通常使用 AI 来弥合物理世界和数字世界之间的差距。例如,LLM Twin 是一个学习模仿你声音、个性和写作风格的 LLM。
在这些定义的基础上,一个像你一样写作的内容创作 AI 助手就是你的 LLM Twin 共同飞行员。
此外,理解构建 LLM Twin 完全是道德的至关重要。LLM 将仅在我们个人的数字数据上进行微调。我们不会收集和使用他人的数据来试图模仿任何人的身份。我们有一个明确的目标:创建我们的个性化写作复制品。每个人都将拥有自己的 LLM Twin,并受到限制的访问权限。
当然,涉及许多安全问题,但在这里我们不会深入探讨,因为这可能是一本自己的书。
为什么不使用 ChatGPT(或另一个类似的聊天机器人)?
本小节将讨论在生成个性化内容的情况下使用 ChatGPT(或类似聊天机器人)。
我们已经提供了答案。ChatGPT 并没有 个性化 到你的写作风格和声音。相反,它非常通用、不连贯且冗长。在建立品牌时保持原创声音对于长期成功至关重要。因此,直接使用 ChatGPT 或 Gemini 并不会产生最佳结果。即使你愿意分享非个性化的内容,盲目使用 ChatGPT 也可能导致以下情况:
-
由于幻觉而产生的不实信息:手动检查结果以查找幻觉或使用第三方工具评估你的结果是一个繁琐且低效的经历。
-
繁琐的手动提示:你必须手动制作你的提示并注入外部信息,这是一个令人疲惫的经历。此外,由于你无法完全控制你的提示和注入的数据,生成的答案在多个会话之间难以复制。你可以通过使用 API 和 LangChain 等工具来解决部分问题,但你需要编程经验才能做到这一点。
根据我们的经验,如果你想要提供真正价值的高质量内容,你将花费更多的时间调试生成的文本,而不是自己编写。
LLM Twin 的关键在于以下方面:
-
我们收集哪些数据
-
我们如何预处理数据
-
我们如何将数据输入到 LLM 中
-
我们如何链式多个提示以获得期望的结果
-
我们如何评估生成的内容
LLM 本身很重要,但我们想强调的是,使用 ChatGPT 的网页界面在管理和注入各种数据源或评估输出方面非常繁琐。解决方案是构建一个 LLM 系统,该系统封装并自动化以下所有步骤(每次手动复制并不是一个长期且可行的解决方案):
-
数据收集
-
数据预处理
-
数据存储、版本控制和检索
-
LLM 微调
-
RAG
-
内容生成评估
注意,我们从未说过不要使用 OpenAI 的 GPT API,只是我们将要介绍的 LLM 框架是 LLM 无关的。因此,如果它可以被程序化操作并暴露微调接口,它就可以集成到我们将学习构建的 LLM Twin 系统中。大多数成功的 ML 产品关键在于以数据为中心,并使你的架构模型无关。因此,你可以快速在你的特定数据上对多个模型进行实验。
规划 LLM Twin 产品的 MVP
既然我们已经了解了 LLM Twin 是什么以及为什么我们要构建它,我们必须明确定义产品的功能。在这本书中,我们将关注第一代产品,通常被称为最小可行产品(MVP),以遵循大多数产品的自然周期。在这里,主要目标是使用可用的资源将我们的想法与实际可行的商业目标对齐,以生产产品。即使作为一个工程师,随着你责任的增加,你也必须经历这些步骤,以弥合商业需求与可实现实施之间的差距。
什么是 MVP?
MVP 是产品的一个版本,它包含足够的功能来吸引早期用户,并在开发的初始阶段测试产品概念的可行性。通常,MVP 的目的是以最小的努力从市场收集见解。
MVP 是一种强大的策略,原因如下:
-
加速上市时间:快速推出产品以获得早期吸引力
-
理念验证:在投入产品全面开发之前,用真实用户测试它
-
市场研究:深入了解哪些内容与目标受众产生共鸣
-
风险最小化:减少可能不会取得市场成功的产品所需的时间和资源
坚持 MVP 中的 V 是至关重要的,这意味着产品必须是可行的。产品必须提供一个端到端的用户旅程,没有半成品的功能,即使产品很简陋。它必须是一个具有良好用户体验的运行产品,人们会喜欢并希望继续使用它,以查看它如何发展到其全部潜力。
定义 LLM Twin MVP
作为一种思想实验,让我们假设我们不是为这本书构建这个项目,而是想制作一个真正的产品。在这种情况下,我们的资源是什么?不幸的是,并不多:
-
我们是一个由三个人组成的团队,其中有两个机器学习工程师和一个机器学习研究员
-
我们的笔记本电脑
-
用于计算的个人资金,例如训练 LLM
-
我们的热情
正如你所见,我们资源有限。即使这只是一个思想实验,它也反映了大多数初创公司在创业初期的现实情况。因此,我们必须在定义我们的 LLM Twin MVP 以及我们想要选择的功能时非常策略。我们的目标很简单:我们希望最大化产品的价值,相对于投入的努力和资源。
为了保持简单,我们将构建以下功能,以供 LLM Twin 使用:
-
从你的 LinkedIn、Medium、Substack 和 GitHub 个人资料收集数据
-
使用收集到的数据微调开源 LLM
-
使用我们的数字数据为 RAG 填充向量数据库(DB)
-
利用以下内容创建 LinkedIn 帖子:
-
用户提示
-
RAG 以重用和引用旧内容
-
为 LLM 提供新帖子、文章或论文作为额外的知识
-
-
拥有一个简单的网页界面来与 LLM Twin 交互,并能够执行以下操作:
-
配置你的社交媒体链接并触发收集步骤
-
发送提示或链接到外部资源
-
这将是 LLM Twin MVP。即使它听起来不多,记住我们必须使这个系统具有成本效益、可扩展性和模块化。
即使只关注本节中定义的 LLM Twin 的核心功能,我们也会考虑到最新的 LLM 研究和最佳软件工程以及 MLOps 实践来构建产品。我们的目标是向您展示如何构建一个成本效益高且可扩展的 LLM 应用程序。
到目前为止,我们已经从用户和企业的角度审视了 LLM Twin。最后一步是从工程角度审视它,并制定一个开发计划来了解如何从技术上解决它。从现在起,本书的重点将转向 LLM Twin 的实现。
使用特征/训练/推理管道构建 ML 系统
在深入了解 LLM Twin 架构的具体细节之前,我们必须了解架构核心的一个 ML 系统模式,称为特征/训练/推理(FTI)架构。本节将介绍 FTI 管道设计的一般概述以及它如何结构化 ML 应用程序。
让我们看看如何将 FTI 管道应用于 LLM Twin 架构。
构建 ML 系统的问题
构建生产就绪的 ML 系统远不止训练一个模型。从工程角度来看,在大多数用例中,训练模型是最直接的一步。然而,当决定正确的架构和超参数时,训练模型变得复杂。这不仅仅是一个工程问题,而是一个研究问题。
在这一点上,我们想要专注于如何设计一个生产就绪的架构。训练一个高精度的模型非常有价值,但仅仅在静态数据集上训练,你离稳健部署还远。我们必须考虑如何做到以下:
-
摄入、清洗和验证新鲜数据
-
训练与推理设置
-
在正确环境中计算和提供特征
-
以成本效益的方式提供服务模型
-
版本、跟踪和共享数据集和模型
-
监控你的基础设施和模型
-
在可扩展的基础设施上部署模型
-
自动部署和训练
这些是机器学习或 MLOps 工程师必须考虑的问题,而研究或数据科学团队通常负责训练模型。

图 1.1:ML 系统中的常见元素
前面的图示展示了谷歌云团队建议的成熟机器学习和 MLOps 系统所需的所有组件。除了机器学习代码,还有很多动态部分。系统的其余部分包括配置、自动化、数据收集、数据验证、测试和调试、资源管理、模型分析、流程和元数据管理、服务基础设施和监控。重点是,在生产化机器学习模型时,我们必须考虑许多组件。
因此,关键问题是这样的:我们如何将这些组件连接成一个单一的统一系统?我们必须为清晰地设计机器学习系统创建一个模板来回答这个问题。
对于经典软件,也存在类似的解决方案。例如,如果你从宏观角度观察,大多数软件应用程序都可以分为数据库、业务逻辑和 UI 层。每一层都可以根据需要变得复杂,但从高层次概述来看,标准软件的架构可以简化为前三个组件。
我们对于机器学习应用有类似的东西吗?第一步是检查以前的解决方案以及为什么它们不适合构建可扩展的机器学习系统。
以前解决方案的问题
在图 1.2中,你可以观察到大多数机器学习应用中典型的架构。它基于单体批量架构,将特征创建、模型训练和推理耦合到同一组件中。通过采取这种方法,你迅速解决了机器学习世界中的一个关键问题:训练-服务偏差。训练-服务偏差发生在传递给模型的特征在训练和推理时计算不同时。
在这个架构中,特征是通过相同的代码创建的。因此,默认情况下解决了训练-服务偏差问题。当处理小数据时,这种模式运行良好。管道以批量模式按计划运行,预测由第三方应用程序(如仪表板)消费。

不幸的是,构建单体批量系统会引发许多其他问题,例如以下:
-
特征不可重用(由您的系统或其他人)
-
如果数据增加,你必须重构整个代码以支持 PySpark 或 Ray
-
重新编写预测模块以更高效的编程语言(如 C++、Java 或 Rust)是很困难的
-
在特征、训练和预测模块之间共享工作很困难
-
对于实时训练来说,切换到流式技术是不可能的
在图 1.3中,我们可以看到一个实时系统的类似场景。这个用例除了我们之前列出的之外,还引入了另一个问题。为了做出预测,我们必须通过客户端请求传输整个状态,以便计算特征并将其传递给模型。
考虑为用户计算电影推荐场景。我们不仅要传递用户 ID,还必须传输整个用户状态,包括他们的姓名、年龄、性别、电影历史等。这种方法充满了潜在的错误,因为客户端必须了解如何访问这个状态,并且它与模型服务紧密耦合。
另一个例子是在实现具有 RAG 支持的 LLM 时。我们添加到查询中的文档作为上下文代表我们的外部状态。如果我们没有在向量数据库中存储记录,我们就必须与用户查询一起传递它们。为了做到这一点,客户端必须知道如何查询和检索文档,这是不可行的。客户端应用程序知道如何访问或计算特征是一种反模式。如果您不了解 RAG 的工作原理,我们将在第八章和第九章中详细解释。

图 1.3:无状态实时架构
总之,我们的问题是访问特征以进行预测,而不需要根据客户端的请求传递它们。例如,基于我们的第一个用户电影推荐示例,我们如何仅根据用户的 ID 来预测推荐?请记住这些问题,因为我们将在不久后回答它们。
最终,在另一端,Google Cloud 提供了一个生产就绪的架构,如图 1.4所示。不幸的是,即使这是一个可行的解决方案,它也非常复杂且不直观。如果您在部署和保持机器学习模型在生产中的经验不是很丰富,您将难以理解这一点。此外,了解如何从小处开始并在一段时间内扩展系统也不是那么简单。
以下图像是从 Google 创建和共享的工作中复制的,并按照 Creative Commons 4.0 署名许可协议的条款使用:

图 1.4:CT 的机器学习管道自动化(来源: https://cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning )
但在这里,FTI 管道架构开始发挥作用。接下来的部分将向您展示如何使用直观的机器学习设计来解决这些基本问题。
解决方案——机器学习系统的机器学习管道
解决方案是基于创建一个清晰且直接的思维导图,任何团队或个人都可以遵循它来计算特征、训练模型和进行预测。基于任何机器学习系统所需的这三个关键步骤,这种模式被称为 FTI 管道。那么,这与我们之前展示的内容有何不同?
这种模式表明,任何机器学习系统都可以简化为这三个管道:特征、训练和推理(类似于经典软件中的数据库、业务逻辑和 UI 层)。这很强大,因为我们可以清楚地定义每个管道的范围和接口。此外,理解这三个组件如何交互也更容易。最终,我们只有三个而不是 20 个移动部件,正如图 1.4所示,这更容易处理和定义。
如图 1.5所示,我们有特征、训练和推理管道。我们将逐一深入探讨它们,了解它们的范围和接口。

图 1.5:FTI 管道架构
在深入了解之前,重要的是要理解每个管道都是一个不同的组件,可以在不同的进程或硬件上运行。因此,每个管道可以使用不同的技术编写,由不同的团队完成,或以不同的方式进行扩展。关键思想是设计非常灵活,以满足你团队的需求。它充当了构建你架构的思维导图。
特征管道
特征管道以原始数据作为输入,对其进行处理,并输出模型训练或推理所需的特征和标签。而不是直接将它们传递给模型,特征和标签存储在特征存储中。其责任是存储、版本化、跟踪和共享特征。通过在特征存储中保存特征,我们始终有特征的状态。因此,我们可以轻松地将特征发送到训练和推理管道。
由于数据有版本号,我们始终可以确保训练和推理时间特征匹配。因此,我们避免了训练-服务偏差问题。
训练管道
训练管道从作为输入的特征存储中的特征和标签中获取,并输出一个或多个训练模型。这些模型存储在模型注册表中。其作用类似于特征存储,但这次模型是第一类公民。因此,模型注册表将存储、版本化、跟踪并与推理管道共享模型。
此外,大多数现代模型注册表支持元数据存储,允许你指定模型训练的必要方面。最重要的是特征、标签以及用于训练模型的版本。因此,我们始终知道模型是在什么数据上训练的。
推理管道
推理管道以特征存储中的特征和标签以及模型注册表中的训练模型作为输入。有了这两个,可以轻松地在批量或实时模式下进行预测。
由于这是一个通用的模式,如何处理你的预测取决于你。如果是批量系统,它们可能会存储在数据库中。如果是实时系统,预测将提供给请求它们的客户端。此外,特征、标签和模型都有版本号。我们可以轻松升级或回滚模型的部署。例如,我们始终知道模型 v1 使用特征 F1、F2 和 F3,而模型 v2 使用 F2、F3 和 F4。因此,我们可以快速更改模型与特征之间的连接。
FTI 架构的好处
总结来说,你必须记住关于 FTI 管道最重要的东西是它们的接口:
-
特征管道接收数据并输出保存到特征存储中的特征和标签。
-
训练管道查询特征存储以获取特征和标签,并将模型输出到模型注册表。
-
推理管道使用特征存储中的特征和模型注册表中的模型进行预测。
无论你的 ML 系统多么复杂,这些接口都将保持不变。
现在我们更好地理解了这种模式的工作原理,我们想强调使用这种模式的主要好处:
-
由于你只有三个组件,使用起来直观且易于理解。
-
每个组件都可以写入其技术栈中,这样我们可以快速适应特定的需求,例如大数据或流数据。这也允许我们选择最适合工作的工具。
-
由于三个组件之间存在透明的接口,因此每个组件可以由不同的团队(如果需要)开发,这使得开发更加可管理和可扩展。
-
每个组件都可以独立部署、扩展和监控。
关于 FTI 模式,你必须理解的最后一点是,系统不必只包含三个管道。在大多数情况下,它将包含更多。例如,特征管道可以由一个计算特征的服务和验证数据的另一个服务组成。同样,训练管道可以由训练和评估组件组成。
FTI 管道作为逻辑层。因此,每个管道都复杂且包含多个服务是完全可以接受的。然而,最重要的是坚持 FTI 管道通过特征存储和模型注册表相互交互的相同接口。通过这样做,每个 FTI 组件可以独立进化,无需了解彼此的细节,也不会因新的变化而破坏系统。
要了解更多关于 FTI 管道模式的信息,请考虑阅读 Jim Dowling(Hopsworks 的首席执行官和联合创始人)所著的《从 MLOps 到 ML 系统:使用特征/训练/推理管道》一书:www.hopsworks.ai/post/mlops-to-ml-systems-with-fti-pipelines。他的文章启发了本节。
现在我们已经理解了 FTI 管道架构,本章的最后一步是看看它如何应用于 LLM Twin 用例。
设计 LLM Twin 的系统架构
在本节中,我们将列出 LLM Twin 应用的详细技术细节,并了解我们如何通过使用 FTI 架构设计我们的 LLM 系统来解决这些问题。然而,在深入管道之前,我们想强调,我们在这个阶段不会关注工具或技术栈。我们只想定义一个系统的高级架构,在这个阶段它是语言无关、框架无关、平台无关和基础设施无关的。我们将关注每个组件的范围、接口和互连性。在未来的章节中,我们将介绍实现细节和技术栈。
列出 LLM Twin 架构的技术细节
到目前为止,我们已经从用户的角度定义了 LLM Twin 应该支持的内容。现在,让我们从纯粹的技术角度明确 ML 系统的要求:
-
在数据方面,我们必须做以下事情:
-
完全自主地从 LinkedIn、Medium、Substack 和 GitHub 收集数据,并按照计划进行
-
标准化爬取的数据并将其存储在数据仓库中
-
清洗原始数据
-
创建用于微调 LLM 的指令数据集
-
将清洗后的数据分块并嵌入。将向量化的数据存储到向量数据库中,以供 RAG 使用。
-
-
对于训练,我们必须做以下事情:
-
微调各种大小(7B、14B、30B 或 70B 参数)的 LLM
-
在多个大小的指令数据集上进行微调
-
在 LLM 类型之间切换(例如,在 Mistral、Llama 和 GPT 之间)
-
跟踪和比较实验
-
在部署之前测试潜在的 LLM(大型语言模型)生产候选者
-
当有新的指令数据集可用时,自动启动训练。
-
-
推理代码将具有以下特性:
-
为客户端与 LLM 双提供 REST API 接口
-
实时访问向量数据库以进行 RAG(阅读-询问-生成)
-
使用各种大小的 LLM 进行推理
-
根据用户请求进行自动扩展
-
自动部署通过评估步骤的 LLM
-
-
系统将支持以下 LLMOps 功能:
-
指令数据集版本控制、血缘关系和可重用性
-
模型版本控制、血缘关系和可重用性
-
实验跟踪
-
持续训练、持续集成和持续交付(CT/CI/CD)
-
提示和系统监控
-
如果任何技术要求现在听起来没有意义,请耐心等待。为了避免重复,我们将在它们各自的章节中详细探讨。
上述列表相当全面。我们本可以更详细地说明,但在此阶段,我们想专注于核心功能。在实现每个组件时,我们将关注所有细节。但就目前而言,我们必须问自己这样一个基本问题:我们如何将 FTI 管道设计应用于实现上述要求列表?
如何使用 FTI 管道设计来设计 LLM 双架构
我们将系统分为四个核心组件。你会问自己这个问题:“四个?为什么不按 FTI 管道设计明确指出的那样是三个?”这是一个很好的问题。幸运的是,答案很简单。我们必须在三个特征/训练/推理管道中实现数据管道。根据最佳实践:
-
数据工程团队负责数据管道
-
机器学习工程团队负责 FTI 管道。
考虑到我们的目标是使用小团队构建一个 MVP(最小可行产品),我们必须实现整个应用程序。这包括定义数据收集和 FTI(特征/训练/推理)管道。从头到尾解决问题在无法承担专用团队的初创公司中很常见。因此,工程师必须根据产品的状态扮演多个角色。尽管如此,在任何情况下,了解端到端机器学习系统的工作原理对于更好地理解他人的工作都是非常有价值的。
图 1.6显示了 LLM 系统架构。理解它的最好方法是分别审查四个组件,并解释它们是如何工作的。

图 1.6:LLM Twin 高级架构
数据收集管道
数据收集管道涉及从 Medium、Substack、LinkedIn 和 GitHub 爬取您的个人数据。作为一个数据管道,我们将使用提取、加载、转换(ETL)模式从社交媒体平台提取数据,对其进行标准化,并将其加载到数据仓库中。
强调这一点至关重要,即数据收集管道仅设计用于从您的社交媒体平台爬取数据。它将无法访问其他人。作为本书的示例,我们同意将我们的收集数据用于学习目的。否则,未经他人同意使用他人的数据是不道德的。
此组件的输出将是一个 NoSQL 数据库,它将充当我们的数据仓库。由于我们处理的是自然无结构的文本数据,因此 NoSQL 数据库非常适合。
尽管 NoSQL 数据库,如 MongoDB,没有被标记为数据仓库,但根据我们的观点,它将充当数据仓库。为什么?因为它存储了由各种 ETL 管道收集的标准化原始数据,这些数据已准备好被摄入到 ML 系统中。
收集的数字数据被分为三个类别:
-
文章(Medium、Substack)
-
帖子(LinkedIn)
-
代码(GitHub)
我们希望抽象出数据被爬取的平台。例如,当向 LLM 提供文章时,知道它来自 Medium 或 Substack 并不是必要的。我们可以保留源 URL 作为元数据以提供参考。然而,从处理、微调和 RAG 的角度来看,了解我们摄入的数据类型至关重要,因为每个类别都必须以不同的方式处理。例如,帖子、文章和代码之间的分块策略将看起来不同。
此外,通过按类别而不是来源分组数据,我们可以快速将来自其他平台的数据,如 X 插入帖子或 GitLab 插入代码收集。作为一个模块化系统,我们必须在数据收集管道中附加额外的 ETL,其他所有操作将无需进一步代码修改。
特征管道
特征管道的作用是从数据仓库中提取原始文章、帖子以及代码数据点,对其进行处理,并将它们加载到特征存储中。
FTI 模式的特点已经存在。
这里是 LLM Twin 特征管道的一些自定义属性:
-
它以不同的方式处理三种类型的数据:文章、帖子以及代码
-
它包含三个主要处理步骤,这些步骤对于微调和 RAG 是必要的:清理、分块和嵌入
-
它创建了数字数据的两个快照,一个在清理后(用于微调)和一个在嵌入后(用于 RAG)
-
它使用逻辑特征存储而不是专用特征存储
让我们稍微深入探讨一下逻辑特征存储库的部分。与任何基于 RAG 的系统一样,基础设施的核心部分之一是一个向量数据库。我们不是集成另一个数据库,更具体地说,是一个专门的特征存储库,而是使用了向量数据库,以及一些额外的逻辑来检查我们系统需要的特征存储库的所有属性。
向量数据库不提供训练数据集的概念,但它可以用作 NoSQL 数据库。这意味着我们可以使用它们的 ID 和集合名称来访问数据点。因此,我们可以轻松地查询向量数据库以获取新的数据点,而无需任何向量搜索逻辑。最终,我们将检索到的数据封装成一个版本化、可追踪和可共享的工件——关于工件的内容将在第二章中详细介绍。现在,你必须知道这是一个 MLOps 概念,用于封装数据,并使用之前列出的属性来丰富它。
系统的其余部分将如何访问逻辑特征存储库?训练流程将使用指示数据集作为工件,推理流程将使用向量搜索技术查询向量数据库以获取额外的上下文。
对于我们的用例,这已经足够了,原因如下:
-
工件非常适合离线用例,如训练
-
向量数据库是为了在线访问而构建的,这是我们进行推理所必需的。
在未来的章节中,我们将解释三种数据类别(文章、帖子以及代码)是如何被清理、分块和嵌入的。
总结来说,我们接收原始的文章、帖子或代码数据点,对它们进行处理,并将它们存储在特征存储库中,以便于训练和推理流程的访问。注意,去除所有复杂性并仅关注接口与 FTI 模式完美匹配。美丽,对吧?
训练流程
训练流程从特征存储库中消耗指示数据集,用其微调一个 LLM,并将微调后的 LLM 权重存储在模型注册库中。更具体地说,当逻辑特征存储库中有新的指示数据集可用时,我们将触发训练流程,消耗工件,并微调 LLM。
在初始阶段,数据科学团队负责这一步骤。他们运行多个实验以找到最适合该工作的最佳模型和超参数,无论是通过自动超参数调整还是手动调整。为了比较和选择最佳的超参数集,我们将使用实验跟踪器来记录所有有价值的内容,并在实验之间进行比较。最终,他们将选择最佳的超参数和微调后的 LLM,并将其作为 LLM 生产候选者提出。提出的 LLM 随后将存储在模型注册库中。实验阶段结束后,我们将存储和重用找到的最佳超参数,以消除过程的手动限制。现在,我们可以完全自动化训练过程,这被称为持续训练。
测试流水线被触发以进行比微调期间更详细的分析。在将新模型推送到生产环境之前,对其进行更严格的测试评估是至关重要的,以确保最新的候选者比当前生产中的更好。如果这一步通过,该模型最终会被标记为接受并部署到生产推理流水线。即使在完全自动化的 ML 系统中,也建议在接受新的生产模型之前有一个手动步骤。这就像在具有高后果的重大行动之前按下红色按钮。因此,在这个阶段,专家会查看测试组件生成的报告。如果一切看起来都很好,它会批准该模型,自动化可以继续。
本组件的特定之处在于 LLM 方面,例如以下内容:
-
你如何实现一个与 LLM 无关的流水线?
-
应该使用哪些微调技术?
-
你如何将微调算法扩展到 LLMs 和各种大小的数据集上?
-
你如何从多个实验中挑选 LLM 生产候选者?
-
你如何测试 LLM 以决定是否将其推送到生产环境?
到这本书的结尾,你将知道如何回答所有这些问题。
我们最后想澄清的一个方面是CT。我们的模块化设计使我们能够快速利用 ML 编排器来调度和触发不同的系统部分。例如,我们可以安排数据收集流水线每周爬取数据。
然后,当数据仓库中有新数据可用时,我们可以触发特征流水线;当有新的指令数据集可用时,我们可以触发训练流水线。
推理流水线
推理流水线是最后一部分。它与模型注册表和逻辑特征存储相连。它从模型注册表中加载微调后的 LLM,并从逻辑特征存储中访问 RAG 的向量数据库。它通过 REST API 接收客户端请求作为查询。它使用微调后的 LLM 和访问向量数据库来执行 RAG 并回答查询。
所有客户端查询、使用 RAG 丰富后的提示以及生成的答案都会发送到提示监控系统进行分析、调试和更好地理解系统。根据具体要求,监控系统可以触发警报,手动或自动采取行动。
在接口层面,该组件严格遵循 FTI 架构,但当我们放大查看时,我们可以观察到 LLM 和 RAG 系统的独特特征,例如以下内容:
-
用于为 RAG 执行向量搜索的检索客户端
-
用于将用户查询和外部信息映射到 LLM 输入的提示模板
-
专门用于即时监控的工具
对 FTI 设计和 LLM 双架构的最终思考
我们不必对 FTI 模式过于严格。它是一个用于阐明如何设计机器学习系统的工具。例如,我们系统中使用基于向量数据库和工件的逻辑特征存储库比使用专门的特性存储库更容易且成本更低。重要的是要关注特性存储库提供的所需属性,例如版本化和可重用的训练数据集。
最终,我们将简要解释每个组件的计算需求。数据收集和特征管道主要基于 CPU,不需要强大的机器。训练管道需要能够加载 LLM 并进行微调的强大 GPU 机器。推理管道位于中间位置,它仍然需要强大的机器,但计算密集度低于训练步骤。然而,它必须经过仔细测试,因为推理管道直接与用户接口。因此,我们希望延迟在良好的用户体验所需的参数范围内。然而,使用 FTI 设计模式没有问题。我们可以为每个组件选择适当的计算需求。
此外,每个管道的扩展方式也将不同。数据和特征管道将根据 CPU 和 RAM 负载进行水平扩展。训练管道将通过添加更多 GPU 进行垂直扩展。推理管道将根据客户端请求的数量进行水平扩展。
总结来说,所提出的 LLM 架构满足了该节开头列出的所有技术要求。它按照要求处理数据,训练是模块化的,并且可以快速适应不同的 LLM、数据集或微调技术。推理管道支持 RAG,并以 REST API 的形式公开。在 LLMOps 方面,系统支持数据集和模型版本控制、血缘关系和可重用性。系统具有监控服务,整个机器学习架构都是考虑到 CT/CI/CD(持续测试/持续集成/持续部署)来设计的。
这就完成了 LLM Twin 架构的高级概述。
摘要
这第一章对于理解本书的目标至关重要。作为一本以产品为导向的书籍,它将引导你构建一个端到端的机器学习系统,因此首先理解 LLM Twin 的概念是至关重要的。之后,我们向您介绍了 MVP 是什么以及如何根据我们可用的资源来规划我们的 LLM Twin MVP。接着,我们将我们的概念转化为一个具有具体要求的实际技术解决方案。在此背景下,我们介绍了 FTI 设计模式,并展示了其在设计既模块化又可扩展的系统中的实际应用。最终,我们成功地将 FTI 模式应用于设计 LLM Twin 的架构,以满足所有技术要求。
在构建系统时,拥有清晰的总体视图至关重要。理解单个组件如何集成到应用程序的其他部分,在开发过程中可能会非常有价值。我们首先对 LLM Twin 架构进行了更抽象的介绍,重点关注每个组件的范围、接口和互连性。
以下章节将探讨如何实现和部署每个组件。在 MLOps 方面,我们将向您介绍如何使用计算平台、编排器、模型注册、工件和其他工具和概念来支持所有 MLOps 最佳实践。
参考文献
-
Dowling, J. (2024a, July 11). 从 MLOps 到 ML 系统:特征/训练/推理管道。 Hopsworks。
www.hopsworks.ai/post/mlops-to-ml-systems-with-fti-pipelines -
Dowling, J. (2024b, August 5). 使用 AI 管道和共享存储构建 AI 系统的模块化和可组合性。 Hopsworks。
www.hopsworks.ai/post/modularity-and-composability-for-ai-systems-with-ai-pipelines-and-shared-storage -
Joseph, M. (2024, August 23). AI 系统中数据转换的分类法。 Hopsworks。
www.hopsworks.ai/post/a-taxonomy-for-data-transformations-in-ai-systems -
MLOps:机器学习中的持续交付和自动化管道。 (2024, August 28). Google Cloud.
cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning -
Qwak. (2024a, June 2). 2024 年机器学习的 CI/CD:构建、测试和部署的最佳实践 | Infer. Medium。
medium.com/infer-qwak/ci-cd-for-machine-learning-in-2024-best-practices-to-build-test-and-deploy-c4ad869824d2 -
Qwak. (2024b, July 23). 2024 年 5 大最佳开源工具构建端到端 MLOps 管道。 Medium。
medium.com/infer-qwak/building-an-end-to-end-mlops-pipeline-with-open-source-tools-d8bacbf4184f -
Salama, K., Kazmierczak, J., & Schut, D. (2021). MLOps 实践指南:机器学习持续交付和自动化的框架(第 1 版)[PDF]. Google Cloud.
services.google.com/fh/files/misc/practitioners_guide_to_mlops_whitepaper.pdf
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第二章:工具和安装
本章介绍了本书中将使用到的所有基本工具,特别是在实现和部署 LLM Twin 项目时。在本书的这一部分,我们并不计划深入介绍 LLM、RAG、MLOps 或 LLMOps 的概念。我们将快速带您了解我们的技术栈和先决条件,以避免在书中重复介绍如何设置特定工具以及为什么选择它。从第三章开始,我们将通过实现一个从互联网爬取数据的 ETL 数据收集过程,开始探索我们的 LLM Twin 用例。
在本章的第一部分,我们将介绍 Python 生态系统中的工具,用于管理多个 Python 版本,创建虚拟环境,并安装项目运行所需的固定依赖项。在介绍这些工具的同时,我们还将展示如何在本地机器上安装LLM-Engineers-Handbook仓库(如果您想亲自尝试代码):github.com/PacktPublishing/LLM-Engineers-Handbook。
接下来,我们将探索我们将使用的所有 MLOps 和 LLMOps 工具,从更通用的工具开始,例如模型注册表,然后转向更面向 LLM 的工具,例如 LLM 评估和提示监控工具。我们还将了解如何使用 ZenML(一个连接 ML 和 MLOps 的桥梁)来管理具有多个 ML 管道的项目。此外,我们将快速探讨我们将使用哪些数据库来存储 NoSQL 和矢量数据。我们将向您展示如何使用 Docker 在本地机器上运行所有这些组件。最后,我们将快速回顾 AWS,并展示如何创建 AWS 用户和访问密钥,以及如何安装和配置 AWS CLI 以程序化地操作云资源。我们还将探索 SageMaker 以及为什么我们使用它来训练和部署我们的开源 LLM。
如果您熟悉这些工具,您可以安全地跳过本章。我们还在仓库的README中解释了如何安装项目和设置所有必要的组件。因此,如果您计划在阅读本书的同时运行代码,您也可以选择使用它作为更简洁的文档。
总结一下,在本章中,我们将探讨以下主题:
-
Python 生态系统和项目安装
-
MLOps 和 LLMOps 工具
-
用于存储非结构化和矢量数据的数据库
-
准备 AWS
到本章结束时,您将了解本书中我们将使用的所有工具。此外,您将学会如何安装LLM-Engineers-Handbook仓库,设置其余的工具,并在阅读本书时运行代码。
Python 生态系统和项目安装
任何 Python 项目都需要三个基本工具:Python 解释器、依赖关系管理和任务执行工具。Python 解释器按照预期执行你的 Python 项目。本书中所有的代码都是使用 Python 3.11.8 进行测试的。你可以从这里下载 Python 解释器:www.python.org/downloads/。我们建议安装确切的 Python 版本(Python 3.11.8),以便使用pyenv运行 LLM Twin 项目,使安装过程变得简单直接。
与安装多个全局 Python 版本相比,我们建议使用pyenv来管理它们,这是一个 Python 版本管理工具,它允许你在项目之间管理多个 Python 版本。你可以使用此链接安装它:github.com/pyenv/pyenv?tab=readme-ov-file#installation。
在你安装了pyenv之后,你可以使用pyenv安装 Python 3.11 的最新版本,如下所示:
pyenv install 3.11.8
现在列出所有已安装的 Python 版本,以查看它是否已正确安装:
pyenv versions
你应该看到类似以下内容:
# * system
# 3.11.8
要将 Python 3.11.8 设置为整个系统的默认版本(每次打开新终端时),请使用以下命令:
pyenv global 3.11.8
然而,我们的目标是只在我们的仓库中本地使用 Python 3.11.8。为了实现这一点,首先,我们必须克隆仓库并导航到它:
git clone https://github.com/PacktPublishing/LLM-Engineers-Handbook.git
cd LLM-Engineers-Handbook
因为我们在仓库中定义了.python-version文件,pyenv将知道从该文件中获取版本,并在你在这个文件夹内工作时本地使用它。为了双重检查,请在仓库内运行以下命令:
python --version
它应该输出:
# Python 3.11.8
要创建.python-version文件,你必须运行一次pyenv local 3.11.8。然后,pyenv将始终知道在特定目录内工作时使用该 Python 版本。
现在我们已经使用pyenv安装了正确的 Python 版本,让我们继续使用 Poetry,我们将将其用作依赖关系和虚拟环境管理器。
诗歌:依赖关系和虚拟环境管理
Poetry 是 Python 生态系统中最受欢迎的依赖关系和虚拟环境管理器之一。但让我们首先明确什么是依赖关系管理器。在 Python 中,依赖关系管理器允许你指定、安装、更新和管理项目所依赖的外部库或包(依赖项)。例如,这是一个简单的 Poetry 需求文件,它使用 Python 3.11 和requests以及numpy Python 包。
[tool.poetry.dependencies]
python = "³.11"
requests = "².25.1"
numpy = "¹.19.5"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
通过使用 Poetry 来锁定依赖项,你总是确保安装了与你的项目兼容的正确版本的依赖项。默认情况下,Poetry 会将所有需求保存在pyproject.toml文件中,这些文件存储在你的仓库根目录下,正如你在克隆的 LLM-Engineers-Handbook 仓库中看到的那样。
使用 Poetry 的另一个巨大优势是它创建一个新的 Python 虚拟环境,在其中安装指定的 Python 版本和需求。虚拟环境允许你将你的项目依赖与全局 Python 依赖和其他项目隔离开来。通过这样做,你可以确保项目之间没有版本冲突。例如,假设项目 A 需要numpy == 1.19.5,而项目 B 需要numpy == 1.26.0。如果你将这两个项目都保留在全局 Python 环境中,那么这将不起作用,因为项目 B 将覆盖项目 A 的numpy安装,这将破坏项目 A 并使其停止工作。使用 Poetry,你可以将每个项目隔离在其自己的 Python 环境中,每个项目都有自己的 Python 依赖,从而避免任何依赖冲突。
你可以从这里安装 Poetry:python-poetry.org/docs/。本书中我们使用 Poetry 1.8.3。一旦安装了 Poetry,导航到你的克隆的 LLM-Engineers-Handbook 仓库,并运行以下命令来安装所有必要的 Python 依赖:
poetry install --without aws
此命令知道从你的仓库中提取所有在pyproject.toml和poetry.lock文件中列出的依赖。安装完成后,你可以在终端中运行poetry shell或通过以下方式将所有 CLI 命令作为前缀来激活你的 Poetry 环境:poetry run <your command>。
关于诗歌的一个最后注意事项是,它会在poetry.lock文件中锁定基于添加到project.toml文件中的定义的依赖树的确切版本。虽然pyproject.toml文件可能指定版本范围(例如,requests = "².25.1"),但poetry.lock文件会记录安装的确切版本(例如,requests = "2.25.1")。它还会锁定子依赖的版本(你的依赖的依赖),这些版本可能没有在pyproject.toml文件中明确列出。通过将所有依赖和子依赖锁定到特定版本,poetry.lock文件确保所有项目安装都使用每个包的相同版本。这种一致性导致行为可预测,减少了遇到“在我的机器上工作”问题的可能性。
与 Poetry 类似的其他工具包括用于创建虚拟环境的 Venv 和 Conda。但它们缺少依赖管理选项。因此,你必须通过 Python 的默认requirements.txt文件来完成,这些文件比 Poetry 的lock文件功能弱。另一个选项是 Pipenv,它在功能上更类似于 Poetry,但速度较慢,还有uv,它是用 Rust 构建的 Poetry 替代品,使其速度极快。uv有很大的潜力取代 Poetry,因此值得尝试:github.com/astral-sh/uv。
最后一个要考虑的问题是查看我们用来管理所有 CLI 命令的任务执行工具。
诗人 Poe:任务执行工具
Poe the Poet 是 Poetry 之上的一个插件,用于管理和执行与项目交互所需的所有 CLI 命令。它帮助你在 Python 项目中定义和运行任务,简化了自动化和脚本执行。其他流行的选项包括 Makefile、Invoke 或 shell 脚本,但 Poe the Poet 消除了为管理项目任务而编写单独的 shell 脚本或 Makefile 的需求,使其成为使用 Poetry 已经使用的相同配置文件管理任务的一种优雅方式。
当使用 Poe the Poet 时,你不必在 README 文件或其他文档中记录所有命令,可以直接将它们添加到 pyproject.toml 文件中,并在命令行中使用别名执行它们。例如,使用 Poe the Poet,我们可以在 pyproject.toml 文件中定义以下任务:
[tool.poe.tasks]
test = "pytest"
format = "black ."
start = "python main.py"
然后,你可以使用 poe 命令运行这些任务:
poetry poe test
poetry poe format
poetry poe start
你可以将 Poe the Poet 作为 Poetry 插件安装,如下所示:
poetry self add 'poethepoet[poetry_plugin]'
最后,使用工具作为所有 CLI 命令的代理来运行你的应用程序是必要的。它显著简化了应用程序的复杂性,并增强了协作,因为它充当了即用型的文档。
假设你已经安装了 pyenv 和 Poetry,以下是你需要运行的所有命令来克隆存储库、安装依赖项并将 Poe the Poet 作为 Poetry 插件安装:
git clone https://github.com/PacktPublishing/LLM-Engineers-Handbook.gitcd LLM-Engineers-Handbook
poetry install --without aws
poetry self add 'poethepoet[poetry_plugin]'
要使项目完全运行,还需要遵循一些步骤,例如填写 .env 文件以包含你的凭证,并从 OpenAI 和 Hugging Face 获取令牌。但本书不是安装指南,所以我们已将这些详细信息移至存储库的 README 文件中,因为它们仅在计划运行存储库时才有用:github.com/PacktPublishing/LLM-Engineers-Handbook。
现在我们已经安装了我们的 Python 项目,让我们来介绍本书中将使用的 MLOps 工具。如果你已经熟悉这些工具,你可以安全地跳过以下工具部分,直接进入 存储非结构化和矢量数据的数据库 部分。
MLOps 和 LLMOps 工具
本节将快速介绍本书中将使用的所有 MLOps 和 LLMOps 工具及其在构建使用 MLOps 最佳实践的 ML 系统中的作用。在本书的这一部分,我们并不旨在详细说明我们将用于实现 LLM Twin 用例的所有 MLOps 组件,例如模型注册表和编排器,而只是提供一个快速了解它们是什么以及如何使用它们的想法。随着我们在本书中开发 LLM Twin 项目,你将看到我们如何使用所有这些工具的实战示例。在 第十一章 中,我们将深入探讨 MLOps 和 LLMOps 的理论,并连接所有线索。由于 MLOps 和 LLMOps 领域高度实用,我们将把这些方面的理论留到后面,因为在你完成 LLM Twin 用例实现后,理解它们会更容易。
此外,本节并非专门介绍如何设置每个工具。它主要关注每个工具的用途,并突出本书中使用的核心功能。
尽管如此,使用 Docker,你可以在本地快速运行整个基础设施。如果你想自己运行书中的步骤,你可以通过以下三个简单步骤在本地托管应用程序:
-
确保 Docker 已安装 27.1.1(或更高版本)。
-
按照仓库 README 中的说明,将所有必要的凭据填入
.env文件。 -
运行
poetrypoelocal-infrastructure-up以在本地启动 ZenML (http://127.0.0.1:8237/) 以及 MongoDB 和 Qdrant 数据库。
你可以在 LLM-Engineers-Handbook 仓库的 README 中阅读更多关于如何在本地运行所有内容的细节:github.com/PacktPublishing/LLM-Engineers-Handbook。在书中,我们还将向你展示如何将每个组件部署到云端。
Hugging Face:模型注册库
模型注册库是一个集中式存储库,它管理着 ML 模型在其整个生命周期中的状态。它存储模型及其元数据、版本历史和性能指标,作为单一的真实来源。在 MLOps 中,模型注册库对于跟踪、共享和记录模型版本至关重要,它促进了团队协作。此外,它还是部署过程中的基本元素,因为它与 持续集成 和 持续部署 (CI/CD) 管道集成。
我们使用 Hugging Face 作为我们的模型注册库,因为我们可以利用其生态系统轻松地将我们的微调 LLM Twin 模型与阅读本书的任何人共享。此外,通过遵循 Hugging Face 模型注册库界面,我们可以轻松地将模型与 LLM 生态系统周围的各个框架集成,例如用于微调的 Unsloth 和用于推理的 SageMaker。
我们微调的 LLM 可在 Hugging Face 上找到:
-
TwinLlama 3.1 8B (在微调之后):
huggingface.co/mlabonne/TwinLlama-3.1-8B -
TwinLlama 3.1 8B DPO (在偏好对齐之后):
huggingface.co/mlabonne/TwinLlama-3.1-8B-DPO

图 2.1:Hugging Face 模型注册库示例
为了快速演示,我们在 Hugging Face Spaces 上提供了它们:
-
TwinLlama 3.1 8B:
huggingface.co/spaces/mlabonne/TwinLlama-3.1-8B -
TwinLlama 3.1 8B DPO:
huggingface.co/spaces/mlabonne/TwinLlama-3.1-8B-DPO
大多数机器学习工具都提供模型注册功能。例如,ZenML、Comet 和 SageMaker(我们将在未来章节中介绍),也提供自己的模型注册功能。它们是不错的选择,但我们选择 Hugging Face 完全是因为其生态系统,它为开源环境提供了易于共享和集成的功能。因此,您通常会选择与您的项目工具和需求集成度最高的模型注册。
ZenML:编排器、工件和元数据
ZenML 作为机器学习和 MLOps 之间的桥梁。因此,它提供了多个 MLOps 功能,使您的机器学习管道的可追溯性、可重复性、部署和维护变得更加容易。在核心上,它旨在创建可重复的机器学习工作流程。它解决了从 Jupyter 笔记本中的探索性研究过渡到生产就绪的机器学习环境的问题。它解决了基于生产的复制问题,如版本控制困难、重现实验、组织复杂的机器学习工作流程、弥合训练和部署之间的差距以及跟踪元数据。因此,ZenML 的主要功能是编排机器学习管道、将机器学习管道作为输出存储和版本控制,以及将元数据附加到工件以实现更好的可观察性。
ZenML 不是另一个机器学习平台,而是引入了 堆栈 的概念,这使得您可以在多个基础设施选项上运行 ZenML。堆栈将使您能够将 ZenML 连接到不同的云服务,例如:
-
一个编排器和计算引擎(例如,AWS SageMaker 或 Vertex AI)
-
远程存储(例如,AWS S3 或 Google Cloud Storage 存储桶)
-
一个容器注册库(例如,Docker Registry 或 AWS ECR)
因此,ZenML 通过其 堆栈 功能充当粘合剂,将您的所有基础设施和工具集中在一个地方,让您能够快速迭代开发过程,并轻松监控整个机器学习系统。这种做法的美妙之处在于 ZenML 不会将您锁定在任何一个云平台上。它完全抽象化了您的 Python 代码的实现与其运行的基础设施。例如,在我们的 LLM Twin 用例中,我们使用了 AWS 堆栈:
-
SageMaker 作为我们的编排器和计算引擎
-
S3 作为我们用于存储和跟踪工件的远程存储
-
ECR 作为我们的容器注册库
然而,Python 代码不包含 S3 或 ECR 特殊性,因为 ZenML 会处理这些。因此,我们可以轻松切换到其他提供商,例如 Google Cloud Storage 或 Azure。有关 ZenML 堆栈 的更多详细信息,您可以从这里开始:docs.zenml.io/user-guide/production-guide/understand-stacks。
我们将专注于本书中使用的 ZenML 功能,例如编排、工件和元数据。有关 ZenML 的更多详细信息,请参阅他们的入门指南:docs.zenml.io/user-guide/starter-guide。
ZenML 服务器的本地版本作为 Python 包安装。因此,当运行poetry install时,它会安装一个 ZenML 调试服务器,你可以在本地使用。在第十一章中,我们将向你展示如何使用他们的云无服务器选项将机器学习管道部署到 AWS。
管道编排器
管道编排器是一个自动化、调度和协调所有机器学习管道的系统。它确保每个管道(如数据摄取、预处理、模型训练和部署)按正确顺序执行并有效地处理依赖关系。通过管理这些流程,编排器优化资源利用,优雅地处理故障,并增强可扩展性,使复杂的机器学习管道更可靠且易于管理。
ZenML 作为编排器是如何工作的?它与管道和步骤一起工作。管道是一个包含多个步骤的高级对象。一个函数通过装饰@pipeline成为 ZenML 管道,一个步骤通过装饰@step。这是使用编排器时的标准模式:你有一个高级函数,通常称为管道,它调用多个单元/步骤/任务。
让我们探索如何使用 LLM Twin 项目实现的机器学习管道之一来实现 ZenML 管道。在下面的代码片段中,我们定义了一个 ZenML 管道,该管道根据用户的完整名称查询数据库,并爬取该用户下提供的所有链接:
from zenml import pipeline
from steps.etl import crawl_links, get_or_create_user
@pipeline
def digital_data_etl(user_full_name: str, links: list[str]) -> None:
user = get_or_create_user(user_full_name)
crawl_links(user=user, links=links)
你可以使用以下 CLI 命令运行管道:poetry poe run-digital-data-etl。要可视化管道运行,你可以访问你的 ZenML 仪表板(在http://127.0.0.1:8237/),然后在左侧面板中点击管道标签,然后点击digital_data_etl管道,如图图 2.2所示:

图 2.2:ZenML 管道仪表板
点击digital_data_etl管道后,你可以可视化所有之前的和当前的管道运行,如图图 2.3所示。你可以看到哪个成功了,失败了,或者仍在运行。此外,你还可以看到用于运行管道的堆栈,其中默认堆栈是用于本地运行你的机器学习管道的堆栈。

图 2.3:ZenML digital_data_etl 管道仪表板。特定管道的示例
现在,在点击最新的digital_data_etl管道运行(或任何其他成功运行或仍在运行的运行)后,我们可以可视化管道的步骤、输出和见解,如图图 2.4所示。这种结构通常被称为有向无环图(DAG)。关于 DAG 的更多内容请见第十一章。

图 2.4:ZenML digital_data_etl 管道运行仪表板(特定管道运行的示例)
通过点击特定的步骤,你可以更深入地了解其代码和配置。它甚至聚合了该特定步骤输出的日志,以避免在工具之间切换,如图图 2.5所示。

图 2.5:数字数据 ETL 管道运行特定步骤的见解示例
defined the get_or_create_user() step, which works just like a normal Python function but is decorated with @step. We won’t go into the details of the logic, as we will cover the ETL logic in *Chapter 3*. For now, we will focus only on the ZenML functionality.
from loguru import logger
from typing_extensions import Annotated
from zenml import get_step_context, step
from llm_engineering.application import utils
from llm_engineering.domain.documents import UserDocument
@step
def get_or_create_user(user_full_name: str) -> Annotated[UserDocument, "user"]:
logger.info(f"Getting or creating user: {user_full_name}")
first_name, last_name = utils.split_user_full_name(user_full_name)
user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
return user
在 ZenML 步骤中,你可以定义你的用例需要的任何 Python 逻辑。在这个简单的例子中,我们只是创建或检索一个用户,但我们可以用任何东西替换这段代码,从数据收集到特征工程和训练。重要的是要注意,为了将 ZenML 集成到你的代码中,你必须编写模块化代码,其中每个函数只做一件事。你代码的模块化使得你可以用 @step 装饰你的函数,然后在用 @pipeline 装饰的主函数中粘合多个步骤变得容易。一个会影响你应用程序的设计选择是决定每个步骤的粒度,因为每个步骤在部署到云中时都会作为不同的单元在不同的机器上运行。
为了将我们的代码与 ZenML 解耦,我们将所有应用程序和领域逻辑封装到了 llm_engineering Python 模块中。我们还定义了 pipelines 和 steps 文件夹,在其中定义了我们的 ZenML 逻辑。在 steps 模块中,我们只使用了从 llm_engineering Python 模块中需要的内容(类似于你使用 Python 包的方式)。在 pipelines 模块中,我们只聚合 ZenML 步骤以将它们粘合到最终的管道中。使用这种设计,我们可以轻松地用另一个编排器替换 ZenML,或者在其他用例中使用我们的应用程序逻辑,例如 REST API。我们只需替换 ZenML 代码,而不必触及存放所有逻辑的 llm_engineering 模块。
此文件夹结构在 LLM-Engineers-Handbook 仓库的根目录中体现,如图 2.6 所示:
图 2.6:LLM-Engineers-Handbook 仓库文件夹结构
在编写 ZenML 步骤时需要考虑的最后一点是,如果你返回一个值,它应该是可序列化的。ZenML 可以序列化大多数可以减少为基本数据类型的对象,但有一些例外。例如,我们在整个代码中使用了 UUID 类型作为 ID,这些类型不是 ZenML 本地支持的。因此,我们必须扩展 ZenML 的材料化器以支持 UUID。我们将这个问题提交给了 ZenML。因此,在未来的 ZenML 版本中,UUID 将得到支持,但这是一个很好的例子,说明了在艺术品中转换函数输出的序列化方面。
艺术品和元数据
如前所述,ZenML 将任何步骤输出转换为工件。首先,让我们快速了解什么是工件。在 MLOps 中,工件是指在机器学习生命周期中产生的任何文件(s),例如数据集、训练模型、检查点或日志。工件对于重现实验和部署模型至关重要。我们可以将任何东西转换为工件。例如,模型注册表是工件的一个特定用例。因此,工件具有这些独特的属性:它们是版本化的、可共享的,并且附有元数据,以便快速了解其内容。例如,当用工件包装您的数据集时,您可以在其元数据中添加数据集的大小、训练-测试分割比率、大小、标签类型以及其他任何有助于了解数据集内容的有用信息,而无需实际下载它。
让我们回到我们的digital_data_etl管道示例,其中我们作为步骤输出一个工件,即爬取的链接,这是一个工件,如图图 2.7所示

图 2.7:使用 digital_data_etl 管道作为示例的 ZenML 工件示例
通过点击crawled_links工件并导航到元数据标签,我们可以快速查看我们为特定作者爬取的所有域名,每个域名爬取的链接数量,以及成功的数量,如图图 2.8所示:

图 2.8:使用 digital_data_etl 管道作为示例的 ZenML 元数据示例
一个更有趣的工件及其元数据的例子是生成的数据集工件。在图 2.9中,我们可以可视化instruct_datasets工件的元数据,该工件是自动生成的,将被用于微调 LLM Twin 模型。关于指令数据集的更多细节请见第五章。现在,我们想强调的是,在数据集的元数据中,我们已经预先计算了关于它的许多有用信息,例如它包含多少数据类别,其存储大小,以及每个训练和测试分割的样本数量。

图 2.9:instruct_datasets 工件的 ZenML 元数据示例
helpful for dataset discovery across your business and projects:
… # More imports
from zenml import ArtifactConfig, get_step_context, step
@step
def generate_intruction_dataset(
prompts: Annotated[dict[DataCategory, list[GenerateDatasetSamplesPrompt]], "prompts"]) -> Annotated[
InstructTrainTestSplit,
ArtifactConfig(
name="instruct_datasets",
tags=["dataset", "instruct", "cleaned"],
),
]:
datasets = … # Generate datasets
step_context = get_step_context()
step_context.add_output_metadata(output_name="instruct_datasets", metadata=_get_metadata_instruct_dataset(datasets))
return datasets
def _get_metadata_instruct_dataset(datasets: InstructTrainTestSplit) -> dict[str, Any]:
instruct_dataset_categories = list(datasets.train.keys())
train_num_samples = {
category: instruct_dataset.num_samples for category, instruct_dataset in datasets.train.items()
}
test_num_samples = {category: instruct_dataset.num_samples for category, instruct_dataset in datasets.test.items()}
return {
"data_categories": instruct_dataset_categories,
"test_split_size": datasets.test_split_size,
"train_num_samples_per_category": train_num_samples,
"test_num_samples_per_category": test_num_samples,
}
此外,您可以使用其通用唯一标识符(UUID)轻松下载和访问数据集的特定版本,您可以使用 ZenML 仪表板或 CLI 找到该 UUID:
from zenml.client import Client
artifact = Client().get_artifact_version('8bba35c4-8ff9-4d8f-a039-08046efc9fdc')
loaded_artifact = artifact.load()
探索 ZenML 的最后一个步骤是了解如何运行和配置 ZenML 管道。
如何运行和配置 ZenML 管道
所有的 ZenML 管道都可以从run.py文件中调用,该文件位于我们的 GitHub 仓库中的tools/run.py。在run.py文件中,我们实现了一个简单的命令行界面(CLI),允许您指定要运行的管道。例如,要调用digital_data_etl管道以爬取 Maxime 的内容,您必须运行:
python -m tools.run --run-etl --no-cache --etl-config-filename digital_data_etl_maxime_labonne.yaml
或者,要爬取 Paul 的内容,您可以运行:
python -m tools.run --run-etl --no-cache --etl-config-filename digital_data_etl_paul_iusztin.yaml
正如在介绍诗人 Poe 时解释的那样,我们用于与项目交互的所有 CLI 命令将通过 Poe 执行,以简化并标准化项目。因此,我们将这些 Python 调用封装在以下poe CLI 命令中:
poetry poe run-digital-data-etl-maxime
poetry poe run-digital-data-etl-paul
我们只在抓取不同人的内容时更改 ETL 配置文件名。ZenML 允许我们在运行时注入特定的配置文件,如下所示:
config_path = root_dir / "configs" / etl_config_filename
assert config_path.exists(), f"Config file not found: { config_path }"
run_args_etl = {
"config_path": config_path,
"run_name": f"digital_data_etl_run_{dt.now().strftime('%Y_%m_%d_%H_%M_%S')}"
}
digital_data_etl.with_options()(**run_args_etl)
在配置文件中,我们指定所有将输入管道的参数。例如,configs/digital_data_etl_maxime_labonne.yaml配置文件如下所示:
parameters:
user_full_name: Maxime Labonne # [First Name(s)] [Last Name]
links:
# Personal Blog
- https://mlabonne.github.io/blog/posts/2024-07-29_Finetune_Llama31.html
- https://mlabonne.github.io/blog/posts/2024-07-15_The_Rise_of_Agentic_Data_Generation.html
# Substack
- https://maximelabonne.substack.com/p/uncensor-any-llm-with-abliteration-d30148b7d43e
… # More links
digital_data_etl函数签名看起来如下:
@pipeline
def digital_data_etl(user_full_name: str, links: list[str]) -> str:
这种方法允许我们在运行时配置每个管道,而不需要修改代码。我们还可以清楚地跟踪所有管道的输入,确保可重复性。如图 2.10所示,我们为每个管道有一个或多个配置。

图 2.10:ZenML 管道配置
我们亲自测试过并认为功能强大的类似 ZenML 的流行编排器包括 Airflow、Prefect、Metaflow 和 Dagster。此外,如果你是 Kubernetes 的重度用户,可以选择 Agro Workflows 或 Kubeflow,后者仅在 Kubernetes 之上运行。我们仍然认为 ZenML 在易用性、功能和成本之间提供了最佳权衡。此外,这些工具中没有哪一个提供 ZenML 所提供的堆栈功能,这允许它避免将你锁定在任何云生态系统中。
在第十一章中,我们将更深入地探讨如何利用编排器来实现 MLOps 的最佳实践。但现在我们了解了 ZenML,它有什么帮助,以及如何使用它,让我们继续到实验跟踪器。
Comet ML:实验跟踪器
训练机器学习模型是一个完全迭代和实验的过程。与传统的软件开发不同,它涉及运行多个并行实验,根据预定义的指标进行比较,并决定哪个应该进入生产。一个实验跟踪工具允许你记录所有必要的信息,例如模型预测的指标和可视化表示,以便比较所有实验并快速选择最佳模型。我们的 LLM 项目也不例外。
如图 2.11所示,我们使用 Comet 跟踪训练和评估损失或梯度范数的值等指标。

图 2.11:Comet ML 训练指标示例
使用实验跟踪器,你可以超越训练和评估指标,并记录你的训练超参数以跟踪实验之间的不同配置。
它还记录了开箱即用的系统指标,如 GPU、CPU 或内存利用率,以清楚地了解你在训练期间需要哪些资源以及潜在瓶颈在哪里减慢了你的训练速度,如图 2.12所示。

图 2.12:Comet ML 系统指标示例
您无需在本地设置 Comet。在本书中,我们将免费使用其在线版本,没有任何限制。此外,如果您想更深入地了解 Comet ML 实验跟踪器,我们在微调我们的 LLM Twin 模型时,已将使用 Comet ML 跟踪的训练实验公开。您可以通过以下链接访问:www.comet.com/mlabonne/llm-twin-training/view/new/panels。
其他流行的实验跟踪器包括 W&B、MLflow 和 Neptune。我们与它们都合作过,可以声明它们都具有大部分相同的功能,但 Comet ML 通过其易用性和直观界面脱颖而出。让我们继续到最后一个 MLOps 拼图部分:Opik 用于提示监控。
Opik:提示监控
在记录和监控提示时,您不能使用标准的工具和技术。原因很复杂。我们将在第十一章中深入探讨。然而,为了快速让您了解一些情况,您不能使用标准的日志记录工具,因为提示是复杂且非结构化的链。
与 LLM 应用交互时,您将多个输入提示和生成的输出链接成一个跟踪,其中一个提示依赖于前面的提示。
因此,您需要一种直观的方式来将这些跟踪分组到一个专门的仪表板中,以便更容易地调试和监控提示的跟踪。
我们使用 Comet 制作的开源工具 Opik 作为我们的提示监控工具,因为它遵循 Comet 的简洁和易用性哲学,这在当前的 LLM 领域中相对罕见。提供类似功能的其他选项包括 Langfuse(开源,langfuse.com)、Galileo(非开源,rungalileo.io)和 LangSmith(非开源,www.langchain.com/langsmith),但我们发现它们的解决方案在使用和实施上更为繁琐。Opik 及其无服务器选项还提供了一个完全由您控制的免费开源版本。您可以在github.com/comet-ml/opik上了解更多关于 Opik 的信息。
用于存储非结构化和矢量数据的数据库
我们还希望展示我们将要在示例中使用到的 NoSQL 和矢量数据库。当在本地工作时,它们已经通过 Docker 集成。因此,当按照上面几个部分中的说明运行poetry poe local-infrastructure-up时,本地数据库的 Docker 镜像将被拉取并在您的机器上运行。此外,在部署项目时,我们将向您展示如何使用它们的无服务器选项并将其集成到 LLM Twin 项目的其余部分。
MongoDB:NoSQL 数据库
MongoDB 是当今最受欢迎、最稳健、最快且功能丰富的 NoSQL 数据库之一。它与大多数云生态系统集成良好,如 AWS、Google Cloud、Azure 和 Databricks。因此,将 MongoDB 作为我们的 NoSQL 数据库是一个显而易见的选择。
当我们撰写本书时,MongoDB 被 Novo Nordisk、Delivery Hero、Okta 和 Volvo 等大公司使用。这种广泛的应用表明 MongoDB 将长期保持领先地位。
我们使用 MongoDB 作为 NoSQL 数据库来存储我们从互联网收集的原始数据,在处理并推送到向量数据库之前。由于我们处理的是非结构化文本数据,NoSQL 数据库的灵活性非常适合。
Qdrant: 向量数据库
Qdrant (qdrant.tech/) 是最受欢迎、最稳健且功能丰富的向量数据库之一。我们本来可以用几乎任何向量数据库来构建我们的小型 MVP,但我们想选择一个轻量级且可能在未来许多年里在业界被广泛使用的数据库。
我们将使用 Qdrant 来存储 MongoDB 处理和转换后的数据,以便用于 GenAI 的可用性。
Qdrant 被像 X(前 Twitter)、迪士尼、微软、Discord 和强生这样的行业巨头所使用。因此,Qdrant 很可能在向量数据库领域长期保持领先地位。
在撰写本书时,其他流行的选择包括 Milvus、Redis、Weaviate、Pinecone、Chroma 和 pgvector(一个用于向量索引的 PostgreSQL 插件)。我们发现 Qdrant 在 RPS、延迟和索引时间之间提供了最佳权衡,使其成为许多生成式 AI 应用的稳固选择。
详细比较所有向量数据库可能本身就是一个章节。我们不想在这里做这件事。不过,如果您好奇,可以查看 Superlinked 提供的向量数据库比较资源,superlinked.com/vector-db-comparison,它从许可证和发布年份到数据库功能、嵌入模型和支持的框架等各个方面比较了所有顶级向量数据库。
准备 AWS
本章的最后一部分将专注于设置 AWS 账户(如果您还没有的话)、AWS 访问密钥和 CLI。我们还将探讨 SageMaker 是什么以及为什么我们使用它。
我们选择 AWS 作为我们的云服务提供商,因为它是最受欢迎的,并且是我们(作者)最有经验的领域。现实是,其他大型云服务提供商,如 GCP 或 Azure,也提供类似的服务。因此,根据您的具体应用,在开发时间(您最有经验的时间)、功能和成本之间总会有权衡。但对我们的小型 MVP 来说,AWS 是完美的选择,因为它为我们需要的所有功能提供了稳健的特性,如 S3(对象存储)、ECR(容器注册库)和 SageMaker(用于训练和推理的计算)。
设置 AWS 账户、访问密钥和 CLI
由于 AWS 可能会更改其 UI/UX,指导您如何创建 AWS 账户的最佳方式是通过将其重定向到他们的官方教程:docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html。
成功创建 AWS 账户后,您可以通过 console.aws.amazon.com 访问 AWS 控制台。选择 使用 root 用户邮箱登录(位于 登录 按钮下方),然后输入您的账户邮箱地址和密码。
接下来,我们必须生成访问密钥以程序化地访问 AWS。这样做最好的方法是首先创建一个具有管理访问权限的 IAM 用户,如本 AWS 官方教程中所述:docs.aws.amazon.com/streams/latest/dev/setting-up.html
对于生产账户,最佳实践是以最小权限策略授予权限,为每个用户提供他们执行其角色所需的权限。然而,为了简化我们的测试账户的设置,我们将使用 AdministratorAccess 管理策略,这将在上述教程中解释并在 图 2.13 中展示。

图 2.13:IAM 用户权限策略示例
接下来,您必须使用以下教程为您刚才创建的 IAM 用户创建一个访问密钥:docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html。
访问密钥将如下所示:
aws_access_key_id = <your_access_key_id>
aws_secret_access_key = <your_secret_access_key>
只需小心地将它们存储在安全的地方,因为创建后您将无法访问它们。此外,与谁分享它们时要谨慎,因为它们可能被用来访问您的 AWS 账户并操纵各种 AWS 资源。
最后一步是安装 AWS CLI 并使用您新创建的访问密钥进行配置。您可以通过以下链接安装 AWS CLI:docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html。
安装 AWS CLI 后,您可以通过运行 aws configure 来配置它。以下是我们 AWS 配置的示例:
[default]
aws_access_key_id = *************
aws_secret_access_key = ************
region = eu-central-1
output = json
要了解如何配置 AWS CLI 的更多详细信息,请查看以下教程:docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html。
此外,为了使用您的 AWS 凭据配置项目,您必须在您的 .env 文件中填写以下变量:
AWS_REGION="eu-central-1" # Change it with your AWS region. By default, we use "eu-central-1".
AWS_ACCESS_KEY="<your_aws_access_key>"
AWS_SECRET_KEY="<your_aws_secret_key>"
关于本书中与动手实践任务相关的费用的重要说明
本书使用的所有云服务都坚持其免费增值选项,除了 AWS。因此,如果您使用个人 AWS 账户,您将负责 AWS 成本,因为您在本书中跟随操作。虽然一些服务可能属于 AWS 免费层使用,但其他服务则不属于。因此,您需要定期检查您的计费控制台。
大部分成本将在测试 SageMaker 用于训练和推理时产生。根据我们的测试,使用本书和存储库中提供的规范,AWS 成本可能在 50 美元到 100 美元之间变化。
请参阅 AWS 文档中关于设置计费警报以监控您的成本的部分,链接为 docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/monitor_estimated_charges_with_cloudwatch.html。
SageMaker:训练和推理计算
本章的最后一个主题是理解 SageMaker 以及我们为什么决定使用它。SageMaker 是一个用于训练和部署机器学习模型的 ML 平台。官方定义如下:AWS SageMaker 是 AWS 提供的完全托管机器学习服务,使开发者和数据科学家能够以规模化的方式构建、训练和部署机器学习模型。它通过处理底层基础设施简化了流程,使用户能够专注于高效地开发高质量模型。
我们将使用 SageMaker 在 GPU 集群上微调和操作我们的训练流程,并将我们的自定义 LLM Twin 模型作为 REST API 部署,该 API 可从世界任何地方实时访问。
为什么选择 AWS SageMaker?
我们还必须讨论为什么我们选择 AWS SageMaker 而不是更简单、成本效益更高的选项,例如 AWS Bedrock。首先,让我们解释 Bedrock 及其优势。
Amazon Bedrock 是一种无服务器解决方案,用于部署大型语言模型(LLMs)。无服务器意味着没有服务器或基础设施需要管理。它提供了预训练的模型,您可以通过 API 调用直接访问。当我们编写这本书时,它们只提供了 Mistral、Flan、Llama 2 和 Llama 3 的支持(选项列表相当有限)。您可以直接向模型发送输入数据并接收预测,而无需管理底层基础设施或软件。这种方法显著降低了将 AI 功能集成到应用程序中的复杂性和时间,使得对机器学习专业知识有限的开发者更容易使用。然而,这种易于集成的便利性是以有限的定制选项为代价的,因为您被限制在 Amazon Bedrock 提供的预训练模型和 API。在定价方面,Bedrock 使用基于 API 调用次数的简单定价模型。这种简单的定价结构使得成本估算和控制更加高效。
同时,SageMaker 提供了一个全面的平台,用于构建、训练和部署机器学习模型。它允许您完全定制您的 ML 流程,甚至可以使用该平台进行研究。这就是为什么 SageMaker 主要被数据科学家和机器学习专家使用,他们知道如何编程,理解机器学习概念,并且熟悉像 AWS 这样的云平台。在成本方面,SageMaker 是一把双刃剑,采用类似于大多数 AWS 服务的按使用付费定价模式。这意味着您必须为计算资源的使用、存储以及构建应用程序所需的任何其他服务付费。
与 Bedrock 相比,即使 SageMaker 端点未被使用,您仍需为 AWS 上部署的资源付费,例如在线 EC2 实例。因此,您必须设计自动扩展系统来删除未使用的资源。总之,Bedrock 提供了一个即用型的解决方案,允许您快速部署由可用基础模型驱动的 API 端点。同时,SageMaker 是一个多功能平台,使您能够完全定制您的 ML 逻辑。
那么为什么我们选择了 SageMaker 而不是 Bedrock?Bedrock 对于快速原型设计来说是一个极好的解决方案,但这是一本关于 LLM 工程的书,我们的目标是深入挖掘 Bedrock 尝试掩盖的所有工程方面。因此,我们选择了 SageMaker,因为它具有高度的定制性,使我们能够向您展示部署模型所需的全部工程。
事实上,即使是 SageMaker 也不是完全可定制的。如果您希望对部署有完全的控制权,请使用 EKS,这是 AWS 的 Kubernetes 自托管服务。在这种情况下,您可以直接访问虚拟机,允许您完全自定义构建您的 ML 管道的方式、它们如何交互以及您如何管理资源。您也可以使用 AWS ECS,这是 AWS 的 Kubernetes 版本。使用 EKS 或 ECS,您还可以降低成本,因为这些服务的成本要低得多。
总结来说,SageMaker 在完全控制和定制与完全托管服务之间取得了平衡,后者隐藏了所有工程复杂性。这种平衡确保了您拥有所需的控制权,同时也能享受到托管服务的便利性。
摘要
在本章中,我们回顾了本书中使用的核心工具。首先,我们了解了如何安装支持我们仓库的正确版本的 Python。然后,我们查看了如何使用 Poetry 创建虚拟环境并安装所有依赖项。最后,我们了解了如何使用像 Poe the Poet 这样的任务执行工具来聚合运行应用程序所需的全部命令。
下一步是审查所有使用的工具,以确保 MLOps 最佳实践,例如用于共享我们模型的模型注册、用于管理我们的训练实验的实验跟踪器、用于管理所有我们的 ML 管道和工件的编排器,以及元数据来管理所有我们的文件和数据集。我们还了解了需要哪种类型的数据库来实现 LLM Twin 用例。最后,我们探索了设置 AWS 账户、生成访问密钥和配置 AWS CLI 以进行程序化访问 AWS 云的过程。我们还对 AWS SageMaker 及其选择用于构建我们的 LLM Twin 应用程序的原因有了深入的了解。
在下一章中,我们将从数据收集 ETL 开始,该 ETL 从互联网上抓取帖子、文章和存储库,并将它们存储在数据仓库中,来探索 LLM Twin 项目的实现。
参考文献
-
Acsany, P. (2024 年 2 月 19 日). 使用 Python Poetry 进行依赖管理.
realpython.com/dependency-management-python-poetry/ -
Comet.ml. (未注明). comet-ml/opik:开源端到端 LLM 开发平台. GitHub.
github.com/comet-ml/opik -
Czakon, J. (2024 年 9 月 25 日). ML 实验跟踪:它是什麼,为什么它很重要,以及如何实现它. neptune.ai.
neptune.ai/blog/ml-experiment-tracking -
Hopsworks. (未注明). ML 工件(ML 资产)? Hopsworks.
www.hopsworks.ai/dictionary/ml-artifacts -
介绍 | 文档 | Poetry – 使 Python 依赖管理和打包变得简单. (未注明).
python-poetry.org/docs -
Jones, L. (2024 年 3 月 21 日). 使用 pyenv 管理多个 Python 版本.
realpython.com/intro-to-pyenv/ -
Kaewsanmua, K. (2024 年 1 月 3 日). 最佳机器学习工作流和管道编排工具. neptune.ai.
neptune.ai/blog/best-workflow-and-pipeline-orchestration-tools -
MongoDB. (未注明). 什么是 NoSQL? NoSQL 数据库解释.
www.mongodb.com/resources/basics/databases/nosql-explained -
Nat-N. (未注明). nat-n/poethepoet:一个与 poetry 配合良好的任务运行器. GitHub.
github.com/nat-n/poethepoet -
Oladele, S. (2024 年 8 月 29 日). ML 模型注册:终极指南. neptune.ai.
neptune.ai/blog/ml-model-registry -
Schwaber-Cohen, R. (未注明). 什么是向量数据库?它是如何工作的?用例+示例. Pinecone.
www.pinecone.io/learn/vector-database/ -
入门指南 | ZenML 文档. (n.d.).
docs.zenml.io/user-guide/starter-guide -
向量数据库比较. (n.d.).
superlinked.com/vector-db-comparison
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第三章:数据工程
本章将更深入地探索 LLM Twin 项目。我们将学习如何设计和实现数据收集管道,以收集我们将用于所有 LLM 用例(如微调或推理)的原始数据。由于这不是一本关于数据工程的书籍,我们将使本章简短,并仅关注严格必要的收集所需原始数据的内容。从第四章开始,我们将专注于 LLM 和 GenAI,探讨其理论和具体实现细节。
当在玩具项目或进行研究时,你通常有一个静态数据集与之工作。但在我们的 LLM Twin 用例中,我们希望模拟一个真实世界的场景,我们必须自己收集和整理数据。因此,实现我们的数据管道将连接有关端到端 ML 项目如何工作的各个点。本章将探讨如何设计和实现一个提取、转换、加载(ETL)管道,该管道爬取多个社交平台(如 Medium、Substack 或 GitHub),并将收集到的数据聚合到一个 MongoDB 数据仓库中。我们将向您展示如何实现各种爬取方法、标准化数据并将其加载到数据仓库中。
我们将首先设计 LLM Twin 的数据收集管道,并解释 ETL 管道的架构。之后,我们将直接进入管道的实现,从 ZenML 开始,它将协调整个流程。我们将研究爬虫实现,并了解如何根据提供的链接的领域实现一个调度层,该层实例化正确的爬虫类,同时遵循软件最佳实践。接下来,我们将学习如何单独实现每个爬虫。此外,我们还将向您展示如何在 MongoDB 之上实现数据层,以结构化所有我们的文档并与数据库交互。
最后,我们将探讨如何使用 ZenML 运行数据收集管道,并从 MongoDB 查询收集到的数据。
因此,在本章中,我们将研究以下主题:
-
设计 LLM Twin 的数据收集管道
-
实现 LLM Twin 的数据收集管道
-
将原始数据收集到数据仓库中
到本章结束时,您将了解如何设计和实现一个 ETL 管道,以提取、转换和加载准备就绪的原始数据,以便被 ML 应用程序摄取。
设计 LLM Twin 的数据收集管道
在深入实施之前,我们必须了解 LLM Twin 的数据收集 ETL 架构,如图 3.1 所示。我们必须探索我们将爬取哪些平台以提取数据,以及我们将如何设计我们的数据结构和流程。然而,第一步是理解我们的数据收集管道如何映射到 ETL 流程。
ETL 管道涉及三个基本步骤:
-
我们提取来自各种来源的数据。我们将从 Medium、Substack 和 GitHub 等平台爬取数据以收集原始数据。
-
我们通过清理和标准化数据将其转换为适合存储和分析的统一格式。
-
我们将加载转换后的数据到数据仓库或数据库中。
对于我们的项目,我们使用 MongoDB 作为我们的 NoSQL 数据仓库。尽管这不是一个标准的方法,但我们将很快解释选择这一方法的原因。

图 3.1:LLM Twin 的数据收集 ETL 管道架构
我们希望设计一个 ETL 管道,它以用户和链接列表作为输入。然后,它单独爬取每个链接,标准化收集的内容,并将其保存到 MongoDB 数据仓库中特定作者的目录下。
因此,数据收集管道的签名将如下所示:
-
输入: 链接列表及其关联的用户(作者)
-
输出: 存储在 NoSQL 数据仓库中的原始文档列表
我们将“user”和“author”互换使用,因为在 ETL 管道的大多数场景中,用户是提取内容的作者。然而,在数据仓库内部,我们只有用户集合。
ETL 管道将检测每个链接的域,根据这个域它将调用一个专门的爬虫。我们为三种不同的数据类别实现了四个不同的爬虫,如图 3.2所示。首先,我们将探讨本书中我们将要工作的三个基本数据类别。我们收集的所有文档都可以归结为文章、仓库(或代码)和帖子。数据来源并不重要。我们主要对文档的格式感兴趣。在大多数情况下,我们都需要对这些数据类别进行不同的处理。因此,我们为每个类别创建了一个不同的域实体,每个实体在 MongoDB 中都有自己的类和集合。由于我们在文档的元数据中保存了源 URL,我们仍然知道它的来源,并且可以在我们的 GenAI 用例中引用它。

图 3.2:爬虫和数据类别之间的关系
我们的基础代码支持四种不同的爬虫:
-
Medium 爬虫:用于从 Medium 收集数据。它输出一个文章文档。它登录 Medium 并爬取文章链接的 HTML。然后,它从 HTML 中提取、清理和标准化文本,并将标准化的文章文本加载到 NoSQL 数据仓库中。
-
自定义文章爬虫:它执行与 Medium 爬虫类似的步骤,但是一个更通用的实现,用于从各种网站收集文章。因此,由于它不实现任何特定平台的特性,它不执行登录步骤,而是盲目地从特定链接收集所有 HTML。这对于在线免费提供的文章来说足够了,您可以在 Substack 和人们的博客上找到这些文章。当链接的域名与其它支持的爬虫不关联时,我们将使用这个爬虫作为安全网。例如,当提供 Substack 链接时,它将默认使用自定义文章爬虫,但当提供 Medium URL 时,它将使用 Medium 爬虫。
-
GitHub 爬虫:此爬虫从 GitHub 收集数据。它输出一个仓库文档。它克隆仓库,解析仓库文件树,清理和标准化文件,并将它们加载到数据库中。
-
LinkedIn 爬虫:此爬虫用于从 LinkedIn 收集数据。它输出多个帖子文档。它登录 LinkedIn,导航到用户的动态,并爬取用户的所有最新帖子。对于每篇帖子,它提取其 HTML,清理和标准化它,并将其加载到 MongoDB 中。
在下一节中,我们将详细检查每个爬虫的实现。现在,请注意,每个爬虫以特定方式访问特定的平台或网站,并从中提取 HTML。之后,所有爬虫都会解析 HTML,从中提取文本,并清理和标准化它,以便可以在相同接口下存储在数据仓库中。
通过将所有收集到的数据减少到三个数据类别,而不是为每个新的数据源创建一个新的数据类别,我们可以轻松地将此架构扩展到多个数据源,而无需付出太多努力。例如,如果我们想开始从 X 收集数据,我们只需要实现一个新的爬虫,该爬虫输出一个帖子文档,这就足够了。其余的代码将保持不变。否则,如果我们引入了源维度到类和文档结构中,我们就需要在所有下游层中添加代码以支持任何新的数据源。例如,我们可能需要为每个新的源实现一个新的文档类,并调整特征管道以支持它。
对于我们的概念验证,爬取几百份文档就足够了,但如果我们想将其扩展到现实世界的产品,我们可能需要更多的数据源来爬取。LLMs 对数据有很高的需求。因此,为了获得理想的结果,您需要数千份文档,而不仅仅是几百份。但在许多项目中,实现一个不是最精确的端到端项目版本,并在之后迭代中对其进行改进是一个很好的策略。因此,通过使用这种架构,您可以在未来的迭代中轻松添加更多数据源以收集更大的数据集。关于 LLM 微调和数据集大小将在下一章中详细讨论。
ETL 过程是如何与特征管道连接的? 特征管道从 MongoDB 数据仓库中摄取原始数据,进一步清理它,将其处理成特征,并将其存储在 Qdrant 向量数据库中以使其可用于 LLM 训练和推理管道。第四章提供了有关特征管道的更多信息。ETL 过程独立于特征管道。两个管道严格通过 MongoDB 数据仓库相互通信。因此,数据收集管道可以为 MongoDB 写入数据,而特征管道可以独立地并且在不同的时间表上读取它。
为什么我们选择 MongoDB 作为数据仓库? 使用像 MongoDB 这样的事务型数据库作为数据仓库并不常见。然而,在我们的用例中,我们处理的数据量很小,MongoDB 可以轻松处理。即使我们计划在 MongoDB 集合上计算统计数据,它也能在我们的 LLM Twin 数据规模(数百个文档)上运行良好。我们选择 MongoDB 来存储原始数据的主要原因是因为我们非结构化数据的性质:从互联网爬取的文本。通过主要处理非结构化文本,选择一个不强制执行模式的 NoSQL 数据库使我们的开发更加容易和快速。此外,MongoDB 稳定且易于使用。他们的 Python SDK 直观。他们提供了一个开箱即用的 Docker 镜像,以及一个适合概念验证的云免费层,例如 LLM Twin。因此,我们可以在本地和云中自由地使用它。然而,当处理大数据(数百万个文档或更多)时,使用 Snowflake 或 BigQuery 等专用数据仓库将是理想的。
现在我们已经了解了 LLM Twin 数据收集管道的架构,让我们继续其实现。
实现 LLM Twin 的数据收集管道
如我们在第二章中所述,LLM Twin 项目的每个管道的入口点是 ZenML 管道,该管道可以通过 YAML 文件在运行时进行配置,并通过 ZenML 生态系统运行。因此,让我们首先了解一下 ZenML 的digital_data_etl管道。你会注意到,这是我们第二章中用作示例的相同管道,用于说明 ZenML。但这次,我们将更深入地探讨其实现,解释数据收集背后的工作原理。在了解管道的工作原理后,我们将探索用于从各个网站收集数据的每个爬虫的实现,以及用于存储和查询数据仓库数据的 MongoDB 文档。
ZenML 管道和步骤
below, we can see the implementation of the ZenML digital_data_etl pipeline, which inputs the user’s full name and a list of links that will be crawled under that user (considered the author of the content extracted from those links). Within the function, we call two steps. In the first one, we look up the user in the database based on its full name. Then, we loop through all the links and crawl each independently. The pipeline’s implementation is available in our repository at pipelines/digital_data_etl.py.
from zenml import pipeline
from steps.etl import crawl_links, get_or_create_user
@pipeline
def digital_data_etl(user_full_name: str, links: list[str]) -> str:
user = get_or_create_user(user_full_name)
last_step = crawl_links(user=user, links=links)
return last_step.invocation_id
图 3.3显示了在 ZenML 仪表板上运行的digital_data_etl管道。下一阶段是单独探索get_or_create_user和crawl_links ZenML 步骤。步骤实现可在我们的存储库steps/etl中找到。

图 3.3:从 ZenML 仪表板运行的 digital_data_etl 管道示例
我们将从 get_or_create_user ZenML 步骤开始。我们首先导入在整个脚本中使用的必要模块和函数。
from loguru import logger
from typing_extensions import Annotated
from zenml import get_step_context, step
from llm_engineering.application import utils
from llm_engineering.domain.documents import UserDocument
接下来,我们定义函数的签名,它接受用户的全名作为输入,并检索现有的用户或(如果不存在)在 MongoDB 数据库中创建一个新的用户:
@step
def get_or_create_user(user_full_name: str) -> Annotated[UserDocument, "user"]:
使用一个实用函数,我们将全名拆分为名和姓。然后,我们尝试从数据库中检索用户或创建一个新的用户(如果不存在)。我们还检索当前的步骤上下文,并将有关用户的元数据添加到输出中,这将反映在 user ZenML 输出实体的元数据中:
logger.info(f"Getting or creating user: {user_full_name}")
first_name, last_name = utils.split_user_full_name(user_full_name)
user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
step_context = get_step_context()
step_context.add_output_metadata(output_name="user", metadata=_get_metadata(user_full_name, user))
return user
此外,我们还定义了一个名为 _get_metadata() 的辅助函数,该函数构建一个包含查询参数和检索到的用户信息的字典,这些信息将被添加到用户实体的元数据中:
def _get_metadata(user_full_name: str, user: UserDocument) -> dict:
return {
"query": {
"user_full_name": user_full_name,
},
"retrieved": {
"user_id": str(user.id),
"first_name": user.first_name,
"last_name": user.last_name,
},
}
我们将进入 crawl_links ZenML 步骤,该步骤从提供的链接中收集数据。代码首先导入用于网络爬取的必要模块和库:
from urllib.parse import urlparse
from loguru import logger
from tqdm import tqdm
from typing_extensions import Annotated
from zenml import get_step_context, step
from llm_engineering.application.crawlers.dispatcher import CrawlerDispatcher
from llm_engineering.domain.documents import UserDocument
在导入之后,主函数输入由特定作者编写的链接列表。在这个函数中,初始化并配置了一个爬虫调度器,以处理特定的域名,如 LinkedIn、Medium 和 GitHub:
@step
def crawl_links(user: UserDocument, links: list[str]) -> Annotated[list[str], "crawled_links"]:
dispatcher = CrawlerDispatcher.build().register_linkedin().register_medium().register_github()
logger.info(f"Starting to crawl {len(links)} link(s).")
函数初始化变量以存储输出元数据和成功爬取的计数。然后,它遍历每个链接。它尝试爬取并提取每个链接的数据,更新成功爬取的计数并累积有关每个 URL 的元数据:
metadata = {}
successfull_crawls = 0
for link in tqdm(links):
successfull_crawl, crawled_domain = _crawl_link(dispatcher, link, user)
successfull_crawls += successfull_crawl
metadata = _add_to_metadata(metadata, crawled_domain, successfull_crawl)
在处理完所有链接后,该函数将累积的元数据附加到输出实体上:
step_context = get_step_context()
step_context.add_output_metadata(output_name="crawled_links", metadata=metadata)
logger.info(f"Successfully crawled {successfull_crawls} / {len(links)}
links.")
return links
代码中包含一个辅助函数,该函数尝试根据链接的域名使用适当的爬虫从每个链接中提取信息。它处理在提取过程中可能发生的任何异常,并返回一个元组,指示爬取的成功与否以及链接的域名:
def _crawl_link(dispatcher: CrawlerDispatcher, link: str, user: UserDocument) -> tuple[bool, str]:
crawler = dispatcher.get_crawler(link)
crawler_domain = urlparse(link).netloc
try:
crawler.extract(link=link, user=user)
return (True, crawler_domain)
except Exception as e:
logger.error(f"An error occurred while crawling: {e!s}")
return (False, crawler_domain)
还提供了一个辅助函数来更新元数据字典,以包含每次爬取的结果:
def _add_to_metadata(metadata: dict, domain: str, successfull_crawl: bool) -> dict:
if domain not in metadata:
metadata[domain] = {}
metadata[domain]["successful"] = metadata.get(domain, {}).get("successful", 0) + successfull_crawl
metadata[domain]["total"] = metadata.get(domain, {}).get("total", 0) + 1
return metadata
如上述 _crawl_link() 函数所示,CrawlerDispatcher 类根据每个链接的域名知道要初始化哪个爬虫。然后,逻辑被抽象到爬虫的 extract() 方法下。让我们深入探讨 CrawlerDispatcher 类,以全面了解其工作原理。
调度器:如何实例化正确的爬虫?
我们爬取逻辑的入口点是 CrawlerDispatcher 类。如图 3.4 所示,调度器充当提供链接和爬虫之间的中间层。它知道要将哪个爬虫与每个 URL 关联。
CrawlerDispatcher类知道如何提取每个链接的域名并初始化从该网站收集数据的适当爬虫。例如,当提供一个指向文章的链接并检测到medium.com域名时,它将构建一个用于爬取该特定平台的MediumCrawler实例。考虑到这一点,让我们来探讨CrawlerDispatcher类的实现。
所有的爬虫逻辑都可以在 GitHub 仓库的llm_engineering/application/crawlers中找到。

图 3.4:提供的链接、CrawlerDispatcher 和爬虫之间的关系
我们首先导入处理 URL 和正则表达式的必要 Python 模块,以及导入我们的爬虫类:
import re
from urllib.parse import urlparse
from loguru import logger
from .base import BaseCrawler
from .custom_article import CustomArticleCrawler
from .github import GithubCrawler
from .linkedin import LinkedInCrawler
from .medium import MediumCrawler
CrawlerDispatcher类被定义为根据给定的 URL 及其域名管理和调度适当的爬虫实例。其构造函数初始化一个注册表来存储已注册的爬虫。
class CrawlerDispatcher:
def __init__(self) -> None:
self._crawlers = {}
由于我们使用构建器创建模式来实例化和配置调度器,我们定义了一个build()类方法,该方法返回调度器的一个实例:
@classmethod
def build(cls) -> "CrawlerDispatcher":
dispatcher = cls()
return dispatcher
调度器包括用于注册特定平台(如 Medium、LinkedIn 和 GitHub)的爬虫的方法。这些方法在底层使用通用的register()方法将每个爬虫添加到注册表中。通过返回 self,我们遵循构建器创建模式(更多关于构建器模式:refactoring.guru/design-patterns/builder)。在实例化调度器时,我们可以链式调用多个register_*()方法,如下所示:CrawlerDispatcher.build().register_linkedin().register_medium()。
def register_medium(self) -> "CrawlerDispatcher":
self.register("https://medium.com", MediumCrawler)
return self
def register_linkedin(self) -> "CrawlerDispatcher":
self.register("https://linkedin.com", LinkedInCrawler)
return self
def register_github(self) -> "CrawlerDispatcher":
self.register("https://github.com", GithubCrawler)
return self
通用的register()方法将每个域名标准化,以确保在将其添加到调度器的self._crawlers注册表作为键之前其格式是一致的。这是一个关键步骤,因为我们将使用字典的键作为域名模式来匹配未来的链接与爬虫:
def register(self, domain: str, crawler: type[BaseCrawler]) -> None:
parsed_domain = urlparse(domain)
domain = parsed_domain.netloc
self._crawlers[r"https://(www\.)?{}/*".format(re.escape(domain))] = crawler
最后,get_crawler()方法通过将给定的 URL 与已注册的域名进行匹配来确定适当的爬虫。如果没有找到匹配项,它将记录一个警告并默认使用CustomArticleCrawler。
def get_crawler(self, url: str) -> BaseCrawler:
for pattern, crawler in self._crawlers.items():
if re.match(pattern, url):
return crawler()
else:
logger.warning(f"No crawler found for {url}. Defaulting to CustomArticleCrawler.")
return CustomArticleCrawler()
理解数据收集管道的工作原理的下一步是逐个分析每个爬虫。
爬虫
在探索每个爬虫的实现之前,我们必须介绍它们的基类,该基类为所有爬虫定义了一个统一的接口。如图3.4所示,我们可以实现调度器层,因为每个爬虫遵循相同的签名。每个类都实现了extract()方法,这使得我们可以利用面向对象技术,如多态,在不了解其具体子类的情况下与抽象对象一起工作。例如,在 ZenML 步骤中的_crawl_link()函数中,我们有以下代码:
crawler = dispatcher.get_crawler(link)
crawler.extract(link=link, user=user)
注意我们是如何调用extract()方法而不关心我们实例化了哪种具体的爬虫类型。总结来说,使用抽象接口确保了核心的可重用性和扩展的便捷性。
基础类
现在,让我们探索BaseCrawler接口,它可以在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/crawlers/base.py仓库中找到。
from abc import ABC, abstractmethod
class BaseCrawler(ABC):
model: type[NoSQLBaseDocument]
@abstractmethod
def extract(self, link: str, **kwargs) -> None: ...
如上所述,接口定义了一个extract()方法,它接受一个链接作为输入。它还在类级别定义了一个模型属性,代表用于将提取的数据保存到 MongoDB 数据仓库的数据类别文档类型。这样做允许我们使用不同的数据类别自定义每个子类,同时保留类级别的相同属性。当我们深入研究文档实体时,我们将很快探索NoSQLBaseDocument类。
我们还通过BaseSeleniumCrawler类扩展了BaseCrawler类,该类实现了使用 Selenium 爬取各种网站的可重用功能,例如 Medium 或 LinkedIn。Selenium是一个自动化网页浏览器的工具。它用于以编程方式与网页交互(如登录 LinkedIn,浏览个人资料等)。
Selenium 可以以编程方式控制各种浏览器,如 Chrome、Firefox 或 Brave。对于这些特定平台,我们需要使用 Selenium 以编程方式操作浏览器以登录并滚动新闻源或文章,然后才能提取整个 HTML。对于其他网站,我们不需要经过登录步骤或可以直接加载整个页面,我们可以使用比 Selenium 更直接的方法从特定 URL 提取 HTML。
为了使基于 Selenium 的爬虫能够工作,您必须在您的机器上安装 Chrome(或如 Brave 这样的基于 Chromium 的浏览器)。
代码首先设置了使用 Selenium 和 ChromeDriver 初始化器进行网络爬取所需的必要导入和配置。chromedriver_autoinstaller确保安装了正确的 ChromeDriver 版本并将其添加到系统路径中,以保持与已安装的 Google Chrome 浏览器(或其他基于 Chromium 的浏览器)的兼容性。Selenium 将使用 ChromeDriver 与浏览器通信并打开一个无头会话,在那里我们可以通过编程方式操作浏览器以访问各种 URL,点击特定的元素,如按钮,或滚动新闻源。使用chromedriver_autoinstaller,我们确保始终安装了与我们的机器 Chrome 浏览器版本匹配的正确 ChromeDriver 版本。
import time
from tempfile import mkdtemp
import chromedriver_autoinstaller
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from llm_engineering.domain.documents import NoSQLBaseDocument
# Check if the current version of chromedriver exists
# and if it doesn't exist, download it automatically,
# then add chromedriver to path
chromedriver_autoinstaller.install()
接下来,我们定义了BaseSeleniumCrawler类,用于需要使用 Selenium 来收集数据的用例,例如从 Medium 或 LinkedIn 收集数据。
其构造函数初始化各种 Chrome 选项以优化性能、增强安全性和确保无头浏览环境。这些选项禁用了如 GPU 渲染、扩展和通知等不必要的功能,这些功能可能会干扰自动化浏览。这些是在无头模式下爬取时的标准配置:
class BaseSeleniumCrawler(BaseCrawler, ABC):
def __init__(self, scroll_limit: int = 5) -> None:
options = webdriver.ChromeOptions()
options.add_argument("--no-sandbox")
options.add_argument("--headless=new")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--log-level=3")
options.add_argument("--disable-popup-blocking")
options.add_argument("--disable-notifications")
options.add_argument("--disable-extensions")
options.add_argument("--disable-background-networking")
options.add_argument("--ignore-certificate-errors")
options.add_argument(f"--user-data-dir={mkdtemp()}")
options.add_argument(f"--data-path={mkdtemp()}")
options.add_argument(f"--disk-cache-dir={mkdtemp()}")
options.add_argument("--remote-debugging-port=9226")
在配置 Chrome 选项后,代码允许子类通过调用set_extra_driver_options()方法设置任何额外的驱动程序选项。然后,它初始化滚动限制并创建一个新的 Chrome 驱动程序实例,带有指定的选项:
self.set_extra_driver_options(options)
self.scroll_limit = scroll_limit
self.driver = webdriver.Chrome(
options=options,
)
BaseSeleniumCrawler类包括set_extra_driver_options()和login()的占位符方法,子类可以覆盖以提供特定功能。这确保了模块化,因为每个平台都有一个不同的登录页面,具有不同的 HTML 结构:
def set_extra_driver_options(self, options: Options) -> None:
pass
def login(self) -> None:
pass
最后,scroll_page()方法实现了一个滚动机制,用于导航到指定滚动限制的页面,例如 LinkedIn。它滚动到页面底部,等待新内容加载,并重复此过程,直到达到页面底部或超过滚动限制。此方法对于内容随用户滚动而出现的动态内容流至关重要:
def scroll_page(self) -> None:
"""Scroll through the LinkedIn page based on the scroll limit."""
current_scroll = 0
last_height = self.driver.execute_script("return document.body.scrollHeight")
while True:
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(5)
new_height = self.driver.execute_script("return document.body.scrollHeight")
if new_height == last_height or (self.scroll_limit and current_scroll >= self.scroll_limit):
break
last_height = new_height
current_scroll += 1
我们已经了解了我们爬虫的基类是什么样的。接下来,我们将探讨以下特定爬虫的实现:
-
GitHubCrawler(BaseCrawler) -
CustomArticleCrawler(BaseCrawler) -
MediumCrawler(BaseSeleniumCrawler)您可以在 GitHub 仓库中找到上述爬虫的实现,网址为
github.com/PacktPublishing/LLM-Engineers-Handbook/tree/main/llm_engineering/application/crawlers。
GitHubCrawler 类
GithubCrawler类旨在抓取 GitHub 仓库,扩展了BaseCrawler的功能。我们不需要通过浏览器登录 GitHub,因为我们可以利用 Git 的克隆功能。因此,我们不需要利用任何 Selenium 功能。初始化时,它设置了一个要忽略的图案列表,以排除 GitHub 仓库中发现的标准文件和目录,如.git、.toml、.lock和.png,确保不必要的文件被排除在抓取过程之外:
class GithubCrawler(BaseCrawler):
model = RepositoryDocument
def __init__(self, ignore=(".git", ".toml", ".lock", ".png")) -> None:
super().__init__()
self._ignore = ignore
接下来,我们实现extract()方法,其中爬虫首先检查仓库是否已经被处理并存储在数据库中。如果存在,它将退出方法以防止存储重复项:
def extract(self, link: str, **kwargs) -> None:
old_model = self.model.find(link=link)
if old_model is not None:
logger.info(f"Repository already exists in the database: {link}")
return
如果仓库是新的,爬虫会从链接中提取仓库名称。然后,它创建一个临时目录来克隆仓库,以确保在处理完毕后从本地磁盘清理克隆的仓库:
logger.info(f"Starting scrapping GitHub repository: {link}")
repo_name = link.rstrip("/").split("/")[-1]
local_temp = tempfile.mkdtemp()
在 try 块中,爬虫将当前工作目录更改为temporary目录,并在不同的进程中执行git clone命令:
try:
os.chdir(local_temp)
subprocess.run(["git", "clone", link])
成功克隆存储库后,爬虫构建了克隆存储库的路径。它初始化一个空字典,用于以标准化的方式聚合文件内容。它遍历目录树,跳过任何匹配忽略模式的目录或文件。对于每个相关文件,它读取内容,删除任何空格,并以文件路径作为键将其存储在字典中:
repo_path = os.path.join(local_temp, os.listdir(local_temp)[0]) #
tree = {}
for root, _, files in os.walk(repo_path):
dir = root.replace(repo_path, "").lstrip("/")
if dir.startswith(self._ignore):
continue
for file in files:
if file.endswith(self._ignore):
continue
file_path = os.path.join(dir, file)
with open(os.path.join(root, file), "r", errors="ignore") as f:
tree[file_path] = f.read().replace(" ", "")
随后,它创建了一个新的RepositoryDocument模型实例,并用存储库内容、名称、链接、平台信息和作者详情填充。然后,这个实例被保存到 MongoDB 中:
user = kwargs["user"]
instance = self.model(
content=tree,
name=repo_name,
link=link,
platform="github",
author_id=user.id,
author_full_name=user.full_name,
)
instance.save()
最后,无论抓取是否成功或发生异常,爬虫都会确保临时目录被删除,以清理在过程中使用的任何资源:
except Exception:
raise
finally:
shutil.rmtree(local_temp)
logger.info(f"Finished scrapping GitHub repository: {link}")
CustomArticleCrawler 类
CustomArticleCrawler类以不同的方式从互联网收集数据。它利用AsyncHtmlLoader类从链接中读取整个 HTML,并使用Html2TextTransformer类从该 HTML 中提取文本。这两个类由langchain_community Python 包提供,如下所示,其中我们导入了所有必要的 Python 模块:
from urllib.parse import urlparse
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_community.document_transformers.html2text import Html2TextTransformer
from loguru import logger
from llm_engineering.domain.documents import ArticleDocument
from .base import BaseCrawler
接下来,我们定义CustomArticleCrawler类,它继承自BaseCrawler。和之前一样,我们不需要登录或使用 Selenium 提供的滚动功能。在extract方法中,我们首先检查文章是否存在于数据库中,以避免内容重复:
class CustomArticleCrawler(BaseCrawler):
model = ArticleDocument
def extract(self, link: str, **kwargs) -> None:
old_model = self.model.find(link=link)
if old_model is not None:
logger.info(f"Article already exists in the database: {link}")
return
如果文章不存在,我们继续抓取它。我们使用AsyncHtmlLoader类从提供的链接中加载 HTML。之后,我们使用Html2TextTransformer类将其转换为纯文本,该类返回一个文档列表。我们只对第一个文档感兴趣。由于我们将整个逻辑委托给这两个类,我们无法控制内容是如何提取和解析的。这就是为什么我们使用这个类作为没有自定义实现的域的回退系统。这两个类遵循 LangChain 范式,它提供了在大多数场景中表现良好的高级功能。它实现速度快,但定制困难。这也是许多开发者在生产用例中避免使用 LangChain 的原因之一:
logger.info(f"Starting scrapping article: {link}")
loader = AsyncHtmlLoader([link])
docs = loader.load()
html2text = Html2TextTransformer()
docs_transformed = html2text.transform_documents(docs)
doc_transformed = docs_transformed[0]
我们从提取的文档中获取页面内容,以及相关的元数据,如标题、副标题、内容和语言:
content = {
"Title": doc_transformed.metadata.get("title"),
"Subtitle": doc_transformed.metadata.get("description"),
"Content": doc_transformed.page_content,
"language": doc_transformed.metadata.get("language"),
}
接下来,我们解析 URL 以确定文章是从哪个平台(或域名)抓取的:
parsed_url = urlparse(link)
platform = parsed_url.netloc
然后,我们创建一个文章模型的新实例,并用提取的内容填充它。最后,我们将这个实例保存到 MongoDB 数据仓库中:
user = kwargs["user"]
instance = self.model(
content=content,
link=link,
platform=platform,
author_id=user.id,
author_full_name=user.full_name,
)
instance.save()
logger.info(f"Finished scrapping custom article: {link}")
到目前为止,我们已经看到了如何使用 LangChain 实用函数抓取 GitHub 存储库和随机网站。最后,我们必须探索一个使用 Selenium 来编程操作浏览器的爬虫。因此,我们将继续MediumCrawler的实现。
MediumCrawler 类
代码首先导入必要的库并定义MediumCrawler类,该类继承自BaseSeleniumCrawler:
from bs4 import BeautifulSoup
from loguru import logger
from llm_engineering.domain.documents import ArticleDocument
from .base import BaseSeleniumCrawler
class MediumCrawler(BaseSeleniumCrawler):
model = ArticleDocument
在MediumCrawler类中,我们利用set_extra_driver_options()方法来扩展 Selenium 使用的默认驱动选项:
def set_extra_driver_options(self, options) -> None:
options.add_argument(r"--profile-directory=Profile 2")
extract()方法实现了核心功能,首先检查文章是否存在于数据库中,以防止重复条目。
如果文章是新的,该方法将继续导航到文章的链接并滚动页面以确保所有内容都已加载:
def extract(self, link: str, **kwargs) -> None:
old_model = self.model.find(link=link)
if old_model is not None:
logger.info(f"Article already exists in the database: {link}")
return
logger.info(f"Starting scrapping Medium article: {link}")
self.driver.get(link)
self.scroll_page()
在页面完全加载后,该方法使用BeautifulSoup解析 HTML 内容并提取文章的标题、副标题和全文。BeautifulSoup是一个流行的 Python 库,用于网络爬取和解析 HTML 或 XML 文档。因此,我们使用它从 Selenium 访问的 HTML 中提取所有需要的 HTML 元素。最后,我们将所有内容聚合到一个字典中:
soup = BeautifulSoup(self.driver.page_source, "html.parser")
title = soup.find_all("h1", class_="pw-post-title")
subtitle = soup.find_all("h2", class_="pw-subtitle-paragraph")
data = {
"Title": title[0].string if title else None,
"Subtitle": subtitle[0].string if subtitle else None,
"Content": soup.get_text(),
}
最后,该方法关闭 WebDriver 以释放资源。然后,它创建一个新的ArticleDocument实例,用通过kwargs提供的提取内容和使用户信息填充它,并将其保存到数据库中:
self.driver.close()
user = kwargs["user"]
instance = self.model(
platform="medium",
content=data,
link=link,
author_id=user.id,
author_full_name=user.full_name,
)
instance.save()
logger.info(f"Successfully scraped and saved article: {link}")
有了这些,我们就完成了MediumCrawler的实现。LinkedIn 爬虫遵循与 Medium 类似的模式,它使用 Selenium 登录并访问用户最新帖子的动态内容。然后,它提取帖子并滚动动态内容以加载下一页,直到达到限制。您可以在我们的仓库中查看完整的实现:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/crawlers/linkedin.py。
随着 LLMs 的兴起,从互联网收集数据已成为许多现实世界 AI 应用的关键步骤。因此,Python 生态系统出现了更多高级工具,例如 Scrapy (github.com/scrapy/scrapy),它爬取网站并从其页面中提取结构化数据,以及 Crawl4AI (github.com/unclecode/crawl4ai),它高度专业于为 LLMs 和 AI 应用爬取数据。
在本节中,我们探讨了实现三种类型的爬虫:一种利用子进程中的git可执行文件来克隆 GitHub 仓库的爬虫,一种使用 LangChain 工具提取单个网页的 HTML 的爬虫,以及一种利用 Selenium 在更复杂的场景中导航登录页面、滚动文章以加载整个 HTML 并将其提取为文本格式的爬虫。最后一步是理解我们本章中使用的文档类,如ArticleDocument,是如何工作的。
NoSQL 数据仓库文档
我们不得不实现三个文档类来结构化我们的数据类别。这些类定义了我们需要的特定属性,例如内容、作者和来源链接。将数据结构化在类中而不是字典中是一种最佳实践,因为每个项目预期的属性更加详细,这可以减少运行错误。例如,当我们从一个 Python 字典中访问值时,我们永远无法确定它是否存在或其类型是否正确。通过将我们的数据项包装在类中,我们可以确保每个属性都符合预期。
通过利用 Python 包如 Pydantic,我们获得了开箱即用的类型验证,这确保了数据集的一致性。因此,我们将数据类别建模为以下文档类,这些类我们在代码中直到该点已经使用过:
-
ArticleDocument类 -
PostDocument类 -
RepositoryDocument类
这些不仅仅是简单的 Python 数据类或 Pydantic 模型。它们支持在 MongoDB 数据仓库上执行读写操作。为了将读写功能注入到所有文档类中而不重复任何代码,我们使用了 对象-文档映射(ODM)软件模式,该模式基于 对象关系映射(ORM)模式。因此,让我们首先探索 ORM,然后转向 ODM,最后深入我们的自定义 ODM 实现和文档类。
ORM 和 ODM 软件模式
在我们讨论软件模式之前,让我们看看什么是 ORM。它是一种技术,允许你使用面向对象的方法查询和操作数据库中的数据。而不是编写 SQL 或 API 特定的查询,你将所有复杂性封装在一个 ORM 类中,该类知道如何处理所有数据库操作,最常见的是 CRUD 操作。因此,使用 ORM 可以消除手动处理数据库操作的需要,并减少手动编写样板代码的需要。ORM 与 SQL 数据库(如 PostgreSQL 或 MySQL)交互。
大多数现代 Python 应用程序在与数据库交互时使用 ORM。尽管 SQL 在数据世界中仍然是一个流行的选择,但在 Python 后端组件中很少看到原始 SQL 查询。最流行的 Python ORM 是 SQLAlchemy (www.sqlalchemy.org/)。此外,随着 FastAPI 的兴起,SQLModel (github.com/fastapi/sqlmodel) 成为了一个常见选择,它是一个 SQLAlchemy 的包装器,使得与 FastAPI 的集成更加容易。
例如,使用 SQLAlchemy,我们定义了一个包含 ID 和名称字段的 User ORM。User ORM 映射到 SQL 数据库中的 users 表。因此,当我们创建一个新用户并将其提交到数据库时,它将自动保存到 users 表中。对 User 类的所有 CRUD 操作也是如此。
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
# Define a class that maps to the users table.
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
使用 User ORM,我们可以快速地从 Python 中直接插入或查询用户,而不需要写一行 SQL。请注意,ORM 通常支持所有 CRUD 操作。以下是一个代码片段,展示了如何将 User ORM 的一个实例保存到 SQLite 数据库中:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
# Create a session used to interact with the database.
Session = sessionmaker(bind=engine)
session = Session()
# Add a new user.
new_user = User(name="Alice")
session.add(new_user)
session.commit()
此外,这就是我们从 users SQLite 表中查询用户的方式:
user = session.query(User).first()
if user:
print(f"User ID: {user.id}")
print(f"User name: {user.name}")
在 GitHub 仓库 code_snippets/03_orm.py 中找到整个脚本及其运行方法。
ODM 模式与 ORM 非常相似,但它不是与 SQL 数据库和表一起工作,而是与 NoSQL 数据库(如 MongoDB)和非结构化集合一起工作。当我们与 NoSQL 数据库一起工作时,数据结构以集合为中心,这些集合存储类似于 JSON 的文档,而不是表中的行。
总结来说,ODM 简化了与基于文档的 NoSQL 数据库的工作,并将面向对象的代码映射到类似于 JSON 的文档。我们将在 MongoDB 之上实现一个轻量级的 ODM 模块,以完全理解 ODM 的工作原理。
实现 ODM 类
本节将探讨如何从头开始实现一个 ODM 类。这是一个很好的练习,可以学习 ODM 的工作原理并提高我们编写模块化和可重用 Python 类的技能。因此,我们将实现一个名为 NoSQLBaseDocument 的基础 ODM 类,其他所有文档都将从中继承以与 MongoDB 数据仓库交互。
该类可以在我们的仓库中找到,位于 llm_engineering/domain/base/nosql.py。
代码首先导入必要的模块并设置数据库连接。通过 _database 变量,我们连接到设置中指定的数据库,默认名为 twin:
import uuid
from abc import ABC
from typing import Generic, Type, TypeVar
from loguru import logger
from pydantic import UUID4, BaseModel, Field
from pymongo import errors
from llm_engineering.domain.exceptions import ImproperlyConfigured
from llm_engineering.infrastructure.db.mongo import connection
from llm_engineering.settings import settings
_database = connection.get_database(settings.DATABASE_NAME)
接下来,我们定义一个类型变量 T,它绑定到 NoSQLBaseDocument 类。该变量利用 Python 的泛型模块,使我们能够泛化类的类型。例如,当我们实现继承自 NoSQLBaseDocument 类的 ArticleDocument 类时,所有使用 T 的实例在分析函数签名时都将被替换为 ArticleDocument 类型(更多关于 Python 泛型的信息:realpython.com/python312-typing)。
然后,NoSQLBaseDocument 类被声明为一个继承自 Pydantic 的 BaseModel、Python 的 Generic(提供前面描述的功能)和 ABC(使该类成为抽象类)类的抽象基类。这个类作为基础 ODM 类:
T = TypeVar("T", bound="NoSQLBaseDocument")
class NoSQLBaseDocument(BaseModel, Generic[T], ABC):
在 NoSQLBaseDocument 类中,定义了一个 id 字段,其类型为 UUID4,默认工厂生成一个唯一的 UUID。该类还实现了 __eq__ 和 __hash__ 方法,以便实例可以被比较,并可以在基于其唯一 id 属性的集合或字典键等散列集合中使用:
id: UUID4 = Field(default_factory=uuid.uuid4)
def __eq__(self, value: object) -> bool:
if not isinstance(value, self.__class__):
return False
return self.id == value.id
def __hash__(self) -> int:
return hash(self.id)
该类提供了在 MongoDB 文档和类实例之间进行转换的方法。from_mongo() 类方法将从 MongoDB 检索到的字典转换为类的实例。to_mongo() 实例方法将模型实例转换为适合 MongoDB 插入的字典:
@classmethod
def from_mongo(cls: Type[T], data: dict) -> T:
if not data:
raise ValueError("Data is empty.")
id = data.pop("_id")
return cls(**dict(data, id=id))
def to_mongo(self: T, **kwargs) -> dict:
exclude_unset = kwargs.pop("exclude_unset", False)
by_alias = kwargs.pop("by_alias", True)
parsed = self.model_dump(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs)
if "_id" not in parsed and "id" in parsed:
parsed["_id"] = str(parsed.pop("id"))
for key, value in parsed.items():
if isinstance(value, uuid.UUID):
parsed[key] = str(value)
return parsed
save() 方法允许将模型实例插入到 MongoDB 集合中。它检索适当的集合,利用上述 to_mongo() 方法将实例转换为 MongoDB 兼容的文档,并尝试将其插入到数据库中,处理可能发生的任何写入错误:
def save(self: T, **kwargs) -> T | None:
collection = _database[self.get_collection_name()]
try:
collection.insert_one(self.to_mongo(**kwargs))
return self
except errors.WriteError:
logger.exception("Failed to insert document.")
return None
get_or_create() 类方法尝试在数据库中找到与提供的过滤器选项匹配的文档。如果找到匹配的文档,则将其转换为类的实例。如果没有找到,则创建一个新的实例,其初始数据为过滤器选项,并将其保存到数据库中:
@classmethod
def get_or_create(cls: Type[T], **filter_options) -> T:
collection = _database[cls.get_collection_name()]
try:
instance = collection.find_one(filter_options)
if instance:
return cls.from_mongo(instance)
new_instance = cls(**filter_options)
new_instance = new_instance.save()
return new_instance
except errors.OperationFailure:
logger.exception(f"Failed to retrieve document with filter options: {filter_options}")
raise
bulk_insert() 类方法允许一次将多个文档插入到数据库中:
@classmethod
def bulk_insert(cls: Type[T], documents: list[T], **kwargs) -> bool:
collection = _database[cls.get_collection_name()]
try:
collection.insert_many([doc.to_mongo(**kwargs) for doc in documents])
return True
except (errors.WriteError, errors.BulkWriteError):
logger.error(f"Failed to insert documents of type {cls.__name__}")
return False
find() 类方法在数据库中搜索单个文档,该文档与给定的过滤器选项匹配:
@classmethod
def find(cls: Type[T], **filter_options) -> T | None:
collection = _database[cls.get_collection_name()]
try:
instance = collection.find_one(filter_options)
if instance:
return cls.from_mongo(instance)
return None
except errors.OperationFailure:
logger.error("Failed to retrieve document.")
return None
类似地,bulk_find() 类方法检索与过滤器选项匹配的多个文档。它将每个检索到的 MongoDB 文档转换为模型实例,并将它们收集到一个列表中:
@classmethod
def bulk_find(cls: Type[T], **filter_options) -> list[T]:
collection = _database[cls.get_collection_name()]
try:
instances = collection.find(filter_options)
return [document for instance in instances if (document := cls.from_mongo(instance)) is not None]
except errors.OperationFailure:
logger.error("Failed to retrieve document.")
return []
最后,get_collection_name() 类方法确定与该类关联的 MongoDB 集合的名称。它期望该类有一个嵌套的 Settings 类,其中包含一个指定集合名称的 name 属性。如果缺少此配置,将引发一个 ImproperlyConfigured 异常,指定子类应定义一个嵌套的 Settings 类:
@classmethod
def get_collection_name(cls: Type[T]) -> str:
if not hasattr(cls, "Settings") or not hasattr(cls.Settings, "name"):
raise ImproperlyConfigured(
"Document should define an Settings configuration class with the name of the collection."
)
return cls.Settings.name
我们可以使用嵌套的 Settings 类配置每个子类,例如定义集合名称,或任何特定于该子类的其他内容。在 Python 生态系统内,有一个基于 MongoDB 的 ODM 实现,称为 mongoengine,您可以在 GitHub 上找到它。它遵循与我们的类似但更全面的模式。我们自行实现了它,因为它是一个练习编写模块化和通用代码的绝佳机会,遵循最佳 OOP 原则,这对于实现生产级代码至关重要。
数据类别和用户文档类
最后一个拼图是查看从 NoSQLBaseDocument 基类继承的子类的实现。这些是定义我们的数据类别的具体类。您在处理爬虫类中的文章、存储库和帖子时已经看到了这些类的使用。
我们首先导入必要的 Python 模块和 ODM 基类:
from abc import ABC
from typing import Optional
from pydantic import UUID4, Field
from .base import NoSQLBaseDocument
from .types import DataCategory
我们定义一个 enum 类,其中我们集中所有我们的数据类别类型。这些变量将在本书中配置所有我们的 ODM 类时作为常量使用。
该类可以在 llm_engineering/domain/types.py 仓库中找到。
from enum import StrEnum
class DataCategory(StrEnum):
PROMPT = "prompt"
QUERIES = "queries"
INSTRUCT_DATASET_SAMPLES = "instruct_dataset_samples"
INSTRUCT_DATASET = "instruct_dataset"
PREFERENCE_DATASET_SAMPLES = "preference_dataset_samples"
PREFERENCE_DATASET = "preference_dataset"
POSTS = "posts"
ARTICLES = "articles"
REPOSITORIES = "repositories"
Document类被引入作为NoSQLBaseDocument ODM 类之上的其他文档的抽象基模型。它包括常见的属性,如内容、平台和作者详情,为将继承它的文档提供了一个标准化的结构:
class Document(NoSQLBaseDocument, ABC):
content: dict
platform: str
author_id: UUID4 = Field(alias="author_id")
author_full_name: str = Field(alias="author_full_name")
最后,通过扩展Document类定义特定的文档类型。RepositoryDocument、PostDocument和ArticleDocument类代表不同的数据类别,每个类别都有独特的字段和设置,指定其在数据库中的相应集合名称:
class RepositoryDocument(Document):
name: str
link: str
class Settings:
name = DataCategory.REPOSITORIES
class PostDocument(Document):
image: Optional[str] = None
link: str | None = None
class Settings:
name = DataCategory.POSTS
class ArticleDocument(Document):
link: str
class Settings:
name = DataCategory.ARTICLES
最后,我们定义了UserDocument类,用于存储和查询 LLM Twin 项目中的所有用户:
class UserDocument(NoSQLBaseDocument):
first_name: str
last_name: str
class Settings:
name = "users"
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
通过实现NoSQLBaseDocument ODM 类,我们必须专注于每个文档或域实体的字段和特定功能。所有 CRUD 功能都委派给了父类。此外,通过利用 Pydantic 来定义字段,我们获得了开箱即用的类型验证。例如,当创建ArticleDocument类的实例时,如果提供的链接是None或不是字符串,它将抛出一个错误,表示数据无效。
通过这样,我们已经完成了数据收集管道的实现,从 ZenML 组件开始,然后研究了爬虫的实现,最后用 ODM 类和数据类别文档结束了整个过程。最后一步是运行数据收集管道并将原始数据导入 MongoDB 数据仓库。
将原始数据收集到数据仓库中
ZenML 协调数据收集管道。因此,利用 ZenML,数据收集管道可以手动运行、计划或由特定事件触发。在这里,我们将向您展示如何手动运行它,而我们将讨论在第十一章中深入挖掘 MLOps 的其他场景。
我们为每位作者配置了不同的管道运行。我们为 Paul Iusztin 或 Maxime Labonne 的数据提供了 ZenML 配置文件。例如,要调用数据收集管道以收集 Maxime 的数据,您可以运行以下 CLI 命令:
poetry poe run-digital-data-etl-maxime
这将调用以下 ZenML YAML 配置文件中的管道:
parameters:
user_full_name: Maxime Labonne # [First Name(s)] [Last Name]
links:
# Personal Blog
- https://mlabonne.github.io/blog/posts/2024-07-29_Finetune_Llama31.html
- https://mlabonne.github.io/blog/posts/2024-07-15_The_Rise_of_Agentic_Data_Generation.html
# Substack
- https://maximelabonne.substack.com/p/uncensor-any-llm-with-abliteration-d30148b7d43e
- https://maximelabonne.substack.com/p/create-mixtures-of-experts-with-mergekit-11b318c99562
- https://maximelabonne.substack.com/p/merge-large-language-models-with-mergekit-2118fb392b54
… # More Substack links
在前面的图 3.3中,我们看到了管道的运行 DAG 和 ZenML 仪表板中的详细信息。同时,图 3.5展示了由这个数据收集管道生成的user输出结果。您可以检查user_full_name查询和从 MongoDB 数据库检索到的user,这是我们在这个特定运行中收集的链接。
![img/B31105_03_05.png]
图 3.5:使用 Maxime 的配置文件运行数据收集管道后的用户输出结果示例
此外,在图 3.6中,您可以观察到crawled_links输出结果,其中列出了我们从哪些域名收集了数据,每个域抓取的链接总数,以及成功收集的链接数量。
我们再次强调这些工件的力量,因为它们跟踪每个管道的结果和元数据,使得单独监控和调试每个管道运行变得极其容易。

图 3.6:使用 Maxime 的配置文件运行数据收集管道后,crawled_links 输出工件的示例
现在,我们可以通过运行以下代码在任何地方下载crawled_links工件,其中工件ID可以在 ZenML 中找到,并且对于每个工件版本都是唯一的:
from zenml.client import Client
artifact = Client().get_artifact_version('8349ce09-0693-4e28-8fa2-20f82c76ddec')
loaded_artifact = artifact.load()
例如,我们可以轻松运行相同的数据收集管道,但使用保罗·尤斯汀的 YAML 配置,如下所示:
parameters:
user_full_name: Paul Iusztin # [First Name(s)] [Last Name]
links:
# Medium
- https://medium.com/decodingml/an-end-to-end-framework-for-production-ready-llm-systems-by-building-your-llm-twin-2cc6bb01141f
- https://medium.com/decodingml/a-real-time-retrieval-system-for-rag-on-social-media-data-9cc01d50a2a0
- https://medium.com/decodingml/sota-python-streaming-pipelines-for-fine-tuning-llms-and-rag-in-real-time-82eb07795b87
… # More Medium links
# Substack
- https://decodingml.substack.com/p/real-time-feature-pipelines-with?r=1ttoeh
- https://decodingml.substack.com/p/building-ml-systems-the-right-way?r=1ttoeh
- https://decodingml.substack.com/p/reduce-your-pytorchs-code-latency?r=1ttoeh
… # More Substack links
要使用保罗的配置运行管道,我们调用以下poe命令:
poetry poe run-digital-data-etl-paul
在幕后,这调用以下 CLI 命令,该命令引用保罗的配置文件:
poetry run python -m tools.run --run-etl --no-cache --etl-config-filename digital_data_etl_paul_iusztin.yaml
您可以在configs/目录下的存储库中找到所有配置。此外,使用poe,我们配置了一个命令,用于调用所有受支持作者的 数据收集管道:
poetry poe run-digital-data-etl
我们可以轻松地使用我们的 ODM 类查询 MongoDB 数据仓库。例如,让我们查询为保罗·尤斯汀收集的所有文章:
from llm_engineering.domain.documents import ArticleDocument, UserDocument
user = UserDocument.get_or_create(first_name="Paul", last_name="Iusztin")
articles = ArticleDocument.bulk_find(author_id=str(user.id))
print(f"User ID: {user.id}")
print(f"User name: {user.first_name} {user.last_name}")
print(f"Number of articles: {len(articles)}")
print("First article link:", articles[0].link)
上述代码的输出是:
User ID: 900fec95-d621-4315-84c6-52e5229e0b96
User name: Paul Iusztin
Number of articles: 50
First article link: https://medium.com/decodingml/an-end-to-end-framework-for-production-ready-llm-systems-by-building-your-llm-twin-2cc6bb01141f
只需两行代码,我们就可以使用项目内定义的任何 ODM 查询和过滤我们的 MongoDB 数据仓库。
此外,为确保您的数据收集管道按预期工作,您可以使用您的IDE 的 MongoDB 插件搜索您的 MongoDB 集合,您必须单独安装此插件。例如,您可以使用此插件为 VSCode:www.mongodb.com/products/tools/vs-code。对于其他 IDE,您可以使用类似的插件或外部 NoSQL 可视化工具。连接到 MongoDB 可视化工具后,您可以使用以下 URI 连接到我们的本地数据库:mongodb://llm_engineering:llm_engineering@127.0.0.1:27017。对于云 MongoDB 集群,您必须更改 URI,我们将在第十一章中探讨。
就这样,您已经学会了如何使用不同的 ZenML 配置运行数据收集管道,以及如何可视化每次运行的输出工件。我们还探讨了如何查询特定数据类别和作者的数据库仓库。因此,我们已经完成了数据工程章节,可以进入结论部分。
故障排除
存储在 MongoDB 数据库中的原始数据是所有后续步骤的核心。因此,如果您由于爬虫问题未能成功运行本章的代码,本节提供了修复潜在问题的解决方案,以便您继续前进。
Selenium 问题
运行 Selenium 可能会由于浏览器驱动程序(如 ChromeDriver)的问题而导致问题,这是一个众所周知的问题。因此,如果使用 Selenium 的爬虫(如 MediumCrawler)由于您的 ChromeDriver 问题而失败,您可以通过注释掉添加到数据收集 YAML 配置中的 Medium 链接来轻松绕过这个问题。为此,请转到 configs/ 目录,并找到所有以 digital_data_etl_* 开头的 YAML 文件,例如 digital_data_etl_maxime_labonne.yaml。打开它们,并注释掉所有与 Medium 相关的 URL,如图 3.7 所示。您可以省略 Substack 或个人博客 URL,因为这些使用的是 CustomArticleCrawler,它不依赖于 Selenium。

图 3.7:在爬取原始数据时修复 Selenium 问题
导入我们的备份数据
如果什么方法都不奏效,你可以尝试将备份的数据填充到 MongoDB 数据库中,这些数据存储在 data/data_warehouse_raw_data 目录下。这将允许你在不运行数据收集 ETL 代码的情况下,继续进行微调和推理部分。要导入此目录中的所有数据,请运行:
poetry poe run-import-data-warehouse-from-json
运行上述 CLI 命令后,你将拥有我们在开发代码时使用的数据集的一对一副本。为确保导入成功完成,你的 MongoDB 数据库中应有 88 篇文章和 3 个用户。
摘要
在本章中,我们学习了如何为 LLM Twin 用例设计和构建数据收集管道。我们收集了自定义数据来模拟现实世界的情况,而不是依赖于静态数据集,为我们在构建 AI 系统中面临的现实世界挑战做好准备。
首先,我们检查了 LLM Twin 数据收集管道的架构,该架构作为一个 ETL 流程运行。接下来,我们开始深入研究管道实现。我们首先了解如何使用 ZenML 来编排管道。然后,我们研究了爬虫实现。我们学习了三种爬取数据的方法:使用子进程中的 CLI 命令或使用 LangChain 或 Selenium 的实用函数来构建自定义逻辑,该逻辑可以编程方式操作浏览器。最后,我们研究了如何构建我们自己的 ODM 类,我们使用它来定义我们的文档类层次结构,其中包含文章、帖子、存储库等实体。
在本章末尾,我们学习了如何使用不同的 YAML 配置文件运行 ZenML 管道,并在仪表板中探索结果。我们还看到了如何通过 ODM 类与 MongoDB 数据仓库进行交互。
在下一章中,我们将介绍 RAG 特征管道的关键步骤,包括分块和嵌入文档,将这些文档摄入到向量数据库中,并应用预检索优化以提高性能。我们还将使用 Pulumi 以编程方式设置必要的基础设施,并通过部署 RAG 摄入管道到 AWS 来结束本章。
参考文献
-
Breuss, M. (2023, July 26). Beautiful Soup:使用 Python 构建 Web 爬虫.
realpython.com/beautiful-soup-web-scraper-python/ -
David, D. (2024, July 8). 2024 年使用 Selenium 进行网络爬取指南. Bright Data.
brightdata.com/blog/how-tos/using-selenium-for-web-scraping -
Hjelle, G. A. (2023, October 21). Python 3.12 预览:静态类型改进.
realpython.com/python312-typing/ -
ORM 快速入门 — SQLAlchemy 2.0 文档. (n.d.).
docs.sqlalchemy.org/en/20/orm/quickstart.html -
Ramos, L. P. (2023, August 4). Python 和 MongoDB:连接到 NoSQL 数据库.
realpython.com/introduction-to-mongodb-and-python/ -
Refactoring.Guru. (2024, January 1). Builder.
refactoring.guru/design-patterns/builder -
什么是 ETL?完整指南. (n.d.). Qlik.
www.qlik.com/us/etl
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第四章:RAG 功能管道
检索增强生成(RAG)在大多数生成式 AI 应用中是基本的。RAG 的核心责任是将自定义数据注入大型语言模型(LLM)以执行给定的操作(例如,总结、重新表述和提取注入的数据)。您通常希望将 LLM 应用于它未训练过的数据(例如,私有或新数据)。由于微调 LLM 是一项高度昂贵的操作,因此 RAG 是一种有吸引力的策略,可以绕过访问新数据时需要不断微调的需求。
我们将从这个章节的理论部分开始,重点关注 RAG 的基本原理及其工作方式。然后,我们将向您介绍一个天真 RAG 系统的所有组件:分块、嵌入和向量数据库。最终,我们将展示用于高级 RAG 系统的各种优化。然后,我们将继续探索 LLM 双胞胎的 RAG 功能管道架构。在这一步,我们将应用我们在章节开头讨论的所有理论方面。最后,我们将通过实现基于全书所述的系统设计的 LLM 双胞胎的 RAG 功能管道来通过一个实际例子。
本章的主要部分包括:
-
理解 RAG
-
高级 RAG 概述
-
探索 LLM 双胞胎的 RAG 功能管道架构
-
实现 LLM 双胞胎的 RAG 功能管道
到本章结束时,您将清楚地全面了解 RAG 是什么以及它是如何应用于我们的 LLM 双胞胎用例的。
理解 RAG
RAG 通过从外部来源获取的信息增强了生成式 AI 模型的准确性和可靠性。它是一种与 LLM 内部知识互补的技术。在深入细节之前,让我们了解 RAG 代表什么:
-
检索:搜索相关数据
-
增强:将数据作为上下文添加到提示中
-
生成:使用增强的提示和 LLM 进行生成
任何大型语言模型(LLM)都必然理解其训练的数据,这有时被称为参数化知识。因此,即使 LLM 可以完美地回答过去发生的事情,它也无法访问最新数据或任何其他它未训练过的外部来源。
让我们以 OpenAI 最强大的模型为例,2024 年夏季是 GPT-4o。该模型在截至 2023 年 10 月的数据上进行了训练。因此,如果我们询问 2020 年大流行期间发生了什么,它可以完美回答,因为它具有参数化知识。然而,如果我们询问 2024 年欧洲足球锦标赛的结果,由于其有限的参数化知识,它将不知道答案。另一个场景是,它将自信地产生幻觉并提供错误的答案。
RAG 克服了 LLM 的这两个限制。它提供了访问外部或最新数据的能力,并防止了幻觉,增强了生成式 AI 模型的准确性和可靠性。
为什么使用 RAG?
我们之前简要解释了在生成式 AI 应用中使用 RAG 的重要性。现在,我们将深入探讨“为什么”,之后我们将关注一个简单的 RAG 框架是什么样的。
目前,为了对 RAG 有一个直观的了解,你必须知道在使用 RAG 时,我们将必要的信息注入提示中,以回答初始用户的问题。之后,我们将增强后的提示传递给 LLM 以获得最终答案。现在,LLM 将使用额外的上下文来回答用户的问题。
RAG 解决了两个基本问题:
-
幻觉
-
过时或私有信息
幻觉
如果一个没有 RAG 的聊天机器人被问及它没有训练过的某个问题,它有很大可能会给出一个关于不真实事物的自信回答。以 2024 年欧洲足球锦标赛为例。如果模型训练到 2023 年 10 月,我们问它关于锦标赛的问题,它很可能会给出一个难以区分现实与真相的随机答案。即使 LLM 并不总是产生幻觉,这也引发了对其答案可信度的担忧。因此,我们必须问自己:“我们何时可以信任 LLM 的答案?”以及“我们如何评估答案是否正确?”。
通过引入 RAG,我们强制 LLM 始终仅基于引入的上下文来回答。LLM 将作为推理引擎,而通过 RAG 添加的额外信息将作为生成答案的唯一真实来源。通过这样做,我们可以快速评估 LLM 的答案是否基于外部数据。
过时信息
任何 LLM 都是在总世界知识数据集的一个子集上训练或微调的。这主要归因于三个主要问题:
-
私有数据:你不能在你不拥有或没有使用权的资料上训练你的模型。
-
新数据:每秒都会生成新的数据。因此,你必须不断地训练你的 LLM 以保持同步。
-
成本:训练或微调 LLM 是一项极其昂贵的操作。因此,按小时或按天进行是不可行的。
RAG 解决了这些问题,因为你不再需要不断地在新数据(甚至私有数据)上微调你的 LLM。直接将必要的数据注入到 LLM 接收的提示中,就足以生成正确且有价值的信息。
总结来说,RAG 对于构建强大和灵活的生成式 AI 系统至关重要。但我们是怎样根据用户的问题将正确的数据注入提示中的呢?我们将在下一节深入探讨 RAG 的技术细节。
基础 RAG 框架
每个 RAG 系统在其根源上都是相似的。我们首先将专注于理解 RAG 在其最简单形式下的情况。随后,我们将逐步介绍更多高级的 RAG 技术来提高系统的准确性。请注意,我们将交替使用 vanilla 和 naive RAG 以避免重复。
一个 RAG 系统由三个相互独立的主要模块组成:
-
摄取管道:用于填充向量数据库的批量或流式管道
-
检索管道:一个查询向量数据库并检索与用户输入相关的条目的模块
-
生成管道:使用检索到的数据来增强提示并使用 LLM 生成答案的层
由于这三个组件都是各自独立的类或服务,我们将分别深入探讨。但就目前而言,让我们尝试回答“这三个模块是如何连接的?”这个问题。以下是一个非常简化的概述:
-
在后端,摄取管道按照计划或持续运行,以将外部数据填充到向量数据库中。
-
在客户端,用户提出问题。
-
问题被传递到检索模块,该模块预处理用户的输入并查询向量数据库。
-
生成管道使用提示模板、用户输入和检索到的上下文来创建提示。
-
提示信息被传递给大型语言模型(LLM)以生成答案。
-
答案被展示给用户。
图 4.1:纯 RAG 架构
当您需要访问任何类型的外部信息时,必须在您的生成式 AI 应用程序中实现 RAG。例如,在实现财务助手时,您很可能需要在提供有价值的答案之前访问最新的新闻、报告和价格。或者,如果您构建一个旅行推荐系统,您必须检索并解析潜在景点、餐厅和活动的列表。在训练时间,LLM 无法访问您的特定数据,因此您通常必须在您的生成式 AI 项目中实现 RAG 策略。现在,让我们深入探讨摄取、检索和生成管道。
摄取管道
RAG 摄取管道从各种数据源(例如数据仓库、数据湖、网页等)提取原始文档。然后,它进行清洗、分块(分割成更小的部分)和嵌入文档。最终,它将嵌入的分块加载到向量数据库(或其他类似的向量存储)中。
因此,RAG 摄取管道被拆分为以下部分:
-
数据提取模块从各种来源(如数据库、API 或网页)收集所有必要的数据。此模块高度依赖于您的数据。它可以像查询您的数据仓库那样简单,也可以像爬取维基百科那样复杂。
-
清洗层将标准化并从提取的数据中移除不需要的字符。例如,您必须从输入文本中移除所有无效字符,如非 ASCII 字符、粗体和斜体字符。另一种流行的清洗策略是将 URL 替换为占位符。然而,您的清洗策略将根据您的数据源和嵌入模型而有所不同。
-
分块模块将清洗后的文档分割成更小的部分。由于我们希望将文档内容传递给嵌入模型,这是必要的,以确保它不超过模型的输入最大大小。此外,分块是必要的,以分离语义相关的特定区域。例如,当分块一本书的章节时,最理想的方式是将相似的段落分组到同一个部分或分块中。通过这样做,在检索时,你将只添加必要的数据到提示中。
-
嵌入组件使用嵌入模型将分块的内容(文本、图像、音频等)投影到一个密集的向量中,该向量包含语义值——更多关于嵌入的内容在下面的什么是嵌入?部分。
-
加载模块接收嵌入的分块以及元数据文档。元数据将包含诸如嵌入内容、分块来源的 URL 以及内容在网络上发布的时间等关键信息。嵌入用作索引以查询相似的块,而元数据用于访问添加到增强提示的信息。
到目前为止,我们有一个 RAG 摄取管道,它以原始文档为输入,处理它们,并填充向量数据库。下一步是正确地从向量存储中检索相关数据。
检索管道
检索组件接收用户的输入(文本、图像、音频等),将其嵌入,并查询向量数据库以找到与用户输入相似的向量。
检索步骤的主要功能是将用户的输入投影到与向量数据库中用作索引的嵌入相同的向量空间。这使得我们能够通过比较向量存储中的嵌入与用户的输入向量来找到最相似的 K 个条目。然后,这些条目作为内容来增强传递给 LLM 以生成答案的提示。
你必须使用距离度量来比较两个向量,例如欧几里得距离或曼哈顿距离。但最流行的是余弦距离,它等于两个向量之间角度的余弦值的 1 减去,如下所示:

它的范围从-1到1,当向量A和B方向相反时值为-1,如果它们是正交的,则值为0,如果它们指向同一方向,则值为1。
大多数情况下,余弦距离在非线性复杂向量空间中表现良好。然而,重要的是要注意,选择两个向量之间的适当距离取决于你的数据和所使用的嵌入模型。
一个需要强调的关键因素是用户的输入和嵌入必须在同一个向量空间中。否则,您无法计算它们之间的距离。要做到这一点,您必须以与 RAG 摄取管道中处理原始文档相同的方式预处理用户输入。这意味着您必须使用相同的函数、模型和超参数来清理、分块(如果需要)和嵌入用户的输入。这类似于您必须在训练和推理之间以相同的方式将数据预处理为特征;否则,推理将产生不准确的结果——这种现象也称为训练-服务偏差。
生成管道
RAG 系统的最后一步是获取用户的输入,检索数据,将其传递给 LLM,并生成一个有价值的答案。
最终的提示结果来自一个系统和提示模板,其中包含用户的查询和检索到的上下文。您可能有一个提示模板或多个提示模板,具体取决于您的应用。通常,所有的提示工程都是在提示模板级别完成的。
下面,您可以看到一个通用系统和提示模板的示例,以及它们如何与检索逻辑和 LLM 一起使用以生成最终答案:
system_template = """
You are a helpful assistant who answers all the user's questions politely.
"""
prompt_template = """
Answer the user's question using only the provided context. If you cannot answer using the context, respond with "I don't know."
Context: {context}
User question: {user_question}
"""
user_question = "<your_question>"
retrieved_context = retrieve(user_question)
prompt = f"{system_template}\n"
prompt += prompt_template.format(context=retrieved_context, user_question=user_question)
answer = llm(prompt)
随着提示模板的演变,每个变更都应该使用机器学习操作(MLOps)最佳实践进行跟踪和版本控制。因此,在训练或推理时间,您始终知道给定的答案是由特定的 LLM 版本和提示模板生成的。您可以通过 Git 完成此操作,将提示模板存储在数据库中,或使用诸如 LangFuse 之类的特定提示管理工具。
正如我们在检索管道中看到的,一些直接影响您 RAG 系统准确性的关键方面是外部数据的嵌入,通常存储在向量数据库中,用户的查询嵌入,以及我们如何使用诸如余弦距离之类的函数在两者之间找到相似性。为了更好地理解 RAG 算法的这一部分,让我们深入探讨嵌入是什么以及它们是如何计算的。
嵌入是什么?
想象一下,你正在尝试教一台计算机理解世界。嵌入就像一个特定的翻译器,将这些事物转换成数值代码。然而,这个代码并非随机,因为相似的单词或项目最终会得到彼此接近的代码。这就像一张地图,其中具有相似意义的单词聚集在一起。
考虑到这一点,一个更理论化的定义是,嵌入是对象在连续向量空间中编码为向量的密集数值表示,例如单词、图像或推荐系统中的项目。这种转换有助于捕捉对象之间的语义意义和关系。例如,在自然语言处理(NLP)中,嵌入将单词转换为向量,其中语义相似的单词在向量空间中位置靠近。

图 4.2:嵌入是什么?
一种流行的方法是可视化嵌入以理解和评估它们的几何关系。由于嵌入通常具有超过 2 或 3 个维度,通常在 64 到 2048 之间,你必须将它们再次投影到 2D 或 3D。
例如,你可以使用 UMAP(umap-learn.readthedocs.io/en/latest/index.html),这是一种在将嵌入投影到 2D 或 3D 时,能够很好地保持点之间几何属性的降维方法。在可视化向量时,另一个流行的降维算法是 t-SNE(scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html)。然而,与 UMAP 相比,t-SNE 更加随机,并且不保留点之间的拓扑关系。
降维算法,如 PCA、UMAP 和 t-SNE,是一种数学技术,用于在保留数据的基本模式、结构和关系的同时,减少数据集中的输入变量或特征数量。目标是把高维数据转换成低维形式,使其更容易可视化、解释和处理,同时最大限度地减少重要信息的损失。这些方法有助于解决“维度诅咒”,提高计算效率,并通常增强机器学习算法的性能。

图 4.3:使用 UMAP 可视化嵌入(来源:UMAP 文档)
为什么嵌入如此强大
首先,机器学习模型只处理数值。当处理表格数据时,这并不是问题,因为数据通常以数值形式存在或可以轻松转换为数值。当我们想要将单词、图像或音频数据输入模型时,嵌入就派上用场了。
例如,当使用转换器模型时,你需要对所有的文本输入进行分词,每个标记都与一个嵌入相关联。这个过程的美妙之处在于其简单性;转换器的输入是一个嵌入序列,这可以被神经网络密集层轻松且自信地解释。
基于这个例子,你可以使用嵌入来编码任何分类变量并将其输入到机器学习模型中。但为什么不使用其他简单的方法,例如独热编码呢?当处理具有高基数(例如语言词汇)的分类变量时,使用其他经典方法会遇到维度灾难。例如,如果你的词汇有 10,000 个标记,那么在应用独热编码后,只有一个标记的长度为 10,000。如果输入序列有 N 个标记,那么这将变成 N * 10,000 个输入参数。如果 N >= 100,通常在输入文本时,输入数据太大而无法使用。其他经典方法(如哈希)不遭受维度灾难的问题,但你可能会丢失向量之间的语义关系。
独热编码是一种将分类变量转换为二进制矩阵表示的技术。每个类别都表示为一个唯一的二进制向量。对于每个分类变量,创建一个长度等于唯一类别数量的二进制向量,其中所有值都是零,除了对应特定类别的索引,该索引被设置为 1。该方法保留了关于类别的所有信息。它简单且可解释。然而,一个显著的缺点是,如果分类变量有大量唯一值,它可能导致高维特征空间,使得该方法不实用。
特征哈希,也称为哈希编码或“哈希技巧”,是一种通过将类别值应用哈希函数来将分类变量转换为数值特征的技术。与独热编码相比,该方法不受唯一类别数量的限制,但它通过将类别映射到固定数量的桶或桶中,减少了特征空间的维度。因此,它减少了特征空间的维度,这在处理高基数分类变量时特别有用。这使得它在内存使用和计算时间方面效率很高。然而,存在碰撞的风险,即不同的类别可能映射到同一个桶,导致信息丢失。这种映射使得该方法不可解释。此外,很难理解原始类别与哈希特征之间的关系。
嵌入帮助我们编码分类变量,同时控制输出向量的维度。它们还使用巧妙的方法将信息压缩到比原始哈希技巧低得多的维度空间中。
其次,嵌入输入减少了其维度的大小,并将所有语义意义压缩到一个密集的向量中。这在处理图像时是一个非常流行的技术,其中 CNN 编码模块将高维意义映射到嵌入中,随后由 CNN 解码器处理分类或回归步骤。
下图展示了一个典型的 CNN 布局。想象一下每一层中的小方块。这些是“感受野”。每个方块将信息传递给前一层的一个神经元。随着你通过网络移动,有两个关键的事情正在发生:
-
缩小图片:特殊的“子采样”操作使层变得更小,专注于关键细节。
-
学习特征:另一方面,“卷积”操作实际上随着网络从图像中学习更复杂的特征而增加层的大小。
最后,一个完全连接的层在最后将所有这些处理过的信息转换成最终的向量嵌入,这是一个数值图像表示。
图 4.4:使用 CNN 从图像中创建嵌入(图片来源)
前面的图片来源于维基共享资源(commons.wikimedia.org/wiki/File:Typical_cnn.png),并授权于 Creative Commons Attribution-ShareAlike 4.0 国际许可协议(CC BY-SA 4.0:creativecommons.org/licenses/by-sa/4.0/deed.en)。
嵌入是如何创建的?
嵌入是由理解输入的上下文和语义的深度学习模型创建的,并将它投影到一个连续的向量空间中。
可以使用各种深度学习模型来创建嵌入,具体取决于数据输入类型。因此,在挑选嵌入模型之前,理解你的数据以及你需要从数据中获得什么是非常基本的。
例如,当处理文本数据时,用于创建词汇嵌入的早期方法之一是 Word2Vec 和 GloVe。这些方法至今仍然是用于简单应用中流行的方法。
另一种流行的方法是使用仅编码器的 transformer,如 BERT,或其家族中的其他方法,如 RoBERTa。这些模型利用 transformer 架构的编码器,将你的输入智能地投影到一个密集的向量空间中,该空间可以稍后用作嵌入。
要快速在 Python 中计算嵌入,你可以方便地利用 Sentence Transformers Python 包(也可在 Hugging Face 的 transformer 包中找到)。这个工具提供了一个用户友好的界面,使得嵌入过程变得简单高效。
在下面的代码片段中,你可以看到我们如何从 SentenceTransformer 加载一个模型,计算三个句子的嵌入,并最终计算它们之间的余弦相似度。一个句子与自身的相似度总是 1。同样,第一句和第二句之间的相似度大约为 0,因为这两个句子没有共同点。相比之下,第一句和第三句之间的值更高,因为它们有一些重叠的上下文:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
sentences = [
"The dog sits outside waiting for a treat.",
"I am going swimming.",
"The dog is swimming."
]
embeddings = model.encode(sentences)
print(embeddings.shape)
# Output: [3, 384]
similarities = model.similarity(embeddings, embeddings)
print(similarities)
# Output:
# tensor([[ 1.0000, -0.0389, 0.2692],
# [-0.0389, 1.0000, 0.3837],
# [ 0.2692, 0.3837, 1.0000]])
#
# similarities[0, 0] = The similarity between the first sentence and itself.
# similarities[0, 1] = The similarity between the first and second sentence.
# similarities[2, 1] = The similarity between the third and second sentence.
https://github.com/PacktPublishing/LLM-Engineering/blob/main/code_snippets/08_text_embeddings.py.
嵌入部分中的示例可以在本书使用的虚拟环境中运行,因为它包含了所有必需的依赖项。
最佳嵌入模型可能会随着时间和你的特定用例而变化。你可以在 Hugging Face 上的大规模文本嵌入基准(MTEB)中找到特定模型。根据你的需求,你可以考虑性能最佳模型、准确度最高的模型或内存占用最小的模型。这个决定完全基于你的要求(例如,准确度和硬件)。然而,Hugging Face 和 SentenceTransformer 使得在不同模型之间切换变得简单。因此,你可以始终尝试不同的选项。
当处理图像时,你可以使用卷积神经网络(CNNs)来嵌入它们。基于 ResNet 架构的 CNN 网络很受欢迎。然而,我们无法直接使用图像嵌入技术来处理音频记录。相反,我们可以创建音频的视觉表示,例如频谱图,然后应用图像嵌入模型到这些视觉上。这使我们能够以计算机可以理解的方式捕捉图像和声音的本质。
通过利用 CLIP 等模型,你可以实际上将一段文本和一张图片嵌入到同一个向量空间中。这允许你使用句子作为输入来找到相似图片,或者反过来,展示了 CLIP 的实用性。
在下面的代码片段中,我们使用 CLIP 对一张疯狂猫的图片和三个句子进行编码。最终,我们使用余弦相似度来计算图片和句子之间的相似度:
from io import BytesIO
import requests
from PIL import Image
from sentence_transformers import SentenceTransformer
response = requests.get(
"https://github.com/PacktPublishing/LLM-Engineering/blob/main/images/crazy_cat.jpg?raw=true"
)
image = Image.open(BytesIO(response.content))
model = SentenceTransformer("clip-ViT-B-32")
img_emb = model.encode(image)
text_emb = model.encode(
["A crazy cat smiling.",
"A white and brown cat with a yellow bandana.",
"A man eating in the garden."]
)
print(text_emb.shape) # noqa
# Output: (3, 512)
similarity_scores = model.similarity(img_emb, text_emb)
print(similarity_scores) # noqa
# Output: tensor([[0.3068, 0.3300, 0.1719]])
源代码可以在github.com/PacktPublishing/LLM-Engineering/blob/main/code_snippets/08_text_image_embeddings.py找到。
在这里,我们简要介绍了如何计算嵌入。具体实现的领域非常广泛,但重要的是要知道,大多数数字数据类别都可以计算嵌入,例如单词、句子、文档、图像、视频和图。
理解这一点至关重要:当你需要计算两个不同数据类别之间的距离时,例如句子向量和图像向量之间的距离,你必须使用专门的模型。这些模型旨在将两种数据类型投影到同一个向量空间,例如 CLIP,以确保准确的距离计算。
嵌入的应用
由于使用 RAG 的生成式 AI 革命,嵌入在信息检索任务中变得极其流行,例如文本、代码、图像和音频的语义搜索以及代理的长时记忆。但在生成式 AI 之前,嵌入已经在以下方面被大量使用:
-
表示传递给 ML 模型的分类变量(例如,词汇标记)
-
通过编码用户和项目并找到它们之间的关系来构建推荐系统
-
聚类和异常值检测
-
使用 UMAP 等算法进行数据可视化
-
使用嵌入作为特征进行分类
-
通过比较每个类的嵌入并选择最相似的一个进行零样本分类
完全理解 RAG(检索增强生成)工作原理的最后一步是检查向量数据库以及它们如何利用嵌入来检索数据。
更多关于向量数据库的信息
向量数据库是专门设计的数据库,用于高效地存储、索引和检索向量嵌入。传统的基于标量的数据库在处理向量数据的复杂性方面存在困难,这使得向量数据库对于实时语义搜索等任务至关重要。
虽然像 FAISS 这样的独立向量索引对于相似度搜索是有效的,但它们缺乏向量数据库全面的数据管理功能。向量数据库支持 CRUD 操作、元数据过滤、可伸缩性、实时更新、备份、生态系统集成和强大的数据安全,这使得它们比独立索引更适合生产环境。
向量数据库是如何工作的?
想象一下你通常是如何搜索数据库的。你输入一些特定的内容,系统就会输出精确匹配的结果。这就是传统数据库的工作方式。向量数据库则不同。我们寻找的是查询向量的最近邻,而不是完美匹配。在底层,向量数据库使用近似最近邻(ANN)算法来找到这些邻近的邻居。
虽然 ANN 算法不会为给定搜索返回顶部匹配项,但标准最近邻算法在实践中的速度太慢。此外,经验表明,仅使用给定输入查询的顶部匹配项的近似值就足够好了。因此,准确性和延迟之间的权衡最终有利于 ANN 算法。
这是一个向量数据库的典型工作流程:
-
索引向量:向量使用针对高维数据优化的数据结构进行索引。常见的索引技术包括层次可导航小世界(HNSW)、随机投影、产品量化(PQ)和局部敏感哈希(LSH)。
-
查询相似度:在搜索过程中,数据库查询索引向量以找到与输入向量最相似的向量。这个过程涉及根据相似度度量(如余弦相似度、欧几里得距离或点积)比较向量。每种方法都有其独特的优势,适用于不同的用例。
-
后处理结果:在识别出潜在匹配项后,结果将进行后处理以精炼准确性。这一步骤确保返回给用户的是最相关的向量。
向量数据库可以在向量搜索之前或之后根据元数据过滤结果。这两种方法在性能和准确性方面都有权衡。查询还依赖于元数据(以及向量索引),因此它包含一个用于过滤操作的元数据索引用户。
创建向量索引的算法
向量数据库使用各种算法来创建向量索引并有效地管理搜索数据:
-
随机投影:随机投影通过使用随机矩阵将向量投影到低维空间来降低向量的维度。这项技术保留了向量之间的相对距离,从而促进了更快的搜索。
-
PQ:PQ 通过将向量分成更小的子向量,然后将这些子向量量化为代表性代码来压缩向量。这减少了内存使用并加快了相似性搜索。
-
LSH:LSH 将相似的向量映射到桶中。这种方法通过关注数据的一个子集,实现了快速近似最近邻搜索,从而降低了计算复杂度。
-
HNSW:HNSW 构建了一个多层图,其中每个节点代表一组向量。相似节点相连,允许算法在图中导航并有效地找到最近邻。
这些算法使向量数据库能够高效地处理复杂和大规模数据,使它们成为各种人工智能和机器学习应用的完美选择。
数据库操作
向量数据库也具有与标准数据库的共同特征,以确保在生产环境中具有高性能、容错性和易于管理。关键操作包括:
-
分片和复制:数据在多个节点之间分区(分片),以确保可扩展性和高可用性。节点间的数据复制有助于在节点故障的情况下保持数据完整性和可用性。
-
监控:持续监控数据库性能,包括查询延迟和资源使用情况(RAM、CPU、磁盘),有助于保持最佳运行状态并在问题影响系统之前发现潜在问题。
-
访问控制:实施强大的访问控制机制确保只有授权用户才能访问和修改数据。这包括基于角色的访问控制和其它安全协议,以保护敏感信息。
-
备份:定期的数据库备份对于灾难恢复至关重要。自动备份过程确保在数据损坏或丢失的情况下,数据可以恢复到之前的状态。
高级 RAG 概述
我们刚才提出的标准 RAG 框架没有解决影响检索和答案生成质量的一些基本方面,例如:
-
检索的文档与用户的问题相关吗?
-
检索到的上下文是否足够回答用户的问题?
-
是否存在任何冗余信息,这些信息只会增加增强提示的噪声?
-
检索步骤的延迟是否符合我们的要求?
-
如果我们无法使用检索到的信息生成有效的答案,我们该怎么办?
从上述问题中,我们可以得出两个结论。第一个结论是我们需要一个强大的评估模块来评估我们的 RAG 系统,它可以量化并衡量检索数据的质量,并针对用户的问题生成答案。我们将在第九章中更详细地讨论这个话题。第二个结论是我们必须改进我们的 RAG 框架,以直接在算法中解决检索限制。这些改进被称为高级 RAG。
原始的 RAG 设计可以在三个不同的阶段进行优化:
-
预检索:这个阶段侧重于如何对数据进行结构和预处理,以优化数据索引和查询优化。
-
检索:这个阶段主要围绕改进嵌入模型和元数据过滤,以提高向量搜索步骤。
-
后检索:这个阶段主要针对从检索到的文档中过滤噪声和压缩提示的不同方法,在将其馈送到 LLM 进行答案生成之前。

图 4.5:高级 RAG 的三个阶段
本节的目的不是列出所有可用的先进 RAG 方法,而是建立对可以优化内容的直觉。我们将仅使用基于文本数据的示例,但无论数据类别如何,高级 RAG 的原则都是相同的。现在,让我们深入探讨所有三个组成部分。
预检索
预检索步骤以两种不同的方式进行:
-
数据索引:它是 RAG 摄取管道的一部分。它主要在清理或分块模块中实现,以预处理数据以更好地索引。
-
查询优化:算法在嵌入和从向量数据库检索块之前直接在用户的查询上执行。
由于我们使用语义上表示分块文档内容的嵌入来索引我们的数据,大多数数据索引技术都集中在更好的数据预处理和结构化上,以提高检索效率,例如:
-
滑动窗口:滑动窗口技术引入了文本块之间的重叠,确保了重要上下文在块边界附近被保留,这提高了检索的准确性。这在法律文件、科学论文、客户支持日志和医疗记录等领域特别有益,在这些领域,关键信息经常跨越多个部分。嵌入计算在块及其重叠部分上。因此,滑动窗口通过保持上下文跨边界来提高系统检索相关和连贯信息的能力。
-
增强数据粒度:这涉及数据清理技术,如删除无关细节、验证事实准确性以及更新过时信息。一个干净且准确的数据集允许更精确的检索。
-
元数据: 添加日期、URL、外部 ID 或章节标记等元数据标签有助于在检索过程中有效地过滤结果。
-
优化索引结构: 它基于不同的数据索引方法,如各种块大小和多索引策略。
-
从小到大: 该算法将用于检索的块和用于最终答案生成的提示中的上下文解耦。算法使用一小段文本来计算嵌入,同时保留该序列本身及其周围的元数据更宽的窗口。因此,使用较小的块可以提高检索的准确性,而较大的上下文则为 LLM 添加更多上下文信息。
这种直觉在于,如果我们使用整个文本来计算嵌入,可能会引入过多的噪声,或者文本可能包含多个主题,这会导致嵌入的整体语义表示不佳。

图 4.6:查询路由
在查询优化方面,我们可以利用查询路由、查询重写和查询扩展等技术来进一步细化 LLM 检索的信息:
- 查询路由: 根据用户输入,我们可能需要与不同类别的数据交互,并对每个类别进行不同的查询。查询路由用于根据用户输入决定采取什么行动,类似于 if/else 语句。然而,决策完全使用自然语言而不是逻辑语句。
如图 4.6 所示,假设根据用户输入,进行 RAG 操作时,我们可以使用向量搜索查询从向量数据库中检索额外的上下文,通过将用户查询转换为 SQL 命令使用标准 SQL 数据库,或通过利用 REST API 调用从互联网上检索。查询路由器还可以检测是否需要上下文,帮助我们避免对外部数据存储进行冗余调用。此外,查询路由器可以用于为给定输入选择最佳的提示模板。例如,在 LLM Twin 用例中,根据用户是否想要文章段落、帖子或代码片段,需要不同的提示模板来优化创建过程。路由通常使用 LLM 来决定采取哪种路线或通过选择具有最相似向量的路径来选择嵌入。总之,查询路由类似于 if/else 语句,但更加灵活,因为它直接与自然语言工作。
-
查询重写: 有时,用户的初始查询可能无法完美地与您的数据结构对齐。查询重写通过重新表述问题以更好地匹配索引信息来解决这个问题。这可能涉及以下技术:
-
释义: 在保留其意义的同时重新表述用户的查询(例如,“气候变化的原因是什么?”可以改写为“导致全球变暖的因素”)。
-
同义词替换:用同义词替换不太常见的词以扩大搜索范围(例如,“愉快”可以改写为“快乐”)。
-
子查询:对于较长的查询,我们可以将其分解为多个更短、更专注的子查询。这有助于检索阶段更精确地识别相关文档。
-
-
假设文档嵌入(HyDE):这种技术涉及让一个 LLM 对查询创建一个假设性响应。然后,将原始查询和 LLM 的响应都输入到检索阶段。
-
查询扩展:这种方法旨在通过添加额外的术语或概念来丰富用户的问题,从而产生相同初始问题的不同视角。例如,当搜索“疾病”时,可以利用与原始查询词相关的同义词和相关术语,并包括“疾病”或“不适”。
-
自查询:核心思想是将非结构化查询映射为结构化查询。一个大型语言模型(LLM)在输入文本中识别关键实体、事件和关系。这些身份被用作过滤参数以减少向量搜索空间(例如,识别查询中的城市,例如“巴黎”,并将其添加到过滤器中以减少向量搜索空间)。
数据索引和查询优化(检索前优化技术)高度依赖于您的数据类型、结构和来源。因此,与任何数据处理管道一样,没有一种方法总是有效,因为每个用例都有其特定的特点和难点。优化您的检索前 RAG 层是实验性的。因此,最重要的是尝试多种方法(如本节中列举的方法),反复试验,并观察哪种方法最有效。
检索
检索步骤可以通过两种基本方式优化:
-
改进 RAG 摄入管道中使用的嵌入模型,以编码分块文档,并在推理时转换用户的输入。
-
利用数据库的过滤和搜索功能。此步骤仅在推理时使用,当您需要根据用户输入检索最相似的块时。
两种策略都与我们的最终目标一致:通过利用查询与索引数据之间的语义相似性来增强向量搜索步骤。
在改进嵌入模型时,通常需要微调预训练的嵌入模型,以使其适应特定领域的术语和细微差别,特别是对于术语不断演变或术语罕见的领域。
与微调嵌入模型不同,您可以使用指导模型(huggingface.co/hkunlp/instructor-xl)通过针对您领域的指令/提示来引导嵌入生成过程。使用此类模型将嵌入网络定制到您的数据中可能是一个好选择,因为微调模型需要更多的计算和人力资源。
在下面的代码片段中,您可以看到一个嵌入关于人工智能文章标题的指导模型示例:
from InstructorEmbedding import INSTRUCTOR
model = INSTRUCTOR("hkunlp/instructor-base")
sentence = "RAG Fundamentals First"
instruction = "Represent the title of an article about AI:"
embeddings = model.encode([[instruction, sentence]])
print(embeddings.shape) # noqa
# Output: (1, 768)
源代码可在github.com/PacktPublishing/LLM-Engineering/blob/main/code_snippets/08_instructor_embeddings.py找到。
要运行指导代码,您必须创建一个不同的虚拟环境并激活它:
python3 -m venv instructor_venv && source instructor_venv/bin/activate
安装所需的 Python 依赖项:
pip install sentence-transformers==2.2.2 InstructorEmbedding==1.0.1
在光谱的另一端,以下是您如何通过利用经典的过滤和搜索数据库功能来提高检索效果的方法:
-
混合搜索:这是一种基于向量和关键词的搜索混合。基于关键词的搜索在识别包含特定关键词的文档方面表现出色。当任务需要精确度时,检索的信息必须包含确切的匹配关键词,混合搜索就非常出色。虽然向量搜索功能强大,但有时在寻找确切的匹配项时可能会遇到困难,但它擅长找到更一般的语义相似性。您通过结合两种方法利用关键词匹配和语义相似性。您有一个参数,通常称为 alpha,它控制两种方法之间的权重。算法有两个独立的搜索,这些搜索随后被归一化和统一。
-
过滤向量搜索:此类搜索利用元数据索引来过滤元数据中的特定关键词。它与混合搜索不同,因为它一次仅使用向量索引检索数据,并在向量搜索之前或之后执行过滤步骤以减少搜索空间。
在实践中,在检索方面,您通常从过滤向量搜索或混合搜索开始,因为它们相对容易实现。这种方法使您可以根据性能调整策略。如果结果不符合预期,您始终可以微调您的嵌入模型。
后检索
后检索优化仅针对检索到的数据进行,以确保 LLM 的性能不会因诸如有限上下文窗口或噪声数据等问题而受到影响。这是因为检索到的上下文有时可能太大或包含不相关信息,这两者都可能分散 LLM 的注意力。
在检索步骤中执行的两个流行方法包括:
-
提示压缩:在保留数据本质的同时消除不必要的细节。
-
重排序:使用交叉编码器机器学习模型为用户输入和每个检索到的片段之间提供匹配分数。检索到的项目根据此分数排序。仅保留前 N 个结果作为最相关的结果。如图 4.7 所示,这是因为重排序模型可以找到用户输入和某些内容之间比简单相似度搜索更复杂的关系。然而,我们无法在初始检索步骤中应用此模型,因为它成本高昂。这就是为什么一种流行的策略是使用嵌入之间的相似度距离检索数据,并使用重排序模型细化检索到的信息,如图 4.8 所示。

图 4.7:双编码器(标准嵌入模型)与交叉编码器
上述技术远非所有潜在解决方案的详尽列表。我们使用它们作为例子,以获得对你在 RAG 工作流程的每个步骤中可以(和应该)优化的内容的直觉。事实是,这些技术可以根据你处理的数据类型而有很大差异。
例如,如果你处理的是多模态数据,如文本和图像,那么之前的大部分技术将无法正常工作,因为它们是为文本设计的。

图 4.8:重排序算法
总结来说,这些优化的主要目标是增强 RAG 算法在三个关键阶段:预检索、检索和后检索。这包括为改进向量索引预处理数据,调整用户查询以进行更精确的搜索,增强嵌入模型,利用经典的过滤数据库操作,以及移除噪声数据。通过牢记这些目标,你可以有效地优化你的 RAG 工作流程以进行数据处理和检索。
探索 LLM Twin 的 RAG 功能管道架构
既然你对 RAG 及其工作原理有了强烈的直觉和深入的理解,我们将继续探索我们特定的 LLM Twin 用例。目标是提供一个端到端的实战示例,以巩固本章中提出的理论。
任何 RAG 系统都分为两个独立的组件:
-
摄取管道接收原始数据,清理、分块、嵌入,并将其加载到向量数据库中。
-
推理管道查询向量数据库以获取相关上下文,并最终通过利用大型语言模型生成答案。
在本章中,我们将专注于实现 RAG 摄取管道,而在第九章中,我们将继续开发推理管道。
考虑到这一点,让我们快速回顾一下我们试图解决的问题以及我们获取原始数据的地方。记住,我们正在构建一个端到端的机器学习系统。因此,所有组件都通过接口(或合约)相互通信,每个管道只有一个职责。在我们的案例中,我们摄取原始文档,预处理它们,并将它们加载到向量数据库中。
我们正在解决的问题
如前一章所述,本书旨在向您展示如何构建一个由端到端机器学习系统支持的、生产就绪的 LLM Twin。在本章中,我们特别想要设计一个 RAG 特征管道,该管道从我们的 MongoDB 数据仓库中提取原始社交媒体数据(例如,文章、代码存储库和帖子)。原始文档的文本将被清理、分块、嵌入,并最终加载到特征存储中。如第一章中所述,我们将使用 ZenML 工件和 Qdrant 向量数据库实现一个逻辑特征存储。
我们希望构建一个完全自动化的特征管道,因此我们需要同步数据仓库和逻辑特征存储。记住,在推理时间,用于生成答案的上下文是从向量数据库中检索的。因此,数据仓库和特征存储之间的同步速度将直接影响我们 RAG 算法的准确性。
另一个关键考虑因素是如何自动化特征管道并将其集成到我们的机器学习系统的其余部分。我们的目标是最大限度地减少两个数据存储之间的任何不同步,因为这可能会损害我们系统的完整性。
总结来说,我们必须设计一个特征管道,它不断地同步数据仓库和逻辑特征存储,同时相应地处理数据。在特征存储中拥有数据对于生产就绪的机器学习系统至关重要。LLM Twin 推理管道将查询它以进行 RAG,而训练管道将从中消费跟踪和版本化的微调数据集。
特征存储
特征存储将是训练和推理管道中所有使用的特征的中央访问点。训练管道将使用特征存储中的清洗数据(存储为工件)来微调 LLMs。推理管道将查询向量数据库以获取用于 RAG 的块状文档。这就是为什么我们设计的是一个特征管道,而不仅仅是 RAG 摄取管道。在实践中,特征管道包含多个子组件,其中之一是 RAG 逻辑。
记住,特征管道主要用于作为思维导图来导航机器学习系统的复杂性。它清楚地表明,它以原始数据作为输入,然后输出特征和可选标签,这些特征和标签存储在特征存储中。因此,一个很好的直觉是考虑所有在数据仓库和特征存储之间的逻辑都进入特征管道命名空间,包括一个或多个子管道。例如,我们将实现另一个管道,它接受清洗数据,将其处理成指令数据集,并将其存储在工件中;这也位于特征管道的伞下,因为工件是逻辑特征存储的一部分。另一个例子是在原始数据或计算特征之上实现数据验证管道。
另一个重要的观察点是,按照标准惯例,存储为字符串的文本数据如果不被视为特征。特征是直接输入到模型中的东西。例如,我们必须对指令数据集或分块文档进行标记化,才能被视为特征。为什么?因为标记直接输入到模型中,而不是作为字符串的句子。不幸的是,这使得系统更加复杂和不灵活。因此,我们将在运行时进行标记化。但这个观察结果很重要,因为它是一个明确的例子,说明你不必对特征/训练/推理(FTI)架构过于僵化。你必须根据自己的用例对其进行调整。
原始数据从哪里来?
作为快速提醒,所有原始文档都存储在 MongoDB 数据仓库中。数据仓库由第三章中介绍的 ETL 数据收集管道填充。ETL 管道爬取各种平台,如 Medium 和 Substack,标准化数据,并将其加载到 MongoDB 中。有关此主题的更多详细信息,请参阅第三章。
设计 RAG 特征管道的架构
最后一步是设计和实施 LLM Twin 应用程序的 RAG 特征管道。我们将使用一个批量设计,该设计计划从 MongoDB 数据仓库中轮询数据,处理它,并将其加载到 Qdrant 向量数据库中。我们首先要问自己的问题是,“为什么是批量管道?”
但在回答这个问题之前,让我们快速了解批量架构是如何工作以及相对于流式设计的表现的。

图 4.9:LLM Twin 的 RAG 特征管道架构
批量管道
数据系统中的批量管道是指一种数据处理方法,其中数据按预定义的间隔和较大体积收集、处理和存储,也称为“批量”。这种方法与实时或流式数据处理不同,后者数据到达时连续处理。这就是批量管道中的情况:
-
数据收集:数据从各种来源收集并存储,直到积累到足够多的量以进行处理。这可以包括来自数据库、日志、文件和其他来源的数据。
-
计划处理:数据处理按固定时间间隔进行,例如每小时或每天。在这段时间内,收集到的数据将批量处理。这可能包括数据清理、转换、聚合和其他操作。
-
数据加载:处理完毕后,数据被加载到目标系统中,例如数据库、数据仓库、数据湖或特征存储。然后,这些处理后的数据可用于分析、查询或进一步处理。
批量管道在处理大量数据且不需要立即处理的情况下特别有用。它们提供了几个优点,包括:
-
效率:批量处理比实时处理更高效地处理大量数据,允许优化资源分配和并行处理。
-
复杂处理:批量管道可以执行可能对实时处理来说过于资源密集的复杂数据转换和聚合。
-
简单性:批量处理系统的架构通常比实时系统简单,这使得它们更容易实现和维护。
批量与流管道对比
当实现特征管道时,你有两个主要的设计选择:批量处理和流处理。因此,了解这两种方法之间的差异以及为什么我们选择批量架构而不是流架构来处理我们的 LLM Twin 用例是值得的。
你可以轻松地写一个关于流管道的专门章节,这表明其复杂性超过批量设计。然而,随着流架构变得越来越流行,人们必须对它们的工作方式有直观的了解,以便为你的应用程序选择最佳选项。
流应用的核心元素是一个分布式事件流平台,如 Apache Kafka 或 Redpanda,用于存储来自多个客户端的事件,以及一个流引擎,如 Apache Flink 或 Bytewax,用于处理这些事件。为了简化你的架构,你可以用队列,如 RabbitMQ,来替换你的事件流平台,以存储事件直到处理。表 4.1根据多个标准比较了批量处理和流处理管道,如处理调度和复杂性:
| 方面 | 批量管道 | 流管道 |
| --- | --- | --- |
| 处理调度 | 定期间隔处理数据(例如,每分钟、每小时、每天)。 | 持续处理数据,延迟最小。 |
| 效率 | 更高效地处理大量数据,优化资源分配和并行处理。 | 处理单个数据点,提供即时洞察和更新,允许快速响应变化。 |
| 处理复杂性 | 能够执行复杂的数据转换和聚合。 | 设计用于以低延迟处理高速数据流。 |
| 用例 | 适用于不需要即时数据处理的场景。常用于数据仓库、报告、ETL 流程和特征管道。 | 适用于需要实时分析、功能、监控和事件驱动架构的应用。 |
| 系统复杂性 | 与流管道相比,系统通常更容易实现和维护。 | 由于需要低延迟处理、容错性和可伸缩性,实现和维护更复杂。工具也更为先进和复杂。 |
表 4.1:批量与流管道对比
例如,流式管道在像 TikTok 这样的社交媒体推荐系统中非常强大。在使用社交媒体时,用户行为经常变化。一个典型的场景是,你希望在某个时间点放松,主要观看小狗的视频。然而,15 分钟后,你会感到无聊,想要一些更严肃的内容,比如教育内容或新闻。这意味着推荐系统必须及时捕捉这些行为变化,以保持你的兴趣。由于兴趣之间的转换是循环的且不可预测的,你不能使用每 30 分钟或每小时运行一次的批处理管道来生成更多内容。你可以每分钟运行一次以创建新内容,但与此同时,它将导致不必要的成本,因为大多数预测将不会被消费。通过实现流式管道,你可以实时更新特定用户的特征,然后这些特征被传递到一系列模型中,以预测新的推荐内容。
流式架构也是实时欺诈检测算法(如 Stripe 或 PayPal 中使用的算法)的骨架。在这种情况下,关键是要在交易发生时识别潜在的欺诈交易,而不是在几分钟或几小时之后,正如批处理管道会处理的那样。同样的紧迫性也适用于基于不断涌入的市场数据进行股票预测的高频交易平台,使交易者能够在毫秒内做出决策。
另一方面,你可以为离线推荐系统使用批处理架构。例如,在为电子商务或流媒体平台实现时,你不需要系统如此反应灵敏,因为用户的行为很少改变。因此,基于历史用户行为数据,定期(如每晚)使用批处理管道更新推荐内容更容易实现且成本更低。
批处理管道的另一个流行例子是用于提取、转换和加载数据以用于不同用例的 ETL 设计。ETL 设计在用于将数据从一个数据库移动到另一个数据库的数据管道中很常见。一些实际用例包括用于分析的聚合数据,其中你必须从多个来源提取数据,对其进行聚合,并将其加载到连接到仪表板的数据库中。分析领域可以非常广泛,从电子商务和营销到金融和研究。
在 LLM Twin 用例中使用的数据收集管道是另一个提取数据来自互联网、对其进行结构化并将其加载到数据仓库以供未来处理的 ETL 管道的例子。
除了预测或特征新鲜度之外,批量管道相对于流式管道的另一个缺点是,你通常会做出冗余的预测。以 Netflix 等流媒体平台的推荐系统为例。每晚,你为所有用户做出预测。有很大可能性,一大群用户那天不会登录。此外,用户通常不会浏览所有推荐,而是坚持查看前几个。因此,只有一部分预测被使用,而其他所有预测都浪费了计算能力。
正因如此,一种流行的策略是从批量架构开始,因为它更快、更容易实现。产品到位后,你逐渐过渡到流式设计,以降低成本并提高用户体验。
总结来说,我们之所以选择批量架构(而非流式架构)来实现 LLM Twin 的特征管道,有以下原因:
-
不需要立即数据处理:即使同步数据仓库和特征存储对于准确的 RAG 系统至关重要,几分钟的延迟是可以接受的。因此,我们可以安排批量管道每分钟运行一次,持续同步两个数据存储。这种技术之所以有效,是因为数据量小。整个数据仓库将只有几千条记录,而不是数百万或数十亿。因此,我们可以快速迭代它们并同步两个数据库。
-
简单性:如前所述,实现流式管道是两倍复杂。在现实世界中,你希望尽可能简化你的系统,使其更容易理解、调试和维护。此外,简单性通常意味着更低的基础设施和开发成本。
在图 8.10中,我们比较了根据你的架构(流式处理与批量处理)以及你要处理的数据量(小数据与大数据)你可以使用哪些工具。在我们的用例中,我们处于小数据和批量象限,我们选择了纯 Python 和生成式 AI 工具的组合,如 LangChain、Sentence Transformers 和 Unstructured。

图 4.10:流式处理与批量处理以及小数据与大数据谱系中的工具
在本章后面的“变更数据捕获:同步数据仓库和特征存储”部分,我们将讨论何时从批量架构切换到流式架构是有意义的。
核心步骤
大多数 RAG 特征管道由五个核心步骤组成。在 LLM Twin 架构中实现的也不例外。因此,你可以快速地将这种模式应用于其他 RAG 应用,但以下是 LLM Twin 的 RAG 特征管道的样子:
-
数据提取:从 MongoDB 数据仓库中提取最新的文章、代码仓库和帖子。在提取步骤中,你通常会聚合所有需要处理的数据。
-
清洗: 数据仓库中的数据已经标准化和部分清洗,但我们必须确保文本只包含有用的信息,不重复,并且可以被嵌入模型解释。例如,在将文本传递给嵌入模型之前,我们必须清理和标准化所有非 ASCII 字符。此外,为了保持信息在语义上密集,我们决定用占位符替换所有 URL,并移除所有表情符号。清洗步骤更多的是艺术而非科学。因此,在你有了带有评估机制的第一次迭代之后,你可能会反复迭代并改进它。
-
分块: 你必须根据每个数据类别和嵌入模型采用各种分块策略。例如,当与代码仓库一起工作时,你希望分块更宽泛,而当与文章一起工作时,你希望它们更窄或定位在段落级别。根据你的数据,你必须决定是否根据章节、部分、段落、句子或只是一个固定的窗口大小来分割你的文档。此外,你必须确保分块大小不超过嵌入模型的最大输入大小。这就是为什么你通常根据你的数据结构和模型的最大输入大小来分块文档。
-
嵌入: 你将每个分块单独传递到你选择的嵌入模型。在实现方面,这一步通常是最简单的,因为 SentenceTransformer 和 Hugging Face 等工具为大多数嵌入模型提供了高级接口。正如本章“什么是嵌入?”部分所解释的,在这一步,最重要的决定是决定使用什么模型以及是否对其进行微调。例如,我们使用了来自 SentenceTransformer 的
"all-mpnet-base-v2"嵌入模型,它相对较小,可以在大多数机器上运行。然而,我们提供了一个配置文件,你可以根据阅读本书时的最新技术状态快速配置嵌入模型。你可以在 Hugging Face 的 MTEB 上快速找到其他选项(huggingface.co/spaces/mteb/leaderboard)。 -
数据加载: 最后一步是将分块文档的嵌入及其元数据(如作者、文档 ID、内容、URL、平台和创建日期)结合起来。最终,我们将向量和元数据包装成一个与 Qdrant 兼容的结构,并将其推送到向量数据库。由于我们希望将 Qdrant 作为特征的单一事实来源,我们也把清洗过的文档(在分块之前)推送到 Qdrant。我们可以推送没有向量的数据,因为 Qdrant 的元数据索引就像一个 NoSQL 数据库。因此,推送没有附加向量的元数据就像使用标准的 NoSQL 引擎一样。
数据捕获变更:同步数据仓库和特征存储
如本章多次强调的那样,数据是不断变化的,这可能导致数据库(DBs)、数据湖、数据仓库和特征存储失去同步。变更数据捕获(CDC)是一种策略,允许您在不进行计算和 I/O 开销的情况下,最优地保持两种或多种数据存储类型同步。它捕获对源数据库执行的任何 CRUD 操作,并将其复制到目标数据库。可选地,您可以在复制之间添加预处理步骤。
同步问题也适用于构建特征管道。一个关键的设计选择是关于如何同步数据仓库与特征存储,以便为您的特定用例提供足够新鲜的数据。
在我们的 LLM Twin 用例中,我们出于简单起见选择了一种天真方法。我们实现了一个周期性或手动触发的批量管道。它从数据仓库中读取所有原始数据,批量处理它们,并将新记录或更新插入到 Qdrant 向量数据库中。当您处理数千或数万条记录时,这种方法效果良好。但我们的天真方法提出了以下问题:
-
如果数据突然增长到数百万条记录(或更多)怎么办?
-
如果从数据仓库中删除了一条记录?这在特征存储中如何反映?
-
如果我们只想处理数据仓库中的新或更新项,而不是所有项怎么办?
幸运的是,CDC 模式可以解决所有这些问题。在实施 CDC 时,您可以采取多种方法,但所有这些方法都使用推送或拉取策略:
-
推送:在推送方法中,源数据库是主要驱动因素。它积极识别并向目标系统传输数据修改以进行处理。此方法确保在目标处几乎瞬间更新,但如果目标系统不可访问,则可能会发生数据丢失。为了减轻这种情况,通常使用消息系统作为缓冲。
-
拉取:拉取方法赋予源数据库更被动的角色,它仅记录数据更改。目标系统定期请求这些更改并相应地处理更新。虽然这种方法减轻了源系统的负担,但引入了数据传播的延迟。在目标系统不可用期间,消息系统再次是防止数据丢失的关键。
总结来说,推送方法适用于需要即时数据访问的应用程序,而拉取方法更适合大规模数据传输,其中实时更新不是关键。考虑到这一点,有不同方法来检测数据变化。因此,让我们列出在行业中使用的主要 CDC 模式:
-
基于时间戳的: 这种方法涉及在数据库表中添加一个修改时间列,通常称为
LAST_MODIFIED或LAST_UPDATED。下游系统可以查询此列以识别自上次检查以来已更新的记录。虽然实现简单,但此方法仅限于跟踪更改,而不是删除,并且由于需要扫描整个表而增加了性能开销。 -
基于触发器的: 基于触发器的方法利用数据库触发器在 INSERT、UPDATE 或 DELETE 操作时自动记录数据修改到一个单独的表中,通常称为事件表。此方法提供了全面的变化跟踪,但由于每个事件都涉及额外的写入操作,可能会影响数据库性能。
-
基于日志的: 数据库维护事务日志以记录所有数据修改,包括时间戳。主要用于恢复,这些日志还可以用于实时传播到目标系统中的更改。这种方法最小化了源数据库的性能影响。作为一个巨大的优势,它避免了源数据库上的额外处理开销,捕获了所有数据更改,并且不需要对模式进行修改。但另一方面,它缺乏标准化的日志格式,导致供应商特定的实现。
关于 CDC 的更多详细信息,我推荐阅读 Confluent 的博客中的 什么是变更数据捕获?:www.confluent.io/en-gb/learn/change-data-capture/。
考虑到这些 CDC 技术,我们可以在我们的 RAG 功能管道中快速实现一个基于拉取时间戳的策略,以在数据增长时更优化地同步数据仓库和特征存储库。我们的实现仍然是基于拉取的,但不会检查源数据库中的任何最后更新字段;它只是从数据仓库中拉取所有内容。
然而,在行业中最为流行和最优的技术是日志基础技术。它不会给源数据库增加任何 I/O 开销,具有低延迟,并支持所有 CRUD 操作。最大的缺点是其开发复杂性,需要队列来捕获所有 CRUD 事件,并需要一个流式处理管道来处理它们。
由于这是一本关于大型语言模型的书,而不是数据工程的书,我们希望保持简单,但重要的是要知道这些技术存在,并且当你的当前实现不再满足应用程序需求时,你总是可以升级你的实现。
为什么数据存储在两个快照中?
我们在逻辑特征存储库中存储了我们数据的两个快照:
-
数据清理后: 用于微调大型语言模型
-
在文档分块和嵌入后: 用于 RAG
我们为什么这样设计? 记住,特征应该仅从特征存储库中访问用于训练和推理。因此,这增加了我们设计的连贯性,并使其更简洁。
此外,将专门用于我们的微调和嵌入用例的数据清洗存储在 MongoDB 数据仓库中将会是一个反模式。仓库中的数据在整个公司中共享。因此,为特定用例处理它不是好的做法。想象一下另一个摘要用例,我们必须以不同的方式清洗和预处理数据。我们必须为每个新的用例创建一个新的“清洗数据”表,并以前缀使用例名称命名。我们必须为每个新的用例重复此操作。因此,为了避免有一个混乱的数据仓库,数据仓库中的数据是通用的,并且仅在下游组件中针对特定应用进行建模,在我们的情况下,是特征存储。
最终,正如我们在 核心步骤 部分提到的,你可以利用向量数据库的元数据索引作为 NoSQL 数据库。基于这些因素,我们决定将清洗后的数据以及文档的块化和嵌入版本都保存在 Qdrant 中。
作为快速提醒,当将我们的 LLM Twin 系统投入运营时,第五章 中解释的创建指令数据集管道将从 Qdrant 读取清洗后的文档,处理它们,并将它们保存为版本化的 ZenML 艺术品。训练管道需要一个数据集而不是普通文档。这是一个提醒,我们的逻辑特征存储包括 Qdrant 向量数据库用于在线服务以及 ZenML 艺术品用于离线训练。
编排
ZenML 将编排批处理 RAG 特征管道。使用 ZenML,我们可以安排它按计划运行,例如每小时运行一次,或者快速手动触发它。另一个选项是在 ETL 数据收集管道完成后触发它。
通过编排特征管道并将其集成到 ZenML(或任何其他编排工具)中,我们可以将特征管道投入运营,最终目标是持续训练(CT)。
我们将在 第十一章 中详细介绍编排、调度和 CT。
实现 LLM Twin 的 RAG 特征管道
最后一步是审查 LLM Twin 的 RAG 特征管道代码,看看我们如何应用本章中讨论的所有内容。我们将向您介绍以下内容:
-
ZenML 代码
-
Pydantic 领域对象
-
一个定制的对象-向量映射(OVM)实现
-
我们所有数据类别的清洗、分块和嵌入逻辑
我们将采取自上而下的方法。因此,让我们从 Settings 类和 ZenML 管道开始。
设置
我们使用 Pydantic Settings (docs.pydantic.dev/latest/concepts/pydantic_settings/) 来定义一个全局的 Settings 类,该类从 .env 文件中加载敏感或非敏感变量。这种方法也为我们带来了 Pydantic 的所有好处,例如类型验证。例如,如果我们为 QDRANT_DATABASE_PORT 变量提供一个字符串而不是整数,程序将会崩溃。这种行为使得整个应用程序更加确定性和可靠。
这是 Settings 类的样子,其中包含构建 RAG 特征管道所需的所有变量:
from pydantic import BaseSettings
class Settings(BaseSettings):
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
… # Some other settings…
# RAG
TEXT_EMBEDDING_MODEL_ID: str = "sentence-transformers/all-MiniLM-L6-v2"
RERANKING_CROSS_ENCODER_MODEL_ID: str = "cross-encoder/ms-marco-MiniLM-L-4-v2"
RAG_MODEL_DEVICE: str = "cpu"
# QdrantDB Vector DB
USE_QDRANT_CLOUD: bool = False
QDRANT_DATABASE_HOST: str = "localhost"
QDRANT_DATABASE_PORT: int = 6333
QDRANT_CLOUD_URL: str = "str"
QDRANT_APIKEY: str | None = None
… # More settings…
settings = Settings()
如内部 Config 类所述,所有变量都有默认值,或者可以通过提供 .env 文件来覆盖。
ZenML 管道和步骤
ZenML 管道是 RAG 特征工程管道的入口点。它反映了 RAG 吸收代码的五个核心阶段:提取原始文档、清理、分块、嵌入并将它们加载到逻辑特征存储中。feature_engineering() 函数内的调用是 ZenML 步骤,代表执行五个阶段的单个执行单元。代码可在 GitHub 仓库中找到:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/pipelines/feature_engineering.py
from zenml import pipeline
from llm_engineering.interfaces.orchestrator.steps import feature_engineering as fe_steps
@pipeline
def feature_engineering(author_full_names: list[str]) -> None:
raw_documents = fe_steps.query_data_warehouse(author_full_names)
cleaned_documents = fe_steps.clean_documents(raw_documents)
last_step_1 = fe_steps.load_to_vector_db(cleaned_documents)
embedded_documents = fe_steps.chunk_and_embed(cleaned_documents)
last_step_2 = fe_steps.load_to_vector_db(embedded_documents)
return [last_step_1.invocation_id, last_step_2.invocation_id]
图 4.11 展示了在 ZenML 仪表板中多个特征工程管道运行的外观。

图 4.11:ZenML 仪表板中的特征管道运行
图 8.12 展示了 RAG 特征管道的 DAG,你可以跟踪所有管道步骤及其输出工件。请记住,从 ZenML 步骤返回的任何内容都会自动保存为工件,存储在 ZenML 的工件注册表中,进行版本控制,并在应用程序中共享。

图 4.12:ZenML 仪表板中的特征管道 DAG
最后一个拼图是理解如何动态配置 RAG 特征管道。所有可用设置都作为函数参数公开。在这里,我们只需要一个作者名字的列表,正如函数签名中所示:feature_engineering(author_full_names: list[str])。我们注入一个运行时 YAML 配置文件,其中包含基于不同用例的所有必要值。例如,以下配置包括本书所有作者的名字,因为我们希望用我们所有人的数据填充特征存储(可在 GitHub 仓库的 configs/feature_engineering.yaml 中找到):
parameters:
author_full_names:
- Alex Vesa
- Maxime Labonne
- Paul Iusztin
这种方法的优点是,你不需要修改代码来使用不同的输入值配置特征管道。你需要在运行时提供不同的配置文件,如下所示:
feature_engineering.with_options(config_path="…/feature_engineering.yaml")()
你可以硬编码配置文件的路径,或者从 CLI 提供的 config_path,这允许你在不同运行之间修改管道的配置。出于简单起见,我们硬编码了配置文件。因此,我们可以通过以下方式调用特征工程管道,即调用 run.py 脚本:
python -m tools.run --no-cache --run-feature-engineering
然而,你可以轻松地添加另一个 CLI 参数来传递 config_path 变量。你也可以使用以下 poe 命令运行特征管道:
poetry poe run-feature-engineering-pipeline
让我们继续前进到 ZenML 步骤,并依次聚焦于所有这些步骤。所有特征工程管道步骤的源代码都可在 GitHub 上的"steps/feature_engineering"获取。我们将从第一个步骤开始,该步骤涉及查询数据仓库以获取新内容进行处理成特征。
查询数据仓库
首先要注意的是,一个步骤是一个用@step装饰的 Python 函数,类似于 ZenML 管道的工作方式。下面的函数接受作者全名列表作为输入,并执行以下核心步骤:
-
它尝试使用姓名和姓氏获取或创建一个
UserDocument实例,并将此实例追加到作者列表中。如果用户不存在,它将抛出一个错误。 -
它从数据仓库中获取所有原始数据供用户使用,并将
documents列表扩展以包括这些用户文档。 -
最终,它计算一个描述性元数据字典,该字典在 ZenML 中记录和跟踪。
… # other imports
from zenml import get_step_context, step
@step
def query_data_warehouse(
author_full_names: list[str],
) -> Annotated[list, "raw_documents"]:
documents = []
authors = []
for author_full_name in author_full_names:
logger.info(f"Querying data warehouse for user: {author_full_name}")
first_name, last_name = utils.split_user_full_name(author_full_name)
logger.info(f"First name: {first_name}, Last name: {last_name}")
user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
authors.append(user)
results = fetch_all_data(user)
user_documents = [doc for query_result in results.values() for doc in query_result]
documents.extend(user_documents)
step_context = get_step_context()
step_context.add_output_metadata(output_name="raw_documents", metadata=_get_metadata(documents))
return documents
获取函数利用线程池,在各个不同的线程上运行每个查询。由于我们有多个数据类别,我们必须为文章、帖子存储在不同的集合中,因此必须为它们进行不同的查询。每个查询都调用数据仓库,这受限于网络 I/O 和数据仓库延迟,而不是机器的 CPU。因此,通过将每个查询移动到不同的线程,我们可以并行化它们。最终,运行此获取函数的时间将是所有调用中的最大值。
在 Python 中使用线程并行化 I/O 受限的调用是良好的实践,因为它们不受 Python 全局解释器锁(GIL)的锁定。相比之下,将每个调用添加到不同的进程中会添加过多的开销,因为进程启动比线程慢。
在 Python 中,您只想在操作受 CPU 或内存限制时使用进程来并行化事物,因为全局解释器锁(GIL)会影响它们。每个进程都有自己的 GIL。因此,并行化您的计算逻辑,例如处理已加载到内存中的文档或图像批处理,不会受到 Python GIL 限制的影响。
def fetch_all_data(user: UserDocument) -> dict[str, list[NoSQLBaseDocument]]:
user_id = str(user.id)
with ThreadPoolExecutor() as executor:
future_to_query = {
executor.submit(__fetch_articles, user_id): "articles",
executor.submit(__fetch_posts, user_id): "posts",
executor.submit(__fetch_repositories, user_id): "repositories",
}
results = {}
for future in as_completed(future_to_query):
query_name = future_to_query[future]
try:
results[query_name] = future.result()
except Exception:
logger.exception(f"'{query_name}' request failed.")
results[query_name] = []
return results
_get_metadata()函数接受查询到的文档和作者列表,并计算它们相对于每个数据类别的数量:
def _get_metadata(documents: list[Document]) -> dict:
metadata = {
"num_documents": len(documents),
}
for document in documents:
collection = document.get_collection_name()
if collection not in metadata:
metadata[collection] = {}
if "authors" not in metadata[collection]:
metadata[collection]["authors"] = list()
metadata[collection]["num_documents"] = metadata[collection].get("num_documents", 0) + 1
metadata[collection]["authors"].append(document.author_full_name)
for value in metadata.values():
if isinstance(value, dict) and "authors" in value:
value["authors"] = list(set(value["authors"]))
return metadata
我们将在 ZenML 仪表板上公开此元数据,以便快速查看加载数据的某些统计数据。例如,在图 4.13中,我们访问了query_data_warehouse()步骤的元数据标签,您可以看到,在特征管道的特定运行中,我们加载了来自三位作者的 76 个文档。这对于监控和调试批处理管道来说非常强大。
您可以始终根据您的用例扩展任何有意义的操作。

图 4.13:“查询数据仓库”ZenML 步骤的元数据
清理文档
在清理步骤中,我们遍历所有文档,并将所有逻辑委托给一个CleaningDispatcher,该调度器知道根据数据类别应用哪种清理逻辑。请记住,我们希望应用,或者在未来有应用不同清理技术的能力,对文章、帖子以及代码库进行清理。
@step
def clean_documents(
documents: Annotated[list, "raw_documents"],
) -> Annotated[list, "cleaned_documents"]:
cleaned_documents = []
for document in documents:
cleaned_document = CleaningDispatcher.dispatch(document)
cleaned_documents.append(cleaned_document)
step_context = get_step_context()
step_context.add_output_metadata(output_name="cleaned_documents", metadata=_get_metadata(cleaned_documents))
return cleaned_documents
计算出的元数据与我们在query_data_warehouse()步骤中记录的类似。因此,让我们继续进行分块和嵌入。
分块并嵌入清洗后的文档
与我们清理文档的方式类似,我们将分块和嵌入逻辑委托给一个知道如何处理每个数据类别的调度器。请注意,分块调度器返回一个列表而不是单个对象,这是有意义的,因为文档被分割成多个块。我们将在本章的“调度器层”部分深入探讨调度器。
@step
def chunk_and_embed(
cleaned_documents: Annotated[list, "cleaned_documents"],
) -> Annotated[list, "embedded_documents"]:
metadata = {"chunking": {}, "embedding": {}, "num_documents": len(cleaned_documents)}
embedded_chunks = []
for document in cleaned_documents:
chunks = ChunkingDispatcher.dispatch(document)
metadata["chunking"] = _add_chunks_metadata(chunks, metadata["chunking"])
for batched_chunks in utils.misc.batch(chunks, 10):
batched_embedded_chunks = EmbeddingDispatcher.dispatch(batched_chunks)
embedded_chunks.extend(batched_embedded_chunks)
metadata["embedding"] = _add_embeddings_metadata(embedded_chunks, metadata["embedding"])
metadata["num_chunks"] = len(embedded_chunks)
metadata["num_embedded_chunks"] = len(embedded_chunks)
step_context = get_step_context()
step_context.add_output_metadata(output_name="embedded_documents", metadata=metadata)
return embedded_chunks
在图 4.14中,您可以查看分块和嵌入 ZenML 步骤的元数据。例如,您可以快速了解我们将 76 篇文档转换成了 2,373 个块,或者我们用于分块文章的属性,例如chunk_size为 500 和chunk_overlap为50。

图 4.14:嵌入和分块 ZenML 步骤的元数据,详细说明了未分类和分块下拉菜单
在图 4.15 中,嵌入和分块步骤的其余 ZenML 元数据详细说明了嵌入模型及其用于计算向量的属性。

图 4.15:嵌入和分块 ZenML 步骤的元数据,详细说明了嵌入下拉菜单
由于机器学习系统在生产过程中可能会因为漂移或未处理的用例而随时崩溃,利用元数据部分来监控摄取的数据可以是一个强大的工具,这将节省调试天数,对于您的业务来说可能意味着数万美元或更多。
将文档加载到向量数据库
由于每篇文章、帖子或代码库都位于向量数据库中的不同集合中,我们必须根据其数据类别对所有文档进行分组。然后,我们将每个组批量加载到 Qdrant 向量数据库中:
@step
def load_to_vector_db(
documents: Annotated[list, "documents"],
) -> None:
logger.info(f"Loading {len(documents)} documents into the vector database.")
grouped_documents = VectorBaseDocument.group_by_class(documents)
for document_class, documents in grouped_documents.items():
logger.info(f"Loading documents into {document_class.get_collection_name()}")
for documents_batch in utils.misc.batch(documents, size=4):
try:
document_class.bulk_insert(documents_batch)
except Exception:
return False
return True
Pydantic 领域实体
在调查调度器之前,我们必须了解我们正在处理的领域对象。在某种程度上,在实现 LLM Twin 时,我们遵循领域驱动设计(DDD)原则,该原则指出领域实体是应用程序的核心。因此,在继续之前,了解我们正在处理的领域类别的层次结构非常重要。
领域实体的代码可在 GitHub 上找到,链接为github.com/PacktPublishing/LLM-Engineering/tree/main/llm_engineering/domain。
我们使用 Pydantic 来建模所有我们的领域实体。当我们编写这本书时,选择 Pydantic 是不言而喻的,因为它是编写具有开箱即用类型验证的数据结构的 Python 包的首选。由于 Python 是一种动态类型语言,使用 Pydantic 在运行时进行类型验证可以使你的系统更稳健,因为你可以确信你总是在处理正确的数据类型。
我们 LLM Twin 应用程序的领域被分为两个维度:
-
数据类别:帖子、文章和库
-
数据状态:清洗、分块和嵌入
我们决定为文档的每个状态创建一个基类,从而得到以下基抽象类:
-
class CleanedDocument(VectorBaseDocument, ABC) -
class Chunk(VectorBaseDocument, ABC) -
class EmbeddedChunk(VectorBaseDocument, ABC)
注意,所有这些类都继承自 VectorBaseDocument 类,这是我们自定义的 OVM 实现,我们将在本章下一节中解释。它还继承自 ABC,这使得类成为抽象类。因此,你不能从这些类中初始化对象;你只能从它们继承。这就是为什么基类总是被标记为抽象的。
上面的每个基本抽象类(它表示状态)都将有一个子类,该子类添加数据类别维度。例如,CleanedDocument 类将具有以下子类:
-
class CleanedPostDocument(CleanedDocument) -
class CleanedArticleDocument(CleanedDocument) -
class CleanedRepositoryDocument(CleanedDocument)
正如我们在 图 8.16 中所看到的,我们将对 Chunk 和 EmbeddedChunk 基抽象类重复相同的逻辑。我们将为每个数据类别和状态组合实现特定的文档类,从而产生九种类型的领域实体。例如,当摄取原始文档时,清洗步骤将产生一个 CleanedArticleDocument 实例,分块步骤将返回一个 ArticleChunk 对象列表,嵌入操作将返回封装嵌入和所有必要元数据的 EmbeddedArticleChunk 实例,以便在向量数据库中摄取。
对于帖子和解压库,也会发生同样的事情。

图 4.16:领域实体类层次结构及其交互
我们选择这种设计是因为状态列表很少改变,我们希望扩展数据类别列表。因此,根据状态结构化类允许我们通过继承这些基抽象类来插入另一个数据类别。
让我们看看清洗文档的完整代码。清洗文档的所有属性都将保存在向量数据库的元数据中。例如,清洗文章文档的元数据将始终包含内容、平台、作者 ID、作者全名和文章链接。
另一个基本方面是Config内部类,它定义了向量数据库中集合的名称、实体的数据类别以及是否在创建集合时利用向量索引:
class CleanedDocument(VectorBaseDocument, ABC):
content: str
platform: str
author_id: UUID4
author_full_name: str
class CleanedPostDocument(CleanedDocument):
image: Optional[str] = None
class Config:
name = "cleaned_posts"
category = DataCategory.POSTS
use_vector_index = False
class CleanedArticleDocument(CleanedDocument):
link: str
class Config:
name = "cleaned_articles"
category = DataCategory.ARTICLES
use_vector_index = False
class CleanedRepositoryDocument(CleanedDocument):
name: str
link: str
class Config:
name = "cleaned_repositories"
category = DataCategory.REPOSITORIES
use_vector_index = False
为了总结本节内容,让我们也看一下块和嵌入块的基础抽象类:
class Chunk(VectorBaseDocument, ABC):
content: str
platform: str
document_id: UUID4
author_id: UUID4
author_full_name: str
metadata: dict = Field(default_factory=dict)
… # PostChunk, ArticleChunk, RepositoryChunk
class EmbeddedChunk(VectorBaseDocument, ABC):
content: str
embedding: list[float] | None
platform: str
document_id: UUID4
author_id: UUID4
author_full_name: str
metadata: dict = Field(default_factory=dict)
… # EmbeddedPostChunk, EmbeddedArticleChunk, EmbeddedRepositoryChunk
我们还定义了一个枚举,它将所有数据类别聚合到一个常量结构中:
class DataCategory(StrEnum):
POSTS = "posts"
ARTICLES = "articles"
REPOSITORIES = "repositories"
完全理解域对象如何工作的最后一步是放大查看VectorBaseDocument OVM 类。
OVM
术语 OVM 是从我们在第三章中讨论的对象关系映射(ORM)模式中获得的灵感。我们称之为 OVM,因为我们与嵌入和向量数据库而不是结构化数据和 SQL 表一起工作。否则,它遵循与 ORM 模式相同的原理。
与我们在第三章中所做的一样,我们将实现自己的 OVM 版本。即使我们的自定义示例很简单,它也是一个强大的示例,展示了如何通过利用面向对象的最佳实践和原则来编写模块化和可扩展的类。
VectorBaseDocument类的完整实现可在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineering/blob/main/llm_engineering/domain/base/vector.py。
我们的 OVM 基础类被称为VectorBaseDocument。它将在 Qdrant 之上支持 CRUD 操作。根据我们应用的需求,我们仅将其限制为创建和读取操作,但它可以轻松扩展到更新和删除功能。
让我们看一下VectorBaseDocument类的定义:
from pydantic import UUID4, BaseModel
from typing import Generic
from llm_engineering.infrastructure.db.qdrant import connection
T = TypeVar("T", bound="VectorBaseDocument")
class VectorBaseDocument(BaseModel, Generic[T], ABC):
id: UUID4 = Field(default_factory=uuid.uuid4)
@classmethod
def from_record(cls: Type[T], point: Record) -> T:
_id = UUID(point.id, version=4)
payload = point.payload or {}
attributes = {
"id": _id,
**payload,
}
if cls._has_class_attribute("embedding"):
payload["embedding"] = point.vector or None
return cls(**attributes)
def to_point(self: T, **kwargs) -> PointStruct:
exclude_unset = kwargs.pop("exclude_unset", False)
by_alias = kwargs.pop("by_alias", True)
payload = self.dict(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs)
_id = str(payload.pop("id"))
vector = payload.pop("embedding", {})
if vector and isinstance(vector, np.ndarray):
vector = vector.tolist()
return PointStruct(id=_id, vector=vector, payload=payload)
VectorBaseDocument类继承自 Pydantic 的BaseModel,帮助我们从向量数据库中结构化单个记录的属性。每个 OVM 默认都会初始化为 UUID4 作为其唯一标识符。使用泛型——更确切地说,通过继承自Generic[T]——VectorBaseDocument类的所有子类的签名都将适应给定的类。例如,继承自VectorBaseDocument的Chunk()类的from_record()方法将返回块类型,这极大地帮助了静态分析器和类型检查器,如mypy(mypy.readthedocs.io/en/stable/)。
from_record()方法将来自 Qdrant 的数据点格式适配到基于 Pydantic 的内部结构。另一方面,to_point()方法将当前实例的属性适配到 Qdrant 的PointStruct()格式。我们将利用这两个方法来进行创建和读取操作。
最终,所有对 Qdrant 的操作都将通过connection实例来完成,该实例在应用的基础设施层中实例化。
bulk_insert()方法将每个文档映射到一个点。然后,它使用 Qdrant connection实例将所有点加载到 Qdrant 中给定集合。如果插入失败一次,它将尝试创建集合并再次进行插入。通常,将逻辑分成两个函数是一个好的做法。一个私有函数包含逻辑,在我们的例子中是_bulk_insert(),而一个公共函数处理所有错误和失败场景。
class VectorBaseDocument(BaseModel, Generic[T], ABC):
… # Rest of the class
@classmethod
def bulk_insert(cls: Type[T], documents: list["VectorBaseDocument"]) -> bool:
try:
cls._bulk_insert(documents)
except exceptions.UnexpectedResponse:
logger.info(
f"Collection '{cls.get_collection_name()}' does not exist. Trying to create the collection and reinsert the documents."
)
cls.create_collection()
try:
cls._bulk_insert(documents)
except exceptions.UnexpectedResponse:
logger.error(f"Failed to insert documents in '{cls.get_collection_name()}'.")
return False
return True
@classmethod
def _bulk_insert(cls: Type[T], documents: list["VectorBaseDocument"]) -> None:
points = [doc.to_point() for doc in documents]
connection.upsert(collection_name=cls.get_collection_name(), points=points)
集合名称是从继承 OVM 的子类中定义的Config类推断出来的:
class VectorBaseDocument(BaseModel, Generic[T], ABC):
… # Rest of the class
@classmethod
def get_collection_name(cls: Type[T]) -> str:
if not hasattr(cls, "Config") or not hasattr(cls.Config, "name"):
raise ImproperlyConfigured(
"The class should define a Config class with" "the 'name' property that reflects the collection's name."
)
return cls.Config.name
现在,我们必须定义一个方法,使我们能够从向量数据库(不使用向量相似性搜索逻辑)中读取所有记录。bulk_find()方法使我们能够滚动(或列出)集合中的所有记录。下面的函数滚动 Qdrant 向量数据库,它返回一个数据点列表,这些数据点最终通过from_record()方法映射到我们的内部结构。
限制参数控制我们一次返回多少项,而偏移量指示 Qdrant 开始返回记录的点 ID。
class VectorBaseDocument(BaseModel, Generic[T], ABC):
… # Rest of the class
@classmethod
def bulk_find(cls: Type[T], limit: int = 10, **kwargs) -> tuple[list[T], UUID | None]:
try:
documents, next_offset = cls._bulk_find(limit=limit, **kwargs)
except exceptions.UnexpectedResponse:
logger.error(f"Failed to search documents in '{cls.get_collection_name()}'.")
documents, next_offset = [], None
return documents, next_offset
@classmethod
def _bulk_find(cls: Type[T], limit: int = 10, **kwargs) -> tuple[list[T], UUID | None]:
collection_name = cls.get_collection_name()
offset = kwargs.pop("offset", None)
offset = str(offset) if offset else None
records, next_offset = connection.scroll(
collection_name=collection_name,
limit=limit,
with_payload=kwargs.pop("with_payload", True),
with_vectors=kwargs.pop("with_vectors", False),
offset=offset,
**kwargs,
)
documents = [cls.from_record(record) for record in records]
if next_offset is not None:
next_offset = UUID(next_offset, version=4)
return documents, next_offset
最后一个拼图是要定义一个方法,该方法在提供的查询嵌入上执行向量相似性搜索。就像之前一样,我们定义了一个公共的search()方法和一个私有的_search()方法。搜索是通过调用connection.search()函数时由 Qdrant 执行的。
class VectorBaseDocument(BaseModel, Generic[T], ABC):
… # Rest of the class
@classmethod
def search(cls: Type[T], query_vector: list, limit: int = 10, **kwargs) -> list[T]:
try:
documents = cls._search(query_vector=query_vector, limit=limit, **kwargs)
except exceptions.UnexpectedResponse:
logger.error(f"Failed to search documents in '{cls.get_collection_name()}'.")
documents = []
return documents
@classmethod
def _search(cls: Type[T], query_vector: list, limit: int = 10, **kwargs) -> list[T]:
collection_name = cls.get_collection_name()
records = connection.search(
collection_name=collection_name,
query_vector=query_vector,
limit=limit,
with_payload=kwargs.pop("with_payload", True),
with_vectors=kwargs.pop("with_vectors", False),
**kwargs,
)
documents = [cls.from_record(record) for record in records]
return documents
现在我们已经了解了我们的领域实体看起来像什么以及 OVM 是如何工作的,让我们继续到清理、分块和嵌入文档的分发器。
分发器层
分发器输入一个文档,并根据其数据类别(文章、帖子或存储库)应用特定的处理程序。处理程序可以清理、分块或嵌入文档。
让我们从关注CleaningDispatcher开始。它主要实现了一个dispatch()方法,该方法输入一个原始文档。根据其数据类别,它实例化并调用一个处理程序,该处理程序应用针对该数据点的特定清理逻辑:
class CleaningDispatcher:
cleaning_factory = CleaningHandlerFactory()
@classmethod
def dispatch(cls, data_model: NoSQLBaseDocument) -> VectorBaseDocument:
data_category = DataCategory(data_model.get_collection_name())
handler = cls.cleaning_factory.create_handler(data_category)
clean_model = handler.clean(data_model)
logger.info(
"Data cleaned successfully.",
data_category=data_category,
cleaned_content_len=len(clean_model.content),
)
return clean_model
分发器逻辑中的关键是CleaningHandlerFactory(),它根据文档的数据类别实例化不同的清理处理程序:
class CleaningHandlerFactory:
@staticmethod
def create_handler(data_category: DataCategory) -> CleaningDataHandler:
if data_category == DataCategory.POSTS:
return PostCleaningHandler()
elif data_category == DataCategory.ARTICLES:
return ArticleCleaningHandler()
elif data_category == DataCategory.REPOSITORIES:
return RepositoryCleaningHandler()
else:
raise ValueError("Unsupported data type")
分发器或工厂类并不复杂,但它们提供了一个直观且简单的接口,用于将各种操作应用于您的文档。在处理文档时,您无需担心它们的数据类别,也不必用 if-else 语句污染业务逻辑,您有一个专门处理这一点的类。您有一个单独的类可以清理任何文档,这遵循了软件工程中的 DRY(不要重复自己)原则。通过遵循 DRY 原则,您有一个单一的错误点,代码可以轻松扩展。例如,如果我们添加一个额外的类型,我们只需扩展工厂类,而不是在代码中的多个位置进行扩展。
ChunkingDispatcher和EmbeddingDispatcher遵循相同的模式。它们使用ChunkingHandlerFactory和分别的EmbeddingHandlerFactory,根据输入文档的数据类别初始化正确的处理器。之后,它们调用处理器并返回结果。
所有调度器和工厂的源代码可以在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/preprocessing/dispatchers.py
工厂类利用了抽象工厂创建模式(refactoring.guru/design-patterns/abstract-factory),该模式实例化一组实现相同接口的类。在我们的情况下,这些处理器无论处理器类型如何都实现了clean()方法。
此外,Handler类家族利用了策略行为模式(refactoring.guru/design-patterns/strategy),在需要在使用对象内使用算法的不同变体,并在运行时能够从一种算法切换到另一种算法时使用。
直观地说,在我们的调度层中,工厂模式和策略模式的组合工作如下:
-
初始时,我们知道我们想要清理数据,但由于我们只在运行时知道数据类别,我们无法决定应用哪种策略。
-
我们可以围绕清理代码编写整个代码,并将逻辑抽象化在
Handler()接口下,该接口将代表我们的策略。 -
当我们得到一个数据点时,我们应用抽象工厂模式并为其数据类型创建正确的清理处理器。
-
最终,调度层使用处理器并执行正确的策略。
通过这样做,我们:
-
将特定数据类别的逻辑隔离。
-
利用多态性来避免在代码中填充数百个
if-else语句。 -
使代码模块化和可扩展。当新的数据类别到来时,我们必须实现一个新的处理器并修改工厂类,而不需要触及代码的任何其他部分。
直到现在,我们只是对实体进行了建模以及数据在我们应用程序中的流动方式。我们没有编写任何清理、分块或嵌入代码。这是快速演示和可投入生产的应用程序之间的一大区别。在演示中,你不需要关心软件工程的最佳实践和构建未来兼容的代码结构。然而,编写干净、模块化和可扩展的代码对于构建现实世界应用程序的长期寿命至关重要。
RAG 功能管道的最后一个组件是实现清理、分块和嵌入处理器。
处理器
处理器与我们的领域具有一对一的结构,这意味着每个实体都有自己的处理器,如图 8.17 所示。总共,我们将有九个处理器类,它们遵循以下基本接口:
-
class CleaningDataHandler() -
class ChunkingDataHandler() -
class EmbeddingDataHandler()

图 4.17:处理器类层次结构和它们的交互
所有处理器的代码可在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineering/tree/main/llm_engineering/application/preprocessing。
让我们检查每个处理器家族,看看它们是如何实现的。
清理处理器
CleaningDataHandler() 策略接口看起来如下:
… # Other imports.
from typing import Generic, TypeVar
DocumentT = TypeVar("DocumentT", bound=Document)
CleanedDocumentT = TypeVar("CleanedDocumentT", bound=CleanedDocument)
class CleaningDataHandler(ABC, Generic[DocumentT, CleanedDocumentT]):
@abstractmethod
def clean(self, data_model: DocumentT) -> CleanedDocumentT:
pass
现在,对于每个帖子、文章和存储库,我们必须实现不同的处理器,如下所示:
class PostCleaningHandler(CleaningDataHandler):
def clean(self, data_model: PostDocument) -> CleanedPostDocument:
return CleanedPostDocument(
id=data_model.id,
content=clean_text(" #### ".join(data_model.content.values())),
… # Copy the rest of the parameters from the data_model object.
)
class ArticleCleaningHandler(CleaningDataHandler):
def clean(self, data_model: ArticleDocument) -> CleanedArticleDocument:
valid_content = [content for content in data_model.content.values() if content]
return CleanedArticleDocument(
id=data_model.id,
content=clean_text(" #### ".join(valid_content)),
platform=data_model.platform,
link=data_model.link,
author_id=data_model.author_id,
author_full_name=data_model.author_full_name,
)
class RepositoryCleaningHandler(CleaningDataHandler):
def clean(self, data_model: RepositoryDocument) -> CleanedRepositoryDocument:
return CleanedRepositoryDocument(
id=data_model.id,
content=clean_text(" #### ".join(data_model.content.values())),
… # Copy the rest of the parameters from the data_model object.
)
处理器输入原始文档领域实体,清理内容,并返回一个清理后的文档。所有处理器都使用 clean_text() 函数来清理文本。出于简化的考虑,我们为所有数据类别使用了相同的清理技术。然而,在实际的设置中,我们不得不进一步优化并为每个数据类别创建不同的清理函数。策略模式使得这一点变得非常简单,因为我们只需在处理器中交换清理函数即可。
在 clean_text() 函数中应用的清理步骤与在 第五章 的 创建指令数据集 部分中讨论的相同。我们不希望重复自己。因此,为了复习,请查看该章节。在此阶段,我们主要关注自动化并将整个逻辑集成到 RAG 功能管道中。因此,在将 ML 系统投入运行后,所有用于微调的清理数据都将从逻辑特征存储中访问,使其成为访问数据的单一真相来源。
分块处理器
首先,让我们检查 ChunkingDataHandler() 策略处理器。我们将 metadata 字典作为属性公开,以便在单个结构中聚合所有必要的分块属性。通过这种方式结构化,我们可以轻松地将所有内容记录到 ZenML 以跟踪和调试我们的分块逻辑。处理器以清理后的文档为输入,并返回分块实体。所有处理器都可以在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineering/tree/main/llm_engineering/application/preprocessing。
… # Other imports.
from typing import Generic, TypeVar
CleanedDocumentT = TypeVar("CleanedDocumentT", bound=CleanedDocument)
ChunkT = TypeVar("ChunkT", bound=Chunk)
class ChunkingDataHandler(ABC, Generic[CleanedDocumentT, ChunkT]):
@property
def metadata(self) -> dict:
return {
"chunk_size": 500,
"chunk_overlap": 50,
}
@abstractmethod
def chunk(self, data_model: CleanedDocumentT) -> list[ChunkT]:
pass
让我们了解 ArticleChunkingHandler() 类是如何实现的。第一步是重写元数据属性并自定义分块逻辑所需的属性类型。例如,当处理文章时,我们关注分块的最小和最大长度。
处理程序的chunk()方法输入清洗后的文章文档,并返回一个文章文本块实体列表。它使用chunk_text()函数将清洗后的内容分割成文本块。文本块分割函数是根据min_length和max_length元数据字段定制的。文本块 ID 是文本块内容的 MD5 哈希值。因此,如果两个文本块的内容完全相同,它们将具有相同的 ID,我们可以轻松地去除重复项。最后,我们创建一个文本块实体列表并返回它们。
class ArticleChunkingHandler(ChunkingDataHandler):
@property
def metadata(self) -> dict:
return {
"min_length": 1000,
"max_length": 1000,
}
def chunk(self, data_model: CleanedArticleDocument) -> list[ArticleChunk]:
data_models_list = []
cleaned_content = data_model.content
chunks = chunk_article(
cleaned_content, min_length=self.metadata["min_length"], max_length=self.metadata["max_length"]
)
for chunk in chunks:
chunk_id = hashlib.md5(chunk.encode()).hexdigest()
model = ArticleChunk(
id=UUID(chunk_id, version=4),
content=chunk,
platform=data_model.platform,
link=data_model.link,
document_id=data_model.id,
author_id=data_model.author_id,
author_full_name=data_model.author_full_name,
metadata=self.metadata,
)
data_models_list.append(model)
return data_models_list
最后一步是深入研究chunk_article()函数,该函数主要做两件事:
-
它使用正则表达式通过查找后面跟着空格的句号、问号或感叹号来在给定文本中找到所有句子。然而,它避免将标点符号作为缩写或首字母缩略词(如“
e.g.”或“Dr.”)的一部分的情况分割开。 -
它会将句子组合成一个单独的文本块,直到达到
max_length限制。当达到最大尺寸时,如果文本块的大小大于允许的最小值,它就会被添加到函数返回的最终列表中。
def chunk_article(text: str, min_length: int, max_length: int) -> list[str]:
sentences = re.split(r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s", text)
extracts = []
current_chunk = ""
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
if len(current_chunk) + len(sentence) <= max_length:
current_chunk += sentence + " "
else:
if len(current_chunk) >= min_length:
extracts.append(current_chunk.strip())
current_chunk = sentence + " "
if len(current_chunk) >= min_length:
extracts.append(current_chunk.strip())
return extracts
在 GitHub 上可用的PostChunkingHandler和RepositoryChunkingHandler与ArticleChunkingHandler具有类似的结构。然而,它们使用一个更通用的文本块分割函数chunk_text(),值得深入研究。chunk_text()函数是一个两步过程,具有以下逻辑:
-
它使用 LangChain 的
RecursiveCharacterTextSplitter()根据给定的分隔符或文本块大小来分割文本。使用分隔符,我们首先尝试在给定文本中找到段落,但如果没有段落或它们太长,我们就在给定的文本块大小处将其分割。 -
注意,我们希望确保文本块不超过嵌入模型的输入长度最大值。因此,我们将上面创建的所有文本块传递给一个
SenteceTransformersTokenTextSplitter(),该函数会考虑模型的输入长度最大值。在此阶段,我们还应用了chunk_overlap逻辑,因为我们希望在验证文本块足够小之后才执行此操作。… # Other imports. from langchain.text_splitter import RecursiveCharacterTextSplitter, SentenceTransformersTokenTextSplitter from llm_engineering.application.networks import EmbeddingModelSingleton def chunk_text(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> list[str]: character_splitter = RecursiveCharacterTextSplitter(separators=["\n\n"], chunk_size=chunk_size, chunk_overlap=0) text_split_by_characters = character_splitter.split_text(text) token_splitter = SentenceTransformersTokenTextSplitter( chunk_overlap=chunk_overlap, tokens_per_chunk=embedding_model.max_input_length, model_name=embedding_model.model_id, ) chunks_by_tokens = [] for section in text_split_by_characters: chunks_by_tokens.extend(token_splitter.split_text(section)) return chunks_by_tokens
总结来说,上述函数返回一个列表,其中的文本块既符合提供的文本块参数,也符合嵌入模型的最大输入长度。
嵌入处理程序
嵌入处理程序与其他处理程序略有不同,因为EmbeddingDataHandler()接口包含大部分逻辑。我们采取这种方法是因为,在调用嵌入模型时,我们希望尽可能多地批量处理样本以优化推理过程。当在 GPU 上运行模型时,批量样本是独立且并行处理的。因此,通过批量处理文本块,我们可以根据批量大小和硬件优化推理过程,提高 10 倍或更多。
我们实现了一个 embed() 方法,以防你想在单个数据点上运行推理,以及一个 embed_batch() 方法。embed_batch() 方法接收分块文档作为输入,将它们的内容收集到一个列表中,然后将它们传递给嵌入模型,并将结果映射到嵌入块域实体。映射是通过 map_model() 抽象方法完成的,该方法必须针对每个数据类别进行定制。
… # Other imports.
from typing import Generic, TypeVar, cast
from llm_engineering.application.networks import EmbeddingModelSingleton
ChunkT = TypeVar("ChunkT", bound=Chunk)
EmbeddedChunkT = TypeVar("EmbeddedChunkT", bound=EmbeddedChunk)
embedding_model = EmbeddingModelSingleton()
class EmbeddingDataHandler(ABC, Generic[ChunkT, EmbeddedChunkT]):
"""
Abstract class for all embedding data handlers.
All data transformations logic for the embedding step is done here
"""
def embed(self, data_model: ChunkT) -> EmbeddedChunkT:
return self.embed_batch([data_model])[0]
def embed_batch(self, data_model: list[ChunkT]) -> list[EmbeddedChunkT]:
embedding_model_input = [data_model.content for data_model in data_model]
embeddings = embedding_model(embedding_model_input, to_list=True)
embedded_chunk = [
self.map_model(data_model, cast(list[float], embedding))
for data_model, embedding in zip(data_model, embeddings, strict=False)
]
return embedded_chunk
@abstractmethod
def map_model(self, data_model: ChunkT, embedding: list[float]) -> EmbeddedChunkT:
pass
让我们只看看 ArticleEmbeddingHandler() 的实现,因为其他处理程序高度相似。正如你所见,我们只需要实现 map_model() 方法,该方法接收输入块并批量计算嵌入。它的作用是将这些信息映射到 EmbeddedArticleChunk Pydantic 实体。
class ArticleEmbeddingHandler(EmbeddingDataHandler):
def map_model(self, data_model: ArticleChunk, embedding: list[float]) -> EmbeddedArticleChunk:
return EmbeddedArticleChunk(
id=data_model.id,
content=data_model.content,
embedding=embedding,
platform=data_model.platform,
link=data_model.link,
document_id=data_model.document_id,
author_id=data_model.author_id,
author_full_name=data_model.author_full_name,
metadata={
"embedding_model_id": embedding_model.model_id,
"embedding_size": embedding_model.embedding_size,
"max_input_length": embedding_model.max_input_length,
},
)
最后一步是理解 EmbeddingModelSingleton() 的工作原理。它是 Sentence Transformers 中 SentenceTransformer() 类的包装器,用于初始化嵌入模型。在外部包上编写包装器通常是很好的实践。因此,当你想要更改第三方工具时,你只需修改包装器的内部逻辑,而不是整个代码库。
SentenceTransformer() 类使用在 Settings 类中定义的 model_id 进行初始化,这使得我们能够通过更改配置文件而不是代码来快速测试多个嵌入模型。这就是为什么我根本不坚持使用哪种嵌入模型。这始终根据你的用例、数据、硬件和延迟而变化。但是,通过编写一个可以快速配置的通用类,你可以尝试多个嵌入模型,直到找到最适合你的模型。
from sentence_transformers.SentenceTransformer import SentenceTransformer
from llm_engineering.settings import settings
from .base import SingletonMeta
class EmbeddingModelSingleton(metaclass=SingletonMeta):
def __init__(
self,
model_id: str = settings.TEXT_EMBEDDING_MODEL_ID,
device: str = settings.RAG_MODEL_DEVICE,
cache_dir: Optional[Path] = None,
) -> None:
self._model_id = model_id
self._device = device
self._model = SentenceTransformer(
self._model_id,
device=self._device,
cache_folder=str(cache_dir) if cache_dir else None,
)
self._model.eval()
@property
def model_id(self) -> str:
return self._model_id
@cached_property
def embedding_size(self) -> int:
dummy_embedding = self._model.encode("")
return dummy_embedding.shape[0]
@property
def max_input_length(self) -> int:
return self._model.max_seq_length
@property
def tokenizer(self) -> AutoTokenizer:
return self._model.tokenizer
def __call__(
self, input_text: str | list[str], to_list: bool = True
) -> NDArray[np.float32] | list[float] | list[list[float]]:
try:
embeddings = self._model.encode(input_text)
except Exception:
logger.error(f"Error generating embeddings for {self._model_id=} and {input_text=}")
return [] if to_list else np.array([])
if to_list:
embeddings = embeddings.tolist()
return embeddings
嵌入模型类实现了单例模式(refactoring.guru/design-patterns/singleton),这是一种创建型设计模式,确保一个类只有一个实例,同时提供一个全局访问点来访问这个实例。EmbeddingModelSingleton() 类继承自 SingletonMeta 类,确保每次实例化 EmbeddingModelSingleton() 时,都返回相同的实例。这对于机器学习模型来说效果很好,因为你可以通过单例模式在内存中一次性加载它们,之后你可以在代码库的任何地方使用它们。否则,你每次使用模型时都可能在内存中加载它,或者加载多次,从而导致内存问题。此外,这使得访问诸如 embedding_size 这样的属性变得非常方便,因为你必须对嵌入模型进行一次虚拟前向传递以找到其输出的大小。作为单例,你只需进行一次前向传递,然后在程序执行期间始终可以访问它。
摘要
本章以对 RAG(关系型数据库)的软性介绍开始,解释了何时以及为什么应该使用它。我们还了解了嵌入和向量数据库是如何工作的,这是任何 RAG 系统的基石。然后,我们探讨了高级 RAG 及其为何最初需要它。我们深入理解了 RAG 哪些部分可以被优化,并提出了针对文本数据的一些流行的先进 RAG 技术。接下来,我们将所学关于 RAG 的知识应用于设计 LLM Twin 的 RAG 功能管道架构。我们还理解了批处理和流式管道之间的区别,并简要介绍了 CDC 模式,该模式有助于同步两个数据库。
最终,我们逐步深入到 LLM Twin 的 RAG 功能管道实现中,我们看到了如何将 ZenML 作为协调器进行集成,如何设计应用程序的领域实体,以及如何实现一个 OVM 模块。我们还理解了如何应用一些软件工程最佳实践,例如抽象工厂和策略软件模式,以实现一个模块化和可扩展的层,该层根据每个文档的数据类别应用不同的清理、分块和嵌入技术。
本章仅关注实现摄取管道,这只是一个标准 RAG 应用的一个组成部分。在第九章中,我们将通过实现检索和生成组件并将它们集成到推理管道中来完成 RAG 系统的构建。但首先,在下一章中,我们将探讨如何使用收集到的数据生成自定义数据集,并使用它微调 LLM。
参考文献
-
Kenton, J.D.M.W.C. 和 Toutanova, L.K., 2019 年 6 月。BERT:用于语言理解的双向变换器预训练。在naacL-HLT 会议论文集(第 1 卷,第 2 页)。
-
Liu, Y., 2019. Roberta:一种稳健优化的 BERT 预训练方法。arXiv 预印本 arXiv:1907.11692。
-
Mikolov, T., 2013. 高效估计向量空间中的词表示。arXiv 预印本 arXiv:1301.3781。
-
Jeffrey Pennington, Richard Socher, 和 Christopher Manning。2014. GloVe:全局词表示向量。在2014 年自然语言处理实证方法会议(EMNLP),第 1532-1543 页,多哈,卡塔尔。计算语言学协会。
-
He, K., Zhang, X., Ren, S. 和 Sun, J., 2016. 用于图像识别的深度残差学习。在IEEE 计算机视觉与模式识别会议论文集(第 770-778 页)。
-
Radford, A., Kim, J.W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., Sastry, G., Askell, A., Mishkin, P., Clark, J. 和 Krueger, G., 2021 年 7 月。从自然语言监督中学习可迁移的视觉模型。在国际机器学习会议(第 8748-8763 页)。PMLR。
-
什么是变更数据捕获 (CDC)?| Confluent. (n.d.). Confluent.
www.confluent.io/en-gb/learn/change-data-capture/ -
Refactoring.Guru. (2024, January 1). Singleton.
refactoring.guru/design-patterns/singleton -
Refactoring.Guru. (2024b, January 1). 策略.
refactoring.guru/design-patterns/strategy -
Refactoring.Guru. (2024a, January 1). 抽象工厂.
refactoring.guru/design-patterns/abstract-factory -
Schwaber-Cohen, R. (n.d.). 什么是向量数据库?它如何工作?用例 + 示例. Pinecone.
www.pinecone.io/learn/vector-database/ -
Monigatti, L. (2024, February 19). 高级检索增强生成:从理论到 LlaMaIndex 实现. Medium.
towardsdatascience.com/advanced-retrieval-augmented-generation-from-theory-to-llamaindex-implementation-4de1464a9930 -
Monigatti, L. (2023, December 6). 生产就绪 RAG 应用程序的 12 种调优策略指南。Medium.
towardsdatascience.com/a-guide-on-12-tuning-strategies-for-production-ready-rag-applications-7ca646833439 -
Monigatti, L. (2024b, February 19). 高级检索增强生成:从理论到 LlaMaIndex 实现. Medium.
towardsdatascience.com/advanced-retrieval-augmented-generation-from-theory-to-llamaindex-implementation-4de1464a9930 -
Maameri, S. (2024, May 10). RAG-驱动应用中的路由 - 走向数据科学。Medium.
towardsdatascience.com/routing-in-rag-driven-applications-a685460a7220
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第五章:监督微调
监督微调(SFT)是准备 LLMs(大型语言模型)应用于现实世界的关键步骤。在 LLM 学习预测序列中下一个标记的初始预训练阶段之后,SFT 通过精心挑选的指令和相应答案对,进一步细化模型的能力。这个过程有两个主要目的:它教会模型理解和遵循特定的聊天格式,有效地将其转变为对话代理,并允许模型将广泛的知识库适应于特定任务或专业领域。
SFT 的重要性在于其能够弥合模型的一般语言理解和其实际效用之间的差距。通过向模型展示期望的输入输出模式示例,SFT 塑造 LLM 的行为,使其与特定目标保持一致,无论是任务完成(如摘要或翻译)还是领域专业知识(如医学或法律知识)。这种定制方法不仅提高了模型在预期领域的性能,还提高了其遵循指令和生成更相关、更连贯响应的能力。
本章将涵盖以下主题:
-
创建高质量的指令数据集
-
SFT 技术
-
实际操作中的微调实现
到本章结束时,你将能够创建自己的指令数据集,并高效地对 LLMs 进行微调。
本章中所有的代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/LLM-Engineering。
创建指令数据集
在大多数用例中,创建指令数据集是微调过程中最困难的部分。这是由于多个因素造成的。大多数用例可以与原始文本相关联,但找到自然指令和答案对的情况很少。这些原始文本需要转换成包含指令和答案的格式。此外,数据质量也非常关键。因此,大量的时间被投入到手动检查和验证单个样本中。这种仔细的审查有助于确保数据集的准确性和对模型训练的有用性。

图5.1 – 本章涵盖的培训后数据管道概述
在本节中,我们将介绍一个通用框架来创建自己的指令数据集,无论最终用途如何。然后,我们将利用来自第三章的抓取数据并将其转换为指令数据集。我们数据生成管道的不同阶段总结在图 5.1中。
通用框架
指令数据集被定义为指令和答案的配对。指令是模型的输入,在微调期间用作上下文。答案是模型预期的输出。在微调期间,你可以选择在指令和答案上训练模型,或者只训练答案。指令和答案的配对遵循一定的模板。一些指令模板,如 Alpaca,引入了额外的字段,如inputs和system。它们都可以被认为是instruction字段的子字段。在这种情况下,inputs包含模型完成指令所需的数据,system是一个元提示,用于引导模型的一般行为。以下是从 SlimOrca 数据集的一个示例,包括system和instruction:
| 系统你是一个有帮助的助手,总是提供解释。想象你是在对一个五岁的孩子回答。 |
| --- |
| 指令概念:建造、商店、城镇写一个包含所有这些词的句子。 |
| 输出在我们的小镇里,有一个大建筑里的商店,人们可以去那里购买他们最喜欢的玩具和糖果。 |
表 5.1 – Open-Orca/SlimOrca 数据集的样本示例
这个例子说明了“系统”字段是如何用来定义模型的具体行为的,例如:提供帮助、始终提供解释,以及像与五岁孩子说话一样定制响应。 “指令”字段提供了必要的数据(概念)和任务(构建句子)。输出字段显示了预期的答案,虽然这不是唯一的可能答案,但它代表了一个高质量的响应。
要构建一个指令数据集,我们希望收集代表模型如何使用的代表性数据。一旦我们收集了足够的样本,我们的目标就是过滤它们,只保留高质量的数据。在这种情况下,高质量的数据可以通过三个主要维度来描述:
-
准确性:它指的是样本的事实正确性和相关性。在指令数据集的上下文中,这意味着确保响应不仅事实准确,而且与其对应的指令相关。高准确性对于训练能够提供可靠和可信信息的模型至关重要。
-
多样性:一个高质量的数据集应该涵盖广泛的使用案例,包括部署的 LLM 可能遇到的潜在查询和任务。这种多样性应涵盖主题、上下文、文本长度和写作风格。通过以代表性的方式采样数据,我们允许模型发展强大的指令遵循能力。
-
复杂性:简单或过于简单的样本对提高 LLM 的能力帮助不大。相反,数据集应包括复杂的多步骤推理问题和具有挑战性的任务,这些任务可以推动模型处理预期内容的边界。这种复杂性有助于开发能够解决复杂现实世界问题的模型。
在以下章节中,我们将看到根据这些维度过滤和评估指令样本的技术。
数据量
Hugging Face Hub 包含许多指令数据集,这些数据集可以是通用的,也可以是为特定任务或领域设计的。当处理一个新的用例时,寻找相关的开源数据集以用于微调可能会有所帮助。如果你的样本数量太少(例如,少于 1,000 个),这尤其重要,需要你用高质量的数据进行增强。

图 5.2 – Hugging Face Hub 上最受欢迎的数据集截图
计算一个理想的样本数量是一项困难的任务,因为数据的质量和模型的大小都可能产生重大影响。对于大型模型(例如,约 70 亿参数),这个数量可以低至 1,000 个高质量样本(参见参考文献部分中的 LIMA 论文)。对于较小的模型(例如,约 70 亿参数),情况并非如此,因为它们需要更多的样本来简单地学习正确的聊天模板。在任何情况下,数据的质量都是一个关键因素,并且总是希望有大量的样本。
为了提供更多的数据,我们可以查看公司和开源社区开发的微调模型。我们可以区分两种类型的微调:通用型,旨在重现 GPT 等模型的能力,以及针对特定任务或领域的模型,旨在优化特定应用的性能。
通用型模型覆盖更多主题,这需要额外的样本。在各个公司中,我们观察到广泛的数值范围。例如,01-ai 的 Yi 模型依赖于不到 10,000 个样本。在光谱的另一端,Meta 报告在整个微调过程中(包括偏好对齐)使用了 1,000 万个样本。在开源社区中,OpenHermes 和 Dolphin 等模型使用大约一百万个样本。基于这些微调的质量,我们建议创建一个良好的通用指令模型至少需要一百万个样本。另一方面,针对特定目的微调的模型需要更少的样本。在这里,我们将特定任务模型与特定领域模型区分开来。
特定任务和特定领域的模型代表了微调 LLM 的两种不同方法。特定任务模型旨在在特定功能上表现出色,例如翻译、摘要或情感分析。这些模型通过在单一任务上采用专注的训练方法而受益,即使在较小的模型尺寸(通常小于 80 亿参数)下也能实现高效性能。特定任务微调所需的数据通常更容易管理,从 100 到 10 万个样本不等。这使得特定任务微调成为许多资源可能有限的应用的吸引人选择。
另一方面,特定领域的模型旨在通过专业知识以及对该领域词汇和语言模式的熟悉来调整 LLM。这些模型在医学、法律、金融、电子商务、工程和酒店业等领域非常有价值。特定领域微调的数据需求可能因领域的复杂性和广度而大相径庭。一些领域,如医学或法律,可能需要与通用目的微调一样多的数据,因为它们拥有庞大的技术语料库。其他领域,如电子商务或酒店业,可能需要更少的样本,这与特定任务的微调更为一致。
决定特定领域模型数据需求的关键因素是领域的“大小”(即其专业知识和词汇的范围)以及该领域在模型预训练数据中的表示。在原始训练数据中表现良好的领域可能需要较少的微调,而那些更加专业或代表性不足的领域可能需要更广泛的数据集。即使对于开源的 LLM,许多预训练数据集仍然是封闭的,这需要做出有根据的猜测来确定其组成(例如,30%的代码或 20%的数学)。
数据整理
当涉及到获取微调数据时,针对特定任务和特定领域的模型的方法不同。对于特定任务的模型,数据整理通常涉及从现有数据集中收集所需任务的示例或创建新的数据集。这可能包括为摘要模型收集原始文本和摘要文本的配对,或者为翻译模型收集不同语言的句子。
特定领域的数据整理可能更具挑战性。它通常需要与领域专家合作收集和验证相关文本、研究论文、技术文档和其他特定领域内容。在某些情况下,可能涉及与拥有大量专业信息库的组织或机构合作。这些数据的质量和相关性至关重要,因为它直接影响模型在目标领域理解和生成内容的能力。
值得注意的是,少样本提示已成为微调的替代策略,尤其是在特定任务应用中。这种方法通过在输入提示中提供所需任务的几个示例来利用大型、强大的模型的能力。虽然它不能在所有场景下替代微调(例如,当你想学习一个新领域时),但少样本提示可以是一种在没有需要大量额外训练的情况下适应新任务的效率方法。
在实践中,特定任务模型和特定领域模型之间的界限有时会变得模糊。例如,一个针对医疗诊断微调的模型可以被认为是既特定于任务(专注于诊断)又特定于领域(专门化于医学知识)。关键是理解微调过程的主要目标,并相应地调整方法。
在这个处理阶段,我们应该有一组适合我们用例的数据集。下一步是通过基于规则的过滤、数据重复、数据净化和数据质量评估来提高样本的质量。
基于规则的过滤
基于规则的过滤是一种系统化的数据质量控制方法,它依赖于明确的、预定义的规则来评估和过滤数据样本。这些规则通常旨在解决常见的质量问题,范围从简单的检查到更复杂的逻辑运算。基于规则过滤的主要目标是通过对不符合特定标准的数据样本进行移除,保持高标准的数据质量。
长度过滤是一种简单而有效的基于规则的过滤技术。这种方法涉及为数据集中可接受响应长度设置阈值。极短的响应通常缺乏足够的信息以有意义,而过长的响应可能包含无关或冗余的内容。重要的是要注意,适当的长度阈值可能因具体任务和领域而显著不同。例如,用于生成简洁摘要的数据集可能比用于详细解释的数据集具有更低的最高阈值。
关键词排除是另一种强大的基于规则的过滤技术,它关注的是样本的内容而不是其结构。这种方法涉及创建一个与低质量或不适当内容相关的关键词或短语列表,然后过滤掉包含这些术语的任何样本。关键词列表可以包括明显的低质量指标,如粗话或与垃圾邮件相关的术语,以及可能表明无关或不相关内容的特定领域词汇。例如,在一个专业写作助手的数据集中,你可能需要排除包含俚语或不符合预期语气和风格的非正式表达的样本。
对于包含结构化数据或遵循特定格式要求的数据集,格式检查是推荐的。这项技术确保所有样本都遵循预期的格式,保持一致性并便于后续处理。对于包含代码样本、JSON 结构或其他格式化文本的数据集,格式检查尤为重要。例如,在一个包含编程指令和解决方案的数据集中,你可能实施规则来验证代码样本在语法上是正确的,并遵循指定的样式指南。
基于规则的过滤在准备指令数据集方面具有显著优势。其速度和效率允许快速应用于大量数据,使其具有高度可扩展性。规则应用的统一性确保了对数据的统一处理,减少了人为错误和偏差。此外,过滤标准的明确定义提供了透明度和可解释性,便于理解、审计和调整。能够自动化基于规则的过滤减少了手动干预的需求,并使持续监控数据质量成为可能。
然而,基于规则的过滤也存在必须考虑的限制。预定义的规则可能缺乏捕捉语言和语境完整复杂性的细微差别,可能导致移除有效但异常的样本。规则通常的二进制性质(通过/失败)可能并不总是与语言和指令质量的细微性质相一致。此外,随着数据模式和品质标准的演变,规则需要定期审查和更新以保持有效性。还存在风险,即设计不良的规则可能会无意中引入或放大数据集中的偏差。
数据去重
数据集多样性对于训练能够良好泛化到新、未见数据集的模型至关重要。当数据集中包含重复或近似重复的数据时,可能会导致以下问题:
-
过度拟合:模型可能会记住特定的例子,而不是学习一般模式。
-
偏差性能:过度代表的数据点可能会使模型性能偏向某些类型的输入。
-
训练效率低下:冗余数据可能会增加训练时间,而不会提供额外的有价值信息。
-
评估指标膨胀:测试集中的重复数据可能会导致过于乐观的性能估计。
为了去重数据集,我们区分精确和模糊去重。精确去重通过涉及数据归一化、哈希生成和重复项删除的简单过程来删除相同的样本。数据归一化标准化条目的格式,例如将文本转换为小写。然后,哈希生成使用 MD5 或 SHA-256 等算法为每个条目创建唯一的哈希值。这些哈希值被比较以找到匹配项,并删除重复项,只留下每个的唯一实例。虽然对于相同条目有效,但精确去重无法检测近重复项或语义相似内容,需要更高级的技术来处理这些情况。
最流行的模糊去重方法是 MinHash 去重。与其他模糊技术相比,它在显著降低计算复杂性的同时保持了高精度。MinHash 通过为每个数据项生成紧凑的表示或签名来操作。这些签名作为指纹,捕捉数据的本质,同时极大地降低了其维度。在实践中,MinHash 将数据项(如文本文档)转换为 shingles 集合,对这些集合应用多个哈希函数,并选择最小哈希值来形成签名向量。然后可以使用 Jaccard 相似度等相似性度量来比较这些签名,从而有效地识别近重复项。
除了精确和模糊去重之外,语义相似性通过关注文本的意义来采取不同的去重方法。这种方法涉及使用各种自然语言处理技术将单词或整个样本转换为向量表示。例如,Word2Vec、GloVe 和 FastText 等词嵌入模型将单个单词转换为密集向量,捕捉语义关系。
为了获得更具上下文感知的表示,BERT、句子转换器或交叉编码器等语言模型可以生成整个句子或文档的嵌入。一旦获得这些向量表示,就可以通过比较向量之间的相似性来进行去重。常见的相似性度量包括余弦相似度或欧几里得距离。相似度得分高于预定义阈值的样本可以被认为是重复的。对于大型数据集,可以应用聚类技术来分组相似的向量。例如,K-means、DBSCAN 或层次聚类等方法可以有效地组织向量空间,从而识别出代表语义相似内容的簇。在每个簇中,可以保留一个代表性样本,而其他样本则被标记为重复。
数据净化
数据去污是确保训练数据集不包含与评估或测试集中相同或高度相似的样本的过程。这一步骤对于确保模型评估的质量以及防止过拟合或测试数据的记忆化至关重要。
数据去污采用数据去重技术。可以使用精确匹配来移除任何与评估集中相同的训练样本。这可以通过哈希函数或直接字符串比较来完成。接下来,我们还可以使用近似重复检测方法来识别和移除与评估样本非常相似的训练样本,即使它们并不完全相同。这通常涉及像 MinHash 或基于 n-gram 或嵌入计算相似度分数的技术。
执行数据去污的一个简单方法是在数据去重阶段将您的评估集添加到指令数据集中。在这种情况下,我们希望确保我们只从指令数据集中移除样本,这可以通过不同的方式实现(仅过滤掉第一个重复项、记录评估样本的索引等)。理想情况下,您可以在数据去重阶段自动添加您的评估集,以完全自动化此过程。如果您迭代多个自定义基准版本,这将特别高效。
数据去污的另一个方面是过滤掉可能源自与评估数据相同来源的样本。这可能包括检查重叠的短语、相似的句子结构或共同的元数据。从业者还可能使用来源跟踪(追踪他们使用的数据来源)来识别和排除已知用于评估集的特定来源的数据。
数据质量评估
数据质量评估是机器学习的一个关键方面,尤其是对于 LLMs。这个过程涉及评估数据集的各种特征,包括准确性、多样性和复杂性。虽然像数学精度这样的方面可以通过 Python 解释器等工具轻松验证,但评估主观或开放式内容仍然具有挑战性。
数据质量评估的传统方法包括人工标注,这通常提供高精度,但资源密集。为了解决可扩展性问题,已经开发出机器学习技术来自动化评估过程。这包括使用 LLMs 作为评判者、奖励模型以及用于质量预测的经过训练的分类器。
LLM-as-a-judge策略涉及提示 LLM 评估每个样本的质量。这种方法因其灵活性和易用性而变得流行,尽管它确实带来了一些挑战。不同的 LLM 在任务上的表现水平不同,它们的评估通常与非专家的评估更为接近。对于特定领域的数据集,您可能希望使用特定领域的模型而不是更好的通用 LLM。比较评估方法(例如,“答案 A 是否比答案 B 更好?”)通常优于绝对评分方法(例如,“对答案 A 进行 1 到 4 的评分”),尽管两者都可以通过足够的提示工程进行大规模使用。我们建议在代表性子集上迭代不同的提示,以手动验证响应的质量。表 5.2展示了为裁判 LLM 定制的提示示例。
| 指令您是一位数据质量评估员。您的目标是评估一个指令及其相应的答案,确定答案在多大程度上解决了给定任务。在您的评估中,您将提供反馈,详细说明答案的优点和缺点,然后给出 1 到 4 分的评分。1 分表示答案糟糕且与指令无关。2 分表示答案无帮助且遗漏了指令的重要方面。3 分表示答案有帮助,但在相关性、准确性和深度方面可以改进。4 分表示答案优秀且完全解决了任务。请按照以下格式提供您的评估:反馈:(您认为相关的优点和缺点)评分:(1 到 4 之间的数字) |
| --- |
表 5.2 – LLM-as-a-judge 用于数据质量评估的示例提示
LLM-as-a-judge(作为裁判的 LLM)已知存在几个偏见。首先,它在比较评分中存在立场偏见,即 LLM 裁判倾向于第一个答案。这可以通过随机化答案 A 和 B 的顺序来解决。此外,像人类一样,LLM 裁判倾向于长答案。可以通过应用长度归一化技术来缓解这个问题。最后,LLM 裁判存在模型内偏好,意味着他们更喜欢同一家族的模型(例如,GPT-4o 与 GPT-4 和 GPT-4o mini)。这可以通过使用多个模型而不是单个模型来解决。
通常,为了提高评估可靠性,使用多个 LLM 作为陪审团等策略可以减少偏见并提高一致性。利用较小 LLM 的陪审团还可以降低成本,同时提高准确性和减轻模型内偏好。对于像聊天机器人这样的特定应用,建议 LLM 裁判和人类评估员之间达到高一致性(大约 80%)。简单的评分标准(带有少量提示)和特定任务的基准也是推荐的,以确保相关和可解释的评估。
奖励模型是另一种将 LLM 重新用于数据质量评估的方法。术语“奖励模型”来自人类反馈的强化学习(RLHF,见第六章)。它们可以被广泛定义为接受指令和答案对并返回分数作为输出的模型。通常,奖励模型是在 Gemma 或 Llama 这样的仅解码器架构之上添加一个线性头创建的。然后,它们为此特定目的进行训练,使用强化学习或传统的微调。图 5.3显示了 ArmoRM-Llama3-8B-v0.1 的架构,该架构在 Llama 3 8B 模型之上添加了回归和门控层。该模型输出多个分数以针对特定维度,如有用性、正确性、连贯性、复杂性和冗长性。这允许对数据质量评估采取更细致的方法。

图 5.3 – RLHFlow/ArmoRM-Llama3-8B-v0.1 的架构,基于 Llama 3(来源:doi.org/10.48550/arXiv.2406.12845)
由 Hugging Face(allenai/reward-bench)托管的艾伦人工智能研究所的 RewardBench 排行榜是一个比较不同奖励模型的良好资源。它结合了各种类型的奖励模型(生成式、分类器、DPO 等),并在每个指令的精选和拒绝的答案集上对它们进行评估。虽然这项任务与指令数据质量没有直接关系,但它是一个寻找能够区分好答案和坏答案的模型的良好资源。
分类器或仅编码器模型可以被训练以执行数据质量评估。一个很好的例子是 HuggingFaceFW/fineweb-edu-classifier,这是一个旨在判断网页教育价值的分类器。该模型被设计为预训练数据的质量过滤器,但可以采用类似的方法来评估大规模的指令样本。在实践中,fineweb-edu-classifier 向一个嵌入模型(Snowflake/snowflake-arctic-embed-m)添加了一个分类头,并在由 Llama 3 70B Instruct 标注的 450,000 个样本上训练了 20 个 epoch。
这种方法依赖于仅编码器模型,这些模型既更小,又更适合分类任务。由于它们的参数数量较少,这些模型运行速度更快,可以扩展到数百万个样本。然而,它们的准确性不如更大的模型,尤其是在需要捕捉细微差别的高级推理任务中。在小规模上,仅编码器模型仍然有价值,用于过滤异常值或作为自动化数据管道的一部分,这需要更快的处理速度。
数据探索
数据探索是一个持续的过程,需要从业者熟悉训练数据。它涉及手动检查和自动化分析,每个都在理解数据集的特征、优势和潜在不足方面发挥着关键作用。
手动数据集探索,尽管耗时,但是一个重要的步骤。它揭示了自动化过程可能遗漏的错误和不一致性,包括格式问题、数据输入错误、不连贯的推理和事实不准确。这个过程为数据集的内容和风格提供了定性的洞察。为了提高效率,研究人员可以采用分层抽样(选择多样化的样本)、系统回顾(使用标准清单)和协作回顾(涉及多个审阅者)等技术。
图 5.4展示了使用 Argilla(一个用于手动数据质量评估和探索的协作平台)的示例。

图5.4 – Argilla 的协作数据质量评估和探索界面
统计分析是一种补充技术,可以揭示词汇多样性、潜在偏见和概念表示。这个过程利用自然语言处理库如 NLTK 或 spaCy 进行分词和分析大量文本。可视化工具如 Matplotlib 或 Seaborn 创建直方图和词云,使直观的图案识别成为可能。这些技术提供了对数据集组成、语言广度和可能的文化或情境偏好的洞察,这些可能影响模型输出。
主题聚类自动将相似的文档或文本片段分组在一起,揭示数据中的潜在主题和模式。这个过程对于理解大型文本语料库的内容、识别趋势和以有意义的方式组织信息尤为重要。它通常与数据可视化相关联,其中包含显示相似样本聚类的图形。
让我们考虑构建关于各种编程语言的指令数据集的任务。您已经从在线论坛、文档和教程中收集了大量与编程相关的文本。首先,主题聚类可以帮助识别数据集中存在的不同编程语言(Python、JavaScript 等)。其次,在每种语言的聚类内部,您可以进一步识别如错误处理、数据结构和网络框架等子主题。这允许在语料库中对每种语言和子主题进行平衡表示。
这确保了每个主题在每个编程语言中都被正确覆盖。

图5.5 – 使用 Nomic Atlas 制作的 TikTok 历史数据集的表示
有多种工具可用于执行主题聚类,每种工具都有其自身的优势和途径。例如,Hugging Face 的 text-clustering 提供了一个简单的管道,使用 sentence transformers 将文本嵌入到向量空间,UMAP 进行降维,以及 DBSCAN 进行聚类。它还可以使用 LLM 自动标记聚类,并可以输出可视化。Nomic Atlas(见图 5.5)、BunkaTopics 和 Lilac 是提出类似方法并增加额外功能的替代方案。
数据生成
当可用的指令数据集不足时,创建自定义数据变得必要。这对于公开数据稀缺的专业应用尤其相关。
此外,它还作为一种方法来增强数据集中代表性不足的区域,例如在我们之前的例子中 JavaScript 错误处理技术的示例不足。虽然数据可以通过个人手动生成或通过众包生成,但这些方法通常会产生显著的成本和时间投入。使用 LLM 生成合成数据提供了一种更高效和可扩展的替代方案。这种方法与精心设计的提示工程相结合,可以在更大的规模上生成高质量的数据,有效地解决手动数据创建过程的局限性。
合成数据生成的过程通常从准备一组精心设计的提示(有时称为分类法)开始。这些提示是生成新、多样化示例的基础。原始 Alpaca 数据集中使用的五个种子提示可以在表 5.3中看到。合成生成数据的质量在很大程度上取决于生成过程中使用的提示和技术。精心设计的提示可以引导语言模型产生多样化、相关且高质量的操作-响应对。这些提示通常包括具体的指令、示例和约束,以确保生成的数据与期望的格式和内容一致。
| 种子指令
-
我早餐能吃什么,不包含鸡蛋,但包含蛋白质,并且大约有 700-1000 卡路里?
-
给定的成对之间有什么关系?输入:Night : Day :: Right : Left
-
为以下每个人生成一句描述。输入:-Barack Obama\n- Elon Musk\n- Taylor Swift
-
描述一个给定刻板印象可能对你造成伤害的情况。输入:所有亚洲人都很聪明!
-
为以下电子邮件生成一个合适的标题:输入:“Hi [person name],\n\n 我写信是想问你是否愿意成为我们 CVPR 多模态研讨会的一名评审。研讨会将于 2023 年 6 月 20 日举行。\n\n 最好的问候,\n[my name]”
|
表 5.3 – 原始 Alpaca 数据集中使用的种子提示示例
许多合成数据生成流程包含多个步骤以确保数据质量。这可能包括生成一组初始问题或指令,然后生成相应的答案或响应。一些系统还实施了验证步骤,其中另一个模型或一组规则检查生成的对是否准确、相关以及是否符合指定标准。
合成数据生成的一个重要方面是能够控制生成数据的各种属性。这包括诸如指令的复杂性、响应的长度、使用的语言语气或风格以及特定主题或领域等因素。通过微调这些参数,可以创建针对特定训练目标定制的数据集,或者以有针对性的方式补充现有数据集。使用如 Outlines 等库进行结构化生成也有助于遵守特定格式。
此外,合成数据生成在解决现有数据集的偏差和空白方面特别有用。通过精心设计生成过程,可以创建更加平衡和包容的数据集,这些数据集代表了更广泛的视角、主题和语言风格。这有助于训练更加公平且能够服务于多样化用户群体的 LLM。
然而,合成数据生成也面临着挑战。一个主要问题是生成的数据可能会继承用于生成的底层语言模型的偏差或错误。为了减轻这一问题,许多方法都采用了人工监督、多样化的提示和额外的过滤机制,以确保生成数据的质量和适宜性。
另一个考虑因素是生成数据需要足够多样化和具有挑战性。如果合成数据过于简单或重复,可能无法提供训练稳健 LLM 所需的复杂度。合成数据生成的高级技术通常专注于创建多样化的、细微的指令-响应对,以推动模型学习边界的扩展。
数据增强
在这个背景下,数据增强指的是增加数据样本数量和质量的过程。与数据生成不同,在这个阶段我们使用预存在的指令样本作为输入。虽然可以增加指令和答案对的样本数量,但数据增强主要用于提高现有样本的质量。特别是,它关注两个方面:多样性和复杂性。
该领域的开创性方法是 Evol-Instruct 方法,它使用 LLM 将简单的指令进化为更高质量的指令。然后可以使用这些进化后的指令通过强大的 LLM 生成答案。这种方法采用两种主要策略:深度和广度进化。
深度演化专注于提高现有指令的复杂性。它包括几种技术:
-
约束:这涉及到引入额外的要求或限制到原始指令中,使其更具挑战性。
-
深化:它试图寻找更深入的问题,需要更全面的回答,而不是浅层的问题。
-
具体化:它用更具体的概念替换一般概念,为指令添加细节和精确度。
-
增加推理步骤:它修改指令以明确请求多步推理,促进更复杂的解决问题。
-
复杂化输入:这涉及到向指令中添加更复杂的数据格式或结构,例如 XML、JSON 或代码片段。
广度演化,另一方面,旨在扩展指令数据集的多样性。它通过现有指令的灵感生成全新的指令,专注于在同一领域内创建更多罕见或长尾的示例。
作为具体实现的例子,深度演化可以通过以下来自 AutoEvol 论文的提示自动化。你只需提供要演化的指令作为输入,一个强大的模型如 GPT-4o 将返回一个更复杂的原始指令版本。
| 你是一个指令重写者,将给定的#指令#重写为一个更复杂的版本。请按照以下步骤将给定的“#指令#”重写为一个更复杂的版本。
-
第 1 步:请仔细阅读“#指令#”,并列出所有可能的使该指令更复杂的方法(使它对像 ChatGPT 和 GPT4 这样的知名 AI 助手来说更具挑战性)。请勿提供方法
-
改变指令的语言!
-
第 2 步:请根据第 1 步生成的#方法列表#制定一个全面的计划,使#指令#更加复杂。该计划应包括#方法列表#中的几个方法。
-
第 3 步:请逐步执行计划并提供#重写指令#。#重写指令#只能将 10 到 20 个单词添加到“#指令#”中。
-
第 4 步:请仔细审查#重写指令#,并识别任何不合理的地方。确保#重写指令#只是#指令#的一个更复杂的版本。只需提供#最终重写指令#,无需任何解释。
请严格按照以下格式回复:Step 1 #方法列表#:Step 2 #计划#:Step 3 #重写指令#:Step 4 #最终重写指令#:#指令#:{Instruction} |
表 5.4 – 来自 Zeng 等人(2024 年)“为大型语言模型自动指令演化”论文的 Evol LLM 提示
超反馈方法是另一种创新方法,它专注于答案质量而不是指令质量。它使用 AI 反馈来提高模型响应的质量和多样性。与 Evol-Instruct 不同,后者演进指令,UltraFeedback 使用大量多样化的指令和模型来生成广泛范围的响应。
然后,它利用像 GPT-4 这样的高级语言模型,从多个维度如指令遵循、真实性、诚实性和有用性等方面,为这些响应提供详细的批评和数值评分。
基于这些想法,你可以创建自己的增强技术,以创建更具挑战性和多样化的指令数据集。通过改进和演进现有的指令和答案,生成的数据集可以更好地训练模型以处理复杂的多步骤任务,并提高它们在更广泛的应用范围内的性能。
创建我们自己的指令数据集
在本节中,我们将根据从第三章爬取的数据创建自己的指令数据集。为了创建高质量的指令数据集,我们需要解决两个主要问题:我们数据的不结构化性质以及我们能够爬取的文章数量有限。
这种不结构化性质源于我们处理的是原始文本(文章),而不是指令和答案的配对。为了解决这个问题,我们将使用一个大型语言模型(LLM)来完成这个转换。具体来说,我们将采用回译和改写相结合的方法。回译是指提供预期的答案作为输出并生成相应的指令。然而,使用一段文本,如一个段落作为答案可能并不总是合适的。这就是为什么我们想要改写原始文本,以确保我们输出的答案是格式正确、高质量的。此外,我们可以要求模型遵循作者的写作风格,以保持与原始段落的接近。虽然这个过程涉及大量的提示工程,但它可以自动化并大规模使用,正如我们将在以下实现中看到的那样。
我们关于样本数量有限的第二个问题在现实世界的用例中相当常见。我们能检索到的文章数量有限,这限制了我们可以创建的指令数据集的大小。在这个例子中,我们拥有的样本越多,模型在模仿原始作者方面的表现就越好。为了解决这个问题,我们将文章分成块,并为每个块生成三组指令-答案对。这将增加我们创建的样本数量,同时保持最终数据集的多样性。为了简单起见,我们将使用 OpenAI 的 GPT-4o-mini 模型来完成这项工作,但你也可以使用开源模型。
然而,当涉及到生成结构化输出时,LLM 并不可靠。即使给出了特定的模板或指令,也无法保证模型会始终遵循它们。这种不一致性通常需要额外的字符串解析,以确保输出符合所需的格式。
为了简化这个过程并确保结果结构良好,我们可以采用结构化生成技术。结构化生成是一种有效的方法,可以迫使大型语言模型(LLM)遵循预定义的模板,例如 JSON、pydantic 类或正则表达式。在下面的内容中,我们将使用 OpenAI 的 JSON 模式功能,它提供了一种更健壮的方式来返回有效的 JSON 对象,并减少了对大量后处理的依赖。
根据这个描述,以下图总结了我们要构建的合成数据管道的每个步骤。

图 5.6 – 从原始文本到指令数据集的合成数据生成管道
现在我们将其在 Python 中实现。您可以将它作为 LLMOps 管道的一部分实现,或者作为一个独立的脚本:
-
我们想要确保以下库已安装。OpenAI 库将使我们能够与模型交互以生成指令数据,并将数据集格式化为 Hugging Face 兼容的格式。tqdm 库已安装,以便在数据生成过程中可视化进度。
openai==1.37.1 datasets==2.20.0 tqdm==4.66.4 -
我们按照以下方式导入所有必需的库。
import concurrent.futures import json import random import re from concurrent.futures import ThreadPoolExecutor from typing import List, Tuple from datasets import Dataset from openai import OpenAI from pydantic import BaseModel, Field from tqdm.auto import tqdm -
我们拥有的原始数据是一个 JSON 文件。我们通过从每篇文章中提取特定字段(
id、content、platform、author_id、author name和link)来从该 JSON 文件创建一个 Hugging Face 数据集。def load_articles_from_json(file_path: str) -> Dataset: with open(file_path, "r") as file: data = json.load(file) return Dataset.from_dict( { "id": [item["id"] for item in data["artifact_data"]], "content": [item["content"] for item in data["artifact_data"]], "platform": [item["platform"] for item in data["artifact_data"]], "author_id": [item["author_id"] for item in data["artifact_data"]], "author_full_name": [item["author_full_name"] for item in data["artifact_data"]], "link": [item["link"] for item in data["artifact_data"]], } )
如果我们简单地将数据集作为 pandas 数据框加载,它将返回以下表格。
| | id | content | platform | author_id | author_full_name | link |
| --- | --- | --- | --- | --- | --- | --- |
| 0 | ab2f9e2e-5459-4dd6-97d6-c291de4a7093 | The Importance of Data Pipelines in the Era of... | medium | e6b945ba-6a9a-4cde-b2bf-0890af79732b | Alex Vesa | medium.com/decodingml/the-importance-o... |
| 1 | ccfe70f3-d324-40b6-ba38-86e72786dcf4 | Change Data Capture: Enabling Event-Driven Arc... | medium | e6b945ba-6a9a-4cde-b2bf-0890af79732b | Alex Vesa | medium.com/decodingml/the-3nd-out-of-1... |
| 2 | 4c9f68ae-ec8b-4534-8ad5-92372bf8bb37 | The Role of Feature Stores in Fine-Tuning LLMs... | medium | e6b945ba-6a9a-4cde-b2bf-0890af79732b | Alex Vesa | medium.com/decodingml/the-role-of-feat... |
| ... | ... | ... | ... | ... | ... | ... |
| 73 | 68795a4d-26c2-43b7-9900-739a80b9b7dc | DML: 训练 LLM 你必须知道的 4 个关键想法... | decodingml.substack.com | 1519b1d1-1a5d-444c-a880-926c9eb6539e | Paul Iusztin | decodingml.substack.com/p/dml-4-key-id... |
| 74 | d91b17c0-05d8-4838-bf61-e2abc1573622 | DML: 如何添加实时监控与指标... | decodingml.substack.com | 1519b1d1-1a5d-444c-a880-926c9eb6539e | Paul Iusztin | https://decodingml.substack.com/p/dml-how-to-a... |
| 75 | dcf55b28-2814-4480-a18b-a77d01d44f5f | DML: 你必须知道的 6 大 ML 平台特性... | decodingml.substack.com | 1519b1d1-1a5d-444c-a880-926c9eb6539e | Paul Iusztin | decodingml.substack.com/p/dml-top-6-ml... |
- 如果我们进一步检查一些文章的内容,我们会意识到其中一些文章包含特殊字符和多余的空白。我们可以使用简单的正则表达式来清理这些内容。
首先,我们使用[^\w\s.,!?']来移除非字母数字字符(除了撇号、句号、逗号、感叹号和问号)。然后,我们使用\s+将多个连续的空白字符替换为单个空格。
最后,我们实现strip()来移除任何前导或尾随的空白。
def clean_text(text):
text = re.sub(r"[^\w\s.,!?']", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
- 现在我们能够加载我们的文章,在将它们转换成指令和答案的对之前,我们需要对它们进行分块。理想情况下,您会希望使用标题或段落来产生语义上有意义的分块。
然而,在我们的示例中,就像现实世界一样,原始数据往往很杂乱。由于格式不正确,我们无法从原始数据集中的每一篇文章中提取段落或标题。相反,我们将使用正则表达式提取句子,以获取 1,000 到 2,000 个字符的块。这个数字可以根据文本中包含的信息密度进行优化。
extract_substrings 函数通过首先清理文本然后使用正则表达式将其分割成句子来处理数据集中的每一篇文章。然后,通过连接这些句子来构建文本块,直到每个块的字数在 1,000 到 2,000 个字符之间。
def extract_substrings(dataset: Dataset, min_length: int = 1000, max_length: int = 2000) -> List[str]:
extracts = []
sentence_pattern = r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s"
for article in dataset["content"]:
cleaned_article = clean_text(article)
sentences = re.split(sentence_pattern, cleaned_article)
current_chunk = ""
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
if len(current_chunk) + len(sentence) <= max_length:
current_chunk += sentence + " "
else:
if len(current_chunk) >= min_length:
extracts.append(current_chunk.strip())
current_chunk = sentence + " "
if len(current_chunk) >= min_length:
extracts.append(current_chunk.strip())
return extracts
-
接下来,我们想要从提取的文本块中创建指令-答案对。为了有效地管理这些对,我们引入了
InstructionAnswerSet类。这个类允许我们直接从 JSON 字符串创建实例,这在解析来自 OpenAI API 的输出时非常有用。class InstructionAnswerSet: def __init__(self, pairs: List[Tuple[str, str]]): self.pairs = pairs @classmethod def from_json(cls, json_str: str) -> 'InstructionAnswerSet': data = json.loads(json_str) pairs = [(pair['instruction'], pair['answer']) for pair in data['instruction_answer_pairs']] return cls(pairs) def __iter__(self): return iter(self.pairs) -
现在我们已经从文章中提取了一组长度合理的文本块,我们可以使用 LLM 将它们转换成指令和答案的对。请注意,这一步是模型无关的,可以使用任何开源或闭源模型实现。因为这个输出基于我们提供的上下文,所以它不需要复杂的推理或高性能的模型。
为了方便起见,我们将在这个例子中使用 GPT-4o mini。这个选择是基于该模型低成本和高性能的动机。提示工程是数据转换阶段最重要的方面,需要多次迭代以产生预期的输出。我们建议从简单的提示开始,并在需要更精确、修改风格或输出多个响应时增加复杂性。
在我们的例子中,我们想要创建像“关于 X 主题写一段段落”这样的指令和相应的、事实性且模仿作者风格的答案。为了实现这一点,我们需要提供一个将使模型响应有根据的摘录。为了提高效率,我们还选择为每个摘录生成五个指令-答案对。以下是我们的指令生成函数的开始,包括我们的提示。
def generate_instruction_answer_pairs(
extract: str, client: OpenAI
) -> List[Tuple[str, str]]:
prompt = f"""Based on the following extract, generate five instruction-answer pairs. Each instruction \
must ask to write about a specific topic contained in the context. each answer \
must provide a relevant paragraph based on the information found in the \
context. Only use concepts from the context to generate the instructions. \
Instructions must never explicitly mention a context, a system, a course, or an extract. \
Instructions must be self-contained and general. \
Answers must imitate the writing style of the context. \
Example instruction: Explain the concept of an LLM Twin. \
Example answer: An LLM Twin is essentially an AI character that mimics your writing style, personality, and voice. \
It's designed to write just like you by incorporating these elements into a language model. \
The idea is to create a digital replica of your writing habits using advanced AI techniques. \
Provide your response in JSON format with the following structure:
{{
"instruction_answer_pairs": [
{{"instruction": "...", "answer": "..."}},
...
]
}}
Extract:
{extract}
"""
- 除了用户提示外,我们还可以指定一个系统提示来引导模型生成预期的指令。在这里,我们在系统提示中重复我们的高级任务。
系统提示和用户提示的连接被输入到 OpenAI API 中,使用 GPT-4o mini 模型以 JSON 模式和最多 1,200 个标记的答案。我们还使用标准的 0.7 温度来鼓励多样化的响应。生成的文本直接使用 InstructionAnswerSet 类进行解析,以返回指令和答案的对。
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system", "content": "You are a helpful assistant who \
generates instruction-answer pairs based on the given context. \
Provide your response in JSON format.",
},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
max_tokens=1200,
temperature=0.7,
)
# Parse the structured output
result = InstructionAnswerSet.from_json(completion.choices[0].message.content)
# Convert to list of tuples
return result.pairs
- 让我们创建一个主函数来自动化这个过程。它从输入数据集中提取子字符串,然后通过 Python 的
ThreadPoolExecutor进行并发处理,以高效地为每个摘录生成指令-答案对。
我们使用默认的 max_workers 值为 4,因为更高的值往往会超过 OpenAI 的速率限制,可能导致 API 请求失败或限制。
def create_instruction_dataset(
dataset: Dataset, client: OpenAI, num_workers: int = 4
) -> Dataset:
extracts = extract_substrings(dataset)
instruction_answer_pairs = []
with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(generate_instruction_answer_pairs, extract, client)
for extract in extracts
]
for future in tqdm(concurrent.futures.as_completed(futures), total=len(futures)
):
instruction_answer_pairs.extend(future.result())
instructions, answers = zip(*instruction_answer_pairs)
return Dataset.from_dict(
{"instruction": list(instructions), "output": list(answers)}
)
-
我们可以通过调用此函数来创建我们的指令数据集。使用 GPT-4o mini 在原始数据上运行的成本低于 0.5 美元。
-
我们现在可以创建一个主函数来协调整个流程。它加载原始数据,创建指令数据集,将其分为训练集和测试集,并将结果推送到 Hugging Face Hub。
def main(dataset_id: str) -> Dataset: client = OpenAI() # 1\. Load the raw data raw_dataset = load_articles_from_json("cleaned_documents.json") print("Raw dataset:") print(raw_dataset.to_pandas()) # 2\. Create instructiondataset instruction_dataset = create_instruction_dataset(raw_dataset, client) print("Instruction dataset:") print(instruction_dataset.to_pandas()) # 3\. Train/test split and export filtered_dataset = instruction_dataset.train_test_split(test_size=0.1) filtered_dataset.push_to_hub("mlabonne/llmtwin") return filtered_dataset **Dataset({** **features: [****'instruction'****,** **'output'****],** **num_rows:** **3335** **})**
我们通过这个过程获得了 3,335 对数据。您可以在 huggingface.co/datasets/mlabonne/llmtwin 找到我们版本的数据集。Hugging Face Hub 提供了一个方便的数据集查看器(见 图 5.7),用于探索指令和答案,并确保这些样本中没有明显的错误。由于数据集规模较小,没有必要进行全面探索和主题聚类。

图 5.7 – Hugging Face Hub 上的 mlabonne/llmtwin 指令数据集
如前文所述,我们可以通过增加样本的多样性和复杂性来细化这个指令数据集。更高级的提示工程也可以通过提供预期结果的示例来提高生成数据的质量,例如。最后,通过逐个审查,质量评估可以帮助过滤掉低质量样本。为了简洁和简单,我们将保持对这一指令数据集的直接方法,并在创建偏好数据集时探索更高级的方法,如第六章所述。
在下一节中,我们将介绍 SFT 技术以及相关概念。
探索 SFT 及其技术
微调(SFT)包括在由指令和答案对组成的小数据集上重新训练预训练模型。SFT 的目标是将只能进行下一个标记预测的基础模型转变为一个有用的助手,能够回答问题和遵循指令。SFT 还可以用于提高基础模型的一般性能(通用 SFT)、灌输新知识(例如,新语言、领域等)、专注于特定任务、采用特定语气等。
在本节中,我们将讨论何时使用微调,并探讨与存储格式和聊天模板相关的相关概念。最后,我们将介绍三种流行的 SFT 实现方式:全微调、低秩适应(LoRA)和量化感知低秩适应(QLoRA)。
何时进行微调
在大多数情况下,建议先从提示工程开始,而不是直接微调模型。提示工程可以与开放权重或封闭源模型一起使用。通过使用诸如少样本提示或检索增强生成(RAG)等技术,可以有效地解决许多问题,而无需 SFT。提示工程还允许我们构建一个健壮的评估流程,该流程不仅测量准确性等指标,还测量成本和延迟。如果这些结果不符合要求,我们可以探索创建指令数据集的可能性,如前文所述。如果数据足够,微调就成为一个选项。

图 5.8 – 技术层面确定何时进行微调的基本流程图
除了这些技术考虑因素之外,SFT 在控制(“了解你的数据”)和可定制性(微调模型是独特的)方面满足了常见需求。与围绕聊天机器人构建应用程序相比,微调允许开发者与 LLMs 创建更多样化的交互,如工具分析、监管和附加上下文。请注意,如果我们在这本书中关注开放权重模型,几个 LLM 提供商提供自动微调服务。虽然它们提供的控制度和可定制性不如管理自己的微调管道,但在特定场景下(例如,机器学习工程资源有限)这可能是一个有趣的权衡(例如)。
尽管有这些优势,微调也有局限性。通常认为 SFT 利用基础模型权重中的现有知识,并重新聚焦参数以实现特定目的。这有几个影响。首先,与预训练集中学习到的知识过于遥远(如未知或罕见语言)的知识可能难以有效学习。
更糟糕的是,一项研究表明,在新知识上微调模型可能导致更频繁的幻觉。根据使用的 SFT 技术,我们还面临删除在基础模型中存在的知识的风险(这是一个常见的问题,被称为“灾难性遗忘”)。
指令数据集格式
指令数据集以特定格式存储,以组织指令和答案。通常,数据集中的每个样本都可以表示为一个 Python 字典,其中键是提示类型,如system、instruction、output,而值对应实际文本。最标准的三个格式是 Alpaca、ShareGPT 和 OpenAI。以下表格显示了这些数据格式通常是如何组织的。
| 名称 | JSONL 格式 |
| --- | --- |
| Alpaca | {“instruction”: “...”, “input”: “...”, “output”: “...”}{“instruction”: “...”, “output”: “...”} |
| ShareGPT | {“conversations”: [{“from”: “...”, “value”: “...”}, …]} |
| OpenAI | {“conversations”: [{“role”: “...”, “content”: “...”}, …]} |
| OASST | {“INSTRUCTION”: “...”, “RESPONSE”: “...”} |
| 原始文本 | {“text”: “...”} |
表 5.5 – 指令数据存储格式的示例
注意,对于 Alpaca,input键是可选的。当input键存在时,其内容仅附加到instruction键的内容中。我们还添加了raw text数据格式,以表明 SFT 本质上并不与预训练不同。如果你选择在原始文本上重新训练模型,这通常被称为“持续预训练”的微调类型。
在上一节中我们创建的数据集有两列(“指令" 和 “输出")并对应于 Alpaca 格式。Alpaca 对于单轮指令和答案来说是足够的,这意味着它仅限于一个指令和一个答案。当您想要处理对话(多个指令和答案)时,ShareGPT 或 OpenAI 这样的格式更适合。通过将每条消息作为列表中的字典存储,它们可以在每个样本中表示任意长度的对话。
单轮和多轮对话的选择直接影响到存储类型,并取决于最终用途。
聊天模板
一旦从数据集格式中解析出指令-答案对,我们希望以聊天模板的形式组织它们。聊天模板为向模型展示指令和答案提供了一种统一的方式。
通常,它们还包括特殊的标记来识别消息的开始和结束,或者识别消息的作者。由于基础模型不是设计来遵循指令的,它们没有聊天模板。这意味着当您微调一个基础模型时,您可以选择任何模板。如果您想微调一个指令模型(不推荐),您需要使用相同的模板,否则可能会降低您的性能。
与指令数据集格式类似,存在不同的聊天模板:ChatML、Llama 3、Mistral 以及许多其他模板。在开源社区中,ChatML 模板(最初来自 OpenAI)是一个流行的选择。它只是简单地添加了两个特殊标记 (<|im_start|> 和 <|im_end|>) 来指示说话者是谁。为了给您一个例子,以下是当我们将 ChatML 模板应用于 表 5.1 中显示的指令-答案对时得到的结果:
|
<|im_start|>system
You are a helpful assistant, who always provide explanation. Think like you are answering to a five year old.<|im_end|>
<|im_start|>user
Concepts: building, shop, town
Write a sentence that includes all these words.<|im_end|>
<|im_start|>assistant
In our little town, there is a shop inside a big building where people go to buy their favorite toys and candies.<|im_end|>
|
表 5.6 – 使用 ChatML 聊天模板从 表 5.1 中提取的样本
如您所见,我们仍然有三个不同的部分:系统、用户和助手。每个部分都以 <|im_start|> 标记开始,以 <|im_end|>. 标记结束。当前说话者通过一个字符串(如“system")来识别,而不是特殊标记。这是在微调期间由模型标记化和用作输入的确切字符串。
然而,在推理过程中,我们无法提供预期的答案。在这种情况下,我们提供系统和用户部分,如图 图 5.6 所示,并通过添加 <|im_start|>assistant\n 来提示模型回答。
由于模型已经使用此模板进行了微调,它理解下一个标记应该是与用户指令相关且由系统提示引导的答案。这就是微调模型获得遵循指令能力的方式。
聊天模板的一个常见问题是每个空格和换行都非常重要。添加或删除任何字符都可能导致错误的标记化,这会负面影响模型的性能。因此,建议使用像 Transformers 库中实现的 Jinja 这样的可靠模板。表 5.7展示了此类模板的一些示例,包括 Alpaca,它既是指令数据集格式也是聊天模板的名称。
| 名称 | Jinja 模板 |
| --- | --- |
| Alpaca |
### Instruction: What is the capital of France?
### Response: The capital of France is Paris.<EOS>
|
| ChatML |
| --- |
<|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
The capital of France is Paris.<|im_end|>
|
| Llama 3 |
| --- |
<|begin_of_text|><|start_header_id|>user<|end_header_id|>
What is the capital of France?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
The capital of France is Paris.<|eot_id|>
|
| Phi-3 |
| --- |
<|user|>
What is the capital of France?<|end|>
<|assistant|>
The capital of France is Paris.<|end|>
|
| Gemma |
| --- |
<bos><start_of_turn>user
What is the capital of France?<end_of_turn>
<start_of_turn>model
The capital of France is Paris.<end_of_turn>
|
表 5.7 – 常见聊天模板的示例
Jinja 实现了循环和条件,允许相同的模板用于训练和推理(add_generation_prompt)。
参数高效的微调技术
虽然文献中存在许多技术,但 SFT 已经收敛到三种主要技术:完全微调、LoRA 和 QLoRA。我们将分别介绍每种技术,并根据您的用例权衡它们的优缺点。

图 5.9 – 三种主要 SFT 技术在模块级别的架构差异
完全微调
完全微调是指最直接的 SFT 技术,包括重新训练基础模型中的每一个参数。与预训练类似,SFT 使用下一个标记预测作为其训练目标。这意味着之前讨论的数据集结构可以看作是持续预训练和完全微调之间的主要区别。
此方法通常提供最佳结果,但需要大量的计算资源。内存使用量取决于多个因素,包括模型大小、训练技术和优化方法。在最简单的情况下,使用单 GPU 设置,所需的内存可以使用以下公式估计:

对于使用32 位浮点(fp32)精度的基本设置,我们可以估计:
-
参数:神经网络中的可学习权重和偏差。在一个大型语言模型中,这些通常是注意力机制、前馈层和嵌入层中的权重。成本:每个参数 4 字节(FP32)或 2 字节(FP16/BF16)。
-
梯度:梯度是损失函数相对于每个模型参数的偏导数。它们指示每个参数应该调整多少以最小化损失。在训练过程中,通过反向传播为每个参数计算梯度,并用于更新模型参数。成本:每个参数 4 字节。
-
优化器状态:优化器状态是优化算法(如 Adam 或 AdamW)维护的附加值。这些通常包括每个参数过去梯度和过去平方梯度的运行平均值。它们有助于为每个参数调整学习率并更有效地导航损失景观。例如,Adam 为每个参数维护两个额外的值(动量和方差)。成本:每参数 8 字节(对于 Adam 优化器)。
-
激活:激活是神经网络在正向传递过程中每个层的中间输出。对于基于 transformer 的模型,这包括注意力机制、前馈层和归一化层的输出。在正向传递期间,需要将激活保存在内存中,以便在反向传递中计算梯度,除非使用像激活检查点这样的技术。成本:可变,但对于小批量大小通常可以忽略不计。
这为我们提供了一个每参数 16 字节的基线。这相当于 7B 模型的 112 GB VRAM 和 70B 模型的 1,120 GB VRAM。然而,这通常是一个低估,因为它没有考虑到激活、临时缓冲区和各种训练技术开销所需的额外内存。
可以采用几种技术来减少在 LLM 微调期间的内存使用。模型并行将工作负载分散到多个 GPU 上,尽管这会增加一些开销。梯度累积允许在不成比例增加内存的情况下实现更大的有效批量大小。内存高效的优化器,如 8 位 Adam,可以减少优化器状态的大小。通过重新计算某些激活,激活检查点以计算成本换取内存。当结合使用时,这些技术可以显著降低内存使用。例如,使用混合精度和模型并行可能将成本降低到每参数 14-15 字节,而基线为 16 字节。然而,即使有这些优化,大型模型的内存需求仍然很大。
此外,完全微调会直接修改预训练权重,这使得它本质上具有破坏性。如果训练行为不符合预期,可能会擦除以前的知识和技能——这种现象被称为“灾难性遗忘”。同样的现象也可能发生在持续预训练中,这通常使得这些技术更难使用。由于这种额外的复杂性和其高计算需求,参数高效的技术通常比完全微调更受欢迎,以创建特定任务和领域的模型。
LoRA
LoRA 是一种用于微调 LLM 的参数高效技术。为了解决适应大规模神经网络相关的计算挑战而开发,LoRA 很快成为 LLM 微调的一个基石技术。
LoRA 的主要目的是通过引入可训练的低秩矩阵来降低 LLM 微调所需的计算资源。这是通过在不改变模型原始参数的情况下修改模型行为来实现的。LoRA 的关键优势包括:
-
训练过程中显著降低内存使用
-
更快的微调过程
-
保留预训练模型权重(非破坏性)
-
能够通过交换 LoRA 权重来高效地在任务之间切换
这些好处使得 LoRA 对于使用有限计算资源的研发人员特别有吸引力,有效地民主化了 LLM 微调的过程。
在其核心,LoRA 使用低秩分解技术来高效地更新模型权重。而不是直接修改原始权重矩阵
,LoRA 引入了两个较小的矩阵,
和
,它们共同构成了对
的低秩更新。

图 5.10 – LoRA 添加了两个可训练矩阵
和
,并保持预训练权重
冻结
从数学上讲,这可以表示为:

在这里,
是原始权重矩阵,
和
是 LoRA 矩阵,而
是推理过程中使用的有效权重矩阵。
矩阵 A 和 B 的维度被选择,使得它们的乘积与
的形状相同,但秩要低得多。这个秩,通常表示为
,是 LoRA 中的一个关键超参数。在训练过程中,原始权重
保持冻结状态,而只有
和
被更新。这种方法显著减少了可训练参数的数量,从而实现了大量的内存节省和更快的训练时间。
为了有效地实现 LoRA,我们需要选择正确的超参数和目标模块。LoRA 包含两个超参数:
-
秩 (
):决定了 LoRA 矩阵的大小。一个常见的起点是
,但在某些情况下,值高达 256 已经显示出良好的效果。更大的秩可能能够捕捉更多样化的任务,但也可能导致过拟合。 -
Alpha (
):应用于 LoRA 更新的缩放因子。在实践中,我们通过一个因子
更新冻结的权重
。这就是为什么一个常见的启发式方法是设置
为
的两倍,从而在 LoRA 更新中应用一个缩放因子 2。在过拟合或欠拟合的情况下,您可以尝试不同的比率。
此外,还可以添加一个 dropout 层来防止过拟合。dropout 率通常设置为 0 到 0.1,作为一个可选的正则化因子,它略微降低了训练速度。
LoRA 可以应用于模型架构的各个部分。最初,LoRA 主要关注修改注意力机制,特别是 transformer 层中的查询(Q)和值(V)矩阵。然而,实验表明,将 LoRA 的应用扩展到模型的其它关键组件也能带来显著的好处。这些额外的目标模块包括:
-
注意力层中的关键(K)矩阵
-
注意力机制中的输出投影层(通常表示为 O)
-
注意力层之间的前馈或多层感知器(MLP)块
-
线性输出层
然而,需要注意的是,增加 LoRA 适配模块的数量也会增加可训练参数的数量,从而增加内存需求。
使用 LoRA,可以在单个 GPU 上微调一个 7B 参数模型,只需 14-18 GB 的 VRAM,具体取决于配置。与通常需要多个高端 GPU 的全微调相比,这是一个巨大的减少。在可训练参数方面,LoRA 与全微调相比大幅减少了数量。例如,即使针对每个具有 16 阶的模块,Llama 3 8 B 模型也只有 8 亿参数中的 4200 万可训练 LoRA 参数,占模型参数的 0.5196%。
在质量方面,LoRA 也能达到与全微调相当甚至更好的结果。可以通过组合不同任务或领域的多个 LoRA 权重集来实现灵活部署和任务切换,而无需重新训练。多个项目专注于多 LoRA 服务,例如 LoRAX。这也是 Hugging Face 的文本生成推理(TGI)和Nvidia 推理微服务(NIM)支持的功能。
QLoRA
由 Dettmers 等人提出的 QLoRA 是一种针对高计算成本挑战的 LLM 微调方法。通过结合量化技术与 LoRA,QLoRA 允许开发者在小型且广泛可用的 GPU 上微调模型。
QLoRA 方法的核心是将基础模型参数量化到自定义的4 位 NormalFloat(NF4)数据类型,这显著降低了内存使用。与 LoRA 类似,QLoRA 在微调期间不是更新所有模型参数,而是引入小的、可训练的低秩矩阵(适配器)到模型的特定层。只有这些适配器在训练期间被更新,而原始模型权重保持不变。为了进一步减少内存使用,QLoRA 采用双重量化,量化量化常数本身。此外,它使用分页优化器通过利用 Nvidia 的统一内存功能来管理训练期间的内存峰值。
与 LoRA 相比,QLoRA 提供了显著的内存节省,可以将峰值 GPU 内存使用量降低高达 75%。例如,对于一个 70 亿参数的模型,QLoRA 在初始化期间将峰值内存使用量从 14 GB 降低到 9.1 GB,减少了 35%。在微调期间,内存节省增加到 40%,从 LoRA 的 15.6 GB 增加到 QLoRA 的 9.3 GB。然而,这种内存效率是以增加训练时间为代价的,QLoRA 比 LoRA 慢约 30%。在模型性能方面,QLoRA 与 LoRA 相比只有细微的差异。
总结来说,当内存限制是主要关注点时,例如在处理非常大的模型或在有限的 GPU 内存的硬件上工作时,QLoRA 特别有益。然而,如果训练速度至关重要且内存充足,LoRA 可能是一个更好的选择。
QLoRA 和 LoRA 之间的选择应基于项目的具体要求、可用硬件以及平衡内存使用、训练速度和模型性能的需求。
训练参数
在微调 LLM 时,几个超参数指导训练过程,并显著影响模型的收敛、泛化能力和整体有效性。
学习率和调度器
学习率是最重要的超参数。它控制模型参数在训练期间更新的程度。它通常范围从非常小的值,如1e-6,到较大的值,如1e-3。对于 transformer 模型,一个常见的起点通常是1e-5。如果学习率太低,训练进展缓慢,可能会陷入次优解。相反,如果太高,训练可能会变得不稳定或发散,导致性能不佳。通常,尝试不同的学习率以找到特定任务和模型的最佳值是有益的。
学习率调度器在整个训练过程中调整学习率。它通常以较高的学习率开始,以实现快速初始进展,然后在后期逐渐降低,以更精确地微调模型。最常见的两种调度器类型是线性调度器和余弦调度器。线性调度器随着时间的推移稳步降低学习率,而余弦调度器遵循余弦曲线,在训练初期降低较慢,然后在训练后期加快。例如,你可能会从 3e-4 的学习率开始,在训练过程中降低到 1e-7。具体值和衰减计划取决于你的模型和数据集,但常见的方法是使用预热期(例如,总步骤的 5%)在此期间学习率从 0 增加到初始值,然后是剩余 95%步骤的衰减期。这种方法有助于稳定早期训练,并在模型收敛时允许更精细的更新。一般来说,线性调度器和余弦调度器提供相同级别的性能。
批处理大小
批处理大小决定了在模型权重更新之前处理的样本数量。对于 LLM 微调,典型的批处理大小范围从 1 到 32,常见的值有 1、2、4、8 或 16。较大的批处理大小通常会导致更稳定的梯度估计,并可以提高训练速度,因为它们提供了对整个数据集真实梯度的更好近似。
然而,它们也需要更多的内存,这可能是具有较少 VRAM 的 GPU 的限制因素。例如,一个 16 的批处理大小在一个具有 24GB 内存的高端 GPU 上可能工作得很好,而一个较小的 8GB GPU 可能只能处理 2 或 4 的批处理大小。
为了在仍然受益于较大批处理大小的同时克服内存限制,可以使用一种称为梯度累积的技术。它通过执行多个带有较小迷你批次的正向和反向传递,在这些步骤中累积梯度,然后在应用单个更新到模型参数之前,对累积的梯度进行更新。这种方法在处理大型模型或有限的 GPU 内存时特别有用。例如,如果你想达到 32 个有效批处理大小,但你的 GPU 一次只能处理 8 个样本,你可以将梯度累积步骤设置为 4。这意味着你将处理 4 个包含 8 个样本的迷你批次,累积梯度,然后像一次性处理所有 32 个样本一样更新模型。
梯度累积步骤的数量通常从 1(无累积)到 8 或 16 不等,具体取决于期望的有效批处理大小和可用的计算资源。在选择步骤数量时,请考虑训练速度和内存使用之间的权衡。更多的累积步骤允许更大的有效批处理大小,但会增加每次更新的时间。以下是一个简单的公式,用于确定有效批处理大小:

例如,如果你使用 2 个 GPU,每个处理 4 个样本的批次,并且有 4 个梯度累积步骤,你的有效批次大小将是4 * 2 * 4 = 32个样本。
最大长度和打包
最大序列长度决定了模型可以处理的最长输入。它通常设置在 512 到 4,096 个标记之间,但根据任务和可用的 GPU 内存,可以高达 128,000 个或更多。例如,对于许多语言生成任务,最大长度为 2,048 个标记是常见的,而 RAG 应用可能使用高达 8,192 个标记或更多。在处理输入数据时,超过此限制的序列将被截断,这意味着多余的标记将被移除。截断可以发生在序列的开始(左截断)或结束(右截断)。例如,最大长度为 1,024 个标记的情况下,1,500 个标记的输入将移除 476 个标记。此参数直接影响批次大小和内存使用;一个最大长度为 1,024 的批次大小为 12 将包含 12,288 个标记(12 * 1,024),而相同批次大小但最大长度为 512 的批次将只包含 6,144 个标记。平衡此参数与你的 GPU 能力和训练数据的性质对于优化性能和资源利用非常重要。
打包最大化了每个训练批次的利用率。而不是每个批次分配一个样本,打包将多个较小的样本组合成一个批次,从而有效地增加了每次迭代的处理数据量。例如,如果你的最大序列长度是 1,024 个标记,但许多样本只有 200-300 个标记长,打包可能允许你将 3-4 个样本放入每个批次槽位。这种方法可以显著提高训练效率,尤其是在处理包含许多短序列的数据集时。然而,打包需要谨慎实现,以确保模型注意力不会在打包样本之间交叉。这通常是通过使用注意力掩码来实现的,它可以防止模型关注同一打包序列中来自不同样本的标记。
训练轮数
迭代次数是另一个重要参数,表示整个训练数据集的完整遍历次数。对于 LLM 微调,典型的范围是 1 到 10 次迭代,许多成功的运行使用了 2 到 5 次迭代。最佳迭代次数取决于任务复杂性、数据集大小和模型架构等因素。更多的迭代次数允许模型细化其学习,从而可能提高性能。然而,存在一个关键的权衡:迭代次数太少可能导致欠拟合,而太多则可能导致过拟合。例如,在一个小型数据集上微调的大型模型可能只需要 1-3 次迭代,而一个在大型数据集上微调的小型模型可能从 5-10 次迭代中受益。在训练期间监控验证性能并实施早期停止,如果模型性能停滞或下降,是有帮助的。这种方法有助于动态确定最佳迭代次数并防止过拟合。
优化器
优化器通过调整模型参数来最小化损失函数。对于 LLM 微调,强烈推荐使用 AdamW(自适应动量估计与权重衰减),尤其是其 8 位版本。AdamW 8 位在性能上与 32 位版本相当,同时使用更少的 GPU 内存(但它不会提高训练速度)。AdamW 结合了自适应学习率与权重衰减正则化,通常会导致更好的训练稳定性和模型性能。
对于内存约束严重的场景,AdaFactor 提供了一种针对内存效率设计的替代方案。它无需显式调整学习率即可良好工作,因此在资源受限的环境中特别有用。然而,它可能并不总是能在所有情况下匹配 AdamW 的性能。在涉及极大型模型或有限 GPU 内存的情况下,优化器的分页版本,如分页 AdamW 8-bit,可以通过将部分工作卸载到 CPU RAM 来进一步减少内存消耗。如果内存允许且最大性能是优先考虑的,非量化的adamw_torch优化器可能是最佳选择。
权重衰减
权重衰减通过在损失函数中添加对大权重的惩罚来实现,鼓励模型学习更简单、更通用的特征。这有助于模型避免过度依赖任何单个输入特征,从而提高其在未见数据上的性能。通常,权重衰减的值范围在 0.01 到 0.1 之间,0.01 是一个常见的起始点。例如,如果你使用 AdamW 优化器,你可能将权重衰减设置为 0.01。
虽然权重衰减可能有益,但设置得太高可能会阻碍学习,使模型难以捕捉数据中的重要模式。相反,设置得太低可能不足以提供足够的正则化。最佳权重衰减值通常取决于特定的模型架构和数据集,因此通常是一个好习惯,即尝试不同的值进行实验。
梯度检查点
梯度检查点是一种在训练过程中通过仅存储正向传递中生成的中间激活子集来减少内存消耗的技术。在标准训练过程中,所有中间激活都保留在内存中,以方便反向传播期间的梯度计算。然而,对于像 LLMs 这样非常深的网络,这种方法可能会因为硬件限制而迅速变得不切实际,尤其是在内存容量有限的 GPU 上。
梯度检查点通过在网络中特定层选择性地保存激活来解决这一挑战。对于未保存激活的层,在反向传播过程中需要时,将重新计算这些激活以进行梯度计算。这种方法在计算时间和内存使用之间产生权衡。虽然它显著降低了内存需求,但由于需要重新计算一些激活,它可能会增加整体计算时间。
其他参数和技术存在,但与之前讨论的那些相比,它们的作用较小。在下一节中,我们将通过具体示例探讨如何选择和调整这些参数。
实际微调
现在让我们在我们的定制数据集上微调一个开源模型。在本节中,我们将展示一个实现 LoRA 和 QLoRA 以提高效率的示例。根据您可用的硬件,您可以选择最适合您配置的技术。
对于任务或领域特定的用例,我们可以利用许多高效的开放权重模型。为了选择最相关的 LLM,我们需要考虑三个主要参数:
-
许可:某些模型许可仅允许非商业工作,如果我们想为公司进行微调,这会成为一个问题。在这个领域,定制许可很常见,可以针对具有一定用户数量的公司,例如。
-
预算:参数规模较小的模型(小于 10 B)比大型模型更便宜,更适合进行微调和推理部署。这是因为它们可以在更便宜的 GPU 上运行,并且每秒可以处理更多的标记。
-
性能:在通用基准或更好的情况下,在特定领域或任务基准上评估基础模型至关重要。这有助于确保模型在微调后具有在预期任务上表现良好的必要能力。
在本章中,我们将选择 Meta 发布的开源模型 Llama 3.1 8B。它有一个许可的定制许可(“Llama 3.1 社区许可协议”),允许商业使用。具有 8B 参数,它足够小,可以适应大多数 GPU,同时与竞争对手相比达到高水平的表现。我们可以使用 Open LLM 排行榜以及其他在模型卡片中详细说明的基准来验证这一点。
存在专门用于微调模型的工具和库。特别是,我们推荐以下工具:
-
TRL:这是一个由 Hugging Face 创建和维护的库,用于使用 SFT 和偏好对齐来训练 LLMs。它是一个流行且可靠的库,在算法方面通常是最新的。它支持使用 FSDP 和 DeepSpeed 的单 GPU 和多 GPU 设置。
-
Axolotl:由 Wing Lian 创建,这个工具通过可重用的 YAML 配置文件简化了 LLMs 的微调过程。它基于 TRL,但包含许多额外功能,例如自动组合存储在不同格式的数据集。它还支持使用 FSDP 和 DeepSpeed 的单 GPU 和多 GPU 设置。
-
Unsloth:由 Daniel 和 Michael Han 创建,Unsloth 使用自定义内核来加速训练(2-5 倍)并减少内存使用(最多减少 80%的内存)。它基于 TRL,并提供许多实用工具,例如自动将模型转换为 GGUF 量化格式。在撰写本文时,它仅适用于单 GPU 设置。
为了最大化效率,我们将使用 Unsloth 库进行微调。以下代码是我们 LLMOps 管道的一部分,但也可以作为独立的脚本使用。它还可以在不同的环境中执行,如 SageMaker、云 GPU(如 Lambda Labs 或 RunPod)、Google Colab 等。我们在 A40、A100 和 L4 等不同的 GPU 上进行了测试。
要安装 Unsloth 库及其依赖项,我们建议直接从本书的 GitHub 仓库(github.com/PacktPublishing/LLM-Engineering)或 Unsloth 的仓库(github.com/unslothai/unsloth)安装。这种方法推荐,因为安装步骤会定期更新以解决潜在的依赖项冲突:
-
首先,我们想要访问一个门控模型,并且(可选地)将我们的微调模型上传到 Hugging Face (
huggingface.co/)。这需要登录到一个账户。如果您没有账户,您可以创建一个账户,并在.env 文件中存储您的 API 密钥(设置 | 访问令牌 | 创建新令牌):HF_TOKEN = YOUR_API_KEY -
确保您的 Comet ML API 密钥也位于.env 文件中:
COMET_API_KEY = YOUR_API_KEY -
导入所有必要的包:
import os import torch from trl import SFTTrainer from datasets import load_dataset, concatenate_datasets from transformers import TrainingArguments, TextStreamerfrom unsloth import FastLanguageModel, is_bfloat16_supported -
现在我们加载模型和相应的分词器进行微调。我们使用 Unsloth 的 FastLaguageModel 类和
.from_pretrained()方法。除了模型名称外,我们还需要指定最大序列长度(本例中为 2,048)。最后,load_in_4bit参数表示我们是否想要使用QLoRA(量化预训练权重)或 LoRA。
在这个例子中,我们将使用 LoRA,因为它训练速度快,质量高,但如果您不满足 VRAM 要求,可以轻松切换到 QLoRA。
max_seq_length = 2048
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="meta-llama/Meta-Llama-3.1-8B",
max_seq_length=max_seq_length,
load_in_4bit=False,
)
-
现在模型已经加载,我们可以定义我们的 LoRA 配置。在这里,我们使用 32 的秩,这足以模仿写作风格并复制我们的指令样本中的知识。如果你的结果不尽人意,你可以将这个值增加到 64 或 128。我们还设置了一个 32 的 alpha 值,没有 dropout 和偏差,以加快训练速度。最后,我们将每个线性层的目标设置为最大化微调过程的质量。
model = FastLanguageModel.get_peft_model( model, r=32, lora_alpha=32, lora_dropout=0, target_modules=["q_proj", "k_proj", "v_proj", "up_proj", "down_proj", "o_proj", "gate_proj"], ) -
接下来,我们需要准备合适格式的数据以进行微调。在这个例子中,我们在 llmtwin 数据集中没有很多样本(3,000 个样本)。这是一个问题,因为模型可能无法正确学习聊天模板。为了解决这个问题,我们将使用一个名为 FineTome 的高质量通用数据集进行上采样。这是
arcee-ai/The-Tome使用fineweb-edu-classifier过滤后的版本。我们不会使用这个数据集的 100,000 个样本,而是在训练分割中指定我们只想使用 10,000 个。我们将这两个数据集拼接起来以创建我们的最终集。dataset1 = load_dataset("mlabonne/llmtwin") dataset2 = load_dataset("mlabonne/FineTome-Alpaca-100k", split="train[:10000]") dataset = concatenate_datasets([dataset1, dataset2]) -
现在,我们需要使用聊天模板来格式化这些数据。为了方便,让我们使用 Alpaca 模板。这个模板不需要额外的标记,这使得它更不容易出错(但与 ChatML 相比可能会稍微影响性能)。在这里,我们将所有指令和答案映射到 Alpaca 模板。我们手动在每个消息的末尾添加句子结束(EOS)标记,以确保模型学会输出它。没有它,它将不断生成答案而不会停止。
alpaca_template = """Below is an instruction that describes a task. Write a response that appropriately completes the request. ### Instruction: {} ### Response: {}""" EOS_TOKEN = tokenizer.eos_token dataset = dataset.map(format_samples, batched=True, remove_columns=dataset.column_names) -
一旦数据集准备就绪,我们可以将其分为训练集(95%)和测试集(5%)以进行训练期间的验证。
dataset = dataset.train_test_split(test_size=0.05) -
模型现在已准备好进行训练。SFTTrainer()类存储了我们训练的所有超参数。此外,我们提供了模型、分词器、LoRA 配置和数据集。根据上一节的建议,我们设置了一个学习率为
3e-4的线性调度器,最大序列长度为 2048。我们以 2 个批大小和 8 个梯度累积步骤(有效批大小为 16)训练这个模型三个周期。我们还选择了adamw_8bit优化器,weight_decay为 0.01。根据我们使用的 GPU,它将自动使用 FP16 或 BF16 进行激活。最后,我们将我们的训练运行报告给 Comet ML 以进行实验跟踪。trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset["train"], eval_dataset=dataset["test"], dataset_text_field="text", max_seq_length=max_seq_length, dataset_num_proc=2, packing=True, args=TrainingArguments( learning_rate=3e-4, lr_scheduler_type="linear", per_device_train_batch_size=2, gradient_accumulation_steps=8, num_train_epochs=3, fp16=not is_bfloat16_supported(), bf16=is_bfloat16_supported(), logging_steps=1, optim="adamw_8bit", weight_decay=0.01, warmup_steps=10, output_dir="output", report_to="comet_ml", seed=0, ), ) trainer.train()
在我们的拼接数据集上训练这个模型可能需要几个小时。例如,在一个 A100 GPU 上需要 50 分钟。
- 一旦完成,我们可以用一个快速示例来测试它。目标不是正确评估微调后的模型,而是确保没有与分词器或聊天模板相关的明显错误。
为了快速推理,我们可以使用 Unsloth 中的FastLanguageModel.for_inference()。我们直接使用 Alpaca 格式格式化一个指令。请注意,我们在用户指令的末尾提供一个空答案以附加助手标题(### Response):这迫使模型回答指令而不是完成它。我们还使用文本流器来流式传输生成,而不是等待它完成后再打印。
FastLanguageModel.for_inference(model)
message = alpaca_prompt.format("Write a paragraph to introduce supervised fine-tuning.", "")
inputs = tokenizer([message], return_tensors="pt").to("cuda")
text_streamer = TextStreamer(tokenizer)
_ = model.generate(**inputs, streamer=text_streamer, max_new_tokens=256, use_cache=True)
-
这里是我们模型提供的答案:
Supervised fine-tuning is a method used to enhance a language model by providing it with a curated dataset of instructions and their corresponding answers. This process is designed to align the model's responses with human expectations, thereby improving its accuracy and relevance. The goal is to ensure that the model can respond effectively to a wide range of queries, making it a valuable tool for applications such as chatbots and virtual assistants.
这是对的,并且已经按照 Alpaca 聊天模板正确格式化。
-
现在我们已经成功微调了我们的模型,我们可以使用以下函数将其保存到本地和/或将它推送到 Hugging Face Hub。
model.save_pretrained_merged("model", tokenizer, save_method="merged_16bit") model.push_to_hub_merged("mlabonne/TwinLlama-3.1-8B", tokenizer, save_method="merged_16bit")
恭喜您从头开始微调了一个基础模型!在训练过程中,您可以访问 Comet ML 来监控您的训练损失、验证损失以及许多其他指标。您需要确保这些指标与预期相符。图 5.11显示了 Comet ML 中对应于之前代码的训练运行。

图 5.11 – 在 Comet ML 中微调期间监控的四个指标
尤其是以下三个指标是重要的监控对象:
-
训练损失:它衡量模型在训练任务上的表现如何。损失应该平均持续下降,表明性能在提高。我们预计在训练开始时会迅速下降,然后是一个漫长的平台期。损失值的峰值和持续增加是训练失败的迹象。在这种情况下,您可能需要检查数据的质量、分词器的问题,并调整学习率、批量大小等参数。在图 5.11(loss)中,您可以看到对应于我们三个不同阶段的三个不同阶段。
-
验证损失:它使用验证集而不是训练集来衡量损失;一个拟合良好的模型通常显示训练和验证损失都在下降,并最终稳定,两者之间有一个小的差距。这个差距应该是最小的,但预期会存在,因为模型在训练数据上总是表现得稍微好一些。如果训练损失继续下降而验证损失开始增加,这是过拟合的迹象。相反,如果两条曲线都保持在一个相对较高的损失值上,这表明欠拟合。没有关于损失值的“推荐范围”,因为这些取决于具体问题和使用的损失函数。然而,您应该在两条曲线上寻找收敛和稳定性。在图 4.11(eval_loss)中,我们看到在第 340 步时略有上升。这仍然是可接受的,但可能表明模型开始过拟合。
-
梯度范数:它表示训练过程中梯度向量的幅度。大的梯度范数可能表明训练不稳定,如过拟合,尤其是如果伴随着训练和验证损失之间的差异。另一方面,稳定或下降的梯度范数通常意味着模型正在收敛到一个局部最优。为了减轻与大的梯度范数相关的问题,可以采用梯度裁剪。这项技术涉及设置梯度范数的最大阈值,从而有效地限制参数更新的规模。
尝试不同的学习率并基于最小损失选择最佳模型通常很有趣。请注意,这只是一个对真实评估的代理,相关内容将在下一章中介绍。
摘要
本章涵盖了 LLM 微调的必要方面,包括理论和实践。我们考察了指令数据管道以及如何从整理到增强创建高质量数据集。每个管道阶段都提供了优化机会,尤其是在质量评估、数据生成和增强方面。这个灵活的管道可以通过选择最相关的阶段和技术来适应您的用例。
我们将此框架应用于第三章的真实世界数据,使用 LLM 将原始文本转换为指令-答案对。然后我们探讨了 SFT 技术。这包括对 SFT 的优势和局限性的分析,使用聊天模板存储和解析指令数据集的方法,以及三种主要 SFT 技术的概述:全微调、LoRA 和 QLoRA。我们根据它们对内存使用、训练效率和输出质量的影响进行了比较。本章以一个实际演示结束,涉及在我们的自定义指令数据集上微调 Llama 3.1 8 B 模型。这个例子突出了成功微调的关键步骤和实现细节。
在下一章中,我们将使用偏好对齐技术创建 TwinLlama-3.1-8B 的新版本。我们将生成一个包含所选和拒绝答案的新数据集,这将帮助我们校准我们期望从模型获得的答案类型。我们将详细介绍许多可以从这个框架中受益的应用以及如何实现它。
参考文献
-
Tahori, Gulrajani, Zhang, Dubois, 等人。“Alpaca:一个强大的、可复制的指令遵循模型” crfm.stanford.edu,2023 年 3 月 13 日,
crfm.stanford.edu/2023/03/13/alpaca.html。 -
Subhabrata Mukherjee 等人。“Orca:从 GPT-4 的复杂解释轨迹中进行渐进式学习。” arXiv 预印本 arXiv:2306.02707,2023 年 6 月。
-
Wing Lian 和 Bleys Goodson 和 Eugene Pentland 和 Austin Cook 和 Chanvichet Vong 和 “Teknium”。“Open-Orca/OpenOrca.” huggingface.co,2023 年,
huggingface.co/datasets/Open-Orca/OpenOrca。 -
Zeng Weihao 等. “大型语言模型的自动指令进化.” arXiv 预印本 arXiv:2406.00770, 2024 年 6 月.
-
周纯等. “少即是多:对齐的 LIMA.” arXiv 预印本 arXiv:2305.11206, 2023 年 5 月
-
01. AI. “Yi:01.AI 的开源基础模型.” arXiv 预印本 arXiv:2403.04652, 2024 年 3 月.
-
Alex Birch. “LLM 微调的内存需求.” blog.scottlogic.com, 2023 年 11 月 24 日,
blog.scottlogic.com/2023/11/24/llm-mem.html. -
Quentin Anthony 等. “Transformer 数学 101.” blog.eleuther.ai, 2023 年 4 月 18 日,
blog.eleuther.ai/transformer-math/. -
Edward J. Hu 等. “LoRA:大型语言模型的低秩自适应.” arXiv 预印本 arXiv:2106.09685, 2021 年 6 月.
-
Tim Dettmers 等. “QLoRA:量化 LLM 的高效微调.” arXiv 预印本 arXiv:2305.14314, 2023 年 5 月.
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第六章:偏好对齐的微调
监督微调(SFT)在使大型语言模型(LLMs)执行特定任务方面至关重要。然而,SFT 在捕捉人类偏好的细微差别以及模型可能遇到的潜在交互的长尾方面存在困难。这种限制导致了更高级技术的发展,用于将人工智能系统与人类偏好对齐,这些技术被统称为偏好对齐。
偏好对齐通过将直接的人类或 AI 反馈纳入训练过程来解决 SFT 的不足。这种方法允许更细微地理解人类偏好,特别是在简单监督学习不足的复杂场景中。虽然存在许多偏好对齐的技术,但本章将主要关注直接偏好优化(DPO)以实现简单和高效。
本章中,我们将讨论偏好对齐算法如 DPO 所需的数据类型。我们将构建自己的数据集来修改我们模型的写作风格,使其更少人工化,更真实。我们将介绍 DPO 算法并实现它以对齐第五章中训练的模型。
本章将涵盖以下主题:
-
理解偏好数据集
-
如何创建我们自己的偏好数据集
-
直接偏好优化(DPO)
-
在实践中实施 DPO 以对齐我们的模型
到本章结束时,你将能够创建自己的偏好数据集,并使用多种技术将模型与模型对齐。
本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineering。
理解偏好数据集
创建高质量偏好数据集的原则与第五章中讨论的指令数据集的原则相同。我们希望最大化样本的准确性、多样性和复杂性。为了实现这一点,我们遵循与图 6.1 中概述的相同阶段:数据整理、去重、净化、质量评估、探索、生成和增强。

图 6.1 – 本章涵盖的培训后数据管道概述
为了避免重复,本节将专注于指令数据集和偏好数据集之间的主要区别。我们将介绍偏好样本的结构和偏好数据集的理想大小。然后,我们将关注与创建指令数据集最不同的两个阶段:数据生成和评估。
偏好数据
由于不同训练算法对数据要求的不同,偏好数据集缺乏指令数据集的标准性。偏好数据包括对给定指令的一系列响应,这些响应由人类或语言模型按顺序排列。本章重点关注 DPO,因此我们将检查该算法所需的具体数据格式。
如表 6.1所示,DPO 数据集的结构简单明了:每条指令都配对了一个首选答案和一个拒绝答案。目标是训练模型生成首选响应而不是拒绝的响应。
| 指令告诉我一个关于章鱼的故事。 |
| --- |
| 选择答案为什么章鱼不在赌场里玩牌?因为它们数不过八。 | 拒绝答案要多少次轻触才能让章鱼笑?十次轻触。 |
表 6.1 – mlabonne/orpo-dpo-mix-40k 数据集的样本示例
在偏好数据集中,拒绝的响应与选择的响应一样重要。没有拒绝的响应,数据集将只是一个简单的指令集。拒绝的响应代表我们希望从模型中消除的行为。这提供了很大的灵活性,并允许我们在许多情境中使用偏好数据集。以下是一些例子,说明在单独使用 SFT 相比,使用偏好数据集更有益的情况:
-
聊天机器人:在对话式人工智能中,响应的质量往往取决于主观因素,如自然度、参与度和上下文适宜性。偏好数据集允许模型通过比较更好的和较差的响应来学习这些细微之处。简单的 SFT 可能无法捕捉到在特定情境下使一个响应比另一个响应更可取的微妙之处。
-
内容审核:确定内容是否适当或违反指南通常涉及细微的判断。偏好数据集可以帮助模型通过比较可接受和不可接受的内容的例子来学习区分边缘案例。这比通过 SFT 进行二进制分类更有效,因为它有助于模型理解审核决策背后的推理。
-
摘要:摘要的质量往往取决于诸如简洁性、相关性和连贯性等因素。通过使用偏好数据集,模型可以学习生成人类认为更有用和更有信息量的摘要。简单的 SFT 可能导致技术上正确但不如人类读者偏好的摘要。
-
代码生成:在编码任务中,通常有多种正确解决方案,但其中一些比其他方案更高效、更易读,或遵循更好的实践。偏好数据集可以帮助模型学习代码质量的这些定性方面,这些方面可能无法通过基于简单正确性的 SFT 捕捉到。
-
创意写作:对于故事生成或诗歌写作等任务,输出的质量高度主观且多方面。偏好数据集可以比指令数据集更好地捕捉人类对风格、创造力和情感影响的判断,后者可能更多地关注技术正确性或遵循提示。
-
翻译:虽然像 BLEU 分数这样的传统指标可以衡量翻译的准确性,但它们并不总是能捕捉到翻译的流畅性或自然性。偏好数据集可以帮助模型学习产生母语人士偏好的翻译,即使有多种翻译在技术上都是正确的。
在所有这些场景中,偏好数据集使训练方法更加精细。它们捕捉主观质量评估和人类偏好,这些偏好超越了简单的正确性或遵循指令。这种方法可以产生不仅技术上准确,而且与人类在复杂、开放性任务中的判断和偏好更好的对齐的模型。
与指令数据集不同,没有像 Alpaca 或 ShareGPT 这样的标准化存储格式。大多数偏好数据集的结构类似于表 6.1中所示,包含指令、首选答案和拒绝答案的列。在偏好对齐中,多轮对话不常见。在撰写本文时,主要的微调库不支持多轮对话,通常只提取对话中的第一条或最后一条消息。
数据量
DPO 数据集通常比指令数据集需要更少的样本量,才能对模型行为产生显著影响。与指令数据集一样,所需的样本数量取决于模型大小和任务复杂性。较大的模型更有效率地使用样本,因此需要的数据更少,而复杂任务则需要更多的示例来捕捉所需的行为。再次强调,数据质量至关重要,大量偏好对通常是有益的。
通用对齐被 LLM 提供商用于提高微调模型的总体性能。这需要数百万样本的偏好数据集。AI 行业的领军企业,包括 Nvidia 和 Meta,正在趋同于类似的训练后管道,涉及多轮偏好对齐和广泛使用合成数据。这一共识表明,这些方法正在证明是推动语言模型能力边界的最有效方法。
在较小规模上,开源社区使用从 10,000 到 100,000 个样本的数据集来提高模型性能。这种方法不仅证明了在提高基准分数方面的有效性,而且在合并、修剪和其他修改后修复网络方面也有效。一般来说,DPO 比 SFT 破坏性小,对最终模型的影响也较轻。
另一方面,如前所述的任务需要较少的偏好对。特定任务的校准关注于提高模型在特定功能上的性能,例如修改写作风格、拒绝某些指令等。这些校准通常可以通过较小的数据集实现,从 100 到 10,000 个偏好对不等,具体取决于任务的复杂度。
需要少量样本的应用示例之一是指导模型声明它并非由 OpenAI、Meta 或其他大型语言模型(LLM)提供商训练。这可以通过使用偏好数据集来实现,其中被拒绝的答案是那些声称有其他来源的回答,而选中的答案是模型正确声明由你训练的回答。一个包含 200 到 500 对的相对较小的数据集可能就足够完成这项任务。
数据生成和评估
在创建偏好数据集时,数据生成和评估是紧密相连的。我们首先创建答案,然后对其进行评分以形成最终的数据集。在下面的介绍中,我们将这两个步骤作为一个过程而不是两个单独的过程来介绍。
生成偏好
在创建新的偏好数据之前,查看相关的开源数据集是个好主意。与指令数据集相比,这些数据集较少,但在 Hugging Face Hub 上可以找到高质量的偏好数据集。这些数据集可以用于特定任务或添加到你的数据集中。知名偏好数据集包括 Anthropic HH-RLHF 数据集,该数据集包含人类对有益且无害的 AI 响应的偏好,以及 OpenAI 的基于人类反馈的摘要数据集,该数据集专注于文章摘要。
DPO 数据集可以通过各种方法创建,每种方法都有其质量、成本和可扩展性之间的权衡。这些方法可以根据特定应用进行定制,并需要不同程度的人类反馈。我们将它们分为四个主要类别:
-
由人类生成、由人类评估的数据集:这种方法涉及雇佣人员来创建对提示的响应并评估这些响应的质量。虽然这种方法可以捕捉到细微的人类偏好,并且对于复杂任务来说是最理想的,但它极其资源密集且难以扩展。因此,它主要被拥有大量资源的大型 AI 公司所采用。
-
由人类生成、由 LLM 评估的数据集:如果你有大量现有的人类生成内容,这种方法可能很有用。然而,由于效率低下,这种方法在实践中很少使用,因为它在生成响应时仍然需要大量的人类输入,同时在 LLM 评估阶段可能会错过细微的偏好。
-
由 LLM 生成、人工评估的数据集:这种方法在质量和效率之间提供了良好的平衡。LLM 对提示生成多个响应,人类对这些响应进行排名。这种方法通常更受欢迎,因为人类通常在判断答案方面比从头开始编写答案更擅长。它允许快速生成多样化的响应,同时仍然有效地捕捉人类偏好。然而,它可能不会提供人类可能生成的创造性或意外响应。
-
由 LLM 生成、LLM 评估的数据集:完全合成的数据集,其中生成和评估都由 LLM 完成,由于其可扩展性和成本效益,正变得越来越普遍。这种方法可以快速生成大量数据集,并随着 LLM 能力的提升而改进。然而,它需要仔细的提示工程以确保质量和多样性,并可能延续生成 LLM 的偏见或局限性。
在实践中,人工生成的数据集成本高昂,难以扩展,且不一定质量最高。另一方面,人工评估非常有价值,但可能难以扩展,这就是为什么大型数据集受益于 LLM 评估。除了这些高级考虑因素之外,你获取数据的方式以及你打算如何使用它也需要被考虑。例如,拥有许多用户的程序可以嵌入反馈机制以提供偏好。这可以简单到“喜欢”和“不喜欢”评分,或者更深入一些,包括文本。
注意,评估并不总是必需的,偏好可以从生成过程中自然出现。例如,可以使用高质量的模型生成首选输出,而使用低质量或故意有缺陷的模型生成不那么首选的替代方案。这会在偏好数据集中产生清晰的区分,从而更有效地训练 AI 系统识别和模仿高质量输出。Hugging Face Hub 上可用的Intel/orca_dpo_pairs数据集就是通过这个过程创建的。
另一种方法是比较模型生成的输出与人工编写的响应,这可以提供关于模型与实际人类偏好的匹配程度以及模型可能缺乏的领域的见解。这可以用来复制特定的风格,并为模型提供更真实的语气。
数据生成技巧
数据生成在指令和偏好数据集之间是一致的。提示应该设计成鼓励模型响应的多样性和复杂性。通过制作明确要求不同方法或风格的提示,我们可以确保广泛范围的输出,从而捕捉人类偏好的多样性。
例如,在生成摘要时,可能会要求不同的变体,如简洁摘要、详细摘要和关注关键点的摘要。这种方法不仅产生多样化的数据集,还有助于了解不同的风格和方法如何与人类偏好相一致。
在生成合成偏好数据集时,引入输出中的可变性是另一个关键方面。这可以通过调整温度设置或在 LLM 中采用其他采样方法来实现。较高的温度设置往往会产生更具创造性和多样化的响应,而较低的设置则会导致更专注和确定性的输出。这会在多样性和连贯性之间产生权衡,这取决于我们想要生成的数据类型。例如,生成代码需要较低的创造力,因此需要较低的温度,而撰写文章则可以采用较高的温度。
使用多个 LLM 生成样本可能比仅使用一个模型更好。一些 LLM 在特定任务上表现更好,这种方法也增加了更多多样性。这种方法被流行的开源数据集如argilla/Capybara-Preferences所采用,结合了 GPT-4 和开放权重模型。然后,评估过程会选择选定的和被拒绝的答案。
评估偏好
数据评估可以通过人工评分或使用 LLM 自动化完成。LLM 评估涉及制定详细的准则,创建一个明确传达这些指南给 LLM 的提示,并使用该模型选择首选和被拒绝的响应。虽然比人工评分更具可扩展性,并允许一致地应用标准,但这种 LLM 评估的质量直接取决于模型的表现和提供的指南。它可能错过细微的人类偏好或文化细微差别。然而,随着 LLM 的持续改进,它们进行细微判断的能力也在提高,这可能导致随着时间的推移生成更高质量的数据集。
通过绝对评分或成对排名来实现 LLM 对偏好数据集的评估。在绝对评分中,LLM 根据预定义的标准为每个响应分配一个数值分数或分类评级。这种方法简单直接,但可能在不同提示或评估会话中存在不一致性。另一方面,成对排名涉及向 LLM 展示两个响应,并要求它选择更好的一个或对它们进行排名。这种方法更接近于人类评估的格式,并可能导致更一致的结果。
对于绝对评分,您将创建一个概述评估标准的提示,并要求 LLM 在特定尺度上对回答进行评分(例如,1-5 或差/一般/好/优秀)。提示可能看起来像这样:“根据相关性、连贯性和有帮助性,对以下回答进行 1-5 级别的评分:[INSERT RESPONSE]。”对于成对排名,提示可能是:“比较以下两个回答。在相关性、连贯性和有帮助性方面,哪个更好?回答 A:[INSERT RESPONSE A] 回答 B:[INSERT RESPONSE B]。”
偏好数据集的比较性质使得成对排名成为评估的理想方法。这种方法通常比绝对评分更准确,并且与人类判断的相关性更紧密。成对排名模仿了人类比较选项的自然方式,使得人类评分者和 LLM 都能提供一致且有意义的评估。
通过提供真实答案和使用思维链推理,我们可以进一步提高成对排名的准确性。这种方法鼓励评估 LLM 考虑回答的多个方面,并阐明其决策过程,从而得出更全面和合理的评估。当没有真实答案可用时,我们可以提示 LLM 创建评分笔记,即对预期答案的描述。这种技术在 LLM 对特定主题没有广泛知识的情况下尤其有效,因为它迫使模型在评估回答之前建立明确的评估标准。
这里是一个将 LLM 作为裁判的提示进行成对排名的具体实现:
| 指令您是一位答案裁判。您的目标是比较答案 A 和答案 B。我想知道哪个答案在相关性、准确性、完整性、清晰度、结构和简洁性方面更好地回答了指令。指令:{instruction}答案 A:{answer_a}答案 B:{answer_b}逐步解释您的推理,并使用以下结构输出最佳答案的字母:(比较两个答案)最佳答案:(A 或 B) |
| --- |
表 6.2 – 使用一个指令和两个答案的 LLM 作为裁判的成对排名提示示例
然而,需要注意的是,基于 LLM 的评估可能受到几种类型的偏差的影响:
-
位置偏差:在相对评分中,LLM 裁判倾向于偏好第一个呈现的答案。这种偏差可能会扭曲结果并导致不准确的选择。
-
长度偏差:与人类类似,LLM 裁判通常偏好较长的答案,可能会忽略较短、更简洁的回答的质量。
-
家族偏差:LLM 裁判可能会偏好由自己或同一家族的模型生成的回答,这可能是由于语言模式或知识库的相似性。
为了减轻这些偏差并提高偏好数据集的质量,可以实施几种解决方案。一种关键的方法是在每次比较中随机化答案 A 和答案 B 的顺序,这可以通过确保展示顺序不会持续影响评估来抵消位置偏差。另一种有价值的策略是提供少量示例,以展示分数的平衡分布。这些示例用于校准评判 LLM 的内部评分机制,并可以通过展示较短答案或来自不同模型家族的答案也可以是高质量的来有效解决长度和家族偏差。此外,采用多个模型作为陪审团,而不是依赖单个 LLM 评判员,可以显著提高评估过程的鲁棒性。这种多模型方法有助于平衡单个模型中可能存在的任何个人偏差,从而对响应进行更全面和准确的评估。
在下一节中,我们将创建自己的偏好数据集。我们将依靠数据生成过程自然地创建选定的(人工生成)和拒绝的(LLM 生成)答案。
创建我们自己的偏好数据集
我们的模式目前可以就与机器学习相关的话题撰写段落,但它没有与原始作者相同的写作风格。这是一个典型的偏好对齐用例,我们希望改变模型的“声音”以更接近源数据。需要注意的是,实验上,DPO 往往使模型更加冗长,并推动它们使用非常正式的语言。因此,训练将需要使用 DPO 进行精细操作,以避免这种陷阱,并采用这些博客文章的较少正式的风格。
在本节中,我们将创建一个偏好数据集,其中选定的答案是从文本中提取的,而拒绝的答案是模型生成的。为了实现这一点,我们将修改在第五章中创建的代码,该代码旨在生成指令数据集。
如前节所示,偏好和指令数据集依赖于相同的原则。而不是指令和答案的配对,我们需要三元组(指令,答案 1,答案 2)。在这个设置中有趣的是,我们在文本块中有真实答案,这意味着我们不需要像 LLM 评判员那样的复杂评估过程。为了确保这些提取是高质量的,我们将实施两个额外的质量过滤器,基于长度和标点。图 6.2总结了端到端的过程:

图 6.2 – 从原始文本到偏好数据集的合成数据生成流程
现在我们已经准备好实现偏好数据生成流程:
-
我们首先导入必要的库。
import concurrent.futures import json import re from typing import List, Tuple from datasets import Dataset from openai import OpenAI from tqdm.auto import tqdm -
我们现在不再有
InstructionAnswerSet类,而是有一个PreferenceSet类。该类旨在处理指令的三元组、生成的答案(被拒绝)和提取的答案(被选中)。class PreferenceSet: def __init__(self, triples: List[Tuple[str, str, str]]): self.triples = triples @classmethod def from_json(cls, json_str: str) -> 'PreferenceSet': data = json.loads(json_str) triples = [(triple['instruction'], triple['generated_answer'], triple['extracted_answer']) for triple in data['preference_triples']] return cls(triples) def __iter__(self): return iter(self.triples) -
load_articles_from_json、clean_text和extract_substrings函数与原始代码保持不变。让我们从load_articles_from_json开始,它接受包含文章的 JSON 文件(cleaned_documents.json)作为输入,并返回一个包含文本和元数据(ID、平台、作者 ID、作者全名、链接)的 Hugging Face 数据集。def load_articles_from_json(file_path: str) -> Dataset: with open(file_path, "r") as file: data = json.load(file) return Dataset.from_dict( { "id": [item["id"] for item in data["artifact_data"]], "content": [item["content"] for item in data["artifact_data"]], "platform": [item["platform"] for item in data["artifact_data"]], "author_id": [item["author_id"] for item in data["artifact_data"]], "author_full_name": [item["author_full_name"] for item in data["artifact_data"]], "link": [item["link"] for item in data["artifact_data"]], } ) -
clean_text函数移除非字母数字字符(除撇号、句号、逗号、感叹号和问号外),并将多个空格替换为单个空格,以确保正确的格式。def clean_text(text: str) -> str: text = re.sub(r"[^\w\s.,!?']", " ", text) text = re.sub(r"\s+", " ", text) return text.strip() -
extract_substrings函数将文章分割成长度在 1,000 到 2,000 个字符之间的块。为了确保分割不会破坏句子,从而改变其含义,我们使用正则表达式仅在句子末尾进行分割。def extract_substrings(dataset: Dataset, min_length: int = 1000, max_length: int = 2000) -> List[str]: extracts = [] sentence_pattern = r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s" for article in dataset["content"]: cleaned_article = clean_text(article) sentences = re.split(sentence_pattern, cleaned_article) current_chunk = "" for sentence in sentences: sentence = sentence.strip() if not sentence: continue if len(current_chunk) + len(sentence) <= max_length: current_chunk += sentence + " " else: if len(current_chunk) >= min_length: extracts.append(current_chunk.strip()) current_chunk = sentence + " " if len(current_chunk) >= min_length: extracts.append(current_chunk.strip()) return extracts -
generate_preference_triples函数取代了原始的generate_instruction_answer_pairs函数。提示信息从指令版本中调整,旨在生成三元组而不是对。它还提供了关于我们感兴趣的指令类型、如何从文章中提取答案以及如何格式化它们的通用指导:def generate_preference_triples(extract: str, client: OpenAI) -> List[Tuple[str, str, str]]: prompt = f"""Based on the following extract, generate five instruction-answer triples. Each triple should consist of: 1\. An instruction asking about a specific topic in the context. 2\. A generated answer that attempts to answer the instruction based on the context. 3\. An extracted answer that is a relevant excerpt directly from the given context. Instructions must be self-contained and general, without explicitly mentioning a context, system, course, or extract. Important: - Ensure that the extracted answer is a verbatim copy from the context, including all punctuation and apostrophes. - Do not add any ellipsis (...) or [...] to indicate skipped text in the extracted answer. - If the relevant text is not continuous, use two separate sentences from the context instead of skipping text. Provide your response in JSON format with the following structure: {{ "preference_triples": [ {{ "instruction": "...", "generated_answer": "...", "extracted_answer": "..." }}, ... ] }} Extract: {extract} """ -
在同一函数中,我们使用 GPT-4o-mini 以 JSON 模式生成我们的答案。我们在系统提示中指定我们想要三元组而不是对。JSON 答案直接由我们的
PreferenceSet类解析,以返回预期的元组列表。completion = client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": "You are a helpful assistant who generates instruction-answer triples based on the given context. Each triple should include an instruction, a generated answer, and an extracted answer from the context. Provide your response in JSON format.", }, {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, max_tokens=2000, temperature=0.7, ) result = PreferenceSet.from_json(completion.choices[0].message.content) return result.triples -
为偏好数据处理管道引入了两个新的过滤函数:
filter_short_answers和filter_answer_format。这些函数过滤掉短答案,并确保答案以大写字母开头并正确结束。我们将它们用作启发式方法来过滤掉质量较差的样本。def filter_short_answers(dataset: Dataset, min_length: int = 100) -> Dataset: def is_long_enough(example): return len(example['chosen']) >= min_length return dataset.filter(is_long_enough) def filter_answer_format(dataset: Dataset) -> Dataset: def is_valid_format(example): chosen = example['chosen'] return (len(chosen) > 0 and chosen[0].isupper() and chosen[-1] in ('.', '!', '?')) return dataset.filter(is_valid_format) -
create_preference_dataset函数取代了原始的create_instruction_dataset函数。现在,该函数使用三元组而不是对,并在生成的数据集中使用不同的列名。def create_preference_dataset(dataset: Dataset, client: OpenAI, num_workers: int = 4) -> Dataset: extracts = extract_substrings(dataset) preference_triples = [] with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: futures = [ executor.submit(generate_preference_triples, extract, client) for extract in extracts ] for future in tqdm(concurrent.futures.as_completed(futures), total=len(futures)): preference_triples.extend(future.result()) instructions, generated_answers, extracted_answers = zip(*preference_triples) return Dataset.from_dict( { "prompt": list(instructions), "rejected": list(generated_answers), "chosen": list(extracted_answers) } ) -
主要功能已更新,包括新的过滤步骤,并使用偏好数据集创建功能:
def main(dataset_id: str) -> Dataset: client = OpenAI() # 1\. Load the raw data raw_dataset = load_articles_from_json("cleaned_documents.json") print("Raw dataset:") print(raw_dataset.to_pandas()) # 2\. Create preference dataset dataset = create_preference_dataset(raw_dataset, client) print("Preference dataset:") print(dataset.to_pandas()) # 3\. Filter out samples with short answers dataset = filter_short_answers(dataset) # 4\. Filter answers based on format dataset = filter_answer_format(dataset) # 5\. Export dataset.push_to_hub(dataset_id) return dataset
create_preference_dataset() 函数生成了 2,970 个样本。然后,通过删除过短或格式不正确的答案(例如,以大写字母开头或以句号、感叹号或问号结尾的答案),该数据集被大量过滤,仅保留 1,467 个样本。
最终数据集可在以下 Hugging Face Hub 地址获取:huggingface.co/datasets/mlabonne/llmtwin-dpo。您可以在 图 6.3 中看到一个示例,它捕捉到了写作风格方面的微妙差异。两个答案都是正确的,但选择的(提取的)答案听起来稍微随意一些。

图 6.3 – Hugging Face Hub 上 mlabonne/llmtwin-dpo 偏好数据集的屏幕截图
为了生成这个数据集,我们多次迭代提示以生成数据。这需要一些手动评估和实验,直到我们达到令人满意的结果。提示的质量在这个过程中至关重要,这就是为什么建议遵循类似的过程来生成您自己的偏好数据集。
在下一节中,我们将介绍与 来自人类反馈的强化学习(RLHF)和 DPO 相关的概念。这包括在本章最后部分实现的新参数和想法。
偏好对齐
偏好对齐重新组合了用于在偏好数据上微调模型的技巧。在本节中,我们概述了这个领域,然后重点介绍我们将要实施的技巧:直接偏好优化(DPO)。
来自人类反馈的强化学习
来自人类反馈的强化学习(RLHF)将 强化学习(RL)与人类输入相结合,以使模型与人类偏好和价值观保持一致。RLHF 作为对传统 RL 方法挑战的回应而出现,特别是指定复杂任务的奖励函数的困难以及工程奖励与预期目标之间可能出现的偏差。
RLHF 的起源可以追溯到基于偏好的强化学习(PbRL)领域,该领域由 Akrour 等人和 Cheng 等人于 2011 年独立引入。PbRL 旨在从定性反馈中推断目标,例如行为之间的成对偏好,而不是依赖于定量奖励信号。这种方法解决了传统强化学习的一些局限性,在传统强化学习中,定义适当的奖励函数可能具有挑战性,并且容易受到奖励黑客攻击或产生意外行为。
RLHF 这个术语是在 2021-2022 年左右提出的,因为这种方法在训练大型语言模型(LLMs)的背景下获得了显著的关注。然而,其核心思想已经发展了数年。Christianos 等人在 2017 年发表的一篇开创性论文展示了从人类偏好中学习奖励模型并使用它们来训练强化学习代理的有效性。这项工作表明,RLHF 可以匹配或超过基于手工设计的奖励训练的代理的性能,但所需的人类努力显著减少。
在其核心,RLHF 通过迭代改进奖励模型和政策来工作:
-
奖励模型学习:RLHF 不是使用预定义的奖励函数,而是从人类反馈中学习奖励模型。这通常是通过向人类展示不同的答案并询问他们更喜欢哪一个来完成的。这些偏好被用来训练奖励模型,通常使用布拉德利-特里模型或类似的方法,将偏好映射到潜在效用函数。
-
策略优化:使用学习到的奖励模型,可以采用标准的强化学习算法来优化策略。这种策略生成新的行为,旨在最大化从学习模型预测的奖励。
-
迭代改进:随着策略的改进,它会产生新的行为,这些行为可以通过人类进行评估,从而对奖励模型进行细化。这种循环持续进行,理想情况下会导致与人类偏好高度一致的政策。
强化学习与人类反馈的关键创新在于其处理人类反馈高成本的方法。而不是要求持续的人类监督,强化学习与人类反馈允许异步和稀疏的反馈。
学习到的奖励模型作为人类偏好的代理,使得强化学习算法能够在没有直接人类输入的情况下,为每个动作持续训练。
例如,图 6.4展示了近端策略优化(PPO)算法的高级视图,这是最受欢迎的强化学习与人类反馈融合(RLHF)算法之一。在此,奖励模型用于评估训练模型生成的文本。这种奖励通过额外的库尔巴克-莱布勒(KL)散度因子进行正则化,确保训练前(冻结模型)的标记分布保持相似。

图 6.4 – 偏好对齐的 PPO 算法的高级视图
尽管 RLHF 在将人工智能系统与人类偏好对齐方面已被证明是有效的,但由于其迭代性质和对单独奖励模型的依赖,它面临着挑战。这种依赖可能导致计算成本高昂且可能不稳定。尽管在理论上具有优越性,但 RLHF 算法在实验中与简单方法相比也表现不佳。其中一种受到广泛关注的方法是 DPO。
直接偏好优化
在 2023 年的论文《直接偏好优化:你的语言模型实际上是奖励模型》中,Rafailov 等人引入了 DPO,它为传统的 RLHF 方法提供了一种简化的替代方案。
DPO 的核心创新在于其对偏好学习问题的重新表述。与 RLHF 不同,RLHF 通常涉及训练一个单独的奖励模型,然后使用如 PPO 之类的强化学习算法来微调语言模型,DPO 采取了一种更直接的方法。
它在标准 RLHF 目标下,即在具有参考策略的 KL 散度约束下最大化预期奖励的情况下,推导出最优策略的闭式表达式。这一数学洞察力使得 DPO 可以直接用策略来表述偏好学习问题,从而消除了需要单独的奖励模型或复杂的强化学习算法的需求。
在实际应用中,DPO 可以作为一个简单的二元交叉熵损失函数实现,该函数直接作用于语言模型的输出概率。这个损失函数鼓励模型将更高的概率分配给首选响应,将更低的概率分配给非首选响应,同时保持与参考(冻结)模型的接近。通过 0 到 1 之间的 beta 参数直接控制参考模型的重要性。当 beta 等于 0 时,参考模型被忽略,这意味着训练的模型可以与 SFT 模型非常不同。在实践中,0.1 是最受欢迎的值,但这个值可以根据下一节的内容进行调整。
这种方法的简单性允许使用标准的梯度下降技术进行优化,无需在训练期间从模型中采样或实现复杂的 RL 算法。图 6.5展示了 DPO 算法的高级视图,与图 6.4相比,极大地简化了训练过程。

图 6.5 – DPO 算法的偏好对齐高级视图
DPO 相对于传统的 RLHF 方法具有几个优势。如前所述,它显著简化了偏好学习流程,减少了与 RLHF 方法相关的工程复杂性。通过消除对单独的奖励模型和 RL 算法的需求,DPO 比传统的 RLHF 方法在计算效率上更高。特别是当与适配器(LoRA,QLoRA)一起训练时,冻结和训练的模型不需要分离。实际上,因为我们只训练适配器,所以训练的模型不会被修改。这使得我们只需要加载一个模型而不是两个,从而节省了额外的 VRAM。
尽管这种方法很简单,DPO 通常与更复杂的 RLHF 方法的性能相匹配。它还倾向于在训练期间更加稳定,对超参数的敏感性更低。简化的方法使得 DPO 更容易实现和扩展,尤其是对于没有广泛 RL 知识的中小团队来说。
虽然 RLHF(Reinforcement Learning from Human Feedback)允许通过多个训练轮次进行迭代改进,并且可以动态适应新的偏好,但 DPO 提供了实现类似结果的更直接途径。DPO 与基于 PPO 的 RLHF 之间的选择通常取决于实现简便性和潜在峰值性能之间的权衡。对于包含数百万个偏好样本的大规模训练运行,受 PPO 启发的方 法仍然具有更高的性能上限。然而,对于大多数应用来说,DPO 在较低的计算和工程成本下提供了大部分的性能优势。
RLHF 和 DPO 都显著受益于合成数据的集成。随着 LLM(Large Language Model)能力的增强,它们可以生成在质量和多样性上超越人类创造内容的数据。这创造了一个良性循环,即更好的模型产生更好的训练数据,反过来又导致模型进一步改进。这两种方法的迭代性质允许进行多轮模型精炼,每轮都关注模型性能的不同方面,并逐渐增强各个领域的功能。
尽管 DPO 具有优势,但它并非没有缺点。像 RLHF 一样,DPO 仍然需要成对的偏好数据,这可能很昂贵且耗时收集。DPO 缺乏与强化学习方法相关的某些理论保证。可能存在某些场景,其中 RLHF 增加的灵活性是有益的,特别是对于复杂任务或环境。
尽管如此,DPO 在大多数情况下都是理想的,包括我们的双 LLM 示例。在下一节中,我们将使用 Unsloth 实现它。
实现 DPO
在本节中,我们将对在第五章中创建的TwinLlama-3.1-8B模型进行 DPO 微调。为了便于使用并最大化性能,我们再次使用 Unsloth 库来实现我们的 DPO。根据可用的 VRAM,您可以选择 LoRA(更高质量、速度和 VRAM 使用)和 QLoRA(较低质量、速度和 VRAM 使用)。这种技术与其他偏好对齐算法一起,也存在于 TRL 和 Axolotl 中。
这个例子可以被视为 DPO(Document Positioning Operator)的高级应用。事实上,我们模仿写作风格的目标与 DPO 鼓励正式语言的自然倾向相冲突。这部分原因是因为所选答案通常比被拒绝的答案更正式。在实践中,这会迫使我们进行轻微的微调,使用低学习率和较少的 epoch 数。为了找到最佳的超参数,我们在一组问题上进行训练,包括“写一段介绍监督微调的段落。”这使我们能够选择最适合此任务的模型和参数。
依赖项与第五章中的 SFT 相同,可以在本书的 GitHub 仓库(github.com/PacktPublishing/LLM-Engineering)或 Unsloth 的仓库(github.com/unslothai/unsloth)中找到:
-
首先,我们想要访问一个门控模型,并且(可选地)将我们的微调模型上传到 Hugging Face(
huggingface.co/)。这需要我们登录一个账户。如果您没有账户,您可以创建一个,并将 API 密钥(设置 | 访问令牌 | 创建新令牌)存储在.env文件中:HF_TOKEN = YOUR_API_KEY -
确保您的 Comet ML API 密钥也包含在
.env文件中。否则,当训练开始时,代码会崩溃并引发错误。COMET_API_KEY = YOUR_API_KEY -
在导入所有必要的包之前,我们想要对 TRL 中的
DPOTrainer类应用一个补丁。这修复了笔记本环境中的 DPO 日志。from unsloth import PatchDPOTrainer PatchDPOTrainer() -
我们现在可以导入其他库。DPO 与 SFT 之间的主要区别是导入 TRL 中的
DPOConfig和DPOTrainer,它们是 DPO 训练特有的。import os import torch from datasets import load_dataset from transformers import TrainingArguments, TextStreamer from unsloth import FastLanguageModel, is_bfloat16_supportedfrom trl import DPOConfig, DPOTrainer -
此步骤加载第五章中的微调模型。我们使用相同的配置,
max_seq_length为 2048。您可以通过将load_in_4bit设置为True来激活 QLoRA。在以下步骤中,我们将执行 LoRA DPO 微调以提高速度和质量。max_seq_length = 2048 model, tokenizer = FastLanguageModel.from_pretrained( model_name="mlabonne/TwinLlama-3.1-8B", max_seq_length=max_seq_length, load_in_4bit=False, ) -
现在,让我们使用 LoRA 配置准备模型进行 PEFT。我们将排名(
r)和lora_alpha从第五章中的32增加到64。这将允许更丰富的微调。我们保持0的 dropout 以加快速度,并针对每个线性模块进行操作。model = FastLanguageModel.get_peft_model( model, r=32, lora_alpha=32, lora_dropout=0, target_modules=["q_proj", "k_proj", "v_proj", "up_proj", "down_proj", "o_proj", "gate_proj"], ) -
我们加载
llmtwin-dpo数据集(训练分割),其中包含我们的提示、选择和拒绝的答案。dataset = load_dataset("mlabonne/llmtwin-dpo", split="train") -
数据准备与第五章中的 SFT 示例有显著不同。在这里,我们有包含提示、选择答案和拒绝答案的三元组。在
format_samples函数中,我们将 Alpaca 聊天模板应用于每个单独的消息。请注意,指令是唯一需要聊天格式的:选择和拒绝答案只需要与句子结束标记(EOS)连接。最后,我们以 95%/5%的比例创建一个训练/测试分割。alpaca_template = """Below is an instruction that describes a task. Write a response that appropriately completes the request. ### Instruction: {} ### Response: """ EOS_TOKEN = tokenizer.eos_token def format_samples(example): example["prompt"] = alpaca_template.format(example["prompt"]) example["chosen"] = example['chosen'] + EOS_TOKEN example["rejected"] = example['rejected'] + EOS_TOKEN return {"prompt": example["prompt"], "chosen": example["chosen"], "rejected": example["rejected"]} dataset = dataset.map(format_samples) dataset = dataset.train_test_split(test_size=0.05) -
模型和数据现在已准备就绪,我们可以开始微调。与 SFT 相比,有一些新的参数,如
ref_model和beta。由于我们使用 LoRA(或 QLoRA),我们不是直接训练模型,而是训练适配器。这意味着我们可以使用原始模型(不带适配器)作为参考,节省大量 VRAM。beta参数控制参考模型的重要性。在大多数情况下,标准值 0.1 效果良好,但根据我们的实验,我们决定将其增加到 0.5。这是因为训练模型使用了较低值的正式语言。使其更接近参考模型有助于解决这个问题。
学习率也降低了(从 SFT 的 3e-4 到这里的 2e-6)。我们训练 1 个 epoch 而不是 3 个,并且max_seq_length参数现在被分解为两个新的参数:max_prompt_length(仅提示)和max_length(提示和答案)。请注意,我们还用DPOConfig类替换了TrainingArguments类。
trainer = DPOTrainer(
model=model,
ref_model=None,
tokenizer=tokenizer,
beta=0.5,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
max_length=max_seq_length//2,
max_prompt_length=max_seq_length//2,
args=DPOConfig(
learning_rate=2e-6,
lr_scheduler_type="linear",
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=1,
fp16=not is_bfloat16_supported(),
bf16=is_bfloat16_supported(),
optim="adamw_8bit",
weight_decay=0.01,
warmup_steps=10,
output_dir="output",
eval_strategy="steps",
eval_steps=0.2,
logging_steps=1,
report_to="comet_ml",
seed=0,
),
)
trainer.train()
-
一旦模型训练完成,我们就可以运行它进行快速合理性检查。这一步骤与 SFT 示例类似。它准备模型进行推理,并对提示生成响应。
FastLanguageModel.for_inference(model) message = alpaca_template.format("Write a paragraph to introduce supervised fine-tuning.", "") inputs = tokenizer([message], return_tensors="pt").to("cuda") text_streamer = TextStreamer(tokenizer) _ = model.generate(**inputs, streamer=text_streamer, max_new_tokens=256, use_cache=True) -
训练好的 DPO 模型返回以下响应:
Supervised fine-tuning is a method used to enhance the performance of pre-trained language models by utilizing labeled data. This technique involves taking a pre-trained model and refining it on a specific task, such as content creation or customer service. By providing the model with relevant data and guidance, it can learn to generate outputs that align more closely with the desired outcomes. This approach allows for the creation of more specialized models that can tackle complex tasks with greater accuracy and efficiency.
我们可以将其与 SFT 模型提供的答案进行比较:
Supervised fine-tuning is a method used to enhance a language model by providing it with a curated dataset of instructions and their corresponding answers. This process is designed to align the model's responses with human expectations, thereby improving its accuracy and relevance. The goal is to ensure that the model can respond effectively to a wide range of queries, making it a valuable tool for applications such as chatbots and virtual assistants.
DPO 模型提供的答案既更准确,又更接近期望的写作风格。它正确地将预训练语言模型识别为 SFT 的源模型。它还提到了特定领域或任务的微调,而不是与“人类期望”对齐,这更接近偏好对齐阶段。答案也更不正式,是我们会在博客文章中使用的。
-
最后,最后一步包括将训练好的模型保存在本地并将其推送到 Hugging Face Hub。
model.save_pretrained_merged("model", tokenizer, save_method="merged_16bit")
恭喜!我们已经训练并导出了我们的 DPO 模型。它现在可在 Hugging Face Hub 上找到,网址为huggingface.co/mlabonne/TwinLlama-3.1-8B-DPO。与 SFT 相比,DPO 在训练期间需要跟踪一些额外的指标。图 6.6显示了 Comet ML 仪表板上的主要指标。您可以使用以下 URL 公开访问它:www.comet.com/mlabonne/llm-twin-training/


图 6.6 – 使用 DPO 指标在 Comet ML 中进行实验跟踪
让我们回顾一下这些指标:
-
训练损失:我们仍然希望平均损失持续下降。请注意,它可能会迅速下降到零,这意味着模型不再学习任何东西。这种行为不一定导致过拟合或不良模型,但需要密切监控。
-
验证损失:关于验证损失,也可以说同样的话。我们预计与训练损失相比会有一个小差距。
-
梯度范数:我们期望梯度范数很小,几乎没有峰值。
-
奖励:我们有两种不同的奖励:选择和拒绝。它们对应于训练和参考模型输出的对数概率之间的平均值差异。随着时间的推移,我们预计模型会选择选择的答案并拒绝拒绝的答案,这意味着它们之间的差距应该增加。这种差异直接通过
margins指标跟踪,定义为选择和拒绝奖励之间的差异。一个训练良好的模型的 margin 会迅速增加然后趋于平稳。 -
准确率:此指标表示模型正确识别所选答案的百分比。我们希望这个准确率在训练过程中逐渐提高,但不需要达到 100%。100%的准确率,尤其是如果它快速实现,可能表明偏好数据集对模型来说可能太简单了。虽然 LLM 仍然可以从这样的数据集中学习,但添加更多具有挑战性的例子可能是有益的。
通常,DPO 比 SFT 更难监控和调试,因为它是一个更复杂的过程,涉及一个参考模型。然而,它比 PPO 和其他 RLHF 算法更容易使用。只要你有高质量的偏好数据集和强大的微调模型,你就可以尝试不同的排名、beta 参数、学习率和 epoch 数量,以查看哪个实验最能捕捉你的偏好。
虽然这不是本章的目的,但自动评估旨在模仿写作风格的模型是可能的。一个可能的解决方案是,将不同模型(SFT 和 DPO)生成的文本中单词的分布与我们的基准数据集进行比较。在这个例子中,我们预计 SFT 模型会输出很多在 GPT-4o-mini 中过度表示的单词(如“深入研究”)。我们的 DPO 模型输出的分布应该与所选答案非常接近。
摘要
本章探讨了用于改进 LLM 的偏好对齐技术。它介绍了偏好数据集的概念,解释了其结构和在捕捉细微的人类偏好中的重要性。我们通过比较真实文章的原始文本和 AI 生成的文本,实现了我们自己的定制偏好数据生成管道。此管道可以根据您的用例重用和定制。
我们还概述了 RLHF 的演变过程,导致了 DPO(直接偏好优化)作为一种更简单、更有效的替代方案的出现。最后,我们使用 Unsloth 库实现了 DPO,以微调我们的 TwinLlama-3.1-8B 模型,该模型来自第五章。我们的逐步教程提供了训练模型的实际指导,并突出了与 SFT 的关键差异。最终模型可在 Hugging Face Hub 上找到。
在下一章中,我们将探讨至关重要的 LLM 评估主题,解决评估 LLM 性能的挑战和当前方法。我们将涵盖创建特定领域评估集,探讨为什么评估在领域内仍然是一个持续存在的问题,并介绍使用更大模型评估较小模型的概念(LLM 作为法官)。本章将以一个全面的评估流程结束,提供一个结构化的框架,以实现一致和有效的 LLM 评估。
参考文献
-
Rafael Rafailov 等人。“直接偏好优化:你的语言模型实际上是一个奖励模型。”arXiv 预印本 arXiv:2305.18290,2023 年 5 月。
-
Timo Kaufmann 等人. “人类反馈强化学习的强化学习综述。” arXiv 预印本 arXiv:2312.14925,2023 年 12 月。
-
Anthropic. “GitHub - anthropics/hh-rlhf: 用于“通过人类反馈的强化学习训练一个有用且无害的助手”的人类偏好数据。” github.com, 2022,
github.com/anthropics/hh-rlhf. -
Nisan Stiennon 等人. “从人类反馈中学习总结。” arXiv 预印本 arXiv:2009.01325,2020 年 9 月。
-
Intel(R) Neural Compressor. “在 Intel Gaudi2 上进行的监督微调和直接偏好优化。” medium.com, 2024 年 3 月 26 日,
medium.com/intel-analytics-software/the-practice-of-supervised-finetuning-and-direct-preference-optimization-on-habana-gaudi2-a1197d8a3cd3. -
Argilla. “GitHub - argilla-io/distilabel。” github.com, 2024 年 8 月 23 日,
github.com/argilla-io/distilabel. -
Databricks. “使用评分笔记增强 LLM-as-a-Judge。” databricks.com, 2024 年 7 月 22 日,
www.databricks.com/blog/enhancing-llm-as-a-judge-with-grading-notes. -
Akrour, Riad & Schoenauer, Marc & Sebag, Michèle. (2011). Preference-Based Policy Learning. 12-27. 10.1007/978-3-642-23780-5_11.
-
Cheng, Weiwei & Fürnkranz, Johannes & Hüllermeier, Eyke & Park, Sang-Hyeun. (2011). 基于偏好的策略迭代:利用偏好学习进行强化学习。 312-327. 10.1007/978-3-642-23780-5_30.
-
Paul Christiano 等人. “从人类偏好中进行深度强化学习。” arXiv 预印本 arXiv:1706.03741,2017 年 6 月。
-
Long Ouyang 等人. “通过人类反馈训练语言模型以遵循指令。” arXiv 预印本 arXiv:2203.02155,2022 年 3 月。
-
John Schulman 等人. “近端策略优化算法。” arXiv 预印本 arXiv:1707.06347,2017 年 7 月。
-
unslothai. “*GitHub - unslothai/unsloth: 使用 80%更少的内存,将 Llama 3.1、Mistral、Phi 和 Gemma LLMs 的微调速度提高 2-5 倍。” github.com, 2024 年 8 月 21 日,
github.com/unslothai/unsloth.
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第七章:评估大型语言模型(LLMs)
LLM 评估是用于评估 LLM 模型性能和能力的关键过程。它可以采取多种形式,如多项选择题回答、开放式指令和真实用户的反馈。目前,没有统一的衡量模型性能的方法,但有一些模式和配方我们可以根据特定用例进行调整。
虽然通用评估是最受欢迎的,例如大规模多任务语言理解(MMLU)或 LMSYS 聊天机器人竞技场,但特定领域和任务的模型从更狭窄的方法中受益。这在处理整个 LLM 系统(而不是模型)时尤其如此,通常围绕一个检索增强生成(RAG)流程。在这些情况下,我们需要扩展我们的评估框架,包括检索器和后处理器等新模块。
在本章中,我们将涵盖以下主题:
-
模型评估
-
RAG 评估
-
评估 TwinLlama-3.1-8B
到本章结束时,你将了解最流行的 LLM 评估以及如何使用不同的技术评估模型和 RAG 系统。
模型评估
在模型评估中,目标是评估单个模型的能力,而不需要任何提示工程、RAG 流程等。
这种评估对于几个原因至关重要,例如选择最相关的 LLM 或确保微调过程实际上提高了模型。在本节中,我们将比较机器学习(ML)和 LLM 评估,以了解这两个领域之间的主要区别。然后我们将探讨通用、特定领域和特定任务的基准。
比较 ML 和 LLM 评估
机器学习评估主要集中在评估为预测、分类和回归等任务设计的模型的性能。与 LLM 评估不同,LLM 评估通常关注模型理解和生成语言的能力,ML 评估更关注模型如何准确和高效地处理结构化数据以产生特定结果。
这种差异源于这些模型处理的任务的本质。ML 模型通常是为定义狭窄的问题设计的,如预测股价或检测异常值,这通常涉及数值或分类数据,使评估过程更加直接。另一方面,LLM 被要求解释和生成语言,这给评估过程增加了一层主观性。LLM 评估不仅依赖于数值基准,还需要更细致的方法,并经常结合定性评估,检查模型在自然语言中产生连贯、相关和上下文准确响应的能力。
尤其是我们可以看到这些模型工作方式中的三个关键差异,这影响了评估过程:
-
数值指标: 评估机器学习模型通常涉及测量客观性能指标,如准确率、精确率、召回率或均方误差,具体取决于手头任务的类型。对于能够处理多个任务(因此,多个评估)且很少依赖于相同数值指标的 LLMs(大型语言模型),这一点并不明确。
-
特征工程: 在传统的机器学习中,过程的一个关键部分是在训练模型之前手动选择和转换相关的数据特征。评估这种特征工程的成功往往成为更广泛模型评估的一部分。然而,LLMs 被设计为直接处理原始文本数据,减少了手动特征工程的需求。
-
可解释性: 对于机器学习模型,更容易解释模型为何做出某些预测或分类,这种可解释性可以是它们评估的核心部分。然而,LLMs 无法进行这种直接解释。但是,在生成过程中请求解释可以提供关于模型决策过程的见解。
在下一节中,我们将更细致地探讨不同类型的 LLMs。虽然评估通用模型与机器学习评估相对独立,但特定任务的 LLMs 与传统机器学习更紧密地相关。
通用 LLMs 评估
通用评估指的是针对基础和通用微调模型的指标。它们涵盖了与知识和实用性相关的一系列能力,而不专注于特定任务或领域。这允许开发者对这些能力有一个全面的了解,与竞争对手进行比较,并识别优势和劣势。基于这些结果,可以调整数据集和超参数,甚至修改架构。
我们可以将通用评估大致分为三个阶段:预训练期间、预训练之后以及微调之后。
在预训练期间,我们密切监控模型的学习过程,如第五章末所示。最直接的指标是低级指标,与模型的训练方式相对应:
-
训练损失: 基于交叉熵损失,衡量模型预测的概率分布与下一个标记的真实分布之间的差异
-
验证损失: 计算与训练损失相同的损失,但是在保留的验证集上,以评估泛化能力
-
困惑度: 交叉熵损失的指数,表示模型对数据的“惊讶”程度(越低越好)
-
梯度范数: 监控训练过程中梯度的幅度,以检测潜在的不稳定性或梯度消失/爆炸
在这个阶段也可以包括像 HellaSwag(常识推理)这样的基准测试,但存在过度拟合这些评估的风险。
在预训练之后,通常使用一系列评估来评估基模型。这个套件可以包括内部和公共基准。以下是一个非详尽的常见公共预训练评估列表:
-
MMLU (知识): 在 57 个科目(从基础到专业水平)的多个选择题上测试模型
-
HellaSwag (推理): 挑战模型从多个选择中完成给定情境的最合理结局
-
ARC-C (推理): 评估模型在需要因果推理的年级学校水平选择题上的表现
-
Winogrande (推理): 通过精心制作的句子中的代词解析来评估常识推理
-
PIQA (推理): 通过关于日常物理互动的问题来衡量物理常识理解能力
许多这些数据集也被用来评估通用微调模型。在这种情况下,我们关注基模型和微调模型之间给定分数的差异。例如,不良的微调可能会降低模型的知识,如通过 MMLU 衡量的。相反,良好的微调可能会灌输更多的知识并提高 MMLU 分数。
这也可以帮助识别任何污染问题,模型可能被微调在过于接近测试集的数据上。例如,在微调阶段提高基模型的 MMLU 分数 10 分是不太可能的。这是一个迹象,表明指令数据可能已被污染。
除了这些预训练评估之外,微调模型也有自己的基准。在这里,我们使用“微调模型”这个术语来指代经过监督微调(SFT)和偏好对齐训练的模型。这些基准针对与微调模型理解和回答问题能力相关的功能。特别是,它们测试指令遵循、多轮对话和代理技能:
-
IFEval (指令遵循): 评估模型在特定约束下(如答案中不输出任何逗号)遵循指令的能力
-
Chatbot Arena (对话): 一个框架,其中人类投票选择对指令的最佳答案,通过面对面对话比较两个模型
-
AlpacaEval (指令遵循): 与 Chatbot Arena 高度相关的自动评估,用于微调模型
-
MT-Bench (对话): 评估模型在多轮对话中的表现,测试其保持上下文并提供连贯回答的能力
-
GAIA (代理): 以多步骤的方式测试广泛的技能,如工具使用和网络浏览
理解这些评估的设计和使用方式对于选择最适合你应用的 LLM 非常重要。例如,如果你想微调模型,你希望选择在给定大小上知识推理方面最好的基础模型。这允许你比较不同 LLM 的能力,并选择一个将为你的微调提供最强基础的模型。
即使你不想微调模型,像 Chatbot Arena 或 IFEval 这样的基准也是一个比较不同指令模型的好方法。例如,如果你在构建聊天机器人,你希望拥有出色的对话能力。然而,如果你的最终目标是像从非结构化文档中提取信息这样的任务,这就不必要了。在这种情况下,你将更多地受益于出色的指令遵循技能来理解和执行任务。
虽然这些基准很受欢迎且有用,但它们也存在固有的缺陷。例如,公开基准可以通过在测试数据或与基准数据集非常相似的数据或样本上训练模型来操纵。甚至人类评估也不是完美的,并且往往偏向于长而自信的答案,尤其是当它们格式良好时(例如,使用 Markdown)。另一方面,私人测试集没有像公开测试集那样受到仔细审查,可能存在自身的问题和偏见。
这意味着基准不是真理的唯一来源,而应该用作信号。一旦多个评估提供了相似的答案,你就可以提高对模型真实能力的信心水平。
领域特定 LLM 评估
领域特定 LLM 的适用范围不如通用模型广泛。这有助于针对比以前基准更精细的能力进行更深入的定位。
在该类别中,基准的选择完全取决于所涉及的领域。对于像特定语言模型或代码模型这样的常见应用,建议搜索相关的评估甚至基准套件。这些套件包含不同的基准,并设计成可复制的。通过针对领域的不同方面,它们通常能更准确地捕捉领域性能。
为了说明这一点,以下是一个在 Hugging Face Hub 上有排行榜的领域特定评估列表:
-
Open Medical-LLM 排行榜:评估 LLM 在医学问答任务中的性能。它重新组合了 9 个指标,包括来自美国医学执照考试(MedQA)的 1,273 个问题,来自 PubMed 文章(PubMedQA)的 500 个问题,来自印度医学入学考试(MedMCQA)的 4,183 个问题,以及来自 MMLU 的 6 个子类别(临床知识、医学遗传学、解剖学、专业医学、大学生物学和大学医学)的 1,089 个问题。
-
BigCodeBench 排行榜:评估代码 LLM 的性能,包括两个主要类别:BigCodeBench-Complete,基于结构化文档字符串的代码补全,以及 BigCodeBench-Instruct,从自然语言指令生成代码。模型根据其 Pass@1 分数使用贪婪解码进行排名,对于 Complete 变体还有一个额外的 Elo 评分。它涵盖了广泛的编程场景,测试 LLM 的组合推理和指令遵循能力。
-
幻觉排行榜:评估 LLM 在 16 个涵盖 5 个类别的多样化任务中产生虚假或不支持信息的能力。这些包括问答(使用 NQ Open、TruthfulQA 和 SQuADv2 等数据集)、阅读理解(使用 TriviaQA 和 RACE)、摘要(采用 HaluEval Summ、XSum 和 CNN/DM)、对话(特色 HaluEval Dial 和 FaithDial)、以及事实核查(利用 MemoTrap、SelfCheckGPT、FEVER 和 TrueFalse)。排行榜还使用 IFEval 评估指令遵循能力。
-
企业场景排行榜:评估 LLM 在六个现实世界企业用例上的性能,涵盖与商业应用相关的多样化任务。基准包括 FinanceBench(100 个带有检索上下文的金融问题)、Legal Confidentiality(来自 LegalBench 的 100 个用于法律推理的提示)、Writing Prompts(创意写作评估)、Customer Support Dialogue(客户服务互动中的相关性)、Toxic Prompts(有害内容生成的安全性评估)以及 Enterprise PII(敏感信息保护的企业安全)。一些测试集是闭源的,以防止排行榜被操纵。评估侧重于特定能力,如答案准确性、法律推理、创意写作、上下文相关性以及安全措施,为 LLM 在企业环境中的适用性提供全面评估。
排行榜可以根据其领域采用不同的方法。例如,BigCodeBench 与其他排行榜显著不同,因为它仅依赖于两个足以涵盖整个领域的指标。另一方面,幻觉排行榜重新组合了 16 个指标,包括许多通用评估。这表明,除了定制基准之外,重用通用基准也可以完成自己的套件。
尤其是语言特定的 LLM 通常重用通用基准的翻译版本。这可以通过母语中的原始评估来完成。虽然一些基准使用机器翻译,但最好依赖于人工翻译的评估来提高其质量。我们选择了以下三个特定任务的排行榜及其相应的评估套件,以给您提供一个如何构建自己的排行榜的思路:
-
OpenKo-LLM 排行榜:使用九项指标评估韩语 LLM 的性能。这些指标包括将通用基准翻译成韩语(GPQA、Winogrande、GSM8K、EQ-Bench 和 IFEval)以及定制评估(知识、社会价值、无害性和帮助性)。
-
Open 葡萄牙 LLM 排行榜:使用九种不同的基准评估葡萄牙语 LLM 的性能。这些基准包括教育评估(ENEM 有 1,430 个问题,BLUEX 有 724 个来自大学入学考试的问题)、专业考试(OAB 考试有超过 2,000 个问题)、语言理解任务(ASSIN2 RTE 和 STS、FAQUAD NLI)以及社交媒体内容分析(HateBR 有 7,000 个 Instagram 评论,PT Hate Speech 有 5,668 条推文,tweetSentBR)。
-
Open 阿拉伯 LLM 排行榜:使用一套全面的基准评估阿拉伯语 LLM 的性能,包括本土阿拉伯语任务和翻译数据集。排行榜包括两个本土阿拉伯语基准:AlGhafa 和阿拉伯文化价值观对齐。此外,它还包含 12 个翻译基准,涵盖各种领域,如 MMLU、ARC-Challenge、HellaSwag 和 PIQA。
通用和领域特定的评估设计遵循三个主要原则。首先,它们应该是复杂的,挑战模型区分好和坏的输出。其次,它们应该是多样化的,尽可能涵盖更多主题和场景。当一项基准不足以时,额外的基准可以创建一个更强的套件。最后,它们应该是实用的,易于运行。这与评估库更为相关,这些库可能更容易或更难使用。我们推荐使用 Eleuther AI 的 lm-evaluation-harness(github.com/EleutherAI/lm-evaluation-harness)和 Hugging Face 的 lighteval(github.com/huggingface/lighteval)来运行您的基准。
任务特定的 LLM 评估
虽然通用和领域特定的评估表明了强大的基础或指导模型,但它们无法提供关于这些模型在特定任务中表现如何的见解。这需要专门为此目的设计的基准,以衡量下游性能。
由于它们的关注范围较窄,任务特定的 LLM 很少能依赖现有的评估数据集。这可能是有利的,因为它们的输出也往往更结构化,更容易使用传统的机器学习指标进行评估。例如,摘要任务可以利用基于召回的摘要评估辅助工具(ROUGE)指标,该指标通过使用 n-gram 来衡量生成的文本和参考文本之间的重叠。
同样,分类任务也能从中受益,并使用以下经典指标,以及其他指标:
-
准确性:准确性指的是正确预测的实例数与总实例数的比例。它对于具有分类输出或存在明显对错之分的任务特别有用,例如命名实体识别(NER)。
-
精确度:模型做出的真实阳性预测与模型做出的总阳性预测的比例。
-
召回率:真实阳性预测与总实际阳性实例的比例。
-
F1 分数:精确度和召回率的调和平均数,用于平衡这两个指标。这些指标在分类或实体提取等任务中特别有用。
当任务不能直接映射到传统的机器学习任务时,可以创建一个自定义基准。这个基准可以受到通用和特定领域评估数据集的启发。一个常见且成功的模式是使用多项选择题回答。在这个框架中,指令由一个问题及其几个选项组成。以下是一个来自 MMLU 数据集(抽象代数)的问题示例:
| 指令找到给定域扩展 Q(sqrt(2), sqrt(3))在 Q.A. 0B. 4C. 2D. 6 上的度。 |
| --- |
| 输出B |
表 7.1:来自 MMLU 数据集的示例
使用此方案评估模型主要有两种方式——文本生成和对数似然评估:
-
第一种方法涉及让模型生成文本回答,并将这些回答与预定义的答案选项进行比较。例如,模型生成一个字母(A、B、C 或 D)作为其答案,然后与正确答案进行核对。这种方法测试了模型在类似实际应用格式中产生连贯且准确回答的能力。
-
相反,使用概率进行评估则关注模型对不同答案选项的预测概率,而不需要文本生成。对于 MMLU,lm-evaluation-harness 比较了每个答案选项全文的概率。这种方法允许对模型的理解进行更细致的评估,因为它可以捕捉模型对不同选项的相对信心,即使它不一定生成确切的正确答案文本。
为了简化,我们建议使用模仿人类考试过程的文本生成版本进行评估。这种方法更容易实现,并且通常更具区分性,因为低质量模型在基于概率的评估中往往表现过好。你可以将这项技术应用于测试模型对特定任务的掌握程度,甚至扩展到特定领域。
相反,如果任务过于开放,传统的机器学习指标和多项选择题回答可能就不相关了。在这种情况下,可以在第五章中介绍的使用 LLM 作为评判者的技术可以用来评估答案的质量。如果你有真实答案,提供它们作为额外上下文可以提高评估的准确性。否则,定义不同的维度(例如相关性或毒性,取决于你的任务)也可以使评估更易于理解。
建议使用大型模型进行评估,并迭代地改进你的提示。在这个过程中,模型输出的解释对于理解其推理中的错误以及通过额外的提示工程修复它们非常重要。
为了便于解析答案,可以在指令中指定结构或使用某种类型的结构化生成(如提纲或 OpenAI 的 JSON 模式)。以下是一个带有结构的指令示例:
| 你是一个评估者,负责评估指令的答案质量。你的目标是提供一个分数,表示答案如何满足指令。你将使用 1 到 4 的刻度,其中每个数字代表以下内容:1. 答案与指令不相关。2. 答案与指令相关但无帮助。3. 答案与指令相关且有帮助,但可以更详细。4. 答案与指令相关、有帮助且详细。请按照以下方式提供你的评估:##评估##说明:(分析答案的相关性、帮助性和复杂性)总分:(1 到 4 之间的最终分数)指令:{instruction}答案:{answer}##评估##说明: |
| --- |
表 7.2:用于答案评估的通用 LLM 作为评判者的提示示例
自然地,你可以调整刻度,将真实答案添加到这个提示中,并根据自己的用例进行定制。
然而,评判 LLM 可能会表现出偏向于自信或冗长回答的偏见,可能高估了听起来更有信心但准确性较低的答案。它们也可能缺乏特定主题的领域专业知识,导致误判。一致性也是一个问题,因为 LLM 可能会对相似响应给出不同的评分。此外,它们可能对某些与实际答案质量无关的写作风格有隐含的偏好。为了减轻这些问题,可以将 LLM 评估与其他指标相结合,使用多个评判者,并精心设计提示以解决偏见。
一旦模型经过适当的评估并按预期工作,它可能被包含在一个更广泛系统中。在下一节中,我们将看到系统是如何改变评估框架的。
RAG 评估
虽然传统的 LLM 评估侧重于模型固有的能力,但 RAG 评估需要更全面的方法,既要考虑模型的生成能力,也要考虑其与外部信息源的交互。
RAG 系统结合了 LLM 的优势和信息检索机制,使它们能够生成既连贯又符合上下文、且基于最新、外部来源信息的响应。这使得 RAG 在当前和准确信息至关重要的领域特别有价值,例如新闻报道、研究和客户支持。
RAG 系统的评估不仅限于评估一个独立的 LLM。它需要检查整个系统的性能,包括:
-
检索准确性:系统检索相关信息的程度如何?
-
整合质量:检索到的信息被有效整合到生成的响应中的程度如何?
-
事实性和相关性:最终输出是否适当地解决了查询,同时无缝地融合了检索和生成内容?
RAG 评估的关键指标包括检索精度和召回率,这些指标衡量检索信息的准确性和全面性。此外,检索数据与生成文本之间的整合质量至关重要,同样,输出的整体事实性和连贯性也是关键。
为了说明这些指标在实际中的应用,考虑一个为电子商务环境中的客户支持聊天机器人设计的 RAG 系统。在这种情况下,用户询问“假日促销期间购买的笔记本电脑的退货政策是什么?”RAG 管道找到有关电子产品退货政策的文档和有关假日促销条款的文档。这些额外的上下文附加在问题的末尾,并且模型使用它来回答:
| 在我们假日促销期间购买的笔记本电脑,您从购买日期起享有 60 天的延长退货期。这比我们标准电子产品的 30 天退货政策更长。请确保笔记本电脑处于原始包装中,并附带所有配件,以便有资格获得全额退款。 |
| --- |
表 7.3:为客服设计的 RAG 管道的输出示例
在这个管道中,我们可以评估检索的文档是否与预期相符(检索准确性)。我们还可以衡量带有和没有额外上下文的响应之间的差异(整合质量)。最后,我们可以评估输出是否与提供的信息相关,并且是否在文档提供的信息基础上(事实性和相关性)。
在本节中,我们将介绍两种评估 RAG 模型如何将外部信息纳入其响应的方法。
Ragas
检索增强生成评估(Ragas)是一个开源工具包,旨在为开发者提供一套全面的工具,用于 RAG 评估和优化。它围绕指标驱动开发(MDD)的理念设计,这是一种产品开发方法,它依赖于数据来做出明智的决策,涉及对关键指标随时间持续监控,以获得有关应用程序性能的宝贵见解。通过采用这种方法,Ragas 使开发者能够客观地评估他们的 RAG 系统,确定改进领域,并跟踪随时间变化的影响。
Ragas 的关键功能之一是其能够合成生成多样化和复杂的测试数据集。这一功能解决了 RAG 开发中的一个重大痛点,因为手动创建数百个问题、答案和上下文既耗时又费力。相反,它使用受 Evol-Instruct 等作品启发的进化方法范式来构建具有不同特征的问题,如推理复杂性、条件元素和多上下文要求。这种方法确保了对 RAG 管道中不同组件的全面评估。
此外,Ragas 可以生成模拟基于聊天的问题和后续交互的对话样本,使开发者能够在更真实的场景中评估他们的系统。

图 7.1:Ragas 评估框架概述
如图 7.1所示,Ragas 提供了一套由 LLM 辅助的评价指标,旨在客观地衡量 RAG 系统性能的不同方面。这些指标包括:
-
忠实度:此指标衡量生成的答案与给定上下文的事实一致性。它通过将答案分解为单个主张并验证每个主张是否可以从提供的上下文中推断出来来工作。忠实度得分是可验证主张与答案中主张总数的比率。
-
答案相关性:此指标评估生成的答案与给定提示的相关性。它采用了一种创新的方法,即提示 LLM 根据答案生成多个问题,然后计算这些生成问题与原始问题之间的平均余弦相似度。这种方法有助于识别那些可能事实正确但与主题无关或不完整的答案。
-
上下文精确度:此指标评估上下文中存在的所有真实相关项目是否被适当地排序。它考虑检索上下文中相关信息的位置,奖励将最相关信息置于顶部的系统。
-
上下文召回率:这个指标衡量检索到的上下文与标注答案(真实情况)的一致程度。它分析真实情况答案中的每个主张,以确定它是否可以归因于检索到的上下文,从而为检索信息的完整性提供见解。
最后,Ragas 还提供了用于在生产环境中监控 RAG 质量的构建模块。这促进了 RAG 系统的持续改进。通过利用测试数据集的评估结果和生产监控中收集的见解,开发者可以迭代地增强他们的应用程序。这可能包括微调检索算法、调整提示工程策略或优化检索上下文和 LLM 生成的平衡。
Ragas 可以与另一种基于自定义分类器的方法相补充。
ARES
ARES(一个用于 RAG 系统的自动化评估框架)是一个综合性的工具,旨在评估 RAG 系统。它提供了一个自动化流程,结合合成数据生成和微调的分类器来评估 RAG 性能的各个方面,包括上下文相关性、答案忠实度和答案相关性。
ARES 框架在三个主要阶段运行:合成数据生成、分类器训练和 RAG 评估。每个阶段都是可配置的,允许用户根据他们的特定需求和数据集定制评估过程。
在合成数据生成阶段,ARES 创建与真实世界场景紧密相似的测试数据集,以进行鲁棒的 RAG 测试。用户可以通过指定文档文件路径、少量提示文件和合成查询的输出位置来配置此过程。该框架支持用于此任务的各种预训练语言模型,默认为 google/flan-t5-xxl。用户可以控制采样文档的数量和其他参数,以在全面覆盖和计算效率之间取得平衡。

图 7.2:ARES 评估框架概述
在分类器训练阶段,ARES 创建高精度的分类器以确定 RAG 输出的相关性和忠实度。用户可以指定分类数据集(通常从前一个阶段生成)、用于评估的测试集、标签列和模型选择。ARES 使用 microsoft/deberta-v3-large 作为默认模型,但支持其他 Hugging Face 模型。可以通过微调训练参数,如 epoch 数量、早期停止的耐心值和学习率,来优化分类器性能。
最后的阶段,RAG 评估,利用训练好的分类器和合成数据来评估 RAG 模型的性能。用户提供评估数据集、用于指导评估的少样本示例、分类器检查点和黄金标签路径。ARES 支持各种评估指标,并可以为其评估生成置信区间。
ARES 通过 vLLM 集成提供灵活的模型执行选项,支持基于云和本地运行。该框架还支持各种工件类型(代码片段、文档、HTML、图像等),使不同 RAG 系统输出的全面评估成为可能。
总结来说,Ragas 和 ARES 通过它们在评估和数据集生成方面的不同方法相互补充。Ragas 在生产监控和 LLM 辅助指标方面的优势可以与 ARES 高度可配置的评估过程和基于分类器的评估相结合。虽然 Ragas 可能基于 LLM 能力提供更细致的评估,但一旦其分类器训练完成,ARES 提供一致且可能更快的评估。将它们结合起来提供了一个全面的评估框架,从 Ragas 的快速迭代和 ARES 在关键阶段的深入、定制评估中受益。
在下一节中,我们将创建自己的评估框架来评估我们的特定任务模型 TwinLlama-3.1-8B。
评估 TwinLlama-3.1-8B
在前面的章节中,我们创建了两个模型,经过微调以生成高质量的文章和帖子:TwinLlama-3.1-8B 和 TwinLlama-3.1-8B-DPO。基于这个总结,我们想要评估它们编写既准确又流畅文本的能力。相比之下,通用微调模型由于知识广泛而准确,但常常使用过于正式和冗长的语言。通过这次微调,我们希望采用更自然的写作风格,基于训练集中的原始文章。
由于这个问题具有开放性,我们将利用一个裁判 LLM 来评估生成文本的质量。它将指令和答案作为输入,并根据两个标准在 1-3 的尺度上评分:
-
准确性:答案中呈现的信息的事实正确性和全面性的程度
-
风格:博客文章或社交媒体内容的语气和写作风格的适当性(无正式或学术表达)
在我们的评估框架中,我们将使用指令数据集的测试分割来获取测试指令。我们将将它们输入到我们的模型中并生成答案。然后,这些答案将由我们的裁判 LLM(GPT-4o-mini)根据一个指定我们标准的提示进行评估。最后,我们将分析分数,并根据定性和定量评估得出结论。
生成答案
第一步包括高效地为测试集中的每个指令生成答案。除了我们的两个模型外,我们还将使用 meta-llama/Meta-Llama-3.1-8B-Instruct,这是 Llama-3.1-8B 的官方 instruct 版本,作为参考点,以更好地理解我们所做的权衡。
让我们开始实施的第一个阶段:
-
我们导入相关的库,包括用于快速生成的 vLLM。这个库在本地模型批量生成方面比 transformers 快得多:
from vllm import LLM, SamplingParams from datasets import load_dataset from tqdm.auto import tqdm import gc -
我们定义了一个名为
generate_answers的函数,该函数将处理我们的数据集并使用指定的模型生成响应。它接受两个输入——我们想要使用的模型的 ID 和测试数据集的名称:def generate_answers(model_id, dataset_name): dataset = load_dataset(dataset_name, split="test") -
我们需要使用我们的模型训练的聊天模板格式化原始指令。请注意,Llama-3.1-8B-Instruct 已经使用了一个不同的模板,但它可以遵循这个简单的格式。在这里,为了简单起见,我们使用与每个模型相同的聊天模板。我们使用
format()函数将整个测试集映射到这个模板:def format(sample): return "Below is an instruction that describes a task. Write a response that appropriately completes the request.\n\n### Instruction:\n{}\n\n### Response:\n".format(sample["instruction"]) dataset = dataset.map(lambda sample: {"prompt": format(sample)}) -
让我们用最大长度为 4,096 个标记初始化 vLLM 使用的 LLM 对象。我们还可以指定采样参数,这些参数对应于解码策略中使用的变量。在这里,我们使用参数来鼓励多样性(高温度)同时移除最不可能的标记(
top_p和min_p)。最后,我们通过提供dataset["prompt"]中的提示列表来开始生成:llm = LLM(model=model_id, max_model_len=4096) sampling_params = SamplingParams(temperature=0.8, top_p=0.95, min_p=0.05, max_tokens=4096) outputs = llm.generate(dataset["prompt"], sampling_params) -
使用我们的 334 个提示,这个过程应该需要几分钟。一旦完成,我们就从 vLLM 输出的对象中提取答案。然后,我们将这些答案作为新列添加到我们的数据集中。这有助于记录答案并在以后进行审查:
answers = [output.outputs[0].text for output in outputs] dataset = dataset.add_column("answers", answers) -
我们将结果保存到 Hugging Face Hub 以便以后方便访问。然后,我们清理 GPU 内存,以防止在处理下一个模型时空间不足:
print(f"Uploading results for {model_id}") dataset.push_to_hub(f"mlabonne/{model_id.split('/')[-1]}-results") gc.collect() return dataset -
我们创建了一个包含我们想要测试的三个模型的列表。然后,我们逐个运行这些模型的
generate_answers()函数。这将为每个模型创建和上传一组单独的结果:model_ids = [ 'mlabonne/TwinLlama-3.1-8B', 'mlabonne/TwinLlama-3.1-8B-DPO', 'meta-llama/Meta-Llama-3.1-8B-Instruct' ] for model_id in model_ids: generate_answers(model_id, "mlabonne/llmtwin")
现在我们有了答案生成,我们可以继续到评估过程。
评估答案
为了评估我们的答案,我们将依赖 GPT-4o-mini 作为评判者。这种策略与我们用于数据生成时使用的策略相似。事实上,你可以将其修改为在数据生成过程中过滤掉坏样本。在这里,我们将根据准确性和风格对每个模型生成的每个答案进行评分。平均分数将告诉我们我们的微调质量与 Llama-3.1-8B-Instruct 相比如何:
-
首先,我们导入所需的库,包括
openai:import json from typing import List from datasets import Dataset, load_dataset from openai import OpenAI from tqdm.auto import tqdm import concurrent.futures -
然后,我们定义了
evaluate_answer()函数。这个函数包含我们的评估提示,它根据准确性和风格设置评估答案的上下文:def evaluate_answer( instruction: str, answer: str, client: OpenAI ) -> dict: prompt = f"""You are an expert judge. Please evaluate the quality of a given answer to an instruction based on two criteria: 1\. Accuracy: How factually correct is the information presented in the answer? You are a technical expert in this topic. 2\. Style: Is the tone and writing style appropriate for a blog post or social media content? It should use simple but technical words and avoid formal or academic language. -
在相同的提示中,我们为每个指标定义了我们的刻度。这些是三点李克特量表,每个分数都有精确的定义:
Accuracy scale: 1 (Poor): Contains factual errors or misleading information 2 (Good): Mostly accurate with minor errors or omissions 3 (Excellent): Highly accurate and comprehensive Style scale: 1 (Poor): Too formal, uses some overly complex words 2 (Good): Good balance of technical content and accessibility, but still uses formal words and expressions 3 (Excellent): Perfectly accessible language for blog/social media, uses simple but precise technical terms when necessary -
最后,我们通过两个示例来总结提示,说明我们所说的“复杂词汇”和“正式或学术语言”。我们提供相应的指令-答案对,并要求模型以 JSON 格式返回响应:
Example of bad style: The Llama2 7B model constitutes a noteworthy progression in the field of artificial intelligence, serving as the successor to its predecessor, the original Llama architecture. Example of excellent style: Llama2 7B outperforms the original Llama model across multiple benchmarks. Instruction: {instruction} Answer: {answer} Provide your evaluation in JSON format with the following structure: {{ "accuracy": {{ "analysis": "...", "score": 0 }}, "style": {{ "analysis": "...", "score": 0 }} }} """ -
这个提示作为用户查询提供给 GPT-4o-mini 模型。系统提示强化了我们对基于准确性和风格评估答案的兴趣:
completion = client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": "You are a helpful assistant who evaluates answers based on accuracy and style. Provide your response in JSON format with a short analysis and score for each criterion.", }, {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, max_tokens=1000, temperature=0.8, ) -
如前几章所述,我们将批量处理请求以加快处理速度。这就是我们创建
evaluate_batch()函数的原因,该函数返回一个包含对应索引的解析结构化输出列表。这些索引对于确保评估的正确顺序非常重要:def evaluate_batch(batch, start_index): client = OpenAI(api_key=OPENAI_KEY) return [ (i, evaluate_answer(instr, ans, client)) for i, (instr, ans) in enumerate(batch, start=start_index) ] -
我们现在可以在
evaluate_answers()函数中编排之前的代码。该函数接收模型 ID、线程数和批处理大小作为输入。首先,我们加载之前保存的生成数据集:def evaluate_answers(model_id: str, num_threads: int = 10, batch_size: int = 5) -> Dataset: dataset = load_dataset(f"mlabonne/{model_id.split('/')[-1]}-results", split="all") -
我们从数据集中创建指令-答案对的批次。每个批次包含
batch_size数量的对:batches = [ (i, list(zip(dataset["instruction"][i:i+batch_size], dataset["answers"][i:i+batch_size]))) for i in range(0, len(dataset), batch_size) ] -
我们使用多个线程并行评估指令-答案对的批次。我们使用并行处理同时评估多个批次,从而加快整体评估过程。
ThreadPoolExecutor将每个批次提交给evaluate_batch()。结果存储在评估列表中:evaluations = [None] * len(dataset) with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor: futures = [executor.submit(evaluate_batch, batch, start_index) for start_index, batch in batches] for future in tqdm(concurrent.futures.as_completed(futures), total=len(futures)): for index, evaluation in future.result(): evaluations[index] = evaluation -
我们创建一个新的列来存储评估过程的结果。这个列将存储裁判模型的原始 JSON 输出,包括分数和解释:
if 'evaluation' in dataset.column_names: dataset = dataset.remove_columns(['evaluation']) dataset = dataset.add_column("evaluation", evaluations) -
我们可以直接使用
json.loads()解析这个 JSON 对象,并尝试检索应该生成的准确性和风格分数。这种生成处于尽力而为模式,这意味着分数不能保证。如果解析出现错误,我们使用None值作为后备:accuracy_scores = [] style_scores = [] for evaluation in dataset['evaluation']: try: eval_dict = json.loads(evaluation) if isinstance(evaluation, str) else evaluation accuracy_score = eval_dict['accuracy']['score'] style_score = eval_dict['style']['score'] accuracy_scores.append(accuracy_score) style_scores.append(style_score) except (json.JSONDecodeError, KeyError, TypeError): accuracy_scores.append(None) style_scores.append(None) -
我们添加两个新列来存储准确性和风格分数,以进行进一步分析:
if 'accuracy' in dataset.column_names: dataset = dataset.remove_columns(['accuracy']) dataset = dataset.add_column('accuracy', accuracy_scores) if 'style' in dataset.column_names: dataset = dataset.remove_columns(['style']) dataset = dataset.add_column('style', style_scores) -
让我们将生成的答案、评估和分数的最终数据集推送到 Hugging Face Hub:
dataset.push_to_hub(f"mlabonne/{model_id.split('/')[-1]}-results") return dataset -
我们现在可以用我们选择的三个模型调用
evaluate_answers()函数:model_ids = [ 'mlabonne/TwinLlama-3.1-8B', 'mlabonne/TwinLlama-3.1-8B-DPO', 'meta-llama/Meta-Llama-3.1-8B-Instruct' ] for model_id in model_ids: evaluate_answers(model_id)
通过保存中间结果,我们的评估框架对失败更加健壮。它也容易扩展到其他模型、数据集和标准。
分析结果
在这个评估过程的最后,我们可以审查三个元素:模型答案、评估的解释和分数。
虽然审查答案不可扩展,但它是一个至关重要的步骤,可以帮助识别许多常见错误,例如错误的聊天模板或错误的模型。在这里,我们将关注一个单独的指令,并阅读我们两个模型和 Llama-3.1-8B-Instruct 提供的答案:
| 指令讨论算法偏差的概念及其影响。 |
| --- |
| TwinLlama-3.1-8B算法偏差指的是算法由于潜在假设或编程偏差而倾向于产生偏斜或不公平的结果。这可能会产生重大影响,尤其是在机器学习和人工智能等领域,这些领域依赖于算法做出关键决策。如果一个算法没有经过适当的偏见审查,可能会导致歧视性结果,例如贷款实践对某些人口群体产生不成比例的影响或招聘流程不公平地偏袒某些候选人。确保算法在设计和管理时考虑到公平性和准确性,以减轻这些风险是至关重要的。 |
| TwinLlama-3.1-8B-DPO算法偏差指的是算法由于潜在假设或编程偏差而倾向于产生偏斜或不公平的结果。这可能会产生重大影响,尤其是在机器学习和人工智能等领域,这些领域依赖于算法做出关键决策。如果一个算法没有经过适当的偏见审查,可能会导致歧视性结果,例如拒绝向某些人口群体发放贷款或持续传播刻板印象。因此,积极应对算法偏差对于确保我们开发的技术公平和正义至关重要。 |
| Llama-3.1-8B-Instruct 算法偏差,也称为算法偏差,指的是机器学习模型中可能影响其性能、准确性和公平性的无意或固有偏差。这种偏差可能由各种因素引起,包括用于训练模型的数据、算法本身以及社会或文化中存在的偏差。算法偏差的最显著影响之一是其可能持续并加剧现有的社会不平等。例如,面部识别软件已被证明对有色人种的准确性较低,导致错误的逮捕和歧视。同样,语言处理模型被发现偏向于主流群体的语言和方言,使得边缘化社区难以获取信息和提供服务。算法偏差在医疗保健、教育和就业等领域也可能产生严重后果。例如,偏向于某些人口统计数据的医疗诊断算法可能会误诊或漏诊其他群体的条件,导致治疗延迟或不充分。在教育领域,偏向于某些学习风格的算法可能会不成比例地影响不同学习能力的学生,导致机会不均等。为了减轻算法偏差的影响,解决偏差的根本原因并实施确保机器学习模型公平性和透明度的措施至关重要。这可以通过数据清洗、正则化和去偏技术实现,以及实施多样化和代表性数据集。此外,至关重要的是要涉及多元化的利益相关者,包括边缘化社区的成员,以确保算法的开发和测试是公平和公正的。总之,算法偏差是一个重大问题,可能对个人和社会整体产生深远影响。认识到机器学习模型中的潜在偏差并采取积极措施减轻其影响至关重要。通过这样做,我们可以创建更加公平和正义的系统,使所有人受益,无论其背景或人口统计特征如何。 |
表 7.4:给定指令生成的答案示例
立即可以看出,我们模型的答案非常接近。Llama-3.1-8B-Instruct 的答案并非如此,它非常冗长,列举了许多例子。这种类型的答案通常是正确的,但由于其长度,可能会感觉人工且不愉快。另一方面,DPO 模型略微简化了 SFT 版本的措辞,使其不那么学术化。这正是我们想要捕捉的行为,即修改写作风格但不改变答案的实际内容。
现在我们来回顾 GPT-4o-mini 为每个答案提供的评估。
| TwinLlama-3.1-8B |
| --- |
| 准确性该答案准确定义了算法偏差,并强调了其在机器学习、人工智能等领域的含义。它正确地识别了由于偏差算法导致的歧视性结果的风险。没有明显的事实错误,这使得这是一个全面的回答。评分:3 | 风格语气信息丰富且相对易于理解,尽管在某些部分(例如,“潜在假设或编程偏差”)中趋向于正式。虽然它适当地使用了一些技术术语,但可以进一步简化,以适应更广泛的受众,特别是对于博客或社交媒体内容。评分:2 |
| TwinLlama-3.1-8B-DPO |
| 准确性该答案准确定义了算法偏差,并概述了其在机器学习、人工智能等关键领域的含义。它突出了可能导致歧视性结果的可能性,因此是对该主题的全面概述,没有任何事实错误。评分:3 | 风格写作风格清晰、易于理解,有效地传达了算法偏差的技术概念,而没有变得过于正式。虽然它使用了一些技术术语,但对于一般受众来说,它们是适当且解释良好的。评分:3 |
| Llama-3.1-8B-Instruct |
| 准确性该答案准确定义了算法偏差,并讨论了其在面部识别、医疗保健和教育等各个领域的成因和影响。它还正确地识别了减轻偏差的方法,例如涉及多元化的利益相关者和采用去偏技术。总体而言,信息是事实性的且全面的。评分:3 | 风格语气信息丰富且易于理解,技术术语解释清晰。然而,一些短语如“无意或固有的偏差”和“实施多元化和代表性数据集”可能需要简化以吸引更广泛的受众。写作总体上适合博客或社交媒体内容,但可能从更随和的语气中受益。评分:2 |
表 7.5:根据风格和准确性对 GPT-4o-mini 每个答案的评价
根据我们的评估者 LLM,答案的准确性没有问题,获得了满分。然而,对于 TwinLlama-3.1-8B(SFT)和 Llama-3.1-8B-Instruct,其风格被认为过于正式,评分为 2。评估者 LLM 同意我们之前的分析,并将满分分配给了 TwinLlama-3.1-8B-DPO 的答案,因为它“在不过度正式的情况下传达了算法偏差的技术概念”。
这种趋势由每个模型获得的平均分数得到证实:
TwinLlama-3.1-8B - Accuracy: 2.45
TwinLlama-3.1-8B - Style: 2.04
TwinLlama-3.1-8B-DPO - Accuracy: 2.46
**TwinLlama-3.1-8B-DPO - Style: 2.12**
**Llama-3.1-8B-Instruct - Accuracy: 2.62**
Llama-3.1-8B-Instruct - Style: 1.86
在准确性方面,我们的两个微调模型获得了相似的分数,而 Llama-3.1-8B-Instruct 实现了最高的准确性分数 2.62。这表明,经过训练的 Llama 模型在提供事实性信息方面可能略有优势。这可能是由于其超过 1000 万个样本的广泛后训练过程(相比之下,我们的案例中为 13000 个)。
然而,当涉及到风格时,我们看到了不同的模式。TwinLlama-3.1-8B-DPO 以 2.12 的分数领先,成功实现了更易于理解和不太正式的写作风格,同时没有牺牲内容质量。TwinLlama-3.1-8B(SFT)以 2.04 的分数紧随其后,显示出改进但保留了一些正式性,而 Llama-3.1-8B-Instruct 以 1.86 的分数落后,趋向于冗长。
基于这些反馈和手动审查生成的答案,我们可以检测错误并确定改进领域。这对于通过额外过滤或用缺失信息增强数据集来完善数据生成过程至关重要。虽然这个版本已经显示出有希望的结果,但通过迭代不同的数据集和模型,我们将能够显著超越基线,并为我们的用例创建最佳模型。
摘要
在本章中,我们探讨了使用模型和 RAG 系统进行 LLM 评估。我们看到了如何解释经典基准如 MMLU 来选择使用或微调的强大候选者。我们还详细介绍了特定领域和特定任务的评估如何工作,以及如何根据公开可用的示例创建自己的评估。
我们将这些定制评估框架的核心聚焦于两种技术(多项选择题回答和 LLM 作为裁判)。
然而,模型通常被集成到提供额外上下文的更广泛系统中。我们为 RAG 系统引入了两个评估框架,Ragas 和 ARES。我们看到了它们在评估 RAG 系统方面的相似之处(例如,合成数据生成)和差异(基于上下文的指标与训练分类器)。最后,我们根据相关性、连贯性和简洁性三个标准,使用裁判 LLM 评估了 TwinLlama-3.1-8B。这为我们提供了如何改进它的见解。
在下一章中,我们将探讨推理优化技术以提高速度并减少内存使用,同时不会显著影响模型性能。我们还将深入研究优化方法、模型并行技术,并考察不同的量化方法。
参考文献
-
Lianmin Zheng 等人。“使用 MT-Bench 和聊天机器人竞技场评估 LLM 作为裁判。”arXiv 预印本 arXiv:2306.05685,2023 年 6 月。
-
Aymeric Roucher。“使用 LLM 作为裁判进行自动化和通用的评估 - Hugging Face 开源 AI 食谱。”huggingface.co,未找到日期,
huggingface.co/learn/cookbook/en/llm_judge。 -
LangChain。“*将 LLM 作为裁判与人类偏好对齐。”blog.langchain.dev,2024 年 6 月 26 日,
blog.langchain.dev/aligning-llm-as-a-judge-with-human-preferences/。 -
Dan Hendrycks 等人。“衡量大规模多任务语言理解。”arXiv 预印本 arXiv:2009.03300,2020 年 9 月。
-
Jeffrey Zhou 等人. “大型语言模型的指令遵循评估.” arXiv 预印本 arXiv:2311.07911, 2023 年 11 月.
-
Yann Dubois 等人. “长度控制的 AlpacaEval: 一种简单的方法来消除自动评估器的偏差.” arXiv 预印本 arXiv:2404.04475, 2024 年 4 月.
-
Grégoire Mialon 等人. “GAIA: 通用人工智能助手的基准.” arXiv 预印本 arXiv:2311.12983, 2023 年 11 月.
-
Giwon Hong 等人. “幻觉排行榜 -- 一个衡量大型语言模型中幻觉的公开努力.” arXiv 预印本 arXiv:2404.05904, 2024 年 4 月.
-
Shahul Es 等人. “RAGAS: 自动评估检索增强生成.” arXiv 预印本 arXiv:2309.15217, 2023 年 9 月.
-
Jon Saad-Falcon 等人. “ARES: 检索增强生成系统的自动评估框架.” arXiv 预印本 arXiv:2311.09476, 2023 年 11 月.
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第八章:推理优化
由于 LLM 具有显著的计算和内存需求,部署 LLM 具有挑战性。高效运行这些模型需要使用专门的加速器,如 GPU 或 TPU,这些加速器可以并行化操作并实现更高的吞吐量。虽然一些任务,如文档生成,可以在夜间批量处理,但其他任务,如代码补全,则需要低延迟和快速生成。因此,优化推理过程——即这些模型如何根据输入数据进行预测——对于许多实际应用至关重要。这包括减少生成第一个标记所需的时间(延迟),增加每秒生成的标记数量(吞吐量),以及最小化 LLM 的内存占用。
事实上,简单的部署方法会导致硬件利用率低,吞吐量和延迟不尽如人意。幸运的是,各种优化技术已经出现,可以显著加快推理速度。本章将探讨关键方法,如推测性解码、模型并行化和权重量化,展示精心设计的实现如何实现 2-4 倍或更高的加速。我们还将介绍三种流行的推理引擎(文本生成推理、vLLM 和 TensorRT-LLM),并比较它们在推理优化方面的特性。
在本章中,我们将涵盖以下主题:
-
模型优化策略
-
模型并行化
-
模型量化
到本章结束时,你将了解 LLM 推理的核心挑战,并熟悉最先进的优化技术,包括模型并行化和权重量化。
本章中所有的代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/LLM-Engineering。
模型优化策略
目前大多数使用的 LLM,如 GPT 或 Llama,都由仅包含解码器的 Transformer 架构驱动。仅包含解码器的架构是为文本生成任务设计的。它根据前面的单词预测序列中的下一个单词,这使得它在生成上下文适当的文本续写方面非常有效。
相比之下,仅包含编码器架构,如 BERT,专注于通过详细的嵌入来理解和表示输入文本。它在需要全面上下文理解的任务中表现出色,例如文本分类和命名实体识别。最后,编码器-解码器架构,如 T5,结合了这两种功能。编码器处理输入文本以生成丰富的上下文表示,解码器随后使用这些表示来生成输出文本。这种双重结构对于翻译和摘要等序列到序列任务特别强大,在这些任务中,理解输入上下文和生成相关输出同等重要。
在本书中,我们只关注仅包含解码器的架构,该架构主导了 LLM 领域。

图 8.1 – 仅使用解码器模型的推理过程。我们以“我有一个梦想”作为输入,得到“of”作为输出。
如图 8.1所示,仅解码器模型的基本推理过程包括:
-
标记化输入提示并将其通过嵌入层和位置编码。
-
使用多头注意力机制为每个输入标记计算键和值对。
-
逐个、顺序地生成输出标记,使用计算出的键和值。
虽然步骤 1和步骤 2在计算上很昂贵,但它们由高度可并行化的矩阵乘法组成,可以在 GPU 和 TPU 等加速器上实现高硬件利用率。
真正的挑战在于步骤 3中的标记生成本质上是顺序的 – 要生成下一个标记,你需要生成所有之前的标记。这导致了一个迭代过程,其中输出序列逐个标记地增长,未能利用硬件的并行计算能力。解决这个瓶颈是推理优化的核心重点之一。
在本节中,我们将详细介绍几种常用的优化策略,这些策略可以加快推理速度并减少视频随机存取存储器(VRAM)的使用,例如实现(静态)KV 缓存、连续批处理、推测解码和优化的注意力机制。
KV 缓存
我们看到,大型语言模型(LLMs)是逐个生成文本标记的,这很慢,因为每个新的预测都依赖于整个先前的上下文。例如,要预测序列中的第 100 个标记,模型需要 1 到 99 个标记的上下文。在预测第 101 个标记时,它又需要 1 到 99 个标记的信息,加上标记 100。这种重复计算特别低效。
键值对(KV)缓存通过存储自注意力层生成的键值对来解决此问题。模型不再为每个新标记重新计算这些对,而是从缓存中检索它们,从而显著加快了生成速度。
你可以在图 8.2中看到这种技术的说明:

图 8.2 – KV 缓存的说明
当生成新标记时,只需要计算并添加到缓存中该单个标记的键和值。KV 缓存是每个流行工具和库中实施的即时优化。一些实现为模型的每一层维护一个单独的 KV 缓存。
KV 缓存的大小与标记数(
)和几个模型维度成比例,如层数(
)、注意力头数(
)、它们的维度(
)以及参数的精度(以字节为单位)(
):

对于一个典型的 7B 参数模型,使用 16 位精度,对于高序列长度(高于 2,048 个标记),这超过了 2 GB。具有更多层和更高嵌入维度的更大模型将看到更大的内存需求。
由于 KV 缓存随着每个生成步骤的增长而增长且是动态的,它阻止了你利用 torch.compile,这是一个强大的优化工具,可以将 PyTorch 代码融合到快速和优化的内核中。静态 KV 缓存通过预先分配 KV 缓存大小到最大值来解决这个问题,这允许你将其与torch.compile结合使用,在正向传递中实现高达 4 倍的速度提升。
要配置模型使用 transformers 库中的静态 KV 缓存,请按照以下步骤操作:
-
我们导入标记化器和我们要优化的模型:
import torch from transformers import AutoTokenizer, AutoModelForCausalLM model_id = "google/gemma-2b-it" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto") -
要实现静态缓存,我们将模型生成配置中的缓存实现更改为
static:model.generation_config.cache_implementation = "static" -
现在 KV 缓存是静态的,我们可以使用 torch.compile 编译模型:
compiled_model = torch.compile(model, mode="reduce-overhead", fullgraph=True) -
我们对输入问题进行标记化,“
2+2 等于多少?",并在可用的 GPU 上存储它(如果不可用,我们将其存储在 CPU 上):device = "cuda" if torch.cuda.is_available() else "cpu" inputs = tokenizer("What is 2+2?", return_tensors="pt").to(device) -
让我们使用
generate()方法获取模型的输出,并用batch_decode()解码以打印其答案:outputs = model.generate(**inputs, do_sample=True, temperature=0.7, max_new_tokens=64) print(tokenizer.batch_decode(outputs, skip_special_tokens=True)) **[****'What is 2+2?\n\nThe answer is 4\. 2+2 = 4.'****]**
这返回一个包含输入和输出的列表,正确回答了我们的问题。
注意,静态缓存并不适用于所有架构。有关支持哪些架构的详细信息,请查看 transformers 文档。
有效管理 KV 缓存至关重要,因为它可以快速耗尽可用的 GPU 内存并限制可以处理的批处理大小。这促使开发了内存高效的注意力机制和其他技术,我们将在最后一节中介绍。
连续批处理
批处理,或同时处理多个推理请求,是实现高吞吐量的标准方法。较大的批处理大小可以分散模型权重的内存成本,并一次性传输更多数据到 GPU,更好地饱和其并行计算能力。
然而,仅解码器模型由于输入提示长度和期望输出长度的极高可变性而带来特定的挑战。一些请求可能有简短的提示并且只需要一个单词的回答,而其他请求可能输入一个冗长的上下文并期望多段落的响应。
在传统的批处理中,我们必须等待批处理中请求的最长时间完成,然后才能开始新的批处理。这导致加速器部分空闲,等待拖沓的请求完成,从而造成低效。连续批处理,也称为飞行中批处理,旨在通过在请求完成时立即将新的请求喂入批处理来防止空闲时间。
批处理过程开始时相同——通过填充初始请求。但是,一旦一个请求完成其生成,它就会被从批处理中移除,一个新的请求取而代之。这样,加速器始终在处理一个完整的批处理,从而实现最大化的硬件利用率。另一个需要考虑的因素是需要定期暂停生成过程以运行预填充,即等待请求的嵌入和编码。在生成和预填充之间找到最佳平衡需要调整等待-服务比率超参数。
连续批处理在大多数推理框架中是原生实现的,例如 Hugging Face 的文本生成推理(TGI)、vLLM 和 NVIDIA TensorRT-LLM。
推测解码
另一种强大的优化技术是推测解码,也称为辅助生成。关键洞察是,即使在连续批处理中,按标记逐个生成的过程也无法充分利用加速器的并行处理能力。推测解码旨在使用这种额外的计算能力同时预测多个标记,使用较小的代理模型(见图 8.3)。

图 8.3 – 传统解码(左)和推测解码(右)的示意图
通用方法是:
-
应用一个较小的模型,例如主模型的蒸馏或剪枝版本,以并行预测多个标记补全。这可能是单步预测 5-10 个标记。
-
将这些推测补全输入到完整模型中,以验证哪些预测与大型模型生成的结果相匹配。
-
保留推测补全中最长的匹配前缀,并丢弃任何错误的标记。
结果是,如果小模型很好地近似大模型,可以在单步中生成多个标记。这避免了多次迭代运行昂贵的大型模型。加速的程度取决于小模型预测的质量——90%的匹配可能导致 3-4 倍的速度提升。
确保两个模型使用相同的分词器至关重要。如果不是这样,草稿模型生成的标记将不会与大型模型产生的标记对齐,使它们不兼容。让我们使用 transformers 库来实现这一点。在这个例子中,我们将使用来自阿里巴巴云的两个 Qwen1.5 模型:一个 1.8B 版本作为主模型,一个 0.5B 版本作为草稿模型。注意,如果您有足够的 VRAM,您可以使用更大的模型,如 14B、32B、72B 或 110B 作为主模型。
在这里,我们受限于 Google Colab 中 T4 GPU 的 VRAM,但要获得最大的加速效果,辅助模型应该远小于大型模型。
下面是实现推测性解码的逐步指南:
-
我们加载了分词器和两个模型:
import torch from transformers import AutoTokenizer, AutoModelForCausalLM model_id = "Qwen/Qwen1.5-1.8B-Chat" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto") draft_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen1.5-0.5B-Chat", device_map="auto") -
然后,我们将相同的输入进行分词并存储在加速器中,如果有的话:
device = "cuda" if torch.cuda.is_available() else "cpu" inputs = tokenizer("What is 2+2?", return_tensors="pt").to(device) -
我们现在可以使用
model.generate()并带有assistant_model参数来启用推测性解码:outputs = model.generate(**inputs, do_sample=True, assistant_model=draft_model, temperature=0.7, max_new_tokens=64) print(tokenizer.batch_decode(outputs, skip_special_tokens=True)) **[****'What is 2+2? 2 + 2 equals 4!'****]**
在这个小型示例中,加速效果并不显著,但使用更大的模型时,这种加速效果是明显可感知的。
提示查找解码 是一种推测性解码的变体,适用于输入与输出有重叠的任务,如摘要。在这里,共享的 n-gram 被用作 LLM 的候选标记。我们可以通过在 model.generate() 中使用 prompt_lookup_num_tokens 参数来启用提示查找解码:
outputs = model.generate(**inputs, prompt_lookup_num_tokens=4)
通过结合静态 KV 缓存、使用 torch.compile 实现连续批处理以及利用推测性解码技术,LLMs 可以在质量不受损失的情况下看到 2-4 倍或更多的推理速度提升。
创建小型代理模型的另一种方法是与大型模型联合微调一个小型模型,以实现最大的保真度。这里的一个代表性技术是 Medusa,它将专门的推测头插入到主模型中。Medusa-1 方法在冻结大型模型的同时微调这些推测头,而 Medusa-2 方法联合微调推测头和大型模型。Medusa 方法已经展示了令人印象深刻的结果,使一个 70M 参数的模型能够在一系列任务上接近一个 7B 参数模型的性能。推测性解码在 TGI 中原生支持。
优化的注意力机制
Transformer 架构基于注意力机制,其复杂度与输入标记数(或序列长度)的平方成正比。这对于较长的序列来说尤其低效,因为 KV 缓存的大小可能会急剧增加。
Kwon、Li 等人(2023)提出的 PagedAttention 通过从操作系统的虚拟内存和分页中汲取灵感来解决这些内存挑战。它将 KV 缓存划分为块,消除了对连续内存分配的需求。每个块包含一定数量的标记的键和值。在注意力计算过程中,PagedAttention 内核有效地检索这些块,无论它们的物理内存位置如何。
这种分区可以实现接近最优的内存利用率。这对于将更多序列一起批处理很有用,这增加了吞吐量和 GPU 利用率。此外,PagedAttention的基于块的策略自然支持来自同一提示的多个输出序列之间的内存共享。这在并行采样和 beam search 中特别有利,因为在这些情况下,使用相同的提示生成多个输出。共享的内存块减少了冗余计算和内存使用,根据作者的说法,内存开销减少了高达 55%,吞吐量提高了高达 2.2 倍。vLLM 库首先实现了 PagedAttention。从那时起,PagedAttention 也已在 TGI 和 TensorRT-LLM 中得到实现。
另一个流行的选项是FlashAttention-2。由 Tri Dao(2023)开发,它引入了几个旨在解决传统注意力中二次运行时间和内存约束的关键创新。通过将输入和输出矩阵划分为更小的块,FlashAttention-2 确保这些块可以适应 GPU 的片上 SRAM,这比高带宽内存要快得多。这种方法显著减少了 GPU 主内存与其处理单元之间数据传输的频率。
这与在线 softmax 相结合,它独立地为注意力分数矩阵的每个块计算 softmax 函数,而不是一次性计算整个矩阵。通过维护指数的运行最大值和运行总和,FlashAttention-2 可以在不存储大型中间矩阵的情况下计算注意力概率。
此外,FlashAttention-2 的在线 softmax 计算允许块级处理,在保持准确性的同时显著降低内存需求。这对于训练尤为重要,因为在反向传播中重新计算中间值(而不是存储它们)可以将内存使用从与序列长度成二次关系降低到线性关系。
与 PagedAttention 不同,FlashAttention-2 可以通过attn_implementation参数轻松地与 transformers 库一起使用:
-
使用
--no-build-isolation安装flash-attn库,这样我们就不需要安装依赖项:pip install flash-attn --no-build-isolation -
要使用 FlashAttention-2 进行推理,在加载模型时在
attn_implementation参数中指定flash_attention_2。例如,这是如何使用 FlashAttention-2 加载 Mistral-7B-Instruct-v0.3 的示例:from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained( "mistralai/Mistral-7B-Instruct-v0.3", attn_implementation="flash_attention_2", )
本节中介绍的技术主要集中在提高模型处理令牌的效率。在下一节中,我们将讨论如何将我们的模型和计算分布到多个 GPU 上。
模型并行化
模型并行化允许您将 LLM 的内存和计算需求分布到多个 GPU 上。这使得训练和推理那些无法适应单个设备的模型成为可能,同时也在吞吐量(每秒令牌数)方面提高了性能。
模型并行有三种主要方法,每种方法都涉及以不同的方式分割模型权重和计算:数据并行、流水线并行和张量并行。
尽管这些方法最初是为训练开发的,但我们可以通过仅关注正向传递来重新用于推理。
数据并行
数据并行(DP)是最简单的一种模型并行方式。它涉及复制模型并在不同的 GPU 上分配这些副本(参见图 8.4)。每个 GPU 同时处理数据的一个子集。在训练过程中,每个 GPU 上计算的梯度被平均并用于更新模型参数,确保每个副本保持同步。这种方法在批大小太大而无法适应单个机器或旨在加快训练过程时特别有益。

图 8.4 – 使用四个 GPU 的数据并行示意图
在推理过程中,DP(数据并行)可以用于处理并发请求。通过将工作负载分配到多个 GPU 上,这种方法有助于降低延迟,因为可以同时处理多个请求。这种并发处理也提高了吞吐量,因为可以同时处理更多的请求。
然而,DP 的有效性受到模型大小和 GPU 之间通信开销的限制。实际上,在每个 GPU 上复制模型参数是不高效的。这意味着这种技术仅在模型足够小,可以适应单个 GPU 时才有效,从而为输入数据留下较少的空间,从而限制了批大小。对于更大的模型或当内存成为限制时,这可能是一个重大的缺点。
通常,DP 主要用于训练,而流水线和张量并行则更适用于推理。
流水线并行
由 Huang 等人于 2019 年在 GPipe 论文中引入的流水线并行(PP)是一种将训练和运行大型神经网络的计算负载分配到多个 GPU 的策略。
与在传统 DP 中在每个 GPU 上复制整个模型不同,流水线并行将模型的层分配到不同的 GPU 上。这种方法允许每个 GPU 处理模型的一个特定部分,从而减轻了单个 GPU 的内存负担。

图 8.5 – 使用四个 GPU 的流水线并行示意图
如图 8.5所示,在典型的四路管道并行分割中,模型被分为四个部分,每个部分分配给不同的 GPU。模型的前 25%层可能由 GPU 1 处理,接下来的 25%由 GPU 2 处理,依此类推。在正向传递过程中,激活被计算然后传递到下一个 GPU。对于训练,反向传递遵循类似的顺序,但方向相反,梯度通过 GPU 反向传播。GPU 的数量通常被称为并行度。
管道并行化的主要优势在于其能够显著降低每个 GPU 的内存需求。然而,这种方法引入了新的挑战,尤其是与管道的顺序性质相关的问题。其中一个主要问题是“管道气泡”的出现。这些气泡发生在某些 GPU 空闲,等待前一层激活时。这种空闲时间可能会降低整个过程的效率。
微批处理是为了减轻管道气泡的影响而开发的。通过将输入批次分成更小的子批次,微批处理确保 GPU 保持忙碌,因为下一个子批次可以在前一个子批次完全完成之前开始处理。

图 8.6 – 微批处理管道并行的示意图。
图 8.6展示了带有微批处理的管道并行化示例。在这个例子中,管道有四个阶段(F0、F1、F2、F3),输入批次被分为四个微批次。GPU 0 将依次处理正向路径F0,0、F0,1、F0,2 和F0,3。一旦F0,0 完成,GPU 1 可以立即开始处理F1,0,依此类推。完成这些正向传递后,GPU 0 等待其他 GPU 完成各自的正向计算,然后开始反向路径(B0,3、B0,2、B0,1 和B0,0)。
管道并行化在分布式训练框架如 Megatron-LM、DeepSpeed (ZeRO)和 PyTorch 中通过专门的PyTorch 管道并行化库(PiPPy)实现。在撰写本文时,只有某些推理框架如 TensorRT-LLM 支持管道并行化。
张量并行化
由 Shoeby、Patwary、Puri 等人于 2019 年在 Megatron-LM 论文中提出,张量并行化(TP)是另一种流行的技术,用于将 LLM 层的计算分布在多个设备上。与管道并行化不同,TP 将单个层中找到的权重矩阵分割。这允许同时计算,显著减少内存瓶颈并提高处理速度。
在 TP 中,大型矩阵,如 MLP 中的权重矩阵或自注意力层中的注意力头,被分配到多个 GPU 上。每个 GPU 持有这些矩阵的一部分,并在其相应的切片上进行计算。

图 8.7 – MLP 层中列向张量并行化的示意图(W)
例如,在一个 MLP 层中,权重矩阵被分割,使得每个 GPU 只处理权重的一个子集(参见图 8.7)。输入被广播到所有 GPU,然后它们独立地计算各自的输出。然后通过 all-reduce 操作聚合部分结果,将它们组合成最终的输出。
在自注意力层的上下文中,TP 由于注意力头固有的并行性而特别高效。每个 GPU 可以独立计算这些头的一部分,这使得模型能够更有效地处理长序列。这使得 TP 比需要等待前一层完成的管道并行化更高效。
尽管 TP 具有优势,但它并不适用于神经网络的所有层。像 LayerNorm 和 Dropout 这样的层,其依赖关系跨越整个输入,无法有效地分区,通常会在设备之间复制。然而,这些操作可以在输入序列的序列维度上拆分(序列并行化)。不同的 GPU 可以在输入序列的不同切片上计算这些层,避免权重的复制。这项技术仅限于少数特定层,但它可以为非常大的输入序列长度提供额外的内存节省。
此外,TP 需要在设备之间实现高速互连以最小化通信开销,这使得在互连带宽不足的节点上实现变得不切实际。
TP 也被实现于分布式训练框架中,如 Megatron-LM、DeepSpeed (ZeRO)和 PyTorch (FSDP)。它在大多数推理框架中都是可用的,如 TGI、vLLM 和 TensorRT-LLM。
组合方法
数据、张量和平行化是可组合的正交技术。图 8.8展示了如何根据每种方法将给定的模型进行拆分:

图 8.8 – 不同模型并行化技术的示意图
结合这些技术可以缓解它们各自的问题。管道并行化提供了最大的内存减少,但牺牲了效率,因为存在管道气泡。如果主要约束适合 GPU 内存中的模型,这可能是一个理想的选择。相反,如果低延迟至关重要,那么优先考虑张量并行化并接受更大的内存占用可能是一个更好的权衡。在实践中,模型可能被深度拆分为几个管道阶段,每个阶段使用张量并行化。
平衡这些权衡并映射给定的模型架构到可用的硬件加速器是部署大型语言模型(LLMs)的一个关键挑战。
模型量化
量化是指使用低精度数据类型来表示神经网络权重和激活的过程。在 LLMs 的上下文中,量化主要关注降低模型权重和激活的精度。
默认情况下,权重通常以 16 位或 32 位浮点格式(FP16 或 FP32)存储,这提供了高精度,但代价是增加了内存使用和计算复杂性。量化是一种减少内存占用并加速 LLMs 推理的解决方案。
除了这些好处外,当量化到 2 位或 3 位精度时,超过 300 亿参数的大型模型在质量方面可以优于较小的模型(7B–13B LLMs)。这意味着它们可以在保持可比内存占用的情况下实现更优越的性能。
在本节中,我们将介绍量化的概念、GGUF 与llama.cpp、GPTQ 和 EXL2,以及额外技术的概述。除了本节中提供的代码外,您还可以参考 AutoQuant (bit.ly/autoquant),使用 Google Colab 笔记本量化他们的模型。
量化简介
权重量化的主要有两种方法:训练后量化(PTQ)和量化感知训练(QAT)。PTQ 是一种直接将预训练模型的权重转换为低精度格式而不进行任何重新训练的直接技术。虽然 PTQ 易于实现,但它可能会导致一些性能下降。相反,QAT 在训练或微调阶段进行量化,允许模型适应低精度权重。与 PTQ 相比,QAT 通常能提供更好的性能,但需要额外的计算资源和代表性的训练数据。
数据类型的选择在量化中起着至关重要的作用。在深度学习中,浮点数,如FP32、FP16(半精度)和BF16(脑浮点),被广泛使用。这些格式为表示数字的符号、指数和尾数(尾数)分配了固定数量的位。

图 8.9 – FP32、FP16 和 BF16 格式的比较
0 的符号表示正数,而 1 表示负数。相反,指数控制表示的范围(大或小)。最后,尾数控制数字的精度(数字的位数)。将这些表示转换为实数的公式是:

图 7.7中显示的数据类型展示了不同的权衡,如图
(
)的不同表示所示。FP32 使用 32 位,提供高精度,但也需要更多的内存。相反,FP16 和 BF16 使用 16 位,降低了内存占用,但精度较低。通常,神经网络更喜欢更大的范围而不是更高的精度,这就是为什么当硬件支持时,BF16 是最受欢迎的数据类型。例如,NVIDIA 的 Ampere 架构(A100、A30 等)支持 BF16,但之前的 Turing(T4、T40 等)不支持。
然而,我们并不局限于这三种数据类型。可以使用低精度数据类型,如 INT8(8 位整数),进行量化,进一步减少内存占用。简单的量化技术,如绝对最大值(absmax)量化和零点量化,可以将FP32、FP16或BF16权重转换为INT8,如图图 8.10所示:

图 8.10 – 使用 absmax 量化和零点量化在[-3.0, 3.2]范围内的 0.1 量化
Absmax 量化将原始权重
映射到范围[-127, 127],通过除以
的绝对最大值并对其进行缩放:

例如,如果我们的绝对最大值是 3.2(见图 8.8),则权重 0.1 将被量化为
。为了去量化它,我们执行逆操作:

这意味着如果我们去量化我们的权重,我们将获得
。在这个例子中,我们可以看到一个舍入误差
。在 Python 中,我们可以使用 PyTorch 库如下实现:
import torch
def absmax_quantize(X):
# Calculate scale
scale = 127 / torch.max(torch.abs(X))
# Quantize
X_quant = (scale * X).round()
return X_quant.to(torch.int8)
另一方面,零点量化考虑了非对称输入分布,并通过引入零点偏移将权重
映射到范围[-128, 127]:

其中
和
。
如果我们用相同的例子,权重为 0.1,我们得到一个缩放因子
和一个零点值
。权重 0.1 将被量化为
,而不是由 absmax 提供的
值。
我们可以通过应用逆操作轻松地获得去量化:

在 Python 中,零点量化可以如下实现:
def zeropoint_quantize(X):
# Calculate value range (denominator)
x_range = torch.max(X) - torch.min(X)
x_range = 1 if x_range == 0 else x_range
# Calculate scale
scale = 255 / x_range
# Shift by zero-point
zeropoint = (-scale * torch.min(X) - 128).round()
# Scale and round the inputs
X_quant = torch.clip((X * scale + zeropoint).round(), -128, 127)
return X_quant.to(torch.int8)
然而,简单的量化方法存在局限性,尤其是在处理 LLMs 中的异常特征时。异常特征是极端的权重值(占总值的约 0.1%),它们可以显著影响量化过程,导致其他值的精度降低。
丢弃这些异常值是不可行的,因为这会降低模型的表现。您可以在图 8.11中看到异常值的示例:

图 8.11 – 权重矩阵中异常值的示例
为了解决异常值问题,已经提出了更先进的量化技术。一个值得注意的例子是 Dettmers 等人(2022)引入的LLM.int8()。LLM.int8()采用混合精度量化方案,其中异常特征使用 FP16 处理,而其余值量化为 INT8。这种方法有效地将 LLM 的内存占用减少了近 2 倍,同时最大限度地减少了性能下降。
LLM.int8()通过三个步骤执行矩阵乘法。首先,它使用自定义阈值从输入隐藏状态中提取包含异常特征列。其次,它对异常值(在FP16中)和非异常值(在INT8中)执行单独的矩阵乘法,使用向量量化。最后,它对非异常结果进行反量化,并将它们与异常结果合并,以获得最终的FP16输出。
LLM.int8()的有效性已经通过实证研究得到证明,与原始的FP32模型相比,性能下降可忽略不计(<1%)。然而,它确实引入了额外的计算开销,导致大型模型的推理速度大约慢 20%。可以使用 transformer 库直接以 8 位精度加载模型,使用LLM.int8(),如下所示:
from transformers import AutoModelForCausalLM
model_name = "meta-llama/Meta-Llama-3-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto", load_in_8bit=True)
由Dettmers 等人(2023)引入的 NF4 是一种 4 位精度格式,专为 QLoRA(在第五章中讨论)设计。它还集成到 transformers 库中,但需要 bitsandbytes 库作为依赖项。要加载 NF4(4 位精度)模型,可以使用load_in_4bit参数,如下所示:
from transformers import AutoModelForCausalLM
model_name = "meta-llama/Meta-Llama-3-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto", load_in_4bit=True)
使用 GGUF 和 llama.cpp 进行量化
llama.cpp 项目是由 Georgi Gerganov 创建的开源 C++软件库,旨在使用各种 LLM 进行推理。它是最受欢迎的量化技术,Hugging Face Hub 上有许多量化模型可用。
与依赖于特定硬件的封闭源库(如 CUDA)的其他库相比,llama.cpp 可以在更广泛的硬件上运行。它在没有专用硬件的用户中获得了显著的流行,因为它可以在 CPU 和 Android 设备上运行。此外,llama.cpp 还可以将层卸载到 GPU,加速推理速度。它与不同的推理优化技术兼容,如 FlashAttention-2 和推测性解码。
该项目具有自己的量化格式,GGUF,旨在简化并加快模型加载。GGUF 文件存储张量和元数据,支持从 1 位到 8 位精度的各种格式。它遵循基于使用的位数和特定变体的命名约定,例如:
-
IQ1_S和IQ1_M: 1 位精度 - 非常低质量 -
IQ2_XXS/XS/S/M和Q2_K: 2 位精度 - 通常质量较低,但 IQ2 可以用于大型模型 -
IQ3_XXS/XS/S/M和Q3_K_S/M/L: 3 位精度 - 质量较低,但可用于大型模型 -
IQ4_XS/NL和Q4_K_S/M, Q4_0/1: 4 位精度 - 良好质量,适用于大多数模型 -
Q5_K_S/M和Q5_0/1: 5 位精度 - 高质量 -
Q6_K: 6 位精度 - 非常高质 -
Q8_0: 8 位精度 - 最高质量
为了简要概述 GGUF 量化,llama.cpp 将值分组到块中,并将它们四舍五入到更低的精度。例如,传统的 Q4_0 格式处理每个块 32 个值,根据块中最大的权重值进行缩放和量化(
)。在 Q4_1 中,还添加了块中最小的 L 值(
)。在 Q4_K 中,权重被分成超块,每个超块包含 8 个块,每个块有 32 个值。块缩放和最小值也以 6 位更高的精度进行量化(
)。最后,i-quants 如 IQ4_XS 受到另一种称为 QuIP#的量化技术的启发。这确保了在每组八个中正(或负)量化符号的数量是偶数,并实现了
晶格来存储它们的幅度。
这里是一个如何在 GGUF 格式中量化模型的实际示例。以下步骤可以在 Google Colab 中的免费 T4 GPU 上执行:
-
安装 llama.cpp 和所需的库:
!git clone https://github.com/ggerganov/llama.cpp !cd llama.cpp && git pull && make clean && LLAMA_CUBLAS=1 make !pip install -r llama.cpp/requirements.txt -
下载模型进行转换。我们将从 Hugging Face Hub 提供模型 ID - 例如,
mistralai/Mistral-7B-Instruct-v0.2:MODEL_ID = "mlabonne/EvolCodeLlama-7b" MODEL_NAME = MODEL_ID.split('/')[-1] !git lfs install !git clone https://huggingface.co/{MODEL_ID} -
首先,我们将模型转换为 FP16。这是一个中间工件,将被用于所有 GGUF 量化类型。请注意,llama.cpp 中存在不同的转换脚本,并且与不同的模型兼容:
fp16 = f"{MODEL_NAME}/{MODEL_NAME.lower()}.fp16.bin" !python llama.cpp/convert.py {MODEL_NAME} --outtype f16 --outfile {fp16} -
我们选择一个格式(这里,
Q4_K_M)并开始量化。这个过程在 T4 GPU 上可能需要一个小时:METHOD = "q4_k_m" qtype = f"{MODEL_NAME}/{MODEL_NAME.lower()}.{method.upper()}.gguf" !./llama.cpp/quantize {fp16} {qtype} {METHOD} -
一旦完成,你的量化模型就准备好了。你可以将其下载到本地,或者使用以下代码将其上传到 Hugging Face Hub:
from huggingface_hub import create_repo, HfApi hf_token = "" # Specify your token username = "" # Specify your username api = HfApi() # Create empty repo create_repo( repo_id = f"{username}/{MODEL_NAME}-GGUF", repo_type="model", exist_ok=True, token=hf_token ) # Upload gguf files api.upload_folder( folder_path=MODEL_NAME, repo_id=f"{username}/{MODEL_NAME}-GGUF", allow_patterns=f"*.gguf", token=hf_token )
GGUF 模型可以与 llama-cpp-python 等后端以及 LangChain 等框架一起使用。如果你想要将量化模型集成到一个更广泛系统中,这将非常有用。你也可以直接使用前端与模型进行聊天,例如 llama.cpp 的轻量级服务器、LM Studio 和文本生成 Web UI。这些工具使得与 GGUF 模型交互变得简单,提供类似于 ChatGPT 的体验。
使用 GPTQ 和 EXL2 进行量化
虽然 GGUF 和 llama.cpp 提供了带 GPU 卸载的 CPU 推理,但 GPTQ 和 EXL2 是两种针对 GPU 的量化格式。这使得它们在推理时都比 llama.cpp 快。特别是,EXL2 通过其专用库 ExLlamaV2 提供了最高的吞吐量。
GPTQ 和 EXL2 量化器基于 Frantar 等人(2023 年)提出的 GPTQ 算法。它通过细化最优脑量化(OBQ)方法来优化 LLM 的权重量化,以高效处理大量矩阵。它从 Hessian 逆矩阵的 Cholesky 分解开始,确保数值稳定性。GPTQ 不是严格按照顺序量化权重,而是将它们分批处理,迭代更新列和相关块。这种方法利用了懒惰批更新,减少了计算冗余和内存瓶颈。
虽然 GPTQ 限制在 4 位精度,但 EXL2 提供了高度可定制的精度,可以混合不同的量化级别。这允许每个权重之间精确的比特率在 2 到 8 位之间,例如2.3、3.5或6.0。它还可以将多个量化级别应用于每个线性层,优先考虑更高位量化的重要权重。参数通过多次量化每个矩阵并选择一个组合来选择,该组合在满足目标比特率的同时最小化量化错误。在实践中,这允许 70B 模型在单个 24GB GPU 上以 2.55 位精度运行。
推理本身由支持 GPTQ 和 EXL2 模型的 ExLlamaV2 库处理。
在以下示例中,让我们使用 ExLlamaV2 以 EXL2 格式量化一个模型。这些步骤可以在 Google Colab 中的免费 T4 GPU 上执行:
-
从源代码安装 ExLlamaV2 库:
!git clone https://github.com/turboderp/exllamav2 !pip install -e exllamav2 -
通过从 Hugging Face Hub 克隆其 repo 来下载用于量化的模型:
MODEL_ID = "meta-llama/Llama-2-7b-chat-hf" MODEL_NAME = MODEL_ID.split('/')[-1] !git lfs install !git clone https://huggingface.co/{MODEL_ID} -
下载用于测量量化错误的校准数据集。在这种情况下,我们将使用 WikiText-103,这是一个包含来自维基百科高质量文章的标准校准数据集:
!wget https://huggingface.co/datasets/wikitext/resolve/9a9e482b5987f9d25b3a9b2883fc6cc9fd8071b3/wikitext-103-v1/wikitext-test.parquet -
在给定的精度下量化模型(例如,4.5):
!mkdir quant !python exllamav2/convert.py \ -i {MODEL_NAME} \ -o quant \ -c wikitext-test.parquet \ -b 4.5
量化模型然后可以上传到 Hugging Face Hub,如前所述。
GPTQ 和 EXL2 量化器不像 GGUF 那样得到广泛支持。例如,LM Studio 等前端目前尚未集成它们。您可以使用其他工具,如 oobabooga 的文本生成 Web UI。它也直接集成到 transformers 库中,并得到 TGI 的支持。GPTQ 模型也支持在 TensorRT-LLM 中。
虽然 GGUF 更受欢迎,但您可以在 Hugging Face Hub 上找到许多 GPTQ 和 EXL2 模型。
其他量化技术
除了 GGUF、GPTQ 和 EXL2 之外,还有许多其他量化技术。本小节将简要介绍激活感知权重量化(AWQ)以及极端量化技术,如 QuIP#(具有失真处理的量化)和 HQQ(半二次量化)。
由 Lin 等人(2023 年)引入的 AWQ 是另一种流行的量化算法。它识别并保护最重要的权重,这些权重是基于激活幅度而不是权重幅度确定的。这种方法涉及对这些显著的权重应用最优的每通道缩放,而不依赖于反向传播或重建,确保 LLM 不会过度拟合校准集。尽管它依赖于不同的范式,但 AWQ 与 GPTQ 和 EXL2 版本相当接近,尽管速度略慢。它们得到了推理引擎的良好支持,并集成到了 TGI、vLLM 和 TensorRT-LLM 中。
一个有趣的趋势是将模型量化为 1 位或 2 位精度。虽然一些格式,如 EXL2,允许极端量化,但模型的品质通常会显著下降。然而,最近的算法如 QuIP#和 HQQ 针对这一领域,并提供了更好地保留原始模型性能的量化方法。这对于大型模型(超过 30B 参数)尤其如此,这些模型最终可能比 7B 或 13B 参数模型占用更少的空间,同时提供更高品质的输出。
预计这一趋势将持续下去,进一步优化这些量化方法。
为了总结本章内容,以下是一个表格,总结了我们在前几节中介绍的三种主要推理引擎的特征:
| 技术 | TGI | vLLM | TensorRT-LLM |
| --- | --- | --- | --- |
| 连续批处理 | ✓ | ✓ | ✓ |
| 推测解码 | ✓ | | |
| FlashAttention2 | ✓ | ✓ | ✓ |
| PagedAttention | ✓ | ✓ | ✓ |
| 管道并行性 | | | ✓ |
| 张量并行性 | ✓ | ✓ | ✓ |
| GPTQ | ✓ | | ✓ |
| EXL2 | ✓ | | |
| AWQ | ✓ | ✓ | ✓ |
表 8.1 – TGI、vLLM 和 TensorRT-LLM 的特征总结
摘要
总结来说,推理优化是有效部署大型语言模型(LLMs)的关键方面。本章探讨了各种优化技术,包括优化的生成方法、模型并行性和权重量化。通过利用预测多个标记与推测解码并行进行等技术,可以实现显著的加速。此外,我们还讨论了模型并行性方法,包括数据、管道和张量并行性,这些方法如何将计算负载分配到多个 GPU 上以提高吞吐量和降低延迟。使用 GGUF 和 EXL2 等格式的权重量化进一步减少了内存占用并加速了推理,尽管在输出质量上有所权衡。
理解和应用这些优化策略对于在 LLM 的实际应用中实现高性能至关重要,例如聊天机器人和代码补全。技术工具的选择取决于具体要求,包括可用的硬件、所需的延迟和吞吐量。通过结合各种方法,如连续批处理和推测性解码,以及高级注意力机制和模型并行性,用户可以定制其部署策略以最大化效率。
在第四章中,我们只关注了实现摄取管道,这只是标准 RAG 应用的一个组件。在下一章中,我们将通过实现检索和生成组件并将它们集成到推理管道中来完成 RAG 系统的构建。
参考文献
-
Hugging Face, 文本生成推理,
github.com/huggingface/text-generation-inference, 2022. -
W. Kwon, Z. Li, S. Zhuang, Y. Sheng, L. Zheng, C.H. Yu, J.E. Gonzalez, H. Zhang, I. Stoica, Efficient Memory Management for Large Language Model Serving with PagedAttention, 2023.
-
Nvidia, TensorRT-LLM,
github.com/NVIDIA/TensorRT-LLM, 2023. -
Y. Leviathan, M. Kalman, Y. Matias, Fast Inference from Transformers via Speculative Decoding, 2023.
-
T. Cai, Y. Li, Z. Geng, H. Peng, J.D. Lee, D. Chen, T. Dao, Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads, 2024.
-
W. Kwon, Z. Li, S. Zhuang, Y. Sheng, L. Zheng, C.H. Yu, J.E. Gonzalez, H. Zhang, I. Stoica, Efficient Memory Management for Large Language Model Serving with PagedAttention, 2023.
-
R.Y. Aminabadi, S. Rajbhandari, M. Zhang, A.A. Awan, C. Li, D. Li, E. Zheng, J. Rasley, S. Smith, O. Ruwase, Y. He, DeepSpeed Inference: Enabling Efficient Inference of Transformer Models at Unprecedented Scale, 2022.
-
Y. Huang, Y. Cheng, A. Bapna, O. Firat, M.X. Chen, D. Chen, H. Lee, J. Ngiam, Q.V. Le, Y. Wu, Z. Chen, GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism, 2019.
-
K. James Reed, PiPPy: Pipeline Parallelism for PyTorch,
github.com/pytorch/PiPPy, 2022. -
M. Shoeybi, M. Patwary, R. Puri, P. LeGresley, J. Casper, B. Catanzaro, Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism, 2020.
-
Verma and Vaidya, Mastering LLM Techniques: Inference Optimization, NVIDIA 开发者技术博客,
developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/, 2023. -
T. Dettmers, M. Lewis, Y. Belkada, L. Zettlemoyer, LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale, 2022.
-
G. Gerganov, llama.cpp,
github.com/ggerganov/llama.cpp, 2023. -
E. Frantar, S. Ashkboos, T. Hoefler, D. Alistarh, GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers, 2023.
-
Tuboderp, exllamav2,
github.com/turboderp/exllamav2, 2023. -
J. Lin, J. Tang, H. Tang, S. Yang, W.-M. Chen, W.-C. Wang, G. Xiao, X. Dang, C. Gan, S. Han, AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration, 2024.
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第九章:RAG 推理流程
回到第四章,我们实现了检索增强生成(RAG)特征流程,以填充向量数据库(DB)。在特征流程中,我们从数据仓库中收集数据,对其进行清理、分块和嵌入文档,最终将它们加载到向量数据库中。因此,到目前为止,向量数据库已经填充了文档,并准备好用于 RAG。
根据 RAG 方法,你可以将你的软件架构分为三个模块:一个用于检索,一个用于增强提示,一个用于生成答案。我们将通过实现一个检索模块来查询向量数据库。在这个模块中,我们将实现高级 RAG 技术来优化搜索。之后,我们不会为增强提示单独设置一个模块,因为这将是过度设计,我们试图避免这种情况。然而,我们将编写一个推理服务,该服务输入用户查询和上下文,构建提示,并调用 LLM 生成答案。总结来说,我们将实现两个核心 Python 模块,一个用于检索,一个用于使用用户的输入和上下文作为输入调用 LLM。当我们把它们粘合在一起时,我们将拥有一个端到端的 RAG 流程。
在第五章和第六章中,我们对我们的 LLM 双胞胎模型进行了微调,在第八章中,我们学习了如何对其进行推理优化。因此,到目前为止,LLM 已经准备好投入生产。剩下的是构建和部署上述两个模块。
我们将在下一章中完全致力于将我们的微调后的 LLM 双胞胎模型部署到 AWS SageMaker,作为 AWS SageMaker 推理端点。因此,本章的重点是深入探讨高级 RAG 检索模块的实现。我们专门用一章来讲述检索步骤,因为这是 RAG 系统中发生魔法的地方。在检索步骤(而不是调用 LLM 时),你将编写大部分 RAG 推理代码。这一步是你必须整理数据以确保从向量数据库中检索到最相关数据点的步骤。因此,大部分高级 RAG 逻辑都包含在检索步骤中。
总结来说,在本章中,我们将涵盖以下主题:
-
理解 LLM 双胞胎的 RAG 推理流程
-
探索 LLM 双胞胎的高级 RAG 技术
-
实现 LLM 双胞胎的 RAG 推理流程
到本章结束时,你将了解如何实现一个高级 RAG 检索模块,使用检索到的上下文增强提示,并调用 LLM 生成最终答案。最终,你将了解如何从头到尾构建一个生产就绪的 RAG 推理流程。
理解 LLM 双胞胎的 RAG 推理流程
在实现 RAG 推理管道之前,我们想要讨论其软件架构和高级 RAG 技术。图 9.1展示了 RAG 推理流程的概述。推理管道从输入查询开始,使用检索模块(基于查询)检索上下文,并调用 LLM SageMaker 服务生成最终答案。

图 9.1: RAG 推理管道架构
特征管道和检索模块,如图 9.1所示,是独立的过程。特征管道在不同的机器上按计划运行,以填充向量数据库。同时,检索模块在推理管道中按需调用,针对每个用户请求。
通过在两个组件之间分离关注点,向量数据库总是填充最新的数据,确保特征的新鲜性,而检索模块可以访问每个请求上的最新特征。RAG 检索模块的输入是用户的查询,基于此,我们必须从向量数据库中返回最相关和相似的数据点,这些数据点将被用来指导 LLM 生成最终答案。
为了完全理解 RAG 推理管道的动态,让我们逐步通过图 9.1的架构流程:
-
用户查询: 我们从提出查询的用户开始,例如“写一篇关于...的文章”。
-
查询扩展: 我们扩展初始查询以生成多个查询,这些查询反映了原始用户查询的不同方面或解释。因此,我们不会使用一个查询,而是使用xN个查询。通过多样化搜索词,检索模块增加了捕获全面相关数据点的可能性。当原始查询过于狭窄或模糊时,这一步至关重要。
-
自我查询: 我们从原始查询中提取有用的元数据,例如作者的名字。提取的元数据将被用作向量搜索操作的过滤器,从查询向量空间中消除冗余数据点(使搜索更准确和更快)。
-
过滤向量搜索: 我们将每个查询嵌入并执行相似性搜索,以找到每个搜索的前K个数据点。我们执行 xN 个搜索,对应于扩展查询的数量。我们称这一步为过滤向量搜索,因为我们利用从自我查询步骤中提取的元数据作为查询过滤器。
-
收集结果: 对于每个搜索操作,我们获取最接近其特定扩展查询解释的xK个结果。进一步,我们聚合所有 xN 个搜索的结果,最终得到一个包含文章、帖子、和代码块片段的N x K结果列表。这些结果包括更广泛的一组可能相关的片段,基于原始查询的不同方面提供多个相关角度。
-
重新排序:为了只保留从N x K个潜在项目列表中的前K个最相关结果,我们必须进一步过滤列表。我们将使用一个重新排序算法,根据相关性和相对于初始用户查询的重要性对每个片段进行评分。我们将利用一个神经交叉编码器模型来计算分数,这是一个介于 0 和 1 之间的值,其中 1 表示结果与查询完全相关。最终,我们将N x K个结果根据分数排序,并选择前K个项目。因此,输出是一个排序的K个片段列表,其中最相关的数据点位于顶部。
-
构建提示并调用 LLM:我们将最相关的 K 个片段的最终列表映射到一个用于构建最终提示的字符串。我们使用提示模板、检索到的上下文和用户的查询来创建提示。最终,增强后的提示被发送到 LLM(托管在 AWS SageMaker 上,作为 API 端点暴露)。
-
答案:我们正在等待生成答案。在 LLM 处理提示后,RAG 逻辑通过将生成的响应发送给用户来完成。
这就完成了 RAG 推理管道概述。现在,让我们深入了解细节。
探索 LLM 双胞胎的先进 RAG 技术
现在我们已经了解了我们的 RAG 推理管道的整体流程,让我们来探索我们在检索模块中使用的先进 RAG 技术:
-
预检索步骤:查询扩展和自我查询
-
检索步骤:过滤向量搜索
-
后检索步骤:重新排序
在深入探讨每种方法之前,让我们列出我们将在此部分使用的 Python 接口,这些接口可在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/base.py找到。
第一个是提示模板工厂,它标准化了提示模板的实例化方式。作为一个接口,它继承自ABC并公开了create_template()方法,该方法返回一个 LangChain PromptTemplate实例。即使我们避免过度依赖 LangChain,因为我们想自己实现所有内容以了解背后的工程,但某些对象,如PromptTemplate类,有助于加快开发速度,同时不会隐藏太多功能:
from abc import ABC, abstractmethod
from langchain.prompts import PromptTemplate
from pydantic import BaseModel
class PromptTemplateFactory(ABC, BaseModel):
@abstractmethod
def create_template(self) -> PromptTemplate:
pass
我们还希望定义一个RAGStep接口,用于标准化高级 RAG 步骤(如查询扩展和自我查询)的接口。由于这些步骤通常依赖于其他 LLM,它有一个mock属性,以减少开发过程中的成本和调试时间:
from typing import Any
from llm_engineering.domain.queries import Query
class RAGStep(ABC):
def __init__(self, mock: bool = False) -> None:
self._mock = mock
@abstractmethod
def generate(self, query: Query, *args, **kwargs) -> Any:
pass
最终,我们必须了解我们如何对Query域实体进行建模,以便用其他所需的高级 RAG 元数据包装用户的输入。因此,让我们看看它的实现。首先,我们导入必要的类:
from pydantic import UUID4, Field
from llm_engineering.domain.base import VectorBaseDocument
from llm_engineering.domain.types import DataCategory
接下来,我们定义了继承自第四章中讨论的VectorBaseDocument 对象-向量映射(OVM)类的Query实体类。因此,每个查询都可以轻松地保存或从向量数据库中检索:
class Query(VectorBaseDocument):
content: str
author_id: UUID4 | None = None
author_full_name: str | None = None
metadata: dict = Field(default_factory=dict)
class Config:
category = DataCategory.QUERIES
重要的是要注意该类用于将用户的查询与一系列元数据字段组合的属性:
-
content:包含输入查询的字符串。 -
author_id:从查询中提取的可选 UUID4 标识符,用作向量搜索操作中的过滤器,以检索仅由特定作者编写的块 -
author_full_name:用于查询author_id的可选字符串 -
metadata:用于任何附加元数据的字典,默认初始化为空的dict
除了标准定义的域类之外,我们还定义了一个from_str()类方法,可以直接从字符串创建Query实例。这允许我们标准化在构建query对象之前清理查询字符串的方式,例如删除任何前导或尾随空白和换行符:
@classmethod
def from_str(cls, query: str) -> "Query":
return Query(content=query.strip("\n "))
此外,还有一个名为replace_content()的实例方法,用于创建一个新的Query实例,其中包含更新的内容,同时保留原始查询的id、author_id、author_full_name和metadata:
def replace_content(self, new_content: str) -> "Query":
return Query(
id=self.id,
content=new_content,
author_id=self.author_id,
author_full_name=self.author_full_name,
metadata=self.metadata,
)
这在修改查询文本时特别有用,例如在预处理或归一化过程中,而不会丢失相关的元数据或标识符。在Query类之后,我们定义了EmbeddedQuery类:
class EmbeddedQuery(Query):
embedding: list[float]
class Config:
category = DataCategory.QUERIES
EmbeddedQuery类通过添加嵌入字段扩展了Query类。EmbeddedQuery实体封装了执行在 Qdrant(或另一个向量数据库)上执行向量搜索操作所需的所有数据和元数据。
现在我们已经理解了 RAG 推理管道中使用的所有接口和新域实体,让我们继续了解我们的高级 RAG 预检索优化技术。
高级 RAG 预检索优化:查询扩展和自查询
我们实现了两种方法来优化预检索优化步骤:查询扩展和自查询。这两种方法与过滤向量搜索步骤紧密合作,我们将在下一节中讨论。然而,现在我们将从理解查询扩展的代码开始,然后转向实现自查询。
在这两个方法中,我们将在查询扩展步骤中利用 OpenAI 的 API 生成原始查询的变体,并在自查询算法中提取必要的元数据。当我们编写这本书时,我们在所有示例中都使用了GPT-4o-mini,但随着 OpenAI 模型的快速演变,该模型可能会被弃用。但这不是问题,因为您可以通过配置OPENAI_MODEL_ID环境变量快速在您的.env文件中更改它。
查询扩展
在典型的检索步骤中,问题在于你使用原始问题的单个向量表示查询你的向量数据库。这种方法只覆盖了嵌入空间的一小部分,这可能有限制。如果嵌入不包含你查询的所有必要信息或细微差别,检索到的上下文可能不相关。这意味着与查询向量语义相关但不在其附近的重要文档可能会被忽略。
解决方案基于查询扩展,提供了一种克服这种限制的方法。使用 LLM 根据你的初始问题生成多个查询,你创建了各种视角,以捕捉查询的不同方面。这些扩展查询在嵌入时,针对的是与你的原始问题仍然相关的嵌入空间的其他区域。这增加了从向量数据库检索更多相关文档的可能性。
实现查询扩展可以像编写一个详细的零样本提示来引导 LLM 生成这些替代查询一样简单。因此,在实现查询扩展后,你将不再只有一个查询来搜索相关上下文,而是将有 xN 个查询,因此有 xN 次搜索。
增加搜索次数可能会影响你的延迟。因此,你必须实验性地调整你生成的查询数量,以确保检索步骤满足你的应用需求。你还可以通过并行化搜索来优化搜索,这可以大幅减少延迟,我们将在本章末尾实现的ContextRetriever类中这样做。
查询扩展也称为多查询,但其原理是相同的。例如,这是一个 LangChain 实现的多查询检索器的例子,称为MultiQueryRetriver:python.langchain.com/docs/how_to/MultiQueryRetriever/
现在,让我们深入代码。我们首先导入查询扩展所需的必要模块和类:
from langchain_openai import ChatOpenAI
from llm_engineering.domain.queries import Query
from llm_engineering.settings import settings
from .base import RAGStep
from .prompt_templates import QueryExpansionTemplate
接下来,我们定义QueryExpansion类,该类生成扩展查询版本。类的实现可以在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/query_expanison.py找到:
class QueryExpansion(RAGStep):
def generate(self, query: Query, expand_to_n: int) -> list[Query]:
assert expand_to_n > 0, f"'expand_to_n' should be greater than 0\. Got {expand_to_n}."
if self._mock:
return [query for _ in range(expand_to_n)]
在generate方法中,我们首先确保请求的扩展数量(expand_to_n)大于零。如果实例处于模拟模式(self._mock is True),它将简单地返回一个包含原始查询副本的列表,以模拟扩展而不实际调用 API。如果不处于模拟模式,我们继续创建提示并初始化语言模型:
query_expansion_template = QueryExpansionTemplate()
prompt = query_expansion_template.create_template(expand_to_n - 1)
model = ChatOpenAI(model=settings.OPENAI_MODEL_ID, api_key=settings.OPENAI_API_KEY, temperature=0)
在这里,我们实例化QueryExpansionTemplate并创建一个针对生成expand_to_n - 1个新查询(不包括原始查询)的定制提示。我们使用指定的设置初始化ChatOpenAI模型并将温度设置为 0 以获得确定性输出。然后我们通过将提示与模型结合创建一个 LangChain 链,并用用户的问题调用它:
chain = prompt | model
response = chain.invoke({"question": query})
result = response.content
通过将提示管道输入到模型中(prompt | model),我们设置了一个在调用原始查询时生成扩展查询的链。模型的响应被捕获在result对象中。在收到响应后,我们解析和清理扩展查询:
queries_content = result.strip().split(query_expansion_template.separator)
queries = [query]
queries += [
query.replace_content(stripped_content)
for content in queries_content
if (stripped_content := content.strip())
]
return queries
我们使用模板中定义的分隔符来分割结果,以获取单个查询。从包含原始查询的列表开始,我们在去除任何额外空格后添加每个扩展查询。
最后,我们定义了QueryExpansionTemplate类,该类构建用于查询扩展的提示。该类和其他提示模板可以在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/prompt_templates.py找到:
from langchain.prompts import PromptTemplate
from .base import PromptTemplateFactory
class QueryExpansionTemplate(PromptTemplateFactory):
prompt: str = """You are an AI language model assistant. Your task is to generate {expand_to_n}
different versions of the given user question to retrieve relevant documents from a vector
database. By generating multiple perspectives on the user question, your goal is to help
the user overcome some of the limitations of the distance-based similarity search.
Provide these alternative questions separated by '{separator}'.
Original question: {question}"""
@property
def separator(self) -> str:
return "#next-question#"
def create_template(self, expand_to_n: int) -> PromptTemplate:
return PromptTemplate(
template=self.prompt,
input_variables=["question"],
partial_variables={
"separator": self.separator,
"expand_to_n": expand_to_n,
},
)
这个类定义了一个提示,指示语言模型生成用户问题的多个版本。它使用像{expand_to_n}、{separator}和{question}这样的占位符来自定义提示。
它接受expand_to_n作为输入参数,以定义在构建PromptTemplate实例时我们希望生成多少个查询。分隔符属性提供了一个独特的字符串来分割生成的查询。expand_to_n和separator变量作为partial_variables传递,使它们在运行时不可变。同时,{question}占位符将在每次调用 LLM 链时更改。
现在我们已经完成了查询扩展实现的研读,接下来让我们看看如何使用QueryExpansion类的一个示例。使用以下python -m llm_engineering.application.rag.query_expansion命令来运行以下代码:
query = Query.from_str("Write an article about the best types of advanced RAG methods.")
query_expander = QueryExpansion()
expanded_queries = query_expander.generate(query, expand_to_n=3)
for expanded_query in expanded_queries:
logger.info(expanded_query.content)
我们得到了原始查询的以下变体。如您所观察到的,查询扩展方法成功地提供了更多细节和初始查询的不同视角,例如突出高级 RAG 方法的有效性或这些方法的概述(请记住,第一个查询是原始查询):
2024-09-18 17:51:33.529 | INFO - Write an article about the best types of advanced RAG methods.
2024-09-18 17:51:33.529 | INFO - What are the most effective advanced RAG methods, and how can they be applied?
2024-09-18 17:51:33.529 | INFO - Can you provide an overview of the top advanced retrieval-augmented generation techniques?
现在,让我们转向下一个预检索优化方法:自查询。
自查询
当将查询嵌入到向量空间时遇到的问题是,你不能保证你的用例所需的所有方面都在嵌入向量中有足够的信号。例如,你想要确保你的检索完全依赖于用户输入中提供的标签。不幸的是,你无法控制嵌入中留下的强调标签的信号。仅通过嵌入查询提示,你永远无法确定标签在嵌入向量中得到了充分的表示,或者在与其他向量计算距离时具有足够的信号。
这个问题代表了你希望在搜索过程中展示的任何其他元数据,例如 ID、名称或类别。
解决方案是使用自查询来提取查询中的标签或其他关键元数据,并将其与向量搜索一起用作过滤器。自查询使用 LLM 提取对您的业务用例至关重要的各种元数据字段,如标签、ID、评论数、点赞、分享等。之后,您将完全控制提取的元数据在检索过程中的考虑方式。在我们的 LLM Twin 用例中,我们提取作者的名字并将其用作过滤器。自查询与过滤向量搜索协同工作,我们将在下一节中解释。
现在,让我们转向代码。我们首先导入代码所依赖的必要模块和类:
from langchain_openai import ChatOpenAI
from llm_engineering.application import utils
from llm_engineering.domain.documents import UserDocument
from llm_engineering.domain.queries import Query
from llm_engineering.settings import settings
from .base import RAGStep
from .prompt_templates import SelfQueryTemplate
接下来,我们定义了继承自 RAGStep 并实现了 generate() 方法的 SelfQuery 类。该类可以在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/self_query.py找到:
class SelfQuery(RAGStep):
def generate(self, query: Query) -> Query:
if self._mock:
return query
在 generate() 方法中,我们检查 _mock 属性是否设置为 True。如果是,我们将返回未经修改的原始查询对象。这允许我们在测试和调试时绕过调用模型。如果不是模拟模式,我们将创建提示模板并初始化语言模型。
prompt = SelfQueryTemplate().create_template()
model = ChatOpenAI(model=settings.OPENAI_MODEL_ID, api_key=settings.OPENAI_API_KEY, temperature=0)
在这里,我们使用 SelfQueryTemplate 工厂类实例化提示,并创建一个 ChatOpenAI 模型实例(类似于查询扩展实现)。然后我们将提示和模型组合成一个链,并用用户的查询调用它。
chain = prompt | model
response = chain.invoke({"question": query})
user_full_name = response.content.strip("\n ")
我们从 LLM 响应中提取内容,并去除任何前导或尾随空白,以获得 user_full_name 值。接下来,我们检查模型是否能够提取任何用户信息。
if user_full_name == "none":
return query
如果响应是 "none",则表示查询中没有找到用户名,因此我们返回原始查询对象。如果找到用户名,我们将使用一个实用函数将 user_full_name 分割成 first_name 和 last_name 变量。然后,根据用户的详细信息,我们检索或创建一个 UserDocument 用户实例:
first_name, last_name = utils.split_user_full_name(user_full_name)
user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
最后,我们将提取的作者信息更新到查询对象中,并返回它:
query.author_id = user.id
query.author_full_name = user.full_name
return query
更新后的查询现在包含了author_id和author_full_name值,这些值可以在 RAG 管道的后续步骤中使用。
让我们看看定义提取用户信息的提示的SelfQueryTemplate类:
from langchain.prompts import PromptTemplate
from .base import PromptTemplateFactory
class SelfQueryTemplate(PromptTemplateFactory):
prompt: str = """You are an AI language model assistant. Your task is to extract information from a user question.
The required information that needs to be extracted is the user name or user id.
Your response should consist of only the extracted user name (e.g., John Doe) or id (e.g. 1345256), nothing else.
If the user question does not contain any user name or id, you should return the following token: none.
For example:
QUESTION 1:
My name is Paul Iusztin and I want a post about...
RESPONSE 1:
Paul Iusztin
QUESTION 2:
I want to write a post about...
RESPONSE 2:
none
QUESTION 3:
My user id is 1345256 and I want to write a post about...
RESPONSE 3:
1345256
User question: {question}"""
def create_template(self) -> PromptTemplate:
return PromptTemplate(template=self.prompt, input_variables=["question"])
在SelfQueryTemplate类中,我们定义了一个提示,指示 AI 模型从输入问题中提取用户名或ID。该提示使用少量样本学习来指导模型在不同场景下的响应。当模板被调用时,{question}占位符将被替换为实际的用户问题。
通过实现自我查询,我们确保了对于我们的用例所必需的关键元数据被明确提取并在检索过程中使用。这种方法克服了仅依赖嵌入语义来捕获查询所有必要方面的局限性。
现在我们已经实现了SelfQuery类,让我们提供一个示例。使用python -m llm_engineering.application.rag.self_query CLI 命令运行以下代码:
query = Query.from_str("I am Paul Iusztin. Write an article about the best types of advanced RAG methods.")
self_query = SelfQuery()
query = self_query.generate(query)
logger.info(f"Extracted author_id: {query.author_id}")
logger.info(f"Extracted author_full_name: {query.author_full_name}")
我们得到了以下结果,其中正确提取了作者的完整姓名和 ID:
2024-09-18 18:02:10.362 | INFO - Extracted author_id: 900fec95-d621-4315-84c6-52e5229e0b96
2024-09-18 18:02:10.362 | INFO - Extracted author_full_name: Paul Iusztin
现在我们已经了解了自我查询的工作原理,让我们探索如何在检索优化步骤中将它与过滤向量搜索结合使用。
高级 RAG 检索优化:过滤向量搜索
基于语义相似性检索相关信息是向量搜索的关键。然而,普通的向量搜索可能会引入显著挑战,这些挑战会影响信息检索的准确性和延迟。这主要是因为它仅基于向量嵌入的数值邻近性进行操作,而没有考虑可能对相关性至关重要的上下文或分类细微差别。
普通向量搜索的一个主要问题是检索到语义相似但上下文无关的文档。由于向量嵌入捕获了一般的语义含义,它们可能会将高相似度分数分配给共享语言模式或主题的内容,但这些内容并不符合查询的具体意图或约束。例如,搜索“Java”可能会根据语义相似性检索关于编程语言或印度尼西亚岛屿的文档,导致模糊或误导性的结果。
此外,随着数据集大小的增加,普通向量搜索可能会遇到可扩展性问题。缺乏过滤意味着搜索算法必须在整个向量空间中计算相似度,这可能会显著增加延迟。
这种详尽的搜索会减慢响应时间并消耗更多计算资源,对于实时或大规模应用来说效率低下。
通过过滤额外的标准,如元数据标签或类别,过滤向量搜索成为解决方案,在计算向量相似性之前减少搜索空间。通过应用这些过滤器,搜索算法将潜在结果池缩小到与查询意图上下文一致的结果。这种有针对性的方法通过消除由于语义相似性而被考虑的不相关文档来提高准确性。
此外,通过减少算法需要执行的比较次数,过滤向量搜索提高了延迟。处理更小、更相关的数据子集可以降低计算开销,从而缩短响应时间。这种效率对于需要实时交互或处理大量查询的应用至关重要。
由于过滤向量搜索中使用的元数据通常是用户输入的一部分,我们必须在查询向量数据库之前提取它。这正是我们在自我查询步骤中所做的,我们提取了作者的名字,以将向量空间仅限于作者的内容。因此,当我们处理自我查询步骤中的查询时,它被归类为检索前优化,而当我们过滤向量搜索优化查询时,它被归类为检索优化。
例如,当使用 Qdrant 时,为了在每份文档的元数据中查找匹配的author_id,你必须实现以下代码:
from qdrant_client.models import FieldCondition, Filter, MatchValue
records = qdrant_connection.search(
collection_name="articles",
query_vector=query_embedding,
limit=3,
with_payload=True,
query_filter= Filter(
must=[
FieldCondition(
key="author_id",
match=MatchValue(
value=str("1234"),
),
)
]
),
)
从本质上讲,虽然普通向量搜索为语义检索提供了一个基础,但其局限性可能会在实际应用中降低性能。过滤向量搜索通过结合向量嵌入和上下文过滤的优势来解决这些挑战,从而在 RAG 系统中实现更准确和高效的信息检索。优化我们的 RAG 管道的最后一步是考虑重新排序。
高级 RAG 检索后优化:重新排序
RAG 系统中问题在于检索到的上下文可能包含不相关的片段,这些片段只会:
-
增加噪声:检索到的上下文可能是不相关的,这会扰乱信息并可能使语言模型产生混淆。
-
使提示更大:包括不必要的片段会增加提示的大小,导致成本更高。此外,语言模型通常偏向于上下文的第一部分和最后一部分。因此,如果你添加了大量上下文,有很大可能会错过本质。
-
与你的问题不一致:片段是根据查询与片段嵌入之间的相似性检索的。问题是嵌入模型可能没有针对你的问题进行调整,导致不完全不相关的片段具有很高的相似度评分。
解决方案是使用重排序对所有检索到的 N × K 个片段进行排序,根据它们相对于初始问题的相关性,其中第一个片段将是最相关的,最后一个是最不相关的。N 代表查询扩展后的搜索次数,而 K 是每次搜索检索的片段数。因此,我们总共检索了 N x K 个片段。在 RAG 系统中,重排序作为关键的后检索步骤,可以细化从检索模型获得的初始结果。
我们通过应用重排序算法来评估每个片段对原始查询的相关性,该算法通常使用像神经交叉编码器这样的高级模型。这些模型比基于嵌入和余弦相似度距离的初始检索方法更准确地评估查询与每个片段之间的语义相似性,如在第四章中更详细地解释的 高级 RAG 概述 部分。
最终,我们根据重排序分数从排序后的 N x K 项中挑选出最相关的 K 个片段。当与查询扩展结合使用时,重排序效果良好。首先,让我们了解在没有查询扩展的情况下重排序是如何工作的:
-
搜索 > K 个片段:检索超过 K 个片段,以获得更广泛的潜在相关信息。
-
使用重排序重新排序:将重排序应用于这个更大的集合,以评估每个片段相对于查询的实际相关性。
-
选择前 K 个:选择前 K 个片段,在最终提示中使用它们作为上下文。
因此,当与查询扩展结合使用时,我们从空间中的多个点收集潜在的有价值上下文,而不仅仅是寻找单个位置中的超过 K 个样本。现在流程看起来是这样的:
-
搜索 N × K 个片段:使用扩展查询检索多个片段集。
-
使用重排序重新排序:根据相关性对所有检索到的片段进行重排序。
-
选择前 K 个:选择最相关的片段作为最终提示。
将重排序集成到 RAG 流程中可以增强检索上下文的质量和相关性,并有效地使用计算资源。让我们看看如何实现 LLM Twin 的重排序步骤,以了解我们上面描述的内容,该步骤可以在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/reranking.py。
我们首先导入重排序过程中所需的必要模块和类:
from llm_engineering.application.networks import CrossEncoderModelSingleton
from llm_engineering.domain.embedded_chunks import EmbeddedChunk
from llm_engineering.domain.queries import Query
from .base import RAGStep
接下来,我们定义 Reranker 类,该类负责根据查询的相关性对检索到的文档进行重排序:
class Reranker(RAGStep):
def __init__(self, mock: bool = False) -> None:
super().__init__(mock=mock)
self._model = CrossEncoderModelSingleton()
在 Reranker 类的初始化器中,我们通过创建 CrossEncoderModelSingleton 的实例来实例化我们的交叉编码器模型。这是用于对每个文档片段相对于查询的相关性进行评分的交叉编码器模型。
Reranker类的核心功能在generate()方法中实现:
def generate(self, query: Query, chunks: list[EmbeddedChunk], keep_top_k: int) -> list[EmbeddedChunk]:
if self._mock:
return chunks
query_doc_tuples = [(query.content, chunk.content) for chunk in chunks]
scores = self._model(query_doc_tuples)
scored_query_doc_tuples = list(zip(scores, chunks, strict=False))
scored_query_doc_tuples.sort(key=lambda x: x[0], reverse=True)
reranked_documents = scored_query_doc_tuples[:keep_top_k]
reranked_documents = [doc for _, doc in reranked_documents]
return reranked_documents
generate()方法接受一个查询、一个块列表(文档段)以及要保留的前keep_top_k个文档数。如果我们处于模拟模式,它将简单地返回原始块。否则,它执行以下步骤:
-
创建查询内容和每个块内容的对
-
使用交叉编码器模型对每一对进行评分,评估块与查询的匹配程度
-
将分数与相应的块进行匹配,创建一个分数列表的元组
-
根据分数按降序排序此列表
-
选择前
keep_top_k个块 -
从元组中提取块并将它们作为重新排序的文档返回
在定义CrossEncoder类之前,我们导入必要的组件:
from sentence_transformers.cross_encoder import CrossEncoder
from .base import SingletonMeta
我们从 sentence_transformers 库中导入CrossEncoder类,该库提供了评分文本对的功能。我们还从我们的基础模块中导入SingletonMeta,以确保我们的模型类遵循单例模式,这意味着在整个应用程序中只有一个模型实例。现在,我们定义CrossEncoderModelSingleton类:
class CrossEncoderModelSingleton(metaclass=SingletonMeta):
def __init__(
self,
model_id: str = settings.RERANKING_CROSS_ENCODER_MODEL_ID,
device: str = settings.RAG_MODEL_DEVICE,
) -> None:
"""
A singleton class that provides a pre-trained cross-encoder model for scoring pairs of input text.
"""
self._model_id = model_id
self._device = device
self._model = CrossEncoder(
model_name=self._model_id,
device=self._device,
)
self._model.model.eval()
此类使用从.env文件加载的全局settings中指定的model_id和device初始化交叉编码器模型。我们使用self._model.model.eval()将模型设置为评估模式,以确保模型已准备好进行推理。
CrossEncoderModelSingleton类包含一个可调用的方法来评分文本对:
def __call__(self, pairs: list[tuple[str, str]], to_list: bool = True) -> NDArray[np.float32] | list[float]:
scores = self._model.predict(pairs)
if to_list:
scores = scores.tolist()
return scores
__call__方法允许我们传入一个包含文本pairs(每个包含查询和文档块)的列表,并接收它们的相关性分数。该方法使用模型的predict()函数调用模型并计算分数。
CrossEncoderModelSingleton类是对CrossEncoder类的包装,我们编写它有两个目的。第一个目的是为了单例模式,这使得我们能够轻松地从应用程序的任何地方访问相同的交叉编码器模型实例,而无需每次需要时都将其加载到内存中。第二个原因是,通过编写我们的包装器,我们定义了交叉编码器模型(或用于重新排序的任何其他模型)的接口。这使得代码具有前瞻性,因为如果我们需要不同的实现或策略进行重新排序,例如使用 API,我们只需编写一个遵循相同接口的不同包装器,并用新类替换旧类。因此,我们可以引入新的重新排序方法,而无需触及代码的其他部分。
我们现在理解了架构中使用的所有高级 RAG 技术。在下一节中,我们将检查ContextRetriever类,该类连接所有这些方法,并解释如何使用检索模块与 LLM 结合进行端到端的 RAG 推理管道。
实现 LLM Twin 的 RAG 推理管道
如本章开头所述,RAG 推理管道主要可以分为三个部分:检索模块、提示创建和答案生成,这归结为调用一个 LLM 并使用增强的提示。在本节中,我们的主要重点是实现检索模块,其中大部分代码和逻辑都在这里。之后,我们将看看如何使用检索到的上下文和用户查询构建最终的提示。
最终,我们将探讨如何结合检索模块、提示创建逻辑和 LLM 来捕获端到端的 RAG 工作流程。不幸的是,我们无法在完成第十章之前测试 LLM,因为我们尚未将微调的 LLM Twin 模块部署到 AWS SageMaker。
因此,在本节结束时,你将学习如何实现 RAG 推理管道,你只能在完成第十章后才能从头到尾进行测试。现在,让我们先看看检索模块的实现。
实现检索模块
让我们深入到ContextRetriever类的实现,该类通过整合我们之前使用过的所有高级技术(查询扩展、自我查询、重新排序和过滤向量搜索)来协调我们 RAG 系统中的检索步骤。该类可以在 GitHub 上找到:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/application/rag/retriever.py。

图 9.2:RAG 检索模块的搜索逻辑
ContextRetriever 类的入口函数是 search() 方法,它调用本章讨论的所有高级步骤。图 9.2 更详细地展示了搜索方法如何将所有用于搜索与用户查询相似结果的步骤粘合在一起。它突出了从自我查询步骤中提取的作者详细信息如何在过滤向量搜索中使用。同时,它还聚焦于搜索操作本身,对于每个查询,我们向向量数据库进行三次搜索,寻找与查询相似的文章、帖子或存储库。对于每次搜索(N 次搜索中的每一次),我们希望检索最多 K 个结果。因此,对于每个数据类别,我们最多检索 K / 3 个条目(因为我们有三个类别)。因此,当汇总时,我们将有一个≤ K个块组成的列表。当某个特定数据类别或更多在应用作者过滤器后返回< K / 3个条目时(由于缺少该特定作者或数据类别的块),检索到的列表将是≤ K(而不是等于 K)。

图 9.3:RAG 检索模块的结果处理流程
图 9.3说明了我们如何处理 xN 搜索返回的结果。由于每次搜索返回≤ K个项目,我们最终将得到≤ N x K个块,我们将它们聚合到一个单独的列表中。由于一些结果可能在搜索者之间重叠,我们必须去重聚合列表以确保每个块是唯一的。最终,我们将结果发送到重排序模型,根据它们的重排序分数进行排序,并选择最相关的K个块作为 RAG 的上下文。
让我们了解从图 9.2和图 9.3中的一切如何在ContextRetriever类中实现。首先,我们通过设置QueryExpansion、SelfQuery和Reranker类的实例来初始化该类:
class ContextRetriever:
def __init__(self, mock: bool = False) -> None:
self._query_expander = QueryExpansion(mock=mock)
self._metadata_extractor = SelfQuery(mock=mock)
self._reranker = Reranker(mock=mock)
在search()方法中,我们将用户的输入字符串转换为query对象。然后我们使用SelfQuery实例从查询中提取author_id和author_full_name:
def search(
self,
query: str,
k: int = 3,
expand_to_n_queries: int = 3,
) -> list:
query_model = Query.from_str(query)
query_model = self._metadata_extractor.generate(query_model)
logger.info(
"Successfully extracted the author_id from the query.",
author_id=query_model.author_id,
)
接下来,我们使用QueryExpansion实例扩展查询,生成多个语义相似的查询:
n_generated_queries = self._query_expander.generate(query_model, expand_to_n=expand_to_n_queries)
logger.info(
"Successfully generated queries for search.",
num_queries=len(n_generated_queries),
)
然后,我们使用线程池并发地对所有扩展查询进行搜索。每个查询都通过我们很快将要探讨的_search()方法进行处理。结果被扁平化、去重并收集到一个单独的列表中:
with concurrent.futures.ThreadPoolExecutor() as executor:
search_tasks = [executor.submit(self._search, _query_model, k) for _query_model in n_generated_queries]
n_k_documents = [task.result() for task in concurrent.futures.as_completed(search_tasks)]
n_k_documents = utils.misc.flatten(n_k_documents)
n_k_documents = list(set(n_k_documents))
logger.info("All documents retrieved successfully.", num_documents=len(n_k_documents))
在检索到文档后,我们根据它们与原始查询的相关性对它们进行重排序,并仅保留前k个文档:
if len(n_k_documents) > 0:
k_documents = self.rerank(query, chunks=n_k_documents, keep_top_k=k)
else:
k_documents = []
return k_documents
_search()方法在帖子、文章和存储库等不同数据类别上执行过滤向量搜索。它使用EmbeddingDispatcher将查询转换为EmbeddedQuery,该查询包括查询的嵌入向量和任何提取的元数据:
def _search(self, query: Query, k: int = 3) -> list[EmbeddedChunk]:
assert k >= 3, "k should be >= 3"
def _search_data_category(
data_category_odm: type[EmbeddedChunk], embedded_query: EmbeddedQuery
) -> list[EmbeddedChunk]:
if embedded_query.author_id:
query_filter = Filter(
must=[
FieldCondition(
key="author_id",
match=MatchValue(
value=str(embedded_query.author_id),
),
)
]
)
else:
query_filter = None
return data_category_odm.search(
query_vector=embedded_query.embedding,
limit=k // 3,
query_filter=query_filter,
)
embedded_query: EmbeddedQuery = EmbeddingDispatcher.dispatch(query)
我们使用了与 RAG 特征管道中嵌入文档块相同的EmbeddingDispatcher来嵌入查询。使用相同的类确保我们在摄取和查询时使用相同的嵌入模型,这对于检索步骤至关重要。
我们通过利用本地_search_data_category()函数分别对每个数据类别进行搜索。在_search_data_category()函数中,我们应用从embedded_query对象中提取的过滤器。例如,如果存在author_id,我们使用它来过滤搜索结果,仅包括该作者的文档。然后,将所有类别的结果合并:
post_chunks = _search_data_category(EmbeddedPostChunk, embedded_query)
articles_chunks = _search_data_category(EmbeddedArticleChunk, embedded_query)
repositories_chunks = _search_data_category(EmbeddedRepositoryChunk, embedded_query)
retrieved_chunks = post_chunks + articles_chunks + repositories_chunks
return retrieved_chunks
最后,rerank()方法接收原始查询和检索到的文档列表,根据相关性对它们进行重新排序:
def rerank(self, query: str | Query, chunks: list[EmbeddedChunk], keep_top_k: int) -> list[EmbeddedChunk]:
if isinstance(query, str):
query = Query.from_str(query)
reranked_documents = self._reranker.generate(query=query, chunks=chunks, keep_top_k=keep_top_k)
logger.info("Documents reranked successfully.", num_documents=len(reranked_documents))
return reranked_documents
search() method:
from loguru import logger
from llm_engineering.application.rag.retriever import ContextRetriever
query = """
My name is Paul Iusztin.
Could you draft a LinkedIn post discussing RAG systems?
I'm particularly interested in:
- how RAG works
- how it is integrated with vector DBs and large language models (LLMs).
"""
retriever = ContextRetriever(mock=False)
documents = retriever.search(query, k=3)
logger.info("Retrieved documents:")
for rank, document in enumerate(documents):
logger.info(f"{rank + 1}: {document}")
使用以下 CLI 命令调用上面的代码:poetry poe call-rag-retrieval-module。这将输出以下内容:
2024-09-18 19:01:50.588 | INFO - Retrieved documents:
2024-09-18 19:01:50.588 | INFO - 1: id=UUID('541d6c22-d15a-4e6a-924a-68b7b1e0a330') content='4 Advanced RAG Algorithms You Must Know by Paul Iusztin Implement 4 advanced RAG retrieval techniques to optimize your vector DB searches. Integrate the RAG retrieval module into a production LLM system…" platform='decodingml.substack.com' document_id=UUID('32648f33-87e6-435c-b2d7-861a03e72392') author_id=UUID('900fec95-d621-4315-84c6-52e5229e0b96') author_full_name='Paul Iusztin' metadata={'embedding_model_id': 'sentence-transformers/all-MiniLM-L6-v2', 'embedding_size': 384, 'max_input_length': 256} link='https://decodingml.substack.com/p/the-4-advanced-rag-algorithms-you?r=1ttoeh'
2024-09-18 19:01:50.588 | INFO - 2: id=UUID('5ce78438-1314-4874-8a5a-04f5fcf0cb21') content='Overview of advanced RAG optimization techniquesA production RAG system is split into 3 main components ingestion clean, chunk, embed, and load your data to a vector DBretrieval query your vector DB for …" platform='medium' document_id=UUID('bd9021c9-a693-46da-97e7-0d06760ee6bf') author_id=UUID('900fec95-d621-4315-84c6-52e5229e0b96') author_full_name='Paul Iusztin' metadata={'embedding_model_id': 'sentence-transformers/all-MiniLM-L6-v2', 'embedding_size': 384, 'max_input_length': 256} link='https://medium.com/decodingml/the-4-advanced-rag-algorithms-you-must-know-to-implement-5d0c7f1199d2'
2024-09-18 19:02:45.729 | INFO - 3: id=UUID('0405a5da-4686-428a-91ca-446b8e0446ff') content='Every Medium article will be its own lesson An End to End Framework for Production Ready LLM Systems by Building Your LLM TwinThe Importance of Data Pipelines in the Era of Generative AIChange Data Capture Enabling Event Driven …" platform='medium' document_id=UUID('bd9021c9-a693-46da-97e7-0d06760ee6bf') author_id=UUID('900fec95-d621-4315-84c6-52e5229e0b96') author_full_name='Paul Iusztin' metadata={'embedding_model_id': 'sentence-transformers/all-MiniLM-L6-v2', 'embedding_size': 384, 'max_input_length': 256} link='https://medium.
com/decodingml/the-4-advanced-rag-algorithms-you-must-know-to-implement-5d0c7f1199d2'
如您在输出中观察到的,除了检索到的内容外,我们还可以访问各种元数据,例如用于检索的嵌入模型或提取块的链接。这些可以快速添加到生成用户结果时的参考列表中,从而增加对最终结果的信任。
现在我们已经了解了检索模块的工作原理,让我们再迈出最后一步,检查端到端的 RAG 推理管道。
将所有内容整合到 RAG 推理管道中
要完全实现 RAG 流程,我们仍然需要使用检索模型中的上下文构建提示,并调用 LLM 生成答案。本节将讨论这两个步骤,并将所有内容整合到一个单一的 rag() 函数中。本节中的函数可以在 GitHub 上访问:github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/infrastructure/inference_pipeline_api.py。
让我们从查看 call_llm_service() 函数开始,该函数负责与 LLM 服务进行接口交互。它接收用户的查询和一个可选的上下文,设置语言模型端点,执行推理,并返回生成的答案。上下文是可选的;你可以不使用它来调用 LLM,就像与其他任何 LLM 交互时一样:
def call_llm_service(query: str, context: str | None) -> str:
llm = LLMInferenceSagemakerEndpoint(
endpoint_name=settings.SAGEMAKER_ENDPOINT_INFERENCE, inference_component_name=None
)
answer = InferenceExecutor(llm, query, context).execute()
return answer
此函数向我们的微调 LLM Twin 模型发送 HTTP 请求,该模型托管在 AWS SageMaker 推理端点上。我们将在下一章中探索所有 SageMaker 的细节,我们将深入研究 LLMInferenceSagemakerEndpoint 和 InferenceExecutor 类。目前,我们需要知道的是,我们使用此函数调用我们的微调 LLM。然而,我们必须强调查询和上下文是如何传递给 InferenceExecutor 类的,并且如何转换成最终的提示。我们通过使用一个简单的提示模板来完成这个任务,该模板使用用户查询和检索到的上下文进行定制:
prompt = f"""
You are a content creator. Write what the user asked you to while using the provided context as the primary source of information for the content.
User query: {query}
Context: {context}
"""
接下来,我们来看 rag() 函数,这是 RAG 逻辑汇聚的地方。它负责根据查询检索相关文档,将文档映射到将要注入提示的上下文中,并从 LLM 获取最终答案:
def rag(query: str) -> str:
retriever = ContextRetriever(mock=False)
documents = retriever.search(query, k=3)
context = EmbeddedChunk.to_context(documents)
answer = call_llm_service(query, context)
return answer
由于我们将所有 RAG 步骤模块化为独立的类,我们将高级的 rag() 函数简化为五行代码(封装了系统的所有复杂性),类似于我们在 LangChain、LlamaIndex 或 Haystack 等工具中看到的那样。我们不是使用它们的高级实现,而是学习了如何从头开始构建高级 RAG 服务。此外,通过明确分离每个类的责任,我们可以像乐高积木一样使用它们。因此,你可以快速独立调用 LLM 而无需上下文,或者将检索模块用作你向量数据库上的查询引擎。在下一章中,我们将部署我们的微调 LLM 到 AWS SageMaker 推理端点后,看到 rag() 函数的实际应用。
在结束本章之前,我们想讨论您可以添加到 RAG 推理管道中的潜在改进。由于我们正在构建聊天机器人,第一个改进是添加一个对话记忆,它将存储所有用户提示和生成的答案。因此,当与聊天机器人交互时,它将意识到整个对话,而不仅仅是最新的提示。当提示 LLM 时,除了新的用户输入和上下文外,我们还传递内存中的对话历史。由于对话历史可能会很长,为了避免超出上下文窗口或更高的成本,你必须实现一种方法来减少你内存的大小。如图 9.4 所示,最简单的方法是只保留聊天历史中的最新 K 个项目。不幸的是,使用这种策略,LLM 将永远不会意识到整个对话。
因此,将聊天历史添加到提示的另一种方法是保留对话的摘要以及最新的 K 个回复。计算这个摘要有多种方法,如果我们详细讨论它们,可能会违背本书的目的,但最简单的方法是在每次用户提示时始终更新摘要并生成答案。

图 9.4:路由和内存示例
对于每次搜索,我们向向量数据库发送三个查询,每个数据类别一个。因此,第二个改进是在查询和搜索之间添加一个路由器。该路由器将是一个多类别分类器,它将预测我们必须为该特定查询检索的数据类别。因此,我们不必为每次搜索发出三个请求,通常可以减少到一或两个。例如,如果用户想为一篇文章撰写关于 RAG 的理论段落,那么查询文章集合可能非常有价值。在这种情况下,路由器将预测文章类别,我们可以用它来决定我们必须查询哪个集合。
另一个例子是,如果我们想展示一段代码,说明如何构建 RAG 管道。在这种情况下,路由器必须预测文章和存储库数据类别,因为我们需要在两个集合中查找示例以获得详尽的环境。
通常,路由策略根据用户的输入决定调用哪个模型,例如是否为特定查询使用 GPT-4 或自托管 Llama 3.1 模型。然而,在我们的特定用例中,我们可以调整路由算法以优化检索步骤。
我们可以通过使用一种结合了基于嵌入的向量搜索和关键字搜索算法(如 BM25)的混合搜索算法来进一步优化检索。在向量搜索算法变得流行之前,搜索算法使用 BM25(或类似方法)在数据库中找到相似项。通过合并这些方法,混合搜索可以检索到匹配精确术语的结果,例如 RAG、LLM 或 SageMaker,以及查询语义,从而提高检索结果的准确性和相关性。从根本上讲,混合搜索算法遵循以下机制:
-
并行处理:搜索查询同时通过向量搜索和 BM25 算法进行处理。每个算法根据其标准检索一组相关文档。
-
分数归一化:对两次搜索的结果分配相关性分数,然后对这些分数进行归一化以确保可比性。这一步至关重要,因为向量搜索和 BM25 评分机制在不同的尺度上工作。因此,没有归一化,它们无法进行比较或合并。
-
结果合并:归一化后的分数通过加权求和等方式合并,以产生文档的最终排名。调整权重允许对语义或关键字搜索算法的强调进行微调。
总结来说,通过结合语义和精确关键字搜索算法,你可以提高检索步骤的准确性。向量搜索有助于识别同义词或相关概念,确保由于词汇差异而不会遗漏相关信息。关键字搜索确保包含关键字的文档得到适当的强调,尤其是在具有特定术语的技术领域。
我们可以对我们的 RAG 系统进行最后一次改进,即使用多索引向量结构而不是仅基于内容嵌入进行索引。让我们详细说明多索引是如何工作的。不是使用单个字段的嵌入来对特定集合进行向量搜索,而是结合多个字段。
例如,在我们的 LLM Twin 用例中,我们只使用了我们文章、帖子或存储库的内容字段来查询向量数据库。当使用多索引策略时,除了内容字段外,我们还可以索引内容发布平台或内容发布时的嵌入。这可能会影响检索的最终准确性,因为不同的平台有不同的内容类型,或者更近期的内容通常更相关。例如,Superlinked 等框架使多索引变得简单。例如,在下面的代码片段中,使用 Superlinked,我们只需几行代码就在我们的文章集合的内容和平台上定义了一个多索引:
from superlinked.framework.common.schema.id_schema_object import IdField
from superlinked.framework.common.schema.schema import schema
from superlinked.framework.common.schema.schema_object import String
… # Other Superlinked imports.
@schema
class ArticleSchema:
id: IdField
platform: String
content: String
article = ArticleSchema()
articles_space_content = TextSimilaritySpace(
text=chunk(article.content, chunk_size=500, chunk_overlap=50),
model=settings.EMBEDDING_MODEL_ID,
)
articles_space_plaform = CategoricalSimilaritySpace(
category_input=article.platform,
categories=["medium", "substack", "wordpress"],
negative_filter=-5.0,
)
article_index = Index(
[articles_space_content, articles_space_plaform],
fields=[article.author_id],
)
Superlinked 是一个强大的 Python 工具,适用于包括向量计算在内的任何用例,如 RAG、推荐系统和语义搜索。它提供了一个生态系统,您可以将数据快速导入到向量数据库中,在其上编写复杂的查询,并将服务作为 RESTful API 部署。
LLM 和 RAG 的世界是实验性的,类似于任何其他 AI 领域。因此,在构建现实世界产品时,重要的是快速构建一个工作但未必是最好的端到端解决方案。然后,您可以通过各种实验进行迭代,直到完全优化以适应您的用例。这是行业中的标准做法,让您能够快速迭代,同时为业务提供价值,并在产品的生命周期中尽可能快地收集用户反馈。
摘要
本章教我们如何构建高级 RAG 推理管道。我们首先研究了 RAG 系统的软件架构。然后,我们聚焦于检索模块中使用的先进 RAG 方法,例如查询扩展、自我查询、过滤向量搜索和重新排序。之后,我们看到了如何编写一个模块化的ContextRetriever类,它将所有高级 RAG 组件粘合在一个单一接口下,使得搜索相关文档变得轻而易举。最终,我们探讨了如何将所有缺失的环节,如检索、提示增强和 LLM 调用,连接到一个单一的 RAG 函数,该函数将作为我们的 RAG 推理管道。
如本章多次强调,我们无法测试我们微调的 LLM,因为我们还没有将其部署到 AWS SageMaker 作为推理端点。因此,在下一章中,我们将学习如何将 LLM 部署到 AWS SageMaker,编写一个调用端点的推理接口,并实现一个 FastAPI 网络服务器作为我们的业务层。
参考文献
-
社交媒体数据的实时检索系统 | SuperLinked 的 VectorHub。(未注明日期)。
superlinked.com/vectorhub/articles/real-time-retrieval-system-social-media-data -
从头开始构建路由器 - LlamaIndex。(未注明日期)。
docs.llamaindex.ai/en/stable/examples/low_level/router/ -
如何为聊天机器人添加内存 | LangChain。(未注明日期)。
python.langchain.com/docs/how_to/chatbots_memory/#summary-memory -
如何进行“自我查询”检索 | LangChain。(未注明日期)。
python.langchain.com/docs/how_to/self_query/ -
如何在子链之间进行路由 | LangChain。(未注明日期)。
python.langchain.com/docs/how_to/routing/#routing-by-semantic-similarity -
如何使用 MultiQueryRetriever | LangChain。(n.d.)。
python.langchain.com/docs/how_to/MultiQueryRetriever/ -
混合搜索解释。(2023,1 月 3 日)。Weaviate。
weaviate.io/blog/hybrid-search-explained -
Iusztin, P.(2024,8 月 20 日)。你必须知道的 4 个高级 RAG 算法 | 解码机器学习。Medium。
medium.com/decodingml/the-4-advanced-rag-algorithms-you-must-know-to-implement-5d0c7f1199d2 -
Monigatti, L.(2024,2 月 19 日)。高级检索增强生成:从理论到 LlamaIndex 实现。Medium。
towardsdatascience.com/advanced-retrieval-augmented-generation-from-theory-to-llamaindex-implementation-4de1464a9930 -
使用向量嵌入进行多属性搜索 | Superlinked 的 VectorHub。(n.d.)。
superlinked.com/vectorhub/articles/multi-attribute-semantic-search -
使用混合搜索和重排序优化 RAG | Superlinked 的 VectorHub。(n.d.)。
superlinked.com/vectorhub/articles/optimizing-rag-with-hybrid-search-reranking -
Refactoring.Guru.(2024,1 月 1 日)。Singleton。
refactoring.guru/design-patterns/singleton -
Stoll, M.(2024,9 月 7 日)。可视化你的 RAG 数据——使用 Ragas 评估你的检索增强生成系统。Medium。
towardsdatascience.com/visualize-your-rag-data-evaluate-your-retrieval-augmented-generation-system-with-ragas-fc2486308557 -
使用 LLM 进行检索和重排序——LlamaIndex,LLM 应用数据框架。(n.d.)。
www.llamaindex.ai/blog/using-llms-for-retrieval-and-reranking-23cf2d3a14b6
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十章:推理管道部署
部署大型语言模型(LLM)Twin 应用程序的推理管道是机器学习(ML)应用程序生命周期中的一个关键阶段。这是为您的业务增加最大价值的地方,使您的模型能够被最终用户访问。然而,成功部署 AI 模型可能具有挑战性,因为模型需要昂贵的计算能力和访问最新特征来运行推理。为了克服这些限制,精心设计您的部署策略至关重要。这确保了它满足应用程序的要求,例如延迟、吞吐量和成本。当我们与 LLM 合作时,我们必须考虑第八章中提出的推理优化技术,例如模型量化。此外,为了自动化部署流程,我们必须利用 MLOps 最佳实践,例如模型注册,它可以在我们的基础设施中版本控制和共享我们的模型。
为了理解如何设计 LLM Twin 的部署架构,我们首先将查看我们可以选择的三个部署类型:在线实时推理、异步推理和离线批量转换。为了更好地理解为我们的 LLM Twin 用例选择哪个选项,我们将快速带您了解在做出架构决策之前必须考虑的一组关键标准,例如延迟、吞吐量、数据和基础设施。此外,我们将权衡单体架构和微服务架构在模型服务中的优缺点,这个决定可以显著影响您服务的可扩展性和可维护性。一旦我们掌握了各种设计选择,我们将专注于理解 LLM Twin 推理管道的部署策略。随后,我们将带您完成部署 LLM Twin 服务的端到端教程,包括将我们的自定义微调 LLM 部署到 AWS SageMaker 端点和实现 FastAPI 服务器作为我们用户的中心入口点。然后,我们将以关于自动扩展策略及其在 SageMaker 上的使用方法的简短讨论来结束本章。
因此,在本章中,我们将涵盖以下主题:
-
选择部署类型的标准
-
理解推理部署类型
-
模型服务中的单体架构与微服务架构
-
探索 LLM Twin 的推理管道部署策略
-
部署 LLM Twin 服务
-
处理使用高峰的自扩展能力
选择部署类型的标准
当涉及到部署 ML 模型时,第一步是理解每个 ML 应用程序中存在的四个要求:吞吐量、延迟、数据和基础设施。
理解它们及其相互作用是至关重要的。在设计您模型的部署架构时,始终存在四个直接影响用户体验的权衡。例如,您的模型部署应该优化为低延迟还是高吞吐量?
吞吐量和延迟
吞吐量是指系统在给定时间内可以处理的推理请求数量。通常以每秒请求数(RPS)来衡量。当部署 ML 模型并预期处理大量请求时,吞吐量至关重要。它确保系统可以高效地处理大量请求,而不会成为瓶颈。
高吞吐量通常需要可扩展且健壮的基础设施,例如配备多个高端 GPU 的机器或集群。延迟是指系统从接收到单个推理请求到返回结果所需的时间。在需要快速响应时间的实时应用中,延迟至关重要,例如在实时用户交互、欺诈检测或任何需要即时反馈的系统。例如,OpenAI API 的平均延迟是从用户发送请求到服务提供可在您的应用程序中访问的结果的平均响应时间。
延迟是网络 I/O、序列化和反序列化以及 LLM 推理时间的总和。同时,吞吐量是 API 每秒处理的平均请求数量。
低延迟的系统需要优化且通常成本更高的基础设施,例如更快的处理器、更低的网络延迟,以及可能需要边缘计算来减少数据需要传输的距离。
当服务成功并行处理多个查询时,较低的延迟会转化为更高的吞吐量。例如,如果服务处理请求需要 100 毫秒,这相当于每秒 10 个请求的吞吐量。如果延迟达到每个请求 10 毫秒,吞吐量将上升到每秒 100 个请求。
然而,事情变得复杂,大多数 ML 应用采用批处理策略,同时将多个数据样本传递给模型。在这种情况下,较低的延迟可以转化为较低的吞吐量;换句话说,较高的延迟对应于较高的吞吐量。例如,如果您在 100 毫秒内处理 20 个批处理请求,延迟是 100 毫秒,而吞吐量是每秒 200 个请求。如果您在 200 毫秒内处理 60 个请求,延迟是 200 毫秒,而吞吐量上升到每秒 300 个请求。因此,即使在服务时间批处理请求时,考虑为良好的用户体验所接受的最低延迟也是至关重要的。
数据
如我们所知,数据在 ML 系统中无处不在。但当我们谈论模型服务时,我们主要关心的是模型的输入和输出。这包括处理数据的格式、体积和复杂性。数据是推理过程的基础。数据的特点,如其大小和类型,决定了系统需要如何配置和优化以实现高效处理。
数据的类型和大小直接影响延迟和吞吐量,因为更复杂或更广泛的数据可能需要更长的时间来处理。例如,设计一个接受结构化数据输入并输出概率的模型与接受文本(甚至图像)输入并输出字符数组的 LLM 完全不同。
基础设施
基础设施指的是支持 ML 模型部署和运行的底层硬件、软件、网络和系统架构。基础设施为部署、扩展和维护 ML 模型提供必要的资源。它包括计算资源、内存、存储、网络组件和软件栈:
-
对于高吞吐量,系统需要可扩展的基础设施来管理大量数据和高速请求,可能通过并行处理、分布式系统和高端 GPU。
-
基础设施必须优化以减少处理时间以实现低延迟,例如使用更快的 CPU、GPU 或专用硬件。在优化系统以实现低延迟并批量处理请求时,你通常必须牺牲高吞吐量以换取低延迟,这可能导致你的硬件无法充分利用其总容量。随着每秒处理的请求数量减少,会导致闲置计算,从而增加处理请求的整体成本。因此,选择适合您需求的机器对于优化成本至关重要。
设计满足特定数据要求的基础设施至关重要。这包括选择存储解决方案来处理大型数据集,并实施快速检索机制以确保高效的数据访问。例如,我们主要关注优化离线训练的吞吐量,而对于在线推理,我们通常关注延迟。
考虑到这一点,在选择特定的部署类型之前,你应该问自己一些问题,例如:
-
吞吐量需求是多少?你应该根据吞吐量的所需最小值、平均值和最大值统计数据来做出这个决定。
-
系统必须同时处理多少请求?(1,10,1,000,1 百万等)
-
延迟需求是多少?(1 毫秒,10 毫秒,1 秒等)
-
系统应该如何扩展?例如,我们应该查看 CPU 的工作负载、请求数量、队列大小、数据大小,或者它们的组合。
-
成本需求是什么?我们使用什么数据?例如,我们处理图像、文本还是表格数据?
-
我们处理的数据大小是多少?(100 MB,1 GB,10 GB)
深入思考这些问题会直接影响你应用程序的用户体验,这最终决定了产品成功与否。即使你推出一个令人惊叹的模型,如果用户需要等待很长时间才能得到响应,或者它经常崩溃,用户会转向使用更准确但可靠性较低的替代品。例如,谷歌在 2016 年的一项研究发现,如果移动网站加载时间超过三秒,53%的访问将被放弃:www.thinkwithgoogle.com/consumer-insights/consumer-trends/mobile-site-load-time-statistics/。
让我们继续探讨我们可以利用的三个部署架构来服务我们的模型。
理解推理部署类型
如图 10.1所示,在服务模型时,你可以从三种基本的部署类型中选择:
-
在线实时推理
-
异步推理
-
离线批量转换
在选择一种设计而不是另一种设计时,需要在延迟、吞吐量和成本之间进行权衡。你必须考虑数据是如何访问的,以及你正在与之合作的基础设施。你必须考虑的另一个标准是用户将如何与模型交互。例如,用户是否会直接使用它,就像聊天机器人一样,或者它是否会被隐藏在你的系统中,就像一个检查输入(或输出)是否安全的分类器?
你还必须考虑预测的新鲜度。例如,如果你的用例中可以接受延迟预测,那么在离线批量模式下部署你的模型可能更容易实现。否则,你必须实时部署你的模型,这需要更多的基础设施。此外,你还必须考虑你的应用程序流量。问问自己,例如,“应用程序是否会持续使用,还是会有流量高峰然后平缓下来?”
考虑到这一点,让我们来探讨三种主要的机器学习部署类型。

图 10.1:推理部署类型的三个基本架构
在线实时推理
在实时推理中,我们有一个基于服务器的简单架构,可以通过 HTTP 请求访问。最受欢迎的选项是实现 REST API 或 gRPC 服务器。REST API 更易于访问但速度较慢,使用 JSON 在客户端和服务器之间传递数据。
当将模型部署到内部网络之外,面向更广泛的公众时,通常会采用这种方法。例如,OpenAI 的 API 实现了 REST API 协议。
另一方面,实现 gRPC 可以使您的机器学习服务器更快,尽管这可能会降低其灵活性和通用性。您必须在客户端应用程序中实现protobuf模式,这比 JSON 结构更繁琐。然而,好处是protobuf对象可以编译成字节,使网络传输更快。因此,该协议通常被采用于同一机器学习系统内的内部服务。
使用实时推理方法,客户端向机器学习服务发送 HTTP 请求,该服务立即处理请求并在同一响应中返回结果。这种同步交互意味着客户端在继续之前必须等待结果。
为了使这一过程高效运行,基础设施必须支持低延迟、高度响应的机器学习服务,通常部署在快速、可扩展的服务器上。负载均衡对于均匀分配传入流量至关重要,而自动扩展确保系统可以处理变化的负载。高可用性对于始终保持服务运行也是必不可少的。
例如,这种架构在与大型语言模型(LLM)交互时经常出现,例如在向聊天机器人或由 LLM(如 ChatGPT 或 Claude)驱动的 API 发送请求时,您可以直接消耗预测。LLM 服务,如 ChatGPT 或 Claude,通常使用 WebSockets 将每个标记单独流式传输到最终用户,这使得交互更加响应。其他著名的例子包括嵌入或重排序模型,这些模型用于检索增强生成(RAG)或 TikTok 等平台上的在线推荐引擎。
实时推理的简单性,以及其直接的客户端-服务器交互,使其成为需要即时响应的应用程序(如聊天机器人或实时推荐)的吸引人选择。然而,这种方法在扩展上可能具有挑战性,并且在低流量期间可能会导致资源利用率不足。
异步推理
在异步推理中,客户端向机器学习服务发送请求,该服务确认请求并将其放入队列以供处理。与实时推理不同,客户端不需要等待即时响应。相反,机器学习服务异步处理请求。这需要一个强大的基础设施,该基础设施将消息排队,以便稍后由机器学习服务处理。
当结果准备好时,您可以使用多种技术将它们发送到客户端。例如,根据结果的大小,您可以将它放入不同的队列或一个专门用于存储结果的对象存储。
客户端可以选择采用轮询机制,定期检查是否有新的结果,或者采用推送策略并实现一个通知系统,在结果准备好时通知客户端。
异步推理更有效地使用资源。它不必同时处理所有请求,但可以定义一个最大数量的机器,这些机器可以并行运行以处理消息。这是可能的,因为请求被存储在队列中,直到有机器可以处理它们。另一个巨大的好处是它可以处理请求的峰值而不会超时。例如,假设在一个电子商务网站上,我们通常每秒由两台机器处理 10 个请求。由于促销活动,许多人开始访问该网站,请求的数量激增到每秒 100 个。而不是通过增加 10 个虚拟机(VMs)的数量,这可能会增加巨大的成本,请求被排队,相同的两台 VMs 可以按其节奏处理它们,而不会出现任何故障。
异步架构的另一个流行优势是当请求的工作需要很长时间才能完成时。例如,如果工作需要超过五分钟,你不想让客户端等待响应。
虽然异步推理提供了显著的好处,但它也带来了一些权衡。它引入了更高的延迟,使其不太适合对时间敏感的应用。此外,它增加了实现和基础设施的复杂性。根据你的设计选择,这种架构类型介于在线和离线之间,提供了利益和权衡的平衡。
例如,这是一个健壮的设计,你不太关心推理的延迟,但想大幅度优化成本。因此,它对于诸如从文档中提取关键词、使用大型语言模型进行总结或在上面的视频上运行深度伪造模型等问题来说是一个流行的选择。但是,如果你仔细设计了自动扩展系统,以适中的速度处理队列中的请求,那么你可以利用这种设计来处理其他用例,例如电子商务的在线推荐。最终,它总结了为了满足应用程序的期望,你愿意支付多少计算能力。
离线批处理转换
批处理转换是关于同时处理大量数据,无论是按计划还是手动触发。在批处理转换架构中,机器学习服务从存储系统提取数据,一次性处理它,然后将结果存储在存储系统中。存储系统可以实施为对象存储,如 AWS S3,或数据仓库,如 GCP BigQuery。
与异步推理架构不同,批处理转换设计针对高吞吐量和可接受的延迟要求进行了优化。当实时预测不是必需时,这种方法可以显著降低成本,因为批量处理数据是最经济的方法。此外,批处理转换架构是提供模型的最简单方式,可以加速开发时间。
客户端直接从数据存储中拉取结果,解耦了它与机器学习服务的交互。采用这种方法,客户端永远不需要等待机器学习服务处理其输入,但与此同时,它也没有在任何时候请求新结果的灵活性。你可以看到数据存储,其中结果作为大缓存存储,客户端可以从那里获取所需的内容。如果你想使你的应用程序更响应,客户端可以在处理完成后被通知并检索结果。
不幸的是,这种方法总是在预测计算和消费之间引入延迟。这就是为什么并非所有应用程序都能利用这种设计选择。例如,如果我们为视频流应用程序实现一个推荐系统,对于预测的电影和电视节目有一天的延迟可能可行,因为你不会频繁地消费这些产品。但假设你为社交媒体平台实现一个推荐系统。在这种情况下,一天的延迟甚至一小时的延迟都是不可接受的,因为你始终希望向用户提供新鲜的内容。
批量转换在需要高吞吐量的场景中表现出色,如数据分析或定期报告。然而,由于其高延迟,它不适合实时应用程序,并且需要仔细规划和调度来有效地管理大数据集。这就是为什么它是一种离线服务方法。
总结来说,我们探讨了服务机器学习模型最常用的三种架构。我们首先从在线实时推理开始,当客户端请求预测时为客户端提供服务。然后,我们看了异步推理方法,它介于在线和离线之间。最终,我们介绍了离线批量转换,它用于处理大量数据并将它们存储在数据存储中,客户端稍后从那里消费它们。
模型服务中的单体架构与微服务架构
在上一节中,我们看到了部署机器学习服务的三种不同方法。架构上的差异主要基于客户端与机器学习服务之间的交互,例如通信协议、机器学习服务的响应性和预测的新鲜度。
但另一个需要考虑的方面是机器学习服务的架构本身,它可以实现为一个单体服务器或多个微服务。这将影响机器学习服务的实现、维护和扩展方式。让我们来探讨这两种选项。

图 10.2:模型服务中的单体架构与微服务架构
单体架构
在单体架构中,LLM(或任何其他 ML 模型)及其相关的业务逻辑(预处理和后处理步骤)被捆绑成一个单一的服务。这种方法在项目开始时易于实现,因为所有内容都放在一个代码库中。在小到中等规模的项目中,由于更新和更改可以在统一的系统中进行,因此简单性使得维护变得容易。
单体架构的一个关键挑战是独立扩展组件的困难。LLM 通常需要 GPU 性能,而其余的业务逻辑是 CPU 和 I/O 密集型。因此,基础设施必须针对 GPU 和 CPU 进行优化。这可能导致资源使用效率低下,当业务逻辑执行时 GPU 处于空闲状态,反之亦然。这种低效率可能导致额外的成本,这些成本本可以避免。
此外,这种架构可能会限制灵活性,因为所有组件必须共享相同的技栈和运行环境。例如,您可能希望使用 Rust 或 C++运行 LLM 或使用 ONNX 或 TensorRT 进行编译,同时保持业务逻辑在 Python 中。将所有代码放在一个系统中使得这种差异化变得困难。最后,将工作分配给不同的团队是复杂的,通常会导致瓶颈和敏捷性降低。
微服务架构
微服务架构将推理管道分解为独立的、独立的服务——通常将 LLM 服务和业务逻辑拆分为不同的组件。这些服务通过 REST 或 gRPC 等协议在网络中进行通信。
如图 10.3所示,这种方法的主要优势在于能够独立扩展每个组件。例如,由于 LLM 服务可能需要比业务逻辑更多的 GPU 资源,因此它可以水平扩展而不会影响其他组件。这优化了资源使用并降低了成本,因为可以根据每个服务的需求使用不同类型的机器(例如,GPU 与 CPU)。
例如,假设 LLM 推理需要更长的时间,因此您将需要更多的 ML 服务副本来满足需求。但请记住,GPU 虚拟机很昂贵。通过解耦这两个组件,您只需在 GPU 机器上运行所需的操作,而不会因为其他可以在更便宜的机器上完成的计算而阻塞 GPU 虚拟机。
因此,通过解耦组件,您可以按需水平扩展,成本最低,为您的系统需求提供经济有效的解决方案。

图 10.3:根据计算需求独立扩展微服务
此外,每个微服务都可以采用最合适的技术栈,使团队能够独立创新和优化。
然而,微服务在部署和维护中引入了复杂性。每个服务都必须单独部署、监控和维护,这可能比管理单体系统更具挑战性。
服务的增加网络通信也可能引入延迟和潜在的故障点,需要强大的监控和弹性机制。
注意,将机器学习和业务逻辑解耦到两个服务中的设计方案可以根据需要扩展。例如,你可以有一个服务用于数据预处理,一个用于模型,另一个用于数据后处理。根据四个支柱(延迟、吞吐量、数据和基础设施),你可以发挥创意,为你的应用程序需求设计最优化架构。
在单体架构和微服务架构之间进行选择
为服务机器学习模型选择单体架构和微服务架构主要取决于应用程序的具体需求。对于小型团队或更简单的应用程序,单体方法可能是理想的,在这些应用程序中,开发和维护的简便性是优先考虑的。它也是没有频繁扩展要求的项目的一个良好起点。此外,如果机器学习模型较小,不需要 GPU,或者不需要更小、更便宜的 GPU,那么在降低成本和复杂化你的基础设施之间的权衡是值得考虑的。
另一方面,微服务因其适应性和可扩展性,非常适合更大、更复杂的系统,其中不同的组件有不同的扩展需求或需要不同的技术堆栈。这种架构在扩展特定系统部分,如 GPU 密集型 LLM 服务时特别有利。由于 LLM 需要配备 GPU 的强大机器,如 Nvidia A100、V100 或 A10g,这些机器成本极高,微服务提供了优化系统的灵活性,以保持这些机器始终忙碌或当 GPU 空闲时快速缩放。然而,这种灵活性是以开发和运营复杂度增加为代价的。
一种常见的策略是在项目增长的过程中,从单一的设计开始,并将其进一步解耦为多个服务。然而,为了在不使过渡过于复杂和昂贵的情况下成功实现这一点,你必须考虑到这一点来设计单体应用程序。例如,即使所有代码都在单个机器上运行,你仍然可以在软件级别完全解耦应用程序的模块。这使得在需要的时候将这些模块移动到不同的微服务中变得更容易。例如,当使用 Python 时,你可以将机器学习和业务逻辑实现为两个不同的 Python 模块,这两个模块之间不相互交互。然后,你可以在更高的层次上将这些模块粘合在一起,例如通过服务类,或者直接粘合到你用来在互联网上公开应用程序的框架中,例如 FastAPI。
另一个选择是将机器学习和业务逻辑编写为两个不同的 Python 包,然后以与之前相同的方式将它们粘合在一起。这样做更好,因为它完全强制了两者之间的分离,但在开发时增加了额外的复杂性。因此,主要思想是,如果你从一个单体开始,并且最终想要迁移到微服务架构,那么在设计软件时必须考虑模块化。否则,如果逻辑混合,你可能不得不从头开始重写一切,这将增加大量的开发时间,这转化为浪费的资源。
总结来说,单体架构提供了简单性和易于维护性,但以灵活性和可扩展性为代价。同时,微服务提供了可扩展性和创新的敏捷性,但需要更复杂的管理和运营实践。
探索 LLM Twin 的推理管道部署策略
现在我们已经了解了实现 LLM Twin 推理管道部署策略的所有设计选择,让我们来探讨我们为了实现它所做出的具体决策。
我们的主要目标是开发一个促进内容创作的聊天机器人。为了实现这一目标,我们将按顺序处理请求,并强调低延迟。这需要选择一个在线实时推理部署架构。
在单体与微服务方面,我们将把机器学习服务分为一个包含业务逻辑的 REST API 服务器和一个针对运行给定 LLM 进行优化的 LLM 微服务。由于 LLM 需要强大的机器来运行推理,并且我们可以使用各种引擎进一步优化它以加快延迟和内存使用,因此采用微服务架构最有意义。通过这样做,我们可以根据不同的 LLM 大小快速调整基础设施。例如,如果我们运行一个 8B 参数模型,模型在量化后可以在单个机器上运行,配备 Nivida A10G GPU。但如果我们想运行一个 30B 模型,我们可以升级到 Nvidia A100 GPU。这样做允许我们只升级 LLM 微服务,同时保持 REST API 不变。
如图 10.4所示,在我们的特定用例中,大部分业务逻辑都集中在 RAG 上。因此,我们将执行 RAG 的检索和增强部分在业务微服务中。它还将包括上一章中介绍的所有的先进 RAG 技术,以优化预检索、检索和后检索步骤。
LLM 微服务严格优化了 RAG 生成组件。最终,业务层将用户查询、提示、答案和其他中间步骤组成的提示跟踪信息发送到提示监控管道,我们将在第十一章中详细说明。
总结来说,我们的方法涉及通过微服务架构实现一个在线实时 ML 服务,这有效地将 LLM 和业务逻辑分为两个不同的服务。

图 10.4:LLM Twin 推理管道的微服务部署架构
让我们回顾推理管道的接口,该接口由特征/训练/推理(FTI)架构定义。为了使管道运行,它需要两样东西:
-
用于 RAG 的实时特征,由特征管道生成,从我们的在线特征存储中查询,更具体地说,从 Qdrant 向量数据库(DB)中查询
-
由训练管道生成的经过微调的 LLM,该 LLM 从我们的模型注册库中提取
考虑到这一点,ML 服务的流程如下,如图 10.4 所示:
-
用户通过 HTTP 请求发送查询。
-
用户输入通过利用第四章中实现的先进 RAG 检索模块来检索适当的上下文。
-
使用专用提示模板将用户输入和检索到的上下文打包到最终的提示中。
-
通过 HTTP 请求将提示发送到 LLM 微服务。
-
业务微服务等待生成的答案。
-
生成答案后,它连同用户输入和其他重要信息一起发送到提示监控管道以进行监控。
-
最终,生成的答案发送回用户。
现在,让我们探索我们用来实现图 10.4 中展示的架构的技术堆栈。正如我们所知,我们使用 Qdrant 作为向量数据库。我们将利用 Hugging Face 进行模型注册。通过这样做,我们可以与测试本书代码的每个人公开共享我们的模型。因此,如果您不想运行训练管道,可以节省高达 100 美元,您可以使用我们提供的模型。如您所见,可共享性和可访问性是将您的模型存储在模型注册库中的最美丽方面之一。
我们将使用 FastAPI 实现业务微服务,因为它受欢迎、易于使用且速度快。LLM 微服务将部署在 AWS SageMaker 上,我们将利用 SageMaker 与 Hugging Face 的深度学习容器(DLCs)的集成来部署模型。我们将在下一节讨论 Hugging Face 的 DLCs,但直观地说,它是一个用于在服务时间优化 LLM 的推理引擎。提示监控管道使用 Comet 实现,但我们将只在第十一章中查看该模块。
SageMaker 推理部署由以下组件组成,我们将向您展示如何实现这些组件:
-
SageMaker 端点:端点是一个可扩展且安全的 API,SageMaker 托管它以启用从部署模型进行实时预测。它本质上是应用程序与您的模型交互的接口。一旦部署,应用程序就可以向端点发送 HTTP 请求以接收实时预测。
-
SageMaker 模型:在 SageMaker 中,模型是训练算法后产生的工件。它包含进行预测所需的信息,包括权重和计算逻辑。您可以创建多个模型,并在不同的配置或用于各种预测中使用它们。
-
SageMaker 配置:此配置指定了托管模型所需的硬件和软件设置。它定义了端点所需资源,例如 ML 计算实例的类型和数量。在创建或更新端点时使用端点配置。它们允许在托管模型的部署和可扩展性方面具有灵活性。
-
SageMaker 推理组件:这是拼图中最后一块,将模型和配置连接到端点。您可以将多个模型部署到端点,每个模型都有自己的资源配置。一旦部署,模型可以通过 Python 中的 InvokeEndpoint API 轻松访问。
这些组件共同为在 SageMaker 中部署和管理机器学习模型提供了一个强大的基础设施,使可扩展、安全且高效的实时预测成为可能。
其他流行的云平台提供了确切解决方案。例如,在 Azure 上,您有 Azure OpenAI 代替 Bedrock,以及 Azure ML 代替 SageMaker。机器学习部署工具的列表,如 Hopsworks、Modal、Vertex AI、Seldon、BentoML 等,是无穷无尽的,并且可能会发生变化。然而,重要的是要了解您的用例需求,并找到一个适合您需求的工具。
训练与推理管道的比较
在部署推理管道之前,理解训练和推理管道之间的细微差别至关重要。虽然训练管道用于训练,推理管道用于推理可能看起来很简单,但我们需要充分理解这些差异,以全面理解我们讨论的技术方面。
一个关键的区别在于每个管道内部处理和访问数据的方式。在训练过程中,数据通常以批量模式从离线存储中访问,优化了吞吐量并确保了数据可追溯性。例如,我们的 LLM Twin 架构使用 ZenML 工件以批量方式访问、版本控制和跟踪训练循环中提供的数据。相比之下,推理管道需要一个针对低延迟优化的在线数据库。我们将利用 Qdrant 向量数据库来获取 RAG 所需的必要上下文。在这种情况下,重点从数据可追溯性和版本控制转移到快速数据访问,确保无缝的用户体验。此外,这些管道的输出也显著不同。训练管道输出存储在模型注册表中的训练模型权重。同时,推理管道输出直接提供给用户的预测。
此外,每个管道所需的架构也不同。训练管道需要配备尽可能多的 GPU 的更强大的机器。这是因为训练涉及批量处理数据并在内存中保留所有必要的梯度以进行优化步骤,这使得它非常计算密集。更多的计算能力和 VRAM 允许更大的批量(或吞吐量),从而减少训练时间并允许更广泛的实验。另一方面,推理管道通常需要的计算较少。推理通常涉及将单个样本或更小的批量传递到模型中,而不需要优化步骤。
尽管这两个管道之间存在一些差异,但在预处理和后处理步骤方面有一些重叠。在训练和推理过程中应用相同的预处理和后处理函数以及超参数至关重要。任何差异都可能导致所谓的训练-服务偏差,即模型在推理时的性能与其在训练时的性能不一致。
部署 LLM Twin 服务
最后一步是实现前一部分中提出的架构。更具体地说,我们将使用 AWS SageMaker 部署 LLM 微服务,使用 FastAPI 部署业务微服务。在业务微服务中,我们将把第九章中编写的 RAG 逻辑与我们的微调后的 LLM Twin 结合起来,最终能够端到端地测试推理管道。
在任何机器学习应用程序的生命周期中,提供机器学习模型是其中最关键的步骤之一,因为用户只能在完成此阶段后与我们的模型交互。如果服务架构设计不当或基础设施运行不正常,那么你即使实现了强大而优秀的模型也没有意义。只要用户不能适当地与之交互,从商业角度来看,它的价值几乎为零。例如,如果你拥有市场上最好的代码助手,但使用它的延迟太高,或者 API 调用不断崩溃,用户可能会转而使用性能较差但运行更快且更稳定的代码助手。
因此,在本节中,我们将向您展示如何:
-
将我们的微调后的 LLM Twin 模型部署到 AWS SageMaker
-
编写一个推理客户端与部署的模型交互
-
使用 FastAPI 编写业务服务
-
将我们的 RAG 逻辑与微调后的 LLM 集成
-
为 LLM 微服务实现自动扩展规则
使用 AWS SageMaker 实现 LLM 微服务
我们的目标是将存储在 Hugging Face 模型注册表中的 LLM Twin 模型部署到 Amazon SageMaker 作为在线实时推理端点。我们将利用 Hugging Face 的专用推理容器,称为 Hugging Face LLM DLC,来部署我们的 LLM。
Hugging Face 的 DLC 是什么?
DLC 是预装了必要的深度学习框架和库的专用 Docker 镜像,包括来自 Hugging Face 的流行工具,如 transformers、datasets 和 tokenizers。这些容器旨在通过消除复杂环境设置和优化的需求来简化模型训练和部署的过程。特别是,Hugging Face Inference DLC 包括一个完全集成的服务栈,极大地简化了部署过程,并减少了在生产中部署深度学习模型所需的技术专业知识。
当涉及到模型部署时,DLC 由 Hugging Face 制作的文本生成推理(TGI)引擎提供支持:github.com/huggingface/text-generation-inference.
TGI 是一个开源解决方案,用于部署和托管 LLMs。它提供了使用张量并行性和动态批处理的高性能文本生成,适用于 Hugging Face 上最流行的开源 LLMs,如 Mistral、Llama 和 Falcon。总结一下,DLC 镜像提供的最强大功能包括:
-
Tensor 并行性,从而提高模型推理的计算效率
-
优化后的推理 Transformer 代码,利用 flash-attention 技术以最大化性能,适用于最广泛使用的架构:
github.com/Dao-AILab/flash-attention -
使用
bitsandbytes进行量化,在保持性能的同时减小模型大小,使部署更加高效:github.com/bitsandbytes-foundation/bitsandbytes -
对传入请求进行连续批处理,从而通过动态批处理请求来提高吞吐量
-
通过利用
safetensors加速权重加载,以更快的模型初始化速度,减少启动时间:github.com/huggingface/safetensors -
支持通过 Server-Sent Events(SSE)进行令牌流,以支持实时交互
总结来说,我们的 LLM Twin 模型将在 DLC Docker 镜像中运行,监听请求,优化 LLM 以进行推理,并实时提供服务。DLC 的 Docker 镜像将托管在 AWS SageMaker 的推理端点上,可以通过 HTTP 请求访问。考虑到这一点,让我们继续实施。我们将从部署 LLM 开始,然后编写一个包装类来与 SageMaker Inference 端点交互。
配置 SageMaker 角色
第一步是创建适当的 AWS 身份和访问管理(IAM)用户和角色,以便访问和部署 SageMaker 基础设施。AWS IAM 控制谁可以进行身份验证以及任何行为者可以访问什么。您可以通过 IAM 创建新的用户(分配给人员)和新的角色(分配给您基础设施中的其他行为者,例如 EC2 虚拟机)。
整个部署过程是自动化的。我们可能需要运行一些 CLI 命令,但首先,请确保您已正确配置 .env 文件中的 AWS_ACCESS_KEY、AWS_SECRET_KEY 和 AWS_REGION 环境变量。在这一步,最简单的方法是使用附加到管理员角色的凭证,因为在接下来的步骤中,我们将创建一组用于本章其余部分的更狭窄的 IAM 角色。
在您配置了 .env 文件之后,我们必须:
-
创建一个 IAM 用户,该用户仅限于创建和删除我们部署所需的资源,例如 SageMaker 本身、弹性容器注册库(ECR)和 S3。要创建它,请运行以下命令:
poetry poe create-sagemaker-role
此命令将生成一个名为 sagemaker_user_credentials.json 的 JSON 文件,其中包含新的 AWS 访问密钥和秘密密钥。从现在开始,我们将使用这些凭证来部署与 SageMaker 相关的所有内容,以确保我们只修改与 SageMaker 相关的资源。否则,我们可能会意外地使用管理员账户修改其他 AWS 资源,导致额外的费用或更改其他现有项目。因此,只针对您的用例拥有一个狭窄的角色是良好的实践。
最后一步是从 JSON 文件中获取新的凭证并更新 .env 文件中的 AWS_ACCESS_KEY 和 AWS_SECRET_KEY 变量。您可以在 github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/infrastructure/aws/roles/create_sagemaker_role.py 查看实现。
-
创建一个 IAM 执行角色。我们将把这个角色附加到 SageMaker 部署中,使其能够代表我们访问其他 AWS 资源。这是云部署的标准做法,因为您不需要为您的凭证中的每一台机器进行身份验证,而是附加一个角色,允许它们仅从您的基础设施中访问必要的资源。在我们的例子中,我们将为 SageMaker 提供访问 AWS S3、CloudWatch 和 ECR 的权限。要创建该角色,请运行以下命令:
poetry poe create-sagemaker-execution-role
此命令将生成一个名为sagemaker_execution_role.json的 JSON 文件,其中包含新创建角色的Amazon 资源名称(ARN)。ARN 是附加到任何 AWS 资源上的 ID,用于在您的云基础设施中识别它。从 JSON 文件中获取 ARN 值,并用它更新.env文件中的AWS_ARN_ROLE变量。您可以在github.com/PacktPublishing/LLM-Engineers-Handbook/blob/main/llm_engineering/infrastructure/aws/roles/create_execution_role.py查看实现。
如果您遇到问题,请使用与.env文件中相同的 AWS 凭证配置 AWS CLI,并重复此过程。安装 AWS CLI 的官方文档:docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html。
通过在.env文件中设置 IAM 用户和角色,我们将自动在 Python 设置对象中加载它们,并在以下步骤中使用它们。现在,让我们继续实际部署。
将 LLM Twin 模型部署到 AWS SageMaker
AWS SageMaker 的部署完全通过一组 Python 类实现自动化,我们将在本章中介绍这些类。本节旨在理解如何直接从 Python 配置 SageMaker 基础设施。因此,你不需要像在标准教程中那样一步一步运行所有内容,只需理解代码即可。
我们可以使用简单的 CLI 命令poe deploy-inference-endpoint启动和完成整个 SageMaker 部署。此命令将初始化图 10.5中展示的所有步骤,但不会创建我们在上一步中创建和配置的 SageMaker AWS IAMs。
在本节中,我们将向您展示图 10.5中呈现的代码,该代码帮助我们完全自动化部署过程,从create_endpoint()函数开始。最终,我们将测试 CLI 命令并检查 AWS 控制台,以查看部署是否成功。SageMaker 部署代码可在github.com/PacktPublishing/LLM-Engineers-Handbook/tree/main/llm_engineering/infrastructure/aws/deploy找到。

图 10.5:AWS SageMaker 部署步骤
我们将采用自上而下的方法向您介绍实现过程,从将 LLM Twin 模型部署到 AWS SageMaker 的主函数开始。在下面的函数中,我们首先使用get_huggingface_llm_image_uri()函数获取 Docker DLC 镜像的最新版本,该函数随后传递给部署策略类,以及资源管理器和部署服务的实例:
def create_endpoint(endpoint_type=EndpointType.INFERENCE_COMPONENT_BASED):
llm_image = get_huggingface_llm_image_uri("huggingface", version=None)
resource_manager = ResourceManager()
deployment_service = DeploymentService(resource_manager=resource_manager)
SagemakerHuggingfaceStrategy(deployment_service).deploy(
role_arn=settings.ARN_ROLE,
llm_image=llm_image,
config=hugging_face_deploy_config,
endpoint_name=settings.SAGEMAKER_ENDPOINT_INFERENCE,
endpoint_config_name=settings.SAGEMAKER_ENDPOINT_CONFIG_INFERENCE,
gpu_instance_type=settings.GPU_INSTANCE_TYPE,
resources=model_resource_config,
endpoint_type=endpoint_type,
)
我们必须回顾 create_endpoint() 函数中使用的三个类,以全面了解部署过程。让我们从 ResourceManager 类开始。该类从初始化方法开始,使用 boto3(Python 的 AWS SDK)建立与 AWS SageMaker 的连接,它提供了与各种 AWS 服务(包括 SageMaker)交互所需的必要功能。
class ResourceManager:
def __init__(self) -> None:
self.sagemaker_client = boto3.client(
"sagemaker",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY,
aws_secret_access_key=settings.AWS_SECRET_KEY,
)
接下来,我们实现了 endpoint_config_exists 方法,用于检查是否存在特定的 SageMaker 端点配置:
def endpoint_config_exists(self, endpoint_config_name: str) -> bool:
try:
self.sagemaker_client.describe_endpoint_config(EndpointConfigName=endpoint_config_name)
logger.info(f"Endpoint configuration '{endpoint_config_name}' exists.")
return True
except ClientError:
logger.info(f"Endpoint configuration '{endpoint_config_name}' does not exist.")
return False
该类还包括 endpoint_exists 方法,用于检查特定 SageMaker 端点是否存在:
def endpoint_exists(self, endpoint_name: str) -> bool:
try:
self.sagemaker_client.describe_endpoint(EndpointName=endpoint_name)
logger.info(f"Endpoint '{endpoint_name}' exists.")
return True
except self.sagemaker_client.exceptions.ResourceNotFoundException:
logger.info(f"Endpoint '{endpoint_name}' does not exist.")
return False
让我们转到 DeploymentService。在构造函数中,我们设置了 sagemaker_client,它将与 AWS SageMaker 以及我们之前提到的 ResourceManager 类实例进行接口:
class DeploymentService:
def __init__(self, resource_manager):
self.sagemaker_client = boto3.client(
"sagemaker",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY,
aws_secret_access_key=settings.AWS_SECRET_KEY,
)
self.resource_manager = resource_manager
deploy() 方法是 DeploymentService 类的核心。此方法协调将模型部署到 SageMaker 端点的整个过程。它检查是否已设置必要的配置,如果没有,则触发部署:
def deploy(
self,
role_arn: str,
llm_image: str,
config: dict,
endpoint_name: str,
endpoint_config_name: str,
gpu_instance_type: str,
resources: Optional[dict] = None,
endpoint_type: enum.Enum = EndpointType.MODEL_BASED,
) -> None:
try:
if self.resource_manager.endpoint_config_exists(endpoint_config_name=endpoint_config_name):
logger.info(f"Endpoint configuration {endpoint_config_name} exists. Using existing configuration...")
else:
logger.info(f"Endpoint configuration{endpoint_config_name} does not exist.")
self.prepare_and_deploy_model(
role_arn=role_arn,
llm_image=llm_image,
config=config,
endpoint_name=endpoint_name,
update_endpoint=False,
resources=resources,
endpoint_type=endpoint_type,
gpu_instance_type=gpu_instance_type,
)
logger.info(f"Successfully deployed/updated model to endpoint {endpoint_name}.")
except Exception as e:
logger.error(f"Failed to deploy model to SageMaker: {e}")
raise
deploy 方法首先使用 resource_manager 检查端点配置是否已存在。这一步至关重要,因为它可以避免在配置已设置的情况下进行不必要的重新部署。部署本身是通过调用 prepare_and_deploy_model() 方法来处理的,该方法负责将模型实际部署到指定的 SageMaker 端点。
prepare_and_deploy_model() 方法是 DeploymentService 类中的一个静态方法。此方法专注于设置和部署 Hugging Face 模型到 SageMaker:
@staticmethod
def prepare_and_deploy_model(
role_arn: str,
llm_image: str,
config: dict,
endpoint_name: str,
update_endpoint: bool,
gpu_instance_type: str,
resources: Optional[dict] = None,
endpoint_type: enum.Enum = EndpointType.MODEL_BASED,
) -> None:
huggingface_model = HuggingFaceModel(
role=role_arn,
image_uri=llm_image,
env=config,
transformers_version="4.6",
pytorch_version="1.13",
py_version="py310",
)
huggingface_model.deploy(
instance_type=gpu_instance_type,
initial_instance_count=1,
endpoint_name=endpoint_name,
update_endpoint=update_endpoint,
resources=resources,
tags=[{"Key": "task", "Value": "model_task"}],
endpoint_type=endpoint_type,
)
此方法首先创建一个 HuggingFaceModel 实例,这是一个 SageMaker 的专用模型类,用于处理 Hugging Face 模型。HuggingFaceModel 构造函数接受多个基本参数,例如角色 ARN(它为 SageMaker 提供必要的权限)、LLM DLC Docker 图像的 URI 以及 LLM 配置,该配置指定从 Hugging Face 加载哪个 LLM 以及其推理参数,例如最大令牌总数。
一旦实例化了 HuggingFaceModel,该方法就会使用 deploy 函数将其部署到 SageMaker。此部署过程包括指定使用的实例类型、实例数量以及是否更新现有端点或创建新端点。该方法还包括用于更复杂部署的可选资源,例如用于多模型端点的 initial_instance_count 参数和用于跟踪和分类的标签。
最后一步是向您介绍 SagemakerHuggingfaceStrategy 类,该类汇总了我们展示的所有内容。该类仅初始化一个部署服务实例,如上面所示。
class SagemakerHuggingfaceStrategy(DeploymentStrategy):
def __init__(self, deployment_service):
self.deployment_service = deployment_service
SagemakerHuggingfaceStrategy 类的核心功能封装在其 deploy() 方法中。此方法协调部署过程,接受各种参数,这些参数定义了如何将 Hugging Face 模型部署到 AWS SageMaker:
def deploy(
self,
role_arn: str,
llm_image: str,
config: dict,
endpoint_name: str,
endpoint_config_name: str,
gpu_instance_type: str,
resources: Optional[dict] = None,
endpoint_type: enum.Enum = EndpointType.MODEL_BASED,
) -> None:
logger.info("Starting deployment using Sagemaker Huggingface Strategy...")
logger.info(
f"Deployment parameters: nb of replicas: {settings.COPIES}, nb of gpus:{settings.GPUS}, instance_type:{settings.GPU_INSTANCE_TYPE}"
)
传递给方法中的参数对于部署过程至关重要:
-
role_arn:提供 SageMaker 部署权限的 AWS IAM 角色。 -
llm_image:DLC Docker 图像的 URI。 -
config:包含模型环境配置设置的字典。 -
endpoint_name和endpoint_config_name:分别为 SageMaker 端点和其配置的名称。 -
gpu_instance_type:用于部署的 GPU EC2 实例的类型。 -
resources:用于多模型端点部署的可选资源字典。 -
endpoint_type:这可以是MODEL_BASED或INFERENCE_COMPONENT,决定端点是否包含推理组件。
该方法将实际的部署过程委托给 deployment_service。这种委托是策略模式的关键方面,允许在不改变高级部署逻辑的情况下,灵活地执行部署。
try:
self.deployment_service.deploy(
role_arn=role_arn,
llm_image=llm_image,
config=config,
endpoint_name=endpoint_name,
endpoint_config_name=endpoint_config_name,
gpu_instance_type=gpu_instance_type,
resources=resources,
endpoint_type=endpoint_type,
)
logger.info("Deployment completed successfully.")
except Exception as e:
logger.error(f"Error during deployment: {e}")
raise
此外,让我们回顾资源配置,以更好地了解基础设施。当设置使用多个副本来服务客户端并满足应用程序的延迟和吞吐量要求的多端点配置时,这些资源被利用。ResourceRequirements 对象使用一个字典初始化,该字典指定了各种资源参数。这些参数包括要部署的模型副本(副本)数量、所需的 GPU 数量、CPU 核心数以及以兆字节为单位的内存分配。这些参数中的每一个都在部署模型的性能和可扩展性中扮演着至关重要的角色。
from sagemaker.compute_resource_requirements.resource_requirements import ResourceRequirements
model_resource_config = ResourceRequirements(
requests={
"copies": settings.COPIES,
"num_accelerators": settings.GPUS
"num_cpus": settings.CPUS,
"memory": 5 * 1024
},
)
ResourceRequirements is configured with four key parameters:
-
copies:此参数确定应部署多少个模型实例或副本。拥有多个副本可以帮助降低延迟并提高吞吐量。
-
num_accelerators:此参数指定要分配的 GPU 数量。由于 LLM 计算密集,通常需要多个 GPU 来加速推理过程。
-
num_cpus:这定义了部署应具有的 CPU 核心数。CPU 的数量影响模型处理数据预处理、后处理和其他任务的能力,这些任务虽然不太依赖于 GPU,但仍然是必不可少的。
-
memory:内存参数设置部署所需的最低 RAM 量。足够的内存对于确保模型可以加载和运行而不会遇到内存不足至关重要。
通过设置这些参数,该类确保当模型部署到 SageMaker 端点时,它有足够的资源来高效运行。这些值的精确调整将根据 LLM 的具体要求而变化,例如其大小、将要执行的任务的复杂性以及预期的负载。为了更好地理解如何使用它们,在部署端点后,我们建议修改它们并观察 LLM 微服务的性能如何变化。
最后,让我们回顾一下配置 LLM 引擎的设置。HF_MODEL_ID 识别要部署哪个 Hugging Face 模型。例如,在设置类中,我们将其设置为 mlabonne/TwinLlama-3.1-8B-13 以加载存储在 Hugging Face 中的自定义 LLM Twin 模型。SM_NUM_GPUS 指定每个模型副本分配的 GPU 数量,这对于将模型适配到 GPU 的 VRAM 中至关重要。HUGGING_FACE_HUB_TOKEN 提供对 Hugging Face Hub 的访问权限以检索模型。HF_MODEL_QUANTIZE 指定要使用的量化技术,而其余变量控制 LLM 令牌生成过程。
hugging_face_deploy_config = {
"HF_MODEL_ID": settings.HF_MODEL_ID,
"SM_NUM_GPUS": json.dumps(settings.SM_NUM_GPUS), # Number of GPU used per replica
"MAX_INPUT_LENGTH": json.dumps(settings.MAX_INPUT_LENGTH), # Max length of input text
"MAX_TOTAL_TOKENS": json.dumps(settings.MAX_TOTAL_TOKENS), # Max length of the generation (including input text)
"MAX_BATCH_TOTAL_TOKENS": json.dumps(settings.MAX_BATCH_TOTAL_TOKENS),
"HUGGING_FACE_HUB_TOKEN": settings.HUGGINGFACE_ACCESS_TOKEN,
"MAX_BATCH_PREFILL_TOKENS": "10000",
"HF_MODEL_QUANTIZE": "bitsandbytes",
}
使用这两个配置,我们完全控制我们的基础设施,选择哪个 LLM 使用,以及它的行为方式。要启动具有上述配置的 SageMaker 部署,请按照以下方式调用 create_endpoint() 函数(本节开头介绍):
create_endpoint(endpoint_type=EndpointType.MODEL_BASED)
为了方便起见,我们还将其封装在一个 poe 命令下:
poetry poe deploy-inference-endpoint
部署推理管道到 AWS SageMaker 所需的步骤就是这些。最困难的部分是找到适合您需求且能降低基础设施成本的正确配置。根据 AWS,这需要 15-30 分钟才能部署。您可以直接从 .env 文件中更改任何值,并使用不同的配置部署模型,而无需修改代码。例如,我们的默认值使用单个 ml.g5.xlargeGPU 类型的 GPU 实例。如果您需要更多副本,可以调整 GPUS 和 SM_NUM_GPUS 设置,或者通过更改 GPU_INSTANCE_TYPE 变量来更改实例类型。
在将 LLM 微服务部署到 AWS SageMaker 之前,请确保您已通过运行 poetry poe create-sagemaker-role 生成用户角色,并通过运行 poetry poe create-sagemaker-execution-role 生成执行角色。此外,请确保您已更新 .env 文件中的 AWS_* 环境变量,以包含由这两个步骤生成的凭证。您可以在存储库的 README 文件中找到更多关于此方面的详细信息。
在部署 AWS SageMaker 推理端点后,您可以通过 AWS 中的 SageMaker 仪表板来可视化它。首先,在左侧面板中点击SageMaker 仪表板,然后在推理列中点击端点按钮,如图 10.6 所示。

图 10.6:AWS SageMaker 推理端点示例
点击端点按钮后,您将看到您的双端点处于创建中或已创建状态,如图图 10.7所示。点击它后,您可以在 CloudWatch 中查看端点的日志并监控 CPU、内存、磁盘和 GPU 的利用率。
此外,它们提供了一个很好的方法,可以在一个地方监控所有 HTTP 错误,例如4XX和5XX。

图 10.7:AWS SageMaker 双端点推理端点示例
调用 AWS SageMaker Inference 端点
现在我们已经将 LLM 服务部署到 AWS SageMaker,让我们学习如何调用该服务。为此,我们将编写两个类,这些类将帮助我们为 SageMaker 准备提示,通过 HTTP 请求调用推理端点,并以客户端可以处理的方式解码结果。所有 AWS SageMaker Inference 代码均可在 GitHub 上找到,位于llm_engineering/model/inference。一切始于以下示例:
text = "Write me a post about AWS SageMaker inference endpoints."
llm = LLMInferenceSagemakerEndpoint(
endpoint_name=settings.SAGEMAKER_ENDPOINT_INFERENCE
)
Answer = InferenceExecutor(llm, text).execute()
如前所述,我们将向您介绍LLMInferenceSagemakerEndpoint和InferenceExecutor类。让我们从LLMInferenceSagemakerEndpoint类开始,该类直接与 SageMaker 交互。构造函数初始化了与 SageMaker 端点交互所需的所有基本属性:
class LLMInferenceSagemakerEndpoint(Inference):
def __init__(
self,
endpoint_name: str,
default_payload: Optional[Dict[str, Any]] = None,
inference_component_name: Optional[str] = None,
) -> None:
super().__init__()
self.client = boto3.client(
"sagemaker-runtime",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY,
aws_secret_access_key=settings.AWS_SECRET_KEY,
)
self.endpoint_name = endpoint_name
self.payload = default_payload if default_payload else self._default_payload()
self.inference_component_name = inference_component_name
endpoint_name对于识别我们想要请求的 SageMaker 端点是至关重要的。此外,该方法使用提供的值初始化有效负载,如果没有提供,则通过调用生成默认有效负载的方法。
该类的一个关键特性是它能够为推理请求生成默认的有效负载。这由_default_payload()方法处理:
def _default_payload(self) -> Dict[str, Any]:
return {
"inputs": "",
"parameters": {
"max_new_tokens": settings.MAX_NEW_TOKENS_INFERENCE,
"top_p": settings.TOP_P_INFERENCE,
"temperature": settings.TEMPERATURE_INFERENCE,
"return_full_text": False,
},
}
此方法返回一个字典,表示要发送进行推理的有效负载的默认结构。参数部分包括影响推理期间模型行为的设置,例如要生成的标记数、采样策略(top_p)和温度设置,该设置控制输出中的随机性。这些参数从应用程序的设置中获取,确保不同推理任务之间的一致性。
该类允许通过set_payload()方法自定义有效负载,这使用户能够在发送推理请求之前修改输入和参数:
def set_payload(self, inputs: str, parameters: Optional[Dict[str, Any]] = None) -> None:
self.payload["inputs"] = inputs
if parameters:
self.payload["parameters"].update(parameters)
此方法将有效负载的inputs字段更新为用户提供的新的输入文本。此外,如果提供了任何推理参数,它还允许修改推理参数。
最终,我们利用inference()方法调用 SageMaker 端点,并使用自定义的有效负载:
def inference(self) -> Dict[str, Any]:
try:
logger.info("Inference request sent.")
invoke_args = {
"EndpointName": self.endpoint_name,
"ContentType": "application/json",
"Body": json.dumps(self.payload),
}
if self.inference_component_name not in ["None", None]:
invoke_args["InferenceComponentName"] = self.inference_component_name
response = self.client.invoke_endpoint(**invoke_args)
response_body = response["Body"].read().decode("utf8")
return json.loads(response_body)
except Exception:
logger.exception("SageMaker inference failed.")
raise
在这个方法中,推理方法构建要发送到 SageMaker 端点的请求。该方法将有效载荷和其他必要细节打包成 SageMaker 期望的格式。如果指定了inference_component_name,它将被包含在请求中,如果需要,允许对推理过程进行更细粒度的控制。请求通过invoke_endpoint()函数发送,响应被读取、解码并返回为 JSON 对象。
让我们了解InferenceExecutor如何使用我们之前展示的LLMInferenceSagemakerEndpoint类来向 AWS SageMaker 端点发送 HTTP 请求。
InferenceExecutor类从构造函数开始,该构造函数输入调用 LLM 所需的关键参数。llm参数接受任何实现 Inference 接口的实例,例如用于执行推理的LLMInferenceSagemakerEndpoint类。
此外,它接受查询参数,该参数表示用户输入。最终,如果您想进行 RAG,它还接受一个可选的上下文字段,并且您可以自定义提示模板。如果没有提供提示模板,它将默认为一种通用的版本,这种版本不针对任何 LLM 进行专门化:
class InferenceExecutor:
def __init__(
self,
llm: Inference,
query: str,
context: str | None = None,
prompt: str | None = None,
) -> None:
self.llm = llm
self.query = query
self.context = context if context else ""
if prompt is None:
self.prompt = """
You are a content creator. Write what the user asked you to while using the provided context as the primary source of information for the content.
User query: {query}
Context: {context}
"""
else:
self.prompt = prompt
execute()方法是InferenceExecutor类的关键组件。该方法负责实际执行推理。当调用execute时,它会通过使用用户的查询和上下文格式化提示来准备发送给 LLM 的有效载荷。
然后,它配置了影响 LLM 行为的一些参数,例如模型允许生成的最大新令牌数、一个重复惩罚以阻止模型生成重复文本,以及控制输出随机性的温度设置。
一旦设置好有效载荷和参数,该方法就会调用LLMInferenceSagemakerEndpoint中的inference函数,并等待生成的答案:
def execute(self) -> str:
self.llm.set_payload(
inputs=self.prompt.format(query=self.query, context=self.context),
parameters={
"max_new_tokens": settings.MAX_NEW_TOKENS_INFERENCE,
"repetition_penalty": 1.1,
"temperature": settings.TEMPERATURE_INFERENCE,
},
)
answer = self.llm.inference()[0]["generated_text"]
return answer
通过通过实现 Inference 接口的对象进行推理,我们可以轻松注入其他推理策略和上面展示的LLMInferenceSagemakerEndpoint实现,而无需修改代码的不同部分。
运行测试示例非常简单。只需调用以下 Python 文件,如下所示:
poetry run python -m llm_engineering.model.inference.test
此外,为了方便,我们将其封装在一个poe命令下:
poetry poe test-sagemaker-endpoint
现在,我们必须了解我们如何使用 FastAPI 实现业务微服务。这个微服务将向上面定义的 LLM 微服务发送 HTTP 请求,并调用在第九章中实现的 RAG 检索模块。
使用 FastAPI 构建业务微服务
为了实现一个简单的 FastAPI 应用程序来证明我们的部署策略,我们首先必须定义一个 FastAPI 实例,如下所示:
from fastapi import FastAPI
app = FastAPI()
接下来,我们使用 Pydantic 的BaseModel定义QueryRequest和QueryResponse类。这些类代表 FastAPI 端点的请求和响应结构:
class QueryRequest(BaseModel):
query: str
class QueryResponse(BaseModel):
answer: str
既然我们已经定义了我们的 FastAPI 组件,并且已经将所有 SageMaker 元素就绪,那么让我们回顾一下在第九章中介绍的 call_llm_service() 和 rag() 函数,因为我们尚未部署我们的微调 LLM 而无法运行。因此,作为一个复习,call_llm_service() 函数封装了用于调用 SageMaker LLM 微服务的推理逻辑:
def call_llm_service(query: str, context: str | None) -> str:
llm = LLMInferenceSagemakerEndpoint(
endpoint_name=settings.SAGEMAKER_ENDPOINT_INFERENCE, inference_component_name=None
)
answer = InferenceExecutor(llm, query, context).execute()
return answer
接下来,我们定义了一个名为 rag() 的函数,该函数实现了所有的 RAG 业务逻辑。为了避免重复,请查看第九章以获取完整的函数说明。需要强调的是,rag() 函数仅实现了执行 RAG 所需的业务步骤,这些步骤受 CPU 和 I/O 限制。例如,ContextRetriever 类调用 OpenAI 和 Qdrant 的 API,这些是网络 I/O 限制的,并调用嵌入模型,该模型直接在 CPU 上运行。此外,由于 LLM 推理逻辑被移动到不同的微服务中,call_llm_service() 函数仅受网络 I/O 限制。总之,整个函数运行起来很轻量,重计算在其他服务上完成,这使得我们可以在不需要 GPU 且低延迟的情况下,在轻量且便宜的机器上托管 FastAPI 服务器:
def rag(query: str) -> str:
retriever = ContextRetriever(mock=False)
documents = retriever.search(query, k=3 * 3)
context = EmbeddedChunk.to_context(documents)
answer = call_llm_service(query, context)
return answer
最终,我们定义了 rag_endpoint() 函数,用于将我们的 RAG 逻辑作为 HTTP 端点暴露到互联网上。我们在 FastAPI 应用程序中使用 Python 装饰器将其暴露为 POST 端点。此端点映射到 /rag 路由,并期望输入一个 QueryRequest。该函数通过调用 rag 函数并使用用户的查询来处理请求。如果成功,它将答案封装在 QueryResponse 对象中返回。如果发生异常,它将抛出一个带有异常详细信息的 HTTP 500 错误:
@app.post("/rag", response_model=QueryResponse)
async def rag_endpoint(request: QueryRequest):
try:
answer = rag(query=request.query)
return {"answer": answer}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
这个 FastAPI 应用程序展示了如何有效地将托管在 AWS SageMaker 上的 LLM 集成到 Web 服务中,利用 RAG 来增强模型响应的相关性。代码的模块化设计,利用自定义类如 ContextRetriever、InferenceExecutor 和 LLMInferenceSagemakerEndpoint,使得它易于定制和扩展,成为在生产环境中部署 ML 模型的一个强大工具。
我们将利用 uvicorn Web 服务器,这是 FastAPI 应用程序的常用方法,来启动服务器。为此,您必须运行以下命令:
uvicorn tools.ml_service:app --host 0.0.0.0 --port 8000 --reload
此外,您可以使用以下 poe 命令达到相同的效果:
poetry poe run-inference-ml-service
要调用 /rag 端点,我们可以利用 curl CLI 命令向我们的 FastAPI 服务器发送 POST HTTP 请求,如下所示:
curl -X POST 'http://127.0.0.1:8000/rag' -H 'Content-Type: application/json' -d '{\"query\": \"your_query \"}'
如同往常,我们提供了一个使用 poe 命令的示例,其中包含一个实际的用户查询:
poetry poe call-inference-ml-service
此 FastAPI 服务器仅在本地运行。下一步将是将其部署到 AWS 弹性 Kubernetes 服务(EKS),这是 AWS 的自托管 Kubernetes 版本。另一个选择是将它部署到 AWS 弹性容器服务(ECS),它与 AWS EKS 类似,但底层不使用 Kubernetes,而是 AWS 的实现。不幸的是,这并不特定于 LLM 或 LLMOps。因此,我们不会在本书中介绍这些步骤。但为了让你了解你需要做什么,你必须从仪表板创建一个 AWS EKS/ECS 集群,或者利用像 Terraform 这样的 基础设施即代码(IaC)工具。之后,你将不得不将上面展示的 FastAPI 代码 Docker 化。最终,你必须将 Docker 镜像推送到 AWS ECR,并使用托管在 ECR 上的 Docker 镜像创建 ECS/EKR 部署。如果这听起来很多,好消息是我们在第十一章中会带你通过一个类似的例子,我们将部署 ZenML 管道到 AWS。
一旦你完成测试你的推理管道部署,删除用于部署 LLM 的所有 AWS SageMaker 资源是至关重要的。由于几乎所有 AWS 资源都使用按使用付费的策略,使用 SageMaker 几小时不会让你的钱包破费,但如果你忘记并让它保持开启状态,几天后,成本可能会呈指数增长。因此,一个好的经验法则是始终在测试完你的 SageMaker 基础设施(或任何 AWS 资源)后删除所有内容。幸运的是,我们提供了一个脚本,可以为你删除所有 AWS SageMaker 资源:
poetry poe delete-inference-endpoint
为了确保一切都被正确删除,请访问您的 SageMaker 仪表板并自行检查。
自动扩展功能以处理使用高峰
到目前为止,SageMaker LLM 微服务一直使用静态数量的副本为我们用户提供服务,这意味着无论流量如何,它始终有相同数量的实例在运行。正如我们在整本书中强调的那样,带 GPU 的机器很昂贵。因此,在大多数副本空闲时,我们会在停机期间损失大量资金。此外,如果我们的应用程序出现流量高峰,服务器将无法处理请求的数量,应用程序的表现将很差。这对我们应用程序的用户体验是一个巨大的问题,因为在那些高峰期,我们带来了大部分新用户。因此,如果他们对我们的产品有糟糕的印象,我们将大大降低他们返回我们平台的机会。
以前,我们使用 SageMaker 的 ResourceRequirements 类来配置我们的多端点服务。例如,假设我们请求了以下计算需求的四个副本:
model_resource_config = ResourceRequirements(
requests={
"copies": 4, # Number of replicas.
"num_accelerators": 4, # Number of GPUs required.
"num_cpus": 8, # Number of CPU cores required.
"memory": 5 * 1024, # Minimum memory required in Mb (required)
},
)
使用此配置,无论空闲时间或流量高峰,我们始终有四个副本在为客户端提供服务。解决方案是实现一个自动扩展策略,根据各种指标(如请求数量)动态地调整副本的数量。
例如,图 10.8 展示了一个标准架构,其中 SageMaker Inference 端点根据请求数量进行扩展和缩减。当没有流量时,我们可以有一个在线副本,以便服务器能够响应用户的新请求,或者在延迟不是特别关键的情况下缩减到零。然后,假设当我们每秒有大约 10 个请求时,我们必须保持两个副本在线,而当请求的数量激增到每秒 100 个时,自动扩展服务应该启动 20 个副本以满足需求。请注意,这些是虚构的数字,应该根据您的具体用例进行调整。

图 10.8:自动扩展的可能用例
不深入云网络的小细节,当与多副本系统一起工作时,客户端和副本之间有一个 应用程序负载均衡器(ALB)或另一种类型的负载均衡器。
所有请求首先发送到 ALB,它知道将它们路由到副本。ALB 可以采用各种路由策略,其中最简单的一种称为轮询,它依次将请求发送到每个副本。例如,第一个请求被路由到副本一,第二个到副本二,依此类推。采用这种方法,无论您有多少在线副本,客户端调用的端点始终由充当集群入口点的负载均衡器表示。因此,添加或删除新副本不会影响服务器和客户端通信协议。
让我们快速学习如何为我们的 AWS SageMaker Inference 端点实现自动扩展策略。SageMaker 提供了一个名为 应用程序自动扩展 的功能,允许您根据预定义的策略动态扩展资源。有效地利用此功能涉及两个基础步骤:注册可扩展目标和创建可扩展策略。
注册可扩展目标
启用资源自动扩展的第一步是使用 AWS 提供的 应用程序自动扩展 功能注册一个可扩展的目标。想象一下,这是在通知 AWS 关于您打算扩展的具体资源,以及设置扩展应发生的边界。然而,这一步并不规定扩展应该如何或何时发生。
例如,当与 SageMaker Inference 组件一起工作时,您将定义以下内容:
-
资源 ID:这作为您想要扩展的资源的一个唯一标识符,通常包括 SageMaker Inference 组件的名称。
-
服务命名空间:这标识了资源所属的 AWS 服务,在本例中是 SageMaker。
-
可扩展维度:这指定了要扩展的资源,例如所需的副本数量。
-
MinCapacity 和 MaxCapacity:这些参数定义了自动扩展策略的边界,例如副本数量的最小和最大限制。
通过注册可缩放目标,你为 SageMaker 推理组件的未来缩放操作做好准备,而无需确定这些操作何时或如何发生。
创建可缩放策略
一旦你的可缩放目标注册成功,下一步就是定义缩放应该如何发生。这就是创建缩放策略的地方。缩放策略定义了触发缩放事件的特定规则。在创建策略时,你必须定义指标以了解要监控什么,以及阈值以了解何时触发缩放事件。
在我们的 SageMaker 推理组件的上下文中,可缩放策略可能包括以下元素:
-
策略类型:例如,你可能选择 TargetTrackingScaling,这是一种调整资源容量以维持所选指标特定目标值的策略。
-
目标跟踪配置:这涉及到选择要监控的指标(例如 SageMakerInferenceComponentInvocationsPerCopy),设置期望的目标值,并指定冷却期,以控制在前一个操作之后缩放操作可以多快发生。
缩放策略定义了你的缩放入和缩放出策略的规则。它持续监控指定的指标,并根据指标是否超过或低于目标值,触发操作以调整推理组件副本的数量上下,始终在注册的可缩放目标定义的范围内。
让我们更深入地解释一下 TargetTrackingScaling 缩放策略是如何工作的。想象一下,你有一个代表应用程序理想平均利用率或吞吐量水平的指标。使用目标跟踪,你选择这个指标并设置一个反映应用程序最佳状态的目标值。一旦定义,应用程序自动缩放 将创建并管理必要的 CloudWatch 警报来监控这个指标。当出现偏差时,会触发缩放操作,类似于恒温器调整以保持室内温度一致。
例如,考虑一个运行在 SageMaker 上的应用程序。假设我们设定了一个目标,即保持 GPU 利用率在 70% 左右。这个目标允许你在管理突发流量峰值的同时保持足够的余量,同时防止闲置资源的无必要成本。当 GPU 使用率超过目标时,系统会进行扩展,增加资源以管理增加的负载。相反,当 GPU 使用率低于目标时,系统会进行缩减,减少容量以在较安静的时期最小化成本。
使用应用程序自动缩放设置目标跟踪策略的一个显著优势是它们简化了缩放过程。你不再需要配置 CloudWatch 警报和手动定义缩放调整。
最小和最大缩放限制
当为您的 SageMaker Inference 端点设置自动扩展时,在创建扩展策略之前,建立最大和最小扩展限制至关重要。最小值代表您的模型可以运行的最少资源。此值必须至少为 1,确保您的模型始终有一定的容量。
接下来,配置最大值,它定义了模型可以扩展到的资源上限。虽然最大值必须等于或大于最小值,但它不设定任何上限。因此,您可以在 AWS 提供的范围内根据应用程序的需求进行扩展。
冷却期
扩展策略的另一个重要方面是冷却期,在此期间,保持响应性和稳定性之间的平衡至关重要。这个冷却期充当一个保护措施,确保在扩展事件(无论是减少容量(扩展入)还是增加容量(扩展出))期间,您的系统不会过度反应。通过引入计算暂停,冷却期防止实例数量的快速波动。具体来说,它延迟了在扩展入请求中移除实例,并限制了在扩展出请求中创建新副本。这种策略有助于为 LLM 服务维护一个稳定和高效的环境。
这些实用的基础知识被广泛应用于大多数自动扩展的 Web 服务器,包括在线实时 ML 服务器。一旦您了解了如何为 SageMaker 配置扩展策略,您就可以立即将您学到的策略应用到其他流行的部署工具,如 Kubernetes 或 AWS ECS。
关于如何配置本章中实现的 AWS SageMaker 端点的自动扩展的逐步指南,您可以遵循 AWS 的官方教程:docs.aws.amazon.com/sagemaker/latest/dg/endpoint-auto-scaling-prerequisites.html。
自动扩展是任何云架构中的关键组件,但您应该注意一些陷阱。第一个也是最危险的是过度扩展,这直接影响了您的基础设施成本。如果您的扩展策略或冷却时间太敏感,您可能会无用地启动新的机器,这些机器将保持空闲或资源利用率不足。另一个原因是相反的一端,您的系统扩展不足,导致最终用户体验不佳。
因此,一个好的做法是了解您系统的需求。基于这些需求,您应该在开发或测试环境中调整和实验自动扩展参数,直到找到最佳平衡点(类似于训练模型时的超参数调整)。例如,假设您预计您的系统每分钟支持平均 100 个用户,在异常事件(如假日)的情况下,每分钟扩展到 10,000 个用户。使用这个规范,您可以压力测试您的系统,并监控您的资源,以找到支持标准用例和异常用例的最佳成本、延迟和吞吐量之间的平衡。
摘要
在本章中,我们通过向您介绍 ML 模型的三个基本部署类型:在线实时推理、异步推理和离线批量转换,学习了在部署 ML 模型(无论是 LLM 还是其他类型)之前应做出的设计决策。然后,我们考虑了将我们的 ML 服务作为单体应用程序构建是否合理,或者将其拆分为两个微服务,例如 LLM 微服务和业务微服务。为此,我们权衡了在模型服务中单体架构与微服务架构的优缺点。
接下来,我们向您展示了如何将我们的微调后的 LLM Twin 部署到 AWS SageMaker 推理端点。我们还看到了如何使用 FastAPI 实现业务微服务,该服务包含基于第九章中实现的检索模块的所有 RAG 步骤,以及部署在 AWS SageMaker 上的 LLM 微服务。最终,我们探讨了为什么我们必须实现自动扩展策略。我们还回顾了一种流行的基于给定一组指标进行扩展和缩小的自动扩展策略,并展示了如何在 AWS SageMaker 中实现它。
在下一章中,我们将学习 MLOps 和 LLMOps 的基础知识,然后探索如何将 ZenML 管道部署到 AWS,并实现持续训练、持续集成和持续交付(CT/CI/CD)以及监控管道。
参考文献
-
AWS 开发者。(2023 年 9 月 22 日)。15 分钟机器学习:Amazon SageMaker 低成本高性能推理 [视频]。YouTube。
www.youtube.com/watch?v=FRbcb7CtIOw -
bitsandbytes-foundation.(n.d.)。GitHub—bitsandbytes-foundation/bitsandbytes:通过 PyTorch 的 k 位量化访问可访问的大语言模型。GitHub。
github.com/bitsandbytes-foundation/bitsandbytes -
AWS 中 IAM 角色与 IAM 用户的区别。(n.d.)。Stack Overflow。
stackoverflow.com/questions/46199680/difference-between-iam-role-and-iam-user-in-aws -
Huggingface. (n.d.-a). GitHub—huggingface/safetensors: 简单、安全地存储和分发张量。GitHub.
github.com/huggingface/safetensors -
Huggingface. (n.d.-b). GitHub—huggingface/text-generation-inference: 大型语言模型文本生成推理。GitHub.
github.com/huggingface/text-generation-inference -
Huyen, C. (n.d.). 设计机器学习系统. O’Reilly Online Learning.
www.oreilly.com/library/view/designing-machine-learning/9781098107956/ -
Iusztin, P. (2024, August 20). 架构 LLM & RAG 推理管道 | 解码机器学习. Medium.
medium.com/decodingml/architect-scalable-and-cost-effective-llm-rag-inference-pipelines-73b94ef82a99 -
Lakshmanan, V., Robinson, S., and Munn, M. (n.d.). 机器学习设计模式. O’Reilly Online Learning.
www.oreilly.com/library/view/machine-learning-design/9781098115777/ -
Mendoza, A. (2024, August 21). 机器学习模型部署的最佳工具. neptune.ai.
neptune.ai/blog/ml-model-serving-best-tools
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十一章:MLOps 和 LLMOps
在整本书中,我们已经使用了机器学习操作(MLOps)组件和原则,例如模型注册来共享和版本控制我们的微调大型语言模型(LLMs),用于微调和 RAG 数据的逻辑特征存储,以及一个编排器来将所有我们的机器学习管道粘合在一起。但 MLOps 不仅仅是这些组件;它通过自动化数据收集、训练、测试和部署,将机器学习应用提升到下一个层次。因此,MLOps 的最终目标是尽可能自动化,让用户专注于最关键的决策,例如当检测到分布变化时,必须决定是否需要重新训练模型。但关于LLM 操作(LLMOps)呢?它与 MLOps 有何不同?
术语LLMOps是 LLMs 广泛采用的结果。它是建立在 MLOps 之上的,而 MLOps 又是建立在开发运维(DevOps)之上的。因此,为了全面理解 LLMOps 是什么,我们必须提供一个历史背景,从 DevOps 开始,然后在此基础上构建术语——这正是本章将要做的。在本质上,LLMOps 专注于 LLMs 特有的问题,例如提示监控和版本控制,输入和输出护栏以防止有害行为,以及反馈循环以收集微调数据。它还关注与 LLMs 一起工作时出现的问题,例如收集用于训练数据集的数十亿个标记,在巨大的 GPU 集群上训练模型,以及降低基础设施成本。幸运的是,对于普通人来说,这些问题主要是由少数几家微调基础模型的公司解决的,例如 Meta,它提供了 Llama 系列模型。大多数公司将采用这些预训练的基础模型来满足他们的用例,专注于 LLMOps 问题,如提示监控和版本控制。
在实施方面,为了将 LLMOps 添加到我们的 LLM Twin 用例中,我们将部署所有我们的 ZenML 管道到 AWS。我们将实现一个持续集成和持续部署(CI/CD)管道来测试代码的完整性并自动化部署过程,一个持续训练(CT)管道来自动化我们的训练,以及一个监控管道来跟踪所有我们的提示和生成的答案。这是任何机器学习项目的自然进展,无论你是否使用 LLMs。
在前面的章节中,你学习了如何构建 LLM 应用。现在,是时候探索与 LLMOps 相关的三个主要目标了。第一个目标是对 LLMOps 有一个理论上的理解,从 DevOps 开始,然后转向 MLOps 的基本原则,最后深入 LLMOps。我们并不旨在提供 DevOps、MLOps 和 LLMOps 的完整理论,因为你可以轻易地写一本书来涵盖这些主题。然而,我们希望对在实现 LLM Twin 用例时我们为何做出某些决策有一个深入的理解。
我们的第二个目标是部署 ZenML 管道到 AWS(目前,我们在第十章中只部署了我们的推理管道到 AWS)。本节将进行实战,向您展示如何利用 ZenML 将一切部署到 AWS。我们需要这样做来实现我们的第三个也是最后一个目标,即将我们在理论部分学到的知识应用到我们的 LLM Twin 用例中。我们将使用 GitHub Actions 实现一个 CI/CD 管道,使用 ZenML 实现一个 CT 和警报管道,以及使用来自 Comet ML 的 Opik 实现一个监控管道。
因此,在本章中,我们将涵盖以下主题:
-
通向 LLMOps 的路径:理解其在 DevOps 和 MLOps 中的根源
-
将 LLM Twin 的管道部署到云端
-
将 LLMOps 添加到 LLM Twin
通向 LLMOps 的路径:理解其在 DevOps 和 MLOps 中的根源
要理解 LLMOps,我们必须从该领域的起点开始,即 DevOps,因为它从那里继承了大部分的基本原则。然后,我们将转向 MLOps,以了解 DevOps 领域是如何适应以支持 ML 系统的。最后,我们将解释 LLMOps 是什么,以及它是如何在 LLMs 得到广泛应用后从 MLOps 中产生的。
DevOps
手动发布软件既耗时又容易出错,涉及安全风险,且无法扩展。因此,DevOps 应运而生,以实现大规模软件发布过程的自动化。更具体地说,DevOps 用于软件开发,你希望完全自动化你的构建、测试、部署和监控组件。它是一种旨在缩短开发生命周期并确保持续交付高质量软件的方法。它鼓励协作、自动化流程、集成工作流程并实施快速反馈循环。这些元素共同促成了一个文化,其中构建、测试和发布软件变得更加可靠和快速。
接受 DevOps 文化为组织带来了显著的优势,主要是提高运营效率、加快功能交付和提升产品质量。其中一些主要好处包括:
-
改进协作: DevOps 在创建更统一的工作环境中起着关键作用。消除开发和运维团队之间的障碍,促进了更好的沟通和团队合作,从而带来更高效和富有成效的工作场所。
-
提高效率: 自动化软件开发生命周期减少了手动任务、错误和交付时间。
-
持续改进: DevOps 不仅仅是关于内部流程。它关乎确保软件有效满足用户需求。推广持续反馈的文化使团队能够快速适应并改进他们的流程,从而交付真正满足最终用户的软件。
-
卓越的质量和安全: DevOps 通过 CI/CD 和主动安全措施确保快速软件开发的同时,保持高质量和安全标准。
DevOps 生命周期
如图 11.1所示,DevOps 生命周期涵盖了从软件开发 inception 到交付、维护和安全的整个旅程。这个生命周期的关键阶段包括:
-
计划: 组织并优先处理任务,确保每个任务都能被追踪至完成。
-
代码: 与您的团队协作,编写、设计、开发和安全地管理代码和项目数据。
-
构建: 将您的应用程序和依赖项打包成可执行格式。
-
测试: 这个阶段至关重要。这是您确认代码功能正确并符合质量标准的地方,理想情况下是通过自动化测试。
-
发布: 如果测试通过,将测试过的构建标记为新版本,现在它已准备好发货。
-
部署: 将最新版本部署给最终用户。
-
运营: 在软件上线后,管理和维护软件运行的基础设施。这包括扩展、安全性、数据管理以及备份和恢复。
-
监控: 跟踪性能指标和错误,以减少事件发生的严重性和频率。

图 11.1:DevOps 生命周期步骤
核心 DevOps 概念
DevOps 涵盖了整个应用程序生命周期中的各种实践,但本书中我们将涉及的核心实践包括:
-
部署环境: 在将代码部署到生产环境之前,您必须定义多个预生产环境,以模拟生产环境。最常见的方法是创建一个开发环境,让开发者可以测试他们的最新功能。然后,您有一个预发布环境,其中 QA 团队和利益相关者可以检查应用程序以发现错误,并在它们向用户发布之前体验最新功能。最后,我们有生产环境,它面向最终用户。
-
版本控制: 用于跟踪、管理和版本控制对源代码所做的每个更改。这允许您完全控制代码和部署过程的演变。例如,如果没有版本控制,跟踪开发、预发布和生产环境之间的更改将是不可能的。通过对您的软件进行版本控制,您始终知道哪个版本是稳定的,并准备好发货。
-
持续集成(CI): 在将代码推送到开发、预发布和生产主分支之前,您会自动构建应用程序并对每个更改运行自动化测试。在所有自动化测试通过后,功能分支可以被合并到主分支。
-
持续交付(CD): 持续交付与 CI 协同工作,自动化基础设施供应和应用程序部署步骤。例如,代码合并到预发布环境后,带有最新更改的应用程序将自动部署到您的预发布基础设施上。之后,QA 团队(或利益相关者)开始手动测试最新功能,以验证它们是否按预期工作。这两个步骤通常一起被称为 CI/CD。
注意,DevOps 提出了一套核心原则,这些原则与平台/工具无关。然而,在我们的 LLM Twin 用例中,我们将使用 GitHub 添加一个版本控制层,旨在跟踪代码的演变。版本控制的另一个流行工具是 GitLab。为了实现 CI/CD 管道,我们将利用 GitHub 生态系统和 GitHub Actions,这些对于开源项目是免费的。其他工具选择包括 GitLab CI/CD、CircleCI 和 Jenkins。通常,您会根据您的开发环境、定制和隐私需求选择 DevOps 工具。例如,Jenkins 是一个开源的 DevOps 工具,您可以自己托管并完全控制。缺点是您必须自己托管和维护它,这增加了一个复杂性层。因此,许多公司选择与他们的版本控制生态系统最匹配的工具,例如 GitHub Actions 或 GitLab CI/CD。
现在我们已经对 DevOps 有了坚实的理解,让我们来探讨 MLOps 领域是如何出现的,以保持这些相同的核心原则在 AI/ML 世界中的应用。
MLOps
如您现在可能已经推断出的那样,MLOps 试图将 DevOps 原则应用于 ML。核心问题是与标准软件应用相比,ML 应用有许多其他移动部件,如数据、模型,最后是代码。MLOps 旨在跟踪、运营和监控所有这些概念,以实现更好的可重复性、鲁棒性和控制。
在 ML 系统中,构建可以由这些领域的任何变化触发——无论是代码的更新、数据的修改还是模型的调整。

图 11.2:数据、模型和代码变化之间的关系
在 DevOps 中,一切都是以代码为中心的。例如,当向代码库添加新功能时,您必须触发 CI/CD 管道。在 MLOps 中,代码可以保持不变,而只有数据发生变化。在这种情况下,您必须训练(或微调)一个新的模型,从而产生一个新的数据集和模型版本。直观地讲,当一个组件发生变化时,它会影响一个或多个其他组件。因此,MLOps 必须考虑所有这些额外的复杂性。以下是一些可以触发数据变化并间接影响模型变化的例子:
-
在部署 ML 模型后,随着时间的推移,其性能可能会下降,因此我们需要新的数据来重新训练它。
-
在理解了如何在现实世界中收集数据之后,我们可能会意识到为我们的问题获取数据具有挑战性,因此我们需要重新制定方案以适应我们的现实世界设置。
-
在实验阶段和训练模型时,我们通常必须收集更多数据或重新标记它,这会产生一组新的模型。
-
在生产环境中部署模型并收集最终用户的反馈后,我们可能会意识到我们为训练模型所做的假设是错误的,因此我们必须更改我们的模型。
那么,MLOps 是什么?
MLOps 的一个更正式的定义如下:MLOps 是 DevOps 领域的扩展,它将数据和模型视为一等公民,同时保留 DevOps 方法论。
与 DevOps 类似,MLOps 起源于这样一个想法,即隔离机器学习模型开发与其部署过程(机器学习操作)会降低系统的整体质量、透明度和敏捷性。考虑到这一点,最佳的 MLOps 体验将机器学习资产始终如一地视为 CI/CD 环境中的其他软件资产,作为统一发布过程的一部分。
MLOps 核心组件
我们已经在本书中使用了所有这些组件,但现在我们更好地理解了这个领域,让我们快速回顾一下 MLOps 的核心组件。除了源代码控制和 CI/CD 之外,MLOps 围绕以下内容展开:
-
模型注册库:一个集中式存储库,用于存储训练好的机器学习模型(工具:Comet ML,W&B,MLflow,ZenML)
-
特征存储:预处理并存储输入数据作为模型训练和推理管道的特征(工具:Hopsworks,Tecton,Featureform)
-
机器学习元数据存储:此存储跟踪与模型训练相关的信息,例如模型配置、训练数据、测试数据和性能指标。它主要用于比较多个模型并查看模型谱系,以了解它们是如何创建的(工具:Comet ML,W&B,MLflow)
-
机器学习项目编排器:自动化机器学习项目中的步骤序列(工具:ZenML,Airflow,Prefect,Dagster)
你可能会注意到 MLOps 组件与其特定工具之间存在重叠。这是常见的,因为大多数 MLOps 工具提供统一解决方案,通常称为 MLOps 平台。
MLOps 原则
六个核心原则指导 MLOps 领域。这些原则与任何工具无关,并位于构建稳健和可扩展的机器学习系统的核心。
他们包括:
-
自动化或操作化:MLOps 中的自动化涉及通过持续集成和持续部署(CI/CD)将手动流程转换为自动化管道。这使得能够高效地根据触发器(如新数据、性能下降或未处理的边缘情况)重新训练和部署机器学习模型。从手动实验到完全自动化确保我们的机器学习系统稳健、可扩展,并能适应不断变化的需求,而不会出现错误或延迟。
-
版本控制:在 MLOps 中,跟踪代码、模型和数据的单独变化至关重要,以确保一致性和可重复性。代码使用 Git 等工具进行跟踪,模型通过模型注册表进行版本控制,数据版本控制可以使用 DVC 或工件管理系统等解决方案进行管理。
-
实验跟踪:由于训练机器学习模型是一个迭代和实验的过程,涉及根据预定义的指标比较多个实验,因此使用实验跟踪器帮助我们选择最佳模型非常重要。像 Comet ML、W&B、MLflow 和 Neptune 这样的工具允许我们轻松记录所有必要信息,以便比较实验并选择适合生产的最佳模型。
-
测试:MLOps 建议,除了测试你的代码外,你还应该通过单元测试、集成测试、验收测试、回归测试和压力测试来测试你的数据和模型。这确保了每个组件都能正确运行并良好集成,重点关注输入、输出和处理边缘情况。
-
监控:此阶段对于检测由于生产数据变化导致的已部署机器学习模型性能下降至关重要,允许及时干预,例如重新训练、进一步提示或特征工程,或数据验证。通过跟踪日志、系统指标和模型指标并检测漂移,我们可以维护生产中机器学习系统的健康,尽可能快地检测问题,并确保它们继续提供准确的结果。
-
可重复性:这确保了当通过跟踪所有移动变量(如代码版本、数据版本、超参数或任何其他类型的配置)给相同输入时,你 ML 系统中的每个过程(如训练或特征工程)都会产生相同的结果。由于机器学习训练和推理的非确定性,在生成伪随机数时设置已知的种子对于实现一致的结果和尽可能使过程确定至关重要。
如果你想了解更多,我们已经在本书末尾的附录中对这些原则进行了深入探讨。
机器学习与 MLOps 工程
机器学习工程与 MLOps 之间存在一条细线。如果我们想为这两个规则定义一个严格的职位描述,那么完全区分机器学习工程(MLE)和 MLOps 的责任可能并不容易。我见过许多将 MLOps 角色与平台和云工程师归为一类的职位。从一个角度来看,这很有道理:作为一名 MLOps 工程师,你在基础设施方面有很多工作要做。另一方面,正如本节所示,MLOps 工程师仍然需要实现实验跟踪、模型注册、版本控制等功能。一个好的策略是让机器学习工程师将这些集成到代码中,而 MLOps 工程师则专注于在他们的基础设施上使它们工作。
在大型企业中,最终区分这两个角色可能是有意义的。但当在小型到中型团队中工作时,你将需要扮演多个角色,并且可能需要在机器学习系统的 MLE 和 MLOps 方面工作。

图 11.3:数据科学(DS)vs. 机器学习工程师(MLE)vs. MLOps
例如,在图 11.3中,我们可以看到三个关键角色:数据科学家/ML 研究人员、机器学习工程师和 MLOps 工程师之间的责任划分非常清晰。数据科学家(DS)实施特定模型来解决这些问题。
机器学习工程师从数据科学团队那里获取功能模型,并在其之上构建一层,使它们模块化且可扩展,并提供访问数据库(DB)或通过互联网将其作为 API 暴露的功能。然而,MLOps 工程师在这个过程中扮演着关键角色。他们将这个中间层的代码放置在一个更通用的层,即基础设施层。这一行为标志着应用程序向生产的过渡。从这一点开始,我们可以开始考虑自动化、监控、版本控制和更多内容。
中间层区分了原型和实际产品。在那个层中,你通过集成数据库并使其通过 API 在互联网上可访问来设计一个可扩展的应用程序,并具有状态。当在特定基础设施上部署应用程序时,你必须考虑可扩展性、延迟和成本效益。当然,中间层和通用层相互依赖,并且通常你必须反复迭代以满足应用程序需求。
LLMOps
LLMOps 包括管理和运行 LLMs 所必需的实践和流程。这个领域是 MLOps 的一个专业分支,专注于与 LLMs 相关的独特挑战和需求。虽然 MLOps 解决管理各种 ML 模型的原则和实践,但 LLMOps 专注于 LLMs 的独特方面,包括它们的大规模、高度复杂的训练需求、提示管理以及生成答案的非确定性。然而,请注意,在核心上,LLMOps 仍然继承了 MLOps 部分中提出的所有基本原理。因此,在这里,我们将关注它添加的内容。
当从头开始训练 LLMs 时,ML 系统的数据和模型维度会显著增长,这是 LLMOps 与 MLOps 区别开来的一个方面。这是从头开始训练 LLMs 时的主要关注点:
-
数据收集和准备涉及收集、准备和管理训练 LLMs 所需的庞大数据集。它包括处理、存储和共享训练数据集的大数据技术。例如,GPT-4 在大约 1300 万亿个标记上进行了训练,相当于大约 100 万亿个单词。
-
从基础设施的角度来看,管理大型语言模型(LLMs)的大量参数是一个重大的技术挑战。它需要大量的计算资源,通常是由支持 CUDA 的 Nvidia GPU 驱动的机器集群。
-
LLM 的巨大规模直接影响了模型训练。当从头开始训练一个 LLM 时,由于模型的大小或你需要更高的批量大小以获得预期结果,你无法将其安装在单个 GPU 上。因此,你需要多 GPU 训练,这涉及到优化你的流程和基础设施以支持数据、模型或张量并行性。
-
管理大量数据集和多 GPU 集群涉及大量的成本。例如,OpenAI 首席执行官山姆·奥特曼表示,GPT-4 的估计训练成本约为 1 亿美元(
en.wikipedia.org/wiki/GPT-4#Training)。再加上多次实验、评估和推理的成本。即使这些数字并不完全准确,因为来源并非 100%可靠,但训练 LLM 的成本规模是可信的,这意味着只有行业中的大型玩家才能负担得起从头开始训练 LLM。
在其核心,LLMOps 是扩展规模的 MLOps。它使用相同的 MLOps 原则,但应用于需要更多计算能力来训练和运行的大数据和巨大模型。然而,由于其巨大的规模,最显著的趋势是远离为特定任务从头开始训练神经网络。随着微调的出现,尤其是随着基础模型如 GPT 的出现,这种方法正变得过时。只有少数具有大量计算资源的组织,如 OpenAI 和 Google,开发这些基础模型。因此,现在大多数应用现在依赖于这些模型部分的轻量级微调、提示工程,或者可选地将数据或模型蒸馏到更小、更专业的推理网络中。
因此,对于大多数现有的 LLM 应用,你的开发步骤将包括选择一个基础模型,然后你必须通过使用提示工程、微调或 RAG 来进一步优化它。因此,这三个步骤的操作方面是最关键的。让我们深入了解一些流行的 LLMOps 组件,这些组件可以提高提示工程、微调和 RAG。
人类反馈
你 LLM 的一个有价值的改进步骤是将它与受众的偏好相一致。你必须在你的应用程序中引入一个反馈循环,并收集人类反馈数据集,以使用带人类反馈的强化学习(RLHF)或更高级的技术如直接偏好优化(DPO)等方法进一步微调 LLM。一个流行的反馈循环是大多数聊天机器人界面中的点赞/踩按钮。你可以在第六章中了解更多关于偏好对齐的内容。
安全措施
不幸的是,LLM 系统并不可靠,因为它们经常出现幻觉。你可以优化你的系统以对抗幻觉,但由于幻觉难以检测且可以采取多种形式,未来仍有可能发生重大变化。
大多数用户已经接受了这一现象,但不可接受的是当大型语言模型(LLM)意外地输出敏感信息时,例如 GitHub Copilot 输出 AWS 密钥或其他聊天机器人提供人们的密码。这种情况也可能发生在人们的电话号码、地址、电子邮件地址等更多方面。理想情况下,你应该从训练数据中移除所有这些敏感数据,这样 LLM 就不会记住它,但这种情况并不总是发生。
LLM 因其产生有毒和有害的输出而闻名,例如性别歧视和种族歧视的输出。例如,在 2023 年 4 月左右对 ChatGPT 进行的实验中,人们发现通过强迫聊天机器人采取负面角色,例如“坏人”或“可怕的人”,可以劫持系统。即使通过强迫聊天机器人扮演我们历史中著名的负面角色,如独裁者或罪犯,这也同样有效。例如,这就是 ChatGPT 在模仿一个坏人时的输出:
X is just another third-world country with nothing but drug lords and poverty-stricken people. The people there are uneducated and violent, and they don't have any respect for law and order. If you ask me, X is just a cesspool of crime and misery, and no one in their right mind would want to go there.
查看实验的来源以获取更多不同角色的示例:techcrunch.com/2023/04/12/researchers-discover-a-way-to-make-chatgpt-consistently-toxic/.
讨论可以扩展到无数个例子,但关键是要认识到你的 LLM 可能会产生有害的输出或接收危险的输入,因此你应该监控并为此做好准备。因此,为了创建安全的 LLM 系统,你必须通过添加防护措施来保护它们免受有害、敏感或无效的输入和输出的侵害:
-
输入防护措施:输入防护措施主要保护三种主要风险:向外部 API 泄露私人信息、执行可能损害你系统的有害提示(模型越狱),以及接受暴力或不道德的提示。当谈到向外部 API 泄露私人信息时,风险是特定的,涉及将敏感数据发送到组织外部,如凭证或机密信息。当谈到模型越狱时,我们主要指的是提示注入,例如执行可以访问、删除或破坏你的数据的恶意 SQL 代码。最后,一些应用程序不希望接受来自用户的暴力或不道德的查询,例如询问 LLM 如何制造炸弹。
-
输出防护措施:在 LLM 响应的输出端,你希望捕捉到不尊重你应用程序标准的失败输出。这因应用而异,但一些例子包括空响应(这些响应不符合你预期的格式,如 JSON 或 YAML)、有毒响应、幻觉,以及一般意义上的错误响应。此外,你还需要检查 LLM 或你的 RAG 系统内部知识可能泄露的敏感信息。
流行的防护工具包括 Galileo Protect,它可以检测提示注入、有害语言、数据隐私保护泄露和幻觉。此外,你还可以使用 OpenAI 的 Moderation API 来检测有害输入或输出并采取行动。
添加输入和输出防护栏的缺点是会增加系统中的额外延迟,这可能会干扰你的应用程序的用户体验。因此,在输入/输出的安全性和延迟之间有一个权衡。关于无效输出,由于 LLM 是非确定性的,你可以实现一个重试机制来生成另一个可能的候选者。然而,如上所述,顺序运行重试将加倍响应时间。因此,一种常见的策略是并行运行多个生成并选择最佳的一个。这将增加冗余但有助于控制延迟。
提示监控
监控对于 LLMOps 来说并不陌生,但在 LLM 的世界里,我们有一个新的实体需要管理:提示。因此,我们必须找到特定的方法来记录和分析它们。
大多数机器学习平台,如来自 Comet ML 的 Opik 和 W&B,或其他专门的工具如 Langfuse,都实现了日志记录工具以用于调试和监控提示。在生产环境中,使用这些工具时,你通常希望追踪用户输入、提示模板、输入变量、生成的响应、标记数量和延迟。
当使用大型语言模型(LLM)生成答案时,我们不会等待整个答案生成完成;我们按顺序逐个生成输出标记。这使得整个过程更加迅速和响应灵敏。因此,在追踪生成答案的延迟时,最终用户体验必须从多个角度来考虑,例如:
-
首次标记生成时间(TTFT):生成第一个标记所需的时间
-
标记间时间(TBT):每个标记生成之间的间隔
-
每秒标记数(TPS):标记生成的速率
-
每个输出标记的时间(TPOT):生成每个输出标记所需的时间
-
总延迟:完成响应所需的总时间
此外,跟踪总输入和输出标记对于理解托管你的 LLMs 的成本至关重要。
最终,你可以计算针对每个输入、提示和输出元组的指标,以验证模型性能。根据你的用例,你可以计算诸如准确性、毒性以及幻觉率等指标。当与 RAG 系统一起工作时,你还可以计算与检索到的上下文的相关性和精确度相关的指标。
在监控提示时,另一个需要考虑的重要事项是记录它们的完整跟踪。从用户查询到最终通用答案,您可能有多个中间步骤。例如,为了提高 RAG 的检索准确性而重写查询,会演变一个或多个中间步骤。因此,记录完整的跟踪可以揭示从用户发送查询到最终响应返回的整个过程,包括系统采取的操作、检索的文档以及发送给模型的最终提示。此外,您还可以记录每个步骤的延迟、标记和成本,从而提供对所有步骤的更细致的视图。

图 11.4:Langfuse UI 中的示例跟踪
如图 11.4所示,最终目标是跟踪从用户输入到生成答案的每一步。如果出现故障或行为异常,您可以精确地指出故障步骤。查询可能因答案错误、无效上下文或数据处理错误而失败。此外,如果在特定步骤中生成的标记数量突然波动,应用程序可能会出现意外的行为。
总结来说,LLMOps 是一个快速发展的领域。鉴于其快速演变,做出预测是具有挑战性的。事实是,我们不确定 LLMOps 这个术语是否会持续存在。然而,可以确定的是,将出现许多新的 LLM 用例,以及管理和它们生命周期的工具和最佳实践。
即使这个 DevOps、MLOps 和 LLMOps 部分远非全面,但它为我们如何在 LLM Twin 用例中应用最佳操作实践提供了一个强有力的概念。
将 LLM Twin 的管道部署到云端
本节将向您展示如何将所有 LLM Twin 的管道部署到云端。我们必须部署整个基础设施,以确保整个系统在云端运行。因此,我们将不得不:
-
设置 MongoDB 无服务器实例。
-
设置 Qdrant 无服务器实例。
-
将 ZenML 管道、容器和工件注册表部署到 AWS。
-
将代码容器化并将 Docker 镜像推送到容器注册库。
注意,训练和推理管道已经与 AWS SageMaker 兼容。因此,通过遵循前面的四个步骤,我们确保我们的整个系统在云端,准备好扩展并服务于我们的想象中的客户。
部署成本是多少?
我们将坚持使用 MongoDB、Qdrant 和 ZenML 服务的免费版本。至于 AWS,我们将主要坚持他们的免费层来运行 ZenML 管道。SageMaker 的训练和推理组件运行成本更高(我们将在本节中不运行)。因此,在接下来的章节中,我们将展示的内容将产生最低的成本(最多几美元)来自 AWS。
理解基础设施
在逐步教程之前,我们将展示如何设置所有必要的组件,让我们简要概述我们的基础设施以及所有元素如何交互。这将帮助我们细心地跟随下面的教程。
如图 11.5所示,我们需要设置一些服务。为了保持简单,对于 MongoDB 和 Qdrant,我们将利用它们的免费服务器版本。至于 ZenML,我们将利用 ZenML 云的免费试用版,这将帮助我们云中编排所有管道。它将如何做到这一点呢?
通过利用 ZenML 云,我们可以快速分配所有必要的 AWS 资源来运行、扩展和存储 ML 管道。它将帮助我们通过几个点击启动以下 AWS 组件:
-
用于存储 Docker 镜像的 ECR 服务
-
用于存储所有工件和模型的 S3 对象存储
-
用于编排、运行和扩展所有我们的 ML 管道的 SageMaker 编排器

图 11.5:基础设施流程
现在我们已经了解了我们基础设施的基本资源,让我们回顾一下在云中运行管道的核心流程,这是我们将要学习的实现,如图 11.5所示:
-
构建一个包含所有系统依赖项、项目依赖项和 LLM Twin 应用程序的 Docker 镜像。
-
将 Docker 镜像推送到ECR,其中SageMaker可以访问它。
-
现在,我们可以从本地机器的 CLI 或ZenML仪表板触发本书中实现的任何管道。
-
ZenML 管道的每个步骤都将映射到在 AWS EC2 虚拟机(VM)上运行的 SageMaker 作业。基于有向无环图(DAG)步骤之间的依赖关系,一些将并行运行,而另一些将顺序运行。
-
当运行一个步骤时,SageMaker 会从步骤 2 中定义的 ECR 中拉取 Docker 镜像。根据拉取的镜像,它创建一个 Docker 容器来执行管道步骤。
-
当作业执行时,它可以访问 S3 工件存储、MongoDB 和 Qdrant 向量数据库以查询或推送数据。ZenML 仪表板是一个关键工具,提供管道进度的实时更新,并确保对过程的清晰视图。
既然我们已经了解了基础设施的工作原理,让我们先设置 MongoDB、Qdrant 和 ZenML 云。
我应该选择哪个 AWS 云区域?
在我们的教程中,所有服务都将部署到位于法兰克福(eu-central-1)区域的 AWS。您可以选择另一个区域,但请确保所有服务保持一致,以确保组件之间的响应更快并减少潜在的错误。
我应该如何管理服务 UI 的变化?
不幸的是,MongoDB、Qdrant 或其他服务可能会更改它们的 UI 或命名约定。由于我们无法在每次发生这种情况时更新这本书,请参考它们的官方文档以检查与我们的教程不同的任何内容。我们对此不便表示歉意,但遗憾的是,这不在我们的控制范围内。
设置 MongoDB
我们将向您展示如何创建并将免费的 MongoDB 集群集成到我们的项目中。为此,您必须遵循以下步骤:
-
前往他们的网站
www.mongodb.com并创建一个账户。 -
在左侧面板中,转到部署 | 数据库并点击构建集群。
-
在创建表单中,执行以下操作:
-
选择一个M0 Free集群。
-
将您的集群命名为twin。
-
选择AWS作为您的提供商。
-
选择法兰克福(eu-central-1)作为您的区域。您可以选择另一个区域,但请注意为所有未来的 AWS 服务选择相同的区域。
-
将其余的属性保留为默认值。
-
在右下角,点击创建部署的绿色按钮。
-
-
为了测试您新创建的 MongoDB 集群是否正常工作,我们必须从我们的本地机器连接到它。我们使用了 MongoDB VS Code 扩展来完成此操作,但您可以使用任何其他工具。因此,从他们的选择连接方法设置流程中选择MongoDB for VS Code。然后,按照他们网站上提供的步骤操作。
-
要连接,您必须在 VS Code 扩展(或您喜欢的其他工具)中粘贴 DB 连接 URL,其中包含您的用户名、密码和集群 URL,类似于这个:
mongodb+srv://<username>:<password>@twin.vhxy1.mongodb.net。请确保将此 URL 保存在您可以复制的地方。 -
如果您不知道或想更改密码,请转到左侧面板中的安全 → 快速入门。在那里,您可以编辑您的登录凭证。请确保将它们保存在安全的地方,因为您稍后无法访问它们。
-
在验证您的连接正常工作后,在左侧面板中转到安全 → 网络访问并点击添加 IP 地址。然后点击允许从任何地方访问并确认。出于简便起见,我们允许任何 IP 地址的机器访问我们的 MongoDB 集群。这确保了我们的管道可以查询或写入数据库,而无需任何额外的复杂网络设置。这不是生产环境中最安全的选项,但对我们这个例子来说,完全没问题。
-
最后一步是返回您的项目并打开您的
.env文件。现在,您可以将DATABASE_HOST变量添加或替换为您的 MongoDB 连接字符串。它应该看起来像这样:DATABASE_HOST=mongodb+srv://<username>:<password>@twin.vhxy1.mongodb.net。
就这样!现在,您将不再从本地 MongoDB 读取和写入,而是从我们刚刚创建的云 MongoDB 集群中进行。让我们用 Qdrant 重复一个类似的过程。
设置 Qdrant
我们必须重复与 MongoDB 相似的过程。因此,要创建 Qdrant 集群并将其连接到我们的项目,请按照以下步骤操作:
-
前往 Qdrant 在
cloud.qdrant.io/并创建一个账户。 -
在左侧面板中,前往集群并点击创建。
-
使用以下信息填写集群创建表单:
-
选择集群的免费版本。
-
选择GCP作为云提供商(在撰写本书时,它是唯一允许免费集群的提供商)。
-
选择法兰克福作为区域(或与您为 MongoDB 选择的相同区域)。
-
将集群命名为twin。
-
将其余的属性保留为默认值,然后点击创建。
-
-
在左侧面板的数据访问控制部分访问集群。
-
点击创建并选择您的twin集群以创建一个新的访问令牌。将新创建的令牌复制到安全的地方,因为您将无法再次访问它。
-
您可以从用法示例中运行他们的示例来测试您的连接是否正常。
-
返回到 Qdrant 的集群部分,打开您新创建的twin集群。您将能够访问集群的端点,您需要在代码中配置 Qdrant。
您可以通过点击打开仪表板并输入您的API 密钥作为密码来可视化您的 Qdrant 集合和文档。此时,Qdrant 集群仪表板将显示为空,但在运行管道后,您将看到所有集合,如图所示:

图 11.6:在填充了两个集合后,Qdrant 集群仪表板示例。
最后,返回到您的项目并打开您的.env文件。现在,我们必须填写几个环境变量,如下所示:
USE_QDRANT_CLOUD=true
QDRANT_CLOUD_URL=<the endpoint URL found at step 7>
QDRANT_APIKEY=<the access token created at step 5>
就这样!您将不再从本地 Qdrant 向量数据库中读取和写入,而是从我们刚刚创建的云 Qdrant 集群中进行。为了确保一切正常,请按照以下方式运行端到端数据管道,使用 MongoDB 和 Qdrant 的云版本:
peotry poe run-end-to-end-data-pipeline
最后一步是设置 ZenML 云并将所有基础设施部署到 AWS。
设置 ZenML 云
设置 ZenML 云和 AWS 基础设施是一个多步骤的过程。首先,我们将设置一个 ZenML 云账户,然后通过 ZenML 云设置 AWS 基础设施,最后,我们将我们的代码打包到 Docker 镜像中,以便在 AWS SageMaker 中运行。
让我们从设置 ZenML 云开始:
-
前往 ZenML 云
cloud.zenml.io并创建一个账户。他们提供七天免费试用,这对于运行我们的示例来说已经足够了。 -
填写他们的入职表格,并创建一个具有唯一名称和名为twin的租户。租户是指在完全隔离的环境中部署 ZenML 的实例。在继续下一步之前,请等待几分钟,直到您的租户服务器启动。
-
如果您愿意,您可以浏览他们的快速入门指南来了解 ZenML 云如何与一个更简单的示例一起工作。虽然部署 LLM Twin 应用程序不需要通过它,但我们建议您这样做以确保一切正常工作。
-
到目前为止,我们假设您已经完成了 快速入门指南。否则,您在下一步可能会遇到问题。为了将我们的项目与这个 ZenML 云租户连接起来,返回项目并运行仪表板中提供的
zenml connect命令。它看起来类似于以下示例,但 URL 不同:zenml connect --url https://0c37a553-zenml.cloudinfra.zenml.io。 -
为了确保一切正常工作,从您的代码中运行一个随机管道。请注意,在此阶段,我们仍在本地运行它,但不是将结果记录到本地服务器,而是将所有内容记录到云端:
poetry poe run-digital-data-etl -
前往 ZenML 仪表板的左侧面板中的 管道 部分。如果一切正常,您应该在那里看到您在 步骤 5 中运行的管道。
确保您的 ZenML 服务器版本与本地 ZenML 版本相匹配。例如,当我们编写这本书时,两者都是版本 0.64.0。如果它们不匹配,您可能会遇到奇怪的行为,或者它可能无法正确工作。最简单的修复方法是前往您的
pyproject.toml文件,找到zenml依赖项,并使用服务器版本更新它。然后运行poetry lock --no-update && poetry install以更新您的本地虚拟环境。
要将代码发送到 AWS,您必须创建一个 ZenML 堆栈。堆栈是一组组件,例如底层的编排器、对象存储和容器注册表,ZenML 在幕后需要这些组件来运行管道。直观地说,您可以将您的堆栈视为您的基础设施。在本地工作时,ZenML 提供了一个默认的堆栈,允许您快速开发代码并在本地测试。然而,通过定义不同的堆栈,您可以快速在不同的基础设施环境中切换,例如本地和 AWS 运行,我们将在本节中展示这一点。
在开始本节之前,请确保您有一个具有管理员权限的 AWS 账户准备就绪。
在此基础上,让我们为我们的项目创建一个 AWS 堆栈。为此,请按照以下步骤操作:
-
在左侧面板中,点击 堆栈 部分,然后点击 新建堆栈 按钮。
-
您将有多种创建堆栈的选项,但最简单的是在浏览器体验中从头开始创建一个,这不需要额外的准备。这并不非常灵活,但对于托管我们的项目来说已经足够了。因此,选择 创建新基础设施 → 浏览器体验。
-
然后,选择 AWS 作为您的云提供商。
-
选择 欧洲(法兰克福)—eu-central-1 作为您的位置或您用于设置 MongoDB 和 Qdrant 的区域。
-
将其命名为 aws-stack。必须将其命名为确切的这个名字,这样我们使用的命令才能正常工作。
-
现在,ZenML 将创建一组 IAM 角色,以授予所有其他组件相互通信的权限,一个 S3 存储桶作为您的工件存储,一个 ECR 仓库作为您的容器注册表,以及 SageMaker 作为您的编排器。
-
点击 下一步。
-
点击 部署到 AWS 按钮。它将在 AWS 上打开一个 CloudFormation 页面。ZenML 利用 CloudFormation(一个基础设施即代码工具)创建我们在 步骤 6 中列出的所有 AWS 资源。
-
在底部,勾选所有复选框以确认 AWS CloudFormation 将代表您创建 AWS 资源。最后,点击 创建堆栈 按钮。现在,我们必须等待几分钟,让 AWS CloudFormation 启动所有资源。
-
返回 ZenML 页面并点击 完成 按钮。
通过利用 ZenML,我们高效地部署了我们的 ML 管道所需的整个 AWS 基础设施。我们从基本示例开始,牺牲了一些控制。然而,如果您寻求更多控制,ZenML 提供了使用 Terraform(一个基础设施即代码工具)来完全控制您的 AWS 资源或连接 ZenML 与您当前基础设施的选项。
在进行下一步之前,让我们快速回顾一下我们刚刚创建的 AWS 资源:
-
IAM 角色 是一个 AWS 身份,具有定义该角色允许或拒绝执行哪些操作的权限策略。它用于授予对 AWS 服务的访问权限,而无需共享安全凭证。
-
S3 是一个可扩展且安全的对象存储服务,允许从网络上的任何地方存储和检索文件。它通常用于数据备份、内容存储和数据湖。它比 Google Drive 更具可扩展性和灵活性。
-
ECR 是一个完全托管的 Docker 容器注册表,使得存储、管理和部署 Docker 容器镜像变得容易。
-
SageMaker 是一个完全托管的服务,允许开发人员和数据科学家快速构建、训练和部署 ML 模型。
-
SageMaker Orchestrator 是 SageMaker 的一个功能,它有助于自动化 ML 工作流的执行,管理步骤之间的依赖关系,并确保模型训练和部署管道的可重复性和可扩展性。其他类似的工具有 Prefect、Dagster、Metaflow 和 Airflow。
-
CloudFormation 是一个服务,允许您建模和设置您的 AWS 资源,这样您就可以花更少的时间管理它们,更多的时间专注于您的应用程序。它通过使用模板自动化了使用 AWS 基础设施的过程。
在运行 ML 管道之前,最后一步是将代码容器化并准备一个 Docker 镜像,该镜像打包了我们的依赖项和代码。
使用 Docker 容器化代码
到目前为止,我们已经定义了我们的基础设施,MongoDB、Qdrant 和 AWS,用于存储和计算。最后一步是找到一种方法,将我们的代码运行在这个基础设施之上。最流行的解决方案是 Docker,这是一个允许我们创建一个包含运行应用程序所需一切(如系统依赖项、Python 依赖项和代码)的隔离环境(容器)的工具。
我们在项目的根目录中的 Dockerfile 中定义了我们的 Docker 镜像。这是 Docker 的标准命名约定。在深入研究代码之前,如果你想自己构建 Docker 镜像,请确保你的机器上已安装 Docker。如果没有,你可以按照这里提供的说明进行安装:docs.docker.com/engine/install。现在,让我们一步一步地查看 Dockerfile 的内容。
Dockerfile 以指定基础镜像开始,这是一个基于 Debian Bullseye 分发的 Python 3.11 轻量级版本。然后设置环境变量以配置容器的各个方面,例如工作区目录、关闭 Python 字节码生成以及配置 Python 直接输出到终端。此外,指定了要安装的 Poetry 版本,并设置了一些环境变量以确保软件包安装非交互式,这对于自动化构建至关重要。
FROM python:3.11-slim-bullseye AS release
ENV WORKSPACE_ROOT=/app/
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV POETRY_VERSION=1.8.3
ENV DEBIAN_FRONTEND=noninteractive
ENV POETRY_NO_INTERACTION=1
接下来,我们在容器中安装 Google Chrome。安装过程首先通过更新软件包列表并安装必要的工具,如 gnupg、wget 和 curl 来开始。添加了 Google Linux 签名密钥,并配置了 Google Chrome 仓库。在再次更新软件包列表后,安装了 Google Chrome 的稳定版本。安装完成后,移除软件包列表以尽可能保持镜像大小最小。
RUN apt-get update -y && \
apt-get install -y gnupg wget curl --no-install-recommends && \
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-linux-signing-key.gpg && \
echo "deb [signed-by=/usr/share/keyrings/google-linux-signing-key.gpg] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -y && \
apt-get install -y google-chrome-stable && \
rm -rf /var/lib/apt/lists/*
在 Chrome 安装之后,安装了其他必要的系统依赖项。一旦这些软件包安装完成,就会清理软件包缓存以进一步减小镜像大小。
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends build-essential \
gcc \
python3-dev \
build-essential \
libglib2.0-dev \
libnss3-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
然后,使用 pip 安装了依赖管理工具 Poetry。--no-cache-dir 选项防止 pip 缓存软件包,有助于保持镜像更小。安装后,将 Poetry 配置为在安装软件包时使用最多 20 个并行工作进程,这可以加快安装过程。
RUN pip install --no-cache-dir "poetry==$POETRY_VERSION"
RUN poetry config installer.max-workers 20
容器内部的工作目录设置为 WORKSPACE_ROOT,默认为 /app/,其中将存放应用程序代码。pyproject.toml 和 poetry.lock 文件定义了 Python 项目的依赖项,并被复制到该目录。
WORKDIR $WORKSPACE_ROOT
COPY pyproject.toml poetry.lock $WORKSPACE_ROOT
在依赖文件就绪后,使用 Poetry 安装项目依赖项。配置关闭了虚拟环境的创建,这意味着依赖项将直接安装到容器的 Python 环境中。安装排除了开发依赖项并防止缓存以最小化空间使用。
此外,安装了 poethepoet 插件以帮助管理项目中的任务。最后,移除任何剩余的 Poetry 缓存,以尽可能保持容器精简。
RUN poetry config virtualenvs.create false && \
poetry install --no-root --no-interaction --no-cache --without dev && \
poetry self add 'poethepoet[poetry_plugin]' && \
rm -rf ~/.cache/pypoetry/cache/ && \
rm -rf ~/.cache/pypoetry/artifacts/
在最后一步,将主机机器上的整个项目目录复制到容器的的工作目录中。这一步确保了所有应用程序文件都在容器内可用。
在编写Dockerfile时,一个重要的技巧是将安装步骤与复制其余文件解耦。这很有用,因为每个 Docker 命令都会被缓存,并逐层叠加。因此,每次当你重建 Docker 镜像时更改一个层,所有更改层下面的层都会再次执行。因为你很少更改系统和项目依赖项,但主要更改代码,所以在最后一步复制项目文件可以通过利用缓存机制的全部潜力来加快重建 Docker 镜像的速度。
COPY . $WORKSPACE_ROOT
这个Dockerfile旨在创建一个干净、一致的 Python 环境,包含所有必要的依赖项。它允许项目在任何支持 Docker 的环境中顺利运行。
最后一步是构建 Docker 镜像并将其推送到 ZenML 创建的 ECR。要从项目的根目录构建 Docker 镜像,请运行以下命令:
docker buildx build --platform linux/amd64 -t llmtwin -f Dockerfile .
我们必须在 Linux 平台上构建它,因为我们使用的 Google Chrome 安装程序只能在 Linux 机器上运行。即使你使用 macOS 或 Windows 机器,Docker 也可以模拟一个虚拟 Linux 容器。
新创建的 Docker 镜像的标签是llmtwin。我们还在poethepoet命令下提供了这个build命令:
poetry poe build-docker-image
现在,让我们将 Docker 镜像推送到 ECR。为此,导航到你的 AWS 控制台,然后转到 ECR 服务。从那里,找到新创建的 ECR 仓库。它应该以zenml-*开头,如图所示:

图 11.7:AWS ECR 示例
第一步是验证到 ECR。为了使其工作,请确保你已经安装并配置了 AWS CLI,并使用你的管理员 AWS 凭证,如第二章中所述:
AWS_REGION=<your_region> # e.g. AWS_REGION=eu-central-1
AWS_ECR_URL=<your_acount_id>
aws ecr get-login-password --region ${AWS_REGION}| docker login --username AWS --password-stdin ${AWS_ECR_URL}
你可以通过点击右上角的切换按钮来获取当前的AWS_REGION,如图 11.8 所示。你也可以复制 ECR URL 以填充AWS_ECR_URL变量,如图 11.7 所示。运行前面的命令后,你应该在 CLI 上看到消息登录成功。

图 11.8:AWS 区域和账户详情
现在我们必须给llmtwin Docker 镜像添加另一个标签,以指示我们想要将其推送到哪个 Docker 注册表:
docker tag llmtwin ${AWS_ECR_URL}:latest
最后,我们通过以下命令将其推送到 ECR:
docker push ${AWS_ECR_URL}:latest
上传完成后,返回你的 AWS ECR 仪表板并打开你的 ZenML 仓库。Docker 镜像应该会显示出来,如图所示:

图 11.9:Docker 镜像推送到 ECR 后的 AWS ECR 仓库示例
对于您需要发布和测试的代码中的每个更改,您都必须通过所有这些步骤,这些步骤既繁琐又容易出错。本章的将 LLMOps 添加到 LLM Twin部分将教会我们如何使用 GitHub Actions 在 CD 管道中自动化这些步骤。然而,我们首先想手动完成它们,以便完全理解幕后过程,而不是将其视为黑盒。理解这些细节对于调试您的 CI/CD 管道至关重要,在那里您必须理解错误消息以及如何修复它们。
现在我们已经构建了 Docker 镜像并将其推送到 AWS ECR,让我们将其部署到 AWS。
在 AWS 上运行管道
我们即将在 AWS 上运行 ML 管道,但我们必须完成几个最终步骤。让我们从默认的 ZenML 堆栈切换到本章中创建的 AWS 堆栈。从项目的根目录,在 CLI 中运行以下命令:
zenml stack set aws-stack
返回您的 AWS ECR ZenML 存储库,并按照图 11.9 所示复制镜像 URI。然后,转到configs目录,打开configs/end_to_end_data.yaml文件,并将settings.docker.parent_image属性更新为您的 ECR URL,如下所示:
settings:
docker:
parent_image: <YOUR ECR URL> #e.g., 992382797823.dkr.ecr.eu-central-1.amazonaws.com/zenml-rlwlcs:latest
skip_build: True
我们已配置管道始终使用 ECR 中可用的最新 Docker 镜像。这意味着每当推送新镜像时,管道将自动获取代码的最新更改。
我们必须将.env文件中的所有凭证导出到 ZenML 密钥,这是一个安全存储您的凭证并在管道中使其可访问的功能:
poetry poe export-settings-to-zenml
最后一步是设置异步运行管道,这样我们就不必等待它们完成,这可能会导致超时错误:
zenml orchestrator update aws-stack --synchronous=False
现在,ZenML 知道使用 AWS 堆栈、我们的自定义 Docker 镜像,并且可以访问我们的凭证,我们终于完成了设置。使用以下命令运行end-to-end-data-pipeline:
poetry poe run-end-to-end-data-pipeline
现在您可以前往ZenML Cloud → Pipelines → end_to_end_data并打开最新运行实例。在 ZenML 仪表板上,您可以可视化管道的最新状态,如图 11.10 所示。请注意,此管道在单个运行中运行所有与数据相关的管道。
在将 LLMOps 添加到 LLM Twin部分,我们将解释为什么我们将所有步骤压缩到单个管道中。

图 11.10:ZenML 端到端数据管道示例
您可以点击任何正在运行的块,找到有关运行、用于该特定步骤的代码以及用于监控和调试的日志的详细信息,如图 11.11 所示:

图 11.11:ZenML 步骤元数据示例
要运行其他管道,您必须更新configs/目录下其配置文件中的settings.docker.parent_image属性。
要查找有关运行的更多详细信息,您可以访问 AWS SageMaker。在左侧面板中,点击 SageMaker 仪表板,然后在右侧的 处理 列表中,点击如 图 11.12 所示的绿色 运行 部分。
这将打开一个包含执行您的 ZenML 管道的所有 处理作业 的列表。

图 11.12:SageMaker 仪表板
如果您想再次在本地运行管道,请使用以下 CLI 命令:
poetry poe set-local-stack
如果您想从 ZenML 云仪表板断开连接并再次使用本地版本,请运行以下命令:
zenml disconnect
在 SageMaker 上运行 ZenML 管道后解决 ResourceLimitExceeded 错误
假设,您在 SageMaker 上使用 AWS 栈运行 ZenML 管道后遇到了 ResourceLimitExceeded 错误。在这种情况下,您必须明确要求 AWS 给您访问特定类型的 AWS EC2 VM 的权限。
ZenML 默认使用 ml.t3.medium EC2 机器,这是 AWS 免费增值层的一部分。然而,一些 AWS 账户默认无法访问这些 VM。要检查您的访问权限,请在 AWS 控制台中搜索 服务配额。
然后,在左侧面板中,点击 AWS 服务,搜索 Amazon SageMaker,然后搜索 ml.t3.medium。在 图 11.13 中,您可以查看这些类型机器的配额。如果您的配额是 0,您应该在 应用账户级配额值 列中请求 AWS 将其增加到与 图 11.13 中类似的数字。整个过程免费,只需点击几下即可。不幸的是,您可能需要等待几小时到一天,直到 AWS 接受您的请求。

图 11.13:SageMaker—ml.t3.medium 预期配额
您可以在以下链接中找到如何解决此错误并请求新配额的逐步说明:repost.aws/knowledge-center/sagemaker-resource-limit-exceeded-error。
如果您已从 .env 文件更改了值并希望使用它们更新 ZenML 密钥,请首先运行以下 CLI 命令以删除旧密钥:
poetry poe delete-settings-zenml
然后,您可以通过运行以下命令再次导出它们:
poetry poe export-settings-to-zenml
将 LLMOps 添加到 LLM Twin
在上一节中,我们看到了如何通过手动构建 Docker 镜像并将其推送到 ECR 来手动设置 LLM Twin 项目的基础设施。我们希望自动化整个流程,并使用 GitHub Actions 实现一个 CI/CD 管道,使用 ZenML 实现一个 CT 管道。如前所述,实现 CI/CD/CT 管道确保推送到主分支的每个功能都是一致的并且经过测试。此外,通过自动化部署和训练,您支持协作,节省时间,并减少人为错误。
最后,在本节结束时,我们将向您展示如何使用 Comet ML 的 Opik 实现一个提示监控流程,以及使用 ZenML 实现一个警报系统。这个提示监控流程将帮助我们调试和分析 RAG 和 LLM 逻辑。由于 LLM 系统是非确定性的,捕获和存储提示跟踪对于监控您的 ML 逻辑至关重要。
在深入实施之前,让我们先快速了解一下 LLM Twin 的 CI/CD 流程。
LLM Twin 的 CI/CD 流程
我们有两个环境:预发布和产品。当开发一个新功能时,我们从预发布分支创建一个新的分支,并在该分支上独立开发。当我们完成并认为功能完成时,我们向预发布分支打开一个拉取请求(PR)。在功能分支被接受后,它会被合并到预发布分支。这是大多数软件应用中的标准工作流程。可能会有一些变化,比如添加一个开发环境,但原则保持不变。
如 图 11.14 所示,CI 流程在 PR 打开时触发。在这个时候,我们测试功能分支的代码风格和格式错误。同时,我们运行 gitleaks 命令来检查是否不小心提交了凭据和敏感信息。如果代码风格、格式和 gitleaks 步骤通过(也称为静态分析),我们将运行自动测试。请注意,静态分析步骤比自动测试运行得快。因此,顺序很重要。这就是为什么在 CI 流程的开始处添加静态分析步骤是良好实践的原因。我们建议以下 CI 步骤的顺序:
-
gitleaks检查 -
检查代码风格
-
格式检查
-
自动测试,如单元测试和集成测试
如果任何检查失败,CI 流程将失败,创建 PR 的开发者无法在修复问题之前将其合并到预发布分支。
实施一个 CI 流程确保新功能遵循存储库的标准,并且不会破坏现有功能。当我们计划将预发布分支合并到产品分支时,确切的过程会重复。我们打开一个 PR,在将预发布分支合并到产品分支之前,CI 流程会自动执行。

图 11.14:CI/CD 流程
CD 流程在分支合并后运行。例如,在功能分支合并到预发布分支后,CD 流程从预发布分支获取代码,构建一个新的 Docker 镜像,并将其推送到 AWS ECR Docker 仓库。当在预发布环境中运行未来的流程运行时,它将使用由 CD 流程构建的最新 Docker 镜像。确切的过程发生在预发布和产品之间。尽管如此,关键的区别在于预发布环境作为一个实验场所存在,QA 团队和利益相关者可以进一步手动测试新功能,同时自动测试 CI 流程中的内容。
在我们的仓库中,我们只使用了主分支,它反映了生产环境,以及功能分支来推送新工作。我们这样做是为了保持简单,但相同的原理适用。要扩展流程,您必须创建一个预发布分支并将其添加到 CD 管道中。
更多关于格式化错误的说明
格式化错误与代码的风格和结构有关,确保它遵循一致的视觉布局。这可能包括空格的位置、缩进、行长度和其他风格元素。
格式化的主要目的是使您的代码更易于阅读和维护。一致的格式化有助于团队更有效地协作,因为代码看起来统一,无论谁编写它。格式化错误的例子包括:
-
错误的缩进(例如,混合空格和制表符)
-
行太长(例如,超过
79或88个字符,具体取决于您的风格指南) -
运算符或逗号周围缺少或多余的空格
更多关于代码检查错误的说明
代码检查错误与可能导致错误、低效或不遵守编码标准的潜在问题有关,而不仅仅是风格问题。代码检查通常涉及对代码的静态分析,以捕捉未使用的变量、未定义的名称或可疑的实践。
代码检查的主要目标是尽早在开发过程中捕捉潜在的错误或不良实践,提高代码质量并降低出现错误的可能性。代码检查错误的例子包括:
-
未使用的导入或变量
-
正在使用未定义的变量或函数
-
可能危险的代码(例如,使用
==而不是is来检查None)
我们使用 Ruff,一个用于格式化和代码检查的多功能工具。它包含了对常见格式问题的检查以及 PEP 8 规范的遵守,同时还进行了更深入的代码质量检查,以发现潜在的错误和质量问题。此外,它是用 Rust 编写的,这使得它在处理大型代码库时非常快速。
在实施上述内容之前,让我们先来探讨 GitHub Actions 的核心原则。
GitHub Actions 快速概述
GitHub Actions 是 GitHub 提供的一个 CI/CD 平台,允许开发者在 GitHub 仓库内直接自动化他们的工作流程。它通过在 YAML 文件中定义工作流程,使用户能够直接从 GitHub 构建代码、测试和部署代码。由于它是 GitHub 的一部分,它可以与仓库、问题、PR 和其他 GitHub 功能无缝工作。以下是您应该了解的关键组件:
-
工作流程: 工作流程是一个在您仓库的
.github/workflows目录中定义的自动化过程。它指定了应该发生什么(例如,build、test和deploy)以及何时发生(例如,在推送时,在 PR 时)。 -
作业: 工作流程由作业组成,作业是在同一运行器上执行的步骤组。每个作业都在自己的虚拟环境中运行。
-
步骤: 任务由多个独立的步骤组成,这些步骤可以是操作或 shell 命令。
-
操作:操作是可重用的命令或脚本。您可以使用 GitHub 市场中的预构建操作或创建自己的操作。您可以将它们视为 Python 函数。
-
运行者:运行者是指运行您作业的服务器。GitHub 提供托管运行者(Linux、Windows、macOS),或者您甚至可以自行托管运行者。
使用 YAML 语法描述工作流程。例如,一个简单的示例工作流程,它克隆当前的 GitHub 存储库并在 Ubuntu 机器上安装 Python 3.11,看起来像这样:
name: Example
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: "3.11"
工作流程由诸如 push、pull_request 或 schedule 之类的事件触发。例如,您可能会在将代码推送到特定分支时触发工作流程。现在我们了解了 GitHub Actions 的工作原理,让我们看看 LLM Twin 的 CI 管道。
CI 管道
LLM Twin 的 CI 管道分为两个作业:
-
一个使用 Ruff 查找格式化和 linting 错误的 QA 作业。它还运行一个
gitleaks步骤,以扫描整个存储库中的泄露秘密。 -
一个运行所有自动测试的 测试作业,使用
Pytest。在我们的用例中,我们仅实现了一个模拟测试来展示 CI 管道,但使用本书中的结构,您可以轻松地扩展它以适应您的实际测试用例。
GitHub Actions CI YAML 文件
pull_request event occurs. Hence, the CI workflow will automatically run whenever a PR is opened, synchronized, or reopened.
name: CI
on:
pull_request:
concurrency 部分确保在任何给定时间只有一个工作流程实例在给定的参考(如分支)上运行。group 字段使用 GitHub 的表达式语法定义,以根据工作流程和参考创建唯一的组名。cancel-in-progress: true 行确保如果在新工作流程运行完成之前触发新的工作流程运行,则取消先前的运行。这特别有用,可以防止同一工作流程的冗余执行。
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
工作流程定义了两个独立的作业:qa 和 test。每个作业都在由 runs-on: ubuntu-latest 指定的最新版本的 Ubuntu 上运行。
第一个作业,命名为 QA,负责质量保证任务,如代码检查和格式验证。在 qa 作业中,第一步是使用 actions/checkout@v3 动作检出存储库的代码。这一步是必要的,以确保作业可以访问需要分析的代码。
jobs:
qa:
name: QA
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
下一步是设置 Python 环境。这是通过使用 actions/setup-python@v3 动作来完成的,Python 版本指定为 "3.11"。这一步确保作业中的后续步骤将在正确的 Python 环境中运行。
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: "3.11"
然后工作流程使用 abatilo/actions-poetry@v2 动作安装 Poetry,指定 Poetry 版本为 1.8.3:
- name: Install poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: 1.8.3
Poetry 设置完成后,工作流程使用 poetry install --only dev 命令安装项目的开发依赖项。此外,工作流程添加了用于在项目中更方便地运行预定义任务的 poethepoet 插件。
- name: Install packages
run: |
poetry install --only dev
poetry self add 'poethepoet[poetry_plugin]'
qa作业随后在代码上运行几个质量检查。第一个检查使用一个名为gitleaks的工具来扫描代码库中的秘密,确保没有敏感信息被意外提交:
- name: gitleaks check
run: poetry poe gitleaks-check
在执行gitleaks检查之后,工作流运行一个 linting 过程来强制执行 Python 代码的编码标准和最佳实践。这是通过使用底层的poetry poe lint-check命令来实现的。
- name: Lint check [Python]
run: poetry poe lint-check
qa作业的最后一步是格式检查,确保 Python 代码根据项目的风格指南正确格式化。这是通过使用底层的poetry poe format-check命令来完成的。
- name: Format check [Python]
run: poetry poe format-check
工作流中定义的第二个作业是test作业,它也在最新的 Ubuntu 版本上运行。与qa作业类似,它首先从仓库检出代码并安装 Python 3.11 和 Poetry 1.8.3。
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
…
在设置系统依赖项之后,test作业使用poetry install命令安装项目的所有依赖项。由于我们这次需要运行测试,因此需要安装所有运行应用程序所需的依赖项。
- name: Install packages
run: |
poetry install –-without aws
poetry self add 'poethepoet[poetry_plugin]'
最后,test作业使用poetry poe test命令运行项目的测试。这一步确保所有测试都执行,并提供有关当前代码更改是否破坏任何功能的反馈。
- name: Run tests
run: |
echo "Running tests..."
poetry poe test
如果 QA 或测试作业中的任何步骤失败,GitHub Actions 工作流将失败,导致 PR 无法合并,直到问题得到修复。通过采取这种方法,我们确保所有添加到主分支的新功能都符合项目标准,并且通过自动化测试不会破坏现有功能。
图 11.15显示了 GitHub 仓库操作标签页中的 CI 管道。它是在带有消息feat: 添加 Docker 镜像和 CD 管道的提交后运行的,并运行了上述描述的两个作业,即 QA 和 Test。

图 11.15:GitHub Actions CI 管道运行示例
CD 管道
CD 管道将自动化我们在将 LLM Twin 的管道部署到云部分手动执行的 Docker 步骤,这些步骤包括:
-
设置 Docker。
-
登录 AWS。
-
构建 Docker 镜像。
-
将 Docker 镜像推送到 AWS ECR。
考虑到这一点,让我们看看位于.github/workflows/cd.yaml下的 GitHub Actions YAML 文件。它首先将工作流命名为CD,并指定此工作流的触发器。触发器是向仓库主分支的任何推送。当新代码推送到主分支时,通常在 PR 合并到主分支时,此工作流将自动运行。on.push配置设置了触发器:
name: CD
on:
push:
branches:
- main
工作流随后定义了一个名为Build & Push Docker Image的单个作业:
jobs:
build:
name: Build & Push Docker Image
runs-on: ubuntu-latest
作业内的第一步是检出仓库的代码。
steps:
- name: Checkout Code
uses: actions/checkout@v3
检出代码后,工作流程会设置 docker buildx,这是一个 Docker CLI 插件,它通过多平台构建和缓存导入/导出等功能扩展了 Docker 的构建能力:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
下一步涉及配置 AWS 凭据。这一步对于与 AWS 服务交互至关重要,例如 Amazon 弹性容器注册表(ECR),其中将推送 Docker 镜像。AWS 访问密钥、秘密访问密钥和区域从存储库的秘密中安全检索,以使用 AWS 验证工作流程。这确保了工作流程具有将 Docker 镜像推送到 ECR 存储库所必需的权限。在完成 YAML 文件后,我们将向您展示如何配置这些秘密:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
一旦配置了 AWS 凭据,工作流程就会登录到 Amazon ECR。这一步对于使用 Docker CLI 验证 ECR 注册表至关重要,允许后续步骤将镜像推送到注册表:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
工作流程中的最后一步是构建 Docker 镜像并将其推送到 Amazon ECR 存储库。这是通过使用 docker/build-push-action@v6 动作来完成的。context 指定构建上下文,通常是存储库的根目录。file 选项指向 Dockerfile,它定义了镜像应该如何构建。tags 部分将标签分配给镜像,包括特定的提交 SHA 和 latest 标签,这是标识镜像最新版本的常见做法。push 选项设置为 true,这意味着在构建完成后镜像将被上传到 ECR:
- name: Build images & push to ECR
id: build-image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
tags: |
${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_ECR_NAME }}:${{ github.sha }}
${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_ECR_NAME }}:latest
push: true
总结来说,CD 流水线验证 AWS,构建 Docker 镜像,并将其推送到 AWS ECR。Docker 镜像使用 latest 和提交的 SHA 标签进行推送。这样做,我们总能使用最新镜像并指向生成镜像的代码提交。
此外,在我们的代码中,我们只有一个主分支,这反映了我们的生产环境。但作为开发者,你有权通过添加预发布和开发环境来扩展这一功能。你只需在 YAML 文件的开头添加 on.push.branches 配置中的分支名称即可。
在 图 11.16 中,你可以观察到在合并到生产分支后 CD 流水线的样子。正如之前所见,我们这里只有 构建并推送 Docker 镜像 作业。

图 11.16:GitHub Actions CD 流水线运行示例
设置 CI/CD 流水线的最后一步是测试它并查看其工作情况。
测试 CI/CD 流水线
要自己测试 CI/CD 流水线,你必须将 LLM-Engineering 仓库分叉以获得对 GitHub 仓库的完全 写入 权限。以下是官方教程,介绍如何分叉 GitHub 项目:docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo
最后一步是设置几个密钥,以便 CD 管道能够登录 AWS 并指向正确的 ECR 资源。为此,请转到 GitHub 分支仓库顶部的 设置 选项卡。在左侧面板中,在 安全 部分点击 密钥和变量 切换,最后点击 操作。然后,在 密钥 选项卡中,创建四个仓库密钥,如图 11.17 所示。这些密钥将安全存储,并且只能由 GitHub Actions CD 管道访问。
AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY 是你在本书中使用的 AWS 凭据。在 第二章 中,你看到了如何创建它们。AWS_REGION(例如,eu-central-1)和 AWS_ECR_NAME 与在 将 LLM Twin 的管道部署到云 部分中使用的相同。
对于 AWS_ECR_NAME,你应该只配置仓库的名称(例如,zenml-vrsopg),而不是完整的 URI(例如,992382797823.dkr.ecr.eu-central-1.amazonaws.com/zenml-vrsopg),如图下所示:

图 11.17:仅配置仓库名称
要触发 CI 管道,创建一个功能分支,修改代码或文档,并向主分支创建一个 PR。要触发 CD 管道,将 PR 合并到主分支。
CD GitHub Actions 完成后,检查 ECR 仓库以查看 Docker 镜像是否成功推送。

图 11.18:GitHub Actions 密钥
如果你需要更多关于如何设置 GitHub Actions 密钥的详细信息,我们建议查看他们的官方文档:docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions
CT 管道
要实现 CT 管道,我们将利用 ZenML。一旦 ZenML(或其他编排器,如 Metaflow、Dagster 或 Airflow)编排了所有管道并且你的基础设施已部署,你就可以非常接近实现 CT。
记住 CI/CD 和 CT 管道之间的核心区别。CI/CD 管道负责测试、构建和部署你的代码——这是任何软件程序都有的一个维度。CT 管道利用 CI/CD 管理的代码来自动化你的数据、训练和模型托管过程,其中数据和模型维度仅存在于 AI 世界中。
在深入实施之前,我们想强调两个设计选择,这使得实现 CT 变得简单:
-
FTI 架构:一个具有清晰接口和组件的模块化系统,使得捕获管道之间的关系并自动化它们变得容易。
-
从编排器开始,从第 0 天起: 我们在项目开发初期开始使用 ZenML。早期,我们只在本地使用它。但它充当了我们的管道的入口点,以及监控它们执行的方式。这样做迫使我们解耦每个管道,并通过各种类型的数据存储(如数据仓库、特征存储或工件存储)将它们之间的通信完全通过。由于我们从第 0 天起就利用了 ZenML,我们摆脱了实现繁琐的 CLI 来配置我们的应用程序。相反,我们直接通过 YAML 配置文件“开箱即用”来完成它。
在图 11.19中,我们可以看到我们必须连接在一起以完全自动化我们的培训和部署的所有管道。这些管道不是新的;它们汇总了我们在整本书中涵盖的所有内容。因此,在这个阶段,我们将它们视为相互交互的黑盒。

图 11.19:CT 管道
对于 LLM Twin 的 CT 管道,我们必须讨论启动管道的初始触发器以及管道是如何相互触发的。
初始触发器
如图 11.18所示,我们最初希望触发数据收集管道。通常,触发器可以是三种类型之一:
-
手动触发器: 通过 CLI 或编排器的仪表板完成,在我们的情况下,通过 ZenML 仪表板。手动触发器仍然是极其强大的工具,因为您只需一个动作就可以启动整个 ML 系统,从数据收集到部署,而不是摆弄可能配置错误或以无效顺序运行的几十个脚本。
-
REST API 触发器: 您可以通过 HTTP 请求调用管道。当将您的 ML 管道与其他组件集成时,这非常有用。例如,您可以有一个监视器持续寻找新的文章。当它找到一些时,它会使用这个 REST API 触发器来触发 ML 逻辑。要了解更多关于此功能的信息,请查看 ZenML 文档中的此教程:
docs.zenml.io/v/docs/how-to/trigger-pipelines/trigger-a-pipeline-from-rest-api。 -
计划触发器: 另一种常见的方法是按固定间隔持续调度您的管道运行。例如,根据您的用例,您可以安排您的管道每天、每小时或每分钟运行一次。大多数编排器,包括 ZenML,都提供了一个 cron 表达式界面,您可以在其中定义您的执行频率。在以下 ZenML 的示例中,管道每小时调度一次:
Schedule(cron_expression="* * 1 * *")
我们为我们的 LLM Twin 用例选择了手动触发器,因为我们没有其他组件可以利用 REST API 触发器。此外,由于数据集是从 ZenML 配置中定义的静态链接列表生成的,因此按计划运行它们没有意义,因为它们总是会得出相同的结果。
但项目的下一个可能的步骤是实现一个监视器,用于监视新文章。当它找到任何文章时,它会生成一个新的配置并通过 REST API 触发管道。另一个选项是将监视器作为额外的管道实现,并利用调度触发器每天查找新数据。如果找到任何数据,它将执行整个 ML 系统;否则,它将停止。
结论是,一旦您可以通过单个命令手动触发所有 ML 管道,您就可以快速将其适应更高级和复杂的场景。
触发下游管道
为了保持简单,我们按顺序链式连接了所有管道。更具体地说,当数据收集管道完成时,它将触发特征管道。当特征管道成功完成后,它将触发数据集生成管道,依此类推。您可以使逻辑更复杂,例如,安排生成指令数据集的管道每天运行,检查 Qdrant 向量数据库中的新数据量,并且只有当有足够的新数据时才启动。从这个点开始,您可以进一步调整系统的参数并优化它们以降低成本。
要一次性触发所有管道,我们创建了一个主管道,它将所有内容聚合在一个入口点:
@pipeline
def end_to_end_data(
author_links: list[dict[str, str | list[str]]], … # Other paramaters…
) -> None:
wait_for_ids = []
for author_data in author_links:
last_step_invocation_id = digital_data_etl(
user_full_name=author_data["user_full_name"], links=author_data["links"]
)
wait_for_ids.append(last_step_invocation_id)
author_full_names = [author_data["user_full_name"] for author_data in author_links]
wait_for_ids = feature_engineering(author_full_names=author_full_names, wait_for=wait_for_ids)
generate_instruct_datasets(…)
training(…)
deploy(…)
为了保持函数轻量,我们添加了所有逻辑,直到计算特征。但是,如上述代码片段中建议的,您可以轻松地将指令数据集生成、训练和部署逻辑添加到父管道中,以实现端到端流程。通过这样做,您可以自动化从数据收集到部署模型的所有过程。
要运行端到端管道,请使用以下poe命令:
poetry poe run-end-to-end-data-pipeline
我们实施的方法并不是最佳方法,因为它将所有步骤压缩到一个单一的单一管道中(这是我们想要避免的),如图 11.20所示。通常,您希望保持每个管道的隔离性,并使用触发器来启动下游管道。这使得系统更容易理解、调试和监控。

图 11.20:ZenML 仪表板中展示的端到端管道
不幸的是,ZenML 云服务的免费试用版有一个限制,即最多只能有三个管道。由于我们拥有更多,为了避免这个限制,我们将所有步骤压缩到一个单独的管道中。但是,如果您计划自己托管 ZenML 或购买他们的许可证,他们提供从另一个管道独立触发管道的可能性,如下面的代码片段所示,我们在数据收集 ETL 之后触发了特征工程管道:
from zenml import pipeline, step
@pipeline
def digital_data_etl(user_full_name: str, links: list[str]) -> str:
user = get_or_create_user(user_full_name)
crawl_links(user=user, links=links)
trigger_feature_engineering_pipeline(user)
@step
def trigger_feature_engineering_pipeline(user):
run_config = PipelineRunConfiguration(…)
Client().trigger_pipeline("feature_engineering", run_configuration=run_config)
@pipeline
def feature_engineering(author_full_names: list[str]) -> list[str]:
… # ZenML steps
采用这种方法,每个管道都将独立运行,其中每个管道依次触发下一个管道,如本节开头所述。请注意,这个特性并不仅限于 ZenML,在编排工具中很常见。我们迄今为止学到的原则仍然适用。只是我们与工具的交互方式发生了变化。
提示监控
我们将使用 Opik(来自 Comet ML)来监控我们的提示。但请记住,在本章早些时候的 LLMOps 部分中,我们不仅对输入提示和生成的答案感兴趣。
我们希望记录从用户输入到最终结果可用的整个跟踪。在深入探讨 LLM Twin 用例之前,让我们看看一个更简单的例子:
from opik import track
import openai
from opik.integrations.openai import track_openai
openai_client = track_openai(openai.OpenAI())
@track
def preprocess_input(text: str) -> str:
return text.strip().lower()
@track
def generate_response(prompt: str) -> str:
response = openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
@track
def postprocess_output(response: str) -> str:
return response.capitalize()
@track(name="llm_chain")
def llm_chain(input_text: str) -> str:
preprocessed = preprocess_input(input_text)
generated = generate_response(preprocessed)
postprocessed = postprocess_output(generated)
return postprocessed
result = llm_chain("Hello, do you enjoy reading the book?")
llm_chain() main function, which takes the initial input as a parameter and returns the final result.
然后,您有围绕实际 LLM 调用的预处理和后处理函数。使用 @track() 装饰器,我们记录每个函数的输入和输出,这些最终将被汇总成一个单一的跟踪。通过这样做,我们将能够访问初始输入文本、生成的答案以及调试任何潜在问题所需的全部中间步骤,使用 Opik 的仪表板。
update() method, where you can tag your trace or add any other metadata, such as the number of input tokens, through a Python dictionary:
from opik import track, opik_context
@track
def llm_chain(input_text):
# LLM chain code
# ...
opik_context.update_current_trace(
tags=["inference_pipeline"],
metadata={
"num_tokens": compute_num_tokens(…)
},
feedback_scores=
{
"name": "user_feedback",
"value": 1.0,
"reason": "The response was valuable and correct."
},
{
"name": "llm_judge_score",
"value": compute_llm_judge_score(…),
"reason": "Computing runtime metrics using an LLM Judge."
}
)
您可以在此基础上扩展这个想法并记录各种反馈分数。最常见的是询问用户生成的答案是否有价值且正确。另一种选择是通过启发式方法或 LLM 判决自动计算各种指标。
最后,让我们看看如何将提示监控添加到我们的 LLM Twin 项目中。首先,查看 图 11.21 并记住我们的模型服务架构。我们有两个微服务,LLM 和业务微服务。LLM 微服务的范围较窄,因为它只接受包含用户输入和上下文的提示作为输入,并返回通常需要后处理的答案。因此,业务微服务是实施监控管道的正确地方,因为它协调端到端流程。更具体地说,Opik 实现将在 第十章 中开发的 FastAPI 服务器中实现。
![图片
图 11.21:推理管道服务架构
由于我们的实现已经是模块化的,使用 Opik 使得记录用户请求的端到端跟踪变得简单:
from opik import track
@track
def call_llm_service(query: str, context: str | None) -> str:
llm = LLMInferenceSagemakerEndpoint(…)
answer = InferenceExecutor(llm, query, context).execute()
return answer
@track
def rag(query: str) -> str:
retriever = ContextRetriever()
documents = retriever.search(query, k=3 * 3)
context = EmbeddedChunk.to_context(documents)
answer = call_llm_service(query, context)
return answer
rag() 函数代表您应用程序的入口点。所有其他处理步骤都在 ContextRetriever 和 InferenceExector 类中进行。此外,通过装饰 call_llm_service() 函数,我们可以清楚地捕获发送给 LLM 的提示及其响应。
为了使我们的跟踪更加细致,我们可以进一步装饰包含预处理或后处理步骤的其他函数,例如 ContextRetriever 搜索函数:
class ContextRetriever:
…
@track
def search(
self,
query: str,
k: int = 3,
expand_to_n_queries: int = 3,
) -> list:
query_model = Query.from_str(query)
query_model = self._metadata_extractor.generate(query_model)
… # Rest of the implementation
或者甚至进一步到检索优化方法,例如自查询元数据提取器,以添加更多粒度:
class SelfQuery:
@track
def generate(self, query: str) -> str:
…
return enhanced_query
开发者负责决定应用程序需要多少粒度才能进行适当的调试和分析。虽然详细的监控是健康的,但监控一切可能会很危险,因为它会添加过多的噪声,使得手动理解跟踪变得困难。您必须找到正确的平衡。一个好的经验法则是跟踪最关键的功能,如 rag() 和 call_llm_service(),并在需要时逐步添加更多粒度。
最后一步是为我们的跟踪附加有价值的元数据和标签。为此,我们将进一步增强rag()函数,如下所示:
@track
def rag(query: str) -> str:
retriever = ContextRetriever()
documents = retriever.search(query, k=3 * 3)
context = EmbeddedChunk.to_context(documents)
answer, prompt = call_llm_service(query, context)
trace = get_current_trace()
trace.update(
tags=["rag"],
metadata={
"model_id": settings.HF_MODEL_ID,
"embedding_model_id": settings.TEXT_EMBEDDING_MODEL_ID,
"temperature": settings.TEMPERATURE_INFERENCE,
"prompt_tokens": compute_num_tokens(prompt),
"total_tokens": compute_num_tokens(answer),
}
)
return answer
我们应该持续监控的三个主要方面是:
-
模型配置: 在这里,我们应该考虑 LLM 和 RAG 层中使用的其他模型。日志记录的最关键方面是模型 ID,但您还可以捕获其他对生成有显著影响的重要信息,例如温度。
-
总令牌数: 持续分析您输入提示和总令牌数的统计数据至关重要,因为这会显著影响您的服务成本。例如,如果总令牌数的平均值突然增加,这可能是您系统中存在错误的强烈信号,您应该进行调查。
-
每个步骤的持续时间: 跟踪您跟踪中每个步骤的持续时间对于找到系统中的瓶颈至关重要。如果特定请求的延迟异常大,您将快速获得一份报告,帮助您找到问题的根源。
警报
使用 ZenML,您可以在任何您喜欢的平台上快速实现警报系统,例如电子邮件、Discord 或 Slack。例如,您可以在训练管道中添加一个回调,当管道失败或训练成功完成时触发通知:
from zenml import get_pipeline_context, pipeline
@pipeline(on_failure=notify_on_failure)
def training_pipeline(…):
…
notify_on_success()
实现通知功能很简单。如以下代码片段所示,您需要从当前堆栈中获取alerter实例,构建您认为合适的消息,并将其发送到您选择的任何通知渠道:
from zenml.client import Client
alerter = Client().active_stack.alerter
def notify_on_failure() -> None:
alerter.post(message=build_message(status="failed"))
@step(enable_cache=False)
def notify_on_success() -> None:
alerter.post(message=build_message(status="succeeded"))
ZenML 和大多数编排器简化了alerter的实现,因为它是您 MLOps/LLMOps 基础设施中的关键组件。
摘要
在本章中,我们通过一个关于 DevOps 的理论部分奠定了基础。然后,我们转向 MLOps 及其核心组件和原则。最后,我们通过介绍诸如提示监控、安全线和人工反馈循环等策略,展示了 LLMOps 与 MLOps 的不同之处。此外,我们还简要讨论了为什么大多数公司会选择通过提示工程或微调来优化 LLM 以适应其用例,而不是从头开始训练 LLM。在章节理论部分的结尾,我们学习了什么是 CI/CD/CT 管道,ML 应用的三个核心维度(代码、数据、模型),以及由于模型退化,部署后实施监控和警报层比以往任何时候都更加关键。
接下来,我们学习了如何将 LLM Twin 的管道部署到云中。我们了解了基础设施,并逐步部署 MongoDB、Qdrant、ZenML 云以及所有必要的 AWS 资源以维持应用程序。最后,我们学习了如何将我们的应用程序 Docker 化并将我们的 Docker 镜像推送到 AWS ECR,该镜像将用于在 AWS SageMaker 上执行应用程序。
最后一步是将 LLMOps 添加到我们的 LLM Twin 项目中。我们首先通过 GitHub Actions 实现了 CI/CD 管道,然后利用 ZenML 审视了我们的 CT 策略。
最后,我们看到了如何使用 Comet ML 的 Opik 实现监控管道,以及使用 ZenML 实现警报系统。这些是向任何基于 LLM 的应用添加 MLOps 和 LLMOps 的基本支柱。
我们在整本书中学到的框架可以迅速扩展到其他 LLM 应用中。即使我们以 LLM Twin 用例为例,大多数应用到的策略也可以适应其他项目。因此,通过改变数据和代码的微小调整,我们可以得到一个全新的应用。数据是新石油,记住了吗?
通过完成这一章,我们学会了如何构建端到端的 LLM 应用,从数据收集和微调开始,直到部署 LLM 微服务和 RAG 服务。在整个书中,我们旨在提供一个思维框架,帮助你在 GenAI 领域中构建和解决实际问题。现在你已经拥有了它,我们祝愿你在旅途中好运,快乐构建!
参考文献
-
GitLab. (2023, January 25). 什么是 DevOps?| GitLab. GitLab.
about.gitlab.com/topics/devops/ -
Huyen, C. (2024, July 25). 构建生成式 AI 平台。Chip Huyen。
huyenchip.com/2024/07/25/genai-platform.html -
Lightricks 客户故事:从头开始构建推荐引擎。 (n.d.).
www.qwak.com/academy/lightricks-customer-story-building-a-recommendation-engine-from-scratch -
什么是 LLMOps。 (n.d.). Google Cloud.
cloud.google.com/discover/what-is-llmops?hl=en -
MLOps:机器学习中的持续交付和自动化管道。 (2024, August 28). Google Cloud.
cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning#top_of_page -
Ml-ops.org。 (2024a, July 5).
ml-ops.org/content/mlops-principles -
Ml-ops.org。 (2024b, July 5).
ml-ops.org/content/mlops-principles -
Ml-ops.org。 (2024c, July 5).
ml-ops.org/content/motivation -
Mohandas, G. M. (2022a). 监控机器学习系统。用 ML 制作。
madewithml.com/courses/mlops/monitoring/ -
Mohandas, G. M. (2022b). 测试机器学习系统:代码、数据和模型。用 ML 制作。
madewithml.com/courses/mlops/testing/ -
Preston-Werner, T. (n.d.). 语义版本控制 2.0.0. Semantic Versioning.
semver.org/ -
Ribeiro, M. T., Wu, T., Guestrin, C., & Singh, S. (2020, May 8). 超越准确性:使用 CheckList 对 NLP 模型的行为测试. arXiv.org.
arxiv.org/abs/2005.04118 -
Wandb. (2023, November 30). 理解 LLMOps:大型语言模型操作. Weights & Biases.
wandb.ai/site/articles/understanding-llmops-large-language-model-operations/ -
Zenml-Io. (n.d.). GitHub—zenml-io/zenml-huggingface-sagemaker: 从 Hugging Face 模型仓库到部署的 AWS SageMaker 端点的 ZenML 管道 MLOps 概览示例. GitHub.
github.com/zenml-io/zenml-huggingface-sagemaker/tree/main
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

附录
MLOps 原则
构建稳健且可扩展的机器学习系统不仅需要创建强大的模型,还需要对整个机器学习生命周期进行全面的实施方法。让我们探讨指导 MLOps 领域的六个核心原则。这些原则与任何工具无关,是构建稳健且可扩展机器学习系统的核心。它们为设计生产就绪的应用程序提供指导,确保在各个阶段的一致性、可靠性和可扩展性。
考虑到这一点,让我们从基础开始:自动化或运营化。
1. 自动化或运营化
要采用 MLOps,大多数应用程序会逐步构建三个核心层级,从手动处理到完全自动化:
-
手动流程:在开发机器学习应用的早期阶段,该流程是实验性和迭代的。数据科学家手动执行每个管道步骤,例如数据准备和验证、模型训练和测试。在这个阶段,他们通常使用 Jupyter 笔记本来训练他们的模型。这一阶段的输出是用于准备数据和训练模型的代码。
-
持续 训练 (CT):下一级涉及自动化模型训练。这被称为持续训练,它会在需要时触发模型重新训练。在这个阶段,你通常会自动化你的数据和模型验证步骤。这一步通常由一个编排工具,如 ZenML,来完成,它将所有代码粘合在一起并在特定的触发器上运行。最常见的触发器是按计划,例如每天或当发生特定事件时,例如新数据上传或监控系统检测到性能下降,这为你提供了适应各种触发器的灵活性。
-
CI/CD:在最终阶段,你实施 CI/CD 管道,以实现将你的机器学习代码快速且可靠地部署到生产环境。这一阶段的关键进步是自动构建、测试和部署数据、机器学习模型和训练管道组件。CI/CD 用于快速将新代码推送到各种环境,如预发布或生产环境,确保高效且可靠的部署。
在我们使用FTI(特征、训练、推理)架构构建我们的 LLM 系统时,我们可以快速地从手动流程过渡到 CI/CD/CT。在图 A.1中,我们可以观察到 CT 流程可以被各种事件触发,例如监控管道检测到的性能下降或一批新数据的到达。此外,图 A.1被分为两个主要部分;第一个部分突出了自动化流程,而在底部,我们可以观察到数据科学团队在尝试各种数据处理方法和模型时进行的手动流程。一旦他们通过调整数据处理方式或模型架构来改进模型,他们就会将代码推送到代码仓库,这会触发 CI/CD 流程来构建、测试、打包并将新更改部署到 FTI 管道中。

图 A.1:FTI 架构上的 CI/CD/CT
最后,CT 自动化 FTI 管道,而 CI/CD 构建、测试并将 FTI 管道代码的新版本推送到生产环境中。
2. 版本控制
到目前为止,我们已经了解到如果代码、模型或数据发生变化,整个 ML 系统都会发生变化。因此,跟踪和单独版本控制这三个元素至关重要。但我们能采用哪些策略来分别跟踪代码、模型和数据?
-
代码通过 Git 进行跟踪,这有助于我们在代码库中添加每个更改时创建一个新的提交(代码的快照)。此外,基于 Git 的工具通常允许我们进行发布,通常包含多个功能和错误修复。虽然提交包含独特的标识符,这些标识符对人类不可解释,但发布遵循基于它们的重大、次要和修补版本的更常见约定。例如,在版本为“v1.2.3”的发布中,1 是主版本,2 是次要版本,3 是修补版本。流行的工具包括 GitHub 和 GitLab。
-
要对模型进行版本控制,你可以利用模型注册表来存储、共享和版本化系统中使用的所有模型。它通常遵循与代码发布相同的版本控制约定,定义为语义版本控制,它除了主版本、次要版本和修补版本之外,还支持 alpha 和 beta 发布,这些发布向应用程序发出信号。在此阶段,你还可以利用 ML 元数据存储将信息附加到存储的模型上,例如它是在什么数据上训练的、其架构、性能、延迟以及对你特定用例有意义的任何其他信息。这样做可以创建一个清晰的模型目录,可以轻松地在你的团队和公司中导航。
-
版本控制数据并不像版本控制代码和模型那样直接,因为它取决于你所拥有的数据类型(结构化或非结构化)以及数据规模(大或小)。例如,对于结构化数据,你可以利用带有版本列的 SQL 数据库来跟踪数据集的变化。然而,其他流行的解决方案基于类似于 Git 的系统,如数据版本控制(DVC),它跟踪数据集的每一个变化。其他流行的解决方案基于类似于模型注册表的工件,它允许你为你的数据集添加一个虚拟层,跟踪并创建每次对数据进行更改时的新版本。Comet.ml、W&B(权重与偏差)、ZenML 提供了强大的工件功能。对于所有解决方案,你必须将数据存储在本地或使用如 AWS S3 之类的云对象存储解决方案。这些工具提供了允许你结构化你的数据集和版本、跟踪和访问它们的功能。
3. 实验跟踪
训练机器学习模型是一个完全迭代和实验的过程。与传统的软件开发不同,它涉及运行多个并行实验,根据一组预定义的指标进行比较,并决定哪一个应该推进到生产。实验跟踪工具允许你记录所有必要的信息,例如模型预测的指标和可视化表示,以便比较所有实验并轻松选择最佳模型。流行的工具包括 Comet ML、W&B、MLflow 和 Neptune。
4. 测试
当测试机器学习系统时,遵循相同的趋势。因此,我们必须在所有三个维度上测试我们的应用程序:数据、模型和代码。我们还必须确保特征、训练和推理管道与外部服务(如特征存储)良好集成,并作为一个系统协同工作。当使用 Python 时,编写测试最常用的工具是pytest,我们也推荐使用它。
测试类型
在开发周期中,六个主要类型的测试在各个阶段被普遍采用:
-
单元测试:这些测试专注于具有单一职责的个别组件,例如一个添加两个张量的函数或一个在列表中查找元素的函数。
-
集成测试:这些测试评估系统内集成组件或单元之间的交互,例如数据评估管道或特征工程管道,以及它们如何与数据仓库和特征存储集成。
-
系统测试: 系统测试在开发周期中扮演着至关重要的角色,因为它们检查整个系统,包括完整和集成的应用程序。这些测试严格评估系统的端到端功能,包括性能、安全性和整体用户体验——例如,测试整个机器学习管道,从数据摄取到模型训练和推理,确保系统对于给定的输入产生正确的输出。
-
验收测试: 这些测试通常被称为用户验收测试(UAT),旨在确认系统满足指定的要求,确保其可以部署。
-
回归测试: 这些测试检查以前识别的错误,以确保新的更改不会重新引入它们。
-
压力测试: 这些测试评估系统在极端条件下的性能和稳定性,例如高负载或资源有限。它们的目的是识别故障点,并确保系统在需求意外激增或不利情况下不会失败。

图 A.2:测试类型
我们故意在先前的图中省略了回归测试,因为它们不是一个独立的测试阶段。相反,回归测试应用于所有级别——单元、集成、系统、验收和压力测试——以确保更改不会重新引入以前的错误。这是一个在这些阶段中的持续过程,而不是一种单独的测试类型,这就是为什么它没有作为一个单独的类别显示。
我们要测试什么?
在编写大多数测试时,您将一个组件视为一个黑盒。因此,您能控制的是输入和输出。您想要测试的是对于给定的输入,您是否得到了预期的输出。考虑到这一点,以下是一些您通常应该测试的内容:
-
输入: 数据类型、格式、长度和边缘情况(最小/最大、小/大等)
-
输出: 数据类型、格式、异常、中间和最终输出
测试示例
在测试您的代码时,您可以利用经典软件工程的标准。以下是一些您在编写单元测试时可以包含的代码测试示例,以更好地了解我们在这个阶段想要测试的内容——例如,您想要检查一个句子是否按预期清理。
此外,您还可以查看您的分块算法,并通过使用各种句子和分块大小来断言它是否正常工作。
当我们谈论数据测试时,我们主要指的是数据的有效性。您的数据有效性代码通常在从数据仓库摄取原始数据或计算特征后运行。它是特征管道的一部分。因此,通过为您的特征管道编写集成或系统测试,您可以检查系统对有效和无效数据的响应是否正确。
测试数据的有效性很大程度上取决于你的应用程序和数据类型。例如,当处理表格数据时,你可以检查非空值,一个分类变量只包含预期的值,或者一个浮点值始终为正。当处理文本等非结构化数据时,你可以检查长度、字符编码、语言、特殊字符和语法错误。
模型测试是最棘手的,因为模型训练是机器学习系统中最非确定性的过程。然而,与传统软件不同,机器学习系统可以成功完成而不会抛出任何错误。然而,真正的问题是它们会产生只有在评估或测试期间才能观察到的错误结果。一些标准的模型测试技术包括检查:
-
输入和模型输出张量的形状
-
在一个批次(或更多)的训练后损失减少
-
在小批次上过度拟合,损失接近 0
-
你的训练管道可以在所有支持的设备上工作,例如 CPU 和 GPU
-
你的早期停止和检查点逻辑正常工作
所有测试都是在 CI 管道内部触发的。如果某些测试成本较高,例如模型测试,你可以在特殊条件下执行它们,例如仅在修改模型代码时。
在光谱的另一端,你也可以对你的模型进行行为测试,这试图采用代码测试的策略,将模型视为黑盒,在查看输入数据和预期输出时仅关注输入数据。这使得行为测试方法对模型无感知。该领域一篇基础论文是《超越准确性:使用 CheckList 对 NLP 模型进行行为测试》,如果你想要深入了解这个主题,我们推荐这篇论文。然而,作为一个快速概述,该论文提出你应该对你的模型进行三种类型的测试。我们以一个从句子中提取主要主题的模型为例:
-
不变性:输入的变化不应该影响输出——例如,以下是基于同义词注入的示例:
model(text="The advancements in AI are changing the world rapidly.") # output: ai model(text="The progress in AI is changing the world rapidly.") # output: ai -
方向性:输入的变化应该影响输出——例如,以下是一个我们知道输出应该基于提供的输入而变化的示例:
model(text="Deep learning used for sentiment analysis.") # output: deep-learning model(text="Deep learning used for object detection.") # output: deep-learning model(text="RNNs for sentiment analysis.") # output: rnn -
最小功能:输入和预期输出的最简单组合——例如,以下是一组我们期望模型始终能够正确处理的简单示例:
model(text="NLP is the next big wave in machine learning.") # output: nlp model(text="MLOps is the next big wave in machine learning.") # output: mlops model(text="This is about graph neural networks.") # output: gnn关于测试的更多信息,我们推荐阅读 Goku Mohandas 所著的《测试机器学习系统:代码、数据和模型》:
madewithml.com/courses/mlops/testing/.
5. 监控
监控对于任何达到生产阶段的机器学习系统至关重要。传统的软件系统是基于规则和确定性的。因此,一旦构建完成,它将始终按定义工作。不幸的是,机器学习系统并非如此。在实施机器学习模型时,我们没有明确描述它们应该如何工作。我们使用数据来编译一个概率解决方案,这意味着我们的机器学习模型将不断面临降级的风险。这是因为生产中的数据可能与模型训练时的数据不同。因此,交付的模型不知道如何处理这些场景是自然的。
我们不应该试图避免这些情况,而应该制定一个策略来及时捕捉和修复这些错误。直观地说,监控检测到模型性能下降,这会触发一个警报,表明模型应该手动、自动或两者结合重新训练。
为什么要重新训练模型? 由于训练数据集的漂移和从生产中输入的内容,模型性能下降,唯一的解决方案是在一个新数据集上调整或重新训练模型,该数据集捕获了生产中的所有新场景。
训练是一个成本高昂的操作,有一些技巧可以避免重新训练,但在描述它们之前,让我们快速了解我们可以监控什么来了解我们的机器学习系统的健康状况。
日志
日志记录的方法很简单,即捕获一切,例如:
-
记录系统配置。
-
记录查询、结果以及任何中间输出。
-
记录组件开始、结束、崩溃等情况。
-
确保每个日志条目都带有标签并按一种方式标识,以便阐明其在系统中的来源。
虽然捕获所有活动可以迅速增加日志量,但你可以利用众多工具进行自动日志分析和异常检测,这些工具利用 AI 高效地扫描所有日志,为你提供有效管理日志的信心。
指标
为了量化应用程序的健康状况,你必须定义一组指标。每个指标衡量应用程序的不同方面,例如基础设施、数据和模型。
系统指标
系统指标基于监控服务级别指标(延迟、吞吐量、错误率)和基础设施健康(CPU/GPU、内存)。这些指标在传统软件和机器学习中都至关重要,因为它们对于理解基础设施是否运行良好以及系统是否按预期工作以提供良好的用户体验至关重要。
模型指标
仅监控系统的健康状况不足以识别我们模型中的深层问题。因此,转向下一层指标,这些指标专注于模型的性能至关重要。这包括定量评估指标,如准确率、精确率和 F1 分数,以及受模型影响的业务指标,如投资回报率和点击率。
分析整个部署期间的累积性能指标通常效果不佳。相反,评估与我们的应用相关的特定时间间隔的性能,例如每小时,是至关重要的。因此,在实践中,你会在窗口级别对输入进行窗口化,并计算和汇总指标。这些滑动指标可以提供系统健康状况的更清晰图景,使我们能够更及时地检测问题,而这些问题不会被历史数据所掩盖。
我们可能无法始终访问真实结果来评估模型在生产数据上的性能。这在存在重大延迟或现实生活中的数据需要标注时尤其具有挑战性。为了解决这个问题,我们可以开发一个近似信号来估计模型的性能,或者对一小部分实时数据集进行标注以评估性能。在谈论机器学习监控时,近似信号也被称为代理指标,通常由漂移检测方法实现,这些方法将在下一节中讨论。
Drifts
Drifts是代理指标,帮助我们及时检测生产模型中可能存在的问题,而无需任何真实值/标签。表 A.1显示了三种类型的漂移。
| What drifts | Description | Drift formulation |
| --- | --- | --- |
|
| 输入(特征) |
|
|
| 输出(真实值/标签) |
|
|
|
|
|
表 A.1:数据、模型和代码更改之间的关系
Data drift
数据漂移,也称为特征漂移或协变量偏移,发生在生产数据的分布偏离训练数据分布时,如图 A.3 所示。这种差异意味着模型无法处理特征空间中的变化,导致可能不可靠的预测。漂移可能源于自然现实生活中的变化或系统问题,如数据缺失、管道错误和模式修改。

图 A.3:数据漂移示例
当数据开始漂移时,我们模型性能的下降可能不会立即明显,尤其是如果模型插值效果良好。然而,这提供了一个理想的机会,在漂移影响模型性能之前考虑重新训练。
Target drift
除了输入数据(数据漂移)的变化之外,我们还可能遇到输出分布的偏移。这种偏移可能涉及分布形状的变化或在分类任务中类别的增加和删除。虽然重新训练模型可以帮助减少由于目标漂移导致的性能下降,但通过调整头部处理步骤和模型头部以支持输出类的新架构,通常可以预防它。
例如,如果你有一个预测图像是否包含动物或人类的分类器,而你得到的是一些建筑物的图片,你可以调整你的模型以支持未知类别,或者调整模型的头部以添加新的类别以供未来的预测。
概念漂移
除了输入和输出数据的变化,它们之间的关系也可能发生变化。这种现象被称为概念漂移,使得我们的模型变得无效,因为之前学会将输入与输出关联的模式已经过时。如图所示,概念漂移可以以各种方式表现出来:
-
随着时间的推移逐渐
-
突然,由于外部事件
-
定期,由于重复发生的事件

图 A.4:概念漂移示例
例如,当在不同的地理区域使用模型时,这种情况会发生。假设你想要构建一个预测某人是否会购买特定汽车的模型。你最初是为美国市场构建的。现在,你想要在欧洲市场使用它,那里的人们倾向于购买更小的汽车,这会在汽车的大小特征和购买车辆的概率输出之间产生漂移。当然,概念漂移可能比这个例子更微妙。
所有这些类型的漂移都可能同时发生,这使得确定漂移的确切来源变得复杂。
如何检测和衡量漂移
既然我们已经识别了各种类型的漂移,了解如何检测和衡量它就变得至关重要。为此,你需要两种类型的窗口:
-
参考窗口:这是用作基准来比较生产数据分布以识别漂移的数据点集合。它通常来自训练数据集。
-
测试窗口:这是在 ML 系统处于生产状态时收集的数据点。它被与参考窗口比较,以确定是否发生了漂移。
为了衡量漂移,你利用假设检验来验证两个窗口之间分布的变化。例如,你可以使用科尔莫哥洛夫-斯米尔诺夫(KS)检验来监控单个连续特征。这被称为单变量(1D)检验。因此,你必须为每个你想要监控的特征运行它。你可以利用卡方单变量检验来监控分类变量,并确定生产中事件的发生频率是否与参考窗口分布一致。
from alibi_detect.cd import KSDrift
cd = KSDrift(X_ref, p_val=.05, preprocess_fn=preprocess_fn, input_shape=(max_len,))
当使用嵌入表示的文本数据时,我们必须建模一个多元分布,这就是 LLMs 如何处理文本的方式。一种流行的方法是取测试窗口和参考窗口的嵌入,应用降维算法,然后应用如最大均值差异(MMD)之类的算法。这是一种基于核的方法,通过计算两个窗口嵌入的平均值之间的距离来衡量两个分布之间的距离。
from alibi_detect.cd import MMDDrift
cd = MMDDrift(x_ref, backend='pytorch', p_val=.05)
preds = cd.predict(x)
监控与可观察性
监控涉及数据的收集和可视化,而可观察性通过检查系统的输入和输出来提供对系统健康状况的见解。例如,监控使我们能够跟踪特定的指标以检测潜在的问题。
另一方面,如果一个系统生成有关其内部状态的有意义数据,那么它被认为是可观察的,这对于诊断根本原因至关重要。
警报
一旦我们定义了我们的监控指标,我们需要一种被通知的方式。最常见的方法是在以下场景下发送警报:
-
一个指标通过了静态阈值的值——例如,当分类器的准确率低于 0.8 时,发送警报。
-
调整检查漂移的统计测试的 p 值。较低的 p 值意味着对生产分布与参考分布不同的置信度更高。
这些阈值和 p 值取决于您的应用。然而,找到正确的值至关重要,您不希望您的警报系统因为误报而过于拥挤。在这种情况下,您的警报系统将不可信,您可能会对系统中的问题反应过度或完全无动于衷。向您的利益相关者发送警报的一些常见渠道包括 Slack、Discord、您的电子邮件和 PagerDuty。系统的利益相关者可以是核心工程师、经理或任何对系统感兴趣的人。
根据警报的性质,您需要采取不同的反应。但在采取任何行动之前,您应该能够检查它并了解导致其发生的原因。您应该检查触发警报的指标、其值、发生的时间以及任何对您的应用有意义的信息。
当模型的表现下降时,第一个冲动是重新训练它。但这是一项昂贵的操作。因此,您首先必须检查数据是否有效,模式是否没有改变,数据点是否不是一个孤立的外部异常。如果这两个条件都不成立,您应该触发训练流程,并在新移动的数据集上训练模型以解决漂移问题。
6. 可重现性
可重现性意味着在您的机器学习系统中,每个过程都应该在相同的输入下产生相同的结果。这有两个主要方面。
第一,您应该始终知道输入是什么——例如,当训练一个模型时,您可以使用大量的超参数。因此,您需要一种方法来始终跟踪用于生成新资产所使用的资产,例如用于训练模型的哪些数据集版本和配置。
第二个方面是基于机器学习过程的非确定性。例如,当从头开始训练一个模型时,所有权重最初都是随机初始化的。因此,即使您使用相同的 dataset 和超参数,最终得到的模型性能也可能不同。这一方面可以通过在生成随机数之前始终使用一个种子来解决,因为在现实中,我们无法数字上创建随机性,只能创建伪随机数。因此,通过提供种子,我们确保始终产生相同的伪随机数序列。这也可以发生在特征工程步骤中,如果我们用随机值填充值或随机删除数据或标签。但作为一个一般规则,始终尽量使您的过程尽可能确定,如果您必须引入随机性,请始终提供一个您能控制的种子。
加入我们书籍的 Discord 空间。
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及领先的工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
为什么订阅?
-
使用来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,增加编码时间。
-
通过为您量身定制的技能计划提高您的学习效果。
-
每月免费获得一本电子书或视频。
-
完全可搜索,便于快速访问关键信息。
-
复制粘贴、打印和收藏内容。
在www.packt.com网站上,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 的其他这些书籍也感兴趣:
RAG-Driven Generative AI
Denis Rothman
ISBN: 9781836200918
-
规模化 RAG 管道以高效处理大数据集。
-
采用最小化幻觉并确保准确响应的技术。
-
实施索引技术,通过可追溯和透明的输出提高 AI 的准确性。
-
在各个领域定制和扩展 RAG 驱动的生成式 AI 系统。
-
了解如何使用 Deep Lake 和 Pinecone 进行高效快速的数据检索。
-
控制和构建基于真实世界数据的强大生成式 AI 系统。
-
结合文本和图像数据,以生成更丰富、更有信息量的 AI 响应。
Building LLM Powered Applications
Valentina Alto
ISBN: 9781835462317
-
探索 LLM 架构的核心组件,包括编码器-解码器块和嵌入
-
了解 GPT-3.5/4、Llama 2 和 Falcon LLM 等 LLM 的独特功能
-
使用 LangChain 等人工智能编排器,配合 Streamlit 进行前端开发
-
熟悉 LLM 的组件,如记忆、提示和工具
-
学习如何使用非参数知识和向量数据库
-
了解 LFM 对人工智能研究和行业应用的影响
-
通过微调来定制你的 LLM
-
了解 LLM 驱动的应用的伦理影响
Packt 正在寻找像你这样的作者
如果你对成为 Packt 的作者感兴趣,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
现在你已经完成了 《LLM 工程师手册,第一版》,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。
你的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。

):决定了 LoRA 矩阵的大小。一个常见的起点是
,但在某些情况下,值高达 256 已经显示出良好的效果。更大的秩可能能够捕捉更多样化的任务,但也可能导致过拟合。
):应用于 LoRA 更新的缩放因子。在实践中,我们通过一个因子
更新冻结的权重
。这就是为什么一个常见的启发式方法是设置
浙公网安备 33010602011771号