阅读视图

发现新文章,点击刷新页面。

得物自研DSearch3.0搜索核心引擎升级之路

一、背景

随着交易和社区搜索业务稳步快跑,基建侧引擎越来越复杂,之前搜索底层索引查询结构已经存在较为严重的性能瓶颈。成本和运维难度越来越高。在开发效率上和引擎的稳定性上,也暴露出了很多需要解决的运维稳定性和开发效率短板。而在引擎的业务层部分也需要逐步升级,来解决当前引擎中召回层和业务层中各个模块强耦合,难维护,迭代效率低下等问题。

图片

二、引擎开发技术方案

DSearch1.0索引层整体结构

DSearch1.0的索引结构比较特殊一些,总体上使用了全局rcu的设计思想,整体架构上单写多读,所以实现了并发高性能无锁读,内部数据结构都是无锁数据结构,所以查询性能高。在写操作上因为rcu机制实现写入无锁。整体上优点读性能高,没有传统段合并操作带来的磁盘抖动。缺点是索引地址和操作系统强相关,运维复杂,热更新受限。全局地址分配难以并行写入,构建瓶颈明显。无法对浪费的内存进行回收导致内存空间利用率低,索引空间占用大。总体结构如图所示:

图片

DSearch2.0的索引升级

DSearch2.0分段索引整体设计

引擎2.0索引升级采用经典段合并架构,除了继承了段合并中优异的高性能写入性能和查询已经索引合并等优势外,针对段合并中频繁的正排字段更新等带来的高IO缺点。我们设计了新的正排字段原地更新索引,使新的DSearch2.0引擎拥有Redis的高性能写入和查询,也拥有lucene的紧凑索引和索引合并带来的内存空间节省的优势。

※ 索引段结构

  1. 每个索引段包含了文档文件,用于紧凑存放document中的各个字段的详细信息。字符串池文件是对document中所有的字符串进行统一顺序存储,同时对字符串进行ID化,每个字符串ID就是对应于字符串池中的offset偏移
  2. 可变数组文件是专门存放数组类型的数据,紧凑型连续存放,当字段更新的时候采用文件追加append进行写。最终内存回收通过段之间的compaction进行。FST索引文件是专门存放document中全部字符串索引。每个fst的node节点存放了该字符串在字符串池中的偏移offset。而通过字符串的offset,能够快速在倒排termoffset数组上二分查找定位到term的倒排链。
  3. 倒排文件是专门存放倒排docid,词频信息、位置信息等倒排信息,其中docid倒排链数据结构会根据生成段的时候计算docid和总doc数的密度来做具体判断,如果密度高于一定阈值就会使用bitmap数据结构,如果小于一定阈值会使用array的数据结构
  4. 标记删除delete链主要是用于记录段中被删除的document,删除操作是软删除,在最后查询逻辑操作的时候进行最后的过滤。
  5. 实时增量的trie树结构,实时增量段中的前缀检索和静态段中的前缀检索数据结构不一样,trie因为能够进行实时更新所以在内存中使用trie树。
  6. 段中的metadata文件,metadata文件是记录每个段中的核心数据的地方,主要记录段内doc数量,段内delete文档比例,实时段的metadata会记录kafka的offset等核心数据。

微信图片_2025-05-14_105656_384.png

Document文档和索引结构

Document文档数据结构

  1. Document文档使用紧凑型存储,其中array和字符串类型单独存放,其他字段连续存放,string和array字段存放。
  2. array字段类型数据直接存放在可变数组文件区,连续追加写。
  3. string字符串池对所有字符串进行连续存放,多个doc中同一个字符串引用同一个字符串地址,节省大量字符串存放空间。

倒排索引文件结构

  1. 倒排索引文件存放docid倒排和Tf以及位置position数据。其中内存实时段中的倒排索引数据结构是固定一种类型array类型。而内存实时段固化为静态段的时候,倒排数据结构会根据docid中的密度进行选择array和bitmap存储。当docid密度大于一定阈值是bitmap,反之是array结构。
  2. Tf数据结构是一个uint16的数组,数组长度和docid的数组长度一致,所以当确定了某个docid时候,也随即确定了它的tf信息。
  3. postion信息存储是一个二维数组的格式,第一层数组存放的是对应于term的在字符串池的offset,因为term在字符串池中已经ID化,所以offset可以表示唯一term。第二层数组是该term在字段中多次出现的位置,使用uint16存储。

前缀检索文件

  1. FST静态段文件

    a. 静态段中前缀是fst的数据结构,因为fst一旦建立是不能够进行修改的,所以在段合并的时候需要对所有term进行排序然后再构建fst结构。

    b. fst的node节点存放了对应于term的字符串池的offset。当需要查询一个term的倒排结构时候,需要先查询该term的字符串池的offset,然后拿该offset去倒排的termoffset文件中二分查找找到对应的倒排positionlist结构拿到对应倒排。所以一次term到倒排的查询需要查询一次fst+一次二分查询。

    c. term到倒排的查询一次fst+一次二分查找效率不高,所以针对term到倒排查询,新增了第二种HashMap索引,直接通过term到倒排的offset索引,这个选项在建表的时候可以配置。

  2. 实时段RcuTrie树索引

    a. 实时段中需要支持边写边读,前缀检索需要支持并发读写。引擎中trie树是rcu实现,单线程更新,多线程并发读,trie树写更新节点内存延迟回收。

微信图片_2025-05-14_105704_249.png

倒排索引和查询树逻辑

倒排链优化

  1. DSearch1.0的roaringbimap倒排索引在低密度数据量上存在一些瓶颈,比如对于倒排链比较短的情况下,roaringbitmap的container大部分都是array结构,在倒排链查询和合并都会进行一次二分查找,在大面积的倒排链合并中是个相当大的性能瓶颈。
  2. 针对上面所说的情况对roaringbitmap进行了精简,只存array或者bitmap合并的时候不需要查找,直接链式合并。

逻辑树合并优化

  1. DSearch2.0重点从逻辑语法树和倒排入手,优化语法树,减少合并树高,从二叉树合并变成单层合并。
  2. 优化倒排链合并方式,采用原地倒排链合并,消除倒排合并临时对象,同时引入多线程并行合并,减少长尾提高性能。

微信图片_2025-05-14_105712_581.png

增量更新逻辑

增量实时写入逻辑

  1. 引擎支持多个并发实时段,这个由配置文件通过配置来进行配置。多个实时段能够提升并发写入的性能。
  2. 每个实时段对应一个写入队列,提高并发写入吞吐。
  3. 每个段真实写入一条信息会同步原子更新消费的kafka的offset,用于对后面进程重启等恢复数据做准备。
  4. 当进程重启或者异常退出时候,会读取metadata文件中的最后一条kafka offset进行重新消费增量在内存中重新构建新的正排、文档和倒排等信息,完成数据的恢复。

微信图片_2025-05-14_105722_614.png

实时段固化和段合并策略

实时段固化逻辑:

  1. 当实时段内随着增量写,doc文件大小超过128M时候会进行内存实时段固化操作。
  2. 固化操作开始时,会先生成新的内存实时段,老的内存实时段会变成只读内存段。
  3. 遍历按整个只读内存段,构建新的索引和新的正排结构生成新的静态段。

段合并策略:

  1. 实时段固化的小静态段因为大小比较小,会优先和之前固化后的小段进行合并,按照1,2,4,8进行合并,逐步合并成静态段最大的上限。
  2. 静态段的合并触发策略是当静态段中delete的doc比例超过了30%会触发静态段之间的合并,合并会按照近邻合并原则,从左右近邻中选取一个最小doc数的段进行合并,进而新生成一个新的段。

微信图片_2025-05-14_105730_298.png

查询和更新中的并发控制

查询流程

引擎查询时候,先遍历查询实时段,然后再查询静态段。实时段查询存在最大增量查询截断,当实时段查询到最大增量截断时实时段停止查询。

实时段查询后,查询静态段。静态段中包含了全量构建索引的全量最大offset记录同时全量的doc是通过质量分进行排序,所以在全量段查询的时候,先遍历质量分最大的全量段,逐步往后面静态段查询,直到查询到全量截断。

实时段查询和静态段查询结果进行merge作为最终的查询结果。

更新并发控制

因为DSearch2.0的索引更新是直接在实时段或者静态段进行更新,所以存在多线程读写问题。尤其是正排字段更新写入量大更新频繁。同时更新涉及到所有的实时段和静态段,较为复杂。

为了解决正排字段和倒排的更新问题,新版本引擎引入了document文档锁池,对每个doc进行hash计算落到锁池中具体一个锁上来减少锁冲突,当前锁池内有多个个文档锁。文档锁在文档进行拷贝和更新的时候会进行锁住。

DSearch3.0搜索核心升级

异步非阻塞图调度框架

微信图片_2025-05-14_105739_148.png

引擎主要改造:

  1. 图框架支持RPC异步非阻塞请求:引擎图框架RpcServer服务使用brpc的异步处理无需同步阻塞等待调度完成,只需框架调度完算子返回结果,不阻塞RpcServer线程,例如:当前引擎调用neuron服务是同步调用,当neuron服务负载高阻塞时,同步调用会导致拖住引擎RpcServer处理线程,新的异步非阻塞模式引擎client在调用引擎后已经返回,等待引擎RpcServer中异步调度框架中remote异步算子回调,减少外部服务影响引擎。
  2. 减少线程切换: 图框架调度器会优先调度当前运行线程,同时使用M:N类型的bthread线程池,线程切换会更小,执行效率高。
  3. RPC服务和框架算子独立: 引擎RPC服务和框架算子完全解耦,跨集群部署算子服务无需任何改造,实现算子脱离运行环境。
  4. 高效的算子异常处理和超时机制: 每个算子维护自己的运行超时时间和请求到算子调度执行的超时时间,对整个请求流程中各算子执行更加精准。 
  5. 动态图支持: 图框架支持静态图和动态图业务组合式调用。支持静态子图和动态子图调用等复杂业务组合。
  6. 复杂子图支持: 图框架支持嵌套子图,支持自调用模型,可以实现复杂单节点多功能调用。

算子间数据交换Table设计

微信图片_2025-05-14_105745_282.png

引擎主要改造:

  1. 列式数据共享优化: 算子交换数据全部存放在Table列中,Table中全部共享列式数据,省去大面积数据拷贝,大幅提升引擎业务执行性能。
  2. 兼容引擎索引中doc数据: 引擎索引中doc行式存储有很多优点,比如多字段访问效率高等,Table设计中考虑了行式存储优点,不仅存高频的列字段也储存了引擎内部的doc以及对应FieldDef,能直接方便访问索引数据,接口统一,易于迭代。
  3. 打通FlatBuffer序列化协议: 当前引擎FlatBuffer序列化传输协议和引擎内部数据出口需要多次遍历转换,需要拷贝很多数据,新Table的设计内部数据列和FlatBuffer内部的数据列互转互通,节省大量内部拷贝同时避免了字段兼容等问题。
  4. 支持原地排序和标记删除: Table数据表,支持原地sort操作和标记删除操作,节省数据排序时大量数据的拷贝和删除操作中导致的数据重排等拷贝操作,提升性能。

算子间数据交换Table设计

微信图片_2025-05-14_105752_514.png

引擎主要改造:

  1. 动态图支持: DSsearch3.0支持动态图编排,主要通过业务方通过动态编排请求来组织对应的算子编排逻辑,实现业务方自主编排调度逻辑,方便整体业务开发。
  2. Remote远程调用支持: 通过开发远程异步调用算子,支持DSearch3.0跨集群调用,实现多机算子化互联互通。提高引擎的整体纵向拓展能力。
  3. 引擎算子库复用: 通过设计统一的算子接口,开发基础的可复用框架算子,支持配置化组合运行图,实现业务逻辑快速复用和开发,提高整体引擎开发效率。

三、性能和效果提升

DSearch在2024年Q1季度索引升级开发完成后逐步推全到交易和社区等各个主场景业务中,最后拿到了很多超预期结果:

索引内存优化超出预期: 社区搜索和交易搜索总索引单分片优化60%。

构建和写入性能优化超出预期: 社区搜索和交易搜索主表写入性能提升10倍。

索引更新优化超预期: 社区和交易主表更新时间提升接近10倍。

性能优化符合预期: 社区搜索平均rt降低一倍,P99晚高峰降低2倍。

四、总结

DSearch引擎从开始的DSearch1.0的搜索引擎逐步经历了DSearch2.0的分段式索引改造升级,又经历了DSearch3.0的全图化引擎升级。逐步将DSearch引擎升级到业界较为领先的支持内存型、磁盘型多段式搜索引擎,为支持得物业务的发展做出了重要的贡献,后续DSearch会围绕着通用化、自迭代、高性能等多个方向继续升级,将DSearch引擎迭代到业界领先的引擎。

算法团队大量HC,欢迎加入我们:得物技术大量算法岗位多地上线,“职”等你来!

往期回顾

1. 以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

2. 最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

3. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

4. 得物自研DGraph4.0推荐核心引擎升级之路

5. 大语言模型的训练后量化算法综述 | 得物技术

文 / 苏黎

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

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

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

以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

一、前言

在得物技术部,「稳定」「效率」「体验」「成长」「创新」是我们的关键词。这些关键词就像是战略航行的导航系统:在短期诱惑前构筑认知屏障,筛选干扰项;在组织进化中沉淀文化基因,保持创新。其中的「成长」就意味着专业深耕中永不自满的自我迭代、跨边界协作中主动打破能力天花板的勇气,以及在成就业务目标的同时构建个人价值护城河的清醒认知。

作为得物技术保障部的容器技术团队成员,孟同学在入职两年内迅速成长为团队标杆人物,其主导的【一站式大模型训练与推理平台项目】 不仅极大降低了大模型接入成本,在社区、客服、公司内部应用等场景成功落地,增强了业务价值与用户体验。在公司内外多次积极分享技术成果,提升了公司技术影响力,更以极致细节与自驱力在内部形成示范效应。

正值Q2成长宣传季,技术运营牵头做人物采访,本季度将会采访两位在得物成长比较快的同学,看看他们究竟做了什么?又是如何将「成长」「自驱」融入工作中?今天我们来看看第一位同学「孟同学」,看看他背后的故事。

二、初心与选择:得物的创新很吸引我

孟同学之前在多家互联网公司工作,包括腾讯、Paypal、唯品会、蚂蚁,2019年后阿里达摩院从事算法工程开发;2022年10月加入得物,在得物容器技术从事算法工程相关工作,主要负责得物大模型平台的相关业务。

当时,得物发布了一个云原生AI的职位,要求既有云原生技术背景,又能涉猎AI领域。这两个方向在都是业界的热门趋势,一时间挺难招到比较合适的人,这时候孟同学出现了。他正好在阿里达摩院从事类似的工作,且具备一定的云原生与AI的经验。他说:业界大多数岗位通常会专注于云原生或AI某一方向,但得物把这两者结合起来招聘,给了他一个新的视角和机会。于是他就抱着试试的心态来了得物。

他说:“这样的职位可以让我在专业技术上做一些新的探索,尝试将云原生与AI融合,可能带来更多创新的空间。带着“试试看”的心态,我投递了得物的这个职位,最终决定加入得物,去应对这个充满挑战和机遇的新环境。”

加入得物后,得物的文化和他也超级契合,他说,“在文化价值观中,最吸引我的是得物对 “效率”和“创新” 的高度重视,在当前快速发展的科技环境中,得物不仅倡导快速迭代,还鼓励在保证高效执行的基础上,持续创新并不断突破常规。我现在所在的容器团队就深刻体现了这一点。我们的工作模式通常是先通过小范围验证,快速实验新技术或方案的可行性,确认其技术路径可行且能够带来预期收益后,再进入大规模的开发与应用。这样整个容器团队紧跟技术发展,高效上线了很多新的好的技术优化方案。”

他也是得物技术飞速成长的员工之一,当技术运营问到他如何理解「成长」这一关键词,他表示, “成长”是一个持续自我突破和不断提升的过程。在快速变化的环境中,技术人员不仅要不断提升专业能力,还需要敢于走出舒适区,迎接新的技术挑战。成长不仅体现在技术上,更是一种心态,保持持续学习和反思的能力,追求更高标准。

他在主导“一站式大模型推理平台项目”时,面临的最大挑战是如何降低大模型接入的高成本。2023年初,刚开始接入大模型时,由于推理引擎性能较低,需要大量GPU卡,很多业务难以落地。如果按常规思路,这个项目很难推进。既然常规思路走不通,那么他就换了个思路,通过主动关注社区的最新论文和开源代码**,去尝试社区中提到的优化技巧和加速方案。通过把社区的创新思路与得物内部场景结合,快速验证并落地到得物内部的大模型推理平台中,将优化方案应用到实际场景中,降低了成本并提升了平台性能。

Sean曾在π问π答说过:成长 = 当你遇到复杂问题的时候,能解决复杂问题。 就是说,在这个过程中,克服困难,牵头思考解决方案、寻找解决方案,这样的成长是最快的。回顾孟同学的成长过程,亦是如此。

三、专业与细节:细节直接影响整体效果

在从0到1 建设一站式大模型训练推理平台时,孟同学也遇到了很多问题。包括做这件事的 ROI 是什么?为什么要做这个项目?怎么落地?需要谁来协同?

ROI是什么?

从大模型在业内的落地情况来看,大模型相比传统小模型在效果上有显著提升,尤其在处理复杂任务,的确很有价值。但是从一个开源的大模型到能落地到我们的实际业务场景,需要投入较大的人力资源、研发周期、机器成本等。当时孟同学也是实打实去做了很多调研,包括从初期的人力投入、开发周期,到后续的计算成本,这些都是需要深入权衡ROI的因素。

当我问到,如何平衡收益和建设时,孟同学说一般从以下角度进行权衡:

一是聚焦核心应用场景:我们在选择大模型应用场景时,会优先聚焦那些能够带来最大业务增值的领域。例如,在客户服务和社区管理等场景中,大模型可以有效提高自动化水平,改善用户体验,从而大大提升效率,预期收益也比较显著。通过与算法团队的紧密合作,将大模型的应用精准落地到这些高价值场景中,我们能够在资源有限的情况下,最大化模型的价值和投入产出比。

二是持续优化大模型性能,降低大模型部署成本:大模型的部署成本高是不可忽视的现实,但我们注重的是在实施过程中持续优化大模型的性能,并结合社区最新的大模型推理优化技术进行调整。例如,我们引入了最新的Radix Attention,并行推理,大模型量化,DeepSeek MTP推理加速等技术,结合得物的具体业务场景,进行多方面的性能优化。这些优化不仅提升了大模型在实际应用中的效果,也有效降低了大模型部署的成本,从而实现更好的ROI。

三是通过资源池合并,多部门共用GPU资源的方式降低大模型训练与推理成本:在这方面,我们通过构建多个部门共用的大模型训练资源池,来降低大模型训练成本。在推理阶段,我们通过复用空闲的GPU资源,提供大模型的公共服务,等多种方式,使得多个部门可以共享这些资源,从而降低了大模型的推理成本。这种资源池的合并和共享方式,使得我们能够更加高效地利用公司现有的计算资源,降低整体的开销。

四是持续优化大模型训练与推理平台的效率,缩短上线时间:在训练与推理平台的效率优化上,我们做了大量的工作,通过构建更加高效的训练和推理流水线,减少了大模型上线的时间。同时,我们通过一键微调、一键部署等功能,使得业务方同学能够快速根据业务需求调整和部署大模型。这个自动化的流程不仅提高了工作效率,也大大缩短了从模型开发到实际应用的周期,进一步提升了项目的ROI。

通过上述方法,他们在建设大模型平台时能够尽量控制成本,优化投入产出比,确保大模型的业务落地在合理的时间周期内能带来可观的收益。

为什么做这个项目?

ROI 和团队说清楚了之后,还要跟团队说清楚,我们为什么要做「大模型训练推理平台」,以及怎么去做?

