普通视图

发现新文章,点击刷新页面。
今天 — 2025年4月8日掘金专栏-得物技术

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

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

一、引言

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

TL;DR

  • 列举 Cursor 的错误预期

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

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

二、就差一个程序员了

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

2.1 从需求到代码

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

PRD → Cursor → Code(一步到位)

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

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

2.2 从规划到执行

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

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

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

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

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

三、现有问题

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

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

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

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

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

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

图片

前端方案中的各环节分布

传统的解决方案通常是:

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

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

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

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

四、协作流程

4.1 反馈循环

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

图片

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

4.2 流程对比

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

图片

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

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

五、场景应用

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

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

5.1 现状分析

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

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

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

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

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

5.2 改动方案

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

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

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

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

因此在整体流程上:

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

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

  • Cursor 提供方案

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

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

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

提示词参考:

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

5.3 影响评估

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

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

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

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

图片

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

图片

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

六、小结

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

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

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

往期回顾

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

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

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

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

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

文 / 魏命名

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

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

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

昨天以前掘金专栏-得物技术

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

作者 得物技术
2025年3月27日 11:35
我们期望的是:告诉AI我们想要的目标或者任务,AI能够理解深度理解并分析我们的意图、自动的进行任务的拆解、自动的寻找可以使用的工具、自动的进行结果数据的汇总过滤、自动的呈现符合任务的展示形式。

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

作者 得物技术
2025年3月18日 10:45
我们不仅分析了稳定性建设的重要性,还从理论角度,揭示了稳定性建设的核心要素与挑战,提供了具体的解决方案和建设任务。简单统合一下,就生成了下面的稳定性建设全景图,希望能为正在努力追求系统稳定性的小伙伴们

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

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

一、引 言

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

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

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

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

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

二、Rust核心特性

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

所有权

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

Rust所有权规则

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

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

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

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

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

共享所有权

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

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

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

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

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

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

    // 获取最终计数值
    println!("Final count: {}", *counter.lock().unwrap());
}

生命周期和引用

在 Rust 中,生命周期lifetimes)和引用references)是两个密切相关的概念,它们共同构成了 Rust 的所有权系统的重要组成部分。生命周期用于确保引用在使用时是有效的,从而防止悬空引用和数据竞争等问题。

引用

前面提到,Rust值的所有权可以被借用,它允许在不获取数据所有权的情况下访问数据。Rust中有两种类型的引用:

  • 不可变引用 (&T):允许你读取数据,但不允许修改。
  • 可变引用 (&mut T):允许你修改数据。

在使用引用的时候需要满足以下规则:

  • 在同一时间只能有一个可变引用。
  • 多个不可变引用可以同时存在,但在可变引用存在时,不能有不可变引用。
  • 每个引用都有一个生命周期,表示该引用在程序中的有效范围,且引用的生命周期不能超过被借用的值的生命周期。

生命周期

在 Rust 编程语言中,生命周期用于确保引用在使用时是有效的。生命周期的存在使得 Rust 能够在编译时检查引用的有效性,从而防止悬空引用。如下是一个Rust编译器检查生命周期的例子:

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}

这里编译器将r的生命周期记为'a,x的生命周期记为'b。可以明显看出,内部块的'b比外部块的'a生命周期小,当x离开作用域被释放时,r仍然持有x的引用。所以当把生命周期为'a的r想引用生命周期为'b的x时,编译器发现了这个问题,并拒绝通过编译,保证了程序不会出现悬垂引用。

生命周期标注

正如我们看到的,Rust的引用代表对值的一次借用,它们有着种种限制,所以,在函数中、在结构体中等等位置上使用引用时,你都要给Rust编译器一些关于引用的提示,这种提示,就是生命周期标记。对于简单的情况,聪明的Rust编译器可以自动推断出引用的生命周期。对于一些模棱两可的情况,编译器也无法推断引用是否在程序运行期间始终有效,这时就需要我们提供生命周期标注来提示编译器我们的代码是正确的,放我过去吧。

生命周期标注并没有改变传入的值和返回的值的生命周期,我们只是向借用检查器指出了一些用于检查非法调用的一些约束而已,而借用检查器并不需要知道 x、y 的具体存活时长。而事实上如果函数引用外部的变量,那么单靠 Rust 确定函数和返回值的生命周期几乎是不可能的事情。因为函数传递什么参数都是我们决定的,这样的话函数在每次调用时使用的生命周期都可能发生变化,正因如此我们才需要手动对生命周期进行标注。

相信第一次看到生命周期的小伙伴们都感觉概念非常难理解,且写出的代码非常丑,简直要逼死强迫症。但是有得就有舍,要写出安全且高效的Rust代码,就要学会理解和使用生命周期。如果实在不想用,那就多用Rc和Arc吧。

三、用Rust构建生产级应用

了解了Rust最核心的基本知识和特性后,你已经成为了一个合格的Rust练习生,可以开始用Rust愉快的进行开发工作了。但是要使用Rust开发高性能的生产级应用,只了解到这种程序是不行的。当初笔者信心满满地将第一个Rust应用发布到测试环境后,竟然发现效率比Java版本还低,于是开始了长期的瓶颈排查和调优,且调优时间远大于编码时间。最终我们的应用在相同吞吐量的条件下,CPU使用率从高于Java 20%优化到低于Java 40%。在这个过程中,也总结了一些经验进行分享。

合理利用引用减少数据拷贝

相信很多刚接触Rust的小伙伴在面对同一份数据需要在多处使用的情况时,为了逃避复杂的生命周期问题,会倾向于使用Clone来创建数据副本。如果这样做的话,一份数据在内存中重复出现多次,带来的cpu和内存消耗会让你会怀疑人生,为什么这么相信Rust的性能而不相信自己能啃下生命周期这块硬骨头呢?

有一个应用场景,我们从数据源得到若干个源数据,根据业务逻辑聚合成batch并存储到远端或者本地。聚合的逻辑可以有两种方式:

  • 将源数据的所有权移动到batch。
  • 将源数据拷贝一份到batch。

然而这两种方式都不可取。第一种方式的问题是,我们不知道一份源数据是不是只会被使用一次。而使用第二种方式则会消耗更多的CPU,且占用内存成倍上升。

前面提到,Rust的值是可以借用的,如果在batch中不获得所有权,而是存储引用,那么可以几乎零消耗的实现需求。以上述应用场景为例,这里介绍我们是怎么解决这个问题的。

首先给出源数据Data和Batch的定义:

struct Data {
    condition: bool,
    num: i32,
    msg: String
}
struct Batch<'a> {
    msgList: Vec<&'a str>
}

假设需求是将Data的msg字段在Batch里存储num次,我们很容易写出这样的代码:

fn main() {
    let batch: Batch = Batch:new();    // 初始化Batch
    loop {                            
        let data:Data = dataSource.getData();    // 从数据源获得data
        recordData(batch, &data);
        if (batch.len() > 100) {    // batch存储的数据大于100条时,存储并清空
            save(batch);
            batch.clear();
        }    // ------------------- data的生命周期到此结束
    }    // ------------------- batch的生命周期到此结束
}

fn record_data(batch: Batch, data: Data) {
    if(condition) {    // 根据条件将msg保存num次
        for i in 0..data.num {    
            batch.msgList.push(&data.msg);
        }
    }
}

看起来是不是很合理,和其他语言也没有什么区别,当信心满满按下编译后,会发现天空飘来五个字:编译不通过。原因很简单,因为编译器发现被引用对象data的生命周期小于batch,data的在当前循环结束后就会销毁,batch存储的引用就变成了野指针。我们可以做如下修改:

fn() {
    let batch: Batch = Batch:new();    // 初始化Batch
    let dataList: Vec<Data> = Vec::new();    // dataList的生命周期和batch一样
    loop {                            
        let data: Data = dataSource.getData();    // 从数据源获得data
        dataList.push(data);    // 将data保存在dataList,提升生命周期
        if(batch.len() > 100) {
            for data_ref: &Batch in dataList.iter() {
                record_data(batch, data_ref);    // 此时data的生命周期和batch相等
            }
            save(batch);
            batch.clear();
            dataList.clear();
        }
    }
}

fn record_data<'a>(batch: Batch<'a>, data: &'a Data) {
    if(condition) {    // 根据条件将msg保存num次
        for i in 0..data.num {    
            batch.msgList.push(&data.msg);
        }
    }
}

可以看到,我们对代码做了一些小改动:

  • 在循环外初始化了一个Vec,并保存每次得到的data。
  • record_data函数上增加了生命周期标注。

为什么这么做呢?我们已经知道最初版本是因为data的生命周期小于batch,导致batch不能存储data的引用。解决这个问题的思路很简单,提升data的生命周期不就完了。假设batch的生命周期是'a,data的生命周期是'b,很明显'a是大于'b的,因为batch的生命周期是整个main函数,而data的生命周期仅仅在loop内。我们在batch同样的作用域内定义一个容器,它的生命周期也是'a。在每次得到data后把它存入容器中,那data就不会在循环结束的时候被销毁了。

同时,在record_data函数定义上,我们也要使用标注告诉编译器batch和data的生命周期是相等的。如果data的生命周期大于batch,我们也可以在参数中定义data的生命周期为'a,因为实际的生命周期和参数生命周期标注无需一致,只需要实际的生命周期大于参数生命周期就行了。如果你有强迫症,也可以在参数中标注实际的生命周期,只需要加上适当的生命周期约束就行了:

// 'b: 'a表示'b的生命周期能够覆盖'a
fn record_data<'a>(batch: Batch<'a>, data: &'b Data) where 'b: 'a {
    ......
}

经过这些小改动,你的应用会比粗暴的使用拷贝提升许多性能并且节约大量内存使用。经过我们的测试,在类似需求中将需要大量拷贝的操作替换成引用,可以节省一倍的内存,CPU使用率也下降了20%。

FFI(Foreign Function Interface)

在一些情况下,我们项目使用的编程语言在实现一些功能时,想使用现成的依赖库来实现复杂的逻辑,但是因为生态不完善,导致缺少此类库或者现存的依赖库不成熟。在使用Rust时,这种现象尤其普遍。很多热门组件没有为Rust提供官方API,非官方实现功能和性能又得不到保证,且更新不稳定。难道Rust进阶之路就要到此为止?

Rust很贴心地提供了跨语言交互能力,对FFI的良好支持可以让开发者方便的在Rust代码中调用C程序。如果我们需要的依赖库刚好有C/C++的实现,就能使Rust完成主要逻辑,把一些Rust不完善的功能通过C/C++实现,而且性能也不会受到影响。在Rust程序调用C代码也非常简单:

  1. 声明外部函数
extern "C" {
    fn c_add(a: i32, b: i32) -> i32;
}
  1. 在RUST中调用C函数
fn main() {
    unsafe {
        c_add(1, 2); 
    }
}
  1. 将C程序编译打包为静态/动态链接库
g++ -std=c++17 -shared -fPIC -o libhello.so hello.cpp
  1. 然后编译 Rust 文件并链接到链接库
rustc main.rs hello.o

尽管用Rust调用C程序已经非常方便,但是仍需要注意这些问题:

  • 处理数据类型:在 Rust FFI 中,需要特别注意数据类型的转换和处理。Rust 和其他语言的数据类型可能存在差异,需要进行适当的转换。例如,Rust的i32和C的int可以直接相互转换。而字符串的传递之所以需要特殊处理,是因为Rust的字符串实现和C/C++不一样。C/C++的字符串指针只包含地址,且字符串后有“\0”作为结尾,而Rust字符串的指针不仅包含地址,还包含字符串长度,且末尾没有“\0”作为结尾。
  • 内存管理:尽管Rust是内存安全的语言,但是在使用FFI的情况下,Rust无法保证调用的外部语言的安全性。作为开发者,我们要自己管理外部语言的内存。
  • 线程安全:在多线程环境下使用 Rust FFI 时,需要注意线程安全问题。某些外部函数可能不是线程安全的,需要在调用时进行适当的同步操作。
  • 性能优化:在使用 Rust FFI 时,需要注意性能优化问题。由于涉及跨语言调用,可能会导致一定的性能损失。因此,需要对 FFI 调用的性能进行评估和优化。

Tokio

如果你想构建一个高性能的Rust服务器应用,那么Tokio绝对是你的首选框架。Tokio 是一个用 Rust 编写的异步运行时,旨在提供高性能的 I/O、任务调度和并发支持。虽说Tokio提供了强大的异步支持,要用好Tokio也不是一件容易得事,首先要了解“异步”的概念。在计算机编程中,“异步”是指一种不阻塞的操作方式,允许程序在等待某些操作(如 I/O 操作、网络请求等)完成时继续执行其他代码。

Tokio 通过使用协程和 Future 机制来实现高效的并发处理。它将异步任务封装为Future对象,并通过运行时的调度器管理这些任务的执行状态。当任务被调用时,运行时通过poll方法检查其状态,如果任务无法继续执行(返回 Poll::Pending),则将其挂起并注册一个Waker来在后续的某个时刻唤醒任务。一旦相关的I/O操作完成,Waker会通知运行时重新调度该任务,从而实现非阻塞的并发执行。Tokio支持多线程运行,可以充分利用多核CPU的能力,提高应用程序的性能和响应性。

性能和响应性.jpeg Tokio的使用非常简单,使用async和await就可以很方便地创建异步任务,但是要使用Tokio写出高性能的代码不是一件简单的事。刚刚接触Tokio的开发者会经常发现代码无故卡死或者性能低下,这是因为没有正确使用Tokio。举个例子,下面是一段运行后会卡死的代码:

#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
async fn main() {
    let h = tokio::spawn(async {
        let (tx, rx) = std::sync::mpsc::channel::<String>();
        tokio::spawn(async move{
            let _ = tx.send("send message".to_string());
        });
        let ret = rx.recv().unwrap();
        println!("{}", ret)
    });
    h.await;
}

代码结构很简单,但是运行后会发现代码似乎hang住了,检查代码结构也没有发现问题。要解释这个卡死的问题,要从Tokio的任务调度机制来分析:

调度机制来分析.jpeg Processor 获取 Task 后,会开始执行这个 Task,在 Task 执行过程中,可能会产生很多新的 Task,第一个新 Task 会被放到 LIFO Slot 中,其他新 Task 会被放到 Local Run Queue 中,因为 Local Run Queue 的大小是固定的,如果它满了,剩余的 Task 会被放到 Global Queue 中。

Processor 运行完当前 task 后,会尝试按照以下顺序获取新的 Task 并继续运行:

  1. LIFO Slot.
  2. Local Run Queue.
  3. Global Queue.
  4. 其他 Processor 的 Local Run Queue。

如果 Processor 获取不到 task 了,那么其对应的线程就会休眠,等待下次唤醒。

在上面的例子中,我们首先Spawn了一个异步任务Task-1,Task-1被分配给了Processor-1执行。然后在Task-1里Spawn了另一个异步任务Task-2,Task-2被放到了Processor-1的LIFO Slot中。

因为Task-1继续运行的条件依赖于Task-2,所以Task-1被阻塞了。而且Tokio的协程是非抢占式的,在Task-1没有遇到.await前无法让出CPU,Processor-1无法去执行Task-2。又因为Task-2在Processor-1的LIFO Slot中,其他的Processor也无法偷取Task-2执行。于是,Task-2永远也不会有机会被执行,这两个Task在循环等待中就永远卡死了。

要解决这个问题,我们要将阻塞型的数据结构替换成Tokio的非阻塞式的:

#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
async fn main() {
    let handler = tokio::spawn(async {
        let (tx, mut rx) = tokio::sync::mpsc::channel(2);
        tokio::spawn(async move{
            let _ = tx.send("send message".to_string()).await;
        });
        let ret = rx.recv().await.unwrap();
        println!("{}", ret)
    });
    handler.await;
}

将channel替换成Tokio的非阻塞数据结构后,Task-1在提交完Task-2后遇到await让出了CPU,Processor-1就可以从LIFO Slot取出Task-2执行了,循环等待也就被打破了。

由这个例子可以看出,Tokio 的轻量级线程之间的关系是一种合作式的。合作式的意思就是同一个 CPU 核上的任务大家是配合着执行(不同 CPU 核上的任务是并行执行的)。我们可以设想一个简单的场景,A 和 B 两个任务被分配到了同一个 CPU 核上,A 先执行,那么,只有在 A 异步代码中碰到 .await 而且不能立即得到返回值的时候,才会触发挂起,进而切换到任务 B 执行。也就是说,在一个 task 没有遇到 .await 之前,它是不会主动交出这个 CPU 核的,其他 task 也不能主动来抢占这个 CPU 核。

所以在使用Tokio时,我们要注意两点:

  • 不要在异步代码中执行阻塞操作,不然这个OS线程中的其他任务都会被阻塞。
  • Tokio 虽然适合网络 I/O 型并发,但是也要在 I/O 任务里小心地控制计算型代码的时间,否则会导致运行时任务调度不均,从而长时间阻塞其它任务的运行。

四、Rust应用发布

通过 Cargo,开发者可以轻松创建、构建和共享 Rust 项目。但是因为发布系统只支持Java和Golang应用,要在发布系统发布Rust应用还是需要一些工作的。以下是我们发布Rust应用的流程。

上传镜像

因为公司平台是没有Rust应用的,所以我们需要自己制作镜像并上传,这样才能在发布平台发布我们的代码。我们需要创建两个 Docker 镜像:一个用于构建(CI 镜像),另一个用于运行(运行时镜像)。

另一个用于运行.jpeg 在dockerfile里可以安装自己想要的工具包,根据自己需求来定制。

FROM repoin.shizhuang-inc.net/ci-build/rust:1.79.0

RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 871920D1991BC93C

# 创建 /etc/apt/sources.list
RUN echo "deb http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse" > /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse" >> /etc/apt/sources.list

# 更新包列表并安装必要的工具
RUN apt-get install -y \
    protobuf-compiler \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# 验证安装
RUN protoc --version

RUN pwd

RUN ls -alh .

RUN ls -alh workspace

发布

建好集群后,还需要对集群进行一些配置:

  • 修改编译配置的镜像为自己上传的镜像。
  • 将编译命令设为cargo build --release。
  • 修改运行时镜像。
  • 修改发布配置,改为自己应用所需要的。

还需要注意的是,发布平台的编译环境和运行环境是不同的,编译完成后发布平台会将可执行文件移动到/opt/apps目录下进行执行,而配置文件不会被打包。遇到这种情况可以使用rust-embed库,它允许将静态文件(如 Yaml、Json、图像等)打包到您的二进制文件中,从而简化文件管理和部署。

上监控

虽说Rust应用主打的是稳定,但是发布后持续对应用进行监控也是必须的,不然晚上能睡得着吗。和发布一样,Rust应埋的指标要被监控采集,需要额外的配置。在KubeOne平台找到自己的集群,在发布配置里加上这两项,监控平台就可以采集到指标了。

labels:
    - key: http://dewu.com/qos
      value: LS
    - key: http://duapp.kubernetes.io/metrics-scraped
      value: metrics
containerPorts:
    - containerPort: "2892"
      name: http-metrics
      protocol: TCP

通过上监控,可以实时观察Rust服务的运行情况,并且根据自己的埋点分析系统的瓶颈。可以看到,Rust应用运行非常平稳。相比于有GC的Java应用,Rust明显毛刺很少,非常平滑,而且内存占用相比Java减少了70%。

在这里插入图片描述

五、结 论

通过迁移到Rust,我们的计算层能够在处理高并发请求时显著提高系统的吞吐量和响应能力,同时减少服务器资源的浪费。这不仅能降低运营成本,还能为我们的用户提供更流畅、更快速的体验。

但是,如果要持续地拥抱Rust生态,目前仍然面临如下挑战:

1. 生态不完善 尽管 Rust 已经有一些非常优秀的库和工具,但某些特定领域仍然缺乏成熟且广泛使用的库。这意味着开发者可能需要花费更多的时间来构建自己的解决方案或者整合不同语言的库。

2. 学习曲线陡峭 Rust 语言引入了许多独特的概念和特性,对于初学者和来自其他语言的开发者来说,这些特性可能需要一段时间来彻底掌握。

3. 开发进度 相比于自动内存管理类型语言的开发任务,Rust严格的编译检查会让开发进度一度阻塞。

尽管开发Rust生产级应用有那么多阻碍,我们目前已经发布的Rust应用已经证明了,相比于付出,迁移Rust带来的收益更大。希望大家都可以探索Rust的可行性,为节能减排和世界和平出一份力,也欢迎各位对Rust有兴趣的同学一起交流。

文 / 小新

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

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

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

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

