今天是我上手前端+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.js(Express)后端。同事已经帮我开通了一个兼容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
在决定流式方案的时候,我先看了两个主流选项:SSE(Server-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 = ref<ChatMessage[]>([]) 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 | 系统提示词 + 用户问题 + 历史消息 | 每轮都重复携带历史,轮次越多涨得越快 |
| 输出token | AI本次生成的内容 | 回复越长成本越高,流式界面让用户更倾向于说“继续” |
| 调用次数 | 每次发起一次聊天请求 | 用户反复重试、多次点击发送都会产生额外消耗 |
第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; charset=utf-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怎么调、某个参数怎么传,而是整个过程中遇到的这些工程问题。明天我准备接着做多模态输入和会话历史持久化,到时候再继续更新。