ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 3 聊天机器人进阶:上下文管理、流式输出与持久化实战

Vue 3 聊天机器人进阶:上下文管理、流式输出与持久化实战

这一篇是《用Vue实现小Q聊天机器人》系列的第三篇。前两篇我们把项目从零搭了起来——消息列表能渲染、输入框能发消息、后端接口也联通了,小Q总算能回两句话。但做到那个程度,最多算个 Demo:你连续问几句,它就把前面的对话忘光了;点完发送之后页面干等着,好几秒钟没反应;刷新一下浏览器,刚才聊的全没了。这些问题不解决,小Q就没法真的拿出去用。

这一篇的目标只有一个:把小Q从“能聊天”打磨到“好用”。我不打算盲目加新功能,而是集中解决三件影响体验的大事情——多轮对话的上下文管理、流式输出与打字机效果、消息持久化与会话恢复。另外我会把移动端输入和滚动定位的适配补上,最后列出这个阶段我踩过的一堆坑。整体技术栈还是本系列前两篇一直在用的 Vue 3 + Vite,状态管理用组合式函数,跟前文结构保持一致。

1. 先把定位说清楚:这一篇与前两篇的区别

1.1 从 Demo 到能用的三个差距

先说我判断“能不能用”的三个硬标准。第一个是“失忆”的问题。单轮问答只需要把当次问题发给后端,但真实聊天几乎都是多轮对话。用户说“你刚才不是说支持吗”,机器人得知道“刚才”指的是哪句话。这里当然可以完全依赖后端做会话存储,但如果后端是无状态的——很多对话接口都是这个模式——那前端必须自己把历史消息整理好,在每次请求里一起送过去。这个工作不做,小Q聊两句就变成一个金鱼记忆只有七秒的机器人。

第二个是“枯等”的问题。不是所有后端接口都支持流式返回,但支持的那部分,如果你按普通请求去写,就得等整个回复生成完,页面才跳出来一段文字。这个时间放在真实场景里可能得三十秒,三十秒白屏,用户早关页面了。哪怕最终结果一模一样,流式返回带来的体感差异也是天壤之别。打字机效果很多人觉得只是个花活,但它其实是一个很自然的“正在思考”的信号,人看到文字在逐字增加,会比看到一个静止的 loading 转圈更安心。

第三个是“丢档”的问题。聊天这件事用户默认是连续的,今天聊完明天接着聊,或者翻到十天前还想看看当时的上下文。如果刷新一次页面全部归零,这个产品就谈不上能用。会话持久化不复杂,但它牵涉异步数据库操作、恢复时机、写入防抖,属于典型的“细节不多、坑不少”的活。这三个差距正是本篇三个核心章节的引子,我先记住、再流畅、后存档,顺着这条线往下走。

1.2 技术选型:为什么继续用 Vue 3

前两篇已经搭好了 Vue 3 + Vite,这一篇没必要推翻重来。组合式 API 在聊天场景里的优势很明显:状态可以被收敛在useChat这样的小函数里,上下文数组、发送状态、流式字符缓冲都能作为独立 ref 暴露给组件;响应式系统又天然适合消息列表这种“推模式”数据流,后端推一段文本,视图就更新一段,不用手动操作 DOM。

有人可能会问,要不要引入 Pinia?我的看法是,对于单页面的聊天场景,一个组合式函数就已经够了。聊天状态只在当前页面用,也不涉及跨页面共享,引入 Pinia 反而多写了一层。除非后面要做会话列表独立页面、需要多个组件共享同一份聊天数据,那再考虑集中管理也不迟。这个选择不算什么最佳实践,只是个务实的取舍,你完全可以根据自己的项目结构调整。

1.3 本篇内容地图

后面的结构是这样的:第二节讲多轮对话的上下文怎么维护、怎么裁剪;第三节讲流式输出完整接入和打字机效果;第四节讲消息持久化和会话恢复;第五节补上移动端输入和滚动定位的适配;第六节整理常见问题排查表。每一节都能直接照着写,代码我尽量贴完整的,关键参数和解释放在代码前后的文字里。

2. 多轮对话的上下文管理:让小Q记住前面说了什么

2.1 为什么上下文要由前端来管

现在很多对话接口都是“无状态”的:你每次调用它,它只看你这次传进去的 messages 数组。从接口设计者的角度来看,它不关心“你是谁”“你们之前聊了什么”,这些上下文得由调用方自己组装。所以前端去管理上下文,不是把活揽在自己身上,而是本来就在合理分工里。

