FreshRSS

🔒
❌ 关于 FreshRSS
发现新文章,点击刷新页面。
昨天以前美团技术团队

美团跨端一体化富文本管理技术实践

2021年11月25日 00:00

一、引言

在互联网圈,开发和产品经理之间相爱相杀的故事,相信大家都有所耳闻。归根结底,往往都是从简单的改需求开始,然后你来我往、互不相让,接着吵架斗嘴,最后导致矛盾不断升级,甚至带来比较严重的后果。

图1

在这种背景下,如果把一些功能相对简单的、需求变动比较频繁的页面,直接交给产品或者运营自己去通过平台实现,是不是就可以从一定程度上减少产品和开发人员之间的矛盾呢?

二、背景

当然上述的情况,美团也不例外。近些年,美团到家事业群(包括美团外卖、美团配送、闪购、医药、团好货等)的各个业务稳步发展,业务前端对接的运营团队有近几十个,每个运营团队又有不同的运营规则,这些规则还存在一些细微的样式差别,同时规则内容还会随着运营季节、节日、地理位置等进行变化和更新。这些需求具体来说有以下几个特点:

  1. 需求量大:业务稳步发展,业务需求不断叠加,甚至部分业务呈指数级增长,且业务方向涉及到一些业务规则、消息通知、协议文档、规则介绍等需求。
  2. 变更频繁:面对市场监管和法务的要求,以及新业务调整等因素的影响,会涉及到需求的频繁变更,像一些业务FAQ、产品介绍、协议文档、业务规则、系统更新日志等页面,需要做到快速响应和及时上线。
  3. 复杂度低:这些页面没有复杂的交互逻辑,如果能把这些简单的页面交给运营/产品去实现,开发人员就能有更多的时间去进行复杂功能的研发。
  4. 时效性高:临时性业务需求较多,且生命周期较短,具有定期下线和周期性上线等特点。

基于以上特点,为了提高研发效率,美团医药技术部开始构建了一个跨端一体化富文本管理平台,希望提供解决这一大类问题的产研方案。不过,部门最初的目标是开发一套提效工具,解决大量诸如帮助文档、协议页、消息通知、规则说明等静态页面的生产与发布问题,让产品和运营同学能够以所见即所得的方式自主完成静态页面制作与发布,进而缩短沟通成本和研发成本。

但是,随着越来越多业务部门开始咨询并使用这个平台,我们后续不断完善并扩充了很多的功能。经过多次版本的设计和迭代开发后,将该平台命名为Page-佩奇,并且注册成为美团内部的公共服务,开始为美团内部更多同学提供更好的使用体验。

本文将系统地介绍Page-佩奇平台的定位、设计思路、实现原理及取得成效。我们也希望这些实战经验与总结,能给更多同学带来一些启发和思考。

三、跨端一体化富文本管理解决方案

3.1 平台定位

我们希望将Page-佩奇打造成一款为产品、运营、开发等用户提供快速一站式发布网页的产研工作台,这是对该平台的一个定位。

  • 对产品运营而言,他们能够可视化地去创建或修改一些活动说明、协议类、消息类的文章,无需开发排期,省去向开发二次传递消息等繁琐的流程,也无需等待漫长的发布时间,从而达到灵活快速地进行可视化页面的发布与管理。
  • 对开发同学而言,他们能够在线编写代码,并实现秒级的发布上线,并且支持ES 6、JavaScript 、Less、CSS语法,我们还提供了基础的工具、图表库等,能够生成丰富多样的页面。帮助开发同学快速实现数据图表展示,设计特定样式,完成各种交互逻辑等需求。
  • 对项目管理方而言,他们能够清晰地看到整个需求流转状态和开发日志信息,为运营管理提供强大的“抓手”。

一般来讲,传统开发流程是这样的:首先产品提出需求,然后召集研发评审,最后研发同学开发并且部署上线;当需求上线之后,如果有问题需要反馈,产品再找研发同学进行沟通并修复,这种开发流程也是目前互联网公司比较常见的开发流程。

图2 传统开发流程图

而美团Page-佩奇平台的开发流程是:首先产品同学提出需求,然后自己在Page平台进行编辑和发布上线,当需求上线之后有问题需要反馈,直接就能触达到产品同学,他们通常可自行进行修复。如果需求需要定制化,或者需要做一些复杂的逻辑处理,那么再让研发人员配合在平台上进行开发并发布上线。

图3 Page-佩奇平台开发流程图

简单来说,对那些功能相对简单、需求变动比较频繁的页面,如果用传统的开发流程将会增加产研沟通和研发排期成本,因此传统方案主要适用于功能复杂型的需求。而Page-佩奇平台开发流程,并不适合功能复杂型的需求,特别适用于功能相对简单、需求变动比较频繁的页面需求。

综上所述,可以看出这两种开发流程其实起到了一个互补的作用,如果一起使用,既可以减少工作量,又可以达到降本提效的目的。

3.2 设计思路

我们最初设计Page-佩奇平台的初心其实很简单,为了给产品和运营提供一个通过富文本编辑器快速制作并发布网页的工具。但是,在使用的过程中,很多缺陷也就慢慢地开始暴露,大致有下面这些问题: 1. 简单的富文本编辑器满足不了想要的页面效果,怎么办? 2. 如果能导入想要的模板,是否会更友好? 3. 怎么查看这个页面的访问数据?如何能监控这个页面的性能问题? 4. 发布的页面是否有存在安全风险?

于是,我们针对这些问题进行了一些思考和调研:

  • 当富文本编辑器满足不了想要实现的效果的时候,可以引入了WebIDE编辑器,可以让研发同学再二次编辑进行实现。
  • 一个系统想要让用户用得高效便捷,那么就要完善它的周边生态。就需要配备完善的模板素材和物料供用户灵活选择。
  • 如果用户想要了解页面的运行情况,那么页面运行的性能数据、访问的数据也是必不可少的。
  • 如果发布的内容存在不当言论,就会造成不可控的法律风险,所以内容风险审核也是必不可少的。

实现一个功能很容易,但是想要实现一个相对完善的功能,就必须好好下功夫,多思考和多调研。于是,围绕着这些问题,我们不断挖掘和延伸出了一系列功能:

  1. 富文本编辑:强大而简单的可视化编辑器,让一切操作变得简单、直观。产品同学可以通过编辑器自主创建、编辑网页,即使无程序开发经验也可以通过富文本编辑器随意操作,实现自己想要的效果,最终可以实现一键快速发布上线。
  2. WebIDE:定制化需求,比如,与客户端和后端进行一些通信和请求需求,以及针对产品创建的HTML进行二次加工需求,均可以基于WebIDE通过JavaScript代码实现。具备专业开发经验的同学也可以选择通过前端框架jQuery、Vue,Echarts或者工具库Lodash、Axios实现在线编辑代码。
  3. 页面管理:灵活方便地管理页面。大家可以对有权限的文档进行查看、编辑、授权、下线、版本对比、操作日志、回滚等操作,且提供便捷的文档搜索功能。
  4. 模板市场:丰富多样的网页模板,简易而又具备个性。模板市场提供丰富的页面模板,大家可选择使用自己的模板快速创建网页,且发布的每个页面又可以作为自己的模板,再基于这个模板,可随时添加个性化的操作。
  5. 物料平台:提供基础Utils、Echart、Vue、jQuery等物料,方便开发基于产品的页面进行代码的二次开发。
  6. 多平台跨端接入:高效快捷地接入业务系统。通过通信SDK,其他系统可以快速接入Page-佩奇平台。同时支持以HTTP、Thrift方式的开放API供大家选择,支持客户端、后端调用开放API。
  7. 内容风险审核:严谨高效的审核机制。接入美团内部的风险审核公共服务,针对发布的风险内容将快速审核,防止误操作造成不可控的法律风险。
  8. 数据大盘:提供页面的数据监测,帮助大家时刻掌握流量动向。接入美团内部一站式数据分析平台,帮助大家安全、快速、高效地掌握页面的各种监测数据。
  9. 权限管理:创建的每个页面都有相对独立的权限,只有经过授权的人才能查看和操作该页面。
  10. 业务监控:提供页面级别JavaScript错误和资源加载成功率等数据,方便开发排查和解决线上问题。

功能流程图如下所示:

图4 Page-佩奇平台功能流程图

3.3 实现原理

3.3.1 基础服务

Page-佩奇平台的基础服务有四个部分,包括物料服务、编译服务、产品赋能、扩展服务。

图5 整体架构图

3.3.2 核心架构

图6 核心架构图

Page-佩奇平台核心架构主要包含页面基础配置层、页面组装层以及页面生成层。我们通过Vuex全局状态对数据进行维护。

  • 页面基础配置层主要提供生成页面的各种能力,包括富文本的各种操作能力、编辑源码(HTML、CSS、JavaScript)的能力、自定义域名配置、适配的容器(PC/H5)、发布环境等。
  • 页面组装层则会基于基础配置层所提供的的能力,实现页面的自由编辑,承载大量的交互逻辑,用户的所有操作都在这一层进行。
    • 业务PV和UV埋点,错误统计,访问成功率上报。
    • 自动适配PC和移动端样式。
    • 内网页面显示外网不可访问标签。
  • 页面生成层则需要根据组装后的配置进行解析和预处理、编译等操作,最终生成HTML、CSS、JavaScript渲染到网页当中。

3.3.3 关键流程

图7 关键流程图

如上图7所示,平台的核心流程主要包含页面创建之后的页面预览、编译服务、生成页面。

  • 页面预览:创建、编辑之后的页面,将会根据内容进行页面重组,对样式和JavaScript进行预编译之后,对文本+JavaScript+CSS进行组装,生成HTML代码块,然后将代码块转换成Blob URL,最终以iframe的方式预览页面。
  • 编译服务:文件树状结构和代码发送请求到后端接口,基于Webpack将Less编译成CSS,ES 6语法编译成ES 5。通用物料使用CDN进行引入,不再进行二次编译。
  • 生成页面:当创建、编辑之后的页面进行发布时,服务端将会进行代码质量检测、内容安全审查、代码质量检测、单元测试、上传对象存储平台、同步CDN检测,最终生成页面链接进行访问。

3.3.4 多平台接入

Page-佩奇平台也可以作为一个完善的富文本编辑器供业务系统使用,支持内嵌到其他系统内。作为消息发布等功能承载,减少重复的开发工作,同时我们配备完善的SDK供大家选择使用。通过Page-SDK可以直接触发Page平台发布、管理等操作,具体的流程如下图所示:

图8 Page-SDK流程图

3.3.5 Open API

在使用Page-佩奇平台的时候,美团内部一些业务方提出想要通过Page-佩奇平台进行页面的发布,同时想要拿到发布的内容做一些自定义的处理。于是,我们提供了Open API开放能力,支持以HTTP和Thrift两种方式进行调用。下面主要讲一下Thrift API实现的思路,首先我们先了解下Thrift整体流程:

图9 Thrift整体流程图

Thrift的主要使用过程如下:

  1. 服务端预先编写接口定义语言 IDL(Interface Definition Language)文件定义接口。
  2. 使用Thrift提供的编译器,基于IDL编译出服务语言对应的接口文件。
  3. 被调用服务完成服务注册,调用发起服务完成服务发现。
  4. 采用统一传输协议进行服务调用与数据传输。

下面具体讲讲,Node语言是如何实现和其他服务语言实现调用的。由于我们的服务使用的Node语言,因此我们的Node服务就充当了服务端的角色,而其他语言(Java等)调用就充当了客户端的角色。

图10 Thrift使用详细流程图

  • 生成文件:由服务端定义IDL接口描述文件,然后基于IDL文件转换为对应语言的代码文件,由于我们用的是Node语言,所以转换成JavaScript文件。
  • 服务端启动服务:引入生成的JavaScript文件,解析接口、处理接口、启动并监听服务。
  • 服务注册:通过服务器内置的“服务治理代理”,将服务注册到美团内部的服务注册路由中心(也就是命名服务),让服务可被调用方发现。
  • 数据传输:被调用时,根据“服务治理服务”协议序列化和反序列化,与其他服务进行数据传输。

目前,美团内部已经有相对成熟的NPM包服务,已经帮我们实现了服务注册、数据传输、服务发现和获取流程。客户端如果想调用我们所提供的的Open API开放能力,首先申请AppKey,然后选择使用Thrift方式或者HTTP的方式,按照所要求的参数进行请求调用即可。

3.4 方案实践

3.4.1 H5协议

能力:富文本编辑。 描述:提供富文本可视化编辑,产品和运营无需前端就可以发布和二次编辑页面。 场景:文本协议,消息通知,产品FAQ。

具体案例:

图11 H5静态文本协议案例

3.4.2 业务自定义渲染

能力:开放API(Thirft + HTTP)。 描述:提供开放API,支持业务自定义和样式渲染到业务系统,同时解决了iframe体验问题。 场景:客户端、后端、小程序的同学,可根据API渲染文案,实现动态化管理富文本信息。

具体案例:

