软考论文大纲

过去两年,全球贸易环境在需求波动、供应链重组和数字技术发展的多重影响下持续变化。世界贸易组织数据显示,2024年全球货物贸易总额约为3.3万亿美元,同比增长3.3%,虽然贸易总体仍在增长,但企业之间的竞争日趋激烈。与此同时,买卖双方的信息壁垒依然明显,传统依靠展会、人脉和代理的外贸模式在效率和可持续性上显得力不从心。对中国外贸企业而言,这一趋势尤为明显。根据海关总署数据,2024年中国进出口总额达到43.85万亿元人民币,同比增长5%,其中出口额为25.45万亿元,同比增长7.1%。但在数字化转型成为行业共识的当下,许多中小企业仍依赖人工整理数据、分散管理客户资源,无法有效整合全球采购、海关、信用、物流等信息,导致潜在客户挖掘难、转化效率低、营销成本高。
基于此需求,网易推出了“外贸通”智能外贸综合服务平台,旨在以数字化方式帮助外贸企业提升客户拓展与市场分析能力。该平台整合全球海关数据、采购网站、企业工商信息与网站行为数据,构建统一的外贸数据中心,并利用AI算法实现客户画像、智能推荐和商机预测。系统能够为企业提供“从数据到客户”的一体化解决方案,使销售人员能够基于客观数据快速筛选高价值客户,而非依赖经验式判断。该项目自2022年7月启动,立项资金500万元,历时7个月上线,目前仍在持续迭代,用户规模不断扩大逐渐成为事业部的盈利增长点。

论负载均衡

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目的研发工作,我在该项目中担任系统架构设计师一职,主要负责架构设计和系统评估。该项目是一个为企业提供从用户发现到外贸营销的一站式外贸SaaS平台,主要功能模块包括客户发现、邮件营销EDM、客户管理CRM、海关贸易数据爬取链路等,并通过AI和大数据为外贸企业赋能。我将以“外贸通”项目为背景,阐述负载均衡在该项目三层架构中的应用:表示层使用Nginx进行七层负载均衡、功能层使用SpringCloud Gateway和Ribbon实现微服务负载均衡治理、数据层的Redis使用主从复制实现读写分离提高并发,MySQL利用ShardingSphere JDBC实现水平分片,并通过自定义的哈希算法实现负载均衡。由于多种负载均衡算法的考虑使得系统稳定性提高了40%、性能提高32%,获得客户的一致好评。

思路

思路:先阐述层次架构的概念,介绍我们的项目是B/S和C/S混合的瘦客户端架构,然后介绍负载均衡的意义以及负载均衡的常见算法,随机、哈希、轮询、一致性哈希、四层负载和七层负载,http重定向、NAT重定向。接着分别介绍表示层也就是前端的技术栈Vue+Nginx,使用服务端渲染,Nginx反向代理给后端网关,转发和路由,监控;结合介绍功能层也就是后端的技术微服务架构,采用Spring Ribbon实现网关的负载均衡,分配到具体的服务实例上,一般的算法是哈希分片和轮询RR,我们在这里使用的微服务服务发现中心Nacos事实上也可以起到一定的均衡作用;最后介绍数据层面的负载均衡,Redis的主从复制用了一台主Redis节点和两台从Redis,均为四核16GB,主从复制采用Replica of IP进行复制,从节点会存储一个offset以便与主节点同步,初始同步使用RDB后续使用AOF,存储在Replication backlog buffer,进行了热key的预热,用负载均衡和读写分离抵御了一次高并发热点活动;分库分表使用Apache ShardingSphere,水平分片突破单机MySQL上线,使用自定义的哈希算法实现子查询的负载均衡。

论面向服务架构

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目,在该项目中担任软件架构设计师一职,主要负责架构设计和功能优化。该项目是一个为企业提供从客户发现到外贸营销的一站式外贸SaaS平台,主要功能模块包括客户发现、邮件营销EDM、客户关系管理CRM和海关贸易数据爬取链路等。为了消除信息孤岛问题,项目构建采用面向服务的架构,我将主要描述SOA企业服务总线、业务处理服务、业务创新服务、控制服务、IT管理模块,开发服务在本项目中的应用。采用的面向服务的架构有效提升开发和通信效率,获得了业界和用户的一致好评。

思路

首先描述面向服务的架构是更粗粒度的,相比于传统的面向对象和面向构建更松耦合,SOA的技术栈主要包括UDDI进行服务发现注册、WSDL网络服务描述语言描述服务基本功能、接口和位置、SOAP完成服务调用、TCP/IP作为传输层协议辅助通信。采用SOA的意义在于架构复用,每个模块功能通过企业服务总线ESB进行调用,能够有效提高性能和效率。接着介绍SOA服务的六个主要类别:企业服务总线ESB作为服务发现中心协调服务之间的调用,由于外贸通项目的调用链路特别长,从获取信息到邮件营销到潜在客户管理;此外还要屏蔽各平台贸易数据的实现差异以及海关数据的接口差异,例如有的使用http有的使用soap的webservice,ESB需要屏蔽这一层差异,我们做了多重枚举的适配解决这一问题,从而给接口调用返回一个公共类型的对象;此外我们的ESB还设计了事件触发机制,多条链路的数据爬取通过隐式调用同步到线上,通过ESB的高级功能实现。接着是业务处理服务和业务创新服务,我们将业务粗粒度的划分为应用服务、数据服务、海关服务、CRM服务等,应用服务和数据服务服务总线隐式调用,应用服务包括restful api和controller来直接与用户界面交互、数据服务利用xxljob和kafka,采用多种爬虫策略多渠道多国家获取贸易数据和潜在公司,再结合大数据和大模型对数据进行清洗,聚类分析和分类分析,尽最大可能给用户提供高相似度的推荐公司,通过召回不断迭代优化算法;开发服务采用devops模式,实现了一套持续交付持续基础的git流水线,利用jenkins实现了开发、部署、灰度和上线的全流程流水线,集成在我们的SOA架构中,有效提高了软件开发效率;控制服务和IT服务管理主要是高效安全的管理业务流程,对于IT资产使用加密的模型库和数据库,通过线上审核和工单提单机制来进行责任追踪,确保资产的完整性和安全性;对于整条业务流程采用多种权限控制,前后端均进行校验,后端采用spring security和shiro进行校验,同时自动化巡检会对漏洞定期抽查,随后反馈到工作群组中。基于完整的介绍了基于服务的架构SOA在“外贸通”项目中的应用。

论云原生架构

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目的开发,在该项目中担任系统架构设计师,主要负责项目的系统架构设计和软件质量分析。“外贸通”为企业提供了一套从潜在客户发现到邮件营销到商机转化到客户管理的一站式的外贸SaaS解决方案,自上线以来用户规模不断扩大逐渐成为事业部的核心赢利点。我们聚焦于云原生架构使得能够自动化部署高效运维,通过容器化和自动编排以及CI/CD,结合私有云和服务网格实践整合出了一套高效的适合本业务场景的微服务治理体系。在安全性和面对高并发场景能够较强的健壮性,同时也能实现快速迭代的需求。

思路

先说明传统架构的缺点,普通的基于虚拟机的架构不适合持续集成持续交付,多服务运维较为复杂,配置环境无法统一。使得程序员需要花大量的时间在非功能代码方面,运维成本大,无法聚焦于业务。通过云原生的serviceless模式,部署采用git流水线通过jenkins和容器化直接同步到线上,程序员可以更加关注代码而非运维和环境,解放了生产力;此外可以借助容器化实现微服务治理,不再需要重量级的部署,通过docker虚拟化携带操作系统必要的执行文件,可以轻量化应用实现微服务架构;此外云原生使用k8s动态扩容集群,针对某些高并发场景提高健壮性,使得系统更灵活;云原生的链路追踪和日志管理采用ElasticSeach+LogStach+Kibana+SkyWalking实现日志统计和报表产生,数据看板采用普罗米修斯+Grafana。综合来看,我们搭建了的云原生架构包括以下内容:使用docker将微服务装载进容器,使得部署更加轻量级;使用基于jenkins和git的持续集成/持续部署流水线,简化运维流程;使用基于k8s的容器扩容和动态调整,面对高并发场景更鲁棒;使用ELK的日志管理系统,以及普罗米修斯的数据看板;结合公司内部的私有云服务完成了一套微服务下的云原生治理体系。

实习2.0-项目总结

实习拷打

项目介绍

网易外贸通是一个为外贸企业打造的一站式外贸营销平台,主要的功能包括数据获客,邮件营销,客户管理和订单追踪等。
我所在的组是做数据获客的,主要业务就是让用户能够通过我们的应用查到他的潜在客户。就比如一个想做服装贸易的就可以通过我们的数据找到对应的国外买家。
这一部分数据获取是多渠道的,我们通过海关每年的hscode,各种类似盘踞这些网站采购,以及一些爬虫技术拿到了大概六千万家公司数据。我们的业务一方面是搜索功能,所以我们数据层没用mysql多用的是es;
另一部分是数据层,多渠道去搜集这些公司作为我们的信息。

数据双向同步链路-落库

  1. 目的:我们这边是做数据的,另外一边算法组做的是域名行业分类,意思就是他们把域名对应的html通过某种模型预测一个行业标签,我们组展示需要这个tag,但是他们落库不在我们这边,于是就将这部分有tag的域名推送给我们,kafka消息里面传的是这个domain,但是具体的标签信息需要我们去请求grpc获取。
  2. 解决方案:对于这部分工作我首先是给entity新增了关于域名分类的字段,然后写了一个kafka listener批量消费,grpc接口能够一次传50个域名然后返回50个对应的行业信息。
  3. 遇到问题:由于grpc限流以及有可能域名查不到,会造成消息丢失。他那边grpc接口设置是1秒一次,因此我要保证他那边报错了消息重新消费。
  4. 解决问题:两个set,一个全局set一个失败set,错误可能发生在kafka消费的时候、拿着所有50条去请求rpc接口的时候、rpc接口报错或者没有数据的时候、es更新的时候:
    (1)kafka消费报错,这里是手动ack的,所以一旦没有消费成功offset也没偏移,一般不会出现这种情况。
    (2)拿着50条数据请求rpc接口的时候:在消息消费的时候就组装了这两个set,如果这里出现问题,也能保证这50条不提交
    (3)rpc接口报错或者没有数据的时候:例如限流了,此时也可以返回这50条;但如果返回有效需要清空这个失败set,按照实际他返回的数据再去组装,因为有可能这个域名他就是没有数据,允许一部分这种差异。
    (4)es落库一条从这个失败set里面移除一条。
    最终效果是推送了900w条数据,根据新增的这个recommend字段exist查询已落库数量,成功率接近100%,分析了一些差错case,原因基本上是动态问题,原本推送的某些域名后续调整他把字段删除了,因此rpc接口请求不到。

数据双向同步链路-推送

  1. 目的:我们数据组每天都会搜集域名,算法组需要这些增量数据,因此需要一个topic每天定时通过kafka发送新增域名
  2. 解决方案:xxljob设置每天凌晨推送新增domain数据,根据createTime来看,然后深度分页用scrollSearch解决
  3. 遇到问题:他这边没有createTime,只有updateTime,这就可能导致重复发送了,因此需要一个发送的tag,与mt商量后同意这种做法了
  4. 解决问题:发送的时候同时给数据新增一个字段flag,查询的时候去看有没有这个字段,有并且又是昨天的就发送。

订阅更新任务

原本逻辑:每周每个账号订阅的公司消息进行处理和监听;
后来逻辑:增加一个watchTime字段,如果用户在这一周查看过这个订阅更新界面就刷新。
改造方法:查询接口crud这个字段,一天watchtime只更新一次。然后xxljob里面的执行器加一个这个过滤即可,然后将这些作为一个消息发送给kafka

kafka消费者拿到这些id去数据库找对应的公司,将订阅公司表项和公司实体项(从es来的,如果前者有companyId就直接查询,如果没有就需要根据名字和地区来查询并保存)作为参数传分别分析facebook信息,facebook提及信息,海关信息,联系人信息,开了一个线程池并行处理这些。
原来这块是顺序的,因为本就是离线任务,触发的频率也不高,后续改成了线程池,用了异步编排,以便后续扩展

1
2
private static final ExecutorService executor = new ThreadPoolExecutor(5,
10, 60 * 5L, TimeUnit.SECONDS, new LinkedBlockingDeque<>(2000), COLLECTMONITORCOMPONENT_SERVICE_THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy());

虚拟线程与区块链

虚拟线程

虚拟线程是jdk19提出来的预览特性,jdk21转正。在以往的一对一线程模型中,虽然将进程划分为了线程,但是线程的粒度还是太大了,一个线程占1m,在高并发动不动几十万qps下,内存都不一定打的住。利用粒度更细的协程,可以将这部分开销继续缩小。

虚拟线程优点

  1. 占据资源少,切换开销小:虚拟线程是一种用户级线程(绿色线程),不需要操作系统管理而是jvm管理。切换开销更小,可能只涉及jvm的堆空间。
  2. 增大cpu的吞吐率,适合io密集型任务:在经典的线程模型中,线程在io时会让出cpu。如果每个线程都要io那么cpu的利用率就很低了,会导致资源耗尽的同时硬件资源没有充分利用。但是虚拟线程是运行在平台线程上的,一个虚拟线程io了可以切到另一个虚拟线程,但是平台线程是不会释放的。这样可以用粒度更低的开销更高效的利用线程资源。
  3. 异步改同步,适合阻塞式io:io多路复用是通过一个线程来监听多个socket的状态,通过大量的异步回调实现,机制较为复杂;虚拟线程跟它解决的问题是一致的,都是解决如何增大并发量;但是虚拟线程不需要进行如此复杂的异步回调,用同步操作即可,如果一个线程在io阻塞,jvm无非就是把平台线程的资源切到另一个虚拟线程,免去了复杂的回调。
  4. 更灵活:有个例子我觉得很形象。传统的线程模型可以理解为一排固定死的插座,虚拟线程模型就是插座连接的插排。你要修改插座是需要改墙体改布线的,对应os和cpu的关系,而且还存在就算插排插满了但是功率还上不去的现象;但是如果用虚拟线程也就是插排,你想要多点接口就换个更大的插排,这样你的功率也上去了,对电力的利用率也很高。

