agent_memory.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """图状态(checkpoint)存储。
  2. 与 1.5 的关键差异:1.5 用了一个自定义的 ``_PerLoopSaver`` 去桥接
  3. 「异步按事件循环分发 / 同步每次开新连接」——那套桥接是为了应付多事件
  4. 循环的同进程场景,代价是每个同步调用都要新开一次 sqlite 连接(见 1.5
  5. agents/agent_memory.py 的注释)。2.0 的单进程约束下(uvicorn workers=1)
  6. 只有一个事件循环,不需要这层桥接:启动时创建一次 saver,全进程复用。
  7. 代价是 **不能多 worker**:多进程各自持有自己的 sqlite 文件句柄与图状态,
  8. 同一会话的续跑会落到不同进程而看不到对方的状态。这条约束由 main.py 的
  9. 启动检查兜住。
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. from typing import Any
  14. import config
  15. from obs import get_logger
  16. logger = get_logger(__name__)
  17. # 进程级单例。初始化前为 None,避免在无事件循环时(import 期)创建连接。
  18. _saver: Any | None = None
  19. _init_lock = asyncio.Lock()
  20. _kind: str = config.CHECKPOINTER
  21. class CheckpointerConfigError(RuntimeError):
  22. """checkpointer 配置不可用。刻意 fail-fast,不静默回落到内存。"""
  23. def saver_kind() -> str:
  24. """当前档位:sqlite | memory。"""
  25. return _kind
  26. def get_checkpointer() -> Any:
  27. """取进程级 checkpointer。
  28. ``memory`` 档在首次调用时**惰性创建**——InMemorySaver 是同步可构造的,
  29. 没有任何初始化依赖,却要求调用方先进 lifespan 是没必要的仪式,也会让
  30. 脚本、测试、调度器这些非 HTTP 入口平白多一层耦合。
  31. ``sqlite`` 档仍然要求先 ``await init_checkpointer()``:它需要打开异步
  32. 连接,没法在同步函数里做,而且**绝不能静默回落到内存**——那会让
  33. 「重启后中断恢复失效」变成一个没人发现的行为变化。
  34. """
  35. global _saver
  36. if _saver is None and _kind == "memory":
  37. from langgraph.checkpoint.memory import InMemorySaver
  38. _saver = InMemorySaver()
  39. logger.info("checkpointer=memory 惰性创建(图状态不落盘)")
  40. if _saver is None:
  41. raise CheckpointerConfigError(
  42. "checkpointer 尚未初始化:请确认已在 lifespan 中 await init_checkpointer()"
  43. )
  44. return _saver
  45. def is_initialized() -> bool:
  46. """是否已初始化(/health 用)。"""
  47. return _saver is not None
  48. async def init_checkpointer() -> Any:
  49. """初始化 checkpointer。幂等,重复调用直接返回已有实例。"""
  50. global _saver, _kind
  51. async with _init_lock:
  52. if _saver is not None:
  53. return _saver
  54. if _kind == "memory":
  55. from langgraph.checkpoint.memory import InMemorySaver
  56. _saver = InMemorySaver()
  57. logger.warning(
  58. "checkpointer=memory:图状态不落盘,重启后中断恢复失效"
  59. "(会话历史仍从 MySQL 补种)"
  60. )
  61. return _saver
  62. if _kind != "sqlite":
  63. raise CheckpointerConfigError(
  64. f"不支持的 CHECKPOINTER={_kind!r},可选值:sqlite | memory"
  65. )
  66. try:
  67. import aiosqlite
  68. from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
  69. except ImportError as exc:
  70. raise CheckpointerConfigError(
  71. f"checkpointer=sqlite 需要 aiosqlite 与 langgraph-checkpoint-sqlite:{exc}"
  72. ) from exc
  73. path = config.resolve_path(config.CHECKPOINT_DB_PATH)
  74. try:
  75. conn = await aiosqlite.connect(str(path))
  76. saver = AsyncSqliteSaver(conn)
  77. await saver.setup()
  78. except Exception as exc:
  79. raise CheckpointerConfigError(
  80. f"打开 checkpoint 库失败 path={path}:{type(exc).__name__}: {exc}"
  81. ) from exc
  82. _saver = saver
  83. logger.info("checkpointer=sqlite 就绪 path=%s", path)
  84. return _saver
  85. async def close_checkpointer() -> None:
  86. """关闭底层连接(lifespan 退出时调用)。"""
  87. global _saver
  88. saver = _saver
  89. _saver = None
  90. if saver is None:
  91. return
  92. conn = getattr(saver, "conn", None)
  93. if conn is not None and hasattr(conn, "close"):
  94. try:
  95. await conn.close()
  96. except Exception as exc: # pragma: no cover - 关闭失败不影响进程退出
  97. logger.warning("关闭 checkpoint 连接失败:%s: %s", type(exc).__name__, exc)
  98. logger.info("checkpointer 已关闭")
  99. def thread_config(thread_id: str) -> dict[str, Any]:
  100. """构造 LangGraph 的 configurable config。
  101. ``thread_id`` 恒等于 ``session_id``(由 api/chat_routes 强制),
  102. 这是防止越权读写别人会话状态的第一道闸。
  103. """
  104. return {"configurable": {"thread_id": thread_id}}
  105. async def has_checkpoint(thread_id: str) -> bool:
  106. """该 thread 是否已有图状态。
  107. 冷线程需要从 MySQL 补种历史消息;热线程只喂当轮新消息,否则历史会被
  108. 重复追加,上下文按 O(N²) 膨胀。
  109. """
  110. saver = get_checkpointer()
  111. try:
  112. tup = await saver.aget_tuple(thread_config(thread_id))
  113. except Exception as exc: # pragma: no cover - 取决于底层库
  114. logger.warning("探测 thread 状态失败 thread=%s:%s: %s", thread_id, type(exc).__name__, exc)
  115. raise
  116. return tup is not None
  117. async def delete_thread(thread_id: str) -> None:
  118. """删除某 thread 的全部图状态。删会话时必须同步调用,否则图状态残留。"""
  119. saver = get_checkpointer()
  120. delete = getattr(saver, "adelete_thread", None)
  121. if delete is None:
  122. raise RuntimeError("当前 checkpointer 不支持删除 thread")
  123. try:
  124. await delete(thread_id)
  125. logger.info("已清除图状态 thread=%s", thread_id)
  126. except Exception as exc:
  127. logger.warning("清除图状态失败 thread=%s:%s: %s", thread_id, type(exc).__name__, exc)
  128. raise