提示工程已死:DSPy 是提示的新范式

我清楚地记得,直到几个月前,快速工程才被大肆宣传。整个就业市场都充斥着快速工程师的角色,但现在情况已不再如此。快速工程不是任何艺术或科学,它只是一种聪明的汉斯现象,人类为系统提供必要的背景信息,以便以更好的方式回答。人们甚至写了书籍/博客,比如《前 50 个提示,以充分利用 GPT》,等等。但大规模实验清楚地表明,没有一种单一的提示或策略可以解决各种问题,只是有些提示单独看起来更好,但综合分析后就会出现问题。所以,今天我们要讨论DSPY:将声明性语言模型调用编译成自我改进的管道,这是斯坦福大学为自我改进管道开发的框架,其中 LLM 被视为由编译器优化的模块,类似于 PyTorch 中的抽象。

介绍

正如我上面提到的,互联网上充斥着各种推销书籍和博客。其中大多数只是在向你推销一堆垃圾。现在,正如我所说,其中一些可能确实有效,但这不是构建应用程序的好方法。不知道什么时候某些东西不起作用很重要,我们需要定义一个安全的假设空间,系统在其中可以工作,也可以不工作。

甚至有论文表明,通过某些情感提示,LLM的表现会提高。对我来说,我仍然对这种论文的真实性持保留态度。它能持续多久?对每个主题都是如此吗?是否有主题进行这种情感提示可能会导致更糟糕的结果?有很多这样的论文,它们无意中发表了不成熟的研究。另一篇类似的论文是 Embers of Autoregression,后来证明很多东西都是错误的

https://arxiv.org/pdf/2307.11760

但更大的问题是,我需要用什么样的科学/系统的方式告诉系统,“如果你不立即给出答案,我可能会被解雇,或者我的祖母病了,等等”。这只是人们试图侵入LLM的行为。

理解提示问题

例如,当我说“使用 RAG 添加 5 次 CoT,使用困难的负样例”时,从概念上讲非常清楚,但在实践中很难实现。LLM 对提示非常敏感,因此将这种结构放在提示中在大多数情况下都不起作用。LLM 的行为对提示的编写方式非常敏感,这使得操纵它们变得非常困难。

因此,当我们构建管道时,不仅仅是我试图说服 LLM 以某种方式给出输出,而且更多的输出应该受到限制,以便它可以作为更大管道中其他模块的输入。

为了解决这个问题,已经有很多研究,但它们在很多方面都很有限。他们中的大多数人都在努力使用字符串模板,这些模板很脆弱不可扩展。语言模型会随着时间的推移而改变,提示会中断。如果我们想将模块插入不同的管道,这是行不通的。我们希望它与更新的工具、新的数据库或检索器进行交互,这是行不通的。

这正是 DSPy 旨在解决的问题,将 LLM 作为一个模块,根据它与管道中其他组件的交互方式自动调整其行为。

DSPy 范式:让我们编程 — 而不是提示 — LMs

因此,DSPy 的目标是将重点从调整 LLM 转移到良好的总体系统设计。

但该怎么做呢?

为了从心理层面思考这个问题,我们可以将 LLM 视为:一种设备:执行指令并通过类似于 DNN 的抽象进行操作。

例如,我们在 PyTorch 中定义了一个卷积层,它可以对来自其他层的一组输入进行操作。从概念上讲,我们可以堆叠这些层并在原始输入上实现所需的抽象级别,我们不需要定义任何 CUDA 核心和许多其他指令。所有这些都已在卷积层的定义中抽象化。这就是我们希望用 LLM 做的事情,其中 LLM 是抽象模块,以不同的组合堆叠以实现某种类型的行为,无论是 CoT、ReAct 还是其他什么。

为了获得期望的行为,我们需要改变一些事情:

NLP 签名

这些只是我们希望 LLM 实现的行为的声明。这仅定义了需要实现什么,而不是如何实现的规范。规范告诉 DSPy转换的作用,而不是如何提示 LLM 执行转换。

