promptfoo是开源的Prompt测试框架,支持YAML配置测试用例、输出断言、版本对比和CI集成,可对prompt实现测试驱动开发。
你不会在没有测试的情况下发布一个支付函数。但决定哪些客户获得退款的提示词呢?却靠"感觉"发货——在聊天窗口里改改、目测一次,然后粘贴到生产环境。接着模型变了,或者有人"优化"了措辞,退款机器人就开始用法语道歉了。
promptfoo(promptfoo.dev)是一个开源工具,用来解决这个问题:一套用于测试驱动 LLM 开发的 CLI 和库。你在 YAML 配置中定义提示词、提供者和测试用例,为输出附加断言,然后像运行 pytest 一样运行 promptfoo eval。它并排比较提示词版本,给每个输出打分,并导出可用于门禁部署的机器可读结果。
本教程完整走一遍整个流程。我们将构建一个工单分类机器人,编写两个竞争性提示词,针对三条工单用八种断言类型进行测试,看看那个naive的提示词如何在生产环境中精确地失败,通过 CSV 文件扩展测试套件,并把整个流程接入 CI。下面每条命令都已在 2026 年 9 月 30 日针对 promptfoo 0.123.1 执行过,所有展示的输出都是真实的。最棒的是:无需 API key、无需 GPU、无需花费——整个教程基于确定性 mock 提供者运行,完全免费且完美可复现。
Node.js 22 或更高版本——用 node --version 检查。promptfoo 作为 npm 包发布;我们通过 npx 运行它,所以不会全局安装任何东西。
Python 3——仅用于第 2–7 步的 mock 提供者(约 60 行脚本,无依赖)。
一个终端,以及大约 10 分钟的耐心——第一次 npx 调用会通过 npm 下载包;之后会被缓存。
无需 API key、无需账号、无需 GPU。模型评分断言(如 llm-rubric)存在且有文档,但本教程使用确定性断言,因此所有操作都可以离线运行。
其实没有安装步骤——npx 按需获取并运行最新版本:
npx -y promptfoo@latest --version
# 0.123.1
这是本教程所有命令验证过的版本。如果你希望永久放在 PATH 中,npm install -g promptfoo 也可以——命令完全相同。建议快速阅读 promptfoo eval --help 的完整内容;我们实际会用到的参数列表(--no-cache、--filter-prompts、-o、--table)不多,但 CLI 还支持 first-N 过滤、元数据过滤、采样、输出转换以及 JUnit/XML/CSV/HTML 导出。
promptfoo 的 providers 列表接受真实模型 ID(openai:gpt-6-luna、anthropic:messages:claude-opus-4-6、google:gemini-3.8-flash、ollama:llama2——名称直接来自文档),但也接受导出 call_api(prompt, options, context) 函数的 Python 文件。文档将"创建用于测试的 mock 提供者"列为一等用例,这是学习的完美方式:确定性输出、零成本、零延迟、零不稳定。
创建一个项目目录并将其保存为 mock_support_bot.py。它读取渲染后的提示词,检测正在测试的提示词变体,并返回基于工单关键词的罐头响应:
"""Deterministic mock support-bot: no network, no API key."""
def _variant(prompt: str) -> str:
# The v2 prompt explicitly asks for JSON output; v1 does not.
if "Return your answer as JSON" in prompt:
return "v2"
return "v1"
def _subject(ticket: str):
tl = ticket.lower()
if "password" in tl or "reset" in tl:
return "password reset email issue", "account"
if "charged" in tl or "refund" in tl or "invoice" in tl:
return "duplicate billing charge", "billing"
return "router connectivity issue", "network"
def call_api(prompt, options, context):
# promptfoo passes the fully-rendered prompt; extract the ticket line.
ticket = ""
for line in prompt.splitlines():
if line.lower().startswith("ticket:"):
ticket = line.split(":", 1)[1].strip()
subject, dept = _subject(ticket)
variant = _variant(prompt)
if variant == "v2":
import json
return {"output": json.dumps({
"subject": subject,
"department": dept,
"urgency": "high" if "charged" in ticket.lower() else "normal",
})}
return {"output": f"Subject: {subject}. Department: {dept}."}
在接入 promptfoo 之前,用一个小驱动进行完整性检查:
python - <<'EOF'
import mock_support_bot
r = mock_support_bot.call_api("Ticket: I was charged twice", {}, {})
print(r["output"])
EOF
你应该看到 Subject: duplicate billing charge. Department: billing.——mock 能穿透到 v1 的纯文本契约。
保存两个提示词文件。naive 的 v1 提示词:
Triage this support ticket. Respond with the subject and department.
Ticket: {{ticket}}
以及一个加固版的 v2 提示词:
You are a support-ticket triage classifier.
Return your answer as JSON with exactly these keys: subject, department, urgency.
- subject: one short noun phrase naming the issue
- department: one of account, billing, network
- urgency: "high" if the customer was charged money, otherwise "normal"
Ticket: {{ticket}}
v2 契约从构造上就是可测试的:JSON 输出、封闭的部门集合、以及作为句子陈述的紧急程度规则。
配置就是整个套件。将其保存为 promptfooconfig.yaml:
description: Support-ticket triage bot — v1 vs v2 prompt shootout
prompts:
- file://v1.txt
- file://v2.txt
providers:
- file://mock_support_bot.py
tests:
- vars:
ticket: "I was charged twice for the same invoice"
assert:
- type: icontains
value: "billing"
- type: icontains
value: "duplicate billing charge"
- type: regex
value: "urgency.*high|high.*urgency"
- type: python
value: "len(output) < 200"
- vars:
ticket: "The password reset email never arrives"
assert:
- type: icontains
value: "account"
- type: icontains
value: "password reset"
- type: is-json
value: true
- vars:
ticket: "Router keeps dropping the Wi-Fi connection"
assert:
- type: icontains
value: "network"
- type: icontains
value: "router connectivity"
四种类型共八个断言。注意组合:icontains 和 regex 检查内容,python 检查长度属性,is-json 检查输出契约本身——这正是 v1 会失败的地方。
promptfoo eval --no-cache
--no-cache 参数很重要:promptfoo 默认缓存结果,而当你迭代提示词时,过期缓存读取是经典的假绿色陷阱。

