WheatField
WheatField

DSPy 从入门到劝退

August 20, 20242838 words, 15 min read
Authors

调试过 prompt 模板的朋友都知道,LLM prompting 是一门艺术,讲究“坑蒙拐骗”。一个好的 prompt 模板的诞生,往往伴随着辛苦的尝试、深厚的技术底蕴以及深夜突然闪现的灵感。

究其原因,LLM 的结果对 prompt 的扰动特别敏感(参考《Quantifying Language Models' Sensitivity ...》),一处轻微的改动就能带来巨大的效果提升,最知名的套路莫过于 CoTlet's think step by step,而其他各种 prompting 奇淫绝技更是层出不穷,比如“好好解答,就给 10 美元的奖励”、“如果不认真执行任务,就随机杀死一个老奶奶”等等。😓

一条 prompt 的设计就能让效果天差地别,而如果一个任务需要结合 pipeline 来完成,每个步骤分别使用不同的 prompt 模板,前后相互依赖,牵一发而动全身,再通过误差传递,最终一致性就更难以保证。而且随着系统越来越复杂,prompt 越积越多,维护成本也水涨船高。很多时候,开发者的精力都耗费在 prompt 的设计、模型选择、参数微调上,耗时耗力却收效甚微。

作为一种解决方案,Stanford 提出的 DSPy 则从另一个角度出发,强调“编程”而非“提示”,让开发者从繁琐的 prompt 设计中解放出来,从而专注于解决方案的构建。

设计理念

DSPy 做的事情,就是把流程构建跟参数选择解耦,不再依赖于灵光一现的 prompt hacking,而是通过高效的构建、迭代、优化过程来找出一条更加稳妥的解决方案。最终效果可能不是最优的,但从统计上讲,大概率会比手撸 prompt 强的多。

这里的参数主要指 prompt, LLM 及 LLM 参数(比如模型本身参数、温度、top_p、top_k 等),可选范围非常大。而流程构建,就是编程。编程与参数都很重要,但如果流程构建快,参数的优化与选择就变得容易的多。这跟传统 NLP 算法工程师在训练小模型时一样,先准备好数据,再通过 PyTorch-Lightning 搭建好框架,至于模型选 LSTM、BERT 还是 Elmo,优化器用 Adam 还是 SGD 都是一键替换的事情。这样开发人员就可以专心码字,参数的优化与选择交给框架来完成。

设计图——签名

为达成这个目标,DSPy 引入了三个核心概念:签名(Signature)模块(Module)优化器 (optimizers)。作为任务的蓝图,签名定义了一套规范(specifation),宏观的讲,它定义了任务的内容、所需的输入以及预期的输出。签名关注的是“要做什么”,而“怎么做”则由后面的模块来完成。

举个例子

我们在借助 LLM 去解决问题时,一般遵循下图所示流程:先构造一个 prompt,然后填入问题,再调用 LLM API 获取文本,最后再通过后处理获得最终的结果。

以一个数学问题为例,一个可能的 prompt 模板如下:

prompt = '''
You're an expert in math, try to solve the question, and follow the following format.

Question: question
Answer: answer

Question: what is the square root of 16 * 25?
'''

这里面包含了几个元素:系统提示、返回格式以及问题。对于复杂一点的任务,一个 prompt 里还可以包含元数据、示例、上下文等,这一点笔者在《AI 推理加速利器:提示缓存技术解析》中介绍过。

Signature 做的事情是把这些元素都定义清晰,作为编程的元数据。几个核心概念有: descriptioninputoutputprefix 等,真正提交给 LLM 的 prompt 是通过编程的方式,把多个元素组合起来得到的。

承上,一种 signature 的定义可能是:

class BasicQA(dspy.Signature):
    """Answer questions with short factoid answers."""

    question = dspy.InputField()
    answer = dspy.OutputField(prefix="Answer:")

这里面用类文档注释的方式来定义 description,用 dspy.InputFielddspy.OutputField 来定义 inputoutput。如果想加点其他指令怎么办,比如让 LLM 给出具体分析过程,那可以在 prefix 里加一句 Let's think step by step

class BasicQA(dspy.Signature):
    """Answer questions with short factoid answers."""

    prefix = "Reasoning: Let's think step by step. Answer:"
    question = dspy.InputField()
    answer = dspy.OutputField(prefix=prefix)

这样做的好处是,结构化定义,结构清晰,通过输入输出域(input/output fileds)的实现还可以对输入、输出进行类型检查,保证数据格式正确等,便于维护。

模块(Module)

有了 signature 这个设计图,接下来就是通过模块的构造去搭建这个系统,用于执行真正的任务。模块把一些常用的 prompting 技巧抽象出来,比如 ChainOfThought,ReAct。多个模块组合起来,就构成最终的系统,这跟累加多个 NN layer 组成一个神经网络的做法是类似的。

以下是官方 ChainOfThought 模块的部分实现:

class ChainOfThought(Module):
    def __init__(self, signature, rationale_type=None, activated=True, **config):
        super().__init__()
        ...
        prefix = "Reasoning: Let's think step by step in order to"
        desc = "${produce the " + last_key + "}. We ..."
        ...

    def forward(self, **kwargs):
        ...

在运行时,dspy 会把 signature 的 docstring,CoT 的 prefix 以及 input, output 的参数拼接成 prompt,然后调用 LLM 获取结果。因此可以说, prompt 里面的内容并没有多大变化,只不过换了一种写法。

lm = dspy.OpenAI(model='gpt-3.5-turbo')
dspy.configure(lm=lm)
qa = dspy.ChainOfThought('question -> answer')
question = "What is the capital of France?"
response = qa(question=question)

优化器(optimizers)

如上所述,优化器的作用是为了迭代优化 prompt 及参数,从而获得最佳结果。可以优化的参数主要有三类:LLM 参数(本身参数、温度、top_p、top_k 等)、指令、 输入/输出的示例。LLM 参数可通过传统的梯度下降法来优化,指令和输入/输出的示例则使用 LM 驱动优化来创建和验证。

实现粗糙

作为 DSPy 的一种实现, dspy 这个 Python 框架在实现上有诸多问题:代码粗糙,设计不足,框架中充斥着大量的元编程、数据结构解析和构建过程,直接放在生产环境中使用无疑有很大风险。

笔者在体验时遇到了几个小问题,这里记录一下。

生态兼容不足

OpenAI 的 API 规范已俨然成为行业的标杆,很多 API 供应商、第三方代理商都兼容了 OpenAI SDK(比如国内知名的 DeepSeek),一些开源项目在设计及实现时也都会考虑并遵守这一点。

dspy 确实尝试兼容了 OpenAI 的 API 规范,但在实现上却不够彻底。比如,如果要使用 ChainOfThought 去解决一个 QA 问题,采用以下代码直接通过 API 调用 GPT-3.5 模型是没有问题的,但想调用 DeepSeek 的 deepseek-chat 模型却不行,尽管 DeepSeek 也兼容了 OpenAI 的 API 规范。

lm = dspy.OpenAI(model='deepseek-chat', url='https://api.deepseek.com/v1')
dspy.configure(lm=lm)
qa = dspy.ChainOfThought('question -> answer')

所以,现在的情况是,如果开发者想使用第三方模型,就必须自己去实现一个LM client(如以下代码块所示)。这就产生了一个问题,要实现这样的一个模块,开发者必须对 dspy 的执行过程、优化系统有足够的了解,那一点就抬高了使用的门槛,对希望开箱即用的开发者不够友好。

from dsp import LM

class DeepSeek(LM):
    def __init__(self, model, api_key,
                 base_url: str,
                 **kwargs,):
        self.model = model
        self.api_key = api_key
        self.base_url = base_url
        self.provider = "default"
        self.history = []
        self.kwargs = {
            "temperature": kwargs.get("temperature", 0.0),
            "max_tokens": min(kwargs.get("max_tokens", 4096), 4096),
            "top_p": kwargs.get("top_p", 0.95),
            "top_k": kwargs.get("top_k", 1),
            "n": kwargs.pop("n", kwargs.pop("num_generations", 1)),
            **kwargs,
        }
        self.kwargs["model"] = model

    def basic_request(self, prompt: str, **kwargs):
        headers = {
            "Authorization": "Bearer " + self.api_key,
            "Accept": "application/json",
            "Content-Type": "application/json"
        }
        data = {
            **kwargs,
            "model": self.model,
            "messages": [
                {"role": "user", "content": prompt}
            ],
            "steam": False
        }

        response = requests.post(self.base_url, headers=headers, json=data)
        response = response.json()

        self.history.append({
            "prompt": prompt,
            "response": response,
            "kwargs": kwargs,
        })
        return response

    def __call__(self, prompt, **kwargs):
        response = self.request(prompt, **kwargs)
        print(response)
        completions = [result['message']['content']
                       for result in response["choices"]]
        return completions


lm = DeepSeek(model='deepseek-chat',
              api_key=DEEPSEEK_KEY,
              base_url=DEEPSEEK_API)
dspy.configure(lm=lm)
qa = dspy.ChainOfThought('question -> answer')
response = qa(question="巴黎的首都是哪里?let's think step by step")
print(response.answer)

另一个例子,笔者尝试用 Ollama 托管本地模型,结合 fastAPI 来模拟 OpenAI 的 API,尽管笔者已经参照 OpenAI 官方示例实现了 completion 接口,但直接使用 DSPy 的 ChainOfThought 模块是没法调用的。在不看源码的情况下,根本无法定位是什么地方导致的错误。

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
During handling of the above exception, another exception occurred:

代码逻辑不清晰

@CacheMemory.cache
def v1_cached_gpt3_turbo_request_v2(**kwargs):
    if "stringify_request" in kwargs:
        kwargs = json.loads(kwargs["stringify_request"])
    return openai.chat.completions.create(**kwargs)

上面的代码是官方代码库 dsp/module/gpt3.py 中的 v1_cached_gpt3_request_v2 函数,首先这个函数名多少有点随意,看代码调用逻辑应该是从用户输入到 LLM API 调用中间的一环,而这个函数中 cached, stringify_request 等字段让人费解。 第二点,笔者尝试在不自定义模块的情况下,跑通 deepseek-chat 的例子,几番折腾后,还真发现了一个可行方案,即先创建一个 OpenAI 的 client,然后把 base_urlapi_key 传进去,就可以使用了,着实神奇。

@CacheMemory.cache
def v1_cached_gpt3_turbo_request_v2(**kwargs):
    if "stringify_request" in kwargs:
        kwargs = json.loads(kwargs["stringify_request"])
    from openai import OpenAI
    client = OpenAI(
        api_key=openai.api_key,
        base_url=openai.base_url
    )
    return client.chat.completions.create(**kwargs)

Bug 较多

参考官网示例,笔者尝试去跑一个如下的情感分析小例子

sentence = "it's a charming and often affecting journey"
classify = dspy.Predict('sentence -> sentiment')
x = classify(sentence=sentence).sentiment
print(x)

使用自定义的 DeepSeek LM, 使用的模型是 deepseek-chat,按照 signature 的设计逻辑,输入 sentence ,理应得到一个单词:positive, negative 或 neutral。但实际上得到的结果居然是一长串文本,看起来更像是一小批训练数据,而不是问题的直接推理结果。

Positive
---
Sentence: The service was terrible and the food was cold.

Sentiment:

Negative

---

Sentence: I'm so excited for the new movie coming out!
Sentiment:

Positive
# 此处省略近 20 行

上述任务还只是一个简单的情感分析任务,总计代码不到 10 行,产出却比代码行数还多,这也是不建议当前把 dspy 应用到生产环境的原因之一。这种问题比错误更可怕,后者好歹还能打印出栈信息。

文档有待优化

参考文档内容不多,写的比较精简,很多地方一笔带过,没有详细解释。Quick start, tutorial, cheatsheet 在功能上有重复,新手用户很难找到切入点。

小结

作为一个优化 LM 权重与参数的新框架,DSPy 在构思上还算是新颖,能让开发者从繁琐的 prompt 调参中解放出来,专注于流程构建,这一点值得持续关注。

但当下代码实现上确实存在诸多问题,官方仍在不间断地打补丁,接口和代码也在持续变动中,目前尚不太建议用在实际生产环境中。官方也意识到一点,所以在文档中给出的指导也相对中肯:“如果你是一个 AI/NLP 科研人员,DSPy 是一个不错的选择;如果不是,你可得好好读读文档了”。

If you're a NLP/AI researcher (or a practitioner exploring new pipelines or new tasks), the answer is generally an invariable yes. If you're a practitioner doing other things, please read on.

参考

  1. Reddit: who is using dspy
  2. Medium: An introduction to DSPy