数据截至 (上游 commit d3b797b4c5cc)
可观测子系统 — Trace 上报到落库到查询
本章讲什么: 你的 Agent 跑一次,背后 prompt→model→tool→parser 一连串步骤。可观测子系统把每步记成一个 span,串成 trace,存进 ClickHouse,供你回放与排障。本章追三条路:① span 怎么上报落库 ② span 长什么样 ③ 怎么查回来 + 算指标。
1. 先建直觉:span 与 trace
- span = 一段被计时的操作(一次模型调用、一次工具调用、一次 prompt 渲染)。有开始时间、耗时、输入、输出、状态。
- trace = 同一次请求里所有 span,用
parent_id串成的一棵树,trace_id是这次请求的身份证。
和通用 APM(如 OpenTelemetry)一样的模型,但 CozeLoop 的 span 专为 LLM 场景定制:内建 model_name、input_tokens、output_tokens、model_provider、prompt_key 这些字段(见 span.go:40-57 的 SpanField* 常量),span 类型也带 prompt/model/tool/agent/embedding 等 LLM 语义(span.go:65-80 SpanType*)。
2. 路径一:span 怎么上报落库
2.1 入口:IngestTraces
SDK 把一批 span POST 到 OpenAPI,最终进入 TraceServiceImpl.IngestTraces(trace_service.go:1536):
// trace_service.go:1536(真实源码,节选+精简注释)
func IngestTraces(ctx, req) error {
processors := buildHelper.BuildIngestTraceProcessors(...) // 取一组上报期处理器
for _, p := range processors {
req.Spans, _ = p.Transform(ctx, req.Spans) // 逐个 transform(裁剪/脱敏/补字段)
}
traceData := &entity.TraceData{ Tenant: req.Tenant, SpanList: req.Spans, ... }
traceProducer.IngestSpans(ctx, traceData) // 丢进 MQ,不在请求里同步落库
}
关键设计:上报接口不直接写库,而是 transform 后丢 MQ,把昂贵的落库异步化,保护上报链路低延迟。
2.2 落库管道:OTel 风格的 collector
MQ 那头是一个仿 OpenTelemetry Collector 的管道。IngestionService(domain/trace/service/ingestion.go:18)启动一个 collector.Collector,它由三类工厂组装(:33):
| 阶段 | 角色 | 实现位置 |
|---|---|---|
| receiver | 收数据(从 RocketMQ 拉 span) | domain/trace/service/collector/receiver/rmqreceiver/ |
| processor | 加工(批处理、转换) | domain/trace/entity/collector/processor/ |
| exporter | 落地(批量写 ClickHouse) | domain/trace/entity/collector/exporter/ |
这套 Factories{Receivers, Processors, Exporters} 结构(ingestion.go:34-48)、Consumer 链、MakeFactoryMap 命名,全是 OTel Collector 的影子。复用成熟管道抽象而不是自己撸一套消费循环,是个聪明的工程选择。
管道里还有一层 ObserveConsumer(collector/consumer/observe_consumer.go:31)做自监控:它包住真正的 consumer,按 PSM/租户分组打吞吐 、延迟、span 数指标(:51-65),并用 stopwatchConsumer(:73)扣掉内层耗时算出"本节点自身耗时"——一个干净的装饰器埋点模式。
SDK ─POST─▶ IngestTraces ─transform─▶ MQ(RocketMQ)
│
┌─────────────▼──────────────┐
│ Collector 管道 │
│ rmqreceiver 收 span │
│ ▼ │
│ processor 批处理/转换 │
│ ▼ │
│ exporter 批量写 CK │
│ (ObserveConsumer 全程埋点) │
└─────────────┬──────────────┘
▼
ClickHouse spans 表
2.3 真正写 CK
exporter 最终调 SpansCkDaoImpl.Insert(infra/repo/ck/spans.go:52):批量 Create,带 3 次重试,注释点明取舍——满足条件的批写入幂等,但网络错误重试可能导致重复写(:54-57)。这是"宁可偶尔重复也不丢"的可观测系统常见权衡。
3. 路径二:span 实体长什么样
loop_span.Span(span.go:157)是中心数据结构。它的字段设计揭示了几个重要决策:
3.1 标签按类型分桶
// 示意,非源码:span 的标签存储(取自 span.go:178-187)
TagsString map[string]string // 字符串标签
TagsLong map[string]int64 // 整型标签
TagsDouble map[string]float64 // 浮点标签
TagsBool map[string]bool
// 另有 SystemTags*(系统注入)与之并行
为什么按类型分三个 map 而不是一个 map[string]any? 因为落到 ClickHouse 要进强类型列,分桶后写库直接对应 tags_string/tags_long/tags_double 列,查询时也能用对应类型的索引与聚合(如对 tokens 求和必须是数值列)。GetSystemTags(span.go:225)把它们统一拍平成字符串供展示。
3.2 大字段外移到对象存储
span 的 Input/Output 可能很大(整段对话)。ObjectStorage(span.go:195)+ AttrTos(span.go:215)把大输入/输出/多模态附件存到对象存储(TOS)只在 span 里留 key/URL。这呼应了评测侧反复出现的 IsContentOmitted()/大字段裁剪——全系统统一的"大字段不入主表"策略,控制 ClickHouse 行宽和 MQ 消息体。
3.3 TTL
span 带 TTL(span.go:93-102,3d~365d)与 LogicDeleteTime,按租户/套餐决定保留期。TTL 直接影响 ClickHouse 的过期清理与查询的时间窗口下界。
4. 路径三:怎么查回来 + 算指标
TraceServiceImpl 的查询方法群(trace_service.go)覆盖各种查法:
| 方法 | 干什么 |
|---|---|
GetTrace (:1201) | 按 trace_id 拉一棵完整 trace |
ListSpans (:1294) | 按条件分页列 span |
SearchTraceOApi / ListSpansOApi (:1365/:1424) | OpenAPI 对外查询 |
GetTracesAdvanceInfo (:1566) | 算 trace 的 token/成本/大小汇总 |
ListMetadata (:1700) | 列可筛选的元数据维度 |
ListAnnotations (:1801) | 列某 span 的标注(人工反馈/自动评分) |
指标聚合直接下推到 ClickHouse
GetMetrics(spans.go:95)/buildMetricsSql(:109)很能体现 CK 的用法:它先复用普通查询 SQL 当内层子查询,外层再套 GROUP BY、时间分桶(toStartOfInterval + fromUnixTimestamp64Micro,:135)、聚合函数,拼成:
SELECT <聚合表达式...>, <时间桶>
FROM ( <复用的过滤查询> )
GROUP BY <time_bucket, 维度...>
(模板 metricsSqlTemplate,spans.go:93/:156)。把指标计算下推给 ClickHouse(而非拉回内存算),是用对了列存 OLAP 的强项——按时间桶/维度聚合海量 span 正是 CK 的主场。
5. 巧妙之处(本章带走的精华)
- 上报与落库解耦:IngestTraces 只 transform + 发 MQ,落库异步化,保护上报延迟。
trace_service.go:1536。 - 复用 OTel Collector 抽象:receiver→processor→exporter + Consumer 链,不重造消费循环。
domain/trace/.../collector/。 - 装饰器式自监控:
ObserveConsumer包住内层算自身耗时与吞吐。observe_consumer.go:31。 - 标签按类型分桶:对齐 ClickHouse 强类型列,写得进、查得快。
span.go:178-187。 - 大字段外移对象存储:主表只存 key,控行宽控消息体。
span.go:195。 - 指标 SQL 下推:子查询外套 GROUP BY + 时间桶,吃尽列存聚合能力。
spans.go:109。
6. 边界与局限
- 落库异步:上报成功 ≠ 立即可查,存在 MQ→落库的延迟窗口。
- CK 写入重试可能重复(
spans.go:54),下游需容忍/去重。 - 大字段在对象存储,查全文需二次拉取(
AttrTos的 URL)。
7. 代码地图(本章导航)
| 主题 | 文件 | 符号 |
|---|---|---|
| 上报入口 / 全部查询 | modules/observability/domain/trace/service/trace_service.go | IngestTraces、GetTrace、ListSpans、GetTracesAdvanceInfo、ListMetadata |
| 落库管道启动 | modules/observability/domain/trace/service/ingestion.go | IngestionService、IngestionCollectorFactory、NewIngestionServiceImpl |
| 管道自监控 | modules/observability/domain/trace/entity/collector/consumer/observe_consumer.go | ObserveConsumer、stopwatchConsumer |
| receiver/processor/exporter | modules/observability/domain/trace/.../collector/ | receiver.Factory、processor.Factory、exporter.Factory |
| span 实体 | modules/observability/domain/trace/entity/loop_span/span.go | Span、SpanField*、SpanType*、ObjectStorage、TTL |
| CK 落库与查询 | modules/observability/infra/repo/ck/spans.go | SpansCkDaoImpl.Insert、Get、GetMetrics、buildMetricsSql |
| 应用层入口 | modules/observability/application/ | trace.go、openapi.go、ingestion.go、metric.go |
下一章把两子系统接起来:trace 怎么变数据集、在线评测怎么把分数写回 span。