自动提示词优化(DSPy)框架与应用实践
自动提示词优化(DSPy)框架与应用实践
从「炼金术」到「工程学」—— 大语言模型应用的范式转变
引言:提示工程的困境
在过去两年 LLM 应用开发的实践中,一个反复出现的模式是:提示工程(Prompt Engineering)虽然门槛低,但工程成本极高。开发者不断尝试不同的措辞、格式和示例,试图匹配模型的理解偏好。这个过程有三个结构性缺陷:
| 痛点 | 表现 | 工程后果 |
|---|---|---|
| 脆弱性 | 一个词的替换、语序调整 → 输出剧烈波动 | 无法保证生产环境稳定性 |
| 不可维护性 | 提示膨胀为「指令 + 示例 + 格式化」的庞大整体 | 难以测试、难以扩展、难以交接 |
| 不可复现性 | 相同提示在不同模型、不同版本下表现各异 | 缺乏系统工程保障 |
本质问题:传统提示工程是一门「手艺」而非「工程学科」—— 它依赖个人经验和直觉,缺乏系统性方法论。
DSPy 的核心理念
DSPy(Declarative Self-improving Python)是斯坦福大学 NLP 团队提出的框架。它试图为 LLM 应用开发带来类似 PyTorch 时刻的范式转变:
- PyTorch 之前:手工管理神经网络权重、手算梯度 —— 繁琐易错
- PyTorch 之后:
nn.Module抽象 +optimizer.step()自动化 —— 专注架构设计 - DSPy 的目标:用可组合的**模块(Modules)替代手写提示技巧,用优化器(Optimizers)**自动搜索最优提示参数
| 维度 | 传统提示工程 | DSPy |
|---|---|---|
| 核心范式 | 手写自然语言指令 | 声明式 Python 程序 |
| 优化方式 | 人工试错 | 算法自动搜索 |
| 可复现性 | 低(依赖个人经验) | 高(数据和指标驱动) |
| 跨模型适用 | 每个模型重新调试 | 同一程序编译到不同模型 |
| 维护成本 | 每次迭代重新调试 | 重新编译即可 |
DSPy 核心组件
DSPy 的编程模型建立在三个核心组件之上:
签名(Signature):声明任务规范
签名是 DSPy 最基本的抽象。它将手写提示替换为声明式规范——只需要描述「做什么」,不需要指定「怎么做」。
# 方式一:字符串简洁定义
"question -> answer"
"context, question -> answer"
# 方式二:类定义(推荐生产使用)
class AddressExtract(dspy.Signature):
"""任务说明"""
text: str = dspy.InputField(desc="输入的描述")
result: dict[str, str] = dspy.OutputField(desc="输出结果的描述")
类定义方式包含五个语义组件:类名描述任务、docstring 给出任务级指令、InputField 和 OutputField 定义参数及描述、类型注解指导编译器生成格式化指令。DSPy 从这些信息中自动生成初始提示词,开发者无需手写任何自然语言指令。
模块(Module):实现推理策略
模块负责将签名的声明转化为实际的 LLM 调用逻辑,类比 PyTorch 中的神经网络层:
| 模块 | 推理策略 | 适用场景 |
|---|---|---|
dspy.Predict |
直接生成,无中间推理 | 简单分类、信息提取 |
dspy.ChainOfThought |
生成推理链后输出答案 | 需要多步推理的复杂任务 |
dspy.ReAct |
推理-行动-观察循环,支持工具调用 | 需要外部工具的 Agent 场景 |
dspy.ProgramOfThought |
生成并执行代码来推导答案 | 数学推理、代码生成 |
dspy.MultiChainComparison |
生成多条推理链并比较 | 需要高置信度的决策 |
模块组合:构建多步处理流水线
模块可以像 PyTorch 的 nn.Sequential 一样组合,构成多阶段的处理流水线。DSPy 提供了 dspy.Chain 来串联多个模块,也支持通过继承 dspy.Module 自定义复杂拓扑。
class AddressPipeline(dspy.Module):
def __init__(self):
super().__init__()
self.normalize = dspy.Predict(NormalizeSignature) # 步骤1: 文本规范化
self.extract = dspy.ChainOfThought(ExtractSignature) # 步骤2: 地址提取
self.validate = dspy.Predict(ValidateSignature) # 步骤3: 结果校验
def forward(self, text: str):
cleaned = self.normalize(text=text)
result = self.extract(text=cleaned.output)
final = self.validate(text=text, extracted=result.output)
return final
这种设计使得整个流水线可以端到端联合优化——编译器会同时考虑所有模块之间的交互,而不是逐节点独立调优。这是 DSPy 区别于 LangChain、LlamaIndex 等框架的核心优势。
优化器(Optimizer):自动搜索最优提示
优化器是 DSPy 的核心竞争力。它们自动搜索最优的提示参数(指令措辞、Few-shot 示例选择),最大化预定义的评估指标。
| 特性 | BootstrapFewShot | MIPROv2 | GEPA |
|---|---|---|---|
| 核心机制 | 从训练集采样成功示例 | 贝叶斯搜索 + 多指令候选 | 文本反思 + 帕累托进化 |
| 优化粒度 | 仅优化 Few-shot 示例 | 同时优化指令 + 示例 | 指令 + 示例 + 推理策略 |
| 数据需求 | ~50+ 样本 | ~50+ 样本 | 仅需 3 个样本即可启动 |
| 运行成本 | 低(纯采样) | 中(多轮贝叶斯搜索) | 中($2~10/次) |
| 指令优化 | 不修改指令 | 贝叶斯搜索最优指令 | 反思式指令变异 |
| 理论基础 | DSPy 基础论文 | MIPRO 系列论文 | ICLR 2026 Oral |
| 适用场景 | 快速原型验证 | 追求生产级最佳效果 | 极致性能 + 数据稀缺 |
BootstrapFewShot 的核心逻辑:从训练集抽取样本 → 用当前程序处理 → 筛选预测正确的作为 Demonstrations → 追加到 Prompt 中。指令本身不变,适合签名已足够清晰的场景。
MIPROv2 在此基础上加入指令优化,分三阶段运作:
- Bootstrap:生成候选 Few-shot 示例集
- Propose:基于任务描述,让 LLM 结合 6 类提示策略模板生成多样化的候选指令
- Search:将指令选择和示例选择作为两个优化维度,用贝叶斯搜索找出全局最优组合
GEPA(ICLR 2026 Oral)是最新一代优化器,核心创新是用 LLM 的文本反思能力替代强化学习的梯度更新:Select → Execute → Reflect → Mutate → Accept。它仅需 3 个样例即可启动,在 ARC-AGI Agent 任务上将成功率从 32% 提升到 89%。
使用 DSPy 的标准工作流
DSPy 的开发流程分为四个阶段:
关键特点是声明式定义在前:开发者只需定义任务签名和评估指标,编译器负责后续所有的提示工程工作。优化后的程序可以序列化保存(通过 program.save()),作为标准化产物进行版本管理;新模型上线时,重新运行编译即可自动适配,无需人工重写提示词。
实战案例:中文地址结构化提取
下面通过一个真实的中文地址提取项目,演示 DSPy 的完整实践流程。
数据清洗与转换
本案例原始的数据集来自CCKS2021 中文地址解析学术评测任务。可以使用示例代码里的conll_to_json.py将原始的BIOES格式表述的数据转换为JSON格式,并清理掉不关注的标签。
浙 B-prov
江 I-prov
省 E-prov
- O
温 B-city
州 E-city
- O
瑞 B-district
安 I-district
市 E-district
滨 B-district
江 I-district
区 E-district
滨 B-road
盛 I-road
路 E-road
钱 B-poi
塘 I-poi
帝 I-poi
景 E-poi
转换为:
[
{
"text": "浙江省-温州-瑞安市",
"label": {
"province": "浙江省",
"city": "温州",
"district": "瑞安市"
}
},
{
"text": "滨江区滨盛路钱塘帝景",
"label": {
"district": "滨江区",
"road": "滨盛路",
"poi": "钱塘帝景"
}
}
]
任务定义与数据
任务目标:将非结构化的中文地址文本解析为结构化标签。
输入: "浙江省杭州市萧山区南秀路杭州萧然医药有限公司"
输出: {
"province": "浙江省",
"city": "杭州市",
"district": "萧山区",
"road": "南秀路",
"poi": "杭州萧然医药有限公司"
}
需要提取 10 类标签:省、市、区/县、镇/乡/街道、村/社区、开发区、兴趣点、道路名、道路门牌号、楼栋号。
数据概览:
| 数据集 | 数量 | 格式 | 用途 |
|---|---|---|---|
train.conll |
7,048 条 | 字符级 BIO 标注 | 原始训练数据源 |
fewshot.json |
50 条 | text → label JSON | DSPy 优化训练集 |
eval.json |
200 条 | text → label JSON | 评估测试集 |
标签分布(评估集 200 条 / 优化集 50 条):
| 标签 | 评估集 | 优化集 | 标签 | 评估集 | 优化集 |
|---|---|---|---|---|---|
| district | 135 | 34 | city | 96 | 23 |
| poi | 134 | 35 | town | 86 | 26 |
| road | 121 | 31 | province | 74 | 24 |
| road_number | 82 | 21 | house_number | 50 | 16 |
| community | 32 | 9 | dev_zone | 16 | 3 |
数据呈长尾分布。优化集中 community 仅 9 例、dev_zone 仅 3 例——这个细节在后续结果分析中将变得非常重要。
方案 A:传统手工提示词
这是大多数团队的做法——精心编写 System Prompt:
# infer.py
SYSTEM_PROMPT = """你是一个中文地址解析专家。给定一个中文地址文本,
请从中提取以下信息,用JSON格式输出,没有的字段不要输出。
字段说明:
- province: 省/自治区/直辖市
- city: 市
- district: 区/县/县级市
- town: 镇/乡/街道
- community: 村/社区
- dev_zone: 开发区/工业园区
- poi: 兴趣点(小区/大厦/商场/公司/机构等)
- road: 道路名
- road_number: 道路门牌号
- house_number: 楼栋号/幢号
只输出JSON对象,不要包含任何解释或其他内容。"""
async def infer_one(text: str) -> dict:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text},
]
response = await litellm.acompletion(
model="deepseek/deepseek-v4-flash",
messages=messages,
temperature=0,
)
return parse_response(response.choices[0].message.content)
这 15 行 System Prompt 是经过多轮手工调试的产物,编码了大量领域知识。它的效果确实可用,但存在明显的工程瓶颈:换模型需要重调、加字段需要扩充描述、调试依赖个人经验和直觉。
方案 B:DSPy 声明式优化
步骤 1:定义签名
# infer_dspy.py
class AddressExtract(dspy.Signature):
"""从原始中文地址文本中提取结构化地址标签"""
text: str = dspy.InputField(desc="原始中文地址文本")
result: dict[str, str] = dspy.OutputField(desc="提取的结构化地址标签 (JSON object)")
仅 4 行代码。不需要手写任何提示指令 —— DSPy 编译器从类名、docstring、字段名和类型注解中自动生成初始提示。
步骤 2:配置语言模型
def make_lm() -> dspy.LM:
return dspy.LM(
model="deepseek/deepseek-v4-flash",
temperature=0,
api_key=API_KEY,
api_base="https://api.deepseek.com/v1",
cache=False,
)
lm = make_lm()
dspy.settings.configure(lm=lm)
DSPy 通过 LiteLLM 支持几乎所有主流模型提供商,可以随时切换模型而不需要修改任何任务代码。
步骤 3:定义评估指标
优化器需要一个评分函数来量化输出质量。本任务使用标签级 F1(程序化 Metric):
def label_f1(pred: dict, gold: dict) -> float:
pred = {k: v for k, v in pred.items() if k in KEEP_KEYS}
gold = {k: v for k, v in gold.items() if k in KEEP_KEYS}
if not gold:
return 0.0 if pred else 1.0
tp = sum(1 for k in gold if k in pred and pred[k] == gold[k])
fp = sum(1 for k in pred if k not in gold or pred[k] != gold[k])
fn = sum(1 for k in gold if k not in pred or pred[k] != gold[k])
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
if precision + recall == 0:
return 0.0
return 2 * precision * recall / (precision + recall)
def metric(example: dspy.Example, pred: dspy.Prediction, trace=None) -> float:
return label_f1(pred.result, example.result)
程序化 Metric 具有确定性、零额外成本的优势,适合分类、NER、信息提取等有明确正确答案的任务。对于开放式生成任务,DSPy 也支持 LLM-as-a-Judge 方式。
步骤 4:构建训练集
def build_trainset(fewshot_path: Path) -> list[dspy.Example]:
data = load_json(fewshot_path)
examples = []
for item in data:
label = {k: v for k, v in item["label"].items() if k in KEEP_KEYS}
examples.append(
dspy.Example(text=item["text"], result=label).with_inputs("text")
)
return examples
trainset = build_trainset(fewshot_path) # 50 条
步骤 5:编译优化
program = dspy.Predict(AddressExtract)
# 首次优化:medium 模式充分探索搜索空间
optimizer = dspy.MIPROv2(
metric=metric,
auto="medium",
num_threads=20,
)
optimized = optimizer.compile(program, trainset=trainset)
optimized.save("output/dspy_program", save_program=True)
编译过程自动完成指令搜索和示例选择。auto="medium" 提供比 "light" 更充分的贝叶斯搜索空间,对本案例 50 条训练集来说,这一差别直接决定了最终结果。
步骤 6:推理部署
program = dspy.load("output/dspy_program", allow_pickle=True)
pred = program(text="浙江省杭州市萧山区南秀路杭州萧然医药有限公司")
print(pred.result)
# {"province": "浙江省", "city": "杭州市", "district": "萧山区", ...}
效果对比与深度分析
整体指标
| 方案 | 平均 F1 | 说明 |
|---|---|---|
| 手工提示词 | 0.8062 | 15 行 System Prompt,多轮手工调试 |
| DSPy 编译 | 0.8281 | MIPROv2 auto=medium,50 条优化数据 |
DSPy 在仅 50 条训练数据上实现了 2.7% 的提升,超越了经过多轮手工调试的提示词。
逐标签拆解
整体提升背后存在显著的类别分化:
| 标签 | 支持数 | 手工 F1 | DSPy F1 | 变化 |
|---|---|---|---|---|
| province | 74 | 0.6772 | 0.9664 | +0.289 |
| city | 96 | 0.6696 | 0.8038 | +0.134 |
| road_number | 82 | 0.9277 | 0.9704 | +0.043 |
| district | 135 | 0.8837 | 0.8906 | +0.007 |
| town | 86 | 0.9364 | 0.9398 | +0.003 |
| road | 121 | 0.9627 | 0.9587 | -0.004 |
| poi | 134 | 0.7424 | 0.7537 | +0.011 |
| community | 32 | 0.7541 | 0.4286 | -0.326 |
| dev_zone | 16 | 0.8125 | 0.5833 | -0.229 |
| house_number | 50 | 0.7843 | 0.4545 | -0.330 |
核心发现:
- 高频标签大幅改善:province(优化集中含 24 例)和 city(23 例)的提升最为显著,MIPROv2 的贝叶斯搜索有效捕获了这些标签的识别模式
- 长尾标签严重退化:community(优化集仅 9 例)、dev_zone(仅 3 例)和 house_number(16 例)的 F1 显著下降。优化器对低频类别的训练覆盖不足,反而产生了负优化
- 手工提示的长尾防线:手工提示中 10 个字段的精确定义(如「开发区/工业园区」、「楼栋号/幢号」)为低频类别提供了 DSPy 优化器无法从少数训练样本中得到的先验约束
伴随的评估工具
项目配套的 score.py 提供完整的评估能力:
- 整体指标:平均 F1
- 逐标签指标:每个字段的 Precision / Recall / F1 / Support
- 分数分布:按
[0, 0.1)到[0.9, 1.0)的 10 段统计 - 可视化报告:带柱状图的终端输出
# 使用方式
# python score.py --pred output/pred-dspy.json --gold dataset/eval.json
分析与启示
DSPy 胜在何处
MIPROv2 在 50 条训练数据上超越手工提示,验证了自动优化的可行性。关键成功因素:
auto="medium"足够充分:相比"light",medium 模式提供更多轮贝叶斯搜索迭代,对搜索空间的覆盖更完整- 标签级 F1 精确引导方向:确定性的程序化 Metric 避免了 LLM-as-a-Judge 的评估噪声
- 声明式签名即可起步:即使 Signature 仅定义输入输出类型(不写详细 docstring),编译器也能通过搜索找到有效指令
DSPy 的局限性
MIPROv2 是按训练分布加权的效果最大化器——它对高频模式的优化能力极强,但对长尾模式可能产生退化。这引出三条改进路径:
- 扩大并均衡训练集:将优化集从 50 条扩至 150+ 条,对长尾标签过采样,确保每个类别至少 20 例代表性样本
- 在签名中注入领域约束:将手工提示中的精确定义(如字段说明、格式约束)写入 Signature 的 docstring,为优化器提供先验锚点
- 多轮迭代优化:medium → light → light 的三轮编译,配合训练集 F1 监控,避免过拟合
方案对比总结
| 维度 | 手工提示词 | DSPy 编译 |
|---|---|---|
| 开发时间 | 数小时到数天的手动调试 | 定义签名 + 运行编译(分钟级) |
| 代码量 | ~15 行提示词 + ~100 行工程代码 | ~4 行签名定义 + 框架调用 |
| 维护成本 | 模型变化需要重新调试提示 | 重新编译即可适配 |
| 领域知识注入 | 通过提示词文本显式编码 | 通过训练数据和 Signature docstring 隐式注入 |
| 可复现性 | 低(依赖调试者的经验直觉) | 高(数据 + 指标驱动) |
| 当前效果 | F1=0.8062 | F1=0.8281 |
| 高频标签 | 表现稳定 | 显著优于手工 |
| 长尾标签 | 先验知识保护,表现稳定 | 训练不足时严重退化 |
| 可扩展性 | 标签增加 → 人工修改提示 | 标签增加 → 重新编译 |
优化器选型策略
| 阶段 | 推荐优化器 | 数据需求 | 成本 | 适用条件 |
|---|---|---|---|---|
| 概念验证 | BootstrapFewShot |
20+ 样本 | 极低 | 签名描述已足够清晰 |
| 效果调优 | MIPROv2 (auto=medium) |
50+ 样本 | 低到中 | 首次优化,充分搜索 |
| 生产级优化 | MIPROv2 (auto=full)/ 迭代 |
150+ 样本 | 中 | 追求最佳效果 |
| 极致性能 | GEPA |
3+ 样本 | $2-10/轮 | 数据极度稀缺 |
工程实践建议
数据是生命线
- 构建 ≥ 50 条高质量标注训练集,确保标签准确率 100%
- 标签分布应与线上真实分布一致;长尾标签需额外过采样
- 严格分离训练/验证/测试集,避免数据泄露
指标设计优先
- 优先使用程序化 Metric(确定性、零成本、可精确控制)
- 对于无唯一正确答案的任务(翻译、摘要),使用 LLM-as-a-Judge
- Metric 函数的返回值直接决定优化方向,务必与业务目标对齐
版本管理与可观测性
# 每次优化后保存完整程序
optimized.save(f"output/dspy_program_v{version}", save_program=True)
# 保存时自动生成 metadata.json,记录依赖版本
# {"dependency_versions": {"python": "3.13", "dspy": "3.2.1", "cloudpickle": "3.1"}}
# 编译后立即在训练集上评估,防止优化退化
for ex in trainset:
pred = optimized(text=ex.text)
f1 = label_f1(pred.result, ex.result)
思维模型转变
| 传统思维 | DSPy 思维 |
|---|---|
| 「这段提示应该怎么措辞?」 | 「这个签名的输入输出怎么定义?」 |
| 「加个示例会不会更好?」 | 「用什么 Metric 来衡量?」 |
| 「换行和缩进对效果有影响吗?」 | 「训练数据的分布是否合理?」 |
| 「这个模型用 X 格式效果更好」 | 「编译器会自动适配不同模型」 |
把 LLM 当作可优化的函数,而非需要哄的对话伙伴。把精力从编写提示转移到设计评估指标和准备高质量数据上。
总结
DSPy 推动的不是提示工程的消亡,而是将提示工程从一门「手艺」升级为一门「工程学科」。正如 PyTorch 让研究者将精力从计算梯度转移到设计架构,DSPy 让开发者将精力从编写提示转移到设计评估指标和准备高质量数据上。
好的数据和好的指标,比任何「魔法提示词」都更有生命力。