AI 聊天消息长列表性能优化:后端分页 + 前端虚拟滚动

大家好,今天想和大家分享一个我们在开发 AI 多模态聊天应用 时遇到的性能挑战以及解决方案。随着用户使用时间的增加,单个会话的聊天记录可能会积累到成百上千条,这时候性能问题就变得非常突出了。经过一番调研和实践,我们采用了 "后端分页 + 前端虚拟滚动" 的组合方案,效果显著。下面就来详细介绍一下我们的实现思路和遇到的问题。

前言

在开发 AI 聊天应用的过程中,我们发现随着用户使用频率的提高,单个会话的聊天记录会越来越多。最初的实现方式是一次性加载所有历史消息 ,这就导致了两个明显的问题:一是接口响应缓慢 ,用户需要等待较长时间才能看到消息;二是前端渲染时出现明显卡顿,影响用户体验。

为了解决这些问题,我们研究了多种长列表优化方案,最终选择了 "后端分页 + 前端虚拟滚动" 的组合策略。经过实践检验,这个方案不仅显著提升了性能,还保证了良好的用户体验。

问题分析

性能瓶颈在哪里?

在决定优化方案之前,我们首先对现有系统进行了全面的性能分析,发现了两个主要瓶颈:

  1. 网络传输延迟:一次性传输大量聊天记录导致首字节时间(TTFB)过长,用户需要等待 1-2 秒才能看到内容。
  2. DOM 渲染压力:每条聊天消息都包含用户输入和 AI 回复,实际渲染的 DOM 节点数量是消息数量的两倍。当消息数量达到数千条时,浏览器的重绘和重排开销变得非常巨大,导致滚动不流畅。

优化目标

基于以上分析,我们制定了明确的优化目标:

  • 首屏加载时间控制在 500ms 以内
  • 滚动流畅度达到 60fps
  • 支持无限加载历史记录
  • 整个优化过程对用户体验无感知

技术方案

我们采用了经典的 "分段加载" 策略,结合后端和前端的优势,形成了一个完整的解决方案:

markdown 复制代码
┌─────────────────────────────────────┐
│          后端分页                      │
│  - 按时间倒序分页查询                 │
│  - 默认返回最新 20 条记录             │
│  - 支持向前翻页加载更早消息           │
└─────────────────────────────────────┘
             ↓
┌─────────────────────────────────────┐
│          前端虚拟滚动                  │
│  - 只渲染可视区域的消息               │
│  - 动态测量每条消息的真实高度         │
│  - 滚动到顶部自动加载更多             │
└─────────────────────────────────────┘

这个方案的核心思想是 "按需加载""按需渲染":后端只返回当前需要的数据,前端只渲染用户当前能看到的内容。

后端实现:基于 Prisma 的智能分页

核心代码实现

我们的后端采用 Node.js + Prisma + MySQL 的技术栈,分页实现的关键在于高效的查询和有用的元数据返回:

javascript 复制代码
async getSessionDetail(sessionId, userId, queryParams = {}) {
   const { page = 1, limit = 20 } = queryParams
   
   // 参数验证和边界处理
   const pageNum = Math.max(1, parseInt(page))
   const limitNum = Math.min(100, Math.max(1, parseInt(limit)))
   const skip = (pageNum - 1) * limitNum
   
   // 并行查询记录和总数,优化性能
   const [chatRecords, totalRecords] = await Promise.all([
       prisma.chatRecord.findMany({
           where: { session_id: sessionId, user_id: userId },
           select: {
               id: true,
               request_text: true,
               response_text: true,
               think_text: true,
               create_time: true,
               // ... 其他字段
           },
           orderBy: { create_time: 'desc' }, // 最新的在前
           skip,
           take: limitNum,
       }),
       prisma.chatRecord.count({
           where: { session_id: sessionId, user_id: userId },
       }),
   ])
   
   // 计算分页元信息
   const totalPages = Math.ceil(totalRecords / limitNum)
   const hasNext = pageNum < totalPages
   const hasPrev = pageNum > 1
   
   return {
       chat_records: formattedRecords.reverse(), // 翻转顺序,让最早的在前
       pagination: {
           page: pageNum,
           limit: limitNum,
           total: totalRecords,
           total_pages: totalPages,
           hasNext,  // 前端据此判断是否还有更多数据
           hasPrev,
       },
   }
}

