> ## Documentation Index
> Fetch the complete documentation index at: https://niceeval.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 实验与生命周期

> 如何写好实验。选择 Agent、传递 model 和 flags，设置 runs、预算、并发与 Sandbox，并用 setup / teardown 起停实验级共享服务。

Experiment 是可签入的运行配置：同一批评估用例对着哪个 adapter、哪个模型、开哪些 flags、跑几次、预算多少，都写在 `experiments/` 里。CLI 的位置参数只负责筛评估用例，不负责临时改 agent 或运行配置。

## 最小实验

```ts theme={null}
// experiments/local.ts
import { defineExperiment } from "niceeval";
import { webAgent } from "../agents/web-agent.ts";

export default defineExperiment({
  agent: webAgent({ baseUrl: "http://127.0.0.1:5188" }),
});
```

`agent` 放的是已经配置好的 agent 实例。被测系统的 URL、鉴权、协议细节通常传给 adapter 工厂；运行器不会单独保存一个 `agentConfig` 字段。

## 评估不同的 System Prompt 对于 Agent 的影响

使用 flag 机制，配置不同的 Flag，设置两个实验，对比不同 Prompt 的区别。

```ts theme={null}
// experiments/concise.ts
import { defineExperiment } from "niceeval";
import { webAgent } from "../agents/web-agent.ts";

export default defineExperiment({
  description: "测试 V1 System Prompt",
  agent: webAgent({
    baseUrl: "https://staging.example.com",
  }),
  model: "gpt-5.4",
  flags: {
    promptVariant: "v1",
  },
  runs: 1,
  earlyExit: true,
  budget: 5,
});
```

```ts theme={null}
// experiments/concise.ts
import { defineExperiment } from "niceeval";
import { webAgent } from "../agents/web-agent.ts";

export default defineExperiment({
  description: "测试 V1 System Prompt",
  agent: webAgent({
    baseUrl: "https://staging.example.com",
  }),
  model: "gpt-5.4",
  flags: {
    promptVariant: "v1",
  },
  runs: 1,
  earlyExit: true,
  budget: 5,
});
```

`model` 会作为 `ctx.model` 传给 Adapter；如果你的 Agent 支持模型选择，自己构建请求

`flags` 会作为 `ctx.flags` 传给 Adapter，也会作为 `t.flags` 出现在评估用例里。

语义就是产品 A/B 测试里的 feature flag，应该编写 Adapter 把 Flag 发给你的 Agent，Agent 根据 Flag 切换不同的 System Prompt 或者行为。

```ts theme={null}
// agents/web-agent.ts
async send(input, ctx) {
  await fetch(`${baseUrl}/api/turn`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      message: input.text,
      model: ctx.model,
      flags: ctx.flags,
    }),
    signal: ctx.signal,
  });
}
```

## 编写一组实验

一个实验文件是一格配置。要比较多个模型、agent 或 flag 取值，就写多个文件；目录只负责 id：

```text theme={null}
experiments/
  prompts/baseline.ts
  prompts/concise.ts
  retrieval/with-retrieval.ts
```

```bash theme={null}
npx niceeval exp prompts
```

报告直接比较当前 Scope 中的这些 experiments，不需要额外分组字段。

只想验证某一个配置时，把位置参数写成该配置的完整 id：

```bash theme={null}
npx niceeval exp prompts/concise
```

用于逐配置排查——确认某一格改动是否达标，不用先跑完整组，也不用把其它配置文件挪出目录。

## 常用字段

| 字段                | 用途                                                                                                               |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| `agent`           | 选择并配置 adapter；必填                                                                                                 |
| `model`           | 单个模型名，经 `ctx.model` 透传                                                                                           |
| `reasoningEffort` | 单个推理努力程度（如 `"high"`），经 `ctx.reasoningEffort` / `t.reasoningEffort` 透传，归属与 `model` 一致                             |
| `flags`           | 实验条件（A/B 里的 feature flag），任意 JSON 对象，经 `ctx.flags` / `t.flags` 透传                                                |
| `runs`            | 每个评估用例 × 配置最多跑几次                                                                                                 |
| `earlyExit`       | 多次运行里通过一次就提前停止                                                                                                   |
| `evals`           | `"*"`、id 前缀数组，或遍历只读 eval 描述并返回 boolean 的函数                                                                       |
| `timeoutMs`       | 单个 attempt 的超时                                                                                                   |
| `budget`          | 这一格配置的预算上限                                                                                                       |
| `maxConcurrency`  | 这一格配置的并发上限                                                                                                       |
| `sandbox`         | sandbox agent 使用的固定 spec；spec 可带 `environments` 表按评估用例的环境 profile 换预制产物，也可以链 `.setup()` / `.teardown()` 挂环境 Hook |
| `setup`           | 实验级 Hook：整个实验只跑一次、在你自己的机器上执行，启动全部 Attempt 共享的服务（见下）                                                              |
| `teardown`        | 与 `setup` 成对的实验级 Hook：全部 Attempt 收尾后执行一次，当且仅当 `setup` 的时点已经走到（见下）                                                |

