跳到主要内容

数据截至 (上游 commit c149fcf36c2a)

Promptfoo — 结果落地与观测:SQLite 存储、分享、Web 报告与 OTLP 追踪

30 秒导读: 前五章讲的是"怎么把一格格测试跑出来"。这一章讲跑完之后:一条结果怎么被清洗、写进本地 SQLite,再怎么变成一个 JUnit 文件、一张网页矩阵、一个可以发给同事的链接,以及一棵可以拿来写断言的调用链。


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

  • 一句话定义: Promptfoo 跑完评测后的落地层——把内存里的结果对象变成磁盘上的行、文件、网页和 trace。
  • 解决什么问题: 跑 2000 格 LLM 测试很贵,结果不能只打在终端里。你需要:明天还能翻出来、能筛出"所有失败的"、能塞进 CI 当 JUnit 报告、能发链接给产品经理看、能看清楚每一格背后 agent 到底调了哪些工具。
  • 给谁用: 在 CI 里跑 eval 的工程师、要给团队看红队报告的安全同学、要把 agent 内部调用链接进断言的人。

它提供的四条出口:

出口命令/入口产物
自动,每次 eval~/.promptfoo/promptfoo.db 里的行
--output out.jsonjson / jsonl / csv / yaml / html / xml / junit.xml
promptfoo viewlocalhost 上的 React 结果矩阵与红队报告
分享--share一个 https://.../eval/<id> 链接

用起来什么样:

# 跑一次,同时落库、导两份文件、发一个分享链接
promptfoo eval -c promptfooconfig.yaml \
--output results.json --output results.junit.xml \
--share

# 之后随时翻旧账
promptfoo view # 起本地 Web UI
promptfoo export latest -o dump.csv

一句话直觉: 把这一层当成评测的 git——Eval 是一次 commit(配置 + 提示词 + 元信息),EvalResult 是这次 commit 里的每一个 diff hunk(一格的输入、输出、判分)。其余全是这份底本的不同渲染方式。


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

怎么读这张图: 从上往下是时间顺序;Eval / EvalResult唯一写入口,下面四条分叉都只读它。

评测引擎跑完一格(见 02 章)
│ EvaluateResult

┌──────────────────────────┐
│ Eval / EvalResult 模型 │ 清洗 + 脱敏 + 编号
└───────────┬──────────────┘
│ drizzle

┌──────────────────────────┐
│ SQLite: ~/.promptfoo/ │
│ evals · eval_results │
│ traces · spans │
└──┬────────┬────────┬──────┘
│ │ │
① 导出 │ ② 看 │ ③ 分享 │
▼ ▼ ▼
文件(6 种) Express+React 远端 /api/eval

trace 是旁路进来的:被测目标自己把 span 打到 Promptfoo 内建的 OTLP 接收器,落进同一个库的 traces / spans 表。

被测 agent ──OTLP/HTTP──▶ :4318 内建接收器 ──▶ spans 表
▲ │
│ traceparent(评测器下发) │
└────────── Eval 的一格 ◀── trace 断言读回 ────┘

部件一句话职责:

部件干什么在哪个文件
drizzle schema定义 14 张表和 JSON 表达式索引src/database/tables.ts
Eval一次评测的聚合根:建、存、分页读、算指标src/models/eval.ts
EvalResult一格的行模型:脱敏、落库、投影回内存对象src/models/evalResult.ts
EvaluationStore存储抽象,让引擎不依赖 SQLitesrc/evaluator/runtime.ts
writeOutput按扩展名分派到 6 种导出格式src/util/output.ts
createShareableUrl自适应分块上传 + 失败回滚src/share.ts
Express server/api/eval 等路由 + Socket.IO 推送src/server/server.ts
OTLPReceiver内建 OTLP/HTTP 接收器(JSON + protobuf)src/tracing/otlpReceiver.ts
TraceStoretrace / span 的读写与脱敏src/tracing/store.ts

主线走一遍(高层): 引擎产出一个 EvaluateResultEval.addResult() 交给 EvalResult.createFromEvaluateResult() 清洗并 INSERT → 全部跑完后 doEval 依次触发分享、写文件、打 CLI 表格 → 用户随后 promptfoo view,服务端从同一张 eval_results 表分页取回、拼成矩阵。


3. 核心原理

3.1 存储层:两代 schema 并存的 SQLite

