feat(storage): add selectable SQLite and PostgreSQL persistence
- Select one storage backend per instance via AGENT_STORAGE, retaining memory as the backward-compatible default. Manage the selected async repository, official checkpointer and connection pools in lifespan; fail startup on unavailable or uninitialized storage without fallback. - Add SQLite and PostgreSQL adapters, schema initialization CLI, sample PostgreSQL compose configuration and a Windows-compatible serve entry. SQLite enforces single-instance file ownership; PostgreSQL uses a dedicated pool for session advisory locks plus version/epoch fencing. - Make repository IO and commit callbacks awaitable. Persist business fields, a complete committed graph snapshot and turn result/version in one business transaction before success. Rebuild execution checkpoints from that snapshot, discarding uncommitted progress after interruption. - Support optional Idempotency-Key on durable chat requests. Replay committed results and return 409 on changed request content; delete removes business/history data and replay records while retaining only an epoch/version tombstone to reject stale commits. - Document migration, database switching boundaries, backup/deployment expectations and crash recovery. Ignore generated database files. Validation: 315 tests passed with real SQLite and isolated PostgreSQL, including abrupt child-process exits before/after business commit; both backends passed CLI migration and actual HTTP server restart smoke. Existing API tests were rechecked after isolating them from user storage. An initial Windows CreateProcess permission failure did not recur in the full diagnostic run; failure assertions were not skipped or relaxed.
This commit is contained in:
@@ -19,6 +19,14 @@ class Config:
|
||||
# "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。
|
||||
AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt")
|
||||
|
||||
# 每个实例只选一个会话存储;与通用 DATABASE_URL 独立。
|
||||
AGENT_STORAGE = os.getenv("AGENT_STORAGE", "memory").strip().lower()
|
||||
AGENT_SQLITE_PATH = os.getenv("AGENT_SQLITE_PATH", "./data/agent.sqlite3")
|
||||
AGENT_POSTGRES_DSN = os.getenv("AGENT_POSTGRES_DSN", "")
|
||||
AGENT_DB_AUTO_MIGRATE = os.getenv("AGENT_DB_AUTO_MIGRATE", "false").lower() in ("true", "1")
|
||||
AGENT_DB_POOL_SIZE = int(os.getenv("AGENT_DB_POOL_SIZE", "10"))
|
||||
AGENT_DB_LOCK_TIMEOUT_SECONDS = float(os.getenv("AGENT_DB_LOCK_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# 命名 LLM 提供商列表(均为 OpenAI 兼容接口),两种写法二选一:
|
||||
# ① JSON 字典(推荐,单变量管理):
|
||||
# LLM_PROVIDERS={"deepseek": {"base_url": "https://...", "api_key": "...",
|
||||
@@ -138,6 +146,17 @@ class Config:
|
||||
raise ValueError(
|
||||
f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}"
|
||||
)
|
||||
if cls.AGENT_BACKEND == "langgraph":
|
||||
if cls.AGENT_STORAGE not in ("memory", "sqlite", "postgresql"):
|
||||
raise ValueError("AGENT_STORAGE must be memory, sqlite or postgresql")
|
||||
if cls.AGENT_STORAGE == "sqlite" and (
|
||||
not cls.AGENT_SQLITE_PATH.strip() or cls.AGENT_SQLITE_PATH == ":memory:"
|
||||
):
|
||||
raise ValueError("AGENT_SQLITE_PATH must be a persistent file path")
|
||||
if cls.AGENT_STORAGE == "postgresql" and not cls.AGENT_POSTGRES_DSN.strip():
|
||||
raise ValueError("AGENT_POSTGRES_DSN is required for postgresql storage")
|
||||
if cls.AGENT_DB_POOL_SIZE < 1 or cls.AGENT_DB_LOCK_TIMEOUT_SECONDS <= 0:
|
||||
raise ValueError("Database pool size and lock timeout must be positive")
|
||||
profiles = cls.llm_profiles() # JSON 非法时在此抛出明确错误
|
||||
incomplete = [n for n in cls._declared_provider_names() if n not in profiles]
|
||||
if incomplete:
|
||||
|
||||
+38
-66
@@ -1,107 +1,79 @@
|
||||
"""FastGPT client and chat backend dependency injection."""
|
||||
from contextlib import asynccontextmanager
|
||||
"""后端与单一会话存储的生命周期、依赖注入。"""
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastgpt_client import AsyncChatClient
|
||||
|
||||
from ..agent.graph import build_graph
|
||||
from ..backends.chat import ChatBackend
|
||||
from ..backends.fastgpt import FastGPTBackend
|
||||
from ..backends.langgraph import LangGraphBackend
|
||||
from ..services.app_service import AccidentAppService
|
||||
from ..services.repository import InMemorySessionRepository
|
||||
from ..storage.checkpoints import delete_checkpoint
|
||||
from .config import Config
|
||||
|
||||
|
||||
_fastgpt_client: AsyncChatClient | None = None
|
||||
_chat_backend: ChatBackend | None = None
|
||||
_langgraph_graph = None
|
||||
_app_service: AccidentAppService | None = None
|
||||
|
||||
# 两后端缺省 60 秒、最多额外重试 3 次,1/2/4 秒退避。
|
||||
# FastGPT SDK 的 max_retries 实为总尝试数,注入时转换为 1 + 重试次数。
|
||||
# LLM 关闭 SDK 自动重试;流式发出文本后不再重试。
|
||||
# LLM 预算按单次模型调用计算,FastGPT timeout 为 HTTP SDK 超时,
|
||||
# 均不承诺多次模型调用的整个聊天回合具有相同总期限。
|
||||
FASTGPT_TIMEOUT_SECONDS = 60.0
|
||||
FASTGPT_MAX_RETRIES = 3
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage chat backend resource lifecycle."""
|
||||
global _chat_backend, _fastgpt_client
|
||||
global _chat_backend, _fastgpt_client, _langgraph_graph, _app_service
|
||||
Config.validate()
|
||||
llm = None
|
||||
async with AsyncExitStack() as resources:
|
||||
try:
|
||||
if Config.AGENT_BACKEND == 'langgraph':
|
||||
from ..agent.llm import build_llm_service
|
||||
from ..storage.runtime import StorageSettings, open_storage
|
||||
|
||||
if Config.AGENT_BACKEND == "langgraph":
|
||||
# 作业 2:LangGraph fake 模式不要求 FastGPT 密钥。
|
||||
# TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。
|
||||
from ..agent.llm import build_llm_service
|
||||
|
||||
global _langgraph_graph, _app_service
|
||||
_langgraph_graph = build_graph()
|
||||
llm = build_llm_service()
|
||||
_chat_backend = LangGraphBackend(_langgraph_graph, llm=llm)
|
||||
# 作业 6:应用服务 + 内存 repository(作业 7 换 PostgreSQL)。
|
||||
_app_service = AccidentAppService(
|
||||
_chat_backend, InMemorySessionRepository()
|
||||
)
|
||||
else:
|
||||
_fastgpt_client = AsyncChatClient(
|
||||
api_key=Config.FASTGPT_API_KEY,
|
||||
base_url=Config.FASTGPT_BASE_URL,
|
||||
timeout=FASTGPT_TIMEOUT_SECONDS,
|
||||
max_retries=FASTGPT_MAX_RETRIES + 1,
|
||||
retry_delay=1.0,
|
||||
enable_logging=Config.DEBUG,
|
||||
)
|
||||
await _fastgpt_client.__aenter__()
|
||||
_chat_backend = FastGPTBackend(_fastgpt_client)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_chat_backend = None
|
||||
_langgraph_graph = None
|
||||
_app_service = None
|
||||
if llm is not None and hasattr(llm, "aclose"):
|
||||
await llm.aclose()
|
||||
if _fastgpt_client:
|
||||
await _fastgpt_client.__aexit__(None, None, None)
|
||||
_fastgpt_client = None
|
||||
storage = await resources.enter_async_context(open_storage(StorageSettings.from_config(Config)))
|
||||
_langgraph_graph = build_graph(storage.checkpointer)
|
||||
llm = build_llm_service()
|
||||
_chat_backend = LangGraphBackend(_langgraph_graph, llm=llm)
|
||||
_app_service = AccidentAppService(_chat_backend, storage.repository, locks=storage.locks)
|
||||
else:
|
||||
_fastgpt_client = AsyncChatClient(
|
||||
api_key=Config.FASTGPT_API_KEY, base_url=Config.FASTGPT_BASE_URL,
|
||||
timeout=FASTGPT_TIMEOUT_SECONDS, max_retries=FASTGPT_MAX_RETRIES + 1,
|
||||
retry_delay=1.0, enable_logging=Config.DEBUG,
|
||||
)
|
||||
await _fastgpt_client.__aenter__()
|
||||
_chat_backend = FastGPTBackend(_fastgpt_client)
|
||||
yield
|
||||
finally:
|
||||
_chat_backend = None
|
||||
_langgraph_graph = None
|
||||
_app_service = None
|
||||
if llm is not None and hasattr(llm, 'aclose'):
|
||||
await llm.aclose()
|
||||
if _fastgpt_client:
|
||||
await _fastgpt_client.__aexit__(None, None, None)
|
||||
_fastgpt_client = None
|
||||
|
||||
|
||||
async def delete_chat_session(thread_id: str) -> None:
|
||||
"""删除图 checkpoint;缺少清理能力或存储出错必须报告失败。"""
|
||||
if _langgraph_graph is None:
|
||||
raise RuntimeError("LangGraph backend not initialized")
|
||||
checkpointer = getattr(_langgraph_graph, "checkpointer", None)
|
||||
async_delete = getattr(checkpointer, "adelete_thread", None)
|
||||
if callable(async_delete):
|
||||
await async_delete(thread_id)
|
||||
return
|
||||
sync_delete = getattr(checkpointer, "delete_thread", None)
|
||||
if callable(sync_delete):
|
||||
sync_delete(thread_id)
|
||||
return
|
||||
raise RuntimeError("LangGraph checkpointer does not support thread deletion")
|
||||
await delete_checkpoint(_langgraph_graph, thread_id)
|
||||
|
||||
|
||||
def get_fastgpt_client() -> AsyncChatClient:
|
||||
"""Get the initialized FastGPT client."""
|
||||
if _fastgpt_client is None:
|
||||
raise RuntimeError("FastGPT client not initialized")
|
||||
raise RuntimeError('FastGPT client not initialized')
|
||||
return _fastgpt_client
|
||||
|
||||
|
||||
def get_app_service() -> AccidentAppService:
|
||||
"""Get the initialized application service(langgraph 模式)。"""
|
||||
if _app_service is None:
|
||||
raise RuntimeError("Application service not initialized")
|
||||
raise RuntimeError('Application service not initialized')
|
||||
return _app_service
|
||||
|
||||
|
||||
def get_chat_backend() -> ChatBackend:
|
||||
"""Get the backend-neutral chat service."""
|
||||
if _chat_backend is None:
|
||||
raise RuntimeError("Chat backend not initialized")
|
||||
raise RuntimeError('Chat backend not initialized')
|
||||
return _chat_backend
|
||||
|
||||
Reference in New Issue
Block a user