作者 得物技术
2025年2月25日 11:52

一、什么是小程序平台

得物小程序平台致力于整合并管理微信、支付宝等渠道的得物数字资产,实现数字化管理。通过该平台,小程序和公众号等功能纳入公司工作流,以提升用户体验和管理效率。

小程序1.jpeg

项目一期的成功推出,使得数字化管理得以实现,传统的线下沟通模式顺利迁移至线上。截至目前,平台已接入多个小程序,实现了用户自主申请角色的能力,以及迅速处理了多条用户投诉,并及时转发至技术支持部门。

多小程序管理 多小程序管理.jpeg

小程序维护人员审批流 小程序维护.jpeg

小程序维护人员列表 小程序维护列表.jpeg

二、为什么要做小程序平台

当前挑战

1.管理流程与效率

  • 成员与开发者管理主要依赖手动操作,缺少自动化支持,这在人员变动时造成了权限更新的延迟。

2.消息处理与联动不足

  • 现有的投诉与问题推送系统未能有效与管理流程对接,从而影响整体响应效率和用户体验。

3.数字资产透明度

  • 数字资产管理尚缺乏透明度,追踪与管理资产使用情况变得复杂,这给管理带来了一定挑战。

改进方向

1.增强微信开放平台的对接能力,以提升工作效率。 2.提升小程序体系的基础能力,推动管理流程的数字化转型。

核心目标

实现多个小程序的统一线上管理,减少人为操作。具体目标包括:

  • 降低因权限滥用导致的数据安全问题。
  • 实现用户投诉的快速响应,以显著提升用户体验。
  • 提高小程序的运营效率。

三、怎么做小程序平台

技术重点: 1.小程序在线标准化 2.多小程序多平台的统一管理

作为平台方,我们需有效整合内部工作流与外部微信和支付宝平台,具体拆解如下:

1.工作台: 提供一站式工作平台,不同用户角色通过该平台进行小程序的在线管理。普通用户可轻松申请体验权限,客服专员则聚焦于处理客户投诉与反馈,管理员负责管理小程序资产。

2.API规范: 通过中间层对接SDK,消除微信和支付宝开放平台之间的技术壁垒。

3.数据中心: 作为核心模块,统一管理公司内部数据,确保小程序信息、用户、角色、投诉等数据实时同步。

4.流程引擎与权限管理: 结合工作台,通过飞书进行流程管理和消息实时推送。

流程引擎.jpeg

总体设计

基于线上化目标,从实际痛点和公司基建现状出发,得物小程序平台的核心内容包括:

1.主工作台:

  • 开发管理:角色管理、成员管理、小程序体验码等
  • 消息推送:投诉/违规消息提示、告警消息、工单下发等
  • 小程序管理:小程序列表管理
  • ...

2.工作流协同:与飞书审批流、飞书助手、TS工单、离职系统等进行协同。

工作流协同.jpeg

详细设计

一期在线化目标重点在【开发管理】和【消息推送】2个模块。

1.开发管理

  • 实现小程序角色的在线新增和移除,确保每一次操作都有记录,同时对接公司的审批流程。
  • 离职员工的信息可通过系统自动移除,避免了人为失误。

开发管理1.jpeg开发管理.jpeg

2.消息推送

  • 对接TS工单,技术性问题通过TS平台处理,业务类问题则交由运营团队。
  • 对接飞书机器人,完成了消息的实时推送。

信息推送1.jpeg消息推送.jpeg

数据库设计

根据上方工作流设计,设计如下表数据结构,用来存储角色、权限、消息等数据。并结合Cursor AI工具,使代码逻辑的开发变得高效便捷。

数据库设计.jpeg

业务效果

1.在线管理成功落地:

小程序平台V1.0成功推出,接入多款小程序,多个用户已开通线上权限申请。集成管理平台大幅减少了人工操作时间。

2.运营效率显著提升:

通过优化流程,降低了对人工干预的依赖,确保所有投诉能够及时跟进并解决。

3.用户反馈积极改善:

收集的用户反馈显示,投诉响应时间由平均数小时缩短至几分钟,新的小程序平台提高了处理效率与透明度。

四、遇到的挑战

挑战1:跨平台接入的复杂性超出预期

在系统实现过程中,我们要深度接入4个平台(微信、支付宝、飞书、内部系统),涉及的上下游系统超过10个,导致工作流的复杂性远超预期。

  • 缺乏前置经验参考:从0到1工作量巨大,且涉及内外多个系统和工作流;
  • 接入流程门槛高:微信三方平台、飞书审批流、微信消息服务器,每个都是都独立且较高门槛的接入流程;
  • 依赖大量文档调研:很多微信/飞书功能都需通过查阅官方文档进行探索,导致信息的不确定性;

1.微信三方平台接入流程

微信三方平台.jpeg

2.飞书审批流接入流程

飞书审批流.jpeg

挑战2:缺乏开箱即用的基础设施

运维配置的复杂性增加了落地过程中的挑战,需反复与相关团队沟通,以解决网络问题和资源配置。

  • 缺乏运维解决方案。示例,微信和飞书等外网服务器的接入需要处理的运维问题非常繁琐,而目前的运维工具无法提供一站式解决方案。
  • 前端基建待完善。新建一个后台管理系统成本高,新域名、Nginx配置、B端脚手架都需要时间和耐心去管理。
  • 文档不全。团队在技术支持和功能需求上常面临信息不足,尤其在对接微信公网时,缺乏系统化文档影响了问题解决的效率。

解决方案

策略:

模块化设计分解复杂性,先完成再完美。 AI工具提效,让多个AI人帮你打工。 文档留痕。

模块化设计分解复杂性

拆分清楚每个子系统在整体架构图里所处的位置,是否是关键核心链路还是用户体验优化,根据这判断工作优先级。

  • 要事第一,尽快跑通最小可用性产品(MVP),
  • 保持跟主管时刻对焦,确保目标产物跟预期一致,事事有反馈,件件有着落。

模块化设计.jpeg

AI工具提效

现在的AI足够强大,很适合做流程性、确定性的工作。充分利用好Cursor、ChatGPT、Kimi等工具,让个人效率和产出最大化。强烈推荐Cursor Compose模式,Cursor底层用的claude.ai,它最大的能力是基于当前codebase索引库,所以它生成的代码可以读懂上下文,也能模仿项目里其他的代码写法,更智能。心得总结:

  • 表结构要设计好,完整、准确、非歧义。
  • 询问的范围Scope,尽量缩小。不然AI给你的答案可能越来越偏,也能理解,范围越大意图理解就偏差了。
  • 准确的意图。可以让AI出解决方案,但一定是基于你现有的上下文足够多,且有正反馈。再强调下,准确的意图
  • 沟通的方案,让AI记录在README.md中。这个方式很有效,随着系统越来越复杂,把中间跟AI达成的一致内容,包括背景、设计目标、技术方案等让AI写在Markdown文档中。

沟通的方案.jpeg

文档留痕

1.基本每个步骤都能有对应的文档。

2.顺道完善了公司2个配套基建问题。

五、总结心得

1.加强技术基础建设

  • 技术基础设施及文档管理的重要性不可忽视,改善沟通成本和提高工作效率至关重要。
  • 运维侧工具足够多,但缺少一站式解决方案。本质上大家所处视角不同,业务背景也不同,需要从全局视角看运维要做什么,业务方又需要配合哪些场景。

2.合理利用AI提升效率

  • AI工具在本次项目中的应用显著提高了效率。无论是文档阅读、功能调研还是代码生成,AI工具都能快速提供有效的建议和总结,帮助节省了大量的时间。
  • 然而,也意识到AI并非万能。它能够处理大量信息和基础逻辑,但在复杂的人际互动、视觉设计或独特创新性任务上仍然存在局限。因此,在未来的工作中,将继续探索AI的应用边界,以更好地发挥其优势。

3.持续优化的心态

  • 尽管小程序平台的V1.0版取得了阶段性成功,但深知这只是一个起点。在后续开发中,将着重于持续迭代与优化,确保平台能够不断满足用户的需求与公司发展的目标。
  • 跟业务制定反馈收集机制,以便及时调整策略,提升产品的用户体验和整体效率。

六、未来计划

1.小程序平台二期开发:

  • 启动小程序平台的二期开发,专注于整合管理微信、支付宝等外部渠道的得物数字资产。
  • 实现与得物工作流的无缝对接,目标使不同角色人员在项目处理中无需进行下沟通或人肉搜索,提升工作效率。

2.AI工具分享:

在团队内部进行AI工具的使用技巧分享,尤其针对Cursor和各种AI类工具应用。

文 / springleo

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

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

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

多场景建模在得物交易搜索下的创新与实践

作者 得物技术
2025年2月21日 10:15

一、整体概述

2024年得物算法团队基于交易搜索的场景特点与数据现状,围绕“多场景建模”开展了一系列工作,取得了较大幅度的在线业务指标提升;同时我们利用碎片时间将积累的技术经验形成相应的论文,成功被搜索推荐/数据挖掘领域顶级会议WWW'2025CCF-A)、DASFAA'2025CCF-B)录用。三篇文章具体信息如下:

(1)WWW'2025(industry track长文):Disentangling Scenario-wise Interest Network for Multi-scenario Recommendation. (录用率仅为:22.4% (63/281))

(2)DASFAA'2025(research track长文oral):Towards Scenario-adaptive User Behavior Modeling for Multi-scenario Recommendation. (录用率仅为:20.7% (137 / 662))

(3)DASFAA'2025(industry track长文oral):When Multi-scenario Meets Multi-attribute: Scenario and Attribute-aware Recommendation with Contrastive Learning. (录用率为:41.7% (15 / 36))

下面详细介绍下这三项工作的具体内容。

二、背景介绍

近年来,线上购物平台在用户日常生活中扮演着越来越重要的角色。为了满足用户多样化的购物需求,当前大多电商App往往会集成多种购物场景(首页瀑布流、详情页、订单页等等),为不同用户提供量身定制的购物服务。随之而来,多场景学习Multi-scenario Learning,MSL)在电商平台的搜索推荐系统中也取得了蓬勃发展。下面我们从得物App整体和得物App搜索两个角度出发,深入分析不同场景的特性。

得物App整体多场景

以得物App整体为例,图1展示了其几种常见场景:

(1)首页瀑布流:用户一访问应用就能在首页上看到推荐的商品。该场景根据用户的历史行为偏好展示吸引用户的多种类型的商品。

(2)购物车页面或订单页面:该场景在用户将某些特定商品加入购物车或购买后向其推荐商品,推荐的商品更多取决于用户过去的购买记录。

得物整体多场景示例图.jpeg图1. 得物App整体多场景示例图

显然这些典型场景彼此之间的差异很大。不同场景下用户的行为和兴趣(例如,用户对特定品牌、价格或类别的偏好)也存在很大差异。具体来说,首页瀑布流上的用户兴趣比购物车页面或订单页面更加发散,因为后两个场景中展示的商品更多受到用户历史加购和下单行为的限制。比如首页上的人往往有多样化的兴趣(用户喜欢浏览更多类别和品牌的商品,它们的价格选择范围更广);相反,用户在订单页里可能会表现出更具体和集中的偏好。如果用户最近在订单页面购买了一部新手机,他可能接下来会更关注手机壳和耳机等电子设备。用户行为也会受到他们所购买过的商品引导。

得物App搜索多场景

以得物App搜索为例,我们从用户搜索流量来源角度出发进行场景划分,图2展示了其对应的场景。

得物搜索多场景示例图.jpeg图2. 得物App搜索多场景示例图

通过对不同Query来源的用户数据进行分析后我们发现用户在不同来源下的价格、类目和品牌偏好存在较为显著的差异。因此我们考虑将不同Query来源作为场景化信息,并在CTR预估任务中引入多场景学习做精细化建模。

主要问题

我们可以归纳出得物搜索多场景建模需要解决的两个主要问题

(1)用户行为兴趣(价格、类目、品牌等偏好)在不同场景下的差异如何进行有效刻画?

(2)在搜索推荐领域用户行为序列建模通常是对用户兴趣表达最为有效的手段,我们该如何将场景化信息优雅地融入到行为序列建模当中以实现最有效的多场景兴趣建模?

三、整体优化思路

SACN:针对问题(1),我们考虑既然用户在不同场景下对价格、类目和品牌等商品属性存在显式的偏好差异,而以往的用户行为序列建模通用做法也引入了商品多属性作为side info来丰富用户的兴趣表达,那么我们能否将多场景的先验信息多属性序列建模进行深入结合,达到解决问题的目的?基于此,我们提出在多场景建模下引入多属性信息去刻画用户在特定场景下的多粒度画像偏好,同时更好辅助刻画不同场景下的用户兴趣差异。我们在DASFAA'2025(industry track)提出了Scenario and Attribute-aware Contrastive Network(SACN),它首次将多场景建模和用户行为序列多属性建模结合,构造两种不同粒度的用户兴趣偏好提取组件,即Item-level Preference Extracting (IPE)和Attribute-level Preference Extracting (APE),并结合对比学习对用户在不同目标场景(target scenario)下的兴趣进行区分。

SACN虽然对问题(1)进行了一定程度优化,但它和其他工业界通用的多场景建模方法(PEPNet、SAR-Net等)类似,仅仅考虑了目标场景(target scenario)信息。而没有对用户的历史行为来源于哪个场景做区分,即它们均未对问题(2)进行过深入讨论和优化。从我们过往的用户行为序列建模迭代经验中得到启发,我们遵循如下两个优化思路,考虑在行为序列中引入丰富的场景化信息。

  • 思路1:把用户每一次历史行为所属场景来源作为和商品属性类似的side info加入到序列中;
  • 思路2:参考序列建模SIM的方式,把用户的历史行为按照所属场景来源进行分组,对用户兴趣进行更精确表达;

SAINet:SAINet沿着思路1展开优化。我们在DASFAA'2025(research track)中提出Scenario-adaptive Interest Network(SAINet),SAINet首先堆叠了多个由Scenario-aware Interest Extracting (SIE) 和Scenario Tailoring Module (STM) 组成的Scenario-adaptive Block。SIE 自适应地将场景上下文类似于行为序列中每一个商品的属性信息方式融入到用户行为序列,以更精确地捕捉用户的兴趣差异。通过堆叠的方式实现深度网络,增强了模型对场景间差异建模的能力。

DSWIN:DSWIN沿着思路2展开优化。受序列建模SIM方法的启发(即按照类目过滤筛选与目标商品类目相关的历史序列),我们按照场景对用户行为序列进行划分,更有效地提取与目标场景相关的兴趣偏好。DSWIN提出了Global Interest Aggregation(GIA)和Local Interest Resolution(LIR)模块分别建模用户全局的兴趣偏好和用户场景内的兴趣偏好。最后引入Interest Disentangling Module将场景的兴趣差异进行显式解分离。

四、相关工作

传统方法通常使用场景特定的数据为每个场景训练单独的模型。然而,这种孤立的模型训练方法通常需要更复杂的计算和维护成本。此外,单独的模型无法捕捉场景之间的相互关系。目前,最先进的模型通常使用所有场景的合并数据训练统一模型,以学习场景之间的共性和特定特征。目前有两种主流的多场景建模方法:

Scenario-specific network structures

Scenario-specific network structures受到多任务学习 (MTL)的启发。这些方法针对每个场景应用特定网络,并输出多个场景特定CTR分数,即把每个场景当作一个任务建模,然后沿用多任务建模思路构造模型。MoE提出根据共享底部输入选择子专家。MMoE通过在所有任务之间共享子网络来调整MoE结构,同时训练轻量级门控网络以优化每个任务。然而,MMoE 存在跷跷板现象(即某一任务的效果提升通常会导致其他任务的效果下降)。为了解决这个问题,PLE明确提出共享专家和任务特定专家,并采用渐进式路由机制逐步提取和分离更深层的语义知识,从而在一般设置中提高跨任务联合表示学习和信息传递的效率。HMoE利用MMoE隐式识别场景之间的差别和共同点。然而,由于多场景数据集很复杂,HMoE很难明确捕获共享和特定信息。考虑到这一点,SAML引入了场景感知Embedding模块来学习全局和场景特定视角下的特征表征,并提出了一个交互单元来学习场景之间的相似性。AESM2提出了一种新颖的专家网络结构,通过计算 KL散度自动选择最合适的共享和特定专家网络。

然而Scenario-specific network structures在模型底层表征Embedding上忽略了场景之间的差异,它们都是由很重的网络结构组成也不利于实际上线,因此Parameter adaptive network structures这种轻量结构的多场景模型是目前业界的主流。

Parameter adaptive network structures

受语音识别领域提出的 LHUC算法算法的影响,这些方法提出将场景上下文信息作为输入,通过门机制动态调整模型底层Embedding和顶层DNN隐藏单元,以学习场景多样性。AdaSparse为每个场景学习自适应稀疏结构,增强跨域泛化能力。SASS设计了一个场景自适应传输模块,将有用信息从整个场景注入到单个场景。DFFM将场景相关信息纳入特征交互和用户行为模块的参数中。3MN提出了一种基于三元网络的新颖方法来建模复杂的任务-任务、场景-场景和任务-场景相互关系。PEPNet将场景相关特征作为输入,并通过门机制动态缩放底层嵌入和顶层DNN隐藏单元。SFPNet包含一系列称为场景定制模块,通过重新定义基本输入特征并在粗粒度级别上集成场景信息。

然而这些方法都将用户的整个历史行为序列视为一个整体,无法区分行为序列中的每个行为交互来自哪个场景。如图 3 (a) 所示,粗粒度的权重调整方法(例如 PEPNet)对从历史行为聚合而成的表征向量统一施加相同的权重。图3 (b) 中的 SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们均无法区分行为序列中每个行为的所属场景。而正如背景介绍的,不同场景下的用户行为差异很大,这不可避免地会降低CTR模型的准确性。

图3场景信息利用示意图.jpeg 图3. PEPNet和SAR-Net中的场景信息利用示意图

五、SACN:When Multi-scenario Meets Multi-attribute: Scenario and Attribute-aware Recommendationwith Contrastive Learning

整体结构

SACN模型结构如图4所示。

sacn示意图.jpeg图4. SACN示意图

问题定义

2.jpeg分别表示用户集合、商品集合、商品ID集合、第j类商品属性集合和场景集合。2.2.jpeg表示用户1.jpeg按时间顺序排列的历史行为序列,其中2.3.jpeg是用户交互序列中的第i个商品,N是序列的最大长度。假设我们有m种商品属性,则5.jpeg,其中6.jpeg是第i个交互商品的ID,7.jpeg表示第i个交互商品的第j种属性。给定目标商品8.jpeg, 目标场景10.jpeg及交互历史11.jpeg,多场景建模的目标是预测用户u在场景12.jpeg下对目标商品vt.jpeg感兴趣的概率。考虑CTR预估任务,可将其形式化为:13.jpeg

具体方法

Item-level Preference Extracting

为了捕捉用户的粗粒度(Item-level)偏好,Item-level Preference Extracting(IPE)模块采用场景感知多头自注意力(MHSA)机制来处理商品ID构成的用户历史序列。我们对目标场景和商品相关信息在模型里的重要性进行了加强。1.jpeg

表示行为序列商品ID的Embedding矩阵,目标场景和目标商品ID特征向量可表示为2.jpeg。场景感知 MHSA的最终输出3.3.jpeg计算如下:

4.jpeg

为了使得目标商品和历史行为商品充分交互,并利用目标场景指导用户历史行为编码,Q、K、V 将目标场景和商品 ID对应的Embedding集成到自注意力的参数当中。集成流程如下:

5.jpeg 其中6.jpeg为变换矩阵,7.jpeg表示逐元素乘积。8.jpeg由目标场景和商品ID的Embedding重新reshape而成,与变换矩阵维度相同。这样,目标场景和商品ID的信息就可以逐元素、全面地参与到粗粒度用户偏好提取过程中。IPE的最终偏好表示9.jpeg公式如下: 10正确的图.jpeg 类似地,融合其他场景信息的偏好表征向量可以计算为10.1.jpeg,只需将Q、K、V 计算公式中的11.jpeg替换为12.jpeg,其中K表示场景数量。

Attribute-level Preference Extracting

