做 AI 系统时,有一种问题特别折磨人:
- 用户说:“刚才那次请求很慢”、“语音突然没返回”、“AI 回答到一半断了”、“页面显示成功但实际没有结果”。
- 你打开日志一看,每个模块都好像没有明显错误:
- API 层有请求日志。
- 模型调用层有耗时日志。
- WebSocket 层有连接日志。
- 任务队列里也能看到消费记录。
但这些日志像散落在地上的碎片,很难拼成一条完整链路。
尤其在 AI 系统里,这种问题会被进一步放大。因为一个用户请求往往不是一次简单的 HTTP 调用,而是会穿过多个物理与异步边界:
更麻烦的是,AI 请求天然具有不确定性:
- 模型响应时间不稳定:受网络、负载及 Token 数量影响极大。
- 流式输出可能中途失败:网络抖动或上游限流随时会导致连接中断。
- 上下文长度影响延迟:越长的 Context,首字延迟(TTFT)和总耗时越高。
- 多模态输入处理复杂:音频、图片、文本的接收与处理耗时差异巨大。
- 多协程协作:多个 Goroutine 可能共同处理同一个任务,导致日志顺序错乱。
用户端看到的问题,往往发生在服务端链路的后半段。所以 AI 系统排查最难的地方,不是“有没有日志”,而是:当一次请求跨过多个模块、多个异步边界、多个 IO 系统后,你还能不能把它重新串起来。
这就是 TraceID 和全链路日志的价值。它不是为了让日志变多,而是为了让每一条日志都能回答同一个问题:这条日志,属于哪一次用户请求?
一、 为什么 AI 系统比普通业务系统更容易变成黑盒
传统 CRUD 系统的问题链路通常比较短。一次请求进来,查数据库,做业务判断,返回结果。如果出错,大多数时候在 API 日志、SQL 日志或错误堆栈里就能定位。
但 AI 系统不是这样。以一次语音 AI 请求为例,它经历了极其漫长且复杂的链路:
在这条链路里,任何一个环节异常都会导致严重的用户体验问题:
- ASR 成功了,但 LLM 超时了。
- LLM 成功了,但 WebSocket 推送失败了。
- WebSocket 推送成功了,但前端没有正确处理最后一个事件。
- 模型返回了内容,但落库失败导致历史记录缺失。
- 队列任务被消费了,但上下文信息没有正确传递。
- 重试机制触发了两次,导致用户收到重复结果。
这就是黑盒感的来源:不是系统真的没有日志,而是日志之间缺少共同的坐标系。
TraceID 做的事情,就是给这条链路建立一个坐标系。
二、 TraceID 不是一个字段,而是一条请求的生命线
在分布式 AI 系统里,TraceID 更像是一条请求的生命线。它应该从请求进入系统的第一刻开始出现,并且在每一次边界切换时被继续带下去。
这些边界包括:
- HTTP 请求边界
- WebSocket 连接边界
- Goroutine 异步边界
- 消息队列边界
- 第三方模型调用边界
- 数据库写入边界
- 定时任务或回调边界
[!IMPORTANT]
只在 API 层生成 TraceID 没有意义。真正有用的是,它能不能穿过系统的每一层。
一个比较理想的链路应该是这样:
text
trace_id = T-001
HTTP request received
└─> auth checked
└─> quota checked
└─> task created
└─> queue published
└─> worker consumed
└─> embedding requested
└─> vector search completed
└─> llm stream started
└─> websocket chunks pushed
└─> final message persisted
当用户反馈问题时,只要拿到 trace_id = T-001,就应该能把这一次请求的完整行为捞出来。
- 如果你只能查到 HTTP 层日志,查不到 worker 日志,说明 TraceID 在队列边界断了。
- 如果你能查到模型调用日志,查不到 WebSocket 推送日志,说明 TraceID 在长连接会话里断了。
- 如果你能查到异步任务日志,但查不到用户请求入口,说明异步任务变成了孤岛。
三、 在 Go 里,TraceID 应该从 context 开始
在 Go 项目中,最自然的 TraceID 载体是 context.Context。
它本来就是为了在请求范围内传递取消信号、超时、元信息而设计的。一个常见的做法是,在接入层生成或读取 TraceID,然后把它放进请求上下文。
go
func TraceMiddleware(next Handler) Handler {
return func(ctx Context, req Request) Response {
// 1. 优先读取上游传来的 TraceID
traceID := req.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = NewTraceID() // 2. 没有时再自动生成
}
ctx = WithTraceID(ctx, traceID)
resp := next(ctx, req)
// 3. 把 TraceID 写回响应头,方便前端/用户上报
resp.Header.Set("X-Trace-ID", traceID)
return resp
}
}
关键细节
- 优先读取上游传来的 TraceID:如果你的系统前面还有网关、BFF 或客户端 SDK,它们可能已经生成了 TraceID。直接覆盖会切断跨服务链路。
- 写回响应头:这一步极具实操价值。用户反馈问题时,前端可以把响应里的 TraceID 一并上报,排查效率会高很多。
- 避免在业务代码中直接读写裸字符串 key:使用强类型包装读写逻辑,防覆盖防冲突。
go
type traceIDKey struct{}
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey{}, traceID)
}
func TraceIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(traceIDKey{}).(string); ok {
return v
}
return ""
}
四、 日志封装:不要让业务代码手动拼 TraceID
如果依赖业务开发者在打日志时手动拼写 TraceID,系统里很快就会出现风格迥异的代码(如 trace_id=xxx、traceId=xxx、tid=xxx 等),导致日志平台无法统一检索。
正确的方案是将 TraceID 的注入作为日志基础设施的一部分:
go
func Info(ctx context.Context, msg string, fields ...Field) {
fields = appendTraceFields(ctx, fields...)
logger.Info(msg, fields...)
}
func Warn(ctx context.Context, msg string, fields ...Field) {
fields = appendTraceFields(ctx, fields...)
logger.Warn(msg, fields...)
}
func Error(ctx context.Context, msg string, fields ...Field) {
fields = appendTraceFields(ctx, fields...)
logger.Error(msg, fields...)
}
func appendTraceFields(ctx context.Context, fields ...Field) []Field {
if traceID := TraceIDFromContext(ctx); traceID != "" {
fields = append(fields, String("trace_id", traceID))
}
return fields
}
现在,业务开发编写代码时,只需关注核心业务字段:
go
log.Info(ctx, "llm stream started",
String("model", modelName),
Int("prompt_tokens", promptTokens),
)
最终生成的结构化 JSON 日志会自动富集 TraceID:
json
{
"level": "info",
"time": "2026-06-23T10:21:03.120+08:00",
"msg": "llm stream started",
"trace_id": "T-001",
"model": "model_x",
"prompt_tokens": 1024
}
五、 AI 链路中容易折断的三大边界
5.1 Goroutine 异步边界
在 Go 中,我们习惯直接使用 go func 开协程处理后台任务:
go
// 错误示范:上下文断连
go func() {
processJob(job)
}()
这会导致任务与原有请求上下文脱钩。更稳健的做法是显式传递上下文:
go
// 改进版:传递 Context
go func(ctx context.Context, job Job) {
processJob(ctx, job)
}(ctx, job)
[!WARNING]
警惕连接断开:原请求的ctx会随着 HTTP 连接的意外中断而被 Cancel。如果异步后台任务必须继续执行,应该复制 TraceID 并重建无取消语义的任务级 Context。
go
// 正确做法:延续追踪,但解绑取消信号
taskCtx := NewBackgroundContext()
taskCtx = WithTraceID(taskCtx, TraceIDFromContext(ctx))
go func(ctx context.Context, job Job) {
processJob(ctx, job)
}(taskCtx, job)
5.2 消息队列(MQ)边界
当任务进入队列时,生产者和消费者的进程上下文是彻底断开的。我们必须将 TraceID 写入消息的**元数据元组(Metadata)**中传递:
go
type JobMessage struct {
ID string
Payload Payload
Metadata map[string]string // 存放追踪元数据
}
// 生产者发布任务:
msg.Metadata["trace_id"] = TraceIDFromContext(ctx)
queue.Publish(msg)
// 消费者消费任务:
func Consume(msg JobMessage) {
ctx := context.Background()
ctx = WithTraceID(ctx, msg.Metadata["trace_id"]) // 恢复 TraceID
process(ctx, msg.Payload)
}
5.3 WebSocket 长连接边界
WebSocket 是一条持续连接的通道,中间可能会承载数十次用户的 AI 对话和流式推送。如果在建立连接时只生成一个 ID,会让多轮对话的日志乱作一团。
正确的做法是区分两个概念:
connection_id:用于标识并跟踪这一条特定的 WebSocket 物理连接。trace_id:用于标识每一次具体的请求事件或单轮对话任务。
text
connection_id = C-001, user_id = U-001
├── trace_id = T-101, event = user_audio_started
├── trace_id = T-101, event = llm_chunk_pushed
└── trace_id = T-101, event = response_completed
六、 全链路日志不是把所有东西都打出来
为了避免日志平台存储爆炸、性能下降和噪音泛滥,全链路日志应遵循“关键节点 + 阶段耗时”的原则,而非全量记录。
我们将日志结构化为以下三类核心数据:
1. 链路事件日志(Where)
用于精确描述请求走到了哪个核心节点:
request_received->auth_checked->retrieval_started->llm_started->tts_completed->message_persisted
2. 性能指标日志(Why Slow)
用于定量回答慢请求的瓶颈所在:
http_latency_ms(接口耗时)queue_wait_ms(队列等待)llm_first_token_latency_ms(首字延迟,至关重要)llm_total_latency_ms(模型推理总耗时)tts_latency_ms(语音合成耗时)
3. 结果状态日志(Success Or Not)
在 AI 链路中,请求存在“部分成功”的灰色状态(例如:模型返回文字成功了,但是 TTS 语音合成失败了)。必须精细化记录状态:
status=success|failed|canceled|partial_successerror_code=upstream_timeoutretry_count= 2
七、 哪些内容不应该进入日志(脱敏规范)
AI 输入与输出通常包含用户隐私、敏感的专有知识库碎片或内部 Prompt 设计。未经脱敏全量打入日志属于严重安全漏洞。
| 🟢 建议记录的字段 | 🟡 谨慎记录的字段 | 🔴 默认禁止记录的字段 |
|---|---|---|
trace_id |
prompt 长度(Token数) |
用户原始输入的 Prompt 文本 |
user_id_hash (哈希值) |
response 长度 |
模型生成的完整 Response 文本 |
request_type |
命中的知识库文档 ID | 原始音频/转写出的明文 |
model_alias (模型别名) |
调用的工具函数名称 (Function) | Token / API Key / 鉴权密钥 |
latency_ms (各阶段耗时) |
用户手机号、邮箱等 PII 隐私数据 | |
status & error_code |
内部系统真实服务 IP 和地址 |
八、 一次真实排查应该怎么用 TraceID
假设用户反馈:“刚才我说完话以后,AI 过了很久才回答,而且回答到一半断了。”
利用 TraceID,排查逻辑会像剥洋葱一样简单:
通过链路排查,你可以精准还原现场:
- 排除 ASR 故障:ASR 耗时 920ms,正常。
- 锁定首字延迟:LLM 的首 Token 耗时达到了 2600ms,定位到是模型服务过载或冷启动。
- 定位中断原委:日志中显示
llm_stream_interrupted,错误码为upstream_timeout,说明是模型侧的 Stream 突然中断,从而引发了 WebSocket 关闭。
九、 TraceID、RequestID、SpanID 协同设计
TraceID:标识一次端到端的完整业务链路(比如用户发起的一轮多模态对话)。RequestID:标识链路中的某一次具体网络请求。SpanID:标识链路中某一个具体的微小执行步骤或子模块调用。
在复杂的大模型工程架构中,建议通过日志规范预留如下字段设计,便于后续无缝对接 Loki、ELK 甚至 OpenTelemetry 分布式链路追踪系统:
json
{
"trace_id": "T-101",
"request_id": "R-202",
"span_id": "S-303",
"connection_id": "C-001",
"span_name": "vector_search",
"latency_ms": 150,
"status": "success"
}
十、 落地时最容易踩的六个坑
- 只有错误日志带 TraceID:排查时你必须清楚错误发生前系统在做什么。因此,链路关键点上的 Info、Warn 日志也必须统一带上 TraceID。
- TraceID 在异步任务(Goroutine/Queue)中丢失:队列边界未显式解析 Metadata,或开辟新 Goroutine 时直接传递了空上下文。
- 一个 WebSocket 长连接从始至终只用一个 TraceID:必须使用
connection_id标识连接,用trace_id区分连接里的每轮请求。 - 日志字段命名不统一:
traceId、trace_id、tid混用,导致聚合分析时提取失败。一律规范为trace_id。 - 为图方便将 Prompt / Response 原文直接打印到生产环境日志:数据隐私红线,极易造成数据泄漏。
- 只记录了请求的总耗时:AI 系统必须做阶段耗时拆解(特别是首包时间 TTFT 和推理时间),否则根本查不出是慢在网络、模型推理还是语音合成。
结语
在构建复杂的 AI 工程时,上下文一旦在某个物理或协程边界发生断连,所有的日志就会从“连续的生命线”退化成“孤立的碎片”。
TraceID 全链路日志并不是为了消除 AI 系统的不确定性,而是为了当超时、中断或幻觉发生时,能够让复杂性变得可追踪、可解释、可修复。