Jason Pan

实验驱动推荐的服务框架

潘忠显 / 2025-10-23


前两年在做推荐服务的开发,一直使用之前大佬 peterruan 开发的框架。

近期切换使用新框架,于切换之际对老框架做个系统的学习,分享其中设计思想和智慧,优点能得以继续传承。

该框架除了考虑了性能和通用性之外,主要特点是**「算法实验驱动」推荐服务,使得工程人员关注开发插件,而算法同学能便捷地控制使用何种算法模型与参数,真正解耦两类人员的工作。**

1. 实验驱动的推荐服务

这是一个用Go语言开发的推荐系统框架,采用了插件化架构设计,支持召回(Match)、排序(Rank)、重排序(Rerank)等推荐系统的核心流程。该框架为服务框架,具体的排序算法相关,是通过请求额外的模型服务来实现的。

推荐服务整体框架大都类似如下:

what-is-a-rec-sys

而单独拎出推荐服务部分,其逻辑的大概如下。这里将过滤、精排、混排都抽象为排序,而具体的逻辑包括请求模型、规则排序等:

rec-service

虽然上边的框图表达了推荐的基本流程,但实际过程往往在召回、排序阶段,会通过实验的方式针对不同用户使用多种不同的算法,动态优化推荐服务。实际的请求处理可能是如下(只展示召回、排序相关模块):

rec-service-with-exp

而该推荐服务框架的亮点,就是以「实验配置驱动的推荐服务」为核心思想。上边的推荐服务,只需要做简单配置即可实现。

这种方案让工程人员关注开发插件,而让算法同学能便捷地控制使用何种算法与参数,真正解耦两类人员的工作。

静态配置server.yaml 定义基础流程——使用的实验APP ID、场景ID、实验名称。

appid: rec-sys-demo
exp:     # 实验信息
  appid: 1001
  sceneid: 101
routine: 
- name: match    # 固定第1个插件为召回
  alias:
  - demo_cross_match
- name: prerank  # 从第2个开始为排序插件
  alias:
  - demo_rank_layer1
- name: rank
  alias:
  - demo_rank_layer2 # 同一层域中,也可能存在多个实验
  - demo_rank_layer3 # 不同用户可能命中不同的层域中的不同实验
  plugins:
  - name: hot # 同一层域中,可能有流量不走实验,使用默认插件
- name: mix   # 没有实验,直接指定插件名称
  plugins:
  - name: demo_cross_mix

动态配置:AB实验平台控制具体策略,以上边的match插件为例:

rec-exp-1

排序插件往往会配置若干参数,参数的格式由所使用的插件决定:

rec-exp-2

这样个性化的参数配置,非常清晰易于算法同学理解:

{"new_max_cnt":1,"hot_max_cnt":1,"fixed_pos":{"246":7,"250":8,"251":9,"255":12,"269":6}}

除了「实验驱动推荐服务」这一特点之外。框架还有其他一些特点:

在文章的接下来的内容,将对其中的设计和实现,进行详细的介绍。

2. 项目架构概览

下图展示了针对单次请求,一个完整的推荐流程。

配置加载、路由配置、服务监听、插件加载等操作是前序完成的,不在图中展示。

proc-single-rec-req

插件类型

插件类型主要有5种,其关系如图(省略其他无关步骤):

relationship-between-plugins

Data插件

数据插件提供缓存、数据存储等基础能力。以全局单例的形式,为其他插件提供服务。

往往是每个请求都会用到的数据,但为了性能不需要每个请求都去获得一次。两个示例使用场景:

Data插件没有要求 Plugin 之外的任何其他函数实现,所以具体有什么函数,都看其功能需要。

Feature插件

特征插件用于获取用户或者道具的特征,需要在请求之后、召回之前,获取之后通过上下文传递:

Feature 插件不能通过实验配置,所有代码指定的都会被串行执行

Match插件

召回插件可以从不同渠道获得候选道具,所有的 Match 插件将会被并发调用

插件只能返回召回的道具,不能修改上下文,因为并发可能发生数据竞争。

Match 的结果将会按照指定 Quota 进行配额合并,每个物品除了基本信息之外,还会携带来自哪个召回渠道——插件名称。

Rank插件

排序插件可以根据算法、规则,将召回并合并的物品数组,进行排序。

