﻿---
title: "last30days-skill 拆解：模型和引擎怎么分工搜最近 30 天的讨论"
description: "一个叫 last30days 的 Claude Skill 登上过 GitHub Trending 第一，装上以后发现它是 261 行的 SKILL.md 加 7 万行 Python。顺着一次真实运行往下追：为什么一个 Skill 需要这么大的引擎，引擎在没有密钥时能从哪拿到数据，Reddit 赞同票、HN 分数和 Polymarket 成交额怎么排在一起，引擎的输出为什么是写给模型看的，SKILL.md 里那 11 条 LAW 各自堵的是哪次事故，以及发现模式里让模型当裁判是怎么接上的。"
pubDate: 2026-10-24
tags: ["AI", "Agent", "Skill", "源码分析", "搜索"]
clc: "TP18"
hero: "./hero.svg"
refs:
  - id: repo
    title: "mvanhorn/last30days-skill"
    url: https://github.com/mvanhorn/last30days-skill
    site: GitHub
    author: Matt Van Horn
    note: "本文读的是 2026-10-22 的提交 `f3cd06a`（v3.30.0）"
  - id: skill-md
    title: "skills/last30days/SKILL.md"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/SKILL.md
    site: last30days 源码
    note: "根文件 261 行：分派规则、按条件读取 references 的路由表、LAW 1–11 和出稿前自检"
  - id: runbook
    title: "skills/last30days/references/research-runbook.md"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/references/research-runbook.md
    site: last30days 源码
    note: "Step 0.55（解析 subreddit、X 账号等）和 Step 0.75（模型自己写 JSON 查询计划）"
  - id: concepts
    title: "CONCEPTS.md"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/CONCEPTS.md
    site: last30days 源码
    note: "Skill / Engine / Harness 的定义，Keyless path、Entity grounding、Discovery 三段协议等术语"
  - id: agentskills
    title: "Agent Skills"
    url: https://agentskills.io
    site: agentskills.io
    note: "`SKILL.md` + 同目录脚本的开放格式，Claude Code、Codex、Cursor 等宿主都能装"
  - id: v1
    title: "Initial commit: last30days skill"
    url: https://github.com/mvanhorn/last30days-skill/commit/5ca4829
    site: last30days 源码
    note: "2026-01-23 初版：Reddit 走 OpenAI Responses API 的 `web_search`，X 走 xAI，再去 reddit.com 取真实赞同数"
  - id: reddit-keyless
    title: "fix(reddit): restore free path via keyless RSS + shreddit scrape (.json is dead) (#457)"
    url: https://github.com/mvanhorn/last30days-skill/commit/8d3a9e4
    site: last30days 源码
    note: "2026-05-29：`search.json` 被 403，改成 RSS 发现 + shreddit 评论抓取的分层免密钥路径"
  - id: signals
    title: "scripts/lib/signals.py"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/scripts/lib/signals.py
    site: last30days 源码
    note: "`engagement_raw`（各平台 log1p 加权）、`_ENGAGEMENT_LOG_REFERENCE`（各平台“很高”的参考值）、`engagement_score`"
  - id: fusion
    title: "scripts/lib/fusion.py"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/scripts/lib/fusion.py
    site: last30days 源码
    note: "`weighted_rrf`：`RRF_K = 60`，同一 URL 在多个流里出现就累加"
  - id: rerank
    title: "scripts/lib/rerank.py"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/scripts/lib/rerank.py
    site: last30days 源码
    note: "`_final_score` 的权重、`_fallback_tuple`（无 LLM 时的回退分）、实体未命中扣分"
  - id: hn
    title: "scripts/lib/hackernews.py"
    url: https://github.com/mvanhorn/last30days-skill/blob/main/skills/last30days/scripts/lib/hackernews.py
    site: last30days 源码
    note: "`_title_matches_query`：查询带英文引号时整批结果被滤掉的那个过滤器"
  - id: law7
    title: "fix: add LAW 7 - YOU ARE the planner, --plan mandatory on named entities"
    url: https://github.com/mvanhorn/last30days-skill/commit/a0d61b0
    site: last30days 源码
    note: "2026-04-19：规则写在 SKILL.md 第 647、729 行，模型没读到就调用了引擎，于是把它提到文件最前面"
  - id: discover
    title: "feat(discover): host-judged three-leg discovery protocol (#856)"
    url: https://github.com/mvanhorn/last30days-skill/commit/5ccda40
    site: last30days 源码
    note: "2026-07-21：nominate-only / judgments / finalize 三段，`lib/discovery_handoff.py` 管文件契约"
  - id: rrf
    title: "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods"
    url: https://dl.acm.org/doi/10.1145/1571941.1572114
    author: Gordon V. Cormack, Charles L. A. Clarke, Stefan Büttcher
    site: SIGIR 2009
    date: 2009-07-19
  - id: arctic
    title: "Arctic Shift"
    url: https://github.com/ArthurHeitmann/arctic_shift
    site: GitHub
    note: "社区维护的 Reddit 数据存档和查询 API，last30days 在 Reddit 自家接口被挡时拿它补帖子"
  - id: oc-skills
    title: "技能即能力：OpenClaw 与 Claude Skills 机制对比"
    url: https://twwy.net/blog/openclaw-vs-claude-skills/
    site: twwy.net
    note: "SKILL.md 的发现、触发和 token 管理"
---

在云端容器里装好 last30days，没配任何 API 密钥，跑了一次：

```text
$ python3 skills/last30days/scripts/last30days.py "Claude Code skills" --emit=compact
✓ Research complete (10.6s) - Reddit: 0 threads, HN: 12 stories, Web: 0 results, Github: 0 results, Jobs: 5 results
```

10 秒后拿到 167 行输出。第一行是一个徽章 `🌐 last30days v3.30.0 · synced 2026-10-24`，紧接着是一段加粗的 **SYNTHESIS CONTRACT — read before emitting anything**，最后几行写着：