商品的属性对于在不同场景下更全面地捕捉用户的偏好至关重要。然而,据我们所知现有的多属性建模模型都没有利用目标场景和商品信息,这会导致信息丢失,限制网络的表达能力。令1.jpeg表示历史行为的第j个商品属性;目标场景和第j个目标商品属性特征的Embedding矩阵,分别表示为2.jpeg。对称地,我们分别用3.jpeg4.jpeg替换IPE 模块Q、K、V 计算公式中的X和5.jpeg。然后Attribute-level Preference Extracting(APE)模块可以获得对应于m种商品属性类型的m个Attribute-level偏好表征。6.jpeg表示第j个表征。为了捕捉用户对属性(例如类别或品牌)的不同偏好,我们将融合目标场景信息的m个Attribute-level偏好表征利用原始的注意力网络进行融合,其定义如下:

7.jpeg 类似地,我们可以得到融合其他场景信息的Attribute-level偏好表征8.jpeg

Scenario Contrastive Module

如前所述,不同场景下的用户兴趣存在显著差异。利用自监督学习来刻画该差异性。具体而言,我们将融合当前目标场景信息的Item-level偏好表征1.jpeg和Attribute-level偏好表征2.jpeg视为正对比样本,将融合其他场景信息的相应表征3.jpeg视为负样本。我们利用对比学习损失指导模型增强两个正样本之间的相似度得分,削弱负样本和两个正样本之间的相似度得分。因此有两个对比任务如下:

5.jpeg 其中6.jpeg表示相似度函数,用于计算两个实例之间的余弦距离。7.jpeg是温度参数。Scenario Contrastive Module (SCM) 最终自监督学习场景之间的区别兴趣,并提高模型辨别场景间区别的能力。

Prediction and Optimization

我们将IPE和APE的输出、场景特征和目标商品特征连接起来,然后将它们输入多层DNN塔:

1.jpeg1.1.jpeg是用户与目标商品交互的概率。我们使用通用的交叉熵损失作为目标函数:

2.jpeg 其中2.2.jpeg是样本的标签。M是样本数量。联合损失函数利用超参数gamma平衡监督目标和自监督目标,公式如下:

3.jpeg

实验部分

实验设置

数据集

我们在Dewu收集并采样了多个场景的样本作为我们的实验数据集。该数据集中的商品属性包括价格、三级类目和商品品牌。所有商品的属性都是离散类别特征。

评估指标

对于离线评估,我们使用ROC曲线下面积 (AUC) 作为评估指标,该指标在工业推荐系统中被广泛采用。

baseline

为了验证所提出的SACN的有效性,我们将我们的模型的性能与一系列最先进的多场景学习 (MSL) 方法进行了比较,即 MMoE、PLE、M2M、PEPNet和MARIA。

整体实验结果

我们重复每个模型三次并报告平均结果。离线比较结果如表1所示。

表1. SACN和其他方法离线效果对比

表1离线效果对比.jpeg 主要观察结果总结如下:

(1)选择MMOE作为基础模型是因为它在MSL中具有代表性。与MMOE相比,PLE通过将专家网络划分为两个不同的组,实现了跨场景的更高稳定性,并更有效地提取了场景之间的差异和共同点,因此性能更佳。

(2)MMOE和PLE都在模型顶部引入了场景特定的DNN塔,并为不同场景输出多个分数。然而,它们忽略了模型的底层(例如Embedding层)优化,这将严重降低多场景建模的效果。PEPNet使用场景感知门控单元自适应地调整Embedding层和隐藏层。M2M引入了一种新颖的meta单元,它结合了丰富的场景知识来显式学习场景相关性,并增强了捕获场景特定特征表示的能力。它们都比MMoE和PLE表现更好。

(3)MARIA通过优化模型的底层和上层结构而胜过其他模型。然而,所有这些方法在多场景建模时对于用户行为的利用都只考虑商品ID,而没有考虑商品的多种属性的影响,而这些属性对于生成丰富的潜在兴趣表征和反映用户在不同场景下的兴趣差异是必不可少的。由于在多场景建模中利用了商品属性信息,我们的SACN模型在所有场景中都取得了与其他模型相比最好的性能。

消融实验

为了验证所提出的SACN中每个组件的有效性,特别是多属性相关模块,我们进行了几项消融实验:

(1)w/o APE从SACN模型中删除了APE模块,代表未引入商品属性信息;

(2)w/o APEw DIF-SR)用DIF-SR替换了APE模块,DIF-SR是一种基于自注意力的属性感知行为建模方法,但其不考虑目标场景和商品信息;

(3)w/o APEw ASIF)用 ASIF替换了APE模块, ASIF也是一种具有对齐商品ID和属性信息的属性感知行为序列建模方法;

(4)w/o SCM是从SACN中删除了SCM模块,这意味着兴趣差异没有进行明确区分;

消融实验结果如表2所示。我们发现删除APE后,模型效果会显著下降,甚至比PEPNet更差。这一观察证明了在对多种场景进行建模时利用商品属性的正确性和必要性。当用其他多属性感知行为建模方法替换APE而不使用目标场景和商品信息时,也会观察到AUC在下降,这意味着这目标场景和商品信息有利于学习细粒度的偏好表征。此外,如果没有SCM,模型性能在所有场景中都会显著下降。这表明,把融合了场景信息的Item-level偏好和Attribute偏好作为自监督信号对于区分场景间的兴趣非常有帮助。

表2. SACN变体消融实验结果

sacn变体消融.jpeg

AB实验

为了进一步证明所提出的SACN的有效性,我们将其部署在平台上进行 A/B 测试。由于工业环境限制,无法在线比较所有基线模型。因此,我们选择PEPNet作为比较的基线模型。在线评估指标是pvctr,即点击次数除以展示次数。经过一周的在线A/B测试,我们发现所提出的SACN比PEPNet模型实现了持续改进,即总体上实现 了pvctr+1.02%的提升幅度。简而言之,在线A/B测试结果再次证明了我们的SACN模型在工业环境中的有效性和实用性。

结论

在本文中,我们提出了一种用于多场景学习的新方法SACN,在对用户行为进行建模时同时引入了场景信息和商品属性信息。SACN可以利用商品ID和属性来捕获用户的粗粒度和细粒度偏好。偏好提取过程还考虑了使用目标场景和目标商品先验信息来得到更好的效果。在自监督学习的帮助下,SACN结合用户的Item-level和Attribute-level兴趣表示对不同场景中用户的偏好差异进行区分。通过大量实验表明,SACN始终优于最先进的基线模型。

六、SAINet:Towards Scenario-adaptive User Behavior Modeling for Multi-scenario Recommendation

整体结构

图 5 (a) 所示,PEPNet对从历史行为聚合(可以使用concat、pooling等聚合方式)而成的表征向量统一施加相同的权重。**图5 **(b) 中的SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们仅仅用到了目标场景的信息,均无法区分行为序列中每个行为的所属场景。图5c)中我们提出的SAINet则把场景信息作为一种属性side info自适应地加入到用户历史行为序列中,同时使用级联堆叠的方式让场景信息在深层的网络结构中得到有效表达。SAINet的具体结构如图6所示,它主要由一系列Scenario-adaptive Block,Target-aware Interest Fusion和Scenario-aware DNN Tower三大部分组成。

图5对比.jpeg图5. SAINet和PEPNet、SAR-Net的对比

图6SAINet示意图.jpeg图6. SAINet示意图

问题定义

1.jpeg为包含2.jpeg个商品的集合,3.jpeg表示包含K个场景的集合。4.jpeg表示用户u的历史行为按时间顺序排列的序列,N表示序列的长度。给定目标商品5.jpeg、目标场景6.jpeg和其他特征7.jpeg,多场景建模任务旨在设计一个统一的排序模型,以同时在K个场景中提供准确和个性化的商品推荐。我们选择点击率 (CTR) 预测作为我们的任务,其公式如下:

8.jpeg CTR预测是预测用户u在场景9.jpeg中与目标商品10.jpeg交互的概率11.jpeg。我们采用业界通用的Embedding技术将稀疏特征转换为低维dense向量。例如,12.jpeg分别表示目标商品13.jpeg和其他特征的Embedding。

具体方法

Scenario-adaptive Block

如图5所示,现有方法在进行多场景建模时,无法区分不同场景下用户行为的差异,而行为序列中蕴含的场景感知先验知识对提升模型结果的准确率有重要影响。因此,我们设计了Scenario-adaptive Block,它自适应地将场景感知上下文注入用户行为序列,获得全面、细粒度的兴趣表征,同时根据目标场景信息定制用户兴趣表征,进一步捕捉与当前场景密切相关的用户兴趣。Scenario-adaptive Block由L层堆叠而成,每个Block包含两个模块(Scenario-aware Interest Extracting和Scenario Tailoring Module)。通过堆叠Block,SAINet构建了一个深度网络,逐步增强了其对不同场景行为差异的建模能力。为了阐明这一点,我们给出了第l个Block内的计算过程,如下所示:

Scenario-aware Interest Extracting

Scenario-aware Interest Extracting (SIE) 模块旨在整合来自历史行为的场景先验信息并提取更细粒度的用户兴趣。我们采用了包含特定场景知识的改进多头自注意力 (MHA) 。1.jpeg表示第(l-1)个block的输出,N表示历史交互商品的数量。经过MHA后,2.jpeg的编码兴趣矩阵记为3.jpeg,编码计算方法如下:

4.jpeg Q、K、V 将场景Embedding集成到第(l-1)个block的输出中,以获得更精确的兴趣表征。令5.jpeg表示每个行为所来自场景的Embedding矩阵。然后集成过程可以定义为:

6.jpeg 其中h表示head数量。7.jpeg表示输出线性变换的权重矩阵,其中8.jpeg。图片分别是query、key、value对应的第i个head的投影矩阵。9.jpeg是场景Embedding的变换矩阵。

Scenario Tailoring Module