它要解决的小问题: 早期版本把整个评测结果塞进 evals.results 一个 JSON 大列,评测一大就读不动、也没法按条件筛。

思路: 把结果规范化出去,一格一行,放进 eval_results;同时保留老列,让旧库仍能打开。这就是代码里到处出现的 "V3 vs V4"。

Eval.version() 就是这个判断,只有一行逻辑:结果列里还带 table 字段的就是 V3(src/models/eval.ts:651 version),否则是 V4,表由 App 现算。useOldResults():652)是所有读路径的岔口。

表清单(全在 src/database/tables.ts,共 14 张):

符号装什么
evalsevalsTable (:58)一次评测:config、prompts、vars、is_redteam
eval_resultsevalResultsTable (:79)一格:testCase / prompt / response / gradingResult / score
prompts + evals_to_promptspromptsTable (:29)、evalsToPromptsTable (:156)提示词按内容哈希去重、多对多挂到评测
datasets + evals_to_datasetsdatasetsTable (:257)、evalsToDatasetsTable (:269)测试集按 sha256(tests) 去重
tags + evals_to_tagstagsTable (:43)、evalsToTagsTable (:177)name:value 标签
blob_assets / blob_references:211 / :228音视频/图片二进制按 hash 存一份,行里只留引用
configsconfigsTable (:324)Web UI 存下来的 eval / redteam 配置,供 /api/configs 读写
model_auditsmodelAuditsTable (:385)模型文件扫描结果(另一条产品线)
traces / spanstracesTable (:439)、spansTable (:457)OTLP 收来的调用链

数数时会踩的坑: 文件里还留着一段被 /* */ 整体注释掉的 llmOutputs(注释从 :343 开始),它既没导出也没建表——直接 grep sqliteTable 会数出 15 个,实际生效的只有上面 14 张。文件里那句注释也写明了原因:"We're just recording these on eval.results for now"。

关键细节 —— 在 JSON 列上建索引。 eval_results 的大字段全是 text(..., { mode: 'json' }),但筛选又必须快,所以 schema 直接对 json_extract 表达式建索引:

metadataPluginIdIdx: index('eval_result_metadata_plugin_id_idx').on(
sql`json_extract(${table.metadata}, '$.pluginId')`,
),

出自 src/database/tables.ts:147-152(还有 metadataStrategyIdIdx)。这两条索引正是第 05 章红队报告"按插件/策略切片"能秒开的原因。

数据库在哪、怎么迁移:

  • 路径固定为配置目录下的 promptfoo.db,见 src/database/index.ts:121 getDbPath
  • 连接时打开外键、busy_timeout=5000、WAL 模式,见同文件 configureDatabase:44 起)——WAL 让 CLI 写、view server 读能并存。
  • 迁移是 drizzle 的标准 SQL 目录,src/migrate.ts:76 runDbMigrationsmigrate(db, { migrationsFolder })resolveMigrationsFolder:55)负责在源码 / bundled dist / 云端 runtime 三种布局下找到 drizzle/。截至本 commit 有 25 个迁移,最后一个是 drizzle/0024_repair_eval_redteam_flags.sql(回填 is_redteam 布尔列)。
  • CLI 每次启动都先跑迁移:src/main.ts:71

3.2 Eval 类:写入侧

它要解决的小问题: 一次评测涉及 6 张表,必须一起成功或一起失败。

Eval.create()src/models/eval.ts:461)把整套建表操作包进一个 db.transaction:插 evals 行 → 逐个 hashPrompt(prompt) 去重插 prompts 并挂关系 → 可选地批量插入已有结果 → 用 sha256(JSON.stringify(config.tests))datasetIddatasets → 展开 config.tags。所有关联表插入都带 .onConflictDoNothing(),所以同一个提示词/数据集跑一百次也只存一份。

跑的过程中怎么写。 每格结果走 addResult():744):

const newResult = await EvalResult.createFromEvaluateResult(this.id, result, {
persist: this.persisted,
});
if (!this.persisted) {
// 只有未落库的评测才把结果留在内存
this.results.push(newResult);
}

src/models/eval.ts:751-764。这行注释点破了内存策略:落库的评测不在内存里囤结果,几万格也不会 OOM;没落库(程序内调用、测试)才退回数组。

收尾时怎么写。 save():662)更新 config / prompts / vars。V4 评测的时长字段不整列覆盖,而是用 SQL 的 json_set 原子合并:

