跳到主要内容

数据截至 (上游 commit c149fcf36c2a)

Promptfoo — 执行引擎:一格的生命周期、并发、超时与断点续跑

30 秒导读: 上一章把 YAML 展开成了一张「测试 × prompt × provider」的矩阵(见 从 YAML 到测试矩阵)。本章讲这张矩阵怎么被真正跑完:一格从渲染到落库经过哪些函数、几百上千格怎么并发、跑一半断电/超时/目标挂了怎么办、以及为什么 repeat: 3 不会三次都命中同一份缓存。


1. 这是什么(零基础也能懂)

一句话定义: 执行引擎是 promptfoo 里那个「拿着矩阵去挨个格子干活」的调度器 + 单格执行器,全部代码在 src/evaluator.ts(4899 行)和 src/cache.ts(900 行)两个文件里。

它面对的现实问题,用大白话说是这四件事:

  • 一格 = 一次真实的模型调用,慢(几百毫秒到几十秒)、贵(要钱)、还会失败。
  • 格子多(1000 个测试 × 3 个模型 = 3000 次调用),必须并发,但并发太猛会被限流。
  • 有些格子不能并发:多轮对话第二轮要等第一轮的回答。
  • 跑到一半 Ctrl-C 或者目标 API 返回 403,不能把前面几百次已经花掉的钱丢掉。

一格里到底发生什么(先建立直觉,细节在 §3):

vars + prompt 模板 ──渲染──▶ 一段真实文本 ──调用──▶ 模型回答

转换/断言/存库 ◀─┘

心智模型: 把执行引擎当成一个带故障处理的 for 循环。循环体(一格)是 runEval,循环本身(并发、顺序、分组)是 Evaluator 类,而超时、中止、续跑是套在循环外面的三层保险丝。

本节不出现代码。目标是知道「这一层负责把矩阵变成结果行」。


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

怎么读这张图: 从上往下是控制流。上半部分是调度层(决定谁先跑、几个一起跑),下半部分是单格执行(一格内部的固定七步)。

_runEvaluation evaluator.ts:4481

┌─────────────────┼──────────────────┐
│ │ │
建中止信号 划分串行/并发 装进度报告器
(双 AbortSignal) (runSerially) (进度条 / CI 输出)
└─────────────────┼──────────────────┘

executeEvalSteps evaluator.ts:3583

┌───────────────────┼───────────────────┐
▼ ▼ ▼
runGroupedEvalSteps runSerialEvalSteps runConcurrentEvalSteps
(按 provider 分组) (一个一个) (async.forEachOfLimit)
└───────────────────┼───────────────────┘

processEvalStepWithTimeout evaluator.ts:3486
│ 单格超时 + 中止信号合并

runEval / runEvalInternal evaluator.ts:1403

一格内部固定走这七步,顺序不变:

① 建状态 createRunEvalState vars 深拷贝 + conversationKey
② 挂对话 attachConversationVar 只在 prompt 真用了 _conversation 时
③ 渲染 renderRunEvalPrompt nunjucks + prompt 函数 + file:// 加载
④ 调用 callProviderForRunEval → callActiveProvider → provider.callApi
⑤ 冷却 applyProviderDelayIfNeeded 命中缓存则跳过 sleep
⑥ 装行 createEvaluateResult 拼出一条 EvaluateResult
⑦ 定成败 applyRunEvalResponseOutcome 错误 / 空输出 / 送去打分

各部件一句话职责:

部件干什么在哪
Evaluator持有 store/stats/conversations/registers,跑完整场评测src/evaluator.ts:3285
_runEvaluation一场评测的主流程:建信号 → 建矩阵 → 跑 → 收尾src/evaluator.ts:4653
executeEvalSteps选择三种跑法之一,并统一处理中断异常src/evaluator.ts:3755
processEvalStepWithTimeout给单格套上超时和额外的 AbortSignalsrc/evaluator.ts:3653
runEval / runEvalInternal单格的完整生命周期src/evaluator.ts:1559 / :1410
ProgressBarManager终端进度条,并与日志输出协调避免刷屏错乱src/evaluator.ts:173
CIProgressReporterCI 环境下的定时百分比日志src/progress/ciProgressReporter.ts:3
fetchWithCache所有 HTTP provider 的缓存入口src/cache.ts:806

主线走一遍(不进代码): _runEvaluation 先把中止信号和进度报告器准备好 → 跑 beforeAll 钩子 → 展开出 runEvalOptions[](每个元素就是一格)→ 按需砍并发 → 把格子分成「串行的」和「可并发的」两堆 → 交给 executeEvalSteps 跑 → 跑完做对比类断言(select-best / max-score)→ 收尾存库、跑 afterAll


