跳到主要内容

数据截至 (上游 commit c149fcf36c2a)

Promptfoo — 从 YAML 到测试矩阵:配置解析与笛卡尔积展开

30 秒导读: Promptfoo 跑一次评测,本质是填一张表——行是"测试用例",列是"provider × prompt"。 这一章只讲这张表是怎么被算出来的:从一份 promptfooconfig.yaml 开始,经过读配置、解引用、 合并、加载 prompt/测试数据,最后被一组嵌套循环展开成 N 个待跑单元。是下一章的事(02)。


1. 这一章讲什么(零基础也能懂)

一句话: 把"人写的配置"翻译成"机器要跑的清单"。

Promptfoo 是一个 LLM 评测工具。你写一份 YAML,说清三件事:

你写什么意思
prompts要测哪几套提示词
providers要打哪几个模型 / 被测目标
tests每条测试灌什么变量、期望什么结果

它跑完给你一张对比表:每行一条测试,每列一个「provider + prompt」组合,格子里是模型输出和判定结果。

用起来什么样

这是仓库自带的最小例子 examples/getting-started/promptfooconfig.yaml:

prompts:
- 'Convert the following English text to {{language}}: {{input}}'

providers:
- openai:chat:gpt-5.5
- openai:chat:gpt-5.4-mini

tests:
- vars:
language: French
input: Hello world
assert:
- type: contains
value: 'Bonjour le monde'
- vars:
language: Spanish
input: Where is the library?
assert:
- type: icontains
value: 'Dónde está la biblioteca'

1 套 prompt × 2 个 provider × 2 条测试 = 4 次模型调用。这个「4」不是跑的时候慢慢冒出来的, 而是开跑前就被完整算好、排成一个数组。这一章讲的就是这个"算好"的过程。

一句话直觉

它就是 CI 里的 test matrix,或者 SQL 里的 CROSS JOIN 你声明几个维度,它替你把所有组合乘出来。 区别只在于:维度不是"操作系统 × Node 版本",而是"测试用例 × 变量取值 × 目标模型 × 提示词模板"。

一个必须先立住的词

后面反复出现的承重词是 RunEvalOptions——一个待执行的评测单元,包含"用哪个 provider、哪条 prompt、 哪套 vars、跑第几遍"这四件事,是这一章流水线的最终产物。它对应结果表里的一个单元格。


2. 顶层全景(它大概怎么转)

先看整条流水线。从上往下是数据形态的四次变身,每一步的名字就是负责它的那个函数:

promptfooconfig.yaml (可以给多份 / 带 glob)

│ readConfig → dereferenceConfig → combineConfigs

① UnifiedConfig 「原始配置」providers 还是字符串,prompts 还是文件路径

│ resolveConfigs

② TestSuite 「解析后」Prompt[] / ApiProvider[] / TestCase[] 都已就位

│ buildTestsFromSuite → filterByRange → prepareTestVariables

③ AtomicTestCase[] 「每条测试的 vars 已跟 defaultTest 合并完」

│ buildRunEvalOptions(四层嵌套循环)

④ RunEvalOptions[] N 个待跑单元 → 交给执行引擎(见 02)

部件一句话职责

部件干什么在哪个文件
readConfig读一个配置文件,解 $ref、渲染 env、改写别名字段src/util/config/load.ts:334
combineConfigs把多份配置合成一份,按字段用不同策略归并src/util/config/load.ts:527
resolveConfigs把配置里的路径/字符串真正加载成对象,产出 TestSuitesrc/util/config/load.ts:768
readPromptsprompts: 的各种写法变成 Prompt[]src/prompts/index.ts:225
readTeststests: 的各种写法/数据源变成 TestCase[]src/util/testCaseReader.ts:583
buildTestsFromSuite展开 scenarios,得到扁平的 AtomicTestCase[]src/evaluator.ts:2342
buildRunEvalOptions四层循环做笛卡尔积,产出 RunEvalOptions[]src/evaluator.ts:2460

两个入口,同一条下游

Promptfoo 有两个入口,它们在第 ② 步之后汇合:

入口起点怎么造 TestSuite
CLI (promptfoo eval)src/node/doEval.ts:472resolveConfigs(cmdObj, defaultConfig)
Node SDK (promptfoo.evaluate())src/evaluate.ts:317 evaluateWithSourcecreateRuntimeTestSuite(src/evaluate.ts:218)

