多服务分布式追踪设计

跨 React/Go/Python 三服务架构的分布式追踪设计:request ID 透传、OpenTelemetry 集成、SSE 流式场景的追踪难点。

#type / concept #status / growing #tech / ai #tech / ops

[!info] related notes

多服务分布式追踪设计

这篇解决什么问题

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语言导出方式
ReactWeb SDK(可选)TypeScriptHTTP/JSON
Gogo.opentelemetry.io/otelGoOTLP gRPC
Pythonopentelemetry-pythonPythonOTLP 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)

最小方案只需要:

  1. 前端生成 X-Request-ID
  2. Go 透传
  3. 所有日志都带上这个 ID
  4. 用日志搜索而不是 UI 来排查问题

设计要点

  1. trace_id 前端生成最可靠 — 后端可能有多层代理,从前端开始最不容易断
  2. SSE 每个事件都要带 trace_id — 否则流中断时无法定位断点
  3. 先做最小方案再上 OTel — 日志关联 trace_id 已经能解决 80% 的排查问题
  4. span 命名要统一rag_retrievalllm_generationfaithfulness_check 等,方便在 UI 中筛选
  5. 采样率要合理 — 全量采集成本高,生产环境通常 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
创建于 2026/6/26 更新于 2026/6/26