跳到主要内容

设计 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)方案

lib/stream.ts
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;
}
}
}

流式消息状态管理

stores/chat.ts
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(),高频更新可能导致性能问题。优化手段:

  1. 批量更新 — 积攒 16ms 内的 Token 再一次性更新(requestAnimationFrame
  2. 选择性订阅 — 只订阅当前可见消息的变化(Zustand selector
  3. 虚拟列表 — 长对话用虚拟滚动,只渲染可见区域

三、工具调用(Tool Use / Function Calling)

Agent 的核心能力是调用外部工具(搜索、代码执行、文件操作等)。前端需要处理工具调用的中间状态和结果展示。

工具调用状态机

工具调用数据结构

types/tool.ts
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

components/ToolCallBlock.tsx
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 图表、图片等。需要一个灵活的渲染引擎。

内容解析与渲染

components/MessageContent.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';

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 预处理

lib/stream-markdown.ts
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 内容与最终回答分开渲染。

components/ThinkingBlock.tsx
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>
);
}

六、输入与交互

多模态输入

components/ChatInput.tsx
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>
);
}

七、会话管理

stores/conversation.ts
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)大数据量,异步不阻塞
流式消息的 RAF 批量更新
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-windowreact-virtuoso
动态高度需手动测量 + resetAfterIndex自动测量,开箱即用
底部对齐不支持alignToBottom 原生支持
自动跟随新内容需自己实现followOutput 原生支持
流式内容高度变化高度缓存失效需手动处理ResizeObserver 自动检测
反向加载历史需大量手动处理firstItemIndex + startReached

推荐使用 react-virtuoso

components/VirtualMessageList.tsx
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}
/>
)}
/>
);
}

智能滚动逻辑

hooks/useSmartScroll.ts
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 };
}
自动滚动的常见陷阱
  1. 流式期间不要用 smooth:smooth 动画有延迟,token 到达速度可能超过滚动速度,导致"追不上"出现抖动。流式期间用 instant
  2. 阈值不要太小< 10px 太敏感,用户轻微触摸就误判为"离开底部",建议 100px
  3. isAutoScrolluseRef:onScroll 高频触发,用 useState 会造成大量无意义重渲染
  4. 启用阈值:消息少于 50 条时直接渲染,超过再切换虚拟列表(虚拟列表的测量开销在消息少时反而更高)

十、消息持久化与多端同步

生产级 AI 对话应用需要处理客户端缓存加速服务端持久化同步两个层面。

整体架构

核心原则:服务端是单一事实来源(Single Source of Truth),客户端只是缓存层,用于加速首屏和离线查看。

合并策略实现

lib/message-sync.ts
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 服务不稳定,用户行为不可预测。

流式中断恢复

核心思路:边收边存,不要等流结束才持久化。