两者最后都调同一个 evaluate()(src/evaluator.ts:5041),所以第 ③④ 步的展开逻辑是唯一的一份


3. 第一步:readConfig —— 读一个文件

它要解决的小问题: 用户写的 YAML 有历史包袱(字段别名)、有外部引用($ref)、有环境变量占位符, 得先把这些抹平,再交给下游。

readConfig 的处理顺序是固定的五步(src/util/config/load.ts:334-448):

yaml.load → dereferenceConfig → renderConfigEnvTemplates → zod 校验 → 别名改写 + 兜底
读文件 展开 $ref 渲染 {{ env.X }} 只 warn targets→providers …

3.1 zod 校验只警告,不拦截

这是个容易误判的细节:校验用的是 safeParse,失败只打一条 logger.warn,然后照样把原始对象返回 (src/util/config/load.ts:376-382)。所以"配置写错了但还是跑起来了"是设计如此,不是 bug。

3.2 别名改写表

历史上红队(redteam)功能用了另一套字段名,readConfig 在末尾统一改写:

用户写的改写成代码位置
targets:providers:src/util/config/load.ts:410-414
plugins:redteam.pluginssrc/util/config/load.ts:415-420
strategies:redteam.strategiessrc/util/config/load.ts:421-426

3.3 没写 prompts 时的兜底

红队场景不需要显式 prompt,所以缺 prompts 时它塞一个默认值:

ret.prompts = ['{{prompt}}'];

—— src/util/config/load.ts:446。塞之前还会判断"是不是真的忘了写":如果 tests 里没有名为 prompt 的变量、 目标也不是多输入型,就先 logger.warn 提醒一句(src/util/config/load.ts:427-445)。


4. dereferenceConfig —— $ref 解引用,以及一个必须"挖坑再填"的坑

它要解决的小问题: 让配置能拆成多个文件复用,用 JSON Schema 的 $ref 语法互相引用。

实现只有一行是真活:await $RefParser.dereference(rawConfig)(src/util/config/load.ts:264)。 其余五十多行全是在给这一行打补丁

为什么要打补丁

函数调用(function calling)的 tools[i].function.parameters 本身就是一份 JSON Schema,里面合法地出现 $ref——那是给模型看的,不是给 promptfoo 看的。如果直接解引用,这些 schema 会被展开成一坨,发给模型就错了。