小程序使用组件、Vue使用v-html指令实现动态化渲染商品选择说明。

    {
    "code": 0,
    "data": {
      "tag": "苹果,标准",
      "title": "如何挑选苹果",
      "html": "

如何挑选苹果

>

以下标准可供消费者参考

  • 酸甜
  • 硬度
", "css": "", "js": "", "file": {} }, "msg": "success" }

3.4.3 投放需求

能力:WebIDE代码编辑。 描述:开发基于WebIDE代码开发工作,基于渠道和环境修改下载链接,能够做到分钟级支撑。 场景:根据产品创建静态页面进行逻辑和样式开发。

具体案例:

    var ua = window.navigator.userAgent
    var URL_MAP = {
        ios: 'https://apps.apple.com/cn/app/xxx',
        android: 'xxx.apk',
        ios_dpmerchant: 'itms-apps://itunes.apple.com/cn/app/xxx'
    }
    
    if (ua.match(/android/i)) location.href = URL_MAP.android
    if (ua.match(/(ipad|iphone|ipod).*os\s([\d_]+)/i)) {
        if (/xx\/com\.xxx\.xx\.mobile/.test(ua)) {
            location.href = URL_MAP.ios_dpmerchant
        } else {
            location.href = URL_MAP.ios
        }
    }

3.4.4 客户端通信中间页

能力:WebIDE代码编辑 + 物料平台。 描述:通过物料平台,引入公司客户端桥SDK,可以快速完成客户端通信需求。方便前端调试客户端基础桥功能。 场景:客户端跳转,通信中间页。

具体案例:

    // 业务伪代码
    XXX.ready(() => {
        XXX.sendMessage({
          sign: true,
            params: {
                id: window.URL
            }
        }, () => {
            console.error('通信成功')
        }, () => {
            console.error('通信失败')
        })
    })

3.4.5 业务系统内嵌Page

能力:提供胶水层Page-SDK,连接业务系统和Page。 描述:业务系统与Page-佩奇平台可进行通信,业务系统可调用Page发布、预览、编辑等功能,Page可返回业务系统页面链接、内容、权限等信息。减少重复前后端工作,提升研发效率。 场景:前端富文本信息渲染,后端富文本信息管理后台。

具体案例:

图12 业务系统内嵌Page案例

3.5 业务成绩

截止目前数据统计,Page-佩奇平台生成网页5000多个,编辑页面次数16000多次,累计页面访问PV超过8260万。现在,美团已经有十多个部门和三十多条业务线接入并使用了Page-佩奇平台。

图13 Page平台每日生成页面统计

四、总结与展望

富文本编辑器和WebIDE不仅是复杂的系统,而且还是比较热门的研究方向。特别是在和美团的基建结合之后,能够解决团队内部很多效率和质量问题。这套系统还提供了语法智能提示、Diff对比、前置检测、命令行调试等功能,不仅要关注业务发布出去页面的稳定性和质量,更要有内置的一系列研发插件,主动帮助研发提高代码质量,降低不必要的错误。

经过长期的技术和业务演进,Page-佩奇平台已经能够有效地帮助研发人员大幅提升开发效率,具备初级的Design To Code能力,但是仍有许多业务场景值得去我们探索。我们也期待优秀的你参与进来,一起共同建设。

  • WebIDE融合:完善基础设施建设和功能需求,更好地支持Vue、React、ES 6、TS、Less语法,预览模式采用浏览器编译,能有效地提高预览的速度,发布使用后端编译的模式。
  • 研发流程链路:针对代码进行有效评估,包括ESlint、代码重复率、智能提示是否可以三方库替代。出具开发代码质量、业务上线的质量报告。
  • 综合研发平台:减少团队同学了解整体基建的时间成本,内置了监控、性能、任务管理等功能,提升业务开发效率。建设自动化日报、周报系统,降低非开发工作量占比。
  • 物料开放能力:接入公共组件平台,沉淀更多的物料,快速满足产品更多样化的需求。

五、作者简介

高瞻、宇立、肖莹、浩畅,来自美团医药终端团队。王咏、陈文,来自美团闪购终端团队。

六、招聘信息

美团医药长期招聘Android、iOS、FE前端工程师,坐标在北京和成都。感兴趣的同学可将简历发送至:sunyuli@meituan.com(邮件主题请注明:美团医药终端)。

Fairplay DRM与混淆实现的研究

2021年11月25日 00:00

什么是DRM?

DRM全称Digital Rights Management,即数字版权保护。苹果为了保护App Store分发的音乐/视频/书籍/App免于盗版,开发了Fairplay DRM技术,并申请了很多相关的专利,比较有代表性的如: - US8934624B2: Decoupling rights in a digital content unit from download - US8165286B2: Combination white box/black box cryptographic processes and apparatus - ES2373131T3: Safe distribution of content using descifrado keys

长久以来,关于App DRM的研究很少,而DRM的关键是授权和加密。破解Fairplay DRM加密的方式俗称“砸壳”,这是进行iOS App安全研究的必要前提。自从2013年苹果引入App DRM机制以后,诞生了如Cluth、Bagbak、Flexdecrypt这样的经典“砸壳工具”,而此类“砸壳工具”通常需要越狱设备的支持,因此具有一定的局限性。

2020年发布的M1 Mac将Fairplay DRM机制引入了MacOS,由于Mac设备的权限没有iOS严格,因此我们得以在MacOS上探索更多Fairplay DRM的原理,最终目标是使解密流程不受Apple平台的限制。下面,我们先来聊聊Apple中是如何实现的?

Apple上DRM的实现:Fairplay DRM

LC_ENCRYPTION_INFO中的标记

加密的MachO含有LC_ENCRYPTION_INFO字段,其中cryptoff标识了加密部分在文件中的起始偏移,cryptsize标识了加密部分的尺寸,cryptid则表明了加密的方法。Fairplay DRM保护下的App,其加密尺寸为4096的倍数,加密方式标识为1。

图1

而负责解密Mach-O的组件主要包括:内核态的FairplayIOKit和用户态的fairplayd

Fairplay的Open

MacOS的XNU Kernel中有text_crypter_create_hook这个导出符号,IOTextEncryptionFamily驱动则注册了这个Hook,并作为桥梁,将调用转发给了FairplayIOKit内核驱动。

图2

最终负责处理的函数是:

com_apple_driver_FairPlayIOKit::xhU6d1(
  char const* executable_path,
  long long cpu_type,
  long long cpu_subtype,
  rp6S0jzg** out_handle
)

此后,内核中的FairplayIOKit开始初始化,通过host_get_special_port中的unfreed port发送MIG调用到用户态的fairplayd,fairplayd开始处理SC_Info目录下的sinf和supp文件,并将处理的数据返回给内核中的FairplayIOKit。

注:用户态的fairplayd具体工作流程不在本文讨论范围内。

其中MIG调用的结构如下:

struct FPRequest{
    mach_msg_header_t header;
    mach_msg_body_t body;
    mach_msg_ool_descriptor_t ool;
    NDR_record_t ndr;
    uint32_t size;
    uint64_t cpu_type;
    uint64_t cpu_subtype;
};

struct FPResponse{
    mach_msg_header_t header;
    mach_msg_body_t body;
    mach_msg_ool_descriptor_t ool1; //supf文件映射
    mach_msg_ool_descriptor_t ool2; //unk,正比与加密内容的尺寸
    uint64_t unk1;
    uint8_t unk2[136];
    uint8_t unk3[84];
    uint32_t size1;
    uint32_t size2;
    uint64_t unk5;
};

完成所有调用后,返回的结构rp6S0jzg*实际是一个uint32_t类型的handle,接下来则可以用这个handle来完成解密操作。

Fairplay的Decrypt Page

前面提到的Fairplay Open操作最终返回了一个pager_crypt_info的结构体,其中page_decrypt的Hook由IOTextEncryptionFamily驱动接管,并最终转发给FairplayIOKit。

图3

最后,FairplayIOKit中负责解密的函数定义如下:

com_apple_driver_FairPlayIOKit::bvqhJ(
  rp6S0jzg *hanlde,
  unsigned long long offset,
  unsigned char const* src,
  unsigned char * dst
)

至此,Fairplay的解密逻辑完成调用。值得注意的是,在Fairplay DRM中,page的概念为4096bytes。

那么,用户态fairplayd处理的sinf和supp文件又是什么样子的呢?

SINF和SUPF文件

结构

用户态的fairplayd会读取随IPA携带的两个重要文件:SINF和SUPF,存储在App的SC_Info目录下。

图4

其中SUPF文件和IPA一起分发,每个用户的IPA和SUPF文件都是一致的,其中SUPF文件中保存了加密Mach-O的密钥,但是密钥本身被另外的机制加密。而SINF文件则作为每个用户的DRM许可,记录了购买用户的标识符和姓名,以及解密SUPF需要的信息,因此在Sandbox策略下,App无法读取自身的SINF文件,以防止其被作为唯一ID追踪用户

SINF

SINF文件是一个LTV+KV结构的文件,它的字段如下所示:

sinf.frma: game
sinf.schm: itun
sinf.schi.user: 0xdeadbeef
sinf.schi.key : 0x00000005
sinf.schi.iviv: 0x12345678901234567890123456789012
sinf.schi.righ.veID: 0x000007d3
sinf.schi.righ.plat: 0x00000000
sinf.schi.righ.aver: 0x01010100
sinf.schi.righ.tran: 0xdc64f80c
sinf.schi.righ.sing: 0x00000000
sinf.schi.righ.song: 0x59a73c58
sinf.schi.righ.tool: P550
sinf.schi.righ.medi: 0x00000080
sinf.schi.righ.mode: 0x00002000
sinf.schi.righ.hi32: 0x00000004
sinf.schi.name: User Name
sinf.schi.priv: (432 Bytes Private Key)
sinf.sign: (128 Bytes Private)

SUPF

SUPF文件主要分为三个部分,我们将其命名为Key Segments、Fairplay Certificate、RSA Signature,其中Key Segments可以含有多个子Segment,用来保存多个架构的解密信息。

KeyPair Segments:
Segment 0x0: arm64, Keys: 0x36c/4k, sha1sum = e369546960d805dd1188d42e3350430c7e3a0025

Fairplay Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            33:33:af:08:07:08:af:00:01:af:00:00:10
        Signature Algorithm: sha1WithRSAEncryption
        Issuer: C=US, O=Apple Inc., OU=Apple Certification Authority, CN=Apple FairPlay Certification Authority
        Validity
            Not Before: Jul  8 00:48:29 2008 GMT
            Not After : Jul  7 00:48:29 2013 GMT
        Subject: C=US, O=Apple Inc., OU=Apple FairPlay, CN=AP.3333AF080708AF0001AF000010
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (1024 bit)
                Modulus:
                    00:b0:01:16:4b:62:b2:37:8d:60:12:4f:02:15:15:
                    a0:32:1b:e8:ed:44:ed:e9:17:5b:ec:9e:5d:11:24:
                    5a:66:2f:dc:a3:25:aa:52:70:e1:09:22:09:4b:65:
                    0f:67:f5:82:dc:af:78:9b:4c:45:f3:b4:f4:77:aa:
                    fc:a3:b2:84:c3:8b:09:c6:2e:55:f5:14:85:07:ac:
                    ae:0d:ff:ff:ca:41:3b:44:cb:52:b6:28:60:55:23:
                    35:8d:26:71:c6:12:a5:e0:72:58:09:3c:4a:9e:b6:
                    63:df:2a:91:94:27:eb:65:0a:b2:36:45:11:c1:91:
                    43:58:12:d9:e5:18:a1:ad:db
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment, Data Encipherment, Key Agreement
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Subject Key Identifier: 
                7B:07:34:81:A5:75:D0:F6:11:BB:D2:36:3F:79:93:4B:A1:70:EB:CF
            X509v3 Authority Key Identifier: 
                keyid:FA:0D:D4:11:91:1B:E6:B2:4E:1E:06:49:94:11:DD:63:62:07:59:64

    Signature Algorithm: sha1WithRSAEncryption
         06:11:4e:87:ed:b1:08:70:c2:0d:e4:d2:94:bb:7f:ee:50:18:
         c0:2a:21:34:0e:99:1f:bf:60:a2:58:d0:0c:28:3d:03:5b:ab:
         4e:72:69:ba:41:52:45:b2:29:27:4a:c8:ba:7f:b5:9b:63:78:
         b1:68:41:40:59:3f:05:8a:57:74:c5:63:30:cc:f3:20:41:c0:
         3c:65:d4:0d:22:47:f3:97:76:e6:d6:3c:eb:e7:20:78:10:59:
         fd:96:09:82:c3:41:f0:5f:d0:3e:91:44:6d:77:3f:a5:d9:da:
         f0:f7:53:ad:94:61:28:1c:4c:40:3b:17:2b:dd:e3:00:df:77:
         71:22

RSA Signature: 6aeb00124d62f75f5761f7c26ec866a061f0776be7e84bfad4b6a1941dbddfdb3bd1afdcc5ef305877fa5bee41caa37b1a9d4ce763cf7d2cb89efa60660a49dd5ddff0f46eee7cd916d382f727d912e82b6e0a62e8110c195e298481aa8c8162faac066ef017c6c2c508700d7adb57e0c988af437621e698946da1b09adf89e9

下面,我们来聊聊Fairplay DRM的混淆原理和实现。

混淆原理和一些实现

LLVM Pass

LLVM是一个优良的编译器框架,其中,我们可以将其大略的分为前端、中端、后端:

图5

配图节选自CMU的CS 15-745课程:https://www.cs.cmu.edu/~15745

前端负责将高级语言转化为LLVM IR;中端处理LLVM IR,完成一系列的分析、优化任务,我们称之为Pass,再次输出LLVM IR;后端则负责将LLVM IR转化为机器码。其中,中端的玩法特别丰富,基本的优化任务:如死代码消除、常量折叠都在这一部分完成;Address Sanitizer、PC Sanitizer等编译器插桩也是在这里进行的;其他的混淆框架如讨论的较多的ollvm以及Hikari,甚至包括苹果的混淆机制,也都是基于此完成。

这一混淆方式可以基本的分为控制流混淆和数据流混淆,除此之外的一些混淆方式,比如VMP等,不在本文讨论范围内。

makeOpaque

在编译器中,为了防止一些具体的表达式被优化,我们会将表达式进行等价变化,我们暂时将这样的操作定义为makeOpaque(如Safari的JavascriptCore,其JIT组件B3就提供这样的机制),C++伪代码如下:

Expression* makeOpaque(Expression *in);

不透明谓词(Opaque Predicate)

谓词(Predicate)在计算机中,指的是执行后为True或False的表达式。数论里面的一些结论可以作为我们生成不透明谓词的基础,这些不透明谓词的结果恒为True或恒为False。比如下列表达式中,y执行的结果就恒为True:

uint32_t x = 0;
bool y = ((x * x % 4) == 0 || (x * x % 4) == 1);

不透明谓词应用到混淆中的一个例子就是bogus CFG。 如源语句如下:

foo1();
foo2();

经过变换,我们添加了一个虚假的分支(即bogus CFG) :

foo1();
if ( false )
  junk_code();
else
  foo2();

但是如果没有经过特别处理,编译器、反编译器的死代码消除就会将虚假分支去除掉,因此我们需要makeOpaque的引入,假设我们引入了前面示例中的表达式:

foo1();
uint32_t x = rand();
bool y = ((x * x % 4) == 0 || (x * x % 4) == 1);
if ( !y )
  junk_code();
else
  foo2();

那么如果编译器、反编译器没有相应的识别机制的话,这一部分的死代码就保留了下来,通过在死代码里面插入大量干扰指令,可以为逆向的人员带来极大的困扰。经测试在-O2优化下,Clang 11已经可以识别这个规则,但是GCC 5.4无法识别。

可逆变换

这里我们介绍一下目前混淆技术中常用的等价变换方式。

异或

异或规则是最常见的变换,这里不再赘述。

x ^ c ^ c = x;

仿射变换(Affine transformation)

我们先来看一下仿射函数。

下面我们来看一下实际应用。

由于计算机中的运算属于隐式的模运算,因此会具有一些有意思的性质。如对于一个uint32上的运算,模运算逆元定义如下:

//对于
uint32_t a, r_a;

//如果满足
(a * r_a) % UINT32_MAX == 1;

//那么 a 和 r_a 互为模反元素

对于互为模反元素的a和r_a(可通过扩展欧几里得算法求得),有这样的特性:

uint32_t x = rand();
uint32_t y1 = a * x + c;
//那么满足
x == ra * y1 +  (- ra * c)

最后举个例子来说明:

//对于互为模反元素的4872655123 * 3980501275,取
uint32_t x = 0xdeadbeef;
uint32_t c = 0xbeefbeef;
//则 -ra * c = 0x57f38dcb,且
((x * 4872655123) + 0xbeefbeef) * 3980501275 + 0x57f38dcb == x
/*
可在lldb中验证如下
(lldb) p/x uint32_t x=0xdeadbeef; (uint32_t)(((x * 4872655123) + 0xbeefbeef) * 3980501275 + 0x57f38dcb)
(uint32_t) $8 = 0xdeadbeef
*/

MBA表达式(Mixed Boolean-Arithmetic Expression)

MBA表达式是把算术运算(+,-,*,/)和位运算(&,|,~)混合在一起用以隐藏原本表达式的混淆方法。它基于不同的数学原理存在多种形式,这里主要介绍多项式MBA,这是目前混淆技术中最常遇到的形式。

类似的,在Fairplay混淆中用到的MBA表达式为:

//OperationSet(+, -, *, &, |, ~)
x - c = (x ^ ~c) + ((2 * x) & ~(2 * c + 1)) + 1;

而使用MBA进行混淆操作主要依靠以下两个步骤:

不透明常量(Opaque Constant)

不透明常量是基于MBA混淆的方法,用于隐藏数据流中的常量。它使用了置换多项式,是一种在有限域上的可逆多项式。

控制流平坦化

这一部分是逆向工程中讨论的最热门的话题,即将正常的控制流转换等价替换为一个状态机,从而干扰静态的控制流分析,业界也有较多的解决方案。同时因为Fairplay DRM中没有明显用到这种类型的混淆,不再多讨论。

非直接跳转(Indirect Branch)

将一些基本块的起始地址保存在全局变量中,通过不透明常量的生成,使得反汇编工具和肉眼无法直接获取到基本块跳转的目标,模型如下:

//记录基本块地址到全局查找表LUT
LUT[i] = PC;

//执行跳转
jmp/call LUT[makeOpaque(i)]

具体的实例:

图6

这样,逆向人员就无法直接获取跳转的目标函数、基本块了。同理,通过将判断语句的条件映射到跳转表,也可以实现对条件跳转的混淆。

所以,在逆向被混淆的Fairplay代码时,IDA Pro大多数时刻,只能识别出来函数的第一个基本块,无法分析出函数的边界

跨函数混淆 + 调用约定混淆

正常情况下,编程语言如C语言的参数传递遵循特定的调用约定,但是部分混淆工具会对一些内部函数的调用约定进行修改,以Fairplay DRM为例:

图7

我们可以看到常规的以寄存器和栈传递参数的方式被替换成了以堆传递参数的方式了,在构造好了结构体的情况下,这个参数传递的特征可以被清晰的看出来。同时,这里面对一些传递的参数进行了异或混淆,在子函数里面再恢复出来,使得我们难以直接得到原始数据,而静态分析的工具比如IDA Pro也不支持跨函数的数据流分析。

更严重的是,一些影响子函数运行的重要依赖数据,被提升到了父函数内,导致在没有恢复调用关系前,我们根本无法推测子函数的运行流程。

那么,Fairplay DRM的破解之道就是要找到它的弱点。

Fairplay混淆的弱点

通过前边的工作,我们已经能Fairplay正常的完成打开和解密工作了,通过一系列的静态分析和追踪调试,我们发现了这一套混淆系统的一些对抗方案。

这些问题的本质原因是:混淆系统在IR层面设计,对机器相关的部分操作没有混淆,因此在生成的机器码里面,我们可以推断得到混淆前的一些特征信息

函数边界识别

前面提到,由于Fairplay用到了非直接跳转的混淆技术,IDA Pro无法直接分析函数的边界。通过跟踪,我们发现在arm64e设备下,该内核驱动中,同一个函数的所有基本块在运行到跳转指令时,均使用了同一个PAC Context,或者称之为PAC Modifier。

图8

借由这个特性,我们可以将函数的边界和基本块分组,尽管目前为止这些基本块之间并不是连通的。

非直接跳转

对于无条件跳转,我们通过设置断点跟踪执行流,就可以解决了。

图9

再通过KeyPatch这样的工具,我们可以将一些简单的函数恢复到比较易于理解的地步。

图10

但是这里的难点在于恢复混淆里面的非直接跳转指令,如下图所示:

图11

对于这个跳转指令,我们可以生成如下的表达式:

//cmp x0, #0
w10 = qword[x12 + (EQ * 0xB + w19) 

通过CSET指令的形式,我们已经可以推断跳转指令应该是J.NE或者J.EQ了,通过我们的调试器插件,我们可以得到其中一个分支的跳转地址和原本的跳转指令,再通过表达式的信息,我们可以很快推断出另外一个分支的地址。

图12

再通过Keypatch,我们可以得到混淆前的分支语句结构:

图13

至此,我们已经可以完整地恢复Fairplay的大多数控制流了。

数据流混淆

这一部分在前面已经提及到了一些,目前我们已经找到了MBA表达式的模式,但还没能找到Fairplay中生成不透明常量的完整规则。其中MBA表达式的重写规则目前看起来仅有一个,即:

x - c = (x ^ ~c) + ((2 * x) & ~(2 * c + 1)) + 1;

一些基于模式匹配的工具,比如D810已经可以比较好的处理这样的情况了。

结束语

目前,我们已经可以获取到解密每一段Mach-O的AES密钥了,通过大量的调试和反混淆,我们已经得出了这些密钥生成的初步结论。我们希望最终的目的是不依赖Apple设备的前提下,完成Fairplay DRM加解密的研究。

图14

最后,附上源码,欢迎大家进行参考和研究。

参考文献

作者简介

吴聊、落落、朱米,均来自美团信息安全部。

美团搜索多业务商品排序探索与实践

2021年11月19日 00:00

参考资料

  • [1] 多业务建模在美团搜索排序中的实践
  • [2] Ma X, Zhao L, Huang G, et al. Entire space multi-task model: An effective approach for estimating post-click conversion rate[C]//The 41st International ACM SIGIR Conference on Research & Development in Information Retrieval. 2018: 1137-1140.
  • [3] Friedman et al., A note on the group lasso and a sparse group lasso.
  • [4] Kendall et al., Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics. In CVPR, 2018.
  • [5] Guo et al., Dynamic Task Prioritization for Multitask Learning. In ECCV, 2018.
  • [6] Sheng et al., One Model to Serve All: Star Topology Adaptive Recommender for Multi-Domain CTR Prediction. In CIKM, 2021.
  • [7] Zhou G, Zhu X, Song C, et al. Deep interest network for click-through rate prediction[C]//Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. ACM, 2018: 1059-1068.
  • [8] Zhou G, Mou N, Fan Y, et al. Deep interest evolution network for click-through rate prediction[C]//Proceedings of the AAAI Conference on Artificial Intelligence. 2019, 33: 5941-5948.
  • [9] Feng Y, Lv F, Shen W, et al. Deep Session Interest Network for Click-Through Rate Prediction[J]. arXiv preprint arXiv:1905.06482, 2019.
  • [10] Chen Q, Zhao H, Li W, et al. Behavior sequence transformer for e-commerce recommendation in Alibaba[C]//Proceedings of the 1st International Workshop on Deep Learning Practice for High-Dimensional Sparse Data. 2019: 1-4
  • [11] Kan Ren, Jiarui Qin, Yuchen Fang, Weinan Zhang, Lei Zheng, Weijie Bian, Guorui Zhou, Jian Xu, Yong Yu, Xiaoqiang Zhu, et al. Lifelong sequential modeling with personalized memorization for user response prediction. In SIGIR, 2019.
  • [12] Qi Pi, Weijie Bian, Guorui Zhou, Xiaoqiang Zhu, and Kun Gai. Practice on long sequential user behavior modeling for click-through rate prediction. In KDD, 2019.
  • [13] Jiarui Qin, W. Zhang, Xin Wu, Jiarui Jin, Yuchen Fang, and Y. Yu. User behavior retrieval for click-through rate prediction. In SIGIR, 2020.
  • [14] Search-based User Interest Modeling with Lifelong Sequential Behavior Data for Click-Through Rate Prediction.
  • [15] Transformer 在美团搜索排序中的实践
  • [16] Ma J, Zhao Z, Yi X, et al. Modeling task relationships in multi-task learning with multi-gate mixture-of-experts[C]//Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2018: 1930-1939.
  • [17] Xi D, Chen Z, Yan P, et al. Modeling the Sequential Dependence among Audience Multi-step Conversions with Multi-task Learning in Targeted Display Advertising[J]. arXiv preprint arXiv:2105.08489, 2021.
  • [18] Burges C J C. From ranknet to lambdarank to lambdamart: An overview[J]. Learning, 2010, 11(23-581): 81.
  • [19] https://en.wikipedia.org/wiki/Huber_loss
  • [20] Liu et al., AutoFIS: Automatic Feature Interaction Selection in Factorization Models for Click-Through Rate Prediction, In ADS-KDD, 2020.
  • [21] Khawar et al., AutoFeature: Searching for Feature Interactions and Their Architectures for Click-through Rate Prediction, In CIKM, 2020.
  • [22] Tang et al., Progressive Layered Extraction (PLE): A Novel Multi-Task Learning (MTL) Model for Personalized Recommendations, In Recsys, 2020.

作者简介

曹越、瑶鹏、诗晓、李想、家琪、可依、晓江、肖垚、培浩、达遥、陈胜、云森、利前均来自美团平台搜索与 NLP 部。

美团知识图谱问答技术实践与探索

2021年11月3日 00:00

1 背景与挑战

问答系统(Question Answering System, QA)是人工智能和自然语言处理领域中一个倍受关注并具有广泛发展前景的方向,它是信息检索系统的一种高级形式,可以用准确、简洁的自然语言回答用户用自然语言提出的问题。这项研究兴起的主要原因是人们对快速、准确地获取信息的需求,因此被广泛应用于工业界的各种业务场景中。美团在平台服务的售前、售中、售后全链路的多个场景中,用户都有大量的问题需要咨询商家。因此我们基于问答系统,以自动智能回复或推荐回复的方式,来帮助商家提升回答用户问题的效率,更快地解决用户的问题。

针对不同问题,美团的智能问答系统包含多路解决方案:

  1. PairQA:采用信息检索技术,从社区已有回答的问题中返回与当前问题最接近的问题答案。
  2. DocQA:基于阅读理解技术,从商家非结构化信息、用户评论中抽取出答案片段。
  3. KBQA(Knowledge-based Question Answering):基于知识图谱问答技术,从商家、商品的结构化信息中对答案进行推理。

本文主要分享在KBQA技术落地中的实践与探索。

在用户的问题中,包括着大量关于商品、商家、景区、酒店等相关基础信息及政策等信息咨询,基于KBQA技术能有效地利用商品、商家详情页中的信息,来解决此类信息咨询问题。用户输入问题后,KBQA系统基于机器学习算法对用户查询的问题进行解析、理解,并对知识库中的结构化信息进行查询、推理,最终将查询到的精确答案返回给用户。相比于PairQA和DocQA,KBQA的答案来源大多是商家数据,可信度更高。同时,它可以进行多跳查询、约束过滤,更好地处理线上的复杂问题。

实际落地应用时,KBQA系统面临着多方面的挑战,例如:

  1. 繁多的业务场景:美团平台业务场景众多,包涵了酒店、旅游、美食以及十多类生活服务业务,而不同场景中的用户意图都存在着差别,比如“早餐大概多少钱”,对于美食类商家需要回答人均价格,而对于酒店类商家则需要回答酒店内餐厅的价格明细。
  2. 带约束问题:用户的问题中通常带有众多条件,例如“故宫学生有优惠吗”,需要我们对故宫所关联的优惠政策进行筛选,而不是把所有的优惠政策都回答给用户。
  3. 多跳问题:用户的问题涉及到知识图谱中多个节点组成的路径,例如“XX酒店的游泳池几点开”,需要我们在图谱中先后找到酒店、游泳池、营业时间。

下面将详细讲述我们是如何设计高准确、低延时的KBQA系统,处理场景、上下文语境等信息,准确理解用户、捕捉用户意图,从而应对上述的挑战。

2 解决方案

KBQA系统的输入为用户Query,输出为答案。总体架构如下图1所示。最上层为应用层,包括对话以及搜索等多个入口。在获取到用户Query后,KBQA线上服务通过Query理解和召回排序模块进行结果计算,最终返回答案文本。除了在线服务之外,知识图谱的构建、存储也十分重要。用户不仅会关心商户的基本信息,也会询问观点类、设施信息类问题,比如景点好不好玩、酒店停车是否方便等。针对上述无官方供给的问题,我们构建了一套信息与观点抽取的流程,可以从商家非结构化介绍以及UGC评论中抽取出有价值的信息,从而提升用户咨询的满意度,我们将在下文进行详细介绍。

图1 KBQA系统架构图

对于KBQA模型,目前的主流解决方案有两种,如下图2所示:

图2 KBQA解决方案分类

  • 基于语义解析(Semantic Parsing-based):对问句进行深度句法解析,并将解析结果组合成可执行的逻辑表达式(如SparQL),直接从图数据库中查询答案。
  • 基于信息抽取(Information Retrieval):先解析出问句的主实体,再从KG中查询出主实体关联的多个三元组,组成子图路径(也称多跳子图),之后分别对问句和子图路径编码、排序,返回分数最高的路径作为答案。

基于语义解析的方法可解释性更强,但这种方法需要标注大量的自然语言逻辑表达式,而信息抽取式的方法更偏向端到端的方案,在复杂问题、少样本情况下表现更好,但若子图过大,会显著降低计算的速度。

因此,考虑到两者的优势,我们采用将两者结合的方案。如下图3所示,整体的流程分为四大步骤,以“故宫周末有学生票吗”为例:

图3 美团KBQA解决方案

  1. Query理解:输入原始Query,输出Query理解结果。其中会对对Query进行句法分析,识别出用户查询的主实体是“故宫” 、业务领域为“旅游”、问题类型为一跳(One-hop)。
  2. 关系识别:输入Query、领域、句法解析结果、候选关系,输出每个候选的分数。在这个模块中,我们借助依存分析强化Query的问题主干,召回旅游领域的相关关系,进行匹配排序,识别出Query中的关系为“门票”。
  3. 子图召回:输入前两个模块中解析的主实体和关系,输出图谱中的子图(多个三元组)。对于上述例子,会召回旅游业务数据下主实体为“故宫”、关系为“门票”的所有子图。
  4. 答案排序:输入Query和子图候选,输出子图候选的分数,如果Top1满足一定阈值,则输出作为答案。基于句法分析结果,识别出约束条件为“学生票”,基于此条件最终对Query-Answer对进行排序,输出满足的答案。

下面将介绍我们对于重点模块的建设和探索。

2.1 Query理解

Query理解是KBQA的第一个核心模块,负责对句子的各个成分进行细粒度语义理解,其中两个最重要的模块是:

  • 实体识别和实体链接,输出问句中有意义的业务相关实体和类型,如商家名称、项目、设施、人群、时间等。
  • 依存分析:以分词和词性识别结果为输入,识别问句的主实体、被提问信息、约束等。

实体识别是句法分析的重要步骤,我们先基于序列标注模型识别实体,再链接到数据库中的节点。对于该模块我们主要做了以下优化:

  • 为了提升OOV(Out-of-Vocabulary)词的识别能力,我们对实体识别的序列标注模型进行了知识注入,利用已知的先验知识辅助新知识的发现。
  • 考虑到实体嵌套的问题,我们的实体识别模块会同时输出粗粒度和细粒度的结果,保证后续模块对于Query的充分理解。
  • 在问答的长Query场景下,利用上下文信息进行实体的链接,得到节点id。

最终,该模块会输出句子中各个重要成分的类型,如下图4所示:

图4 Query理解流程及结果

依存分析是句法分析的一种,它的目的是识别句子中词与词的非对称支配关系,在输出的结果中用有向弧表示,该弧线由从属词(dep)指向支配词(head)。对于KBQA任务,我们定义了五种关系,如下图5所示:

图5 依存类型定义

依存分析主要有两种方案:基于转移的(Transition-based)和基于图的(Graph-based)。基于转移的依存分析将依存句法树的构建建模为一系列操作,由模型预测每一步的动作(shift、left_arc、right_arc),不断将未处理的节点入栈并赋予关系,最终构成句法树。基于图的方法则致力于在图中找出一棵最大生成树,也就是句子整体依存关系的全局最优解。考虑到基于图的方法是对全局进行搜索,准确率更高,我们采用较为经典的“Deep Biaffine Attention for Neural Dependency Parsing”模型,它的结构如下图6所示:

图6 依存分析模型结构

该模型先通过BiLSTM对词与词性的拼接向量进行编码,之后采用对用两个MLP头分别编码出h(arc-head)和h(arc-dep)向量,去除冗余信息。最终将各个时刻的向量拼接起来得到H(arc-head)和H(arc-dep),且在H(arc-dep)上拼接了一个单位向量,加入中间矩阵U(arc)进行仿射变换,得到dep与head的点积分数矩阵S(arc),找到每个词依存的head。

有了依存分析的结果,我们可以更好地识别关系、复杂问题,具体的特征使用方法将在下文进行介绍。

2.2 关系识别

关系识别是KBQA中另一个核心模块,目的是识别出用户Query所问的关系(Predicate),从而与主实体(Subject)联合确定唯一子图,得到答案(Object)。

在实践中,考虑到图谱中边关系的数量会不断增加,我们将关系识别建模为文本匹配任务,输入用户Query、Query特征和候选关系,输出关系匹配的分数。为了解决开头提到的多领域问题,我们在输入的特征中加入了领域信息,从而在领域表示中存储一定的领域相关知识,让模型更好地判断。同时,为了提升复杂Query的理解,我们在输入中还融入了句法信息,让模型可以更好地理解带约束、多跳的问题。

图7 关系识别模型结构

随着大规模预训练语言模型的出现,BERT等大模型在匹配任务上取得了SOTA的结果,通常业界通用的方法主要归类为以下两种:

  1. 表示型:也称“双塔模型”,它的主要思想是将两段文本转换成一个语义向量,然后在向量空间计算两向量的相似度,更侧重对语义向量表示层的构建。
  2. 交互型:该方法侧重于学习句子中短语之间的对齐,并学习比较他们之间的对齐关系,最终将对齐整合后的信息聚合到预测层。由于交互型模型可以利用到文本之前的对齐信息,因而精度更高、效果更好,所以在本项目中我们采用交互型模型来解决匹配问题。

为了充分利用BERT的语义建模能力,同时考虑实际业务的线上延时要求,我们在推理加速、数据增强、知识增强方面做了以下三点优化:

  1. 层次剪枝:BERT每层都会学到不同的知识,靠近输入侧会学到较为通用的句法知识,而靠近输出则会学习更多任务相关的知识,因此我们参考DistillBERT,采取Skip等间隔式层次剪枝,只保留对任务效果最好的3层,比单纯保留前三层的剪枝在F1-score上提升了4%,同时,实验发现不同剪枝方法效果差距可达7%。
  2. 领域任务数据预精调:剪枝后,由于训练数据有限,3层模型的效果有不小的下降。通过对业务的了解,我们发现美团的“问大家”模块数据与线上数据的一致性很高,并对数据进行清洗,将问题标题和相关问题作为正例,随机选取字面相似度0.5-0.8之间的句子作为负例,生成了大量弱监督文本对,预精调后3层模型在准确率上提升超过4%,甚至超过了12层模型的效果。
  3. 知识增强:由于用户的表达方式多种多样,准确识别用户的意图,需要深入语意并结合语法信息。为了进一步提升效果,同时解决部分Case,我们在输入中加入了领域与句法信息,将显式的先验知识融入BERT,在注意力机制的作用下,同时结合句法依存树结构,准确建模词与词之间的依赖关系,我们在业务数据以及五个大型公开数据集上做验证,对比BERT Base模型在准确率上平均提升1.5%。

经过上述一系列迭代后,模型的速度、准确率都有了大幅的提升。

2.3 复杂问题理解

在真实场景中,大部分问题可以归为以下四类(绿色为答案节点),如下图8所示:

图8 复杂问题分类

问题的跳数根据实体数量决定,单跳问题通常只涉及商户的基本信息,比如问商户的地址、电话、营业时间、政策等,在知识图谱中都可以通过一组SPO(三元组)解答;两跳问题主要是针对商户中某些设施、服务的信息提问,比如酒店的健身房在几层、早餐几点开始、以及接送机服务的价格等,需要先找到商户->主实体(设施/服务/商品等)的路径,再找到主实体的基本信息三元组,也就是SPX、XPO两个三元组。约束问题指主实体或答案节点上的约束条件,一般为时间、人群或是定语。

下面介绍针对不同类型的复杂问题,我们所进行的一些改进。

2.3.1 带约束问题

通过对线上日志的挖掘,我们将约束分为以下几类,如下图9所示:

图9 约束问题分类

对于带约束问题的回答涉及两个关键步骤:约束识别答案排序

通过KBQA系统中的依存分析模块,我们可以识别出用户在实体或关系信息上所加的约束限制,但约束的说法较多,且不同节点的约束类型也不一样,因此我们在构造数据库查询SQL时先保证召回率,尽量召回实体和关系路径下的所有候选节点,并在最终排序模块对答案约束进行打分排序。

为了提升效率,我们首先在知识存储层上进行了优化。在复合属性值的存储方面,Freebase提出Compound Value Type (CVT) 类型,如下图10所示,来解决这类复合结构化的数据的存储与查询问题。比如欢乐谷的营业时间,对于不同的场次是不一样的。这种复合的属性值可以用CVT的形式去承接。

图10 CVT类型示例

但是,CVT存储方式增加查询复杂度、耗费数据库存储。以图“欢乐谷营业时间CVT”为例:

  • 该信息以通常成对CVT形式存储,一个CVT涉及3个三元组存储。
  • 对于“欢乐谷夏季夜场几点开始”这样的问题,在查询的时候,涉及四跳,分别为, 营业时间CVT>, 季节=夏季>, 时段=夜场>, 时间>。对业界查询快速的图数据库比如Nebula来说,三跳以上的一般查询时间约为几十毫秒,在实际上线使用中耗时较长。
  • 一旦属性名称、属性值有不同的但是同意的表达方式,还需要多做一步同义词合并,从而保证查询时能匹配上,没有召回损失。

为了解决上述问题,我们采用Key-Value的结构化形式承载属性信息。其中Key为答案的约束信息,如人群、时间等可以作为该属性值的约束的信息,都可以放在Key中,Value即为要查的答案。对于上文的例子,我们将所有可能的约束维度的信息组成Key,如下图11所示:

图11 约束问题解决方案

之后,为了解决约束值说法过多的问题,在实际查询过程中,在找不到完全匹配的情况下,我们用属性值的Key和问题中的约束信息进行匹配计算相关度,相关度最高的Key,对应的Value即为答案。因此,Key的表示方法可以为多种形式:

  • 字符串形式:用文本相似度的方法去计算和约束文本的相关性。
  • 文本Embedding:如对Key的文本形式做Embedding形式,与约束信息做相似计算,在训练数据合理的情况下,效果优于字符串形式。
  • 其他Embedding算法:如对虚拟节点做Graph Embedding,约束文本与对应的虚拟节点做联合训练等等。

这种形式的存储方式,相当于只存储一个三元组,即营业时间KV>,查询过程压缩成了一跳+文本匹配排序。基于语义模型的文本匹配可以在一定程度上解决文本表达不同造成的不能完全匹配的问题。对语义模型进行优化后,可以尽量压缩匹配时间,达到十几毫秒。

进行复杂条件优化后,先通过前置模块识别到实体、关系和约束,组成约束文本,再与当前召回子图的Key值候选进行匹配,得到最终的答案。

2.3.2 多跳问题

多跳问题是天然适合KBQA的一类问题,当用户询问商户中的设施、服务、商品等实体的信息时,我们只需要先在图谱中找到商户,再找到商户下的实体,接着找到下面的基本信息。如果使用FAQ问答的解法,就需要为每个复杂问题都设置一个标准问,比如“健身房的位置”、“游泳馆的位置”等。而在KBQA中,我们可以很好地对这类问题进行压缩,不管问什么实体的位置,都问的是“位置”这条边关系,只是起始实体不同。

在KBQA系统中,我们先依赖依存分析模块对句子成分间的依赖关系进行识别,之后再通过关系识别模块判断句子所询问的关系跳数以及关系,具体流程如下图12所示:

图12 多跳问题解决方案

借助实体识别的类型,我们可以将句子中的重要成分进行替换,从而压缩候选关系配置的个数、提升关系识别准确率。在对句子进行了充分理解后,系统会基于主实体、关系、跳数对子图进行查询,并输入给答案排序模块进行更细粒度的约束识别和打分。

2.4 观点问答

除了上述基本信息类的查询Query外,用户还会询问观点类的问题,比如“迪士尼停车方便吗?”、“XX酒店隔音好吗?”等。对于主观观点类问题,可以基于FAQ或阅读理解技术,从用户评论中找出对应的评论,但这种方法往往只能给出一条或几条评论,可能会太过主观,无法汇总群体的观点。因此我们提出了观点问答方案,给出一个观点的正反支持人数,同时考虑到可解释性,也会给出多数观点的评论证据,在App中的实际展示如下图13所示:

图13 观点问答截图

为了自动化地批量挖掘用户观点,我们拆解了两步方案:观点发现和Evidence挖掘,如下图14所示。

图14 观点挖掘步骤

第一步,先通过观点发现在用户评论中挖掘出多种观点。我们采用基于序列标注的模型发掘句子中的实体和观点描述,并使用依存分析模型对实体-观点的关系进行判断。

第二步,在挖掘到一定数量的观点后,再深入挖掘评论中的证据(Evidence),如下图15所示。虽然在第一步观点发现时也能找到部分观点的出处,但还有很多用户评论的观点是隐式的。比如对于“是否可以带宠物”,用户不一定在评论中直接指明,而是说“狗子在这里玩的很开心”。这就需要我们对评论语句进行深度语义理解,从而归纳其中的观点。在方案的落地过程中,最初我们使用了分类模型对观点进行分类,输入用户评论,用编码器对句子进行理解,之后各个观点的分类头判断观点正向程度。但随着自动化挖掘的观点增多,为了减少人工标注分类任务的成本,我们将其转换成了匹配任务,即输入观点标签(Tag)和用户评论,判断评论语句对该观点的支撑程度。最后,为了优化速度,我们对12层Transformer进行了裁剪,在速度提升3倍的情况下效果只降了0.8%,实现了大批量的观点离线挖掘。

图15 观点证据挖掘步骤

2.5 端到端方案的探索

在上文中,我们针对多跳、带约束等复杂问题设计了不同的方案,虽然可以在一定程度上解决问题,但系统的复杂度也随之提高。基于关系识别模块的预训练思路,我们对通用的、端到端的解决方案进行了更多的探索,并在今年的EMNLP发表了《Large-Scale Relation Learning for Question Answering over Knowledge Bases with Pre-trained Language Models》论文

对于KBQA,目前学术界有很多研究专注于图学习方法,希望用图学习来更好地表示子图,但却忽略了图谱节点本身的语义。同时,BERT类的预训练模型是在非结构化文本上训练的,而没接触过图谱的结构化数据。我们期望通过任务相关的数据来消除两者的不一致性,从而提出了三种预训练任务,如下图16所示:

图16 关系识别预训练任务

  1. Relation Extraction:基于大规模关系抽取开源数据集,生成了大量一跳( [CLS]s[SEP]h, r, t[SEP] )与两跳( [CLS]s1 , s2 [SEP]h1 , r1 , t1 (h2 ), r2 , t2 [SEP] )的文本对训练数据,让模型学习自然语言与结构化文本间的关系。
  2. Relation Matching:为了让模型更好的捕捉到关系语义,我们基于关系抽取数据生成了大量文本对,拥有相同关系的文本互为正例,否则为负例。
  3. Relation Reasoning:为了让模型具备一定的知识推理能力,我们假设图谱中的(h, r, t)缺失,并利用其他间接关系来推理(h, r, t)是否成立,输入格式为:[CLS]h, r, t[SEP]p1 [SEP] … pn [SEP]。

经过上述任务预训练后,BERT模型对于Query和结构化文本的推理能力显著提升,并且在非完全KB的情况下有更好的表现,如下图17所示:

图17 模型效果

3 应用实践

经过一年多的建设,当前KBQA服务已经接入美团的旅游、酒店、到综等多个业务,辅助商家及时回答用户问题,并提升了用户的满意度和转化率。

3.1 酒店问一问

酒店是用户出行的必备需求之一,但一些中小商家没有开通人工客服入口,无法及时回答用户信息。为满足用户对详情页内信息的快速查找,智能助理辅助未开通客服功能的酒店商家进行自动回复,提升用户下单转化率。用户可询问酒店以及房型页的各类信息,如下图18所示:

图18 酒店问一问产品示例

3.2 门票地推

门票地推致力于帮助旅游商家解决主要的卖票业务,在景区高峰时段,线上购票相比于排队更加便捷,然而仍有很多用户保持着线下购票的习惯。美团通过提过二维码以及简单的交互,提升了商户卖票以及用户购票的便捷程度。同时,我们通过在购票页内置「智能购票助手」,解决用户购票过程中的问题,帮用户更快捷地买到合适的门票,如下图19所示:

图19 门票地推产品示例

3.3 商家推荐回复

除了出行场景外,用户在浏览其他本地服务商家时也会有很多问题,比如“理发店是否需要预约?”、“店家最晚几点关门?”,这些都可以通过商家客服进行咨询。但商家本身的人力有限,难免在高峰时期迎接不暇。为了降低用户的等待时间,我们的问答服务会为商家提供话术推荐功能,如下图20所示。其中KBQA主要负责解答商家、团购相关的信息类问题。

图20 商家推荐回复产品示例

4 总结与展望

KBQA不仅是一个热门的研究方向,更是一个复杂的系统,其中涉及到实体识别、句法分析、关系识别等众多算法,不仅要关注整体准确率,更要控制延时,对算法和工程都提出了很大的挑战。经过一年多的技术的探索,我们团队不仅在美团落地多个应用,并在2020年获得了CCKS KBQA测评的A榜第一、B榜第二和技术创新奖。同时我们开放出了部分美团数据,与北大合作举办了2021年的CCKS KBQA测评。

回到技术本身,虽然目前我们的KBQA已能解决大部分头部问题,但长尾、复杂问题才是更大的挑战,接下来还有很多前沿技术值得探索,我们希望探索以下方向:

  • 无监督领域迁移:由于KBQA覆盖美团酒店、旅游到综等多个业务场景,其中到综包含十多个小领域,我们希望提升模型的Few-Shot、Zero-Shot能力,降低标注数据会造成的人力成本。
  • 业务知识增强:关系识别场景下,模型核心词聚焦到不相关的词将对模型带来严重的干扰,我们将研究如何利用先验知识注入预训练语言模型,指导修正Attention过程来提升模型表现。
  • 更多类型的复杂问题:除了上述提到的带约束和多跳问题,用户还会问比较类、多关系类问题,未来我们会对图谱构建和Query理解模块进行更多优化,解决用户的长尾问题。
  • 端到端KBQA:不管对工业界还是学术界,KBQA都是一个复杂的流程,如何利用预训练模型以及其本身的知识,简化整体流程、甚至端到端方案,是我们要持续探索的方向。

也欢迎对KBQA感兴趣的同学加入我们团队,一起探索KBQA的更多可能性!简历投递地址:wangsirui@meituan.com。

作者简介

如寐、梁迪、思睿、鸿志、明洋、武威,均来自搜索与NLP部NLP中心知识图谱组。

美团基于知识图谱的剧本杀标准化建设与应用

2021年10月28日 00:00

剧本杀作为爆发式增长的新兴业务,在商家上单、用户选购、供需匹配等方面存在不足,供给标准化能为用户、商家、平台三方创造价值,助力业务增长。本文介绍了美团到店综合业务数据团队从0到1快速建设剧本杀供给标准化的过程及算法方案。我们将美团到店综合知识图谱(GENE,GEneral NEeds net)覆盖至剧本杀行业,构建剧本杀知识图谱实现供给标准化建设,包括剧本杀供给挖掘、标准剧本库构建、供给与标准剧本关联等环节,并在多个场景进行应用落地,希望给大家带来一些帮助或启发。

一、背景

剧本杀行业近年来呈爆发式增长态势,然而由于剧本杀是新兴行业,平台已有的类目体系和产品形态,越来越难以满足飞速增长的用户和商户需求,主要表现在下面三个方面:

  • 平台类目缺失:平台缺少专门的“剧本杀”类目,中心化流量入口的缺失,导致用户决策路径混乱,难以建立统一的用户认知。
  • 用户决策效率低:剧本杀的核心是剧本,由于缺乏标准的剧本库,也未建立标准剧本和供给的关联关系,导致剧本信息展示和供给管理的规范化程度低,影响了用户对剧本选择决策的效率。
  • 商品上架繁琐:商品信息需要商户人工一一录入,没有可用的标准模板用以信息预填,导致商户在平台上架的剧本比例偏低,上架效率存在较大的提升空间。

为了解决上述痛点,业务需要进行剧本杀的供给标准化建设:首先建立“剧本杀”新类目,并完成相应的供给(包括商户、商品、内容)的类目迁移。以此为基础,以剧本为核心,搭建标准剧本库,并关联剧本杀供给,继而建立剧本维度的信息分发渠道、评价评分和榜单体系,满足用户“以剧本找店”的决策路径。

值得指出的是,供给标准化是简化用户认知、帮助用户决策、促进供需匹配的重要抓手,标准化程度的高低对平台业务规模的大小有着决定性影响。具体到剧本杀行业,供给标准化建设是助力剧本杀业务持续增长的重要基础,而标准剧本库的搭建是剧本杀供给标准化的关键。由于基于规格如「城限」、背景如「古风」、题材如「情感」等剧本属性无法确定具体的剧本,但剧本名称如「舍离」则能起唯一标识的作用。因此,标准剧本库的搭建,首先是标准剧本名称的建设,其次是规格、背景、题材、难度、流派等标准剧本属性的建设。

综上,美团到店综合业务数据团队与业务同行,助力业务进行剧本杀的供给标准化建设。在建设过程中,涉及了剧本名称、剧本属性、类目、商户、商品、内容等多种类型的实体,以及它们之间的多元化关系构建。而知识图谱作为一种揭示实体及实体间关系的语义网络,用以解决该问题显得尤为合适。特别地,我们已经构建了美团到店综合知识图谱(GENE,GEneral NEeds net),因此,我们基于GENE的构建经验快速进行剧本杀这一新业务的知识图谱构建,从0到1实现剧本杀标准化建设,从而改善供给管理和供需匹配,为用户、商户、平台三方创造出更大的价值。

二、解决方案

我们构建的GENE,围绕本地生活用户的综合性需求,以行业体系、需求对象、具象需求、场景要素和场景需求五个层次逐层递进,覆盖了玩乐、医美、教育、亲子、结婚等多个业务,体系设计和技术细节可见美团到店综合知识图谱相关的文章。剧本杀作为一项新兴的美团到店综合业务,体现了用户在玩乐上的新需求,天然适配GENE的体系结构。因此,我们将GENE覆盖至剧本杀新业务,沿用相同的思路来进行相应知识图谱的构建,以实现相应的供给标准化。

基于知识图谱来实现剧本杀标准化建设的关键,是以标准剧本为核心构建剧本杀知识图谱。图谱体系设计如图1所示,具体地,首先在行业体系层进行剧本杀新类目的构建,挖掘剧本杀供给,并建立供给(包括商户、商品、内容)与类目的从属关系。在此基础上,在需求对象层,进一步实现标准剧本名称这一核心对象节点和其剧本属性节点的挖掘以及关系构建,建立标准剧本库,最后将标准剧本库的每个标准剧本与供给和用户建立关联关系。此外,具象需求、场景要素、场景需求三层则实现了对用户在剧本杀上的具象的服务需求和场景化需求的显性表达,这部分由于与剧本杀供给标准化建设的联系不多,在这里不做展开介绍。

图 1

剧本杀知识图谱中用于供给标准化部分的具体样例如下图2所示。其中,标准剧本名称是核心节点,围绕它的各类标准剧本属性节点包括题材、规格、流派、难度、背景、别称等。同时,标准剧本之间可能构建诸如“同系列”等类型的关系,比如「舍离」和「舍离2」。此外,标准剧本还会与商品、商户、内容、用户之间建立关联关系。

我们基于剧本杀知识图谱的这些节点和关系进行供给标准化,在图谱构建过程中,包括了剧本杀供给挖掘标准剧本库构建供给与标准剧本关联三个主要步骤,下面对三个步骤的实现细节以及涉及的算法进行介绍。

图 2

三、实现方法

3.1 剧本杀供给挖掘

剧本杀作为新兴的业务,已有的行业类目树中并没有相应的类目,无法直接根据类目获取剧本杀的相关供给(包括商户、商品和内容)。因此,我们需要首先进行剧本杀供给的挖掘,即从当前与剧本杀行业相近类目的供给中挖掘出剧本杀的相关供给。

对于剧本杀的商户供给挖掘,需要判断商户是否提供剧本杀服务,判别依据包括了商户名、商品名及商品详情、商户UGC三个来源的文本语料。这个本质上是一个多源数据的分类问题,然而由于缺乏标注的训练样本,我们没有直接采用端到端的多源数据分类模型,而是依托业务输入,采用无监督匹配和有监督拟合相结合的方式高效实现,具体的判别流程如下图3所示,其中:

  • 无监督匹配:首先构造剧本杀相关的关键词词库,分别在商户名、商品名及商品详情、商户UGC三个来源的文本语料中进行精确匹配,并构建基于BERT[1]的通用语义漂移判别模型进行匹配结果过滤。最后根据业务规则基于各来源的匹配结果计算相应的匹配分数。
  • 有监督拟合:为了量化不同来源匹配分数对最终判别结果的影响,由运营先人工标注少量商户分数,用以表征商户提供剧本杀服务的强弱。在此基础上,我们构造了一个线性回归模型,拟合标注的商户分数,获取各来源的权重,从而实现对剧本杀商户的精准挖掘。

图 3

采用上述方式,实现了桌面和实景两种剧本杀商户的挖掘,准确率和召回率均达到了要求。基于剧本杀商户的挖掘结果,能够进一步对商品进行挖掘,并创建剧本杀类目,从而为后续剧本杀知识图谱构建及标准化建设打好了数据基础。

3.2 标准剧本库构建

标准剧本作为整个剧本杀知识图谱的核心,在剧本杀供给标准化建设中扮演着重要的角色。我们基于剧本杀商品相似聚合的方式,结合人工审核来挖掘标准剧本,并从相关发行方获取剧本授权,从而构建标准剧本库。标准剧本由两部分构成,一个是标准剧本名称,另一个是标准剧本属性。因此,标准剧本库构建也分为标准剧本名称的挖掘和标准剧本属性的挖掘两个部分。

3.2.1 标准剧本名称的挖掘

我们根据剧本杀商品的特点,先后采用了规则聚合、语义聚合和多模态聚合三种方法进行挖掘迭代,从数十万剧本杀商品的名称中聚合得到数千标准剧本名称。下面分别对三种聚合方法进行介绍。

规则聚合

同一个剧本杀商品在不同商户的命名往往不同,存在较多的不规范和个性化。一方面,同一个剧本名称本身就可以有多种叫法,例如「舍离」、「舍离壹」、「舍离1」就是同一个剧本;另一方面,剧本杀商品名除了包含剧本名称外,商家很多时候也会加入剧本的规格和题材等属性信息以及吸引用户的描述性文字,例如「《舍离》情感本」。所以我们首先考虑剧本杀商品的命名特点,设计相应的清洗策略对剧本杀商品名称进行清洗后再聚合。

图 4

我们除了梳理常见的非剧本词,构建词库进行规则过滤外,也尝试将其转换为命名实体识别问题[2],采用序列标注对字符进行“是剧本名”与“不是剧本名”两个类别的区分。对于清洗后的剧本杀商品名称,则通过基于最长公共子序列(LCS)的相似度计算规则,结合阈值筛选对其进行聚合,例如「舍离」、「舍离壹」、「舍离1」最后均聚在一起。整个流程如上图4所示,采用规则聚合的方式,能够在建设初期帮助业务快速对剧本杀商品名称进行聚合。

语义聚合

规则聚合的方式虽然简单好用,但由于剧本名称的多样性和复杂性,我们发现聚合结果中仍然存在一些问题:1)不属于同一个剧本的商品被聚合,例如「舍离」和「舍离2」是同一个系列的两个不同剧本,却被聚合在一起。2)属于同一个剧本的商品没有聚合,例如,商品名使用剧本的简称缩写(「唐人街名侦探和猫」和「唐探猫」)或出现错别字(「弗洛伊德之锚」和「佛洛依德之锚」)等情况时则难以规则聚合。

针对这上述这两种问题,我们进一步考虑使用商品名称语义匹配的方式,从文本语义相同的角度来进行聚合。常用的文本语义匹配模型分为交互式和双塔式两种类型。交互式是把两段文本一起输入进编码器,在编码的过程中让其相互交换信息后再进行判别;双塔式模型是用一个编码器分别给两个文本编码出向量,然后基于两个向量进行判别。

由于商品数量众多,采用交互式的方法需要将商品名称两两组合后再进行模型预测,效率较为低下,为此,我们采用双塔式的方法来实现,以Sentence-BERT[3]的模型结构为基础,将两个商品名称文本分别通过BERT提取向量后,再使用余弦距离来衡量两者的相似度,完整结构如下图5所示:

图 5

在训练模型的过程中,我们首先基于规则聚合的结果,通过同聚簇内生成正例和跨聚簇交叉生成负例的方式,构造粗粒度的训练样本,完成初版模型的训练。在此基础上,进一步结合主动学习,对样本数据进行完善。此外,我们还根据上文提到的规则聚合出现的两种问题,针对性的批量生成样本。具体地,通过在商品名称后添加同系列编号,以及使用错字、别字和繁体字替换等方式来实现样本的自动构造。

多模态聚合

通过语义聚合的方式实现了从商品名称文本语义层面的同义聚合,然而我们通过对聚合结果再分析后发现还存在一些问题:两个商品属于同一个剧本,但仅从商品名称的角度是无法判别。例如,「舍离2」和「断念」从语义的角度无法聚合,但是它们本质上是一个剧本「舍离2·断念」。虽然这两个商品的名称各异,但是它们的图像往往是相同或相似的,为此,我们考虑引入商品的图像信息来进行辅助聚合。

一个简单的方法是,使用CV领域成熟的预训练模型作为图像编码器进行特征提取,直接计算两个商品的图像相似度。为了统一商品图像相似度计算和商品名称语义匹配的结果,我们尝试构建一个剧本杀商品的多模态匹配模型,充分利用商品名称和图像信息来进行匹配。模型沿用语义聚合中使用的双塔式结构,整体结构如下图6所示:

图 6

在多模态匹配模型中,剧本杀商品的名称和图像分别通过文本编码器和图像编码器得到对应的向量表示后,再进行拼接作为最终的商品向量,最后使用余弦相似度来衡量商品之间的相似度。其中:

  • 文本编码器:使用文本预训练模型BERT[1]作为文本编码器,将输出平均池化后作为文本的向量表示。
  • 图像编码器:使用图像预训练模型EfficientNet[4]作为图像编码器,提取网络最后一层输出作为图像的向量表示。

在训练模型的过程中,文本编码器会进行Finetune,而图像编码器则固定参数,不参与训练。对于训练样本构建,我们以语义聚合的结果为基础,以商品图像相似度来圈定人工标注样本的范围。具体地,对于同聚簇内商品图像相似度高的直接生成正例,跨聚簇交叉的商品图像相似度低的直接生成负例,而对于剩余的样本对则交由人工进行标注确定。通过多模态聚合,弥补了仅使用文本匹配的不足,与其相比准确率提升了5%,进一步提升了标准剧本的挖掘效果。

3.2.2 标准剧本属性的挖掘

标准剧本的属性包括了剧本的背景、规格、流派、题材、难度等十余个维度。由于商户在剧本杀商品上单的时候会录入商品的这些属性值,所以对于标准剧本属性的挖掘,本质上是对该标准剧本对应的所有聚合商品的属性的挖掘。

在实际过程中,我们通过投票统计的方式来进行挖掘,即对于标准剧本的某个属性,通过对应的聚合商品在该属性上的属性值进行投票,选择投票最高的属性值,作为该标准剧本的候选属性值,最后由人工审核确认。此外,在标准剧本名称挖掘的过程中,我们发现同一个剧本的叫法多种多样,为了对标准剧本能有更好的描述,还进一步为标准剧本增加了一个别称的属性,通过对标准剧本对应的所有聚合商品的名称进行清洗和去重来获取。

3.3 供给与标准剧本关联

在完成标准剧本库构建后,还需要建立剧本杀的商品、商户和内容三种供给,与标准剧本的关联关系,从而使剧本杀的供给实现标准化。由于通过商品和标准剧本的关联关系,可以直接获取该商品对应商户和标准剧本的关系,所以我们只需要对商品和内容进行标准剧本关联。

3.3.1 商品关联

在3.2节中,我们通过聚合存量剧本杀商品的方式来进行标准剧本的挖掘,在这个过程中其实已经构建了存量商品和标准剧本的关联关系。对于后续新增加的商品,我们还需要将其和标准剧本进行匹配,以建立两者之间的关联关系。而对于与标准剧本无法关联的商品,我们则自动进行标准剧本名称和属性的挖掘,经由人工审核后再加入标准剧本库。

整个商品关联流程如下图7所示,首先对商品名称进行清洗再进行匹配关联。在匹配环节,我们基于商品和标准剧本的名称及图像的多模态信息,对两者进行匹配判别。

图 7

与商品之间的匹配不同,商品与标准剧本的关联不需要保持匹配的对称性。为了保证关联的效果,我们在3.2.1节的多模态匹配模型的结构基础上进行修改,将商品和标准剧本的向量拼接后通过全连接层和softmax层计算两者关联的概率。训练样本则直接根据存量商品和标准剧本的关联关系构造。通过商品关联,我们实现了绝大部分剧本杀商品的标准化。

3.3.2 内容关联

对于剧本杀内容关联标准剧本,主要针对用户产生的内容(UGC,例如用户评价)这一类型的内容和标准剧本的关联。由于一段UGC文本通常包含多个句子,且其中只有部分句子会提及标准剧本相关信息,所以我们将UGC与标准剧本的匹配,细化为其子句粒度的匹配,同时出于效率和效果的平衡的考虑,进一步将匹配过程分为了召回和排序两个阶段,如下图8所示:

图 8

在召回阶段,将UGC文本进行子句拆分,并根据标准剧本名称及其别称,在子句集合中进行精确匹配,对于匹配中的子句则将进入到排序阶段进行精细化的关联关系判别。

在排序阶段,将关联关系判别转换为一个Aspect-based的分类问题,参考属性级情感分类的做法[5],构建基于BERT句间关系分类的匹配模型,将实际命中UGC子句的标准剧本别称和对应的UGC子句用[SEP]相连后输入,通过在BERT后增加全连接层和softmax层来实现是否关联的二分类,最后对模型输出的分类概率进行阈值筛选,获取UGC关联的标准剧本。

与上文中涉及的模型训练不同,UGC和标准剧本的匹配模型无法快速获取大量训练样本。考虑到训练样本的缺乏,所以首先通过人工少量标注数百个样本,在此基础上,除了采用主动学习外,我们还尝试对比学习,基于Regularized Dropout[6]方法,对模型两次Dropout的输出进行正则约束。最终在训练样本不到1K的情况下,UGC关联标准剧本的准确率达到上线要求,每个标准剧本关联的UGC数量也得到了大幅提升。

四、应用实践

当前剧本杀知识图谱,以数千标准剧本为核心,关联百万供给。剧本杀供给标准化建设的结果已在美团多个业务场景上进行了初步的应用实践。下面介绍具体的应用方式和应用效果。

4.1 类目构建

通过剧本杀供给挖掘,帮助业务识别出剧本杀商户,从而助力剧本杀新类目和相应剧本杀列表页的构建。剧本杀类目迁移、休闲娱乐频道页的剧本杀入口、剧本杀列表页均已上线,其中,频道页剧本杀ICON固定第三行首位,提供了中心化流量入口,有助于建立统一的用户认知。上线示例如图9所示((a)休闲娱乐频道页剧本杀入口,(b)剧本杀列表页)。

图 9

4.2 个性化推荐

剧本杀知识图谱包含的标准剧本及属性节点,以及其与供给和用户的关联关系,可应用于剧本杀各页面的推荐位。一方面应用于剧本列表页热门剧本推荐(图10(a)),另一方面还应用于剧本详情页的商品在拼场次推荐(图10(b)左)、可玩门店推荐(图10(b)左)和相关剧本推荐模块(图10(b)右)。这些推荐位的应用,帮助培养了用户在平台找剧本的心智,优化了用户认知和选购体验,提高了用户和供给的匹配效率。

图10

以剧本列表页的热门剧本推荐模块为例,剧本杀知识图谱包含的节点和关系除了可以直接用于剧本的召回,还可以进一步在精排阶段进行应用。在精排中,我们基于剧本杀知识图谱,结合用户行为,参考Deep Interest Network(DIN)[7]模型结构,尝试对用户访问剧本的序列和访问商品的序列进行建模,构建双通道DIN模型,深度刻画用户兴趣,实现剧本的个性化分发。其中商品访问序列部分,通过商品与标准剧本的关联关系将其转为为剧本序列,与候选剧本采用Attention方式进行建模,具体模型结构如下图11所示:

图 11

4.3 信息外露和筛选

基于剧本杀知识图谱中的节点和关系,在剧本杀列表页和在剧本列表页增加相关标签筛选项,并外露剧本的属性和关联的供给信息,相关应用如下图12所示。这些标签筛选项和信息的外露,为用户提供了规范的信息展示,降低了用户决策成本,更加方便了用户选店和选剧本。

图 12

4.4 评分和榜单

在剧本详情页,内容和标准剧本的关联关系参与到剧本的评分计算中(图13(a))。在此基础上,基于剧本维度,形成经典必玩和近期热门的剧本榜单,如图13(b)所示,从而为用户的剧本选择决策提供了更多的帮助。

图 13

五、总结展望

面对剧本杀这一新兴行业,我们快速响应业务,以标准剧本为核心节点,结合行业特点,通过剧本杀供给挖掘、标准剧本库构建、供给与标准剧本关联,构建相应的知识图谱,从0到1逐步推进剧本杀的供给标准化建设,力求以简单而有效的方法来解决剧本杀业务的问题。

目前剧本杀知识图谱已在剧本杀多个业务场景中取得应用成果,赋能剧本杀业务持续增长,显著提升了用户体验。在未来的工作中,我们将不断进行优化和探索:

  • 标准剧本库的持续完善:优化标准剧本名称和属性以及相应的供给关联关系,保证标准剧本库的质与量俱佳,并尝试引入外部的知识补充当前的标准化结果。
  • 剧本杀场景化:当前剧本杀知识图谱主要以“剧本”这类用户的具象需求对象为主,后续将深入挖掘用户的场景化需求,探索剧本杀和其他行业的联动,更好的助力剧本杀行业的发展。
  • 更多的应用探索:将图谱数据应用于搜索等模块,在更多的应用场景中提升供给匹配效率,从而创造出更大的价值。

参考文献

[1] Devlin J, Chang M W, Lee K, et al. Bert: Pre-training of deep bidirectional transformers for language understanding[J]. arXiv preprint arXiv:1810.04805, 2018.

[2] Lample G, Ballesteros M, Subramanian S, et al. Neural architectures for named entity recognition[J]. arXiv preprint arXiv:1603.01360, 2016.

[3] Reimers N, Gurevych I. Sentence-bert: Sentence embeddings using siamese bert-networks[J]. arXiv preprint arXiv:1908.10084, 2019.

[4] Tan M, Le Q. EfficientNet: Rethinking model scaling for convolutional neural networks[C]//International Conference on Machine Learning. PMLR, 2019: 6105-6114.

[5] Sun C, Huang L, Qiu X. Utilizing BERT for aspect-based sentiment analysis via constructing auxiliary sentence[J]. arXiv preprint arXiv:1903.09588, 2019.

[6] Liang X, Wu L, Li J, et al. R-Drop: Regularized Dropout for Neural Networks[J]. arXiv preprint arXiv:2106.14448, 2021.

[7] Zhou G, Zhu X, Song C, et al. Deep interest network for click-through rate prediction[C]//Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2018: 1059-1068.

作者简介

李翔、陈焕、志华、晓阳、王奇等,均来自美团到店平台技术部到综业务数据团队。

招聘信息

美团到店平台技术部-到综业务数据团队,长期招聘算法(自然语言处理/推荐算法)、数据仓库、数据科学、系统开发等岗位同学,坐标上海。欢迎感兴趣的同学发送简历至:licong.yu@meituan.com。

情感分析技术在美团的探索与应用

2021年10月20日 00:00

参考文献

  • [1] https://github.com/Meituan-Dianping/asap.
  • [2] Bu J, Ren L, Zheng S, et al. ASAP: A Chinese Review Dataset Towards Aspect Category Sentiment Analysis and Rating Prediction. In Proceedings of the 2021 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies. 2021.
  • [3] https://www.luge.ai/
  • [4] Zhang, L. , S. Wang , and B. Liu . “Deep Learning for Sentiment Analysis : A Survey.” Wiley Interdisciplinary Reviews: Data Mining and Knowledge Discovery (2018):e1253.
  • [5] Liu, Bing. “Sentiment analysis and opinion mining.” Synthesis lectures on human language technologies 5.1 (2012): 1-167.
  • [6] Peng, Haiyun, et al. “Knowing what, how and why: A near complete solution for aspect-based sentiment analysis.” In Proceedings of the AAAI Conference on Artificial Intelligence. Vol. 34. No. 05. 2020.
  • [7] Zhang, Chen, et al. “A Multi-task Learning Framework for Opinion Triplet Extraction.” In Proceedings of the 2020 Conference on Empirical Methods in Natural Language Processing: Findings. 2020.
  • [8] Yoon Kim. 2014. Convolutional neural networks for sentence classification. arXiv preprint arXiv:1408.5882.
  • [9] Peng Zhou, Wei Shi, Jun Tian, Zhenyu Qi, Bingchen Li,Hongwei Hao, and Bo Xu. 2016. Attention-based bidirectional long short-term memory networks for relation classification. In Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics (Volume 2: Short Papers), pages 207–212.
  • [10] Devlin, Jacob, et al. “Bert: Pre-training of deep bidirectional transformers for language understanding.” arXiv preprint arXiv:1810.04805 (2018).
  • [11] 杨扬、佳昊等. 美团BERT的探索和实践.
  • [12] Pontiki, Maria, et al. “Semeval-2016 task 5: Aspect based sentiment analysis.” International workshop on semantic evaluation. 2016.
  • [13] Pontiki, M. , et al. “SemEval-2014 Task 4: Aspect Based Sentiment Analysis.” In Proceedings of International Workshop on Semantic Evaluation at (2014).
  • [14] Yequan Wang, Minlie Huang, and Li Zhao. 2016. Attention-based lstm for aspect-level sentiment classification. In Proceedings of the 2016 Conference on Empirical Methods in Natural Language Processing, pages 606–615.
  • [15] Sara Sabour, Nicholas Frosst, and Geoffrey E Hinton. 2017. Dynamic routing between capsules. In Advances in neural information processing systems, pages 3856–3866.
  • [16] Chi Sun, Luyao Huang, and Xipeng Qiu. 2019. Utilizing bert for aspect-based sentiment analysis via constructing auxiliary sentence. arXiv preprint arXiv:1903.09588.
  • [17] Qingnan Jiang, Lei Chen, Ruifeng Xu, Xiang Ao, and Min Yang. 2019. A challenge dataset and effective models for aspect-based sentiment analysis. In Proceedings of the 2019 Conference on Empirical Methods in Natural Language Processing and the 9th International Joint Conference on Natural Language Processing (EMNLP-IJCNLP), pages 6281–6286.
  • [18] Wu, Zhen, et al. “Grid Tagging Scheme for End-to-End Fine-grained Opinion Extraction.” In Proceedings of the 2020 Conference on Empirical Methods in Natural Language Processing: Findings. 2020.
  • [19] Liu, Yinhan, et al. “Roberta: A robustly optimized bert pretraining approach.” arXiv preprint arXiv:1907.11692 (2019).
  • [20] Clark, Kevin, et al. “Electra: Pre-training text encoders as discriminators rather than generators.” arXiv preprint arXiv:2003.10555 (2020). 0- [21] Timothy Dozat and Christopher D. Manning. 2017.Deep biaffine attention for neural dependency parsing. In 5th International Conference on Learning Representations, ICLR 2017.

作者介绍

任磊、佳昊、张辰、杨扬、梦雪、马放、金刚、武威等,均来自美团平台搜索与NLP部NLP中心。

招聘信息

美团搜索与NLP部/NLP中心是负责美团人工智能技术研发的核心团队,使命是打造世界一流的自然语言处理核心技术和服务能力。

NLP中心长期招聘自然语言处理算法专家/机器学习算法专家,感兴趣的同学可以将简历发送至renlei04@meituan.com。具体要求如下。

岗位职责

  1. 预训练语言模型前瞻探索,包括但不限于知识驱动预训练、任务型预训练、多模态模型预训练以及跨语言预训练等方向;
  2. 负责百亿参数以上超大模型的训练与性能优化;
  3. 模型精调前瞻技术探索,包括但不限于Prompt Tuning、Adapter Tuning以及各种Parameter-efficient的迁移学习等方向;
  4. 模型inference/training压缩技术前瞻探索,包括但不限于量化、剪枝、张量分析、KD以及NAS等;
  5. 完成预训练模型在搜索、推荐、广告等业务场景中的应用并实现业务目标;
  6. 参与美团内部NLP平台建设和推广

岗位要求

  1. 2年以上相关工作经验,参与过搜索、推荐、广告至少其一领域的算法开发工作,关注行业及学界进展;
  2. 扎实的算法基础,熟悉自然语言处理、知识图谱和机器学习技术,对技术开发及应用有热情;
  3. 熟悉Python/Java等编程语言,有一定的工程能力;
  4. 熟悉Tensorflow、PyTorch等深度学习框架并有实际项目经验;
  5. 熟悉RNN/CNN/Transformer/BERT/GPT等NLP模型并有过实际项目经验;
  6. 目标感强,善于分析和发现问题,拆解简化,能够从日常工作中发现新的空间;
  7. 条理性强且有推动力,能够梳理繁杂的工作并建立有效机制,推动上下游配合完成目标。

加分项

  1. 熟悉模型训练各Optimizer基本原理,了解分布式训练基本方法与框架;
  2. 对于最新训练加速方法有所了解,例如混合精度训练、低比特训练、分布式梯度压缩等

广告深度预估技术在美团到店场景下的突破与畅想

2021年10月14日 00:00

参考文献

  • [1] Friedman J H . Greedy Function Approximation: A Gradient Boosting Machine[J]. Annals of Statistics, 2001, 29(5):1189-1232.
  • [2] Rendle S. Factorization machines[C]//2010 IEEE International conference on data mining. IEEE, 2010: 995-1000.
  • [3] HT Cheng, et al. Wide & Deep Learning for Recommender Systems, 2016
  • [4] Zhou, Guorui, et al. “Deep interest network for click-through rate prediction.” Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2018
  • [5] Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts. ACM, 2018.
  • [6] Wen, Ling, Chua. A closer look at strategies for memorization.[J]. Clavier Companion, 2014, 6(6):50-52.
  • [7] Huang J, Hu K, Tang Q, et al. Deep Position-wise Interaction Network for CTR Prediction[J]. arXiv preprint arXiv:2106.05482, 2021.
  • [8] Search-based User Interest Modeling with Lifelong Sequential Behavior Data for Click-Through Rate Prediction
  • [9] Qi, Yi, et al. “Trilateral Spatiotemporal Attention Network for User Behavior Modeling in Location-based Search”, CIKM 2021.
  • [10] Overcoming catastrophic forgetting in neural networks[J]. Proceedings of the National Academy of Sciences of the United States of America, 2017.
  • [11] M. Zinkevich. Online convex programming and generalized infinitesimal gradient ascent. In ICML, 2003.
  • [12] Optimized Cost per Click in Taobao Display Advertising[C]// the 23rd ACM SIGKDD International Conference. ACM, 2017.
  • [13] https://tech.meituan.com/2020/08/20/kdd-cup-debiasing-practice.html

招聘信息

美团到店广告平台广告算法团队立足广告场景,探索深度学习、强化学习、人工智能、大数据、知识图谱、NLP和计算机视觉前沿的技术发展,探索本地生活服务电商的价值。主要工作方向包括:

  • 触发策略:用户意图识别、广告商家数据理解,Query改写,深度匹配,相关性建模。
  • 质量预估:广告质量度建模。点击率、转化率、客单价、交易额预估。
  • 机制设计:广告排序机制、竞价机制、出价建议、流量预估、预算分配。
  • 创意优化:智能创意设计。广告图片、文字、团单、优惠信息等展示创意的优化。

岗位要求

  • 有三年以上相关工作经验,对CTR/CVR预估、NLP、图像理解,机制设计至少一方面有应用经验。
  • 熟悉常用的机器学习、深度学习、强化学习模型。
  • 具有优秀的逻辑思维能力,对解决挑战性问题充满热情,对数据敏感,善于分析/解决问题。
  • 计算机、数学相关专业硕士及以上学历。

具备以下条件优先

  • 有广告/搜索/推荐等相关业务经验。
  • 有大规模机器学习相关经验。

感兴趣的同学可投递简历至:chengxiuying@meituan.com(邮件标题请注明:美团广平算法团队)。

美团智能客服核心技术与实践

2021年9月30日 00:00

客服是在用户服务体验不完美的情况下,尽可能帮助体验顺畅进行下去的一种解决办法,是问题发生后的一种兜底方案。而智能客服能让大部分简单的问题得以快速自助解决,让复杂问题有机会被人工高效解决。在用户服务的全旅程中,美团平台/搜索与NLP部提供了问题推荐、问题理解、对话管理、答案供给、话术推荐和会话摘要等六大智能客服核心能力,以期达到低成本、高效率、高质量地与用户进行沟通的目的。本文主要介绍了美团智能客服核心技术以及在美团的实践。

1 背景

目前,美团的年交易用户量为6.3亿,服务了770万生活服务类商家。此外,在美团优选业务中还有一个很大的团长群体。美团平台涵盖吃、住、行、游、购、娱等200多个生活服务品类,在平台服务的售前、售中、售后各个环节,都有大量信息咨询、订单状态获取以及申诉投诉等沟通诉求。另外,作为一家拥有几万名员工的上市企业,员工之间亦有大量的沟通诉求。面对以上这些需求,如果都是通过人力进行实现,显然不符合公司长远发展的目标,这就需要引入智能客服。

1.1 面对不同场景的智能客服落地

首先,我们看看日常生活中几种最为常见的客服场景。

  • 售前场景:比如消费者在平台选择入住酒店,对房型价格、酒店设施、入退房政策等,下单前都有很强的信息咨询诉求。
  • 售中场景:比如外卖催单还没到,添加备注不要辣、加开发票等咨询等等,售前和售中场景主要发生在消费者和商家或平台之间。
  • 售后场景:比如外卖场景投诉菜品少送、骑手送餐超时、要求退款等,酒店场景投诉酒店到店无法入住等,售后往往涉及到客服座席、消费者、骑手和商家,需要多方协同解决。
  • 办公场景:比如IT、人力资源、财务、法务等咨询,产运研对提供的接口产品的咨询答疑,产品对销售顾问的答疑,以及销售顾问对商家的答疑等等。

1.2 面对不同人群的智能客服落地

沟通是人类的一项基本需求,在绝大多数场景下,我们对沟通的追求都是以低成本、高效率和高质量为目标,而对话机器人也需要同时满足这三点要求。目前我们按照服务的群体进行划分,智能客服落地场景大体可以分为以下四类:

  • 面向用户:提供智能客服机器人,来帮助他们自助解决大部分的问题。
  • 面向座席:用话术推荐或者会话摘要等能力来提升人工座席的工作效率,改善人工座席的工作体验。
  • 面向商家:打造商家助手来降低商家回复的费力度,改善消费者和商家的沟通体验。
  • 面向员工:通过对话机器人,可以自助给员工进行答疑,从而提升办公效率。

1.3 智能客服是什么

要回答智能客服是什么,可以先看看客服是什么。我们的理解是,客服是在用户服务体验不完美的时候,来帮助体验顺畅进行下去的一种解决办法,是问题发生后的一种兜底方案。而智能客服能让大部分简单的问题得以快速自助解决,让复杂问题有机会被人工高效解决。

上图展示的是用户服务旅程。首先,用户会通过在线打字或者拨打热线电话的方式进线寻求服务,其中在线咨询流量占比在85%以上。当用户进入到服务门户后,先是用户表达需求,然后是智能机器人响应需求,过程中机器人先要理解问题,比如是追加备注或是修改地址,还是申请退款等等,继而机器人尝试自助解决。如果解决不了,再及时地流转到人工进行兜底服务。最后,当用户离开服务时,系统会发送调查问卷,期待用户对本次服务进行评价。

2 智能客服核心技术

2.1 对话交互技术概述

智能客服背后的技术主要是以对话交互技术为核心。常见的对话任务可分为闲聊型、任务型和问答型:

  • 闲聊型:通常是不关注某项特定任务,它的主要的目标是和人进行开放领域的对话,关注点是生成流畅、合理且自然的回复。
  • 任务型:通常是帮助用户完成某项任务指令,如查找酒店、查询订单状态、解决用户的退款申请等等。用户的需求通常比较复杂,需要通过多轮交互来不断收集任务所需的必要信息,进而根据信息进行决策,执行不同的动作,最终完成用户的指令。
  • 问答型:侧重于一问一答,即直接根据用户的问题给出精准答案。问答型和任务型最本质的区别在于,系统是否需要维护一个用户目标状态的表示和是否需要一个决策过程来完成任务。

在技术实现上,通常又可以划分为检索式、生成式和任务式:

  • 检索式:主要思路是从对话语料库中找出与输入语句最匹配的回复,这些回复通常是预先存储的数据。
  • 生成式:主要思路是基于深度学习的Encoder-Decoder架构,从大量语料中习得语言能力,根据问题内容及相关实时状态信息直接生成回答话术。
  • 任务式:就是任务型对话,通常要维护一个对话状态,根据不同的对话状态决策下一步动作,是查询数据库还是回复用户等等。

闲聊、问答、任务型对话本质都是在被动地响应用户需求。在具体业务中还会有问题推荐、商品推荐等来主动引导用户交互。在美团的业务场景里主要是任务型和问答型,中间也会穿插一些闲聊,闲聊主要是打招呼或者简单情绪安抚,起到润滑人机对话的作用。

如前面用户服务流程所介绍的那样,用户的沟通对象可能有两个,除了跟机器人沟通外,还可能跟人工沟通。如果是找客服场景人工就是客服座席,如果是找商家场景人工就是商家。机器人的能力主要包括问题推荐、问题理解、对话管理以及答案供给。

目前,衡量机器人能力好坏的核心输出指标是不满意度和转人工率,分别衡量问题解决的好坏,以及能帮人工处理多少问题。而在人工辅助方面,我们提供了话术推荐和会话摘要等能力,核心指标是ATT和ACW的降低,ATT是人工和用户的平均沟通时长,ACW是人工沟通后的其它处理时长。

2.2 智能机器人——多轮对话

这是一个真实的多轮对话的例子。当用户进入到服务门户后,先选择了一个推荐的问题“如何联系骑手”,机器人给出了联系方式致电骑手。同时为了进一步厘清场景,询问用户是否收到了餐品,当用户选择“还没有收到”的时候,结合预计送达时间和当前时间,发现还未超时,给出的方案是“好的,帮用户催一下”,或者是“我再等等吧”,这时候用户选择了“我再等等吧”。

这个例子背后的机器人是怎么工作的呢?首先当用户输入“如何联系骑手”的时候,问题理解模块将它与知识库中的拓展问进行匹配,进而得到对应的标准问即意图“如何联系骑手”。然后对话管理模块根据意图“如何联系骑手”触发相应的任务流程,先查询订单接口,获取骑手电话号码,进而输出对话状态给到答案生成模块,根据模板生成最终结果,如右边的红框内容所示。在这个过程中涉及到要先有意图体系、定义好Task流程,以及订单的查询接口,这些都是业务强相关的,主要由各业务的运营团队来维护。那么,对话系统要做的是什么呢?一是将用户的输入与意图体系中的标准问进行匹配,二是完成多轮交互里面的调度。

问题理解是将用户问题与意图体系进行匹配,匹配到的拓展问所对应的标准问即用户意图。机器人的工作过程实际是要做召回和精排两件事情。召回更多地是用现有检索引擎实现,技术上更多地关注精排。

美团自研的智能客服系统是从2018年开始搭建的,在建设的过程中,我们不断地将业界最先进的技术引入到我们的系统中来,同时根据美团业务的特点,以及问题理解这个任务的特点,对这些技术进行适配。

比如说,当2018年底BERT(参见《美团BERT的探索和实践》一文)出现的时候,我们很快全量使用BERT替换原来的DSSM模型。后面,又根据美团客服对话的特点,我们将BERT进行了二次训练及在线学习改造,同时为了避免业务之间的干扰,以及通过增加知识区分性降低噪音的干扰,我们还做了多任务学习(各业务在上层为独立任务)以及多域学习(Query与拓展问匹配,改为与拓展问、标准问和答案的整体匹配),最终我们的模型为Online Learning based Multi-task Multi-Field RoBERTa。经过这样一系列技术迭代,我们的识别准确率也从最初不到80%到现在接近90%的水平。

理解了用户意图后,有些问题是可以直接给出答案解决的,而有些问题则需要进一步厘清。比如说“如何申请餐损”这个例子,不是直接告诉申请的方法,而是先厘清是哪一个订单,是否影响食用,进而厘清一些用户的诉求是部分退款还是想安排补送,从而给出不同的解决方案。这样的一个流程是跟业务强相关的,需要由业务的运营团队来进行定义。如右边任务流程树所示,我们首先提供了可视化的TaskFlow编辑工具,并且把外呼、地图以及API等都组件化,然后业务运营人员可以通过拖拽的方式来完成Task流程设计。

对话引擎在与用户的真实交互中,要完成Task内各步骤的匹配调度。比如这个例子里用户如果不是点选”可以但影响就餐了…”这条,而是自己输入说“还行,我要部分退款”,怎么办?这个意图也没有提前定义,这就需要对话引擎支持Task内各步骤的模糊匹配。我们基于Bayes Network搭建的TaskFlow Engine恰好能支持规则和概率的结合,这里的模糊匹配算法复用了问题理解模型的语义匹配能力。

这是另外一个例子,在用户问完“会员能否退订”后,机器人回复的是“无法退回”,虽然回答了这个问题,但这个时候用户很容易不满意,转而去寻找人工服务。如果这个时候我们除了给出答案外,还去厘清问题背后的真实原因,引导询问用户是“外卖红包无法使用”或者是“因换绑手机导致的问题”,基于顺承关系建模,用户大概率是这些情况,用户很有可能会选择,从而会话可以进一步进行,并给出更加精细的解决方案,也减少了用户直接转人工服务的行为。

这个引导任务称为多轮话题引导,具体做法是对会话日志中的事件共现关系以及顺承关系进行建模。如右边图所示,这里原本是要建模句子级之间的引导,考虑到句子稀疏性,我们是将其抽象到事件之间的引导,共现关系我们用的是经典的协同过滤方式建模。另外,考虑到事件之间的方向性,我们对事件之间的顺承关系进行建模,公式如下:

并通过多目标学习,同时考虑点击指标和任务指标,如在非转人工客服数据和非不满意数据上分别建模顺承关系,公式如下:

最终,我们在点击率、不满意度、转人工率层面,都取得了非常正向的收益。

美团平台涵盖吃、住、行、游、购、娱等200多个生活服务品类,当用户是从美团App或点评App等综合服务门户入口进入服务时,需要先行确定用户要咨询的是哪个业务,这里的一个任务是“判断用户Query是属于哪个业务”,该任务我们叫做领域识别。若能明确判断领域时,则直接用该领域知识来解答;当不能明确判断时,则还需要多轮对话交互与用户进行澄清。比如用户输入“我要退款”,在多个业务里都存在退款意图,这个时候就需要我们先判断是哪个业务的退款意图,如果判断置信度不高,则给出业务列表让用户自行选择来进行澄清。

领域识别模型主要是对三类数据建模:各领域知识库的有标数据、各领域大量弱监督无标数据和个性化数据。

  1. 依据从各领域知识库的有标数据中学习得到的问题理解模型信号,可以判断用户输入属于各业务各意图的可能性。
  2. 我们注意到除了美团App、点评App等综合服务入口涉及多个业务外,还有大量能够明确业务的入口,比如说订单入口,从商品详情页进来的入口,这些入口进来的对话数据是有明确业务标签信息的。因此,我们可以得到大量的弱监督的各业务领域的数据,基于这些数据我们可以训练一个一级分类模型。
  3. 同时,有些问题是需要结合用户订单状态等个性化数据才能进一步明确的。比如“我要退款”,多个业务里都会有。因此,又要结合用户状态特征一起来训练一个二级模型,最终来判断用户的输入属于哪个业务。

最终,该二级领域识别模型在满意度、转人工率以及成功转接率指标上都取得了非常不错的收益。

2.3 智能机器人——问题推荐

在介绍完多轮对话基础模块问题理解和对话管理后,接下来我们介绍一下智能机器人的另外两个模块:问题推荐和答案供给。如前面多轮对话的例子所示,当用户进入服务门户后,机器人首先是要如何引导用户精准地表达需求,这样即可降低用户迷失或者直接转人工服务,也降低了若机器人不能正确理解时带来的多轮澄清等无效交互。

该问题是一个标准的曝光点击问题,它的本质是推荐问题。我们采用了CTR预估任务经典的FM模型来作为基础模型,同时结合业务目标,期望用户点击的问题的解决方案能够解决用户问题,该问题最终定义为“曝光、点击、解决”问题,最终的模型是结合多目标学习的ESSM-FM,对有效交互的转化率、转人工率和不满意度等指标上都带来了提升。

2.4 智能机器人——答案供给

售后客服场景通常问题较集中,且问题的解决多依赖业务内部系统数据及规则,通常是业务部门维护知识库,包括意图体系、Task流程和答案等。但在售前场景,知识多来自于商户或商品本身、用户体验及评价信息等,具有用户问题开放、知识密度高、人工难以整理答案等特点。比如去哪个城市哪个景点游玩,附近有哪些酒店,酒店是否有浴缸,酒店地址在哪里等,都需要咨询”决策”,针对这些诉求,我们通过智能问答来解决咨询以及答案供给问题。

智能问答就是从美团数据中习得答案供给,来快速回答用户的问题,基于不同的数据源,我们建设了不同的问答技术。

  • 针对商家基础信息,比如问营业时间、地址、价格等,我们通过图谱问答(KBQA)来解决。利用商家基础信息构建图谱,通过问题理解模型来理解问题,进而查询图谱获取准确的答案。
  • 针对社区数据,即商户详情页中“问大家”模块的用户问用户答的社区数据,构建社区问答(Community QA)能力,通过对用户问题与问大家中的”问答对”的相似度建模,选择相似度最高的作为答案,来回答用户的一些开放性问题。
  • 针对UGC评论数据以及商户政策等无结构化数据,构建文档问答(Document QA)能力,针对用户问题利用机器阅读理解技术从文档中抽取答案,类似我们小时候语文考试中的阅读理解题,进一步回答用户的一些开放性问题。

最后,针对多个问答模块给出的答案,进行多答案来源的答案融合排序,来挑选最终的答案,此外这里还考察了答案真实性,即对“相信多数认为正确的则正确”建模。这部分的详细介绍大家可以参考《美团智能问答技术探索与实践》一文。

3 人工辅助核心技术

3.1 人工辅助——话术推荐

前文介绍的都是智能机器人技术,用户除了跟机器人沟通外,还可能是跟人工沟通。我们在客服座席职场调研过程中发现,座席在与用户的对话聊天中经常回复相似甚至相同的话术,他们一致期望提供话术推荐的能力来提高效率。此外,除了请求客服座席帮助外,很多情况下用户与商家直接沟通会使得解决问题更高效,而沟通效率不仅影响到消费者的体验,也影响到了商家的经营。比如在外卖业务中,消费者的下单率和商家的回复时长有较为明显的反比关系,无论是客服座席还是商家,都有很强的话术推荐诉求。

那么,话术推荐具体要怎么做呢?常见的做法是先准备好常用通用话术库,部分座席或商家也会准备个人常见话术库,然后系统根据用户的Query及上下文来检索最合适的话术来推荐。我们根据调查发现,这部分知识库维护得很不好,既有业务知识变更频繁导致已维护的知识很快不可用因素,也有座席或商家本身意愿不强的因素等。另外,针对新客服座席或者新商家,可用的经验更少。因此我们采用了自动记忆每个座席及其同技能组的历史聊天话术,商家及其同品类商家的历史聊天话术,根据当前输入及上下文,预测接下来可能的回复话术,无需人工进行整理,大大提升了效率。

我们将历史聊天记录构建成“N+1”QA问答对的形式建模,前N句看作问题Q,后1句作为回复话术A,整个框架就可以转化成检索式的问答模型。在召回阶段,除了文本信息召回外,我们还加入了上文多轮槽位标签,Topic标签等召回优化,排序为基于BERT的模型,加入角色信息建模,角色为用户、商家或者座席。

整个架构如上图所示,分为离线和在线两部分。另外上线后我们也加入了一层CTR预估模型来提升采纳率。当前多个业务的话术推荐平均采纳率在24%左右,覆盖率在85%左右。话术推荐特别是对新座席员工价值更大,新员工通常难以组织话术,通过采纳推荐的话术可以来缩减熟练周期,观测发现,3个月内座席员工的平均采纳率是3个月以上座席员工的3倍。

3.2 人工辅助——会话摘要

在客服场景座席跟用户沟通完后,还需要对一些必要信息进行工单纪要,包括是什么事件,事件发生的背景是什么,用户的诉求是什么,最后的处理结果是什么等等。而填写这些内容对座席来说其实是很不友好,通常需进行总结归纳,特别是有些沟通进行的时间还比较长,需要来回翻看对话历史才能正确总结。另外,为了持续对于服务产品进行改善,也需要对会话日志进行相应事件抽取及打上标签,从而方便经营分析。

这里有些问题是选择题,有些问题是填空题,比如这通会话具体聊的是哪个事件,我们提前整理有比较完整的事件体系,可以看成是个选择题,可以用分类或者语义相似度计算模型来解决。又比如说事件发生的背景,如外卖退款的背景是因餐撒了、酒店退款的背景是到店没有房间等,是个开放性问题,分析发现可以很好地从对话内容中抽取,可以用摘要抽取模型来解决。而对于处理结果,不仅仅依赖对话内容,还包括是否外呼,外呼了是否商家接通了,后续是否需要回访等等,我们实验发现生成模型更有效。具体使用的模型如上图所示,这里事件选择考虑到经常有新事件的添加,我们转成了双塔的相似度计算任务,背景抽取采用的是BERT-Sum模型,处理结果采用的是谷歌的PEGASUS模型。

04 小结与下一步计划

4.1 小结——交互立方

前面介绍了美团智能客服实践中的一些核心技术,过程中也穿插着介绍了客服座席与消费者/商家/骑手/团长等之间的沟通提效,以及消费者与商家之间的沟通提效。除了这两部分之外,在企业办公场景,其实还有员工之间、销售顾问与商家之间的大量沟通。如果一个个去做,成本高且效率低,解决方案是把智能客服中沉淀的能力进行平台化,最好“一揽子”进行解决,以固定成本来支持更多的业务需求。于是我们搭建了美团的对话平台-摩西对话平台,用“一揽子”方案以固定成本来解决各业务的智能客服需求。

4.2 小结——对话平台“摩西”

构建一个怎么样的对话平台,才能提供期望的没有NLP能力的团队也能拥有很好的对话机器人呢?首先是把对话能力工具化和流程化。如上图所示,系统可分为四层:应用场景层、解决方案层、对话能力层、平台功能层。

  • 应用场景层:在售前应用场景,一类需求是商家助手,如图中所列的美团闪购IM助手和到综IM助手,需要辅助商家输入和机器人部分接管高频问题能力;还有一类需求是在没有商家IM的场景需要智能问答来填补咨询空缺,比如图中所列的酒店问一问和景点问答搜索;另外售中、售后以及企业办公场景,各自需求也不尽相同。
  • 解决方案层:这就要求我们有几套解决方案,大概可以分为智能机器人、智能问答、商家辅助、座席辅助等。每个解决方案的对话能力要求也有所不同,这些解决方案是需要很方便地对基础对话能力进行组装,对使用方是透明的,可以拿来即用。
  • 对话能力层:前面也进行了相应的介绍,六大核心能力包括问题推荐、问题理解、对话管理、答案供给、话术推荐和会话摘要。
  • 平台功能层:此外,我们需要提供配套的运营能力,提供给业务方的运营人员来日常维护知识库、数据分析等等。

其次,提供“一揽子”的解决方案,还需要针对处在不同阶段的业务提供不同阶段的解决方案。

  • 有些业务只希望维护好常用的问答,能回答高频的问题就好,那么他们只需要维护一个入门级的机器人,只需要在意图管理模块来维护它的意图,意图的常见说法以及答案就可以了。
  • 而对于有运营资源的团队,他们希望不断地去丰富知识库来提升问答能力,这个时候可以使用知识发现模块,可以自动地从每天的日志里面发现新意图及意图的新说法,运营人员只需要每天花一点时间来确认添加及维护答案即可,这是一个进阶的业务方。
  • 还有一些高级的业务方希望调用他们业务中的API来完成复杂问题的求解。这个时候他们可以使用TaskFlow编辑引擎,在平台上直接注册业务的API,通过可视化拖拽的方式来完成Task编辑。

此外, 为了进一步方便更多的业务介入,我们也提供了一些闲聊、通用指令、地区查询等官方技能包,业务方可以直接勾选使用。另外,随着我们不断在业务中沉淀,也会有越来越多的官方行业技能包。整体方向上是逐步让业务方使用的门槛变得越来越低。

4.3 下一步计划

前文所介绍的对话系统是一种Pipeline式对话系统,按照功能划分为不同的模块,各个模块单独建模,依次串联。这种方式的好处是可以做到不同团队职责的有效分工,比如研发同学专注于建设好问题推荐模型、问题理解模型和Task引擎等;业务运营同学专注于意图体系维护、Task流程设计以及答案设计等等。它的劣势也很明显,模块耦合,误差累积,很难联合优化,进而各模块负责的同学可能会去修修补补,容易导致动作变形。

另一类建模方式是End-to-End,将Pipeline式对话系统的各个模块联合建模成一个模型,直接实现语言到语言的转变,此类方法最初应用在闲聊式对话系统里面,近期随着大规模预训练模型的快速发展,学术上也逐渐开始研究基于预训练模型的端到端任务型对话系统。它的优点是模型可以充分利用无监督人人会话,用数据驱动可以快速迭代;缺点是模型的可控性差,不易解释且缺乏干预能力。目前主要以学术研究为主,未见成熟的应用案例。

除了使用这种大量无监督的人人会话日志外,还有一种思路是基于Rule-Based TaskFlow构建规则的用户模拟器,进行交互以生成大量的对话数据,进而训练对话模型。为了保证对话系统的鲁棒性,也可使用类似对抗攻击的方法优化,可以模拟Hard User的行为,不按顺序执行TaskFlow,随机打断、跳转某个对话节点等等。

此外,通过对比分析人机对话日志和人人对话日志,人机对话比较僵硬死板,无法有效捕捉用户的情绪,而人就很擅长这方面。这在客服场景非常重要,用户往往进来就是带着负面情绪的,机器人需要有共情能力。而端到端数据驱动的对话和对话共情能力建设,也将是接下来一段时间我们尝试的重点方向。

如何优雅地记录操作日志?

2021年9月16日 00:00

操作日志几乎存在于每个系统中,而这些系统都有记录操作日志的一套 API。操作日志和系统日志不一样,操作日志必须要做到简单易懂。所以如何让操作日志不和业务逻辑耦合,如何让操作日志的内容易于理解,让操作日志的接入更加简单?上面这些都是本文要回答的问题,主要围绕着如何“优雅”地记录操作日志展开描述。

1. 操作日志的使用场景

例子

系统日志和操作日志的区别

系统日志:系统日志主要是为开发排查问题提供依据,一般打印在日志文件中;系统日志的可读性要求没那么高,日志中会包含代码的信息,比如在某个类的某一行打印了一个日志。

操作日志:主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。

操作日志的记录格式大概分为下面几种: * 单纯的文字记录,比如:2021-09-16 10:00 订单创建。 * 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号“NO.11089999”。 * 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从“金灿灿小区”修改到“银盏盏小区” ,其中涉及变量配送的原地址“金灿灿小区”和新地址“银盏盏小区”。 * 修改表单,一次会修改多个字段。

2. 实现方式

2.1 使用 Canal 监听数据库记录操作日志

Canal 是一款基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费的开源组件,通过采用监听数据库 Binlog 的方式,这样可以从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。

这种方式的优点是和业务逻辑完全分离。缺点也很明显,局限性太高,只能针对数据库的更改做操作日志记录,如果修改涉及到其他团队的 RPC 的调用,就没办法监听数据库了,举个例子:给用户发送通知,通知服务一般都是公司内部的公共组件,这时候只能在调用 RPC 的时候手工记录发送通知的操作日志了。

2.2 通过日志文件的方式记录

log.info("订单创建")
log.info("订单已经创建,订单编号:{}", orderNo)
log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区")

这种方式的操作记录需要解决三个问题。

问题一:操作人如何记录

借助 SLF4J 中的 MDC 工具类,把操作人放在日志中,然后在日志中统一打印出来。首先在用户的拦截器中把用户的标识 Put 到 MDC 中。

@Component
public class UserInterceptor extends HandlerInterceptorAdapter {
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //获取到用户标识
    String userNo = getUserNo(request);
    //把用户 ID 放到 MDC 上下文中
    MDC.put("userId", userNo);
    return super.preHandle(request, response, handler);
  }

  private String getUserNo(HttpServletRequest request) {
    // 通过 SSO 或者Cookie 或者 Auth信息获取到 当前登陆的用户信息
    return null;
  }
}

