跳到主要内容

Adapter:把签名渲染成 prompt、把文本解析回字段

这是 DSPy 工程含量最高的一层。难点从来不是「调用模型」,而是**「把声明式签名可靠地变成 一段 prompt,再把模型吐的纯文本可靠地切回结构化字段」**。本章讲清这条双向翻译链。

1. Adapter 在管线里的位置

第 1 章说过,Predict.forward 把活儿全交给 Adapter:adapter(lm, lm_kwargs, signature, demos, inputs)。 那一次调用内部分成五步,全在 Adapter.__call__dspy/adapters/base.py:314):

输入: signature + demos + inputs

▼ _call_preprocess 处理原生功能(工具调用/citations),可能删字段

▼ format 渲染成 chat messages [{role, content}, ...]

▼ _render_request 包成规范化的 LMRequest

▼ _call_lm 真正打模型,拿回文本输出

▼ _call_postprocess 对每个输出调 parse() → 字段字典
输出: list[dict] (每个 dict 是一组输出字段)

format(基类 dspy/adapters/base.py:366)在基类里已是完整实现——它负责按固定模板把消息 拼起来(见 §2),并调用一组留给子类实现的字段级钩子。真正的抽象方法是 parsebase.py:713,实际在 :725 raise NotImplementedError)和这组 format_* 字段钩子: format_field_descriptionbase.py:468)、format_field_structure:480)、 format_task_description:494)、format_user_message_content:518)、 format_assistant_message_content:539)——它们都在基类 raise NotImplementedError,由具体 适配器实现。换句话说,子类填的是「字段级怎么渲染 + 怎么 parse」,顶层 format 的骨架是共享的。 DSPy 内置 ChatAdapter(默认)、JSONAdapter、XMLAdapter、TwoStepAdapter、BAMLAdapter。

注:base.py 里有大量 TODO(adapters-plan) / TODO(language-models) 注释,标明 Adapter↔LM 边界正在向「规范化 LMRequest/LMResponse」迁移(base.py:83:226)。 当前代码走的是行为保持的 legacy 路径:渲染成 OpenAI-chat 形状的 dict,调完再转回 legacy 输出给 _call_postprocessbase.py:346)。本章描述的是当前实际行为。

2. format:签名 → chat 消息

2.1 消息总体结构

Adapter.formatdspy/adapters/base.py:366)按一个固定模板拼消息(文档字符串里也画了):

[ system ] 字段说明 + 格式约定 + 任务指令
[ user/assistant 对 ] ← few-shot demos(每个例子一来一回)
[ user/assistant 对 ] ← 对话历史(如果签名里有 History 字段)
[ user ] 当前真正的输入

format 本体(base.py:411-441):先抽出 history 字段(若有)、拼 system message、 用 format_demos 铺 few-shot、再追加历史和当前输入。

2.2 system message = 三段

format_system_messagebase.py:443)把三块拼起来:

f"{self.format_field_description(signature)}\n" # 有哪些输入/输出字段,各是什么
f"{self.format_field_structure(signature)}\n" # 它们在消息里长什么样(格式约定)
f"{self.format_task_description(signature)}" # 任务目标(≈ signature.instructions)