在这个项目中,孟同学担任了一个二合一的角色,既负责产品设计,也负责功能开发。时间回溯到2022年底至2023年初,伴随着ChatGPT的发布,大模型概念的爆发式增长**。与此同时,公司内部也有很多同学开始关注如何部署大模型、如何利用大模型为他们的业务带来实际的收益。当时面临的问题,业务需求不断增加,大模型不断发展,没有统一大模型专用平台,更别说利用大模型来为业务做有效支撑和带来实际收益了。

孟同学就意识到,必须尽快构建一个大模型的专用的平台,让大家能够在这个平台上以低门槛的方式使用并接入大模型。在这个背景下,孟同学他们就开始构建一站式大模型训练与推理平台。

怎么做?

在落地这个项目过程中,还需要考虑到几个实际问题。

首先这个平台支持大模型的快速部署。伴随大模型概念的火爆,通过Lora**微调大模型的方式,因其成本低,效果好,很快流行出来了。于是他们把大模型微调功能也加到平台上了。这样很多业务方便可以使用少量数据,以较低的成本快速微调他们自己的专用大模型。这个便是一站式大模型训练与推理平台最初的架构。但那个时候很多云厂商都还没有相关的平台,落地全靠一步步摸索。

从收益角度来看,首先,通过集中训练与统一部署大模型,我们可以进行统一的优化与资源配置,显著降低了大模型训练与部署的成本。其次,这个平台打破了技术壁垒,使得公司内部非算法同学也能够通过平台自助式操作,基于自己的数据进行模型微调与快速部署。

最终「一站式大模型训练推理平台」也是在得物内部顺利落地,不仅极大降低了大模型接入成本,在社区、客服、公司内部应用等场景成功落地,增强了业务价值与用户体验。

项目成功上线后,有项目小伙伴吐槽到,孟同学对「用户动线设计/代码注释规范」简直是有“强迫症”。

孟同学表示,强迫症肯定没有,就是有点爱抠细节。

在每个项目开始之前,他都会与业务方进行详细的需求梳理,并通过多轮评审确保需求的准确性和可执行性。这种做法在开发大模型平台时,帮助他们避免了许多潜在的风险。

他还说:“在工作中,细节直接影响整体效果,尤其是在用户动线设计和代码注释规范上。用户动线设计关系到用户体验的流畅性,而代码注释则是团队协作的关键,能帮助成员快速理解和优化代码。任何细节上的不足,都可能导致后续问题的产生。  

例如,平台操作步骤过于复杂或逻辑不清晰,会直接影响用户体验和平台的使用频率。在大模型平台的设计过程中,通过反复优化用户操作流程,简化步骤,减少不必要的点击,确保用户体验顺畅。”

四、自驱与成长:补齐短板,让长板更长

从项目牵头设计到最终落地,孟同学的成长无疑是非常快的,在沟通过程中,还发现孟同学是一个「自驱」的小伙伴,入职后,主动牵头了向量数据库Milvus平台构建这个项目。这对他来说完全是一个全新的领域,但是他竟然可以在短时间学习相关知识,快速补齐短板。

图片

他说,“我有幸牵头了Milvus向量数据库平台的建设项目。虽然我之前有一定的数据库和分布式系统的经验,但向量数据库的应用和优化对我来说是一个全新的领域。这个挑战让我希望能够学习并掌握更多技术,拓宽自己的视野,提升专业能力。”

为了尽快弥补不足,他采取了两个方法。

一是,通过多种途径学习Milvus相关的理论和实践,深入理解其原理和应用,特别是如何处理大规模向量数据、优化索引和提升检索效率等。这样,不仅积累了经验,还能帮助他在项目中做出更加合适的技术决策。

二是,加入了Milvus开源社区,积极与社区的开发者和专家进行交流,主动去向社区专家请教问题,了解他们的经验和解决方案。这种互动不仅让他学到了很多实用的知识,还获得了很多帮助,也让他能更好地理解Milvus的最新动态和功能。

在项目的早期,Milvus的某些版本在高并发和大规模数据量下存在稳定性问题。为了解决这些问题,他们进行了多次性能压测,分析系统瓶颈并向社区反馈,最终在社区的帮助下逐步优化了性能,确保了平台的稳定和高效运行。这个过程中,他积累了Milvus的系统调优经验,也加深了对Milvus架构的理解。

除了工作上的成长外,孟同学还经常受到来自外部行业大会的邀请去分享相关的实战经验。孟同学说,“我认为行业分享是自我成长的途径。” 在准备分享时,他会回顾自己的工作,思考技术的有效性,帮助他识别和改进可能忽视的细节。

他认为,“每次分享不仅是与他人交流,也是提升自己知识体系的机会,促使自己可以不断学习和拓展知识。分享还可以结识行业内外的优秀实践和新朋友,吸收新见解,拓宽视野,促进与其他专家和企业的合作,这对个人成长和公司影响力都很有益。”

微信图片_2025-05-12_140458_402.jpg 对他来说,分享虽然需要消耗时间和精力,但他认为这是长期投资,提升个人影响力,推动团队进步,为公司带来更多价值,这是一件值得长期去做的事情,不断地去通过持续学习、分享,自己也会不断的向前探索。

五、工作与平衡:“计划驱动”和“灵活调整”

有时候他们也会面临紧急项目,当项目和生活中重要事情冲突时,孟同学表示,“我始终保持 “计划驱动”和“灵活调整” 相结合的方法,以确保项目按时交付并达成预期的业务结果。我的经验是先从小规模验证开始,再逐步扩大应用,确保每一步都有清晰的反馈和调整。”

以大模型平台项目为例,在项目初期,他们构建了最小可行产品(MVP),并邀请相关同学进行试用。虽然前期看似投入了较多时间,但通过小范围的验证,能够在功能扩展前发现潜在问题,确保后续的推广和扩展更具保障。这种方式避免了大规模投入后发现问题的风险,并让他们能在优化过程中积累实际经验。

类似地,在进行推理服务性能优化的CPU与GPU分离项目时,也是先进行了小范围验证,并在验证效果良好后,才将其正式上线并在更多业务中推广。尽管前期验证看起来会浪费一些时间,但通过实际数据的反馈,他们就及时优化了方案,最终大规模部署时效果显著,节省了成本并提升了性能。

他表示,“这种逐步推进、快速反馈与调整的方法,帮助我们在高压环境下保持灵活性,确保项目能在预定时间内顺利完成,并且保证了最终的业务收益。”

六、展望与建议:保持成长型思维,勇于突破自我边界

孟同学说“未来三年,他也会持续专注于大模型的部署性能优化和应用场景落地。”

当前,大模型的推理性能和高昂成本是制约其广泛应用的主要因素,特别是在计算资源和效率方面。与此同时,像Rag,Agent这样的应用场景在各行业的落地也面临一些技术挑战,仍需要更深入的研究。

他也有自己一些学习方向分享给大家。

一是通过多种方式为自己积累相关的知识和经验。比如参与一些项目,关于如何优化大模型的计算效率,并降低推理成本。

二是积极参与开源社区的讨论,跟踪相关领域的技术进展。通过阅读最新的论文和开源代码,去了解了当前大模型优化的前沿技术,并从一些专家那里获得了宝贵的指导,帮助他更好地理解这一领域中的技术挑战。

他也会持续关注新的场景融合,比如,探讨如何将大模型与云计算结合,特别是在云原生环境下如何提高资源调度效率,进一步提升大模型的训练和推理性能。

我们也相信在未来三年,孟同学在大模型的优化和应用落地方面会有更多的积累,并能为行业提供更加实用的技术解决方案。一说到孟同学都纷纷说,对,就是那个大佬!

当问到他对新入职的小伙伴有什么建议时,他说: “保持成长型思维,勇于突破自我边界。”

在职场初期,很多人会遇到不熟悉的工作和挑战,可能感到不安或迷茫,但这些正是成长的机会。孟同学在入职初期也经历了不少挑战,特别是在跨部门协作方面。刚开始时,他会对如何协调各部门的需求和资源感到不确定。

当时他参与的一个大模型平台项目,项目初期需要与多个部门沟通确认需求,每个部门的系统和流程都不相同,信息的对接和沟通也很复杂。为了快速推荐和落地,他主动向经验丰富的同学请教,逐步了解各团队的工作流程,并通过与各部门的同学逐一沟通,确保每个环节都能顺利衔接。

他说,“不必害怕犯错或显得不成熟,向有经验的同学请教能让我快速融入团队,学到更高效的工作方法。同时,我也学会了通过反思总结,不断找出自己的优点和不足,每完成一个任务后回顾自己在其中的表现,这让我在之后的工作中更加从容、不断提升。职场中的很多机会常常来源于那些需要学习新技能、走出舒适区的挑战。虽然这些任务看似困难,但正是通过解决这些困难,才能带来更大的成长空间。因此,保持开放的学习心态和积极迎接挑战,是职场新人最重要的品质,它不仅能帮助你在工作中不断进步,也为未来的职业发展打下坚实的基础。”

通过和孟同学的对话,我们看到的不仅是一个将“反复打磨”刻入日常的细节控,更是一个在时代快变中锚定自我进化节奏的长期主义者——他用行动验证:真正的成长从非宏大口号,而是把每个需求拆解为精进机会,将每次压力转化为认知升级的燃料,在“自驱”而非“他驱”的节奏中拓宽能力象限。

当组织与个体形成双向奔赴的成长型契约,那些被认真对待的代码、反复推敲的方案、深夜迭代的模型,终将沉淀为个人不可替代的价值坐标。你就只管往山顶走,走过的路自然都会变成我们的台阶。 你要坚信,时间从不辜负认真打磨自己的人。共勉!

往期回顾

1. 最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

2. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

3. 得物业务参数配置中心架构综述

4. 得物增长兑换商城的构架演进

5. 得物自研DGraph4.0推荐核心引擎升级之路

文 / 得物技术

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

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

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

最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

一、前言

MCP 全称 Model Context Protocol,是由 Anthropic公司在 2024 年 11 月推出一个开放协议,主要用于标准化应用程序向大语言模型提供上下文的方式。可以将 MCP 想象成 AI 应用程序的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式。

微信图片_2025-05-08_114916_097.png

近期 MCP 的热度持续上升,网上也是喷涌出大量相关文章,相信在不远的将来 MCP 将成为每个开发者必备的技能之一,非常值得投入时间学习一下。下面会通过简单的实践来带大家理解一下 MCP 的工作原理,以及展望下 MCP 在未来可能的一些应用场景。

二、MCP 基础架构

基础架构

在开始实践之前,还是简单介绍一下 MCP 的基本架构和一些基础组件:

微信图片_2025-05-08_114957_376.jpg

  • MCP Host:需要通过MCP访问数据的程序,例如 Claude Desktop、Cursor**、Cline等桌面工具。

    主要职责:接受&返回你的提问、跟模型交互、内置了 MCP Client,与服务器保持一对一连接的协议客户端。

  • MCP Server:轻量级程序,每个程序都通过标准化的模型上下文协议 (MCP) 提供特定功能。

    主要职责:能力暴露(操作本地文件&浏览器,访问数据库,访问远程服务)。

  • 本地数据源:MCP 服务器可以安全访问的数据库、本地文件、浏览器等。

  • 远程服务:MCP 服务器可以通过互联网(例如通过 API)连接到的外部系统。

工作流程

从用户提问,到最终完成任务的完整流程可参考下图:

图片

百闻不如一见,百见不如一练。下面我们手把手开发一个 MCP Server,并且通过 Cline 来使用它,实践过程中会容易帮助我们去理解 MCP。

三、MCP Server 开发&实践

准备MCP Client

这里我用的是 Cline,是 VSCode** 中的一个插件,直接在 VSCode 插件市场中搜索安装即可,其实这里的 Cline 在 MCP 的概念中是 MCP Host,只是 Host 里面内置了 MCP Client(负责跟模型&MCP Server 交互)。

其实更推荐使用 Claude,但是 Claude注册流程相对复杂一点,对网络环境要求也更高(需要科学上网)。

图片

安装好后,第一步就是需要配置大模型,这里我选择的是 DeepSeek。

需要自行购买 API Key(platform.deepseek.com/api_keys)

图片

然后就可以开始配置 MCP server 了,点击右上角的第二个图标。

图片

这里可以使用开源的 MCP Server,也可以使用自己开发的 MCP Server,下面我们尝试自己动手开发一个简单的 MCP Server。

开发MCP Server

想要开发一个 MCP Server,并不需要关心协议本身的一些细节,因为官方推出了各种语言的 SDK modelcontextprotocol.io/sdk/java/mc… ,通过 SDK 可以快速搭建一个 MCP Server,并且主流语言都针对 MCP 推出了自己的框架,Java 也不例外,这里我们选择使用 Spring 框架来搭建一个 MCP Server docs.spring.io/spring-ai/r…

引入依赖

<dependency>    <groupId>org.springframework.ai</groupId>    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId></dependency>

定义 Tools

这里我们定义一个发送飞书消息的工具类:

