写在前面
做 AI 系统时,有一种问题特别折磨人:
用户说“刚才那次请求很慢”“语音突然没返回”“AI 回答到一半断了”“页面显示成功但实际没有结果”。
你打开日志一看,每个模块都好像没有明显错误。
API 层有请求日志。
模型调用层有耗时日志。
WebSocket 层有连接日志。
任务队列里也能看到消费记录。
但这些日志像散落在地上的碎片,很难拼成一条完整链路。
尤其在 AI 系统里,这种问题会被进一步放大。因为一个用户请求往往不是一次简单的 HTTP 调用,而是会穿过:
Web API 鉴权与限流 任务队列 向量检索 模型调用 流式响应 WebSocket 推送 异步回调 数据落库
更麻烦的是,AI 请求天然具有不确定性:
模型响应时间不稳定 流式输出可能中途失败 上下文长度会影响延迟 音频、图片、文本等输入形态不同 多个 goroutine 可能共同处理同一个任务 用户端看到的问题,往往发生在服务端链路的后半段
所以 AI 系统排查最难的地方,不是“有没有日志”,而是:
当一次请求跨过多个模块、多个异步边界、多个 IO 系统后,你还能不能把它重新串起来。
这就是 TraceID 和全链路日志的价值。
它不是为了让日志变多,而是为了让每一条日志都能回答同一个问题:
这条日志,属于哪一次用户请求?
一、为什么 AI 系统比普通业务系统更容易变成黑盒
传统 CRUD 系统的问题链路通常比较短。
一次请求进来,查数据库,做业务判断,返回结果。
如果出错,大多数时候在 API 日志、SQL 日志或错误堆栈里就能定位。
但 AI 系统不是这样。
以一次语音 AI 请求为例,它可能经历这样的过程:
客户端发起请求
-> HTTP / WebSocket 接入层
-> 鉴权、额度、会话校验
-> 音频分片接收
-> ASR 语音识别
-> 上下文组装
-> 向量检索 / 业务数据查询
-> LLM 推理
-> TTS 合成
-> 流式返回给客户端
-> 保存对话记录
-> 触发后续任务
这条链路里,任何一个环节慢了,用户感知到的都是“AI 很慢”。
任何一个环节丢了,用户感知到的都是“没有结果”。
任何一个环节错误处理不完整,用户感知到的都是“系统不稳定”。
但从服务端看,问题可能完全不同:
ASR 成功了,但 LLM 超时了 LLM 成功了,但 WebSocket 推送失败了 WebSocket 推送成功了,但前端没有正确处理最后一个事件 模型返回了内容,但落库失败导致历史记录缺失 队列任务被消费了,但上下文没有正确传递 重试机制触发了两次,导致用户收到重复结果
这就是黑盒感的来源。
不是系统真的没有日志,而是日志之间缺少共同的坐标系。
TraceID 做的事情,就是给这条链路建立一个坐标系。
二、TraceID 不是一个字段,而是一条请求的生命线
很多人第一次接触 TraceID,会把它理解成“日志里多打一个 ID”。
这当然没错,但不完整。
在分布式 AI 系统里,TraceID 更像是一条请求的生命线。它应该从请求进入系统的第一刻开始出现,并且在每一次边界切换时被继续带下去。
这些边界包括:
HTTP 请求边界 WebSocket 连接边界 goroutine 异步边界 消息队列边界 第三方模型调用边界 数据库写入边界 定时任务或回调边界
只在 API 层生成 TraceID 没有意义。
真正有用的是,它能不能穿过系统的每一层。
一个比较理想的链路应该是这样:
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 在连接会话里断了。
如果你能查到异步任务日志,但查不到用户请求入口,说明异步任务变成了孤岛。
所以判断 TraceID 做得好不好,不是看日志里有没有这个字段,而是看:
系统里有没有任何一段关键链路,会产生没有 TraceID 的日志。
三、在 Go 里,TraceID 应该从 context 开始
在 Go 项目中,最自然的 TraceID 载体是 context.Context。
原因很简单:
它本来就是为了在请求范围内传递取消信号、超时、元信息而设计的。
一个常见的做法是,在接入层生成或读取 TraceID,然后把它放进请求上下文。
这里用伪代码表达,避免绑定具体项目结构:
func TraceMiddleware(next Handler) Handler {
return func(ctx Context, req Request) Response {
traceID := req.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = NewTraceID()
}
ctx = WithTraceID(ctx, traceID)
resp := next(ctx, req)
resp.Header.Set("X-Trace-ID", traceID)
return resp
}
}
这里有几个细节很关键。
第一,优先读取上游传来的 TraceID。
如果你的系统前面还有网关、BFF、客户端 SDK 或其他服务,那么它们可能已经生成了 TraceID。直接覆盖会切断跨服务链路。
第二,没有 TraceID 时再生成。
这样可以兼容内部请求、调试请求和没有接入追踪体系的调用方。
第三,把 TraceID 写回响应头。
这一步很实用。用户反馈问题时,前端可以把响应里的 TraceID 一并上报,排查效率会高很多。
第四,不建议在业务代码里直接读写裸字符串 key。
更安全的方式是封装方法:
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 ""
}
这样可以避免不同模块使用相同字符串 key 互相覆盖,也能让调用点更清晰。
四、日志封装的关键:不要让业务代码手动拼 TraceID
TraceID 真正落地时,很容易出现一个问题:
每个人都知道日志要带 TraceID,但每个人都用自己的方式带。
最后系统里会出现各种风格:
trace_id=xxx
traceId=xxx
tid=xxx
request_id=xxx
[xxx] something happened
这种日志看起来都有 ID,但在日志平台里检索会非常痛苦。
所以我更倾向于一开始就做统一封装:
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...)
}
appendTraceFields 统一负责从 context 里取 TraceID:
func appendTraceFields(ctx context.Context, fields ...Field) []Field {
if traceID := TraceIDFromContext(ctx); traceID != "" {
fields = append(fields, String("trace_id", traceID))
}
return fields
}
这样业务代码只需要关心业务字段:
log.Info(ctx, "llm stream started",
String("model", modelName),
Int("prompt_tokens", promptTokens),
)
最终输出的结构化日志会自动带上 TraceID:
{
"level": "info",
"time": "2026-06-04T10:21:03.120+08:00",
"msg": "llm stream started",
"trace_id": "T-001",
"model": "model_x",
"prompt_tokens": 1024
}
这里有一个原则很重要:
TraceID 应该成为日志基础设施的一部分,而不是业务开发者每次手动记得加的东西。
凡是依赖“大家记得做”的工程规范,最后都会漏。
五、AI 链路里最容易断的地方:goroutine、队列和 WebSocket
TraceID 在同步 HTTP 调用里不难传。
真正容易断的,是异步边界。
1. goroutine 边界
Go 项目里常见写法是开启一个 goroutine 去处理后台任务:
go func() {
processJob(job)
}()
这段代码的问题是,任务已经离开了原来的请求上下文。
如果 processJob 里面继续打日志,很可能就没有 TraceID 了。
更稳的写法是显式传入 context:
go func(ctx context.Context, job Job) {
processJob(ctx, job)
}(ctx, job)
但这里还有一个更细的问题:
请求的 ctx 可能会随着 HTTP 连接断开而被取消。
如果这个 goroutine 是必须继续执行的后台任务,就不能直接复用原请求的取消语义。更合理的做法是复制追踪信息,重新创建任务级 context:
taskCtx := NewBackgroundContext()
taskCtx = WithTraceID(taskCtx, TraceIDFromContext(ctx))
go func(ctx context.Context, job Job) {
processJob(ctx, job)
}(taskCtx, job)
这体现了一个设计区别:
TraceID 应该延续 取消信号是否延续,要看任务语义
很多线上问题就出在这里。
有些任务本该继续执行,却因为用户断开连接被取消;有些任务本该取消,却因为用了后台 context 继续跑完。
TraceID 只是追踪链路,不能替你决定任务生命周期。
2. 消息队列边界
如果 AI 请求会进入队列,那么 TraceID 必须写入消息元数据。
不要只把 TraceID 放在日志里,也不要只放在进程内 context 里。因为一旦任务进了队列,消费者进程和生产者进程可能已经不是同一个上下文了。
消息结构可以抽象成这样:
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"])
process(ctx, msg.Payload)
}
这一步如果没做,全链路日志会在队列这里断掉。
你只能看到“API 创建了任务”,也能看到“worker 处理了任务”,但无法证明它们是同一次请求。
3. WebSocket 边界
WebSocket 的麻烦在于,它不是一次请求一次响应,而是一条长连接。
一次连接里可能承载多个会话、多个 AI 请求、多个流式响应。
如果只在连接建立时绑定一个 TraceID,后面所有消息都用同一个 ID,也会带来混乱。
更合理的做法是区分两个概念:
connection_id:标识这条 WebSocket 连接trace_id:标识某一次具体请求或任务
连接建立时记录连接维度:
connection_id = C-001
user_id = U-001
每次用户在连接里发起一轮 AI 请求时,再生成或继承一个请求维度的 TraceID:
connection_id = C-001
trace_id = T-101
event = user_audio_started
connection_id = C-001
trace_id = T-101
event = llm_chunk_pushed
connection_id = C-001
trace_id = T-101
event = response_completed
这样排查时可以做两种检索:
按 trace_id查某一次 AI 请求按 connection_id查某条连接上的所有事件
这对实时语音 AI 特别重要。因为用户反馈“刚才语音断了一下”时,你既要看那一次模型响应,也要看连接在同一时间是否发生了重连、心跳超时或写入失败。
六、全链路日志不是把所有东西都打出来
很多团队第一次做全链路日志,会走向另一个极端:
既然要排查问题,那就什么都打。
这会带来三个问题。
第一,日志成本失控。
AI 系统本来就可能有大量流式 chunk,如果每个 token、每个音频分片都完整打日志,存储和检索成本会很快上来。
第二,噪音过大。
日志越多,不代表越好查。真正排查时,最怕的是关键事件被淹没。
第三,隐私和安全风险。
AI 请求经常包含用户输入、业务数据、对话上下文、知识库片段。如果未经处理直接打到日志里,很容易把敏感信息带进日志系统。
所以全链路日志的重点不是“全量内容”,而是“关键节点 + 必要字段”。
我一般会把日志分成三类。
1. 链路事件日志
用于描述请求走到了哪里:
request_received
auth_checked
quota_checked
retrieval_started
retrieval_completed
llm_started
llm_first_token
llm_completed
websocket_push_completed
message_persisted
这类日志应该稳定、结构化、可检索。
2. 性能指标日志
用于回答“慢在哪里”:
http_latency_ms
queue_wait_ms
retrieval_latency_ms
llm_first_token_latency_ms
llm_total_latency_ms
tts_latency_ms
websocket_push_latency_ms
persist_latency_ms
AI 系统排查慢请求时,最重要的不是总耗时,而是拆分耗时。
只有拆开看,才能知道瓶颈是在排队、检索、模型、合成还是推送。
3. 结果状态日志
用于回答“最后成功了吗”:
status = success | failed | canceled | timeout | partial_success
error_code = ...
retry_count = ...
fallback_used = true | false
这里尤其要注意 partial_success。
AI 系统里很多请求不是简单的成功或失败。
比如:
LLM 成功了,但 TTS 失败 文本结果推送成功了,但音频合成失败 前半段 chunk 推送成功,后半段连接断开 主模型超时,fallback 模型返回了结果
如果日志里只有 success 和 failed,这类情况会被掩盖。
七、哪些内容不应该进入日志
做 AI 系统时,日志安全要比普通业务系统更敏感。
因为 AI 链路里经常会出现这些内容:
用户原始输入 语音转写文本 私有知识库片段 提示词模板 业务字段 第三方模型返回内容 内部路由规则 鉴权 token 临时下载链接
这些内容如果直接进入日志系统,后续会很难治理。
更好的做法是建立日志脱敏规则:
允许记录:
- trace_id
- user_id_hash
- request_type
- model_alias
- latency_ms
- token_count
- chunk_count
- status
- error_code
谨慎记录:
- prompt 长度
- response 长度
- 命中的知识库文档 ID
- 工具调用名称
默认不记录:
- 完整 prompt
- 完整 response
- 原始音频文本
- token / api key
- 用户手机号、邮箱、地址等 PII
- 内部系统密钥和真实服务地址
如果确实需要排查内容质量,可以考虑做受控采样:
只在灰度环境开启 只采样极低比例 对敏感字段脱敏 设置更短的日志保留周期 明确谁可以访问内容日志
这点非常重要。
全链路日志的目标是提升可观测性,不是把系统里的所有内容复制一份到日志平台。
八、一次真实排查应该怎么用 TraceID
TraceID 的价值,只有在排查流程里才能体现出来。
假设用户反馈:
“刚才我说完话以后,AI 过了很久才回答,而且回答到一半断了。”
如果前端能上报 TraceID,那么排查路径就会非常清晰。
第一步,查入口日志:
trace_id=T-101 event=request_received user_id_hash=... request_type=voice_chat
确认请求是否进入服务端,以及进入时间。
第二步,查音频和 ASR:
trace_id=T-101 event=audio_received chunks=42 duration_ms=3800
trace_id=T-101 event=asr_completed latency_ms=920 text_len=128
确认是不是音频接收或语音识别慢。
第三步,查上下文与检索:
trace_id=T-101 event=context_built history_turns=12 token_estimate=3800
trace_id=T-101 event=retrieval_completed latency_ms=180 hit_count=5
确认是否因为上下文太长或检索慢。
第四步,查模型调用:
trace_id=T-101 event=llm_started model=model_x
trace_id=T-101 event=llm_first_token latency_ms=2600
trace_id=T-101 event=llm_stream_interrupted error_code=upstream_timeout
到这里基本可以判断,慢在首 token,断在上游流式响应。
第五步,查 WebSocket 推送:
trace_id=T-101 connection_id=C-001 event=ws_push_chunk count=18
trace_id=T-101 connection_id=C-001 event=ws_write_failed error_code=connection_closed
如果这里也有连接关闭,就要进一步判断是模型断了导致连接结束,还是客户端断连导致后续 chunk 推送失败。
这就是 TraceID 的意义。
没有它,你只能在不同模块里猜。
有了它,你可以沿着同一条线把问题一步步缩小。
九、TraceID、RequestID、SpanID 要不要都做
很多人会问:有了 TraceID,还要不要 RequestID、SpanID?
我的理解是:
TraceID标识一次端到端业务链路RequestID标识某一次具体网络请求SpanID标识链路中的一个局部步骤
在简单系统里,只做 TraceID 就能解决大部分问题。
但在复杂 AI 系统里,区分这些 ID 会更清楚。
比如一轮语音对话可能是一个 Trace:
trace_id = T-001
里面可能包含多次网络请求:
request_id = R-001 audio upload
request_id = R-002 llm stream
request_id = R-003 feedback submit
每个请求里又可以拆成多个步骤:
span = asr
span = retrieval
span = llm
span = tts
span = websocket_push
如果团队已经接入 OpenTelemetry,可以进一步把这些概念标准化。
但即使暂时没有完整接入,也建议至少在日志字段设计上预留这些层次。
一个简单但够用的字段组合是:
trace_id
request_id
connection_id
job_id
span
event
latency_ms
status
error_code
这样后面不管接 ELK、Loki、ClickHouse 还是 OpenTelemetry,都不会太被动。
十、落地时最容易踩的几个坑
1. 只有错误日志带 TraceID
这是很常见的问题。
排查时你不只需要知道哪里错了,还需要知道它之前发生了什么。
如果只有 Error 日志带 TraceID,Info 日志没有,那么链路仍然是不完整的。
关键链路上的 Info、Warn、Error 都应该带 TraceID。
2. TraceID 在异步任务里丢失
HTTP 入口日志是完整的,worker 日志没有 TraceID。
这种情况说明 TraceID 没有写入队列消息,或者消费者没有恢复 context。
队列边界必须显式处理。
3. 一个 WebSocket 连接只用一个 TraceID
长连接和业务请求不是一回事。
连接维度应该用 connection_id,请求维度应该用 trace_id。
否则一条连接里多个 AI 请求会混在一起。
4. 日志字段命名不统一
同一个字段出现 traceID、trace_id、tid 三种写法,后面检索会很痛苦。
字段名必须统一,最好一开始就定规范。
5. 把 prompt 和 response 原文打进日志
这在调试时很方便,但在生产环境风险很高。
建议默认记录长度、token 数、摘要字段、命中文档 ID,而不是记录完整内容。
6. 只记录最终耗时,不记录阶段耗时
用户说“慢”,总耗时只能证明它慢。
阶段耗时才能告诉你慢在哪里。
AI 系统尤其要记录首 token 延迟、总推理耗时、流式中断位置和推送耗时。
总结
分布式 AI 系统的排查难点,不是某一段代码有多复杂,而是一次用户请求会穿过太多边界。
HTTP、goroutine、队列、模型服务、WebSocket、数据库,每一个边界都有可能让上下文断掉。
上下文一断,日志就会从“链路”退化成“碎片”。
TraceID 的价值,就是让系统在复杂链路里始终保留一条线。
但真正有用的 TraceID,不只是入口生成一个 UUID,也不是日志里偶尔多一个字段。它应该贯穿:
请求入口 业务处理 异步任务 模型调用 流式返回 实时推送 数据落库 错误处理
更重要的是,日志应该记录关键节点、阶段耗时、结果状态和必要上下文,同时避免泄露用户数据、提示词、内部规则和敏感字段。
做好这件事之后,AI 系统依然会有不确定性。
模型还是可能慢,流式响应还是可能断,外部服务还是可能超时。
但至少当问题发生时,你不再只能凭感觉猜。
你可以拿着一个 TraceID,从用户入口一路查到模型调用,再查到 WebSocket 推送和最终落库。
这就是全链路日志最大的价值:它不能消除复杂性,但能让复杂性变得可追踪、可解释、可修复。
夜雨聆风