除了初始化之外,排序插件的排序函数输入和输出均为 []item 数组,因此多个排序插件可以串联

支持配置多层 Rank 插件,而每层中又可以有多个 Rank。如果配置实验,多层之间就可以产生流量正交的效果。

Fallback插件

兜底插件 跟以上四类插件不太相同,他需要直接实现 Recommend(ctx *Context) (*Response, error) 函数,也就是直接返回对应的响应。

该插件无法配置,最多只能注册一个。用于异常不能返回结果时兜底推荐。

插件间消息传递

前面介绍了不同类型的插件,但是插件与插件之间,如何进行消息传递与流转的呢。主要是通过一个请求级上下文携带和推荐列表。

请求级上下文中

在接收到请求,调用 RecommendWithLog() 函数的时候,会创建一个请求级别的上下文。

其中内容包括:

除了数据插件之外,其他几个插件的函数都会传递进来 Context:

info-through-plugins

推荐列表

上边的图和函数还展示出了一点,就是单个 Match 插件会产生 []ItemBasic,之后传递给 Rank 插件的则是 []Item

ItemBasic 中包含了召回的ID,以及该ID的一些属性标签,通过 map 来存储。

Item 是在 ItemBasic 的基础上,加上了Channel 和 Score数组。Channel 用来标识该道具出自哪路召回;Score使用数组则是用来记录不同的 Rank 插件可能对道具进行的打分。

type ItemBasic struct {
	Id   string            `json:"id"`
	Tags map[string]string `json:"tags,omitempty"`
}

type Item struct {
	ItemBasic
	Score   []float32 `json:"score,omitempty"`
	Channel string    `json:"-"`
}

3. 插件深度解析

本节会介绍一下该推荐系统框架的核心概念——插件,以及在此基础上「实验驱动推荐服务」的具体实现。

插件接口设计

插件接口设计比较简洁,所有的插件都要实现 Init()Fini() 方法。Init的两个参数分别是:插件需要加载的初始配置,Prometheus的注册处。这两个变量在 Init 传入,插件如果需要使用,实现时应当使用相应的成员来存储:

// 基础插件接口
type Plugin interface {
    Init(cfg []byte, registry *prometheus.Registry) error
    Fini()
}

其他三个插件分别在 Plugin 的基础上,分别要求的实现对应的 特征获取 Fetch()、召回 Match()、排序 Rank()

// 特征插件
type FeaturePlugin interface {
    Plugin
    Fetch(ctx *Context) error
}

// 召回插件
type MatchPlugin interface {
    Plugin
    Match(ctx *Context, cfg string) ([]ItemBasic, error)
}

// 排序插件
type RankPlugin interface {
    Plugin
    Rank(ctx *Context, cfg string, items []Item) ([]Item, error)
}

泛型插件管理

框架使用了 Go 的泛型特性,实现了类型安全的插件管理:

type pluginMap[T Plugin] map[string]pluginRecord[T]
type pluginList[T Plugin] []pluginRecord[T]

var(
	dataPlugins    pluginList[DataPlugin]
	featurePlugins pluginList[FeaturePlugin]
	matchPlugins   = make(pluginMap[MatchPlugin])
	rankPlugins    = make(pluginMap[RankPlugin])
)

// 类型安全的注册方法
func (m pluginMap[T]) registerOrDie(plugin T, name string, configable bool) {
    if initialized {
        panic("cannot register after initialized")
    }
    if _, found := m[name]; found {
        panic("plugin name conflict")
    }
    m[name] = pluginRecord[T]{
        name:       name,
        object:     plugin,    // 类型T,编译时检查
        configable: configable,
    }
}

类型安全是指在编译时就能确保类型正确性,避免运行时类型错误。在Go语言中,类型安全意味着:

  1. 编译时检查:在编译阶段就能发现类型不匹配的问题
  2. 避免类型断言:不需要在运行时进行类型转换和检查
  3. IDE支持:更好的代码提示和自动补全
  4. 减少运行时错误:避免因类型错误导致的 panic

框架使用者如果要注册 Match 插件,无法直接调用未导出的 registerOrDie() 函数,而只能掉用封装了该函数的函数,能够保证不会将插件注册错:

// 注册插件 - 编译时类型检查
func RegisterMatchPluginOrDie(plugin MatchPlugin, name string, configable bool) {
	matchPlugins.registerOrDie(plugin, name, configable)
}

框架中使用 matchPlugins.get(config) 也能保证获得的类型一定是 MatchPlugin

插件的初始化和销毁

前边有列出插件接口定义,需要有 Init() 和 Fini() 函数。这些函数是在哪里调用的呢?

服务启动相对比较简单,在监听端口之前,会调用 initPlugins 依次调用各类插件的初始化。而在服务接收到退出信号之后,会先进行HTTP服务的善后工作(关闭HTTP监听端口,停止接收新请求并等待在途请求完成,刷新关闭日志),最后再调用 finiPlugins 依次终止各类插件——通过defer来实现。

	if err := initPlugins(cfgDir, registry); err != nil {
		fmt.Printf("fail to init plugins: %v\n", err)
		return 1
	}
	defer finiPlugins()
	
	router := httprouter.New()
	// call router.Handler, router.HandlerFunc
	if err := utils.RunHttpServer(router, fmt.Sprintf(":%d", port)); err != nil {
		return -1
	}

插件的配置

插件的配置有两种类型:插件初始化时使用一次的初始化配置,每次运行都从实验配置获取的运行时配置

初始化配置可以选择是否需要配置——configable控制:

func RegisterMatchPluginOrDie(plugin MatchPlugin, name string, configable bool)

配置需要写入到文件中,有一定的命名规则,如上边的 Match 插件注册名如果时 "simple",则需要创建对应的配置 match-simple.yaml 文件。

配置文件在调用插件的 Init(cfg []byte, registry *prometheus.Registry)的方法时被读取,并通过 cfg 变量传入使用。配置内容约定为 yaml 格式。

运行时配置 会在调用 Match、Rank 对应函数时候使用,框架会在拉取对应用户实验信息时,加载该配置,并传入对应函数。

type RankPlugin interface {
	Plugin
	Rank(ctx *Context, cfg string, items []Item) ([]Item, error)
}

运行时配置通过 string 传入,但其内容序列化取决于插件的实现,实际是一个 URL、一个 JSON、一个Yaml都是可以的。

Q:配置应该如何划分在初始化配置还是运行时配置?

A:需要对算法同学屏蔽的、长期不发生变化的,应当配置在初始化配置中,如 Redis/ES 地址;需要算法同学动态调整的、需实验检验效果的,应当配置在运行时配置中,比如 热销Key的前缀、模型地址等等。实验配置的修改,会实时地影响到推荐服务。

复杂实验的配置支持

在真正生产环境的,使用的实验并非都是简单的AB实验,而是会有复杂的层域设计。

比如下边的这个实验的层域设计,会留出20%的全局对照组,不进行任何的个性化算法推荐干预。实际场景中可能多个活动都会使用这个域作为全局对照组。

同时实验流量被分成两个隔离的域,每个域中会有各自的召回层、排序层。

complex-exp

所有请求到推荐服务的用户,都会被 Hash 到不同的域中,因此需要为不同的域的用户配置不同的插件配置。

这个配置也很简单,有配置实验的,就将插件所在层名配置进去,并配置一个默认的 plugins 供查询不到实验配置的流量,去使用的逻辑。

- name: match
  alias:
  - domain_1_match
  - domain_2_match
  plugins:
  - name: simple

当然,这个配置可以进行简化——不使用任何实验,可以直接配置 plugins,这样就是一个不需要算法同学干预的服务了。

实验配置加载

上边对复杂实验的支持,是通过根据ExpUID获取实验配置+插件组织来实现,主要逻辑在下边的这个函数中:

// 每次请求都会获取最新的实验配置
func getRuntimeConfig(ctx *Context, req *Request) (*runtimeConfig, error)

对海外ABC和国内IEGAB的兼容,也是在上边这个函数中封装的,配置中通过一个oversea的flag进行控制。

因为实验平台只能配置 key-value 的列表,因此要转换成插件的配置,还需要有额外的约定:

# 召回实验配置
p0: items_from_model
p0_config: {"model_version": "v2.1", "top_k": 100}
p0_quota: 50

