跳转至

一键全文异步生成技术方案

  • 版本:v1.0
  • 日期:2026-05-18
  • 作者:Codex
  • 适用项目:Tender_Documents

1. 背景

当前写标书页面支持单章节生成、批量生成和一键全文生成。一份标书可能包含几十个可写正文的目录节点;点击“一键全文”后,每个节点都需要独立组装上下文、调用模型、写回正文。

旧实现中,已删除的 POST /v1/tenders/{tender_id}/sections/generate-allPOST /v1/tenders/{tender_id}/generate-full 通过 API SSE 请求在服务端循环生成章节。章节数量少时可用,但对外上线后会遇到明显瓶颈:

  1. API 请求长期占用连接和进程资源。
  2. 一个大任务中任意章节失败会影响整体体验。
  3. 用户刷新页面后,任务进度恢复依赖当前 SSE 连接。
  4. 多用户同时点一键全文时,API 层和 LLM 调用层都会被批量任务拖慢。
  5. 当前正在编辑章节的“重新编写”容易被后台全文任务影响。

因此,一键全文应从“API 内循环生成”改为“创建批量任务 + Worker 并发执行 + DB 快照 + Redis Pub/Sub 事件流”。


2. 目标与非目标

2.1 目标

  1. API 只负责创建任务、拆分章节 job、入队并立即返回 run_id
  2. 每个章节正文生成作为独立 section_job 由 ARQ Worker 执行。
  3. 数据库保存 run 和 job 的权威状态,支持刷新恢复、失败重试和审计。
  4. Redis/ARQ 负责任务派发,Redis Pub/Sub 负责实时进度推送。
  5. 支持整次取消、失败章节重试、部分成功状态。
  6. 保留现有单章节生成能力,尽量复用 generation_service 的上下文组装、模型选择、正文写回和用量记录逻辑。

2.2 非目标

  1. 本期不重写正文生成 prompt 和模型策略。
  2. 本期不要求把单章节生成也迁到 worker;可保留现有交互式 SSE,后续再统一。
  3. 本期不做复杂调度算法,只做基础并发控制和状态管理。
  4. 本期不要求完全替换旧接口,可先并行提供新接口,前端灰度切换。

3. 一句话结论

可以参考标书解析的大框架,但一键全文需要多一层批量编排:

FullGenerationRun
  -> GenerationSectionJob[]
      -> ARQ Worker
      -> TenderSectionContent

数据库是状态真相来源,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 请求里串行执行:

  1. tender_content.outline
  2. 收集叶子章节
  3. 循环每个章节
  4. 组装上下文
  5. 调模型流式写正文
  6. 写入 tender_section_contents
  7. 记录用量
  8. 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_contentsgeneration_section_jobs 是过程表,负责记录任务状态、错误、模型、耗时、用量等。正文业务结果应保持在现有内容表里,避免同一份正文多处存储导致一致性问题。


7. 状态机

7.1 Run 状态

PENDING -> RUNNING -> COMPLETED
PENDING -> RUNNING -> PARTIAL_SUCCESS
PENDING -> RUNNING -> FAILED
PENDING -> RUNNING -> CANCELLED

规则:

  1. 所有 job 成功:COMPLETED
  2. 有成功也有失败:PARTIAL_SUCCESS
  3. 全部失败:FAILED
  4. 用户取消且仍有未完成任务:CANCELLED
  5. 运行中状态由 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 名:

generate_section_content

入参只传轻量 ID:

