1. 大模型对话前端到底难在哪从一次翻车说起去年下半年我接手了一个内部知识问答工具的前端重构后端接的是公司自研的大模型服务。当时我的判断很简单不就是个聊天界面吗消息列表加输入框顶多再加个流式打字机效果两周搞定。结果这个项目硬生生拖了一个半月中间经历了三次比较大的返工踩的坑从流式渲染的性能问题到工具调用状态机设计混乱再到长会话切换时消息错乱几乎把大模型前端能踩的雷踩了个遍。这篇文章就是那次重构的完整复盘。我会把流式响应、工具调用状态管理、长会话同步这三块拆开讲透每一块都会说清楚为什么这么做、不这么做会出什么问题、具体代码怎么写。适合正在做大模型对话应用的前端同学也适合想了解这块技术细节的后端和产品同学。读完之后你应该能直接把这套方案搬到自己的项目里少走我走过的弯路。先说一个最核心的认知大模型对话前端和传统IM聊天界面有本质区别。传统IM的消息是离散的、完整的、一次到达的而大模型的消息是流式的、增量的、可能中途插入工具调用状态的。这个差异决定了你不能用做IM的思路来做大模型对话界面否则一定会在某个环节翻车。2. 流式响应从SSE到打字机效果的完整链路2.1 为什么必须用流式以及SSE为什么是首选大模型生成一段500字的回答如果等全部生成完再返回用户可能要盯着loading转5到10秒。这个等待体验是灾难性的。流式响应的价值就在于让用户看到内容在生长首字延迟从5秒降到500毫秒以内感知速度提升一个数量级。流式传输的技术选型主要有三种WebSocket、SSEServer-Sent Events、以及基于fetch的ReadableStream手动读取。我实测下来大模型对话场景SSE是最优解原因有三大模型对话是典型的单向推送场景客户端发一次请求服务端持续推token不需要WebSocket的双向能力SSE基于HTTP天然支持断线重连、自动重试浏览器原生EventSource就能用SSE的文本协议格式简单data: xxx\n\n调试时直接看network面板就能看到每个token但EventSource有个硬伤不支持自定义请求头也没法发POST请求。而大模型对话通常需要POST传参消息历史、模型参数等还需要带Authorization头。所以实际项目中我推荐用fetch ReadableStream手动解析SSE格式兼顾了SSE的协议优势和fetch的灵活性。async function streamChat(messages, onToken, onDone, onError) { const controller new AbortController(); try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ messages, stream: true }), signal: controller.signal }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); // 最后一段可能不完整留到下次处理 buffer lines.pop() || ; for (const line of lines) { if (!line.startsWith(data: )) continue; const data line.slice(6); if (data [DONE]) { onDone(); return; } try { const parsed JSON.parse(data); onToken(parsed); } catch (e) { console.warn(解析失败, data); } } } } catch (err) { if (err.name ! AbortError) onError(err); } return controller; }这段代码有几个关键点值得展开。第一是buffer的处理SSE的数据包在网络传输中可能被切分一次read拿到的chunk可能包含多个完整事件也可能只包含半个事件。所以必须用buffer累积按\n\n切分最后一段不完整的留在buffer里等下次。我见过有同学直接对每个chunk做JSON.parse结果偶发解析失败就是没处理这个边界。第二是AbortController用户点停止生成或者切换会话时必须能中断正在进行的请求。不中断的话旧请求的token会继续往新会话里写造成消息错乱。这个后面讲长会话同步时还会细说。2.2 打字机效果的实现别用setInterval拿到token之后怎么渲染成打字机效果这里有个常见的错误做法把完整回答存起来然后用setInterval每隔50毫秒往界面上加一个字。这个方案在短文本上没问题但大模型输出速度是不均匀的有时候一秒吐20个token有时候卡一下两秒没动静用固定间隔的定时器会导致渲染速度和实际接收速度脱节用户看到的是匀速打字反而失去了流式响应的真实感。我的做法是收到即渲染用requestAnimationFrame做批量更新。具体来说维护一个待渲染队列每收到一个token就push进去然后用rAF在下一帧统一flush到DOM。这样既保证了渲染跟得上接收速度又避免了每个token都触发一次React重渲染导致的性能问题。const pendingRef useRef(); const rafRef useRef(null); function scheduleRender(token) { pendingRef.current token; if (rafRef.current) return; rafRef.current requestAnimationFrame(() { setContent(prev prev pendingRef.current); pendingRef.current ; rafRef.current null; }); }实测下来这个方案在每秒50个token的高频输出下CPU占用比逐token setState低了60%以上。如果你的项目用的是Vue思路一样把setContent换成响应式赋值即可。注意流式渲染时不要对整段Markdown做实时解析。Markdown解析是O(n)的每来一个token就重新解析整段1000字的回答会产生几十万次解析操作直接卡死。正确做法是流式期间用纯文本渲染收到[DONE]后再做一次Markdown解析替换。2.3 流式过程中的几个坑坑一代码块高亮闪烁。如果流式期间就做语法高亮代码块每来一个字符就重新高亮一次视觉上会疯狂闪烁。解决方案是流式期间代码块用等宽字体纯文本展示结束后再高亮。坑二自动滚动打架。用户往上翻看历史消息时新token到达触发的自动滚动会把用户拽回底部。正确做法是监听滚动位置只有当用户处于底部附近比如距底部小于100px时才自动滚动。坑三网络中断的续传。SSE断线后如果直接重连服务端会重新生成一遍用户会看到重复内容。我的做法是在请求里带一个lastEventId服务端从上次中断的位置继续推。这个需要后端配合但体验提升非常明显。3. 工具调用状态把异步流程管明白3.1 工具调用为什么会让前端状态爆炸大模型对话和普通聊天最大的区别之一就是模型可以调用工具。比如用户问帮我查一下明天北京的天气模型不会直接回答而是先输出一个工具调用请求前端要展示正在查询天气...等工具返回结果后模型再基于结果生成最终回答。这个流程涉及的状态至少有模型思考中、工具调用中、工具返回中、模型生成中、完成、失败。如果同时有多个工具调用比如并行查天气和查航班状态组合会指数级增长。我第一版就是用一个isLoading布尔值糊弄结果工具调用和模型生成的状态混在一起界面上经常出现正在查询和回答文字同时显示的诡异情况。3.2 用状态机而不是布尔值正确的做法是把每条消息的状态建模成一个有限状态机。我定义的状态枚举如下状态含义界面表现pending消息已发送等待服务端响应显示发送中指示thinking模型正在思考显示思考动画tool_calling正在调用工具显示工具名和参数tool_result工具已返回等待模型继续显示工具结果摘要streaming模型正在流式输出打字机效果done完成正常展示error出错显示错误和重试按钮aborted用户主动中断显示已停止状态之间的转换必须严格约束比如tool_calling只能转到tool_result或error不能直接跳到streaming。我在代码里用一个转换表来约束const VALID_TRANSITIONS { pending: [thinking, error, aborted], thinking: [tool_calling, streaming, error, aborted], tool_calling: [tool_result, error, aborted], tool_result: [thinking, streaming, error], streaming: [done, error, aborted], done: [], error: [pending], aborted: [] }; function transition(current, next) { if (!VALID_TRANSITIONS[current]?.includes(next)) { console.error(非法状态转换: ${current} - ${next}); return current; } return next; }这个转换表看起来简单但它帮我挡掉了至少三次因为异步回调顺序错乱导致的状态bug。比如工具调用还没返回模型生成的回调先到了如果没有状态约束界面就会先显示回答再显示工具调用逻辑完全颠倒。3.3 工具调用消息的渲染设计工具调用的展示是个产品设计问题。我见过两种极端一种是把工具调用的原始JSON全展示出来用户一脸懵另一种是完全隐藏用户不知道模型在干什么只觉得卡住了。我的方案是折叠式展示默认显示一行摘要比如正在查询天气北京带一个展开箭头。用户点开可以看到完整的工具名、参数、返回结果。这样既保持了界面清爽又给了需要细节的用户查看的入口。function ToolCallBlock({ toolCall }) { const [expanded, setExpanded] useState(false); const statusText { calling: 正在调用 ${toolCall.name}..., success: 已调用 ${toolCall.name}, error: ${toolCall.name} 调用失败 }[toolCall.status]; return ( div classNametool-call-block div classNametool-call-header onClick{() setExpanded(!expanded)} span className{status-dot ${toolCall.status}} / span{statusText}/span span classNameexpand-icon{expanded ? 收起 : 展开}/span /div {expanded ( div classNametool-call-detail div参数{JSON.stringify(toolCall.args, null, 2)}/div div结果{toolCall.result}/div /div )} /div ); }实操心得工具调用的参数和结果里可能包含敏感信息比如用户ID、内部接口地址展示前一定要做脱敏。我吃过这个亏测试环境把内部接口地址暴露给了用户虽然不是什么大事故但被安全同学约谈了一次。3.4 并行工具调用的处理当模型一次返回多个工具调用时前端要能并行处理。这里的关键是给每个工具调用分配一个唯一ID用Map来管理它们的状态而不是用一个数组按顺序处理。因为并行调用的返回顺序是不确定的用数组会导致结果错位。const toolCallsRef useRef(new Map()); function handleToolCall(toolCall) { toolCallsRef.current.set(toolCall.id, { ...toolCall, status: calling }); updateUI(); } function handleToolResult(result) { const existing toolCallsRef.current.get(result.id); if (existing) { toolCallsRef.current.set(result.id, { ...existing, status: result.error ? error : success, result: result.data }); updateUI(); } }用Map而不是数组还有个好处是查找是O(1)的。当工具调用数量多的时候我见过一次返回8个并行调用的数组的find操作会成为性能瓶颈。4. 长会话同步切换会话时的数据一致性4.1 长会话带来的三个问题当用户有几十个会话、每个会话几百条消息时前端会遇到三个问题问题一内存占用。所有会话的消息都放在内存里一个会话500条消息每条平均500字50个会话就是12.5MB的纯文本加上React的虚拟DOM开销轻松突破100MB。移动端直接卡死。问题二切换延迟。从会话A切到会话B如果B的消息没缓存要重新请求用户要等如果缓存了渲染几百条消息也要时间。问题三流式写入冲突。用户在会话A正在生成回答时切到会话BA的流式回调还在往A的消息列表里写如果处理不当可能写到B里或者A的消息状态错乱。4.2 分层缓存策略我的方案是三层缓存内存缓存最近3个会话的完整消息IndexedDB缓存所有会话的消息服务端作为最终数据源。内存层用Map存最近访问的3个会话切换时直接命中零延迟IndexedDB层所有会话的消息都持久化刷新页面后能秒开服务端层分页拉取每次只拉最近50条往上滚动时再拉更多class MessageCache { constructor() { this.memory new Map(); // sessionId - messages this.maxMemorySessions 3; this.accessOrder []; } async get(sessionId) { if (this.memory.has(sessionId)) { this.touch(sessionId); return this.memory.get(sessionId); } const messages await this.loadFromIndexedDB(sessionId); this.set(sessionId, messages); return messages; } set(sessionId, messages) { this.memory.set(sessionId, messages); this.touch(sessionId); // 超出上限时淘汰最久未访问的 while (this.accessOrder.length this.maxMemorySessions) { const oldest this.accessOrder.shift(); this.memory.delete(oldest); } this.saveToIndexedDB(sessionId, messages); } touch(sessionId) { const idx this.accessOrder.indexOf(sessionId); if (idx -1) this.accessOrder.splice(idx, 1); this.accessOrder.push(sessionId); } }这个LRU策略实测下来内存占用稳定在30MB以内切换会话基本无感。4.3 流式写入的会话隔离这是最容易出bug的地方。核心原则是每个流式请求绑定一个sessionId回调里只操作这个sessionId对应的消息列表。function startStream(sessionId, messages) { const controller new AbortController(); const streamContext { sessionId, controller, messageId: generateId() }; streamChat( messages, (token) { // 关键只更新对应session的消息 updateMessage(streamContext.sessionId, streamContext.messageId, token); }, () finishMessage(streamContext.sessionId, streamContext.messageId), (err) handleError(streamContext.sessionId, streamContext.messageId, err) ); return streamContext; } function switchSession(newSessionId) { // 切换前中断当前流 if (currentStream) { currentStream.controller.abort(); // 把中断的消息标记为aborted保留已生成内容 markAborted(currentStream.sessionId, currentStream.messageId); } currentStream null; loadSession(newSessionId); }这里有个细节中断时不要删除已生成的内容而是标记为aborted状态保留。用户切回来还能看到之前生成到一半的内容体验更好。我第一版是直接丢弃用户反馈说我切个会话回来回答就没了后来改成保留。4.4 消息列表的虚拟滚动几百条消息的会话如果全部渲染DOM滚动会卡成PPT。必须上虚拟滚动。我用的是自己实现的简易版核心思路是只渲染可视区域内的消息上下各留5条缓冲。function VirtualMessageList({ messages, itemHeight 100 }) { const [scrollTop, setScrollTop] useState(0); const [viewportHeight, setViewportHeight] useState(0); const containerRef useRef(null); const startIndex Math.max(0, Math.floor(scrollTop / itemHeight) - 5); const endIndex Math.min( messages.length, Math.ceil((scrollTop viewportHeight) / itemHeight) 5 ); const visibleMessages messages.slice(startIndex, endIndex); return ( div ref{containerRef} onScroll{e setScrollTop(e.target.scrollTop)} style{{ height: 100%, overflow: auto }} div style{{ height: messages.length * itemHeight, position: relative }} {visibleMessages.map((msg, i) ( div key{msg.id} style{{ position: absolute, top: (startIndex i) * itemHeight, height: itemHeight, width: 100% }} MessageItem message{msg} / /div ))} /div /div ); }注意虚拟滚动和流式渲染会打架。流式输出时消息高度是变化的虚拟滚动需要动态测量高度。我的做法是流式期间禁用虚拟滚动只渲染当前消息流式结束后再启用。因为流式期间用户基本都在看最新消息不需要滚动历史。5. 常见问题与排查技巧实录5.1 流式响应相关问题token乱码或截断。排查思路先看network面板的原始数据确认是服务端发送问题还是前端解析问题。如果是前端解析99%是buffer没处理好检查decoder.decode(value, { stream: true })有没有加stream: true参数。不加这个参数多字节字符比如中文被切分时会解码失败。问题流式速度忽快忽慢。这通常是服务端的问题不是前端。但前端可以做优化用rAF批量渲染避免每个token都触发重排。另外检查是不是在流式期间做了重计算比如实时Markdown解析。问题停止生成后还在继续输出。检查AbortController有没有正确传递到fetch以及服务端有没有监听连接关闭事件。有些服务端框架不会自动中断需要手动处理。5.2 工具调用相关问题工具调用状态卡在调用中。排查检查工具返回的回调有没有正确匹配ID。最常见的原因是ID类型不一致比如前端用number后端返回stringMap查找失败。问题多个工具调用结果错位。检查是不是用了数组按顺序处理。并行调用必须用Map按ID匹配。问题工具调用参数展示乱码。检查JSON.stringify有没有处理循环引用以及特殊字符的转义。5.3 长会话相关问题切换会话后消息重复。检查是不是在切换时没有清空当前消息列表导致新旧消息叠加。另外检查IndexedDB的写入是不是有并发问题。问题刷新页面后消息丢失。检查IndexedDB的写入时机是不是只在切换会话时写。正确做法是每次消息更新都写用防抖控制频率。问题内存持续增长不释放。检查LRU淘汰逻辑以及有没有在组件卸载时清理事件监听和定时器。React的useEffect清理函数一定要写。5.4 问题速查表现象可能原因排查方向中文乱码TextDecoder未加stream参数检查decode调用消息重复切换会话未清空列表检查状态重置逻辑状态卡死状态转换未约束检查转换表工具结果错位用数组按序处理改用Map按ID匹配内存泄漏监听器未清理检查useEffect清理滚动跳动自动滚动未判断位置检查滚动阈值高亮闪烁流式期间实时解析改为结束后解析5.5 几个独家避坑技巧技巧一给流式请求加超时。大模型有时候会卡住不输出也不结束前端要设置一个超时比如60秒无token就中断否则用户会一直等。技巧二消息ID用时间戳随机数。纯随机数有碰撞风险纯时间戳在快速连续发送时会重复。组合起来最稳。技巧三IndexedDB写入用事务批量提交。单条写入性能很差攒够10条或每隔500毫秒批量写一次性能提升明显。技巧四开发环境加一个模拟慢速开关。把流式速度调慢10倍方便调试各种边界情况。这个开关帮我发现了至少5个只在慢速下才出现的bug。技巧五错误重试要带退避。网络错误重试不要立即重发用指数退避1秒、2秒、4秒避免服务端压力过大。这套方案我在三个项目里复用过了从内部工具到面向C端的产品基本没遇到大的问题。当然每个项目的具体需求不同比如有的需要支持多模态消息图片、文件有的需要支持消息编辑和重新生成这些都可以在这套框架上扩展。核心思路就是流式用fetchReadableStream状态用有限状态机长会话用分层缓存虚拟滚动。把这三块吃透大模型对话前端基本就没有难点了。