```text
If your response contains the literal string `### 1.` followed by a score
tuple like `(score N, M items, sources: ...)`, you dumped evidence instead
of synthesizing - STOP and regenerate. This is the 2026-04-19 Hermes Agent
Use Cases failure mode (evidence-dump).
```

这段话显然不是写给我看的。再翻仓库，这个叫“Skill”的东西有 261 行的 `SKILL.md`，旁边是 7 万行 Python，`SKILL.md` 里还写着 11 条全大写的 “LAW”。

一个 Skill 为什么要带这么大的引擎？引擎为什么在跟模型说话？Reddit 的赞同票、Hacker News 的分数、Polymarket 的成交额，又是怎么排进同一张榜的？下面就从这次运行出发，一层一层往里看。

## 实验环境

last30days 是开源的，作者是 Matt Van Horn[^repo]。本文读的是 2026-10-22 的提交 `f3cd06a`，版本 v3.30.0。实验在一台云端 Linux 容器里做，Python 3.13，没有配置任何密钥，相当于一个刚装上、什么都没设置的用户；唯一的不同是这台机器的出口 IP 在数据中心，后面会看到这一点影响很大。

文中的命令输出、日志和分数都来自这台机器上的实际运行，原始输出存在调研笔记里。

## 一、Skill 和它要补的缺口

### Skill 是什么

先交代 Skill 这个载体。Agent Skills 是一种开放格式：一个目录里放一份 `SKILL.md`，开头用 YAML 写名字和描述，正文是写给模型的操作说明；同目录可以放脚本，说明里告诉模型什么时候、怎么调用它们[^agentskills]。Claude Code、Codex、Cursor 这些宿主都会扫描 Skill 目录，按描述决定什么时候加载，用户也可以用 `/last30days` 这样的斜杠命令直接调起。SKILL.md 的发现和触发机制，之前在另一篇里对比过[^oc-skills]。

所以 Skill 本身只是“一份说明书 + 一些工具”，执行它的是宿主里的模型。

### 模型自己搜不到什么

宿主模型一般都有 WebSearch。那为什么不直接让模型搜“最近 30 天大家怎么评价 X”？

README 给的理由是：WebSearch 背后是搜索引擎，搜索引擎索引的是网页，而人们最近的讨论在 Reddit 评论、X 帖子、YouTube 字幕、TikTok 里。这些平台各有各的接口、令牌和登录，搜索引擎要么搜不到，要么搜到了也没有赞同数、点赞数这些“有多少人认同”的信号[^repo]。last30days 想做的是：用用户自己的密钥和浏览器会话，让一个 agent 同时去这些平台搜，再按真实互动排序。

### 从 2,400 行到 7 万行

最初的版本很小。2026-01-23 的第一个提交里，`SKILL.md` 145 行，Python 2,448 行[^v1]。它的做法很取巧：Reddit 交给 OpenAI Responses API 的 `web_search` 工具去找帖子链接，X 交给 xAI 的 Grok 去搜，拿到链接后再去 reddit.com 取真实的赞同数和评论。也就是说，第一版是**借别家模型的搜索能力**去够那些围墙后面的内容。

九个月后，它长成了下面这样：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="l30-growth-title l30-growth-desc">
<title id="l30-growth-title">last30days 九个月里的代码量和关键改动</title>
<desc id="l30-growth-desc">Python 引擎行数：2026-01-23 为 2,448，02-27 为 10,161，04-26 为 21,584，06-30 为 32,857，10-22 为 70,609。SKILL.md 行数依次为 145、563、1,639、2,040，最后拆成 261 行根文件加 2,406 行 references。下方时间轴标出 04-08 v3.0 多源加 RRF、04-19 LAW 7、05-29 Reddit .json 被封、07-21 发现协议、10-11 热度改为绝对刻度。</desc>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="368" rx="8"/>
<text class="t-zone" x="36" y="44">Python 引擎行数（不含测试）</text>
<line class="dd-edge" x1="72" y1="240" x2="740" y2="240"/>
<line class="dd-edge" x1="100" y1="320" x2="700" y2="320"/>
<rect class="dd-track" x="80" y="234" width="40" height="6"/>
<rect class="dd-track" x="157" y="216" width="40" height="24"/>
<rect class="dd-track" x="285" y="188" width="40" height="52"/>
<rect class="dd-track" x="429" y="161" width="40" height="79"/>
<rect class="dd-track" x="680" y="70" width="40" height="170"/>
<text class="t-val" x="100" y="226" text-anchor="middle">2.4k</text>
<text class="t-val" x="177" y="208" text-anchor="middle">10.2k</text>
<text class="t-val" x="305" y="180" text-anchor="middle">21.6k</text>
<text class="t-val" x="449" y="153" text-anchor="middle">32.9k</text>
<text class="t-val" x="700" y="62" text-anchor="middle">70.6k</text>
<text class="t-note" x="100" y="258" text-anchor="middle">01-23</text>
<text class="t-note" x="177" y="258" text-anchor="middle">02-27</text>
<text class="t-note" x="305" y="258" text-anchor="middle">04-26</text>
<text class="t-note" x="449" y="258" text-anchor="middle">06-30</text>
<text class="t-note" x="700" y="258" text-anchor="middle">10-22</text>
<text class="t-note" x="24" y="286">SKILL.md</text>
<text class="t-mono t-acc" x="100" y="286" text-anchor="middle">145</text>
<text class="t-mono t-acc" x="177" y="286" text-anchor="middle">563</text>
<text class="t-mono t-acc" x="305" y="286" text-anchor="middle">1,639</text>
<text class="t-mono t-acc" x="449" y="286" text-anchor="middle">2,040</text>
<text class="t-mono t-acc" x="676" y="286" text-anchor="middle">261 + 2,406</text>
<circle class="dd-dot" cx="265" cy="320" r="4"/>
<circle class="dd-dot" cx="290" cy="320" r="4"/>
<circle class="dd-dot" cx="378" cy="320" r="4"/>
<circle class="dd-dot" cx="495" cy="320" r="4"/>
<circle class="dd-dot" cx="676" cy="320" r="4"/>
<text class="t-note" x="265" y="344" text-anchor="middle">04-08 v3.0 多源+RRF</text>
<text class="t-note" x="290" y="368" text-anchor="middle">04-19 LAW 7</text>
<text class="t-note" x="390" y="344" text-anchor="start">05-29 Reddit .json 被封</text>
<text class="t-note" x="495" y="368" text-anchor="middle">07-21 发现协议</text>
<text class="t-note" x="700" y="344" text-anchor="end">10-11 热度改绝对刻度</text>
<text class="t-say" x="100" y="100">SKILL.md 先涨到 2,040 行，</text>
<text class="t-say" x="100" y="120">再拆成 261 行根文件 + 按条件读取的 references；</text>
<text class="t-say" x="100" y="140">Python 一直在涨。</text>
</svg>
</div>
<figcaption>图 1 · 九个月里引擎涨了近 30 倍，SKILL.md 先涨后拆</figcaption>
</figure>

仓库现在有 1,398 个提交、161 位作者，很多提交带着 `Co-Authored-By: Claude`。这些代码长在哪了？要回答这个问题，得先看一次研究里，模型和引擎分别做了什么。

## 二、一次研究的三段分工

### 宿主模型、引擎、宿主模型

项目的概念文档把三方说得很清楚：Skill 是分发单位；Engine 是 `last30days.py`，真正干活；Harness 是加载 Skill 的宿主，比如 Claude Code[^concepts]。一次正常的 `/last30days <主题>` 按 SKILL.md 走下来，是“模型 → 引擎 → 模型”三段：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="l30-flow-title l30-flow-desc">
<title id="l30-flow-title">一次 /last30days 研究里模型和引擎的分工</title>
<desc id="l30-flow-desc">左栏宿主模型在研究前用 WebSearch 解析出 subreddit ClaudeAI 和 ClaudeCode，并写出查询计划 plan.json，含两个子查询 claude code skills 和 agent skills SKILL.md，权重 1.0 和 0.7，以 --plan 传给引擎。中栏引擎依次扇出抓取 9 个流、归一化打分、加权 RRF 融合、重排、聚类渲染出证据信封。右栏宿主模型读证据信封，按 LAW 1 到 11 写成以 What I learned 开头的简报，并原样转发页脚。</desc>
<defs>
<marker id="l30-flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="l30-flow-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="240" height="408" rx="8"/>
<text class="t-zone" x="32" y="44">宿主模型 · 研究前</text>
<rect class="dd-zone" x="272" y="16" width="256" height="408" rx="8"/>
<text class="t-zone" x="288" y="44">引擎 · last30days.py</text>
<rect class="dd-zone" x="544" y="16" width="240" height="408" rx="8"/>
<text class="t-zone" x="560" y="44">宿主模型 · 研究后</text>
<path class="dd-edge-acc" d="M240 240 H256 Q264 240 264 232 V98 Q264 90 272 90 H286" marker-end="url(#l30-flow-arrow-acc)"/>
<path class="dd-edge" d="M400 116 V134" marker-end="url(#l30-flow-arrow)"/>
<path class="dd-edge" d="M400 188 V206" marker-end="url(#l30-flow-arrow)"/>
<path class="dd-edge" d="M400 260 V278" marker-end="url(#l30-flow-arrow)"/>
<path class="dd-edge" d="M400 332 V350" marker-end="url(#l30-flow-arrow)"/>
<path class="dd-edge" d="M512 378 H528 Q536 378 536 370 V98 Q536 90 544 90 H558" marker-end="url(#l30-flow-arrow)"/>
<path class="dd-edge" d="M664 200 V230" marker-end="url(#l30-flow-arrow)"/>
<rect class="dd-card" x="32" y="64" width="208" height="84" rx="6"/>
<path class="dd-card-head" d="M33 70 Q33 65 38 65 H234 Q239 65 239 70 V96 H33 Z"/>
<text class="t-name" x="48" y="85">解析（Step 0.55）</text>
<text class="t-mono" x="48" y="113">subs  ClaudeAI,ClaudeCode</text>
<line class="dd-hair" x1="33" y1="122" x2="239" y2="122"/>
<text class="t-mono" x="48" y="139">x     未启用</text>
<rect class="dd-card" x="32" y="172" width="208" height="136" rx="6"/>
<path class="dd-card-head" d="M33 178 Q33 173 38 173 H234 Q239 173 239 178 V204 H33 Z"/>
<text class="t-name" x="48" y="193">查询计划</text>
<text class="t-mono t-acc" x="224" y="193" text-anchor="end">plan.json</text>
<text class="t-mono" x="48" y="221">intent  concept</text>
<line class="dd-hair" x1="33" y1="230" x2="239" y2="230"/>
<text class="t-mono" x="48" y="247">sq1  claude code skills</text>
<line class="dd-hair" x1="33" y1="256" x2="239" y2="256"/>
<text class="t-mono" x="48" y="273">sq2  agent skills SKILL.md</text>
<line class="dd-hair" x1="33" y1="282" x2="239" y2="282"/>
<text class="t-mono" x="48" y="299">weight  1.0 / 0.7</text>
<text class="t-say" x="32" y="340">模型自己写计划，以 <tspan class="t-acc">--plan</tspan> 交给</text>
<text class="t-say" x="32" y="360">引擎，引擎不再自己规划（LAW 7）</text>
<rect class="dd-card" x="288" y="64" width="224" height="52" rx="6"/>
<text class="t-name" x="304" y="85">扇出抓取</text>
<text class="t-mono" x="304" y="104">2 子查询 × 来源 = 9 个流</text>
<rect class="dd-card" x="288" y="136" width="224" height="52" rx="6"/>
<text class="t-name" x="304" y="157">归一化、打分</text>
<text class="t-mono" x="304" y="176">相关性 · 新鲜度 · 热度</text>
<rect class="dd-card" x="288" y="208" width="224" height="52" rx="6"/>
<text class="t-name" x="304" y="229">加权 RRF 融合</text>
<text class="t-mono" x="304" y="248">Σ w/(60+rank)，按 URL 合并</text>
<rect class="dd-card" x="288" y="280" width="224" height="52" rx="6"/>
<text class="t-name" x="304" y="301">重排</text>
<text class="t-mono" x="304" y="320">无 LLM 密钥：词重叠回退分</text>
<rect class="dd-card" x="288" y="352" width="224" height="52" rx="6"/>
<text class="t-name" x="304" y="373">聚类、渲染</text>
<text class="t-mono" x="304" y="392">证据信封 + emoji 页脚</text>
<rect class="dd-card" x="560" y="64" width="208" height="136" rx="6"/>
<path class="dd-card-head" d="M561 70 Q561 65 566 65 H762 Q767 65 767 70 V96 H561 Z"/>
<text class="t-name" x="576" y="85">证据信封</text>
<text class="t-val" x="752" y="85" text-anchor="end">compact</text>
<text class="t-mono" x="576" y="113">SYNTHESIS CONTRACT</text>
<line class="dd-hair" x1="561" y1="122" x2="767" y2="122"/>
<text class="t-mono" x="576" y="139">### 1. … (score 45)</text>
<line class="dd-hair" x1="561" y1="148" x2="767" y2="148"/>
<text class="t-mono" x="576" y="165">## Partial Coverage</text>
<line class="dd-hair" x1="561" y1="174" x2="767" y2="174"/>
<text class="t-mono" x="576" y="191">footer  emoji 树</text>
<rect class="dd-card" x="560" y="232" width="208" height="136" rx="6"/>
<path class="dd-card-head" d="M561 238 Q561 233 566 233 H762 Q767 233 767 238 V264 H561 Z"/>
<text class="t-name" x="576" y="253">简报</text>
<text class="t-val" x="752" y="253" text-anchor="end">LAW 1–11</text>
<text class="t-mono" x="576" y="281">What I learned:</text>
<line class="dd-hair" x1="561" y1="290" x2="767" y2="290"/>
<text class="t-name" x="576" y="307">至少 2 条署名评论</text>
<line class="dd-hair" x1="561" y1="316" x2="767" y2="316"/>
<text class="t-name" x="576" y="333">页脚原样转发</text>
<line class="dd-hair" x1="561" y1="342" x2="767" y2="342"/>
<text class="t-name" x="576" y="359">不写 ## 标题、不用破折号</text>
<text class="t-say" x="560" y="400">证据只给模型读，不直接给人看</text>
</svg>
</div>
<figcaption>图 2 · 模型负责规划和成文，中间抓取、打分、融合的苦活交给引擎</figcaption>
</figure>

**第一段，模型规划。** 有 WebSearch 的宿主要先搜两三次，把主题对应的 subreddit、X 账号、YouTube 查询解析出来（Step 0.55），然后**由模型自己写一份 JSON 查询计划**（Step 0.75）[^runbook]。这是我写给 `Claude Code skills` 的计划：

```json
{"intent": "concept", "freshness_mode": "evergreen_ok", "cluster_mode": "none",
 "subqueries": [
  {"label": "primary", "search_query": "claude code skills",
   "ranking_query": "How are people building and using Agent Skills (SKILL.md) in Claude Code?",
   "sources": ["reddit", "hackernews", "github", "polymarket"], "weight": 1.0},
  {"label": "skillmd", "search_query": "agent skills SKILL.md",
   "ranking_query": "What do developers think of the Agent Skills SKILL.md format?",
   "sources": ["hackernews", "reddit", "github"], "weight": 0.7}]}
