一条三天前的爆款,把首页变成了旧闻墙
「回声」的首页推荐在周三做了一次小改动:热度权重从 0.3 提到 0.7,时间权重从 0.5 降到 0.2。改动本身只有两个数字,评审时所有人都觉得“让用户先看到好内容”没有错。
周四上午,运营在群里贴了一张截图:首页前十条里有七条来自三天前的一篇爆款,作者是同一个账号,内容是同一件事的连续更新。当天新发的二十多条内容,只有一条挤进前二十。新人作者的曝光量在案例假设值下从每天 1200 次掉到 260 次,客服开始收到“发了没人看”的反馈。
更麻烦的是,团队最初把这当成“推荐不准”,又去调召回和特征。真正的问题在排序:当热度是一个累计值、时间只占很小权重时,过去积累的互动会一直压过今天的内容。排序函数对“老”没有惩罚,只对“新”有奖励,而且奖励太弱。
三个信号各自只看一个方向
排序不是把“好内容”挑出来,而是把几种互相冲突的目标压成一个分数。每加一个信号,就同时引入它看不见的东西。
| 信号 | 它在奖励什么 | 它看不见什么 | 单独使用时的偏差 | 常见的错误用法 |
|---|---|---|---|---|
| 时间 | 新鲜、及时 | 内容是否值得看 | 首页变成时间线,旧的好内容永远沉底 | 只看发布时间,不看用户是否已读 |
| 热度 | 多数人已经认可 | 小圈子、新作者、长尾 | 老爆款长期霸榜,马太效应 | 用累计点赞而不是单位时间增速 |
| 亲和度 | 用户与作者的历史互动 | 探索新作者、公共议题 | 信息茧房,熟人内容反复出现 | 把关注关系直接当亲和度,不做衰减 |
| 多样性约束 | 作者与话题的分布 | 单条内容的质量 | 为凑比例塞入低质内容 | 理解成随机打散,破坏相关性 |
时间信号的价值不只是“新”,而是让排序有机会重新洗牌。一个三天前的爆款如果仍然值得看,它应该靠热度或亲和度回来,而不是靠时间权重被动保留。时间衰减函数的选择会影响“半衰期”:1/(1+AgeHours) 在 1 小时处剩一半,24 小时处只剩约 4%,适合小时级刷新的信息流;如果是周更的深度内容,这个衰减就太快了。
热度信号最容易骗人,因为点赞是累计的。一篇昨天发布的文章和一个小时前发布的文章,如果都显示 500 赞,后者显然更热。正确的热度通常要看增量:过去一小时新增多少互动、曝光转化率是多少。直接用累计值,等于把“发布时间早”又奖励了一遍。
亲和度信号解决“我关心的人发了什么”,但它天然排外。用户越常互动,系统越确定他喜欢什么,也越少给他没见过的东西。亲和度需要和探索预算一起设计:比如首页固定留出两成位置给非关注作者,而不是让亲和度权重无限增大。
多样性不是随机打散。随机打散会让排序结果不可解释,也会把低质内容硬塞进首页。可执行的多样性是约束:同一个作者在首页前 N 条里最多出现 K 次,同一个话题最多占多少比例,超出部分往后挪。它改变的是分布,不是单条得分。
失败场景:热度权重 0.7,新内容永远起不来
把权重设成时间 0.2、热度 0.7、评论 0.05、亲和度 0.05,看起来时间仍然有一席之地。但热度用的是累计点赞,量级是几十到几千;时间项最大只有 0.2。一条 500 赞的旧内容,热度项就有 350 分,而一条刚发布、0 赞的新内容时间项最多 0.2 分。新内容除非在很短时间内爆发出大量互动,否则在排序里没有任何机会。
这个结构会自我强化。旧内容排在前面,获得更多曝光,继续积累点赞;新内容排在后面,曝光少,互动更少,下一轮排序更低。案例假设值下,一周后新人作者的次日留存下降 9%,内容发布量下降 18%。团队看到的是“供给变少”,于是加大创作者激励,但激励来的内容仍然排不上去。
修复不是简单把热度权重调回去。更稳的做法是三步:第一,热度改用时间窗口内的增量,比如过去 6 小时的点赞加评论,让“老”自然衰减;第二,时间项保留一个下限,保证新内容至少能被抽样展示;第三,加作者维度的多样性约束,同一作者在首页前 20 条最多 3 条,超出的降权而不是删除。
还要区分“排序”和“过滤”。已经读过的内容、同一事件的重复报道、明显低质的内容,应该在排序前处理,而不是靠权重把它们压下去。排序函数只负责在候选集里排先后,候选集本身的质量决定了上限。
用 Score 与 Sort 把权重和衰减写死
下面的实现来自 webapp-lab 的 internal/rank 包。Score 只做一件事:把一条内容和一组权重算成一个分数,并在输入不合法时明确报错。Sort 在副本上排序,保证调用方拿到的顺序变化不会污染缓存里的原切片。
时间衰减固定为 1/(1+AgeHours),不引入可调参数。这样做的代价是灵活性,好处是测试可以精确断言:0 小时得 1,1 小时得 0.5,3 小时得 0.25。权重必须全部非负且总和大于 0,负数点赞和负数评论直接报错,避免脏数据被静默算成一个看似正常的低分。
// file: internal/rank/score.go
// tier: L1
// verify: go test ./internal/rank/
package rank
import (
"fmt"
"sort"
)
// Weights 是四类信号在最终得分中的权重。
type Weights struct {
Recency float64
Likes float64
Comments float64
Affinity float64
}
// Item 是排序的最小输入,不依赖存储层。
type Item struct {
ID string
AuthorID string
AgeHours float64
Likes int
Comments int
Affinity float64
}
// Score 计算单条内容的得分。时间使用 1/(1+AgeHours) 衰减。
func Score(i Item, w Weights) (float64, error) {
if w.Recency < 0 || w.Likes < 0 || w.Comments < 0 || w.Affinity < 0 {
return 0, fmt.Errorf("weights must be >= 0: %+v", w)
}
if w.Recency+w.Likes+w.Comments+w.Affinity <= 0 {
return 0, fmt.Errorf("weights must sum to a positive value")
}
if i.AgeHours < 0 {
return 0, fmt.Errorf("item %q age hours must be >= 0, got %f", i.ID, i.AgeHours)
}
if i.Likes < 0 {
return 0, fmt.Errorf("item %q likes must be >= 0, got %d", i.ID, i.Likes)
}
if i.Comments < 0 {
return 0, fmt.Errorf("item %q comments must be >= 0, got %d", i.ID, i.Comments)
}
recency := 1 / (1 + i.AgeHours)
return w.Recency*recency +
w.Likes*float64(i.Likes) +
w.Comments*float64(i.Comments) +
w.Affinity*i.Affinity, nil
}
// Sort 返回按得分从高到低排列的副本,不修改入参。
func Sort(items []Item, w Weights) ([]Item, error) {
out := make([]Item, len(items))
copy(out, items)
scores := make([]float64, len(out))
for idx, it := range out {
s, err := Score(it, w)
if err != nil {
return nil, err
}
scores[idx] = s
}
// 先算分再排序,避免比较函数里重复计算或吞掉错误。
order := make([]int, len(out))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool {
return scores[order[a]] > scores[order[b]]
})
sorted := make([]Item, len(out))
for i, idx := range order {
sorted[i] = out[idx]
}
return sorted, nil
}
Sort 先为每条内容算分,再用稳定排序按分数从高到低重排索引。稳定排序在这里不是细节:分数相同的两条内容应该保持候选集原来的顺序,否则同一批请求可能因为排序不稳定而抖动,用户刷新一次就看到完全不同的首页。
返回副本而不是原地排序,是为了让上游可以安全地复用候选切片。如果调用方在多个实验组之间共享同一份候选集,原地排序会让后一个实验组拿到已经被前一个实验组打乱的数据,实验结论也就不可信了。
测试:权重、负数与稳定排序
排序逻辑的 bug 往往不在主路径,而在边界:权重全是 0、点赞是负数、两条内容分数相同。下面的测试把这些情况固定下来。
// file: internal/rank/score_test.go
// tier: L1
// verify: go test ./internal/rank/
package rank
import (
"math"
"testing"
)
func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
func TestScoreCombinesSignals(t *testing.T) {
item := Item{
ID: "p1",
AuthorID: "u1",
AgeHours: 1,
Likes: 10,
Comments: 2,
Affinity: 0.8,
}
w := Weights{Recency: 0.4, Likes: 0.3, Comments: 0.2, Affinity: 0.1}
got, err := Score(item, w)
if err != nil {
t.Fatalf("Score returned error: %v", err)
}
// 0.4*0.5 + 0.3*10 + 0.2*2 + 0.1*0.8 = 3.68
if !almostEqual(got, 3.68) {
t.Fatalf("Score = %f, want 3.68", got)
}
}
func TestScoreDecaysWithAge(t *testing.T) {
w := Weights{Recency: 1}
fresh, err := Score(Item{ID: "fresh", AgeHours: 0}, w)
if err != nil {
t.Fatalf("Score returned error: %v", err)
}
old, err := Score(Item{ID: "old", AgeHours: 3}, w)
if err != nil {
t.Fatalf("Score returned error: %v", err)
}
if !almostEqual(fresh, 1) {
t.Fatalf("fresh score = %f, want 1", fresh)
}
if !almostEqual(old, 0.25) {
t.Fatalf("old score = %f, want 0.25", old)
}
}
func TestScoreRejectsNegativeWeights(t *testing.T) {
_, err := Score(Item{ID: "p1"}, Weights{Recency: 1, Likes: -0.1})
if err == nil {
t.Fatal("expected an error for a negative weight")
}
}
func TestScoreRejectsZeroWeights(t *testing.T) {
_, err := Score(Item{ID: "p1"}, Weights{})
if err == nil {
t.Fatal("expected an error when every weight is zero")
}
}
func TestScoreRejectsNegativeCounts(t *testing.T) {
w := Weights{Likes: 1}
if _, err := Score(Item{ID: "p1", Likes: -1}, w); err == nil {
t.Fatal("expected an error for negative likes")
}
if _, err := Score(Item{ID: "p1", Comments: -1}, w); err == nil {
t.Fatal("expected an error for negative comments")
}
}
func TestScoreRejectsNegativeAge(t *testing.T) {
if _, err := Score(Item{ID: "p1", AgeHours: -0.5}, Weights{Recency: 1}); err == nil {
t.Fatal("expected an error for a negative age")
}
}
func TestSortDescendingAndStable(t *testing.T) {
items := []Item{
{ID: "low", AgeHours: 10, Likes: 1},
{ID: "tie-a", AgeHours: 0, Likes: 5},
{ID: "high", AgeHours: 0, Likes: 9},
{ID: "tie-b", AgeHours: 0, Likes: 5},
}
original := make([]Item, len(items))
copy(original, items)
got, err := Sort(items, Weights{Likes: 1})
if err != nil {
t.Fatalf("Sort returned error: %v", err)
}
want := []string{"high", "tie-a", "tie-b", "low"}
for i, id := range want {
if got[i].ID != id {
t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
}
}
for i := range items {
if items[i] != original[i] {
t.Fatalf("Sort modified the input at position %d", i)
}
}
}
func TestSortPropagatesScoreError(t *testing.T) {
items := []Item{
{ID: "ok", Likes: 1},
{ID: "bad", Likes: -1},
}
if _, err := Sort(items, Weights{Likes: 1}); err == nil {
t.Fatal("expected the invalid item to fail the sort")
}
}
func TestSortEmpty(t *testing.T) {
got, err := Sort(nil, Weights{Recency: 1})
if err != nil {
t.Fatalf("Sort returned error: %v", err)
}
if len(got) != 0 {
t.Fatalf("len = %d, want 0", len(got))
}
}
TestScoreCombinesSignals 用一组手工可算的输入验证四项相加,结果是 3.68。这个用例的价值在于它同时覆盖了时间衰减和三个加权项,任何一项写错都会让结果偏离。
TestSortDescendingAndStable 构造了两条同分内容 tie-a 和 tie-b,要求它们保持输入顺序;同时检查原切片没有被修改。排序稳定性和“不修改入参”是产品层面能感知的性质:前者影响刷新抖动,后者影响多个实验组共享候选集时的正确性。
TestScoreRejectsNegativeCounts 和 TestScoreRejectsNegativeAge 覆盖脏数据。点赞数在数据库里可能是负数(回滚、补偿、迁移错误),如果排序函数默认接受,它会安静地把内容压到后面,排查时很难发现根因。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/rank/ -count=1 -run 'TestScore|TestSort' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestScoreCombinesSignals
--- PASS: TestScoreCombinesSignals (0.00s)
=== RUN TestScoreDecaysWithAge
--- PASS: TestScoreDecaysWithAge (0.00s)
=== RUN TestScoreRejectsNegativeWeights
--- PASS: TestScoreRejectsNegativeWeights (0.00s)
=== RUN TestScoreRejectsZeroWeights
--- PASS: TestScoreRejectsZeroWeights (0.00s)
=== RUN TestScoreRejectsNegativeCounts
--- PASS: TestScoreRejectsNegativeCounts (0.00s)
=== RUN TestScoreRejectsNegativeAge
--- PASS: TestScoreRejectsNegativeAge (0.00s)
=== RUN TestSortDescendingAndStable
--- PASS: TestSortDescendingAndStable (0.00s)
=== RUN TestSortPropagatesScoreError
--- PASS: TestSortPropagatesScoreError (0.00s)
=== RUN TestSortEmpty
--- PASS: TestSortEmpty (0.00s)
PASS
ok webapp/internal/rank 0.293s
-run 的正则同时匹配得分计算和排序两组测试。最后的 ok webapp/internal/rank 表示整个包在当前筛选下通过;耗时只代表这次本机运行,不能当成性能结论。
小结
时间、热度、亲和度各自只看见一个方向。时间让新内容有机会,热度奖励已经被验证的内容,亲和度照顾用户已有关系,多样性约束防止任何一类内容占满首页。把它们压成一个分数时,权重的量级比权重的比例更重要:一个累计点赞项可以轻易压过时间项,哪怕时间权重看起来并不小。
Score 把衰减和校验写死,Sort 保证稳定且不修改入参。代码本身不复杂,难的是承认“热度高”不等于“现在该看”,以及给新内容留出结构性的位置,而不是指望调一个权重就能解决。
阅读导航:上一节:目录与学习路径 · 下一节:4.2 去重、折叠与已读 。
常见错误与边界
- 用累计点赞当热度。发布时间越早,累计值越大,等于把“老”又奖励了一遍。
- 只调权重比例,不看各项量级。时间项最大是 1,点赞项可能是几百,比例相同不代表影响相同。
- 把多样性理解成随机打散。随机化会破坏相关性,应该用作者和话题的占比约束。
- 认为时间权重小一点没关系。只要热度是累计值,新内容仍然会被结构性地压住。
- 在排序函数里做过滤。已读、重复、低质应该在候选集阶段处理,排序只负责排先后。
- 原地排序共享候选集。多个实验组会互相污染,结论无法归因。
- 接受负数计数并静默计算。脏数据会表现成“内容质量差”,而不是“输入错误”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。