前端接入大模型实践:SSE流式输出与工程落地指南
最近我把手头一个工具型前端项目接进了大模型前后折腾了差不多三周整个过程很值得记一笔。这篇“前端使用AI试水报告”不聊云里雾里的概念只讲我在真实业务里踩过的坑、验证过的方案以及最后沉淀下来的一套可以直接照搬的做法。如果你也想在前端项目里加一点AI能力比如做个文档助手、聊天面板、内容提炼工具这篇文章应该能让你少走好几段弯路。1. 这次试水到底在试什么1.1 前端和AI能碰撞出哪些玩法前端接AI很多人第一反应是“在页面里嵌一个聊天框”。这只是最基础的一种。我这次试水其实同时验证了好几个方向自然语言操作界面用户输入一句中文前端解析成结构化参数去查询数据文档内容总结把一段长文本丢给大模型生成要点列表智能表单填充从一段粘贴的杂乱文本里抽取姓名、电话、地址等字段逐字流式输出生成过程中实时渲染Markdown。这几个方向覆盖了大多数前端团队会对AI产生的真实需求。核心不是“谁家模型更聪明”而是前端怎么把模型能力变成顺手的工具。也就是说前端的角色不是算法研究者而是模型能力的包装者和体验设计者。1.2 试水前先想清楚三个问题动工之前我们团队讨论了很久最后把目标收敛成三个问题。第一成本谁来承担大模型API不是免费的尤其流式对话场景一次长对话可能消耗几千个token。如果产品没有明确的付费点成本会迅速吃掉利润。我们最后做了调用频率上限和内容长度限制才敢放开内测。第二延迟和卡顿怎么处理模型生成不是瞬间返回而是几百毫秒到几十秒不等。用户看到空白页面三秒就开始怀疑页面崩溃了。所以必须做流式响应即使首字延迟1到2秒页面也要立刻给出“正在思考”的反馈。第三内容安全谁负责用户输入什么、模型输出什么前端不能不管。基础过滤必须有比如输入长度超限要拦截输出内容要接入审核接口不能把模型生成的内容直接当事实展示给用户。这三个问题没有在动手前想清楚后面一定会返工。我们是做到第三天才意识到成本和内容安全必须提前设计后面补起来非常难受。2. 核心技术选型我踩过的思路坑2.1 在线调用还是本地推理前端接入AI第一步就要选使用方式。我试过两条路线在线调用大模型API以及本地跑量化模型。在线调用是最快的落地方式。前端把请求发给后端代理后端再转发到大模型服务拿到结果流式返回给前端。这种方式好处是模型能力最强、接入成本低两天就能出Demo。坏处也很明显只要有网络波动或者服务限流体验就崩塌。本地推理则是把模型文件下载到用户设备用WebAssembly或者浏览器内置的深度学习库来跑。好处是数据不出本地、无服务器成本、离线可用坏处是模型体积通常以GB计首次加载很慢普通电脑跑大参数模型基本不可用。我的建议是业务型产品优先走在线调用数据极度敏感的再考虑本地小模型。没有一个方案是完美的但“先把产品跑起来”比“技术上完美”重要得多。2.2 流式输出才是关键体验SSE为什么是首选我第一次接AI时犯了个经典错误——用普通请求等完整结果返回结果用户等十秒只看到一句“正在生成中”体验差到想放弃。后来换成了SSEServer-Sent Events也就是服务器单向推送事件流。前端通过fetch发起请求后端持续把生成的文本片段推过来前端拿到一片就渲染一片用户就能看到文字一个字一个字蹦出来。虽然技术上有WebSocket这种双向通道但AI对话场景主要是服务器到客户端的单向推送SSE足够用而且实现简单得多。SSE的核心特征是响应头的Content-Type: text/event-stream。每段数据以data:开头以空行结束。我用代码封装了一个基础请求async function createAIStream(payload, onMessage, onDone, onError) { const response await fetch(/api/ai/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(HTTP ${response.status}); } 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 events buffer.split(\n\n); buffer events.pop(); for (const event of events) { if (event.startsWith(data:)) { const data event.replace(data:, ).trim(); if (data [DONE]) { onDone(); } else { try { onMessage(JSON.parse(data)); } catch (e) { console.warn(parse event error, e); } } } } } }这段代码有几点必须注意用TextDecoder处理流式二进制转字符串很多初学者直接用response.text()就永远拿不到增量内容用buffer拼接半包数据因为网络传输会把一条事件拆成好几段先判断[DONE]再解析JSON避免拿到空消息。这些细节不处理流式输出就会出现漏字、卡顿、崩溃。2.3 API密钥安全前端永远别直接拼前端直接调用大模型API看起来省事其实是个大坑。API密钥一旦放进前端代码等于公开了账户别人随便抓一下包就能薅走你的额度。我们第一版就是这么干的第二天发现被人刷了几百次请求。正确做法是前端只请求自己的后端接口由后端持有密钥并转发请求。前端传用户输入后端拼装参数、调用API、把结果流回前端。这样密钥不出服务器也方便做鉴权、频控和内容安全过滤。后端接口的职责可以理解为“安全网关”它不该只是个透明转发还要做这些事校验用户身份防止匿名刷接口限制单用户调用频率比如每人每分钟最多5次限制单次请求的输入长度和输出长度记录请求日志方便排查问题对输入输出做关键词过滤。我建议前端只保留一个最小配置的API客户端所有关于大模型的配置都放到后端环境变量里。连模型名称、温度系数这种参数也尽量由后端决定前端不要私自改否则出问题很难定位。3. 实操记录从零实现一个AI文档助手3.1 项目准备与接口定义这个阶段我做了一个具体的DemoAI文档助手用户粘贴一篇长文它自动生成结构化的要点和摘要。技术栈用的是现在最常见的前端工程化方式组件化框架加Vite构建UI库则挑了一个团队用得最顺手的没有特别之处。功能拆成两个接口POST /api/ai/summarize 参数 - text: string原始文本最长8000字 - style: brief | detailed摘要详细程度 返回 - SSE流每帧包含一个content片段后端拿到参数后组装Prompt你是一名专业的文档助理。请阅读下面文本输出三部分内容 1. 一句话概括 2. 核心要点最多5条 3. 待确认事项如果有 输出格式要求 - 开头直接输出内容不要寒暄。 - 使用Markdown语法要点用短横线列表。 - 语言简洁不要重复原文。 原文 {{text}}Prompt设计的坑也很多。第一版没加“不要寒暄”模型每次都回复“好的我来帮你总结”白白浪费几十个token。加“输出格式要求”后结果结构稳定了很多。说明大模型在明确的约束下表现会好很多前端开发者别把Prompt当聊天要当接口文档来写。3.2 封装流式请求把SSE吃掉前端侧我的核心目标是构建一个可复用的useAIStream组合函数。它负责发起请求、接收流、暴露数据状态任何组件都能直接用。import { ref, readonly } from vue; export function useAIStream() { const text ref(); const status ref(idle); const error ref(); let controller null; async function start(payload) { text.value ; error.value ; status.value loading; controller new AbortController(); try { const response await fetch(/api/ai/summarize, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), signal: controller.signal, }); if (!response.ok) throw new Error(网络请求失败请稍后重试); 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 }); let boundary buffer.indexOf(\n\n); while (boundary ! -1) { const chunk buffer.slice(0, boundary); buffer buffer.slice(boundary 2); processChunk(chunk); boundary buffer.indexOf(\n\n); } } status.value done; } catch (e) { if (e.name AbortError) { status.value idle; } else { error.value e.message; status.value error; } } } function processChunk(chunk) { const lines chunk.split(\n); for (const line of lines) { if (!line.startsWith(data:)) continue; const data line.slice(5).trim(); if (data [DONE]) { status.value done; continue; } try { const parsed JSON.parse(data); if (parsed.content) { text.value parsed.content; } } catch (e) { // ignore partial json } } } function stop() { if (controller) controller.abort(); } return { text: readonly(text), status: readonly(status), error: readonly(error), start, stop }; }这段代码解决了实际体验里的几个问题。支持AbortController用户点“停止生成”能够立刻中断请求前端状态也能正确重置。用indexOf(\n\n)处理流式数据不会因为半包导致漏数据。status状态跟踪让按钮能根据状态切换文案生成中显示“停止”完成后恢复“重新生成”。3.3 Markdown流式渲染与回放处理流式生成的文本是半截Markdown比方说模型还没写完一个链接前一半是[这是一个后一半才出现超链接](https://example.com)。如果每次都把完整Markdown重新解析一次并渲染性能消耗很大而且光标位置会跳动。我的方案是用一个markdown-it实例缓存渲染新增文本时只追加渲染增量。但这有个坑Markdown状态会跨块关联比如列表会合并、代码块会跨片段包裹。所以更好的做法是维护一个中等长度的“缓冲尾部”每次把尾部内容重新渲染前面稳定输出的部分用普通HTML片段插入不重复解析。import MarkdownIt from markdown-it; import { ref, computed } from vue; const md new MarkdownIt({ html: false, linkify: true }); export function useStreamingMarkdown(sourceText) { const stableHtml ref(); const stableLength ref(0); const tailLength 800; // 尾巴长度 const outputHtml computed(() { const tail sourceText.value.slice(stableLength.value); const tailHtml md.render(tail); return stableHtml.value tailHtml; }); function flushStable(boundary) { const nextStableLength boundary; stableHtml.value md.render(sourceText.value.slice(stableLength.value, nextStableLength)); stableLength.value nextStableLength; } return { outputHtml, flushStable }; }tailLength是经验值太短会让列表符号重复渲染太长又浪费性能。800个字符的尾巴足够兼容大多数Markdown跨块语法实测在生成的性能上表现稳定。还有个小细节渲染Markdown时关闭html选项防止大模型输出script之类的内容造成XSS。这是必须做的模型内容不可信不能把安全性完全寄托在“模型不会输出恶意内容”上。3.4 状态机设计闲置、生成中、暂停、失败AI交互不是简单一次请求用户可能频繁点击、中断、重试。用一个状态机来管理最稳妥idle - loading - done idle - loading - error loading - idle (用户主动停止) loading - error - idle (重试)我在useAIStream里用status字段表达这些状态页面按钮的文案和禁用逻辑都基于它。还有一些关联状态需要一起处理比如用户正在输入文本时生成按钮应该禁用生成过程中编辑框应该只读错误状态下要保留上一轮生成结果而不是清空。这套状态机逻辑不复杂但必须在一开始就定好。我见过不少项目在这上面翻车最常见的问题是请求已经中止但状态还停在“生成中”按钮卡死用户只能刷新页面。4. 排坑实录这些雷我替你们踩过了4.1 常见问题速查表我整理了一张问题速查表基本覆盖了前两周遇到的所有问题问题现象可能原因解决方案页面长时间无输出接口返回了非SSE格式被fetch拦截检查响应头是否包含text/event-stream后端不要压缩流输出文字乱码中文字符被半个截断必须用TextDecoder并开启stream: true偶发漏字data:和JSON之间有多余空格解析时先trim()再JSON.parse生成到一半报错API限流或网络抖动增加重试机制但要保留已生成内容页面卡顿每次更新都重新渲染整份Markdown用增量渲染加尾部缓冲模型输出带##标题样式混乱页面没有引入Markdown样式自定义CSS或引入一个美观的markdown样式包输出内容被浏览器标记为不安全没做内容过滤前置过滤后置审核双保险同一用户短时间疯狂调用缺少频控后端按用户维度做令牌桶限流这些坑都不是模型的问题而是前端开发者对大模型服务不熟悉导致的。尤其是TextDecoder和SSE解析几乎每个第一次接流式输出的人都会踩一遍。4.2 成本失控与限流我有一个特别痛的教训。Demo上线第一天内部测试加外部试用两个小时内消耗了价值几百元的token量。看了一下日志是某个用户反复点击“重新生成”每次都触发长文本总结单次请求消耗非常大。后来我加了三道保险。第一输入长度硬限制超过5000字直接提示不让模型处理第二每次请求前做token预估返回结果超过预算就自动中断第三单用户维度限流同一用户一分钟最多3次生成请求超出后返回友好提示。成本控制在AI产品里不是运维问题是产品体验问题。如果用户一次操作能让公司损失几毛钱那用户量大起来财务就会找上门。建议每个前端AI功能上线前都先算一笔账单次平均消耗多少token折算多少成本用户每天用10次一个月需要多少钱。算完你就会主动把生成逻辑写得克制一点。4.3 降级方案与体验兜底AI接口再稳定也不可能100%可用。每个依赖AI的功能都必须准备降级方案。我做的是三层降级第一层模型服务超时时前端提示“当前服务繁忙请稍后重试”并保留用户输入的原始文本不让用户重新粘贴第二层接口频繁失败时自动切换到本地规则算法例如摘要功能退化为提取首段和加粗关键句第三层彻底不可用时隐藏AI入口不展示一堆报错按钮。降级方案听起来增加工作量但非常值得。用户对AI功能失败的容忍度很低一旦他感觉“这个功能不靠谱”就再也不用了。前端要给用户一个稳定、可预期的体验哪怕背后换了一套老旧规则也比转圈圈强。5. 前端AI化的下一步5.1 值得试的方向这次试水跑通后我觉得有几个方向值得继续深挖。一个是结构化抽取让模型把非结构化文本自动转成JSON直接把结果交给表单组件回填可以极大减少用户的操作成本。另一个是检索增强生成把前端本地的一些数据片段拼接进Prompt让模型基于你的业务数据回答而不是凭空发挥。还有一个是交互式图表生成用户说一句“统计上月销售趋势”前端自动生成图表配置并渲染这种体验很惊艳。不过每个方向都需要重新评估成本和延迟。AI功能不是越强越好前端要控制好用户的等待上限。我的经验是超过三秒的生成任务一定要做分步加载超过十秒就要提供后台任务和结果通知否则用户早就走了。5.2 最后的几条忠告根据这几周的实操我最后想分享几个容易被忽略的判断。不要迷信模型的记忆能力多轮对话里的历史和上下文都是成本前端能裁剪就裁剪能压缩就压缩。Prompt别越写越长清晰的要求比冗长的描述有效得多。日志一定要全量记录AI服务出问题无法本地复现日志就是救命稻草。还有前端工程师不需要成为算法专家但一定要看得懂API文档、理解token、会用流式协议这些才是AI时代前端的基本功。这次试水真正让我意识到AI不是要取代前端而是给前端多了一双可以做更多事的手。把这双手用在用户真正觉得方便的地方比堆砌一堆花哨的AI功能重要得多。希望这篇报告能给你带来一些参考少走几步弯路。