跳到主要内容

大模型流式交互与生成式 UI (Streaming Interaction & Generative UI)✅

本模块聚焦前端直面用户的核心交互体验层:端到端流式通信协议与打字机缓冲队列、Markdown AST 容错增量排版防抖、生成式 UI(Generative UI)动态组件沙箱与 XSS 防御,以及多轮会话分支树状态机与流式语音转写(ASR)的临时结果 / 最终结果两段式渲染。


前端如何实现类似 ChatGPT 的打字机流式响应?SSE 与 Fetch ReadableStream 该如何选择?​

答案

核心结论:

在现代大模型前端交互中,基于 Fetch API 与 ReadableStream 的自定义流式协议是当今工业界(如 ChatGPT、Claude、Vercel AI SDK)的绝对首选,而传统原生 EventSource(SSE)由于底层功能局限正逐渐退居次要地位:

  • 请求灵活性:大模型对话通常需要提交包含数万 Token 的历史上下文(System Prompt + Messages 数组),原生 EventSource 仅支持 HTTP GET 请求且无法自定义 Request Body 与 Authorization Header,难以承载大型 Payload;Fetch API 则原生支持 POST、自定义头以及 ReadableStream 响应体读取;
  • 传输控制与性能:通过 response.body.getReader() 配合 TextDecoder({ stream: true }) 进行增量分块解码,结合前端“平滑打字机缓冲队列(Smooth Typing Buffer Queue)”算法,既能避免微任务高频触发 React 重新渲染(Re-render 抖动),又能保证视觉上的连贯吐字动效。

原理解析:

1. 传输协议全方位架构选型矩阵

选型维度原生 SSE (EventSource)Fetch API (ReadableStream)WebSocket
HTTP 动词支持仅限 GET(需依赖 URL Query 传参)支持 POST(完整承载大体积 Payload)握手为 GET,后续双向全双工帧传输
自定义 Header❌ 规范不支持(无法灵活传鉴权 Token)✅ 完全支持自定义 Header 与 Cookie仅握手阶段支持部分 Header
取消与中止eventSource.close()原生 AbortController.abort()socket.close()
网络协议兼容性基于标准 HTTP/1.1 或 HTTP/2 单向事件流完美适配 HTTP/2 多路复用与边缘计算需代理网关特殊支持长连接保持与升级
断线重连机制引擎内置原生重连机制需前端应用层手写指数退避重试需前端心跳保活与手动重连
工业级定位传统服务端单向广播通知大模型流式推理对话的绝对标准范式强实时双向协同(如在线多人白板、即时通讯)

2. 端到端流式读取与 TextDecoder 边界机制

网络传输中的 TCP 分包或 HTTP/2 Data Frame 是按任意字节分片传输的。中文字符在 UTF-8 编码中占用 3 个字节: 若网络切片恰好将一个 3 字节的中文字符截断在第 1 或第 2 个字节处,如果直接调用 decoder.decode(chunk),引擎会因遇到非法字节序列而输出替换字符乱码。 因此必须开启 new TextDecoder('utf-8', { fatal: false, stream: true }): 当 stream: true 时,解码器会在内部保留未凑齐完整 Unicode 码点的悬挂字节,并在下一个 Data Chunk 到达时自动拼接还原,杜绝字符乱码。


规范代码实现:

在 TypeScript 中实现可取消、带 TextDecoder 状态流与 RAF 平滑消费的端到端完整流式客户端:

// services/llm-stream-client.ts
export interface StreamChunkCallback {
onChunk: (text: string) => void;
onError?: (err: Error) => void;
onFinish?: () => void;
}

export class LlmStreamClient {
private abortController: AbortController | null = null;

public async fetchStream(
url: string,
payload: Record<string, unknown>,
callbacks: StreamChunkCallback
): Promise<void> {
this.abortController = new AbortController();

try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
},
body: JSON.stringify(payload),
signal: this.abortController.signal
});

if (!response.ok || !response.body) {
throw new Error(`[StreamError] HTTP 响应异常状态: ${response.status}`);
}