运行产生一个通过/失败网格——2 个提示词 × 3 个测试——加上一份详细报告。用 promptfoo view 打开 Web 查看器,点击查看每个输出和断言结果。记分牌说明了一切:v2 通过所有测试;v1 在密码重置工单上未通过 is-json 断言,在账单工单上未通过 urgency 正则表达式——正是这两个失败模式会在生产环境中让你难堪。
用 --table 运行以获得终端可读的版本:
promptfoo eval --no-cache --table
每一行显示提示词、测试变量、输出以及每个断言的通过/失败。账单工单的 v1 行读起来像一份事后报告:输出 Subject: duplicate billing charge. Department: billing.——主题正确,部门正确,但完全没有紧急程度信号,所以正则表达式失败。而密码工单的输出是纯文本,所以 is-json 失败。
这就是整个练习的意义:套件捕获了提示词中的两个真实回归,而非模型中的。没有套件,v1 就发货了,账单紧急程度 SLA 悄然降级,JSON 契约在某个周五破坏了一些下游解析器。
在 YAML 中硬编码测试在十几个用例左右就无法扩展了。promptfoo 也可以读取 CSV——相同的列即变量契约:
ticket,__expected
"I was charged twice for the same invoice",billing
"The password reset email never arrives",account
"Router keeps dropping the Wi-Fi connection",network
tests:
- file://tickets.csv
assert:
- type: icontains
value: "{{__expected}}"
五十张工单变成五十个测试,无需触碰配置。从工单追踪器生成 CSV——昨天的真实工单就是明天的回归套件。
两个参数保持循环紧凑。--filter-prompts v2 只运行 v2 提示词(迭代候选时跳过已知良好的 v1),-o results.json 写入机器可读的输出用于对比:
promptfoo eval --no-cache --filter-prompts v2 -o results.json
然后将 results.json 与上一次运行进行对比。提示词迭代变成:改措辞、运行套件、对比——与单元测试相同的红绿循环。
评估在断言失败时退出非零,这就是整个 CI 契约。一个 GitHub Actions job:
name: prompt-evals
on: [pull_request]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: npx -y promptfoo@latest eval --no-cache -o results.json,junit.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: promptfoo-results, path: "results.json,junit.xml" }
现在一个破坏 JSON 契约的提示词变更会像破坏的单元测试一样让 PR 失败。对于生产套件中的模型评分断言,注意成本影响:每次 CI 运行都会消耗评判模型 tokens,所以很多团队在每个 PR 上运行确定性断言,而将 llm-rubric 评估留给夜间运行或发布分支。

如果你有以下情况,就采用它:迭代提示词、比较模型或提示词版本,或者曾经被一个"小措辞调整"悄然改变了行为。mock 提供者工作流意味着这个测试工具零成本可试。
从确定性断言开始。is-json、icontains、regex 和 javascript/python 检查快速、免费且稳定。只在人类判断真正构成规格的地方添加模型评分断言。
关注接缝。退出码行为意味着你必须自己接入门禁——不要假设红色意味着 CI 失败。Mock 提供者验证的是工具链,而非模型;在信任绿色之前,用 temperature: 0 和 --repeat 针对真实提供者重新运行套件。而对于非确定性输出,需要统计思维:一次通过不是证明。
如果你的提示词真的是一次性的且从不改变,可以跳过。但一旦提示词成为基础设施——一个客服机器人、一个分类器、一个 RAG 管道——它就是代码,而没有测试的代码是负债。
本教程展示的不舒服的真相是:这有多容易。一个 YAML 文件、两个提示词文件、一个 60 行的 mock 和八个断言——突然之间"新提示词还能工作吗"成了一个有答案的问题,而不是一个希望。v1 提示词失败不是因为它傻;而是因为没有人写下"正常工作"的定义。promptfoo 就是把定义写下来的纪律,以机器可以在每次提交时检查的形式。
今晚就克隆这个模式:挑选你堆栈中重要的一个提示词,为它的输出契约写三个带断言的测试用例,然后运行 promptfoo eval。如果变绿,你就有了一个回归套件。如果变红,你刚刚发现了你的用户本来会帮你发现的 bug。