Files
writer-work-flow/docs/design/chain-workflow.md

18 KiB
Raw Blame History

设计文档 · 多章工作流链Chain Workflow

状态:设计稿(实施前契约)· 分支 feat/chain-workflow · 锚点 ARCHITECTURE §5.2/§5.5、CLAUDE.md 架构不变量 #1/#3/#5/#9、DEV_PLAN §4

本文是实施的唯一契约源。所有实施 agent 先读本文,按其中签名/事件/DoD 落地;与现有 spec 冲突时以更具体者为准并回写。


1. 背景与目标

1.1 为什么做

对标竞品「星月写作」的核心能力是一键多章工作流(导语→大纲→细纲→一键十章正文循环),但其纯走量、无一致性闸,靠提示词补"关联前文/不跑偏"。本产品的差异化:同样的链式量产,但每一章都过 assemble → write → 四审 → digest 闭环 → 批量生成而长篇不崩。这是星月架构上做不到的杀手锏。

1.2 为什么用 LangGraph而非端点+DB 循环)

多章循环是本项目第一个真正需要 LangGraph 的场景:单章流程是线性的(写一发、审一次 ainvoke、验收一个事务),无需图;而"写→审→判定→循环/中断、跨小时可恢复、遇冲突暂停交人"是有环、可持久、可中断的编排 = LangGraph 本职。本设计借此激活当前休眠的资产checkpointer(现恒 None)、interrupt()(现未实现)、build_write_graph(现死代码——将被本链图取代/吸收)。