const reader = response.body.getReader();
// 核心要点:开启 stream: true 保证多字节字符切分不产生乱码
const decoder = new TextDecoder('utf-8', { fatal: false, stream: true });

while (true) {
const { done, value } = await reader.read();
if (done) {
// 刷出解码器剩余缓存
const finalChunk = decoder.decode();
if (finalChunk) callbacks.onChunk(finalChunk);
callbacks.onFinish?.();
break;
}

const decodedText = decoder.decode(value, { stream: true });
callbacks.onChunk(decodedText);
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.warn('[StreamClient] 用户主动中断流式生成');
} else {
callbacks.onError?.(error);
}
} finally {
this.abortController = null;
}
}

public cancel(): void {
if (this.abortController) {
this.abortController.abort('Client cancelled');
}
}
}

面试官视角:

考察意图: 评估应试者是否真正亲手做过生产级大模型流式对话。初级前端常把 EventSource 当标准答案,但一旦涉及鉴权头、POST 长 Payload、UTF-8 跨包乱码与 React 频繁 re-render,就会暴露出无实战经验。

深度追问链:

  1. 追问 1: 后端返回的是 data: {"text": "xxx"}\n\n 格式的 SSE 文本流,前端如何从 ReadableStream 读取的数据中精准按行解析,避免由于 TCP 分包把一条 JSON 拦腰截断?
    • 答题切入点: 必须实现行缓冲区(Line Buffer):把收到的 chunk 拼接到 buffer 中,按 \n\n 切割事件,最后未以换行符结尾的不完整行保留在 buffer 中留给下一次 chunk 到达时继续拼接。

延伸阅读:


大模型流式输出 Markdown 时,如何避免代码块、公式与 Mermaid 图表解析闪烁?​

答案

核心结论:

大模型在流式逐字输出 Markdown 时,中间状态必然经历语法不闭合阶段(如刚输出 ````typescript但没有闭合四个反引号、输出了$$但未闭合公式、输出了flowchart TD` 但图表连线尚未结束)。如果直接将不完整文本送入标准的 Markdown AST 解析器(如 Remark/Markdown-it),会导致严重的**“界面排版闪烁与抖动(Layout Thrashing & Flickering)”**。

工业级三大解法:

  1. 未闭合代码块与语法围栏的客户端投机补齐(Speculative Closure):在交给 AST 解析器前,检测到奇数个代码围栏()时,在内存字符串副本末尾预先补齐关闭标记(),使 AST 始终生成稳定的代码块节点;
  2. 重型渲染组件(代码高亮、KaTeX 公式、Mermaid 图表)的流式挂起(Stream Debounce / Hydration Suspension):对于 Mermaid、SVG 或复杂交互公式,在检测到其完全闭合前,渲染轻量纯文本占位骨架,流结束或完全闭合后再进行昂贵的高亮或图表绘制;
  3. 内容锚定与虚拟滚动对齐(Scroll Pinning & ResizeObserver):自回归吐字导致页面高度高频变动,必须通过滚动锁定锚定用户当前阅读位置,避免页面自动跳屏。

原理解析:

1. Markdown 流式 AST 容错修补时序


规范代码实现:

在 TypeScript 中实现流式 Markdown 语法自动修补处理器:

// utils/markdown-stream-patcher.ts
export class MarkdownStreamPatcher {
public static patchUnclosedMarkdown(raw: string): string {
let patched = raw;

// 1. 统计代码围栏 ``` 的数量
const codeBlockMatches = patched.match(/```/g);
const codeBlockCount = codeBlockMatches ? codeBlockMatches.length : 0;

// 如果为奇数,说明代码块正在流式输出中且未闭合
if (codeBlockCount % 2 === 1) {
patched += '\n```';
}

// 2. 统计行内代码 ` 的数量 (排除 ``` 的情况)
const inlineCodeCount = (patched.replace(/```/g, '').match(/`/g) || []).length;
if (inlineCodeCount % 2 === 1) {
patched += '`';
}

// 3. 统计 KaTeX 独立公式块 $$ 的数量
const mathBlockMatches = patched.match(/\$\$/g);
const mathBlockCount = mathBlockMatches ? mathBlockMatches.length : 0;
if (mathBlockCount % 2 === 1) {
patched += '\n$$';
}

return patched;
}
}

