解密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进行优化。
代码示例
下面我们以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,这个等我玩过再来总结吧~