```

`search_query` 是拿去平台上搜的关键词，`ranking_query` 是拿来判断相关性的自然语言问题，两者分开写。

**第二段，引擎干活。** 引擎按“子查询 × 来源”扇出，每个组合得到一个排好序的结果流；再归一化、打分、融合、重排、聚类，最后渲染成一个“证据信封”。

**第三段，模型成文。** 证据信封里有排好的证据簇、最热的评论、哪些来源失败了；模型读完，写成以 `What I learned:` 开头的几段话，并把引擎给的 emoji 页脚原样贴上。

所以开头那 167 行输出不是最终结果，是**给模型的中间材料**。人看到的是模型改写之后的那份简报。

这样分工，引擎就不必理解主题，模型也不必亲自去抓几十个接口。那 7 万行代码里，最大的一块花在了第二段的第一步上：去各个平台拿数据。

## 三、数据从哪里来

### 无密钥路径

什么都不配，last30days 也能用。README 说 Reddit、Hacker News、Polymarket 和 GitHub 开箱即用。项目把这条路叫 Keyless path：抓公开页面和公开接口，排序用本地打分，不用 LLM[^concepts]。

我在容器里用同一个主题跑了三次，只改模型那一侧给引擎的东西：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="l30-runs-title">同一主题 Claude Code skills，三次运行各来源的结果</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="l30-runs-title">
<colgroup><col style="width:22%"><col><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">来源</th><th scope="col">A 引擎自己规划</th><th scope="col">B 模型写计划</th><th scope="col" class="acc">C 计划 + 指定 subreddit</th></tr>
</thead>
<tbody>
<tr><th scope="row">规划</th><td class="mono">1 个子查询 "Claude Code"</td><td colspan="2">2 个子查询（上面的 JSON）</td></tr>
<tr><th scope="row">Reddit</th><td class="bad">0（403 Blocked）</td><td class="bad">0（403 Blocked）</td><td class="good acc">13 条</td></tr>
<tr><th scope="row">Hacker News</th><td class="mono">12</td><td class="mono" colspan="2">13</td></tr>
<tr><th scope="row">Polymarket</th><td>没搜</td><td class="mono" colspan="2">12</td></tr>
<tr><th scope="row">GitHub</th><td class="warn" colspan="3">403（数据中心 IP 未认证，被限流）</td></tr>
<tr><th scope="row">Web</th><td class="warn" colspan="3">没有 Brave / Serper 等密钥，不可用</td></tr>
<tr><th scope="row">用时</th><td class="mono">10.6 s</td><td class="mono">4.5 s</td><td class="mono">40 s</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">C 只比 B 多了 --subreddits ClaudeAI,ClaudeCode 这一个参数。</p>
</div>
<figcaption>图 3 · 在数据中心 IP 上，Reddit 能不能拿到，取决于模型有没有先说出具体的 subreddit</figcaption>
</figure>

### Reddit 为什么时有时无

A、B 两次 Reddit 都是 0，日志是这样的：

```text
[RedditSearch] global page 1 failed: HTTP 403: Blocked
[RedditKeyless] Tier 1 (site search) 0 posts; no listings
```

Reddit 曾经有个人人都能用的 `.json` 接口，2026 年 5 月被关了，项目那次改成了一组分层的免密钥抓取：先试站内搜索，再从 RSS 和版块列表发现帖子，最后抓 shreddit 页面片段里的评论[^reddit-keyless]。可是站内搜索对数据中心 IP 直接返回 403，第一层就断了；后面几层要从版块列表下手，A、B 两次没给版块，于是 `no listings`，什么也没有。

C 只多给了两个 subreddit，引擎就换了一条路：版块列表同样被挡，它转而去查 Arctic Shift，一个社区维护的 Reddit 存档 API[^arctic]，按 8 天一段把这一个月的帖子扫了一遍：

```text
[RedditKeyless] arctic-shift supplement: 50 new posts from 50 arctic results
[RedditKeyless] Tier 1 (site search) 0 posts; listing discovery 50
```

50 条候选，过完相关性门槛留下 13 条。也就是说，**模型在第一段说出的 subreddit 名字，直接决定了引擎在第二段能不能拿到 Reddit**。Step 0.55 要求模型先搜一下 subreddit，原因就在这里。

### 失败要和“没有讨论”分开

还有一处值得注意。三次运行的证据信封里都有这么一段：

```text
## Partial Coverage
> GitHub rate-limited: ... Web unreachable: ... Reddit auth-failed: HTTP 403: Blocked
> Do not interpret a failed source as no discussion on that source.
```

引擎把每个来源这次的结果分成 `no-results`（正常跑完，确实没有）和 `auth-failed`、`rate-limited`、`unreachable` 等失败状态，SKILL.md 也规定：失败的来源不能说成“Reddit 上没人讨论”[^skill-md]。我的 A 次运行里 Reddit 是被挡了，不是没人聊，这个区别如果交给模型自己猜，很容易写错。

这一节解释了代码量的主要去向：光 Reddit 相关的模块就有 8 个、3,100 行；X 那边有官方 API、xAI、浏览器 cookie 等好几条后端，加上读各家浏览器 cookie 的代码，约 7,200 行。每个平台都在变，每变一次就多一层回退。

数据拿到了，接下来是一个更有意思的问题：一条 487 分的 HN 帖子、一条 17 票的 Reddit 帖子、一个成交额 1.4 万美元的 Polymarket 盘口，**谁该排在前面？**

## 四、点赞、赞同票和美元怎么放在一起排

### 原始数字不能直接比

难处在于单位不同。HN 一条帖子几百分就很热了；YouTube 一条视频几万个赞很平常；Polymarket 的“互动”是美元成交额。直接比原始数字，YouTube 和 Polymarket 会永远压过 HN。

### 第一次尝试：每个流内部归一化

初版的做法是在每个结果流内部做 min-max 归一化：流里最热的一条记 100，最冷的记 0[^v1]。这样各平台都落到 0–100，看起来可比了。

问题是这个分数只说明“它在自己那一组里排第几”，不说明它有多热。一个只有 3 条结果的冷门流，最好的那条也是 100；只有一条结果的流，那一条固定记 50。`signals.py` 里的注释说得很直接：这样的分数“对实际互动什么也没说”[^signals]。

### 解决：每个平台一把对数尺子

现在的做法是给每个平台定一个“很高”的参考值，把互动换算成一把绝对的尺子[^signals]：

1. 先把这个平台的几个计数各取 $\ln(1+x)$，按权重加起来。取对数是因为互动数是长尾的，500 分和 1,000 分的差距不该是 5 分和 10 分差距的一百倍。
2. 再除以这个平台“很高”的那个值的对数，夹到 0–100。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="l30-eng-title l30-eng-desc">
<title id="l30-eng-title">不同平台的互动数换算到同一个 0 到 100 的热度</title>
<desc id="l30-eng-desc">HN 的公式是 0.55 乘 ln(1+分数) 加 0.45 乘 ln(1+评论数)，再除以 6.2。实算结果：HN 487 分 480 评为 100；YouTube 13.3 万播放 8,400 赞为 73；Polymarket 成交 1.4 万美元流动性 2.3 万美元为 64；X 1,000 赞 100 转 50 回为 60；Reddit 160 票 34 评为 50；HN 15 分 4 评为 36；Reddit 17 票 24 评为 34；X 3 个赞为 8。</desc>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="368" rx="8"/>
<text class="t-zone" x="36" y="44">热度：各平台的计数 → 同一把 0–100 的尺子</text>
<rect class="dd-card" x="32" y="60" width="736" height="56" rx="6"/>
<text class="t-mono" x="48" y="83"><tspan class="t-acc">HN</tspan>  raw = 0.55·ln(1+points) + 0.45·ln(1+comments)　热度 = raw ÷ 6.2 × 100</text>
<text class="t-note" x="48" y="104">6.2 ≈ ln(1+500)，是给 HN 定的“很高”；Reddit 用 ln(1+2000)，Polymarket 按成交额和流动性定在 15.2</text>
<text class="t-name" x="36" y="151"><tspan class="t-acc">HN</tspan></text>
<text class="t-mono" x="120" y="151">487 分 · 480 评</text>
<rect class="dd-fill-acc" x="360" y="140" width="360" height="14" rx="2"/>
<text class="t-val" x="728" y="151">100</text>
<line class="dd-hair" x1="32" y1="164" x2="768" y2="164"/>
<text class="t-name" x="36" y="181">YouTube</text>
<text class="t-mono" x="120" y="181">13.3 万播放 · 8,400 赞</text>
<rect class="dd-track" x="360" y="170" width="263" height="14" rx="2"/>
<text class="t-val" x="631" y="181">73</text>
<line class="dd-hair" x1="32" y1="194" x2="768" y2="194"/>
<text class="t-name" x="36" y="211">Polymarket</text>
<text class="t-mono" x="120" y="211">成交 $14k · 流动性 $23k</text>
<rect class="dd-track" x="360" y="200" width="230" height="14" rx="2"/>
<text class="t-val" x="598" y="211">64</text>
<line class="dd-hair" x1="32" y1="224" x2="768" y2="224"/>
<text class="t-name" x="36" y="241">X</text>
<text class="t-mono" x="120" y="241">1,000 赞 · 100 转 · 50 回</text>
<rect class="dd-track" x="360" y="230" width="216" height="14" rx="2"/>
<text class="t-val" x="584" y="241">60</text>
<line class="dd-hair" x1="32" y1="254" x2="768" y2="254"/>
<text class="t-name" x="36" y="271">Reddit</text>
<text class="t-mono" x="120" y="271">160 票 · 34 评</text>
<rect class="dd-track" x="360" y="260" width="180" height="14" rx="2"/>
<text class="t-val" x="548" y="271">50</text>
<line class="dd-hair" x1="32" y1="284" x2="768" y2="284"/>
<text class="t-name" x="36" y="301"><tspan class="t-acc">HN</tspan></text>
<text class="t-mono" x="120" y="301">15 分 · 4 评</text>
<rect class="dd-fill-acc" x="360" y="290" width="130" height="14" rx="2"/>
<text class="t-val" x="498" y="301">36</text>
<line class="dd-hair" x1="32" y1="314" x2="768" y2="314"/>
<text class="t-name" x="36" y="331">Reddit</text>
<text class="t-mono" x="120" y="331">17 票 · 24 评</text>
<rect class="dd-track" x="360" y="320" width="122" height="14" rx="2"/>
<text class="t-val" x="490" y="331">34</text>
<line class="dd-hair" x1="32" y1="344" x2="768" y2="344"/>
<text class="t-name" x="36" y="361">X</text>
<text class="t-mono" x="120" y="361">3 赞</text>
<rect class="dd-track" x="360" y="350" width="29" height="14" rx="2"/>
<text class="t-val" x="397" y="361">8</text>
</svg>
</div>
<figcaption>图 4 · 用仓库里的函数实算：取对数后除以各平台的“很高”参考值，分数才有绝对含义</figcaption>
</figure>

图里的数是我把这些计数喂给仓库里的 `engagement_score` 算出来的，其中 HN、Reddit、Polymarket 那几行就是这次运行里抓到的真实帖子。换成绝对尺子后，一条 487 分的 HN 帖子是 100，一条 15 分的是 36，差距看得出来了。

但也要看到，**“很高”是人定的**。Polymarket 的参考值 15.2 按 900 万美元成交额来定，结果一个成交 1.4 万美元、问“Claude 6 什么时候发布”的盘口拿了 64 分，比 160 票的 Reddit 帖子还高。这些常数是作者对“每个平台什么算火”的判断，不是测出来的。

### 多个结果流怎么合并：RRF

热度解决的是“一条内容有多热”。另一个问题是：同一条内容可能出现在好几个流里（第一个子查询在 HN 搜到了，第二个子查询在 HN 又搜到了），这些排序怎么合成一个？

引擎用的是加权 RRF（Reciprocal Rank Fusion）[^fusion]。它只看名次，不看原始分：一条内容在每个流里排第 $r$ 名，就得 $\frac{w}{60+r}$ 分，$w$ 是子查询权重，几个流的得分加起来[^rrf]。在多个流里都靠前的内容自然累积得多；不同来源的分数尺度也不用对齐，因为只用了名次。

### 最终分：相关性压过热度

最后，`rerank.py` 把几项加成一个最终分[^rerank]：

$$
\text{final} = 0.60\cdot\text{重排分} + 0.20\cdot\text{RRF} + 0.10\cdot\text{新鲜度} + 0.05\cdot\text{来源质量} + 0.15\cdot\text{热度}
$$

热度满分也只贡献 15 分，大头是“重排分”，也就是相关性。配了 OpenAI、Gemini 等密钥时，重排分由一个 LLM 按 0–100 打；没配，就退回本地分：$70\times$ 词重叠相关性 $+\ 10\times$ 来源质量。另外，内容里没提到主实体（这里是 claude）的，回退分扣 25，最终分再扣 20；日期在 30 天窗口外的乘 0.35。

把 C 次运行排在前面的几条拆开，可以逐项对上：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="l30-score-title">C 次运行的最终分拆解（无 LLM 密钥）</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="l30-score-title">
<colgroup><col style="width:34%"><col><col><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">条目</th><th scope="col" class="num">重排分（词重叠）</th><th scope="col" class="num">新鲜度</th><th scope="col" class="num">热度</th><th scope="col" class="num acc">最终分 · 名次</th></tr>
</thead>
<tbody>
<tr><th scope="row">HN · How the Claude Code team does code review（487 分）</th><td class="num">28.3</td><td class="num">83</td><td class="num">100</td><td class="num acc">44.8 · #1</td></tr>
<tr><th scope="row">Reddit · Claude Use Cases (for a newbie)（17 票）</th><td class="num warn hl">43.8</td><td class="num">85</td><td class="num">34</td><td class="num acc">43.7 · #2</td></tr>
<tr><th scope="row">Reddit · I built a Claude skill to find … remote jobs（62 票）</th><td class="num">31.9</td><td class="num">85</td><td class="num">43</td><td class="num acc">37.6 · #5</td></tr>
<tr><th scope="row">Polymarket · Claude 6 released by...?</th><td class="num">12.0</td><td class="num">100</td><td class="num">64</td><td class="num acc">29.8 · #18</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">以第一行为例：0.6×28.3 + 0.2×2.4 + 0.1×83 + 0.05×80 + 0.15×100 = 44.8。归一化后的 RRF 在 2 到 4 之间，各条差不多。</p>
</div>
<figcaption>图 5 · 没有 LLM 重排时，一个 17 票的新手提问靠词重叠排到了第 2</figcaption>
</figure>

第二行是一个新手在问“Claude 能拿来干什么”，17 票，跟 skill 关系不大，却因为和 `ranking_query` 的用词重叠得多，相关性拿了 0.54，排在一个真正讲“用 Claude Code 写了个 skill”的 62 票帖子前面。

这说明没有 LLM 的时候，引擎的排序只是粗排。精排交给谁？交给第三段读证据的宿主模型：它读到的是排好的前几十条，判断哪些真的相关，再决定怎么写。那么问题就变成了：**怎么让模型老老实实做好它那两段？**

## 五、写给模型的契约：SKILL.md 里的 LAW

### 一条规则为什么要提到最前面

LAW 7 的提交说明讲了一次事故[^law7]。2026-04-19 跑 `/last30days Hermes Agent use cases`，模型没带 `--plan` 就直接调用了引擎。其实 SKILL.md 里早就写了 Step 0.75（你来写计划）和一个前置检查，可它们在第 647 行和第 729 行，**模型还没读到那里就已经去执行 Bash 了**。

修法很朴素：把这条规则提到文件最前面的“输出契约”里，起名 LAW 7，用大写写明“YOU ARE THE PLANNER”。同一天还有另一个失败：模型把证据簇原样倒给了用户。于是 LAW 6 规定证据簇不许出现在正文里，引擎输出的末尾也加上了开头看到的那句“出现 `### 1.` 就停下重写”。同一条规则在 SKILL.md 开头写一遍、在引擎输出结尾再写一遍，因为模型最后读到的是引擎输出。

