跳到主要内容

AI 应用性能优化

问题

AI 应用在前端有哪些独特的性能挑战?如何优化 LLM 响应速度、降低成本、提升用户体验?

答案

AI 应用的性能优化与传统 Web 应用存在本质差异——传统应用的 API 响应通常在 50-200ms,而 LLM 的单次响应可能需要 1-30 秒,且延迟主要由模型推理决定,前端几乎无法直接加速。因此优化策略需要从指标度量、感知优化、缓存策略、请求管理、上下文优化、部署架构、渲染性能、成本控制八个维度系统展开。

一、AI 应用核心性能指标

1.1 指标定义

指标全称含义优秀值可接受值
TTFTTime To First Token首个 token 到达时间< 500ms< 1.5s
TPSTokens Per Secondtoken 生成速率> 50 tps> 20 tps
TTLTime To Last Token完整响应生成时间取决于长度-
E2E LatencyEnd-to-End Latency用户发送到看到首字< 1s< 2s
Cache Hit Rate-缓存命中率> 40%> 20%
Cost per Query-单次查询成本< $0.01< $0.05

1.2 主流模型 TTFT 实测基准(2025)

不同模型的 TTFT 差异显著,选择模型时需要将延迟纳入考量:

模型平均 TTFTTPS输入价格 ($/M tokens)输出价格 ($/M tokens)
GPT-4o~400-800ms60-80$2.50$10.00
GPT-4o-mini~200-400ms80-120$0.15$0.60
Claude Sonnet 4~300-600ms50-70$3.00$15.00
Claude Haiku 3.5~200-350ms80-100$0.80$4.00
Gemini 2.0 Flash~200-400ms70-90$0.10$0.40
DeepSeek V3~300-500ms40-60$0.27$1.10
TTFT 的影响因素

实测 TTFT 会受多种因素影响:prompt 长度(越长越慢)、API 区域(距离越远越慢)、服务器负载(高峰时段更慢)、是否命中 Prompt Cache(命中后可降低 50-80%)。上表为中等长度 prompt (~1000 tokens) 的典型值。

1.3 性能度量实现

lib/ai-metrics.ts
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 对比

特性AnthropicOpenAIGoogle
启用方式在 message 中添加 cache_control 标记自动(相同前缀自动缓存)cachedContent 中设置
最小缓存长度1024 tokens (Sonnet),2048 tokens (Haiku)1024 tokens32,768 tokens
缓存 TTL5 分钟(滑动窗口)~5-10 分钟可自定义(1min-无限)
价格优惠写入 +25%,读取 -90%读取 -50%存储按小时收费,读取 -75%
缓存命中标识cache_creation_input_tokens / cache_read_input_tokenscached_tokens in usage响应头标识

2.3 实现示例

lib/prompt-cache.ts
// ---- 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)}%)`);
}
Prompt Caching 最佳实践
  1. 将不变内容放在前面:system prompt、few-shot 示例、工具定义等固定内容放在 messages 最前面,变化的用户消息放在最后
  2. 超过最小长度才有效:Anthropic 要求至少 1024 tokens 才能触发缓存
  3. 保持前缀一致:哪怕修改一个字符,该位置之后的所有内容都需要重新计算
  4. 利用 5 分钟窗口:频繁请求的应用场景(如多轮对话)天然受益,低频场景效果有限

三、感知优化

当 LLM 延迟不可控时,让用户感觉快比实际快更重要。研究表明,有视觉反馈的等待体验比无反馈的等待感觉快约 35%。

components/PerceptualOptimization.tsx
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 批量渲染

hooks/useBufferedStream.ts
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 大小优化

lib/chunk-optimizer.ts
/**
* 自适应 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();
}
}
为什么不直接用 setInterval?

requestAnimationFramesetInterval 更适合流式渲染批量更新:

  1. 与浏览器渲染周期同步:RAF 在每帧绘制前调用,不会产生中间帧的无意义更新
  2. 后台自动暂停:页面不可见时 RAF 停止,节省 CPU
  3. 更精确的时序:避免 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 + 向量搜索

lib/semantic-cache.ts
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 缓存中间件

lib/cache-middleware.ts
// 将语义缓存集成为请求中间件
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 请求优先级队列

lib/ai-request-queue.ts
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 实际使用:取消过期请求

hooks/useAIChat.ts
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 实现

lib/context-optimizer.ts
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 RuntimeNode.js Runtime
冷启动~50ms~250-500ms
执行时间限制25-30s (Vercel)10-60s (可配置)
运行位置全球 CDN 边缘节点固定区域服务器
API 兼容性Web API 子集(无 fs, net, child_process)完整 Node.js API
代码大小< 4MB (Vercel)无限制
流式支持原生 Web StreamsNode.js Streams / Web Streams
数据库连接仅 HTTP(无 TCP 长连接)支持连接池
适用场景代理转发、轻量计算复杂业务逻辑、数据库操作

8.2 Edge AI 路由实现

app/api/chat/route.ts
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 的限制
  1. 不能直连数据库:Edge Runtime 不支持 TCP 长连接,需要使用 HTTP 协议的数据库服务(如 Neon Serverless、PlanetScale、Turso)
  2. 执行时间受限:长时间流式响应可能超时(Vercel Edge 默认 25s),复杂的 AI 任务建议用 Node.js Runtime
  3. npm 包兼容性:依赖 Node.js 原生模块(如 sharpbcrypt)的包无法在 Edge 运行
  4. 调试困难:本地开发环境和 Edge 运行环境差异较大

8.3 混合部署策略(推荐)

middleware.ts
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 基于分类器的模型路由

lib/model-router.ts
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 的路由分类器

lib/llm-classifier-router.ts
/**
* 用小模型做路由判断(更准确但有额外延迟)
* 适用于对质量要求高的场景
*/
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 请求的性能指标。以下实现一个可视化的流式指标仪表盘组件。

