Day 14 / 共 20 天 · 第 3 周 数据与评测

Trace 观测:给每一次调用拍一张"体检片"

Day 13 的实验一跑就是几千次模型调用,出了问题怎么排查?答案是可观测性(Observability)模块——backend/modules/observability。今天走一遍"SDK 上报一条 Span → 落进 ClickHouse → 前端查询展示"的完整链路,这是理解"分布式追踪"最好的实战案例。

📍 你在整门课的位置 · 第 3 周 数据与评测(共 4 周 · 20 天)
D11 Dataset D12 评测集+评估器 D13 实验主链路 D14 Trace 观测 D15 跨模块组装图
L01

为什么需要 Trace

🤔 痛点:一次 Agent 回复,背后可能调了 8 次模型 + 3 次工具 用户反馈"这次回答很慢/答错了",你怎么知道慢在哪一步?是模型响应慢,还是检索工具超时,还是中间某次重试?没有留痕,出了问题只能两眼一抹黑。
💡 本质:Trace = 一次完整请求的"调用树",Span = 树上的一个节点 一次用户请求叫一个 Trace(有唯一 TraceID),它由多个 Span 组成——每次模型调用、每次工具调用都是一个 Span,Span 之间用 ParentID 连成树。观测系统要做的事就是:接收海量 Span 上报、存起来、支持按各种条件快速查询、在前端画成一棵树或火焰图。
生活类比:快递的物流轨迹 Trace 就像一个快递单号的完整轨迹——"发件揽收→分拨中心A→分拨中心B→派送→签收"。每一条轨迹记录(几点几分在哪个网点)就是一个 Span。你查一个单号(TraceID)能看到它经过的所有网点(Span),一旦哪一段卡了很久,一眼就能定位问题出在哪个环节。
L02

Span 长什么样

核心结构在 backend/modules/observability/domain/trace/entity/loop_span/span.go:157-182(节选真实字段):

type Span struct {
	StartTime      int64  `json:"start_time"` // 微秒
	SpanID         string `json:"span_id"`
	ParentID       string `json:"parent_id"`   // 靠这个字段串成一棵树
	TraceID        string `json:"trace_id"`    // 同一次请求下所有 Span 共享
	DurationMicros int64  `json:"duration_micros"`
	SpanType       string `json:"span_type"`   // model / tool / prompt / …
	Method         string `json:"method"`
	StatusCode     int32  `json:"status_code"`
	Input          string `json:"input"`
	Output         string `json:"output"`

	SystemTagsString map[string]string  `json:"system_tags_string"`  // 系统自动打的标签
	TagsString       map[string]string  `json:"tags_string"`         // 用户自定义标签
}
💡 TagsString 系列字段为什么按"类型"拆成好几个 map 注意有 TagsString / SystemTagsLong / SystemTagsDouble 等好几个不同类型的 map,而不是一个 map[string]interface{}。这是为了配合 Day 05 会讲的"ClickHouse Map 列"——ClickHouse 对同构 Map(值类型统一)的存储和查询效率远高于"什么都能装"的 JSON 列,所以在写入前就按值类型分好桶,牺牲一点点结构灵活性换查询性能。
SpanType 有哪些:常见的有 model(模型调用)、tool(工具调用)、prompt(Prompt 渲染)、chain/graph(编排节点,呼应之前教程里编排引擎的概念)。前端会根据 SpanType 决定用什么图标、展示什么专属字段(比如模型 Span 展示 token 用量,工具 Span 展示参数)。
L03

上报入口:三条路都能把 Span 送进来

backend/api/handler/coze/loop/apis/observability_open_apiservice.go 定义了三个开放接口:

POST /v1/loop/traces/ingest                # Coze Loop 自有格式的 SDK 上报
POST /v1/loop/opentelemetry/v1/traces        # 标准 OpenTelemetry 协议上报
POST /v1/loop/traces/search                  # OpenAPI 方式查询(给外部系统集成用)
💡 为什么同时支持自有格式和标准 OTel 协议 IngestTraces 面向"用 Coze Loop 官方 SDK 埋点"的场景,格式贴合自己的领域模型;OtelIngestTracesobservability_open_apiservice.go:38-45)则直接接收业界标准的 OpenTelemetry 协议数据——这样任何已经用 OTel SDK 埋点的现有系统,都不需要改代码,只要把上报地址指向 Coze Loop 就能接入。这是"不重新发明轮子、兼容生态标准"的典型设计。
生活类比 就像快递柜既支持"官方 App 扫码存件",也支持"任何第三方快递员用标准电子面单直接投递"——两条路走的是不同的登记方式,但最终都进了同一个柜子(Collector)。
L04

Ingestion:像流水线一样处理海量 Span

不管从哪条路进来的 Span,最终都汇入同一套 Collector 流水线backend/modules/observability/domain/trace/service/ingestion.go),三段式设计和很多可观测性系统(比如 OpenTelemetry Collector 本身)思路一致:

Receiver 接收器解析不同格式的上报数据(自有格式 / OTel 格式),统一转成内部 Span 结构
Processor 处理器做清洗、脱敏、采样、限流等中间加工(可插拔链式处理)
Exporter 导出器把处理完的数据写到最终存储,这里对应 clickhouseexporter/
// ingestion.go: 三种 Factory 拼成一套可插拔流水线
type IngestionCollectorFactoryImpl struct {
	receiverFactories  []receiver.Factory
	processorFactories []processor.Factory
	exporterFactories  []exporter.Factory
}

真正落库那一步,clickhouseexporter/clickhouse_exporter.go:30-51

