隙中驹,石中火,梦中身。
周末不卷,忙里偷闲,写点技术
。
如何与大模型有效地沟通,是一门艺术。
于是,摆在我们这个时代的人们面前的一个课题是:如何把这门艺术变成一个有迹可循的工程问题。今天,我们就一起聊聊自动提示词工程APE (Automated Prompt Engineering),并分析一个具体的开源实现DSPy[1],看看它到底是怎么“用魔法破除魔法”的。
由于篇幅比较长,所以我计划分成2~3篇文章来写。本篇我们先聊聊APE和DSPy中的基础概念,并通过一个具体程序的例子来解释DSPy的运行过程,借此获得一个直观的感受;在后面的文章中,我们再分析一下DSPy和APE的区别、类似的工程思路给我们带来的启示,以及可能存在的一些问题。
两大类提示词
对于不同角色的人来说,提示词 (Prompt) 这个概念的含义,可能还有些许的不同。为了讨论方便,我们可以把提示词粗略地分成两类:
普通聊天提示词是针对普通用户来说的。一个用户通过聊天的方式与大模型产品交互,他需要用清晰的语言来描述他的问题,才能获得期望的答案。比如,你希望让大模型帮助写一篇中学生作文,这时候你就需要把作文的背景信息、你希望的立意、行文风格,甚至你希望描写的具体场景,以及其他一切写作要求,都完整清晰地表达出来。
也就是说,普通聊天提示词是用户自己写的,只要描述清楚当前特定任务的需求就可以了。
而系统开发提示词就不一样了,它是针对AI应用开发者(也就是工程师)来说的。工程师在编写程序、构建AI系统的过程中,将用户任务进行拆解,会在很多环节上需要与大模型进行交互,这就产生了提示词的编写工作。比如,对用户输入的query进行意图分类、实体提取、检索关键词扩展,等等,都是AI应用开发中常见的情形。
可见,系统开发提示词与普通聊天提示词有很大的不同,它是由工程师来写的,需要考虑某个场景中各种各样的用户可能的输入情况。因此,系统开发提示词需要更系统性地设计,需要考虑足够多的边界情况,需要提供稳定的准确率保障,其重要性与代码等同。
我们接下来要讨论的APE,显然是针对第二大类提示词——系统开发提示词来讲的。
APE和DSPy中的基础概念
在分析具体的DSPy程序之前,我们先来明确一下APE及DSPy中的一些基础概念。
什么是APE呢?简单来说,就是利用LLM来自动化地帮助我们生成提示词。
那么问题来了,我们怎么知道LLM生成的提示词好不好呢?它有没有满足我们的要求呢?因此,为了使用APE,我们必须要指定一个明确的
metric
。基于这个metric,我们能够自动化地评测当前所生成的提示词,它的性能表现达到什么程度了。当然,定义metric并不是一件容易的事,我们后面再讨论。
现在有了metric,那么它具体是在什么数据集上进行评测呢?为此,我们还需要提供一个
标注好的数据集 (labelled dataset)
。当然,在实际使用时,这个数据集会被划分为训练集、验证集、测试集。
APE是一个不断迭代的过程。每生成一个新版的prompt,它就会根据metric,在数据集上进行评测得到一个分数(score)。只要新版prompt比旧版本的prompt能够取得更高的分数,APE就可以不断重复这个过程,从而得到越来越好的prompt。
这个迭代过程颇有点「左脚踩右脚」的意味。而在迭代的开始,我们还需要提供一个
初始的提示词 (initial prompt)
,作为优化迭代的起点。
另外,由于在APE中提示词是由LLM生成的,因此我们还需要一个
为了生成新提示词而使用的提示词,称为meta-prompt
。meta-prompt在APE中是一个非常重要的概念。
DSPy是一个开源框架,它包含了APE工程的大部分元素。但是,DSPy不仅仅是APE,这两者之间的区别我们在分析完DSPy的执行过程之后,在后面的文章中再讨论。
在进入DSPy的具体分析之前,我们先概要性地了解一下DSPy抽象出来的几个核心概念:
-
Module
:是一个DSPy程序的基本组成单元。一个Module具有明确的输入和输出定义,并且它调用LLM来实现从输入到输出的处理。一个DSPy程序本身就是一个Module,其内部又可以包含多个子Module;DSPy的优化器可以将同属一个DSPy程序内的多个Module放在一起进行优化迭代。这里的优化,其实一般就是指的对提示词进行优化,也就是前面说的APE。
-
Signature
:描述了一个Module的输入和输出,类似于函数的签名。Signature是DSPy中特有的一个概念。我们下面具体分析的时候再详细解释。
-
Metric
:我们前面提到过metric,在DSPy里,Metric被抽象为一个函数 (function) ,这个函数可以基于一个DSPy程序的输出和标注好的预期答案,计算出一个分数 (score) 。分数的计算方式,可以有很多种。
-
Evaluate
:在一个指定的数据集上逐个计算Metric,之后汇总计算出总体的评测分数。
-
Optimizer
:优化器。在DSPy中优化器的具体实现被称为
Teleprompter
。Teleprompter负责驱动整个DSPy程序进行迭代优化,它在每次迭代中参考来自各方面的信息(程序本身、数据集、旧版prompt及对应的评估分数等),运行某些优化策略为组成DSPy程序的每个Module生成新的prompt。
DSPy运行示例分析
在本节,我们来分析一个具体的DSPy程序的运行过程。这个程序来自DSPy的官方教程:https://dspy.ai/tutorials/rag/,它展示了一个典型的RAG程序如何使用DSPy框架进行优化。我们把这个RAG程序作为一个示例,逐步分析一下它主要的运行步骤和背后的原理。
程序的骨架代码如下:
# Part 1: 初始化LLM
lm = dspy.LM('openai/gpt-4o-mini')
dspy.configure(lm=lm)
# Part 2: 初始化数据集
with open('ragqa_arena_tech_500.json') as f:
data = ujson.load(f)
data = [dspy.Example(**d).with_inputs('question') for d in data]
random.shuffle(data)
trainset, valset, devset, testset = data[:50], data[50:150], data[150:300], data[300:500]
# Part 3: 初始化Metric和Evaluate
metric = SemanticF1()
evaluate = dspy.Evaluate(devset=testset, metric=metric, num_threads=8,
display_progress=True, display_table=2)
# Part 4: 初始化RAG module
# 检索的代码search,此处略去
class RAG(dspy.Module):
def __init__(self, num_docs=5):
self.num_docs = num_docs
self.respond = dspy.ChainOfThought('context, question -> response')
def forward(self, question):
context = search(question, k=self.num_docs)
return self.respond(context=context, question=question)
rag = RAG()
score_before_optimization = evaluate(rag)
# Part 5: 初始化Teleprompter并完成编译/优化
# dspy.MIPROv2是Teleprompter的子集
tp = dspy.MIPROv2(
metric=metric,
auto="light",
num_threads=8
)
optimized_rag = tp.compile(rag, trainset=trainset, valset=valset,
max_bootstrapped_demos=2, max_labeled_demos=2,
requires_permission_to_run=False)
score_after_optimization = evaluate(optimized_rag)
RAG程序骨架解析
以上RAG程序的骨架,大体由五部分组成。
第一部分,初始化LLM
。在一个DSPy程序中(或者一个典型的APE程序中),用到LLM的地方一般来说有三个:
-
(1)待优化的程序本身使用的LLM。也就是由Module来调用的LLM。当然,Module的各个子Module也都会调用LLM。
-
(2)评测使用的LLM。也就是计算Metric时或由Evaluate来调用的LLM。
-
(3)优化器使用的LLM。也就是由Teleprompter来调用的LLM。
这三处LLM在DSPy程序中可以分别指定。我们看到,以上示例代码只初始化了一个LLM实例,意味着这三个地方都使用同一个LLM实例。
至于DSPy支持的LLM模型类型,市面上常见的都能支持,包括OpenAI、Anthropic、Databricks各家公司的模型API,还有本地私有部署的LLM,以及OpenAI通过微软Azure提供的模型API。
第二部分,初始化数据集