## 启动 Experiment 共享服务

有些资源是「一个实验一份、所有 Attempt 共用」的：一条到内网记忆服务的隧道、一个实验专用的 mock server、一个 license 租约。这类资源写进一对实验级 Hook `setup` / `teardown`：整场至多各跑一次。`setup` 在这个实验第一个要派发的 Attempt 前执行；`teardown` 在全部 Attempt 收尾后执行（运行被中断也执行），当且仅当 `setup` 的时点已经走到才触发——`setup` 抛错同样要走到 `teardown`，收尾代码要对可能未赋值的变量做防御。上一次的结果全部被复用、这个实验一个 Attempt 都不需要真正运行时，`setup` 和 `teardown` 都不会执行：

```ts theme={null}
// setup 拿到的地址、密钥放模块变量：teardown 和同文件里的 agent（每个 Attempt 都会执行、晚于 setup）直接读它。
let tunnel: { url: string; apiKey: string; stop(): Promise<void> };

export default defineExperiment({
  agent: nowledgeAgent(() => ({ url: tunnel.url, apiKey: tunnel.apiKey })),
  evals: ["memory/"],
  async setup(ctx) {
    ctx.progress({ message: "starting nowledge tunnel" });
    tunnel = await nowledgeTunnel({ signal: ctx.signal });
  },
  async teardown(ctx) {
    ctx.progress({ message: "stopping nowledge tunnel" });
    await tunnel?.stop(); // setup 抛错也会走到这里：对可能未赋值的变量做防御
  },
});
```

`setup` 在跑的时候，终端的 `ACTIVE` 区会显示一行 `experiment setup · <实验 id>`，`ctx.progress(...)` 的消息就更新在这一行末尾；等它的 Attempt 计入排队数，这不是卡住。在 CI 或 agent 输出里，`setup` / `teardown` 的开始和结束各追加一行。

`setup` 抛错时，这个实验的每条 Attempt 都记为 `errored`（错误码 `experiment-setup-failed`）、逐条进报告；同一批的其它实验照常跑——环境起不来不该伪装成绿，也不该连坐别人。

`teardown` 里资源释放是必达底线：用 `try/finally` 包住，不管前面的观测代码是否出错都要执行。观测类动作（health probe、指标上报）只是 best-effort——给它自己的短超时、失败不要拦住释放，并在 `ctx.signal.aborted` 时直接跳过；中断路径上，一次可能挂起的观测不该挡在「拆隧道、退租约」前面：

```ts theme={null}
async teardown(ctx) {
  try {
    if (!ctx.signal.aborted) {
      await tunnel?.probe({ timeoutMs: 10_000 }).catch(() => {});
    }
  } finally {
    await tunnel?.stop(); // 释放是必达底线，无论观测成败都要执行
  }
},
```

`setup` / `teardown` 只管「在你机器上、一个实验一份」的服务。要在跑 agent 前按实验在**Sandbox 里**准备环境（装二进制、预热、跨 attempt 载入和回存状态），挂在 `sandbox` 字段的 spec 上：

```ts theme={null}
export default defineExperiment({
  agent: codexAgent({ mcpServers: [mempalMcp] }),
  sandbox: e2bSandbox({ template: "fasteval-agents" })
    .setup(mempalSetup("codex"))        // 预检、写动态配置、预热、载入状态
    .teardown(mempalTeardown("codex")), // 回存状态
  maxConcurrency: 1,                    // 载入和回存之间不能并发，声明串行
});
```

