Merge remote-tracking branch 'origin/master'

This commit is contained in:
吉鑫升 committed 2026-05-20 17:06:06 +08:00
commit 313290b22c
21 files changed
+6764 -2745

No files matched your search

@@ -0,0 +1,916 @@
# AI 写作 — 后端开发文档
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
>
> **配套文档**:
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
---
## 一、产品定位与技术架构
### 产品定位
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
### 技术栈
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
- **业务数据**:MySQL `ai_writing_sessions` 表
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
### 与主 Agent 的关系
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
---
## 二、目录结构
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── routers/
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
│
└── packages/harness/deerflow/
├── agents/ai_writing/
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
│ ├── state.py # AIWritingState TypedDict(121 行)
│ │
│ ├── nodes/ # ── Graph 节点 ──
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
│ │ ├── intent_parser.py # 节点 1:意图解析
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
│ │ ├── writer_outline.py # 节点 3:大纲规划
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
│ │
│ ├── prompts/ # ── Prompt 模板 ──
│ │ ├── __init__.py
│ │ ├── researcher_prompts.py
│ │ ├── writer_prompts.py
│ │ └── editor_prompts.py
│ │
│ └── search/ # ── 素材检索 provider ──
│ ├── base.py # SearchResult / SearchProvider 接口
│ ├── duckduckgo_search.py
│ ├── internet_search.py # 互联网检索(默认)
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
│ └── demo_search.py # 离线 demo 数据
│
└── persistence/ai_writing_sessions/
├── __init__.py # AIWritingSessionRepository 工厂
├── model.py # ORM:AIWritingSessionRow
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
```
---
## 三、Graph 拓扑(最重要)
### StateGraph 节点 + 边
```
START
│
↓
┌─────────────────┐
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
└────────┬────────┘
↓
┌─────────────────┐
│ researcher │ ← (re_search 回到这里)
└────────┬────────┘
↓ (after_researcher: ⼟材 ready)
┌──────────────────────┐
│ pause_material │ ⚙️ PAUSE-1:素材确认
└─┬──────────┬─────────┘
│ confirm │ re_search
↓ └→ researcher
┌─────────────────┐
│ writer_outline │ ← (re_outline 回到这里)
└────────┬────────┘
↓
┌──────────────────────┐
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
└─┬──────────┬─────────┘
│ confirm │ re_outline
↓ └→ writer_outline
┌─────────────────┐
│ writer_draft │ ← (素材补充后回到这里)
└─┬───────────────┘
↓ (after_writer_draft)
┌──────┴────────────────┐
↓ ↓
┌──────────────────┐ ┌────────────────────┐
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
│ help(素材不足)│ │ │
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
│ supplement │ del/ │ │ │ to_editor
│↓ │ loose │ │ user_revise
research writer_ │ final ↓
draft │ ize writer_draft
│↓
done
│
(to_editor)│
↓
┌─────────────────┐
│ editor │ 3 维度并行流式审核
└────────┬────────┘
↓ (after_review)
┌──────────────────────┐
│ pause_review │ ⚙️ PAUSE-4:审核确认
└─┬──────────┬─────────┘
│ confirm │ revise
│↓ └→ writer_draft
done
```
### 节点定义位置一览
| 节点名 | 实现文件 | 类型 | 流式? |
|---|---|---|---|
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
### 条件路由函数(graph.py)
| 函数 | 在哪个节点之后 | 路由依据 |
|---|---|---|
| `after_researcher` | researcher | 是否找到素材 |
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
| `after_section_help_pause` | pause_section_help | status |
| `after_draft_pause` | pause_draft | done / revising / reviewing |
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
| `after_review_pause` | pause_review | done / writing_draft |
---
## 四、State 定义(state.py)
```python
class AIWritingState(TypedDict):
# —— 写作配置(启动时填充)——
user_intent: str
article_type: str
target_audience: str
word_count_target: int
keyword_count: int # 1-10,默认 6
writing_mode: str # "strict" | "loose"
# —— Agent 产出 ——
material_package: Optional[MaterialPackage] # 素材收集专家
current_outline: Optional[Outline] # 作家
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
current_draft: Optional[DraftArticle] # 最新草稿
review_result: Optional[ReviewResult] # 编辑
# —— 用户干预 ——
last_user_intervention: Optional[UserInterventionPayload]
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
# —— 流程控制 ——
revision_count: int
max_revisions: int # 默认 3
status: WritingStatus
# —— SSE 推送队列 ——
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
```
### State 字段的特殊语义
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
---
## 五、路由层(app/gateway/routers/ai_writing.py)
### 14 个接口一览
| HTTP | 路径 | 用途 | 鉴权 |
|---|---|---|---|
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
```python
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
_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] # 动态文章类型列表
_session_error_flags: dict[str, bool]
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
```
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
### `_stream_graph_segment`(graph 推进函数)
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
5. 任何异常 → 推 `error` 事件 → 关闭 queue
6. finally 段:更新 DB 最终状态、清理所有模块字典
### SSE 生成器 `_sse_generator`
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
**关键逻辑**:
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
- 收到 None 哨兵 → 关闭流
### `_recover_session`(跨 worker 恢复)
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
1. 从 checkpointer 读出 state snapshot
2. 重建 `_event_queues[sid]` 队列
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
返回三种状态:
- `"interrupted"` — 图停在干预点,已重建队列
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
---
## 六、Graph 与持久化(graph.py)
### Lazy 单例 + 自动降级
```python
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
_async_checkpointer_singleton = None # checkpointer 单例
async def get_ai_writing_graph():
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
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
```
### Checkpointer 优先级(_get_async_checkpointer)
```
1. config.yaml 的 checkpointer 段
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
└── type: memory → InMemorySaver
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
```
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
### Shutdown
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
调用顺序(详见 `app/gateway/app.py` lifespan):
1. 停清理任务
2. `wait_for_inflight_graph_tasks(30s)`
3. `shutdown_ai_writing_graph()`
---
## 七、节点详解(nodes/)
### `nodes/_utils.py`(公共工具)
| 函数 / 类 | 用途 |
|---|---|
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
| `count_words(text)` | 中文 + 英文混合计字数 |
### `nodes/intent_parser.py` — 意图解析
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
- 输出:更新 state 的对应字段,推 `step_done` 事件
### `nodes/researcher.py` — 素材检索
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
- 用 `search/*` 的 provider 并行检索
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
### `nodes/writer_outline.py` — 大纲规划
- 输入:`user_intent` + `material_package`
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
- 解析最终 JSON → `current_outline`,推 `outline_ready`
### `nodes/writer_draft.py` — 草稿生成(最复杂)
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
### `nodes/editor.py` — 编辑审核(3 维度并行)
- 3 个维度(**fact / logic / language**)并行调 LLM
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
- 三个都完成后聚合成 `review_result`,推 `review_ready`
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
---
## 八、持久化(persistence/ai_writing_sessions/)
### `model.py` — ORM
```python
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] # = session_id(uuid)
user_id: Mapped[str | None] # NULL = no-auth 模式
title: Mapped[str] # 从 user_intent 截取前 50 字
user_intent: Mapped[str | None]
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
draft_title: Mapped[str | None]
draft_markdown: Mapped[str | None]
completed_interventions: Mapped[str | None] # JSON 字符串
review_result: Mapped[str | None] # JSON 字符串
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
created_at / updated_at: BeijingDateTime
```
### `sql.py` — Repository
| 方法 | 用途 |
|---|---|
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
| `list(user_id, limit)` | 列出当前用户的会话 |
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
| `delete(session_id, user_id)` | 删除(带权限校验) |
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
### Alembic 迁移
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
---
## 九、定期清理(ai_writing_cleanup.py)
### 设计目标
- 删超过 `retention_days`(默认 7)的旧会话
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
- **绝对不删正在写的会话**(活跃保护)
### 关键函数
```python
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
```
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
执行流程:
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
5. 循环直到本批 < batch_size
### Cron 调度
`cleanup_scheduler_loop()` 常驻后台任务:
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
- `asyncio.sleep` 到下次触发时间
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
### Admin 手动触发
`POST /api/ai-writing/admin/cleanup`:
- 鉴权:`system_role == "admin"`
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
---
## 十、配置(config.yaml)
```yaml
# Checkpointer:graph 状态持久化
checkpointer:
type: sqlite # sqlite | postgres | memory
connection_string: .deer-flow/data/checkpoints.db
# AI 写作子系统
ai_writing:
# ── 清理调度 ─────────────────────────────────────────────
cleanup:
enabled: true
retention_days: 7
cron: "0 3 * * *" # 每天凌晨 3 点
delete_only_finished: false
batch_size: 500
# ── 素材收集专家(researcher)专用配置 ──────────────────
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
# 取值范围 [1, 100],非法值或缺省回退 20。
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
max_materials: 20
# ── 素材检索 provider ───────────────────────────────────
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
search_provider: internet
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
ddg_region: "cn-zh"
# intranet 模式:对接内网知识库 ES 检索接口
# 接口契约(POST):
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
```
### 配置项与代码的对应关系
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|---|---|---|---|
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
### 配置如何传到节点
```
config.yaml
└─ ai_writing: { ... }
│
▼ _ai_writing_config() 取出整个 dict
routers/ai_writing.py:_make_config()
│ 注入到 config.configurable.ai_writing_config
▼
nodes/researcher.py
cfg = config.get("configurable", {})
ai_writing_config = cfg.get("ai_writing_config", {})
keyword_model = ai_writing_config.get("keyword_model")
rank_model = ai_writing_config.get("rank_model")
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
```
### 切换内网检索的最小步骤
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
### 备注
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
---
## 十一、SSE 事件协议(后端 → 前端契约)
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
### 推出事件的位置
| 事件 | 推出位置 |
|---|---|
| `connected` | `_sse_generator` 入口 |
| `step_start` / `step_done` | 节点内部主动 emit |
| `research_progress` | `researcher_node` 内的 `_emit` |
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
| `resumed` | pause 节点 return 时塞进 `progress_events` |
| `notice` | 节点内手动推(非致命告警) |
| `done` | `_stream_graph_segment` 正常结束 |
| `error` | `_stream_graph_segment` except 分支 |
### 节点如何流式推 chunk
通过 `config.configurable.sse_queue`:
```python
async def writer_outline_node(state, config):
sse_queue = config.get("configurable", {}).get("sse_queue")
sse_session_id = config.get("configurable", {}).get("sse_session_id")
async for chunk in llm.astream(prompt):
if sse_queue:
await sse_queue.put(("outline_chunk", {
"session_id": sse_session_id,
"text": chunk.content,
"is_thinking": False,
}))
return {"current_outline": parsed, ...}
```
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
---
## 十二、扩展指南:常见开发任务
### 任务 1:新增一个 Graph 节点
**例**:在 `editor` 之后加一个「术语统一」节点。
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
```python
async def terminology_node(state, config):
# ... 读 current_draft 改术语
return {
"current_draft": new_draft,
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
}
```
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
```python
from .nodes.terminology import terminology_node
g.add_node("terminology", terminology_node)
# 改 editor 后的边
g.add_edge("editor", "terminology")
g.add_edge("terminology", "pause_review")
```
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
### 任务 2:新增一个用户干预点
1. **写 pause 节点**:在 `graph.py` 顶部加:
```python
async def pause_xxx(state):
user_input = interrupt({
"pause_point": "xxx_confirm",
"message": "...",
"context_field": state["..."],
})
action = user_input.get("action", "confirm")
if action == "confirm":
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
# ... 其他分支
```
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
```python
class ResumeRequest(BaseModel):
# ...
new_field: Optional[str] = None
```
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
```python
intervention = {
# ...
"newField": body.new_field,
}
```
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
5. **前端配合**:见前端文档
### 任务 3:新增一种素材检索 provider
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
```python
class MyProvider(SearchProvider):
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
...
```
2. 配置 `config.yaml`:
```yaml
ai_writing:
search:
provider: my_pkg.search:MyProvider
# provider 特定的配置...
```
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
### 任务 4:修改 LLM Prompt
所有 prompt 集中在 `agents/ai_writing/prompts/`:
- `researcher_prompts.py` — 检索专家
- `writer_prompts.py` — 大纲 + 草稿
- `editor_prompts.py` — 审核
改 prompt 后**强烈建议**:
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
2. 跑现有测试确认拓扑兼容
### 任务 5:扩展持久化字段
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
1. `model.py` 加列:
```python
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
```
2. **必须**新建 alembic 迁移:
```bash
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
```
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
---
## 十三、容错策略(关键设计原则)
### 「写作流程能完整继续」契约
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
### 落实位置
| 失败点 | 兜底位置 | 行为 |
|---|---|---|
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
| DB `update(review)` 失败 | 同上 | 同上 |
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
### 容器优雅关闭
lifespan shutdown 顺序:
1. 停 cleanup scheduler
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
3. 超时未结束的 task `cancel()` + 2s ack 等待
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
---
## 十四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|---|---|---|
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
跑全部:
```bash
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
```
期望 **51 个测试全过**。
---
## 十五、关键调试 / 排查命令
### 看 graph 当前状态
```python
# 进 python shell
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
print(snap.values, snap.next, snap.tasks)
```
### 直接读 sqlite checkpointer
```bash
sqlite3 /app/.deer-flow/data/checkpoints.db
.tables # 看表(checkpoints / checkpoint_writes 等)
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
WHERE thread_id = '<session_id>' LIMIT 5;
```
### 查 MySQL 业务行
```sql
SELECT id, status, draft_title, updated_at,
JSON_LENGTH(transcript) AS transcript_size
FROM ai_writing_sessions
WHERE id = '<session_id>';
```
### 查内存中的活跃会话(调试时手动加)
```python
# 临时在路由加一个 admin 接口
@router.get("/admin/inflight")
async def admin_inflight():
return {
"event_queues": list(_event_queues.keys()),
"inflight_tasks": len(_inflight_graph_tasks),
"pending_pause": list(_session_pending_pause.keys()),
}
```
### 日志关键字
```bash
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
```
期望看到(启动时):
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
警告:
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
---
## 十六、性能与限制
### 单 session 资源占用
- 内存:~50KB(state + progress_events 累计)
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
### 并发上限
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
### 长会话风险
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
### 优化方向
- progress_events 节流(聚合 chunk 事件)
- transcript 增量保存(diff 而非全量覆盖)
- 节点级 LLM 调用加 retry(tenacity)
---
## 十七、与主 Agent 的关系
| 维度 | 主 Agent | AI 写作 |
|---|---|---|
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
| Middleware | 18 层中间件 | 无(直接节点链) |
| State | `ThreadState` | `AIWritingState` |
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
---
## 十八、未来 RFC 方向
| 主题 | 描述 |
|---|---|
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
| 多模态素材 | 支持图片素材输入和图文混合输出 |
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
---
## 附录 A:相关文件速查
| 文件 | 行数 | 用途 |
|---|---|---|
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
---
## 附录 B:常见踩坑
| 现象 | 根因 | 解决 |
|---|---|---|
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -0,0 +1,538 @@
# AI 写作 — 前端开发文档
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
>
> **配套文档**:
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
---
## 一、产品定位
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
### UI 入口
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
---
## 二、目录结构
```
frontend-web/src/open-canvas/
├── pages/
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
│
├── api/ # ── 后端 API 适配层 ──
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
│ └── ai-writing-article-types.ts # 文章类型 CRUD
│
├── hooks/ # ── 状态管理 ──
│ ├── useAIWriting.ts # 写作主状态机(最重要)
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
│ └── useArticleTypes.ts # 文章类型选项 hook
│
├── components/ai-writing/ # ── UI 组件 ──
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
│ │
│ ├── AIWritingTimeline.tsx # 时间线主容器
│ ├── TimelineStep.tsx # 单条进度事件
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
│ │
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
│ ├── MaterialsCard.tsx # 素材确认
│ ├── MaterialItem.tsx # 单个素材项
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
│ ├── OutlineSection.tsx # 大纲单章节
│ ├── ReviewCard.tsx # 审核确认
│ ├── IssueList.tsx # 审核意见列表
│ ├── ScoreBar.tsx # 评分进度条
│ │
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
│ ├── DraftStreamingView.tsx # 草稿流式打字机
│ ├── ReviewStreamingView.tsx # 审核流式打字机
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
│ │
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
│
└── types/
└── ai-writing.ts # 全部类型定义(重要参考)
```
---
## 三、数据流总览
```
┌─────────────────────────────────────────────────────────────────┐
│ AIWritingPanel │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ useAIWriting() 主状态 hook │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
│ │ │ ↓ 返回 session_id │ │ │
│ │ │ │ │ │
│ │ │ subscribeToProgress(sessionId) │ │ │
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
│ │ │ ─── handleEvent reducer → setState │ │ │
│ │ │ │ │ │
│ │ │ submitIntervention() │ │ │
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
│ │ │ │ │ │
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 渲染: │
│ ├ AIWritingForm(idle 状态) │
│ ├ AIWritingTimeline(运行中) │
│ │ ├ TimelineStep × N(progressEvents) │
│ │ └ CompletedInterventionCard × N(已完成的干预) │
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 四、状态机(useAIWriting.ts)
### 状态字段(完整定义见 `types/ai-writing.ts`)
```typescript
interface AIWritingState {
// —— 核心标识 ——
sessionId: string | null; // 后端返回的会话 id
status: WritingStatus; // 当前阶段(13 种状态之一)
isPaused: boolean; // 是否在干预点等待用户
error: string | null;
// —— 配置 ——
request: AIWritingRequest | null; // 用户提交的写作请求
// —— 累计数据 ——
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
outline: AIOutline | null; // 大纲(大纲就绪后填充)
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
// —— 干预 ——
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
// —— 流式视图状态 ——
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
outlineStreamingText: string;
outlineThinkingText: string;
researchProgress: string;
streamingReviewItems: StreamingReviewItem[];
revisionSummary: RevisionSummary | null;
pendingRevisionContext: { /* ... */ } | null;
}
```
### 状态转移图(写作状态机)
```
┌──────┐
│ idle │ ←─────────────────────────────────────┐
└──┬───┘ │
│ startWriting() │
↓ │
┌─────────────────┐ │
│ researching │ │
└──┬──────────────┘ │
↓ materials_ready │
┌─────────────────────────────┐ │
│ awaiting_material_confirm │ ← await_user │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_outline │ ← (re_search 路径) │
└──┬──────────────┘ │
↓ outline_ready │
┌─────────────────────────────┐ │
│ awaiting_outline_confirm │ │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_draft │ ← (re_outline 路径) │
└──┬──────────────┘ │
│ │
┌───────┴────────┐ │
↓ ↓ │
awaiting_section_ awaiting_draft_confirm │
help (素材不足) └─┬───┬───┬──────────────┐ │
│ (supplement) │ │ │ to_editor │ │
│ ↑回到 research │ │ ↓ │ │
│ │ │ reviewing │ │
│ │ │ └─┬──────┐ │ │
│ │ │ ↓ ↓ │ │
│ │ │ awaiting_review_confirm │
│ │ │ └─┬────────┐ │
│ │ │ ↓ submit ↓ │
│ │ │ revising done ───────────────┘
│ │ │ │
│ │ │ └─→ writing_draft (循环修订)
│ │ │
│ │ │ user_revise
│ │ └─→ revising
│ │
│ │ finalize
│ └─→ done
│
└──→ (delete/loose) writing_draft 继续
```
### handleEvent reducer(核心)
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
| 事件类型 | 主要影响的状态字段 |
|---|---|
| `connected` | 仅记录到 progressEvents |
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
| `step_done` | 仅记录 |
| `research_progress` | `researchProgress`(实时检索进度文案) |
| `materials_ready` | `materials`、清空 researchProgress |
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
| `outline_ready` | `outline`、清空流式文本 |
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
| `review_ready` | `reviewResult` |
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
| `resumed` | `currentPause = null` |
| `notice` | 仅记录(非致命告警) |
| `done` | `status = 'done'` |
| `error` | `status = 'error'`,`error = event.message` |
---
## 五、API 层(api/)
### 1. `api/ai-writing.ts` — 核心写作 API
```typescript
// 启动写作 → 返回 session_id
export async function startWritingSession(req: AIWritingRequest): Promise<string>
// 订阅 SSE 进度流 → 返回取消订阅函数
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
): () => void
// 提交用户干预 → 让 graph 续推
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void>
```
**关键实现细节**:
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
### 2. `api/ai-writing-sessions.ts` — 历史会话
```typescript
// CRUD
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
deleteAIWritingSession(id) : Promise<void>
// 时间线持久化(fire-and-forget,永不抛错)
saveAIWritingTranscript(id, transcript) : Promise<boolean>
```
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
| 函数 | 用途 |
|---|---|
| `listArticleTypes()` | 表单下拉框选项 |
| `createArticleType()` | 管理员新增 |
| `updateArticleType()` | 管理员编辑 |
| `deleteArticleType()` | 管理员删除 |
---
## 六、组件树详解
### 顶层:`AIWritingPanel`(457 行,最大)
负责:
1. 调用 `useAIWriting()` 拿主状态
2. 调用 `useAIWritingSessions()` 拿历史列表
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
### 干预卡:`InterventionCard`(663 行,第二大)
按 `pausePoint` 渲染**不同的卡片**:
| pausePoint | 卡片 | 用户能做的 |
|---|---|---|
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
### 时间线:`AIWritingTimeline`
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
### 流式视图:`{Outline,Draft,Review}StreamingView`
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
### 历史回显:`AIWritingHistoryView`
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
---
## 七、SSE 事件协议(前后端契约)
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
### 解析流程
```
ReadableStream → TextDecoder
→ 按 "\n\n" 分帧
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
→ JSON.parse(data)
→ normalize(parsed)(snake → camel)
→ onEvent(normalized)
```
### 事件载荷字段(不完全列表)
| 字段 | 出现在哪些事件 | 用途 |
|---|---|---|
| `type` | 全部 | 事件类别 |
| `sessionId` | 全部 | 标识 |
| `message` | 大多数 | 显示给用户的文案 |
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
| `materials` | materials_ready | 素材数组 |
| `outline` | outline_ready | 大纲对象 |
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
| `reviewResult` | review_ready | 完整审核结果 |
| `pausePoint` | await_user | 哪个干预点 |
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
---
## 八、扩展指南:常见开发任务
### 新增一个干预点
1. **前端类型扩展**:
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
- 在 `CompletedIntervention.pausePoint` 加 case
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
3. **后端配合**(详见后端开发文档):
- `graph.py` 加 `pause_xxx_confirm` 节点
- graph 拓扑加这个节点的边
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
### 新增一个 SSE 事件类型
1. `types/ai-writing.ts`:
- `ProgressEventType` 联合类型加新值
- `ProgressEvent` 接口加新字段(如果有载荷)
2. `api/ai-writing.ts`:
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
### 修改一个流式打字机的行为
每个 streaming view 内部都是独立的:
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
- 渲染在 `*StreamingView.tsx`
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
### 新增草稿到右侧画布的逻辑
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
---
## 九、容错与边界处理
### 已落实的容错(不会阻断写作)
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
- ✅ 历史会话列表加载失败仅 `console.warn`
- ✅ 重命名/删除会话失败静默吞掉
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
### 已知边界(可能影响 UI 体验)
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
---
## 十、调试技巧
### 看 SSE 原始事件
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
### 查当前 hook 状态
在浏览器 console 里:
```js
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
// 或在 useAIWriting 里临时加:
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
// 然后在 console 里 __aiwriting 看
```
### 模拟事件
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
### 看后端 transcript
```
GET /api/ai-writing/sessions/<id>
```
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
---
## 十一、性能注意点
### 大数组渲染
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
- `streamingDraftSections` 章节多时也是潜在瓶颈
### transcript PUT 频率
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
### 浏览器内存
- progressEvents 不主动清理 → 长会话可能占用几十 MB
- 完成一篇文章后建议 `reset()` 清空状态
---
## 十二、相关后端接口(速查)
| 接口 | 用途 | 前端调用方 |
|---|---|---|
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
---
## 十三、未来优化方向
| 项目 | 说明 | 工作量 |
|---|---|---|
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
---
## 附录 A:所有类型一览
完整定义在 `types/ai-writing.ts`,主要类型:
```typescript
// 业务实体
AIMaterial // 单个素材
AIOutline // 大纲
OutlineSection // 大纲单章节
ReviewIssue // 审核问题
ReviewResult // 完整审核结果
BlockedSection // 素材不足的章节
SectionDecision // 章节求助决策
// 流程
WritingStatus // 13 种状态
ProgressEventType // 17 种事件类型
ProgressEvent // SSE 事件载荷
UserInterventionPayload // 干预提交体
AIWritingRequest // 启动写作的请求
CompletedIntervention // 已完成的干预记录
// 流式视图
StreamingDraftSection
StreamingReviewItem
RevisionSummary
// Hook 状态
AIWritingState // useAIWriting 的完整状态
```
---
## 附录 B:常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
+461
View File
@@ -0,0 +1,461 @@
# 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 维护会话状态:
```python
_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
原代码:
```python
# 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`**。
```bash
# 主 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 配置:
```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。
```yaml
# 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,让同一个客户端粘到同一个容器
```
```nginx
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)** — 长期方案,等真正需要水平扩容时再上
---
## 七、运维操作清单
### 部署前验证
```bash
# 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 警告
```
### 启动时关注的日志
```bash
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 初始化失败,需立刻排查
### 用户报告「会话丢失」时的排查
```bash
# 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)
```bash
# 用 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}'
```
返回:
```json
{
"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`
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
@@ -59,15 +59,29 @@ export async function deleteAIWritingSession(id: string): Promise<void> {
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`); if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
} }
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */ /**
* 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。
*
* 该功能属于「锦上添花」:保存失败不应影响写作主流程,因此本函数永不抛错,
* 内部捕获所有异常(网络错误 / 接口 500 等),仅打印告警并返回是否成功。
*/
export async function saveAIWritingTranscript( export async function saveAIWritingTranscript(
id: string, id: string,
transcript: AIWritingTranscript, transcript: AIWritingTranscript,
): Promise<void> { ): Promise<boolean> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, { try {
method: 'PUT', const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify({ transcript }), headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ transcript }),
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`); });
if (!resp.ok) {
console.warn(`[ai-writing] 保存对话时间线失败: HTTP ${resp.status}(已忽略,不影响写作流程)`);
return false;
}
return true;
} catch (err) {
console.warn('[ai-writing] 保存对话时间线异常(已忽略,不影响写作流程):', err);
return false;
}
} }
@@ -36,7 +36,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800'); const [wordCountInput, setWordCountInput] = useState('800');
const [keywordCountInput, setKeywordCountInput] = useState('6'); const [keywordCountInput, setKeywordCountInput] = useState('1');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]); const [models, setModels] = useState<ModelOption[]>([]);
@@ -100,7 +100,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
function handleKeywordCountBlur() { function handleKeywordCountBlur() {
const num = parseInt(keywordCountInput, 10); const num = parseInt(keywordCountInput, 10);
if (isNaN(num)) { if (isNaN(num)) {
setKeywordCountInput('6'); setKeywordCountInput('1');
} else { } else {
setKeywordCountInput(String(Math.max(1, Math.min(10, num)))); setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
} }
@@ -295,15 +295,19 @@ export function useAIWriting() {
async (payload: UserInterventionPayload) => { async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return; if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接" // 提交干预前始终重连 SSE:
// - 正常流程下旧连接仍在,abort 后重建无副作用(后端通过 _session_pending_pause
// 重发 await_user,前端 handleEvent 对同一暂停点有去重);
// - 若旧连接已因服务重启 / 反向代理空闲超时而断开,这一步能确保后端恢复出的
// 会话有消费者,否则 resume 后的进度事件会堆在队列里无人接收。
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
if (state.isPaused) { if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false })); setState((prev) => ({ ...prev, isPaused: false }));
} }
@@ -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';
@@ -265,6 +265,7 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.** Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
- **`PortableJSON`** — native `JSON` on SQLite/Postgres; `TEXT` (with manual `json.dumps`/`loads`) on MySQL, where older servers lack JSON support. - **`PortableJSON`** — native `JSON` on SQLite/Postgres; `TEXT` (with manual `json.dumps`/`loads`) on MySQL, where older servers lack JSON support.
- **`PortableLongText`** — a large text column: maps to MySQL `LONGTEXT` (4 GB) and plain `TEXT` on SQLite/Postgres (already unbounded). Use for big JSON/markdown blobs that would overflow MySQL's 64 KB `TEXT` cap — e.g. `ai_writing_sessions.transcript`.
- **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency. - **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency.
### Sandbox System (`packages/harness/deerflow/sandbox/`) ### Sandbox System (`packages/harness/deerflow/sandbox/`)
@@ -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)
@@ -71,6 +71,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.
@@ -362,10 +368,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()
@@ -403,6 +467,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
@@ -440,6 +515,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
@@ -149,11 +171,44 @@ _session_article_types: dict[str, list[dict]] = {}
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it. # so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {} _session_pending_pause: dict[str, tuple] = {}
# Serializes session recovery so that a concurrent /stream + /resume on the same
# lost session don't each rebuild a different queue object.
_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:
@@ -189,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
@@ -254,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)
@@ -317,16 +439,26 @@ async def delete_session(session_id: str, request: Request):
@router.put("/sessions/{session_id}/transcript") @router.put("/sessions/{session_id}/transcript")
async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request): async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request):
"""保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。""" """保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。
时间线持久化属于「锦上添花」功能:即便保存失败(如内网部署缺少 transcript
列、或 MySQL TEXT 列超长),也绝不能阻断写作流程。任何异常都吞掉并返回
success=False,由前端忽略即可。
"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request) repo = get_ai_writing_session_store(request)
if repo is None: if repo is None:
return {"success": False} return {"success": False, "reason": "store_unavailable"}
user_id = await get_current_user(request) try:
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript) user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript)
except Exception:
logger.exception("ai_writing: failed to save transcript: session=%s", session_id)
return {"success": False, "reason": "save_failed"}
if updated is None: if updated is None:
raise HTTPException(status_code=404, detail="Session not found") # 会话不存在或不属于当前用户:同样不抛 500,返回 success=False 即可
return {"success": False, "reason": "session_not_found"}
return {"success": True} return {"success": True}
@@ -416,11 +548,23 @@ 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:
raise HTTPException(status_code=404, detail="Session not found") # 内存会话注册表丢失(进程内清理后重连等)。先尝试从 checkpointer 恢复,
# 让用户能在干预点继续,而不是直接 404 中断整个写作流程。
recovery = await _recover_session(session_id, request)
if recovery != "interrupted":
logger.warning(
"ai_writing: resume on unrecoverable session=%s (recovery=%s)", session_id, recovery
)
raise HTTPException(
status_code=404,
detail="会话状态已丢失或流程已结束,无法继续,请重新发起写作。",
)
config = _make_config(session_id) config = _make_config(session_id)
intervention = { intervention = {
@@ -458,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": "已提交,流程恢复中"}
@@ -467,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(
@@ -623,6 +864,105 @@ def _event_type(evt: dict) -> str:
return t return t
async def _load_graph_snapshot(session_id: str):
"""读取 AI 写作 Graph 在该 session 上的状态快照(失败/无状态返回 None)。
Graph 的 checkpointer 现在按 config.yaml 持久化到磁盘(默认 SQLite),
所以容器重启后只要数据卷还在,仍然能恢复出来。
"""
try:
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snapshot = await graph.aget_state(_make_config(session_id))
except Exception:
logger.exception("ai_writing: aget_state failed during recovery: session=%s", session_id)
return None
if not snapshot or not getattr(snapshot, "values", None):
return None
return snapshot
def _extract_interrupt(snapshot) -> dict | None:
"""从快照里取出当前挂起的 interrupt 负载(即 await_user 的数据)。"""
for task in getattr(snapshot, "tasks", None) or ():
for intr in getattr(task, "interrupts", None) or ():
value = getattr(intr, "value", None)
if isinstance(value, dict):
return value
return None
async def _recover_session(session_id: str, request: Request | None = None) -> str:
"""内存会话注册表丢失时,尝试从 checkpointer 重建会话。
返回:
- "interrupted" —— 图正停在某个干预点,已重建队列并预置 await_user 事件,可继续 resume
- "finished" —— 图已完成,已重建队列并预置 done 事件(+草稿)
- "unrecoverable" —— 无可恢复状态(进程/容器重启过,或 session 从未存在)
幂等:内存里已有队列时直接当作 "interrupted" 返回。整段在 `_recovery_lock`
下执行,避免并发的 /stream 与 /resume 各自重建出不同的队列对象。
"""
async with _recovery_lock:
if session_id in _event_queues:
return "interrupted"
snapshot = await _load_graph_snapshot(session_id)
if snapshot is None:
return "unrecoverable"
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_interventions.setdefault(session_id, [])
_session_model_names.setdefault(session_id, "")
_session_article_types.setdefault(session_id, [])
# 尽量恢复 DB 仓库引用,使后续 resume 仍能持久化草稿/审核结果
if request is not None and session_id not in _session_repos:
try:
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is not None:
_session_repos[session_id] = repo
_session_user_ids[session_id] = await get_current_user(request)
except Exception:
logger.exception("ai_writing: failed to restore repo during recovery: session=%s", session_id)
interrupt_val = _extract_interrupt(snapshot)
if interrupt_val is not None:
event = ("await_user", {**interrupt_val, "session_id": session_id})
await queue.put(event)
_session_pending_pause[session_id] = event
logger.info("ai_writing: recovered interrupted session from checkpointer: session=%s", session_id)
return "interrupted"
values = snapshot.values or {}
if not getattr(snapshot, "next", None) or values.get("status") == "done":
draft = values.get("current_draft") or {}
if draft.get("full_markdown"):
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft.get("title", ""),
"markdown": draft.get("full_markdown", ""),
"revision_count": draft.get("revision_count", 0),
}))
await queue.put(("done", {"session_id": session_id, "message": "写作已完成"}))
await queue.put(None) # 哨兵:让 SSE 生成器读完即关闭
logger.info("ai_writing: recovered finished session from checkpointer: session=%s", session_id)
return "finished"
# 既不在干预点、也未结束:上次运行多半被中断在某个节点,无法续推
await queue.put(("error", {
"session_id": session_id,
"message": "写作流程已中断,请重新发起写作。",
}))
await queue.put(None)
logger.warning("ai_writing: session checkpoint stuck mid-flow, not resumable: session=%s", session_id)
return "finished"
async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]: async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
"""从队列消费事件,推送为 SSE 帧。""" """从队列消费事件,推送为 SSE 帧。"""
# 等待队列创建(最多 5 秒) # 等待队列创建(最多 5 秒)
@@ -631,10 +971,24 @@ async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
break break
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
else: else:
yield _sse("error", {"message": "Session not found"}) # 内存里没有这个会话队列。不要直接报「Session not found」——
return # 先尝试从 checkpointer 恢复(图可能仍停在某个干预点 / 已完成)。
recovery = await _recover_session(session_id)
if recovery == "unrecoverable":
yield _sse("connected", {"session_id": session_id})
yield _sse("error", {
"session_id": session_id,
"message": "会话状态已丢失(服务可能已重启),请重新发起写作。",
})
return
# 恢复成功:_event_queues[session_id] 已重建并预置事件,继续走正常消费逻辑。
queue = _event_queues[session_id] queue = _event_queues.get(session_id)
if queue is None:
# 极端兜底:恢复后又被并发清理
yield _sse("connected", {"session_id": session_id})
yield _sse("error", {"session_id": session_id, "message": "会话状态已丢失,请重新发起写作。"})
return
yield _sse("connected", {"session_id": session_id}) yield _sse("connected", {"session_id": session_id})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists. # Re-inject the saved await_user event if the queue is empty and a pending pause exists.
@@ -41,6 +41,26 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: true supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: false
title: title:
enabled: true enabled: true
@@ -175,3 +195,11 @@ memory:
block_invisible_unicode: true block_invisible_unicode: true
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。 # streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
# streaming_scrubber: true # SSE 流前清洗 <memory-context> # streaming_scrubber: true # SSE 流前清洗 <memory-context>
# AI 写作子系统 —— 素材收集专家专用配置。
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
@@ -0,0 +1,845 @@
# AI 写作 — 后端开发文档
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
>
> **配套文档**:
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
---
## 一、产品定位与技术架构
### 产品定位
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
### 技术栈
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
- **业务数据**:MySQL `ai_writing_sessions` 表
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
### 与主 Agent 的关系
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
---
## 二、目录结构
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── routers/
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
│
└── packages/harness/deerflow/
├── agents/ai_writing/
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
│ ├── state.py # AIWritingState TypedDict(121 行)
│ │
│ ├── nodes/ # ── Graph 节点 ──
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
│ │ ├── intent_parser.py # 节点 1:意图解析
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
│ │ ├── writer_outline.py # 节点 3:大纲规划
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
│ │
│ ├── prompts/ # ── Prompt 模板 ──
│ │ ├── __init__.py
│ │ ├── researcher_prompts.py
│ │ ├── writer_prompts.py
│ │ └── editor_prompts.py
│ │
│ └── search/ # ── 素材检索 provider ──
│ ├── base.py # SearchResult / SearchProvider 接口
│ ├── duckduckgo_search.py
│ ├── internet_search.py # 互联网检索(默认)
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
│ └── demo_search.py # 离线 demo 数据
│
└── persistence/ai_writing_sessions/
├── __init__.py # AIWritingSessionRepository 工厂
├── model.py # ORM:AIWritingSessionRow
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
```
---
## 三、Graph 拓扑(最重要)
### StateGraph 节点 + 边
```
START
│
↓
┌─────────────────┐
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
└────────┬────────┘
↓
┌─────────────────┐
│ researcher │ ← (re_search 回到这里)
└────────┬────────┘
↓ (after_researcher: ⼟材 ready)
┌──────────────────────┐
│ pause_material │ ⚙️ PAUSE-1:素材确认
└─┬──────────┬─────────┘
│ confirm │ re_search
↓ └→ researcher
┌─────────────────┐
│ writer_outline │ ← (re_outline 回到这里)
└────────┬────────┘
↓
┌──────────────────────┐
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
└─┬──────────┬─────────┘
│ confirm │ re_outline
↓ └→ writer_outline
┌─────────────────┐
│ writer_draft │ ← (素材补充后回到这里)
└─┬───────────────┘
↓ (after_writer_draft)
┌──────┴────────────────┐
↓ ↓
┌──────────────────┐ ┌────────────────────┐
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
│ help(素材不足)│ │ │
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
│ supplement │ del/ │ │ │ to_editor
│↓ │ loose │ │ user_revise
research writer_ │ final ↓
draft │ ize writer_draft
│↓
done
│
(to_editor)│
↓
┌─────────────────┐
│ editor │ 3 维度并行流式审核
└────────┬────────┘
↓ (after_review)
┌──────────────────────┐
│ pause_review │ ⚙️ PAUSE-4:审核确认
└─┬──────────┬─────────┘
│ confirm │ revise
│↓ └→ writer_draft
done
```
### 节点定义位置一览
| 节点名 | 实现文件 | 类型 | 流式? |
|---|---|---|---|
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
### 条件路由函数(graph.py)
| 函数 | 在哪个节点之后 | 路由依据 |
|---|---|---|
| `after_researcher` | researcher | 是否找到素材 |
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
| `after_section_help_pause` | pause_section_help | status |
| `after_draft_pause` | pause_draft | done / revising / reviewing |
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
| `after_review_pause` | pause_review | done / writing_draft |
---
## 四、State 定义(state.py)
```python
class AIWritingState(TypedDict):
# —— 写作配置(启动时填充)——
user_intent: str
article_type: str
target_audience: str
word_count_target: int
keyword_count: int # 1-10,默认 6
writing_mode: str # "strict" | "loose"
# —— Agent 产出 ——
material_package: Optional[MaterialPackage] # 素材收集专家
current_outline: Optional[Outline] # 作家
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
current_draft: Optional[DraftArticle] # 最新草稿
review_result: Optional[ReviewResult] # 编辑
# —— 用户干预 ——
last_user_intervention: Optional[UserInterventionPayload]
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
# —— 流程控制 ——
revision_count: int
max_revisions: int # 默认 3
status: WritingStatus
# —— SSE 推送队列 ——
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
```
### State 字段的特殊语义
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
---
## 五、路由层(app/gateway/routers/ai_writing.py)
### 14 个接口一览
| HTTP | 路径 | 用途 | 鉴权 |
|---|---|---|---|
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
```python
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
_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] # 动态文章类型列表
_session_error_flags: dict[str, bool]
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
```
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
### `_stream_graph_segment`(graph 推进函数)
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
5. 任何异常 → 推 `error` 事件 → 关闭 queue
6. finally 段:更新 DB 最终状态、清理所有模块字典
### SSE 生成器 `_sse_generator`
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
**关键逻辑**:
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
- 收到 None 哨兵 → 关闭流
### `_recover_session`(跨 worker 恢复)
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
1. 从 checkpointer 读出 state snapshot
2. 重建 `_event_queues[sid]` 队列
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
返回三种状态:
- `"interrupted"` — 图停在干预点,已重建队列
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
---
## 六、Graph 与持久化(graph.py)
### Lazy 单例 + 自动降级
```python
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
_async_checkpointer_singleton = None # checkpointer 单例
async def get_ai_writing_graph():
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
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
```
### Checkpointer 优先级(_get_async_checkpointer)
```
1. config.yaml 的 checkpointer 段
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
└── type: memory → InMemorySaver
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
```
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
### Shutdown
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
调用顺序(详见 `app/gateway/app.py` lifespan):
1. 停清理任务
2. `wait_for_inflight_graph_tasks(30s)`
3. `shutdown_ai_writing_graph()`
---
## 七、节点详解(nodes/)
### `nodes/_utils.py`(公共工具)
| 函数 / 类 | 用途 |
|---|---|
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
| `count_words(text)` | 中文 + 英文混合计字数 |
### `nodes/intent_parser.py` — 意图解析
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
- 输出:更新 state 的对应字段,推 `step_done` 事件
### `nodes/researcher.py` — 素材检索
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
- 用 `search/*` 的 provider 并行检索
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
### `nodes/writer_outline.py` — 大纲规划
- 输入:`user_intent` + `material_package`
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
- 解析最终 JSON → `current_outline`,推 `outline_ready`
### `nodes/writer_draft.py` — 草稿生成(最复杂)
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
### `nodes/editor.py` — 编辑审核(3 维度并行)
- 3 个维度(**fact / logic / language**)并行调 LLM
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
- 三个都完成后聚合成 `review_result`,推 `review_ready`
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
---
## 八、持久化(persistence/ai_writing_sessions/)
### `model.py` — ORM
```python
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] # = session_id(uuid)
user_id: Mapped[str | None] # NULL = no-auth 模式
title: Mapped[str] # 从 user_intent 截取前 50 字
user_intent: Mapped[str | None]
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
draft_title: Mapped[str | None]
draft_markdown: Mapped[str | None]
completed_interventions: Mapped[str | None] # JSON 字符串
review_result: Mapped[str | None] # JSON 字符串
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
created_at / updated_at: BeijingDateTime
```
### `sql.py` — Repository
| 方法 | 用途 |
|---|---|
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
| `list(user_id, limit)` | 列出当前用户的会话 |
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
| `delete(session_id, user_id)` | 删除(带权限校验) |
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
### Alembic 迁移
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
---
## 九、定期清理(ai_writing_cleanup.py)
### 设计目标
- 删超过 `retention_days`(默认 7)的旧会话
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
- **绝对不删正在写的会话**(活跃保护)
### 关键函数
```python
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
```
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
执行流程:
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
5. 循环直到本批 < batch_size
### Cron 调度
`cleanup_scheduler_loop()` 常驻后台任务:
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
- `asyncio.sleep` 到下次触发时间
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
### Admin 手动触发
`POST /api/ai-writing/admin/cleanup`:
- 鉴权:`system_role == "admin"`
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
---
## 十、配置(config.yaml)
```yaml
# Checkpointer:graph 状态持久化
checkpointer:
type: sqlite # sqlite | postgres | memory
connection_string: .deer-flow/data/checkpoints.db
# 清理调度
ai_writing:
cleanup:
enabled: true
retention_days: 7
cron: "0 3 * * *" # 每天凌晨 3 点
delete_only_finished: false
batch_size: 500
# (可选)素材检索 provider 配置
search:
provider: deerflow.agents.ai_writing.search.internet_search:WebSearchProvider
...
```
`_ai_writing_config()` 函数从 `get_app_config().ai_writing` 里读 dict,节点通过 `config.configurable.ai_writing_config` 拿到。
---
## 十一、SSE 事件协议(后端 → 前端契约)
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
### 推出事件的位置
| 事件 | 推出位置 |
|---|---|
| `connected` | `_sse_generator` 入口 |
| `step_start` / `step_done` | 节点内部主动 emit |
| `research_progress` | `researcher_node` 内的 `_emit` |
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
| `resumed` | pause 节点 return 时塞进 `progress_events` |
| `notice` | 节点内手动推(非致命告警) |
| `done` | `_stream_graph_segment` 正常结束 |
| `error` | `_stream_graph_segment` except 分支 |
### 节点如何流式推 chunk
通过 `config.configurable.sse_queue`:
```python
async def writer_outline_node(state, config):
sse_queue = config.get("configurable", {}).get("sse_queue")
sse_session_id = config.get("configurable", {}).get("sse_session_id")
async for chunk in llm.astream(prompt):
if sse_queue:
await sse_queue.put(("outline_chunk", {
"session_id": sse_session_id,
"text": chunk.content,
"is_thinking": False,
}))
return {"current_outline": parsed, ...}
```
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
---
## 十二、扩展指南:常见开发任务
### 任务 1:新增一个 Graph 节点
**例**:在 `editor` 之后加一个「术语统一」节点。
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
```python
async def terminology_node(state, config):
# ... 读 current_draft 改术语
return {
"current_draft": new_draft,
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
}
```
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
```python
from .nodes.terminology import terminology_node
g.add_node("terminology", terminology_node)
# 改 editor 后的边
g.add_edge("editor", "terminology")
g.add_edge("terminology", "pause_review")
```
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
### 任务 2:新增一个用户干预点
1. **写 pause 节点**:在 `graph.py` 顶部加:
```python
async def pause_xxx(state):
user_input = interrupt({
"pause_point": "xxx_confirm",
"message": "...",
"context_field": state["..."],
})
action = user_input.get("action", "confirm")
if action == "confirm":
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
# ... 其他分支
```
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
```python
class ResumeRequest(BaseModel):
# ...
new_field: Optional[str] = None
```
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
```python
intervention = {
# ...
"newField": body.new_field,
}
```
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
5. **前端配合**:见前端文档
### 任务 3:新增一种素材检索 provider
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
```python
class MyProvider(SearchProvider):
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
...
```
2. 配置 `config.yaml`:
```yaml
ai_writing:
search:
provider: my_pkg.search:MyProvider
# provider 特定的配置...
```
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
### 任务 4:修改 LLM Prompt
所有 prompt 集中在 `agents/ai_writing/prompts/`:
- `researcher_prompts.py` — 检索专家
- `writer_prompts.py` — 大纲 + 草稿
- `editor_prompts.py` — 审核
改 prompt 后**强烈建议**:
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
2. 跑现有测试确认拓扑兼容
### 任务 5:扩展持久化字段
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
1. `model.py` 加列:
```python
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
```
2. **必须**新建 alembic 迁移:
```bash
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
```
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
---
## 十三、容错策略(关键设计原则)
### 「写作流程能完整继续」契约
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
### 落实位置
| 失败点 | 兜底位置 | 行为 |
|---|---|---|
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
| DB `update(review)` 失败 | 同上 | 同上 |
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
### 容器优雅关闭
lifespan shutdown 顺序:
1. 停 cleanup scheduler
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
3. 超时未结束的 task `cancel()` + 2s ack 等待
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
---
## 十四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|---|---|---|
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
跑全部:
```bash
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
```
期望 **51 个测试全过**。
---
## 十五、关键调试 / 排查命令
### 看 graph 当前状态
```python
# 进 python shell
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
print(snap.values, snap.next, snap.tasks)
```
### 直接读 sqlite checkpointer
```bash
sqlite3 /app/.deer-flow/data/checkpoints.db
.tables # 看表(checkpoints / checkpoint_writes 等)
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
WHERE thread_id = '<session_id>' LIMIT 5;
```
### 查 MySQL 业务行
```sql
SELECT id, status, draft_title, updated_at,
JSON_LENGTH(transcript) AS transcript_size
FROM ai_writing_sessions
WHERE id = '<session_id>';
```
### 查内存中的活跃会话(调试时手动加)
```python
# 临时在路由加一个 admin 接口
@router.get("/admin/inflight")
async def admin_inflight():
return {
"event_queues": list(_event_queues.keys()),
"inflight_tasks": len(_inflight_graph_tasks),
"pending_pause": list(_session_pending_pause.keys()),
}
```
### 日志关键字
```bash
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
```
期望看到(启动时):
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
警告:
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
---
## 十六、性能与限制
### 单 session 资源占用
- 内存:~50KB(state + progress_events 累计)
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
### 并发上限
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
### 长会话风险
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
### 优化方向
- progress_events 节流(聚合 chunk 事件)
- transcript 增量保存(diff 而非全量覆盖)
- 节点级 LLM 调用加 retry(tenacity)
---
## 十七、与主 Agent 的关系
| 维度 | 主 Agent | AI 写作 |
|---|---|---|
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
| Middleware | 18 层中间件 | 无(直接节点链) |
| State | `ThreadState` | `AIWritingState` |
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
---
## 十八、未来 RFC 方向
| 主题 | 描述 |
|---|---|
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
| 多模态素材 | 支持图片素材输入和图文混合输出 |
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
---
## 附录 A:相关文件速查
| 文件 | 行数 | 用途 |
|---|---|---|
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
---
## 附录 B:常见踩坑
| 现象 | 根因 | 解决 |
|---|---|---|
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -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)
@@ -8,6 +8,7 @@ from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
from deerflow.persistence.types import PortableLongText
class AIWritingSessionRow(Base): class AIWritingSessionRow(Base):
@@ -22,7 +23,8 @@ class AIWritingSessionRow(Base):
draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object
# 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显 # 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显。
transcript: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object # 用 PortableLongText:MySQL 上映射为 LONGTEXT,避免 TEXT 64KB 上限导致保存 500。
transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -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)
@@ -0,0 +1,64 @@
"""Widen ai_writing_sessions.transcript to LONGTEXT on MySQL.
The ``transcript`` column stores the full AI-writing conversation timeline as a
JSON blob. It was originally created as ``TEXT``, which on MySQL caps at 64 KB —
a transcript with many progress events overflows it and the save endpoint
returns HTTP 500. This revision widens the column to ``LONGTEXT`` (4 GB) on
MySQL. SQLite/PostgreSQL ``TEXT`` is already unbounded, so they are left as-is.
Revision ID: 20260519_02
Revises: 20260519_01
Create Date: 2026-05-19
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260519_02"
down_revision = "20260519_01"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
# Only MySQL has the 64 KB TEXT limit; other backends need no change.
if bind.dialect.name != "mysql":
return
if not _has_column("ai_writing_sessions", "transcript"):
return
op.alter_column(
"ai_writing_sessions",
"transcript",
type_=LONGTEXT(),
existing_nullable=True,
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "mysql":
return
if not _has_column("ai_writing_sessions", "transcript"):
return
# Revert to TEXT. Rows longer than 64 KB would be truncated, but downgrade
# is a developer-only path.
import sqlalchemy as sa
op.alter_column(
"ai_writing_sessions",
"transcript",
type_=sa.Text(),
existing_nullable=True,
)
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import JSON, DateTime, Text from sqlalchemy import JSON, DateTime, Text
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
# China Standard Time. China has observed UTC+8 with no DST since 1991, so a # China Standard Time. China has observed UTC+8 with no DST since 1991, so a
@@ -39,6 +40,25 @@ class PortableJSON(TypeDecorator):
return json.loads(value) return json.loads(value)
class PortableLongText(TypeDecorator):
"""A large text column.
MySQL's ``TEXT`` caps at 64 KB, which silently truncates — or, in STRICT
mode, raises ``Data too long`` (HTTP 500) — for large JSON blobs such as the
AI-writing conversation transcript. This decorator maps to MySQL ``LONGTEXT``
(4 GB) while staying plain ``TEXT`` on SQLite/PostgreSQL, where ``TEXT`` is
already unbounded.
"""
impl = Text
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "mysql":
return dialect.type_descriptor(LONGTEXT())
return dialect.type_descriptor(Text())
class BeijingDateTime(TypeDecorator): class BeijingDateTime(TypeDecorator):
"""Store timestamps as Beijing (UTC+8) wall-clock time. """Store timestamps as Beijing (UTC+8) wall-clock time.
+2702 -2699
View File
File diff suppressed because it is too large. Load diff
+35 -1
View File
@@ -37,6 +37,26 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: true supports_thinking: true
- name: minimax-m2.7
display_name: MiniMax M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
tool_groups: tool_groups:
- name: web - name: web
- name: file:read - name: file:read
@@ -97,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 > 此处