Files
reader/plans/article-summary-async-job-plan.md
T

20 KiB
Raw Blame History

单篇总结异步 job 最小版落地方案

1. 背景与问题定义

当前 reader 已经把日更 FreshRSS 主流程做成了带 run-state.json 的 runtime 模型:

  • src/summary_mcp/runtime/state_models.py
  • src/summary_mcp/runtime/run_store.py
  • src/summary_mcp/runtime/query_service.py
  • src/summary_mcp/workflows/freshrss_pipeline.py

这条主链路已经具备:

  • run / stage / artifact 的结构化状态
  • MCP 查询接口:get_run_status / list_runs / list_run_artifacts
  • 结果读取接口:get_delivery_payload / get_run_report

但“单篇总结”这条线目前还是同步调用:

  • 核心逻辑:src/summary_mcp/workflows/article_summary.py
  • MCP 暴露:src/summary_mcp/server.py 中的 generate_article_summaries
  • CLI 辅助:scripts/run_article_summaries.py

现状问题已经很明确:

  • 在 OpenClaw → MCP tool 这条链路里,generate_article_summaries 可能因为 tool 调用时长而 timeout
  • 但 reader 项目本体在 .venv 下直接跑 article summary,大约 37.5 秒即可成功
  • 这说明问题不一定在 summary 业务本身,而更可能在“同步工具调用 + 上层等待模型”这个包装层

所以目标不是先继续调 timeout,而是把单篇总结也纳入 真正异步、可轮询、可落盘、可恢复基本状态 的最小 job 模型里。


2. 为什么同步 MCP 不适合这一步

generate_article_summaries 当前在 server.py 里直接同步执行 summarize_selected_articles(...),调用方必须一直阻塞等待,直到:

  1. 读取 extracted payload
  2. 调用 LLM 生成总结
  3. 可选 repair retry
  4. 渲染 Markdown
  5. 写文件完成
  6. MCP tool 返回生成路径

这个模式对“几十秒级、依赖外部 LLM、可能重试”的任务不稳,核心问题有三层:

2.1 tool 调用时长不可控

run_loop_payload() 内部会发起外部 HTTP 请求,还可能做 repair retry。即便单次平均 37.5 秒,也已经接近很多上层编排系统的心理和技术超时边界。

2.2 调用方看不到中间状态

现在如果卡住,调用方只能等:

  • 不知道是在读输入
  • 不知道是在调 LLM
  • 不知道是在重试
  • 不知道是否已经写出部分结果

这也是同步接口最烦的点:失败时只能看到“tool timeout / tool failed”,而不是“业务跑到哪一步了”。

2.3 与 reader 已有 runtime 风格不一致

FreshRSS 主流程已经是“run truth + status query + artifact read”的思路,而单篇总结仍然是黑箱同步函数。继续维持两套风格,只会让 SOP 更复杂:

  • 日报主链路用 run_id
  • 单篇总结却要么同步等,要么退回 CLI fallback

这不利于后续把 reader-digest-flow 稳定成正式 SOP。


3. 本轮目标:最小真异步,不做大而全

这次只做 单篇总结异步 job 最小版,目标是:

让 OpenClaw 或其他调用方能先“启动单篇总结 job”,立即拿到 job_id,再通过状态接口轮询,最后读取输出文件/结果。

3.1 本轮必须做到的范围

  1. 新增单篇总结 job 的 start/status/result 最小接口
  2. job 真正在后台执行,而不是 MCP 请求线程里阻塞到完成
  3. 状态落盘到文件,遵循 reader 当前 runtime 风格
  4. 复用现有 summarize_selected_articles 逻辑,不重写业务
  5. 输出仍然是现有 Markdown 文件,不改知识内容 schema

3.2 本轮明确不做

  1. 不做通用队列系统
  2. 不做数据库
  3. 不做多 worker / 分布式调度
  4. 不做取消 job / kill job
  5. 不做并发配额控制
  6. 不做 resume/retry from stage
  7. 不把 article summary 一次性并入 freshrss resume_run 体系
  8. 不改 summary prompt / validator / 输出格式
  9. 不处理批量高吞吐场景优化

