普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-得物技术

DPP推荐引擎架构升级演进之路|得物技术

作者 得物技术
2025年4月10日 09:48

一、DPP整体架构

DPP依赖于算法平台的引擎服务(FeatureServer,召回引擎, 精排打分),提供“开箱即用”的召回,粗排,精排服务。采用“热加载技术”解决算法平台的工程和算法同学策略迭代效率问题,支持策略随时发布,让他们可以专注于业务逻辑,即可拥有稳定的推荐在线服务。

图片 图1.0 DPP服务整体架构

平台特性

  1. 快速迭代:通过系统解耦,实现算法、策略的快速迭代。

  2. 效果分析自动化:打通数据平台,BI数据分析标准化。

  3. 灵活实验:通过分层实验平台,支持多层多实验的灵活配置。

  4. 诊断方便:落地各子流程中间结果,支持算法、策略的细化分析;提供方便的监控告警,运维,时光机等问题排查工具。

二、DPP引擎演进

DPP编排引擎的迭代分为了3个阶段:固定编排,灵活编排,图化DAG编排;均是在策略迭代过程中,围绕着“迭代效率”提升的不断进化。下面分别介绍下各阶段引擎产生的背景及其方案。

2.1 固定编排 - DPP-Engine

推荐业务一般都可以抽象为“召回->融合->粗排->精排->干预”等固定的几个阶段,每个阶段通常是有不同的算法或工程同学进行开发和维护,为了提升迭代效率,通过对推荐流程的抽象,将各阶段的逻辑抽象为“组件"+"配置”,整体的流程同样是一个配置,统一由“编排引擎”进行调度,同时提供统一的埋点/日志等。让工程或算法同学可以关注在自己的业务模块和对应的逻辑,而框架侧也可以做统一的优化和升级。

DPP-Engine就是在此基础上,将业务策略抽象为“初始层->召回层->融合层->粗排层->精排层->干预层”这6层, 有DPP负责串行调度这6层,每一层有若干个组件组成,各层将结果进行合并后传递到下一层(也就是List)。

图片

图1.2-1 DPP-Engine层编排

通过分层,DPP-Engine较好的支持了业务的快速迭代,业务“各层”的开发同学可以独立迭代。但是随着场景的增多,对“灵活”编排有了更多的需求,比如不固定6层,层内可有自己的"编排"等。

其次对于DPP平台同学来说,DPP-Engine嵌入在DPP系统内, 不利于引擎的迭代和维护。

2.2 灵活编排 - BizEngine

BizEngine根据策略同学提供的组件及其编排流程,负责执行和调度,包括组件间的并发。它在推荐系统链路中的位置如下图:

图片

图1.3-1 DPP系统(BizEngine)

目前在BizEngine看来,“组件”是策略开发的最小粒度,策略同学在DPP-后台中可以在场景维度划分桶(小流量桶, 分层桶),在桶可以配置不同的层编排,默认为6层:INIT层->召回层->融合层->粗排层->精排层->干预层。分别在层内可以配置不同的组件。一次请求中,BizEngine负责按层进行调度(层与层之间为串行调度),层内的组件根据组件间的依赖进行串行或者并发调度。

图片

图1.3-2 编排管理及其配置协议

用户请求到DPP后, 会通过AB分流得到该请求(用户)命中的所有实验(包括桶,层,实验),DPP解析命中配置后,可以构建出BizEngine需要的入参-编排配置(桶配置+实验配置+组件配置),它会根据层及组件的配置构建出执行的层Stages,按组件维度提交到各线程池进行同步或异步的调度,流程可参考下图:

图片

图1.3-3 BizEngine的组件调度和执行

从上图可以看到我们是按层进行串行调度的,“分层”是按推荐的业务策略逻辑来分的,符合工程算法同学的分工和职责,特别是算法同学通常有各自负责的领域(召回模型,粗排模型,精排模型,干预),按层划分和进行实验可以有效提高迭代效率,做到相互之间不影响。“组件”则是BizEngine层内调度的单元,但是目前组件的粒度可大可小,比如社区的部分场景,他们在组件内拆分了更细粒度的Steps,并且独立于组件进行调度(依赖DPP场景线程池或自定义线程池),因此策略代码即负责了策略的逻辑, 还需要负责策略逻辑单元(Step)的调度。由此可以看出BizEngine未来的可进一步发展的方向:

  1. 按层进行串行调度,即便层与层组件之间为串行,也需要按层调度,存在一定开销。

  2. BizEngine的线程调度和策略内自定义调度的冲突,线程池资源难于实现高效利用。

  3. “组件粒度”问题:目前看策略同学实现的组件对BizEngine来说是“逻辑黑盒”,里面可能是CPU,也可能是IO,也可能是一个发起并发任务的模块,可能涉及自定义的线程池资源。

  4. 随着业务不断迭代, 策略组件的迁移和重构成本逐渐上升;缺少“组件”/“代码”共享及发现的机制,不利于我们通过“组件复用”的方式去提升迭代效率。

2.3 图化DAG - DagEngine

为什么需要做图化?

那为什么要去做“图化”/“DAG”呢?其实要真正要回答的是:  如何应对上面看到的挑战?如何解决BizEngine目前发展碰到的问题?

从业界搜推领域可以看到不约而同地在推进“图化”/“DAG”。 从TensorFlow广泛采用之后,我们已经习惯把计算和数据通过采用算子(Operation)和数据(Tensor)的方式来表达,可以很好的表达搜索推荐的“召回/融合/粗排/精排/过滤”等逻辑,图化使得大家可以使用一套“模型”语言去描述业务逻辑。DAG引擎也可以在不同的系统有具体不同的实现,处理业务定制支持或者性能优化等。

通过图(DAG)来描述我们的业务逻辑,也带来这些好处:为算法的开发提供统一的接口,采用算子级别的复用,减少相似算子的重复开发;通过图化的架构,达到流程的灵活定制;算子执行的并行化和异步化可降低RT,提升性能。

图片

图化架构

图化是要将业务逻辑抽象为一个DAG图,图的节点是算子,边是数据流。不同的算子构成子图,用于逻辑高一层的封装,子图的输出可以被其他子图或者算子引用。图化后,策略同学的开发任务变成了开发算子,抽象业务领的数据模型。不用再关心“并行化异步化”逻辑,交由DAG引擎进行调度。“算子”要求我们以较小粒度支持,通过数据实现节点的依赖。

图化定义了新的业务编排框架,对策略同学来说是“新的开发模式”,可分为3个部分:一个是我们会定义算子/图/子图的标准接口和协议,策略同学实现这些接口,构建业务的逻辑图;二是DAG引擎,负责逻辑图的解析,算子的调度,保证性能和稳定性;三是产品化,DAG Debug助手支持算子/图/子图的开发调试,后台侧提供算子/子图/图的可视化管理。整体架构参考下图:

图片

图4.0.0 - DPP图化框架

图片

图4.0.1 - DagEngine

图化核心设计和协议

1.算子

  • 算子接口定义Processor
public interface Processor<O> {
    /**
     * 执行逻辑
     *
     * @param computeContext 执行上下文信息
     * @return 返回执行结果
     */
    DataFrame<O> run(ComputeContext computeContext, DataFrame... inputs);
}
  • 算子注解@DagProcessor

通过注解可对算子进行描述和提供运行时信息:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface DagProcessor {
    /**
     * 标记IO/CPU, 影响DagEngine的调度
     * @return
     */
    String type() default "IO";
    /**
     * 算子描述
     *
     * @return String
     */
    String desc() default "";
    /**
     * 用于标识该算子会输出的一些中间值, 可用于做运行时的依赖校验
     * 可理解为是算子OP的side effects
     */
    String sideValues() default "";
}
  • 依赖配置@ConfigAnno)

算子通过注解(@ConfigAnno) 一是声明算子需要的配置(通过DPP-后台实验配置进行配置), 二是运行时DAG引擎会对注解的值进行注入.

  • 依赖数据@DependsDataAnno

算子节点上游的数据,通过接口参数也会透传过来(DataFrame数组),算子内可以通过dataFame.getName()获取数据的唯一标识(请求session内唯一)。

算子的返回作为该算子的输出数据,通过name可以获取, 比如 @DependsDataAnno(name = "某一路的输出",desc = "recall1")。

写策略逻辑过程中的中间变量是我们必不可少的,算子可以通过注解@DagProcessor#sideValues声明会输出那些数据(names),通过name 可以获取。

比如依赖了同一个算子(多个实例),它的输出name是一样的,下游获取需要通过这个优先级决定。

Note:@DagProcessor#sideValues 可能作为必须的,只有sideValues声明了的数据,才可以被依赖算子引用,这有助于我们管理和防止依赖不存在的数据。

Note:算子获取sideValue时有多相同name的数据时,通过配置指定算子优先级。

2.图/子图

  • 图/子图/配置文件

图分为图和子图,一个场景可以有多个图,可按垂直桶制定不同的图;子图定位为业务逻辑模版,可以将若干个独立算子组装为具有特定业务含义的“子图”,子图和算子一样可在场景大“图”中进行配置,即运行时可有多个“实例”,实现逻辑的复用和配置化。

图或子图通过“配置文件”文件来描述,考虑到可读性和是否支持注释等特性,确定选用yaml来定义。

  • 协议

子图

## 子图(定位为逻辑模版, 包含: 若干个算子及其依赖关系, 子图的配置及其默认值
## Note: 子图的配置实际为算子的配置, 在算子中引用
name'Recall子图1' ## 场景全局唯一
type'subgraph' ## 标记图为"子图"
configs: ## 子图包含配置项( 指定默认值 )
  - name'configKey1' ## 
    value'默认值Value, 可为string, json等, xx'
  # - 其他配置及其默认值
  # ...
nodes: ## 子图包含的所有算子, 通过dpends指定依赖.
  ## 比如一路召回
  - name'fistRecallOp1'
    op'com.dag.demo.recrecall.FirstRecallOP'
    depends: []
    # 指定子图中该算子的默认值
    configs:
    - name'configKey1'
      value'fistRecallOp1s value'
  - name'otherRecall1'
    op'com.dag.demo.recrecall.OtherRecallOP'
    depends: ['fistRecallOp1']

## 图(场景逻辑描述, 包含若干个算子或子图, 及其他们的依赖关系, 图的配置及其默认值(Note: 图的配置实际为算子的配置, 在算子中引用)
name'场景图Name' ## 场景全局唯一
type'graph'
configs: ## 图包含配置项( 指定默认值 )
  - name'configKey1'
    value'默认值Value, 可为string, json等'
  # - 其他配置及其默认值
  # ...
nodes: ## 图包含的所有算子或子图, 通过dpends指定依赖.
  ## 比如一路召回
  - name'fistRecallOp1'
    op'com.dag.demo.recrecall.FirstRecallOP'
    depends: []
  - name'otherRecall1'
    op'com.dag.demo.recrecall.OtherRecallOP'
    depends: ['fistRecallOp1']
  ## 子图1( 为`Recall子图1`的实例 )
  - name'someRecallComplex1'
    op'$Recall子图1' ## 依赖该子图
    configs: ## 子图包含配置项( 指定默认值 )
      - name'configKey1' 
        value'fistRecallOp1s value'
        ## 覆盖这两个算子的默认值
        targets: ['recallGroup1''dssmRandomBatchRecall']
      ## todo 修改op的配置
      ## 
    depends: ['fistRecallOp1']
  ## 子图2( 为`Recall子图1`的实例 )
  - name'someRecallComplex1'
    op'$Recall子图1' ## 依赖该子图
    depends: ['fistRecallOp1']

3.算子配置如何获取? 如何配置?

图通过算子(子图)+数据依赖的DAG描述了业务的逻辑关系,配置的作用就是影响逻辑如何生效。这些配置通过“实验/AB”来决定,不同的实验就是对图或算子的不同配置。

  • 默认值

配置的默认值通过两种方式指定:1/ 算子变量的默认值(代码方式);2/ 图或者子图的Confgis#key#defaultValue

  • 运行时的值

算子某个配置在运行时的值,是通过该次请求命中的所有实验进行配置融合和覆盖后得到的。

  • 如何配置?

实验配置中:

需要考虑配置key在子图和算子中的name作为前缀,规则为<subGraph'sName>.<op'sName>.<key'sName>,若算子不在子图中(即, 直接配置在主图中),那么配置为_.<op'sName>.<key'sName>。

算子代码中:

通过注解 @ConfigAnno(key = "key'sName")来获取对的key'sName的值. 运行时DAG引擎负责识别<subGraph'sName> 和<op'sName>。

配置支持json和dto对象绑定,DAG运行时实现缓存和校验指定Json配置和类的映射,@ConfigAnno(key = "somepojo.value",isJson = true,clazz = SomePojo.class),DAG引擎负责反序列化。

图化相关特性/结果

  • DPP图化落地广告/社区等场景。

图片

  • 图桶推全SOP流程: 通过引入"分支"概念,图桶推全变为合入Master,待推全各桶由各Owner自行合并Master。支持一分支绑定多桶。简化了场景编排迭代流程。

  • 图编辑可视化: 支持算子及其依赖的表单化修改,提升修改效率和易用性。

三、总结

DPP编排引擎经历了固定编排,灵活编排到图化DAG编排三个阶段,持续提升策略迭代效率。

图片

图化DAG编排在我们落地的一些场景中显著提升了性能,同时新的开发模式要求策略同学关注算子级别的实现,减少对调度逻辑的关注。在产品侧DPP-后台提供了产品化工具支持本地调试和可视化管理。

未来我们可以进一步探索图化DAG编排在更多业务场景中的应用,尤其是需要高性能和灵活定制的场景。其次加强算子复用机制和标准化建设,降低组件迁移与重构成本, 持续优化DagEngine的高性能特性,如DataFrame数据结构的使用,以进一步提升系统性能。 并且随着引擎及机器学习平台图化的推进,我们有可能也去端到端链路上实现“全图化”。用一张图描述一个业务的策略逻辑。

往期回顾

1.Cursor 在前端需求开发工作流中的应用|得物技术

2.得物 iOS 启动优化之 Building Closure

3.分布式数据一致性场景与方案处理分析|得物技术

4.从对话到自主行动:AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

5.得物技术部算法项目管理实践分享

文 / 在东

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Cursor 在前端需求开发工作流中的应用|得物技术

作者 得物技术
2025年4月8日 09:45

一、引言

很高兴与大家分享现阶段 Cursor 在我的工作中的使用体验。首先是预期管理,本篇文章不会分享 x 个你可能不知道的小技巧,也不会让你拥有无需自行编码的能力,同时不涉及 Cursor 工程化方面内容。仅仅是围绕个人开发流程中的已有问题,分享如何使用 Cursor 来提升这部分的开发体验,在工作中持续保持好的节奏和状态。

TL;DR

  • 列举 Cursor 的错误预期

  • 相比过去的开发流程,使用 Cursor 后的变化

  • Cursor 在现状分析、方案设计和影响评估中的收益

二、就差一个程序员了

最近团队在大力推广 Cursor AI,随着几个版本迭代体验下来,其精准的自动补全深得我心,具体可以体现在 Tab 键的使用率已逐渐高于 Command + C/V。既然这么懂我,那么能否更进一步,根据 PRD 直接输出代码呢?

2.1 从需求到代码

Cursor 能够理解代码上下文,从而根据简短的描述生成符合上下文的代码,于是我尝试直接将 PRD 提供给 Cursor 来生成代码:

PRD → Cursor → Code(一步到位)

几个需求尝试下来,总的来说分为两类问题:

这就像你去理发店,希望 Tony 老师稍微剪短一点,结果却被剪得稍微短了点。而这需要我们在开始之前对齐认知,补充描述和参照。在这个前置阶段,即使发现对方理解有偏差,也还能及时纠正。俗称“对齐颗粒度”。

2.2 从规划到执行

Cursor 产出的代码由它所接收的上下文决定,如果没有准确描述需求意图,它会通过推断做出假设,导致产出不准确。因此我们在使用 Cursor 时,关键在于区分开发过程中的规划阶段执行阶段。在这个分层的视角下,不管是自身的关注点还是 AI 的角色定位都变得更加清晰:

Cursor 在这个过程中,不应该被视为开发者的替代品,而是一面能够放大开发者能力的镜子:

  • 对于已知的部分,Cursor 可以加速实现,减少重复劳动。

  • 对于未知的部分,Cursor 可以协助探索,但不能替代开发者的判断。

在理解了 AI 的角色后,我们需要重构目前的开发工作流,让 AI 成为真正有效的助手。最关键的转变是:**不再试图让 AI 替代开发流程中的任何环节,而是让它协助完成每个环节。**这意味着不是把 PRD 扔给 AI,等待完整代码,而是和 AI 一起理解 PRD 和代码现状,共同设计方案,明确步骤,然后分步实现。

三、现有问题

作为前端开发,我们的日常工作流程中大多围绕需求文档进行代码产出。这需要介于

  1. 我们对业务需求的理解。

  2. 对所属业务和项目现状的认知。

  3. 从而进行方案设计和决策,整理思路将复杂问题分解成可执行的粒度。

但同时,这导致我们不得不面临着一个矛盾:方案设计对效率的影响。一方面,方案设计是保证质量的必要环节;另一方面,生成和维护这些产物又会显著降低开发效率。尤其是在快速迭代的项目需求中,这种矛盾更为突出。

有时即使是一个小需求,可能也需要经过大量前置分析,才能进入开发。举个例子,以下是某个小需求的前端方案截图,通过不同的颜色区分了各流程的占比。从图中可以看出,各模块中绿色和蓝色所对应的「现状分析」和「改动方案」后占据了主要的篇幅,与相应的时间占用成正比。

图片

前端方案中的各环节分布

传统的解决方案通常是:

  • 模板化方案设计,减少重复工作。

  • 简化方案设计,减少不必要的细节描述。

  • 提高团队熟练度,使得方案设计生成更加高效。

作为附加项,现在我们能在这些基础上借助 Cursor 进一步提升效能。

四、协作流程

4.1 反馈循环

在协作时,关键在于对 Cursor 补充上下文,并对 Cursor 提供的结论进行人工核验,两者构成反馈循环。前者是希望 Cursor 知道,后者是需要我们自己知道,从而保障产出的结果符合预期。

图片

整体的 Cursor 协作流程分为规划和执行两个阶段。规划阶段专注于产出方案,执行阶段根据方案产出代码,两者交替执行。

4.2 流程对比

相较于以往,在使用 Cursor 后的工作模式差异如下:

图片

乍一看使用 Curosr 后流程更加繁琐,而实际上也确实如此。

所以这里更推荐换一个心态来看待流程上的变化,不必为了使用工具而使用。过去我们面向 Google / GitHub / Stack Overflow 编程也并不是因为我们为了搜索而搜索,是因为在具体开发中遇到了不明确的信息需要确认,现在这个角色可以渐进地由 Cursor 替代,比起搜索引擎,Cursor 能充分地根据项目现状分析出更贴切的答案,如同行车的导航和选购的得物,为此不必有太多的心理负担。

五、场景应用

重新回到在需求开发工作中的问题,占据我代码之外的主要工作是“现状分析”、“改动方案”和“影响评估”,因此主要分享这三个场景中的 Cursor 使用体验。

关于提示词,可根据实际需要使用 notepads 或 rules 降低单次使用成本。

5.1 现状分析

在需求开发过程中,我们时常会接触到陌生的业务模块,如何理解现状往往是最耗时也最容易被忽视的部分。如果对现状不够了解,当需求相对复杂或者项目本身存在较多的历史债务时,我们很难输出符合预期的方案,更难以保证最终代码的质量。对于新接手项目的开发者而言,这一阶段常常伴随着无数次的"代码考古"和"问询前人"。

Cursor 离代码上下文更近,我们可以在它的协助下抽丝剥茧,快速了解业务主线。这是一个学习的过程,当知道的越多,在后续的设计和开发中就越能正确地引导 Cursor。

具体可以从需求的目标改动点开始,梳理其所属功能和实现方式,包含交互流程、数据管理和条件渲染等:

业务需求
    ├── 1. 功能
    │   ├── 2. 实现 
    │   ... └── 3. 字段
    ...
目标 了解业务功能 了解代码实现 了解字段依赖
提示词参考 当前功能如何运作,用户交互有哪些路径,具体数据流向是怎样的,请整理成 mermaid 时序图。 当前代码如何组织,核心模块有哪些,组件间如何通信,梳理组件关系图。 梳理当前表单字段的显隐关系、联动逻辑以及数据源。
效果 输出所属功能中的角色和角色之间的交互方式,能快速掌握业务模块的大体脉络。 输出组件职责和组件间的关系,以便在投入开发前以组件模块维度确定改动范围。 能直观地呈现表单字段间的联动说明。

通过对上述三个层面的不断往复,Cursor 提供的直观输入能帮助我们摆脱掉一知半解的状态,消除不确定性也就消除了焦虑。

5.2 改动方案

在了解了现状后,开始面向需求进行改动方案设计。

在问答中,Cursor 倾向于直接满足表面的需求,但可能会忽略一些深层的系统设计考虑。当遇到复杂的问题时,建议先让 Cursor 分析问题本身,而不是直接要求它给出解决方案。通过引导它进行更全面的思考,能防止 Cursor 胡编乱造,确保它理解需求,同时也能暴露自身的思考局限,减少返工。具体做法可以先提示 “在我让你写代码之前不要生成代码” 以及 “先逐步分析需求再说明你打算怎么做”;

另一方面,由于 Cursor 背后 LLM 的 Context Window 存在上下文长度限制,意味着 Cursor 跟我们一样都存在“短期记忆”,这体现在当对话超出范围后,Cursor 会在输出方案和代码时,遗忘此前的要求和结论,造成不准确。因此,为了将短期记忆转换成长期记忆,需要我们对复杂任务进行必要的拆解,每次只专注于单个粒度下的问答,当确认方案后,再让 Cursor 汇总并记录到外置文档,以便在后续的对话中补充上下文(也可以借助 @Summarized Composers 实现)。在面对下一个任务时,开启新的会话进行问答,多轮下来形成由不同模块组装而成的方案设计。

这样一来,在生成代码阶段,Cursor 所需要面对的只是局部复杂度中的改动,这能很大程度上减缓我们在代码审核和验证上的投入成本。Cursor 也能始终保持在长度限制范围内,面对精炼后的方案设计进行决策和产出。

因此在整体流程上:

1. 拆解需求,缩小关注范围

2. 明确目标,清晰表达需求描述

  • Cursor 提供方案

  • 检查是否有理解偏差,并不断调整提示

  • 在确认方案后,最终由 Cursor 汇总成果

3. 渐进开发,分模块由 Cursor 生成代码,及时验证效果和审核代码

提示词参考:

  • 方案设计
我们先探讨方案,在我让你写代码之前不要生成代码
如果此处要加个 xxx 该怎么做,请先逐步分析需求
在想明白后向我说明为什么要这么设计
  • 代码产出,在功能之外,留意识别边界场景以及控制影响面
在写代码时遵循最小改动原则,避免影响原先的功能
即使识别到历史问题也不要自行优化,可以先告知我问题描述和对当前需求的影响,不要直接改跟本次需求无关的代码

5.3 影响评估

除去开发之前的方案耗时,在完成开发后,我们所要解决的是如何保障自测质量的问题。对于研发而言,需要关注的是在这个需求迭代内,改动点所关联的调用链路,而在这个路径依赖下不断冒泡所涉及到的具体功能就是影响面。

因此可以从两个方面提高自测可信度

  • 自下而上:基于改动代码和依赖项进行白盒测试,这需要研发自身投入必要的时间进行代码审核;

  • 自上而下:识别改动最终涉及到的页面和功能进行黑盒测试,逐个回归和确认功能是否符合预期。

图片

借助 Cursor 可以很低成本地分析改动,并按需产出测试用例,通过 @git 指令让 Cursor 参与到对当前功能分支或具体 commit 的评估:

图片

目标 代码审查 功能验证
提示词 @git逐个文件分析并总结改动点,评估是否引入了新的问题。 @git基于代码变更输出自测用例清单。
效果 在列举出每个文件的改动意图后,会告知潜在问题和修改意见。 围绕改动,生成新旧功能在不同场景中的测试用例。

六、小结

过去,成为一名优秀开发者需要经历漫长的积累:从反复查阅文档、在搜索引擎中筛选有效信息,到系统掌握编程语言、算法与网络原理,每一步都在构建扎实的「知识护城河」。而 AI 时代颠覆了这一逻辑 —— 当大模型能快速生成代码、解析技术方案时,开发者的核心能力似乎从“记忆与执行”转向成了“正确地提问,让 AI 提供答案”。

客观来看,AI 降低了信息获取的门槛,能更快地落地想法、验证思路。不变的是,好的答案源于好的问题,而提出好问题依旧需要积累专业领域下的知识,知道的越清楚才能在提问时描述得越清晰。

所有事都有吃力不讨好的部分,随着 Cursor 等 AI 工具在工程中的应用,我们可以逐渐将这部分职能分配出去,利用我们的知识储备,描述问题,引导过程,审核结果。工具的使用始终是为了节省人类体力和脑力的开销,从而在提升体验的同时提升生产力,以更充沛的精力聚焦在工作成果和个人成长上。

往期回顾

1.得物 iOS 启动优化之 Building Closure

2.分布式数据一致性场景与方案处理分析|得物技术

3.从对话到自主行动:AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

4.得物技术部算法项目管理实践分享

5.商家域稳定性建设之原理探索|得物技术

文 / 魏命名

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物 iOS 启动优化之 Building Closure

作者 得物技术
2025年4月3日 09:50

得物一直重视用户体验,尤其是启动时长这一重要指标。在近期的启动时长跟进中,我们发现了在BuildingClosure 阶段的一个优化方式,成功的帮助我们降低了 1/5 的 BuildingClosure 阶段的启动耗时。Building Closure 并非工程的编译阶段(虽然它有一个building),Building Closure 是应用初次启动时会经历的阶段,因此它会影响应用的启动时长。

单就BuildingClosure阶段而言,我们观察到该阶段其中一个函数从 480ms 暴增到 1200ms 左右(PC 电脑端运行 dyld 调试统计耗时数据),我们通过优化,将耗时从1200ms降低到110ms。即使相比最开始的情况,也相当于从480ms降低到了110ms,由此可见Building Closure 优化是应用进行启动优化必不可少的一个重要手段。因此在这里我们也和各位读者进行分享,期望能够对各自项目有所帮助。

一、神秘的 BuildingClosure

启动优化的技术、实现方案业界有不少的文章可以参考学习,这里不再额外赘述。我们来探索下启动过程中非常神秘的 BuildingClosure。

BuildingClosure 是在 System Interface Initialization 阶段 dyld 生成的,并且我们也无法做任何的干预,另外相关的剖析文章相对较少,所以说 BuildingClosure 较为神秘,也是实至名归。

BuildingClosure 是由 dyld 在应用启动阶段执行的,所以想要了解 BuildingClosure 还是要从 dyld 开始了解。

1.1 dyld && BuildingClosure

Dyld 源码可以在 Apple GitHub 上查阅 github.com/apple-oss-d…

相信大家都应该了解过,BuildingClosure 是在 iOS 13 引入进来的,对应的 dyld 为 dyld3,目的是为了减少启动环节符号查找、Rebase、Bind 的耗时。

核心技术逻辑是将重复的启动工作只做一次,在 App 首次启动、版本更新、手机重启之后的这次启动过程中,将相关信息缓存到 Library/Caches/com.app.dyld/xx.dyld 文件中,App 在下次启动时直接使用缓存好的信息,进而优化二次启动的速度。

在 iOS 15 Dyld4 中更是引入了 SwiftConformance,进一步解决了运行时 Swift 中的类型、协议检查的耗时。

图片

以上优化,我们都无需做任何工作即可享受 dyld 带来的启动速度的优化,可以感受到 Apple 的开发人员也在关心启动速度并为之做了大量的工作。

1.2 BuildingClosure 非常耗时

我们通过 instrument 观测到 BuildingClosure 的耗时占据了启动耗时将近 1/3 的时间。

虽然说,BuildingClosure 只会在首次启动、版本更新、手机重启的第一次启动生成和耗时,但是对用户的体验影响是非常之大的。

图片

1.3 BuildingClosure 文件解析

我们通过对 dyld 的编译和搭建模拟手机环境,成功模拟器了 dyld 加载可执行文件的过程,也就成功解析了 BuildingClosure 文件。BuildingClosure 文件数据格式如下(数据格式、注释仅供参考,并非全部的数据格式):

图片

BuildingClosure 文件内部结构(数据格式、注释仅供参考)

其中占用比较大的部分主要为 Loader-selectorReferencesFixupsSize SwiftTypeConformance  objcSelector objcClass

二、离奇的启动耗时暴增事件

如上,我们已经对 BuildingClosure 有了基本的了解和对 dyld 的执行过程有了一定的了解。但是这份宁静在某一天突然被打破。

2.1 启动耗时暴增 200ms

在我们一个新版本开发过程中,例行对启动耗时进行跟踪测试,但是发现新版本启动耗时暴增 200ms,可以说是灾难级别的事情。

我们开始对最近的出包做了基本的耗时统计,方式为基于 instrument,统计出来启动各个阶段的耗时数据。经过对比,可以明显观测到,200ms 耗时的增加表现在 BuildingClosure 这个环节。

但是 BuildingClosure 耗时的增加既不是阶梯式增加,也不是线性增加,并且只在新版本有增加。在排除相关因素(动态库、工程配置、打包脚本、编译环境)之后,仍然没有定位明确的原因。

在以上定位工作之后,最终确定耗时确实在 dyld 的 BuildingClosure 阶段耗时,并且怀疑可能是某些代码触发了 Dyld 的隐藏彩蛋。所以我们开始了对 BuildingClosure 更深一步的研究。

2.2 BuildingClosure 耗时异常变化定位

通过使用 Instrument 对 System Interface Initialization 阶段进行堆栈分析,最终发现了耗时最高的函数:dyld4::PrebuiltObjC::generateHashTables(dyld4::RuntimeState&)

在对比了新老版本数据,耗时变化差异的函数也是此函数,我们简称为 generateHashTables。这样使得我们更加确定耗时为 dyld 过程中的 BuildingClosure 阶段。

图片

使用 Instrument 分析 BuildingClosure 阶段耗时

三、启动优化新秘境

在发现 BuildingClosure 生成过程中耗时占比非常大,并且有异常时,起初并没有意识到有什么问题,因为这是 dyld 内的代码,并未感觉会有什么问题。但是一切都指向了该函数,于是开始撸起袖子看代码。

从代码中可以看到,此处是为了生成 BuildingClosure 中 objcSelector objcClass objcProtocol 这三个部分的 HashTable(可以参考上面的 【BuildingClosure 文件解析】部分)。

拿起 dyld 开始对耗时异常版本的可执行文件进行调试,通过对该函数和内部实现的代码逻辑阅读,以及增加耗时信息打印。最终确定,耗时的代码在 make_perfect 这个函数中,这个函数是对【输入的字符串列表】生成一个【完美 Hash 表】。

void PrebuiltObjC::generateHashTables(RuntimeState& state)
{
    // Write out the class table
    writeObjCDataStructHashTable(state, PrebuiltObjC::ObjCStructKind::classes, objcImages, classesHashTable, duplicateSharedCacheClassMap, classMap);
    // Write out the protocol table
    writeObjCDataStructHashTable(state, PrebuiltObjC::ObjCStructKind::protocols, objcImages, protocolsHashTable, duplicateSharedCacheClassMap, protocolMap);
    // If we have closure selectors, we need to make a hash table for them.
    if ( !closureSelectorStrings.empty() ) {
        objc::PerfectHash phash;
        objc::PerfectHash::make_perfect(closureSelectorStrings, phash);
        size_t size = ObjCStringTable::size(phash);
        selectorsHashTable.resize(size);
        //printf("Selector table size: %lld\n", size);
        selectorStringTable = (ObjCStringTable*)selectorsHashTable.begin();
        selectorStringTable->write(phash, closureSelectorMap.array());
    }
}

继续深入了解 make_perfect 这个函数的实现。

3.1 Perfect Hash

通过对研读代码逻辑和耗时分析,最终定位到耗时代码部分为PerfectHash.cpp 中 findhash 函数,这个函数也是 完美散列函数 的核心逻辑。

这里涉及到了一个概念PerfectHash,PerfectHash 的核心是完美散列函数,我们看下维基百科的解释:

zh.wikipedia.org/wiki/%E5%AE…

对集合S的完美散列函数是一个将S的每个元素映射到一系列无冲突的整数的哈希函数

简单来讲 完美散列函数 是【对输入的字符串列表】【为每个字符串生成一个唯一整数】。

for (si=1; ; ++si)
    {
        ub4 rslinit;
        /* Try to find distinct (A,B) for all keys */
        *salt = si * 0x9e3779b97f4a7c13LL; /* golden ratio (arbitrary value) */
        initnorm(keys, *alen, blen, smax, *salt);
        rslinit = inittab(tabb, keys, FALSE);
        if (rslinit == 0)
        {
            /* didn't find distinct (a,b) */
            if (++bad_initkey >= RETRY_INITKEY)
            {
                /* Try to put more bits in (A,B) to make distinct (A,B) more likely */
                if (*alen < maxalen)
                {
                    *alen *= 2;
                }
                else if (blen < smax)
                {
                    blen *= 2;
                    tabb.resize(blen);
                    tabq.resize(blen+1);
                }
                bad_initkey0;
                bad_perfect0;
            }
            continue;                             /* two keys have same (a,b) pair */
        }
        /* Given distinct (A,B) for all keys, build a perfect hash */
        if (!perfect(tabb, tabh, tabq, smax, scramble, (ub4)keys.count()))
        {
            if (++bad_perfect >= RETRY_PERFECT)
            {
                if (blen < smax)
                {
                    blen *= 2;
                    tabb.resize(blen);
                    tabq.resize(blen+1);
                    --si;               /* we know this salt got distinct (A,B) */
                }
                else
                {
                    return false;
                }
                bad_perfect0;
            }
            continue;
        }
        break;
    }

此时通过对比新老版本的数据(使用 dyld 分别运行新老版本的可执行文件对比打印的日志),发现:

  • 老版本循环了 31 次成功生成 HashTable

  • 新版本循环了 92 次成功生成 HashTable

至此,我们距离成功已经非常接近了,于是进一步研读 dyld 源码和增加了更多打印信息代码,最终找到了相互冲突的函数字符串名称。

/*
 * put keys in tabb according to key->b_k
 * check if the initial hash might work
 */
static int inittab_ts(dyld3::OverflowSafeArray<bstuff>& tabb, dyld3::OverflowSafeArray<key>& keys, int complete, int si)
// bstuff   *tabb;                     /* output, list of keys with b for (a,b) */
// ub4       blen;                                            /* length of tabb */
// key      *keys;                               /* list of keys already hashed */
// int       complete;        /* TRUE means to complete init despite collisions */
{
  int  nocollision = TRUE;
  ub4 i;
  memset((void *)tabb.begin(), 0, (size_t)(sizeof(bstuff)*tabb.maxCount()));
  /* Two keys with the same (a,b) guarantees a collision */
  for (i0; i < keys.count(); i++) {
    key *mykey = &keys[i];
    key *otherkey;
    for (otherkey=tabb[mykey->b_k].list_b;
     otherkey;
     otherkey=otherkey->nextb_k)
    {
      if (mykey->a_k == otherkey->a_k)
      {
          // 打印冲突的字符串
        std::cout << mykey->name_k << " and " << otherkey->name_k << " has the same ak " << otherkey->a_k << " si is " << si << std::endl;
        nocollision = FALSE;
          /* 屏蔽此处代码,有冲突的情况下,继续执行,便于打印所有的冲突
    if (!complete)
      return FALSE;
           */
      }
    }
    ++tabb[mykey->b_k].listlen_b;
    mykey->nextb_k = tabb[mykey->b_k].list_b;
    tabb[mykey->b_k].list_b = mykey;
  }
  /* no two keys have the same (a,b) pair */
  return nocollision;
}

根据以上信息,我们已经了解到在Building Closure阶段中,可能存在字符串的 Hash 碰撞 引发循环次数大幅增加,进而引发了启动耗时暴增。

在经过 dyld 调试的耗时数据、构建出包后验证的数据验证后,通过避免 Hash 碰撞,我们完成了启动时长的优化。

3.2 向前一步

其实从打印的冲突函数名称来看,历史代码中已经存在了 Hash 碰撞 的现象。

猜想,如果我们解决了所有的字符串的 Hash 碰撞,岂不是不仅可以修复启动耗时异常上升的问题,还可以进一步降低启动耗时,提高启动速度?

于是我们对每个有碰撞的函数名称进行修改,经过出包验证,结果与我们猜测的一致,启动耗时有明显的下降。

图片

数据为 PC 电脑端运行 dyld 生成 BuildingClosure 的耗时数据,非手机端数据

四、总结

我们探索了 BuildingClosure 的生成过程,发现在Building Closure阶段中,可能存在字符串的 Hash 碰撞 引发循环次数大幅增加,进而引发了启动耗时暴增,进而导致启动耗时的大幅增加。

我们也发现,Building Closure Hash碰撞相关的启动耗时,其实与项目配置、编译环境、打包脚本等均无任何关系,就只是存在了字符串的Hash 碰撞 ,才引发循环次数大幅增加,进而导致启动时长增加。

往期回顾

1.分布式数据一致性场景与方案处理分析|得物技术

2.从对话到自主行动:AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

3.得物技术部算法项目管理实践分享

4.商家域稳定性建设之原理探索|得物技术

5.得物 Android Crash 治理实践

文 / 道隐

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

分布式数据一致性场景与方案处理分析|得物技术

作者 得物技术
2025年4月1日 10:31

一、引言

在经典的CAP理论中一致性是指分布式或多副本系统中数据在任一时刻均保持逻辑与物理状态的统一,这是确保业务逻辑正确性和系统可靠性的核心要素。在单体应用单一数据库中可以直接通过本地事务(ACID)保证数据的强一致性。

然而随着微服务架构的普及和业务场景的复杂化,原来的原子性操作会随着系统拆分而无法保障原子性从而产生一致性问题,但业务实际又需要保障一致性,为此BASE理论提出了最终一致性来解决这类问题。那么如何在跨服务、跨数据库的事务中保证数据最终一致性。

二、CAP理论与BASE理论

在经典的CAP理论中提到一个分布式系统中,一致性(C)可用性(A)分区容错性(P)最多只能同时实现两点,不可能三者兼顾。实际上这是一个伪命题,必须从 A 和 C 选择一个和 P 组合,更进一步基本上都会选择 A,相比一致性,系统一旦不可用或不可靠都可能会造成整个站点崩溃,所以一般都会选择 AP。

1.jpg

BASE理论源于对大规模互联网分布式系统实践的总结,作为CAP定理中一致性与可用性矛盾的实践性补充逐步演化形成。该理论主张在无法保证强一致性的场景下,系统可基于业务特性灵活调整架构设计,通过基本可用性保障、允许短暂中间状态等机制,确保数据最终达成一致性状态,从而在分布式环境中实现可靠服务能力与业务需求的平衡。

