AI 对话里的消息时间线分页:上滑加载更多历史记忆的实现与坑点
前言
AI 对话页面看起来像一个普通列表。
但真做起来会发现,它和商品列表、文章列表、表格分页都不太一样。
普通列表更多是:
text
从上往下看
滚到底部加载下一页
新数据追加在列表后面
而聊天消息时间线是:
text
最新消息在底部
用户默认停留在底部
向上滑才是查看更早历史
更早消息要插到列表前面
插入后还不能让页面跳
这就是 AI 对话里"历史记忆分页"真正麻烦的地方。
当前项目是一个 Zero Code Agent 工作台,中间消息区使用 Ant Design X Vue 的 BubbleList 做消息展示。前面已经完成了 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 产品,而不是一个普通列表硬套聊天界面。