Skip to content

测试 · 评估 · 可观测体系

harness9 的质量保障体系由三个相互独立但协同工作的子系统构成,共同回答一个核心问题:这个 Agent 是否真的在正确地工作?

开发阶段 ──→ Test(确定性测试)      ScriptedProvider + Assertion
CI 阶段  ──→ Eval(黄金数据集评估)  16 个用例 + Quality Gate
生产阶段 ──→ Observability(追踪)  OTEL Traces + Metrics → Langfuse

一、设计哲学

1.1 为什么 Agent 需要专门的测试体系?

传统软件的单元测试假设:给定相同输入,总得到相同输出。Agent 系统打破了这一假设——LLM 的输出天然非确定性,同一 prompt 在不同会话下可能产生截然不同的行为路径。

挑战传统测试的失效原因harness9 的解法
非确定性Mock 不了真实 LLM 行为ScriptedProvider 将行为脚本化,使测试确定可重复
行为验证断言返回值不够,要验证「做了什么」recordingHook + Assertion 框架验证工具调用轨迹
性能退化没有 baseline 就发现不了退化黄金数据集 + CI Quality Gate,每次 PR 自动对比
生产可见性没有工具看清 Agent 在做什么OTEL 链路追踪,每次 LLM 调用和工具执行都可视化

1.2 三层金字塔模型

          ┌─────────────────────────────┐
          │   Observability(可观测)    │  ← 生产环境:OTEL Traces + Metrics
          │   看清 Agent 在做什么        │    接入 Langfuse / Grafana / Jaeger
          └──────────────┬──────────────┘

          ┌──────────────▼──────────────┐
          │   Eval(评估)               │  ← CI/CD:黄金数据集 Quality Gate
          │   量化 Agent 能力边界        │    16 个用例,PR 触发,失败阻断合并
          └──────────────┬──────────────┘

          ┌──────────────▼──────────────┐
          │   Test(测试)               │  ← 开发阶段:ScriptedProvider + Assertion
          │   验证 Agent 行为的正确性    │    确定性、Hermetic 隔离、无 API Key 依赖
          └─────────────────────────────┘

1.3 非侵入设计原则

harness9 的核心引擎(engine / provider / hooks)不感知任何测试或可观测逻辑。所有能力通过三个已有扩展点无缝接入:

┌─────────────────────────────────────────────────────────────┐
│                     AgentEngine(核心引擎)                   │
│  EngineObserver ← 唯一新增接口(4 处生命周期回调)  [接入点 1] │
└──────────────────────────────────────────────────────────────┘
         ↑ WithEngineObserver 注入

┌────────┴──────────┐   ┌──────────────────────────────────────┐
│ OTELEngineObserver│   │ TracingProvider [接入点 2]             │
│ Interaction Span  │   │ 包装 LLMProvider,LLM Request Span     │
│ Turn Span         │   │ + Token Metrics + Input/Output 上报   │
└───────────────────┘   └──────────────────────────────────────┘
                                      ↑ 替换原始 provider
┌──────────────────────────────────────────────────────────────┐
│ ObservabilityHook [接入点 3]                                   │
│ 实现 ToolHook,Tool Execution Span + Tool Metrics              │
│ 注册到 HookRegistry 末端(纯观测,不干预工具决策)              │
└──────────────────────────────────────────────────────────────┘

二、Test 子系统

2.1 架构总览

internal/evals/
├── provider.go       ScriptedProvider — 确定性 LLM mock
├── assertions.go     Assertion 接口 + Case/Result 类型 + 8 种断言
├── harness.go        RunCase / Suite / recordingHook
├── testenv.go        SetupHermeticEnv — 标准 Hermetic 隔离
├── report.go         SuiteReport / BuildReport / WriteJSON / WriteMarkdown
└── dataset/
    ├── tool_calling_test.go    工具调用准确性(4 用例)
    ├── planning_test.go        Planning 完成率(4 用例)
    ├── context_test.go         Context Engineering 连贯性(3 用例)
    ├── error_handling_test.go  Error Handling / Self-Healing(3 用例)
    └── memory_test.go          Memory 持久化(2 用例)

2.2 ScriptedProvider:把 LLM 行为脚本化

ScriptedProvider 是 eval 框架的基石,实现 provider.LLMProvider 接口,按预设的 ScriptedTurn 序列返回确定性回复,不发起任何网络请求。