expr = sql`json_set(${expr}, '$.durationMs', ${this.durationMs})`;

src/models/eval.ts:693-703,外面还套了一层 json_valid(...) AND json_type(...) = 'object' 的兜底。目的写在注释里:并发的两次 save() 不会互相抹掉对方写的键。

3.3 Eval 类:读出侧(分页、筛选、指标)

它要解决的小问题: Web UI 要在一万格结果里翻页 + 全文搜索 + 按插件筛,还要显示"筛完之后的通过率"。如果分页和指标各写一套 SQL,两个数字就会对不上。

思路: 只写一个 WHERE 生成器,两边共用。buildFilterWhereSql()src/models/eval.ts:911)返回一段 SQL 片段,注释直接标了 CRITICAL: ... single source of truth。它支持:

  • filterModeerrors / failures / passes / highlightsgrading_result.comment!highlight 开头)/ user-ratedcomponentResults 里有 human 断言)。
  • 结构化 filter:metric(eq/neq/gt/gte/lt/lte/is_defined)、metadata、plugin、strategy、severity、policy。
  • 全文搜索:同时 LIKE responsegrading_result.reasonnamed_scorestest_case.vars 等。

两个消费者:queryTestIndices():1148,取本页的 test_idx 列表 + 计数)和 getFilteredMetrics():1209,把同一段 WHERE 交给 calculateFilteredMetrics)。

分页为什么按 test_idx 而不是行: 一个测试用例在矩阵里横跨多个 prompt/provider 列,必须整行一起取。getTablePage():1224)先拿到本页 testIndices,再 EvalResult.findManyByEvalIdAndTestIndices() 一把捞回所有列,最后按 testIdx 分组交给 convertTestResultsToTableRow 拼行(src/util/exportToFile/index.ts:72)。

几个小而实用的读接口:

方法位置作用
getStats()eval.ts:1395prompts[].metrics 汇总成功/失败/错误与 token
toEvaluateSummary()eval.ts:1417V3 评测返回 version: 2 的老摘要,V4 返回 version: 3 的新摘要
toResultsFile()eval.ts:1496摘要 + config + prompts + traces,导出和分享的公共底稿
findTargetErrorStatus()eval.ts:838一条 LIMIT 1json_extract(response,'$.metadata.http.status') 查询,捞出 401/403/404/500 这类"目标配错了"的信号
fetchResultsBatched()eval.ts:804异步生成器,按 testIdx 窗口分批吐结果,分享和导出都靠它避免全量装载
getEvalSummaries()eval.ts:1720评测列表页用;includeProviders 为假时把 config 列直接 SELECT NULL,避免拖出巨大的内嵌测试集

3.4 EvalResult:清洗与凭据红线

它要解决的小问题: 传进来的 EvaluateResult 里可能挂着活的 SDK 客户端(llm-rubric 裁判用的 provider)、循环引用、以及一堆带凭据的 HTTP 头。这些东西直接 JSON.stringify 要么崩,要么把 API key 写进数据库。

三档清洗,按字段分派src/models/evalResult.ts):

函数用在哪些字段做什么
sanitizeForDb (:142)response / gradingResult / namedScores / metadata只去循环引用与不可序列化值
sanitizeForDbWithSecrets (:177)testCase / prompt再叠一层无限深度的凭据字段脱敏
sanitizeProvider (:93)provider把 provider 对象压成 {id, label, config}id() 是函数就调用它

在这之上还有一组 HTTP 头红线:SENSITIVE_RESPONSE_HEADER_NAMES:199)列出 authorization / set-cookie / openai-organization / x-amzn-trace-id / cf-ray 等,前缀表再盖住 x-ratelimit-*redactSensitiveResultFieldsForDb:506)是"哪个字段配哪个脱敏器"的唯一事实源——注释写明了理由:入库路径和 JSONL 落盘路径必须共用同一张表,否则新加的敏感字段会在一条路上被挡、在另一条路上泄漏。

巧妙处 —— 遗留 metadata.headers 的溯源判断。 顶层 metadata.headers 既可能是传输层回声,也可能是用户自己写的测试元数据。代码不敢一律抹掉,而是拿真实响应头做 isDeepStrictEqual 比对,只有深度相等才判定为传输回声并 REDACTredactSensitiveHeaders:242-262)。