三、一致性失效场景及其解决方案

这里有一个简化的仓库上架的流程(在实际业务中可能还会涉及到履约,仓储库存等等),体现分布式系统中可能出现的一致性问题,在分布式系统中的处理流程可能如下所示:

1.操作员操作商品仓库上架 

   商品在仓储系统(WMS)中上架,写入仓储数据库 

   仓储系统通知中央库存系统(SCI)添加可用库存 

   仓储系统通知交易该商品可以进行售卖

2.jpg

简化代码示例:

@Transactional
public void upper(upperRequest request) {

    // 1. 写入仓储数据库
    UpperDo upperDo = buildUpperDo(request);
    wmsService.upper(upperDo);

    // 2. 调用rpc添加中央库存系统库存
    SciAInventoryRequest sciInventoryRequest = buildSciAInventoryRequest(request);
    sciRpcService.addInventory(sciInventoryRequest)

    // 3. 发送商品可以售卖的消息
    TradeMessageRequest tradeMessage = buildTradeMessageRequest(request);
    sendMessageToDealings(tradeMessage);

    // 4. 其他处理
    recordLog(buildLogRequest(request))
    return;
}

整个时序逻辑拆解到事务层面执行流程如下:

3.jpg

在第5步添加sci库存之前任意一步出现问题,事务都会回滚,对其他系统的影响为0,所以不存在一致性问题。

但是,在此之后出现问题都有可能会出现事务问题。

调用写RPC

在分布式系统中,调用RPC一般可以分为着两类: 

1.读RPC:当前数据结构不完整,需要通过其他服务补充数据,对其他服务无影响。 

2.写RPC:当前业务操作、数据变更需要通知其他服务,对其他服务有影响。

调用写RPC添加sci可用库存可能出现的问题:

  • 调用处理成功,返回成功。【数据一致】

  • 调用处理成功,返回失败。【数据不一致】

对于这种情况,最简单的做法是直接操作重试,但是需要下游幂等处理,保证同样的请求效果一致。这里重试的方式,即重新操作上架,此外也可以直接在rpc方法中异步重试机制(这种方式不会阻塞整体流程,但是增大了数据不一致的风险)。如果重试失败可能需要研发介入排查具体失败的原因(对于写RPC的接口超时问题,需要研发关注,配置告警或抛出特定异常等)。

针对RPC方法重试,可以考虑采用本地消息表的方式实现,具体参考3.3.本地消息表。

消息发送

写RPC调用成功后,会给trade服务发送消息,而后提交事务,整个流程结束。

Rocket消息发送有多种方式,不同的方式适用场景不一,一般业务逻辑使用同步发送消息配合重试机制即可,对于一致性要求高的场景,可以考虑事务消息确保消息与本地事务的原子性。

4.jpg

同步消息+重试

同步消息比异步消息更可靠,比事务消息性能更高是一种广泛采用的方式。

同步消息通过confirm机制能保证消息发送成功:生产者发送同步消息后,等待Broker返回确认结果(SendResult)。如果 Broker 成功接收并存储消息,返回成功状态;否则返回失败状态。消息发送失败时,Rocket默认自动重试2次,支持手动设置,提高消息发送的可靠性。

DefaultMQProducer producer = new DefaultMQProducer("ProducerGroup");
producer.setRetryTimesWhenSendFailed(3); // 设置重试次数为 3 次
producer.start();
Message msg = new Message("TopicTest", "TagA", "Hello RocketMQ".getBytes());
SendResult sendResult = producer.send(msg); // 同步发送
if (sendResult.getSendStatus() == SendStatus.SEND_OK) {
    log.info("Send Success: " + sendResult);
} else {
    log.warn("Send Failed: " + sendResult);
}

同步消息+重试机制能尽可能的保证消息成功发送,但是在这种情况下仍可能出现一致性问题:消息成功发送,在提交事务之前,依然可能出现问题(第8步出现问题),导致事务回滚,但是下游的消息是无法回滚的。

为此在RocketMQ中提供了事务消息作为一种解决方案。

RocketMQ事务消息

RocketMQ 的分布式事务消息功能,在普通消息基础上,支持二阶段的提交能力。将二阶段提交和本地事务绑定,实现全局提交结果的一致性。

5.jpg

Rocket的事务消息可以确保消息和本地事务的原子性,但是实现起来很复杂,性能也比较低,特别是需要实现回查本地事务状态,这是一个比较复杂的问题,需要case by case,每一个消息都需要单独写逻辑,还必须确保消息体中的数据支持回查本地事务状态,对代码入侵度较高。

在笔者的了解中我司事务消息的使用情况不多,对于低并发且强一致性的场景可以考虑使用这种方式。在这个业务场景中使用事务消息可以解决3.2.1中出现的消息发送成功但事务回滚的问题,但是这个场景使用这种方式并不太合适。最终结果可能是整体数据一致性提升2%-3%,但是业务性能下降20%-30%。

spring提供给了一种事件发布-订阅机制可以解决事务回滚但消息依然发送成功的问题,并且性能损失几乎可以忽略。

事务事件+同步消息

事务事件是指在事务执行的不同阶段触发的事件。这些事件通常用于处理次要逻辑,例如发送领域事件、消息或者邮件等。

spring通过事务管理@Transactional和事件发布机制ApplicationEventPublisher,可以实现类似事务事件的功能。事件发布后事件广播器(SimpleApplicationEventMulticaster)接收事件,根据事件类型匹配所有的监听者(getApplicationListeners)。

@Service
public class wmsService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Transactional
    public void upper(upperRequest request) {

        // 1. 写入仓储数据库
        UpperDo upperDo = buildUpperDo(request);
        wmsService.upper(upperDo);

        // 3. 发布上架事件
        UpperFinishEvent upperFinishEvent = buildUpperFinishEvent(request)
        eventPublisher.publishEvent(upperFinishEvent);
        return;
    }
}

@Component
public class upperFinishEventListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleUpperFinishEvent(UpperFinishEvent event) {
        // 处理事件

        // 1. 调用rpc添加中央库存系统库存
        SciAInventoryRequest sciInventoryRequest = buildSciAInventoryRequest(event);
        sciRpcService.addInventory(sciInventoryRequest)


        // 2. 发送商品可以售卖的消息
        TradeMessageRequest tradeMessage = buildTradeMessageRequest(event);
        sendMessageToDealings(tradeMessage);

        // 2. 其他处理
        recordLog(buildLogRequest(event))
    }
}

上述流程在写完DB,调用写RPC之后,发布上架完成的事件并提交事务。upperFinishEventListener订阅上架完成的事件,并发送可以售卖的消息。

通过这种方式可以在事务提交之后再发送消息。通过事务事件保证事务提交,通过重试机制和confirm机制确保生产者发送消息成功。

本地消息表

在上述过程中我们选择使用事务事件+同步消息可以来替代事务消息,但是事务事件对RPC调用并不太友好,本地事务提交之后,调用写RPC就一定要成功,不然一致性问题就无法保证。

为此可以考虑使用本地消息表这个方案:将需要分布式处理的事件通过本地消息日志存储的方式来异步执行,通过异步线程或者自动Job发起重试,确保上下游一致。

6.jpg

将上述流程抽象为代码可以实现一个一致性框架,通过注解实现无侵入、策略化、通用性和高复用性的能力。然后本地消息表的方式仍然存在一些问题:

  • 高并发场景不适用,写本地消息会带来延迟可能出现数据积压,影响系统的吞吐量。

  • 业务逻辑过程会长时间的占用事务,造成大事务问题。

  • 本地消息报文巨大,难以存储等。

四、总结

本文分析的场景都是解决生产者端的一致性问题。结合部分场景探讨不同方式的优缺点。

  1. 事务事件+普通消息&重试 :适合对实时一致性要求不高、需要异步处理的场景、适合高并发场景,可靠性一般,实现简单但需手动处理重试和幂等性。

  2. 事务消息 :适合一致性要求较高的场景(如金融交易),性能较低,实现复杂但能确保消息与事务的原子性。

  3. 本地消息表 :适合跨服务事务、异步任务处理和最终一致性场景,高并发场景可能出现数据积压,实现简单且可靠性高,但存在延迟性和资源占用问题。

在分布式系统中,很难有能100%保证一致性的方案,正如《人月神话》中说的“没有不存在缺陷的软件,只是尚未发现缺陷”。

在上面提到的各种方案中,笔者所在团队高并发场景很少,所以一般都采用本地详细表的方式来处理一致性问题,这既可以处理写RPC的调用问题,也能通过消息状态显示的统一失败情况,统一进行重试。

往期回顾

1.从对话到自主行动:AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

2.得物技术部算法项目管理实践分享

3.商家域稳定性建设之原理探索|得物技术

4.得物 Android Crash 治理实践

5.基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

文 / 勇者

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

作者 得物技术
2025年3月27日 11:35

一、引言

从2022年12月份OpenAI发布ChatGPT产品至今已有2年多的时间,当大家已经习惯于在对话框中与AI交互,习惯于通过各种Prompt技巧让AI更好的理解并回答我们的问题,似乎默认这就是一种比较好与AI的交互方式了。

然而,这就是我们期盼的与AI交互的形式嘛?这是一种高效的方式嘛?

显然,这是不够的。

我们期望的是:告诉AI我们想要的目标或者任务,AI能够理解深度理解并分析我们的意图、自动的进行任务的拆解、自动的寻找可以使用的工具、自动的进行结果数据的汇总过滤、自动的呈现符合任务的展示形式。同时在任务处理过程中,可以自己完成异常的检测和修改。就如同一位优秀的同学,我们告诉他任务的目标,他可以自己寻找飞书文档、搜索网络知识、使用内部系统、自己编码验证方案可行性,并最终给一份好的解决方案。

二、以「对话为中心」的ChatBot

我们发送一条指令,AI被动的响应指令。即完成一轮人与AI的交互。

具体视频请前往“得物技术”微信公众号观看。

三、以「交付为中心」的多智能体Agent

我们发送一个任务,AI自动分析任务、调用可用的工具、分析结果、过滤数据并自动处理异常,最终呈现解决方案。

完成这样的一个任务,需要多智能体Agent间的协作以及对常用工具的调用。那什么是智能体Agent呢?

具体视频请前往“得物技术”微信公众号观看。

四、什么是智能体Agent

从Prompt到思维链

随着大模型的发展,Prompt工程已成为撬动大模型潜能的核心技术。即使我们普通用户在与大模型的交互中,也通过角色定义(如"资深工程师")或示例引导来优化输出效果,但这类简单提示往往难以突破模型固有的逻辑天花板——就像给赛车装自行车轮胎,再怎么调整也难以突破速度极限。

但偶然间,人们发现了一个神奇的咒语:只需要告诉大模型,你的 think 要 step by step。研究者发现只要加了这个prompt,就能极为显著地改善大模型做数学题的正确率。

大模型的数学与逻辑能力短板,是所有体验过其对话功能的用户都能直观感受到的痛点。这一缺陷严重制约了大模型的商业化落地进程,毕竟没有人敢轻易信任一个逻辑混乱的智能系统能输出可靠的决策结果。于是,提升大模型数学能力,被所有做基础模型的公司当作了第一目标。

研究者试图通过强化思维链来突破这一瓶颈。一个直观的思路是:让模型像人类解题时在草稿纸上推演那样,通过 "step by step" 的方式展开逻辑链条 —— 在这个过程中,包含假设、演绎、反思、纠错等一系列思维活动。既然人类通过这种结构化的思考方式能够有效解决数学问题,那么大模型是否也能通过类似机制实现能力跃迁?这一猜想推动着研究向纵深发展,最终形成了思维链技术的核心框架。这样的观念经过继续钻研,最终就构成了思维链,思维链是一个能以最小的代价,而非常显著提升模型智力水平(逻辑能力、解题能力、代码能力)的技术。

值得注意的是,2025 年春节期间引发广泛关注的 DeepSeek 大模型,正是思维链技术的成功实践典范。尽管 DeepSeek 并非首创者,但其通过创新性地融合混合专家(MoE)架构与强化学习技术,显著提升了思维链推理的计算效率与性能表现。这种技术优化使得 DeepSeek 在保持高精度推理的同时,大幅降低了计算成本,最终实现了屠榜级表现。

ReAct架构

如果说思维链(COT)是给 AI 装上了人类的 "草稿纸",那么 ReAct 框架就是为它配备了 "双手"—— 让 AI 不仅能在脑子里推演,还能主动采取行动获取信息。这种 "思考 + 行动" 的组合,正在把大模型从 "纸上谈兵" 的理论家,变成能解决现实问题的实干家。

ReAct 的核心在于将**推理(Reasoning)与行动(Action)**紧密结合。当模型面对复杂问题时,会先像人类一样拆解思考步骤,然后根据中间结果调用外部工具(如搜索引擎、数据库、计算器)获取实时数据,再把这些信息整合到后续推理中。

其实,实现一个ReAct很简单,只需要构建Prompt+提供工具+循环执行即可,笔者在这里不进行详细的介绍,只需要给一个Prompt例子,读者就能理解:

尽可能最好地为用户回答接下来的问题,你可以使用以下工具来辅助你:{tools} 使用以下格式:

- 问题:你需要回答的输入问题

- 思考:你需要持续思考下一步采取什么行动 

- 行动:要采取的行动,应该是 [{tool_names}] 中的一个,以及该行动的输入内容 

- 观察:行动并观测结果,并判断结果是否合理 ...(这个思考 / 行动  / 观察可以重复 N 次,直到你认为知道了最终答案 

- 最终答案:原始输入问题的最终答案 

开始! 

- 问题:{input}

Tools支持开发者自定义,比如给予LLM一个查询天气的接口、计算器接口等。

ReAct架构实现了一种**"问题拆解-工具调用-结果整合"闭环机制**,使得开发者仅需通过定义工具集(如天气API、计算器、知识图谱接口)和设计任务引导词,就能将大模型转化为可执行多步骤决策的智能体。最终可以使大模型突破纯文本推理的局限,真正具备了在动态场景中解决开放性问题的工程化能力。

Agent

Agent作为大模型技术的集大成者,通过整合思维链(CoT)的推理能力和ReAct框架的行动机制,构建了具备自主决策与执行能力的智能系统。其核心突破在于将**“大脑”与“四肢”**有机统一,标志着大模型从被动应答迈向主动干预现实的质变。

在架构上,Agent与ReAct差别不大,ReAct是Agent的核心实现范式之一,Agent进一步整合记忆存储、多智能体协作等模块,形成更完整的自主决策系统。下图是一个简单的Agent架构图:

v2ad31f685f1330333011c67eccc3cb64c_1440w.png

Agent处理流程

1-4步会循环进行,直到LLM认为问题已被回答。

1.规划(Planning):

  • 定义:规划是Agent的思维模型,负责拆解复杂任务为可执行的子任务,并评估执行策略。

  • 实现方式:通过大模型提示工程(如ReAct、CoT推理模式)实现,使Agent能够精准拆解任务,分步解决。

2.记忆(Memory):

  • 定义:记忆即信息存储与回忆,包括短期记忆和长期记忆。

  • 实现方式:短期记忆用于存储会话上下文,支持多轮对话;长期记忆则存储用户特征、业务数据等,通常通过向量数据库等技术实现快速存取。

3.工具(Tools):

  • 定义:工具是Agent感知环境、执行决策的辅助手段,如API调用、插件扩展等。

  • 实现方式:通过接入外部工具(如API、插件)扩展Agent的能力,如ChatPDF解析文档、Midjourney文生图等。

4.行动(Action):

  • 定义:行动是Agent将规划与记忆转化为具体输出的过程,包括与外部环境的互动或工具调用。

  • 实现方式:Agent根据规划与记忆执行具体行动,如智能客服回复、查询天气预报、AI机器人抓起物体等。

Manus:一个Agent典型案例

在读完前一节关于智能体(Agent)的技术解析后,读者也许会认为这类系统的工程实现并非难事,实际上也确实是这样。近期爆火的 Agent 产品 Manus 便是典型案例。当用户提出 "定制 7 天日本旅行计划" 的需求时,Manus 能够基于目标,自主进行网络搜索并将信息整合,展现出高度拟人化的任务执行逻辑

2.png

尽管 Manus 目前尚未向普通用户开放,且采用邀请制注册的封闭运营模式,但其通过官方演示视频呈现的强大智能化表现,已在技术圈引发广泛关注。值得关注的是,随着Agent技术的热度攀升,开源社区已迅速涌现出 OpenManus、OWL 等多个复刻项目。

因为Manus并非开源,我们很难了解其技术细节。但好在:

  1. "Manus 的部分技术细节,包括其提示词设计、运行机制等内容被网友通过非官方渠道披露,感兴趣的读者可自行查阅相关公开资料。

  2. 我们可以了解一下大模型上下文协议(Model Context Protocol,MCP),这是 Anthropic (Claude) 主导发布的一个开放的、通用的、有共识的协议标准,虽然Manus不一定用了这个协议,但目前一些相关开源项目也是基于MCP的,本文会在下面介绍MCP。

  3. 目前已有复刻的开源项目Openmanus,笔者会在接下来的章节剖析其源码。

大模型上下文协议(MCP)

MCP是做什么的?

MCP(Model Context Protocol)作为一项开放协议,旨在为应用程序与大型语言模型(LLMs)之间的上下文交互提供标准化框架。其设计理念可类比为数字时代的 "USB-C 接口"—— 正如 USB-C 统一了设备与外设的连接标准,MCP 通过标准化的上下文交互接口,实现了 AI 模型与多样化数据源、工具之间的无缝对接。

如下图所示,图中的MCP server都可以看成一个个工具(如搜索引擎、天气查询),通过“接口”连接到MCP clients(大模型)上,大模型可以使用各种MCP server来更好地处理用户的问题。

此外,下游工具的开发者也可以更好的开发其工具,目前在MCP官网即可了解其各种编程语言的SDK和相关概念。

3.png

MCP架构

MCP 的核心采用客户端-服务器架构,其中 host 可以连接到多个服务器,读者简单看看即可:

img_v3_02kp_bcaed6dcc3e04917a824cf74a340516g.png

  • MCP 主机(MCP Hosts):指需要通过 MCP 协议获取数据的应用程序,涵盖 AI 开发工具(如 Claude Desktop)、集成开发环境(IDEs)等智能应用场景。

  • MCP 客户端(MCP Clients):作为协议的执行者,每个客户端与对应的 MCP 服务器建立一对一的专属连接,负责协议层面的通信交互。

  • MCP 服务器(MCP Servers):轻量化的功能载体,通过标准化的 Model Context Protocol 对外开放特定能力,可视为连接模型与工具的智能桥梁。

  • 本地化数据源(Local Data Sources):包括服务器可安全访问的本地文件系统、数据库及专有服务,构成数据交互的近端生态。

  • 远程服务(Remote Services):通过互联网连接的外部系统,例如各类 API 接口服务,拓展了模型的能力边界。

为什么要用MCP?

从技术演进视角看,MCP 的诞生是提示工程(Prompt Engineering)发展的必然产物。研究表明,结构化的上下文信息能显著提升大模型的任务表现。在传统提示工程中,我们往往需要人工从数据库筛选信息或通过工具检索相关内容,再手动将这些信息注入提示词。然而,随着复杂任务场景的增多,这种手工注入信息的操作变得愈发繁琐且低效。

为解决这一痛点,主流大模型平台(如 OpenAI、Google)先后引入了函数调用(Function Call)机制。该机制允许模型在推理过程中主动调用预定义函数获取数据或执行操作,极大提升了自动化水平。然而,函数调用机制存在显著局限性:其一,不同平台的函数调用 API 存在较大差异,例如 OpenAI 与 Google 的实现方式互不兼容,开发者在切换模型时需重新编写代码,徒增适配成本;其二,该机制在安全性、交互性及复杂场景的扩展性方面仍存在优化空间。

在此背景下,MCP 协议通过标准化的上下文交互接口,为大模型构建了更具普适性的工具调用框架。它不仅解耦了模型与工具的依赖关系,还通过统一的协议规范解决了跨平台兼容性问题。更重要的是,MCP 将上下文管理提升到系统架构层面,为大模型在复杂业务场景中的深度应用提供了可扩展的技术底座。这种从碎片化的提示工程到体系化的上下文协议的演进,标志着大模型应用正在向更高效、更规范的方向迈进。

四、智能体Agent实现的源码剖析(OpenManus项目)

img_v3_02kp_7f7cdb11c5c3435e8bdcc98e38f9cddg.png

OpenManus 是一个基于 MCP 协议的开源智能体实现项目,旨在通过标准化的上下文协议实现大模型与工具的高效协同。当前项目仍处于快速迭代阶段,本文以其 2025 年 3 月 12 日的版本为分析对象。选择该项目的原因如下:

  • 团队背景与代码质量:项目作者来自MetaGPT,具备深厚的工程经验,代码结构清晰且注释完善,兼顾了技术实现与可读性。

  • 部署便捷性:只需通过虚拟环境安装依赖并配置大模型 API Key(如 OpenAI 的 API 密钥),即可快速启动,降低了技术门槛。

  • 技术前沿性:项目紧跟大模型技术发展,且目前仍在不断迭代的过程中。

在经过前面对相关概念的讨论,我们可以得知实现Agent有几个关键的点,读者可以带着问题在项目中寻找答案:

  • Prompt:其结构化的Prompt是什么样的?通过Prompt可以对其架构有一个初步认识。

  • OpenManus:怎么通过大模型思考和处理问题?

  • 工具相关:怎么进行工具注册、工具管理的?工具执行逻辑是什么的?

准备

项目地址:

github.com/mannaandpoe…

构建环境

创建一个python=3.12的虚拟环境

  • 笔者测试了一下,非3.12版本会有一个package不兼容。

  • 可以用conda或python内置的uv,项目文档提供了详细的指令。

安装playwright

  • 如果第一次使用,需要安装playwright。
playwright install
## 或者
python -m playwright install
## 以上命令会安装所有浏览器,如果只需要安装一个浏览器比如firefox
python -m playwright install firefox

配置大模型API Key

  • 可以用DeepSeek或通义千问的API Key,其中通义有免费额度,DeepSeek虽然收费但价格便宜,测试一次使用约1000token,成本不到0.01元。

  • 根据项目文档配置cofig.yaml即可,但项目调用大模型是使用基础的OpenAI API,如果使用其他大模型,可能需要基于对应的官方文档小改一下。

代码

OpenManus客户端

Python OpenManus/main.py即可在终端运行OpenManus,读者也可以尝试其Web版本。

  • 具体会调用20行代码,执行Manus类的方法run()。

img_v3_02kp_037da7610f23414cb15d567f598ac4bg.png

进入OpenManus/app/agent/manus.py查看Manus类,可以发现它继承了ToolCallAgent类,再进入会发现又是继承,有点复杂,这里我画一张关系图。

  • act()执行时使用execute_tools()进行具体的工具执行。

  • 总体来说,Manus类定义了Prompt和可使用的工具。

  • Base类定义了run(),在run()中会循环执行ReAct类的方法step(),直到Finish或达到max_step。

  • step()类会顺序执行ToolCallAgent类的think()和act()。

当然,这里只罗列了重要的组件和方法,一些方法没有画在图中。

img_v3_02kp_e50578ddab27439f91d97a3f5e38943g.jpg

Prompt

一般来说,输入给LLM的prompt分为两种:1)系统 prompt,用于定义模型的角色定位和行为规则;2)用户 prompt(OpenManus称为Next Step Prompt),用于传达具体的任务指令或信息需求。

在OpenManus/app/prompt/manus.py中即可看到Manus的Prompt,这里展示一下中文版,读者基于此可对OpenManus架构有一个初步认识:

  • 系统Prompt(SYSTEM_PROMPT):“你是 OpenManus,一个全能的人工智能助手,旨在解决用户提出的任何任务。你拥有各种可使用的工具,能调用这些工具高效地完成复杂的请求。无论是编程、信息检索、文件处理还是网页浏览,你都能应对自如。”

  • 下一步Prompt(NEXT_STEP_PROMPT):“你可以使用 PythonExecute 与计算机进行交互,通过 FileSaver 保存重要的内容和信息文件,使用 BrowserUseTool 打开浏览器,并使用 GoogleSearch 检索信息。根据用户的需求,主动选择最合适的工具或工具组合。对于复杂的任务,你可以将问题分解,逐步使用不同的工具来解决它。在使用完每个工具后,清晰地解释执行结果并给出下一步的建议。

当然,在实际执行时会对prompt有进一步优化,不过核心的系统定位与任务指导原则是不会改变的。

Manus类

img_v3_02kp_83117adc20bf418fbd98933c2671522g.png

我们先看一下OpenManus拥有的工具,工具也支持自定义,会在后文进行介绍。

  • PythonExecute:执行 Python 代码以与计算机系统交互、进行数据处理、自动化任务等等。

  • FileSaver:在本地保存文件,例如 txt、py、html 等文件。

  • BrowserUseTool:打开、浏览并使用网络浏览器。如果你打开一个本地 HTML 文件,必须提供该文件的绝对路径。

  • GoogleSearch:执行网络信息检索。

  • Terminate:如果LLM认为回答完毕,会调用这个工具终止循环。

Base类

run()

img_v3_02kp_36fbb768418d4f2892b676943131916g.jpg

  • 首先,输入的request就是用户输入的提问。

状态管理

img_v3_02kp_036ebee8ebfd4b4c94cb283d4a071aag.jpg

  • 执行时首先检查代理的当前状态是否为 IDLE(空闲状态)。如果不是空闲状态,会抛出 RuntimeError 异常,因为只有在空闲状态下才能启动代理的执行。

img_v3_02kp_1fa59b67e15247069e103f001a8b2a2g.jpg

  • 当进入循环时前,使用 state_context上下文管理器将代理的状态临时切换到 RUNNING(运行状态)。在上下文管理器中执行的代码块会在进入时将状态切换为指定状态,在退出时恢复到之前的状态。如果在执行过程中发生异常,会将状态切换为 ERROR

Memory管理

我们调用大模型的API,本质是向大模型提供方发http请求,http请求是无状态的。

  • 也就是说,服务端不会保留任何会话信息。对于每次都完成一个独立的任务,无状态是没有任何问题的。但对持续聊天来说,就会出现对之前会话一无所知的情况。

所以为了让大模型持续与用户的对话,一种常见的解决方案就是把聊天历史告诉大模型。

  • 因此,在OpenManus中会进行Memory的管理。

img_v3_02kp_8c1e4d8812b840d9804ed82c2e6b68cg.jpgimg_v3_02kp_c74745982b0042e59b77935079c3b55g.png

  • 用户提供的 request 参数,调用 update_memory 方法将该请求作为用户消息添加到代理的Memory中。

  • 除了这个函数,Manus也在进行think()、act()时也会更新Memory,同时Memory容量也不是无限大的,容量满时需要删除老的Message。

主循环

img_v3_02kp_1ce792754452405cbd686c976d9a2bfg.png

agent本质就是循环执行。

  • step实现参考react step。

  • 循环结束条件:max_steps或者FINISHED状态。

  • 每次执行一个step并获得result——step_result = await self.step()。

  • is_stuck 方法用于检查代理是否陷入了循环(即是否出现了重复的响应)。如果是,则调用 handle_stuck_state 方法处理这种情况,例如添加一个提示来改变策略。

ReAct

step()

img_v3_02kp_3999f1b8a5bb413f826ca4b7c3d8836g.png

  • 这里的逻辑很简单。

ToolcallAgent

Think()

  • 输入:不需要输入,因为用户的question是被存放在Memory中。

  • 输出:一个bool类型,当内部LLM判断需要act()时,为True,否则为Fasle。

询问LLM

img_v3_02kp_ecd6a3006d254268a783101c86d86a0g.png

  • 55行的代码用于调用LLM的API接口,获取回复。

img_v3_02kp_d194c2fca02e47b9be3c05ab5195c25g.png

对应到OpenManus/app/llm.py 233行附近,这里就是基于OpenAI提供的API接口进行对话,具体的参数可参考相应官方文档。

  • 这里会将之前定义的下一步Prompt发给LLM,LLM会根据提供的工具列表,判断是否需要且调用的是哪个工具,当然也可能是:1)不需要工具只进行回复 2)调用Terminate工具结束会话。

下图是一次返回response结果

  • 输入的question是“计算Kobe Bryant的BMI?”,LLM先分析出了要通过浏览器查询资料,因此要use the BrowserUseTool。

  • 根据传入的工具类型等信息,LLM自动构建了执行工具需要用的tool_name、action等参数。

ChatCompletionMessage(
    content="It seems there was an issue with retrieving the information about Kobe Bryant's height and weight through a Google search. To calculate Kobe Bryant's BMI, we need his height and weight. Let's try to find this information by opening a browser and visiting a reliable source. I will use the BrowserUseTool to navigate to a website that provides details about Kobe Bryant's height and weight. Let's proceed with this approach.", 
    refusal=None, 
    role='assistant', 
    annotations=None, 
    audio=None, 
    function_call=None, 
    tool_calls=[        ChatCompletionMessageToolCall(            id='call_aez57ImfIEZrqjZdcW9sFNEJ',            function=Function(            arguments='{
                "action":"navigate",
                "url":"https://www.biography.com/athlete/kobe-bryant"
                }',             name='browser_use'),             type='function')]
)

think后续逻辑

  • think()后续的逻辑比较简单,主要是更新memory(memory存储单位是message),最后在100行附近的逻辑,基于self.tool_choices等参数的设置和LLM返回的工具列表,输出bool类型结果。

  • 同时,需要被调用的工具会被记录到self.tool_calls这个列表中,后续的act()会执行对应的工具。

Act()

  • 输入:同think(),不需要输入。

  • 输出:results,根据工具结果构建的一个字符串。

img_v3_02kp_44e6894bd91540ec82dc03c8e3e970bg.png

  • 这个函数比较简单,主要是调用execute_tool()函数。

Execute_tool()

img_v3_02kp_030fab99df154e819a61d3ff3bed5aeg.png

该函数会调用Tool类提供的接口execute()。

  • Tool类接口会在后面介绍。

同时,对于预设定的special tool,会self._handle_special_tool(name=name, result=result)进行特殊处理。

  • 当前的special tool 只有一个Terminate工具,特殊处理就是设置Agent的状态为AgentState.FINISHED,结束对话。

工具相关

我们在之前介绍了MCP相关的概念,如下图所示:

img_v3_02kp_841aa8ccb6d74423a435decd316bc3bg.png

事实上,OpenManus也是基于MCP的,OpenManus的tool相当于MCP server,根据MCP协议,我们只需要定义tool类支持的方法和参数等,每次注册一个新工具,根据父类override一个子类即可。

那我们首先要了解父类都定义了什么参数和方法,也就是OpenManus/app/tool/base.py定义的Basetool类。

Base Tool

img_v3_02kp_3a61d2518cb343539aad1dd28cd6686g.png

可以看出,代码很简单,每个tool包含的参数为:name、description(提供给LLM看的,对工具的介绍)、parameters(执行工具时要用的参数)。

同时,一个tool支持的方法有execute()和to_param()。

  • execute()用于执行具体的逻辑,每个子类需要override这个方法

  • to_param()将工具调用的结果结构化输出。

当然,这里还有一个python关键字__call__,这个关键字很简单,定义了__call__,该类的实例对象可以像函数一样被调用。

工具JSON

可以根据OpenManus预定义的工具json简单了解一下,每个工具执行时需要的参数。

[
  {
    "type": "function",
    "function": {
      "name": "python_execute",
      "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
      "parameters": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "The Python code to execute."
          }
        },
        "required": ["code"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "google_search",
      "description": "Perform a Google search and return a list of relevant links.\nUse this tool when you need to find information on the web, get up-to-date data, or research specific topics.\nThe tool returns a list of URLs that match the search query.\n",
      "parameters": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "(required) The search query to submit to Google."
          },
          "num_results": {
            "type": "integer",
            "description": "(optional) The number of search results to return. Default is 10.",
            "default": 10
          }
        },
        "required": ["query"]
      }
    }
]

工具示例——google_search

OpenManus项目在OpenManus/app/tool中定义了bash工具、浏览器工具、谷歌搜索工具等,这里简单看一下谷歌搜索工具。

当然,国内可能比较难使用谷歌搜索,OpenManus社区也有大佬提供了baidu、bing等搜索引擎工具。

img_v3_02kp_970ea2580aca4c8980980b7f28db476g.png

可以看出,代码很简单,主要做了两件事。

  • 定义工具参数:name、description、parameters。

  • 定义execute:基于googlesearch库提供的函数进行搜索并返回。

五、总结

OpenManus的代码介绍到这里,主要是介绍一下核心代码,同时,原作者写了planning部分的代码但暂时没有应用到项目中,笔者也没有介绍。如果想对该项目有更进一步的了解,请大家查看github上提供的源码。而且,作者还是非常积极的,每天会有十几个commit。

同时,读者可以简单本地部署玩一下OpenManus,通过几个prompt,就可以知道该项目还是停留在**“玩具阶段”,比如笔者测试了一下,当询问“计算一下科比的BMI?”,OpenManus可以很准确的实现谷歌搜索****——浏览器访问——python计算**这个过程。但如果询问“计算科比、梅西的BMI并排序?”,无论我改写了几次prompt,OpenManus都没有给我满意的回答。

此外,无论是在工具参数信息、还是prompt、memory管理中,都可以看到agent应用大模型token消耗量巨大,即使我们不考虑token成本,但大模型的上下文仍然是有限的,这种资源消耗也会直接导致模型在处理多步骤任务时面临信息截断的风险 —— 早期的关键信息可能因上下文溢出而被丢弃,进而引发推理链条的断裂。更值得警惕的是,当模型试图在有限的上下文中 “脑补” 缺失的信息时,往往会产生与事实不符的幻觉。

鉴于此,尽管 OpenManus 展示出了利用工具链解决复杂问题的潜力,不过距离成为一个实用、高效且稳定的生产级人工智能助手仍有很长的路要走。未来,开发者们或许需要在优化工具使用逻辑、提升多任务处理能力、降低大模型 token 消耗以及增强上下文管理等方面进行深入探索与改进。同时,对于普通用户而言,在体验这类项目时,也应该保持理性和客观的态度,既看到其创新性和趣味性,也认识到其当前存在的局限性。希望在技术的不断迭代和完善下,OpenManus 以及类似的项目能够早日突破现有的瓶颈,真正为人们的工作和生活带来实质性的帮助。

往期回顾

1. 得物技术部算法项目管理实践分享

2. 商家域稳定性建设之原理探索|得物技术

3. 得物 Android Crash 治理实践

4. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

5. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术

文 / 汉堡

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物技术部算法项目管理实践分享

作者 得物技术
2025年3月20日 09:30

一、引 言

在得物技术生态的核心地带,算法作为核心技术力量的中流砥柱,承担着推荐系统、搜索算法、AI 查验鉴别和图像识别等多个前沿且关键领域的研发重任。随着业务的快速扩展和技术的不断迭代,项目管理的重要性日益凸显。如何高效地管理算法项目,确保团队在快速变化的环境中保持高产出和高创新力,成为了我们面临的核心挑战。本文将分享得物技术部算法团队在项目管理进阶过程中的经验与思考。

二、项目管理现状与挑战

2.1 现状

得物算法团队的项目管理历程,是一段充满突破与创新的卓越发展之路。在团队创立初期,项目运作凭借团队成员深厚的专业能力与丰富的经验积累稳步前行。与此同时,我们积极探寻更科学管理的模式,持续探索让项目进度规划和资源分配迈向系统性与前瞻性的方法。

随着团队规模稳步拓展,项目复杂度显著攀升,我们敏锐察觉到传统项目管理模式需要与时俱进。PMO 团队以无畏的探索精神和积极的实践态度,全身心投入创新征程。历经反复尝试与优化,成功打造出一套契合得物算法自身特色的项目管理体系。该体系让团队冲破发展阻碍,实现研发效率飞跃式提升,沟通协作变得更为高效流畅,助力团队在发展道路上稳步迈进,开启全新辉煌篇章。

2.2 挑战

在持续奋进的征程中,得物算法团队在项目管理领域收获了阶段性的成绩,同时,也欣然迎接全新的机遇与挑战,这些挑战也成为我们迈向更高峰的强劲动力:

需求动态优化:市场环境日新月异,业务需求展现出强大的创新活力,快速迭代升级。这促使算法项目的目标和优先级不断优化调整,激发团队持续创新。每一次需求的更新,都是团队展现敏捷应变能力的契机,我们高效重新规划项目路径、灵活调配资源,积极拥抱全新业务要求,在动态变化中精准锚定项目前行方向。

技术攻坚创新:算法项目凭借深厚的技术底蕴,蕴藏着无限的创新潜力。在大量实验与反复迭代进程中,我们无畏探索未知领域,持续突破技术瓶颈。面对实验结果的不确定性与技术难题,项目启动之初,我们便以开拓者的姿态精心规划时间与资源,将挑战转化为技术创新的强大动力,全方位提升项目的综合效益。

跨团队协同增效:算法团队作为创新生态的核心力量,与运营、产品、数据、质量、法务、财务等多个团队紧密合作,共同构建高效协同的创新网络。尽管不同团队专业背景、工作方式各具特色,加之多地办公(上海、北京、杭州、长沙)带来空间上的多元分布,但我们积极搭建沟通桥梁,通过创新的协作模式,打破沟通障碍,确保信息精准传递,让跨团队协作成本转化为协同增效的投入,极大提升项目执行效率。

例如在一次重要项目中,杭州的算法团队与上海的产品团队,通过创新的沟通协作机制,迅速化解了对需求理解的差异,成功追回延误的一周进度,为项目推进注入强大动力。 成果长效评估:算法效果的评估需要具备长远眼光,其价值在持续沉淀中逐步彰显。虽然无法像传统项目那样迅速反馈阶段性进展,但我们建立了科学的评估机制,以长远视角和精准分析,在项目推进过程中及时、深入地洞察项目的实际成效,为项目决策提供坚实可靠的依据,确保项目始终朝着高价值方向稳步迈进。

三、项目管理进阶策略

3.1 引入混合式项目管理方法

为有效应对需求频繁变更和技术复杂度高这两大难题,得物算法团队创新性地采用了混合式项目管理方法,巧妙融合了敏捷开发和瀑布模型的优势:

敏捷开发

通过设置短周期的迭代(Sprint),团队能够快速响应不断变化的需求,确保项目始终保持高度的灵活性。在每个迭代周期内,团队成员聚焦于完成特定的任务模块,通过每日站会、迭代评审等环节,及时获取反馈并做出调整优化。

例如得物质拍项目,这是一个软硬件一体的自主研发项目,它创新性地集成了AI查验功能,打造出了一套拍照查验一体化的解决方案。该方案在实际应用中成效显著,大幅提升了供应链查验的效率和质量,成为推动供应链业务模式从全人工操作向人机协同转变的重要引擎。

在得物质拍项目的推进过程中,面临着诸多复杂的技术难题。比如,如何实现硬件设备与AI算法的高效协同,以确保拍照数据能够准确、快速地被AI系统处理和分析;以及如何在不同的环境条件下,保证设备的稳定性和可靠性等。同时,业务需求也在不断更新变化,对查验的精度、速度以及系统的易用性等方面都提出了更高的要求。

