ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端集成AI对话:流式输出、上下文管理与安全实践

前端集成AI对话:流式输出、上下文管理与安全实践 今天是我上手前端AI项目的第4天。前3天我基本处于“脑子过载”的状态第一天看了大模型的基础概念和API文档第二天用官方SDK跑通了一个demo第三天摸索了Prompt和简单调参。到了第4天我给自己定了一个必须完成的目标把AI能力真正嵌进前端页面里做成一个用户能实际使用、别人也能接手维护的功能而不是只能在终端里用curl调通的接口。这篇笔记适合正在走“前端转AI应用开发”这条路的朋友也适合那些已经写过几个普通管理系统、想把手里的组件做得更智能的前端同学。今天的内容没有高深算法全部是工程层面的实操——从流式输出到上下文管理再到一个能上线的AI对话组件的封装思路最后是上线前绕不开的安全和兜底问题。如果你也想做类似的事情把这篇文章当成你第4天的地图就好。1. 第4天的起点从一个“完整对话流程”开始1.1 为什么我不再刷文档而是直接做“对话组件”前3天我犯了一个很多前端同学转型时会犯的错总觉得先把所有概念搞懂才能动手。结果是大模型API文档看了4遍真到了做项目还是一头雾水。第4天我换了个策略——从“用户视角”倒推需求。用户不需要知道什么是System Prompt他只需要在输入框里打字然后看到AI像真人一样一句一句回复说错了能让他停下来网络断了能重试。这样一个看似简单的对话流程其实把流式输出、上下文管理、状态管理、异常处理全部串起来了。把一个完整流程走通比背100个概念都管用。我选“对话式AI组件”作为第一个完整功能还有一个现实原因它是AI应用最通用的“容器”。客服问答、知识库查询、写作辅助、代码解释……底层都是对话结构。把这个组件做扎实了后面接什么场景都是往里面填消息的问题。1.2 第4天动手前的技术评估我的技术栈是Vue3 TypeScript Node.jsExpress后端。同事已经帮我开通了一个兼容OpenAI格式的大模型API服务所以我前端和后端要关注的回调格式已经固定了主要精力可以放在工程实现上。第4天开始前我把要做的事情拆成了4个卡点卡点一AI生成内容不能一次性返回必须“打字机式”输出前端怎么处理流式数据卡点二大模型本身没有记忆聊了几句之后它就把前面忘了前端怎么维护上下文卡点三对话过程中用户可能点“停止生成”也可能重复提交状态怎么管理才不乱卡点四API密钥绝对不能写在前端代码里请求要从哪里过这4个卡点正好对应了今天这篇笔记的结构。第一个问题就是流式输出它是“AI对话体验”和“普通表单请求”最大的区别。2. 流式响应是AI前端集成的第一道坎2.1 为什么AI对话必须做流式输出我第一次调通大模型接口时用的是传统POST请求然后等完整结果一次性返回。结果用户那边看到的现象是点一下发送页面转圈8到10秒然后突然“哗”地冒出几百字的回答。第一版demo上线后同事的反馈非常直接“这玩意儿是不是卡死了”大模型接口的“首字延迟”普遍在1到3秒长一点的复杂问题可能更久。如果等全量生成完再展示用户面对的就是一个空白的等待过程超过5秒没有反馈用户大概率以为页面挂了。这不是网络问题而是产品形态决定了必须流式返回。所谓流式输出就是服务端一边生成一边把内容推送过来前端收到一个片段就渲染一个片段。用户看到的是“正在打字的效果”哪怕生成慢一点感知上也是“AI在思考、在打字”比白屏干等舒服得多。在AI应用领域这已经不是一个可选优化项而是默认标配。2.2 SSE和WebSocket为什么我用SSE在决定流式方案的时候我先看了两个主流选项SSEServer-Sent Events和WebSocket。很多前端同学一提到“实时推送”就想到WebSocket但AI对话场景用SSE反而更合适。对比项SSE服务器推送事件WebSocket通信方向单向从服务端到客户端双向客户端和服务端可以互推协议基于HTTP独立的WS/WSS协议实现成本低原生fetch就能读流高需要额外处理连接状态、心跳、重连断线重连浏览器原生支持自动重连需要自己实现重试逻辑适用场景服务端持续推送数据AI增量输出、通知弹窗实时互动聊天室、多人协作、在线游戏大模型对话的数据流方向就是“服务端到前端单向推送”中间用户虽然会发送请求但发送动作本身还是普通HTTP请求不需要服务端主动接收实时指令。用WebSocket属于大材小用还要自己操心连接生命周期。所以我最终选择了SSE方案前端通过fetch读取可读流按块处理增量数据。2.3 用fetch读取流式接口的落地代码这里我直接把useChat组合式函数的核心代码贴出来。我用的是Vue3的组合式API配合TypeScript这套逻辑用React Hooks也能平移过去。// useChat.ts import { ref } from vue interface ChatMessage { role: user | assistant | system content: string } export function useChat() { const messages refChatMessage[]([]) const loading ref(false) const error ref() let controller new AbortController() function createAssistantMessage(): ChatMessage { return { role: assistant, content: } } async function send(text: string) { if (loading.value) return loading.value true error.value controller new AbortController() // 1. 把用户消息推入列表 messages.value.push({ role: user, content: text }) // 2. 先放一个空的AI消息占位后续增量填充 const assistantMessage createAssistantMessage() messages.value.push(assistantMessage) const assistantIndex messages.value.length - 1 try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ // 传给后端的历史消息 messages: messages.value.slice(0, -1) }), signal: controller.signal }) if (!response.ok) { throw new Error(HTTP ${response.status}) } const reader response.body!.getReader() const decoder new TextDecoder(utf-8) // 3. 循环读取流数据块 while (true) { const { done, value } await reader.read() if (done) break // 用stream: true处理跨块的多字节字符避免中文乱码 const chunk decoder.decode(value, { stream: true }) // 后端返回的是纯文本增量直接拼接到assistant消息上 messages.value[assistantIndex].content chunk } } catch (e: any) { if (e.name ! AbortError) { error.value 网络异常请重试 console.error(e) } } finally { loading.value false } } function stop() { controller.abort() loading.value false } return { messages, loading, error, send, stop } }这段代码里最值得注意的有两点。第一是TextDecoder的stream: true参数很多人在这里踩过乱码的坑——流式数据会把一个UTF-8中文字符拆成两个数据块如果每次都直接decode第一个块末尾会出现一个“”这样的乱码字符加了stream: true之后解码器会缓存没凑完整的字节等下一块到达后拼完整再输出就不会乱码了。第二是我们在发送前先往列表里塞了一条空的assistant消息后续读取流的时候直接索引到这条消息然后累加内容。这样做的好处是页面会自动渲染出一条“从无到有”的AI回复不需要额外维护一个“临时流内容”的变量。2.4 流式交互中的两个体验细节代码跑通之后我发现“能显示字”和“体验顺滑”之间还差两个细节。第一个是自动滚动。当AI回复变长、超出视口时页面必须跟着内容滚动否则用户看到的永远是上半段内容。但如果用户主动往上翻看历史消息这时候就不能强拖着滚动条往下走否则会很有攻击性。我在项目里加了一个判断如果用户在聊天过程中把滚动条往上拉了超过一定距离就暂停自动滚动等他拉回底部附近再恢复跟随。第二个细节是UI刷新频率。流式响应最快时每几十毫秒就来一个数据块如果每个块都触发Vue的响应式更新和DOM渲染老设备会明显卡顿。我的处理方式是内容先累积到一个缓冲变量里用requestAnimationFrame把这个缓冲变量的最新值定期同步到ref上这样一帧内不管来了多少个数据块只重新渲染一次。实际体感是输入30个块和输入3个块渲染开销差不多。3. 上下文管理让AI不“失忆”的工程账3.1 大模型没有记忆上下文依赖前端每次携带用户问完第一个问题之后紧接着问“那刚才那个方案里提到的库呢”如果前端不做任何处理AI会一脸茫然。原因是大模型本身是无状态的它不记得上一次对话发生了什么。所谓“记忆”本质上是前端在后一次请求里把前面的对话内容原样再发给服务端让模型在生成时参考这些历史内容。所以上下文管理的核心数据结构就是messages数组每一次发给大模型的请求都要携带system、user、assistant三类消息按时间顺序排好。system消息用来设定AI的角色和规则比如“你是一个前端开发助手回答尽量用中文和代码示例”user是用户每次输入的内容assistant是AI之前的回复。3.2 前端如何维护会话状态第4天我做的事本质上就是把这堆消息管理好。我定的规则是messages数组永远是“唯一可信源”不管界面上有什么状态都和它保持一致。发送消息时按顺序push进去接收流式响应时直接修改最后一条assistant消息清空会话时把整个数组重置为只保留system消息。这样后面做“重新生成”“查看历史会话”都会非常省事因为所有环节共用同一个数据源。具体到代码里我建议把维护逻辑收敛进useChat不要在组件里散落着一堆对数组的push和splice操作。否则一旦对话轮数变多“谁改了消息列表”“哪条消息是最后一条”就会变成噩梦。我见过不少失败的重构案例都栽在了状态分散上。3.3 上下文不能无限带token是一笔现实账第二个问题是历史消息不能全带。每次请求携带的历史越长模型的响应就越慢费用也越高。这里面有个关键概念叫“token”你可以把它理解成大模型处理文本的“最小计量单位”中文通常是1个汉字约等于1到2个token英文则是1个单词约等于1到2个token。大模型的计费是按输入token加输出token的总和来算的而“输入token”里历史消息占了很大比例。费用项计量口径前端实际消耗点输入token系统提示词 用户问题 历史消息每轮都重复携带历史轮次越多涨得越快输出tokenAI本次生成的内容回复越长成本越高流式界面让用户更倾向于说“继续”调用次数每次发起一次聊天请求用户反复重试、多次点击发送都会产生额外消耗第4天我开始算这笔账之后果断给前端加了两个策略。第一个是“滑动窗口截断”只携带最近N轮消息比如最近6轮更早的内容直接丢掉。对多数问答场景来说最近几轮的上下文已经足够再早的对话对当前问题的影响很小。第二个是“摘要压缩”这个策略稍微高级一点当对话超过一定轮数后前端先把前面的消息发到一个专门用来总结的接口生成一段摘要然后把“摘要 最近几轮完整消息”拼在一起再发给主模型。这样既保留了整体脉络又不会让输入token无限膨胀。第4天我只做了第一个策略第二个是后面几天的计划但思路先写在这里。3.4 上下文变长之后的超时与超预算问题聊到30多轮以后新的问题出现了请求体变得非常大接口响应时间明显变长偶尔还会直接断掉。前端如果只在那里傻等用户体验会很差。我的处理办法是给前端设置一个“软等待时间”如果15秒还没有收到任何数据块就提示“回复时间较长还在生成中”如果30秒仍无数据就主动中断请求给出重试按钮。这个超时时间和“2.3”节里的AbortController是配套的中断时同样走stop()的逻辑。另外前端最好展示一个“当前请求体大小”的数据对后端同学和产品经理都会友好很多他们看到“携带了1万token的历史消息”时会主动给你出优化建议的。4. 把AI对话封装成组件从“能跑”到“能维护”4.1 组件设计不要写一个500行的巨型单文件组件第4天下午我的代码里出现了一个200多行的ChatPanel.vue。功能是都跑通了但看着那坨代码我有一种非常不好的预感——明天再扩展一个“图片输入”功能这个文件就会变成500行大泥球。我决定按标准的组件拆分逻辑把它拆开ChatContainer.vue负责布局管理输入区、消息列表、滚动逻辑MessageList.vue只负责渲染消息列表接收messages数组处理滚动事件MessageItem.vue渲染单条消息区分用户消息和AI消息处理头像、时间、Markdown展示ChatInput.vue输入框、发送按钮、停止生成按钮、禁用态控制这样拆分之后每个组件都只干一件事后续要加“语音输入”只需要扩展ChatInput要加“多模态图片展示”只需要动MessageItem要加“会话历史侧边栏”把messages数组抽到一个store里就行不用改聊天组件本身。4.2 用组合式函数管理聊天状态组件拆好之后状态逻辑还是需要有地方放。我在项目里用Vue3的组合式API写了一个useChat也就是上面代码里展示的那个函数。这个函数把消息列表、加载状态、错误信息、发送/停止/重试方法全部封装进去组件里只需要调用它不需要关心内部实现。useChat对外暴露的状态和方法我做了严格限制不允许组件直接修改messages数组。所有变更都通过send、stop、retry、clear这几个方法完成。这样做的目的是在任何地方触发状态变化行为都是可控的不会出现“某个组件偷偷改了消息内容另一个组件渲染出异常”这种问题。4.3 竞态处理是状态管理里最容易翻车的地方AI对话有一个很典型的竞态场景用户快速点了两次发送按钮。如果前端不拦截第二次点击会在第一次请求还在进行时又发起一个请求两个流同时往同一个assistant消息上追加内容界面直接乱套。我在useChat的入口处加了最简单的防重入判断if (loading.value) return发送期间所有重复点击直接忽略。这个逻辑虽然简单但实时上很多团队都会忘记加。另一个竞态是“停止生成”和“发送下一条”之间几乎没有间隙用户刚点停止又立刻点发送。我的处理方式是停止操作会先把AbortController中断掉再把loading置为false这样在下一个send执行时loading已经是false不会误拦截合法的新请求。还有一类竞态容易被忽略用户在AI回答过程中切走了页面组件被销毁但异步的流读取还在继续等回来时发现页面状态混乱。我后来在onUnmounted里把controller.abort()也加上了防止组件销毁后还在写入已经卸载的响应式数据。React生态里等价的做法是在useEffect的清理函数里调用abort。4.4 Markdown渲染与代码高亮不能无脑做AI回复的内容大部分是Markdown格式尤其是代码类内容占比很高。我在第4天引入了marked做Markdown解析配合highlight.js做代码高亮。实现起来不难但有两个性能问题需要提前预防。第一个问题是流式过程中每一帧都重新解析Markdown。我一开始是在assistant消息的content变化时直接重新渲染整个Markdown结果AI每打几个字整块文本的解析和高亮就全部重来一次长回复时页面卡得不像话。后来我改成流式过程中只渲染纯文本等loading变为false之后才把完整内容交给Markdown渲染组件。用户可以接受“生成过程中是纯文本结束后变成带格式”的体验但绝对接受不了每打几个字就闪烁一次。第二个问题是代码高亮很吃计算量。我在MessageItem里对代码块用了“延迟高亮”消息渲染到DOM之后在requestIdleCallback或setTimeout里再做代码高亮。简单来说就是界面先把内容展示出来高亮配色稍微晚半拍出现用户感知不明显但页面主线程的压力显著下降。5. 上线前必须处理的几个“非功能”问题5.1 API密钥绝不能出现在前端代码里第4天晚上我开始认真考虑“这个功能能不能让用户真的用到”。第一个头像般的决策就是绝对不能把大模型API的密钥直接写在前端代码里。原因很简单所谓“前端代码”一旦发到浏览器里就等于公开了。就算你打包时混淆、加密、藏在环境变量里用户在开发者工具里翻一翻网络请求照样能看到。把API密钥放在前端等于把公司的钱包挂在门口任何人拿着密钥都能以你的身份疯狂调用接口产生巨额费用甚至绕过你的业务逻辑直接使用模型能力。正确的做法是加一个“后端中转接口”。前端统一请求自己的后端服务后端拿到请求后再把请求转发给大模型API。密钥只保存在后端环境变量里。结构是这样的浏览器前端 - 自己的后端/chat接口 - 大模型API这个中转接口用Express写起来非常短我贴一个最小可运行的版本// server/chat.js const express require(express) const router express.Router() router.post(/chat, async (req, res) { const { messages } req.body // 1. 基础入参校验 if (!Array.isArray(messages) || messages.length 0) { return res.status(400).json({ error: invalid messages }) } // 2. 转发给上游大模型服务 const upstreamRes await fetch(process.env.LLM_API_URL, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.LLM_API_KEY} }, body: JSON.stringify({ model: process.env.LLM_MODEL, messages, stream: true }) }) if (!upstreamRes.ok) { return res.status(upstreamRes.status).json({ error: upstream error }) } // 3. 以SSE格式把上游流式响应转发给前端 res.setHeader(Content-Type, text/event-stream; charsetutf-8) res.setHeader(Cache-Control, no-cache) res.setHeader(Connection, keep-alive) const reader upstreamRes.body.getReader() const decoder new TextDecoder(utf-8) try { while (true) { const { done, value } await reader.read() if (done) break // 这里按行转发前端可以直接拼接内容 res.write(data: ${decoder.decode(value)}\n\n) } res.end() } catch (e) { res.end() } })这段代码还有两个可以继续补强的点。第一是加一个“消息内容长度限制”比如单条用户消息不能超过2000字历史消息总条数不能超过50条防止有人用超长文本攻击接口。第二是加一个简单的频率限制比如同一个IP每分钟最多调用5次这些在后端实现都很常规。5.2 入参校验与错误格式统一做后端中转的另一大好处是可以把上游大模型各种五花八门的错误响应统一成前端能看懂的结构。上游可能返回400、401、429、500等各种状态码如果前端直接对待上游的原始响应每个错误码都要写一遍判断。我做了个统一错误包装后端捕获到错误后统一返回{ code, message }比如{ code: RATE_LIMITED, message: 请求太频繁请稍后重试 }。前端只需要根据code字段做提示。这样就算后面换了一家大模型服务商只要后端透出的错误格式不变前端一行代码都不用改。5.3 降级方案AI服务挂了页面不能跟着挂第4天我特别意识到一件事AI功能对你来说是项目的核心但对用户来说他只是想“问一个问题”。如果模型服务今天真的不可用用户不能连一个问题都发不出去。我的方案是给整个AI对话组件加了一个“降级开关”在后端接口返回某些明确错误时前端自动进入降级模式。降级表现有三种降级等级触发条件页面表现提示级模型暂时超时显示Toast提示“AI服务繁忙请稍后再试”排队级并发限制触发显示一个等待队列提示按钮变成“排队中”但不允许重复发送备用模式模型服务完全不可用聊天框保留但发送按钮变成“提交到留言列表”内容落到后端工单表第三种模式是我觉得最有价值的。就算没有大模型用户的消息照样能提交进去运营第二天看到之后再手动回复保证用户不会因为AI挂了就彻底流失。这个方案我只花了半天就接上了效果却非常明显。5.4 灰度与监控先让10个人用别全量放开最后一个经验是灰度。AI对话功能和普通页面有一个很大的区别它的单次请求成本是持续发生的而且不可控因素很多。我把这个功能藏在一个需要手动开启的入口后面只让内部测试团队的10个人先体验。同时在请求里加了一个sessionId参数后端按这个ID记录每轮请求的耗时、消耗的token数量、成功率。第4天我只看两个核心指标首字节平均耗时和单轮平均token消耗。首字节超过4秒的会话大概率是历史消息带得太多需要优化上下文截断策略单轮token消耗异常高的多半是用户在故意输长文本或者请求陷入了重试循环。有了数据之后优化方向一下子变得清晰不再靠拍脑袋猜问题。第4天结束前的三点体会写到这儿第4天的笔记基本结束了。我最后想分享三点实实在在的体会也是给明天自己的备忘。第一前端做AI集成真正的难点不在AI概念本身而在把一个“模型能力”翻译成“用户可体验的产品”。流式输出、上下文、竞态、降级这些东西教科书里不会教你但每一个都会真实地砸在项目上线前的某一天。第二调试流式接口有一个很实用的小技巧写前端代码之前先用Postman或curl直接打一次后端中转接口确认它能稳定输出data: xxx格式的流。如果这一步就不通不要急着调前端代码问题大概率在后端转发逻辑或上游服务上。把接口链路先打通再处理渲染层会省掉大量无头绪的排查时间。第三也是我最大的变化前3天我一直在“学东西”第4天我在“做东西”。学习笔记写到第4天最值得记录的已经不是某个API怎么调、某个参数怎么传而是整个过程中遇到的这些工程问题。明天我准备接着做多模态输入和会话历史持久化到时候再继续更新。
返回列表