提示评测自动化:把 Prompt 质量变成可回归测试

很多人调 Prompt 的体验是这样的:改一版、肉眼看几个例子觉得「好多了」,过两周再改,又觉得「好像不如上次」。问题不在模型,而在评测方式——我们一直用「人眼对比」代替「可复现的测试」。本文把 Prompt 工程里的评测自动化讲清楚:如何设计评测集、如何用代码跑断言、如何用 LLM 当裁判、如何把分数接进 CI 做回归,最后介绍几个现成框架。

为什么 Prompt 也要有评测

Prompt 本质是一段「代码」:它控制模型的输入、约束输出格式、定义角色与边界。既然代码要写单元测试,Prompt 也该有对应的测试。人眼比对至少有三个硬伤:

  1. 不可复现:同一版 Prompt,今天和一周后看同一个输出,你的判断标准已经漂移了。没有固定用例,就没有「这次比上次好」的客观依据。
  2. 版本漂移:Prompt 被多人改过几次后,你根本不知道哪次改动让某类输入变差了。没有基线,回滚都无从回滚。
  3. 团队协作需要基线:当三个人都在改同一个系统 Prompt,争论「哪种写法更好」没有意义,需要的是「在 fixed 评测集上,谁的版本综合分更高」。

把评测做成可回归的测试后,每次改动都能跑同一套用例,输出一张分数表。质量从「感觉还行」变成「通过率 92% 升到 95%」。

评测集设计:golden set 与评测维度

固定用例集(golden set)

评测集是若干条「输入加期望」的固定样本。每条样本至少包含:

  • input:喂给模型的输入(用户问题、上下文、变量等)。
  • expect:对输出的约束或参考答案。约束可以是「必须包含某关键字」「必须能解析为合法 JSON」「应该与参考答案语义相近」。

建议把评测集单独存成文件(JSON 或 YAML),和程序代码一起进版本库,这样改 Prompt 时评测集不动,保证可比性。

[
  {
    "id": "case_001",
    "input": "把『今天天气不错』翻译成英文",
    "expect": {
      "contains": ["The weather"],
      "format": "plain_text"
    }
  },
  {
    "id": "case_002",
    "input": "提取订单信息:用户张三,金额 199 元,地址北京",
    "expect": {
      "json_schema": {
        "type": "object",
        "required": ["name", "amount", "city"],
        "properties": {
          "name": {"type": "string"},
          "amount": {"type": "number"},
          "city": {"type": "string"}
        }
      }
    }
  }
]

四个常用评测维度

  • 相关性:输出是否真正回答了输入的问题,没有跑题。
  • 格式合规:是否如期返回 JSON、是否包含必要字段、是否遵守长度限制。
  • 安全性:是否拒绝违规请求、是否产生有害内容、是否存在提示注入泄漏。
  • 成本:单次调用的 token 消耗与延迟(延迟通常不作为通过门槛,但可作为监控指标)。

前三个维度通常作为「是否通过」的硬性断言,第四个维度作为趋势监控。

用代码跑评测:pytest 化与 LLM 裁判

思路是把每条用例写成一个 pytest 测试用例,在用例里调用模型,再对返回文本做断言。这样你能直接复用 pytest 的收集、并行、失败报告与 CI 集成能力。

用断言约束输出

下面这个例子覆盖三种常见约束:关键字包含、正则匹配、JSON Schema 校验。

import json
import re
import pytest
from jsonschema import validate
from openai import OpenAI

client = OpenAI()  # 读取环境变量 OPENAI_API_KEY;兼容端点见后文


def call_model(prompt: str, user_input: str):
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "prompt": prompt},
            {"role": "user", "content": user_input},
        ],
        temperature=0,
    )
    return resp.choices[0].message.content


def test_contains_keyword():
    out = call_model("只翻译下面这句话,不要解释。", "今天天气不错")
    assert "The weather" in out


def test_regex_match():
    out = call_model("只输出一个邮箱地址,不要其他内容。", "我的邮箱是 a@b.com")
    assert re.search(r"[\w.]+@[\w.]+", out) is not None