go
// 脚本:第一轮发起 bash 工具调用,第二轮返回文本结论
p := evals.NewScriptedProvider(
    evals.ScriptedTurn{
        ToolCalls: []schema.ToolCall{
            evals.MakeToolCall("tc1", "bash", `{"command":"ls -la"}`),
        },
    },
    evals.ScriptedTurn{Text: "目录中有 3 个文件。"},
)
机制说明
Turn 序列每次 Generate 消费一个 ScriptedTurn,耗尽后返回默认终止回复
录制调用所有 LLM 调用都被记录到 calls []RecordedCall,供 Assertion 验证
Err 注入ScriptedTurn{Err: err} 模拟 LLM API 失败,测试引擎自愈能力
线程安全内部互斥锁,goroutine 并发调用无竞争

2.3 Assertion 框架

断言分为 Hard(失败则 Case 不通过)和 Soft(仅记警告)两类:

Assertion
├── Hard Assertions(失败 → Passed=false)
│   ├── ToolCalledAssertion{ToolName, MinTimes}   工具被调用 >= N 次
│   ├── ToolNotCalledAssertion{ToolName}           工具一次都没被调用
│   ├── OutputContainsAssertion{Expected}          最终输出包含期望字符串
│   ├── OutputExcludesAssertion{Forbidden}         最终输出不含禁止字符串
│   ├── NoErrorAssertion{}                         RunError == nil
│   └── ErrorAssertion{}                           RunError != nil(测试错误路径)
└── Soft Assertions(失败 → Warnings,不影响 Passed)
    ├── MaxTurnsAssertion{Max}                     Turn 数 <= Max(效率告警)
    └── MaxToolCallsAssertion{Max}                 工具调用次数 <= Max(效率告警)

recordingHookHookRegistry.BeforeExecute 阶段记录工具名——在 registry 查找之前触发,无论工具是否注册都能正确捕获 LLM 的调用意图。

2.4 EvalHarness:最小化引擎环境

RunCase 为每个 Case 构建完全隔离的最小化 AgentEngine

RunCase(c *Case) Result

    ├── 确定工作目录(c.WorkDir 或自动创建临时目录,defer 清理)
    ├── 注册四个基础工具(read_file / write_file / bash / edit_file)
    ├── 挂载 recordingHook(记录工具名,位于 HookRegistry 最前端)
    ├── engine.NewAgentEngine(c.Provider, hookReg, workDir, WithMaxTurns(c.MaxTurns))
    │       ← ScriptedProvider + 无 Session + 无 Compactor(保证确定性)
    ├── eng.Run(ctx, c.Prompt)
    └── 逐一执行 c.Assertions → 聚合 Failures / Warnings → Result.Passed

不使用 Session 和 Compactor 是关键决策——排除持久化和压缩带来的非确定性,保证相同脚本总产生相同结果。

2.5 Hermetic 测试隔离

go
func TestMyFeature(t *testing.T) {
    evals.SetupHermeticEnv(t)  // 必须首行调用

    c := &evals.Case{
        ID:       "feature/basic",
        Category: "feature",
        Prompt:   "运行 ls 命令",
        Provider: evals.NewScriptedProvider(
            evals.ScriptedTurn{
                ToolCalls: []schema.ToolCall{
                    evals.MakeToolCall("tc1", "bash", `{"command":"ls"}`),
                },
            },
            evals.ScriptedTurn{Text: "命令已执行。"},
        ),
        Assertions: []evals.Assertion{
            &evals.ToolCalledAssertion{ToolName: "bash"},
            &evals.NoErrorAssertion{},
            &evals.MaxTurnsAssertion{Max: 3}, // soft:效率告警
        },
    }

    result := evals.RunCase(context.Background(), c)
    if !result.Passed {
        for _, f := range result.Failures {
            t.Errorf("❌ %s", f.Error())
        }
    }
    for _, w := range result.Warnings {
        t.Logf("⚠️ %s", w.Error())
    }
}

SetupHermeticEnv 清除所有 _API_KEY_TOKEN_SECRET 后缀的环境变量,防止 eval 测试因环境中存在真实 API Key 而意外调用付费服务,保证本地与 CI 环境行为完全一致。


三、Eval 子系统:黄金数据集

3.1 当前黄金数据集(16 个用例)

类别用例验证目标
tool_callingbash_basicbash 工具被正确调用
tool_callingread_fileread_file 工具被正确调用
tool_callingwrite_then_read多工具顺序调用(write → read)
tool_callingno_tool_conversation纯对话不触发工具调用
planningplan_generatedtodo_write 写入计划
planningno_write_in_plan_mode规划阶段不调用 write_file/edit_file
planningplan_then_execute先生成计划再执行(完整 Planning 链路)
planningexploration_only纯探索模式只用只读工具
contextsequential_tool_chain多步工具调用依赖上一步 Observation
contextmulti_turn_conversation多轮纯对话连贯性
contexttool_error_observation工具失败 Observation 驱动 LLM 改变策略
error_handlingbash_fallback_on_error工具失败后 LLM 切换替代方案(Self-Healing)
error_handlingwrite_failure_graceful_stop写入失败后优雅降级不重试
error_handlingmax_turns_protectionMaxTurns 触发引擎受控终止(不 panic)
memorywrite_memorymemory_write 工具被调用
memorysearch_memorymemory_search 工具被调用

