ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

使用DSPy实现提示词自动优化:从手工调词到声明式编程

使用DSPy实现提示词自动优化:从手工调词到声明式编程

1. 为什么我放弃了手写Prompt,改用DSPy优化提示词

先交代一下背景。过去一年我大部分时间都在跟大模型打交道,做各种自然语言处理相关的原型和落地项目。早期阶段,我的工作模式非常简单粗暴:把任务描述清楚,丢给模型,看输出,然后根据输出不断微调提示词。这个流程听起来合理,实际跑起来却非常折磨人——尤其当任务稍微复杂一点,比如涉及多步骤推理、工具调用、结构化输出时,提示词的任何一个措辞变化都可能导致结果剧烈波动。

我试过各种技巧:few-shot示例轮换、CoT链路改写、格式约束、自我一致性投票,全都有效果,但效率极低。每次调整一个词、换一个示例,都要手动跑一遍完整流程,然后肉眼对比输出质量。项目里的提示词越攒越多,维护成本越来越高,最后几乎变成一团乱麻。

后来我接触到了DSPy,一个专门针对提示词和推理管道做系统性优化的框架。它的核心思路和传统方式完全相反:不再靠人肉调Prompt,而是把提示词当作可优化的参数,通过编译、评估、自动迭代来找到最优组合。说白了,DSPy是在用工程化的方式替代手工调词,让提示词优化变成一条可量化的流水线。

这篇文章我不会去复述官方文档,而是想把我在实际项目里用DSPy优化提示词的完整过程、踩过的坑、以及最终沉淀下来的方法论,一次性讲清楚。适合谁看?如果你也在做基于大模型的应用开发,尤其是那些提示词反复改、效果不稳定、评估标准模糊的项目,这篇文章应该能帮你省下不少时间。

2. 从手工调词到声明式编程:DSPy的核心设计思想

2.1 传统提示词工程最大的问题不是“写”,而是“调”

先说一个很多人忽略的事实:提示词工程的瓶颈从来不在于写第一版Prompt,而在于后续的持续调优。

比如你要做一个文本分类器,给模型写了一段指令,测试下来准确率不够。你会怎么办?大概率是改几个示例、调整一下输出格式要求、或者把任务描述换一种说法,然后重新测试。这个循环看起来简单,但每次只能验证一个变量的影响,而且不同变量之间还会互相干扰。你今天把示例从3个加到5个,准确率提升了2%,但明天换了一批测试数据,这2%可能就消失了。你以为是提示词的问题,实际可能是示例选取偏差导致的偶然波动。

手工调词的另一个致命问题是不可复现。你的调优经验全部存在脑子里,换一个人来接手同一个项目,他完全不知道你为什么用这个句式、为什么选这些示例,只能从头开始踩坑。项目一多,这种隐性知识的流失非常严重。

2.2 DSPy把提示词变成了程序的参数

DSPy解决这个问题的思路很彻底。它不再把你写的自然语言指令当成“最终答案”,而是当成一个初始化状态。你定义好任务、数据、评估函数,然后框架会自动生成多个候选Prompt,在训练集上评估效果,再通过优化算法迭代出最优版本。

换句话说,DSPy把“调Prompt”这件事从直觉驱动变成了数据驱动。你不是在“写”提示词,而是在“声明”任务的结构,剩下的交给编译器去探索。

我个人的理解是,DSPy的核心贡献是用一套类似传统机器学习的框架来治理LLM应用开发中的不确定性。它把LLM调用封装成可组合的模块,把提示词和思维链示例当作可调参数,然后用优化器自动搜索优质参数组合。这个设计理念,本质上是在告诉开发者:与其跟模型的随机性对抗,不如把对抗过程变成算法的一部分。

2.3 DSPy的三个核心抽象:Signature、Module、Optimizer

如果你第一次接触DSPy,我建议先把这三个概念吃透,因为它们就是整个框架的骨架。

Signature(签名)描述输入输出之间的语义映射。你不需要写完整的Prompt,只需要定义“输入是什么、输出是什么、任务目标是什么”。DSPy会根据这个签名自动生成Prompt的骨架。