尽管SIE考虑利用历史行为中的场景信息,但它忽略了目标场景信息,而目标场景信息对于显式捕捉与当前场景相关的用户兴趣非常重要。我们提出了Scenario Tailoring Module (STM) 来进一步定制处理用户的兴趣表征(即11.jpeg。STM由N个轻量级门控单元组成。第i个门控调节计算如下:

12.jpeg 其中13.jpeg分别是投影矩阵和偏置项。14.jpeg是缩放因子,用于进一步压缩和放大定制信号。15.jpeg表示目标场景的Embedding。16.jpeg是逐元素乘积。最后,第l个block的兴趣表征可以定义为17.jpegScenario-adaptive Bloc块迭代L次,以提高其捕捉场景间行为差异的能力。特别地,第一个block的原始输入(即18.jpeg)定义为:

19.jpeg 其中20.jpeg表示用户行为序列21.jpeg的Embedding矩阵

Target-aware Interest Fusion

在经过Scenario-adaptive Block后,会生成多个兴趣表征。所有表征必须首先进行融合,以方便其与传输到下游 DNN网络的其他特征向量融合。我们采用Target-aware Interest Fusion (TIF) 通过注意力机制进行融合:

22.jpeg 其中23.jpeg表示与目标商品和目标场景相对应的融合兴趣表征向量。24.jpeg是可学习参数。25.jpeg是注意力权重,可以按如下公式计算:

26.jpeg 其中27.jpeg之间的相关性。28.jpeg表示目标商品Embedding和场景Embedding拼接后得到的向量。29.jpeg是可学习参数。

Scenario-aware DNN Tower

虽然我们优先考虑底层架构(即用户行为)的优化,但模型顶层的优化也是不容忽视的。因此,我们通过在预测阶段引入Scenario-aware DNN Tower(SDT)对DNN顶层的隐层单元进行动态缩放。我们首先将所有输出连接起来:

30.jpeg 然后使用Scenario-aware DNN Tower(SDT)来预测用户点击目标商品的概率:

31.jpeg 其中sDNN中第j层的具体计算为:

32.jpeg 其中33.jpeg为Sigmoid激活函数。34.jpeg分别为第j层的权重和bias。对于CTR任务,我们使用如下交叉熵损失作为目标函数:

35.jpeg 其中36.jpeg是样本的ground truth。|D|是样本的数量。

实验部分

我们进行了大量实验来验证我们提出的SAINet模型的有效性并回答以下问题:

RQ1:与最先进的基线相比,SAINet的表现如何? RQ2:SAINet中的每个模块是如何工作的? RQ3:所提出的 SAINet 中的超参数如何影响其性能?

实验设置

1.数据集

我们在以下两个真实数据集上进行了实验。数据集统计信息见表3。

AliCCP:AliCCP是淘宝发布的带有训练和测试集的公开数据集,在推荐领域的相关文献中被广泛使用。我们根据上下文特征值将数据集分为三个场景(缩写为#C1至#C3)。

Dewu数据集:它涉及五个场景(表示为#A1至#A5)的用户日志,通过随机抽样得到。

表3. 数据集基本信息统计

表3数据集.jpeg

2.评估指标

我们采用广泛使用的准确率指标AUC来验证模型性能。AUC表示测试集上ROC曲线下的面积。AUC的小幅提升可能会导致真实工业平台上CTR指标的显著提升。

3.baseline

为了证明我们提出的模型的有效性,我们将SAINet与多场景建模中的三类方法进行了比较。

General recommenders:所有场景的样本被合并以训练统一的排名模型。

  • DNN:这是一个通用模型,由全参数共享的单DNN塔进行预测打分。
  • DeepFM:它结合了factorization machines和DNN组件,消除了人工特征工程。

Scenario-specific network structures:每个场景都被视为不同任务,由多个场景特定的网络构成。

  • SharedBottom (SBT):在底层共享所有参数,在顶层采用多个场景特定的DNN塔。
  • MMoE:将原有的多任务学习转移到多场景学习。MMoE应用门控网络调整底层专家输出表征向量,然后是场景特定的塔,并学习从数据中建模场景关系。
  • PLE:在MMoE的基础上引入场景共享专家和场景特定专家,有效缓解跷跷板现象。
  • STAR: 设计星型拓扑结构,一个中心网络维护所有场景的共性信息,一组场景特定网络区分场景差异信息。
  • AESM2: 提出一种新颖的专家网络结构,通过计算KL散度自动选择细粒度专家,动态选择最合适的共享专家和特定专家。

Parameter adaptive network structures:场景上下文直接应用于Embedding层和DNN的隐藏层,并根据场景变化动态调整模型参数。

  • PEPNet:以场景相关特征为输入,通过门控机制动态缩放模型中底层嵌入和顶层DNN隐藏单元。
  • AdaSparse (ADS) 自适应学习每个场景的稀疏结构,通过学习权重修剪冗余神经元,增强跨场景泛化能力。
  • SFPNet:由一系列场景定制模块组成,通过重新定义基础特征,在粗粒度级别集成场景信息,同时将目标场景信息融入行为建模中,支持目标场景感知的用户兴趣建模。

整体实验结果

表4显示了所有方法在两个数据集上的比较结果。对于每种方法,我们重复实验五次并报告平均结果。使用t-test进行统计显著性检验。我们的方法相对于最佳基线的效果在0.05水平上具有统计意义显著性。

表4. SAINet和其他方法离线效果对比

表4离线效果对比.jpeg

基于此我们可以得出以下结论:

  • 所有General recommenders方法在两个数据集上的表现都不如其他方法。这是因为它们都忽略了场景之间的相互关系和差异。为了解决这个问题,Scenario-specific network structures被提出并实现了显著的性能提升。SharedBottom增加了几个场景特有的DNN塔来利用场景特定的知识,这无法捕捉场景之间复杂的相互作用。MMoE使用专家和门控网络来提取不同场景的共性并得到更好的结果。但MMoE在多个场景中表现出跷跷板现象(即一个场景的改进往往会导致其他场景的性能下降)。例如,与DeepFM相比,MMoE在工业数据集的场景#A4和场景#A5中的表现不尽如人意,因为这些场景具有更多不同的特性和不均匀的数据分布,而MMoE不足以处理它。PLE 通过将专家分为两组(即部分场景共享和部分场景特有),缓解了这一现象,并在两个数据集上都表现出比MMoE 更好的性能。AESM2进一步引入了专家自动选择机制,以获得比PLE更好的性能。
  • Scenario-specific network structures仅优化了网络的顶层部分。然而,底层Embedding表征中场景之间的差异对于实现良好的性能也至关重要。PEPNet通过将场景相关特征作为输入,并通过门机制动态缩放模型中的底层Embedding和顶层DNN隐藏单元,在两个数据集上都获得了良好的性能改进。与PEPNet相比,AdaSparse的性能较差,因为稀疏隐藏单元的机制难以学习。SFPNet在实验中表现出所有基线中的最佳性能,因为它集成了场景信息重新定义基本特征,同时将目标场景信息和行为融合以支持场景感知的用户兴趣建模。然而,所有这些研究都忽略了利用历史行为序列和目标商品中涉及的场景先验知识来有效地建模用户在不同场景中的兴趣差异。我们提出的SAINet通过引入丰富的场景上下文明确地建模了不同场景中用户行为的差异。此外,它还可以轻松捕捉跨场景的兴趣迁移。如表4所示,SAINet在两个数据集上的所有场景中都优于所有基线。

消融实验

为了评估SAINet中每个模块的有效性,我们还将SAINet与其变体进行了比较。考虑了以下变体:

  • w/o SIE:从Scenario-adaptive Block中删除了SIE,这意味着该模块不再强调历史行为中的场景信息。
  • w/o STM:从Scenario-adaptive Block中删除了STM,这意味着目标场景信息不用于定制化兴趣表征。
  • w/o TIF:从SAINet中删除了TIF模块,并将其替换为mean pooling操作。
  • w/o SDT:从SAINet中删除了SDT模块,并将其替换为正常的DNN网络。

具体的消融实验结果如表5所示。

表5. SAINet变体消融实验结果

表5变体消融.jpeg

表5所示,每个模块都对SAINet的性能做出有效贡献。具体而言,SIE模块的缺失(w/o SIE)会影响所有场景的预测性能,表明在增强对不同场景的用户兴趣差异进行建模的能力时,整合来自历史行为的场景先验知识是至关重要的。此外,STM的移除(w/o STM)也会导致模型的预测性能明显下降。这有力地验证了定制用户兴趣表示以捕捉与当前场景显着相关的用户偏好的有效性。此外,如果没有TIF模块,模型性能在两个数据集中都会有所下降。这反映了引入目标注意机制而不是mean pooling的兴趣融合对于确保预测准确性非常有帮助。最后,删除SDT将损害模型的性能。这表明,在场景信息的帮助下调整顶层网络参数也不容忽视,以获得更好的结果。

超参实验

我们进行了大量实验来检验几个关键超参数的影响,其中包括Scenario-adaptive Block的数量L、STM模块中的缩放因子图片、SIE模块中的head数量h。

Block数量L的影响:图7 (a) 说明了不同L对模型效果的影响。随着数值的增加,AUC呈现出改善的趋势。这主要是因为随着L的增大,兴趣表示和场景上下文之间的相互作用得到加强,而将L增加到 2以上并没有带来显著的收益。

图片的影响:缩放因子在 {0.8, 1.2, 1.6, 2.0, 2.4, 2.8} 中进行多组实验。根据图7(b) 所示的曲线,当因子值等于2时,SAINet在AUC上表现最佳,而将值增加到2以上会降低其性能。因此,我们在所有实验中将SAINet及其变体中的缩放因子设置为2。

head数量h的影响:h在 {2, 4, 6, 8, 10} 进行选择。图7 (c) 显示了head数量对SIE中多头注意力机制的影响。当head等于4时,AUC曲线达到峰值,而引入超过4的head数量会带来更差的性能。因此,在所有实验中将h设置为 4。

图7超参实验曲线图.jpeg图7. SAINet超参实验曲线图

结论

本文强调了区分不同场景下用户行为差异对兴趣建模的必要性,并提出SAINet模型。它首先引入了一系列Scenario-adaptive Block,将场景先验知识融合到用户行为当中以捕捉用户的细粒度兴趣,并根据目标场景上下文定制兴趣表示。通过堆叠block,可以增强对不同场景兴趣差异的建模能力。SAINet还利用场景感知DNN Tower (SDT) 来自动调节顶层 DNN隐藏单元,从而获得更好的预测结果。大量实验证明了SAINet在多场景建模中的优势。

七、DSWIN:Disentangling Scenario-wise Interest Network for Multi-scenario Recommendation

整体结构

图8所示,和SAINet类似,我们把DSWIN和PEPNet、SAR-Net进行对比。图8 (a) PEPNet对从历史行为聚合(可以使用concat、pooling等聚合方式)而成的表征向量统一施加相同的权重。图8 (b) 中的SAR-Net尝试在通过目标注意机制计算序列中每个行为的权重时融合目标场景信息。但它们仅仅用到了目标场景的信息,均无法区分行为序列中每个行为的所属场景。图8c)中我们提出的DSWIN则按照场景信息对用户行为进行分组,并引入Global Interest Aggregation建模用户全局行为兴趣,Local Interest Resolution建模用户在每个场景内的局部兴趣,最后Interest Disentangling Module对用户在不同场景下的兴趣进行解分离。DSWIN的具体结构如图9所示,它主要由Global Interest Aggregation,Local Interest Resolution和Interest Disentangling Module三大部分组成。

图8对比.jpeg图8. DSWIN和PEPNet、SAR-Net的对比

图9示意图.jpeg图9. DSWIN示意图

问题定义

1.jpeg个商品组成的集合,2.jpeg表示K个场景组成的集合。3.jpeg表示用户u的历史行为按时间顺序排列的序列,N表示序列的长度。4.jpeg表示用户在第k个场景中发生行为的子序列,满足5.jpeg。给定目标商品6.jpeg、目标场景7.jpeg和其他特征8.jpeg,多场景建模任务旨在设计一个统一的排名模型,同时在多个场景中提供准确且个性化的推荐。我们的工作考虑了点击率 (CTR) 预测任务,其公式如下:

9.jpeg CTR 预测是利用行为序列和其他上下文特征,预测用户u在给定场景10.jpeg中点击目标商品11.jpeg。我们利用广泛使用的Embedding技术将稀疏特征转换为低维dense向量。例如,12.jpeg分别表示目标商品13.jpeg和其他特征的Embedding向量。

具体方法

Global Interest Aggregation

如图8所示,先前的研究将用户的历史行为序列视为一个整体,而忽略了行为产生来源场景的信息。因此,我们首先设计了Global Interest Aggregation(GIA)模块,该模块动态融合用户的全局行为和场景感知的上下文信息,旨在获得全面、细粒度的兴趣表征。

Scenario-aware Context Aggregation Module

我们首先设计了Scenario-aware Context Aggregation Module(SCAM),它使用注意力机制对来自不同场景的行为进行聚合。同时,我们考虑将来自历史行为和当前样本的目标场景先验信息集成到SCAM中,以更好地理解用户的场景感知全局兴趣。因此,SCAM可以表述如下:

1.jpeg 其中2.jpeg表示与当前样本和目标场景相对应的兴趣表征向量,它是由历史交互商品的加权聚合而成,3.jpeg是可学习参数。4.jpeg是注意力权重,可以表示如下:

5.jpeg 其中6.jpeg是目标商品7.jpeg与用户在行为序列中第j个交互商品之间的相关性。8.jpeg表示目标商品Embedding向量9.jpeg与场景Embedding向量10.jpeg的拼接。类似地,11.jpeg表示第j个点击商品Embedding向量12.jpeg与对应场景Embedding向量13.jpeg的拼接。14.jpeg是学习参数。

Context Feedback Fusion Module

尽管SCAM考虑在计算权重时利用场景信息,但其表达目标场景与用户行为之间复杂相互作用的能力有限。为了进一步捕捉与当前目标场景密切相关的用户全局兴趣,我们提出了Context Feedback Fusion Module(CFFM),通过非线性特征交互将行为的兴趣表征向量与相应的上下文(即目标商品和场景)融合在一起。具体来说,CFFM 由具有k个block的MLP组成:

15.jpeg 其中16.jpeg是第k层的输出,17.jpeg是可学习参数。初始化输入公式如下:

18.jpeg 其中*表示逐元素乘积。我们最终可以获得与目标场景上下文和当前样本相对应的全局兴趣19.jpeg

Local Interest Resolution

为了明确区分不同场景下用户兴趣的差异,我们设计了Local Interest Resolution(LIR)模块,以明确提取每个子场景中用户的场景兴趣。LIR将全局行为按场景拆分为多个子序列,并由多个结构对称的Interest Extracting Unit(IEU)组成。考虑到同一场景下的用户行为更加集中和明确,每个IEU采用改进的多头自注意力(MHA),在IEU里引入特定场景信息作为偏差项来建模每个子序列并获得用户在不同场景中的局部兴趣表征。此外,MHA使LIR能够从多个兴趣角度建模用户的偏好。

Interest Extracting Unit

20.jpeg表示用户在第k个场景中发生交互的行为子序列,21.jpeg表示发生交互的商品数量。经过Embedding变换后,22.jpeg可表示场景23.jpeg中的行为Embedding矩阵。经过MHA编码后矩阵,记为24.jpeg,计算方法如下:

25.jpeg Q、K、V 将场景Embedding作为偏置项集成到行为Embedding矩阵中,以指导局部兴趣解析。它们的定义如下:

26.jpeg 其中h表示head的数量。28.jpeg表示输出线性变换的权重矩阵,29.jpeg30.jpeg分别是query、key、value对应的第i个head的投影矩阵。31.jpeg是偏置项的变换矩阵。随后,输出矩阵经过mean pooling层处理,得到一个表征向量32.jpeg,表示用户在第k个场景中的局部兴趣。特别地,对于当前场景33.jpeg。最后,LIR的输出由所有K个场景的表示组成:

34.jpeg

Interest Disentangling Module

如前所述,不同场景下的用户兴趣既有重叠之处,也有差异之处。由于不存在用户兴趣的标注信息(没有一个显式的信号告诉模型两个兴趣表征是否相似),目前的监督建模方法缺乏明确的监督信号来充分区分不同场景的兴趣。因此,我们利用自监督学习来解分离场景间的兴趣。与现有的结合对比学习的方法不同,这些方法往往侧重于复杂的数据增强技术,我们分析特定领域问题并基于原始数据设计对比策略。具体而言,我们将当前目标场景的全局兴趣表征35.jpeg和局部兴趣表征36.jpeg作为正对比样本,将其他场景的局部兴趣表征37.jpeg作为负样本。我们利用对比学习损失来教导模型增强两个正样本之间的相似度得分,削弱负样本和两个正样本之间的相似度得分。因此有两个对比任务如下:

38.jpeg 上述优化目标通过InfoNCE损失计算,如下所示:

39.jpeg 其中40.jpeg表示相似度函数,用于计算两个实例之间的余弦距离。41.jpeg是温度参数。LIR最终通过强烈的自监督信号监督场景兴趣的分离,并提高模型辨别不同场景差异的能力。

Prediction and Optimization

我们将GIA和LIR的输出、场景特征、目标商品特征和其他特征连接起来:

42.jpeg 然后结合多层DNN塔来预测用户点击目标商品的可能性:

43.jpeg 其中45.jpeg是sigmoid激活函数。对于 CTR 预测等监督任务,我们使用交叉熵损失作为目标函数:

44.jpeg 其中46.jpeg是样本的 ground truth。|D|是样本的数量。我们以端到端的方式在监督和自监督目标上训练模型。具体来说,联合损失函数带有一个超参数47.jpeg来平衡目标,可以表述如下:

48.jpeg

实验部分

我们进行了大量实验来验证我们提出的框架的有效性,并回答以下问题:

RQ1:与最先进的基线模型相比,DSWIN的表现如何?

RQ2:DSWIN中每个模块如何工作?

RQ3:提出的DSWIN中的超参数如何影响其性能?

RQ4:DSWIN能否有效地根据场景解分离用户兴趣?

实验设置

同第二篇文章SAINet所述。

整体实验结果

表6分别显示了所有方法在工业和公共数据集上的比较结果。对于每种方法,我们重复实验五次并报告平均结果。使用t-test进行统计显着性检验。我们的模型相对于最佳基线的性能在0.05水平上具有统计显著性。可以得出以下结论。

表6. DSWIN和其他方法离线效果对比 表6.jpeg

所有General recommenders方法在两个数据集上与其他解决方案相比,效果始终不尽如人意。DNN使用单个DNN塔来处理所有场景,完全忽略了场景之间的相互关系和差异。虽然DeepFM尝试在特征交互方面进行一些优化,但当数据集表现出明显的场景变化时,效果并没有得到明显改善。

为了解决General recommenders方法的问题,Scenario-specific network structures被提出。它们都比 General recommenders带来了显著的性能提升。SharedBottom 为每个场景添加了几个特定的DNN塔,以利用场景特定的知识,这不足以捕捉场景之间复杂的相互作用。MMoE使用专家和门控网络来提取有关场景和用户的更有效信息,STAR使用共享网络来维护整个场景的共性。MMoE和STAR均增强了模型跨场景学习共享知识的能力,因此优于 SharedBottom。MMoE 和STAR在多个场景中也表现出跷跷板现象(即一个场景的改进往往导致其他场景的性能下降)。例如,与最好的General recommenders模型相比,MMoE和STAR在工业数据集的场景#A4和场景#A5中的表现较差。这表明 MMoE和STAR不足以处理具有更多不同特征和不均匀数据分布的场景。PLE通过将专家分为两组(即部分场景共享和部分场景特定)缓解了这种现象,并在两个数据集上都表现出比 MMoE 显著的改进。AESM2进一步引入了专家自动选择机制,以获得比 PLE 更好的性能。

然而,Scenario-specific network structures仅仅注重网络顶层的优化,而忽略了底层表征中场景之间的差异,而这些差异对于实现最优性能至关重要。PEPNet以场景相关特征为输入,通过门控机制动态调整模型中的底层Embedding和顶层DNN隐藏单元,在两个数据集上都获得了良好的性能提升。AdaSparse的性能与 PEPNet 相比并不理想,因为稀疏隐藏单元的机制相对难以学习。SFPNet在实验中表现出所有基线中的最佳性能,因为它融合了场景信息重新定义了基本特征,同时将场景上下文融入行为以支持场景感知的用户兴趣建模。但很明显,它们都将用户的整个历史行为序列视为一个整体,而忽略了对跨场景用户兴趣差异的建模。我们提出的DSWIN以细粒度的方式明确地模拟了不同场景中用户行为的差异,并了解了用户的场景兴趣,其表现优于两个数据集上所有场景的所有基线,如表6所示。

消融实验

为了评估DSWIN中每个模块的有效性,我们还在实验中将DSWIN与其变体进行了比较。我们有三个变体,如下所示:

  • w/o GIA:删除了GIA中的SCAM和CFFM模块,直接用普通的DIN网络替换它进行全局序列建模。
  • **w/o LIR:**从DSWIN中删除了LIR模块,同时IDM模块也被分离。这意味着在不同场景中没有提取明确的局部兴趣。
  • **w/o IDM:**从DSWIN中删除了IDM模块,这意味着兴趣差异没有得到强调和解分离。

表7. DSWIN变体消融实验结果

表7.jpeg

表7所示,每个模块都做出了相当大的贡献,以确保DSWIN在所有场景中的预测结果的质量。具体而言,GIA的缺失(w/o GIA)会影响所有场景的预测性能,表明在增强针对用户行为的场景特定定制能力时,整合来自历史行为和当前实例的场景先验信息非常重要。此外,GIA模块中的CFFM还可以捕捉与当前目标场景密切相关的用户全局兴趣。此外,LIR的移除(w/o LIR)也会导致模型预测性能明显下降。这有力地验证了我们区分个人行为来自哪种场景并明确捕捉不同场景中用户兴趣差异的原理。同时,LIR有效地学习了同一场景中用户行为的内在相关性。最后,如果没有IDM,模型在两个数据集中的性能都会显著下降。这反映了注入无监督信号的自监督学习对于解开场景兴趣和确保预测准确性非常有帮助。

超参实验

我们进行了大量实验来检验几个关键超参数的影响,其中包括CFFM中的block数量k、对比损失中的温度温度.jpeg、平衡监督损失和无监督损失的权重权重.jpeg

CFFM中block数量的影响: 图10 (a) 说明了不同k的影响。随着数值增加,AUC显示出改善的趋势。这主要是因为随着k变大,兴趣表示和上下文之间的相互作用更深,而将k增加到2以上并没有带来显著的收益。

温度系数的影响:温度在 {0.1, 0.2, 0.4, 0.8} 内经过精心调整实验。根据图10 (b) 中所示的曲线,我们发现温度.jpeg的最佳选择因数据集而异。太小的值(例如0.1)或太大的值(例如0.8)都不合适。较大的值会削弱对负样本的区分能力。相反,太小的值会过度夸大某些负样本的作用,导致性能不佳。

损失平衡权重的影响:我们进行了实验,将权重.jpeg从 {1e0, 1e-1, 1e-2, 1e-3, 1e-4}中进行变化。特别地,当权重.jpeg为0时,相当于删除了模块IDM。从图10 (c) 中的结果可以看出,当权重.jpeg为1e-1时,性能达到峰值。随着值的进一步增加,模型性能变得越来越差。我们将其归因于随着权重.jpeg的增大,主预测任务重要度降低,这验证了超参数平衡不同任务目标的必要性。

图10.jpeg图10. DSWIN超参实验曲线图

可视化分析

我们通过实验来探索IDM如何促进兴趣表征学习,以及该模块是否理解不同场景下的兴趣相似度。我们基于模块LIR给出的Embedding向量计算了场景#A1和#A2输出的余弦相似度,分别在引入和不引入对比损失的情况下,同时绘制了相似度得分的分布(图11所示)。从结果中,我们可以观察到DSWIN学习到的Embedding的相似度得分小于不使用IDM 学习到的分布。这一现象表明IDM使模型能够有效地解分离场景化的兴趣。

图11.jpeg图11. LIR输出兴趣相似度分布柱形图

AB实验

我们基于实际流量进行在线 A/B测试。具体来说,我们在在线服务系统中部署DSWIN和baseline方法,并针对用户的日常请求执行推理任务。由于工业限制,在线系统中无法比较所有基线模型。因此,我们选择PEPNet作为比较的基线模型。我们取一周测试的平均结果,DSWIN比PEPNet在线获得了pvctr+1.51%的收益。1.51%在成熟的工业系统中是显著的效果提升。

结论

在本文中,我们强调了区分不同场景中用户行为差异对于兴趣建模的必要性,并设计了一种名为DSWIN的新颖的scenario-wise interest disentangling network。它首先引入GIA模块来融合用户的全局行为和场景感知上下文信息,旨在动态获取全面、细粒度的用户兴趣表征。随后,使用LIR模块明确提取用户在每个子场景中的场景兴趣。最后,DSWIN利用对比学习技术按场景解分离兴趣并辨别场景之间的区别。此外,我们提出的模型可以有效捕捉不同场景中兴趣的迁移。大量的离线和在线实验证明了DSWIN在多场景建模中的优势。

八、总结与展望

  1. 当前实现的多场景建模方法只在搜索区分流量来源这一个维度上进行实验,后续可以继续思考如何将场景化信息泛化到人群(比如按照购买力划分、活跃度、新老客等),实现多人群精细化建模;
  2. 进一步也可以将多场景泛化到得物多个行业下(比如按照商品品类进行差异化建模),实现多行业差异化建模;
  3. 在当前这三项工作中,对于用于行为序列中的商品side info还只是考虑ID类的特征(item ID,类目ID和品牌ID),而商品还有丰富的多模态(文本、图片)信息也可以进一步融合到建模当中,实现多场景 & 多模态建模。

文 / huangjin

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

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

从大模型性能优化到DeepSeek部署|得物技术

作者 得物技术
2025年2月18日 14:02

一、背景

Deepseek-r1模型的爆火标志着本地部署大模型的需求日益增长。本文主要探讨如何优化本地部署大模型的性能,并结合我们的实践进行评测分析,文章最后我们将分享如何在本地高效部署满血版Deepseek-r1大模型。

在生产环境中,我们已部署专用的大模型推理集群,并对其性能进行了全面优化。对于大模型推理来说,性能优化主要聚焦于两个关键指标:吞吐量与响应时间(RT)。

1.吞吐量 传统上,我们用每秒请求数(QPS)来衡量吞吐量,即系统每秒能够处理多少请求。但对于大模型而言,还有一个重要指标——每秒Token数(token/s),它反映了系统每秒能处理的输入或输出Token数量。 2.响应时间(RT) 这是指系统处理每个请求所需的时间。对于支持流式输出的大模型,还需要关注另外一个指标——首个Token到达时间(TTFT: Time To First Token),即从开始处理请求到输出第一个Token所需的时间

接下来文章将介绍部署高性能大模型推理服务的方法与思路,这些方法主要围绕提升吞吐量与响应时间这两个关键指标展开。

二、高性能、易扩展的大模型推理框架是什么样的

尽管业界已有许多经典的大模型推理框架,但在深入了解这些框架之前,我们不妨先思考一下,如何设计一个既高性能又易于扩展的大模型推理框架。

大模型推理框架需要满足的基本条件

性能足够高——CPU与GPU分离设计

对于一款以Python为主的大模型GPU推理引擎,要实现较高的性能,关键在于CPU与GPU的分离设计。至少需要将系统拆分为两个进程:CPU进程和GPU进程。CPU进程主要负责与CPU相关的逻辑,例如序列化、调度、分发和Resize等;而GPU进程则专注于GPU推理逻辑,其底层通过直接调用CUDA等库来进行GPU运算。

目前,主流的大模型推理框架,如vllm与sglang,已经实现或正在实施CPU与GPU分离架构。

CPU与GPU分离设计.jpeg

那么,CPU与GPU分离究竟解决了什么问题呢?

一直以来,Python 在运行时采用全局解释器锁(GIL)机制,这意味着在任意时刻只有一个线程能够执行 Python 字节码。也就是说,即使在多线程程序中,各线程也无法在 Python 层面实现真正的并行执行。这个设计主要是为了简化内存管理和对象引用计数,从而保证线程安全,但也带来了一些限制,特别是在以GPU计算为主的推理服务中更为明显。

在单一的 Python 进程中,如果同时存在多个 CPU 密集型任务(比如网络请求处理、数据预处理、请求验证等)和 GPU 任务,它们都必须在同一个 GIL 下运行。这样,CPU密集型任务就会与GPU任务竞争 GIL,导致 GPU kernel 启动调度不足,从而形成性能瓶颈。这种瓶颈表现为GPU利用率不高,在高并发场景下,GIL 的竞争会极大地影响系统的响应速度和吞吐量。

下表为我们曾经针对Python GIL锁做过的专项对比测试,在做了CPU与GPU分离设计后,GPU利用率大幅提高,QPS提升了7倍,RT缩减了50%。

截屏20250218 11.55.41.png

下面是VLLM在0.6版本做的最大变更,即做CPU与GPU进程分离设计,带来了性能的大幅提升,吞吐提升了2.7倍。具体可以参考文章[1]vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction。

5x.jpeg

可扩展性足够好——各模块高内聚低耦合

为了实现高效且易于扩展的设计,我们应将系统按照功能拆分为多个模块,每个模块只负责其特定功能,确保模块内部的高内聚和模块间的低耦合。一个完整的大模型推理框架至少需要包含以下四个模块:

  • 接入层:接入层负责处理各种请求。比如当收到OpenAI格式的请求时,接入层将其转化为内部可识别的原始请求(raw request),以便后续其他模块继续处理。
  • 调度器:调度器负责管理和调度各个请求(Request)。当有多个并发请求时,调度器动态调整模型的输入和输出,以确保计算资源得到高效利用,同时满足调度限制,如GPU缓存、最大请求数和最大处理长度等。调度器通过管理请求的状态、缓存、优先级和资源使用,确保推理过程流畅进行。
  • 模型推理:在接收到请求后,模型推理层调用相应模型的forward方法进行推理计算。其底层实际上调用CUDA等进行GPU推理。
  • 显存管理:操作系统有物理内存管理机制,避免了频繁申请和释放内存带来的碎片问题。然而,CUDA计算中并没有显存管理机制,频繁的显存申请与释放同样会导致显存碎片问题。因此,显存管理成为推理引擎中不可或缺的模块,用于解决显存碎片问题。

大模型推理框架设计

综合上述内容,我们可以设计出一个高性能、可扩展的大模型推理框架。框架图如下:

大模型推理框架设计.jpeg

从框架图中可以看出,系统首先被拆分为多个进程(多个CPU进程与GPU进程),进程间可通过管道等方式进行通信。此外,系统在逻辑上又被拆分为多个模块,其中接入层、调度器、模型推理和显存管理四个模块是必不可少的。

该架构也是当前vllm 1.0与sglang等经典推理框架的基础架构。感兴趣的同学可以通过查看相关代码,你会发现它们的设计思路大致与上面相同。比如下面的sglang推理引擎的代码,参考:[2]sglang 代码

sglang代码.jpeg

三、解决显存碎片问题,大幅提升吞吐—Paged Attention

在 Linux 等操作系统上运行的应用程序通常不会出现内存碎片问题,这是因为 Linux 内核拥有强大的内存管理机制,专门用于解决内存碎片问题。然而,这一优势仅适用于系统内存;如果在 GPU 上频繁申请和释放不规则大小的显存,就可能导致显存碎片的产生。

在大模型推理场景中,显存碎片问题尤为严重。大部分推理过程都涉及注意力计算(Attention),而每次计算都需要申请并使用一个名为 kvcache 的缓存。随着请求的不断增加,kvcache 的大小与数量会逐步上升,通常占据总总显存的约三分之一,而且它会被频繁地被申请和释放。如果不对 kvcache 使用的 GPU 显存进行有效管理,显存碎片将大量累积,最终可能导致系统性能下降甚至崩溃。

KA ca.jpeg

为了解决这一问题,vllm 提出了 Paged Attention 的概念[3],其设计思路正是借鉴了操作系统的内存管理机制,对 kvcache 的显存进行统一管理。

首先,我们回顾一下操作系统是如何避免内存碎片的。操作系统通常将物理内存划分为若干固定大小的块,并利用页表将应用程序的虚拟地址映射到相应的物理内存块。当应用程序申请内存时,系统先分配虚拟地址,然后在实际使用时,从固定大小的物理内存块中分配空间,并建立虚拟地址与物理地址之间的映射。这样,就能有效避免内存碎片问题。

内存碎片问题.jpeg

Paged Attention 正是基于这一原理而提出的——“Paged”体现了类似页表的映射方式,而“Attention”则表示这种映射机制被应用在大模型注意力计算中。

大模型注意力计算.jpeg

PagedAttention 工作原理,图片来自[3] Efficient Memory Management for Large Language Model Serving with PagedAttention。

vllm 的 Paged Attention 是一种受操作系统虚拟内存和分页机制启发的注意力算法。它将大型语言模型中的 KV Cache 缓存划分为固定大小的块,这些块可以在内存中非连续存储。然后通过Block table(类似Linux页表)把每个请求的逻辑KV 块(类似Linux虚拟地址)映射到物理KV 块(类似物理内存)中。通过这种方法,PagedAttention 能有效管理内存,减少碎片和浪费,大幅提升系统的吞吐量。

Paged Attention评测效果

paged 评测效果.jpeg

图片来自[4] vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention

通过高效的内存管理,Paged Attention减少了内存浪费,提高了 GPU 的利用率,使模型的推理吞吐量比传统方法提升了数倍。例如,与 HuggingFace Transformers 相比,吞吐量可提升至 24 倍;与 HuggingFace TGI 相比,提升可达 3.5 倍。此外,它还降低了内存开销,支持更复杂的采样方法,使 LLM 服务变得更快、更经济。

目前VLLM与SGLang等推理引擎默认支持Paged Attention开启,所以你使用这些推理引擎部署大模型,系统会自动支持。

四、缓存之前请求的计算结果,减少重复计算—Radix Attention

虽然 Paged Attention 成功解决了显存碎片问题,并显著提升了系统吞吐量,但在大模型推理中,还有一个常见场景具备较大性能优化空间。

实际应用中,我们往往需要多次给大模型发送请求,而这些请求的Prompt中有很大一部分的内容是完全相同的。如下所示:

prompt.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

上图中蓝色框表示可共享的Prompt部分,绿色框表示不可共享的Prompt部分,黄色框表示模型的输出。

上图中可共享部分场景包括少量示例学习、自我一致性中的问题、多轮对话中的聊天历史,以及思维树中的搜索历史。在这些场景中,每次请求都会重复计算这些Prompt中可共享的部分,这些会造成大量的计算资源浪费。

那么,有没有办法将这些重复部分的计算结果(KV Cache)缓存起来,下次请求时直接使用呢?为此,SGLang 提出了一种优秀的算法—— Radix Attention。

RadixAttention 是一种新技术,用于在大语言模型的推理过程中优化 KV 缓存的重用。其核心在于利用基数树(Radix Tree)来高效管理和重用不同请求之间共享的前缀,从而减少重复计算和内存占用。也就是说,当多个请求共享相同的前缀(例如系统提示 "You are a helpful assistant")时,RadixAttention 可以重用该前缀对应的 KV 缓存,避免重复计算。

下图中的例子展示了 RadixAttention 在九个时间点上的基数树动态演变过程,以下是具体步骤的解释:

以下是具体步骤的解释.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

  1. 初始状态:基数树最初为空。
  2. 第一场聊天开始:用户发送“你好!”,助手回复“你好!”,系统提示、用户消息和助手回复被合并为基数树中的一条边,连接到新节点。
  3. 第一场聊天继续:同一会话中收到新消息,服务器在基数树中找到已有前缀并重用其KV缓存,新消息作为新节点附加到树上。
  4. 第二场聊天开始:另一个用户开始新的聊天会话,服务器分割现有节点,使两个会话共享公共前缀和KV缓存。
  5. 第二场聊天继续并进行淘汰:第二场聊天继续,因内存限制,第一场聊天中最少近期使用的节点被淘汰释放空间,新消息在共享节点后添加到树上。
  6. 处理少样本学习查询:服务器收到不与现有节点共享前缀的少样本学习请求,根节点被分割以容纳新序列,请求作为单独分支插入树中。
  7. 处理一批少样本学习查询:收到一批共享相同少样本示例的查询,分割第六步的节点以在这些查询间实现KV缓存共享,最大化重用并减少冗余计算。
  8. 第一场聊天继续并进行淘汰:第一场聊天继续,基于最近最少使用(LRU)策略,淘汰第二场聊天的节点以高效管理内存。
  9. 自一致性采样并进行淘汰:服务器收到生成多个答案的请求,为自一致性目的,按照LRU策略淘汰较不重要的节点以为新请求分配内存。

那Radix Attention带来的性能提升如何呢?我们针对SGLang推出的Radix Attention与VLLM (v0.5.0)进行了对比评测。同时由于Radix Attention可以复用不同请求的上下文,这与我们的日常业务使用比较吻合,取得了不错的评测结果,Radix Attention充分利用不同请求之间的共享前缀,其耗时比VLLM(v0.5.0)快30%,吞吐是VLLM(v0.5.0)的1.5倍。

下面为SGLang给出的Radix Attention性能对比效果,与当前系统相比,SGLang吞吐提升了5倍以上。

SGLang吞吐量.jpeg

图片来自[5] Fast and Expressive LLM Inference with RadixAttention and SGLang

如果你也想尝试下Radix Attention,可以直接使用SGLang的推理引擎去启动大模型尝试下。

五、请求分块处理,避免单个请求卡顿 —— Chunked Prefill

在将大模型应用于生产环境时,我们有时会遇到一种奇怪的现象:某个请求的响应时间(RT)异常长,甚至出现卡顿,而系统的平均响应时间却依然正常。这是什么原因导致的呢?又如何解决呢?

大模型的推理过程实际上可以分为两个阶段:prefill 阶段和 decode 阶段。举个例子:假设我们输入了一个包含 1000 个 token 的 prompt,并希望模型生成 100 个 token 的响应。

  1. Prefill 阶段:系统首先对这 1000 个 token 进行并行推理,这一步骤可以充分利用 GPU 的并行计算能力。
  2. Decode 阶段:随后,系统会逐个生成后续的 100 个 token。由于每个新生成的 token 都依赖于之前的输出,因此这一阶段必须按顺序逐个生成。

decode按顺序生成.jpeg

在实际应用中,多个请求往往会同时进行推理,因此可能出现不同请求的阶段交叉运行。例如,如果请求 req3 的 prefill 阶段处理了一个非常长的 prompt,那么它就会占用大量的 GPU 资源;而如果此时 req13的 prefill 阶段与请求 req1的 decode 阶段并行运行,就会导致 req1的 decode 阶段响应速度明显变慢,甚至出现卡顿现象。

卡顿现象.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public) ,较大的prompt请求与decode阶段请求并行调度,算力资源争抢,会显著影响decode阶段。

问题原因明确了,解决方法也十分简单:缩短每次提交给 GPU 并行计算的 prompt 长度。具体来说,我们可以将整个 prompt 按照固定长度(例如 512 个 token)进行分块,每次在 prefill 阶段只处理一块。这样一来,每次并行计算的内容就变得更短,不仅能减轻单个请求对 GPU 资源的占用,还能避免对同时运行的 decode 请求产生影响。这个方法便被称为 chunked prefill

chunked1.jpeg

chunked2.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public) 开启chunked prefill后,prefill与decode并行互不影响。

