AI 对话里的消息时间线分页:上滑加载更多历史记忆的实现与坑点

AI 对话里的消息时间线分页:上滑加载更多历史记忆的实现与坑点

前言

AI 对话页面看起来像一个普通列表。

但真做起来会发现,它和商品列表、文章列表、表格分页都不太一样。

普通列表更多是:

text 复制代码
从上往下看
滚到底部加载下一页
新数据追加在列表后面

而聊天消息时间线是:

text 复制代码
最新消息在底部
用户默认停留在底部
向上滑才是查看更早历史
更早消息要插到列表前面
插入后还不能让页面跳

这就是 AI 对话里"历史记忆分页"真正麻烦的地方。

当前项目是一个 Zero Code Agent 工作台,中间消息区使用 Ant Design X VueBubbleList 做消息展示。前面已经完成了 SSE 流式输出、打字机效果和 Markdown 渲染,这次继续补上更接近真实项目的能力:

消息时间线分页,上滑加载更多历史消息。

这篇文章主要记录我是怎么实现的,以及实际做这个能力时容易踩到哪些坑。

为什么聊天历史不能普通分页

普通分页常见做法是:

text 复制代码
pageNum = 1
pageNum = 2
pageNum = 3

但聊天消息不是特别适合这种方式。

因为聊天记录是持续增长的。

当用户正在翻历史时,底部可能还会新增消息。

如果用 pageNum,可能出现:

text 复制代码
第一页加载了最近 50 条
用户继续加载第二页
这时底部新增了一条消息
分页边界变化
第二页可能重复或者漏掉部分消息

所以聊天历史更常用 cursor 分页。

比如:

text 复制代码
第一次:给我最近 50 条
第二次:给我早于当前最早 createTime 的 50 条
第三次:继续给我更早的 50 条

对应接口参数就是:

ts 复制代码
pageSize
lastCreateTime

这里的 lastCreateTime 不是"最后一条最新消息",而是当前页面里最早消息的时间游标。

也就是:

text 复制代码
以当前最早消息为边界,继续向历史深处查询。

这比 pageNum 更适合聊天时间线。

当前项目的历史消息接口

项目里封装了一个接口:

ts 复制代码
export function listAppChatHistory(appId: EntityId, params: ChatHistoryListParams = {}) {
  const queryString = toQueryString({
    pageSize: params.pageSize,
    lastCreateTime: params.lastCreateTime,
  });

  return request<PageResult<ChatHistory>>(`/chatHistory/app/${appId}${queryString}`);
}

这里有两个关键参数:

参数 作用
pageSize 每次加载多少条历史消息
lastCreateTime 加载早于这个时间点的消息

第一次进入会话时,不传 lastCreateTime

ts 复制代码
listAppChatHistory(appId, {
  pageSize: 50,
});

后续上滑加载更多时,再传:

ts 复制代码
listAppChatHistory(appId, {
  pageSize: 50,
  lastCreateTime: historyCursor,
});

这样前端就可以用游标一点点向上加载更早的历史。

会话状态要补什么

一开始我们的 ConversationItem 只关心:

text 复制代码
messages
historyLoaded
status

但要做时间线分页,这还不够。

每个会话都需要维护自己的分页状态。

所以我给会话补了几个字段:

ts 复制代码
export interface ConversationItem {
  messages: AgentMessage[];
  historyLoaded?: boolean;
  historyCursor?: string;
  hasMoreHistory?: boolean;
  historyLoadingMore?: boolean;
}

这几个字段的职责分别是:

字段 作用
historyLoaded 当前会话是否已经加载过首屏历史
historyCursor 当前最早历史消息的时间游标
hasMoreHistory 是否还有更早历史可以继续加载
historyLoadingMore 是否正在加载更早历史

这里有一个关键点:

分页状态必须挂在 conversation 上,而不是挂在页面全局。

因为用户可能在多个会话之间切换。