{
  "run_id": "...",
  "section_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 建议:

section_generation:{section_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 和独立队列:

ARQ_GENERATION_QUEUE_NAME
ARQ_GENERATION_MAX_JOBS

第一期可复用现有队列,降低改造成本。

8.4 Pub/Sub Key

generation:run:{run_id}:pub
generation:run:{run_id}:done
generation:run:{run_id}:claim

第一期只需要 pubdoneclaim 可在需要防止重复入队时加入。


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,只推状态和完成事件。

原因:

  1. 一键全文可能同时跑多个章节,多路 chunk 混在一起会让前端复杂度明显升高。
  2. 正文完成后可通过现有章节内容接口或 run 快照读取。
  3. 状态事件足以支撑进度展示。

如果后续需要更强实时体验,可以仅对“当前打开章节”推 chunk,后台批量章节仍只推完成事件。


10. API 设计

10.1 创建一键全文 run

POST /v1/tenders/{tender_id}/generation-runs/full

请求:

{
  "section_ids": ["1.1.1", "1.1.2"],
  "scene_key": "section_content",
  "overwrite_existing": false,
  "only_dirty": false
}

说明:

  1. section_ids 不传则默认全部可写叶子节点。
  2. overwrite_existing=false 时,已有正文的章节可跳过。
  3. 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 事件

GET /v1/tenders/{tender_id}/generation-runs/{run_id}/events

行为:

  1. 建连后先发送一次 DB 快照事件。
  2. 然后订阅 Redis Pub/Sub。
  3. 若 Pub/Sub 消息丢失,定期从 DB 补快照。
  4. 终态后发送最终事件并关闭。

10.3 取消 run

POST /v1/tenders/{tender_id}/generation-runs/{run_id}/cancel

行为:

  1. 将 run 标记为 CANCELLED
  2. QUEUED job 标记为 CANCELLED
  3. RUNNING job 由 worker 协同检查 run/job 状态后中止。
  4. 已完成正文不回滚。

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:
    ...

该方法复用现有:

  1. assemble_section_context
  2. classify_section
  3. write_section
  4. upsert_section_content
  5. usage_service.record_usage

11.3 重试策略

两层重试:

  1. 单 job 内部保留现有 section_write_max_retries,处理模型超时或临时失败。
  2. ARQ 层保留 max_tries,处理 worker 进程异常。

注意:

  1. 每次重试前检查 run/job 是否已取消。
  2. 写正文前后要尽量幂等。
  3. 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 队列优先级

建议分两类:

  1. interactive:用户点击单章节“重新编写”,高优先级。
  2. bulk:一键全文,低优先级。

第一期可不拆队列,但需要在设计上保留 priority 字段。后续可以拆:

ARQ_GENERATION_INTERACTIVE_QUEUE
ARQ_GENERATION_BULK_QUEUE

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 第一阶段策略

第一阶段可先做简单版本:

  1. overwrite_existing=false
  2. 若章节已有非空正文,则 SKIPPED
  3. overwrite_existing=true,强制生成并覆盖

13.3 第二阶段策略

加入完整 input_hash

  1. hash 未变化且正文存在:跳过
  2. hash 变化:标记 dirty 并重写
  3. 用户手动编辑正文后,可保留用户编辑版本,不被批量覆盖,除非明确 overwrite_existing=true

14. 前端交互建议

14.1 一键全文点击后

  1. 调创建 run 接口。
  2. 按返回 job 列表更新左侧目录状态。
  3. 连接 run SSE。
  4. 按事件更新目录节点状态。
  5. 当前打开章节若完成,自动刷新该章节正文。

14.2 左侧目录状态

建议状态:

未生成
排队中
生成中
已完成
失败
已跳过
已取消

14.3 页面刷新恢复

刷新后:

  1. GET 最新 active run。
  2. 若 run 未终态,重新连接 /events;SSE 建连后会先返回 DB 快照。

14.4 失败处理

  1. 单章节失败在当前 run 结果中展示。
  2. 需要重新生成时,由用户重新创建全文生成任务。

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 迁移上线顺序

  1. 加表和 model,不影响旧接口。
  2. 抽出单章节 Worker 生成方法,旧批量接口仍可调用原逻辑。
  3. 增加 ARQ job 和入队方法。
  4. 增加 run 创建接口和快照接口。
  5. 增加 run SSE。
  6. 前端切“一键全文”到新接口。
  7. 前端切换稳定后删除旧 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. 监控指标

建议记录:

  1. run 总数、成功率、部分成功率、取消率
  2. section job 平均耗时、P95 耗时
  3. 单 run 平均章节数
  4. 单章节失败错误码分布
  5. LLM token 消耗
  6. 用户级并发峰值
  7. 队列等待时间
  8. SSE 连接数和断连率

18. 第一阶段落地清单

后端:

  1. 新增 GenerationRun / GenerationSectionJob model。
  2. 新增 Alembic migration。
  3. 新增 run/job schema。
  4. 抽出 generate_one_section_for_job(...)
  5. 新增 enqueue_section_generation(...)
  6. Worker 注册 generate_section_content
  7. 新增 generation_run_sse_service
  8. 新增 run 创建、查询、事件、取消、失败重试接口。

前端:

  1. 一键全文从长 SSE 改为创建 run。
  2. 使用 run 快照初始化目录状态。
  3. 连接 run events。
  4. 章节完成后刷新当前章节正文。
  5. 支持取消和失败重试。

19. 结论

这次改造不是重写生成系统,而是在现有单章节生成能力外面加一层批量任务系统。

最核心的变化是:

  1. API 不再承担几十个章节的长耗时生成。
  2. 一键全文被拆成 N 个可恢复、可重试、可取消的 section job。
  3. Worker 执行正文生成并写回现有 tender_section_contents
  4. 前端通过 run 快照和 SSE 事件展示整体进度。

按第一阶段实现,改动规模中等,但能明显提升上线稳定性,并为后续优先级队列、输入 hash、脏章节、成本统计打基础。