Pārlūkot izejas kodu

feat: 链路追踪 + LLM 可观测性(X-Request-ID trace_id + 日志落盘 + token 监控)

- AiGateway 注入 X-Request-ID 请求头(每次请求唯一 UUID)
- cfc-langgraph RequestLogMiddleware 读取 trace_id 并写入日志
- log_config.py 新增可选文件落盘(LOG_FILE 环境变量,默认关闭)
- llm/client.py 新增 LLM 调用监控(prometheus: 调用次数/耗时/token 用量)
Sisyphus 1 nedēļu atpakaļ
vecāks
revīzija
ecfc0f7931

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -340,6 +340,7 @@ public class AiGateway {
     private org.springframework.http.HttpHeaders createJsonHeaders() {
         org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
         headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+        headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
         return headers;
     }
 

+ 16 - 6
cfc-langgraph/app/log_config.py

@@ -1,6 +1,8 @@
 import logging
+import logging.handlers
 import json
 import sys
+import os
 from datetime import datetime, timezone
 
 
@@ -27,17 +29,25 @@ def setup_logging(level: str = "INFO", json_format: bool = False):
 
     root.handlers.clear()
 
-    handler = logging.StreamHandler(sys.stdout)
-
+    # 控制台 handler
+    console_handler = logging.StreamHandler(sys.stdout)
     if json_format:
-        handler.setFormatter(JsonFormatter())
+        console_handler.setFormatter(JsonFormatter())
     else:
-        handler.setFormatter(logging.Formatter(
+        console_handler.setFormatter(logging.Formatter(
             "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
             datefmt="%Y-%m-%d %H:%M:%S",
         ))
-
-    root.addHandler(handler)
+    root.addHandler(console_handler)
+
+    # 可选:文件落盘 (LOG_FILE 环境变量指定路径,默认不开启)
+    log_file = os.getenv("LOG_FILE")
+    if log_file:
+        file_handler = logging.handlers.RotatingFileHandler(
+            log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
+        )
+        file_handler.setFormatter(JsonFormatter())
+        root.addHandler(file_handler)
 
     logging.getLogger("httpx").setLevel(logging.WARNING)
     logging.getLogger("chromadb").setLevel(logging.WARNING)

+ 17 - 4
cfc-langgraph/app/middleware.py

@@ -8,6 +8,7 @@
 纯 ASGI 中间件通过包装 receive/send 通道缓存请求体与响应状态,既记录日志又不破坏下游请求。
 """
 import time
+import uuid
 from collections import deque
 import logging
 
@@ -63,6 +64,17 @@ class RequestLogMiddleware:
         client = scope.get("client")
         client_ip = client[0] if client else "-"
 
+        # 读取或生成 X-Request-ID(Java AiGateway 在每次调用时都会注入 UUID)
+        request_id = None
+        for name, value in scope.get("headers", []):
+            if name == b"x-request-id":
+                request_id = value.decode()
+                break
+        request_id = request_id or str(uuid.uuid4())
+
+        # 带 trace_id 的 adapter:JsonFormatter 会把它写为顶层字段
+        req_logger = logging.LoggerAdapter(logger, {"trace_id": request_id})
+
         # 缓存请求体(仅对带 body 的方法),不影响下游 receive
         body_bytes = b""
         body_cached = False
@@ -98,8 +110,8 @@ class RequestLogMiddleware:
         try:
             await self.app(scope, receive_wrapper, send_wrapper)
         except Exception as exc:
-            logger.error("REQUEST EXCEPTION: %s %s %.2fs error=%r",
-                         method, path, time.perf_counter() - start_time, exc)
+            req_logger.error("REQUEST EXCEPTION: %s %s %.2fs error=%r",
+                          method, path, time.perf_counter() - start_time, exc)
             if not sent_response_start:
                 from starlette.responses import JSONResponse
                 resp = JSONResponse(status_code=500, content={"detail": str(exc)})
@@ -116,12 +128,13 @@ class RequestLogMiddleware:
             "status": final_status,
             "duration_ms": round(elapsed * 1000),
             "client_ip": client_ip,
+            "trace_id": request_id,
             "body": _decode_body(body_bytes) if body_cached else None,
             "response": _decode_body(b"".join(response_chunks)[:1000]) if response_chunks else "",
         }
         _request_log.appendleft(entry)
 
         if elapsed > 5:
-            logger.warning("SLOW_REQUEST: %s %s %.2fs", method, path, elapsed)
+            req_logger.warning("SLOW_REQUEST: %s %s %.2fs", method, path, elapsed)
         else:
-            logger.debug("REQUEST: %s %s %.2fs", method, path, elapsed)
+            req_logger.debug("REQUEST: %s %s %.2fs", method, path, elapsed)

+ 40 - 2
cfc-langgraph/src/llm/client.py

@@ -1,5 +1,8 @@
 import os
+import time
+import functools
 from langchain_openai import ChatOpenAI
+from app.monitoring import llm_calls_total, llm_duration_seconds, llm_tokens_total
 
 
 def _common_kwargs() -> dict:
@@ -11,20 +14,55 @@ def _common_kwargs() -> dict:
     }
 
 
+def _wrap_llm_ainvoke(llm: ChatOpenAI, model_name: str) -> ChatOpenAI:
+    original_ainvoke = llm.ainvoke
+
+    @functools.wraps(original_ainvoke)
+    async def monitored_ainvoke(*args, **kwargs):
+        llm_calls_total.labels(model=model_name, status="started").inc()
+        start = time.perf_counter()
+        try:
+            result = await original_ainvoke(*args, **kwargs)
+            elapsed = time.perf_counter() - start
+            llm_duration_seconds.labels(model=model_name).observe(elapsed)
+            llm_calls_total.labels(model=model_name, status="success").inc()
+            try:
+                usage = getattr(result, "response_metadata", {}).get("token_usage", {})
+                if usage:
+                    prompt_tokens = usage.get("prompt_tokens", 0)
+                    completion_tokens = usage.get("completion_tokens", 0)
+                    total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)
+                    llm_tokens_total.labels(model=model_name, type="prompt").inc(prompt_tokens)
+                    llm_tokens_total.labels(model=model_name, type="completion").inc(completion_tokens)
+                    llm_tokens_total.labels(model=model_name, type="total").inc(total_tokens)
+            except Exception:
+                pass
+            return result
+        except Exception as e:
+            llm_duration_seconds.labels(model=model_name).observe(time.perf_counter() - start)
+            llm_calls_total.labels(model=model_name, status="error").inc()
+            raise
+
+    llm.ainvoke = monitored_ainvoke
+    return llm
+
+
 def get_llm() -> ChatOpenAI:
     # 优先 LLM_* 系列(与 .env.production 一致),兼容 OPENAI_* 系列
     # timeout=180/max_retries=1:问卷推理可达 30-60s,重试叠加会拖垮 gunicorn worker
-    return ChatOpenAI(
+    llm = ChatOpenAI(
         model=os.getenv("LLM_MODEL", os.getenv("OPENAI_MODEL", "gpt-4o-mini")),
         temperature=0.7,
         **_common_kwargs(),
     )
+    return _wrap_llm_ainvoke(llm, llm.model_name)
 
 
 def get_vision_llm() -> ChatOpenAI:
     # 视觉模型(舌诊等图像分析),复用 LLM 网关与 key,仅切换模型名
-    return ChatOpenAI(
+    llm = ChatOpenAI(
         model=os.getenv("LLM_VISION_MODEL", "glm-5"),
         temperature=0.3,
         **_common_kwargs(),
     )
+    return _wrap_llm_ainvoke(llm, llm.model_name)