可观测栈总览
pino → Alloy → Loki → Grafana 四层管道 + label cardinality 陷阱 + 三种部署形态 + 升级到 Prometheus/OTel 的时机判断
为什么把可观测当作第一等公民
Agent 系统的 bug 有一类特别隐蔽——行为上对、但经济上不对。LLM 依然返回了合理的答案,测试用例依然通过,但每一步都在重复 cache miss、每次压缩都比应该的更激进、每个 tool 调用都在掉 25% 的浮动预算。肉眼看不到,只能靠仪表盘暴露。
本章介绍 Zapvol 现在走的一套可观测方案——pino → Alloy → Loki → Grafana——以及为什么选这四件、什么时候应该升级、陷阱在哪。不是部署手册,是运维这个系统需要的心智模型。
四层管道全景
每一层都可以被替换,但这四件的组合是当前综合性价比最高的:全部开源、标准协议、Grafana Cloud 提供托管免费档、横向扩展路径清晰。
为什么选这套——三个候选方案的对比
候选 1:ELK(Elasticsearch + Logstash + Kibana)
历史最悠久,功能最全。对 Zapvol 这种规模太重:
- Elasticsearch 是全文索引引擎,每个字段都建倒排索引。日志场景下 90% 字段从不被查询,索引成本全白烧
- Logstash 的 JRuby 运行时内存占用是 Alloy 的 5-10 倍
- Kibana 的权限模型复杂度远超单团队需要
结论:适合做全文搜索(“grep everything in prod”)场景,Zapvol 的日志查询都是结构化的(按 event、taskId、时间段过滤),ES 的强项派不上用场。
候选 2:OpenTelemetry Collector + 任意后端
最标准化。但 Alloy 本身就是基于 OTel Collector 的发行版,两者的差异主要在:
- Alloy 带 River 配置语言 + 可视化调试 UI(
:12345) - Alloy 对 Grafana 全家桶有开箱即用的最佳实践
- OTel Collector 通用性更强但配置更原始
结论:不逃离 Grafana 生态时,Alloy 是 OTel Collector 的 superset。真要用其他后端(Datadog、Honeycomb)时再换。
候选 3:商业化(Datadog / Honeycomb / Logz.io)
UI 最好,支持最全。钱的问题:
- Datadog 的日志定价是
$1.27/GB ingest + $2.50/M indexed events - 一个中等规模 agent 任务每步产生 5-10 条 info + debug 日志,20 步 × 100 任务/天 = 2 万条/天 = 6 万/月 = 小几百刀
- 开源方案同量级成本 < $10(对象存储 + 小 VM)
结论:钱宽裕的时候选。内部工具阶段没必要。
Zapvol 现有基建
pino 配置(apps/server/src/lib/logger.ts)
const pinoLogger = pino(
{ level: process.env.LOG_LEVEL || (isDev ? "debug" : "info") },
isDev ? pretty({ colorize: true, translateTime: "HH:MM:ss" }) : undefined,
);
开发态走 pino-pretty(彩色、好读),生产态默认 JSONL 到 stdout——这是 Alloy 能直接消费的格式,零中间转换。
event-first schema
log.info("task.created", { taskId, userId });
log.error("stream.failed", { taskId, err }, "Stream failed");
event 永远是第一个必需参数。 这是全系统的约定,贯穿 @zapvol/backend、@zapvol/server、@zapvol/desktop。后果:
- Grafana 里
event="task.created"就能精确筛选一类事件 - 所有事件名天然形成一个可审计的 event catalog
- 新开发者加日志时被迫想”这件事叫什么”——而不是写
log.info("something happened")
AsyncLocalStorage 注入
function mergeContext(event, data) {
const ctx = RequestContext.get();
if (ctx?.traceId) merged.traceId = ctx.traceId;
if (ctx?.userId) merged.userId = ctx.userId;
// ...
}
每条日志自带 traceId 和 userId,不用显式传。排查时用 traceId 就能拎出一次完整请求的所有日志——跨 service、跨 async
boundary。
已存在的关键 event(摘录)
| Event 名 | 发生位置 | 业务含义 |
|---|---|---|
task.created / task.completed | apps/server/src/routes/tasks.ts | 任务生命周期 |
stream.messages_prepared | agent-round.ts | 一轮启动时 prepareInitialMessages 的结果 |
stream.step_finished | agent-round.ts onStepFinish | 每步 usage + cache 详情 |
cache.breakpoints_placed | agent-round.ts prepareStep | Anthropic cache 断点实际落点 |
compaction.step_triggered / .round_degraded / .tools_compacted / .llm_summarize_triggered | compaction/step-compactor.ts | 三级压缩各级触发 |
agent.created | create-agent.ts | ToolLoopAgent 装配完成 |
Dashboard 建模时,把这些 event 当 primary key。
三种部署形态
形态 A:Grafana Cloud 免费档(推荐起步)
最简单。开 Grafana Cloud 账号、跑一个 Alloy 容器。成本:$0/月(50GB logs + 10K metrics + 50GB traces)。
# docker-compose.yml 片段
alloy:
image: grafana/alloy:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config.alloy:/etc/alloy/config.alloy
command: run /etc/alloy/config.alloy --server.http.listen-addr=0.0.0.0:12345
environment:
GRAFANA_CLOUD_LOKI_USER: ${GRAFANA_CLOUD_LOKI_USER}
GRAFANA_CLOUD_LOKI_TOKEN: ${GRAFANA_CLOUD_LOKI_TOKEN}
config.alloy(River 语言):
discovery.docker "zapvol" {
host = "unix:///var/run/docker.sock"
}
loki.source.docker "zapvol" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.zapvol.targets
forward_to = [loki.process.zapvol.receiver]
}
loki.process "zapvol" {
forward_to = [loki.write.cloud.receiver]
stage.json {
expressions = { event = "event", module = "module", taskId = "taskId" }
}
stage.labels {
values = { event = "", module = "" } // 只把低基数的提为 label
}
}
loki.write "cloud" {
endpoint {
url = "https://logs-prod-XX.grafana.net/loki/api/v1/push"
basic_auth {
username = env("GRAFANA_CLOUD_LOKI_USER")
password = env("GRAFANA_CLOUD_LOKI_TOKEN")
}
}
}
形态 B:自建单机
单台 VM 跑 Loki + Grafana + Alloy。数据留本地对象存储(S3 / Cloudflare R2)。
- 成本:VM + 对象存储 ≈ $10-30/月(取决于日志量)
- 运维:需要自己管 Loki 存储 retention、Grafana 升级
- 适合:已经有现成 VM 基建、不想把日志往外送的团队
形态 C:Kubernetes
Alloy 作为 DaemonSet,每个节点一个。Loki 以 StatefulSet 跑在集群内,或用托管 Loki。
- 成本:取决于集群规模
- 运维:标准 K8s 运维模式
- 适合:已经跑 K8s 的团队
起步强烈建议形态 A——零基建负担,用不爽再迁。Loki 数据可以通过 Grafana 的 loki-migrate 工具搬走。
以上三种是生产部署形态。本地 dev 有一条更轻量的路径——不用 Docker,不用起 Alloy 容器,让 pino 直接推 Loki——见 本地 dev 接入 Grafana Cloud。
Label Cardinality 陷阱(必读)
Loki 的存储成本几乎全部由 label 组合数(cardinality) 决定,不是日志量。规则:
| 字段 | 能否作为 label | 原因 |
|---|---|---|
event | 可以 | 有限枚举(几十种) |
module | 可以 | 有限枚举 |
level | 可以 | 5 种 |
taskId | 不可 | 高基数(每任务一个,几百万级) |
userId | 不可 | 中高基数 |
traceId | 不可 | 每请求一个 |
| 任何数值字段(tokens、ratio 等) | 不可 | 连续值 |
一条错误配置能让 Loki 慢 100×、存储涨 50×。规则:
- Label = “我会用它做
sum by分组的”;字段 = “我会用它做精确查询的” - 禁止把 ID 类字段设为 label
- 新 label 上线前先在预发环境跑一周,监控
loki_ingester_memory_streams
查询时用 | json 解析字段,和 label 的区别:
# label 过滤(快)
{event="stream.step_finished"}
# 字段过滤(慢,但不贡献 cardinality)
{event="stream.step_finished"} | json | taskId="abc-123"
两者能组合使用。正确做法:用 label 粗过滤到几百万条内,再用字段精筛。
什么时候该升级到 Prometheus 指标
日志适合查询具体某次行为(“这个任务为什么 cache miss”),指标适合长期趋势+告警(“过去 7 天 cache hit ratio 95 分位”)。
升级信号:
| 信号 | 动作 |
|---|---|
| 某个 Dashboard 查询经常超过 30 秒 | 把那个指标用 prom-client emit,从 Prometheus 查 |
| 需要声明式告警规则(“ratio < 0.3 持续 5 分钟”) | Prometheus Alertmanager |
| 日志量接近 Grafana Cloud 免费档上限 | 把 debug 级 event 降级为 metrics、只保留 info+ 在 Loki |
升级路径:不用换 Alloy。Alloy 同时支持 prometheus.scrape(拉)和 prometheus.remote_write(推),应用里加
prom-client 就能接上。
什么时候该升级到 OpenTelemetry Traces
Traces 适合一次请求跨多个服务的场景。Zapvol 的 agent 执行流程横跨:
- Server 端
task-orchestrator.ts - Agent engine 的 20+ step 循环
- Tool 调用穿 sandbox
- BUA session 穿 WebSocket
如果调试”一个任务从进来到出去每一步耗时多少”是常规需求,Traces 会比日志高效一个量级。升级信号:
- 排查任务延迟时,要在 10+ 条日志里按时间排序推导
- 出现 “step N 卡住了” 类问题但日志里看不出卡在哪
- 有多 service 协作但 traceId 追不完全路径
升级路径:在 pino 里挂 OpenTelemetry logs appender + 在热点代码加 tracer.startSpan。后端从 Loki-only 改成 Loki +
Tempo。
内部工具阶段通常用不上。日志里的 traceId + 时间戳 + duration 字段已经够用。
相关章节
- 可观测面板 — 当前内置的 4 个核心 Dashboard 和判读标准
- 本地 dev 接入 Grafana Cloud — Dev 机通过 pino-loki opt-in 直接推日志
- Context Compaction
— 压缩边界如何影响 cache 断点设计(
cache.breakpoints_placed事件的由来) - prepareStep 语义 —
stream.step_finished事件产生的位置