如下图所示,vllm 通过启用 chunked prefill 功能,显著降低了系统的最大响应时间(max RT)。

max.jpeg

图片来自 Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve vLLM @ Fourth Meetup (Public),开启chunked prefill后,在高并发QPS下,平均RT提升了2倍。

在 vllm 的最新版本中,chunked prefill 已默认开启。

六、缩短输出长度,显著提升性能

在前文中,我们提到过大模型的推理过程分为 prefill 阶段和 decode 阶段。Prefill 阶段主要对 prompt 进行注意力计算,并可以并行进行;而 decode 阶段则用于生成新输出的 token,这一阶段必须按顺序逐个生成,无法并行。

因此,如果我们能够在 decode 阶段尽量减少生成的 token 总长度,就能显著提高整体的响应时间(RT)。具体来说,如果每生成一个 token 耗时 5 毫秒,减少 5 个 token 的输出,就能减少 25 毫秒的总响应时间。对于非流式调用的大模型来说,这种改进会带来显著效果。例如,如果大模型只需进行简单的分类或识别任务,那么仅需输出结果即可,其他无关信息完全不需要生成。

那么如何缩短大模型的输出长度呢?

a.限制最大输出长度 首先,可以通过设置系统参数来限制大模型的最大输出长度。如果你通过 OpenAI 接口调用大模型,在输入参数中有一个叫做 max tokens 的设置项。你可以调整该参数,限制大模型的最大输出长度。这样一来,可以避免大模型产生过长的输出,甚至防止无限循环的情况。一个示例代码如下:

一个示例代码.jpeg

b.通过 Prompt 限制输出 另外,可以通过优化 prompt 来引导大模型产生更短的输出。例如,在 prompt 中加上类似“请直接输出结果”或“请尽可能简短输出结果”的提示语,可以有效减少无关内容的输出。

c.微调大模型 如果条件允许,微调大模型也是一种有效的方法。通过微调,可以让大模型在满足需求的前提下尽量缩短输出。微调的过程中,首先可以通过 prompt 调整输出长度,制造大量数据后,再对大模型进行训练。这样一来,不仅输入的内容被优化,输出的长度也能有效缩短,达到更好的效果。

达到更好的效果.jpeg

七、使用多卡推理,推理速度翻倍

在某些场景下,出于模型效果考虑无法对大模型进行量化,但对响应时间(RT)有非常高的要求。这时,尝试 多卡推理 可以带来立竿见影的效果,通常能够将 RT 缩短至原来的 1/3 或 1/2。

以下是我们针对 单卡双卡 推理性能的对比测试结果:

截屏20250218 11.56.09.png

可见推理中单卡变双卡可以显著提升大模型推理速度与QPS。

那为什么多卡可以提升大模型的推理速度呢?主要原因多卡推理的优化是通过 tensor parallelism(张量并行)实现的。

假设你将 tensor parallel 设置为 2,意味着使用两张 GPU 来加速推理。在模型加载时,推理引擎会将大模型的 attention 参数的数量分为两组,分别加载到每张 GPU 上。然后,在推理过程中,两个 GPU 会并行计算注意力,最后再将结果聚合合并。

想象一下,你有一本非常厚的书,你想一次性复印整本书,但是你的复印机一次只能复印几页。这时,你可以把这本书分成几个部分,每个部分分别复印,最后再把所有复印好的部分按顺序拼接起来,这样就完成了整本书的复印。

在张量并行中,我们要处理的大模型就像是那本厚书,而GPU则像是复印机。因为单个GPU无法一次处理整个大模型,我们就需要把模型(在这个例子中是权重张量)分成几个部分,让不同的GPU分别处理(相当于复印书的不同部分)。在处理输入数据时,就像是把书的每一页分别复印,然后再把复印好的各个部分拼接起来,形成完整的输出结果。

形成完整的输出结果.jpeg

如何配置tensor parell并行呢?下面我们分别给出vllm 与sglang两款大模型的配置方式。

1.vllm配置多卡推理

以下命令为vllm如何配置多卡推理的方式。

配置多卡推理.jpeg

图片来自[6] vllm Documentation

2.SGLang配置多卡推理

以为命令为SGLang推理服务如何配置多卡推理。

SGLang多卡推理.jpeg

八、小模型推理+大模型验证 —— 预测解码 (Speculative Decoding)

最近,一种名为预测解码的加速技术备受关注,它能够在特定条件下显著提升大型模型(如72B大模型)的推理速度。

推理速度.jpeg

预测解码工作原理比较简单,假如你想加速一个70b大模型。你可以训练一个同类型的7b小模型,然后开启预测解码。系统同时加载7b小模型与70b大模型,在推理的时候先让7b小模型做输出,比如输出5个token。然后再把这5个token交给70b大模型去做验证,并保留验证正确的前N个token做为输出,以此类推。

由于验证是可以批量进行的,而小模型的推理速度又比较快。这样就可以大大提升70b大模型的推理速度,同时保障70b大模型的效果。

以下为我们针对70b模型所做的实验效果。

截屏20250218 11.56.32.png

可见预测解码在一定的场景下可以提升大模型的推理速度。

此外还有一种更简单的方式,如果你不想训练7b的小模型,而你的输出中大部分都与输入promt相似,比如你只是让大模型帮你修改下文章中错别字。那么你可以直接使用n-gram匹配prompt的方法替代小模型,即直接从输入prompt中选取预测的token,让大模型直接去验证。这样输出速度更快,更简单。

关于预测解码,想深入了解的同学可以参考vllm的这篇文档。[8] How Speculative Decoding Boosts vLLM Performance by up to 2.8x

下面我们介绍下如何在vllm中配置预测解码。

a.使用大模型+小模型的方式

在这里插入图片描述

b.使用n-gram的方式

使用ngram的方式.jpeg

九、高效部署Deepseek-R1模型的方法

前面我们介绍了业界大模型性能优化的很多方法,接下来我们将用SGLang这个推理引擎来部署下最近爆火Deepseek-R1满血版大模型。下面分享下详细的部署步骤。

a.如何下载Deepseek-r1

这次Deepseek发布了一系列模型,如下:

如何下载deep.jpeg

如何下载deep2.jpeg

原始模型包括Deepseek-r1-zero与Deepseek-r1,其中Deepseek-r1是官方推荐的经过多阶段训练的最优模型。但是Deepseek-r1有671B大小的参数,部署起来至少需要2*8H20GPU,比较耗费资源。所以deepseek团队又基于Deepseek-r1蒸馏出了一系列小的模型,其中效果不错比如DeepSeek-R1-Distill-Qwen-32B,单卡H20可以运行启动。

我们这里只介绍满血版Deepseek-r1的部署方法。

b.准备部署环境

我们尝试使用SGLang这个大模型推理引擎部署Deepseek-r1。以下为我们的部署软硬件环境准备。

截屏20250218 13.39.10.png 部署Deepseek-r1

准备好SGLang镜像,deepseek-r1模型,GPU后,可以按照如下命令启动deepseek-r1

node 1:

python -m sglang.launch_server --model-path deepseek-ai/DeepSeek-V3 --tp 16 --dist-init-addr 10.0.0.1:5000 --nnodes 2 --node-rank 0 --trust-remote-code

node 2:

python -m sglang.launch_server --model-path deepseek-ai/DeepSeek-V3 --tp 16 --dist-init-addr 10.0.0.1:5000 --nnodes 2 --node-rank 1 --trust-remote-code

这里使用的是多机多卡部署,部署后使用Openai格式请求发送到node1上即可。

多记多卡部署.jpeg

十、总结

文章依次总结了部署高性能大模型推理服务的技巧与实践,先后介绍了Paged Attention,Radix Attention,chunked prefill,多卡并行等大模型推理加速方法,并给出验证结果与操作方法。文章最后还给出最近爆火的deepseek-r1的高效部署方法,欢迎大家去尝试优化。

后续我们将会持续关注大模型推理性能提升方面的最新技术,验证并及时分享给大家。

参考文献

[1] vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction blog.vllm.ai/2024/09/05/…

[2] sglang 代码 github.com/sgl-project…

[3] Efficient Memory Management for Large Language Model Serving with PagedAttention(arxiv.org/abs/2309.06…)

[4] vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention blog.vllm.ai/2023/06/20/…

[5] Fast and Expressive LLM Inference with RadixAttention and SGLang lmsys.org/blog/2024-0…

[6] vllm Documentation docs.vllm.ai/en/latest/

[7] SGLang Documentation docs.sglang.ai/backend/ser…

[8] How Speculative Decoding Boosts vLLM Performance by up to 2.8x blog.vllm.ai/2024/10/17/…

文 / menglinggong

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

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

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

得物端智能视频封面推荐

作者 得物技术
2025年2月13日 15:00

一、背景

什么要做智能封面?

用户可以在得物购物,也可以在得物社区分享自己的生活。

得物社区中的视频使用双列流,每条内容包含封面、标题等。

  • 对得物社区的创作者而言,选择视频封面是创作链路的重要环节。
  • 对得物社区的消费者而言,封面是影响 CTR(点击率)的关键因素。

封面推荐可以降低创作者的创作成本,提高消费者 CTR。

端智能介绍

端智能(Edge/Client Intelligence)是指在边缘设备(如物联网设备、智能传感器、移动设备等)上进行数据处理和智能决策的能力。与云计算模型相比,端智能将计算、存储和分析功能移到更接近数据源的地方,优势如下:

  • 低延迟 :数据在本地处理,减少了传输到远程服务器的时间,提高响应速度。
  • 节省带宽 :通过在本地处理数据,仅发送必要的信息到中心服务器,减少了网络带宽的消耗。
  • 数据隐私和安全 :数据在本地处理,敏感信息不必传输到云,从而提高了数据隐私和安全性。
  • 可靠性 :在网络连接不稳定或中断的情况下,边缘设备可以继续进行本地处理和决策。

尽管端智能带来了很多优势,但在实际应用中也面临一些挑战:

  • 计算能力的局限性 :边缘设备通常具有有限的计算资源,可能无法处理复杂的人工智能模型。
  • 数据一致性与协同 :多个边缘设备之间的数据一致性和协调处理仍然是一个挑战。
  • 设备管理与部署 :随着设备数量的增加,边缘设备的管理、监控和更新变得更加复杂。

考虑到用户隐私、实时性和服务端压力,我们选择用端智能推荐视频封面,并克服相关的挑战,最终获得收益。

得物端智能

对客户端而言,不需要训练模型,只需要推理。

端智能框架可以简化推理过程,常见的端智能 SDK 如下:

  • 开源 SDK:MNN、TNN、NCNN、Paddle Light、TensorFlow Light 等。
  • 闭源 SDK:ByteNN、Pitaya、KwaiNN、Ykit 等。
  • 系统 SDK:CoreML(iOS)、MLKit(Android)等。

考虑到 iOS、Android 双端的通用性和开发成本,得物基于 MNN [1] 框架,开发得物端智能推理基建。端智能基建核心功能如下:

  • 提供端智能模型管理后台,提供完整链路,管理模型的放量。
  • 端侧提供统一的基建,方便业务进行模型的下载、运行管理,以及熔断和降级的处理,降低使用门槛。
  • 提供相对完善的稳定性和性能监控机制,及时报警和出错时止损。

整体架构

智能封面主要开发流程如下,算法侧产出端智能模型,客户端调用模型推荐视频封面。

整体架构.jpeg

二、内容理解算法

算法调研

端智能封面推荐场景要求无参图片质量评价(NR-IQA)、轻量化,因此基于目前的前沿进展进行调研和摸底,确定相关实现方案。主要的调研内容:

Faster-VQA[2]:轻量化的视频质量评估模型。核心是使用优化版本的Transformer->Swin-Transformer来减少网络计算,加速效率。

算法调研.jpeg

UNIQA[3]:统一的图像质量评估(IQA)框架,旨在同时处理全参考(FR)和无参考(NR)任务。现有的IQA模型通常只能处理FR或NR任务之一,而人类视觉系统(HVS)则可以无缝地在两者之间转换,因此提出开发一个能够像人类一样处理不同类型图像质量评估任务的模型,统一全参/无参两类任务。

统一全参:无参.jpeg

LAR-IQA[4]:轻量级的NR-IQA模型,基于MobileNetV3提出了一种新的无参考图像质量评估模型LAR-IQA。该模型旨在解决现有模型在实际应用中的局限性,特别是对于资源受限的移动设备上的实时图像质量评估任务。核心贡献点有:双分支架构、多色空间训练、Kolmogorov-Arnold Networks (KAN)结构代替MLP。