举个例子,做情感分类时,签名可以这样定义:

class SentimentClassification(dspy.Signature): """判断一段文本的情感倾向。""" text: str = dspy.InputField(desc="待分类的文本内容") sentiment: str = dspy.OutputField(desc="只能是positive、neutral或negative之一")

你看,这里完全没有写“请你仔细阅读以下文本,然后判断情感倾向,你的回答必须……”之类的长指令。DSPy会自动把这些描述组织成完整的Prompt。

Module(模块)是任务的执行单元。你可以用预置的dspy.Predict直接调用签名,也可以用dspy.ChainOfThought在预测前插入推理步骤,或者用dspy.ReAct实现带工具调用的Agent行为。模块可以嵌套组合,构建成复杂的管道。

classify = dspy.ChainOfThought(SentimentClassification)

一行代码,就让模型在输出答案之前先进行一步思考,不需要你手动写“让我们一步一步思考”。

Optimizer(优化器)是自动调优的核心。它负责生成候选Prompt、评估结果、迭代更新。常用的优化器包括BootstrapFewShot、BootstrapFewShotWithRandomSearch、MIPROv2等,各自适用于不同场景。

这里需要强调一点:DSPy优化的核心是少样本示例,而不是像微调那样更新模型权重。它通过自动构造高质量示例来“教”模型完成任务,所以速度快、成本低,不需要GPU训练。

3. 工具选型解析:为什么选择DSPy而不是其他方案

3.1 与手写Prompt工程的对比

很多开发者会问:我手写Prompt已经能解决80%的问题,为什么还要学DSPy?

我的回答是:如果你的任务非常简单,比如单一分类、简单抽取、固定格式生成,手写Prompt完全够用,没必要上框架。但一旦你的任务满足以下任一条件,手写Prompt的维护成本会急剧上升:

  • 任务涉及多个处理步骤,中间结果需要传递给后续模块
  • 输出格式必须严格结构化,且字段较多
  • 需要定期在新增数据上更新示例
  • 团队成员需要协作维护同一套Prompt逻辑
  • 评估指标不单一,需要综合多个维度判断效果

DSPy的价值在于把这些复杂场景工程化。你定义一次管道,之后数据更新、示例优化都可以自动完成。手写Prompt是手工作坊,DSPy是流水线生产,两者定位不同,不是谁替代谁的问题。

3.2 与LangChain的定位差异

有人会把DSPy和LangChain放在一起比较,其实它们解决的问题不一样。LangChain的核心是编排——管理LLM调用、工具、记忆、外部API的连接关系,相当于给你一套乐高积木。而DSPy的核心是优化——在给定管道结构的前提下,自动寻找最优的Prompt参数,相当于给你一个自动调音器。

实践中两者可以配合使用:用LangChain搭建应用骨架,把DSPy优化好的模块嵌入其中。不过我自己更倾向于在一个项目里尽量保持技术栈的统一,避免引入过多抽象层。如果主任务是提示词优化,直接用DSPy就够了。

3.3 可选方案横向对比

方案核心机制适用场景局限性
手写Prompt调优人工迭代,经验驱动简单任务、快速原型效率低、不可复现、变量耦合
DSPy声明式编程 + 自动优化复杂管道、持续迭代、团队协作学习曲线陡峭、抽象层次多
LangChain模块编排、工具集成Agent应用、多工具协同不提供Prompt自动优化能力
微调更新模型权重需要深度改变模型行为成本高、数据要求大、周期长

DSPy在“成本可控”和“自动化程度高”之间取得了不错的平衡,这也是它在开发者社区快速升温的原因。

4. 实操过程与核心环节实现:用DSPy优化一个多分类任务的完整记录

4.1 项目背景与任务定义

我拿一个实际做过的项目来演示。项目目标是做一个“技术问题工单自动分类”系统,输入是用户提交的技术工单描述,输出是5个类别之一:网络故障、账号权限、软件异常、硬件故障、数据处理问题。

