← 返回文章选择

AI Reader 的 37 分是怎么来的

一次评分失败怎样写进数据库、被缓存固化,又怎样被误当成正常分数。

文章的技术价值、商业价值、时效性等维度全部相同,看着很不对劲。比如总分 37,每一项也是 37,很难相信模型真的分别判断过。回看 5 月 14 日那版评分代码,原因清楚了:这些数字可以来自长度兜底,根本没经过正常的模型评分。

37 分只由字数决定

模型请求或 JSON 解析抛异常时,程序进入 _baseline_payload()。它把标题和正文合起来,每 50 个字符算一分,再把同一个值复制给七个维度。主要计算如下:

combined = (title + " " + content).strip()
raw_score = min(100, len(combined) // 50)
dimension_scores = {key: raw_score for key in dimension_keys}

所以 37 不是写死的默认值:合并文本长度在 1850 到 1899 个字符之间,除以 50 取整都是 37。别的长度有别的分数,随长度往上涨,到 5000 个字符封顶 100,但各维度始终相同,因为本来就是同一个数复制过去的。

兜底 payload 也不是空的。它从标题里挑 4 个字符以上的词当 tags,把 length= 和 hash= 写进 reason,再按 min(1.0, 长度 / 500) 给一个 confidence。一行兜底记录其实写明了“我是按长度算的”,只是字段齐全,看上去和正常评分没两样。

进兜底还有一条更早的路:标题和正文都为空时,代码根本不调模型,直接返回兜底,error_message 写 title and content are both empty,分数为 0。

失败记录被当成了成绩

payload 里其实有 model_provider="baseline"、scoring_status="error" 和错误信息,可主流程还是把它交给 upsert_score(),返回 ok: true。从接口看,请求处理完了;从数据库看,确实有一行结果;但模型评分失败了。几种“成功”挤在一起,前端如果只读 score,就会把错误记录当普通成绩排进列表。

模型解析失败后,长度兜底如何被当成正常评分

文字版
模型返回无法解析
→ 生成长度兜底 payload,标记 error
→ 写入数据库
→ 请求返回 ok
→ 读取方若忽略状态,就显示兜底分

历史 scoring.py(7df57e9 版本)里,错误信息没有丢,丢的是展示时对状态的区分。所以查这个问题,不该先改评分标准,而应先写明哪些记录有资格参与排序。

表结构也在往“看起来正常”那边偏。item_scores 里 model_provider 默认 baseline,model_name 默认 length-baseline,scoring_status 却默认 success。少写一个字段,落库就是一行“兜底身份、成功状态”的记录。

兜底分不会自己消失

更麻烦的是,这行 37 分会一直留着。表的唯一键是 (tenant_id, miniflux_entry_id, content_hash, model_version),兜底行的 model_version 是 0.1.0,模型评分行是 minimax:MiniMax-M2.7:rss-score-v2。两者键不同,后来的正常评分是另起一行,覆盖不掉那行兜底分。

PostgreSQL 文档对 ON CONFLICT DO UPDATE 的保证是原子的插入或更新,前提是撞上同一个 conflict target。所以“重评同一篇不会写重复行”只在同一个键内成立;换了模型版本或正文变了,就是新行。

缓存又把失败固化了一层。force=false 时服务先查 get_latest_score(),命中就返回 {"ok": True, "cached": True},连模型都不调。这个查询按 scored_at DESC 取最新一行,没有任何 scoring_status 条件。一次模型失败之后,这篇在缓存眼里就是“已经评过”,之后每次请求拿到的都是同一个兜底分。

webhook 走的正是 force=false。唯一绕过缓存的是阅读页的“实时评分”按钮,请求体不写 force 时按 true 处理。整套流程里,能把一篇救回来的只有这个按钮。

所以改评分代码清不掉历史上那行,只能在读取侧把它排除。

清理格式和过滤状态,两处都要

<think> 清理和 JSON 对象提取在 5 月 13 日已经加进评分代码;5 月 14 日又在读取侧补上过滤,读取正常评分的查询要求 scoring_status = 'success',并排除 model_provider = 'baseline'。这些改动和本文回看的五月中旬那版事件评分服务处在同一阶段。

清理返回格式能减少解析错误,查询条件则保证仍然失败的记录不会混成普通分数。两条条件是这个查询的准入过滤:

  • 状态不是 success,这条记录不能作为成功评分展示;
  • provider 是 baseline,说明分数由长度兜底算出,不是模型给的。

读取方据此只取已记录为成功的模型评分,不再凭有没有分数来判断。

取分用的是 DISTINCT ON (miniflux_entry_id) 配 scored_at DESC,每篇挑最新一行。过滤条件得写在挑最新值之前,否则最新那行恰好是兜底时,就会被选中再丢掉,这篇反而没分。

格式这一半当时也没有更硬的办法。MiniMax 的 Chat Completions 文档参数表里没有 response_format 这类 JSON 模式,最接近的是 tools 加 JSON Schema。所以只能在系统提示词里要求严格 JSON,再靠清理和提取兜住。换成 tools,代价是要处理 tool_calls 那套结构。

事件驱动以后,状态更要分清

5 月 14 日的版本已经改成内部 HTTP 服务,接单篇评分(可强制重评)和 Miniflux 新文章事件,没有后台轮询。服务源码开头的 docstring 写得很直接:

Scoring is event-driven. There is no background polling loop.

同一份 docstring 列了三个路由:/healthz、/internal/score-entry、/webhooks/miniflux。上一版是定时循环:启动先抓一轮,然后 while True 睡 SCORER_INTERVAL_SECONDS 再来一轮,默认查最近 300 篇、不分状态。改成事件驱动的代价是,只有被通知到的新文章才会评分,不再定期把全部兜一遍。

事件更及时,状态区别反而更重要:收到 webhook 只说明通知到了;任务写回了,还要看模型结果是否成功。Miniflux webhook 失败后不会自动重试,有了事件入口,也不能保证每篇都会完成。

再看 webhook 这端具体做了什么:只认 X-Miniflux-Event-Type: new_entries,跳过非 unread 的条目,单次最多处理 min(100, webhook_max_entries) 篇(设置默认 20),响应码固定 202。循环里每篇的评分结果直接丢掉,返回的 processed 只表示尝试处理了几篇。官方一共也只定义了两种事件:刷新时发现新条目,和用户收藏。

这里返回 202 本身没错。MDN 对 202 的说明讲得清楚:请求被接受,不保证处理成功,之后也没有异步回执的通道。问题是没人据此回头查结果。

调用失败后也没有第二次机会。模型客户端是一次 httpx.post(...),超时 30 秒,超时就直接落兜底。httpx 的重试要在 transport 层显式打开,比如 httpx.HTTPTransport(retries=1),而且只覆盖连接错误和连接超时。要做真正的重试和退避,得自己处理重复调用和计费。

入口的认证也偏省事。服务只做了 Basic 认证,没校验 Miniflux 文档里放在 X-Miniflux-Signature 头的 HMAC 签名。Basic 只证明请求方知道账号密码,不证明请求体来自 Miniflux。代价是:拿到 Basic 凭据(或凭据泄露)的请求,就能伪造 new_entries,让服务去花模型额度。

排查一篇没分数的文章,可以按这个顺序:

  1. 有没有触发;
  2. 调用是否失败;
  3. 格式是否合格;
  4. 数据库里有没有正常结果。

临时兜底仍可以帮着记录错误,但不该再用一个平平无奇的 37 分,把失败藏进正常列表。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。