import org.springframework.ai.tool.annotation.Tool;import org.springframework.ai.tool.annotation.ToolParam;import org.springframework.stereotype.Service;import com.lark.oapi.Client;import com.lark.oapi.core.cache.LocalCache;import com.lark.oapi.core.enums.AppType;import com.lark.oapi.service.im.v1.enums.MsgTypeEnum;import com.lark.oapi.service.im.v1.enums.ReceiveIdTypeEnum;import com.lark.oapi.service.im.v1.model.CreateMessageReq;import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;import com.lark.oapi.service.im.v1.model.CreateMessageResp;import java.util.concurrent.TimeUnit;/** * @author xinyi */@Servicepublic class LarkService {    private final Client larkClient = feishuClient();    public Client feishuClient() {        return Client.newBuilder(System.getenv("larkAppId"),                   System.getenv("larkAppSecret")).appType(AppType.SELF_BUILT) // 设置app类型,默认为自建                .tokenCache(LocalCache.getInstance()) // 设置token缓存,默认为内存缓存                .requestTimeout(10, TimeUnit.SECONDS) // 设置httpclient 超时时间,默认永不超时                .logReqAtDebug(false)                .build();    }    @Tool(description = "用飞书给用户发消息")    public String sendLarkCardMessage(@ToolParam(description = "接收人邮箱") String receiveEmail,                                      @ToolParam(description = "飞书卡片内容(参考飞书文档要求的结构体)") String cardContent) throws Exception {        CreateMessageReq req = CreateMessageReq.newBuilder().receiveIdType(ReceiveIdTypeEnum.EMAIL.getValue())                .createMessageReqBody(CreateMessageReqBody.newBuilder()                        .receiveId(receiveEmail)                        .msgType(MsgTypeEnum.MSG_TYPE_INTERACTIVE.getValue())                        .content(cardContent)                        .build())                .build();        CreateMessageResp resp = larkClient.im().message().create(req);        return resp.getMsg();    }}

这里 Spring 会自动把@Tools 注解的方法按照 MCP 标准暴露出来,大模型会根据其中的描述来决策是否可以调用此方法。

启动类

import org.springframework.ai.tool.ToolCallbackProvider;import org.springframework.ai.tool.method.MethodToolCallbackProvider;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.annotation.Bean;@SpringBootApplicationpublic class McpServerApplication {    public static void main(String[] args) {        SpringApplication.run(McpServerApplication.class, args);    }    @Bean    public ToolCallbackProvider weatherTools(LarkService larkService) {        return MethodToolCallbackProvider.builder().toolObjects(larkService).build();    }}

打包

到这里一个简单的 MCP Server 就已经开发完成了,下面只需要执行 mvn clean package 打成可执行 jar 包就能配置到 Cline 中了。

图片

配置MCP Server

回到 VSCode 的 Cline 插件,点击第二个图标,然后点击下面的 Configure MCP Servers,然后开始编辑右侧的配置文件:

图片

这里的配置文件是 MCP 标准化的,下面基于我们这个 MCP Server 介绍下几个核心配置的含义:

 "mcpServers": {      "lark": {        "disabled": false,        "timeout": 60,        "command": "/Users/admin/Documents/jdk-17.jdk/Contents/Home/bin/java",        "args": [          "-Dspring.ai.mcp.server.stdio=true",          "-Dspring.main.web-application-type=none",          "-Dlogging.pattern.console=",          "-jar",          "/Users/admin/Documents/git/open-source/spring-ai-mcp-server-demo/target/spring-ai-mcp-server-demo-1.0-SNAPSHOT.jar"        ],        "env": {          "larkAppId": "xxx",          "larkAppSecret": "xxx"        },        "autoApprove": [          "sendLarkCardMessage"        ],        "transportType": "stdio"      },
  • mcpServers:JSON 配置跟 Key

  • lark:MCP Server 唯一标识&名称

  • command:启动 MCP Server 的命令(如 Java 就是 java -jar,Node 一般是 npx,Python 一般是 uvx)

  • args:执行命令后面的自定义参数

  • env:环境变量,用于配置一些可配置参数,比如密钥、外部 URL 等

这里配置好了后,如果右上角的点变成了绿色说明 MCP Server 加载成功,而且在下面还可以看到 MCP Server 提供的所有 Tools,以及每个 Tool 的参数跟描述。

图片

开始体验

点击右上角的+号开始聊天:给我发一条下午好的飞书卡片消息,附带一下今日的热点新闻。

图片

可以看到 Cline 调用了大模型开始思考,并且根据 MCP Server 提供的 Tools 开始选择发送消息接口并执行。

图片

而且如果第一次尝试失败,还会自动纠错,最后成功调用了我们 MCP Server 提供的 Tools,发送了一条消息给我。

图片图片

进阶体验

上面的例子我们只用到了一个 Tools,我们可以尝试组合多个 Tools&多个 MCP Server 来实现更复杂的任务,比如我们现在再开发一个可以操作 ES 的 MCP Server,然后打包后配置到 Cline 中。

@Tool(description = """        通用ES查询工具,参数示例:        path: 请求路径        method: HTTP请求方法 GET 或 POST        queryJson: 具体请求体        """)public String searchByQuery(        String path,        String method,        String queryJson) {    String url = String.format("%s/%s", System.getEnv("esBaseUrl"), path);    HttpEntity<String> request = buildEsRequest(queryJson);    ResponseEntity<String> response = restTemplate.exchange(            url, HttpMethod.valueOf(method), request, String.class);    return response.getBody();}

配置好后,在对话中发送:分析一下 es 集群目前的索引分布,重点分析一下哪些索引的分片设置不合理,最终整理后飞书发给我。

图片

然后会根据请求 ES 返回的结果,再次吐给模型进行分析。

图片

最终整理后通过飞书发送一份简单报告。

图片

联想一下

想象一下,如果组合一下飞书文档、浏览器操作、文件系统、发布系统对接等 MCP Server,一句话就可以让大模型从自动连接浏览器,打开飞书文档,分析需求,分析视觉稿,然后自己写代码,对比视觉稿,你就喝杯咖啡,静静的看着它工作。

顺带推荐一下常用的 MCP Client 以及一些现成的 MCP Server。

四、总结

相信大家通过上面的实践后,对 MCP 有了一个基本的认识,组合多个 MCP Server 的工作流可以自主完成非常复杂的任务,关键是这协议统一了连接标准,有大量现成的 MCP Server 可以即插即用,大幅降低建设成本。总之 MCP 协议的持续落地,让 AI 不再只是聊天工具,而是工业智能革命的万能操作平台,在未来潜力无限,想象无限,值得每一位开发者去学习并掌握它!

往期回顾

1. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

2. 得物业务参数配置中心架构综述

3. 得物增长兑换商城的构架演进

4. 得物自研DGraph4.0推荐核心引擎升级之路

5. 大语言模型的训练后量化算法综述 | 得物技术

文 / 新一

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

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

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

大语言模型的训练后量化算法综述 | 得物技术

简介

在模型轻量化领域,量化是一种用于减少神经网络模型大小和计算量的技术,将模型参数(权重)或中间变量(激励)从高精度类型(FP32, FP16, BF16等)转换为低精度类型(int8, int4, fp8等)。 而近年来随着Transformer,MoE等架构的提出和大模型的兴起,使得神经网络模型能轻松突破几十亿甚至上万亿的规模参数,因此,我们需要一些适应于大模型的压缩技术,来降低模型的部署成本,并提升模型的推理效率。

从最初的GPTQ、AWQ等weight-only的量化算法开始,到现在LLM从训练、推理、轻量化、Agent等所有赛道都卷到飞起的时代,基于大模型的特性,在两年多时间里业内已有很多新的量化算法。 

概念

以下介绍一些模型量化中的概念。

量化

  • 量化感知训练(Quantization Aware Training, QAT):训练过程中插入伪量化算子,通过训练时统计输入输出的数据范围并动态调整量化参数,将量化过程结合到模型的训练过程中,适用于对模型精度要求较高的场景。

  • 训练后量化(Post Training Quantization, PTQ):模型训练完成后对其参数进行量化,通常只需要少量校验数据或不需要校验数据,简单高效,不需要训练,但通常相比QAT精度损失略大。

由于LLM通常训练成本巨大,所以PTQ在LLM中通常是主要的量化选择,本文后续主要介绍各种PTQ的方案。 

量化对象

  • Weight:即模型的权重,在LLM中主要指Linear算子的权重。权重量化可减少模型显存开销。

  • Activation:在模型前向计算过程中的输入输出变量,通常不会单独量化激励张量,而是结合权重量化一起。在LLM中激励矩阵的数值变化范围相比权重更大,有更多离群的异常值,因此相比权重量化更难。

  • KV Cache:除了权重和激活之外,在LLM的 KV Cache作为减少重复计算的特殊存在,会消耗不少的显存。 因此,KV Cache量化在LLM推理中减少显存开销,提升吞吐也很重要。

在LLM中,对Weight和Activation而言,通常有只量化权重的weight-only方法和weight & activation都量化的方法;另外为减少KV Cache的计算开销,也有对其进行量化。

细粒度

  • per-tensor量化:逐张量量化,或逐层量化,每个张量只有一个缩放因子。

  • per-channel 量化:逐通道量化,每个通道都有不同的缩放因子。

  • per-token 量化:主要对transformer中的激励矩阵而言,即逐行量化。在LLM中,常与权重per-channel 量化配合使用。

  • per-group:以组为单位,多个元素成组共享一个缩放因子,如GPTQ、AWQ常用的128个元素为一组进行量化,将通道划分为更小的子组,以实现更细粒度的精度控制。

其他维度

分类维度 类型 对比特点 适用范围
按是否需要额外校验数据 静态量化 不需要,通常速度较快。 常用于权重量化
动态量化 需要额外校验集对模型进行前向推理或后向传播,根据推理结果动态计算量化参数;相比静态量化速度较慢。 适用于权重量化和激励量化
按量化过程的时机 离线量化 在模型上线推理前,提前计算量化参数。 常用于权重量化和激励量化
在线量化 在推理过程中实时计算量化参数。 常用于LLM中的激励量化
按量化步长是否均匀 线性量化 量化步长固定,表示的范围均匀。计算复杂度低,硬件友好。 常用于基于通用GPU的量化方案
非线性量化 量化步长不固定,表示范围更灵活。精度损失更小,但计算复杂度高。对硬件支持要求更高。 用于基于专用芯片的量化方案
按量化范围是否对称 对称量化 量化数据范围以零值对称。零点值(zero-point)固定为0值,仅需考虑缩放(scale)参数。 用于权重量化和激励量化
非对称量化 量化数据范围为非对称。zero-point和scale参数都要计算。 权重量化和激励量化通常不会同时为非对称量化

量化方法摘要

GPTQ

GPTQ是一种weight-only的量化方法。它的特点是通过Hessian矩阵对每层权重做逐列量化,并在每列量化中通过反馈补偿之前的量化损失。它是LLM早期主要量化算法,因量化速度快和量化损失小,是早期在实践中被应用最广的算法。具体细节可参见之前的文章:
模型量化与量化在LLM中的应用

图片

GPTQ算法流程(图片来源:参考文献[1])

AWQ

AWQ(Activation-aware Weight Quantization) 也是一种weight-only的量化算法,也是早期主流的LLM量化算法,其特点是量化速度相较于GPTQ更快,且量化损失在多数量化方案和模型上相较于GPTQ也更小,到目前为止也是一种非常实用的量化方案。

AWQ出自深耕深度学习轻量化多年的HanSong团队,其主要原理是根据前向推理中的对应激励矩阵各个通道的数值,而非权重矩阵的通道数值来衡量权重矩阵各个通道的重要性,从而自动检索每个通道的缩放因子,并进而在优化损失函数中减小量化误差。具体细节也可参见之前的文章:模型量化与量化在LLM中的应用

图片

AWQ中的平滑过程(图片来源:参考文献[3])

HQQ

HQQ(Half-Quadratic Quantization)也是一种weight-only的量化方法,由其名称可知通过半二次优化的方法得到量化参数。相比AWQ和GTPQ,HQQ不依赖于校验数据集,不从最小化输出激励的角度优化,而是直接从权重本身优化量化前后的权重误差;而且其量化速度特别快,并且在低精度量化上有较好的量化误差。

优化目标如下,最小化原权重与量化反量化后的权重之间的误差,图片图片范数。

图片

  •  为量化参数(zero point和scale)

  • 图片  为量化、反量化过程。

  • 损失函数为图片范数,P<1, 相比于图片范数的均方差,图片 范数更关注权重数值中的长尾奇异值(outliers),以及矩阵的稀疏性,然而其非凸(non-convex)的特性需要优化函数做一定的转化。

优化过程

基于上述问题,引入一个额外变量图片让主优化函数分割成2个子优化问题;同时,为了方便使用迭代更新的过程解题,我们固定尺度参数图片,从而只优化零值图片

图片

通过交替优化的方法,可以写出如下子问题,以及超参的更新,

图片

sp1的形式是近端算子,对于图片范数,存在一个广义的阈值解如下,

图片

sp2可以通过量化公式代入,得到如下,

图片

并通过进一步简化(基于W的quantization grouping维度取均值),

图片

表现性能

HQQ的量化耗时非常短,以Llama2-70B为例,在A100上相比于GPTQ和AWQ,耗时分别缩短为1/50和1/25,同时也有着不逊色于前两者的量化精度损失;而Llama2-7B模型的量化耗时更是缩短到1分钟左右。 

图片

Llama2-7B量化:GPTQ, AWQ, HQQ三者的耗时对比

(图片来源:参考文献[6])

图片

Llama2-70B量化:GPTQ, AWQ, HQQ三者的耗时对比(图片来源:参考文献[6])

图片

HQQ 在group-wise量化模式下与GPTQ, AWQ等的性能对比(图片来源:参考文献[6])

SmoothQuant

SmoothQuant 是LLM量化领域首个对weight和activation做全量化,并能保障良好的量化损失,从而在实际中有广泛应用的量化算法,并以被Nvidia和Intel集成到各自的大模型量化工具TensorRT-LLM和Neural-Compressor中。SmoothQuant 也是由HanSong团队提出,因此也可在算法中看到相似的通道缩放操作。

该方法直接聚焦LLM量化困难的最主要原因,即transformer推理过程中的激活值中的异常值(Outliers)。激励矩阵中的异常值指的是绝对值比大多数的值大得多的元素值,异常值一直是量化领域的难点,是量化损失的重要来源,而LLM中的异常值尤难处理,因其通常持续存在于部分通道中,且量化过程中对其直接截断处理会对模型的生成能力造成重大影响。

图片

SmoothQuant中量化难度的迁移:激励矩阵中异常值的平滑(图片来源:参考文献[4])

该方法的核心是通过逐通道的缩放变换,使得Activation矩阵的绝对值幅度变得平滑,从而变得容易量化,而为了保障计算一致性,将反缩放因子作用到Weight中,稍微增加了Weight的量化难度;从而整体上使得模型的量化难度降低,且提高了量化精度。

量化过程

Transformer中常规的矩阵乘法表示为图片,SmoothQuant的矩阵乘法则表示如下,

图片

激励矩阵图片在列维度上每个元素除以图片, 权重矩阵图片在行维度上每个元素乘以图片,从而完成了对激励矩阵的平滑,以及保持整个乘法计算的一致性。

通道维度的缩放因子用对角矩阵图片表示,而如何对图片取值呢?作者提出了几种方案,

  • 一种是利用激励矩阵各个通道的绝对极值,即

    图片

  • 一种是利用权重矩阵各个通道的绝对极值,即

    图片

本质上,缩放因子的大小取值表达了我们要将激励矩阵量化难度的多少转移给权重矩阵。而以上的前者,容易将激活的量化难度向权重过度转移,从而导致权重量化难度大大增加;而后者会直接导致权重各通道的极值都相同,而激励依旧很难量化。

因而,一种平衡的方式如下,用α\alpha表示迁移强度,来控制激励量化难度迁移到权重的强度,图片

图片

而当图片时,下图表示了乘法计算中的缩放平滑过程。XW 在各自对应的通道计算绝对极大值,随后通过图片这两个向量计算得到缩放矩阵,再对XW 两个矩阵进行缩放变换,最后再对两个变换后的矩阵做乘法。 

图片

SmoothQuant矩阵乘法中的平滑过程示例(图片来源:参考文献[4])

表现性能

在量化模型的效果上,对比了同为Weight-activation量化的几种算法,SmoothQuant在多个数据集上的准确率表现突出。但作者没有对比同时代下的GPTQ、AWQ等weight-only的效果。

图片

SmoothQuant与W8A8和LLM.int8()的量化效果比较(图片来源:参考文献[4])

而在吞吐上,作者用CUTLASS实现了INT8乘法kernel,并将SmoothQuant集成到Pytorch之后,以W8A8方案为例,实现了在OPT模型上相比于原FP16模型在速度上1.56倍,以及在显存上1.96倍的优势。

图片

SmoothQuant经算子优化后与FP16和LLM.int8()的推理吞吐性能比较(图片来源:参考文献[4])

QuIP

在基于正交矩阵旋转优化大模型量化中的异常值(Outlier)问题的思路中,QuIP(Quantization with Incoherence Processing)是较早提出的一个方案。这种思路与SmoothQuant一样,都是在真正的量化步骤之前,通过对权重矩阵或激励矩阵做一定的前处理,使得该矩阵中的异常值改善或消失,使矩阵平滑,同时在整个前向推导中还能保持计算一致性。而与SmoothQuant直接对目标矩阵做尺度缩放不同,这种思路通常是通过对目标矩阵左乘、右乘正交矩阵,使得矩阵变得更容易量化。

该方案的主要主要亮点如下,

  • 一是分析矩阵中元素的绝对值分布,并定义了不相干性,将一个矩阵的量化难易程度具象化。

  • 二是提出了基于正交矩阵LDL分解的对权重矩阵的逐列量化方案,并证明了GPTQ也是该算法下的一种特殊情况。

  • 最后在低比特量化情况下,该算法证明了其性能优于之前的方案。 

矩阵的不相干性

作者定义了基于图片值的不相干性:当一个Hessian矩阵图片可以通过特征分解得到图片,且对所有的图片满足如下,

图片

那么我们说是的。 

而对于权重矩阵, 则定义其 如下, 

图片

以上定义中,矩阵的最大绝对值受限于图片值,而图片值越小,则越不相干,对应地,矩阵中的异常值就越少,也越容易量化。

量化过程

整体算法过程涉及到较为复杂的数学推导过程和大量定义和论证,其主要过程如下,

  • 第一步,对权重矩阵 图片做不相干性的前处理,使图片更容易量化,并作简单的量化处理;

  • 第二步,对Hessian矩阵(用图片计算,与GPTQ相同)做LDL分解;

  • 第三步,对图片进行逐列量化,每次量化当前列时,考虑前面所有已量化列的误差为反馈以缩小量化误差;

  • 第四步,逆不相干处理,以及反量化。

图片

QuIP算法的量化过程(图片来源:参考文献[5])

LDLQ

作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,

图片

作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,图片表示浮点权重,图片表示量化后的权重,图片表示输入矩阵,图片是其二阶矩阵,Hessian矩阵。

而对于每层Linear的图片,用如下形式作逐列量化,

图片

图片表示第k列权重,而图片表示第1到k-1列,图片表示量化后的第k列权重,Q表示对应的Near或Stochastic近似方法选择。图片表示某种序列的向量,也正是需要通过LDL分解求的校正项的系数。而整体的量化过程可以用矩阵的形式表示,用一个上三角矩阵表示LDL分解的系数矩阵,即图片 组成的矩阵,

图片

具体的不相干性处理和逆处理的算法过程可以参考论文中给出的细节。

图片

QuIP中不相干性的前处理和逆处理过程(图片来源:参考文献[5])

表现性能

下图是作者给出的对OPT模型权重层的处理前后,各层的元素值的不相干性的变化,可见在处理后,最大绝对值下降十分明显。

图片

OPT-2.7B模型在不相干性处理前后异常值数量的变化(图片来源:参考文献[5])

而在量化效果上,对比了同为weight-only的主流算法OPTQ(即GPTQ)在同比特情况下,多个验证集的准确率。在对Llama2-70b模型的低比特量化中,尤其是2-bit和3-bit, QuIP的效果明显,且没有崩坏。

图片

QuIP与OPTQ(GPTQ)在不同比特下的量化效果比较(图片来源:参考文献[5])

QuaRot

QuaRot(Quantization scheme based on Rotation)是基于旋转矩阵变换的一种量化方案,它的量化对象包括weight,activation以及KV cache。通过旋转矩阵,在保持一致性的前提下,去除中间变量的异常值,从而使量化更容易,这种模式应用于transformer中的Attention,KV cache和FFN中的激活值。

旋转矩阵

旋转变换利用的是正交矩阵先简单介绍一些相关的矩阵知识。

  • 正交矩阵图片是满足图片的方阵。

  • 旋转矩阵是正交矩阵。

  • Hadamard 矩阵是一个元素值都为{+1, -1}的正交矩阵。

  • Walsh-Hadamard矩阵是维度为图片 的Hadamard矩阵,

图片

图片图片是一个包含从{+1, -1}随机抽取的向量,可知图片也是正交矩阵。 

计算不变性

图片是一个权重矩阵,出现在Attention或FFN Block的左侧 (FFN中的图片图片,及Attention中的图片图片,那么可以将左侧乘以正交矩阵 图片 ,并通过将输出矩阵(FFN中的图片, 及Attention中的图片 )乘以图片来消除这种影响。

上述的计算不变性在当两个Block之间有RMSNorm时也是成立的。因为从概念上讲, RMSNorm对输入矩阵的每一行做归一化(其尺度缩放的参数会被吸收到就近的Linear权重),正交矩阵图片应用于 activation 矩阵不会影响范数。

图片

那么总的来说,对于一个Activation矩阵图片,右乘 图片,使得线性层的输入由图片变为了图片,被归一化之后送入下一个 Block,该Block 的输入权重现在是图片 ;即原本的图片 ,变成了图片, 输出不变,保持一致。 

量化过程

QuaRot总体分为2个阶段

  • 第1阶段,对transformer的前向过程进行旋转变换,具体是在Attention和FFN过程中插入离线Hadamard变换**和额外的在线Hadamard变换。
  • 第2阶段,利用现有的量化方法对weight进行量化,以及将量化过程加入前向过程使得对activation和cache进行在线量化。

第一阶段

第1阶段是对各个环节做Hadamard变换。 

图片

原Attention(包括RMSNorm): 实线箭头表示训练期间的变量流向,包括每个token的填充和推理

图片

原FFN(包括RMSNorm):门控前馈网络

图片

QuaRot版Attention:RMSNorm缩放alpha被吸收到输入矩阵,隐藏状态插入在线Hadamard变换进行旋转

图片

QuaRot版FFN:RMSNorm缩放alpha被吸收到输入矩阵,降采样Linear前插入在线Hadamard变换进行旋转。

QuaRot 量化前对transformer各个模块的旋转变换(图片来源:参考文献[7]

阶段1a: 权重调整,遵循计算不变性原理对权重做正交变换

对权重矩阵,例如图片, 首先,前面的LayerNorm 或 RMSNorm 的线性部分将被融合进来,再左乘随机Hadamard矩阵**图片,表示如下,

图片

其中图片表示归一化op被吸纳的线性部分,而对应输入的激励居住,则变为了图片

该操作对应Attention中的图片和FFN中的图片,而这样处理后对比处理前,激励不再包含异常值。

 

图片

旋转变换前后激励矩阵中异常值数量的变化(图片来源:参考文献[7])

阶段1b: 对FFN的输出插入在线Hadamard变换

该操作是针对下采样乘法图片的输入激励的异常值的处理。由上图可知插入了一个在线Hadamard变换算子,同时对下采样矩阵的参数做了补偿,使得图片

同时为了保障下一个Block的激励输入是带变换的,所以还需右乘一个图片,使得最终的变换形式是图片,保障FFN的输出为图片,作为下一模块的输入。 

阶段1c: 对Attention模块的注意力和Value的Hadamard变换

作者对注意力块同时应用了在线Hadamard变换和融入权重的离线Hadamard变换。 在计算注意力时,写成每个Head计算的维度,有如下形式,图片表示相应的Linear权重,

图片

其中,

  • 图片为softmax的输出,是一个维度为序列长度的方阵

  • 图片是单个Head的value矩阵,图片

  • 图片相乘后与图片相乘,上式表示逐Head的Attention模块输出图片的计算过程

首先对图片分别右乘和左乘,做Hadamard变换,带入上式,可知保持计算不变性。 

图片

图片分别有每个Head维度的图片concat而成,可以用单个Kronecker**乘法的形式表示对图片的变换,

图片

然后利用如下特性构建完整的Hadamard变换,

图片

  • 图片,先右乘了图片之后,再进行一次Hadamard Head操作(即图片图片表示注意力计算的输出),即相当于又右乘了图片,即总体右乘了 图片

  • 图片,先左乘了图片,再左乘了图片,所以总体左乘了图片

综上,所以总体上整个过程的设计保持了计算不变性。 

阶段1d: 对key的Hadamard变换

Key向量的计算也会收到异常值的影响,所以也需要引入Hadamard变换来消除这个问题。注意力矩阵图片 计算如下,

图片

其中,图片,是 输入Softmax时的缩放尺度,图片表示mask, 如最常用的Causal Mask,Pos 表示位置编码。 

由于Pos的存在妨碍了直接将Hadamard矩阵融合到图片中,因此也使用了在线Hadamard Head操作来旋转图片,对他们右乘图片

图片

其中的图片相当于变成了图片,整个计算过程保持了计算不变性。 

第2阶段

第2阶段是在变换后的真正量化过程。

阶段2a: 权重的量化

采用现成的GPTQ,或者更直接、更快速的RTN。

阶段2b: 激励的量化

对输入input进行per-token维度的在线量化,而其中RMSNorm依旧保持FP32的精度。

阶段2c: 缓存的量化

对kv cache直接量化到低比特并存储,并在需要计算时提取并重新反量化到FP16精度,计算乘法。而过程中Query保持FP16,  并参考了类似Flash Attention中的在线Softmax计算方式。

所以,结合上述细节和上图,我们可以讨论整个过程的数据流转。

在Attention过程中,FP16的输入图片右乘变换后,经过RMSNorm归一化,量化到INT4形式,并与左乘变换并量化后的权重做INT乘法运算,随后再反量化回FP16,其中图片经过位置编码(RoPE)计算,而图片经过变换并量化保存为cache,且在做MHA时反量化并变换回来,最后到输出Linear时再经变换和量化,与已变换并量化的权重相乘,最终再反量化为FP16输出图片

在FFN过程中,FP16的输入图片右乘变换后,经过RMSNorm归一化,量化到INT4形式,分别与左乘变换并量化后的上采样权重和门控权重做INT乘法运算,并反量化回FP16,做点乘;最后经变换和量化到INT4,与变换并量化后的下采样权重做乘法,最终再反量化为FP16输出图片

表现性能

在对权重、激励和缓存的全4-bits量化效果对比中,QuaRot相对于SmoothQuant, QmniQuant和QuIK,在Llama模型上有性能优势;且应用了group-wise后,对比Atom也有性能优势。 

图片

QuaRot与其他量化算法的性能比较(图片来源:参考文献[7])

SpinQuant

SpinQuant也是一种在利用正交旋转矩阵减少异常值的思路上的量化方法。该量化方案也是一个全量化方案,其量化对象也是所有的权重,激励和KV缓存。

该方案中,作者分析了不同随机矩阵变换下,多次量化效果的稳定性。用普通随机矩阵做旋转变换的量化过程的量化效果,最好与最差之间相比差距多大13个点,而随机 Hadamard 矩阵优于随机旋转矩阵,但也仍有6个点的不可忽略的方差。而作者提出的Cayley优化矩阵,如下图对比,则能将最终量化性能的方差明显缩小。 

图片

Llama2-7B 在不同随机旋转矩阵量化到W4A4模型的性能分布。不同随机旋转矩阵(普通随机,Hadamard和Cayley优化矩阵)之间的方差(图片来源:参考文献[8])

插入旋转矩阵

作者提出了针对不同复杂度而定制两种旋转策略。

下图是在完整的transformer block中插入不同旋转矩阵的概图,有四类旋转矩阵:图片,根据是否能合并,分为两类,

  • 图片 2个可合并的旋转矩阵:产生旋转不变的全精度网络。

  • 图片 2个在线的Hadamard旋转矩阵:进一步减少极端activation, kv-cache量化的异常值。

由此,作者提出了两种量化方案

  • SpinQuant(NoHad): 仅使用了离线旋转矩阵图片

  • SpinQuant(Had): 使用了图片

图片

SpinQuant整体的旋转变换(图片来源:参考文献[8])

R1R2

SpinQuant旋转矩阵的插入和应用与QuaRot大同小异。

由上图(a)(b)可知,图片和QuaRot中的1a一样,作用于每个Attention和FFN的输入处的激励矩阵,即Attention的Q、K、V Linear输入和FFN的上采样、门控Linear输入;具体到模块内部,其补偿矩阵图片$会被吸收到各种的权重矩阵中。

图片则是Head-wise地将注意力机制的输出乘以图片, 随后在输出output的投影矩阵图片乘以图片。这一操作类比于QuaRot中的1b,其旋转的计算一致性如下,

图片

R3R4

类似于QuaRot中的1c,在注意力机制中插入了额外的在线Hadamard变换(图片),以及在FFN的降采样Linear之前插入了在线Hadamard变换(图片),其旋转的计算一致性如下:

图片

注意力机制中value矩阵的旋转变换(图片来源:参考文献[8])

图片

FFN中下采样输入的变换(图片来源:参考文献[8])

Cayley优化旋转矩阵

本方案的一个主要贡献,是基于上述随机矩阵的方差分析,对旋转矩阵进一步做了基于最小化量化网络误差的优化。优化目标是上述的可合并的图片 ,而在线旋转图片依旧使用了Hadamard随机矩阵,这也是两种方案命名为NoHad和Had的原因。

基于图片优化过程的损失函数如下,

图片

这里,

  • 图片表示 Stiefel 流形,是正交矩阵的集合,{图片}。

  • 图片是基于校准集的比较量化前后的任务损失,可以是交叉熵,是一个关于{图片}的函数。图片 和图片分别是权重矩阵和输入激励矩阵。

为了优化上述函数,作者采用了一种叫Cayley SGD的梯度方法,这是一种Stiefel流形上的高效算法。其本质是一个迭代更新的优化过程, 在每次迭代中,旋转矩阵图片基于梯度更新, 

图片

其中定义 图片,是对矩阵图片的Cayley变换,图片是斜对称矩阵(图片)。 而图片由上述的损失函数的梯度 图片 的一个投影计算得到,

图片

通过矩阵图片的正交属性,推出Cayley变化得到的矩阵 图片的正交属性,从而保证更新后的旋转矩阵图片的正交属性。通过上述的梯度计算的迭代过程,可以求解优化图片 ,在这个过程中transformer的权重参数保持不变。

在具体实践中,作者基于WikiText2数据集作为校验集,用其中800个样本作前向推导,使用迭代更新次数为100次的Cayley SGD梯度优化结果作为新的旋转矩阵{图片},并在上述的随机矩阵量化结果分析中取得了最小方差和最优效果。 

表现性能

在量化性能上,基于Llama2系列与SmoothQuant、OmniQuant等方案作了比较,也与weight-only的算法GPTQ, AWQ, QuIP等做了比较,有更低的PPL(困惑度)和更好的准确度。

图片

量化效果对比(验证集:基于8个0-shot推理任务的平均准确度和基于WikiText2的PPL; 测试模型:Llama2)(图片来源:参考文献[8])

而且作者也对比了基于优化Cayley旋转矩阵和随机Hadamard矩阵的相同量化方案下的量化效果,体现了控制变量下的优化效果。 

图片

Llama3.2, Llama-3, Mistral等在8个0-shot任务下,Hadamard与Cayley优化矩阵的效果对比(图片来源:参考文献[8])

QQQ

QQQ(Quality Quattuor-Bit Quantization)是来自meituan的一个缝合了多种量化手段的方案。它吸收了自适应smooth技巧和Hessian-based权重量化算法,并重写了整型乘法的高效算子库,是一个针对weight和activation全量化的two-stage的量化算法。

图片

QQQ算法的二阶段量化流程(图片来源:参考文献[9])

量化过程

自适应平滑

通过通道维度的缩放操作,使得激励矩阵的异常值变得平滑,从而降低量化难度,这是启发自smoothquant算法,为求最优的平滑系数,构建了如下最小化量化前后输出误差的优化函数,

图片

图片 为element-wise的除法和乘法。

权重量化

基于Hessian的逐列的权重量化算法,则是借鉴自GPTQ,

图片

图片表示图片图片行, 图片表示Hessian矩阵。

W4A8

为支持和加速不同比特位的整型乘法,重写了W4A8的GEMM算子,融合了整型转换和反量化的过程,如下图,包含了per-channel和per-group 两种方案。

  • Per-channel: INT4的权重图片先通过精度转换变成INT8格式,再与INT8的激励图片 做GEMM, 最后反量化为FP16精度。

  • Per-group:  INT4的权重图片首先通过精度快速转换为FP16, 再加载group量化参数将权重反量化,随后再精度转换为INT8, 与INT8的激励做GEMM, 最后再反量化为FP16精度。

图片

W4A8的 per-channel权重量化模式(图片来源:参考文献[9])

图片

W4A8的 per-group权重量化模式(图片来源:参考文献[9])

表现性能

在量化效果上,以Llama2为例,基于Wikitext2的PPL和多个测试集的0-shot准确率和同等量化QoQ效果相当,而在PPL上与weight-only算法相比似乎稍有不足。 

图片

QQQ与其他算法的量化效果PPL对比(图片来源:参考文献[9])

图片

QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[9])

而在推理性能上,通过基于高性能算子库Marlin重写了GEMM并集成到推理引擎vLLM上,

在Llama2-13B上相比于FP16,SmoothQuant和AWQ,有着2.24×, 2.10×, 1.59×的速度优势。

图片

QQQ量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[9])

QoQ

QoQ(Quattuor-Oct ̄o-Quattuor)是来自HanSong团队的W4A8KV4的低精度全量化方案。QoQ算法及其相关的Qserve推理系统,集成了包括量化过程和算子优化,与其说是量化算法,不如说是一套完整的端到端的模型轻量化推理引擎。 

作者在量化比特的选择上对比了Weight-only的W4A16方案和per-channel weight量化和per-token激励在线量化结合的W8A8方案。

  • 对于批处理较小的情况,LLM的GEMM主要是内存受限,W4A16有着更高的吞吐。

  • 当批次变大时,GEMM就变成了计算受限,由于INT8 Tensor Core具有更高吞吐量而使W8A8显得更快。

  • 而作者认为W4A8能兼顾两者,在内存密度和计算密度的场景下都能保持较高的吞吐。

  • 而在解码阶段,由于token的逐个生成特性,Attention的计算密度相对较低,因而KVcache的量化有助于解决内存密度问题,对KVCache选择W4,相比与W8能获得更高的性能。

  • 而对于更激进的W4A4,一方面由于准确性下降,另一方面也由于W4A4 GEMM在当前GPU架构(Ampere, Hopper)上并没有太显著的提升。

量化过程

QoQ的量化过程是众多量化算法中的技巧融合,通过不同手段来减小量化误差。 

渐进分组量化

图片

QoQ的渐进分组量化 (图片来源:参考文献[10])

渐进分组量化(Progressive Group Quantization)指的是对weight 先进行per-channel的INT8量化,再进行per-group的INT4量化。 

给定权重图片,先用per-channel对称量化至INT8形式,

图片

表示量化后得到的INT8的权重,图片是所使用的量化参数scale。

然后,对上述量化结果再使用per-grouup非对称量化至INT4形式,

图片

图片表示最终的无符号4-bit量化权重, 图片分别是对应的量化参数zero-point和scale。

当推理的前向过程计算W4A8 GEMM时,INT4的权重图片 被加载后,先反量化为INT8的权重图片,再执行W8A8的矩阵乘法。

另外,实际中,为了保护INT8反量化时饱和溢出,将INT8对称量化范围从[-127, 127]缩小到[-119, 119]。

平滑注意力

平滑注意力(SmoothAttention)借鉴了SmoothQuant中依靠通道缩放转移激励量化难度的思路,主要针对Key矩阵异常值较多且难量化的问题。

下图可视化了Value矩阵和经过RoPE的Key矩阵的元素值分布,可见Value矩阵的值较为平滑,而Key矩阵中有明显通道固定的异常值。 

图片

RoPE输出Key矩阵经smooth前后的异常值变化,以及Value矩阵的异常值变化(图片来源:参考文献[10])

借鉴SmoothQuant, 通过per-channel缩放因子缓解Key矩阵中的异常值范围,

图片

图片可以通过激励矩阵简单计算,图片, 而缩放强度超参图片可以取经验值0.5。由上图可见,通过平滑后Key矩阵的异常值得到明显缓解。

而实际中,通常缩放矩阵图片的补偿矩阵会融合到前一层的权重中去,而LLM中Attention的Query和Key通常会经过RoPE处理。RoPE在每个Head中将通道图片与通道图片配对。因此为了使SmoothQuant在RoPE中可交换,作者附加了一个硬约束条件,令图片,即

图片

图片

Qserve中的平滑缩放优化(图片来源:参考文献[10])

这样则可以通过图片 和 图片 将缩放矩阵的补偿矩阵融合到Query和Key的Linear层权重中去了。

旋转矩阵

同样,借鉴自QuaRot,QuIP等,使用Hadamard矩阵做旋转变换,来抑制输入激励矩阵的异常值。

图片

Qserve中的旋转矩阵优化(图片来源:参考文献[10])

通道重排

另外,参考AWQ, GPTQ等,提出了基于激励的通道重排序,使用激励矩阵逐通道的最大|图片|值,来表征通道显著性,重新排序通道,使得具有相似显著性的通道在同一个量化组中。

图片

Qserve中的通道重排优化(图片来源:参考文献[10])

Qserve吞吐优化

在通过各种量化技巧融合实现了W4A8KV4的量化流程后,为了保障其推理吞吐性能,作者又设计了一个Serving系统,命名为Qserve,将量化过程融合,设计GEMM kernel,  相当于一个高效的推理引擎。

下图是Qserve runtime示意图,其中所有的GEMM层都使用了W4A8输入并在INT8的TensorCore上执行,输出FP16格式,所有的Attention和LayerNorm都以FP16计算,且整体的LLM模块的输入输出都是FP16格式。 

图片

Qserve runtime推理流程中的精度变化(图片来源:参考文献[10])

  • 算子融合

    对于QKV投影和FFN第一个Linear,激励量化被融合到前面的 LayerNorm 中;FFN层第二个Linear的激励量化,则融合到前面的激活 Kernel 中。

  • KV-cache管理

    参考了vLLM、TensorRT-LLM等的PagedAttention模式,相比这些搜索引擎,Qserve采用了逐Head的动态管理模式,因为其需要存放量化参数,以及动态更新。

  • W4A8 GEMM

    GEMM是计算的主要开销,Qserve通过对权重重排、Per-channel反量化、Per-Group反量化等做了深度优化。

  • KV4** 缓存

    结合KV的量化和反量化优化整体的Attention流程耗时。

表现性能

在量化模型的PPL指标上,基于Llama,Mistral等模型,作者对比了很多量化算法,在同等量化条件下,QoQ有一定的优势,和QuaRot相当,而相比于Weight-only算法稍有不如;而在0-shot的准确率上优于Atom和QuaRot算法。 

图片

QoQ与其他算法的量化效果PPL对比(图片来源:参考文献[10])

图片

QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[10])

在推理吞吐上,得益于其对Pipeline的深度优化,Qserve甚至表现得优于TRT-LLM这样的专业推理引擎。

图片

Qserve量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[10])

FP8

FP8是以8-bit位表示的一种低精度浮点格式,Nvidia 的GPU从Hopper架构的显卡开始支持FP8的训练和推理格式。FP8有2种格式,以下是与FP16和BF16的数据格式对比,

图片

FP8两种格式与FP16及BF16的比特位表示对比(图片来源:参考文献[13])

  • E4M3:包含1个符号位,4个指数位(exponent) 和 3个尾数位(mantissa),可以表示[-448, 448] 范围的数值和nan.

  • E5M2:包含1个符号位,5个指数位(exponent) 和 2个尾数位(mantissa). 可以表示[-57344, 57344],正负无穷(inf)和 nan. 

图片

FP8两种格式的具体表达范围(图片来源:参考文献[11])

符号位占一位,表示正负。

指数部分在浮点表示法当中,一般会减去一个偏移量,对于FP8 E4M3 而言,这个偏移量为-7,这使得指数的表示范围为[-7, 8]。对于 FP8 E5M2 而言,指数偏移量为 -15,指数表示范围为[-15, 16]。

底数从高位到低位,分别表示2的负k次幂;对于E4M3格式,使用3个比特表示底数,其分别对应2的负1, 2, 3次幂。对于E5M2格式,使用2个比特表示底数,分别对应2的负1, 2幂。底数表示时会额外加1,而当指数部分全为0时,则不额外加1。

浮点量化

相比于整型量化,浮点的量化属于非均匀量化,即浮点量化的步长是不固定的,由下图可知,相比于FP32到INT8的映射,浮点量化的步长随着指数部分的变大而变大。

图片

FP8与INT8量化的量化步长对比(图片来源:参考文献[12])

量化精度

FP8与INT8量化孰优孰劣,只能说各有长短。FP8 相比INT8,有更大的表示范围,但在一定范围内,其精度表达能力相较INT8为差。如下图,从高斯分布随机抽样1000万个数字,分别使用 FP8 E4M3, FP8 E5M2, INT8 完成量化。在三者的量化中,应用不同的缩放参数来调整量化效果,画出量化误差(用MSE表示)。可以看到,FP8之间,E4M3的量化效果要好于E5M2, 而在选取合适的量化参数范围内,INT8的量化效果要好于FP8,而FP8具有更好的兼容性,对缩放参数的选择相对不敏感,更适合不需要校验集的量化。

图片

FP8与INT8在不同量化参数下,对正态分布数据量化的精度损失对比(图片来源:参考文献[12])

总结

下面是对以上介绍的一些大模型量化方案的简要总结和对比,

量化算法名称 量化对象 特点和适用范围
GTPQ 权重 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟
AWQ 权重 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟
HQQ 权重 离线量化,支持1~8-bit,量化速度在所有算法中最快,量化精度与GTPQ,AWQ相当
SmoothQuant 权重、激励 在线量化,支持8-bit,量化速度较快,支持模型较多,比较成熟,推理吞吐较快
QuIP 权重 离线量化,支持2~4bit,量化速度较快,低精度(2-bit)下效果优于GPTQ
QuaRot   权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant 
SpinQuant 权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant,GPTQ,量化速度较快,但略慢于GPTQ
QQQ 权重、激励 在线量化,支持4-bit, 8-bit,推理吞吐较快
QoQ 权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,推理吞吐较快
FP8 权重、激励、KV缓存 在线量化,支持FP8精度,依赖较新GPU,推理吞吐较快

综上,文章简要介绍了近期一些LLM后量化的算法和方案,当然还有众多算法未涉及和细讲,如SpQR,ZeroQuant, KIVI**,Atom, OmniQuant,AQLM等。

参考文献

  1. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
  2. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration
  3. AWQ slides: hanlab.mit.edu/projects/aw…
  4. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models
  5. QuIP: 2-Bit Quantization of Large Language Models With Guarantees
  6. HQQ: Half-Quadratic Quantization of Large Machine Learning Models.
  7. QuaRot: Outlier-Free 4-Bit Inference in Rotated LLMs
  8. SpinQuant: LLM quantization with learned rotations
  9. QQQ: Quality Quattuor-Bit Quantization for Large Language Models
  10. QServe: W4A8KV4 Quantization and System Co-design for Efficient LLM Serving
  11. Nvidia: FP8 Formats for Deep Learning
  12. FP8量化原理简介:zhuanlan.zhihu.com/p/574825662
  13. Nvidia Transformer Engine: Using FP8 with Transformer Engine 

往期回顾

1.如何合理规划Elasticsearch的索引|得物技术

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

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

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

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

文 / 旭囧

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

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

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

得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

一、摘 要

在前端可观测分析场景中,需要实时观测并处理多地、多环境的运行情况,以保障 Web 应用和移动端的可用性与性能。传统方案往往依赖代理 Agent → 消息队列 → 流计算引擎 → OLAP 存储的多级架构,虽然能满足基本数据接入与查询需求,但面临以下困难与挑战:

分层架构的精细化演进:

当前分层模型在支撑多业务场景时,需要为分钟级、小时级、天级等不同时间粒度的数据视图分别构建计算链路。这种模式在保障灵活性的同时,可能存在存储与计算资源的重复消耗现象,如何通过增量计算或动态视图技术实现"一次计算多级复用",或将成为提升资源利用效率的关键突破点。

复杂分析场景的效能平衡:

在应对全量 Join、多维交叉分析等高复杂度场景时,现有的按量计费机制与计算资源调度策略,可能在高频业务周期(如大促活动)中面临成本曲线的非线性增长挑战。探索预计算加速、智能冷热分层与弹性资源调度的深度结合,或许能进一步释放大规模分析场景的性价比潜力。

生产环境的安全加固:

基于 SQL 的敏捷开发模式在提升数据处理效率的同时,也对企业级数据资产管理提出了更高要求。通过强化语法预校验、分区保护机制、操作审计追溯等防护手段,构建覆盖开发、测试、发布全流程的可靠性护城河,将有效降低误操作风险并提升数据治理成熟度。

本文聚焦将前端可观测后端数据分析场景演进到 GreptimeDB 的实践,深入剖析如何利用 GreptimeDB Flow 引擎实现 10s、1m、10m 等多粒度持续聚合,结合HyperLogLog 与 UDDsketch 函数,为前端可观测场景提供高性能、低成本且易于运维的端到端解决方案。

二、部署架构

图片

为解决前述痛点,逐步将时序/观测数据场景迁移至 GreptimeDB,并借助其内置的Flow引擎(SQL)自动维护秒级、分钟级、小时级等多精度下采样表,可极大简化分层建模和物化视图运维。

GreptimeDB分布式架构

采用 GreptimeDB 开源的分布式模式,在这种模式下,GreptimeDB 的节点可以分为如下角色:

图片

  • Frontend:负责协议处理、请求校验和鉴权、初步查询优化,是一个无状态节点,可以根据负载任意扩缩容。

  • Datanode:负责管理数据分片、处理数据写入和持久化以及执行具体的查询。

  • Flownode:对于配置了流计算任务的集群,Flownode 负责接受 Frontend 镜像而来的写入请求并执行流计算任务。流计算的结果最终会被写入到 Datanode 中进行持久化。

  • Metasrv:GreptimeDB 的控制面组件,负责管理集群的元数据(如表的分片路由信息等)。Metasrv 本身是无状态的,这里我们采用 PostgreSQL 作为后端存储。

透明数据缓存

GreptimeDB 对数据访问层进行了高度的抽象,负责管理数据分片的 Datanode 并不需要感知到数据文件位于本地磁盘还是对象存储。但是考虑到当使用对象存储时数据文件的访问延迟会大大增加,因此 GreptimeDB 设计了多层的透明数据缓存来解决此问题。

图片 (GreptimeDB 的缓存结构)

GreptimeDB 的缓存结构如上图所示。从缓存所在位置可以分为磁盘缓存和内存缓存两类:

  • 磁盘缓存的数据来源通常是对象存储,其类似于操作系统的 page cache,只不过 page cache 是利用内存加速磁盘数据的访问,而 GreptimeDB 的这部分缓存则是利用磁盘加速对象存储的访问,将频繁访问的文件按范围缓存到磁盘可以实现更低的查询延迟,并且能够智能根据访问模式实现预取(prefetch)、IO 合并等优化。

  • 内存缓存除了原始的文件内容之外也包括从磁盘/对象存储的原始内容反序列化出来的数据结构,如字段的 min/max,bloomfilter 等等。

而从数据类型来分可以分为结构化和非结构化两类:

  • 非结构化缓存的内容通常是文件的二进制内容,而缓存的 key 则是文件名加上字节范围。比如在 flush 的过程中写入到对象存储的文件往往是大概率很快就会被查询的热数据,因此可以在本地缓存一份避免查询请求穿透到对象存储。

  • 结构化缓存则是文件、索引的内容或元数据反序列化得到的结构体,这些数据在查询剪枝时频繁被用到,因此  GreptimeDB 缓存了反序列化之后的结构,避免频繁反序列化带来的开销。

尽管 GreptimeDB 的缓存机制较为复杂,但是用户无需过多了解细节,只需要给定特定的缓存大小,GreptimeDB 会自动分配各类缓存的配额以及管理缓存的分配和释放,具体调优指南请参考附录[1]。

无畏扩缩容

GreptimeDB 的最小数据读写单元是表的数据分片(称之为 region)。Region 可以在不同的节点之间进行迁移。目前开源版本的 GreptimeDB 支持手动通过 migrate_region函数进行 region 的迁移(详见附录[3])。当监控发现某些 datanode 的负载较高时,可以将部分 region 迁移到其他较为空闲的 datanode 上避免可用性的降级。

图片

此外,GreptimeDB 是面向云原生基础设施设计的数据库,其 Metasrv 节点能够实时采集各个节点的负载并且将流量在不同节点之间进行分配。对于不同的负载读写特性,还可以利用 Kubernetes 的弹性调度特性来调整不同节点组的副本数量来实现读写分离。关于 GreptimeDB 读写分离的实践,可以参考附录[2]。

三、GreptimeDB Flow 流计算实践

GreptimeDB Flow 是一个专为时序场景设计的轻量级流计算引擎。 它特别适用于提取 - 转换 - 加载 (ETL) 过程或执行即时的过滤、计算和查询,例如求和、平均值和其他聚合。通过在 Frontend 将写入流量镜像一份到 Flownode 进行计算再写回 Frontend 并进行持久化,它可以确保数据被增量和连续地处理,根据到达的新的流数据更新最终结果。

图片

更加重要的是,编写一个 Flow 流计算任务无需额外的学习成本,它完全使用 SQL 语句定义计算任务。如以下语句:

图片

定义了一个名叫 ngx_status_count的任务,它负责流式地统计 ngx_access_log表中每分钟内每个不同状态码的访问日志数量。在AS之后的任务定义部分是一个标准的 SQL,因此对于熟悉 SQL 的开发者来说极容易上手。

多级持续聚合架构

10s 粒度热数据层

CREATE FLOW rpc_cost_10sSINK TO rpc_cost_10s_aggEXPIRE AFTER '12hours'::INTERVALAS SELECT  app_name,  url,  date_bin('10s'::INTERVALtimestampAS time_window,  uddsketch(cost_time_ms, 0.010.001AS cost_sketchFROM rpc_cost_timeGROUP BY app_name, url, date_bin('10s'::INTERVALtimestamp);

说明:每 10s 计算一次 UDDsketch,近似捕获耗时分布,并写入“热表”,支持毫秒级查询。

1m 粒度中层 Roll‑up

CREATE FLOW rpc_cost_1mSINK TO rpc_cost_1m_aggEXPIRE AFTER '30days'::INTERVALAS SELECT  app_name,  url,  date_bin('1m'::INTERVAL, time_window) AS time_window_1m,  uddsketch_merge(cost_sketch) AS cost_sketch_1mFROM rpc_cost_10s_aggGROUP BY app_name, url, date_bin('1m'::INTERVAL, time_window);

说明:周期性合并 10s 粒度的 sketch,生成分钟级聚合,保留 30 天。

10m 粒度冷层

CREATE FLOW rpc_cost_10mSINK TO rpc_cost_10m_aggEXPIRE AFTER '180days'::INTERVALAS SELECT  app_name,  url,  date_bin('10m'::INTERVAL, time_window_1m) AS time_window_10m,  uddsketch_merge(cost_sketch_1m) AS cost_sketch_10mFROM rpc_cost_1m_aggGROUP BY app_name, url, date_bin('10m'::INTERVAL, time_window_1m);

说明:进一步合并至 10 分钟级,存入低成本对象存储,保留 180 天。

UV 近似统计:HyperLogLog

和耗时分布统计类似,统计各个 URL 的独立访问量(UV)也是常见的需求。不过想要精确统计特定时间段的 UV 成本是极高的,因此业界往往使用近似算法来实现 UV 计算,如 HyperLogLog。GreptimeDB v0.12 提供了对 HyperLogLog 相关函数的支持,结合 Flow 可以实现强大的任意时间段 UV 近似统计。

图片

10s UV 状态

CREATE FLOW uv_hll_10sSINK TO uv_state_10sEXPIRE AFTER '12hours'::INTERVALAS SELECT  app_name,  url,  date_bin('10s'::INTERVAL, ts) AS time_window,  hll(user_id) AS uv_stateFROM access_logGROUP BY app_name, url, date_bin('10s'::INTERVAL, ts);
  • hll 函数:  Flow 任务中我们通过 hll 函数将同一时间窗口内的 user_id 进行散列并写入到 uv_state_10s 的 uv_state字段中。

  • uv_state BINARY 类型: 是一个二进制字段(BINARY 类型),无法直接进行查询。如果要查询某个10 秒的时间窗口内的独立访问用户量,需要通过 hll_count函数来进行查询。

SELECT     `app_name`,    `url`,    hll_count(`uv_state`as uv_countFROM uv_state_10sWHERE time_window = 1743479260;

1m UV 聚合

  • 如果用户需要进一步将 10 秒的访问数据聚合到 1 分钟或者直接需要查询特定时间段内的用户访问数量,则可以通过hll_merge 函数来对二进制的 HyperLogLog 状态进行合并。
CREATE FLOW uv_hll_1mSINK TO uv_state_1mEXPIRE AFTER '180days'::INTERVALAS SELECT  app_name,  url,  date_bin('1m'::INTERVAL, time_window) AS time_window_1m,  hll_merge(uv_state) AS uv_stateFROM uv_state_10sGROUP BY app_name, url, date_bin('1m'::INTERVAL, time_window);
  • 查询示例:
SELECT  app_name,  url,  hll_count(uv_state) AS uv_countFROM uv_state_1mWHERE time_window_1m = '2025-04-20T15:23:00Z';GROUP BY app_name, url;

效果与收益

  • 查询性能显著提升:

预聚合 + 多级 Roll‑up,避免全量扫描,P99 查询延迟从秒级降至毫秒级。

图片

  • 存储与成本可控:

不同粒度数据设置差异化 TTL:10s 热表保留 1 天,1m 中表保留 7 天,10m 冷表保留 180 天,冷热分离降低存储成本。

  • 资源解偶 & 弹性扩缩容:

Frontend、Flownode、Datanode 独立伸缩,流计算、存储、查询三者互不干扰。

  • 开发效率提升:

Flow 编写使用标准 SQL,上手难度低,Roll‑up、HyperLogLog、UDDsketch 等内置函数无需额外学习曲线。

四、最佳实践与落地建议

  • 合理划分数据分层:根据监控场景与 SLA 要求确定不同粒度保留策略。

  • 调整 sketch 精度:UDDsketch 支持自定义误差范围(α、β 参数),可根据业务侧对 P50/P99 精度要求调优。

  • 监控与告警:为各级聚合任务配置失败重试与告警机制,确保持续计算的稳定性。

  • 资源规划:根据写入 QPS 与聚合复杂度合理预估 Flownode 与 Datanode 数量,结合对象存储带宽设计分区策略。

参考文档:

1.**  [性能调优技巧 | GreptimeDB Documentation | Unified Time-Series Database]

2.** 【使用指南】在 Kubernetes 上部署读写分离的 GreptimeDB 集群

3.Region Migration | GreptimeDB Documentation | Unified Time-Series Database

往期回顾

1. 得物新一代可观测性架构:海量数据下的存算分离设计与实践

2. RUST练习生如何在生产环境构建万亿流量|得物技术

3. 得物业务参数配置中心架构综述

4. 得物增长兑换商城的构架演进

5. 得物自研DGraph4.0推荐核心引擎升级之路

文 / 南风

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

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

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

得物业务参数配置中心架构综述

一、背景

现状与痛点

在目前互联网飞速发展的今天,企业对用人的要求越来越高,尤其是后端的开发同学大部分精力都要投入在对复杂需求的处理,以及代码架构,稳定性的工作中,在对比下,简单且重复的CRUD就显得更加浪费开发资源。目前scm供应链管理页面中,存在约77%的标准页面,这些标准页面里,还存在着很多类似的参数配置页面,就是对某一个模型进行增、删、改、查、导入、导出进行类似的操作,这种开发工作技术含量较低,而且相对耗费人力。

什么是业务参数配置中心

参数配置中心,是一个能够通过配置的方式,快速生成前端页面以及配套增、删、改、查、导入、导出服务的配置平台,它与得物内部低代码前端页面平台wizard相互集成,参数配置中心提供后台增删改查服务,wizard输出对应的前端页面代码,并可以支持用户自定义修改。

使用场景

  • 针对读多写少的简单的单表的增删改查;
  • 业务中需要交给运营来修改的复杂ark配置(简单配置除外),可以尝试使用业务参数配置中心接入,减少人为修改JSON可能产生的错误,导致系统无法编译进而产生故障。

  

比如如下的JSON:

[{"position":"1","red":2.49,"blue":2.4,"green":1},{"position":"2","red":2.49,"blue":2.4,"green":1},{"position":"3","red":2.49,"blue":2.4,"green":1},{"position":"4","red":2.49,"blue":2.4,"green":1},{"position":"5","red":2.49,"blue":2.4,"green":1},{"position":"6","red":2.49,"blue":2.4,"green":1},{"position":"7","red":2.49,"blue":2.4,"green":1},{"position":"8","red":2.49,"blue":2.4,"green":1}]
业务参数配置中心极速体验
  1. 后台服务搭建流程,以及数据录入

  2. 数据读取可以通过参数配置中心的SDK,输入自己的业务入参以及自己的业务出参,SDK会自动根据方案下的参数以及用户的输入条件,查询出对应的参数信息:

图片

从上面的快速体验里可以看到很多名词,你一定有会有下面的疑问:

图片

二、整体架构与原理

实现思路

首先我们对这种普通的页面进行初步剖析:页面中总体包含搜索条件、静态展示字段以及操作栏,搜索条件一般是静态字段的子集,并且操作栏的功能一般都类似,所以为了能够结构化地构造出这样的页面,我们可以将静态展示字段进行进一步抽象:比如元素、维度、参数、方案、参数实例。

图片

元素

构成页面的每一个业务字段,统称元素,因为有些字段是大家常用的(比如仓库,品牌,一级类目,省份等),它有自己的字段名称,以及取值范围。

维度

一条记录一定有能够标注其唯一性的信息,可能是一个字段或者是多个字段,在参数中心里,能确定一条记录唯一性的所有字段就叫做维度,维度这个概念在参数中心里很重要,它是不可变的。

参数

在业务发展过程里,可以改变值的字段,就叫参数,也可以说一条记录里,除了维度,都可以叫做参数。

综合维度和参数,举个例子,比如商品信息,商品ID就是维度,商品售价、折扣率就是参数。或者医院挂号系统,科室ID就是维度,挂号费,出诊时间就是参数。

方案

一个参数方案它管理着一个场景下的业务配置,可以简单理解一个方案就代表着一个页面,包含了上述我们说的维度以及参数,并且指定了可以指定哪些字段为搜索条件,哪些是必填字段,哪些字段可以多选。

参数实例

描述好方案并生成页面后,实际产生的业务配置数据,我们称之为参数实例。

经过刚才对页面元素的解剖,大家会发现搭建一个这样的页面,犹如建房子一样,维度与参数是最基础的木料,创建方案就是设计建造的过程,参数实例就是一个个真实的房间,所以业务参数配置中心整体产品思路如下:

图片

整体架构

通过上文的介绍,我们介绍了业务参数配置中心最核心的概念,接下来我们看看整体的架构设计。我们针对这些最核心的概念,来设计实现这些业务功能的架构、核心包含领域模型、领域服务、应用服务以及基础设施层需要的存储部件,以及外部可以整合的导入导出框架、日志框架(外部依赖的框架也可以自己实现)、核心的元素维护、方案维护,存储设计好之后,我们就需要一个SDK,可以让用户访问到我们的数据。

图片

系统的实体关系图如下:

图片

通过上文我们可以初步了解到整体的架构设计,那么每一个子模块我们如何实现?接下来我们分析更加细节的原理。

核心原理

如何设计存储的细节是这个系统的一大挑战,因为既要兼顾页面的灵活变动,也要兼顾数据整体的一致性不受影响,同时也要兼顾整体数据的查询性能,下面的小节列出了所有这些核心的挑战点。

存储流程

每一个页面的字段都不一样,我们是怎么存储的?

图片图片

从上面的两个页面可以看到,因为页面的字段变化多端,所以我们的思考是,必须采用抽象存储的方式来应对,核心用一张 大宽表存储,其中包含很多抽象列,每一个抽象列在不同的方案下,业务含义不同。

同时把方案的元数据:维度、参数、以及功能性设置(如每个字段是否可以删除,是否需要多选)单独存储,每个方案下的大宽表里的抽象列的业务含义,就存储在这些元数据表中。

同时为了应对大批量的查询,我们引入了OLAP的数据库,对于在应用内部的单点查询,我们走MySQL实现,如果运营后台针对某个字段做大批量查询,则可以用OLAP数据库来缓解查询压力。

下面是存储的整个过程以及举例:

图片

SDK查询流程

因为在业务参数使用时,各个业务方有自己的业务对象,所以我们在SDK中集成了反射的能力,可以避免用户直接感知到底层的抽象存储,查询的流程使用上比较简单,一共分为三步,第一步为自定义request,第二步自定义response,第三步调用SDK方法获取参数实例,比如:

  1. 定义request:
@Data

public class PinkDeviceCameraConfigRequest  implements Serializable  {
  

    */***

     * 配置类型

     */

    private String configType;

    */***

     * 设备编号

     */

    private String deviceNo;


}

2. 定义response

@Data

public class PinkDeviceCameraConfigResponse implements Serializable {

  

    */***

     * 配置类型

     */

    private String configType;

    */***

     * 设备编号

     */

    private String deviceNo;

  

        */***

     * 配置明细

     */

    private List<CameraConfigDto> configValueList;

  

        @Data

    public static class CameraConfigDto implements Serializable {

        private String position;

        */***

         * 白平衡(Red)

         */

        private BigDecimal red;

        */***

         * 白平衡(Blue)

         */

        private BigDecimal blue;

        */***

         * 白平衡(Green)

         */

        private BigDecimal green;

        */***

         * 亮度(Brightness)

         */

        private BigDecimal brightness;

        */***

         * 自动曝光时间上限(us)

         */

        private BigDecimal autoExposureTimeUpperLimit;

        */***

         * 采集帧率

         */

        private BigDecimal acquisitionFrameRate;

        */***

         * 增益自动开关(us)

         */

        private String gainAuto;

        */***

         * 增益自动上限

         */

        private BigDecimal gainAutoUpperLimit;

        */***

         * 增益自动上限

         */

        private BigDecimal gainAutoLowerLimit;

    }
}

3. 调用SDK的服务方法查询

PinkDeviceCameraConfigRequest pinkDeviceCameraConfigRequest = new PinkDeviceCameraConfigRequest();

pinkDeviceCameraConfigRequest.setConfigType("DEVICE_NO");

pinkDeviceCameraConfigRequest.setDeviceNo("123@LuSun");

  

*//* 单个查询场景

PinkDeviceCameraConfigResponse response = 

    paramInstQueryService.getParams("P80-DEVICE-CAMERA-PARAM-MANAGER",

         pinkDeviceCameraConfigRequest,

         PinkDeviceCameraConfigResponse.class);



*//* 批量查询场景

PageQueryOption pageQueryOption = new PageQueryOption();

pageQueryOption.setPageIndex(1);

pageQueryOption.setPageSize(200);

PageInfo<PinkDeviceCameraConfigResponse> paramsPage = 

    paramInstQueryService.getParamsPage("P80-DEVICE-CAMERA-PARAM-MANAGER", 

        pinkDeviceCameraConfigRequest, 

        PinkDeviceCameraConfigResponse.class,

        pageQueryOption);

4. 获得结果

图片

整体查询实现原理如下:

图片

目前整个服务的性能在10+ms左右:

图片

参数优先级实现

为什么会有参数优先级这个功能?

比如有一个场景,要维护一个供应链系统中的补货参数:安全库存,低于这个安全库存的时候,要通知商家进行补货,整个供应链里有100个仓库,20个一级类目,200个二级类目,2000个三级类目,涉及到500个品牌,要维护每一个商品的安全库存,你会怎么实现?

你一定不会把 100仓库2000类目500品牌 = 1000000000种可能全都设置一遍参数,对你来说,重点类目,要单独详细配置安全库存,非重点类目可能只需要管控到一级或者二级类目即可,这样你所需要的配置会大大减少。那么参数的决策就需要遵循一定的规则,比如:

有仓库+一级类目+二级类目+三级类目 的安全库存,优先取;

如果取不到,则取仓库+一级类目+二级类目的安全库存;

再取不到,取仓库+一级类目的安全库存。

比如:

DN仓 鞋 安全库存 100

DN仓 鞋-运动鞋 安全库存 500

DN仓 鞋-运动鞋-篮球鞋 安全库存 1000

那如果一个商品是篮球鞋的话,则会命中安全库存1000的规则,如果是登山鞋的话,只能命中运动鞋的规则取500,如果是高跟鞋,则只能取100的安全库存。

(事实上这种补货规则要详细的多,这里只是方便大家理解需求,并不是真正的参数)

也就是说,当用户的入参同时可能命中多条参数的时候,需要通过优先级来判断应该返回哪个参数。

图片图片

为了加速查询,系统在设计时添加了两层缓存:

图片

当后台数据发生变化时,会将对应的缓存进行失效。

图片

元素多选处理

维度多选场景:

图片

参数多选场景:

图片

既要保证维度唯一,又要保证能正常搜索,以及展示,如何实现?业务参数配置中心引入了一个“组”的概念,是将同属于一行的参数实例,归为一个组,这个组是最小的新建、编辑单位。

对于新增流程如下图所示:

图片

对于修改流程,如下图所示:

图片

元素范围查询

页面中的字段,我们统称为元素,只要是字段,一定有它的取值范围,我们平衡了用户使用成本以及系统性能,将字段取值类型划分成了四种:

1)枚举类元素

2)dubbo全量接口元素

3)dubbo单点查询接口元素

4)自定义文本元素

  1. 枚举元素由用户手动在页面创建,一般几十个以内为佳,创建成本不高,比如经常用到的 “是”,“否”,或者比如单据类型等等。

  2. dubbo全量接口元素,一般是几十到上百个的体量,比如一级类目,仓库等,地址。

  3. dubbo单点查询接口,一般是几千到几万体量的取值范围,无法直接在内存里存储所有枚举,比如品牌等。只能通过两个接口来完成搜索以及数据的展示,比如“品牌ID >品牌名称”接口 和  “品牌名称->品牌ID” 接口。

  4. 自定义文本,非枚举类字段,可以选择使用自定义文本来承接。