其次,把 userId 格式化到日志中,使用 %X{userId} 可以取到 MDC 中用户标识。

"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"

问题二:操作日志如何和系统日志区分开

通过配置 Log 的配置文件,把有关操作日志的 Log 单独放到一日志文件中。

//不同业务日志记录到不同的文件
logs/business.logtrueINFOACCEPTDENYlogs/业务A.%d.%i.log9010MB"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"UTF-8

然后在 Java 代码中单独的记录业务日志。

//记录特定日志的声明
private final Logger businessLog = LoggerFactory.getLogger("businessLog");
 
//日志存储
businessLog.info("修改了配送地址");

问题三:如何生成可读懂的日志文案

可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后续内容将会进行介绍。这样就可以把日志单独保存在一个文件中,然后通过日志收集可以把日志保存在 Elasticsearch 或者数据库中,接下来看下如何生成可读的操作日志。

2.3 通过 LogUtil 的方式记录日志

  LogUtil.log(orderNo, "订单创建", "小明")模板
  LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999",  "小明")
  String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”"
  LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"),  "小明")

这里解释下为什么记录操作日志的时候都绑定了一个 OrderNo,因为操作日志记录的是:某一个“时间”“谁”对“什么”做了什么“事情”。当查询业务的操作日志的时候,会查询针对这个订单的的所有操作,所以代码中加上了 OrderNo,记录操作日志的时候需要记录下操作人,所以传了操作人“小明”进来。

