前端AI应用开发:流式渲染与状态管理工程实践 📅 发布时间:2026/9/19 21:39:41 👁 浏览次数: 1. 这个系列到底在写什么为什么第四篇才是真正的分水岭“前端手摸手跑路之 AI 应用开发”这个系列我从第一篇追到第四篇越看越觉得它踩中了一个很真实的痛点前端开发者想往 AI 应用方向靠但市面上要么是纯算法视角的论文式教程要么是后端工程师写的 API 调用示例真正从“前端怎么落地一个能用的 AI 应用”这个角度切入的内容少得可怜。第四篇之所以关键是因为前三篇基本在铺基础设施——环境搭建、模型接口对接、基础对话流打通而到了第四篇才开始碰真正的工程问题流式输出怎么在前端优雅地渲染、多轮对话的状态怎么管理、上下文超长之后怎么截断、以及一个前端工程师在 AI 应用开发里到底该承担哪些职责边界。我先把结论放前面前端做 AI 应用开发核心竞争力不在于你会不会调大模型 API而在于你能不能把“不确定性极强的模型输出”包装成“用户体验稳定的产品界面”。这句话听起来有点抽象但你在实际项目里踩过几次坑之后就会明白——模型返回慢、返回格式飘、返回内容长、返回中途断掉这些全是前端要兜住的事。第四篇讲的就是这些兜底逻辑。这篇文章适合三类人看第一类是有 Vue 或 React 基础、想切入 AI 应用方向的前端第二类是在做 AI 产品但流式渲染和状态管理一直写得很别扭的开发者第三类是想搞清楚“AI 应用开发工程师”这个岗位到底要求什么能力的人。我会把第四篇涉及的核心技术点拆开补上原系列可能没展开的工程细节再结合我自己做过的几个 AI 对话类项目的经验把踩过的坑和验证过的方案都摊开讲。2. 第四篇的核心命题把“流”变成“稳”2.1 为什么流式输出是前端在 AI 应用里的第一个硬骨头大模型的响应天生就是流式的。你调一次接口它不是等全部生成完再一次性返回而是一个 token 一个 token 地吐出来。这个特性对后端来说只是数据传输方式但对前端来说它直接决定了整个交互架构。如果你还用传统的“请求-等待-渲染”三段式思维去做用户会盯着一个空白页面等十几秒体验直接崩掉。第四篇里我注意到一个很关键的转变它不再把 AI 接口当成普通 REST 接口来处理而是引入了 SSEServer-Sent Events或者基于 fetch 的 ReadableStream 来接收流式数据。这个选择背后有明确的工程考量。WebSocket 虽然也能做流式但它是双向的对于“用户发一句、模型回一段”这种单向流场景来说太重了而且连接维护成本高。SSE 是单向服务端推送天然适配这种场景浏览器原生支持 EventSource虽然 EventSource 只支持 GET 请求、不能自定义 header所以很多项目会改用 fetch ReadableStream 手动解析流。我实测下来fetch ReadableStream 的方案灵活性最高因为你可以自由控制请求头、请求体还能在中途 abort。但它的代价是你得自己处理分块解析因为流返回的是 Uint8Array需要手动 decode 成字符串再按行或按 SSE 格式切分。这里有个很容易踩的坑中文字符在 UTF-8 编码下可能被切分到两个 chunk 里如果你直接对每个 chunk 单独 decode会出现乱码。正确做法是用 TextDecoder 并开启 stream 模式让它自己处理跨 chunk 的字符边界。const decoder new TextDecoder(utf-8); const reader response.body.getReader(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按 SSE 格式切分处理 const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能不完整留到下次 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) return; // 解析并渲染 } } }这段代码看起来简单但buffer的保留逻辑和stream: true这两个细节是我见过最多人写错的地方。少了任何一个中文就会间歇性乱码而且这种 bug 很难复现因为取决于网络分块时机。2.2 多轮对话的状态管理别再用 useState 硬撑了第四篇另一个重点是对话状态的管理。前三篇可能只做了单轮问答到第四篇开始处理多轮上下文。很多人的第一反应是用一个数组存消息列表每条消息有 role 和 content然后每次请求把整个数组发给后端。这个思路没错但问题在于当对话轮次多了之后这个数组会变得非常大每次请求都全量发送token 消耗爆炸而且前端渲染长列表也会卡。我在实际项目里的做法是分两层管理一层是“展示用的消息列表”一层是“发送给模型的上下文窗口”。展示列表可以无限长用户往上翻能看到所有历史但发送给模型的上下文需要做截断策略。常见的截断策略有三种按轮次截断只保留最近 N 轮、按 token 数截断估算总 token 不超过模型上限的某个比例、以及摘要压缩把早期对话用模型总结成一段话。第四篇里我推测它用的是按轮次截断因为实现最简单对于大多数场景够用。但这里有个细节值得展开截断的时候不能把 system prompt 截掉也不能让对话历史出现“用户发了消息但助手没回复”的断裂。我一般会保留 system 消息 最近 N 轮完整对话如果 N 轮之后还有空间再往前补。另外前端做 token 估算不需要精确用字符数除以 1.5 到 2 之间的系数粗略估算就够了精确计算交给后端或专门的 tokenizer 库。状态管理工具的选择上如果项目是 Vue3用 Pinia 管理对话 store 比用 ref 散落在组件里清晰得多React 的话 Zustand 或 Jotai 都比 Redux 轻。关键是要把“流式接收中的临时状态”和“已完成的消息状态”分开因为流式接收时内容在不停变化如果直接往消息列表里 push 一个不断更新的对象会触发大量无意义的重渲染。3. 前端在 AI 应用里的职责边界比你想的要宽3.1 不只是画界面你要兜住模型的“不确定性”传统前端开发里后端返回的数据结构是确定的你按约定渲染就行。但 AI 应用里模型返回的内容格式是不确定的。它可能返回 Markdown、可能返回 JSON、可能返回一半突然断了、可能在 JSON 外面包一层自然语言。前端如果假设“返回的一定是标准格式”那线上一定会出问题。第四篇里我看到的处理思路是在渲染层做“渐进式降级”。具体来说流式接收到的内容先按纯文本渲染等流结束后再尝试解析 Markdown 或结构化数据。这样做的好处是用户能立刻看到内容在往外冒而不是等解析完才显示。如果解析失败就退化成纯文本展示至少不会白屏。代码高亮也是同理。AI 应用里模型经常返回代码块如果你在流式过程中就实时高亮性能会很差因为每来一个字符都要重新解析。我的做法是流式过程中用等宽字体纯文本展示流结束后再触发一次高亮渲染。这个细节在第四篇里可能没展开但实际项目里对流畅度影响很大。还有一个容易被忽略的点模型返回的内容里可能包含 HTML 标签或脚本。如果你直接用 v-html 或 dangerouslySetInnerHTML 渲染会有 XSS 风险。必须做 sanitize或者干脆用 Markdown 渲染库并禁用原始 HTML。这个安全边界前端必须自己守住不能指望模型“不会返回恶意内容”。3.2 错误处理和重试AI 应用的可用性全靠这里大模型接口的失败率比普通接口高得多。超时、限流、内容审核拦截、模型过载各种情况都会出现。第四篇如果涉及了错误处理那它的价值就很高因为这是区分“demo”和“产品”的分水岭。我的经验是至少要做三层处理第一层是请求层的超时和 abort用户切换会话或关闭页面时要能中断正在进行的流第二层是业务层的重试对于限流和临时故障做指数退避重试第三层是 UI 层的降级重试失败后要给用户明确的提示和“重新生成”按钮而不是转圈转到天荒地老。这里有个实操技巧流式请求的中断不能只靠 AbortController因为有些后端在连接断开后仍然会继续生成浪费 token。如果后端支持最好在 abort 的同时发一个取消信号。另外重试的时候要注意不要把已经接收了一半的内容丢掉要么从头重试并清空要么从断点续传后者实现复杂一般不做。4. 从第四篇延伸出去一个完整 AI 应用前端的落地清单4.1 技术选型和目录结构别一上来就堆功能如果你跟着这个系列做到第四篇准备自己起一个完整项目我建议先把目录结构定清楚。我常用的结构是这样的api目录放模型接口封装和流式解析逻辑stores放对话状态components放消息气泡、输入框、流式光标这些原子组件composables或hooks放流式接收、自动滚动、token 估算这些可复用逻辑。这样分层的好处是流式解析这种容易出 bug 的逻辑被隔离在 api 层测试和替换都方便。技术栈上Vue3 Pinia TypeScript 是我目前最推荐的组合TypeScript 在 AI 应用里尤其重要因为消息类型、流式事件类型、错误类型都需要明确定义不然协作和重构会很痛苦。UI 库用 Element Plus 或 Ant Design Vue 都行但消息列表建议自己写因为 AI 对话的交互细节流式光标、代码块复制、消息重新生成通用组件库覆盖不了。4.2 性能优化长对话列表和流式渲染的冲突怎么解当对话超过几十轮消息列表的渲染就会成为瓶颈而流式渲染又在不停地触发更新两者叠加会明显卡顿。我的解法是流式中的那条消息单独用一个组件渲染不放进主列表的响应式数组里等流结束后再合并进去。这样流式更新只影响一个组件不会导致整个列表 diff。另外消息列表用虚拟滚动是必要的但虚拟滚动和“自动滚动到底部”会打架。我的做法是监听用户是否手动向上滚动如果用户在看历史消息就停止自动滚动并在底部显示“有新消息”的提示按钮。这个交互细节在 AI 应用里很关键因为用户经常会在模型生成时往上翻看之前的对话。4.3 常见问题速查问题现象可能原因排查方向中文间歇性乱码chunk 边界切断了多字节字符检查 TextDecoder 是否开启 stream 模式buffer 是否正确保留流式内容渲染卡顿每个 token 都触发全列表重渲染流式消息独立组件流结束后再合并上下文超限报错发送的历史消息 token 超模型上限实现轮次截断或 token 估算截断切换会话后旧流还在跑没有 abort 正在进行的请求会话切换时调用 AbortController.abort()代码块高亮闪烁流式过程中实时高亮流式时纯文本结束后再高亮模型返回内容被截断达到 max_tokens 限制前端提示用户或调大限制并做分段续写这张表里的每一条都是我在实际项目里真实遇到过的。尤其是第一条和第二条几乎每个做流式 AI 应用的人都会踩一遍。5. 我个人的一些实操心得做 AI 应用前端这段时间最大的体会是这个方向和传统前端最大的区别在于你要习惯和“不确定性”共处。传统前端可以把接口契约卡得很死但 AI 应用的契约是模糊的模型的行为你控制不了你只能控制自己怎么响应。所以前端的价值反而更大了因为用户体验的上限和下限很大程度上由前端决定。另外别被“AI 应用开发工程师”这个 title 吓到。它不要求你会训练模型、会调参、会写推理框架。它要求的是你能把模型能力产品化而这恰恰是前端最擅长的事——把复杂的技术包装成用户能用的界面。第四篇之所以重要就是因为它开始触碰这些产品化的核心问题。如果你能把流式渲染、状态管理、错误兜底这三件事做扎实你就已经超过大部分只会调 API 的开发者了。最后分享一个小技巧在开发阶段用一个 mock 的流式接口来模拟各种异常情况慢速、中途断开、返回乱码、返回超长内容比等真实接口出问题再排查高效得多。我一般会写一个简单的 Node 脚本按字符间隔推送预设文本这样前端的所有边界情况都能在本地复现和修复。这个习惯帮我省了大量联调时间也让我对流的理解深了很多。