# 第401题 前端如何实现大模型的流式输出?
30 秒速记
- 流式输出不让总时间变短,解决的是「首字等太久」,让用户一直看到进展
fetch+response.body.getReader()读流,同一个TextDecoder带{ stream: true }解码- 一次
read()不等于一条消息:要自己留buffer,按\n或SSE的空行拆帧,处理半包和粘包 - 协议里要能区分增量文本、错误、结束;用户停止用
AbortController,后端也要跟着停 - 渲染别一个
token刷一次,合并到每帧一次,Markdown解析和代码高亮要节流
前端做流式输出,就是边读响应流边拼字,但要自己处理「一块数据不等于一条消息」这件事。 打个比方,后端是一条一条发快递,网络中途可能把两个包捆一起送,也可能把一个包拆成两半送。所以我会用 getReader() 一直读,同一个 TextDecoder 解码,防止中文字被切成两半变乱码,再在 buffer 里按换行拆出完整的 JSON。页面上把增量攒一下,每帧刷一次,不然回答一长就卡。另外取消、断网、服务端报错要分开提示,别只留半截文字。
const reader = res.body.getReader()
const decoder = new TextDecoder() // 只建一个,跨块保留半个字符
let buffer = ''
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split('\n')
buffer = lines.pop() // 最后一段可能不完整,留到下一轮
for (const line of lines) if (line.trim()) onEvent(JSON.parse(line))
if (done) break
}
假设服务端返回按行分隔的 NDJSON,每行都是一个独立的 JSON 事件。一次网络读取可能只拿到半行,也可能一次拿到三行,所以必须保留未完整的尾部:
async function* readNdjson(response, signal) {
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) throw signal.reason
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.trim()) yield JSON.parse(line)
}
if (done) break
}
if (buffer.trim()) yield JSON.parse(buffer)
} finally {
reader.releaseLock()
}
}
const controller = new AbortController()
const response = await fetch('/api/chat', {
method: 'POST',
signal: controller.signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: '解释事件循环' })
})
let answer = ''
for await (const event of readNdjson(response, controller.signal)) {
if (event.type === 'delta') answer += event.text
if (event.type === 'error') throw new Error(event.message)
if (event.type === 'done') break
}
TextDecoder.decode(value, { stream: true }) 会保留未组成完整字符的字节,避免中文在数据块边界处变成乱码。buffer 解决的则是应用层半包问题。生产中还应给每次对话分配请求标识,只允许当前请求更新页面;用户重新提问时,先调用 AbortController.abort() 终止旧流,防止旧回答串到新消息里。
渲染层的常见瓶颈是更新太细。模型一秒可能送出几十个增量,如果每次都重新解析全部 Markdown,回答越长开销越大。可以先把文本收进变量,通过 requestAnimationFrame 或 30–50ms 的刷新窗口更新视图。流结束后再做一次完整校验和最终渲染,并保留错误态、重试入口与已接收的部分内容。
面试官追问
追问 1线上偶尔有中文显示成 �,后端日志里是正常的,先查哪?
查前端是不是每块都 new TextDecoder(),或者没传 { stream: true }。一个中文在 UTF-8 里占 3 个字节,刚好被切在两块之间就会乱码,同一个解码器才能把半个字符留到下一块。
追问 2后端说每次 write 都是完整 JSON,前端直接对每次 read() 的结果 JSON.parse 行不行?
不行。中间有代理、压缩、TCP 分段,一次 write 可能被拆开或和下一次合并。必须按协议分隔符拆帧,buffer 里不完整的尾巴留到下一轮。
追问 3用户点了停止,页面不动了,但服务端还在调模型计费,算取消成功吗?
不算。前端 controller.abort() 只是断开连接,服务端要监听连接关闭,再把取消传给下游模型请求。不然只是界面不收了,钱照样在花。
追问 4回答越长页面越卡,Performance 里全是 Markdown 解析,怎么改?
先把增量攒起来,用 requestAnimationFrame 每帧最多渲染一次。再看能不能只重新解析最后一个没闭合的段落,已经完成的部分缓存起来,代码高亮放到流结束后再做。
追问 5用户连着发了两个问题,第一个回答串到了第二条消息里,为什么?
旧流没停,还在往同一个状态里写。发新问题前先 abort 旧请求,每条消息带个请求 id,只有当前 id 的数据才允许更新页面。
# 第400题 RAG 是什么,一次检索增强生成经历了哪些步骤?
30 秒速记
RAG= 先检索、再生成:从知识库找证据塞进提示词,模型参数一点没变- 离线:清洗 → 切块 → 算
embedding→ 建索引;在线:改写问题 → 召回 → 权限过滤 → 重排 → 拼上下文 → 生成 - 只靠向量会丢精确词(型号、错误码),实际多用关键词 + 向量混合召回,再用重排模型精排
- 切块太小丢上下文,太大噪声多还费
token;块里带上标题、版本、来源方便引用 - 权限过滤必须在内容进模型之前做;效果评估要把「召回对不对」和「答得好不好」分开测
RAG 就是让模型开卷考试:先去知识库翻出相关的几段,再让它照着这几段回答。 它适合公司文档、产品手册这种经常变的知识,改文档只要重建索引,比重新微调模型快得多也便宜得多。但链路远不止「向量搜一下」:切块切得好不好、关键词和向量要不要混合、谁有权看哪篇、召回的几十条怎么重排挑前几条,每一环都会影响结果。召回错了,模型说得再顺也是在胡说。我排查答错的问题,第一步永远是看正确的那段有没有进候选。
const candidates = await hybridSearch(question, { limit: 20 }) // 关键词 + 向量
const visible = candidates.filter(c => c.allowedUserIds.includes(userId)) // 先过滤权限
const top = (await rerank(question, visible)).slice(0, 5) // 再精排取前 5
const answer = await generate({ question, evidence: top }) // 最后带证据生成
下面的示例用一个可替换的检索器表达在线链路。真实项目可以把 search 换成向量数据库与全文搜索的混合召回,但权限过滤必须发生在内容进入模型之前:
type Chunk = {
id: string
text: string
score: number
allowedUserIds: string[]
}
async function answerWithRag(question: string, userId: string) {