3. 核心原理一:一格的生命周期

3.1 入口只做一件事:套缓存命名空间

runEval 是公开入口,但它自己不干活,只是把真正的实现包进一个缓存命名空间里:

// src/evaluator.ts:1403 runEval
return withCacheNamespace(
getRepeatCacheNamespace(options.repeatIndex, options.evaluateOptions),
() => runEvalInternal(options),
);

这一层的意义在 §7 讲缓存时才完全说得清:它保证 repeat: 3 的第 2 次和第 3 次不会读到第 1 次的缓存。

3.2 建状态:vars 深拷贝与对话身份证

createRunEvalStatesrc/evaluator.ts:680)做三件事,每件都有讲究:

  • structuredClone(test.vars) —— 深拷贝。一格在渲染时会就地改写 vars(file:// 会被替换成文件内容),不拷贝就会污染同一个 test 的其它格。
  • collectFileMetadata(vars) —— 记下哪些 var 来自文件,最后写进结果的 metadata。
  • conversationKey —— 多轮对话的身份证,由 provider.label || provider.id()prompt.id、以及可选的 test.metadata.conversationId 拼成。

3.3 渲染:把模板变成真正要发出去的那段文本

renderRunEvalPromptsrc/evaluator.ts:812)调用 renderPromptsrc/evaluatorHelpers.ts:241)。渲染远不止「填空」,它按顺序做了这些事:

阶段做什么位置
加载外部变量file:// 指向的 js/py/yaml/pdf/图片音视频,分别执行或转 base64evaluatorHelpers.ts:253-361
prompt 函数若 prompt 是函数,调用它拿到 prompt 与可选 configevaluatorHelpers.ts:388-412
去尾换行把 var 末尾的 \n 削掉(JSON prompt 的常见坑)evaluatorHelpers.ts:414-419
第三方仓库portkey:// / langfuse:// / helicone:// 直接远程取 prompt 并提前返回evaluatorHelpers.ts:424-496
nunjucks 渲染最后才真正渲染模板evaluatorHelpers.ts:498

红队场景有个例外: 攻击载荷所在的那个变量必须原样注入,不能被 nunjucks 二次渲染(否则载荷里的 {{ }} 会被当成模板执行)。所以红队跑时会算出 skipRenderVars,把注入变量排除在渲染之外:

// src/evaluator.ts:808 renderRunEvalPrompt
const skipRenderVars = shouldSkipRedteamInjectVar(test, testSuite, isRedteam)
? [getRedteamInjectVar(test, promptForRender, testSuite)]
: undefined;

getRedteamInjectVarsrc/evaluator.ts:470)挑哪个变量:配置里显式写了就用配置的,否则取「prompt 模板里出现过、且 test.vars 里有值」的最后一个变量。

3.4 调用:三层薄壳包住 provider.callApi

调用被拆成三个函数,各管一件事:

函数职责位置
callProviderForRunEval掐表算 latency;若 test 写死了 providerOutput 就不调用模型,直接造一个假响应src/evaluator.ts:860
callActiveProvider决定用哪个 provider(test 级覆盖 > 套件级)、红队时按需包一层 MCP 壳、走限流器src/evaluator.ts:1018
buildCallApiContext拼出传给 provider 的上下文(vars、filters、logger、getCache、traceparent…)src/evaluator.ts:1093

限流不是这层自己实现的,而是委托出去——有 registry 就走 registry,没有就裸调:

// src/evaluator.ts:940 callActiveProvider
const response = rateLimitRegistry
? await rateLimitRegistry.execute(activeProvider, callApi, createProviderRateLimitOptions())
: await callApi();

值得注意的是 buildCallApiContextgetCache 本身传给了 provider(src/evaluator.ts:1121)。这意味着 provider 拿到的是当前命名空间下的缓存句柄,而不是全局缓存——命名空间通过 AsyncLocalStorage 隐式传递,provider 代码完全无感。

__eval* 运行期变量是这一层的一个细节:getEvalRuntimeVarssrc/evaluator.ts:726)往 vars 里塞 __evalId / __evalStepId / __repeatIndex,让 prompt 和 provider 能引用;但 omitEvalRuntimeVars:732)在落库前把它们摘掉,避免污染持久化结果和断言输入。

3.5 冷却:延迟只对没命中缓存的请求生效

// src/evaluator.ts:1034 applyProviderDelayIfNeeded
if (!response.cached && provider.delay && provider.delay > 0) {
await sleep(provider.delay);
}

一句话:delay 是为了别把上游打挂,缓存命中时没有真实请求,自然不用等。缓存重跑因此快得多。