trace 关联怎么存。 traceId / evaluationId 不是 schema 列。persistTraceMetadata():421)把它们塞进 metadata.__promptfoo.traceLinkage,读回来时 surfaceTraceMetadata():473)再剥出去——注释说明这样做是为了不加迁移就能带上关联。相应地,EvalQueries.getMetadataKeysFromEvaleval.ts:217)和搜索条件(eval.ts:1129json_remove)都显式屏蔽了 __promptfoo 这个保留命名空间。

3.5 store 是可替换的抽象

它要解决的小问题: 评测引擎不该硬编码"结果一定进 SQLite"。浏览器里跑、内存里跑、测试里跑,都得能换。

src/evaluator/runtime.ts 定义了两个纯接口,整个文件没有一行实现

  • EvaluationStore:31)——引擎需要的全部存储动作:appendResult / saveResult / readResultsByTestIdx / readCompletedIndexPairs / recordResultPersistenceFailure / setDurationMs
  • EvaluatorRuntime:68)——工厂:createEvaluationStore()createResultWriters()

两个实现:

EvaluatorRuntime
├─ nodeEvaluatorRuntime → EvalEvaluationStore(SQLite)
│ + JsonlFileWriter(流式落盘)
└─(内存/测试) → InMemoryEvaluationStore

src/node/evaluatorRuntime.ts:79 是 Node 侧实现,src/evaluator/inMemoryStore.ts:34 InMemoryEvaluationStore 是纯 Map 版本——它把 failedResults / finalResults 各维护成一个 testIdx:promptIdx 索引的 Map(:26 getResultIndexKey),和 DB 版的 getResultIndexKeysrc/models/evalResult.ts:1033)用同一套键格式。

readCompletedIndexPairs() 就是第 02 章断点续跑的钩子;DB 实现是 EvalResult.getCompletedIndexPairs()evalResult.ts:796),excludeErrors 为真时把 ERROR 行排除,好让重试模式重跑它们。

3.6 导出:一个扩展名分派器

它要解决的小问题: CI 要 JUnit,数据分析要 CSV,人要 HTML,程序要 JSON,超大评测要 JSONL。

writeOutput()src/util/output.ts:563)先用 getOutputFileFormat()src/util/outputFormats.ts:11)识别格式——注意 junit.xml 是按后缀串判的,不是扩展名——然后分派:

格式走哪条路关键点
Google Sheets URLwriteCsvToGoogleSheet路径以 docs.google.com/spreadsheets/ 开头就直接走这条
junit.xmlwriteJunitXmlOutputsrc/util/junit.ts:322createJunitXml:279)生成
csvstreamEvalCsvsrc/util/eval/evalTableUtils.ts:764流式写文件句柄,与 Web UI 导出同格式
jsonwriteJsonOutputSafelyoutput.ts:531捕获 Invalid string length / heap OOM,并明确提示改用 --output out.jsonl
yaml / yml / txtyaml.dump(createOutputData(...))同一份 OutputFile 结构
htmlnunjucks 渲染 tableOutput.html现算 pass rate 塞进模板

多路径就走 writeMultipleOutputs():778)——用 Promise.allSettled 全部写完再一起抛错,不会因为第一个路径没权限就丢掉其余产物。

表格是怎么来的。 V4 评测没有存表,Eval.getTable()eval.ts:737)现调 convertResultsToTable()src/util/convertEvalResultsToTable.ts:14)。这个函数干的杂活比名字多:把 metadata.sessionId(s) 提升成一列 var、把红队的 redteamFinalPrompt 回写进原变量位、按"配置里的 var 顺序在前、运行时新增的排序在后"确定列序(:190-194)。

JSONL 是另一条路。 它不由 writeOutput 负责,而是引擎跑的过程中经 JsonlFileWritersrc/util/exportToFile/writeToFile.ts:3)逐行写。这个类有两处值得抄:懒开流(没有结果就绝不 truncate 已有文件,:15-19)、以及把异步 stream error 记下来、下一次 write/close 时再抛,避免变成掀翻进程的 unhandled 'error' 事件(:9:28-32)。写进 JSONL 的每行先过 sanitizeResultForJsonlArtifact()src/models/evalResult.ts:552),共用入库那套脱敏 + 一组 PROMPTFOO_STRIP_* 投影开关。

关于 src/integrations/: 这个目录(langfuse.ts / helicone.ts / portkey.ts / huggingfaceDatasets.ts)是拉取侧集成——从 prompt 管理平台取提示词、从 HF 取数据集,不是把结果推出去。代码里看不出结果导出到这些平台的路径。

