前言

今年三月初我写了 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
// vite.config.js —— 开发时把 /api 转发给本地 Node 服务
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
// server/index.js(核心部分,文件顶部需要 const { Readable } = require('node:stream'))
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 => {
// 设置 SSE 响应头,然后把上游字节流直接透传给下游
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 把浏览器侧的断开同步给上游,停止白烧 token
req.on('close', () => openaiRes.body.cancel());
// fetch 的 body 是 Web ReadableStream,得先转成 Node 流才能 pipe
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
// utils/sse.ts —— 从 fetch 响应中持续产出"一段一段"的文本
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;

// TextDecoder 第二个参数必须传 stream:true!
// 否则一个多字节中文字符恰好被切在两个 chunk 边界时会解码成乱码
buffer += decoder.decode(value, { stream: true });

// SSE 消息以空行分隔,按行切开后逐行解析
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 最后一段可能是半截行,留到下一个 chunk

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 {
// 偶发的不完整 JSON,丢弃该行即可(buffer 机制已兜住大多数情况)
}
}
}
}

三个必须记住的细节,每一个我都踩过:

  1. **TextDecoder 必须传 { stream: true }**。SSE 按 chunk 推送,一个多字节中文字符可能被切在两个 chunk 之间,不传 stream 参数就会解码出乱码;
  2. buffer 是必须的。网络层不保证 data: {...} 完整地在一个 chunk 里到达,lines.pop() 把半截行留到下一个 chunk 拼接,这是手写 SSE 解析最容易漏的防御;
  3. 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
// hooks/useChat.ts(核心逻辑节选)
const contentRef = useRef(''); // 流式内容的"真相"存 ref,避免高频 setState
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) {
// 本帧还没排过渲染任务,排一个;下一帧统一刷到 React 状态
rafId.current = requestAnimationFrame(() => {
rafId.current = 0;
setMessages(prev => {
const next = [...prev];
next[next.length - 1] = {
...assistantMsg,
content: contentRef.current
};
return next;
});
});
}
}
// 流结束后强制补一次最终状态,防止最后一个 rAF 没来得及执行
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') {
// 用户主动停止,不算错误,见第 6 节
} else {
setMessages(prev => updateLast(prev, m => ({ ...m, error: true })));
}
} finally {
setLoading(false);
abortRef.current = null;
}
}

效果:数据仍在全速到达,但 React 每 16.7ms 才重渲染一次,肉眼看到的就是流畅的打字机,长列表也扛得住。顺带一提,就算某一帧里有多处 setState,React 18 的自动批处理也会把它们合并成一次渲染,所以我们只需要管”渲染频率”,不用管”合并”。

React 流式渲染架构:SSE 数据流经 ref 缓冲与 rAF 节流后进入组件渲染

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
// utils/markdown.ts —— 流式渲染时修补未闭合的 ``` 围栏
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
// types.ts
interface Session {
id: string; // crypto.randomUUID()
title: string; // 取首条用户消息的前 20 个字
messages: ChatMessage[];
updatedAt: number;
}

// utils/storage.ts
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 {
// 配额溢出:留最新的 20 个会话,其余淘汰
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 迁移。代码整理完我会开源,感兴趣的朋友到时候来玩。