比如以下是可以通过dubbo接口全量获取配置的元素:

图片

与dubbo全量接口的录入类似,单点搜索接口与全量接口不同的点在于,单点接口需要保留一个变量,给系统查询时调用,比如“通过品牌ID 查询品牌名称” 和  “通过品牌名称查询品牌ID” ,需要留给系统调用的入参,用#{var}代替。

图片

当然,有时元素的范围并不是只取决于它自己,可能也取决于同页面里其他元素的取值,比如说有一个质量原因的字段,当一级类目为鞋时 取值为A、B、C,为服装时为 D、E、F,这是元素范围在设置时,就需要将对应的元素入参维护到其中,比如:

接口入参类型 接口入参取值
com.d.s.q.s.d.r.ConfigRequest {"ruleVersion":#{ruleVersion},"spuId":#{spuId}}

导入导出

以下是导入处理流程:

图片

为了照顾使用人员的体验,再多数导入场景时,我们的导入文件都用的是文案,而不是后台存储的数值,比如导入的字段包含类目时,导入文件输入的是鞋、服装、美妆等文案,而不是2、3、4这样存储在后台的数值,那么势必这里就会有将文案转换成数值的过程,这其中就用到了2.3.5章节中提到的元素范围查询使用的接口,当然,对于需要其他元素作为入参的元素,我们默认每个元素左边的元素都可以作为当前元素的入参。

业务参数配置中心不适合做什么?

  1. 有极为复杂的UI交互

  2. 较为复杂的校验逻辑(长期计划支持)

  3. 高频写入场景

  4. 应用查询参数时以非“=”条件匹配

三、总结与展望

本文简要描述了业务参数配置中心的设计思路,参数配置中心配套生成增、删、改、查、导入、导出服务,并且结合前端低代码平台自动生成前端代码,平台目前业务参数中心已经有40+个场景接入节省了大量的工作人日,能够让研发人员,摆脱低效的CRUD,更专注于自己内部业务逻辑的开发。

对于目前系统的未来规划:

  1. 持续增加SDK的查询灵活性:包括不限于批量代参数优先级对数据进行查询、通过SDK分页查询全量参数、对系统字段吐出方便业务方使用;

  2. 持续增加对方案定义的灵活性:支持更多的元素范围的定义,比如HTTP等调用方式;

  3. 持续增加对元数据定义的灵活性:部分元数据的取值可能需要同页面中的另一个元素的取值来决定,所以在取值渲染时,可以保留给其他元素的占位符,进而随着页面的动态变动,后台取值也可以动态变动。

往期回顾

1. 得物增长兑换商城的构架演进

2. 得物自研DGraph4.0推荐核心引擎升级之路

3. 大语言模型的训练后量化算法综述 | 得物技术

4. 如何合理规划Elasticsearch的索引|得物技术

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

文 / sakuta

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

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

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

得物增长兑换商城的构架演进

一、简介

在移动互联网蓬勃发展的今天,用户的选择日益多元化,App市场的竞争也愈发白热化。为了在激烈的市场竞争中脱颖而出,提升用户获取效率并增强用户粘性,越来越多的应用开始采用积分兑换、抽奖等互动玩法。这些精心设计的运营策略不仅能够满足用户的参与感和成就感需求,更能有效促进社交传播,提升品牌忠诚度。通过持续优化和迭代,开发者能够在竞争激烈的市场中占据优势地位,实现用户的可持续增长和长期留存。

基于提升系统复用性和稳定性的考量,我们构建了一个统一的兑换商城中台系统。该中台旨在为各上游业务线提供标准化的积分兑换和奖励机制,使各类应用和服务能够快速接入并享受便捷的服务支持。通过这一中台架构,我们不仅实现了核心业务逻辑的集中化管理与维护,更为后续的功能扩展(如抽奖、彩票等多样化玩法)奠定了坚实基础,从而显著提升了开发效率和用户体验。

二、诞生背景

在用户增长和留存策略的探索中,增长团队先后推出了多种互动类玩法,如每日签到、星愿森林、心愿海洋以及早期的点鞋成金等。这些玩法通过积分或虚拟货币的积累,不仅增强了用户的参与感,也为用户提供了丰富的奖励机制。然而,随着玩法的多样化,如何高效管理这些积分或货币的消耗途径,成为提升用户体验和趣味性的关键。

image.png

面对这一挑战,团队意识到为每个互动玩法单独开发兑换功能不仅需要前后端的全面支持,还需重复搭建复杂的监控和预警系统以防范潜在的资损风险。这种重复开发的模式不仅成本高昂,而且难以保证系统的一致性和稳定性。因此,构建一个统一的积分商城,为各互动玩法提供通用化的兑换系统,成为了提升开发效率和确保系统安全性的必然选择。这样不仅优化了资源配置,也为后续的玩法扩展和用户体验升级奠定了坚实的基础。

在既有兑换业务的基础上,我们也成功沉淀并提炼了多个核心领域模型。后续基于这些通用化的领域模型,我们逐步探索并实现了多种新型玩法的快速落地,包括老虎机、盲盒以及抽奖券等多样化互动形式。

三、业务架构

业务架构图

图片

业务架构概述

业务架构主要分4级:

  • 上层业务

    ▪ 主要是有使用兑换商城功能的上游应用,包括星愿森林、每日签到、心愿海洋、增长新客、社区抽奖、学生答题等应用。

  • 玩法沉淀

    ▪ 主要包括三类玩法,包括积分兑换、老虎机/盲盒,以及抽奖券等。玩法核心所依赖的领域模型基本相似,各个系统都能得到了很好的复用。而在玩法层面,我们主要使用了设计模式中的模板模式,优先搭建好每个玩法的骨架,然后将一些关键步骤交由上游应用自定义实现。

  • 领域模型

    ▪ 活动:对应每个新接入的应用都需要新建一个活动模型,而各类自定义的业务层面的逻辑都是在活动维度上实现的。

    ▪ 模板:模板作为上下关系的重要承接,一个活动能够创建多个模板,而每个模板下又可以关联多个奖品。之所以存在模板这个中间层,是有多个原因的。 

       ▫ 原因1:模板和活动之间的关系,我们可以以星愿森林为例,一个活动他是存在多个兑换专区的,包含了合种专区、超值专区等,这些是需要通过模板维度来划分的。

       ▫ 原因2:模板和奖品之间的关系,奖品需要关联到不同的资产,创建一个奖品的成本较高。而将奖品设计成可以被绑定到多个模板上,奖品这个资产就可以得到一个很好的复用。同时每个模板中该奖品有其独立的库存模块,那么模板之间就不会互相影响了。

       ▫ ...

    ▪ 奖品:后台不同类型的奖品统一都需要在奖品库内创建。这样我们就能在不同的活动和模板中在奖品库内选出该奖品。每次新增新的类型奖品,需要实现该新类型奖品的发奖能力,如优惠券、津贴、现金等都依赖不同的下游应用进行发放。

    ▪ 库存:目前支持分时间段库存、每日库存以及总库存逻辑频控,包含用户及奖品的发放频控、包含每日频次、周期循环频次以及总频次。

  • 下游依赖

    ▪ 主要是兑换商城所依赖的下游应用,包括营销中台、商品聚合中心、提现中台、口令服务、增长配置中心等。

四、玩法迭代

整体玩法总览

本次玩法迭代的介绍主要以积分兑换玩法为主,参考样式如下:

图片

要想兑换具体的商品需要经过一系列流程,下图为兑换的主流程介绍,包含了用户从发起兑换到最终兑换成功或失败的流程。主要为用户选择某个奖品兑换需要处理的一系列业务逻辑。

如上文所说,在玩法层面,主要使用了设计模式中的模板模式,利用自己领域模型的能力,优先搭建好兑换玩法的骨架,比如有效性校验、库存扣减、奖品发放等逻辑,然后将一些关键步骤如前置自定义检查、扣减货币、回滚操作交由上游应用自定义实现。

玩法内置逻辑是由商城内部的领域模型实现,主要包含通用逻辑如下:

  1. 各个模型的有效性校验:确保兑换请求的合法性。

  2. 库存及频控模型的前置校验:检查库存和频控模型,确保兑换请求的可行性。

  3. 库存扣减逻辑:扣减奖品库存,确保库存数据的实时性和准确性。

  4. 奖励模型的发放逻辑:发放用户所兑换的奖品。

  5. 兑换成功后置处理:进行兑换成功的后置处理,如记录日志、更新用户状态等。

即下图灰色模块是由商城自己的玩法模板实现。

需业务方对接逻辑主要包含:

  1. 前置自定义校验:实现特定业务规则的前置校验。

  2. 货币扣减:扣除用户相应的积分或虚拟货币。

  3. 兑换失败后的货币回滚:在兑换失败时,将扣除的积分或虚拟货币返还给用户。

黄色箭头及模块是由商城侧发起,调用上游接入应用,由上游应用处理相关逻辑,然后通知商城成功与否。

图片

RPC调用迭代

1.0版本

在最初的版本中,黄色模块(如前置自定义检查、扣减货币、回滚操作)的实现方式较为繁琐。具体流程如下:

  1. 上游业务方提供protobuf文件:每个新接入的业务方需要提供自己的protobuf文件。

  2. 生成go-grpc的server及client端桩代码:根据protobuf文件生成go-grpc的server和client端桩代码。

  3. 商城服务编写代码调用client端桩代码:在商城服务中编写代码,调用client端桩代码发起RPC调用。

这种实现方式存在以下问题:

  • 开发成本高:每次新接入一个业务,都需要在商城服务和接入方同时编写代码。
  • 维护复杂:随着业务接入数量的增加,代码维护和管理的复杂度显著提升。

图片

2.0版本

为了减少商城侧的开发成本,我们借鉴了Java的SPI(Service Provider Interface)概念,对RPC调用进行了优化。具体实现如下:

  1. 商城统一生成protobuf的server端代码:商城统一生成protobuf的server端代码,作为服务提供者接口。

  2. 活动模块统一配置调用路由:在商城服务的活动模块中,统一配置调用路由,实现服务的动态路由和调用。

  3. 接入方引用protobuf生成的server端桩代码:每次有新业务接入,接入方只需引用对应protobuf生成的server端桩代码,并通过反向注册机制实现服务的注册和调用。

这种实现方式带来了以下优势:

  • 开发成本降低 :商城层无需再为每个新接入的业务编写代码,只需进行应用配置。

  • 维护简化 :通过统一的服务提供者接口和调用路由,代码维护和管理的复杂度显著降低。

  • 扩展性强 :新业务的接入更加便捷,系统的扩展性和灵活性得到提升。

通过这种优化,我们不仅实现了RPC调用的高效复用,还为未来的业务扩展和系统优化奠定了坚实的基础。这种模块化、可配置的设计理念,不仅提升了开发效率,还增强了系统的灵活性和可扩展性。

图片

库存设计迭代

一开始库存只有每日及总库存的概念,实现也比较简单。为了保障库存扣减的并发安全,使用Redis作为库存的存储。具体的库存扣减则通过提前设置好Redis值并不断扣减即可。由于Redis的单线程特性,也不用担心并发重复扣减的风险。Redis一直扣减直至数值小于0就表示库存已被耗尽。

rest, err := redis.decr(key)
if err {
...
return err
}

if rest < 0 {  
return limitErr
}

而后需求迭代中出现了分时间段库存,每天需要分成多个时段来分配库存,每个时间段的库存如果未被耗尽会累积到下一个时段。这个时候通过简单的Decr命令就不能满足需求了。

  • 方案一 :为每个时段单独设置一个Redis的key,但这样会导致某个时段的库存被浪费,不符合功能需求。要实现库存累积效果,需要在进入下一个时段时将上一个时段的库存加到下一个时段,增加了实现复杂度。

  • 方案二 :继续使用同一个库存key,但需要拆分成两次Redis命令调用,首先判断当前时段的库存是否足够,然后进行扣减库存。这种非事务性的执行方式可能导致并发问题,难以确保库存被准确扣减。

考虑到实现成本,我们选择了方案二。具体逻辑如下:

图片

  • 当前剩余未释放库存 = 总库存 - 当前已释放库存(即timeStock)

    ▪ 当前已释放库存是当前已过时段各个库存的累加。

  • 如果当前库存 < 当前剩余未释放库存,表示当前时段已无可使用库存,返回-1。
  • 否则,返回当前剩余未释放库存-1。

图片

为了解决并发问题,我们引入了Lua脚本,确保库存扣减的原子性。

LUA脚本伪代码:

*# KEYS*[*1*] **为 *redis*的*key*

*# ARGV*[*1*] **为 **提前算出的剩余未释放库存

*# return*的值 *< 0* 代表库存扣减失败

local _args1 = ARGV[1]

local stock = redis.call('GET', KEYS[1])

if stock < _args1 then

    return -1

else

    local rest = redis.call('DECR', KEYS[1])

    return rest

end

return -1

奖品横向迭代

图片

商城内部的奖品都统一维护在奖品库里,每个类型的奖品都有通用属性和扩展属性,同时不同的奖品背后对应着不同的资产,需要有独立的发奖逻辑(资产发放)。因此每增加一个类型的奖品,不仅需要增加奖品类型的枚举,也需要对接下游资产的发放功能。

商城从一开始便不断的在新增不同类型的奖品,从优惠券、代金券、津贴,到后面拓展出现金、虚拟奖品、道具等奖品。

而一些特殊的奖品,比如抽奖券,单独实现一个发奖逻辑就不能实现一个完整功能,为此我们还特地拓展出抽奖券玩法。具体玩法是:用户需要在开奖前获得抽奖券,这时会发放一个抽奖码给用户,我们会在指定时间根据规则进行开奖并通知获奖的用户。这里除了发放抽奖券的逻辑之外,我们还需要一个独立的开奖功能。

开奖流程如下图:

图片

抽奖券的玩法迭代:

抽奖券玩法的核心在于开奖定时任务的逻辑,这类抽奖券活动不同于奖品兑换,单次活动可能有百万人参与,一万人中奖的情况。那么这样开奖Job的实时计算量就过于庞大了,测试环境按照这个量级进行开奖的话,耗时就已达到10多分钟之久。这个时长对于开奖脚本来说是不可接受的了,不仅用户体验达不到预期,而且时长太长,这段时间如果服务一旦出现其他情况(如新功能部署、容器节点被调度等)中断开奖流程更是会阻塞流程。

为此我们做了以下迭代优化:

  1. 开奖Job的实时计算前置:在用户兑换抽奖券时,系统提前为用户打好标签,并随机计算出一个分数。将用户的分数加入到Redis的有序队列中。这样,在开奖时只需从有序队列中取数,大大减少了实时计算量。

  2. 开奖Job引入并发操作:允许多场次和多奖品同时进行匹配,显著提高开奖效率。当然,为了避免并发操作引起的数据错乱,在关键流程中加入了Mutex锁,确保数据的一致性和完整性。

  3. 异常通知与一键重开功能:在应用出现各类意外情况导致开奖流程被阻塞时,我们会有异常通知告知负责人,并提供一键重开的功能。

最终效果:经过以上优化,正式版上线时,100万人的开奖时长从10多分钟缩短了30倍,仅需20秒即可完成开奖。这不仅大幅提升了用户体验,还增强了系统的稳定性和可靠性。

兑换防刷迭代

针对于防刷策略,项目从最开始只是通过接口验签及H5参数加密进行防控,但这种方式的破解成本比较低,也很容易被黑灰产刷接口。到后面我们便系统梳理了从业务层、接口层及黑灰产防控三方面对代码进行加固。

  • 业务层防刷

    ▪ 首先是奖品维度的频控,这是基于3.2-业务架构概述中模板模型实现的,包含每日频次、周期循环频次以及总频次。

    ▪ 其次是活动维度的频控,这个是在兑换流程中,商城中台给业务方提供了开放能力。在兑换前置环节可以自定义校验逻辑,比如N天可兑换N次。

图片图片

  • 接口层防刷

    ▪ 这个主要是依赖于得物统一的流控中心,可以针对于单个接口配置限流和熔断策略,这里就不过多赘述了。

  • 黑灰产防控

    ▪ 针对于这部分流量,我们首先是通过接口验签和时间校验先过滤一部分。

    ▪ 其次是针对IP及设备的黑名单过滤。

    ▪ 还有就是针对于高价值奖品,我们会在前置环节预埋token,只有通过正常流程的用户才能进行兑换。

项目稳定性建设

商城作为一个潜在的资损高危区,在稳定性建设上投入大量资源是必要的。本文我们首先聚焦于问题发现及应急止血方面的工作,包括通过监控和告警快速发现问题,以及通过一键止损开关及时对发生异常的业务进行熔断。

监控告警

在监控告警方面,我们采用了多种技术手段来确保系统的稳定性和及时发现潜在问题:

业务监控组件:对于一些明细数据的同环比对比,我们使用了得物通用的业务监控组件。这种组件能够实时监控业务数据的变化,帮助我们快速发现异常情况。

Prometheus SDK埋点:对于一些需要分位数统计的数据,我们使用了Golang的Prometheus SDK进行埋点,并统一收集上报。

最终这些数据统一接入到增长的监控大盘中,能够清晰地看到各个接入方实时的访问数据、兑换数量及面额等。

图片

告警配置:根据采集到的数据,我们根据规则指标制作了各类告警通知,确保在异常情况发生时能够及时响应:

  • 事前预警 :奖品库存的实时通知与查看,确保库存充足。

  • 事中告警 :奖品数量及金额的同环比变化,及时发现异常波动。

  • 事后统计 :奖品的核销率及T+1天金额对账,确保数据准确无误。

图片图片图片

图片
图片

一键止损开关

为了在发生异常时能够快速响应,我们引入了一键止损开关。这个开关能够在检测到异常情况时,立即对相关业务进行熔断,防止资损进一步扩大。通过这种机制,我们能够在最短时间内控制问题,减少损失。

更多拓展

抽奖组件搭建器

为了进一步提升抽奖玩法的易用性和灵活性,我们与前端团队联合打造了可视化的H5抽奖组件。该组件基于商城中台,提供了完整的抽奖解决方案,运营人员只需按照SOP文档进行配置,即可快速接入抽奖玩法并投入使用,真正实现了抽奖玩法的“开箱即用”。

产品特性:

  • 开箱即用:  提供完整的前后端组件, 支持九宫格/翻拍/老虎机等样式;

  • 中奖控制:  中奖次数及中奖频次控制;

  • 库存控制:  奖品每日/总库存控制, 分时段释放;

  • 多种奖品:  优惠券/代金券/津贴/虚拟奖品等;

部分组件样式如下,也支持在可视化的H5搭建器内自定义组件样式。

图片

在抽奖组件的设计中,抽奖次数的获取是一个关键环节。最初,这一功能完全依赖于业务方自行实现,这导致只有已经接入过商城玩法的业务方才能使用H5的抽奖组件。为了提升组件的通用性和易用性,我们进行了以下优化:

引入增长的任务中台

为了简化抽奖次数的获取流程,我们引入了增长的任务中台。通过这一中台,用户可以完成配置的指定任务来获得积分,这些积分可以直接用于参与抽奖。

图片

打造简易的积分系统

我们打造了一个完整的积分系统,实现了从获得积分、消耗积分到领取奖励的闭环流程。具体实现如下:

  1. 获得积分:用户通过完成指定任务(任务中台提供了非常丰富的任务类型)获得积分。

  2. 消耗积分:用户使用积分参与抽奖,系统自动扣除相应积分。

  3. 领取奖励:用户中奖后,系统自动发放奖品,完成整个抽奖流程。

这种闭环流程的设计带来了多重优势:

  • 简化接入流程 :业务方无需自行实现抽奖次数的获取逻辑,降低了接入成本。

  • 增强通用性 :所有业务方都可以通过积分系统使用H5抽奖组件,提升了组件的适用范围。

六、总结

本文讲述了增长兑换商城整体的业务框架及部分功能的实现细节。兑换商城作为一个中台,承接了不同上下游提出的需求,很多功能的实现都需要考虑到通用性及拓展性,而一些复杂需求或功能的实现,是否会加重配置的难度,影响后续业务方的接入成本,都是需要在项目迭代中不断思考的问题。不仅如此,在迭代过程中的稳定性保障也会是商城自始至终的基本要求。

对于兑换商城之后的规划:

  1. 拓展玩法:引入更潮流、更具吸引力的玩法,以保持用户的持续参与和新鲜感。通过不断创新,确保商城始终处于用户增长的前沿。

  2. 降本提效:输出简版玩法及后台配置,维护每个版本的Changelog及SOP文档,确保迭代过程透明且可追溯。精简业务接入流程,减少开发成本和维护成本,提升整体效率。

  3. 交流进步:本文作为大纲综述,简要的讲解了兑换商城整体的架构,后续将输出更深入的博客文章,详细探讨兑换商城的核心技术实现、优化策略。通过不断的技术交流,促进项目的成熟和优化,确保商城系统的高效稳定运行。

往期回顾

1.得物自研DGraph4.0推荐核心引擎升级之路

2.如何合理规划Elasticsearch的索引|得物技术

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

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

文 / 麦饼

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

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

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

得物自研DGraph4.0推荐核心引擎升级之路

一、前言

DGraph是得物自主研发的新一代推荐系统核心引擎,基于C++语言构建,自2021年启动以来,经过持续迭代已全面支撑得物社区内容分发、电商交易等核心业务的推荐场景。DGraph在推荐链路中主要承担数据海选和粗排序功能,为上层精排提供高质量候选集。

核心技术特性:

  • 索引层 - 支持KV(键值)、KVV(键-多值)、INVERT(倒排)、DENSE-KV(稠密键值)等。索引存储支持磁盘 & 内存两种模式,在预发等延迟压力低场景,通过磁盘索引使用低规格服务器提供基本服务。线上场景使用内存索引保证服务稳定性,提供毫秒级延迟响应。索引更新支持双buff热更新【内存足够】、服务下线滚动更新【内存受限】、Kafka流式数据实时更新等三种模式。

  • 查询层 - 支持向量检索IVF & HNSW、键值(KV)查询、倒排检索、X2I关联查询、图查询。对外提供JavaSDK & C++ SDK。

系统依赖架构:

  • 索引全生命周期管理由得物索引平台DIP统一管控。
  • 服务发现基于ZooKeeper(zk)。
  • 集群资源调度基于得物容器平台,目前已经支持HPA。

服务规模:

目前在线100+集群,2024年双11在线突破了100W qps。

本文主要介绍DGraph系统在2024年的一些重要改进点。主要包括两次架构调整 + 性能优化 + 用户体验提升方面的一些工作。

二、架构升级

2.1 垂直拆分业务集群支持

在2023年前,DGraph系统始终采用单一集群架构提供服务。该架构模式在平台发展初期展现出良好的经济性和运维便利性,但随着业务规模扩张,单集群架构在系统层面逐渐显露出三重刚性约束:

  1. 存储容量瓶颈 - 单节点内存上限导致数据规模受限;
  2. 网络带宽瓶颈 - 单物理机Pod共享10Gbps带宽,实际可用带宽持续承压,推荐引擎业务中部分核心集群200余张数据表(单表需20分钟级更新)的实时处理需求已遭遇传输瓶颈;
  3. 计算能力瓶颈 - 单实例最大64核的算力天花板,难以支撑复杂策略的快速迭代,核心场景响应时效与算法复杂度形成显著冲突;
  4. 稳定性 - 大规格集群对于容器调度平台不友好,在扩容、集群故障、集群发布时耗时较久;基于得物平台推荐数据量增长和算法迭代需求,我们实施业务垂直拆分的多集群架构升级,通过资源解耦与负载分离,有效突破了单节点资源约束,为复杂算法策略的部署预留出充足的技术演进空间。

系统改进点是在DGraph中增加了访问了其他DGraph集群 & FeatureStore特征集群的能力(图1)。为了成本考虑,我们复用了之前系统的传输协议flatbuffers,服务发现仍基于ZooKeeper。

图 1 DGraph 访问架构改进

改造的难点在图化集群!

目前推荐业务的核心场景都进行了图化改造,图化查询是把多路召回、打散、融合、粗排等策略打包到一个DAG图中一次发送到DGraph,DGraph的算子调度模块根据DAG的描述查询索引数据 & 执行算子最终把结果返回给业务系统,但这些DAG图规模都很大,部分业务DAG图涉及300+算子,因此如何在垂直拆分业务中把这些DAG图拆分到不同的DGraph集群中是一个非常复杂的问题,我们主要做了三方面改进:

  1. DAG管理 - 集群分主集群和从集群【多个】,DAG图部署在存在主集群中,DIP平台会分析DAG的拓步结构并把属于从集群的部分复制出来分发给从集群,为了保证DAG的一致性,只允许从主集群修改DAG图;

  2. 集群划分 - 通常按召回划分,比如Embedding召回、X2I召回、实验召回可以分别部署在不同的集群,另外也可以把粗排等算力需求大的部分单独放在一个集群,具体根据业务场景调整;

  3. 性能优化 - 核心表多个集群存放,减少主集群和从集群间数据交换量。

图 2 DGraph业务垂直拆分集群

2.2 分布式能力支持

垂直拆分集群,虽然把推荐N路召回分散到了M个集群,但是每个集群中每个表依然是全量。随着得物业务的发展,扩类目、扩商品,部分业务单表的数据量级已经接近单集群的存储瓶颈。因此需要DGraph中引入数据水平拆分的能力。

图 3 DGraph 分布式集群架构图

在DGraph分布式架构设计中,重点考虑了部署成本优化与业务迁移工作量:

  1. 分布式集群采用【分片数2】×【双活节点2】×【数据副本数2】的最小拓扑结构,理论上需要8台物理节点保障滚动更新与异常容灾时的稳定性。针对CPU负载较轻的场景,为避免独立Proxy集群带来的额外资源开销,DGraph将Proxy模块和DGraph引擎以对称架构部署到所有节点,通过本地优先的智能路由策略(本地节点轮询优先于跨节点访问)实现资源利用率与访问效率的平衡;

  2. 在业务兼容性方面,基础查询接口(KV检索、倒排索引、X2I关联查询)保持完全兼容以降低迁移成本,而DAG图查询需业务侧在查询链路中明确指定Proxy聚合算子的位置以发挥分布式性能优势。数据链路层面,通过DIP平台实现索引无缝适配,支持DataWorks原有任务无需改造即可对接分布式集群,同时增量处理模块内置分片过滤机制,可直接复用现有Flink实时计算集群进行数据同步。

三、性能优化

3.1 算子执行框架优化

在DGraph中,基于DGraph DAG图(参考图9)的一次查询就是图查询,内部简称graphSearch。在一个DAG图中,每个节点都是一个算子(简称Op),算子通过有向边连接其他算子,构成一个有向无环图,算子执行引擎按DAG描述的关系选择串行或者并发执行所有算子,通过组合不同算子DAG图能在推荐场景中灵活高效的完成各种复杂任务。

在实际应用场景中受DAG图规模 & 超时时间(需要控制在100ms内)限制,算子执行框架的效率非常重要。在最开始的版本中我们使用过Omp & 单队列线程池,集群在CPU负载低于30%时表现尚可,但在集群CPU负载超过30%后,rt99表现糟糕。在降本增效的背景下,我们重点对算子执行框架进行了优化,引入了更高效的线程池 & 减少了调度过程中锁的使用。优化后目前DGraph 在CPU压力超过60%依然可以提供稳定服务。

图4 DGraph算子执行框架优化

线程池优化:将原1:N 的线程池-队列架构调整为M:N 分组模式。具体实现为将N个工作线程划分为M个执行组(每组N/M线程),各组配备独立任务队列。任务提交采用轮询分发机制至对应组队列,通过资源分区有效降低线程调度时的锁竞争强度。

调度器优化:在DAG调度过程中存在两个典型多写场景

  1. 前驱算子节点完成时需并行更新后继节点标记;

  2. DAG全局任务计数器归零判断。原方案通过全局锁(Graph锁+Node锁)保障原子性,但在高负载场景引发显著锁竞争开销,影响线程执行效率。经分析发现这两个状态变更操作符合特定并发模式:所有写操作均为单调增减操作,因此可将锁机制替换为原子变量操作。针对状态标记和任务计数场景,分别采用原子变量的FetchAdd和FetchSub指令即可实现无锁化同步,无需引入CAS机制即满足线程安全要求。

3.2 传输协议编码解码优化

优化JavaSDK - DGraph数据传输过程:在DGraph部分场景,由于请求引擎返回的数据量很大,解码编码耗时占整个请求20%以上。分析已有的解码编码模块,引擎在编码阶段会把待传输数据编码到一个FlatBuffer中,然后通过rpc协议发送到业务侧的JavaSDK,sdk 解码FlatBuffer封装成List 返回给业务代码,业务代码再把List 转化成 List<业务Object>。过程中没有并发 & sdk侧多了一层冗余转换。

优化方案如下: 

  1. 串行编码调整为根据文档数量动态调整编码块数量。各子编码块可以并发编码解码,加快编码&解码速度,提升整体传输性能;

  2. sdk 侧由 Doc -> Map -> JavaObject 的转化方式调整为 Doc -> JavaObject,减少解码端算力开销。

图5 DGraph 传输编码解码过程优化

四、用户体验优化

4.1 DAG图调试功能优化

目前我们已经把DGraph DAG图查询的调试能力集成到DIP平台。其原理是:DGraph 的算子基类实现了执行结果输出,由于算子的中间结果数据量极大,当调试模块发现调试标志后会先把当前算子的中间结果写入日志中,数据按TraceID + DAGID+ NodeID 组织,最终这些数据被采集到SLS日志平台。

图6 DGraph DAG图查询调试

从DIP平台调试DAG图请求,首先通过DGraph JavaSDK的调试入口拿到DAG图请求json,填入DIP平台图请求调试入口,发起请求。索引平台会根据请求体自动关联DAG图并结合最终执行结果通过页面的方式展示。DIP平台拿到结果后,在DAG图中成功的算子节点标记为绿色,失败的节点标记为红色(图6)。点击任意节点可以跳转到日志平台查看该节点的中间结果输出。可用于分析DAG图执行过程中的各种细节,提升业务排查业务问题效率。

4.2 DAG图支持TimeLine分析

基于Chrome浏览器中的TimeLine构建,用于DGraph DAG图查询时算子性能分析优化工作。TimeLine功能集成在算子基类中,启动时会记录每个算子的启动时间、等待时间、完成时间、执行线程pid等信息,这些信息首先输出到日志,然后被SLS日志平台采集。用户可以使用查询时的TraceID在日志平台搜索相关的TimeLine信息。

图7 DGraph DAG图例子

图8 使用浏览器查看DGraph DAG图 TimeLine

当我们拿到请求的TimeLine信息后,通过浏览器加载可以通过图形化的方式分析DAG执行过程中耗时分布。图7是一个DAG 请求,它有9个算子节点,图8是它的一次请求的TimeLine。通过分析这些算子的耗时,可以帮助我们定位当前DAG图查询的瓶颈点在哪里,从而精准去解决性能方面的问题。

4.3 DAG图支持动态子图

在DAG图召回中,业务的召回通常都带有一些固定模式,比如一个业务在一个DAG图召回中有N路召回,每一路召回都是:① 查找数据;② 关联可推池;③ 打散; 它们之间的区别可能仅仅是召回数据表名不同或者传递的参数不同。通常我们业务调整或者算法实验调整只需要增加或者减少部分召回,原有模式下这些操作需要去新增或者修改DAG图,加上算法实验很多,业务维护DAG图的成本会非常高。

DAG动态子图的引入就是为了解决这类问题,首先我们在DAG图中配置一个模板子图,它仅仅描述一个行为模式,代表会涉及几个算子,算子之间的关系如何,实际的参数以及召回路的数量则由业务方在发起请求时动态决定。子图的执行和主图的执行共用同一套调度框架,共享运行时资源以降低运行开销。

图9 DGraph 子图

图9是一个DAG召回使用DAG子图后的变化,它有8路召回,一个Merge节点,这些召回分为两类,一类是基于KV表(ForwardSearch)触发的向量召回,另外一类是基于KVV表(IvtSearch)触发的向量召回。引入DAG子图后,在主图中节点数量由17个降为3个。

五、展望未来

过去四年,DGraph聚焦于实现得物推荐引擎体系从0到1的突破,重点完成了核心系统架构搭建、算法策略支持及业务迭代空间拓展,取得多项基础性成果。基于2024年底的用户调研反馈结合DGraph当前的发展,后续将重点提升产品易用性、开发与运维效能及用户体验,同时在系统稳定性、可扩展架构和平台化建设方面持续深化。

算法团队大量HC,欢迎加入我们:得物技术大量算法岗位多地上线,“职”等你来!

文 / 寻风

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

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

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

如何合理规划Elasticsearch的索引|得物技术

一、背景

随着ES在业务场景中的使用逐渐增多,平台对ES集群的稳定性、管理、运维的压力逐渐增大,通过日常的运维情况来看,发现用户对ES的了解熟悉程度参差不齐,经常性的遇到索引创建不规范,或者参考别人索引的创建脚本进行创建索引,对索引没有一个比较清晰的认知,对索引结构的规划也寥寥无几,为此,平台使用了一些列手段来帮助用户提前合理规划模板,比如索引、模板的创建接入飞书审批流,平台侧会逐一结合业务场景和ES集群情况详细沟通确定索引或者模板结构;又比如ES内核增加业务不停服的动态扩分片能力,旨在进行不合理索引的治理提升ES集群稳定性(索引一旦创建分片是不能修改的),我们内部改动ES源码实现了不停服动态扩分片。

因此有必要从ES的索引讲起,让大家对ES的索引从概念、原理到使用有一个清晰的认知,希望日常业务场景中用到ES的同学能够抽时间读一下。当然文章避免不了存在主观的分析,大家可以在文章底部进行评论或者私聊我们,一起探讨。好了废话不多说了,现在开始介绍。

二、什么是index(索引)

下面会针对索引的组成和基本结构结合官方文档逐一介绍。

基本概念

index(索引)是索引是具有相似特征的文档(Document)集合,类似于关系型数据库中的表。每个索引都具有自己唯一的名称与_id。并且可以进行不同的参数配置与mapping映射。以适应不同的业务场景。索引中的最小单位是文档。每一条文档(doc)都是一个json格式的数据对象。包含了实际的具体数据以及该数据所对应的元数据。文档可以是结构化,半结构化或非结构化的数据。索引在elasticsearch中被用于存储,检索与分析数据。通过对索引进行搜索与聚合操作可以快速地找到相关的文档。

官方描述:The index is the fundamental unit of storage in Elasticsearch, a logical namespace for storing data that share similar characteristics. After you have Elasticsearch deployed, you’ll get started by creating an index to store your data.

翻译:索引是Elasticsearch中存储数据的基本单位,是一个逻辑命名空间,用于存储具有相似特性的数据。在部署Elasticsearch后,您将通过创建索引来存储数据。

An index is a collection of documents uniquely identified by a name or an alias. This unique name is important because it’s used to target the index in search queries and other operations.

翻译:索引是一种文档集合,通过名称或别名唯一标识。这个唯一名称非常重要,因为它用于在搜索查询和其他操作中定位索引。

三、索引结构详解

索引结构详解

图片

创建索引结构
PUT /index_demo
{
  "aliases" : {
    "index_demo_alias" : { }
  },
  "mappings" : {
    "properties" : {
      "id" : {
        "type" : "long"
      },
      "name" : {
        "type" : "text",
        "fields" : {
          "keyword" : {
            "type" : "keyword",
            "ignore_above" : 256
          }
        }
      },
      "status" : {
        "type" : "keyword"
      },
      "createDate" : {
        "type" : "long"
      }
    }
  },
  "settings" : {
    "index" : {
      "refresh_interval" : "5s",
      "number_of_shards" : "3",
      "number_of_replicas" : "1"
    }
  }
}

ignore_above属性说明:

- ignore_above的默认值通常为256个字符,这意味着任何超过256个字符的字符串将不会被索引或存储。

- 该参数仅适用于keyword类型的字段,因为这些字段主要用于过滤、排序和聚合操作,不需要进行全文搜索。

- ignore_above的值以字符为单位计算,包括英文字符和汉字。例如,一个汉字和一个英文字符都算作一个字符。

- 性能优化:通过限制字段长度,可以减少索引大小和查询时间,从而提高性能。

- 避免资源浪费:对于包含大量数据的字段,如日志文件中的长字符串,可以通过ignore_above避免不必要的存储和索引。

官方描述:Strings longer than the ignore_above setting will not be indexed or stored. For arrays of strings, ignore_above will be applied for each array element separately and string elements longer than ignore_above will not be indexed or stored.

3.1 别名

别名将其生命置于群集状态内,由主节点(master node) 管理; 这意味着如果你有一个名为 xiaoming 的别名指向一个名为 potato 的索引,那么开销就是群集状态映射中的一个额外键,它将名称 xiaoming 映射到具体的索引字符串。这意味着与其他指数相比,别名的重量要轻得多; 可以维护数千个而不会对集群产生负面影响。

官方原话:An alias points to one or more indices or data streams. Most Elasticsearch APIs accept an alias in place of a data stream or index name.

Aliases enable you to:

- Query multiple indices/data streams together with a single name

- Change which indices/data streams your application uses in real time

- Reindex data without downtime

翻译:别名(Alias)可以指向一个或多个索引或数据流。大多数Elasticsearch API接受别名代替数据流或索引名称。别名的功能包括:

- 使用单一名称查询多个索引/数据流;

- 实时更改应用程序使用的索引/数据流;

- 在不中断服务的情况下进行扩分片。

可以看到索引有上面三个作用,平台建议为每个索引添加别名(动态扩分片依赖别名)。添加别名可以在索引创建时和创建后再添加,即索引可以随时添加,但是平台还是建议你在创建索引时候指定别名,避免动态扩分片时候再去修改代码重新部署应用。

添加别名的几种方式

1. 创建索引时指定别名

PUT /test_index
{
    "settings" : {
        "number_of_shards" : 1,
        "number_of_replicas" : 1
    },
    "aliases":{"test_alias":{}},
    "mappings" : {
        "properties" : {
            "field1" : { 
                "type" : "text" 
            },
            "createdAt": {
                "type""date",
                "format""yyyy-MM-dd HH:mm:ss"
           }
        }
    }
}

2. 已存在的索引添加别名

POST /_aliases
{
  "actions": [
    {
      "add": {
        "index""test_index"# 索引名
        "alias""test_alias" # 别名
      }
    }
  ]
}

3. 别名更换

别名更换可以零停机进行动态扩分片。

POST /_aliases
{
  "actions": [
    {
      "add": {
        "index""existing_index",
        "alias""test_alias" # 别名
      },
      {
        "remove": {
          "index""old_index",
          "alias""old_test_alias" # 别名
        }
      }
    }
  ]
}

3.2 映射

建立索引时需要定义文档的数据结构,这种结构叫作映射。在映射中,文档的字段类型一旦设定后就不能更改。因为字段类型在定义后,elasticsearch已经针对定义的类型建立了特定的索引结构,这种结构不能更改。借助映射可以给文档新增字段。另外,elasticsearch还提供了自动映射功能,即在添加数据时,如果该字段没有定义类型,elasticsearch会根据用户提供的该字段的真实数据来猜测可能的类型,从而自动进行字段类型的定义。

3.3 字段类型

字段类型(Field Type)是定义数据格式和索引方式的重要概念,它决定了字段在索引中的存储、搜索和聚合行为。下面针对日常用到最多的三个字段类型进行解释,text、keyword、Numeric(Integer、Long)。

Text

text字段类型是Elasticsearch中用于全文搜索的核心字段类型。它通过分析器将文本拆分为单个词,并存储为倒排索引,适用于非结构化文本的搜索和分析。然而,由于其经过分析器处理,不适用于排序和聚合操作。

1. 特点

  • 全文搜索: text字段类型主要用于存储和索引可读的文本内容,例如邮件正文、产品描述、新闻文章等。这些字段会被分析器(analyzer)处理,将字符串拆分为单个词(term),以便进行全文搜索。
  • 分词处理: text字段支持分词器(tokenizer),可以根据语言和需求选择不同的分词策略(如标准分词器、正则表达式分词器等)。分词后的结果会存储为倒排索引,便于快速检索。
  • 不适用于排序和聚合: 由于text字段经过分析器处理,其原始字符串无法直接用于排序或聚合操作。如果需要排序或聚合,通常需要结合keyword字段类型。
  • 支持多字段映射: 可以通过多字段(multi-field)映射同时使用text和keyword类型,以满足全文搜索和精确匹配的需求。

2. 使用场景

  • 全文搜索: 适用于需要对文本内容进行模糊搜索的场景,例如搜索引擎、新闻网站、商品搜索等。
  • 文本分析: 可以结合分析器(如TF-IDF、BM25等)进行文本相似性搜索或评分计算。
  • 日志分析: 用于分析和搜索日志文件中的文本内容,提取关键信息。
  • 内容管理: 在内容管理系统中,用于存储和搜索文档、文章等内容。

3. 官方建议

Use a field as both text and keyword

Sometimes it is useful to have both a full text (text) and a keyword (keyword) version of the same field: one for full text search and the other for aggregations and sorting. This can be achieved with multi-fields.

通过多字段映射同时使用text和keyword类型,可以实现全文搜索和精确匹配的双重需求。

4. 平台建议

  • 明确业务使用场景,如果不需要进行模糊搜索的话,设置为keyword类型,来避免分词带来的存储开销,增加系统压力。

Keyword

keyword字段类型是一种用于存储和索引结构化数据的字段类型。

1. 特点

  • 不进行分词: keyword字段类型不会对字段值进行分词处理,而是将其作为整体存储。这意味着字段值会被原样存储到倒排索引中,不会被拆分成单独的单词或短语。
  • 精确匹配: 由于字段值不进行分词,keyword字段类型非常适合用于精确匹配查询,例如查找特定的电子邮件地址、身份证号或状态码等。
  • tips: 在term查询中可以结合case_insensitive属性,忽略大小写对值进行搜索,但不支持terms查询。
  • 支持排序和聚合: keyword字段类型可以用于排序和聚合操作,例如按状态码统计数量或按用户ID进行分组。
  • 存储效率高: 由于不需要分词,keyword字段类型的存储开销较低,适合存储大量具有唯一性或固定值的字段。

2. 使用场景

  • 精确查询: 适用于需要精确匹配的场景,例如查找特定的电子邮件地址、身份证号、状态码等。
  • 排序和聚合: 当需要对数据进行排序或聚合时,keyword字段类型是理想选择。例如,按用户ID排序或按状态统计数量。
  • 标签和分类: 用于存储标签、分类等结构化数据,例如用户画像标签(学生、IT、教师等)。
  • 唯一性字符串: 适用于存储具有唯一性的字符串,如SpuId、货号、得物订单号等。

Numeric

数值类型,包含long、interger、short、byte、double、float等数字类型。

1. 特点

  • 整数类型: 适用于范围查询、排序和聚合操作。由于整数类型占用空间较小,推荐优先使用范围较小的类型(如 integer 或 long)以提高索引和搜索效率。
  • 浮点类型: 适用于需要高精度的计算场景。如果数据范围较大或精度要求不高,可以使用 scaled_float 类型并设置合适的 scale 值。
  • 选择合适的类型: 在满足需求的前提下,尽量选择范围较小的类型以节约存储空间和提升性能。

tips

如果确定业务使用场景,建议keyword代替数值类型字段,如果不确定则采用多字段,keyword在term查询中性能更佳。

图片

3.4 针对字段类型选择的几条建议

  1. 针对Text和数值类型场景的字段,尽量改成keyword字段类型,来提升查询速度。

  2. 在不确定业务查询有哪些需求的情况下,设置多字段类型keyword。

  3. 枚举字段没有特殊业务场景下,统一使用keyword字段类型。

  4. 业务不需要范围查询的话,使用keyword字段类型(支持聚合和排序的)。

  5. 对keyword字段类型进行模糊查询会性能较差,使用多字段类型wildcard来模糊查询性能更高。

  6. 尽量不要使用聚合查询,text的fielddata会加大对内存的占用,如有需求使用,建议使用keyword。

  7. 需要中文分词的话,不要使用默认分词器,推荐使用ik_smart,ik_max_word会生成更多的分词,其中含有重复的内容,需谨慎使用。

  8. 时间字段不要使用keyword,除非点查,推荐使用date/long类型,支持范围查询,建议精确到分钟,会提高查询效率。

  9. keyword字段类型不适用于模糊wildcard查询,建议使用wildcard字段类型。

    图片

  10. 日期的查询条件为now时,并不能有效利用缓存,尽量换成绝对时间值。

  11. ES默认字段个数最大1000,但建议不要超过100,对于不需要建立索引的字段,不写入ES。

  12. 将不需要建立索引的字段index数据设置为false,对字段不分词,不索引可以减少很多运算操作。

  13. 不建议或者禁止每次写入后立马进行显示的refresh,refresh会带来较高的磁盘IO,和CPU消耗,甚至有可能导致ES宕机。

  14. 持续补充......

3.5 索引结构与关系性数据库对比

图片

四、索引(Shard)结构-分片与副本

4.1 什么是Shard

基本概念

分片是管理文档的一个数据单元,分片是Elasticsearch中逻辑概念。ES内部把索引中文档进行按照一定路由规则(文档_id的hash值与分片数取余)进行路由到不同的存储数据单元,存储数据单元就是分片。你可以理解为MySQL的分表。

ElS的逻辑分片就是一个Lucene索引,一个ES索引是分哦的集合,当ES在索引中搜索的时候,他发送查询到每一个属于索引的分片(Lucene索引)进行检索,最后合并每个分片的结果得到一个全局的结果集。

分片划分

分片分为primary shard(主分片)replicate shard(副本分片)

  • 主分片: 索引的基本数据存储单元,每个索引被水平拆分为多个主分片,每个分片都是互相独立的。包含一部分索引的数据与索引的结构(segement)。每个分片都可以在集群中不同的节点上进行移动与复制。以提高数据的可用性与容错性。

  • 副本分片: 主分片的完整拷贝,用于冗余存储和容灾,副本分片和主分片在ES节点数足够的情况下不会同时存在一个ES节点。

注意:单分片的记录条数不要超过上限2,147,483,519。

  • 主副分片分布示意图

图片

分片的功能

1. 主分片

  • 数据存储与写入: 所有文档通过路由算法(如 hash(_id) % num_primary_shards(主分片数))分配到主分片,主分片负责处理索引、更新、删除等写操作。
  • 扩展性: 通过增加节点和分片分布,实现数据的水平扩展。
  • 不可变性: 主分片数量在索引创建时通过 number_of_shards 参数设定,创建后无法修改(需重建索引)。

2. 副本分片

  • 高可用性: 当主分片所在节点宕机时,副本分片自动升级为主分片(和对应的主分片不在一个节点),避免数据丢失和服务中断。
  • 读取负载均衡: 副本分片可并行处理查询请求,提升读吞吐量。
  • 动态调整: 副本分片数量通过 number_of_replicas 参数动态配置,支持按需扩展或缩减。

4.2 分片数规划

分片的基本概念和功能咱们咱们已经了解,在日常ES运维过程中发现不少同学对分片和数量的设置没有什么概念,照搬其他同学的比较多,这是严重错误的。咱们在实际的业务场景中也要做好分片(主副)数量的规划,来避免慢查、数据倾斜、磁盘容量浪费等问题。

当索引分片数量过多时,可能会对ES性能产生不利影响。因为每个分片都需要一定量的内存来存储索引数据和缓存,从而导致内存消耗增加。另外当查询或写入数据涉及多个分片时,ES需要在节点之间进行传输和协调数据,从而增加网络开销,这也会导致查询和写入性能的降低。可见分片数量的选择需要慎重考虑。

索引在不同场景中,其分片分设置是不一样的,接下来咱们会在下面四个场景中来进行阐述。

读场景

索引单分片20g~40g,尽量减少分片数,可以降低热点,因为当分片数过多时,就容易出现长尾子请求,即有可能部分子请求因ES集群节点异常、Old GC、网络抖动等延迟响应,导致整个请求响应缓慢。另一方面,拆分过多的子请求无法提升数据节点请求吞吐,不能充分利用 CPU。在尽量减少主分片数的情况下,同时也可以适当增加副本数,从而提升查询吞吐。

写场景

索引单分片10g~20g,小分片更有利于数据写入。小分片维护的segment数量远低于大分片,在数据刷新落盘与段合并上更有优势。由于单分片数据量更少,在写入时数据可以更快地缓存至内存中并通过refresh参数更快的持久化至磁盘中。

日志存储场景

  • 需要考虑每日写入集群的数据总量大小。通过过数据量与数据节点数评估索引分片数量。
  • 在日志存储后是否需要兼顾查询与聚合性能。合理大小的分片数据量能够提高查询效率。
  • 根据日志持久化策略,采用按月/周/天的策略生成索引。并使用ILM(索引生命周期管理策略)动态对日志索引进行完整生命周期的管理。
  • 建议副本数设置为0来减少磁盘容量成本。

小数据量索引业务场景

对于数据量比较小的索引,增加索引分片数并不一定会带来性能提升,反而可能会带来一些负面影响。

首先,增加索引分片数会增加集群的管理开销,包括维护分片的状态、备份和恢复分片等。如果索引数据量比较小,这种开销可能会超过性能提升带来的收益。

其次,增加索引分片数可能会导致数据分布不均衡,从而影响查询性能。具体来说,如果某些分片中的数据量过小,可能会导致这些分片的查询性能比其他分片差。此外,如果查询涉及到多个分片,数据的合并操作也会增加查询时间。

因此,对于数据量比较小的索引,在查询场景下,通常建议将分片数设置为1或2,以避免不必要的开销和性能问题。如果需要提高查询性能,可以考虑配置索引副本,优化查询语句或使用缓存。

通用场景

  • 根据实际业务场景提前规划预算索引数据量,做好分片数量规划(索引一旦创建无法修改分片数)。

  • 分片数量:推荐公式:主分片数 ≈ 总数据量 / 单分片容量上限(官方建议单分片10-50GB,单个分片文档数在1亿条以内,日志场景可放宽至50-100GB)。

    注意:分片数量平台强烈建议或者要求设置为ES data节点角色的整数倍。

  • 副本数量:增加副本数可提升读性能,但会降低写入速度(需同步更多副本),因此在读场景可以酌情考虑。

  • 如果索引是时序类,或者数据过大,单分片几百G,可以结合生命周期和索引模板进行索引滚动管理。

  • 平台不建议使用自动移routing值进行分片,默认使用文档_id就好。

    原因:使用自定义routing值进行路由分片的话很容易产生数据倾斜,另外ES内部会多一些计算逻辑来如何进行分片路由,在写入较高的场景下也会有一定的性能损耗。

  • 控制分片数量,分片数不是越多越好,过多分分片,也会造成ES集群元数据管理的压力,降低系统的性能损耗。

  • 设置total_shards_per_node,将索引压力分摊至多个节点。

  • index.routing.allocation.total_shards_per_node参数可以限制每个节点上的shard数量,从而将索引的压力分摊到多个节点,这样可以提高集群性能和可用性,避免某个节点过载导致整个集群出现问题。

  • index.routing.allocation.total_shards_per_node是一个索引级别设置(创建索引和对已有索引进行设置),语法如下:

PUT <index_name>/_settings
{
    "index.routing.allocation.total_shards_per_node":<number_of_shards>
}
<index_name>为索引名字,<number_of_shards>表示每个节点上该索引的分片数量。

持续调整索分片

对于集群分片的调整,通常不是一蹴而就的。随着业务的发展,不断新增的子业务 或 原有子业务规模发生突变,都需要持续调整分片数量。

4.3 索引与资源消耗的关系**

分片数量与内存消耗

每个分片都是独立的Lucene索引,需要维护倒排索引、缓存等内存结构。分片数量过多会导致以下问题:

  • 内存占用激增: 每个分片默认占用约10-30MB内存(含元数据),数千分片可能消耗数十GB内存。
  • 文件句柄耗尽: 集群总分片数过多会占用大量文件描述符,可能触发"too many open files"错误。
  • CPU热点问题: 分片分配不均会导致部分节点负载过高。

Segment碎片化

分片由多个segment组成,segment数量过多会:

  • 增加IO压力: 查询需遍历多个segment文件。
  • 占用堆内存: 每个segment需加载部分元数据到内存,百万级segment可能消耗数GB内存。
  • 影响GC效率: 频繁的segment合并会触发Full GC。

五、总结

创建一个索引需要结合业务使用场景考量字段类型选择和是否需要索引分词,按照数据规模和业务增长速度来确定分片和副本的数量的大小。索引的结构直接影响集群的稳定性,因此我们在创建索引的时候要养成习惯,作为技术方案的一环去仔细打磨这样才能保证线上的稳定性。

大家工作中遇到的一些稳定性问题,和使用上的一些问题都可以找我们一起探讨,寻找最优解。

文 / 阳光

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

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

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

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

一、DPP整体架构

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

图片 图1.0 DPP服务整体架构

平台特性

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

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

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

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

二、DPP引擎演进

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

2.1 固定编排 - DPP-Engine

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

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

图片

图1.2-1 DPP-Engine层编排

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

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

2.2 灵活编排 - BizEngine

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

图片

图1.3-1 DPP系统(BizEngine)

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

图片

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

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

图片

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

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

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

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

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

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

2.3 图化DAG - DagEngine

为什么需要做图化?

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

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

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

图片

图化架构

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

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

图片

图4.0.0 - DPP图化框架

图片

图4.0.1 - DagEngine

图化核心设计和协议

1.算子

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

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

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

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

  • 依赖数据@DependsDataAnno

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

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

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

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

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

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

2.图/子图

  • 图/子图/配置文件

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

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

  • 协议

子图

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

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

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

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

  • 默认值

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

  • 运行时的值

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

  • 如何配置?

实验配置中:

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

算子代码中:

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

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

图化相关特性/结果

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

图片

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

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

三、总结

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

图片

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

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

往期回顾

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

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

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

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

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

文 / 在东

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

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

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

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

一、引言

很高兴与大家分享现阶段 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?开源项目源码深度揭秘|得物技术

一、引言

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

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

显然,这是不够的。

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

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

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

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

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

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

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

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

四、什么是智能体Agent

从Prompt到思维链

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

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

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

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

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

ReAct架构

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

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

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

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

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

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

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

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

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

开始! 

- 问题:{input}

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

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

Agent

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

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

v2ad31f685f1330333011c67eccc3cb64c_1440w.png

Agent处理流程

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

1.规划(Planning):

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

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

2.记忆(Memory):

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

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

3.工具(Tools):

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

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

4.行动(Action):

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

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

Manus:一个Agent典型案例

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

2.png

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

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

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

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

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

大模型上下文协议(MCP)

MCP是做什么的?

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

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

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

3.png

MCP架构

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

img_v3_02kp_bcaed6dcc3e04917a824cf74a340516g.png

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

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

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

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

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

为什么要用MCP?

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

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

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

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

img_v3_02kp_7f7cdb11c5c3435e8bdcc98e38f9cddg.png

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

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

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

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

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

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

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

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

准备

项目地址:

github.com/mannaandpoe…

构建环境

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

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

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

安装playwright

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

配置大模型API Key

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

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

代码

OpenManus客户端

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

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

img_v3_02kp_037da7610f23414cb15d567f598ac4bg.png

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

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

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

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

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

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

img_v3_02kp_e50578ddab27439f91d97a3f5e38943g.jpg

Prompt

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

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

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

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

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

Manus类

img_v3_02kp_83117adc20bf418fbd98933c2671522g.png

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

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

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

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

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

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

Base类

run()

img_v3_02kp_36fbb768418d4f2892b676943131916g.jpg

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

状态管理

img_v3_02kp_036ebee8ebfd4b4c94cb283d4a071aag.jpg

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

img_v3_02kp_1fa59b67e15247069e103f001a8b2a2g.jpg

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

Memory管理

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

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

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

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

img_v3_02kp_8c1e4d8812b840d9804ed82c2e6b68cg.jpgimg_v3_02kp_c74745982b0042e59b77935079c3b55g.png

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

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

主循环

img_v3_02kp_1ce792754452405cbd686c976d9a2bfg.png

agent本质就是循环执行。

  • step实现参考react step。

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

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

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

ReAct

step()

img_v3_02kp_3999f1b8a5bb413f826ca4b7c3d8836g.png

  • 这里的逻辑很简单。

ToolcallAgent

Think()

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

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

询问LLM

img_v3_02kp_ecd6a3006d254268a783101c86d86a0g.png

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

img_v3_02kp_d194c2fca02e47b9be3c05ab5195c25g.png

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

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

下图是一次返回response结果

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

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

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

think后续逻辑

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

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

Act()

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

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

img_v3_02kp_44e6894bd91540ec82dc03c8e3e970bg.png

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

Execute_tool()

img_v3_02kp_030fab99df154e819a61d3ff3bed5aeg.png

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

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

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

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

工具相关

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

img_v3_02kp_841aa8ccb6d74423a435decd316bc3bg.png

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

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

Base Tool

img_v3_02kp_3a61d2518cb343539aad1dd28cd6686g.png

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

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

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

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

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

工具JSON

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

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

工具示例——google_search

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

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

img_v3_02kp_970ea2580aca4c8980980b7f28db476g.png

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

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

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

五、总结

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

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

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

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

往期回顾

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

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

3. 得物 Android Crash 治理实践

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

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

文 / 汉堡

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

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

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

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

一、稳定性及其意义

什么是稳定性?

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

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

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

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

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

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

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

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

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

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

其中:

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

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

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

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

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

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

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

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

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

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

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

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

1.失去稳定性的损失 :

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

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

2.具备稳定性的好处 :

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

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

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

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

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

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

图片

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

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

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

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

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

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

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

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

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

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

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

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

存量风险识别的难度

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

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

风险治理的难度

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

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

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

图片

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

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

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

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

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

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

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

增量风险预防的难度

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

变更可见性

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

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

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

方案可控性

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

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

人员可靠性

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

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

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

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

建立稳定性共识

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

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

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

明确稳定性建设目标

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

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

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

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

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

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

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

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

落地稳定性建设任务

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

意识培养

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

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

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

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

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

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

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

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

安全生产规范

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

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

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

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

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

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

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

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

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

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

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

应急响应

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

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

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

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

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

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

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

日常巡检

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

架构治理

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

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

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

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

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

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

资损防控

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

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

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

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

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

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

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

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

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

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

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

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

四、稳定性建设全景图

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

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

图片

往期回顾

1. 得物 Android Crash 治理实践

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

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

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

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

文 / 裁衣(Joker)

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

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

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

得物 Android Crash 治理实践

一、前言

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

二、DNS解析崩溃

背景

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

堆栈与上报趋势

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

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

上报趋势.jpeg

问题分析

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

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

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

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

解决过程

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

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

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

struct sigaction old;
static sigjmp_buf buf;

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

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

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

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

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

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

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

三、MediaCodec 状态异常崩溃

背景

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

堆栈与上报趋势

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

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

问题分析

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

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

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

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

规避这个问题.jpeg

解决过程

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

#include <iostream>
#include <csignal>

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

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

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

private:
    std::string msg_;
};

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

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

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

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

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

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

四、bio多线程环境崩溃

背景

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

堆栈与上报趋势

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

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

bio多线程.jpeg

问题分析

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

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

解决过程

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

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

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

背景

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

堆栈与上报趋势

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

问题分析

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

cs.android.com/android/pla…

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

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

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

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

解决过程

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

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

六、总结

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

文 / 亚鹏

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

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

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

❌