### 计划到底值多少

我的三次运行可以看出模型写计划的价值。A 次引擎自己规划，把 `skills` 当作“意图修饰词”去掉了，实际搜的是整个 Claude Code；它还给查询加了英文引号，结果撞上 HN 过滤器的一个小毛病：

```text
[HN] Prefix filter removed 30/30 false-positive hits for '"Claude Code"'
```

这个过滤器本来只是想剔除标题前缀 “Show HN:” 造成的误匹配，它按词边界去标题里找查询词，可查询词里带着引号，找的是 `"claude`，永远找不到，30 条全被滤掉了[^hn]。之后引擎发现结果太少，用去掉引号的核心词重查了一次，才把结果找回来。我在本地用两行 Python 复现过：同一个标题，带引号匹配失败，不带引号成功。

B 次的第二个子查询多找到了 `Show HN: A Claude Code skill to analyze your chess games`，这是 A 里没有的；C 次多给了 subreddit，Reddit 从 0 变成 13。确定性规划器能兜底，但懂主题的是模型。

### 11 条 LAW 各管什么

SKILL.md 一度涨到 2,040 行，后来拆成 261 行的根文件，加上 15 个 references。根文件里有一张路由表：做比较时读 `comparison.md`，做发现时读 `discovery.md`，第一次运行读 `setup-wizard.md`……只有满足条件才读[^skill-md]。留在根文件里的，是每次都要守的 11 条 LAW：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="l30-laws-title">SKILL.md 的 11 条 LAW</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="l30-laws-title">
<colgroup><col style="width:12%"><col style="width:40%"><col></colgroup>
<thead>
<tr><th scope="col">编号</th><th scope="col">规定</th><th scope="col">防的是什么</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="3" scope="rowgroup">第一段：规划</th></tr>
<tr><th scope="row" class="mono acc">LAW 7</th><td>具名主题必须带 <span class="mono">--plan</span>，计划由宿主模型写</td><td>模型没读到规则就直接调引擎，或以为“规划要另配密钥”</td></tr>
<tr><th scope="row" class="mono">LAW 11</th><td>发现模式必须走三段协议，模型当裁判</td><td>跳过协议，退回机械命名</td></tr>
<tr class="dt-group"><th colspan="3" scope="rowgroup">第三段：成文</th></tr>
<tr><th scope="row" class="mono">LAW 6</th><td>证据簇不许出现在正文</td><td>把带分数的原始证据倒给用户</td></tr>
<tr><th scope="row" class="mono">LAW 5</th><td>页脚原样转发，不许自己编</td><td>编出不存在的来源统计或保存路径</td></tr>
<tr><th scope="row" class="mono">LAW 1、8</th><td>行内引用；链接只能照抄证据里的 URL</td><td>猜出或拼出不存在的链接</td></tr>
<tr><th scope="row" class="mono">LAW 9、10</th><td>至少织入两条署名评论；人物主题优先引用本人的帖子</td><td>只写二手报道，或在正文里讲引擎内部细节</td></tr>
<tr><th scope="row" class="mono">LAW 2、3、4</th><td>固定开头、不用破折号、不用小标题</td><td>每次输出格式不一样</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">规则正文里常带着日期和失败名，比如“2026-04-19 Hermes Agent Use Cases failure”“2026-06-17 Kevin Rose 失败”。</p>
</div>
<figcaption>图 6 · 每条 LAW 对应一类模型出过的错，规划两条，成文占了大半</figcaption>
</figure>