每个会话的历史加载进度都不同。

比如:

text 复制代码
A 会话已经加载到三个月前
B 会话只加载了最近 50 条
C 会话还没打开过

如果用全局 historyCursor,很容易串。

所以更合理的是:

text 复制代码
每个 conversation 自己保存自己的历史游标和加载状态。

消息也要保留 createdAt

历史消息从后端返回时通常长这样:

ts 复制代码
interface ChatHistory {
  id: EntityId;
  message?: string;
  messageType?: 'user' | 'ai';
  createTime?: string;
}

映射成前端消息时,不能只保留 content

我给 AgentMessage 增加了:

ts 复制代码
createdAt?: string;

映射逻辑如下:

ts 复制代码
export function mapChatHistoriesToMessages(
  histories: ChatHistory[],
  conversationKey: string,
): AgentMessage[] {
  return [...histories]
    .sort((left, right) => getTimeValue(left.createTime) - getTimeValue(right.createTime))
    .map<AgentMessage>((history, index) => ({
      key: history.id || `${conversationKey}-history-${index}`,
      role: history.messageType === 'ai' ? 'assistant' : 'user',
      content: history.message || '',
      createdAt: history.createTime,
    }))
    .filter(item => item.content.trim());
}

这里做了三件事。

第一,按时间正序排序。

聊天页面最终应该是:

text 复制代码
更早消息在上面
更新消息在下面

第二,把后端 messageType 映射成前端角色:

text 复制代码
ai -> assistant
user -> user

第三,保留 createdAt

虽然当前游标是从原始 ChatHistory 里计算的,但前端消息保留 createdAt 之后,后续做本地排序、调试、锚点定位都会更方便。

首次加载最近一页历史

点击左侧会话时,会调用:

ts 复制代码
async function loadConversationMessages(conversationKey: string) {
  const target = findConversation(conversationKey);
  if (!target?.appId || target.historyLoaded || target.status === 'running') {
    return;
  }

  const result = await listAppChatHistory(target.appId, {
    pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
  });

  const historyMessages = mapChatHistoriesToMessages(result.records, target.key);

  target.messages = historyMessages.length
    ? historyMessages
    : [createWelcomeMessage(target)];

  target.historyLoaded = true;
  target.historyCursor = getEarliestHistoryCursor(result.records);
  target.hasMoreHistory = result.records.length >= WORKBENCH_HISTORY_PAGE_SIZE;
}

这里的逻辑是:

text 复制代码
找到当前会话
如果没 appId,不加载
如果已经加载过,不重复加载
如果正在流式生成,不打断
请求最近一页历史
映射成前端消息
设置 historyCursor
设置 hasMoreHistory

hasMoreHistory 这里用的是一个简单判断:

ts 复制代码
target.hasMoreHistory = result.records.length >= WORKBENCH_HISTORY_PAGE_SIZE;

意思是:

text 复制代码
如果后端返回数量达到 pageSize,就认为可能还有更多。
如果少于 pageSize,大概率已经到底了。

更严谨的做法是后端直接返回:

ts 复制代码
hasMore: boolean
nextCursor?: string

但当前后端结构是 PageResult,所以先用数量判断。

如何计算游标

游标取的是当前这一页里最早的 createTime

ts 复制代码
export function getEarliestHistoryCursor(histories: ChatHistory[]) {
  return histories.reduce<string | undefined>((cursor, history) => {
    if (!history.createTime) {
      return cursor;
    }

    if (!cursor) {
      return history.createTime;
    }

    return getTimeValue(history.createTime) < getTimeValue(cursor)
      ? history.createTime
      : cursor;
  }, undefined);
}

为什么不是取第一条?

因为你不能完全假设后端返回顺序一定符合前端需要。

更稳一点的做法是:

text 复制代码
遍历这一页所有记录
找到 createTime 最早的那一条
拿它作为下一次查询边界