2.3 ChatAdapter 的招牌:[[ ## field ## ]] 标记协议

ChatAdapter 用一套显式分隔符把字段框起来。format_field_structuredspy/adapters/chat_adapter.py:122)告诉模型:每个字段都用 [[ ## 字段名 ## ]] 起头, 输出结束时打一个 [[ ## completed ## ]] 标记。

一段渲染后的 system message 大致长这样(示意):

Your input fields are:
1. `question` (str)
Your output fields are:
1. `answer` (str)

All interactions will be structured in the following way ...

[[ ## question ## ]]
{question}

[[ ## answer ## ]]
{answer}

[[ ## completed ## ]]

In adhering to this structure, your objective is: ...

用户消息里也用同样标记包住实际输入值(format_user_message_contentchat_adapter.py:149), 并在最后追加一句「输出格式提醒」(user_message_output_requirementschat_adapter.py:172)—— 因为对话一长,模型容易忘了输出格式,所以每条 user 消息都重申一遍。

为什么用 [[ ## ... ## ]] 而不是 JSON? 因为这种行首标记对模型的「自由发挥」更宽容: 模型可以在字段值里写多行、写代码、写 Markdown,解析器只认行首那一行的标记,不会被内容里 的花括号/引号搞崩。

2.4 few-shot demos 的渲染

format_demosbase.py:541)把每个样例拆成一对 user/assistant 消息。它还区分两类:

  • 完整样例(所有字段都有值):直接当正常一问一答。
  • 不完整样例(缺一些字段):加前缀说明「这是个示例,部分字段未给」,缺的字段用占位语句 (base.py:573-599)。

3. parse:LM 文本 → 字段字典

3.1 ChatAdapter 怎么切

ChatAdapter.parsedspy/adapters/chat_adapter.py:218)逻辑很直白:

field_header_pattern = re.compile(r"\[\[ ## (\w+) ## \]\]") # 行首标记

# 逐行扫描:碰到 [[ ## x ## ]] 就开一个新 section,其余行累进当前 section
for line in completion.splitlines():
match = field_header_pattern.match(line.strip())
if match:
header = match.group(1)
sections.append((header, ...))
else:
sections[-1][1].append(line)

切完后,对每个属于输出字段的 section,调 parse_value 按字段类型转换(比如 int 字段把 文本转成整数)。如果切出的字段集和签名的输出字段集不一致,直接抛 AdapterParseErrorchat_adapter.py:245)——宁可报错也不返回半成品。

3.2 JSONAdapter 的不同与回退

JSONAdapter(dspy/adapters/json_adapter.py:40)继承 ChatAdapter,区别在:

  • format:要求模型直接吐 JSON 对象;若模型支持 response_format,还会下发结构化输出 schema 强约束(json_adapter.py:56-91)。
  • parse:用 json_repair.loads 容错解析(能修小语法错),失败再用正则抠出最外层 {...} 重试(json_adapter.py:167-183)。

3.3 巧妙处:ChatAdapter 失败自动降级到 JSONAdapter

ChatAdapter 重写了 __call__dspy/adapters/chat_adapter.py:76)包一层 try/except:

try:
return super().__call__(lm, lm_kwargs, signature, demos, inputs)
except Exception as e:
# 但不是所有错都回退:
if isinstance(e, LMError) or isinstance(self, JSONAdapter) or not self.use_json_adapter_fallback:
raise # LM 自己挂了 / 已经是 JSON / 用户禁用回退 → 不重试
return self._make_json_adapter_fallback()(lm, lm_kwargs, signature, demos, inputs)

也就是:解析失败(格式问题)→ 换 JSONAdapter 再试一次;但模型 API 报错(LMError)→ 直接 抛,不浪费一次调用。这是「格式韧性」和「不掩盖真错误」之间的平衡。

4. 原生功能:工具调用与 citations

_call_preprocessbase.py:83)在渲染前处理原生 LM 特性:

  • 若开启 use_native_function_calling 且签名里有 list[dspy.Tool] 输入 + ToolCalls 输出, 就把工具转成 LiteLLM 的 function 格式塞进 lm_kwargs,并从签名里删掉这两个字段base.py:110-121)——因为它们由原生工具调用通道处理,不该出现在文本 prompt 里。
  • citations / reasoning 这类「原生响应类型」走 adapt_to_native_lm_featurebase.py:127)。

解析时 _call_postprocessbase.py:137)再把原生通道返回的 tool_calls 转回 ToolCalls 对象塞回字段(base.py:183-185)。

5. 边界与局限

  • ChatAdapter 的 [[ ## ## ]] 协议是基于约定的「软格式」——模型可能不遵守,所以才需要 JSONAdapter 回退和「每条 user 消息重申格式」这两道保险。
  • Adapter↔LM 的规范化边界(LMRequest/LMResponse)仍在迁移中,源码里多处 TODO 标明 当前是兼容性 shim(base.py:226:305)。

6. 代码地图

主题文件符号
主流程五步dspy/adapters/base.pyAdapter.__call___call_preprocess_call_postprocess
消息组装dspy/adapters/base.pyformatformat_system_messageformat_demos
Chat 渲染dspy/adapters/chat_adapter.pyformat_field_structureformat_user_message_contentuser_message_output_requirements
Chat 解析dspy/adapters/chat_adapter.pyparsefield_header_pattern
JSON 适配 + 回退dspy/adapters/json_adapter.pyJSONAdapterJSONAdapter.parse
自动降级dspy/adapters/chat_adapter.pyChatAdapter.__call__
LM 调用边界dspy/clients/base_lm.pyBaseLM.__call__forward_process_completion