多服务分布式追踪设计
跨 React/Go/Python 三服务架构的分布式追踪设计:request ID 透传、OpenTelemetry 集成、SSE 流式场景的追踪难点。
#type / concept
#status / growing
#tech / ai
#tech / ops
[!info] related notes
- 所属 MOC: BodySense MOC
- 可观测性: AI 服务可观测性
- 架构: 多服务架构, 多服务 SSE 管道
- 健康检查: 健康检查端点
多服务分布式追踪设计
这篇解决什么问题
BodySense 三服务架构(React → Go → Python)下,一个用户请求会经过多个服务。出了问题时:
- 用户说”AI 回复很慢”——是 Go 代理慢、Python LLM 调用慢、还是 RAG 检索慢?
- 用户说”SSE 流断了”——是 Go 断了、Python 断了、还是网络断了?
- 用户说”AI 回答错了”——对应的 RAG 检索结果是什么?用了哪个 prompt?
没有分布式追踪,这些问题只能靠猜。
Trace ID 透传架构
React Go Python
│ │ │
│ 生成 trace_id │ │
│ X-Request-ID: abc │ │
│ ──────────────────► │ │
│ │ 透传 trace_id │
│ │ X-Request-ID: abc │
│ │ ────────────────────────► │
│ │ │
│ │ │ 创建 span
│ │ │ span.trace_id = abc
│ │ │
│ │ ◄── SSE events ──────── │
│ │ (每个事件带 trace_id) │
│ ◄── SSE events ─── │ │
│ (带 trace_id) │ │
前端:生成 Trace ID
// 在 API 请求中生成或复用 trace_id
function generateTraceId(): string {
return crypto.randomUUID().replace(/-/g, '').substring(0, 32);
}
// SSE 请求
const traceId = generateTraceId();
const response = await fetch('/api/chat/stream', {
headers: {
'X-Request-ID': traceId,
},
});
前端生成 trace_id 的好处:
- 前端可以提前关联”用户操作”和”后端响应”
- 即使后端没有完整接入 OpenTelemetry,也有基本的请求关联
Go 服务:透传 + 创建 Span
func ChatHandler(c *gin.Context) {
// 提取前端传来的 trace_id
traceID := c.GetHeader("X-Request-ID")
if traceID == "" {
traceID = generateTraceID()
}
// 用 OpenTelemetry 创建 span
ctx, span := otel.Tracer("api").Start(c.Request.Context(), "chat_stream")
defer span.End()
// 透传到 AI 服务
req.Header.Set("X-Request-ID", traceID)
// 代理 SSE 流,每个事件注入 trace_id
proxySSE(c, resp, traceID)
}
Python AI 服务:提取 + 注入
@app.post("/chat/stream")
async def chat_stream(request: Request):
# 提取 trace_id
trace_id = request.headers.get("X-Request-ID", "")
# 创建 OpenTelemetry span
tracer = trace.get_tracer("ai-service")
with tracer.start_as_current_span("llm_call") as span:
span.set_attribute("trace_id", trace_id)
span.set_attribute("model", model_name)
# RAG 检索子 span
with tracer.start_as_current_span("rag_retrieval"):
results = await rag.retrieve(query)
# LLM 生成子 span
with tracer.start_as_current_span("llm_generation"):
async for chunk in llm.stream(messages):
yield sse_event(chunk, trace_id=trace_id)
SSE 流式场景的追踪难点
难点 1:SSE 是长连接
普通 HTTP 请求的追踪是”请求 → 响应”,span 的开始和结束很明确。SSE 流可能持续几十秒,span 的生命周期管理不同:
# SSE 流的 span 应该覆盖整个流的生命周期
with tracer.start_as_current_span("sse_stream") as span:
async for chunk in stream:
span.add_event("chunk_sent", {"chunk_size": len(chunk)})
span.set_attribute("total_chunks", chunk_count)
难点 2:每个 SSE 事件都要可追踪
# 每个 SSE 事件都应该携带 trace_id
def sse_event(data: dict, trace_id: str) -> str:
data["trace_id"] = trace_id
return f"data: {json.dumps(data)}\n\n"
这样前端收到每个事件时都能关联到后端链路。
难点 3:流中断时的追踪
try:
async for chunk in stream:
yield sse_event(chunk, trace_id)
except Exception as e:
span.record_error(e)
span.set_status(StatusCode.ERROR, str(e))
# 记录中断位置
span.add_event("stream_interrupted", {
"chunks_sent_before_interrupt": chunk_count,
"error": str(e),
})
OpenTelemetry 集成方案
三个服务使用同一套 OTel 协议,trace 数据汇聚到同一个后端(如 Jaeger):
| 服务 | SDK | 语言 | 导出方式 |
|---|---|---|---|
| React | Web SDK(可选) | TypeScript | HTTP/JSON |
| Go | go.opentelemetry.io/otel | Go | OTLP gRPC |
| Python | opentelemetry-python | Python | OTLP gRPC |
React ──HTTP──► Go ──OTLP──► OTel Collector ──► Jaeger/Tempo
Python ──OTLP──┘
最小可用方案
如果不想立即接入完整 OTel,可以用最小方案:
// Go:中间件注入 trace_id
func TraceMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := c.GetHeader("X-Request-ID")
if traceID == "" {
traceID = uuid.New().String()
}
c.Set("trace_id", traceID)
c.Header("X-Request-ID", traceID)
c.Next()
}
}
# Python:日志绑定 trace_id
logger = logger.bind(trace_id=trace_id)
logger.info("llm_call_started", model=model)
logger.info("llm_call_completed", tokens=usage.total_tokens)
最小方案只需要:
- 前端生成
X-Request-ID - Go 透传
- 所有日志都带上这个 ID
- 用日志搜索而不是 UI 来排查问题
设计要点
- trace_id 前端生成最可靠 — 后端可能有多层代理,从前端开始最不容易断
- SSE 每个事件都要带 trace_id — 否则流中断时无法定位断点
- 先做最小方案再上 OTel — 日志关联 trace_id 已经能解决 80% 的排查问题
- span 命名要统一 —
rag_retrieval、llm_generation、faithfulness_check等,方便在 UI 中筛选 - 采样率要合理 — 全量采集成本高,生产环境通常 10%~20% 采样,但错误请求 100% 采样
常见错误
Trace ID 只在 Go 内部传递
# ❌ Go 和 Python 之间没有透传 trace_id
# Python 的日志无法关联到 Go 的请求
resp = requests.post(ai_url, json=body) # 没带 X-Request-ID
# ✅ 透传
resp = requests.post(ai_url, json=body, headers={"X-Request-ID": traceID})
SSE 事件不带 trace_id
# ❌ 流中断后无法定位是哪个请求
yield f"data: {json.dumps({'text': delta})}\n\n"
# ✅ 每个事件都带
yield f"data: {json.dumps({'text': delta, 'trace_id': trace_id})}\n\n"
把 trace_id 和 span_id 混淆
trace_id:标识一个完整的请求链路,从前端到后端所有服务共享span_id:标识链路中的一个步骤(如一次 LLM 调用、一次 RAG 检索)- 一个 trace 包含多个 span,每个 span 有自己的 span_id