AI客服聊天记录优化:从全量加载到游标分页

文章目录

  • 前言
    • 背景
    • [第一战:SSE 滚动劫持](#第一战:SSE 滚动劫持)
    • [第二战:Cursor 分页](#第二战:Cursor 分页)
      • 效果:
      • 原始问题
      • [方案选择:Cursor 还是 Offset?](#方案选择:Cursor 还是 Offset?)
      • [底层 SQL 对比](#底层 SQL 对比)
        • [Offset 分页(SQL 标准写法)](#Offset 分页(SQL 标准写法))
        • [Cursor 分页(SQL 标准写法)](#Cursor 分页(SQL 标准写法))
      • 核心理解
      • 换个数据库验证
      • [所以 Prisma 在这里做了什么?](#所以 Prisma 在这里做了什么?)
      • 一句话总结
      • 数据库层
      • [API 层](#API 层)
      • 前端层
    • 第三战:踩坑合集
      • [坑 1:`nextCursorCreatedAt` 用 state 导致的死循环 ------ 致命的闭包过期](#坑 1:nextCursorCreatedAt 用 state 导致的死循环 —— 致命的闭包过期)
      • [坑 2:Observer 依赖 `isLoadingHistory` 导致来回销毁/重建](#坑 2:Observer 依赖 isLoadingHistory 导致来回销毁/重建)
      • [坑 3:Observer 回调里的状态检查形同虚设](#坑 3:Observer 回调里的状态检查形同虚设)
      • [坑 4:首次加载后不滚到底部](#坑 4:首次加载后不滚到底部)
      • [坑 5:nodejieba 原生模块在 Next.js 构建时丢失](#坑 5:nodejieba 原生模块在 Next.js 构建时丢失)
    • 最终架构一览
    • 核心收获

前言

习惯公众号阅读的玩家 🚀https://mp.weixin.qq.com/s/27Q1DFa2gTb56EULRSK4Rg

记录我在一个 Next.js + Prisma + PostgreSQL 的 AI 聊天项目中,对聊天记录加载逻辑进行深度优化的完整过程。涉及 SSE 滚动劫持、游标分页、无限加载、闭包陷阱等多个真实踩坑经历。


背景

项目是一个基于 Next.js 15 App Router 的 AI 聊天应用,类似 ChatGPT 的交互体验:用户发起问题后,后端通过 SSE(Server-Sent Events)流式返回 AI 的回复,前端逐字渲染。

上线运行一段时间后,暴露了三个体验问题:

  1. SSE 流式输出时,用户向上翻阅历史会被强制拉回底部
  2. 打开一个对话后,所有历史消息一次性加载,消息多了会很慢
  3. 频繁刷新页面会触发多次重复的 /api/get-chat 请求

下面按解决顺序展开。


第一战:SSE 滚动劫持

原始问题

ChatPanel.tsx 中,每次 SSE 推送一个字符 chunk,都会触发 setMessages 更新状态:

ts 复制代码
// 原代码 ------ 每次 messages 变化都强制 scrollIntoView
useEffect(() => {
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

用户一旦向上翻阅历史,下一个 chunk 到达 → setMessages 更新 → useEffect 触发 → 瞬间被拉回底部。完全无法浏览历史。

第一次尝试:用 state 跟踪滚动位置

ts 复制代码
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false)

const handleScroll = useCallback(() => {
  const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 120
  setIsUserScrolledUp(!nearBottom)
}, [])

useEffect(() => {
  if (!isUserScrolledUp) {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }
}, [messages, isUserScrolledUp])

结果:不生效,还是被拉回。

根因:React 状态更新的时序窗口

setIsUserScrolledUp(true) 是一个异步调度 。如果在 state 生效之前 SSE 又推送了 chunk,useEffect 看到 isUserScrolledUp 仍然是 false,继续滚动。在高速 SSE 流式场景下,这个窗口几乎必定命中。

最终方案:useLayoutEffect + 直接读 DOM

移除中间状态 isUserScrolledUp,在 useLayoutEffect同步读取 DOM 的真实滚动位置来做判断:

ts 复制代码
useLayoutEffect(() => {
  const container = scrollContainerRef.current
  if (!container) return

  // 标记位优先:初始加载或需调整位置时跳过常规检测
  if (pendingScrollAdjRef.current) {
    const { prevScrollHeight } = pendingScrollAdjRef.current
    container.scrollTop += container.scrollHeight - prevScrollHeight
    pendingScrollAdjRef.current = null
    return
  }

  if (shouldScrollToBottomRef.current) {
    shouldScrollToBottomRef.current = false
    container.scrollTop = container.scrollHeight
    return
  }

  // 直接读 DOM ------ 不受 React 状态异步影响
  const threshold = 15
  const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold
  if (nearBottom) {
    container.scrollTop = container.scrollHeight
  }
}, [messages])

关键点

  • useLayoutEffect 在 DOM 更新后、浏览器绘制前同步执行 ,读到的 scrollTop 是真实的
  • container.scrollTop = container.scrollHeight 代替 scrollIntoView({ behavior: 'smooth' }),避免平滑动画与用户手动滚动的冲突
  • 用 ref 做标记位来处理特殊场景(初始加载、prepend 历史消息后的位置修正),而不引入额外的 state 导致渲染抖动
这样在AI返回结果的同时,用户向上查看历史记录,互不影响。

第二战:Cursor 分页

效果:

原始问题

/api/get-chat?conversationId=xxx 返回该对话的全部消息,无分页:

ts 复制代码
const messages = await prisma.openRouterChat.findMany({
  where: { conversationId },
  orderBy: { createdAt: 'asc' },
})

一个对话积累几百上千条消息后,全量加载 + 全量 DOM 渲染会直接拖垮性能。

方案选择:Cursor 还是 Offset?

Cursor 分页是纯 SQL 概念,Prisma 只是把它包装了一层语法糖。

底层 SQL 对比

无论用不用 Prisma,最终落到数据库的都是同样的 SQL。

Offset 分页(SQL 标准写法)
sql 复制代码
-- SQL 标准
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
ORDER BY "createdAt" ASC
LIMIT 30 OFFSET 90;
typescript 复制代码
// Prisma
prisma.openRouterChat.findMany({
  where: { conversationId: 'xxx' },
  orderBy: { createdAt: 'asc' },
  take: 30,
  skip: 90,
})
Cursor 分页(SQL 标准写法)
sql 复制代码
-- SQL 标准(不使用 OFFSET,而是 WHERE 条件过滤)
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
  AND ("createdAt", "id") < ('2026-05-20T12:00:00Z', 'cm7xxxx')
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 30;
typescript 复制代码
// Prisma(cursor API 方式)
prisma.openRouterChat.findMany({
  where: { conversationId: 'xxx' },
  orderBy: { createdAt: 'asc' },
  take: 30,
  skip: 1,
  cursor: { id: 'cm7xxxx' },
})
typescript 复制代码
// 或者不用 Prisma cursor API,直接用 where 也能实现(推荐)
await prisma.$queryRaw`
  SELECT * FROM "OpenRouterChat"
  WHERE "conversationId" = ${conversationId}
    AND ("createdAt", "id") < (${lastCreatedAt}, ${lastId})
  ORDER BY "createdAt" DESC, "id" DESC
  LIMIT ${limit}
`

核心理解

概念 是谁的
SELECT ... LIMIT N OFFSET M SQL 标准(广泛支持)
WHERE createdAt < $cursor 代替 OFFSET SQL 标准(任何数据库都支持)
cursor: { id: 'xxx' }, skip: 1 Prisma 的语法糖,编译后生成上面的 SQL
cursor 字段必须是 @id@@unique Prisma 的限制(SQL 本身没有这个限制)
ORDER BY createdAt ASCWHERE createdAt < $cursor 配合 纯 SQL 设计模式,与 ORM 无关

换个数据库验证

同样的游标分页,不用 Prisma,用 pg 驱动直连写也是一样的:

typescript 复制代码
// 只用 pg 驱动,不用任何 ORM
const result = await pool.query(`
  SELECT * FROM "OpenRouterChat"
  WHERE "conversationId" = $1
    AND "createdAt" < $2
  ORDER BY "createdAt" DESC
  LIMIT $3
`, [conversationId, lastCreatedAt, limit])

换 MySQL、SQLite 也一样,只是语法微调(MySQL 用 ? 占位,SQLite 同 PostgreSQL)。

所以 Prisma 在这里做了什么?

Prisma 的 cursor: { id: 'xxx' } 帮你生成了类似这样的 SQL:

sql 复制代码
-- Prisma 内部生成的 SQL(简化)
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
  AND "id" > 'cm7xxxx'           -- ← cursor 展开成 WHERE 条件
ORDER BY "createdAt" ASC, "id" ASC  -- ← 自动补 id 做 tiebreaker
LIMIT 30
OFFSET 1                           -- ← 跳过游标本身

但 Prisma 的限制是 cursor 必须用 @id@@unique 字段,而 SQL 本身没有这个限制------你可以用任何字段做 WHERE 过滤来实现游标。

一句话总结

LIMIT 是 SQL 语法,OFFSET 是 SQL 语法,WHERE col > $cursor 也是 SQL 语法。 Prisma 只是把 WHERE id > $cursor 包装成了 cursor: { id: xxx } 这样更声明式的写法。如果 Prisma 的限制让你束手束脚(比如 cursor 字段必须唯一),直接写 WHERE createdAt < $cursor 就行了,完全绕过 Prisma 的 cursor API。

维度 Offset (skip/take) Cursor (WHERE createdAt < $cursor)
深层分页性能 线性衰减(跳过的行仍需扫描) O(1) 恒定
并发写入稳定性 SSE 持续插入数据会偏移 不受影响
随机跳页 ✅ 支持 ❌ 不支持(聊天不需要)
数据库索引要求 (conversationId) (conversationId, createdAt)

聊天场景天然是「无限滚动 + 持续追加」的模型,Cursor 分页完美匹配。

数据库层

第一步,给 OpenRouterChat 表加复合索引:

js 复制代码
model OpenRouterChat {
  id               String   @id @default(cuid())
  userId           String
  conversationId   String
  role             String
  content          String
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt

  @@index([conversationId, createdAt])  // ← 游标分页的核心索引
  @@index([userId, createdAt])          // ← 对话列表查询
}

API 层

ts 复制代码
// src/app/api/get-chat/route.ts
const cursorCreatedAt = searchParams.get('cursorCreatedAt')
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)

if (conversationId) {
  const where: Record<string, unknown> = { conversationId }
  if (cursorCreatedAt) {
    where.createdAt = { lt: new Date(cursorCreatedAt) }
  }

  // 降序取 limit+1 条(多取 1 条判断 hasMore,免去额外 count 查询)
  const messages = await prisma.openRouterChat.findMany({
    where,
    orderBy: { createdAt: 'desc' },
    take: limit + 1,
  })

  const hasMore = messages.length > limit
  if (hasMore) messages.pop()
  messages.reverse() // 恢复为升序展示

  return NextResponse.json({
    messages,
    nextCursorCreatedAt: hasMore ? messages[0]?.createdAt.toISOString() : null,
    hasMore,
  })
}

设计亮点

  • take: limit + 1:多取 1 条判断是否还有更多数据,避免一次额外的 count 查询
  • cursorCreatedAt 做游标键:利用 @@index([conversationId, createdAt]),WHERE + ORDER BY 走复合索引,不回表
  • 首次请求不传 cursorCreatedAt,取最新 N 条

前端层

ts 复制代码
const PAGE_SIZE = 10
const sentinelRef = useRef<HTMLDivElement>(null)
const nextCursorRef = useRef<string | null>(null)  // 注意:ref 而非 state!
const isLoadingHistoryRef = useRef(false)           // 同步防重锁

// IntersectionObserver 监听 sentinel 进入视口
useEffect(() => {
  if (!hasMore) return

  const sentinel = sentinelRef.current
  if (!sentinel) return

  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting && currentConversationId) {
        loadOlderMessages()
      }
    },
    { rootMargin: '200px 0px 0px 0px' }  // 提前 200px 触发,无感加载
  )

  observer.observe(sentinel)
  return () => observer.disconnect()
}, [hasMore, currentConversationId])

rootMargin: '200px 0px 0px 0px' 是体验的关键------触顶前 200px 就开始拉数据,等用户真正滚动到顶部时数据已经就绪。


第三战:踩坑合集

坑 1:nextCursorCreatedAt 用 state 导致的死循环 ------ 致命的闭包过期

现象:上滚加载历史时,第 2、3、4... 次请求的 cursor 始终是同一个值,反复请求同一页数据。

根因useEffect 的依赖数组里没有 nextCursorCreatedAt(它是 state),Observer 闭包捕获了初始值。而 hasMore 始终是 true(一直有更旧的数据),所以 useEffect 不会重建 Observer。

js 复制代码
hasMore 不变 (true → true)
  → Observer 不重建
    → loadOlderMessages 闭包里的 nextCursorCreatedAt 永不变
      → 每次请求相同的 cursor
        → 返回相同数据
          → hasMore 还是 true
            → 死循环

修复 :用 useRef 代替 useState 存储 cursor:

ts 复制代码
const nextCursorRef = useRef<string | null>(null)

// 写入(同步生效)
nextCursorRef.current = data.nextCursorCreatedAt

// 读取(始终是最新值,不受闭包影响)
`/api/get-chat?cursorCreatedAt=${nextCursorRef.current}&limit=${PAGE_SIZE}`

坑 2:Observer 依赖 isLoadingHistory 导致来回销毁/重建

原本依赖是 [hasMore, isLoadingHistory, currentConversationId],每次 isLoadingHistory toggle 都 disconnect → reconnect,sentinel 重新进入视口立即触发。

修复 :依赖精简为 [hasMore, currentConversationId],防重逻辑下沉到 loadOlderMessages 内部用 ref 做同步检查:

ts 复制代码
if (isLoadingHistoryRef.current) return   // ← ref 同步检查,无延迟
isLoadingHistoryRef.current = true         // ← 立即加锁

坑 3:Observer 回调里的状态检查形同虚设

ts 复制代码
// 修复前 ------ hasMore 和 isLoadingHistory 是闭包捕获的过期值
if (entry.isIntersecting && hasMore && !isLoadingHistory && currentConversationId)

// 修复后 ------ 只做最小检查,所有权限控制下沉到函数内部
if (entry.isIntersecting && currentConversationId)

坑 4:首次加载后不滚到底部

useLayoutEffect 中的 nearBottom 检测公式 scrollHeight - 0 - clientHeight < 15 在首次加载时 scrollTop = 0,条件永远不成立。

修复 :用 shouldScrollToBottomRef 标记初始加载完成,在 useLayoutEffect 中优先处理。

坑 5:nodejieba 原生模块在 Next.js 构建时丢失

nodejieba 是 C++ 原生模块,webpack 打包时路径错乱。修复只需要一行配置:

ts 复制代码
// next.config.ts
serverExternalPackages: ['nodejieba'],

最终架构一览

js 复制代码
用户打开对话
    ↓
GET /api/get-chat?conversationId=xxx&limit=10
    ↓ (走 @@index([conversationId, createdAt]))
SELECT ... WHERE conversationId = 'xxx'
ORDER BY createdAt DESC LIMIT 11
    ↓
返回 { messages(10条), nextCursorCreatedAt, hasMore }
    ↓
用户上滚 → sentinel(哨兵) 进入视口 (提前 200px)
    ↓
useLayoutEffect: shouldScrollToBottomRef → 强制滚底
    ↓
GET /api/get-chat?conversationId=xxx&cursorCreatedAt=...&limit=10
    ↓ (cursor 直接定位,O(1))
SELECT ... WHERE conversationId = 'xxx' AND createdAt < '...'
ORDER BY createdAt DESC LIMIT 11
    ↓
prepend 到消息列表 + useLayoutEffect 修正 scrollTop(不跳动)

SSE 流式输出中:
    useLayoutEffect → 读 DOM scrollTop → 在底部?→ 滚到最新
                                  → 不在底部?→ 什么都不做 + 显示浮窗按钮

核心收获

  1. 跟 DOM 有关的判断尽量直接读 DOM 而非用 state 中转。React 状态是异步的,DOM 是同步的。

  2. Cursor 分页不是 ORM 特性,是 SQL 层的设计模式。Prisma 的 cursor API 有限制(必须 @id/@@unique),但你可以用 where.createdAt = { lt: cursor } 绕过,本质是一样的。

  3. IntersectionObserver 的回调闭包会过期。回调里用到的 state 值需要放在 useEffect 依赖中,但这又会导致 Observer 重建。解法是把真正「会变的值」用 useRef 存,Observer 回调里直接读 ref。

  4. rootMargin 是无限滚动的体验魔药。提前 200px 触发加载,用户根本察觉不到分页的存在。

  5. 一行 serverExternalPackages 配置可以免去大量原生模块的折腾。

相关推荐
星辰AI1 小时前
AI 应用安全最佳实践:保护数据和系统安全
人工智能·ai·语言模型
AI科技星1 小时前
基于光速螺旋拓扑模型的宇宙时空特征周期研究
人工智能·线性代数·架构·概率论·学习方法
路远_61 小时前
Token、上下文、Prompt:大模型应用开发的三个基础概念
开发语言·人工智能
毕设做完了吗?1 小时前
YOLO+paddlecor的智能车牌识别系统
人工智能·python·yolo·目标检测·计算机视觉
ZHW_AI课题组1 小时前
基于Grounded-SAM-2的动态场景目标检测
人工智能·目标检测·机器学习·视觉检测
Zevalin爱灰灰1 小时前
智能控制 第七章——智能控制算法介绍(部分)(二)
人工智能·智能控制
li星野1 小时前
RAG优化系列:自适应检索(Adaptive Retrieval)——让系统智能选择是否检索
人工智能·python·学习
前端不太难1 小时前
具身智能:下一代人工智能的产业新范式
人工智能·状态模式
碳基硅坊1 小时前
Qwen3.6-27B 本地部署三大工具:Ollama、LM Studio、llama.cpp 谁更快?
人工智能·llama·大模型部署