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

发布时间:2026/10/16 6:29:25
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 的上限或者干脆按“每组 userassistant 算一轮”来处理。第二个是字符数上限。轮数裁剪能处理大多数情况但遇到单条消息特别长的时候就不够用了。比如用户直接粘贴了一篇文章进来这一条消息就可能把接口的 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 和 10080 在触屏上感觉最自然你可以按自己的偏好调。5.3 输入交互细节与扩展方向输入框我会做三件事回车发送、ShiftEnter 换行、发送后让输入框重新获得焦点。这三件事看起来基础却直接影响聊天效率。尤其是“发送后重新聚焦”如果不做用户每发一条消息都要手动点一下输入框中断对话流。发送按钮的位置我做了个小设计同一个位置两种状态。空闲时是“发送”按钮发送中变成“停止”按钮点击后调用前面写的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这个系列做到第三篇我自己最深的体会是聊天类前端真正花时间的从来不是“把请求发出去”那一步而是上下文、流式、持久化这些连续体验的细节。第二篇结束时我以为已经做完了九成结果第三篇光这三件事就写了一整篇。做前端本来就是功能跑通只是开始你要是也想做一个类似的聊天机器人我建议按第六节的思路先把这些坑踩一遍踩过的坑才是自己的经验。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询