3.7 分享:自适应分块上传

它要解决的小问题: 一次红队评测可能几万行、几百 MB,一个 POST 塞不下;而且一格里可能有一段 base64 音频。

思路: 先发一个"壳",拿到远端 ID,再流式分块补结果;哪块太大就二分重试;彻底失败就删掉远端半成品。

createShareableUrl()src/share.ts:685)是入口,sendChunkedResults():414)是主体,流程:

① 采样:取头 100 条 → findLargestResultSize
② 定块:900KB ÷ 最大单条大小 = 每块条数
③ 发壳:sendEvalRecord(config + prompts + traces,results 置空)→ 远端 evalId
④ 流式补:fetchResultsBatched → sendChunkWithRetry
└─ 413 或网络超时 → 一分为二递归重发
⑤ 失败:rollbackEval(DELETE 远端 eval)

分块常数在 :448TARGET_CHUNK_SIZE = 0.9MB),二分逻辑在 sendChunkWithRetry():293)——递归深度由 log2(chunk / minResultsPerChunk) 推出(:305),降到单条还失败就明确报"这一条本身太大"。

二进制怎么处理。 两条互斥的路(:425-431):连了 promptfoo cloud 就先把 blob 上传再发引用(uploadBlobRefsForShare);自托管则把 blob 内联成 data URL 塞进 payload(inlineBlobRefsForSharesrc/util/inlineBlobsForShare.ts:138)。内联侧有个安全细节:结果文本里可能混进别的 eval 的 blob URI,所以取字节前要过 getShareAuthorizedBlob(hash, localEvalId) 的归属校验(:50)。

其余细节: isSharingEnabled():53)判断有没有可用的分享后端(eval 里配的 apiBaseUrl / 非官方的环境变量 URL / 已登录 cloud);stripAuthFromUrl():593)默认把 URL 里的用户名密码抹掉再打印(issue #1184);getShareableUrl():657)按 cloud / 自托管拼 /eval/<id> 还是 /eval/?evalId=<id>

3.8 看:promptfoo view 背后的服务端

viewCommandsrc/commands/view.ts:9)只做一件事:startServer(port, browserBehavior)

服务端在 src/server/server.tscreateApp():122)装 CORS、CSRF、compression、JSON 解析,然后挂路由;startServer():372)包一层 http server + Socket.IO,并再跑一次 runDbMigrations()

路由文件用途
GET /api/eval/:id/tablesrc/server/routes/eval.ts:313结果矩阵分页,直通 getTablePage
POST /api/eval/job · GET /api/eval/job/:idroutes/eval.ts:132 / :202从 UI 发起评测并轮询进度
GET /api/eval/:id/metadata-keys / -valuesroutes/eval.ts:483 / :531前端筛选器的候选值
POST /api/eval/:id/copyroutes/eval.ts:894复制一次评测(Eval.copy,分批 1000 条)
GET/POST /api/configssrc/server/routes/configs.ts读写 configs 表,UI 里保存/取回配置(挂载在 server.ts:335
GET /api/traces/evaluation/:evaluationIdroutes/traces.ts:11一次评测的所有 trace
POST /api/redteam/run · GET /api/redteam/statusroutes/redteam.ts:245 / :395UI 里跑红队

实时刷新怎么做的。 CLI 进程和 view server 是两个进程,靠一个信号文件通信:server 用 setupSignalWatcher 监听 ~/.promptfoo/evalLastWritten,收到变化就清缓存并 io.emit('update', { evalId })server.ts:387-437);新连接进来先推一次最新评测 ID(:451)。写侧就是模型里那些 notifyEvaluationChanged(this.id) 调用。

前端src/app/(React 19 + Vite + MUI)。只给入口,不逐行讲:

  • 结果矩阵:src/app/src/pages/eval/components/ResultsView.tsxResultsTable.tsxEvalOutputCell.tsx;导出菜单 DownloadMenu.tsx
  • 红队报告:src/app/src/pages/redteam/report/components/Report.tsxOverview.tsxFrameworkCompliance.tsx
  • 其他页面:pages/evals(列表)、pages/historypages/model-audit*pages/media

3.9 追踪:内建 OTLP 接收器

它要解决的小问题: 被测的是一个 agent,光看最终输出不够——你想知道它调了几次工具、哪个 span 报错了。而这些数据在被测系统里,不在 Promptfoo 里。

