DeepSeek 是如何处理历史消息累积不卡顿的?
2025-06-04 · 13min之前在公司做过 AI 对话的项目,不过由于产品的需求,仅仅简单涉及到了 Markdown 渲染和 SSE (Server-Sent Events) 流式传输来达到流式渲染功能。
当时觉得 AI 对话好像其实也没什么难度,前端工作不还是掉用后端接口,把返回的数据按照流式的方式渲染在页面上吗?
直到最近才意识到自己多么无知。
当时的产品并不涉及历史对话的功能,用户每次提问都是一个全新的对话。
但如果是像 DeepSeek 这种,可以在一个对话里不断持续追问下去的 AI 对话产品,过往的 Markdown 渲染会积累出大量的 HTML 元素,这时候如何保证页面流程不卡顿?不阻塞新的回答都流式渲染呢?
虚拟渲染
大量 HTML 元素渲染导致页面卡顿的问题,第一反应就是想到去用虚拟渲染解决。
但我下一秒就立刻否决掉了虚拟渲染的方案。
因为我们的 Markdown 内容是不确定高度的,甚至流式渲染过程还会不断的变化,也就是我们需要等待一次提问的回答完整渲染完 Markdown 之后,才能获取一个确定的高度,需要非常复杂计算与更新。
而且流式渲染的过程中,还存在两种交互情况:
- 无用户操作时,容器要随着内容渲染保存滚动到底。
- 有用户操作时,保持在当前位置不动。
如果是第二种情况,我们是不是还得强制渲染当前正在流式渲染的内容?
好像也不是不行。

这种方案的虚拟列表方案需要用一个元素去撑出容器的滚动条,虚拟渲染的元素通过绝对定位来进行定位,不过这样计算依然是非常复杂。
以一次问答来作为虚拟渲染的元素单位来说:
- 当增加新问答时,我们需要计算该问答渲染的
top。 - 当新问答正在渲染时,还需要不断获取其高度,用于重新计算虚拟列表的总高度。
- 当新问答结束渲染时,需要将其位置,高度保存记录。
- 虚拟列表渲染的元素需要每个元素的位置、高度、元素之间的间隔以及滚动位置来计算,过于复杂。
复杂也不是不能做出来,只是这种过于复杂的方案我觉得性能肯定不会太好,一定是需要进行性能优化的,那又是进一步增加难度。
而且这个方案还有个致命缺陷:只适合在新对话中,虚拟列表的高度是随着每次问答流式渲染累加出来的。
如果我们是刷新了页面,点击旁边对话列表打开进来,这种情况下我们不可能要把所有元素渲染一遍来获取总高度再进行虚拟渲染吧?
缓存高度?也不是不行,但也只能缓存在本地呀。
而且如果你在手机用 DeepSeek 开启一个全新的对话,电脑再打开 DeepSeek 进入同一个对话,滚动条是有的,而且是触底的,所以肯定不会是通过缓存高度来解决的。
考虑到手机与电脑还有响应式的差异,不同设备的高度也不一样,存去后端也不可行呀。
DeepSeek 的方案
思考了一晚上实在想不出,除了分页下拉加载数据跟历史消息自动折叠之外,还有什么更好的方案了。
而且从用户角度来说,DeepSeek 也没用到分页下拉加载,也没有进行折叠,依然可以做到一个非常流畅的交互体验。
那干脆就去打开控制台看看 DeepSeek 看看它是怎么处理历史对话积累过多的问题的。
我直接找了一个非常多提问的对话来看,结果发现 DeepSeek 根本连虚拟渲染都没使用:

点开看也能看到我第一个提问的回答 Markdown 渲染元素确实存在,但是确实就是一点也不卡顿。

内容也确实累积了很大的高度。
抱着强烈的好奇心,我直接询问 DeepSeek 他们的网页到底是用了什么方案来避免历史问答累积过多元素导致卡顿的问题。

**省流:不用去问了,DeepSeek 网页端都没用他回答的方案,追问真正的技术实现方案他直接“服务器繁忙,请稍后再试”了。 **
以下是我根据他回答方案一些实测 + 踩坑。
渲染隔离
DeepSeek 给我提供的方案是智能折叠 + 渲染隔离的方案。
但是我没有看到除了深度思考的内容以外可以折叠的内容,但这个方案确实可以说是兜底方案。
主要看看渲染隔离是怎么实现的。
内容可见性优化
使用 content-visibility: auto 跳过视口外元素的渲染计算,减少布局和绘制时间。