3.2 运行 Eval

bash
# 运行全量黄金数据集(16 个用例,无需 API Key)
go test ./internal/evals/... ./internal/evals/dataset/... -v

# 只运行特定类别
go test ./internal/evals/dataset/... -v -run TestToolCalling
go test ./internal/evals/dataset/... -v -run TestPlanning
go test ./internal/evals/dataset/... -v -run TestContextEngineering
go test ./internal/evals/dataset/... -v -run TestErrorHandling
go test ./internal/evals/dataset/... -v -run TestMemory

# 生成 JSON + Markdown 报告
results := suite.Run(ctx)
report  := evals.BuildReport(results)
evals.WriteJSON(report, "eval-report.json")
evals.WriteMarkdown(report, "eval-report.md")

3.3 新增 Eval 用例的规范

Feature 开发完成后,必须internal/evals/dataset/ 下新增对应的黄金用例(参见 AGENTS.md §5.8 测试与评估规范):

  • 每个功能至少覆盖正向用例(功能正常工作)和反向用例(约束被正确执行)
  • SetupHermeticEnv 首行调用,NoErrorAssertionErrorAssertion 必选
  • 扩展数据集只需新增 _test.go 文件,无需修改框架代码
  • 当前 16 个用例是 baseline,只能增加,不能删除或降低覆盖率

四、CI/CD 质量门控

4.1 流水线设计

PR 触发(push to master / pull_request)


  unit-tests job
  └── go test ./...  ← 全量单元测试(含 observability + evals)

       ▼ needs: unit-tests
  eval job(Quality Gate)
  ├── 环境:OPENAI_API_KEY=""  ANTHROPIC_API_KEY=""  HARNESS9_EVAL_HERMETIC=1
  │          OTEL_ENABLED=false(CI 中关闭 OTEL 上报)
  ├── go test ./internal/evals/... ./internal/evals/dataset/... -v
  ├── 结果上传为 Artifact(保留 30 天)
  └── 摘要写入 GitHub Step Summary

Quality Gatecontinue-on-error: false——eval 失败则 CI 失败,PR 无法合并。

Hermetic 保障

  1. 不产生任何真实 LLM API 费用
  2. 测试结果完全确定,无随机波动
  3. 任何行为退化都来自代码变更,而非 LLM 版本更新

五、Observability 子系统

5.1 Span 层次结构

harness9 的每一次 Agent 运行产生一棵完整的 Span 树:

harness9.interaction   [session.id="abc123",  langfuse.trace.input="用户 prompt"]
│   duration: 12.4s

├── harness9.turn   [agent.turn=1]
│   │   duration: 3.2s
│   │
│   ├── harness9.llm_request   [gen_ai.request.model="anthropic/claude-sonnet-4.6"]
│   │       langfuse.observation.input  = [{"role":"system",...},{"role":"user",...}]
│   │       langfuse.observation.output = "LLM 回复文本或工具调用 JSON"
│   │       gen_ai.usage.input_tokens=4821, gen_ai.usage.output_tokens=312
│   │       duration: 2.1s
│   │
│   ├── harness9.tool   [tool.name="bash", tool.success=true]
│   │       langfuse.observation.input  = {"command":"ls -la"}
│   │       langfuse.observation.output = "total 24\n..."
│   │       duration: 0.8s
│   │
│   └── harness9.tool   [tool.name="read_file", tool.success=true]
│           duration: 0.1s

└── harness9.turn   [agent.turn=2, turn.has_tool_calls=false]
    └── harness9.llm_request   [...]
            duration: 2.6s

5.2 三组件实现原理

OTELEngineObserver — Interaction + Turn Span

runLoop 在 4 个生命周期点回调 EngineObserver

runLoop 入口 → OnInteractionStart(ctx, sessionID, prompt)
               返回携带 interaction Span 的增强 ctx

for 每个 Turn:
  → OnTurnStart(ctx, turn)    返回携带 turn Span 的 turnCtx
    em.generate(turnCtx, ...)   ← LLM 调用继承 turn Span
    e.executeTools(turnCtx, ...) ← 工具执行继承 turn Span
  → OnTurnEnd(turnCtx, turn, hasToolCalls)