我习惯用一个电话录音的类比:单轮问答等于打电话只说一句就挂;多轮对话像把通话全程录音,每次提问之前把前面一段录音稿一并念给对方。前端做的事情,就是把录音稿压缩到对方能接受的篇幅。随着聊天越来越长,录音稿也在变长,我们就需要在合适的位置动刀,保留离当前问题最近的部分。

实际项目里有两种常见实现。一种是前端维护上下文数组,每次请求全量或部分携带;另一种是后端按 session 维护,前端只传一个会话 ID。选前端方案的前提是后端无状态、前端要做本地恢复、并且方便调试。如果后端已经支持 session_id,那前端就不必拼 content 历史了,把小 Q 接上有状态后端的时候,这部分逻辑可以整体简化。但我后面要讲的持久化思路,依然能跟后端方案共存,只是不需要再往请求里塞历史了。

2.2 消息结构与上下文裁剪策略

我在小Q里给消息定义了三个字段:role、content、timestamp。role 只有 user 和 assistant 两种取值,timestamp 主要给排序和持久化用。有人会在每条消息里存一个 id,但本地聊天记录用不上,生成 id 反而增加无意义的复杂度。系统提示词不属于用户消息,我单独存,不混在上下文数组里。

裁剪策略需要同时考虑两个维度。第一个是轮数上限,默认保留最近 20 条消息,也就是说用户和助手各算十条。注意这里有个容易搞混的细节:slice(-20)保留的是最后 20 条消息,不是最后 20 轮对话。如果要严格按轮数算,用户和助手各算一条,就应该给 40 的上限,或者干脆按“每组 user+assistant 算一轮”来处理。

第二个是字符数上限。轮数裁剪能处理大多数情况,但遇到单条消息特别长的时候就不够用了。比如用户直接粘贴了一篇文章进来,这一条消息就可能把接口的 token 上限冲爆。我在代码里加了MAX_CHARS = 6000的软限制,从消息数组尾部往前累加字符数,超过上限就从头部开始丢,保证离当前问题最近的内容完整。系统提示词固定在 messages 最前面,不参与裁剪。

2.3 用组合式函数 useChat 管理消息状态

直接看代码,我把它写在useChat.js里。

// useChat.js import { ref } from 'vue' const MAX_MESSAGES = 40 const MAX_CHARS = 6000 const messages = ref([]) const systemPrompt = ref('你是小Q,一个友好、简洁的智能助手。') export function useChat() { function addMessage(role, content) { messages.value.push({ role, content, timestamp: Date.now(), }) } function buildContext() { let list = messages.value.slice(-MAX_MESSAGES) let totalChars = systemPrompt.value.length for (let i = list.length - 1; i >= 0; i--) { if (totalChars + list[i].content.length > MAX_CHARS) { list = list.slice(i + 1) break } totalChars += list[i].content.length } return [ { role: 'system', content: systemPrompt.value }, ...list.map(({ role, content }) => ({ role, content })), ] } function resetChat() { messages.value = [] } return { messages, addMessage, buildContext, resetChat, systemPrompt } }

buildContext的逻辑我解释一下:先从尾部拿最近 40 条消息,然后从后往前累计字符数。如果加上某条消息后超过了 6000 字符,就把这条消息以及它前面的全部丢弃,只保留它后面的部分。这样能保证临近当前问题的历史不被裁掉,只把最远的内容扔掉。返回的时候,system prompt 永远是第一项,符合大多数接口对 messages 数组的格式要求。

实际发送时我怎么用?先调用addMessage('user', 输入内容),再拿buildContext()作为请求体:

addMessage('user', input.value) const payload = { messages: buildContext(), stream: true, } // 这里再走 fetch 发送

这里有个调试技巧:第一次联调时把buildContext()的结果打印到控制台,亲眼看一眼每条历史消息长什么样。很多上下文相关的诡异问题,在打印结果里一眼就能看出来,比如角色顺序错了、系统提示词丢了、或者历史消息里混入了空内容。

2.4 前端维护上下文的常见误区

第一个误区是把全部历史一直堆下去。最早我犯过这个错,想着“模型能处理的上下文那么长,前端干嘛要截断”。结果连续聊了五十多轮之后接口直接报错,一看日志是请求体太大了。第二道防线就是字符数限制,截断策略宁可保守一点也不要挑战接口上限。