def test_json_schema():
    schema = {
        "type": "object",
        "required": ["name", "amount", "city"],
        "properties": {
            "name": {"type": "string"},
            "amount": {"type": "number"},
            "city": {"type": "string"},
        },
    }
    out = call_model(
        "提取订单字段并只返回 JSON。",
        "用户张三,金额 199 元,地址北京",
    )
    data = json.loads(out)  # 若模型返回带 json 代码围栏,需先剥离
    validate(instance=data, schema=schema)

注意:模型常把 JSON 包在 Markdown 代码围栏里返回。生产断言里要先做「去围栏」清洗,再 json.loads,否则会因为格式噪声误判。

用 LLM-as-Judge 打分

有些质量(相关性、安全性、语气)无法用正则或 Schema 表达,适合交给另一个模型当裁判。这是有学术依据的:Zheng 等的论文《Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena》(arXiv 2306.05685,NeurIPS 2023)指出,像 GPT-4 这样的强模型做裁判,与人类偏好的一致率能超过 80%,和人类之间的互评一致率处于同一水平。该论文也提醒裁判存在位置偏差、冗长偏差与自增强偏差,因此裁判 Prompt 应当要求「先给理由、再给分数」,并尽量固定裁判模型。

下面给出一个可运行示例,调用 OpenAI 或兼容 API 做裁判,对「输出相关性」打出 1 到 5 分。

import os
from openai import OpenAI

"兼容端点示例:把 base_url 指向你自己的网关即可,例如本地 Ollama、vLLM 等"
client = OpenAI(
    api_key=os.environ.get("OPENAI_API_KEY", "sk-待核实"),
    base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)


def llm_judge(question: str, answer: str, rubric: str):
    judge_prompt = f"""你是评分裁判。请先写一句评分理由,再在最后一行只输出 1 到 5 的整数分数。
评分标准:{rubric}
用户问题:{question}
模型回答:{answer}
"""
    resp = client.chat.completions.create(
        model=os.environ.get("JUDGE_MODEL", "gpt-4o"),
        messages=[{"role": "user", "content": judge_prompt}],
        temperature=0,
    )
    text = resp.choices[0].message.content
    # 取最后一行里的首个数字作为分数
    last_line = [line for line in text.strip().splitlines() if line.strip()][-1]
    score = int(re.search(r"[1-5]", last_line).group())
    return score


def test_relevance_by_judge():
    question = "用一句话解释什么是向量数据库"
    answer = call_model("用一句话解释什么是向量数据库。", question)
    score = llm_judge(
        question,
        answer,
        "答案是否准确、切题,且在一句话内说清核心概念",
    )
    assert score >= 4

用法提示:裁判模型建议用比被评模型更强的模型;对关键场景可做「两个裁判互评取一致」,缓解单一裁判的位置偏差。裁判调用的单价(待核实,取决于所用模型与调用量)。

评分指标

把单条断言汇总成可比较的指标,常见有五类:

  • 精确匹配(Exact Match):输出与标准答案逐字符一致。适合分类、固定模板场景,但自然语言任务太严,通常不单独使用。
  • 包含率(Contains / Recall):输出是否覆盖期望关键字或关键事实。实现简单,适合格式与要点检查。
  • 结构化校验:用 JSON Schema、正则或类型解析校验输出结构。能稳定拦截「格式崩了」这类硬错误。
  • 语义相似(Semantic Similarity):把输出与参考答案各自 embedding 后算余弦相似度,缓解表述差异。下面的示例用 embedding 接口计算。
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", "sk-待核实"))


def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = sum(x * x for x in a) ** 0.5
    nb = sum(y * y for y in b) ** 0.5
    return dot / (na * nb)


def semantic_similarity(text_a: str, text_b: str):
    emb = []
    for t in (text_a, text_b):
        r = client.embeddings.create(
            model=os.environ.get("EMBED_MODEL", "text-embedding-3-small"),
            input=t,
        )
        emb.append(r.data[0].embedding)
    return cosine(emb[0], emb[1])


def test_semantic_close():
    ref = "向量数据库用于存储高维向量的嵌入表示,支持按相似度检索。"
    got = call_model("一句话说明向量数据库用来干什么。", "向量数据库是做什么的")
    assert semantic_similarity(ref, got) >= 0.82  # 阈值按业务自定,待核实
  • 裁判一致性(Judge Agreement):多个裁判或裁判与人类标注之间的吻合度,可用一致率、Cohen’s kappa 等度量。它衡量的是「评测本身可不可信」,建议在正式把 LLM 裁判纳入门槛前先人工抽检一批,确认一致率达标。