这个任务看起来很简单,但实际做起来有几个难点:

  • 用户描述口语化严重,很多还夹杂错别字和英文缩写
  • 类别之间存在语义重叠,比如“软件异常”和“数据处理问题”边界模糊
  • 数据分布极不均衡,网络故障类样本非常多,硬件故障类样本很少
  • 需要输出置信度信息,便于后续人工复核

如果完全手写Prompt,这些难点每个都需要单独设计应对策略。而用DSPy,我可以把大部分策略转换成数据约束和评估函数。

4.2 环境准备与基础配置

首先安装DSPy,我使用的是Python 3.10环境:

pip install dspy-ai

然后需要配置语言模型和后端。目前DSPy支持OpenAI、Anthropic、本地模型等多种后端。我演示时用的是OpenAI接口,配置方式如下:

import dspy # 使用gpt-4o-mini作为主模型 lm = dspy.LM( model="openai/gpt-4o-mini", api_key="你的API_KEY", temperature=0.3 ) dspy.configure(lm=lm)

这里有几个关键选择需要说明。

temperature设置为0.3,而不是0,是因为0虽然能最大程度保证确定性,但会让模型缺乏多样性,影响Bootstrap阶段采集候选示例的有效性。我实测下来,0.2到0.4之间是比较合适的区间。

api_key不建议硬编码在代码里,建议使用环境变量管理,方便多人协作和部署切换。

4.3 定义签名:把任务说清楚

接下来定义工单分类的签名。这一步是整个流程的地基,因为DSPy会基于签名自动生成Prompt。如果签名描述不够清晰,生成的Prompt就先天不足,后期优化也很难弥补。

class TicketClassification(dspy.Signature): """对技术工单进行精确分类。 注意:类别之间可能存在语义重叠,请综合判断最合理的类别。""" ticket_text: str = dspy.InputField(desc="用户提交的技术工单完整内容") category: str = dspy.OutputField(desc="分类结果,必须是以下之一:网络故障、账号权限、软件异常、硬件故障、数据处理问题") confidence: float = dspy.OutputField(desc="置信度打分,0到1之间,表示本次分类的确信程度")

这里有个值得注意的细节:我在Docstring里写了“类别之间可能存在语义重叠,请综合判断”,这句话不是随便写的。根据我之前的测试,如果不在文档字符串里显式提示重叠问题,模型在遇到边界模糊样本时更容易给出错误分类。DSPy生成的Prompt会很自然地把这个提示融入指令中。

我最初也试过在OutputField的desc里写“必须返回JSON格式”,但效果反而不稳定。DSPy有自己的结构化输出机制,它会自动将多个输出字段组合成JSON格式,不需要你手动要求。过度指定格式约束反而会限制优化器的搜索空间。

4.4 准备数据集与评估函数

在DSPy的体系里,数据分为两类:用于优化的训练集,和用于验证效果的开发集。

我整理了约400条带标注的工单数据,其中300条作为训练集,100条作为开发集。数据格式如下:

# 每条数据是一个dspy.Example对象 train_dataset = [ dspy.Example( ticket_text="公司内网无法访问,ping网关不通,同事电脑也有同样问题", category="网络故障", confidence=0.95 ), dspy.Example( ticket_text="登录ERP系统提示密码过期,但三天前刚改过密码", category="账号权限", confidence=0.9 ), # ... 共300条 ].with_inputs("ticket_text")

特别注意with_inputs("ticket_text")这一行。它标记了哪些字段是输入、哪些字段是输出。在DSPy v2.8以上的版本里,训练集必须明确指定输入字段,否则后续Bootstrap阶段会报错或行为异常。这个坑我踩过,一开始没加,结果优化器在生成示例时把标准答案也当成输入喂给模型,导致性能非常差。

评估函数的设计也很重要。对于分类任务,准确率是最直观的指标,但如果只算准确率,模型可能会倾向于把模糊样本分到占比最高的类别来“蒙混过关”。所以我额外加了一个惩罚机制:对“软件异常”和“数据处理问题”这两个易混淆类别之间的误判,扣分加倍。

