从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践

文章来源声明: 原文作者:梦想很大很大; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687943158864969763; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

价值在于把可观测与评测统一到运行证据,适合需要调试复杂 Agent 工作流、防止评测误伤外部系统并追踪版本回归的团队。

> **“回答看起来对”并不等于“这次执行是对的”。**

做 Agent workflow 时,最容易被忽略的就是这件事。

例如在一个“取消订单”的工作流中,Agent 最后回复了用户“订单已取消”。这句话可能是真的,也可能是模型在工具调用失败后补出来的;它可能绕过了鉴权节点,也可能该进入人工审核时走错了分支。等到改了一段 prompt、换了模型,或者调整了节点配置,问题会变得更拆不清楚:我们究竟是在修复问题,还是在引入另一种退化?

这也正是开源项目 Workrun(一款强调 Local-first 的桌面端 Agent Workflow 自动化工具)在设计 Telemetry(遥测)与 Evaluation(评测)时最想解决的问题。

我没有把它们设计成两套独立的“附加功能”:一套用来打日志,另一套用来跑测试。相反,Workrun 先保留一份可回放、经过脱敏的运行事实;本地诊断、成本指标、评测结果和版本比较,都是从这份事实派生出来的。

这篇文章会以一个订单取消 workflow 为例,介绍这套设计是怎样落地的,也会如实说明它目前的工程边界。

问题不只在最终输出

先看一个很典型的工作流:用户请求取消订单,Agent 需要先查询订单、检查权限和风控状态,再决定取消还是转人工审核。

flowchart TD
    A["用户:请取消订单 42"] --> B[authorization]
    B --> C[lookup_order]
    C --> D[risk_check]
    D -->|safe| E[cancel_order]
    E --> F[respond]
    D -->|risky| G[manual_review]

截屏2026-09-22 10.24.48.png

如果只验证最终文本,我们大概会写一个测试:输出中必须包含“订单 42 已取消”。但这远远不够:

  • lookup_order 可能没有成功,模型却凭空猜了一个答案;
  • cancel_order 可能根本没被调用,模型只是“伪造”了成功回复;
  • 风控命中后本应转人工,工作流却依然继续执行了取消;
  • 工具结果或最终输出可能带出了敏感字段;
  • 新版本看起来还能回答问题,但 token、延迟和错误率已经明显变差。

截屏2026-09-22 10.24.58.png

因此,我们希望一次运行结束后至少能回答两类问题:

  1. 这次运行中发生了什么? 哪个节点、模型调用或工具调用出了问题?(Telemetry 的职责)
  2. 这次运行的行为是否符合预期? 下一次修改后,是否发生了可识别的回归?(Evaluation 的职责)

前者是 Telemetry 的职责,后者是 Evaluation 的职责。它们的共同基础,是同一份运行证据

一份运行事实,多个派生视图

Workrun 中,每一次 workflow 或 app 执行都会有一个 Run。运行过程中产生的事件会按顺序持久化到本地 Run History;输出面板、span、聚合指标和评测观察值都不是唯一事实来源,而是围绕事件日志建立的投影。

flowchart TD
    A[Workflow execution] --> B[脱敏事件日志]
    B --> C["本地 Run History / span projection"]
    C --> C1["节点、模型、工具耗时"]
    C --> C2["token、成本、错误"]
    C --> C3["成功率、p50/p95、版本指标"]
    B --> D[Evaluation observation]
    D --> D1[最终输出]
    D --> D2[节点与路由轨迹]
    D --> D3[工具调用和结果]
    B --> E[可选 OTLP export]
    E --> E1["workflow trace context + GenAI spans"]

这个设计看起来朴素,但它带来了两个很重要的工程结果:

  1. 诊断信息与业务执行解耦:Span 是事件日志的派生投影;即使某次 telemetry 写入失败,Run 本身仍然完好保留,工作流绝不会因为“观测系统挂了”而崩溃。
  2. 评测不必再造一套平行的执行器:评测系统可以直接读取同一份经过处理的运行证据,评估真实的执行路径,而不是只对一个脱离 runtime 的模拟结果打分。

本地 telemetry:把运行过程变成可查询的数据