一句话:这轮只解“同步 tool 容易 timeout,但业务本身能跑完”这个问题,不顺手扩成任务调度平台。


4. 接入当前 reader 结构的建议

4.1 复用现有 runtime 设计,但单独建 article summary job 命名空间

不建议把 article summary job 粗暴塞进现有 freshrss_daily_digest run 查询里混用一个 schema;更合适的是:

  • 复用 RunState / StageState / ArtifactRecord / RunStore 这套思维
  • 但给单篇总结定义独立 workflow 名称与存储目录

建议:

  • workflow: article_summary_job
  • run_type: article_summary
  • output root: outputs/freshrss/article_summary_jobs/<job_id>/

这样有几个好处:

  • 不污染 outputs/freshrss/rerun/
  • 语义清楚:这是独立 job,不是假装自己是日报 rerun
  • 查询和排查时更直观

4.2 job 与结果 Markdown 解耦

job 目录只负责:

  • 状态文件
  • 输入快照
  • artifact 索引
  • 执行报告

真正生成的总结 Markdown,仍然可以写到用户指定的 output_dir(或默认 single_summaries/)。

这样不破坏当前下游 SOP:

  • reader-digest-flow 依然从原来的单篇总结输出目录拿 .md
  • job 目录只提供状态与索引,不强迫下游改结果路径约定

5. 新增工具 / API 设计

本轮建议新增 3 个 MCP tool,名字尽量和当前 runtime 风格一致。

5.1 start_article_summary_job

作用

启动一个后台 job,立即返回 job_id,不等待总结完成。

输入建议

{
  "extracted_path": "outputs/freshrss/rerun/<run_id>/extracted/item-01.extracted.json",
  "selected_ids": ["12345"],
  "output_dir": "outputs/freshrss/single_summaries/2026-04-10",
  "max_retries": 2,
  "timeout_seconds": 120,
  "llm_api_key": null,
  "llm_model": null,
  "llm_api_url": null
}

返回建议

{
  "job_id": "article-summary-20260410-144500-ab12cd34",
  "workflow": "article_summary_job",
  "run_type": "article_summary",
  "status": "running",
  "output_dir": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34",
  "message": "Article summary job started successfully. Use get_article_summary_job_status to poll progress."
}

约束建议

  • selected_ids 第一版允许多个,但建议由 OpenClaw 每次只传一篇或少量篇,避免一个 job 干太多事
  • extracted_path 必须存在,否则直接拒绝启动
  • output_dir 不传则按当前默认逻辑推导

5.2 get_article_summary_job_status

作用

查询 job 当前状态、阶段、进度、错误摘要、已注册 artifact。

输入

{
  "job_id": "article-summary-20260410-144500-ab12cd34"
}

返回建议

{
  "job_id": "article-summary-20260410-144500-ab12cd34",
  "workflow": "article_summary_job",
  "run_type": "article_summary",
  "status": "running",
  "current_stage": "generate_markdown",
  "started_at": "2026-04-10T14:45:00+08:00",
  "updated_at": "2026-04-10T14:45:23+08:00",
  "finished_at": null,
  "output_dir": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34",
  "progress": {
    "completed_stage_count": 2,
    "running_stage_count": 1,
    "failed_stage_count": 0,
    "pending_stage_count": 1,
    "total_stage_count": 4
  },
  "artifacts": [...],
  "error_summary": null
}

5.3 get_article_summary_job_result

作用

当 job 成功后,返回结构化结果,供 OpenClaw 继续下游 IMA 沉淀。

输入

{
  "job_id": "article-summary-20260410-144500-ab12cd34"
}

返回建议

{
  "job_id": "article-summary-20260410-144500-ab12cd34",
  "status": "success",
  "written_paths": [
    "outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
  ],
  "artifact": {
    "name": "job_result",
    "path": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34/result.json",
    "kind": "json",
    "stage": "write_result"
  },
  "result": {
    "selected_ids": ["12345"],
    "written_paths": [
      "outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
    ]
  }
}

行为建议

  • 若 job 还没完成,返回当前状态 + 提示“not ready”
  • 若 job 失败,返回失败摘要,不硬抛文件不存在异常

6. job 状态文件设计

建议直接复用现有 RunState 模型,不另造一套 schema。