可以看到，引擎负责“确定的事”，SKILL.md 负责“约束模型的事”，而后者是一条条事故写出来的。

计划这一步，模型只是给引擎提供输入。还有一个模式走得更远：**让模型在引擎运行的中途做判断**。

## 六、发现模式：让模型当裁判

### 问题：热榜上的东西要有人起名、去重、挑

`/last30days what's exploding in AI agents?` 这类问题没有具体主题，要先找出“最近什么在火”。引擎能扫各平台的热榜，但热榜上是一条条帖子，要变成“话题”，得有人起个能搜的名字，把讲同一件事的几条合起来，再判断哪些是新闻、哪些只是求助帖。

先看没有模型参与时是什么样。不走协议，直接跑一次性的 `--discover`，引擎用启发式规则起名，日志里会提醒“topic names use deterministic heuristics”。

### 解决：拆成三段，中间交给模型

现在的做法是把一次发现拆成三个命令，模型在两段之间写判断文件[^discover]：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="l30-disc-title l30-disc-desc">
<title id="l30-disc-title">发现模式的三段协议</title>
<desc id="l30-disc-desc">引擎第一段 --discover --nominate-only 扫热榜得到 15 个候选，写 discover-nominations.json，bundle_id 17519f00500c772c。宿主模型据此写 judg.json，给每个候选起名、标 junk、打值得度。引擎第二段 --discover --judgments 合并同名、去掉 junk、对每个候选跑完整研究、过置信度门槛，15 个剩 6 个，写 discover-pending.json。宿主模型写 angles.json。引擎第三段 --discover --finalize 输出简报并记入话题队列。每个文件都要带同一个 bundle_id。</desc>
<defs>
<marker id="l30-disc-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="164" rx="8"/>
<text class="t-zone" x="32" y="44">引擎（每段一个进程，跑完就退出）</text>
<rect class="dd-zone" x="16" y="196" width="768" height="168" rx="8"/>
<text class="t-zone" x="32" y="224">宿主模型</text>
<path class="dd-edge" d="M200 164 V232 Q200 240 208 240 H222" marker-end="url(#l30-disc-arrow)"/>
<path class="dd-edge" d="M330 232 V172" marker-end="url(#l30-disc-arrow)"/>
<path class="dd-edge" d="M470 164 V232 Q470 240 478 240 H492" marker-end="url(#l30-disc-arrow)"/>
<path class="dd-edge" d="M610 232 V172" marker-end="url(#l30-disc-arrow)"/>
<rect class="dd-card" x="32" y="60" width="232" height="104" rx="6"/>
<path class="dd-card-head" d="M33 66 Q33 61 38 61 H258 Q263 61 263 66 V92 H33 Z"/>
<text class="t-mono" x="48" y="81">--nominate-only</text>
<text class="t-name" x="48" y="111">扫热榜 → 15 个候选</text>
<line class="dd-hair" x1="33" y1="120" x2="263" y2="120"/>
<text class="t-small" x="48" y="137">discover-nominations.json</text>
<text class="t-small" x="48" y="154">bundle <tspan class="t-acc">17519f00500c772c</tspan></text>
<rect class="dd-card" x="284" y="60" width="232" height="104" rx="6"/>
<path class="dd-card-head" d="M285 66 Q285 61 290 61 H510 Q515 61 515 66 V92 H285 Z"/>
<text class="t-mono" x="300" y="81">--judgments judg.json</text>
<text class="t-name" x="300" y="111">每个候选跑一次完整研究</text>
<line class="dd-hair" x1="285" y1="120" x2="515" y2="120"/>
<text class="t-name" x="300" y="141">过置信度门槛：15 → 6</text>
<text class="t-small" x="300" y="157">discover-pending.json</text>
<rect class="dd-card" x="536" y="60" width="232" height="104" rx="6"/>
<path class="dd-card-head" d="M537 66 Q537 61 542 61 H762 Q767 61 767 66 V92 H537 Z"/>
<text class="t-mono" x="552" y="81">--finalize --angles</text>
<text class="t-name" x="552" y="111">写入角度，输出简报</text>
<line class="dd-hair" x1="537" y1="120" x2="767" y2="120"/>
<text class="t-name" x="552" y="141">记入话题队列</text>
<text class="t-small" x="552" y="157">下次再出现会标“第 N 次”</text>
<rect class="dd-card" x="224" y="232" width="232" height="112" rx="6"/>
<path class="dd-card-head" d="M225 238 Q225 233 230 233 H450 Q455 233 455 238 V264 H225 Z"/>
<text class="t-name" x="240" y="253">judg.json</text>
<text class="t-small" x="440" y="253" text-anchor="end"><tspan class="t-acc">17519f00…</tspan></text>
<text class="t-small" x="240" y="282">n2  "eMudhra CA compromise"</text>
<line class="dd-hair" x1="225" y1="290" x2="455" y2="290"/>
<text class="t-small" x="240" y="308">n10 junk: true</text>
<line class="dd-hair" x1="225" y1="316" x2="455" y2="316"/>
<text class="t-small" x="240" y="334">n9  worthiness: 85</text>
<rect class="dd-card" x="494" y="232" width="232" height="112" rx="6"/>
<path class="dd-card-head" d="M495 238 Q495 233 500 233 H720 Q725 233 725 238 V264 H495 Z"/>
<text class="t-name" x="510" y="253">angles.json</text>
<text class="t-small" x="710" y="253" text-anchor="end"><tspan class="t-acc">17519f00…</tspan></text>
<text class="t-small" x="510" y="282">podcast    一句话的播客切入点</text>
<line class="dd-hair" x1="495" y1="290" x2="725" y2="290"/>
<text class="t-small" x="510" y="308">x_article  一句话的长文切入点</text>
<text class="t-say" x="32" y="268">状态只在文件里；</text>
<text class="t-say" x="32" y="288">bundle_id 对不上</text>
<text class="t-say" x="32" y="308">直接报错，不重跑</text>
</svg>
</div>
<figcaption>图 7 · 引擎每段跑完就退出，模型在两段之间写判断，靠同一个 bundle_id 接上</figcaption>
</figure>

