设计 AI Agent 的前端架构
问题
如何设计一个支持多轮对话、流式输出、工具调用(Tool Use)和多模态交互的 AI Agent 前端架构?
答案
AI Agent 前端不同于传统 CRUD 应用——它需要处理流式数据渲染、复杂的异步状态流转、工具调用的中间态展示和多模态内容(文本/代码/图片/图表)混排。本文以 ChatGPT / Claude / Cursor 等产品为参考,系统性地讲解 AI Agent 前端的架构设计。
一、整体架构
| 层级 | 职责 | 关键技术 |
|---|---|---|
| UI 层 | 对话界面、输入框、工具面板 | React 组件、虚拟列表 |
| 状态管理层 | 会话、消息、Agent 状态 | Zustand / Redux |
| 消息处理层 | 流式解析、Token 拼接、工具调用处理 | SSE Parser、状态机 |
| 通信层 | 与后端通信 | SSE、WebSocket、fetch |
| 渲染引擎 | Markdown、代码高亮、图表 | react-markdown、Shiki、Mermaid |
二、流式输出(Streaming)
AI Agent 的核心体验是逐 Token 输出。前端需要实时接收并渲染,而非等待完整响应。
SSE(Server-Sent Events)方案
async function* streamChat(
messages: Message[],
signal?: AbortSignal
): AsyncGenerator<StreamChunk> {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
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;
const chunk: StreamChunk = JSON.parse(data);
yield chunk;
}
}
}
流式消息状态管理
interface Message {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
status: 'pending' | 'streaming' | 'complete' | 'error';
toolCalls?: ToolCall[]; // 工具调用
toolResults?: ToolResult[]; // 工具返回
reasoning?: string; // 思维链(thinking)
attachments?: Attachment[]; // 多模态附件
createdAt: number;
}
interface ChatStore {
conversations: Map<string, Conversation>;
activeConversationId: string | null;
streamingMessageId: string | null;
sendMessage: (content: string, attachments?: File[]) => Promise<void>;
stopGeneration: () => void;
retryMessage: (messageId: string) => void;
}
const useChatStore = create<ChatStore>((set, get) => ({
conversations: new Map(),
activeConversationId: null,
streamingMessageId: null,
sendMessage: async (content, attachments) => {
const conversationId = get().activeConversationId;
if (!conversationId) return;
// 1. 添加用户消息
const userMessage: Message = {
id: nanoid(),
role: 'user',
content,
status: 'complete',
attachments: attachments?.map(fileToAttachment),
createdAt: Date.now(),
};
addMessage(conversationId, userMessage);
// 2. 创建 AI 占位消息
const assistantMessage: Message = {
id: nanoid(),
role: 'assistant',
content: '',
status: 'streaming',
createdAt: Date.now(),
};
addMessage(conversationId, assistantMessage);
set({ streamingMessageId: assistantMessage.id });
// 3. 流式接收
const abortController = new AbortController();
try {
const messages = getConversationMessages(conversationId);
for await (const chunk of streamChat(messages, abortController.signal)) {
handleStreamChunk(conversationId, assistantMessage.id, chunk);
}
updateMessageStatus(conversationId, assistantMessage.id, 'complete');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
updateMessageStatus(conversationId, assistantMessage.id, 'complete');
} else {
updateMessageStatus(conversationId, assistantMessage.id, 'error');
}
} finally {
set({ streamingMessageId: null });
}
},
stopGeneration: () => {
abortController?.abort();
},
}));
流式渲染时每个 Token 都会触发 set(),高频更新可能导致性能问题。优化手段:
- 批量更新 — 积攒 16ms 内的 Token 再一次性更新(
requestAnimationFrame) - 选择性订阅 — 只订阅当前可见消息的变化(Zustand
selector) - 虚拟列表 — 长对话用虚拟滚动,只渲染可见区域
三、工具调用(Tool Use / Function Calling)
Agent 的核心能力是调用外部工具(搜索、代码执行、文件操作等)。前端需要处理工具调用的中间状态和结果展示。
工具调用状态机
工具调用数据结构
interface ToolCall {
id: string;
name: string; // 工具名:'web_search' | 'code_exec' | 'file_read'
arguments: Record<string, unknown>;
status: 'calling' | 'executing' | 'success' | 'error';
result?: ToolResult;
}
interface ToolResult {
content: string | object;
duration?: number; // 执行耗时
metadata?: Record<string, unknown>;
}
// 流式 Chunk 中的工具调用事件
type StreamChunk =
| { type: 'text_delta'; content: string }
| { type: 'thinking_delta'; content: string }
| { type: 'tool_call_start'; toolCall: { id: string; name: string } }
| { type: 'tool_call_delta'; id: string; arguments: string }
| { type: 'tool_call_end'; id: string }
| { type: 'tool_result'; id: string; result: ToolResult }
| { type: 'done' };
工具调用 UI
function ToolCallBlock({ toolCall }: { toolCall: ToolCall }) {
const [expanded, setExpanded] = useState(false);
const statusIcon = {
calling: <Spinner size="sm" />,
executing: <Spinner size="sm" />,
success: <CheckIcon className="text-green-500" />,
error: <XIcon className="text-red-500" />,
}[toolCall.status];
return (
<div className="tool-call-block">
<button
className="tool-header"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{statusIcon}
<span className="tool-name">{getToolDisplayName(toolCall.name)}</span>
{toolCall.result?.duration && (
<span className="tool-duration">{toolCall.result.duration}ms</span>
)}
</button>
{expanded && (
<div className="tool-detail">
<div className="tool-input">
<CodeBlock language="json">
{JSON.stringify(toolCall.arguments, null, 2)}
</CodeBlock>
</div>
{toolCall.result && (
<div className="tool-output">
<ToolResultRenderer result={toolCall.result} toolName={toolCall.name} />
</div>
)}
</div>
)}
</div>
);
}
四、消息渲染引擎
AI 输出包含多种内容类型混排:纯文本、Markdown、代码块、数学公式、Mermaid 图表、图片等。需要一个灵活的渲染引擎。
内容解析与渲染
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';
const MessageContent = memo(({ content, isStreaming }: Props) => {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// 代码块:语法高亮 + 复制按钮
code({ className, children, ...props }) {
const language = className?.replace('language-', '');
if (!language) return <code {...props}>{children}</code>;
// Mermaid 图表
if (language === 'mermaid') {
return <MermaidRenderer chart={String(children)} />;
}
return (
<div className="code-block">
<div className="code-header">
<span>{language}</span>
<CopyButton text={String(children)} />
</div>
<SyntaxHighlighter language={language}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
},
// 表格增强
table({ children }) {
return (
<div className="table-wrapper overflow-x-auto">
<table>{children}</table>
</div>
);
},
}}
>
{content}
</ReactMarkdown>
);
});
流式输出时 Markdown 内容不完整,可能出现:
- 代码块只有
```开头没有结尾 → 用正则检测并临时闭合 - 表格只渲染了一半 → 暂缓渲染直到表格完整
- 数学公式不完整 →
$未闭合时不触发 KaTeX 渲染
解决方案:在 Markdown 渲染前做流式内容预处理,自动补全未闭合的标记。
流式 Markdown 预处理
function preprocessStreamingMarkdown(content: string): string {
let processed = content;
// 1. 补全未闭合的代码块
const codeBlockCount = (processed.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
processed += '\n```';
}
// 2. 补全未闭合的行内代码
const inlineCodeCount = (processed.match(/(?<!`)`(?!`)/g) || []).length;
if (inlineCodeCount % 2 !== 0) {
processed += '`';
}
// 3. 补全未闭合的粗体/斜体
const boldCount = (processed.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) {
processed += '**';
}
return processed;
}
五、Thinking / Reasoning 展示
现代 AI Agent(如 Claude、DeepSeek)支持展示思维链(Chain of Thought),前端需要将 thinking 内容与最终回答分开渲染。
function ThinkingBlock({ reasoning, isStreaming }: ThinkingBlockProps) {
const [expanded, setExpanded] = useState(false);
// 流式时自动展开,完成后可折叠
useEffect(() => {
if (isStreaming) setExpanded(true);
}, [isStreaming]);
return (
<div className="thinking-block">
<button
className="thinking-header"
onClick={() => setExpanded(!expanded)}
>
{isStreaming ? <Spinner size="sm" /> : <BrainIcon />}
<span>{isStreaming ? '思考中...' : '思考过程'}</span>
<ChevronIcon direction={expanded ? 'up' : 'down'} />
</button>
{expanded && (
<div className="thinking-content text-muted">
<MessageContent content={reasoning} isStreaming={isStreaming} />
</div>
)}
</div>
);
}
六、输入与交互
多模态输入
function ChatInput({ onSend, isStreaming }: ChatInputProps) {
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 自适应高度
useEffect(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}
}, [content]);
const handleSubmit = () => {
if (!content.trim() && attachments.length === 0) return;
onSend(content, attachments);
setContent('');
setAttachments([]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter 发送,Shift+Enter 换行
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
// 粘贴图片
const handlePaste = (e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData.files).filter(f =>
f.type.startsWith('image/')
);
if (files.length > 0) {
setAttachments(prev => [...prev, ...files]);
}
};
return (
<div className="chat-input">
{/* 附件预览 */}
{attachments.length > 0 && (
<div className="attachment-preview">
{attachments.map((file, i) => (
<AttachmentChip key={i} file={file} onRemove={() =>
setAttachments(prev => prev.filter((_, j) => j !== i))
} />
))}
</div>
)}
<div className="input-row">
<textarea
ref={textareaRef}
value={content}
onChange={e => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息..."
rows={1}
/>
{isStreaming ? (
<button onClick={stopGeneration} aria-label="停止生成">
<StopIcon />
</button>
) : (
<button onClick={handleSubmit} disabled={!content.trim()}>
<SendIcon />
</button>
)}
</div>
</div>
);
}
七、会话管理
interface Conversation {
id: string;
title: string;
messages: Message[];
model: string;
systemPrompt?: string;
tokenUsage: { prompt: number; completion: number; total: number };
createdAt: number;
updatedAt: number;
}
// 会话列表持久化
const useConversationStore = create(
persist<ConversationStore>(
(set, get) => ({
conversations: [],
createConversation: (model: string) => {
const conversation: Conversation = {
id: nanoid(),
title: '新对话',
messages: [],
model,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
};
set(state => ({
conversations: [conversation, ...state.conversations],
activeId: conversation.id,
}));
return conversation.id;
},
// 自动生成标题(用 LLM 总结第一轮对话)
generateTitle: async (conversationId: string) => {
const conv = get().conversations.find(c => c.id === conversationId);
if (!conv || conv.messages.length < 2) return;
const title = await fetch('/api/title', {
method: 'POST',
body: JSON.stringify({ messages: conv.messages.slice(0, 2) }),
}).then(r => r.text());
set(state => ({
conversations: state.conversations.map(c =>
c.id === conversationId ? { ...c, title } : c
),
}));
},
}),
{ name: 'conversations', storage: createJSONStorage(() => localStorage) }
)
);
八、性能优化
| 问题 | 方案 | 说明 |
|---|---|---|
| 高频 Token 更新 | requestAnimationFrame 批量更新 | 每帧只触发一次 re-render |
| 长对话列表 | 虚拟列表(react-window) | 只渲染可见消息 |
| Markdown 渲染开销 | React.memo + useMemo | 完成的消息不重新解析 |
| 代码高亮延迟 | 异步加载 Shiki / Prism | 不阻塞首次渲染 |
| 大消息内容 | 分段渲染 + IntersectionObserver | 超长代码块折叠 |
| 会话持久化 | IndexedDB(替代 localStorage) | 大数据量,异步不阻塞 |
function useStreamBuffer() {
const bufferRef = useRef('');
const [content, setContent] = useState('');
const rafRef = useRef<number>();
const appendToken = useCallback((token: string) => {
bufferRef.current += token;
// 每帧只更新一次,避免每个 Token 都触发 re-render
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setContent(bufferRef.current);
rafRef.current = undefined;
});
}
}, []);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
return { content, appendToken };
}
更多性能优化策略参考 React 性能优化 和 长列表优化。
九、消息列表虚拟滚动与智能滚动
AI 对话列表与普通列表的虚拟滚动有三个核心差异:每条消息高度不固定(代码块、图片、工具调用等)、流式渲染期间高度持续变化、需要底部对齐(新消息从底部出现)。
虚拟滚动方案选择
| 对比项 | react-window | react-virtuoso |
|---|---|---|
| 动态高度 | 需手动测量 + resetAfterIndex | 自动测量,开箱即用 |
| 底部对齐 | 不支持 | alignToBottom 原生支持 |
| 自动跟随新内容 | 需自己实现 | followOutput 原生支持 |
| 流式内容高度变化 | 高度缓存失效需手动处理 | ResizeObserver 自动检测 |
| 反向加载历史 | 需大量手动处理 | firstItemIndex + startReached |
推荐使用 react-virtuoso:
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
function VirtualMessageList({ messages, isStreaming }: Props) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const followOutput = useCallback(
(isAtBottom: boolean) => {
// 流式输出 + 用户在底部 → 跟随
if (isStreaming && isAtBottom) return 'smooth';
// 用户在上方浏览历史 → 不打断
return false;
},
[isStreaming]
);
return (
<Virtuoso
ref={virtuosoRef}
data={messages}
initialTopMostItemIndex={messages.length - 1}
followOutput={followOutput}
alignToBottom // 消息少时从底部排列
overscan={300} // 上下各多渲染 300px
itemContent={(index) => (
<MessageBubble
message={messages[index]}
isStreaming={isStreaming && index === messages.length - 1}
/>
)}
/>
);
}
智能滚动逻辑
function useSmartScroll(listRef: RefObject<HTMLDivElement>) {
// 用 ref 存储(高频 onScroll 不触发重渲染),按钮显隐用 state
const isAutoScrollRef = useRef(true);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceFromBottom < 100; // 阈值 100px
isAutoScrollRef.current = isNearBottom;
setShowScrollBtn(!isNearBottom);
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
listRef.current?.scrollTo({
top: listRef.current.scrollHeight,
behavior,
});
isAutoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
return { isAutoScrollRef, showScrollBtn, handleScroll, scrollToBottom };
}
- 流式期间不要用
smooth:smooth 动画有延迟,token 到达速度可能超过滚动速度,导致"追不上"出现抖动。流式期间用instant - 阈值不要太小:
< 10px太敏感,用户轻微触摸就误判为"离开底部",建议 100px isAutoScroll用useRef:onScroll 高频触发,用 useState 会造成大量无意义重渲染- 启用阈值:消息少于 50 条时直接渲染,超过再切换虚拟列表(虚拟列表的测量开销在消息少时反而更高)
十、消息持久化与多端同步
生产级 AI 对话应用需要处理客户端缓存加速和服务端持久化同步两个层面。
整体架构
核心原则:服务端是单一事实来源(Single Source of Truth),客户端只是缓存层,用于加速首屏和离线查看。
合并策略实现
import Dexie from 'dexie';
const db = new Dexie('ChatDB');
db.version(1).stores({
messages: 'id, conversationId, status, createdAt, updatedAt',
});
/**
* 加载会话消息:本地秒开 + 双向同步
*
* 关键设计:本地可能只缓存了最近 N 条消息,所以需要两个方向的同步:
* - 向后(after):拉取本地最新消息之后的增量(其他设备的新消息)
* - 向前(before):用户滚动到顶部时,按需加载更早的历史消息
*
* 不能只用 after,否则比本地最早消息更旧的历史永远拉不到。
*/
async function loadMessages(
conversationId: string,
onLocalReady: (messages: Message[]) => void,
onSynced: (messages: Message[]) => void,
) {
// 第一步:本地数据立即可用(秒开)
const localMessages = await db.table('messages')
.where('conversationId').equals(conversationId)
.sortBy('createdAt');
onLocalReady(localMessages);
// 第二步:向后增量拉取(本地最新之后的新消息)
const lastTimestamp = localMessages.at(-1)?.updatedAt ?? 0;
const newerMessages: Message[] = await fetch(
`/api/messages?conversationId=${conversationId}&after=${lastTimestamp}`
).then(r => r.json());
// 合并增量 + 写回
if (newerMessages.length > 0) {
const merged = mergeMessages(localMessages, newerMessages);
await db.table('messages').bulkPut(merged);
onSynced(merged);
}
}
/**
* 向前加载历史消息:用户滚动到顶部时触发
*
* 用 before + limit 分页,返回比指定时间戳更早的消息
* 配合 react-virtuoso 的 startReached 回调使用
*/
async function loadOlderMessages(
conversationId: string,
beforeTimestamp: number,
limit: number = 20,
): Promise<Message[]> {
const olderMessages: Message[] = await fetch(
`/api/messages?conversationId=${conversationId}&before=${beforeTimestamp}&limit=${limit}`
).then(r => r.json());
// 写入本地缓存
if (olderMessages.length > 0) {
await db.table('messages').bulkPut(olderMessages);
}
return olderMessages;
}
/**
* 消息合并:按 ID 去重,服务端数据优先
*/
function mergeMessages(local: Message[], server: Message[]): Message[] {
const map = new Map<string, Message>();
// 先放本地数据
for (const msg of local) {
map.set(msg.id, msg);
}
// 服务端数据覆盖本地(服务端是权威源)
for (const msg of server) {
const localMsg = map.get(msg.id);
if (!localMsg || msg.updatedAt > localMsg.updatedAt) {
map.set(msg.id, msg);
}
}
// 处理服务端标记删除的消息
for (const msg of server) {
if (msg.deleted) map.delete(msg.id);
}
return [...map.values()].sort((a, b) => a.createdAt - b.createdAt);
}
不同场景处理
| 场景 | 处理方式 |
|---|---|
| 正常打开 | 本地秒开 → 增量拉取 → 合并覆盖 |
| 本地有未同步消息(断网时发的) | 标记 syncStatus: 'pending',联网后上传,以服务端返回的 ID 和时间戳为准 |
| 服务端消息被编辑/删除 | 服务端 updatedAt 更新,合并时覆盖本地旧版本 |
| 流式中断的半条消息 | 本地标记 status: 'interrupted',服务端有完整版则覆盖 |
| 多设备同步 | 每次打开都拉增量,消息 ID 全局唯一(UUID),冲突时服务端赢 |
| localStorage 满 / IndexedDB 不可用 | 降级为纯服务端模式,不做本地缓存 |
- 向后同步(after):拉取本地最新消息之后的增量——用于多设备同步、离线后恢复
- 向前加载(before + limit):用户滚动到顶部时按需加载更早的历史——用于浏览历史消息
- 永远不要全量拉取,一个长会话可能有数千条消息。用时间戳游标分页,每次只拉一页
十一、异常处理与容错
AI 对话中的异常场景远比普通 Web 应用复杂——流式连接可能在任意时刻中断,LLM 服务不稳定,用户行为不可预测。
流式中断恢复
核心思路:边收边存,不要等流结束才持久化。
/**
* 流式接收时定期将内容写入 IndexedDB
* 页面关闭 / 网络断开后,下次打开可恢复
*/
function useStreamPersistence() {
const bufferRef = useRef('');
const timerRef = useRef<ReturnType<typeof setInterval>>();
const startPersistence = useCallback((messageId: string) => {
// 每 500ms 写入一次(节流,避免频繁 IO)
timerRef.current = setInterval(() => {
if (bufferRef.current) {
db.table('messages').update(messageId, {
content: bufferRef.current,
status: 'streaming',
updatedAt: Date.now(),
});
}
}, 500);
}, []);
const appendToken = useCallback((token: string) => {
bufferRef.current += token;
}, []);
const finishPersistence = useCallback(async (messageId: string) => {
if (timerRef.current) clearInterval(timerRef.current);
await db.table('messages').update(messageId, {
content: bufferRef.current,
status: 'complete',
updatedAt: Date.now(),
});
bufferRef.current = '';
}, []);
// 页面关闭前兜底写入(beforeunload 中不能用异步 API)
useEffect(() => {
const handleUnload = () => {
if (bufferRef.current) {
localStorage.setItem('stream_recovery', JSON.stringify({
content: bufferRef.current,
timestamp: Date.now(),
}));
}
};
window.addEventListener('beforeunload', handleUnload);
return () => window.removeEventListener('beforeunload', handleUnload);
}, []);
return { startPersistence, appendToken, finishPersistence };
}
恢复流程:页面打开 → 检查 IndexedDB 中 status === 'streaming' 的消息 → 展示已有内容 + "回答未完成"提示 → 提供"继续生成" / "重新生成"按钮。
"继续生成"原理:将已有内容作为 assistant prefill 发送给 API,让模型接着输出。Anthropic Claude 对 assistant prefill 支持较好,OpenAI 模型可能会重新回答而非续写。
各异常场景处理策略
| 异常 | 检测方式 | 处理策略 |
|---|---|---|
| 网络断开 | fetch 抛出 TypeError | 指数退避重试(最多 3 次),提示"网络异常" |
| SSE 中断 | reader.read() 提前返回 done | 检查消息完整性,不完整则走中断恢复 |
| 请求超时 | AbortController + setTimeout | 超时后中断,展示已有内容 + 重试按钮 |
| 429 限流 | response.status === 429 | 读取 Retry-After 头,显示倒计时 |
| 上下文溢出 | 413 或 API 报错 context_length_exceeded | 自动截断历史 / 生成摘要,提示开新对话 |
| Token 截断 | finish_reason === 'length' | 显示已有内容 + "回答被截断" + "继续生成" |
| 内容过滤 | finish_reason === 'content_filter' | 提示"无法生成",不可重试 |
| 流式中途发新消息 | 用户点击发送 | 中止当前流,保留已有内容标记为 complete |
| 快速连续点击 | 防抖检测 | 1s 内忽略重复请求 |
| 页面切到后台 | visibilitychange | 流式继续接收但暂停 UI 更新,切回时一次性刷新 |
- 永远不丢数据:流式内容边收边存,页面关闭前兜底写入
- 给用户选择权:中断后提供"继续生成" / "重新生成",不自动重试整个请求
- 区分可重试和不可重试:网络错误可重试;内容过滤、上下文溢出重试没用
- 优雅降级:IndexedDB 不可用 → 退回 localStorage;模型不支持 prefill → 退回重新生成
十二、安全防护
- Prompt 注入防御 — 对用户输入做基础过滤,不将原始用户输入拼接到系统 prompt
- XSS 防护 — AI 输出的 Markdown 可能包含恶意脚本,用
rehype-sanitize过滤 HTML - API Key 保护 — 永远不在前端暴露 API Key,通过后端代理转发
- 速率限制 — 前端限制发送频率,防止滥用(每 N 秒一条)
- 内容过滤 — 展示前检查是否包含敏感内容
function MessageErrorBoundary({ message, onRetry }: Props) {
if (message.status !== 'error') return null;
return (
<div className="error-block">
<AlertIcon />
<span>生成失败,请重试</span>
<button onClick={() => onRetry(message.id)}>
<RetryIcon /> 重试
</button>
</div>
);
}
十三、技术选型参考
| 需求 | 推荐方案 | 备选方案 |
|---|---|---|
| 框架 | Next.js App Router | Vite + React |
| 状态管理 | Zustand | Redux Toolkit |
| 通信 | SSE(fetch + ReadableStream) | WebSocket |
| Markdown | react-markdown + remark/rehype | MDX |
| 代码高亮 | Shiki(WASM) | Prism.js |
| 虚拟列表 | react-window | @tanstack/virtual |
| 样式 | Tailwind CSS + Radix UI | CSS Modules |
| 持久化 | IndexedDB(Dexie) | localStorage |
| 测试 | Vitest + React Testing Library | Jest |
常见面试问题
Q1: AI 对话场景中,前端如何实现流式输出?SSE 和 WebSocket 怎么选?
答案:
流式输出实现:使用 fetch + ReadableStream 读取 SSE 流,逐行解析 data: 字段,将 Token 实时追加到消息内容中。
SSE vs WebSocket 对比:
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向(服务端→客户端) | 双向 |
| 协议 | HTTP | ws:// |
| 自动重连 | 内置 | 需手动实现 |
| 浏览器支持 | 广泛 | 广泛 |
| 适用场景 | AI 对话(请求-响应模式) | 实时协作、多端同步 |
推荐:AI 对话用 SSE,因为本质是"一问一答"模式,SSE 基于 HTTP 更简单、天然支持重连、兼容 CDN 和代理。WebSocket 适合需要服务端主动推送的场景(如多人协作编辑)。
Q2: 流式渲染时如何避免性能问题?每个 Token 都触发 re-render 怎么办?
答案:
核心问题:LLM 输出速度可达每秒 50-100 个 Token,如果每个 Token 都 setState,会导致高频 re-render。
优化方案:
- RAF 批量更新 — 用
requestAnimationFrame将一帧内的所有 Token 合并为一次更新 - 选择性订阅 — Zustand 的
selector只订阅当前消息的content字段 - memo 隔离 — 已完成的消息用
React.memo防止被正在流式的消息连带更新 - 虚拟列表 — 长对话只渲染视口内的消息
// RAF 批量:16ms 内的 Token 合并为一次 setState
const rafId = requestAnimationFrame(() => {
setContent(buffer); // 一帧只更新一次
});
Q3: 前端如何处理 AI Agent 的工具调用(Tool Use)?
答案:
工具调用在流式输出中表现为特殊的 Chunk 类型:
tool_call_start— 收到工具名和 ID,UI 显示"正在调用 xxx"tool_call_delta— 流式接收工具参数(JSON 片段拼接)tool_call_end— 参数接收完毕,等待执行结果tool_result— 收到执行结果,展示结果摘要
前端需要维护一个工具调用状态机(calling → executing → success/error),并为每种工具类型提供专门的结果渲染组件(搜索结果卡片、代码执行输出、文件预览等)。
Q4: 流式输出中 Markdown 不完整怎么处理?比如代码块没闭合
答案:
流式渲染时 Markdown 内容随时可能截断在任意位置,导致渲染异常。解决方案是在传给 Markdown 渲染器前做预处理:
- 检测未闭合的代码块(
```奇数个),追加闭合标记 - 检测未闭合的行内代码(
`奇数个),追加闭合 - 检测未闭合的粗体/斜体(
**/*奇数个),追加闭合 - 不完整的表格暂缓渲染(检测
|行数判断)
关键是只对流式中的消息做预处理,已完成的消息直接渲染原始内容。
Q5: 如何实现对话的"停止生成"功能?
答案:
使用 AbortController:
const abortControllerRef = useRef<AbortController>();
const sendMessage = async (content: string) => {
abortControllerRef.current = new AbortController();
const response = await fetch('/api/chat', {
signal: abortControllerRef.current.signal,
});
// 流式读取...
};
const stopGeneration = () => {
abortControllerRef.current?.abort();
// 将当前消息状态从 'streaming' 改为 'complete'
// 保留已接收的内容
};
要点:1)abort 后保留已渲染内容,不清空;2)将消息状态标记为 complete;3)清理 streamingMessageId;4)让输入框恢复可用。
Q6: 如何设计 AI Agent 的会话管理?
答案:
会话管理包含:
- 数据结构 —
Conversation { id, title, messages[], model, createdAt } - 持久化 — 小数据用 localStorage(Zustand persist),大数据用 IndexedDB(Dexie)
- 自动标题 — 第一轮对话后调用 LLM 生成摘要作为标题
- Token 统计 — 累加每次请求的
usage数据,展示消耗 - 会话分叉 — 从历史消息某一条重新提问(fork),创建新分支
- 导出/分享 — 导出为 Markdown 或生成分享链接
Q7: AI 输出可能包含恶意内容(XSS),前端如何防御?
答案:
AI 输出的 Markdown 可能包含 <script>、<img onerror>、javascript: 链接等。防御措施:
- rehype-sanitize — 在 react-markdown 的 rehype 插件链中加入 HTML 消毒,只允许安全标签
- CSP 策略 — 设置 Content-Security-Policy 禁止 inline script
- 链接检查 — 只允许
http://和https://协议的链接 - 代码块隔离 — 代码只做高亮展示,不执行
- iframe 沙箱 — 如需渲染 HTML 预览,用
sandbox属性的 iframe 隔离
Q8: 如何实现 AI 对话的"思维链"(Thinking)展示?
答案:
思维链是 LLM 在回答前的推理过程。前端处理:
- 数据层 — 在 Message 类型中增加
reasoning字段,与content分开存储 - 流式处理 — 区分
thinking_delta和text_delta两种 Chunk 类型,分别追加到不同字段 - UI 展示 — 思维链用可折叠的区块展示,灰色/斜体区分视觉层级
- 交互逻辑 — 流式时自动展开,完成后默认折叠,用户可手动展开查看
Q9: 多模态消息(图片、文件、代码执行结果)如何渲染?
答案:
设计一个消息内容渲染器,根据内容类型分发到不同的渲染组件:
function MessageRenderer({ message }: { message: Message }) {
return (
<div className="message">
{/* 思维链 */}
{message.reasoning && <ThinkingBlock reasoning={message.reasoning} />}
{/* 工具调用 */}
{message.toolCalls?.map(tc => <ToolCallBlock key={tc.id} toolCall={tc} />)}
{/* 文本内容(Markdown) */}
{message.content && <MessageContent content={message.content} />}
{/* 附件(图片/文件) */}
{message.attachments?.map(a => <AttachmentRenderer key={a.id} attachment={a} />)}
</div>
);
}
关键是将消息视为多个内容块的组合,而非单一文本。每种块有独立的渲染逻辑和交互行为。
Q10: 实际项目中你会如何选择 AI Agent 前端的技术栈?
答案:
选择依据和推荐:
| 层面 | 推荐 | 原因 |
|---|---|---|
| 框架 | Next.js App Router | SSR 首屏快、API Routes 做 BFF 代理 LLM 请求 |
| 状态 | Zustand | 轻量、支持 persist、selector 精确订阅 |
| 通信 | SSE(fetch stream) | 简单、兼容性好、适合一问一答 |
| Markdown | react-markdown | 生态好、插件丰富(remark/rehype) |
| 代码高亮 | Shiki | WASM 加载、主题丰富、与 VS Code 一致 |
| 样式 | Tailwind CSS + Radix UI | 快速开发 + 无头组件高定制 |
| 持久化 | Dexie(IndexedDB) | 大数据量、异步、结构化查询 |
如果是内部工具或 MVP,可以用 Vercel AI SDK(ai 包),它封装了流式处理、工具调用、多模型切换等能力,大幅减少样板代码。
Q11: 流式渲染到一半关掉页面,下次打开内容丢失怎么办?
答案:
核心思路是边收边存,分三层防护:
- 客户端增量持久化:流式接收时每 500ms 将当前内容写入 IndexedDB,消息状态标记为
streaming - 页面关闭兜底:
beforeunload事件中用同步localStorage做最后一次写入(beforeunload 中不能用异步 IndexedDB) - 服务端持久化(生产级推荐):BFF 层转发 LLM 响应时同步落库,客户端重连后从服务端恢复
恢复流程:打开页面 → 检查 IndexedDB 中 status === 'streaming' 的消息 → 展示已有内容 + "回答未完成"提示 → 提供两个按钮:
- 继续生成:将已有内容作为 assistant prefill 发给 API,让模型接着说(Claude 支持较好)
- 重新生成:丢弃部分内容,重新请求
Q12: 前端的消息缓存和后端的消息数据如何合并?
答案:
采用服务端为权威源,客户端缓存加速的策略:
- 本地秒开:打开会话时先从 IndexedDB 读本地消息,立即渲染
- 向后增量拉取:请求
GET /api/messages?after=<本地最新时间戳>,拉取其他设备的新消息 - 向前按需加载:用户滚动到顶部时请求
GET /api/messages?before=<本地最早时间戳>&limit=20,加载更早的历史 - 合并去重:按消息 ID 去重,
updatedAt更新的覆盖旧的,服务端优先 - 写回缓存:合并结果写回 IndexedDB
注意不能只用 after——如果本地只缓存了最近 N 条,比这 N 条更早的历史消息永远拉不到。需要 before 方向按需补全。
关键原则:双向分页同步,不全量拉取;冲突时服务端赢。
Q13: AI 对话中有哪些常见异常?分别怎么处理?
答案:
分三类:
连接层:网络断开(指数退避重试 3 次)、SSE 中断(走中断恢复流程)、429 限流(读 Retry-After 头,显示倒计时)、请求超时(AbortController + 超时后展示已有内容)。
业务层:上下文窗口溢出(自动截断历史或摘要,提示开新对话)、Token 超限截断(finish_reason === 'length',显示"继续生成")、内容安全过滤(提示无法生成,不可重试)、模型 500(自动重试 1-2 次)。
用户操作:流式中途发新消息(中止当前流,保留已有内容)、快速连续点击(1s 防抖)、用户主动停止(abort() + 保留内容标记为 complete)。
核心原则:永远不丢已接收数据;区分可重试和不可重试;给用户选择权而非自动决定。
Q14: AI 对话中的虚拟滚动为什么比普通列表难?
答案:
四个特殊难点:
- 高度动态变化:流式输出期间消息高度持续增长,图片加载后高度突变,代码块折叠/展开高度改变——虚拟列表需要频繁重新计算布局
- 底部对齐:消息少时需要从底部排列(类似 IM),
react-window不支持,需要react-virtuoso的alignToBottom - 自动跟随新内容:流式输出时在底部要跟随滚动,浏览历史时不能打断——需要和智能滚动深度配合
- 反向加载历史:滚动到顶部加载更早消息,加载后滚动位置不能跳动
推荐 react-virtuoso(~15KB),它原生支持以上所有场景。建议消息少于 50 条时直接渲染,超过再切换虚拟列表。
Q15: 如何实现对话列表的智能自动滚动?
答案:
核心是判断用户意图:在底部则跟随新内容,在上方浏览历史则不打断。
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listRef.current!;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
isAutoScrollRef.current = distanceFromBottom < 100; // 阈值 100px
};
关键细节:
- 流式期间用
instant而非smooth——smooth 有动画延迟,token 快时滚动追不上内容增长 isAutoScroll用useRef(onScroll 高频触发避免无意义重渲染),showScrollButton用useState(需要触发 UI 更新)- 用户发送新消息时强制滚到底部,不管当前位置
相关链接
- AI 对话界面设计 - 组件级实现详解
- 流式渲染与 SSE - SSE 协议与流式 Markdown 渲染
- AI 应用性能优化 - TTFT/TPS 指标与渲染优化
- React 性能优化 - memo、useMemo、useCallback
- 状态管理方案
- 长列表优化 - 虚拟滚动技术
- WebSocket 与 SSE
- 前端 SDK 通用架构设计
- Vercel AI SDK
- Anthropic Streaming API
- OpenAI Function Calling
- react-virtuoso - 支持动态高度的虚拟列表