上面看起来问题并不大,在修改地址的业务逻辑方法中使用一行代码记录了操作日志,接下来再看一个更复杂的例子:

private OnesIssueDO updateAddress(updateDeliveryRequest request) {
    DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());
    // 更新派送信息,电话,收件人,地址
    doUpdate(request);
    String logContent = getLogContent(request, deliveryOrder);
    LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator);
    return onesIssueDO;
}

private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) {
    String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”";
    return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress);
}

可以看到上面的例子使用了两个方法代码,外加一个 getLogContent 的函数实现了操作日志的记录。当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁杂,最后导致 LogUtils.logRecord() 方法的调用存在于很多业务的代码中,而且类似 getLogContent() 这样的方法也散落在各个业务类中,对于代码的可读性和可维护性来说是一个灾难。下面介绍下如何避免这个灾难。

2.4 方法注解实现操作日志

为了解决上面问题,一般采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦,接下来看一个简单的 AOP 日志的例子。

@LogRecord(content="修改了配送地址")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。可能有同学注意到,上面的方式虽然解耦了操作日志的代码,但是记录的文案并不符合我们的预期,文案是静态的,没有包含动态的文案,因为我们需要记录的操作日志是: 用户%s修改了订单的配送地址,从“%s”修改到“%s”。接下来,我们介绍一下如何优雅地使用 AOP 生成动态的操作日志。

