main.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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 runtime import concurrency
  11. from runtime.environment import RuntimeEnvironment
  12. from billing import guard
  13. from db import db_conn
  14. from obs import bind_trace_id, get_logger, new_trace_id, setup_logging, unbind_trace_id
  15. setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
  16. logger = get_logger(__name__)
  17. @asynccontextmanager
  18. async def lifespan(app: FastAPI):
  19. async with RuntimeEnvironment(scheduler=True):
  20. yield
  21. app = FastAPI(
  22. title="deep_agent 2.0",
  23. description="基于 deepagents 的深度智能体服务骨架;主入口 POST /api/chat(SSE)",
  24. version=config.APP_VERSION,
  25. lifespan=lifespan,
  26. )
  27. # 业务路由统一挂在 /api 下(api/__init__.py 里声明了 prefix)
  28. app.include_router(api_router)
  29. @app.exception_handler(guard.QuotaExceededError)
  30. async def quota_error(request: Request, exc: guard.QuotaExceededError):
  31. return JSONResponse(status_code=429, content={"detail": {"code": "quota_exceeded", "message": str(exc)}})
  32. @app.exception_handler(guard.BillingConfigError)
  33. async def billing_error(request: Request, exc: guard.BillingConfigError):
  34. logger.error("计费准入失败:%s", exc)
  35. return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
  36. @app.exception_handler(concurrency.TooManyTasksError)
  37. async def concurrency_error(request: Request, exc: concurrency.TooManyTasksError):
  38. return JSONResponse(status_code=429, headers={"Retry-After": str(exc.retry_after)},
  39. content={"detail": {"code": "too_many_tasks", "message": str(exc),
  40. "retry_after": exc.retry_after}})
  41. @app.exception_handler(concurrency.SessionBusyError)
  42. async def session_busy(request: Request, exc: concurrency.SessionBusyError):
  43. return JSONResponse(status_code=409,
  44. content={"detail": {"code": "session_busy", "message": str(exc)}})
  45. @app.exception_handler(concurrency.SessionMissingError)
  46. async def session_missing(request: Request, exc: concurrency.SessionMissingError):
  47. return JSONResponse(status_code=404,
  48. content={"detail": {"code": "session_not_found", "message": str(exc)}})
  49. if config.TRACE_MIDDLEWARE:
  50. @app.middleware("http")
  51. async def trace_middleware(request: Request, call_next):
  52. """把 ``X-Trace-Id`` 透传进来或新生成一个,回写响应头。
  53. 注意:这里绑定的 ContextVar 在 StreamingResponse 的生成器里**不保证**
  54. 可见(响应体迭代发生在中间件调用栈之外),所以 SSE 生成器内部会自己
  55. 再绑一次。这个中间件的作用是让普通 HTTP 接口与访问日志带上 trace_id。
  56. """
  57. trace_id = request.headers.get("X-Trace-Id") or new_trace_id()
  58. request.state.trace_id = trace_id
  59. token = bind_trace_id(trace_id)
  60. try:
  61. response = await call_next(request)
  62. finally:
  63. unbind_trace_id(token)
  64. response.headers["X-Trace-Id"] = trace_id
  65. return response
  66. @app.exception_handler(Exception)
  67. async def unhandled_exception_handler(request: Request, exc: Exception):
  68. """兜底异常处理:统一成 ``{code, message}``,与业务错误同形状。
  69. 不返回堆栈——它可能含连接串、内网地址。堆栈只进日志,用 trace_id 关联。
  70. """
  71. trace_id = getattr(request.state, "trace_id", "")
  72. logger.exception("未处理异常 path=%s trace=%s", request.url.path, trace_id)
  73. return JSONResponse(
  74. status_code=500,
  75. content={"code": "internal_error", "message": f"服务内部错误:{type(exc).__name__}"},
  76. )
  77. @app.get("/")
  78. async def index():
  79. """重定向到测试页(与 1.5 的行为一致:``GET /`` → 静态页)。"""
  80. return RedirectResponse(url="/static/index.html")
  81. @app.get("/health")
  82. async def health():
  83. """健康检查:逐子系统报状态。
  84. 整体 ``status`` 只由**关键路径**决定(存储 + checkpoint)。调度器、
  85. 计费单价缺失这类是 ``degraded`` 而不是 ``down``:对话还能用,不该被
  86. 负载均衡摘掉。
  87. """
  88. from agents import agent_memory
  89. detail: dict = {
  90. "version": config.APP_VERSION,
  91. "pid": os.getpid(),
  92. "dialect": config.DB_DIALECT,
  93. "storage": db_conn.health(),
  94. "checkpointer": {
  95. "kind": agent_memory.saver_kind(),
  96. "initialized": agent_memory.is_initialized(),
  97. },
  98. "concurrency": concurrency.stats(),
  99. "model": config.MODEL,
  100. "port": config.PORT,
  101. # 环境变量冲突要能在健康检查里看到,不必去翻日志
  102. "config_conflicts": dict(config.CONFLICTS),
  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. )