AI智能答题平台笔记
接入智谱AI
1.引入SDK
比较推荐 SDK,方便,不用自己构造 HTTP 请求并构造响应对象。
1 | <!-- 智谱SDKAI--> |
2.配置文件,配置类
- 在 application.yml 中定义 AI 配置
1 | ai: |
- 定义 AI 配置类,加载配置文件,初始化调用智谱的 Client 并将其注册为 bean。
1 |
|
- 根据实际业务需求,封装本项目内通用的 AI 请求构建模块。
3. 封装请求方法
1 | import com.miku.ai.common.ErrorCode; |
比如将复杂的消息、请求构建、从返回值中获取结果的过程进行封装
AI生成题目
给AI的预设promat
系统promat
1 | 你是一位严谨的出题专家,我会给你如下信息: |
用户promat
1 | MBTI 性格测试, |
项目性能优化-AI生成题目优化
1.RxJava 响应式编程-优化
什么是响应式编程?
响应式编程(Reactive Programming)是一种编程范式(一种编程方法),它专注于 异步数据流 和 变化传播。
响应式编程的核心思想是“数据流是第一等公民”,程序的逻辑建立在数据流的变化之上。
响应式编程的几个核心概念:
数据流
:响应式编程中,数据以流(Streams)的形式存在。流就像一条河,源源不断、有一个流向(比如从 A 系统到 B 系统再到 C 系统),它可以被过滤、观测、或者跟另一条河流合并成一个新的流。
比如用户输入、网络请求、文件读取都可以是数据流,可以很轻松地对流进行处理。
比如 Java 8 的 Stream API,下列代码中将数组作为一个流,依次进行过滤、转换、汇聚。
1 | list.stream() |
异步处理
:响应式编程是异步的,即操作不会阻塞线程,而是通过回调或其他机制在未来某个时间点处理结果。这提高了应用的响应性和性能。变化传播
:当数据源发生变化时,响应式编程模型会自动将变化传播到依赖这些数据源的地方。这种传播是自动的,不需要显式调用。
举个例子,有一只股票涨了,所有 订阅 了这只股的人,都会同时收到 APP 的通知,不用你自己盯着看。
注意,响应式编程更倾向于声明式编程风格,通过定义数据流的转换和组合来实现复杂的逻辑。比如,可以利用 map、filter 等函数来实现数据转换,而不是将一大堆复杂的逻辑混杂在一个代码块中。
2. 什么是 RxJava?
RxJava 是一个基于事件驱动的、利用可观测序列来实现异步编程的类库,是响应式编程在 Java 语言上的实现。
这个定义中有几个概念,我们分别解释。
1、事件驱动
事件可以是任何事情,如用户的点击操作、网络请求的结果、文件的读写等。事件驱动的编程模型是通过事件触发行动。
比如前端开发中,用户点击按钮后会进行弹窗,这就是“点击事件”触发了“弹窗行动”
1 | ▼javascript复制代码// 前端按钮点击 |
在 RxJava 中,事件可以被看作是数据流中的数据项,称为“事件流”或“数据流”。每当一个事件发生,这个事件就会被推送给那些对它感兴趣的观察者(Observers)。
2、可观测序列
可观测序列是指一系列按照时间顺序发出的数据项,可以被观察和处理。可观测序列提供了一种将数据流和异步事件建模为一系列可以订阅和操作的事件的方式。
可以理解为在数据流的基础上封装了一层,多加了一点方法。
3. RxJava 的核心知识点
观察者模式
RxJava 是基于 观察者模式 实现的,分别有观察者和被观察者两个角色,被观察者会实时传输数据流,观察者可以观测到这些数据流。
基于传输和观察的过程,用户可以通过一些操作方法对数据进行转换或其他处理。
在 RxJava 中,观察者就是 Observer,被观察者是 Observable 和 Flowable。
Observable 适合处理相对较小的、可控的、不会迅速产生大量数据的场景。它不具备背压处理能力,也就是说,当数据生产速度超过数据消费速度时,可能会导致内存溢出或其他性能问题。
Flowable 是针对背压(反向压力)(类似与一个生产线,上一级生产太快,导致下一级的产品处理不完导致堆积)问题而设计的可观测类型。背压问题出现于数据生产速度超过数据消费速度的场景。Flowable 提供了多种背压策略来处理这种情况,确保系统在处理大量数据时仍然能够保持稳定。
被观察者.subscribe(观察者)
,它们之间就建立的订阅关系,被观察者传输的数据或者发出的事件会被观察者观察到。
常用操作符
前面提到用户可以通过一些方法对数据进行转换或其他处理,RxJava 提供了很多操作符供我们使用,这块其实和 Java8 的 Stream 类似,概念上都是一样的。
操作符主要可以分为以下几大类:
- 变换类操作符,对数据流进行变换,如 map、flatMap 等。
比如利用 map 将 int 类型转为 string
1 | ▼Flowable<String> flowable = Flowable.range(0, Integer.MAX_VALUE) |
- 聚合类操作符,对数据流进行聚合,如 toList、toMap 等。
将数据转成一个 list
1 | Flowable.range(0, Integer.MAX_VALUE).toList() |
- 过滤操作符,过滤或者跳过一些数据,如 filter、skip 等。
将大于 10 的数据转成一个 list
1 | Flowable.range(0, Integer.MAX_VALUE).filter(i -> i > 10).toList(); |
- 连接操作符,将多个数据流连接到一起,如 concat、zip 等。
创建两个 Flowable,通过 concat 连接得到一个被观察者,进行统一处理
1 | ▼// 创建两个 Flowable 对象 |
5)排序操作符,对数据流内的数据进行排序,如 sorted
1 | Flowable<String> flowable = Flowable.concat(flowable1, flowable2).sorted(); |
事件
RxJava 也是一个基于事件驱动的框架,我们来看看一共有哪些事件,分别在什么时候触发:
1)onNext,被观察者每发送一次数据,就会触发此事件。
2)onError,如果发送数据过程中产生意料之外的错误,那么被观察者可以发送此事件。
3)onComplete,如果没有发生错误,那么被观察者在最后一次调用 onNext 之后发送此事件表示完成数据传输。
对应的观察者得到这些事件后,可以进行一定处理,例如:
1 | flowable.observeOn(Schedulers.io()) |
4. 前后端通讯的几种模式
短轮询
轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。
短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
长轮询
长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。
长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。
SSE(推荐)
前端发送请求并和后端建立连接后,后端可以实时推送数据给前端,无需前端自主轮询。
SSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream
类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
WebSocket
全双工协议,前端能实时推送数据给后端(或者从后端缓存拿数据),后端也可以实时推送数据给前端。
5. SSE技术
基本概念
服务器发送事件(Server-Sent Events)是一种用于从服务器到客户端的 单向、实时 数据传输技术,基于 HTTP协议实现。
它有几个重要的特点:
- 单向通信:SSE 只支持服务器向客户端的单向通信,客户端不能向服务器发送数据。
- 文本格式:SSE 使用 纯文本格式 传输数据,使用 HTTP 响应的
text/event-stream
MIME 类型。 - 保持连接:SSE 通过保持一个持久的 HTTP 连接,实现服务器向客户端推送更新,而不需要客户端频繁轮询。
- 自动重连:如果连接中断,浏览器会自动尝试重新连接,确保数据流的连续性。
SSE 数据格式
SSE 数据流的格式非常简单,每个事件使用 data
字段,事件以两个换行符结束。还可以使用 id
字段来标识事件,并且 retry
字段可以设置重新连接的时间间隔。
1 | data: First message\n |
实现SSE
如果使用的Spring项目
1 |
|
应用场景
由于现代浏览器普遍支持 SSE,所以它的应用场景非常广泛,AI 对话就是 SSE 的一个典型的应用场景。
再举一些例子:
- 实时更新:股票价格、体育比赛比分、新闻更新等需要实时推送的应用。
- 日志监控:实时监控服务器日志或应用状态。
- 通知系统:向客户端推送系统通知或消息。
方案对比
熟悉了 SSE 技术后,对比上述前后端实时通讯方案。
主动轮询其实是一种伪实时,比如每 3 秒轮询请求一次,结果后端在 0.1 秒就返回了数据,还要再等 2.9 秒,存在延迟。
WebSocket 和 SSE 虽然都能实现服务端推送,但 Websocket 会更复杂些,且是二进制协议,调试不方便。AI 对话只需要服务器单向推送即可,不需要使用双向通信,所以选择文本格式的 SSE。
回归到本项目,具体实现方案如下:
1)前端向后端发送普通 HTTP 请求
2)后端创建 SSE 连接对象,为后续的推送做准备
3)后端流式调用智谱 AI,获取到数据流,使用 RxJava 订阅数据流
4)以 SSE 的方式响应前端,至此接口主流程已执行完成
5)异步:基于 RxJava 实时获取到智谱 AI 的数据,并持续将数据拼接为字符串,当拼接出一道完整题目时,通过 SSE 推送给前端。
6)前端每获取一道题目,立刻插入到表单项中
性能优化-AI评分
需求分析
目前的 AI 评分功能存在 2 个问题:
- AI 调用需要费用,用户对同样的题目做出同样的选择,理论会得到一样的解答,不需要每次都询问 AI
- AI 评分的响应时间较长,效率有待提升。
如何解决这些问题呢?
答案必然是 “缓存”。只要提到“响应慢”、“数据可复用”,就要想到缓存。
方案设计
技术选型
缓存的技术选型上,一般是本地缓存和 Redis 分布式缓存。
如果项目不考虑分布式或扩容、且不要求持久化,一般用本地缓存能解决的问题,就不要用分布式缓存,会增加系统的复杂度。
对于我们的缓存需求,哪怕是多机部署,每台服务器上分别缓存也是 ok 的,不用保证多台机器缓存间的一致性,所以采用 Caffeine 本地缓存。
缓存设计
要缓存哪些内容?如何设计缓存的 key / value 结构呢?
1)缓存 key 设计
回归到需求“用户对同样的题目做出同样的选择,理论会得到一样的解答”
所以可以将应用 id 和用户的答案列表作为 key。
但答案列表可能很长,可以利用哈希算法(md5)来压缩 key,节省空间。
注意,如果是分布式缓存,还需要在 key 开头拼接业务前缀。此处我们可以单独为每个业务创建本地缓存,相互隔离,所以 key 可以简单一些。
2)缓存 value 设计
缓存 AI 回答的结果,为了可读性可以存 JSON 结构,为了压缩空间可以存二进制等其他结构。
3)缓存过期时间设置
必须设置缓存过期时间!假设有 20 道题目,那么不同选择累计总次数一共是 2 的 20 次方,100 多万。
过期时间根据实际业务场景和缓存空间的大小、数据的一致性的要求设置,合适即可,此处可以设置为 1 天。
业务流程
1)在 AI 回答前,哈希处理用户答题选择,得到摘要,拼接缓存 key。
2)通过摘要查找缓存,若命中则直接返回答题结果。
3)若缓存中未找到,则请求 AI 回答。
4)正确解析 AI 返回的 JSON 后,将其放置在缓存中。
注意事项
1)应用题目发生变更时,需要清理缓存
2)主要针对 AI 去缓存
缓存击穿问题解决
思考:如果同一时刻有大量的用户答题,比如 1w 个用户,且答题选择都是一致的,但没有命中缓存(刚好过期),这时候会有 1w 个请求并发访问 AI。
这其实就是缓存击穿问题,即大量请求并发访问热点数据,刚好热点数据过期,会直接绕过缓存,命中数据库或 AI 接口。
在 AI 场景因接口限流,AI 应该不会崩溃,但是 token(钱)浪费了,而且搞不好平台会以为你的服务器是攻击者,把你的 IP 封禁。
在数据库场景,所有请求打到数据库上,数据库可能直接宕机。
因此,我们需要避免缓存击穿,一种常见的解决方式就是加锁。如果服务部署在多个机器上,就必须要使用分布式锁。
分布式锁不建议自己实现,理解原理即可。可以直接使用 Redisson 客户端,它为 Redis 提供了多种数据结构的支持,并提供了线程安全的操作,简化了在 Java 中使用 Redis 的复杂度。
Redisson 对 Redis 的一些功能进行了增强,如分布式锁、计数器、队列等,使得 Redis 的使用更加方便。
分库分表
这里我们先简单了解下分库分表的场景。
随着用户量的激增和时间的堆砌,存在数据库里面的数据越来越多,此时的数据库就会产生瓶颈,出现资源报警、查询慢等场景。
首先单机数据库所能承载的连接数、I/O 及网络的吞吐等都是有限的,所以当并发量上来了之后,数据库就渐渐顶不住了。
而且如果单表的数据量过大,查询的性能也会下降。因为数据越多底层存储的 B+ 树就越高,树越高则查询 I/O 的次数就越多,那么性能也就越差。
分库和分表怎么区分呢?
把以前存在 一个数据库 实例里的数据拆分成多个数据库实例,部署在不同的服务器中,这是分库。
把以前存在 一张表 里面的数据拆分成多张表,这是分表。
一般而言:
- 分表:是为了解决由于单张表数据量多大,而导致查询慢的问题。大致三、四千万行数据就得拆分,不过具体还是得看每一行的数据量大小,有些字段都很小的可能支持更多行数,有些字段大的可能一千万就顶不住了。
- 分库:是为了解决服务器资源受单机限制,顶不住高并发访问的问题,把请求分配到多台服务器上,降低服务器压力。
比如电商网站的使用人数不断增加, 用户数不断增加,订单数也日益增长,此时就应该把用户库和订单库拆开来,这样就能降低数据库的压力,且对业务而言数据分的也更清晰,并且理论上订单数会远大于用户数,还可以针对订单库单一升配。
由于电商网站品类不断增加,在促销活动的作用下订单取得爆炸式增长,如果所有订单仅存储在一张表中,这张表得有多大?
因此此时就需要根据订单表进行分表,可以按时间维度,比如 order_202401、order_202402 来拆分,如果每天的订单量很大,则可以通过 order_20240101、order_20240102 这样拆分。
如何实现
Sharding-JDBC
Sharding-JDBC 核心原理其实很简单,可以用几个字总结:改写 SQL
比如我们想根据 appId 来将对应的用户答题记录表进行分表。
将 appId % 2 等于 0 的应用的所有用户的答题记录都划分到 user_answer_0,等于 1 的应用的所有用户的答题记录都划分到 user_answer_1。
按照我们正常的想法处理逻辑就是:
1 | if(appId % 2 == 0){ |
而用了 Sharding-JDBC 后,我们只要写好配置,Sharding-JDBC 就会根据配置,执行我们上面的逻辑,在业务代码上我们就可以透明化分库分表的存在,减少了很多重复逻辑!
它会解析 SQL ,根据我们指定的 分片键,按照我们设置的逻辑来计算得到对应的路由分片(数据库或者表),最终改写 SQL 后进行 SQL 的执行。
简单来说就是根据分表字段取模来进行,就类似user_answer{appId}这种
你在哪个字段加索引,就用哪个字段分表,核心在于用户的查询,一定要根据业务的实际情况来。尽量避免出现跨表和跨库查询。
对于本项目,user_answer 有个天然的拆分字段即 appId,不同应用的用户答题记录没有关联,因此我们可以根据 appId 拆解 user_answer 表。
实现流程比较简单:
- 新建 user_answer_0 和 user_answer_1,作为 user_answer 表的分表
- 引入 Sharding-JDBC(引依赖)
- 配置文件中设置分表逻辑(主要就是在这里面)(改配置)
1 | spring: |
系统设计
幂等性判断
方案设计
利用数据库唯一索引
业务流程
唯一索引大家应该很熟悉,但是给用户答题记录表的哪个字段添加唯一索引呢?
id 一般是首要选择,因为本来就是唯一的。但由于是插入新数据,id 还没有生成,怎么办?
那我们造一个字段来作为唯一索引就好了!
- 前端进入答题页面的时候,请求后端返回一个全局唯一 id
- 用户提交回答的时候,前端不仅提交用户的选项,同时也需要带上这个全局唯一 id
- 后端可以将这个全局唯一 id 保存到数据库的某个唯一索引字段,利用数据库实现幂等性。
这样改造后,每个用户答题时,即使提交多次,也能避免多条记录的产生。