签名示例

  • 签名处理结构化格式和解析逻辑。
  • 签名可以被编译成自我改进和管道自适应的提示或微调。

DSPY 使用以下方式推断字段的作用:

  • 他们的名字,例如 DSPy 将使用上下文学习来以不同于答案的方式解释问题
  • 它们的踪迹(输入/输出示例)

注意:所有这些都不是硬编码的,而是系统在通信过程中计算出来的。

模块

在这里,我们使用签名来构建模块,例如,如果我们想构建一个 CoT 模块,我们会使用这些签名来构建它。这会自动生成高质量的提示,以实现某些提示技术的行为。

更技术性的定义:模块是通过抽象提示技术来表达签名的参数化层。

模块类型

声明之后,模块的行为就像一个可调用函数。

参数:为了表达特定的签名,任何 LLM 调用都需要指定:

  • 要调用的特定 LLM
  • 提示说明
  • 各签名字段的字符串前缀
  • 演示使用了少量的镜头提示和/或微调数据

优化器

为了使这个系统发挥作用,优化器基本上会采用整个管道并根据某个指标对其进行优化,并在此过程中自动提出最佳提示,甚至语言模型的权重也会在此过程中更新。

高层的想法是,我们将使用优化器来编译我们的代码,从而进行语言模型调用,以便我们管道中的每个模块都被优化为为我们自动生成的提示,或者为我们的语言模型生成一组新的经过微调的权重,以适合我们正在尝试解决的任务。

实例

单一的搜索查询通常不足以完成复杂的 QA 任务。例如,HotPotQA其中的一个例子包括有关“Right Back At It Again”作者出生城市的问题。搜索查询通常正确地将作者标识为“Jeremy McKinnon”,但缺乏确定作者出生时间的预期答案的能力。

在检索增强型 NLP 文献中,应对这一挑战的标准方法是构建多跳搜索系统,例如 GoldEn(Qi 等人,2019 年)和 Baleen(Khattab 等人,2021 年)。这些系统读取检索到的结果,然后生成其他查询以在必要时收集其他信息,然后得出最终答案。使用 DSPy,我们只需几行代码就可以轻松模拟此类系统。

目前,为了实现这一点,我们需要编写非常复杂的提示,并以非常混乱的方式构建它们。但不好的是,一旦我改变问题类型,我可能需要完全改变系统设计,但使用 DSPy 则不需要。

配置语言模型和检索模型

import dspy

turbo = dspy.OpenAI(model='gpt-3.5-turbo')
colbertv2_wiki17_abstracts = dspy.ColBERTv2(url='http://20.102.90.50:2017/wiki17_abstracts')

dspy.settings.configure(lm=turbo, rm=colbertv2_wiki17_abstracts)

加载数据集

我们利用上述HotPotQA数据集,这是一组通常以多跳方式回答的复杂问答对。我们可以通过HotPotQA类加载DSPy提供的这个数据集

rom dspy.datasets import HotPotQA

# Load the dataset.
dataset = HotPotQA(train_seed=1, train_size=20, eval_seed=2023, dev_size=50, test_size=0)

# Tell DSPy that the 'question' field is the input. Any other fields are labels and/or metadata.
trainset = [x.with_inputs('question') for x in dataset.train]
devset = [x.with_inputs('question') for x in dataset.dev]

len(trainset), len(devset)

#Output
(20, 50)

Building Signature

现在我们已经加载了数据,让我们开始定义 Baleen 管道子任务的签名。

我们将首先创建以作为输入并作为输出的GenerateAnswer签名。contextquestionanswer

class GenerateAnswer(dspy.Signature):
    """Answer questions with short factoid answers."""

    context = dspy.InputField(desc="may contain relevant facts")
    question = dspy.InputField()
    answer = dspy.OutputField(desc="often between 1 and 5 words")


class GenerateSearchQuery(dspy.Signature):
    """Write a simple search query that will help answer a complex question."""

    context = dspy.InputField(desc="may contain relevant facts")
    question = dspy.InputField()
    query = dspy.OutputField()

构建管道

那么,让我们来定义程序本身SimplifiedBaleen。有很多可能的方法来实现这一点,但我们将把这个版本保留为关键元素。

from dsp.utils import deduplicate

class SimplifiedBaleen(dspy.Module):
    def __init__(self, passages_per_hop=3, max_hops=2):
        super().__init__()

        self.generate_query = [dspy.ChainOfThought(GenerateSearchQuery) for _ in range(max_hops)]
        self.retrieve = dspy.Retrieve(k=passages_per_hop)
        self.generate_answer = dspy.ChainOfThought(GenerateAnswer)
        self.max_hops = max_hops
    
    def forward(self, question):
        context = []
        
        for hop in range(self.max_hops):
            query = self.generate_query[hop](context=context, question=question).query
            passages = self.retrieve(query).passages
            context = deduplicate(context + passages)

        pred = self.generate_answer(context=context, question=question)
        return dspy.Prediction(context=context, answer=pred.answer)

我们可以看到,该__init__方法定义了几个关键的子模块:

  • generate_query:对于每一跳,我们将有一个带有签名dspy.ChainOfThought的预测器GenerateSearchQuery
  • 检索:该模块将通过模块使用生成的查询对我们定义的 ColBERT RM 搜索索引进行搜索dspy.Retrieve
  • generate_answer:该dspy.Predict模块将与签名一起使用GenerateAnswer以产生最终答案。

forward方法在简单的控制流中使用这些子模块。

  1. 首先,我们将循环至self.max_hops时间。
  2. 在每次迭代中,我们将使用 的预测器生成一个搜索查询self.generate_query[hop]
  3. 我们将使用该查询检索前 k 个段落。
  4. 我们将把(去重复的)段落添加到我们的context累加器中。
  5. 循环结束后,我们将用它self.generate_answer来产生答案。
  6. context我们将返回已检索到的和预测的预测answer

执行管道

让我们在零样本(未编译)设置下执行该程序。

这并不一定意味着性能会很差,而是意味着我们直接受到底层 LM 的可靠性限制,无法从最少的指令理解我们的子任务。通常,当在最简单和最标准的任务(例如,回答有关流行实体的简单问题)上使用最昂贵/最强大的模型(例如,GPT-4)时,这是完全没问题的。

# Ask any question you like to this simple RAG program.
my_question = "How many storeys are in the castle that David Gregory inherited?"

# Get the prediction. This contains `pred.context` and `pred.answer`.
uncompiled_baleen = SimplifiedBaleen()  # uncompiled (i.e., zero-shot) program
pred = uncompiled_baleen(my_question)

# Print the contexts and the answer.
print(f"Question: {my_question}")
print(f"Predicted Answer: {pred.answer}")
print(f"Retrieved Contexts (truncated): {[c[:200] + '...' for c in pred.context]}")


#Output
Question: How many storeys are in the castle that David Gregory inherited?
Predicted Answer: five
Retrieved Contexts (truncated): ['David Gregory (physician) | David Gregory (20 December 1625 – 1720) was a Scottish physician and inventor. His surname is sometimes spelt as Gregorie, the original Scottish spelling. He inherited Kinn...', 'The Boleyn Inheritance | The Boleyn Inheritance is a novel by British author Philippa Gregory which was first published in 2006. It is a direct sequel to her previous novel "The Other Boleyn Girl," an...', 'Gregory of Gaeta | Gregory was the Duke of Gaeta from 963 until his death. He was the second son of Docibilis II of Gaeta and his wife Orania. He succeeded his brother John II, who had left only daugh...', 'Kinnairdy Castle | Kinnairdy Castle is a tower house, having five storeys and a garret, two miles south of Aberchirder, Aberdeenshire, Scotland. The alternative name is Old Kinnairdy....', 'Kinnaird Head | Kinnaird Head (Scottish Gaelic: "An Ceann àrd" , "high headland") is a headland projecting into the North Sea, within the town of Fraserburgh, Aberdeenshire on the east coast of Scotla...', 'Kinnaird Castle, Brechin | Kinnaird Castle is a 15th-century castle in Angus, Scotland. The castle has been home to the Carnegie family, the Earl of Southesk, for more than 600 years....']

