☰
前端接入大模型实践:SSE流式输出与工程落地指南
2026/10/17 23:20:02 网站建设 项目流程

最近我把手头一个工具型前端项目接进了大模型,前后折腾了差不多三周,整个过程很值得记一笔。这篇“前端使用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时犯了个经典错误——用普通请求等完整结果返回,结果用户等十秒只看到一句“正在生成中”,体验差到想放弃。

后来换成了SSE(Server-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 项目准备与接口定义

这个阶段我做了一个具体的Demo:AI文档助手,用户粘贴一篇长文,它自动生成结构化的要点和摘要。技术栈用的是现在最常见的前端工程化方式:组件化框架加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功能重要得多。希望这篇报告能给你带来一些参考,少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询