代替MLP.jpeg

CLIP-IQA[5]:利用(引入)对比语言-图像预训练( CLIP)模型来评估图像的视觉感知,包括图像的质量(look)和抽象感知(feel),无需进行特定任务的训练。核心在于利用CLIP中蕴含的视觉语言先验,通过精心设计的提示策略来提升评估性能。同时提出了一种反义词提示配对策略(如“好照片”和“坏照片”成对使用),以减少语言模糊性并增强模型在视觉感知评估中的表现。此外,为了克服CLIP对固定尺寸输入的要求及其可能引入的额外失真问题,增加了移除位置嵌入的方法,进一步提升了模型与人类感知的一致性。

人类感知的一致性.jpeg

Q-Align[6]:目前NR-IQA领域的SOTA模型,将大模型引入到视觉打分任务中。通过文本定义的级别(例如好、差等)而不是直接的分数(例如3.45、1.77)来指导训练LLMs。标志着在视觉评分领域的一个重要进展,通过创新地使用离散文本定义级别来训练LMMs,不仅提高了评分的准确性和鲁棒性,还为未来的研究开辟了新的方向。

未来研究方向.jpeg

技术卡点

端侧模型存在体积限制,考虑到带宽成本、推理速度等,将模型体积控制在 30M 以内。

目前图片质量打分 sota 模型,整体都是从打分效果出发,不考虑模型性能(size/推理耗时/cpu性能/MAC等),最小的模型体积也超过 120M,不满足端上移植的要求。

现有的 Faster-VQA 和 LAR-IQA 虽然模型打分效果都不错,但是同样因为尺寸超额无法直接使用,也无法直接移植。

算法方案

轻量化网络:本次算法模型主要在手机本地部署,受限于带宽和计算资源限制,对模型尺寸有严格要求。综合考虑后采用业界比较成熟的轻量化模型 MobileNetV3 结构作为基础框架模块,从0到1重新训练轻量化图片打分模型。

数据清洗与数据集构建:考虑到图片-质量分数据的缺失,使用开源图片评价大模型对数据预标注(必要时进行人工介入清洗),通过多模型交叉打分验证和人工标注,最终总体训练数据量级超过10w+。整体流程如图所示:

轻量化图片质量模型.jpeg轻量化图片质量评价模型

loss优化:loss设计上采用回归任务loss+主观感知偏差衡量loss,超参数控制多loss融合。

模型移植

MNN 模型支持 Tensorflow、Caffe、ONNX、Torchscripts 等主流模型文件格式,支持CNN / RNN / GAN / Transformer 等主流网络结构。

MobileNetV3 使用 PyTorch 框架创建、训练、持久化模型,需要先转换成为ONNX格式,然后再转换成 MNN 模型。通过 FP16/Int8 压缩与量化,模型最终大小为 24M,客户端可以接受。

在客户端进行模型推理调用时,需关注输入图片的尺寸、预处理方式以及输出数据格式等方面。这些参数与模型相互绑定,且在后续的迭代过程中应保持同步。

三、客户端部署

整体流程

整体流程如图所示,用户进入封面选择页,首先对视频抽帧,然后调用端智能推理。端智能输出一个评分,获取评分最高的图片作为推荐的封面。为了提高封面识别速度,采用批量异步计算。

时序图.jpeg时序图

整体架构如图所示,双端共用端智能基建,各自实现具体的业务逻辑。ClientIntelligence 作为端智能基建,底层封装了 MNN、OpenCV 等,实现了模型管理(下载、缓存等)、推理、监控等功能。

架构图.jpeg架构图

推理一致性

推理一致性(Inference Consistency)是指在不同时间、不同环境、或不同条件下,模型输出的结果保持稳定、可靠、一致的能力。这是一个非常重要的概念,尤其是在部署机器学习模型时,确保模型的推理一致性对于维护模型的质量和可信度至关重要。

推理不一致的来源:

  • 在不同硬件平台上运行模型(例如不同的 CPU、GPU、TPU 等)可能会导致数值精度上的细微差异,进而影响推理结果。
  • 不同的深度学习框架(例如 TensorFlow、PyTorch )可能会在推理过程中产生不一致的结果,尤其是涉及到数值计算时。
  • 输入数据预处理方式不一致导致推理结果不同,可以通过数据标准化、归一化等减少对推理结果的影响。

具体到智能封面的场景,主要面临下面几种一致性:

  • PyTorch、ONNX、MNN 推理一致性:不一致主要来源框架本身,端上模型为了提高推理速度,会对模型进行量化,比如将浮动精度的模型(如 Float32)转换为低精度模型(如 INT8)。框架造成的推理结果不一致无法避免。
  • iOS、Android 双端推理一致性:输入数据预处理方式是影响推理一致性的关键因素,在智能封面场景,图片数据的预处理方式需要保持一致。双端由于硬件的差异,推理结果也不同。此外,使用 CPU、GPU 推理结果也会存在细微的差别。智能封面会对图片评分,选择评分最高的图片,因此硬件造成的差别在本场景下可以接受。

耗时优化

用户在封面选择页面停留时间有限,因此要尽可能地减小封面推荐耗时。

首先要定位到耗时操作,然后有针对性地优化。

在本场景中,耗时操作包含抽帧、推理,具体优化如下:

  • 并行计算:多线程同时抽帧、推理,需要注意的是,并行数需要考虑 CPU 和内存的占用。
  • GPU 推理:端智能同时支持 CPU 和 GPU 推理,通过 GPU 推理可以显著减小耗时。
  • 不同性能的手机处理速度差别较大,低性能手机会适当减小抽帧数量,以提高运行速度。

优化后,可以在秒级完成抽帧、封面推荐全过程。

四、收益与效果评估

线上效果对比

线上智能封面、非智能封面抽样结果如下,使用智能封面功能,整体画风更优,更清晰。

智能封面1.png智能封面.jpeg智能封面3.jpeg智能封面

非智能封面1.jpeg转存失败,建议直接上传图片文件非智能封面.jpeg转存失败,建议直接上传图片文件非智能封面3.jpeg转存失败,建议直接上传图片文件

非智能封面1.jpeg非智能封面.jpeg非智能封面3.jpeg非智能封面

竞品效果对比

得物智能封面与主流短视频平台对比结果如下,整体选帧效果和主流短视频平台可比,部分场景效果较优。

得物.jpeg得物2.jpeg得物3.jpeg得物4.jpeg得物

短视频平台A.jpeg短视频平台A2.jpeg短视频平台a3.jpeg短视频平台a4.jpeg短视频平台A

短视频平台B.jpeg短视频平台n2.jpeg短视频平台b3.jpeg短视频平台b4.jpeg短视频平台B

人工GSB评测

在智能封面功能上线后,我们随机抽取了线上真实的视频数据,并通过人工GSB(Good Same Bad)评估方法,对智能选帧所得的图片与默认首帧图片进行了图像质量的对比分析。

多组数据、多人次测评整体评估结果为:Good(好)361票,Same(一样)182票,Bad(差)95票。

相较于默认首帧图片,智能选帧的GSB评分提升了 41.7%,表明选帧功能在图像质量上有了显著的改进。

线上实验收益

在发布侧,采用智能封面点击率、选择率作为衡量指标,获得了显著的收益,其中智能封面点击率 5.5%,非首帧封面选择率相对提升 +25.61%。

在内容推荐侧,采用推荐双列流视频点击率作为衡量指标,pvctr 和 uvctr 都有明显提升,与对照组相比,pvctr+13.12%、uvctr+18.05%。实验结果也表明在推荐双列场景下,更好得封面内容会带来更好的消费。

五、总结

本文通过端智能推荐视频封面,帮助创作者降低发文成本,提高发文质量。

我们也希望将端智能用在更多的场景,提高用户体验。

六、参考资料

  1. github.com/alibaba/MNN
  2. Wu, Haoning, et al. "Fast-vqa: Efficient end-to-end video quality assessment with fragment sampling." European conference on computer vision. Cham: Springer Nature Switzerland, 2022.
  3. Zhou, Hantao, et al. "UniQA: Unified Vision-Language Pre-training for Image Quality and Aesthetic Assessment." arXiv preprint arXiv:2406.01069 (2024).
  4. Avanaki, Nasim Jamshidi, et al. "LAR-IQA: A Lightweight, Accurate, and Robust No-Reference Image Quality Assessment Model." arXiv preprint arXiv:2408.17057 (2024).
  5. Wang, Jianyi, Kelvin CK Chan, and Chen Change Loy. "Exploring clip for assessing the look and feel of images." Proceedings of the AAAI Conference on Artificial Intelligence. Vol. 37. No. 2. 2023.
  6. Wu, Haoning, et al. "Q-align: Teaching lmms for visual scoring via discrete text-defined levels." arXiv preprint arXiv:2312.17090 (2023).

文 / Devin&linghu

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

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

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

从0到1构建 Kubernetes中间件运维平台:标准化、可视化与全栈运维的最佳实践|得物技术

作者 得物技术
2025年2月11日 14:28

一、项目背景

传统运维的痛点与挑战

在传统的中间件运维过程中,存在以下几个突出问题:

  • 管理分散:不同中间件( Kafka和Elasticsearch)都有独立的管理台,运维逻辑分散,难以形成统一规范。
  • 成本高昂:运维操作与各自的管理台强绑定,SRE 需要学习不同工具,操作复杂,维护成本高。
  • 黑屏操作依赖:很多关键运维操作需要依赖 kubectl apply 等黑屏命令,操作门槛高,风险大。

Kubernetes与Operator的优势

Kubernetes(K8s)和 Operator 提供了一套通用的运维管理机制,将中间件运维操作抽象成 Kubernetes CR(Custom Resource)对象,由 Operator 负责具体的运维执行。这种模式具备以下优势:

标准化:运维操作可以以 CR 为中心进行统一管理。

自动化:减少了人工干预,降低了人为失误的风险。

可视化:可以通过 UI 平台降低运维复杂度。

平台建设的核心目标

基于上述背景,我们决定建设一个统一的中间件运维平台,目标包括:

  • 标准化:统一规范中间件的运维操作,沉淀最佳实践。
  • 自动化:减少对黑屏操作的依赖,提升运维效率。
  • 可视化:通过 UI 界面,让运维操作更加直观、简单。

二、建设历程

平台架构概览

在展开详细的建设内容前,我们先看看整体的架构设计。本架构图展示了白屏化运维平台的核心组成和各层之间的交互关系,帮助我们更直观地理解平台的整体运作逻辑和功能分布。

平台架构概览.jpeg

运维平台层的核心作用

运维平台层是整个白屏化运维平台的中枢大脑,承上启下,连接用户层与 Kubernetes 集群层,同时对接外部系统,确保运维操作的标准化、自动化和可审计。它的架构图如下:

运维平台层的核心作用.jpeg

运维平台的具体作用包括:

多云管理服务

  • 统一托管来自不同云厂商的 Kubernetes 集群,确保多云环境下的资源可视化和统一调度。
  • 为大规模中间件部署提供基础支撑,保障平台跨云高可用性。

中间件运维服务

  • 负责对 Kafka 和 Elasticsearch 进行统一的部署、运维和管理,规范操作流程,降低运维复杂度。
  • 提供可视化操作界面,降低 SRE 的操作门槛。

K8s 通用资源管理服务

  • 统一管理 Kubernetes 中常见的资源,包括 Node(打标、污点管理)、PV(云盘释放)、PVC(生命周期管理)、SVC(服务暴露与管理)、Pod(日志查看、终端登录、CPU BURST)。
  • 减少对黑屏命令的依赖,降低运维风险,提高操作效率。

YAML 管理服务

  • 版本管理:提供 YAML 文件的版本控制功能,支持版本新增、修改、回滚 和 差异对比(Diff)。
  • 变更可审计:所有 YAML 配置的变更都会被详细记录,确保每次配置变更都可追溯。
  • 配置可视化:提供可视化 YAML 编辑界面,降低操作错误率。

操作审计服务

  • 平台操作审计:对平台内所有运维操作进行详细记录,确保操作可追溯。
  • 对接 DCheck:将审计数据传送至 DCheck 和NOC事件中心,进行合规性检查和安全监控,保障操作安全性和可控性。

运维平台层不仅是各类运维操作的执行中枢,更是数据流通的核心枢纽,负责将用户的运维请求转化为 Kubernetes 资源变更操作,同时记录和审计所有操作,确保系统的安全性和可追溯性。

接下来,我们将深入剖析这些核心服务,看看它们是如何在实际场景中解决痛点、提升效率的。

多云管理:跨云资源托管,告别kubeconfig切换地狱

故事背景

“Kubeconfig 切换地狱,谁用谁知道。”

小卡作为一名资深 SRE,每天都要在多个 Kubernetes 集群之间穿梭,管理不同环境下的资源。这些集群来自不同的云厂商,运行在不同的 Kubernetes 版本上,甚至还有不同的认证和网络策略。

  • 传统方式:每个 Kubernetes 集群都需要一个对应的 kubeconfig 文件,存储在本地的 ~/.kube 目录中。
  • 上下文切换:每次操作前,需要执行kubectl --kubeconfig=/path/to/kubeconfig,或者使用 kubectl config use-context 切换上下文。
  • 风险高:当集群数量增多时,小卡根本记不清当前所操作的集群是 kubeconfig1 还是 kubeconfig2。有时候,为了省事,直接用 cp kubeconfig1 config,再去执行 kubectl 命令,完全忘记当前上下文对应哪个集群。
  • 灾难场景:一不小心,将生产集群当成测试集群,直接执行了 kubectl delete pods --all,后果不堪设想。

痛点分析

多 kubeconfig 文件管理混乱

  • 每个集群一个 kubeconfig,本地目录下文件堆积如山,管理成本高。
  • 使用 kubectl config use-context 切换上下文,容易混淆当前所在的集群。

操作风险高

  • 一旦操作上下文错误,轻则资源误删,重则导致生产事故。
  • 缺乏有效的权限隔离和审计,无法追踪到具体的操作人和上下文。

跨云兼容性问题

  • 每个云厂商的 Kubernetes 集群可能存在不同的 API 版本和兼容性问题。
  • 手动管理多个 Kubernetes 版本的集群,风险和维护成本极高。

访问性能瓶颈

  • Kubernetes API 请求频繁直接访问集群,容易导致延迟和性能瓶颈。
  • 每次查询都需要访问 Kubernetes API,缺乏高效的缓存机制。

解决方案

根据运维同学的痛点,我们计划构建一个多云 Kubernetes 集群管理平台,实现跨云环境资源的统一托管、可视化管理与快速访问,避免 kubeconfig 切换带来的混乱和风险。效果图如下:

效果图如下.jpeg

目标和行动拆解:

目标和行动拆解.jpeg

截至目前,平台已跨云托管了30+套Kubernetes集群。

中间件运维:Kafka 扩容,从黑屏脚本到白屏可视化

故事背景

“Kafka 扩容——一个让人捏把汗的运维操作”

凌晨三点,运维小卡的手机突然爆炸式震动起来,屏幕上跳出无数条报警消息:“Kafka 集群负载过高,CPU 使用率接近 100%!”

小卡揉了揉惺忪的睡眼,坐在电脑前打开黑屏终端,迅速敲下一连串熟练的命令:

kubectl --kubeconfig=k8s-xxx-prd get kafka

他屏住呼吸,盯着屏幕上的滚动字符,一行一行地检查 Kafka 集群状态,判断哪些节点资源吃紧,哪些副本需要扩容。然而,每次操作都让他倍感焦虑——“这可是生产环境啊,万一一行命令敲错,就要上新闻头条了!”

  • 第一步:修改 YAML,spec.replicas +1。
  • 第二步:轮询所有 Pod 状态,检查是否都变为 Running。
  • 第三步:调用 Cruise-Control API,触发数据迁移。
  • 第四步:轮询数据迁移状态,直到所有分区完成重新分配。

四步流程,看似简单,但每一步都需要小卡屏息凝神,稍有差错,就可能导致数据丢失,甚至集群崩溃。

“这种凌晨抢救场面,为什么不能更简单一点?” 小卡心里忍不住嘀咕。

传统 Kafka 扩容黑屏脚本

在中间件运维场景中,Kafka 集群扩容是一项典型的复杂运维任务。这不仅仅是一个简单的「增加节点」操作,还涉及到集群状态监控、资源调度、数据迁移等多个环节。

传统方式下,SRE 需要通过黑屏脚本完成扩容任务,整个过程不仅繁琐,还充满了不确定性。

以下是一个 Kafka 集群扩容的典型黑屏脚本示例:

#!/bin/bash

# 设置 kubeconfig
export KUBECONFIG=/path/to/kubeconfig

# 1. 检查 Kafka 集群状态
echo "Step 1: 查询 Kafka 集群状态"
kubectl get kafka -n kafka-namespace

# 2. 扩容 Kafka 集群副本数
echo "Step 2: 扩容 Kafka 集群"
kubectl patch kafka my-cluster -n kafka-namespace --type='merge' -p '{"spec":{"kafka":{"replicas":5}}}'

# 3. 轮询 Kafka Pod 状态
echo "Step 3: 检查所有 Kafka Pod 是否 Running"
while true; do
    READY_PODS=$(kubectl get pods -n kafka-namespace -l app.kubernetes.io/name=kafka -o jsonpath='{.items[*].status.phase}' | grep -o "Running" | wc -l)
    TOTAL_PODS=5
    echo "Running Pods: $READY_PODS / $TOTAL_PODS"
    if [ "$READY_PODS" -eq "$TOTAL_PODS" ]; then
        echo "所有 Kafka Pod 已经就绪"
        break
    fi
    sleep 5
done

# 4. 触发数据迁移
echo "Step 4: 开始数据迁移"
curl -X POST "http://cruise-control.kafka-namespace.svc.cluster.local:9090/kafkacruisecontrol/rebalance" -d "dryrun=false"

# 5. 轮询数据迁移状态
echo "Step 5: 等待数据迁移完成"
while true; do
    STATUS=$(curl -s "http://cruise-control.kafka-namespace.svc.cluster.local:9090/kafkacruisecontrol/user_tasks" | grep "COMPLETED")
    if [ -n "$STATUS" ]; then
        echo "数据迁移完成"
        break
    fi
    sleep 10
done

echo "Kafka 集群扩容完成!"

可以看到,传统脚本有以下几个痛点:

多步骤手动介入

  • 每个步骤都需要依赖脚本执行。
  • 出错后排查困难,且很难进行流程回滚。

缺乏可视化

  • 集群状态、Pod 变化、数据迁移进度全靠日志和命令行输出。
  • 无法直观了解整体扩容进度。

风险高

  • 在生产环境中执行此类脚本,如果操作不当,可能导致服务中断或数据丢失。
  • 错误信息分散在多个命令输出中,难以快速定位问题。

不可审计

  • 操作记录分散,无法进行完整的审计与回溯。

白屏化平台的 Kafka 扩容

目标:将 Kafka 扩容的整个过程标准化、可视化、自动化,降低操作风险,提升执行效率。

从此,凌晨三点的 Kafka 扩容,变成了这样的场景:

  1. 打开平台:登录运维平台,进入 Kafka 集群运维界面。
  2. 点击扩容:输入副本数,点击 “一键扩容”。
  3. 实时监控:平台自动执行扩容,Pod 状态、资源分配、数据迁移一目了然。
  4. 完成审计:所有操作都记录在日志中,可随时回溯。

“10 分钟,Kafka 扩容完成,小卡又可以安心地回床上睡觉了。”,如下图:

小卡安心睡觉如下图.jpeg

目前,Kafka和ES在运维中都面临相似的痛点。为解决这些问题,大部分通用的中间件运维操作已被统一收敛至平台。

截至目前,平台已累计托管300+个中间件集群(Kafka: 120+,ES: 180+),完成100+个中间件的运维操作(Kafka: 60+,ES: 40+),累计执行430+次白屏化运维操作(Kafka: 210+次,ES: 220+次),覆盖扩缩容、升降配、数据迁移、重启、重建等常见运维场景,极大提升了运维效率与操作稳定性。

Node管理:从黑屏脚本到白屏化平台

故事背景

“凌晨三点,ES集群扩容需求紧急上线。”

运维小哥小吴接到告警电话,ES集群节点资源已接近饱和,业务性能明显下降。扩容节点,是当务之急。

然而,扩容并不是简单地加几台机器那么轻松。Node打标是扩容的关键前置步骤,如果节点没有正确打标,Pod将无法被调度到对应的资源池,扩容将直接失败。