def evaluate(example, pred, trace=None): if example.category == pred.category: return True # 易混淆类别之间的误判,额外扣分 confusing_pairs = {("软件异常", "数据处理问题"), ("数据处理问题", "软件异常")} if (example.category, pred.category) in confusing_pairs: return False return False

这个评估函数只返回布尔值,DSPy在编译时会统计所有样本的真假结果来计算整体性能。

4.5 选择优化器:从零样本到自动少样本的完整链路

定义好模块之后,可以先做一个零样本版本的基准测试,这一步很有价值,它告诉你“不优化”时的效果底线。

classify_module = dspy.Predict(TicketClassification) # 在开发集上评估 evaluator = dspy.Evaluate(metric=evaluate, devset=dev_dataset, num_threads=4) baseline_score = evaluator(classify_module) print("零样本基线准确率:", baseline_score)

我在这个任务上测出来的基线准确率大约是78%。对于这个结果我并不意外,因为类别重叠问题天然难处理。

接下来进入核心环节:使用优化器自动生成和筛选少样本示例。这里我选择了BootstrapFewShotWithRandomSearch,它会在训练集上进行随机搜索,找到最优的示例组合。

from dspy.teleprompt import BootstrapFewShotWithRandomSearch optimizer = BootstrapFewShotWithRandomSearch( metric=evaluate, max_bootstrapped_demos=8, max_labeled_demos=16, num_candidate_programs=6, num_threads=4 ) compiled_predictor = optimizer.compile( student=classify_module, teacher=classify_module, trainset=train_dataset )

参数含义说明:

  • max_bootstrapped_demos:最多生成8个由模型推理得到的示例。这类示例包含完整的推理过程,信息量更大。
  • max_labeled_demos:最多16个直接从训练集选取的标注示例。
  • num_candidate_programs:尝试6组不同的候选程序,每组对应一套不同的示例组合和Prompt表述,最终保留效果最好的一组。
  • teacher参数指向一个效果更好的教师模型。如果只有学生模型,可以让学生自己生成示例;如果你有更强模型的API,可以用它来生成更高质量的Bootstrap示例。

编译完成后,再跑一次开发集评估:

compiled_score = evaluator(compiled_predictor) print("优化后准确率:", compiled_score)

在我的测试数据上,优化后准确率从78%提升到了86.5%。别小看这8.5个百分点的提升,在工单分类场景里,这相当于减少了约四成的错分工单,对后续自动流转环节的影响非常大。

4.6 用MIPROv2进一步探索Prompt指令

如果你的任务更复杂,或者BootstrapFewShot的示例优化效果已经到瓶颈,可以尝试MIPROv2优化器。它不仅能优化示例,还会对Prompt的描述指令本身进行搜索优化。

MIPROv2的用法类似,但编译时间明显更长,因为它要遍历更多候选指令和示例组合。

from dspy.teleprompt import MIPROv2 optimizer = MIPROv2( metric=evaluate, num_candidates=10, max_bootstrapped_demos=8 ) compiled_predictor = optimizer.compile( student=classify_module, trainset=train_dataset, eval_set=dev_dataset )

如果你是API按次数计费,需要注意MIPROv2的token消耗非常大,因为它会为每条候选指令和每个示例组合都跑一次完整的评估。建议先用小数据集试跑,确定参数可行性再扩大规模。

4.7 核心代码完整串联

把整个流程串联起来,核心代码结构如下:

