跳到主要内容

签名(Signature)与 Predict

本章讲 DSPy 的两个最小原子:签名(声明一步任务的输入输出)和 Predict(把签名跑成 一次 LM 调用)。读完你能声明并调用任意单步任务,并理解为什么 DSPy 说 demos/instructions 是「参数」。

1. 签名:任务的类型声明

1.1 它要解决的小问题

你想让模型「读一段文字,输出情感标签」。传统做法是写一段自然语言 prompt。问题是:这段 prompt 既描述了任务(做情感分类),又规定了格式(怎么输出),还混着 few-shot 例子——三件事 糅在一起,难复用、难优化。

签名把这三者拆开。签名只负责声明「输入有哪些字段、输出有哪些字段、任务目标是什么」, 至于「怎么把它变成 prompt 文本」交给 Adapter(见第 2 章),「指令怎么措辞最好」交给优化器 (见第 4 章)。

1.2 两种写法

写法一:字符串(最常用)。

import dspy

# 箭头左边是输入字段,右边是输出字段;可带类型标注
sig = dspy.Signature("document: str -> sentiment: str")

写法二:类(要写明字段描述、复杂类型时用)。

class Emotion(dspy.Signature):
"""把句子分类到一个情感标签。""" # 这段 docstring 就是 instructions(任务指令)
sentence: str = dspy.InputField()
sentiment: str = dspy.OutputField(desc="happy / sad / angry 之一")

类的 docstring 自动成为指令;字段用 InputField / OutputField 声明。这俩函数其实是 pydantic Field 的薄封装——把 DSPy 专有的参数(desc__dspy_field_type 等)塞进 json_schema_extra,见 dspy/signatures/field.py:79InputField)和 move_kwargsdspy/signatures/field.py:39)。

1.3 字符串是怎么被解析成类的(巧妙处)

dspy.Signature("...") 看起来是「实例化」,实际返回的是一个新的类,不是实例。这靠 元类 SignatureMeta.__call__ 拦截实现:当你调用 Signature(...) 本身时,它转去 make_signature(...) 动态造一个 Signature 子类(dspy/signatures/signature.py:42)。

字符串解析的核心在 _parse_signaturedspy/signatures/signature.py:644):

  • signature.split("->") 切成输入段和输出段(强制只能有一个 ->);
  • 每段交给 _parse_field_string,它用一个巧招——把 "x: int, y: str" 包成 "def f(x: int, y: str): pass" 然后用 ast.parse 解析函数参数(signature.py:678), 借 Python 自己的语法分析器拿到字段名和类型。

类型注解节点再交给 _parse_type_node 递归翻译成真实 Python 类型,支持 list[int]Optional[X]int | None(PEP 604)甚至 dspy.Image 这种点号类型 (dspy/signatures/signature.py:685)。

1.4 字段顺序与默认类型(容易踩的细节)

pydantic 默认不保证字段顺序,但 prompt 里字段先后很重要。SignatureMeta.__new__ 专门做了 两件事(dspy/signatures/signature.py:138):

  • 保序:先按 namespace 里出现的顺序记下 field_order,再据此重排 __annotations__, 保证输入/输出字段在 prompt 里的顺序和你声明的一致(signature.py:170-174)。
  • 默认 str 类型:没写类型标注的字段默认 str,并打上 IS_TYPE_UNDEFINED 标记 (signature.py:167-169),后续类型检查会跳过这种字段。

fields 属性永远「输入在前、输出在后」(signature.py:240),这正是 Adapter 渲染时依赖的次序。

1.5 签名是「不可变 + 函数式」的

签名的所有「修改」方法都返回新类,不改原类with_instructionsprependappendinsertdeletedspy/signatures/signature.py:277-507)。这一点对优化器至关重要—— 优化器会反复造「同字段、不同指令」的签名变体来搜索,必须保证不污染原始签名。例如 with_instructions 直接 return Signature(cls.fields, instructions)signature.py:304)。

2. Predict:把签名变成一次 LM 调用

2.1 它是什么

Predict 是 DSPy 最基础的可调用模块:给它一个签名,它就能被当函数调用,内部完成 「渲染 prompt → 调 LM → 解析结果」。它同时继承 Module(可组合、可遍历)和 Parameter (可被优化器调),见 dspy/predict/predict.py:42