select/poll/epoll

  1. select: 每一个socket对应一个fd(文件描述符),用户态给内核态监听,最多长度1024,内核态通过循环的方式监听,一旦有io准备就绪就改变fd状态,然后传输给用户态的时候应用进程还需要遍历一边。也就是说既有长度限制,有需要遍历两次,效率较低。
  2. poll:用链表改变了1024这个数字,其余没区别。还是改变状态让用户态自己去遍历。
  3. epoll:将socket的文件描述符通过红黑树组织起来,查询效率从on变为ologn,此外将循环遍历变成事件回调,有复杂的事件注册函数。触发机制有水平触发和垂直触发,一旦回调函数说socket准备就绪,就把他放在一个队列里面等待返回用户态,水平触发是只要没读完就一直提醒用户态来读,垂直触发是只提醒一次,但是一次需要全部读完。

java虚拟线程和go的gmp协程模型

gmp模型

  1. g是goroutine,每次在调用go func的时候就会产生一个g,也可以理解为协程控制块;m是机器线程,对应了一个真实的被操作系统调度的线程;p是逻辑处理器,必须要一个g通过p绑定到m才可以运行,相当于一个上下文填充器。
  2. 有两个队列:局部运行队列LRQ,每一个p都有一个这个队列,放等待该p作为中介给m运行的g;全局运行队列GRQ,分配尚未给LRQ的m
  3. gmp模型是通过调度的方式把g调度给线程,底层运行的还是线程,是一个M对N模型,这点与java是不同的,java的虚拟线程是通过jvm调度的,运行在平台线程上,而不是哪里线程空了就用哪个线程的资源。
  4. 交接机制:如果一个m上的g在等待io,这个时候中介p挂到别的空闲m上让自己队列里面g使用。使得阻塞不会影响整体的调度。
  5. 窃取机制:每个线程都是工贼,没有任务就要去别的LRQ里面偷,或者去GRQ中偷。

虚拟线程调度模型

  1. Java 的虚拟线程由 Continuation(保存/恢复执行栈)+ Scheduler(Executor) 实现:虚拟线程在运行时被 mount(挂载)到一个 carrier(承载,平台)线程 上执行;遇阻塞时会 yield(保存 continuation 并释放 carrier),阻塞完成或 unpark 时将 continuation 重新提交给 scheduler 以被某个 carrier 线程恢复执行。这个逻辑以 java.lang.VirtualThread + JVM/HotSpot 的 continuation 支持 + JDK core libs 的调度器协作实现。
  2. 虚拟线程也支持交接和窃取,这点是gmp学的
  3. 注意虚拟线程使用synchronized会将虚拟线程pin住,因为这里synchronized是监视器锁,锁住的是线程,如果在虚拟线程用这个会把平台线程锁住,无法参与调度。一般并发低没事,高了就会将虚拟线程退化为平台线程。因此要控制同步操作使用基于jdk的reentrantlock好一点。

软考-基础部分

系统架构设计

系统架构设计概述

  1. 软件架构设计(Software Architecture)解决了需求分析和设计阶段之间的鸿沟,是需求分析与设计阶段的过渡阶段。一个好的系统架构,能够使得系统在面对灾难性错误时不会产生严重的故障。
  2. 软件架构就是需求分配,将满足需求的职责分配到对应的组件上。
  3. 软件架构是软件在结构、行为和属性的高层次抽象。主要由构件的描述、构件之间的联系关系(连接子)、系统继承的模式和一组约束构成。
  4. 软件架构不仅规定了系统的组织架构和拓扑结构,还规定了组件与需求之间的映射关系。
  5. 软件架构解决的核心问题是软件的复用、质量和维护。
  6. 软件架构设计主要有以下三个活动:提出架构模型、进行架构设计、进行架构审核。其中架构设计主要关注软件架构的结构、属性和连接关系,并通过多视图全面描述特定模型的软件架构。
  7. 软件架构在设计更改相对容易的阶段,便于技术人员和非技术人员进行交互,从而展现软件的结构、属性和交互关系。
  8. 软件架构是可传递和可复用的模型。

软件架构和生命周期

  1. 需求分析阶段:传统的需求分析阶段主要产出的是问题空间,而软件架构在此阶段产生解空间。主要研究的问题是:如何将需求分析转化为软件架构SA;以及如何进行跟踪(包括正向跟踪和反向跟踪)
  2. 设计阶段:主要有三个活动:软件架构的描述、对软件架构进行分析和设计、对产生的设计经验进行总结和复用。其中对SA的描述可以有三个层次:SA的基本描述、体系结构描述语言ADL,SA的多视图。
  3. 实现阶段:软件架构的管理,可以使用项目管理工具;软件架构的实现,如何将SA过渡到具体实现,如何将SA的需求分配到对应构件上,可以采用程序设计语言的思想进行设计;软件架构的测试
  4. 组装阶段:SA给予了一个更高层次的蓝图,主要研究构件之间如何相互关联,也就是连接子的实现;此外还要解决这些构件之间的失配问题,包括构件的失配、连接子的失配以及结构产生的失配问题。
  5. 部署:SA可以为部署提供一个更好的解决方案
  6. 后开发阶段:进行软件架构的演化、复用和维护

构件

  1. 构件是一个可以独立部署并交付的功能单元,外界可以通过其接口进行访问。由多个原子构件构成,原子构件由模块和资源构成。原子构件是部署,版本控制和替换的基本单位,原子构件一般可以独立部署,但是不会独立部署。都是按照一组原子构件家族进行部署。
  2. 模块是不带资源的原子构件,一个模块可以包含多个类进行打包。
  3. 构件与对象的区别:构件的特征是可以独立部署且独立交付、作为第三方的组装单元,可以由第三方调用、对外隐藏自己状态;对象的特征是可以一个实例单元且有唯一标识、可以有自己的状态、对外进行封装。
  4. 构件接口规定了构件之间通信的格式、模式和协议,使得接口之间传递消息更具有规范性和一致性。
  5. 面向构件的编程一般要求具有:多态性(可以替换同接口的实现构件)、模块封装性(更高层次的隐藏)、独立部署性、安全性
  6. 目前市面上的主流构件有以下三类:
  • EJB(Enterprise Java Bean)是Sun公司提出的一种在Java平台的企业级构件模型,主要规定了三类构件bean,会话bean,实体bean和消息传递bean。使得企业开发能更加注重业务也就是实现这一些bean,提高工作效率,但是缺点是配置大于约定,配置过于复杂,对程序员要求高且不方便;
  • COM是微软公司提出的,只能在Windows平台进行开发的构建技术,还衍生出DCOM和COM+的技术
  • CORBA(Common Object Request Broker Architecture)公共对象请求代理架构,由OMG公司提出主要分为三个层次:ORB对象请求代理,针对异构的对象实现了不同的接口,定义了一条软总线能够将不同的对象转化成对上层统一的公共对象;公共对象基础服务,这一层在得到对象的基础上进行了一系列公共服务的抽象,例如并发控制、事务控制等;最后一层是对外的公共基础服务,此时可以对外暴露接口提供对象访问的服务了。

架构设计风格

  1. 软件架构设计风格是在特定领域下软件架构设计的惯用模式。主要由软件结构,一个词汇表和一组约束构成。词汇表包含架构中构件和连接件的描述,约束定义了这些组件之间的关系。
  2. 软件架构风格反应了在某特定领域下多种架构设计的共有结构和语义特性,能够指导子模块和子系统的有效集成和组成一个更大的系统。研究软件架构风格促进软件复用,能够将该领域已有的架构解决方案迁移到解决新的问题上。
  3. 软件复用是研究软件架构的一个核心问题
  4. 架构风格种类:数据流、调用-返回、独立构件、仓库、虚拟机

数据流风格

  1. 批处理风格:构件是一系列顺序排列的计算单元,构件之间通过数据进行连接,每个构件必须由上个构件处理完成之后才能开始处理,数据的传输是完整的。
  2. 管道-过滤器风格:每个构件都有输入和输出,构件读取输入的数据流,经过处理得到输出数据流。要求数据流顺序,输入必须是上一个构件产生的输出流,可以把并行。过滤器为构件,管道为连接子。一般出现在早期的编译器软件架构中。

调用-返回风格

  1. 主函数-子函数风格:单线程模型,将复杂问题分解为可以单独处理的子问题。子函数解决子问题,通过主函数进行调用。函数为构件,相互调用为连接子。
  2. 面向对象风格:对象即为构件,对象之间的相互作用,例如函数之间的方法调用和过程调用为连接子。
  3. 层次架构:构建为每一个层次,连接子为每个层次之间的调用关系。每一层都进行封装,为上一层提供接口,同时使用下一层的服务。修改层次时只会影响相邻的两层。这样的优点是:将一个复杂问题分解为若干的子问题;越靠近底层越抽象;可复用。缺点是:调用无法跨层,效率底;也不一定所有的系统都能划分为层次结构。

独立构件风格

  1. 进程通信:命名进程是构件,进程之间相互作用为连接子。一般有三种调用方式:点对点、同步/异步方式,rpc
  2. 隐式调用(事件驱动):构件不直接调用过程,而是通过触发或者广播一到多个事件。构件的过程可以由多个触发源进行定义,当构件接收到触发,就会使用已经定义好的函数过程进行调用,从而达到隐式和解耦的功能。这样的优点是增大了软件的复用性,缺点是放弃了软件调用过程的控制权。

虚拟机风格

  1. 解释器风格:由解释引擎,存放被解释程序的缓存区,存储解释引擎解释状态的数据结构和程序解释执行的具体位置的存储器构成。优点是规则可以自定义,缺点是慢
  2. 基于规则的系统:规则集,规则解释器,规则选择器,工作内存。一般用在ai和dss

数据风格

  1. 数据库风格:两个构件,数据库和多个独立的数据处理单元
  2. 黑板风格:知识源,黑板,控制。多个独立的知识处理单元,可以操作黑板进行演算。黑板是一个集中式数据库,可以通过知识源进行修改。一般适用于嵌入式系统、语音识别系统等复杂的没有固定解的系统
  3. 超文本风格:基于网络的

层次架构风格

  1. 两层C/S:表示层和数据层,表示层也可以存储数据,还要保证一致性,现已不用
  2. 三层C/S:表示层、功能层和数据层,表示层仅作展示。优点:管理更清晰;可以并行开发;可以选择适应的环境和开发平台;保持了逻辑独立性
  3. 三层B/S:客户端变成浏览器,访问慢了,安全性也不强
  4. 富互联网应用:小程序,看上去没有客户端,但是通过高速网络现场搭建,因此综合了B和C的优点
  5. MVC架构:模型、控制、视图。模型直接与视图交互,不安全
  6. MVP架构:模型、表示、视图。切断了模型与视图的直接交互,更安全

特定领域软件架构

基本概念

  1. 特定领域软件架构DSSA(Domain Specific Software Architecture)是一组在特定领域下的软件开发构件集合,是一组在特定领域下获取系统的参考结构,参考需求和领域模型的开发环境,目标就是生成一个或多个领域应用。
  2. 水平域和垂直域
  3. 三个基本活动:领域架构分析:目标是获取领域模型,找领域专家来定义领域范围和定义领域特定元素;领域架构设计:目标是获取DSSA;领域架构实现:软件架构重用
  4. 四个人员:领域专家、领域分析专家、领域设计专家、领域实现专家
  5. 五个过程:定义领域范围;定义领域特有的元素;定义领域特有的设计和约束;定义领域模型和实现;搜集并开发可重用元素
  6. 四个特征:有明确的问题域和解决域;适当程度的抽象;有可重用元素;在领域内有普遍性
  7. 三个开发环境和五个产出:领域开发环境、特定领域下的应用开发环境,应用实际环境。产出:领域结构,领域需求,架构,开发工具,领域模型。

基于架构的软件开发方法

  1. ABSD(Architecture Based Software Development)是由架构驱动,强调由软件业务,质量和功能需求指导软件架构设计。用视角和视图来描述软件的软件架构,以用例和质量属性场景来描述软件的需求。
  2. 使用ABSD方法可以在软件模型定义明确之后立刻进行设计
  3. 三个基础:功能的分解,需求分解到对应组件;软件架构风格;可重用的组件,软件模板
  4. 六个阶段:架构需求、架构设计、架构文档化、架构复审、架构实现、架构演化
  5. 架构需求三步:定义类图,类进行归类,产生包
  6. 架构设计五步:提出架构模型,需求切分,构件之间联系,产生架构,架构评审
  7. 架构文档化两个文档:架构规格说明书,测试架构需求的质量设计说明书

软件质量属性

  1. 质量属性分为开发时和运行时
  2. 八个质量属性和提升方法:
    • 性能:优先级队列,并发,增加计算资源,减少开销
    • 可用性/可靠性:容错容灾,心跳,ping\echo,选举
    • 安全性:入侵检测,用户认证,用户权限控制,追踪审计
    • 可修改性
    • 互操作性
    • 功能性
    • 可变性
  3. 质量属性场景六个要素:刺激源,刺激,场景,制品,响应,响应度量
  4. 敏感点:为了实现某种特定的质量属性,一个或多个构件所具有的特性
  5. 权衡点:影响多个质量属性的决策点,是多个质量属性的敏感点。

杂谈-下一步的方向