job 目录示例:

outputs/freshrss/article_summary_jobs/
  article-summary-20260410-144500-ab12cd34/
    run-state.json
    input.json
    result.json
    job-report.json

6.1 run-state.json

建议直接沿用当前字段:

{
  "run_id": "article-summary-20260410-144500-ab12cd34",
  "workflow": "article_summary_job",
  "run_type": "article_summary",
  "status": "running",
  "current_stage": "generate_markdown",
  "started_at": "2026-04-10T14:45:00+08:00",
  "updated_at": "2026-04-10T14:45:23+08:00",
  "finished_at": null,
  "input": {
    "extracted_path": "outputs/freshrss/rerun/<run_id>/extracted/item-01.extracted.json",
    "selected_ids": ["12345"],
    "output_dir": "outputs/freshrss/single_summaries/2026-04-10",
    "max_retries": 2,
    "timeout_seconds": 120
  },
  "stages": [...],
  "artifacts": [...],
  "error": null,
  "recovery": {
    "resumable": false,
    "resume_from_stage": null,
    "last_success_stage": "load_input"
  }
}

第一版 stage 建议

建议只切 4 个 stage,够看即可:

  1. prepare_job

    • 校验输入
    • 解析路径
    • 写 input.json
  2. load_input

    • 读取 extracted payload
    • 确认 selected_ids 可匹配条目
  3. generate_markdown

    • 调 summarize_selected_articles(...)
    • 这是主要耗时阶段
  4. write_result

    • 写 result.json
    • 注册输出 artifact

这里不要把 LLM 调用再拆更多细 stage,否则最小版反而过度设计。


6.2 input.json

作用:保留启动请求快照,便于排查。

建议内容与 run-state.input 基本一致。


6.3 result.json

成功时写:

{
  "job_id": "article-summary-20260410-144500-ab12cd34",
  "selected_ids": ["12345"],
  "written_paths": [
    "outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
  ],
  "completed_at": "2026-04-10T14:45:41+08:00"
}

失败时可以不写,或只写失败快照都行。最小版建议:

  • 成功写 result.json
  • 失败只依赖 run-state.json

避免双份失败状态不一致。


7. 执行模型建议:优先子进程,不建议线程

7.1 推荐:子进程后台执行

最小真异步推荐模型:

  • start_article_summary_job 负责:
    • 创建 job 目录
    • 初始化 run-state.json
    • 通过 subprocess.Popen(...) 启动一个独立 Python 进程执行 job runner
    • 立即返回 job_id

后台 runner 再去:

  • 读取 input.json
  • 用 RunStore 更新状态
  • 调用 summarize_selected_articles(...)
  • 写 result.json
  • finish/fail run

7.2 为什么不推荐线程

虽然线程实现看起来更省事,但不适合作为 reader 的正式最小异步落地:

  1. MCP server 进程重启后线程直接丢失
  2. 线程状态不天然可恢复,容易出现“状态文件还在 running,但线程没了”
  3. 未来要做健康检查/孤儿 job 检测时,线程模型更难收口

7.3 为什么子进程更贴当前项目风格

reader 现在本来就偏“文件产物 + runtime 状态真相”风格。子进程模式有天然优势:

  • 和 CLI/fallback 思维一致
  • 进程边界清楚
  • run-state.json 由实际执行者写,职责清晰
  • 将来如果要做 orphan detection / stale running job 修复,也容易补

7.4 本轮不做进程管理增强

最小版里,不要求:

  • 记录 PID 后做 kill/cancel
  • 自动清理僵尸进程
  • 守护进程/worker 池

但建议在 input.json 或 run-state.input 里附带:

  • launcher_pid
  • runner_command

方便排障。


8. 与现有 summarize_selected_articles 的复用关系

核心原则:不重写总结业务,只包一层 job runner。

8.1 直接复用的部分

src/summary_mcp/workflows/article_summary.py 已经做了:

  • 读取 extracted payload
  • 根据 selected_ids 找条目
  • 调用 run_loop_payload(...)
  • 渲染 Markdown
  • 写到 output_dir
  • 返回 list[Path]

这些都继续用。

8.2 最小新增建议

