前言
从 2026 年的面试情况来看,AI 工程能力已从"加分项"变成"必选项"。很多前端同学一上来就学 Agent、学 RAG、学 MCP,结果面试一问“SSE 怎么解析”、“Token 和 Context Window 什么关系”就卡壳。本章将从一个基础的 Chat Demo 开始和大家一步步成长。
- 在学习和练习时你需要自己准备好大模型的 API Key,模型能力无所谓主要是能调用就行。
- 代码仓库为:github.com/yuhano1937-…
- 欢迎大家在评论区交流。
基础知识点
以下是一些基本概念,以理解为主,面试能做到准确表达。
-
Token
Token 不是字符也不是单词,是子词(subword)。中文约 1 字 ≈ 0.6~1.5 token,英文 1 词 ≈ 1.3 token。计费、上下文窗口、限速全部按 token 算,且输入 + 输出一起算。
面试话术:Token 是模型最小计费与计算单元。前端要关心两点:长上下文会撑爆窗口和钱包,要做截断或摘要;流式渲染本质就是后端按 token 切片吐、前端逐片拼。
-
Context Window(上下文窗口)
比如常见以及 8K / 32K / 128K。多轮对话时会把历史全塞进去,会越聊越贵、越慢,还可能超窗。
工程对策:短期最近 N 轮 + 中期摘要压缩 + 长期丢向量库检索。前端最好要能展示“已折叠”或者“已摘要”状态,别让用户以为模型记得全部。
-
Temperature(随机性)
低(0.2)= 稳、准、重复;高(1.2)= 发散、有创意。代码类用 0.1~0.3,头脑风暴用 0.8~1.2。
-
Top-p / Top-k
Top-k:只保留概率最高的 k 个词,固定数量,不自适应分布形状。
Top-p(核采样):按累积概率裁剪,取”累积概率首次 ≥ p 的最小词集合“,分布尖就小、平就大。
项目实战
- 看完了枯燥的概念,我们直入主题,用 Nextjs 来搭建一个 Chat Demo 。直接用官方脚手架,省心且规范:
npx create-next-app@14 w1-chat-demo \
--ts --app --no-src-dir --eslint --no-tailwind --import-alias "@/*" --use-npm
- 我们搭建服务端的代理,这里我们主要是学习前端如何解析 SSE ,所以仅涉及到获取环境变量,代理上游的大模型供应商接口。新建
app/api/chat/route.ts:
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const apiKey = process.env.OPENAI_API_KEY;
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
const model = process.env.OPENAI_MODEL || 'gpt-4o-mini';
if (!apiKey) return new Response('缺少 OPENAI_API_KEY', { status: 500 });
// Key 只在服务端读,前端永远拿不到
const upstream = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, messages, stream: true }),
signal: req.signal, // 把前端中止信号透传给上游(避免前端终止了对话,后端还在继续的情况)
});
if (!upstream.ok || !upstream.body) {
return new Response(`上游错误 ${upstream.status}`, { status: 502 });
}
// 直接透传上游 SSE 字节流,保持真流式打字机
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no', // 反向代理别缓冲,否则变假流式
},
});
}
这里有一个关于API Key 的安全红线,API Key 只在服务端 process.env 读,前端永远拿不到。可以故意把 Key 写进前端再 npm run build,用 grep -r "sk-" .next 搜产物,能看到就说明泄露了。
上述代码中我们用到了三个环境变量,新建.env.local文件(绝不进仓库,记得 .gitignore 文件中添加忽略):
OPENAI_API_KEY=sk-xxxx # 本地 Ollama 时则随意填写,如 ollama
OPENAI_BASE_URL=https://api.openai.com/v1 # 或 DeepSeek/通义/智谱/Ollama
OPENAI_MODEL=gpt-4o-mini
- 前端手写流式解析(这段是面试高频点 —— 手写
fetch+ReadableStream消费 SSE),新建文件app/page.tsx:
// 代码只展示核心逻辑,完整 page.tsx 文件包含了前端交互和UI逻辑
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
signal: ctrl.signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 流式解析三步法:切事件、残片存回、解析data
const events = buffer.split('\n\n'); // 以 \n\n 来切割事件
buffer += events.pop() ?? ''; // 解决粘包和半包
for (const evt of events) {
const line = evt.split('\n').find(l => l.startsWidth('data: '));
if (!line) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
if (delta) setMessages(/* 追加到最后一条 assistant 消息 */);
}
}
上述便是流式解析的核心代码,完整代码克隆代码并运行体验。掌握”三步法“的核心思想能手写代码。这是一最小闭环的代码,如果在工作中更推荐使用 Vercel AI SDK。手写一遍后,新建个 app/sdk/page.tsx 文件用 Vercel AI SDK 的 useChat()来实现一遍:
'use client';
import { useChat } from '@ai-sdk/react';
export default function SdkChat() {
const { messages, input, handleInputChange, handleSubmit, status, stop, error } =
useChat({ api: '/api/chat-sdk' });
// 同样一个聊天界面,这里约 20 行搞定手写版 60+ 行的活
}
使用这个库的话,它封装了消息状态、流式解析、错误、停止。对应服务端要用 streamText 转成 SDK 自己的协议(不能直接用手写版的透传路由):
const provider = createOpenAI({ apiKey, baseURL: baseUrl });
const result = streamText({ model: provider(model), messages, abortSignal: req.signal });
return result.toDataStreamResponse(); // 关键:转成 AI SDK 私有协议
面试怎么讲: ”生产我直接用 useChat(),它封装了消息状态、流式解析、错误、停止。但我清楚它底下用 ReadableStream+TextDecoder 按 \n\n 切 SSE、残片拼接防粘包、逐 token 追加 state“。
会调 Hook 的人很多,知道它怎么流式解析的人少,这就是差异化。
-
除了上述的流式调用还有其他的调用方式:
- 基础对话:一次性拿完整结果。
- JSON Mode / Function Calling:把自然语言变成结构化数据或工具调用意图,是 Agent 的入口。
源码的 scripts/ 下用纯 fetch 写 6 个脚本,建议拉去代码后都运行一下,重点感受:02 的逐字输出、04 模型只产出调用意图不真的执行、06 同样 prompt 不同温度出完全不同结果。
node --env-file=.env.local scripts/01-chat.mjs # 基础对话
node --env-file=.env.local scripts/02-stream.mjs # 流式
node --env-file=.env.local scripts/03-json-mode.mjs # JSON Mode
node --env-file=.env.local scripts/04-function-calling.mjs # Function Calling
node --env-file=.env.local scripts/05-multiturn.mjs # 多轮上下文
node --env-file=.env.local scripts/06-params-demo.mjs # 温度/Top-p 感受(多跑几次感受随机性)
疑问与拓展
完成了上述最小闭环的项目后又或是在阅读的过程中,都会产生很多的疑问。以下是部分解答与拓展,欢迎补充和提问交流。
疑问
- 为什么要使用
\n\n来进行事件的拆分:
这是 W3C Server-Sent Events 标准规范 定义的消息定界规则。单换行 \n是“字段分隔符”:一个事件内部可以包含多行属性(如 event:、data:、id:、retry:)。双换行 \n\n(即空行)是“事件终止与分界符”。协议规定,当客户端连续读到两个换行符(一个空行)时,才代表一个完整事件的所有字段已发送完毕,触发一次完整的事件派发。
event: message\n
id: 101\n
data: {"choices":[{"delta":{"content":"你"}}]}\n
\n
event: message\n
id: 102\n
data: {"choices":[{"delta":{"content":"好"}}]}\n
\n
如果只按单个 \n 切割,就会把同一事件里的 event、id、data 拆得七零八落;只有按 \n\n 切割,拿到的才是由一个空行封装的独立事件包。
if (data === '[DONE]') continue;大模型流式内容标识除了 DONE 还有哪些?
除了 OpenAI 事实标准的 data: [DONE] 结束符,标准 SSE 还有心跳注释(: ping)、错误事件、以及其他大模型协议(如 Anthropic 的 message_delta、Vercel AI SDK 的类型前缀等)。
像 Anthropic Claude API,并不直接在同一事件里硬塞,而是通过 event: 区分生命周期:
event: message_start(初始化上下文元数据)event: content_block_start(开始正文块)event: content_block_delta(正文文字增量)event: message_delta(返回 token 用量usage)event: message_stop(流式结束)
像 DeepSeek-R1、Qwen-QwQ 等推理模型,增量事件中不仅有正文字符,还会先输出思考链:
data: {"choices":[{"delta":{"reasoning_content":"先分析用户的意图..."}}]}\n\n
-
SSE和原生的EventSource有什么区别?
EventSource虽是浏览器内置的高级 API,但标准限定死只能发 GET 请求且无法配置复杂请求头,而大模型交互动辄携带庞大的上下文消息体(Messages Array),必须用 POST,因此生产中一律改用基于 fetch + ReadableStream 自行消费。 -
为什么
buffer = events.pop() ?? ''能彻底解决半包?
- 如果收到的数据以
\n\n结尾,split('\n\n')的最后一项必然是空字符串"",被pop()拿走存入buffer,对后续没有负面影响; - 如果收到的数据被截断在半截(没有
\n\n),split('\n\n')的最后一项必然是未收尾的残片(半包) ,被pop()拿出来存回buffer; - 等待下一次
reader.read()拿到新的分片后,直接通过buffer += ...拼接到残片后面,残片就变成了完整数据。
res.body.getReader()是什么作用?
res.body 的本质是 ReadableStream 当你使用 fetch 发起请求后,只要 HTTP 响应头一返回,fetch 的 Promise 就 Resolve 了。此时服务器虽然还没发完全部数据,但通道已经建立,res.body 就是一个指向这个通道的“可读字节流对象”。调用 res.body.getReader() 会产生一个排他锁(Exclusive Lock)锁住这个流,并返回一个流控制器对象:ReadableStreamDefaultReader。一旦拿到了 reader,就能通过 while(true) 循环不断执行:
const { done, value } = await reader.read();
-
done(布尔值) :false:表示流还在持续,网卡刚收到了一批新数据;true:表示服务端已正常结束响应或主动关闭,循环可以break退出。
-
value(二进制数据块 Chunk) :- 它的类型是
Uint8Array(一个装满 0~255 数字的字节数组),比如:Uint8Array(12) [100, 97, 116, 97, 58, 32, 228, 189, 160...]。
- 它的类型是
TextDecoder又是什么? 为什么必须传{ stream: true }?
从网卡收到的 value 只是底层的物理字节(Uint8Array),JavaScript 无法直接把一个字节数组和一个字符串做 + 拼接(如果直接强行拼,会变成 "[object Uint8Array]")。
TextDecoder 是浏览器原生的文本解码器(默认采用 UTF-8 编码)。它的唯一使命就是:把二进制的字节数组(Uint8Array)高效翻译回 JavaScript 的文字字符串(string)。
英文字符在 UTF-8 里只占 1 个字节;中文汉字在 UTF-8 里通常占 3 个字节。例如汉字 "你" 的三个字节分别是:[228, 189, 160]。假设网卡切包非常凑巧,前一个包正好收到了汉字“你”的前 2 个字节 [228, 189],而后 1 个字节 [160] 被分到了下一个包里。
如果不加 { stream: true },TextDecoder 认为当前这包数据已经结束了,遇到未收拢的残缺字节会直接判定为非法编码,立刻吐出一个黑菱形问号乱码。
加上 { stream: true }, TextDecoder 内部会开启“流式记忆”,当它发现汉字少了字节时,不会立即解码报错,而是默默把残缺的 2 个字节缓存在解码器内部;等到下一个包进来把第 3 个字节送达时,它才一口气合成正确的汉字 "你" 输出!
拓展1
在当前的最小闭环 Demo 中,只处理 data: 确实就足够了;但在生产级的大模型应用中,只处理 data: 是远远不够的。
OpenAI 的原生流式协议非常极简,它默认省略了 event: 字段行(根据 W3C SSE 规范,没有 event: 行时默认事件类型就是普通的 message),并且把所有增量 token、思考链和停止原因全部打包在 data: {...} 的 JSON 字符串中。因此 Demo 过滤出以 data: 开头的行就能满足打字机需求。
-
为什么生产级不能只看
data:?- 事件类型分流(
event:行) :不同厂商(如 Anthropic Claude)或企业网关会将业务流拆解为独立的event: error、event: tool_call、event: ping等。如果不解析event:,就无法区分当前数据包是正文、工具调用还是异常报错。 - 非正文数据的处理:真实业务中大模型流式吐出的不仅是“对话文字”,还包括思维链(Reasoning)、工具调用过程(Tool Calls)、引用文献链接(Citations)以及计费统计(Usage)。
- 事件类型分流(
一、SSE 规范定义的 4 种字段行
根据 W3C 规范,一个用 \n\n 隔离的事件块内部,可以包含以下 4 种以特定标识开头的行:
| 行前缀 | 作用 | 生产环境如何处理 |
|---|---|---|
data: | 承载业务数据内容 | 核心数据载荷,根据业务反序列化为 JSON 或字符串。 |
event: | 声明该事件的自定义类型 | 非常关键。如 event: error 或 event: tool_call,用于前端路由分发。 |
id: | 事件的唯一序列 ID | 用于网络中断重连时,浏览器自动在请求头带上 Last-Event-ID 实现断点续传。 |
: (冒号) | 注释 / 心跳行 | 网关发出的保活信号(如 : ping)。前端应主动忽略,防止影响数据解析。 |
二、生产环境下必须额外处理的场景
1. 错误流(event: error)
当大模型生成到一半发生限流、敏感词触发或上游服务崩溃时,规范的服务端通常会发送:
event: error
data: {"code": "rate_limit_exceeded", "message": "账户并发达到上限"}
- 如果前端只看
data::代码会尝试去提取json.choices[0].delta.content,结果为undefined甚至导致页面渲染崩溃,用户看不到明确错误提示。 - 正确的处理:先读取
event,若为error,立刻打断打字机并向用户弹出气泡提示。
2. 多厂商协议差异(以 Claude 为例)
Anthropic 的官方 API 强制要求按 event 类型处理流:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_123", ...}}
event: content_block_delta
data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "你好"}}
event: message_delta
data: {"type": "message_delta", "usage": {"output_tokens": 15}}
前端必须先依据 event 做 switch-case 分发,才能分别拿到“文字正文”和“统计用量”。
3. Agent 与工具调用(Tool Calls)
大模型在决策调用搜索或查库工具时,流式吐出的是工具名称和参数切片,而非普通对话文字:
- 前端需要识别出这是工具事件,在 UI 上展示:“正在检索本地知识库...” 等状态微动画,而不是把参数 JSON 逐字打印在聊天气泡里。
4. 深度思考过程(Reasoning)
对于 DeepSeek-R1 或类似模型,前端需要将流式内容分流:
- 思考链文字(
reasoning_content)打入顶部的折叠面板中; - 正式回答(
content)打入主气泡中。
拓展2
在 scripts/04-function-calling.mjs 中有这段代码,这段代码中只处理了第一个工具调用:
if (msg?.tool_calls?.length) {
const call = msg.tool_calls[0].function;
console.log('模型想调用:', call.name, JSON.parse(call.arguments));
// ← 这里才是我们真正执行工具的地方(查数据库 / 调 API / 算数)
console.log('(示例)执行 get_weather(' + JSON.parse(call.arguments).city + ') → 晴 12℃');
}
那么在模型返回多个工具调用时该如何处理?假设向模型提问:"北京和上海今天天气怎么样?",模型在单次响应中返回的 tool_calls 数组示例如下:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_01_bj",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"北京\"}"
}
},
{
"id": "call_02_sh",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"上海\"}"
}
}
]
}
在编写 Agent 或工具执行循环时,需要严格遵守以下工程规范:
- 客户端并发执行使用
Promise.all替代循环等待,显著提升执行效率:
js
const results = await Promise.all(
msg.tool_calls.map(async (call) => {
const args = JSON.parse(call.function.arguments);
const result = await executeTool(call.function.name, args); // 真实调 API 或查库
return {
role: 'tool',
tool_call_id: call.id, // 核心:必须一一对应匹配
content: JSON.stringify(result),
};
})
);
- 必须把整个调用与结果全部追加进上下文,向大模型再次发送请求以获取最终自然语言总结时,上下文必须严格对齐:
- 第一步:先追加 Assistant 带有
tool_calls的那条消息; - 第二步:紧随其后追加所有对应
tool_call_id的 Tool 结果消息。
写在最后
这个章节主要是学习流式解析的原理,引出了多个疑问和拓展,多实践、理解记忆。排版比较混乱,大家凑活看吧~