一键全文异步生成技术方案¶
- 版本:v1.0
- 日期:2026-05-18
- 作者:Codex
- 适用项目:
Tender_Documents
1. 背景¶
当前写标书页面支持单章节生成、批量生成和一键全文生成。一份标书可能包含几十个可写正文的目录节点;点击“一键全文”后,每个节点都需要独立组装上下文、调用模型、写回正文。
旧实现中,已删除的 POST /v1/tenders/{tender_id}/sections/generate-all 和 POST /v1/tenders/{tender_id}/generate-full 通过 API SSE 请求在服务端循环生成章节。章节数量少时可用,但对外上线后会遇到明显瓶颈:
- API 请求长期占用连接和进程资源。
- 一个大任务中任意章节失败会影响整体体验。
- 用户刷新页面后,任务进度恢复依赖当前 SSE 连接。
- 多用户同时点一键全文时,API 层和 LLM 调用层都会被批量任务拖慢。
- 当前正在编辑章节的“重新编写”容易被后台全文任务影响。
因此,一键全文应从“API 内循环生成”改为“创建批量任务 + Worker 并发执行 + DB 快照 + Redis Pub/Sub 事件流”。
2. 目标与非目标¶
2.1 目标¶
- API 只负责创建任务、拆分章节 job、入队并立即返回
run_id。 - 每个章节正文生成作为独立
section_job由 ARQ Worker 执行。 - 数据库保存 run 和 job 的权威状态,支持刷新恢复、失败重试和审计。
- Redis/ARQ 负责任务派发,Redis Pub/Sub 负责实时进度推送。
- 支持整次取消、失败章节重试、部分成功状态。
- 保留现有单章节生成能力,尽量复用
generation_service的上下文组装、模型选择、正文写回和用量记录逻辑。
2.2 非目标¶
- 本期不重写正文生成 prompt 和模型策略。
- 本期不要求把单章节生成也迁到 worker;可保留现有交互式 SSE,后续再统一。
- 本期不做复杂调度算法,只做基础并发控制和状态管理。
- 本期不要求完全替换旧接口,可先并行提供新接口,前端灰度切换。
3. 一句话结论¶
可以参考标书解析的大框架,但一键全文需要多一层批量编排:
数据库是状态真相来源,Redis/ARQ 是任务派发,Redis Pub/Sub 是实时进度通道。主 API 不执行长耗时章节生成。
4. 现有代码现状¶
4.1 当前入口¶
单章节生成:
POST /v1/tenders/{tender_id}/sections/{section_id}/generate- 文件:
app/routes/v1/tenders/section.py - 调用:
generation_service.generate_section(...)
一键全文现使用异步任务入口:
POST /v1/tenders/{tender_id}/generation-runs/full- 旧接口
POST /v1/tenders/{tender_id}/generate-full已删除
4.2 当前批量生成的主要问题¶
generation_service.generate_all_sections(...) 当前在一个 API SSE 请求里串行执行:
- 取
tender_content.outline - 收集叶子章节
- 循环每个章节
- 组装上下文
- 调模型流式写正文
- 写入
tender_section_contents - 记录用量
- yield SSE 事件
这段逻辑可复用,但需要拆成 Worker 可调用的“单章节 job 执行函数”。
5. 总体架构¶
5.1 主流程¶
flowchart LR
A[前端点击一键全文] --> B[API 创建 FullGenerationRun]
B --> C[扫描 outline 叶子节点]
C --> D[批量创建 SectionJob]
D --> E[写入 ARQ 队列]
E --> F[API 返回 run_id]
F --> G[前端订阅 run events]
E --> H[Worker 消费 SectionJob]
H --> I[生成章节正文]
I --> J[写入 TenderSectionContent]
J --> K[更新 SectionJob 和 Run 进度]
K --> L[Redis Pub/Sub 推送事件]
L --> G
5.2 分层职责¶
| 层 | 职责 | 不做什么 |
|---|---|---|
| API | 鉴权、创建 run、拆 job、入队、返回 run_id |
不直接调用 LLM 生成几十个章节 |
| DB | 保存 run/job 状态、正文结果、错误、用量审计 | 不承担实时推送 |
| ARQ Worker | 消费章节 job、调用模型、写回内容、更新状态 | 不依赖 API 连接存活 |
| Redis Pub/Sub | 推送 run/job 实时事件 | 不作为状态真相来源 |
| 前端 | 展示整体进度、章节状态、失败重试、取消 | 不把 SSE 当作唯一状态来源 |
6. 数据模型设计¶
6.1 generation_runs¶
一次一键全文任务。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
UUID PK | run ID |
tender_id |
UUID index | 标书 ID |
user_id |
UUID index | 用户 ID,冗余便于鉴权 |
status |
enum/string | PENDING/RUNNING/PARTIAL_SUCCESS/COMPLETED/FAILED/CANCELLED |
scene_key |
string nullable | prompt 场景,默认 section_content |
total_count |
int | 总章节数 |
queued_count |
int | 排队数 |
running_count |
int | 运行中数量 |
completed_count |
int | 成功数量 |
failed_count |
int | 失败数量 |
cancelled_count |
int | 取消数量 |
skipped_count |
int | 跳过数量 |
config |
JSONB | 运行配置,如 section_ids、overwrite、priority |
error_message |
text nullable | 整体错误信息 |
started_at |
timestamptz nullable | 开始时间 |
finished_at |
timestamptz nullable | 结束时间 |
created_at/updated_at |
timestamptz | 时间戳 |
建议索引:
idx_generation_runs_tender_created: tender_id, created_at desc
idx_generation_runs_user_status: user_id, status
6.2 generation_section_jobs¶
一次 run 下的单章节生成任务。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
UUID PK | job ID |
run_id |
UUID index | 所属 run |
tender_id |
UUID index | 标书 ID |
user_id |
UUID index | 用户 ID |
section_id |
string | outline 节点 id |
node_uid |
string nullable | 稳定节点 UID |
section_title |
text | 节标题快照 |
outline_path |
text nullable | 章节路径快照 |
status |
enum/string | QUEUED/RUNNING/COMPLETED/FAILED/CANCELLED/SKIPPED |
priority |
int | 优先级,交互任务高于批量任务 |
input_hash |
string nullable | 输入指纹,用于跳过重复生成 |
attempt_count |
int | 尝试次数 |
max_attempts |
int | 最大尝试次数 |
model_used |
string nullable | 实际模型 |
content_id |
UUID nullable | 写入的 TenderSectionContent.id |
content_chars |
int | 生成正文字符数 |
token_usage |
JSONB nullable | token 和成本统计 |
error_code |
string nullable | 错误码 |
error_message |
text nullable | 错误详情 |
started_at |
timestamptz nullable | 开始时间 |
finished_at |
timestamptz nullable | 结束时间 |
created_at/updated_at |
timestamptz | 时间戳 |
建议约束和索引:
uq_generation_section_jobs_run_section: run_id, section_id
idx_generation_section_jobs_run_status: run_id, status
idx_generation_section_jobs_tender_section: tender_id, section_id
idx_generation_section_jobs_user_status: user_id, status
6.3 正文存储原则¶
正文结果继续写入现有 tender_section_contents。generation_section_jobs 是过程表,负责记录任务状态、错误、模型、耗时、用量等。正文业务结果应保持在现有内容表里,避免同一份正文多处存储导致一致性问题。
7. 状态机¶
7.1 Run 状态¶
PENDING -> RUNNING -> COMPLETED
PENDING -> RUNNING -> PARTIAL_SUCCESS
PENDING -> RUNNING -> FAILED
PENDING -> RUNNING -> CANCELLED
规则:
- 所有 job 成功:
COMPLETED - 有成功也有失败:
PARTIAL_SUCCESS - 全部失败:
FAILED - 用户取消且仍有未完成任务:
CANCELLED - 运行中状态由 job 更新时聚合刷新
7.2 SectionJob 状态¶
QUEUED -> RUNNING -> COMPLETED
QUEUED -> RUNNING -> FAILED
QUEUED -> CANCELLED
RUNNING -> CANCELLED
QUEUED -> SKIPPED
SKIPPED 用于 input hash 命中、章节不存在、已有正文且不覆盖等情况。
8. Redis / ARQ 设计¶
8.1 ARQ Job¶
新增 job 名:
入参只传轻量 ID:
不把 prompt、正文、outline、引用资料塞入 Redis。Worker 通过 DB 读取最新任务和标书上下文。
8.2 入队方法¶
在 app/services/arq_queue_service.py 增加:
async def enqueue_section_generation(
self,
run_id: str,
section_job_id: str,
priority: int = 0,
) -> None:
...
ARQ _job_id 建议:
8.3 Worker 注册¶
在 app/worker/parser_jobs.py 或新增 generation_jobs.py 注册:
func(
generate_section_content.__func__,
name="generate_section_content",
max_tries=settings.ARQ_MAX_TRIES,
timeout=settings.ARQ_JOB_TIMEOUT_SECONDS,
)
若希望避免解析任务和生成任务抢占同一 worker,可后续拆独立 WorkerSettings 和独立队列:
第一期可复用现有队列,降低改造成本。
8.4 Pub/Sub Key¶
第一期只需要 pub 和 done。claim 可在需要防止重复入队时加入。
9. SSE 事件协议¶
9.1 事件类型¶
| type | 场景 |
|---|---|
run_started |
run 开始 |
section_queued |
章节已排队 |
section_started |
章节开始生成 |
section_progress |
章节生成中,可选传 chunk |
section_completed |
章节完成 |
section_failed |
章节失败 |
section_cancelled |
章节取消 |
run_completed |
全部成功 |
run_partial_success |
部分成功 |
run_failed |
全部失败 |
run_cancelled |
整体取消 |
9.2 示例¶
章节开始:
{
"type": "section_started",
"run_id": "4d...",
"section_job_id": "8a...",
"section_id": "1.1.1",
"section_title": "专项预案编制",
"completed": 3,
"failed": 0,
"total": 48
}
章节完成:
{
"type": "section_completed",
"run_id": "4d...",
"section_job_id": "8a...",
"section_id": "1.1.1",
"node_uid": "node-xxx",
"content_id": "7f...",
"content_chars": 1800,
"completed": 4,
"failed": 0,
"total": 48
}
整体结束:
{
"type": "run_partial_success",
"run_id": "4d...",
"status": "PARTIAL_SUCCESS",
"completed": 46,
"failed": 2,
"total": 48
}
9.3 是否推送正文 chunk¶
第一期建议不推每个章节的完整 token chunk,只推状态和完成事件。
原因:
- 一键全文可能同时跑多个章节,多路 chunk 混在一起会让前端复杂度明显升高。
- 正文完成后可通过现有章节内容接口或 run 快照读取。
- 状态事件足以支撑进度展示。
如果后续需要更强实时体验,可以仅对“当前打开章节”推 chunk,后台批量章节仍只推完成事件。
10. API 设计¶
10.1 创建一键全文 run¶
请求:
{
"section_ids": ["1.1.1", "1.1.2"],
"scene_key": "section_content",
"overwrite_existing": false,
"only_dirty": false
}
说明:
section_ids不传则默认全部可写叶子节点。overwrite_existing=false时,已有正文的章节可跳过。only_dirty可第二期启用,第一期可保留字段但不实现复杂脏标记。
响应:
{
"id": "run_id",
"tender_id": "...",
"status": "PENDING",
"total_count": 48,
"queued_count": 48,
"running_count": 0,
"completed_count": 0,
"failed_count": 0,
"cancelled_count": 0,
"skipped_count": 0
}
10.2 监听 run 事件¶
行为:
- 建连后先发送一次 DB 快照事件。
- 然后订阅 Redis Pub/Sub。
- 若 Pub/Sub 消息丢失,定期从 DB 补快照。
- 终态后发送最终事件并关闭。
10.3 取消 run¶
行为:
- 将 run 标记为
CANCELLED。 - 将
QUEUEDjob 标记为CANCELLED。 RUNNINGjob 由 worker 协同检查 run/job 状态后中止。- 已完成正文不回滚。
11. Worker 执行逻辑¶
11.1 单 job 主流程¶
读取 GenerationSectionJob
校验 run/job 未取消
job.status = RUNNING
publish section_started
读取 Tender / TenderContent / outline
定位 section 节点
组装 SectionContext
调用现有 write_section
写入 TenderSectionContent
记录 usage
job.status = COMPLETED
聚合刷新 run 计数
publish section_completed
若 run 所有 job 终态,刷新 run 终态并 publish run_completed/run_partial_success
11.2 复用现有逻辑¶
应从 generation_service.generate_all_sections(...) 中抽出内部单章节逻辑,形成类似:
async def generate_one_section_for_job(
session: AsyncSession,
*,
tender_id: uuid.UUID,
section_id: str,
scene_key: str | None,
request_id: str,
) -> SectionContentResponse:
...
该方法复用现有:
assemble_section_contextclassify_sectionwrite_sectionupsert_section_contentusage_service.record_usage
11.3 重试策略¶
两层重试:
- 单 job 内部保留现有
section_write_max_retries,处理模型超时或临时失败。 - ARQ 层保留
max_tries,处理 worker 进程异常。
注意:
- 每次重试前检查 run/job 是否已取消。
- 写正文前后要尽量幂等。
input_hash一致且正文已存在时可跳过,避免重复扣费。
12. 并发与限流¶
12.1 第一阶段并发¶
使用 ARQ worker 的 max_jobs 控制总并发。
优点:改动最小。
缺点:无法细分用户级和 run 级并发。
12.2 第二阶段并发¶
建议加入三层控制:
| 层级 | 建议 |
|---|---|
| 全局并发 | worker max_jobs 或 Redis semaphore |
| 用户并发 | 同一 user_id 最多 2-3 个章节同时生成 |
| run 并发 | 同一 run_id 最多 3-5 个章节同时生成 |
12.3 队列优先级¶
建议分两类:
interactive:用户点击单章节“重新编写”,高优先级。bulk:一键全文,低优先级。
第一期可不拆队列,但需要在设计上保留 priority 字段。后续可以拆:
13. 输入 Hash 与跳过策略¶
13.1 input_hash 组成¶
建议包含:
tender_id
section_id 或 node_uid
section_title
outline_path
content_hint
estimated_words / estimated_pages
source_markdown hash
overview / requirements hash
knowledge references version
product references version
image references version
prompt template version
scene_key
model config version
13.2 第一阶段策略¶
第一阶段可先做简单版本:
overwrite_existing=false- 若章节已有非空正文,则
SKIPPED - 若
overwrite_existing=true,强制生成并覆盖
13.3 第二阶段策略¶
加入完整 input_hash:
- hash 未变化且正文存在:跳过
- hash 变化:标记 dirty 并重写
- 用户手动编辑正文后,可保留用户编辑版本,不被批量覆盖,除非明确
overwrite_existing=true
14. 前端交互建议¶
14.1 一键全文点击后¶
- 调创建 run 接口。
- 按返回 job 列表更新左侧目录状态。
- 连接 run SSE。
- 按事件更新目录节点状态。
- 当前打开章节若完成,自动刷新该章节正文。
14.2 左侧目录状态¶
建议状态:
14.3 页面刷新恢复¶
刷新后:
- GET 最新 active run。
- 若 run 未终态,重新连接
/events;SSE 建连后会先返回 DB 快照。
14.4 失败处理¶
- 单章节失败在当前 run 结果中展示。
- 需要重新生成时,由用户重新创建全文生成任务。
15. 兼容策略¶
15.1 接口收口¶
当前一键全文接口:
POST /v1/tenders/{tender_id}/generation-runs/full
GET /v1/tenders/{tender_id}/generation-runs/{run_id}/events
POST /v1/tenders/{tender_id}/generation-runs/{run_id}/cancel
15.2 迁移上线顺序¶
- 加表和 model,不影响旧接口。
- 抽出单章节 Worker 生成方法,旧批量接口仍可调用原逻辑。
- 增加 ARQ job 和入队方法。
- 增加 run 创建接口和快照接口。
- 增加 run SSE。
- 前端切“一键全文”到新接口。
- 前端切换稳定后删除旧
generate-full,不保留兼容 wrapper(已完成)。
16. 风险与处理¶
| 风险 | 处理 |
|---|---|
| Worker 重复消费同一 job | _job_id=section_generation:{section_job_id},DB 状态二次校验 |
| API 创建 run 后入队部分失败 | DB 保留 QUEUED,可补偿扫描重新入队 |
| Redis Pub/Sub 消息丢失 | SSE 定时读取 DB 快照补偿 |
| 用户取消时 worker 正在调用模型 | 协同取消,模型请求结束后检查状态,不再写入或标为取消 |
| 部分章节失败 | run 标记 PARTIAL_SUCCESS,支持失败章节重试 |
| 一键全文影响交互式生成 | 后续拆高低优先级队列,第一期至少限制 worker 并发 |
| 重复点击一键全文 | 可限制同一 tender 同时只有一个 active run,或允许多个 run 但前端只展示最新 |
17. 监控指标¶
建议记录:
- run 总数、成功率、部分成功率、取消率
- section job 平均耗时、P95 耗时
- 单 run 平均章节数
- 单章节失败错误码分布
- LLM token 消耗
- 用户级并发峰值
- 队列等待时间
- SSE 连接数和断连率
18. 第一阶段落地清单¶
后端:
- 新增
GenerationRun/GenerationSectionJobmodel。 - 新增 Alembic migration。
- 新增 run/job schema。
- 抽出
generate_one_section_for_job(...)。 - 新增
enqueue_section_generation(...)。 - Worker 注册
generate_section_content。 - 新增
generation_run_sse_service。 - 新增 run 创建、查询、事件、取消、失败重试接口。
前端:
- 一键全文从长 SSE 改为创建 run。
- 使用 run 快照初始化目录状态。
- 连接 run events。
- 章节完成后刷新当前章节正文。
- 支持取消和失败重试。
19. 结论¶
这次改造不是重写生成系统,而是在现有单章节生成能力外面加一层批量任务系统。
最核心的变化是:
- API 不再承担几十个章节的长耗时生成。
- 一键全文被拆成 N 个可恢复、可重试、可取消的 section job。
- Worker 执行正文生成并写回现有
tender_section_contents。 - 前端通过 run 快照和 SSE 事件展示整体进度。
按第一阶段实现,改动规模中等,但能明显提升上线稳定性,并为后续优先级队列、输入 hash、脏章节、成本统计打基础。