3.6 装行与三岔口

createEvaluateResultsrc/evaluator.ts:1190)把 setup、渲染后的 prompt、响应、metadata、trace 关联拼成一条 EvaluateResult,此时 success 先置 false

然后 applyRunEvalResponseOutcomesrc/evaluator.ts:1262)分三条路:

response.error ? ──是──▶ failureReason = ERROR,直接返回
│否

output 为 null/undefined ? ──是──▶ applyEmptyResponseOutcome
│否

gradeRunEvalResponse (转换 → 跑断言 → 见 04 章)

空输出的处理在红队场景是反过来的,这个反直觉细节值得记住:

// src/evaluator.ts:1184 applyEmptyResponseOutcome
if (isRedteam) {
ret.success = true; // 模型什么都没说 = 没被攻破 = 通过
} else {
ret.success = false;
ret.error = 'No output'; // 普通评测里空输出就是失败
}

3.7 转换:两级 transform 与二进制外置

transformRunEvalResponsesrc/evaluator.ts:1446)按 provider → test 的顺序跑两级 transform,并且记住 provider transform 之后、test transform 之前的那个中间值:

// src/evaluator.ts:1319
const providerTransformedOutput = processedResponse.output;

这个中间值会作为 providerTransformedOutput 传给断言层,让断言既能看到最终值也能看到「模型原始产物」。之后 extractAndStoreBinaryData 把图片/音频这类大二进制从结果行里挪出去,只留引用——否则 SQLite 里的一行会大到离谱(存储见 结果落地与观测)。

3.8 兜底:任何异常都变成一行错误结果,而不是让整场崩

runEvalInternal 的整个 try 块外面有一个 catch(src/evaluator.ts:1755-1789)。它不重新抛出,而是造一条 failureReason: ERROR 的结果行返回。附带两个细节:

  • buildProviderErrorContext:1609)会把 HTTP status、statusText、截断到 500 字符的响应片段塞进 metadata.errorContext——这正是后面「目标不可用熔断」的判据来源。
  • AbortError 不打日志:1582)。中止是预期行为,几百格同时中止会刷屏。

4. 核心原理二:多轮对话状态

4.1 要解决的小问题

同一个 test 跑「第二轮追问」时,prompt 里要能引用第一轮的问答。但一格和一格之间是无状态的,历史存在哪?

4.2 思路:一个进程内的字典,键是「谁在跟谁聊」

Evaluator 持有 this.conversationssrc/evaluator.ts:3309),键就是 §3.2 的 conversationKey

conversationKey = "openai:gpt-4o" + ":" + prompt.id [+ ":" + conversationId]
└ provider ┘ └ prompt ┘ └ 用户显式分组 ┘

4.3 只在真的用了才挂:一次模板静态分析

attachConversationVarsrc/evaluator.ts:763)不是无脑注入,而是先问 promptUsesConversationVariable——这个函数解析模板 AST,看有没有引用 _conversation(常量定义在 src/evaluator.ts:142):

// src/evaluator.ts:147
const { referenced, parsed } = analyzeTemplateReference(prompt.raw, CONVERSATION_VAR_NAME);
// 只缓存解析成功的结果。缓存一次解析失败会毒化整个进程的缓存,
// 并悄悄把后续本该串行的对话运行降级成并行。
if (parsed) {
promptUsesConversationVariableCache.set(prompt.raw, referenced);
}

为什么这个判断这么重要? 因为它直接决定并发度——用了 _conversation 就必须强制串行(§5.3)。把「解析失败」误缓存成「没用到」,后果是对话历史错乱,而且极难复现。源码里那段注释就是在解释这件事。

4.4 写回历史

一格拿到响应后,updateConversationHistorysrc/evaluator.ts:1146)往数组里 push 一条记录:

字段内容
prompt渲染后的完整 prompt(能解析成 JSON 就用 JSON 形式)
input若 prompt 是消息数组,取最后一条的 content,否则同 prompt
output模型回答
metadata响应 metadata

input 单独存一份,是为了让模板里写 {{ _conversation[0].input }} 时拿到「用户那句话」而不是整个消息数组。


5. 核心原理三:调度与并发

5.1 三种跑法,选一种

executeEvalStepssrc/evaluator.ts:3755)的分支很简单:要么走分组模式,要么走「串行堆 + 并发堆」模式

shouldGroupGradingByProvider ?

├─是─▶ runGroupedEvalSteps(把串行堆和并发堆拼成一条队列顺序跑,
│ 但把模型裁判的打分攒起来按 provider 分组执行)

