deerflow-code/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md
2026-09-07 18:24:55 +08:00

10 KiB
Raw Blame History

后台挂起「在当前进度继续」实现方案

状态:待实现(仅方案,未改代码) 关联:multi-agent-background-run-dev.md、multi-agent-frontend-dev.md、memory project-roundtable-background 目标:点「后台挂起」后,后端作业在前端当前研讨进度上接着跑,而不是新建空线程从头重跑。


1. 现象与根因

现象

在第二步研讨进行中点「后台挂起」,转入后台的作业是从头重新开始执行(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。

根因

后端「起作业」路由 start_job 从不设 is_resume(默认 False),而执行器据此新建一套全新线程:

# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py  (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
    thread_ids, coordinator_name = await init()   # ← 建全新空线程

加上前端 startBackgroundJob 故意不传 threadIds、用 buildInitialIntent() 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。

这是原设计的有意取舍。startBackgroundJob 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。

后端其实已支持续跑

澄清续跑路由 resume_job(roundtable_jobs.py 约 293 行)已经在做「在原线程上接着派活」:

executor.start_job(StartParams(
    ...,
    seed_message=_RESUME_PREFIX + answer,        # 续跑式提示,而非原始 intent
    thread_ids=row.get("thread_ids"),            # 复用作业自己的线程
    coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
    is_resume=True,                              # ← 关键:跳过 init_threads,直接复用
    ...
))

执行器在 is_resume=True 时直接用 params.thread_ids + params.coordinator_name(不调 init_threads),交给 run_orchestration 续跑。所以能力已存在,start_job 只是没走这条路。


2. 设计目标与关键事实

  • 「同进度续跑」= 后台作业复用前端当前会话的真实线程(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
  • 前端编排是逐轮的:每轮一个 POST /api/multi-agent/run/stream(leader 或某席位),轮与轮之间线程空闲。只有「某一轮在飞时」线程才被占用。
  • 后端复用现成线程是已验证路径(resume_job 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。

3. 改造点(最小集)

3.1 后端:让 start_job 支持复用线程

文件:offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py

  1. StartJobRequest 增加字段:
    resume: bool = False  # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建
    
  2. start_job 里把它透传给 StartParams:
    is_resume = bool(body.resume and body.threadIds)
    executor.start_job(StartParams(
        ...,
        thread_ids=body.threadIds,
        coordinator_name=body.coordinatorName,
        is_resume=is_resume,        # ← 新增
        ...
    ))
    
    其余执行器逻辑不动(它在 is_resume=True 时已能复用 params.thread_ids)。

幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。

3.2 前端 API:补 resume 入参

文件:frontend-web/src/roundtable-planning/lib/job-types.ts

  • StartJobPayload 增加 resume?: boolean。

文件:frontend-web/src/roundtable-planning/api/roundtable-jobs.ts

  • toStartBody 增加 resume: p.resume ?? false。

3.3 前端:startBackgroundJob 改为复用线程续跑

文件:frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx(startBackgroundJob 约 1119 行)

当前端已有现成会话(step2.threadIds 且 step2.coordinatorName 非空,说明研讨已 init)时,改传:

const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
  draftId: draftId ?? undefined,
  // 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
  intent: buildInitialIntent(),                 // 仍传,作为无线程回退时的首轮 prompt
  seedMessage: canContinue
    ? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
    : undefined,
  agents,
  model: selectedModel || undefined,
  orchestrationMode,
  chain: chainMeta,
  orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
  // 关键三件套:复用前端真实线程 + 协调名 + resume 标志
  threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
  coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
  resume: canContinue,
});

没有现成会话(极早期、线程未建)时 canContinue=false,自动回退到「新建线程 + 原 intent」的旧行为,安全。

3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)

文件:同上,handleBackgroundSuspend(约 1161 行)

当前顺序是 startBackgroundJob() → resetToStep1()(reset 里才 stopOrchestration)。复用线程时必须先停再交:

const handleBackgroundSuspend = useCallback(async () => {
  if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
  // 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
  step2.stopOrchestration();
  step2.setIsRoundTableRunning(false);
  try {
    // 2. 再起后台作业(复用线程续跑)
    await startBackgroundJob();
  } catch (err) { /* toast 失败后 return */ }
  // 3. 最后重置回第一步(后台作业继续在历史记录里跑)
  resetToStep1();
  triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
  void drafts.refresh();
}, [...]);

4. 关键风险:线程占用(409)与三种处理

复用前端线程的唯一风险:在某一轮在飞时挂起,stopOrchestration() 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。

方案 做法 取舍
A(推荐起步) 仅前端 stop + 后端 is_resume 复用,接受小概率 409 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环现有 rollback/重试容错兜底
B(最稳) 同 A,但后端在复用线程续跑前,先 cancel 这些线程上可能残留的 run(参考 takeover 里 cancelJob 的 task.cancel() 思路,落到 thread-run 级别)再 post 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑
C(不推荐) 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 零 409,但席位子线程上下文全丢、不是真正同进度、leader prompt 臃肿

建议:先上 A,真机验证 409 命中率;若频繁再加 B 的后端线程级 cancel。


5. 适用范围(哪些挂起入口改续跑)

三个触发源(见 autoSuspendRef):

  1. 手动「后台挂起」按钮(handleBackgroundSuspend)——改续跑。能在 start 前干净 stop,最适合复用线程。
  2. 路由卸载自动挂起(切到其它页面,fire)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。
  3. 刷新/关标签页 keepalive(beforeunload 的 beacon → startJobKeepalive)——建议保持新建线程。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。

落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。


6. 与已实现「查看=接管回前端」的关系

二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:

  • 后台挂起(本方案):前端 → 后端。前端 stop,把前端线程交给后台 is_resume 续跑。
  • 查看接管(已实现,见 memory):后端 → 前端。cancelJob 停后台,hydrateFromDraft 恢复作业真实线程,resumeLiveTakeover() 由前端在那些线程上续跑。

实现本方案时可直接参照 takeover 的线程复用与 is_resume 经验。


7. 测试

后端(make test):

  • 新增/扩展 tests/test_roundtable_jobs.py:start_job 带 resume=true + threadIds ⇒ StartParams.is_resume=True 且不调 init_threads、直接用传入线程;resume=false 或无 threadIds ⇒ 走 init_threads 新建(回归旧行为)。
  • 回归:tests/test_roundtable_inprocess_request.py、tests/test_roundtable_step3_pipeline.py(确认续跑不破坏 Step3 双产物)。

前端(pnpm typecheck)+ 真机:

  • 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是接着已有对话往下派活,而非从第 1 轮重来。
  • 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。

8. 落地清单(TL;DR)

  • 后端 StartJobRequest.resume 字段 + start_job 透传 is_resume
  • 前端 StartJobPayload.resume + toStartBody
  • 前端 startBackgroundJob:有会话时传 threadIds/coordinatorName/seedMessage/resume
  • 前端 handleBackgroundSuspend:调整为 先 stop 再 start 再 reset
  • 后端测试(resume 复用 vs 新建分支)
  • 真机验证「接着进度跑」+ 409 命中率
  • (可选)方案 B:后端复用线程前按 thread_id 取消残留 run