思路: Promptfoo 自己起一个 OTLP/HTTP 接收器(默认 127.0.0.1:4318),把 W3C traceparent 下发给被测目标,让它把 span 打回来。

四步链路:

① 评测器给这一格生成 traceId/spanId
generateTraceContextIfNeeded → traceparent "00-<32hex>-<16hex>-01"
② 通过 callApiContext.traceparent 交给 provider
③ 被测目标(或 genaiTracer)把 span POST 到 :4318/v1/traces
④ 接收器解析 → 按 evaluation.id / test.case.id 关联 → 落 traces/spans 表

第 ① 步src/tracing/evaluatorTracing.tsgenerateTraceId():44)取 16 字节、generateSpanId():51)取 8 字节、generateTraceparent():59)拼成 00-<traceId>-<spanId>-01generateTraceContextIfNeeded():333)还顺手在 traces 表建好关联记录。接收器的生命周期由 startOtlpReceiverIfNeeded():135)管——引用计数 + 串行化的 start/stop promise,让并发的多个评测共用一个 4318 端口,最后一个结束才关。

第 ③ 步的服务端是 OTLPReceiversrc/tracing/otlpReceiver.ts:275),值得注意的有三点:

  • 两种格式POST /v1/traces 同时收 application/jsonapplication/x-protobuf,中间件按 content-type 动态选 parser(:390-425);protobuf 走 decodeExportTraceServiceRequest()src/tracing/protobuf.ts:212)。
  • 还收日志POST /v1/logs:488)把每条 OTEL log record 合成一个 1ms 的 spanlogRecordToParsedTrace:765)。注释说明了动机:Claude Agent SDK 把工具执行这类最有用的遥测发在 log 信号上而不是 span 上。没有 inline traceId 时退回资源属性 promptfoo.trace_id
  • ID 转换很宽容convertId():1003)依次尝试"已经是 hex"、"base64 解出的字节"、"base64 里装的其实是 hex 字符串"三种形态——不同 SDK 编码方式不一。

脱敏有两道。 接收侧按配置的 redactAttributes 模式匹配 key 做替换(redactSpan:362);这里有个防回声的巧思:如果 span 的 namestatusMessage 恰好等于某个将被 REDACT 的属性值,也一并抹掉。读取侧再来一道 sanitizeAttributes()src/tracing/sanitizeAttributes.ts:64),按关键字(token / apikey / secret…)判敏感——但先用 SAFE_TOKEN_ATTRIBUTE_KEYS 白名单((:23))放行 gen_ai.usage.*_tokens,否则 token 计数会被自己的关键字规则误伤。

GenAI 语义约定。 src/tracing/genaiTracer.ts:28GenAIAttributes 是一张常量表,覆盖 gen_ai.system / gen_ai.operation.name / gen_ai.request.model / gen_ai.usage.input_tokens 等 OpenTelemetry GenAI 约定字段。provider 用 withGenAISpan():212)包住一次 API 调用:span 名按约定拼成 "{operation} {model}",有 traceparent 就用 propagation.extract 挂到评测的那棵树下。多轮的 agent provider(src/providers/claude-agent-sdk.tssrc/providers/openai/codex-sdk.ts 等)另用 openTurnSpan() / closeTurnSpan():585 / :634)产出 gen_ai.turn N 这一层。工具名从一组候选属性里取,见 getToolNameFromAttributes()src/tracing/toolAttributes.ts:104)。

第 04 章的 trace 断言从哪拿数据。 链路是这样的:

evaluator.ts:1239 getTraceId(traceContext) + 有 trace 断言 → flushOtel()
↓ traceId
assertions/index.ts:456 assertionMayNeedTraceContext → loadTraceData(traceId)
↓ 注入 assertionValueContext.trace
assertions/traceSpanCount.ts:12 handleTraceSpanCount 读 trace.spans

loadTraceData()src/assertions/index.ts:183)用 traceStore.getTrace(traceId, { sanitizeAttributes: false }) 取原始属性——断言要看真值,不能被截断或打码。flushOtel() 那一步很关键:不等 exporter 冲刷完就查库,会查到空 span 集。给红队多轮攻击器用的是另一个入口 fetchTraceContext()src/tracing/traceContext.ts:607-660),它带重试等待(默认 3 次 × 500ms)并顺手算出 insights(错误 span、工具调用、guardrail 命中,:138)。