└─否─▶ runSerialEvalSteps(runSerially 的格子,一个一个)
然后
runConcurrentEvalSteps(其余格子,async.forEachOfLimit)

分组模式的开关条件很苛刻:

// src/evaluator.ts:4668
const shouldGroupGradingByProvider =
concurrency === 1 && !hasEvalStepTimeout && !usesConversationVar;

它的目的不是提速执行,而是减少本地裁判模型的反复加载——把所有需要模型打分的行攒进 ProviderGroupedCallQueue,按 provider 成批地打分(runGroupedGradingForRowssrc/evaluator.ts:3002)。打分本身属于 断言与打分 的范围,这里只需知道调度层为它让了路。

5.2 并发怎么限:一行 async.forEachOfLimit

// src/evaluator.ts:3853 runConcurrentEvalSteps
await async.forEachOfLimit(
concurrentRunEvalOptions,
processingContext.concurrency,
async (evalStep) => { ... },
);

没有自研线程池,直接用 async 库的并发上限迭代器。默认并发是 DEFAULT_MAX_CONCURRENCY--max-concurrency 可覆盖。真正的自适应限流在 src/scheduler/RateLimitRegistry 里(src/evaluator.ts:3317-3350 订阅了它的 ratelimit:hit / concurrency:decreased 等事件做 debug 日志)。

5.3 有三种功能会把并发强行按到 1

adjustConcurrencyForSerialFeaturessrc/evaluator.ts:2934)是一个「安全阀」:

触发条件为什么必须串行判据
prompt 用了 _conversation第 N 轮要读第 N-1 轮的结果prompts.some(promptUsesConversationVariable)
任何 test 用了 storeOutputAs后面的 test 要读前面写进 registers 的值t.options?.storeOutputAs
browser provider 开了 persistSession一个浏览器会话不能被并发复用provider.config?.persistSession === true

三者都会 logger.info 说明原因,避免用户困惑「我明明设了并发 20 为什么这么慢」。函数还有一个短路:concurrency <= 1 时直接返回,不做后面的扫描。

5.4 落库节流

无论哪种跑法,每完成一格都会 store.appendResult(row)(逐行落);但 prompt 级的汇总指标是按秒节流写的:

// src/evaluator.ts:136
const PROMPTS_FLUSH_INTERVAL_MS = 1000;

三个跑法里各有一份 now - lastPromptsFlush >= PROMPTS_FLUSH_INTERVAL_MS 的判断(:3830:3862:3698)。


6. 核心原理四:容错、中止与断点续跑

这是本章最值钱的部分。promptfoo 的定位决定了它必须假设「一定会跑到一半出事」。

6.1 三层超时,管的东西不一样

层级配置生效位置超时后的行为
单格timeoutMs / PROMPTFOO_EVAL_TIMEOUT_MSprocessEvalStepWithTimeout src/evaluator.ts:3653该格写一条 timeout 错误行,继续跑下一格
全场maxEvalTimeMs / PROMPTFOO_MAX_EVAL_TIME_MS_runEvaluation src/evaluator.ts:4680-4691中止全场,所有没跑到的格子批量补写错误行
单次请求provider 自己的 timeoutprovider 实现Provider 抽象

两个默认值都是 0(不限),由 getEvalTimeoutMs / getMaxEvalTimeMs 读环境变量(src/envars.ts:557:559)。

6.2 单格超时:Promise.race + 两个巧思

// src/evaluator.ts:3517 processEvalStepWithTimeout
return await Promise.race([
this.processEvalStep(evalStepWithSignal, index, {
deferGrading, onRowsReady: clearEvalStepTimeout,
providerCallQueue, shouldSkipStaleRows: () => didTimeout,
}, context),
new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => { didTimeout = true; abortController.abort(); reject(...); }, timeoutMs);
}),
]);

两个不显然的设计:

  • onRowsReady: clearEvalStepTimeout —— provider 一返回就把计时器关掉。也就是说这个超时只覆盖模型调用,不覆盖后续打分。否则一个慢裁判会把本来成功的格子判成超时。
  • shouldSkipStaleRows: () => didTimeout —— 已经判超时之后,万一原任务又完成了,它的行会被丢弃(processEvalRows 每行开头检查,src/evaluator.ts:3539),避免同一格既写超时行又写正常行。

超时行由 createEvalStepTimeoutResult:2927)构造,注意它会先把 test.provider 从 testCase 里删掉再存(:3554-3555),避免把整个 provider 实例序列化进库。

6.3 两条中止信号:为什么要分成两条

这是全章最容易看漏的设计。_runEvaluation 里同时维护两条信号(src/evaluator.ts:4670-4691):

