【学习笔记】同一个问题的两种解法:rss-to-daily-report 与 Infinitum 深度对比
最近接连深挖了两个开源项目,恰好都在「RSS → AI 日报」这条赛道上:一个是 mathitme/rss-to-daily-report,一个是 shawnxie94/Infinitum。两边的分析方法一致——多路子代理并行通读源码、独立复核员逐条对照源码核实断言与引文——所以文中的行号和数字都是复核过的,不是印象流。
先把反差立起来:前者 2,900 行 Python、4 个依赖、3 次提交;后者 306 个源文件、69,186 行 TypeScript、102 个测试文件 878 个用例。同一个问题,代码量差了 20 多倍。 这个差距本身就是本文最值得咀嚼的东西——不是比谁好,而是看两条路线各自把复杂度放在了哪里,又在哪些关键决策上殊途同归。
rss-to-daily-report:把聚合外包给 Miniflux 的 2,900 行
它是怎么跑的
整条链路由三个 cron 任务驱动:
- 每 30 分钟:
export_miniflux.sh调 Miniflux REST API(requests + X-Auth-Token)分页拉取 unread 条目(page_size=500),交给 Rust 的html-to-markdownCLI 转 Markdown,按data/md/YYYY/MM/DD/落盘;处理完(含失败/重复)标已读,没处理的保持 unread 由下一轮补齐——增量游标完全外包给 Miniflux,本地零状态、断网自愈。 - 08:30 综合日报:
generate_digest.sh扫描 36 小时锚点窗口内的语料 → light 模型(免费档 Qwen3-8B,temperature 0.1 + json_object,只送 title+source)做重要性筛选 → 七类归类 → 每类由 heavy 模型(本地 OpenAI 兼容网关 + fallback 链,32k 输出、1200 字/篇截断)写综述 → 提炼三句话要点 → 落盘data/report/。 - 08:50 财经专题日报:
themed_main.py复用同一语料和提示词机制,跑财经专版。
生成器只落盘,发送是独立的 send_report.py,由 shell 判断「报告文件存在才发」——重跑不重复发信,失败可单独重发。
值得学的工程设计
- 单点容错网关(
digest/llm_client.py:34-93):所有 LLM 调用收敛到一个call_with_retry。它区分两种失败语义——瞬时异常走指数退避重试;而命中「模型下线短语」(is no longer available、model not found等,针对 new-api/one-api 类网关 HTTP 200 但 content 是错误文案的真实行为)或长度低于min_chars阈值时,立即 break 换 fallback 链的下一个模型——死模型不值得退避。30 行挡住模型下线、限流、截断三类真实故障,可直接移植。 - 成本驱动的两级漏斗:筛选/分类(高频、机械)走免费 8B 级模型、只送标题;写作(低频、重质量)才上 heavy。全量 → 筛掉 40-50% → 分类 → 每类一篇综述,让「每天全量 RSS 过 LLM」在零预算下可持续。选型还有配套的
test_model_speed.py测速背书,不是拍脑袋。 - 文件系统即数据库(
miniflux/storage.py:26-136):YYYY/MM/DD目录即索引 + 内容/链接双 SHA256 持久索引 + 文件名存在性兜底 +force_overwrite逃生门。三层幂等让 30 分钟级重复导出无副作用,语料可直接 grep/diff/rsync/手工修复。 - 运行时路径总线(
digest/runtime.py:43-104):40 行 frozen dataclass +DAILYREPORT_*环境变量覆盖链 + 统一幂等 mkdir,md/prompt/report/log 四目录随一个变量整体搬迁,五个入口模块无一处自行拼路径。cron 部署最怕脚本在错误工作目录散落文件,这是小型自托管工具里性价比极高的模式。 - 锚点时间可重放(
digest/main.py:98-133):--date + --anchor-time构造虚拟 now,回溯窗口与 cron 实跑完全一致,配合--md-dir/--test-limit可用示例语料低成本复现任意一天的运行。凡以当前时间为输入的批处理都该把时间做成可注入参数。 - LLM 故障降级为内容缺失:返回值契约
None → or "" → if summary: 跳过板块——端点整体宕机时日报仍落盘、cron 不报错、邮件照发(缺板块而已)。 - 行为/配置/密钥/产物四层物理隔离:prompt 在
data/prompt、模型接线在双层 YAML(可提交基线 + gitignore 的本地深合并覆盖)、密钥在.env、产物在 report/log。复核时对全 git 历史做了密钥扫描,只发现占位符——「开源可公开」与「私有可定制」同时成立。 - 示例语料入库:
data/md/examples/的 9 篇脱敏文章,同时充当解析器输入契约锚、脏输入回归基线、--md-dir离线冒烟素材。数据流水线开源最难的是让陌生人跑起来,一份结构真实的小语料比任何文档都有效。 - 重活下沉 Rust CLI:HTML→Markdown 交给 Rust 子进程(readability 式预处理、30 秒超时、返回码检查),Python 依赖只剩 4 个包;转换器构造时先跑
--version探测,不可用立即抛错,避免导出到一半才失败。
全部 12 段提示词
提示词全部外置在 data/prompt/*.txt(prompt_loader.py 用 str.format 渲染,DAILYREPORT_PROMPT_DIR 可整目录替换便于 A/B),另有 4 处内嵌片段(llm_client.py:166-174 与 themed_main.py:196-204 的文章资料块拼接、generate_finance_digest.sh:22-23 的财经主题描述注入、test_model_speed.py:20 的测速探针)。8 个模板:
| 模板 | 用途 |
|---|---|
filter_important_articles.txt | 重要性筛选(light 模型,只送标题) |
categorize_articles.txt | 通用七类归类 |
write_deep_summary.txt | 分类深度综述(heavy 主力) |
generate_three_points.txt | 日报头部三句话要点 |
filter_theme_articles.txt | 专题重要性筛选 |
categorize_finance_batch.txt | 财经八类批量归类 |
write_theme_summary.txt | 子板块综述 |
generate_theme_highlights.txt | 主题核心动态 |
最值得抄的是 write_deep_summary.txt 的四条「反 AI 味」核心原则(逐字):
1. 客观克制:拒绝使用情绪化形容词,避免空泛”AI味”废话。 2. 拒绝强行关联:如果新闻之间没有明显逻辑联系,请分段独立陈述。 3. 信息密度:直接陈述核心事实(Who, What, When, Where, Why, How)。 4. 信源标注:必须保留并对应引用来源,格式为 📎。
外加输出规格:新闻简报风、400-600 字、### 小标题、「直接输出正文,无需寒暄」。generate_three_points.txt 则把格式约束写到字——每条 ≤50 字、固定「1. 关键词:内容」句式、关键词不加粗。共性写法:角色前置(「作为资深主编」)、约束编号化、字数硬预算、格式给模板、负面清单防跑偏。
短板与坑(复核确认)
- 两条管线漂移:
main.py与themed_main.py约百行骨架复制而非抽象,LLM JSON 防御两侧不对称(themed 侧缺_extract_json和长度阈值)——新增一种日报要抄一遍骨架。 - 会在生产中真实咬人的坑:config 用相对路径、依赖 cwd,失败静默且默认值漂移;shell 对报告路径的预判与透传参数脱节,改参数后会静默不发信;
save_article失败语义混淆,有数据丢失风险;filter 路径 ID 未归一化可致整批静默丢失。 - 工程欠账:无测试无 CI、依赖零版本约束、SMTP 无超时无重试、日志无轮转、时区硬编码 Asia/Shanghai、写盘非原子。
Infinitum:把一切吃进来的 6.9 万行
它是怎么跑的
Next.js 16(App Router)+ Prisma + SQLite(WAL)的自托管资讯工作台,README 给自己的定义是「对日益膨胀的个人信息流进行必要但保守的预处理」。一行流水线:RSS 拉取 → 全文抽取(必要时)→ AI 分析/摘要 → 聚类去重 → 公开展示 + Admin 管理。产物有三种形态:公开信息流(网页 + JSON API + RSS)、事件速览(/events)、每日 AI 日报(/daily,含 Markdown 导出与 RSS)。
架构上有三个定型决策:
- 双进程共享单库:Web 进程(Next.js)只负责入队与展示,Worker 进程每 2 秒轮询认领执行;两容器共享一个 SQLite 文件卷,靠 WAL +
connection_limit=1+ busy_timeout + 进程级 setup 锁共存。 - DB 即基础设施:任务队列(条件更新乐观认领,
updateMany where status:queued → running,count≠1 即让位)、分布式锁(日报按日期锁,TTL 5 分钟 + 续期 + 过期原子接管)、配置存储(源/黑名单/模型/提示词/调度全是数据库行)、缓存版本号——全部用 SQLite 实现,零外部依赖。 - 三层 AI 消费:①摄取时的「统一理解」——一次调用同时产出摘要、审核判定、0-100 质量分、事件签名、聚合拆分;②归组时的「合并漏斗」;③每日「日报流水线」九环节。
摄取:四层去重与打分制过滤
去重做成成本递增的多级短路:源级条件请求(ETag/Last-Modified 命中 304 整源跳过)→ 整源内容 SHA256(且延迟到运行成功才提交,中途崩溃不会误标已消费)→ 条目 urlHash(去 hash + 五个 utm 参数后取哈希,唯一约束)→ ItemDedupeHistory 归档拦截旧条目。每层独立失效都不致命。
过滤是打分制而非一票否决:起始 100 分,黑名单 -100 硬过滤、标题低信号 -45、URL 低信号 -35、正文过短 -25、模板套话 -20,低于 50 才拦——单条弱信号保留待审,且初筛只用 RSS 内容、先于昂贵的全文抓取,抓到全文后再筛一遍。被过滤内容进复核列表可恢复:保守拦截 + 人工可逆。
正文补抓双引擎按失败原因路由:RSS 只给 HTML 就优先 Jina Reader、内容太短就优先本地 Readability(JSDOM + @mozilla/readability);配 RPM 槽位限速、每运行 20 次调用预算、微信域名豁免、minChars 500 / maxChars 32000 门槛。处理失败按原因分类进补偿队列,15 分钟到 6 小时指数退避排期。
归组:三段式合并漏斗
对抗「同一事件被 50 个源报道」的核心设计,LLM 放在漏斗最末端:
- 指纹速配免 LLM:主体/客体/动作/类型/时间五元组规范化后取 SHA256 做事件指纹——刻意去掉日期(时间无关指纹,注释里记着来自生产「碎片误合」案例),命中即归组,零 token;
- 规则灰区 ∪ 向量准入预筛:规则评分器(subject +45 / object +40 / action +20 / 日期 +15)打分,embedding 通道 RRF 融合召回,只有灰区候选才值得送 LLM;0.72 的准入线在常量文件里注明「bge-m3 挖掘 ≥0.72 同事件精确率约 99%」——魔法数自带标定证据;
- LLM 两两终审:对预筛 Pair 逐对判定,输出与输入等长的 verdicts 数组(位置对齐,不让模型复述 ID),ambiguous 显式定义为「交人工复核」。
基线评估用数据驱动过一次架构重构:规则层 ≥95 分的候选 61.1% 被 LLM 拒绝——「问题不在判断,而在谁被送到 AI 面前」。人工的每次合并/拆分/移动同时回写三样东西:评估标签快照、聚类约束(must_link/cannot_link 持久压制自动重合并)、复核队列(declined 按 6h/24h/48h 冷却退避)——标注成为管理操作的副产物。
日报:九环节流水线
prepare → assess → merge → plan → plan_validate → write(内含 REPAIR 补丁轮)→ validate → review → persist_publish,每段独立设重试语义:
- assess 分批评估,批级 checkpoint,成功批次恢复时直接复用不重调 AI;
- plan 模型只做选题(blockKey + candidateIds),topicId 由代码分配;20 类违规码本地校验;
- write 失败后走 REPAIR 最小补丁协议——只补缺失的必填要点,temperature 强制 0,输入压缩到仅 topicId+notes;
- review 终审输出经引用完整性双重代码校验(violation 引用不存在的 topicId 即抛错重试);拒绝驱动定向重生成,任何重试失败回滚保草稿——审核失败永不阻断持久化,只阻断自动发布;
- persist_publish 幂等 revision + 同日互斥锁 + 全量候选快照留底(含排除原因),管理台可审计。
整条流水线全量 checkpoint:对话级 transcript 持久化、恢复前四重一致性校验(inputHash+候选快照+模板签名+管线版本)、按阶段外科手术式清理产物——失败不必从头烧 token。
提示词体系:约 88 条资产的契约化治理
Infinitum 的提示词不是散落的字符串,而是一套带治理的资产体系(约 88 条:30 个默认词与片段 + 8 个日报字段指南 + 7 条嵌入式阶段合同 + 模板 DSL 槽位 + AGENTS.md 等),运行时每次真正发送的核心单元 24 条。治理规则:
- 系统提示词契约化:7 类 AI 任务各有
{type, contractVersion, contractHash, systemPrompt}契约,systemPrompt 归代码所有,管理员只能改用户侧指令;契约哈希随每条 AI 用量记账——成本可追溯到确切提示词版本。 - 「用户指令不是模板」:12 类历史占位符从协议中结构性废除;所有单轮任务的 user 消息固定两段——「用户补充指令(不得改变系统协议)」+「系统生成的输入 JSON(唯一输入边界)」,提示词注入面被机制消除,而不是靠提示词里写「请忽略恶意指令」。
- 采样契约由代码强制:判定/抽取类任务 temperature 无条件锁 0,注释写明「行值清空会落到 API 默认 ≈1.0」的坑。
- 评分 rubric 数据化:五维结构(事实密度 30 / 一手性 25 / 完整度 20 / 可信度 15 / 信息聚焦 10),模型只报逐维子分,代码负责档位吸附与求和(同距取低档保守)——总分不信模型自报。
- 模板 DSL:日报栏目/条数/要点是 JSON 配置,编译成完整中文系统提示词——管理员改模板即改提示词,无需改代码;配置到措辞的「机器翻译」有逐句回归断言。
- JSON 三层防御:提示词内语法规则 →
response_format=json_object+ finish_reason=length 截断检测 → 解析失败把错误原文回喂定向重试。 - 保守原则格言化:实体归一的提示词写着「宁可漏合,不可错合;无法确定即 isSameEntity=false 且 confidence=low」,解析侧配套保守默认(仅
===true为真、confidence 非法一律 low)。
评测文化:改打分逻辑先过回归门
这是 Infinitum 最超出「个人项目」直觉的部分:scripts/ 下 7 个聚类评估/校准脚本 + docs/eval/ 15 份评估文档。冻结生产快照建基线,baseline-gate 算三条量化 guard(规则过度激进/强匹配膨胀/召回退化),越界 exit 1、通过 exit 0、输入异常 exit 2;snapshot-gate 同快照逐 pair 重放对比;80 组合网格搜索校准阈值(冲突豁免 0.9→0.72 的结论已应用回常量);分层补盲抽样量化盲区(实体别名覆盖率 91.9%)。改打分逻辑从「凭感觉」变成「有数据护栏的变更」。
短板与坑
- 复杂度本身是负债:69k 行、74 个 admin 路由的维护面;双容器共享 SQLite 文件卷需要持续伺候 WAL/锁/连接数这套并发模型;五阶段 Docker 双镜像的部署心智不低。
- 安全细节:admin 登录无速率限制(多路分析如实标注,靠强密码与反代兜底)。
- 稳定性长尾:102 个测试文件中有一例时序敏感用例在不同机器上 waitFor 超时(三个环境交叉验证后的结论,非已证缺陷);另有配置加载锚定 CWD 与 runtime 根锚定不一致、个别 except 引用未导入符号等小瑕疵。
总览对比
| 维度 | rss-to-daily-report | Infinitum |
|---|---|---|
| 规模 | ~2,900 行 Python,4 依赖,3 提交 | 69,186 行 TS,102 测试文件 / 878 用例 |
| 架构哲学 | 借力:聚合层外包给 Miniflux | 全自研:摄取、归组、展示、管理全自己来 |
| 数据层 | 文件系统即数据库(日期目录 + frontmatter) | SQLite 一把梭:队列、锁、配置、缓存版本全是表 |
| 内容理解 | 扁平:筛选 → 分类 → 每类综述 | 深度:事件签名 → 三段式聚类 → 实体归一 → 跨日去重 |
| 模型策略 | light/heavy 双档 + fallback 链 | 7 类任务契约 + embedding 通道 + 熔断器 + 默认模型回退 |
| 提示词治理 | 8 个外置 txt 模板 | 契约化 + 模板 DSL 编译 + 30+ 测试断言 + 幂等升级 |
| 质量保障 | 无测试无 CI | 评测体系:回归门 + 校准 + 补盲抽样 |
| 交付形态 | Markdown 落盘 + 可选 SMTP | 公开站点 + RSS + 管理台 + llms.txt |
| 复杂度预算 | 极低(cron + shell 就能跑) | 高(双容器共享 SQLite 卷 + WAL 并发治理) |
三个真正的分歧
复杂度放在哪一层
rss-to-daily-report 的赌注是「RSS 解析是脏活,交给专业工具」——2,900 行里没有一行碰 feed 解析。Infinitum 的赌注恰好相反:「管道的核心竞争力恰恰在摄取与归组」——于是手写坏 XML 修复状态机,把 ETag、四层去重、坏响应哨兵全部握在自己手里。
前者用 20 倍少的代码换来同等可靠的输入,代价是被 Miniflux 锁定;后者获得了对每一层的完全控制,代价是 69k 行的维护面和「两个容器共享一个 SQLite 文件」这种需要小心伺候的并发模型。这是复杂度预算的选择题,不是对错题。
提示词的资产等级
上面两节的提示词部分放在一起,是一条完整的进化光谱:配置文件(外置 txt、可 A/B、零防护)→ 契约化代码(归代码所有、版本化、可测试、幂等升级、防注入机制化)。有意思的是,rss-to-daily-report 已经朝中间爬了半步:外置模板 + 统一渲染器 + {{}} 转义纪律,已经是「提示词当资产管理」的雏形,缺的只是 Infinitum 那套契约哈希、测试断言和幂等升级。
内容理解的天花板
rss-to-daily-report 解决的是「筛选 + 压缩」:全量 → 筛掉 40-50% → 分类 → 每类一篇综述。Infinitum 解决的是更难的问题:「同一事件被 50 个源反复报道怎么办」——事件五元组签名、时间无关指纹、规则+向量融合召回、LLM 两两终审、人工拆分以 cannot_link 约束持久压制。这是量变到质变的分水岭:订阅源少的时候,去重是锦上添花;源上到几十个,归组能力就是活不活得下去的问题。
四个殊途同归
两个互不相识的项目在关键决策上独立收敛——这比任何单一项目的最佳实践都更可信:
1. LLM 故障降级为内容缺失,而非任务失败。 rss-to-daily-report 用 None → 空串 → 跳过板块,端点宕机日报照发;Infinitum 把 partial 做成一等状态,可选栏目违规就剔除后照常出报并标注,审核失败永不阻断持久化、只阻断自动发布。
2. 幂等是一切 cron 管道的前提。 rss-to-daily-report 用锚点时间(--anchor-time 构造虚拟 now,任意一天可确定性重放)+ 双哈希;Infinitum 用 inputHash + 生成签名(提示词契约哈希变了就触发重生成)。连「停机补跑不风暴」的思路都一致:从上一周期锚定推进,而不是从现在推进。
3. 有信息损失的自动化决策必须留台账。 rss-to-daily-report 对「异常也标已读防堵塞」的每条路径追加 failure log;Infinitum 的每次归组判定落 cluster_decisions 表,连输入哈希都存,支持回归重放。原则一句话:凡是「为了不堵塞而丢弃信息」的地方,必须留可回溯的记录。
4. 把 LLM 放在漏斗末端,而不是开端。 rss-to-daily-report 先用规则初筛再花 token,且初筛先于昂贵的全文抓取;Infinitum 更极致:指纹速配免 LLM → 规则灰区∪向量准入预筛 → LLM 只做两两终审,并用基线数据证明「问题不在判断而在谁被送到 AI 面前」。省 token 只是表象,本质是让确定性系统做确定性的事。
如果要抄:分阶段清单
把 rss-to-daily-report 当 MVP 蓝图、Infinitum 当 v2 模式库,按自己项目的实际痛点取用:
- 现在就该抄(成本低、立刻受益):Miniflux 做聚合底座 + unread 游标;
--anchor-time式可重放;生成与投递解耦、审核失败只阻断发布;坏响应哨兵 + fallback 换模型(30 行,可直接移植)。 - 源变多之后抄(信号:同一新闻在日报里重复出现):事件签名指纹 + 三段式合并漏斗;打分制过滤 + 复核队列(保守拦截、人工可逆——误杀的代价远高于漏放)。
- 提示词开始频繁迭代时抄:契约化分层(协议归代码、侧重归用户);rubric 数据化评分;提示词行为的测试断言;默认词幂等升级。
- 不必抄:Infinitum 的 DB 即队列、双容器共享 SQLite——那是全自研路线的伴生复杂度。除非要做公开站点和多用户,SQLite/文件 + cron 足够。
结语
这两个项目合起来,恰好说清了一件事:LLM 应用的护城河不在模型调用本身,而在调用之前的漏斗设计(谁值得花 token)、调用之后的校验与恢复(输出不可信时系统如何自愈)、以及长期运行的账本(每个判定可追溯、每次变更可回归)。
rss-to-daily-report 证明了这件事最少可以用 2,900 行做成;Infinitum 证明了它可以长成一棵 6.9 万行的大树而不烂掉。我的 DailyDigest 正处在「该抄前者」的阶段——如果你也在做类似的管道,建议先诚实地回答一个问题:你的订阅源超过 20 个了吗?没有的话,别急着造 Infinitum。