19 KiB
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
关键设计:
/start在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel/stream在 worker B:subscribe Redis channel,把消息转 SSE 推前端/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 写作低并发场景影响小)
推荐顺序
- 方案 C(AI 写作独立单 worker 进程) — 改动最小、风险最低、立刻可上
- 方案 F(多容器 sticky) — 如果你们已经在做多容器编排
- 方案 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— 路由 + 内存状态字典 + drainoffline-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 定义 + 持久化 checkpointeroffline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/— MySQL repooffline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py—PortableLongTextoffline-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 检测 |
十、决策摘要(给未来的你/同事)
如果你现在第一次接手这个问题,按这个清单往下走:
- ✅ transcript 500 不用管 — 已彻底修复
- ⚠️ resume 报错的根因是多 worker — sqlite 持久化已减轻,但 SSE 事件流仍隔离
- ⚠️ 写作能跑完,但用户体验断断续续 — 草稿数据安全,刷新就能看到
- 🎯 要彻底解决,从方案 C / F / B 选一个(建议从 C 开始)
- 🚨 务必确认
.deer-flow数据卷挂出来了 — 否则 sqlite 持久化白做- 📊 关注的日志关键字:
ai_writing:、checkpointer、AsyncSqliteSaver、WEB_CONCURRENCY
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。