面试官视角:

考察意图: 检验候选人对大模型 C 端/B 端前端交互细节的极致打磨能力。普通的“调个第三方库展示 Markdown”在大模型场景下必定出现光标跳动、公式解析报错卡死、Mermaid 重绘 CPU 100% 等严重缺陷,懂得“投机闭合 + 重型图表闭合前占位”体现高级工程师的工艺水准。

深度追问链:

  1. 追问 1: Mermaid 图表在流式输出到一半时,如果直接调用 mermaid.render() 会报 Syntax Error 导致控制台疯狂红字,前端最佳工程实践是什么?
    • 答题切入点: 实现“语法就绪门禁”:流式过程中将 mermaid` 块渲染为优雅的“图表生成中...”带有骨架屏脉动的代码预览框;仅当检测到该 block 拥有配对闭合的 且流结束(或语法通过静态预检)时,才触发真实的 Mermaid SVG 渲染。

延伸阅读:


什么是生成式 UI(Generative UI)?如何安全渲染模型动态生成的 React/Vue 组件并防止 XSS?​

答案

核心结论:

生成式 UI(Generative UI)是大语言模型交互范式的重大跃迁:模型不再仅仅返回单调的自然语言文本,而是能够根据业务上下文和用户意图,在对话流中动态生成、编排并渲染强交互、高可用的前端组件(如订票日历卡片、数据可视化仪表盘、交互式表单)。

在生产环境中实现 Generative UI,前端必须兼顾**“动态表现力”与“安全沙箱防御(XSS Defense)”**:

  1. 严禁直接执行模型生成的原始 HTML / JavaScript(杜绝 eval / dangerouslySetInnerHTML):大模型极易受提示词注入(Prompt Injection)诱导生成包含 <script> 或恶意外部资源加载的脚本;
  2. 基于受控组件白名单(Component Registry Pattern)与 JSON Schema 驱动:模型只负责输出组件名称(如 FlightBookingCard)和严格强类型的属性参数(Props Payload);前端组件工厂(Component Factory)根据白名单映射本地安全组件,实现确定性渲染;
  3. 样式与 DOM 边界隔离(Shadow DOM / Scoped CSS):动态渲染的组件样式必须通过 Shadow DOM 或 CSS Modules 严格隔离,防止模型污染宿主页面全局样式与全局变量。

原理解析:

1. Generative UI 生产级安全渲染管线


规范代码实现:

在 TypeScript/React 中实现白名单驱动的 Generative UI 安全渲染沙箱:

// components/generative-ui-renderer.tsx
import React, { FC, useMemo } from 'react';
import { z } from 'zod';

// 1. 本地定义安全受控组件
const FlightCard: FC<{ flightNo: string; price: number }> = ({ flightNo, price }) => (
<div className="flight-card p-4 border rounded shadow">
<div className="font-bold">航班号: {flightNo}</div>
<div className="text-red-500">票价: ¥{price}</div>
<button onClick={() => alert(`预订: ${flightNo}`)}>立即预订</button>
</div>
);

// 2. 注册表与白名单映射
const COMPONENT_REGISTRY: Record<string, FC<any>> = {
FlightCard
};

// 3. Schema 验证结构
const GenerativeComponentPayloadSchema = z.object({
componentName: z.string(),
props: z.record(z.any())
});

export const GenerativeUISandbox: FC<{ rawPayload: unknown }> = ({ rawPayload }) => {
const parsed = useMemo(() => {
return GenerativeComponentPayloadSchema.safeParse(rawPayload);
}, [rawPayload]);

if (!parsed.success) {
return <div className="text-gray-400">组件参数解析失败,已降级展示</div>;
}

const { componentName, props } = parsed.data;
const TargetComponent = COMPONENT_REGISTRY[componentName];

if (!TargetComponent) {
return (
<div className="text-amber-500 p-2 border border-amber-300 rounded">
未知组件 [{componentName}],安全沙箱已拦截动态加载
</div>
);
}

// 安全渲染:纯属性驱动,绝无代码字符串动态 eval
return (
<div className="generative-sandbox-container">
<TargetComponent {...props} />
</div>
);
};

面试官视角:

考察意图: 评估应聘者是否接触过前沿 AI UI 体系(如 Vercel AI SDK RSC/Generative UI、ChatGPT Canvas)。能否清晰阐述为什么不能直接执行模型吐出的 JSX 代码字符串、如何通过 JSON Schema + Registry 实现安全解耦,是衡量高级前端架构师的分水岭。

深度追问链:

  1. 追问 1: 如果业务确实要求渲染模型动态生成的任意 HTML/CSS(如网页原型预览),前端沙箱应如何做到最高级别防御?
    • 答题切入点: 必须采用独立子域名、跨域隔离的 iframe 沙箱,配置严格的 sandbox="allow-scripts"(禁止 allow-same-origin,彻底阻断 Cookie 与 localStorage 读取),并结合严格的 Content Security Policy (CSP),禁止向非法第三方域发送网络请求。

延伸阅读:


在大模型多轮分支对话交互中,复杂会话树状态机与撤销重试是如何设计的?​

答案

核心结论:

在真实大模型交互中,用户的思维不是单一线性的。用户经常会在第 3 轮回答后点击“重新生成”,或者“编辑第 2 轮的提问”。此时,会话数据模型由传统的一维线性数组(Linear Array)演进为有向树状拓扑(Conversation Branch Tree)。

前端架构设计核心要点:

  1. 数据结构设计(Node-Parent-Children 树拓扑):每个节点具备全局唯一 id、指向父节点的 parentId、子节点数组 childrenIds 以及当前激活的分支索引 activeChildIndex;
  2. 状态快照与时光旅行(Time Travel):切换任一节点的分支版本,前端状态机能够快速回溯计算出从 Root 到当前叶子节点的完整线性对话栈(Linear Projection),将其作为上下文准确发送给大模型;
  3. 并发编辑与乐观撤销隔离:分支树使得撤销、重试与对比不同模型的生成结果(A/B Test 对比卡片)具备天然的数据隔离,互不污染。

原理解析:

1. 对话分支树拓扑结构模型

  • 用户在第 2 轮切换版本(1/2 或 2/2),下级对话树跟随切换,实现上下文精准隔离;
  • 向服务端提交上下文时,只需从当前活动节点沿 parentId 向上递归收集到根节点,再将数组 reverse() 即可得到当前分支的完整输入链。

规范代码实现:

在 TypeScript 中实现工业级会话分支树状态机与线性回溯投影算法:

// store/conversation-tree.ts
export interface MessageNode {
id: string;
parentId: string | null;
childrenIds: string[];
role: 'system' | 'user' | 'assistant';
content: string;
activeChildIndex: number;
}

export class ConversationTreeManager {
private nodes: Map<string, MessageNode> = new Map();
private rootId: string | null = null;
private currentActiveLeafId: string | null = null;

// 1. 追加新消息节点
public appendMessage(role: 'user' | 'assistant', content: string): string {
const newId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const parentId = this.currentActiveLeafId;

const newNode: MessageNode = {
id: newId,
parentId,
childrenIds: [],
role,
content,
activeChildIndex: 0
};

this.nodes.set(newId, newNode);

if (parentId && this.nodes.has(parentId)) {
const parent = this.nodes.get(parentId)!;
parent.childrenIds.push(newId);
parent.activeChildIndex = parent.childrenIds.length - 1;
} else {
this.rootId = newId;
}

this.currentActiveLeafId = newId;
return newId;
}

// 2. 重新生成:在指定父节点下派生新分支
public forkBranchAt(parentId: string, content: string): string {
this.currentActiveLeafId = parentId;
return this.appendMessage('assistant', content);
}

// 3. 线性投影:自底向上追溯获取当前激活的完整上下文链路
public getLinearProjection(): MessageNode[] {
if (!this.currentActiveLeafId) return [];

const path: MessageNode[] = [];
let currId: string | null = this.currentActiveLeafId;

while (currId) {
const node = this.nodes.get(currId);
if (!node) break;
path.push(node);
currId = node.parentId;
}

return path.reverse();
}
}

面试官视角:

考察意图: 评估应试者在处理复杂大前端状态机与高阶数据结构时的设计功底。初学者习惯一维数组 messages.map(),一旦产品经理提出“支持编辑提问并切换历史版本”或“多模型横向对比”,一维数组模型将彻底重构报废;具备树形状态机设计经验能够展现优异的架构前瞻性。

深度追问链:

  1. 追问 1: 会话分支树持久化到本地 IndexedDB 或后端时,如何设计数据库表结构,避免深层递归查询?
    • 答题切入点: 采用邻接表模型(Adjacency List)配合路径物化路径(Materialized Path,如 /root/node1/node2),一次索引查询即可取出整棵树,避免递归全表扫描。

延伸阅读:


流式回复还没输出完,用户又发来一条新消息,前端怎么打断、怎么排队、怎么保证最终渲染顺序?​

答案

核心结论:

这是流式交互里最容易被忽略、却最容易出线上问题的一类并发。三件事必须分开做:

  1. 打断的载体是 AbortController,不是 UI 层的 ignore 标志。请求管理器持有「当前活动请求的 controller」,新消息发出前先 abort() 掉上一条,读流循环随之以 AbortError 结束。只用一个 isStreaming 布尔标志挡不住已经在途的分片——两个流会同时往同一个缓冲区写。
  2. 防乱序要靠请求序号,不能只靠 abort。abort 是「尽力而为」:服务端可能已经把整段结果算好并缓冲在网络层,客户端 abort 后仍可能收到尾部数据。所以每次请求带一个单调递增的 token,落状态前先比对「是否仍是最新请求」,过期响应整包丢弃——渲染顺序由本地序号裁决,不由网络到达顺序裁决。
  3. 流式分片与最终消息必须分两层存。收到的分片先写进当前轮的 streaming buffer,UI 只读 buffer 做打字机渲染;流正常结束后才把 buffer 提交为一条正式消息进列表。分开的收益是:打字机节流、Markdown 增量解析、最终落库各管各的,互不污染。

中断的半截内容要有明确归宿:产品上要么丢弃,要么标记为「已中断」保留为可重试草稿。这一层选择直接对应 Agent Harness 的会话树——被中断的那一轮应是树上的一个可回溯节点,而不是从历史里凭空消失。


原理解析:

1. 时序与三种竞态

三种必须分别处理的竞态:

  • 双写:上一条流没停,两条流同时写缓冲区 → 用 abort 解决;
  • 乱序:上一条流的尾部分片迟到并覆盖新内容 → 用 token 序号解决;
  • 半截污染:中断的半截内容被当成正式回答写进历史 → 用「分片缓冲 / 正式消息」两层状态解决。

2. 排队策略是产品语义,不是实现细节

  • 打断式(interrupt-and-replace):新消息直接中断当前流。对话类产品的主流做法,因为用户发出新消息的语义通常就是「我改主意了」。
  • 串行排队式(queue):新消息入队,等当前流自然结束再发。适合「上一条还在生成、但用户想补充一句」的场景。

前端必须把策略显式建模,不能隐式——两种策略下用户看到的差别极大(打断式会丢掉上一条的半截内容)。

3. 与 Agent Harness 会话树的关系

「撤销重试 / 重新生成」本质上是会话树上的分叉:被中断的那一轮应作为树上的一个带 interrupted 状态节点保留,用户点「重新生成」是在该节点下 fork 出新分支,而不是把半截文本当作一条正式回答写进线性历史。这一层如果一开始就没建模,后面做「重试」「回退」「多版本对比」时会被迫大改。


规范实现:

// services/streaming-session.ts
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
status: 'done' | 'interrupted';
}

export interface StreamingView {
committed: ChatMessage[]; // 已完成 / 已中断的历史消息
streaming: { token: number; content: string } | null; // 当前轮分片缓冲
}

export class StreamingSession {
private committed: ChatMessage[] = [];
private buffer = '';
private token = 0; // 每次开始新请求或中断旧请求都自增,旧请求的 token 立即过期
private active: { token: number; controller: AbortController } | null = null;

constructor(private keepInterruptedDraft = true) {}

public get view(): StreamingView {
return {
committed: this.committed,
streaming: this.active ? { token: this.active.token, content: this.buffer } : null
};
}

public async send(
userText: string,
request: (signal: AbortSignal) => AsyncIterable<string>
): Promise<void> {
this.interrupt(); // 1) 先打断上一条流
const token = ++this.token; // 2) 单调序号:过期响应据此丢弃
const controller = new AbortController();
this.active = { token, controller };
this.buffer = '';
this.committed.push({ id: `u_${token}`, role: 'user', content: userText, status: 'done' });

try {
for await (const chunk of request(controller.signal)) {
if (token !== this.token) return; // 过期响应:整包丢弃,不写入任何状态
this.buffer += chunk; // 分片只进 buffer,不进消息列表
}
if (token !== this.token) return;
this.commit(token, 'done'); // 3) 正常结束:buffer 提交为正式消息
} catch (err) {
if (token !== this.token) return; // 已被新一轮取代,无需处理
this.commit(token, 'interrupted'); // 中断:按产品语义保留草稿或丢弃
if (!isAbortError(err)) throw err;
} finally {
if (this.active?.token === token) this.active = null;
}
}

public interrupt(): void {
this.token += 1; // 立即让在途请求的 token 过期
this.active?.controller.abort();
this.active = null;
}

private commit(token: number, status: ChatMessage['status']): void {
if (status === 'interrupted' && !this.keepInterruptedDraft) {
this.buffer = '';
return;
}
this.committed.push({
id: `a_${token}`,
role: 'assistant',
content: this.buffer,
status
});
this.buffer = '';
}
}

function isAbortError(err: unknown): boolean {
return (err as { name?: string })?.name === 'AbortError';
}

三个关键点:token 在 interrupt() 里也要自增(否则被 abort 的请求仍认为自己是最新,会继续写 buffer);分片只进 buffer 不进消息列表(否则中断会留下半截的「正式消息」);过期响应直接 return(既不写 buffer 也不提交消息)。


面试官视角:

考察意图: 考察候选人是否真正处理过「流式 + 并发输入」这类边界,还是只实现了「一次一问一答」的流式渲染。高分回答必须能区分 abort、序号防乱序、两层状态这三件不同的事,并说清半截内容的归宿。

深度追问链:

  1. 追问 1: 用一个 isStreaming 标志拦住新请求不行吗?
    • 答题切入点: 不行。标志只能拦住「用户再点一次」,拦不住已经在途的分片;两个流仍会同时写缓冲区。必须用 AbortController 真正终止上一条读流,再用序号丢弃迟到的过期分片。
  2. 追问 2: 上一条流被 abort 了,它已经产生的半截内容怎么办?
    • 答题切入点: 产品语义二选一——丢弃,或标记为「已中断」保留为可重试草稿。关键是先落到独立的流式缓冲,流正常结束才提交为正式消息;否则半截内容会混进历史,并被下一次请求的上下文拼装带进去。
  3. 追问 3: 加上「重新生成」按钮后,状态模型要怎么改?
    • 答题切入点: 从线性历史升级为会话树:每一轮是一个节点,带 done / interrupted 状态与父指针;「重新生成」是在该节点下 fork 新分支而不是覆盖原节点。渲染顺序由「当前激活分支路径」决定,而不是数组下标。

延伸阅读:


流式语音转写里,临时结果和最终结果有什么区别?前端怎么渲染才不会一直抖?​

答案

核心结论:

  1. 流式 ASR 天生是「先给假设、再给确认」。服务端会持续吐出临时结果(partial / hypothesis),随着新音频到达,前面的词可能被推翻改写;只有被**确认(committed)**的前缀才不会变,后续内容只在它后面追加。这套语义不是各家随意定的——Whisper-Streaming 论文提出的 LocalAgreement-n 策略就是它的来源:当 n 次连续更新(每次都带来新的音频块)对同一段前缀达成一致时,该前缀被确认;实践中通常取 n 等于 2,即连续两轮一致即确认。

  2. 前端最大的坑是把临时结果当最终结果渲染。临时结果会变,一旦每次收到就整段重排,用户看到的就是文字反复跳动、标点乱跳、词被换掉。这与本库「大模型流式输出 Markdown 时的闪烁」是同一类问题在语音链路的翻版。

  3. 正确姿势是「稳定前缀 + 可变尾部」两段式渲染:

    • 已确认段冻结:绝不因为新结果而重排或改写,只在尾部追加;
    • 未确认段独立成块:视觉上区别对待(灰色 / 半透明 / 弱化),让用户知道「这部分还没定」;
    • 整体不做全文 diff 重排:用「已确认前缀 + 未确认尾部」的结构增量更新,而不是每帧把整段文本重新排版。
  4. 前端还要防两类协议层面的坑:

    • final 不是「整段结束」:很多实现会分段发 final(一句一个),前端必须追加而不是覆盖,也不能假设收到 final 就结束;
    • 真正的结束信号通常是独立事件(如 speech stopped / 结束事件),前端要按事件类型驱动状态机,而不是按「有没有 final」判断。
  5. 服务端策略决定体验上限,前端只能忠实渲染:同样的前端,LocalAgreement 的 n 取值不同,延迟与准确率的取舍完全不同(n 越大越准越慢,越小越快越容易错)。前端不能自己决定哪个结果可信——只能按服务端给的语义做稳定/不稳定的区分。

  6. 一个必须知道的现状:Whisper-Streaming 的官方仓库已注明,它在 2025 年被 SimulStreaming 取代(后者官方称在效率上约快 5 倍,并在 IWSLT 2025 同时语音翻译共享任务上取得最好成绩)。所以不要把 Whisper-Streaming 当成当前唯一方案,但它的局部一致策略至今仍是理解流式 ASR 语义的标准心智模型。

  7. 中文场景的额外一层:partial 阶段的断句与标点经常是错的(模型还没听到后半句)。前端要预期「标点会变」,因此不要在未确认段落上做依赖标点的富文本解析(比如按句号切动画、按问号触发 TTS)。

原理解析:

1. 三类文本在一条流里的生命周期

关键:被推翻的是「还没被确认的部分」。一旦某段前缀被确认,它就是只追加、不改写的稳定区。

2. 前端渲染:为什么要分段而不是整段重排

渲染策略用户看到什么结论
每次收到 partial 整段重排文字跳动、词被替换、标点乱跳✗ 最常见的错误实现
整段文本做 diff 高亮视觉上仍有跳动,且 diff 成本高✗ 治标不治本
已确认段冻结 + 未确认段弱化独立块稳定区不动,只有尾部在补全✓ 推荐

3. 协议层状态机(前端该有的样子)

规范实现:

下面是最小可用的流式转写渲染器:已确认前缀冻结,未确认尾部替换,并按事件类型而非「有无 final」推进状态机。

type AsrEvent =
| { type: 'partial'; text: string }
| { type: 'final'; text: string }
| { type: 'speech_started' }
| { type: 'speech_stopped' }

interface TranscriptState {
/** 已确认前缀:只追加,永不改写 */
committed: string
/** 未确认尾部:可被新的 partial 整体替换 */
pending: string
status: 'idle' | 'listening' | 'finalizing'
}

export function initialState (): TranscriptState {
return { committed: '', pending: '', status: 'idle' }
}

export function reduce (s: TranscriptState, e: AsrEvent): TranscriptState {
switch (e.type) {
case 'speech_started':
return { ...s, status: 'listening' }

case 'partial':
// 整体替换未确认尾部;已确认前缀保持不动
return { ...s, status: 'listening', pending: e.text }

case 'final':
// final 可能是分段到达的:追加到已确认区,而不是覆盖
return {
committed: joinText(s.committed, e.text),
pending: '',
status: 'finalizing'
}

case 'speech_stopped':
// 真正的结束信号:收尾并清空未确认尾部
return { committed: joinText(s.committed, s.pending), pending: '', status: 'idle' }
}
}

function joinText (a: string, b: string): string {
if (!a) return b
if (!b) return a
return a + (/[\\s\\u3000]$/.test(a) ? '' : ' ') + b
}

/** 渲染:稳定区常规样式,未确认区弱化;不要把两者拼成一段再整体重排 */
export function toView (s: TranscriptState): Array<{ text: string; stable: boolean }> {
const out: Array<{ text: string; stable: boolean }> = []
if (s.committed) out.push({ text: s.committed, stable: true })
if (s.pending) out.push({ text: s.pending, stable: false })
return out
}

注意 reduce 里 partial 分支只改 pending、final 分支只追加到 committed——这两个字段的分离就是「不抖动」的根本保证。

面试官视角:

考察意图: 考察候选人是否理解流式 ASR 的「假设 / 确认」两段语义,以及能否把「不抖动」翻译成具体的渲染与状态机设计。这题在语音交互与多模态前端岗常见,高分回答必须能说出局部一致(local agreement)策略的机制,并指出**「final 不等于结束」**这个协议陷阱。

评分 Rubric:

  • 必答:说清 partial 可被推翻、final 前缀被冻结;解释 LocalAgreement-n「连续 n 轮一致才确认」的机制;给出「稳定前缀 + 可变尾部」的两段式渲染;知道按事件类型推进状态机而不是靠 final 判断结束。
  • 加分项:知道 LocalAgreement 的 n 取值在延迟与准确率间做取舍;知道 2025 年 SimulStreaming 取代了 Whisper-Streaming;能指出中文 partial 阶段标点不可靠、不能据此做富文本切分。
  • 常见误区:把 partial 当最终结果整段重排;以为收到 final 就代表整段结束;让前端自己「猜」哪个结果可信;认为前端可以优化服务端策略。

深度追问链:

  1. 追问 1:服务端把 LocalAgreement 的 n 从 2 调到 3,体感会怎么变?
    • 答题切入点:更准但更慢——要多等一轮一致才敢确认,延迟上升、尾字出现得更晚;反之 n 减小则更快但更容易把错误内容固化进已确认区,而固化后的错误不会被后续修正。
  2. 追问 2:已确认区里其实有一处识别错了,后面也不会再改,用户会不会被误导?
    • 答题切入点:会——这正是「确认即冻结」的代价:已确认区错了只能靠用户修改或整段重转修正。所以服务端要保留原始音频与对齐结果以便事后校正,前端要提供「点选某段重转」的入口。
  3. 追问 3:如果你的产品要求「用户一停顿就立刻出最终稿」,这套机制还适用吗?
    • 答题切入点:需要更激进的端点检测(VAD + 静音时长阈值),但这会把「停顿」误判为「说完了」而截断内容;必须结合语义完整度判断(模型是否认为句子结束),并接受一定的延迟与截断率的权衡。
  4. 追问 4:要把这套能力接到 Agent 的工具调用链上,注意什么?
    • 答题切入点:未确认文本不能进入工具调用——只有 committed 段才可作为指令依据;否则模型会基于一段随时可能被改写的假设去发邮件、改配置。这也是 HITL 审批绑定问题的同源问题。

延伸阅读: