解密prompt系列35. 标准化Prompt进行时! DSPy论文串烧和代码示例

一晃24年已经过了一半,我们来重新看下大模型应用中最脆弱的一环Prompt Engineering有了哪些新的解决方案。这一章我们先看看大火的DSPy框架,会先梳理DSPy相关的几篇核心论文了解下框架背后的设计思想和原理,然后以FinEval的单选题作为任务,从简单指令,COT指令,到采样Few-shot和优化指令给出代码示例和效果评估。

论文串烧

  • DEMONSTRATE–SEARCH–PREDICT:
    Composing retrieval and language models for knowledge-intensive NLP
  • DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines
  • In-Context Learning for Extreme Multi-Label Classification
  • Optimizing Instructions and Demonstrations for Multi-Stage Language Model Programs
  • DSPy Assertions:
    Computational Constraints for Self-Refining Language Model Pipelines

DSPY这个prompt框架着实火了一阵了,项目也发了上面的许多论文,每篇论文都对应了项目中的一个或几个模块。我们来串一遍以上论文的核心思想。

DEMONSTRATE–SEARCH–PREDICT 是DSPy的第一篇,核心思想和现在coze这类流程控制软件很相似(哈哈现在大模型领域的名词异常的多,这个workflow其实和agent,chain,pipeline,bot啥的意思也很相似)。核心在固定流程,模块化推理过程和指令(few-shot)生成过程。

虽然是在RAG任务上提出的DSP框架,但我们抛开RAG的search,predict的流程,论文的核心其实是把任务拆分成多个原子节点,每个原子节点是一个不可再分割的function,通过整体的control flow来串联原子节点。同时这种流程化的框架,使得每个节点都可以基于训练数据生成demonstation,并且可以通过不同的召回逻辑,在推理时进行few-shot的动态选择,来优化每一个节点的效果。

以下是OpenQA任务上的一个workflow的示例,整个流程有三个部分,生成Demonstration,并基于示例,进行检索和推理。

其中示例的生成,会使用训练数据在相同的流程上,取运行过程中每一步的结果作为实例,这里选择了k=3的3-shot示例

在上面DSP的基础上, DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines 对流程进一步做了抽象和重构,提出了以三个核心模块为基础的prompt生成和优化框架

  • Signature : 继承了pydantic的BaseModel,定义任务的范式,例如Context,Question -> Answer
  • Module :类似pytorch对模型定义的callable function,定义了任务的workflow,例如上面retrieve-then-read的RAG流程
  • Teleprompter : 基于任务指标对流程进行优化的编译器,这里的优化集中在few-shot的选择和动态流程选择,这里流程选择主要是Ensemble,而非直接对workflow进行修改。论文说还提供了微调能力,其实也是基于流程生成的Demonstration作为样本来微调。

其中Telepropmter提供了BootstrapFewShot, BootstrapFewShotWithRandomSearch,Ensemble等prompt优化器。 这篇论文的重心是放在prompt中few-shot选择和优化

以BootstrapFewShotWithRandomSearch为例,Teleprompter会先基于训练集生成一批Demonstration,再基于指定任务的metric,从中采样得到在验证集上效果表现最优的few-shot来构建最终的prompt。

In-Context Learning for Extreme Multi-Label Classification 其实是利用了DSPy的框架,在标签超大(≥10,000)的分类任务上构建了一个新的workflow,叫做Infer-Retrieve-Rank。也就是想让模型猜测N个可能的分类标签,然后召回这些标签。

Optimizing Instructions and Demonstrations for Multi-Stage Language Model Programs 把重心放在了端到端的对任务prompt进行整体优化,除了few-shot之外,还包括 对任务指令和任务条件的优化 。这里有两个技术难点,一个是prompt空间的搜索范围极大,另一个是整体优化时指标的归因问题。

为解决prompt搜索空间问题,论文借鉴APE,也就是大模型基于input-output样本来分析任务本质,生成任务指令的思路。并且在描述任务时除了必须的训练样本,还提供了多个可选择的任务信息,包括

  • 是否使用大模型生成的2~3句话的任务描述
  • 是否使用任务的流程代码
  • 是否使用历史已经尝试过的指令
  • 是否使用不同的指令风格(tips), 例如更有创意,更简洁

模型生成指令的代码如下


TIPS = {
        "none": "",
        "creative": "Don't be afraid to be creative when creating the new instruction!",
        "simple": "Keep the instruction clear and concise.",
        "description": "Make sure your instruction is very informative and descriptive.",
        "high_stakes": "The instruction should include a high stakes scenario in which the LM must solve the task!",
        "persona": 'Include a persona that is relevant to the task in the instruction (ie. "You are a ...")',
    }
