可观测栈总览

pino → Alloy → Loki → Grafana 四层管道 + label cardinality 陷阱 + 三种部署形态 + 升级到 Prometheus/OTel 的时机判断

为什么把可观测当作第一等公民

Agent 系统的 bug 有一类特别隐蔽——行为上对、但经济上不对。LLM 依然返回了合理的答案,测试用例依然通过,但每一步都在重复 cache miss、每次压缩都比应该的更激进、每个 tool 调用都在掉 25% 的浮动预算。肉眼看不到,只能靠仪表盘暴露

本章介绍 Zapvol 现在走的一套可观测方案——pino → Alloy → Loki → Grafana——以及为什么选这四件、什么时候应该升级、陷阱在哪。不是部署手册,是运维这个系统需要的心智模型

四层管道全景

可观测性管道 pino → Alloy → Loki → Grafana —— 四层可替换 应用层 (Application Layer) @zapvol/server · @zapvol/backend · @zapvol/desktop pino —— 结构化 JSON,event-first schema AsyncLocalStorage 自动注入 traceId 与 userId stdout → JSONL 采集层 (Collection Layer) Grafana Alloy(OpenTelemetry Collector 血统) 读 stdout · 提取 JSON 字段 · 筛选 label River 配置;:12345 端口有可视化调试 UI 批量 + gzip 推送 存储层 (Storage Layer) Grafana Loki label 索引(低基数)+ 原文 chunks 存于对象存储 成本跟 label cardinality 挂钩,不跟日志总量 LogQL 查询 可视化层 (Visualization Layer) Grafana —— 仪表盘 · 声明式告警 Dashboard JSON 提交到 ops/grafana/dashboards/ 告警规则以 YAML 形式提交到 ops/grafana/alerts/

每一层都可以被替换,但这四件的组合是当前综合性价比最高的:全部开源、标准协议、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;
  // ...
}

每条日志自带 traceIduserId,不用显式传。排查时用 traceId 就能拎出一次完整请求的所有日志——跨 service、跨 async boundary。

已存在的关键 event(摘录)

Event 名发生位置业务含义
task.created / task.completedapps/server/src/routes/tasks.ts任务生命周期
stream.messages_preparedagent-round.ts一轮启动时 prepareInitialMessages 的结果
stream.step_finishedagent-round.ts onStepFinish每步 usage + cache 详情
cache.breakpoints_placedagent-round.ts prepareStepAnthropic cache 断点实际落点
compaction.step_triggered / .round_degraded / .tools_compacted / .llm_summarize_triggeredcompaction/step-compactor.ts三级压缩各级触发
agent.createdcreate-agent.tsToolLoopAgent 装配完成

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×。规则:

  1. Label = “我会用它做 sum by 分组的”;字段 = “我会用它做精确查询的”
  2. 禁止把 ID 类字段设为 label
  3. 新 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 字段已经够用。

相关章节

这页有帮助吗?