《互联网产品的客户端架构·内容、社交与实时互动》12.3 发现页的推荐位与内容流的差异

发现页不是换皮的信息流:运营位、榜单、专题与普通内容来自不同契约。本节用纯函数组合器实现固定槽位、跨源去重、占位与曝光 key,讲清位置为什么不能塌陷、去重优先级如何显式表达,给出可复用的布局校验规则,并复盘一次运营位为空导致整页错位的事故。

发现页不是换皮的信息流

发现页乍看也是一列内容卡片,于是很多团队直接复用了信息流的窗口、分页和刷新逻辑。上线后问题集中爆发:运营配置的焦点图没下发时,下面的榜单整体上移,用户点错位置;同一篇内容既出现在焦点图又在信息流里;曝光报表里同一个位置被记了两次。根因不是列表组件写得不好,而是把几种契约完全不同的内容源当成了同一种。

本节只讨论客户端如何组合多个来源:哪些位置是固定的、来源之间如何去重、曝光事件以什么为身份、信息流取尽后页面怎么收尾。它不讨论推荐算法如何打分,也不讨论广告投放与计费,更不讨论搜索结果的分页与筛选(12.2)。我们假定每个来源都已经按自己的规则返回了一段有序内容。

四类来源,四种契约

来源顺序由谁决定更新频率为空时意味着什么位置约束
信息流 feed推荐或时间排序高,可持续翻页页面可以提前结束填充剩余位置
运营位 promotion运营配置低,按活动切换活动未开始或已结束固定在声明位置
榜单 ranking榜单规则与统计周期中,按小时或天统计未产出固定块,通常连续多格
专题 topic编辑编排低,人工维护专题下线固定块

四类来源的「空」含义不同。信息流为空只说明这次没取到更多,页面可以结束;运营位为空是一个需要被记录的业务状态,不能悄悄让后面的内容顶上来;榜单为空可能只是统计任务还没跑完,界面应该保留榜单骨架或占位;专题为空则通常意味着配置被撤下。

如果客户端把它们都归一成 ContentItem[],再 flatMap 成一个数组,位置约束和空语义会同时丢失。正确做法是保留来源信息,在组合层决定谁占哪个位置。

固定槽位:位置是产品契约

运营位和榜单的位置不是渲染细节,而是产品契约。运营同学按「首屏第一张」「榜单第三位」售卖或配置资源;用户也按固定位置形成肌肉记忆。位置一旦因为上游返回为空而塌陷,后面的卡片整体上移,点击率和曝光归因都会失真。

所以固定槽位要「先占位,再取内容」:布局先声明 position 与 limit,组合器先把这些位置标记为已保留,再尝试填内容。来源不足时,缺的位置输出 item: null 的占位,而不是把后面的信息流拉上来。

interface FixedSlot {
  slotId: string;
  source: "promotion" | "ranking" | "topic";
  position: number;
  limit: number;
}

占位不是「渲染一张空白卡片」这么简单。它至少要满足三件事:位置仍然被占用,曝光系统知道这里没有内容,业务监控能统计到「运营位为空」的次数。把 null 直接过滤掉,等于把业务异常藏进了渲染层。

跨源去重:优先级必须显式

同一篇内容完全可能同时出现在运营位、榜单和信息流里。运营把一篇爆款放上焦点图,推荐系统也会把它推给用户。如果不去重,用户会在同一屏看到两次;如果去重但优先级不明确,焦点图可能被信息流「抢走」,运营配置失效。

去重键仍然用服务端 id,不用标题、作者或内容哈希。优先级按位置显式定义:位置靠前的固定槽位先取,信息流最后取。固定槽位之间按 position 升序处理,同一个来源被多个槽位消费时共享一个游标,避免两个榜单块取到同一批内容。

被跳过的内容要计数,而不是静默丢弃。duplicates 指标能回答两个问题:运营配置与推荐结果的重合度有多高,以及某个来源是否在重复下发同一批内容。没有这个计数,去重逻辑出问题时只能靠用户投诉发现。

曝光语义:key 不能带位置

曝光上报需要一个稳定身份。常见的错误 key 有两种:

  • 只用内容 id:同一篇内容在榜单和信息流里各曝光一次,报表无法区分是哪个位置带来的。
  • 用数组下标:榜单从第 2 位换到第 3 位,同一个位置被记成两次曝光,内容本身却没变。

正确的 key 由「槽位 id + 内容 id」组成,例如 top-rank:r1 或 feed:f1。位置变化不影响 key,槽位变化才影响 key。这样排行榜内部换序不会重复计数,而同一篇内容从榜单进入信息流时,会被视为一次新的、不同场景的曝光。

占位没有曝光 key。它应该上报的是「槽位为空」的业务事件,而不是一条内容曝光。把两者混在同一个字段里,报表会出现「内容 id 为空但曝光量很高」的脏数据。

组合多个来源而不串位

组合器的职责可以拆成四步:

  1. 合并同一来源的多个分页,保留来源标签。
  2. 按 position 处理固定槽位,先占位,再按优先级取内容。
  3. 用剩余位置填充信息流,跳过已使用的 id。
  4. 按 position 排序输出,渲染层按位置放置,不按数组下标猜位置。

