cover

DSPy 是什么?

DSPy 是什么?有什么用途?

2024-07-14

DSPy 出自论文“DSPY: COMPILING DECLARATIVE LANGUAGE MODEL CALLS INTO SELF-IMPROVING PIPELINES”,直译过来就是“将声明式语言模型的调用便以为可自我改进的流水线”,感觉这个名称并不直观,在阅读原文后,个人理解 DSPy 实际上是一种大语言模型软件的开发范式,其目标是:通过“声明式”的语言定义 LLM 应用的功能(输入 -> 输出),然后通过数据集自动生成能够取得较好结果的 PROMPT。

DSPy 提供了一个 Python 版本的具体实现(Github),其基本流程为:

  1. 准备用于编译和评估的数据集;
  2. 定义 LLM 程序的签名(Signature);
  3. 定义 LLM 程序使用的模块(Modules);
  4. 使用自动提词器(Teleprompter)优化程序
heading

DSPy 中的三个抽象

在 DSPy 定义了三个抽象:

  • 签名 Signature:对一个模块的输入和输出进行了抽象,类似编程语言中的函数签名
  • 模块 Modules:可以简单地理解为特定类型 Prompt 的框架,多个模块可以自由地组合成为流水线 Pipeline;
  • 自动提词器 Teleprompters:用于优化流水线中的所有模块。

如何理解这三个抽象呢?在我看来,他们之间的关系如下:

流水线[模块1[模块1的签名], 模块2[模块2的签名], ...] -> 自动提词器 -> 优化后的程序
  • 签名 Signature 的定义很容易理解,看成程序语言中的函数签名即可,主要作用是定义一个模块的输入和输出。
  • 模块 Module 的话,可以理解成函数主体,在 LLM 应用程序中,可以看成是特定类型的 Prompt,如简单的 QA 模块(Predict)意味着让 LLM 回答一个问题、CoT(ChainOfThought,思维链)模块,则意味着在 Prompt 中会有“让我们一步一步的思考”,其他的模块以此类推。或许将模块看成满足特定形式的 Prompt 的模版更容易理解。
  • 自动提词器 Teleprompters 是一个程序优化工具,类比到软件开发过程中:在一个程序的生命周期中会包含多个迭代,每次迭代都是通过“反馈”进行“优化”。自动提词器的目标类似这一过程,唯一的区别在于这种迭代优化的过程是通过数据集结合 LLM 地能力进行的 - 在 DSPy 中这一过程被称为“编译(Compile)”。
heading

DSPy 中时如何进行“编译 Compile”的?

在 DSPy 中,“编译”过程是通过自动提词器 Teleprompters 实现的,“编译”过程主要有以下三个步骤:

候选生成 编译器首先(递归地)查找程序中所有唯一的预测模块(预测器),包括那些嵌套在其他模块下的预测器。对于每个唯一的预测器 p,提词器可能会为 p 的参数生成候选值:指令、字段描述,或者最重要的是演示(即,示例输入-输出对)。在 DSPy 的这次迭代中,我们专注于演示,并发现类似简单的拒绝抽样方法可以帮助启动高度有效的多阶段系统。

参数优化 现在每个参数都有一组离散的候选集:演示、指令等。许多超参数调整算法(例如,随机搜索或树状结构 Parzen 估计器,如 HyperOpt 和 Optuna 中使用的)可以应用于候选集之间的选择。我们在附录 E.2 和附录 E.3 中报告了 DSPy 的 BootstrapFewShotWithRandomSearch 和 BootstrapFewShotWithOptuna 的简化实现。

优化高阶程序 一种 DSPy 编译器支持的不同类型的优化是修改程序的控制流程。其中最简单的形式之一是集成(ensembles),我们在本文的案例研究中使用了这种方法。一个集成会启动多个相同程序的副本,然后用一个新的程序替换它,这个新程序会并行运行所有副本,并通过一个自定义函数(例如,多数投票)将它们的预测结果汇总为一个。在未来的工作中,这一阶段可以很容易地适应更动态(即测试时)的启动技术以及类似自动回溯的逻辑。

说实话从论文中的这段描述中不太能够理解到编译这一步的具体实现方式。按照自己的理解用 JS 实现了一个(可能的)编译过程,代码如下:

/* mkdir * yarn install * export OPENAI_API_KEY=<your api key> * node . */ const { OpenAI } = require("openai"); function rndPick(items, count) { const copy = [...items]; const result = []; for (let i = 0; i < count; i++) { const randomIndex = Math.floor(Math.random() * copy.length); result.push(copy[randomIndex]); copy.splice(randomIndex, 1); } return result; } function findIndexOfMaxValue(arr) { if (arr.length === 0) return -1; let max_index = 0; let max_value = arr[0]; for (let i = 1; i < arr.length; i++) { if (arr[i] <= max_value) continue; max_value = arr[i]; max_index = i; } return max_index; } async function requestLLM( content, model = "deepseek-chat", temperature = 0.0, ) { const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: "https://api.deepseek.com/v1", }); try { const response = await client.chat.completions.create({ model, messages: [{ role: "user", content }], temperature, }); return response.choices[0].message.content; } catch (error) { console.error("Error asking question:", error); } } class CoTModule { type = "cot"; constructor(input, output, model = "deepseek-chat") { this.input = input; this.output = output; this.model = model; this.prompt = `请根据给定的"${input}"字段中的内容,生成"${output}"字段,请遵循下面的格式: --- 问题:${input} 推理:一步一步的思考然后进行回答 回答:${output} --- `; } async run(question, temperature = 0.0) { const content = this.prompt + `\n问题:${question}`; return requestLLM(content, this.model, temperature); } updatePrompt(fn) { this.prompt = fn(this.prompt); } copy() { return new CoTModule(this.input, this.output); } } class FewShotsTeleprompters { constructor( data, model = "deepseek-chat", shot_count = 2, student_count = 3, ) { this.data = data; this.model = model; this.shot_count = shot_count; this.student_count = student_count; } async compile(module) { const shots = await this.prepareShots(module); const students = this.prepareStudents(module, shots); const students_score = await this.startExam(students); const best_index = findIndexOfMaxValue(students_score); return students[best_index]; } prepareStudents(module, shots) { return Array.from({ length: this.student_count }).map(() => { const m = module.copy(); m.updatePrompt((p) => p + rndPick(shots, this.shot_count).join("\n")); return m; }); } async prepareShots(module) { let shots = []; switch (module.type) { case "cot": shots = await this.prepareCoTShots(module); break; default: shots = data .slice(0, data.length - 3) .map( (p) => `---\n${module.input}${module.input}\n${pair[module.output]}${pair[module.output]}\n---\n`, ); break; } return shots; } async prepareCoTShots(module) { return await Promise.all( this.data.slice(0, this.data.length - 3).map(async (pair) => { const answer = await requestLLM( `请生成从“${module.input}“字段推导得到“${module.output}“字段的推理过程,只需要生成“推理过程”即可。 下面是一个例子: --- 问题:如何使用 Python 打印 "Hello, world" 回答:如果使用 Python2.x,使用 \`print 'Hello, World'\`,如果使用 Python3.x,使用 \`print('Hello, World')\` 推理: 1. Python 有两个版本:2.x 和 3.x,在不确定用户使用版本的情况下应当回复两个版本的方式 2. 在 Python2.x 中,使用 \`print "Hello, World"\` 可以打印 3. 在 Python3.x 中,使用 \`print("Hello, World")\` 可以打印 4. 综上,如果使用 Python2.x,使用 \`print 'Hello, World'\`,如果使用 Python3.x,使用 \`print('Hello, World')\` — ${module.input}${pair[module.input]} ${module.output}${pair[module.output]} `, this.model, 0.5, ); return `---\n${[module.input]}${pair[module.input]}\n推理:${answer}\n${module.output}${pair[module.output]}\n---\n`; }), ); } async startExam(students) { const student_exams = students.map(() => 0); for (const question of this.data.slice(this.data.length - 3)) { for (let i = 0; i < students.length; i += 1) { const answer = await students[i].run(question[module.input]); const score = await requestLLM( `请为下面的问题与回复进行评分,其中“问题”是带回答的问题,“回答”是学生的回复,“参考”答案是预期的答案,请根据“回答”和“参考”间的匹配程度进行评分,分值为 1,2,3,4,5,仅需回复评分,无需额外的解释。 --- 问题:${question[module.input]} 回答:${answer} 参考:${question[module.output]} --- `, this.model, 0.0, ); student_exams[i] += Number(score); } } return student_exams; } } const TEST_DATA = [ { [input]: "法国的首都是哪座城市?", [output]: "巴黎" }, { [input]: "简单地说明什么是大语言模型", [output]: "大语言模型是一个 NLP 中概念,由具有许多参数的人工神经网络组成,使用自监督学习或半监督学习对大量未标记文本进行训练。", }, { [input]: "冰箱时如何制冷的?", [output]: "冰箱制冷的基本原理是通过循环工作流体(通常是制冷剂)在封闭系统中的状态变化来实现的,其基本步骤包括:压缩、冷凝、膨胀、蒸发、返回压缩机。这个循环不断重复,从而持续地将冰箱内部的热量转移到外部环境中,实现制冷效果。冰箱的制冷系统设计得非常高效,能够在不消耗过多电能的情况下保持恒定的低温。", }, { [input]: "小明在淘宝上花了 30 元买了一个风扇,他是 88VIP 打了 5 折,并且使用了一个 5 元的优惠券,这个风扇原价多少钱?", [output]: "65元", }, { [input]: "为什么要给猫绝育?", [output]: "给猫咪绝育有以下几点好处,一是能够控制繁殖,二是能够减少猫的攻击性行为,三是能够延长猫的寿命。", }, { [input]: "Python 中如何打印“Hello, World”", [output]: "如果使用 Python2.x,使用 `print 'Hello, World'`,如果使用 Python3.x,使用 `print('Hello, World')`", }, { [input]: "过量摄入盐分有什么坏处?", [output]: "过量摄入盐分(钠)可能会对人体健康产生多种负面影响,包括:\n- 高血压:高盐饮食是导致高血压的主要因素之一。高血压会增加心脏病、中风和肾脏疾病的风险。\n- 心血管疾病:长期高盐摄入可能导致动脉硬化和心血管疾病,包括心脏病和中风。\n- 肾脏问题:过多的盐分需要肾脏更努力地工作来排除体外,这可能导致肾脏负担加重,长期可能损害肾脏功能。\n- 水肿:摄入过多的盐分可能导致体内水分潴留,引起水肿,尤其是在脚踝和腿部。\n- 骨质疏松:高盐饮食可能导致钙质从尿液中排出增加,这可能与骨质疏松和骨折风险增加有关。\n- 胃癌风险增加:一些研究表明,高盐饮食可能与胃癌风险增加有关。 \n- 影响药物效果:高盐饮食可能影响某些药物的效果,如利尿剂和降压药。", }, { [input]: "断食是否真的对健康有益处?", [output]: "断食,即在一定时间内不摄入或极少摄入食物,是一种古老的实践,近年来因其潜在的健康益处而受到科学界的关注。断食的方式多种多样,包括间歇性断食(如16/8断食法,即每天在8小时内进食,其余16小时断食)、周期性断食(如5:2断食法,即一周中选择两天摄入极低热量)和长期断食(如连续24小时或更长时间不进食)。然而,断食并不适合所有人,特别是孕妇、哺乳期妇女、儿童、老年人、体重过轻者、有进食障碍史的人以及某些慢性疾病患者。此外,断食可能会导致一些短期副作用,如饥饿、疲劳、头痛和情绪波动。在考虑断食之前,最好咨询医生或营养专家,以确保这种做法适合您的个人健康状况,并了解如何安全地实施断食。此外,断食期间的营养摄入也非常重要,以确保身体获得必需的营养素。", }, ]; async function main() { const input = "问题"; const output = "回答"; const module = new CoTModule(input, output); const compiler = new FewShotsTeleprompters(TEST_DATA); const module_compiled = await compiler.compile(module); console.log("[INFO] best module is ", module_compiled); console.log("----------"); if (!module_compiled) { console.log("[ERROR] something wrong"); return; } for (const data of test_data) { console.log(`[INFO] ${input}${data[input]}\n---`); const [a1, a2] = await Promise.all([ module.run(data[input]), module_compiled.run(data[input]), ]); console.log(`[INFO] expected ${output}${data[output]}\n---`); console.log(`[INFO] module ${output}${a1}\n---`); console.log(`[INFO] best module ${output}${a2}\n---`); console.log("----------"); } } main();