p1: content_based
p1_config: {"similarity_threshold": 0.8}
p1_quota: 30
# 排序实验配置
p0: collaborative_filtering
p0_config: {"model_version": "v2.0"}

p1: linear_fuse
p1_config: "[0.7, 0.3]"

实验标签追踪

前面的介绍中,有一个概念被反复强调:一个用户可以命中多个实验。用户命中哪个实验,对于分析算法的效果非常重要。

我们算法同学有两种方式关联人和实验:

这两种方式以后者更为精准,比如一些预拉取的场景下,推荐服务的结果并不一定真正的展示给用户。

框架中会自动在拉取用户实验时填充实验标签信息。因为可能有多个,所以使用数组进行保存:

func Recommend(req *Request) (resp *Response, err error)

// 系统会自动记录实验标签,便于效果分析
type Response struct {
    RecId   string     `json:"recid"`
    Items   []RespItem `json:"items"`
    ExpTags []string   `json:"exp_tags,omitempty"` // 实验标签
}

实验标签示例:

["match:collaborative_v2:exp_a", "rank:tfctr_v2:exp_b"]

4. 多路召回与公平合并

出于对整体推荐延迟的考虑,框架中使用并发执行所有的召回插件。而多路召回之后如何选择这些结果,框架采用了一种公平合并的方法。

并发过程

Match的过程可以配置一个整体超时,框架实现上看上去比较巧妙,就是在并发调用Match插件之前,将请求级别上下文中的 context.Context 进行了替换:

	if matchTimeout > 0 {
		tCtx, cancel := context.WithTimeout(ctx.Ctx, matchTimeout)
		defer cancel()
		ctx.Ctx, tCtx = tCtx, ctx.Ctx // 临时换掉
		defer func() { ctx.Ctx = tCtx }()
	}

但是这种实现方式是有问题的。如果本身服务超时设置的比 matchTimeout 短,会造成服务的响应的耗时可能比总体的超时时间长。可以改为判断 remainingmatchTimeout 的大小再决定是否替换。

其他一些细节

公平合并算法 (fairMerge)

框架针对多路召回的情况,即只要不是所有召回路都失败,就不认为是失败。接下来,我们考虑如何合并多路召回的结果

召回源再配置的时候,都支持配置独立的 Quota 配额,当然这个 Quota 可以为0。框架采用了一个非常精妙的设计,确保各召回通道按配额公平分配。

并发召回之后,根据配额缺口——quota - len(result)——从大到小排序,然后使用两个链表 mainrest 来保存未满足配额的和已满足配额的召回源

输入:
- 源A: quota=3, list=[item1,item2,item3,item4,item5]  (缺口=3-5=-2)
- 源B: quota=2, list=[item6,item7]                    (缺口=2-2=0)  
- 源C: quota=0, list=[item8,item9,item10]             (缺口=0-3=-3)

排序后:源B → 源A → 源C (按缺口从大到小)

分类后:
有配额 main队列: 源B → 源A  (有配额)
无配额 rest队列: 源C        (无配额)

接下来优先满足有配额的数据源,遍历 main 队列,从其中数据源取出一个元素,并将配额减1,如果某个数据源的配额降低到0,则移入 rest 队列:

第1轮:从源B取item6,配额变为1
第2轮:从源A取item1,配额变为2  
第3轮:从源B取item7,配额变为0 → 源B移到rest队列
第4轮:从源A取item2,配额变为1
第5轮:从源A取item3,配额变为0 → 源A移到rest队列

满足完有配额的数据源之后,再处理候补 rest 队列,继续补充召回结果:

rest队列: 源A(item4,item5) → 源C(item8,item9,item10)

第1轮:从源A取item4
第2轮:从源C取item8  
第3轮:从源A取item5
第4轮:从源C取item9
第5轮:从源C取item10

总结下来其主要特点包括:

  1. 配额优先分配:初次分配按配额,确保每个通道都能获得应有的份额
  2. 公平轮询:在满足配额的前提下,公平地从各个源取数据
  3. 去重处理:使用book映射避免重复推荐
  4. 候补机制:配额用完后进入候补队列,为了考虑总量,可以使用后补队列继续补充

召回结束之后,每个物品都会标记来源渠道 (Channel)——也就是上边提到的,便于后续排序、分析和调试。

5. 通用插件