立个flag,我需要在9月份之前完成这些事情:

  1. 论文idea和实验:学习pytorch和图神经网络,可能还得学一些信号处理。(ddl8月份)
  2. i茅台复现:主体自己实现一下,tidb可以先不用,用sharding-jdbc分片即可(ddl5月份)
  3. go语言相关:做一个轮子类项目(分布式kv或者MIT6.多少多少的raft),以及一个业务类项目(im即时通讯或者流媒体),这部分其实看情况,也不是一定需要转go(ddl6月份)
  4. 大模型相关:复现gpt2(ddl5月份),继续学习rag的相关工程,主要放在并发上(ddl8月份)
  5. unity:不是主要工作,只给五一假期几天去尝试一下

ProjectMygo!!!!!-go的基本语法

实习了一段时间深感java已经不太行了,转go才是出路

go

变量,结构与函数

变量

与java不同,go可以不显示声明变量数据类型。一般来说有:

  • 整数型int,int8,int16,int32,int64,无符号整型uint,uint8,uint16,uint32,uint64
  • 浮点型:float32,float64,相当于单精度浮点型float和双精度浮点型double
  • 布尔:true和false,值得一提的是bool赋初始值的时候默认false
  • 字符串:string直接就是一个数据类型了,还有一个rune,后续再去了解

很重要的一点是go语言的变量声明了就必须得用,而且最后不用加分号。下面是声明数据类型的例子,有:=可以代替var进行类型推断,可以同时推断多个类型(但我觉得还是显示声明类型比较好,否则一个函数返回来怎么判断类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
var age_1 uint8 = 31
var age_2 = 32
age_3 := 33
fmt.Println(age_1, age_2, age_3)

var age_4, age_5, age_6 int = 31, 32, 33
fmt.Println(age_4, age_5, age_6)

var name_1, age_7 = "Tom", 30
fmt.Println(name_1, age_7)

name_2, is_boy, height := "Jay", true, 180.66
fmt.Println(name_2, is_boy, height)

常量也是类似,可以进行类型推断,但是必须赋初始值,且一旦定义了就不能改变了,类似java中的private static final int = 1;这种

函数与判断结构

go的函数与主流编程语言类似,但是估计不分static和非静态,也是给出参数列表和返回值。但是这里可以返回多个变量,这一点应该会比java好

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var numerator int = 11
var denominator int = 2
var result, remainder int = intDivision(numerator, denominator)
fmt.Println(result, remainder)

}
func intDivision(num1, num2 int) (int, int) {
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder
}

注意这里的异常处理,与java中try-catch的思想不同,函数在返回的时候也会给一个error返回值,外部调用通过error是否为nil来判断函数执行是否出错。这是一个广泛使用的设计思想,后续可能需要遵守。

例如这里的除数为0的例子,当除数为0相当于要抛出异常,用errors包下的一个函数throw new RunTimeException(),再在主函数去判断这个error是否为空,为空说明没有抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"errors"
"fmt"
)

func main() {
var numerator int = 11
var denominator int = 0
result, remainder, err := intDivision(numerator, denominator)
//执行正常
if err == nil {
fmt.Println(result, remainder)
} else {
fmt.Println(err)
}

}
func intDivision(num1, num2 int) (int, int, error) {
var err error
if num2 == 0 {
err = errors.New("num1 is zero")
return 0, 0, err
}
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder, err
}

最后提一下go的判断结构,if后面的括号必须贴着同一行,else的哪一行必须写成”} else {“,否则编译器会报错,此外switch语句不需要写break了

数组,切片和哈希表

数组

go中的数组跟java的数组很像,但是go的数组可以操作指针。数组的大小在声明的时候就已经固定,如果想用跟ArrayList那样的动态数组,请使用slice切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var intArr [10]int32
for i := 0; i < len(intArr); i++ {
intArr[i] = int32(i + 1)
}
//左闭右开,这个就是下标为456的三个元素
fmt.Println(intArr[4:7])

//地址
for i := 0; i < len(intArr); i++ {
fmt.Println(&intArr[i])
}
//这里输出结果是连续的4B,说明经典数组是连续分布的
}

值得注意的是这里数组如果传入的是形式变量,需要传地址,跟c语言一样,否则就只会改变形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var arr = [5]int{1, 2, 3, 4, 5}
withAddress(&arr)
fmt.Println(arr)//[1 20 3 4 5]
noAddress(arr)
fmt.Println(arr)//[1 20 3 4 5]
}

func withAddress(a *[5]int) {
a[1] = 20
}
func noAddress(a [5]int) {
a[3] = 20
fmt.Println(a)//[1 20 3 20 5]
}

切片slice

基本上跟ArrayList的机制一样,长度和容量,如果到了设定阈值就会动态扩容。如果能够预估业务数据量,在构造slice的时候直接指定容量可以免去动态扩容的开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import "fmt"

func main() {
sliceDynamic()
}

// 静态方法创建
func sliceStatic() {

var slice []int32 = []int32{1, 2, 3}
//length is 3, with capacity is 3
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
slice = append(slice, 4)
//length is 4, with capacity is 6
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
fmt.Println(slice)
}

// 动态方法创建
func sliceDynamic() {
//可以指定构造长度和容量,这里构造了一个长度为3,容量为20的slice
var intSlice []int32 = make([]int32, 3, 20)
fmt.Printf("length is %v, with capacity is %v\n", len(intSlice), cap(intSlice))
//前三个元素是初始化了的,后面没有
for i := 0; i < len(intSlice); i++ {
fmt.Println(intSlice[i])
}
//就跟ArrayList一样,如果业务能够预估动态数组的长度,最好还是构造的时候就提前设定好
//否则会频繁进行扩容,影响效率
}
//取数逻辑。左闭右开,跟java中subString类似
func slicePartition() {
sli := []int{1, 2, 3, 4, 5, 6}
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli), cap(sli), sli)

fmt.Println("sli[1] ==", sli[1])
fmt.Println("sli[:] ==", sli[:])
//sli[1]->sli[len-1]
fmt.Println("sli[1:] ==", sli[1:])
//sli[0]->sli[4-1]
fmt.Println("sli[:4] ==", sli[:4])
//sli[0]->sli[3-1]
fmt.Println("sli[0:3] ==", sli[0:3])
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli[0:3]), cap(sli[0:3]), sli[0:3])
}

循环

go语言中没有while循环(反正我也没经常用),对于slice和map需要特别注意,range关键字会在遍历这两个数据结构的时候进行处理。例如slice通过range关键字的时候会有index和value两个值,不需要index则直接”_”,跟python相似;同理map会遍历出key和value

这里就顺带把map提一下,map[key]value,这样的结构,查询一个元素直接括号里面找,注意找不到也会返回0这个默认值,所以以后用到的时候可能需要判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func sliceWithClass() {
var teachers []Teacher = make([]Teacher, 0)
teachers = append(teachers, Teacher{"yangyifan", 12})
fmt.Println(teachers)

var teacherMap map[string]Teacher = make(map[string]Teacher)
teacherMap["yangyifan"] = Teacher{"yangyifan", 12}
fmt.Println(teacherMap["yangyifan"])
teacherMap["xuxuanyan"] = Teacher{"xuxuanyan", 12}
fmt.Println(teacherMap["xuxuanyan"])
delete(teacherMap, "xuxuanyan")
fmt.Println(teacherMap["xuxuanyan"])

//遍历数组的时候返回两个值,一个是index一个是值
//不需要的就直接_
for _, teacher := range teachers {
fmt.Println(teacher)
}
//遍历map的时候每次都会得到两个值,一个是key一个是value
//这里只希望返回所有的value前面的key就用_代替
for _, teacher := range teacherMap {
fmt.Println(teacher)
}
}

string与rune

在go中string的底层是一个字节数组,采用utf8编码,由于utf8是不固定长度的,一般来说汉字都会占3B。所以直接去用len一个string数组长度返回的是字节数量,有两种遍历方式,一种是直接遍历len,这样会返回每一个未解码的utf8字节,比如一个汉字“大”,占三位,用普通遍历就会返回这三个字节的初始值;但是如果用range关键字,他会帮我们做一些处理,把这三个字节解码拼成一块,就会返回真实的字符,但这样前面的index仍然不准确。

如果采用rune就是我们直觉上的遍历字符数组了,例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package main

import "fmt"
import "strings"

func main() {
//stringByte()
runeSlice()
stringBuilder()
}
func stringByte() {
var str1 = "大连理工大学"
char := str1[0]
fmt.Println(char)
//大连理工大学 has 18 character
fmt.Printf("%v has %v character\n", str1, len(str1))

//index: 0, char:å
//index: 1, char:¤
//index: 2, char:§
//index: 3, char:è
//index: 4, char:¿
//index: 5, char:ž
//index: 6, char:ç
//index: 7, char:
//index: 8, char:†
//index: 9, char:å
//index: 10, char:·
//index: 11, char:¥
//index: 12, char:å
//index: 13, char:¤
//index: 14, char:§
//index: 15, char:å
//index: 16, char:­
//index: 17, char:¦
//这里出现乱码的原因是字符串底层是一个字节数组结构,
//而一个汉字在utf8中占3个字节,他把每一个字节的内容都输出,就不会组成一个完整的汉字
for i := 0; i < len(str1); i++ {
fmt.Printf("index: %d, char:%c\n", i, str1[i])
}

//如果用range关键字就会帮我们把字符串的utf8解码
//index: 0, char:大
//index: 3, char:连
//index: 6, char:理
//index: 9, char:工
//index: 12, char:大
//index: 15, char:学
//这里也可以看到跳过了一些index
for index, v := range str1 {
fmt.Printf("index: %d, char:%c\n", index, v)
}

}
func runeSlice() {
var runeSlice = []rune("大连理工大学")
//index: 0, char:大
//index: 1, char:连
//index: 2, char:理
//index: 3, char:工
//index: 4, char:大
//index: 5, char:学
//这里的index就是顺序的了
for i := 0; i < len(runeSlice); i++ {
fmt.Printf("index: %d, char:%c\n", i, runeSlice[i])
}

}

此外一些string操作都在strings这个包下面,例如stringBuilder和其他的一些字符串操作,需要的时候导入。

结构体与接口

go应该是一个面向过程的语言,这里采用的还是结构体,但是类似的也有接口和类方法,在实现go中的接口时,不需要有java那种implements,编译器搜索所有有该方法签名的结构体自动绑定;在实现类方法时,需要在方法名前绑定结构体。例子如下所示,有一个engine接口,两个实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import "fmt"

// Engine 接口
type Engine interface {
milesLeft() uint8
}

type GasEngine struct {
mpg uint8
gallons uint8
}

func (ge GasEngine) milesLeft() uint8 {
return ge.mpg * ge.gallons
}

type ElectricEngine struct {
mpkwh uint8
kwh uint8
}

func (ee ElectricEngine) milesLeft() uint8 {
return ee.mpkwh * ee.kwh
}

func canMakeIt(engine Engine, remainMiles uint8) bool {
if engine.milesLeft() < remainMiles {
return false
} else {
return true
}
}
func main() {
var milesLeft uint8 = 60
var ge GasEngine = GasEngine{10, 20}
fmt.Println(canMakeIt(ge, milesLeft))
var ee ElectricEngine = ElectricEngine{10, 5}
fmt.Println(canMakeIt(ee, milesLeft))
}

网易KM社区分享-小P老师服务GC卡顿定位解决

小P老师作为有道AI大模型的重点服务,稳定性与低延迟至关重要。但随着开学季到来,接口流量增加,服务偶现请求延迟升高和GC卡顿异常重启现象。
本文总结上述问题的排查思路及定位过程,相信对遇到内存泄漏、CPU升高、JVM GC异常等问题的小伙伴,有借鉴意义。

一、背景和现象

小P老师服务(java服务容器部署、jdk17、G1回收器)核心任务是提供教育场景下的大模型对话式问答。随着开学季到来,流量也逐渐上升,保障服务稳定性是比较重要的任务之一。

大模型对话式问答通常是一个流式过程,模型回答是一段一段输出给用户的,为了观察到整个模型的延时情况,大模型回答完毕的时间(total time)以及大模型每一段回答的时间(interval time)都添加了监控。

近期发现,小P老师服务里子曰大模型interval time的监控总是超时告警,但是子曰大模型自身的interval time监控确实正常的,同时很奇怪的是只有一个或者部分容器pod出问题。

这两个监控有什么区别呢?简单来说一个是A使用B时对B的监控,另外一个是B对自身的监控,所以理论来说他两监控应该基本一致才是符合预期的(抛去网络延时)。

从这一现象看,说明小P老师本身代码逻辑存在耗时情况或者网络有问题。

另外之前小P也出现过类似情况,我们使用了Huggingface去做大模型token计算,这个组件cpu占用率很多,所以按照之前惯例会查看cpu是否够用。
img.png

img_1.png

图1 容器cpu使用图

img_2.png

图2 jvm监控图

于是发现了图1这样的现象,在容器cpu监控图中发现在服务告警期间cpu usages(使用量)和cpu cfs throttled(抢占)有尖刺。同时也是机缘巧合,想看看jvm里cpu使用占用率多少,于是在图2(黄色线是分配的内存、绿色线是使用的内存)发现了比较重要的一个信息,jvm在这期间eden区分配降低,old区使用、分配激增,维持了一段时间后就自行恢复了。于是我便去查看了一段时间内的GC日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2024-10-23T20:23:56.727+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause)
[2024-10-23T20:23:56.727+0800] GC(77) Using 1 workers of 1 for evacuation
[2024-10-23T20:24:02.147+0800] GC(77) MMU target violated: 201.0ms (200.0ms/201.0ms)
[2024-10-23T20:24:02.147+0800] GC(77) Pre Evacuate Collection Set: 12.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Merge Heap Roots: 56.7ms
[2024-10-23T20:24:02.147+0800] GC(77) Evacuate Collection Set: 5334.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Post Evacuate Collection Set: 15.4ms
[2024-10-23T20:24:02.147+0800] GC(77) Other: 0.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Eden regions: 482->0(63)
[2024-10-23T20:24:02.147+0800] GC(77) Survivor regions: 17->13(63)
[2024-10-23T20:24:02.147+0800] GC(77) Old regions: 32->32
[2024-10-23T20:24:02.147+0800] GC(77) Archive regions: 2->2
[2024-10-23T20:24:02.147+0800] GC(77) Humongous regions: 473->456 // 标记1
[2024-10-23T20:24:02.147+0800] GC(77) Metaspace: 156861K(158080K)->156861K(158080K) NonClass: 139277K(139840K)->139277K(139840K) Class: 17583K(18240K)->17583K(18240K)
[2024-10-23T20:24:02.147+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause) 4018M->2007M(6144M) 5419.626ms // 标记2
[2024-10-23T20:24:02.147+0800] GC(77) User=5.08s Sys=0.20s Real=5.42s // 标记3

