可观测面板
每个 cache / 压缩 / 延迟问题现在在哪回答——admin 任务检查器(DB)、Loki info 级事件、Tempo 里的 OTel trace——各自的查询与判读标准
面板的设计原则
不是“能看就加”,而是每个面板回答一个具体问题。问题越狭窄,出现异常时行动路径越短。
可观测拆成三个面。先读下一节:它决定你的问题该由哪个面回答,再去写查询。
三个面——各回答什么
| 面 | 适合 | 数据源 | 对应下文小节 |
|---|---|---|---|
| Admin 任务检查器 | per-task 深挖:本任务的 cache 读写 token、压缩次数 + 省量、每步预测 vs 实测 | DB stepUsages[](每步落库) | Cache & 压缩——per-task |
| Loki / LogQL | 跨部署聚合,基于 info 级事件:生命周期、压缩触发率、预算线 | pino → Loki(仅 info+) | Loki 面板 |
| OTel trace(Tempo) | 延迟 + 吞吐 + 每工具耗时——作为 span 属性,用 TraceQL 查 | @ai-sdk/otel → Tempo | 延迟/吞吐面板 |
贯穿这一切的约束:pino 只把 info+ 上报 Loki(apps/server/src/lib/logger.ts 把 debug 丢掉以保护免费档配额)。两个最与 cache 相关的事件——stream.step_usage(per-step usage)和 cache.breakpoints_placed——都是 debug 级,所以生产环境不进 Loki。它们的 per-task 细节落在 admin 检查器里。要在 Loki 里对它们上面板,先把它们提到 info 并接受量。
Cache & 压缩——per-task(admin 检查器)
per-task 的 cache 和压缩细节落在 admin 任务检查器里,数据来自 DB stepUsages[] 快照(agent-loop.ts 的 createOnStepEnd),每步落库 cacheReadTokens / cacheCreationTokens / predictedTokens / compactedTokens / durationMs。点开一个任务看:
- 这个任务的 prompt cache 在省钱吗? —— 每步
cacheReadTokensvscacheCreationTokens。跨任务的聚合读/写比是下面那个 TraceQL 面板。 - 压缩在触发吗,省了多少? —— 每步
compactedTokens和预测 vs 实测 input。 - cache 断点落对了吗? ——
cache.breakpoints_placedpayload{ messagesCount, stepIndex, placedAt, lastRole }(model.ts的markPrefixCacheBoundary)。placedAt是computeInnerAnchors+ 尾块标记的消息下标集合。在检查器的运行时上下文 tab 看。
Loki 面板——今天就能跑的 info 级事件
这些读的是确实上报 Loki(info+)的事件,所以生产环境直接能跑。
面板:压缩触发率 + 省量
问题——“in-loop 压缩多久触发一次,省了多少?”
sum(rate({job="zapvol-server", event="compaction.step_fired"}[5m]))
省量趋势:
avg_over_time(
{job="zapvol-server", event="compaction.step_fired"} | json | unwrap savedTokens [10m]
)
compaction.step_fired 带 { taskId, step, savedTokens }(agent-loop.ts),仅在某步新触发 reduce(savedTokens > 0)时 emit——所以这个 rate 是真实触发频率,不是每步 replay 的持续套用。
面板:预算线 vs 上下文窗口
问题——“实测 overhead(instructions + tools)离模型上下文窗口还有多远?”
{job="zapvol-server", event="compaction.budget_measured"}
| json
| line_format "instr={{.instructionsTokens}} tools={{.toolsTokens}} window={{.contextWindow}}"
compaction.budget_measured 带 { taskId, instructionsTokens, toolsTokens, contextWindow }(agent-loop.ts)。
面板:生命周期 & prefix 尺寸
task.created / task.completed(routes/tasks.ts)看生命周期 rate;stream.messages_prepared
{ inputMessages, modelMessages }(agent-loop.ts)看一轮起手的 prefix 尺寸;agent.created 看装配。全是 info+,都能安全上面板。
延迟/吞吐面板(TraceQL)
上面这些都在用日志或 DB 检查器回答 cache / 压缩问题——全都不带时延。stream.step_usage 有 token 数,但日志行里没有 TTFO、没有 tokens/s、没有每步耗时。这条轴活在 OTel span 里。
@ai-sdk/otel 产出的是 span、不是 metrics——它不注册 meter(见
可观测栈总览 → 分布式追踪)。所以时延作为 span 属性活在 Tempo 里,下面的面板是 TraceQL,挂在 Tempo data source 上——不是 PromQL、不是 Loki。Tempo 的 TraceQL-metrics 函数(rate、quantile_over_time、sum_over_time)直接从 span 现算聚合,不需要单独的 metrics 管道。
前提:启动时
registerTelemetry(new OpenTelemetry())。注册后 span 默认就 emit,没有 per-call 开关——SDK 7 去掉了experimental_telemetry: { isEnabled }。没注册之前,Tempo data source 是空的,这些面板读空。
面板:首字延迟(TTFO)
问题——“用户等多久才看到第一个 token 流出?” 体感延迟指标;这里劣化,即使总吞吐正常用户也能感觉到。TTFO 挂在 chat span 上:
{ name = "chat" } | quantile_over_time(span.gen_ai.client.operation.time_to_first_chunk, .95) by (resource.service.name)
正常值域跟模型强相关(看 p50/p95 的分布,不看绝对值)。告警看台阶式跳变(对齐某次发布或 provider 事故),或 p95 比 p50 偏离超过 ~3×(排队 / 限流退避)。
面板:每步耗时
问题——“一步端到端在变慢吗?” chat span 自身的 duration:
{ name = "chat" } | quantile_over_time(duration, .95) by (resource.service.name)
这里升高但 TTFO 稳定,说明是流中途生成变慢,而不是排队;和 onLanguageModelCallEnd 里的 output tokens per second 交叉核对。
面板:每工具执行耗时
问题——“一步里哪个 tool 是长尾?” execute_tool span 按 tool 名拆开,直接暴露慢工具——这件事日志现在逼你按时间戳重排才能推出来:
{ name =~ "execute_tool.*" } | quantile_over_time(duration, .95) by (span.gen_ai.tool.name)
再下钻:从慢任务的 trace_id 打开 invoke_agent 根 span,在一条时间线上看它的 execute_tool / chat 子节点——这正是日志难以廉价回答的“step N 卡在哪”。
面板:Cache 经济性(读/写 token 比)
问题——“cache 写入的内容被复用了吗,还是空耗?” chat span 带 gen_ai.usage.cache_read.input_tokens 和 cache_creation.input_tokens,各自作为一条 TraceQL 查询聚合:
{ name = "chat" } | sum_over_time(span.gen_ai.usage.cache_read.input_tokens)
{ name = "chat" } | sum_over_time(span.gen_ai.usage.cache_creation.input_tokens)
再在 Grafana 的 math 表达式里把两者相除。R > 3 健康(每次写被读 3+ 次);R < 1 告警(写多于读——cache 策略失效)。要 per-task 版的这个比值,admin 检查器的 stepUsages[] 才是权威——TraceQL 形态是跨部署聚合。
扩展新面板的工作流
Step 0:先选面
写任何查询之前,先判断这个问题属于三个面里的哪一个:
- operator 会点进去看的 per-task 细节 → admin 检查器(DB)。别为它加 Loki 事件。
- 跨部署聚合 / 趋势 / 告警 → Loki(必须是 info+ 事件)或 OTel trace(TraceQL)。
- 延迟 / 吞吐 → 只走 OTel trace(TraceQL);日志不带时延。
新 Loki 事件必须打在 info 或更高级别——log.debug(...) 在进 Loki 前就被丢了(见上面的上报约束)。量大的 per-step 数据有意留在 debug,该落 DB 检查器或 OTel trace,不是 Loki。
Step 1:先加 event,再加面板
绝不要把 Grafana 当代码写。 面板是数据的视图,不是业务逻辑的载体。先在 Zapvol 代码里 emit 一个结构化 event:
log.info("your_event.name", {
taskId,
/* 低基数字段 */ kind: "...",
/* 数值字段 */ someMetric: 42,
});
运行几次,在 Grafana Explore 里用 {event="your_event.name"} | json 确认能查到、字段对不对。
Step 2:定义面板四要素
在 Dashboard 创建之前,在文档里写下来(可以先在 PR 描述里):
- 问题:这个面板回答什么
- 查询:LogQL / TraceQL 原型
- 正常值域:不加入我的判读,别人也能看懂“这个数字该是多少”
- 异常信号:至少 2 条可操作的排查路径
写不出来说明问题没想清楚,不要开始画面板。
Step 3:Dashboard JSON 提交到 repo
Grafana 的 Dashboard → Settings → JSON Model 复制出来,放到:
ops/grafana/dashboards/{domain}-{name}.json
在同目录的 README.md 里追加导入说明和判读要点(或链接到本章对应小节)。
为什么要 commit JSON:Grafana 单实例丢了数据就没了。commit 到 repo 后,新人 15 分钟能在本地拉起等效 Grafana。也是 code review 的好载体——别人改了一个查询你能在 PR 里看到 diff。
Step 4:如果需要告警
用 Grafana 自带的 Alert rules(声明式 YAML,不要用点点点的 UI)。声明式规则也 commit 到 repo:
ops/grafana/alerts/cache-hit-degradation.yaml
告警规则要极度克制。Agent 系统里“可能出问题”的事很多,但“必须立刻处理”的事很少。默认阈值:
for: 15m以上——短暂波动不告警- 只告结果性指标(cache hit ratio、error rate、latency 尾部),不告过程性指标(断点数、压缩频率)
相关章节
- 可观测栈总览 — 为什么 logs 和 traces 都直推进 Grafana Cloud(无 collector),以及已上线的
@ai-sdk/otel追踪 - 压缩边界作为 cache 锚点
—
cache.breakpoints_placed的由来 - prepareStep 语义 — per-step 事件产生的位置