3. 优雅地支持 AOP 生成动态的操作日志

3.1 动态模板

一提到动态模板,就会涉及到让变量通过占位符的方式解析模板,从而达到通过注解记录操作日志的目的。模板解析的方式有很多种,这里使用了 SpEL(Spring Expression Language,Spring表达式语言)来实现。我们可以先写下期望的记录日志的方式,然后再看下能否实现这样的功能。

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

通过 SpEL 表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。 但是现在还有几个问题需要解决: * 操作日志需要知道是哪个操作人修改的订单配送地址。 * 修改订单配送地址的操作日志需要绑定在配送的订单上,从而可以根据配送订单号查询出对这个配送订单的所有操作。 * 为了在注解上记录之前的配送地址是什么,在方法签名上添加了一个和业务无关的 oldAddress 的变量,这样就不优雅了。

为了解决前两个问题,我们需要把期望的操作日志使用形式改成下面的方式:

@LogRecord(
     content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
     operator = "#request.userName", bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

修改后的代码在注解上添加两个参数,一个是操作人,一个是操作日志需要绑定的对象。但是,在普通的 Web 应用中用户信息都是保存在一个线程上下文的静态方法中,所以 operator 一般是这样的写法(假定获取当前登陆用户的方式是 UserContext.getCurrentUser())。

operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}"

这样的话,每个 @LogRecord 的注解上的操作人都是这么长一串。为了避免过多的重复代码,我们可以把注解上的 operator 参数设置为非必填,这样用户可以填写操作人。但是,如果用户不填写我们就取 UserContext 的 user(下文会介绍如何取 user )。最后,最简单的日志变成了下面的形式:

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", 
           bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