import dspy from dspy.teleprompt import BootstrapFewShotWithRandomSearch # 配置模型 lm = dspy.LM(model="openai/gpt-4o-mini", temperature=0.3) dspy.configure(lm=lm) # 定义签名 class TicketClassification(dspy.Signature): """对技术工单进行精确分类。 类别之间可能存在语义重叠,请综合判断最合理的类别。""" ticket_text: str = dspy.InputField(desc="用户提交的技术工单完整内容") category: str = dspy.OutputField(desc="分类结果:网络故障、账号权限、软件异常、硬件故障、数据处理问题") confidence: float = dspy.OutputField(desc="置信度打分,0到1之间") # 定义评估指标 def evaluate(example, pred, trace=None): if example.category == pred.category: return True confusing_pairs = {("软件异常", "数据处理问题"), ("数据处理问题", "软件异常")} if (example.category, pred.category) in confusing_pairs: return False return False # 创建基础模块,跑零样本基线 base_predictor = dspy.Predict(TicketClassification) evaluator = dspy.Evaluate(metric=evaluate, devset=dev_dataset, num_threads=4) print("基线:", evaluator(base_predictor)) # 编译优化 optimizer = BootstrapFewShotWithRandomSearch( metric=evaluate, max_bootstrapped_demos=8, max_labeled_demos=16, num_candidate_programs=6, num_threads=4 ) compiled = optimizer.compile( student=base_predictor, trainset=train_dataset ) # 评估优化结果 print("优化后:", evaluator(compiled))

这套代码跑下来,一次编译在300条训练数据上大约耗时5到8分钟(取决于API响应速度),token消耗大约在15万到30万之间。相比手工调Prompt消耗的几小时精力,这个成本完全可以接受。

5. 常见问题与排查技巧实录

5.1 优化器编译后效果反而变差了怎么办

这是最常遇到的问题。很多人在第一次使用DSPy时满怀期待,结果编译完一测,准确率反而比零样本基线更低。

先说一个最容易忽略的原因:训练集数据质量参差,或者标注标准不一致。DSPy的优化器会从训练集中采样示例,如果某条示例的标注本身就是错的,Bootstrap阶段还会把错误扩展成带推理过程的错误示例,误导模型。解决方法是先检查训练集标注一致性,剔除明显错误和边缘争议样本。

第二个原因是评估函数设计不当。如果你的指标是准确率,但样本类别不均衡,优化器会倾向于把所有样本都归类到多数类,因为这样准确率最高。解决办法是换用F1分数,或者像我前面那样对易混淆类别增加惩罚。

第三个原因是关键:示例数量设置过大。很多人以为示例越多越好,但模型能容纳的有效上下文是有限的。示例占了太多上下文空间,反而挤压了指令信息的比重,导致效果下降。根据我的经验,对于普通分类任务,6到10个示例通常足够;超过12个效果就开始边际递减甚至下降。你可以用小步调整示例数量,跑几次对比。

5.2 编译过程Token消耗太高,如何控制成本

Token消耗是DSPy编译绕不开的问题,尤其在MIPROv2里。这里分享几个实测有效的控制手段:

  • 先用小训练集试跑,比如50条数据,验证参数设置合理后再扩大到完整集
  • 降低num_candidate_programs,比如从10降到4,以搜索次数换取成本降低
  • 使用更便宜的模型做Bootstrap阶段的示例生成,最终推理时才用主力模型
  • 如果任务不复杂,优先使用BootstrapFewShot而不是随机搜索版本,消耗能减少一半以上

5.3 模型输出字段格式不规范,频繁解析失败

这通常不是DSPy的问题,而是签名中OutputField的描述不够明确。如果你发现返回的JSON字段经常缺少或格式不对,优先检查desc是否写清楚了每个字段的含义和约束。另一个常见原因是你使用了过高的temperature,比如0.7以上时模型可能输出发散。建议将temperature控制在0.2到0.4之间。

5.4 训练集和开发集指标差异很大,是不是过拟合了

如果你发现开发集上的准确率远低于训练集,大概率是训练集中的数据分布和真实场景分布不一致,而不是模型的过拟合。因为DSPy优化的是Prompt示例,不是模型权重,真正的过拟合风险比微调低很多。此时更重要的是检查数据集的分布是否覆盖了真实场景的边界情况,建议从线上采一些真实数据加入训练集。

5.5 一个容易被忽略的坑:版本差异导致API行为不同

