一、问题的起点:prompt 工程的缺点

做狼人杀 AI 的头几周,我对于提示词部分就是用这样方式进行设计的:

看起来很正常。但当游戏角色从 1 个变成 8 个,每个人有不同的规则约束(女巫有解药和毒药、守卫不能连守、狼人白天要伪装身份……),prompt 越来越多,冲突越来越多,改一个地方会意外破坏另一个地方的效果。

更根本的问题是:我不知道这个prompt是不是"最好的"。调参全靠感觉,直觉告诉你"这句话放这儿可能更好",但没有验证方法,没有量化指标,模型表现差了也不清楚是prompt问题、数据问题还是模型本身的问题。

二、DSPy 是什么:一种语言模型编程的方法论

DSPy 的文档第一句话通常是“用声明式的方式编程语言模型”。在我的理解中:

DSPy 把 prompt 工程从"手工写文本"升级成了"定义问题结构 + 让系统搜索最优解"。

传统的prompt工程结果判定全靠我们感觉来判断如何,那么,DSPy是如何进行的呢?

这不是换了一个工具,是换了一种编程思路:通过定义目标函数来选择最优解,而不是在手动构造解决方案。

接下来,我们就从DSPy的结构来分别介绍其核心组件。

三、Signature:把你的问题"翻译"成 DSPy 能理解的形式

签名 (Signatures):以声明式方式定义任务的输入输出规格,例如用 "question -> answer" 定义问答任务。编译器会根据签名自动生成和优化提示词,替代传统手写提示。

我最初的 Signature 是这样写的:

class SeerAction(dspy.Signature):
    game_state = dspy.InputField()
    check_target = dspy.OutputField()

能用,但很差。因为game_state是一个随意命名的字段,DSPy不知道这个字段里装的是什么数据、格式是什么样的。模型的推理质量取决于模型能否从自然语言描述里推断出字段含义。后来我改成了这样:

class SeerNightAction(dspy.Signature):
    """
    你是狼人杀游戏中的预言家。重要规则(必须严格遵守):
    1. 【禁止自查】check_target 绝不能等于 seer_id。
    2. 【只能查存活】check_target 必须是存活玩家。
    3. 推理必须结合:存活人物情况 + 别人发言 + 查验结果 + 白天投票。
    """
    seer_id = dspy.InputField(
        desc="你自己的玩家ID,例如 'Bot5'"
    )
    game_state = dspy.InputField(
        desc="游戏状态,包含:存活玩家列表、发言摘要..."
    )
    check_target = dspy.OutputField(
        desc="查验目标玩家ID,必须是存活玩家且不能等于 seer_id"
    )
    reasoning = dspy.OutputField(
        desc="推理过程,必须结合存活人物情况进行分析",
        prefix="推理:"
    )

这里有四个关键的设计原则:

1. 字段名要有语义game_state不如alive_players + death_records + recent_speeches清晰。字段名越具体,DSPy的prompt自动生成质量越高。

2. 字段描述(desc)是prompt的核心desc的内容会直接影响模型对字段的理解。"玩家ID"不如"你自己被分配的玩家标识,例如'Bot5'"具体。把格式示例和约束条件都写进去。

3. prefix参数控制输出格式reasoning字段加上了prefix="推理:",这让模型的输出天然带有这个前缀,在解析时不容易混淆。同理,proposal字段用prefix="提议:",speech字段用prefix="发言:"。

4. 把规则写进Signature的docstring,而不是只放在desc Signature的顶层docstring是模型的系统指令,描述的是角色身份和行为规则,这是prompt里权重最高的部分。字段级别的desc只描述单个字段,两者的作用不同,不能互相替代。

四、Module:把 Signature 变成可执行的推理单元

模块 (Modules):封装了特定LLM调用模式的抽象层,可自由组合构建复杂流程。常用内置模块包括:dspy.ChainOfThought(实现思维链)、dspy.ReAct(构建智能体Agent)、dspy.Refine(迭代优化输出)和 dspy.BestOfN(多候选生成并选最优)。

有了 Signature,你需要把它变成一个可以调用函数。DSPy 提供了两种主要方式:

方式一:直接使用dspy.Predict(无推理链)

