AI 应用性能优化
问题
AI 应用在前端有哪些独特的性能挑战?如何优化 LLM 响应速度、降低成本、提升用户体验?
答案
AI 应用的性能优化与传统 Web 应用存在本质差异——传统应用的 API 响应通常在 50-200ms,而 LLM 的单次响应可能需要 1-30 秒,且延迟主要由模型推理决定,前端几乎无法直接加速。因此优化策略需要从指标度量、感知优化、缓存策略、请求管理、上下文优化、部署架构、渲染性能、成本控制八个维度系统展开。
一、AI 应用核心性能指标
1.1 指标定义
| 指标 | 全称 | 含义 | 优秀值 | 可接受值 |
|---|---|---|---|---|
| TTFT | Time To First Token | 首个 token 到达时间 | < 500ms | < 1.5s |
| TPS | Tokens Per Second | token 生成速率 | > 50 tps | > 20 tps |
| TTL | Time To Last Token | 完整响应生成时间 | 取决于长度 | - |
| E2E Latency | End-to-End Latency | 用户发送到看到首字 | < 1s | < 2s |
| Cache Hit Rate | - | 缓存命中率 | > 40% | > 20% |
| Cost per Query | - | 单次查询成本 | < $0.01 | < $0.05 |
1.2 主流模型 TTFT 实测基准(2025)
不同模型的 TTFT 差异显著,选择模型时需要将延迟纳入考量:
| 模型 | 平均 TTFT | TPS | 输入价格 ($/M tokens) | 输出价格 ($/M tokens) |
|---|---|---|---|---|
| GPT-4o | ~400-800ms | 60-80 | $2.50 | $10.00 |
| GPT-4o-mini | ~200-400ms | 80-120 | $0.15 | $0.60 |
| Claude Sonnet 4 | ~300-600ms | 50-70 | $3.00 | $15.00 |
| Claude Haiku 3.5 | ~200-350ms | 80-100 | $0.80 | $4.00 |
| Gemini 2.0 Flash | ~200-400ms | 70-90 | $0.10 | $0.40 |
| DeepSeek V3 | ~300-500ms | 40-60 | $0.27 | $1.10 |
实测 TTFT 会受多种因素影响:prompt 长度(越长越慢)、API 区域(距离越远越慢)、服务器负载(高峰时段更慢)、是否命中 Prompt Cache(命中后可降低 50-80%)。上表为中等长度 prompt (~1000 tokens) 的典型值。
1.3 性能度量实现
interface AIMetrics {
ttft: number; // 首 token 延迟 (ms)
tps: number; // tokens/秒
totalTokens: number; // 总 token 数
totalTime: number; // 总耗时 (ms)
promptTokens: number; // 输入 token 数
completionTokens: number; // 输出 token 数
cacheHit: boolean; // 是否命中缓存
model: string; // 使用的模型
estimatedCost: number; // 预估费用 ($)
}
// 完整的流式性能度量函数
async function measureStreamPerformance(
response: Response,
model: string
): Promise<AIMetrics> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const startTime = performance.now();
let firstTokenTime = 0;
let tokenCount = 0;
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 格式提取 token
const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
if (tokenCount === 0) {
firstTokenTime = performance.now();
}
tokenCount++;
fullText += content;
}
} catch { /* 跳过非 JSON 行 */ }
}
}
const totalTime = performance.now() - startTime;
const ttft = firstTokenTime - startTime;
// 从响应头读取 token 用量(部分 API 提供)
const promptTokens = parseInt(
response.headers.get('x-prompt-tokens') ?? '0'
);
return {
ttft,
tps: tokenCount / (totalTime / 1000),
totalTokens: promptTokens + tokenCount,
totalTime,
promptTokens,
completionTokens: tokenCount,
cacheHit: false,
model,
estimatedCost: calculateCost(model, promptTokens, tokenCount),
};
}
// 费用计算
function calculateCost(
model: string,
inputTokens: number,
outputTokens: number
): number {
const pricing: Record<string, { input: number; output: number }> = {
'gpt-4o': { input: 2.5, output: 10.0 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
'claude-sonnet-4-20250514': { input: 3.0, output: 15.0 },
'claude-haiku-3-5-20241022': { input: 0.8, output: 4.0 },
};
const price = pricing[model] ?? { input: 1.0, output: 3.0 };
return (
(inputTokens / 1_000_000) * price.input +
(outputTokens / 1_000_000) * price.output
);
}
二、Prompt Caching(提示缓存)
Prompt Caching 是 2024-2025 年最重要的 AI 性能优化技术之一。它由 API 提供商在服务端实现,对相同前缀的 prompt 进行 KV Cache 复用,大幅降低 TTFT 和成本。
2.1 工作原理
2.2 各平台 Prompt Caching 对比
| 特性 | Anthropic | OpenAI | |
|---|---|---|---|
| 启用方式 | 在 message 中添加 cache_control 标记 | 自动(相同前缀自动缓存) | 在 cachedContent 中设置 |
| 最小缓存长度 | 1024 tokens (Sonnet),2048 tokens (Haiku) | 1024 tokens | 32,768 tokens |
| 缓存 TTL | 5 分钟(滑动窗口) | ~5-10 分钟 | 可自定义(1min-无限) |
| 价格优惠 | 写入 +25%,读取 -90% | 读取 -50% | 存储按小时收费,读取 -75% |
| 缓存命中标识 | cache_creation_input_tokens / cache_read_input_tokens | cached_tokens in usage | 响应头标识 |
2.3 实现示例
// ---- Anthropic Prompt Caching ----
interface AnthropicCacheMessage {
role: string;
content: Array<{
type: 'text';
text: string;
cache_control?: { type: 'ephemeral' }; // 标记缓存断点
}>;
}
async function callWithPromptCache(
systemPrompt: string,
fewShotExamples: string,
userMessage: string
): Promise<Response> {
const body = {
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' }, // 缓存 system prompt
},
],
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: fewShotExamples,
cache_control: { type: 'ephemeral' }, // 缓存 few-shot 示例
},
{ type: 'text', text: userMessage },
],
},
],
};
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'prompt-caching-2024-07-31',
},
body: JSON.stringify(body),
});
return response;
}
// ---- 检查缓存命中情况 ----
interface AnthropicUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number; // 本次写入缓存的 token 数
cache_read_input_tokens: number; // 本次从缓存读取的 token 数
}
function analyzeCachePerformance(usage: AnthropicUsage): void {
const totalInput = usage.input_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens;
const cacheHitRate = usage.cache_read_input_tokens / totalInput;
console.log(`缓存命中率: ${(cacheHitRate * 100).toFixed(1)}%`);
console.log(`缓存读取 tokens: ${usage.cache_read_input_tokens}`);
console.log(`缓存写入 tokens: ${usage.cache_creation_input_tokens}`);
console.log(`未缓存 tokens: ${usage.input_tokens}`);
// 成本节省计算(以 Claude Sonnet 4 为例)
const normalCost = totalInput * 3.0 / 1_000_000;
const cachedCost =
usage.input_tokens * 3.0 / 1_000_000 + // 正常价格
usage.cache_creation_input_tokens * 3.75 / 1_000_000 + // 写入 +25%
usage.cache_read_input_tokens * 0.30 / 1_000_000; // 读取 -90%
console.log(`费用节省: $${(normalCost - cachedCost).toFixed(4)} (${((1 - cachedCost / normalCost) * 100).toFixed(1)}%)`);
}
- 将不变内容放在前面:system prompt、few-shot 示例、工具定义等固定内容放在 messages 最前面,变化的用户消息放在最后
- 超过最小长度才有效:Anthropic 要求至少 1024 tokens 才能触发缓存
- 保持前缀一致:哪怕修改一个字符,该位置之后的所有内容都需要重新计算
- 利用 5 分钟窗口:频繁请求的应用场景(如多轮对话)天然受益,低频场景效果有限
三、感知优化
当 LLM 延迟不可控时,让用户感觉快比实际快更重要。研究表明,有视觉反馈的等待体验比无反馈的等待感觉快约 35%。
import { useChat } from '@ai-sdk/react';
import { useState, useCallback } from 'react';
// 1. 乐观更新 - 立即显示用户消息
function useOptimisticChat() {
const chat = useChat({ api: '/api/chat' });
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const sendMessage = useCallback((content: string) => {
setPendingMessage(content); // 立即显示(不等网络)
chat.append({ role: 'user', content });
}, [chat]);
return { ...chat, sendMessage, pendingMessage };
}
// 2. 分阶段指示器 - 比简单转圈更有信息量
function AIProgressIndicator({ status }: {
status: 'connecting' | 'thinking' | 'generating' | 'done';
}) {
const stages = {
connecting: { text: '正在连接...', progress: 15 },
thinking: { text: 'AI 正在思考...', progress: 35 },
generating: { text: '正在生成回答...', progress: 70 },
done: { text: '完成', progress: 100 },
};
const { text, progress } = stages[status];
return (
<div className="flex items-center gap-3 p-3">
<div className="w-32 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-gray-500 text-sm">{text}</span>
</div>
);
}
// 3. 骨架屏 + 打字动画
function ThinkingIndicator() {
return (
<div className="flex items-center gap-2 p-3">
<div className="flex gap-1">
<span className="animate-bounce delay-0 w-2 h-2 bg-gray-400 rounded-full" />
<span className="animate-bounce delay-150 w-2 h-2 bg-gray-400 rounded-full" />
<span className="animate-bounce delay-300 w-2 h-2 bg-gray-400 rounded-full" />
</div>
<span className="text-gray-500 text-sm">AI 正在思考...</span>
</div>
);
}
// 4. 流式输出是最核心的感知优化
// 用户在 TTFT 后就能看到内容逐步展现,无需等待完整响应
// 详见流式渲染文档
四、流式缓冲策略
流式输出中,每个 token 到达都触发 React 重渲染会导致严重的性能问题。需要合理的缓冲策略来批量合并更新。详细的流式渲染实现参见 流式渲染与 SSE。
4.1 RAF 批量渲染
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* RAF 批量缓冲 Hook
* 将高频 token 更新合并到每帧一次的 DOM 更新
* 从 "每 token 一次渲染" 优化为 "每帧一次渲染"
*/
function useBufferedStream() {
const [displayText, setDisplayText] = useState('');
const bufferRef = useRef('');
const rafIdRef = useRef<number | null>(null);
const accumulatedRef = useRef('');
const flush = useCallback(() => {
if (bufferRef.current) {
accumulatedRef.current += bufferRef.current;
setDisplayText(accumulatedRef.current);
bufferRef.current = '';
}
rafIdRef.current = null;
}, []);
// 每个 token 到达时调用
const appendToken = useCallback((token: string) => {
bufferRef.current += token;
// 关键:用 RAF 合并更新,确保每帧最多渲染一次
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(flush);
}
}, [flush]);
// 清理
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
const reset = useCallback(() => {
bufferRef.current = '';
accumulatedRef.current = '';
setDisplayText('');
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
}, []);
return { displayText, appendToken, reset };
}
4.2 Chunk 大小优化
/**
* 自适应 chunk 合并策略
* 根据当前 TPS 动态调整缓冲大小
*/
class AdaptiveChunkBuffer {
private buffer = '';
private lastFlushTime = 0;
private tokensSinceFlush = 0;
private currentTPS = 0;
private callback: (text: string) => void;
constructor(callback: (text: string) => void) {
this.callback = callback;
}
push(token: string): void {
this.buffer += token;
this.tokensSinceFlush++;
const now = performance.now();
const elapsed = now - this.lastFlushTime;
// 更新 TPS 估算
if (elapsed > 0) {
this.currentTPS = (this.tokensSinceFlush / elapsed) * 1000;
}
// 根据 TPS 决定 flush 策略
const shouldFlush =
this.currentTPS < 20 ? true : // 低速:每个 token 立即显示
this.currentTPS < 50 ? this.buffer.length >= 3 : // 中速:3 个字符一批
this.currentTPS < 100 ? this.buffer.length >= 8 : // 高速:8 个字符一批
this.buffer.length >= 15; // 超高速:15 个字符一批
if (shouldFlush) {
this.flush();
}
}
private flush(): void {
if (this.buffer) {
this.callback(this.buffer);
this.buffer = '';
this.lastFlushTime = performance.now();
this.tokensSinceFlush = 0;
}
}
// 流结束时刷出剩余内容
end(): void {
this.flush();
}
}
requestAnimationFrame 比 setInterval 更适合流式渲染批量更新:
- 与浏览器渲染周期同步:RAF 在每帧绘制前调用,不会产生中间帧的无意义更新
- 后台自动暂停:页面不可见时 RAF 停止,节省 CPU
- 更精确的时序:避免 setInterval 的时间漂移问题
五、语义缓存(Semantic Cache)
语义缓存的核心是将问题向量化后进行相似度搜索,而非精确字符串匹配。例如 "如何用 JS 深拷贝" 和 "JavaScript 怎么实现深拷贝" 本质是同一问题,应该命中同一缓存。
5.1 语义缓存 vs 精确匹配缓存
| 特性 | 精确匹配缓存 | 语义缓存 |
|---|---|---|
| 匹配方式 | 字符串完全相同 | 向量相似度 >= 阈值 |
| 命中率 | 低(5-15%) | 高(30-60%) |
| 实现复杂度 | 简单(Redis String) | 较高(Redis + 向量搜索) |
| 额外延迟 | ~1ms | ~10-50ms(embedding + 搜索) |
| 误命中风险 | 无 | 有(阈值需调优) |
| 存储要求 | 仅存文本 | 需存向量 (1536维 = 6KB/条) |
| 适用场景 | FAQ、固定话术 | 开放式对话、知识问答 |
5.2 生产级实现:Redis + 向量搜索
import { createClient } from 'redis';
import { SchemaFieldTypes, VectorAlgorithms } from 'redis';
/**
* 基于 Redis Stack 的生产级语义缓存
* 使用 RediSearch 的向量搜索能力,性能远超内存数组遍历
*/
class ProductionSemanticCache {
private redis;
private indexName = 'idx:ai_cache';
private prefix = 'ai_cache:';
constructor() {
this.redis = createClient({ url: process.env.REDIS_URL });
}
async initialize(): Promise<void> {
await this.redis.connect();
// 创建向量搜索索引
try {
await this.redis.ft.create(this.indexName, {
question: { type: SchemaFieldTypes.TEXT },
embedding: {
type: SchemaFieldTypes.VECTOR,
ALGORITHM: VectorAlgorithms.HNSW,
TYPE: 'FLOAT32',
DIM: 1536, // OpenAI text-embedding-3-small 维度
DISTANCE_METRIC: 'COSINE',
M: 16,
EF_CONSTRUCTION: 200,
},
answer: { type: SchemaFieldTypes.TEXT },
model: { type: SchemaFieldTypes.TAG },
createdAt: { type: SchemaFieldTypes.NUMERIC },
ttl: { type: SchemaFieldTypes.NUMERIC },
}, { ON: 'HASH', PREFIX: this.prefix });
} catch {
// 索引已存在,忽略
}
}
async get(
question: string,
threshold: number = 0.92
): Promise<{ answer: string; similarity: number } | null> {
const embedding = await this.getEmbedding(question);
// 向量相似度搜索
const results = await this.redis.ft.search(
this.indexName,
`*=>[KNN 3 @embedding $BLOB AS score]`, // 搜索最近的 3 个向量
{
PARAMS: { BLOB: Buffer.from(new Float32Array(embedding).buffer) },
SORTBY: 'score',
RETURN: ['question', 'answer', 'score', 'createdAt', 'ttl'],
DIALECT: 2,
}
);
if (results.total === 0) return null;
const best = results.documents[0];
const similarity = 1 - parseFloat(best.value.score as string); // COSINE 距离转相似度
const createdAt = parseInt(best.value.createdAt as string);
const ttl = parseInt(best.value.ttl as string);
// 检查 TTL
if (Date.now() - createdAt > ttl) {
await this.redis.del(best.id);
return null;
}
// 检查相似度阈值
if (similarity < threshold) return null;
return {
answer: best.value.answer as string,
similarity,
};
}
async set(
question: string,
answer: string,
model: string,
ttl: number = 3600_000 // 默认 1 小时
): Promise<void> {
const embedding = await this.getEmbedding(question);
const id = `${this.prefix}${crypto.randomUUID()}`;
await this.redis.hSet(id, {
question,
answer,
model,
embedding: Buffer.from(new Float32Array(embedding).buffer),
createdAt: Date.now(),
ttl,
});
}
// 缓存失效策略
async invalidateByModel(model: string): Promise<number> {
// 当模型更新时,清除该模型的所有缓存
const results = await this.redis.ft.search(
this.indexName,
`@model:{${model}}`,
{ RETURN: [] }
);
let deleted = 0;
for (const doc of results.documents) {
await this.redis.del(doc.id);
deleted++;
}
return deleted;
}
private async getEmbedding(text: string): Promise<number[]> {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
}),
});
const data = await response.json();
return data.data[0].embedding;
}
}
5.3 缓存中间件
// 将语义缓存集成为请求中间件
async function cachedChat(
messages: Array<{ role: string; content: string }>,
cache: ProductionSemanticCache,
model: string
): Promise<ReadableStream<string>> {
const lastUserMsg = messages.findLast(m => m.role === 'user');
if (!lastUserMsg) throw new Error('No user message');
// 1. 检查缓存
const cached = await cache.get(lastUserMsg.content);
if (cached) {
console.log(`Cache HIT (similarity: ${cached.similarity.toFixed(3)})`);
// 将缓存结果模拟为流式返回(保持 UI 一致)
return simulateStream(cached.answer);
}
// 2. 调用 LLM
console.log('Cache MISS, calling LLM...');
const stream = await fetchLLMStream(messages, model);
// 3. 收集完整响应后写入缓存
const [s1, s2] = stream.tee();
collectStream(s2).then(fullText => {
cache.set(lastUserMsg.content, fullText, model);
});
return s1;
}
// 模拟流式返回(保持前端 UI 一致)
function simulateStream(text: string, delay: number = 15): ReadableStream<string> {
const chars = Array.from(text);
let index = 0;
return new ReadableStream({
async pull(controller) {
if (index >= chars.length) {
controller.close();
return;
}
await new Promise(r => setTimeout(r, delay));
// 每次输出 2-5 个字符,模拟自然的 token 输出节奏
const chunkSize = Math.min(2 + Math.floor(Math.random() * 4), chars.length - index);
controller.enqueue(chars.slice(index, index + chunkSize).join(''));
index += chunkSize;
},
});
}
关于向量搜索和 Embedding 的更多细节,参见 向量搜索与语义化。
六、并发请求管理
AI 应用常常需要同时处理多个请求场景——多轮对话快速提问、并行调用多个模型、RAG 中的并行检索等。不当的并发管理会导致请求堆积、响应混乱、资源浪费。
6.1 请求优先级队列
type Priority = 'high' | 'normal' | 'low';
interface QueuedRequest<T> {
id: string;
priority: Priority;
execute: () => Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
abortController: AbortController;
createdAt: number;
timeout: number;
}
/**
* AI 请求优先级队列
* - 支持优先级排序(high > normal > low)
* - 支持并发限制
* - 支持请求取消和超时
* - 支持过期请求自动丢弃
*/
class AIRequestQueue {
private queue: QueuedRequest<unknown>[] = [];
private activeCount = 0;
private readonly maxConcurrent: number;
constructor(maxConcurrent: number = 3) {
this.maxConcurrent = maxConcurrent;
}
async enqueue<T>(
execute: (signal: AbortSignal) => Promise<T>,
options: {
priority?: Priority;
timeout?: number;
id?: string;
} = {}
): Promise<T> {
const {
priority = 'normal',
timeout = 30_000,
id = crypto.randomUUID(),
} = options;
const abortController = new AbortController();
return new Promise<T>((resolve, reject) => {
const request: QueuedRequest<T> = {
id,
priority,
execute: () => execute(abortController.signal),
resolve: resolve as (v: unknown) => void,
reject,
abortController,
createdAt: Date.now(),
timeout,
};
// 按优先级插入
const priorityOrder: Record<Priority, number> = { high: 0, normal: 1, low: 2 };
const insertIndex = this.queue.findIndex(
q => priorityOrder[q.priority] > priorityOrder[priority]
);
if (insertIndex === -1) {
this.queue.push(request as QueuedRequest<unknown>);
} else {
this.queue.splice(insertIndex, 0, request as QueuedRequest<unknown>);
}
this.processNext();
});
}
// 取消指定请求
cancel(id: string): boolean {
const index = this.queue.findIndex(r => r.id === id);
if (index !== -1) {
const [request] = this.queue.splice(index, 1);
request.abortController.abort();
request.reject(new DOMException('Request cancelled', 'AbortError'));
return true;
}
return false;
}
// 取消所有低优先级请求(用户发送新消息时)
cancelStale(): void {
const now = Date.now();
this.queue = this.queue.filter(request => {
if (now - request.createdAt > request.timeout) {
request.abortController.abort();
request.reject(new DOMException('Request timeout', 'TimeoutError'));
return false;
}
return true;
});
}
private async processNext(): Promise<void> {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) return;
const request = this.queue.shift()!;
this.activeCount++;
// 超时处理
const timeoutId = setTimeout(() => {
request.abortController.abort();
}, request.timeout);
try {
const result = await request.execute();
request.resolve(result);
} catch (error) {
request.reject(error);
} finally {
clearTimeout(timeoutId);
this.activeCount--;
this.processNext();
}
}
get pending(): number { return this.queue.length; }
get active(): number { return this.activeCount; }
}
6.2 实际使用:取消过期请求
import { useRef, useCallback } from 'react';
/**
* 在多轮对话中,用户快速连续提问时
* 自动取消之前未完成的请求,避免响应错乱
*/
function useAIChat() {
const queueRef = useRef(new AIRequestQueue(2));
const activeRequestIdRef = useRef<string | null>(null);
const sendMessage = useCallback(async (content: string) => {
// 取消上一个未完成的请求
if (activeRequestIdRef.current) {
queueRef.current.cancel(activeRequestIdRef.current);
}
const requestId = crypto.randomUUID();
activeRequestIdRef.current = requestId;
try {
const response = await queueRef.current.enqueue(
(signal) => fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages: [{ role: 'user', content }] }),
signal,
}),
{ priority: 'high', id: requestId }
);
return response;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('请求已取消(用户发送了新消息)');
return null;
}
throw error;
}
}, []);
return { sendMessage, queueStatus: queueRef.current };
}
七、上下文窗口优化
上下文窗口(Context Window)是 LLM 单次请求能处理的最大 token 数。更多的上下文意味着更好的回答质量,但也意味着更高的延迟和成本。如何在质量与效率之间取得平衡是关键。
7.1 上下文管理策略
7.2 RAG vs 长上下文对比
| 特性 | RAG 检索增强 | 长上下文 (128K+) |
|---|---|---|
| 成本 | 低(只发送相关片段) | 高(所有内容计入 token) |
| 延迟 | 较低(检索 + 短上下文) | 较高(长 prompt 处理慢) |
| 准确性 | 取决于检索质量 | 较好(模型看到全部信息) |
| 实现复杂度 | 高(需要向量库、分块、索引) | 低(直接拼接) |
| 适合场景 | 大规模知识库、文档问答 | 单文档分析、长对话 |
| 上限 | 理论无限(按需检索) | 受模型上下文窗口限制 |
关于 RAG 的详细实现,参见 RAG 检索增强生成。
7.3 实现
interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
timestamp?: number;
}
interface ContextConfig {
maxTokens: number; // 上下文 token 上限
strategy: 'sliding' | 'summary' | 'rag' | 'hybrid';
reserveForOutput: number; // 为输出预留的 token 数
summaryThreshold: number; // 超过此 token 数触发摘要
}
class ContextOptimizer {
private config: ContextConfig;
constructor(config: ContextConfig) {
this.config = config;
}
async optimize(messages: Message[], currentQuery: string): Promise<Message[]> {
const budget = this.config.maxTokens - this.config.reserveForOutput;
switch (this.config.strategy) {
case 'sliding':
return this.slidingWindow(messages, budget);
case 'summary':
return this.summaryCompression(messages, budget);
case 'rag':
return this.ragRetrieval(messages, currentQuery, budget);
case 'hybrid':
return this.hybridStrategy(messages, currentQuery, budget);
default:
return messages;
}
}
// 策略1:滑动窗口
private slidingWindow(messages: Message[], budget: number): Message[] {
const systemMsg = messages.find(m => m.role === 'system');
const systemTokens = systemMsg ? estimateTokens(systemMsg.content) : 0;
let remaining = budget - systemTokens;
const conversationMsgs = messages.filter(m => m.role !== 'system');
const result: Message[] = [];
// 从最新到最旧遍历,优先保留最近的对话
for (let i = conversationMsgs.length - 1; i >= 0; i--) {
const tokens = estimateTokens(conversationMsgs[i].content);
if (remaining - tokens < 0) break;
remaining -= tokens;
result.unshift(conversationMsgs[i]);
}
return systemMsg ? [systemMsg, ...result] : result;
}
// 策略2:摘要压缩
private async summaryCompression(
messages: Message[],
budget: number
): Promise<Message[]> {
const totalTokens = messages.reduce(
(sum, m) => sum + estimateTokens(m.content), 0
);
if (totalTokens <= budget) return messages;
// 将超出部分的旧消息压缩为摘要
const recentCount = 6; // 保留最近 3 轮对话
const oldMessages = messages.slice(0, -recentCount);
const recentMessages = messages.slice(-recentCount);
// 用小模型生成摘要(节省成本)
const summary = await generateSummary(oldMessages, 'gpt-4o-mini');
return [
{
role: 'system',
content: `[历史对话摘要] ${summary}`,
},
...recentMessages,
];
}
// 策略3:RAG 检索
private async ragRetrieval(
messages: Message[],
currentQuery: string,
budget: number
): Promise<Message[]> {
const systemMsg = messages.find(m => m.role === 'system');
const recentMessages = messages.slice(-4); // 保留最近 2 轮
// 检索相关历史对话片段
const relevantChunks = await searchRelevantContext(currentQuery, 5);
const contextMessage: Message = {
role: 'system',
content: `[相关上下文]\n${relevantChunks.join('\n---\n')}`,
};
return [systemMsg, contextMessage, ...recentMessages].filter(Boolean) as Message[];
}
// 策略4:混合策略(推荐)
private async hybridStrategy(
messages: Message[],
currentQuery: string,
budget: number
): Promise<Message[]> {
const systemMsg = messages.find(m => m.role === 'system');
const recentMessages = messages.slice(-6);
const olderMessages = messages.slice(0, -6);
const parts: Message[] = [];
let usedTokens = 0;
// 1. System prompt(必须保留)
if (systemMsg) {
parts.push(systemMsg);
usedTokens += estimateTokens(systemMsg.content);
}
// 2. 旧对话摘要(如果有足够历史)
if (olderMessages.length > 2) {
const summary = await generateSummary(olderMessages, 'gpt-4o-mini');
const summaryMsg: Message = { role: 'system', content: `[对话摘要] ${summary}` };
usedTokens += estimateTokens(summary);
parts.push(summaryMsg);
}
// 3. RAG 检索相关内容(用剩余 token 预算的 30%)
const ragBudget = Math.floor((budget - usedTokens) * 0.3);
if (ragBudget > 200) {
const chunks = await searchRelevantContext(currentQuery, 3);
const ragContent = chunks.join('\n---\n').slice(0, ragBudget * 4); // 粗略 4 chars/token
parts.push({ role: 'system', content: `[参考资料]\n${ragContent}` });
}
// 4. 最近对话(优先保证完整)
parts.push(...recentMessages);
return parts;
}
}
// Token 估算函数(粗略:中文 1 字 ≈ 1.5 token,英文 1 词 ≈ 1.3 token)
function estimateTokens(text: string): number {
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const otherChars = text.length - chineseChars;
return Math.ceil(chineseChars * 1.5 + otherChars / 4);
}
八、Edge 部署优化
将 AI API 路由部署在边缘节点(Edge Runtime)可以显著降低网络延迟,但 Edge Runtime 有诸多限制需要了解。
8.1 Edge Runtime vs Node.js Runtime
| 特性 | Edge Runtime | Node.js Runtime |
|---|---|---|
| 冷启动 | ~50ms | ~250-500ms |
| 执行时间限制 | 25-30s (Vercel) | 10-60s (可配置) |
| 运行位置 | 全球 CDN 边缘节点 | 固定区域服务器 |
| API 兼容性 | Web API 子集(无 fs, net, child_process) | 完整 Node.js API |
| 代码大小 | < 4MB (Vercel) | 无限制 |
| 流式支持 | 原生 Web Streams | Node.js Streams / Web Streams |
| 数据库连接 | 仅 HTTP(无 TCP 长连接) | 支持连接池 |
| 适用场景 | 代理转发、轻量计算 | 复杂业务逻辑、数据库操作 |
8.2 Edge AI 路由实现
export const runtime = 'edge'; // 声明为 Edge Runtime
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export async function POST(req: Request): Promise<Response> {
const { messages } = await req.json();
// Edge Runtime 下的轻量处理
// 1. 参数校验
if (!messages?.length) {
return new Response('Missing messages', { status: 400 });
}
// 2. 速率限制(使用请求头中的信息,无需数据库)
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
const rateLimitOk = await checkEdgeRateLimit(ip);
if (!rateLimitOk) {
return new Response('Rate limit exceeded', { status: 429 });
}
// 3. 调用 LLM(边缘节点到 LLM API 的网络延迟更低)
const result = streamText({
model: anthropic('claude-haiku-3-5-20241022'),
messages,
maxTokens: 2048,
});
return result.toDataStreamResponse();
}
// 基于 KV 的轻量速率限制(Edge 兼容)
async function checkEdgeRateLimit(ip: string): Promise<boolean> {
// 使用 Vercel KV 或 Cloudflare KV
// 这些是边缘兼容的键值存储
const key = `ratelimit:${ip}`;
// 实际实现使用 @vercel/kv 或 Cloudflare Workers KV
// 这里简化展示逻辑
const count = parseInt(
await globalThis.caches?.open?.('ratelimit')
.then(c => c.match(key))
.then(r => r?.text()) ?? '0'
);
return count < 20; // 每分钟 20 次
}
- 不能直连数据库:Edge Runtime 不支持 TCP 长连接,需要使用 HTTP 协议的数据库服务(如 Neon Serverless、PlanetScale、Turso)
- 执行时间受限:长时间流式响应可能超时(Vercel Edge 默认 25s),复杂的 AI 任务建议用 Node.js Runtime
- npm 包兼容性:依赖 Node.js 原生模块(如
sharp、bcrypt)的包无法在 Edge 运行 - 调试困难:本地开发环境和 Edge 运行环境差异较大
8.3 混合部署策略(推荐)
import { NextRequest, NextResponse } from 'next/server';
// 在中间件中根据请求类型路由到不同 Runtime
// 中间件本身运行在 Edge
export function middleware(req: NextRequest): NextResponse {
const path = req.nextUrl.pathname;
if (path === '/api/chat/simple') {
// 简单对话 → Edge Runtime(快速响应)
return NextResponse.rewrite(new URL('/api/chat/edge', req.url));
}
if (path === '/api/chat/complex') {
// 复杂任务(RAG、Function Calling) → Node.js Runtime
return NextResponse.rewrite(new URL('/api/chat/node', req.url));
}
return NextResponse.next();
}
九、智能模型路由
根据问题复杂度自动选择最合适的模型,是平衡质量与成本的关键策略。
9.1 基于分类器的模型路由
type ModelTier = 'fast' | 'standard' | 'powerful' | 'reasoning';
interface RouteResult {
tier: ModelTier;
model: string;
reason: string;
}
// 模型路由配置
const MODEL_CONFIG: Record<ModelTier, {
model: string;
inputPrice: number; // $/M tokens
outputPrice: number; // $/M tokens
avgTTFT: number; // ms
}> = {
fast: { model: 'gpt-4o-mini', inputPrice: 0.15, outputPrice: 0.6, avgTTFT: 300 },
standard: { model: 'gpt-4o', inputPrice: 2.5, outputPrice: 10.0, avgTTFT: 600 },
powerful: { model: 'claude-sonnet-4-20250514', inputPrice: 3.0, outputPrice: 15.0, avgTTFT: 450 },
reasoning: { model: 'o3', inputPrice: 10.0, outputPrice: 40.0, avgTTFT: 2000 },
};
/**
* 基于规则 + 关键词的快速分类器
* 生产环境可替换为训练好的轻量分类模型
*/
function classifyComplexity(input: string): ModelTier {
const features = extractFeatures(input);
// 规则引擎
if (features.isSimpleGreeting) return 'fast';
if (features.requiresReasoning) return 'reasoning';
if (features.isComplex || features.hasCode) return 'powerful';
if (features.isMedium) return 'standard';
return 'fast';
}
function extractFeatures(input: string): Record<string, boolean> {
return {
isSimpleGreeting: /^(你好|hi|hello|谢谢|ok)/i.test(input.trim()),
hasCode: /```|function\s|class\s|import\s|const\s|interface\s/.test(input),
requiresReasoning: /推理|证明|数学|计算|逻辑|为什么.*为什么/.test(input),
isComplex: /原理|架构|设计|对比|分析|详细|深入/.test(input) || input.length > 500,
isMedium: input.length > 100 || /如何|怎么|什么是/.test(input),
};
}
// 路由函数
function routeModel(question: string): RouteResult {
const tier = classifyComplexity(question);
const config = MODEL_CONFIG[tier];
return {
tier,
model: config.model,
reason: `Classified as "${tier}" (TTFT ~${config.avgTTFT}ms, $${config.inputPrice}/M in)`,
};
}
9.2 基于 LLM 的路由分类器
/**
* 用小模型做路由判断(更准确但有额外延迟)
* 适用于对质量要求高的场景
*/
async function llmBasedRoute(question: string): Promise<ModelTier> {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini', // 用最便宜的模型做分类
messages: [
{
role: 'system',
content: `You are a query classifier. Classify the user query into one of:
- "fast": Simple greeting, yes/no question, factual lookup
- "standard": General knowledge question, short explanation
- "powerful": Code generation, complex analysis, detailed explanation
- "reasoning": Math proof, multi-step logical reasoning
Respond with ONLY the category name.`,
},
{ role: 'user', content: question },
],
max_tokens: 10,
temperature: 0,
}),
});
const data = await response.json();
const tier = data.choices[0].message.content.trim().toLowerCase() as ModelTier;
// 安全回退
return ['fast', 'standard', 'powerful', 'reasoning'].includes(tier) ? tier : 'standard';
}
- 规则分类器:零延迟、零成本,但准确率约 70-80%
- LLM 分类器:额外 ~200ms 和 ~$0.00003/次,准确率 90%+
- 推荐方案:先用规则分类,对 "standard" 级别的模糊区域再用 LLM 二次判断
十、流式指标监控面板
在生产环境中,需要实时监控 AI 请求的性能指标。以下实现一个可视化的流式指标仪表盘组件。
import { useState, useEffect, useCallback, useRef } from 'react';
interface MetricsSnapshot {
timestamp: number;
ttft: number;
tps: number;
totalRequests: number;
cacheHitRate: number;
activeStreams: number;
errorRate: number;
estimatedCostToday: number;
p95TTFT: number;
avgTPS: number;
}
/**
* 性能指标收集器(单例)
* 在每次 AI 请求完成时记录指标,供 Dashboard 消费
*/
class MetricsCollector {
private static instance: MetricsCollector;
private metrics: AIMetrics[] = [];
private listeners: Set<(snapshot: MetricsSnapshot) => void> = new Set();
private cacheHits = 0;
private cacheMisses = 0;
private errors = 0;
static getInstance(): MetricsCollector {
if (!MetricsCollector.instance) {
MetricsCollector.instance = new MetricsCollector();
}
return MetricsCollector.instance;
}
record(metric: AIMetrics): void {
this.metrics.push(metric);
if (metric.cacheHit) this.cacheHits++;
else this.cacheMisses++;
this.notify();
}
recordError(): void {
this.errors++;
this.notify();
}
subscribe(listener: (snapshot: MetricsSnapshot) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
const snapshot = this.getSnapshot();
this.listeners.forEach(fn => fn(snapshot));
}
getSnapshot(): MetricsSnapshot {
const recent = this.metrics.slice(-100); // 最近 100 条
const ttfts = recent.map(m => m.ttft).sort((a, b) => a - b);
return {
timestamp: Date.now(),
ttft: recent.length ? recent[recent.length - 1].ttft : 0,
tps: recent.length ? recent[recent.length - 1].tps : 0,
totalRequests: this.metrics.length,
cacheHitRate: this.cacheHits / (this.cacheHits + this.cacheMisses || 1),
activeStreams: 0, // 由外部更新
errorRate: this.errors / (this.metrics.length + this.errors || 1),
estimatedCostToday: this.metrics.reduce((sum, m) => sum + m.estimatedCost, 0),
p95TTFT: ttfts[Math.floor(ttfts.length * 0.95)] ?? 0,
avgTPS: recent.reduce((sum, m) => sum + m.tps, 0) / (recent.length || 1),
};
}
}
// ---- React Dashboard 组件 ----
function AIMetricsDashboard() {
const [snapshot, setSnapshot] = useState<MetricsSnapshot | null>(null);
const [history, setHistory] = useState<MetricsSnapshot[]>([]);
const collector = MetricsCollector.getInstance();
useEffect(() => {
const unsubscribe = collector.subscribe((newSnapshot) => {
setSnapshot(newSnapshot);
setHistory(prev => [...prev.slice(-59), newSnapshot]); // 保留最近 60 个快照
});
return unsubscribe;
}, [collector]);
if (!snapshot) return <div>等待数据...</div>;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4">
<MetricCard
title="TTFT (P95)"
value={`${snapshot.p95TTFT.toFixed(0)}ms`}
status={snapshot.p95TTFT < 1000 ? 'good' : snapshot.p95TTFT < 2000 ? 'warn' : 'bad'}
/>
<MetricCard
title="平均 TPS"
value={`${snapshot.avgTPS.toFixed(1)}`}
status={snapshot.avgTPS > 30 ? 'good' : snapshot.avgTPS > 15 ? 'warn' : 'bad'}
/>
<MetricCard
title="缓存命中率"
value={`${(snapshot.cacheHitRate * 100).toFixed(1)}%`}
status={snapshot.cacheHitRate > 0.4 ? 'good' : snapshot.cacheHitRate > 0.2 ? 'warn' : 'bad'}
/>
<MetricCard
title="今日费用"
value={`$${snapshot.estimatedCostToday.toFixed(2)}`}
status={snapshot.estimatedCostToday < 50 ? 'good' : snapshot.estimatedCostToday < 100 ? 'warn' : 'bad'}
/>
<MetricCard
title="总请求数"
value={`${snapshot.totalRequests}`}
status="neutral"
/>
<MetricCard
title="错误率"
value={`${(snapshot.errorRate * 100).toFixed(2)}%`}
status={snapshot.errorRate < 0.01 ? 'good' : snapshot.errorRate < 0.05 ? 'warn' : 'bad'}
/>
{/* 趋势图表(简化展示) */}
<div className="col-span-2 md:col-span-4">
<TTFTTrendChart data={history} />
</div>
</div>
);
}
function MetricCard({ title, value, status }: {
title: string;
value: string;
status: 'good' | 'warn' | 'bad' | 'neutral';
}) {
const colorMap = {
good: 'text-green-600 bg-green-50 border-green-200',
warn: 'text-yellow-600 bg-yellow-50 border-yellow-200',
bad: 'text-red-600 bg-red-50 border-red-200',
neutral: 'text-gray-600 bg-gray-50 border-gray-200',
};
return (
<div className={`p-4 rounded-lg border ${colorMap[status]}`}>
<div className="text-sm opacity-75">{title}</div>
<div className="text-2xl font-bold mt-1">{value}</div>
</div>
);
}
// 简化的 TTFT 趋势图(实际生产中可用 Recharts / ECharts)
function TTFTTrendChart({ data }: { data: MetricsSnapshot[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d')!;
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
const ttfts = data.map(d => d.p95TTFT);
const maxTTFT = Math.max(...ttfts, 1000);
// 绘制折线
ctx.beginPath();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ttfts.forEach((ttft, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - (ttft / maxTTFT) * height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// 绘制阈值线
const thresholdY = height - (1000 / maxTTFT) * height;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(0, thresholdY);
ctx.lineTo(width, thresholdY);
ctx.stroke();
ctx.setLineDash([]);
}, [data]);
return (
<div>
<div className="text-sm text-gray-500 mb-2">TTFT 趋势(红线 = 1000ms 阈值)</div>
<canvas ref={canvasRef} width={600} height={150} className="w-full" />
</div>
);
}
十一、成本优化与预算控制
AI API 的费用可能快速失控。以下通过真实定价数据来说明成本优化的重要性。
11.1 月度费用估算
假设一个 AI 产品,日活 10,000 用户,每用户每日平均 20 次对话:
| 场景 | 模型 | 平均 Input | 平均 Output | 单次费用 | 日费用 | 月费用 |
|---|---|---|---|---|---|---|
| 无优化 | GPT-4o | 2000 tokens | 500 tokens | $0.01 | $2,000 | $60,000 |
| 模型路由 | 70% mini + 30% 4o | 混合 | 混合 | ~$0.003 | $600 | $18,000 |
| + 缓存 (40% hit) | 同上 | - | - | ~$0.002 | $360 | $10,800 |
| + Prompt 压缩 | 同上 | 减少 30% | - | ~$0.0014 | $252 | $7,560 |
| + Prompt Cache | 同上 | 缓存 -50% | - | ~$0.001 | $180 | $5,400 |
| 全部优化 | 综合 | - | - | ~$0.001 | $180 | $5,400 |
从月 5,400,节省 91%。模型路由是最大的单项优化(节省 70%),其次是语义缓存(节省 40%)。
11.2 预算控制系统
interface BudgetConfig {
dailyLimit: number; // 日预算上限 ($)
monthlyLimit: number; // 月预算上限 ($)
warningThreshold: number; // 告警阈值 (0-1)
perUserLimit: number; // 单用户日限额 ($)
}
class BudgetController {
private config: BudgetConfig;
private dailySpend = 0;
private monthlySpend = 0;
private userSpend = new Map<string, number>();
private lastResetDay = new Date().getDate();
private lastResetMonth = new Date().getMonth();
constructor(config: BudgetConfig) {
this.config = config;
}
// 请求前检查预算
canProceed(userId: string, estimatedCost: number): {
allowed: boolean;
reason?: string;
suggestion?: string;
} {
this.checkReset();
// 检查全局日预算
if (this.dailySpend + estimatedCost > this.config.dailyLimit) {
return {
allowed: false,
reason: '已达到每日预算上限',
suggestion: '请明天再试,或联系管理员提高额度',
};
}
// 检查月预算
if (this.monthlySpend + estimatedCost > this.config.monthlyLimit) {
return {
allowed: false,
reason: '已达到每月预算上限',
suggestion: '本月额度已用完,请联系管理员',
};
}
// 检查用户限额
const userTotal = (this.userSpend.get(userId) ?? 0) + estimatedCost;
if (userTotal > this.config.perUserLimit) {
return {
allowed: false,
reason: '您今日的使用额度已用完',
suggestion: '请明天再使用,或升级为高级用户',
};
}
// 接近阈值时告警(但不阻止)
const dailyUsage = (this.dailySpend + estimatedCost) / this.config.dailyLimit;
if (dailyUsage > this.config.warningThreshold) {
console.warn(
`[Budget Warning] Daily spend at ${(dailyUsage * 100).toFixed(1)}%`
);
// 可以触发 Slack/邮件告警
}
return { allowed: true };
}
// 记录实际花费
recordSpend(userId: string, actualCost: number): void {
this.dailySpend += actualCost;
this.monthlySpend += actualCost;
this.userSpend.set(
userId,
(this.userSpend.get(userId) ?? 0) + actualCost
);
}
// 预估请求成本(在发送前)
estimateCost(model: string, inputTokens: number, maxOutputTokens: number): number {
return calculateCost(model, inputTokens, maxOutputTokens);
}
private checkReset(): void {
const now = new Date();
if (now.getDate() !== this.lastResetDay) {
this.dailySpend = 0;
this.userSpend.clear();
this.lastResetDay = now.getDate();
}
if (now.getMonth() !== this.lastResetMonth) {
this.monthlySpend = 0;
this.lastResetMonth = now.getMonth();
}
}
getUsageReport(): {
daily: { spent: number; limit: number; percentage: number };
monthly: { spent: number; limit: number; percentage: number };
} {
return {
daily: {
spent: this.dailySpend,
limit: this.config.dailyLimit,
percentage: this.dailySpend / this.config.dailyLimit,
},
monthly: {
spent: this.monthlySpend,
limit: this.config.monthlyLimit,
percentage: this.monthlySpend / this.config.monthlyLimit,
},
};
}
}
// ---- 使用示例 ----
const budget = new BudgetController({
dailyLimit: 200, // 每天 $200
monthlyLimit: 5000, // 每月 $5,000
warningThreshold: 0.8, // 80% 时告警
perUserLimit: 2, // 每用户每天 $2
});
十二、渲染性能优化
import { memo, useMemo, useRef, useEffect } from 'react';
import { FixedSizeList } from 'react-window';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
}
// 1. 消息组件 memo 化 - 避免所有消息重渲染
const MessageItem = memo<{ message: Message }>(({ message }) => {
const renderedContent = useMemo(
() => renderMarkdown(message.content),
[message.content]
);
return (
<div className={`message ${message.role}`}>
{renderedContent}
</div>
);
});
// 2. 虚拟列表 - 大量消息时
function VirtualMessageList({ messages }: { messages: Message[] }) {
const listRef = useRef<FixedSizeList>(null);
useEffect(() => {
listRef.current?.scrollToItem(messages.length - 1);
}, [messages.length]);
return (
<FixedSizeList
ref={listRef}
height={600}
itemCount={messages.length}
itemSize={100}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<MessageItem message={messages[index]} />
</div>
)}
</FixedSizeList>
);
}
// 3. Markdown 增量渲染
// 流式内容不要每个 token 都全量重渲整个 Markdown
function useIncrementalMarkdown(content: string) {
const prevContentRef = useRef('');
const renderedHtmlRef = useRef('');
if (content.length > prevContentRef.current.length) {
const newPart = content.slice(prevContentRef.current.length);
// 只将新增部分追加到已渲染的 HTML 中
renderedHtmlRef.current += renderMarkdownIncremental(newPart);
}
prevContentRef.current = content;
return renderedHtmlRef.current;
}
// 4. 代码块延迟高亮
function useDelayedHighlight(content: string, isStreaming: boolean) {
const [highlighted, setHighlighted] = useState(content);
useEffect(() => {
// 流式过程中不做语法高亮(避免频繁调用 Prism/Shiki)
if (isStreaming) return;
// 流结束后再一次性高亮所有代码块
const timer = setTimeout(() => {
setHighlighted(applyCodeHighlight(content));
}, 100);
return () => clearTimeout(timer);
}, [content, isStreaming]);
return highlighted;
}
架构全景图
常见面试问题
Q1: AI 应用中 TTFT 很长,如何系统性优化?
答案:
TTFT(Time To First Token)是用户发出请求到看到首个 token 的时间,是 AI 应用最核心的体验指标。优化从减少实际延迟和减少感知延迟两方面入手:
减少实际延迟:
| 策略 | 效果 | 原理 |
|---|---|---|
| Prompt Caching | TTFT 降低 50-80% | 服务端 KV Cache 复用,跳过已缓存的 prompt 计算 |
| 语义缓存 | 命中时 TTFT → ~10ms | 直接返回缓存结果 |
| 模型选择 | TTFT 降低 30-60% | 小模型推理更快(GPT-4o-mini ~300ms vs GPT-4o ~600ms) |
| Edge 部署 | 网络延迟降低 50-200ms | API 路由部署在离用户最近的边缘节点 |
| Prompt 压缩 | TTFT 降低 10-30% | 更短的 prompt = 更少的 prefill 时间 |
减少感知延迟:
- 流式输出:使用 SSE 流式响应,TTFT 后立即显示(详见 流式渲染与 SSE)
- 乐观更新:用户消息立即显示,不等网络
- 分阶段指示器:显示 "连接中 → 思考中 → 生成中",比简单转圈更有信息量
- 预请求:预测用户意图提前发送 LLM 请求
Q2: 什么是 Prompt Caching?如何降低 TTFT?
答案:
Prompt Caching 是 LLM API 提供商实现的服务端优化。当多次请求的 prompt 前缀相同时(如相同的 system prompt),API 会缓存该前缀的 KV(Key-Value)矩阵,后续请求直接复用,无需重新计算。
关键点:
-
为什么能降低 TTFT? LLM 推理分两个阶段:
- Prefill:计算 prompt 所有 token 的 KV,这是 TTFT 的主要来源
- Decode:逐个生成 output token
- Prompt Caching 直接跳过 Prefill 中已缓存的部分,所以 TTFT 显著降低
-
Anthropic vs OpenAI 实现差异:
- Anthropic:需要显式标记
cache_control: { type: 'ephemeral' },可以精确控制缓存断点 - OpenAI:自动缓存(相同前缀超过 1024 tokens 自动触发),无需代码改动
- Anthropic:需要显式标记
-
最佳实践:
- 将不变内容(system prompt、few-shot 示例、工具定义)放在 messages 最前面
- 变化的用户消息放在最后
- 保持前缀完全一致(哪怕改一个字符,后面都需要重新计算)
- Anthropic 缓存 TTL 为 5 分钟(滑动窗口),适合频繁请求的场景
-
成本影响(以 Anthropic Claude Sonnet 4 为例):
- 正常价格:$3.0/M input tokens
- 缓存写入:$3.75/M(+25%)
- 缓存读取:$0.30/M(-90%)
- 假设 90% 命中率,成本约为正常的 15%
Q3: 如何设计 AI 应用的请求优先级队列?
答案:
AI 请求的优先级队列需要解决三个问题:排序执行、并发控制、过期取消。
const queue = new AIRequestQueue(3); // 最大并发 3
// 用户发送消息 → 高优先级
await queue.enqueue(
(signal) => fetch('/api/chat', { signal, body: '...' }),
{ priority: 'high', id: 'msg-1' }
);
// 预加载建议 → 低优先级
await queue.enqueue(
(signal) => fetch('/api/suggest', { signal }),
{ priority: 'low', id: 'suggest-1' }
);
// 用户发送新消息,取消上一个未完成的请求
queue.cancel('msg-1');
设计要点:
| 要点 | 说明 |
|---|---|
| 优先级排序 | high > normal > low,新请求按优先级插入队列 |
| 并发限制 | 限制同时进行的请求数(通常 2-3),避免浏览器连接数耗尽 |
| AbortController | 每个请求绑定独立的 AbortController,支持单独取消 |
| 超时自动取消 | 请求超时后自动 abort,避免无限等待 |
| 过期清理 | 定期清理队列中已过期的请求 |
| 快速提问场景 | 用户连续提问时,取消上一个未完成的流式响应 |
Q4: 对比语义缓存与精确匹配缓存在 AI 应用中的优劣?
答案:
| 维度 | 精确匹配缓存 | 语义缓存 |
|---|---|---|
| 匹配逻辑 | question === cachedQuestion | cosineSimilarity(embed(q1), embed(q2)) >= 0.92 |
| 命中率 | 5-15%(必须一字不差) | 30-60%(语义相似即可命中) |
| 额外延迟 | ~1ms(Redis GET) | ~30-50ms(embedding + 向量搜索) |
| 额外成本 | 几乎为 0 | ~$0.00002/次(embedding API) |
| 误命中风险 | 无 | 有(需调优阈值) |
| 存储 | 仅文本 | 文本 + 向量(每条 ~6KB) |
| 适合场景 | FAQ、固定格式查询 | 开放式对话、知识问答 |
推荐方案:两级缓存
async function twoLevelCache(question: string): Promise<string | null> {
// 第一级:精确匹配(快,无额外成本)
const exact = await redis.get(`exact:${hashString(question)}`);
if (exact) return exact;
// 第二级:语义匹配(慢一点,但命中率高)
const semantic = await semanticCache.get(question, 0.92);
if (semantic) return semantic.answer;
return null;
}
阈值调优建议:
- 0.98+:几乎等同于精确匹配,误命中率极低
- 0.92-0.95:推荐值,在命中率和准确性之间取得平衡
- 0.85-0.90:高命中率但可能出现误命中,适合对准确性要求不高的场景
- < 0.85:不推荐,误命中率过高
Q5: 如何平衡上下文窗口大小与响应质量?
答案:
上下文越多,模型获得的信息越全,回答质量越高;但上下文越大,延迟越高、成本越高、且可能引入噪声导致质量反而下降("迷失在中间" 问题)。
策略选择决策树:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 短对话(< 10 轮) | 保留全部上下文 | 总 token 少,无需优化 |
| 长对话(10-50 轮) | 滑动窗口(保留最近 10 轮) | 简单有效 |
| 超长对话(50+ 轮) | 摘要 + 最近对话 | 旧内容压缩为摘要 |
| 知识库问答 | RAG 检索 | 只取相关文档,不需要全部历史 |
| 复杂分析任务 | 混合策略(摘要 + RAG + 最近对话) | 兼顾上下文完整性和相关性 |
"迷失在中间" (Lost in the Middle) 问题:
研究表明,LLM 在处理长上下文时,对开头和结尾的信息记忆最好,中间部分的信息容易被忽略。因此:
- 最重要的信息放在开头(system prompt)和结尾(最近的用户消息)
- 中间放相关上下文(RAG 检索结果、历史摘要)
- 上下文长度并非越大越好——超过有效利用范围后,质量提升会趋于平缓甚至下降
关于 RAG 检索方案的详细实现,参见 RAG 检索增强生成。
Q6: 如何实现流式指标监控面板?
答案:
流式指标监控需要解决数据采集、实时更新、可视化三个问题:
- 数据采集层:使用单例
MetricsCollector,在每次 AI 请求完成时记录 TTFT、TPS、费用等指标 - 实时更新:基于发布-订阅模式,指标变化时通知所有订阅者(Dashboard 组件)
- 可视化层:React 组件消费指标数据,展示 KPI 卡片和趋势图
关键指标建议:
| 指标 | 阈值(绿/黄/红) | 含义 |
|---|---|---|
| P95 TTFT | < 1s / 1-2s / > 2s | 绝大部分用户的首 token 等待时间 |
| 平均 TPS | > 30 / 15-30 / < 15 | token 生成速度是否流畅 |
| 缓存命中率 | > 40% / 20-40% / < 20% | 缓存策略是否有效 |
| 错误率 | < 1% / 1-5% / > 5% | API 稳定性 |
| 日费用 | 在预算内 / 接近上限 / 超出 | 成本是否可控 |
实现要点:
- 使用
requestAnimationFrame限制图表重绘频率 - 只保留最近 N 条记录(滑动窗口),避免内存无限增长
- P95 等分位数指标比平均值更能反映真实用户体验
- 告警可以接入 Slack/钉钉 webhook 实现自动通知
Q7: Edge Runtime vs Node.js Runtime 用于 AI API 路由各有什么优劣?
答案:
| 维度 | Edge Runtime | Node.js Runtime |
|---|---|---|
| 冷启动 | ~50ms(极快) | ~250-500ms |
| 网络延迟 | 低(全球边缘节点) | 较高(固定区域) |
| 执行时间 | 25-30s 限制 | 10s-5min(可配置) |
| Node.js API | 仅 Web API 子集 | 完整支持 |
| 数据库 | 仅 HTTP 协议(Neon/PlanetScale) | 完整 TCP 连接池 |
| npm 包 | 部分不兼容(无原生模块) | 全部兼容 |
| 包大小 | < 4MB 限制 | 无限制 |
| 适合的 AI 场景 | 简单对话代理转发 | RAG、Function Calling、复杂管线 |
推荐的混合策略:
简单对话请求 → Edge Runtime(低延迟、快响应)
复杂请求(RAG/工具调用/长生成) → Node.js Runtime(完整能力)
选择 Edge Runtime 的条件:
- API 路由逻辑简单(基本是代理转发到 LLM API)
- 不需要直连数据库(或只用 HTTP 协议数据库)
- 不依赖 Node.js 原生模块
- 预期响应时间在 25s 以内
Q8: 如何优化流式 Markdown 渲染性能?
答案:
流式 Markdown 渲染的核心挑战是:每个 token 到达都会改变 Markdown 内容,如果每次都全量重解析+重渲染,60 TPS 意味着每秒 60 次 DOM 更新,导致严重卡顿。
优化策略(按优先级排序):
| 策略 | 效果 | 实现难度 |
|---|---|---|
| RAF 批量更新 | 从 60次/秒 → 16次/秒 | 低 |
| 增量渲染 | 只渲染新增部分 | 中 |
| 代码块延迟高亮 | 避免流式过程中频繁调用 Shiki/Prism | 低 |
| 消息 memo 化 | 已完成的消息不参与重渲染 | 低 |
| 虚拟列表 | 大量消息时只渲染可视区域 | 中 |
| 自适应 chunk 合并 | 根据 TPS 动态调整批量大小 | 中 |
RAF 批量更新是最简单且效果最好的优化——将多个 token 合并到一帧中渲染,比 setInterval 更与浏览器渲染周期同步。具体实现参见本文第四节"流式缓冲策略"。
更多流式渲染细节参见 流式渲染与 SSE。
Q9: 如何估算和控制每月 AI API 费用?
答案:
估算公式:
控制手段(按效果排序):
| 策略 | 节省比例 | 实现方式 |
|---|---|---|
| 模型路由 | 40-80% | 简单问题用小模型(GPT-4o-mini $0.15/M),复杂问题用大模型 |
| 语义缓存 | 30-60% | 相似问题复用缓存,减少 LLM 调用 |
| Prompt Caching | 20-50% | 复用 KV Cache,降低输入费用 |
| Prompt 压缩 | 20-40% | 减少 input token 数 |
| 输出长度限制 | 10-30% | 合理设置 max_tokens |
| 预算告警 | 预防性 | 监控每日/每月 token 消耗,超阈值自动告警 |
| 用户限流 | 预防性 | 对每个用户设置日请求数或 token 上限 |
预算控制系统设计要点:
- 每次请求前预估费用并检查预算余额
- 实际消费后记录真实费用
- 支持日预算 + 月预算 + 用户级限额三层控制
- 接近阈值时自动告警通知
- 超限后可以降级到更便宜的模型而非完全拒绝
Q10: AI 应用如何实现预请求和推测执行?
答案:
// 预测用户可能的下一步操作,提前发送请求
class SpeculativeExecutor {
private cache = new Map<string, Promise<string>>();
// 用户输入时预测可能的完整问题
async prefetch(partialInput: string): Promise<void> {
const predictions = await predictQuestions(partialInput);
for (const question of predictions.slice(0, 2)) {
if (!this.cache.has(question)) {
this.cache.set(question, this.fetchAnswer(question));
}
}
}
// 用户确认发送时,如果命中预测直接返回缓存
async getAnswer(question: string): Promise<string> {
const cached = this.cache.get(question);
if (cached) return cached;
return this.fetchAnswer(question);
}
private async fetchAnswer(question: string): Promise<string> {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages: [{ role: 'user', content: question }] }),
});
return response.text();
}
}
适用场景:
- 搜索建议:用户 hover 搜索建议时预加载答案
- 引导式对话:展示候选问题按钮,用户点击前预请求
- 多轮追问:根据上一轮回答预测可能的追问
- 成本浪费:预测不准确时,预请求的费用白花
- 资源占用:预请求占用并发连接数,可能影响实际请求
- 建议:只在命中率 > 50% 的场景使用,限制预请求并发为 1-2 个
Q11: 如何实现智能模型路由来平衡质量与成本?
答案:
模型路由的核心是用最低成本的模型满足当前请求的质量要求。两种实现方案:
方案一:规则分类器(推荐作为第一版)
function routeByRules(question: string): ModelTier {
// 简单问候 → 最便宜的模型
if (/^(你好|hi|hello|谢谢|ok)/i.test(question.trim())) return 'fast';
// 需要推理 → 推理模型
if (/推理|证明|数学|逻辑/.test(question)) return 'reasoning';
// 代码或复杂分析 → 强模型
if (/```|function|class|原理|架构/.test(question) || question.length > 500) return 'powerful';
// 其他 → 标准模型
return 'standard';
}
方案二:LLM 分类器(准确率更高)
- 用 GPT-4o-mini 判断问题复杂度(额外
200ms、$0.00003/次) - 准确率从规则的 ~75% 提升到 ~92%
混合方案(推荐):先用规则分类,对不确定的 "standard" 级别再用 LLM 二次判断。
成本对比(以月 200,000 次请求为例):
| 方案 | 月费用 | 质量 |
|---|---|---|
| 全部用 GPT-4o | ~$5,000 | 最好 |
| 全部用 GPT-4o-mini | ~$150 | 够用 |
| 规则路由 (70% mini, 30% 4o) | ~$1,600 | 好 |
| LLM 路由 (最优分配) | ~$1,200 | 最优 |
Q12: 实际项目中如何系统性地做 AI 性能优化?给出优先级。
答案:
按投入产出比排序的优化清单:
| 优先级 | 优化项 | 预期效果 | 实现难度 | 适用阶段 |
|---|---|---|---|---|
| P0 | 流式输出 | 感知延迟降低 90% | 低 | Day 1 |
| P0 | 乐观更新 + 骨架屏 | 感知延迟降低 50% | 低 | Day 1 |
| P1 | 模型路由 | 成本降低 40-80% | 中 | 用户增长期 |
| P1 | Prompt Caching | TTFT 降低 50-80% | 低 | 多轮对话场景 |
| P1 | 预算控制 | 避免费用失控 | 中 | 上线前 |
| P2 | 语义缓存 | 成本降低 30-60% | 高 | 规模化后 |
| P2 | RAF 批量渲染 | 渲染卡顿消除 | 低 | 有性能问题时 |
| P2 | 请求优先级队列 | 用户体验提升 | 中 | 复杂交互场景 |
| P3 | 上下文窗口优化 | 成本降低 20%,质量提升 | 高 | 长对话场景 |
| P3 | Edge 部署 | 网络延迟降低 50-200ms | 中 | 全球用户 |
| P3 | 监控面板 | 数据驱动优化 | 中 | 持续优化期 |
- 先做感知优化(流式、骨架屏),这是 Day 1 就该有的
- 再做成本优化(模型路由、缓存),否则费用会随用户增长爆炸
- 最后做精细化优化(Edge、上下文优化),有数据支撑再针对性优化
- 全程监控指标,用数据驱动优化决策,而非凭感觉
Q13: 长对话内存溢出怎么解决?
答案:
长对话场景下内存持续增长的四大根因及对应解决方案:
根因一:对话历史无限累积
每条消息都存在 React state 中,几百轮对话后 messages 数组可达数十 MB(含 Markdown AST、代码块等富内容)。
import { useRef, useState, useCallback } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
interface ConversationMemoryOptions {
/** 内存中保留的最大消息数 */
maxInMemory?: number;
/** 发送给 API 的最大消息数(上下文窗口管理) */
maxForAPI?: number;
}
/**
* 长对话内存管理 Hook
* 策略:滑动窗口 + IndexedDB 归档 + API 上下文压缩
*/
export function useConversationMemory(options: ConversationMemoryOptions = {}) {
const { maxInMemory = 50, maxForAPI = 20 } = options;
const [messages, setMessages] = useState<Message[]>([]);
const archivedCountRef = useRef(0);
const addMessage = useCallback((msg: Message) => {
setMessages((prev) => {
const next = [...prev, msg];
// 超出内存限制时,将旧消息归档到 IndexedDB
if (next.length > maxInMemory) {
const toArchive = next.slice(0, next.length - maxInMemory);
archiveToIndexedDB(toArchive); // 异步归档,不阻塞渲染
archivedCountRef.current += toArchive.length;
return next.slice(-maxInMemory);
}
return next;
});
}, [maxInMemory]);
// 构建 API 请求时的上下文窗口
const getAPIMessages = useCallback((): Message[] => {
if (messages.length <= maxForAPI) return messages;
// 保留第一条(系统上下文)+ 最近 N 条
// 中间用摘要替代,避免丢失关键上下文
const first = messages[0];
const recent = messages.slice(-maxForAPI + 2);
const summary: Message = {
id: 'summary',
role: 'assistant',
content: `[系统摘要:此前有 ${messages.length - recent.length - 1} 条对话已省略]`,
timestamp: Date.now(),
};
return [first, summary, ...recent];
}, [messages, maxForAPI]);
return { messages, addMessage, getAPIMessages, archivedCount: archivedCountRef.current };
}
async function archiveToIndexedDB(messages: Message[]): Promise<void> {
const db = await openDB();
const tx = db.transaction('messages', 'readwrite');
for (const msg of messages) {
tx.objectStore('messages').put(msg);
}
await tx.done;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('chat-archive', 1);
request.onupgradeneeded = () => {
request.result.createObjectStore('messages', { keyPath: 'id' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
根因二:Blob URL / ObjectURL 未释放
图片预览、文件上传等场景中 URL.createObjectURL() 创建的 Blob URL 不会自动回收:
/**
* 自动回收 Blob URL 的 Hook
*/
import { useEffect, useRef } from 'react';
export function useBlobURLManager() {
const urlsRef = useRef<Set<string>>(new Set());
const createURL = (blob: Blob): string => {
const url = URL.createObjectURL(blob);
urlsRef.current.add(url);
return url;
};
const revokeURL = (url: string): void => {
URL.revokeObjectURL(url);
urlsRef.current.delete(url);
};
// 组件卸载时自动回收所有 Blob URL
useEffect(() => {
return () => {
urlsRef.current.forEach((url) => URL.revokeObjectURL(url));
urlsRef.current.clear();
};
}, []);
return { createURL, revokeURL };
}
根因三:流式渲染的中间状态未清理
流式输出时每个 token 都在拼接字符串,如果用 += 拼接且保留中间引用,V8 的字符串 cons string 结构可能导致旧字符串无法被 GC:
// ❌ 错误:闭包持有 ref 的中间值
const contentRef = useRef('');
onToken((token) => {
contentRef.current += token; // cons string 链越来越长
});
// ✅ 正确:定期做字符串 flatten
const contentRef = useRef('');
const tokenBufferRef = useRef<string[]>([]);
onToken((token) => {
tokenBufferRef.current.push(token);
// 每 50 个 token 合并一次,打断 cons string 链
if (tokenBufferRef.current.length >= 50) {
contentRef.current = contentRef.current + tokenBufferRef.current.join('');
tokenBufferRef.current = [];
}
});
根因四:消息列表 DOM 节点膨胀
几百条消息 × 每条消息的 Markdown DOM = 数万个 DOM 节点,浏览器内存和渲染压力都很大:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 消息虚拟化 | 只渲染可视区域的消息,用 react-window / @tanstack/virtual | 消息数 > 100 |
| 已读消息简化 | 滚出视口的消息替换为纯文本摘要(移除 Markdown AST) | 消息含大量代码块 |
| 分页加载 | 向上滚动时从 IndexedDB 加载历史消息 | 超长对话(500+条) |
前端内存管理只解决客户端问题。发送给 LLM 的消息同样需要控制:
- Token 计数:用
tiktoken(OpenAI)或@anthropic-ai/tokenizer计算当前上下文占用 - 滑动窗口:只发送最近 N 条 + system prompt,超出部分用摘要替代
- 自动摘要:每 10-20 轮让模型生成一段对话摘要,替换原始历史
- Prompt Caching:利用 Anthropic Prompt Caching 或 OpenAI 的缓存前缀,减少重复 token 计费
完整治理策略:
相关链接
- 流式渲染与 SSE - 流式响应处理详解
- 前端接入大模型 API - API 接入与 Token 计费
- AI 对话界面设计 - 消息列表与渲染组件
- AI SDK 与框架 - SDK 内置的性能优化
- RAG 检索增强生成 - RAG 架构与上下文管理
- 向量搜索与语义化 - Embedding 与向量搜索原理
- 渲染优化 - 通用渲染优化策略
- 长列表优化 - 虚拟列表实现
- 内存优化 - 通用内存优化策略
- Web 性能指标与监控系统 - 通用性能监控方案