Files
deerflow-code/frontend-web/docs/ai-writing-说明.md
T
2026-05-20 13:06:29 +08:00

19 KiB
Raw Blame History

AI 写作 — 故障排查与解决方案说明

本文档目的:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。

适用读者:维护 AI 写作功能的前端/后端开发者、内网部署运维。

更新日期:2026-05-20


一、问题背景

起因

内网 Docker 部署后,AI 写作出现两类报错:

接口 报错 频率
PUT /api/ai-writing/sessions/{id}/transcript HTTP 500 经常
POST /api/ai-writing/{id}/resume HTTP 4xx(用户记忆为 400,实际可能是 404) 经常,且没有 Docker 重启时也发生

这两个接口的作用

1. PUT /sessions/{id}/transcript — 对话时间线持久化

  • 职责:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
  • 调用时机:前端在写作过程中反复 PUT(事件累计、干预完成、草稿生成等节点全量覆盖)
  • 存到哪:MySQL 的 ai_writing_sessions.transcript 列(LONGTEXT)
  • 用途:用户关闭页面后再回来 / 刷新 / 看历史会话时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
  • 定位:纯锦上添花 — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整

2. POST /{id}/resume — 写作流程的核心控制接口

  • 职责:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
  • 调用时机:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
  • 传什么:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
  • 定位:流程主干 — 它失败,用户就真的卡死在干预点

二、根因分析

根因 1:transcript 列容量不足 + 路由层无兜底

  • MySQL TEXT 列上限 64 KB
  • 用户写作过程中累计 progressEvents(含每个流式 chunk)+ completedInterventions 后,JSON blob 经常超过 64 KB
  • session.commit() 抛 DataError: Data too long for column 'transcript'
  • 原路由没有 try/except,异常直接冒泡成 HTTP 500

根因 2:内存中的会话状态在多 worker 部署下隔离

后端用了大量模块级 dict 维护会话状态:

_event_queues:          dict[str, asyncio.Queue]  # SSE 事件队列
_session_pending_pause: dict[str, tuple]          # 挂起的 await_user 事件
_session_model_names:   dict[str, str]            # 用户选的模型
_session_interventions: dict[str, list]           # 累计干预历史
_session_repos:         dict[str, repo]           # DB 仓库引用
_session_user_ids:      dict[str, str]            # 用户 id
_session_article_types: dict[str, list]           # 文章类型列表

uvicorn / gunicorn 多 worker 是多进程共享 socket,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:

T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
                  graph 推进任务跑在 worker A
T1: GET /stream → 落 worker B,没有 QueueA 的引用
                  → 触发 _recover_session,从 sqlite 重建 QueueB
                  → SSE 推 QueueB 但事件都在 QueueA 里
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态

结果:SSE 时断时续、resume 偶发 404「会话状态已丢失」。

根因 3:Graph 用了内存 checkpointer

原代码:

# ai_writing/graph.py
checkpointer = MemorySaver()

进程内的 MemorySaver 重启即丢,容器重启或单 worker 进程被回收后所有进行中会话都拿不到。

根因 4:路径配错

config.yaml 里 sqlite 路径写成了 Windows 绝对路径 D:\cursor-work\...,Linux 容器里根本无法解析。


三、已落地的修复(已合入 dhk-1 分支)

修复 1:transcript 列改 LONGTEXT + 路由兜底

文件 改动
packages/harness/deerflow/persistence/types.py 新增 PortableLongText 类型(MySQL→LONGTEXT,SQLite/PG→TEXT)
packages/harness/deerflow/persistence/ai_writing_sessions/model.py transcript 列改成 PortableLongText
packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py Alembic 迁移:MySQL 上 ALTER ... type_=LONGTEXT()
app/gateway/routers/ai_writing.py save_transcript 整段 try/except 包裹,任何异常都返回 {success: false, reason},永远不抛 5xx

结果:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 console.warn,写作流程不阻断。

修复 2:Checkpointer 改为持久化 SQLite

文件 改动
packages/harness/deerflow/agents/ai_writing/graph.py MemorySaver() → 懒加载 AsyncSqliteSaver
同上 新增 get_ai_writing_graph() / get_ai_writing_checkpointer() / shutdown_ai_writing_graph()
offline-backend-20260512/config.yaml sqlite 路径改成相对路径 .deer-flow/data/checkpoints.db

关键容错:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会自动降级 InMemorySaver,写作流程能跑完,但跨重启不可恢复。

修复 3:跨 worker / 进程重启会话恢复

文件 改动
app/gateway/routers/ai_writing.py 新增 _recover_session(session_id, request)

当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 _event_queues、重新注入 await_user 事件。

效果:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,resume 接口本身能跑通(但 SSE 事件流仍隔离 — 见下文剩余风险)。

修复 4:多层失败兜底,保证写作流程能继续