Workrun 的本地 telemetry 重点不是堆很多日志,而是把运行中的关键动作投影为可查询的 span

对于 workflow,当前会记录三类核心 span:

  • workflow node:节点在哪一步执行、执行了多久、最后是否完成;
  • model call:模型名、输入/输出 token、cache token、reasoning token、audio token、估算成本以及是否 BYOK;
  • tool call:调用了什么工具、耗时多久、成功还是失败。

截屏2026-09-22 10.25.30.png

截屏2026-09-22 10.25.42.png

这些 span 都通过 run_id 关联到一条 Run。例如,下面是一条写入 run_events 的脱敏后模型调用事件:

<span>{</span>
  <span>"type"</span><span>:</span> <span>"custom"</span><span>,</span>
  <span>"node"</span><span>:</span> <span>"risk_check"</span><span>,</span>
  <span>"event_type"</span><span>:</span> <span>"agent.model_call"</span><span>,</span>
  <span>"data"</span><span>:</span> <span>{</span>
    <span>"modelCallId"</span><span>:</span> <span>"4a563af4-5f36-4b5b-9ac5-2c6372b3164f"</span><span>,</span>
    <span>"model"</span><span>:</span> <span>"gpt-5"</span><span>,</span>
    <span>"startedAt"</span><span>:</span> <span>"2026-09-21T10:30:12Z"</span><span>,</span>
    <span>"endedAt"</span><span>:</span> <span>"2026-09-21T10:30:13Z"</span><span>,</span>
    <span>"durationMs"</span><span>:</span> <span>842</span><span>,</span>
    <span>"inputTokens"</span><span>:</span> <span>320</span><span>,</span>
    <span>"outputTokens"</span><span>:</span> <span>45</span><span>,</span>
    <span>"totalTokens"</span><span>:</span> <span>365</span><span>,</span>
    <span>"totalTokensEstimated"</span><span>:</span> <span><span>false</span></span><span>,</span>
    <span>"cacheReadTokens"</span><span>:</span> <span>128</span><span>,</span>
    <span>"estimatedCostMicrousd"</span><span>:</span> <span>730</span><span>,</span>
    <span>"isByok"</span><span>:</span> <span><span>true</span></span>
  <span>}</span>
<span>}</span>

这个事件会被投影为 model_call span;模型调用的内容不需要进入 span 表,查询时仍可在保留期内回到经过脱敏的事件证据。

在聚合层,Workrun 会按 workflow、版本和时间范围计算成功率、平均耗时、p50/p95、token 与估算成本。离线 evaluation 流量会和普通生产运行显式标记区分开,避免批量测试把日常运行指标冲高。

一个容易漏掉的细节:终态 span 收口

正常情况下,节点会收到 node_start → node_end,工具会收到 tool_call → tool_resulttool_error,span 会自然结束。

但真实系统里还有另一种情况:运行在节点或工具执行中被取消,或者因为异常提前失败。此时最后的事件未必能送达;如果不处理,本地历史里会留下永远处于 running 的 span。

Workrun 现在会在 Run 进入终态时,在同一个数据库事务中收口仍处于 running 的 span:

  • Run 正常完成时,遗留 span 标记为 completed
  • Run 失败时,遗留 span 标记为 failed
  • Run 取消或中断时,遗留 span 标记为 cancelled
  • 已经有明确结束状态的 span 不会被覆盖。

这不是一个很“炫”的功能,却直接决定了历史数据是否可信。对诊断系统而言,终态一致性比多一个图表更重要

OTLP:把本地诊断接入标准 tracing 工具

本地 Run History 适合在 Workrun 中快速复盘单次运行;当需要跨运行、跨服务,或者希望接入团队已有的 APM 系统时,Workrun 也支持把 tracing 数据导出到远端 OTLP collector。

项目设置中提供了 OTLP 端点:填入兼容 OTLP/gRPC 的 collector 地址并保存,重启后生效。未配置时,工作流完全在本地高能运行;配置后,Workrun 会将 workflow 和 ADK runtime 的 tracing context 实时导出到远端。

下图来自一条真实的“退款申请” workflow:它先提取结构化信息,随后调用模拟 CRM 查询,再根据工具结果生成 最终 JSON。该次运行在 Jaeger 中持续约 6.5 秒,共导出了 25 个 span