保留策略在 tracing.storage.retentionDays,评测开始前调 TraceStore.deleteOldTraces()src/tracing/store.ts:354)清旧数据。


4. CLI 全貌:从命令到落地

把整章串起来,一次 promptfoo eval 的控制流是这样:

src/main.ts:53 main()
├─ checkForUpdates()
├─ runDbMigrations() ← 每次启动都迁移(:64)
├─ loadDefaultConfig()
├─ 注册 ~25 个命令(:88-135)
└─ program.parseAsync()

src/commands/eval.ts evalCommand → 校验 --output 扩展名(:230)

src/node/doEval.ts:224 doEval()
├─ Eval.create(...) (:783)
├─ 跑引擎(第 02 章)
├─ isSharingEnabled / createShareableUrl (:912 / :922,后台并发跑)
├─ 打印 CLI 表格
├─ writeMultipleOutputs(paths, ...) (:1079,等分享完成好把 URL 写进文件)
├─ telemetry.record('command_used', ...) (:1083)
└─ 按阈值设 process.exitCode (:1183)

命令表src/main.ts:125-180,按注册顺序分组):

命令
主线eval(含 eval setup)、initviewmcpshare
数据importexportlistshowdeletegenerate dataset|assertions|redteam
红队redteam init|eval|discover|generate|run|report|setup|plugins
运维authcacheconfigdebuglogsvalidateretryoptimizemodel-scancode-scansfeedback

遥测。 src/telemetry.ts:53 Telemetry 双写:PostHog(:124 capture)+ 自建 R_ENDPOINT:143)。三个工程细节:PROMPTFOO_DISABLE_TELEMETRY 直接让 client 返回 null(:19);flushInterval: 0 是为了不让 PostHog 的定时器吊住 event loop 导致进程不退出(issue #5893,:33);每个事件都镜像一份 person properties 到 $set:132),这样即使用户从没触发 $identify,dashboard 上按人筛选也有效。


5. 巧妙之处(可借鉴)

  1. 分页和指标共用同一段 WHERE。 一个 buildFilterWhereSql()src/models/eval.ts:911)同时喂给 queryTestIndicesgetFilteredMetrics,从结构上杜绝"页面显示 12 条失败、汇总却写 15 条"的经典 bug。
  2. 在 JSON 列的表达式上建索引。 json_extract(metadata,'$.pluginId') 直接进索引(tables.ts:147),既保留 schema-less 的灵活,又让红队切片查询走得动。
  3. 脱敏器与字段的映射只写一次。 redactSensitiveResultFieldsForDbevalResult.ts:506)被入库和 JSONL 两条路共用,新增敏感字段不会只挡住一条路。
  4. 遗留头字段靠深比对判定溯源。 只有当 metadata.headers 与真实传输头深度相等时才 REDACT(:242),既堵泄漏又不误伤用户自写的测试元数据。
  5. 无需迁移的关联字段。 traceId 借道 metadata.__promptfoo.traceLinkage 存取(:421 / :473),同时在元数据发现 API 和搜索里屏蔽这个命名空间。
  6. 上传失败就二分。 413 或超时后把 chunk 一劈为二递归重发(share.ts:295),比"固定退避重试同样大的包"更快收敛,最后还能定位到"就是这一条太大"。
  7. JSONL writer 懒开流 + 缓存异步错误。 无结果不 truncate 旧文件、stream error 不炸进程(writeToFile.ts:15:28)。
  8. 接收器的引用计数生命周期。 并发评测共用一个 4318 端口,start/stop 各用一个 promise 串行化(evaluatorTracing.ts:140 / :260),并在 listen 回调里额外校验 server.listening,因为 Express 在 EADDRINUSE 时也会调这个回调(otlpReceiver.ts:1049-1060)。
  9. token 计数的白名单先于关键字黑名单。 SAFE_TOKEN_ATTRIBUTE_KEYSstore.ts:63)避免 gen_ai.usage.input_tokenstoken 规则打成 <redacted>

