32 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) — 长期方案,等真正需要水平扩容时再上
六-A、方案 C 在 Docker 容器内的详细实施指南
目标:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
基础设施:本项目镜像基于
python:3.12-slim-bookworm,用uv管理依赖,Dockerfile 位于offline-backend-20260512/backend/Dockerfile,默认入口是CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
总览:单容器双 uvicorn 进程
┌─────────────── nginx (容器外或前置) ───────────────┐
│ │
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
└────────────────────────────────────────────────────┘
▲
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
两进程都用 同一个 app.gateway.app:app,区别只在:
8002单 worker,专门接/api/ai-writing/*8001多 worker,接其他所有路由- 容器内用
supervisord同时托管这两个 uvicorn
步骤 1:在镜像里加 supervisord
编辑 offline-backend-20260512/backend/Dockerfile,给 runtime stage(第 67 行 FROM python:3.12-slim-bookworm 之后)补一段:
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
RUN apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& rm -rf /var/lib/apt/lists/*
如果内网无法访问 debian 源,可用现有的 APT_MIRROR build-arg:
docker build \
--build-arg APT_MIRROR=mirrors.aliyun.com \
-f offline-backend-20260512/backend/Dockerfile \
-t deerflow-gateway:dual .
步骤 2:写 supervisord 配置
新建 offline-backend-20260512/backend/deploy/supervisord.conf:
[supervisord]
nodaemon=true ; 容器主进程,必须前台
user=root
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
[program:gateway-main]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8001
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
[program:gateway-aiwriting]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8002
--workers 1
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
几个关键点:
nodaemon=true让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程stopasgroup=true+killasgroup=true确保 uvicorn 多 worker 模式下所有子进程一起退stopwaitsecs=35必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL%(ENV_GATEWAY_MAIN_WORKERS)s让主进程 worker 数走环境变量,下面会传
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage COPY --from=builder /app/backend ./backend 之后加):
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
步骤 3:把 Dockerfile 入口换成 supervisord
把 Dockerfile runtime stage 最后一行 改成:
# 之前
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
# 之后
EXPOSE 8001 8002 2024
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
devstage 不用改:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
步骤 4:暴露端口 / 挂卷 / 环境变量
启动容器时:
docker run -d --name deerflow-gateway \
-p 8001:8001 \
-p 8002:8002 \
-e GATEWAY_MAIN_WORKERS=4 \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-v /your/host/path/.deer-flow:/app/.deer-flow \
-v /your/host/path/config.yaml:/app/config.yaml:ro \
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
deerflow-gateway:dual
两个进程必须共享 .deer-flow 卷 —— 因为 checkpoints.db 在里面(offline-backend-20260512/config.yaml 里配的是相对路径 .deer-flow/data/checkpoints.db)。两个进程跑在同一个容器内,天然共享文件系统,这一步不用做任何额外配置,只要确认数据卷挂出去就行。
⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
步骤 5:nginx 路由
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
upstream gateway_main { server gateway-host:8001; }
upstream gateway_aiwriting { server gateway-host:8002; }
server {
listen 80;
server_name deerflow.example.com;
# SSE 长连接必须关 buffering
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# AI 写作 → 单 worker 后端
location /api/ai-writing/ {
proxy_pass http://gateway_aiwriting;
proxy_read_timeout 3600s; # graph 跑得久,给足时间
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 其它一切 API → 多 worker 后端
location /api/ {
proxy_pass http://gateway_main;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端静态资源不变
location / {
proxy_pass http://frontend:5174;
}
}
proxy_buffering off 是必须的 —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 frontend-web 里 AI 写作相关的 fetch/EventSource 改成 :8002,但更推荐 始终走 nginx,对前端透明。
步骤 6:如果你用 docker-compose
完整 compose 示例:
services:
gateway:
image: deerflow-gateway:dual
container_name: deerflow-gateway
restart: unless-stopped
environment:
GATEWAY_MAIN_WORKERS: "4"
DEER_FLOW_CONFIG_PATH: /app/config.yaml
ports:
- "8001:8001"
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
volumes:
- ./data/deer-flow:/app/.deer-flow
- ./config.yaml:/app/config.yaml:ro
- ./extensions_config.json:/app/extensions_config.json:ro
healthcheck:
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
nginx:
image: nginx:1.27-alpine
depends_on: [gateway]
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
stop_grace_period: 45s 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
步骤 7:验证(按这个清单逐项过)
# 1) 两个进程都起来了
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
# 期望看到:
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
# 3) 8001 不再警告多 worker
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
# 4) 8002 进程能看到 ai_writing 持久化日志
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
# 5) /api/ai-writing/* 走 8002,其他走 8001
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
步骤 8:监控两个进程的健康
supervisord 自带 unix_http_server + supervisorctl,但对容器化场景更直接的是看 docker logs:
# 实时看两个进程的日志
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
# 进程级状态
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
# 期望:
# gateway-main RUNNING pid 7, uptime 0:05:12
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
资源开销(实测预估)
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|---|---|---|---|
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
结论:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
常见坑位
-
stop_grace_period没改大 → 重启时 in-flight 写作被秒杀 表现:每次docker compose restart都有用户报告「写到一半没了」。修复见步骤 6。 -
8002 没暴露 / nginx 没改 → 所有请求还是落 8001 表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
-
两路用了不同的
config.yaml表现:8001 跟 8002 行为不一致。务必让两个进程只读同一个文件(在 compose 里只挂一次卷)。 -
.deer-flow卷没挂 → 8002 重启后会话全丢 表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的-v必须有。 -
AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸 表现:
gateway-aiwriting状态在 RUNNING/EXITED/BACKOFF 间反复。 修复:先supervisorctl tail gateway-aiwriting stderr看真正原因(多半是 sqlite 权限或配置文件路径),再修,别盲目调autorestart=false。 -
想做 K8s 部署但还想用方案 C 不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
回滚方案
如果上线后发现问题,回滚只需要 1 步:
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),不会丢任何用户数据,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
七、运维操作清单
部署前验证
# 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
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。