在过去,Node 资源调度和打标是一项高风险、高强度的任务。需要依赖脚本在黑屏终端中逐台节点检查 CPU、内存、磁盘类型、可用区 等指标,然后筛选出符合条件的节点进行打标和调度。

如果某个细节疏忽——比如忘记检查污点、磁盘挂载数量超标,轻则导致扩容失败,重则影响整个业务链路。

传统黑屏脚本分析

在传统的 Node 筛选脚本中,我们依赖 kubectl 命令逐个检查节点的各类资源指标,并进行节点筛选。以下是小吴编写的一个典型的黑屏脚本示例:

public class ESNodeSelectorTest {
    public static void main(String[] args) {
        //cpu核心数
        int needCpu = 9;
        //内存容量G
        int needMemory = 33;
        //磁盘类型
        DiskType needDiskType = DiskType.efficiency;
        //可用区
        Zone zone = Zone.cn_shanghai_m;
        //标签
        String label = null;
        //集群名称
        String clusterName = null;
        //开始自动筛选
        selectNode(label,zone,needCpu,needMemory,needDiskType,clusterName);
    }

    public static void selectNode(String label,Zone zone,int needCpu,int needMemory,DiskType diskType,String clusterName){
        String getNodes = "kubectl get node -l ";
        String segment;
        if(label!=null){
            getNodes += label;
        }else {
            if (zone != null) {
                segment = "topology.kubernetes.io/zone=" + zone.name().replaceAll("_", "-");
                getNodes += segment;
            }
        }

        String nodes = executeCommand("/bin/bash", "-c", getNodes);
        String[] nodeList = nodes.split("\n");
        String describeNode;
        int lineCount = 0;
        for (int i = 1; i < nodeList.length; i++) {
            String node = nodeList[i].split(" ",2)[0];
            if(lineCount>4){
                lineCount = 0;
                System.out.println();
            }
            lineCount++;

            System.out.print(node);
            if(isMaster(node)){
                continue;
            }
            describeNode = "kubectl describe node " + node +" | grep 'Taints\\|cpu    \\|memory   ";
            //...太多了...省略...
            }
        }
    }

    public static String executeCommand(String... command){
        try {
            Process process = Runtime.getRuntime().exec(command);
            //...
        }catch (Exception e){
            throw new RuntimeException("kubectl apply exception:",e);
        }
    }
}

可以看到,传统方式有以下几个痛点:

操作复杂

  • 需要编写和维护复杂的脚本。
  • 每次执行都需要逐节点检查,耗时长,效率低。

高风险

  • 稍有疏忽(如忘记检查污点、CPU 余量计算错误)可能导致资源分配失败。
  • 整个过程缺乏可视化,排查问题难度大。

响应慢

  • 在业务高峰期,这种人工节点筛选方式无法快速响应突发需求。

白屏化平台的 Node 管理

目标:

将 Node 资源管理的整个流程标准化、自动化和可视化

设计思路: 1.指标可视化

  • 在白屏界面上展示 Node 的 CPU 分配/使用率、内存分配/使用率、磁盘类型、标签、污点等关键指标。

2.多维度筛选

  • 支持通过标签、污点、CPU/内存余量、磁盘类型、可用区等维度快速筛选节点。

3.批量打标与调度

  • 支持批量打标和污点管理,减少人工操作的复杂性。

4.资源状态实时更新

  • 实时展示节点资源的可用性,避免资源分配冲突。

在大促扩容场景中,平台已累计完成了 280+ 台 Node 的打标与调度。原本1小时+的 Node 筛选和打标操作,现在只需要 3 分钟

只需要3分钟.jpeg

PV云盘管理:打破孤盘与繁琐操作的枷锁

故事背景

“集群删了,PV 留下了,云盘成了‘孤儿’。”

一次运维例会中,小宋提到这样一个现象:当中间件集群被释放后,原先挂载的 PV 和云厂商的云盘并不会自动删除。更让人头疼的是,云厂商云盘的标签只有两个关键字段,这里以某云为例:

  • k8s.yun.com : true
  • createdby: alibabacloud-csi-plugin

当集群被销毁,这些标签几乎无法追溯到云盘的真正使用方。出于风险考虑,这些云盘被闲置着,无人敢于释放,久而久之,闲置云盘成堆,云成本居高不下。如下图:

云成本居高不下.jpeg

痛点分析

云盘归属无法追溯

  • 当中间件集群被销毁后,PV 与云盘的映射关系断开。
  • 云盘仅有两个标签,缺乏更具体的归属信息。
  • 运维人员难以确定云盘的真实使用方,释放云盘面临很大的风险。

手动释放繁琐

  • 集群节点释放后,通常 PVC 资源会被删除,但 PV 依然保留。
  • 运维团队需要借助机器人或定期巡检手动查找闲置 PV。
  • 每次释放云盘都需要手动在云厂商管理台完成,流程繁琐,耗时长。

流程缺乏闭环

  • 云盘释放 → 成本中心审批 → 云厂商控制台删除 PV,整个流程需要跨平台、跨系统完成,且容易出错。

解决方案

目标:实现 PV 云盘资源的可视化、自动化管理,打通从 Kubernetes 到云厂商的全链路操作流程。如下图所示:

解决方案目标.jpeg

目标和行动拆解:

目标和行动拆解2.jpeg

截止目前,平台已累计释放了 675+ 块闲置云盘,每月节省云成本约 15+万元。操作时间从15 分钟+缩短到 1 分钟。且所有操作均可审计与回溯,保障了运维安全性。

CPU Burst 管理:关键时刻的“应急电源”

故事背景

“高峰期 CPU 100%,服务卡成 PPT?”

一次业务高峰来临,ES 集群的 CPU 使用率迅速飙升到 100%,多个关键服务开始响应迟缓,甚至部分 Pod 被强制驱逐。运维同学小宋看着监控大屏上的红色告警,不禁捏了一把汗。

在传统运维方式下,CPU 资源一旦达到极限,唯一的解决方案就是扩容,但扩容并非瞬时可完成的操作,往往需要排查资源、调度 Pod、重启服务,甚至等待新节点的资源分配。而这些步骤,在高并发、高压力场景下,每一秒的延迟都是用户体验的巨大损失。

痛点分析

资源调度滞后

  • CPU 资源短缺时,传统调度往往依赖于扩容,响应时间较长。
  • 在高并发场景下,调度效率决定了系统的生死。

临时应急难

  • 当 CPU 达到瓶颈,传统 Kubernetes 无法临时突破 CPU 限制,服务只能停滞或被驱逐。

解决方案

目标:在高压场景下,通过 CPU Burst 管理功能,允许关键 Pod 在短时间内突破 CPU Limit 限制,保障服务稳定性和业务连续性。如下所示:

![CPU burst.jpeg](h5cdn.dewu.com/efe/ctoo-op… burst_1739254751374.jpeg)

截止目前, CPU Burst 已在 10+ 套 Kubernetes 集群30+ 套 ES 集群 中启用。在高并发场景下,有效解决了 CPU受限和CPU使用率瓶颈问题,提升了服务稳定性。

YAML 管理服务:让配置变更安全、可控、可回滚

故事背景

“一行 YAML,毁灭一个集群。”

在 Kubernetes 的运维场景中,YAML 配置文件是所有资源操作的核心。无论是 Pod 调度、Service 暴露,还是 ConfigMap 更新,所有的操作都离不开 YAML 文件。

但 YAML 配置管理往往充满风险:

  • 一行配置错误:可能导致整个服务不可用。
  • 版本混乱:配置文件缺乏版本管理,一旦出错,回滚难度极大。
  • 缺乏审计:每次变更是谁做的,变更了哪些内容,几乎没有清晰的记录。
  • 人工操作:黑屏模式下,直接通过 kubectl apply 修改 YAML,出错率极高。

“运维人员常说:‘YAML 是 Kubernetes 的灵魂,但也是运维事故的导火索。’”

痛点分析

版本管理缺失

  • 没有完整的 YAML 文件版本历史记录。
  • 配置错误难以回滚,出错后很难快速恢复。

变更审计不透明

  • 谁修改了配置?
  • 修改了哪些内容?
  • 修改的原因是什么?
  • 缺乏详细的审计日志,责任难以追溯。

手工变更风险高

  • 直接使用 kubectl apply 进行 YAML 修改,存在人为输入错误的风险。
  • 缺少可视化的配置变更比对工具,难以进行精确的差异分析。

变更回滚复杂

  • 配置变更失败后,回滚通常需要手动恢复之前的版本。
  • 缺乏自动化的回滚机制,出错后容易引发连锁反应。

解决方案

目标:通过YAML 管理服务,将 Kubernetes YAML 配置的版本管理、变更审计、回滚机制 和 可视化管理 集中整合到平台中,降低人为操作风险,提升变更效率和安全性。如下所示:

YAML管理.jpeg

三、项目收益总结

经过三期建设,白屏化运维平台已从概念验证逐步发展成为覆盖全场景运维的高效工具,取得了显著的成果和收益,主要体现在以下几个方面:

运维标准化与规范化

  • 统一运维流程:通过平台白屏化界面,规范了 Kafka、ES、Node、PV、PVC、SVC、Pod 等核心运维场景,减少了操作流程的随意性。
  • 最佳实践沉淀:在每个运维场景中,平台积累并固化了标准化的运维流程与策略,降低了运维操作的失误率。
  • 减少人为依赖:降低了对资深运维人员的依赖,新手 SRE 也可以在平台指引下完成复杂运维操作。

运维效率显著提升

  • 操作效率: Node 打标与污点管理从 1小时+ 缩短至 3分钟。 PV 云盘释放从 15分钟+ 缩短至 1分钟

  • 高效应急响应:在 CPU Burst 管理的支持下,有效缓解高并发场景下的 CPU受限和CPU使用率瓶颈问题,保障业务连续性。

  • 批量化管理:支持 Node、PV、PVC、SVC、Pod等资源的批量管理,减少重复性操作,提高资源调度效率。

成本优化与资源利用最大化

  • 资源回收:累计释放 675+ 块闲置云盘,月均节省云成本 15+万元。
  • 资源高效分配:通过 Node 筛选与 PV优化管理,有效提升了资源利用率,避免资源浪费。
  • 跨云资源托管:统一管理来自不同云厂商的 Kubernetes 集群,避免因平台差异导致的资源闲置。

安全性与可审计性提升

  • 操作可追溯:所有运维操作均纳入审计日志,累计记录超过 1020+ 条审计日志。
  • 合规性保障:平台操作对接 DCheck 系统,实现运维审计和合规性检查。

业务稳定性与可扩展性

  • 支撑业务高峰:在七夕大促等业务高压场景下,平台有效支撑了中间件的快速扩容和稳定运行。
  • 平台可扩展性:架构设计支持快速新增运维场景,满足未来更多资源类型和场景的需求。

四、经验总结与反思

在三期的建设和落地过程中,我们积累了宝贵的经验,也发现了一些可以优化和提升的空间。以下是关键经验和反思:

以标准化为核心

  • 运维流程标准化:通CR管理、Node/PV/PVC/SVC 资源管理,实现了运维操作的标准化和可重复性。
  • 减少个性化操作:避免了运维过程中因个人操作习惯差异带来的不一致性。

技术与流程的深度结合

  • 白屏化降低门槛:将复杂的 Kubernetes 运维操作封装到 UI 界面,减少了对黑屏命令的依赖。
  • 批量运维能力:批量操作大幅减少重复性工作,有效提升整体运维效率。

强化审计与合规

  • 全链路审计:所有操作均有详细的审计记录,确保运维行为可追溯、可还原。
  • 合规检查:对接 DCheck 系统,实时进行合规检查,避免风险隐患。

面向未来的架构设计

  • 弹性扩展:平台架构具备较强的可扩展性,支持快速集成新的运维场景。
  • 跨云平台适配:平台已实现对多云 Kubernetes 集群的统一管理,降低了跨云资源运维的复杂度。

遇到的挑战

  • 与KubeOne平台无法融合:KubeOne平台主要面向无状态服务的运维,而白屏化平台主要面向有状态服务的运维,二者无法融合。
  • 多场景运维复杂度:不同中间件和 Kubernetes 资源类型的运维逻辑存在差异,初期难以统一抽象。
  • 持续优化测试流程:部分场景的测试覆盖率还有待提升,未来需持续加强单元测试和集成测试。

五、未来展望

白屏化运维平台的未来,将持续以“标准化、可视化、智能化”为核心,不断拓展运维场景,降低运维门槛,提升运维效率与安全性。

  • 扩展更多 Kubernetes 运维场景:实现更多 Kubernetes 资源(如Deployment、StatefulSet、 Ingress、ConfigMap、Secret)和自定义资源(DMQ、Pulsar、ZK)的白屏化运维支持。
  • 引入智能化运维:基于案例匹配、数据挖掘或总结最佳实践,实现故障自愈、资源动态调度和智能告警。
  • 持续优化用户体验:通过用户反馈持续改进平台功能,优化运维操作流程,降低运维心智负担。
  • 加强多云资源管理能力:支持更多云厂商 Kubernetes 集群的接入,提升平台的多云资源管理能力。

文 / 初澜

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

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

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

RAG应用在得物开放平台的智能答疑的探索

作者 得物技术
2025年1月7日 11:30

一、背景

得物开放平台是一个把得物能力进行开放,同时提供给开发者提供 公告、应用控制台、权限包申请、业务文档等功能的平台。

  1. 面向商家:通过接入商家自研系统。可以实现自动化库存、订单、对账等管理。
  2. 面向ISV :接入得物开放平台,能为其产品提供更完善的全平台支持。
  3. 面向内部应用:提供安全、可控的、快速支持的跨主体通讯。

得物开放平台目前提供了一系列的文档以及工具去辅助开发者在实际调用API之前进行基础的引导和查询。

但目前的文档搜索功能仅可以按照接口路径,接口名称去搜索,至于涉及到实际开发中遇到的接口前置检查,部分字段描述不清等实际问题,且由于信息的离散性,用户想要获得一个问题的答案需要在多个页面来回检索,造成用户焦虑,进而增大TS的答疑可能性。

背景.jpg

随着这几年AI大模型的发展,针对离散信息进行聚合分析且精准回答的能力变成了可能。而RAG应用的出现,解决了基础问答类AI应用容易产生幻觉现象的问题,达到了可以解决实际应用内问题的目标。

二、简介

什么是RAG

RAG(检索增强生成)指Retrieval Augmented Generation。

这是一种通过从外部来源获取知识来提高生成性人工智能模型准确性和可靠性的技术。通过RAG,用户实际上可以与任何数据存储库进行对话,这种对话可视为“开卷考试”,即让大模型在回答问题之前先检索相关信息。

RAG应用的可落地场景

RAG应用的根本是依赖一份可靠的外部数据,根据提问检索并交给大模型回答,任何基于可靠外部数据的场景均是RAG的发力点。

RAG应用的主要组成部分

  • 外部知识库:问题对应的相关领域知识,该知识库的质量将直接影响最终回答的效果。
  • Embedding模型:用于将外部文档和用户的提问转换成Embedding向量。
  • 向量数据库:将外部信息转化为Embedding向量后进行存储。
  • 检索器:该组件负责从向量数据库中识别最相关的信息。检索器将用户问题转换为Embedding向量后执行相似性检索,以找到与用户查询相关的Top-K文档(最相似的K个文档)。
  • 生成器(大语言模型LLM):一旦检索到相关文档,生成器将用户查询和检索到的文档结合起来,生成连贯且相关的响应。
  • 提示词工程(Prompt Engineering):这项技术用于将用户的问题与检索到的上下文有效组合,形成大模型的输入。

RAG应用的核心流程

以下为一个标准RAG应用的基础流程:

  1. 将查询转换为向量
  2. 在文档集合中进行语义搜索
  3. 将检索到的文档传递给大语言模型生成答案
  4. 从生成的文本中提取最终答案

RAG应用.jpg

但在实际生产中,为了确保系统的全面性、准确性以及处理效率,还有许多因素需要加以考虑和处理。

下面我将基于答疑助手在开放平台的落地,具体介绍每个步骤的详细流程。

三、实现目标

鉴于目前得物开放平台的人工答疑数量相对较高,用户在开放平台查询未果就会直接进入到人工答疑阶段。正如上文所说,RAG擅长依赖一份可靠的知识库作出相应回答,构建一个基于开放平台文档知识库的RAG应用再合适不过,同时可以一定程度降低用户对于人工答疑的依赖性,做到问题前置解决。

实现目标.jpg

四、整体流程

整体流程.jpg

技术选型

准确性思考

问答的准确性会直接反馈到用户的使用体验,当一个问题的回答是不准确的,会导致用户根据不准确的信息进一步犯错,导致人工客服介入,耐心丧失直至投诉。

所以在实际构建基于开放平台文档的答疑助手之前,首先考虑到的是问答的准确性,主要包括以下2点:

  1. 首要解决答疑助手针对非开放平台提问的屏蔽
  2. 寻找可能导致答非所问的时机以及相应的解决方案

屏蔽非相关问题

为了屏蔽AI在回答时可能会回答一些非平台相关问题,我们首先要做的是让AI明确我们的目标(即问答上下文),且告诉他什么样的问题可以回答,什么问题不可以回答。

在这一点上,常用的手段为告知其什么是开放平台以及其负责的范畴。

例如:得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。

在这一段描述中,我们告知了答疑助手,开放平台包含着API文档,包含着解决方案,同时包含接口信息,同时会有商家等之类的字眼。大模型在收到这段上下文后,将会对其基础回答进行判断。

同时,我们可以通过让答疑助手二选一的方式进行回答,即平台相关问题与非平台相关问题。我们可以让大模型返回特定的数据枚举,且限定枚举范围,例如:开放平台通用问题、开放平台API答疑问题,未知问题。

借助Json类型的输出 + JSON Schema,我们可通过Prompt描述来限定其返回,从而在进入实际问答前做到事前屏蔽。

寻找可能导致答非所问的时机

当问题被收拢到开放平台这个主题之后,剩余的部分就是将用户提问与上下文进行结合,再交由大模型回答处理。在这过程中,可能存在的答非所问的时机有:不够明确的Prompt说明、上下文信息过于碎片化以及上下文信息的连接性不足三种。

  • 不够明确的Prompt说明:Prompt本身描述缺少限定条件,导致大模型回答轻易超出我们给予的要求,从而导致答非所问。
  • 上下文信息过于碎片化:上下文信息可能被分割成N多份,这个N值过大或者过小,都会导致单个信息过大导致缺乏联想性、单个信息过小导致回答时不够聚焦。
  • 上下文信息连接性不够:若信息之间被随意切割,且缺少相关元数据连接,交给大模型的上下文将会是丧失实际意义的文本片段,导致无法提取出有用信息,从而答非所问。

为了解决以上问题,在设计初期,开放平台答疑助手设定了以下策略来前置解决准确性问题:

  • 用户提问的结构化
  • 向量的分割界限以及元信息处理
  • CO-STAR Prompt结构
  • 相似性搜索的K值探索

用户提问结构化

目标:通过大模型将用户提问的结构化,将用户提问分类并提取出精确的内容,便于提前引导、终止以及提取相关信息。

结构化.jpg

例如,用户提问今天天气怎么样,结构化Runnable会将用户问题进行初次判断。

一个相对简单的Prompt实现如下:

# CONTEXT
得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。

# OBJECTIVE
你现在扮演一名客服。请将每个客户问题分类到固定的类别中。
你只接受有关开放平台接口的相关问答,不接受其余任何问题。
具体的类别我会在提供给你的JSON Schema中进行说明。

# STYLE

你需要把你的回答以特定的 JSON 格式返回

# TONE

你给我的内容里,只能包含特定 JSON 结构的数据,不可以返回给我任何额外的信息。

# AUDIENCE

你的回答是给机器看的,所以不需要考虑任何人类的感受。

# RESPONSE