用户传入的 abortSignal ──┐
├──▶ providerAbortSignal ──▶ 传给 provider.callApi
全局超时 globalAbort ─────┘ (能真的取消 HTTP 请求)

目标熔断 targetErrorAbort ─┴──▶ combinedAbortSignal ──▶ checkAbort()(只停循环)

源码注释把理由说得很直白:目标错误信号不传给 provider,因为等你检测到 403 时那次调用早就结束了,这条信号只用来停下评测循环。组合方式用的是标准 AbortSignal.any

// src/evaluator.ts:4504
let combinedAbortSignal: AbortSignal = options.abortSignal
? AbortSignal.any([options.abortSignal, targetErrorAbortController.signal])
: targetErrorAbortController.signal;

单格那层还会再叠一次:AbortSignal.any([evalStep.abortSignal, abortController.signal]):3503),把「全局中止」和「本格超时」合成一条给这一格用。

6.4 目标不可用熔断:401/403/404/501 直接停

跑红队时最浪费的场景是:目标 URL 写错了,于是 500 个格子挨个失败 500 次。熔断就是防这个的。

// src/evaluator.ts:628 getNonTransientTargetStatus
const httpStatus = row.response?.metadata?.http?.status;
return typeof httpStatus === 'number' && isNonTransientHttpStatus(httpStatus) ? httpStatus : undefined;

「非暂时性」的名单只有四个状态码,src/util/fetch/errors.ts:256

状态码含义为什么算「重试也没用」
401未认证密钥错了,下一格也会错
403被拒绝权限问题,重试无意义
404端点不存在URL 写错了
501未实现方法不支持

刻意排除的是 500 / 502 / 503 / 504——源码注释解释:500 常常是某个特定输入触发的 bug,下一格可能就好了;502/504 是网关抖动。把它们算进熔断会误杀。

命中后 abortIfTargetUnavailablesrc/evaluator.ts:3638)打日志、置 targetUnavailable、abort 信号并 break 掉当前行循环。

6.5 中断异常的三种归宿

executeEvalSteps 的 catch 块(src/evaluator.ts:3820-3837)是整个容错的分诊台。判断顺序不能换:

catch (err)

├─ combinedAbortSignal 没 aborted ? ──▶ 这是真 bug:清进度条 + 原样抛出

├─ 是全局超时(evalTimedOut)? ──▶ 只 warn,继续往下走正常收尾
│ (这样才能进 addMaxDurationTimeoutResults 补行)

└─ 目标还可用(用户 Ctrl-C)? ──▶ saveInterruptedEval:存已完成的进度并直接返回

saveInterruptedEval:3870)会清全局定时器、停进度条、setVarsappendPrompts,然后返回 store.evaluation。它一返回,_runEvaluationreturn interruptedEval:4715-4717)——跳过对比断言和 finalize。所以用户按 Ctrl-C 得到的是「一份不完整但可打开的评测记录」,而不是空。

6.6 全局超时的补行:靠 processedIndices 认领遗漏

全局超时时,很多格子根本没被调度到。addMaxDurationTimeoutResultssrc/evaluator.ts:4533)遍历原始的 runEvalOptions,凡是不在 processedIndices 里的都补一条错误行:

// src/evaluator.ts:4374
for (let i = 0; i < runEvalOptions.length; i++) {
if (processedIndices.has(i)) { continue; }
const timeoutResult = createMaxDurationTimeoutResult(evalStep, maxEvalTimeMs, startTime);
...
}

processedIndices 里存的是原始下标,靠 evalStepIndexMapMap<RunEvalOptions, number>:4656)维护。用 Map 而不是 indexOf,是为了避免热循环里的 O(n) 扫描。

6.7 断点续跑:只做「跳过已完成的格子」

续跑的实现出人意料地简单——不做 checkpoint 文件,直接问数据库要「已完成的 (testIdx, promptIdx) 对」:

// src/evaluator.ts:2721 filterCompletedResumeSteps
const completedPairs = await store.readCompletedIndexPairs({ excludeErrors: cliState.retryMode });
for (let i = runEvalOptions.length - 1; i >= 0; i--) {
if (completedPairs.has(`${runEvalOptions[i].testIdx}:${runEvalOptions[i].promptIdx}`)) {
runEvalOptions.splice(i, 1);
}
}

