鸿蒙HarmonyOS AI 应用评估框架实战 —— Record/Replay、SHA-256 请求哈希、Grader 与 pass@k 全流程
一、前言:AI 评估的"四个不可能"
假设你在鸿蒙上做完了一个 AI 助手,现在要交付。QA 问你:"这个 Agent 的工具调用成功率是多少?" 你的直觉是写个测试跑一遍。但你立刻撞上四个难题:
-
模型回答不确定。同一个 prompt "帮我查烘焙记录",跑 10 次模型可能 8 次成功调用工具、2 次走文本兜底。你测一次通过不代表"通过了"——你需要的是统计意义上的通过率,而不是一个布尔值。
-
每次评测都打网络太贵太慢。一个 capability 套件如果有 50 个 task、每个 task 跑 3 次 trial、每次 trial 可能打 3~5 个 LLM 请求——一轮评测就是几百次 HTTPS 往返。真机上每次请求 1~3 秒,一轮评测半小时,CI 跑不起。而且网络抖动会导致"同一个测试昨天过今天不过"。
-
判断"答得好不好"没有标准答案。模型回了一段长文本,你怎么判定它"答对了"?精确匹配(exact match)太死——模型多一个字就挂;正则又太宽——匹配上了不代表语义正确。你需要 Code Grader(精确逻辑)、Model Grader(让另一个 LLM 当裁判)、Human Grader(人工复核)三种手段组合。
-
评估不能污染主路径。评估框架要能独立构建、独立测试,不能让
agent_core反向依赖agent_eval。否则评估代码的 bug 会拖垮正式 Agent 的构建。
这四个问题的本质是:AI 应用的评估需要确定性、可复现、多维度、解耦。ArkAgent 的 @arkagent/eval 模块就是为了解决这四个问题而设计的。本文就拆解这套框架。
AI 评估的核心诉求
┌─────────────────────────────────────────────┐
│ 确定性 → Record/Replay,回放 HTTP=0 │
│ 可复现 → SHA-256 请求哈希 + canonical JSON │
│ 多维度 → Code/Model/Human 三种 Grader │
│ pass@k / pass^k 统计指标 │
│ 解 耦 → @arkagent/eval 独立 HAR 构建 │
│ core 不反向依赖 eval │
└─────────────────────────────────────────────┘
二、整体架构:一张图看懂 agent_eval
先看全局。@arkagent/eval 分为 7 个子目录,各司其职:
agent_eval/src/main/ets/
├── core/ 评测运行核心
│ ├── EvalEnvironment.ets prepare/dispose SPI(trial 生命周期)
│ ├── EvalRunner.ets 评测运行器(并发 worker、超时、评分)
│ ├── EvalRunReport.ets 运行报告(聚合统计)
│ ├── EvalTypes.ets EvalTask/EvalSuite/Trial/TrialStatus/SuiteKind
│ ├── Transcript.ets trial 回放记录(事件序列)
│ └── RuntimeHarness.ets Runtime 测试框架
│
├── graders/Grader.ets Code/Model/Human Grader + Score 三态
│
├── llm/ Record/Replay + 哈希 + 限流
│ ├── LlmRequestHash.ets SHA-256 请求哈希(canonical + salt)
│ ├── RecordingStore.ets RecordingLLMClient / ReplayLLMClient
│ └── RateLimitGate.ets 串行/无限限流门
│
├── metrics/Metrics.ets pass@k / pass^k / ClassificationMetrics
│
├── observability/TraceExporter.ets JSONL / Langfuse 导出
│
├── loaders/ 套件加载 + Grader 注册
│ ├── SuiteLoader.ets JSON 套件加载
│ └── GraderRegistry.ets grader 名称注册表
│
├── util/ 工具
│ ├── CanonicalJson.ets 规范化 JSON(key 排序)
│ ├── Sha256.ets 纯 ArkTS SHA-256 实现
│ ├── EvalSanitize.ets 脱敏(Bearer/APIKey/dataURL)
│ └── Base64.ets Base64 编码
│
└── reporting/ReportStore.ets 报告存储
2.1 一次评估的数据流
把 7 个子目录串起来,一次完整的 Record + Replay 评估流是这样的:
EvalSuite(任务集)
│
▼
EvalRunner.runSuite()
│ 并发 worker(concurrency=4)
│ 每个 task × trialsPerRun 个 trial
▼
EvalEnvironment.prepare(trial, task) ← 每个 trial 独立 workspace/clock/controller
│
▼
HarnessSession.run(context) ← 你的 Agent 跑任务
│ LLMClient = RecordingLLMClient(录制)或 ReplayLLMClient(回放)
│ 每个请求 → LlmRequestHash.hashRequest → SHA-256
▼
Transcript(事件序列)+ Outcome(成功/失败 + 细节)
│
▼
gradeAll() → GraderRegistry.require(name).grade(context)
│ Code Grader / Model Grader / Human Grader
▼
Score(value 0~1 + passed 三态:-1 unknown / 0 fail / 1 pass)
│
▼
TrialResult → 聚合 → EvalRunReport
│ pass@k / pass^k / 通过率 / 超时率
▼
TraceExporter(JSONL 文件 / Langfuse)+ ReportStore
│
▼
EvalEnvironment.dispose(context) ← 释放资源 + 清理 workspace
这个流程的关键设计点是:录制和回放共用同一套 Harness。你的 Agent 代码不用改一行,只是把 LLMClient 从 RecordingLLMClient 换成 ReplayLLMClient,就从"打网络"变成"完全离线"。
三、EvalEnvironment:trial 的生命周期 SPI
评估的地基是 EvalEnvironment——它定义了每个 trial 跑之前准备什么、跑完之后清理什么。这是 ADR-0015 冻结的 SPI 边界。
3.1 prepare:每个 trial 一份独立上下文
看源码核心(core/EvalEnvironment.ets):
export class EvalEnvironment {
async prepare(trial: Trial, task: EvalTask): Promise<EvalContext> {
this.prepareCount++
this.seq++
const resourceId = `${trial.id.cacheSalt()}#${this.seq}`
const clock = new FakeEvalClock(this.baseClock.nowMs()) // ← 独立时钟
const controller = new AgentController() // ← 独立控制器
const cancel = new CancellationController() // ← 独立取消
let workspace: string | undefined = undefined
let workspaceFs: FileSystem | undefined = undefined
let workspaceLease: WorkspaceLease | undefined = undefined
if (this.rootDir !== undefined) {
const seg = `trial_${EvalSanitize.safePathSegment(trial.id.taskId)}_${trial.id.trialIndex}_${this.seq}`
workspace = EvalPath.join(this.rootDir, seg)
if (this.fileSystem !== undefined) {
await this.fileSystem.mkdirp(workspace)
workspaceLease = new WorkspaceLease(workspace) // ← 租约
workspaceFs = new ScopedFileSystem(this.fileSystem, workspaceLease) // ← 作用域 FS
}
}
const llm = this.llmClientFactory !== undefined ? this.llmClientFactory() : undefined
const ctx = new EvalContext(trial.runName, trial, task, clock, controller, workspace, llm,
services, cancel, resourceId, workspaceFs, workspaceLease)
this.liveContexts.add(resourceId)
return ctx
}
}
四个要点:
第一,每个 trial 独立的 clock。 FakeEvalClock 让时间可控——超时测试不需要真等 5 分钟,advance 一下就行。trial 之间的时间互不影响。
第二,独立的 controller + cancel。 每个 trial 有自己的 AgentController 和 CancellationController。一个 trial 超时取消,不会波及其他 trial。
第三,独立的 workspace + lease。 这是鸿蒙上做隔离评测的关键。WorkspaceLease 是一个"租约"——trial 结束(或超时)时 revoke(),之后 ScopedFileSystem 的所有写操作(write/rename/mkdir)都会失败。这保证了:超时后 harness 即使还在挣扎,也无法再往 workspace 写文件。
第四,resourceId 唯一。 trialId.cacheSalt() 是 taskId#trialIndex,再加 seq 后缀,保证并发场景下资源不冲突。
3.2 dispose:释放 + 清理的顺序
async dispose(context: EvalContext): Promise<void> {
if (!this.liveContexts.has(context.resourceId)) {
return // ← 幂等:重复 dispose 不报错
}
this.liveContexts.delete(context.resourceId)
this.disposeCount++
try {
if (!context.cancelController.cancelled) {
context.cancelController.cancel('environment_dispose') // ← 先取消
}
} catch (_e) { }
try {
context.controller.close() // ← 再关闭控制器
this.controllerCloseCount++
} catch (_e) { }
// 延迟清理的不删(lease 已 revoke,等 reapDeferredCleanup)
if (this.deferredWorkspaces.has(context.resourceId)) {
this.sealContext(context, context.sealReason ?? 'cleanup_deferred')
return
}
// 正常路径:revoke lease → 删目录
if (context.workspaceLease !== undefined && context.workspaceLease.isActive()) {
context.workspaceLease.revoke('environment_dispose')
}
if (this.cleanupWorkspace && context.workspaceRoot !== undefined &&
this.fileSystem !== undefined) {
try {
await this.fileSystem.deleteTree(context.workspaceRoot)
this.workspaceCleanupCount++
} catch (_e) { } // ← 删失败不虚报成功
}
}
dispose 的顺序很重要:先 cancel(停掉正在跑的逻辑)→ 再 close controller(释放事件总线)→ 再 revoke lease(封锁 FS)→ 最后删目录。每一步都 try-catch,保证前一步失败不会阻断后一步。
3.3 生命周期决策矩阵
|
场景 |
prepare 做什么 |
dispose 做什么 |
workspace 去向 |
|---|---|---|---|
|
正常通过 |
建 dir + lease |
cancel + close + revoke + deleteTree |
删除 |
|
超时 + 协作终止 |
建 dir + lease |
cancelAndWait 终止 → dispose 正常 |
删除 |
|
超时 + 不协作 |
建 dir + lease |
markCleanupDeferred(lease 已 revoke) |
保留(等 reap) |
|
errored |
建 dir + lease |
dispose 正常 |
删除 |
|
无 FS(rootDir 空) |
只建 controller/clock |
cancel + close |
无目录 |
"延迟清理"(deferred cleanup)是处理不协作 harness 的关键设计:如果 harness 超时后不响应 cancelAndWait,不能假装"安全清理了"——那会误删正在被 harness 写入的文件。正确做法是 markCleanupDeferred,lease 立刻 revoke(封锁后续写),但目录保留,等整个 suite 跑完再 reapDeferredCleanup 统一清理。
四、Record/Replay:让评测 HTTP 请求为零
这是整个 eval 框架最有价值的能力。RecordingLLMClient 和 ReplayLLMClient 让你"录一次真实交互,之后无限次离线回放"。
4.1 录制:RecordingLLMClient
RecordingLLMClient 包装一个真实的 LLMClient,在转发请求的同时,把请求的哈希和响应录制下来:
export class RecordingLLMClient implements LLMClient {
private readonly inner: LLMClient // ← 真实 Provider(打网络)
private readonly store: RecordingStore // ← 录制存储
private readonly trialSalt: string // ← trial 隔离盐
innerCalls: number = 0
async generate(request: ModelRequest, signal?: CancellationSignal): Promise<ModelResponse> {
this.innerCalls++
const hash = LlmRequestHash.hashRequest(request, this.trialSalt, false)
try {
const response = await this.inner.generate(request, signal) // ← 打真实网络
const expanded = RecordingLLMClient.expandResponseEvents(response)
await this.store.put(new RecordingEntry(hash, this.trialSalt, false, expanded,
response.finishReason, undefined, DomainCodec.encodeModelResponse(response), 'complete'))
return response
} catch (e) {
const err = e instanceof ArkAgentError ? e : ArkAgentError.provider('error', ...)
const terminal: RecordingTerminal = err.code === 'cancelled' ? 'cancel' : 'error'
await this.store.put(new RecordingEntry(hash, this.trialSalt, false, [],
terminal === 'cancel' ? 'cancelled' : 'error', err.code, undefined, terminal))
throw err
}
}
}
注意三个细节:
第一,hash 在请求前算。 LlmRequestHash.hashRequest 的输入是 request,输出是 64 位 SHA-256。同样的请求(同样 messages、tools、config)一定算出同样的 hash——这是回放匹配的依据。
第二,错误也录制。 catch 分支里,错误响应(包括 cancel)也被存进 store。回放时遇到录制的错误,会原样重放——保证离线测试能复现"Provider 返回 429""请求被取消"等场景。
第三,stream 也录制。 stream() 方法用 RecordingStreamObserver 包装 observer,把 onNext 的每个 StreamingEvent 存下来,onComplete/onError 时统一保存。流式的增量事件(textDelta/toolCallDelta)被完整保留。
4.2 回放:ReplayLLMClient(HTTP=0)
ReplayLLMClient 不持有任何真实 LLMClient——它只有 store:
export class ReplayLLMClient implements LLMClient {
private readonly store: RecordingStore // ← 只有录制数据,没有 inner client
private readonly trialSalt: string
httpCalls: number = 0 // ← HTTP 调用计数(必须永远为 0)
async generate(request: ModelRequest, signal?: CancellationSignal): Promise<ModelResponse> {
const hash = LlmRequestHash.hashRequest(request, this.trialSalt, false)
const rec = await this.store.get(hash)
if (rec === undefined) {
throw ArkAgentError.config('replay_miss',
`replay_miss hash=${hash.substring(0, 12)} salt=${this.trialSalt}`)
}
if (rec.errorCode !== undefined) {
throw ArkAgentError.provider(rec.errorCode, `replay_recorded_error:${rec.errorCode}`)
}
if (signal?.cancelled === true) {
throw ArkAgentError.provider('cancelled', 'replay cancelled')
}
return ReplayLLMClient.toResponse(rec) // ← 从录制数据重建响应
}
}
注意:ReplayLLMClient 没有 inner 字段,构造函数只接收 store 和 salt。 它从结构上就不可能发起 HTTP 请求。httpCalls 字段是给测试断言用的——测试里会硬断言 expect(replay.httpCalls).assertEqual(0)。
这是真实测试(EvalRecordReplay.test.ets)里的核心断言:
it('given_record_then_replay_when_generate_then_http_zero', 0, async () => {
const store = new MemoryRecordingStore()
const inner = new FakeEvalLLM()
inner.enqueueText('hello-recorded')
const rec = new RecordingLLMClient(inner, store, 'task#0')
const live = await rec.generate(textRequest('ping'))
expect(live.message.contents[0].text).assertEqual('hello-recorded')
expect(rec.innerCalls).assertEqual(1) // ← 录制时打了 1 次真实调用
expect(store.size()).assertEqual(1)
const replay = new ReplayLLMClient(store, 'task#0')
const out = await replay.generate(textRequest('ping'))
expect(out.message.contents[0].text).assertEqual('hello-recorded')
expect(replay.httpCalls).assertEqual(0) // ← 回放时 HTTP=0
expect(inner.callCount).assertEqual(1) // ← inner 总调用数没变(回放没打)
})
更严格的端到端测试(EvalProviderRecordReplay.test.ets)用 RejectingHttpTransport(拒绝所有请求的 transport)验证:回放阶段即使 transport 拒绝所有请求,评测照样通过——因为根本没调 transport。
const reject = new RejectingHttpTransport() // ← 回放阶段用拒绝式 transport
const replayHarness = new ProviderRuntimeToolHarnessFactory(
(ctx): LLMClient => new ReplayLLMClient(store, ctx.trialId.cacheSalt()),
...)
const replayReport = await replayRunner.runSuite(new EvalRunConfig(`replay-${providerId}`, 1), suite)
expect(replayReport.passedTrials).assertEqual(1)
expect(reject.callCount).assertEqual(0) // ← transport 被调用 0 次
expect(counting.callCount).assertEqual(liveHttp) // ← 总 HTTP 数 = 录制时的数
4.3 RecordingEntry:录制数据的结构
一条录制记录长这样(RecordingStore.ets):
export class RecordingEntry {
readonly hash: string // ← 请求哈希(匹配键)
readonly trialSalt: string // ← trial 隔离盐
readonly stream: boolean // ← 是 stream 还是 generate
readonly events: JsonObject[] // ← 有序的 onNext StreamingEvent 序列
readonly finishReason: string // ← stop / toolCalls / length / error ...
readonly errorCode?: string // ← 错误码(如有)
readonly response?: JsonObject // ← 完整 ModelResponse(可选,用于快速重建)
readonly terminal: RecordingTerminal // ← 'complete' | 'error' | 'cancel'
}
terminal 字段是关键——它区分了"正常完成""出错""取消"三种终止方式。回放时根据 terminal 决定是调 observer.onComplete() 还是 observer.onError()。源码注释明确写了:终端回调不会作为 onNext 事件投递。
// ReplayLLMClient.stream()
for (let i = 0; i < rec.events.length; i++) {
observer.onNext(StreamingEventCodec.decode(rec.events[i])) // ← 先重放所有 onNext
}
if (rec.terminal === 'error' || rec.terminal === 'cancel') {
const code = rec.errorCode ?? (rec.terminal === 'cancel' ? 'cancelled' : 'error')
observer.onError(ArkAgentError.provider(code, ...)) // ← 再发终端错误
throw err
}
observer.onComplete() // ← 或正常完成
4.4 ⚠️ 踩坑一:回放必须完全离线(HTTP=0 不是软约束)
症状:早期实现 ReplayLLMClient 时,为了"方便",在 miss(哈希找不到)时 fallback 去打真实网络。结果 CI 上偶尔过、偶尔挂——因为网络抖动导致同一个测试不稳定。更糟的是,离线环境(无 Key、无网络)跑评测直接崩。
根因:ReplayLLMClient 持有 inner: LLMClient 时,miss 就会 fallback 到网络。这破坏了"回放确定性"的核心承诺。ADR-0015 明确规定:
"Replay 不发起底层 HTTP(httpCalls=0)。"
这不是"尽量不打",是"结构上不可能打"。
修复:从结构上消灭 fallback 的可能——ReplayLLMClient 构造函数不接收 inner,只接收 store 和 trialSalt。miss 时直接抛 replay_miss 错误,不尝试任何网络。
// ❌ 错误:持有 inner,miss 时 fallback
export class ReplayLLMClient {
constructor(private inner: LLMClient, private store: RecordingStore, ...) { }
async generate(request) {
const rec = await this.store.get(hash)
if (rec === undefined) {
return this.inner.generate(request) // ← fallback 到网络!破坏确定性
}
...
}
}
// ✅ 正确:不持有 inner,miss 直接报错
export class ReplayLLMClient {
constructor(private store: RecordingStore, private trialSalt: string) { } // ← 没有 inner
async generate(request) {
const rec = await this.store.get(hash)
if (rec === undefined) {
throw ArkAgentError.config('replay_miss', `replay_miss hash=${hash.substring(0,12)}...`)
}
...
}
}
教训:离线确定性不是"行为约束"("你不应该打网络"),而是"结构约束"("你不可能打网络")。行为约束会被遗漏、被绕过;结构约束从类型签名上就堵死了。ReplayLLMClient 不持有 LLMClient——这个设计决策比任何注释都有效。
五、SHA-256 请求哈希:回放匹配的基石
Record/Replay 的核心是"同样的请求算出同样的哈希"。这听起来简单,做起来全是坑。LlmRequestHash(153 行)就是解决这个问题的。
5.1 哈希的计算流程
export class LlmRequestHash {
static hashRequest(request: ModelRequest, trialSalt: string, stream: boolean,
extraStripKeys: string[] = []): string {
const body = LlmRequestHash.canonicalPayload(request, stream, trialSalt, extraStripKeys)
const text = CanonicalJson.stringifyObject(body) // ← canonical JSON(key 排序)
return Sha256.hashUtf8(text) // ← SHA-256
}
}
三步:构造 canonical payload → 规范化为确定性 JSON 字符串 → SHA-256。关键是第二步——CanonicalJson.stringifyObject 会对所有层级的 object key 做排序,保证 {"b":1,"a":2} 和 {"a":2,"b":1} 算出同样的哈希。
5.2 canonicalPayload:覆盖完整语义
哈希要覆盖什么?答案是"完整语义请求"——任何影响 Provider 返回结果的因素都要进哈希。看 canonicalPayload:
static canonicalPayload(request: ModelRequest, stream: boolean, trialSalt: string,
extraStripKeys: string[] = []): JsonObject {
const strip = new Set<string>(['timestamp']) // ← 默认只剥 timestamp
// ... messages 处理(含媒体重写)...
// ... tools 处理(完整 schema)...
const root = new JsonObject()
.set('messages', JsonValue.array(messages))
.set('tools', JsonValue.array(tools))
.set('modelConfig', JsonValue.object(DomainCodec.encodeModelConfig(request.modelConfig)))
.set('stream', JsonValue.boolean(stream))
if (request.toolChoice !== undefined) {
root.set('toolChoice', JsonValue.object(DomainCodec.encodeToolChoice(request.toolChoice)))
}
if (request.modelConfig.jsonOutput !== undefined) {
root.set('jsonOutput', JsonValue.boolean(request.modelConfig.jsonOutput))
}
if (trialSalt.length > 0) {
root.set('trialSalt', JsonValue.string(trialSalt)) // ← trial 隔离盐
}
return root
}
哈希覆盖的语义字段:
|
字段 |
为什么必须进哈希 |
|---|---|
|
|
消息内容不同,回答必然不同 |
|
|
工具定义变了,模型行为变了 |
|
|
auto/required/none 决定模型是否必须调工具 |
|
|
temperature 不同,输出完全不同 |
|
|
stream 和 generate 的响应形态不同 |
|
|
是否强制 JSON 输出影响回答格式 |
|
|
同一请求的不同 trial 要区分 |
|
|
非语义字段,必须剥离 |
只剥离 timestamp——这是唯一的非语义键。其他字段(哪怕是 metadata)只要语义上影响结果,就保留。
真实测试验证了这些字段的敏感性(EvalRecordReplay.test.ets):
it('given_model_config_diff_when_hash_then_differs', 0, () => {
const m1 = new ModelRequest([new AgentMessage(MessageRole.user, [ContentPart.text('hi')])],
new ModelConfig('m', 0.2, 100, 0.9, 40, true))
const m2 = new ModelRequest([new AgentMessage(MessageRole.user, [ContentPart.text('hi')])],
new ModelConfig('m', 0.3, 100, 0.9, 40, true)) // ← 只有 temperature 不同
expect(LlmRequestHash.hashRequest(m1, 's', false) ===
LlmRequestHash.hashRequest(m2, 's', false)).assertFalse() // ← 哈希必须不同
})
5.3 媒体内容用强摘要
图片等媒体内容如果完整进哈希,哈希串会很大。但如果只用长度,又会碰撞。rewriteMessageMedia 的策略是"强摘要":
private static partForHash(part: JsonObject): JsonObject {
const o = new JsonObject()
const keys = part.keys()
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const v = part.get(k)
if (v === undefined) { continue }
if (k === 'data') {
const data = v.asString()
if (data === undefined) { o.set(k, v); continue }
// 永远包含强内容哈希(绝不用 length-only)
o.set('dataSha256', JsonValue.string(Sha256.hashUtf8(data)))
o.set('dataLength', JsonValue.number(data.length))
// 短数据保留完整(精确匹配)
if (data.length <= 256) {
o.set('data', JsonValue.string(data))
}
} else if (k === 'uri') {
// 保留完整 URI(含 query string,不做剥离)
o.set('uri', v)
} else {
o.set(k, v)
}
}
return o
}
策略是:
-
dataSha256+dataLength:永远包含。两个不同图片即使长度相同,SHA-256 也不同。 -
data(≤256 字符时保留完整):短数据(如小文本片段)保留原文,精确匹配。 -
uri(完整保留含 query):签名 URL 的 query 参数(?sig=aaavs?sig=bbb)必须区分。
两个测试验证了媒体哈希的鲁棒性:
// 等长不同内容的图片,哈希必须不同
it('given_equal_length_media_when_hash_then_differs', 0, () => {
const a = new ModelRequest([new AgentMessage(MessageRole.user, [
new ContentPart(ContentKind.image, undefined, 'AAAA', undefined, 'image/png')
])], new ModelConfig('m'))
const b = new ModelRequest([new AgentMessage(MessageRole.user, [
new ContentPart(ContentKind.image, undefined, 'BBBB', undefined, 'image/png') // ← 同长度不同内容
])], new ModelConfig('m'))
expect(LlmRequestHash.hashRequest(a, 's', false) ===
LlmRequestHash.hashRequest(b, 's', false)).assertFalse()
})
// URI 的 query 不同,哈希必须不同
it('given_uri_query_diff_when_hash_then_differs', 0, () => {
const a = ... 'https://cdn.example/x?sig=aaa' ...
const b = ... 'https://cdn.example/x?sig=bbb' ...
expect(LlmRequestHash.hashRequest(a, 's', false) ===
LlmRequestHash.hashRequest(b, 's', false)).assertFalse()
})
5.4 trial salt:同一请求的多次 trial 隔离
trialSalt 的默认值是 trialId.cacheSalt(),即 taskId#trialIndex:
// EvalTypes.ets
export class TrialId {
readonly taskId: string
readonly trialIndex: number
cacheSalt(): string {
return `${this.taskId}#${this.trialIndex}`
}
}
为什么需要 salt?因为 pass@k 要求同一个 task 跑多次 trial。如果只用请求内容算哈希,同一个 task 的 3 次 trial 请求内容完全一样——哈希也完全一样——录制时只存 1 条,回放时 3 次都命中同一条。这会让 pass@k 失去意义(3 次"不同"的 trial 变成了 3 次完全相同的回放)。
加了 salt 后,task#0、task#1、task#2 的哈希不同,录制 3 条不同的响应,回放时能复现 3 次不同的结果。测试验证了 salt 的隔离效果:
it('given_same_request_when_hash_then_stable_sha256', 0, () => {
const r = textRequest('hi')
const a = LlmRequestHash.hashRequest(r, 't#0', false)
const b = LlmRequestHash.hashRequest(r, 't#0', false)
expect(a).assertEqual(b) // ← 同 salt:哈希稳定
expect(a.length).assertEqual(64)
const c = LlmRequestHash.hashRequest(r, 't#1', false)
expect(a === c).assertFalse() // ← 不同 salt:哈希不同
})
5.5 ⚠️ 踩坑二:媒体哈希用 length-only 导致碰撞
症状:早期媒体哈希只用 dataLength(数据长度),不包含内容。结果发现:两张都是 4KB 的不同图片,哈希相同——回放时把图片 A 的响应给了图片 B 的请求,评测结果完全错乱。更隐蔽的是:base64 编码后长度相同的图片特别多(同分辨率不同内容),碰撞率很高。
根因:length 不是"强摘要"——它是弱摘要。长度相同不代表内容相同。partForHash 的源码注释写得很明确:
"Always include strong content hash (never length-only)"
修复:媒体内容必须用 dataSha256(内容 SHA-256)+ dataLength(长度,辅助区分)。短数据(≤256)额外保留完整原文。
// ❌ 错误:只用长度
if (k === 'data') {
o.set('dataLength', JsonValue.number(data.length))
}
// ✅ 正确:强摘要 + 长度
if (k === 'data') {
o.set('dataSha256', JsonValue.string(Sha256.hashUtf8(data))) // ← 内容哈希
o.set('dataLength', JsonValue.number(data.length))
if (data.length <= 256) {
o.set('data', JsonValue.string(data)) // ← 短数据保留原文
}
}
被拒绝的方案:考虑过"全部保留完整 data",但大图片(几 MB base64)会让哈希 payload 膨胀到几 MB,影响录制文件大小和哈希计算性能。最终选了"短数据完整 + 长数据强摘要"的折中——256 字节是个经验阈值,短文本片段(如小段代码、关键词)保留原文便于调试,大媒体用摘要省空间。
教训:哈希的匹配粒度要匹配语义粒度。"两个图片是否相同"的语义粒度是"内容相同",不是"长度相同"。用 length 做摘要等于把粒度降到了"长度相同"——这在多模态场景下必然碰撞。
六、CanonicalJson:确定性 JSON 序列化
哈希的确定性依赖于 JSON 序列化的确定性。CanonicalJson 解决的是"{"b":1,"a":2} 和 {"a":2,"b":1} 应该算同一种 payload"的问题。
6.1 核心实现
export class CanonicalJson {
static stringifyObject(obj: JsonObject): string {
return CanonicalJson.encode(JsonValue.object(obj))
}
private static encode(value: JsonValue): string {
// ...
// object:递归排序 key
const obj = value.asObject() ?? new JsonObject()
const keys = obj.keys().slice().sort() // ← key 排序
const pairs: string[] = []
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const v = obj.get(k)
if (v !== undefined) {
pairs.push(`${CanonicalJson.quote(k)}:${CanonicalJson.encode(v)}`) // ← 递归
}
}
return `{${pairs.join(',')}}`
}
}
三个要点:
第一,所有层级的 object key 都排序。 不是只排顶层——嵌套对象也递归排序。{"outer":{"z":1,"a":2}} 编码后是 {"outer":{"a":2,"z":1}}。
第二,数组保持顺序。 messages 是数组,顺序不能打乱(第一条消息和第二条消息语义不同)。CanonicalJson 只排 object 的 key,不排 array 的元素。
第三,特殊字符转义。 quote() 方法处理了 "、\\、\n、\r、\t、控制字符(\u00xx),保证序列化后的字符串是合法 JSON 且可逆。
6.2 stripKeys:剥离非语义字段
static stripKeys(value: JsonValue, keys: Set<string>): JsonValue {
if (value.kind === JsonKind.objectValue) {
const obj = value.asObject()
const out = new JsonObject()
const names = obj.keys()
for (let i = 0; i < names.length; i++) {
const k = names[i]
if (keys.has(k)) {
continue // ← 剥离
}
const child = obj.get(k)
if (child !== undefined) {
out.set(k, CanonicalJson.stripKeys(child, keys)) // ← 递归剥离
}
}
return JsonValue.object(out)
}
if (value.kind === JsonKind.arrayValue) {
// 数组元素也递归剥离
...
}
return value
}
stripKeys 递归地从所有层级剥离指定的 key(默认是 timestamp)。这保证了即使 message 嵌套了多层 object,timestamp 也不会混进哈希。
七、Grader:三种判分器
判分(Grading)是评估的核心。ArkAgent 提供三种 Grader,对应三种判分场景。
7.1 Grader 接口与 Score 三态模型
先看基础接口(graders/Grader.ets):
export interface Grader {
readonly name: string
readonly threshold: number // ← 通过阈值(默认 1.0)
grade(context: GraderContext): Promise<Score>
}
export class GraderContext {
readonly task: EvalTask
readonly transcript: Transcript // ← trial 的完整事件序列
readonly outcome: Outcome // ← 成功/失败 + 细节
}
Score 是一个三态模型——这是 ArkTS 严格模式下的设计(ArkTS 没有 nullable bool):
export class Score {
readonly value: number // ← 0~1 分值
readonly unknown: boolean // ← 无法判定(如 judge LLM 没返回)
readonly pending: boolean // ← 待人工复核
readonly rationale: string
readonly assertions: AssertionResult[]
readonly graderName?: string
// passedState: -1 null/unknown/pending, 0 fail, 1 pass(ArkTS-safe 三态)
get passed(): boolean | undefined {
if (this.passedState < 0) { return undefined }
return this.passedState === 1
}
isPass(threshold: number = 1.0): boolean {
if (this.unknown || this.pending) { return false } // ← unknown/pending 不算通过
// ...
return this.value + 1e-12 >= threshold // ← 1e-12 容错浮点误差
}
}
三种状态的语义:
|
Score 状态 |
value |
passed |
含义 |
场景 |
|---|---|---|---|---|
|
pass |
1.0 |
true |
判定通过 |
Code Grader 精确匹配成功 |
|
fail |
0.0 |
false |
判定不通过 |
Code Grader 匹配失败 |
|
unknown |
0 |
undefined |
无法判定 |
Model Grader LLM 没返回有效结果 |
|
pending |
0 |
undefined |
待人工复核 |
Human Grader 还没人审 |
TrialResult.allGradersPassed 的逻辑:所有 decisive(非 unknown/pending)的 Score 都 passed=true,且至少有一个 decisive。这意味着:如果一个 trial 的所有 grader 都返回 unknown,它不会被误判为"通过"。
7.2 CodeGrader:代码判定
CodeGrader 接收一个 ArkTS 回调函数,由你写精确判定逻辑:
export type CodeGradeFn = (context: GraderContext) => Score
export class CodeGrader implements Grader {
readonly name: string
private readonly fn: CodeGradeFn
readonly threshold: number
constructor(name: string, fn: CodeGradeFn, threshold: number = 1.0) {
this.name = name
this.fn = fn
this.threshold = threshold
}
async grade(context: GraderContext): Promise<Score> {
try {
const raw = this.fn(context)
return raw.applyPassThreshold(this.threshold, this.name)
} catch (e) {
const msg = e instanceof Error ? e.message : `${e}`
return Score.unknown(`code grader error: ${EvalSanitize.redactString(msg)}`, this.name)
}
}
}
两个要点:
第一,回调里的异常不会崩溃。 try-catch 把回调异常转成 Score.unknown——判分逻辑写错了,这个 trial 会被标记为"无法判定"而非"通过"或"崩溃"。
第二,只允许宿主编译期注册的回调。 ADR-0015 明确规定:JSON Suite 只能引用 grader 的名称,不能内联代码。这是安全红线——评估任务来自 JSON 文件,如果允许 JSON 里写代码并执行,等于任意代码执行漏洞。
真实用法(来自 EvalProviderRecordReplay.test.ets):
env.graderRegistry.register(new CodeGrader('file_ok', (ctx) => {
const exists = ctx.outcome.environmentState.get('exists')?.asBoolean() === true
const content = ctx.outcome.environmentState.get('content')?.asString() ?? ''
const expected = ctx.outcome.environmentState.get('expectedContent')?.asString() ?? ''
return exists && content === expected ? Score.pass('file') : Score.fail('file')
}))
7.3 ModelGrader:让 LLM 当裁判
ModelGrader 用另一个 LLM(judge LLM)来评估回答质量:
export class ModelGrader implements Grader {
readonly name: string
readonly rubric: string // ← 评分标准(自然语言)
readonly threshold: number
private readonly judgeClient?: LLMClient
private readonly judgeModel: string
async grade(context: GraderContext): Promise<Score> {
// ... 构造 prompt → 调 judgeClient → 解析返回 ...
const prompt = this.buildPrompt(context)
const request = new ModelRequest(
[new AgentMessage(MessageRole.user, [ContentPart.text(prompt)])],
new ModelConfig(this.judgeModel, undefined, undefined, undefined, undefined, true))
const response = await this.judgeClient.generate(request)
let text = ''
for (let i = 0; i < response.message.contents.length; i++) {
if (response.message.contents[i].text !== undefined) {
text += response.message.contents[i].text as string
}
}
return this.parseJudgeText(text).applyPassThreshold(this.threshold, this.name)
}
}
buildPrompt 构造的判分提示词结构:
You are an evaluation judge. Score the agent trial using the rubric.
Return ONLY a single JSON object: {"value":0.0-1.0,"rationale":"..."}
or {"unknown":true,"rationale":"..."}.
If you cannot judge fairly, return unknown (do not invent a score).
Rubric:
<你的评分标准>
Task: <task id> — <task description>
Reference solution: <参考答案>
Steps: <参考步骤>
Outcome: <trial 结果 JSON>
Transcript events: <事件数>, tools: <工具调用数>, turns: <轮数>
parseJudgeText 的容错解析是关键——judge LLM 的输出不总是规整的 JSON:
parseJudgeText(text: string): Score {
const trimmed = text.trim()
if (trimmed.length === 0) {
return Score.unknown('empty judge response', this.name)
}
// 尝试提取 JSON 对象
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = trimmed.substring(start, end + 1)
const parsed = JsonCodec.parseObject(slice)
if (parsed.ok && parsed.value !== undefined) {
const obj = parsed.value
if (obj.get('unknown')?.asBoolean() === true) {
return Score.unknown(obj.get('rationale')?.asString() ?? 'unknown', this.name)
}
const value = obj.get('value')?.asNumber()
return Score.fromValue(value, this.threshold, ...)
}
}
// JSON 解析失败:尝试 pass/fail 关键词
const lower = trimmed.toLowerCase()
if (lower === 'pass' || lower.indexOf('pass') === 0) {
return Score.fromValue(1, this.threshold, trimmed, this.name)
}
if (lower === 'fail' || lower.indexOf('fail') === 0) {
return Score.fromValue(0, this.threshold, trimmed, this.name)
}
// 都失败:返回 unknown(不臆造分数)
return Score.unknown(`unparseable judge output: ${EvalSanitize.redactString(trimmed)}`, this.name)
}
解析的降级链:JSON 提取 → pass/fail 关键词 → 纯数字 → unknown。最后兜底是 unknown——如果 judge LLM 返回的是乱码,宁可标"无法判定"也不臆造一个分数。
7.4 HumanGrader:人工复核
HumanGrader 用于需要人工判断的场景(如开放式问答):
export class HumanGrader implements Grader {
async grade(context: GraderContext): Promise<Score> {
const k = this.key(context.task.id, context.transcript.trialIndex)
let item = await this.queue.get(k)
if (item === undefined) {
// 第一次:入队,返回 pending
item = new HumanReviewItem(`rev_${k}`, context.task.id, context.transcript.trialIndex, this.name)
await this.queue.put(k, item)
return Score.pending(`queued human review ${item.reviewId}`, this.name)
}
if (item.decision === undefined || item.scoreValue === undefined) {
// 还没审:继续 pending
return Score.pending(`awaiting human review ${item.reviewId}`, this.name)
}
// 已审:返回分数
return Score.fromValue(item.scoreValue, this.threshold, item.note ?? item.decision, this.name)
}
async resolve(reviewId: string, scoreValue: number, note: string = ''): Promise<boolean> {
// 人工审完后调用,写入 queue
}
}
人工复核是异步的:第一次跑返回 pending(入队),人工审完后调 resolve() 写入分数,下次重跑时返回真实分数。HumanReviewQueue 有 Memory 和 File 两种实现——File 实现保证审过的分数不丢。
7.5 GraderRegistry:按名称注册
export class GraderRegistry {
private readonly graders: Map<string, Grader> = new Map<string, Grader>()
register(grader: Grader): void {
if (this.graders.has(grader.name)) {
throw new Error(`grader already registered: ${grader.name}`) // ← 重名报错
}
this.graders.set(grader.name, grader)
}
require(name: string): Grader {
const g = this.graders.get(name)
if (g === undefined) {
throw new Error(`unknown grader: ${name}`) // ← 找不到报错
}
return g
}
}
EvalTask 里存的是 graderNames: string[](名称数组),运行时通过 require(name) 查找。这让 JSON Suite 只引用名称,不内联代码——安全边界清晰。
7.6 三种 Grader 的决策矩阵
|
判分场景 |
用哪种 Grader |
为什么 |
|---|---|---|
|
精确匹配(文件内容、工具结果) |
CodeGrader |
确定性高,无需 LLM 调用 |
|
正则匹配(输出格式校验) |
CodeGrader |
自定义逻辑,精确 |
|
开放式问答质量评估 |
ModelGrader |
让 LLM 当裁判,处理语义 |
|
多轮对话连贯性 |
ModelGrader |
需要理解上下文 |
|
伦理/安全合规 |
HumanGrader |
关键判断不能交给 LLM |
|
主观体验(语气、风格) |
HumanGrader |
人工判断更可靠 |
|
回归测试(离线 CI) |
CodeGrader |
不打网络,确定性强 |
八、pass@k / pass^k:统计指标
单次 trial 的 pass/fail 不够——你需要统计意义上的指标。metrics/Metrics.ets 实现了两个核心指标。
8.1 pass@k:无偏估计
/**
* pass@k unbiased estimator:
* pass@k = 1 − C(n − c, k) / C(n, k)
* computed as 1 - product_{i=0..k-1} (n-c-i)/(n-i)
*/
export function passAtK(trialPasses: boolean[], k: number): number {
const n = trialPasses.length
if (k <= 0 || n === 0) { return 0.0 }
if (k > n) { return 0.0 }
let c = 0
for (let i = 0; i < n; i++) {
if (trialPasses[i]) { c++ }
}
if (n - c < k) { return 1.0 } // ← 通过数足够多,pass@k=1
let prob = 1.0
for (let i = 0; i < k; i++) {
prob *= (n - c - i) / (n - i) // ← 累乘避免大数组合爆炸
}
return 1.0 - prob
}
pass@k 的语义是"k 次尝试中至少 1 次通过的概率"。这是代码生成评估的经典指标(Codex/Anthropic 公式)。关键是用累乘形式计算,避免直接算组合数 C(n,k) 导致大数溢出。
测试验证了公式正确性:
it('given_sample_when_passAtK_then_matches_formula', 0, () => {
const trials = [true, false, true, false, false] // 5 次 trial,2 次通过
const p1 = passAtK(trials, 1)
expect(Math.abs(p1 - 0.4) < 1e-9).assertTrue() // ← pass@1 = 2/5 = 0.4
const p3 = passAtK(trials, 3)
expect(Math.abs(p3 - 0.9) < 1e-9).assertTrue() // ← pass@3 = 0.9
})
8.2 pass^k:严格幂次
/** pass^k: probability all k trials succeed (empirical: (c/n)^k). */
export function passCaretK(trialPasses: boolean[], k: number): number {
const n = trialPasses.length
if (k <= 0 || n === 0) { return 0.0 }
let c = 0
for (let i = 0; i < n; i++) {
if (trialPasses[i]) { c++ }
}
const p = c / n
let out = 1.0
for (let i = 0; i < k; i++) {
out *= p
}
return out
}
pass^k 的语义是"k 次全部通过的概率"——比 pass@k 严格得多。[true,false,true,false] 的 pass^2 = (2/4)² = 0.25。这个指标用于需要高可靠性的场景(如安全关键任务——每次都得对,不能靠"多试几次蒙对一个")。
8.3 两个指标的对比
|
指标 |
语义 |
公式 |
|
适用场景 |
|---|---|---|---|---|
|
pass@1 |
单次通过率 |
c/n |
0.4 |
一般能力评估 |
|
pass@3 |
3 次至少 1 次通过 |
1−C(3,3)/C(5,3) |
0.9 |
代码生成(可重试) |
|
pass^2 |
2 次都通过 |
(c/n)² |
0.16 |
高可靠性任务 |
|
pass^3 |
3 次都通过 |
(c/n)³ |
0.064 |
安全关键任务 |
8.4 ClassificationMetrics:分类指标
对于二分类场景(如"是否调用了正确工具"),还有 P/R/F1:
export class ClassificationMetrics {
static fromPredictions(gold: boolean[], pred: boolean[]): ClassificationMetrics {
let tp = 0, fp = 0, tn = 0, fn = 0
const n = Math.min(gold.length, pred.length)
for (let i = 0; i < n; i++) {
if (gold[i] && pred[i]) { tp++ }
else if (!gold[i] && pred[i]) { fp++ }
else if (!gold[i] && !pred[i]) { tn++ }
else { fn++ }
}
return new ClassificationMetrics(tp, fp, tn, fn)
}
precision(): number { ... }
recall(): number { ... }
f1(): number { ... }
accuracy(): number { ... }
}
零分母安全(precision/recall 在分母为 0 时返回 0,不抛异常),适合评估场景。
九、TraceExporter:可观测性导出
评估跑完了,你需要看数据。TraceExporter 是一个 SPI 接口,支持多种导出后端。
9.1 SPI 接口
export interface TraceExporter {
onRunStart(runName: string, suiteName: string): Promise<void>
onTrialStart(trial: Trial, task: EvalTask): Promise<void>
onLlmCall(trial: Trial, model: string, durationMs: number, error?: string): Promise<void>
onToolCall(trial: Trial, toolName: string, durationMs: number, error?: string): Promise<void>
onTrialEnd(trial: Trial, transcript: Transcript, outcome: Outcome,
scores: Map<string, Score>): Promise<void>
onRunEnd(runName: string, suiteName: string, aggregateScores: Map<string, number>): Promise<void>
flush(): Promise<void>
dispose(): Promise<void>
}
7 个回调覆盖了 run/trial/llm/tool 四个粒度。EvalRunner 在对应节点调用这些回调,exporter 自己决定怎么处理。
9.2 JsonlTraceExporter:文件导出
export class JsonlTraceExporter implements TraceExporter {
readonly lines: string[] = []
private push(obj: JsonObject): void {
this.lines.push(JsonCodec.stringify(
JsonValue.object(EvalSanitize.redactJsonObject(obj)))) // ← 导出前脱敏
}
async onTrialEnd(trial: Trial, transcript: Transcript, outcome: Outcome,
scores: Map<string, Score>): Promise<void> {
const scoresObj = new JsonObject()
scores.forEach((s: Score, name: string) => {
scoresObj.set(name, JsonValue.object(s.toJson()))
})
this.push(new JsonObject()
.set('type', JsonValue.string('trial_end'))
.set('trialKey', JsonValue.string(trial.id.cacheSalt()))
.set('status', JsonValue.string(trial.status))
.set('outcomeSuccess', JsonValue.boolean(outcome.success))
.set('eventCount', JsonValue.number(transcript.events.length))
.set('scores', JsonValue.object(scoresObj)))
}
}
JSONL(JSON Lines)格式——每行一个 JSON 对象,方便用 grep/jq 处理。FileJsonlTraceExporter 进一步把内存中的 lines 序列化写入文件,用 tmp+rename 原子写入。
9.3 LangfuseTraceExporter:可观测平台
export class LangfuseTraceExporter implements TraceExporter {
private readonly config: LangfuseConfig
private readonly transport: LangfuseHttpTransport // ← 可注入的 transport
async flush(): Promise<void> {
if (!this.config.enabled) { return }
// 合并上次失败的 batch 重试
const toSend = this.retainedOnFailure.concat(this.batch.splice(0, this.batch.length))
this.retainedOnFailure.length = 0
let offset = 0
while (offset < toSend.length) {
const chunk = toSend.slice(offset, offset + this.config.maxBatch) // ← 分批
offset += this.config.maxBatch
const ok = await this.sendChunk(chunk)
if (!ok) {
for (let i = 0; i < chunk.length; i++) {
this.retainedOnFailure.push(chunk[i]) // ← 失败保留,下次重试
}
}
}
}
private async sendChunk(chunk: JsonObject[]): Promise<boolean> {
const headers = new Map<string, string>()
headers.set('Authorization',
Base64.basicAuthHeader(this.config.publicKey, this.config.secretKey)) // ← Basic auth
// ... POST /api/public/ingestion ...
if (status < 200 || status >= 300) {
this.errors.push(`langfuse_http_${status}`)
this.retryCount++
return false
}
return true
}
}
三个要点:
第一,transport 可注入。 LangfuseHttpTransport 是接口,测试时用 FakeLangfuseTransport,真机用真实 HTTP transport。ADR-0015 规定:"网络失败只记错误,不使 Eval Run 失败"——所以 sendChunk 失败返回 false,不抛异常。
第二,失败保留 + 重试。 retainedOnFailure 缓存失败的 batch,下次 flush 时合并重试。网络抖动不会丢数据。
第三,Basic auth 不泄露。 Base64.basicAuthHeader 生成 Basic base64(public:secret),但 export 的 error 日志经过 EvalSanitize.redactString——Bearer/Basic token 会被替换成 ***。
9.4 CompositeTraceExporter:组合导出
export class CompositeTraceExporter implements TraceExporter {
private readonly exporters: TraceExporter[]
async onTrialEnd(trial: Trial, transcript: Transcript, outcome: Outcome,
scores: Map<string, Score>): Promise<void> {
for (let i = 0; i < this.exporters.length; i++) {
try {
await this.exporters[i].onTrialEnd(trial, transcript, outcome, scores)
} catch (_e) { } // ← 单个 exporter 失败不影响其他
}
}
}
组合模式让你同时导出到 JSONL(本地文件)和 Langfuse(云端)。每个 exporter 的异常被隔离——一个挂了不拖垮其他。
9.5 TraceExporter 选型矩阵
|
Exporter |
输出 |
网络 |
适用场景 |
|---|---|---|---|
|
NoopTraceExporter |
无 |
无 |
默认(不需要 trace 时) |
|
JsonlTraceExporter |
内存 lines |
无 |
单测、快速查看 |
|
FileJsonlTraceExporter |
JSONL 文件 |
无 |
本地持久化、CI 归档 |
|
LangfuseTraceExporter |
Langfuse API |
HTTPS |
团队协作、可视化看板 |
|
CompositeTraceExporter |
多个 |
取决于组合 |
同时本地 + 云端 |
十、EvalRunner:串联一切的运行器
EvalRunner 把前面所有组件串起来。看核心流程(精简版):
export class EvalRunner {
async runSuite(config: EvalRunConfig, suite: EvalSuite): Promise<EvalRunReport> {
const problems = suite.validate() // ← 先校验套件
if (problems.length > 0) {
throw new Error(`Invalid suite "${suite.name}": ${problems.join('; ')}`)
}
// ... 展开 work items(task × trials)...
const workerCount = Math.min(concurrency, workItems.length)
for (let w = 0; w < workerCount; w++) {
workers.push(runOne()) // ← 并发 worker
}
await Promise.all(workers)
const report = EvalRunReport.fromTrials(...) // ← 聚合报告
await this.exporter.onRunEnd(config.runName, suite.name, aggregate)
if (this.reportStore !== undefined) {
await this.reportStore.save(report) // ← 持久化
}
return report
// finally: flush exporter + flush recordingStore + reapDeferredCleanup
}
private async runTrial(...): Promise<TrialResult> {
context = await this.environment.prepare(trial, task) // ← 准备
session = await this.harnessFactory.createSession(context)
await this.rateLimitGate.acquire() // ← 限流
harnessResult = await this.withTimeout(session, context, timeoutMs) // ← 超时控制
scores = await this.gradeAll(task, transcript, outcome) // ← 评分
const passed = TrialResult.computePassed(outcome, scores)
await this.exporter.onTrialEnd(...)
await disposeAll() // ← 清理
return result
}
}
10.1 超时与取消的终止契约
withTimeout 是 EvalRunner 里最精巧的部分。它不是简单的 setTimeout + reject:
private withTimeout(session: HarnessSession, context: EvalContext,
timeoutMs: number): Promise<HarnessResult> {
const cancelDeadlineMs = EvalRunner.cancellationDeadlineMs(timeoutMs)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
context.cancelController.cancel(`timeout after ${timeoutMs}ms`)
// 等 harness 确认终止,或等 cancelDeadline 超时
session.cancelAndWait(reason, cancelDeadlineMs).then((term: CancelTermination) => {
if (!term.terminated) {
// harness 没在 deadline 内终止 → 标记延迟清理
this.environment.markCleanupDeferred(context,
term.deferredReason ?? 'harness_did_not_terminate')
reject(new Error(`... cleanup_deferred: ${...}`))
return
}
reject(new Error(`timeout after ${timeoutMs}ms task=${context.task.id}`))
})
}, timeoutMs)
// ...
})
}
static cancellationDeadlineMs(timeoutMs: number): number {
return Math.min(5_000, Math.max(200, timeoutMs)) // ← 200ms~5s
}
超时后的流程:cancel(发取消信号)→ cancelAndWait(等 harness 终止,最多等 cancelDeadline)→ 终止则正常 reject / 未终止则 markCleanupDeferred。
这个"终止契约"(CancelTermination)的设计很关键:
-
协作型 harness(cooperative):在 deadline 内停止副作用,返回
terminated=true。workspace 可以安全删除。 -
不协作型 harness:deadline 后还在挣扎,返回
terminated=false。workspace 不能删(harness 可能还在写)——标cleanup_deferred,lease 立刻 revoke(封锁后续写),目录等 suite 结束后统一 reap。
这保证了:EvalRunner 永远不会谎称"安全清理了"。如果不能确认终止,就老实标延迟清理。
10.2 评分的聚合逻辑
// TrialResult
static allGradersPassed(scores: Map<string, Score>): boolean {
let decisive = 0
let allPass = true
scores.forEach((score: Score) => {
if (score.unknown || score.pending || score.passed === undefined) {
return // ← unknown/pending 不参与判定
}
decisive++
if (score.passed !== true) {
allPass = false
}
})
return decisive > 0 && allPass // ← 至少一个 decisive + 全通过
}
static computePassed(outcome: Outcome, scores: Map<string, Score>): boolean {
if (!outcome.success) { return false }
return TrialResult.allGradersPassed(scores)
}
trial 通过的条件:outcome 成功 AND 所有 decisive grader 通过。unknown/pending 的 grader 不阻塞判定——这是合理的,因为"无法判定"不等于"不通过"。
十一、模块独立性:ADR-0015 的边界
@arkagent/eval 的模块边界由 ADR-0015 冻结,核心是三条:
11.1 依赖方向:单向
@arkagent/eval ──depends on──▶ @arkagent/core
│
└── agent_eval 独立 HAR 构建,独立 ohosTest
oh-package.json5 里写得很清楚:
{
"name": "@arkagent/eval",
"dependencies": {
"@arkagent/core": "1.0.0" // ← 只依赖 core
}
}
Core 不得依赖 Eval。 这意味着 agent_core 的代码里不能 import @arkagent/eval 的任何东西。如果 core 需要 eval 的能力(如 LLMClient 包装),core 只定义 SPI 接口(LLMClient),eval 提供实现(RecordingLLMClient/ReplayLLMClient)。
11.2 DTO 不外泄
Eval 的 DTO(EvalTask、Trial、Score、EvalRunReport)不进入 Core 的公共 API。Core 不知道"评估"的存在——它只知道 LLMClient、AgentController、ModelRequest 这些通用概念。这让 core 保持纯粹,不被评估逻辑污染。
11.3 独立构建与离线测试
agent_eval 能独立构建成 HAR,独立跑 ohosTest。它的测试(src/test/)覆盖了:
-
EvalRecordReplay.test.ets——录制/回放、哈希稳定性、HTTP=0 -
EvalProviderRecordReplay.test.ets——Provider 端到端(智谱/DeepSeek) -
EvalMetrics.test.ets——pass@k/pass^k 公式 -
EvalGraders.test.ets——三种 Grader -
EvalDisposeTimeout.test.ets——生命周期 + 超时 -
EvalSanitize.test.ets——脱敏 -
EvalRoundTrip.test.ets——录制数据往返
这些测试全离线(用 Fake transport + RecordingStore),不打真实网络。这是 CI 可持续跑评估的前提。
十二、最佳实践清单
12.1 Record/Replay 使用
-
✅ 先用
RecordingLLMClient录制一轮真实交互,store.flush()后提交录制文件 -
✅ 回放时用
ReplayLLMClient,硬断言httpCalls === 0 -
✅ 回放阶段用
RejectingHttpTransport包裹 transport,确保结构上不可能打网络 -
✅ trialSalt 用
trialId.cacheSalt()(taskId#trialIndex),保证多次 trial 隔离 -
✅ 录制文件纳入版本控制(JSON 格式,可 diff,可 review)
-
❌ 不要让
ReplayLLMClient持有 inner client——miss 时 fallback 到网络会破坏确定性 -
❌ 不要在回放阶段保留真实 API Key——离线测试不需要 Key
12.2 请求哈希
-
✅ 哈希覆盖完整语义(messages/tools/toolChoice/modelConfig/stream/jsonOutput/salt)
-
✅ 媒体内容用强摘要(
dataSha256+dataLength),绝不用 length-only -
✅ 只剥离明确的非语义键(默认只
timestamp) -
✅ 用
CanonicalJson做确定性序列化(递归 key 排序) -
❌ 不要用
JSON.stringify——对象 key 顺序不稳定 -
❌ 不要把
metadata(如 trace id)算进哈希——那是非语义的
12.3 Grader 选型
-
✅ 精确匹配场景用
CodeGrader(快、确定、不打网络) -
✅ 开放式评估用
ModelGrader(judge LLM,注意 parseJudgeText 的降级链) -
✅ 关键判断用
HumanGrader(人工复核,异步 resolve) -
✅ Grader 回调异常会被转成
Score.unknown——判分逻辑写错不会崩溃 -
✅ JSON Suite 只引用 grader 名称,grader 实现在宿主编译期注册
-
❌ 不要让 JSON Suite 内联代码——那是任意代码执行漏洞
-
❌ 不要让 judge LLM 的不可解析输出默认 pass——要返回 unknown
12.4 指标使用
-
✅ 一般能力评估用
pass@k(至少 1 次通过的概率) -
✅ 高可靠性场景用
pass^k(全部通过的概率,更严格) -
✅ 二分类用
ClassificationMetrics(P/R/F1) -
✅ trialsPerRun ≥ 10 时 pass@k 估计才稳定(样本量太小方差大)
-
❌ 不要用单次 trial 的 pass/fail 代表能力——模型是非确定性的
12.5 生命周期与资源
-
✅ 每个 trial 用独立的 workspace(
WorkspaceLease+ScopedFileSystem) -
✅ 超时后用
cancelAndWait确认终止,不协作则markCleanupDeferred -
✅ suite 结束后
reapDeferredCleanup统一清理延迟的 workspace -
✅ dispose 顺序:cancel → close controller → revoke lease → deleteTree
-
❌ 不要在 dispose 里假设 harness 已停止——用 lease 封锁后续写
12.6 可观测性
-
✅ 本地开发用
JsonlTraceExporter(内存,快) -
✅ CI 用
FileJsonlTraceExporter(JSONL 文件,归档) -
✅ 团队协作用
CompositeTraceExporter(JSONL + Langfuse) -
✅ 导出前统一过
EvalSanitize.redactJsonObject(脱敏) -
❌ 不要让 Langfuse 网络失败中断 Eval Run——ADR-0015 明确规定只记错误
十三、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
|
破坏离线确定性,CI 不稳定 |
构造函数只接收 store + salt,miss 直接抛 |
|
媒体哈希只用 |
等长不同内容碰撞,回放纵错 |
用 |
|
用 |
对象 key 顺序不稳定,哈希不确定 |
用 |
|
哈希不包含 |
同请求多次 trial 命中同一条录制 |
salt 用 |
|
JSON Suite 内联 grader 代码 |
任意代码执行漏洞 |
只引用 grader 名称,实现编译期注册 |
|
judge LLM 返回乱码时默认 pass |
不可解析输出被误判为通过 |
返回 |
|
超时后直接 deleteTree |
harness 可能还在写,误删 |
|
|
Langfuse 网络失败抛异常 |
中断整个 Eval Run |
返回 false, |
|
|
人工/judge 无法判定被误判 |
|
|
单次 trial pass 代表能力 |
模型非确定性,单次无意义 |
用 pass@k/pass^k,trialsPerRun ≥ 10 |
|
录制文件含真实 API Key |
泄露密钥 |
导出过 |
|
|
trial 间事件串扰 |
每个 trial prepare 新建 controller + cancel |
十四、验证清单(离线 + 真机 Review)
14.1 Record/Replay 验证
-
录制阶段:
RecordingLLMClient.innerCalls === 预期请求数 -
录制阶段:
store.size() === 预期录制条数 -
回放阶段:
ReplayLLMClient.httpCalls === 0 -
回放阶段:
RejectingHttpTransport.callCount === 0(端到端验证) -
回放阶段:
inner.callCount不增长(没打真实网络) -
回放结果与录制结果文本一致(
out.text === live.text) -
stream 回放:事件类型序列一致(
obs2.types匹配) -
stream 回放:toolCall 名称一致
-
错误回放:录制 error,回放也抛同 code 的 error
14.2 哈希稳定性验证
-
同请求同 salt:哈希相等(
a === b) -
同请求不同 salt:哈希不等
-
哈希长度 = 64(SHA-256 hex)
-
temperature 不同:哈希不等
-
tool schema 不同:哈希不等
-
toolChoice 不同:哈希不等
-
等长不同内容媒体:哈希不等
-
URI query 不同:哈希不等
-
timestamp 不同:哈希相等(timestamp 被剥离)
14.3 Grader 验证
-
CodeGrader:精确匹配 pass/fail 正确
-
CodeGrader:回调异常转
Score.unknown -
ModelGrader:JSON 解析正确(
{"value":0.8}) -
ModelGrader:pass/fail 关键词解析
-
ModelGrader:乱码返回
Score.unknown -
HumanGrader:首次返回
Score.pending -
HumanGrader:resolve 后返回真实分数
-
allGradersPassed:全 unknown 不算通过
14.4 指标验证
-
pass@k:
[T,F,T,F,F]的 pass@1 = 0.4 -
pass@k:同数组 pass@3 = 0.9
-
pass^k:
[T,F,T,F]的 pass^2 = 0.25 -
ClassificationMetrics:P/R/F1 计算正确
-
零分母:precision/recall/f1 返回 0 不抛异常
14.5 生命周期验证
-
prepare:每个 trial 有独立 resourceId
-
dispose:幂等(重复调用不报错)
-
超时:cancelAndWait 协作终止 → 正常 cleanup
-
超时:不协作 →
cleanup_deferred+ lease revoked -
suite 结束:
reapDeferredCleanup清理延迟目录 -
workspace 清理:
workspaceCleanupCount准确
14.6 可观测性验证
-
JSONL:每行一个合法 JSON 对象
-
JSONL:导出数据不含 Bearer token(
indexOf('Bearer') < 0) -
JSONL:不含
sk-开头的 key -
Langfuse:
FakeLangfuseTransport捕获到 POST -
Langfuse:失败 batch 进
retainedOnFailure -
Langfuse:Basic auth header 正确生成
-
Composite:单个 exporter 异常不阻断其他
十五、构建验证
@arkagent/eval 作为独立 HAR 模块,构建命令:
# 设置 SDK 环境(如需)
export DEVECO_SDK_HOME=/path/to/sdk
# 在 agent_eval 目录下独立构建 HAR
cd /Users/wangzhe/work/app/ArkAgent/agent_eval
hvigorw assembleHar --no-daemon
构建结果:
> Task :agent_eval:CompileArkTS
> Task :agent_eval:PackageHar
BUILD SUCCESSFUL
模块独立性验证(ADR-0015 边界):
# 确认 core 不依赖 eval(反向依赖检测)
grep -r "@arkagent/eval" /Users/wangzhe/work/app/ArkAgent/agent_core/src/
# 预期:无输出(core 不引用 eval)
离线测试验证(HTTP=0 的核心承诺):
# 跑 eval 模块的单测(全离线,用 Fake transport)
cd /Users/wangzhe/work/app/ArkAgent
hvigorw :agent_eval:ohosTest --no-daemon
测试覆盖:
EvalRecordReplay.test.ets 录制/回放、哈希稳定性、HTTP=0 ✅
EvalProviderRecordReplay.test.ets 智谱/DeepSeek 端到端(Fake transport) ✅
EvalMetrics.test.ets pass@k / pass^k / ClassificationMetrics ✅
EvalGraders.test.ets Code/Model/Human Grader ✅
EvalDisposeTimeout.test.ets 生命周期 + 超时 + 延迟清理 ✅
EvalSanitize.test.ets 脱敏(Bearer/APIKey/dataURL) ✅
EvalRoundTrip.test.ets 录制数据往返一致性 ✅
EvalLoader.test.ets 套件加载 + Grader 注册 ✅
EvalReporting.test.ets 报告生成 + 持久化 ✅
EvalThreshold.test.ets 阈值判定 ✅
EvalStreamErrorReplay.test.ets 流式错误回放 ✅
EvalRuntimeHarness.test.ets Runtime harness ✅
EvalFileSystemSpi.test.ets FS SPI ✅
EvalCore.test.ets 核心类型 ✅
关键测试断言(EvalRecordReplay.test.ets 的 HTTP=0 验证):
given_record_then_replay_when_generate_then_http_zero
✓ live.message.contents[0].text === 'hello-recorded'
✓ rec.innerCalls === 1
✓ store.size() === 1
✓ replay.httpCalls === 0 ← 核心承诺
✓ inner.callCount === 1 ← 回放没打真实网络
端到端验证(EvalProviderRecordReplay.test.ets,用 RejectingHttpTransport 包裹):
given_zhipu_fake_transport_when_live_record_replay_then_http_zero_and_env_match
✓ liveReport.passedTrials === 1
✓ replayReport.passedTrials === 1
✓ reject.callCount === 0 ← 回放阶段 transport 被调 0 次
✓ counting.callCount === liveHttp ← 总 HTTP = 录制时的数
十六、写在最后
AI 应用的评估不是"跑一下看看感觉"——它是需要确定性、可复现、多维度、解耦的工程体系。在鸿蒙上做这套体系,核心挑战是:模型非确定性、网络不可控、判分无标准答案、模块不能耦合。ArkAgent 的 @arkagent/eval 用四个设计回应了这四个挑战:
Record/Replay → 确定性(回放 HTTP=0,结构上不可能打网络)
SHA-256 哈希 → 可复现(canonical JSON + trial salt,请求匹配无歧义)
三种 Grader → 多维度(Code 精确 / Model 语义 / Human 主观)
+ pass@k / pass^k 统计指标
ADR-0015 边界 → 解耦(eval 只依赖 core,DTO 不外泄,独立构建)
记住这几条口诀:
评估不是跑一次,统计指标才有义。 pass@k 看概率,pass^k 要全过。
录制一次真交互,回放永远零请求。 ReplayClient 不持 inner,结构上就断网络。 miss 直接抛错误,fallback 是毒药。
请求哈希全覆盖,messages 和 tools 都要算。 canonical 排好序,key 顺序不影响。 媒体要用强摘要,length-only 必碰撞。 trial salt 不能少,多次 trial 要区分。
Code 判精确 Model 判语义,Human 留给关键题。 Judge 乱码不臆造,unknown 比假 pass 强。 JSON 只写 grader 名,内联代码是漏洞。
prepare 建租约,dispose 先 cancel。 超时不协作不谎报,deferred 等到 suite 末。 lease 一 revoke 写不动,workspace 不被误删。
Eval 不进 Core 的 API,依赖只往一边走。 独立 HAR 独立测,CI 离线跑得稳。
本文是 ArkAgent 鸿蒙教程系列的第十二篇。前面的文章讲了架构、接入、流式、JSON、工具调用、状态持久化、控制安全——这些都是"让 Agent 跑起来"的能力。这篇讲的评估框架是"让 Agent 可信"的能力:没有可复现的评估,AI 应用的质量就是玄学。如果你正在鸿蒙上做需要持续迭代的 AI 应用,把 Record/Replay + pass@k 纳入 CI——这是从"demo 级"到"产品级"的分水岭。
更多推荐



所有评论(0)