workrun.workflow.run [Run ID, Workflow ID, Version, Thread ID]
├── run (Agent Loop 1: 语义解析)
│   └── call_llm
│       └── model.generate_content
│           ├── execute_stream
│           └── gen_ai.generate [Model: gpt-5, Tokens: 320/45]
├── run (Agent Loop 2: 工具调用)
│   ├── call_llm ──► gen_ai.generate
│   └── execute_tool: crm_lookup_user [Duration: 420ms]
└── run (Agent Loop 3: 最终响应生成)
    └── call_llm ──► gen_ai.generate

根 span workrun.workflow.run 附带 Run ID、workflow ID、版本和 thread ID;子 span 则清晰拆解出每轮 Agent 执行、模型请求(含 token 与 provider)和工具调用的耗时。这能一眼看出时间究竟是花在了“模型等待”还是“工具执行”上。

截屏2026-09-22 08.57.09.png

本地 Run History 用于面向作者的执行复盘,OTLP trace 则把同一次运行接入 Jaeger 或 Grafana Tempo。两者共享上下文标识,排查远端异常时,凭 run_id 就能精准锚定本地历史现场。

需要提醒的是,远端 collector 属于本地设备之外的数据系统。虽然 Workrun 会对事件进行脱敏,但 trace 仍包含部分运行元数据,应按照生产级基础设施规范来配置访问控制与保留期。

Evaluation:评估 workflow 行为,而不只评估答案

有了运行证据,下一步才是 Evaluation。

回到取消订单的例子。下面是一个“高风险订单不允许直接取消”的 Case 定义:

<span>{</span>
  <span>"id"</span><span>:</span> <span>"cancel-order-with-risk"</span><span>,</span>
  <span>"name"</span><span>:</span> <span>"高风险订单转人工审核"</span><span>,</span>
  <span>"input"</span><span>:</span> <span>{</span> <span>"message"</span><span>:</span> <span>"请取消订单 42"</span> <span>}</span><span>,</span>
  <span>"expectation"</span><span>:</span> <span>{</span>
    <span>"assertions"</span><span>:</span> <span>[</span>
      <span>{</span>
        <span>"kind"</span><span>:</span> <span>"node_trajectory"</span><span>,</span>
        <span>"id"</span><span>:</span> <span>"expected-path"</span><span>,</span>
        <span>"mustExecute"</span><span>:</span> <span>[</span><span>"authorization"</span><span>,</span> <span>"lookup_order"</span><span>,</span> <span>"risk_check"</span><span>,</span> <span>"manual_review"</span><span>]</span><span>,</span>
        <span>"mustNotExecute"</span><span>:</span> <span>[</span><span>"cancel_order"</span><span>]</span><span>,</span>
        <span>"orderedNodes"</span><span>:</span> <span>[</span><span>"authorization"</span><span>,</span> <span>"lookup_order"</span><span>,</span> <span>"risk_check"</span><span>,</span> <span>"manual_review"</span><span>]</span><span>,</span>
        <span>"requireCompleted"</span><span>:</span> <span><span>true</span></span>
      <span>}</span><span>,</span>
      <span>{</span>
        <span>"kind"</span><span>:</span> <span>"route"</span><span>,</span>
        <span>"id"</span><span>:</span> <span>"risk-route"</span><span>,</span>
        <span>"nodeId"</span><span>:</span> <span>"risk_check"</span><span>,</span>
        <span>"expectedRoute"</span><span>:</span> <span>"risky"</span>
      <span>}</span><span>,</span>
      <span>{</span>
        <span>"kind"</span><span>:</span> <span>"tool_trajectory"</span><span>,</span>
        <span>"id"</span><span>:</span> <span>"lookup-only"</span><span>,</span>
        <span>"tools"</span><span>:</span> <span>[</span><span>{</span> <span>"name"</span><span>:</span> <span>"lookup_order"</span><span>,</span> <span>"args"</span><span>:</span> <span>{</span> <span>"orderId"</span><span>:</span> <span>"42"</span> <span>}</span> <span>}</span><span>]</span><span>,</span>
        <span>"config"</span><span>:</span> <span>{</span> <span>"strictOrder"</span><span>:</span> <span><span>true</span></span><span>,</span> <span>"strictArgs"</span><span>:</span> <span><span>true</span></span> <span>}</span>
      <span>}</span>
    <span>]</span>
  <span>}</span><span>,</span>
  <span>"fixture"</span><span>:</span> <span>{</span>
    <span>"toolFixtures"</span><span>:</span> <span>[</span>
      <span>{</span>
        <span>"tool"</span><span>:</span> <span>"lookup_order"</span><span>,</span>
        <span>"args"</span><span>:</span> <span>{</span> <span>"orderId"</span><span>:</span> <span>"42"</span> <span>}</span><span>,</span>
        <span>"result"</span><span>:</span> <span>{</span> <span>"status"</span><span>:</span> <span>"high_risk"</span><span>,</span> <span>"owner"</span><span>:</span> <span>"user_123"</span> <span>}</span>
      <span>}</span>
    <span>]</span>
  <span>}</span>
<span>}</span>

