niceeval 0.4.4 → 0.4.5
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.md +21 -12
- package/README.zh.md +8 -9
- package/docs-site/.mintignore +13 -0
- package/docs-site/AGENTS.md +46 -0
- package/docs-site/concepts/adapter.mdx +287 -0
- package/docs-site/concepts/assert.mdx +243 -0
- package/docs-site/concepts/drive.mdx +118 -0
- package/docs-site/concepts/evals.mdx +108 -0
- package/docs-site/concepts/experiment.mdx +37 -0
- package/docs-site/concepts/hitl.mdx +83 -0
- package/docs-site/concepts/judge.mdx +129 -0
- package/docs-site/concepts/overview.mdx +98 -0
- package/docs-site/concepts/tier.mdx +42 -0
- package/docs-site/docs-ref/00-index.md +23 -0
- package/docs-site/docs-ref/01-page-types.md +43 -0
- package/docs-site/docs-ref/03-style-rules.md +45 -0
- package/docs-site/docs-ref/04-code-examples.md +37 -0
- package/docs-site/docs-ref/05-dx-failure-paths.md +45 -0
- package/docs-site/docs-ref/06-checklists.md +53 -0
- package/docs-site/docs.json +256 -0
- package/docs-site/example/ai-agent-application.mdx +148 -0
- package/docs-site/example/claude-code-codex-plugin.mdx +162 -0
- package/docs-site/example/claude-code-codex-skill.mdx +147 -0
- package/docs-site/example/showcase.mdx +39 -0
- package/docs-site/example/tier1-ai-sdk-v7.mdx +136 -0
- package/docs-site/example/tier1-claude-sdk.mdx +119 -0
- package/docs-site/example/tier1-codex-sdk.mdx +111 -0
- package/docs-site/example/tier1-langgraph.mdx +119 -0
- package/docs-site/example/tier1-pi-sdk.mdx +119 -0
- package/docs-site/favicon.svg +5 -0
- package/docs-site/github-diff.css +135 -0
- package/docs-site/github-diff.js +11 -0
- package/docs-site/guides/authoring.mdx +129 -0
- package/docs-site/guides/ci-integration.mdx +97 -0
- package/docs-site/guides/connect-otel.mdx +209 -0
- package/docs-site/guides/connect-your-agent.mdx +221 -0
- package/docs-site/guides/dataset-fanout.mdx +98 -0
- package/docs-site/guides/experiments.mdx +71 -0
- package/docs-site/guides/fixtures.mdx +147 -0
- package/docs-site/guides/reporters.mdx +113 -0
- package/docs-site/guides/runner.mdx +79 -0
- package/docs-site/guides/sandbox-agent.mdx +138 -0
- package/docs-site/guides/sandbox-backends.mdx +64 -0
- package/docs-site/guides/scoring-guide.mdx +92 -0
- package/docs-site/guides/viewing-results.mdx +195 -0
- package/docs-site/guides/write-experiment.mdx +81 -0
- package/docs-site/guides/write-send.mdx +347 -0
- package/docs-site/images/adapter-tiers-zh.svg +60 -0
- package/docs-site/images/agent-turn-roundtrip-en.svg +62 -0
- package/docs-site/images/agent-turn-roundtrip-zh.svg +77 -0
- package/docs-site/images/hitl-handshake-zh.svg +89 -0
- package/docs-site/images/logo.svg +6 -0
- package/docs-site/index.mdx +181 -0
- package/docs-site/introduction.mdx +141 -0
- package/docs-site/quickstart.mdx +136 -0
- package/docs-site/reference/builtin-agents.mdx +161 -0
- package/docs-site/reference/capabilities.mdx +76 -0
- package/docs-site/reference/cli.mdx +120 -0
- package/docs-site/reference/define-agent.mdx +168 -0
- package/docs-site/reference/define-config.mdx +41 -0
- package/docs-site/reference/define-eval.mdx +160 -0
- package/docs-site/reference/events.mdx +131 -0
- package/docs-site/reference/expect.mdx +112 -0
- package/docs-site/tracker.js +8 -0
- package/docs-site/zh/concepts/adapter.mdx +286 -0
- package/docs-site/zh/concepts/assert.mdx +243 -0
- package/docs-site/zh/concepts/drive.mdx +118 -0
- package/docs-site/zh/concepts/evals.mdx +124 -0
- package/docs-site/zh/concepts/experiment.mdx +37 -0
- package/docs-site/zh/concepts/hitl.mdx +83 -0
- package/docs-site/zh/concepts/judge.mdx +130 -0
- package/docs-site/zh/concepts/overview.mdx +109 -0
- package/docs-site/zh/concepts/tier.mdx +44 -0
- package/docs-site/zh/example/ai-agent-application.mdx +152 -0
- package/docs-site/zh/example/claude-code-codex-plugin.mdx +163 -0
- package/docs-site/zh/example/claude-code-codex-skill.mdx +147 -0
- package/docs-site/zh/example/showcase.mdx +39 -0
- package/docs-site/zh/example/tier1-ai-sdk-v7.mdx +140 -0
- package/docs-site/zh/example/tier1-claude-sdk.mdx +122 -0
- package/docs-site/zh/example/tier1-codex-sdk.mdx +117 -0
- package/docs-site/zh/example/tier1-langgraph.mdx +123 -0
- package/docs-site/zh/example/tier1-pi-sdk.mdx +122 -0
- package/docs-site/zh/guides/agent-feedback-loop.mdx +79 -0
- package/docs-site/zh/guides/authoring.mdx +129 -0
- package/docs-site/zh/guides/ci-integration.mdx +97 -0
- package/docs-site/zh/guides/connect-otel.mdx +208 -0
- package/docs-site/zh/guides/connect-your-agent.mdx +226 -0
- package/docs-site/zh/guides/dataset-fanout.mdx +83 -0
- package/docs-site/zh/guides/experiments.mdx +72 -0
- package/docs-site/zh/guides/fixtures.mdx +147 -0
- package/docs-site/zh/guides/official-adapters.mdx +132 -0
- package/docs-site/zh/guides/reporters.mdx +113 -0
- package/docs-site/zh/guides/runner.mdx +82 -0
- package/docs-site/zh/guides/sandbox-agent.mdx +139 -0
- package/docs-site/zh/guides/sandbox-backends.mdx +75 -0
- package/docs-site/zh/guides/scoring-guide.mdx +92 -0
- package/docs-site/zh/guides/viewing-results.mdx +195 -0
- package/docs-site/zh/guides/write-experiment.mdx +101 -0
- package/docs-site/zh/guides/write-send.mdx +353 -0
- package/docs-site/zh/index.mdx +180 -0
- package/docs-site/zh/introduction.mdx +140 -0
- package/docs-site/zh/quickstart.mdx +137 -0
- package/docs-site/zh/reference/builtin-agents.mdx +364 -0
- package/docs-site/zh/reference/capabilities.mdx +76 -0
- package/docs-site/zh/reference/cli.mdx +140 -0
- package/docs-site/zh/reference/define-agent.mdx +461 -0
- package/docs-site/zh/reference/define-config.mdx +107 -0
- package/docs-site/zh/reference/define-eval.mdx +674 -0
- package/docs-site/zh/reference/events.mdx +252 -0
- package/docs-site/zh/reference/expect.mdx +208 -0
- package/package.json +3 -2
- package/src/agents/bub.ts +1 -1
- package/src/agents/claude-code.ts +8 -5
- package/src/agents/codex.ts +9 -5
- package/src/agents/sdk-streams.test.ts +4 -4
- package/src/agents/sdk-streams.ts +14 -9
- package/src/agents/types.ts +40 -1
- package/src/agents/ui-message-stream.ts +1 -0
- package/src/cli.ts +36 -7
- package/src/context/types.ts +140 -0
- package/src/expect/index.ts +15 -8
- package/src/i18n/en.ts +19 -6
- package/src/i18n/zh-CN.ts +19 -6
- package/src/o11y/parsers/bub.test.ts +71 -0
- package/src/o11y/parsers/bub.ts +5 -0
- package/src/o11y/types.ts +27 -2
- package/src/runner/reporters/artifacts.ts +11 -3
- package/src/runner/reporters/live.ts +45 -8
- package/src/runner/run.ts +71 -21
- package/src/runner/types.ts +45 -2
- package/src/sandbox/types.ts +18 -0
- package/src/scoring/types.ts +5 -0
- package/src/shared/types.ts +3 -0
- package/src/util.test.ts +26 -1
- package/src/util.ts +16 -0
- package/src/view/app/App.tsx +2 -2
- package/src/view/app/components/AttemptModal.tsx +2 -0
- package/src/view/app/components/CopyControls.tsx +87 -28
- package/src/view/app/i18n.ts +3 -3
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/docs/README.md +0 -120
- package/docs/adapters/README.md +0 -60
- package/docs/adapters/authoring.md +0 -179
- package/docs/adapters/coding-agent-skills-plugins.md +0 -324
- package/docs/adapters/collection.md +0 -128
- package/docs/adapters/contract.md +0 -264
- package/docs/adapters/reference/agent-eval.md +0 -215
- package/docs/adapters/reference/agent-loop-apis.md +0 -69
- package/docs/adapters/reference/claude-code-otel-telemetry.md +0 -100
- package/docs/adapters/reference/eve-protocol.md +0 -127
- package/docs/adapters/reference/otel-genai.md +0 -107
- package/docs/adapters/reference/otel-instrumentation.md +0 -65
- package/docs/adapters/targets.md +0 -99
- package/docs/architecture.md +0 -140
- package/docs/assertions.md +0 -387
- package/docs/capabilities-by-construction.md +0 -48
- package/docs/cli.md +0 -173
- package/docs/concepts.md +0 -106
- package/docs/e2e-ci.md +0 -332
- package/docs/eval-authoring.md +0 -319
- package/docs/experiments.md +0 -157
- package/docs/getting-started.md +0 -224
- package/docs/multi-agent.md +0 -145
- package/docs/observability.md +0 -337
- package/docs/origin-integration.md +0 -195
- package/docs/references.md +0 -35
- package/docs/reports.md +0 -551
- package/docs/results-format.md +0 -228
- package/docs/results-lib.md +0 -191
- package/docs/runner.md +0 -104
- package/docs/sandbox.md +0 -276
- package/docs/scoring.md +0 -119
- package/docs/source-map.md +0 -126
- package/docs/tier-sync.md +0 -193
- package/docs/view.md +0 -194
- package/docs/vision.md +0 -88
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "查看 NiceEval 结果并调试 agent 行为"
|
|
3
|
+
sidebarTitle: "查看结果"
|
|
4
|
+
description: "NiceEval 每次运行后会在 .niceeval/ 写入结构化 artifacts。用 npx niceeval view 查看 transcript、diff、event stream 和 pass rate。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
每次运行后,[NiceEval](https://niceeval.com/) 都会把结构化结果写入 `.niceeval/<timestamp>/`。控制台提供即时反馈,结果查看器用于深入分析失败原因。
|
|
8
|
+
|
|
9
|
+
## 控制台输出
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
Discovered 3 evals
|
|
13
|
+
|
|
14
|
+
✓ classify (12ms)
|
|
15
|
+
✓ weather/brooklyn (456ms)
|
|
16
|
+
✗ api-validation (38s)
|
|
17
|
+
- gate: expected src/routes/users.ts to include .safeParse() [FAILED]
|
|
18
|
+
|
|
19
|
+
Results: 2 passed, 1 failed, 0 errored, 0 skipped
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
每行包含 eval ID、outcome 和耗时。失败项会显示断言类型和错误信息。
|
|
23
|
+
|
|
24
|
+
## `.niceeval/<timestamp>/`
|
|
25
|
+
|
|
26
|
+
典型结构:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
.niceeval/
|
|
30
|
+
└─ 2025-01-15T14-23-00/
|
|
31
|
+
├─ summary.json
|
|
32
|
+
└─ artifacts/
|
|
33
|
+
└─ compare__web-agent__gpt-5.4__weather-tool__0/
|
|
34
|
+
├─ events.json
|
|
35
|
+
├─ sources.json
|
|
36
|
+
├─ trace.json
|
|
37
|
+
├─ o11y.json
|
|
38
|
+
└─ diff.json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## `niceeval view`
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx niceeval view
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
这会打开本地结果查看器,默认展示最近一次运行的结果。你可以浏览 eval、查看 transcript、读 diff、检查 event stream 和断言结果。数据不会上传到外部服务。
|
|
48
|
+
|
|
49
|
+
<Tip>
|
|
50
|
+
失败后立刻运行 `npx niceeval view`,可以直接打开刚刚那次运行的 artifacts。
|
|
51
|
+
</Tip>
|
|
52
|
+
|
|
53
|
+
## 导出与静态托管
|
|
54
|
+
|
|
55
|
+
发布结果只有一种导出:`--out <目录>` 整站导出,完整带上能查看的一切。想要自己的页面形态,再用报告积木自建。
|
|
56
|
+
|
|
57
|
+
### 内置查看器整站:静态托管
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx niceeval view --out site
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
NiceEval 写入 `<dir>/index.html`,并把查看器要读取的工件(`sources.json`、`events.json`、`trace.json`)复制到 `<dir>/artifact/` 下。把整个目录交给任何静态托管(Vercel、GitHub Pages 或 `python3 -m http.server`),代码视图、transcript 和 trace 瀑布图都和本地 `niceeval view` 一致。
|
|
64
|
+
|
|
65
|
+
`--out` 只接受目录。没有单文件导出:代码视图、transcript 和 trace 依赖工件文件,单个 HTML 装不下完整证据。要把结果发给别人,托管整站发链接即可。
|
|
66
|
+
|
|
67
|
+
两点限制:
|
|
68
|
+
|
|
69
|
+
- `diff.json` 和 `o11y.json` 不会被复制。查看器不读取它们,且 diff 可能达到上百 MB。
|
|
70
|
+
- 用 `file://` 直接打开 `index.html` 时浏览器不允许 fetch 工件,代码视图会提示源码不可用。本地预览用 http 服务打开。
|
|
71
|
+
|
|
72
|
+
最简单的流程是在跑过 eval 的机器上导出,把产物目录直接部署,不需要额外步骤。`.niceeval/` 通常不进版本库,如果构建发生在别的机器(CI、托管平台),先用 `copyRun` 把要发布的快照瘦身复制进仓库,再对着快照目录导出:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// scripts/snapshot.ts —— 跑完 eval 后执行,产物提交进仓库
|
|
76
|
+
import { openResults, latestPerExperiment, copyRun } from "niceeval/results";
|
|
77
|
+
|
|
78
|
+
const results = await openResults(".niceeval");
|
|
79
|
+
const { snapshots } = latestPerExperiment(results.snapshots);
|
|
80
|
+
await copyRun(snapshots, "site-data/run", {
|
|
81
|
+
artifacts: ["sources", "events", "trace"],
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx niceeval view --out site site-data/run # CI 上对着快照构建
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`latestPerExperiment` 按 experiment 挑最新快照,而不是挑最新的 run——某次运行只跑了部分实验时,其它实验仍保留上一轮的结果。
|
|
90
|
+
|
|
91
|
+
### 自建报告页:自己的看法
|
|
92
|
+
|
|
93
|
+
内置查看器的榜单形态是固定的。想要自己的看法——考试成绩单、benchmark 榜、质量 × 成本散点——用报告积木搭进你自己的应用:`niceeval/results` 读结果,`niceeval/report` 算数据,`niceeval/report/react` 渲染组件。
|
|
94
|
+
|
|
95
|
+
计算和渲染之间是一条可序列化边界:计算函数(`table()`、`scatter()`、`overview()` 等)读文件系统,只能跑在 Node(构建脚本、服务端、CI);组件只认算好的 JSON 数据,零 IO,在哪都能渲染。静态发布因此有两种姿势。
|
|
96
|
+
|
|
97
|
+
**姿势一:构建时算好,导出纯静态页。** 页面在能读到结果数据的机器上构建(Next.js RSC 或任何 SSG),产物就是普通静态文件:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// app/evals/page.tsx
|
|
101
|
+
import { openResults, latestPerExperiment } from "niceeval/results";
|
|
102
|
+
import { table, passRate, costUSD } from "niceeval/report";
|
|
103
|
+
import { MetricTable } from "niceeval/report/react";
|
|
104
|
+
import "niceeval/report/react/styles.css";
|
|
105
|
+
|
|
106
|
+
export default async function EvalsPage() {
|
|
107
|
+
const results = await openResults(".niceeval");
|
|
108
|
+
const { snapshots } = latestPerExperiment(results.snapshots);
|
|
109
|
+
const data = await table(snapshots, {
|
|
110
|
+
rows: "agent",
|
|
111
|
+
columns: [passRate, costUSD],
|
|
112
|
+
sort: passRate,
|
|
113
|
+
});
|
|
114
|
+
return <MetricTable data={data} />;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**姿势二:CI 落 JSON,任意前端渲染。** 计算跑在出结果的地方,页面在哪都行(Vite SPA、已有的内部面板):
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// scripts/build-report-data.ts —— CI 里跑完 eval 后执行
|
|
122
|
+
import { writeFile } from "node:fs/promises";
|
|
123
|
+
import { openResults, latestPerExperiment } from "niceeval/results";
|
|
124
|
+
import { table, passRate, costUSD } from "niceeval/report";
|
|
125
|
+
|
|
126
|
+
const results = await openResults(".niceeval");
|
|
127
|
+
const { snapshots } = latestPerExperiment(results.snapshots);
|
|
128
|
+
await writeFile("public/report.json", JSON.stringify({
|
|
129
|
+
board: await table(snapshots, { rows: "agent", columns: [passRate, costUSD] }),
|
|
130
|
+
}));
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// 前端:任何 React 应用
|
|
135
|
+
import { MetricTable } from "niceeval/report/react";
|
|
136
|
+
|
|
137
|
+
const doc = await fetch("/report.json").then((r) => r.json());
|
|
138
|
+
|
|
139
|
+
<MetricTable data={doc.board} />;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
组件不 hydrate 也完整:排序在数据侧预排、下钻是普通链接、展开折叠用 `<details>`,静态导出的产物不需要任何客户端 JS。样式随包发布(`niceeval/report/react/styles.css`),在其后加载自己的 CSS 即可覆盖。
|
|
143
|
+
|
|
144
|
+
自建页负责结论,逐条 attempt 的 transcript 和 trace 不必自己画:把内置查看器一起导出到子路径(`npx niceeval view --out <站点目录>/evidence`),再用组件的 `attemptHref` 生成深链跳过去:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
<MetricTable
|
|
148
|
+
data={data}
|
|
149
|
+
attemptHref={(ref) => `/evidence/#/attempt/${encodeURIComponent(ref.run)}/${ref.result}`}
|
|
150
|
+
/>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Artifact 说明
|
|
154
|
+
|
|
155
|
+
### `summary.json`
|
|
156
|
+
|
|
157
|
+
整次运行的汇总:run ID、pass / fail 数量、耗时、成本和每个 eval 的状态。
|
|
158
|
+
|
|
159
|
+
### `events.json`
|
|
160
|
+
|
|
161
|
+
标准事件流,是工具调用、消息、命令和错误的底层事实来源。
|
|
162
|
+
|
|
163
|
+
### `diff.json`
|
|
164
|
+
|
|
165
|
+
Sandbox eval 中 agent 改动的文件 diff。
|
|
166
|
+
|
|
167
|
+
### `sources.json`
|
|
168
|
+
|
|
169
|
+
eval 源码位置和断言位置,用于查看器把失败断言对应回代码。
|
|
170
|
+
|
|
171
|
+
### `trace.json`
|
|
172
|
+
|
|
173
|
+
OTLP trace 或标准化后的 span 数据。
|
|
174
|
+
|
|
175
|
+
### `o11y.json`
|
|
176
|
+
|
|
177
|
+
工具调用、命令、usage 和成本等观测摘要。
|
|
178
|
+
|
|
179
|
+
## Outcome 含义
|
|
180
|
+
|
|
181
|
+
<Tabs>
|
|
182
|
+
<Tab title="passed">所有 gate 通过。</Tab>
|
|
183
|
+
<Tab title="failed">至少一个 gate 失败,或 `--strict` 下 soft 断言低于阈值。</Tab>
|
|
184
|
+
<Tab title="errored">环境、超时、adapter 或 agent runtime 出错。</Tab>
|
|
185
|
+
<Tab title="skipped">eval 被主动跳过。</Tab>
|
|
186
|
+
</Tabs>
|
|
187
|
+
|
|
188
|
+
Soft 断言的分数记录在 `assertions[].score` 里;非 `--strict` 模式下,它们不会产生额外的 outcome。
|
|
189
|
+
|
|
190
|
+
## 调试建议
|
|
191
|
+
|
|
192
|
+
- 先看 `summary.json` 找到失败断言。
|
|
193
|
+
- 再看 transcript 视图,了解 agent 的决策过程。
|
|
194
|
+
- coding-agent 失败时看 `diff.json` 和命令事件输出。
|
|
195
|
+
- 工具调用问题看 `events.json`。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "写实验:用 defineExperiment 固定运行配置"
|
|
3
|
+
sidebarTitle: "写实验"
|
|
4
|
+
description: "学习如何在 experiments/ 里用 defineExperiment 选择 agent、传 model 和 flags、设置 runs、预算、并发与 sandbox。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Experiment 是可签入的运行配置:同一批 eval 对着哪个 adapter、哪个模型、哪些 feature flags、跑几次、预算多少,都写在 `experiments/` 里。CLI 的位置参数只负责筛 eval,不负责临时改 agent 或运行配置。
|
|
8
|
+
|
|
9
|
+
## 最小实验
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// experiments/local.ts
|
|
13
|
+
import { defineExperiment } from "niceeval";
|
|
14
|
+
import { webAgent } from "../agents/web-agent.ts";
|
|
15
|
+
|
|
16
|
+
export default defineExperiment({
|
|
17
|
+
agent: webAgent({ baseUrl: "http://127.0.0.1:5188" }),
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`agent` 放的是已经配置好的 agent 实例。被测系统的 URL、鉴权、协议细节通常传给 adapter 工厂;运行器不会单独保存一个 `agentConfig` 字段。
|
|
22
|
+
|
|
23
|
+
## 传 model 和 flags
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// experiments/concise.ts
|
|
27
|
+
import { defineExperiment } from "niceeval";
|
|
28
|
+
import { webAgent } from "../agents/web-agent.ts";
|
|
29
|
+
|
|
30
|
+
export default defineExperiment({
|
|
31
|
+
description: "简洁 prompt 变体",
|
|
32
|
+
agent: webAgent({
|
|
33
|
+
baseUrl: "https://staging.example.com",
|
|
34
|
+
apiKey: process.env.STAGING_AGENT_API_KEY,
|
|
35
|
+
}),
|
|
36
|
+
model: "gpt-5.4",
|
|
37
|
+
flags: {
|
|
38
|
+
promptVariant: "concise",
|
|
39
|
+
webResearch: false,
|
|
40
|
+
},
|
|
41
|
+
runs: 3,
|
|
42
|
+
earlyExit: true,
|
|
43
|
+
budget: 5,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`model` 会作为 `ctx.model` 传给 adapter;应用接口支持模型选择时,adapter 把它放进请求体、header 或 CLI 参数即可。
|
|
48
|
+
|
|
49
|
+
`flags` 会作为 `ctx.flags` 传给 adapter,也会作为 `t.flags` 出现在 eval 里。它适合表达 experiment 维度的 feature 开关,例如 prompt 版本、工具集、检索开关。真正按 flag 切换行为的是你的应用或 adapter,[NiceEval](https://niceeval.com/) 只负责原样透传。
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// agents/web-agent.ts
|
|
53
|
+
async send(input, ctx) {
|
|
54
|
+
await fetch(`${baseUrl}/api/turn`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
message: input.text,
|
|
59
|
+
model: ctx.model,
|
|
60
|
+
flags: ctx.flags,
|
|
61
|
+
}),
|
|
62
|
+
signal: ctx.signal,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 一组实验怎么写
|
|
68
|
+
|
|
69
|
+
一个实验文件是一格配置。要比较多个模型、agent 或 flags,就把多个文件放进同一个文件夹:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
experiments/
|
|
73
|
+
prompt-variants/
|
|
74
|
+
baseline.ts
|
|
75
|
+
concise.ts
|
|
76
|
+
with-retrieval.ts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx niceeval exp prompt-variants
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
这样报告会按同一组展示,差异也能在 Git 里 review。
|
|
84
|
+
|
|
85
|
+
## 常用字段
|
|
86
|
+
|
|
87
|
+
| 字段 | 用途 |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `agent` | 选择并配置 adapter;必填 |
|
|
90
|
+
| `model` | 单个模型名,经 `ctx.model` 透传 |
|
|
91
|
+
| `reasoningEffort` | 单个推理努力程度(如 `"high"`),经 `ctx.reasoningEffort` / `t.reasoningEffort` 透传,归属与 `model` 一致 |
|
|
92
|
+
| `flags` | 任意 JSON 对象,经 `ctx.flags` / `t.flags` 透传 |
|
|
93
|
+
| `runs` | 每个 eval × 配置最多跑几次 |
|
|
94
|
+
| `earlyExit` | 多次运行里通过一次就提前停止 |
|
|
95
|
+
| `evals` | 限定这一格配置跑哪些 eval |
|
|
96
|
+
| `timeoutMs` | 单个 attempt 的超时 |
|
|
97
|
+
| `budget` | 这一格配置的预算上限 |
|
|
98
|
+
| `maxConcurrency` | 这一格配置的并发上限 |
|
|
99
|
+
| `sandbox` | sandbox agent 使用的后端,如 `dockerSandbox()` / `e2bSandbox()` |
|
|
100
|
+
|
|
101
|
+
跨配置比较的设计建议见[实验矩阵](/zh/guides/experiments)。adapter 如何消费 `ctx.model` 和 `ctx.flags` 见[Adapter](/zh/concepts/adapter)。
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "如何写好 Send"
|
|
3
|
+
sidebarTitle: "如何写好 Send"
|
|
4
|
+
description: "手写 Adapter 的 send 函数,一条主线七步走:发消息拿回复、接上之前的消息、记录消费、把工具解析成事件、人工介入(HITL)、接上 OTel trace、透传 experiment 的 flags。每一步只在上一步的代码上多几行,每一步解锁一组断言。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
[Adapter](/zh/concepts/adapter) 讲了契约:`send` 传入 `TurnInput` 和 `AgentContext`,返回 `Turn`。这一页讲怎么写它——从"发一条消息拿到回复"的最小形态起步,一步一步加到完整接入。每一步只在上一步的代码上多几行,前面已有的部分在代码里用 `// ……省略` 标出;每一步末尾说明这几行解锁了哪些断言。改完任何一步都能立刻验证:把新解锁的断言写进一条 eval 重跑 `npx niceeval exp`,`niceeval view` 里能看到这一步新增的数据——多轮轨迹、用量、工具事件、待回答请求。
|
|
8
|
+
|
|
9
|
+
两个贯穿全文的原则:
|
|
10
|
+
|
|
11
|
+
- **接的位置永远是用户前端本来就在用的那个接口。** 页面调哪个端点、收什么格式,Adapter 就调哪个端点、收什么格式——不为 eval 开新接口,也不 import 应用内部代码直调函数(为什么,见[接入你的 Agent](/zh/guides/connect-your-agent))。
|
|
12
|
+
- **你唯一真正手写的是 transport**——URL、鉴权、请求体每家本来就不一样。解析(原始返回 → 标准事件流)有官方转换器,从 `niceeval/adapter` 导出;编排(会话续接、HITL 暂停恢复)的状态槽就挂在 `ctx.session` 上,取用即可,不需要额外声明什么。
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
## 起点:你的应用接口是什么形状
|
|
17
|
+
|
|
18
|
+
[NiceEval](https://niceeval.com/) 不发明协议。你的应用大概率建在下面某种业界协议(或其变体)之上,官方件按这几种形状备齐:
|
|
19
|
+
|
|
20
|
+
| 协议 | 谁在定义 | 一句话介绍 | 参考 |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| **Chat Completions** | OpenAI;业界事实标准,几乎所有模型服务商都提供兼容端点 | 无状态一问一答:客户端每轮发完整 `messages` 列表,一个 JSON 回来 | [API 参考](https://platform.openai.com/docs/api-reference/chat) |
|
|
23
|
+
| **Responses / Open Responses** | OpenAI(Responses API);Open Responses 是基于它的开放规范,OpenAI 发起、Hugging Face 等社区共建,用于多提供商互操作 | 有状态、面向 agent:请求带 `previous_response_id` 由服务端续接历史,`output` 数组承诺记录全过程 | [API 参考](https://platform.openai.com/docs/api-reference/responses) · [Open Responses 规范](https://www.openresponses.org/specification) |
|
|
24
|
+
| **AI SDK(Vercel)** | Vercel;TypeScript 生态的事实标准 | 不是线上协议而是 SDK:`generateText` 返回完整结果,`useChat` 走它定义的 UI Message Stream 流协议 | [generateText 参考](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) · [UI Message Stream 协议](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) |
|
|
25
|
+
|
|
26
|
+
下面的主线用一个 Chat Completions 形接口的应用(最常见的形状)从零写到完整;接口是 Responses 形或流式的,替换件在第二、四步各自给出。
|
|
27
|
+
|
|
28
|
+
## 第一步:发一条消息,拿到回复
|
|
29
|
+
|
|
30
|
+
最小的 `send` 只做三件事:把 `input.text` 发给应用的接口,把回复放进一条 `message` 事件,报告本轮 `status`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// agents/chat-app.ts
|
|
34
|
+
import { defineAgent } from "niceeval/adapter";
|
|
35
|
+
|
|
36
|
+
const BASE_URL = "http://localhost:8080"; // 要按 experiment 切换地址,改成工厂参数:见接入指南
|
|
37
|
+
|
|
38
|
+
export default defineAgent({
|
|
39
|
+
name: "chat-app",
|
|
40
|
+
async send(input, ctx) {
|
|
41
|
+
const res = await fetch(`${BASE_URL}/v1/chat/completions`, { // ← 前端本来就在用的接口
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
messages: [{ role: "user", content: input.text }],
|
|
46
|
+
model: ctx.model, // ← experiment.model 经 ctx.model 到这里;应用接口不支持选模型就删掉这行
|
|
47
|
+
}),
|
|
48
|
+
signal: ctx.signal, // ← 运行器的超时与取消
|
|
49
|
+
}).then((r) => r.json());
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status: "completed",
|
|
53
|
+
events: [{ type: "message", role: "assistant", text: res.choices[0].message.content }],
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**这一步解锁**:`t.reply`、`t.messageIncludes()`、judge 的全部对话材料,以及 experiment 侧的**模型对比**(`ctx.model` 就是 experiment 声明的 `model`,运行器原样递给你、Adapter 不解释含义,只转发——不需要等到后面的步骤,也不需要应用配合做任何改造,分档见 [Tier](/zh/concepts/tier))。
|
|
60
|
+
|
|
61
|
+
它有两个明显的局限:每轮都是一场全新对话(第二次 `t.send` 接不上第一次),工具调用完全看不见。后面两步各解决一个。
|
|
62
|
+
|
|
63
|
+
## 第二步:接上之前的消息
|
|
64
|
+
|
|
65
|
+
运行器在会话上只承诺一件事:**同一条会话线的每次 `send` 拿到同一个 `ctx.session`,新会话线(eval 的第一轮,或 `t.newSession()` 之后)拿到一个全新的。**
|
|
66
|
+
|
|
67
|
+
"之前的消息"怎么接,取决于应用接口的形状。业界统共两种模式,`ctx.session` 上各备了一对存取器:
|
|
68
|
+
|
|
69
|
+
- **客户端带全量历史**(服务端无状态,每轮发完整消息列表:Chat Completions 形是典型)→ `ctx.session.history<TMsg>()`
|
|
70
|
+
- **服务端记历史**(接口收一个会话 id:Responses 形的 `previous_response_id`、各 SDK 的原生 session / thread)→ `ctx.session.id` + `ctx.session.capture(id)`
|
|
71
|
+
|
|
72
|
+
主线的接口是前者:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// agents/chat-app.ts
|
|
76
|
+
import { defineAgent } from "niceeval/adapter";
|
|
77
|
+
|
|
78
|
+
const BASE_URL = "http://localhost:8080";
|
|
79
|
+
|
|
80
|
+
interface Msg { role: "user" | "assistant"; content: string | null }
|
|
81
|
+
|
|
82
|
+
export default defineAgent({
|
|
83
|
+
name: "chat-app",
|
|
84
|
+
async send(input, ctx) {
|
|
85
|
+
const history = ctx.session.history<Msg>(); // ← 本会话线的历史槽;新会话线是空的
|
|
86
|
+
const messages = [...history.get(), { role: "user" as const, content: input.text }];
|
|
87
|
+
|
|
88
|
+
const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
|
89
|
+
// ……method、headers、signal 同第一步,省略
|
|
90
|
+
body: JSON.stringify({ messages, model: ctx.model }), // ← 变化:发的是完整历史;model 同第一步转发
|
|
91
|
+
}).then((r) => r.json());
|
|
92
|
+
|
|
93
|
+
const reply = res.choices[0].message;
|
|
94
|
+
history.commit([...messages, reply]); // ← 写回;同一条线的下一轮 get() 自动带上
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
status: "completed",
|
|
98
|
+
events: [{ type: "message", role: "assistant", text: reply.content }],
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
注意这里**没有"第一轮"分支**:新会话线的 `history.get()` 自然返回空数组——"第一次发"的形态是新会话线的自然结果,不是要你判断的条件。也不需要在 `defineAgent` 上声明任何东西:接了 `ctx.session`,多轮就续得上;没接,每轮各是一场新对话。
|
|
105
|
+
|
|
106
|
+
应用接口收会话 id 的话,历史在服务端,Adapter 只记 id——`send` 里只改两处:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// send 里:
|
|
110
|
+
// 请求体带上一轮的 id …… previous_response_id: ctx.session.id ← 新会话线自然是 undefined
|
|
111
|
+
// 返回里抓到 id 就写回 …… ctx.session.capture(res.id) ← 下一轮续接靠它
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`capture` 只在还没记过 id 时落地,后端重复回传(甚至因 fork 变了)也不会覆盖正在续接的线。
|
|
115
|
+
|
|
116
|
+
**这一步解锁**:多轮对话,以及 `t.newSession()` 的会话隔离。
|
|
117
|
+
|
|
118
|
+
## 第三步:记录消费
|
|
119
|
+
|
|
120
|
+
答对了但烧掉十倍 token 的 agent,不该跟省着用的拿一样的分。消费是 `Turn` 上和 `events`、`status` 并列的第四个字段 `usage`:应用接口回了用量就如实填上,运行器逐轮累加到会话线与整次运行。Chat Completions 形返回自带 `usage`,照抄进来——`send` 的其余部分和第二步完全一样:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// ……transport 与会话同第二步,省略
|
|
124
|
+
return {
|
|
125
|
+
status: "completed",
|
|
126
|
+
events: [{ type: "message", role: "assistant", text: reply.content }],
|
|
127
|
+
usage: { // ← 变化:把接口回的用量如实报上
|
|
128
|
+
inputTokens: res.usage.prompt_tokens,
|
|
129
|
+
outputTokens: res.usage.completion_tokens,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`Usage` 的完整字段还有可选的 `cacheReadTokens` / `cacheWriteTokens`、请求次数 `requests`,以及 `costUSD`——网关回了实测成本就填它,优先于价格表估算。接口不回用量就整个不填 `usage`,其它断言不受影响。这段照抄也是过渡:下一步的官方转换器会连 `usage` 一起填好。
|
|
135
|
+
|
|
136
|
+
**这一步解锁**:`t.maxTokens()` / `t.maxCost()` 评分器(`maxCost` 用 `costUSD` 或配置里的价格表估算),以及报告和 `niceeval view` 里的用量。
|
|
137
|
+
|
|
138
|
+
## 第四步:把工具解析成事件
|
|
139
|
+
|
|
140
|
+
应用的返回里不只有回复文本——Chat Completions 形返回的 `tool_calls` 记录了这轮调过什么工具。Adapter 最重要的工作就是**把接口的返回归一成标准事件流**:本轮发生的每件事一个对象,按真实发生顺序排进 `Turn.events`,对象是下面九种类型之一(各字段的实际值,[契约页有一轮的完整示例](/zh/concepts/adapter)):
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
type StreamEvent =
|
|
144
|
+
| { type: "message"; role: "assistant" | "user"; text: string }
|
|
145
|
+
| { type: "action.called"; callId: string; name: string; input: JsonValue; tool?: ToolName }
|
|
146
|
+
| { type: "action.result"; callId: string; output?: JsonValue;
|
|
147
|
+
status: "completed" | "failed" | "rejected" }
|
|
148
|
+
| { type: "subagent.called"; callId: string; name: string; remoteUrl?: string }
|
|
149
|
+
| { type: "subagent.completed"; callId: string; output?: JsonValue;
|
|
150
|
+
status: "completed" | "failed" }
|
|
151
|
+
| { type: "input.requested"; request: InputRequest }
|
|
152
|
+
| { type: "thinking"; text: string }
|
|
153
|
+
| { type: "compaction"; reason?: string }
|
|
154
|
+
| { type: "error"; message: string };
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
解析就是一张"返回字段 → 事件"的映射。手写出来长这样——`send` 的其余部分和第二步完全一样:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// ……transport 与会话同第二步,usage 同第三步,省略
|
|
161
|
+
const reply = res.choices[0].message;
|
|
162
|
+
|
|
163
|
+
const events: StreamEvent[] = [];
|
|
164
|
+
for (const call of reply.tool_calls ?? []) {
|
|
165
|
+
events.push({ type: "action.called", callId: call.id,
|
|
166
|
+
name: call.function.name, input: JSON.parse(call.function.arguments) });
|
|
167
|
+
}
|
|
168
|
+
if (reply.content) events.push({ type: "message", role: "assistant", text: reply.content });
|
|
169
|
+
|
|
170
|
+
return { events, status: "completed", usage };
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
但这段循环通常**不用你写**。返回是标准形状时,官方转换器一行顶替上面全部——`events`、`status`、连第三步手抄的 `usage` 都在返回值里,拿来直接 `return`:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// ……transport 与会话同第二步,省略
|
|
177
|
+
return fromChatCompletion(res); // ← 上面那段映射加第三步的 usage,官方版
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
接口不是这个形状,就换对应的件:
|
|
181
|
+
|
|
182
|
+
| 接口的到达形状 | 官方件 | 你写什么 |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| Chat Completions 形返回 | `fromChatCompletion(res)` | 零映射 |
|
|
185
|
+
| Responses 形返回 | `fromResponses(res)` | 零映射 |
|
|
186
|
+
| AI SDK `generateText` 的完整结果 | `fromAiSdk(result)` | 零映射 |
|
|
187
|
+
| 流式:SDK 原生事件透传(一帧一个完整单元) | `fromClaudeSdkMessages()` / `fromPiAgentEvents()` / `fromCodexThreadEvents()` | 零映射 |
|
|
188
|
+
| 流式:AI SDK 的 UI Message Stream(`useChat` 协议) | `uiMessageStreamAgent()` | 零映射(整个 Agent 都是现成的,连 send 都不用写) |
|
|
189
|
+
| 流式:逐 token / 逐参数增量 | 协议方官方 reducer;没有才用 `deltaStream({ toOps })` | 一张"帧类型 → 操作"映射表 |
|
|
190
|
+
| 只有最终答案 | 手写几条 events | 一条 `message` 起步(第一步就是这个档) |
|
|
191
|
+
|
|
192
|
+
预设的件只到"形状"为止,不到"协议"为止——接口的到达形状统共就这几种,每种一个官方件;只有"增量流且协议方没有官方 reducer"这一种情况需要你写映射,而且写的是声明(这一帧对应哪个操作),拼接、配对、落地时机由 `deltaStream` 统一管。
|
|
193
|
+
|
|
194
|
+
归一完成后,你吐哪种事件,eval 作者就能写哪族断言:
|
|
195
|
+
|
|
196
|
+
| 你吐的事件 | 解锁的断言 |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `message` | `t.reply`、`t.messageIncludes()`、judge 的对话材料 |
|
|
199
|
+
| `action.called` / `action.result`(靠 `callId` 配对) | `t.calledTool()` / `t.toolOrder()` / `t.maxToolCalls()` / `t.noFailedActions()`… |
|
|
200
|
+
| `input.requested`(配合 status `"waiting"`) | `t.parked()`、`t.requireInputRequest()`、`t.respond()`(第五步) |
|
|
201
|
+
| `thinking` / `compaction` / `error` | 对应断言与 o11y 计数 |
|
|
202
|
+
|
|
203
|
+
一个如实交代:Chat Completions 这个形状**不承诺**"返回 = 全量过程记录"(应用完全可能在服务端跑完工具循环、只把最终答案吐给你),所以 `fromChatCompletion` 的返回不带完整性证明——`calledTool` 这类正断言照常可用,`notCalledTool` 这类负断言会提示不可信。Responses 形正相反:协议契约承诺 `output` 数组记录全过程,`fromResponses` 的返回自带完整性证明,负断言可信。同样零映射,可信度的差别完全来自接口形状本身。
|
|
204
|
+
|
|
205
|
+
**这一步解锁**:`t.calledTool()`、`t.toolOrder()`、`t.maxToolCalls()`、`t.noFailedActions()` 等整族工具断言。
|
|
206
|
+
|
|
207
|
+
## 第五步:HITL
|
|
208
|
+
|
|
209
|
+
应用中途停下来等人(工具审批、等补充信息)时,`send` 两侧各有义务:
|
|
210
|
+
|
|
211
|
+
- **停轮**:返回 `status: "waiting"`,并且每个待回答的问题吐一条带稳定 `id` 的 `input.requested` 事件——`t.parked()`、`t.requireInputRequest()` 读它,回答靠这个 `id` 对位。
|
|
212
|
+
- **回答轮**:eval 里的 `t.respond(...)` 到 Adapter 是**又一次普通的 `send`**(还是同一条会话线、同一份状态),人的裁决以结构化形式随 `input.responses` 到达,每条带 `requestId`、`optionId` 或 `text`(形态见[不同回答的入参](/zh/concepts/adapter#不同回答的入参))。Adapter 先把裁决交回应用,再接着取结果。被人拒绝的调用,`action.result` 的 `status` 置 `"rejected"` 而不是 `"failed"`——拒绝是人的决定、不是工具故障,`noFailedActions()` 不误伤。
|
|
213
|
+
|
|
214
|
+
"停轮时读了一半的现场"(比如一条读到一半的 SSE 流)也存在 `ctx.session` 上:停轮时 `ctx.session.hold(现场)`,回答轮开头 `ctx.session.take()` 取回——取到即清除,一次消费。
|
|
215
|
+
|
|
216
|
+
HITL 几乎总是发生在流式接口上(停在流中间),所以这一步的示例换成一个以 SSE 透传原生事件的应用——它同时用上前面几步的全部内容(完整可跑版本见 [tier1 示例](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1)):
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// agents/my-app.ts
|
|
220
|
+
import { defineAgent, sseJsonFrames, fromPiAgentEvents, driveFrameStream } from "niceeval/adapter";
|
|
221
|
+
import type { AgentContext, PiAgentStream, SseFrameCursor } from "niceeval/adapter";
|
|
222
|
+
import type { Turn, TurnInput } from "niceeval";
|
|
223
|
+
|
|
224
|
+
const BASE_URL = "http://localhost:5299"; // 应用自己的端口,eval 不代管进程
|
|
225
|
+
|
|
226
|
+
interface Pending { cursor: SseFrameCursor<Frame>; stream: PiAgentStream; callId: string }
|
|
227
|
+
|
|
228
|
+
function readStream(cursor: SseFrameCursor<Frame>, ctx: AgentContext, stream: PiAgentStream): Promise<Turn> {
|
|
229
|
+
return driveFrameStream(cursor, stream, ctx, (frame) => {
|
|
230
|
+
if (frame.type === "session") {
|
|
231
|
+
ctx.session.capture(frame.sessionId); // ← 第二步:回传的 id 写回,下一轮续接
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (frame.type === "approval_request") { // ← 本步:应用停下等审批,存下现场
|
|
235
|
+
ctx.session.hold<Pending>({ cursor, stream, callId: frame.toolCallId });
|
|
236
|
+
return { pause: { id: frame.toolCallId, action: frame.toolName,
|
|
237
|
+
options: [{ id: "approve" }, { id: "deny" }] } };
|
|
238
|
+
// driveFrameStream 收到 pause:补一条 input.requested、status 置 "waiting"、停止读流
|
|
239
|
+
}
|
|
240
|
+
if (frame.type === "server_error") return { fail: frame.message };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export default defineAgent({
|
|
245
|
+
name: "my-app",
|
|
246
|
+
async send(input: TurnInput, ctx: AgentContext): Promise<Turn> {
|
|
247
|
+
const held = ctx.session.take<Pending>();
|
|
248
|
+
if (held) { // ← t.respond("approve"):普通 send,先交裁决
|
|
249
|
+
const approved = input.responses?.[0]?.optionId === "approve"; // 多请求并停时按 requestId 对位
|
|
250
|
+
if (!approved) held.stream.markRejected(held.callId);
|
|
251
|
+
await fetch(`${BASE_URL}/api/chat/approve`, { method: "POST", signal: ctx.signal,
|
|
252
|
+
body: JSON.stringify({ toolUseId: held.callId, approved }) });
|
|
253
|
+
return readStream(held.cursor, ctx, held.stream); // 再接着读同一条流,不重发请求
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const res = await fetch(`${BASE_URL}/api/chat`, { // ← transport:唯一真正手写的一段
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
message: input.text, // ← 第一步:发消息
|
|
260
|
+
model: ctx.model, // ← 第一步:experiment.model 转发
|
|
261
|
+
sessionId: ctx.session.id, // ← 第二步:第一次发自动不带,之后自动带
|
|
262
|
+
}),
|
|
263
|
+
signal: ctx.signal,
|
|
264
|
+
});
|
|
265
|
+
return readStream(sseJsonFrames<Frame>(res.body!), ctx, fromPiAgentEvents()); // ← 第四步:官方转换器零映射
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
不需要 HITL 的接口,删掉停轮现场相关的三处(`Pending`、`hold`、开头的 `take` 分支)即可,其余不变。停轮 / 回答 / 续跑的完整心智模型见 [HITL](/zh/concepts/hitl)。
|
|
271
|
+
|
|
272
|
+
**这一步解锁**:`t.parked()`、`t.requireInputRequest()`、`t.respond()` / `t.respondAll()`,以及 `calledTool(..., { status: "rejected" })` 的精确断言。
|
|
273
|
+
|
|
274
|
+
## 第六步:接上 OTel trace
|
|
275
|
+
|
|
276
|
+
应用已经埋了点(标准 OTel HTTP 服务端埋点即可)的话,接入分两半——一半是启动期配置,一半在 `send` 里。分清它们就是分清「不变的」和「每轮变的」。
|
|
277
|
+
|
|
278
|
+
**端点是启动期配置,不从 send 传。** [NiceEval](https://niceeval.com/) 的 OTLP 接收地址每次运行都一样,所以它不走 `ctx`:在 `niceeval.config.ts` 里把接收端口钉住,应用启动时把自己的 OTel exporter 指向这个固定 URL,之后跑多少次 eval 都不用再改:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
// niceeval.config.ts
|
|
282
|
+
import { defineConfig } from "niceeval";
|
|
283
|
+
|
|
284
|
+
export default defineConfig({
|
|
285
|
+
telemetry: { port: 4318 }, // 接收器固定监听 http://localhost:4318/v1/traces
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces node server.js
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**send 里传的是本轮的 trace context,不是端点。** `ctx.telemetry.headers` 是运行器每轮新生成的 W3C `traceparent` 头——把它 spread 进请求,应用这一轮产生的 span 就精确挂到本轮的 trace 下,并发跑多条 eval 时归属不混。回到主线的 chat-app,`send` 里只多一行:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
|
297
|
+
// ……其余同第四步,省略
|
|
298
|
+
headers: { "content-type": "application/json", ...ctx.telemetry?.headers }, // ← 只多这一行
|
|
299
|
+
}).then((r) => r.json());
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`ctx.telemetry` 只在配置了 OTel 接入时出现,没配时 spread 一个 `undefined` 也安全,这行可以常驻。不带这个头 span 也能收到,但归属退化成时间窗口、该 agent 的轮次会降为串行——带上它是并发下归属准确的来源。
|
|
303
|
+
|
|
304
|
+
**这一步解锁**:`niceeval view` 里的调用瀑布图——应用内部每次模型调用、每次工具执行的耗时与 token,按轮铺成时间线。断言不变:判决依据在前几步的事件映射里已经齐了,span 只进瀑布图、不喂断言。接收器怎么起、span 怎么归属到轮,见 [OTel 接入](/zh/guides/connect-otel)。
|
|
305
|
+
|
|
306
|
+
## 第七步:透传 experiment 的 flags(A/B 对比)
|
|
307
|
+
|
|
308
|
+
应用把变体暴露成可切换的配置后,experiment 声明 `flags`,运行器每轮经 `ctx.flags` 原样递给 `send`;Adapter 不解释它们的含义,只随请求转发——应用按 flag 切换变体:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// ……send 其余部分同前,省略
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
messages,
|
|
314
|
+
model: ctx.model, // ← 第一步就转发的 experiment.model;这里一起列出
|
|
315
|
+
flags: ctx.flags, // ← experiment.flags 原样透传;没配时是 {}
|
|
316
|
+
}),
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// experiments/concise.ts
|
|
321
|
+
import { defineExperiment } from "niceeval";
|
|
322
|
+
import chatApp from "../agents/chat-app.ts";
|
|
323
|
+
|
|
324
|
+
export default defineExperiment({
|
|
325
|
+
agent: chatApp,
|
|
326
|
+
model: "gpt-5.4", // ← 经 ctx.model 到 send
|
|
327
|
+
flags: { promptVariant: "concise" }, // ← 每轮经 ctx.flags 到 send
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
两个 experiment 文件各声明一份 `flags`,`npx niceeval exp` 分别跑同一批 eval,就是一组 A/B 对比。这是三档接入里的 Tier 3(要求应用配合暴露开关),投入与回报见 [Tier](/zh/concepts/tier);`flags` 与 `model`、`runs` 等其余 experiment 字段见[写实验](/zh/guides/write-experiment)。
|
|
332
|
+
|
|
333
|
+
**这一步解锁**:同一批 eval 跨变体的成绩对比。
|
|
334
|
+
|
|
335
|
+
## 对照:五个 t API 到 send 的形态
|
|
336
|
+
|
|
337
|
+
七步写完,回头看 eval 侧的五个驱动 API——它们到 `send` 只是同一个函数收到不同字段,没有第二个要实现的方法:
|
|
338
|
+
|
|
339
|
+
| eval 侧 | 到 Adapter 时 | `send` 收到什么 |
|
|
340
|
+
|---|---|---|
|
|
341
|
+
| `t.send(text)` | 一次 `send` | `input.text`;`ctx.session` 是这条会话线的自有状态 |
|
|
342
|
+
| `t.sendFile(path, text?)` | 一次 `send` | 同上,外加 `input.files`(base64 的 `InputFile[]`);按应用接口的方式放进请求体,不支持多模态就忽略 |
|
|
343
|
+
| `t.newSession()` | 不直接触发 `send` | 开第二条会话线;该线随后第一次 `send` 拿到一份**全空的**状态——和整个 eval 的第一次 `send` 形态完全相同 |
|
|
344
|
+
| `t.respond(...answers)` | 一次**普通的** `send` | `input.text` = 回答文本;`input.responses` 逐请求一条 `{ requestId, optionId }`(自由文本回答则是 `{ requestId, text }`);还是同一条线、同一份状态 |
|
|
345
|
+
| `t.respondAll(optionId)` | 一次**普通的** `send` | 同上,每条待处理请求各一条回答;`optionId` 已在 eval 侧对每条请求校验过存在,写错直接抛,不会静默传给应用 |
|
|
346
|
+
|
|
347
|
+
## 相关阅读
|
|
348
|
+
|
|
349
|
+
- [Adapter](/zh/concepts/adapter) — 契约本身:`send` 传入什么返回什么、三个接入 Tier、能力从哪来。
|
|
350
|
+
- [接入你的 Agent](/zh/guides/connect-your-agent) — 接入全景:最小接入、参数通道与增量地图。
|
|
351
|
+
- [HITL](/zh/concepts/hitl) — 停轮等人的完整概念:握手时序与两侧义务。
|
|
352
|
+
- [Drive](/zh/concepts/drive) — eval 侧视角:`t.send()`、`t.newSession()` 与 HITL 怎么用。
|
|
353
|
+
- [Assert](/zh/concepts/assert) — 标准事件流驱动的完整断言词汇。
|