6. 边界与局限

  • 单机 SQLite。 存储是本地文件(src/database/index.ts:121),没有多写者协调;靠 WAL + busy_timeout 缓解,不是分布式方案。团队协作走"分享到远端",不是共享一个 db。
  • 导出格式对内存的要求不同。 JSON 会整份进内存,超大评测直接抛错并建议改 JSONL(output.ts:531 起的 OOM 分支);CSV 和 JSONL 才是流式的。
  • trace 只走 OTLP/HTTP。 接收器只实现 HTTP 的 /v1/traces/v1/logs,代码里看不到 gRPC 接收端;/v1/logs 还只收 JSON(otlpReceiver.ts:431-444)。请求体上限 10MB。
  • trace 关联依赖被测目标配合。 目标必须回传 traceparent(或由 provider 注入 promptfoo.trace_id 资源属性),否则接收器只能记下孤立 span 并跳过 trace 记录(otlpReceiver.ts:592-612)。
  • V3 老评测功能受限。 getTablePage 这类分页/筛选只在 V4 路径上;V3 走 oldResults.table 整块返回。
  • src/integrations/ 不是结果出口。 它是提示词/数据集的拉取侧集成。

7. 横向对比与本组其它章


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

主题文件路径符号名
全部表定义src/database/tables.tsevalsTableevalResultsTableconfigsTabletracesTablespansTableblobAssetsTable
db 路径与 PRAGMAsrc/database/index.tsgetDbPathconfigureDatabasegetDbSignalPath
迁移入口src/migrate.tsrunDbMigrationsresolveMigrationsFolder
评测聚合根src/models/eval.tsEval.createEval.findByIdaddResultsaveversion
分页与筛选src/models/eval.tsbuildFilterWhereSqlqueryTestIndicesgetTablePagegetFilteredMetrics
摘要与导出底稿src/models/eval.tsgetStatstoEvaluateSummarytoResultsFilegetTraces
行模型与脱敏src/models/evalResult.tscreateFromEvaluateResultsanitizeProviderredactSensitiveResultFieldsForDbpersistTraceMetadata
续跑与批读src/models/evalResult.tsgetCompletedIndexPairsfindManyByEvalIdBatched
存储抽象src/evaluator/runtime.tsEvaluationStoreEvaluatorRuntimeEvaluatorResultWriter
内存实现src/evaluator/inMemoryStore.tsInMemoryEvaluationStore
Node 实现src/node/evaluatorRuntime.tsnodeEvaluatorRuntimeEvalEvaluationStore
导出分派src/util/output.tswriteOutputwriteMultipleOutputscreateOutputData
格式识别src/util/outputFormats.tsgetOutputFileFormatSUPPORTED_OUTPUT_FILE_FORMATS
JUnitsrc/util/junit.tscreateJunitXmlwriteJunitXmlOutput
JSONL 写入src/util/exportToFile/writeToFile.tsJsonlFileWriter
表格构造src/util/convertEvalResultsToTable.tssrc/util/exportToFile/index.tsconvertResultsToTableconvertTestResultsToTableRow
分享src/share.tscreateShareableUrlisSharingEnabledsendChunkedResultssendChunkWithRetryrollbackEval
blob 内联src/util/inlineBlobsForShare.tsinlineBlobRefsForSharecreateBlobInlineCache
Web 服务端src/server/server.tscreateAppstartServer
结果路由src/server/routes/eval.tsevalRouter
配置路由src/server/routes/configs.tsconfigsRouter
trace 路由src/server/routes/traces.tstracesRouter
前端入口src/app/src/pages/eval/components/ResultsViewResultsTableDownloadMenu
红队报告src/app/src/pages/redteam/report/components/ReportOverviewFrameworkCompliance
OTLP 接收器src/tracing/otlpReceiver.tsOTLPReceiverstartOTLPReceiverconvertIdlogRecordToParsedTrace
protobuf 解码src/tracing/protobuf.tsdecodeExportTraceServiceRequestbytesToHex
trace 存储src/tracing/store.tsTraceStoregetTraceSpansdeleteOldTracessanitizeAttributes
traceparent 生成src/tracing/evaluatorTracing.tsgenerateTraceparentgenerateTraceContextIfNeededstartOtlpReceiverIfNeeded
trace 读取(重试)src/tracing/traceContext.tsfetchTraceContextextractTraceIdFromTraceparent
GenAI 语义约定src/tracing/genaiTracer.tsGenAIAttributeswithGenAISpanopenTurnSpan
工具名提取src/tracing/toolAttributes.tsgetToolNameFromAttributes
CLI 入口src/main.tsmain
eval 主流程src/node/doEval.tsdoEvalEvalRunError
遥测src/telemetry.tsTelemetryrecordshutdown