四个值得注意的点:

  • 倒序 splice。正序删会跳元素,这是个经典坑。
  • excludeErrors: cliState.retryMode 区分了两种模式:resume 跳过所有已完成的格子;retryMode 额外把 ERROR 结果排除出「已完成」,让它们被重跑(语义定义在 src/cliState.ts:27-39)。
  • 失败不致命:读库出错只 logger.warn 然后跑全量(:2744-2748),宁可多花钱也不要丢结果。
  • 时机在展开之后_runEvaluation 的顺序是 buildRunEvalOptionsmarkComparisonRowsbuildRepeatCacheContextByTestIdx → 才 filterCompletedResumeStepssrc/evaluator.ts:4737-4751)。先展开完整矩阵再删,testIdx / promptIdx 才和上次一致——否则续跑后行列全错位。

7. 核心原理五:缓存与 repeat 的相互作用

7.1 要解决的小问题

repeat: 3 的本意是「同一格跑三次看稳定性」。但请求参数一模一样,缓存键也就一模一样——不处理的话第 2、3 次会直接吃缓存,测出来的方差恒为 0,功能形同虚设。

7.2 缓存的基本盘

fetchWithCachesrc/cache.ts:806)是所有 HTTP provider 的统一入口。它的流程:

算 cacheKey ──▶ 命中? ──是──▶ 直接返回(cached: true)
│否/禁用 │否
▼ ▼
裸 fetch 查 inflight 表:同一个 key 已有请求在飞?
├─有─▶ 等它(天然去重)
└─无─▶ 发请求 → 可缓存才写入 → 从 inflight 表摘掉

什么叫「可缓存」prepareFetchResponsesrc/cache.ts:705-769):

情况是否缓存
HTTP 非 2xx
JSON 响应里带 error 字段
请求体不可确定性序列化(如流)否(getFetchCacheKey 返回 null)
其余成功响应是,TTL 默认 14 天

inflight 表的键还会带上 AbortSignal 的身份(getInflightFetchCacheKey:555)——两个逻辑相同但绑了不同中止信号的请求不能共享同一个 Promise,否则一方取消会牵连另一方。

7.3 命名空间:用 AsyncLocalStorage 做隐式作用域

withCacheNamespacesrc/cache.ts:249)把命名空间放进 AsyncLocalStorage,作用域内所有 getCache() 拿到的都是加前缀的缓存视图(getNamespacedCache:117)。命名空间会嵌套拼接

// src/cache.ts:259
const scopedNamespace = parentNamespace ? `${parentNamespace}:${namespace}` : namespace;

同样的手法用在开关上:withCacheEnabled:263)让某段代码临时禁用缓存,getEffectiveCacheEnabled:271)读局部值、回落到全局。

7.4 repeat 隔离:两道保险,且互相不叠加

第一道在 evaluator 侧。getRepeatCacheNamespacesrc/evaluator.ts:433):

if (repeatIndex > 0 || (evaluateOptions?.repeat ?? 1) > 1) {
return `repeat:${repeatIndex}`;
}
return undefined; // 没开 repeat 时返回 undefined —— 缓存键与老版本完全一致

返回 undefined 这一支很关键:不开 repeat 的绝大多数用户,缓存键不变,历史缓存继续有效。

repeat 的取值本身也做了防御。normalizeRepeatCount:435)要求是安全正整数,否则回落——全局 repeat 和 test 级 options.repeat 各过一遍,test 级以全局值为 fallback(:2401-2406)。

第二道在 cache 侧,给没走 evaluator 命名空间的调用兜底。getFetchCacheKey 会加 :repeat{N} 后缀,但先检查当前命名空间里是不是已经有这个 repeat 了

// src/cache.ts:534
const repeatSuffix = shouldApplyRepeatCacheSuffix(repeatIndex) ? `:repeat${repeatIndex}` : '';

shouldApplyRepeatCacheSuffix:172)→ currentNamespaceIncludesRepeatIndex:165)会拆开命名空间找 repeat:N 片段。两道保险因此不会叠成 repeat:2:...:repeat2

7.5 一个容易看错的细节:fetchWithCache 用的是未命名空间的缓存实例

// src/cache.ts:800 fetchWithCache
const cache = getCacheInstance(); // 注意不是 getCache()

看起来像 bug,其实是对的:getFetchCacheKey 内部已经调用 getScopedCacheKey 加过前缀了(:535)。如果这里再用命名空间视图,前缀会被加两次。

7.6 对比断言也要待在同一个命名空间

select-best 这类跨行断言是在所有格子跑完之后才执行的,那时 AsyncLocalStorage 的上下文早没了。所以引擎提前把每个 testIdx 的 repeat 上下文存进 repeatCacheContextByTestIdxbuildRepeatCacheContextByTestIdxsrc/evaluator.ts:2893),事后重新套回去(:4055-4061)。这类小心思是「隐式上下文」方案必须还的债。


8. 进度反馈:终端和 CI 两套