改动 作用
_sse(event, data) 加 default=str + notice 帧降级 JSON 序列化失败不会让 SSE 断开
所有 DB 写入(草稿/审核/干预/status)已包 try/except DB 异常吞掉,graph 继续推
文章类型加载失败 → 退回 [] intent_parser 仍能跑
/start 加 503 兜底 graph 构建失败时给前端可读错误而不是 500

修复 5:定期清理 + admin 触发接口

文件 改动
app/gateway/ai_writing_cleanup.py 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread)
packages/harness/deerflow/persistence/ai_writing_sessions/sql.py 新增 list_older_than() / delete_by_ids()
app/gateway/app.py lifespan 启动/停止清理任务
app/gateway/routers/ai_writing.py 新增 POST /api/ai-writing/admin/cleanup 手动触发接口(admin only)
config.yaml 新增 ai_writing.cleanup 段(默认每天凌晨 3 点跑,保留 7 天)

关键安全机制:清理永远跳过 _event_queues 里仍在跑的会话(活跃保护),即使 admin 手动传 retention_days: 0 也不会秒杀正在写作的用户。

修复 6:优雅关闭 — In-flight Drain

文件 改动
app/gateway/routers/ai_writing.py 新增 _inflight_graph_tasks + _spawn_graph_task() + wait_for_inflight_graph_tasks(timeout)
app/gateway/app.py lifespan shutdown 顺序:取消清理 → 等 in-flight 写作 task 收尾(30s 超时) → 关 checkpointer connection

容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。

修复 7:多 worker 配置陷阱检测

文件 改动
app/gateway/app.py lifespan startup 检测 WEB_CONCURRENCY > 1 或 /proc 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志

下次启动容器看 docker logs 第一屏就能看到强提醒。


四、当前状态评估

transcript 500

✅ 彻底修复,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。

resume 报错

⚠️ 大幅缓解:

  • 接口本身(在 worker > 1 下)能跑通
  • sqlite 共享让 worker B 也能恢复 worker A 创建的 session

❌ 未彻底解决:

  • SSE 事件流仍 per-worker 隔离
  • 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
  • 但草稿、审核结果都落到 MySQL,刷新 /sessions/{id} 总能看到最终成品

「写作流程能不能跑完」

✅ 能。最坏情况下:

  • 用户感受:进度断断续续,需要刷新几次
  • 数据保证:草稿和审核都落 MySQL,写出来的内容不会丢

五、剩余风险

风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要

症状:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。

为什么不能用 --workers 1 解决:业务团队反馈其他业务功能需要多 worker。

解决方案见第六章。

风险 B:容器优雅关闭时 in-flight 写作进度

当前缓解:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。

完美方案(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。

风险 C:节点级别 LLM 抽风

某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。

当前缓解:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。

改进方向(不在本次修复范围):

  • 给节点加 tenacity.retry 重试装饰器
  • 节点抛错时把已生成的部分塞进 progress_events

风险 D:跨 worker 恢复时降级丢失

worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:

数据 默认值 影响
_session_model_names "" 退回默认模型
_session_interventions [] 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全)
_session_article_types [] 没影响(graph 早过了 intent_parser)

改进方向:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。


六、Worker > 1 限制下的解决方案路径

三条路径对比

方案 是否引入新依赖 代码改动 运维改动 适用场景
B. Redis Pub/Sub 共享状态 ⚠️ 需 Redis ~150 行 docker-compose 加 redis 已有 Redis 或愿意上
C. AI 写作独立单 worker 进程 无 ~30 行 docker-compose 加一个容器 / 进程 + nginx 转发 不想上 Redis
F. 多容器单 worker + nginx sticky 无 ~5 行(nginx config) 把 1 个多 worker 容器拆成 N 个单 worker 容器 已经在做容器编排

方案 B:Redis Pub/Sub(最彻底)

改造点:

之前(per-worker memory dict)            之后(Redis 共享)
─────────────────────────                ─────────────────────
_event_queues[sid] = asyncio.Queue()  →  Redis Pub/Sub channel "ai_writing:events:{sid}"
_session_pending_pause                →  Redis Hash "ai_writing:pause"
_session_model_names                  →  Redis Hash "ai_writing:meta:model"
_session_interventions                →  Redis Hash + JSON
_session_repos / _session_user_ids    →  Redis Hash

关键设计:

  1. /start 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
  2. /stream 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
  3. /resume 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」

优点:根治。任何 worker 数、任何部署形态都能跑。

缺点:

  • 引入 Redis 依赖(运维负担)
  • 代码改动较大(约 150 行)
  • 需要处理 Redis 断连 / 分布式锁释放等边缘情况

方案 C:AI 写作独立服务(次推荐)

