20 KiB
单篇总结异步 job 最小版落地方案
1. 背景与问题定义
当前 reader 已经把日更 FreshRSS 主流程做成了带 run-state.json 的 runtime 模型:
src/summary_mcp/runtime/state_models.pysrc/summary_mcp/runtime/run_store.pysrc/summary_mcp/runtime/query_service.pysrc/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(...),调用方必须一直阻塞等待,直到:
- 读取 extracted payload
- 调用 LLM 生成总结
- 可选 repair retry
- 渲染 Markdown
- 写文件完成
- 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 本轮必须做到的范围
- 新增单篇总结 job 的 start/status/result 最小接口
- job 真正在后台执行,而不是 MCP 请求线程里阻塞到完成
- 状态落盘到文件,遵循 reader 当前 runtime 风格
- 复用现有
summarize_selected_articles逻辑,不重写业务 - 输出仍然是现有 Markdown 文件,不改知识内容 schema
3.2 本轮明确不做
- 不做通用队列系统
- 不做数据库
- 不做多 worker / 分布式调度
- 不做取消 job / kill job
- 不做并发配额控制
- 不做 resume/retry from stage
- 不把 article summary 一次性并入 freshrss
resume_run体系 - 不改 summary prompt / validator / 输出格式
- 不处理批量高吞吐场景优化
一句话:这轮只解“同步 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,够看即可:
-
prepare_job- 校验输入
- 解析路径
- 写
input.json
-
load_input- 读取 extracted payload
- 确认
selected_ids可匹配条目
-
generate_markdown- 调
summarize_selected_articles(...) - 这是主要耗时阶段
- 调
-
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 的正式最小异步落地:
- MCP server 进程重启后线程直接丢失
- 线程状态不天然可恢复,容易出现“状态文件还在 running,但线程没了”
- 未来要做健康检查/孤儿 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_pidrunner_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
最小版尽量少改,只建议加两类低风险增强:
-
可选输入校验增强
- 如果
selected_ids一个都匹配不到,显式报错 - 避免“成功返回空列表”却让 job 看起来像成功
- 如果
-
可选 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
建议调整为:
- OpenClaw 在用户确认保留文章后
- 调
start_article_summary_job - 拿到
job_id - 轮询
get_article_summary_job_status - 成功后调
get_article_summary_job_result - 再继续 IMA 格式化与上传
10.3 对 skill 文档的影响
reader-digest-flow 需要后续补一条新规则:
- 单篇总结的正式生产路径从“同步 MCP + 本地 fallback”升级为“异步 job + 状态轮询”
- 本地
.venvCLI fallback 仍保留,但降级为:- job 启动失败
- job runner 异常
- reader 服务端出现系统性问题时的应急路径
10.4 用户体验改善
异步 job 后,OpenClaw 可给出更像正式系统的反馈:
- “已启动单篇总结任务,正在生成”
- “当前状态:generate_markdown”
- “已完成,准备继续沉淀到 IMA”
而不是现在的:
- 直接卡住几十秒
- 然后 tool timeout
- 再走一套 fallback
11. 验证方案
本轮验证不要追求大而全,按 4 层做就够。
11.1 单元级验证
目标:确保 job 状态文件和结果文件行为正确。
建议覆盖:
start_article_summary_job能创建 job 目录与run-state.json- 输入路径不存在时,启动直接失败
get_article_summary_job_status能正确读取状态- job 成功后
get_article_summary_job_result返回written_paths - job 失败后状态为
failed,并带错误摘要
11.2 本地集成验证
用一个真实 extracted 文件跑:
- 启动 job
- 轮询 status
- 成功后检查:
result.json存在- Markdown 文件存在
- 路径正确
11.3 OpenClaw 链路验证
在实际 reader-digest-flow 环节,用一篇已确认保留的文章做:
- 启动 async job
- 等待成功
- 继续做 IMA markdown 整理与上传
- 确认整个链路不再因为同步 tool timeout 中断
11.4 异常验证
至少测 3 类异常:
selected_ids不存在- LLM 接口失败 / 超时
- runner 进程异常退出
预期:
run-state.json最终为failederror_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 层使用即可
回滚路径
- 停止调用
start_article_summary_job - 恢复回原 SOP:
- 先尝试同步 MCP
generate_article_summaries - 失败则本地
.venvfallback
- 先尝试同步 MCP
- async job 相关代码保留但不作为正式入口
也就是说,这轮改动不会堵死当前生产路径,风险可控。
13. 实施顺序(建议按这个顺序落地)
Phase A:先把方案落地成最小代码骨架
- 新增
src/summary_mcp/runtime/article_summary_jobs.py - 新增 job 目录常量与 helper
- 新增
scripts/run_article_summary_job.py - 实现 runner 内部对
summarize_selected_articles的调用 - 先本地命令验证 job 可跑通
Phase B:再把 MCP 接口接上
- 在
server.py新增:start_article_summary_jobget_article_summary_job_statusget_article_summary_job_result
- 本地 MCP 调用验证
Phase C:最后接 OpenClaw SOP
- 更新
reader-digest-flowskill,把正式路径切到 async job - 保留本地
.venvfallback 作为应急方案 - 跑一次真实日报保留文章沉淀闭环
14. 推荐的最小返回/状态语义
为了跟现有 runtime 风格一致,建议继续用:
status:running/success/failedcurrent_stage: 当前 stage 名artifacts: 注册产物列表error_summary: 结构化错误
第一版不必引入:
queuedcancelledretryingpaused
避免状态机一开始就复杂化。
15. 结论 / 拍板建议
拍板建议很直接:
- 这件事值得做,而且优先级高,因为它正好卡在当前日报 SOP 的真实痛点上
- 不建议继续先修同步 timeout,因为同步模型本身就不适合几十秒级、外部 LLM 驱动的任务
- 最小真异步应采用“文件状态 + 后台子进程 + start/status/result 三接口”
- 业务层严格复用
summarize_selected_articles,不要为异步化重写 summary 核心逻辑 - 先把单篇总结 job 做成独立小 runtime 命名空间,不要急着并入 freshrss 主 run 的 resume 体系
如果只允许做一轮最小落地,我建议就做到:
start_article_summary_jobget_article_summary_job_statusget_article_summary_job_resultoutputs/freshrss/article_summary_jobs/<job_id>/run-state.json- 子进程 runner
这套已经足够把当前 OpenClaw timeout 问题从“同步等待”改成“正式异步轮询”,并且几乎不碰无关模块。