第四步经常被忽略。组合结果可能因为信息流提前取尽而出现「位置不连续」:固定槽位在 0 和 6,信息流只够填到 2,位置 6 的专题仍然应该输出。渲染层如果按数组下标渲染,会把专题放到第 3 格;按 position 渲染,才能保持声明的位置。

这也解释了为什么组合结果不适合直接塞进虚拟列表的「连续数组」假设。虚拟列表可以按位置建索引,但空位和固定槽位要作为显式条目存在,而不是被压缩掉。

一个可运行的发现页组合器

下面的 discover.ts 属于 L1:输入是布局声明和各来源的条目,输出是带位置、槽位与曝光 key 的条目列表。它不关心网络与渲染,只保证固定位置、跨源去重和空槽位语义。

// file: src/search/discover.ts
// tier: L1
// verify: node --test test/search/discover.test.ts
import type { ContentItem } from "../core/content-model.ts";

export type DiscoverSource = "feed" | "promotion" | "ranking" | "topic";
export type FixedSource = Exclude<DiscoverSource, "feed">;

export interface SourcePage {
  source: DiscoverSource;
  items: ContentItem[];
}

export interface FixedSlot {
  slotId: string;
  source: FixedSource;
  position: number;
  limit: number;
}

export interface DiscoverLayout {
  total: number;
  fixed: FixedSlot[];
}

export interface DiscoverEntry {
  position: number;
  slotId: string;
  source: DiscoverSource;
  item: ContentItem | null;
  impressionKey: string | null;
}

export interface DiscoverResult {
  entries: DiscoverEntry[];
  duplicates: number;
  placeholders: number;
}

export function exposureKey(slotId: string, itemId: string): string {
  return `${slotId}:${itemId}`;
}

export function validateLayout(layout: DiscoverLayout): void {
  if (!Number.isInteger(layout.total) || layout.total < 1) throw new Error("total 必须是正整数");
  const seen = new Set<string>();
  const slots = [...layout.fixed].sort((a, b) => a.position - b.position);
  let previousEnd = 0;
  for (const slot of slots) {
    if (slot.slotId.trim() === "") throw new Error("slotId 不能为空");
    if (seen.has(slot.slotId)) throw new Error(`slotId 重复:${slot.slotId}`);
    seen.add(slot.slotId);
    if (!Number.isInteger(slot.position) || slot.position < 0) throw new Error(`槽位 ${slot.slotId} 的 position 非法`);
    if (!Number.isInteger(slot.limit) || slot.limit < 1) throw new Error(`槽位 ${slot.slotId} 的 limit 非法`);
    if (slot.position + slot.limit > layout.total) throw new Error(`槽位 ${slot.slotId} 超出页面范围`);
    if (slot.position < previousEnd) throw new Error(`槽位 ${slot.slotId} 与前一个槽位重叠`);
    previousEnd = slot.position + slot.limit;
  }
}

export function composeDiscover(layout: DiscoverLayout, pages: SourcePage[]): DiscoverResult {
  validateLayout(layout);
  const bySource = new Map<DiscoverSource, ContentItem[]>();
  for (const page of pages) {
    const list = bySource.get(page.source) ?? [];
    list.push(...page.items);
    bySource.set(page.source, list);
  }

  const used = new Set<string>();
  const cursors = new Map<DiscoverSource, number>();
  let duplicates = 0;
  let placeholders = 0;

  const take = (source: DiscoverSource, limit: number): ContentItem[] => {
    const list = bySource.get(source) ?? [];
    let index = cursors.get(source) ?? 0;
    const picked: ContentItem[] = [];
    while (picked.length < limit && index < list.length) {
      const item = list[index];
      index += 1;
      if (used.has(item.id)) {
        duplicates += 1;
        continue;
      }
      used.add(item.id);
      picked.push(item);
    }
    cursors.set(source, index);
    return picked;
  };

  const reserved = new Set<number>();
  const entriesByPosition = new Map<number, DiscoverEntry>();
  const fixed = [...layout.fixed].sort((a, b) => a.position - b.position);
  for (const slot of fixed) {
    for (let offset = 0; offset < slot.limit; offset += 1) {
      const position = slot.position + offset;
      reserved.add(position);
      const [item] = take(slot.source, 1);
      if (!item) {
        placeholders += 1;
        entriesByPosition.set(position, {
          position,
          slotId: slot.slotId,
          source: slot.source,
          item: null,
          impressionKey: null,
        });
        continue;
      }
      entriesByPosition.set(position, {
        position,
        slotId: slot.slotId,
        source: slot.source,
        item,
        impressionKey: exposureKey(slot.slotId, item.id),
      });
    }
  }

  for (let position = 0; position < layout.total; position += 1) {
    if (reserved.has(position)) continue;
    const [item] = take("feed", 1);
    // 信息流取尽后页面到此结束;固定槽位仍按声明位置保留。
    if (!item) break;
    entriesByPosition.set(position, {
      position,
      slotId: "feed",
      source: "feed",
      item,
      impressionKey: exposureKey("feed", item.id),
    });
  }

  return {
    entries: [...entriesByPosition.values()].sort((a, b) => a.position - b.position),
    duplicates,
    placeholders,
  };
}