func (c *ckExporter) ConsumeTraces(ctx context.Context, td consumer.Traces) error {
	tracesMap := make(map[loop_span.TTL]loop_span.SpanList)
	for _, td := range td.TraceData {
		ttl := td.TenantInfo.TTL          // 按数据保留时长(TTL)分组
		tracesMap[ttl] = append(tracesMap[ttl], td.SpanList...)
	}
	for ttl, spans := range tracesMap {
		c.traceRepo.InsertSpans(ctx, &repo.InsertTraceParam{Spans: spans, Tenant: td.Tenant, TTL: ttl})
	}
	return nil
}
⚠️ 按 TTL 分组写入的原因 不同租户/不同套餐可能配置"数据保留 7 天"还是"保留 90 天",ClickHouse 通常用不同的表或不同的分区策略来实现自动过期删除(TTL 是 ClickHouse 的原生特性)。写入前按 TTL 分组,就是为了把数据分别导向该进的那张表/分区,不然没法让 ClickHouse 自动清理过期数据。
L05

为什么落 ClickHouse,不是 MySQL

🤔 痛点:Trace 数据量有多大? 一次实验跑 2000 条数据、每条触发好几次模型/工具调用,一天下来可能是千万级 Span。而且查询模式几乎全是"按时间范围 + 若干标签过滤,聚合统计或分页浏览"——这和 MySQL 擅长的"事务型、按主键点查"完全是两种负载。
💡 本质:OLTP vs OLAP,选对工具比优化错的工具重要 MySQL 是 OLTP(在线交易处理)——擅长少量行的读写、强一致性、事务。ClickHouse 是 OLAP(在线分析处理)——列式存储,擅长"扫描海量行、按列聚合过滤",正好匹配"Trace 检索"这种分析型查询。Coze Loop 的存储选型策略很清晰:业务实体(用户/数据集/实验元信息)用 MySQL,海量分析型数据(Trace/Span)用 ClickHouse。
生活类比 MySQL 像"银行柜台账本"——每一笔存取款都要精确记录、不能出错,但你不会一次查一亿笔交易。ClickHouse 像"超市的销售分析系统"——你不关心某一笔具体交易的每个细节,而是想快速回答"过去一周哪个品类卖得最好"这种要扫描海量记录才能回答的问题。
L06

查询链路:四层往下传,一层比一层"脏"

前端点一下"查询 Trace",请求要经过四层,ListSpans 这个名字在每一层都出现,但含义逐层下沉:

application/trace.go:163TraceApplication.ListSpans——鉴权(CheckWorkspacePermission)、请求校验、DTO→内部请求结构转换
domain/trace/service/trace_service.go:1296TraceServiceImpl.ListSpans——构建过滤条件(SpanEnv/FilterFields),处理业务规则(分页/权限相关的平台过滤)
infra/repo/trace.go:205TraceRepoImpl.ListSpans——根据租户选存储后端(storageProvider.GetTraceStorage),拿到具体的 spanDao
infra/repo/ck/spans.go最底层 DAO——真正拼 ClickHouse SQL、执行查询、把行数据反序列化回 Span 结构
💡 为什么要挑一个 storageProvider.GetTraceStorage,而不是直连一个固定的 ClickHouse? 这是为多租户、多存储后端预留的抽象——不同工作空间可能配置了不同的 ClickHouse 集群(比如企业版允许自建存储),repo 层通过 StorageProvider 这一层间接寻址,domain 层完全不知道底层是哪个具体集群,这正是 AGENTS.md 强调的"domain 定义接口,infra 实现接口,domain 绝不引用 infra"的具体体现——从 ITraceServiceITraceRepo 都是接口,实现细节全部封装在 infra。

👶 小白问:四层都叫 ListSpans,会不会读错层?

👨‍🏫 老师:这正是 DDD 分层的"统一语言"(Ubiquitous Language)思路——同一个业务概念在每一层都用同名方法表达,方便跨层追踪。读代码时看包路径(application / domain/.../service / infra/repo)比看方法名更重要,配合 IDE 的"跳转到定义"逐层往下点就不会迷路。

L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • Trace 和 Span 的关系是什么?靠哪个字段串成树?
  • 为什么 Trace 有两条上报路径(自有格式 + OTel 标准协议)?
  • Ingestion 的 Receiver/Processor/Exporter 三段各做什么?
  • 为什么 Span 数据落 ClickHouse 而不是 MySQL?
  • 查询链路的四层分别叫什么,各自的职责边界在哪?
🎵 记忆口诀一次请求一个 Trace、每次调用一个 Span,兼容标准好接入,分析数据进 ClickHouse,四层同名不同职」——这条链路和 Day 13 的实验消费者流水线是同一种"分层 + 流水线"思维的两种应用。

✋ 动手 5 分钟(可选)

# 1. 看 Span 的完整字段定义
sed -n '157,190p' backend/modules/observability/domain/trace/entity/loop_span/span.go

# 2. 找到三条上报路由
grep -n "@router.*ingest\|@router.*opentelemetry" backend/api/handler/coze/loop/apis/observability_open_apiservice.go

# 3. 看 ClickHouse Exporter 怎么按 TTL 分组写入
sed -n '30,51p' backend/modules/observability/domain/trace/service/collector/exporter/clickhouseexporter/clickhouse_exporter.go

# 4. 沿着 ListSpans 逐层跳转(用 IDE 的"跳转到定义"体验更好)
grep -rn "func.*ListSpans(ctx" backend/modules/observability/
明天预告 · Day 15:这几天我们一个模块一个模块地深入,明天要"退一步"看全局——精读 api/api.goInit 函数,搞清楚 foundation→llm→prompt→data→evaluation↔observability 到底怎么被一行行代码组装起来的,认识 lodataset/lotrace 这类"本地客户端"的设计。
← 上一天 Day 13 下一天 · 跨模块组装图 →