第二个误区是会忘记加时间戳。一开始消息结构里只有 role 和 content,后来做持久化时才意识到需要知道每条消息什么时候发的,否则排序和恢复都会很乱。好在这个成本很低,push 的时候顺手加一个Date.now()就行。

第三个误区是忽略“用户正在输入中”的状态。上下文数组只管历史记录,当前输入框内容不应该在还没发送时就被塞进上下文。否则用户打了几个字删掉,又打了几个字,最后发出去之前上下文就已经变了好几轮,接口收到的历史全乱套。我只在真正发送、确定要交给后端的那一刻才addMessage。

3. 流式输出与打字机效果:从干等到逐字生成

3.1 流式输出的原理,以及为什么放弃 EventSource

流式输出用到的原理在 Web 里叫 Server-Sent Events,简称 SSE。普通 HTTP 请求是后端把整个 body 攒完一次性返回;SSE 响应头部带上Content-Type: text/event-stream,后端每生成一段文本就 flush 一次,前端在同一个长连接里持续读取。这样用户看到的是文字一段段冒出来,体感自然比一个 loading 转圈等三十秒好得多。

那为什么不直接用原生EventSource?两个痛点。第一,EventSource的请求头不可定制,很多对话接口需要带 token 或者密钥,原生 API 加不上去。第二,中断控制很弱,想主动停止当前生成得额外 hack。所以我直接用fetch接流式响应,代码虽然稍微多写几行,但可控性高很多,也能兼容部分后端实现的非标准数据格式。

另一种方式是 WebSocket,它适合双向高频通信,比如语音输入、服务端主动推送。但如果只是“发起一个提问然后拿回复”,SSE 已经足够。用 fetch 流式读还有一个额外好处:当后端暂时不支持流式时,同一套 fetch 逻辑回退到普通 JSON 响应也很容易,不用动组件层。

3.2 fetch 流式解析完整实现

来看代码。我在发送函数里维护三个关键状态:isSending用来锁发送按钮,rawText存后端推过来的全部原始文本,displayText是最终显示在气泡里的文本。

let controller = null const rawText = ref('') const displayText = ref('') const isSending = ref(false) async function sendMessage() { if (isSending.value) return const text = input.value.trim() if (!text) return addMessage('user', text) input.value = '' isSending.value = true rawText.value = '' displayText.value = '' controller = new AbortController() try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ messages: buildContext(), stream: true, }), signal: controller.signal, }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('data:')) continue const data = trimmed.slice(5).trim() if (data === '[DONE]') continue try { const json = JSON.parse(data) const delta = json.content || '' if (delta) { rawText.value += delta displayText.value = rawText.value } } catch (e) { console.warn('解析 SSE 数据失败:', data, e) } } } } catch (err) { if (err.name === 'AbortError') { console.log('用户已手动停止生成') } else { console.error('请求失败:', err) } } finally { isSending.value = false controller = null } }

几个关键点我单独强调。

注意:TextDecoder解码时一定要传{ stream: true }。不传的话,中文字符在跨包传输时会被截断,显示出来就是一个乱码的“�”。这是流式输出里最容易踩的坑。

为什么buffer变量要存在?因为 SSE 数据是按行传输的,但一个 TCP 包可能只带半行内容。我把每个包 decode 后先追加到 buffer,再按换行切分,最后一段未闭合的字符串要留到下一轮。如果不这么做,遇到半行就会被错误解析。

有些后端用的不是data:前缀,或者行尾是\r\n。处理这类情况可以在split('\n')之后把\r替换掉,或者判断行内容时多兼容一种前缀。具体以你对接的后端为准,这就是调试时要打开 Network 面板看原始响应格式的原因。

3.3 打字机效果与中断停下的处理

上面的代码是一收到内容就整段更新displayText,适合不以打字机效果为目标的场景。如果小Q想要逐字蹦出来的效果,那就不能在后端推多少就渲染多少,而是要加一层缓冲。

思路很简单:流式解析仍然往rawText里追加,但气泡绑定的是displayText。另起一个定时器,每隔 30ms 从rawText的未消费部分取 1 到 3 个字符,拼接到displayText上。

let typingTimer = null let typedIndex = 0 function startTyping() { if (typingTimer) return typingTimer = setInterval(() => { if (typedIndex >= rawText.value.length) { clearInterval(typingTimer) typingTimer = null return } const step = Math.min(3, rawText.value.length - typedIndex) displayText.value += rawText.value.slice(typedIndex, typedIndex + step) typedIndex += step }, 30) }