class GenerateSingleModuleInstruction(dspy.Signature):
    (
        """Use the information below to learn about a task that we are trying to solve using calls to an LM, then generate a new instruction that will be used to prompt a Language Model to better solve the task."""
    )
    if use_dataset_summary:
        dataset_description = dspy.InputField(
            desc="A description of the dataset that we are using.",
            prefix="DATASET SUMMARY:",
        )
    if program_aware:
        program_code = dspy.InputField(
            format=str,
            desc="Language model program designed to solve a particular task.",
            prefix="PROGRAM CODE:",
        )
        program_description = dspy.InputField(
            desc="Summary of the task the program is designed to solve, and how it goes about solving it.",
            prefix="PROGRAM DESCRIPTION:",
        )
        module = dspy.InputField(
            desc="The module to create an instruction for.", prefix="MODULE:",
        )
    task_demos = dspy.InputField(
        format=str,
        desc="Example inputs/outputs of our module.",
        prefix="TASK DEMO(S):",
    )
    if use_instruct_history:
        previous_instructions = dspy.InputField(
            format=str,
            desc="Previous instructions we've attempted, along with their associated scores.",
            prefix="PREVIOUS INSTRUCTIONS:",
        )
    basic_instruction = dspy.InputField(
        format=str, desc="Basic instruction.", prefix="BASIC INSTRUCTION:",
    )
    if use_tip:
        tip = dspy.InputField(
            format=str,
            desc="A suggestion for how to go about generating the new instruction.",
            prefix="TIP:",
        )
    proposed_instruction = dspy.OutputField(
        desc="Propose an instruction that will be used to prompt a Language Model to perform this task.",
        prefix="PROPOSED INSTRUCTION:",
    )

基于以上大模型生成的指令候选,以及前面BootstrapFewShot生成的众多示例,下一步就是基于训练集选择最优的指令。这里论文构建了包括randomSearch等多个优化器,论文主推的MIPRO(Multi-prompt Instruction Proposal Optimizer)使用常见的超参优化的TPE算法来拟合以上指令中的多个超参对最终prompt效果的正负面影响,基于训练集上的评估指标最终选出最优的指令。所以是生成指令的超参(包括采样生成的指令)选择,并非直接去迭代更新指令本身。

其实这里也可以使用各类模型可解释算法。核心难点是整个任务中有多个节点,每个节点prompt有多个超参,彼此间互相影响,并最终影响任务的完成效果,但我们只能拿到最终任务完成的标签,无法获得中间节点的输出反馈。和决策树之类的归因算法难点相似。

DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines 是DSPy新出的一个子功能-条件判断。考虑常见的prompt构成基本就包括三个部分,任务描述,few-shot示例,还有针对任务完成细节的requirement,前面两篇论文分别给出了生成优化任务描述和采样筛选few-shot的方案,那Assert就是面向requirements的优化方案。

而现实任务中requirement往往是最琐碎的部分,例如像Query改写任务,我们可能需要要求改写query和原始query相似度不能太高,但又不能丢失核心主体,不能丢失或者改写时间实体,query不能太长,不能对query中无关细节等等

论文给出了硬性要求(Assert)和软性建议(Suggest)两种方法,直接加入到前面编写的任务module中,这样任务推理的过程会根据Assert和Suggest直接生成建议,而使用也有两种,一种是模型在推理时命中assert和suggest后,对应建议会直接加入到prompt中用于模型self-refine,另一种是assert可以直接打断模型施法进行重试。

论文就简单说这么多,下面我们以金融单选题任务为示例尝试对prompt进行优化。

代码示例

  • https://github.com/stanfordnlp/dspy
  • https://dspy-docs.vercel.app/

下面我们以FinEval的单选题作为任务,尝试使用DSPy进行Prompt生成,Prompt优化,和效果评估。考虑成本这里只使用了100条样本。

基础Prompt

首先定义LLM模型,这里我使用的Azure的GPT4

import dspy
model = dspy.AzureOpenAI(**kwargs)
dspy.settings.configure(lm=model)

使用DSPy定义prompt,就是定义一个Pydantic模型,包括任务描述,输入描述和输出描述,称之为Signature,如下

class SingleChoiceQA(dspy.Signature):
    """单项选择题,给定题目和ABCD四个选项,输出正确的选项"""
    question = dspy.InputField(desc='问题和选项')
    answer = dspy.OutputField(desc="[ABCD]之一的正确选项")

然后我们把数据导入并转化成DSPy规定的样本格式,同样只要注明输入,Example会自动把剩余字段都当做输出

def format_example(line):
    input = line['question']
    for choice in ['A', 'B', 'C', 'D']:
        input += f'\n{choice}. {line[f"{choice}"]}'
    output = line["answer"]
    example = dspy.Example(question=input, answer=output).with_inputs('question')
    return example

下一步我们来定义任务,DSPy称之为Module,也就是定义任务流,像RAG就是retrieve-read,multihop QA就是多轮的QA,而这里因为是简单的单选题,因此Module定义非常简单。

class SQA(dspy.Module):
    def __init__(self):
        self.generate_answer = dspy.Predict(SingleChoiceQA)

    def forward(self, question):
        return self.generate_answer(question=question)