你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<json-schema></json-schema>\`标签包裹.
每个字段的描述,都是你推算出该字段值的依据,请仔细阅读。

<json-schema>
  {schema}
</json-schema>

Json Schema的结构通过zod描述如下:

const zApiCallMeta = z
  .object({
    type: z
      .enum(['api_call''unknown', 'general'])
      .describe('当前问题的二级类目, api_call为API调用类问题,unknown为非开放平台相关问题, general为通用类开放平台问题'),
    apiName: z
      .string()
      .describe(
        '接口的名称。接口名称为中文,若用户未给出明确的API中文名称,不要随意推测,将当前字段置为空字符串',
      ),
    apiUrl: z.string().describe('接口的具体路径, 一般以/开头'),
    requestParam: z.unknown().default({}).describe('接口的请求参数'),
    response: z
      .object({})
      .or(z.null())
      .default({})
      .describe('接口的返回值,若未提供则返回null'),
    error: z
      .object({
        traceId: z.string(),
      })
      .optional()
      .describe('接口调用的错误信息,若接口调用失败,则提取traceId并返回'),
  })
  .describe('当二级类目为api_call时,使用这个数据结构');

以上结构,将会对用户的问题输入进行结构化解析。同时给出相应JSON数据结构。

将以上结构化信息结合,可实现一个基于LangChain.js的结构化Runnable,在代码结构设计上,所有的Runnable将会使用$作为变量前缀,用于区分Runnable与普通函数。

import { ChatOpenAI } from '@langchain/openai';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { $getPrompt } from './$prompt';
import { zSchema, StructuredInputType } from './schema';
import { n } from 'src/utils/llm/gen-runnable-name';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';

const b = n('$structured-input');

const $getStructuredInput = () => {
  const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
    response_format: {
      type: 'json_object',
    },
  });

  const $input = RunnableMap.from<{ question: string }>({
    schema: () => getStringifiedJsonSchema(zSchema),
    question: (input) => input.question,
  }).bind({ runName: b('map') });

  const $prompt = $getPrompt();
  const $parser = new StringOutputParser();

  return RunnableSequence.from<{ question: string }, string>([
    $input.bind({ runName: b('map') }),
    $prompt.bind({ runName: b('prompt') }),
    $model,
    $parser.bind({ runName: b('parser') }),
  ]).bind({
    runName: b('chain'),
  });
};

export { $getStructuredInput, type StructuredInputType };

鉴于CO-STAR以及JSONSchema的提供的解析稳定性,此Runnable甚至具备了可单测的能力。

import dotenv from 'dotenv';
dotenv.config();
import { describe, expect, it } from 'vitest';
import { zSchema } from '../runnables/$structured-input/schema';
import { $getStructuredInput } from '../runnables/$structured-input';

const call = async (question: string) => {
  return zSchema.safeParse(
    JSON.parse(await $getStructuredInput().invoke({ question })),
  );
};

describe('The LLM should accept user input as string, and output as structured data', () => {
  it('should return correct type', { timeout: 10 * 10000 }, async () => {
    const r1 = await call('今天天气怎么样');
    expect(r1.data?.type).toBe('unknown');
    const r2 = await call('1 + 1');
    expect(r2.data?.type).toBe('unknown');
    const r3 = await call('trace: 1231231231231231313');
    expect(r3.data?.type).toBe('api_call');
    const r4 = await call('快递面单提示错误');
    expect(r4.data?.type).toBe('api_call');
    const r5 = await call('发货接口是哪个');
    expect(r5.data?.type).toBe('api_call');
    const r6 = await call('怎么发货');
    expect(r6.data?.type).toBe('general');
    const r7 = await call('获取商品详情');
    expect(r7.data?.type).toBe('api_call');
    const r8 = await call('dop/api/v1/invoice/cancel_pick_up');
    expect(r8.data?.type).toBe('api_call');
    const r9 = await call('开票处理');
    expect(r9.data?.type).toBe('api_call');
    const r10 = await call('权限包');
    expect(r10.data?.type).toBe('api_call');
  });

数据预处理与向量库的准备工作

RAG应用的知识库准备是实施过程中的关键环节,涉及多个步骤和技术。以下是知识库准备的主要过程:

  1. 知识库选择:【全面性与质量】数据源的信息准确性在RAG应用中最为重要,基于错误的信息将无法获得正确的回答。
  2. 知识库收集:【多类目数据】数据收集通常涉及从多个来源提取信息,包括不同的渠道,不同的格式等。如何确保数据最终可以形成统一的结构并被统一消费至关重要。
  3. 数据清理:【降低额外干扰】原始数据往往包含不相关的信息或重复内容。
  4. 知识库分割:【降低成本与噪音】将文档内容进行分块,以便更好地进行向量化处理。每个文本块应适当大小,并加以关联,以确保在检索时能够提供准确的信息,同时避免生成噪声。
  5. 向量化存储:【Embedding生成】使用Embedding模型将文本块转换为向量表示,这些向量随后被存储在向量数据库中,以支持快速检索。
  6. 检索接口构建:【提高信息准确性】构建检索模块,使其能够根据用户查询从向量数据库中检索相关文档。

知识库拆分

知识库文档的拆分颗粒度(Split Chunk Size) 是影响RAG应用准确性的重要指标:

  • 拆分颗粒度过大可能导致检索到的文本块包含大量不相关信息,从而降低检索的准确性。
  • 拆分颗粒度过小则可能导致必要的上下文信息丢失,使得生成的回答缺乏连贯性和深度。
  • 在实际应用中,需要不断进行实验以确定最佳分块大小。通常情况下,128字节大小的分块是一个合适的分割大小。
  • 同时还要考虑LLM的输入长度带来的成本问题。

下图为得物开放平台【开票取消预约上门取件】接口的接口文档:

接口文档.jpg开票取消预约上门取件接口信息

拆分逻辑分析(根据理论提供128字节大小)

在成功获取到对应文本数据后,我们需要在数据的预处理阶段,将文档根据分类进行切分。这一步将会将一份文档拆分为多份文档。

由上图中信息可见,一个文档的基础结构是由一级、二级标题进行分割分类的。一个基本的接口信息包括:基础信息、请求地址、公共参数、请求入参、请求出参、返回参数以及错误码信息组成。

拆分方式

拆分的实现一般有2种,一是根据固定的文档大小进行拆分(128字节)二是根据实际文档结构自己做原子化拆分。

直接根据文档大小拆分的优点当然是文档的拆分处理逻辑会直接且简单粗暴,缺点就是因为是完全根据字节数进行分割,一段完整的句子或者段落会被拆分成2半从而丢失语义(但可通过页码进行链接解决)。

根据文档做结构化拆分的优点是上下文结构容易连接,单个原子文档依旧具备语义化,检索时可以有效提取到信息,缺点是拆分逻辑复杂具备定制性,拆分逻辑难以与其他知识库复用,且多个文档之间缺乏一定的关联性(但可通过元信息关联解决)。

在得物开放平台的场景中,**因为文档数据大多以json为主(例如api表格中每个字段的名称、默认值、描述等),将这些json根据大小做暴力切分丢失了绝大部分的语义,难以让LLM理解。**所以,我们选择了第二种拆分方式。

拆分实现

在文档分割层面,Markdown作为一种LLM可识别且可承载文档元信息的文本格式,作为向量数据的基础元子单位最为合适。

拆分实现.jpg 基础的文档单元根据大标题进行文档分割,同时提供frontmatter作为多个向量之间连接的媒介。

正文层面,开放平台的API文档很适合使用Markdown Table来做内容承接,且Table对于大模型更便于理解。

根据以上这种结构,我们可得到以下拆分流程:

拆分流程.jpg

代码实现:

 const hbsTemplate = `
---
服务ID (serviceId): {{ service.id }}
接口ID (apiId): {{ apiId }}
接口名称 (apiName): {{ apiName }}
接口地址 (apiUrl): {{ apiUrl }}
页面地址 (pageUrl): {{ pageUrl }}
---

# {{ title }}

{{ paragraph }}
`;
export const processIntoEmbeddings = (data: CombinedApiDoc) => {
  const template = baseTemplate(data);

  const texts = [
    template(requestHeader(data)),
    template(requestUrl(data)),
    template(publicRequestParam(data)),
    template(requestParam(data)),
    template(responseParam(data)),
    template(errorCodes(data)),
    template(authPackage(data)),
  ].filter(Boolean) as string[][];

  return flattenDeep(texts).map((content) => {
    return new Document<MetaData>({
      // id: toString(data.apiId!),
      metadata: {
        serviceId: data.service.id,
        apiId: data.apiId!,
        apiName: data.apiName!,
        apiUrl: data.apiUrl!,
        pageUrl: data.pageUrl!,
      },
      pageContent: content!,
    });
  });
};

知识库导入

通过建立定时任务(DJOB),使用MILVUS sdk将以上拆分后的文档导入对应数据集中。

CO-STAR结构

在上文中的Prompt,使用了一种名为CO-STAR的结构化模板,该框架由新加坡政府科技局的数据科学与AI团队创立。CO-STAR框架是一种用于设计Prompt的结构化模板,旨在提高大型语言模型(LLM)响应的相关性和有效性,考虑了多种影响LLM输出的关键因素。

结构:

  • 上下文(Context): 提供与任务相关的背景信息,帮助LLM理解讨论的具体场景,确保其响应具有相关性。
  • 目标(Objective): 明确你希望LLM执行的具体任务。清晰的目标有助于模型聚焦于完成特定的请求,从而提高输出的准确性。
  • 风格(Style): 指定希望LLM采用的写作风格。这可以是某位名人的风格或特定职业专家的表达方式,甚至要求LLM不返回任何语气相关文字,确保输出符合要求。
  • 语气(Tone): 设定返回的情感或态度,例如正式、幽默或友善。这一部分确保模型输出在情感上与用户期望相符。
  • 受众(Audience): 确定响应的目标受众。根据受众的不同背景和知识水平调整LLM的输出,使其更加适合特定人群。
  • 响应(Response): 规定输出格式,以确保LLM生成符合后续使用需求的数据格式,如列表、JSON或专业报告等。这有助于在实际应用中更好地处理LLM的输出。

在上文结构化的实现中,演示了如何使用CO-STAR结构的Prompt,要求大模型“冰冷的”对用户提问进行的解析,当然CO-STAR也适用于直接面向用户的问答,例如:

## Context
我是一名正在寻找酒店信息的旅行者,计划在即将到来的假期前往某个城市。我希望了解关于酒店的设施、价格和预订流程等信息。

## Objective
请提供我所需的酒店信息,包括房间类型、价格范围、可用设施以及如何进行预订。

## Style
请以简洁明了的方式回答,确保信息易于理解。

## Tone
使用友好和热情的语气,给人一种欢迎的感觉。

## Audience
目标受众是普通旅行者,他们可能对酒店行业不太熟悉。

## Response
请以列表形式呈现每个酒店的信息,包括名称、地址、房间类型、价格和联系方式。每个酒店的信息应简短且直接,便于快速浏览。

相似性搜索

当我们使用了问题结构化Runnable后,非开放平台类问题将会提前终止,告知用户无法解答相关问题,其他有效回答将会进入相似性搜索环节。

相似性搜索基于数据之间的相似性度量,通过计算数据项之间的相似度来实现检索。在答疑助手的相似性实现是通过余弦相似度来进行相似性判断的。

我们将用户的提问,与向量数据库中数据进行余弦相似度匹配。取K为5获取最相似的五条记录。

注意:此K值是经过一系列的推断最终决定的,可根据实际情况调整。

import { Milvus } from '@langchain/community/vectorstores/milvus';
import { OpenAIEmbeddings } from '@langchain/openai';
import { RunnableSequence } from '@langchain/core/runnables';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';

export const $getContext = async () => {
  const embeddings = new OpenAIEmbeddings(
    getLLMConfig().OpenAIEmbeddingsConfig,
  );

  const vectorStore = await Milvus.fromExistingCollection(embeddings, {
    collectionName: 'open_rag',
  });

  return RunnableSequence.from([
    (input) => {
      return input.question;
    },
    vectorStore.asRetriever(5),
  ]);
};

此Runnable会将搜索结果组成一大段可参考数据集,用于后续用户提问。

用户提问解答

用户提问的解答同样通过Runnable的方式来承接,通过用户提问、结构化数据、提取的相似性上下文进行结合,最终得到问题的解答。

我们先将上下文进行格式化整理:

import { RunnablePassthrough, RunnablePick } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { PromptTemplate } from '@langchain/core/prompts';
import { MetaData } from 'src/types';

const $formatRetrieverOutput = async (documents: Document<MetaData>[]) => {
  const strings = documents.map(async (o) => {
    const a = await PromptTemplate.fromTemplate(`{pageContent}`).format({
      pageContent: o.pageContent,
    });

    return a;
  });

  const context = (await Promise.all(strings)).join('\n');

  return context;
};

export const $contextAssignRunnable = () => {
  return RunnablePassthrough.assign({
    context: new RunnablePick('context').pipe($formatRetrieverOutput),
  });
};

问答整体Prompt实现:

export const promptTemplateMarkdown = () => {
  return `
# CONTEXT

得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。
现在得物开放平台的人工答疑率相当高,原因可能是文档的信息藏的较深,我希望做一个人工智能答疑助手,通过分析开放平台的各种文档,来回答用户的问题,最终让用户不进入人工答疑阶段。
我们只讨论[开放平台接口]的相关问题,不要谈及其他内容。

# OBJECTIVE
你需要根据用户的输入,以及提供的得物开放平台的文档上下文,进行答疑。
你只接受有关[开放平台接口]的相关问答,不接受其余任何问题。

## 关于用户的输入:

1. 你会得到一份符合 JSONSchema 结构的结构化数据,这份数据我会使用\`<structured-input></structured-input>\`包裹。
   这份结构化数据是通过实际的用户提问进行了二次分析而得出的。结构化数据里也会包含用户的最初始的问题供你参考(最初始的问题会放在 question 字段里)

## 关于上下文

1.  我已经提前准备好了你需要参考的资料,作为你回答问题的上下文,上下文是由许多篇 Markdown 文档组成的。这些 Markdown 的文档大标题代表了这个片段的模块名,例如 \`# 接口入参\`就代表这部分是文档的接口入参部分, \`# 接口返回\`就代表这部分是文档的接口返回部分,
2.  上下文中的主要信息部分我会使用 Markdown Table 的结构提供给你。
3.  每个上下文的开头,我都会给你一些关于这份上下文的元信息(使用 FrontMatter 结构),这个元信息代表了这份文档的基础信息,例如文档的页面地址,接口的名称等等。

以下是我提供的结构化输入,我会使用\`<structured-input></structured-input>\`标签做包裹
<structured-input>
{structuredInput}
</structured-input>

以下是我为你提供的参考资料,我会使用\`<context></context>\`标签包裹起来:
<context>
{context}
</context>

# STYLE

你需要把你的回答以特定的 JSON 格式返回

# TONE

你是一个人工智能答疑助手,你的回答需要温柔甜美,但又不失严谨。对用户充满了敬畏之心,服务态度要好。在你回答问题之前,需要简单介绍一下自己,例如“您好,很高兴为您服务。已经收到您的问题。”

# AUDIENCE

你的用户是得物开放平台的开发者们,他们是你要服务的对象。

# RESPONSE

你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<structured-output-schema></structured-output-schema>\`标签包裹.

<structured-output-schema>
  {strcuturedOutputSchema}
</structured-output-schema>
`;
};

以上问答通过CO-STAR结构,从6个方面完全限定了答疑助手的回答腔调以及问答范畴,我们现在只需要准备相应的数据结构提供给这份Prompt模板。

问答结果结构化

在开放平台答疑助手的场景下,我们不仅要正面回答用户的问题,同时还需要给出相应的可阅读链接。结构如下:

import { z } from 'zod';

const zOutputSchema = z
  .object({
    question: z
      .string()
      .describe(
        '提炼后的用户提问。此处的问题指的是除去用户提供的接口信息外的问题。尽量多的引用用户的提问',
      ),
    introduction: z
      .string()
      .describe('开放平台智能答疑助手对用户的问候以及自我介绍'),
    answer: z
      .array(z.string())
      .describe(
        '开放平台智能答疑助手的回答,需将问题按步骤拆分,形成数组结构,回答拆分尽量步骤越少越好。如果回答的问题涉及到具体的页面地址引用,则将页面地址放在relatedUrl字段里。不需要在answer里给出具体的页面地址',
      ),
    relatedUrl: z
      .array(z.string())
      .describe(
        '页面的链接地址,取自上下文的pageUrl字段,若涉及多个文档,则给出所有的pageUrl,若没有pageUrl,则不要返回',
      )
      .optional(),
  })
  .required({
    question: true,
    introduction: true,
    answer: true,
  });

type OpenRagOutputType = z.infer<typeof zOutputSchema>;

export { zOutputSchema, type OpenRagOutputType };

在我们之前的设计中,我们的每一份向量数据的头部,均带有相应的文档meta信息,通过这种向量设计,我们可以很容易的推算出可阅读链接。同时,我们在这份zod schema中提供了很详细的description,来限定机器人的回答可以有效的提取相应信息。

Runnable的结合

在用户提问解答这个Runnable中,我们需要结合Retriever, 上下文,用户提问,用户输出限定这几部分进行组合。

import { ChatOpenAI } from '@langchain/openai';
import { $getPrompt } from './prompt/index';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { zOutputSchema } from './schema';
import { $getContext } from './retriever/index';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';
import { n } from 'src/utils/llm/gen-runnable-name';

const b = n('$open-rag');

type OpenRagInput = {
  structuredInput: string;
  question: string;
};

const $getOpenRag = async () => {
  const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
    response_format: {
      type: 'json_object',
    },
  });

  const chain = RunnableSequence.from([
    RunnableMap.from<OpenRagInput>({
      // 问答上下文
      context: await $getContext(),
      // 结构化输入
      structuredInput: (input) => input.structuredInput,
      // 用户提问
      question: (input) => input.question,
      // 输出结构
      strcuturedOutputSchema: () => getStringifiedJsonSchema(zOutputSchema),
    }).bind({ runName: b('runnable-map') }),
    $getPrompt().bind({ runName: b('prompt') }),
    $model,
    new JsonOutputParser(),
  ]).bind({ runName: b('chain') });

  return chain;
};

export { $getOpenRag };

流程串联

通过上文的几大部分,我们已经将 用户提问、结构化解析、相似性搜索、问答上下文,问答Prompt模板以及结构化输出这几部分实现完成。

现在可以通过LangChain的Runnable能力进行流程串联。

const structure = safeJsonParse<StructuredInputType>(
  await $getStructuredInput().invoke({ question }),
  null,
)
const mainChain = RunnableSequence.from<{ question: string }, OpenRagOutputType>([
  RunnablePassthrough.assign({
    structuredInput: () => structure,
  }),
  await $getOpenRag(),
]);
const response = await mainChain.invoke({
  question: question,
});

串联流程如下:

串联流程.jpg

返回结果示例:

输入:

{
  "question": "正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错(https://openapi.dewu.com/dop/api/v2/order/brand_deliver/express_sheet)。请求参数:{\"order_no\":\"110152014363632294\",\"app_key\":\"953a43bec40943c98a5cdbc63333242f;\",\"sign\":\"AD968ED6A359D144B61F1FEF6BB380ED\",\"timestamp\":1720430064169}"
}

输出:

{
  "question": "我的问题是:正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错。",
  "introduction": "您好,很高兴为您服务。已经收到您的问题。",
  "answer": [
    "根据您提供的请求参数信息,出现错误可能是由于接口调用参数不正确导致的,接下来我们一步步排查问题。",
    "首先,您提供的请求参数中的订单号为\"110152014363632294\",请确认该订单号的状态是否为待物流揽收或待买家收货状态。",
    "其次,检查您所使用的app_key是否正确,确保该app_key授权了调用该接口的权限。",
    "再次,请确认传入的签名(sign)是否正确,可能需要重新生成签名以保证准确性。",
    "最后,检查时间戳(timestamp)是否正确,并且处于合理的时间范围内。",
    "如果以上步骤都没有解决问题,建议您查看详细的接口文档以进一步调试和解决问题。"
  ],
  "relatedUrl": [
    "https://open.dewu.com/#/api?apiId=1174"
  ]
}

五、应用调试

基于大模型应用可能设计到多个Runnable的多次调用,借用LangSmith的trace功能,我们可以对每一个Runnable进行出入参的debug。

关于LangSmith的接入:

Langsmith.jpg

六、未来展望

未来展望.gif RAG在减少模型幻觉,无需模型训练就可享受内容时效性的特点在此类答疑应用中展露无遗,RAG应用开放平台落地从一定程度上验证了依赖可靠知识库的答疑场景具备可执行性,还为内部系统的应用提供了有力的参考。在实际应用中,除了直接解决用户的提问外,通过回放用户提问的过程,可以为产品和业务的发展提供重要的洞察。

面向未来,是否可以尝试将答疑助手的形式在内部系统落地,在内部建立知识库体系,将部分问题前置给大模型处理,降低TS和开发介入答疑的成本。

文 / 惑普

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

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

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

❌
❌