选哪一套的判断在 _runEvaluationsrc/evaluator.ts:4788-4795):

isCI() && !isWebUI ──▶ CIProgressReporter
showProgressBar && stderr.isTTY ──▶ ProgressBarManager
都不满足 ──▶ 只打 debug 日志
维度ProgressBarManagerCIProgressReporter
位置src/evaluator.ts:173src/progress/ciProgressReporter.ts:3
输出单条 cli-progress 进度条,写 stderr定时 logger.info
节奏每格完成即 increment默认每 30 秒一次 + 25/50/75% 里程碑
特殊处理与 winston 日志抢终端时的防错乱GitHub Actions 的 ::notice:: / ::error:: 注解

进度条与日志的冲突怎么解? 进度条常驻在终端最后一行,winston 一写日志就会把它冲乱。installLogInterceptor:212)包了一层全局日志回调:日志写出前先 readline.clearLine 擦掉进度条那行,写完后用 setImmediate 重绘(:186-207)。removeLogInterceptor:228)还会检查「当前回调是不是我装的」再还原,避免嵌套安装时踩到别人。

CI 侧的里程碑做了单调性保护:只有 percentage > highestPercentageSeen 才可能触发(ciProgressReporter.ts:44),所以百分比不会来回跳。错误日志则有 5 秒节流(:83),防止批量失败刷屏。


9. 扩展钩子:四个切入点

runExtensionHooksrc/evaluatorHelpers.ts:746)提供四个生命周期钩子:

钩子调用位置能改什么
beforeAllsrc/evaluator.ts:4713整个 testSuite(返回值会替换 testSuite
beforeEachsrc/evaluator.ts:3517单个 test(返回值替换 evalStep.test
afterEachsrc/evaluator.ts:3557单行结果(namedScores、metadata、response)
afterAllsrc/evaluator.ts:4571只读全部结果,做汇总/上报

三个实现上的讲究:

  • ensureDefaultTestForExtensionssrc/evaluator.ts:2133)在跑 beforeAll 前保证 testSuite.defaultTestdefaultTest.assert 存在(只在配了 extensions 时)——钩子想往 defaultTest 里加断言时才有地方可加。
  • afterEach 传的是浅拷贝:3392-3399)。钩子若原地改坏了东西、或者中途抛异常,原始行不受影响;异常只 logger.error 然后「不带钩子修改地」落库(:3415-3420)。
  • 返回值要再消毒row.namedScores = filterFiniteScores(afterEachOut.result.namedScores):3403)——钩子可能塞进 NaN/Infinity,进了库就是脏数据。旁边的 synchronizeLegacyTransportHeaders:553)更细:钩子换掉 response.metadata 后,顶层 metadata 里那份从旧 transport 复制来的 headers 就成了陈旧(且可能含凭据)的残留,需要重新同步。

afterAll 与前三个不同,它会先把结果从 store 里读回来runAfterAllExtensions:4391-4406),因为内存里并没有保留全量行。


10. 巧妙之处(可带走的技术)

  1. 超时只盖住该盖的那段。 onRowsReady 一响就清计时器(src/evaluator.ts:3695),让「单格超时」严格等于「模型调用超时」,不误伤慢裁判。配套的 shouldSkipStaleRows 保证迟到的结果不会造成重复行。

  2. 中止信号按「谁需要知道」分路。 provider 只收到「用户取消 + 全局超时」,因为只有这两者能真的取消飞行中的请求;「目标熔断」只喂给循环层(src/evaluator.ts:4670-4678)。信号不是越多越好,是越准越好。

  3. 熔断名单刻意做窄。 只有 401/403/404/501 算「重试无用」,500/502/503/504 明确排除(src/util/fetch/errors.ts:248-256)。做熔断最难的是抑制住把所有 5xx 都拉黑的冲动。

  4. 不开的功能不改行为。 getRepeatCacheNamespacerepeat 未启用时返回 undefinedsrc/evaluator.ts:437-440),缓存键与老版本逐字节一致。给新特性加隔离维度时,这是最省事的兼容做法。

  5. 只缓存解析成功的结果。 promptUsesConversationVariable 拒绝缓存模板解析失败(src/evaluator.ts:159-161),因为一次误判会在整个进程生命周期里把对话运行悄悄降级成并行——注释直接把这个后果写在了代码旁边。

  6. 续跑靠现有的结果表,不引入新状态。 filterCompletedResumeSteps 只读 (testIdx, promptIdx) 集合(src/evaluator.ts:2913),没有 checkpoint 文件、没有额外一致性问题,读失败就退化成全量跑。

  7. 隐式上下文要记得手动续期。 AsyncLocalStorage 很方便,但事后阶段(对比断言)拿不到,所以提前把 repeat 上下文存进 Map 再套回去(src/evaluator.ts:2893:4055)。


