主题
前端 AI 交互与工程实践
说明
共 25 题,难度 ⭐ ~ ⭐⭐⭐,覆盖流式渲染、Chat UI、状态管理、Vercel AI SDK、Generative UI、性能优化等前端工程师 AI 核心战场。
1. SSE(Server-Sent Events)和 WebSocket 的区别?AI 流式输出场景如何选择? ⭐⭐
理解两种实时通信方案的本质差异和 AI 场景适配。
考察点:流式通信
核心对比
┌───────────────────────────────────────────────────────────┐
│ 特性 │ SSE │ WebSocket │
├───────────────────────────────────────────────────────────┤
│ 协议 │ HTTP/1.1 或 HTTP/2 │ ws:// 独立协议 │
│ 方向 │ 单向(服务端→客户端)│ 双向 │
│ 数据格式 │ 纯文本(UTF-8) │ 文本 + 二进制 │
│ 自动重连 │ ✅ 浏览器内置 │ ❌ 需手动实现 │
│ 浏览器兼容 │ 除 IE 外全支持 │ 全支持 │
│ HTTP/2 多路复用 │ ✅ 天然支持 │ ❌ 独立连接 │
│ 代理/CDN 友好 │ ✅ 标准 HTTP │ ❌ 需特殊配置 │
│ 连接数限制 │ HTTP/1.1 下 6 个 │ 无(独立协议) │
│ 适用场景 │ 服务端推送 │ 实时双向通信 │
└───────────────────────────────────────────────────────────┘AI 流式输出 → SSE 是主流选择
为什么 AI 场景首选 SSE:
AI 对话 = 用户发一条消息 → 等 AI 逐 Token 回复
→ 本质是单向推送,不需要双向通信
SSE 优势:
✅ 基于标准 HTTP,CDN/反向代理零配置
✅ 浏览器原生 EventSource API,自动重连
✅ 与 REST API 一致的认证方式(Cookie/Header)
✅ HTTP/2 下无连接数限制
什么时候用 WebSocket:
→ 实时协作编辑(多人同时编辑 Prompt)
→ 语音对话(双向音频流)
→ 多 Agent 实时通信(Agent 之间互相推送)SSE 协议格式
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
规则:
每个事件以 "data: " 开头
事件之间用空行分隔
特殊标记: id: / event: / retry: / data:
结束标记: data: [DONE](OpenAI 约定)两种前端接入方式
typescript
// 方式 1: EventSource API(简单,但不支持 POST / 自定义 Header)
const es = new EventSource('/api/chat?message=hello')
es.onmessage = (event) => {
if (event.data === '[DONE]') {
es.close()
return
}
const data = JSON.parse(event.data)
appendToken(data.choices[0].delta.content)
}
// 方式 2: Fetch + ReadableStream(推荐,支持 POST + Header)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
processSSEChunk(chunk)
}追问延伸
- HTTP/2 SSE 和 HTTP/1.1 SSE 有什么区别?连接数限制怎么处理?
- Fetch Stream 在 Safari 旧版不支持怎么兼容?
- OpenAI 的 SSE 格式和标准 SSE 有什么差异?
2. 如何用 Fetch API + ReadableStream 实现流式数据接收?手写一个流式解析器 ⭐⭐⭐
手写完整的 SSE 流式解析,这是 AI 前端最核心的底层能力。
考察点:流式实现
SSE 解析的难点
难点: SSE 数据可能被 TCP 分片
理想情况(每次 read 得到完整事件):
chunk1: "data: {\"content\":\"你\"}\n\n"
chunk2: "data: {\"content\":\"好\"}\n\n"
实际情况(数据被切割):
chunk1: "data: {\"content\":\""
chunk2: "你\"}\n\ndata: {\"conte"
chunk3: "nt\":\"好\"}\n\n"
→ 必须有 buffer 机制拼接不完整的行完整实现
typescript
interface StreamCallbacks {
onToken: (token: string) => void
onComplete: (fullText: string) => void
onError: (error: Error) => void
}
async function fetchSSE(
url: string,
body: Record<string, unknown>,
callbacks: StreamCallbacks,
signal?: AbortSignal
) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
try {
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) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
callbacks.onComplete(fullText)
return
}
try {
const parsed = JSON.parse(data)
const token = parsed.choices?.[0]?.delta?.content ?? ''
if (token) {
fullText += token
callbacks.onToken(token)
}
} catch {
// 非 JSON 数据,跳过
}
}
}
}
callbacks.onComplete(fullText)
} catch (err) {
if ((err as Error).name === 'AbortError') {
callbacks.onComplete(fullText)
} else {
callbacks.onError(err as Error)
}
}
}使用示例
typescript
const controller = new AbortController()
fetchSSE(
'/api/chat',
{ messages: [{ role: 'user', content: '什么是闭包?' }] },
{
onToken: (token) => {
document.getElementById('output')!.textContent += token
},
onComplete: (text) => {
console.log('完成:', text)
},
onError: (err) => {
console.error('错误:', err)
},
},
controller.signal
)
// 用户点击"停止生成"
stopButton.onclick = () => controller.abort()进阶:支持多种 SSE 格式
typescript
type SSEParser = (data: string) => string | null
const parsers: Record<string, SSEParser> = {
openai: (data) => {
const parsed = JSON.parse(data)
return parsed.choices?.[0]?.delta?.content ?? null
},
anthropic: (data) => {
const parsed = JSON.parse(data)
if (parsed.type === 'content_block_delta') {
return parsed.delta?.text ?? null
}
return null
},
custom: (data) => {
const parsed = JSON.parse(data)
return parsed.token ?? parsed.text ?? null
},
}
function createStreamReader(provider: keyof typeof parsers) {
const parse = parsers[provider]
return async function* (response: Response): AsyncGenerator<string> {
const reader = response.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 })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') return
try {
const token = parse(data)
if (token) yield token
} catch {}
}
}
}
}
// 使用: async generator 消费
const readStream = createStreamReader('openai')
for await (const token of readStream(response)) {
appendToken(token)
}追问延伸
TextDecoder的stream: true参数的作用?多字节字符截断怎么处理?- 如何实现 SSE 的指数退避重连?
- async generator 和 callback 两种消费方式的优劣?
3. AI 流式输出的"打字机"效果如何实现?性能优化方案? ⭐⭐
让 AI 输出看起来像人在打字,而不是一次性刷出来。
考察点:打字机效果
基础实现
typescript
// 最简单的实现: 直接追加 DOM
function appendToken(token: string) {
outputEl.textContent += token
}
// 问题: 每个 Token 触发一次 DOM 更新 → 频率太高(可达 100+ 次/秒)性能问题分析
问题: LLM 生成速度快时(如 GPT-4o 100 tokens/s)
每个 Token 触发:
1. React setState → 重新渲染
2. DOM 更新 → 重排/重绘
3. 可能触发 Markdown 重新解析
100 tokens/s = 100 次渲染/秒 → 掉帧、卡顿
解决思路:
→ 批量更新(攒一批再渲染)
→ requestAnimationFrame 对齐帧率
→ 字符缓冲队列 + 逐字释放动画方案 1:requestAnimationFrame 批量更新
typescript
class TokenBuffer {
private buffer: string[] = []
private rafId: number | null = null
private onFlush: (tokens: string) => void
constructor(onFlush: (tokens: string) => void) {
this.onFlush = onFlush
}
push(token: string) {
this.buffer.push(token)
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => this.flush())
}
}
private flush() {
const batch = this.buffer.join('')
this.buffer = []
this.rafId = null
this.onFlush(batch)
}
destroy() {
if (this.rafId) cancelAnimationFrame(this.rafId)
}
}
// 使用
const buffer = new TokenBuffer((batch) => {
setContent(prev => prev + batch)
})
// SSE 回调中
onToken: (token) => buffer.push(token)方案 2:逐字释放动画(更平滑)
typescript
class TypewriterQueue {
private queue: string[] = []
private isAnimating = false
private charDelay: number
constructor(
private onChar: (char: string) => void,
charDelay = 20
) {
this.charDelay = charDelay
}
enqueue(text: string) {
this.queue.push(...text.split(''))
if (!this.isAnimating) this.animate()
}
private animate() {
this.isAnimating = true
const step = () => {
if (this.queue.length === 0) {
this.isAnimating = false
return
}
const batchSize = Math.min(
Math.ceil(this.queue.length / 10),
5
)
const chars = this.queue.splice(0, batchSize)
this.onChar(chars.join(''))
setTimeout(step, this.charDelay)
}
step()
}
}方案 3:React Hook 封装
tsx
function useStreamText() {
const [displayText, setDisplayText] = useState('')
const bufferRef = useRef('')
const rafRef = useRef<number>()
const appendToken = useCallback((token: string) => {
bufferRef.current += token
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setDisplayText(prev => prev + bufferRef.current)
bufferRef.current = ''
rafRef.current = undefined
})
}
}, [])
const reset = useCallback(() => {
setDisplayText('')
bufferRef.current = ''
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = undefined
}
}, [])
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [])
return { displayText, appendToken, reset }
}光标闪烁效果
css
.ai-cursor::after {
content: '▍';
animation: blink 1s steps(2) infinite;
color: var(--vp-c-brand);
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0; }
}
/* 生成完成后移除光标 */
.ai-cursor.done::after {
display: none;
}追问延伸
requestAnimationFramevssetTimeout(fn, 16)的区别?- React 18 的
startTransition能用来优化打字机效果吗? - 如何做到"慢速模型 → 逐字显示,快速模型 → 流畅连续"自适应?
4. Markdown 流式渲染的截断问题:代码块 / 公式被截断导致闪烁怎么解决? ⭐⭐⭐
这是 AI Chat UI 最头疼的问题之一:流式 Markdown 渲染的不完整态。
考察点:Markdown 渲染
问题描述
LLM 输出:
"下面是示例代码:\n```javascript\nfunction hello() {\n console.log('hi')\n}\n```"
流式接收过程:
第 1 帧: "下面是示例代码:\n```ja"
→ 渲染: 把 "```ja" 当普通文本显示 ❌
第 2 帧: "下面是示例代码:\n```javascript\nfunction"
→ 渲染: 识别到代码块开始,切换为 <code> ← 页面闪烁!
第 3 帧: "...}\n```"
→ 渲染: 代码块完整,正常显示 ✅
同样的问题:
LaTeX: "$E = mc^" → 不完整的公式
表格: "| 列1 | 列" → 不完整的表格行
链接: "[文本](http" → 不完整的链接
加粗: "**重要" → 不完整的加粗解决方案 1:状态机 + 缓冲区
typescript
class MarkdownStreamBuffer {
private buffer = ''
private renderableEnd = 0
append(chunk: string): string {
this.buffer += chunk
this.renderableEnd = this.findSafeBreakpoint(this.buffer)
return this.buffer.slice(0, this.renderableEnd)
}
private findSafeBreakpoint(text: string): number {
let pos = text.length
// 检查未闭合的代码块
const codeBlockCount = (text.match(/```/g) || []).length
if (codeBlockCount % 2 !== 0) {
const lastOpen = text.lastIndexOf('```')
pos = Math.min(pos, lastOpen)
}
// 检查未闭合的行内代码
const inlineCodeCount = (text.match(/(?<!`)`(?!`)/g) || []).length
if (inlineCodeCount % 2 !== 0) {
const lastBacktick = text.lastIndexOf('`')
pos = Math.min(pos, lastBacktick)
}
// 检查未完成的 LaTeX
const dollarCount = (text.match(/\$/g) || []).length
if (dollarCount % 2 !== 0) {
const lastDollar = text.lastIndexOf('$')
pos = Math.min(pos, lastDollar)
}
// 检查未闭合的加粗/斜体
const boldCount = (text.match(/\*\*/g) || []).length
if (boldCount % 2 !== 0) {
const lastBold = text.lastIndexOf('**')
pos = Math.min(pos, lastBold)
}
return pos
}
getFullText(): string {
return this.buffer
}
}解决方案 2:未闭合标记自动补全
typescript
function autoCompleteMarkdown(partial: string): string {
let result = partial
// 自动闭合代码块
const codeBlocks = partial.match(/```/g) || []
if (codeBlocks.length % 2 !== 0) {
result += '\n```'
}
// 自动闭合行内代码
const lastLine = result.split('\n').pop() ?? ''
const backticks = (lastLine.match(/(?<!`)`(?!`)/g) || []).length
if (backticks % 2 !== 0) {
result += '`'
}
// 自动闭合加粗
const boldMarks = (result.match(/\*\*/g) || []).length
if (boldMarks % 2 !== 0) {
result += '**'
}
return result
}
// 渲染时使用
function renderStreamingMarkdown(rawText: string, isComplete: boolean) {
const text = isComplete ? rawText : autoCompleteMarkdown(rawText)
return markdownToHtml(text)
}解决方案 3:增量渲染(性能最优)
typescript
class IncrementalMarkdownRenderer {
private renderedLength = 0
private containerEl: HTMLElement
constructor(container: HTMLElement) {
this.containerEl = container
}
update(fullText: string) {
const newContent = fullText.slice(this.renderedLength)
if (!newContent) return
// 只处理新增的内容
const lastParagraph = this.containerEl.lastElementChild
if (lastParagraph && this.isIncompleteParagraph(lastParagraph)) {
// 追加到现有段落
lastParagraph.textContent += newContent
} else {
// 创建新段落
const p = document.createElement('p')
p.textContent = newContent
this.containerEl.appendChild(p)
}
this.renderedLength = fullText.length
}
private isIncompleteParagraph(el: Element): boolean {
return !el.textContent?.endsWith('\n')
}
finalize(fullText: string) {
// 流结束后,用完整的 Markdown 渲染替换
this.containerEl.innerHTML = markdownToHtml(fullText)
this.renderedLength = fullText.length
}
}追问延伸
react-markdown流式渲染的性能瓶颈在哪?如何优化?- 如何实现 LaTeX 公式的流式渲染?MathJax vs KaTeX?
- 代码块的语法高亮在流式场景下怎么做增量更新?
5. 如何实现 Markdown 实时渲染引擎 + 代码高亮? ⭐⭐
构建 AI Chat 的内容渲染层。
考察点:Markdown、代码高亮
技术选型
Markdown 解析器:
┌─────────────────────────────────────────────────────┐
│ 库 │ 特点 │ 大小 │
├─────────────────────────────────────────────────────┤
│ marked │ 速度快,可扩展 │ 35KB │
│ markdown-it │ 插件丰富,社区大 │ 70KB │
│ react-markdown │ React 组件,安全 │ 40KB │
│ unified/remark │ AST 级控制,最灵活 │ 100KB+ │
│ MDX │ 支持 JSX 嵌入 │ 150KB+ │
└─────────────────────────────────────────────────────┘
代码高亮:
┌─────────────────────────────────────────────────────┐
│ 库 │ 特点 │ 大小 │
├─────────────────────────────────────────────────────┤
│ Shiki │ VS Code 级高亮,静态 │ 按需加载 │
│ highlight.js │ 经典,运行时高亮 │ 按需加载 │
│ Prism │ 轻量,插件丰富 │ 按需加载 │
└─────────────────────────────────────────────────────┘React 实现方案
tsx
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
interface ChatMarkdownProps {
content: string
isStreaming?: boolean
}
function ChatMarkdown({ content, isStreaming }: ChatMarkdownProps) {
return (
<div className={`markdown-body ${isStreaming ? 'ai-cursor' : ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const codeString = String(children).replace(/\n$/, '')
if (match) {
return (
<div className="code-block">
<div className="code-header">
<span>{match[1]}</span>
<CopyButton text={codeString} />
</div>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
>
{codeString}
</SyntaxHighlighter>
</div>
)
}
return (
<code className="inline-code" {...props}>
{children}
</code>
)
},
table({ children }) {
return (
<div className="table-wrapper">
<table>{children}</table>
</div>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
)
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button onClick={handleCopy}>
{copied ? '✓ 已复制' : '复制'}
</button>
)
}性能优化:流式场景下避免重复解析
tsx
const MemoizedMarkdown = memo(ChatMarkdown, (prev, next) => {
if (prev.isStreaming && next.isStreaming) {
return prev.content === next.content
}
return prev.content === next.content && prev.isStreaming === next.isStreaming
})
function StreamingMessage({ content, isStreaming }: ChatMarkdownProps) {
const debouncedContent = useDeferredValue(content)
return (
<MemoizedMarkdown
content={isStreaming ? debouncedContent : content}
isStreaming={isStreaming}
/>
)
}追问延伸
- Shiki 和 highlight.js 在流式场景下哪个更合适?
- 如何实现代码块的"运行"按钮(在线执行 JS/Python)?
- Markdown 渲染的 XSS 防护怎么做?
dangerouslySetInnerHTML的风险?
6. AI 对话界面的"自动滚动到底部"功能,如何兼顾用户手动向上滚动查看历史? ⭐⭐
一个看似简单但处处是坑的 UX 问题。
考察点:滚动控制
需求分析
用户期望:
1. AI 正在输出时 → 自动滚动到底部(跟随最新内容)
2. 用户向上滚动查看历史 → 停止自动滚动(不要把我拉回去)
3. 用户滚回底部 → 恢复自动滚动
4. 新消息到达但用户在看历史 → 显示"有新消息"提示
核心状态: isAutoScroll
→ 默认 true
→ 用户向上滚动 → false
→ 用户滚到底部 → true
→ 点击"回到底部"按钮 → true完整实现
tsx
function useAutoScroll(containerRef: RefObject<HTMLDivElement | null>) {
const isAutoScrollRef = useRef(true)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isAtBottom = distanceFromBottom < 50
isAutoScrollRef.current = isAtBottom
setShowScrollToBottom(!isAtBottom)
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [containerRef])
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = containerRef.current
if (!container) return
container.scrollTo({
top: container.scrollHeight,
behavior,
})
isAutoScrollRef.current = true
setShowScrollToBottom(false)
}, [containerRef])
const onContentUpdate = useCallback(() => {
if (isAutoScrollRef.current) {
requestAnimationFrame(() => {
containerRef.current?.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'instant',
})
})
}
}, [containerRef])
return { showScrollToBottom, scrollToBottom, onContentUpdate }
}
// 使用
function ChatView({ messages }: { messages: Message[] }) {
const containerRef = useRef<HTMLDivElement>(null)
const { showScrollToBottom, scrollToBottom, onContentUpdate } =
useAutoScroll(containerRef)
useEffect(() => {
onContentUpdate()
}, [messages, onContentUpdate])
return (
<div className="chat-container" ref={containerRef}>
{messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
{showScrollToBottom && (
<button
className="scroll-to-bottom"
onClick={() => scrollToBottom()}
>
↓ 回到底部
</button>
)}
</div>
)
}追问延伸
scrollIntoViewvsscrollTo哪个更适合这个场景?- iOS Safari 的弹性滚动(overscroll)会导致误判吗?
- 如何在虚拟列表中实现自动滚动到底部?
7. 长对话列表的虚拟滚动实现?在 AI 场景下与传统虚拟列表有什么不同? ⭐⭐⭐
当对话有几百条消息时,全量渲染会崩溃。
考察点:虚拟滚动
AI 场景的特殊挑战
传统虚拟列表(如商品列表):
✅ 每项高度固定或可预估
✅ 数据一次性加载
✅ 内容不会变化
AI 对话虚拟列表:
❌ 每条消息高度差异巨大(一行 vs 100 行代码块)
❌ 流式生成中,最后一条消息高度持续增长
❌ Markdown 渲染后高度变化(图片加载、代码块展开)
❌ 需要自动滚动到底部
→ 需要"动态高度 + 实时更新"的虚拟滚动方案方案对比
┌────────────────────────────────────────────────────────┐
│ 方案 │ 动态高度 │ 流式适配 │ 复杂度 │
├────────────────────────────────────────────────────────┤
│ react-window │ 需手动 │ 差 │ 低 │
│ react-virtuoso │ ✅ 原生 │ ✅ 好 │ 中 │
│ @tanstack/virtual │ ✅ 原生 │ 中 │ 中 │
│ 手动 Intersection │ ✅ │ ✅ │ 高 │
│ Observer │ │ │ │
└────────────────────────────────────────────────────────┘
推荐: react-virtuoso(专为聊天场景设计)react-virtuoso 方案
tsx
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
function ChatMessages({ messages, isStreaming }: Props) {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [atBottom, setAtBottom] = useState(true)
useEffect(() => {
if (atBottom) {
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: messages.length - 1,
align: 'end',
behavior: 'auto',
})
})
}
}, [messages, atBottom])
return (
<>
<Virtuoso
ref={virtuosoRef}
data={messages}
atBottomStateChange={setAtBottom}
atBottomThreshold={50}
followOutput={atBottom ? 'smooth' : false}
itemContent={(index, message) => (
<MessageBubble
key={message.id}
message={message}
isLast={index === messages.length - 1}
isStreaming={isStreaming && index === messages.length - 1}
/>
)}
/>
{!atBottom && (
<button onClick={() => {
virtuosoRef.current?.scrollToIndex({
index: messages.length - 1,
behavior: 'smooth',
})
}}>
↓ 新消息
</button>
)}
</>
)
}手动实现(Intersection Observer)
typescript
class ChatVirtualScroller {
private observer: IntersectionObserver
private itemHeights = new Map<string, number>()
private visibleRange = { start: 0, end: 0 }
constructor(
private container: HTMLElement,
private messages: Message[],
private renderItem: (msg: Message) => HTMLElement
) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const id = entry.target.getAttribute('data-msg-id')!
if (entry.isIntersecting) {
this.itemHeights.set(id, entry.boundingClientRect.height)
}
})
},
{ root: container, rootMargin: '200px 0px' }
)
}
getEstimatedHeight(messageId: string): number {
return this.itemHeights.get(messageId) ?? 80
}
updateVisibleRange() {
const { scrollTop, clientHeight } = this.container
let accHeight = 0
let start = 0
let end = this.messages.length
for (let i = 0; i < this.messages.length; i++) {
const h = this.getEstimatedHeight(this.messages[i].id)
if (accHeight + h < scrollTop - 200) {
start = i + 1
}
if (accHeight > scrollTop + clientHeight + 200) {
end = i
break
}
accHeight += h
}
this.visibleRange = { start, end }
}
}追问延伸
- 虚拟列表中图片异步加载导致高度变化怎么处理?
followOutput在高速流式输出时会不会出现抖动?- 如何在虚拟列表中实现消息搜索定位?
8. React 中如何处理流式响应与状态管理?useChat Hook 的设计思路? ⭐⭐
设计一个优雅的 React Hook 来管理 AI 对话状态。
考察点:React + AI
设计 useChat Hook
typescript
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
createdAt: Date
}
interface UseChatOptions {
api?: string
initialMessages?: Message[]
onFinish?: (message: Message) => void
onError?: (error: Error) => void
}
interface UseChatReturn {
messages: Message[]
input: string
setInput: (input: string) => void
isLoading: boolean
error: Error | null
send: () => Promise<void>
stop: () => void
reload: () => Promise<void>
reset: () => void
}完整实现
typescript
function useChat(options: UseChatOptions = {}): UseChatReturn {
const {
api = '/api/chat',
initialMessages = [],
onFinish,
onError,
} = options
const [messages, setMessages] = useState<Message[]>(initialMessages)
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const abortRef = useRef<AbortController | null>(null)
const send = useCallback(async () => {
if (!input.trim() || isLoading) return
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
createdAt: new Date(),
}
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
createdAt: new Date(),
}
setMessages(prev => [...prev, userMessage, assistantMessage])
setInput('')
setIsLoading(true)
setError(null)
abortRef.current = new AbortController()
try {
const allMessages = [...messages, userMessage]
const response = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: allMessages.map(({ role, content }) => ({ role, content })),
}),
signal: abortRef.current.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullContent = ''
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) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
try {
const data = JSON.parse(line.slice(6))
const token = data.choices?.[0]?.delta?.content
if (token) {
fullContent += token
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMessage.id
? { ...msg, content: fullContent }
: msg
)
)
}
} catch {}
}
}
const finalMessage = { ...assistantMessage, content: fullContent }
onFinish?.(finalMessage)
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError(err as Error)
onError?.(err as Error)
}
} finally {
setIsLoading(false)
abortRef.current = null
}
}, [input, isLoading, messages, api, onFinish, onError])
const stop = useCallback(() => {
abortRef.current?.abort()
}, [])
const reload = useCallback(async () => {
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
if (!lastUserMsg) return
setMessages(prev => {
const idx = prev.lastIndexOf(lastUserMsg)
return prev.slice(0, idx)
})
setInput(lastUserMsg.content)
await new Promise(r => setTimeout(r, 0))
// send() 需要在下一个 tick 调用
}, [messages])
const reset = useCallback(() => {
abortRef.current?.abort()
setMessages(initialMessages)
setInput('')
setIsLoading(false)
setError(null)
}, [initialMessages])
return { messages, input, setInput, isLoading, error, send, stop, reload, reset }
}使用示例
tsx
function ChatPage() {
const { messages, input, setInput, isLoading, send, stop } = useChat({
api: '/api/chat',
onFinish: (msg) => console.log('完成:', msg.content.length, '字'),
onError: (err) => toast.error(err.message),
})
return (
<div className="chat">
<MessageList messages={messages} isStreaming={isLoading} />
<form onSubmit={(e) => { e.preventDefault(); send() }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入消息..."
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={stop}>停止</button>
) : (
<button type="submit" disabled={!input.trim()}>发送</button>
)}
</form>
</div>
)
}追问延伸
setMessages在流式更新中频繁调用,如何避免性能问题?- 如何支持多轮对话的"分支"功能(修改历史消息重新生成)?
- Vercel AI SDK 的
useChat和自己实现的有什么区别?
9. Vue 中流式响应与响应式系统如何配合?shallowRef + triggerRef 的优化方案? ⭐⭐
Vue 的响应式系统在高频更新场景下的特殊优化。
考察点:Vue + AI
Vue 响应式的问题
问题: 流式更新时,每个 Token 修改 messages 数组的最后一条消息
如果用 ref([]):
→ 每次修改 content 字符串
→ Vue 的深度响应式追踪到变化
→ 触发依赖收集的所有 effect
→ 所有消息组件重新渲染(即使只有最后一条变了)
100 tokens/s = 100 次深度响应式触发 → 卡顿优化方案
vue
<script setup lang="ts">
import { shallowRef, triggerRef, computed, ref } from 'vue'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
const messages = shallowRef<Message[]>([])
const input = ref('')
const isLoading = ref(false)
const abortController = ref<AbortController | null>(null)
// shallowRef: 只追踪引用变化,不深度追踪数组元素
// triggerRef: 手动触发更新(攒一批再触发)
let rafId: number | null = null
function appendToken(token: string) {
const msgs = messages.value
const last = msgs[msgs.length - 1]
last.content += token
// 用 RAF 批量触发更新
if (!rafId) {
rafId = requestAnimationFrame(() => {
triggerRef(messages)
rafId = null
})
}
}
async function send() {
if (!input.value.trim() || isLoading.value) return
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.value.trim(),
}
const assistantMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
}
messages.value = [...messages.value, userMsg, assistantMsg]
input.value = ''
isLoading.value = true
const controller = new AbortController()
abortController.value = controller
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.value.map(({ role, content }) => ({ role, content })),
}),
signal: controller.signal,
})
const reader = response.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 })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
try {
const data = JSON.parse(line.slice(6))
const token = data.choices?.[0]?.delta?.content
if (token) appendToken(token)
} catch {}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error(err)
}
} finally {
isLoading.value = false
abortController.value = null
triggerRef(messages)
}
}
function stop() {
abortController.value?.abort()
}
const lastMessage = computed(() => messages.value[messages.value.length - 1])
</script>
<template>
<div class="chat">
<div class="messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['message', msg.role]"
>
<MarkdownRenderer
:content="msg.content"
:is-streaming="isLoading && msg.id === lastMessage?.id"
/>
</div>
</div>
<form @submit.prevent="send">
<input v-model="input" :disabled="isLoading" placeholder="输入消息..." />
<button v-if="isLoading" type="button" @click="stop">停止</button>
<button v-else type="submit" :disabled="!input.trim()">发送</button>
</form>
</div>
</template>Vue vs React 流式更新对比
┌───────────────────────────────────────────────────┐
│ 方面 │ React │ Vue │
├───────────────────────────────────────────────────┤
│ 状态更新 │ setState (不可变) │ 直接修改 │
│ 批量更新 │ 自动批处理(React 18) │ nextTick │
│ 流式优化 │ useDeferredValue │ shallowRef │
│ 手动触发 │ forceUpdate(不推荐) │ triggerRef │
│ 最佳实践 │ RAF + setState │ RAF+triggerRef│
└───────────────────────────────────────────────────┘追问延伸
shallowRef+triggerRef和ref的性能差距有多大?- Vue 3 的
effectScope能用来优化 AI 对话的副作用管理吗? - 如何用 Vue 3 的
Suspense处理 AI 响应的异步加载状态?
10. AI 对话的状态管理架构设计:消息列表 / 加载状态 / 连接状态 / Token 用量如何组织? ⭐⭐
当 AI 应用复杂到一定程度,需要系统性的状态管理设计。
考察点:状态管理
状态分层
AI 对话应用的状态维度:
┌────────────────────────────────────────────────────┐
│ Layer 1: 会话状态 │
│ ├─ conversations: Conversation[] // 多会话列表 │
│ ├─ activeConversationId: string // 当前激活会话 │
│ └─ conversationSettings: {} // 会话配置 │
├────────────────────────────────────────────────────┤
│ Layer 2: 消息状态 │
│ ├─ messages: Message[] // 消息列表 │
│ ├─ streamingMessageId: string // 正在流式的ID │
│ └─ pendingMessages: Message[] // 发送中队列 │
├────────────────────────────────────────────────────┤
│ Layer 3: 连接状态 │
│ ├─ connectionStatus: 'idle'|'connecting'|'streaming'│
│ ├─ error: Error | null │
│ └─ retryCount: number │
├────────────────────────────────────────────────────┤
│ Layer 4: 用量状态 │
│ ├─ tokenUsage: { prompt, completion, total } │
│ ├─ estimatedCost: number │
│ └─ rateLimit: { remaining, reset } │
├────────────────────────────────────────────────────┤
│ Layer 5: UI 状态 │
│ ├─ isAutoScroll: boolean │
│ ├─ selectedModel: string │
│ └─ sidebarOpen: boolean │
└────────────────────────────────────────────────────┘Zustand 实现
typescript
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { persist } from 'zustand/middleware'
interface ChatState {
conversations: Conversation[]
activeId: string | null
connectionStatus: 'idle' | 'connecting' | 'streaming' | 'error'
error: Error | null
tokenUsage: TokenUsage
// Actions
createConversation: () => string
deleteConversation: (id: string) => void
setActiveConversation: (id: string) => void
addMessage: (conversationId: string, message: Message) => void
updateStreamingMessage: (conversationId: string, messageId: string, content: string) => void
setConnectionStatus: (status: ChatState['connectionStatus']) => void
updateTokenUsage: (usage: Partial<TokenUsage>) => void
}
const useChatStore = create<ChatState>()(
persist(
immer((set, get) => ({
conversations: [],
activeId: null,
connectionStatus: 'idle',
error: null,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
createConversation: () => {
const id = crypto.randomUUID()
set(state => {
state.conversations.push({
id,
title: '新对话',
messages: [],
createdAt: new Date().toISOString(),
model: 'gpt-4o',
})
state.activeId = id
})
return id
},
deleteConversation: (id) => {
set(state => {
state.conversations = state.conversations.filter(c => c.id !== id)
if (state.activeId === id) {
state.activeId = state.conversations[0]?.id ?? null
}
})
},
setActiveConversation: (id) => {
set(state => { state.activeId = id })
},
addMessage: (conversationId, message) => {
set(state => {
const conv = state.conversations.find(c => c.id === conversationId)
conv?.messages.push(message)
})
},
updateStreamingMessage: (conversationId, messageId, content) => {
set(state => {
const conv = state.conversations.find(c => c.id === conversationId)
const msg = conv?.messages.find(m => m.id === messageId)
if (msg) msg.content = content
})
},
setConnectionStatus: (status) => {
set(state => { state.connectionStatus = status })
},
updateTokenUsage: (usage) => {
set(state => {
Object.assign(state.tokenUsage, usage)
})
},
})),
{
name: 'chat-storage',
partialize: (state) => ({
conversations: state.conversations,
activeId: state.activeId,
}),
}
)
)派生状态(Selectors)
typescript
const useActiveConversation = () =>
useChatStore(state =>
state.conversations.find(c => c.id === state.activeId)
)
const useActiveMessages = () =>
useChatStore(state =>
state.conversations.find(c => c.id === state.activeId)?.messages ?? []
)
const useIsStreaming = () =>
useChatStore(state => state.connectionStatus === 'streaming')
const useTotalCost = () =>
useChatStore(state => {
const { prompt, completion } = state.tokenUsage
return (prompt * 2.5 + completion * 10) / 1_000_000
})状态更新时序
用户发送消息:
1. addMessage(userMsg) → Layer 2
2. addMessage(assistantMsg) → Layer 2 (空内容占位)
3. setConnectionStatus('connecting') → Layer 3
4. fetch /api/chat → 网络请求
流式接收:
5. setConnectionStatus('streaming') → Layer 3
6. updateStreamingMessage(token) → Layer 2 (高频)
7. updateTokenUsage(delta) → Layer 4
完成/错误:
8. setConnectionStatus('idle'|'error') → Layer 3
9. updateTokenUsage(final) → Layer 4追问延伸
- Zustand vs Jotai vs Redux Toolkit 在 AI 应用中怎么选?
- 消息列表的持久化策略?IndexedDB vs localStorage?大量消息怎么分页?
- 如何实现"撤回"和"重新生成"功能的状态管理?
11. Vercel AI SDK 的核心 API?useChat / useCompletion / streamText 怎么用? ⭐⭐
前端 AI 开发的事实标准工具库。
考察点:Vercel AI SDK
为什么选 Vercel AI SDK
痛点: 每个 AI 提供商的 API 格式都不一样
OpenAI: choices[0].delta.content
Anthropic: content_block_delta → delta.text
Google: candidates[0].content.parts[0].text
Vercel AI SDK 的价值:
✅ 统一接口: 一套代码适配所有模型提供商
✅ 流式原生: 内置 SSE 解析、流式传输
✅ 框架适配: React / Vue / Svelte / Solid Hooks
✅ Edge Runtime: 适配 Vercel Edge / Cloudflare Workers
✅ 类型安全: 完整的 TypeScript 类型核心架构
Vercel AI SDK 三层架构:
┌─────────────────────────────────────────────┐
│ AI SDK UI (前端) │
│ useChat / useCompletion / useObject │
│ React / Vue / Svelte / Solid │
├─────────────────────────────────────────────┤
│ AI SDK Core (通用) │
│ generateText / streamText / generateObject │
│ streamObject / embed / embedMany │
├─────────────────────────────────────────────┤
│ AI SDK Providers (适配层) │
│ @ai-sdk/openai / @ai-sdk/anthropic │
│ @ai-sdk/google / @ai-sdk/mistral │
└─────────────────────────────────────────────┘服务端:streamText
typescript
// app/api/chat/route.ts (Next.js App Router)
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
system: '你是一个前端开发助手,用中文回答。',
messages,
maxTokens: 2000,
temperature: 0.7,
// 工具定义
tools: {
getWeather: {
description: '获取天气信息',
parameters: z.object({
city: z.string().describe('城市名'),
}),
execute: async ({ city }) => {
return { city, temp: 25, condition: '晴' }
},
},
},
})
return result.toDataStreamResponse()
}前端:useChat
tsx
// React 组件
import { useChat } from 'ai/react'
function ChatPage() {
const {
messages, // Message[] 消息列表
input, // string 输入框内容
handleInputChange, // 输入框 onChange
handleSubmit, // 表单 onSubmit
isLoading, // boolean 是否正在生成
stop, // () => void 停止生成
reload, // () => void 重新生成
error, // Error | undefined
setMessages, // 直接设置消息列表
} = useChat({
api: '/api/chat',
initialMessages: [],
onFinish: (message) => {
console.log('完成:', message)
},
onError: (error) => {
toast.error(error.message)
},
onResponse: (response) => {
// 可以在这里检查 HTTP 状态
if (response.status === 429) {
toast.error('请求过于频繁,请稍后重试')
}
},
})
return (
<div>
{messages.map(m => (
<div key={m.id} className={m.role}>
{m.content}
{/* 工具调用结果 */}
{m.toolInvocations?.map(tool => (
<div key={tool.toolCallId}>
调用了 {tool.toolName}: {JSON.stringify(tool.result)}
</div>
))}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
{isLoading ? (
<button type="button" onClick={stop}>停止</button>
) : (
<button type="submit">发送</button>
)}
</form>
</div>
)
}useCompletion vs useChat
useChat:
→ 多轮对话(消息列表 messages[])
→ 支持 Tool Calling
→ 适合: ChatBot、AI 助手
useCompletion:
→ 单次补全(输入 prompt → 输出 completion)
→ 更轻量
→ 适合: AI 写作、代码补全、摘要生成
useObject:
→ 流式生成结构化 JSON 对象
→ 配合 Zod Schema 使用
→ 适合: 表单生成、数据提取切换模型只需一行
typescript
import { openai } from '@ai-sdk/openai'
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
// 切换模型只需改这一行
const result = streamText({
model: openai('gpt-4o'),
// model: anthropic('claude-3-5-sonnet-20241022'),
// model: google('gemini-2.0-flash'),
messages,
})追问延伸
toDataStreamResponse()返回的数据格式是什么?和原生 SSE 有什么区别?- 如何在 Vercel AI SDK 中实现流式 Tool Calling?
useObject+ Zod Schema 的流式 JSON 解析原理?
12. 什么是 Generative UI(生成式 UI)?如何让 Agent 返回 React 组件而不只是文本? ⭐⭐⭐
AI 不只是生成文本,还能生成交互式 UI 组件。
考察点:生成式 UI
概念
传统 AI Chat:
用户: "帮我查一下北京的天气"
AI: "北京今天天气晴,温度 25°C,湿度 45%" ← 纯文本
Generative UI:
用户: "帮我查一下北京的天气"
AI: ┌──────────────────────┐
│ 🌤️ 北京 · 晴 │
│ 25°C 湿度 45% │
│ [━━━━━━━━━━░░] 72% │ ← 交互式 React 组件!
│ 📊 查看 7 天趋势 │
└──────────────────────┘
本质: Agent 的工具返回的不是纯数据,而是 React 组件Vercel AI SDK 的 Generative UI
tsx
// 服务端: 定义工具返回 React 组件
import { streamUI } from 'ai/rsc'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
async function submitMessage(input: string) {
'use server'
const result = await streamUI({
model: openai('gpt-4o'),
system: '你是一个智能助手',
prompt: input,
tools: {
getWeather: {
description: '获取城市天气',
parameters: z.object({
city: z.string(),
}),
generate: async function* ({ city }) {
// 先返回 Loading 状态
yield <WeatherSkeleton />
const weather = await fetchWeather(city)
// 返回完整的交互式组件
return <WeatherCard data={weather} />
},
},
showStockChart: {
description: '显示股票走势图',
parameters: z.object({
symbol: z.string(),
period: z.enum(['1d', '1w', '1m', '1y']),
}),
generate: async function* ({ symbol, period }) {
yield <ChartSkeleton />
const data = await fetchStockData(symbol, period)
return <StockChart data={data} symbol={symbol} />
},
},
},
})
return result.value
}前端消费 Generative UI
tsx
function Chat() {
const [messages, setMessages] = useState<ReactNode[]>([])
async function handleSubmit(input: string) {
const ui = await submitMessage(input)
setMessages(prev => [...prev, ui])
}
return (
<div>
{messages.map((msg, i) => (
<div key={i}>{msg}</div> {/* 直接渲染 React 组件 */}
))}
</div>
)
}不依赖 RSC 的实现方案
tsx
// 方案: Tool Calling + 组件映射
const componentMap: Record<string, React.ComponentType<any>> = {
weather_card: WeatherCard,
stock_chart: StockChart,
code_sandbox: CodeSandbox,
image_gallery: ImageGallery,
data_table: DataTable,
}
function AIMessage({ message }: { message: Message }) {
return (
<div>
{/* 渲染文本部分 */}
{message.content && <Markdown>{message.content}</Markdown>}
{/* 渲染工具调用结果为组件 */}
{message.toolInvocations?.map(tool => {
const Component = componentMap[tool.toolName]
if (Component && tool.state === 'result') {
return <Component key={tool.toolCallId} {...tool.result} />
}
if (tool.state === 'call') {
return <ToolCallSkeleton key={tool.toolCallId} name={tool.toolName} />
}
return null
})}
</div>
)
}追问延伸
- React Server Components (RSC) 在 Generative UI 中的角色?
- 如何实现 Generative UI 的"交互回传"(用户点击组件内按钮触发新对话)?
- Generative UI 的安全风险?如何防止 Agent 生成恶意组件?
13. Function Calling 的前端配合流程:解析工具调用指令 → 执行 → 回传结果的完整实现? ⭐⭐⭐
Function Calling 是 Agent 的核心能力,前端需要完整配合。
考察点:前端工具调用
完整流程
┌─────────────────────────────────────────────────────────┐
│ 1. 用户输入 → 发送到后端 │
│ 2. LLM 决定调用工具 → 返回 tool_calls │
│ 3. 前端解析 tool_calls → 执行对应函数 │
│ 4. 执行结果 → 回传给 LLM │
│ 5. LLM 基于结果生成最终回答 │
│ 6. (可能循环多次 2-5) │
│ │
│ 前端需要处理: │
│ → 解析流式中的 tool_calls │
│ → 渲染工具调用的"执行中"状态 │
│ → 执行客户端工具(前端能力: DOM操作/本地存储/导航等) │
│ → 回传结果并继续对话 │
└─────────────────────────────────────────────────────────┘前端工具执行引擎
typescript
type ToolFunction = (args: Record<string, unknown>) => Promise<unknown>
const clientTools: Record<string, ToolFunction> = {
navigate_to: async ({ url }) => {
window.location.href = url as string
return { success: true }
},
get_page_content: async () => {
return {
title: document.title,
url: window.location.href,
text: document.body.innerText.slice(0, 2000),
}
},
copy_to_clipboard: async ({ text }) => {
await navigator.clipboard.writeText(text as string)
return { success: true }
},
get_local_storage: async ({ key }) => {
return { value: localStorage.getItem(key as string) }
},
set_local_storage: async ({ key, value }) => {
localStorage.setItem(key as string, value as string)
return { success: true }
},
show_notification: async ({ title, body }) => {
if (Notification.permission === 'granted') {
new Notification(title as string, { body: body as string })
}
return { shown: Notification.permission === 'granted' }
},
}
async function executeToolCall(
toolName: string,
args: Record<string, unknown>
): Promise<unknown> {
const fn = clientTools[toolName]
if (!fn) {
throw new Error(`Unknown tool: ${toolName}`)
}
return fn(args)
}流式 Tool Calling 完整处理
typescript
interface ToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
async function handleStreamWithTools(
messages: Message[],
onToken: (token: string) => void,
onToolCall: (tool: ToolCall, result: unknown) => void
) {
let currentToolCalls: Partial<ToolCall>[] = []
let continueConversation = true
while (continueConversation) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let assistantContent = ''
currentToolCalls = []
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) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
const data = JSON.parse(line.slice(6))
const delta = data.choices?.[0]?.delta
if (delta?.content) {
assistantContent += delta.content
onToken(delta.content)
}
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
if (!currentToolCalls[tc.index]) {
currentToolCalls[tc.index] = {
id: tc.id,
type: 'function',
function: { name: '', arguments: '' },
}
}
const current = currentToolCalls[tc.index]!
if (tc.function?.name) current.function!.name = tc.function.name
if (tc.function?.arguments) current.function!.arguments += tc.function.arguments
}
}
}
}
if (currentToolCalls.length > 0) {
messages.push({
role: 'assistant',
content: assistantContent || null,
tool_calls: currentToolCalls as ToolCall[],
})
for (const tc of currentToolCalls as ToolCall[]) {
const args = JSON.parse(tc.function.arguments)
const result = await executeToolCall(tc.function.name, args)
onToolCall(tc, result)
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result),
})
}
} else {
continueConversation = false
}
}
}追问延伸
- 并行 Tool Calling(一次返回多个工具调用)前端怎么处理?
- 工具执行超时前端怎么处理?如何给用户反馈?
- 如何实现"工具调用审批"(Agent 要调用危险工具时,让用户先确认)?
14. AI 场景下的敏感操作拦截:Agent 想调用"删除订单"等危险操作时,前端如何守门? ⭐⭐
Agent 不是万能的,危险操作必须有人类确认。
考察点:安全拦截
Human-in-the-Loop 设计
安全分级:
┌───────────────────────────────────────────────────┐
│ Level 0: 只读操作 → 直接执行 │
│ 查询天气 / 搜索文档 / 获取页面信息 │
├───────────────────────────────────────────────────┤
│ Level 1: 低风险写操作 → 执行后通知 │
│ 保存草稿 / 添加书签 / 设置提醒 │
├───────────────────────────────────────────────────┤
│ Level 2: 中风险操作 → 确认后执行 │
│ 发送邮件 / 修改设置 / 提交表单 │
├───────────────────────────────────────────────────┤
│ Level 3: 高风险操作 → 二次确认 + 详情展示 │
│ 删除数据 / 转账 / 发布上线 / 权限变更 │
└───────────────────────────────────────────────────┘实现方案
typescript
interface ToolMeta {
riskLevel: 0 | 1 | 2 | 3
description: string
requiresConfirm: boolean
confirmMessage?: string
}
const toolRegistry: Record<string, ToolMeta> = {
search_docs: { riskLevel: 0, description: '搜索文档', requiresConfirm: false },
save_draft: { riskLevel: 1, description: '保存草稿', requiresConfirm: false },
send_email: { riskLevel: 2, description: '发送邮件', requiresConfirm: true,
confirmMessage: '确认发送邮件给 {to}?' },
delete_order: { riskLevel: 3, description: '删除订单', requiresConfirm: true,
confirmMessage: '⚠️ 此操作不可恢复!确认删除订单 {orderId}?' },
transfer_money: { riskLevel: 3, description: '转账', requiresConfirm: true,
confirmMessage: '⚠️ 确认向 {to} 转账 {amount} 元?' },
}
async function safeExecuteTool(
toolName: string,
args: Record<string, unknown>,
confirm: (message: string, details: Record<string, unknown>) => Promise<boolean>
): Promise<{ result: unknown; executed: boolean }> {
const meta = toolRegistry[toolName]
if (!meta) {
return { result: { error: '未知工具' }, executed: false }
}
if (meta.requiresConfirm) {
let message = meta.confirmMessage ?? `确认执行 ${meta.description}?`
for (const [key, val] of Object.entries(args)) {
message = message.replace(`{${key}}`, String(val))
}
const confirmed = await confirm(message, args)
if (!confirmed) {
return {
result: { error: '用户拒绝执行此操作', reason: 'user_denied' },
executed: false,
}
}
}
const result = await executeToolCall(toolName, args)
return { result, executed: true }
}确认弹窗组件
tsx
function ToolConfirmDialog({
toolName,
args,
meta,
onConfirm,
onDeny,
}: ToolConfirmProps) {
const riskColors = {
0: 'bg-green-100',
1: 'bg-blue-100',
2: 'bg-yellow-100',
3: 'bg-red-100',
}
return (
<div className="confirm-dialog">
<div className={`risk-badge ${riskColors[meta.riskLevel]}`}>
风险等级: {'⭐'.repeat(meta.riskLevel)}
</div>
<h3>AI 请求执行: {meta.description}</h3>
<div className="args-preview">
<pre>{JSON.stringify(args, null, 2)}</pre>
</div>
<div className="actions">
<button className="deny" onClick={onDeny}>
拒绝
</button>
<button
className={meta.riskLevel >= 3 ? 'danger' : 'confirm'}
onClick={onConfirm}
>
{meta.riskLevel >= 3 ? '我确认,执行此操作' : '确认'}
</button>
</div>
</div>
)
}追问延伸
- 如何防止 Prompt Injection 绕过安全拦截?
- Agent 连续多次触发危险操作怎么办?频率限制?
- 安全日志和审计追踪怎么设计?
15. AI 聊天界面的离线支持方案?IndexedDB 存储对话历史?断线重连机制? ⭐⭐
AI 应用也需要考虑离线和弱网场景。
考察点:离线、持久化
离线存储方案
┌────────────────────────────────────────────────────┐
│ 存储方案 │ 容量 │ 适合场景 │
├────────────────────────────────────────────────────┤
│ localStorage │ 5-10MB │ 少量设置/偏好 │
│ sessionStorage │ 5-10MB │ 当前会话临时数据 │
│ IndexedDB │ 无限制* │ 大量结构化数据 │
│ Cache API │ 无限制* │ 缓存 HTTP 响应 │
│ OPFS │ 无限制* │ 高性能文件操作 │
└────────────────────────────────────────────────────┘
* 受用户磁盘空间和浏览器配额限制
AI Chat 选择: IndexedDB(消息量大、需要查询)IndexedDB 消息存储
typescript
import { openDB, IDBPDatabase } from 'idb'
interface ChatDB {
conversations: {
key: string
value: Conversation
indexes: { 'by-updated': number }
}
messages: {
key: string
value: Message
indexes: { 'by-conversation': string; 'by-created': number }
}
}
async function initDB(): Promise<IDBPDatabase<ChatDB>> {
return openDB<ChatDB>('ai-chat', 1, {
upgrade(db) {
const convStore = db.createObjectStore('conversations', { keyPath: 'id' })
convStore.createIndex('by-updated', 'updatedAt')
const msgStore = db.createObjectStore('messages', { keyPath: 'id' })
msgStore.createIndex('by-conversation', 'conversationId')
msgStore.createIndex('by-created', 'createdAt')
},
})
}
class ChatStorage {
private db: IDBPDatabase<ChatDB> | null = null
async init() {
this.db = await initDB()
}
async saveMessage(message: Message) {
await this.db!.put('messages', message)
}
async getMessages(conversationId: string): Promise<Message[]> {
return this.db!.getAllFromIndex('messages', 'by-conversation', conversationId)
}
async saveConversation(conv: Conversation) {
await this.db!.put('conversations', conv)
}
async getConversations(): Promise<Conversation[]> {
const all = await this.db!.getAllFromIndex('conversations', 'by-updated')
return all.reverse()
}
async deleteConversation(id: string) {
const tx = this.db!.transaction(['conversations', 'messages'], 'readwrite')
await tx.objectStore('conversations').delete(id)
const messages = await tx.objectStore('messages').index('by-conversation').getAllKeys(id)
for (const key of messages) {
await tx.objectStore('messages').delete(key)
}
await tx.done
}
}断线重连
typescript
class ConnectionManager {
private isOnline = navigator.onLine
private pendingMessages: Message[] = []
private listeners = new Set<(online: boolean) => void>()
constructor() {
window.addEventListener('online', () => this.handleOnline())
window.addEventListener('offline', () => this.handleOffline())
}
private handleOnline() {
this.isOnline = true
this.listeners.forEach(fn => fn(true))
this.flushPendingMessages()
}
private handleOffline() {
this.isOnline = false
this.listeners.forEach(fn => fn(false))
}
onStatusChange(fn: (online: boolean) => void) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
async send(message: Message): Promise<Message | null> {
if (!this.isOnline) {
this.pendingMessages.push(message)
return null
}
try {
return await this.doSend(message)
} catch (err) {
if (this.isNetworkError(err)) {
this.pendingMessages.push(message)
return null
}
throw err
}
}
private async flushPendingMessages() {
while (this.pendingMessages.length > 0) {
const msg = this.pendingMessages[0]
try {
await this.doSend(msg)
this.pendingMessages.shift()
} catch {
break
}
}
}
private isNetworkError(err: unknown): boolean {
return err instanceof TypeError && (err as Error).message.includes('fetch')
}
private async doSend(message: Message): Promise<Message> {
// 实际发送逻辑
throw new Error('Not implemented')
}
}React Hook 集成
tsx
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
function ChatView() {
const isOnline = useOnlineStatus()
return (
<div>
{!isOnline && (
<div className="offline-banner">
📡 当前离线 · 消息将在恢复网络后自动发送
</div>
)}
{/* ... */}
</div>
)
}追问延伸
- IndexedDB 存储满了怎么办?如何实现 LRU 清理策略?
- Service Worker 能缓存 AI API 响应吗?有什么局限?
- 离线状态下能否利用 WebLLM 实现本地推理?
16. 如何处理 AI 响应中的多模态内容?文本 / 图片 / 表格 / 代码 / LaTeX 公式的混合渲染? ⭐⭐⭐
AI 的回答越来越丰富,前端需要一个强大的内容渲染引擎。
考察点:多模态渲染
内容类型识别
AI 响应可能包含:
普通文本 → <p>
Markdown 标题 → <h1>-<h6>
代码块 → <pre><code> + 语法高亮
行内代码 → <code>
数学公式 → KaTeX / MathJax
表格 → <table> (可横向滚动)
图片 → <img> (需 lazy load)
链接 → <a> (安全处理)
Mermaid 图表 → SVG (流程图/时序图)
引用块 → <blockquote>
列表 → <ul>/<ol> (可嵌套)
分割线 → <hr>
HTML 标签 → 需要安全过滤统一渲染引擎
tsx
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import { lazy, Suspense } from 'react'
const MermaidChart = lazy(() => import('./MermaidChart'))
const CodeBlock = lazy(() => import('./CodeBlock'))
function AIContentRenderer({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
rehypeKatex,
rehypeRaw,
rehypeSanitize,
]}
components={{
code({ className, children }) {
const lang = className?.replace('language-', '')
const code = String(children).replace(/\n$/, '')
if (lang === 'mermaid') {
return (
<Suspense fallback={<div>加载图表...</div>}>
<MermaidChart chart={code} />
</Suspense>
)
}
if (lang) {
return (
<Suspense fallback={<pre>{code}</pre>}>
<CodeBlock language={lang} code={code} />
</Suspense>
)
}
return <code className="inline-code">{children}</code>
},
img({ src, alt }) {
return (
<figure>
<img
src={src}
alt={alt}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder.png'
}}
/>
{alt && <figcaption>{alt}</figcaption>}
</figure>
)
},
table({ children }) {
return (
<div className="table-scroll-wrapper">
<table>{children}</table>
</div>
)
},
a({ href, children }) {
const isExternal = href?.startsWith('http')
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
>
{children}
{isExternal && ' ↗'}
</a>
)
},
}}
>
{content}
</ReactMarkdown>
)
}Mermaid 图表渲染
tsx
import { useEffect, useRef } from 'react'
import mermaid from 'mermaid'
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'strict',
})
function MermaidChart({ chart }: { chart: string }) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const id = `mermaid-${crypto.randomUUID().slice(0, 8)}`
mermaid
.render(id, chart)
.then(({ svg }) => {
if (containerRef.current) {
containerRef.current.innerHTML = svg
}
})
.catch(() => {
if (containerRef.current) {
containerRef.current.innerHTML = `<pre class="mermaid-error">${chart}</pre>`
}
})
}, [chart])
return <div ref={containerRef} className="mermaid-container" />
}追问延伸
- Mermaid 在流式渲染中如何处理(代码块不完整时不渲染)?
- 如何实现图片的"放大查看"功能?lightbox 方案选型?
- HTML 安全过滤的最佳实践?哪些标签/属性需要白名单?
17. AI 应用中的并发请求控制:多个对话窗口 / 同时发送多条消息如何管理? ⭐⭐
复杂 AI 应用中,并发是不可避免的问题。
考察点:并发控制
场景
场景 1: 多会话并发
会话 A 正在流式接收 → 用户切换到会话 B → 发送新消息
→ 两个流式请求同时进行
场景 2: 快速连续发送
用户快速按两次回车 → 两条消息几乎同时发出
→ 第二条可能在第一条回复完成前就发送了
场景 3: 重新生成
AI 正在回复 → 用户点击"重新生成"
→ 需要取消当前请求,发起新请求请求管理器
typescript
class AIRequestManager {
private activeRequests = new Map<string, AbortController>()
private requestQueue = new Map<string, Array<() => Promise<void>>>()
private maxConcurrent: number
constructor(maxConcurrent = 3) {
this.maxConcurrent = maxConcurrent
}
async send(
conversationId: string,
request: (signal: AbortSignal) => Promise<void>
) {
// 同一会话内,取消之前的未完成请求
this.cancelConversation(conversationId)
// 全局并发限制
if (this.activeRequests.size >= this.maxConcurrent) {
await new Promise<void>(resolve => {
const queue = this.requestQueue.get('__global__') ?? []
queue.push(async () => resolve())
this.requestQueue.set('__global__', queue)
})
}
const controller = new AbortController()
this.activeRequests.set(conversationId, controller)
try {
await request(controller.signal)
} finally {
this.activeRequests.delete(conversationId)
this.processQueue()
}
}
cancelConversation(conversationId: string) {
const controller = this.activeRequests.get(conversationId)
if (controller) {
controller.abort()
this.activeRequests.delete(conversationId)
}
}
cancelAll() {
for (const [id, controller] of this.activeRequests) {
controller.abort()
}
this.activeRequests.clear()
}
private processQueue() {
const queue = this.requestQueue.get('__global__')
if (queue && queue.length > 0) {
const next = queue.shift()!
next()
}
}
getActiveCount(): number {
return this.activeRequests.size
}
isConversationActive(conversationId: string): boolean {
return this.activeRequests.has(conversationId)
}
}
const aiRequests = new AIRequestManager(3)防抖发送
typescript
function useDebouncedSend(delay = 300) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const debouncedSend = useCallback(
(sendFn: () => Promise<void>) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(async () => {
await sendFn()
}, delay)
},
[delay]
)
return debouncedSend
}追问延伸
- 如何实现"排队发送"(等上一条回复完再发下一条)?
- 多标签页之间的请求协调(避免同一用户在不同标签页重复请求)?
- 限流 429 错误的全局处理策略?
18. AI 应用的错误处理策略:网络超时 / 模型限流(429) / 内容审核拒绝如何优雅处理? ⭐⭐
AI 应用的错误类型比传统应用多得多。
考察点:错误处理
AI 应用常见错误
┌──────────────────────────────────────────────────────┐
│ 错误类型 │ HTTP 状态 │ 原因 │
├──────────────────────────────────────────────────────┤
│ 认证失败 │ 401 │ API Key 无效/过期 │
│ 额度不足 │ 402 │ 余额不足 │
│ 权限不足 │ 403 │ 模型/功能未开通 │
│ 模型不存在 │ 404 │ 模型名拼写错误 │
│ 请求限流 │ 429 │ TPM/RPM 超限 │
│ 输入内容违规 │ 400 │ 触发内容审核 │
│ 上下文超长 │ 400 │ 超出模型上下文窗口 │
│ 服务端错误 │ 500/503 │ 模型服务不可用 │
│ 网络超时 │ - │ 响应超时/断网 │
│ 流式中断 │ - │ SSE 连接断开 │
└──────────────────────────────────────────────────────┘统一错误处理
typescript
class AIError extends Error {
constructor(
message: string,
public code: string,
public status?: number,
public retryable: boolean = false,
public retryAfter?: number,
) {
super(message)
this.name = 'AIError'
}
static fromResponse(response: Response, body?: any): AIError {
const { status } = response
switch (status) {
case 401:
return new AIError('认证失败,请检查 API 配置', 'AUTH_ERROR', status)
case 402:
return new AIError('API 额度不足,请充值', 'QUOTA_ERROR', status)
case 429: {
const retryAfter = parseInt(response.headers.get('retry-after') ?? '60')
return new AIError(
`请求过于频繁,${retryAfter}秒后重试`,
'RATE_LIMIT', status, true, retryAfter
)
}
case 400:
if (body?.error?.code === 'content_policy_violation') {
return new AIError('内容不符合使用规范', 'CONTENT_FILTER', status)
}
if (body?.error?.code === 'context_length_exceeded') {
return new AIError('对话过长,请开启新对话', 'CONTEXT_LENGTH', status)
}
return new AIError(body?.error?.message ?? '请求参数错误', 'BAD_REQUEST', status)
case 500:
case 503:
return new AIError('AI 服务暂时不可用', 'SERVICE_ERROR', status, true, 10)
default:
return new AIError(`请求失败 (${status})`, 'UNKNOWN', status)
}
}
}
// 带重试的请求
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3
): Promise<Response> {
let lastError: AIError | null = null
for (let i = 0; i <= maxRetries; i++) {
try {
const response = await fetch(url, options)
if (response.ok) return response
const body = await response.json().catch(() => null)
lastError = AIError.fromResponse(response, body)
if (!lastError.retryable || i === maxRetries) throw lastError
const delay = lastError.retryAfter
? lastError.retryAfter * 1000
: Math.min(1000 * 2 ** i, 30000)
await new Promise(r => setTimeout(r, delay))
} catch (err) {
if (err instanceof AIError) throw err
if (i === maxRetries) {
throw new AIError('网络连接失败', 'NETWORK_ERROR', undefined, true)
}
await new Promise(r => setTimeout(r, 1000 * 2 ** i))
}
}
throw lastError!
}用户友好的错误展示
tsx
function AIErrorBanner({ error, onRetry, onNewChat }: ErrorProps) {
const getErrorUI = (error: AIError) => {
switch (error.code) {
case 'RATE_LIMIT':
return {
icon: '⏳',
message: error.message,
action: <CountdownRetryButton seconds={error.retryAfter!} onRetry={onRetry} />,
}
case 'CONTEXT_LENGTH':
return {
icon: '📏',
message: '对话内容过长',
action: <button onClick={onNewChat}>开启新对话</button>,
}
case 'CONTENT_FILTER':
return {
icon: '🚫',
message: '内容不符合使用规范,请修改后重试',
action: null,
}
case 'NETWORK_ERROR':
return {
icon: '📡',
message: '网络连接失败',
action: <button onClick={onRetry}>重试</button>,
}
default:
return {
icon: '❌',
message: error.message,
action: <button onClick={onRetry}>重试</button>,
}
}
}
const ui = getErrorUI(error)
return (
<div className="error-banner">
<span>{ui.icon}</span>
<span>{ui.message}</span>
{ui.action}
</div>
)
}追问延伸
- 流式传输中途断开(SSE 连接中断)如何恢复?能从断点续传吗?
- 多模型降级:主模型 429 → 自动切备用模型怎么实现?
- 如何区分是"AI 拒绝回答"还是"API 错误"?
19. 如何实现 AI 对话的"停止生成"功能?AbortController 的使用? ⭐⭐
用户体验的关键:让用户能随时中断 AI。
考察点:取消请求
AbortController 基础
typescript
const controller = new AbortController()
// 发起请求时传入 signal
fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
signal: controller.signal, // ← 关键
})
// 用户点击"停止生成"
controller.abort()
// fetch 会抛出 AbortError
// reader.read() 也会抛出 AbortError完整的停止生成实现
tsx
function useStoppableChat() {
const [messages, setMessages] = useState<Message[]>([])
const [isLoading, setIsLoading] = useState(false)
const controllerRef = useRef<AbortController | null>(null)
const fullContentRef = useRef('')
const send = useCallback(async (userInput: string) => {
const userMsg = createMessage('user', userInput)
const assistantMsg = createMessage('assistant', '')
setMessages(prev => [...prev, userMsg, assistantMsg])
setIsLoading(true)
fullContentRef.current = ''
const controller = new AbortController()
controllerRef.current = controller
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, userMsg] }),
signal: controller.signal,
})
const reader = response.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 })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
try {
const data = JSON.parse(line.slice(6))
const token = data.choices?.[0]?.delta?.content
if (token) {
fullContentRef.current += token
const content = fullContentRef.current
setMessages(prev =>
prev.map(m => m.id === assistantMsg.id ? { ...m, content } : m)
)
}
} catch {}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
// 用户主动停止 → 保留已生成的内容
setMessages(prev =>
prev.map(m =>
m.id === assistantMsg.id
? { ...m, content: fullContentRef.current + '\n\n*[已停止生成]*' }
: m
)
)
} else {
throw err
}
} finally {
setIsLoading(false)
controllerRef.current = null
}
}, [messages])
const stop = useCallback(() => {
controllerRef.current?.abort()
}, [])
return { messages, isLoading, send, stop }
}停止按钮 UX
tsx
function SendButton({ isLoading, onSend, onStop }: Props) {
if (isLoading) {
return (
<button
className="stop-button"
onClick={onStop}
title="停止生成 (Esc)"
>
<StopIcon />
停止
</button>
)
}
return (
<button className="send-button" onClick={onSend}>
<SendIcon />
</button>
)
}
// 快捷键支持
function useChatKeyboard(stop: () => void, send: () => void, isLoading: boolean) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isLoading) {
stop()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [stop, isLoading])
}追问延伸
AbortController.abort()后,服务端还会继续生成吗?如何通知服务端也停止?- 停止生成后,Token 费用怎么算?已消耗的 Token 能退吗?
- 如何实现"暂停/继续"功能(不是停止,而是暂停接收)?
20. AI 应用的前端性能监控:首 Token 时间(TTFT) / Token 生成速度 / 完整响应时间如何采集? ⭐⭐⭐
AI 应用有独特的性能指标体系。
考察点:性能指标
AI 性能指标定义
┌─────────────────────────────────────────────────────────┐
│ 指标 │ 含义 │
├─────────────────────────────────────────────────────────┤
│ TTFT (Time To First Token) │ 发送请求到收到第一个 │
│ │ Token 的时间(秒) │
│ 典型值: 0.3-2s │ │
├─────────────────────────────────────────────────────────┤
│ TPS (Tokens Per Second) │ Token 生成速度 │
│ 典型值: 30-100 tokens/s │ │
├─────────────────────────────────────────────────────────┤
│ TTLR (Time To Last Response) │ 从请求到完整响应的 │
│ │ 总时间(秒) │
├─────────────────────────────────────────────────────────┤
│ Token Count │ 输入 + 输出 Token 数 │
│ │ → 直接关联成本 │
├─────────────────────────────────────────────────────────┤
│ Error Rate │ 请求失败率 │
│ │ 429/500/超时/中断 │
└─────────────────────────────────────────────────────────┘性能采集器
typescript
interface AIMetrics {
requestId: string
model: string
ttft: number // ms
tps: number // tokens/s
ttlr: number // ms
inputTokens: number
outputTokens: number
totalTokens: number
status: 'success' | 'error' | 'aborted'
errorCode?: string
}
class AIPerformanceMonitor {
private requestStart = 0
private firstTokenTime = 0
private tokenCount = 0
private lastTokenTime = 0
startRequest() {
this.requestStart = performance.now()
this.firstTokenTime = 0
this.tokenCount = 0
this.lastTokenTime = 0
}
recordToken() {
const now = performance.now()
this.tokenCount++
if (this.tokenCount === 1) {
this.firstTokenTime = now
}
this.lastTokenTime = now
}
getMetrics(model: string, usage?: { prompt_tokens: number; completion_tokens: number }): AIMetrics {
const now = performance.now()
const ttft = this.firstTokenTime ? this.firstTokenTime - this.requestStart : 0
const streamDuration = this.lastTokenTime - this.firstTokenTime
const tps = streamDuration > 0 ? (this.tokenCount / streamDuration) * 1000 : 0
const ttlr = now - this.requestStart
return {
requestId: crypto.randomUUID(),
model,
ttft: Math.round(ttft),
tps: Math.round(tps * 10) / 10,
ttlr: Math.round(ttlr),
inputTokens: usage?.prompt_tokens ?? 0,
outputTokens: usage?.completion_tokens ?? this.tokenCount,
totalTokens: (usage?.prompt_tokens ?? 0) + (usage?.completion_tokens ?? this.tokenCount),
status: 'success',
}
}
}集成到 useChat
typescript
function useChatWithMetrics(options: UseChatOptions) {
const monitor = useRef(new AIPerformanceMonitor())
const [metrics, setMetrics] = useState<AIMetrics | null>(null)
const chat = useChat({
...options,
onResponse: () => {
monitor.current.startRequest()
},
onFinish: (message) => {
const m = monitor.current.getMetrics('gpt-4o')
setMetrics(m)
reportMetrics(m)
options.onFinish?.(message)
},
})
// 在流式回调中计数
useEffect(() => {
const lastMsg = chat.messages[chat.messages.length - 1]
if (lastMsg?.role === 'assistant' && chat.isLoading) {
monitor.current.recordToken()
}
}, [chat.messages, chat.isLoading])
return { ...chat, metrics }
}上报与可视化
typescript
async function reportMetrics(metrics: AIMetrics) {
// 1. 发送到监控平台
navigator.sendBeacon('/api/metrics', JSON.stringify(metrics))
// 2. 本地存储用于 Debug
const history = JSON.parse(localStorage.getItem('ai_metrics') ?? '[]')
history.push({ ...metrics, timestamp: Date.now() })
if (history.length > 100) history.shift()
localStorage.setItem('ai_metrics', JSON.stringify(history))
}
// Debug 面板组件
function AIMetricsPanel({ metrics }: { metrics: AIMetrics | null }) {
if (!metrics) return null
return (
<div className="metrics-panel">
<div>TTFT: {metrics.ttft}ms</div>
<div>速度: {metrics.tps} tokens/s</div>
<div>总耗时: {(metrics.ttlr / 1000).toFixed(1)}s</div>
<div>Token: {metrics.inputTokens}+{metrics.outputTokens}={metrics.totalTokens}</div>
<div>
预估成本: ${((metrics.inputTokens * 2.5 + metrics.outputTokens * 10) / 1_000_000).toFixed(4)}
</div>
</div>
)
}性能基准
好的 AI 应用性能指标:
TTFT < 1s (用户感知快)
TPS > 50 tokens/s (流畅的打字机效果)
TTLR < 10s (短回答场景)
Error Rate < 1% (稳定可靠)
需要告警的阈值:
TTFT > 3s (用户会觉得卡住了)
TPS < 10 tokens/s (明显卡顿感)
Error Rate > 5% (服务质量问题)追问延伸
- 如何基于 TTFT 实现"智能 Loading"(预估等待时间)?
- Web Vitals(LCP/FID/CLS)在 AI 应用中如何适配?
- 如何实现 AI 请求的全链路追踪(前端 → BFF → AI Provider)?
21. 前端 Prompt 模板管理系统:如何设计可维护、可复用、可测试的 Prompt 模板? ⭐⭐
Prompt 是 AI 应用的灵魂,但硬编码在代码里是灾难。
考察点:Prompt 工程化
问题
硬编码 Prompt 的问题:
// ❌ 散落在各个 API Route 里
const prompt = `你是一个${role},请用${language}回答...`
→ 修改一个词需要重新部署
→ 无法 A/B 测试不同版本
→ 团队成员各写各的,风格不一致
→ 无法追踪哪个版本效果好Prompt 模板引擎
typescript
interface PromptTemplate {
id: string
name: string
version: number
template: string
variables: VariableSchema[]
model: string
temperature: number
maxTokens: number
tags: string[]
}
interface VariableSchema {
name: string
type: 'string' | 'number' | 'array' | 'enum'
required: boolean
default?: unknown
description: string
enum?: string[]
}
class PromptManager {
private templates = new Map<string, PromptTemplate>()
register(template: PromptTemplate) {
this.templates.set(`${template.id}@${template.version}`, template)
this.templates.set(template.id, template)
}
render(id: string, variables: Record<string, unknown>, version?: number): string {
const key = version ? `${id}@${version}` : id
const template = this.templates.get(key)
if (!template) throw new Error(`Template not found: ${key}`)
this.validateVariables(template, variables)
let result = template.template
for (const [name, value] of Object.entries(variables)) {
const placeholder = `{{${name}}}`
if (Array.isArray(value)) {
result = result.replace(placeholder, value.join('\n'))
} else {
result = result.replace(placeholder, String(value))
}
}
return result
}
private validateVariables(template: PromptTemplate, variables: Record<string, unknown>) {
for (const schema of template.variables) {
if (schema.required && !(schema.name in variables)) {
throw new Error(`Missing required variable: ${schema.name}`)
}
}
}
getConfig(id: string): Pick<PromptTemplate, 'model' | 'temperature' | 'maxTokens'> {
const template = this.templates.get(id)!
return {
model: template.model,
temperature: template.temperature,
maxTokens: template.maxTokens,
}
}
}
// 注册模板
const prompts = new PromptManager()
prompts.register({
id: 'code-review',
name: '代码审查',
version: 2,
template: `你是一名资深前端工程师,专注于代码质量审查。
审查标准:
- 安全性(XSS/注入/敏感信息泄露)
- 性能(不必要的重渲染/内存泄漏/大循环)
- 可维护性(命名/复杂度/重复代码)
- 最佳实践({{framework}} 官方推荐)
代码语言: {{language}}
框架: {{framework}}
请审查以下代码:
\`\`\`{{language}}
{{code}}
\`\`\`
输出格式: JSON 数组
[{"severity": "critical|warning|info", "line": number, "message": string, "fix": string}]`,
variables: [
{ name: 'language', type: 'string', required: true, description: '编程语言' },
{ name: 'framework', type: 'enum', required: true, description: '框架', enum: ['React', 'Vue', 'Angular', 'Svelte'] },
{ name: 'code', type: 'string', required: true, description: '待审查代码' },
],
model: 'gpt-4o',
temperature: 0.3,
maxTokens: 2000,
tags: ['code', 'review'],
})
// 使用
const systemPrompt = prompts.render('code-review', {
language: 'typescript',
framework: 'React',
code: 'const x = document.innerHTML = userInput',
})远程配置方案
typescript
class RemotePromptManager extends PromptManager {
private apiBase: string
private cache = new Map<string, { data: PromptTemplate; expiry: number }>()
constructor(apiBase: string) {
super()
this.apiBase = apiBase
}
async fetchTemplate(id: string): Promise<PromptTemplate> {
const cached = this.cache.get(id)
if (cached && cached.expiry > Date.now()) {
return cached.data
}
const response = await fetch(`${this.apiBase}/prompts/${id}`)
const template = await response.json()
this.cache.set(id, { data: template, expiry: Date.now() + 5 * 60 * 1000 })
this.register(template)
return template
}
}追问延伸
- 如何实现 Prompt 的版本回滚?
- Prompt 的单元测试怎么写?如何自动化评估 Prompt 质量?
- 如何防止 Prompt 注入(用户输入中包含恶意指令)?
22. AI 应用的 A/B 测试方案:不同 Prompt / 模型 / 参数如何对比效果? ⭐⭐⭐
Prompt 调优不能靠感觉,需要数据驱动。
考察点:A/B 测试
为什么 AI 应用需要 A/B 测试
AI 应用的"不确定性":
同一个 Prompt:
→ 换一个词 → 输出质量可能大幅变化
→ 换一个模型 → 成本/速度/质量全变
→ 改一个参数 → temperature 0.3 vs 0.7 效果天差地别
需要 A/B 测试的维度:
1. Prompt 版本 (v1 vs v2)
2. 模型选择 (gpt-4o vs claude-3.5)
3. 参数调优 (temperature / top-p / max_tokens)
4. RAG 策略 (top-3 vs top-5 / chunk size)
5. UI 交互 (流式 vs 非流式 / 有无引用)A/B 测试引擎
typescript
interface Experiment {
id: string
name: string
variants: Variant[]
trafficAllocation: number[] // 流量分配百分比
status: 'draft' | 'running' | 'completed'
startDate: string
metrics: string[]
}
interface Variant {
id: string
name: string
promptId: string
promptVersion: number
model: string
temperature: number
maxTokens: number
}
class ABTestEngine {
private experiments = new Map<string, Experiment>()
private assignments = new Map<string, string>() // userId -> variantId
assignVariant(experimentId: string, userId: string): Variant {
const cacheKey = `${experimentId}:${userId}`
const cached = this.assignments.get(cacheKey)
if (cached) {
const exp = this.experiments.get(experimentId)!
return exp.variants.find(v => v.id === cached)!
}
const experiment = this.experiments.get(experimentId)!
const hash = this.hashString(`${experimentId}:${userId}`)
const bucket = hash % 100
let cumulative = 0
let selectedVariant = experiment.variants[0]
for (let i = 0; i < experiment.variants.length; i++) {
cumulative += experiment.trafficAllocation[i]
if (bucket < cumulative) {
selectedVariant = experiment.variants[i]
break
}
}
this.assignments.set(cacheKey, selectedVariant.id)
return selectedVariant
}
private hashString(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash |= 0
}
return Math.abs(hash)
}
recordMetric(
experimentId: string,
variantId: string,
metric: string,
value: number
) {
const event = {
experimentId,
variantId,
metric,
value,
timestamp: Date.now(),
}
navigator.sendBeacon('/api/ab-metrics', JSON.stringify(event))
}
}集成到 AI 请求
typescript
const abTest = new ABTestEngine()
async function sendChatWithABTest(
messages: Message[],
userId: string
) {
const variant = abTest.assignVariant('prompt-v2-test', userId)
const prompt = prompts.render(variant.promptId, variables, variant.promptVersion)
const startTime = performance.now()
const response = await streamChat({
model: variant.model,
temperature: variant.temperature,
maxTokens: variant.maxTokens,
messages: [{ role: 'system', content: prompt }, ...messages],
})
const ttft = performance.now() - startTime
abTest.recordMetric('prompt-v2-test', variant.id, 'ttft', ttft)
abTest.recordMetric('prompt-v2-test', variant.id, 'output_tokens', response.tokenCount)
return response
}用户反馈采集
tsx
function MessageFeedback({ messageId, experimentId, variantId }: Props) {
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null)
const handleFeedback = (type: 'up' | 'down') => {
setFeedback(type)
abTest.recordMetric(experimentId, variantId, 'thumbs_up', type === 'up' ? 1 : 0)
abTest.recordMetric(experimentId, variantId, 'thumbs_down', type === 'down' ? 1 : 0)
}
return (
<div className="feedback">
<button
className={feedback === 'up' ? 'active' : ''}
onClick={() => handleFeedback('up')}
>
👍
</button>
<button
className={feedback === 'down' ? 'active' : ''}
onClick={() => handleFeedback('down')}
>
👎
</button>
</div>
)
}效果评估
评估指标:
┌──────────────────────────────────────────────────────┐
│ 指标类型 │ 具体指标 │
├──────────────────────────────────────────────────────┤
│ 质量指标 │ 👍率、完成率、重新生成率 │
│ 性能指标 │ TTFT、TPS、总耗时 │
│ 成本指标 │ 平均 Token 数、单次请求成本 │
│ 参与指标 │ 对话轮次、会话时长 │
└──────────────────────────────────────────────────────┘
统计显著性: 通常需要每个变体至少 1000 次请求追问延伸
- 如何保证 A/B 测试的用户分组是稳定的(同一用户始终在同一组)?
- AI 输出的质量如何量化评估?可以用 LLM 评估 LLM 吗?
- 多臂老虎机(MAB)比传统 A/B 测试更适合 AI 场景吗?
23. 如何设计一个可复用的 Chat 组件库?API 设计 / 主题定制 / 插件系统? ⭐⭐⭐
从业务组件提炼为通用组件库的架构设计能力。
考察点:组件库设计
组件拆分
Chat 组件库拆分:
┌──────────────────────────────────────────────────────┐
│ ChatProvider (Context: 配置 / 主题 / 国际化) │
│ ├── ChatContainer (布局容器) │
│ │ ├── ChatHeader (标题 / 模型选择 / 设置) │
│ │ ├── MessageList (消息列表 + 虚拟滚动) │
│ │ │ ├── MessageBubble (单条消息) │
│ │ │ │ ├── Avatar (头像) │
│ │ │ │ ├── MessageContent (内容渲染) │
│ │ │ │ │ ├── MarkdownRenderer │
│ │ │ │ │ ├── CodeBlock │
│ │ │ │ │ ├── ToolCallCard │
│ │ │ │ │ └── ThinkingIndicator │
│ │ │ │ ├── MessageActions (复制/重试/反馈) │
│ │ │ │ └── MessageMeta (时间/Token) │
│ │ │ └── ScrollToBottom (回到底部按钮) │
│ │ ├── ChatInput (输入区域) │
│ │ │ ├── TextArea (自适应高度) │
│ │ │ ├── FileUpload (附件上传) │
│ │ │ ├── ModelSelector (模型切换) │
│ │ │ └── SendButton / StopButton │
│ │ └── ChatFooter (免责声明 / Token 统计) │
│ └── ChatSidebar (会话列表 / 搜索) │
└──────────────────────────────────────────────────────┘API 设计
tsx
// 最简使用
<Chat
api="/api/chat"
placeholder="输入消息..."
/>
// 完整配置
<ChatProvider
theme={customTheme}
locale="zh-CN"
plugins={[markdownPlugin, codeHighlightPlugin, mermaidPlugin]}
>
<Chat
api="/api/chat"
model="gpt-4o"
systemPrompt="你是一个前端助手"
initialMessages={[]}
// 回调
onSend={(message) => analytics.track('message_sent')}
onFinish={(message) => analytics.track('message_received')}
onError={(error) => toast.error(error.message)}
// UI 配置
showAvatar={true}
showTimestamp={true}
showTokenCount={true}
showModelSelector={true}
enableFileUpload={true}
maxFileSize={10 * 1024 * 1024}
// 渲染定制
renderMessage={(message) => <CustomMessage message={message} />}
renderInput={(props) => <CustomInput {...props} />}
renderToolCall={(tool) => <CustomToolCard tool={tool} />}
// 行为配置
autoScroll={true}
maxMessages={100}
streamingEnabled={true}
/>
</ChatProvider>主题系统
typescript
interface ChatTheme {
colors: {
primary: string
background: string
surface: string
text: string
textSecondary: string
border: string
userBubble: string
userBubbleText: string
assistantBubble: string
assistantBubbleText: string
error: string
success: string
}
typography: {
fontFamily: string
fontSize: { sm: string; md: string; lg: string }
lineHeight: number
codeFont: string
}
spacing: {
messagePadding: string
messageGap: string
containerPadding: string
}
borderRadius: {
bubble: string
input: string
button: string
}
shadows: {
bubble: string
input: string
}
}
const defaultTheme: ChatTheme = {
colors: {
primary: '#6366f1',
background: '#ffffff',
surface: '#f9fafb',
text: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
userBubble: '#6366f1',
userBubbleText: '#ffffff',
assistantBubble: '#f3f4f6',
assistantBubbleText: '#111827',
error: '#ef4444',
success: '#22c55e',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: { sm: '0.875rem', md: '1rem', lg: '1.125rem' },
lineHeight: 1.6,
codeFont: '"Fira Code", "JetBrains Mono", monospace',
},
spacing: {
messagePadding: '12px 16px',
messageGap: '16px',
containerPadding: '16px',
},
borderRadius: {
bubble: '12px',
input: '12px',
button: '8px',
},
shadows: {
bubble: '0 1px 2px rgba(0,0,0,0.05)',
input: '0 1px 3px rgba(0,0,0,0.1)',
},
}
const darkTheme: ChatTheme = {
...defaultTheme,
colors: {
...defaultTheme.colors,
background: '#0f172a',
surface: '#1e293b',
text: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
assistantBubble: '#1e293b',
assistantBubbleText: '#f1f5f9',
},
}插件系统
typescript
interface ChatPlugin {
name: string
setup: (context: PluginContext) => void
}
interface PluginContext {
registerRenderer: (type: string, component: React.ComponentType<any>) => void
registerAction: (name: string, handler: ActionHandler) => void
onBeforeSend: (hook: (message: Message) => Message | null) => void
onAfterReceive: (hook: (message: Message) => Message) => void
}
// Mermaid 图表插件
const mermaidPlugin: ChatPlugin = {
name: 'mermaid',
setup(ctx) {
ctx.registerRenderer('mermaid', MermaidChart)
},
}
// 消息加密插件
const encryptionPlugin: ChatPlugin = {
name: 'encryption',
setup(ctx) {
ctx.onBeforeSend((msg) => ({
...msg,
content: encrypt(msg.content),
}))
ctx.onAfterReceive((msg) => ({
...msg,
content: decrypt(msg.content),
}))
},
}
// 敏感词过滤插件
const filterPlugin: ChatPlugin = {
name: 'content-filter',
setup(ctx) {
ctx.onBeforeSend((msg) => {
if (containsSensitiveWords(msg.content)) {
toast.warning('消息包含敏感内容,已过滤')
return null
}
return msg
})
},
}追问延伸
- Headless UI(无样式组件)和带样式组件,Chat 组件库应该选哪种?
- 如何实现组件库的 Tree Shaking(按需引入)?
- 组件库的版本管理和发布流程怎么设计?
24. AI 对话应用的国际化方案:多语言 System Prompt + UI 国际化 + 模型输出语言控制? ⭐⭐
AI 应用的国际化比传统应用多了一层:模型输出语言。
考察点:国际化
AI 应用国际化的三层
┌──────────────────────────────────────────────────────┐
│ Layer 1: UI 国际化(和传统应用一样) │
│ ├─ 按钮/标签/提示文字 │
│ ├─ 日期/数字格式 │
│ └─ 布局方向 (LTR / RTL) │
├──────────────────────────────────────────────────────┤
│ Layer 2: System Prompt 国际化 │
│ ├─ 不同语言的 Prompt 模板 │
│ ├─ 示例内容本地化 (Few-Shot) │
│ └─ 输出格式本地化 │
├──────────────────────────────────────────────────────┤
│ Layer 3: 模型输出语言控制 │
│ ├─ 强制模型用指定语言回答 │
│ ├─ 混合语言处理(代码+解释) │
│ └─ 多语言检测与自动切换 │
└──────────────────────────────────────────────────────┘Layer 1: UI 国际化
typescript
// i18n 配置 (使用 i18next)
const resources = {
'zh-CN': {
chat: {
placeholder: '输入消息...',
send: '发送',
stop: '停止生成',
regenerate: '重新生成',
copy: '复制',
copied: '已复制',
newChat: '新对话',
clearHistory: '清空历史',
tokenUsage: '已使用 {{count}} Token',
model: '模型',
thinking: '思考中...',
error: {
network: '网络连接失败,请检查网络',
rateLimit: '请求过于频繁,{{seconds}}秒后重试',
contentFilter: '内容不符合使用规范',
},
},
},
'en-US': {
chat: {
placeholder: 'Type a message...',
send: 'Send',
stop: 'Stop generating',
regenerate: 'Regenerate',
copy: 'Copy',
copied: 'Copied',
newChat: 'New chat',
clearHistory: 'Clear history',
tokenUsage: '{{count}} tokens used',
model: 'Model',
thinking: 'Thinking...',
error: {
network: 'Network error, please check your connection',
rateLimit: 'Too many requests, retry in {{seconds}}s',
contentFilter: 'Content violates usage policy',
},
},
},
'ja-JP': {
chat: {
placeholder: 'メッセージを入力...',
send: '送信',
stop: '生成を停止',
// ...
},
},
}Layer 2: System Prompt 国际化
typescript
const systemPrompts: Record<string, string> = {
'zh-CN': `你是一个专业的前端开发助手。
请用中文回答所有问题。
代码注释使用中文。
专业术语首次出现时标注英文原文,如:闭包(Closure)。`,
'en-US': `You are a professional frontend development assistant.
Answer all questions in English.
Use English for code comments.`,
'ja-JP': `あなたはプロフェッショナルなフロントエンド開発アシスタントです。
すべての質問に日本語で回答してください。
コードコメントは日本語を使用してください。`,
}
function getSystemPrompt(locale: string, basePrompt: string): string {
const languageInstruction = systemPrompts[locale] ?? systemPrompts['en-US']
return `${languageInstruction}\n\n${basePrompt}`
}Layer 3: 模型输出语言控制
typescript
function buildMessages(
userMessages: Message[],
locale: string
): Message[] {
const languageMap: Record<string, string> = {
'zh-CN': '请始终使用简体中文回答',
'en-US': 'Always respond in English',
'ja-JP': '常に日本語で回答してください',
'ko-KR': '항상 한국어로 대답해 주세요',
}
const instruction = languageMap[locale]
return [
{
role: 'system',
content: getSystemPrompt(locale, businessPrompt),
},
...userMessages,
...(instruction ? [{
role: 'system' as const,
content: `[语言提醒] ${instruction}`,
}] : []),
]
}自动语言检测
typescript
function detectLanguage(text: string): string {
const patterns: [RegExp, string][] = [
[/[\u4e00-\u9fff]/, 'zh-CN'],
[/[\u3040-\u309f\u30a0-\u30ff]/, 'ja-JP'],
[/[\uac00-\ud7af]/, 'ko-KR'],
[/[\u0400-\u04ff]/, 'ru-RU'],
[/[\u0600-\u06ff]/, 'ar-SA'],
]
for (const [pattern, locale] of patterns) {
if (pattern.test(text)) return locale
}
return 'en-US'
}
// 自动匹配用户输入语言
function useAutoLocale(messages: Message[]) {
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
if (lastUserMsg) {
return detectLanguage(lastUserMsg.content)
}
return navigator.language
}追问延伸
- 多语言 Few-Shot 示例怎么管理?每种语言都要准备不同的示例吗?
- RTL 语言(阿拉伯语/希伯来语)对 Chat UI 的布局影响?
- 模型对不同语言的生成质量差异怎么处理?
25. 浏览器端 AI 推理:WebLLM / Transformers.js / ONNX Runtime Web 的前端实战? ⭐⭐⭐
不调 API,直接在浏览器里运行 AI 模型。
考察点:端侧 AI
为什么要在浏览器端运行 AI
优势:
✅ 隐私: 数据不出设备
✅ 离线: 无需网络
✅ 低延迟: 无网络往返
✅ 零成本: 不消耗 API 额度
✅ 无限制: 不受 API 限流
限制:
❌ 模型大小受限(通常 < 4B 参数)
❌ 首次加载时间长(下载模型 1-4GB)
❌ 推理速度受设备性能影响
❌ 浏览器内存限制
❌ 老设备/低端手机体验差方案对比
┌──────────────────────────────────────────────────────┐
│ 方案 │ 后端引擎 │ 适用场景 │
├──────────────────────────────────────────────────────┤
│ WebLLM │ WebGPU │ 完整 LLM 对话 │
│ │ (MLC) │ (需 WebGPU 浏览器) │
├──────────────────────────────────────────────────────┤
│ Transformers.js │ ONNX Runtime│ NLP 任务 │
│ (@huggingface) │ Web │ (分类/嵌入/摘要等) │
├──────────────────────────────────────────────────────┤
│ ONNX Runtime Web │ WebAssembly │ 通用模型推理 │
│ │ / WebGPU │ (自定义 ONNX 模型) │
├──────────────────────────────────────────────────────┤
│ MediaPipe │ TFLite │ 视觉/手势/人脸 │
│ (Google) │ WebAssembly │ │
└──────────────────────────────────────────────────────┘WebLLM:浏览器端运行完整 LLM
typescript
import { CreateMLCEngine, MLCEngine } from '@mlc-ai/web-llm'
class BrowserLLM {
private engine: MLCEngine | null = null
async init(
modelId: string = 'Llama-3.2-1B-Instruct-q4f16_1-MLC',
onProgress?: (progress: { text: string; progress: number }) => void
) {
this.engine = await CreateMLCEngine(modelId, {
initProgressCallback: (report) => {
onProgress?.({
text: report.text,
progress: report.progress,
})
},
})
}
async chat(messages: Array<{ role: string; content: string }>) {
if (!this.engine) throw new Error('Engine not initialized')
const reply = await this.engine.chat.completions.create({
messages: messages as any,
temperature: 0.7,
max_tokens: 512,
})
return reply.choices[0].message.content
}
async *chatStream(messages: Array<{ role: string; content: string }>) {
if (!this.engine) throw new Error('Engine not initialized')
const chunks = await this.engine.chat.completions.create({
messages: messages as any,
temperature: 0.7,
max_tokens: 512,
stream: true,
})
for await (const chunk of chunks) {
const token = chunk.choices[0]?.delta?.content
if (token) yield token
}
}
async unload() {
await this.engine?.unload()
this.engine = null
}
}React 集成
tsx
function useLocalLLM(modelId?: string) {
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [progress, setProgress] = useState(0)
const [progressText, setProgressText] = useState('')
const llmRef = useRef(new BrowserLLM())
const load = useCallback(async () => {
setStatus('loading')
try {
await llmRef.current.init(modelId, ({ text, progress }) => {
setProgress(progress)
setProgressText(text)
})
setStatus('ready')
} catch (err) {
setStatus('error')
console.error('Failed to load model:', err)
}
}, [modelId])
const generate = useCallback(async function* (
messages: Array<{ role: string; content: string }>
) {
if (status !== 'ready') throw new Error('Model not loaded')
yield* llmRef.current.chatStream(messages)
}, [status])
useEffect(() => {
return () => {
llmRef.current.unload()
}
}, [])
return { status, progress, progressText, load, generate }
}
function LocalChatPage() {
const { status, progress, progressText, load, generate } = useLocalLLM()
const [messages, setMessages] = useState<Message[]>([])
if (status === 'idle') {
return (
<div className="load-prompt">
<h2>浏览器端 AI 对话</h2>
<p>模型将下载到本地(约 800MB),数据完全不出设备</p>
<button onClick={load}>加载模型</button>
</div>
)
}
if (status === 'loading') {
return (
<div className="loading">
<div className="progress-bar">
<div style={{ width: `${progress * 100}%` }} />
</div>
<p>{progressText}</p>
<p>{Math.round(progress * 100)}%</p>
</div>
)
}
return <ChatView messages={messages} generate={generate} />
}Transformers.js:轻量级 NLP 任务
typescript
import { pipeline, env } from '@huggingface/transformers'
env.allowLocalModels = false
// 文本分类
const classifier = await pipeline('sentiment-analysis', 'Xenova/distilbert-base-uncased-finetuned-sst-2-english')
const result = await classifier('I love this product!')
// [{ label: 'POSITIVE', score: 0.9998 }]
// 文本嵌入(用于本地语义搜索)
const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
const embedding = await embedder('React Hooks 教程', { pooling: 'mean', normalize: true })
// Float32Array(384) [0.012, -0.034, ...]
// 文本生成
const generator = await pipeline('text2text-generation', 'Xenova/flan-t5-small')
const output = await generator('Translate to Chinese: Hello World')
// [{ generated_text: '你好世界' }]
// 零样本分类
const zeroShot = await pipeline('zero-shot-classification', 'Xenova/nli-deberta-v3-xsmall')
const classification = await zeroShot('I want to return this order', {
candidate_labels: ['refund', 'shipping', 'payment', 'other'],
})
// { labels: ['refund', ...], scores: [0.95, ...] }实际应用场景
浏览器端 AI 适合的场景:
┌──────────────────────────────────────────────────────┐
│ 场景 │ 方案 │ 模型大小 │
├──────────────────────────────────────────────────────┤
│ 输入框智能补全 │ Transformers.js │ ~50MB │
│ 离线文档问答 │ WebLLM (1B) │ ~800MB │
│ 客服意图分类 │ Transformers.js │ ~100MB │
│ 隐私敏感表单分析 │ WebLLM (3B) │ ~2GB │
│ 实时情感分析 │ Transformers.js │ ~50MB │
│ 本地语义搜索 │ Transformers.js │ ~30MB │
│ 图像分类/OCR │ Transformers.js │ ~100MB │
└──────────────────────────────────────────────────────┘
混合方案(推荐):
在线时 → 调用云端 GPT-4o(质量高)
离线时 → 自动切换到本地 Llama 3.2 1B(有总比没有好)
隐私数据 → 强制使用本地模型WebGPU 检测
typescript
async function checkWebGPU(): Promise<{
supported: boolean
adapter?: GPUAdapterInfo
}> {
if (!navigator.gpu) {
return { supported: false }
}
try {
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) return { supported: false }
const info = await adapter.requestAdapterInfo()
return { supported: true, adapter: info }
} catch {
return { supported: false }
}
}
// 根据设备能力选择方案
async function selectAIPlan(): Promise<'cloud' | 'webllm' | 'transformers'> {
const { supported } = await checkWebGPU()
if (supported) {
const memory = (navigator as any).deviceMemory ?? 4
return memory >= 4 ? 'webllm' : 'transformers'
}
return navigator.onLine ? 'cloud' : 'transformers'
}追问延伸
- WebGPU 的浏览器兼容性现状?不支持 WebGPU 的设备怎么降级?
- 模型下载的缓存策略?Service Worker + Cache API 能缓存 GB 级模型吗?
- WebLLM 的量化精度(q4f16 / q4f32 / q8)对输出质量的影响?