面对这些挑战,敏捷开发模式发挥了关键作用。在项目初期,开发团队采用敏捷开发的迭代式开发方法,将整个项目划分为多个短周期的迭代阶段。每个迭代周期都设定明确的目标和可交付成果,使得团队能够快速响应业务需求的变化。例如,在第一个迭代周期中,团队专注于搭建硬件设备的基本框架和实现AI查验的基础功能,通过快速开发和测试,及时发现并解决了硬件与软件之间的兼容性问题。

在日常工作中,团队坚持每日站会制度。成员们在站会上分享自己的工作进展、遇到的问题以及需要的支持,确保信息在团队内部的高效流通。通过这种方式,团队能够及时发现潜在的风险和问题,并迅速调整开发计划。例如,当发现某个功能的开发进度落后时,团队可以及时调配资源,加快开发速度,保证项目按计划进行。

通过敏捷开发模式的应用,得物质拍项目不仅成功攻克了复杂的技术难题,满足了不断变化的业务需求,还大大缩短了项目的开发周期,提高了开发效率。与传统开发模式相比,项目整体开发周期有效缩短,同时产品的质量和用户满意度也得到了显著提升。

瀑布模型

在项目启动初期,瀑布模型的应用为项目搭建起了清晰的架构和方向。团队按照线性顺序依次完成需求分析、设计、开发、测试、维护等阶段,每个阶段都有明确的输入和输出,以及严格的评审环节,确保项目在每个阶段都能达到预期目标,为后续阶段奠定坚实基础。

例如北极星训练平台项目,北极星作为得物自研的搜推模型训练平台,支持参数多级存储和混合并行分布式训练,比开源框架训练速度提升2~6倍,可支持TB级模型训练,为得物算法策略团队进一步探索大模型和复杂模型打开了空间。

团队在项目初期选用瀑布模型,组织多轮技术PM研讨,对项目的技术方案进行反复论证和优化,明确了以深度学习框架为核心,结合大数据分析技术的技术路线。制定了详细的项目里程碑计划,将整个项目的开发过程划分为多个有序且明确的阶段,每个阶段都设定了具体的任务、交付成果以及验收标准。

在后续长达一年的开发进程中,瀑布模型的优势得以充分彰显。由于前期对技术路线的清晰界定和对项目计划的精准把控,项目始终沿着正确的方向稳步推进。在每个阶段,团队成员严格按照既定计划完成相应任务,前一个阶段的成果作为后续阶段的坚实基础,有效避免了因技术路线模糊不清而引发的项目反复调整以及资源浪费的情况。

最终,项目得以按时交付,并顺利应用于得物的核心业务场景。北极星训练平台为算法策略的持续迭代提供了强大助力,使得算法能够不断优化以适应业务的发展需求。同时,也为业务增长注入了强劲动力,通过更精准高效的搜推模型,提升了用户体验,促进了业务量的显著提升

通过将敏捷开发和瀑布模型相结合,得物算法团队在应对复杂多变的项目环境时,既能够保证项目的灵活性和快速响应能力,又能确保项目在整体上保持清晰的方向和稳定的推进节奏。

3.2 强化需求管理与优先级排序

需求池管理

依据不同的业务场景,构建了与之对应的各条线需求池。团队成员定期与业务方展开深入且全面的沟通交流,这一过程并非简单的信息传递,而是双方基于业务目标和技术能力的深度探讨。通过这种方式,团队能够精准剖析每个需求背后隐藏的业务价值,同时从技术层面评估其可行性。

例如,在C端鉴别业务场景下,业务方提出识别用户是否拼图的需求。算法团队通过业务方提供的场景描述与识别规则,从技术角度给出了开发代价更小的解决方案。这种精准分析避免了项目开展过程中盲目投入资源,有效防止了资源浪费。

优先级排序

为了进一步提升资源利用效率,使项目产生更大的效益,团队采用 ROI评估和业务打分相结合的规则,对需求池中的众多需求进行科学合理的优先级排序。在计算 ROI 时,综合考虑需求实施后的潜在收益、所需投入的资源成本以及实现难度等因素。同时,结合业务一号位OKR、业务重心方向、需求紧迫性等维度给出的综合打分,全面评估每个需求的优先级。

比如,对于一个旨在优化商品搜索算法以提高搜索准确率的需求,团队通过详细的成本效益分析,预估实施该需求后能够提升用户搜索满意度,进而带来一定比例的业务增长,同时评估了实现该需求所需的人力、时间和技术资源成本。再结合业务方对搜索功能在当前业务重心方向的重要性打分,最终确定该需求在需求池中的优先级。通过这种方式,确保团队资源能够集中投入到最具价值的任务上,提高了项目的整体效益。

3.3 优化技术实验流程

实验设计标准化

在算法研发过程中,实验的科学性和规范性直接影响着结果的可靠性。得物算法团队制定了标准化的实验设计模板,在模板中明确规定实验目标、评估指标以及基线模型。实验目标的清晰界定,让团队成员从实验开始就明确努力的方向,避免实验过程中出现目标偏差。

评估指标则为实验结果的衡量提供了统一的标准。以评估社区搜索算法的实验为例,选用内容搜索渗透、内容搜索次留、内容搜索 qvctr、内容搜索有效点击率等作为一级评估指标,这些指标从不同维度反映了用户对搜索结果的使用情况和反馈。以大盘 UV 价值、App 次留、分组 UV 人均 App 时长、内容搜索用户人均社区时长等作为大盘指标,从宏观层面衡量实验对整体业务的影响。通过这些指标的综合运用,使实验结果能够直观地反映算法的性能。如当内容搜索有效点击率提升,同时大盘 UV 价值也随之增长时,就表明该算法在实际应用中可能产生了积极效果。

基线模型的确定也至关重要,它为新算法的对比提供了参照,方便团队判断新算法是否真正带来了性能提升。在社区搜索算法实验中,团队将之前稳定运行的搜索算法作为基线模型,新算法在相同的测试环境和数据集下进行实验,对比两者在各项评估指标上的表现。若新算法在内容搜索qvctr等关键指标上显著优于基线模型,就说明新算法具有一定的优势和应用价值。

通过这一举措,大大减少了实验过程中的不确定性。以往不同成员开展相似实验时,可能因实验设计的差异导致结果难以对比,如今标准化的模板使得不同实验之间的结果更具可比性与可靠性,为算法的优化提供了更坚实的数据基础。

自动化工具支持

开发自动化实验平台,支持实验的快速部署、监控和结果分析,大幅提升实验效率。该平台具备强大的功能,在实验部署环节,能够快速完成实验环境的搭建、数据集的导入以及算法模型的初始化,相比传统手动部署,时间从原来的数小时缩短至半小时以内。在实验进行过程中,平台实现了实时监控,团队成员可以随时查看实验的运行状态、资源使用情况以及关键指标的变化趋势,一旦发现异常能够及时调整。实验结束后,平台还能高效地进行结果分析,自动生成详细的数据报表和可视化图表,帮助团队成员快速理解实验结果。

例如,AI鉴别平台将数据下载、模型训练、模型评估、在线验证、结果分析、发布部署的动作整合至统一平台,任务启动时完成配置后,各个环节任务自动完成,极大节省了人工操作时间与机器等待时间,提升AI研发效能。

3.4 制定标准化的项目管理文档

背景

在过往的发展历程中,得物算法团队积极探索项目管理的优化路径。当时,不同项目的管理文档在格式与内容详略上存在差异,相关信息分布于语雀、云空间、知识库等多个平台。这一状况促使团队深刻认识到建立标准化体系的重要性,进而开启了提升项目管理效能的征程。

实践

为从根本上解决这些问题,团队投入大量精力迁移、整合、建立、维护知识库,并制作了一套全面的标准化项目管理文档模板。这套模板涵盖了项目从启动到结束的各个关键环节,具体包括项目立项书,详细阐述项目的背景、目标、预期成果等,为项目的开展奠定基础;需求文档,清晰记录业务需求、用户需求等,确保团队对项目需求有准确一致的理解;技术方案,展示项目所采用的技术架构、算法选型等核心技术内容;测试用例,用于对项目成果进行全面测试,保障项目质量;LR和 CR文档,规范代码审查流程和变更管理流程;双日会同步文档,及时记录项目进展、问题及解决方案,促进团队成员之间的信息共享。通过这些文档,项目的所有关键信息都能得到准确、完整的记录,为项目管理提供了有力的数据支撑。

机制

同时,团队建立了严格的文档评审机制。在每份文档完成初稿后,会组织相关模块的技术PM、研发TL、测试TL和团队成员进行细致审核。从文档的内容完整性角度,检查是否涵盖了该文档类型应包含的所有关键信息;从准确性方面,核对文档中的数据、技术细节等是否正确无误;在规范性上,确保文档格式、术语使用等符合统一标准。例如,对于技术方案文档,会重点审查技术选型的合理性、技术架构的可行性等内容;对于测试用例文档,会检查测试场景是否全面覆盖了项目需求。只有通过评审的文档,才能进入下一阶段的使用和保存,通过这种方式,确保每一份文档都能达到高质量标准,为项目顺利推进筑牢根基。

3.5 加强跨团队协作:从 “单打独斗” 到 “协同作战”,打造高效的项目团队

明确角色和职责

在跨团队项目中,角色与职责的模糊往往是沟通不畅和工作效率低下的根源。在每个跨团队项目启动之初,组织相关团队共同参与,清晰明确地界定各团队的角色和职责。通过制定详细的项目任务拆解,将每个团队的任务、目标、交付物以及与其他团队的协作边界都一一明确,同时落档到得物研发协同平台(实时化、透明化、自动化)。

以得物AI查验鉴别项目为例,算法团队负责 AI 查验鉴别算法的研发与优化,明确规定了算法的准确率、召回率等关键指标以及完成时间节点;产品团队负责定义产品需求、制定产品规划,确保算法研发与市场需求紧密结合;数据团队则负责数据的收集、清洗和标注,为算法训练提供高质量的数据支持。运营同学负责需求在仓内的推广覆盖。这样一来,每个团队都能清楚知晓自己的任务和目标,避免了因职责不清引发的沟通摩擦和工作推诿现象,为项目的顺利推进奠定了坚实基础。

定期同步会议

为确保信息在各个团队之间能够透明流通,及时解决协作过程中出现的各类问题,定期(双日/周/双周)召开跨团队同步会议。这些会议成为了高效的信息共享平台,各团队在会议中可及时交流项目进展情况,分享遇到的问题和解决方案。

会议通常分为项目进度同步、问题讨论与解决、下一阶段计划制定等环节。在项目进度同步环节,产运研各团队依次同步自己负责部分的进展情况,包括已完成的任务、正在进行的工作以及潜在风险;问题讨论与解决环节,针对各团队提出的问题,大家共同探讨解决方案,充分发挥团队的智慧;下一阶段计划制定环节,根据项目整体目标和当前进展,共同制定下一阶段的工作计划和任务分配。通过定期同步会议,团队成员能够及时了解项目全貌,及时发现并解决问题,确保项目按计划顺利推进。

加强团队沟通

为促进不同团队成员之间的知识共享和技术交流,增强团队的凝聚力和协作能力,团队定期组织新人串讲、技术分享会、代码评审会(CR、LR)等活动。

新人串讲活动为新加入团队的成员提供了展示自己的模块,他们可以分享自己的专业知识、项目经验以及对业务的理解,让老成员也能从新视角获得启发。

技术分享会(组内分享、内部技术分享、得物技术夜校)则是定期调研、分享最新的技术趋势、前沿技术应用以及在项目中遇到的技术难题和解决方案,拓宽团队成员的技术视野。

代码评审会(CR、LR)则是对代码质量的严格把控,通过团队成员之间的相互评审,不仅可以发现代码中的潜在问题,提高代码质量,还能促进团队成员之间的技术交流和学习。

这些活动为团队成员创造了更多交流学习机会,使得团队成员之间的关系更加紧密,协作更加顺畅。

image.png

知识共享

知识的沉淀和共享是团队持续进步的关键。团队不断完善和迭代知识库,将项目过程中积累的宝贵经验和技术文档进行系统沉淀和广泛共享。通过建立知识共享平台,打破了团队之间的信息壁垒,避免了重复劳动,提高了整个团队的工作效率。

通过知识库,团队成员可以方便地查阅过往项目的技术方案、实验报告、问题解决方案等资料。例如,当新成员接手一个类似的算法项目时,可以在知识库上快速找到相关的经验教训和技术参考,避免走弯路;当团队遇到技术难题时,也可以在知识库里搜索类似问题的解决方案,获得灵感和启发。同时,团队鼓励成员积极上传自己在项目中的经验总结和技术成果,不断丰富知识库的内容,形成一个良性的知识循环,促进团队整体能力的提升。

image.png

3.6 量化项目成果:从 “经验驱动” 到

“数据驱动”,用数据夯实项目管理根基

建立项目数据指标体系

建立评估体系:制定算法项目的评估体系,明确定义项目的关键指标,既包括短期指标,如模型准确率、召回率等,能够快速反映算法在当前阶段的性能表现;也涵盖长期指标,如业务增长、用户满意度提升等,用于衡量项目对业务的长期影响。同时,持续记录需求变更率、缺陷率等关键数据,并定期进行收集和深入分析,为项目管理提供全面的数据支持。

数据驱动决策:借助数据分析工具和技术,RDC实时监控项目进展情况。通过对数据的深入挖掘和分析,一旦发现项目存在潜在问题或风险,能够及时调整项目策略,确保项目始终朝着预定目标前进,提高项目的成功率。

利用数据可视化工具

过去,项目数据主要以复杂的表格和文字形式呈现,难以直观展示项目的状态和趋势,不利于团队成员理解和分析。

引入RDC(研发协同平台)、DPP平台(得物个性化推荐系统)、AB实验平台等多样化的数据可视化工具,将项目数据以直观、易懂的方式呈现出来,让团队成员能够一目了然地了解项目的各项关键指标和发展趋势。这使得团队能够更加迅速地做出决策,及时调整项目方向,提高项目的执行效率。

不同与传统项目,算法更多项目是通过实验探索方式来支持的,DPP作为个性化推荐平台,支持同时成百上千个实验同时在线,让算法与业务产品同学,可以去探索自己的思路,在实验维度,支持多种能力,支持垂直桶实验,正交配置层实验,跨场景贯穿实验,同时DPP平台联合数据平台与监控团队支持实时,小时级与天级等等各维度实验指标的展现,通过各种维度为算法同学提供工具,可以更快速判断出自己实验的情况,再进行后续决策。

image.png

image.png

基于数据进行项目复盘和优化:

过程质量复盘 & 业务效果复盘

实践:以往的项目复盘往往流于形式,未能充分挖掘项目过程中的经验教训。如今,在项目结束后,团队基于详细的数据进行全面深入的项目复盘。从过程质量维度,分析项目执行过程中的流程是否合理,如项目进度安排是否紧凑高效、资源分配是否合理;资源利用是否高效,如人力、机器资源是否得到充分利用,是否存在浪费现象。从业务效果维度,注重项目对业务目标的达成情况,如是否实现了预期的业务增长、用户满意度是否提升等。通过总结成功经验和不足之处,为后续项目的管理提供宝贵参考。例如,在复盘某个算法优化项目时,通过数据发现项目前期由于需求变更频繁,导致项目进度延误,后续项目就可以加强需求管理;同时发现算法优化后业务增长明显,但用户满意度提升不显著,后续就可以在提升用户体验方面加大投入。

机制:建立完善的项目复盘机制,明确复盘的流程、参与人员和时间节点。规定在每个项目上线后一个月内,必须组织项目复盘会议,由项目负责人、产运研核心参与人以及技术PM、TL参与。复盘会议中同步项目是否符合预期,再进行数据展示和分析,最后共同讨论总结经验教训。确保复盘工作能够常态化、有效地开展。

成果:通过基于数据的项目复盘和优化,团队的项目管理水平得到了稳步提升,项目成功率持续提高。不断总结和改进的过程,使得团队能够更好地应对各种复杂的项目挑战,为业务的发展提供更有力的技术支持。

image.png

四、结 论

项目管理是算法团队高效运作的基石。通过引入混合式管理方法、优化需求管理和技术实验流程、加强跨团队协作以及量化项目成果,得物技术部算法团队在项目管理进阶之路上迈出了坚实的步伐。未来,我们将继续探索和创新,为业务创造更大的价值。

往期回顾

1.  基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

2. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术

3. 一个Rust小白发布生产级Rust应用的进阶之路 | 得物技术

4. 得物小程序平台设计与实践

5.商家域稳定性建设之原理探索|得物技术

作者:聴禾

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

活动推荐: 得物技术沙龙第27期「AI大模型的客服技术实践与应用」杭州站 火热报名中,详情至得物技术公众号查看(掘金活动页面 3月30日-杭州活动也可以直接报名)。

商家域稳定性建设之原理探索|得物技术

作者 得物技术
2025年3月18日 10:45

一、稳定性及其意义

什么是稳定性?

我们先来探讨一个核心概念:稳定性 。想象一下,一个系统、一个物体或一个过程在受到外部干扰或内部变化时,能否如一面坚实的墙壁,屹立不倒?在信息系统的世界里,稳定性的定义就如同这面的墙壁,它确保在各种干扰面前,我们服务依旧保持可用。

然而,尽管这个定义听上去很清晰,若要用它来推动我们的稳定性建设,却显得有些模糊。因此,我们需要深入探讨如何将这一概念转化为切实可行的方法论。

我们将方法论定义为影响结果的公式,并为稳定性写下了如下公式:

稳定性 = 全局风险可见性 * 风险转化概率 * 故障可感知 * 预案可靠性

但随着实践经验的增长,再对上述公式因子进行正交分析后,我发现稳定性其实可以简化为:

稳定性 = 系统风险(概率) * 风险应对能力

我们进一步拆分这一关系:

系统风险(概率) = 固有风险(概率) + 变更风险(概率)

其中固有风险对应稳定性概念的内部变化,工程上可以定义为网络、服务器等运行环境变化。而变更风险,则是包括发布、配置变更在内的,由人为发起的系统变化。一般固有风险会由运维团队进行关注,因此我们主要展开变更风险:

变更风险 = 变更频率 * 变更复杂度 * 变更爆炸半径

其中:

变更频率: 代表变更的发生次数,它一般和业务的需求有关。

变更复杂度: 这不仅限于代码的可理解性和可修改性,还包括配置的复杂性。一般来说,复杂度越高,单次变更出问题的概率也越大。

变更爆炸半径: 表示发生问题后的影响面,直接影响实际的损失。这个爆炸半径也有很多的衡量因子,如QPS、场景重要性、强弱依赖关系等等。

接下来我们对风险处理能力进行展开:

风险处理能力 = 风险前置发现概率 * 前置风险处理 * 后置风险发现时长 * 应急效果

其中,风险前置发现可以定义为在测试阶段发现问题的场景,由于线下的风险总会有手段可以处理,因此前置风险处理并不会成为瓶颈;后置风险则可定义为上线之后暴露的问题,由于生产问题总会暴露,其关键影响点为发现时长,以及完成应急后最终的影响面,即应急效果

如果认可这些稳定性公式的拆解推导步骤,我们最终可以将稳定性拉成一个庞大的公式:

稳定性 =(固有风险 + 变更频率 * 变更复杂度 * 变更爆炸半径

风险前置发现概率 * 前置风险处理)后置风险发现时长 * 应急效果

而这个公式,涵盖了影响稳定性的一系列关键因素,这些关键因素,也将为后续的稳定性建设定下基础。

为什么要进行稳定性建设?

在进行如何建设稳定性的探讨之前。我们先来讨论一个问题:为什么我们如此重视稳定性?这可以从两个重要方面来理解:

1.失去稳定性的损失 :

  • 直接经济与业务损失 : 想象一下,系统故障导致下单链路出现异常,订单急剧下降;营销逻辑错误,导致优惠券被滥发,直接造成公司的资损。

  • 信任度与隐形资产损失 : 故障不可用或大规模的技术问题可能引发舆论危机,给品牌形象带来巨大损害。尤其对云服务厂商而言,稳定性故障更可能导致客户的大量流失。

2.具备稳定性的好处 :

  • 提高业务迭代效率,使得团队能迅速应对市场变化。

  • 节约值班等额外的投入,减少资源浪费。

此时,大家一定会疑惑,稳定性差的损失容易理解,但良好稳定性与业务迭代的高效又有何关系呢?回到我们的变更风险公式,你会发现,变更复杂度与业务迭代效率之间存在着显著的负相关关系,容易的变更交付的快,复杂的变更交付的慢,这很好理解。因此:良好的稳定性 -> 低变更风险 -> 低变更复杂度 -> 高迭代效率,形成了一个合理的逻辑链。

稳定性建设究竟要建什么?

在推导出稳定性公式之前,这个命题简直宽泛地让人无从下手。但推导出公式之后,所有的关键点都已经成竹在胸!(这也是方法论的魅力所在)

1.1中,我们已经给出了稳定性的公式,并标红了关键因子。当我们应用上这个方法论时,问题就变得清晰起来:稳定性建设的目标应该聚焦于之前提及的关键因素,借此我们可以制定出实用的治理项如下:

图片

当然,每个治理项都能进一步拆分成若干举措。由于每个团队、每个应用的生命周期阶段不同、实际特性不同,因此各团队在需要重点治理的方向和举措上均有所不同。但总归跳不出这个框架。

二、稳定性建设面临什么困难?

稳定性建设在当今技术驱动的时代至关重要,但它常常被视为“重要但不紧急”的任务,导致在排期过程中得不到必要的优先级支持。许多时候,团队甚至不得不依赖于故障的驱动才能艰难推进稳定性建设。这一现象的根源,可以归结为以下几个方面。

稳定性建设缺少立竿见影的短期价值

其一:量化价值不明确,收益评估困难

我们可以从上文提到的稳定性公式中找到一些线索,尤其是两个非常重要的因子:变更复杂度和风险前置发现概率。变更复杂度实际上对应的是研发引入的单次变更中携带风险的概率,而风险前置发现概率是经过研发和测试团队的努力后,变更风险仍被遗漏到生产环境中的可能性。

正是因为在稳定性的衡量公式计算中,带入了这2个概率因子,稳定性建设的量化价值的不确定性就显而易见了。概率常常需要经过大量的样本统计才能形成有效的量化指标,但在实操时聚焦到某个团队、某个应用或某个具体的治理需求中时,它提升的概率影响往往不足以成为一个可以衡量的量化指标。甚至运气不好的时候,可能会出现治理越多故障越多的离谱事件。

其二:业务压力重,稳定性任务排不上优先级

我们不妨再回到稳定性的关键因子,尤其是变更频率这个有意思的指标。我们很容易通过公式推导得出:变更频率越高的功能,其稳定性治理的收益也越大。然而,正如你所想的,这类功能往往是在业务高速发展时诞生的,此时需求繁多且时间紧迫,在这样的情况下,稳定性治理的优先级与业务的迭代需求相较,无疑排不上号。

而当业务进入稳定期,变更频率下降,终于有时间投入稳定性治理了,但变更频率的下降又同样带来了稳定性风险的减小,治理优先级随之降低。此时的稳定性治理就变成了“食之无味,弃之可惜”的鸡肋工程,仍旧排不上优先级。

稳定性建设存在极大的复杂性和风险

与上文所述的不确定收益相对的,却是稳定性建设确定性的复杂度和风险。无论是风险识别、风险治理,还是风险预防,均需要投入大量的精力。

存量风险识别的难度

在解决问题的第一步中,发现问题是重中之重。稳定性建设的第一步同样是识别其中的稳定性问题。但问题是,我们该如何发现这些问题呢?依靠故障或者TS工单吗?这种方式确实可以帮助我们发现问题,并在后续解决问题。但这种亡羊补牢的操作,对于稳定性的建设而言,实在是太过滞后,根本达不到预期的效果。

为了有效地防范问题发生,我们需要从整体上排除风险——它大到一个域,几十个应用,成千上万条调用链路;小到一个git仓库,数万甚至数十万代码——要准确评估整个域的稳定性并识别其中的风险,这无疑是一项巨大的挑战。

风险治理的难度

风险识别已经足够困难,风险治理的复杂性同样不容忽视。虽然理论上,技术同学从不畏惧已知问题,但不同问题背后的复杂性,也往往会带来不同的治理难度。

首当其冲的自然是技术同学最头疼的排期问题,“世上无难事,只要有排期”。但正如2.1中提到的,稳定性建设由于价值的不确定性,往往难以取得足够的排期。即便是再高瞻远瞩的管理者,也不得不严格控制技术投入的占比,将更多的资源用于服务业务,创造更多的增长。

其次,稳定性治理本身带来的变更风险也不容小觑。 这里贴上技术人员非常喜欢的一张图,来贴切地表示这个难题。

图片

上图的这个房屋,毫无疑问是个风吹就塌的危房。但谁又真敢动手对这样的危房进行稳定性治理呢?如果就是个普通房屋,推倒重建就完了,但业务系统可无法停机。在这种情况下进行代码改动,就如同需要持续地挪动木头、泥土和石块,试图将其替换为坚固的建筑材料,却很可能无意中移走某个重要支撑,导致整个系统崩溃。

这在稳定性建设中是不可接受的。为了预防可能发生的故障,反而引入了变更故障,这实属本末倒置。

至于另外一种治理方案……新建一个系统,然后把流量切过去。如果面对类似图片这种治理难度地狱级的项目,确实是个最佳选择,并且该方法也确实大量应用在架构治理上(如服务拆分)。但大部分应用,使用该方案又着实奢侈了。叠加上文提及的排期问题,也限制了这种方案成为稳定性治理银弹的可能性。

如果继续深入探讨变更风险的问题,我们必然会碰到“代码债务”的概念。每一位技术开发者都对代码债务耳熟能详,深有感触。它通常定义为低代码质量和不合理架构设计等一系列技术负担,而这些问题并非立刻显现出危害。一个重的代码债务,只要在生产环境中能够正常运行,就意味着它是能被接受的。即如图中的房子再怎么危房,没塌之前,住人防风挡雨都是没问题的。

然而,代码债务阻碍了变更,无论是业务的迭代还是技术的治理,都会提升变更的风险。因此,最后的风险治理难度来到了稳定性风险因子中的变更复杂度问题。

命名为变更复杂度,而非代码复杂度这种客观描述,也是意味着变更难度是包含主观含义,是因人而异的。 例如某个应用由一位同学贯穿始终地维护,代码再复杂,变更复杂度也高不到哪里去。因为这份代码从始至终,都是由同一个人,以他的思维框架,解读业务链路后,再抽象建设而成的,这份代码从头到脚都是这位同学的形状。他知道这些代码从何而来,又应当往哪里去。但实际生产过程中,一个应用往往要经历多人维护,就必然出现信息传递的损失。最终,我们在面对这种代码时,大概率会遇到理解困难以及对变更后果的无能为力。因此,变更复杂度的本质,是由不同人员的思维方式、设计理念、编码习惯,以及业务知识在传递过程中的信息偏差交织在一起,构成的一种现象。

至此,排期、变更复杂度、变更风险三者,构成了整个稳定性风险治理的难度。

增量风险预防的难度

在此前的讨论中,我们已经探讨了存量风险的识别和治理。而本节将重点关注每次变更引入的增量风险。这是一个不可忽视的领域,因为风险的根源在于变更,而变更又是业务发展的必然过程。那么,如何有效控制这些因变更而来的风险呢?

变更可见性

首先,最重要的是确保变更的可见性和可感知性 。这里所说的可见性,不仅仅是变更执行者本人的知晓,更是整个团队乃至所有相关方共同的认知。毕竟,执行变更的同事自然会清楚自己做了什么,但真正的问题在于,执行变更的同事是否知道这些变更意味着什么,这个认知和其他相关人员——比如PM和测试人员——是否是一致的?

这就是为什么变更可见性如此重要。做过业务负责人的都知道,最担心的事情就是业务/产品和技术说要改个什么一句话功能,或者是刷数等操作,技术同学顺手就给做了;因为功能点太小,甚至都没通知测试和PM,直接自测完就上线了,真就映着一句话:天知地知,你知我知。但这个却是风险最高的行为,因为没有任何人帮助变更同学进行二次确认,不出问题都是侥幸。

那么难点来了,对一个域少则几十,多则数百的同学,每个迭代也是几十个需求,上百种不同类型的变更,怎么保证每个微小的变更,都能让变更的所有相关方都感知到,并且进行有效的二次确认呢?

方案可控性

在确认了变更共识后,下一步便是对变更方案本身进行评估,从而确保每项调整都符合预期。但此时,又出现了一个障碍:如何保证这个变更是符合预期的呢?

对于一项代码的变更,它不仅会对这行代码的所有上游场景产生影响,更会影响所有使用到这行代码结果的下游场景。若是数据的变更,更是牵一发而动全身,所有读取和写入到这行数据的场景都要受到影响。由于整体链路的复杂性和不可控性,对于变更方案的风险可控性评估就显得异常困难。

人员可靠性

最后,涉及到的还有变更执行人员本身的可靠性 ,人是同时具备高上限和低下限的特性的。即便是一个优秀的同学,即有高瞻远瞩,防范未然的时候;也有马失前蹄,被"!"和“NullPointException”搞得焦头烂额的时候。

那么怎样在各种各样的变更中,去保障人员的下限,不要让这种人员的波动性影响到系统的稳定性;甚至尽可能让人员保持他们的高上限,将更多的稳定性风险扼杀在摇篮之中?这便是稳定性建设中最后一个需要重点考虑的问题。

三、如何进行稳定性建设?

经过前面的铺垫,我们已经明确了稳定性建设的重要性,以及在实施过程中面临的种种挑战。那么,问题的关键就在于如何克服这些困难,顺利进行稳定性的建设。实际操作中,很多治理建设的思路和策略都已经隐含在前文的分析中,现在我们只需将它们整合提炼出来。

建立稳定性共识

从上文知,困难中排在第一点的,即是稳定性治理的优先级和的资源排期问题。因此,在解决稳定性建设的客观困难之前,首先需要业务团队内部从主观层面建立对稳定性的一致认知:即业务团队需要针对本团队业务的重要性、发展阶段、风险情况进行综合评估,确定好稳定性建设在本团队中的重要程度。

直白点说,这个共识就是业务团队确定好稳定性建设将在团队总投入中的时间占比。

这个占比可以在迭代维度进行波动,但周期拉长到季度、年维度的时候,是需要保持在一个符合预期的比例的。它可以是5%或者更低,也可以是10%甚至更高,具体的数值需要和团队目前的业务和技术现状相匹配。

明确稳定性建设目标

当确定了资源比例后,接下来就是明确具体的目标。在此过程中,我们需制定可执行的方案,将大方向细化为明确的阶段目标。

回到1.3脑图中提供的三个大方向:

  • 风险前置发现 : 侧重于人和流程的管理;

  • 变更风险控制 : 关注系统性架构建设;

  • 风险后置处理 : 着眼于应急响应,同时关注人的应急流程和系统的预案建设;

因此,归根结底,稳定性的目标收敛成是练人、建系统两种。我们通常建议先从练人中的强化意识和流程入手,再优化系统,最后持续性地提高人员的综合能力。

这是因为加强团队意识和规范团队流程的投入相对较低,通过制定规范、流程,进行宣导培训甚至考试等形式,不需要投入过多资源就能取得良好效果。这种意识的培养,虽然不会立即影响故障率,但有助于营造稳定性的文化氛围,为长期的治理打下基础。其次,在这一过程中,无需直接修改代码,在初期可以尽量避免“越治理越故障”的困境。最后,加强团队意识和规范团队流程,有助于后续保护好稳定性治理结果。避免一边堵漏,一边挖坑的迷惑行为。

需要注意的是,稳定性建设是一个动态变化的过程。随着时间的推移,人可能会逐渐懈怠,系统架构也可能因为业务迭代而腐化。因此,稳定性建设必须是一个周期性的工作,并且建议每一个季度都专注于1-2项关键点,使得整个系统的稳定性可以在螺旋中上升。

落地稳定性建设任务

为了有效实施稳定性建设,我们将其任务进一步细分为五个核心部分:意识培养、 安全生产规范、应急响应、日常巡检和架构治理 。接下来,我们将逐一阐述这五个部分的重要性及其具体实现方案,并表述清楚这些部分应对的是上述的那些困难点。

意识培养

意识培养是提升团队成员在稳定性建设中能动性的关键环节。它主要涵盖三个方面:认知、意愿和能力。换句话说,我们要弄清楚团队成员对于稳定性的认知程度、愿意投入的程度以及他们的能力如何。

认知 :团队成员是否充分了解稳定性的重要性。

针对这一点,我们可以定期举办“谨慎编码”宣讲,以提高大家的意识。虽然这看似简单,但不可忽视。因为如果长期不提及稳定性,其重要性就会在潜意识中随时间弱化。

意愿 :团队成员是否愿意花费时间和精力去评估并解决稳定性风险。

评估稳定性风险往往需要深入细致的工作,还需要克服习惯、自信、侥幸、嫌麻烦等心理障碍。为了提升意愿度,可从奖、惩两方面入手。如可以通过设置稳定性红线或进行故障复盘来进行必要的惩罚,同时引入激励机制,比如对表现突出的团队成员进行表彰或绩效激励。此外权责到人也是激发意愿的手段之一,当同学有了固定负责的应用,并有权限进行完全控制时,会更愿意吃透其业务,保证其代码整洁和稳定。

能力 :团队成员能否识别风险,并设计有效的解决方案。

这块可提升点就很多了:如一是案例分享可以扩展同学眼界,通过举一反三可以避免同类问题的发生;二是沉淀组内/域内的稳定性知识库,将团队的能力沉淀下来,将团队的智慧变成个人的智慧,提高同学能力上限;三是寻求组内同学的帮助也是一种方法,这适合于发现了问题后,在设计方案时进行组内交流,查缺补漏,共同设计完备的解决方案。

当然,意识培养,或者说人的培养,同样是一个庞大复杂的体系,这里仅针对三个关键因素进行粗浅的解读,更多内容可以关注一些专业书籍。

安全生产规范

安全生产规范,我们定义为为了保证变更风险可控而制定的一系列流程规范。但很多人对于这些流程规范可能不以为然,认为繁琐的过程除了降低效率外并没有什么实际的益处。但其实,这些环节的存在是对变更方案及其风险进行二次确认的重要保障。

在一个典型的需求变更流程中,一般会有需求评审、技术方案评审、用例评审、自测/测试环节、CR、验收等多个环节。为什么需要有这么多环节呢?

  • 需求评审: 针对业务变化带来的功能变化,在产品、研发、测试之间达成一致,进行多方确认

  • 技术方案评审: 针对功能变化对应的技术变化,在产品、研发、测试之间达成一致,进行多方确认

  • 用例评审: 针对功能变化/技术变化带来的用例变化,在产品、研发、测试之间达成一致,进行多方确认

  • 自测/测试环节: 针对技术变化的正确性和完备性,在研发、测试之间达成一致,进行二次确认

  • CR: 针对技术变化对应的代码变化,在研发团队内部进行风险确认,属于二次确认

  • 验收: 针对功能变化的最终效果,在产品、研发、测试之间达成一致,属于多方确认

可以看到,通过这些环节,变更的可见性将得以显著提升:几乎所有的相关方,都能够准确知道变更内容、变更方案和变更时间,并共同确认过变更风险。正因为这些环节在现有的需求流程中多半能够充分落实,因此需求变更带来风险的概率是相对较低的。

与需求变更的多方确认相反的是,技改需求、curl、数据订正、Ark变更等操作,在技术部多次管控加码之前,这些变更操作发生问题的概率远高于需求变更。其原因正是由于这些变更可能就是某个研发顺手操作了,其可见范围极小。根本没有相关方进行多轮有效的二次确认操作,容易出问题也就不足为奇了。其他类似的案例还有业务方突然执行了大量的业务变更操作,突然进行了某项营销活动导致引入远超预期的流量等等,这同样也是由于变更的可见性并未被技术团队感知,而导致的变更风险。

因此,安全生产规范,就是用来约定当任意变更产生时,需要通过何种流程将该变更通知到所有相关方,并通过何种方式进行多方确认,共同确保变更风险可控的共识方案。 了解了安全生产的本质后,各团队就完全可以针对自己的业务特性和所有的变更场景,制定专属的安全生产规范。其完备性取决于变更场景的完备性,其有效性取决于多方确认的有效性。 这样,也同时回答了“如何制定一个好的安全生产规范”这个问题。

应急响应

应急响应主要分三个部分:发现响应处理;关键的标准则是及时性有效性及时性确保了问题的影响不被放大,有效性则确保了已经发生的影响能被控制和修复。

发现: 发现的关键点是及时。若不考虑及时性,客户进线、结算错误这种后置发现手段,是可以发现所有的问题的。但这种通过实际的业务损失来发现问题的方案,显然不符合预期。因此,必须通过系统的手段,做好监控、告警布防,不论是系统资源使用率、服务可用性情况,还是业务数据正确性、波动值,均要做好完善的布控,方可及时发现。

响应: 响应的要点同样也是及时,它关系到已经出现的异常事件是否有人立即进行跟进处理,它一方面和意识培养直接相关,对应人的责任意识。另一方面对应的工具的正确使用,诸如手机、电脑、飞书等通知配置,也是关系到值班同学是否能第一时间获取到紧急事件通知的关键。

处理: 问题处理是应急响应的最后一环,它需要兼顾及时性和有效性:是否能够快速定位根因?是否能够有效止血?这就不仅和个人的能力有关,也和系统的完备性有关。

个人能力这块基本和3.3.1提及的内容一致。但系统能力的完备性,则同样可以展开大量的建设任务,如:为了定位问题根因: 告警信息的重要性(是否提供关键信息快速定位),日志信息的完备性和串联性(是否能够提供足够的信息定位问题,是否提供的信息均是重要的关联信息,减少不必要的噪声),都是非常重要的基础建设。

为了快速止血:除了通过个人的能力快速找到止血方案外,更重要的在于系统是否预设过相应的故障,并提供了止血预案。如果有,往往可以快速解决问题。但如果没有,要在短时间内解决问题,往往难度极高。如果操作不当,容易引入额外的风险。

最后,团队中关于应急问题处理的知识库也是非常重要的知识沉淀,有助于不熟悉该业务的同学,也能够快速定位和处理问题。

日常巡检

“防患于未然”是我们维护稳定性的重要目标。通过日常巡检,团队能够识别潜在的风险苗头。或是慢SQL、或是慢接口,或是cpu突刺。包括业务数据量是否逐步增长到了危险的范围,各项活动/配置是否临近过期,上下游的调用量是否接近容量上限……等等,这些风险,均可以在相应的巡检中发现问题,避免潜在风险逐步积累引发的灾难性后果。

架构治理

如果之前的部分主要关注人员层面的提升,那么架构治理则是从代码层面提升系统稳定性的一项重要措施,能够真正提升系统抗风险的硬实力。