回归与 CI:每次改 Prompt 跑全套用例

把评测接进 CI,核心是「修改 Prompt 即触发测试,未达门槛则失败」。下面是一段 GitHub Actions 配置示例,它在 push 或针对 prompt 文件的改动时运行 pytest。

name: prompt-eval
on:
  push:
    paths:
      - "prompts/**"
      - "tests/test_prompt_eval.py"
  pull_request:
    paths:
      - "prompts/**"

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt
      - name: Run prompt evaluation
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          JUDGE_MODEL: gpt-4o
        run: pytest tests/test_prompt_eval.py --junitxml=report.xml
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eval-report
          path: report.xml

门槛策略建议分层:格式类与安全性断言「零容忍」,必须全过;相关性、语义相似这类软指标设最低通过率(例如 90%),低于门槛就让流水线失败。这样你得到的是「可回归」而不是「可观赏」的评测。

评测框架简介

自己写 pytest 适合轻量场景,团队规模化后可以借助成熟框架:

  • promptfoo:开源(MIT 许可,仓库声明已并入 OpenAI)的 LLM 评测与红队工具,用声明式 promptfooconfig.yaml 定义 Prompt、模型与断言,支持 GitHub Actions 等 CI 集成,本地运行、Prompt 不出本机。安装为 npm install -g promptfoo,常用命令 promptfoo initpromptfoo evalpromptfoo view,也提供 pip install promptfoobrew install promptfoo。支持 OpenAI、Anthropic、Azure、Bedrock、Ollama 等大量 provider(已核验于官方仓库)。
  • LangSmith datasets:LangChain 生态的评测平台,可把数据集在线管理、跑在线评测并对比不同 Prompt 版本;在线服务,涉及用量计费(定价待核实)。
  • Ragas:专注 LLM 应用(尤其 RAG 管线)的评测库,提供忠实度(Faithfulness)、回答相关性(Response Relevancy)、上下文精度(Context Precision)、上下文召回(Context Recall)、上下文实体召回(Context Entities Recall)等指标,也覆盖 Agent、SQL、摘要等场景;以 experiments 为先,可与 LangChain、LlamaIndex 集成(已核验于官方文档)。

选型参考:纯 Prompt 回归、想要本地与 CI 友好,先看 promptfoo;做 RAG 质量,看 Ragas;已经在用 LangChain 并想要托管数据集与版本对比,看 LangSmith。

小结

  • Prompt 也是「代码」,应当有可复现、可回归的测试,而不是靠人眼比对。
  • 评测集(golden set)要固定进版本库,覆盖相关性、格式合规、安全性、成本四个维度。
  • 用 pytest 把每条用例写成断言:关键字包含、正则、JSON Schema 校验负责「硬约束」。
  • 相关性、语气等软质量用 LLM-as-Judge 打分,但要留意位置/冗长/自增强偏差,并先验证裁判与人类的一致率。
  • 把 pytest 接进 CI,格式与安全零容忍、软指标设最低通过率,未达门槛即失败,质量才算「可回归」。
  • 规模化可引入 promptfoo、Ragas、LangSmith 等框架,按场景选型。

参考与延伸阅读

  • promptfoo 官方仓库与文档(GitHub: promptfoo/promptfoo;官网 promptfoo.dev):CLI/库、MIT 开源、已并入 OpenAI、provider 与 CI 集成说明 —— 已核验。
  • Ragas 官方文档(docs.ragas.io):RAG 与 LLM 应用评测指标、experiments 工作流、框架集成 —— 已核验。
  • Zheng 等《Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena》,arXiv:2306.05685,NeurIPS 2023:LLM 裁判与人类偏好一致率超 80%,及位置/冗长/自增强偏差的讨论 —— 已核验。
  • LangSmith datasets(docs.smith.langchain.com):在线评测与数据集管理 —— 官网存在,具体定价待核实。
  • 文中涉及的模型名称、embedding 相似度阈值(如 0.82)、裁判最低分门槛(如 4 分)均按示例给出,实际数值与 API 单价待核实。
本文累计阅读