main.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """deep_agent 2.0 HTTP 入口;公共资源生命周期由 RuntimeEnvironment 管理。"""
  2. from __future__ import annotations
  3. import os
  4. from contextlib import asynccontextmanager
  5. from fastapi import FastAPI, Request
  6. from fastapi.responses import JSONResponse, RedirectResponse
  7. from fastapi.staticfiles import StaticFiles
  8. import config
  9. from api import router as api_router
  10. from api.request_logging import RequestLogMiddleware
  11. from runtime import concurrency
  12. from runtime.environment import RuntimeEnvironment
  13. from billing import guard
  14. from db import db_conn
  15. from obs import bind_trace_id, get_logger, setup_logging, unbind_trace_id
  16. setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
  17. logger = get_logger(__name__)
  18. @asynccontextmanager
  19. async def lifespan(app: FastAPI):
  20. async with RuntimeEnvironment(scheduler=True):
  21. yield
  22. app = FastAPI(
  23. title="deep_agent 2.0",
  24. description="基于 deepagents 的深度智能体服务骨架;主入口 POST /api/chat(SSE)",
  25. version=config.APP_VERSION,
  26. lifespan=lifespan,
  27. )
  28. # 业务路由统一挂在 /api 下(api/__init__.py 里声明了 prefix)
  29. app.include_router(api_router)
  30. @app.exception_handler(guard.QuotaExceededError)
  31. async def quota_error(request: Request, exc: guard.QuotaExceededError):
  32. return JSONResponse(status_code=429, content={"detail": {"code": "quota_exceeded", "message": str(exc)}})
  33. @app.exception_handler(guard.BillingConfigError)
  34. async def billing_error(request: Request, exc: guard.BillingConfigError):
  35. logger.error("计费准入失败", exc_info=(type(exc), exc, exc.__traceback__),
  36. extra={"kv": {"event": "billing.admission_failed"}})
  37. return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
  38. @app.exception_handler(concurrency.TooManyTasksError)
  39. async def concurrency_error(request: Request, exc: concurrency.TooManyTasksError):
  40. return JSONResponse(status_code=429, headers={"Retry-After": str(exc.retry_after)},
  41. content={"detail": {"code": "too_many_tasks", "message": str(exc),
  42. "retry_after": exc.retry_after}})
  43. @app.exception_handler(concurrency.SessionBusyError)
  44. async def session_busy(request: Request, exc: concurrency.SessionBusyError):
  45. return JSONResponse(status_code=409,
  46. content={"detail": {"code": "session_busy", "message": str(exc)}})
  47. @app.exception_handler(concurrency.SessionMissingError)
  48. async def session_missing(request: Request, exc: concurrency.SessionMissingError):
  49. return JSONResponse(status_code=404,
  50. content={"detail": {"code": "session_not_found", "message": str(exc)}})
  51. app.add_middleware(RequestLogMiddleware, trace_enabled=config.TRACE_MIDDLEWARE)
  52. @app.exception_handler(Exception)
  53. async def unhandled_exception_handler(request: Request, exc: Exception):
  54. """兜底异常处理:统一成 ``{code, message}``,与业务错误同形状。
  55. 不返回堆栈——它可能含连接串、内网地址。堆栈只进日志,用 trace_id 关联。
  56. """
  57. trace_id = getattr(request.state, "trace_id", "")
  58. token = bind_trace_id(trace_id)
  59. try:
  60. logger.exception("请求处理异常", extra={"kv": {"event": "http.request.failed",
  61. "method": request.method, "path": request.url.path,
  62. "error_type": type(exc).__name__}})
  63. finally:
  64. unbind_trace_id(token)
  65. return JSONResponse(
  66. status_code=500,
  67. content={"code": "internal_error", "message": f"服务内部错误:{type(exc).__name__}"},
  68. headers={"X-Trace-Id": trace_id} if trace_id else None,
  69. )
  70. @app.get("/")
  71. async def index():
  72. """重定向到测试页(与 1.5 的行为一致:``GET /`` → 静态页)。"""
  73. return RedirectResponse(url="/static/index.html")
  74. @app.get("/health")
  75. async def health():
  76. """健康检查:逐子系统报状态。
  77. 整体 ``status`` 只由**关键路径**决定(存储 + checkpoint)。调度器、
  78. 计费单价缺失这类是 ``degraded`` 而不是 ``down``:对话还能用,不该被
  79. 负载均衡摘掉。
  80. """
  81. from agents import agent_memory
  82. detail: dict = {
  83. "version": config.APP_VERSION,
  84. "pid": os.getpid(),
  85. "dialect": config.DB_DIALECT,
  86. "storage": db_conn.health(),
  87. "checkpointer": {
  88. "kind": agent_memory.saver_kind(),
  89. "initialized": agent_memory.is_initialized(),
  90. },
  91. "concurrency": concurrency.stats(),
  92. "model": config.MODEL,
  93. "port": config.PORT,
  94. # 环境变量冲突要能在健康检查里看到,不必去翻日志
  95. "config_conflicts": dict(config.CONFLICTS),
  96. # 能力开关:上线后要能一眼确认「资料目录挂上了没有」「附件上限是多少」,
  97. # 不必靠"猜哪个进程在服务"。
  98. "capabilities": {
  99. "max_upload_files": config.MAX_UPLOAD_FILES,
  100. "airflow_monitoring_dir": config.AIRFLOW_MONITORING_DIR,
  101. "airflow_output_dir": config.AIRFLOW_OUTPUT_DIR or f"data/{config.AIRFLOW_OUTPUT_DIRNAME}",
  102. },
  103. }
  104. try:
  105. from scheduler import scheduler as sched
  106. detail["scheduler"] = {
  107. "running": sched.is_running(),
  108. "jobs": len(sched.jobs()),
  109. }
  110. except Exception as exc: # pragma: no cover
  111. detail["scheduler"] = {"running": False, "error": f"{type(exc).__name__}: {exc}"}
  112. try:
  113. from tools import registry
  114. detail["tools"] = len(registry.get_tool_names())
  115. except Exception as exc: # pragma: no cover
  116. detail["tools"] = {"error": f"{type(exc).__name__}: {exc}"}
  117. storage_ok = detail["storage"].get("status") == "ok"
  118. checkpointer_ok = bool(detail["checkpointer"]["initialized"])
  119. status = "ok" if (storage_ok and checkpointer_ok) else "down"
  120. detail["status"] = status
  121. detail["degraded"] = status == "ok" and not detail.get("scheduler", {}).get("running", True)
  122. return detail
  123. try:
  124. config.resolve_path("static/.keep")
  125. app.mount("/static", StaticFiles(directory=str(config.ROOT / "static")), name="static")
  126. except Exception as exc: # pragma: no cover - 静态目录缺失不该阻止服务启动
  127. logger.warning("挂载静态目录失败:%s: %s", type(exc).__name__, exc)
  128. if __name__ == "__main__":
  129. import uvicorn
  130. uvicorn.run(
  131. "main:app",
  132. host=config.HOST,
  133. port=config.PORT,
  134. workers=1, # 单进程是硬约束,见 _check_single_process
  135. reload=False, # 开 reload 会起子进程,同样破坏进程内状态
  136. log_level=config.LOG_LEVEL.lower(),
  137. log_config=None, access_log=False,
  138. )