生产级 LLM 应用的完整工程实践指南
Chip Huyen 专家级文章,系统覆盖数据准备、模型选型、成本优化、部署等核心工程问题,对构建真实 LLM 产品不可或缺。
Chip Huyen 专家级文章,系统覆盖数据准备、模型选型、成本优化、部署等核心工程问题,对构建真实 LLM 产品不可或缺。
更新:我即将推出的书籍《AI 工程》(2024 年末/2025 年初)将深入讨论使用基础模型构建应用程序。
最近我被频繁问到一个问题:大语言模型(LLM)将如何改变机器学习工作流。在与多家正在开发 LLM 应用的公司合作,以及亲身钻研开发 LLM 应用后,我意识到两点:
使用 LLM 创建有趣的东西很容易,但要让它投入生产就很难。
LLM 的局限性因提示工程中工程严谨性的缺失而被放大,这部分源于自然语言的模糊性,部分源于该领域还处于早期阶段。
本文分为三个部分。
第 1 部分讨论了 LLM 应用生产化的关键挑战以及我所看到的解决方案。
第 2 部分讨论如何使用控制流(如 if 语句、for 循环)组合多个任务,并整合工具(如 SQL 执行器、bash、网络浏览器、第三方 API),以构建更复杂、更强大的应用。
第 3 部分涵盖我所见到的一些公司在 LLM 基础上构建的有前景的用例,以及如何从较小的任务构建它们。
关于 LLM 已经有很多文章发表,如果你已经熟悉某些部分,可以跳过。
在计算机历史的大部分时间里,工程师用编程语言编写指令。编程语言是"大多数"精确的。歧义在开发者中引发沮丧,甚至激烈反感(想想 Python 或 JavaScript 中的动态类型)。
在提示工程中,指令用自然语言编写,比编程语言灵活得多。这可以创造很好的用户体验,但会导致糟糕的开发者体验。
灵活性来自两个方向:用户如何定义指令,以及 LLM 如何响应这些指令。
首先,用户定义的提示中的灵活性导致无声故障。如果有人在代码中意外做出一些改变,比如添加一个随机字符或删除一行,它可能会抛出错误。但如果有人不小心改变了提示,它仍然会运行,但给出完全不同的输出。
虽然用户定义的提示中的灵活性只是烦恼,但 LLM 生成的响应中的歧义可能是一个破坏性因素。它导致两个问题:
输出格式的歧义:LLM 之上的下游应用期望输出格式一致,以便能够解析。我们可以编写明确的提示来说明输出格式,但不能保证输出总是遵循这种格式。
输出格式的歧义:LLM 之上的下游应用期望输出格式一致,以便能够解析。我们可以编写明确的提示来说明输出格式,但不能保证输出总是遵循这种格式。
用户体验的不一致:使用应用程序时,用户期望有一定的一致性。想象一家保险公司每次你查看他们的网站时都给你不同的报价。LLM 是随机的——不能保证 LLM 对相同的输入每次都给出相同的输出。你可以通过设置 temperature = 0 来强制 LLM 给出相同的响应,这通常是一个很好的做法。虽然这在很大程度上解决了一致性问题,但它不会增强对系统的信任。想象一位老师,只有坐在一个特定的房间里时才会给你一致的分数。如果那位老师坐在不同的房间,那位老师对你的分数就会非常不稳定。
用户体验的不一致:使用应用程序时,用户期望有一定的一致性。想象一家保险公司每次你查看他们的网站时都给你不同的报价。LLM 是随机的——不能保证 LLM 对相同的输入每次都给出相同的输出。
这似乎是 OpenAI 正在积极尝试缓解的问题。他们有一个关于如何提高模型可靠性的笔记本。
一些多年从事 LLM 工作的人告诉我,他们只是接受了这种歧义并围绕它构建工作流。与开发确定性程序相比,这是一种不同的思维方式,但不是不可能适应的。
这种歧义可以通过尽可能多地应用工程严谨性来缓解。在本文的其余部分,我们将讨论如何使提示工程(即使不是确定性的)也是系统性的。
少样本学习是提示工程的一种常见技术,即在提示中提供一些例子,希望 LLM 能从这些例子中推广。
例如,考虑尝试给文本赋予争议性分数——这是我做的一个有趣项目,用来找到推文的受欢迎程度与其争议性的关联。这是一个缩短的提示,包含 4 个少样本例子:
Given a text, give it a controversy score from 0 to 10.
Examples:
1 + 1 = 2
Controversy score: 0
Starting April 15th, only verified accounts on Twitter will be eligible to be in For You recommendations
Controversy score: 5
Everyone has the right to own and use guns
Controversy score: 9
Immigration should be completely banned to protect our country
Controversy score: 10
The response should follow the format:
Controversy score: { score }
Reason: { reason }
Here is the text.
进行少样本学习时,需要记住两个问题:
LLM 是否理解提示中提供的例子。一种评估方法是输入相同的例子,看模型是否输出预期的分数。如果模型在提示中给定的相同例子上表现不好,可能是因为提示不清楚——你可能想重写提示或将任务分解成较小的任务(并将它们组合在一起,在本文的第二部分中详细讨论)。
LLM 是否过度拟合这些少样本例子。你可以在单独的例子上评估你的模型。
我还发现另一个有用的做法是要求模型给出它会给予某个标签的例子。例如,我可以让模型给我它会给出分数 4 的文本示例。然后我会将这些例子输入 LLM,看看它是否会确实输出 4。
from llm import OpenAILLM
def eval_prompt(examples_file, eval_file):
prompt = get_prompt(examples_file)
model = OpenAILLM(prompt=prompt, temperature=0)
compute_rmse(model, examples_file)
compute_rmse(model, eval_file)
eval_prompt("fewshot_examples.txt", "eval_examples.txt")
对提示的小改变可能导致非常不同的结果。版本控制和追踪每个提示的性能是必要的。你可以使用 git 来版本控制每个提示及其性能,但我不会惊讶地看到会出现像 MLflow 或 Weights & Biases 这样的提示实验工具。
有很多论文和博客文章讨论如何优化提示。我同意 Lilian Weng 在她的有用博客文章中的观点,即大多数关于提示工程的论文都是可以用几句话解释的技巧。OpenAI 有一个很好的笔记本,用例子解释了许多技巧。这里是其中一些:
促使模型解释或逐步说明它如何得出答案,这种技术称为思维链(Chain-of-Thought 或 COT)(Wei et al., 2022)。权衡:COT 可能会因为输出 token 数增加而增加延迟和成本[见成本和延迟部分]
为相同的输入生成许多输出。通过多数投票(也称为 Wang et al., 2023 的自洽技术)选择最终输出,或者你可以让你的 LLM 选择最好的。在 OpenAI API 中,你可以通过传递参数 n 为相同的输入生成多个响应(如果你问我的话,这不是一个理想的 API 设计)。
将一个大提示分解成更小、更简单的提示。
许多工具承诺自动优化你的提示——它们通常相当昂贵,通常只是应用这些技巧。这些工具的一个好处是它们无需代码,这对非代码人员很有吸引力。
提示中输入的明确细节和例子越多,模型性能就越好(希望如此),你的推理成本就越高。
OpenAI API 会同时对输入和输出 token 收费。根据任务不同,一个简单的提示词可能包含 300~1000 个 token。如果想加入更多上下文,例如将你自己的文档或从互联网上检索到的信息添加到提示词中,那么仅提示词本身就很容易达到 1 万个 token。
长提示词的成本并不在实验阶段,而在推理阶段。
就实验而言,提示工程是一种成本低、见效快的启动方式。例如,即使使用 GPT-4 和以下设置,实验成本也只略高于 300 美元。传统机器学习在数据收集和模型训练方面的成本通常要高得多,所需时间也长得多。
提示词:1 万个 token(每 1000 个 token 0.06 美元)
输出:200 个 token(每 1000 个 token 0.12 美元)
在 20 个样例上进行评估
试验 25 个不同版本的提示词
LLMOps 的成本在于推理。
如果使用 GPT-4,输入包含 1 万个 token,输出包含 200 个 token,那么每次预测的成本为 0.624 美元。
如果使用 GPT-3.5-turbo,输入和输出合计 4000 个 token,那么每次预测的成本为 0.004 美元,即每 1000 次预测 4 美元。
做一个思想实验:2021 年,DoorDash 的机器学习模型每天会执行 100 亿次预测。如果每次预测的成本为 0.004 美元,那么每天的成本将达到 4000 万美元!
相比之下,AWS 个性化服务每 1000 次预测的成本约为 0.0417 美元,而 AWS 欺诈检测服务每 1000 次预测的成本约为 7.5 美元[每月预测次数超过 10 万次时]。对于任何达到中等规模的公司来说,AWS 服务通常都被认为贵得令人望而却步,而且灵活性较差。
输入 token 可以并行处理,这意味着输入长度应该不会对延迟产生太大影响。
然而,输出长度会显著影响延迟,这很可能是因为输出 token 是按顺序逐个生成的。
即使输入极短(51 个 token)、输出也只有 1 个 token,gpt-3.5-turbo 的延迟仍约为 500ms。如果输出增加到 20 个 token 以上,延迟就会超过 1 秒。
下面是我进行的一项实验,每种设置都运行了 20 次。所有运行都在 2 分钟内完成。如果我重新进行这项实验,延迟数据可能会有很大不同,但这 3 种设置之间的相对关系应该仍然类似。
这也是使用 OpenAI 这类 API 将 LLM 应用投入生产时面临的另一项挑战:API 非常不可靠,而且目前尚未承诺何时会提供 SLA。
目前尚不清楚这些延迟中有多少来自模型,有多少来自网络(考虑到不同运行之间存在很大的方差,我猜网络占比很高),又有多少只是低效工程实现带来的额外开销。延迟很可能会在不久的将来大幅降低。
尽管半秒钟对许多用例来说似乎很高,但考虑到模型的庞大规模以及 API 的使用规模,这个数字已经令人难以置信。gpt-3.5-turbo 的参数量并未公开,但据估计约为 1500 亿。截至本文撰写时,还没有任何开源模型达到这一规模。Google 的 T5 有 110 亿个参数,Facebook 最大的 LLaMA 模型有 650 亿个参数。人们在这个 GitHub 讨论串中讨论了运行 LLaMA 模型所需的配置,看起来仅让 300 亿参数的模型正常运行就已经相当困难。其中最成功的似乎是 randaller,他成功地在 128 GB 内存上运行了 300 亿参数的模型,但仅生成一个 token 就需要几秒钟。
LLM 应用领域发展得如此迅速,以至于任何关于成本和延迟的分析都注定会很快过时。Scribd 应用研究高级经理 Matt Ross 告诉我,在过去一年中,他的用例所估算的 API 成本下降了两个数量级。延迟也显著降低了。同样,许多团队告诉我,他们感觉自己每周都必须重新进行可行性评估,并重新决定是购买(使用付费 API),还是自研(使用开源模型)。
提示:对于每个样本,明确告诉模型应该如何响应。
微调:训练模型如何响应,这样就不必在提示词中明确指定。
在权衡提示与微调时,主要需要考虑 3 个因素:数据可用性、性能和成本。
如果只有少量样例,使用提示可以快速、轻松地开始。不过,由于最大输入 token 长度的限制,能够放入提示词中的样例数量存在上限。
针对你的任务微调模型需要多少样例,当然取决于具体任务和模型。但根据我的经验,如果使用数百个样例进行微调,通常可以观察到模型性能出现明显变化。不过,结果可能不会比使用提示好太多。
在《How Many Data Points is a Prompt Worth?》(2021)中,Scao 和 Rush 发现,一个提示词大约相当于 100 个样例(需要注意:不同任务和模型之间的方差很大——见下图)。总体趋势是,随着样例数量增加,微调会比提示带来更好的模型性能。用于微调模型的样例数量没有上限。
微调的好处有两方面:
可以获得更好的模型性能:能够使用更多样例,而且这些样例会成为模型内部知识的一部分。
可以降低预测成本。能够融入模型的指令越多,需要放入提示词中的指令就越少。假设每次预测都能从提示词中减少 1000 个 token,那么使用 gpt-3.5-turbo 完成 100 万次预测时,就可以节省 2000 美元。
一种介于提示和微调之间的巧妙思路是提示调优,由 Leister 等人于 2021 年提出。从一个提示词开始,但并不修改提示词本身,而是以编程方式修改它的嵌入。要让提示调优生效,你需要能够将提示词的嵌入输入 LLM 模型,并根据这些嵌入生成 token。目前,这只能通过开源 LLM 实现,OpenAI API 尚不支持。在 T5 上,提示调优的表现似乎远好于提示工程,并且能够追赶模型调优的效果(见下图)。
2023 年 3 月,一群 Stanford 学生提出了一个很有前景的想法:使用更大的语言模型(text-davinci-003,1750 亿个参数)生成样例,再利用这些样例微调一个较小的开源语言模型(LLaMA-7B,即拥有 70 亿个参数的 LLaMA 版本)。这种训练小模型模仿大模型行为的技术称为蒸馏。最终得到的微调模型在行为上与 text-davinci-003 类似,但体积小得多,运行成本也低得多。
在微调过程中,他们使用了 5.2 万条指令,将这些指令输入 text-davinci-003 获得输出,然后利用这些输出微调 LLaMA-7B。生成这些数据的成本不到 500 美元,微调的训练过程成本不到 100 美元。参见《Stanford Alpaca: An Instruction-following LLaMA Model》(Taori 等,2023)。
这种方法的吸引力显而易见。仅仅 3 周后,他们的 GitHub 仓库就获得了近 2 万颗星!相比之下,HuggingFace 的 transformers 仓库花了一年多才获得相近数量的星,而 TensorFlow 仓库则花了 4 个月。
我认为一个很有前景的方向是使用 LLM 生成嵌入,然后基于这些嵌入构建机器学习应用,例如搜索和推荐系统。截至 2023 年 4 月,使用较小的 text-embedding-ada-002 模型生成嵌入的成本为每 1000 个 token 0.0004 美元。如果每个条目平均包含 250 个 token(187 个单词),按照这一价格计算,每 1 万个条目的成本为 1 美元,每 100 万个条目的成本为 100 美元。
尽管这仍然比一些现有开源模型的成本更高,但考虑到以下几点,它依然非常实惠:
通常每个条目的嵌入只需要生成一次。
使用 OpenAI API,可以轻松实时地为查询和新条目生成嵌入。
如果想进一步了解如何使用 GPT 嵌入,可以参阅《SGPT》(Niklas Muennighoff,2022),或者 Nils Reimers 于 2022 年撰写的关于 GPT-3 嵌入性能和成本的分析。Nils 文章中的一些数据已经过时了(这个领域发展得实在太快了!),但其分析方法非常出色。
在实时用例中,嵌入模型的主要成本来自将这些嵌入加载到向量数据库中,以实现低延迟检索。不过,无论使用哪种嵌入,这项成本都不可避免。看到如此多的向量数据库蓬勃发展令人兴奋——既有 Pinecone、Qdrant、Weaviate、Chroma 等新兴产品,也有 Faiss、Redis、Milvus、ScaNN 等老牌方案。
如果说 2021 年是图数据库之年,那么 2023 年就是向量数据库之年。
Hacker News 讨论:谁在研究 LLM 的向前兼容和向后兼容?
基础模型无需我们进行大量重新训练,就能开箱即用地处理许多任务。然而,随着模型逐渐过时,它们确实需要不时重新训练或微调。根据 Lilian Weng 的《Prompt Engineering》一文:
上面的争议评分器示例只包含一个单一任务:给定输入,输出争议分数。但大多数应用都更加复杂。考虑"与你的数据对话"这类用例,我们需要连接到数据库并用自然语言查询该数据库。想象一个信用卡交易表。你可能想问这样的问题:"凤凰城有多少家独特的商户,他们分别是什么?"而你的数据库会返回:"凤凰城有9家独特商户,他们分别是……"。
一种实现方式是编写一个程序,执行以下任务序列:
任务 1:将用户的自然语言输入转换为 SQL 查询 [LLM]
任务 2:在 SQL 数据库中执行该 SQL 查询 [SQL 执行器]
任务 3:将 SQL 结果转换为自然语言响应展示给用户 [LLM]
我在网络中进行了一个小范围调查,目前似乎还没有一致的术语共识。
"智能体"这个词被广泛用来指代可以根据给定控制流执行多个任务的应用(见"控制流"部分)。一个任务可以利用一个或多个工具。在上面的例子中,SQL 执行器就是一个工具的例子。
注:我网络中的一些人反对在这个语境中使用"智能体"这个术语,因为它在其他语境中已经被过度使用(例如强化学习中指代策略的智能体)。
除了 SQL 执行器,还有更多工具的例子:
工具和插件基本上是同一回事。你可以把插件看作贡献给 OpenAI 插件商店的工具。在撰写本文时,OpenAI 插件还未向公众开放,但任何人都可以创建和使用工具。
在上面的例子中,sequential(顺序执行)是控制流的一个例子,其中一个任务在另一个任务完成后执行。还有其他类型的控制流,如 parallel(并行)、if statement(条件判断)、for loop(循环)。
Sequential:任务 B 在任务 A 完成后执行,通常是因为任务 B 依赖于任务 A。例如,SQL 查询只有在从用户输入翻译后才能执行。
Parallel:同时执行任务 A 和 B。
If statement:根据输入执行任务 A 或任务 B。
For loop:重复执行任务 A,直到满足某个条件。例如,想象你使用浏览器操作获取一个网页的内容,然后继续使用浏览器操作获取该网页中找到的链接的内容,直到智能体认为它已获得足够信息来回答原始问题。
更新(2024年8月):许多用例需要并行控制流。并行函数调用很重要。
在传统软件工程中,控制流的条件是确定的。在 LLM 应用(也称为智能体)中,条件也可能由提示决定。
例如,如果你希望智能体在 search、SQL executor 和 Chat 三个操作中选择,你可以解释它应该如何选择其中一个(非常粗略的示例)。换句话说,你可以使用 LLM 来决定控制流的条件!
You have access to three tools: Search, SQL executor, and Chat.
Search is useful when users want information about current events or products.
SQL executor is useful when users want information that can be queried from a database.
Chat is useful when users want general information.
Provide your response in the following format:
Input: { input }
Thought: { thought }
Action: { action }
Action Input: { action_input }
Observation: { action_output }
Thought: { thought }
要使智能体可靠,我们需要能够在组合之前分别构建和测试每个任务。有两种主要的失败模式:
控制流错误:选择了非可选的操作
一个或多个任务产生不正确的结果
有一个关于 SituatedQA 数据集的观察是,尽管 LM(预训练截止日期为 2020 年)可以通过 Google Search 访问最新信息,但它在 2020 年后问题上的性能仍然远不如在 2020 年前问题上的性能。这表明上下文信息和模型内部知识之间存在某种差异或冲突。
在传统软件中,当软件获得更新时,理想情况下它应该仍然与为其旧版本编写的代码兼容。但在提示工程中,如果你想使用更新的模型,就没有办法保证你的所有提示仍然能够按预期与新模型一起工作,所以你很可能需要重新编写你的提示。如果你预期使用的模型会发生任何变化,对所有提示使用评估示例进行单元测试是很重要的。
我经常听到一个论点,即提示重写不应该成为一个问题,因为:
较新的模型应该总是比现有模型工作得更好。我对此不太确信。较新的模型总体上可能更好,但会有某些用例,在这些用例中较新的模型表现更差。
正如我们在"成本"部分讨论的那样,对提示的实验速度快且成本低。虽然我同意这个论点,但我在当今 MLOps 中看到的一个大挑战是,缺乏对模型逻辑、特征逻辑、提示等的集中知识库。一个应用可能包含具有复杂逻辑的多个提示(在第 2 部分"任务组合"中讨论过)。如果编写原始提示的人离开了,可能很难理解原始提示背后的意图来对其进行更新。这可能会变得类似于有人留下一个 700 行 SQL 查询、没人敢去触碰的情况。
另一个挑战是提示模式对变化不够健壮。例如,我看过的许多已发布的提示都以"我想让你扮演 XYZ"开头。如果 OpenAI 有朝一日决定打印类似"我是一个 AI 助手,我无法扮演 XYZ"的内容,所有这些提示都需要更新。