从稳定性共识中可知,架构治理的能影响的关键因子为变更风险 ,而变更风险主要包括变更频率变更复杂度变更爆炸半径

对应的领域建模、高内聚低耦合、OO等的架构原则,反映到变更风险中,就是控制了变更复杂度。因为内聚性,变更多可以聚焦在单一应用中,爆炸半径也同时得到控制。

资源隔离的架构设计,则是专门用于控制爆炸半径,不论是容器资源、线程池,还是DB、redis,甚至是P0/PN链路拆分等,均为控制爆炸半径,避免相互影响。

还有一种特殊的称作B/C流量拆分,这种看似是爆炸半径,但实际上也控制了变更频次。或者更精确的说法,是运营/B/C三端拆分,它的逻辑除了流量来源不同之外,更在于场景和变更频次不同。一般可以认为B端/运营端的供给侧,相较于C端的消费侧,会有更复杂的模型,更高的变更频次。进行这几端的拆分,更多在于减少C端(往往更核心)的变更频次,减少变更时的相互影响。

关于架构治理还有一个关键点,那就是抓住主要矛盾,先从最核心的业务场景开始治理。如果没有考虑好治理优先级,那么茫茫多的场景和链路就会成为一个交织在一起的毛线球,是无法进行抽丝剥茧逐一治理的。

资损防控

最后还有一个特殊的稳定性场景,资损防控。它在稳定性建设中比较特殊,是一个强业务相关的防控方案。一般可以在事前、事中、事后三个环节进行防控。

事前环节: 一般考虑防呆拦截/提醒、二次确认、审批流等多轮操作确认;更深入的可以增加结果预计算、影响面提示、前后对比等重提示,给到使用方对于执行后果的直观展示,减少误操作可能性。

事中环节: 一般会有资金上限熔断、实时/准实时Dcheck预警、相关资金指标波动预警等策略,在出现资损风险的时候进行预警,或业务熔断。

事后环节: 一般会采取T+1对账,确认多方资金数据一致。并辅以货款抵扣、调账等工具,在发生资金差额的情况下,进行金额补偿。

稳定性建设的困难是否都被解决了?

最后,让我们回过头来复盘一下第2节中提到的困难,看看这些拦路虎是否在本节中被逐一击破。

首先是短期价值不明确带来的争议,这块我们通过建立团队的稳定性共识得到彻底的解决。

其次稳定性建设的复杂性和风险性:

  • 先说相对明确的增量风险预防:3.3.2中的安全生产规范整个存在的意义就是为了通过流程来控制增量风险。

  • 然后是风险治理的难度:该问题先可以通过架构治理进行分而治之,将大问题拆解成若干个小问题;再通过安全生产规范,控制每次解决小问题引入风险的概率。

  • 最后是存量风险识别的难度:日常巡检有助于发现存量风险的苗头,意识培养则有助于对单应用风险的摸排,架构治理则对应了对于应用间、甚至整个域内的依赖链路风险评估和治理。

至此,所有的核心困难点都有了解决的方案,稳定性治理不再是一座不可逾越的高山,剩下的无非是根据具体问题,照着公式,逢山开路,遇水搭桥了。

四、稳定性建设全景图

通过以上的探讨,我们不仅分析了稳定性建设的重要性,还从理论角度,揭示了稳定性建设的核心要素与挑战,提供了具体的解决方案和建设任务。简单统合一下,就生成了下面的稳定性建设全景图,希望能为正在努力追求系统稳定性的小伙伴们提供启发与帮助。

当然,其中的支撑事项仅是抛砖引玉,每个团队都可以因地制宜,设计有团队特色的支撑事项。只要是能够服务于上层的建设目标,就具备落地的价值。

图片

往期回顾

1. 得物 Android Crash 治理实践

2. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

3. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术

4. 一个Rust小白发布生产级Rust应用的进阶之路 | 得物技术

5. 得物小程序平台设计与实践

文 / 裁衣(Joker)

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物 Android Crash 治理实践

作者 得物技术
2025年3月13日 17:07

一、前言

通过修复历史遗留的Crash漏报问题(包括端侧SDK采集的兼容性优化及Crash平台的数据消费机制完善),得物Android端的Crash监控体系得到显著增强,使得历史Crash数据的完整捕获能力得到系统性改善,相应Crash指标也有所上升,经过架构以及各团队的共同努力下,崩溃率已从最高的万2降至目前的万1.1到万1.5,其中疑难问题占比约90%、因系统bug导致的Crash占比约40%,在本文中将简要介绍一些较典型的系统Crash的治理过程。

二、DNS解析崩溃

背景

Android11及以下版本在DNS解析过程中的有几率产生野指针问题导致的Native Crash,其中Android9占比最高。

堆栈与上报趋势

at libcore.io.Linux.android_getaddrinfo(Linux.java)
at libcore.io.BlockGuardOs.android_getaddrinfo(BlockGuardOs.java:172)
at java.net.InetAddress.parseNumericAddressNoThrow(InetAddress.java:1631)
at java.net.Inet6AddressImpl.lookupAllHostAddr(Inet6AddressImpl.java:96)
at java.net.InetAddress.getAllByName(InetAddress.java:1154)

#00 pc 000000000003b938  /system/lib64/libc.so (android_detectaddrtype+1164)
#01 pc 000000000003b454  /system/lib64/libc.so (android_getaddrinfofornet+72)
#02 pc 000000000002b5f4  /system/lib64/libjavacore.so (_ZL25Linux_android_getaddrinfoP7_JNIEnvP8_jobjectP8_jstringS2_i+336)

上报趋势.jpeg

问题分析

崩溃入口方法InetAddress.getAllByName用于根据指定的主机名返回与之关联的所有 IP 地址,它会根据系统配置的名称服务进行解析,沿着调用链查看源码发现在parseNumericAddressNoThrow方法内部调用Libcore.os.android_getaddrinfo时中有try catch的容错逻辑,继续查看后续调用的c++的源码,在调用android_getaddrinfofornet函数返回值不为0时抛出GaiException异常。

https://cs.android.com/android/platform/superproject/+/android-9.0.0_r49:libcore/ojluni/src/main/java/java/net/InetAddress.java

static InetAddress parseNumericAddressNoThrow(String address) {
       // Accept IPv6 addresses (only) in square brackets for compatibility.
       if (address.startsWith("[") && address.endsWith("]") && address.indexOf(':') != -1) {
           address = address.substring(1, address.length() - 1);
       }
       StructAddrinfo hints = new StructAddrinfo();
       hints.ai_flags = AI_NUMERICHOST;
       InetAddress[] addresses = null;
       try {
           addresses = Libcore.os.android_getaddrinfo(address, hints, NETID_UNSET);
       } catch (GaiException ignored) {
       }
       return (addresses != null) ? addresses[0] : null;
   }
https://cs.android.com/android/platform/superproject/+/master:libcore/luni/src/main/native/libcore_io_Linux.cpp?q=Linux_android_getaddrinfo&ss=android%2Fplatform%2Fsuperproject

static jobjectArray Linux_android_getaddrinfo(JNIEnv* env, jobject, jstring javaNode,
        jobject javaHints, jint netId) {
    ......
    int rc = android_getaddrinfofornet(node.c_str(), NULL, &hints, netId, 0, &addressList);
    std::unique_ptr<addrinfo, addrinfo_deleter> addressListDeleter(addressList);
    if (rc != 0) {
        throwGaiException(env, "android_getaddrinfo", rc);
        return NULL;
    }
    ......
    return result;
}

解决过程

解决思路是代理android_getaddrinfofornet函数,捕捉调用原函数过程中出现的段错误信号,接着吃掉这个信号并返回-1,使之转换为JAVA异常进而走进parseNumericAddressNoThrow方法的容错逻辑,和负责网络的同学提前做了沟通,确定此流程对业务没有影响后开始解决。

首先使用inline-hook代理了android_getaddrinfofornet函数,接着使用字节封装好的native try catch工具做吃掉段错误信号并返回-1的,字节工具内部原理是在try块的开始使用sigsetjmp打个锚点并快照当前寄存器的值,然后设置信号量处理器并关联当前线程,在catch块中解绑线程与信号的关联并执行业务兜底代码,在捕捉到信号时通过siglongjmp函数长跳转到catch块中,感兴趣的同学可以用下面精简后的demo试试,以下代码保存为mem_err.c,执行gcc ./mem_err.c;./a.out

#include <stdio.h>
#include <signal.h>
#include <setjmp.h>

struct sigaction old;
static sigjmp_buf buf;

void SIGSEGV_handler(int sig, siginfo_t *info, void *ucontext) {
    printf("信号处理 sig: %d, code: %d\n", sig, info->si_code);
    siglongjmp(buf, -1);
}

int main() {
    if (!sigsetjmp(buf, 0)) {
        struct sigaction sa;

        sa.sa_sigaction = SIGSEGV_handler;
        sigaction(SIGSEGV, &sa, &old);

        printf("try exec\n");
        //产生段错误
        int *ptr = NULL;
        *ptr = 1;
        printf("try-block end\n");//走不到
    } else {
        printf("catch exec\n");
        sigaction(SIGSEGV, &old, NULL);
    }
    printf("main func end\n");
    return 0;
}

//输出以下日志
//try exec
//信号处理 sig: 11, code: 2
//catch exec
//main func end

inline-hook库: github.com/bytedance/a…

字节native try catch工具: github.com/bytedance/a…

三、MediaCodec 状态异常崩溃

背景

在Android 11系统库的音视频播放过程中,偶尔会出现因状态异常导致的SIGABRT崩溃。音视频团队反馈指出,这是Android 11的一个系统bug。随后,我们协助音视频团队通过hook解决了这一问题。

堆栈与上报趋势

#00 pc 0000000000089b1c  /apex/com.android.runtime/lib64/bionic/libc.so (abort+164)
#01 pc 000000000055ed78  /apex/com.android.art/lib64/libart.so (_ZN3art7Runtime5AbortEPKc+2308)
#02 pc 0000000000013978  /system/lib64/libbase.so (_ZZN7android4base10SetAborterEONSt3__18functionIFvPKcEEEEN3$_38__invokeES4_+76)
#03 pc 0000000000006e30  /system/lib64/liblog.so (__android_log_assert+336)
#04 pc 0000000000122074  /system/lib64/libstagefright.so (_ZN7android10MediaCodec37postPendingRepliesAndDeferredMessagesENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEERKNS_2spINS_8AMessageEEE+720)
#05 pc 00000000001215cc  /system/lib64/libstagefright.so (_ZN7android10MediaCodec37postPendingRepliesAndDeferredMessagesENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEi+244)
#06 pc 000000000011c308  /system/lib64/libstagefright.so (_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE+8752)
#07 pc 0000000000017814  /system/lib64/libstagefright_foundation.so (_ZN7android8AHandler14deliverMessageERKNS_2spINS_8AMessageEEE+84)
#08 pc 000000000001d9cc  /system/lib64/libstagefright_foundation.so (_ZN7android8AMessage7deliverEv+188)
#09 pc 0000000000018b48  /system/lib64/libstagefright_foundation.so (_ZN7android7ALooper4loopEv+572)
#10 pc 0000000000015598  /system/lib64/libutils.so (_ZN7android6Thread11_threadLoopEPv+460)
#11 pc 00000000000a1d6c  /system/lib64/libandroid_runtime.so (_ZN7android14AndroidRuntime15javaThreadShellEPv+144)
#12 pc 0000000000014d94  /system/lib64/libutils.so (_ZN13thread_data_t10trampolineEPKS_+412)
#13 pc 00000000000eba94  /apex/com.android.runtime/lib64/bionic/libc.so (_ZL15__pthread_startPv+64)
#14 pc 000000000008bd80  /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64)

状态异常崩溃上报趋势.jpeg

问题分析

根据堆栈内容分析Android11的源码以及结合SIGABRT信号采集到的信息(postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING),找到崩溃发生在onMessageReceived函数处理kWhatRelease类型消息的过程中,onMessageReceived函数连续收到两条消息,第一条是kWhatError:STOPPING,第二条是kWhatRelease:STOPPING此时因mReplyID已经被置为空,因此走到判空抛异常的逻辑。

cs.android.com/android/_/a…

问题分析1.jpeg问题分析2.jpeg问题分析3.jpeg问题分析4.jpeg 对比Android12的源码,在处理kWhatRelease事件且状态为STOPPING抛异常前,增加了对mReplyID不为空的判断来规避这个问题。

cs.android.com/android/_/a…

规避这个问题.jpeg

解决过程

Android12的修复方式意味着上述三个条件结合下吃掉异常是符合预期的,接下来就是想办法通过hook Android11使逻辑对齐Android12。

【初探】最先想到的办法是代理相关函数通过判断走到这个场景时提前return出去来规避,音视频的同学尝试后发现不可行,原因如下:

  • void MediaCodec::postPendingRepliesAndDeferredMessages(std::string origin, status_t err): 匹配origin是否为特征字符串(postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING);很多设备找不到这个符号不可行;
  • void MediaCodec::onMessageReceived(const sp&msg): 已知MediaCodec实例的内存首地址,需要通过hardcode偏移量来获取mReplay、mState两个字段,这里又缺少可供校验正确性的特征,风险略大担心有不同机型的兼容性问题(不同机型新增、删除字段导致偏移量不准)。

【踩坑】接着尝试使用与修复DNS崩溃类似思路的保护方案,使用inline-hook代理onMessageReceived函数调用原函数时使用setjmp打锚点,然后使用plt hook代理_android_log_assert函数并在内部检测错误信息为特征字符串时通过longjmp跳转到onMessageReceived函数的锚点并作return操作,精简后的demo如下:

Plt-hook 库: github.com/iqiyi/xHook

#include <iostream>
#include <setjmp.h>
#include <csignal>

static thread_local jmp_buf _buf;
void *origin_onMessageReceived = nullptr;
void *origin__android_log_assert = nullptr;

void _android_log_assert_proxy(const char* cond, const char *tag, const char* fmt, ...) {
    //模拟liblog.so的__android_log_assert函数
    std::cout << "__android_log_assert start" << std::endl;
    if (!strncmp(fmt, "postPendingRepliesAndDeferredMessages: mReplyID == null", 55)) {
        longjmp(_buf, -1);
    }
    //模拟调用origin__android_log_assert,产生崩溃 
    raise(SIGABRT);
}

void onMessageReceived_proxy(void *thiz, void *msg) {
    std::cout << "onMessageReceived_proxy start" << std::endl;
    if (!setjmp(_buf)) {
        //模拟调用onMessageReceived原函数(origin_onMessageReceived)进入崩溃流程
        std::cout << "onMessageReceived_proxy 1" << std::endl;
        _android_log_assert_proxy(nullptr, nullptr, "postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING");
        std::cout << "onMessageReceived_proxy 2" << std::endl;//走不到
    } else {
        //保护后从此处返回
        std::cout << "onMessageReceived_proxy 3" << std::endl;
    }
    std::cout << "onMessageReceived_proxy end" << std::endl;
}

int main() {
    std::cout << "main func start" << std::endl;
    /**
     inline-hook: shadowhook_hook_sym_name("libstagefright.so","_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE",(void *) onMessageReceived_proxy, (void **) &origin_onMessageReceived);
     plhook: xh_core_register("libstagefright.so", "__android_log_assert", (void *) (_android_log_assert_proxy), (void **) (&origin__android_log_assert));
     */
    //模拟调用libstagefright.so的_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE函数
    onMessageReceived_proxy(nullptr, nullptr);
    std::cout << "main func end" << std::endl;
    return 0;
}

/**
日志输出
 main func start
onMessageReceived_proxy start
onMessageReceived_proxy 1
__android_log_assert start
onMessageReceived_proxy 3
onMessageReceived_proxy end
main func end
*/

线下一阵操作猛如虎经测试保护逻辑符合预期,但是在灰度期间踩到栈溢出保护导致错误转移的坑,堆栈如下:

#00 pc 000000000004e40c  /apex/com.android.runtime/lib64/bionic/libc.so (abort+164)
#01 pc 0000000000062730  /apex/com.android.runtime/lib64/bionic/libc.so (__stack_chk_fail+20)
#02 pc 000000000000a768 /data/app/~~JaQm4SU8wxP7T2GaSWxYkQ==/com.shizhuang.duapp-N5RFIB8WurdccMgAVsBang==/lib/arm64/libduhook.so (_ZN25CrashMediaCodecProtection5proxyEPvS0_)
#03 pc 0000000001091c0c  [anon:scudo:primary]

*关于栈溢出保护机制感兴趣的同学可以参考这篇文章bbs.kanxue.com/thread-2217…

(CSPP 第3版 “3.10.3 内存越界引用和缓冲区溢出”章节讲的更详细)*

longjmp函数只是恢复寄存器的值后从锚点处再次返回,过程中也唯一可能会操作栈祯只有inline-hook,当时怀疑是与setjmp/longjmp机制不兼容,由于inline-hook内部逻辑大量使用汇编来实现排查起来比较困难,因此这个问题困扰比较久,网上的资料提到可以使用代理出错函数(__stack_chk_fail)或者编译so时增加参数不让编译器生成保护代码来绕过,这两种方式影响面都比较大所以未采用。有了前面的怀疑点想到使用c++的try catch机制来做跨函数域的跳转,大致的思路同上只是把setjmp替换为c++的try catch,把longjmp替换为throw exception,精简后的demo如下:

c++异常机制介绍: baiy.cn/doc/cpp/ins…

#include <iostream>
#include <csignal>

void *origin_onMessageReceived = nullptr;
void *origin__android_log_assert = nullptr;

class MyCustomException : public std::exception {
public:
    explicit MyCustomException(const std::string& message)
            : msg_(message) {}

    virtual const char* what() const noexcept override {
        return msg_.c_str();
    }

private:
    std::string msg_;
};

void _android_log_assert_proxy(const char* cond, const char *tag, const char* fmt, ...) {
    //模拟liblog.so的__android_log_assert函数
    std::cout << "__android_log_assert start" << std::endl;
    if (!strncmp(fmt, "postPendingRepliesAndDeferredMessages: mReplyID == null", 55)) {
        throw MyCustomException("postPendingRepliesAndDeferredMessages: mReplyID == null");
    }
    //模拟调用origin__android_log_assert,产生崩溃
    raise(SIGABRT);
}

void onMessageReceived_proxy(void *thiz, void *msg) {
    std::cout << "onMessageReceived_proxy start" << std::endl;
    try {
        //模拟调用onMessageReceived原函数(origin_onMessageReceived)进入崩溃流程
        std::cout << "onMessageReceived_proxy 1" << std::endl;
        _android_log_assert_proxy(nullptr, nullptr, "postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING");
        std::cout << "onMessageReceived_proxy 2" << std::endl;//走不到
    } catch (const MyCustomException& e) {
        //保护后从此处返回
        std::cout << "onMessageReceived_proxy 3" << std::endl;
    }
    std::cout << "onMessageReceived_proxy end" << std::endl;
}

int main() {
    std::cout << "main func start" << std::endl;
    /**
     inline-hook: shadowhook_hook_sym_name("libstagefright.so","_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE",(void *) onMessageReceived_proxy, (void **) &origin_onMessageReceived);
     plhook: xh_core_register("libstagefright.so", "__android_log_assert", (void *) (_android_log_assert_proxy), (void **) (&origin__android_log_assert));
     */
    //模拟调用libstagefright.so的_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE函数
    onMessageReceived_proxy(nullptr, nullptr);
    std::cout << "main func end" << std::endl;
    return 0;
}

/**
日志输出
 main func start
onMessageReceived_proxy start
onMessageReceived_proxy 1
__android_log_assert start
onMessageReceived_proxy 3
onMessageReceived_proxy end
main func end
*/

灰度上线后发现有设备走到了_android_log_assert代理函数中的throw逻辑,但是未按预期走到catch块而是把错误又转移为" terminating with uncaught exception of type" ,有点搞心态啊。

【柳暗花明】C++的异常处理机制在throw执行时,会开始在调用栈中向上查找匹配的catch块,检查每一个函数直到找到一个具有合适类型的catch块,上述的错误信息代表未找到匹配的catch块。从转移的堆栈中注意到没有onMessageReceived代理函数的堆栈,此时基于inline-hook的原理(修改原函数前面的汇编代码跳转到代理函数)又怀疑到它身上,再次排查代码时发现代理函数开头漏写了一个宏,在inline-hook中SHADOWHOOK_STACK_SCOPE就是来管理栈祯的,因此出现找不到catch块以及前面longjmp的问题就不奇怪了。加上这个宏以后柳暗花明,重新放量后保护逻辑按预期执行并且保护生效后视频播放正常。和音视频的小伙伴一努力下,经历了几个版本终于解决了这个系统bug,目前仅剩老版本App有零星的上报。

四、bio多线程环境崩溃

背景

Android 11 Socket close过程中在多线程场景下有几率产生野指针问题导致Native Crash,现象是多个线程同时close连接时,一个线程已销毁了bio的上下文,另外一个线程仍执行close并在此过程中尝试获取这个bio有多少未写出去的字节数时出现野指针导致的段错误。此问题从21年首次上报以来在得物的Crash列表中一直处于较前的位置。

堆栈与上报趋势

at com.android.org.conscrypt.NativeCrypto.SSL_pending_written_bytes_in_BIO(Native method)
at com.android.org.conscrypt.NativeSsl$BioWrapper.getPendingWrittenBytes(NativeSsl.java:660)
at com.android.org.conscrypt.ConscryptEngine.pendingOutboundEncryptedBytes(ConscryptEngine.java:566)
at com.android.org.conscrypt.ConscryptEngineSocket.drainOutgoingQueue(ConscryptEngineSocket.java:584)
at com.android.org.conscrypt.ConscryptEngineSocket.close(ConscryptEngineSocket.java:480)
at okhttp3.internal.Util.closeQuietly_aroundBody0(Util.java:1)
at okhttp3.internal.Util$AjcClosure1.run(Util.java:1)
at org.aspectj.runtime.reflect.JoinPointImpl.proceed(JoinPointImpl.java:3)
at com.shizhuang.duapp.common.aspect.ThirdSdkAspect.t(ThirdSdkAspect.java:1)
at okhttp3.internal.Util.closeQuietly(Util.java:3)
at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.java:42)
at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.java:1)
at okhttp3.internal.connection.ExchangeFinder.find(ExchangeFinder.java:6)
at okhttp3.internal.connection.Transmitter.newExchange(Transmitter.java:5)
at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.java:5)

#00 pc 0000000000064060  /system/lib64/libcrypto.so (bio_ctrl+144)
#01 pc 00000000000615d8  /system/lib64/libcrypto.so (BIO_ctrl_pending+40)
#02 pc 00000000000387dc  /apex/com.android.conscrypt/lib64/libjavacrypto.so (_ZL45NativeCrypto_SSL_pending_written_bytes_in_BIOP7_JNIEnvP7_jclassl+20)

bio多线程.jpeg

问题分析

从设备分布上看,出问题都全是Android 11且各个国内厂商的设备都有,怀疑是Android 11引入的bug,对比了Android 11 和 Android 12的源码,发现在Android12 崩溃堆栈中的相关类 com.android.org.conscrypt.NativeSsl$BioWrapper有四个方法增加了读写锁,此时怀疑是多线程问题,通过搜索Android源码的相关issue以及差异代码的MR描述信息,进一步确认此结论。通过源码进一步分析发现NativeSsl的所有加锁的方法,会分发到NativeCrypto.java中的native方法,最终调用到native_crypto.cc中的JNI函数,如果能hook到相关的native函数并在Native层实现与Android12相同的读写锁逻辑,这个问题就可以解决了。

cs.android.com/android/pla… cs.android.com/android/pla… cs.android.com/android/pla…

解决过程

通过JNI hook代理Android12中增加锁的相关函数,当走到代理函数中时,先分发到JAVA层通过反射获取ReadWriteLock实例并上锁再通过跳板函数调用原来的JNI函数,此时就完成了对Android12 增量锁逻辑的复刻。经历了两个版本的灰度hook方案已稳定在线上运行,期间无因hook导致的网络不可用和其它崩溃问题,目前开关放全量的版本崩溃设备数已降为0。

解决过程.jpegJNI hook原理,以及详细修复过程: blog.dewu-inc.com/article/MTM…

五、小米Android15 焦点处理空指针崩溃

背景

随着Android15开放公测,焦点处理过程中发生的空指针问题逐步增多,并在1月份上升到Top。

堆栈与上报趋势

java.lang.NullPointerException: Attempt to invoke virtual method 'android.view.ViewGroup$LayoutParams android.view.View.getLayoutParams()' on a null object reference
at android.view.ViewRootImpl.handleWindowFocusChanged(ViewRootImpl.java:5307)
at android.view.ViewRootImpl.-$$Nest$mhandleWindowFocusChanged(Unknown Source:0)
at android.view.ViewRootImpl$ViewRootHandler.handleMessageImpl(ViewRootImpl.java:7715)
at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:7611)
at android.os.Handler.dispatchMessage(Handler.java:107)
at android.os.Looper.loopOnce(Looper.java:249)
at android.os.Looper.loop(Looper.java:337)
at android.app.ActivityThread.main(ActivityThread.java:9568)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:593)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:935)

问题分析

通过分析ASOP的源码,崩溃的触发点是mView字段为空。

cs.android.com/android/pla…

问题分析5.jpeg 源码中mView为空的情况有两种:

  • 未调用setView方法前触发窗口焦点变化事件(只有setView方法才会给mView赋不为空的值)。
  • 先正常调用setView使mView不为空,其它地方置为空。

结合前置判断了mAdded为true才会走到崩溃点,在源码中寻找到只有先正常调用setView以后在调用dispatchDetachedFromWindow时才满足mAdded=true、mView=null的条件,从采集的logcat日志中可以证明这一点,此时基本可以定位根因是窗口销毁与焦点事件处理的时序问题。

时序问题.jpeg时序问题2.jpeg

解决过程

在问题初期,尝试通过 Hook 拦截 handleWindowFocusChanged 方法增加防御:当检测到 mView 为空时直接中断后续逻辑执行。本地验证阶段,通过在 Android 15 设备上高频触发商详页 Dialog 弹窗的焦点获取与关闭操作,未复现线上崩溃问题。考虑到 Hook 方案的侵入性风险 ,且无法本地测试,最终放弃此方案上线。

通过崩溃日志分析发现,问题设备100% 集中在小米/红米机型,而该品牌在 Android 15 DAU中仅占 36% ,因此怀疑是MIUI对Android15某些定制功能有bug。经与小米技术团队数周的沟通与联合排查,最终小米在v2.0.28版本修复了此问题,需要用户升级ROM解决,目前>=2.0.28的MIUI设备无此问题的上报。

六、总结

通过上述问题的治理,系统bug类的崩溃显著减少,希望这些经验对大家有所帮助。

文 / 亚鹏

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

作者 得物技术
2025年3月6日 14:58

一、背景

随着得物离线业务的快速增长,为了脱离全托管服务的一些限制和享受技术发展带来的成本优化,公司提出了大数据Galaxy开源演进项目,将离线业务从全托管且封闭的环境迁移到一个开源且自主可控的生态系统中,而离线开发治理套件是Galaxy自研体系中一个核心的项目,在数据开发IDE中最核心的就是SQL编辑器,我们需要一个SQL解析引擎在SQL编辑提供适配得物自研Spark引擎的语法定义,实时语法解析,语法补全,语法校验等能力,结合业内dataworks和dataphin的实践,我们最终选用ANTLR作为SQL解析引擎底座。

二、ANTLR4 简介

ANTLR(一种语法解析引擎工具)是一个功能强大的解析器生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。它广泛用于构建语言、工具和框架。ANTLR可以根据语法规则文件生成一个可以构建和遍历解析树的解析器。

ANTLR4 特性

ANTLR4 是一个强大的工具,适合用于语言处理、编译器构建、代码分析等多种场景。它的易用性、灵活性和强大的特性使得它成为开发者的热门选择。

  • 强大的文法定义:ANTLR4 允许用户使用简单且易读的文法语法来定义语言的结构。这使得创建和维护语言解析器变得更加直观,同时在复杂文法构造上支持左递归文法、嵌套结构以及其他复杂的文法构造,使得能够解析更复杂的语言结构。
  • 抽象语法树遍历:ANTLR4 可以生成抽象语法树,使得在解析源代码时能够更容易地进行分析和变换。AST 是编译器和解释器的核心组件。同时提供了简单的 API 来遍历生成的语法树,使得实现代码分析、转换等操作变得简单
  • 自动语法错误处理:ANTLR4 提供了内置的错误处理机制,可以在解析过程中自动处理语法错误,并且可以自定义错误消息和处理逻辑
  • 可扩展性:ANTLR4 允许用户扩展和自定义生成的解析器的行为。例如,您可以自定义解析器的方法、错误处理以及其他功能。
  • 工具&社区生态:ANTLR4 提供了丰富的工具支持,包括命令行工具、集成开发环境插件和可视化工具,可以帮助您更轻松地开发和调试解析器。同时拥有活跃的社区,提供了大量的文档、示例和支持。这使得新用户能够快速上手,并得到必要的帮助。

ANTLR4 的应用场景

Apache Spark: 流行的大数据处理框架,使用ANTLR作为其SQL解析器的一部分,支持SQL查询。 Twitter: Twitter 使用ANTLR来解析和分析用户的查询语言,这有助于他们的搜索和分析功能。 IBM: IBM使用ANTLR来支持一些其产品和工具中的DSL(领域特定语言)解析需求,例如,在其企业集成解决方案中。

ANTLR4入门

ANTLR元语言

为了实现一门计算机编程语言,我们需要构建一个程序来读取输入语句,对其中的词组和符号进行识别处理,即我们需要语法解释器或者翻译器来识别出一门特定语言的所有词组,子词组,语句。我们将语法分析过程拆分为两个独立的阶段则为词法分析和语法分析。

antlr4入门.jpeg

ANTLR语法遵循了一种专门用来描述其他语言的语法,我们称之为ANTLR元语言(ANTLR’s meta-language)。ANTLR元语句是一个强大的工具,可以用来定义编程语言的语法。通过定义词法和语法规则,可以基于antlr生成解析器和词法分析器。

1、自顶向下 在语言结构中,整体的辨识都是从最粗的粒度开始,一直进行到最详细的层次,并把它们编写成为语法规则,ANTLR4就是采用自顶向下的,词法语法分离,上下文无关的语法框架来描述语言。

// MyGLexer.g4
lexer grammar MyGLexer;

SEMICOLON: ';';
LEFT_PAREN: '(';
RIGHT_PAREN: ')';
COMMA: ',';
DOT: '.';
LEFT_BRACKET: '[';
RIGHT_BRACKET: ']';
LEFT_BRACES: '{';
RIGHT_RACES: '}';
EQ: '=';

FUNCTOM: 'FUNCTION';
LET: 'LET';
CONST: 'CONST';
VAR: 'VAR';
IF: 'IF';
ELSE: 'ELSE';
WHILE: 'WHILE';
FOR: 'FOR';
RETURN: 'RETURN';
// MyGParser.g4
parser grammar MyGParser;

options {
  tokenVocab = MyGLexer;
}

// 入口规则
program: statement* EOF;

statement:
  variableDeclaration
  | functionDeclaration
  | expressionStatement
  | blockStatement
  | ifStatement
  | whileStatement
  | forStatement
  | returnStatement;
  ......

2、语言模式

计算机语言常见4种语言模式:序列(sequence)、选择(choice)、词法符号依赖 (token dependency),以及嵌套结构(nested phrase)。以下是ANTLR对4种模式的语法规则描述。

语言模式.jpeg

3、语法歧义

在自顶向下的语法和手工编写的递归下降语法分析器中,处理表达式都是一件相当棘手的事情,这首先是因为大多数语法都存在歧义,其次是因为大多数语言的规范使用了一种特殊的递归方式,称为左递归。

expr : expr '*' expr
     | expr '+' expr
     | INT
     ;

我们举个运算符优先级带来的语法歧义问题,同样的规则可以匹配多个输入字符流

匹配多个输入字符流.jpeg

在其他语法工具中,通常通过指定额外的标记来指定运算符优先级。而在ANTLR4中通过备选分支的排序来指定优先级,越靠前优先级越高。

代码自动生成

ANTLR可以根据lexer.g4和parser.g4自动生成词法分析器,语法分析器,监听器,访问器等。