这样即使后端返回是倒序,也不会影响下一页游标。

这就是一个真实项目里很常见的小防御。

上滑到顶部触发加载

消息展示组件里使用的是 BubbleList

vue 复制代码
<BubbleList
  ref="bubbleListRef"
  :items="conversation.messages"
  :roles="roles"
  :auto-scroll="true"
  :on-scroll="handleScroll"
/>

BubbleList 不负责请求数据,但它会把滚动事件暴露出来。

于是我们可以在 handleScroll 里判断是否接近顶部:

ts 复制代码
const LOAD_MORE_THRESHOLD = 48;

async function handleScroll(event: Event) {
  const scrollElement = event.target instanceof HTMLElement
    ? event.target
    : bubbleListRef.value?.nativeElement;

  if (
    !scrollElement
    || !props.loadMoreHistory
    || loadingMoreHistory.value
    || props.conversation.historyLoadingMore
    || !props.conversation.hasMoreHistory
    || scrollElement.scrollTop > LOAD_MORE_THRESHOLD
  ) {
    return;
  }

  // load more
}

这里没有要求 scrollTop === 0

而是设置了一个阈值:

ts 复制代码
const LOAD_MORE_THRESHOLD = 48;

原因是浏览器滚动、触摸板滚动、不同设备上的滚动精度都不一样。

如果非要等到绝对 0,触发体验会比较硬。

所以更真实的做法是:

text 复制代码
距离顶部 48px 以内就开始加载更早历史。

加载更早历史

业务层新增了一个方法:

ts 复制代码
async function loadMoreConversationMessages(conversationKey: string) {
  const target = findConversation(conversationKey);
  if (
    !target?.appId
    || !target.historyLoaded
    || !target.hasMoreHistory
    || !target.historyCursor
    || target.historyLoadingMore
    || target.status === 'running'
  ) {
    return false;
  }

  target.historyLoadingMore = true;

  try {
    const result = await listAppChatHistory(target.appId, {
      pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
      lastCreateTime: target.historyCursor,
    });

    const olderMessages = mapChatHistoriesToMessages(result.records, target.key);

    if (olderMessages.length) {
      target.messages = mergeOlderMessages(olderMessages, target.messages);
      target.historyCursor = getEarliestHistoryCursor(result.records) ?? target.historyCursor;
    }

    target.hasMoreHistory = result.records.length >= WORKBENCH_HISTORY_PAGE_SIZE;
    target.description = `${target.messages.length} 条历史消息`;
    return olderMessages.length > 0;
  } finally {
    target.historyLoadingMore = false;
  }
}

这里有几个判断非常重要:

text 复制代码
没有 appId,不加载
首屏历史没加载,不加载更多
没有更多历史,不加载
没有游标,不加载
正在加载更多,不重复请求
当前会话正在流式生成,不加载历史

这些判断看着啰嗦,但在真实项目里很必要。

因为用户操作是不受控的:

text 复制代码
快速滚动
频繁切换会话
边生成边翻历史
网络慢时重复触发 scroll
接口失败后再次上滑

如果没有这些门禁,很容易出现重复请求、消息乱序、游标错乱。

prepend,而不是 append

加载更早消息后,不能 append 到后面。

因为更早消息应该显示在当前消息的上方。

所以合并逻辑是:

ts 复制代码
export function mergeOlderMessages(
  olderMessages: AgentMessage[],
  currentMessages: AgentMessage[],
) {
  const currentKeys = new Set(currentMessages.map(item => item.key));

  return [
    ...olderMessages.filter(item => !currentKeys.has(item.key)),
    ...currentMessages,
  ];
}

这里顺手做了去重。

为什么要去重?

因为 cursor 边界经常容易重复。

比如当前最早消息时间是:

text 复制代码
2026-08-21 10:00:00

下一次请求:

text 复制代码
lastCreateTime=2026-08-21 10:00:00

如果后端条件写成了:

sql 复制代码
create_time <= lastCreateTime

那么边界这条消息可能会再次返回。

前端如果不去重,就会看到重复气泡。

所以即使后端应该保证:

sql 复制代码
create_time < lastCreateTime

前端也最好用 message.id 再兜一层。

这不是不信任后端,这是做产品级稳定性。

最大的坑:prepend 后页面会跳

这是聊天历史分页里最容易被忽略的坑。

假设用户已经滑到顶部,当前列表高度是:

text 复制代码
scrollHeight = 3000
scrollTop = 20

这时加载了 50 条更早消息,插入到顶部。

DOM 更新后列表高度可能变成:

text 复制代码
scrollHeight = 5200

如果你什么都不做,用户原本正在看的内容会被往下挤,视口位置会变得很奇怪。

用户感受到的是:

text 复制代码
页面突然跳了
我刚刚看到的那条消息不见了

所以必须做滚动锚定。

滚动锚定怎么做

当前项目的处理是:

ts 复制代码
loadingMoreHistory.value = true;
const conversationKey = props.conversation.key;
const previousScrollHeight = scrollElement.scrollHeight;
const previousScrollTop = scrollElement.scrollTop;

try {
  const loaded = await props.loadMoreHistory(conversationKey);
  if (!loaded || props.conversation.key !== conversationKey) {
    return;
  }

  await nextTick();

  const nextScrollHeight = scrollElement.scrollHeight;
  scrollElement.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
} finally {
  loadingMoreHistory.value = false;
}

核心公式是:

ts 复制代码
scrollElement.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);

它的意思是:

text 复制代码
新增内容让 scrollHeight 增加了多少
scrollTop 就同步增加多少

这样用户当前正在看的那块内容会尽量保持在原来的视觉位置。

可以理解成:

text 复制代码
更早消息插到上面
但用户视口锚定在原来的消息附近

这就是聊天时间线分页和普通列表分页最大的区别。

普通列表加载下一页是往下 append。

聊天历史加载上一页是往上 prepend。

往上 prepend 必须处理滚动锚定。

为什么要 nextTick

这里必须等:

ts 复制代码
await nextTick();

原因是 loadMoreHistory 里更新的是响应式数据:

ts 复制代码
target.messages = mergeOlderMessages(olderMessages, target.messages);

数据变了,不代表 DOM 立刻更新完成。

如果你在同一个 tick 里马上读取:

ts 复制代码
scrollElement.scrollHeight

拿到的可能还是旧高度。

所以正确顺序是:

text 复制代码
记录旧 scrollHeight
更新 messages
等待 Vue 完成 DOM 更新
读取新 scrollHeight
修正 scrollTop

这也是很多滚动 bug 的来源。

你以为自己算了高度差,其实算的是旧 DOM。

会话切换也是一个坑

还有一个真实场景:

text 复制代码
用户滑到顶部,触发加载 A 会话更早消息
请求还没回来
用户切换到了 B 会话
A 请求返回

如果这时继续拿当前 DOM 做滚动修正,就可能影响 B 会话。

所以代码里记录了触发加载时的会话 key:

ts 复制代码
const conversationKey = props.conversation.key;

请求完成后再判断:

ts 复制代码
if (!loaded || props.conversation.key !== conversationKey) {
  return;
}

如果当前会话已经变了,就不再做滚动修正。

这个判断很小,但非常重要。

真实项目里很多"偶现滚动错乱",都是这类异步返回后上下文已经变了导致的。

为什么加载中提示不放进 BubbleList

我做了一个顶部浮层:

vue 复制代码
<div v-if="conversation.historyLoadingMore" class="history-loading">
  正在加载更早消息...
</div>

它的样式是绝对定位:

css 复制代码
.history-loading {
  position: absolute;
  top: 8px;
  left: 50%;
  z-index: 2;
  border-radius: 999px;
  background: rgb(255 255 255 / 0.92);
  transform: translateX(-50%);
}

