跳到正文
astorlm
语言: 简体中文
← 地图

第 11 关

可观测性与评估

轨迹(trace)展示智能体在一次运行中做了什么。评估(eval)在许多次运行上给它打分。两者你都需要:评估告诉你有东西坏了,轨迹告诉你为什么。
1/59 手风琴褶数:
  • user
  • assistant
  • tool_result
  • tool_result(错误)
用例 1/3。每个洞就是一个评估用例:一个提示词,以及一次好的运行应该是什么样子。标准杆 3:一杆铁杆,再一杆推杆。

EventBus

问题

智能体不是一个能从上读到下的函数。模型在运行时决定每一步,同一个提示词明天可能走出另一条路。当用户说“它订错了东西”时,你需要一步一步地知道它做了什么。而当你修改了提示词、某个工具或者模型时,你需要知道它是变好了还是变差了。

这就是 Blindspot,黑箱。它看起来能用,直到某天不能用了,这时谁也说不清发生了什么、花了多少钱、从什么时候开始的。手动试几个提示词,然后说一句“看着挺好”,Blindspot 就是这样被发布上线的。

解决方案

可观测性指的是把每次运行记录成一条轨迹:一棵由 span 组成的树,每一块工作一个 span,各自记录耗时和成本。整次运行是一个 span;每一轮、每次模型调用、每个工具也都是。出了问题的 span 会被标记为错误。本站的每个循环都已经在发出事件;tracer 只需要监听 EventBus 并给它们计时。把这些 span 发送到任何支持 OpenTelemetry(轨迹的开放标准)的工具里,你就能得到动画里的那条时间线。

评估(evals)就是智能体的测试。一个数据集装着若干用例:一个提示词,以及一次好的运行应该是什么样子。你用全新的智能体运行每个用例,再由评分器(scorer)给每次运行打分。通过用例所占的比例,就是你要盯住的数字。评分器有四种,好的评估会把它们混合使用:

  • 答案

    把最终文本和用例期望的结果对比:完全一致、必须包含某个词,或者符合某个模式。

    便宜且确定。它只看结果:一个走错了路却得出正确答案的运行照样能通过。

  • 轨迹

    把智能体按顺序调用的工具,和用例期望的工具对比。

    能抓住“路走错了、答案却对了”的情况,比如第 3 洞。太严格的话,会让走了另一条合理路线的运行不及格:在无害的地方允许多出几步。

  • 你自己的检查

    任何把一次运行映射成分数的函数:杆数不超过标准杆、token 预算、输出里的某个字段。

    你的产品在乎什么就检查什么。能保持确定性就尽量保持。

  • 让模型当评委

    另一个模型阅读问题、答案和一份评分标准,然后打分。

    适合没有唯一正确文本的答案:语气、有用程度、一段总结。更慢,要多花一次调用,还需要一份精确的评分标准,并时不时和人工打分做对照。

第 3 洞说明了为什么不能只用一个。答案说“4 杆进洞”,而 4 正是标准杆。只有轨迹评分器看到了:本该用铁杆的地方用了一号木。也只有轨迹展示了那里发生了什么:一个红色的 span,一颗掉进水里的球。

角色

还是那群熟悉的角色,这次换到了高尔夫球场上。

一个洞 一个评估用例
一个提示词,以及一次好的运行应该是什么样子:它的标准杆和该用的球杆。
球童 模型
研究这个洞,选一支球杆。它从不挥杆。
球杆 工具
drive、iron 和 putt。挥杆的是 Astor。
飞行轨迹线 轨迹
每一次飞行都留在球场上,每一步都是时间线上的一个 span:模型调用是蓝色,工具是橙色,错误是红色。
裁判 你的评估代码
把每个用例交给一个全新的智能体,结束后在记分卡上盖章。
记分卡 报告
每个用例一行,每个评分器一项检查,底部是通过率。

代码