四个关键决策

先保留位置,再填内容

固定槽位的第一件事是把 position 加入 reserved,然后才调用 take。顺序反过来,信息流就可能先占掉运营位,之后再用占位覆盖,白白消耗一条内容并制造重复计数。先保留位置,信息流的循环天然只会看非保留位置。

同一来源共享游标

两个榜单块如果各自从索引 0 开始取,第二块会把第一块的内容当成重复跳过,最后全是占位。共享 cursors 后,第二个块从第一个块停下的位置继续取,来源内部的顺序和分页语义都得以保留。

信息流取尽不等于整页结束

固定槽位是独立来源,不应该被信息流的长度绑架。信息流取尽后,组合器停止填充普通位置,但已经声明的固定槽位仍然输出。页面可能在视觉上出现空档,这比让专题块漂移到错误位置更可接受;如果产品不接受空档,应该在布局层收缩 total,而不是在组合层压缩位置。

曝光 key 与位置解耦

exposureKey(slotId, itemId) 只接收两个身份,不接收下标。这个签名本身就是约束:调用方无法把位置偷偷塞进 key。位置变化、排序变化、分页变化都不应改变曝光身份;只有内容或槽位变化才应该改变。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/search/discover.test.ts
✔ 固定槽位内容不足时留占位,后续槽位位置不变 (0.997041ms)
✔ 跨源去重时位置靠前的槽位优先,信息流跳过已用内容 (0.136791ms)
✔ 信息流只填充未被固定槽位占用的位置 (0.468084ms)
✔ 同一来源内的重复 id 只取一次并计入去重 (0.1115ms)
✔ 曝光 key 与位置无关,与槽位和内容有关 (0.109416ms)
✔ 布局校验拒绝重叠、越界与重复槽位 (0.275417ms)
✔ 信息流取尽后页面结束,后面的固定槽位仍按声明位置输出 (0.080708ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 128.837833

七条用例覆盖了占位、跨源去重、保留位置、来源内去重、曝光 key、布局校验和稀疏页面。它们没有覆盖真实曝光上报链路,也没有覆盖运营后台的配置变更;那些需要端到端联调。组合器证明的是「给定来源,位置与身份如何计算」,不证明上游数据一定及时。

失败复盘:运营位空了一天,信息流整体上移

「回声」的发现页早期把四个来源合并成一个数组,过滤掉空值后直接渲染。某天运营活动在凌晨结束,焦点图接口返回空数组。组合结果从 21 条变成 20 条,所有位置前移一格:原本在第二位的榜单块跑到了第一位,榜单徽标被渲染到一条普通信息流卡片上;原本第三位的专题入口跑到了第二位,用户点进去发现是另一篇内容。

更麻烦的是曝光报表。上报 key 用的是数组下标 position:0、position:1,位置整体前移后,前一天与后一天的同一位置对应了不同内容,运营看到的「焦点图曝光」其实混进了榜单和信息流。产品、运营、数据三个团队花了一天才对齐口径。

修复分三步。第一,固定槽位先占位,空来源输出 item: null,让信息流不再上移。第二,曝光 key 改为 slotId:itemId,位置变化不再影响身份。第三,跨源去重按位置优先级执行,同一个 id 只保留最靠前的槽位,并在日志里记录 duplicates。

第二个事故来自去重缺失。一篇爆款同时被运营放上焦点图、被榜单收录、又被推荐进信息流,用户在首屏看到三次。修复后焦点图优先,榜单与信息流依次跳过,曝光只记一次。去重不是为了「少显示」,而是为了让每个位置的业务含义唯一。

小结

发现页的组合层要保留来源,而不是过早归一成数组。固定槽位先占位再取内容,来源不足时宁可留空也不塌陷;跨源去重按位置优先级显式执行,并记录重复计数;曝光 key 用槽位与内容组成,不带下标;信息流取尽后页面可以结束,但固定槽位仍按声明位置输出。

搜索与发现的三个问题到这里收口:输入阶段解决竞态,结果阶段解决状态归属,发现阶段解决多来源组合。下一章不再引入新概念,而是把发内容、互动、实时回复串成一条端到端链路,检验这些判断组合起来是否仍然成立。

阅读导航:上一节:12.2 结果分页与筛选状态的保持 · 下一节:13.1 端到端场景:发内容、互动、实时回复 。

常见错误与边界

  • 把运营位、榜单、专题和信息流合并成一个数组,位置约束彻底丢失。
  • 空来源直接过滤,后续内容整体上移,点击与曝光归因失真。
  • 跨源去重没有显式优先级,运营配置被推荐结果挤掉。
  • 用标题或作者去重,改标题后同一内容重复出现。
  • 曝光 key 使用数组下标,换序后同一位置被重复计数。
  • 曝光 key 只含内容 id,无法区分榜单曝光与信息流曝光。
  • 同一来源的多个槽位各自从索引 0 取,第二块全是重复与占位。
  • 信息流提前取尽时压缩固定槽位,专题块漂移到错误位置。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查