singlechoice_qa = SQA()
pred = singlechoice_qa(question=examples[0]['question'])
print('以下为DSPy生成Prompt')
print(model.inspect_history(n=1))

同时我们可以使用inspect_history很方便的查看,DSPy生成的具体Prompt,和对应模型的推理效果,如下

COT优化和评估

在上面基础任务定义的基础上,下一步我们看看能否简单使用COT就能优化任务效果。

加入COT定义的Module如下,DSPy提供了常见的ReACT,POT等思考Prompt。

class SQACOT(dspy.Module):
    def __init__(self):
        self.generate_answer = dspy.ChainOfThought(SingleChoiceQA)

    def forward(self, question):
        return self.generate_answer(question=question)
singlechoice_qa_cot = SQACOT()

加入COT后的prompt和推理效果如下

接下来我们来定义下任务的评估指标,然后批量评估下使用基础Prompt和COT Prompt的效果差异。

DSPy提供了一些Exact Match,Passage Match之类的指标,但其实自己定义指标最方便。只要和DSPy Metric的输入输出对齐即可。这里我们简单抽取答案中的ABCD和标准答案计算Accuracy。评估代码如下,DSPy支持在返回打分的同时,返回每一条预测的具体结果

from dspy.evaluate.evaluate import Evaluate
def choice_match(example, pred, trace=None):
    def extract_choice(gen_ans):
        m = re.findall(r'[ABCD]', gen_ans, re.M)
        if len(m) >= 1:
            answer = m[0]
            return answer
        return random.choice('ABCD')

    return extract_choice(pred.answer) == example.answer

evaluate_on_qa = Evaluate(devset=test, num_threads=1,
                          display_progress=True, display_table=True)
output1 = evaluate_on_qa(singlechoice_qa, metric=choice_match,return_outputs=True,return_all_scores=True)

output2 = evaluate_on_qa(singlechoice_qa_cot, metric=choice_match,return_outputs=True,return_all_scores=True)

以上评估,基础Prompt的准确率在50%,而COT Prompt的准确率在60%

FewShot优化

再进一步我们使用DSPy最重点打造的FewShot采样优化,看下能否进一步提升效果。这里DSPy提供了多种优化器,适配不同的样本量级,这里考虑成本我们使用了基础的BootstrapFewShot,样本更多可以尝试BootstrapFewShotWithRandomSearch,或者BootstrapFewShotWithOptuna。

BootstrapFewShot可以指定few-shot中包含几个模型预测生成的样本(max_bootstrapped_demos),和几个真实标签的训练样本(max_labeld_demos)

teleprompter = BootstrapFewShot(metric=choice_match,
                                max_bootstrapped_demos=4,
                                max_labeled_demos=16)
compiled_qa = teleprompter.compile(singlechoice_qa_cot, trainset=train)

score3, results3, output3, df3 = evaluate_on_qa(compiled_qa, metric=choice_match,
                                                return_outputs=True,
                                                return_all_scores=True)

加入采样Few-Shot后的推理准确率提升到了75% ,使用2个推理Demos,和4个真实标签的Demos拼接而成的模型Prompt如下

指令优化

再看下指令优化,这里使用的是COPRO优化器,也就是使用prompt让大模型基于原有prompt优化生成新的prompt,优化指令和第一轮优化生成的prompt如下

第二轮之后指令优化会给出之前尝试过的所有指令,和每个指令在验证集上的打分,并让模型进行有针对性的优化,称之为GenerateInstructionGivenAttempts,指令如下

在0.7的temperature下,每一轮模型会基于上一轮的最优prompt生成breadth=5个新的prompt,并重复以上过程depth=3轮,最后选取在验证集上效果最优的prompt。

COPRO_teleprompter = COPRO(prompt_model=model ,
                          metric=choice_match,
                          breadth=5,
                          depth=3,
                          init_temperature=0.7,
                          track_stats=True)
kwargs = dict(num_threads=1, display_progress=True, display_table=5)
COPRO_compiled_qa = COPRO_teleprompter.compile(singlechoice_qa_cot,
                                                trainset=train,
                                                eval_kwargs=kwargs)
print(model.inspect_history(n=1))

最终优化后的最优的prompt指令如下

但在测试集上最终并没有提升准确率还是60%,其中一个主要原因也是单选QA任务本身比较基础,在指令上可以优化的空间不算太大,指令优化再更复杂,非常规任务上的效果会更显著些。

整体上DSPy确实提供了模块化,标准化设计Prompt的方案,但是在任务描述上的优化方案现在还比较有限,上面GenerateInstructionGivenAttempts虽然提供了历史尝试过的指令和验证集打分,但缺少了模型从历史prompt的回答中总结模型当前指令存在什么问题的步骤,后面可能可以考虑更多reflection相关的指令优化方案像TextGrad,这个等我玩过再来总结吧~

想看更全的大模型相关论文梳理·微调及预训练数据和框架·AIGC应用,移步Github >> DecryPrompt

标签:游戏攻略