为什么不把它作为一条消息插进 BubbleList

因为它不是业务消息。

如果把 loading 当成一条 message,会带来几个问题:

text 复制代码
污染消息数组
影响时间线 key
影响 scrollHeight 计算
可能被复制、重试、Markdown 渲染
历史消息 prepend 时还要避开这条假消息

所以更合理的是:

text 复制代码
业务消息归 messages
加载状态归 UI 浮层

浮层不占据列表高度,也不参与消息数据。

这会让时间线更干净。

和 BubbleList 的关系

这里要明确一点:

BubbleList 不是历史分页组件。

它负责的是:

text 复制代码
渲染消息
处理角色样式
提供滚动容器
支持 autoScroll
暴露 onScroll
暴露 ref/nativeElement

而历史分页是业务逻辑。

所以当前架构是:

text 复制代码
BubbleList
  只管展示和滚动事件

ChatMessageList
  监听顶部滚动
  记录 scrollHeight
  触发 loadMoreHistory
  做滚动锚定

useWorkbenchChat
  请求历史接口
  维护 cursor / hasMore / loading
  合并和去重消息

我不建议把请求逻辑写进 ChatMessageList

更不建议指望 BubbleList 自动知道怎么加载历史。

UI 组件不应该认识你的后端分页协议。

autoScroll 和上滑加载是两套逻辑

消息区已经开启了:

vue 复制代码
:auto-scroll="true"

但这不代表上滑加载可以交给 autoScroll

autoScroll 的语义是:

text 复制代码
当用户在底部时,新消息到达,自动跟随到底部。

而上滑加载历史的语义是:

text 复制代码
用户正在顶部附近看旧消息,加载更早消息后,不要滚到底部。

这两个方向是相反的。

所以大厂项目里一般会拆成两个滚动策略:

text 复制代码
新消息 append:如果用户在底部,滚到底部
历史消息 prepend:保持当前阅读位置

不要试图用一个 autoScroll 解决所有滚动问题。

这是聊天时间线的基本认知。

实际还会遇到哪些坑

第一,后端边界条件要一致。

如果前端传:

text 复制代码
lastCreateTime = 当前最早消息 createTime

后端应该查:

sql 复制代码
create_time < lastCreateTime

而不是:

sql 复制代码
create_time <= lastCreateTime

否则边界消息会重复。

虽然前端做了去重,但后端语义也应该正确。

第二,时间字段要稳定。

如果多条消息 createTime 完全相同,仅靠时间游标可能不够。

更稳的 cursor 应该是:

text 复制代码
createTime + id

或者后端返回 opaque cursor。

比如:

ts 复制代码
nextCursor: 'eyJjcmVhdGVUaW1lIjoi...IsImlkIjoi...In0='

前端不解析,只带回去。

第三,消息 key 必须稳定。

历史消息应该优先使用后端 id

ts 复制代码
key: history.id

不能每次加载都用 Date.now()

否则 Vue 会认为这是全新的节点,滚动锚定和 diff 都会变差。

第四,Markdown 会改变高度。

AI 消息里有 Markdown、代码块、表格时,渲染后高度不是固定的。

如果后续代码高亮、图片加载、表格渲染继续改变高度,滚动位置可能还需要二次修正。

当前版本主要处理 prepend 后的 DOM 高度变化。

如果后面支持图片或异步代码块增强,就要考虑 ResizeObserver

第五,连续快速滚动会重复触发。

所以必须有:

ts 复制代码
loadingMoreHistory.value
props.conversation.historyLoadingMore

一个本地锁,一个业务状态锁。

本地锁防止同一个组件重复触发。

业务锁防止同一个会话重复请求。

当前实现的完整链路

最后把链路串起来:

text 复制代码
用户点击左侧会话
  ↓
loadConversationMessages
  ↓
请求最近 50 条历史消息
  ↓
