# 单篇总结异步 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//` 这样有几个好处: - 不污染 `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`,不等待总结完成。 ### 输入建议 ```json { "extracted_path": "outputs/freshrss/rerun//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 } ``` ### 返回建议 ```json { "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。 ### 输入 ```json { "job_id": "article-summary-20260410-144500-ab12cd34" } ``` ### 返回建议 ```json { "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 沉淀。 ### 输入 ```json { "job_id": "article-summary-20260410-144500-ab12cd34" } ``` ### 返回建议 ```json { "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 目录示例: ```text 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` 建议直接沿用当前字段: ```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//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` 成功时写: ```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. 建议新增代码组织 建议新增文件: ```text 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 入口。 例如: ```bash 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 接口接上 6. 在 `server.py` 新增: - `start_article_summary_job` - `get_article_summary_job_status` - `get_article_summary_job_result` 7. 本地 MCP 调用验证 ### Phase C:最后接 OpenClaw SOP 8. 更新 `reader-digest-flow` skill,把正式路径切到 async job 9. 保留本地 `.venv` fallback 作为应急方案 10. 跑一次真实日报保留文章沉淀闭环 --- ## 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//run-state.json` - 子进程 runner 这套已经足够把当前 OpenClaw timeout 问题从“同步等待”改成“正式异步轮询”,并且几乎不碰无关模块。