设计要点

1. 为什么倒序查询再翻转?

我们选择先按 create_time desc 排序查询,这样可以利用索引快速获取最新的 N 条记录。查询完成后再通过 reverse() 方法翻转顺序,确保前端拿到的数据是按时间正序排列的(最早的在前),这样更符合用户查看聊天记录的习惯。

2. 分页元数据的妙用

我们发现返回 hasNexthasPrev 比单纯返回 total_pages 更实用:

  • 前端不需要自己计算 "是否还有更多数据"
  • 避免了总页数变化带来的边界问题
  • 减少了前端的逻辑处理

3. 参数边界处理

为了防止恶意请求或不合理的参数导致系统问题,我们添加了参数边界处理:

javascript 复制代码
const pageNum = Math.max(1, parseInt(page))            // 防止 page < 1
const limitNum = Math.min(100, Math.max(1, parseInt(limit))) // 限制单页最大数量

这是生产环境中非常必要的防护措施。

前端实现:高性能虚拟滚动

虚拟滚动的核心思路

虚拟滚动的本质是 "视口裁剪",也就是说,无论总数据量有多大,我们只渲染当前可视区域内的内容,再加上少量缓冲区内容:

复制代码
总数据:1000 条消息
可视区:只能看到 5 条
实际渲染:5 + 上下缓冲区 = 15 条

其余的内容用空白占位符来撑开滚动高度,给用户一种所有内容都已加载的错觉。

自研虚拟滚动工具

我们封装了一个 virtualScrollUtil.js 工具,提供了虚拟滚动所需的核心能力:

1. 动态高度测量

聊天消息的高度是不固定的(短消息可能只有几十像素,长消息可能有几百像素),因此需要在运行时动态测量每条消息的实际高度:

javascript 复制代码
const measureVisible = async () => {
   await nextTick()
   let changed = false
   for (const row of visibleItems.value) {
       const el = itemRefs.get(row.id)
       if (el) {
           const h = el.offsetHeight
           if (h && itemHeights.get(row.id) !== h) {
               itemHeights.set(row.id, h)  // 缓存高度
               changed = true
           }
       }
   }
   // 高度变化时重新计算可见范围
   if (changed) computeVisibleRange()
}

2. 缓冲区机制

为了避免快速滚动时出现白屏现象,我们在可视区上下各增加了一定数量的缓冲行:

javascript 复制代码
const estRowsPerViewport = Math.max(1, Math.ceil(viewportH / estimateItemHeight))
const buf = Math.max(1, Math.floor(estRowsPerViewport * bufferMultiplier))
start = Math.max(0, start - buf)
end = Math.min(rows.length, end + buf)

经过多次测试,我们发现将 bufferMultiplier 设为 0.75 效果最佳,即缓冲区约为 0.75 个可视区高度。

3. 顶部插入数据不跳动

当用户向上滚动加载更多历史消息时,如果处理不当会导致页面跳动,影响体验。我们的解决方案是:

javascript 复制代码
// 加载前记住当前滚动高度
const prevScrollHeight = container.scrollHeight

// 插入新数据到顶部
chatMessages.value[sessionId] = [...newMsgs, ...list]

// 加载后恢复滚动位置
await nextTick()
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - prevScrollHeight

关键在于计算新增内容的高度差,并将 scrollTop 向下偏移相应的距离,让用户感觉不到内容的变化。

与分页的配合

前端使用 Pinia 来管理每个会话的分页状态:

javascript 复制代码
// 分页状态管理
const paginationState = ref({
   [sessionId]: {
       currentPage: 1,
       hasMore: false,
       isLoading: false,
       total: 0,
       totalPages: 0,
   }
})

// 滚动到顶部时自动加载
const handleScroll = () => {
   scroller.onScroll()  // 更新虚拟滚动范围
   
   const container = messagesContainer.value
   const nearTop = container.scrollTop <= 40
   if (nearTop && pagination.value?.hasMore && !isLoadingMore.value) {
       loadOlder()  // 加载更早的消息
   }
}

当用户滚动到距离顶部 40px 时,自动触发加载更早的消息。这个阈值经过多次测试,既能保证及时加载,又不会过于频繁地触发请求。