antlr4ng -Dlanguage=TypeScript -visitor -listener -Xexact-output-dir -o ./src/lib ./src/grammar/*.g

代码自动生成.jpeg

语法解析与业务逻辑解耦

在ANTLR4中语法解析和业务逻辑的高度解耦是一个重要的设计理念,优点就是同一个 AST 结构能够在不同的业务逻辑实现之间实现复用。不同的业务逻辑(如执行、转换、优化等)可以对同一个 AST 进行不同的处理,而不需要关心解析过程。核心几个设计方案如下:

  • 访问者模式:ANTLR4通过访问者模式支持业务代码可访问特定“词法”或“语法”节点执行自定义的操作,通过这个方式完全解耦AST(抽象语法树)生成和业务逻辑,词法分析器和解释器专注于AST生成,而业务可以通过访问器的扩展支持业务定制化诉求。
  • 语法和语义的独立性:ANTLR4中可以独立进行语法解析和语义分析,可以在 AST 中进行语义检查和业务逻辑处理。这种分离使得开发者可以更灵活地处理输入的语法和语义。
  • AST生成:ANRL4通过语法解析器生成结构化AST(抽象语法树),不同业务逻辑可以不断复用同一个AST。
  • 上下文模式:解析器在处理输入数据时,上下文会在解析树中传递信息。每当进入一个新的语法规则时,都会创建一个新的上下文实例上下文可以存储解析过程中需要的临时信息,例如变量的值、数据类型等。上下文信息主要结合访问器模式进行使用,同时也解决了在解析复杂语句如多层嵌套结构的层级调用问题。

三、SparkSQL介绍

Spark SQL 是 Apache Spark 的一个模块,专门用于处理结构化数据,Spark SQL 的特点包括:

  1. 高效的查询执行:通过 Catalyst 优化器和 Tungsten 执行引擎,Spark SQL 能够优化查询执行计划,提升查询性能。
  2. 与 Hive 的兼容性:Spark SQL 支持 HiveQL 语法,使得用户可以轻松迁移现有的 Hive 查询。
  3. 支持多种数据源:Spark SQL 可以从多种数据源读取数据,包括 HDFS、Parquet、ORC、JDBC 等。

四、技术实现

语法设计

在Aparch Spark源码中就是使用ANTLR4来解析和处理SQL语句,以下为Apach Spark中基于ANTLR元语言定义的词法分析器和语法分析器,在语法定义上我们只需要基于这套标准的SparkSQL语法适配得物自研引擎的能力,做能力对齐。

Lexer.g4

https://github.com/apache/spark/blob/master/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4

Parser.g4

https://github.com/apache/spark/blob/master/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4

语法补全

以下我们以字段补全场景为例解析从语法定义,语法解析,语法补全,上下文信息采集各个流程节点剖析最后完成的表字段信息精准推荐。在下列语法场景中,存在多层Select语法嵌套,同时表du_emr_test.empsalary tableB和表du_emr_test.hujh_type_tk AS tableB设置了同一别名, 如图在父子查询中都使用了同一个表别名(tableB),当用户在父子查询中分别输入**tableB.**时,这时候需要结合当前上下文语境,对tableB别名推荐不同表的字段

SELECT 
    tableB.c1
 FROM
    (
       SELECT
            tableB.empno,
            tableC.department
        FROM
                du_emr_test.empsalary as tableB
        LEFT JOIN du_emr_test.employees AS tableC
        WHERE tableC.department = tableB.depname

    ) AS tableA
LEFT JOIN du_emr_test.hujh_type_tk AS tableB
WHERE tableB.c1 = tableA.dename

语法补全1.jpeg语法补全2.jpeg语法补全3.jpeg语法补全4.jpeg

在子查询中我们期望推荐tableB来自du_emr_test.empsalary tableB的字段信息,而在最外层中我们期望的是du_emr_test.hujh_type_tk的字段,如上图。

基于以上场景我们核心要解决2个问题:

问题1:当前光标应该提示哪些推荐语法类型

目前,开源方案ANTLR-C3引擎就能完美解决我们问题,用户在编辑器实时输入时,获取当前光标位置,实时做语法解析,然后基于开源的ANTLR-C3引擎能力结合ANTLR 生成的AST即可获取当前光标位置所需要的语法规则

问题2: 获取当前上下文信息以实现精准推荐

根据不同业务场景需要采集的上下文信息不同,基于字段推荐的场景,我们需要获取当前光标位置处可以推荐的表信息,表别名信息,结合编辑器能力实时获取表对应的字段信息进行字段推荐补全,而上下文信息的采集,我们可以通过ANTLR生成的监听器来实现。

语法定义

以下我们用ANTLR元语言实现一段简化版的SQL查询场景的语法规则(QueryStatment),方便我们理解。

lexer grammar SqlLexer;

// 基础词法
COMMA: ',';
LEFT_PAREN: '(';
RIGHT_PAREN: ')';
IDENTIFY: (LETTER | DIGIT | '_' | '.')+;
fragment DIGIT: [0-9];
fragment LETTER: [A-Z];
SEMICOLON: ';';

parser grammar SqlParser;

program: statment* EOF;

statment: queryStatment SEMICOLON?;
// 查询语句
queryStatment:
  SELECT columnNames FROM (
    tableName
    | (LEFT_PAREN queryStatment LEFT_PAREN)
  ) whereExpression? relationsExpresssion? SEMICOLON?;

// 字段
columnNames: columnName (COMMA columnName)*;

tableName: IDENTIFY AS? tableAlis;

tableAlis: IDENTIFY;

columnName: IDENTIFY AS? columnAlis;

columnAlis: IDENTIFY;

whereExpression: WHERE booleanExpression;

booleanExpression: (NOT | BANG) booleanExpression           # logicalBinary
  | left = booleanExpression operator = AND right = booleanExpression # logicalBinary
  | left = booleanExpression operator = OR right = booleanExpression  # logicalBinary;

relationsExpresssion:
  LEFT JOIN tableName whereExpression?
  | RIGHT JOIN tableName whereExpression?;

代码生成

代码生成1.jpeg代码生成2.jpeg

以下是部分生成代码:

1、词法分析器

 // SqlLexer.ts
    
    public static readonly COMMA = 1;
    public static readonly LEFT_PAREN = 2;
    public static readonly RIGHT_PAREN = 3;
    public static readonly IDENTIFY = 4;
    public static readonly SEMICOLON = 5;

    // 词法分析器可以使用的通道
    public static readonly channelNames = [
        "DEFAULT_TOKEN_CHANNEL", "HIDDEN"
    ];
    // 包含了所有字面量记号的名称
    public static readonly literalNames = [
        null, "','", "'('", "')'", null, "';'"
    ];
    // 包含为每个记号分配的符号名,这些符号在生成解析器时用于标识记号
    public static readonly symbolicNames = [
        null, "COMMA", "LEFT_PAREN", "RIGHT_PAREN", "IDENTIFY", "SEMICOLON"
    ];
    
    //  ANTLR 生成的类中的一个字段,列出了所有定义的规则
    public static readonly ruleNames = [
        "COMMA", "LEFT_PAREN", "RIGHT_PAREN", "IDENTIFY", "DIGIT", "LETTER", 
        "SEMICOLON",
    ];

2、语法分析器

ANTLR自动为每个规则生成了一个解析方法,以下是tableName的 ANTLR 中的解析器方法,具备了处理标识符、可选的别名和错误处理的能力。

// SQLParse.ts
// ANTLR自动生成了一个解析 SQL 表名的 ANTLR 中的解析器方法,具备了处理标识符、可选的别名和错误处理的能力
public tableName(): TableNameContext {
        let localContext = new TableNameContext(this.context, this.state);
        this.enterRule(localContext, 8, SqlParser.RULE_tableName);
        let _la: number;
        try {
            this.enterOuterAlt(localContext, 1);
            {
            this.state = 60;
            this.match(SqlParser.IDENTIFY);
            this.state = 62;
            this.errorHandler.sync(this);
            _la = this.tokenStream.LA(1);
            if (_la === 8) {
                {
                this.state = 61;
                this.match(SqlParser.AS);
                }
            }

            this.state = 64;
            this.tableAlis();
            }
        }
        catch (re) {
            if (re instanceof antlr.RecognitionException) {
                this.errorHandler.reportError(this, re);
                this.errorHandler.recover(this, re);
            } else {
                throw re;
            }
        }
        finally {
            this.exitRule();
        }
        return localContext;
    }

自动补全

ANTLR4代码补全核心(antlr4-c3) 是一个开创性的工具,它为ANTLR4生成的解析器提供了一个通用的代码补全解决方案。无论你的项目是处理哪种编程语言或领域特定语言(DSL),只要是基于ANTLR就能够利用这个库实现精准的代码建议和自动补全,极大地增强开发体验。通过antlr4-c3 能力我们通过手动配置需要收集的语法规则,获取在当前光标处需要推荐的语法规则类型。

1、语法规则

通过ANTLR4工具我们可以自动生成Sqllexer.ts词法解析器,SqlParser.ts语法解析器,SqlParserLister.ts访问器,SqlParseVisitor.ts监听器,在SqlParser 语法解析器自动生成了我们在语法定义中的语法规则。

preferredRules = new Set([
        SqlParser.RULE_tableName,
        SqlParser.RULE_columnName,
]);

2、代码补全

以下我们实现一套简化版的代码补全能力。

当用户在编辑器实时输入时,调用getSuggestionAtCaretPosition获取当前语境中需要推荐的信息,包含语法规则,关键词,上下文信息,在结合业务层数据做自动补全,其中包含5个核心步骤:

  1. 获取当前语法解析器实例。
  2. 获取当前光标位置对应的Token。
  3. 生成AST。
  4. 获取当前语境上下文信息。
  5. 通过ANTLR-C3获取当前位置候选语法规则。
public getSuggestionAtCaretPosition(
        sqlContent: string,
        caretPosition: CaretPosition
        preferredRules: Set
    ): Suggestions | null {
        
        // 1、 使用SqlParse解析器获取
        const sqlParserIns = new SqlParse(sqlContent)
        
        // 2、获取当前光标处token
        const charStreams = CharStreams.fromString(sqlContent);
        const lexer = new SqlLexer(charStreams);
        const tokenStream = new CommonTokenStream(lexer);
        tokenStream.fill()
        const allTokens = tokenStream.getTokens(); 
        let caretTokenIndex = findCaretToken(caretPosition, allTokens); 

        // 3、获取AST抽象语法树
        const parseTree = sqlParserIns.program()
        
        // 4、通过监听器采集上下文表信息(下面上下文分析部分阐述细节)
        const tableEntity = getTableEntitys()
        
         // 异常场景兼容存在多条sql, 获取有效最小SQL范围给到antlr4-c3做推荐。
        const statementCount = splitListener.statementsContext?.length;
        const statementsContext = splitListener.statementsContext; 

        // 5、antlr4-c3接入获取推荐语法规则
        let tokenIndexOffset: number = 0;
        const core = new CodeCompletionCore(sqlParserIns);
        // 推荐规则 来自SQLparse解析器的规则(元语言定义)
        core.preferredRules = preferredRules; 
        // 通过AST和当前光标Token获取推荐类型
        const candidates = core.collectCandidates(caretTokenIndex, parseTree); 
        
        // ruleType -> preferredRules 
        // const [rules, tokens] = candidate;
        const rules = [];
        const keywords = [
                
        for (let candidate of candidates.rules) {
        const [ruleType] = candidate;
        let synContextType;
        switch (ruleType) {
            case SqlParser.RULE_tableName: {
                syntaxContextType = 'table';
                break;
            }
            case SqlParser.RULE_columnName: {
                syntaxContextType = 'column';
                break;
            }
            default:
                break;
        }
        if (synContextType) {
            rules.push(syntaxContextType)
        }
    }

    // 获取对应keywords
    for (let candidate of candidates.tokens) {
        const displayName = sqlParserIns.vocabulary.getDisplayName(candidate[0]);
        const keyword = displayName.startsWith("'") && displayName.endsWith("'")
                ? displayName.slice(1, -1)
                : displayName
        keywords.push(keyword);
    }

    return {
        rules,
        keywords,
        tableEntity
    };
  }

在这里我们简化了流程,忽略了很多异常case的处理,自动补全的前提是在当前语法规则正确,而在多级子查询嵌套场景我们需要考虑到过滤异常QueryStatment, 在当前光标出最小范围有效的QueryStatment做补全。这时候需要配合监听器去做上下文采集做容错性更高的自动补全。

上下文分析

上下文分析.jpeg

如图:每个table都归属于一个QueryStatment表达式, 查询中又存在子层级查询的嵌套。我们需要通过上下文收集以下信息:

  1. 每个查询语句的信息,包含Position位置信息,记录当前的查询开始行,结束行,开始列,结束列。
  2. 查询语句的关联关系,即记录当前查询语句父级查询语句对象。
  3. 表实体信息包含表名,表位置信息,表别名信息,当前表归属于那个查询语句。

则我们需要监听3个语法规则包含QueryStatment, TableName,TableAlias, 采集QueryStatment信息,Table信息同时将table与当前归属的QueryStatment做关联, 还有与别名信息作配对关联。这就要求在不同监听器之间的信息需要做共享,上下文信息需要做传递和保留。ANTLR常用的3种信息共享方案包含:

  • 使用访问器方法来返回值,
  • 使用类成员在事件方法之间共享数据,
  • 在语法定义中使用树标记来存储信息。

在这里我们使用第二种(在这里我们简化了SQL的语法定义,在实际场景中语法层级深度和复杂度远比当前高,这也使得方案1和3实际操作起来更麻烦,规则嵌套层级深使得方案一和方案三开发成本和维护成本更高)

1、监听器(SqlParserLister) 通过ANTLR4工具我们可以自动生成SqlParserLister.ts监听器进行自定义扩展。

// SqlParserListener.ts
export class QueryStatmentContext extends antlr.ParserRuleContext {
   public override enterRule(listener: SqlParserListener): void {
        if(listener.enterQueryStatment) {
             listener.enterQueryStatment(this);
        }
    }
    public override exitRule(listener: SqlParserListener): void {
        if(listener.exitQueryStatment) {
             listener.exitQueryStatment(this);
        }
    }
 }
 
 export class TableNameContext extends antlr.ParserRuleContext {
     public override enterRule(listener: SparkSqlParserListener): void {
        if(listener.enterTableName) {
             listener.enterTableName(this);
        }
    }
    public override exitRule(listener: SparkSqlParserListener): void {
        if(listener.exitTableName) {
             listener.exitTableName(this);
        }
    }
 }
// ....

export class TableAliasContext extends antlr.ParserRuleContext {
    public KW_AS(): antlr.TerminalNode | null {
        return this.getToken(SparkSqlParser.KW_AS, 0);
    }
    public override enterRule(listener: SparkSqlParserListener): void {
        if(listener.enterTableAlias) {
             listener.enterTableAlias(this);
        }
    }
    public override exitRule(listener: SparkSqlParserListener): void {
        if(listener.exitTableAlias) {
             listener.exitTableAlias(this);
        }
    }
}

2、自定义监听器扩展

通过SqlParserListener我们可以自定义采集上下文信息。在

  1. 监听进入QueryStatment表达式采集当前表达式信息到_queryStmtsStack。
  2. 监听退出TableNameToken时采集当前Table信息,并关联当前QueryStatment。
  3. 监听退出TableAliasToken时采集信息,并关联到Table实体。
  4. 监听退出QueryStatment表达式推出_queryStmtsStack
// tableEntityCollect
 export class SqlEntityCollector implements SqlParserListener {
     super() {
         this._tableEntitiesSet = new Set();
         this._queryStmtsStack = [];
         this._tableAliasStack = [];
         this._currentTable = '';
     }
     
     enterQueryStatment(ctx: QueryStatmentContext) {
        this.pushQueryStmt(ctx);
    }

    exitQueryStatment(ctx: QueryStatmentContext) {
        this.popQueryStmt(); 
    }

     exitTableName(ctx: TableNameContext) {
        this.pushTableEntity(ctx);
        this.setCurrentTable(ctx);
     }
     
     exitTableAlias(ctx: TableAliasContext) {
        this.pushTableEntity(ctx);
     }
     
     pushQueryStmt() {}   // 采集QueryStmt信息
     
     popQueryStmt() {}    // 推出当前QueryStmt,进入下个同级Stmt
     
     pushTableEntity() {} // 采集当前表信息,关联当前Stmt
     
     pushTableEntity() {} // 采集关联表
     
     enterProgram() {}    // 清空重置
     
     getTableEntity() {
         return this.TableEntity(ctx)
     }
    
 }

在这里我们简化了语法定义的规则便于讲解,但在实际中语法规则的整体嵌套层级是很深的,从以下的SparkSql语法定义中我们可以看到右侧聚合的表达式高达200+个,单个表达式的备选分支最多高达140+,这也加大了上下文分析采集的复杂度,即我们无法简单的从QueryStmt当前QueryStatmentContext中获取全量信息。

获取全量信息.jpeg

3、触发监听器采集上下文信息

getTableEntitys() {
    const collectListener = new SqlEntityCollector(sqlContent, caretTokenIndex);
    const parse = new SqlParse(sqlContent);
    const parseTree= sqlParserIns.program();
    ParseTreeWalker.DEFAULT.walk(collectListener, parseTree); 
    return collectListener.getTableEntity()
}

语法校验

ANRLR在生成语法分析器中内置了自动错误报告和恢复策略,能够在遇到句法错误时自动产生错误消息,为每个句法错误产生一条错误消息。

词法错误

常见的词法错误包含字符遗漏,词法错误。举个例子,在spark标准语法定义中 tableName规则不支持表变量场景(${variable}),如果要兼容这里词法,就需要在语法定义中变更tableName的语法规则定义。

语法错误.jpeg

以下是语法定义变更:

  1. 新增词法规则$, {, }。
  2. 新增语法规则identifyVar支持变量模式。
SqlLexer.g4
// 新增词法
LEFT_BRACE    : '{';
RIGHT_BRACE   : '}';

VARIABLE    : '$';

SqlParse.g4
// before tableName: IDENTIFY AS? tableAlis; 
tableName: identifyVar AS? tableAlis; 

identifyVar
    : IDENTIFY // odps_table_a
    | IDENTIFY? VARIABLE LEFT_BRACE IDENTIFY RIGHT_BRACE IDENTIFY? // odps_table_a_${variable} odps_table_a_${prefix_variable}_abs

自动恢复机制

语法分析器不应该在遇到非法的成员定义时结束,而是应尽最大可能匹配到一个合法的类定义,ANRTL4自动错误恢复机制能在语法分析器在发现语法错误后还能继续进行尝试语法解析和自动恢复。

1、异常捕获

ANRLT自动生成的语法解析器中自动为每个规则包裹异常捕获能力,并在catch中尝试错误恢复。

异常补货.jpeg

2、恢复策略

一般情况下,语法分析器在遇到无法匹配的错误时会尝试最简单的符号补全和移除来尝试解析,都不管用时,这时候就会用更高阶的策略来进行恢复。包括扫描后续词法符号来恢复,从不匹配的词法符号中恢复,从子规则的错误中恢复,捕获失败的语义判定。

虽然ANTLR提供了很多策略来进行错误恢复,但在实际业务场景中,需要结合考虑语法、语境的复杂度去权衡性能与更友好的错误提示之间的抉择。在复杂场景中ANTLR表现并不理想,在一些复杂语法和语境的情况下解析器在检测错误时难以做出合理的决策,例如:递归和嵌套结构中会使得错误恢复变得很复杂,导致解析器无法做出合理决策。还有在上下文敏感的语境中,错误恢复机制基本无法提供有效恢复。

性能

在 ANTLR 4 中,语法复杂度、语法歧义、语法规则嵌套深度与预测算法的选择都会显著影响解析器的性能和准确性。Spark SQL语法规则达200+,备选分支最高达140, 嵌套深度达20+,同时又存在负责循环嵌套场景, 这也意味着在整个语法解析,语法错误的处理过程是很复杂的,当遇到复杂大SQL量和一片狼籍的语法错误SQL,会导致语法解析过程变得缓慢引发性能问题。目前在性能优化上,有以下几个方向。

缓存优化

在antlr4中词法解析和语法解析能力和业务是完全解耦的,这也意味着底层基于同个SQL内容解析出来的tokens和parserTree都是可以在不同业务逻辑应用里复用。我们可以通过缓存tokens,parseTree减少词法解析和语法解析的损耗。

语法优化

通过减少语法树的层级和优化表达式减少解析过程中“二义性”的次数,可以加速语法解析的速度,优化AST生成性能。合理使用语法定义中用法,例如树标记(用于上下文通信数据共享),在语法解析过程中会为每个标记生成上下文,这也意味着每个局部结果都会保留,会有更大的内存消耗

预测模型选择

在语法解析中不同预测模型的选择对解析性能有显著影响,针对不同的场景需要评估时效性与正确性之间的衡量。

ANTLR4预测模型:

www.antlr.org/api/Java/or…

预测模型.jpeg

我们可以选择性价比更高的SLL预测模型作为语法分析策略,结合定制化的错误监听器做错误纠正。

编辑器应用

编辑器集成

与MonacoEditor集成流程可查看此文章 blog.shizhuang-inc.com/article/MTU…

辅助编程

1、信息项提示(表,函数,字段)

信息项提示1.jpeg信息项提示2.jpeg信息项提示3.jpeg

2、自动补全(库,表,字段,语法)

自动补全1.jpeg自动补全2.jpeg自动补全3.jpeg

五、大模型下的SQL编辑器应用

随着大模型的蓬勃发展,在数据产品中的应用也逐步得到了验证和落地,目前,Galaxy还没有接入Copilot, 内部暂时还没有基于SQL的Copilot。业界较成熟的是阿里云的Dataworks, DataWorks于2023年推出了Copilot 产品, 核心2个方向,一个方向是智能 SQL 编程助手,辅助 SQL 编程,支持 NL2SQL 及 SQL 代码补全;另一个方向是 AI Agent,提供 LUI(自然语言用户界面),以提升产品功能操作的便捷性和用户体验。

NL2SQL应用场景

基于SQL的Copilot一般在以下几个应用场景比较深入和广泛的落地效果:简单数据查询,SQL 优化与转换,SQL 语法查询与讲解, 函数查询,功能咨询,注释生成,SQL 解释,SQL 一键纠错

NL2SQL自动补全

代码补全是编程类 Copilot 的主要场景和能力,单市场上主流的编程类 Copilot 对 SQL 支持的好的并不多见。众所周知,SQL 代码补全比其他高级语言的代码补全更具挑战性,主要原因有以下几个方面:

  • 上下文和环境的依赖性:SQL 代码不是独立存在的,而是依赖于数据表的元数据信息以及表与表之间的关联关系。
  • SQL 语义多样性:实现同一种查询结果,可以有多种 SQL 写法,如何实现“最佳”写法存在挑战。
  • 语法简洁但高度专业化:SQL 语法简洁但每一个关键字、函数或语法都有特定的含义,大模型要准确理解这些得通过针对性的训练学习。
  • 执行计划和性能考量: 这跟数据库底层的执行计划有关,需要考虑如何书写才能使 SQL 的性能最优。
  • 数据库特异性:市面上不同的数据库往往存在不同的 SQL 方言,存在差异,针对这种差异性我们要投入大量时间做 SQL 数据集准备、数据标注、模型微调。
  • 高度业务相关性:SQL 语句通常与特定业务高度相关,比如一个指标存在特定的计算口径,这是与公司业务相关,通用的大模型也无法提前学习。

目前较成熟的代码补全核心场景主要在有规律的代码连续推荐场景(例如:字段、字段别名推荐,注释推荐、分区字段推荐、Group by 字段推荐,上下文自动联想推荐等)。

六、总结

通过SQL引擎能力建设我们在Galaxy数据研发IDE上支持了个性化词法规则定制能力,包含字段别名支持中文, 表变量等场景, 同时通过语法解析和监听器能力,支持实时识别各类的语法规则包含表,函数,字段等做辅助编程提示和做精准化的库,表,字段代码补全和推荐

后续我们仍面临很大的挑战,在非专业的数据开发背景、复杂的业务定制需求、语言定义的复杂性和嵌套深度等因素共同导致了解析器的开发难度。目前,在语法校验自动纠错提示上,虽然ANTLR的提供了自动错误恢复机制但整体表现并不理想,后续2个方向,第一,接入大模型的能力。第二,从基础语法定义上进行重构,减少语法歧义和层级优化。为了应对这些挑战,我们需要加强对 ANTLR 和 Spark SQL语言,数据处理的理解,以便顺利使用和扩展解析器。

参考资料

  • ANTLR
  • ANTLR4-C3
  • DataWorks Copilot:大模型时代数据开发的新范式
  • ANTLR4权威指南 - [美] 特恩斯·帕尔 著

文 / 吴所谓(Ethan)

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

一个Rust小白发布生产级Rust应用的进阶之路 | 得物技术

作者 得物技术
2025年2月27日 13:56

一、引 言

在流量日益增长的今天,随着用户需求的不断增加和性能要求的提升,一个能够更好地处理高并发、低延迟和资源有效利用的计算层是十分重要的。尽管在过去我们平台使用Java开发的计算层提供了稳定的服务支撑,但面对日益增长的流量和低延迟的需求,Java不可避免地开始显现局限性:

  • 垃圾回收:Java 的自动内存管理依赖于垃圾回收机制,而垃圾回收虽然简化了开发工作,却可能引入不可预测的延迟。
  • 内存使用效率:Java 的内存管理通常比手动管理的语言消耗更多的内存,因为它必须保留足够的空间来处理对象分配和回收。
  • 异步处理瓶颈:虽然Java近年来强化了异步编程支持,但在极限性能优化方面,仍存在不可忽视的不足。

在此背景下,经过调研和实验验证,我们发现了Rust这个计算层改造升级的语言选型。Rust语言以其出色的内存管理、安全性和高效性能而闻名。Rust的所有权模型可以在编译时捕捉大多数内存错误,从而减少运行时错误,这对需要高可靠性和稳定性的系统尤为重要。此外,Rust没有垃圾回收机制,这意味着我们可以更好地预测和控制内存使用,提高应用程序的性能和资源利用率。

通过使用Rust对计算层改造升级,我们的系统获得了如下的提升:

  • 相比于Java,减少了30%的CPU核数。
  • 高效内存管理,减少了70%的内存使用。
  • 服务更稳定,Bug少。

二、Rust核心特性

Rust 能够突破传统编程语言的瓶颈,主要得益于其独特的所有权、借用和生命周期机制。这些特性使 Rust 在编译阶段就能够确保内存安全和线程安全,从而最大程度地减少运行时错误和不确定性。接下来,我们将深入探讨 Rust 在并发模型、所有权、生命周期和借用方面的优势。

所有权

Rust 的所有权Ownership)是该语言独特的内存管理机制,它确保内存安全性和并发性而不需要垃圾回收器。所有权机制通过编译时检查来保证安全性,避免绝大多数的运行时错误,例如空指针或数据竞争。

Rust所有权规则

Rust的所有权有三个主要规则:

  • 所有值(除Copy类型)有且只有一个拥有者。
  • 当所有者离开作用域,值会被自动释放,不需要手动回收。
  • 值的所有权可以被移动或者借用。

为了方便理解,这里展示Rust、C++和Java对象赋值的异同来理解所有权的运行机制。 所有权的运行规则.jpeg

可以看到,将a赋值给b时,Java会将a指向的值的引用传递给b,而C++则会产生一个新的副本。从某种意义来说,在内存管理上,Java和C++选择了相反的权衡。代价是Java需要垃圾回收来管理内存,而C++的赋值会消耗更多的内存。不同于Java和C++,Rust选择了另一种方案:移动所有权。即将a指向的堆内存地址“移动到b上”,这时只有b可以访问这段内存,a则成为了未初始化状态并禁止使用。

Rust的所有权概念内置于语言本身,在编译期间对所有权和借用规则进行检查。这样,程序员可以在运行之前解决错误,提高代码的可靠性。

共享所有权

尽管Rust规定大多数值会有唯一的拥有者,但在某些情况下,我们很难为每个值都找到具有所需生命周期的单个拥有者,而是希望某个值在每个拥有者使用完后就自动释放。简单来说,就是可以在代码的不同地方拥有某个值的所有权,所有地方都使用完这个值后,会自动释放内存。对于这种情况,Rust提供了引用计数智能指针:Rc和Arc。

Rc和Arc非常相似,唯一的区别是Arc可以在多线程环境进行共享,代价是引入原子操作后带来的性能损耗。Rc和Arc实现共享所有权的原理是,Rc和Arc内部包含实际存储的数据T和引用计数,当使用clone时不会复制存储的数据,而是创建另一个指向它的引用并增加引用计数。当一个Rc或Arc离开作用域,引用计数会减一,如果引用计数归零,则数据T会被释放。这种机制也叫共享所有权机制。

共享所有权机制.jpeg 这时就有好奇的小伙伴问了,既然可以在多个地方共享所有权,那不是违背了所有权的初衷,从而引入了数据竞争的问题?放心,Rust的开发者早就想到了这个问题,引用计数智能指针是内部不可变的,即无法对共享的值进行修改。那这就又引入了一个问题:如果要对共享的值进行修改怎么办?对于这种情况Rust也提供了解决方案,使用Mutex等同步原语即可避免数据竞争和未定义行为。以下是一个案例,如何在多线程访问数据,并安全的进行修改。

{
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // 锁定 Mutex 以安全地访问数据
            let mut num = counter_clone.lock().unwrap();
            *num += 1; // 修改数据
        });
        handles.push(handle);
    }

    // 等待所有线程完成
    for handle in handles {
        handle.join().unwrap();
    }

    // 获取最终计数值
    println!("Final count: {}", *counter.lock().unwrap());
}

生命周期和引用

在 Rust 中,生命周期lifetimes)和引用references)是两个密切相关的概念,它们共同构成了 Rust 的所有权系统的重要组成部分。生命周期用于确保引用在使用时是有效的,从而防止悬空引用和数据竞争等问题。

引用

前面提到,Rust值的所有权可以被借用,它允许在不获取数据所有权的情况下访问数据。Rust中有两种类型的引用:

  • 不可变引用 (&T):允许你读取数据,但不允许修改。
  • 可变引用 (&mut T):允许你修改数据。

在使用引用的时候需要满足以下规则:

  • 在同一时间只能有一个可变引用。
  • 多个不可变引用可以同时存在,但在可变引用存在时,不能有不可变引用。
  • 每个引用都有一个生命周期,表示该引用在程序中的有效范围,且引用的生命周期不能超过被借用的值的生命周期。

生命周期

在 Rust 编程语言中,生命周期用于确保引用在使用时是有效的。生命周期的存在使得 Rust 能够在编译时检查引用的有效性,从而防止悬空引用。如下是一个Rust编译器检查生命周期的例子:

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}

这里编译器将r的生命周期记为'a,x的生命周期记为'b。可以明显看出,内部块的'b比外部块的'a生命周期小,当x离开作用域被释放时,r仍然持有x的引用。所以当把生命周期为'a的r想引用生命周期为'b的x时,编译器发现了这个问题,并拒绝通过编译,保证了程序不会出现悬垂引用。

生命周期标注

正如我们看到的,Rust的引用代表对值的一次借用,它们有着种种限制,所以,在函数中、在结构体中等等位置上使用引用时,你都要给Rust编译器一些关于引用的提示,这种提示,就是生命周期标记。对于简单的情况,聪明的Rust编译器可以自动推断出引用的生命周期。对于一些模棱两可的情况,编译器也无法推断引用是否在程序运行期间始终有效,这时就需要我们提供生命周期标注来提示编译器我们的代码是正确的,放我过去吧。

生命周期标注并没有改变传入的值和返回的值的生命周期,我们只是向借用检查器指出了一些用于检查非法调用的一些约束而已,而借用检查器并不需要知道 x、y 的具体存活时长。而事实上如果函数引用外部的变量,那么单靠 Rust 确定函数和返回值的生命周期几乎是不可能的事情。因为函数传递什么参数都是我们决定的,这样的话函数在每次调用时使用的生命周期都可能发生变化,正因如此我们才需要手动对生命周期进行标注。

相信第一次看到生命周期的小伙伴们都感觉概念非常难理解,且写出的代码非常丑,简直要逼死强迫症。但是有得就有舍,要写出安全且高效的Rust代码,就要学会理解和使用生命周期。如果实在不想用,那就多用Rc和Arc吧。

三、用Rust构建生产级应用

了解了Rust最核心的基本知识和特性后,你已经成为了一个合格的Rust练习生,可以开始用Rust愉快的进行开发工作了。但是要使用Rust开发高性能的生产级应用,只了解到这种程序是不行的。当初笔者信心满满地将第一个Rust应用发布到测试环境后,竟然发现效率比Java版本还低,于是开始了长期的瓶颈排查和调优,且调优时间远大于编码时间。最终我们的应用在相同吞吐量的条件下,CPU使用率从高于Java 20%优化到低于Java 40%。在这个过程中,也总结了一些经验进行分享。

合理利用引用减少数据拷贝

相信很多刚接触Rust的小伙伴在面对同一份数据需要在多处使用的情况时,为了逃避复杂的生命周期问题,会倾向于使用Clone来创建数据副本。如果这样做的话,一份数据在内存中重复出现多次,带来的cpu和内存消耗会让你会怀疑人生,为什么这么相信Rust的性能而不相信自己能啃下生命周期这块硬骨头呢?

有一个应用场景,我们从数据源得到若干个源数据,根据业务逻辑聚合成batch并存储到远端或者本地。聚合的逻辑可以有两种方式:

  • 将源数据的所有权移动到batch。
  • 将源数据拷贝一份到batch。

然而这两种方式都不可取。第一种方式的问题是,我们不知道一份源数据是不是只会被使用一次。而使用第二种方式则会消耗更多的CPU,且占用内存成倍上升。

前面提到,Rust的值是可以借用的,如果在batch中不获得所有权,而是存储引用,那么可以几乎零消耗的实现需求。以上述应用场景为例,这里介绍我们是怎么解决这个问题的。

首先给出源数据Data和Batch的定义:

struct Data {
    condition: bool,
    num: i32,
    msg: String
}
struct Batch<'a> {
    msgList: Vec<&'a str>
}

假设需求是将Data的msg字段在Batch里存储num次,我们很容易写出这样的代码:

fn main() {
    let batch: Batch = Batch:new();    // 初始化Batch
    loop {                            
        let data:Data = dataSource.getData();    // 从数据源获得data
        recordData(batch, &data);
        if (batch.len() > 100) {    // batch存储的数据大于100条时,存储并清空
            save(batch);
            batch.clear();
        }    // ------------------- data的生命周期到此结束
    }    // ------------------- batch的生命周期到此结束
}

fn record_data(batch: Batch, data: Data) {
    if(condition) {    // 根据条件将msg保存num次
        for i in 0..data.num {    
            batch.msgList.push(&data.msg);
        }
    }
}

看起来是不是很合理,和其他语言也没有什么区别,当信心满满按下编译后,会发现天空飘来五个字:编译不通过。原因很简单,因为编译器发现被引用对象data的生命周期小于batch,data的在当前循环结束后就会销毁,batch存储的引用就变成了野指针。我们可以做如下修改:

fn() {
    let batch: Batch = Batch:new();    // 初始化Batch
    let dataList: Vec<Data> = Vec::new();    // dataList的生命周期和batch一样
    loop {                            
        let data: Data = dataSource.getData();    // 从数据源获得data
        dataList.push(data);    // 将data保存在dataList,提升生命周期
        if(batch.len() > 100) {
            for data_ref: &Batch in dataList.iter() {
                record_data(batch, data_ref);    // 此时data的生命周期和batch相等
            }
            save(batch);
            batch.clear();
            dataList.clear();
        }
    }
}

fn record_data<'a>(batch: Batch<'a>, data: &'a Data) {
    if(condition) {    // 根据条件将msg保存num次
        for i in 0..data.num {    
            batch.msgList.push(&data.msg);
        }
    }
}

可以看到,我们对代码做了一些小改动:

  • 在循环外初始化了一个Vec,并保存每次得到的data。
  • record_data函数上增加了生命周期标注。

为什么这么做呢?我们已经知道最初版本是因为data的生命周期小于batch,导致batch不能存储data的引用。解决这个问题的思路很简单,提升data的生命周期不就完了。假设batch的生命周期是'a,data的生命周期是'b,很明显'a是大于'b的,因为batch的生命周期是整个main函数,而data的生命周期仅仅在loop内。我们在batch同样的作用域内定义一个容器,它的生命周期也是'a。在每次得到data后把它存入容器中,那data就不会在循环结束的时候被销毁了。

同时,在record_data函数定义上,我们也要使用标注告诉编译器batch和data的生命周期是相等的。如果data的生命周期大于batch,我们也可以在参数中定义data的生命周期为'a,因为实际的生命周期和参数生命周期标注无需一致,只需要实际的生命周期大于参数生命周期就行了。如果你有强迫症,也可以在参数中标注实际的生命周期,只需要加上适当的生命周期约束就行了:

// 'b: 'a表示'b的生命周期能够覆盖'a
fn record_data<'a>(batch: Batch<'a>, data: &'b Data) where 'b: 'a {
    ......
}

经过这些小改动,你的应用会比粗暴的使用拷贝提升许多性能并且节约大量内存使用。经过我们的测试,在类似需求中将需要大量拷贝的操作替换成引用,可以节省一倍的内存,CPU使用率也下降了20%。

FFI(Foreign Function Interface)

在一些情况下,我们项目使用的编程语言在实现一些功能时,想使用现成的依赖库来实现复杂的逻辑,但是因为生态不完善,导致缺少此类库或者现存的依赖库不成熟。在使用Rust时,这种现象尤其普遍。很多热门组件没有为Rust提供官方API,非官方实现功能和性能又得不到保证,且更新不稳定。难道Rust进阶之路就要到此为止?

Rust很贴心地提供了跨语言交互能力,对FFI的良好支持可以让开发者方便的在Rust代码中调用C程序。如果我们需要的依赖库刚好有C/C++的实现,就能使Rust完成主要逻辑,把一些Rust不完善的功能通过C/C++实现,而且性能也不会受到影响。在Rust程序调用C代码也非常简单:

  1. 声明外部函数
extern "C" {
    fn c_add(a: i32, b: i32) -> i32;
}
  1. 在RUST中调用C函数
fn main() {
    unsafe {
        c_add(1, 2); 
    }
}
  1. 将C程序编译打包为静态/动态链接库
g++ -std=c++17 -shared -fPIC -o libhello.so hello.cpp
  1. 然后编译 Rust 文件并链接到链接库
rustc main.rs hello.o

尽管用Rust调用C程序已经非常方便,但是仍需要注意这些问题:

  • 处理数据类型:在 Rust FFI 中,需要特别注意数据类型的转换和处理。Rust 和其他语言的数据类型可能存在差异,需要进行适当的转换。例如,Rust的i32和C的int可以直接相互转换。而字符串的传递之所以需要特殊处理,是因为Rust的字符串实现和C/C++不一样。C/C++的字符串指针只包含地址,且字符串后有“\0”作为结尾,而Rust字符串的指针不仅包含地址,还包含字符串长度,且末尾没有“\0”作为结尾。
  • 内存管理:尽管Rust是内存安全的语言,但是在使用FFI的情况下,Rust无法保证调用的外部语言的安全性。作为开发者,我们要自己管理外部语言的内存。
  • 线程安全:在多线程环境下使用 Rust FFI 时,需要注意线程安全问题。某些外部函数可能不是线程安全的,需要在调用时进行适当的同步操作。
  • 性能优化:在使用 Rust FFI 时,需要注意性能优化问题。由于涉及跨语言调用,可能会导致一定的性能损失。因此,需要对 FFI 调用的性能进行评估和优化。

Tokio

如果你想构建一个高性能的Rust服务器应用,那么Tokio绝对是你的首选框架。Tokio 是一个用 Rust 编写的异步运行时,旨在提供高性能的 I/O、任务调度和并发支持。虽说Tokio提供了强大的异步支持,要用好Tokio也不是一件容易得事,首先要了解“异步”的概念。在计算机编程中,“异步”是指一种不阻塞的操作方式,允许程序在等待某些操作(如 I/O 操作、网络请求等)完成时继续执行其他代码。

Tokio 通过使用协程和 Future 机制来实现高效的并发处理。它将异步任务封装为Future对象,并通过运行时的调度器管理这些任务的执行状态。当任务被调用时,运行时通过poll方法检查其状态,如果任务无法继续执行(返回 Poll::Pending),则将其挂起并注册一个Waker来在后续的某个时刻唤醒任务。一旦相关的I/O操作完成,Waker会通知运行时重新调度该任务,从而实现非阻塞的并发执行。Tokio支持多线程运行,可以充分利用多核CPU的能力,提高应用程序的性能和响应性。

性能和响应性.jpeg Tokio的使用非常简单,使用async和await就可以很方便地创建异步任务,但是要使用Tokio写出高性能的代码不是一件简单的事。刚刚接触Tokio的开发者会经常发现代码无故卡死或者性能低下,这是因为没有正确使用Tokio。举个例子,下面是一段运行后会卡死的代码:

#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
async fn main() {
    let h = tokio::spawn(async {
        let (tx, rx) = std::sync::mpsc::channel::<String>();
        tokio::spawn(async move{
            let _ = tx.send("send message".to_string());
        });
        let ret = rx.recv().unwrap();
        println!("{}", ret)
    });
    h.await;
}

代码结构很简单,但是运行后会发现代码似乎hang住了,检查代码结构也没有发现问题。要解释这个卡死的问题,要从Tokio的任务调度机制来分析:

调度机制来分析.jpeg Processor 获取 Task 后,会开始执行这个 Task,在 Task 执行过程中,可能会产生很多新的 Task,第一个新 Task 会被放到 LIFO Slot 中,其他新 Task 会被放到 Local Run Queue 中,因为 Local Run Queue 的大小是固定的,如果它满了,剩余的 Task 会被放到 Global Queue 中。

Processor 运行完当前 task 后,会尝试按照以下顺序获取新的 Task 并继续运行:

  1. LIFO Slot.
  2. Local Run Queue.
  3. Global Queue.
  4. 其他 Processor 的 Local Run Queue。

如果 Processor 获取不到 task 了,那么其对应的线程就会休眠,等待下次唤醒。

在上面的例子中,我们首先Spawn了一个异步任务Task-1,Task-1被分配给了Processor-1执行。然后在Task-1里Spawn了另一个异步任务Task-2,Task-2被放到了Processor-1的LIFO Slot中。

因为Task-1继续运行的条件依赖于Task-2,所以Task-1被阻塞了。而且Tokio的协程是非抢占式的,在Task-1没有遇到.await前无法让出CPU,Processor-1无法去执行Task-2。又因为Task-2在Processor-1的LIFO Slot中,其他的Processor也无法偷取Task-2执行。于是,Task-2永远也不会有机会被执行,这两个Task在循环等待中就永远卡死了。

要解决这个问题,我们要将阻塞型的数据结构替换成Tokio的非阻塞式的:

#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
async fn main() {
    let handler = tokio::spawn(async {
        let (tx, mut rx) = tokio::sync::mpsc::channel(2);
        tokio::spawn(async move{
            let _ = tx.send("send message".to_string()).await;
        });
        let ret = rx.recv().await.unwrap();
        println!("{}", ret)
    });
    handler.await;
}

将channel替换成Tokio的非阻塞数据结构后,Task-1在提交完Task-2后遇到await让出了CPU,Processor-1就可以从LIFO Slot取出Task-2执行了,循环等待也就被打破了。

由这个例子可以看出,Tokio 的轻量级线程之间的关系是一种合作式的。合作式的意思就是同一个 CPU 核上的任务大家是配合着执行(不同 CPU 核上的任务是并行执行的)。我们可以设想一个简单的场景,A 和 B 两个任务被分配到了同一个 CPU 核上,A 先执行,那么,只有在 A 异步代码中碰到 .await 而且不能立即得到返回值的时候,才会触发挂起,进而切换到任务 B 执行。也就是说,在一个 task 没有遇到 .await 之前,它是不会主动交出这个 CPU 核的,其他 task 也不能主动来抢占这个 CPU 核。

所以在使用Tokio时,我们要注意两点:

  • 不要在异步代码中执行阻塞操作,不然这个OS线程中的其他任务都会被阻塞。
  • Tokio 虽然适合网络 I/O 型并发,但是也要在 I/O 任务里小心地控制计算型代码的时间,否则会导致运行时任务调度不均,从而长时间阻塞其它任务的运行。

四、Rust应用发布

通过 Cargo,开发者可以轻松创建、构建和共享 Rust 项目。但是因为发布系统只支持Java和Golang应用,要在发布系统发布Rust应用还是需要一些工作的。以下是我们发布Rust应用的流程。

上传镜像

因为公司平台是没有Rust应用的,所以我们需要自己制作镜像并上传,这样才能在发布平台发布我们的代码。我们需要创建两个 Docker 镜像:一个用于构建(CI 镜像),另一个用于运行(运行时镜像)。

另一个用于运行.jpeg 在dockerfile里可以安装自己想要的工具包,根据自己需求来定制。

FROM repoin.shizhuang-inc.net/ci-build/rust:1.79.0

RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 871920D1991BC93C

# 创建 /etc/apt/sources.list
RUN echo "deb http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse" > /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse" >> /etc/apt/sources.list

# 更新包列表并安装必要的工具
RUN apt-get install -y \
    protobuf-compiler \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# 验证安装
RUN protoc --version

RUN pwd

RUN ls -alh .

RUN ls -alh workspace

发布

建好集群后,还需要对集群进行一些配置:

  • 修改编译配置的镜像为自己上传的镜像。
  • 将编译命令设为cargo build --release。
  • 修改运行时镜像。
  • 修改发布配置,改为自己应用所需要的。

还需要注意的是,发布平台的编译环境和运行环境是不同的,编译完成后发布平台会将可执行文件移动到/opt/apps目录下进行执行,而配置文件不会被打包。遇到这种情况可以使用rust-embed库,它允许将静态文件(如 Yaml、Json、图像等)打包到您的二进制文件中,从而简化文件管理和部署。

上监控

虽说Rust应用主打的是稳定,但是发布后持续对应用进行监控也是必须的,不然晚上能睡得着吗。和发布一样,Rust应埋的指标要被监控采集,需要额外的配置。在KubeOne平台找到自己的集群,在发布配置里加上这两项,监控平台就可以采集到指标了。

labels:
    - key: http://dewu.com/qos
      value: LS
    - key: http://duapp.kubernetes.io/metrics-scraped
      value: metrics
containerPorts:
    - containerPort: "2892"
      name: http-metrics
      protocol: TCP

通过上监控,可以实时观察Rust服务的运行情况,并且根据自己的埋点分析系统的瓶颈。可以看到,Rust应用运行非常平稳。相比于有GC的Java应用,Rust明显毛刺很少,非常平滑,而且内存占用相比Java减少了70%。

在这里插入图片描述

五、结 论

通过迁移到Rust,我们的计算层能够在处理高并发请求时显著提高系统的吞吐量和响应能力,同时减少服务器资源的浪费。这不仅能降低运营成本,还能为我们的用户提供更流畅、更快速的体验。

但是,如果要持续地拥抱Rust生态,目前仍然面临如下挑战:

1. 生态不完善 尽管 Rust 已经有一些非常优秀的库和工具,但某些特定领域仍然缺乏成熟且广泛使用的库。这意味着开发者可能需要花费更多的时间来构建自己的解决方案或者整合不同语言的库。

2. 学习曲线陡峭 Rust 语言引入了许多独特的概念和特性,对于初学者和来自其他语言的开发者来说,这些特性可能需要一段时间来彻底掌握。

3. 开发进度 相比于自动内存管理类型语言的开发任务,Rust严格的编译检查会让开发进度一度阻塞。

尽管开发Rust生产级应用有那么多阻碍,我们目前已经发布的Rust应用已经证明了,相比于付出,迁移Rust带来的收益更大。希望大家都可以探索Rust的可行性,为节能减排和世界和平出一份力,也欢迎各位对Rust有兴趣的同学一起交流。

文 / 小新

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物小程序平台设计与实践

作者 得物技术
2025年2月25日 11:52

一、什么是小程序平台

得物小程序平台致力于整合并管理微信、支付宝等渠道的得物数字资产,实现数字化管理。通过该平台,小程序和公众号等功能纳入公司工作流,以提升用户体验和管理效率。

小程序1.jpeg

项目一期的成功推出,使得数字化管理得以实现,传统的线下沟通模式顺利迁移至线上。截至目前,平台已接入多个小程序,实现了用户自主申请角色的能力,以及迅速处理了多条用户投诉,并及时转发至技术支持部门。

多小程序管理 多小程序管理.jpeg

小程序维护人员审批流 小程序维护.jpeg

小程序维护人员列表 小程序维护列表.jpeg

二、为什么要做小程序平台

当前挑战

1.管理流程与效率

  • 成员与开发者管理主要依赖手动操作,缺少自动化支持,这在人员变动时造成了权限更新的延迟。

2.消息处理与联动不足

  • 现有的投诉与问题推送系统未能有效与管理流程对接,从而影响整体响应效率和用户体验。

3.数字资产透明度

  • 数字资产管理尚缺乏透明度,追踪与管理资产使用情况变得复杂,这给管理带来了一定挑战。

改进方向

1.增强微信开放平台的对接能力,以提升工作效率。 2.提升小程序体系的基础能力,推动管理流程的数字化转型。

核心目标

实现多个小程序的统一线上管理,减少人为操作。具体目标包括:

  • 降低因权限滥用导致的数据安全问题。
  • 实现用户投诉的快速响应,以显著提升用户体验。
  • 提高小程序的运营效率。

三、怎么做小程序平台

技术重点: 1.小程序在线标准化 2.多小程序多平台的统一管理

作为平台方,我们需有效整合内部工作流与外部微信和支付宝平台,具体拆解如下:

1.工作台: 提供一站式工作平台,不同用户角色通过该平台进行小程序的在线管理。普通用户可轻松申请体验权限,客服专员则聚焦于处理客户投诉与反馈,管理员负责管理小程序资产。

2.API规范: 通过中间层对接SDK,消除微信和支付宝开放平台之间的技术壁垒。

3.数据中心: 作为核心模块,统一管理公司内部数据,确保小程序信息、用户、角色、投诉等数据实时同步。

4.流程引擎与权限管理: 结合工作台,通过飞书进行流程管理和消息实时推送。

流程引擎.jpeg

总体设计

基于线上化目标,从实际痛点和公司基建现状出发,得物小程序平台的核心内容包括:

1.主工作台:

  • 开发管理:角色管理、成员管理、小程序体验码等
  • 消息推送:投诉/违规消息提示、告警消息、工单下发等
  • 小程序管理:小程序列表管理
  • ...

2.工作流协同:与飞书审批流、飞书助手、TS工单、离职系统等进行协同。

工作流协同.jpeg

详细设计

一期在线化目标重点在【开发管理】和【消息推送】2个模块。

1.开发管理

  • 实现小程序角色的在线新增和移除,确保每一次操作都有记录,同时对接公司的审批流程。
  • 离职员工的信息可通过系统自动移除,避免了人为失误。

开发管理1.jpeg开发管理.jpeg

2.消息推送

  • 对接TS工单,技术性问题通过TS平台处理,业务类问题则交由运营团队。
  • 对接飞书机器人,完成了消息的实时推送。

信息推送1.jpeg消息推送.jpeg

数据库设计

根据上方工作流设计,设计如下表数据结构,用来存储角色、权限、消息等数据。并结合Cursor AI工具,使代码逻辑的开发变得高效便捷。

数据库设计.jpeg

业务效果

1.在线管理成功落地:

小程序平台V1.0成功推出,接入多款小程序,多个用户已开通线上权限申请。集成管理平台大幅减少了人工操作时间。

2.运营效率显著提升:

通过优化流程,降低了对人工干预的依赖,确保所有投诉能够及时跟进并解决。

3.用户反馈积极改善:

收集的用户反馈显示,投诉响应时间由平均数小时缩短至几分钟,新的小程序平台提高了处理效率与透明度。

四、遇到的挑战

挑战1:跨平台接入的复杂性超出预期

在系统实现过程中,我们要深度接入4个平台(微信、支付宝、飞书、内部系统),涉及的上下游系统超过10个,导致工作流的复杂性远超预期。

  • 缺乏前置经验参考:从0到1工作量巨大,且涉及内外多个系统和工作流;
  • 接入流程门槛高:微信三方平台、飞书审批流、微信消息服务器,每个都是都独立且较高门槛的接入流程;
  • 依赖大量文档调研:很多微信/飞书功能都需通过查阅官方文档进行探索,导致信息的不确定性;

1.微信三方平台接入流程

微信三方平台.jpeg

2.飞书审批流接入流程

飞书审批流.jpeg

挑战2:缺乏开箱即用的基础设施

运维配置的复杂性增加了落地过程中的挑战,需反复与相关团队沟通,以解决网络问题和资源配置。

  • 缺乏运维解决方案。示例,微信和飞书等外网服务器的接入需要处理的运维问题非常繁琐,而目前的运维工具无法提供一站式解决方案。
  • 前端基建待完善。新建一个后台管理系统成本高,新域名、Nginx配置、B端脚手架都需要时间和耐心去管理。
  • 文档不全。团队在技术支持和功能需求上常面临信息不足,尤其在对接微信公网时,缺乏系统化文档影响了问题解决的效率。

解决方案

策略:

模块化设计分解复杂性,先完成再完美。 AI工具提效,让多个AI人帮你打工。 文档留痕。

模块化设计分解复杂性

拆分清楚每个子系统在整体架构图里所处的位置,是否是关键核心链路还是用户体验优化,根据这判断工作优先级。

  • 要事第一,尽快跑通最小可用性产品(MVP),
  • 保持跟主管时刻对焦,确保目标产物跟预期一致,事事有反馈,件件有着落。

模块化设计.jpeg

AI工具提效

现在的AI足够强大,很适合做流程性、确定性的工作。充分利用好Cursor、ChatGPT、Kimi等工具,让个人效率和产出最大化。强烈推荐Cursor Compose模式,Cursor底层用的claude.ai,它最大的能力是基于当前codebase索引库,所以它生成的代码可以读懂上下文,也能模仿项目里其他的代码写法,更智能。心得总结:

  • 表结构要设计好,完整、准确、非歧义。
  • 询问的范围Scope,尽量缩小。不然AI给你的答案可能越来越偏,也能理解,范围越大意图理解就偏差了。
  • 准确的意图。可以让AI出解决方案,但一定是基于你现有的上下文足够多,且有正反馈。再强调下,准确的意图
  • 沟通的方案,让AI记录在README.md中。这个方式很有效,随着系统越来越复杂,把中间跟AI达成的一致内容,包括背景、设计目标、技术方案等让AI写在Markdown文档中。

沟通的方案.jpeg

文档留痕

1.基本每个步骤都能有对应的文档。

2.顺道完善了公司2个配套基建问题。

五、总结心得

1.加强技术基础建设

  • 技术基础设施及文档管理的重要性不可忽视,改善沟通成本和提高工作效率至关重要。
  • 运维侧工具足够多,但缺少一站式解决方案。本质上大家所处视角不同,业务背景也不同,需要从全局视角看运维要做什么,业务方又需要配合哪些场景。

2.合理利用AI提升效率

  • AI工具在本次项目中的应用显著提高了效率。无论是文档阅读、功能调研还是代码生成,AI工具都能快速提供有效的建议和总结,帮助节省了大量的时间。
  • 然而,也意识到AI并非万能。它能够处理大量信息和基础逻辑,但在复杂的人际互动、视觉设计或独特创新性任务上仍然存在局限。因此,在未来的工作中,将继续探索AI的应用边界,以更好地发挥其优势。

3.持续优化的心态

  • 尽管小程序平台的V1.0版取得了阶段性成功,但深知这只是一个起点。在后续开发中,将着重于持续迭代与优化,确保平台能够不断满足用户的需求与公司发展的目标。
  • 跟业务制定反馈收集机制,以便及时调整策略,提升产品的用户体验和整体效率。

六、未来计划

1.小程序平台二期开发:

  • 启动小程序平台的二期开发,专注于整合管理微信、支付宝等外部渠道的得物数字资产。
  • 实现与得物工作流的无缝对接,目标使不同角色人员在项目处理中无需进行下沟通或人肉搜索,提升工作效率。

2.AI工具分享:

在团队内部进行AI工具的使用技巧分享,尤其针对Cursor和各种AI类工具应用。

文 / springleo

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

多场景建模在得物交易搜索下的创新与实践

作者 得物技术
2025年2月21日 10:15

一、整体概述

2024年得物算法团队基于交易搜索的场景特点与数据现状,围绕“多场景建模”开展了一系列工作,取得了较大幅度的在线业务指标提升;同时我们利用碎片时间将积累的技术经验形成相应的论文,成功被搜索推荐/数据挖掘领域顶级会议WWW'2025CCF-A)、DASFAA'2025CCF-B)录用。三篇文章具体信息如下:

(1)WWW'2025(industry track长文):Disentangling Scenario-wise Interest Network for Multi-scenario Recommendation. (录用率仅为:22.4% (63/281))

(2)DASFAA'2025(research track长文oral):Towards Scenario-adaptive User Behavior Modeling for Multi-scenario Recommendation. (录用率仅为:20.7% (137 / 662))

(3)DASFAA'2025(industry track长文oral):When Multi-scenario Meets Multi-attribute: Scenario and Attribute-aware Recommendation with Contrastive Learning. (录用率为:41.7% (15 / 36))

下面详细介绍下这三项工作的具体内容。

二、背景介绍

近年来,线上购物平台在用户日常生活中扮演着越来越重要的角色。为了满足用户多样化的购物需求,当前大多电商App往往会集成多种购物场景(首页瀑布流、详情页、订单页等等),为不同用户提供量身定制的购物服务。随之而来,多场景学习Multi-scenario Learning,MSL)在电商平台的搜索推荐系统中也取得了蓬勃发展。下面我们从得物App整体和得物App搜索两个角度出发,深入分析不同场景的特性。

得物App整体多场景

以得物App整体为例,图1展示了其几种常见场景:

(1)首页瀑布流:用户一访问应用就能在首页上看到推荐的商品。该场景根据用户的历史行为偏好展示吸引用户的多种类型的商品。

(2)购物车页面或订单页面:该场景在用户将某些特定商品加入购物车或购买后向其推荐商品,推荐的商品更多取决于用户过去的购买记录。

得物整体多场景示例图.jpeg图1. 得物App整体多场景示例图

显然这些典型场景彼此之间的差异很大。不同场景下用户的行为和兴趣(例如,用户对特定品牌、价格或类别的偏好)也存在很大差异。具体来说,首页瀑布流上的用户兴趣比购物车页面或订单页面更加发散,因为后两个场景中展示的商品更多受到用户历史加购和下单行为的限制。比如首页上的人往往有多样化的兴趣(用户喜欢浏览更多类别和品牌的商品,它们的价格选择范围更广);相反,用户在订单页里可能会表现出更具体和集中的偏好。如果用户最近在订单页面购买了一部新手机,他可能接下来会更关注手机壳和耳机等电子设备。用户行为也会受到他们所购买过的商品引导。

得物App搜索多场景

以得物App搜索为例,我们从用户搜索流量来源角度出发进行场景划分,图2展示了其对应的场景。

得物搜索多场景示例图.jpeg图2. 得物App搜索多场景示例图

通过对不同Query来源的用户数据进行分析后我们发现用户在不同来源下的价格、类目和品牌偏好存在较为显著的差异。因此我们考虑将不同Query来源作为场景化信息,并在CTR预估任务中引入多场景学习做精细化建模。

主要问题

我们可以归纳出得物搜索多场景建模需要解决的两个主要问题

(1)用户行为兴趣(价格、类目、品牌等偏好)在不同场景下的差异如何进行有效刻画?

(2)在搜索推荐领域用户行为序列建模通常是对用户兴趣表达最为有效的手段,我们该如何将场景化信息优雅地融入到行为序列建模当中以实现最有效的多场景兴趣建模?

三、整体优化思路

SACN:针对问题(1),我们考虑既然用户在不同场景下对价格、类目和品牌等商品属性存在显式的偏好差异,而以往的用户行为序列建模通用做法也引入了商品多属性作为side info来丰富用户的兴趣表达,那么我们能否将多场景的先验信息多属性序列建模进行深入结合,达到解决问题的目的?基于此,我们提出在多场景建模下引入多属性信息去刻画用户在特定场景下的多粒度画像偏好,同时更好辅助刻画不同场景下的用户兴趣差异。我们在DASFAA'2025(industry track)提出了Scenario and Attribute-aware Contrastive Network(SACN),它首次将多场景建模和用户行为序列多属性建模结合,构造两种不同粒度的用户兴趣偏好提取组件,即Item-level Preference Extracting (IPE)和Attribute-level Preference Extracting (APE),并结合对比学习对用户在不同目标场景(target scenario)下的兴趣进行区分。

SACN虽然对问题(1)进行了一定程度优化,但它和其他工业界通用的多场景建模方法(PEPNet、SAR-Net等)类似,仅仅考虑了目标场景(target scenario)信息。而没有对用户的历史行为来源于哪个场景做区分,即它们均未对问题(2)进行过深入讨论和优化。从我们过往的用户行为序列建模迭代经验中得到启发,我们遵循如下两个优化思路,考虑在行为序列中引入丰富的场景化信息。

  • 思路1:把用户每一次历史行为所属场景来源作为和商品属性类似的side info加入到序列中;
  • 思路2:参考序列建模SIM的方式,把用户的历史行为按照所属场景来源进行分组,对用户兴趣进行更精确表达;

SAINet:SAINet沿着思路1展开优化。我们在DASFAA'2025(research track)中提出Scenario-adaptive Interest Network(SAINet),SAINet首先堆叠了多个由Scenario-aware Interest Extracting (SIE) 和Scenario Tailoring Module (STM) 组成的Scenario-adaptive Block。SIE 自适应地将场景上下文类似于行为序列中每一个商品的属性信息方式融入到用户行为序列,以更精确地捕捉用户的兴趣差异。通过堆叠的方式实现深度网络,增强了模型对场景间差异建模的能力。

DSWIN:DSWIN沿着思路2展开优化。受序列建模SIM方法的启发(即按照类目过滤筛选与目标商品类目相关的历史序列),我们按照场景对用户行为序列进行划分,更有效地提取与目标场景相关的兴趣偏好。DSWIN提出了Global Interest Aggregation(GIA)和Local Interest Resolution(LIR)模块分别建模用户全局的兴趣偏好和用户场景内的兴趣偏好。最后引入Interest Disentangling Module将场景的兴趣差异进行显式解分离。

四、相关工作

传统方法通常使用场景特定的数据为每个场景训练单独的模型。然而,这种孤立的模型训练方法通常需要更复杂的计算和维护成本。此外,单独的模型无法捕捉场景之间的相互关系。目前,最先进的模型通常使用所有场景的合并数据训练统一模型,以学习场景之间的共性和特定特征。目前有两种主流的多场景建模方法:

Scenario-specific network structures

Scenario-specific network structures受到多任务学习 (MTL)的启发。这些方法针对每个场景应用特定网络,并输出多个场景特定CTR分数,即把每个场景当作一个任务建模,然后沿用多任务建模思路构造模型。MoE提出根据共享底部输入选择子专家。MMoE通过在所有任务之间共享子网络来调整MoE结构,同时训练轻量级门控网络以优化每个任务。然而,MMoE 存在跷跷板现象(即某一任务的效果提升通常会导致其他任务的效果下降)。为了解决这个问题,PLE明确提出共享专家和任务特定专家,并采用渐进式路由机制逐步提取和分离更深层的语义知识,从而在一般设置中提高跨任务联合表示学习和信息传递的效率。HMoE利用MMoE隐式识别场景之间的差别和共同点。然而,由于多场景数据集很复杂,HMoE很难明确捕获共享和特定信息。考虑到这一点,SAML引入了场景感知Embedding模块来学习全局和场景特定视角下的特征表征,并提出了一个交互单元来学习场景之间的相似性。AESM2提出了一种新颖的专家网络结构,通过计算 KL散度自动选择最合适的共享和特定专家网络。

然而Scenario-specific network structures在模型底层表征Embedding上忽略了场景之间的差异,它们都是由很重的网络结构组成也不利于实际上线,因此Parameter adaptive network structures这种轻量结构的多场景模型是目前业界的主流。

Parameter adaptive network structures

受语音识别领域提出的 LHUC算法算法的影响,这些方法提出将场景上下文信息作为输入,通过门机制动态调整模型底层Embedding和顶层DNN隐藏单元,以学习场景多样性。AdaSparse为每个场景学习自适应稀疏结构,增强跨域泛化能力。SASS设计了一个场景自适应传输模块,将有用信息从整个场景注入到单个场景。DFFM将场景相关信息纳入特征交互和用户行为模块的参数中。3MN提出了一种基于三元网络的新颖方法来建模复杂的任务-任务、场景-场景和任务-场景相互关系。PEPNet将场景相关特征作为输入,并通过门机制动态缩放底层嵌入和顶层DNN隐藏单元。SFPNet包含一系列称为场景定制模块,通过重新定义基本输入特征并在粗粒度级别上集成场景信息。

然而这些方法都将用户的整个历史行为序列视为一个整体,无法区分行为序列中的每个行为交互来自哪个场景。如图 3 (a) 所示,粗粒度的权重调整方法(例如 PEPNet)对从历史行为聚合而成的表征向量统一施加相同的权重。图3 (b) 中的 SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们均无法区分行为序列中每个行为的所属场景。而正如背景介绍的,不同场景下的用户行为差异很大,这不可避免地会降低CTR模型的准确性。

图3场景信息利用示意图.jpeg 图3. PEPNet和SAR-Net中的场景信息利用示意图

五、SACN:When Multi-scenario Meets Multi-attribute: Scenario and Attribute-aware Recommendationwith Contrastive Learning

整体结构

SACN模型结构如图4所示。

sacn示意图.jpeg图4. SACN示意图

问题定义

2.jpeg分别表示用户集合、商品集合、商品ID集合、第j类商品属性集合和场景集合。2.2.jpeg表示用户1.jpeg按时间顺序排列的历史行为序列,其中2.3.jpeg是用户交互序列中的第i个商品,N是序列的最大长度。假设我们有m种商品属性,则5.jpeg,其中6.jpeg是第i个交互商品的ID,7.jpeg表示第i个交互商品的第j种属性。给定目标商品8.jpeg, 目标场景10.jpeg及交互历史11.jpeg,多场景建模的目标是预测用户u在场景12.jpeg下对目标商品vt.jpeg感兴趣的概率。考虑CTR预估任务,可将其形式化为:13.jpeg

具体方法

Item-level Preference Extracting

为了捕捉用户的粗粒度(Item-level)偏好,Item-level Preference Extracting(IPE)模块采用场景感知多头自注意力(MHSA)机制来处理商品ID构成的用户历史序列。我们对目标场景和商品相关信息在模型里的重要性进行了加强。1.jpeg

表示行为序列商品ID的Embedding矩阵,目标场景和目标商品ID特征向量可表示为2.jpeg。场景感知 MHSA的最终输出3.3.jpeg计算如下:

4.jpeg

为了使得目标商品和历史行为商品充分交互,并利用目标场景指导用户历史行为编码,Q、K、V 将目标场景和商品 ID对应的Embedding集成到自注意力的参数当中。集成流程如下:

5.jpeg 其中6.jpeg为变换矩阵,7.jpeg表示逐元素乘积。8.jpeg由目标场景和商品ID的Embedding重新reshape而成,与变换矩阵维度相同。这样,目标场景和商品ID的信息就可以逐元素、全面地参与到粗粒度用户偏好提取过程中。IPE的最终偏好表示9.jpeg公式如下: 10正确的图.jpeg 类似地,融合其他场景信息的偏好表征向量可以计算为10.1.jpeg,只需将Q、K、V 计算公式中的11.jpeg替换为12.jpeg,其中K表示场景数量。

Attribute-level Preference Extracting

商品的属性对于在不同场景下更全面地捕捉用户的偏好至关重要。然而,据我们所知现有的多属性建模模型都没有利用目标场景和商品信息,这会导致信息丢失,限制网络的表达能力。令1.jpeg表示历史行为的第j个商品属性;目标场景和第j个目标商品属性特征的Embedding矩阵,分别表示为2.jpeg。对称地,我们分别用3.jpeg4.jpeg替换IPE 模块Q、K、V 计算公式中的X和5.jpeg。然后Attribute-level Preference Extracting(APE)模块可以获得对应于m种商品属性类型的m个Attribute-level偏好表征。6.jpeg表示第j个表征。为了捕捉用户对属性(例如类别或品牌)的不同偏好,我们将融合目标场景信息的m个Attribute-level偏好表征利用原始的注意力网络进行融合,其定义如下:

7.jpeg 类似地,我们可以得到融合其他场景信息的Attribute-level偏好表征8.jpeg

Scenario Contrastive Module

如前所述,不同场景下的用户兴趣存在显著差异。利用自监督学习来刻画该差异性。具体而言,我们将融合当前目标场景信息的Item-level偏好表征1.jpeg和Attribute-level偏好表征2.jpeg视为正对比样本,将融合其他场景信息的相应表征3.jpeg视为负样本。我们利用对比学习损失指导模型增强两个正样本之间的相似度得分,削弱负样本和两个正样本之间的相似度得分。因此有两个对比任务如下:

5.jpeg 其中6.jpeg表示相似度函数,用于计算两个实例之间的余弦距离。7.jpeg是温度参数。Scenario Contrastive Module (SCM) 最终自监督学习场景之间的区别兴趣,并提高模型辨别场景间区别的能力。

Prediction and Optimization

我们将IPE和APE的输出、场景特征和目标商品特征连接起来,然后将它们输入多层DNN塔:

1.jpeg1.1.jpeg是用户与目标商品交互的概率。我们使用通用的交叉熵损失作为目标函数:

2.jpeg 其中2.2.jpeg是样本的标签。M是样本数量。联合损失函数利用超参数gamma平衡监督目标和自监督目标,公式如下:

3.jpeg

实验部分

实验设置

数据集

我们在Dewu收集并采样了多个场景的样本作为我们的实验数据集。该数据集中的商品属性包括价格、三级类目和商品品牌。所有商品的属性都是离散类别特征。

评估指标

对于离线评估,我们使用ROC曲线下面积 (AUC) 作为评估指标,该指标在工业推荐系统中被广泛采用。

baseline

为了验证所提出的SACN的有效性,我们将我们的模型的性能与一系列最先进的多场景学习 (MSL) 方法进行了比较,即 MMoE、PLE、M2M、PEPNet和MARIA。

整体实验结果

我们重复每个模型三次并报告平均结果。离线比较结果如表1所示。

表1. SACN和其他方法离线效果对比

表1离线效果对比.jpeg 主要观察结果总结如下:

(1)选择MMOE作为基础模型是因为它在MSL中具有代表性。与MMOE相比,PLE通过将专家网络划分为两个不同的组,实现了跨场景的更高稳定性,并更有效地提取了场景之间的差异和共同点,因此性能更佳。

(2)MMOE和PLE都在模型顶部引入了场景特定的DNN塔,并为不同场景输出多个分数。然而,它们忽略了模型的底层(例如Embedding层)优化,这将严重降低多场景建模的效果。PEPNet使用场景感知门控单元自适应地调整Embedding层和隐藏层。M2M引入了一种新颖的meta单元,它结合了丰富的场景知识来显式学习场景相关性,并增强了捕获场景特定特征表示的能力。它们都比MMoE和PLE表现更好。

(3)MARIA通过优化模型的底层和上层结构而胜过其他模型。然而,所有这些方法在多场景建模时对于用户行为的利用都只考虑商品ID,而没有考虑商品的多种属性的影响,而这些属性对于生成丰富的潜在兴趣表征和反映用户在不同场景下的兴趣差异是必不可少的。由于在多场景建模中利用了商品属性信息,我们的SACN模型在所有场景中都取得了与其他模型相比最好的性能。

消融实验

为了验证所提出的SACN中每个组件的有效性,特别是多属性相关模块,我们进行了几项消融实验:

(1)w/o APE从SACN模型中删除了APE模块,代表未引入商品属性信息;

(2)w/o APEw DIF-SR)用DIF-SR替换了APE模块,DIF-SR是一种基于自注意力的属性感知行为建模方法,但其不考虑目标场景和商品信息;

(3)w/o APEw ASIF)用 ASIF替换了APE模块, ASIF也是一种具有对齐商品ID和属性信息的属性感知行为序列建模方法;

(4)w/o SCM是从SACN中删除了SCM模块,这意味着兴趣差异没有进行明确区分;

消融实验结果如表2所示。我们发现删除APE后,模型效果会显著下降,甚至比PEPNet更差。这一观察证明了在对多种场景进行建模时利用商品属性的正确性和必要性。当用其他多属性感知行为建模方法替换APE而不使用目标场景和商品信息时,也会观察到AUC在下降,这意味着这目标场景和商品信息有利于学习细粒度的偏好表征。此外,如果没有SCM,模型性能在所有场景中都会显著下降。这表明,把融合了场景信息的Item-level偏好和Attribute偏好作为自监督信号对于区分场景间的兴趣非常有帮助。

表2. SACN变体消融实验结果

sacn变体消融.jpeg

AB实验

为了进一步证明所提出的SACN的有效性,我们将其部署在平台上进行 A/B 测试。由于工业环境限制,无法在线比较所有基线模型。因此,我们选择PEPNet作为比较的基线模型。在线评估指标是pvctr,即点击次数除以展示次数。经过一周的在线A/B测试,我们发现所提出的SACN比PEPNet模型实现了持续改进,即总体上实现 了pvctr+1.02%的提升幅度。简而言之,在线A/B测试结果再次证明了我们的SACN模型在工业环境中的有效性和实用性。

结论

在本文中,我们提出了一种用于多场景学习的新方法SACN,在对用户行为进行建模时同时引入了场景信息和商品属性信息。SACN可以利用商品ID和属性来捕获用户的粗粒度和细粒度偏好。偏好提取过程还考虑了使用目标场景和目标商品先验信息来得到更好的效果。在自监督学习的帮助下,SACN结合用户的Item-level和Attribute-level兴趣表示对不同场景中用户的偏好差异进行区分。通过大量实验表明,SACN始终优于最先进的基线模型。

六、SAINet:Towards Scenario-adaptive User Behavior Modeling for Multi-scenario Recommendation

整体结构

图 5 (a) 所示,PEPNet对从历史行为聚合(可以使用concat、pooling等聚合方式)而成的表征向量统一施加相同的权重。**图5 **(b) 中的SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们仅仅用到了目标场景的信息,均无法区分行为序列中每个行为的所属场景。图5c)中我们提出的SAINet则把场景信息作为一种属性side info自适应地加入到用户历史行为序列中,同时使用级联堆叠的方式让场景信息在深层的网络结构中得到有效表达。SAINet的具体结构如图6所示,它主要由一系列Scenario-adaptive Block,Target-aware Interest Fusion和Scenario-aware DNN Tower三大部分组成。

图5对比.jpeg图5. SAINet和PEPNet、SAR-Net的对比

图6SAINet示意图.jpeg图6. SAINet示意图

问题定义

1.jpeg为包含2.jpeg个商品的集合,3.jpeg表示包含K个场景的集合。4.jpeg表示用户u的历史行为按时间顺序排列的序列,N表示序列的长度。给定目标商品5.jpeg、目标场景6.jpeg和其他特征7.jpeg,多场景建模任务旨在设计一个统一的排序模型,以同时在K个场景中提供准确和个性化的商品推荐。我们选择点击率 (CTR) 预测作为我们的任务,其公式如下:

8.jpeg CTR预测是预测用户u在场景9.jpeg中与目标商品10.jpeg交互的概率11.jpeg。我们采用业界通用的Embedding技术将稀疏特征转换为低维dense向量。例如,12.jpeg分别表示目标商品13.jpeg和其他特征的Embedding。

具体方法

Scenario-adaptive Block

如图5所示,现有方法在进行多场景建模时,无法区分不同场景下用户行为的差异,而行为序列中蕴含的场景感知先验知识对提升模型结果的准确率有重要影响。因此,我们设计了Scenario-adaptive Block,它自适应地将场景感知上下文注入用户行为序列,获得全面、细粒度的兴趣表征,同时根据目标场景信息定制用户兴趣表征,进一步捕捉与当前场景密切相关的用户兴趣。Scenario-adaptive Block由L层堆叠而成,每个Block包含两个模块(Scenario-aware Interest Extracting和Scenario Tailoring Module)。通过堆叠Block,SAINet构建了一个深度网络,逐步增强了其对不同场景行为差异的建模能力。为了阐明这一点,我们给出了第l个Block内的计算过程,如下所示:

Scenario-aware Interest Extracting

Scenario-aware Interest Extracting (SIE) 模块旨在整合来自历史行为的场景先验信息并提取更细粒度的用户兴趣。我们采用了包含特定场景知识的改进多头自注意力 (MHA) 。1.jpeg表示第(l-1)个block的输出,N表示历史交互商品的数量。经过MHA后,2.jpeg的编码兴趣矩阵记为3.jpeg,编码计算方法如下:

4.jpeg Q、K、V 将场景Embedding集成到第(l-1)个block的输出中,以获得更精确的兴趣表征。令5.jpeg表示每个行为所来自场景的Embedding矩阵。然后集成过程可以定义为:

6.jpeg 其中h表示head数量。7.jpeg表示输出线性变换的权重矩阵,其中8.jpeg。图片分别是query、key、value对应的第i个head的投影矩阵。9.jpeg是场景Embedding的变换矩阵。

Scenario Tailoring Module

尽管SIE考虑利用历史行为中的场景信息,但它忽略了目标场景信息,而目标场景信息对于显式捕捉与当前场景相关的用户兴趣非常重要。我们提出了Scenario Tailoring Module (STM) 来进一步定制处理用户的兴趣表征(即11.jpeg。STM由N个轻量级门控单元组成。第i个门控调节计算如下:

12.jpeg 其中13.jpeg分别是投影矩阵和偏置项。14.jpeg是缩放因子,用于进一步压缩和放大定制信号。15.jpeg表示目标场景的Embedding。16.jpeg是逐元素乘积。最后,第l个block的兴趣表征可以定义为17.jpegScenario-adaptive Bloc块迭代L次,以提高其捕捉场景间行为差异的能力。特别地,第一个block的原始输入(即18.jpeg)定义为:

19.jpeg 其中20.jpeg表示用户行为序列21.jpeg的Embedding矩阵

Target-aware Interest Fusion

在经过Scenario-adaptive Block后,会生成多个兴趣表征。所有表征必须首先进行融合,以方便其与传输到下游 DNN网络的其他特征向量融合。我们采用Target-aware Interest Fusion (TIF) 通过注意力机制进行融合:

22.jpeg 其中23.jpeg表示与目标商品和目标场景相对应的融合兴趣表征向量。24.jpeg是可学习参数。25.jpeg是注意力权重,可以按如下公式计算:

26.jpeg 其中27.jpeg之间的相关性。28.jpeg表示目标商品Embedding和场景Embedding拼接后得到的向量。29.jpeg是可学习参数。

Scenario-aware DNN Tower

虽然我们优先考虑底层架构(即用户行为)的优化,但模型顶层的优化也是不容忽视的。因此,我们通过在预测阶段引入Scenario-aware DNN Tower(SDT)对DNN顶层的隐层单元进行动态缩放。我们首先将所有输出连接起来:

30.jpeg 然后使用Scenario-aware DNN Tower(SDT)来预测用户点击目标商品的概率:

31.jpeg 其中sDNN中第j层的具体计算为:

32.jpeg 其中33.jpeg为Sigmoid激活函数。34.jpeg分别为第j层的权重和bias。对于CTR任务,我们使用如下交叉熵损失作为目标函数:

35.jpeg 其中36.jpeg是样本的ground truth。|D|是样本的数量。

实验部分

我们进行了大量实验来验证我们提出的SAINet模型的有效性并回答以下问题:

RQ1:与最先进的基线相比,SAINet的表现如何? RQ2:SAINet中的每个模块是如何工作的? RQ3:所提出的 SAINet 中的超参数如何影响其性能?

实验设置

1.数据集

我们在以下两个真实数据集上进行了实验。数据集统计信息见表3。

AliCCP:AliCCP是淘宝发布的带有训练和测试集的公开数据集,在推荐领域的相关文献中被广泛使用。我们根据上下文特征值将数据集分为三个场景(缩写为#C1至#C3)。

Dewu数据集:它涉及五个场景(表示为#A1至#A5)的用户日志,通过随机抽样得到。

表3. 数据集基本信息统计

表3数据集.jpeg

2.评估指标

我们采用广泛使用的准确率指标AUC来验证模型性能。AUC表示测试集上ROC曲线下的面积。AUC的小幅提升可能会导致真实工业平台上CTR指标的显著提升。

3.baseline

为了证明我们提出的模型的有效性,我们将SAINet与多场景建模中的三类方法进行了比较。

General recommenders:所有场景的样本被合并以训练统一的排名模型。

  • DNN:这是一个通用模型,由全参数共享的单DNN塔进行预测打分。
  • DeepFM:它结合了factorization machines和DNN组件,消除了人工特征工程。

Scenario-specific network structures:每个场景都被视为不同任务,由多个场景特定的网络构成。

  • SharedBottom (SBT):在底层共享所有参数,在顶层采用多个场景特定的DNN塔。
  • MMoE:将原有的多任务学习转移到多场景学习。MMoE应用门控网络调整底层专家输出表征向量,然后是场景特定的塔,并学习从数据中建模场景关系。
  • PLE:在MMoE的基础上引入场景共享专家和场景特定专家,有效缓解跷跷板现象。
  • STAR: 设计星型拓扑结构,一个中心网络维护所有场景的共性信息,一组场景特定网络区分场景差异信息。
  • AESM2: 提出一种新颖的专家网络结构,通过计算KL散度自动选择细粒度专家,动态选择最合适的共享专家和特定专家。

Parameter adaptive network structures:场景上下文直接应用于Embedding层和DNN的隐藏层,并根据场景变化动态调整模型参数。

  • PEPNet:以场景相关特征为输入,通过门控机制动态缩放模型中底层嵌入和顶层DNN隐藏单元。
  • AdaSparse (ADS) 自适应学习每个场景的稀疏结构,通过学习权重修剪冗余神经元,增强跨场景泛化能力。
  • SFPNet:由一系列场景定制模块组成,通过重新定义基础特征,在粗粒度级别集成场景信息,同时将目标场景信息融入行为建模中,支持目标场景感知的用户兴趣建模。

整体实验结果

表4显示了所有方法在两个数据集上的比较结果。对于每种方法,我们重复实验五次并报告平均结果。使用t-test进行统计显著性检验。我们的方法相对于最佳基线的效果在0.05水平上具有统计意义显著性。

表4. SAINet和其他方法离线效果对比

表4离线效果对比.jpeg

基于此我们可以得出以下结论:

  • 所有General recommenders方法在两个数据集上的表现都不如其他方法。这是因为它们都忽略了场景之间的相互关系和差异。为了解决这个问题,Scenario-specific network structures被提出并实现了显著的性能提升。SharedBottom增加了几个场景特有的DNN塔来利用场景特定的知识,这无法捕捉场景之间复杂的相互作用。MMoE使用专家和门控网络来提取不同场景的共性并得到更好的结果。但MMoE在多个场景中表现出跷跷板现象(即一个场景的改进往往会导致其他场景的性能下降)。例如,与DeepFM相比,MMoE在工业数据集的场景#A4和场景#A5中的表现不尽如人意,因为这些场景具有更多不同的特性和不均匀的数据分布,而MMoE不足以处理它。PLE 通过将专家分为两组(即部分场景共享和部分场景特有),缓解了这一现象,并在两个数据集上都表现出比MMoE 更好的性能。AESM2进一步引入了专家自动选择机制,以获得比PLE更好的性能。
  • Scenario-specific network structures仅优化了网络的顶层部分。然而,底层Embedding表征中场景之间的差异对于实现良好的性能也至关重要。PEPNet通过将场景相关特征作为输入,并通过门机制动态缩放模型中的底层Embedding和顶层DNN隐藏单元,在两个数据集上都获得了良好的性能改进。与PEPNet相比,AdaSparse的性能较差,因为稀疏隐藏单元的机制难以学习。SFPNet在实验中表现出所有基线中的最佳性能,因为它集成了场景信息重新定义基本特征,同时将目标场景信息和行为融合以支持场景感知的用户兴趣建模。然而,所有这些研究都忽略了利用历史行为序列和目标商品中涉及的场景先验知识来有效地建模用户在不同场景中的兴趣差异。我们提出的SAINet通过引入丰富的场景上下文明确地建模了不同场景中用户行为的差异。此外,它还可以轻松捕捉跨场景的兴趣迁移。如表4所示,SAINet在两个数据集上的所有场景中都优于所有基线。

消融实验

为了评估SAINet中每个模块的有效性,我们还将SAINet与其变体进行了比较。考虑了以下变体:

  • w/o SIE:从Scenario-adaptive Block中删除了SIE,这意味着该模块不再强调历史行为中的场景信息。
  • w/o STM:从Scenario-adaptive Block中删除了STM,这意味着目标场景信息不用于定制化兴趣表征。
  • w/o TIF:从SAINet中删除了TIF模块,并将其替换为mean pooling操作。
  • w/o SDT:从SAINet中删除了SDT模块,并将其替换为正常的DNN网络。

具体的消融实验结果如表5所示。

表5. SAINet变体消融实验结果

表5变体消融.jpeg

表5所示,每个模块都对SAINet的性能做出有效贡献。具体而言,SIE模块的缺失(w/o SIE)会影响所有场景的预测性能,表明在增强对不同场景的用户兴趣差异进行建模的能力时,整合来自历史行为的场景先验知识是至关重要的。此外,STM的移除(w/o STM)也会导致模型的预测性能明显下降。这有力地验证了定制用户兴趣表示以捕捉与当前场景显着相关的用户偏好的有效性。此外,如果没有TIF模块,模型性能在两个数据集中都会有所下降。这反映了引入目标注意机制而不是mean pooling的兴趣融合对于确保预测准确性非常有帮助。最后,删除SDT将损害模型的性能。这表明,在场景信息的帮助下调整顶层网络参数也不容忽视,以获得更好的结果。

超参实验

我们进行了大量实验来检验几个关键超参数的影响,其中包括Scenario-adaptive Block的数量L、STM模块中的缩放因子图片、SIE模块中的head数量h。

Block数量L的影响:图7 (a) 说明了不同L对模型效果的影响。随着数值的增加,AUC呈现出改善的趋势。这主要是因为随着L的增大,兴趣表示和场景上下文之间的相互作用得到加强,而将L增加到 2以上并没有带来显著的收益。

图片的影响:缩放因子在 {0.8, 1.2, 1.6, 2.0, 2.4, 2.8} 中进行多组实验。根据图7(b) 所示的曲线,当因子值等于2时,SAINet在AUC上表现最佳,而将值增加到2以上会降低其性能。因此,我们在所有实验中将SAINet及其变体中的缩放因子设置为2。

head数量h的影响:h在 {2, 4, 6, 8, 10} 进行选择。图7 (c) 显示了head数量对SIE中多头注意力机制的影响。当head等于4时,AUC曲线达到峰值,而引入超过4的head数量会带来更差的性能。因此,在所有实验中将h设置为 4。

图7超参实验曲线图.jpeg图7. SAINet超参实验曲线图

结论

本文强调了区分不同场景下用户行为差异对兴趣建模的必要性,并提出SAINet模型。它首先引入了一系列Scenario-adaptive Block,将场景先验知识融合到用户行为当中以捕捉用户的细粒度兴趣,并根据目标场景上下文定制兴趣表示。通过堆叠block,可以增强对不同场景兴趣差异的建模能力。SAINet还利用场景感知DNN Tower (SDT) 来自动调节顶层 DNN隐藏单元,从而获得更好的预测结果。大量实验证明了SAINet在多场景建模中的优势。

七、DSWIN:Disentangling Scenario-wise Interest Network for Multi-scenario Recommendation

整体结构

图8所示,和SAINet类似,我们把DSWIN和PEPNet、SAR-Net进行对比。图8 (a) PEPNet对从历史行为聚合(可以使用concat、pooling等聚合方式)而成的表征向量统一施加相同的权重。图8 (b) 中的SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们仅仅用到了目标场景的信息,均无法区分行为序列中每个行为的所属场景。图8c)中我们提出的DSWIN则按照场景信息对用户行为进行分组,并引入Global Interest Aggregation建模用户全局行为兴趣,Local Interest Resolution建模用户在每个场景内的局部兴趣,最后Interest Disentangling Module对用户在不同场景下的兴趣进行解分离。DSWIN的具体结构如图9所示,它主要由Global Interest Aggregation,Local Interest Resolution和Interest Disentangling Module三大部分组成。

图8对比.jpeg图8. DSWIN和PEPNet、SAR-Net的对比

图9示意图.jpeg图9. DSWIN示意图

问题定义

1.jpeg个商品组成的集合,2.jpeg表示K个场景组成的集合。3.jpeg表示用户u的历史行为按时间顺序排列的序列,N表示序列的长度。4.jpeg表示用户在第k个场景中发生行为的子序列,满足5.jpeg。给定目标商品6.jpeg、目标场景7.jpeg和其他特征8.jpeg,多场景建模任务旨在设计一个统一的排名模型,同时在多个场景中提供准确且个性化的推荐。我们的工作考虑了点击率 (CTR) 预测任务,其公式如下:

9.jpeg CTR 预测是利用行为序列和其他上下文特征,预测用户u在给定场景10.jpeg中点击目标商品11.jpeg。我们利用广泛使用的Embedding技术将稀疏特征转换为低维dense向量。例如,12.jpeg分别表示目标商品13.jpeg和其他特征的Embedding向量。

具体方法

Global Interest Aggregation

如图8所示,先前的研究将用户的历史行为序列视为一个整体,而忽略了行为产生来源场景的信息。因此,我们首先设计了Global Interest Aggregation(GIA)模块,该模块动态融合用户的全局行为和场景感知的上下文信息,旨在获得全面、细粒度的兴趣表征。

Scenario-aware Context Aggregation Module

我们首先设计了Scenario-aware Context Aggregation Module(SCAM),它使用注意力机制对来自不同场景的行为进行聚合。同时,我们考虑将来自历史行为和当前样本的目标场景先验信息集成到SCAM中,以更好地理解用户的场景感知全局兴趣。因此,SCAM可以表述如下:

1.jpeg 其中2.jpeg表示与当前样本和目标场景相对应的兴趣表征向量,它是由历史交互商品的加权聚合而成,3.jpeg是可学习参数。4.jpeg是注意力权重,可以表示如下:

5.jpeg 其中6.jpeg是目标商品7.jpeg与用户在行为序列中第j个交互商品之间的相关性。8.jpeg表示目标商品Embedding向量9.jpeg与场景Embedding向量10.jpeg的拼接。类似地,11.jpeg表示第j个点击商品Embedding向量12.jpeg与对应场景Embedding向量13.jpeg的拼接。14.jpeg是学习参数。

Context Feedback Fusion Module

尽管SCAM考虑在计算权重时利用场景信息,但其表达目标场景与用户行为之间复杂相互作用的能力有限。为了进一步捕捉与当前目标场景密切相关的用户全局兴趣,我们提出了Context Feedback Fusion Module(CFFM),通过非线性特征交互将行为的兴趣表征向量与相应的上下文(即目标商品和场景)融合在一起。具体来说,CFFM 由具有k个block的MLP组成:

15.jpeg 其中16.jpeg是第k层的输出,17.jpeg是可学习参数。初始化输入公式如下:

18.jpeg 其中*表示逐元素乘积。我们最终可以获得与目标场景上下文和当前样本相对应的全局兴趣19.jpeg

Local Interest Resolution

为了明确区分不同场景下用户兴趣的差异,我们设计了Local Interest Resolution(LIR)模块,以明确提取每个子场景中用户的场景兴趣。LIR将全局行为按场景拆分为多个子序列,并由多个结构对称的Interest Extracting Unit(IEU)组成。考虑到同一场景下的用户行为更加集中和明确,每个IEU采用改进的多头自注意力(MHA),在IEU里引入特定场景信息作为偏差项来建模每个子序列并获得用户在不同场景中的局部兴趣表征。此外,MHA使LIR能够从多个兴趣角度建模用户的偏好。

Interest Extracting Unit

20.jpeg表示用户在第k个场景中发生交互的行为子序列,21.jpeg表示发生交互的商品数量。经过Embedding变换后,22.jpeg可表示场景23.jpeg中的行为Embedding矩阵。经过MHA编码后矩阵,记为24.jpeg,计算方法如下:

25.jpeg Q、K、V 将场景Embedding作为偏置项集成到行为Embedding矩阵中,以指导局部兴趣解析。它们的定义如下:

26.jpeg 其中h表示head的数量。28.jpeg表示输出线性变换的权重矩阵,29.jpeg30.jpeg分别是query、key、value对应的第i个head的投影矩阵。31.jpeg是偏置项的变换矩阵。随后,输出矩阵经过mean pooling层处理,得到一个表征向量32.jpeg,表示用户在第k个场景中的局部兴趣。特别地,对于当前场景33.jpeg。最后,LIR的输出由所有K个场景的表示组成:

34.jpeg

Interest Disentangling Module

如前所述,不同场景下的用户兴趣既有重叠之处,也有差异之处。由于不存在用户兴趣的标注信息(没有一个显式的信号告诉模型两个兴趣表征是否相似),目前的监督建模方法缺乏明确的监督信号来充分区分不同场景的兴趣。因此,我们利用自监督学习来解分离场景间的兴趣。与现有的结合对比学习的方法不同,这些方法往往侧重于复杂的数据增强技术,我们分析特定领域问题并基于原始数据设计对比策略。具体而言,我们将当前目标场景的全局兴趣表征35.jpeg和局部兴趣表征36.jpeg作为正对比样本,将其他场景的局部兴趣表征37.jpeg作为负样本。我们利用对比学习损失来教导模型增强两个正样本之间的相似度得分,削弱负样本和两个正样本之间的相似度得分。因此有两个对比任务如下:

38.jpeg 上述优化目标通过InfoNCE损失计算,如下所示:

39.jpeg 其中40.jpeg表示相似度函数,用于计算两个实例之间的余弦距离。41.jpeg是温度参数。LIR最终通过强烈的自监督信号监督场景兴趣的分离,并提高模型辨别不同场景差异的能力。

Prediction and Optimization

我们将GIA和LIR的输出、场景特征、目标商品特征和其他特征连接起来:

42.jpeg 然后结合多层DNN塔来预测用户点击目标商品的可能性:

43.jpeg 其中45.jpeg是sigmoid激活函数。对于 CTR 预测等监督任务,我们使用交叉熵损失作为目标函数:

44.jpeg 其中46.jpeg是样本的 ground truth。|D|是样本的数量。我们以端到端的方式在监督和自监督目标上训练模型。具体来说,联合损失函数带有一个超参数47.jpeg来平衡目标,可以表述如下:

48.jpeg

实验部分

我们进行了大量实验来验证我们提出的框架的有效性,并回答以下问题:

RQ1:与最先进的基线模型相比,DSWIN的表现如何?

RQ2:DSWIN中每个模块如何工作?

RQ3:提出的DSWIN中的超参数如何影响其性能?

RQ4:DSWIN能否有效地根据场景解分离用户兴趣?

实验设置

同第二篇文章SAINet所述。

整体实验结果

表6分别显示了所有方法在工业和公共数据集上的比较结果。对于每种方法,我们重复实验五次并报告平均结果。使用t-test进行统计显着性检验。我们的模型相对于最佳基线的性能在0.05水平上具有统计显著性。可以得出以下结论。

表6. DSWIN和其他方法离线效果对比 表6.jpeg

所有General recommenders方法在两个数据集上与其他解决方案相比,效果始终不尽如人意。DNN使用单个DNN塔来处理所有场景,完全忽略了场景之间的相互关系和差异。虽然DeepFM尝试在特征交互方面进行一些优化,但当数据集表现出明显的场景变化时,效果并没有得到明显改善。

为了解决General recommenders方法的问题,Scenario-specific network structures被提出。它们都比 General recommenders带来了显著的性能提升。SharedBottom 为每个场景添加了几个特定的DNN塔,以利用场景特定的知识,这不足以捕捉场景之间复杂的相互作用。MMoE使用专家和门控网络来提取有关场景和用户的更有效信息,STAR使用共享网络来维护整个场景的共性。MMoE和STAR均增强了模型跨场景学习共享知识的能力,因此优于 SharedBottom。MMoE 和STAR在多个场景中也表现出跷跷板现象(即一个场景的改进往往导致其他场景的性能下降)。例如,与最好的General recommenders模型相比,MMoE和STAR在工业数据集的场景#A4和场景#A5中的表现较差。这表明 MMoE和STAR不足以处理具有更多不同特征和不均匀数据分布的场景。PLE通过将专家分为两组(即部分场景共享和部分场景特定)缓解了这种现象,并在两个数据集上都表现出比 MMoE 显著的改进。AESM2进一步引入了专家自动选择机制,以获得比 PLE 更好的性能。

然而,Scenario-specific network structures仅仅注重网络顶层的优化,而忽略了底层表征中场景之间的差异,而这些差异对于实现最优性能至关重要。PEPNet以场景相关特征为输入,通过门控机制动态调整模型中的底层Embedding和顶层DNN隐藏单元,在两个数据集上都获得了良好的性能提升。AdaSparse的性能与 PEPNet 相比并不理想,因为稀疏隐藏单元的机制相对难以学习。SFPNet在实验中表现出所有基线中的最佳性能,因为它融合了场景信息重新定义了基本特征,同时将场景上下文融入行为以支持场景感知的用户兴趣建模。但很明显,它们都将用户的整个历史行为序列视为一个整体,而忽略了对跨场景用户兴趣差异的建模。我们提出的DSWIN以细粒度的方式明确地模拟了不同场景中用户行为的差异,并了解了用户的场景兴趣,其表现优于两个数据集上所有场景的所有基线,如表6所示。

消融实验

为了评估DSWIN中每个模块的有效性,我们还在实验中将DSWIN与其变体进行了比较。我们有三个变体,如下所示:

  • w/o GIA:删除了GIA中的SCAM和CFFM模块,直接用普通的DIN网络替换它进行全局序列建模。
  • **w/o LIR:**从DSWIN中删除了LIR模块,同时IDM模块也被分离。这意味着在不同场景中没有提取明确的局部兴趣。
  • **w/o IDM:**从DSWIN中删除了IDM模块,这意味着兴趣差异没有得到强调和解分离。

表7. DSWIN变体消融实验结果

表7.jpeg

表7所示,每个模块都做出了相当大的贡献,以确保DSWIN在所有场景中的预测结果的质量。具体而言,GIA的缺失(w/o GIA)会影响所有场景的预测性能,表明在增强针对用户行为的场景特定定制能力时,整合来自历史行为和当前实例的场景先验信息非常重要。此外,GIA模块中的CFFM还可以捕捉与当前目标场景密切相关的用户全局兴趣。此外,LIR的移除(w/o LIR)也会导致模型预测性能明显下降。这有力地验证了我们区分个人行为来自哪种场景并明确捕捉不同场景中用户兴趣差异的原理。同时,LIR有效地学习了同一场景中用户行为的内在相关性。最后,如果没有IDM,模型在两个数据集中的性能都会显著下降。这反映了注入无监督信号的自监督学习对于解开场景兴趣和确保预测准确性非常有帮助。

超参实验

我们进行了大量实验来检验几个关键超参数的影响,其中包括CFFM中的block数量k、对比损失中的温度温度.jpeg、平衡监督损失和无监督损失的权重权重.jpeg

CFFM中block数量的影响: 图10 (a) 说明了不同k的影响。随着数值增加,AUC显示出改善的趋势。这主要是因为随着k变大,兴趣表示和上下文之间的相互作用更深,而将k增加到2以上并没有带来显著的收益。

温度系数的影响:温度在 {0.1, 0.2, 0.4, 0.8} 内经过精心调整实验。根据图10 (b) 中所示的曲线,我们发现温度.jpeg的最佳选择因数据集而异。太小的值(例如0.1)或太大的值(例如0.8)都不合适。较大的值会削弱对负样本的区分能力。相反,太小的值会过度夸大某些负样本的作用,导致性能不佳。

损失平衡权重的影响:我们进行了实验,将权重.jpeg从 {1e0, 1e-1, 1e-2, 1e-3, 1e-4}中进行变化。特别地,当权重.jpeg为0时,相当于删除了模块IDM。从图10 (c) 中的结果可以看出,当权重.jpeg为1e-1时,性能达到峰值。随着值的进一步增加,模型性能变得越来越差。我们将其归因于随着权重.jpeg的增大,主预测任务重要度降低,这验证了超参数平衡不同任务目标的必要性。

图10.jpeg图10. DSWIN超参实验曲线图

可视化分析

我们通过实验来探索IDM如何促进兴趣表征学习,以及该模块是否理解不同场景下的兴趣相似度。我们基于模块LIR给出的Embedding向量计算了场景#A1和#A2输出的余弦相似度,分别在引入和不引入对比损失的情况下,同时绘制了相似度得分的分布(图11所示)。从结果中,我们可以观察到DSWIN学习到的Embedding的相似度得分小于不使用IDM 学习到的分布。这一现象表明IDM使模型能够有效地解分离场景化的兴趣。

图11.jpeg图11. LIR输出兴趣相似度分布柱形图

AB实验

我们基于实际流量进行在线 A/B测试。具体来说,我们在在线服务系统中部署DSWIN和baseline方法,并针对用户的日常请求执行推理任务。由于工业限制,在线系统中无法比较所有基线模型。因此,我们选择PEPNet作为比较的基线模型。我们取一周测试的平均结果,DSWIN比PEPNet在线获得了pvctr+1.51%的收益。1.51%在成熟的工业系统中是显著的效果提升。

结论

在本文中,我们强调了区分不同场景中用户行为差异对于兴趣建模的必要性,并设计了一种名为DSWIN的新颖的scenario-wise interest disentangling network。它首先引入GIA模块来融合用户的全局行为和场景感知上下文信息,旨在动态获取全面、细粒度的用户兴趣表征。随后,使用LIR模块明确提取用户在每个子场景中的场景兴趣。最后,DSWIN利用对比学习技术按场景解分离兴趣并辨别场景之间的区别。此外,我们提出的模型可以有效捕捉不同场景中兴趣的迁移。大量的离线和在线实验证明了DSWIN在多场景建模中的优势。

八、总结与展望

  1. 当前实现的多场景建模方法只在搜索区分流量来源这一个维度上进行实验,后续可以继续思考如何将场景化信息泛化到人群(比如按照购买力划分、活跃度、新老客等),实现多人群精细化建模;
  2. 进一步也可以将多场景泛化到得物多个行业下(比如按照商品品类进行差异化建模),实现多行业差异化建模;
  3. 在当前这三项工作中,对于用于行为序列中的商品side info还只是考虑ID类的特征(item ID,类目ID和品牌ID),而商品还有丰富的多模态(文本、图片)信息也可以进一步融合到建模当中,实现多场景 & 多模态建模。

文 / huangjin

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

从大模型性能优化到DeepSeek部署|得物技术

作者 得物技术
2025年2月18日 14:02

一、背景

Deepseek-r1模型的爆火标志着本地部署大模型的需求日益增长。本文主要探讨如何优化本地部署大模型的性能,并结合我们的实践进行评测分析,文章最后我们将分享如何在本地高效部署满血版Deepseek-r1大模型。

在生产环境中,我们已部署专用的大模型推理集群,并对其性能进行了全面优化。对于大模型推理来说,性能优化主要聚焦于两个关键指标:吞吐量与响应时间(RT)。

1.吞吐量 传统上,我们用每秒请求数(QPS)来衡量吞吐量,即系统每秒能够处理多少请求。但对于大模型而言,还有一个重要指标——每秒Token数(token/s),它反映了系统每秒能处理的输入或输出Token数量。 2.响应时间(RT) 这是指系统处理每个请求所需的时间。对于支持流式输出的大模型,还需要关注另外一个指标——首个Token到达时间(TTFT: Time To First Token),即从开始处理请求到输出第一个Token所需的时间

接下来文章将介绍部署高性能大模型推理服务的方法与思路,这些方法主要围绕提升吞吐量与响应时间这两个关键指标展开。

二、高性能、易扩展的大模型推理框架是什么样的

尽管业界已有许多经典的大模型推理框架,但在深入了解这些框架之前,我们不妨先思考一下,如何设计一个既高性能又易于扩展的大模型推理框架。

大模型推理框架需要满足的基本条件

性能足够高——CPU与GPU分离设计

对于一款以Python为主的大模型GPU推理引擎,要实现较高的性能,关键在于CPU与GPU的分离设计。至少需要将系统拆分为两个进程:CPU进程和GPU进程。CPU进程主要负责与CPU相关的逻辑,例如序列化、调度、分发和Resize等;而GPU进程则专注于GPU推理逻辑,其底层通过直接调用CUDA等库来进行GPU运算。

目前,主流的大模型推理框架,如vllm与sglang,已经实现或正在实施CPU与GPU分离架构。

CPU与GPU分离设计.jpeg

那么,CPU与GPU分离究竟解决了什么问题呢?

一直以来,Python 在运行时采用全局解释器锁(GIL)机制,这意味着在任意时刻只有一个线程能够执行 Python 字节码。也就是说,即使在多线程程序中,各线程也无法在 Python 层面实现真正的并行执行。这个设计主要是为了简化内存管理和对象引用计数,从而保证线程安全,但也带来了一些限制,特别是在以GPU计算为主的推理服务中更为明显。

在单一的 Python 进程中,如果同时存在多个 CPU 密集型任务(比如网络请求处理、数据预处理、请求验证等)和 GPU 任务,它们都必须在同一个 GIL 下运行。这样,CPU密集型任务就会与GPU任务竞争 GIL,导致 GPU kernel 启动调度不足,从而形成性能瓶颈。这种瓶颈表现为GPU利用率不高,在高并发场景下,GIL 的竞争会极大地影响系统的响应速度和吞吐量。

下表为我们曾经针对Python GIL锁做过的专项对比测试,在做了CPU与GPU分离设计后,GPU利用率大幅提高,QPS提升了7倍,RT缩减了50%。

截屏20250218 11.55.41.png

下面是VLLM在0.6版本做的最大变更,即做CPU与GPU进程分离设计,带来了性能的大幅提升,吞吐提升了2.7倍。具体可以参考文章[1]vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction。

5x.jpeg

可扩展性足够好——各模块高内聚低耦合

为了实现高效且易于扩展的设计,我们应将系统按照功能拆分为多个模块,每个模块只负责其特定功能,确保模块内部的高内聚和模块间的低耦合。一个完整的大模型推理框架至少需要包含以下四个模块:

  • 接入层:接入层负责处理各种请求。比如当收到OpenAI格式的请求时,接入层将其转化为内部可识别的原始请求(raw request),以便后续其他模块继续处理。
  • 调度器:调度器负责管理和调度各个请求(Request)。当有多个并发请求时,调度器动态调整模型的输入和输出,以确保计算资源得到高效利用,同时满足调度限制,如GPU缓存、最大请求数和最大处理长度等。调度器通过管理请求的状态、缓存、优先级和资源使用,确保推理过程流畅进行。
  • 模型推理:在接收到请求后,模型推理层调用相应模型的forward方法进行推理计算。其底层实际上调用CUDA等进行GPU推理。
  • 显存管理:操作系统有物理内存管理机制,避免了频繁申请和释放内存带来的碎片问题。然而,CUDA计算中并没有显存管理机制,频繁的显存申请与释放同样会导致显存碎片问题。因此,显存管理成为推理引擎中不可或缺的模块,用于解决显存碎片问题。

大模型推理框架设计

综合上述内容,我们可以设计出一个高性能、可扩展的大模型推理框架。框架图如下:

大模型推理框架设计.jpeg

从框架图中可以看出,系统首先被拆分为多个进程(多个CPU进程与GPU进程),进程间可通过管道等方式进行通信。此外,系统在逻辑上又被拆分为多个模块,其中接入层、调度器、模型推理和显存管理四个模块是必不可少的。

该架构也是当前vllm 1.0与sglang等经典推理框架的基础架构。感兴趣的同学可以通过查看相关代码,你会发现它们的设计思路大致与上面相同。比如下面的sglang推理引擎的代码,参考:[2]sglang 代码

sglang代码.jpeg

三、解决显存碎片问题,大幅提升吞吐—Paged Attention

在 Linux 等操作系统上运行的应用程序通常不会出现内存碎片问题,这是因为 Linux 内核拥有强大的内存管理机制,专门用于解决内存碎片问题。然而,这一优势仅适用于系统内存;如果在 GPU 上频繁申请和释放不规则大小的显存,就可能导致显存碎片的产生。

在大模型推理场景中,显存碎片问题尤为严重。大部分推理过程都涉及注意力计算(Attention),而每次计算都需要申请并使用一个名为 kvcache 的缓存。随着请求的不断增加,kvcache 的大小与数量会逐步上升,通常占据总总显存的约三分之一,而且它会被频繁地被申请和释放。如果不对 kvcache 使用的 GPU 显存进行有效管理,显存碎片将大量累积,最终可能导致系统性能下降甚至崩溃。

KA ca.jpeg

为了解决这一问题,vllm 提出了 Paged Attention 的概念[3],其设计思路正是借鉴了操作系统的内存管理机制,对 kvcache 的显存进行统一管理。

首先,我们回顾一下操作系统是如何避免内存碎片的。操作系统通常将物理内存划分为若干固定大小的块,并利用页表将应用程序的虚拟地址映射到相应的物理内存块。当应用程序申请内存时,系统先分配虚拟地址,然后在实际使用时,从固定大小的物理内存块中分配空间,并建立虚拟地址与物理地址之间的映射。这样,就能有效避免内存碎片问题。

内存碎片问题.jpeg

Paged Attention 正是基于这一原理而提出的——“Paged”体现了类似页表的映射方式,而“Attention”则表示这种映射机制被应用在大模型注意力计算中。

大模型注意力计算.jpeg

PagedAttention 工作原理,图片来自[3] Efficient Memory Management for Large Language Model Serving with PagedAttention。

vllm 的 Paged Attention 是一种受操作系统虚拟内存和分页机制启发的注意力算法。它将大型语言模型中的 KV Cache 缓存划分为固定大小的块,这些块可以在内存中非连续存储。然后通过Block table(类似Linux页表)把每个请求的逻辑KV 块(类似Linux虚拟地址)映射到物理KV 块(类似物理内存)中。通过这种方法,PagedAttention 能有效管理内存,减少碎片和浪费,大幅提升系统的吞吐量。

Paged Attention评测效果

paged 评测效果.jpeg

图片来自[4] vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention

通过高效的内存管理,Paged Attention减少了内存浪费,提高了 GPU 的利用率,使模型的推理吞吐量比传统方法提升了数倍。例如,与 HuggingFace Transformers 相比,吞吐量可提升至 24 倍;与 HuggingFace TGI 相比,提升可达 3.5 倍。此外,它还降低了内存开销,支持更复杂的采样方法,使 LLM 服务变得更快、更经济。

目前VLLM与SGLang等推理引擎默认支持Paged Attention开启,所以你使用这些推理引擎部署大模型,系统会自动支持。

四、缓存之前请求的计算结果,减少重复计算—Radix Attention

虽然 Paged Attention 成功解决了显存碎片问题,并显著提升了系统吞吐量,但在大模型推理中,还有一个常见场景具备较大性能优化空间。

实际应用中,我们往往需要多次给大模型发送请求,而这些请求的Prompt中有很大一部分的内容是完全相同的。如下所示:

prompt.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

上图中蓝色框表示可共享的Prompt部分,绿色框表示不可共享的Prompt部分,黄色框表示模型的输出。

上图中可共享部分场景包括少量示例学习、自我一致性中的问题、多轮对话中的聊天历史,以及思维树中的搜索历史。在这些场景中,每次请求都会重复计算这些Prompt中可共享的部分,这些会造成大量的计算资源浪费。

那么,有没有办法将这些重复部分的计算结果(KV Cache)缓存起来,下次请求时直接使用呢?为此,SGLang 提出了一种优秀的算法—— Radix Attention。

RadixAttention 是一种新技术,用于在大语言模型的推理过程中优化 KV 缓存的重用。其核心在于利用基数树(Radix Tree)来高效管理和重用不同请求之间共享的前缀,从而减少重复计算和内存占用。也就是说,当多个请求共享相同的前缀(例如系统提示 "You are a helpful assistant")时,RadixAttention 可以重用该前缀对应的 KV 缓存,避免重复计算。

下图中的例子展示了 RadixAttention 在九个时间点上的基数树动态演变过程,以下是具体步骤的解释:

以下是具体步骤的解释.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

  1. 初始状态:基数树最初为空。
  2. 第一场聊天开始:用户发送“你好!”,助手回复“你好!”,系统提示、用户消息和助手回复被合并为基数树中的一条边,连接到新节点。
  3. 第一场聊天继续:同一会话中收到新消息,服务器在基数树中找到已有前缀并重用其KV缓存,新消息作为新节点附加到树上。
  4. 第二场聊天开始:另一个用户开始新的聊天会话,服务器分割现有节点,使两个会话共享公共前缀和KV缓存。
  5. 第二场聊天继续并进行淘汰:第二场聊天继续,因内存限制,第一场聊天中最少近期使用的节点被淘汰释放空间,新消息在共享节点后添加到树上。
  6. 处理少样本学习查询:服务器收到不与现有节点共享前缀的少样本学习请求,根节点被分割以容纳新序列,请求作为单独分支插入树中。
  7. 处理一批少样本学习查询:收到一批共享相同少样本示例的查询,分割第六步的节点以在这些查询间实现KV缓存共享,最大化重用并减少冗余计算。
  8. 第一场聊天继续并进行淘汰:第一场聊天继续,基于最近最少使用(LRU)策略,淘汰第二场聊天的节点以高效管理内存。
  9. 自一致性采样并进行淘汰:服务器收到生成多个答案的请求,为自一致性目的,按照LRU策略淘汰较不重要的节点以为新请求分配内存。

那Radix Attention带来的性能提升如何呢?我们针对SGLang推出的Radix Attention与VLLM (v0.5.0)进行了对比评测。同时由于Radix Attention可以复用不同请求的上下文,这与我们的日常业务使用比较吻合,取得了不错的评测结果,Radix Attention充分利用不同请求之间的共享前缀,其耗时比VLLM(v0.5.0)快30%,吞吐是VLLM(v0.5.0)的1.5倍。

下面为SGLang给出的Radix Attention性能对比效果,与当前系统相比,SGLang吞吐提升了5倍以上。

SGLang吞吐量.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

如果你也想尝试下Radix Attention,可以直接使用SGLang的推理引擎去启动大模型尝试下。

五、请求分块处理,避免单个请求卡顿 —— Chunked Prefill

在将大模型应用于生产环境时,我们有时会遇到一种奇怪的现象:某个请求的响应时间(RT)异常长,甚至出现卡顿,而系统的平均响应时间却依然正常。这是什么原因导致的呢?又如何解决呢?

大模型的推理过程实际上可以分为两个阶段:prefill 阶段和 decode 阶段。举个例子:假设我们输入了一个包含 1000 个 token 的 prompt,并希望模型生成 100 个 token 的响应。

  1. Prefill 阶段:系统首先对这 1000 个 token 进行并行推理,这一步骤可以充分利用 GPU 的并行计算能力。
  2. Decode 阶段:随后,系统会逐个生成后续的 100 个 token。由于每个新生成的 token 都依赖于之前的输出,因此这一阶段必须按顺序逐个生成。

decode按顺序生成.jpeg

在实际应用中,多个请求往往会同时进行推理,因此可能出现不同请求的阶段交叉运行。例如,如果请求 req3 的 prefill 阶段处理了一个非常长的 prompt,那么它就会占用大量的 GPU 资源;而如果此时 req13的 prefill 阶段与请求 req1的 decode 阶段并行运行,就会导致 req1的 decode 阶段响应速度明显变慢,甚至出现卡顿现象。

卡顿现象.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public) ,较大的prompt请求与decode阶段请求并行调度,算力资源争抢,会显著影响decode阶段。

问题原因明确了,解决方法也十分简单:缩短每次提交给 GPU 并行计算的 prompt 长度。具体来说,我们可以将整个 prompt 按照固定长度(例如 512 个 token)进行分块,每次在 prefill 阶段只处理一块。这样一来,每次并行计算的内容就变得更短,不仅能减轻单个请求对 GPU 资源的占用,还能避免对同时运行的 decode 请求产生影响。这个方法便被称为 chunked prefill

chunked1.jpeg

chunked2.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public) 开启chunked prefill后,prefill与decode并行互不影响。

如下图所示,vllm 通过启用 chunked prefill 功能,显著降低了系统的最大响应时间(max RT)。

max.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public),开启chunked prefill后,在高并发QPS下,平均RT提升了2倍。

在 vllm 的最新版本中,chunked prefill 已默认开启。

六、缩短输出长度,显著提升性能

在前文中,我们提到过大模型的推理过程分为 prefill 阶段和 decode 阶段。Prefill 阶段主要对 prompt 进行注意力计算,并可以并行进行;而 decode 阶段则用于生成新输出的 token,这一阶段必须按顺序逐个生成,无法并行。

因此,如果我们能够在 decode 阶段尽量减少生成的 token 总长度,就能显著提高整体的响应时间(RT)。具体来说,如果每生成一个 token 耗时 5 毫秒,减少 5 个 token 的输出,就能减少 25 毫秒的总响应时间。对于非流式调用的大模型来说,这种改进会带来显著效果。例如,如果大模型只需进行简单的分类或识别任务,那么仅需输出结果即可,其他无关信息完全不需要生成。

那么如何缩短大模型的输出长度呢?

a.限制最大输出长度 首先,可以通过设置系统参数来限制大模型的最大输出长度。如果你通过 OpenAI 接口调用大模型,在输入参数中有一个叫做 max tokens 的设置项。你可以调整该参数,限制大模型的最大输出长度。这样一来,可以避免大模型产生过长的输出,甚至防止无限循环的情况。一个示例代码如下:

一个示例代码.jpeg

b.通过 Prompt 限制输出 另外,可以通过优化 prompt 来引导大模型产生更短的输出。例如,在 prompt 中加上类似“请直接输出结果”或“请尽可能简短输出结果”的提示语,可以有效减少无关内容的输出。

c.微调大模型 如果条件允许,微调大模型也是一种有效的方法。通过微调,可以让大模型在满足需求的前提下尽量缩短输出。微调的过程中,首先可以通过 prompt 调整输出长度,制造大量数据后,再对大模型进行训练。这样一来,不仅输入的内容被优化,输出的长度也能有效缩短,达到更好的效果。

达到更好的效果.jpeg

七、使用多卡推理,推理速度翻倍

在某些场景下,出于模型效果考虑无法对大模型进行量化,但对响应时间(RT)有非常高的要求。这时,尝试 多卡推理 可以带来立竿见影的效果,通常能够将 RT 缩短至原来的 1/3 或 1/2。

以下是我们针对 单卡双卡 推理性能的对比测试结果:

截屏20250218 11.56.09.png

可见推理中单卡变双卡可以显著提升大模型推理速度与QPS。

那为什么多卡可以提升大模型的推理速度呢?主要原因多卡推理的优化是通过 tensor parallelism(张量并行)实现的。

假设你将 tensor parallel 设置为 2,意味着使用两张 GPU 来加速推理。在模型加载时,推理引擎会将大模型的 attention 参数的数量分为两组,分别加载到每张 GPU 上。然后,在推理过程中,两个 GPU 会并行计算注意力,最后再将结果聚合合并。

想象一下,你有一本非常厚的书,你想一次性复印整本书,但是你的复印机一次只能复印几页。这时,你可以把这本书分成几个部分,每个部分分别复印,最后再把所有复印好的部分按顺序拼接起来,这样就完成了整本书的复印。

在张量并行中,我们要处理的大模型就像是那本厚书,而GPU则像是复印机。因为单个GPU无法一次处理整个大模型,我们就需要把模型(在这个例子中是权重张量)分成几个部分,让不同的GPU分别处理(相当于复印书的不同部分)。在处理输入数据时,就像是把书的每一页分别复印,然后再把复印好的各个部分拼接起来,形成完整的输出结果。

形成完整的输出结果.jpeg

如何配置tensor parell并行呢?下面我们分别给出vllm 与sglang两款大模型的配置方式。

1.vllm配置多卡推理

以下命令为vllm如何配置多卡推理的方式。

配置多卡推理.jpeg

图片来自[6] vllm Documentation

2.SGLang配置多卡推理

以为命令为SGLang推理服务如何配置多卡推理。

SGLang多卡推理.jpeg

八、小模型推理+大模型验证 —— 预测解码 (Speculative Decoding)

最近,一种名为预测解码的加速技术备受关注,它能够在特定条件下显著提升大型模型(如72B大模型)的推理速度。

推理速度.jpeg

预测解码工作原理比较简单,假如你想加速一个70b大模型。你可以训练一个同类型的7b小模型,然后开启预测解码。系统同时加载7b小模型与70b大模型,在推理的时候先让7b小模型做输出,比如输出5个token。然后再把这5个token交给70b大模型去做验证,并保留验证正确的前N个token做为输出,以此类推。

由于验证是可以批量进行的,而小模型的推理速度又比较快。这样就可以大大提升70b大模型的推理速度,同时保障70b大模型的效果。

以下为我们针对70b模型所做的实验效果。

截屏20250218 11.56.32.png

可见预测解码在一定的场景下可以提升大模型的推理速度。

此外还有一种更简单的方式,如果你不想训练7b的小模型,而你的输出中大部分都与输入promt相似,比如你只是让大模型帮你修改下文章中错别字。那么你可以直接使用n-gram匹配prompt的方法替代小模型,即直接从输入prompt中选取预测的token,让大模型直接去验证。这样输出速度更快,更简单。

关于预测解码,想深入了解的同学可以参考vllm的这篇文档。[8] How Speculative Decoding Boosts vLLM Performance by up to 2.8x

下面我们介绍下如何在vllm中配置预测解码。

a.使用大模型+小模型的方式

在这里插入图片描述

b.使用n-gram的方式

使用ngram的方式.jpeg

九、高效部署Deepseek-R1模型的方法

前面我们介绍了业界大模型性能优化的很多方法,接下来我们将用SGLang这个推理引擎来部署下最近爆火Deepseek-R1满血版大模型。下面分享下详细的部署步骤。

a.如何下载Deepseek-r1

这次Deepseek发布了一系列模型,如下:

如何下载deep.jpeg

如何下载deep2.jpeg

原始模型包括Deepseek-r1-zero与Deepseek-r1,其中Deepseek-r1是官方推荐的经过多阶段训练的最优模型。但是Deepseek-r1有671B大小的参数,部署起来至少需要2*8H20GPU,比较耗费资源。所以deepseek团队又基于Deepseek-r1蒸馏出了一系列小的模型,其中效果不错比如DeepSeek-R1-Distill-Qwen-32B,单卡H20可以运行启动。

我们这里只介绍满血版Deepseek-r1的部署方法。

b.准备部署环境

我们尝试使用SGLang这个大模型推理引擎部署Deepseek-r1。以下为我们的部署软硬件环境准备。

截屏20250218 13.39.10.png 部署Deepseek-r1

准备好SGLang镜像,deepseek-r1模型,GPU后,可以按照如下命令启动deepseek-r1

node 1:

python -m sglang.launch_server --model-path deepseek-ai/DeepSeek-V3 --tp 16 --dist-init-addr 10.0.0.1:5000 --nnodes 2 --node-rank 0 --trust-remote-code

node 2:

python -m sglang.launch_server --model-path deepseek-ai/DeepSeek-V3 --tp 16 --dist-init-addr 10.0.0.1:5000 --nnodes 2 --node-rank 1 --trust-remote-code

这里使用的是多机多卡部署,部署后使用Openai格式请求发送到node1上即可。

多记多卡部署.jpeg

十、总结

文章依次总结了部署高性能大模型推理服务的技巧与实践,先后介绍了Paged Attention,Radix Attention,chunked prefill,多卡并行等大模型推理加速方法,并给出验证结果与操作方法。文章最后还给出最近爆火的deepseek-r1的高效部署方法,欢迎大家去尝试优化。

后续我们将会持续关注大模型推理性能提升方面的最新技术,验证并及时分享给大家。

参考文献

[1] vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction blog.vllm.ai/2024/09/05/…

[2] sglang 代码 github.com/sgl-project…

[3] Efficient Memory Management for Large Language Model Serving with PagedAttention(arxiv.org/abs/2309.06…)

[4] vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention blog.vllm.ai/2023/06/20/…

[5] Fast and Expressive LLM Inference with RadixAttention and SGLang lmsys.org/blog/2024-0…

[6] vllm Documentation docs.vllm.ai/en/latest/

[7] SGLang Documentation docs.sglang.ai/backend/ser…

[8] How Speculative Decoding Boosts vLLM Performance by up to 2.8x blog.vllm.ai/2024/10/17/…

文 / menglinggong

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物端智能视频封面推荐

作者 得物技术
2025年2月13日 15:00

一、背景

什么要做智能封面?

用户可以在得物购物,也可以在得物社区分享自己的生活。

得物社区中的视频使用双列流,每条内容包含封面、标题等。

  • 对得物社区的创作者而言,选择视频封面是创作链路的重要环节。
  • 对得物社区的消费者而言,封面是影响 CTR(点击率)的关键因素。

封面推荐可以降低创作者的创作成本,提高消费者 CTR。

端智能介绍

端智能(Edge/Client Intelligence)是指在边缘设备(如物联网设备、智能传感器、移动设备等)上进行数据处理和智能决策的能力。与云计算模型相比,端智能将计算、存储和分析功能移到更接近数据源的地方,优势如下:

  • 低延迟 :数据在本地处理,减少了传输到远程服务器的时间,提高响应速度。
  • 节省带宽 :通过在本地处理数据,仅发送必要的信息到中心服务器,减少了网络带宽的消耗。
  • 数据隐私和安全 :数据在本地处理,敏感信息不必传输到云,从而提高了数据隐私和安全性。
  • 可靠性 :在网络连接不稳定或中断的情况下,边缘设备可以继续进行本地处理和决策。

尽管端智能带来了很多优势,但在实际应用中也面临一些挑战:

  • 计算能力的局限性 :边缘设备通常具有有限的计算资源,可能无法处理复杂的人工智能模型。
  • 数据一致性与协同 :多个边缘设备之间的数据一致性和协调处理仍然是一个挑战。
  • 设备管理与部署 :随着设备数量的增加,边缘设备的管理、监控和更新变得更加复杂。

考虑到用户隐私、实时性和服务端压力,我们选择用端智能推荐视频封面,并克服相关的挑战,最终获得收益。

得物端智能

对客户端而言,不需要训练模型,只需要推理。

端智能框架可以简化推理过程,常见的端智能 SDK 如下:

  • 开源 SDK:MNN、TNN、NCNN、Paddle Light、TensorFlow Light 等。
  • 闭源 SDK:ByteNN、Pitaya、KwaiNN、Ykit 等。
  • 系统 SDK:CoreML(iOS)、MLKit(Android)等。

考虑到 iOS、Android 双端的通用性和开发成本,得物基于 MNN [1] 框架,开发得物端智能推理基建。端智能基建核心功能如下:

  • 提供端智能模型管理后台,提供完整链路,管理模型的放量。
  • 端侧提供统一的基建,方便业务进行模型的下载、运行管理,以及熔断和降级的处理,降低使用门槛。
  • 提供相对完善的稳定性和性能监控机制,及时报警和出错时止损。

整体架构

智能封面主要开发流程如下,算法侧产出端智能模型,客户端调用模型推荐视频封面。

整体架构.jpeg

二、内容理解算法

算法调研

端智能封面推荐场景要求无参图片质量评价(NR-IQA)、轻量化,因此基于目前的前沿进展进行调研和摸底,确定相关实现方案。主要的调研内容:

Faster-VQA[2]:轻量化的视频质量评估模型。核心是使用优化版本的Transformer->Swin-Transformer来减少网络计算,加速效率。

算法调研.jpeg

UNIQA[3]:统一的图像质量评估(IQA)框架,旨在同时处理全参考(FR)和无参考(NR)任务。现有的IQA模型通常只能处理FR或NR任务之一,而人类视觉系统(HVS)则可以无缝地在两者之间转换,因此提出开发一个能够像人类一样处理不同类型图像质量评估任务的模型,统一全参/无参两类任务。

统一全参:无参.jpeg

LAR-IQA[4]:轻量级的NR-IQA模型,基于MobileNetV3提出了一种新的无参考图像质量评估模型LAR-IQA。该模型旨在解决现有模型在实际应用中的局限性,特别是对于资源受限的移动设备上的实时图像质量评估任务。核心贡献点有:双分支架构、多色空间训练、Kolmogorov-Arnold Networks (KAN)结构代替MLP。

代替MLP.jpeg

CLIP-IQA[5]:利用(引入)对比语言-图像预训练( CLIP)模型来评估图像的视觉感知,包括图像的质量(look)和抽象感知(feel),无需进行特定任务的训练。核心在于利用CLIP中蕴含的视觉语言先验,通过精心设计的提示策略来提升评估性能。同时提出了一种反义词提示配对策略(如“好照片”和“坏照片”成对使用),以减少语言模糊性并增强模型在视觉感知评估中的表现。此外,为了克服CLIP对固定尺寸输入的要求及其可能引入的额外失真问题,增加了移除位置嵌入的方法,进一步提升了模型与人类感知的一致性。

人类感知的一致性.jpeg

Q-Align[6]:目前NR-IQA领域的SOTA模型,将大模型引入到视觉打分任务中。通过文本定义的级别(例如好、差等)而不是直接的分数(例如3.45、1.77)来指导训练LLMs。标志着在视觉评分领域的一个重要进展,通过创新地使用离散文本定义级别来训练LMMs,不仅提高了评分的准确性和鲁棒性,还为未来的研究开辟了新的方向。

未来研究方向.jpeg

技术卡点

端侧模型存在体积限制,考虑到带宽成本、推理速度等,将模型体积控制在 30M 以内。

目前图片质量打分 sota 模型,整体都是从打分效果出发,不考虑模型性能(size/推理耗时/cpu性能/MAC等),最小的模型体积也超过 120M,不满足端上移植的要求。

现有的 Faster-VQA 和 LAR-IQA 虽然模型打分效果都不错,但是同样因为尺寸超额无法直接使用,也无法直接移植。

算法方案

轻量化网络:本次算法模型主要在手机本地部署,受限于带宽和计算资源限制,对模型尺寸有严格要求。综合考虑后采用业界比较成熟的轻量化模型 MobileNetV3 结构作为基础框架模块,从0到1重新训练轻量化图片打分模型。

数据清洗与数据集构建:考虑到图片-质量分数据的缺失,使用开源图片评价大模型对数据预标注(必要时进行人工介入清洗),通过多模型交叉打分验证和人工标注,最终总体训练数据量级超过10w+。整体流程如图所示:

轻量化图片质量模型.jpeg轻量化图片质量评价模型

loss优化:loss设计上采用回归任务loss+主观感知偏差衡量loss,超参数控制多loss融合。

模型移植

MNN 模型支持 Tensorflow、Caffe、ONNX、Torchscripts 等主流模型文件格式,支持CNN / RNN / GAN / Transformer 等主流网络结构。

MobileNetV3 使用 PyTorch 框架创建、训练、持久化模型,需要先转换成为ONNX格式,然后再转换成 MNN 模型。通过 FP16/Int8 压缩与量化,模型最终大小为 24M,客户端可以接受。

在客户端进行模型推理调用时,需关注输入图片的尺寸、预处理方式以及输出数据格式等方面。这些参数与模型相互绑定,且在后续的迭代过程中应保持同步。

三、客户端部署

整体流程

整体流程如图所示,用户进入封面选择页,首先对视频抽帧,然后调用端智能推理。端智能输出一个评分,获取评分最高的图片作为推荐的封面。为了提高封面识别速度,采用批量异步计算。

时序图.jpeg时序图

整体架构如图所示,双端共用端智能基建,各自实现具体的业务逻辑。ClientIntelligence 作为端智能基建,底层封装了 MNN、OpenCV 等,实现了模型管理(下载、缓存等)、推理、监控等功能。

架构图.jpeg架构图

推理一致性

推理一致性(Inference Consistency)是指在不同时间、不同环境、或不同条件下,模型输出的结果保持稳定、可靠、一致的能力。这是一个非常重要的概念,尤其是在部署机器学习模型时,确保模型的推理一致性对于维护模型的质量和可信度至关重要。

推理不一致的来源:

  • 在不同硬件平台上运行模型(例如不同的 CPU、GPU、TPU 等)可能会导致数值精度上的细微差异,进而影响推理结果。
  • 不同的深度学习框架(例如 TensorFlow、PyTorch )可能会在推理过程中产生不一致的结果,尤其是涉及到数值计算时。
  • 输入数据预处理方式不一致导致推理结果不同,可以通过数据标准化、归一化等减少对推理结果的影响。

具体到智能封面的场景,主要面临下面几种一致性:

  • PyTorch、ONNX、MNN 推理一致性:不一致主要来源框架本身,端上模型为了提高推理速度,会对模型进行量化,比如将浮动精度的模型(如 Float32)转换为低精度模型(如 INT8)。框架造成的推理结果不一致无法避免。
  • iOS、Android 双端推理一致性:输入数据预处理方式是影响推理一致性的关键因素,在智能封面场景,图片数据的预处理方式需要保持一致。双端由于硬件的差异,推理结果也不同。此外,使用 CPU、GPU 推理结果也会存在细微的差别。智能封面会对图片评分,选择评分最高的图片,因此硬件造成的差别在本场景下可以接受。

耗时优化

用户在封面选择页面停留时间有限,因此要尽可能地减小封面推荐耗时。

首先要定位到耗时操作,然后有针对性地优化。

在本场景中,耗时操作包含抽帧、推理,具体优化如下:

  • 并行计算:多线程同时抽帧、推理,需要注意的是,并行数需要考虑 CPU 和内存的占用。
  • GPU 推理:端智能同时支持 CPU 和 GPU 推理,通过 GPU 推理可以显著减小耗时。
  • 不同性能的手机处理速度差别较大,低性能手机会适当减小抽帧数量,以提高运行速度。

优化后,可以在秒级完成抽帧、封面推荐全过程。

四、收益与效果评估

线上效果对比

线上智能封面、非智能封面抽样结果如下,使用智能封面功能,整体画风更优,更清晰。

智能封面1.png智能封面.jpeg智能封面3.jpeg智能封面

非智能封面1.jpeg转存失败,建议直接上传图片文件非智能封面.jpeg转存失败,建议直接上传图片文件非智能封面3.jpeg转存失败,建议直接上传图片文件

非智能封面1.jpeg非智能封面.jpeg非智能封面3.jpeg非智能封面

竞品效果对比

得物智能封面与主流短视频平台对比结果如下,整体选帧效果和主流短视频平台可比,部分场景效果较优。

得物.jpeg得物2.jpeg得物3.jpeg得物4.jpeg得物

短视频平台A.jpeg短视频平台A2.jpeg短视频平台a3.jpeg短视频平台a4.jpeg短视频平台A

短视频平台B.jpeg短视频平台n2.jpeg短视频平台b3.jpeg短视频平台b4.jpeg短视频平台B

人工GSB评测

在智能封面功能上线后,我们随机抽取了线上真实的视频数据,并通过人工GSB(Good Same Bad)评估方法,对智能选帧所得的图片与默认首帧图片进行了图像质量的对比分析。

多组数据、多人次测评整体评估结果为:Good(好)361票,Same(一样)182票,Bad(差)95票。

相较于默认首帧图片,智能选帧的GSB评分提升了 41.7%,表明选帧功能在图像质量上有了显著的改进。

线上实验收益

在发布侧,采用智能封面点击率、选择率作为衡量指标,获得了显著的收益,其中智能封面点击率 5.5%,非首帧封面选择率相对提升 +25.61%。

在内容推荐侧,采用推荐双列流视频点击率作为衡量指标,pvctr 和 uvctr 都有明显提升,与对照组相比,pvctr+13.12%、uvctr+18.05%。实验结果也表明在推荐双列场景下,更好得封面内容会带来更好的消费。

五、总结

本文通过端智能推荐视频封面,帮助创作者降低发文成本,提高发文质量。

我们也希望将端智能用在更多的场景,提高用户体验。

六、参考资料

  1. github.com/alibaba/MNN
  2. Wu, Haoning, et al. "Fast-vqa: Efficient end-to-end video quality assessment with fragment sampling." European conference on computer vision. Cham: Springer Nature Switzerland, 2022.
  3. Zhou, Hantao, et al. "UniQA: Unified Vision-Language Pre-training for Image Quality and Aesthetic Assessment." arXiv preprint arXiv:2406.01069 (2024).
  4. Avanaki, Nasim Jamshidi, et al. "LAR-IQA: A Lightweight, Accurate, and Robust No-Reference Image Quality Assessment Model." arXiv preprint arXiv:2408.17057 (2024).
  5. Wang, Jianyi, Kelvin CK Chan, and Chen Change Loy. "Exploring clip for assessing the look and feel of images." Proceedings of the AAAI Conference on Artificial Intelligence. Vol. 37. No. 2. 2023.
  6. Wu, Haoning, et al. "Q-align: Teaching lmms for visual scoring via discrete text-defined levels." arXiv preprint arXiv:2312.17090 (2023).

文 / Devin&linghu

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

从0到1构建 Kubernetes中间件运维平台:标准化、可视化与全栈运维的最佳实践|得物技术

作者 得物技术
2025年2月11日 14:28

一、项目背景

传统运维的痛点与挑战

在传统的中间件运维过程中,存在以下几个突出问题:

  • 管理分散:不同中间件( Kafka和Elasticsearch)都有独立的管理台,运维逻辑分散,难以形成统一规范。
  • 成本高昂:运维操作与各自的管理台强绑定,SRE 需要学习不同工具,操作复杂,维护成本高。
  • 黑屏操作依赖:很多关键运维操作需要依赖 kubectl apply 等黑屏命令,操作门槛高,风险大。

Kubernetes与Operator的优势

Kubernetes(K8s)和 Operator 提供了一套通用的运维管理机制,将中间件运维操作抽象成 Kubernetes CR(Custom Resource)对象,由 Operator 负责具体的运维执行。这种模式具备以下优势:

标准化:运维操作可以以 CR 为中心进行统一管理。

自动化:减少了人工干预,降低了人为失误的风险。

可视化:可以通过 UI 平台降低运维复杂度。

平台建设的核心目标

基于上述背景,我们决定建设一个统一的中间件运维平台,目标包括:

  • 标准化:统一规范中间件的运维操作,沉淀最佳实践。
  • 自动化:减少对黑屏操作的依赖,提升运维效率。
  • 可视化:通过 UI 界面,让运维操作更加直观、简单。

二、建设历程

平台架构概览

在展开详细的建设内容前,我们先看看整体的架构设计。本架构图展示了白屏化运维平台的核心组成和各层之间的交互关系,帮助我们更直观地理解平台的整体运作逻辑和功能分布。

平台架构概览.jpeg

运维平台层的核心作用

运维平台层是整个白屏化运维平台的中枢大脑,承上启下,连接用户层与 Kubernetes 集群层,同时对接外部系统,确保运维操作的标准化、自动化和可审计。它的架构图如下:

运维平台层的核心作用.jpeg

运维平台的具体作用包括:

多云管理服务

  • 统一托管来自不同云厂商的 Kubernetes 集群,确保多云环境下的资源可视化和统一调度。
  • 为大规模中间件部署提供基础支撑,保障平台跨云高可用性。

中间件运维服务

  • 负责对 Kafka 和 Elasticsearch 进行统一的部署、运维和管理,规范操作流程,降低运维复杂度。
  • 提供可视化操作界面,降低 SRE 的操作门槛。

K8s 通用资源管理服务

  • 统一管理 Kubernetes 中常见的资源,包括 Node(打标、污点管理)、PV(云盘释放)、PVC(生命周期管理)、SVC(服务暴露与管理)、Pod(日志查看、终端登录、CPU BURST)。
  • 减少对黑屏命令的依赖,降低运维风险,提高操作效率。

YAML 管理服务

  • 版本管理:提供 YAML 文件的版本控制功能,支持版本新增、修改、回滚 和 差异对比(Diff)。
  • 变更可审计:所有 YAML 配置的变更都会被详细记录,确保每次配置变更都可追溯。
  • 配置可视化:提供可视化 YAML 编辑界面,降低操作错误率。

操作审计服务

  • 平台操作审计:对平台内所有运维操作进行详细记录,确保操作可追溯。
  • 对接 DCheck:将审计数据传送至 DCheck 和NOC事件中心,进行合规性检查和安全监控,保障操作安全性和可控性。

运维平台层不仅是各类运维操作的执行中枢,更是数据流通的核心枢纽,负责将用户的运维请求转化为 Kubernetes 资源变更操作,同时记录和审计所有操作,确保系统的安全性和可追溯性。

接下来,我们将深入剖析这些核心服务,看看它们是如何在实际场景中解决痛点、提升效率的。

多云管理:跨云资源托管,告别kubeconfig切换地狱

故事背景

“Kubeconfig 切换地狱,谁用谁知道。”

小卡作为一名资深 SRE,每天都要在多个 Kubernetes 集群之间穿梭,管理不同环境下的资源。这些集群来自不同的云厂商,运行在不同的 Kubernetes 版本上,甚至还有不同的认证和网络策略。

  • 传统方式:每个 Kubernetes 集群都需要一个对应的 kubeconfig 文件,存储在本地的 ~/.kube 目录中。
  • 上下文切换:每次操作前,需要执行kubectl --kubeconfig=/path/to/kubeconfig,或者使用 kubectl config use-context 切换上下文。
  • 风险高:当集群数量增多时,小卡根本记不清当前所操作的集群是 kubeconfig1 还是 kubeconfig2。有时候,为了省事,直接用 cp kubeconfig1 config,再去执行 kubectl 命令,完全忘记当前上下文对应哪个集群。
  • 灾难场景:一不小心,将生产集群当成测试集群,直接执行了 kubectl delete pods --all,后果不堪设想。

痛点分析

多 kubeconfig 文件管理混乱

  • 每个集群一个 kubeconfig,本地目录下文件堆积如山,管理成本高。
  • 使用 kubectl config use-context 切换上下文,容易混淆当前所在的集群。

操作风险高

  • 一旦操作上下文错误,轻则资源误删,重则导致生产事故。
  • 缺乏有效的权限隔离和审计,无法追踪到具体的操作人和上下文。

跨云兼容性问题

  • 每个云厂商的 Kubernetes 集群可能存在不同的 API 版本和兼容性问题。
  • 手动管理多个 Kubernetes 版本的集群,风险和维护成本极高。

访问性能瓶颈

  • Kubernetes API 请求频繁直接访问集群,容易导致延迟和性能瓶颈。
  • 每次查询都需要访问 Kubernetes API,缺乏高效的缓存机制。

解决方案

根据运维同学的痛点,我们计划构建一个多云 Kubernetes 集群管理平台,实现跨云环境资源的统一托管、可视化管理与快速访问,避免 kubeconfig 切换带来的混乱和风险。效果图如下:

效果图如下.jpeg

目标和行动拆解:

目标和行动拆解.jpeg

截至目前,平台已跨云托管了30+套Kubernetes集群。

中间件运维:Kafka 扩容,从黑屏脚本到白屏可视化

故事背景

“Kafka 扩容——一个让人捏把汗的运维操作”

凌晨三点,运维小卡的手机突然爆炸式震动起来,屏幕上跳出无数条报警消息:“Kafka 集群负载过高,CPU 使用率接近 100%!”

小卡揉了揉惺忪的睡眼,坐在电脑前打开黑屏终端,迅速敲下一连串熟练的命令:

kubectl --kubeconfig=k8s-xxx-prd get kafka

他屏住呼吸,盯着屏幕上的滚动字符,一行一行地检查 Kafka 集群状态,判断哪些节点资源吃紧,哪些副本需要扩容。然而,每次操作都让他倍感焦虑——“这可是生产环境啊,万一一行命令敲错,就要上新闻头条了!”

  • 第一步:修改 YAML,spec.replicas +1。
  • 第二步:轮询所有 Pod 状态,检查是否都变为 Running。
  • 第三步:调用 Cruise-Control API,触发数据迁移。
  • 第四步:轮询数据迁移状态,直到所有分区完成重新分配。

四步流程,看似简单,但每一步都需要小卡屏息凝神,稍有差错,就可能导致数据丢失,甚至集群崩溃。

“这种凌晨抢救场面,为什么不能更简单一点?” 小卡心里忍不住嘀咕。

传统 Kafka 扩容黑屏脚本

在中间件运维场景中,Kafka 集群扩容是一项典型的复杂运维任务。这不仅仅是一个简单的「增加节点」操作,还涉及到集群状态监控、资源调度、数据迁移等多个环节。

传统方式下,SRE 需要通过黑屏脚本完成扩容任务,整个过程不仅繁琐,还充满了不确定性。

以下是一个 Kafka 集群扩容的典型黑屏脚本示例:

#!/bin/bash

# 设置 kubeconfig
export KUBECONFIG=/path/to/kubeconfig

# 1. 检查 Kafka 集群状态
echo "Step 1: 查询 Kafka 集群状态"
kubectl get kafka -n kafka-namespace

# 2. 扩容 Kafka 集群副本数
echo "Step 2: 扩容 Kafka 集群"
kubectl patch kafka my-cluster -n kafka-namespace --type='merge' -p '{"spec":{"kafka":{"replicas":5}}}'

# 3. 轮询 Kafka Pod 状态
echo "Step 3: 检查所有 Kafka Pod 是否 Running"
while true; do
    READY_PODS=$(kubectl get pods -n kafka-namespace -l app.kubernetes.io/name=kafka -o jsonpath='{.items[*].status.phase}' | grep -o "Running" | wc -l)
    TOTAL_PODS=5
    echo "Running Pods: $READY_PODS / $TOTAL_PODS"
    if [ "$READY_PODS" -eq "$TOTAL_PODS" ]; then
        echo "所有 Kafka Pod 已经就绪"
        break
    fi
    sleep 5
done

# 4. 触发数据迁移
echo "Step 4: 开始数据迁移"
curl -X POST "http://cruise-control.kafka-namespace.svc.cluster.local:9090/kafkacruisecontrol/rebalance" -d "dryrun=false"

# 5. 轮询数据迁移状态
echo "Step 5: 等待数据迁移完成"
while true; do
    STATUS=$(curl -s "http://cruise-control.kafka-namespace.svc.cluster.local:9090/kafkacruisecontrol/user_tasks" | grep "COMPLETED")
    if [ -n "$STATUS" ]; then
        echo "数据迁移完成"
        break
    fi
    sleep 10
done

echo "Kafka 集群扩容完成!"

可以看到,传统脚本有以下几个痛点:

多步骤手动介入

  • 每个步骤都需要依赖脚本执行。
  • 出错后排查困难,且很难进行流程回滚。

缺乏可视化

  • 集群状态、Pod 变化、数据迁移进度全靠日志和命令行输出。
  • 无法直观了解整体扩容进度。

风险高

  • 在生产环境中执行此类脚本,如果操作不当,可能导致服务中断或数据丢失。
  • 错误信息分散在多个命令输出中,难以快速定位问题。

不可审计

  • 操作记录分散,无法进行完整的审计与回溯。

白屏化平台的 Kafka 扩容

目标:将 Kafka 扩容的整个过程标准化、可视化、自动化,降低操作风险,提升执行效率。

从此,凌晨三点的 Kafka 扩容,变成了这样的场景:

  1. 打开平台:登录运维平台,进入 Kafka 集群运维界面。
  2. 点击扩容:输入副本数,点击 “一键扩容”。
  3. 实时监控:平台自动执行扩容,Pod 状态、资源分配、数据迁移一目了然。
  4. 完成审计:所有操作都记录在日志中,可随时回溯。

“10 分钟,Kafka 扩容完成,小卡又可以安心地回床上睡觉了。”,如下图:

小卡安心睡觉如下图.jpeg

目前,Kafka和ES在运维中都面临相似的痛点。为解决这些问题,大部分通用的中间件运维操作已被统一收敛至平台。

截至目前,平台已累计托管300+个中间件集群(Kafka: 120+,ES: 180+),完成100+个中间件的运维操作(Kafka: 60+,ES: 40+),累计执行430+次白屏化运维操作(Kafka: 210+次,ES: 220+次),覆盖扩缩容、升降配、数据迁移、重启、重建等常见运维场景,极大提升了运维效率与操作稳定性。

Node管理:从黑屏脚本到白屏化平台

故事背景

“凌晨三点,ES集群扩容需求紧急上线。”

运维小哥小吴接到告警电话,ES集群节点资源已接近饱和,业务性能明显下降。扩容节点,是当务之急。

然而,扩容并不是简单地加几台机器那么轻松。Node打标是扩容的关键前置步骤,如果节点没有正确打标,Pod将无法被调度到对应的资源池,扩容将直接失败。

在过去,Node 资源调度和打标是一项高风险、高强度的任务。需要依赖脚本在黑屏终端中逐台节点检查 CPU、内存、磁盘类型、可用区 等指标,然后筛选出符合条件的节点进行打标和调度。

如果某个细节疏忽——比如忘记检查污点、磁盘挂载数量超标,轻则导致扩容失败,重则影响整个业务链路。

传统黑屏脚本分析

在传统的 Node 筛选脚本中,我们依赖 kubectl 命令逐个检查节点的各类资源指标,并进行节点筛选。以下是小吴编写的一个典型的黑屏脚本示例:

public class ESNodeSelectorTest {
    public static void main(String[] args) {
        //cpu核心数
        int needCpu = 9;
        //内存容量G
        int needMemory = 33;
        //磁盘类型
        DiskType needDiskType = DiskType.efficiency;
        //可用区
        Zone zone = Zone.cn_shanghai_m;
        //标签
        String label = null;
        //集群名称
        String clusterName = null;
        //开始自动筛选
        selectNode(label,zone,needCpu,needMemory,needDiskType,clusterName);
    }

    public static void selectNode(String label,Zone zone,int needCpu,int needMemory,DiskType diskType,String clusterName){
        String getNodes = "kubectl get node -l ";
        String segment;
        if(label!=null){
            getNodes += label;
        }else {
            if (zone != null) {
                segment = "topology.kubernetes.io/zone=" + zone.name().replaceAll("_", "-");
                getNodes += segment;
            }
        }

        String nodes = executeCommand("/bin/bash", "-c", getNodes);
        String[] nodeList = nodes.split("\n");
        String describeNode;
        int lineCount = 0;
        for (int i = 1; i < nodeList.length; i++) {
            String node = nodeList[i].split(" ",2)[0];
            if(lineCount>4){
                lineCount = 0;
                System.out.println();
            }
            lineCount++;

            System.out.print(node);
            if(isMaster(node)){
                continue;
            }
            describeNode = "kubectl describe node " + node +" | grep 'Taints\\|cpu    \\|memory   ";
            //...太多了...省略...
            }
        }
    }

    public static String executeCommand(String... command){
        try {
            Process process = Runtime.getRuntime().exec(command);
            //...
        }catch (Exception e){
            throw new RuntimeException("kubectl apply exception:",e);
        }
    }
}

可以看到,传统方式有以下几个痛点:

操作复杂

  • 需要编写和维护复杂的脚本。
  • 每次执行都需要逐节点检查,耗时长,效率低。

高风险

  • 稍有疏忽(如忘记检查污点、CPU 余量计算错误)可能导致资源分配失败。
  • 整个过程缺乏可视化,排查问题难度大。

响应慢

  • 在业务高峰期,这种人工节点筛选方式无法快速响应突发需求。

白屏化平台的 Node 管理

目标:

将 Node 资源管理的整个流程标准化、自动化和可视化

设计思路: 1.指标可视化

  • 在白屏界面上展示 Node 的 CPU 分配/使用率、内存分配/使用率、磁盘类型、标签、污点等关键指标。

2.多维度筛选

  • 支持通过标签、污点、CPU/内存余量、磁盘类型、可用区等维度快速筛选节点。

3.批量打标与调度

  • 支持批量打标和污点管理,减少人工操作的复杂性。

4.资源状态实时更新

  • 实时展示节点资源的可用性,避免资源分配冲突。

在大促扩容场景中,平台已累计完成了 280+ 台 Node 的打标与调度。原本1小时+的 Node 筛选和打标操作,现在只需要 3 分钟

只需要3分钟.jpeg

PV云盘管理:打破孤盘与繁琐操作的枷锁

故事背景

“集群删了,PV 留下了,云盘成了‘孤儿’。”

一次运维例会中,小宋提到这样一个现象:当中间件集群被释放后,原先挂载的 PV 和云厂商的云盘并不会自动删除。更让人头疼的是,云厂商云盘的标签只有两个关键字段,这里以某云为例:

  • k8s.yun.com : true
  • createdby: alibabacloud-csi-plugin

当集群被销毁,这些标签几乎无法追溯到云盘的真正使用方。出于风险考虑,这些云盘被闲置着,无人敢于释放,久而久之,闲置云盘成堆,云成本居高不下。如下图:

云成本居高不下.jpeg

痛点分析

云盘归属无法追溯

  • 当中间件集群被销毁后,PV 与云盘的映射关系断开。
  • 云盘仅有两个标签,缺乏更具体的归属信息。
  • 运维人员难以确定云盘的真实使用方,释放云盘面临很大的风险。

手动释放繁琐

  • 集群节点释放后,通常 PVC 资源会被删除,但 PV 依然保留。
  • 运维团队需要借助机器人或定期巡检手动查找闲置 PV。
  • 每次释放云盘都需要手动在云厂商管理台完成,流程繁琐,耗时长。

流程缺乏闭环

  • 云盘释放 → 成本中心审批 → 云厂商控制台删除 PV,整个流程需要跨平台、跨系统完成,且容易出错。

解决方案

目标:实现 PV 云盘资源的可视化、自动化管理,打通从 Kubernetes 到云厂商的全链路操作流程。如下图所示:

解决方案目标.jpeg

目标和行动拆解:

目标和行动拆解2.jpeg

截止目前,平台已累计释放了 675+ 块闲置云盘,每月节省云成本约 15+万元。操作时间从15 分钟+缩短到 1 分钟。且所有操作均可审计与回溯,保障了运维安全性。

CPU Burst 管理:关键时刻的“应急电源”

故事背景

“高峰期 CPU 100%,服务卡成 PPT?”

一次业务高峰来临,ES 集群的 CPU 使用率迅速飙升到 100%,多个关键服务开始响应迟缓,甚至部分 Pod 被强制驱逐。运维同学小宋看着监控大屏上的红色告警,不禁捏了一把汗。

在传统运维方式下,CPU 资源一旦达到极限,唯一的解决方案就是扩容,但扩容并非瞬时可完成的操作,往往需要排查资源、调度 Pod、重启服务,甚至等待新节点的资源分配。而这些步骤,在高并发、高压力场景下,每一秒的延迟都是用户体验的巨大损失。

痛点分析

资源调度滞后

  • CPU 资源短缺时,传统调度往往依赖于扩容,响应时间较长。
  • 在高并发场景下,调度效率决定了系统的生死。

临时应急难

  • 当 CPU 达到瓶颈,传统 Kubernetes 无法临时突破 CPU 限制,服务只能停滞或被驱逐。

解决方案

目标:在高压场景下,通过 CPU Burst 管理功能,允许关键 Pod 在短时间内突破 CPU Limit 限制,保障服务稳定性和业务连续性。如下所示:

![CPU burst.jpeg](h5cdn.dewu.com/efe/ctoo-op… burst_1739254751374.jpeg)

截止目前, CPU Burst 已在 10+ 套 Kubernetes 集群30+ 套 ES 集群 中启用。在高并发场景下,有效解决了 CPU受限和CPU使用率瓶颈问题,提升了服务稳定性。

YAML 管理服务:让配置变更安全、可控、可回滚

故事背景

“一行 YAML,毁灭一个集群。”

在 Kubernetes 的运维场景中,YAML 配置文件是所有资源操作的核心。无论是 Pod 调度、Service 暴露,还是 ConfigMap 更新,所有的操作都离不开 YAML 文件。

但 YAML 配置管理往往充满风险:

  • 一行配置错误:可能导致整个服务不可用。
  • 版本混乱:配置文件缺乏版本管理,一旦出错,回滚难度极大。
  • 缺乏审计:每次变更是谁做的,变更了哪些内容,几乎没有清晰的记录。
  • 人工操作:黑屏模式下,直接通过 kubectl apply 修改 YAML,出错率极高。

“运维人员常说:‘YAML 是 Kubernetes 的灵魂,但也是运维事故的导火索。’”

痛点分析

版本管理缺失

  • 没有完整的 YAML 文件版本历史记录。
  • 配置错误难以回滚,出错后很难快速恢复。

变更审计不透明

  • 谁修改了配置?
  • 修改了哪些内容?
  • 修改的原因是什么?
  • 缺乏详细的审计日志,责任难以追溯。

手工变更风险高

  • 直接使用 kubectl apply 进行 YAML 修改,存在人为输入错误的风险。
  • 缺少可视化的配置变更比对工具,难以进行精确的差异分析。

变更回滚复杂

  • 配置变更失败后,回滚通常需要手动恢复之前的版本。
  • 缺乏自动化的回滚机制,出错后容易引发连锁反应。

解决方案

目标:通过YAML 管理服务,将 Kubernetes YAML 配置的版本管理、变更审计、回滚机制 和 可视化管理 集中整合到平台中,降低人为操作风险,提升变更效率和安全性。如下所示:

YAML管理.jpeg

三、项目收益总结

经过三期建设,白屏化运维平台已从概念验证逐步发展成为覆盖全场景运维的高效工具,取得了显著的成果和收益,主要体现在以下几个方面:

运维标准化与规范化

  • 统一运维流程:通过平台白屏化界面,规范了 Kafka、ES、Node、PV、PVC、SVC、Pod 等核心运维场景,减少了操作流程的随意性。
  • 最佳实践沉淀:在每个运维场景中,平台积累并固化了标准化的运维流程与策略,降低了运维操作的失误率。
  • 减少人为依赖:降低了对资深运维人员的依赖,新手 SRE 也可以在平台指引下完成复杂运维操作。

运维效率显著提升

  • 操作效率: Node 打标与污点管理从 1小时+ 缩短至 3分钟。 PV 云盘释放从 15分钟+ 缩短至 1分钟

  • 高效应急响应:在 CPU Burst 管理的支持下,有效缓解高并发场景下的 CPU受限和CPU使用率瓶颈问题,保障业务连续性。

  • 批量化管理:支持 Node、PV、PVC、SVC、Pod等资源的批量管理,减少重复性操作,提高资源调度效率。

成本优化与资源利用最大化

  • 资源回收:累计释放 675+ 块闲置云盘,月均节省云成本 15+万元。
  • 资源高效分配:通过 Node 筛选与 PV优化管理,有效提升了资源利用率,避免资源浪费。
  • 跨云资源托管:统一管理来自不同云厂商的 Kubernetes 集群,避免因平台差异导致的资源闲置。

安全性与可审计性提升

  • 操作可追溯:所有运维操作均纳入审计日志,累计记录超过 1020+ 条审计日志。
  • 合规性保障:平台操作对接 DCheck 系统,实现运维审计和合规性检查。

业务稳定性与可扩展性

  • 支撑业务高峰:在七夕大促等业务高压场景下,平台有效支撑了中间件的快速扩容和稳定运行。
  • 平台可扩展性:架构设计支持快速新增运维场景,满足未来更多资源类型和场景的需求。

四、经验总结与反思

在三期的建设和落地过程中,我们积累了宝贵的经验,也发现了一些可以优化和提升的空间。以下是关键经验和反思:

以标准化为核心

  • 运维流程标准化:通CR管理、Node/PV/PVC/SVC 资源管理,实现了运维操作的标准化和可重复性。
  • 减少个性化操作:避免了运维过程中因个人操作习惯差异带来的不一致性。

技术与流程的深度结合

  • 白屏化降低门槛:将复杂的 Kubernetes 运维操作封装到 UI 界面,减少了对黑屏命令的依赖。
  • 批量运维能力:批量操作大幅减少重复性工作,有效提升整体运维效率。

强化审计与合规

  • 全链路审计:所有操作均有详细的审计记录,确保运维行为可追溯、可还原。
  • 合规检查:对接 DCheck 系统,实时进行合规检查,避免风险隐患。

面向未来的架构设计

  • 弹性扩展:平台架构具备较强的可扩展性,支持快速集成新的运维场景。
  • 跨云平台适配:平台已实现对多云 Kubernetes 集群的统一管理,降低了跨云资源运维的复杂度。

遇到的挑战

  • 与KubeOne平台无法融合:KubeOne平台主要面向无状态服务的运维,而白屏化平台主要面向有状态服务的运维,二者无法融合。
  • 多场景运维复杂度:不同中间件和 Kubernetes 资源类型的运维逻辑存在差异,初期难以统一抽象。
  • 持续优化测试流程:部分场景的测试覆盖率还有待提升,未来需持续加强单元测试和集成测试。

五、未来展望

白屏化运维平台的未来,将持续以“标准化、可视化、智能化”为核心,不断拓展运维场景,降低运维门槛,提升运维效率与安全性。

  • 扩展更多 Kubernetes 运维场景:实现更多 Kubernetes 资源(如Deployment、StatefulSet、 Ingress、ConfigMap、Secret)和自定义资源(DMQ、Pulsar、ZK)的白屏化运维支持。
  • 引入智能化运维:基于案例匹配、数据挖掘或总结最佳实践,实现故障自愈、资源动态调度和智能告警。
  • 持续优化用户体验:通过用户反馈持续改进平台功能,优化运维操作流程,降低运维心智负担。
  • 加强多云资源管理能力:支持更多云厂商 Kubernetes 集群的接入,提升平台的多云资源管理能力。

文 / 初澜

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