除了核心的推荐框架功能外,项目还开发了丰富的通用插件。设计这些通用插件,也是为了更高效的开发。

这些插件按照功能,又可以区分成 3 类:

这些通用插件使用起来非常灵活,既可以直接配置,又可以作为别的插件的一部分被调用。因为这些通用插件,大多初始化无需任何初始化配置,而一些必要的参数又直接通过 Rank() 或 Match() 的配置传递

通用插件列表

按照前面介绍的插件类型,这些插件覆盖了推荐系统的数据存储、特征处理、召回、排序、监控等各个环节。

远程调用插件

TFCTR 应该是 TensorFlow Controller 的缩写,这系列插件用于通过网络调用 TensorFlow 模型排序服务

所有涉及的代码都在 plugins/rank/tfctr/ 目录中。

这个目录中有三个类似的插件,他们分使用不同的协议调用TFCTR排序服务:

类似的插件还有召回的Remote插件、排序中的Rerank插件都是通过远程调用获得结果,都支持多种协议。

6. 与模型服务的交互

调用模型服务是推荐系统的常规操作。该推荐框架将对模型的调用,除了做了上边 tfctr 的插件封装之外,还有特征上报等操作。

对于上边的 TensorFlow 的服务我们后边有机会再介绍,该服务相对通用,启动之后,都会支持 .proto 中定义的三个接口:

service Predictor {
  // 单次评估
  rpc Evaluate(EvaluateRequest) returns (EvaluateReply) {}
  // 批量排序
  rpc Rank(RankRequest) returns (RankReply) {}
  // 特征上报
  rpc DumpFeature(DumpFeatureRequest) returns (DumpFeatureReply) {}
}

这里的单个评估接口在框架中通常不会用到。

Rank 接口会被封装在排序插件中进行 RPC 调用,比如 tfctr 的 StandardPlugin 插件的 Rank() 函数实现中:

	proxy := pb.NewPredictorClientProxy(client.WithTarget(cfg))
	resp, err := proxy.Rank(ctx.Ctx, req)

特征上报函数 DumpFeature(),不是通过TFCTR插件调用,而是框架直接调用.

框架通过读取配置文件中的 dump_server 配置,异步创建一个独立的 proxy 来调用 DumpFeature 接口:

		if dumpServer.trpc {
			proxy := pb.NewPredictorClientProxy(client.WithTarget(dumpServer.address))
			_, err = proxy.DumpFeature(context.Background(), obj)
		} else {
			err = adapter.HttpPostPb(context.Background(),
				dumpServer.address, "/dump-feature", obj, nil)
		}

特征上报的内容包括:应用ID、用户信息、上下文信息(包括命中的实验配置)、推荐结果列表等。收集的数据会被用于:离线模型训练、效果分析和评估、特征重要性分析、推荐策略优化等方面。

框架中对特征上报,只支持配置一个服务地址,即使服务中有多个模型的调用。但是这大部份情况下是可以满足的,因为对于同一场景,不同的模型多是参数不同,而使用的特征类似。

当然这个配置可以留空,就不进行特征上报。

7. 兜底插件

主推荐流程失败时,即 recommend() 函数返回错误时,框架会判断是否有兜底插件,如果存在则会运行返回提供备用方案。

recommend() 报错可能是推荐过程中任何一个步骤报错:

Fallback 插件,类似是将框架的 recommend() 函数删繁就简的一个浓缩版本:

通常在 Fallback 中要配置一些相应更快的召回和排序,比如固定规则召回、随机排序等,这样能够减少依赖、降低失败率。这样能在异常情况下,专注于可用性而非质量

8. 框架通用特点

该框架也采纳了其他很多框架的一些优点,这里简单一提。

技术栈

日志系统特性

监控体系

请求-步骤-插件三层监控体系。

9. 总结

该推荐系统框架通过实验驱动的设计理念,实现了工程与算法的有效解耦。框架采用插件化架构,支持召回、排序、重排序等核心流程的灵活配置,让算法同学能够通过AB实验平台便捷地控制算法选择与参数调优,而工程人员则专注于插件开发,真正做到了职责分离。

本文介绍了框架的一些实现细节,充分了解该框架的基础上,掌握其设计亮点,可以应用于其它系统的设计。