使用 astorlm:attachTracer 把智能体的事件转换成 span,可以交给任何导出器。runEval 为每个用例启动一个全新的智能体来运行整个数据集,应用你的评分器,并返回一份带有各评分器通过率的报告。两者都在 astorlm/experimental 里。

从零手写:第 2 关的循环,在每次模型调用和每个工具外面套一个计时器,再用一个 for 遍历用例,每个评分器一个函数。

import { OpenAIProvider, createLocalAgent } from 'astorlm'
import { attachTracer, createInMemoryExporter } from 'astorlm/experimental/tracing'
import { contains, runEval, toolTrajectory, type EvalCase, type Scorer } from 'astorlm/experimental/evals'

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = { baseURL: 'http://localhost:11434/v1', apiKey: 'YOUR_API_KEY' } // local servers usually ignore the key

const newGolfer = () =>
  createLocalAgent({
    provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
    tools: [drive, iron, putt], // your code: each swing moves the ball and says where it landed
    maxTurns: 10,
  })

// 1. SEE one run: a tracer turns the agent's events into spans (run → turn → model call / tool).
const golfer = await newGolfer()
const exporter = createInMemoryExporter() // in production: an OpenTelemetry exporter instead
attachTracer(golfer, { exporter })
await golfer.run('Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m.')
for (const span of exporter.spans) {
  console.log(span.name, span.endTime! - span.startTime, 'ms', span.status) // e.g. "tool drive 320 ms error"
}

// 2. GRADE many runs: a dataset of cases, and scorers that decide pass or fail.
const dataset: EvalCase[] = [
  { id: 'hole-1', input: 'Hole 1: par 3, 150 m to the pin. Play it out and report your score.', expected: { par: 3, clubs: ['iron', 'putt'] } },
  { id: 'hole-2', input: 'Hole 2: par 4, 360 m to the pin. Play it out and report your score.', expected: { par: 4, clubs: ['drive', 'iron', 'putt'] } },
  {
    id: 'hole-3',
    input: 'Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m. Play it out and report your score.',
    expected: { par: 4, clubs: ['iron', 'iron', 'putt'] }, // lay up short of the water
  },
]
type Expected = { par: number; clubs: string[] }

// Each case expects its own clubs, so wrap the built-in trajectory scorer.
const path: Scorer = {
  name: 'trajectory',
  score: (result) => toolTrajectory((result.case.expected as Expected).clubs, { mode: 'ordered-subset' }).score(result),
}
// Your own scorer: any function from a run to a 0..1 score.
const par: Scorer = {
  name: 'par',
  score: (result) => {
    const strokes = Number(/in (\d+)/.exec(result.output)?.[1] ?? Infinity)
    const passed = strokes <= (result.case.expected as Expected).par
    return { scorer: 'par', score: passed ? 1 : 0, passed, details: `${strokes} strokes` }
  },
}

const report = await runEval({
  dataset,
  createAgent: () => newGolfer(), // a fresh agent per case: no shared history
  scorers: [contains('Holed out'), path, par], // add llmJudge({ provider, rubric }) for fuzzy answers
})

console.log(report.summary) // { total: 3, passed: 2, passRate: 0.67, byScorer: { … } }

注意事项

  • 每次改动都跑评估。一个新的提示词、一段工具描述或者一个模型版本,可能修好一个用例,又弄坏两个。把数据集放在仓库里,在 CI 中运行,通过率一下降就让构建失败。
  • 用真实的失败来扩充数据集。用户报告的每个 bug 都变成一个用例,用它的轨迹作为证据。只有简单用例的数据集会永远通过,什么也证明不了。
  • 运行结果会波动。同一个用例今天通过,明天可能失败。重要的用例要多跑几次,看比率,而不是看单次结果。
  • 轨迹里存着你用户的数据。提示词、工具的输入和输出都会进到里面。该遮的要遮(用钩子,第 6 关),并把轨迹存储当作一个装有个人数据的数据库来对待。
  • 成本也是一项指标。记录每个 span 的 token。一个用两倍调用次数通过所有用例的智能体,是一次你的通过率显示不出来的退步。