确实发现了异常的点,标记1可以看出Humongous regions数量非常多且这一次GC并没有回收该区域的内容。(Normal GC是会回收Humongous区域的)

标记2可以看出整个GC耗时大概5.4s,当然从标记3可以更清楚的看出GC耗时,所以我们猜测子曰大模型interval time告警可能和GC耗时过久有关系。

至此我们整合一下问题现象:

  • 小P老师服务对子曰大模型的延时监控发生告警,且与子曰大模型自身监控不一致
  • 只有一部分pod有问题
  • 告警期间服务cpu使用率激增
  • 告警期间jvm内存eden区域分配减少,old区域使用、分配激增,一段时间后恢复

Humongous regions回收不明显,GC停顿过长

根据上述现象,我们可以判断出服务延时告警时和GC有关系,也就是需要从内存的角度来分析为什么GC会停顿这么久,可以算是一个切入点。

分析内存有一个得力工具MemeoryAnayzer(MAT),接下来会先重点介绍一下这个工具,同时也会介绍在jdk17中的G1垃圾回收器。当然如果对此熟悉的可以直接跳过看定位过程

二、Garbage-First (G1) 垃圾回收器

引用文章

Garbage-First (G1) 垃圾收集器针主要对大内存多核的服务,目的是实现应用程序和环境在延迟和吞吐量之间的最佳平衡。

特点:

  • 服务堆大小大于10GB。
  • 对象分配和对象移动的速度可能会随着时间的推移而发生很大变化。

    Rates of object allocation and promotion that can vary significantly over time.

  • 堆中存在大量碎片。
  • 可预测的暂停时间目标不超过几百毫秒,避免长时间的垃圾收集暂停。

2.1 基本概念

G1 将堆分为年轻代(young)和老年代(gen)。空间回收工作集中在最高效的年轻代上,偶尔也会在老年代进行空间回收。

G1 首先回收最高效区域的空间(即大部分被垃圾填充的区域,因此得名)。

G1 主要通过撤离(evacuation)来回收空间:在选定的内存区域找到存活对象复制到新的内存区域,并在过程中对其进行压缩。撤离完成后,先前的空间可用来重新分配。

G1不是实时收集器。尝试尽可能在设定的暂时时间下完成回收,但对于给定的暂停,不能保证绝对满足。

2.2 堆布局

img_3.png

图3 G1垃圾回收器

年轻代包含eden区域(红色)和survivor区域(红色,带“S”)。这些区域内部是连续的,但在G1中这些区域通常以非连续模式排列在内存中。old区域(浅蓝色)构成老生代。对于跨多个区域的对象,会有一个非常大的old区域(浅蓝色,带“H”),叫做Humongous区域 。

应用程序总是分配到年轻代,即eden区域,巨大对象被分配到old区域。

2.3 垃圾回收周期

G1 收集器在两个阶段之间交替。young-only阶段包括垃圾回收(garbage collections),这个阶段会逐渐填满当前可用的内存

空间回收阶段是 G1 除了处理年轻代之外,还会逐步回收老生代中的空间。然后,循环从年轻代阶段重新开始。

img_4.png

图4 垃圾回收周期预览

以下列表详细描述了 G1 垃圾收集周期的各个阶段、暂停以及阶段之间的转换:

  1. 仅年轻代阶段(Young-only phase):此阶段以Normal young collections收集开始,会将对象提升到老年代。当老年代占用率达到某个阈值时,Young-only phase和Space-reclamation phase之间的过渡就开始了。此时,G1 会执行Concurrent Start young collection,而不是Normalyoung collections。

    • Concurrent Start:这种类型的收集除了执行常规Normalyoung collections,还启动标记过程。并发标记确定old区域中的是否可以被回收。在收集标记尚未完全完成时,可能会发生Normalyoung collections。

    • Remark:此这段会完成重新标记。

    • Cleanup:这个阶段决定是否进行Space-reclamation phase。如果确定进行Space-reclamation phase,那么Young-only phase就会进行一次Prepare Mixed young collection.

  2. 空间回收阶段(Space-reclamation phase):此阶段会进行Mixed collections,除了young区域外,还会撤离old区域中的存活对象。当 G1 确定撤离更多老生代区域不会产生足够的可用空间时,空间回收阶段结束。

  1. Young-only phase: This phase starts with a few Normal young collections that promote objects into the old generation. The transition between the young-only phase and the space-reclamation phase starts when the old generation occupancy reaches a certain threshold, the Initiating Heap Occupancy threshold. At this time, G1 schedules a Concurrent Start young collection instead of a Normal young collection.
  • Concurrent Start : This type of collection starts the marking process in addition to performing a Normal young collection. Concurrent marking determines all currently reachable (live) objects in the old generation regions to be kept for the following space-reclamation phase. While collection marking hasn’t completely finished, Normal young collections may occur. Marking finishes with two special stop-the-world pauses: Remark and Cleanup.
  • Remark: This pause finalizes the marking itself, performs global reference processing and class unloading, reclaims completely empty regions and cleans up internal data structures. Between Remark and Cleanup G1 calculates information to later be able to reclaim free space in selected old generation regions concurrently, which will be finalized in the Cleanup pause.
  • Cleanup: This pause determines whether a space-reclamation phase will actually follow. If a space-reclamation phase follows, the young-only phase completes with a single Prepare Mixed young collection.
  1. Space-reclamation phase: This phase consists of multiple Mixed collections that in addition to young generation regions, also evacuate live objects of sets of old generation regions. The space-reclamation phase ends when G1 determines that evacuating more old generation regions wouldn’t yield enough free space worth the effort.

空间回收后,收集周期将以另一个年轻阶段重新启动。作为兜底,如果应用程序在收集活跃度信息时内存不足,G1 将像其他收集器一样执行就会执行Full

2.4 垃圾回收阶段和回收集

garbage Collection Pauses and Collection Set

G1执行垃圾收集和空间回收是在stop-the-world pauses时间内完成的,存活的对象会从堆的一个区域移动到另一个区域,并且对这些对象的引用也会调整。

对于non-humongous的移动:

  • 年轻一代(eden和survivor)的对象被复制到survivor区域或old区域,取决于它们的年龄。
  • 来自old的对象被复制到其他old

对于大对象来说,除非被回收不然永远不会被移动。

对于回收集(collection set):

  • 在 Young-Only ,回收集仅由年轻一代的区域以及可能被回收的巨大区域组成。
  • 在空间回收(Space-reclamation)阶段,回收集由年轻代中的区域、包含可能被回收的对象的巨大区域、以及来自收集集合候选区域的一些老生代区域组成。

G1 在并发周期(concurrent cycle)内准备回收集候选区域。在Remark pause,G1 选择大量闲置空间的低利用率区域。然后在 Remark 和Cleanup pause之间并发准备这些区域以供以后收集使用。Cleanup pause根据效率对准备的结果进行排序。更高效的区域是说,有更多的空间并且回收的时间更少。mixedcollections会更喜欢这些区域。

三、MemeoryAnayzer(MAT)

https://eclipse.dev/mat/

3.1 重要概念

3.1.1 可达性

可达

这个对象仍然有地方引用着他

不可达

这个对象没有任何对象被引用

3.1.2 Shallow 与Retained Heap的区别

Shallow 是一个对象所消耗的内存。对象每个引用需要32或64位(取决于操作系统体系结构),每个Integer需要4字节,每个Long需要8字节,等等。

Shallow heap is the memory consumed by one object. An object needs 32 or 64 bits (depending on the OS architecture) per reference, 4 bytes per Integer, 8 bytes per Long, etc. Depending on the heap dump format the size may be adjusted (e.g. aligned to 8, etc…) to model better the real consumption of the VM.

X的Retained set表示当X被GC垃圾回收后需要移除的对象列表

Retained set of X is the set of objects which would be removed by GC when X is garbage collected.

X的Retained heap是Retained set里所有对象的Shallow大小

Retained heap of X is the sum of shallow sizes of all objects in the retained set of X, i.e. memory kept alive by X.

通俗的来说,Shallow 是这个对象的大小,Retained heap是这个对象被回收之后内存释放的大小

img_5.png

图5 对象引用图以及Retained Set

3.1.3 Dominator Tree

MAT提供了对象图的Dominator Tree,将对象引用图转化为Dominator Tree能够轻松识别保留内存的最大块以及对象之间的依赖关系,下面是一些定义

  • X dominates Y,表示在对象图中,每一个去Y的路径上都需要经过X。
  • X是Y的immediate dominator ,表示X是距离Y最近的支配者
  • dominator tree 是由对象图直接构建而来,能够展现一个对象的immediate dominator

图6是将对象图(左侧)构建为dominator tree (右侧)

img_6.png

图6 对象引用图以及Retained Set

通俗的来说,X dominates Y表示,如果X被回收那么Y一定被回收。但我们常说的引用,如果X引用Y,那么Y是不一定会被回收的,因为Y有可能被Z引用。这就是为什么MAT引入 Dominator这个概念。

3.2 常用功能

3.2.1 Histogram

Histogram列举出每一个class的对象数量以及他的shallow size和retained size,可以快速找出大的对象类

img_7.png

图7 Histogram列表

默认情况下retained size展示的是估算值,也可通过计算才获取他的准确值。

img_11.png

图8 Histogram计算准确retained size

可以查看对象被谁引用或者他又引用了谁

img_10.png

图9 Histogram查看引用关系

img_9.png

图10 Histogram查看引用关系结果

Histogram默认是通过class是分组的,也可以根据包或者加载器

img_8.png

图11 Histogram通过其他类型分组

3.2.2 Dominator Tree

Dominator tree展示了在堆中最大的对象列表。X对象的下一级表示,X被回收之后需要被垃圾回收的对象列表。(也就是X直接支配的对象)同样也可以按类加载器、包进行分组。

The next level of the tree lists those objects that would be garbage collected if all incoming references to the parent node were removed.

img_12.png

图12 Dominator Tree

以上图为例,占用堆内存最大的是TaskThread的http-no-8080-exec-2线程,其本身大小是Shallow Heap是120字节,Retained Heap是2417669960字节,占用整个堆内存94.90%。图中将AspectJExpressionPointcut展开,表示当AspectJExpressionPointcut被内存回收之后,展开列表里的所有对象都会被回收,也就是他的retained set

3.2.3 Immediate Dominators

可以快速找出当前这组(类/对象)的所有immediate dominator(直接支配者)

img_13.png

图13 Histogram找某个类的immediate dominator

下列展现支配Object[]的类列表

img_14.png

图14 Object[]类的immediate dominator

其中所选的那一行表示,TaskThread一共有37个对象,其中支配了133个Object[],并且TaskThread的本身对象大小(shallow size)是4440bytes,他支配的Object[]是2147491680bytes的大小

3.2.4 Leak report

Leak report会列举出可能存在内存泄漏的点,以及发生的栈信息位置

img_15.png

图15 Leak report

四、定位过程

根据在第一节所观察到的问题现象,我们从内存角度来分析GC停顿之间为何这么久?按照惯例,通常都会看一下内存中的大对象,因为大对象一般是造成内存出现问题的罪魁祸首,并且大对象也是最容易发现的。

4.1 查看大对象

jmap -hsito [pid] | head -n [num]

img_16.png

图16 小P老师服务某时刻大对象

大部分服务大对象前列就是byte、int等基本类型(不同的jdk版本可能会不同),也看不出什么门道。

通常先重点关注项目自己的包,再看一些引用的包。图16已经圈出了一些比较可疑的对象,但类比了同类稳定服务,第10行对象也是存在且现象一致的,于是就暂时排除他的嫌疑。

接下来就是12、13行这两个对象,他们用来做流式场景下线程之间上下文的自动传递,在github上看有人也提出了使用该组件的内存问题,我们把他列为可疑对象。

再接着就是20行这个对象,他是之前讲到的Huggingface组件,用来做大模型token计算。这个组件cpu占用率很高(之前性能自测过,图17)。那有没有可能在某个时刻计算量很大导致cpu激增,而容器分配的cpu不够用(而我们也确实发生了cpu抢占的情况),导致长期持有jvm对象而无法回收带来的GC卡顿,所以我也把他列为了可疑对象。

接下来我们来验证猜想。

img_17.png

图17 Huggingface组件性能测试cpu、内存使用情况

4.1.1 验证猜想

我们将图16中,12、13行对象涉及的组件以及20行对象涉及的组件,分别打开/关闭来做性能测试,看 GC和jvm是否有明显变化,但当时并没有发现带来明显的jvm变化以及GC卡顿问题。那么问题可能出现在其他大对象上,这时候就需要把堆内存dump下来做分析了。

4.2 内存dump

根据我们之前观察的现象,old区域激增,一段时间后回落,这不太符合内存泄漏的现象,可能就是大对象被长期持有无法释放,于是在dump内存时,选择将堆里的对象全部dump而不仅仅是存活的对象。

jmap -dump:live,format=b,file=dump.hprof [pid]

4.3 使用MAT工具分析

下载地址

注意一般堆文件多大,MAT内存就需要分配多大,修改方式参考

