容错处理

This commit is contained in:
董宏凯 committed 2026-05-20 12:51:04 +08:00
1 parent f44e2ccf52
commit f358cfb7c7
8 files changed
+988 -21

No files matched your search

@@ -1,3 +1,86 @@
/**
* AI 写作入口页(左右双栏:聊天面板 + 草稿预览)。
*
* ──────────────────────────────────────────────────────────────────────────
* Docker 内网部署后「会话丢失 / transcript 报 500 / resume 报 400」排查指南
* ──────────────────────────────────────────────────────────────────────────
*
* 后端涉及接口(前缀 /api/ai-writing):
* POST /start 启动写作流程
* GET /{session_id}/stream SSE 进度流
* POST /{session_id}/resume 提交用户干预 → 继续流程
* PUT /sessions/{session_id}/transcript 保存对话时间线
*
* ── 1) 确认 alembic 迁移已跑过(修 transcript 500)─────────────────────
* transcript 列在 MySQL 上原是 TEXT(64 KB 上限),长时间线会溢出抛 500。
* 迁移 20260519_02 把它改成 LONGTEXT。容器启动会自动跑,验证方法:
*
* docker exec <gateway> sh -c "cd /app/backend \
* && PYTHONPATH=. uv run alembic current"
* docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
* "SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
*
* 列类型应是 longtext。若仍是 text,手动执行:
* docker exec <gateway> sh -c "cd /app/backend \
* && PYTHONPATH=. uv run alembic upgrade head"
*
* ── 2) 确认 .deer-flow 数据卷已挂出(修「容器没重启也丢会话」)──────
* AI 写作 graph 的 checkpointer 已从 MemorySaver 换成 AsyncSqliteSaver,
* 文件落到 .deer-flow/data/checkpoints.db。docker-compose 必须把该目录
* 挂出来,否则容器重启数据照样丢:
*
* services:
* gateway:
* volumes:
* - ./data:/app/.deer-flow # 关键:挂出 .deer-flow
*
* ── 3) 确认 worker 数 = 1(关键嫌疑:多 worker 内存隔离)────────────
* _event_queues / _session_pending_pause 都是模块级 dict,每个 worker
* 一份。多 worker 下,/start 命中 worker A、/resume 命中 worker B,B
* 的 dict 没有这个 session → 404/「会话丢失」。
*
* docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
*
* 若 > 1:要么把启动参数改成 --workers 1,要么在 nginx 加 sticky
* session(ip_hash / cookie),要么把队列搬 Redis。
*
* ── 4) 排查节点异常(嫌疑:graph 节点抛错触发清理分支)─────────────
* _stream_graph_segment 的 except 分支会清掉 _event_queues[session_id],
* 用户下一次 resume 就 404。日志里搜:
*
* docker logs <gateway> 2>&1 | grep -E \
* 'ai_writing graph segment failed|ai_writing.*error' | tail -50
*
* ── 5) 排查反向代理掐 SSE 长连接(嫌疑:proxy_read_timeout 太短)───
* SSE 默认连接挂半个小时以上,nginx 默认 60s 会断。检查内网反代:
*
* grep -rE 'proxy_read_timeout|sse|keepalive' /etc/nginx/
*
* 需在 SSE 路由上加:proxy_read_timeout 3600s; proxy_buffering off;
*
* ── 定期清理(每天 03:00 跑)────────────────────────────────────────
* config.yaml -> ai_writing.cleanup 段(retention_days: 7)。联动清理
* MySQL 业务表 + SQLite checkpointer 里的 thread 状态。手动验证:
*
* docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
* "SELECT COUNT(*) FROM ai_writing_sessions \
* WHERE updated_at < NOW() - INTERVAL 7 DAY;"
*
* ── 不想等凌晨 3 点?手动触发一次清理(admin only)──────────────────
* POST /api/ai-writing/admin/cleanup
* Body(全部可选,留空走 config 默认):
* { "retention_days": 7, "only_finished": false, "batch_size": 500 }
* 返回:
* { "deleted_db": N, "deleted_checkpoints": N,
* "elapsed_seconds": X, "params": {...} }
*
* 例:清掉所有 1 小时前的会话(测试用):
* curl -X POST http://<gateway>/api/ai-writing/admin/cleanup \
* -H 'Content-Type: application/json' \
* -H 'Cookie: <admin session cookie>' \
* -d '{"retention_days": 0}'
* ──────────────────────────────────────────────────────────────────────────
*/
import { useRef } from 'react'; import { useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
@@ -0,0 +1,300 @@
"""AI 写作会话定期清理。
按 ``config.yaml`` 的 ``ai_writing.cleanup`` 段做联动清理:
1. 找出 MySQL ``ai_writing_sessions`` 表里 ``updated_at < now - retention_days``
的会话 id 列表(分批,避免大事务)
2. 对每个 id 调用 LangGraph checkpointer 的 ``adelete_thread()``,删掉
SQLite checkpointer 里对应的 thread 状态
3. 再把 MySQL 行批量删掉
整个流程「先删 checkpointer 再删 DB」是有意为之 —— 即便清理被中途中断,
也只会出现「checkpointer 删了但 DB 没删」的孤儿,下一轮清理会发现这些
DB 行仍然过期,重新尝试清理(checkpointer 删一个不存在的 thread 是 idempotent)。
反过来如果先删 DB,万一 checkpointer 删失败,文件里就永久留着不可达的状态。
多 worker 下用文件锁做 leader election,只有抢到锁的 worker 才会真正跑清理。
"""
from __future__ import annotations
import asyncio
import logging
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from croniter import croniter
logger = logging.getLogger(__name__)
# ── 文件锁 ────────────────────────────────────────────────────────────────────
def _acquire_lock(lock_path: Path):
"""跨平台非阻塞独占文件锁,多 worker 下做 leader election。
成功 → 返回持有锁的文件句柄(调用方需保持其存活直到主动释放);
抢不到 / 打开失败 → 返回 None。进程退出时 OS 自动释放。
"""
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "a+")
except OSError:
logger.exception("ai_writing_cleanup: 打开锁文件失败 path=%s", lock_path)
return None
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def _release_lock(handle) -> None:
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
finally:
try:
handle.close()
except OSError:
pass
# ── 清理执行 ──────────────────────────────────────────────────────────────────
async def _delete_checkpoint_thread(checkpointer, thread_id: str) -> None:
"""删 checkpointer 里 thread_id 对应的全部状态。失败只记日志,不抛。"""
if checkpointer is None:
return
# LangGraph 异步删法首选 adelete_thread;老版本只有同步 delete_thread。
delete_async = getattr(checkpointer, "adelete_thread", None)
if delete_async is not None:
try:
await delete_async(thread_id)
return
except Exception:
logger.warning(
"ai_writing_cleanup: adelete_thread failed thread_id=%s", thread_id, exc_info=True
)
return
delete_sync = getattr(checkpointer, "delete_thread", None)
if delete_sync is not None:
try:
# 同步方法跑在线程池里,避免堵 event loop
await asyncio.to_thread(delete_sync, thread_id)
except Exception:
logger.warning(
"ai_writing_cleanup: delete_thread failed thread_id=%s", thread_id, exc_info=True
)
def _get_active_session_ids() -> set[str]:
"""返回内存里**当前正在跑**的 session_id 集合。
这些会话的写作流程还活着 —— 任何清理都不能动它们,否则用户「正在写」
的稿子会被秒删,体验灾难。延迟到下一轮清理(24 小时后)再处理也比误
删强。
"""
try:
# 延迟 import 避免循环依赖:cleanup 模块在 lifespan 启动时被 import,
# routers/ai_writing 也被 include_router 主动 import,两者拓扑无环但
# 还是放函数里更稳。
from app.gateway.routers.ai_writing import _event_queues
return set(_event_queues.keys())
except Exception:
# 兜底:拿不到就当作没有活动会话(最差就是清掉一个刚跑完的会话,
# 比误删正在写的要好得多 —— 用户至少能从 DB 历史里看到结果)
logger.warning("ai_writing_cleanup: 读取活动会话列表失败", exc_info=True)
return set()
async def run_cleanup_once(
*,
retention_days: int,
only_finished: bool = False,
batch_size: int = 500,
) -> dict:
"""执行一轮清理。返回统计信息。
**安全保证**:清理永远跳过内存里仍在跑的会话(``_event_queues`` 里有
对应 key 的)—— 这条规则即使在 admin 手动触发清理时也强制生效,防止
把用户「正在写」的稿子误删。
幂等 + 可重入 —— 多次调用对结果只会越扫越干净。即便清理途中被中断,下次
再跑会重新把 DB 里仍然过期的行扫到(checkpointer 删一个已不存在的 thread
是 no-op)。
"""
from deerflow.agents.ai_writing.graph import get_ai_writing_checkpointer
from deerflow.persistence.ai_writing_sessions import AIWritingSessionRepository
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
logger.info("ai_writing_cleanup: persistence backend=memory,跳过清理")
return {"deleted_db": 0, "deleted_checkpoints": 0, "skipped_active": 0}
repo = AIWritingSessionRepository(sf)
checkpointer = get_ai_writing_checkpointer()
active_ids = _get_active_session_ids()
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
logger.info(
"ai_writing_cleanup: 开始清理 cutoff=%s only_finished=%s batch_size=%d active=%d",
cutoff.isoformat(),
only_finished,
batch_size,
len(active_ids),
)
total_db = 0
total_cp = 0
total_skipped = 0
while True:
ids = await repo.list_older_than(cutoff, only_finished=only_finished, limit=batch_size)
if not ids:
break
# 关键保护:跳过任何仍在内存里跑的会话
ids_to_delete = [sid for sid in ids if sid not in active_ids]
skipped = len(ids) - len(ids_to_delete)
if skipped:
logger.warning(
"ai_writing_cleanup: 跳过 %d 个仍在跑的会话: %s",
skipped,
[sid for sid in ids if sid in active_ids],
)
total_skipped += skipped
if not ids_to_delete:
# 本批全是活动会话 —— 但 list_older_than 是按 updated_at 升序的,
# 下一批拿出来的更旧,所以这种情况只可能整个 cutoff 区间都被活动
# 会话占满,不用继续往下扫,break
break
# 先删 checkpointer,再删 DB —— 保证「DB 删了 → checkpointer 一定也清干净」
for sid in ids_to_delete:
await _delete_checkpoint_thread(checkpointer, sid)
total_cp += 1
deleted = await repo.delete_by_ids(ids_to_delete)
total_db += deleted
logger.info(
"ai_writing_cleanup: 本批删 %d 行(DB),%d 个 thread(checkpointer),跳过 %d",
deleted,
len(ids_to_delete),
skipped,
)
# 整批比 batch_size 小 → 没有更多了
if len(ids) < batch_size:
break
logger.info(
"ai_writing_cleanup: 清理完成 total_db=%d total_checkpoints=%d skipped_active=%d",
total_db,
total_cp,
total_skipped,
)
return {
"deleted_db": total_db,
"deleted_checkpoints": total_cp,
"skipped_active": total_skipped,
}
# ── 调度循环 ──────────────────────────────────────────────────────────────────
def _next_cron_delay_seconds(cron_expr: str, base: datetime | None = None) -> float:
"""根据 cron 表达式算到下一次触发的秒数。
用 ``datetime.now(UTC)`` 做基准。croniter 接受 naive 时间,这里转成 naive
UTC 喂进去;当然如果用户希望按本地时区跑(凌晨 3 点),需要在外层调整 ——
我们用本地时区基准,等价于 cron 在本地时区跑。
"""
base = base or datetime.now()
nxt = croniter(cron_expr, base).get_next(datetime)
return max(1.0, (nxt - base).total_seconds())
async def cleanup_scheduler_loop() -> None:
"""常驻后台任务:按 cron 调度跑清理。
多 worker 部署下文件锁做 leader election,没拿到锁的 worker 直接退出。
被 cancel 时 CancelledError 不会被 except Exception 吞掉,能干净退出。
"""
from deerflow.config.app_config import get_app_config
from deerflow.config.runtime_paths import runtime_home
try:
cfg = get_app_config()
except Exception:
logger.exception("ai_writing_cleanup: 加载 app config 失败,调度器退出")
return
raw = getattr(cfg, "ai_writing", None) or {}
cleanup_cfg = (raw.get("cleanup") if isinstance(raw, dict) else None) or {}
if not cleanup_cfg.get("enabled", False):
logger.info("ai_writing_cleanup: cleanup.enabled=false,调度器不启动")
return
cron_expr = str(cleanup_cfg.get("cron", "0 3 * * *"))
retention_days = int(cleanup_cfg.get("retention_days", 7))
only_finished = bool(cleanup_cfg.get("delete_only_finished", False))
batch_size = int(cleanup_cfg.get("batch_size", 500))
# 提前校验 cron 表达式,配错就早 fail 早暴露
try:
croniter(cron_expr, datetime.now())
except Exception:
logger.exception("ai_writing_cleanup: 非法 cron 表达式 %r,调度器退出", cron_expr)
return
lock_handle = _acquire_lock(runtime_home() / ".ai_writing_cleanup.lock")
if lock_handle is None:
logger.info("ai_writing_cleanup: 另一个 worker 已持有调度锁,本 worker 跳过")
return
logger.info(
"ai_writing_cleanup: 已启动 cron=%r retention_days=%d only_finished=%s batch_size=%d",
cron_expr,
retention_days,
only_finished,
batch_size,
)
try:
while True:
delay = _next_cron_delay_seconds(cron_expr)
logger.info("ai_writing_cleanup: 下一次清理 %.0f 秒后", delay)
await asyncio.sleep(delay)
try:
await run_cleanup_once(
retention_days=retention_days,
only_finished=only_finished,
batch_size=batch_size,
)
except Exception:
logger.exception("ai_writing_cleanup: 本轮清理失败(不影响下一轮)")
finally:
_release_lock(lock_handle)
@@ -70,6 +70,12 @@ logger = logging.getLogger(__name__)
# firing signals into a worker that is stuck waiting for shutdown cleanup. # firing signals into a worker that is stuck waiting for shutdown cleanup.
_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0 _SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
# AI 写作 in-flight task 收尾的最大等待时间。一个 graph 片段通常在几秒内
# 跑到下一个 interrupt 或结束,但单段草稿生成走完整个 LLM 可能耗时较久,
# 给 30 秒兜底;超时则 cancel 强制结束,已落盘的 checkpoint 仍可用于重启
# 后恢复,丢的只是最后一段 in-flight 但未 checkpoint 的进度。
_AI_WRITING_DRAIN_TIMEOUT_SECONDS = 30.0
async def _ensure_admin_user(app: FastAPI) -> None: async def _ensure_admin_user(app: FastAPI) -> None:
"""Startup hook: handle first boot and migrate orphan threads otherwise. """Startup hook: handle first boot and migrate orphan threads otherwise.
@@ -361,10 +367,68 @@ async def _curator_scheduler_loop() -> None:
_release_singleton_lock(lock_handle) _release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
AI 写作 + 主 agent 流式响应都依赖**进程内**的内存状态字典
(``_event_queues``、``_session_pending_pause`` 等)。多 worker 下,
用户的 /start /stream /resume 会被 OS 随机分配到不同 worker,
per-worker 的内存字典互相看不见 → SSE 事件丢失、会话「凭空消失」、
resume 走不通。
所以这里在 startup 打 ERROR 强提醒。**不抛异常 fail-fast** —— 万一
运维确实知道自己在干嘛(比如配了 sticky session),不应该阻止启动。
"""
# uvicorn / gunicorn 都不会主动暴露 worker 数到代码层。可以从环境变量
# 或者通过观察 sibling 进程数粗略判断。优先用 WEB_CONCURRENCY(gunicorn
# / uvicorn 都认)。
web_concurrency = os.environ.get("WEB_CONCURRENCY", "").strip()
if web_concurrency and web_concurrency.isdigit() and int(web_concurrency) > 1:
logger.error(
"⚠ 检测到 WEB_CONCURRENCY=%s(多 worker 部署)。AI 写作 / 主 agent "
"流式响应依赖进程内内存状态,多 worker 下会出现「会话丢失 / "
"SSE 断流 / resume 400」等问题。强烈建议改为 WEB_CONCURRENCY=1 "
"或在反向代理上启用 sticky session。详见 AIWritingPage.tsx 的注释。",
web_concurrency,
)
return
# 兜底:扫 /proc 里同名 uvicorn 进程数(只在 Linux 容器里能用)
if not sys.platform.startswith("linux"):
return
try:
from pathlib import Path
peers = 0
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
try:
cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
except OSError:
continue
if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
peers += 1
if peers > 1:
logger.error(
"⚠ 检测到 %d 个同名 uvicorn 进程。多 worker 部署会导致 AI 写作 "
"「会话丢失 / SSE 断流」等问题,请改为单 worker 或在反向代理上 "
"启用 sticky session。",
peers,
)
except Exception:
# 检测本身失败不算事 —— 这只是个提醒
pass
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler.""" """Application lifespan handler."""
# 早一点检测多 worker 部署 —— 这是 AI 写作 / 主 agent 流式响应的常见
# 配置陷阱,每个 worker 启动时都会打一条 ERROR 强提醒运维
_warn_if_multi_worker()
# Load config and check necessary environment variables at startup # Load config and check necessary environment variables at startup
try: try:
app.state.config = get_app_config() app.state.config = get_app_config()
@@ -402,6 +466,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("Skill curator scheduler failed to start (non-fatal)") logger.exception("Skill curator scheduler failed to start (non-fatal)")
# 启动 AI 写作会话定期清理:按 config.yaml 的 ai_writing.cleanup 段调度,
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop())
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured # Start IM channel service if any channels are configured
try: try:
from app.channels.service import start_channel_service from app.channels.service import start_channel_service
@@ -439,6 +514,44 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("Curator scheduler shutdown error") logger.exception("Curator scheduler shutdown error")
# 停止 AI 写作清理调度器
cleanup_task = getattr(app.state, "ai_writing_cleanup_task", None)
if cleanup_task is not None:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
# 等待所有 in-flight 写作 graph task 跑到下一个 interrupt / 结束。
# 这是「写作流程能完整继续」的关键 —— 如果直接关 checkpointer
# connection,正在跑 astream 的 task 会在写 checkpoint 时抛错,
# 已经生成但还没落盘的进度全丢。
try:
from app.gateway.routers.ai_writing import wait_for_inflight_graph_tasks
await wait_for_inflight_graph_tasks(timeout=_AI_WRITING_DRAIN_TIMEOUT_SECONDS)
except Exception:
logger.exception("Wait for in-flight AI writing tasks failed (non-fatal)")
# 关闭 AI 写作 graph 持有的 checkpointer 连接(aiosqlite Connection / pg pool)
try:
from deerflow.agents.ai_writing.graph import shutdown_ai_writing_graph
await asyncio.wait_for(
shutdown_ai_writing_graph(),
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"AI writing checkpointer shutdown exceeded %.1fs; proceeding with worker exit.",
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
except Exception:
logger.exception("Failed to shutdown AI writing checkpointer")
try: try:
scheduler_service = getattr(app.state, "scheduled_task_service", None) scheduler_service = getattr(app.state, "scheduled_task_service", None)
if scheduler_service is not None: if scheduler_service is not None:
@@ -70,6 +70,28 @@ class RenameSessionRequest(BaseModel):
title: str title: str
class AdminCleanupRequest(BaseModel):
"""手动清理接口的可选覆盖参数。
全部留空 → 用 config.yaml 的 ai_writing.cleanup 段默认值;显式传值
→ 覆盖配置(一般用于运维想验证一个临时阈值,例如清掉「最近 1 小时
以前的」会话来模拟整轮调度)。
"""
retention_days: Optional[int] = Field(default=None, ge=0, description="保留多少天以内的会话;0 = 不保留")
only_finished: Optional[bool] = Field(default=None, description="True 时只清 done/error 状态")
batch_size: Optional[int] = Field(default=None, ge=1, le=10000, description="单批最多删多少行")
class AdminCleanupResponse(BaseModel):
deleted_db: int
deleted_checkpoints: int
# 因「仍在跑」被保护掉的会话数。永远跳过 _event_queues 里活跃的会话,
# 即使 retention_days=0 也不会秒杀用户正在写的稿。
skipped_active: int = 0
elapsed_seconds: float
params: dict
class SaveTranscriptRequest(BaseModel): class SaveTranscriptRequest(BaseModel):
# 前端组装的完整对话时间线(progressEvents + completedInterventions) # 前端组装的完整对话时间线(progressEvents + completedInterventions)
transcript: dict transcript: dict
@@ -153,11 +175,40 @@ _session_pending_pause: dict[str, tuple] = {}
# lost session don't each rebuild a different queue object. # lost session don't each rebuild a different queue object.
_recovery_lock = asyncio.Lock() _recovery_lock = asyncio.Lock()
# 在飞的 graph 推进任务(每次 /start 和 /resume 都会塞一个进来)。
# Lifespan shutdown 时会等这些 task 跑到下一个 interrupt / 结束,再关闭
# checkpointer 连接 —— 否则中途关 connection,task 内部的 sqlite 写入会抛
# 错,导致 LLM 已经生成但还没落 checkpoint 的进度丢失。
_inflight_graph_tasks: set[asyncio.Task] = set()
# ── 工具函数 ────────────────────────────────────────────────────────────────── # ── 工具函数 ──────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str: def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" """组装一帧 SSE。
JSON 序列化对 progress_events 来说理论上不会失败(节点里塞的都是 str/dict
/list/int 这类基础类型),但一旦塞进了 datetime / bytes / 自定义对象,
``json.dumps`` 会抛 TypeError → SSE 生成器爆掉 → 客户端再也收不到任何
事件,包括关键的 await_user 和 done。
这里用 ``default=str`` 兜底任何「带 __str__」的对象;如果连 ``str()``
都炸了,最后还兜一次手写一条 error 帧推出去 —— 写作流程「能继续」比
「漂亮地序列化」更重要。
"""
try:
payload = json.dumps(data, ensure_ascii=False, default=str)
except Exception as exc:
logger.exception("ai_writing: SSE payload 序列化失败 event=%s", event)
payload = json.dumps(
{
"session_id": data.get("session_id") if isinstance(data, dict) else None,
"message": f"事件序列化失败(已降级): {type(exc).__name__}",
},
ensure_ascii=False,
)
event = "notice" # 不要硬塞回原 event,万一是 error 会污染语义
return f"event: {event}\ndata: {payload}\n\n"
def _ai_writing_config() -> dict: def _ai_writing_config() -> dict:
@@ -193,14 +244,81 @@ def _truncate_title(text: str, max_len: int = 50) -> str:
return text[:max_len] + ("…" if len(text) > max_len else "") return text[:max_len] + ("…" if len(text) > max_len else "")
def _spawn_graph_task(coro) -> asyncio.Task:
"""启一个 graph 推进 task 并登记到 _inflight_graph_tasks。
用 ``add_done_callback`` 在完成时自动反登记 —— 不管 task 正常结束、抛
异常、还是被 cancel,都会清理;这样 _inflight_graph_tasks 不会泄漏。
"""
task = asyncio.create_task(coro)
_inflight_graph_tasks.add(task)
task.add_done_callback(_inflight_graph_tasks.discard)
return task
async def wait_for_inflight_graph_tasks(timeout: float) -> tuple[int, int]:
"""Lifespan shutdown 入口:阻塞等待所有 in-flight graph task 收尾。
每个 ``_stream_graph_segment`` 跑到下一个 ``interrupt()`` 或 ``END`` 就
会自然返回;通常只需要等几秒到几十秒。超时后剩下的会被 ``cancel()``,
但 cancel 完仍要等它们 ack 退出(带极短的 grace),否则下一步关
checkpointer 时还会撞上活的协程。
Returns:
(drained, cancelled): 自然结束的数量、被 cancel 强制结束的数量
"""
if not _inflight_graph_tasks:
return 0, 0
pending = set(_inflight_graph_tasks)
initial = len(pending)
logger.info("ai_writing: 等待 %d 个 in-flight graph task 收尾(timeout=%.1fs)", initial, timeout)
done, still_running = await asyncio.wait(pending, timeout=timeout)
drained = len(done)
cancelled = 0
if still_running:
logger.warning(
"ai_writing: 仍有 %d 个 task 超时未结束,强制 cancel", len(still_running)
)
for t in still_running:
t.cancel()
cancelled += 1
# 给 cancel 一点 ack 时间,避免下游关连接时撞活协程
await asyncio.wait(still_running, timeout=2.0)
logger.info("ai_writing: in-flight 收尾完成 drained=%d cancelled=%d", drained, cancelled)
return drained, cancelled
# ── 路由 ────────────────────────────────────────────────────────────────────── # ── 路由 ──────────────────────────────────────────────────────────────────────
@router.post("/start", response_model=StartWritingResponse) @router.post("/start", response_model=StartWritingResponse)
async def start_writing(body: StartWritingRequest, request: Request): async def start_writing(body: StartWritingRequest, request: Request):
"""启动写作流程,返回 session_id。""" """启动写作流程,返回 session_id。
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
**失败兜底契约**:本接口除非 Pydantic 校验 body 失败(422),其他任何
后端组件(DB / checkpointer / 文章类型仓库 / 用户解析)失败都不会导致
/start 返回 5xx。哪怕兜底走的是「极简内存模式」也要先把 session_id
交给前端,让它能起 SSE 接到 error 事件,避免 UI 卡死在 loading 状态。
"""
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user
# get_ai_writing_graph() 内部已对 sqlite/postgres 失败做了 InMemorySaver
# 降级,理论上永不抛。但 build_ai_writing_graph 拼图结构也可能因下游
# 节点 import 失败抛错 —— 这种情况 graph 完全不可用,必须早 fail,前端
# 弹错处理。
try:
graph = await get_ai_writing_graph()
except Exception as e:
logger.exception("ai_writing: graph 构建彻底失败,无法启动")
raise HTTPException(
status_code=503,
detail=f"AI 写作服务暂不可用: {type(e).__name__}",
) from e
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue() queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue _event_queues[session_id] = queue
@@ -258,7 +376,7 @@ async def start_writing(body: StartWritingRequest, request: Request):
} }
config = _make_config(session_id, body.model_name, valid_article_types=valid_article_types) config = _make_config(session_id, body.model_name, valid_article_types=valid_article_types)
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH)) _spawn_graph_task(_stream_graph_segment(session_id, initial_state, config, graph))
return StartWritingResponse(session_id=session_id) return StartWritingResponse(session_id=session_id)
@@ -430,9 +548,11 @@ async def stream_progress(session_id: str):
@router.post("/{session_id}/resume") @router.post("/{session_id}/resume")
async def resume_writing(session_id: str, body: ResumeRequest, request: Request): async def resume_writing(session_id: str, body: ResumeRequest, request: Request):
"""用户提交干预决策,恢复暂停的流程。""" """用户提交干预决策,恢复暂停的流程。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH from deerflow.agents.ai_writing.graph import get_ai_writing_graph
from app.gateway.deps import get_current_user from app.gateway.deps import get_current_user
graph = await get_ai_writing_graph()
if session_id not in _event_queues: if session_id not in _event_queues:
# 内存会话注册表丢失(进程内清理后重连等)。先尝试从 checkpointer 恢复, # 内存会话注册表丢失(进程内清理后重连等)。先尝试从 checkpointer 恢复,
# 让用户能在干预点继续,而不是直接 404 中断整个写作流程。 # 让用户能在干预点继续,而不是直接 404 中断整个写作流程。
@@ -482,8 +602,8 @@ async def resume_writing(session_id: str, body: ResumeRequest, request: Request)
# Clear pending pause so SSE reconnect won't re-inject the stale await_user event # Clear pending pause so SSE reconnect won't re-inject the stale await_user event
_session_pending_pause.pop(session_id, None) _session_pending_pause.pop(session_id, None)
asyncio.create_task( _spawn_graph_task(
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH) _stream_graph_segment(session_id, Command(resume=intervention), config, graph)
) )
return {"message": "已提交,流程恢复中"} return {"message": "已提交,流程恢复中"}
@@ -491,15 +611,112 @@ async def resume_writing(session_id: str, body: ResumeRequest, request: Request)
@router.get("/{session_id}/state") @router.get("/{session_id}/state")
async def get_state(session_id: str): async def get_state(session_id: str):
"""查询当前流程状态快照。""" """查询当前流程状态快照。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
config = _make_config(session_id) config = _make_config(session_id)
snapshot = await AI_WRITING_GRAPH.aget_state(config) snapshot = await graph.aget_state(config)
if not snapshot or not snapshot.values: if not snapshot or not snapshot.values:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
return snapshot.values return snapshot.values
# ── 管理接口 ──────────────────────────────────────────────────────────────────
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403。
与 scheduled_tasks._require_admin 同套语义:auth_disabled 模式下
get_optional_user_from_request 返回 None,此时放行(开发环境一致体验)。
"""
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="AI 写作清理管理接口仅限管理员")
def _resolve_cleanup_params(override: AdminCleanupRequest) -> dict:
"""合并 config.yaml 默认值 + 请求显式覆盖。
显式传 0 / False 也算覆盖(用 ``is not None`` 判定),方便测「保留 0 天 =
全删」这种极端场景。
"""
from deerflow.config import get_app_config
raw = getattr(get_app_config(), "ai_writing", None) or {}
cfg = (raw.get("cleanup") if isinstance(raw, dict) else None) or {}
return {
"retention_days": (
override.retention_days if override.retention_days is not None
else int(cfg.get("retention_days", 7))
),
"only_finished": (
override.only_finished if override.only_finished is not None
else bool(cfg.get("delete_only_finished", False))
),
"batch_size": (
override.batch_size if override.batch_size is not None
else int(cfg.get("batch_size", 500))
),
}
@router.post("/admin/cleanup", response_model=AdminCleanupResponse)
async def admin_cleanup(
body: AdminCleanupRequest,
request: Request,
) -> AdminCleanupResponse:
"""**仅管理员** 手动触发一次会话清理(不等凌晨 3 点定时调度)。
用途:
- 验证 cleanup 配置改动是否生效
- 内网紧急清理(磁盘满 / MySQL 行数过多)
- 调试:传 ``retention_days: 0`` 一刷拉清空,方便重新跑回归
所有参数留空 → 沿用 config.yaml 的 ``ai_writing.cleanup`` 段默认。显式
传值会覆盖配置但**不修改 config.yaml**(一次性参数,下次定时调度还是用
配置文件的值)。
"""
import time
from app.gateway.ai_writing_cleanup import run_cleanup_once
await _require_admin(request)
params = _resolve_cleanup_params(body)
started = time.monotonic()
try:
result = await run_cleanup_once(**params)
except Exception as e:
logger.exception("ai_writing admin cleanup failed: params=%s", params)
# 抛 500 是合理的 —— 管理员触发清理失败需要看到失败原因,跟「业务
# 接口被失败拖累」是两回事
raise HTTPException(
status_code=500,
detail=f"清理执行失败: {type(e).__name__}: {e}",
) from e
elapsed = time.monotonic() - started
logger.info(
"ai_writing admin cleanup done: params=%s deleted_db=%d deleted_cp=%d elapsed=%.2fs",
params,
result["deleted_db"],
result["deleted_checkpoints"],
elapsed,
)
return AdminCleanupResponse(
deleted_db=result["deleted_db"],
deleted_checkpoints=result["deleted_checkpoints"],
skipped_active=result.get("skipped_active", 0),
elapsed_seconds=round(elapsed, 3),
params=params,
)
# ── 内部辅助 ────────────────────────────────────────────────────────────────── # ── 内部辅助 ──────────────────────────────────────────────────────────────────
async def _stream_graph_segment( async def _stream_graph_segment(
@@ -650,13 +867,14 @@ def _event_type(evt: dict) -> str:
async def _load_graph_snapshot(session_id: str): async def _load_graph_snapshot(session_id: str):
"""读取 AI 写作 Graph 在该 session 上的状态快照(失败/无状态返回 None)。 """读取 AI 写作 Graph 在该 session 上的状态快照(失败/无状态返回 None)。
注意:Graph 用的是内存 checkpointer(MemorySaver),所以这里只能在 Graph 的 checkpointer 现在按 config.yaml 持久化到磁盘(默认 SQLite),
**进程存活**期间恢复;进程/容器重启后快照同样会丢失。 所以容器重启后只要数据卷还在,仍然能恢复出来。
""" """
try: try:
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH from deerflow.agents.ai_writing.graph import get_ai_writing_graph
snapshot = await AI_WRITING_GRAPH.aget_state(_make_config(session_id)) graph = await get_ai_writing_graph()
snapshot = await graph.aget_state(_make_config(session_id))
except Exception: except Exception:
logger.exception("ai_writing: aget_state failed during recovery: session=%s", session_id) logger.exception("ai_writing: aget_state failed during recovery: session=%s", session_id)
return None return None
@@ -1,3 +1,15 @@
from .graph import AI_WRITING_GRAPH, build_ai_writing_graph from .graph import (
AI_WRITING_GRAPH,
build_ai_writing_graph,
get_ai_writing_checkpointer,
get_ai_writing_graph,
shutdown_ai_writing_graph,
)
__all__ = ["AI_WRITING_GRAPH", "build_ai_writing_graph"] __all__ = [
"AI_WRITING_GRAPH",
"build_ai_writing_graph",
"get_ai_writing_graph",
"get_ai_writing_checkpointer",
"shutdown_ai_writing_graph",
]
@@ -1,7 +1,8 @@
"""AI 写作 LangGraph Graph,含 4 个用户干预点。""" """AI 写作 LangGraph Graph,含 4 个用户干预点。"""
from __future__ import annotations from __future__ import annotations
from langgraph.checkpoint.memory import MemorySaver import logging
from langgraph.graph import END, StateGraph from langgraph.graph import END, StateGraph
from langgraph.types import interrupt from langgraph.types import interrupt
@@ -12,6 +13,8 @@ from .nodes.writer_outline import writer_outline_node
from .nodes.writer_draft import writer_draft_node from .nodes.writer_draft import writer_draft_node
from .nodes.editor import editor_node from .nodes.editor import editor_node
logger = logging.getLogger(__name__)
# ── 干预节点 ────────────────────────────────────────────────────────────────── # ── 干预节点 ──────────────────────────────────────────────────────────────────
async def pause_material_confirm(state: AIWritingState) -> dict: async def pause_material_confirm(state: AIWritingState) -> dict:
@@ -298,7 +301,16 @@ def after_review_pause(state: AIWritingState) -> str:
# ── 组装 Graph ──────────────────────────────────────────────────────────────── # ── 组装 Graph ────────────────────────────────────────────────────────────────
def build_ai_writing_graph(): def build_ai_writing_graph(checkpointer=None):
"""构建并 compile AI 写作 graph。
Args:
checkpointer: 可选的 LangGraph checkpointer。
- ``None`` → 退回 ``MemorySaver`` —— 仅供测试 / 开发使用。
进程或容器重启后会话状态全部丢失,前端拿着旧 session_id 调
``/resume`` 会得到 404。
- 其他 → 传入的 (Async)BaseCheckpointSaver 实例,用于持久化。
"""
g = StateGraph(AIWritingState) g = StateGraph(AIWritingState)
g.add_node("intent_parser", intent_parser_node) g.add_node("intent_parser", intent_parser_node)
@@ -339,8 +351,188 @@ def build_ai_writing_graph():
g.add_conditional_edges("pause_review", after_review_pause, g.add_conditional_edges("pause_review", after_review_pause,
{"end": END, "writer_draft": "writer_draft"}) {"end": END, "writer_draft": "writer_draft"})
checkpointer = MemorySaver() if checkpointer is None:
# MemorySaver 仅作为 fallback —— 提示一下,正式部署应通过
# get_ai_writing_graph() 拿到带持久化 checkpointer 的 graph。
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
return g.compile(checkpointer=checkpointer) return g.compile(checkpointer=checkpointer)
AI_WRITING_GRAPH = build_ai_writing_graph() # ── 持久化 checkpointer 单例(懒加载)─────────────────────────────────────────
# 模块级 graph 槽位。import 时为 None;首次 ``get_ai_writing_graph()`` 调用时
# 按 config.yaml 的 checkpointer 段懒构建出真正的 graph。
# 测试可通过 ``monkeypatch.setattr(graph_mod, "AI_WRITING_GRAPH", stub)``
# 直接注入桩;本模块所有访问点都走 ``get_ai_writing_graph()`` 这一入口,会优先
# 返回 module attribute 的值,保留测试桩注入路径。
AI_WRITING_GRAPH = None
# 持有 async checkpointer(底层 aiosqlite Connection 或 psycopg pool 的所有者)。
# 进程生命周期内常驻;shutdown 时由 ``shutdown_ai_writing_graph`` 显式关闭。
_async_checkpointer_singleton = None
def _fallback_to_memory_saver(reason: str):
"""退回 InMemorySaver 的统一兜底入口。
把 reason 打到 warning 日志里方便运维定位。InMemorySaver 进程重启会丢
状态,但**当次写作能跑完** —— 我们的首要目标是「写作流程能继续」,
持久化是锦上添花。
"""
from langgraph.checkpoint.memory import InMemorySaver
logger.warning(
"ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver。原因: %s",
reason,
)
return InMemorySaver()
async def _get_async_checkpointer():
"""按 config.yaml 的 checkpointer 段返回一个 async checkpointer 实例。
**绝不抛异常** —— 任何失败一律退回 InMemorySaver。这是为了保证「写作
流程能继续」这个首要目标:持久化坏了用户至少还能写完当次稿,重启会丢
会话但不影响当下创作。
优先级:
1. ``checkpointer:`` 段(``type=sqlite|postgres|memory``)
2. fallback 到 ``InMemorySaver`` 并打 warning
返回的 saver 与主 agent 公用同一 sqlite 文件 —— LangGraph 的 schema
用 ``(thread_id, checkpoint_ns)`` 做主键,AI 写作的 thread_id 是
会话 uuid,跟主 agent 的 LangGraph thread_id 不会撞。
"""
global _async_checkpointer_singleton
if _async_checkpointer_singleton is not None:
return _async_checkpointer_singleton
try:
from deerflow.config.checkpointer_config import get_checkpointer_config
cp_cfg = get_checkpointer_config()
except Exception:
logger.exception("ai_writing: 加载 checkpointer 配置失败")
_async_checkpointer_singleton = _fallback_to_memory_saver("加载 config 抛异常")
return _async_checkpointer_singleton
if cp_cfg is None:
_async_checkpointer_singleton = _fallback_to_memory_saver(
"config.yaml 未配置 checkpointer 段"
)
return _async_checkpointer_singleton
if cp_cfg.type == "memory":
from langgraph.checkpoint.memory import InMemorySaver
_async_checkpointer_singleton = InMemorySaver()
logger.info("ai_writing: 配置 type=memory,使用 InMemorySaver")
return _async_checkpointer_singleton
if cp_cfg.type == "sqlite":
try:
import aiosqlite
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from deerflow.runtime.store._sqlite_utils import (
ensure_sqlite_parent_dir,
resolve_sqlite_conn_str,
)
conn_str = resolve_sqlite_conn_str(
cp_cfg.connection_string or "ai_writing_checkpoints.db"
)
ensure_sqlite_parent_dir(conn_str)
# 直接持有 aiosqlite.Connection —— 进程生命周期,shutdown 时显式关。
conn = await aiosqlite.connect(conn_str)
saver = AsyncSqliteSaver(conn)
await saver.setup()
_async_checkpointer_singleton = saver
logger.info("ai_writing: 使用 AsyncSqliteSaver (%s)", conn_str)
return saver
except Exception as exc:
# sqlite 失败原因可能是:依赖未装 / 磁盘满 / 文件权限 / 路径不存在 /
# 文件被外部进程独占锁。任何一个都不能让 AI 写作整个不可用。
logger.exception(
"ai_writing: AsyncSqliteSaver 初始化失败,conn_str=%r",
getattr(cp_cfg, "connection_string", None),
)
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"sqlite 初始化失败: {type(exc).__name__}: {exc}"
)
return _async_checkpointer_singleton
if cp_cfg.type == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
if not cp_cfg.connection_string:
raise ValueError("checkpointer.connection_string 是 postgres 必填项")
pool = AsyncConnectionPool(cp_cfg.connection_string, open=False)
await pool.open()
saver = AsyncPostgresSaver(pool)
await saver.setup()
_async_checkpointer_singleton = saver
logger.info("ai_writing: 使用 AsyncPostgresSaver")
return saver
except Exception as exc:
logger.exception("ai_writing: AsyncPostgresSaver 初始化失败")
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"postgres 初始化失败: {type(exc).__name__}: {exc}"
)
return _async_checkpointer_singleton
# 未知 type —— config 配错也不能让服务挂
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"未知 checkpointer.type={cp_cfg.type!r}"
)
return _async_checkpointer_singleton
async def get_ai_writing_graph():
"""懒加载并返回 AI 写作 graph 单例。
第一次调用按 config 选定的 checkpointer 构建 graph 并缓存;后续调用直接
返回缓存。测试可在调用前通过 ``monkeypatch.setattr`` 直接替换模块级
``AI_WRITING_GRAPH`` —— 本函数会优先返回已设的值。
"""
global AI_WRITING_GRAPH
if AI_WRITING_GRAPH is not None:
return AI_WRITING_GRAPH
checkpointer = await _get_async_checkpointer()
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
return AI_WRITING_GRAPH
def get_ai_writing_checkpointer():
"""返回当前已实例化的 checkpointer(清理任务用它来删 thread)。
若尚未懒构建过则返回 None。
"""
return _async_checkpointer_singleton
async def shutdown_ai_writing_graph() -> None:
"""关闭 saver 持有的底层连接,并清空 graph 单例。Lifespan shutdown 调用。"""
global AI_WRITING_GRAPH, _async_checkpointer_singleton
saver = _async_checkpointer_singleton
_async_checkpointer_singleton = None
AI_WRITING_GRAPH = None
if saver is None:
return
# aiosqlite saver: saver.conn 是 aiosqlite.Connection
conn = getattr(saver, "conn", None)
if conn is not None and hasattr(conn, "close"):
try:
await conn.close()
except Exception:
logger.warning("ai_writing: 关闭 checkpointer connection 失败", exc_info=True)
# postgres saver: saver.pool 是 AsyncConnectionPool
pool = getattr(saver, "pool", None)
if pool is not None and hasattr(pool, "close"):
try:
await pool.close()
except Exception:
logger.warning("ai_writing: 关闭 checkpointer pool 失败", exc_info=True)
@@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow
@@ -160,3 +160,38 @@ class AIWritingSessionRepository:
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()
return True return True
async def list_older_than(
self,
cutoff: datetime,
*,
only_finished: bool = False,
limit: int = 500,
) -> list[str]:
"""列出 updated_at 早于 cutoff 的会话 id(最多 limit 个)。
清理任务先 list 出 id 列表,再用这些 id 联动删 checkpointer
里对应的 thread,最后再删 MySQL 行 —— 保证两边一致。
Args:
cutoff: 截止时间,updated_at < cutoff 的会话被认为「过期」
only_finished: True 时只清 status in (done/error),False 不看状态
limit: 单批最多返回多少条,用于分批处理避免大事务
"""
stmt = select(AIWritingSessionRow.id).where(AIWritingSessionRow.updated_at < cutoff)
if only_finished:
stmt = stmt.where(AIWritingSessionRow.status.in_(("done", "error")))
stmt = stmt.order_by(AIWritingSessionRow.updated_at.asc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [row[0] for row in result.all()]
async def delete_by_ids(self, session_ids: list[str]) -> int:
"""按 id 列表批量删除。返回实际删除的行数。"""
if not session_ids:
return 0
stmt = delete(AIWritingSessionRow).where(AIWritingSessionRow.id.in_(session_ids))
async with self._sf() as session:
result = await session.execute(stmt)
await session.commit()
return int(result.rowcount or 0)
+15 -1
View File
@@ -117,8 +117,22 @@ database:
pool_size: 10 pool_size: 10
echo_sql: false echo_sql: false
checkpointer: checkpointer:
# SQLite 文件路径用相对路径,由 resolve_sqlite_conn_str 解析成绝对路径。
# Docker 部署:把容器内 .deer-flow 目录挂卷出来即可持久化(撑住容器重启)。
# 这里同时被「主 agent」和「AI 写作 graph」共用 —— LangGraph 用
# (thread_id, checkpoint_ns) 做隔离,session_id 是 uuid,不会撞。
type: sqlite type: sqlite
connection_string: D:\cursor-work\offline-backend-20260512\backend\.deer-flow\data\deerflow.db connection_string: .deer-flow/data/checkpoints.db
# AI 写作子系统的会话清理配置。
# 业务表(ai_writing_sessions @ MySQL) + checkpointer(checkpoints.db @ SQLite) 联动清理。
# 留意:清理走 cron 表达式调度,默认每天凌晨 3 点跑一次,按 updated_at 一刷拉清。
ai_writing:
cleanup:
enabled: true
retention_days: 7 # 保留 7 天以内的会话,更早的整条删
cron: "0 3 * * *" # 每天 03:00 本地时区
delete_only_finished: false # false=不看状态一刷拉;true=只清 done/error
batch_size: 500 # 单批最多删多少行,避免长事务
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。 # 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低): # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处