qa = dspy.Predict("question -> answer")
pred = qa(question="2+2 等于几?")
print(pred.answer)

2.2 关键状态:demos 是参数

Predict.reset() 初始化四样东西(dspy/predict/predict.py:64):

def reset(self):
self.lm = None # 这个步骤专用的 LM(不设就用全局默认)
self.traces = []
self.train = []
self.demos = [] # ← few-shot 样例:优化器要调的「参数」

self.demos 就是 few-shot 例子列表。优化器(BootstrapFewShot 等)做的事,本质就是往这个 列表里塞高质量样例(见第 4 章)。dump_state / load_statepredict.py:70predict.py:91)把 demos、签名、LM 配置序列化存盘,这就是 program.save() 能保住「优化成果」的原因。

2.3 forward 的三步(主线)

Predict.forwarddspy/predict/predict.py:250)很短,逻辑清晰:

def forward(self, **kwargs):
lm, config, signature, demos, kwargs = self._forward_preprocess(**kwargs) # 1) 准备
adapter = settings.adapter or ChatAdapter() # 2) 选适配器
# 3) 适配器一把抓:format + 调 LM + parse
completions = adapter(lm, lm_kwargs=config, signature=signature, demos=demos, inputs=kwargs)
return self._forward_postprocess(completions, signature, **kwargs)

三步:

  1. 预处理 _forward_preprocesspredict.py:141):选 LM(按 kwargs > self.lm > settings.lm 的优先级)、合并 config、填默认值、校验输入字段类型、警告多余/缺失字段。
  2. 选 Adapter:用户没配就默认 ChatAdapter
  3. 交给 Adapter 完成 format/call/parse,结果包成 Prediction

一个值得注意的细节:当 n > 1(要采样多个输出)且温度 ≤ 0.15 时,会自动把温度提到 0.7 以保持多样性(predict.py:171)——否则采 n 个一模一样的答案毫无意义。

2.4 后处理与 trace(给优化器埋的钩子)

_forward_postprocesspredict.py:233)把解析结果包成 Prediction.from_completions, 并且——如果当前在 trace 上下文里——把 (self, inputs, pred) 三元组追加进 settings.tracepredict.py:235-239)。

这一步是优化器的命脉:BootstrapFewShot 正是靠在 dspy.context(trace=[]) 里跑一遍程序、 再读出这个 trace,把「哪个 predictor、吃了什么、吐了什么」逐步拆出来当 demo(见第 4 章 §2)。

2.5 调用约定:只能用关键字参数

Predict.__call__ 显式禁止位置参数(predict.py:129):必须 qa(question=...) 而非 qa("..."),因为字段是有名字的,位置传参会产生歧义。报错信息还会贴心地列出你该用的字段名。

3. 巧妙之处

  • 借 AST 解析签名字符串:不用自己写词法分析,把字段串包成假函数定义喂给 ast.parse, 连复杂类型注解都白嫖 Python 的解析器(dspy/signatures/signature.py:678)。
  • 签名全函数式、返回新类:优化器可以随意造变体而不污染原签名(signature.py:277-507)。
  • demos 即参数:把「few-shot 例子」建模成模块的可变状态,使「优化 prompt」统一成 「调参数」这一个心智模型(dspy/predict/predict.py:64)。

4. 边界与局限

  • 字符串签名的类型解析依赖调用方栈帧自省来找自定义类型(_detect_custom_types_from_callersignature.py:53),代码里自己注明这在某些 Python 实现/优化编译下可能失效,建议复杂类型 显式传 custom_types
  • 未标注类型的字段静默默认成 strsignature.py:621 注释也承认这「可能更该显式报错,但会 破坏 program-of-thought 和优化器」)。

5. 代码地图

主题文件符号
元类拦截 / 造类dspy/signatures/signature.pySignatureMeta.__call__make_signature
字符串解析dspy/signatures/signature.py_parse_signature_parse_field_string_parse_type_node
字段顺序/默认类型dspy/signatures/signature.pySignatureMeta.__new___validate_fields
函数式修改dspy/signatures/signature.pywith_instructionsprependinsertdelete
字段定义dspy/signatures/field.pyInputFieldOutputFieldmove_kwargs
单步预测dspy/predict/predict.pyPredictforward_forward_preprocess_forward_postprocess