11. 边界与局限

  • 状态只在进程内。 conversationsregistersEvaluator 的实例字段(src/evaluator.ts:3309-3310),不落盘。续跑一个用了 _conversationstoreOutputAs 的评测,历史不会恢复。

  • 续跑的粒度是格,不是格内步骤。 一格跑到「调用完成、打分崩了」,整格算未完成,下次会重新调用模型(除非命中缓存)。

  • 强制串行是全局的,不是按格的。 只要任何一个 prompt 用了 _conversation,整场评测并发降到 1(src/evaluator.ts:2954-2959),哪怕其余 99% 的格子完全可以并行。

  • 单格超时与分组打分互斥。 配了 timeoutMs 就拿不到按 provider 分组打分的收益(src/evaluator.ts:4838),本地裁判模型可能反复加载——代码会把这个取舍打印出来提醒(logGroupedGradingStatus:506)。

  • 全局超时的补行不含任何执行信息。 createMaxDurationTimeoutResult:3023)造的行只有错误文本,latencyMs 是从全场开始算起的墙钟时间,不代表这一格真的跑了那么久。

  • 缓存是「按请求」而不是「按格」的。 缓存住的是 HTTP 层的响应(src/cache.ts:806),一格里的渲染、转换、断言每次都会重跑。


12. 横向对比

同一个货架上,这类「跑一堆用例并收集结果」的引擎在两个维度上有明显分野:

维度Promptfoo 的选择另一种常见选择
并发模型单进程 async 并发 + 上限迭代器多进程/worker 池
中断恢复读结果表反推未完成集合显式 checkpoint / 状态机持久化
缓存粒度HTTP 请求级,AsyncLocalStorage 隐式作用域用例级、显式传缓存句柄

Promptfoo 三项都选了「轻」的那一边:不引入进程管理、不引入额外状态文件、不改 provider 的函数签名。代价就是 §11 里那些边界——状态不跨进程、恢复粒度粗。


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

主题文件关键符号
单格入口(套缓存命名空间)src/evaluator.tsrunEvalrunEvalInternal
单格状态与对话键src/evaluator.tscreateRunEvalStatecreateRunEvalSetup
渲染src/evaluator.ts / src/evaluatorHelpers.tsrenderRunEvalPromptrenderPromptgetRedteamInjectVar
调用 providersrc/evaluator.tscallProviderForRunEvalcallActiveProviderbuildCallApiContext
结果成形src/evaluator.tscreateEvaluateResultapplyRunEvalResponseOutcomeapplyEmptyResponseOutcometransformRunEvalResponse
运行期变量隔离src/evaluator.tsgetEvalRuntimeVarsomitEvalRuntimeVars
多轮对话src/evaluator.tsCONVERSATION_VAR_NAMEpromptUsesConversationVariableattachConversationVarupdateConversationHistory
主流程src/evaluator.tsEvaluator_runEvaluationexecuteEvalSteps
三种跑法src/evaluator.tsrunConcurrentEvalStepsrunSerialEvalStepsrunGroupedEvalSteps
并发降级src/evaluator.tsadjustConcurrencyForSerialFeatureslogGroupedGradingStatus
单格超时src/evaluator.tsprocessEvalStepWithTimeoutcreateEvalStepTimeoutResult
全场超时src/evaluator.tsaddMaxDurationTimeoutResultscreateMaxDurationTimeoutResult
目标熔断src/evaluator.ts / src/util/fetch/errors.tsabortIfTargetUnavailablegetNonTransientTargetStatusNON_TRANSIENT_HTTP_STATUSES
中断保存与续跑src/evaluator.tssaveInterruptedEvalfilterCompletedResumeSteps
进度src/evaluator.ts / src/progress/ciProgressReporter.tsProgressBarManagerCIProgressReporter
缓存核心src/cache.tsfetchWithCachegetCachegetScopedCacheKeywithCacheNamespacewithCacheEnabled
repeat 隔离src/evaluator.ts / src/cache.tsgetRepeatCacheNamespacenormalizeRepeatCountshouldApplyRepeatCacheSuffix
扩展钩子src/evaluator.ts / src/evaluatorHelpers.tsrunExtensionHookensureDefaultTestForExtensionssynchronizeLegacyTransportHeaders

接着读: 被调用的那个 provider 长什么样 → Provider 抽象;拿到输出之后怎么判定成败 → 断言与打分;结果行最终去了哪 → 结果落地与观测