每次推进 1 到 3 个字符,具体看你想让文字蹦得快还是慢。间隔 30ms 是我实验下来比较舒服的数字:太短了显得生硬,太长了用户会着急。如果后端本身推流速度很快,typedIndex和rawText的长度差会很快被消费完,视觉效果就是“一句一句往外冒”,这也很自然。

有一点容易忘:用户点击“停止生成”后,除了controller.abort()要关闭网络连接,还得把setInterval清掉,并且把rawText里还没显示的部分一次性补到displayText上。否则画面会卡在半句话上,看起来像是程序崩了。

function stopGenerating() { if (controller) controller.abort() if (typingTimer) { clearInterval(typingTimer) typingTimer = null } displayText.value = rawText.value typedIndex = rawText.value.length }

3.4 性能取舍与渲染细节

性能上最容易出问题的地方,不是 fetch 读取,而是把每一次rawText追加都同步触发一大堆 Vue 响应式依赖。如果你的气泡组件里还顺便 computed 了整个消息列表长度、重新渲染了附近很多节点,那推流一快就会卡。小Q里displayText是一个独立的 ref,气泡只绑定它,所以每次只更新那一个文本节点,代价很小。

如果你做的聊天机器人未来要支持 Markdown 渲染,那要注意不能每个字符都跑一遍完整 Markdown 转换。通常做法是流式阶段只渲染纯文本,回复结束后再一次性转 Markdown。这个是后续优化的方向,基础版本不需要碰。

另一个细节是,如果后端根本不是流式返回,而是一次性给了一整段文本,那打字机效果其实是在人为地拖延时间。这种情况下我一般不做打字机,直接把displayText.value = rawText.value渲染出来就好。打字机只有在“后端真的在逐步生成”时才有意义。

4. 消息持久化与会话恢复:刷新之后一切都还在

4.1 存储方案对比:localStorage、sessionStorage 还是 IndexedDB

先给结论:小Q的会话记录我用 IndexedDB,不用 localStorage。

localStorage 的优点是简单、同步,一个setItem就能存。但它的容量通常在 5MB 左右,聊天记录这种持续增长的数据很容易碰到上限。更麻烦的是同步读写,一次写几 MB 的 JSON 字符串,主线程会被卡一卡,手机上体感明显。

sessionStorage 只跟标签页生命周期绑定,刷新页面数据还在,但关掉标签页就没了。对于“今天聊完明天接着聊”这种需求,它只解决了一半。

IndexedDB 异步、容量大、适合存结构化对象。缺点是没有封装 API,代码写起来像回调地狱。不过我们只用一个表、存一个对象,手写一个不到一百行的薄封装足够用了。我倾向不为了这点功能引一个重量级数据库库,除非团队本来就在用。

4.2 手写一个极简 IndexedDB 封装

我在项目里建了一个db.js,只暴露三个方法:保存会话、获取全部会话、删除会话。

// db.js const DB_NAME = 'xiaoq-chat-db' const DB_VERSION = 1 const STORE = 'threads' function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE, { keyPath: 'id' }) } } request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } export async function saveThread(thread) { const db = await openDB() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite') tx.objectStore(STORE).put(thread) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) } export async function getAllThreads() { const db = await openDB() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly') const req = tx.objectStore(STORE).getAll() req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) } export async function deleteThread(id) { const db = await openDB() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite') tx.objectStore(STORE).delete(id) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) }

keyPath: 'id'的意思是每条会话对象里必须有一个 id 字段作为主键。id 我用Date.now().toString()生成,足够满足本地的唯一性需求。

我在openDB里没有做连接复用,每次调用都重新开一个数据库连接。对一个个人项目来说完全够用,而且能少踩版本迁移相关的坑。如果将来并发写入量大了,再改成模块级缓存一个db实例。

4.3 会话保存时机与恢复逻辑

一条会话的数据结构长这样:

const thread = { id: '1700000000000', title: '关于时间管理的讨论', messages: [ { role: 'user', content: '你好', timestamp: 1700000000000 }, { role: 'assistant', content: '你好,我是小Q', timestamp: 1700000000001 }, ], updatedAt: 1700000000001, }

保存时机我一开始做得很粗暴:每条消息一进messages就写一次库。结果是聊了五十多轮之后,只要消息一多,明显感觉到写库卡顿,而且大部分写入都是白费的。后来我改成 debounce 五百毫秒的批量写入:消息变更后,五百毫秒内如果再次变更就重置计时器,等稳定下来再真正落库。这个优化立竿见影。

