Merge remote-tracking branch 'origin/master'
This commit is contained in:
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 里注明改动原因。
|
||||||
@@ -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 里注明改动原因。
|
||||||
+14
-2
@@ -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",
|
||||||
|
]
|
||||||
+196
-4
@@ -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)
|
||||||
+4
-2
@@ -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))
|
||||||
+36
-1
@@ -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)
|
||||||
+64
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2702
-2699
File diff suppressed because it is too large.
Load diff
@@ -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 > 此处
|
||||||
|
|||||||
Reference in new issue
Block a user