前言 今年三月初我写了 ChatGPT API 的接入笔记,五月写了 Prompt 指南,八月交了 Copilot 半年复盘。这篇是这一系列的实际落地:把前面积累的知识拧成一个真正可用的 AI 聊天应用 ——支持流式打字机效果、Markdown 与代码高亮渲染、多会话管理、本地持久化,可以随时中断生成。
这篇文章是写给前端工程师的,四个技术核心会给出完整代码:fetch + ReadableStream 解析 SSE、requestAnimationFrame 打字机、流式 Markdown 的未闭合代码块处理、AbortController 停止生成。除了这四个核心,我还会把自动滚动跟随、会话标题自动生成这些”小而关键”的体验细节一并交出来——做聊天类产品,细节决定质感。全部踩坑点都是我真实踩过的,直接开搞。
1. 技术选型与项目结构 选型没有悬念:React 18 + Vite + TypeScript 前端,Node(Express)做 API 代理。Vite 的开发体验太舒服了,代理配置两行就搞定:
1 2 3 4 5 6 7 8 9 export default defineConfig({ plugins: [react()], server: { proxy: { '/api' : 'http://localhost:8787' } } });
项目结构如下,体量不大,但每个文件的职责都划清楚了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ai-chat/ ├── server/ │ └── index.js # Express 代理:持有 key,转发 OpenAI 并透传 SSE ├── src/ │ ├── App.tsx # 布局 + 会话切换 │ ├── components/ │ │ ├── ChatMessage.tsx # 单条消息(Markdown 渲染在这里) │ │ ├── ChatInput.tsx # 输入框 + 发送/停止按钮 │ │ └── Sidebar.tsx # 会话列表 │ ├── hooks/ │ │ └── useChat.ts # 核心:流式请求、状态批处理、中断 │ ├── utils/ │ │ ├── sse.ts # ReadableStream → 消息块的解析器 │ │ └── storage.ts # localStorage 会话持久化 │ └── types.ts └── package.json
为什么一定要自建代理,三月那篇文章已经讲过了:key 不能进前端,流式场景下代理还要多干一件事——把 OpenAI 返回的 SSE 原样透传给浏览器 ,Node 里就是 pipe 一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 app.post('/api/chat/stream' , (req, res ) => { const { messages } = req.body; fetch('https://api.openai.com/v1/chat/completions' , { method: 'POST' , headers: { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${process.env.OPENAI_API_KEY} ` }, body: JSON .stringify({ model: 'gpt-3.5-turbo' , messages, stream: true }) }).then(openaiRes => { res.setHeader('Content-Type' , 'text/event-stream' ); res.setHeader('Cache-Control' , 'no-cache' ); res.setHeader('Connection' , 'keep-alive' ); req.on('close' , () => openaiRes.body.cancel()); Readable.fromWeb(openaiRes.body).pipe(res); }); });
2. 核心:fetch + ReadableStream 解析 SSE 这是全文最核心的一段。stream: true 时,OpenAI 返回的不是一个大 JSON,而是 Server-Sent Events 格式的字节流:一串 data: {...} 行,每行携带一小段增量文本(delta),最后用 data: [DONE] 收尾:
1 2 3 4 5 data: {"choices":[{"delta":{"content":"你好"}}]} data: {"choices":[{"delta":{"content":",我是"}}]} data: [DONE]
用 fetch 处理它要用到 response.body 这个 ReadableStream,配上 TextDecoder 一块一块地读。这是纯原生 Web API,比 EventSource 好用的地方在于:可以用 POST (EventSource 只支持 GET)、可以自定义 headers 、可以随时 abort 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 export async function * streamChat ( messages: ChatMessage[], signal: AbortSignal ): AsyncGenerator <string > { const res = await fetch('/api/chat/stream' , { method: 'POST' , headers: { 'Content-Type' : 'application/json' }, body: JSON .stringify({ messages }), signal }); if (!res.ok) { throw new Error (`请求失败:${res.status} ${await res.text()} ` ); } const reader = res.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 lines = buffer.split('\n' ); buffer = lines.pop() ?? '' ; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:' )) continue ; const payload = trimmed.slice(5 ).trim(); if (payload === '[DONE]' ) return ; try { const json = JSON .parse(payload); const delta = json.choices?.[0 ]?.delta?.content; if (delta) yield delta; } catch { } } } }
三个必须记住的细节,每一个我都踩过:
**TextDecoder 必须传 { stream: true }**。SSE 按 chunk 推送,一个多字节中文字符可能被切在两个 chunk 之间,不传 stream 参数就会解码出乱码;
buffer 是必须的 。网络层不保证 data: {...} 完整地在一个 chunk 里到达,lines.pop() 把半截行留到下一个 chunk 拼接,这是手写 SSE 解析最容易漏的防御;
JSON.parse 要 try/catch 。残缺行混进来时静默丢弃比让整个流崩掉更合理。
3. 打字机效果:React 状态批处理 + rAF 节流 拿到增量文本流后,最直觉的写法是每个 chunk 都 setState 一次。我的第一版就是这么写的,然后列表稍微长一点就卡了——chunk 到达频率可能高达每秒几十次,每次 setState 都触发一轮重渲染 。
解法是两层:用 ref 攒数据,用 requestAnimationFrame 控制渲染节奏 。rAF 是浏览器渲染前的回调,每帧最多执行一次(约 16.7ms 一帧),天然就是把高频数据”降采样”到渲染帧率的节流器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 const contentRef = useRef('' ); const rafId = useRef<number >(0 );async function sendMessage (text: string ) { const assistantMsg = createMessage('assistant' , '' ); setMessages(prev => [...prev, assistantMsg]); contentRef.current = '' ; const controller = new AbortController(); abortRef.current = controller; try { for await (const delta of streamChat(messagesRef.current, controller.signal)) { contentRef.current += delta; if (!rafId.current) { rafId.current = requestAnimationFrame(() => { rafId.current = 0 ; setMessages(prev => { const next = [...prev]; next[next.length - 1 ] = { ...assistantMsg, content: contentRef.current }; return next; }); }); } } cancelAnimationFrame(rafId.current); rafId.current = 0 ; setMessages(prev => { const next = [...prev]; next[next.length - 1 ] = { ...assistantMsg, content : contentRef.current }; return next; }); persistSession(sessionId, contentRef.current); } catch (err) { if ((err as Error ).name === 'AbortError' ) { } else { setMessages(prev => updateLast(prev, m => ({ ...m, error : true }))); } } finally { setLoading(false ); abortRef.current = null ; } }
效果:数据仍在全速到达,但 React 每 16.7ms 才重渲染一次,肉眼看到的就是流畅的打字机,长列表也扛得住。顺带一提,就算某一帧里有多处 setState,React 18 的自动批处理也会把它们合并成一次渲染,所以我们只需要管”渲染频率”,不用管”合并”。
3.1 消息列表自动跟随滚动 流式输出时还有个体验细节:新内容不断追加,视口必须自动跟随滚动到底部,否则用户看到的一直是静止的旧内容。第一版我用 scrollTop = scrollHeight,能用但有 bug——用户往上翻看历史消息时,会被强行拽回底部,非常恼人。
正确做法是区分”用户在底部”和”用户在看历史”两种状态:只在用户本来就处于底部附近时才自动跟随,一旦用户主动上翻就交还控制权:
1 2 3 4 5 6 7 8 9 10 11 12 // 简化示例:判断用户是否在底部附近 const containerRef = useRef<HTMLDivElement>(null); const isNearBottom = () => { const el = containerRef.current; if (!el) return true; return el.scrollHeight - el.scrollTop - el.clientHeight < 80; }; // 在每次 rAF 刷新消息内容后调用 if (isNearBottom()) { containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight }); }
判断阈值取 80px,既容忍了浮点误差,又给用户留了一点上翻的容错空间。另外注意滚动要放进 rAF 回调里和 setState 一起做,千万别在 for await 循环里直接滚,否则又变成每秒几十次的强制同步布局(layout thrashing),低端机立刻掉帧。这个细节代码不到十行,但它决定了应用”像不像 ChatGPT”——这类细节在 AI 应用里往往比功能本身更影响体感。
4. 流式 Markdown 渲染的”未闭合”难题 回答是 Markdown 格式的,我直接上 react-markdown + react-syntax-highlighter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // components/ChatMessage.tsx import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; export function ChatMessage({ message }: { message: ChatMessage }) { return ( <ReactMarkdown components={{ code({ node, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); return match ? ( <SyntaxHighlighter language={match[1]} style={oneDark}> {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className={className} {...props}>{children}</code> ); } }} > {message.content} </ReactMarkdown> ); }
然后就撞上了本篇最有意思的坑:**生成过程中,Markdown 永远是”半成品”**。正在流式输出一个代码块时,开头的 ```js 已经到了、内容到了一半、但闭合的 ``` 还没来。这时 react-markdown 会把”本应是代码块”的内容当成一堆乱七八糟的段落和行内代码渲染出来,每来一个字符整个块都抖一下,视觉上像页面在抽搐。
解法很朴素:渲染前修补文本,把未闭合的围栏代码块补上闭合标记 :
1 2 3 4 5 6 7 8 9 10 11 export function patchUnbalancedFences (md: string ): string { const fences = md.match(/^```/gm ); if (fences && fences.length % 2 === 1 ) { return md + '\n```' ; } return md; } <ReactMarkdown>{patchUnbalancedFences(message.content)}</ReactMarkdown>
补了闭合标记后,代码块从第一个字符起就以”完整代码块”的形态渲染,只是内容在增长,视觉稳定多了。这里我有个小取舍:每个 token 都做语法高亮其实浪费 ——高亮函数对几千字的代码块每帧跑一遍挺贵的。我的做法是生成中先用纯样式容器,流结束后再切到 SyntaxHighlighter 渲染完整高亮,肉眼几乎无感,性能好一大截。
5. 会话管理与 localStorage 持久化 多会话的数据模型很简单:一个会话数组 + 当前会话 id,全部落 localStorage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 interface Session { id: string ; title: string ; messages: ChatMessage[]; updatedAt: number ; } const KEY = 'ai-chat-sessions-v1' ;export function loadSessions ( ): Session [] { try { return JSON .parse(localStorage .getItem(KEY) ?? '[]' ); } catch { return []; } } export function saveSessions (sessions: Session[] ) { try { localStorage .setItem(KEY, JSON .stringify(sessions)); } catch { saveSessions([...sessions].sort((a, b ) => b.updatedAt - a.updatedAt).slice(0 , 20 )); } }
两个必须设计的点:
保存时机 :不要每个 token 都写 localStorage(又是一个性能坑)。我只在”流正常结束”、”用户点停止”、”切换会话”、”beforeunload”这四个时机写盘,粒度刚好;
配额防御 :localStorage 只有 5MB 左右,长对话 + 代码块很容易撑爆。我按 updatedAt 只保留最近 20 个会话;再大的需求就该上 IndexedDB 了(入库的代码块内容 + 时间戳做审计,也是够用的)。
还有个加分细节:会话标题不要让用户手动填。新建会话时标题就叫”新对话”,当第一轮回答结束后,取首条用户消息的前 20 个字作为标题自动回填——零成本,但会话列表立刻有了”可检索性”。想做得更精致,也可以把这条消息单独发给模型让它起个标题,不过为了省 token 我暂时选了截字方案。
至于多轮上下文,就是三月那篇文章讲的原理:每次请求把当前会话的 messages 完整带上,加上 4096 上限的滑动窗口裁剪。应用层的会话持久化和 API 的无状态,配合得刚刚好。
6. 错误处理与停止生成 停止生成 是聊天应用的刚需(AI 话痨起来真的想让它闭嘴),核心就是 AbortController,前面代码里已经埋好了:点”停止”按钮时调 abortRef.current?.abort(),fetch 立刻 reject 一个 AbortError,同时我们透传给服务端的 req.on('close') 会让上游也断开——浏览器和 OpenAI 两头同时止血,token 一分不多烧 。
错误处理的分类要细致,用户看到的文案完全不同:
1 2 3 4 5 6 7 function humanizeError (err: Error ): string { if (err.name === 'AbortError' ) return '' ; if (err.message.includes('429' )) return '请求太频繁了,喝口水再试' ; if (err.message.includes('401' )) return 'API key 配置有问题,检查服务端环境变量' ; if (err.message.includes('network' )) return '网络开小差了,请重试' ; return '生成失败:' + err.message; }
对于可恢复的瞬时错误(429 限频、网络抖动),可以再加一层简单的自动重试:指数退避(1s、2s、4s)最多三次,重试时把已生成的半截内容丢掉从头再来——流式响应没法断点续传,这一点要有心理预期。但也别重试得太积极:用户盯着一个转圈按钮超过十秒,大概率已经失去耐心,三次之后老老实实报错、让人手动点重发,反而更体面。
另一个容易忽视的细节:被中断的那条消息要不要保留? 我的做法是保留已生成的部分内容并打上”已停止”标记——用户等了半天的内容扔掉太可惜,而且断点续问(”继续”)的体验也依赖它。
写在最后 这个应用做下来一周多一点,代码量千行出头,但它把我今年积累的东西全部串起来了:三月的 API 与多轮对话原理、五月的 Prompt 设计(system prompt 决定了助手的性格)、八月的”审代码”习惯(AI 生成的每一行都验证过)。
技术层面最后总结三句话:SSE 解析的命门是 buffer 与 TextDecoder 的 stream 模式;渲染性能的命门是 ref 攒数据 + rAF 节流;流式 Markdown 的命门是修补未闭合围栏 。这三条记住,你就超过了 80% 的同类实现。
这一年里我最大的收获其实是心态的转变:面对新技术,从”观望等成熟”变成了”上手玩起来”。ChatGPT API 三月刚发布时粗糙得像半成品,但正是那些第一时间动手的人,比观望者早了近一年建立起对新范式的手感。手感这东西,文档给不了,只能靠手蹭出来。
回头看,2023 年说是大模型元年一点不夸张:年初我还在网页上第一次跟 AI 聊得目瞪口呆,年底已经亲手做出一个能日常使用的 AI 应用。这个应用我还在迭代:计划加上会话搜索、Prompt 预设库和 IndexedDB 迁移。代码整理完我会开源,感兴趣的朋友到时候来玩。