离开页面的兜底也很重要:用户在页面里连续聊天,五百毫秒的 debounce 很可能还没触发就关掉了页面。我在beforeunload里强制保存一次当前会话。不过 IndexedDB 的异步写入在页面卸载时不一定能完成,所以更稳的做法是每次切换会话或者新建会话时,把旧会话完整写库一次。这样即使最后一次beforeunload丢了,损失也只是最近几秒的内容。

恢复逻辑相对直接:应用启动时调用getAllThreads(),按updatedAt倒序排列,默认加载最近一条会话到useChat的 messages 里。注意这里有个异步时序问题,IndexedDB 读取是异步的,页面首次渲染时 messages 大概率还是空数组。我加了一个loadingThread的状态,读取完成前显示一个“正在恢复会话”的占位提示,避免用户看到空白列表还以为是 bug。

4.4 遇到的持久化坑

第一个坑是我前面说过的“每条消息立刻写库”,性能问题。

第二个坑是标题。我没做标题输入,而是从第一条用户消息里截前十二个字作为会话标题。这个逻辑本身简单,但要记得过滤掉换行和多余空格,否则标题里会出现一大坨空白。

第三个坑是“切换会话时把当前会话覆盖了”。我最初是在 A 会话切到 B 会话时,直接给 messages 赋 B 的值,但 A 的修改还没保存。后来我规定所有写操作都走同一个updateCurrentThread(),它负责合并 thread 对象并触发保存,组件里不再各自操作 messages。这样虽然多写了一个入口函数,但把状态变更收敛到了同一个地方,排查问题会省很多事。

5. 移动端体验与输入交互细节:键盘弹起和自动滚到底

5.1 底部输入框为什么不建议用 fixed 定位

聊天页面最常见的布局是顶部标题、中间消息列表、底部输入框。很多第一版实现会把输入框用position: fixed; bottom: 0钉在底部,桌面上是没问题的,但手机上一弹键盘就露馅:fixed 元素会被键盘顶上来挡住,或者键盘先收起来、输入框还停留在错误的位置。

我在小Q里改成了 flex 布局。根容器占满视口高度,消息列表flex: 1撑开剩余空间,输入框作为正常文档流里的最后一个 flex 子项。这样键盘弹起时浏览器视口高度变化,容器会自动跟着收缩,输入框就稳稳地待在键盘上方。

.chat-root { height: 100vh; height: 100dvh; display: flex; flex-direction: column; } .message-list { flex: 1; overflow-y: auto; } .input-bar { padding-bottom: env(safe-area-inset-bottom); }

100dvh是动态视口高度,专门用来处理移动端地址栏伸缩导致的100vh溢出问题。老浏览器不认dvh,所以我在它前面先写一个100vh兜底,再用100dvh覆盖。

如果某些安卓浏览器用 dvh 也解决不了键盘遮挡,最后的手段是监听visualViewport的 resize 事件,动态调整消息列表高度。这个方案稍微麻烦,但确实是大部分厂商浏览器都兼容的兜底方式。

5.2 自动滚到底的正确姿势

聊天页面自动滚到底这事,看起来只是scrollTop = scrollHeight一行代码,但直接写在消息更新后面往往不生效,因为 DOM 元素的高度还没重新计算。正确做法是等 Vue 完成 DOM 更新后再滚。

import { nextTick } from 'vue' async function scrollToBottom() { await nextTick() const el = listRef.value if (!el) return el.scrollTop = el.scrollHeight }

光这样还不够。如果用户正往上翻历史,每来一条新消息都强制拉回底部,体验会很差。我加了一个“是否接近底部”的判断:滚动容器距离底部小于 80px 时,认为用户还在底部附近,新消息到达就自动滚;超过 80px 就认为用户在翻历史,不打扰。

function onScroll() { const el = listRef.value if (!el) return isNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 80 }

再配合上面那个scrollToBottom,只有isNearBottom为 true 时才执行自动滚动。这个 80px 的阈值不是写死的标准答案,我试过 50 和 100,80 在触屏上感觉最自然,你可以按自己的偏好调。

5.3 输入交互细节与扩展方向

输入框我会做三件事:回车发送、Shift+Enter 换行、发送后让输入框重新获得焦点。这三件事看起来基础,却直接影响聊天效率。尤其是“发送后重新聚焦”,如果不做,用户每发一条消息都要手动点一下输入框,中断对话流。

