一、前言:AI 评估的"四个不可能"

假设你在鸿蒙上做完了一个 AI 助手,现在要交付。QA 问你:"这个 Agent 的工具调用成功率是多少?" 你的直觉是写个测试跑一遍。但你立刻撞上四个难题:

  1. 模型回答不确定。同一个 prompt "帮我查烘焙记录",跑 10 次模型可能 8 次成功调用工具、2 次走文本兜底。你测一次通过不代表"通过了"——你需要的是统计意义上的通过率,而不是一个布尔值。

  2. 每次评测都打网络太贵太慢。一个 capability 套件如果有 50 个 task、每个 task 跑 3 次 trial、每次 trial 可能打 3~5 个 LLM 请求——一轮评测就是几百次 HTTPS 往返。真机上每次请求 1~3 秒,一轮评测半小时,CI 跑不起。而且网络抖动会导致"同一个测试昨天过今天不过"。

  3. 判断"答得好不好"没有标准答案。模型回了一段长文本,你怎么判定它"答对了"?精确匹配(exact match)太死——模型多一个字就挂;正则又太宽——匹配上了不代表语义正确。你需要 Code Grader(精确逻辑)、Model Grader(让另一个 LLM 当裁判)、Human Grader(人工复核)三种手段组合。

  4. 评估不能污染主路径。评估框架要能独立构建、独立测试,不能让 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 代码不用改一行,只是把 LLMClientRecordingLLMClient 换成 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 有自己的 AgentControllerCancellationController。一个 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 框架最有价值的能力。RecordingLLMClientReplayLLMClient 让你"录一次真实交互,之后无限次离线回放"。

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,只接收 storetrialSalt。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
}

哈希覆盖的语义字段:

字段

为什么必须进哈希

messages(含 tool calls/results)

消息内容不同,回答必然不同

tools(完整 JSON Schema)

工具定义变了,模型行为变了

toolChoice

auto/required/none 决定模型是否必须调工具

modelConfig(model/temperature/maxTokens/topP/...)

temperature 不同,输出完全不同

stream

stream 和 generate 的响应形态不同

jsonOutput

是否强制 JSON 输出影响回答格式

trialSalt

同一请求的不同 trial 要区分

timestamp

非语义字段,必须剥离

只剥离 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=aaa vs ?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#0task#1task#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 两个指标的对比

指标

语义

公式

[T,F,T,F,F] 的值

适用场景

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(EvalTaskTrialScoreEvalRunReport)不进入 Core 的公共 API。Core 不知道"评估"的存在——它只知道 LLMClientAgentControllerModelRequest 这些通用概念。这让 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 明确规定只记错误


十三、常见错误对照表

错误做法

问题

正确做法

ReplayLLMClient 持有 inner,miss 时 fallback

破坏离线确定性,CI 不稳定

构造函数只接收 store + salt,miss 直接抛 replay_miss

媒体哈希只用 dataLength

等长不同内容碰撞,回放纵错

dataSha256 + dataLength 强摘要

JSON.stringify 算哈希

对象 key 顺序不稳定,哈希不确定

CanonicalJson.stringifyObject(递归排序)

哈希不包含 trialSalt

同请求多次 trial 命中同一条录制

salt 用 trialId.cacheSalt()

JSON Suite 内联 grader 代码

任意代码执行漏洞

只引用 grader 名称,实现编译期注册

judge LLM 返回乱码时默认 pass

不可解析输出被误判为通过

返回 Score.unknown,不臆造分数

超时后直接 deleteTree

harness 可能还在写,误删

cancelAndWait 确认终止,未终止则 markCleanupDeferred

Langfuse 网络失败抛异常

中断整个 Eval Run

返回 false,retainedOnFailure 保留重试

score.unknown 当作 fail

人工/judge 无法判定被误判

allGradersPassed 排除 unknown/pending

单次 trial pass 代表能力

模型非确定性,单次无意义

用 pass@k/pass^k,trialsPerRun ≥ 10

录制文件含真实 API Key

泄露密钥

导出过 EvalSanitize,Bearer/sk- 替换为 ***

EvalEnvironment 复用 controller

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 级"到"产品级"的分水岭。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