class SeerModule(dspy.Module):
    def __init__(self):
        super().__init__()
        self.night_action = dspy.Predict(SeerNightAction)

    def forward(self, seer_id, death_records, game_state, known_info, day_vote_info):
        return self.night_action(
            seer_id=seer_id,
            death_records=death_records,
            game_state=game_state,
            known_info=known_info,
            day_vote_info=day_vote_info,
        )

方式二:使用dspy.ChainOfThought(强制推理链)

class SeerModule(dspy.Module):
    def __init__(self):
        super().__init__()
        self.night_action = dspy.ChainOfThought(SeerNightAction)

    def forward(self, ...):
        return self.night_action(...)

两者的区别是:dspy.Predict直接输出结果,dspy.ChainOfThought强制模型先生成推理过程再输出结果。对于狼人杀推理场景,ChainOfThought 是更合适的选择,因为:

1. 推理过程本身就是游戏状态评估的一部分,可以用来做审计
2. ChainOfThought 的中间输出能让 metric 对"推理质量"做细粒度评估
3. 在 DSPy 编译阶段,ChainOfThought 更容易被自动优化器识别和调整

五、Metric:不只是打分,是定义问题本身

Metric(评估指标)是一个函数,它定义了"什么样的输出是好的"。它是优化器(Teleprompt)的评分标准,告诉编译器"朝哪个方向优化"。

我最早写的 metric 只有一行:

def seer_metric(example, pred, trace=None):
    return 1.0 if pred.check_target == example.check_target else 0.0

结果:模型的准确率上去了,但出现"自查"这个bug。因为这个 metric只衡量"答案对不对",不衡量"有没有违规"。

改进后的 metric 把规则遵守作为独立维度:

def _is_rule_compliant(example, pred):
    if pred.check_target == example.seer_id:
        return False  # 自查违规
    alive_players = _extract_alive_players(example.game_state)
    if alive_players and pred.check_target not in alive_players:
        return False  # 查死人违规
    return True

def seer_combined_metric(example, pred, trace=None):
    rule_score   = 1.0 if _is_rule_compliant(example, pred) else 0.0
    answer_score = 1.0 if pred.check_target == example.check_target else 0.0
    reasoning_score = seer_reasoning_quality(example, pred)

    # 0.4 规则 + 0.4 答案 + 0.2 推理质量
    return 0.4 * rule_score + 0.4 * answer_score + 0.2 * reasoning_score

Metric 的天花板就是系统能力的上限。如果你只衡量"对不对",系统就只学会答对题。如果你不衡量推理质量,系统就只会输出最短的推理路径,哪怕那个推理是空洞的。

六、Optimizers:根据打分选择最优解

优化器 (Optimizers):即“编译器”,通过分析训练数据和指标自动优化整个程序。常用策略包括BootstrapFewShot(生成Few-shot示例)、MIPROv2(综合优化)和COPRO(生成并优化任务指令)。

我一开始使用的是BootstrapFewShot,但是每次都只会选择同一个Few-shot,导致大模型每次输出的结果都相差不大,这里我就准备换一个优化器,最终我选择了MIPROv2作为我们项目的优化器,这是我们的一个优化器的参数配置:

optimizer = MIPROv2(
    metric=seer_combined_metric,
    num_candidates=10,          # 候选 prompt 数量
    max_bootstrapped_demos=4,   # 自助采样的示例上限
    max_labeled_demos=8,        # 直接作为 few-shot 的示例上限
    metric_threshold=0.85,       # 达到 85% 就提前停止
)

compiled = optimizer.compile(
    module(),
    trainset=trainset,
    num_trials=12,                       # 搜索次数(这里是 compile 的参数)
    requires_permission_to_run=False,
)

写在最后:DSPy 教会我的最重要的事

用 DSPy 做狼人杀 AI,我最大的感受不是“这个工具真好用”,而是重新理解了什么叫“定义问题”。传统编程追问的是“怎么让模型输出正确答案”,而在 DSPy 的框架里,问题变成了“怎么定义什么是正确答案”——听起来差不多,实际上天差地别。

前者的答案是“写更好的 prompt”,后者的答案则是把对问题的理解翻译成可量化的 metric,这要求你对问题本身有足够深的把握,才能设计出正确的目标函数。DSPy 并不会帮你理解问题,它只能在你真正理解问题之后,帮你找到更好的解法——这个顺序不能颠倒。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