第一段，引擎扫热榜，把候选和它们的证据写进 `discover-nominations.json`，然后退出。模型读这个文件，给每个候选写三样东西：一个能拿去搜的名字、是不是垃圾、0–100 的值得度。第二段，引擎读回判断，对每个候选跑一次完整研究，补齐评论和其他来源，再过一道置信度门槛：要么有独立的跨来源印证，要么单一来源热得非常明显。第三段，模型写播客和长文的切入角度，引擎输出简报。

三段之间靠文件交接，每个文件都要带同一个 bundle_id，还有过期时间。这样模型在中间想多久都行，引擎也不用常驻；对不上号就报错让模型改 id，而不是把最贵的第二段重跑一遍[^concepts]。

### 实际跑一次：我当裁判

在容器里只有 HN 的热榜可用，第一段给出 15 个候选，里面有 3 条在讲同一件事（eMudhra 证书机构的私钥泄露），也有 “PHP 8.2.35” 这样的版本发布和 “Things to know when you get cancer” 这样的个人文章。我按协议写了判断：其中两条 eMudhra 起同一个名字，PHP 和那篇个人文章标为垃圾，其余打分。第二段里，引擎又把第三条 eMudhra 按共享的证据并了进来。第二段跑了 14.6 秒，15 个剩下 6 个。