优化管道

然而,零样本方法很快就无法满足更专业的任务、新颖的领域/设置以及更高效(或开放)的模型的要求。

为了解决这个问题,DSPy 提供了编译功能。让我们来编译我们的多跳(SimplifiedBaleen)程序。

让我们首先定义编译的验证逻辑:

  • 预测答案与黄金答案相符。
  • 检索到的上下文包含黄金答案。
  • 所生成的查询都不是杂乱无章的(即,长度均不超过 100 个字符)。
  • 生成的查询均未粗略重复(即,没有一个查询的 F1 分数在早期查询的 0.8 或更高范围内)。
def validate_context_and_answer_and_hops(example, pred, trace=None):
    if not dspy.evaluate.answer_exact_match(example, pred): return False
    if not dspy.evaluate.answer_passage_match(example, pred): return False

    hops = [example.question] + [outputs.query for *_, outputs in trace if 'query' in outputs]

    if max([len(h) for h in hops]) > 100: return False
    if any(dspy.evaluate.answer_exact_match_str(hops[idx], hops[:idx], frac=0.8) for idx in range(2, len(hops))): return False

    return True   

我们将使用 DSPy 中最基本的提词器之一,即BootstrapFewShot使用少量示例优化管道中的预测器。

from dspy.teleprompt import BootstrapFewShot

teleprompter = BootstrapFewShot(metric=validate_context_and_answer_and_hops)
compiled_baleen = teleprompter.compile(SimplifiedBaleen(), teacher=SimplifiedBaleen(passages_per_hop=2), trainset=trainset)

评估管道

现在让我们定义评估函数并比较未编译和已编译的 Baleen 管道的性能。虽然此 devset 不能作为完全可靠的基准,但对于本教程来说还是很有启发的。

from dspy.evaluate.evaluate import Evaluate

# Define metric to check if we retrieved the correct documents
def gold_passages_retrieved(example, pred, trace=None):
    gold_titles = set(map(dspy.evaluate.normalize_text, example["gold_titles"]))
    found_titles = set(
        map(dspy.evaluate.normalize_text, [c.split(" | ")[0] for c in pred.context])
    )
    return gold_titles.issubset(found_titles)

# Set up the `evaluate_on_hotpotqa` function. We'll use this many times below.
evaluate_on_hotpotqa = Evaluate(devset=devset, num_threads=1, display_progress=True, display_table=5)

uncompiled_baleen_retrieval_score = evaluate_on_hotpotqa(uncompiled_baleen, metric=gold_passages_retrieved, display=False)

compiled_baleen_retrieval_score = evaluate_on_hotpotqa(compiled_baleen, metric=gold_passages_retrieved)

print(f"## Retrieval Score for uncompiled Baleen: {uncompiled_baleen_retrieval_score}")
print(f"## Retrieval Score for compiled Baleen: {compiled_baleen_retrieval_score}")


#Output
## Retrieval Score for uncompiled Baleen: 36.0
## Retrieval Score for compiled Baleen: 60.0

结论

这些结果表明,在 DSPy 中结合多跳设置甚至可以超越人类反馈。他们甚至表明,即使是像 T5 这样小得多的模型,在 DSPy 设置中使用时也可以与 GPT 进行比较。DSPy 是我在 lang chain 发布后遇到的最酷的系统之一,这表明在制作一个更好、系统设计的系统方面有很大的希望,而不是在大型 LLM 管道中疯狂地放置零件。

参考:
https://medium.com/aiguys/prompt-engineering-is-dead-dspy-is-new-paradigm-for-prompting-c80ba3fc4896