返回机制实验室

W08 · S50—S56 · 总 Day 140146

系统观测:一次成功任务到底花了什么

从合成 trace 计算端到端延迟、成功成本与服务目标。

作者准备的学习示例 · 不计真实学习进度 · 不代表生产 / GPU / 真机结果

在仓库根目录运行;只输出合成示例,不写文件、不访问网络

npm run learning:p2 -- w08
跳到完整源码 ↓

核心问题

模型调用平均很快,完整任务却慢;每次调用很便宜,成功任务成本却高。原因可能藏在重试、工具等待、失败请求和分母选择中。只有把一次任务的事件关联起来,才能解释这种差异。

对应 S50~S56。本例用合成 trace 计算,不安装采集平台,不接入真实账单。

1. Span 不是延迟加法题

根 span 表示一次用户任务,子 span 表示模型或工具步骤。traceId 把同一次任务关联起来,spanIdparentId 表示层次。两个子步骤可以并行,也可能存在异步关联,不能把所有 duration 简单相加当端到端延迟。

本例 t1 的根时间是 0~100;模型 10~90、工具 20~80,相互重叠。子步骤耗时合计 140,但用户任务只持续 100。根 span 与完整传播上下文缺失时,定位瓶颈会更加困难。

成本也要避免双计。本例每个 span 只记自身新增成本,根 span 成本为零;如果真实系统根上已经记了整次总费用,再加子 span 就会重复。本例也假设数据完整、没有采样、重复导出或跨时钟偏差。

2. 运行并检查分母

npm run learning:p2 -- w08

共有 3 个任务,2 个标记成功。总费用为 12000 microUSD = 0.012 USD。按全部尝试成本除以成功任务数,每个成功任务的系统平均成本是 0.006 USD;失败任务花掉的 0.004 USD 也算在里面。

这里的费用完全由示例手写,不是任何供应商价格。success 也由作者输入,不代表模型质量已经测得。它只展示:必须先说清“成功”的业务含义、观察窗口与成本边界,成本指标才可解释。

若把 good request 定义为“成功且 200ms 内结束”,仅有 t1 满足,good/all = 1/3。成功率 2/3 和及时成功比例 1/3 回答不同问题。本例没有设置自动发布阈值或评定分数。

3. SLI、目标与诊断要分开

SLI 是观察定义,SLO 是期望目标,告警与诊断是后续行动。定义一个目标之前,要先明确测量点、分母、排除项和观察周期。三条合成记录不能支撑真实月度 SLO。

AI 任务还可能出现输出看似成功但业务无效、人工后来驳回、重试成本归属错误等情况。模型调用状态、任务完成状态、最终业务结果应分开保存,不能都叫 success。

轻量修改:只把 t3 的结束时间从 250 改到 180,goodRequests 增加而成功数和成本不变。再把失败的 t2 成本翻倍,成功数不变,但成功任务平均成本上升。任选一项,解释变化是分子还是分母导致的。

4. 专业材料与已有代码连接

  • OpenTelemetry Traces:理解 trace、span 与上下文传播;本例对象不是完整 OTel 协议,也没有 exporter。
  • Google SRE:Implementing SLOs:选 SLI specification 与 implementation 相关内容,尝试给自己的任务写一句可执行定义。

可选阅读仓库 src/agent/trace/types.ts,对比其 trace 字段和本例根/子关系。只映射有解释价值的字段,不要求替换现有观测系统。

5. 向研究与具身系统延伸

研究中 trace 可以帮助区分推理、检索、工具与反馈,但观测相关性不等于因果解释。具身系统还要记录观测时间、决策时间、动作执行时间与真实状态反馈。局部日志完整也不能保证物理世界被充分观测。

简短笔记:我选择的“成功”分母是什么;并行步骤为什么不能直接相加;当前观测遗漏了哪一类失败成本。

配套日课:按需要补充理论

本实验贯穿一周,不要求一天做完。

可运行源码

src/learning/ai-systems/w08-observability.ts · 构建时直接读取源文件,避免讲义代码与实现各自漂移。

interface Span { traceId: string; spanId: string; parentId?: string; start: number; end: number; costMicroUSD: number; success?: boolean }
export const spans: Span[] = [
  { traceId: 't1', spanId: 'r1', start: 0, end: 100, costMicroUSD: 0, success: true },
  { traceId: 't1', spanId: 'model1', parentId: 'r1', start: 10, end: 90, costMicroUSD: 2000 },
  { traceId: 't1', spanId: 'tool1', parentId: 'r1', start: 20, end: 80, costMicroUSD: 1000 },
  { traceId: 't2', spanId: 'r2', start: 0, end: 120, costMicroUSD: 0, success: false },
  { traceId: 't2', spanId: 'model2', parentId: 'r2', start: 10, end: 100, costMicroUSD: 4000 },
  { traceId: 't3', spanId: 'r3', start: 0, end: 250, costMicroUSD: 0, success: true },
  { traceId: 't3', spanId: 'model3', parentId: 'r3', start: 10, end: 240, costMicroUSD: 5000 },
]

export function summarize(input: Span[], deadlineMs = 200) {
  const roots = input.filter(s => !s.parentId)
  const successes = roots.filter(s => s.success).length
  const totalCostMicroUSD = input.reduce((sum, s) => sum + s.costMicroUSD, 0)
  return {
    requests: roots.length, successes,
    goodRequests: roots.filter(s => s.success && s.end - s.start <= deadlineMs).length,
    totalCostMicroUSD,
    costPerSuccessfulTaskUSD: successes ? totalCostMicroUSD / 1e6 / successes : null,
    rootLatencyMs: roots.map(s => ({ traceId: s.traceId, ms: s.end - s.start })),
  }
}

export function run() {
  return { spans, summary: summarize(spans), boundary: 'Synthetic complete traces, exclusive cost accounting, no OTel exporter, real billing, or measured SLO.' }
}