接下来,我们需要解决第三个问题:为了记录业务操作记录添加了一个 oldAddress 变量,不管怎么样这都不是一个好的实现方式,所以接下来,我们需要把 oldAddress 变量从修改地址的方法签名上去掉。但是操作日志确实需要 oldAddress 变量,怎么办呢?

要么和产品经理 PK 一下,让产品经理把文案从“修改了订单的配送地址:从 xx 修改到 yy” 改为 “修改了订单的配送地址为:yy”。但是从用户体验上来看,第一种文案更人性化一些,显然我们不会 PK 成功的。那么我们就必须要把这个 oldAddress 查询出来然后供操作日志使用了。还有一种解决办法是:把这个参数放到操作日志的线程上下文中,供注解上的模板使用。我们按照这个思路再改下操作日志的实现代码。

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这时候可以看到,LogRecordContext 解决了操作日志模板上使用方法参数以外变量的问题,同时避免了为了记录操作日志修改方法签名的设计。虽然已经比之前的代码好了些,但是依然需要在业务代码里面加了一行业务逻辑无关的代码,如果有“强迫症”的同学还可以继续往下看,接下来我们会讲解自定义函数的解决方案。下面再看另一个例子:

@LogRecord(content = "修改了订单的配送员:从“#oldDeliveryUserId”, 修改到“#request.userId”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这个操作日志的模板最后记录的内容是这样的格式:修改了订单的配送员:从 “10090”,修改到 “10099”,显然用户看到这样的操作日志是不明白的。用户对于用户 ID 是 10090 还是 10099 并不了解,用户期望看到的是:修改了订单的配送员:从“张三(18910008888)”,修改到“小明(13910006666)”。用户关心的是配送员的姓名和电话。但是我们方法中传递的参数只有配送员的 ID,没有配送员的姓名可电话。我们可以通过上面的方法,把用户的姓名和电话查询出来,然后通过 LogRecordContext 实现。

但是,“强迫症”是不期望操作日志的代码嵌入在业务逻辑中的。接下来,我们考虑另一种实现方式:自定义函数。如果我们可以通过自定义函数把用户 ID 转换为用户姓名和电话,那么就能解决这一问题,按照这个思路,我们把模板修改为下面的形式:

@LogRecord(content = "修改了订单的配送员:从“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

其中 deliveryUser 是自定义函数,使用大括号把 Spring 的 SpEL 表达式包裹起来,这样做的好处:一是把 SpEL(Spring Expression Language,Spring表达式语言)和自定义函数区分开便于解析;二是如果模板中不需要 SpEL 表达式解析可以容易的识别出来,减少 SpEL 的解析提高性能。这时候我们发现上面代码还可以优化成下面的形式:

@LogRecord(content = "修改了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这样就不需要在 modifyAddress 方法中通过 LogRecordContext.putVariable() 设置老的快递员了,通过直接新加一个自定义函数 queryOldUser() 参数把派送订单传递进去,就能查到之前的配送人了,只需要让方法的解析在 modifyAddress() 方法执行之前运行。这样的话,我们让业务代码又变得纯净了起来,同时也让“强迫症”不再感到难受了。

4. 代码实现解析

4.1 代码结构

上面的操作日志主要是通过一个 AOP 拦截器实现的,整体主要分为 AOP 模块、日志解析模块、日志保存模块、Starter 模块;组件提供了4个扩展点,分别是:自定义函数、默认处理人、业务保存和查询;业务可以根据自己的业务特性定制符合自己业务的逻辑。

4.2 模块介绍

有了上面的分析,已经得出一种我们期望的操作日志记录的方式,那么接下来看看如何实现上面的逻辑。实现主要分为下面几个步骤: * AOP 拦截逻辑 * 解析逻辑 * 模板解析 * LogContext 逻辑 * 默认的 operator 逻辑 * 自定义函数逻辑 * 默认的日志持久化逻辑 * Starter 封装逻辑

4.2.1 AOP 拦截逻辑

这块逻辑主要是一个拦截器,针对 @LogRecord 注解分析出需要记录的操作日志,然后把操作日志持久化,这里把注解命名为 @LogRecordAnnotation。接下来,我们看下注解的定义:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
    String success();

    String fail() default "";

    String operator() default "";

    String bizNo();

    String category() default "";

    String detail() default "";

    String condition() default "";
}

注解中除了上面提到参数外,还增加了 fail、category、detail、condition 等参数,这几个参数是为了满足特定的场景,后面还会给出具体的例子。

参数名描述是否必填
success操作日志的文本模板
fail操作日志失败的文本版本
operator操作日志的执行人
bizNo操作日志绑定的业务对象标识
category操作日志的种类
detail扩展参数,记录操作日志的修改详情
condition记录日志的条件

为了保持简单,组件的必填参数就两个。业务中的 AOP 逻辑大部分是使用 @Aspect 注解实现的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有问题的,组件为了兼容 Spring boot1.5 的版本我们手工实现 Spring 的 AOP 逻辑。

切面选择 AbstractBeanFactoryPointcutAdvisor 实现,切点是通过 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通过实现 MethodInterceptor 接口实现操作日志的增强逻辑。

下面是拦截器的切点逻辑:

public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
    // LogRecord的解析类
    private LogRecordOperationSource logRecordOperationSource;
    
    @Override
    public boolean matches(@NonNull Method method, @NonNull Class> targetClass) {
          // 解析 这个 method 上有没有 @LogRecordAnnotation 注解,有的话会解析出来注解上的各个参数
        return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass));
    }

    void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) {
        this.logRecordOperationSource = logRecordOperationSource;
    }
}

切面的增强逻辑主要代码如下:

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
    Method method = invocation.getMethod();
    // 记录日志
    return execute(invocation, invocation.getThis(), method, invocation.getArguments());
}

private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable {
    Class> targetClass = getTargetClass(target);
    Object ret = null;
    MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, "");
    LogRecordContext.putEmptySpan();
    Collection operations = new ArrayList();
    Map functionNameAndReturnMap = new HashMap();
    try {
        operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
        List spElTemplates = getBeforeExecuteFunctionTemplate(operations);
        //业务逻辑执行前的自定义函数解析
        functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args);
    } catch (Exception e) {
        log.error("log record parse before function exception", e);
    }
    try {
        ret = invoker.proceed();
    } catch (Exception e) {
        methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage());
    }
    try {
        if (!CollectionUtils.isEmpty(operations)) {
            recordExecute(ret, method, args, operations, targetClass,
                    methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap);
        }
    } catch (Exception t) {
        //记录日志错误不要影响业务
        log.error("log record parse exception", t);
    } finally {
        LogRecordContext.clear();
    }
    if (methodExecuteResult.throwable != null) {
        throw methodExecuteResult.throwable;
    }
    return ret;
}

拦截逻辑的流程:

可以看到,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常之后会先捕获异常,等操作日志持久化完成后再抛出异常。在业务的方法执行之前,会对提前解析的自定义函数求值,解决了前面提到的需要查询修改之前的内容。

4.2.2 解析逻辑

模板解析

Spring 3 提供了一个非常强大的功能:Spring EL,SpEL 在 Spring 产品中是作为表达式求值的核心基础模块,它本身是可以脱离 Spring 独立使用的。举个例子:

public static void main(String[] args) {
        SpelExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression("#root.purchaseName");
        Order order = new Order();
        order.setPurchaseName("张三");
        System.out.println(expression.getValue(order));
}

这个方法将打印 “张三”。LogRecord 解析的类图如下:

解析核心类LogRecordValueParser 里面封装了自定义函数和 SpEL 解析类 LogRecordExpressionEvaluator

public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {

    private Map expressionCache = new ConcurrentHashMap(64);

    private final Map targetMethodCache = new ConcurrentHashMap(64);

    public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
        return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
    }
}

LogRecordExpressionEvaluator 继承自 CachedExpressionEvaluator 类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。 下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。核心的解析逻辑是上面最后一行代码。

getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);

getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 就是类似上面例子中的 order 对象。其中 Context 的实现将会在下文介绍。

日志上下文实现

下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺利的解析方法上不存在的参数了,通过上面的 SpEL 的例子可以看出,要把方法的参数和 LogRecordContext 中的变量都放到 SpEL 的 getValue 方法的 Object 中才可以顺利的解析表达式的值。下面看下如何实现:

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
            bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

在 LogRecordValueParser 中创建了一个 EvaluationContext,用来给 SpEL 解析方法参数和 Context 中的变量。相关代码如下:


EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);

在解析的时候调用 getValue 方法传入的参数 evalContext,就是上面这个 EvaluationContext 对象。下面是 LogRecordEvaluationContext 对象的继承体系:

LogRecordEvaluationContext 做了三个事情: * 把方法的参数都放到 SpEL 解析的 RootObject 中。 * 把 LogRecordContext 中的变量都放到 RootObject 中。 * 把方法的返回值和 ErrorMsg 都放到 RootObject 中。

LogRecordEvaluationContext 的代码如下:

public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {

    public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
                                      ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
       //把方法的参数都放到 SpEL 解析的 RootObject 中
       super(rootObject, method, arguments, parameterNameDiscoverer);
       //把 LogRecordContext 中的变量都放到 RootObject 中
        Map variables = LogRecordContext.getVariables();
        if (variables != null && variables.size() > 0) {
            for (Map.Entry entry : variables.entrySet()) {
                setVariable(entry.getKey(), entry.getValue());
            }
        }
        //把方法的返回值和 ErrorMsg 都放到 RootObject 中
        setVariable("_ret", ret);
        setVariable("_errorMsg", errorMsg);
    }
}

下面是 LogRecordContext 的实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。

public class LogRecordContext {

    private static final InheritableThreadLocal>> variableMapStack = new InheritableThreadLocal();
   //其他省略....
}

上面使用了 InheritableThreadLocal,所以在线程池的场景下使用 LogRecordContext 会出现问题,如果支持线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal> 对象,而是要设置一个 Stack 结构呢?我们看一下这么做的原因是什么。

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
        bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

上面代码的执行流程如下:

看起来没有什么问题,但是使用 LogRecordAnnotation 的方法里面嵌套了另一个使用 LogRecordAnnotation 方法的时候,流程就变成下面的形式:

可以看到,当方法二执行了释放变量后,继续执行方法一的 logRecord 逻辑,此时解析的时候 ThreadLocal>的 Map 已经被释放掉,所以方法一就获取不到对应的变量了。方法一和方法二共用一个变量 Map 还有个问题是:如果方法二设置了和方法一相同的变量两个方法的变量就会被相互覆盖。所以最终 LogRecordContext 的变量的生命周期需要是下面的形式:

LogRecordContext 每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。

默认操作人逻辑

在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的定义:

public interface IOperatorGetService {

    /**
     * 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser()
     *
     * @return 转换成Operator返回
     */
    Operator getUser();
}

下面给出了从用户上下文中获取用户的例子:

public class DefaultOperatorGetServiceImpl implements IOperatorGetService {

    @Override
    public Operator getUser() {
    //UserUtils 是获取用户上下文的方法
         return Optional.ofNullable(UserUtils.getUser())
                        .map(a -> new Operator(a.getName(), a.getLogin()))
                        .orElseThrow(()->new IllegalArgumentException("user is null"));
        
    }
}

组件在解析 operator 的时候,就判断注解上的 operator 是否是空,如果注解上没有指定,我们就从 IOperatorGetService 的 getUser 方法获取了。如果都获取不到,就会报错。

String realOperatorId = "";
if (StringUtils.isEmpty(operatorId)) {
    if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
        throw new IllegalArgumentException("user is null");
    }
    realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
    spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}

自定义函数逻辑

自定义函数的类图如下:

下面是 IParseFunction 的接口定义:executeBefore 函数代表了自定义函数是否在业务代码执行之前解析,上面提到的查询修改之前的内容。

public interface IParseFunction {

  default boolean executeBefore(){
    return false;
  }

  String functionName();

  String apply(String value);
}

ParseFunctionFactory 的代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。

public class ParseFunctionFactory {
  private Map allFunctionMap;

  public ParseFunctionFactory(List parseFunctions) {
    if (CollectionUtils.isEmpty(parseFunctions)) {
      return;
    }
    allFunctionMap = new HashMap();
    for (IParseFunction parseFunction : parseFunctions) {
      if (StringUtils.isEmpty(parseFunction.functionName())) {
        continue;
      }
      allFunctionMap.put(parseFunction.functionName(), parseFunction);
    }
  }

  public IParseFunction getFunction(String functionName) {
    return allFunctionMap.get(functionName);
  }

  public boolean isBeforeFunction(String functionName) {
    return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
  }
}

DefaultFunctionServiceImpl 的逻辑就是根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。

public class DefaultFunctionServiceImpl implements IFunctionService {

  private final ParseFunctionFactory parseFunctionFactory;

  public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
    this.parseFunctionFactory = parseFunctionFactory;
  }

  @Override
  public String apply(String functionName, String value) {
    IParseFunction function = parseFunctionFactory.getFunction(functionName);
    if (function == null) {
      return value;
    }
    return function.apply(value);
  }

  @Override
  public boolean beforeFunction(String functionName) {
    return parseFunctionFactory.isBeforeFunction(functionName);
  }
}

4.2.3 日志持久化逻辑

同样在 LogRecordInterceptor 的代码中引用了 ILogRecordService,这个 Service 主要包含了日志记录的接口。

public interface ILogRecordService {
    /**
     * 保存 log
     *
     * @param logRecord 日志实体
     */
    void record(LogRecord logRecord);

}

业务可以实现这个保存接口,然后把日志保存在任何存储介质上。这里给了一个 2.2 节介绍的通过 log.info 保存在日志文件中的例子,业务可以把保存设置成异步或者同步,可以和业务放在一个事务中保证操作日志和业务的一致性,也可以新开辟一个事务,保证日志的错误不影响业务的事务。业务可以保存在 Elasticsearch、数据库或者文件中,用户可以根据日志结构和日志的存储实现相应的查询逻辑。

@Slf4j
public class DefaultLogRecordServiceImpl implements ILogRecordService {

    @Override
//    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(LogRecord logRecord) {
        log.info("【logRecord】log={}", logRecord);
    }
}

4.2.4 Starter 逻辑封装

上面逻辑代码已经介绍完毕,那么接下来需要把这些组件组装起来,然后让用户去使用。在使用这个组件的时候只需要在 Springboot 的入口上添加一个注解 @EnableLogRecord(tenant = “com.mzt.test”)。其中 tenant 代表租户,是为了多租户使用的。

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "com.mzt.test")
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

再看下 EnableLogRecord 的代码,代码中 Import 了 LogRecordConfigureSelector.class,在 LogRecordConfigureSelector 类中暴露了 LogRecordProxyAutoConfiguration 类。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogRecordConfigureSelector.class)
public @interface EnableLogRecord {

    String tenant();
    
    AdviceMode mode() default AdviceMode.PROXY;
}

LogRecordProxyAutoConfiguration 就是装配上面组件的核心类了,代码如下:

@Configuration
@Slf4j
public class LogRecordProxyAutoConfiguration implements ImportAware {

  private AnnotationAttributes enableLogRecord;


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordOperationSource logRecordOperationSource() {
    return new LogRecordOperationSource();
  }

  @Bean
  @ConditionalOnMissingBean(IFunctionService.class)
  public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) {
    return new DefaultFunctionServiceImpl(parseFunctionFactory);
  }

  @Bean
  public ParseFunctionFactory parseFunctionFactory(@Autowired List parseFunctions) {
    return new ParseFunctionFactory(parseFunctions);
  }

  @Bean
  @ConditionalOnMissingBean(IParseFunction.class)
  public DefaultParseFunction parseFunction() {
    return new DefaultParseFunction();
  }


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) {
    BeanFactoryLogRecordAdvisor advisor =
            new BeanFactoryLogRecordAdvisor();
    advisor.setLogRecordOperationSource(logRecordOperationSource());
    advisor.setAdvice(logRecordInterceptor(functionService));
    return advisor;
  }

  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) {
    LogRecordInterceptor interceptor = new LogRecordInterceptor();
    interceptor.setLogRecordOperationSource(logRecordOperationSource());
    interceptor.setTenant(enableLogRecord.getString("tenant"));
    interceptor.setFunctionService(functionService);
    return interceptor;
  }

  @Bean
  @ConditionalOnMissingBean(IOperatorGetService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public IOperatorGetService operatorGetService() {
    return new DefaultOperatorGetServiceImpl();
  }

  @Bean
  @ConditionalOnMissingBean(ILogRecordService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public ILogRecordService recordService() {
    return new DefaultLogRecordServiceImpl();
  }

  @Override
  public void setImportMetadata(AnnotationMetadata importMetadata) {
    this.enableLogRecord = AnnotationAttributes.fromMap(
            importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false));
    if (this.enableLogRecord == null) {
      log.info("@EnableCaching is not present on importing class");
    }
  }
}

这个类继承 ImportAware 是为了拿到 EnableLogRecord 上的租户属性,这个类使用变量 logRecordAdvisor 和 logRecordInterceptor 装配了 AOP,同时把自定义函数注入到了 logRecordAdvisor 中。

对外扩展类:分别是IOperatorGetServiceILogRecordServiceIParseFunction。业务可以自己实现相应的接口,因为配置了 @ConditionalOnMissingBean,所以用户的实现类会覆盖组件内的默认实现。

5. 总结

这篇文章介绍了操作日志的常见写法,以及如何让操作日志的实现更加简单、易懂;通过组件的四个模块,介绍了组件的具体实现。对于上面的组件介绍,大家如果有疑问,也欢迎在文末留言,我们会进行答疑。

6. 作者简介

站通,2020年加入美团,基础研发平台/研发质量及效率部工程师。

7. 参考资料

8. 招聘信息

美团研发质量及效率部 ,致力于建设业界一流的持续交付平台,现招聘基础组件方向相关的工程师,坐标北京/上海。欢迎感兴趣的同学加入。可投递简历至:chao.yu@meituan.com(邮件主题请注明:美团研发质量及效率部)。

新一代CTR预测服务的GPU优化实践

2021年9月9日 00:00

1 背景

CTR(Click-Through-Rate)即点击通过率,是指网络广告的点击到达率,即该广告的实际点击次数除以广告的展现量。为CTR指标服务的打分模型,一般称为CTR模型。我们可以将此概念进一步扩展到互联网应用中各种预估转化率的模型。CTR模型在推荐、搜索、广告等场景被广泛应用。相对于CV(计算机视觉)、NLP(自然语音处理)场景的模型,CTR模型的历史结构比较简单,计算量较小。美团的CTR模型一直沿用CPU推理的方式。随着近几年深度神经网络的引入,CTR模型结构逐渐趋于复杂,计算量也越来越大,CPU开始不能满足模型对于算力的需求。

而GPU拥有几千个计算核心,可以在单机内提供密集的并行计算能力,在CV、NLP等领域展示了强大的能力。通过CUDA[1]及相关API,英伟达建立了完整的GPU生态。基于此,美团基础研发平台通过一套方案将CTR模型部署到GPU上。单从模型预测阶段看,我们提供的基于英伟达T4的GPU深度优化方案,在相同成本约束下,对比CPU,提升了10倍的吞吐能力。同时,在典型的搜索精排场景中,从端到端的维度来看,整体吞吐能力提升了一倍以上。

除了提高吞吐、降低成本外,GPU方案还为CTR模型的应用带来了额外的可能。例如,在某搜索框自动补全的场景,由于天然的交互属性,时延要求非常苛刻,一般来说无法使用复杂的模型。而在GPU能力的加持下,某复杂模型的平均响应时间从15毫秒降低至6~7毫秒,已经达到了上线要求。

接下来,本文将与大家探讨美团机器学习平台提供的新一代CTR预测服务的GPU优化思路、效果、优势与不足,希望对从事相关工作的同学有所帮助或者启发。

2 CTR模型GPU推理的挑战

2.1 应用层的挑战

  1. CTR模型结构多变,包含大量业务相关的结构,同时新的SOTA模型也层出不穷,硬件供应商由于人力受限,会重点优化常用的经典结构,如ResNet。对于没有收敛的结构,官方没有端到端的优化工具可以支持。
  2. CTR模型中通常包含较大的Embedding表结构,要考虑到Embedding表存在显存放不下的情况。
  3. 在典型的推荐场景中,为了达到更快的POI曝光的目的,模型的时效性要求很高,在线模型服务需要提供增量更新模型的能力。

2.2 框架层的挑战

  1. 算子层面:目前主流的深度学习框架,如TensorFlow和PyTorch,可以说是深度学习第二代框架,它们首先要解决第一代框架Caffe的问题,Caffe有一个明显问题就是Layer的粒度过粗,导致那个时代的算法开发者都必须有“自己写自定义层”的能力。TensorFlow和PyTorch都把模型表达能力放在较高的优先级,导致算子粒度比较小,无论是对CPU还是GPU架构,都会带来很大的额外开销。
  2. 框架层面:TensorFlow和PyTorch本质都是训练框架,对算法开发者比较友好,但非部署友好。其中隐含了很多为了方便分布式训练做的设计,比如TensorFlow为了方便将Variable拆到不同的PS上,内置了Partitioned_Variable的设计。在基于GPU单机预测的场景下,这些结构也会带来额外的开销。

2.3 硬件层的挑战

第一,TensorFlow的算子粒度划分较细,导致一个模型通常由几千个算子构成,这些算子在GPU上的执行转变为对应的GPU kernel的执行。kernel是GPU上并行执行的函数。

GPU kernel大体上可以划分为传输数据、kernel启动、kernel计算等几个阶段,其中每个kernel的启动需要约10𝞵𝘀左右。大量的小算子导致每个kernel的执行时间很短,kernel启动的耗时占了大部分。相邻的kernel之间需要通过读写显存进行数据的传输,产生大量的访存开销。而GPU的访存吞吐远远低于计算吞吐,导致性能低下,GPU利用率并不高。

第二,GPU卡上包含多个计算单元,理论上,不同计算单元是可以跑不同kernel的,但实际上为了编程简单,CUDA默认假设在同一时刻一个Stream里跑同一个kernel。虽然可以通过多Stream的方式跑,但是多Steam之间又缺少细粒度的协同机制。

在经过充分调研与讨论后,我们决定第一期重点关注TensorFlow框架下如何解决常见CTR模型结构在英伟达GPU上执行效率不高的问题,我们先将问题收敛为以下两个子问题: 1. 算子粒度过细,GPU执行效率低下。 2. 模型结构多变,手工优化投入大,通用性差。

3 优化手段

为了解决上面的问题,我们对业界深度学习加速器进行了一些调研。业界比较成熟的推理优化方案主要是TensorRT/XLA/TVM。TensorRT采用手工优化,对一些定制的模型结构进行算子融合,并对计算密集型算子(如卷积)进行了高效调优。XLA是TensorFlow内置的编译优化工具,主要针对访存密集型结构,通过编译手段,实现算子的融合。TVM[2]具备较全面的优化能力,使用编译手段进行算子的融合,同时可以通过机器学习的方式实现计算密集型算子的自动调优。

经过广泛的调研和对比,我们最终选择了TVM作为优化工具。TVM通过编译手段,可以较好地应对多变的模型结构,解决了手工优化通用性差的问题。但TVM应用在业务模型也存在一系列问题:支持的算子数较少,而且目前对动态Shape的支持还不够好。针对这两个问题,我们将TVM和TensorFlow结合起来,结合CTR模型的结构特点与GPU的硬件特性,开发一系列流程,实现了对CTR模型的优化。

3.1 算子融合

通过将多个小算子融合为一个语义等价的大算子,可以有效减少GPU上的kernel数量。一方面,kernel数量减少直接降低了kernel发射的开销;另一方面,融合后的大kernel执行的计算量增加,避免了多个kernel间数据传输导致的频繁访存,提高了计算的访存比。

可以看到,上图中的左右等价结构,左侧的21个算子执行的运算,可以在1个等价算子中完成。反映到GPU的活动上,左侧至少有21个GPU kernel以及21次显存的读写,而右侧只需要执行1个kernel以及1次显存读写。对于每个融合后的算子,需要有对应的kernel实现。然而,模型的算子组合是无穷的,对每种融合后算子手工实现kernel是不现实的。TVM通过编译手段,可以自动进行算子的融合以及设备代码生成,避免了逐一手写kernel的负担。