MAT工具通常我们可以使用他从这几个角度分析:

  • 堆内存中的大对象有些什么?
  • 这些大对象为什么没被回收?看他的支配者:immediate Dominators,看他的GC root
  • 这些大对象为什么这么大?看他支配了谁:retained set

4.3.1 导入堆文件

img_18.png

图18 堆文件导入示意图

4.3.2 查看大对象

使用Histogram查看大的对象(类),根据Retained Heap来排序(点击Retained Heap按钮就可以排序)

img_19.png

图19 堆文件大的对象(类)列表

发现最大的类是java.lang.object[],是一个数组,于是按照刚才思路我们先看他为什么没被回收?就看他的支配者。

4.3.3 查看大对象支配者

尝试看下这个大对象的支配者,看看是不是因为这个支配者应该被回收但是没被回收。

图20发现java.lang.object[]最大的支配者是TaskThread这个类,一共有37个对象实例,支配了133个java.lang.object[],TaskThread类本身大小是4440bytes,支配的对象java.lang.object[]大小是2147491680bytes。

其实看到这里已经没有意义了,因为他是处理http请求的线程,是不可能被回收的,但我们看一下这个TaskThread的GC Root ,看是否是被不小心创建出来的而没释放。

img_20.png

图20 java.lang.object[]的支配者

4.3.4 查看GC root

一般来说查看Gc root时都会选择 exclude weak/soft references,因为这两个引用肯会被GC掉,这是用来查内存泄漏的,但我们场景是对象是被长时间持有段时间无法回收,而不是一直无法回收。所以这里选择展现了所有的references。

img_21.png

图21 查看TaskThreadGC root示意图

从图22来,TaskThread都是tomcat创建的线程用来处理http请求的,http-nio-8080-exec-2支配了很大的对象,那就是刚才java.lang.object[],这种被线程支配的对象,大概率是临时变量,也就是方法栈里创建出来的变量,http-nio-8080-exec-2是不可能被回收的。

img_22.png

图22 TaskThreadGC root

但是临时变量的回收,会在方法执行完,对他引用没有了之后进行。因为我们dump某一个时刻的堆栈信息,可能线程没有执行完,没被回收也是正常的。但是在http所有的线程中,只有这个线程持有很大的对象明显是不合理。于是我接着看 java.lang.object[]对象为什么这么大?

4.3.5 查看retained set

img_23.png

图23 查看java.lang.object[]Retained Set示意图

查看java.lang.object[]Retained Set可以看出他支配了哪些对象/类,就可以知道他为什么这么大(retained set是包含本身的)

img_24.png图22.png

图24 java.lang.object[]Retained Set

从图24可以看出,在其所有支配的对象中,其本身是最大的,到这里好像陷入了死结。

这个对象被谁支配?是一个线程。这个对象为什么这么大?是因为他本身就很大。

但回想起刚才说的,这个对象被http线程支配,因为线程没有执行完,引用没消失所以一直存在,于是我就想能不能看一下这个线程的栈信息,正好MAT中也有这样的功能。

4.3.6 查看栈信息

img_25.png

图25 所有线程的栈信息

从图25来看,http-nio-8080-exec-2占用了很大的retained heap,就接着点开来看就是整个线程的堆栈情况(不排序的话默认就是执行路径)

img_26.png

图26 http-nio-8080-exec-2堆栈信息

看堆栈信息,一般来说是从上到下找到首个业务代码进行分析,从图26可以看出从业务代码ChatManagerImpl.java:300处添加一个元素到列表,最后触发了容器扩容,最终导致OutOfMemoryError。并且这个线程在执行copyOf时持有很大的内存大小Max Local Retained Heap(本地变量保留大小),已经定位到业务代码了,接下来就根据业务代码去看看原因。

4.3.7 跟踪业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public List<ChatInfoDO> getChatInfoHistory(String userId, String taskId, Long parentChatId,
Integer groupLevelCount) throws LlmBusinessException {
// 根据chat_group_level粗筛(只取最近的chatCount个level)
List<ChatInfoDO> chatInfoDOList = chatInfoDOMapper.selectChatHistory(userId, taskId, parentChatId,
groupLevelCount);
if (chatInfoDOList == null || chatInfoDOList.size() == 0) {
throw new LlmBusinessException(ErrorCode.USER_WRONG_CHAT_HISTORY);
}
// 根据 parentChatId 串起 chatHistory 返回,此时是逆序的
Map<Long, ChatInfoDO> chatIdMap = new HashMap<>();
for (ChatInfoDO chatInfoDO : chatInfoDOList) {
chatIdMap.put(chatInfoDO.getChatId(), chatInfoDO);
}
List<ChatInfoDO> history = new ArrayList<>();
Long chatId = parentChatId;
// 逆序查找,从最后一条对话chatId开始,继续条件:chatId=当前parentChatChatId(子节点找父节点)
ChatInfoDO chatInfoDO;
while ((chatInfoDO = chatIdMap.get(chatId)) != null) {
history.add(chatInfoDO); // 标记1 问题代码处
chatId = chatInfoDO.getParentChatId();
}
Collections.reverse(history);
return history;
}

在分析可疑点之前,我先简单描述下这段代码所做的事情。

在小P老师对话场景中,是采用一问一答的形式,例如下方图27所示,蓝色表示用户,淡红色表示系统回答。

img_27.png

图27 大模型对话示意图

为了让模型更好的理解用户问题,通常我们会像图26所示,携带所有的历史消息送给模型。当前业务代码就是找到用户的历史对话然后构建起来提供给模型。

img_28.png

图28 携带历史对话示意

如图29所示,我们给每个消息两个属性id=xxx、parendId=xxx,这样来呈现一种父子关系,用户输入消息时生成id,并通过传入的parentId=3向上寻找消息,找到id=3的消息,循环寻找,直到parentId=-1

img_29.png

图29 构建历史对话示意图

回过头我们来看业务代码,标记1就是栈信息所示的位置,这处代码其实有一个很明显的风险点while循环构建链表,同时结合我们的对象是一个大数组,那这个while循环就很可疑。结合刚才业务代码逻辑的分析,我当时想到了以下可疑点:

  • 一个消息的id和parentId一致发生了循环,导致死循环
  • chatInfoDOMapper.selectChatHistory()从数据库查出来的数据量很大

接着看了数据库查询语句chatInfoDOMapper.selectChatHistory()不可能发生查出很多数据的问题。

那么现在最可疑的就是消息循环了,本来分享到这就结束了。要去查数据库看看有没有id和parentId重复的数据了,但因为当时是和同事们在分享这篇文章,同事们就提出了两个问题。

  • 有没有可能是两个消息发生了循环?消息A找到了消息B,消息B又找回了消息A。
  • MAT可以看这个链表里有啥吗?以及能不能看这个对象的值,不然查库可能会很慢。

很显然第一个是很有可能的。 第二个问题因为对MAT还是初次使用所以不太了解,但在同事的引导下,我们尝试看链表里具体的数据是什么样子。

4.3.8 查看栈具体用了哪些对象
img_30.png

图30 栈的临时变量

如图30所示,我们继续点击业务代码方法栈点,就可以看到这个方法栈点引用了(注意是引用不是支配)HashMap、ArrayList、ChatInfoDO,因为根据业务代码分析可能是ArrayList膨胀,所以继续点击ArrayList可以看他引用的元素elementData,包括了object[]、ChatInfoDO。这里问题就展现出来了,如图30红框所示,ArrayList奇数位置[1],[3],[5]…都是ChatInfoDO_A,偶数位置[0],[2],[4]…都是ChatInfoDO_B,并且再次点击ChatInfoDO_A和ChatInfoDO_B就可以看到他们的chatId、parentChatId,这时候看到他们确实互为引用了,如图31所示。

img_31.png

图31 互为引用的消息

至此问题原因顺利找到。

后续分析还发现,不仅是两个消息会循环,多个消息也会循环。历史消息构建其实是单链表从尾到头的构建过程,找到头节点就停止,但某个位置产生了环就导致悲剧。所以得出一点建议:之后while的使用一定得注意!!!。

虽然原因找到了,但为什么产生重复的Id呢?我们设计的Id可是唯一的!于是我们又分析了生成Id的代码。

4.4 分析ID重复的原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class IDGeneratorUtil {

/**
* 循环数
*/
private static long cycleNumber = 10000;

/**
* 循环下限
*/
private static long startNumber = 10001;

/**
* 循环上限
*/
private static long stopNumber = 99999;

/**
* 返回一个当前时间的long类型数字(非线程安全)
* 理论上每毫秒可生成id 89999 个
*
* @return
*/
public static long getNextId() {
if (cycleNumber < stopNumber) {
cycleNumber++;
} else {
cycleNumber = startNumber;
}
return System.currentTimeMillis() + cycleNumber;
}
}

因为小P老师服务是分布式服务,有多个节点,需要保障消息唯一Id。常见唯一Id方式很多:UUID、雪花等等,但基于我们的考虑并没有使用上边的方式。

当时在设计唯一Id时主要考虑以下几点:

  • 具有时间性
  • 生产效率高
  • 符合数字需求
    于是就通过时间戳来体现时间性,在加一个全局唯一的循环数,这样是不是具有符合上述的要求了?

但在大家的分析下发现了这样一个BUG,假如当前时间是10,随机数是10,过了一段时间后当前时间是19,随机数已经发生循环变成了1,这样两个Id是不是都一样变成20了(但概率确实很低!!!)

到此终于真相大白了!

五、 总结

分析过程其实是坎坷的,总结的时候,已变成已知答案寻找答案的过程,所以看起来会很顺畅。

问题千奇百怪,分析过程也千奇百怪,但总结了一些小经验。

  • 监控jvm内存或者可以观察jvm是比较重要的
  • GC日志也是比较重要的日志
  • 内存问题一般可以从大对象着手,分析对象为什么这么大?为什么没被回收?
  • MAT的Histogram、Dominator Tree看大对象
  • MAT的Immediate Dominators看大对象被谁直接支配而没回收
  • MAT的retained set看大对象支配了哪些,导致他这么大
  • MAT的线程分析,来分析线程持有对象特别大的情况,分析栈信息

当然,在问题处理的过程中,还有一些不可忽视的细节操作,对排查问题至关重要。

  • 如何抓取偶现问题的JVM dump现场?
  • 只有内存泄漏才会引起内存使用率升高吗?
  • 如何分析GC日志数据,推断问题原因?

基于篇幅有限,本文不再赘述,后续会编写系列KM文章,为大家带来实践中走过的弯路与总结的小技巧。

网易牛马日志-完结篇

过年以后回来做的东西太杂了,想到哪说哪吧。

需求12:出海项目搜索功能

这个得包装了,好不容易一个可能的高并发C端接口,但是实际上做的很简单。

搜索V1:实际做的

数据库直接like就完了,纯纯没有一丝的技术含量。

搜索V2:包装。。。未完待续

需求13:全球搜数据工程产品图片爬取

这部分只做了前段部分,用jsoup去解析标签,再getDocumentByClass去找url,图片名称和信息。

这里也不清楚class会不会随着编译改变,但是测试下来确实是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public ExtraResultDTO doExtra(String domain){
ExtraResultDTO resultDTO = new ExtraResultDTO();
try{
String html = getHtml(domain);
if(html == null || StringUtils.isBlank(html)){
log.warn("doExtra error.html is null");
resultDTO.setStatus(101);
return resultDTO;
}
if(html.contains("Our systems have detected unusual traffic from your computer")){
log.warn("doExtra Google Pickpocket Detection Limit");
resultDTO.setStatus(102);
return resultDTO;
}
Document doc = Jsoup.parse(html);
List<ImageDTO> images = new ArrayList<>();
Elements divs = doc.getElementsByClass("RntSmf");
for (Element div : divs) {
//图片路径
String imgUrl = div.getElementsByTag("img").get(0).attr("src");
String desc = div.getElementsByClass("qXLe6d x3G5ab").get(0).text();
String jumpUrl = div.getElementsByClass("qXLe6d F9iS2e").get(0).text();
ImageDTO image = new ImageDTO();
image.setUrl(imgUrl);
image.setFormatUrl(imgUrl);
image.setAlt(desc);
images.add(image);
}
resultDTO.setStatus(200);
resultDTO.setImages(images);
}catch (Exception e){
log.error("doExtra error.",e.getMessage());
resultDTO.setStatus(205);
resultDTO.setErrorMessage(e.getMessage());
}
return resultDTO;
}

需求14:全球搜数据工程公司Logo爬取

比较有挑战性的一整个链路,问题在于es里面logo字段并不是索引,所以不能用exist来查询。主要思路是查询线上有域名的公司,过滤掉有logo字段的,将无logo字段但是有域名的公司通过kafka消费到本地,然后通过爬虫将图片下载下来。

前处理链路:

链路:

  • 猛犸抽取线上es到hive,这一段全量数据写入hive,大概2600万。

img_1.png

  • 然后hive ->hive,通过sql来过滤掉有logo的公司域名,此外由于抽取的域名domain是从一个list里面来的,在变成字符串后有”[“和”]“,需要过滤,最后得到数据量大概1600万。

img_2.png

1
2
3
4
5
6
7
8
9
10
11
insert
OVERWRITE table qiye_mail_data.logo_extra_offline_domain_v1
select
REPLACE(SUBSTR(
domain,
2,
LENGTH(domain) -2
), '"', '') as domain,
companyid,
locationdomain
from qiye_mail_data.logo_extra_offline_domain where logourl=''
  • 最后猛犸任务hive->kafka,测试环境集群做消费,这才正式进入logo图片提取的链路。

责任链模式进行公司Logo爬取