于是有了"挖坑—填坑"的三段式(对应 issue #364,注释在 src/util/config/load.ts:188-189):

① 挖出来: extractFunctionParameters / extractToolParameters
把 provider.config.functions[i].parameters 和
provider.config.tools[i].function.parameters 摘走并 delete

② 解引用: $RefParser.dereference(rawConfig) ← 此时看不到那些 schema

③ 填回去: restoreFunctionParameters / restoreToolParameters

四个辅助函数分别在 src/util/config/load.ts:192:196:207:218

逃生舱:PROMPTFOO_DISABLE_REF_PARSER=1 可整体跳过解引用,函数第一行就返回原配置 (src/util/config/load.ts:184-186)。


5. combineConfigs —— 多份配置怎么合成一份

它要解决的小问题: promptfoo eval -c a.yaml -c b.yaml-c 'configs/*.yaml',得把 N 份配置揉成 1 份。

先注意入口就支持 glob:每个 -c 参数都过一遍 globSync,展开后逐个 readConfig (src/util/config/load.ts:532-544)。

5.1 不同字段,不同合并策略

没有统一的 merge 规则——每个字段按语义各定各的:

字段策略代码位置
providers拼接 + 去重(providerDedupeKey):514-536
tests拼接;字符串路径就地 readTests 展开:538-552
prompts拼接 + 去重;相对路径转绝对路径(makeAbsolute):600-657
scenariosflatMap 拼接:666-668
extensions拼接,>1 个时打 warn(不区分来源):554-564
redteam.plugins/strategies/entities并集去重后排序:575-585
redteam 其它键后者覆盖前者:586-589
tags / env / metadata / evaluateOptions / nunjucksFilters浅合并,后者覆盖:661:700-713
description各配置的描述用 , 拼成一句:662
outputPathflatMap 成数组:703-709
sharing任一份写了 false 就整体关闭:717-724
tracing第一个有值的配置:725

去重键值得单看一眼:providerDedupeKey(src/util/config/load.ts:497)对函数和 provider 实例用引用同一性, 对含函数的配置对象把函数引用序列化进 key——因为配置归一化过程可能克隆外层对象,单纯比对象会漏。

5.2 defaultTest 的三条归并规则

defaultTest 是"所有测试的公共底座",合并规则单独写在 src/util/config/load.ts:702-726,共三条:

  1. 文件引用优先且黏住。 任一份配置的 defaultTestfile:// 字符串,结果就是那个字符串; 一旦结果已经是字符串,后面的对象不再合并(:670-677)。
  2. 对象逐字段深一层合并。 vars / options / metadata 走浅合并(后覆盖前), 顶层其余字段也是后覆盖前(:685-692)。
  3. assert 是拼接,不是覆盖。 [...prev.assert, ...curr.assert](:689)——多份配置的断言会累加

第 3 条是最容易踩的:两份配置各写一条 defaultTest.assert,合并后每条测试跑两条断言。


6. resolveConfigs —— 从 UnifiedConfigTestSuite

这一步是分水岭。 之前所有东西都还是字符串和路径,之后全变成可调用的对象。

6.1 两种类型的区别

UnifiedConfigTestSuite
定义src/types/index.ts:1398src/types/index.ts:1110
providers字符串 / 配置对象 / 路径ApiProvider[](已实例化)
prompts字符串 / 路径 / glob / 对象映射Prompt[](已读出 raw)
tests字符串路径 / 数组 / 生成器配置TestCase[](已展开)
defaultTest可以是 file:// 字符串已加载成对象

源码里对 TestSuiteConfig 的注释一句话说清了这层关系:providers 只是字符串,prompts 只是文件路径 (src/types/index.ts:1238)。

6.2 它按什么顺序干活

① CLI 覆盖 & 兜底 cmdObj.prompts/tests/output 优先于文件配置 load.ts:812-836
② 前置校验 没 prompt / 没 provider 直接失败退出 load.ts:838-875
③ provider 三连 resolveProviderConfigs → CLI 匹配 → filter 过滤 load.ts:884-896
④ 三条并行加载 readPrompts / loadApiProviders / readTests load.ts:910-929
⑤ scenarios 加载 外部文件 + 每个 scenario 内部 readTests + filter load.ts:932-979
⑥ providerPromptMap 建立「哪个 provider 允许跑哪些 prompt」的表 load.ts:984-987
⑦ 组装 + 三道校验 TestSuite 落地,校验断言/provider 引用/prompt 引用 load.ts:1007-1046

第 ③ 步的顺序是有意为之:先按 --filter-providers 过滤配置,再实例化,注释写明了理由—— 避免加载根本用不到的 provider,同时让 file:// 里定义的 provider 也能按 id/label 被匹配到 (src/util/config/load.ts:928-931)。

6.3 defaultTest 在这里被"特殊照顾"

defaultTest: processedDefaultTest
? await readTest(processedDefaultTest, basePath, true)
: undefined,

—— src/util/config/load.ts:858-860。第三个参数 isDefaultTest = true跳过形状校验: 普通测试必须至少含 assert/vars/options/metadata/provider/providerOutput/threshold 之一, 否则抛错(src/util/testCaseReader.ts:456-475);而 defaultTest 允许只写一半。


7. prompts 怎么变成 Prompt[] —— 第一处膨胀点

它要解决的小问题: 用户写 prompts: 的方式有七八种,但下游只认一个扁平的 Prompt[]

7.1 先归一写法:normalizeInput

src/prompts/utils.ts:44 把三种顶层写法统一成 Partial<Prompt>[]:

用户写法归一结果
单个字符串[{ raw: '...' }]
数组(字符串或对象混排)逐项转成 { raw },对象保留其它字段
对象映射 {'p.txt': 'label'}[{ raw: 'p.txt', label: 'label' }](已废弃写法,:76-81)

7.2 再判断"这是字符串还是文件":maybeFilePath

src/prompts/utils.ts:11 用一串启发式规则判断:含 file://、含已知扩展名、含 * / \、 倒数第 3 或第 4 个字符是 . —— 就当文件路径处理。同时硬排除含换行符的、以及 portkey:// / langfuse:// / helicone:// 这三种远程 prompt 协议(:16-19)。

上面那个 getting-started 的 prompt 里没有 /、没有扩展名,所以判为普通字符串,走 processString

7.3 分派到处理器:processPrompt

src/prompts/index.ts:107 按扩展名分派。关键在最后一列:一个文件出几条 prompt。

来源处理器出几条位置
纯字符串processString1processors/string.ts:10
.txtprocessTxtFile多条(按 PROMPT_DELIMITER 分段)processors/text.ts:13
.csvprocessCsvPrompts多条(每行一条,认 prompt/label 列)processors/csv.ts:23
.jsonlprocessJsonlFile多条(每行一条)processors/jsonl.ts:11
.jsonprocessJsonFile1(整个文件序列化成 raw)processors/json.ts:17
.yaml / .ymlprocessYamlFile1(解析后再 JSON.stringify)processors/yaml.ts:20
.mdprocessMarkdownFile1(整篇当一条)processors/markdown.ts:5
.j2processJinjaFile1processors/jinja.ts:13
.js/.tsprocessJsFile由函数决定processors/javascript.ts:25
.pyprocessPythonFile由函数决定processors/python.ts:85
exec: 前缀 / 可执行文件processExecutableFile由程序输出决定processors/executable.ts:118

glob 是另一层膨胀:processPrompt 判断出这是 glob 后,对每个命中文件递归调用自己, 且 maxRecursionDepth 从 1 减到 0——glob 只展开一层,不会无限套娃(src/prompts/index.ts:145-170)。 如果 glob 一个都没命中,它退化成"把这个字符串当 prompt 用"(:162-168)。

所以:prompts: 里写 3 项,最终可能得到 30 条 Prompt 后面的笛卡尔积乘的是 30,不是 3。

7.4 readProviderPromptMap —— 谁能跑哪条 prompt

src/prompts/index.ts:41 建一张 provider id/label → 允许的 prompt label 列表 的表。 默认值是"全部 prompt";只有当 provider 配置里显式写了 prompts: 字段,才收窄 (src/prompts/index.ts:86)。这张表在第 ④ 步展开时当闸门用。


8. tests 怎么变成 TestCase[]

它要解决的小问题: 测试数据可能在 YAML 里内联,也可能在一个 CSV、一张 Google Sheet、 一个 HuggingFace 数据集里。

8.1 入口分派:readTests

src/util/testCaseReader.ts:583tests 的类型分四路:

tests 是什么?
├─ 字符串
│ ├─ az://… → readStandaloneTestsFile (:559-561)
│ ├─ *.yaml / *.yml → loadTestsFromGlob (:563-565)
│ └─ 其它 → readStandaloneTestsFile (:567)
├─ { path, config } → readStandaloneTestsFile(带 config) (:568-575)
├─ 数组 → 逐项:
│ ├─ .py/.js/.xlsx/含 ':' → readStandaloneTestsFile (:586-593)
│ ├─ 其它字符串 → loadTestsFromGlob (:596)
│ └─ 内联对象 → readTest (:602)
└─ 其它 → 打一条格式 warn (:605-613)

8.2 支持的数据源

readStandaloneTestsFile(src/util/testCaseReader.ts:90)是数据源总闸:

前缀 / 扩展名走哪条路位置
huggingface://datasets/…fetchHuggingFaceDataset(支持 ?split=?config= 查询参数):98-103
az://…Azure Blob,再按 csv/json/jsonl/yaml 二次分派:105-107:127
https://docs.google.com/spreadsheets/…fetchCsvFromGoogleSheet:110-114
*.sharepoint.com/…fetchCsvFromSharepoint:115-119
本地 .csvreadLocalCsvRowscsvRowsToTestCases:203-208
本地 .xlsx / .xls(可带 #Sheet1)parseXlsxFile:209-214
本地 .json / .jsonl / .yaml各自 parse:215-234
本地 .py / .js(可带 :funcName)调函数生成,默认函数名 generate_tests:190-201:290-301

CSV 行 → TestCase 的映射规则src/csv.ts:238testCaseFromCsvRow: 普通列变成 vars,__expected / __expected1 / __expected2 … 这类特殊列变成断言(src/csv.ts:277-346)。 断言语义本身见 04

没写 description 的行会被自动补成 Row #N(src/util/testCaseReader.ts:235-241)。

8.3 readTest 的收尾三件事

每条测试最后都过一遍 readTest(src/util/testCaseReader.ts:426):

  1. vars 可以是文件。 vars 写成字符串或字符串数组时,当作 YAML 文件路径去读并合并 (loadTestWithVars,:383-394)。
  2. 测试级 provider 就地实例化。 testCase.provider 是字符串或带 id 的对象时, 立刻 loadApiProvider,路径相对测试文件自己的目录解析(:414-424)。
  3. 形状校验。 见 §6.3。

另外 loadTestsFromGlob(:456)在 YAML/JSON/JSONL 分支上还会跑一次 $RefParser.dereference (:492-495),所以测试文件内部也能用 $ref


9. evaluate() 外壳:交接前的三件事

CLI 那条路走 resolveConfigs;SDK 那条路走 src/evaluate.tsevaluateWithSource(:317)。 后者在把 TestSuite 交给评测器之前,做三件容易被忽略但很关键的事。

9.1 cloneTestForResolve —— 躲开循环引用

src/evaluate.ts:50。问题是这样的:

resolveNestedProviders 要把 test.options.provider 从「字符串」换成「真的 SDK 客户端实例」

但 test 对象可能和「要写进数据库的 unifiedConfig」是同一个引用

Bedrock/Anthropic 的客户端内部有循环引用 → drizzle 的 JSON 序列化在 save() 时炸掉

修法不是深拷贝(太贵),而是精确浅拷贝:只把 optionsassert[] 这两处会被就地改写的字段 拆成新对象(src/evaluate.ts:50-59),其余字段继续共享引用。

注释里明确写了这份契约的边界(src/evaluate.ts:44-48):provider/vars/metadata/providerOutput 仍然是别名,调用方只能整体重新赋值,不能原地改;assert-set 的子断言没深拷贝,因为解析循环会跳过它 (src/evaluate.ts:279)。对应 issue #8687。

9.2 把不可序列化的东西换成占位符

写进数据库的那份配置要能 JSON.stringify。所以:

  • provider 实例 → sanitizeProvider(...)(src/evaluate.ts:61-83)
  • 函数式 transform → 字符串标记 [inline function]: name(replaceFunctionTransforms,src/evaluate.ts:94-113)

替换发生后只打一条汇总警告(靠 droppedRef.value 这个共享标志位攒着,src/evaluate.ts:194-198), 提醒用户"存下来的配置重跑时不会再执行这些函数"。

9.3 writeMultipleOutputs —— 出口统一

outputPath 写成单个字符串或数组都行,统一归一成数组再交给 writeMultipleOutputs (src/evaluate.ts:362-373)。注释点明这是为了和 CLI 那条路(src/node/doEval.ts:1114)保持一致。 结果怎么落地见 06


10. 核心:笛卡尔积展开

到这里 TestSuite 已经就绪。这一节是本章的主线。

10.1 展开链总览

_runEvaluation 里,五个步骤按固定顺序发生(src/evaluator.ts:4722-4740):

buildCompletedPrompts provider × prompt → CompletedPrompt[](表的「列」) :4551

buildTestsFromSuite 展开 scenarios → AtomicTestCase[] :4556

filterByRange 按 --filter-range 切片(切的是「测试」不是「格子」) :4557

prepareTestVariables 合并 defaultTest.vars + 跑 transformVars :4560

buildRunEvalOptions 四层嵌套循环 → RunEvalOptions[] :4567

10.2 先算"列":buildCompletedPrompts

src/evaluator.ts:2248-2291。双层循环 provider × prompt,每个组合产出一个 CompletedPrompt, 被 providerPromptMap 挡掉的跳过(:2045-2047)。紧接着 buildPromptIndexMap(:2068) 建 "${provider}:${promptId}" → 下标 的索引——这个下标就是结果表里的列号

10.3 再算"行":buildTestsFromSuite 与 scenarios

src/evaluator.ts:2342。先取初始测试(getInitialTests,:2142),这里有个反直觉的分支:

情况初始测试
tests 非空就是 tests
tests 空,但有 scenarios[]
tests 空,也没 scenarios[{}]——一条空测试

最后一条是为了让"只有 prompts + providers、没有 tests"的配置也能跑起来(src/evaluator.ts:2363)。

scenarios 是三层乘法(src/evaluator.ts:2350-2355):

for scenario in scenarios
└ for data in scenario.config ← 一组「公共设定」
└ for test in scenario.tests ← 一组「具体测试」
└ mergeScenarioTest(defaultTest, data, test)

产出条数 = Σ(每个 scenario 的 config.length × tests.length)。

mergeScenarioTest(src/evaluator.ts:2376)的优先级和一个细节:

  • vars / options / 顶层字段: defaultTest < data < test(后者覆盖前者)。
  • assert 是拼接: [...data.assert, ...test.assert](:2187)——注意这里没有 defaultTest.assert, 它稍后由 prepareTestCaseForEval 补在最前面(:2314-2317),所以不会重复。
  • 自动会话隔离: 每组 config 分到一个 conversationId = __scenario_${scenarioIndex}__(:2171), 而 scenarioIndex按 config 条目递增、不是按 scenario 递增(:2136)。多轮对话因此不会串台。

10.4 prepareTestVariablesapplyInputTransform 的时机

src/evaluator.ts:2409。两件事:把 defaultTest.vars 合并进每条测试(:2200-2203), 然后跑一次 transformVars(applyInputTransform,:2217)。

时机是重点: 这一步在 §10.5 的组合展开之前,而且全仓库只此一处用到 transformVars。 所以——transformVars 看到的是数组本身,不是展开后的单个取值。 如果你写:

vars:
language: [French, Spanish]
options:
transformVars: '{ ...vars, upper: vars.language.toUpperCase() }' # 会炸:language 此刻是数组

它拿到的 vars.language['French','Spanish']。这不是 bug,是顺序决定的。

10.5 generateVarCombinations —— 组合爆炸就发生在这

src/evaluator.ts:1897思路:逐个变量往已有组合上"乘"一遍。

原理演示(# 示意,非源码):

def combinations(vars):
result = [{}] # 起点:一个空组合
for key, raw in vars.items():
values = raw if isinstance(raw, list) else [raw] # 数组才展开
result = [ {**combo, key: v} # 老组合 × 新取值
for combo in result
for v in values ]
return result
# 重点看:每处理一个变量,result 的长度就乘以该变量的取值个数

真实实现的循环体只有六行(src/evaluator.ts:1932-1941),但外面包着三条特殊规则:

规则行为位置
file:// + glob展开成命中的文件列表,每个文件一个取值;一个都没命中直接抛错:1723-1739
数组,且首元素是字符串正常展开:1741
数组,但首元素不是字符串整个数组当一个取值,不展开:1744-1747

第三条是常见陷阱:temperatures: [0.1, 0.5, 0.9] 不会展开成 3 个组合,因为首元素是数字。

关掉展开的两个开关(src/evaluator.ts:2606-2609):环境变量 PROMPTFOO_DISABLE_VAR_EXPANSION, 或测试级 options.disableVarExpansion: true。任一命中就直接用 [testCase.vars]

10.6 四层嵌套循环

buildRunEvalOptions(src/evaluator.ts:2460)是外壳,真正的循环拆在四个函数里,一层一个:

buildRunEvalOptions for test of tests :2274
└ appendRunEvalOptionsForTestCase for repeatIndex of 0..repeat-1 :2407
└ for vars of varCombinations :2408
│ ← 每到这里 testIdx += 1 :2428
└ appendRunEvalOptionsForVars for provider of providers :2472
└ appendRunEvalOptionsForProvider for prompt of prompts :2539
└ createRunEvalOption push 一个 RunEvalOptions :2552

repeat 在外、vars 在内(src/evaluator.ts:2617-2618),意味着重复跑的是"整批组合"而不是"单个组合连跑 N 遍"。

repeat 取值有两级(src/evaluator.ts:2611-2612):

const globalRepeat = normalizeRepeatCount(options.repeat);
const testRepeat = normalizeRepeatCount(testCase.options?.repeat, globalRepeat);

normalizeRepeatCount(:435)只接受正安全整数,否则回落到 fallback——所以 repeat: 0repeat: -1 会被静默当成 1。

10.7 两个下标的含义

下标每什么递增一次语义位置
testIdx每个 (test, repeatIndex, varCombination)结果表的行号src/evaluator.ts:2637
promptIdx构建循环里按 prompts.length 顺序编号结果表的列号(= provider × prompt)src/evaluator.ts:2269

同一 testIdx 下的所有 provider/prompt 共享一行——这正是"横向对比"的数据基础。 repeat 之所以生成不同的 testIdx,是因为重复执行要各占一行才能分别看结果; 配套地,缓存也按 repeat:${repeatIndex} 分命名空间(getRepeatCacheNamespace,src/evaluator.ts:433-441), 否则第 2 遍会直接命中第 1 遍的缓存。

10.8 四道闸门

展开过程中会主动跳过一些组合:

闸门作用判定函数位置
testCase.providers这条测试只跑指定 provider(支持 openai:* 通配)isProviderAllowedsrc/util/provider.ts:120
providerPromptMap这个 provider 只跑指定 promptisAllowedPromptsrc/evaluator.ts:408
testCase.prompts这条测试只跑指定 promptisAllowedPromptsrc/evaluator.ts:2261-2263
--filter-rangestart:end 切测试(零基下标)filterByRangesrc/util/filterRange.ts:55

isProviderAllowed 有个反直觉的边界:不写 = 全允许,写成空数组 [] = 全不允许 (src/util/provider.ts:124-129)。

filterByRange 作用在 tests 数组上(src/evaluator.ts:4727),切的是测试用例,不是最终的 RunEvalOptions——所以 --filter-range 0:10 得到的实际执行单元数是 10 × provider 数 × prompt 数。

10.9 每个单元里装了什么

createRunEvalOption(src/evaluator.ts:2771)组装最终对象。两处小加工值得点出:

prompt: {
...prompt,
raw: promptPrefix + prompt.raw + promptSuffix,
template: prompt.template ?? prompt.raw,
},

—— src/evaluator.ts:2811-2815prefix/suffix 来自测试或 defaultTestoptions (:2394-2395),在这里才真正拼进 raw;同时把原始模板存进 template 备查。


11. 拿 getting-started 走一遍

回到 §1 那份配置,逐步算:

步骤结果
readPrompts1 条 Prompt(纯字符串,走 processString)
loadApiProviders2 个 ApiProvider
readTests2 条 TestCase(内联对象,各过一遍 readTest)
buildCompletedPrompts2 个 CompletedPrompt = 2 provider × 1 prompt → promptIdx ∈ {0,1}
buildTestsFromSuite2 条 AtomicTestCase(无 scenarios)
generateVarCombinations每条 1 个组合(language/input 都是标量)
repeat1
RunEvalOptions[]2 × 1 × 1 × 2 × 1 = 4

最终矩阵:

gpt-5.5(promptIdx 0)gpt-5.4-mini(promptIdx 1)
testIdx 0 — French / Hello world单元 ①单元 ②
testIdx 1 — Spanish / library单元 ③单元 ④

改一个字就能看出乘法效应:把 language 写成 [French, Spanish, German],该测试的组合数变 3, 总数从 4 变成 (3 + 1) × 2 = 8。


12. 巧妙之处(可借鉴)

  • 解引用的"挖坑—填坑"。 通用工具($RefParser)遇到语义冲突时,不是去改工具,而是先把冲突数据摘出去、 处理完再放回来。三段式代码短、意图明确(src/util/config/load.ts:188-290)。
  • 精确浅拷贝代替深拷贝。 只克隆"会被就地改写的那两个字段",并在注释里把契约边界写死 (src/evaluate.ts:36-59)。省内存,又躲开了 SDK 客户端的循环引用。
  • 过滤前置于实例化。 --filter-providers 在配置层就把 provider 筛掉,不该加载的根本不加载 (src/util/config/load.ts:928-931)。
  • 展开一次性完成,与执行完全解耦。 buildRunEvalOptions 返回一个纯数组,里面没有任何执行状态。 正因为如此,并发调度、断点续跑、进度条才能全部只针对这个数组做文章(见 02)。
  • glob 只递归一层。 maxRecursionDepth 从 1 减到 0(src/prompts/index.ts:155-159), 用最小的代码量杜绝了无限展开。
  • 默认值宽松、校验只警告。 zod 校验失败不拦截(src/util/config/load.ts:376-382)、 缺 prompts 自动兜底(:437)、空 tests 补一条空测试(src/evaluator.ts:2363)—— 优先保证"能跑起来",再靠 warn 提示。

13. 边界与局限

  • 组合数没有上限保护。 generateVarCombinations 就是纯乘法,3 个各 10 取值的变量 = 1000 个组合, 再乘 provider 和 prompt。代码里看不到任何总量阈值或提示。
  • 数字数组不展开。 首元素非字符串就整体当一个取值(src/evaluator.ts:1927-1930), 从 YAML 看不出这个区别。
  • transformVars 在展开之前跑(§10.4),所以它无法针对单个组合做变换。
  • repeat 的非法值被静默吞掉。 normalizeRepeatCount(src/evaluator.ts:443)对 0/负数/小数 直接回落,不报错。
  • 多份配置的 extensions 不区分来源。 源码自己承认这是已知限制,并让用户去提 issue (src/util/config/load.ts:593-597)。
  • defaultTestfile:// 形式会"黏住"。 一旦某份配置用了文件形式,后续配置的对象形式 defaultTest 全部失效(src/util/config/load.ts:703-710),合并时没有任何提示。
  • 配置校验不阻断。 拼错字段名通常不会报错,只会表现为"我配的东西没生效"。

14. 与其它章的关系

想知道什么去哪章
这 N 个 RunEvalOptions 怎么被并发跑掉、超时和续跑怎么做02 — 执行引擎
ApiProvider 内部长什么样、怎么接自定义目标03 — Provider 抽象
assert 的每种 type 怎么判、分数怎么加权04 — 断言与打分
redteam.plugins/strategies 怎么生成攻击测试05 — 红队
结果怎么落 SQLite、怎么分享和看报告06 — 结果落地与观测

15. 代码地图(导航索引)

主题文件路径符号名
配置类型定义src/types/index.tsUnifiedConfigSchemaTestSuiteConfigSchema
运行期套件类型src/types/index.tsTestSuiteSchema
测试用例类型src/types/index.tsTestCaseSchemaAtomicTestCaseSchemaScenarioSchema
断言类型src/types/index.tsAssertionSchemaAssertionSetSchema
测试生成器类型src/types/index.tsTestGeneratorConfigSchema
读单个配置src/util/config/load.tsreadConfigmaybeReadConfig
$ref 解引用src/util/config/load.tsdereferenceConfig
多配置合并src/util/config/load.tscombineConfigsproviderDedupeKey
配置 → TestSuitesrc/util/config/load.tsresolveConfigs
prompt 加载入口src/prompts/index.tsreadPromptsprocessPromptprocessPrompts
prompt 写法归一src/prompts/utils.tsnormalizeInputmaybeFilePath
provider-prompt 映射src/prompts/index.tsreadProviderPromptMap
prompt 各来源处理器src/prompts/processors/*.tsprocessCsvPromptsprocessJsonlFileprocessTxtFileprocessMarkdownFileprocessYamlFileprocessJsFileprocessPythonFileprocessExecutableFile
测试数据加载src/util/testCaseReader.tsreadTestsreadTestloadTestsFromGlobreadStandaloneTestsFile
CSV 行 → 测试src/csv.tstestCaseFromCsvRow
SDK 入口外壳src/evaluate.tsevaluateWithSourcecreateRuntimeTestSuitecloneTestForResolve
CLI 入口src/node/doEval.ts(调用 resolveConfigs)
scenarios 展开src/evaluator.tsbuildTestsFromSuitegetInitialTestsbuildScenarioTestsmergeScenarioTest
变量准备src/evaluator.tsprepareTestVariablesapplyInputTransformprepareTestCaseForEval
组合爆炸src/evaluator.tsgenerateVarCombinations
四层展开循环src/evaluator.tsbuildRunEvalOptionsappendRunEvalOptionsForTestCaseappendRunEvalOptionsForVarsappendRunEvalOptionsForProvidercreateRunEvalOption
列索引src/evaluator.tsbuildCompletedPromptsbuildPromptIndexMap
展开期闸门src/util/provider.tssrc/evaluator.tssrc/util/filterRange.tsisProviderAllowedisAllowedPromptfilterByRange
repeat 处理src/evaluator.tsnormalizeRepeatCountgetRepeatCacheNamespace
最小示例examples/getting-started/promptfooconfig.yaml