""" 共享的 LangGraph checkpointer 工厂。 原实现使用 MemorySaver(纯内存),服务重启即丢失所有对话状态。 这里改用 SQLite 持久化(AsyncSqliteSaver),支持异步调用,对话状态落盘,重启不丢。 说明:LangGraph 官方 RedisSaver 依赖 RedisSearch 模块(FT.* 命令), 当前 Redis 未安装该模块,故采用 SQLite 持久化作为务实替代。 重要:AsyncSqliteSaver 内部绑定 asyncio.Lock + 当前事件循环(get_running_loop), 必须在 FastAPI 的 running loop 内初始化(app startup 时调用 init_checkpointer), 不能在临时 loop 中创建,否则后续 ainvoke 会报 "attached to a different loop"。 """ import logging import os import aiosqlite from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver logger = logging.getLogger(__name__) # SQLite 数据库文件路径: # - Docker 容器内 /data 为持久化卷(与 Chroma 同级),但需检查写权限 # - 本地开发 fallback 到项目 data/ 目录 # - 可用环境变量 CHECKPOINT_DB_PATH 覆盖 _DEFAULT_DB_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "checkpoints.db", ) # 全局单例(由 init_checkpointer 在 startup 时填充) _saver: AsyncSqliteSaver | None = None def _resolve_db_path() -> str: """解析数据库路径:优先环境变量,其次有写权限的目录。""" # 1. 环境变量覆盖 env_path = os.environ.get("CHECKPOINT_DB_PATH") if env_path: return env_path # 2. 尝试 /data(容器卷),需检查写权限 if os.path.isdir("/data") and os.access("/data", os.W_OK): return "/data/checkpoints.db" # 3. fallback 到项目 data 目录 return _DEFAULT_DB_PATH async def init_checkpointer() -> AsyncSqliteSaver: """在运行中的事件循环内初始化 AsyncSqliteSaver(app startup 时调用)。""" global _saver if _saver is not None: return _saver db_path = _resolve_db_path() os.makedirs(os.path.dirname(db_path), exist_ok=True) conn = await aiosqlite.connect(db_path) saver = AsyncSqliteSaver(conn) await saver.setup() _saver = saver logger.info("LangGraph AsyncSqliteSaver 已初始化: %s", db_path) return _saver def get_checkpointer() -> AsyncSqliteSaver: """获取全局 AsyncSqliteSaver(须已通过 init_checkpointer 在 startup 初始化)。""" if _saver is None: raise RuntimeError( "checkpointer 未初始化,请确保 app startup 已调用 init_checkpointer()" ) return _saver