这段代码定义了两个类:

CotModule
FewShotsTeleprompters
。其中:

  • CotModule
    就是一个最简单的 Cot Prompt;
  • FewShotsTeleprompters
    使用给定的数据集对传入的 Module 进行优化,编译流程如下:
  1. 针对不同的
    Module
    (仅实现了
    CotModule
    ),使用 LLM 对给定的示例数据中特定数量的“问题-回复”对构建 CoT 回复
  2. 复制特定数量的
    CoTModule
    ,为得到的每个
    CotModule
    (students) 随机选择几个示例回复添加到原 Prompt 中构造成新的 Prompt;
  3. 从数据集中选择未被使用过的“问题-回复”对,让添加示例后的
    CotModule
    进行回复
  4. 使用 LLM 对这些
    CotModule
    的回复进行评分,选择其中得分最高的一个作为编译后的
    CotModule

就结果而言,感觉似乎“编译”后的

CotModule
的回复似乎更好,但是因为使用的是自己编写的“问题-回复”对,无法确定这种“感觉”是不是自己的幻觉。

注意:上述代码仅是个人理解编写的,如果希望准确的理解 DSPy 的运行机制,请查阅源代码。

heading

关于 DSPy 应用场景的一些想法

就目前对 DPSy 的理解来看,在实际应用中使用 DSPy 依旧存在一定的门槛,主要原因在于使用 DPSy 构建 LLM 程序需要有一套符合实际业务需求的数据集(在论文中使用的是 GMS8K、HotPotQA),并且使用编译的方式创建应用程序,为了取得好的效果,需要的成本是否真的能比“手写” Prompt 更少这点似乎并没有具体的落地案例(在官方 Repo 中似乎有相关的内容,但是没有细看)。至少目前在“手写” Prompt 能够实现目标的情况下,没有太大的必要在生产环境下使用 DSPy 重构 LLM 应用程序。

话虽如此,使用 DSPy 优化端侧模型的效果似乎是一个不错的选择,由于小模型通常难以 Follow Prompt,通过自动化的方式生成对小模型也生效的 Prompt 会比手工编写、验证 Prompt 来得高效得多。

即使不使用 DSPy,其中“编译”这一抽象还是非常值得借鉴的,在构建 LLM 应用时,为 Prompt 建立一个评估系统和数据集,能够很好的保证 LLM 应用程序整体的鲁棒性和可用性。