main.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. """deep_agent 2.0 服务入口。
  2. 启动顺序(lifespan):
  3. 1. 日志
  4. 2. 业务扩展发现与校验;建表(幂等)
  5. 3. 计费单价表种子
  6. 4. checkpointer 初始化
  7. 5. **后台**预热 agent(图编译耗时百毫秒级,不能挡住 uvicorn 就绪)
  8. 6. 调度器启动并装载定时任务
  9. 7. 单进程约束自检
  10. 退出时逆序释放:调度器 → checkpointer。
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. import os
  15. from fastapi import FastAPI, Request
  16. from fastapi.responses import JSONResponse, RedirectResponse
  17. from fastapi.staticfiles import StaticFiles
  18. import config
  19. from api import router as api_router
  20. from api import concurrency
  21. from billing import pricing, guard
  22. from db import db_conn
  23. from obs import bind_trace_id, get_logger, new_trace_id, setup_logging, unbind_trace_id
  24. setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
  25. logger = get_logger(__name__)
  26. _maintenance_stop: asyncio.Event | None = None
  27. _maintenance_task: asyncio.Task | None = None
  28. _prewarm_task: asyncio.Task | None = None
  29. async def _retry_billing(stop: asyncio.Event) -> None:
  30. from billing.meter import retry_pending
  31. while not stop.is_set():
  32. try:
  33. await asyncio.to_thread(retry_pending)
  34. except Exception:
  35. logger.exception("重试待结算流水失败")
  36. try:
  37. await asyncio.wait_for(stop.wait(), 60)
  38. except asyncio.TimeoutError:
  39. pass
  40. def _check_single_process() -> None:
  41. """多 worker 自检。
  42. 2.0 有三处进程内状态:SQLite checkpointer、并发计数、调度器。多 worker
  43. 下它们各自独立——同一会话的续跑会落到不同进程导致图状态读不到,并发闸
  44. 形同虚设,定时任务会被每个 worker 各触发一次。所以这里只警告不阻止:
  45. 本地调试时多 worker 有时是有意的。
  46. """
  47. workers = os.getenv("WEB_CONCURRENCY", "")
  48. try:
  49. count = int(workers) if workers else 1
  50. except ValueError:
  51. count = 1
  52. if count > 1:
  53. logger.warning(
  54. "检测到 WEB_CONCURRENCY=%s:SQLite checkpointer / 并发闸 / 调度器 "
  55. "都是进程内的,多 worker 会导致会话状态分裂、定时任务重复触发。"
  56. "生产请用 --workers 1,或把这三处状态换成 Redis/MySQL 后再扩。",
  57. workers,
  58. )
  59. async def _prewarm() -> None:
  60. """后台预热 agent。失败不阻断启动——首次真实请求会重试并暴露问题。"""
  61. from agents import agent_registry
  62. ok = await agent_registry.prewarm()
  63. logger.info("agent 预热%s", "完成" if ok else "失败(将在首次请求时重试)")
  64. async def _lifespan_startup() -> None:
  65. global _maintenance_stop, _maintenance_task, _prewarm_task
  66. # 配置来源与冲突在日志装配后就打出来:排查「配置不生效」时它是第一手信息。
  67. # 放在 lifespan 而不是模块顶层,是因为 `python main.py` 会让本模块被导入
  68. # 两次(`__main__` 与 `main`),模块级副作用会重复执行。
  69. config.log_config_sources()
  70. logger.info(
  71. "deep_agent 2.0 启动中 dialect=%s model=%s port=%s",
  72. config.DB_DIALECT, config.MODEL, config.PORT,
  73. )
  74. _check_single_process()
  75. # 扩展定义错误先阻断启动,避免启动调度器后才在后台预热时发现。
  76. from extensions.registry import load_extensions, approval_names
  77. load_extensions()
  78. approval_names()
  79. # 建表与单价:失败即拒绝启动。半可用的服务比直接失败更难排查——
  80. # 用户会遇到「对话能开但一落库就报错」这类现象。
  81. db_conn.init_schema()
  82. logger.info("存储就绪 %s", db_conn.health())
  83. pricing.seed_default_pricing()
  84. from db import run_store
  85. run_store.recover_interrupted()
  86. from agents import agent_memory
  87. await agent_memory.init_checkpointer()
  88. from scheduler import scheduler
  89. await scheduler.start()
  90. _prewarm_task = asyncio.create_task(_prewarm())
  91. _maintenance_stop = asyncio.Event()
  92. _maintenance_task = asyncio.create_task(_retry_billing(_maintenance_stop))
  93. if not config.OPENAI_API_KEY:
  94. logger.warning("OPENAI_API_KEY 未配置,对话会失败——请在 .env 里设置")
  95. async def _lifespan_shutdown() -> None:
  96. from agents import agent_memory
  97. from scheduler import scheduler
  98. await scheduler.shutdown()
  99. if _maintenance_stop:
  100. _maintenance_stop.set()
  101. if _maintenance_task:
  102. await _maintenance_task
  103. if _prewarm_task:
  104. await _prewarm_task
  105. await agent_memory.close_checkpointer()
  106. logger.info("deep_agent 2.0 已停止")
  107. async def lifespan(app: FastAPI):
  108. await _lifespan_startup()
  109. try:
  110. yield
  111. finally:
  112. await _lifespan_shutdown()
  113. app = FastAPI(
  114. title="deep_agent 2.0",
  115. description="基于 deepagents 的深度智能体服务骨架;主入口 POST /api/chat(SSE)",
  116. version=config.APP_VERSION,
  117. lifespan=lifespan,
  118. )
  119. # 业务路由统一挂在 /api 下(api/__init__.py 里声明了 prefix)
  120. app.include_router(api_router)
  121. @app.exception_handler(guard.QuotaExceededError)
  122. async def quota_error(request: Request, exc: guard.QuotaExceededError):
  123. return JSONResponse(status_code=429, content={"detail": {"code": "quota_exceeded", "message": str(exc)}})
  124. @app.exception_handler(guard.BillingConfigError)
  125. async def billing_error(request: Request, exc: guard.BillingConfigError):
  126. logger.error("计费准入失败:%s", exc)
  127. return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
  128. @app.exception_handler(concurrency.TooManyTasksError)
  129. async def concurrency_error(request: Request, exc: concurrency.TooManyTasksError):
  130. return JSONResponse(status_code=429, headers={"Retry-After": str(exc.retry_after)},
  131. content={"detail": {"code": "too_many_tasks", "message": str(exc),
  132. "retry_after": exc.retry_after}})
  133. @app.exception_handler(concurrency.SessionBusyError)
  134. async def session_busy(request: Request, exc: concurrency.SessionBusyError):
  135. return JSONResponse(status_code=409,
  136. content={"detail": {"code": "session_busy", "message": str(exc)}})
  137. @app.exception_handler(concurrency.SessionMissingError)
  138. async def session_missing(request: Request, exc: concurrency.SessionMissingError):
  139. return JSONResponse(status_code=404,
  140. content={"detail": {"code": "session_not_found", "message": str(exc)}})
  141. if config.TRACE_MIDDLEWARE:
  142. @app.middleware("http")
  143. async def trace_middleware(request: Request, call_next):
  144. """把 ``X-Trace-Id`` 透传进来或新生成一个,回写响应头。
  145. 注意:这里绑定的 ContextVar 在 StreamingResponse 的生成器里**不保证**
  146. 可见(响应体迭代发生在中间件调用栈之外),所以 SSE 生成器内部会自己
  147. 再绑一次。这个中间件的作用是让普通 HTTP 接口与访问日志带上 trace_id。
  148. """
  149. trace_id = request.headers.get("X-Trace-Id") or new_trace_id()
  150. request.state.trace_id = trace_id
  151. token = bind_trace_id(trace_id)
  152. try:
  153. response = await call_next(request)
  154. finally:
  155. unbind_trace_id(token)
  156. response.headers["X-Trace-Id"] = trace_id
  157. return response
  158. @app.exception_handler(Exception)
  159. async def unhandled_exception_handler(request: Request, exc: Exception):
  160. """兜底异常处理:统一成 ``{code, message}``,与业务错误同形状。
  161. 不返回堆栈——它可能含连接串、内网地址。堆栈只进日志,用 trace_id 关联。
  162. """
  163. trace_id = getattr(request.state, "trace_id", "")
  164. logger.exception("未处理异常 path=%s trace=%s", request.url.path, trace_id)
  165. return JSONResponse(
  166. status_code=500,
  167. content={"code": "internal_error", "message": f"服务内部错误:{type(exc).__name__}"},
  168. )
  169. @app.get("/")
  170. async def index():
  171. """重定向到测试页(与 1.5 的行为一致:``GET /`` → 静态页)。"""
  172. return RedirectResponse(url="/static/index.html")
  173. @app.get("/health")
  174. async def health():
  175. """健康检查:逐子系统报状态。
  176. 整体 ``status`` 只由**关键路径**决定(存储 + checkpoint)。调度器、
  177. 计费单价缺失这类是 ``degraded`` 而不是 ``down``:对话还能用,不该被
  178. 负载均衡摘掉。
  179. """
  180. from agents import agent_memory
  181. detail: dict = {
  182. "version": config.APP_VERSION,
  183. "pid": os.getpid(),
  184. "dialect": config.DB_DIALECT,
  185. "storage": db_conn.health(),
  186. "checkpointer": {
  187. "kind": agent_memory.saver_kind(),
  188. "initialized": agent_memory.is_initialized(),
  189. },
  190. "concurrency": concurrency.stats(),
  191. "model": config.MODEL,
  192. "port": config.PORT,
  193. # 环境变量冲突要能在健康检查里看到,不必去翻日志
  194. "config_conflicts": dict(config.CONFLICTS),
  195. }
  196. try:
  197. from scheduler import scheduler as sched
  198. detail["scheduler"] = {
  199. "running": sched.is_running(),
  200. "jobs": len(sched.jobs()),
  201. }
  202. except Exception as exc: # pragma: no cover
  203. detail["scheduler"] = {"running": False, "error": f"{type(exc).__name__}: {exc}"}
  204. try:
  205. from tools import registry
  206. detail["tools"] = len(registry.get_tool_names())
  207. except Exception as exc: # pragma: no cover
  208. detail["tools"] = {"error": f"{type(exc).__name__}: {exc}"}
  209. storage_ok = detail["storage"].get("status") == "ok"
  210. checkpointer_ok = bool(detail["checkpointer"]["initialized"])
  211. status = "ok" if (storage_ok and checkpointer_ok) else "down"
  212. detail["status"] = status
  213. detail["degraded"] = status == "ok" and not detail.get("scheduler", {}).get("running", True)
  214. return detail
  215. try:
  216. config.resolve_path("static/.keep")
  217. app.mount("/static", StaticFiles(directory=str(config.ROOT / "static")), name="static")
  218. except Exception as exc: # pragma: no cover - 静态目录缺失不该阻止服务启动
  219. logger.warning("挂载静态目录失败:%s: %s", type(exc).__name__, exc)
  220. if __name__ == "__main__":
  221. import uvicorn
  222. uvicorn.run(
  223. "main:app",
  224. host=config.HOST,
  225. port=config.PORT,
  226. workers=1, # 单进程是硬约束,见 _check_single_process
  227. reload=False, # 开 reload 会起子进程,同样破坏进程内状态
  228. log_level=config.LOG_LEVEL.lower(),
  229. )