固定的 Agent CLI、系统包和大模型缓存应先做进 image/template/snapshot；`.setup()` 不应在每个 Attempt 重建同一套环境。从官方 Docker 镜像、E2B 模板或 Vercel runtime 派生预制环境的步骤见 [Sandbox Provider · 从官方基线继续构建以提速](/docs/zh/tutorials/sandbox-providers#从官方基线继续构建以提速)。

Hook 的执行时机、多 Hook 顺序和失败语义见 [Sandbox provider · 生命周期](/docs/zh/tutorials/sandbox-providers#生命周期)。

### 与 Sandbox Hook 协作

实验级 Hook 起宿主机侧服务，Sandbox Hook 每个 Sandbox 把坐标写进去、收尾时回存状态——两层在同一个文件里靠模块变量衔接，时序由 runner 保证：实验级 `setup` 早于本实验任何 Sandbox Hook，Sandbox Hook 读到的变量一定已经赋好值：

```ts theme={null}
// experiments/compare/claude--nowledge.ts
import { defineExperiment } from "niceeval";
import { e2bSandbox } from "niceeval/sandbox";
import { nowledgeAgent, nowledgeTunnel } from "../../agents/nowledge.ts";
import { loadMemoryState, saveMemoryState } from "../shared/memory-state.ts";

let tunnel: { url: string; apiKey: string; stop(): Promise<void> };

export default defineExperiment({
  agent: nowledgeAgent(() => ({ url: tunnel.url, apiKey: tunnel.apiKey })),
  evals: ["memory/"],
  maxConcurrency: 1, // [载入…回存] 是临界区，声明式串行
  sandbox: e2bSandbox({ template: "niceeval-agents" })
    .setup(async (sandbox, ctx) => {
      // 每沙箱一次，晚于实验级 setup：把宿主机侧坐标写进沙箱
      await sandbox.writeFiles({
        ".nowledge/config.json": JSON.stringify({ url: tunnel.url, apiKey: tunnel.apiKey }),
      });
      await loadMemoryState(sandbox, ctx.experimentId);
    })
    .teardown(async (sandbox, ctx) => {
      await saveMemoryState(sandbox, ctx.experimentId); // 每沙箱回存跨 attempt 状态
    }),
  async setup(ctx) {
    tunnel = await nowledgeTunnel({ signal: ctx.signal }); // 整场一次，宿主机侧
  },
  async teardown() {
    await tunnel?.stop(); // 全部 Attempt 收尾后拆
  },
});
```

一份实验文件从上往下读就是完整的运行说明：整场一次的宿主机资源在实验级 Hook 对里；每 Sandbox 的写入与回存在 `sandbox` 链式 Hook 里，读实验级产物；agent 怎么连自己、评估用例的任务夹具各在 agent 定义与 `EvalDef` 里，不进实验文件。

### 多个实验共享同一套生命周期代码

对比组里常常是几个实验对着同一类基础设施——同一个记忆产品，claude 与 codex 各一格对照，起停机制完全一样。把起停写成一个**工厂函数**，返回共享同一闭包的整套件：实验级 Hook 对、给 agent / MCP 工厂读坐标的 getter、把坐标写进 Sandbox 的 sandbox Hook。每个实验文件各自调用一次工厂，同一套代码、各自的实例与坐标：

```ts theme={null}
// experiments/shared/nowledge.ts —— 启停一份代码；实例、坐标每实验一份
import type { ExperimentHookContext } from "niceeval";
import type { SandboxHook } from "niceeval/sandbox";

export function nowledgeLifecycle() {
  let instance: string | undefined;
  let env: { url: string; apiKey: string } | undefined;

  return {
    /** agent / MCP 工厂经它读连接信息：闭包值，setup 之后才存在 */
    endpoint: () => env!,

    async setup(ctx: ExperimentHookContext) {
      instance = `exp-${ctx.experimentId.replace(/[^A-Za-z0-9]+/g, "-")}`;
      ctx.progress({ message: `[nowledge] activating ${instance}` });
      await memctl("up", instance); // 容器 + 隧道，全新记忆库
      env = await readInstanceEnv(instance);
    },

    async teardown(ctx: ExperimentHookContext) {
      if (!instance) return; // setup 没走到起实例就抛了：无事可扫
      await memctl("down", instance); // 释放是必达底线
    },

    /** 每沙箱一次：把闭包坐标写进沙箱 */
    sandboxSetup(): SandboxHook {
      return async (sandbox) => {
        await sandbox.writeFiles({
          ".nowledge/env": `NMEM_URL=${env!.url}\nNMEM_API_KEY=${env!.apiKey}\n`,
        });
      };
    },
  };
}
```

实验文件里换 agent 只换 agent 那几行，生命周期四行接完：

```ts theme={null}
// experiments/compare/codex-gpt-5.4--nowledge.ts
const nowledge = nowledgeLifecycle();
export default defineExperiment({
  agent: codexAgent(nowledgeCodexConfig(nowledge.endpoint)),
  sandbox: e2bSandbox({ template: CODEX_TEMPLATE }).setup(nowledge.sandboxSetup()),
  setup: nowledge.setup,
  teardown: nowledge.teardown,
  maxConcurrency: 1, // 中心化记忆库，attempt 串行累积
});
```

两条纪律保证多个实验并发跑同一套代码不互相踩踏：

* **工厂在 import 期只创建闭包，不做 I/O、不读配置**——实验文件在 `niceeval exp` 的发现阶段就会被 import，import 抛错会连累同批无关实验；所有硬失败留给 `setup`。
* **运行时坐标活在工厂闭包里，不放模块级单例**——同批并行的两个实验各持一份，互不覆写；坐标在 `setup` 之后才存在。

服务起多份太贵、必须让同批实验共享一份实例时，用「首进启动、末出关停」的引用计数代替按实验各建一份：

```ts theme={null}
// experiments/shared/nowledge-shared.ts
let refs = 0;
let starting: Promise<void> | undefined;
let service: { url: string; stop(): Promise<void> } | undefined;

export const sharedNowledge = {
  async setup() {
    refs += 1;
    starting ??= startNowledge().then((s) => { service = s; });
    await starting; // 并发实验等同一个启动；启动失败各自抛错
  },
  async teardown() {
    refs -= 1;
    if (refs === 0) {
      await service?.stop(); // 启动失败时 service 未赋值，防御式跳过
      service = undefined;
      starting = undefined;
    }
  },
};
```

计数能保持平衡，靠的是成对触发规则本身：`teardown` 当且仅当同层 `setup` 时点走到过才执行、`setup` 抛错也照样配对触发 `teardown`，`refs` 不会泄漏。

边界在生命周期：同批共享的服务活不过这次 run。要**跨 run** 存在的服务（先起好、连续跑多次 `niceeval exp`）仍归外部编排（`docker compose` 之类）起停，URL 经环境变量传入。

## 让不同评估用例使用不同预制环境

一批真实任务可能需要不同版本的运行时和依赖。评估用例用 `environment` 声明一个与 provider 无关的 profile ID；sandbox spec 的 `environments` 表再把它映射到具体模板或快照：

```ts theme={null}
// evals/astropy-2021.eval.ts
export default defineEval({
  environment: "python-3.9-astropy-4.2",
  async test(t) {
    // 驱动任务并验证结果
  },
});
```

```ts theme={null}
// experiments/shared.ts —— 一个 provider 一张表，所有实验共用
import { e2bSandbox } from "niceeval/sandbox";

export const e2b = e2bSandbox({
  template: "codex-default",              // 未声明 environment 的 eval 用它
  environments: {
    "python-3.9-astropy-4.2": { template: "codex-python39" },
  },
});
```

```ts theme={null}
// experiments/e2b.ts —— 实验保持一行 diff，覆盖全部 eval
import { defineExperiment } from "niceeval";
import { e2b } from "./shared";

export default defineExperiment({
  agent: codexAgent(),
  sandbox: e2b,
});
```

`environment` 是非空的稳定字符串，不是包版本约束。`environments` 表的值就是该 provider 预制产物字段的覆盖（Docker 的 `image`、E2B 的 `template`、Vercel 的 `snapshotId`）。NiceEval 在启动任何 Sandbox 前对所有选中的评估用例完成查表；某条评估用例声明的 profile 缺表项会在启动时一次性报出全部缺项。remote Agent 不创建 Sandbox，不参与查表。因为映射是随 spec 复用的数据，同一个实验能覆盖全部评估用例——分数和对比表不会因为环境不同被拆成多个实验。

跨配置比较的设计建议见[实验矩阵](/docs/zh/tutorials/experiments)。Adapter 对 `ctx.model` 和 `ctx.flags` 的用法见 [Adapter](/docs/zh/explanation/adapter)。
