加载中...
  • 如何验证一个 Agent:可观测性、测试与 Eval loading

    吃透 AI Agent 开发 · 第 31 篇 · 第六章 · Harness

    没有报错不代表完成任务。日志、轨迹、单元测试、集成测试和 Eval 要共同回答发生了什么、是否正确以及是否退化。

    一个 Agent 回答:“已经修复并通过测试。”进程没有报错,界面也显示绿色。你打开仓库,却发现它只是把失败测试跳过了。

    这就是 Agent 质量最麻烦的地方:程序成功运行,不代表任务成功完成。我们既要观察它做了什么,也要验证最后世界是否变成了我们想要的样子。

    可观测性回答“发生了什么”

    一条有用的运行记录通常分四层:

    1. 会话层:开始、恢复、结束、当前模型。
    2. 步骤层:模型请求、首 token、总耗时、token 和成本。
    3. 工具层:调用名称、参数摘要、结果摘要、耗时和状态。
    4. 任务层:目标、进度、最终副作用和完成判定。

    日志文本适合人读,结构化事件适合搜索、聚合和测试。NDJSON 是本地 Agent 的实用选择:每行独立、易追加、损坏时可局部恢复。

    {"event":"agent.step.end","step":3,"ttftMs":820,"elapsedMs":2410,"inputTokens":4200}
    {"event":"tool.result","tool":"write_file","ok":true,"durationMs":12,"bytes":380}
    

    默认不要记录完整 Prompt、文件内容、Shell 输出或工具参数。摘要、hash、计数和脱敏路径通常足够排障,也更安全。

    指标要能指向问题

    只记录总耗时,很难知道慢在哪里。可以拆成:

    • TTFT:从请求开始到第一个 token。
    • elapsed:整个模型步骤耗时。
    • tool duration:工具执行耗时。
    • input/output tokens:上下文和输出规模。
    • cache read/write:缓存是否生效。
    • cost:按模型定价估算。

    TTFT 变慢可能是模型或 Prompt Cache 问题;总耗时变慢但 TTFT 正常,可能是输出过长;工具时间上升则应查外部服务。指标只有能支持判断才有价值。

    测试金字塔需要多一层 Eval

    Agent 项目仍然需要传统测试:

    • 单元测试:路径判断、状态机、解析器、重试策略。
    • 集成测试:Agent Loop 与假模型、工具 Registry、会话恢复。
    • 端到端测试:真实 CLI 进程、隔离工作区和文件副作用。

    但这些测试多半验证“代码按预期走”。Eval 进一步验证“面对任务,Agent 是否达成目标”。

    一个 Eval Case 可以这样表达:

    interface EvalCase {
      id: string
      prompt: string
      fixture: string
      requiredTools?: string[]
      forbiddenTools?: string[]
      expectedFiles?: Record<string, string>
      maxSteps?: number
      maxTokens?: number
      maxCostUsd?: number
    }
    

    任务不是只看最终文字。它可以要求必须读取配置、禁止执行 Shell、最终某文件包含指定内容,并限制步数和成本。

    三种 Runner 各有用途

    Mock Agent

    使用脚本化假模型返回固定工具调用。它快、稳定、适合 CI,能精确验证 Loop、工具和事件协议。缺点是测不到真实模型的决策能力。

    CLI Subprocess

    在隔离工作区启动真实命令行,但模型仍可使用 mock。它能覆盖参数解析、会话文件、退出码和真实文件副作用,是连接模块测试与真实产品的桥梁。

    Real Agent

    调用真实模型,最接近用户体验,也最慢、最贵、波动最大。默认只开放只读工具;写入和 Shell 应在 Case 中显式允许。CI 不应无意中跑真实模型,命令需要明确 opt-in。

    Scorer 不只看“答案像不像”

    可以组合多种确定性评分:

    • 结果评分:目标文件、输出字段或最终状态是否正确。
    • 轨迹评分:是否使用必需工具,是否调用禁止工具,是否有过多额外步骤。
    • 进度评分:计划中的关键步骤是否完成。
    • 预算评分:步数、工具数、耗时、token 和成本是否越界。
    • 安全评分:是否输出密钥、访问禁止路径或执行危险命令。

    LLM Judge 适合评价解释质量、完整性和语气,但应是显式可选项。能用确定性规则判断的事情,不必交给另一个模型猜。

    先检查世界,再看模型怎么说

    Agent 声称“测试通过”不算证据。Runner 应读取测试退出码;声称“文件已修改”不算证据,应检查隔离工作区的 diff。

    这条原则可以总结为:优先验证外部可观察状态,其次验证工具轨迹,最后才评价自然语言答案。

    Baseline 让回归可见

    单次通过不代表改动更好。保存一组 baseline,可以比较候选版本的:

    • 通过率和各维度得分。
    • 平均步骤、token、耗时和成本。
    • 新增失败 Case。
    • 波动范围。

    真实模型有随机性,关键 Case 可以重复多次,观察成功率而不是只看一次。趋势页能发现性能不是突然坏掉,而是在十次修改里慢慢下降。

    本地优先,外部观测可选

    Eval 的原始 artifact、轨迹和报告应先完整写到本地。Langfuse 或 OpenTelemetry 可以用于跨运行分析,但导出失败不应让本地评测失败。

    外部导出默认关闭原始 I/O。打开前要明确 Prompt、文件内容和工具结果是否含有代码、路径或个人信息。

    用 Baseline Trend 做个小练习

    设计一个“读取 package.json 并报告测试框架”的 Eval Case:

    • 必须调用 read_file
    • 禁止调用写工具和 Shell。
    • 最多 3 个步骤。
    • 最终回答必须包含测试框架名称。

    先用 mock 模型让它通过,再增加一个反例:模型不读文件,直接猜 Jest。确认结果或轨迹评分能把它判为失败。

    系列小结

    我们从一个 while 循环出发,走过入口、上下文、工具、规划、状态、文件、界面、多 Agent 和扩展。最后落到可观测性与 Eval,是因为所有架构边界最终都需要证据:系统是否按预期运行,任务是否真的完成,风险和成本是否在范围内。

    开发 Agent 最值得借鉴的不是某个框架 API,而是这些反复出现的原则:稳定与动态分开,控制面与数据面分开,知识与执行分开,恢复上下文与恢复权限分开,模型建议与系统决策分开。

    当这些边界立住,更换模型、界面或工具协议都只是局部变化;边界混乱时,再聪明的模型也只能在偶然中工作。

    解剖一次“回答正确,任务失败”

    下面这条 Case 要求把 config.json 中的超时从 10 秒改成 30 秒,并运行测试。最终回答写得很漂亮:“已将超时调整为 30 秒,全部测试通过。”如果只用关键词评分,它很可能满分。

    真实轨迹却是:

    {"step":1,"type":"tool_call","tool":"read_file","input":{"path":"config.json"}}
    {"step":1,"type":"tool_result","tool":"read_file","ok":true}
    {"step":2,"type":"tool_call","tool":"write_file","input":{"path":"config.example.json"}}
    {"step":2,"type":"tool_result","tool":"write_file","ok":true}
    {"step":3,"type":"model_end","text":"已将超时调整为 30 秒,全部测试通过。"}
    

    最终文字命中了“30 秒”和“测试通过”,但世界状态暴露出三个问题:改错文件、没有执行测试、声称了不存在的证据。它们应该由不同检查器发现,而不是揉成一个神秘总分。

    证据切面 本次观察 判定
    文件副作用 config.json 未变化,config.example.json 被修改 失败
    工具轨迹 缺少要求的测试工具,出现目标外写入 失败
    最终回答 包含目标值,但“测试通过”无事件支撑 失败
    资源预算 3 步,未超过 6 步上限 通过

    这张表也解释了为什么“平均得分 0.25”还不够。对于会改文件的任务,副作用失败通常应该让 Case 整体失败;不能拿预算得分去抵消改错生产配置的风险。总分适合比较趋势,硬门槛负责阻止危险结果蒙混过关。

    flowchart LR
      C["Eval Case"] --> R["隔离 Runner"]
      R --> T["Trace JSONL"]
      R --> W["工作区副作用"]
      R --> O["最终回答"]
      T --> S1["轨迹 / 安全评分"]
      W --> S2["结果评分"]
      O --> S3["文本或 Judge"]
      S1 --> G{"硬门槛"}
      S2 --> G
      S3 --> G
      G --> A["Run Artifact"]
      A --> B["Baseline / Trend"]
    

    Trace 必须能回答因果,而不只是堆时间戳

    一条 tool.result 至少要能关联对应的 run、case、step 和 callId。否则并发工具一多,就无法判断某个失败属于哪次调用。模型步骤要区分开始、首 token 和结束,才能把“模型排队慢”与“输出很长”分开。

    但 trace 不是录屏。文件正文、Prompt 和密钥不该为了排障默认写进 JSONL。更好的做法是记录输入摘要、内容 hash、字节数和脱敏路径;只有在明确启用且存储边界可信时,才保留原始 I/O。可观测性如果制造了新的泄密面,就失去了意义。

    把 Scorer 失败写成能行动的诊断

    “trajectory score = 0”对维护者帮助很小。失败项应该说出期望、实际和证据位置,例如:

    [trajectory.required_tools] FAIL
    expected: [read_file, run_tests]
    actual:   [read_file, write_file]
    trace:    traces/change-timeout-0.jsonl
    
    [side_effect.expected_file] FAIL
    expected: config.json contains "timeoutMs": 30000
    actual:   file unchanged
    artifact: failures/change-timeout-0/workspace-diff.patch
    

    这样开发者能判断是 Agent 决策退化、工具事件漏记,还是 fixture 本身写错。评测系统也需要被测试:用固定 trace 验证 requiredTools、forbiddenTools、预算、安全模式和文件断言各自会在什么输入下失败。

    五处源码对应一条证据链

    顺位 实现参考 放在证据链里的位置
    1. src/observability/audit.ts 运行时用 NDJSON 留下会话、模型、工具、Hook 和任务事件,并在入口处做脱敏
    2. src/observability/langfuse.ts 把本地事件映射为可选外部 trace;未启用或导出失败时不影响主任务
    3. src/evals/runner.ts 选择 mock、CLI 或真实模型 Runner,创建隔离目录并写 run artifact
    4. src/evals/trace-recorder.ts 将 Loop 回调归一为稳定事件,避免同一次工具结果被重复记录
    5. src/evals/scorers.ts 分别检查输出、轨迹、预算、安全、工具执行和文件副作用

    源码路径只是一组可选落点,真正可迁移的是顺序:先让运行时产生可信事件,再让 Runner 保存外部状态,最后由 Scorer 对照任务合同判断。若顺序反过来,评分器只能从最终回答里猜过程。

    发布前看失败集合,不只看平均线

    候选版本从 82 分升到 85 分,不一定值得发布。先看是否新增了安全失败、写错文件、成本翻倍或某一类任务全军覆没,再看平均值。对真实模型重复运行时,可以同时报告通过率和方差;一个五次里偶尔成功一次的任务,不该被一次幸运样本包装成“已修复”。

    到这里,33 篇内容最终落在同一个朴素标准上:Agent 的每一步要有边界,每个副作用要有证据,每次失败要有恢复路径。模型能力会继续变,框架名字也会变,这三个标准不会很快过时。

    日志、审计、指标、Trace、Eval 各回答一个问题

    普通日志帮助开发者看程序正在做什么;审计记录谁在何时通过哪项策略触发了什么动作;指标聚合延迟、token、错误率;Trace 把一次 run 的模型与工具因果串起来;Eval 用预先声明的目标判断行为是否达标。

    它们可以共享事件源,存储与保留策略不同。把所有内容都打印到 console,既无法聚合,也可能泄密;把完整 Prompt 全部上传 Trace 平台,又会扩大数据边界。先定义问题,再决定记录粒度。

    日志:MCP jira 连接失败,2 秒后重试
    审计:run r8 的 call c4 被 policy P17 阻断
    指标:过去 1 小时 tool_rejected_rate=3.2%
    Trace:step2 -> call c4 -> policy block -> step3 final
    Eval:Case forbidden-path 期望 block,实际是否满足
    

    Audit Event Schema 要稳定且可验证

    事件包含 schemaVersion、eventId、timestamp、sessionId、runId、step、agent identity、event name 和脱敏 payload。Tool call/result 共享 callId,Hook decision 引用 hook/matcher,MCP connect 记录 server alias 而非 secret URL。

    interface AuditRecord {
      schemaVersion: number
      id: string
      at: string
      sessionId?: string
      runId?: string
      step?: number
      agent: { kind: 'main' | 'subagent'; id?: string }
      event: string
      payload: Record<string, unknown>
    }
    

    NDJSON 每行独立,verify 命令检查 JSON、时间、eventId 重复和可选 hash chain;tail 支持 session/event/date 过滤与 follow。文件轮转按大小/日期,保留期清理时不影响活跃 writer。

    Audit 默认开启但 payload 最小化。工具名、状态、字符数、hash 和错误类别通常足够;完整输入输出只有显式 PII 模式且边界可信时记录。

    脱敏在事件创建处做,不依赖展示层

    若先把原始对象放进队列,后面 Dashboard 才脱敏,磁盘或 exporter 早已接触秘密。每类事件用专用 payload builder:用户 Prompt 记录长度/hash,文件记录路径摘要,Shell 记录命令类别与输出大小,Hook 记录决定而非完整 stdin/stdout。

    错误对象要清理 headers、endpoint query、stack 中的本地绝对路径。API key 即使只出现在 provider 错误里,也不能原样进 crash report。

    hash 也不是匿名化万能解。低熵值如短用户名可被枚举,必要时用带本地 secret 的 HMAC 或不记录。Dashboard 默认只绑定 loopback,不向 API 返回本机绝对路径和原始内容。

    Trace 关联要覆盖模型等待与工具执行

    一轮 trace 从 user turn 开始,model step span 记录 request start、first byte、first meaningful part、first visible text、finish、usage;tool span 记录 policy、execute、result;SubAgent 以 child trace 或 agent metadata关联。

    TTFT 慢时查看 model span,完成慢时区分输出、tool 和 backoff。等待心跳是本地 event,不当作模型 token;stalled warning 与总 request timeout 分开。

    Langfuse/OpenTelemetry 是可选后端。本地 trace 与 Eval artifact 先成功,外部初始化、flush 或导出失败只记录 warning,不让主任务或本地 Eval 失败。默认不记录 I/O 原文。

    一个 Eval Case 是任务合同,不是 Prompt 列表

    Case 除 prompt 外还要描述 fixture、runner、期望轨迹、副作用、预算和安全。对 CLI case,fixture/workspace 必须隔离,声明预期创建/修改/删除文件;real-agent 默认只读,写/Shell 工具显式 opt-in。

    id: update-timeout
    runner: cli-subprocess
    fixture: fixtures/timeout-project
    prompt: 将 config.json 的 timeoutMs 改为 30000 并运行测试
    trajectory:
      requiredTools: [read_file, edit_file, shell]
      forbiddenTools: [mcp_publish]
      maxExtraTools: 3
    budgets:
      maxSteps: 8
      maxDurationMs: 45000
    sideEffects:
      files:
        config.json:
          contains: '"timeoutMs": 30000'
      forbiddenPaths: ['../']
    

    Case ID 稳定,变更期望要评审。Fixture 小而真实,不能依赖开发机 .env、绝对路径和网络。

    Mock Runner 测协议,CLI Runner 测产品,Real Runner 测决策

    Mock 模型按脚本返回 tool calls,适合验证 reasoning part、消息顺序、重试、循环检测和工具结果。它不衡量真实模型聪明程度,却能在 CI 每次稳定运行。

    CLI subprocess 从真正入口启动隔离进程,覆盖参数、环境、Session、退出码、stdout/stderr 和文件副作用。模型仍可用 fixture,避免网络波动。它能发现“模块单测都过,安装后的命令却没接上线”。

    Real Agent 调真实 provider,显式 --allow-real-model,有总 token/成本/并发闸门。用于定期回归或人工候选,不作为每次 PR 的唯一门。真实写工具必须 Case 明列,避免评测本身修改未知目录。

    Deterministic Scorer 先判断能确定的事实

    Final output 检查包含/正则/JSON 字段;Trajectory 检查 required/forbidden tools、顺序、extra calls;Budget 检查 step、tool、duration、token、cost;Safety 扫输出和工具 I/O 的 secret pattern、禁止路径;Side Effect 检查文件树;Tool Execution 检查失败是否被谎称成功。

    每项输出 pass/fail、expected、actual、evidence ref。硬门槛如 forbidden tool、越界文件和错误副作用让 Case 整体 fail,不能被其他小项平均抵消。

    Progress scorer 使用 checkpoints,能区分一步没做和完成 80% 后超时。趋势中既看 pass rate,也看 progress、token 和 cost。

    LLM Judge 只处理“需要阅读”的质量

    解释是否清楚、风险是否完整、教学语气是否合适,规则难以完全判断,可以用 Judge。但真实文件是否修改、工具是否调用、是否泄密,已有确定证据就不交给另一个模型。

    Judge 显式 --judge 开启,使用统一 reasoning config,有独立超时与成本;输出严格 JSON,非法或低置信度标 judge error,不把 Case 自动判过。Prompt 给 rubric、任务、脱敏回答和必要证据,不给本地秘密。

    Judge 自身要校准。抽样与人工标注比较,发现偏好冗长、特定措辞或模型家族时调整 rubric。更换 Judge 模型会影响历史可比性,run artifact 记录版本。

    Safety Scorer 要看工具输入与输出

    最终回答没出现密钥,不代表 Agent 没把密钥发给远端工具;禁止路径没写在文本里,也可能进入 read_file 参数。Safety 扫 final、tool_call.input、tool_result.output 的脱敏 trace,并单独检查 forbiddenPaths 的规范化表示。

    Prompt Injection Case 把恶意指令放在 fixture 文档,期望 Agent 把它当数据,不调用外发/删除工具。权限 Case 测 symlink、父目录、绝对路径和 Plan Mode。外部动作 unknown Case 验证不会盲重试。

    Secret pattern fixture 使用假 token,不把真实 key 放测试仓库。Scorer 报 pattern 名与事件位置,不回显完整匹配值。

    预算闸门分运行前和运行后

    Run 前根据 case 数、repeat、模型和上限估算,超过 max total tokens/cost 直接生成 preflight failure artifact,不启动真实请求。运行中 UsageTracker 累计,接近上限不再调度新 Case;已运行结果正常保存。

    Post-run 检查实际总 duration/token/cost,避免 provider 未及时返回 usage 时漏报。未知 usage 单列,不填 0。并发数受模型限流和本机资源控制,不以“越快越好”压垮服务。

    预算失败也是结果。报告说明哪些 Case 未运行及原因,不能让趋势把缺失样本当通过。

    Baseline 是一次完整 Run,不是单个平均分

    Promote 把 run.json、Case 结果和必要报告保存为命名 baseline,保留 suite、commit、模型、配置、scorer version。Candidate compare 按 Case ID 对齐,列新增失败、修复失败、分数、progress、token、cost 和耗时变化。

    平均分上升但新增一个安全失败,比较结果必须醒目标红。Case 增删分开报告,不把样本变化伪装成质量变化。

    Baseline artifact 不提交真实运行中的敏感内容;固定 deterministic baseline 可以纳入受控目录,历史 runs/trends 通常本地忽略。

    真实模型要看重复分布

    一次通过可能是幸运。关键 Case --repeat 5,报告 pass rate、score 均值/方差、最常见失败路径和成本分布。0/1/1/0/1 说明不稳定,不等于 60 分这么简单。

    固定 temperature 也无法消除服务端变化。Run 记录模型标识、日期和 provider,趋势用滚动窗口,不把不同模型直接混成一条线。

    Flaky Case 先找 fixture 与 scorer 是否不稳定,再判断模型。依赖实时网络、当前日期或共享目录的 Case 应隔离或显式标记,不让测试基础设施噪声掩盖回归。

    CI 分层运行

    PR 运行 typecheck、unit/integration、deterministic smoke 和 CLI fixture,输出 JUnit 供检查页面定位;nightly 跑更大 deterministic suite 和趋势,真实模型或 Judge 按预算与 secret 配置显式开启。

    质量门包括最低 pass rate、安全零失败、progress 不下降、token/cost 增幅上限。某项失败时上传本地生成的脱敏 artifact,不在日志打印完整 workspace。

    CI 失败不自动改 baseline。只有人工确认候选行为符合目标,再 promote;否则基准会被退化版本慢慢抬低。

    Eval 数据集也需要版本和所有者

    Case 来自真实事故、用户任务和架构不变量。每个 Case 写 owner、risk、addedBecause、lastReviewed、fixture license/隐私。修复一个事故后把最小复现加入回归,避免只在代码里留注释。

    数据泄漏会让模型“背答案”。Fixture 变化、公开程度和真实模型训练风险要评估;更重要的是 Case 验证行为轨迹与副作用,不只匹配固定句子。

    长期没有区分度、永远通过或目标已过期的 Case可以归档,但保留历史原因。Suite 应覆盖读取、写入、拒绝、恢复、压缩、SubAgent 和部署降级,而不是堆同一类问答。

    Dashboard 只展示控制面摘要

    本地只读 Dashboard 聚合 session、audit、Task、SubAgent artifact metadata 和 Eval run。默认绑定 127.0.0.1/localhost/::1,拒绝非 loopback host;API 不返回绝对路径、Prompt、文件、Shell 和工具原文。

    页面展示 run/session ID 摘要、模型、步骤、tool name/status、token、cost、artifact 是否存在。需要正文时用户回本地受控文件,不让浏览器面板成为新的数据出口。

    Dashboard 自身没有执行按钮,不能修改任务、重跑工具或删除会话。观测和控制分离降低误操作与攻击面。

    从 Eval 失败回到工程修复

    先看哪项 scorer 失败,再定位 trace 与外部状态。required tool 缺失可能是 Prompt/模型选择,tool event 缺失可能是 Registry 旁路,文件状态错误可能是工具实现,预算超限可能是循环/检索噪声。

    修复应加最小单元或集成测试,再跑该 Case 与邻近 suite,最后比较 baseline。不要看到 final output 不含关键词就只改 Prompt,轨迹可能已经暴露真正原因。

    一次完整闭环是:线上事故留下 audit/trace,脱敏成 fixture,确定性 scorer 表达期望,代码修复,CI 防回归,趋势观察成本副作用。可观测性与 Eval 因此不是两个团队的系统,而是一条从现场到预防的证据链。

    发布前的 Eval 决策单

    列出 candidate commit、suite、baseline、确定性通过率、安全失败、平均 progress、p95 steps、total token/cost、real-model repeat、Judge 状态和已知未覆盖风险。发布者基于证据签字,不只看一个绿色总分。

    某个外部 exporter 失败但本地 suite 全过,可以带 warning 发布;出现越界路径或错误副作用,即使总体 99% 也应阻断。质量门槛来自风险,不来自指标好看。

    本文目录
    本文目录