hooks/useStreamPersistence.ts
/**
* 流式接收时定期将内容写入 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 更新,切回时一次性刷新
异常处理核心原则
  1. 永远不丢数据:流式内容边收边存,页面关闭前兜底写入
  2. 给用户选择权:中断后提供"继续生成" / "重新生成",不自动重试整个请求
  3. 区分可重试和不可重试:网络错误可重试;内容过滤、上下文溢出重试没用
  4. 优雅降级:IndexedDB 不可用 → 退回 localStorage;模型不支持 prefill → 退回重新生成

十二、安全防护

安全注意事项
  1. Prompt 注入防御 — 对用户输入做基础过滤,不将原始用户输入拼接到系统 prompt
  2. XSS 防护 — AI 输出的 Markdown 可能包含恶意脚本,用 rehype-sanitize 过滤 HTML
  3. API Key 保护 — 永远不在前端暴露 API Key,通过后端代理转发
  4. 速率限制 — 前端限制发送频率,防止滥用(每 N 秒一条)
  5. 内容过滤 — 展示前检查是否包含敏感内容
错误处理与重试
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 RouterVite + React
状态管理ZustandRedux Toolkit
通信SSE(fetch + ReadableStream)WebSocket
Markdownreact-markdown + remark/rehypeMDX
代码高亮Shiki(WASM)Prism.js
虚拟列表react-window@tanstack/virtual
样式Tailwind CSS + Radix UICSS Modules
持久化IndexedDB(Dexie)localStorage
测试Vitest + React Testing LibraryJest

常见面试问题

Q1: AI 对话场景中,前端如何实现流式输出?SSE 和 WebSocket 怎么选?

答案

流式输出实现:使用 fetch + ReadableStream 读取 SSE 流,逐行解析 data: 字段,将 Token 实时追加到消息内容中。

SSE vs WebSocket 对比

维度SSEWebSocket
方向单向(服务端→客户端)双向
协议HTTPws://
自动重连内置需手动实现
浏览器支持广泛广泛
适用场景AI 对话(请求-响应模式)实时协作、多端同步

推荐:AI 对话用 SSE,因为本质是"一问一答"模式,SSE 基于 HTTP 更简单、天然支持重连、兼容 CDN 和代理。WebSocket 适合需要服务端主动推送的场景(如多人协作编辑)。

Q2: 流式渲染时如何避免性能问题?每个 Token 都触发 re-render 怎么办?

答案

核心问题:LLM 输出速度可达每秒 50-100 个 Token,如果每个 Token 都 setState,会导致高频 re-render。

优化方案:

  1. RAF 批量更新 — 用 requestAnimationFrame 将一帧内的所有 Token 合并为一次更新
  2. 选择性订阅 — Zustand 的 selector 只订阅当前消息的 content 字段
  3. memo 隔离 — 已完成的消息用 React.memo 防止被正在流式的消息连带更新
  4. 虚拟列表 — 长对话只渲染视口内的消息
// RAF 批量:16ms 内的 Token 合并为一次 setState
const rafId = requestAnimationFrame(() => {
setContent(buffer); // 一帧只更新一次
});

Q3: 前端如何处理 AI Agent 的工具调用(Tool Use)?

答案

工具调用在流式输出中表现为特殊的 Chunk 类型

  1. tool_call_start — 收到工具名和 ID,UI 显示"正在调用 xxx"
  2. tool_call_delta — 流式接收工具参数(JSON 片段拼接)
  3. tool_call_end — 参数接收完毕,等待执行结果
  4. tool_result — 收到执行结果,展示结果摘要

前端需要维护一个工具调用状态机(calling → executing → success/error),并为每种工具类型提供专门的结果渲染组件(搜索结果卡片、代码执行输出、文件预览等)。

Q4: 流式输出中 Markdown 不完整怎么处理?比如代码块没闭合

答案

流式渲染时 Markdown 内容随时可能截断在任意位置,导致渲染异常。解决方案是在传给 Markdown 渲染器前做预处理

  1. 检测未闭合的代码块(``` 奇数个),追加闭合标记
  2. 检测未闭合的行内代码(` 奇数个),追加闭合
  3. 检测未闭合的粗体/斜体(** / * 奇数个),追加闭合
  4. 不完整的表格暂缓渲染(检测 | 行数判断)

关键是只对流式中的消息做预处理,已完成的消息直接渲染原始内容。

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 的会话管理?

答案

会话管理包含:

  1. 数据结构Conversation { id, title, messages[], model, createdAt }
  2. 持久化 — 小数据用 localStorage(Zustand persist),大数据用 IndexedDB(Dexie)
  3. 自动标题 — 第一轮对话后调用 LLM 生成摘要作为标题
  4. Token 统计 — 累加每次请求的 usage 数据,展示消耗
  5. 会话分叉 — 从历史消息某一条重新提问(fork),创建新分支
  6. 导出/分享 — 导出为 Markdown 或生成分享链接

Q7: AI 输出可能包含恶意内容(XSS),前端如何防御?

答案

AI 输出的 Markdown 可能包含 <script><img onerror>javascript: 链接等。防御措施:

  1. rehype-sanitize — 在 react-markdown 的 rehype 插件链中加入 HTML 消毒,只允许安全标签
  2. CSP 策略 — 设置 Content-Security-Policy 禁止 inline script
  3. 链接检查 — 只允许 http://https:// 协议的链接
  4. 代码块隔离 — 代码只做高亮展示,不执行
  5. iframe 沙箱 — 如需渲染 HTML 预览,用 sandbox 属性的 iframe 隔离

Q8: 如何实现 AI 对话的"思维链"(Thinking)展示?

答案

思维链是 LLM 在回答前的推理过程。前端处理:

  1. 数据层 — 在 Message 类型中增加 reasoning 字段,与 content 分开存储
  2. 流式处理 — 区分 thinking_deltatext_delta 两种 Chunk 类型,分别追加到不同字段
  3. UI 展示 — 思维链用可折叠的区块展示,灰色/斜体区分视觉层级
  4. 交互逻辑 — 流式时自动展开,完成后默认折叠,用户可手动展开查看

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 RouterSSR 首屏快、API Routes 做 BFF 代理 LLM 请求
状态Zustand轻量、支持 persistselector 精确订阅
通信SSE(fetch stream)简单、兼容性好、适合一问一答
Markdownreact-markdown生态好、插件丰富(remark/rehype)
代码高亮ShikiWASM 加载、主题丰富、与 VS Code 一致
样式Tailwind CSS + Radix UI快速开发 + 无头组件高定制
持久化Dexie(IndexedDB)大数据量、异步、结构化查询

如果是内部工具或 MVP,可以用 Vercel AI SDK(ai 包),它封装了流式处理、工具调用、多模型切换等能力,大幅减少样板代码。

Q11: 流式渲染到一半关掉页面,下次打开内容丢失怎么办?

答案

核心思路是边收边存,分三层防护:

  1. 客户端增量持久化:流式接收时每 500ms 将当前内容写入 IndexedDB,消息状态标记为 streaming
  2. 页面关闭兜底beforeunload 事件中用同步 localStorage 做最后一次写入(beforeunload 中不能用异步 IndexedDB)
  3. 服务端持久化(生产级推荐):BFF 层转发 LLM 响应时同步落库,客户端重连后从服务端恢复

恢复流程:打开页面 → 检查 IndexedDB 中 status === 'streaming' 的消息 → 展示已有内容 + "回答未完成"提示 → 提供两个按钮:

  • 继续生成:将已有内容作为 assistant prefill 发给 API,让模型接着说(Claude 支持较好)
  • 重新生成:丢弃部分内容,重新请求

Q12: 前端的消息缓存和后端的消息数据如何合并?

答案

采用服务端为权威源,客户端缓存加速的策略:

  1. 本地秒开:打开会话时先从 IndexedDB 读本地消息,立即渲染
  2. 向后增量拉取:请求 GET /api/messages?after=<本地最新时间戳>,拉取其他设备的新消息
  3. 向前按需加载:用户滚动到顶部时请求 GET /api/messages?before=<本地最早时间戳>&limit=20,加载更早的历史
  4. 合并去重:按消息 ID 去重,updatedAt 更新的覆盖旧的,服务端优先
  5. 写回缓存:合并结果写回 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 对话中的虚拟滚动为什么比普通列表难?

答案

四个特殊难点:

  1. 高度动态变化:流式输出期间消息高度持续增长,图片加载后高度突变,代码块折叠/展开高度改变——虚拟列表需要频繁重新计算布局
  2. 底部对齐:消息少时需要从底部排列(类似 IM),react-window 不支持,需要 react-virtuosoalignToBottom
  3. 自动跟随新内容:流式输出时在底部要跟随滚动,浏览历史时不能打断——需要和智能滚动深度配合
  4. 反向加载历史:滚动到顶部加载更早消息,加载后滚动位置不能跳动

推荐 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 快时滚动追不上内容增长
  • isAutoScrolluseRef(onScroll 高频触发避免无意义重渲染),showScrollButtonuseState(需要触发 UI 更新)
  • 用户发送新消息时强制滚到底部,不管当前位置

相关链接