重点:父容器不要用 flex 布局,理由下面讲。
Demo 遍历渲染了 30 份 Markdown,总共 29981 个元素。
DeepSeek 20次问答, 总共 27834 个元素。
实测下来,数据如下:
| 方案 | 滚动 FPS | Resize FPS |
|---|---|---|
| 直接渲染 | 稳定 144 FPS | 4 FPS,停止依然在阻塞渲染。 |
使用 content-visibility: auto | 稳定 144 FPS | 最低48 FPS,平均 62 FPS,不会阻塞渲染 |
| DeepSeek | 稳定 144 FPS | 最低32 FPS,平均 58 FPS,不会阻塞渲染 |
从结果来看,虽然 DeepSeek 自己没用这个方案,但是效果基本一致。
但为什么 DeepSeek 不用这个方案呢?查看 content-visibility 的兼容性,只有 Safari 是去年9月份更新的版本才支持的,也只能是这个原因才没有使用。
为什么父容器不能用 flex 布局
说回来为什么父容器不要使用 flex 布局。
使用 flex 布局情况下,能够清晰看到当设置了 content-visibility: auto 元素离开视窗一定距离时,元素跳过渲染,导致滚动条会出现很明显跳动现象。
虽然倒是可以搭配 contain-intrinsic-block-size 设置元素的占位高度来保持占位不变,但是如果父容器还设置了 gap,滚动条依然会有等同于这个 gap 值的跳动问题。
而且如果要获取容器高度,不就又回到了虚拟渲染方案的问题了吗?
至于为什么 flex 布局会出现这个现象,是因为 flex 布局依赖于子元素的尺寸进行计算, content-visibility: auto 会使子元素跳过渲染时,尺寸变为0。
所以当设置了 content-visibility: auto 的元素进入或跳过渲染时,都会重新触发 flex 布局的计算。
独立渲染层
通过contain: content创建独立渲染层,使用content-visibility: auto跳过视口外元素的渲染计算。
根据描述,contain 属性允许我们指定特定的 DOM 元素和它的子元素,让它们能够独立于整个 DOM 树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次都针对整个页面。
直接说说这次需要注意到的 contain 属性值 :
content:将除size外的所有局限规则应用于元素。此值等价于contain: layout paint style。layout:从页面的其余部分中隔离出元素的内部布局。此值意味着元素外的任意内容和元素内部布局互不影响。style:对于可在元素及其后代外产生影响的属性,其影响将不会逃离包含元素。计数器和引号的作用域被限制为元素及其内容。paint:元素后代不在元素边界外显示。若包含盒在屏外,则浏览器无需绘制其被局限的元素——这些元素因为完全局限于此盒故必定也在屏外。若后代元素溢出包含元素的边界,则此后代元素将被裁剪至包含元素的边框盒。
简而言之:
style 确保 CSS 样式作用范围。
layout 则是使元素独立出来,不被其他元素影响,可以说是其他元素重新渲染并不会导致该元素跟着重新渲染。
paint :会对溢出元素进行裁剪,只渲染没溢出的部分。
但实测下来,单独使用,无论是对容器或者消息内容进行设置,都跟直接渲染一样没区别...
与 content-visibility: auto 一起使用,从页面跟 Performance 报告来看,基本看不出区别。
资源管理
资源管理简而言之,就是对 Markdown 中的图片、表格以及代码块等重型资源,通过 IntersectionObserver 进行视窗监听,当离开视窗时,替换成等尺寸的占位符。
这点可以通过对 Markdown 的自定义渲染来实现,实现方式挺多的,有空单独写一篇来记录下。
总结
从实测来看,对消息容器设置 content-visibility: auto 确实既可以保证流式渲染的动态性,也避免累积元素过多导致页面卡顿的,能做到跟 DeepSeek 页面一样的效果。
但是这个总归还是有上限的,测试Demo 也只是3万个左右的元素进行测试,但极端情况,超过10W个元素的情况,我还是推荐使用物理折叠的方案来兜底。