发送按钮的位置我做了个小设计:同一个位置两种状态。空闲时是“发送”按钮,发送中变成“停止”按钮,点击后调用前面写的stopGenerating()。这个交互和很多聊天产品一致,用户不需要额外找停止按钮在哪里。

消息列表如果越聊越长,到几千条时整块渲染一定卡。虚拟列表在聊天这种动态高度场景里实现成本偏高,我建议先做一个便宜的折中:只渲染最近 200 条消息,超出部分不渲染但保留在数据里。配合“向上滚动加载更多”的分页逻辑,性能和实现成本能取得一个平衡。

6. 常见问题排查与避坑速查:这个阶段我踩过的七个坑

6.1 七个坑逐个说

第一个坑是中文乱码。现象是流式输出到某个位置突然出现“�”。我第一反应是后端问题,后来发现是前端TextDecoder使用不当。解决办法就是在decode(value, { stream: true })里带上 stream 参数。排查这类问题可以在控制台打印原始 chunk,如果看到的是半个字的 UTF-8 片段,就能确认是解码问题。

第二个坑是上下文超限导致 400。现象是连续聊了五十多轮之后请求直接报错。解决办法就是前面那段MAX_MESSAGES和MAX_CHARS双限制。这里再提醒一次,slice(-20)保留的是 20 条消息,不是 20 轮。

第三个坑是自动滚动失效。现象是新消息到了但页面停在原来的位置。解决办法就是nextTick之后再滚动。这个坑出现的原因很典型:消息内容改变 DOM 高度需要时间是异步的,同步拿 scrollHeight 一定是旧值。

第四个坑是重复消息。用户在等待回复途中连点了好几次发送,后端把同一条内容处理好几遍,上下文里全是重复。解决办法是发送锁:isSending为 true 时直接忽略发送请求,按钮也要在视觉上 disabled。

第五个坑是消息顺序错乱。用户快速发了两条消息,第二条的回复比第一条先到。聊天场景里最稳妥的解法是串行发送:上一次请求彻底结束才允许发起下一次。并行方案虽然不是不能做,但还要在响应里带请求序号去排序,成本高得多。

第六个坑是点了停止生成但画面还在蹦字。原因是只 abort 了网络请求,没清理打字机的setInterval。所以stopGenerating()里必须同时清理定时器,并把剩余文本一次性补完。

第七个坑是切换会话把当前会话弄丢了。表现是新开会话后,旧会话没有保存,刷新再回来只剩空白。解决办法就是我在持久化部分说的,所有更新统一走同一个入口,切换之前强制保存旧会话。

6.2 快速排查对照表

问题典型现场排查思路解决办法
中文乱码流式输出出现“�”打印原始 chunk 看是否被截断TextDecoder 传入{ stream: true }
上下文超限聊久了请求 400检查请求体里 messages 大小轮数 + 字符数双限制
自动滚动失效新消息到了页面不动DOM 高度未更新就读取 scrollHeightawait nextTick()后再滚
重复消息连续点发送,上下文出现多条相同内容检查发送函数是否被并发调用isSending状态锁
消息顺序乱快速发多条,回复先后颠倒多个请求并发返回次序不定串行发送,上一次结束再发下一次
停止后还在输出点了停止仍然蹦字网络中断但定时器还在跑abort 后 clearInterval,补完剩余文本
切换会话丢数据新开会话后旧会话消失切换前没有保存旧会话统一updateCurrentThread()入口,切换前强制保存

6.3 排查思路小结

聊到这儿,我想给一个总经验:聊天前端百分之九十的 bug 都出在“状态时机”上。DOM 还没更新就滚动、数组还没 push 就去渲染、定时器没清理又起了一个,翻来覆去都是这几类。遇到任何诡异现象,先按三个方向排查:异步事件顺序、响应式更新时机、定时器生命周期。

网络问题另说。流式输出没有反应时,先打开浏览器的 Network 面板看 SSE 响应。如果响应预览里能看到文本在流动,说明后端没问题,切入前端解析;如果长时间 pending,就要找后端确认是不是流式开关没开。用这个顺序排查,通常很容易定位到问题。

小Q这个系列做到第三篇,我自己最深的体会是:聊天类前端真正花时间的,从来不是“把请求发出去”那一步,而是上下文、流式、持久化这些连续体验的细节。第二篇结束时我以为已经做完了九成,结果第三篇光这三件事就写了一整篇。做前端本来就是功能跑通只是开始,你要是也想做一个类似的聊天机器人,我建议按第六节的思路先把这些坑踩一遍,踩过的坑才是自己的经验。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进