和不走协议的启发式结果放在一起：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="l30-disc-names-title">同一批热榜，启发式命名和模型裁判的结果</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="l30-disc-names-title">
<colgroup><col style="width:10%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col" class="c">名次</th><th scope="col">一次性 --discover（启发式）</th><th scope="col" class="acc">三段协议（我当裁判）</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="c">1</th><td class="mono">eMudhra SSL/TLS</td><td class="mono acc">eMudhra CA compromise（3 条候选合并）</td></tr>
<tr><th scope="row" class="c">2</th><td class="mono">DeepInfer High-performance DeepSeek models</td><td class="mono">DeepInfer DeepSeek inference</td></tr>
<tr><th scope="row" class="c">3</th><td class="mono">iPhone</td><td class="mono">Kimi K3.1 Pro</td></tr>
<tr><th scope="row" class="c">4</th><td class="mono">Tesla Roadster</td><td class="mono warn">iPhone hacked at Pwn2Own</td></tr>
<tr><th scope="row" class="c">5</th><td class="mono">Quad9 to exit France</td><td class="mono">Quad9 exits France</td></tr>
<tr><th scope="row" class="c">6</th><td class="mono bad">Security Critical</td><td class="mono">Gemini 4 Argon</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“Security Critical” 是从标题 Security advisory: Critical remote code execution… 里机械截出来的两个词，拿去当查询很难对上原来那件事。</p>
</div>
<figcaption>图 8 · 模型裁判起的名字能直接拿去搜，也会把自己的知识带进来</figcaption>
</figure>