3.1.1 TF-TVM自动切图优化

TensorFlow模型中,如果包含TVM不支持的算子,会导致无法执行TVM转换。我们的思路是将可以用TVM优化的部分切出来,转为TVM的engine,其他部分依然使用TensorFlow的算子。在XLA和TRT转换的时候也有类似问题,我们分析了TF-XLA和TF-TRT二者的实现:

  1. TF-XLA的实现方案,在Grappler[4]优化图之后,有一个POST_REWRITE_FOR_EXEC(通过这个关键字可以在源码中搜索到)阶段,在这个阶段,会执行三个针对Graph的Pass,分别是用来标记算子,封装子图,改写子图并构建LaunchOp。
  2. TF-TRT的实现方案,TF-TRT在Grappler中注册了一个优化器,在这个优化器中,找到连通子图,并将其替换为TRT Engine。

在最终方案实现上,我们参考了TF-TRT的设计。这个设计对比XLA的优势在于XLA切图方案与TensorFlow源码紧耦合,直接将XLA的三个Pass嵌入到了启动Session的主流程中。而切图策略,优化策略后续会有非常频繁的迭代,我们不希望与TensorFlow的源码太过耦合。我们扩展了TF-TVM的方案,在实际使用中我们把这个切图过程为一个独立流程。在模型部署或更新时,自动触发。

在推理阶段,优化过的子图使用TVM执行,其余的计算图使用TensorFlow原生实现执行,将两者结合共同完成模型的推理。由于TVM和TensorFlow的Runtime各自使用独立的内存管理,数据在不同框架间传输会导致额外的性能开销。为了降低这部分开销,我们打通了两个框架的底层数据结构,尽可能避免额外的数据拷贝。

3.1.2 计算图等价替换

TensorFlow模型中过多的不被TVM支持的算子会导致TF-TVM切图零碎,影响最终的优化效果。为了让TF-TVM切图尽量大且完整,以及让TVM优化过程中的融合力度更大,我们对模型中的一些复杂结构进行检测,替换为执行更高效或更易于融合的等价结构。

例如,TensorFlow原生EmbeddingLookup结构,为了支持分布式训练,会对Embedding表进行切分,产生DynamicPartition和ParallelDynamicStitch等动态算子。这些动态算子不被TVM支持,导致TF-TVM图切分过于细碎。为了让TF-TVM切图更完整,我们通过图替换,对这种结构进行修改,通过将Embedding分表提前合并,得到简化的EmbeddingLookup结构。

3.2 CPU-GPU数据传输优化

TVM优化后的子图被替换为一个节点,该节点在GPU上执行,通常有几十甚至几百个输入,该节点的前置输入(如Placeholder)通常是在CPU上执行,会涉及多次的CPU-GPU传输。频繁的小数据量传输,无法充分利用带宽。为了解决这个问题,我们对模型结构进行修改,在计算图中添加合并与拆分节点,控制切图的位置,减少数据传输的次数。

一种可能的合并方式是,对这些输入按相同的Shape和Dtype进行合并,后续进行拆分,将拆分节点切入TVM的子图一起优化。这种方式会导致一些问题,如部分子图的算子融合效果不佳;另一方面,GPU kernel函数的参数传递内存限制在4KB,对于TVM节点输入非常多的情况(如超过512个),会遇到生成代码不合法的情况。

3.3 高频子图手工优化

对于TVM无法支持的子图,我们对业务中高频使用的结构进行抽象,采用手写自定义算子的方式,进行了高效GPU实现。

例如,模型中有部分时序特征使用String类型输入,将输入的字符串转为补齐的数字Tensor,将int类型的Tensor作为下标进行Embedding操作。这部分子图的语义如图,以下简称SE结构(StringEmbedding):

这一部分结构,TensorFlow的原生实现只有基于CPU的版本,在数据量较大且并行度较高的情景下,性能下降严重,成为整个模型的瓶颈。为了优化这部分结构的性能,我们在GPU上实现了高效的等价操作。

如图所示,PadString算子在CPU端将多个字符串按最大长度进行补齐,拼接成一个内存连续的uint8类型Tensor,以便一次性传输到GPU。StringEmbedding接收到补齐后的字符串后,利用GPU并行计算的特性,协同大量线程完成字符串的切分与查表操作。在涉及规约求和、求前缀和等关键过程中,使用了GPU上的Reduce/Scan算法,编码过程使用warp_shuffle指令,不同线程通过寄存器交换数据,避免了频繁访存的开销,获得了很好的性能。

GPU Scan算法示意,一个8个元素的前缀和操作,只需要3个迭代周期。在一个有几十路类似操作的模型中,手工优化前后的GPU timeline对比如下图,可以看到H2D + StringEmbedding这部分结构的耗时有很大的缩减,从42毫秒缩减到1.83毫秒。

除了StringEmbedding结构,我们对StringSplit + ToNumber + SparseSegmentSqrt、多路并行StringEmbedding等结构都进行了高效融合实现,在优化流程中通过结构匹配进行相应的替换。

3.4 CPU-GPU分流

实际线上的RPC请求,每个请求内的样本数(下文称Batch)是在[1,MaxValue]范围内变化的,MaxValue受上游业务系统,其他基础系统能力等多方面因素制约,相对固定。如上图所示,以某个搜索服务为例,我们统计了线上的Batch数值分布,Batch=MaxValue的请求占比约45%,Batch=45占比7.4%,Batch=1占比2.3%。其余的Batch占比从0.5%到1%不等。对于GPU来说,提高单个请求的Batch能更好地利用硬件资源,发挥GPU的并行计算能力,表现出相对CPU更优的延迟和吞吐;当Batch较小时,GPU相对CPU的优势就不明显了(下图是我们测试同样的模型在固定压力下,CPU/GPU上延迟的变化)。

大部分请求都由GPU在做了,CPU资源有较多空余,我们将一些小Batch的碎请求放在CPU运行,这样可以让整个Worker的资源利用更加均衡,提高系统整体的性能。我们根据测试设定了一个Batch阈值,以及计算图在异构硬件上区别执行的判断逻辑:对于小Batch的情况,直接在CPU上执行计算图,只有Batch超过阈值的请求才会在GPU上推理。从线上的统计数据来看,整体流量的77%跑在GPU上,23%跑在CPU上。

在GPU的一系列优化策略和动作中,Batch大小是很重要的信息,不同Batch下优化出的kernel实现可能是不同的,以达到对应workload下最优的计算性能;由于线上的流量特点,发送到GPU的请求Batch分布比较细碎,如果我们针对每个Batch都优化一个模型的kernel实现显然是不够经济和通用的。因此,我们设计了一个Batch分桶策略,生成N个固定Batch的优化模型,在实际请求到来时找到Batch距离最近的一个Bucket,将请求向上Padding到对应的Batch计算,从而提高了GPU的利用效率。

4 压测性能分析

我们选取一个模型进行线上性能压测分析。

  • CPU模型测试环境为16核Intel® Xeon® Gold 5218 CPU @ 2.30GHz,16G内存。
  • GPU模型测试环境为8核Intel® Xeon® Gold 5218 CPU @ 2.30GHz,Tesla T4 GPU,16G内存。

下图对比了在不同的QPS下(x轴),GPU模型在各BatchSize下的推理时延(y轴)。GPU模型在BatchSize=128以下,推理耗时差异不明显,较大的BatchSize更有利于吞吐;对比BatchSize=256的GPU模型与BatchSize为25的CPU模型,在QPS低于64的情况下,二者推理耗时基本持平;QPS超过64的情况下,GPU的推理时延低于CPU。GPU的吞吐相比CPU提升了10倍。

同时,我们可以看到不同曲线的陡峭程度,CPU在QPS高出64后,时延会迅速上升,GPU则依然保持平稳,直到QPS超过128才会有明显上升,但仍旧比CPU更平稳。

5 整体架构

针对CTR模型的结构特点,我们抽象出了一套平台化的通用优化流程。通过对模型结构的分析,自动应用合适的优化策略,通过性能评估和一致性校验,保证模型的优化效果。

6 不足之处与未来规划

在易用性层面,目前的方案形式是提供了一套在线优化脚本,用户提交模型后,自动优化部署。由于涉及对计算图结构的分析、编辑以及TVM的编译等过程,目前的模型优化耗时较长,大部分模型优化耗时在20分钟左右。后续需要考虑加速TVM编译的效率。

在通用性层面,从我们的实际应用情况来看,TVM编译优化和高性能手写算子是最主要的收益来源。手工优化很考验开发同学对业务模型的理解和GPU编程的能力。编写一个高性能的融合算子已经不太容易,要做到有一定的迁移能力和扩展性则更有难度。

总的来说,CTR模型推理在GPU上未来需要考虑的问题还有很多。除了要基于业务理解提供更好的性能外,还要考虑模型规模巨大后无法完整放入显存的问题以及支持在线模型更新的问题。

作者简介

伟龙、小卓、文魁、駃飞、小新等,均来自美团基础研发平台-机器学习预测引擎组。

参考资料

[1] CUDA C++ Programming Guide [2] TVM Documentation [3] Accelerating Inference In TF-TRT User Guide [4] TensorFlow graph optimization with Grappler

招聘信息

美团机器学习平台大量岗位持续招聘中,实习、社招均可,坐标北京/上海,欢迎感兴趣的同学加入我们,构建多领域公司级机器学习平台,帮大家吃得更好,生活更好。简历可投递至:wangxin66@meituan.com。

美团商品知识图谱的构建及应用

2021年9月2日 00:00

背景

美团大脑

近年来,人工智能正在快速地改变人们的生活,背后其实有两大技术驱动力:深度学习知识图谱。我们将深度学习归纳为隐性的模型,它通常是面向某一个具体任务,比如说下围棋、识别猫、人脸识别、语音识别等等。通常而言,在很多任务上它能够取得很优秀的结果,同时它也有一些局限性,比如说它需要海量的训练数据,以及强大的计算能力,难以进行跨任务的迁移,并且不具有较好的可解释性。在另一方面,知识图谱作为显式模型,同样也是人工智能的一大技术驱动力,它能够广泛地适用于不同的任务。相比深度学习,知识图谱中的知识可以沉淀,具有较强的可解释性,与人类的思考更加贴近,为隐式的深度模型补充了人类的知识积累,和深度学习互为补充。因此,全球很多大型的互联网公司都在知识图谱领域积极进行布局。

图1 人工智能两大驱动力

美团连接了数亿用户和数千万商户,背后也蕴含着丰富的日常生活相关知识。2018年,美团知识图谱团队开始构建美团大脑,着力于利用知识图谱技术赋能业务,进一步改善用户体验。具体来说,美团大脑会对美团业务中涉及到的千万级别商家、亿级别的菜品/商品、数十亿的用户评论,以及背后百万级别的场景进行深入的理解和结构化的知识建模,构建人、店、商品、场景之间的知识关联,从而形成生活服务领域大规模的知识图谱。现阶段,美团大脑已覆盖了数十亿实体,数百亿三元组,在餐饮、外卖、酒店、金融等场景中验证了知识图谱的有效性。

图2 美团大脑

在新零售领域的探索

美团逐步突破原有边界,在生活服务领域探索新的业务,不仅局限于通过外卖、餐饮帮大家“吃得更好”,近年来也逐步拓展到零售、出行等其他领域,帮大家“生活更好”。在零售领域中,美团先后落地了美团闪购、美团买菜、美团优选、团好货等一系列相应的业务,逐步实现“万物到家”的愿景。为了更好地支持美团的新零售业务,我们需要对背后的零售商品建立知识图谱,积累结构化数据,深入对零售领域内商品、用户、属性、场景等的理解,以便能更好地为用户提供零售商品领域内的服务。

相比于围绕商户的餐饮、外卖、酒店的等领域,零售商品领域对于知识图谱的建设和应用提出了更大的挑战。一方面,商品数量更加庞大,覆盖的领域范围也更加宽广。另一方面,商品本身所具有的显示信息往往比较稀疏,很大程度上需要结合生活中的常识知识来进行推理,方可将隐藏在背后的数十维的属性进行补齐,完成对商品完整的理解。在下图的例子中,“乐事黄瓜味”这样简单的商品描述其实就对应着丰富的隐含信息,只有对这些知识进行了结构化提取和相应的知识推理后,才能够更好的支持下游搜索、推荐等模块的优化。

图3 商品结构化信息的应用

商品图谱建设的目标

我们针对美团零售业务的特点,制定了多层级、多维度、跨业务的零售商品知识图谱体系。

图4 商品知识图谱体系

多层级

在不同业务的不同应用场景下,对于“商品”的定义会有所差别,需要对各个不同颗粒度的商品进行理解。因此,在我们的零售商品知识图谱中,建立了五层的层级体系,具体包括: - L1-商品SKU/SPU:对应业务中所售卖的商品颗粒度,是用户交易的对象,往往为商户下挂的商品,例如“望京家乐福所售卖的蒙牛低脂高钙牛奶250ml盒装”。这一层级也是作为商品图谱的最底层的基石,将业务商品库和图谱知识进行打通关联。 - L2-标准商品:描述商品本身客观事实的颗粒度,例如“蒙牛低脂高钙牛奶250ml盒装”,无论通过什么渠道在什么商户购买,商品本身并没有任何区别。商品条形码则是在标准商品这层的客观依据。在这一层级上,我们可以建模围绕标准商品的客观知识,例如同一个标准商品都会具有同样的品牌、口味、包装等属性。 - L3-抽象商品:进一步我们将标准商品向上抽象的商品系列,例如“蒙牛低脂高钙牛奶”。在这一层级中,我们不再关注商品具体的包装、规格等,将同系列的商品聚合为抽象商品,承载了用户对于商品的主观认知,包括用户对商品系列的别名俗称、品牌认知、主观评价等。 - L4-主体品类:描述商品主体的本质品类,列如“鸡蛋”、“奶油草莓”、“台式烤肠”等。这一层作为商品图谱的后台类目体系,以客观的方式对商品领域的品类进行建模,承载了用户对于商品的需求,例如各品牌各产地的鸡蛋都能够满足用户对于鸡蛋这个品类的需求。 - L5-业务类目:相比于主体品类的后台类目体系,业务类目作为前台类目体系会依据业务当前的发展阶段进行人工定义和调整,各个业务会根据当前业务阶段的特点和需求建立对应的前台类目体系。

多维度

  • 商品属性视角:围绕商品本身,我们需要有海量的属性维度来对商品进行描述。商品属性维度主要分为两类:一类是通用的属性维度,包括品牌、规格、包装、产地等;另一类是品类特有的属性维度,例如对于牛奶品类我们会关注脂肪含量(全脂/低脂/脱脂牛奶)、存储方式(常温奶、冷藏奶)等。商品属性主要是刻画了商品的客观知识,往往会建立在标准商品这一层级上。
  • 用户认知视角:除了客观的商品属性维度以外,用户往往对于商品会有一系列的主观认知,例如商品的别名俗称(“小黑瓶”、“快乐水”)、对于商品的评价(“香甜可口”、“入口即化”、“性价比高”)、商品的清单/榜单(“进口食品榜单”、“夏季消暑常备”)等维度。这些主观认知往往会建立在抽象商品这一层级上。
  • 品类/类目视角:从品类/类目的视角来看,不同品类/类目也会有各自不同的关注点。在这一层级上,我们会建模各个品类/类目下有哪些典型的品牌、用户关注哪些典型属性、不同品类的复购周期是多长时间等。

跨业务

美团大脑商品知识图谱的目标是希望能够对客观世界中的商品知识进行建模,而非局限于单个业务之中。在商品图谱的五层体系中,标准商品、抽象商品、品类体系都是与业务解耦的,围绕着客观商品所建立的,包括围绕这些层级建立的各维度数据也均是刻画了商品领域的客观知识。

在应用于各个业务当中时,我们将客观的图谱知识向上关联至业务前台类目,向下关联至业务商品SPU/SKU,则可以完成各个业务数据的接入,实现各个业务数据和客观知识之间的联通,提供更加全面的跨业务的全景数据视角。利用这样的数据,在用户方面我们可以更加全面的建模、分析用户对于业务、品类的偏好,对于价格、品质等的敏感程度,在商品方面我们可以更准确的建模各品类的复购周期、地域/季节/节日偏好等。

商品图谱建设的挑战

商品知识图谱的构建的挑战主要来源于以下三个方面:

  1. 信息来源质量低:商品本身所具有的信息比较匮乏,往往以标题和图片为主。尤其在美团闪购这样LBS的电商场景下,商户需要上传大量的商品数据,对于商品信息的录入存在很多信息不完整的情况。在标题和图片之外,商品详情虽然也蕴含着大量的知识信息,但是其质量往往参差不齐,并且结构各异,从中进行知识挖掘难度极高。
  2. 数据维度多:在商品领域有众多的数据维度需要进行建设。以商品属性部分为例,我们不仅需要建设通用属性,诸如品牌、规格、包装、口味等维度,同时还要覆盖各个品类/类目下特定关注的属性维度,诸如脂肪含量、是否含糖、电池容量等,整体会涉及到上百维的属性维度。因此,数据建设的效率问题也是一大挑战。
  3. 依赖常识/专业知识:人们在日常生活中因为有很丰富的常识知识积累,可以通过很简短的描述获取其背后隐藏的商品信息,例如在看到“乐事黄瓜”这样一个商品的时候知道其实是乐事黄瓜味的薯片、看到“唐僧肉”的时候知道其实这不是一种肉类而是一种零食。因此,我们也需要探索结合常识知识的语义理解方法。同时,在医药、个护等领域中,图谱的建设需要依赖较强的专业知识,例如疾病和药品之间的关系,并且此类关系对于准确度的要求极高,需要做到所有知识都准确无误,因此也需要较好的专家和算法相结合的方式来进行高效的图谱构建。

商品图谱建设

在了解了图谱建设的目标和挑战后,接下来我们将介绍商品图谱数据建设的具体方案。

层级体系建设

品类体系建设

本质品类描述了商品本质所属的最细类别,它聚合了一类商品,承载了用户最终的消费需求,如“高钙牛奶”、“牛肉干”等。本质品类与类目也是有一定的区别,类目是若干品类的集合,它是抽象后的品类概念,不能够明确到具体的某类商品品类上,如“乳制品”、“水果”等。

品类打标:对商品图谱的构建来说,关键的一步便是建立起商品和品类之间的关联,即对商品打上品类标签。通过商品和品类之间的关联,我们可以建立起商品库中的商品与用户需求之间的关联,进而将具体的商品展示到用户面前。下面简单介绍下品类打标方法:

  1. 品类词表构建:品类打标首先需要构建一个初步的商品品类词表。首先,我们通过对美团的各个电商业务的商品库、搜索日志、商户标签等数据源进行分词、NER、新词发现等操作,获得初步的商品候选词。然后,通过标注少量的样本进行二分类模型的训练(判断一个词是否是品类)。此外,我们通过结合主动学习的方法,从预测的结果中挑选出难以区分的样本,进行再次标注,继续迭代模型,直到模型收敛。
  2. 品类打标:首先,我们通过对商品标题进行命名实体识别,并结合上一步中的品类词表来获取商品中的候选品类,如识别“蒙牛脱脂牛奶 500ml”中的“脱脂牛奶”、“牛奶”等。然后,在获得了商品以及对应的品类之后,我们利用监督数据训练品类打标的二分类模型,输入商品的SPU_ID和候选品类TAG构成的Pair,即,对它进行是否匹配的预测。具体的,我们一方面利用结合业务中丰富的半结构化语料构建围绕标签词的统计特征,另一方面利用命名实体识别、基于BERT的语义匹配等模型产出高阶相关性特征,在此基础上,我们将上述特征输入到终判模型中进行模型训练。,tag>
  3. 品类标签后处理:在这一步中,我们对模型打上的品类进行后处理的一些策略,如基于图片相关性、结合商品标题命名实体识别结果等的品类清洗策略。

通过上述的三个步骤,我们便可以建立起商品与品类之间的联系。

品类体系:品类体系由品类和品类间关系构成。常见的品类关系包括同义词和上下位等。在构建品类体系的过程中,常用的以下几种方法来进行关系的补全。我们主要使用下面的一些方法: 1. 基于规则的品类关系挖掘。在百科等通用语料数据中,有些品类具有固定模式的描述,如“玉米又名苞谷、苞米棒子、玉蜀黍、珍珠米等”、“榴莲是著名热带水果之一”,因此,可以使用规则从中提取同义词和上下位。 2. 基于分类的品类关系挖掘。类似于上文中提到的品类打标方法,我们将同义词和上下位构建为的样本,通过在商品库、搜索日志、百科数据、UGC中挖掘的统计特征以及基于Sentence-BERT得到的语义特征,使用二分类模型进行品类关系是否成立的判断。对于训练得到的分类模型,我们同样通过主动学习的方式,选出结果中的难分样本,进行二次标注,进而不断迭代数据,提高模型性能。 3. 基于图的品类关系推理。在获得了初步的同义词、上下位关系之后,我们使用已有的这些关系构建网络,使用GAE、VGAE等方法对网络进行链路预测,从而进行图谱边关系的补全。,>

图5 商品图谱品类体系的构建

标准/抽象商品

标准商品是描述商品本身客观事实的颗粒度,和销售渠道和商户无关,而商品条形码是标准商品这层的客观依据。标品关联即将同属于某个商品条形码的业务SKU/SPU,都正确关联到该商品条形码上,从而在标准商品层级上建模相应的客观知识,例如标准商品对应的品牌、口味和包装等属性。 下面通过一个案例来说明标品关联的具体任务和方案。

案例:下图是一个公牛三米插线板的标准商品。商家录入信息的时候,会把商品直接关联到商品条码上。通过商户录入数据完成了一部分的标品关联,但这部分比例比较少,且存在大量的链接缺失,链接错误的问题。另外,不同的商家对于同样的标品,商品的标题的描述是千奇百怪的。我们的目标是补充缺失的链接,将商品关联到正确的标品上。

图6 商品图谱标品关联任务

针对标品关联任务,我们构建了商品领域的同义词判别模型:通过远监督的方式利用商户已经提供的少量有关联的数据,作为已有的知识图谱构造远监督的训练样本。在模型中,正例是置信度比较高的标品码;负例是原始数据中商品名或者图像类似但不属于同一标品的SPU。构造准确率比较高的训练样本之后,通过BERT模型进行同义词模型训练。最后,通过模型自主去噪的方式,使得最终的准确率能够达到99%以上。总体能做到品牌,规格,包装等维度敏感。

图7 商品图谱标品关联方法

抽象商品是用户认知的层面,作为用户所评论的对象,这一层对用户偏好建模更加有效。同时,在决策信息的展示上,抽象商品粒度也更符合用户认知。例如下图所示冰淇淋的排行榜中,罗列了用户认知中抽象商品对应的SKU,然后对应展示不同抽象商品的特点、推荐理由等。抽象商品层整体的构建方式,和标准商品层比较类似,采用标品关联的模型流程,并在数据构造部分进行规则上的调整。

图8 商品图谱抽象商品聚合

属性维度建设

对一个商品的全面理解,需要涵盖各个属性维度。例如“乐事黄瓜味薯片”,需要挖掘它对应的品牌、品类、口味、包装规格、标签、产地以及用户评论特色等属性,才能在商品搜索、推荐等场景中精准触达用户。商品属性挖掘的源数据主要包含商品标题、商品图片和半结构化数据三个维度。

图9 商品图谱属性建设

商品标题包含了对于商品最重要的信息维度,同时,商品标题解析模型可以应用在查询理解中,对用户快速深入理解拆分,为下游的召回排序也能提供高阶特征。因此,这里我们着重介绍一下利用商品标题进行属性抽取的方法。