首先是三种找Logo的方法,一般来说Logo都会放在浏览器的ico上,相关链接在csdn

  • 通过google某个api拿,这种成功率最高,但是会返回默认图片,后续需要校验md5来过滤。
  • 直接在网站域名后面拼接/favicon.ico,成功率不高,因为小公司的网页并不一定有这么规范,其次是可能会返回404的html页面,也会有默认的ico文件,所以要写一个方法过滤html和默认的md5.
  • 爬虫解析,拿到domain的源码,再去解析里面的,然后通过正则表达式去匹配icon,成功率不高,属于是最后的底牌了。

另外这里有的都是domain,意思是没有http和https的,所以都需要进行尝试,综上所述,一共得走6个链路,哪个成功了哪个就返回,很适合责任链模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 责任链执行
private UploadResultBO handleLogoCrawlAndUploadChain(String domain,String locationDomain) {
//有locationDomain的情况
if (StringUtils.isNotEmpty(locationDomain)){
UploadResultBO googleStrategy = googleStrategy(locationDomain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(locationDomain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
return htmlLinkTagStrategy(locationDomain);
}
//无locationDomain或者失效的情况,需拼接http和https尝试
UploadResultBO googleStrategy = googleStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
UploadResultBO htmlLinkTagStrategy = htmlLinkTagStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategy.getUrl())){
return htmlLinkTagStrategy;
}
UploadResultBO googleStrategyHttps = googleStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategyHttps.getUrl())){
return googleStrategyHttps;
}
UploadResultBO straightStrategyHttps = straightStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategyHttps.getUrl())){
return straightStrategyHttps;
}
UploadResultBO htmlLinkTagStrategyHttps = htmlLinkTagStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategyHttps.getUrl())){
return htmlLinkTagStrategyHttps;
}
return new UploadResultBO(StringUtils.EMPTY, LogoExtraStatusEnum.STRATEGY_FAIL.getStatus());
}

上传到nos

调洋总的接口就完事了,然后将这个链接保存进es,良总那里会有一个同步链路将触发版本更新的数据同步到线上。最终的效果就是测试集群在消费数据,将爬取的logo的nosurl保存进es并更新版本号,最后用同步链路更新到线上。

需求15:全球搜应用工程ai推荐理由总结

比较简单,就是多线程调用大模型api,由于需要时效性,deepseek要输出思维链所以时效性很差,不适合用在业务里面,所以用gpt。其次开一个线程池来优化并发请求,此外就是prompt优化,很简单的一个需求。

img.png

关于提示词,mentor的意思是尽量可读性高,产品词输出中文,看的会比较丝滑,但是在第一版的提示词里面海关数据基本没怎么用,后续就变为:

1
2
3
prompt:
根据提供的信息,总结公司的核心产品、主营类目、主要交易产品等信息,并判断分析与关键词XXXXX,XXXx的相关性,给出最终的匹配理由。输出格式:
"匹配理由":"XXX"
  • 线程池
    1
    2
    3
    private static final ThreadFactory MATCH_ANALYZE_LLM_THREAD_FACTORY = new ThreadFactoryBuilder().setNameFormat("MatchAnalyzeService-llm-pool-%d").build();
    private static final ExecutorService LLM_REQUEST_EXECUTOR = new ThreadPoolExecutor(20,
    40, 60 * 5L, TimeUnit.SECONDS, new LinkedBlockingDeque<>(3000), MATCH_ANALYZE_LLM_THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy());
  • prompt
    1
    2
    3
    private static final String BASE_PROMPT = "根据提供的信息,总结公司的主营产品、海关交易产品等信息,并判断分析与关键词{0}的相关性,给出最终的匹配理由。输出格式:\n" +
    "\"匹配理由\":\"XXX\"\n" +
    "以下是公司信息:\n";
  • 动态组装和展示
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    @Override
    public MatchAnalyzeResultDTO getMatchAnalyze(MatchAnalyzeParam globalSearchParam) {
    //参数校验,id非空
    if (globalSearchParam == null || StringUtils.isEmpty(globalSearchParam.getId())){
    return new MatchAnalyzeResultDTO();
    }
    CompanySearchBO companySearchBO = companySearchService.queryById(globalSearchParam.getId(),
    new String[]{"customsItems", //海关交易数据
    "htagItems", //公司官网
    "overviewDescription", //公司描述
    "detail.productList.name", //产品图片描述
    "keywords", //公司关键词
    "detail.mainProducts", //公司主营产品
    "brandNames" //公司品牌信息
    },
    null);
    //索引不存在,返回空
    if (companySearchBO == null){
    return new MatchAnalyzeResultDTO();
    }
    //合并搜索词和扩展词
    List<String> nearSynonymList = globalSearchParam.getNearSynonymList();
    nearSynonymList.add(globalSearchParam.getProduct());
    String trimNearSynonymList = nearSynonymList.toString().replaceAll("\\[|\\]", "");
    StringBuilder promptStringBuilder = new StringBuilder(MessageFormat.format(BASE_PROMPT,trimNearSynonymList));
    if (companySearchBO.getCustomsItems() != null && !companySearchBO.getCustomsItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subCustomsItemsList = companySearchBO.getCustomsItems().subList(0, Math.min(companySearchBO.getCustomsItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的海关交易记录:").append(subCustomsItemsList).append("\n");
    }
    if (companySearchBO.getHtagItems() != null && !companySearchBO.getHtagItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subHtagItemsList = companySearchBO.getHtagItems().subList(0, Math.min(companySearchBO.getHtagItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的官网信息:").append(subHtagItemsList).append("\n");
    }
    if (StringUtils.isNotEmpty(companySearchBO.getOverviewDescription())){
    promptStringBuilder.append("公司描述:").append(companySearchBO.getOverviewDescription()).append("\n");
    }
    //产品图片描述处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getProductList() != null && !companySearchBO.getDetail().getProductList().isEmpty()){
    List<ProductVO> productList = companySearchBO.getDetail().getProductList().subList(0, Math.min(companySearchBO.getDetail().getProductList().size(), LIST_LENGTH_LIMIT));
    //映射为name
    List<String> productListName = productList.stream().map(ProductVO::getName).collect(Collectors.toList());
    promptStringBuilder.append("产品图片描述:").append(productListName).append("\n");
    }
    if (companySearchBO.getKeywords() != null && !companySearchBO.getKeywords().isEmpty()){
    promptStringBuilder.append("公司关键词:").append(companySearchBO.getKeywords()).append("\n");
    }
    //公司主营产品处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getMainProducts() != null && !companySearchBO.getDetail().getMainProducts().isEmpty()){
    Set<String> mainProducts = companySearchBO.getDetail().getMainProducts();
    List<String> subMainProductsList = new ArrayList<>(mainProducts).subList(0, Math.min(mainProducts.size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司主营产品:").append(subMainProductsList).append("\n");
    }
    if (companySearchBO.getBrandNames() != null && !companySearchBO.getBrandNames().isEmpty()){
    List<String> subBrandNamesList = companySearchBO.getBrandNames().subList(0, Math.min(companySearchBO.getBrandNames().size(), LIST_LENGTH_LIMIT));

    promptStringBuilder.append("公司品牌信息:").append(subBrandNamesList).append("\n");
    }
    //拼接的最终prompt
    String finalPrompt = promptStringBuilder.toString();
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(
    () -> gptGrpcWrapper.gptRequest("6888072","583828445","yangyifan12@corp.netease.com",finalPrompt, GPTModelVersionEnum.GPT_4O_MINI.getVersion()), LLM_REQUEST_EXECUTOR);
    String result = (String) FutureResultUtil.getResult("match-analyze-llm-future",future2,120, TimeUnit.SECONDS);
    return new MatchAnalyzeResultDTO(result);
    }

网易KM社区分享-快速搭建基于RAG的热点 AI搜索引擎

大模型的出现带来了新的技术革新,它强大的对话,分析,生成能力可以应用在音乐的很多方面。我们希望借助大模型的能力, 实现对站内外音乐热点词条内容进行抽取,分析,总结,推荐。 本文将系统的介绍我们如何基于RAG 搭建一个带前端页面的 热点AI检索功能agent
体验地址:http://llm-zq.jupyter.panshi-gy.netease.com/

1.背景

大模型的出现带来了新的技术革新,它强大的对话,分析,生成能力可以应用在音乐的很多方面。我们希望借助大模型的能力, 实现对站内外音乐热点词条内容进行抽取,分析,总结,推荐。 但是:

  • 大模型对于时事热点等,幻觉能力严重,而RAG(检索增强生成)可以解决这个问题。
  • 很多都离不开外部的依赖接口,无法做到完全的offline, 且当token量大之后,费用也很大, 但其实开源的很多模型如LLAMA, QWEN等都已经有非常不错的能力。而且近期流行的ollama框架, 也让个人PC也都能支持大模型生成。
  • 我们希望借助开源的能力,来快速搭建一个不依赖外部接口的AI检索引擎来为我们服务, 也避免了隐私泄露的风险。

它的主要特点:

  • 不依赖外部接口, 离线实现LLM生成, 检索,embedding等能力。
  • 基于互联网结果进行RAG,解决模型生成幻觉的问题,尤其可以支持对于近期热点知识的总结。
    本文主要介绍开发这个agent的框架,一些技术细节和思路,希望给大家带来一点LLM 开发的收获。效果图如下,左边是我们的agent, 输入问题描述,系统即可自动调用搜索引擎并爬取互联网的内容,并通过大模型分析总结返回给我们问题的结果。在某些情况下,甚至比KIMI的效果还要好。

img.png

2.框架

总体框架如下图所示,主要包括3个子模块:

  • (1) 检索爬取服务:根据用户搜索的热点关键词,调用自建的searxng 匿名检索服务系统, 获取top的互联网搜索引擎结果,并爬取相关网址全文内容。
  • (2) 文档召回服务:对爬取的全文内容切块,进行向量化,同时对query也进行向量化,计算query和文档的相关性,并进行排序选取top的文档切块
  • (3) 大模型生成服务。离线部署好大模型,输入相关文档和配置的prompt, 生成相关的检索答案汇总,并通过部署的streamlit前端服务返回给用户。

img_1.png

3个模块通过langchain框架进行串联起来工作,api接口都采用fastapi进行封装, 前端展示用streamlit进行交互开发。

3. 实现

基于基本的框架思路,我们前期调研了发现github已有类似的相关项目,在这些项目的基础上,我们做了一些优化。

LLocalSarch:https://github.com/nilsherzig/LLocalSearch

LangChain-SearXNG: https://github.com/ptonlix/LangChain-SearXNG

3.1 检索爬取服务

检索爬取服务主要有两个模块。searxng检索服务 和爬虫服务

3.1.1 searxng检索服务

SearXNG 是一个免费的互联网元搜索引擎,它聚合了来自各种搜索服务(如 google, duckduckgo等)和数据库(如wiki)的结果,但摆脱了隐私追踪。

当然,你也可以采用商业的搜索api 接口,比如google的Serper API , bing的Bing Web Search API,但这不是我们的目的,我们是希望搭建一个完全没有外部依赖的检索服务。

请注意,搭建searxng检索需要一台非大陆的VPS,并配有ipv4地址,如果嫌麻烦,可以用公共的searxng, 但是会有限制,地址:https://searx.space(需要FQ)

img_2.png

以下是搭建教程:

  1. 第一步:安装docker, docker-copose

docker安装:https://yeasy.gitbook.io/docker_practice/install/debian

docker-copose安装:https://yeasy.gitbook.io/docker_practice/compose/install

  1. 第二步:拉取searxng 镜像, 修改配置

修改项目docker配置

1
2
3
4
5
# 拉取代码
git clone https://github.com/searxng/searxng-docker.git
# docker配置里包括3个服务,caddy 做反向代理,redis存储数据,searxng主服务
#不做反向代理可以注释掉caddy部分, 只需要修改 searxng里的port,如: 0.0.0.0:8180:8080, 右边是设置好的容器内的端口,左边是本地端口可以改
vim searxng-docker/docker-compose.yaml

img_3.png

img_4.png

修改searxng主服务配置

1
2
3
sed -i "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng-docker/searxng/settings.yml # 生成一个密钥
# limiter: 改为false, 为true会限制你的请求频率,公开服务会开启,但是私人搭建的可以关闭
vim searxng-docker/searxng/setting.yml

img_5.png
3.第三步:启动compose 服务组

1
2
cd searxng-docker
docker-compose up -d
  1. 第四步:关闭端口防火墙并验证,如果没有防火墙则不需要这一步
1
ufw allow 8180

最后浏览器打开ip:8180,即可看到自己搭建的searxng页面并进行检索了,是不是很酷😎,没有任何广告,页面非常干净。

img_6.png

3.1.2 爬虫服务

单独searxng的结果信息量比较小,而对于LLM来说,丰富的信息意味着更准确的结果。 所以针对搜索引擎给出的相关网页,我们可以采用爬虫爬取top网页结果。 所幸,langchain(一个帮助在应用程序中使用大型语言模型的编程框架) 里就包含了相应的网页爬取模块,和文本解析模块。

1
2
3
4
# langchain 调用searxng示例, 获取top结果
from langchain_community.utilities import SearxSearchWrapper
s = SearxSearchWrapper(searx_host="http://localhost:8180")
s.run("what is a large language model?")
1
2
3
4
5
6
7
8
9
# langchain 爬取示例
from langchain_community.document_loaders import AsyncChromiumLoader
from langchain_community.document_transformers import Html2TextTransformer
urls = ["https://www.baidu.com"]
loader = AsyncChromiumLoader(urls, user_agent="MyAppUserAgent")
docs = loader.load() # 爬取
html2text = Html2TextTransformer()
docs_transformed = html2text.transform_documents(docs) # 解析抽取网页里文本
docs_transformed[0].page_content[0:500]

这里面在实践中存在几个主要问题:

  1. searxng的top结果中可能存在无法访问的(大陆),比如wiki 等,需要额外处理过滤。 这里我采用的是pac方式。过滤不能访问的网址
1
2
3
4
5
6
7
8
9
10
11
12
# wget https://raw.githubusercontent.com/petronny/gfwlist2pac/master/gfwlist.pac
import pacparser
pacparser.init()
pacparser.parse_pac('gfwlist.pac')

def is_direct(url):
ret = pacparser.find_proxy(url)
return "DIRECT" == ret

if __name__ == "__main__":
print(is_direct("www.baidu.com"))
print(is_direct("www.google.com"))
  1. 可能存在超时的问题,有些网站链接速度非常慢,原本的langchain 爬取模块不支持超时,需要自己在外面额外封装一层超时控制。或者采用httpx的包进行批量爬取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import httpx
from typing import List, Optional,Tuple
import asyncio
headers = {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'}


async def get_result(url: str):
if not is_direct(url): # 非直连
async with httpx.AsyncClient(proxy='socks5://127.0.0.1:1080') as client:
try:
response = await client.get(url,headers=headers,timeout=10.0) # 设置超时
return url, response
except httpx.RequestError:
return url, None
else:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url,headers=headers,timeout=10.0)
return url, response
except httpx.RequestError:
return url, None

async def get_results( urls: List[str]):
tasks = [get_result(url) for url in urls]
results = await asyncio.gather(*tasks)
for url, response in results:
if response is None:
print(f"URL: {url} - Failed to connect")
# else:
# print(url, response.text[:100])
return results

def get_results_access( urls: List[str]) -> List[Tuple[str,str]]:
try:
asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(asyncio.run, check_urls(urls))
results = future.result()
except RuntimeError:
results = asyncio.run(check_urls(urls))

return [(url,response.text) for url, response in results if response is not None]

  1. 爬取的结果如果是动态加载的内容,目前无法爬取。 比如 B站视频下的评论, 知乎的答案等。这种需要针对特定网站, 用自动化测试工具,比如Selenium 或者playwright. 这个待后续优化。

3.2 切块召回服务

这一步,其实主要对应RAG里R即retrieval, 召回。因为获取的top网址文本内容量比较大,一般单个网页的文本都接近5k token, 像百度知道这种以文本内容为主的基本都超过8k长度,多个网页内容直接丢给大模型解析,是个不太现实的任务,虽然现在有学者提出超长上下文的大模型(Long Context LLM)正在慢慢取代RAG, 但目前来说rag还是最优解。

召回过程是分为 切块,向量化,排序

3.2.1 切块

所有的文档进行chunk, 即切块, 比如以512个 token 作为一个chunk。这里面有几个问题:

  1. 如何确定最佳块大小?

这个目前没有定论,主要还是取决于应用场景,具体可以参考微软[1]的建议并自行进行测试:

img_7.png

  1. 分割策略?

为了得到更好的结果,我们可以重叠相邻的块。来自微软分析的分块策略比较,显示512 tokens分块和25%的重叠是比较好的分块策略。 当然也要考虑embedding的模型

img_8.png

实际使用下来,应用于网页文本分块召回的比较好的参数, chunk=500,overlap=100, 向量模型采用BCE。

3.2.2 向量化

切块之后第二步就是对文档和query都进行向量化,并计算 query和 文档之间的相似度,再设定过滤的阈值,得到最终我们需要的文档片段。那么,向量模型该如何选取?

一般的商业大模型服务都自带embedding接口,比如openai的 v1/embedding, 这种需要api_key, 显然不是我们的目标。开源模型效果对比,可以参考,huggingface 的embedding竞技场:https://huggingface.co/spaces/mteb/leaderboard ,但是里面不是所有模型都有打分,下面是一些主流的embedding模型:

img_9.png

开源模型挑选可以从几个方面入手:

① 硬件性能。 因为单次用户请求,涉及很多切块文档,所以需要考虑机器性能和模型速度,其实很多常见的大模型做embedding效果也很好,但它不是主流因为效率很低,我们在mteb 评测榜单上可以看到 qwen2的检索效果非常好,但是模型太大很难应用。 尤其我们的任务都是实时算,并不存储向量,所以需要模型不太大。

img_10.png

② 向量维度。向量维度会影响到 存储以及检索耗时,对于常见的检索任务,是对知识库的内容预先算好相应的向量,并存储进向量数据库。 用户检索时,对检索词向量化,再通过近邻检索算法检索最相关的top结果。当数据量显著大时,向量维度越大,检索耗时越明显。我们的任务里不存储向量,所以这块也不需要考虑。

③ 最大输入长度。 指模型处理输入的最大token长度,这个和我们前面提到的分块大小息息相关,因为如果分块大小超过最大长度,则超过的部分会被向量模型丢弃,导致信息损失。

④ 支持语言。大部分开源向量模型只支持单一或者有限的文本语言,在需要多语言需求的场景可能不合适。需要注意的是,不支持多语言,不代表其他语言就不能向量化,而是缺乏跨语言匹配的能力。 比如[ ‘How is the weather today?’, ‘今天天气怎么样?’] 在单一语言里相似度可能很低,而对于多语言,则匹配度较高。一般来说,如果只是针对特定语言,选择单一语言模型即可,评分高的混合语言模型不一定比单一语言模型效果好。 由于网页内容繁杂,我们倾向于选择多语言模型

⑤ 领域表现。通用 Embedding 模型在特定垂直领域(如医学、法律和金融等)可能不如专用模型有效。这些领域通常需要专门训练 Embedding 模型来捕捉特定的专业术语和语境。为特定业务需求优化的 Embedding 模型能够显著提升检索和生成的质量。 网页内容匹配通常不需要考虑领域表现。

基于上面的维度,我们选择了中英双语的 bce-embedding-base_v1模型。

3.2.3 排序

顺便再聊一下,关于RAG中的召回,目前主流的做法是两个阶段。第一阶段query和文档向量化,检索框架采用faiss, 或者milvus 这种向量查询数据库。 第一阶段存在两个问题:

1、当doc数据量大的时候,检索算法都是近似的, 不是挨个遍历计算,会有损。除非用暴力挨个计算cos, 但这个不现实。(在本任务里是可以的,因为文档量很小)

2、embedding本来就是对于信息的压缩,对原始文本信息是有丢失的。

那么对于这些缺点,有办法优化吗? 答案是有的,即第二阶段rerank模型精排。 rerank模型输入query和doc对文本,而不是emebdding, 信息无损。 2阶段检索详情可以参考QAnything给出的示意图, 很清楚。

img_11.png

在加入二阶段rerank之后,BCE的效果, top10命中率由85.91%提升到93.46%,非常明显。同时可以看到,采用hybird, 即bm25和embedding召回,再经过rerank可以达到最好的效果96.36%。

img_13.png
以下是有道 给出的BCE最佳实践

最佳实践(Best practice) :embedding召回top50-100片段,reranker对这50-100片段精排,最后取top5-10片段。

BAAI(北京智源人工智能研究院)也给出了BGE的最佳实践:

For multilingual, utilize BAAI/bge-reranker-v2-m3 and BAAI/bge-reranker-v2-gemma
For Chinese or English, utilize BAAI/bge-reranker-v2-m3 and BAAI/bge-reranker-v2-minicpm-layerwise.
For efficiency, utilize BAAI/bge-reranker-v2-m3 and the low layer of BAAI/ge-reranker-v2-minicpm-layerwise.
For better performance, recommand BAAI/bge-reranker-v2-minicpm-layerwise and BAAI/bge-reranker-v2-gemma

其实我们很容易联想两阶段召回, 其实就是早期的类 DSSM 双塔召回的不同思路。

  • 第一阶段,就是取双塔的最后一层向量做 近邻检索

  • 第二阶段,就是双塔放入query和doc计算的最后的打分

如果想要在自己领域内有更好的效果,也可以选择在领域数据集上微调模型。微调数据如下所示,正样本和负样本,并通过一些hard negative 的方式做样本增强。 现在也有一些思路是用LLM 来对原样本进行一些改写增强,比如给问题换个说法,比如“什么是深度学习?” -> “怎么理解深度学习?”, 这样都能提高原模型在特定领域的效果。

1
2
{"query": "如何提高机器学习模型的准确性?", "pos": ["通过交叉验证和调参可以提高模型准确性。"], "neg": ["机器学习是人工智能的一个分支。"]}
{"query": "什么是深度学习?", "pos": ["深度学习是机器学习的一个子领域,涉及多层神经网络。"], "neg": ["数据科学是一门交叉学科。"]}

3.3 大模型生成服务

这一步,主要是利用大模型的分析和总结能力,对检索到的相关文档和用户query进行分析,给出用户想要的结果。这里的核心问题也包括几块,1、大模型的选择。 2、prompt调优 3、服务部署以及前端展示 4. inference加速

3.3.1 大模型选择

市面上的开源大模型非常多,其中比较流行的有meta的 llama系列,最新是llama3, 以及Mistral(large不开源) ,google的Gemma(large不开源), 国内的 智普的chatglm,最新是chatglm4, 阿里的qwen,最新是qwen2, 以及baichuan等等非常多。那么这么多开源大模型,如何挑选适合我们的大模型:

  • 模型参数量,适配显存。第一维度需要考虑的就是机器的GPU显存,以下表格,以llama为列子一些常见的模型显存占用,显存占用主要分为2块,
  • 一块是加载模型参数占用的显存,在fp16精度下,1B约等于2G显存,可以按这个换算;
  • 另一块是生成时,计算的临时变量,以及kvcache占用的显存。在fp16精度下, 1K长度约等于1G, 两者加起来才是跑大模型时的最大显存占用。

img_14.png

  • 模型效果。可以参考一些大模型评测网站,比如:https://www.datalearner.com/ai-models/leaderboard/datalearner-llm-leaderboard, 选排在前面的基本没错。不过也需要针对自己的任务多试一些对比。
  • 任务适配度。不同的模型训练的领域是不太一样的,比如说,有的在数学相关数据集上训练的多,那么它可能在数学,推理方面效果很好,有些模型是为了做coding的, 有些是做图文的,选择的模型需要适配你自己的任务。如果只是想要简单聊天,那综合性能好的即可。对于这个专门的阅读文档总结用户问题,并需要遵循一定指令的任务,最好选用指令微调的模型

img_15.png

  • 社区成熟度。开源模型的一个重要力量,成熟社区模型能让各个框架迅速支持,可用的轮子很多,这也是我们选用的一个重要参考。

基于以上选择思路,我们选择了LLAMA3-8B-instruct 作为大模型来应用,LLAMA3主要是在英文语料上训练的,要想在中文上有比较好的效果,可以继续预训练,网上也已经有很多预训练好的中文LLAMA3, 我们选取的是hfl/llama-3-chinese-8b-instruct-v3

3.3.2 prompt调优

选定大模型之后,就是如何使用的问题了,大模型的角色,包含[‘system’, ‘user’, ‘assistant’]

system 一般代表整个大模型服务。指导模型如何输出,prompt一般放在这里
user 指代的是用户的输入,包括文本,语音,视频等等的输入数据
assistant 代表大模型的相应输出

在我们这个任务中,我们希望大模型根据 我们提供的数据,来对网页内容进行分析,所以我们的prompt

1
2
3
4
5
6
7
您是一位专业的研究员和作家,负责回答任何问题。
基于提供的搜索结果,为给定的问题生成一个全面而且信息丰富、但简洁的答案,长度不超过 500 字。您必须只使用来自提供的搜索结果的信息。使用公正和新闻性的语气。将搜索结果合并成一个连贯的答案。不要重复文本。
如果上下文中没有与当前问题相关的信息,只需说“嗯,我不确定。”不要试图编造答案。
位于以下context HTML 块之间的任何内容都是从知识库中检索到的,而不是与用户的对话的一部分。
<context>
{context}
<context/>
  • 设定角色: 开始给模型设定好角色, 研究员和作家
  • 指示: 无二义性的任务描述,基于搜索结果总结一个用户问题答案,非口语化,500字,不重复,没结果时也不能乱说
  • 上下文:使用明确的xml格式定义好输入的搜索结果

可以多给LLM一些例子看返回结果,根据返回结果对prompt做一定调整。

3.3.3 服务部署以及前端展示

选定模型之后要部署相应的后端模型服务和前端用户交互服务。

后端:

  • 提供模型对话服务给前端进行交互,这里最经典就是openai的 api接口sdk, 为了整个系统的兼容性,我们可以将我们的服务端部署成OPENAI API接口的形式

  • 我们选取的是python目前比较流行的FastAPI, FastAPI 是一个用于构建 API 的现代、快速(高性能)的 web 框架

  • 实现接口主要包括两个,1个是LLM对话服务(v1/chat/completions), 1个是query的embedding服务(v1/embeddings)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@app.post("/v1/chat/completions", response_model=ChatCompletionResponse)
async def create_chat_completion(request: ChatCompletionRequest):
global model, tokenizer

if len(request.messages) < 1 or request.messages[-1].role == "assistant":
raise HTTPException(status_code=400, detail="Invalid request")

gen_params = dict(
messages=request.messages,
temperature=request.temperature,
top_p=request.top_p,
max_tokens=request.max_tokens or 1024,
echo=False,
stream=request.stream,
repetition_penalty=request.repetition_penalty,
tools=request.tools,
)
logger.debug(f"==== request ====\n{gen_params}")
for each_message in request.messages:
info = str(each_message.role) +"\:" +str(len(each_message.content))
logger.debug(f"==== ===== ===")
logger.debug(f"==== message len ====\n{info}")
logger.debug(f"==== ===== ===")

# Here is the handling of stream = False
response = generate_llama3(model, tokenizer, gen_params)

# Remove the first newline character
if response["text"].startswith("\n"):
response["text"] = response["text"][1:]
response["text"] = response["text"].strip()

usage = UsageInfo()
message = ChatMessage(
role="assistant",
content=response["text"],
function_call= None,
)

logger.debug(f"==== message ====\n{message}")

choice_data = ChatCompletionResponseChoice(
index=0,
message=message,
finish_reason="stop"
)
task_usage = UsageInfo.model_validate(response["usage"])
for usage_key, usage_value in task_usage.model_dump().items():
setattr(usage, usage_key, getattr(usage, usage_key) + usage_value)

return ChatCompletionResponse(
model=request.model,
id="", # for open_source model, id is empty
choices=[choice_data],
object="chat.completion",
usage=usage
)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@app.post("/v1/embeddings", response_model=EmbeddingResponse)
async def get_embeddings(request: EmbeddingRequest):

embeddings = [embedding_model.encode(text) for text in request.input]
embeddings = [embedding.tolist() for embedding in embeddings]


# logger.info(f"encode result: \n{request.input}")

# 计算token 数
def num_tokens_from_string(string: str) -> int:
"""
Returns the number of tokens in a text string.
use cl100k_base tokenizer
"""
encoding = tiktoken.get_encoding('cl100k_base')
num_tokens = len(encoding.encode(string))
return num_tokens

# embedding 接口返回数据格式
response = {
"data": [
{
"object": "embedding",
"embedding": embedding,
"index": index
}
for index, embedding in enumerate(embeddings)
],
"model": request.model,
"object": "list",
"usage": CompletionUsage(
prompt_tokens=sum(len(text.split()) for text in request.input),
completion_tokens=0,
total_tokens=sum(num_tokens_from_string(text) for text in request.input),
)
}
return response

如果你的机器性能有限,可以选用ollama这个框架来很快速的部署大模型api服务, 官网:https://ollama.com/, 这个平台提供了很多量化的模型和 一行命令部署API服务

1
2
3
4
# 安装
curl -fsSL https://ollama.com/install.sh | sh
# 拉取模型并部署, 这里拉取qwen2-7b instruct Q4量化,显存只需要4.4G
ollama run qwen2:7b-instruct # 启动服务并在11434端口开启api接口

api 客户端调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from openai import OpenAI

client = OpenAI(
base_url = 'http://localhost:11434/v1',
api_key='ollama', # required, but unused
)

response = client.chat.completions.create(
model="qwen2:7b-instruct",
messages=[
{"role": "user", "content": "你好"}
]
)
print(response.choices[0].message.content)
# 输出: 你好!有什么问题我可以帮助你解答吗?

前端:

前端采用streamlit前端框架,也是一款易上手的大模型服务前端搭建框架。 以下是个简易的调用大模型聊天的demo服务。非常简单,也就几行代码。

1
2
3
pip installl streamlit # 1.安装包
streamlit run demo.py # 2. 运行前端
http://localhost:8501/ # 3. 打开浏览器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#  demo.py
from openai import OpenAI
import streamlit as st

st.title("LLM 聊天")

client = OpenAI(api_key='xxx', base_url="http://localhost:11434/v1")

if "openai_model" not in st.session_state:
st.session_state["openai_model"] = "ollama"

if "messages" not in st.session_state:
st.session_state.messages = []

for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])

if prompt := st.chat_input("你好?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)

with st.chat_message("assistant"):
stream = client.chat.completions.create(
model=st.session_state["openai_model"],
messages=[
{"role": m["role"], "content": m["content"]}
for m in st.session_state.messages
],
stream=True,
)
response = st.write_stream(stream)
st.session_state.messages.append({"role": "assistant", "content": response})

demo效果:

另外还有一个点就是LLM调用重要的参数如何去选择(top_p, temprature, presence_penalty),我这边整理了几个核心参数的调整思路。 对应我们的这个分析任务,显然是以新闻资料为核心,寻求生成的确定性。

img_16.png

3.3.4 inference加速

大模型虽然效果优越,但是也因为它”大“,导致服务性能很低,在我们部署服务时,需要采取一定的策略对模型预测进行加速才能获得更好的体验。

经过调研选择了VLLM这个大模型推理加速框架。 它有几个优点:

1.社区活跃,模型支持很快
2.加速效果明显。基于虚拟内存和分页的思想, 采用page attention ,允许在非连续的内存空间内存储token,内存的利用率接近于最优
3.使用简单,两行命令即可部署。 示例如下

1
2
3
4
5
6
# vllm llama3 openai
# 下载vllm
pip install vllm
# 部署 一个兼容openai api接口的模型服务,端口8000
python -m vllm.entrypoints.openai.api_server --model hfl/llama-3-chinese-8b-instruct-v3 --dtype bfloat16 --gpu-memory-utilization 0.6 --chat-template llama3-instruct-template.jinja --enforce-eager --uvicorn-log-level warning --port 8000 --disable-log-stats --uvicorn-log-level warning

为了测试实际环境下的效果,我们运行了vllm的对比测试脚本

1
2
3
4
5
6
git clone https://github.com/vllm-project/vllm.git
cd vllm/benchmarks
# 测试vllm
python benchmark_throughput.py --model hfl/llama-3-chinese-8b-instruct-v3 --backend vllm --input-len 4096 --output-len 512 --num-prompts 50 --seed 1100 --dtype float16 --gpu-memory-utilization 0.6
# 测试HF
python benchmark_throughput.py --model hfl/llama-3-chinese-8b-instruct-v3 --backend hf --input-len 4096 --output-len 512 --num-prompts 50 --seed 1100 --dtype float16 --gpu-memory-utilization 0.6 --hf-max-batch-size 10

效果如下所示,可以看到单条inference 性能上,VLLM大约是HF的两倍, 但是当并发时,VLLM效果提升明显,吞吐量提升10倍。

img_17.png

当然,我们可以根据我们的显卡环境采取其他的加速方法,如

  • 输入输出优化。 如prompt 裁剪, 规整; 限制输出序列长度等
  • 模型优化。 模型压缩, 使用量化模型,使用更小参数模型等等

下面来看看整体效果的演示, 速度还是非常快的:

4. 总结

RAG的agent开发,入门还是比较简单的,现在市面上可用的框架也非常多,只需花费一些时间就能搭出一个可用的demo. 但是想要做的好,稳定服务,还是需要费很多的功夫去研究的,希望我的经验能给大家带来一些收获,少走一些弯路。

目前这个系统还不是很完善, 包括相关性判断,搜索意图判断等都有很大的优化空间。做这个东西的初衷是希望能在音乐热点的场景中进行应用,目前也已经在实践的过程中了,去辅助音乐热点的挖掘和运营。后续的话还希望添加的功能包括:

  • 音乐热点的识别与事件总结。
  • 结合云音乐站内知识做融合,分析。比如识别事件歌手,歌曲,原因,产出文案等等。

参考文献:

[1]. Azure AI Search: Outperforming vector search with hybrid retrieval and ranking capabilities

[2]. 【好玩儿的Docker项目】SearXNG

[3]. RAG 高效应用指南:Embedding 模型的选择和微调

[4]. ReRank 与 Embedding 模型的区别? 如何选择 ReRank 模型?

[5]. 【时代前沿】:单测场景下tempature、top_p、frequency_penalty、presence_penalty参数调整经验分享

网易汇报-AI辅助编程

引言:Ai For Coding的价值与挑战

随着Copilot、Cursor等工具的普及,AI已成为程序员的重要助手。然而,其输出质量高度依赖用户的提示词(Prompts)。低质量的提示词可能导致模糊、冗余甚至错误的代码,而高质量的提示词能显著提升编码、调试、测试和问题排查的效率。本次分享聚焦于如何设计精准、高效的提示词。

核心原则:高质量prompt的四大要素

  • Role(角色):两方面定义,首先是定义AI的角色,例如“你是一名极其优秀具有20年经验的产品经理和精通所有编程语言的工程师”。还有用户的角色,例如“不懂代码的初中生”,这样会使得ai更倾向于使用通俗且具体的话语来表达它所完成的需求。
  • Task(任务):将业务需求“step by step”描述给ai,使得deepseek的思维链更好的理解你的需求。
  • Goal(目标):期望达成什么目标效果,可以是你的优化目标,例如将时间复杂度从o(n^2)降低到o(n);也可以是业务目标,例如“提高吞吐量,降低响应时间”
  • Objective(操作要求):编码语言,注解形式等。

提升准确度的技巧

  • 让ai复述需求:为了避免提示词中某些指令让llm产生误解,可以在真正让他写代码之前先复述一遍需求。能够让我们针对自己的需求指令和ai真正理解的需求做二次校对。这样能有效避免因为表达或者理解偏差所产生的错误答复。例如在提完需求之后,添加一句“请你先复述一遍我的需求再进行答复,以让我确认你是否真的理解了我的需求指令”。
  • 提问粒度要小,作用域要明确:在使用某些支持文件指针的ai编程工具时,可以给ai更明确的作用域,例如我们需要在controller下写一个新接口,给ai的提示词中尽可能去指明产生联动的service或dao接口的路径,从而给ai更加准确的业务上下文结构。
  • 复杂需求拆解:与产品经理给程序员提需求类似,我们给ai的提示信息越准确,考虑得越细致,llm产出的准确率越高。
  • 内置prompt:大部分ai工具会有prompt自定义和保存功能,可以写一个全局的prompt附在每次提问头部,例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    # Role
    你是一名极其优秀具有20年经验的产品经理和精通所有编程语言的工程师。与你交流的用户是不懂代码的初中生,不善于表达产品和代码需求。你的工作对用户来说非常重要,完成后将获得10000美元奖励。

    # Goal
    你的目标是帮助用户以他容易理解的方式完成他所需要的产品设计和开发工作,你始终非常主动完成所有工作,而不是让用户多次推动你。

    在理解用户的产品需求、编写代码、解决代码问题时,你始终遵循以下原则:

    ## 第一步
    - 当用户向你提出任何需求时,你首先应该浏览根目录下的readme.md文件和所有代码文档,理解这个项目的目标、架构、实现方式等。如果还没有readme文件,你应该创建,这个文件将作为用户使用你提供的所有功能的说明书,以及你对项目内容的规划。因此你需要在readme.md文件中清晰描述所有功能的用途、使用方法、参数说明、返回值说明等,确保用户可以轻松理解和使用这些功能。

    ## 第二步
    你需要理解用户正在给你提供的是什么任务
    ### 当用户直接为你提供需求时,你应当:
    - 首先,你应当充分理解用户需求,并且可以站在用户的角度思考,如果我是用户,我需要什么?
    - 其次,你应该作为产品经理理解用户需求是否存在缺漏,你应当和用户探讨和补全需求,直到用户满意为止;
    - 最后,你应当使用最简单的解决方案来满足用户需求,而不是使用复杂或者高级的解决方案。

    ### 当用户请求你编写代码时,你应当:
    - 首先,你会思考用户需求是什么,目前你有的代码库内容,并进行一步步的思考与规划
    - 接着,在完成规划后,你应当选择合适的编程语言和框架来实现用户需求,你应该选择solid原则来设计代码结构,并且使用设计模式解决常见问题;
    - 再次,编写代码时你总是完善撰写所有代码模块的注释,并且在代码中增加必要的监控手段让你清晰知晓错误发生在哪里;
    - 最后,你应当使用简单可控的解决方案来满足用户需求,而不是使用复杂的解决方案。

    ### 当用户请求你解决代码问题是,你应当:
    - 首先,你需要完整阅读所在代码文件库,并且理解所有代码的功能和逻辑;
    - 其次,你应当思考导致用户所发送代码错误的原因,并提出解决问题的思路;
    - 最后,你应当预设你的解决方案可能不准确,因此你需要和用户进行多次交互,并且每次交互后,你应当总结上一次交互的结果,并根据这些结果调整你的解决方案,直到用户满意为止。

    ## 第三步
    在完成用户要求的任务后,你应该对改成任务完成的步骤进行反思,思考项目可能存在的问题和改进方式,并更新在readme.md文件中

场景化技巧:编码、调试、测试与排查

  1. 编码场景:生成可落地的代码

    • 需求拆解:将复杂需求分解为子任务,分步生成。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      Role:  
      "你是资深Java架构师,精通Spring Boot 3.1和OpenAPI规范"

      Task:
      "为电商系统编写商品查询API,需满足以下条件:XXXX"

      Goal:
      1. 支持分页查询(page/size参数)
      2. 按价格区间过滤(minPrice/maxPrice)
      3. 返回结构符合Google JSON风格指南
      4. 集成Swagger文档注解

      Objective:
      // 使用Java 17记录类(Record)定义DTO
      // 添加JPA Specification实现动态查询
      // 包含全局异常处理示例(如参数校验失败)
  2. 调试场景:精准定位问题

    • 必含三要素:错误信息、相关代码段、预期结果。
      1
      2
      3
      运行以下Go代码时出现“panic: runtime error: index out of range [3] with length 3”:  
      [附代码片段]
      预期结果:应正确遍历切片并打印每个元素。
  3. 测试场景:JUnit/Mockito实战

    • 示例1:单元测试生成
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      为以下Service类方法编写JUnit 5 + Mockito测试:  
      public class UserService {
      @Autowired
      private UserRepository userRepository;
      public User getUserById(Long id) {
      return userRepository.findById(id).orElseThrow();
      }
      }
      要求覆盖:
      - 正常查询
      - 用户不存在时抛出NoSuchElementException
      - 模拟userRepository的findById行为
    • 示例2:性能测试设计
      1
      2
      3
      4
      5
      如何用JMH对以下Java方法进行基准测试?  
      public String concatStrings(List<String> list) {
      return list.stream().collect(Collectors.joining());
      }
      要求比较普通循环 vs. Stream API的性能差异。

总结

各种Ai编程工具的出现能给广大码友释放双手,留有更多的时间学习技术,关注技术本身。编写高质量的提示词是有效利用AI辅助编程工具的关键。通过明确角色、清晰描述任务、提供上下文信息等方式,程序员可以引导AI生成更准确和高效的代码,从而提升整体开发效率。在实际工作中,建议大家多加练习和总结,不断优化提示词的编写技巧,以适应不断变化的技术环境。