niceeval 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.zh.md +11 -0
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +4 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +39 -5
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +4 -3
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +3 -4
- package/src/i18n/zh-CN.ts +2 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/run.ts +30 -19
- package/src/runner/types.ts +17 -0
- package/src/sandbox/types.ts +3 -3
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/i18n.ts +10 -1
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +13 -19
- package/src/view/loader.test.ts +61 -0
- package/src/view/loader.ts +35 -12
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
package/README.zh.md
CHANGED
|
@@ -72,6 +72,17 @@ NiceEval 支持两种接入方式,取决于被测 agent 是否需要隔离的
|
|
|
72
72
|
- **Agent 适配器** 是开放的边界:你来决定如何调用被测系统。
|
|
73
73
|
- 需要文件系统隔离的 coding agent 走 **Docker Sandbox**;自有的 AI Agent 可以直连,无需 Docker。
|
|
74
74
|
|
|
75
|
+
## 核心概念一览
|
|
76
|
+
|
|
77
|
+
| 概念 | 一句话 |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Eval | 一个测试用例:写在 `evals/*.eval.ts` 里,描述测什么。 |
|
|
80
|
+
| Experiment | 可签入的运行配置:决定连哪个 Adapter、什么 model、什么 flags。 |
|
|
81
|
+
| Adapter | 连接被测系统的适配层:实现一个 `send`,把返回翻译成标准事件流。 |
|
|
82
|
+
| Sandbox | 需要隔离工作区的 coding agent 才用得到;直连 Web Agent 不需要。 |
|
|
83
|
+
| Tier | 接入 Adapter 的三档投入:Tier 1 只接 send,Tier 2 加 OTel 换调用瀑布图,Tier 3 侵入改造做 feature A/B。 |
|
|
84
|
+
|
|
85
|
+
完整术语表见[架构概览](https://niceeval.com/docs/zh/concepts/overview)。
|
|
75
86
|
|
|
76
87
|
## 示例
|
|
77
88
|
|
package/docs/README.md
CHANGED
|
@@ -110,7 +110,8 @@ npx niceeval exp local fixtures/button
|
|
|
110
110
|
- **建立心智模型:** [Vision](vision.md)、[Concepts(术语表)](concepts.md)、[Architecture](architecture.md)。
|
|
111
111
|
- **写 eval:** [Eval Authoring(编写 eval)](eval-authoring.md)、[Assertions(断言参考:作用域 + 来源)](assertions.md)、[Scoring(评分器)](scoring.md)。
|
|
112
112
|
- **连 AI / 接 agent:** [Agents 与 Adapters(定位与导航)](adapters/README.md)、[Origin 应用接入手册(五个应用的接入记录)](origin-integration.md)、[Tier Sync(examples origin→tier 同步维护工具)](tier-sync.md)、[Adapter 契约(逐 API 适配义务)](adapters/contract.md)、[Adapter 写法(递进式写法)](adapters/authoring.md)、[Capabilities by Construction(能力由构造证明)](capabilities-by-construction.md)、[采集设计(三条外部路线对比)](adapters/collection.md)、[接入目标矩阵(12 个被测对象调研)](adapters/targets.md)、[Coding Agent Skills / Plugins DX](adapters/coding-agent-skills-plugins.md)、[Sandbox](sandbox.md)、[Observability(含 OTel trace 瀑布图)](observability.md)。
|
|
113
|
-
- **设计提案(未实现):** [Multi-Agent(多 agent eval 的三种场景)](multi-agent.md)、[
|
|
113
|
+
- **设计提案(未实现):** [Multi-Agent(多 agent eval 的三种场景)](multi-agent.md)、[Reports(自己搭报告页:React 组件 × 查询模型两档积木)](reports.md)、[Results Lib(结果数据的读写库,Reports 的数据层)](results-lib.md)。
|
|
114
|
+
- **框架自测:** [E2E CI(全链路 e2e 测试方案;`e2e/` 的 tier1 矩阵已落地,workflow 未建)](e2e-ci.md)。
|
|
114
115
|
- **跑与看:** [Experiments(实验/运行矩阵)](experiments.md)、[Runner(执行引擎)](runner.md)、[CLI 参考](cli.md)、[Observability(可观测性)](observability.md)、[Results Format(结果保存格式)](results-format.md)、[View(本地查看器)](view.md)。
|
|
115
116
|
- **背景调研:** [References(从其它项目学到什么)](references.md)、[agent-eval 是怎么做适配的](adapters/reference/agent-eval.md)、[OTel GenAI 等「行为怎么记」标准](adapters/reference/otel-genai.md)、[四个主流 agent loop 接入面调研](adapters/reference/agent-loop-apis.md)、[应用侧 OTel 埋点生态调研](adapters/reference/otel-instrumentation.md)、[eve 的协议机制](adapters/reference/eve-protocol.md)、[Claude Code 自带 OTel 遥测能不能提前拿结果](adapters/reference/claude-code-otel-telemetry.md)。
|
|
116
117
|
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
| **usage** | assistant 行 `message.usage`(含 cache read) | 防御式多路径:`data/payload/item/turn/response.usage`,兼容 `input/output_tokens` 与 `prompt/completion_tokens` 两套命名 | `event(name=="run")` 的 `data.usage`(`prompt/completion_tokens`,**`cost` 直接有**) |
|
|
47
47
|
| **session id(resume 用)** | transcript 首个 `sessionId` 字段(`shared.sessionIdFromClaudeTranscript`) | `thread.started.thread_id`(`shared.codexThreadId`) | tape 文件名含 session hash;adapter 自管 |
|
|
48
48
|
| **实际模型** | transcript 行内有 | 网关场景要第二通道读 `~/.codex/sessions` 的 `turn_context.payload.model`(agent-eval 的做法;niceeval 未接,记为已知缺口) | tape 内 run 事件 |
|
|
49
|
-
| **时间轨** |
|
|
49
|
+
| **时间轨** | beta 版原生 OTLP trace 导出,已接进 adapter 的 `tracing.env`(`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` 等一串 env;span 只有结构与计时、内容默认脱敏,内容仍由 transcript 旁读补全,详见 [reference/claude-code-otel-telemetry.md](reference/claude-code-otel-telemetry.md)) | 原生 OTLP/JSON(`config.toml [otel]`,走 `tracing.configure`) | 原生 OTLP/protobuf(env-based `OTEL_*`,走 `tracing.env`) |
|
|
50
50
|
|
|
51
51
|
remote agent(通道 0)不在表里——它没有"采集",字段来源就是你自己代码里的返回值,见下节示例。
|
|
52
52
|
|
|
@@ -72,10 +72,10 @@ interface AgentSession {
|
|
|
72
72
|
| `t.respond()` / `t.parked()` 等 HITL | send 返回过 `"waiting"` + `input.requested` 事件——做到了就是有 |
|
|
73
73
|
| `t.calledTool()` / `t.toolOrder()` 等正断言 | events 里有 `action.*` |
|
|
74
74
|
| `t.notCalledTool()` / `t.usedNoTools()` 等负断言**可信** | 事件来自带完整性证明的官方转换器(SDK 原生事件流透传、`fromAiSdk` 的 `result.steps`、Responses 的 `output`);手写映射没有这份证明,负断言可信度按来源判断,不按作者的自觉 |
|
|
75
|
-
| `t.sandbox`、`t.fileChanged()` 等 | `defineSandboxAgent` 构造(`kind: "sandbox"`) |
|
|
75
|
+
| `t.sandbox`、`t.sandbox.fileChanged()` 等 | `defineSandboxAgent` 构造(`kind: "sandbox"`) |
|
|
76
76
|
| `EvalResult.trace`、`niceeval view` 瀑布图 | 配置了 OTel 接入(agent 的 `tracing` 块,或 remote agent + `defineConfig({ telemetry })`)——只画瀑布图,不影响任何断言,详见[观测性](../observability.md#otlp-traces--统一瀑布图) |
|
|
77
77
|
|
|
78
|
-
唯一仍然存在的运行时守卫是**沙箱能力**:`t.sandbox` / `t.file` / `t.fileChanged()` 等直接读沙箱文件系统,非沙箱型 agent(`kind !== "sandbox"`)调用这些方法会立即得到清晰报错(`src/context/context.ts` 的 `capabilityGuard`)——没有沙箱就没有东西可读,不报错会静默返回空结果。其余能力(多轮对话、工具观测……)不设运行时守卫:没接会话存取器的 agent 每轮各是新对话(不报错,只是断言看不到历史);没吐 `action.*` 事件的 agent 上正断言自然不命中;负断言的可信度靠事件来源判断,不靠拦截调用。
|
|
78
|
+
唯一仍然存在的运行时守卫是**沙箱能力**:`t.sandbox` / `t.sandbox.file()` / `t.sandbox.fileChanged()` 等直接读沙箱文件系统,非沙箱型 agent(`kind !== "sandbox"`)调用这些方法会立即得到清晰报错(`src/context/context.ts` 的 `capabilityGuard`)——没有沙箱就没有东西可读,不报错会静默返回空结果。其余能力(多轮对话、工具观测……)不设运行时守卫:没接会话存取器的 agent 每轮各是新对话(不报错,只是断言看不到历史);没吐 `action.*` 事件的 agent 上正断言自然不命中;负断言的可信度靠事件来源判断,不靠拦截调用。
|
|
79
79
|
|
|
80
80
|
## 标准事件流
|
|
81
81
|
|
|
@@ -182,7 +182,7 @@ interface InputRequest {
|
|
|
182
182
|
|
|
183
183
|
| 断言族 | 读什么 | adapter 义务 | 数据缺失时 |
|
|
184
184
|
|---|---|---|---|
|
|
185
|
-
| `succeeded()` / `parked()`
|
|
185
|
+
| `succeeded()` / `parked()` | `Turn.status` + 事件流(`parked` 看最后有意义事件是否 `input.requested`) | `status` 如实;HITL 停留吐 `input.requested` | `status` 恒 completed → `succeeded` **假通过(静默)** |
|
|
186
186
|
| `messageIncludes()` / `t.reply` / `turn.message` | `message` 且 `role: "assistant"` 的事件文本 | 每段助手文本吐一条 `message`(工具结果**不是**助手消息,别混,见[参考笔记的踩坑记录](reference/agent-eval.md#claude-code-怎么转换)) | 断言 fail(响) |
|
|
187
187
|
| `outputEquals()` / `outputMatches()` | `turn.data` | 结构化输出放 `data`,不要序列化塞进 `events` | fail(响) |
|
|
188
188
|
| `calledTool()` / `toolOrder()` / `loadedSkill()` / `calledSubagent()` | 派生 `toolCalls` / `subagentCalls` | 每次调用吐 `called` + `result`(callId 配对);`name` 原始名 + `tool` 规范名;result `status` 如实 | fail(响) |
|
|
@@ -230,10 +230,11 @@ t.succeeded() ✓
|
|
|
230
230
|
| 鉴权(API key / token / base url) | **agent 本地** —— 它怎么连自己,是私事 | 在定义里读 env / 闭包,不经 ctx |
|
|
231
231
|
| CLI 细节(装什么包、参数形状、transcript 在哪) | **agent 本地** | 写死在 `send` / `setup` 里 |
|
|
232
232
|
| **model** | **实验决定(留空)** | `ctx.model`(省略 → agent 原生默认) |
|
|
233
|
-
| **
|
|
233
|
+
| **reasoningEffort** | **实验决定(留空)** | `ctx.reasoningEffort`(省略 → agent 原生默认) |
|
|
234
|
+
| **feature flags**(webResearch、注入哪个 skill…) | **实验决定** | `ctx.flags.*` —— agent 的 `send` 与 eval 的 `t.flags` 都能读 |
|
|
234
235
|
| runs / earlyExit / evals / sandbox / budget | **实验决定** | 运行器据此调度 |
|
|
235
236
|
|
|
236
|
-
一句话:**agent 只配「怎么连我自己」,不配「跑哪个模型、开哪些开关」**;后者全留给 [experiment](../experiments.md),经 `ctx`(eval 里是 `t`)透传。这样同一个 agent 能被不同实验以不同 model / flags 复用,不必改 agent。
|
|
237
|
+
一句话:**agent 只配「怎么连我自己」,不配「跑哪个模型、开哪些开关」**;后者全留给 [experiment](../experiments.md),经 `ctx`(eval 里是 `t`)透传。这样同一个 agent 能被不同实验以不同 model / reasoningEffort / flags 复用,不必改 agent。
|
|
237
238
|
|
|
238
239
|
## `ctx`(agent 侧)与 `t`(eval 侧):同一份东西,两个名字
|
|
239
240
|
|
|
@@ -79,7 +79,7 @@ OTEL_TRACES_EXPORT_INTERVAL=1000 # 拉到近实时,eval 场
|
|
|
79
79
|
|
|
80
80
|
## 跟现有采集矩阵的关系
|
|
81
81
|
|
|
82
|
-
[
|
|
82
|
+
**已落地(2026-07)**:下文"升级档"描述的接线已经实现——`claudeCodeAgent` 的 `tracing.env` 注入本页这组 env,与 bub/codex 一样默认开启(见 `src/agents/claude-code.ts`),[采集矩阵](../collection.md#采集矩阵现状与-src-对齐)的时间轨一行已同步。span 只有结构与计时(未开内容 flag),行为轨与断言仍走 transcript 旁读,与下文结论一致。
|
|
83
83
|
|
|
84
84
|
## 结论:这条路径值不值得接
|
|
85
85
|
|
package/docs/assertions.md
CHANGED
|
@@ -158,7 +158,6 @@ export default defineEval({
|
|
|
158
158
|
| `t` | `check` / `require` / `skip` / `log` | 记录 eval run 级断言或控制流,不属于某个 session / turn |
|
|
159
159
|
| `t` | `newSession()` | 只有 run 主上下文负责创建额外 session |
|
|
160
160
|
| `t` | `sandbox.*` | 沙箱是 attempt / run 资源,不是某条会话或某一轮的资源 |
|
|
161
|
-
| `turn` | `expectOk()` | 只对一次 `send` 的返回状态有意义 |
|
|
162
161
|
| `turn` | `outputEquals(value)` / `outputMatches(schema)` | 只对这一轮的 `turn.data` 有意义 |
|
|
163
162
|
|
|
164
163
|
### 值级断言
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
| 能力 | 证据 | 用户要写什么 |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| `t.sandbox`、`t.fileChanged()` 等文件系统断言 | `defineSandboxAgent` 构造(`kind: "sandbox"`) | 无——`defineAgent` 构造的 agent(`kind: "remote"`)调用这些方法会立即得到清晰报错,这是唯一仍需要运行时守卫的能力(`src/context/context.ts` 的 `capabilityGuard`) |
|
|
11
|
+
| `t.sandbox`、`t.sandbox.fileChanged()` 等文件系统断言 | `defineSandboxAgent` 构造(`kind: "sandbox"`) | 无——`defineAgent` 构造的 agent(`kind: "remote"`)调用这些方法会立即得到清晰报错,这是唯一仍需要运行时守卫的能力(`src/context/context.ts` 的 `capabilityGuard`) |
|
|
12
12
|
| 多次 `t.send()`、`t.reply`、`t.newSession()` | `send` 里接了 `ctx.session` 的续接存取器(`history<TMsg>()` 或 `id` + `capture(id)`) | 无——没接就每轮各是新对话,不报错,只是断言看不到跨轮历史 |
|
|
13
13
|
| `t.calledTool()` 等正断言 | `send` 返回的 events 里有 `action.*` | 无——有事件就能断,没有就是断言 fail(响,不静默) |
|
|
14
14
|
| `t.notCalledTool()` / `usedNoTools()` 等负断言的**可信度** | 事件来源是否有完整性契约(SDK 原生事件流透传、`fromAiSdk` 的 `result.steps`、Responses 的 `output`) | 无——但手写映射时如果知道自己漏了工具层,应在 eval README 里如实说明这条 eval 的负断言不可信,而不是假装它和正断言一样可靠 |
|
|
@@ -38,7 +38,7 @@ export default defineAgent({
|
|
|
38
38
|
## 边界
|
|
39
39
|
|
|
40
40
|
- **默认值问题已经不存在。** 提案担心的"`defineAgent` 默认给 `conversation: true, toolObservability: true`,教程第一步要写一行反悔"——这个问题连同整个 `capabilities` 字段一起消失了,不需要保留"opt-out 默认值"这个概念。
|
|
41
|
-
- **`workspace` 对远程 agent 依然不放开。** 文件系统断言(`t.sandbox`、`t.fileChanged()`)只在 `kind: "sandbox"` 上解锁,远程 agent 即便自己改了文件,也没有 sandbox 提供的 diff 基线,这条边界和提案设想的一致。
|
|
41
|
+
- **`workspace` 对远程 agent 依然不放开。** 文件系统断言(`t.sandbox`、`t.sandbox.fileChanged()`)只在 `kind: "sandbox"` 上解锁,远程 agent 即便自己改了文件,也没有 sandbox 提供的 diff 基线,这条边界和提案设想的一致。
|
|
42
42
|
- **`t.newSession()` 在没有接会话续接存取器时依然可以调用。** 新会话线的第一轮就是存取器的自然空态,不需要判断"这个 agent 支不支持多轮"。
|
|
43
43
|
|
|
44
44
|
## 相关阅读
|
package/docs/cli.md
CHANGED
|
@@ -58,9 +58,11 @@ niceeval exp compare --no-early-exit # 关掉,要完整通过率分布
|
|
|
58
58
|
niceeval exp compare --max-concurrency <n> # 并发上限
|
|
59
59
|
niceeval exp compare --timeout <ms> # 单 eval 超时
|
|
60
60
|
niceeval exp compare --force # 忽略指纹缓存,全量重跑
|
|
61
|
-
niceeval exp compare --tag <tag> #
|
|
61
|
+
niceeval exp compare --tag <tag> # 按单个标签过滤
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
不带 `--force` 时,续跑携带的基线是跨全部历史 run、每 `(experimentId, evalId)` 取最新一份(`src/view/loader.ts` 的 `loadLatestResultsPerEval`):上次 passed 且 fingerprint 匹配的直接携入新 summary,只真跑失败/errored/缺失的。所以「补齐一组实验」就是重跑 `niceeval exp <组>` 本身。注意带 eval-id 位置参数时,summary 只含本次计划内的 eval——补跑别用位置参数,否则产出部分快照(见 memory 的 rerun-with-eval-filter-partial-snapshot)。
|
|
65
|
+
|
|
64
66
|
## 评分与退出
|
|
65
67
|
|
|
66
68
|
```sh
|
|
@@ -81,7 +83,7 @@ niceeval view # 起本地 web,自动打开浏览器,读
|
|
|
81
83
|
niceeval view .niceeval/<run>/summary.json
|
|
82
84
|
niceeval view --port 4317 # 固定端口;被占用时向后找可用端口
|
|
83
85
|
niceeval view --no-open # 只启动服务并打印 URL(适合远端 shell / CI)
|
|
84
|
-
niceeval view --out .
|
|
86
|
+
niceeval view --out site # 目录式静态导出,不启动服务(index.html + 查看器工件)
|
|
85
87
|
niceeval clean # 清掉这些历史运行结果
|
|
86
88
|
```
|
|
87
89
|
|
package/docs/concepts.md
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
**Integration tier** / **接入 Tier** —— 按「Adapter 接到哪里、额外拿到什么观测数据」给接入方式分的三档(和下面的**模型档**是两回事,后者说的是给 agent 指定哪个模型)。**Tier 1(只接 send)**:应用代码一行不改——adapter 适配应用现有对外接口实现 `send`,靠手写事件映射或官方转换器拿到工具断言;应用接口本身暴露模型选择的话,**模型对比**类 [Experiment](#experiment) 也在这一档(`model` 经 `ctx.model` 透传)。**Tier 2(send + OTel)**:还是同一个 `send`,事件来源换成应用发给 niceeval 的 OTel span(应用已埋点则零代码,未埋点补一段通用 OTel 初始化)——买到的是观测质量的跃升:事件流免手写映射、负断言带完整性证明、trace 瀑布图。**Tier 3(侵入改造 + experiment flags)**:改应用内部代码,把内部可变点(prompt、工具集、feature flag)暴露成 experiment 可选的配置(经 `flags` → `ctx.flags` 透传),解锁**完整的 feature A/B test**——对照的不再只是模型,而是应用内部的功能变体。前两档都是**无侵入**的:应用按自己的方式启动,eval 侧不 spawn 应用进程、不另开端口,Adapter 只对着用户前端本来就在用的那个接口收发。三档递进不互斥,详见 [docs-site · Tier](../docs-site/zh/concepts/tier.mdx)。
|
|
42
42
|
|
|
43
|
-
**Model tier** / **模型档** —— 给 agent 指定模型的标识(如 `opus
|
|
43
|
+
**Model tier** / **模型档** —— 给 agent 指定模型的标识(如 `opus`)。由 [Experiment](#experiment) 的 `model` 字段指定;省略则用 agent 原生默认,不经额外的策略层决定。推理努力程度(如 `low`/`medium`/`high`,取值由具体模型定义)是独立的 `reasoningEffort` 字段,经 `ctx.reasoningEffort`/`t.reasoningEffort` 透传,归属与 `model` 一致——都是实验决定、agent 留空。
|
|
44
44
|
|
|
45
45
|
## 数据集与发现
|
|
46
46
|
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
**Session** —— 一条会话线。`t` 驱动主 session;`t.newSession()` 返回独立 session,用于并行或隔离的多会话测试。`session.*` 作用域断言只看这条 session 已经发生的事件;这些事件仍会汇入 `t.*` run 级断言。
|
|
64
64
|
|
|
65
|
-
**Turn** —— `t.send()` 的一次返回值,对应这一轮的标准事件流片段。带 `message` / `data` / `toolCalls` / `status` / `usage` / `events`
|
|
65
|
+
**Turn** —— `t.send()` 的一次返回值,对应这一轮的标准事件流片段。带 `message` / `data` / `toolCalls` / `status` / `usage` / `events` 等只读字段,以及一套与 `t` 同名的作用域断言(`turn.calledTool`/`turn.succeeded`/…),作用域收窄成只看这一轮,详见 [Assertions](assertions.md#作用域规则)。
|
|
66
66
|
|
|
67
67
|
**EarlyExit** / **早停** —— 一个 eval 取通过率时,先过一次即中止其余 attempt 的策略(可关)。
|
|
68
68
|
|
package/docs/e2e-ci.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
# E2E CI(
|
|
1
|
+
# E2E CI(部分落地)
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 状态:核心骨架已落地并在本地跑绿(2026-07-07)。`e2e/` 目录已建:shared factory/profile 套件、从 tier1 拷来的五个被测应用(`e2e/apps/`)、五个 SDK 薄项目(`e2e/projects/`)、`e2e/scripts/verify.mjs`——全矩阵 6 次 CLI 调用(5 个 ci 全绿 exit 0 + ai-sdk-v7 verdicts 按期望 exit 1)全部符合期望表。**尚未落地**:`.github/workflows/e2e.yml`(GitHub secrets 还没建)、`aisdk-transformer` / `http-mapping` 两个纯框架项目、示例冒烟 job(第 6 节,P0 文档债也还没修)、L1 nightly 沙箱矩阵。与原提案的实现偏差:verdicts fixture 挂在最便宜的 ai-sdk-v7 上(纯框架项目未建);`ciExperiment(agent)` 不吃 profile——能力过滤靠"stub 文件是否存在",ci/verdicts 切分靠 `evals` 谓词按 `deliberate-` 前缀;profile 实际字段是 `weatherToolName / calcToolName / usage / sandboxTools / workspaceDir`(hitl 由 `calcToolName` 非空推导);`e2e/apps` 不是逐字节拷贝而是**只保 backend 的协议夹具**——前端、vite/开发工作流、langgraph 的 node 侧文件和 Jaeger compose 都已裁掉(前端不参与被测协议,裁掉能显著缩短 CI 安装),将来 tier1 → e2e/apps 的 drift check 对比范围只框 `src/backend/`;另注意 `budget` 护栏对不报 usage 的 agent 无法执行(见 `memory/e2e-suite-landing-gotchas.md`)。
|
|
4
|
+
>
|
|
5
|
+
> 本文其余部分保持原提案文本——用真实的 `niceeval exp` 全链路(发现 → 调度 → 断言 → 评分 → 工件 → 退出码)验证框架和官方适配路径,而不是只跑 typecheck。当前 `.github/workflows/ci.yml` 只有 typecheck / site:build / docs 校验,没有任何 workflow 真正执行过 eval。**e2e 全程不允许假 AI**:所有被测应用和框架级项目都调真实模型,CI 里的 key 从 GitHub Actions secrets 注入,本地开发者自己在 `.env` 放真实 key——这和 `examples/zh/origin` 那 6 个示例的既有政策一致(见 `memory/origin-examples-real-ai-credentials.md`),e2e 不搞例外。
|
|
4
6
|
|
|
5
7
|
## 1. 目标
|
|
6
8
|
|
|
7
9
|
一次 e2e CI 要同时证明五件事:
|
|
8
10
|
|
|
9
11
|
1. **完整路径**:从 `evals/` 发现、experiment 展开运行矩阵、`t.*` 断言收集、gate/soft 判决、`.niceeval/<run>/` 工件落盘,到进程退出码,每一环都被真实执行并被机器校验——包括"该红的时候红"(deliberate-fail 必须 exit 1),不是只测 happy path。
|
|
10
|
-
2. **一套 suite
|
|
12
|
+
2. **一套 suite,全矩阵可靠通过**:所有 SDK 适配项目共用**同一份** eval/experiment 定义(单一事实来源,见第 3 节),CI 把每个项目都跑一遍。真实模型跑真实调用,单次尝试允许抖动,但有限重试(见 4.1)吸收不了的失败一定是真回归——任何一个 SDK 的适配层回归都会把矩阵打红,不会被"模型这次没答好"这种噪音长期掩盖,也不会被 retry 永久掩盖。
|
|
11
13
|
3. **正反验证所有功能**:套件对每个功能都配正反两条用例——该调工具时 `t.calledTool`、不该调时 `t.notCalledTool`/`t.usedNoTools`、HITL 有 approve 也有 deny、会话有记忆也有 `newSession` 隔离;再加 deliberate-fail/error 验证"断言真的会挂"。防止"断言永真"这类静默失效。
|
|
12
|
-
4. **重复运行统计**:一个 experiment 配 `runs:
|
|
14
|
+
4. **重复运行统计**:一个 experiment 配 `runs: N, earlyExit: false` 跑重复调度、并发、pass 率计数(niceeval 的字段就叫 `runs`,不叫 pass/trials;`earlyExit` 不关掉的话第一次通过就会 abort 其余 attempt,拿不到完整分布)。真实模型调用要花钱,PR 门禁上 `N` 取小值(如 10),完整的大样本统计(如 100)挪到 nightly 跑,见第 4 节。
|
|
13
15
|
5. **官方适配矩阵**:内建 `uiMessageStreamAgent`、`fromAiSdk`/`fromClaudeSdkMessages`/`fromCodexThreadEvents`/`fromPiAgentEvents` 转换器、自写 `defineAgent` 映射,以及沙箱型 claude-code / codex / bub × docker / e2b / vercel,每条官方路径至少一条 CI 覆盖。
|
|
14
16
|
|
|
15
17
|
## 2. 现状盘点
|
|
@@ -24,11 +26,17 @@ e2e 从 tier1 拷的是**被测应用和 adapter**(各 SDK 真正不同的部分
|
|
|
24
26
|
|
|
25
27
|
所以 e2e 的"一套 eval"不能靠字面复制或软链实现,要靠参数化新写(见第 3 节);tier1 的这些差异正好告诉我们 profile 里要有哪些开关。另外源码层面确认:`src/runner/discover.ts` 固定扫 `<root>/evals` 与 `<root>/experiments`,`walkFiles` 用 Dirent 的 `isDirectory()/isFile()` 判断——**symlink 条目两者都返回 false,逐文件或逐子目录软链不会被发现**;整个 `evals/` 目录本身做软链虽可行(readdir 跟随路径),但前提是文件逐字同构,上面已经否定了。
|
|
26
28
|
|
|
29
|
+
另外,tier1 各项目本来就已经在调真实模型——`claude-sdk`/`pi-sdk`/`langgraph` 的 backend 用 `process.env.AGENT_MODEL ?? "deepseek-v4-flash"` 走 DeepSeek 兼容端点,`codex-sdk` 默认 `"gpt-5.4"` 走 Codex 代理,凭据映射和 `examples/zh/origin` 一致(见 `memory/origin-examples-real-ai-credentials.md`)。e2e/apps 直接复用这套映射即可,不需要为"跑得起来"发明新机制,只需要在 CI 里把对应 key 从 GitHub Actions secrets 注入(见第 3.2、7 节)。
|
|
30
|
+
|
|
31
|
+
### `examples/zh/ai-sdk` 的 README 是需要清理的过期文档
|
|
32
|
+
|
|
33
|
+
`examples/zh/ai-sdk/README.md` 现在写着"默认是 `AGENT_MODE=mock`,不需要 API key",但实测 `src/server.ts`、`src/ai-sdk-runtime.ts`、`src/models.ts` 里**完全没有** `AGENT_MODE` 分支或任何 mock provider——`resolveModel()` 无条件要求真实 `OPENAI_API_KEY`/`DEEPSEEK_API_KEY`,`.env.example` 里也不提 `AGENT_MODE`。这条 mock 声明本身就是过期文档,不是 e2e 该保留或恢复的能力——落地 L1(第 6 节)之前要先把 README 这几行改成真实 key 说明,而不是去"实现"一个文档里写着但代码里没有、而且按项目政策也不该有的 mock 分支。
|
|
34
|
+
|
|
27
35
|
### 框架侧对 CI 重要的事实(源码已确认)
|
|
28
36
|
|
|
29
|
-
- **退出码**:全过/跳过 → 0;任一 failed 或 errored → 1;框架崩溃 → 2(`src/cli.ts:
|
|
30
|
-
- **指纹缓存会静默跳过上次 passed 的 eval**(`src/runner/run.ts
|
|
31
|
-
- **judge 无 key 时 no-op**(`src/scoring/judge.ts
|
|
37
|
+
- **退出码**:全过/跳过 → 0;任一 failed 或 errored → 1;框架崩溃 → 2(`src/cli.ts:461,468`)。CI 判成败首选退出码,细分读 `summary.json`。
|
|
38
|
+
- **指纹缓存会静默跳过上次 passed 的 eval**(`src/runner/run.ts` 的 `run()`,匹配逻辑在文件开头 ~60 行附近,以 `cacheKey`/`computeFingerprint` 为核心)。CI 必须加 `--force`,或保证 `.niceeval/` 不跨 run 复用,否则回归会被缓存掩盖。
|
|
39
|
+
- **judge 无 key 时 no-op**(`src/scoring/judge.ts` 的 `resolveJudge()`/`buildJudge()`,`if (!resolved.apiKey) return noOpJudge()`):不配 judge key,`t.judge.autoevals.*` 断言静默跳过、不判红。e2e 里"judge 真的在判"必须显式配 judge key 验证,不能靠这条 no-op 蒙混过关——这一点和是否用真实模型无关,是两个独立的开关。
|
|
32
40
|
- **可用 flags**:`--runs`、`--no-early-exit`、`--force`、`--junit <path>`、`--strict`、`--max-concurrency`。**不要用** `--json`(死 flag)、`--reporter`(不存在)、`--agent`/`--model`(exp 下报错)、`--sandbox`(已移除)。
|
|
33
41
|
- **`runs` 的语义**:每个 `(agent × model × eval)` 组合跑 `runs` 次;被 earlyExit abort 的 attempt 不计入分母。
|
|
34
42
|
|
|
@@ -41,9 +49,9 @@ e2e/
|
|
|
41
49
|
shared/ # 唯一一份 eval / experiment 定义(单一事实来源)
|
|
42
50
|
profile.ts # AgentProfile 类型:工具名、能力开关
|
|
43
51
|
evals.ts # weatherTool(p) / basicQa(p) / hitlApprove(p) ... 的 factory
|
|
44
|
-
experiments.ts # ciExperiment(agent, p) /
|
|
52
|
+
experiments.ts # ciExperiment(agent, p) / passN(agent, p) factory
|
|
45
53
|
verdicts.ts # deliberateFail() / deliberateError() factory(只进 verdicts 实验)
|
|
46
|
-
apps/ # 被测应用,从 examples/zh/tier1/<name>
|
|
54
|
+
apps/ # 被测应用,从 examples/zh/tier1/<name> 拷来,原样保留真实调用
|
|
47
55
|
ai-sdk-v7/ claude-sdk/ codex-sdk/ pi-sdk/ langgraph/
|
|
48
56
|
projects/ # 每个 SDK 一个 niceeval 项目:adapter + profile + 3 行 stub
|
|
49
57
|
ai-sdk-v7/
|
|
@@ -54,10 +62,9 @@ e2e/
|
|
|
54
62
|
evals/... # 其余 stub,一 eval 一文件,保住可读的 eval id
|
|
55
63
|
experiments/ci.ts # stub:export default ciExperiment(agent, profile)
|
|
56
64
|
claude-sdk/ codex-sdk/ pi-sdk/ langgraph/
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
aisdk-transformer/ # 纯框架项目:defineAgent + 官方 fromAiSdk,进程内直调真实 DeepSeek
|
|
66
|
+
http-mapping/ # 纯框架项目:defineAgent + 手写映射,进程内直调真实 DeepSeek REST
|
|
59
67
|
scripts/
|
|
60
|
-
mock-server.mjs # mock-ai-sdk / mock-http 共用的确定性本地 server
|
|
61
68
|
verify.mjs # 元校验:跑 CLI 子进程,对照期望表检查退出码 + summary.json
|
|
62
69
|
```
|
|
63
70
|
|
|
@@ -66,12 +73,14 @@ e2e/
|
|
|
66
73
|
eval 定义本来就不 import agent(agent 由 experiment 绑定),所以共享 eval 在框架模型上是顺的;挡路的只有两件事:discover 只认 `<root>/evals` 下的实体文件(symlink 条目不被发现,见第 2 节),以及各 SDK 的协议差异(工具名、usage、HITL 支持)。两个问题一个解法——共享层导出**参数化的 factory**,每个项目用一个 `profile.ts` 声明自己的协议现实,`evals/` 下只放 stub 文件喂给 discover:
|
|
67
74
|
|
|
68
75
|
```typescript
|
|
69
|
-
// e2e/shared/profile.ts
|
|
76
|
+
// e2e/shared/profile.ts(与源码同步)
|
|
70
77
|
export interface AgentProfile {
|
|
71
|
-
weatherToolName: string;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
weatherToolName: string | null; // "get_weather" 或 MCP 命名空间名;coding agent 为 null
|
|
79
|
+
calcToolName: string | null; // 经审批门控的计算器;不支持 HITL(codex-sdk)为 null
|
|
80
|
+
searchToolName: string | null; // 网络搜索工具;只有 ai-sdk-v7 的被测应用注册了
|
|
81
|
+
usage: boolean; // 协议是否携带 usage(决定 basic-qa 是否断言 maxTokens)
|
|
82
|
+
sandboxTools: boolean; // 是否是 coding agent(决定 create-file/run-command 等是否生效)
|
|
83
|
+
workspaceDir?: string; // coding agent 的工作目录(eval 直接读磁盘核实)
|
|
75
84
|
}
|
|
76
85
|
```
|
|
77
86
|
|
|
@@ -131,56 +140,89 @@ export function noToolChitchat(p: AgentProfile) {
|
|
|
131
140
|
|
|
132
141
|
**防静默失配**:profile 关掉一个能力,对应 eval stub 就不该存在于该项目——verify.mjs 按 profile 算出每个项目的期望 eval 数,和 `summary.json` 的 results 数对账,防止"少排了用例还全绿"。
|
|
133
142
|
|
|
134
|
-
### 3.2 apps:拷贝自 tier1
|
|
143
|
+
### 3.2 apps:拷贝自 tier1,真实 key 从 secrets 注入
|
|
135
144
|
|
|
136
|
-
`e2e/apps/<name>` 从 `examples/zh/tier1/<name>`
|
|
145
|
+
`e2e/apps/<name>` 从 `examples/zh/tier1/<name>` 拷贝,保留原有的高位端口分配,**不加任何 mock 分支**——tier1 里这些应用本来就已经是真实调用(`AGENT_MODEL ?? "deepseek-v4-flash"` / Codex 代理),e2e 直接复用同一套凭据映射即可。CI 里跑这些应用时,workflow 把 `DEEPSEEK_API_KEY`(claude-sdk / pi-sdk / langgraph / ai-sdk-v7,分别走 Anthropic 兼容和 OpenAI 兼容两个 base URL)、`CODEX_API_KEY`(codex-sdk)从 GitHub Actions secrets 注入进子进程环境,本地开发者跑同样的套件就在自己的 `.env` 里放同一把 key(和 tier1 项目现在的开发方式完全一致,没有新概念)。
|
|
137
146
|
|
|
138
|
-
|
|
147
|
+
为了让 PR 门禁的花费可控,e2e/apps 统一钉住**便宜模型**(DeepSeek `deepseek-v4-flash` / Codex 代理默认模型),更贵的模型对比矩阵挪到 4.2 的 nightly 层。
|
|
139
148
|
|
|
140
|
-
|
|
141
|
-
|---|---|---|
|
|
142
|
-
| ai-sdk-v7 | app 内加 mock 分支(AI SDK 支持自定义 LanguageModel,或规则函数直出) | 是 |
|
|
143
|
-
| langgraph | LangChain 系有 FakeChatModel 类工具,Python 侧替换模型即可 | 是 |
|
|
144
|
-
| pi-sdk | 待验证 pi SDK 的模型注入点;不可行则留 nightly | 待验证 |
|
|
145
|
-
| claude-sdk | Claude Agent SDK 走 claude CLI → Anthropic API,mock 要 `ANTHROPIC_BASE_URL` 指本地 API 形状 mock,可行性待验证;不可行则留 nightly | 待验证 |
|
|
146
|
-
| codex-sdk | 同理,待验证 codex 的 base-url 注入;不可行则留 nightly | 待验证 |
|
|
149
|
+
拷贝的代价是和 tier1 漂移。缓解:`scripts/sync-tiers.mjs` 已经在做 origin → tier1 的同步,给它加一条 tier1 → `e2e/apps` 的 check,`pnpm tiers:check` 顺带守住。e2e/apps 的定位是"协议保真的测试夹具",不承担 tier1 的文档叙事职责,允许它为可测性加代码(比如把端口做成可配置的),但不允许加假响应。
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
### 3.3 aisdk-transformer / http-mapping:不依赖任何 app 进程的框架基线
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
PR 门禁需要一个启动成本最低、最便宜的真实基线,不依赖起 5 个 SDK app 进程。`e2e/projects/aisdk-transformer` 和 `http-mapping` 不需要 `e2e/apps` 里的任何应用——adapter 在进程内直接调真实 DeepSeek,不经过任何被测服务器:
|
|
151
154
|
|
|
152
|
-
|
|
155
|
+
```typescript
|
|
156
|
+
// e2e/projects/aisdk-transformer/agents/aisdk-transformer.ts(示意)
|
|
157
|
+
import { defineAgent } from "niceeval";
|
|
158
|
+
import { fromAiSdk } from "niceeval/agents/ai-sdk";
|
|
159
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
160
|
+
import { generateText, tool } from "ai";
|
|
161
|
+
import { z } from "zod";
|
|
162
|
+
|
|
163
|
+
const deepseek = createOpenAI({
|
|
164
|
+
apiKey: process.env.DEEPSEEK_API_KEY,
|
|
165
|
+
baseURL: "https://api.deepseek.com",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export default defineAgent({
|
|
169
|
+
name: "aisdk-transformer",
|
|
170
|
+
async send(ctx) {
|
|
171
|
+
const result = await generateText({
|
|
172
|
+
model: deepseek.chat("deepseek-v4-flash"),
|
|
173
|
+
messages: ctx.messages,
|
|
174
|
+
tools: {
|
|
175
|
+
get_weather: tool({
|
|
176
|
+
inputSchema: z.object({ city: z.string() }),
|
|
177
|
+
execute: async ({ city }) => ({ city, tempC: 25, condition: "晴" }),
|
|
178
|
+
}),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
return fromAiSdk(result); // 官方转换器,真实的 generateText 结果直喂
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
```
|
|
153
185
|
|
|
154
|
-
- **
|
|
155
|
-
- **
|
|
186
|
+
- **aisdk-transformer**:`defineAgent` + 官方 `fromAiSdk` 转换器,喂真实 `generateText` 结果(不是伪造成 AI SDK 形状的假数据)。
|
|
187
|
+
- **http-mapping**:`defineAgent` + 手写映射,直接 `fetch` DeepSeek 的 chat completions REST 接口,自己把 JSON 响应映射成标准事件流,实现参照 `examples/zh/ai-sdk/adapter/adapter.ts` 裁剪。
|
|
156
188
|
|
|
157
|
-
两个项目复用同一份 shared 套件(profile:`usage: false, hitl: false`),分别覆盖"官方转换器"和"自写映射"
|
|
189
|
+
两个项目复用同一份 shared 套件(profile:`usage: false, hitl: false`),分别覆盖"官方转换器"和"自写映射"两条官方支持的事件流生成方式,都是对真实 DeepSeek 端点的真实调用——省的是"起被测服务器进程"和"更贵的模型"这两项成本,不是省"真实推理"这一项。`runs: N` 统计、deliberate-fail/error、缓存行为这些框架级专项都挂在这两个项目上,因为它们最便宜、启动最快。
|
|
158
190
|
|
|
159
|
-
deliberate-fail / deliberate-error 这类"故意红"的 fixture
|
|
191
|
+
deliberate-fail / deliberate-error 这类"故意红"的 fixture 设计成**对模型输出不敏感**——比如断言一个真实压根不会被调用的工具名(`t.calledTool("this_tool_does_not_exist")`),或者给 adapter 喂一个格式错误的输入触发运行时异常——不管真实模型这次答得好不好,这两条断言都必然会挂,不依赖任何伪造的坏响应。只进 `verdicts` 实验(不进 `ci`),由 verify.mjs 以"期望 exit 1"的方式正向消费——这是第 1 节"正反验证"在退出码层的那一半。
|
|
160
192
|
|
|
161
|
-
## 4.
|
|
193
|
+
## 4. 两层执行模型
|
|
162
194
|
|
|
163
|
-
|
|
195
|
+
不再按"要不要秘密"分层(e2e 全程都要真实 key),而是按"多久跑一次、花多少钱、跑多大的模型/样本量"分两层:
|
|
164
196
|
|
|
165
|
-
| 层 | 触发 |
|
|
197
|
+
| 层 | 触发 | 模型档位 & runs | 跑什么 |
|
|
166
198
|
|---|---|---|---|
|
|
167
|
-
| **L0
|
|
168
|
-
| **L1
|
|
169
|
-
| **L2 真实层** | nightly cron + 手动 dispatch | 各家 API key;Docker | e2e/projects 切真模型跑同一份套件(验证 mock 没有把真实协议测歪)+ 沙箱矩阵 + judge 真产分 |
|
|
199
|
+
| **L0 PR 门禁**(真实 key,便宜模型) | 每个 PR + push main | `deepseek-v4-flash` 等便宜模型;`runs: 3, earlyExit: true`(第一次过就收尾,吸收单次抖动) | `e2e/projects/*` 全矩阵:aisdk-transformer / http-mapping 必跑;apps 里已接入的 SDK 项目逐个纳入。共享套件通过 + verdicts 期望 exit 1 + `runs` 统计(小样本) + 缓存行为 |
|
|
200
|
+
| **L1 真实矩阵**(nightly) | schedule cron + 手动 dispatch | 完整官方模型矩阵(gpt-4o、gpt-5.4 等) + `runs: 20~100`,统计更稳的通过率分布 | 同一份共享套件切到完整模型矩阵 + judge 真产分(配 judge key)+ 沙箱矩阵 |
|
|
170
201
|
|
|
171
|
-
L0
|
|
202
|
+
L0 是合并门禁;L1 是每日健康信号(模型矩阵更贵、样本更大,允许波动,不阻塞 PR)。注意 L0 里"CI 起 app"不违反 tier1 的"eval 不代管进程"原则——起应用的是 CI workflow(扮演用户),不是 eval 框架。`examples/zh/ai-sdk` 的示例冒烟(原第 6 节的"L1")并入 L0 的 PR 门禁,作为独立 job 跑,详见第 6 节。
|
|
172
203
|
|
|
173
|
-
### 4.1 L0
|
|
204
|
+
### 4.1 L0 的通过语义:确定性部分精确断言,模型相关部分容忍抖动
|
|
174
205
|
|
|
175
|
-
|
|
206
|
+
verify.mjs 对每个项目区分两类断言:
|
|
176
207
|
|
|
177
|
-
-
|
|
178
|
-
- `
|
|
179
|
-
|
|
208
|
+
- **确定性部分**(和真实模型答得好不好无关):discover 出的 eval 数量按 profile 精确对账(防止少排用例);`verdicts` 实验里 deliberate-fail/error 的失败/报错次数是精确断言,因为这些 fixture 本来就设计成对模型输出不敏感。
|
|
209
|
+
- **模型相关部分**(工具是否调对、judge 打分):允许有限重试吸收单次抖动——`runs: 3, earlyExit: true`,一次通过就算过。断言口径从"exact count"改成"通过率下限",例如 `ci` 实验断言 `passed >= 期望 eval 数 × 0.95`(留一点余量给真实模型的单次波动;如果某个 eval 连续 3 次都没过,说明是适配层真回归,不是抖动,矩阵照样打红)。
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
// e2e/scripts/verify.mjs(示意,节选)
|
|
213
|
+
const PROJECTS = [
|
|
214
|
+
{ dir: "projects/aisdk-transformer", exp: "ci", expectExit: 0, expect: toleratedCounts(profileOf("aisdk-transformer")) },
|
|
215
|
+
{ dir: "projects/aisdk-transformer", exp: "verdicts", expectExit: 1, expect: { failedAtLeast: 1, erroredAtLeast: 1 } },
|
|
216
|
+
{ dir: "projects/aisdk-transformer", exp: "pass-n", expectExit: 0, expect: { passedAtLeast: 18 /* 2 条 eval × 10 runs,留 10% 余量 */ } },
|
|
217
|
+
{ dir: "projects/http-mapping", exp: "ci", expectExit: 0, expect: toleratedCounts(profileOf("http-mapping")) },
|
|
218
|
+
{ dir: "projects/ai-sdk-v7", exp: "ci", expectExit: 0, expect: toleratedCounts(profileOf("ai-sdk-v7")) },
|
|
219
|
+
// ... 其余已接入的 SDK 项目
|
|
220
|
+
];
|
|
221
|
+
```
|
|
180
222
|
|
|
181
|
-
### 4.2
|
|
223
|
+
### 4.2 L1:nightly 真实矩阵 + 沙箱
|
|
182
224
|
|
|
183
|
-
两块内容。第一块:e2e/projects
|
|
225
|
+
两块内容。第一块:e2e/projects 里的共享套件切到**完整官方模型矩阵**,`--runs 20` 起步,加 judge key 顺带验证 judge 断言真的产出分数而非 no-op;更大的模型矩阵和样本量本来就更贵更慢,放 nightly 而不是 PR 门禁,失败发通知(GitHub issue / 通知渠道)而不是标红 main。
|
|
184
226
|
|
|
185
227
|
第二块:沙箱矩阵,按"官方适配器 × 沙箱后端"取最小生成集,不做全叉乘:
|
|
186
228
|
|
|
@@ -196,54 +238,34 @@ mock 是确定性的,所以期望值是精确的,verify.mjs 对每个项目断
|
|
|
196
238
|
|
|
197
239
|
已知约束(来自项目 memory,落地时直接采纳):e2b 的 `base` 模板 node20/481MB 会 OOM,必须用预制的 `fasteval-agents` 模板;vercel 后端默认并发 1(避免 429),session 上限约 360s;沙箱内不要 hardcode `/home/node`。
|
|
198
240
|
|
|
199
|
-
|
|
241
|
+
L1 每个 experiment 都设 `budget`(建议单 job ≤ $2)和 `timeoutMs`,workflow 层再加 job timeout 兜底——PR 门禁(L0)因为跑得频繁,单个 job 的 `budget` 应该卡得更紧(建议 ≤ $0.5),便宜模型 + 小 runs 是控制单次 PR 成本的主要手段。
|
|
200
242
|
|
|
201
243
|
## 5. 元校验脚本(e2e 的"真正的测试")
|
|
202
244
|
|
|
203
|
-
L0 的本体不是那些 eval——eval 是 fixture;本体是 `e2e/scripts/verify.mjs`:它把 CLI 当黑盒子进程跑,对照期望表校验退出码和 `summary.json
|
|
245
|
+
L0 的本体不是那些 eval——eval 是 fixture;本体是 `e2e/scripts/verify.mjs`:它把 CLI 当黑盒子进程跑,对照期望表校验退出码和 `summary.json`(完整示意见 4.1)。verify.mjs 还负责两个专项:
|
|
204
246
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
import { execFileSync } from "node:child_process";
|
|
208
|
-
import { readFileSync } from "node:fs";
|
|
209
|
-
|
|
210
|
-
const PROJECTS = [
|
|
211
|
-
{ dir: "projects/mock-ai-sdk", exp: "ci", expectExit: 0, expect: exactCounts(profileOf("mock-ai-sdk")) },
|
|
212
|
-
{ dir: "projects/mock-ai-sdk", exp: "verdicts", expectExit: 1, expect: { failedAtLeast: 1, erroredAtLeast: 1 } },
|
|
213
|
-
{ dir: "projects/mock-ai-sdk", exp: "pass-100", expectExit: 0, expect: { passed: 200, failed: 0, errored: 0 } },
|
|
214
|
-
{ dir: "projects/mock-http", exp: "ci", expectExit: 0, expect: exactCounts(profileOf("mock-http")) },
|
|
215
|
-
{ dir: "projects/ai-sdk-v7", exp: "ci", expectExit: 0, expect: exactCounts(profileOf("ai-sdk-v7")) },
|
|
216
|
-
// ... 其余已接 mock 的 SDK 项目
|
|
217
|
-
];
|
|
218
|
-
|
|
219
|
-
for (const c of PROJECTS) {
|
|
220
|
-
const code = runNiceeval(c.dir, ["exp", c.exp, "--force", "--junit", `junit-${c.exp}.xml`]);
|
|
221
|
-
assert.equal(code, c.expectExit, `${c.dir}/${c.exp} 退出码`);
|
|
222
|
-
const summary = JSON.parse(readFileSync(latestRunDir(c.dir) + "/summary.json", "utf8"));
|
|
223
|
-
assertCounts(summary, c.expect); // exactCounts 按 profile 推期望 eval 数,对账防少排
|
|
224
|
-
}
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
verify.mjs 还负责两个专项:
|
|
247
|
+
- **缓存行为**:同一 experiment 连跑两次,第二次**不带** `--force` → 断言 summary 里携入的 cached 结果仍计为 passed 且没有真跑(比较 `durationMs` 或 attempt 工件时间戳,顺带省一次真实调用的钱);第二次**带** `--force` → 断言全部真跑。这把"CI 忘了 --force 会静默跳过"的坑变成被测行为。
|
|
248
|
+
- **工件形状**:抽查一个 attempt 目录,断言 `events.json` 是 JSON array、`summary.json` 顶层有 `format/schemaVersion/producer/passed/failed/errored/results[]`、`results[].artifactsDir` 指向存在的目录。防止 results-format 无声漂移。
|
|
228
249
|
|
|
229
|
-
|
|
230
|
-
- **工件形状**:抽查一个 attempt 目录,断言 `events.json` 是 JSON array、`summary.json` 顶层有 `passed/failed/errored/results[]`、`results[].artifactsDir` 指向存在的目录。防止 results-format 无声漂移(当前 writer 没有 schemaVersion,只能靠这层守)。
|
|
250
|
+
## 6. L0 里的示例冒烟 job
|
|
231
251
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
目的只有一个:公开示例必须能按 README 跑通。首选 `examples/zh/ai-sdk`,因为它的 server 有零 key 的 `AGENT_MODE=mock`:
|
|
252
|
+
目的只有一个:公开示例必须能按 README 跑通。首选 `examples/zh/ai-sdk`——落地前必须先完成第 2 节提到的清理:把 README 里"默认是 `AGENT_MODE=mock`"那几行改成真实 key 说明,因为代码里从来就没有这个开关。清理之后,这个 job 和 L0 里其它项目一样,从 GitHub Actions secrets 注入真实 key:
|
|
235
253
|
|
|
236
254
|
```yaml
|
|
237
255
|
# job 示意
|
|
238
256
|
- run: pnpm install --dir examples/zh/ai-sdk
|
|
239
|
-
- run:
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
- run: node server & # 起 127.0.0.1:5188,等端口就绪
|
|
258
|
+
env:
|
|
259
|
+
OPENAI_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
260
|
+
OPENAI_BASE_URL: https://api.deepseek.com
|
|
261
|
+
AGENT_MODEL: deepseek-v4-flash
|
|
262
|
+
- run: pnpm exec niceeval list # eval 发现冒烟
|
|
263
|
+
- run: pnpm exec niceeval exp compare-models --runs 3 --force --junit junit.xml
|
|
242
264
|
```
|
|
243
265
|
|
|
244
|
-
验收前提(
|
|
266
|
+
验收前提(落地时先确认,不满足就先修示例本身,而不是加 mock 绕过去):`weather-tool` 等 gate 断言必须能在真实 DeepSeek 调用下稳定通过——即真实模型对天气提问确实会触发 `get_weather` 工具调用。judge 断言没配 judge key 时自动跳过,是否额外配 judge key 走完整评分是 nightly(4.2)的事,不是这个冒烟 job 的职责。
|
|
245
267
|
|
|
246
|
-
|
|
268
|
+
这个 job 和 e2e/projects 矩阵的分工:e2e/projects 测的是框架和适配层(fixture 允许为可测性改造);这个 job 测的是"用户照 README 操作会不会翻车"(fixture 一个字都不许为 CI 改)。`examples/zh/tier1/*` 不用这个 job 覆盖——它们的可测化版本已经在 `e2e/apps` 里了。
|
|
247
269
|
|
|
248
270
|
## 7. Workflow 编排
|
|
249
271
|
|
|
@@ -253,43 +275,58 @@ verify.mjs 还负责两个专项:
|
|
|
253
275
|
on:
|
|
254
276
|
push: { branches: [main] }
|
|
255
277
|
pull_request:
|
|
256
|
-
schedule: [{ cron: "0 3 * * *" }] #
|
|
278
|
+
schedule: [{ cron: "0 3 * * *" }] # L1 nightly
|
|
257
279
|
workflow_dispatch:
|
|
258
280
|
|
|
259
281
|
jobs:
|
|
260
282
|
e2e-matrix: # L0,PR 门禁
|
|
261
283
|
runs-on: ubuntu-latest
|
|
262
|
-
|
|
284
|
+
env:
|
|
285
|
+
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
286
|
+
CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
|
|
287
|
+
steps: [checkout, pnpm install, 起已接入的 apps, node e2e/scripts/verify.mjs]
|
|
263
288
|
|
|
264
|
-
e2e-examples: #
|
|
289
|
+
e2e-examples: # 第 6 节的示例冒烟,PR 门禁
|
|
265
290
|
runs-on: ubuntu-latest
|
|
291
|
+
env:
|
|
292
|
+
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
266
293
|
# 见第 6 节
|
|
267
294
|
|
|
268
|
-
e2e-
|
|
295
|
+
e2e-nightly: # L1,仅 schedule / dispatch
|
|
269
296
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
270
|
-
strategy: { fail-fast: false, matrix: { target: [claude-docker, codex-docker, bub-docker, claude-e2b, claude-vercel, sdk-projects-
|
|
297
|
+
strategy: { fail-fast: false, matrix: { target: [claude-docker, codex-docker, bub-docker, claude-e2b, claude-vercel, sdk-projects-full-matrix] } }
|
|
271
298
|
runs-on: ubuntu-latest # ubuntu runner 自带 Docker
|
|
299
|
+
env:
|
|
300
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
301
|
+
CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
|
|
302
|
+
BUB_API_KEY: ${{ secrets.BUB_API_KEY }}
|
|
303
|
+
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
|
304
|
+
NICEEVAL_JUDGE_KEY: ${{ secrets.NICEEVAL_JUDGE_KEY }}
|
|
272
305
|
```
|
|
273
306
|
|
|
307
|
+
需要建的 GitHub repo secrets:`DEEPSEEK_API_KEY`(claude-sdk / pi-sdk / langgraph / ai-sdk-v7 / examples/zh/ai-sdk 共用,分饰 Anthropic 兼容与 OpenAI 兼容两个 base URL,同一把 key)、`CODEX_API_KEY`(codex-sdk & codex 沙箱)、`ANTHROPIC_API_KEY`(claude-code 沙箱)、`BUB_API_KEY`、`E2B_API_KEY`、Vercel token、`NICEEVAL_JUDGE_KEY`(nightly judge)。值可以复用 `examples/zh/origin` 已经在用的那套凭据(见 `memory/origin-examples-real-ai-credentials.md`),不需要重新申请。
|
|
308
|
+
|
|
274
309
|
所有 job 统一约定:
|
|
275
310
|
|
|
276
311
|
- 每次调用 `niceeval exp` 都带 `--force`(禁指纹缓存)+ `--junit <path>`;junit 和整个 `.niceeval/` 目录用 `actions/upload-artifact` 上传,失败时可下载 `events.json` / `trace.json` 排查。
|
|
277
|
-
- 成败判据 = 进程退出码;verify.mjs
|
|
278
|
-
-
|
|
312
|
+
- 成败判据 = 进程退出码;verify.mjs 层再做计数级校验(L0 用容忍区间,见 4.1)。
|
|
313
|
+
- 只有 pull_request 触发的 workflow 才能读到 repo secrets(同仓库 PR;fork 来的 PR 默认拿不到 secrets,这是 GitHub 本身的安全模型,不是本设计要解决的问题)——如果未来要支持 fork PR 跑 e2e,需要额外做 `pull_request_target` + 手动审核这类隔离,不在本提案范围内。
|
|
279
314
|
|
|
280
315
|
## 8. 分阶段落地
|
|
281
316
|
|
|
282
|
-
1. **
|
|
283
|
-
2. **
|
|
284
|
-
3. **
|
|
285
|
-
4. **
|
|
286
|
-
5.
|
|
317
|
+
1. **P0(先修文档债)**:把 `examples/zh/ai-sdk/README.md` 里过期的 `AGENT_MODE=mock` 声明改成真实 key 说明,确认 `weather-tool` 等 gate 断言在真实 DeepSeek 调用下能稳定通过——这是第 6 节能落地的前提,和 e2e 骨架搭建可以并行。
|
|
318
|
+
2. **P1(先立骨架)**:建 `e2e/shared` + `e2e/projects/{aisdk-transformer,http-mapping}` + verify.mjs(共享套件、正反 eval、deliberate-fail/error、`runs: N` 统计),申请 `DEEPSEEK_API_KEY` secret,加 `e2e-matrix` job 进 PR 门禁。这一步依赖最小、成本最低——完整路径、正反断言、重复运行统计、退出码、缓存行为全部落网,factory/profile 机制也在最便宜的项目上定型。
|
|
319
|
+
3. **P2**:拷 `examples/zh/tier1/ai-sdk-v7` 进 `e2e/apps`,`e2e/projects/ai-sdk-v7` 接入共享套件、复用 P0 已修好的 secrets——第一个真实 SDK 协议栈进 L0 矩阵,加 `e2e-examples` job。
|
|
320
|
+
4. **P3**:langgraph、pi-sdk、claude-sdk、codex-sdk 逐个接入 `e2e/apps` 并纳入矩阵(凭据映射直接照抄 tier1,不需要额外调研)。给 `sync-tiers.mjs` 加 tier1 → e2e/apps 的 drift check。
|
|
321
|
+
5. **P4**:补 `sandbox-smoke` factory;开 L1 nightly 的 claude-code × docker(第一条真实沙箱链路),随后铺满 L1 矩阵(codex / bub / e2b / vercel + sdk-projects 完整模型矩阵 + judge)。
|
|
322
|
+
6. **之后**:`e2e/projects/{codex-sdk,langgraph}` 的 OTel 瀑布图链路(`spanMapper` / 固定端口)补一条"trace.json 非空"的冒烟断言,纳入 L1;评估 L0 的 retry/容忍区间参数(3.1 提到的 `runs: 3`、95% 阈值)是否需要按项目调整——不同 SDK 的真实抖动率可能不一样。
|
|
287
323
|
|
|
288
324
|
## 9. 明确不做的
|
|
289
325
|
|
|
290
326
|
- 不做字面共享:不把同一份 `.eval.ts` 软链/复制进多个项目——discover 不跟随 symlink 条目,且各 SDK 的工具名和能力差异是协议现实,抹平它们等于测一个不存在的协议。共享的单位是 factory,差异收敛进 profile。
|
|
291
|
-
- 不和 tier1 共享 eval:`examples/zh/tier1` 的 eval 继续服务文档叙事,e2e 的套件独立编写、独立演进,两边不建同步关系;从 tier1 拷的只有应用和 adapter(受 drift check 约束)
|
|
327
|
+
- 不和 tier1 共享 eval:`examples/zh/tier1` 的 eval 继续服务文档叙事,e2e 的套件独立编写、独立演进,两边不建同步关系;从 tier1 拷的只有应用和 adapter(受 drift check 约束)。
|
|
328
|
+
- 不用任何形式的假 AI/本地 mock server:e2e 全程调真实模型,费用通过便宜模型档位、小 `runs`、per-experiment `budget` 护栏控制,不通过伪造响应控制;PR 门禁和 nightly 的区别是"模型档位、样本量、跑多勤",不是"真的假的"。
|
|
292
329
|
- 不测"OTel 派生事件"这类断言——OTel 只喂瀑布图,e2e 要测的是它产出了 `trace.json`,不是它替代了事件映射。
|
|
293
|
-
- 不在 PR 门禁里跑任何真模型——随机性 + 费用 + secret 暴露面都不适合。
|
|
294
330
|
- 不用 `--tag` 做 CI 内的用例切分(当前实现只收单值,与文档不一致;分层用 experiment 的 `evals` 过滤器表达,更明确)。
|
|
295
|
-
-
|
|
331
|
+
- 不支持 fork 来的 PR 跑需要 secrets 的 e2e job(GitHub 默认不把 secrets 传给 fork PR;需要更严格的隔离方案的话另开提案)。
|
|
332
|
+
- 版本字段只做格式契约校验,不把具体 `producer.version` 写死到测试里;版本不兼容的读取行为由 view loader 自己测。
|
package/docs/eval-authoring.md
CHANGED
|
@@ -51,7 +51,7 @@ export default rows.map((row) =>
|
|
|
51
51
|
|
|
52
52
|
- `t.*` 保留"聚合整个 eval run"的语义——这次 eval 执行的全部轮次、含 `t.newSession()` 开的额外 session,直接对应 eve 的 `timing: "final"` 层。这一层聚合是有意为之,不是要移除的"黑箱"。
|
|
53
53
|
- `session.*`(`t.newSession()` 的返回值)复用 `t.*` 的同一套**作用域断言词汇**,但只看这个 session 在断言记录时已有的事件。
|
|
54
|
-
- `turn.*`(`t.send()` 的返回值)也复用同一套**作用域断言词汇**,但只看这一轮自己的事件和用量,不再是旧版文档里的 4 个手写方法。`turn.
|
|
54
|
+
- `turn.*`(`t.send()` 的返回值)也复用同一套**作用域断言词汇**,但只看这一轮自己的事件和用量,不再是旧版文档里的 4 个手写方法。`turn.outputEquals` / `turn.outputMatches` 是 turn 独有的(只对单轮结果有意义,聚合层不需要),继续保留。
|
|
55
55
|
|
|
56
56
|
也就是:**接收者决定作用域,不是断言名字决定作用域。** author-facing 接收者是 `t` / `session` / `turn`;`Attempt` 只作为 runner/result 里的执行单位存在,不是写 eval 时要操作的一层。完整清单见 [Assertions · 作用域规则](assertions.md#作用域规则)。
|
|
57
57
|
|
|
@@ -96,7 +96,7 @@ export default defineEval({
|
|
|
96
96
|
});
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
`t.reply` 是最后一条 assistant 消息;`t.sessionId` 是当前主会话 id;`t.events` 是主 session 目前捕获到的强类型事件流。`t.send(input)` 接受字符串或结构化消息,返回一个不可变的 **Turn**,带 `message` / `data`(结构化输出)/ `toolCalls` / `status` / `events
|
|
99
|
+
`t.reply` 是最后一条 assistant 消息;`t.sessionId` 是当前主会话 id;`t.events` 是主 session 目前捕获到的强类型事件流。`t.send(input)` 接受字符串或结构化消息,返回一个不可变的 **Turn**,带 `message` / `data`(结构化输出)/ `toolCalls` / `status` / `events`。带本地文件的一轮用 `t.sendFile(path, text?)`,文件会作为 data URL 附加到这一轮输入里,MIME 类型按 `path` 扩展名推断。
|
|
100
100
|
|
|
101
101
|
## 多轮
|
|
102
102
|
|
|
@@ -111,7 +111,7 @@ export default defineEval({
|
|
|
111
111
|
description: "先拟稿,确认后再发送",
|
|
112
112
|
async test(t) {
|
|
113
113
|
const draft = await t.send("帮我拟一封跟进邮件。");
|
|
114
|
-
draft.
|
|
114
|
+
draft.succeeded(); // 只看这一轮:失败就记一条断言
|
|
115
115
|
t.check(draft.message, includes("此致"));
|
|
116
116
|
draft.judge.autoevals.closedQA("语气是否专业").atLeast(0.6);
|
|
117
117
|
|