建议只新增一层 runtime/service,例如:

  • src/summary_mcp/runtime/article_summary_jobs.py

职责:

  • 生成 job_id
  • 创建 job 目录
  • 初始化 RunStore
  • 启动 runner 子进程
  • 提供 status/result 查询
  • 在 runner 里调用 summarize_selected_articles

8.3 是否需要改 summarize_selected_articles

最小版尽量少改,只建议加两类低风险增强:

  1. 可选输入校验增强

    • 如果 selected_ids 一个都匹配不到,显式报错
    • 避免“成功返回空列表”却让 job 看起来像成功
  2. 可选 hook / telemetry(非必须)

    • 如果后面需要更细粒度写 stage 输出,可再加
    • 但第一版没必要为了观测性重构函数

结论:

  • 第一版优先保持 summarize_selected_articles 基本不动
  • job 层只把它作为黑盒业务函数调用

9. 建议新增代码组织

建议新增文件:

src/summary_mcp/runtime/article_summary_jobs.py
scripts/run_article_summary_job.py

9.1 article_summary_jobs.py

建议包含:

  • start_article_summary_job(...)
  • run_article_summary_job(...)
  • get_article_summary_job_status(...)
  • get_article_summary_job_result(...)
  • 若干私有 helper:
    • job id 生成
    • job dir 解析
    • result artifact 读取

9.2 scripts/run_article_summary_job.py

作用:作为子进程 runner 入口。

例如:

python scripts/run_article_summary_job.py --job-id article-summary-... 

runner 只做一件事:

  • 根据 job_id 找到 job 目录和 input.json
  • 真正执行 job

这样避免在 Popen("python -c ...") 里塞长字符串,也方便本地调试。


10. 对 reader-digest-flow SOP 的影响

这块是重点,因为老大的实际痛点就在这。

10.1 现状 SOP

当前 skill 已经有硬规则:

  • 优先走 MCP generate_article_summaries
  • MCP timeout 时,立刻 fallback 到 reader 本地 .venv

这个 fallback 现在是必要的,但它本质是在补“单篇总结没有正式异步接口”。

10.2 引入异步 job 后的推荐 SOP

建议调整为:

  1. OpenClaw 在用户确认保留文章后
  2. 调 start_article_summary_job
  3. 拿到 job_id
  4. 轮询 get_article_summary_job_status
  5. 成功后调 get_article_summary_job_result
  6. 再继续 IMA 格式化与上传

10.3 对 skill 文档的影响

reader-digest-flow 需要后续补一条新规则:

  • 单篇总结的正式生产路径从“同步 MCP + 本地 fallback”升级为“异步 job + 状态轮询”
  • 本地 .venv CLI fallback 仍保留,但降级为:
    • job 启动失败
    • job runner 异常
    • reader 服务端出现系统性问题时的应急路径

10.4 用户体验改善

异步 job 后,OpenClaw 可给出更像正式系统的反馈:

  • “已启动单篇总结任务,正在生成”
  • “当前状态:generate_markdown”
  • “已完成,准备继续沉淀到 IMA”

而不是现在的:

  • 直接卡住几十秒
  • 然后 tool timeout
  • 再走一套 fallback

11. 验证方案

本轮验证不要追求大而全,按 4 层做就够。

11.1 单元级验证

目标:确保 job 状态文件和结果文件行为正确。

建议覆盖:

  1. start_article_summary_job 能创建 job 目录与 run-state.json
  2. 输入路径不存在时,启动直接失败
  3. get_article_summary_job_status 能正确读取状态
  4. job 成功后 get_article_summary_job_result 返回 written_paths
  5. job 失败后状态为 failed,并带错误摘要

11.2 本地集成验证

用一个真实 extracted 文件跑:

  1. 启动 job
  2. 轮询 status
  3. 成功后检查:
    • result.json 存在
    • Markdown 文件存在
    • 路径正确

11.3 OpenClaw 链路验证

在实际 reader-digest-flow 环节,用一篇已确认保留的文章做:

  1. 启动 async job
  2. 等待成功
  3. 继续做 IMA markdown 整理与上传
  4. 确认整个链路不再因为同步 tool timeout 中断

11.4 异常验证

