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 配置可以免去大量原生模块的折腾。

相关推荐
清酒难寻8 小时前
深度学习进阶(二十四)Swin 的二维 RPE
人工智能·深度学习
步步为营DotNet8 小时前
借助 Microsoft.Extensions.AI 与 ASP.NET Core 10 实现智能 Web 应用故障预测
人工智能·microsoft·asp.net
AI创界者8 小时前
零基础上手!ComfyUI + LTX-2.3 图生视频完整工作流搭建与调优指南(附避坑细节)
大数据·人工智能
怕浪猫9 小时前
AI图片工具到底有哪些?一份按能力维度整理的清单
人工智能
hongmai6668889 小时前
FH8856V310芯片详解:6M高清+0.5TOPS算力,赋能智能安防新方案
人工智能·单片机·嵌入式硬件·物联网·智能家居
一颗小树x9 小时前
NVIDIA Jetson Thor 运行 LLM / VLM:模型全整理与 vLLM 实践
人工智能·llm·jetson·vllm
每日综合9 小时前
蓝白风暴席卷BW2026!雷克沙展台首日燃情纪实
人工智能
To_OC9 小时前
手搓 LangChain 工具调用:原来 Agent 的核心逻辑,就是个 while 循环
人工智能·langchain·llm
Drgfd9 小时前
机器人从工厂走进生活:通用机器人开启大众商用时代
人工智能
硕迪科技SOLIDWORKS9 小时前
SOLIDWORKS与ERP对接不再是研发孤岛:硕迪科技数据同步方案让BOM流转提速百分之三百
人工智能·科技·solidworks与erp