做法:在同一个容器里多起一个 uvicorn 进程,专门处理 /api/ai-writing/*,显式 --workers 1。

# 主 gateway(多 worker,处理其他业务)
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4

# AI 写作专用单 worker
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1

nginx 配置:

location /api/ai-writing/ {
    proxy_pass http://localhost:8002;
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

location / {
    proxy_pass http://localhost:8001;
}

优点:

  • 零代码改动
  • 不引入新依赖
  • AI 写作彻底单 worker,问题根除

缺点:

  • 容器内多一个进程
  • 内存占用 +1 份 Python runtime(约 200 MB)

方案 F:多容器单 worker + nginx sticky

做法:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 ip_hash 做 sticky。

# docker-compose.yml
services:
  gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
  gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
  gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
  gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }

  nginx:
    # upstream 配 ip_hash,让同一个客户端粘到同一个容器
upstream gateway_pool {
    ip_hash;
    server gateway-1:8001;
    server gateway-2:8001;
    server gateway-3:8001;
    server gateway-4:8001;
}

优点:零代码改动,标准做法

缺点:

  • 改动 docker-compose
  • 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)

推荐顺序

  1. 方案 C(AI 写作独立单 worker 进程) — 改动最小、风险最低、立刻可上
  2. 方案 F(多容器 sticky) — 如果你们已经在做多容器编排
  3. 方案 B(Redis) — 长期方案,等真正需要水平扩容时再上

七、运维操作清单

部署前验证

# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
docker exec <gateway> ls -la /app/.deer-flow/data/
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)

# 2) 确认 alembic 迁移跑完了
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
# 应该看到包含 20260519_02 的 head

# 3) 确认 transcript 列已扩容
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
  "SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
# Type 列应该是 longtext

# 4) 查 worker 数
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告

启动时关注的日志

docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'

期望看到:

  • ai_writing: 使用 AsyncSqliteSaver (xxx.db) — 持久化生效
  • ai_writing_cleanup: 已启动 cron=... — 清理任务起来了

警告:

  • ⚠ 检测到 WEB_CONCURRENCY=N — 多 worker 配置警告
  • ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver — sqlite 初始化失败,需立刻排查

用户报告「会话丢失」时的排查

# 1) 查 sqlite 里这个 session 的快照
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
  \"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""

# 2) 查 MySQL 里这条会话
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
  "SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"

# 3) 看 gateway 日志里这个 session 的活动轨迹
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50

# 4) 看是否有 recover_session 调用
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20

# 5) 看是否有节点异常
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20

手动触发清理(admin only)

# 用 admin 账号登录后,取 cookie,调清理接口
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: <admin session cookie>' \
  -d '{"retention_days": 7}'

返回:

{
  "deleted_db": 12,
  "deleted_checkpoints": 12,
  "skipped_active": 1,
  "elapsed_seconds": 0.234,
  "params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
}

skipped_active > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。


八、相关文件索引

后端

  • offline-backend-20260512/backend/app/gateway/routers/ai_writing.py — 路由 + 内存状态字典 + drain
  • offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py — 定期清理
  • offline-backend-20260512/backend/app/gateway/app.py — lifespan / 多 worker 检测
  • offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py — Graph 定义 + 持久化 checkpointer
  • offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/ — MySQL repo
  • offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py — PortableLongText
  • offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py — LONGTEXT 迁移
  • offline-backend-20260512/config.yaml — checkpointer: + ai_writing.cleanup: 配置

前端

  • frontend-web/src/open-canvas/pages/AIWritingPage.tsx — 页面顶部有快速排查注释
  • frontend-web/src/open-canvas/api/ai-writing-sessions.ts — transcript 保存(已做 fire-and-forget 容错)
  • frontend-web/src/open-canvas/api/ai-writing.ts — resume / stream

测试

  • tests/test_ai_writing_cleanup.py — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
  • tests/test_ai_writing_session_recovery.py — 跨 worker 会话恢复(8 个测试)
  • tests/test_ai_writing_session_repo.py — Repo(7 个测试)
  • tests/test_ai_writing_transcript_endpoint.py — transcript 兜底(5 个测试)
  • tests/test_portable_long_text.py — LONGTEXT 列类型(5 个测试)

总测试:51 个,全过。


九、变更历史

日期 Commit 内容
2026-05-19 c3d0c82 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复)
2026-05-20 (本次) checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测

十、决策摘要(给未来的你/同事)

如果你现在第一次接手这个问题,按这个清单往下走:

  1. ✅ transcript 500 不用管 — 已彻底修复
  2. ⚠️ resume 报错的根因是多 worker — sqlite 持久化已减轻,但 SSE 事件流仍隔离
  3. ⚠️ 写作能跑完,但用户体验断断续续 — 草稿数据安全,刷新就能看到
  4. 🎯 要彻底解决,从方案 C / F / B 选一个(建议从 C 开始)
  5. 🚨 务必确认 .deer-flow 数据卷挂出来了 — 否则 sqlite 持久化白做
  6. 📊 关注的日志关键字:ai_writing:、checkpointer、AsyncSqliteSaver、WEB_CONCURRENCY

如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。