前端:React 18 + TypeScript + Vite AI:Gemini 2.5 Flash + OpenRouter 后端:Supabase + PostgreSQL 部署:Vercel 核心难点:如何把 AI 响应时间从 3 秒优化到 0.6 秒
一、项目背景
作为一个历史爱好者 + 全栈开发,我一直想做一个「用 AI 改写历史」的策略游戏。传统游戏的问题是脚本化严重,玩几遍就没新鲜感。
2024 年 12 月,当我看到 Gemini 2.5 Flash 的速度指标时,我意识到时机到了:
- Gemini 2.5 Flash Lite:平均响应 600ms
- 支持结构化输出(
responseSchema) - 免费额度足够测试
于是我用 3 周时间,独立完成了 Rewrite History: War Room。
项目地址:rewritehistory.app
二、技术架构
2.1 整体架构图
2.2 技术栈选型
| 技术 | 选型 | 理由 |
|---|---|---|
| 前端框架 | React 18 | 熟悉的技术栈,生态完善 |
| 构建工具 | Vite | 冷启动快,HMR 体验好 |
| 类型安全 | TypeScript | AI 返回结构复杂,必须强类型 |
| AI 模型 | Gemini 2.5 Flash | 速度最快(0.6s),支持结构化输出 |
| API 代理 | OpenRouter | 统一 API 接口,支持多模型 A/B 测试 |
| 后端服务 | Supabase | 开箱即用的 Auth + Database |
| 部署平台 | Vercel | 自动 HTTPS + CDN,秒级部署 |
为什么不用 Next.js?
- 纯客户端应用,不需要 SSR
- Vite 的开发体验更丝滑
- 打包体积更小(gzip 后 < 100KB)
三、核心功能实现
3.1 AI 服务封装
整个项目最核心的文件是 services/geminiService.ts(700+ 行),负责:
- AI 模型选择与调度
- Prompt 构建与优化
- 响应缓存与容错
- 性能监控与分析
3.1.1 主流程代码
// services/geminiService.ts
export const processTurn = async (
actionId: string,
actionLabel: string,
currentTurn: number,
turnState: TurnState,
battleConfig: BattleConfig,
sessionId?: string
): Promise<TurnState> => {
// 1. 检查缓存
const cached = responseCache.get(
battleConfig.name,
currentTurn,
actionId,
turnState.userTroops,
turnState.enemyTroops
);
if (cached) {
console.log('🎯 Cache hit!');
return cached;
}
// 2. 选择 AI 模型(加权随机)
const selectedModel = selectModel();
// 3. 构建 Prompt
const systemInstruction = buildSystemPrompt(battleConfig);
const userPrompt = buildUserPrompt(currentTurn, actionLabel, turnState);
// 4. 调用 AI(双重容错)
let result: TurnState;
// 尝试 OpenRouter
if (openrouterApiKey) {
try {
result = await withTimeout(
callOpenRouterAPI(systemInstruction, userPrompt, selectedModel),
60000 // 60 秒超时
);
// 记录性能数据
analytics.recordAPICall({
session_id: sessionId,
model_name: selectedModel.id,
response_time_ms: duration,
success: true
});
// 存入缓存
responseCache.set(
battleConfig.name,
currentTurn,
actionId,
turnState.userTroops,
turnState.enemyTroops,
result
);
return result;
} catch (err) {
console.error('OpenRouter failed, trying Gemini fallback...');
}
}
// 备用方案:Gemini 直连
if (geminiApiKey) {
try {
const ai = new GoogleGenAI({ apiKey: geminiApiKey });
const response = await withTimeout(
ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: userPrompt,
config: {
temperature: 0.7,
responseSchema: turnStateSchema // 强制 JSON 输出
}
}),
60000
);
result = JSON.parse(response.text);
responseCache.set(...); // 同样存入缓存
return result;
} catch (err) {
console.error('All AI services failed');
throw new Error('AI service unavailable');
}
}
throw new Error('No AI service configured');
};
关键设计点:
- 缓存优先:30% 请求命中缓存,< 10ms 响应
- 双重容错:OpenRouter 失败自动切换 Gemini
- 结构化输出:使用 responseSchema 强制返回 JSON
- 性能监控:每次调用记录响应时间和 token 用量
3.1.2 Timeout 实现
AI 调用最大的问题是可能无限等待。我实现了一个简单的 timeout wrapper:
// services/geminiService.ts
const withTimeout = <T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(
() => reject(new Error('Request timeout')),
timeoutMs
)
)
]);
};
为什么是 60 秒?
- PC 端通常 0.6-1 秒就返回
- 移动端网络慢,可能需要 5-10 秒
- 60 秒是极端情况的兜底
3.2 响应缓存系统
AI 调用很贵(时间 + 金钱),必须做缓存。但游戏状态是动态的,怎么缓存?
3.2.1 缓存键生成策略
// services/responseCache.ts
class ResponseCacheService {
private cache: Map<string, CacheEntry>;
// 核心:如何生成缓存键
private generateKey(
battleName: string,
turnNumber: number,
actionId: string,
userTroops: number,
enemyTroops: number
): string {
// 关键设计:分组相似状态
// 1. 回合数分组(每 5 回合一组)
const turnBucket = Math.floor(turnNumber / 5) * 5;
// 2. 兵力分组(千人为单位)
const userRange = Math.floor(userTroops / 1000) * 1000;
const enemyRange = Math.floor(enemyTroops / 1000) * 1000;
return `${battleName}|T${turnBucket}|A${actionId}|U${userRange}|E${enemyRange}`;
}
get(/* ... */): TurnState | null {
const key = this.generateKey(...);
const entry = this.cache.get(key);
if (entry) {
entry.hits++; // 统计命中次数
entry.timestamp = Date.now(); // 更新时间戳(LRU)
return JSON.parse(JSON.stringify(entry.response)); // 深拷贝
}
return null;
}
set(/* ... */): void {
const key = this.generateKey(...);
// LRU 淘汰
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
this.evictLRU();
}
this.cache.set(key, {
key,
response: JSON.parse(JSON.stringify(response)), // 深拷贝
timestamp: Date.now(),
hits: 0
});
}
// LRU 淘汰算法
private evictLRU(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
}
export const responseCache = new ResponseCacheService(50); // 最多 50 条
设计思路:
- 相似状态合并:兵力 8500 和 8300 算同一量级
- 回合分组:第 3 回合和第 4 回合算同一阶段
- LRU 淘汰:优先保留最近使用的缓存
- 深拷贝:防止外部修改缓存数据
效果:
- 命中率:~30%
- 命中时响应:< 10ms
- 内存占用:< 5MB
3.3 多模型 A/B 测试
为了找到最快的 AI 模型,我实现了一个加权随机选择系统:
// services/modelConfig.ts
export interface AIModel {
id: string;
name: string;
provider: 'openrouter' | 'gemini';
enabled: boolean;
weight: number; // 权重
}
export const AI_MODELS: AIModel[] = [
{
id: 'google/gemini-2.5-flash-lite',
name: 'Gemini 2.5 Flash Lite',
provider: 'openrouter',
enabled: true,
weight: 50 // 50% 概率
},
{
id: 'google/gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'openrouter',
enabled: true,
weight: 50 // 50% 概率
}
];
// 加权随机选择
export function selectModel(): AIModel {
const enabledModels = AI_MODELS.filter(m => m.enabled);
const totalWeight = enabledModels.reduce((sum, m) => sum + m.weight, 0);
let random = Math.random() * totalWeight;
for (const model of enabledModels) {
random -= model.weight;
if (random <= 0) {
return model;
}
}
return enabledModels[0]; // 兜底
}
实际测试结果:
| 模型 | 平均响应时间 | 成功率 | 最终决策 |
|---|---|---|---|
| Gemini 2.5 Flash Lite | 0.60s | 98% | ✅ 启用 |
| Gemini 2.5 Flash | 0.72s | 99% | ✅ 启用 |
| DeepSeek Chat V3 | 0.72s (简单) / 2.5s (复杂) | 95% | ❌ 禁用 |
| Grok 4.1 Fast | 1.66s | 97% | ❌ 禁用 |
| GPT-5 Mini | N/A | 40%(返回空) | ❌ 禁用 |
结论:只启用 Gemini 2.5 Flash 系列。
3.4 Prompt 工程
AI 的表现高度依赖 Prompt 设计。我的 system prompt 长达 200+ 行,核心结构:
// services/geminiService.ts
function buildSystemPrompt(battleConfig: BattleConfig): string {
const isChineseLanguage = battleConfig.language === 'zh';
return `
角色定义
你是一个历史战争模拟引擎,负责生成回合制战斗的动态剧情。
关键约束
${isChineseLanguage
? 'CRITICAL: 所有输出必须使用简体中文!包括 situationReport、lastActionOutcome、options.label、options.description 等所有字段。'
: 'Output in English. Use formal military terminology.'
}
历史背景
战役:${battleConfig.name}
时代:${battleConfig.era}
时间跨度:每回合 ${battleConfig.turnDuration}
可用武器(根据时代)
${battleConfig.era === '1863'
? '滑膛枪、来复枪、加农炮、骑兵军刀'
: '根据实际时代'}
禁止事项
- 不允许超时代武器(如 1863 年不能有机关枪)
- 不允许魔幻战果(如 100 人击败 10000 人)
- 不允许瞬间恢复士气到 100
输出格式
必须返回 JSON,结构如下:
{
"situationReport": "当前战况描述(150-200字)",
"lastActionOutcome": "上一回合行动结果(100-150字)",
"options": [
{
"id": "option_1",
"label": "行动简短标题",
"description": "详细描述(80-120字)",
"risk": "Low" | "Medium" | "High" | "Extreme"
}
],
"userTroops": 8500,
"enemyTroops": 9200,
"userMorale": 75,
"enemyMorale": 68
}
风险等级设计
- Low: 稳定局势,小幅改善,几乎无损失
- Medium: 中等收益,可控损失,试探敌军
- High: 显著优势,重大损失,决战时刻
- Extreme: 可能逆转或崩盘,孤注一掷
历史人物性格
${battleConfig.commander
? `你在模拟 ${battleConfig.commander} 的决策风格:${battleConfig.commanderTraits}`
: ''}
数值计算规则
- 战斗损失:根据兵力对比、地形、士气综合计算
- 士气变化:胜利 +5
15,失败 -1020,重大失败 -30 - 兵力损失:进攻方通常损失 1.2~1.5 倍于防守方
- 弹药消耗:每回合战斗消耗 5%~15%
重要提醒
- 战况要有连贯性,前后呼应
- 选项要有明确的风险-收益权衡
- 不要重复之前的剧情
- 结果要符合军事常识 `.trim(); }
关键设计:
- CRITICAL 标记:强调语言要求(否则 AI 会中英混杂)
- 时代约束:明确可用武器和技术
- 数值规则:指导 AI 计算合理的战斗结果
- 风险定义:确保 4 个选项有明确区分
四、性能优化实战
4.1 问题:AI 响应太慢
初始状态:
- OpenRouter API:平均 3-5 秒
- Gemini API:平均 2-3 秒
- 用户体验:卡顿严重
优化过程:
优化 1:模型选择(3s → 0.8s)
测试脚本:test-model-speed.js
node test-model-speed.js
结果:
Gemini 2.5 Flash Lite: 0.60s ✅ Gemini 2.5 Flash: 0.72s ✅ DeepSeek Chat V3: 2.5s (复杂 prompt) ❌ Grok 4.1 Fast: 1.66s ❌
决策:只用最快的模型。
优化 2:强制结构化输出(0.8s → 0.7s)
// 之前:自由文本 → 需要解析
const response = await ai.chat.completions.create({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: prompt }]
});
const result = JSON.parse(response.choices[0].message.content);
// 之后:强制 JSON → 直接使用
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
config: {
responseSchema: turnStateSchema // Gemini 特有功能
}
});
const result = response.data; // 已经是 JSON
效果:
- 避免 AI 返回格式错误
- 省去前端解析时间
- 响应时间减少 ~100ms
优化 3:智能缓存(0.7s → 0.6s 平均)
// 缓存命中率:~30% // 命中时响应:< 10ms // 平均响应 = 0.7s * 70% + 0.01s * 30% = 0.49s + 0.003s ≈ 0.5s
// 实际略高(约 0.6s)因为: // - 缓存查询有开销 // - 不是所有请求都能命中
优化 4:预连接优化
效果:首次请求减少 ~200ms DNS 查询时间。
4.2 问题:移动端超时失败
现象:
- PC 端成功率 99%
- 移动端成功率 70%(30% 超时)
原因分析:
- 移动网络慢,30 秒超时不够
- 弱网环境下 TCP 握手就要 5-10 秒
解决方案:
// 延长移动端超时时间
const isMobile = /Mobile|Android|iPhone/i.test(navigator.userAgent);
const timeout = isMobile ? 60000 : 30000; // 移动端 60 秒
const result = await withTimeout(
callOpenRouterAPI(...),
timeout
);
效果:移动端成功率提升到 95%+
4.3 问题:Supabase 数据库报错
错误信息: value out of range for type integer
原因:
// Bug:错误的时间计算
const totalDuration = Date.now() - performanceStartTime;
analytics.recordAPICall({
response_time_ms: totalDuration - performanceStartTime // 第二次减法!
});
// 结果:负数!PostgreSQL integer 类型不支持负数
修复:
const geminiDuration = Date.now() - geminiStartTime;
analytics.recordAPICall({
response_time_ms: geminiDuration // 正确
});
五、部署与 DevOps
5.1 环境变量配置
# .env
VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJxxx...
GEMINI_API_KEY=AIzaSyxxx...
OPENROUTER_API_KEY=sk-or-v1-xxx...
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
define: {
// 注入到浏览器环境
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.OPENROUTER_API_KEY': JSON.stringify(env.OPENROUTER_API_KEY),
}
};
});
安全注意:
- ✅ Supabase anon key 可以暴露(有 RLS 保护)
- ❌ AI API key 不应暴露(但前端调用无法避免)
- 🔧 未来计划:使用 Vercel Serverless Functions 隐藏 API key
5.2 Vercel 部署配置
// vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite",
"env": {
"GEMINI_API_KEY": "@gemini-api-key",
"OPENROUTER_API_KEY": "@openrouter-api-key"
}
}
部署流程:
1. 推送代码到 GitHub
git push origin main
2. Vercel 自动构建部署
- 检测到 main 分支更新
- 自动运行 npm run build
- 部署到 CDN
- 生成预览链接
3. 完成!
rewritehistory.app
六、数据分析与监控
6.1 Supabase 数据库设计
-- 游戏记录表
CREATE TABLE game_sessions (
id UUID PRIMARY KEY,
session_id TEXT NOT NULL,
battle_name TEXT NOT NULL,
started_at TIMESTAMPTZ DEFAULT NOW(),
total_turns INTEGER,
game_result TEXT, -- 'VICTORY' | 'DEFEAT'
final_user_troops INTEGER
);
-- API 性能表
CREATE TABLE api_calls (
id UUID PRIMARY KEY,
session_id TEXT NOT NULL,
turn_number INTEGER,
model_name TEXT,
response_time_ms INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
success BOOLEAN,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 性能汇总视图
CREATE VIEW model_performance AS
SELECT
model_name,
COUNT(*) as total_calls,
AVG(response_time_ms) as avg_response_time,
MIN(response_time_ms) as min_response_time,
MAX(response_time_ms) as max_response_time,
SUM(CASE WHEN success THEN 1 ELSE 0 END)::FLOAT / COUNT(*) * 100 as success_rate
FROM api_calls
GROUP BY model_name
ORDER BY avg_response_time ASC;
查询示例:
-- 哪个模型最快?
SELECT * FROM model_performance;
-- 平均每局游戏多少回合?
SELECT AVG(total_turns) FROM game_sessions WHERE game_result IS NOT NULL;
-- 哪个战役最受欢迎?
SELECT battle_name, COUNT(*) FROM game_sessions GROUP BY battle_name;
---
6.2 前端性能监控
// services/analyticsService.ts
class AnalyticsService {
async recordAPICall(record: APICallRecord): Promise<void> {
if (!supabase) return;
await supabase.insert('api_calls', {
session_id: record.session_id,
turn_number: record.turn_number,
model_name: record.model_name,
provider: record.provider,
response_time_ms: record.response_time_ms,
prompt_tokens: record.prompt_tokens,
completion_tokens: record.completion_tokens,
success: record.success,
error_message: record.error_message
});
}
}
export const analytics = new AnalyticsService();
实际数据(上线 7 天):
- 总调用次数:2,347 次
- 平均响应时间:672ms
- 成功率:97.8%
- 缓存命中率:28.3%
七、踩坑记录
坑 1:AI 输出不稳定
现象:
- 偶尔返回纯文本而非 JSON
- 偶尔返回中英混杂
原因:
- AI 模型的"自由意志"
- Prompt 约束不够强
解决:
// 1. 使用 responseSchema 强制 JSON
config: {
responseSchema: turnStateSchema
}
// 2. Prompt 加 CRITICAL 标记
"CRITICAL: You MUST respond ONLY in Simplified Chinese"
坑 2:HMR 失效
现象:
- 修改代码后,浏览器不自动刷新
- 需要手动刷新才能看到变化
原因:
- Vite 的 HMR 对某些动态 import 不友好
- 环境变量变化不会触发 HMR
解决:
// vite.config.ts
export default defineConfig({
server: {
hmr: {
overlay: true
}
}
});
// 环境变量变化后,手动重启开发服务器
坑 3:Supabase RLS 策略
现象:
- 前端无法插入数据到 Supabase
- 报错:new row violates row-level security policy
原因:
- Supabase 默认启用 RLS(Row Level Security)
- 匿名用户没有插入权限
解决: -- 允许匿名用户插入游戏数据
CREATE POLICY "Allow anonymous insert on game_sessions"
ON game_sessions
FOR INSERT
TO anon
WITH CHECK (true);
CREATE POLICY "Allow anonymous insert on api_calls"
ON api_calls
FOR INSERT
TO anon
WITH CHECK (true);
八、性能指标总结
| 指标 | 目标 | 实际 | 状态 |
|---|---|---|---|
| AI 响应时间 | < 1s | 0.6-0.8s | ✅ |
| 首屏加载 | < 2s | 1.2s | ✅ |
| 缓存命中率 | > 20% | ~30% | ✅ |
| 移动端成功率 | > 90% | 95%+ | ✅ |
| 打包体积 | < 200KB | 98KB (gzip) | ✅ |
| Lighthouse 评分 | > 90 | 96 | ✅ |
九、未来优化方向
9.1 技术层面
1. WebSocket 实时对战
// 计划实现 PvP 模式
const ws = new WebSocket('wss://api.rewritehistory.site');
ws.on('message', (data) => {
const opponentMove = JSON.parse(data);
// 更新对手的行动
});
2. WebAssembly 性能提升
// 将复杂计算移到 WASM
import init, { calculate_battle_result } from './wasm/battle.js';
await init();
const result = calculate_battle_result(userTroops, enemyTroops, morale);
3. Service Worker 离线支持
// 缓存静态资源和 AI 响应
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
9.2 产品层面
- 新增战役:二战、古代战争(赤壁、巨鹿等)
- 战场可视化:2D 战术地图
- 自定义战役编辑器:让玩家创造自己的历史
- Steam 版本:Electron 打包
十、总结
核心技术点
- AI 集成:Gemini 2.5 Flash + 结构化输出
- 性能优化:缓存 + 超时控制 + 模型选择
- Prompt 工程:200+ 行系统提示词
- 架构设计:双重 API 容错 + LRU 缓存
开发感悟
- AI 不是万能的:需要大量 Prompt 工程和约束设计
- 性能是王道:0.6 秒和 3 秒是完全不同的体验
- 数据驱动迭代:通过 Supabase 分析用户行为,优化产品
- 快速迭代:3 周从 0 到上线,Vite + Vercel 是神器
项目信息
- 在线体验:rewritehistory.app
- 技术栈:React 18 + TypeScript + Vite + Gemini AI + Supabase
- 开发周期:3 周
- 代码规模:~5000 行 TypeScript
欢迎体验并提供反馈!
如果对技术实现有疑问,欢迎在评论区讨论。
福利:掘金读者专属兑换码
- JUEJIN2025 - 100 点数
- DEVCODE - 50 点数
关注我,持续分享 AI + 前端 的实战经验。