可观测面板
Zapvol 内置的 4 个核心 Dashboard——LogQL 查询 + 判读标准 + 异常时的排查路径
面板的设计原则
不是”能看就加”,而是每个面板回答一个具体问题。问题越狭窄,出现异常时行动路径越短。
本章收录的 4 个面板全部围绕本次 cache 改造展开——不是碰巧,这正好是 Zapvol 第一次把”压缩 + cache + prepareStep”三件事联动编排的地方,最容易出错,也最值得监控。
每个面板按统一结构描述:
- 问题——这个面板回答什么具体疑问
- 查询——LogQL 代码(可直接复制到 Grafana Explore)
- 正常值域——什么叫健康
- 异常信号——什么叫要紧急排查
- 关联事件——这个面板读哪些 log event
面板 1:Cache 命中率趋势
问题
“Anthropic prompt cache 在本部署里真的在省钱吗?”
这是 cache 改造的首要验证指标。没有它,所有其他”断点落位正确、压缩边界稳定”的理论推演都只是理论。
查询
avg_over_time(
{job="zapvol-server", event="stream.step_finished"}
| json
| unwrap cacheHitRatio
[5m]
)
正常值域
| 阶段 | 预期 ratio |
|---|---|
| 任务首步(step 0) | < 10%——首次请求,没东西可 cache |
| 第 2 步起 | ≥ 60%——system prompt + stable prefix 都该命中 |
| 多步任务后段(step 5+) | 70-85%——理想区间 |
异常信号
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| 持续 < 30% | AI SDK propagation 假设不成立,或 Anthropic 端 5 分钟 TTL 在步间隙过期 | 看面板 2 的断点分布 |
| 首步就 > 30% 但后续掉到 < 20% | system prompt 被改变(prompt caching 失效),或 Anthropic 账户 cache 被禁用 | 检查 createCachedInstructions 是否在所有请求里返回相同的 system |
| 长期维持在 40-55% 区间 | 有效断点数不足 4(Rule 2 没触发) | 面板 2 定位 |
| 某些任务 ratio = 0 | 非 Anthropic provider(OpenAI / Google),cache 字段来自 provider raw | 过滤 provider != "anthropic",正常 |
关联事件
stream.step_finished——读cacheHitRatio字段- 交叉检查:
cache.breakpoints_placed(设计端) vsstream.step_finished.cacheHitRatio(观测端)
面板 2:断点落位分布
问题
“applyCacheControl 的三条规则 + extraBreakpointAt 都在正确触发吗?”
面板 1 告诉你”cache 在工作吗”,面板 2 告诉你”为什么在 / 不在工作”。
查询
sum by (placed_count) (
count_over_time(
{job="zapvol-server", event="cache.breakpoints_placed"}
| json
| label_format placed_count="{{len .placedAt}}"
[1h]
)
)
输出:每 1 小时窗口内,每种”断点数量”分别出现多少次。
正常值域
Anthropic 允许每请求 4 个 cache_control 断点(system prompt 不占此 4 个,由 createCachedInstructions
单独管理)。applyCacheControl 在 messages[] 里最多放 3 个:
- Rule 1(last message)
- Rule 2(last user,仅 last ≠ user 时)
- Rule 3 extraBreakpointAt(
compactedPrefixEnd或 length/2 回退)
所以 placed_count 的正常分布:
| placed_count | 场景 | 比例 |
|---|---|---|
| 3 | 压缩已触发 + last ≠ user(典型 agent tool-loop 步) | 应占 60-80% |
| 2 | 无压缩(短对话)+ last ≠ user,或有压缩 + last = user | 15-30% |
| 1 | 首步,last = user 且无压缩 | 5-10% |
| 0 | 异常——messages 为空 | 应为 0 |
| 4+ | 不可能——marked: Set 去重,上限 3 | 应为 0 |
异常信号
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
placed_count = 1 占比 > 30% | Branch B 场景(last=tool_result)里 Rule 2 被伪 user 骗过 → applyCacheControl 在 reminder 注入之后被调用 | 查 agent-round.ts prepareStep 里 applyCacheControl 是否在 reminder 注入之前 |
extraBreakpointUsed: false 持续 true 但压缩本应触发 | compactedPrefixEnd = -1 异常——appliedCrCount 没在 activateMoreRoundReplacements 里递增 | 查 compaction.step_triggered 事件是否正常,再查 compaction.round_degraded 的 activatedCount |
| 分布整体偏低(大部分 = 1) | applyCacheControl 压根没被调用,或 isAnthropicModel() 返回 false | 检查 provider 配置;Gemini / OpenAI 路径本来就不走这里 |
关联事件
cache.breakpoints_placed——主数据源compaction.round_degraded——确认压缩确实在消费 fallbackagent.created——确认模型确实是 Anthropic
面板 3:Cache 读/写 token 比
问题
“cache 写入的内容被重复利用了吗?还是在空耗?”
cacheWriteTokens 是把新 prefix 写入 Anthropic cache 的成本(相当于 1.25× input token 价)。cacheReadTokens
是从 cache 读出的收益(相当于 0.1× input token 价)。比值 R = read / write:
- R >> 1:cache 命中率高,写一次读多次——理想
- R ≈ 1:每次写都没怎么被读——orphan write
- R < 1:大量 cache 写但读不回——严重浪费
查询
sum(rate({job="zapvol-server", event="stream.step_finished"} | json | unwrap cacheReadTokens [5m]))
/
sum(rate({job="zapvol-server", event="stream.step_finished"} | json | unwrap cacheWriteTokens [5m]))
正常值域
| R 值 | 判读 |
|---|---|
| > 3 | 健康——每次写入被读 3+ 次 |
| 1.5-3 | 可接受——读多于写,但 cache 被改写较频繁 |
| 1-1.5 | 临界——写入几乎只被读一次就被下次写入覆盖 |
| < 1 | 告警——写得多读得少,cache 策略失效 |
异常信号
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| R < 1 且面板 1 显示 hit ratio 正常 | Grafana Cloud 免费档时间窗错误聚合(分子分母 rate 窗口不对齐),不一定真异常 | 改成 sum_over_time 替代 rate 再看 |
| R < 1 且面板 1 hit ratio 也低 | 断点位置漂移(length/2 启发 / reminder 污染),每步都在写新 prefix 但下步位置不同 | 面板 2 查断点分布,回代码检查 reminder 注入顺序 |
| R ≈ 1 持续 | 5 分钟 TTL 在步间隙过期(任务节奏慢),Anthropic cache 被动失效 | 监控步与步之间的间隔,考虑批量化步骤 |
关联事件
stream.step_finished——cacheReadTokens+cacheWriteTokens
面板 4:异常任务 Top-N
问题
“过去 1 小时哪些任务的 cache 行为反常?”
前三个面板是聚合视角,本面板给个案视角——按具体 taskId 列出低命中率步,供点进去深挖。
查询
{job="zapvol-server", event="stream.step_finished"}
| json
| cacheHitRatio < 0.3
| line_format "taskId={{.taskId}} step={{.step}} ratio={{.cacheHitRatio}} inputTokens={{.inputTokens}}"
正常值域
首步 cache ratio < 0.3 是正常的,不要大惊小怪。真正值得关注的是:
- 同一
taskId在 step 3+ 仍然 < 0.3 - 一个时间段内多个不同
taskId同时跌破 0.3(系统性问题而非单任务问题)
异常信号
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| 单个任务持续低 ratio | 该任务的 system prompt 或 context 有非确定性内容(时间戳、随机 ID 进 system) | 复制 traceId,拉出该任务的 cache.breakpoints_placed 序列,看断点位置是否稳定 |
| 某时间窗内大量任务同时低 ratio | 版本发布 / 配置变更 / Anthropic 侧 cache 异常 | 看发布时间戳是否对齐;查 Anthropic 状态页 |
| 特定模型 ID 的任务低 ratio | 该模型 provider 不支持 cache,或 cache 定价/行为不同 | 检查 createCachedInstructions 对该 provider 的处理 |
关联事件
stream.step_finished——主数据源- 点入某个 taskId 后,切换到:
{traceId="<id>"} | json——看该任务的全部日志
扩展新面板的工作流
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 原型
- 正常值域:不加入我的判读,别人也能看懂”这个数字该是多少”
- 异常信号:至少 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 尾部),不告过程性指标(断点数、压缩频率)
相关章节
- 可观测栈总览 — pino → Alloy → Loki → Grafana 四层管道的设计理由
- 压缩边界作为 cache 锚点 — 面板 1/2/3 观测的是这里定义的行为
- prepareStep 语义 —
cache.breakpoints_placed事件在哪里产生