性能优化效果

优化前后的对比数据非常显著:

指标 优化前 优化后 提升
首屏加载时间 1.8s 0.3s 83% ↓
首屏渲染 DOM 数量 2000+ 40 95% ↓
滚动 FPS 30-40 55-60 50% ↑
内存占用(1000 条消息) ~180MB ~45MB 75% ↓

这些改进直接转化为了更好的用户体验,特别是在消息量较大的会话中,用户可以流畅地浏览历史记录。

踩过的坑

在实现这个方案的过程中,我们遇到了不少问题,这里分享几个主要的 "坑":

坑 1:去重问题

由于采用分页加载,当用户快速操作时可能会导致重复加载相同的数据。我们的解决办法是使用 Set 存储已加载的记录 ID

javascript 复制代码
// 使用 Set 存储已加载的记录 ID
const seenRecordIdsMap = ref({})

const getSeenSet = (sessionId) => {
   if (!seenRecordIdsMap.value[sessionId]) {
       seenRecordIdsMap.value[sessionId] = new Set()
   }
   return seenRecordIdsMap.value[sessionId]
}

// 加载时过滤重复数据
data.chat_records.forEach((record) => {
   if (seen.has(record.id)) return  // 已存在,跳过
   seen.add(record.id)
   // ... 添加到消息列表
})

坑 2:会话切换时的状态清理

不同会话之间的分页状态是独立的,切换会话时需要注意:

  • 不要清空已缓存的消息数据(用户可能会切回来)
  • 每个会话的分页状态要独立存储
  • 虚拟滚动范围要重新计算

坑 3:新消息到达时的滚动策略

当有新消息到达时,我们希望只有在用户当前浏览到列表底部时才自动滚动到底部,否则会打断用户查看历史消息:

javascript 复制代码
const isNearBottom = () => {
   const c = messagesContainer.value
   if (!c) return false
   const delta = c.scrollHeight - (c.scrollTop + c.clientHeight)
   return delta <= 80  // 距底部 80px 内才自动滚动
}

watch(currentMessages, () => {
   if (isNearBottom()) {
       scrollToBottom()
   }
}, { deep: true })

总结

通过 后端分页 + 前端虚拟滚动 的组合方案,我们成功将长列表性能提升了一个数量级。这套方案的核心在于:

  1. 按需加载:只加载用户需要看的数据
  2. 按需渲染:只渲染用户能看到的 DOM
  3. 智能预加载:缓冲区机制保证滚动流畅
  4. 体验优先:无感知的数据加载和状态切换

如果你的应用也面临长列表性能问题,不妨试试这套方案。我们的代码已经在生产环境稳定运行了一段时间,效果良好。


技术栈:Vue 3 + Pinia + Vite + Node.js + Prisma + MySQL

源码参考

  • 虚拟滚动工具:virtualScrollUtil.js
  • 后端分页服务:sessionService.js
  • 前端集成示例:ChatView.vue

多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示)多模态AI项目开发中...

希望这篇分享对你有所帮助,如果你有更好的想法或遇到了什么问题,欢迎在评论区交流讨论!

相关推荐
TZOF2 小时前
TypeScript的对象如何进行类型声明
前端·后端·typescript
一只叁木Meow2 小时前
DOM元素尺寸属性详解:offset、client、scroll
前端
原则猫2 小时前
单例模式工程运用
前端·设计模式
degree5202 小时前
使用 Web Vitals 量化网页性能:从 LCP、FID 到 CLS 实战优化
前端
水晶浮游2 小时前
💥 半夜3点被拉群骂?学会Sentry监控后,现在都是后端背锅了
前端
Olrookie2 小时前
若依前后端分离版学习笔记(十九)——导入,导出实现流程及图片,文件组件
前端·vue.js·笔记
阿水实证通3 小时前
能源经济大赛选题推荐:新能源汽车试点城市政策对能源消耗的负面影响——基于技术替代效应的视角
大数据·人工智能·汽车
视觉人机器视觉3 小时前
机器视觉Halcon3D中,六大类3D处理算子
人工智能·计算机视觉·3d·视觉检测
GAOJ_K3 小时前
从汽车传动到航空航天:滚珠花键的跨领域精密革命
人工智能·科技·机器人·自动化·制造