我用做了一个历史战争游戏,3 周从 0 到上线|技术复盘

125 阅读10分钟

前端: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 整体架构图

image.png

image.png


2.2 技术栈选型

技术选型理由
前端框架React 18熟悉的技术栈,生态完善
构建工具Vite冷启动快,HMR 体验好
类型安全TypeScriptAI 返回结构复杂,必须强类型
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');
};

关键设计点:

  1. 缓存优先:30% 请求命中缓存,< 10ms 响应
  2. 双重容错:OpenRouter 失败自动切换 Gemini
  3. 结构化输出:使用 responseSchema 强制返回 JSON
  4. 性能监控:每次调用记录响应时间和 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 条

设计思路:

  1. 相似状态合并:兵力 8500 和 8300 算同一量级
  2. 回合分组:第 3 回合和第 4 回合算同一阶段
  3. LRU 淘汰:优先保留最近使用的缓存
  4. 深拷贝:防止外部修改缓存数据

效果:

  • 命中率:~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 Lite0.60s98%✅ 启用
Gemini 2.5 Flash0.72s99%✅ 启用
DeepSeek Chat V30.72s (简单) / 2.5s (复杂)95%❌ 禁用
Grok 4.1 Fast1.66s97%❌ 禁用
GPT-5 MiniN/A40%(返回空)❌ 禁用

结论:只启用 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}`
  : ''}

数值计算规则

  1. 战斗损失:根据兵力对比、地形、士气综合计算
  2. 士气变化:胜利 +515,失败 -1020,重大失败 -30
  3. 兵力损失:进攻方通常损失 1.2~1.5 倍于防守方
  4. 弹药消耗:每回合战斗消耗 5%~15%

重要提醒

  • 战况要有连贯性,前后呼应
  • 选项要有明确的风险-收益权衡
  • 不要重复之前的剧情
  • 结果要符合军事常识 `.trim(); }

关键设计:

  1. CRITICAL 标记:强调语言要求(否则 AI 会中英混杂)
  2. 时代约束:明确可用武器和技术
  3. 数值规则:指导 AI 计算合理的战斗结果
  4. 风险定义:确保 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 响应时间< 1s0.6-0.8s
首屏加载< 2s1.2s
缓存命中率> 20%~30%
移动端成功率> 90%95%+
打包体积< 200KB98KB (gzip)
Lighthouse 评分> 9096

九、未来优化方向

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 产品层面

  1. 新增战役:二战、古代战争(赤壁、巨鹿等)
  2. 战场可视化:2D 战术地图
  3. 自定义战役编辑器:让玩家创造自己的历史
  4. Steam 版本:Electron 打包

十、总结

核心技术点

  1. AI 集成:Gemini 2.5 Flash + 结构化输出
  2. 性能优化:缓存 + 超时控制 + 模型选择
  3. Prompt 工程:200+ 行系统提示词
  4. 架构设计:双重 API 容错 + LRU 缓存

开发感悟

  1. AI 不是万能的:需要大量 Prompt 工程和约束设计
  2. 性能是王道:0.6 秒和 3 秒是完全不同的体验
  3. 数据驱动迭代:通过 Supabase 分析用户行为,优化产品
  4. 快速迭代:3 周从 0 到上线,Vite + Vercel 是神器

项目信息

  • 在线体验:rewritehistory.app
  • 技术栈:React 18 + TypeScript + Vite + Gemini AI + Supabase
  • 开发周期:3 周
  • 代码规模:~5000 行 TypeScript

欢迎体验并提供反馈!

如果对技术实现有疑问,欢迎在评论区讨论。


福利:掘金读者专属兑换码

  • JUEJIN2025 - 100 点数
  • DEVCODE - 50 点数

关注我,持续分享 AI + 前端 的实战经验。