配置了两个评测用例 高风险订单转人工审核安全订单直接取消

截屏2026-09-22 10.56.01.png

Workrun 当前的评测能力以确定性断言为主,支持:

  • 最终文本的精确、包含或相似度匹配;
  • 最终 JSON 的 JSONPath 断言;
  • 工具轨迹、工具参数和返回结果匹配;
  • 节点是否执行、是否完整执行、是否遵守预期顺序;
  • 控制节点是否走到了指定 route;
  • 指定节点的输出、文本和工具轨迹;
  • 面向最终输出、工具参数或工具结果的安全断言。

编辑评测用例:

录屏2026-09-22 12.46.34.gif

评测结果:

录屏2026-09-22 12.39.07.gif

录屏2026-09-22 12.42.08.gif

查看运行输出:

截屏2026-09-22 10.59.18.png

这使得“最终回答对了,但行为错了”不再会被轻易放过。比如模型回复了“订单已取消”,却没有调用 cancel_order,工具轨迹断言会失败;如果风控命中却没有进入 manual_review,route assertion 会失败。最终文本只是证据的一部分,不再是唯一判据。

为什么离线评测不会真的取消订单

对涉及外部系统的 workflow 来说,测试安全性是第一位的。Workrun 的 evaluation 不会放开真实工具调用,而是通过 exact-match fixture 提供工具响应。

<span>{</span>
  <span>"tool"</span><span>:</span> <span>"cancel_order"</span><span>,</span>
  <span>"args"</span><span>:</span> <span>{</span> <span>"orderId"</span><span>:</span> <span>"42"</span> <span>}</span><span>,</span>
  <span>"result"</span><span>:</span> <span>{</span> <span>"status"</span><span>:</span> <span>"cancelled"</span> <span>}</span>
<span>}</span>

截屏2026-09-22 11.01.49.png

评测时,工具调用必须准确命中 fixture;没有 fixture 的调用会直接失败。这样做有几个好处:

  • 🔒 安全性:评测不会真的写入订单系统;
  • 🐛 暴露隐患:未预期的工具调用会暴露出来,而不是悄悄穿透到真实环境;
  • 🧪 可复现:工具返回结果是稳定的,因此断言失败更容易解释和复现。

快照:为什么一次历史评测不会被后来的编辑改写

评测系统很容易犯一个错误:今天打开三周前的一次失败记录,却发现它正在用今天的 case 定义重新解释过去的运行。

Workrun 在创建 evaluation run 时,会冻结并生成快照(workflow snapshot、workflow fingerprint、suite/case、输入、预期、fixture 和 execution profile)。后续修改 prompt、节点、case 或 fixture,不会改写已经存在的评测证据。

这让版本比较有了实际意义:

v1.2.0:10 / 10 passed
v1.3.0: 9 / 10 passed

Regression:cancel-order-with-risk
原因:risk_check 后没有进入 manual_review

即使两个版本都能产出“看起来合理”的最终文本,版本差异仍然能指出:哪一个 case 从通过变成失败、哪一条 criterion 发生了退化,以及对应的执行证据是什么。

脱敏优先:证据有用,但不该成为新的泄露面

Run History 和 evaluation 都会接触到模型输出、工具参数和工具结果,这些地方最容易出现敏感数据。