DSPy的版本迭代非常快,不同版本之间的API变动很大。我在一个项目中遇到的情况是,同事用的旧版本dspy.teleprompt路径和我的新版完全不同,导致代码无法直接复用。如果你参考网络上的教程,务必先确认教程对应的版本号,最好在自己的环境里查看当前版本的API签名。

检查方式:

import dspy print(dspy.__version__)

6. 从分类任务到复杂管道:DSPy的进阶玩法

6.1 用ChainOfThought模块构建推理管道

分类任务只是DSPy的入门级应用,它的真正威力在于构建多步骤推理管道。

比如我之前做过一个“工单自动解决方案生成器”,流程是:先判断工单类别,再提取关键信息,最后生成推荐解决方案。这三个步骤如果分开写Prompt,需要维护三套提示词,中间结果的传递还要写大量解析代码。用DSPy可以直接声明一条管道:

class SolutionGenerator(dspy.Module): def __init__(self): super().__init__() self.classify = dspy.ChainOfThought(TicketClassification) self.extract = dspy.ChainOfThought(InfoExtraction) self.solution = dspy.ChainOfThought(SolutionGeneration) def forward(self, ticket_text): cls_result = self.classify(ticket_text=ticket_text) ext_result = self.extract(ticket_text=ticket_text, category=cls_result.category) sol_result = self.solution(ticket_text=ticket_text, info=ext_result.info) return sol_result.solution

你只需要定义好每一步的签名,DSPy会自动把中间结果拼接成后续步骤的Prompt上下文。整个管道可以在训练数据上统一优化,不用一个模块一个模块地单独调。

6.2 人机协同的评估闭环

虽然DSPy实现了自动化优化,但我仍然建议在流程中加入一部分人工审核环节。我的做法是:优化完成后,随机抽取50条开发集样本,输出模型的分类和置信度,然后由人工快速复核。重点看两类——高置信度但分类错误的,和低置信度但分类正确的。这两类数据最能反映问题:前者说明模型在某些情况下“过于自信”,后者说明模型可能漏掉了一些它实际掌握的信息。把这类错误案例补充进训练集,再做一轮编译,效果往往立竿见影。

6.3 成本与延迟的平衡策略

优化不是越充分越好,尤其是线上场景,模型调用成本和使用延迟都是需要平衡的约束。

我建议在项目初期用较强的模型做优化探索,确定任务的“性能上限”。这个上限的意义在于:如果强模型优化后准确率只有82%,说明任务本身的标注一致性或数据质量有问题,靠调提示词是突破不了的。这时候应该先提升数据质量,而不是继续堆优化资源。

一旦确认任务可行,再考虑用更小、更便宜的模型做压缩。DSPy的Bootstrap机制很适合这个场景:用强模型生成高质量的示例集,然后把这些示例固定下来,换用小模型配合固定示例推理,往往能在成本和效果之间找到不错的平衡点。

7. 最后分享一点个人的实际体会

从接触DSPy到现在,我在三个不同类型的项目里实践过它:工单分类、信息抽取、多步骤解决方案生成。总的来说,DSPy不是万能的,它解决的是“提示词优化”这个特定问题,而不是整个LLM应用开发的所有问题。

我最深的一个体会是:DSPy的学习曲线虽然陡峭,但它的核心思想其实很简单——把调Prompt从玄学变成科学。一旦习惯了“定义签名、准备数据、评估、编译、评估”这个循环,你会觉得手工调Prompt的时代真的过去了。

如果你现在的项目正卡在“Prompt怎么改都不稳定”的阶段,我建议你拿一个典型的任务,准备几百条带标注的数据,花半天时间跑一遍DSPy的流程。哪怕最终效果提升不多,你也会对自己的任务有更清晰的认知——因为评估函数会逼你想清楚“到底什么算好的输出”,而这本身就是提示词工程里经常被忽略的关键环节。

最后再分享一个小技巧:DSPy编译完成后,会把最终使用的Prompt和示例保存下来。你可以打印出来看看,很多时候你会惊讶于优化器生成的Prompt比你手写的版本要简洁得多,但效果反而更好。这恰恰印证了那句老话——好的Prompt不是写出来的,是选出来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进