商品标题解析整体可以建模成文本序列标注的任务。例如,对于商品标题“乐事黄瓜薯片”,目标是理解标题文本序列中各个成分,如乐事对应品牌,黄瓜对应口味,薯片是品类,因此我们使用命名实体识别(NER)模型进行商品标题解析。然而商品标题解析存在着三大挑战:(1)上下文信息少;(2)依赖常识知识;(3)标注数据通常有较多的噪音。为了解决前两个挑战,我们首先尝试在模型中引入了图谱信息,主要包含以下三个维度:

  • 节点信息:将图谱实体作为词典,以Soft-Lexicon方式接入,以此来缓解NER的边界切分错误问题。
  • 关联信息:商品标题解析依赖常识知识,例如在缺乏常识的情况下,仅从标题“乐事黄瓜薯片”中,我们无法确认“黄瓜”是商品品类还是口味属性。因此,我们引入知识图谱的关联数据缓解了常识知识缺失的问题:在知识图谱中,乐事和薯片之间存在着“品牌-售卖-品类”的关联关系,但是乐事跟黄瓜之间则没有直接的关系,因此可以利用图结构来缓解NER模型常识知识缺少的问题。具体来说,我们利用Graph Embedding的技术对图谱进行的嵌入表征,利用图谱的图结构信息对图谱中的单字,词进行表示,然后将包含了图谱结构信息的嵌入表示和文本语义的表征进行拼接融合,再接入到NER模型之中,使得模型能够既考虑到语义,也考虑到常识知识的信息。
  • 节点类型信息:同一个词可以代表不同的属性,比如“黄瓜”既可以作为品类又可以作为属性。因此,对图谱进行Graph Embedding建模的时候,我们根据不同的类型对实体节点进行拆分。在将图谱节点表征接入NER模型中时,再利用注意力机制根据上下文来选择更符合语义的实体类型对应的表征 ,缓解不同类型下词语含义不同的问题,实现不同类型实体的融合。

图10 商品图谱标题解析

接下来我们探讨如何缓解标注噪音的问题。在标注过程中,少标漏标或错标的问题无法避免,尤其像在商品标题NER这种标注比较复杂的问题上,尤为显著。对于标注数据中的噪音问题,采用以下方式对噪音标注优化:不再采取原先非0即1的Hard的训练方式,而是采用基于置信度数据的Soft训练方式,然后再通过Bootstrapping的方式迭代交叉验证,然后根据当前的训练集的置信度进行调整。我们通过实验验证,使用Soft训练+Bootstrapping多轮迭代的方式,在噪声比例比较大的数据集上,模型效果得到了明显提升。具体的方法可参见我们在NLPCC 2020比赛中的论文《Iterative Strategy for Named Entity Recognition with Imperfect Annotations》。

图11 基于噪音标注的NER优化

效率提升

知识图谱的构建往往是针对于各个领域维度的数据单独制定的挖掘方式。这种挖掘方式重人工,比较低效,针对每个不同的领域、每个不同的数据维度,我们都需要定制化的去建设任务相关的特征及标注数据。在商品场景下,挖掘的维度众多,因此效率方面的提高也是至关重要的。我们首先将知识挖掘任务建模为三类分类任务,包括节点建模、关系建模以及节点关联。在整个模型的训练过程中,最需要进行效率优化的其实就是上述提到的两个步骤:(1)针对任务的特征提取;(2)针对任务的数据标注。

图12 知识挖掘任务建模

针对特征提取部分,我们摒弃了针对不同挖掘任务做定制化特征挖掘的方式,而是尝试将特征和任务解耦,构建跨任务通用的图谱挖掘特征体系,利用海量的特征库来对目标的节点/关系/关联进行表征,并利用监督训练数据来进行特征的组合和选择。具体的,我们构建的图谱特征体系主要由四个类型的特征组构成: 1. 规则模板型特征主要是利用人工先验知识,融合规则模型能力。 2. 统计分布型特征,可以充分利用各类语料,基于不同语料不同层级维度进行统计。 3. 句法分析型特征则是利用NLP领域的模型能力,引入分词、词性、句法等维度特征。 4. 嵌入表示型特征,则是利用高阶模型能力,引入BERT等语义理解模型的能力。

图13 知识挖掘特征体系

针对数据标注部分,我们主要从三个角度来提升效率。 1. 通过半监督学习,充分的利用未标注的数据进行预训练。 2. 通过主动学习技术,选择对于模型来说能够提供最多信息增益的样本进行标注。 3. 利用远程监督方法,通过已有的知识构造远监督样本进行模型训练,尽可能的发挥出已有知识的价值。

人机结合-专业图谱建设

当前医药健康行业结构性正在发生变化,消费者更加倾向于使用在线医疗解决方案和药品配送服务,因此医药业务也逐渐成为了美团的重要业务之一。相比于普通商品知识图谱的建设,药品领域知识具有以下两个特点:(1)具有极强的专业性,需要有相关背景知识才能判断相应的属性维度,例如药品的适用症状等。(2)准确度要求极高,对于强专业性知识不允许出错,否则更容易导致严重后果。因此我们采用将智能模型和专家知识结合的方式来构建药品知识图谱。

药品图谱中的知识可以分为弱专业知识和强专业知识两类,弱专业知识即一般人能够较容易获取和理解的知识,例如药品的使用方法、适用人群等;而强专业知识则是需要具有专业背景的人才能够判断的知识,例如药品的主治疾病、适应症状等。由于这两类数据对专家的依赖程度不同,因此我们分别采取不同的挖掘链路:

  • 弱专业知识:对于药品图谱的弱专业知识挖掘,我们从说明书、百科知识等数据源中提取出相应的信息,并结合通过专家知识沉淀出来的规则策略,借助通用语义模型从中提取相应的知识,并通过专家的批量抽检,完成数据的建设。
  • 强专业知识:对于药品图谱的强专业知识挖掘,为了确保相关知识百分百准确,我们通过模型提取出药品相关属性维度的候选后,将这些候选知识给到专家进行全量质检。在这里,我们主要是通过算法的能力,尽可能减少专业药师在基础数据层面上的精力花费,提高专家从半结构化语料中提取专业知识的效率。

在药品这类专业性强的领域,专业知识的表述和用户习惯往往存在差异。因此我们除了挖掘强弱专业知识外,还需要填补专业知识和用户之间的差异,才能将药品图谱更好的与下游应用结合。为此,我们从用户行为日志以及领域日常对话等数据源中,挖掘了疾病、症状和功效的别名数据,以及药品通用名的俗称数据,来打通用户习惯和专业表述之间的通路。

图14 人机结合的专业知识挖掘

商品图谱的落地应用

自从谷歌将知识图谱应用于搜索引擎,并显著提升了搜索质量与用户体验,知识图谱在各垂直领域场景都扮演起了重要的角色。在美团商品领域中,我们也将商品图谱有效的应用在围绕商品业务的搜索、推荐、商家端、用户端等多个下游场景当中,接下来我们举几个典型的案例进行介绍。

结构化召回

商品图谱的数据,对于商品的理解很有帮助。例如,在商品搜索中,如用户在搜索头疼腰疼时,通过结构化的知识图谱,才能知道什么药品是有止疼功效的;用户在搜索可爱多草莓、黄瓜薯片时,需要依赖图谱的常识知识来理解用户真正需求是冰淇淋和薯片,而不是草莓和黄瓜。

图15 基于图谱的结构化召回

排序模型泛化性

图谱的类目信息、品类信息、属性信息,一方面可以作为比较强有力的相关性的判断方法和干预手段,另一方面可以提供不同粗细粒度的商品聚合能力,作为泛化性特征提供到排序模型,能有效地提升排序模型的泛化能力,对于用户行为尤为稀疏的商品领域来说则具有着更高的价值。具体的特征使用方式则包括: 1. 通过各颗粒度进行商品聚合,以ID化特征接入排序模型。 2. 在各颗粒度聚合后进行统计特征的建设。 3. 通过图嵌入表示的方式,将商品的高维向量表示和排序模型结合。

图16 基于图谱的排序优化

多模态图谱嵌入

现有的研究工作已经在多个领域中证明了,将知识图谱的数据进行嵌入表示,以高维向量表示的方式和排序模型结合,可以有效地通过引入外部知识达到缓解排序/推荐场景中数据稀疏以及冷启动问题的效果。然而,传统的图谱嵌入的工作往往忽视了知识图谱中的多模态信息,例如商品领域中我们有商品的图片、商品的标题、商家的介绍等非简单的图谱节点型的知识,这些信息的引入也可以进一步提升图谱嵌入对推荐/排序的信息增益。

图17 基于多模态图谱的推荐-背景

现有的图谱嵌入方法在应用到多模态图谱表征的时候会存在一些问题,因为在多模态场景下,图谱中边的含义不再是单纯的语义推理关系,而是存在多模态的信息补充的关系,因此我们也针对多模态图谱的特点,提出了MKG Entity Encoder和MKG Attention Layer来更好的建模多模态知识图谱,并将其表征有效的接入至推荐/排序模型中,具体方法可以参考我们在CIKM 2020发表了的论文《Multi-Modal Knowledge Graphs for Recommender Systems》。

图18 基于图谱的排序优化-模型

用户/商家端优化

商品图谱在用户端提供显式化的可解释性信息,辅助用户进行决策。具体的呈现形式包括筛选项、特色标签、榜单、推荐理由等。筛选项的维度受当前查询词对应品类下用户关注的属性类别决定,例如,当用户搜索查询词为薯片时,用户通常关注的是它的口味、包装、净含量等,我们将会根据供给数据在这些维度下的枚举值展示筛选项。商品的特色标签则来源于标题、商品详情页信息与评论数据的提取,以简洁明了的结构化数据展示商品特色。商品的推荐理由通过评论抽取与文本生成两种渠道获得,与查询词联动,以用户视角给出商品值得买的原因,而榜单数据则更为客观,以销量等真实数据,反应商品品质。

在商家端,即商家发布侧,商品图谱则提供了基于商品标题的实时预测能力,帮助商家进行类目的挂载、属性信息的完善。例如,商家填写标题“德国进口德亚脱脂纯牛奶12盒”后,商品图谱提供的在线类目预测服务可将其挂载到“食品饮料-乳制品-纯牛奶”类目,并通过实体识别服务,得到商品的“产地-德国”,“是否进口-进口”,“品牌-德亚”,“脂肪含量-脱脂”,“规格-12盒”的属性信息,预测完成后,由商家确认发布,降低商家对商品信息的维护成本,并提升发布商品的信息质量。

作者简介

雪智,凤娇,姿雯,匡俊,林森,武威等,均来自美团平台搜索与NLP部NLP中心。

招聘信息

美团大脑知识图谱团队大量岗位持续招聘中,实习、校招、社招均可,坐标北京/上海,欢迎感兴趣的同学加入我们,利用自然语言和知识图谱技术,帮大家吃得更好,生活更好。简历可投递至:caoxuezhi@meituan.com。

美团外卖实时数仓建设实践

2021年8月26日 00:00

实时数仓以端到端低延迟、SQL标准化、快速响应变化、数据统一为目标。美团外卖数据智能组总结的最佳实践是:一个通用的实时生产平台跟一个通用交互式实时分析引擎相互配合,同时满足实时和准实时业务场景。两者合理分工,互相补充,形成易开发、易维护且效率高的流水线,兼顾开发效率与生产成本,以较好的投入产出比满足业务的多样性需求。

01 实时场景

实时数据在美团外卖的场景是非常多的,主要有以下几个方面:

  • 运营层面:比如实时业务变化,实时营销效果,当日营业情况以及当日分时业务趋势分析等。
  • 生产层面:比如实时系统是否可靠,系统是否稳定,实时监控系统的健康状况等。
  • C端用户:比如搜索推荐排序,需要实时行为、特点等特征变量的生产,给用户推荐更加合理的内容。
  • 风控侧:实时风险识别、反欺诈、异常交易等,都是大量应用实时数据的场景。

02 实时技术及架构

1. 实时计算技术选型

目前,市面上已经开源的实时技术还是很多的,比较通用的有Storm、Spark Streaming以及Flink,技术同学在做选型时要根据公司的具体业务来进行部署。

美团外卖依托于美团整体的基础数据体系建设,从技术成熟度来讲,公司前几年主要用的是Storm。当时的Storm,在性能稳定性、可靠性以及扩展性上也是无可替代的。但随着Flink越来越成熟,从技术性能上以及框架设计优势上已经超越了Storm,从趋势来讲就像Spark替代MR一样,Storm也会慢慢被Flink替代。当然,从Storm迁移到Flink会有一个过程,我们目前有一些老的任务仍然运行在Storm上,也在不断推进任务迁移。

具体Storm和Flink的对比可以参考上图表格。

2. 实时架构

① Lambda架构

Lambda是比较经典的一款架构,以前实时的场景不是很多,以离线为主,当附加了实时场景后,由于离线和实时的时效性不同,导致技术生态是不一样的。而Lambda架构相当于附加了一条实时生产链路,在应用层面进行一个整合,双路生产,各自独立。在业务应用中,顺理成章成为了一种被采用的方式。

双路生产会存在一些问题,比如加工逻辑Double,开发运维也会Double,资源同样会变成两个资源链路。因为存在以上问题,所以又演进了一个Kappa架构。

② Kappa架构

Kappa从架构设计来讲,比较简单,生产统一,一套逻辑同时生产离线和实时。但是在实际应用场景有比较大的局限性,在业内直接用Kappa架构生产落地的案例不多见,且场景比较单一。这些问题在美团外卖这边同样会遇到,我们也会有自己的一些思考,将会在后面的章节进行阐述。

03 业务痛点

首先,在外卖业务上,我们遇到了一些问题和挑战。在业务早期,为了满足业务需要,一般是Case By Case地先把需求完成。业务对于实时性要求是比较高的,从时效性的维度来说,没有进行中间层沉淀的机会。在这种场景下,一般是拿到业务逻辑直接嵌入,这是能想到的简单有效的方法,在业务发展初期这种开发模式也比较常见。

如上图所示,拿到数据源后,我们会经过数据清洗、扩维,通过Storm或Flink进行业务逻辑处理,最后直接进行业务输出。把这个环节拆开来看,数据源端会重复引用相同的数据源,后面进行清洗、过滤、扩维等操作,都要重复做一遍。唯一不同的是业务的代码逻辑是不一样的,如果业务较少,这种模式还可以接受,但当后续业务量上去后,会出现谁开发谁运维的情况,维护工作量会越来越大,作业无法形成统一管理。而且所有人都在申请资源,导致资源成本急速膨胀,资源不能集约有效利用,因此要思考如何从整体来进行实时数据的建设。

04 数据特点与应用场景

那么如何来构建实时数仓呢?首先要进行拆解,有哪些数据,有哪些场景,这些场景有哪些共同特点,对于外卖场景来说一共有两大类,日志类和业务类。

  • 日志类:数据量特别大,半结构化,嵌套比较深。日志类的数据有个很大的特点,日志流一旦形成是不会变的,通过埋点的方式收集平台所有的日志,统一进行采集分发,就像一颗树,树根非常大,推到前端应用的时候,相当于从树根到树枝分叉的过程(从1到n的分解过程)。如果所有的业务都从根上找数据,看起来路径最短,但包袱太重,数据检索效率低。日志类数据一般用于生产监控和用户行为分析,时效性要求比较高,时间窗口一般是5min或10min,或截止到当前的一个状态,主要的应用是实时大屏和实时特征,例如用户每一次点击行为都能够立刻感知到等需求。
  • 业务类:主要是业务交易数据,业务系统一般是自成体系的,以Binlog日志的形式往下分发,业务系统都是事务型的,主要采用范式建模方式。特点是结构化,主体非常清晰,但数据表较多,需要多表关联才能表达完整业务,因此是一个n到1的集成加工过程。

而业务类实时处理,主要面临的以下几个难点:

  • 业务的多状态性:业务过程从开始到结束是不断变化的,比如从下单->支付->配送,业务库是在原始基础上进行变更的,Binlog会产生很多变化的日志。而业务分析更加关注最终状态,由此产生数据回撤计算的问题,例如10点下单,13点取消,但希望在10点减掉取消单。
  • 业务集成:业务分析数据一般无法通过单一主体表达,往往是很多表进行关联,才能得到想要的信息,在实时流中进行数据的合流对齐,往往需要较大的缓存处理且复杂。
  • 分析是批量的,处理过程是流式的:对单一数据,无法形成分析,因此分析对象一定是批量的,而数据加工是逐条的。

日志类和业务类的场景一般是同时存在的,交织在一起,无论是Lambda架构还是Kappa架构,单一的应用都会有一些问题。因此针对场景来选择架构与实践才更有意义。

05 实时数仓架构设计

1. 实时架构:流批结合的探索

基于以上问题,我们有自己的思考。通过流批结合的方式来应对不同的业务场景。

如上图所示,数据从日志统一采集到消息队列,再到数据流的ETL过程,作为基础数据流的建设是统一的。之后对于日志类实时特征,实时大屏类应用走实时流计算。对于Binlog类业务分析走实时OLAP批处理。

流式处理分析业务的痛点是什么?对于范式业务,Storm和Flink都需要很大的外存,来实现数据流之间的业务对齐,需要大量的计算资源。且由于外存的限制,必须进行窗口的限定策略,最终可能放弃一些数据。计算之后,一般是存到Redis里做查询支撑,且KV存储在应对分析类查询场景中也有较多局限。

实时OLAP怎么实现?有没有一种自带存储的实时计算引擎,当实时数据来了之后,可以灵活的在一定范围内自由计算,并且有一定的数据承载能力,同时支持分析查询响应呢?随着技术的发展,目前MPP引擎发展非常迅速,性能也在飞快提升,所以在这种场景下就有了一种新的可能。这里我们使用的是Doris引擎。

这种想法在业内也已经有实践,且成为一个重要探索方向。阿里基于ADB的实时OLAP方案等。

2. 实时数仓架构设计

从整个实时数仓架构来看,首先考虑的是如何管理所有的实时数据,资源如何有效整合,数据如何进行建设。

从方法论来讲,实时和离线是非常相似的。离线数仓早期的时候也是Case By Case,当数据规模涨到一定量的时候才会考虑如何治理。分层是一种非常有效的数据治理方式,所以在实时数仓如何进行管理的问题上,首先考虑的也是分层的处理逻辑,具体内容如下:

  • 数据源:在数据源的层面,离线和实时在数据源是一致的,主要分为日志类和业务类,日志类又包括用户日志、DB日志以及服务器日志等。
  • 实时明细层:在明细层,为了解决重复建设的问题,要进行统一构建,利用离线数仓的模式,建设统一的基础明细数据层,按照主题进行管理,明细层的目的是给下游提供直接可用的数据,因此要对基础层进行统一的加工,比如清洗、过滤、扩维等。
  • 汇总层:汇总层通过Flink或Storm的简洁算子直接可以算出结果,并且形成汇总指标池,所有的指标都统一在汇总层加工,所有人按照统一的规范管理建设,形成可复用的汇总结果。

总结起来,从整个实时数仓的建设角度来讲,首先数据建设的层次化要先建出来,先搭框架,然后定规范,每一层加工到什么程度,每一层用什么样的方式,当规范定义出来后,便于在生产上进行标准化的加工。由于要保证时效性,设计的时候,层次不能太多,对于实时性要求比较高的场景,基本可以走上图左侧的数据流,对于批量处理的需求,可以从实时明细层导入到实时OLAP引擎里,基于OLAP引擎自身的计算和查询能力进行快速的回撤计算,如上图右侧的数据流。

06 实时平台化建设

架构确定之后,我们后面考虑的是如何进行平台化的建设,实时平台化建设是完全附加于实时数仓管理之上进行的。

首先进行功能的抽象,把功能抽象成组件,这样就可以达到标准化的生产,系统化的保障就可以更深入的建设,对于基础加工层的清洗、过滤、合流、扩维、转换、加密、筛选等功能都可以抽象出来,基础层通过这种组件化的方式构建直接可用的数据结果流。这会产生一个问题,用户的需求多样,为了满足了这个用户,如何兼容其他的用户,因此可能会出现冗余加工的情况。从存储的维度来讲,实时数据不存历史,不会消耗过多的存储,这种冗余是可以接受的,通过冗余的方式可以提高生产效率,是一种以空间换时间思想的应用。

通过基础层的加工,数据全部沉淀到IDL层,同时写到OLAP引擎的基础层,再往上是实时汇总层计算,基于Storm、Flink或Doris,生产多维度的汇总指标,形成统一的汇总层,进行统一的存储分发。

当这些功能都有了以后,元数据管理,指标管理,数据安全性、SLA、数据质量等系统能力也会逐渐构建起来。

1. 实时基础层功能

实时基础层的建设要解决一些问题。首先是一条流重复读的问题,一条Binlog打过来,是以DB包的形式存在的,用户可能只用其中一张表,如果大家都要用,可能存在所有人都要接这个流的问题。解决方案是可以按照不同的业务解构出来,还原到基础数据流层,根据业务的需要做成范式结构,按照数仓的建模方式进行集成化的主题建设。

其次要进行组件的封装,比如基础层的清洗、过滤、扩维等功能,通过一个很简单的表达入口,让用户将逻辑写出来。数据转换环节是比较灵活的,比如从一个值转换成另外一个值,对于这种自定义逻辑表达,我们也开放了自定义组件,可以通过Java或Python开发自定义脚本,进行数据加工。

2. 实时特征生产功能

特征生产可以通过SQL语法进行逻辑表达,底层进行逻辑的适配,透传到计算引擎,屏蔽用户对计算引擎的依赖。就像对于离线场景,目前大公司很少通过代码的方式开发,除非一些特别的Case,所以基本上可以通过SQL化的方式表达。

在功能层面,把指标管理的思想融合进去,原子指标、派生指标,标准计算口径,维度选择,窗口设置等操作都可以通过配置化的方式,这样可以统一解析生产逻辑,进行统一封装。

还有一个问题,同一个源,写了很多SQL,每一次提交都会起一个数据流,比较浪费资源,我们的解决方案是,通过同一条流实现动态指标的生产,在不停服务的情况下可以动态添加指标。

所以在实时平台建设过程中,更多考虑的是如何更有效的利用资源,在哪些环节更能节约化的使用资源,这是在工程方面更多考虑的事情。

3. SLA建设

SLA主要解决两个问题,一个是端到端的SLA,一个是作业生产效率的SLA,我们采用埋点+上报的方式,由于实时流比较大,埋点要尽量简单,不能埋太多的东西,能表达业务即可,每个作业的输出统一上报到SLA监控平台,通过统一接口的形式,在每一个作业点上报所需要的信息,最后能够统计到端到端的SLA。

在实时生产中,由于链路非常长,无法控制所有链路,但是可以控制自己作业的效率,所以作业SLA也是必不可少的。

4. 实时OLAP方案

问题

  • Binlog业务还原复杂:业务变化很多,需要某个时间点的变化,因此需要进行排序,并且数据要存起来,这对于内存和CPU的资源消耗都是非常大的。
  • Binlog业务关联复杂:流式计算里,流和流之间的关联,对于业务逻辑的表达是非常困难的。

解决方案

通过带计算能力的OLAP引擎来解决,不需要把一个流进行逻辑化映射,只需要解决数据实时稳定的入库问题。

我们这边采用的是Doris作为高性能的OLAP引擎,由于业务数据产生的结果和结果之间还需要进行衍生计算,Doris可以利用Unique模型或聚合模型快速还原业务,还原业务的同时还可以进行汇总层的聚合,也是为了复用而设计。应用层可以是物理的,也可以是逻辑化视图。

这种模式重在解决业务回撤计算,比如业务状态改变,需要在历史的某个点将值变更,这种场景用流计算的成本非常大,OLAP模式可以很好的解决这个问题。

07 实时应用案例

最后通过一个案例说明,比如商家要根据用户历史下单数给用户优惠,商家需要看到历史下了多少单,历史T+1的数据要有,今天实时的数据也要有,这种场景是典型的Lambda架构。我们可以在Doris里设计一个分区表,一个是历史分区,一个是今日分区,历史分区可以通过离线的方式生产,今日指标可以通过实时的方式计算,写到今日分区里,查询的时候进行一个简单的汇总。

这种场景看起来比较简单,难点在于商家的量上来之后,很多简单的问题都会变得复杂。后续,我们也会通过更多的业务输入,沉淀出更多的业务场景,抽象出来形成统一的生产方案和功能,以最小化的实时计算资源支撑多样化的业务需求,这也是未来我们需要达到的目的。

❌