Files
2026-05-21 08:31:33 +08:00

32 KiB
Raw Permalink 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) — 长期方案,等真正需要水平扩容时再上

六-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"]

dev stage 不用改:开发环境本来就是单 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 以上的部署机基本无压力。

常见坑位

  1. stop_grace_period 没改大 → 重启时 in-flight 写作被秒杀 表现:每次 docker compose restart 都有用户报告「写到一半没了」。修复见步骤 6。

  2. 8002 没暴露 / nginx 没改 → 所有请求还是落 8001 表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。

  3. 两路用了不同的 config.yaml 表现:8001 跟 8002 行为不一致。务必让两个进程只读同一个文件(在 compose 里只挂一次卷)。

  4. .deer-flow 卷没挂 → 8002 重启后会话全丢 表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 -v 必须有。

  5. AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸 表现:gateway-aiwriting 状态在 RUNNING/EXITED/BACKOFF 间反复。 修复:先 supervisorctl tail gateway-aiwriting stderr 看真正原因(多半是 sqlite 权限或配置文件路径),再修,别盲目调 autorestart=false。

  6. 想做 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 — 路由 + 内存状态字典 + 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

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