1.3 目标DoD

  1. 一条内置链 draft_volume:从指定起始章,循环写 K 章,每章 write → 四审 → 判定 → 验收digest 入库。
  2. 杀手锏 interrupt-on-conflict:链自动跑;某章四审报出冲突 → interrupt() 暂停 → 前端取该章冲突交人裁决 → resume 续跑。无冲突章自动 accept。
  3. 跑在 run_job 长任务壳里:POST .../chains/{key}/run → 202 {job_id}GET /jobs/{id} 看进度("3/10 章已过审"POST .../chains/runs/{job_id}/resume 带裁决续跑。
  4. 后端门禁绿ruff/format/mypy/alembic 无漂移/pytest+ E2Emock 网关零 token走通无冲突全自动两章 + 冲突→interrupt→resume 两路径。

1.4 非目标(本期不做)

  • 前端 UI(链发起页/进度/裁决续跑面板)→ 契约稳定后单独一刀(@frontend follow-up
  • 可编辑/可分享的 UGC 链市场(星月式)→ 需多租户/Auth已 deferred单独立项。
  • 续写/扩写 节点 → 可作为链的可选步后补,本期链步固定为 write。
  • 多种 HITL 档位 → 本期只做 auto(无冲突自动过、有冲突才停)。

2. 架构总览

POST .../chains/draft_volume/run {start_chapter_no, count}
        │  (写 jobs 行 status=queued, kind="chain") → 202 {job_id}
        ▼  BackgroundTasks → run_chain_job(job_id, resume=None)
┌──────────────────────── LangGraph ChainGraph (thread_id = job_id) ─────────────────────┐
│  START → write_chapter → review_chapter → decide ──(无冲突)──→ accept_chapter ─┐        │
│            ▲                                  │                                 │        │
│            │                              (有冲突)                              │        │
│            │                                  ▼                                 │        │
│            │                            interrupt() ⏸  (job=awaiting_input)     │        │
│            │                                                                    │        │
│            └────────────────(还有章: current<=last)──── advance ◄──────────────┘        │
│                                                          │                              │
│                                                     (写满 K 章) → END                    │
└────────────────────────────────────────────────────────────────────────────────────────┘
        │ checkpointer (Postgres runtime / MemorySaver tests) 持久控制流位置 + pending handle
        ▼
POST .../chains/runs/{job_id}/resume {decisions:[...]}
        │  (Command(resume=decisions)) → 202 → run_chain_job(job_id, resume=decisions)
        ▼  从 interrupt 续跑accept_chapter 用 decisions → advance → 下一章…

关键纪律:节点做 DB IO 时各自用短事务、独立 session(每章 accept 章原子提交,仿 run_overdue_scan 自建 session。checkpointer 只存控制流current_chapter_no / written / pending),章正文落 chapters、审稿落 chapter_reviews——resume 时重读领域表为真相(不变量 #5


3. 状态与节点

3.1 State 类型 ChainState(新增 orchestrator/chain/state.py

TypedDictlanggraph 状态 = 检查点内容,只放控制流,不放正文/上下文

字段 类型 说明
project_id uuid.UUID
user_id uuid.UUID STUB_OWNER_ID
chain_key str 固定 "draft_volume"
start_chapter_no int 起始章
last_chapter_no int = start + count - 1
current_chapter_no int 当前处理章;初值 = start
written list[int] 本 run 已验收章号(进度来源)
has_conflicts bool decide 节点写入;控制流标志,不存冲突明文

草稿正文/上下文不进 state落 DB。冲突明文不进 stateresume 时从 chapter_reviews 重读,不变量 #5

3.2 节点(orchestrator/chain/nodes.pygateway/session_factory 经闭包绑入工厂)

  1. write_chapter:新建读写 session → assemble(project_id, current_chapter_no) 组装记忆(确定性,不变量 #6/#9硬规则+指纹进缓存前缀)→ gateway.run(收集版写章,复用 write_node 同一 prompt 组装路径,非 SSEchapter_repo.save_draftchapters draft → commit。State 不变(正文在 DB
  2. review_chapter:复用四审。两种实现取其一(实施者选更省的):(a) 内联调用 build_review_graph(gateway, review_repo).ainvoke(review_state)(b) 直接 run_review × REVIEW_SPECS + collect_reviews。落 chapter_reviews 留痕(不变量 #3 只读,不写正文)→ commit。
  3. decide(确定性纯逻辑,无 LLM读该章最近审稿留痕 → has_conflicts = len(conflicts) > 0。写入 state。
  4. 条件边 decide → ?
    • has_conflicts == Trueinterrupt({chapter_no})interrupt() 暂停,恢复值 = 该章 decisions 列表。恢复后进 accept_chapter
    • has_conflicts == False → 直接进 accept_chapterdecisions = [])。
  5. accept_chapter:新建 session → 从 chapter_reviews 重读该章最近审稿(不信 stateassert_conflicts_resolved(latest_review, decisions)(缺判 → CONFLICT_UNRESOLVED,链失败)→ 事务外 extract_digest_facts(从终稿)→ run_accept_transaction(...) 章原子提交 → 提交后 run_overdue_scan(自建 session伏笔到期written.append(current_chapter_no)
  6. 条件边 accept_chapter → ?current_chapter_no < last_chapter_noadvancecurrent_chapter_no += 1)→ 回 write_chapter;否则 → END

advance 可并入 accept_chapter 的返回(current_chapter_no+1+ 一个 should_continue 条件边判 START/END避免独立节点。实施者择简。

3.3 图工厂 build_chain_graphorchestrator/chain/graph.py)— 契约

def build_chain_graph(
    gateway: GatewayRun,                      # 复用 ._protocols.GatewayRun
    *,
    session_factory: SessionFactory,          # 节点自建短事务用services 注入)
    review_repo_factory,                      # session -> ReviewRecorder
    checkpointer: BaseCheckpointSaver | None = None,  # 运行时 Postgres测试 MemorySaver
) -> CompiledStateGraph[ChainState, None, ChainState, ChainState]: ...
  • 节点 DB 依赖经 session_factory + *_repo_factory 闭包注入(与现有"gateway 闭包绑定"同模式langgraph 不收 functools.partial,用默认参绑定,见 gotcha
  • 删除/吸收 build_write_graph:本链图取代它;write_node 函数本体保留(被 write_chapter 节点复用),删掉无人调用的 build_write_graph 工厂YAGNI解决审计发现的死代码

4. checkpointer 接线(激活休眠资产)

  • 运行时AsyncPostgresSaverlanggraph.checkpoint.postgres.aio)。新增注入缝 get_checkpointer()services/chain_deps.py)→ 运行时返 Postgres saverDATABASE_URL),测试注 MemorySaver
  • 建表setup_checkpointer(conn_string)(已存在 orchestrator/graph.py:106,迁移本链图后移到 orchestrator/chain/graph.py 或保留)。在 alembic 迁移/CI 跑一次建 langgraph 检查点表不变量DDL 不在 app-runtime。新增迁移 xxxx_langgraph_checkpoint_setupopawait setup_checkpointer(sync_or_async_conn)(实施者按本仓 alembic async 模式处理;若 alembic 环境不便跑 async saver.setup则在 CI bootstrap 步骤调用并在迁移注释说明)。
  • thread_id = str(job_id)config={"configurable": {"thread_id": str(job_id)}}。初始 run graph.ainvoke(initial, config)resume graph.ainvoke(Command(resume=decisions), config)
  • 测试E2E 用单个进程内 MemorySaver 实例横跨"初始 run"与"resume run"两次模拟调用(同进程同 saver故 interrupt 可恢复);绝不连真 Postgres、绝不真 LLM。

5. 服务层(apps/api/ww_api/services/chain_runner.py

async def run_chain_job(
    session_factory, job_id, *, project_id, chain_key, start_chapter_no, count,
    gateway, checkpointer, resume_decisions=None, request_id=None,
) -> None:
    # 复用 run_job 的生命周期壳set_running → work → complete/fail。
    # work: 构 build_chain_graph(...) → config{thread_id=job_id}
    #   - resume_decisions is None → graph.ainvoke(initial_state, config)
    #   - else → graph.ainvoke(Command(resume=resume_decisions), config)
    # 返回值/进度: 从图最终 state 取 {written, last_chapter_no} → job.result
    #   {"chain_key","written":[...],"completed":bool,"awaiting_chapter":int|None}
    # interrupt 命中 → graph 返回带 __interrupt__ → job 置 status="awaiting_input"
    #   (jobs 复用现有状态机 + 新增/复用一个 awaiting 态,见 §7 数据)
  • 进度:每验收一章可经 job_repo.set_progress(若有)更新 progress;否则 result 里 written 长度即进度。
  • 错误脱敏:复用 run_job_classify_job_errorAppError 透传安全文案,其余通用文案,不泄 str(exc)P0-3
  • token 不泄漏job result/status/日志绝不含 prompt/正文/token只记章号、长度、计数

6. API 契约(@backend改 OpenAPI → 记 contracts + 前端 follow-up pnpm gen:api

端点 方法 请求 响应 说明
/projects/{pid}/chains/{chain_key}/run POST ChainRunRequest{start_chapter_no:int>=1, count:int 1..50} 202 ChainRunAccepted{job_id, chain_key, start_chapter_no, count} 写 jobs(queued,kind=chain) + BackgroundTask run_chain_job
/jobs/{job_id} GET 现有 JobView + result{chain_key,written[],completed,awaiting_chapter} 复用现有 jobs 端点;轮询进度/awaiting
/projects/{pid}/chains/runs/{job_id}/resume POST ChainResumeRequest{decisions: list[ConflictDecision]} 202 ChainRunAccepted 仅当 job=awaiting_inputBackgroundTask resume
  • 校验系统边界fail fastcount 1..50>50 → 422 VALIDATIONstart_chapter_no >= 1chain_key 未知 → 404resume 非 awaiting 态 → 409。
  • ConflictDecision 复用 schemas/projects.py 既有(conflict_index/verdict/note)。
  • schemas 新增 schemas/chain.pyChainRunRequest/ChainRunAccepted/ChainResumeRequest

7. 数据模型

  • 无新业务表。章正文 → chapters;审稿 → chapter_reviewsdigest → chapter_digests;运行 → jobs
  • jobs:复用。新增 kind"chain"(若 kind 列已存在则直接用;若无则本设计允许一个最小迁移加 jobs.kind TEXT + jobs.status 增态 awaiting_input——实施者先查现表,能复用就零迁移)。
  • langgraph 检查点表:由 setup_checkpointer 在迁移/CI 建§4。这是唯一新增 DDL。

实施者 MUST 先查 packages/db/ww_db/models.pyjobs 现有列(是否已有 kind/progress/result/能否表达 awaiting再决定是否需要迁移优先零迁移方案如用 result.awaiting_chapter 表达待裁决,status 复用现有枚举)。


8. 不变量符合性(逐条核对)

  • #1 DB 即单一真源:链节点只经领域表通信;节点间不互调。
  • #3 四审只读 + 验收 HITL 闸review_chapter 只写 chapter_reviews;唯一正文写经 accept_chapterrun_accept_transaction;有冲突必 interrupt() 交人,assert_conflicts_resolved 缺判即拦。
  • #4 digest 从终稿提炼accept_chapter 事务外 extract_digest_facts(final_text)
  • #5 checkpoint 只存控制流ChainState 只含章号/计数/标志;冲突明文 + 正文重读领域表。
  • #9 缓存前缀稳定write_chapter 复用 assemble 的 stable_core/volatile 切分,硬规则+指纹进前缀、无时间戳/UUID。
  • 节点确定性可测LLM 非确定性锁在 gateway 后;decide 是纯逻辑。

9. 任务拆分(多 agent契约先行

任务 skill 依赖 产出 DoD
C1 链图 + 节点 + 单测 @llm orchestrator/chain/{state,nodes,graph}.py + 删 build_write_graph + 单测mock gateway + fake session_factory + MemorySaver测无冲突全跑 + 有冲突 interrupt + resume→acceptdecide 纯逻辑测) packages/core+packages/agents ruff/format/mypy 干净 + pytest 绿;图可 ainvoke、可 interrupt、可 resume
C2 服务 + 端点 + schema + checkpointer 接线 + 单测 @backend C1 契约 services/{chain_runner,chain_deps}.py + routers/chain.py + schemas/chain.py + main 注册 + jobs 复用/最小迁移 apps/api 门禁绿3 端点入 OpenAPIrun→202、resume→202、未知 key 404、count>50 422、resume 非 awaiting 409token 不入 result/日志
C3 检查点建表迁移 @db/@devops C1 alembic 迁移调 setup_checkpointer(或 CI bootstrap+ jobs 最小迁移(如需) alembic upgrade head 应用、alembic check 无漂移
C4 E2E @qa C2 tests/test_chain_workflow_e2e.py:真 pg + mock 网关零 token① 两章无冲突全自动→chapters 两 accepted 版次 + chapter_digests 两行 + job done written=[n,n+1];② 注入冲突→第一章 interrupt→job awaiting→resume 带裁决→续跑→accept负向token 不入 job result/状态 E2E 绿DB 真源逐项断言

并行性C1 是契约源先行§3.3 签名 + §6 端点已在本文锁定,故 C2 可对照契约并行起,但建议 C1 先落地图签名。C3 依赖 C1用其 setup_checkpointer。C4 依赖 C2。


10. 测试计划(守 TDD + mock LLM

  • 单元C1节点函数直接调用不起图运行时write_chapterfake gateway 返固定文 + fake session 断言 save_draftdecide(构造有/无冲突的 review → 断 has_conflicts)、accept_chapterfake repos 断 promote/digest/decisions + 冲突缺判抛 CONFLICT_UNRESOLVED流程MemorySaver 跑全图断无冲突→END 且 written 满、有冲突→__interrupt__、resume→END。
  • 集成/E2EC4FastAPI 端点 + 真 pg + mock 网关interrupt+resume 在单进程单 MemorySaver(经依赖覆盖注入)内横跨两次端点调用。
  • 绝不:真 LLM、真 Postgres 检查点联网、把正文/token 写进断言外的日志。

11. 风险与回退

风险 缓解
alembic 跑 async saver.setup() 不便 退路CI/bootstrap 脚本调 setup_checkpointer迁移注释指明E2E 用 MemorySaver 不依赖该表
一条 run 内多章长事务/连接占用 节点各自短事务,不跨章共享 session图运行本身无 DB 事务,只 checkpointer 写
MemorySaver 跨请求不持久 仅测试用(同进程);运行时强制 Postgres saver文档标注
链中途崩溃 checkpointer 持久 → 可经 resume/重跑同 thread_id 续job 置 failed 带脱敏文案
与既有单章 draft/review/accept 端点重复逻辑 复用同一 run_accept_transaction/assemble/审稿 spec不复制链是编排新增、非业务重写

12. 文档回写(实施完成后)

  • ARCHITECTURE.md §5.2:补"多章链图cyclic + interrupt + Postgres checkpointer",并更正现状描述(写章直连流式、单章 accept 为事务、链图首次启用 checkpointer
  • memory/contracts.md:登记 C-Chain 契约(图签名 + 3 端点)。
  • memory/decisions.md:记"多章编排选 LangGraph 链图而非端点循环"的理由。
  • PROGRESS.md:拉入"Chain Workflow"阶段台账。