mapChatHistoriesToMessages 转成前端消息
  ↓
设置 historyCursor / hasMoreHistory
  ↓
BubbleList 渲染 messages
  ↓
用户上滑到顶部 48px 内
  ↓
ChatMessageList 记录旧 scrollHeight / scrollTop
  ↓
loadMoreConversationMessages
  ↓
携带 lastCreateTime 请求更早历史
  ↓
mergeOlderMessages prepend 并去重
  ↓
nextTick 等 DOM 更新
  ↓
根据高度差修正 scrollTop
  ↓
用户视口保持稳定

这就是一个比较完整的消息时间线分页闭环。

后续可以怎么演进

当前实现已经能覆盖基础历史加载。

但如果要继续往大厂级体验走,可以继续做几件事。

第一,后端返回更标准的 cursor。

比如:

ts 复制代码
{
  records: [],
  hasMore: true,
  nextCursor: 'opaque-cursor'
}

前端不再自己用 records.length >= pageSize 判断。

第二,支持新消息提示。

当用户在上方看历史时,底部有新消息,不要强行滚到底部,而是显示:

text 复制代码
有新消息,点击查看

第三,支持虚拟列表。

当一个会话加载到几百上千条消息后,需要考虑虚拟滚动。

但聊天虚拟列表比普通列表更难,因为消息高度不固定。

第四,抽出专门 composable。

现在分页逻辑放在 useWorkbenchChat 里。

后面可以拆成:

text 复制代码
useConversationHistory
useMessageTimeline
useScrollAnchor

这样 useWorkbenchChat 不会越来越重。

第五,做更强的滚动锚定。

现在是基于 scrollHeight 差值修正。

更复杂场景可以用"锚点消息 key + offset":

text 复制代码
记录当前可视区域第一条消息 key
prepend 后找到同一条消息
恢复它相对容器顶部的位置

这种方式在动态高度特别复杂时更稳。

小结

AI 对话里的历史分页,本质不是普通列表分页。

它是一个消息时间线问题。

真正要处理的是:

text 复制代码
首次加载最近消息
上滑加载更早消息
cursor 分页
prepend 合并
消息去重
滚动锚定
并发保护
会话切换保护
和 autoScroll 分离

当前项目的实现思路是:

text 复制代码
业务层维护 cursor / hasMore / loading
展示层监听顶部滚动
加载前后记录 scrollHeight
prepend 后修正 scrollTop
BubbleList 继续只负责消息展示和基础滚动容器

这套方案不复杂,但它抓住了聊天时间线分页最关键的点:

加载更早消息时,用户当前阅读位置不能跳。

这个细节做好了,历史记忆加载才会像一个真正的 AI 产品,而不是一个普通列表硬套聊天界面。

相关推荐
PedroQue991 小时前
Vue-Router 2.4.0 新增可控重定向功能
前端·uni-app
阿萨德528号2 小时前
npm 包发布实战指南:从零发布、更新迭代到版本治理
前端·npm·策略模式
IT_陈寒2 小时前
Vue的v-for为啥把我的渲染顺序搞乱套了?
前端·人工智能·后端
sir.山2 小时前
在 Vue 3 项目中使用 Mock 数据
前端·vue.js·vue3·vite
李剑一3 小时前
华为新上Pura X View阔直板手机,特殊屏幕比例设备下,前端应该怎么去适配更完美?送你一套完整工具代码
前端
0xBADCODE3 小时前
Flask SSTI读SECRET_KEY+伪造Session:税务系统渗透全流程
前端·后端·python·安全·web安全·网络安全·flask
01_ice3 小时前
html个人简历展示
前端·html
程序员爱钓鱼3 小时前
Rust where详解:让泛型与Trait约束更加清晰
前端·后端·rust
zzzzzz3104 小时前
react-bits 为什么会吸引前端开发者:别把动效当装饰,先把它当成页面能力
前端·react.js·动效