Workrun 的做法是先建立脱敏的可见证据投影,再将其用于历史查看和评测。它会处理常见凭据字段、文本中的秘密模式,以及部分 PII;workflow 也可以配置明确的敏感字段。安全断言会保留“哪个路径命中”或“禁止文本出现了几次”这类结论,而不会把命中的敏感值再次写进评测结果。

发布前检查:可审计的旁路,而不是硬门禁

Workrun 可以为 workflow 配置发布前质量检查:例如最低通过率、最高成本、最长耗时,以及必须通过的 suite。检查的是当前 candidate workflow snapshot 对应的评测结果,而不是某个历史版本的旧数据。

截屏2026-09-22 11.03.19.png

版本比较则给这次检查补上了“相对变化”的上下文。对同一个 suite,先为基线 workflow 运行一次评测;修改节点、prompt 或配置并保存后,再运行一次。每次运行都会冻结各自的 workflow snapshot,评测页会将不同快照(团队模式下也可对应已发布版本)列为可选的基线和候选版本。

选择两个版本后,Workrun 会并排展示通过率、估算成本和总耗时,并列出发生变化的 Case:新增失败、修复、持续失败或只存在于一侧的 Case。点开某个变化项,还可以逐条比较冻结的 criterion 判定。于是“高风险订单转人工审核 从通过变为失败”不只是一个红色状态,而能继续定位到是节点路径少了 manual_reviewrisk_route 命中了错误分支,还是工具轨迹出现了不应有的 cancel_order 调用。

对比总览:

截屏2026-09-22 13.01.54.png

对比详情:

截屏2026-09-22 13.08.58.png

在个人模式中,没有语义化发布版本时,比较单位仍是不可变的 draft snapshot fingerprint;在团队模式中,发布后的版本号会成为更易辨认的比较对象。无论哪种模式,比较读取的是各版本最近一次非重试的完整评测运行,而不是将某次失败重试混入基线。

当前的产品语义是:如果检查未通过,发布界面会要求操作者显式确认旁路并填写原因,同时保存当时的质量策略和评测快照,供之后回看。

截屏2026-09-22 13.04.40.png

这是一种可审计的发布前质量检查,不是服务端不可绕过的强制发布门禁。把边界说清楚并不会削弱它的价值:在本地优先的工作流工具里,显式旁路和可追溯记录往往比静默忽略一次失败更有意义。

目前已经解决了什么,还有什么没有解决

到目前为止,Workrun 已经把几件通常容易断开的事情串了起来:

  • 脱敏优先的本地运行历史;
  • 节点、模型和工具维度的耗时、token、成本与错误诊断;
  • ✅ 运行终态与 span 终态的 DB 事务级一致性
  • ✅ 基于真实 workflow runtime 的确定性评测
  • Fixture 隔离 和不可变评测快照;
  • Case / Criterion 维度的版本回归比较;
  • 可选的 OTLP 导出(已验证 Jaeger 链路)。

同时,它仍然有清晰的边界:

  • 🚧 当前评测以确定性断言为主,还不是 LLM-as-a-judge 或 rubric 平台;
  • 🚧 没有用 fixture 替代真实外部系统的端到端验收;
  • 🚧 OTLP 仍然是可选诊断出口;
  • 🚧 本地 span 也没有被设计成一套完整的 distributed tracing tree。

LLM-as-a-judge 是后续计划补上的能力。它更适合处理“回复是否专业”“是否完整解释了风险”“语气是否符合预期”这类难以写成固定规则的问题。但它会作为确定性断言的补充,而不是替代:工具是否被调用、路由是否正确、结构化字段是否存在,仍然应该优先由可复现、可解释的规则来判断。

结语

对 Agent workflow 来说,最先需要解决的往往不是“采集更多数据”,而是让一次运行留下足够可信的证据:

能定位问题,能解释结果,能复现失败,也能在下一次修改后识别回归。

当运行事实、telemetry 和 evaluation 共享同一条证据链时,workflow 才开始从“能跑的自动化”变成真正可维护的工程系统。

Workrun 是一个开源项目,欢迎在 GitHub 查看源码与交流:1111mp/workrun-app