裁判的好处很明显：名字能直接作为下一次 `/last30days "<话题>"` 的查询，同一件事被合并，值得度把 Kimi 和 Gemini 两个模型发布挤进了前六。

但第 4 行要说明一下：我起的名字里有 “Pwn2Own”，可这个词不在任何一条证据的标题里，是我自己的知识，标题只写了 “Today, we hacked what we'd set out to hack: the iPhone”。引擎检查 bundle_id 和格式、清洗名字里的怪字符，但核对不了名字是不是忠于证据。另外，第 2 名的 “confirmed across 2 sources” 里，第二个来源是一个招聘页面，算不上独立印证。让模型当裁判，判断就带上了模型的偏差，引擎这一侧能做的只是把证据门槛定成绝对的。

## 七、它的边界

最后说几处这个设计绕不过去的地方。

**数据获取是最脆的一环。** 引擎的大部分代码在跟平台的接口和封锁较劲。同一套代码，在家用宽带和浏览器 cookie 下能拿到 X、Reddit、YouTube，在我的数据中心容器里只剩 HN 和 Polymarket；Reddit 的 `.json` 说关就关了。用浏览器 cookie 去读平台内容，也要用户自己掂量是否符合各平台的条款，SKILL.md 规定读 cookie 前必须先问用户[^skill-md]。

**热度不等于正确。** 打分奖励的是“很多人参与”，一条 500 分的帖子可能是一场争吵；30 天窗口天然偏向新闻和新品，对需要长期积累的问题帮助有限。项目在 SKILL.md 和引擎输出里反复强调，证据里的标题、评论、字幕都是不可信的输入，只能当数据读，不能当指令。

**规则会一直长。** 每一条 LAW、每一个“named failure mode”都对应一次模型做错的事。把它们拆进 references 能控制每次读多少，但规则本身只会越来越多。这是把行为写成自然语言契约的代价：引擎的 bug 能写测试，模型不读第 647 行只能靠把规则挪到更前面。

## 小结

1. **一个 Skill 为什么带 7 万行代码？** Skill 是给模型的说明书加工具。last30days 让模型负责规划和成文，把抓取、打分、融合交给 Python 引擎，而大部分代码花在了对付十几个平台的接口和封锁上。
2. **没有密钥能拿到什么？** HN 和 Polymarket 稳定；Reddit 在数据中心 IP 上被挡，但模型先说出 subreddit，引擎就能绕到 Arctic Shift 拿到帖子；失败要和“没有讨论”分开报告。
3. **不同平台怎么排在一起？** 互动数取对数，除以每个平台“很高”的参考值得到绝对的 0–100 热度；多个结果流用 RRF 按名次合并；最终分里相关性占 60%，热度最多 15 分。没有 LLM 时相关性只是词重叠，精排留给读证据的模型。
4. **引擎的输出为什么写给模型？** 它是中间材料。SKILL.md 的 11 条 LAW 约束模型怎么规划、怎么成文，每条都对应一次事故；要紧的规则还会在引擎输出里再写一遍。
5. **发现模式怎么让模型当裁判？** 拆成三个命令，引擎每段跑完退出，模型在中间写判断文件，用 bundle_id 接上。名字更好用了，也带进了模型自己的知识。

想自己看看引擎吐给模型的是什么，可以不装进宿主，直接在仓库里跑：

```bash
git clone https://github.com/mvanhorn/last30days-skill
cd last30days-skill
python3 skills/last30days/scripts/last30days.py --diagnose            # 看哪些来源可用
python3 skills/last30days/scripts/last30days.py "你的主题" \
  --subreddits 相关版块 --emit=compact --save-dir ""                   # 证据信封，不落盘
```

装进 Claude Code 用的是 `/plugin marketplace add mvanhorn/last30days-skill`，那时第一段和第三段由 Claude 自己来做。

## 参考资料
