跳到主内容
创建时间:最后更新:作者:白川

分析耗时、Token 与运行成本

用单调时钟和尝试账本统计耗时与用量,区分首输出、并行、重试和缺失数据。

分析耗时、Token 与运行成本

任务用了十秒,模型请求只用了两秒,剩下的时间可能花在排队、工具、事件处理或界面上。只有一个总耗时,无法判断该优化哪里;只有 Token 总数,也不能判断重复请求是否被漏算。

这篇建立两个独立口径:时间按操作边界测量,用量按实际尝试记录。前者不能用日历时钟相减代替,后者不能反复累加同一次请求的累计值。

本系列的独立示例位于仓库 columns/coding-agent/examples/debugging-observability/,固定使用 pi-agent-core、pi-ai、pi-schema、pi-telemetry 1.5.0。先在该目录执行 npm install,下文命令也从该目录运行。示例不需要模型密钥,不读取个人配置。

先写清起点与终点

指标建议边界常见误判
排队耗时
入队到开始执行
计入模型推理时间
首文本输出
请求开始到首个 text_delta
将连接成功、thinking 或 start 当首字
请求总耗时
调用开始到最终结果或错误
HTTP 收到头就当完成
工具耗时
业务执行开始到结果或错误
将 UI 接收间隔当精确执行时间
任务耗时
接收任务到定义的完成边界
看到第一个 agent_end 就停止

使用 performance.now() 测单进程经过时间,墙上时间用于检索“何时发生”,两者职责不同。并行工具的耗时之和可能大于任务总耗时,不能直接相加得出关键路径。

示例 mock 直接产生最终消息,没有真实流式首字,故不输出伪造的 TTFT。需要验证首文本时应注入 text_delta 序列或真实 Provider 流,并记录无文本响应时的缺失值。

同一次尝试保存最终值

src/metrics.ts 的账本按 attempt ID 覆盖更新,同一次尝试的累计 usage 只保留最终快照。

ts
export interface Attempt {
  id: string
  durationMs: number
  input?: number
  output?: number
  estimatedCost?: number
}

export class UsageLedger {
  private readonly attempts = new Map<string, Attempt>()
  constructor(private readonly limit = 100) {}

  add(attempt: Attempt) {
    if (!Number.isSafeInteger(this.limit) || this.limit < 1) {
      throw new Error("limit 必须是正整数")
    }
    for (const value of [
      attempt.durationMs,
      attempt.input,
      attempt.output,
      attempt.estimatedCost,
    ]) {
      if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
        throw new Error("指标必须是非负有限数")
      }
    }
    if (!this.attempts.has(attempt.id) && this.attempts.size >= this.limit) {
      throw new Error("尝试数量超过上限")
    }
    // 同一次尝试用最终值覆盖,避免累计 usage 更新被反复相加。
    this.attempts.set(attempt.id, { ...attempt })
  }

  summary() {
    const rows = [...this.attempts.values()]
    const total = (key: "input" | "output" | "estimatedCost") => {
      const values = rows.map((row) => row[key])
      return {
        known: values.reduce<number>((sum, value) => sum + (value ?? 0), 0),
        missing: values.filter((value) => value === undefined).length,
      }
    }
    return {
      attempts: rows.length,
      input: total("input"),
      output: total("output"),
      estimatedCost: total("estimatedCost"),
    }
  }
}

export async function timed<T>(operation: () => Promise<T>, clock = () => performance.now()) {
  const started = clock()
  const value = await operation()
  return { value, durationMs: Math.max(0, clock() - started) }
}

假设一次请求先报告 input=10,最终修正为 12,应计 12 而不是 22。另一次重试 input=2,逻辑任务可报告已知输入合计 14。测试还验证 output=0 和未返回 output 是两种情况。

这里的 timed 只返回成功操作的耗时,异常按原值继续抛出。若要记录失败耗时,需要在调用边界的 finally 中更新相同尝试记录,并保证遥测写入失败不覆盖原异常;不能把本函数当作已经完整实现的生产性能中间件。

缺失与零值不能合并

账本返回 known 和 missing。所有缺失都换成 0 会制造“这个错误请求没有成本”的错觉;Provider 在失败时可能没有返回用量,但服务端仍可能已经计费。

模拟流的 usage 固定为零,仅用于构造合法测试消息,不代表真实模型免费。真实数据应记录来源和模型计价版本,estimatedCost 是估算,不等于账单。图像、缓存、工具子调用和不同价格层也不能只用一个通用单价推导。

防止重试和子调用重复统计

统计请求尝试要给每次重试分配新 ID,任务聚合再把尝试关联起来。若宿主已经汇总了工具子调用用量,再将相同子调用逐项相加就会重复;先定义哪一层是权威明细,再决定是否采用宿主汇总。

缓存 read/write token 也是报告值,不能没有分母就称为缓存命中率。比较两个版本时应使用同一类任务和相同采集口径,同时观察最终质量;更快或更少 Token 未必意味着任务完成得更好。

一次慢请求与运行分布

本地 demo 的 monitor 使用整个应用等待区间,包含工具和事件处理。它只是一次运行的样本,没有负载、网络和远程模型,不能作为性能基准。

拿到多次实际样本后再看分位数、错误与重试分组。小样本 p95 可能就是最大值,应一起展示样本量、窗口和采集遗漏;下一篇将这些规则落实到最小运行监控。

依据与版本

本地官方快照为 8efd314fa,相关包标记 1.3.0;配套代码另按已发布的 1.5.0 类型和运行行为核验。主分支文档持续变化,不能混作同一版本。