跳到主要内容

模块体系与智能体

本章讲 DSPy 怎么把单步 Predict 拼成复杂程序:Module 的组合/遍历机制、ChainOfThought 和 ReAct 这两个最常用的内置模块、以及贯穿始终的数据容器 Example/Prediction。

1. Module:仿 PyTorch 的可组合单元

1.1 它要解决的小问题

一个真实程序不止一步:RAG 要「检索 → 推理 → 回答」,agent 要循环调工具。你需要一种方式把 多个 Predict 拼起来,还要能让优化器找到藏在程序各处的每一个 Predict 去调它的参数。 DSPy 的答案是抄 PyTorch:写一个 dspy.Module 子类,在 __init__ 里放子模块,在 forward 里写逻辑。

class RAG(dspy.Module):
def __init__(self):
super().__init__()
self.respond = dspy.ChainOfThought("context, question -> answer")

def forward(self, question):
context = my_retriever(question) # 你自己的检索
return self.respond(context=context, question=question)

1.2 call 包了什么

Module.__call__dspy/primitives/module.py:93)不是直接调 forward,而是包了一层:

  • 维护 caller_modules 上下文(谁调了谁),用于把 LM 历史挂到正确的模块上;
  • 若开启 track_usage,用 track_usage() 统计 token 用量并写回 Predictionmodule.py:102-108)。

还有个有意思的防呆:__getattribute__ 检测到你直接调 module.forward(...)(绕过 __call__)会打 warning(module.py:335-349),因为那样会丢掉上面这些上下文。

1.3 递归发现「参数」(优化器的入口)

named_predictorsmodule.py:131)返回程序里所有 Predict 及其点号路径名。它底层走 named_parametersdspy/primitives/base_module.py:23),递归遍历 __dict__:碰到 Parameter 就收集,碰到子 Module 就深入,还能穿透 list/tuple/dict 容器 (base_module.py:49-65)。

一个关键细节:已编译的子模块会被「冻结」——if not getattr(param_value, "_compiled", False)base_module.py:41)会跳过已编译模块的内部参数。这让你能把一个调好的子程序当成不可变积木 复用,优化器不会再去动它。

1.4 ProgramMeta:保证基类属性一定存在

元类 ProgramMeta.__call__module.py:21)在你的 __init__ 之前先调 Module._base_init, 保证 callbackshistory_compiled 一定存在——即使子类忘了写 super().__init__() 也不会炸(module.py:33-36 再兜一次底)。

1.5 存盘与复制

  • deepcopybase_module.py:110):只深拷参数,其余浅拷——优化器要造程序副本来试不同参数, 这样既隔离又省内存。
  • save(..., save_program=True)base_module.py:171):用 cloudpickle 把整个程序(含 代码)序列化;否则只存 state(demos/instructions/LM 配置)。

2. ChainOfThought:最小的「让我想想」

CoT 是 DSPy 里最能体现「签名即参数」哲学的模块。它的实现短到惊人 (dspy/predict/chain_of_thought.py:12):

class ChainOfThought(Module):
def __init__(self, signature, rationale_field=None, rationale_field_type=str, **config):
super().__init__()
signature = ensure_signature(signature)
rationale_field = rationale_field or dspy.OutputField(desc="${reasoning}")
# 关键一行:在原签名最前面插一个 reasoning 输出字段
extended_signature = signature.prepend("reasoning", rationale_field, type_=rationale_field_type)
self.predict = dspy.Predict(extended_signature, **config)

没有任何特殊的 prompt。它只是用第 1 章讲的 signature.prepend,在输出字段最前面加一个 reasoning 字段。因为 reasoning 排在 answer 前面,模型必须先生成推理、再生成答案——这就是 chain-of-thought。forward 只是转发给内部的 self.predictchain_of_thought.py:37)。

这一步把「prompt 技巧」降维成「改签名」——同样的格式渲染、解析、优化全都白嫖现成的。

3. ReAct:用签名多态搭一个 agent 循环

3.1 思路

ReAct(Reasoning + Acting,dspy/predict/react.py:16)是工具调用智能体的经典范式:模型 反复「想一步 → 选工具 → 看结果」,直到决定收尾。DSPy 的 ReAct 能套在任意签名上 (signature polymorphism)。