runLoop 退出(defer 保证)
  → OnInteractionEnd(ctx, turns, err)
    → span.End() + ForceFlush()   ← 立即推送到后端

关键设计OnInteractionStartOnTurnStart 双写 Span(OTEL 标准 slot + 自定义 key),确保在中间层代码(compaction、session 加载)可能替换 ctx 时,父子关系链路不会断开。

TracingProvider — LLM Request Span + Token Metrics

go
func (p *TracingProvider) GenerateStream(ctx context.Context, ...) {
    ctx, span := p.tracer.Start(ctx, SpanLLMRequest)  // ctx 含 turn Span,自动嵌套
    span.SetAttributes(attribute.String(AttrLangfuseObsInput, serializeMessages(messages)))

    ch, _ := p.inner.GenerateStream(ctx, ...)
    go func() {
        defer span.End()
        // 等待 StreamChunkDone,提取 Usage 和最终回复
        span.SetAttributes(attribute.String(AttrLangfuseObsOutput, serializeOutput(lastMsg)))
        p.recordMetrics(ctx, span, lastUsage, elapsed, nil)
    }()
}

ObservabilityHook — Tool Execution Span

go
func (h *ObservabilityHook) BeforeExecute(ctx context.Context, tc schema.ToolCall) (...) {
    var span trace.Span
    ctx, span = h.tracer.Start(ctx, SpanToolExecution, ...)
    span.SetAttributes(attribute.String(AttrLangfuseObsInput, truncateAttr(string(tc.Arguments))))
    return ctx, hooks.Allow(), nil  // 始终放行,不干预工具决策
}

func (h *ObservabilityHook) AfterExecute(ctx context.Context, tc schema.ToolCall, result schema.ToolResult) schema.ToolResult {
    span := trace.SpanFromContext(ctx)
    span.SetAttributes(attribute.String(AttrLangfuseObsOutput, truncateAttr(result.Output)))
    span.End()
    h.toolDuration.Record(...)   // Histogram
    h.toolCallsTotal.Add(...)    // Counter,by name + status
    return result                // 透传,不修改
}

5.3 Metrics 体系

指标名类型说明
harness9.llm.request.durationHistogramLLM API 请求延迟(秒)
harness9.llm.tokens.inputCounter累计输入 Token
harness9.llm.tokens.outputCounter累计输出 Token
harness9.tool.calls.totalCounter工具调用次数(by name + status)
harness9.tool.execution.durationHistogram工具执行耗时
harness9.agent.turns.totalCounterAgent Turn 总数

5.4 OTEL SDK 初始化与配置

Setup(ctx, cfg)
├── cfg.Enabled=false 或 ExporterNoop  → 零开销 noopProviders()
├── ExporterStdout                     → stdouttrace(本地调试,写 stderr)
└── ExporterOTLP
        ├── 显式拼接 /v1/traces(不依赖 SDK 自动追加,版本间行为差异)
        ├── 显式传 WithHeaders(不依赖 SDK 读 env var,保证可靠性)
        ├── https:// → TLS,http:// → 不加密
        └── 全局 OTEL error handler 写 stderr(绕过 TUI io.Discard)
环境变量默认值说明
OTEL_ENABLEDfalsetrue 启用
OTEL_SERVICE_NAMEharness9服务名
OTEL_EXPORTER_TYPEnoopnoop / stdout / otlp
OTEL_EXPORTER_OTLP_ENDPOINTbase URL,如 https://us.cloud.langfuse.com/api/public/otel
OTEL_EXPORTER_OTLP_HEADERSkey=val,key2=val2,用于 Langfuse Authorization header

六、接入观测平台

6.1 接入 Langfuse(推荐)

Langfuse 是专为 LLM 应用设计的可观测平台,原生支持 OpenTelemetry,提供 Trace 可视化、Token 费用分析、会话回放。

步骤一:注册账号并获取 API Key

  1. 前往 cloud.langfuse.com 注册
  2. 进入项目 → Settings → API Keys → 点击 Create new API key
  3. 复制 Public Keypk-lf-...)和 Secret Keysk-lf-...,仅显示一次)

步骤二:生成 Base64 认证字符串

bash
# macOS / Linux
AUTH=$(echo -n "pk-lf-YOUR_PUBLIC_KEY:sk-lf-YOUR_SECRET_KEY" | base64)

# GNU/Linux(较长 key 防折行)
AUTH=$(echo -n "pk-lf-YOUR_PUBLIC_KEY:sk-lf-YOUR_SECRET_KEY" | base64 -w 0)

步骤三:配置并启动

写入 .env(已在 .gitignore,不会进入 Git 仓库):