components/AIMetricsDashboard.tsx
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-4o2000 tokens500 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
优化前后费用差距可达 10 倍

从月 60,000优化到60,000 优化到 5,400,节省 91%。模型路由是最大的单项优化(节省 70%),其次是语义缓存(节省 40%)。

11.2 预算控制系统

lib/budget-controller.ts
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
});

十二、渲染性能优化

components/OptimizedMessageList.tsx
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 CachingTTFT 降低 50-80%服务端 KV Cache 复用,跳过已缓存的 prompt 计算
语义缓存命中时 TTFT → ~10ms直接返回缓存结果
模型选择TTFT 降低 30-60%小模型推理更快(GPT-4o-mini ~300ms vs GPT-4o ~600ms)
Edge 部署网络延迟降低 50-200msAPI 路由部署在离用户最近的边缘节点
Prompt 压缩TTFT 降低 10-30%更短的 prompt = 更少的 prefill 时间

减少感知延迟:

  1. 流式输出:使用 SSE 流式响应,TTFT 后立即显示(详见 流式渲染与 SSE
  2. 乐观更新:用户消息立即显示,不等网络
  3. 分阶段指示器:显示 "连接中 → 思考中 → 生成中",比简单转圈更有信息量
  4. 预请求:预测用户意图提前发送 LLM 请求

Q2: 什么是 Prompt Caching?如何降低 TTFT?

答案

Prompt Caching 是 LLM API 提供商实现的服务端优化。当多次请求的 prompt 前缀相同时(如相同的 system prompt),API 会缓存该前缀的 KV(Key-Value)矩阵,后续请求直接复用,无需重新计算。

关键点:

  1. 为什么能降低 TTFT? LLM 推理分两个阶段:

    • Prefill:计算 prompt 所有 token 的 KV,这是 TTFT 的主要来源
    • Decode:逐个生成 output token
    • Prompt Caching 直接跳过 Prefill 中已缓存的部分,所以 TTFT 显著降低
  2. Anthropic vs OpenAI 实现差异:

    • Anthropic:需要显式标记 cache_control: { type: 'ephemeral' },可以精确控制缓存断点
    • OpenAI:自动缓存(相同前缀超过 1024 tokens 自动触发),无需代码改动
  3. 最佳实践:

    • 将不变内容(system prompt、few-shot 示例、工具定义)放在 messages 最前面
    • 变化的用户消息放在最后
    • 保持前缀完全一致(哪怕改一个字符,后面都需要重新计算)
    • Anthropic 缓存 TTL 为 5 分钟(滑动窗口),适合频繁请求的场景
  4. 成本影响(以 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 === cachedQuestioncosineSimilarity(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 在处理长上下文时,对开头和结尾的信息记忆最好,中间部分的信息容易被忽略。因此:

  1. 最重要的信息放在开头(system prompt)和结尾(最近的用户消息)
  2. 中间放相关上下文(RAG 检索结果、历史摘要)
  3. 上下文长度并非越大越好——超过有效利用范围后,质量提升会趋于平缓甚至下降

关于 RAG 检索方案的详细实现,参见 RAG 检索增强生成

Q6: 如何实现流式指标监控面板?

答案

流式指标监控需要解决数据采集、实时更新、可视化三个问题:

  1. 数据采集层:使用单例 MetricsCollector,在每次 AI 请求完成时记录 TTFT、TPS、费用等指标
  2. 实时更新:基于发布-订阅模式,指标变化时通知所有订阅者(Dashboard 组件)
  3. 可视化层:React 组件消费指标数据,展示 KPI 卡片和趋势图

关键指标建议:

指标阈值(绿/黄/红)含义
P95 TTFT< 1s / 1-2s / > 2s绝大部分用户的首 token 等待时间
平均 TPS> 30 / 15-30 / < 15token 生成速度是否流畅
缓存命中率> 40% / 20-40% / < 20%缓存策略是否有效
错误率< 1% / 1-5% / > 5%API 稳定性
日费用在预算内 / 接近上限 / 超出成本是否可控

实现要点:

  • 使用 requestAnimationFrame 限制图表重绘频率
  • 只保留最近 N 条记录(滑动窗口),避免内存无限增长
  • P95 等分位数指标比平均值更能反映真实用户体验
  • 告警可以接入 Slack/钉钉 webhook 实现自动通知

Q7: Edge Runtime vs Node.js Runtime 用于 AI API 路由各有什么优劣?

答案

维度Edge RuntimeNode.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 的条件:

  1. API 路由逻辑简单(基本是代理转发到 LLM API)
  2. 不需要直连数据库(或只用 HTTP 协议数据库)
  3. 不依赖 Node.js 原生模块
  4. 预期响应时间在 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 费用?

答案

估算公式:

月费用=DAU×日均请求数×30×单次费用\text{月费用} = \text{DAU} \times \text{日均请求数} \times 30 \times \text{单次费用} 单次费用=输入 tokens×输入单价+输出 tokens×输出单价1,000,000\text{单次费用} = \frac{\text{输入 tokens} \times \text{输入单价} + \text{输出 tokens} \times \text{输出单价}}{1{,}000{,}000}

控制手段(按效果排序):

策略节省比例实现方式
模型路由40-80%简单问题用小模型(GPT-4o-mini $0.15/M),复杂问题用大模型
语义缓存30-60%相似问题复用缓存,减少 LLM 调用
Prompt Caching20-50%复用 KV Cache,降低输入费用
Prompt 压缩20-40%减少 input token 数
输出长度限制10-30%合理设置 max_tokens
预算告警预防性监控每日/每月 token 消耗,超阈值自动告警
用户限流预防性对每个用户设置日请求数或 token 上限

预算控制系统设计要点:

  • 每次请求前预估费用并检查预算余额
  • 实际消费后记录真实费用
  • 支持日预算 + 月预算 + 用户级限额三层控制
  • 接近阈值时自动告警通知
  • 超限后可以降级到更便宜的模型而非完全拒绝

Q10: AI 应用如何实现预请求和推测执行?

答案

lib/speculative-execution.ts
// 预测用户可能的下一步操作,提前发送请求
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 搜索建议时预加载答案
  • 引导式对话:展示候选问题按钮,用户点击前预请求
  • 多轮追问:根据上一轮回答预测可能的追问
推测执行的风险
  1. 成本浪费:预测不准确时,预请求的费用白花
  2. 资源占用:预请求占用并发连接数,可能影响实际请求
  3. 建议:只在命中率 > 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%用户增长期
P1Prompt CachingTTFT 降低 50-80%多轮对话场景
P1预算控制避免费用失控上线前
P2语义缓存成本降低 30-60%规模化后
P2RAF 批量渲染渲染卡顿消除有性能问题时
P2请求优先级队列用户体验提升复杂交互场景
P3上下文窗口优化成本降低 20%,质量提升长对话场景
P3Edge 部署网络延迟降低 50-200ms全球用户
P3监控面板数据驱动优化持续优化期
核心原则
  1. 先做感知优化(流式、骨架屏),这是 Day 1 就该有的
  2. 再做成本优化(模型路由、缓存),否则费用会随用户增长爆炸
  3. 最后做精细化优化(Edge、上下文优化),有数据支撑再针对性优化
  4. 全程监控指标,用数据驱动优化决策,而非凭感觉

Q13: 长对话内存溢出怎么解决?

答案

长对话场景下内存持续增长的四大根因及对应解决方案:

根因一:对话历史无限累积

每条消息都存在 React state 中,几百轮对话后 messages 数组可达数十 MB(含 Markdown AST、代码块等富内容)。

hooks/useConversationMemory.ts
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 不会自动回收:

utils/blob-cleanup.ts
/**
* 自动回收 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:

hooks/useStreamBuffer.ts
// ❌ 错误:闭包持有 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+条)
API 层面的上下文管理同样重要

前端内存管理只解决客户端问题。发送给 LLM 的消息同样需要控制:

  • Token 计数:用 tiktoken(OpenAI)或 @anthropic-ai/tokenizer 计算当前上下文占用
  • 滑动窗口:只发送最近 N 条 + system prompt,超出部分用摘要替代
  • 自动摘要:每 10-20 轮让模型生成一段对话摘要,替换原始历史
  • Prompt Caching:利用 Anthropic Prompt Caching 或 OpenAI 的缓存前缀,减少重复 token 计费

完整治理策略

相关链接