3.2 它在 init 里造了两个 Predict

  1. react:一个动态拼出来的签名,输入是你的原始输入 + 一个 trajectory(历史轨迹字符串), 输出三个字段——next_thoughtnext_tool_name(限定为工具名的 Literal)、 next_tool_argsreact.py:73-79)。注意 next_tool_nameLiteral[tuple(tools.keys())] 约束,让模型只能从已注册工具里选。
  2. extract:一个 ChainOfThought,在循环结束后从完整轨迹里抽出最终的输出字段 (react.py:88)。

工具列表里会自动塞一个 finish 工具(react.py:62),模型选它就表示「信息够了,收尾」。

3.3 循环

ReAct.forwarddspy/predict/react.py:95)就是一个 for 循环:

trajectory = {}
for idx in range(max_iters):
pred = self.react(trajectory=format(trajectory), **input_args) # 模型决定下一步
trajectory[f"thought_{idx}"] = pred.next_thought
trajectory[f"tool_name_{idx}"] = pred.next_tool_name
trajectory[f"tool_args_{idx}"] = pred.next_tool_args
trajectory[f"observation_{idx}"]= self.tools[pred.next_tool_name](**pred.next_tool_args) # 执行
if pred.next_tool_name == "finish":
break
extract = self.extract(trajectory=format(trajectory), **input_args) # 从轨迹抽最终答案
return dspy.Prediction(trajectory=trajectory, **extract)

每轮把「想法/工具名/参数/观测」四件套追加进 trajectory 字典,下一轮把整个轨迹格式化成 字符串再喂回去。工具执行出错不会崩——异常会被捕获并写成 observation 文本喂回模型 (react.py:111-112),让模型自己看到错误、纠正。

3.4 上下文窗口溢出的容错

轨迹越滚越长,可能撑爆上下文。_call_with_potential_trajectory_truncationreact.py:145) 捕获 ContextWindowExceededError,调 truncate_trajectory 砍掉最老的一次工具调用 (一次 = 4 个 key,react.py:182),最多重试 3 次。

4. 数据容器:Example 与 Prediction

4.1 Example:训练/评估数据的「行」

Exampledspy/primitives/example.py:4)是个像字典又像点访问记录的容器,一个 Example ≈ 数据集的一行。核心方法是 with_inputs(...)example.py:223)——它标记哪些字段是 输入,返回一个新副本(不改原对象)。之后:

  • example.inputs() 拿输入字段(喂给程序,example.py:249);
  • example.labels() 拿其余字段当标签(和程序输出比对,example.py:273)。

这个「输入/标签」切分是优化器和评估器的统一约定。

4.2 Prediction:模块的输出,且能当分数用

Predictiondspy/primitives/prediction.py:4)继承 Example,但多了一招:如果它有 score 字段,就能当 float 参与比较和算术__float____lt____add__prediction.py:53-90)。这让优化器可以直接对 prediction 排序、求平均,而无须额外拆包。

from_completionsprediction.py:33)把 Adapter 解析出的多组输出(n>1 时)存进 _completions,但默认 _store 只取每个字段的第一组,所以 pred.answer 总能直接拿到主答案。

5. 巧妙之处

  • CoT = signature.prepend:把 prompt 技巧实现成「改签名」,复用整条 format/parse/optimize 链(dspy/predict/chain_of_thought.py:34)。
  • ReAct 用 Literal[tool names] 约束工具选择:把「只能选已注册工具」编码进类型,让 Adapter 的解析层去保证合法性(dspy/predict/react.py:77)。
  • 已编译子模块被冻结named_parameters 跳过 _compiled 模块,使「调好的积木」可安全复用 (dspy/primitives/base_module.py:41)。
  • Prediction 可比可加:带 score 时直接当数值,简化优化器代码(prediction.py:53)。

6. 边界与局限

  • ReAct 把轨迹格式化成字符串喂回模型,源码末尾的设计笔记(react.py:199 起)承认这导致 demos 的格式不会随 adapter 变化、且重复前缀有 O(n²) 开销。
  • get_lm 要求模块内所有 predictor 用同一个 LM,否则抛错(module.py:210-215)——多 LM 程序 得自己管理。

7. 代码地图

主题文件符号
模块基类 / 调用包装dspy/primitives/module.pyModule__call__ProgramMetanamed_predictors
参数遍历 / 存盘dspy/primitives/base_module.pynamed_parametersdeepcopysaveload
Chain of Thoughtdspy/predict/chain_of_thought.pyChainOfThoughtforward
ReAct agentdspy/predict/react.pyReActforward_call_with_potential_trajectory_truncationtruncate_trajectory
数据行dspy/primitives/example.pyExamplewith_inputsinputslabels
预测结果dspy/primitives/prediction.pyPredictionfrom_completions__float__