bash
OTEL_ENABLED=true
OTEL_EXPORTER_TYPE=otlp

# 选择所在区域
OTEL_EXPORTER_OTLP_ENDPOINT=https://us.cloud.langfuse.com/api/public/otel   # US 区域
# OTEL_EXPORTER_OTLP_ENDPOINT=https://cloud.langfuse.com/api/public/otel    # EU 区域
# OTEL_EXPORTER_OTLP_ENDPOINT=https://jp.cloud.langfuse.com/api/public/otel # JP 区域

OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <YOUR_BASE64_AUTH>,x-langfuse-ingestion-version=4

然后运行 ./harness9,对话产生工具调用后,Langfuse Traces 标签即可看到如下结构:

Trace: harness9.interaction
│   Input  = "用户 prompt"

└── Generation: harness9.llm_request
│       Input  = [{"role":"system",...},{"role":"user",...}]
│       Output = "LLM 回复"
│       25,269 prompt → 1,027 completion  ← 自动费用估算
│       anthropic/claude-sonnet-4.6
└── Span: harness9.tool  (bash)
        Input  = {"command":"ls -la"}
        Output = "total 24\n..."

常见问题排查

症状原因解决方案
控制台无数据AUTH 编码含换行符echo $AUTH 确认输出,或加 -w 0
401 UnauthorizedKey 顺序错误必须是 pk-lf-...:sk-lf-...(Public Key 在前)
Trace 出现但有延迟缺少 ingestion-version header在 headers 中加 x-langfuse-ingestion-version=4
区域连不上endpoint 选错确认账号注册区域(EU/US/JP),切换对应 endpoint
Input/Output 显示 null旧版 langfuse.input/output 属性名已修复(v4 使用 langfuse.trace.* / langfuse.observation.*
trace 导出失败(no data)工具输出含非法 UTF-8 字节已修复(truncateAttr 自动净化)

6.2 接入 Jaeger(本地开发)

bash
# 启动 Jaeger(all-in-one,含 OTLP HTTP 接收端)
docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one

export OTEL_ENABLED=true
export OTEL_EXPORTER_TYPE=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
./harness9

# 打开 http://localhost:16686 → 搜索 Service: harness9

6.3 接入 Grafana + Tempo

bash
export OTEL_ENABLED=true
export OTEL_EXPORTER_TYPE=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318  # Tempo OTLP HTTP 端口
./harness9

6.4 本地调试(stdout 导出器)

bash
export OTEL_ENABLED=true
export OTEL_EXPORTER_TYPE=stdout
./harness9
# Span 数据以 JSON 格式打印到 stderr

七、模块文件索引

文件职责
internal/engine/observer.goengineEngineObserver 接口 + noopObserver(零开销空实现)
internal/observability/config.goobservabilityConfig 结构体 + ConfigFromEnv()(含 parseOTLPHeaders)
internal/observability/attributes.goobservabilitySpan 名称、Metric 名称、Langfuse v4 属性键常量
internal/observability/setup.goobservabilityOTEL SDK 初始化(SetupNewNoopProviders、ForceFlush 绑定)
internal/observability/observer.goobservabilityOTELEngineObserver(Interaction + Turn Span,双写保证链路)
internal/observability/provider.goobservabilityTracingProvider(LLM Request Span + Token Metrics)
internal/observability/hook.goobservabilityObservabilityHook(Tool Execution Span + Tool Metrics)
internal/observability/helpers.goobservabilityserializeMessages / serializeOutput / truncateAttr(UTF-8 净化)
internal/evals/provider.goevalsScriptedProvider(确定性 mock,线程安全)
internal/evals/assertions.goevalsAssertion 接口 + Case / Result 类型 + 8 种断言(Hard/Soft)
internal/evals/harness.goevalsRunCase / Suite / recordingHook(临时目录自动清理)
internal/evals/testenv.goevalsSetupHermeticEnv()(标准 Hermetic 隔离,t.Setenv 自动恢复)
internal/evals/report.goevalsBuildReport / WriteJSON / WriteMarkdown(分类统计 + 详细结果)
internal/evals/dataset/tool_calling_test.godataset工具调用准确性(4 用例)
internal/evals/dataset/planning_test.godatasetPlanning 完成率(4 用例)
internal/evals/dataset/context_test.godatasetContext Engineering(3 用例)
internal/evals/dataset/error_handling_test.godatasetError Handling / Self-Healing(3 用例)
internal/evals/dataset/memory_test.godatasetMemory 持久化(2 用例)
.github/workflows/eval.ymlCIGitHub Actions Quality Gate

Released under the MIT License.