至少测 3 类异常:

  1. selected_ids 不存在
  2. LLM 接口失败 / 超时
  3. runner 进程异常退出

预期:

  • run-state.json 最终为 failed
  • error_summary 可读
  • 调用方能明确知道失败,而不是只看到 transport timeout

12. 风险与回滚方案

12.1 风险

风险 1:后台子进程成功启动,但状态长期卡在 running

常见原因:

  • runner 进程崩了
  • server 重启时某些路径没写全
  • 子进程命令不对

缓解:

  • runner 启动前就先写 run-state.json
  • runner 一进来先更新 prepare_job / load_input
  • 后续可加“stale running 超时判定”,但第一版先不做自动修复

风险 2:复用旧函数导致“空输出也算成功”

当前 summarize_selected_articles() 如果没匹配到文章或全部失败,存在返回空列表的可能。

缓解:

  • job runner 里把“written_paths 为空”视为失败
  • 或者顺手在 summarize_selected_articles 里补显式校验

风险 3:同一时刻大量 job 并发,LLM 调用被打爆

第一版不解决系统级并发控制。

缓解:

  • SOP 层先按单篇/少量串行用
  • skill 层避免一口气启动很多 job

风险 4:状态目录与结果目录分离,排查时容易迷路

缓解:

  • 在 result.json 和 artifact 元数据里明确记录 written_paths
  • 在 run-state.input.output_dir 中保留结果目录

12.2 回滚方案

这个方案很好回滚,因为是“新增,不替换”。

回滚原则

  • 保留现有 generate_article_summaries
  • 保留现有 scripts/run_article_summaries.py
  • 新增 async job 接口如果不稳定,直接停止在 OpenClaw 层使用即可

回滚路径

  1. 停止调用 start_article_summary_job
  2. 恢复回原 SOP:
    • 先尝试同步 MCP generate_article_summaries
    • 失败则本地 .venv fallback
  3. async job 相关代码保留但不作为正式入口

也就是说,这轮改动不会堵死当前生产路径,风险可控。


13. 实施顺序(建议按这个顺序落地)

Phase A:先把方案落地成最小代码骨架

  1. 新增 src/summary_mcp/runtime/article_summary_jobs.py
  2. 新增 job 目录常量与 helper
  3. 新增 scripts/run_article_summary_job.py
  4. 实现 runner 内部对 summarize_selected_articles 的调用
  5. 先本地命令验证 job 可跑通

Phase B:再把 MCP 接口接上

  1. 在 server.py 新增:
    • start_article_summary_job
    • get_article_summary_job_status
    • get_article_summary_job_result
  2. 本地 MCP 调用验证

Phase C:最后接 OpenClaw SOP

  1. 更新 reader-digest-flow skill,把正式路径切到 async job
  2. 保留本地 .venv fallback 作为应急方案
  3. 跑一次真实日报保留文章沉淀闭环

14. 推荐的最小返回/状态语义

为了跟现有 runtime 风格一致,建议继续用:

  • status: running / success / failed
  • current_stage: 当前 stage 名
  • artifacts: 注册产物列表
  • error_summary: 结构化错误

第一版不必引入:

  • queued
  • cancelled
  • retrying
  • paused

避免状态机一开始就复杂化。


15. 结论 / 拍板建议

拍板建议很直接:

  1. 这件事值得做,而且优先级高,因为它正好卡在当前日报 SOP 的真实痛点上
  2. 不建议继续先修同步 timeout,因为同步模型本身就不适合几十秒级、外部 LLM 驱动的任务
  3. 最小真异步应采用“文件状态 + 后台子进程 + start/status/result 三接口”
  4. 业务层严格复用 summarize_selected_articles,不要为异步化重写 summary 核心逻辑
  5. 先把单篇总结 job 做成独立小 runtime 命名空间,不要急着并入 freshrss 主 run 的 resume 体系

如果只允许做一轮最小落地,我建议就做到:

  • start_article_summary_job
  • get_article_summary_job_status
  • get_article_summary_job_result
  • outputs/freshrss/article_summary_jobs/<job_id>/run-state.json
  • 子进程 runner

这套已经足够把当前 OpenClaw timeout 问题从“同步等待”改成“正式异步轮询”,并且几乎不碰无关模块。