AI 辅助物理课堂实验

如何用「内置实验模板 + AI 路由」模式,在不生成任意代码的前提下,让 AI 辅助物理课堂实验?


痛点:当物理老师想做一个「理想气体压强随温度变化」的互动演示......

传统方案要么找一个通用物理模拟器(功能复杂、上手成本高),要么直接用静态图片讲解(缺乏交互感),要么让 AI 现场生成一段物理模拟代码(危险:生成代码的行为无法预测、无法测试)。

Shared Physics Playground 的解题思路很克制:

把 AI 定位成「理解意图 + 选择实验模板」,而不是「生成任意代码」。内置 16 个确定性物理实验模板,覆盖高中/大学典型场景;AI(大模型)负责把用户的自然语言问题路由到最合适的模板,并配置好参数。

这套方案既保证了实验的确定性(所有实验逻辑都通过公式推演,无随机性),又保留了自然语言交互的灵活性。

本文从工程视角深度拆解其核心架构,适合中高级工程师阅读。

站点地址:AI物理实验室


目录

  1. 整体架构概览
  2. [AI Planner 架构:Gemini 理解意图 → 选择实验模板](#AI Planner 架构:Gemini 理解意图 → 选择实验模板)
  3. [确定性实验系统:simulation-planner.ts 物理引擎](#确定性实验系统:simulation-planner.ts 物理引擎)
  4. [多 Provider 路由:planner-provider.ts 的 Fallback 策略](#多 Provider 路由:planner-provider.ts 的 Fallback 策略)
  5. [房间状态管理:room-state.ts 与 PlaygroundRoom.ts](#房间状态管理:room-state.ts 与 PlaygroundRoom.ts)
  6. 访问控制:套餐覆盖配置体系
  7. [邮箱验证码登录:SMTP + Resend 双 Provider](#邮箱验证码登录:SMTP + Resend 双 Provider)
  8. [前端状态管理:Zustand Store 设计](#前端状态管理:Zustand Store 设计)
  9. [Linear 风格 Dark Mode:设计规范落地](#Linear 风格 Dark Mode:设计规范落地)
  10. 总结与延伸

1. 整体架构概览

复制代码
┌─────────────────────────────────────────────────────────────┐
│                        apps/web (React/Vite)                │
│  Zustand store → simulation-client.ts → auth-store.ts       │
│         ↓ HTTP POST /api/education/simulations/plan        │
└───────────────────────────┬─────────────────────────────────┘
                            │ JSON (CreateInputEnvelope)
                            ↓
┌───────────────────────────────────────────────────────────────┐
│                     apps/server (Express)                     │
│                                                               │
│  POST /api/education/simulations/plan                        │
│       ↓                                                       │
│  createCreatePlanner()                                       │
│       ├── PlannerProvider[] (google → minimax fallback)     │
│       ├── PlannerCache (LRU, TTL)                           │
│       └── resolveLocalCreateIntent() (兜底本地路由)          │
│                                                               │
│  AI 大模型理解问题 → 选择 16 个内置实验模板之一              │
│       ↓                                                       │
│  simulation-planner.ts (物理公式计算)                        │
│       ├── 斜面、抛体、弹簧振子、单摆、圆周运动               │
│       ├── 弹性碰撞、浮力、杠杆平衡、欧姆定律、理想气体      │
│       └── 波速、折射、透镜成像、库仑定律、RC 电路            │
│                                                               │
│  房间管理:PlaygroundRoom (Colyseus)                         │
│       └── room-state.ts (状态机,纯函数无副作用)             │
│                                                               │
│  认证:auth-service.ts + mailer.ts (SMTP/Resend)            │
│  套餐:access-config.ts (playground-access.json)             │
└───────────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                      packages/ (共享层)                      │
│                                                               │
│  packages/physics-schema/   → 物理对象 catalog (cube/ball...)  │
│  packages/prompt-contracts/ → SimulationPlan, SpawnIntent    │
│  packages/shared/           → AccessPolicy, 套餐定义         │
└─────────────────────────────────────────────────────────────┘

核心设计原则:

  • 内置实验优先:所有实验逻辑都是代码拥有、公式驱动的确定性系统,无随机性。
  • AI 做理解和路由:AI 不生成任意代码,只把用户问题映射到已有实验模板 + 参数。
  • 本地兜底 :即使 AI 不可用,本地关键词路由(matchAdditionalConcept)依然能覆盖 16 个实验。

Web 端口:5173(Vite Dev Server)
Server 端口:2567(Express)


2. AI Planner 架构:Gemini 理解意图 → 选择实验模板

2.1 入口:createCreatePlanner()

typescript 复制代码
// apps/server/src/ai/create-planner.ts
export function createCreatePlanner(
  config = parsePlannerConfig(process.env),
  secrets: PlannerSecrets = {
    googleApiKey: process.env.GOOGLE_API_KEY,
    minimaxApiKey: process.env.MINIMAX_API_KEY,
  },
  observability: ObservabilityConfig = parseObservabilityConfig(process.env),
  logger: Logger = createLogger({...}),
): CreatePlanner

核心逻辑(简化版):

typescript 复制代码
async plan(input: CreateInputEnvelope) {
  // ① 本地兜底路由(高置信度时跳过 AI)
  const localResult = resolveLocalCreateIntent(input);
  if (observability.plannerBypassEnabled && localResult.confidence === "high") {
    return localResult.intent;  // 跳过 AI,直接走本地
  }

  // ② 遍历 Provider 列表(google → minimax)
  for (const provider of liveProviders) {
    try {
      const result = await provider.plan(input);
      cache.set({ provider, model, prompt, ... }, result.intent);
      return result.intent;
    } catch (error) {
      // 记录错误,尝试下一个 Provider
    }
  }

  // ③ 所有 Provider 都失败 → 回退到本地兜底
  return localResult.intent;
}

2.2 Gemini Provider:createGeminiProvider()

Gemini Provider 负责接收自然语言输入,输出 SpawnIntent(包含 objectKindpromptscale)。

typescript 复制代码
// apps/server/src/ai/providers/gemini-provider.ts
function getGeminiPrompt(input: CreateInputEnvelope): string {
  return [
    'Convert the user request into a constrained spawn intent JSON object.',
    'Return JSON only.',
    'Use this shape: {"source":"text|image","prompt":"...","objectKind":"cube|ball|ramp|spring|wheel|trigger-zone","scale":[number,number,number]}',
    `Input source: ${input.source}`,
    `Input prompt: ${input.prompt}`,
    // image 模式时要求模型分析图片中的物理对象
    input.source === "image"
      ? "Inspect the attached local image and map the dominant physical object..."
      : "",
  ].join("\n");
}

调用 Gemini API:

typescript 复制代码
const response = await globalThis.fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      contents: [{ role: "user", parts: getGeminiParts(input) }],
      generationConfig: { responseMimeType: "application/json" },
    }),
    signal: AbortSignal.timeout(props.timeoutMs),
  }
);

Token 使用量从 Gemini 响应中提取(用于计费和监控):

typescript 复制代码
usage: {
  promptTokenCount: payload.usageMetadata?.promptTokenCount ?? null,
  candidatesTokenCount: payload.usageMetadata?.candidatesTokenCount ?? null,
  totalTokenCount: payload.usageMetadata?.totalTokenCount ?? null,
  cachedContentTokenCount: payload.usageMetadata?.cachedContentTokenCount ?? null,
}

2.3 缓存策略:createPlannerCache()

Planner Cache 是一个 LRU + TTL 的内存缓存:

typescript 复制代码
// apps/server/src/ai/planner-cache.ts
cache.get({ provider, model, promptVersion, prompt, source, imageDataUrl })
// 缓存命中时直接返回,节省 token 费用和延迟

配置项:

  • plannerCacheMaxEntries:最大缓存条目数
  • plannerCacheTtlMs:缓存有效期(毫秒)

3. 确定性实验系统:simulation-planner.ts 物理引擎

3.1 架构设计

整个实验系统分为两层:

职责 代码位置
模板选择层 (simulation-planner.ts) 根据问题关键词或 AI 输出,选择实验模板并构建 SimulationPlan createDefaultPlanForConcept()
数值计算层 (inclined-plane.ts / projectile-motion.ts / spring-oscillator.ts / template-solvers.ts) 输入实验参数,输出物理测量值 各自独立文件

3.2 模板选择逻辑

typescript 复制代码
// simulation-planner.ts(核心路由逻辑)
function matchAdditionalConcept(question: string): SimulationConcept | null {
  if (/摆|单摆|pendulum/i.test(question)) return "pendulum";
  if (/圆周|向心|circular|centripetal/i.test(question)) return "circular_motion";
  if (/碰撞|elastic/i.test(question)) return "elastic_collision";
  if (/浮力|buoyancy|float/i.test(question)) return "buoyancy";
  if (/杠杆|lever|balance/i.test(question)) return "lever_balance";
  if (/欧姆|ohm|circuit/i.test(question)) {
    if (/rc|电容|capacitor/i.test(question)) return "rc_circuit";
    return "ohms_law";
  }
  // ... 覆盖全部 16 个实验
}

语言检测(用于生成中文/英文实验标题和引导问题):

typescript 复制代码
function detectLanguage(question: string): "en" | "zh-CN" {
  return /[\u4e00-\u9fa5]/u.test(question) ? "zh-CN" : "en";
}

每个实验都有默认参数,用户提问时可以覆盖。以斜面实验为例:

typescript 复制代码
function createInclinedPlanePlan(language: "en" | "zh-CN"): SimulationPlan {
  return {
    concept: "inclined_plane",
    title: language === "zh-CN" ? "斜面与摩擦" : "Inclined plane and friction",
    objective: "观察斜面角度和摩擦系数如何改变...",
    variables: {
      angleDeg: 25,
      frictionCoefficient: 0.15,
      lengthM: 4,
      massKg: 1,
    },
    guidingQuestions: [
      "角度变大时,加速度为什么会变大?",
      "摩擦系数变大时,小球为什么可能不再下滑?",
    ],
  };
}

3.3 物理计算示例:斜面实验

typescript 复制代码
// apps/server/src/education/inclined-plane.ts
export function solveInclinedPlane(input: InclinedPlaneVariables): InclinedPlaneResult {
  const angleRad = (input.angleDeg * Math.PI) / 180;
  const acceleration = Math.max(
    0,
    9.81 * (Math.sin(angleRad) - input.frictionCoefficient * Math.cos(angleRad))
  );

  if (acceleration === 0) {
    return { accelerationMps2: 0, timeToBottomS: null, finalSpeedMps: 0, willSlide: false };
  }

  return {
    accelerationMps2: round(acceleration),
    timeToBottomS: round(Math.sqrt((2 * input.lengthM) / acceleration)),
    finalSpeedMps: round(Math.sqrt(2 * acceleration * input.lengthM)),
    willSlide: true,
  };
}

实战建议 :这里用 Math.max(0, ...) 保证了摩擦系数大于临界值时物体不会自行滑动,而不是报错或返回负数。类似的小 defensive 设计在各个 solver 中都有体现,值得借鉴。

3.4 支持的全部 16 个实验

实验 概念标识 核心公式
斜面与摩擦 inclined_plane a = g(sinθ - μcosθ)
抛体运动 projectile_motion 抛物线轨迹,运动分解
弹簧振子 spring_oscillator T = 2π√(m/k)
单摆 pendulum T = 2π√(L/g)
圆周运动 circular_motion a = v²/r
弹性碰撞 elastic_collision 动量 + 动能守恒
浮力 buoyancy F = ρgV
杠杆平衡 lever_balance τ = m × g × r
欧姆定律 ohms_law I = V/R
理想气体 ideal_gas PV = nRT
功和能量 work_energy W = Fd cosθ
波速 wave_speed v = fλ
折射 refraction n₁ sinθ₁ = n₂ sinθ₂
透镜成像 lens_imaging 1/f = 1/do + 1/di
库仑定律 coulombs_law F = kq₁q₂/r²
RC 电路 rc_circuit τ = RC, V(t) = V₀(1 - e^{-t/τ})

4. 多 Provider 路由:planner-provider.ts 的 Fallback 策略

4.1 Provider 接口抽象

typescript 复制代码
// apps/server/src/ai/planner-provider.ts
export type PlannerProvider = {
  name: PlannerProviderName;  // "google" | "minimax"
  model: string;
  plan: (input: CreateInputEnvelope) => Promise<PlannerProviderResult>;
};

每个 Provider 返回:

typescript 复制代码
export type PlannerProviderResult = {
  intent: SpawnIntent;
  model: string;
  provider: string;
  statusCode: number | null;
  usage: {
    promptTokenCount: number | null;
    candidatesTokenCount: number | null;
    totalTokenCount: number | null;
    cachedContentTokenCount: number | null;
  };
};

5. 房间状态管理:room-state.tsPlaygroundRoom.ts

5.1 状态机设计(纯函数)

room-state.ts 是一个纯函数风格的状态转换模块,无副作用,不依赖外部状态。所有操作都返回新状态(Immutable)。

typescript 复制代码
// apps/server/src/domain/room-state.ts
export type ServerRoomState = {
  id: string;
  roomObjectLimit: number;
  objects: ServerRoomObjectState[];
  players: Record<string, ServerRoomPlayerState>;
};

export function applySpawnIntent(
  state: ServerRoomState,
  playerId: string,
  intent: SpawnIntent,
): ServerRoomState {
  // 校验 player 存在、对象数量未超限、objectKind 合法
  // 返回新的 state(含新增对象)
}

export function applyQueuedImpulse(
  state: ServerRoomState,
  playerId: string,
  objectId: string,
  impulse: [number, number, number],
): ServerRoomState

export function applyObjectRemoval(
  state: ServerRoomState,
  playerId: string,
  objectId: string,
): ServerRoomState

export function applyObjectNudge(
  state: ServerRoomState,
  playerId: string,
  objectId: string,
  delta: Vector3,
): ServerRoomState

5.2 PlaygroundRoom:Colyseus 房间集成

PlaygroundRoom.ts 继承自 Room<ServerRoomState>,处理所有 WebSocket 消息。

typescript 复制代码
// apps/server/src/rooms/PlaygroundRoom.ts
export class PlaygroundRoom extends Room<ServerRoomState> {
  private createPlanner: CreatePlanner;
  private clientUsers = new Map<string, AuthUser | null>();

  override onCreate(options?: { planner?: CreatePlanner; templateId?: string; worldId?: string }) {
    this.createPlanner = options?.planner ?? createCreatePlanner();
    this.setState(createServerRoomState(this.roomId));

    // 注册消息处理器
    this.onMessage("create", async (client, payload) => {
      const intent = await this.createPlanner.plan(payload);
      const nextState = applySpawnIntent(this.state, this.getActorId(client), intent);
      this.applyState(nextState);
      this.broadcast("room-state", this.state);
    });

    this.onMessage("queue-impulse", (client, payload) => { /* ... */ });
    this.onMessage("nudge-object", (client, payload) => { /* ... */ });
    this.onMessage("remove-object", (client, payload) => { /* ... */ });
    this.onMessage("clear-room", (client) => { /* ... */ });
  }
}

两种玩家加入模式

  1. 未认证游客 :直接以 sessionId 作为 playerId,有读写权限。
  2. 已认证用户 :从 authToken 解析出 userId,拥有个性化 objectLimit(来自套餐配置)。
typescript 复制代码
override onJoin(client: Client, options?: { authToken?: string }) {
  if (!PlaygroundRoom.authService) {
    // 无认证服务 → 游客模式
    const nextState = addPlayer(this.state, client.sessionId);
    this.applyState(nextState);
    return;
  }

  // 认证服务存在
  const user = PlaygroundRoom.authService?.resolveSession(options?.authToken ?? null) ?? null;
  this.clientUsers.set(client.sessionId, user);

  if (user) {
    const nextState = addPlayer(this.state, user.userId, {
      objectLimit: user.access.maxObjectsPerStage,
    });
    this.applyState(nextState);
  }
}

5.3 对象 ID 生成规则

typescript 复制代码
function getNextObjectId(objects: ServerRoomObjectState[]): string {
  // 解析现有 object-N 中的最大 N,返回 N+1
  // 从未创建过对象时返回 "object-1"
}

空间布局(自动计算对象生成位置):

typescript 复制代码
function getSpawnPosition(objectCount: number): Vector3 {
  if (objectCount === 0) return [0, 0, 0];
  const row = Math.floor((objectCount - 1) / 3);
  const column = (objectCount - 1) % 3;
  const xOffsets: [number, number, number] = [2.8, -2.8, 0];
  return [xOffsets[column], 0, row > 0 ? -row * 2.8 : 0];
}

对象按 3×N 网格排列,避免重叠。


8. 前端状态管理:Zustand Store 设计

8.1 simulation-client.ts:实验规划状态

typescript 复制代码
// apps/web/src/state/simulation-client.ts
type SimulationClientState = {
  planned: PlannedSimulation | null;
  status: SimulationStatus;  // idle | planning | ready | suggestions | error
  saveStatus: SaveStatus;    // idle | saving | saved | error
  plan: (question: string, options?) => Promise<void>;
  saveCurrent: (options: { authToken: string }) => Promise<void>;
  updateVariables: (variables: SimulationVariables) => void;
  loadLocalInclinedPlaneDemo: () => void;
  reset: () => void;
};

plan() 函数的核心流程

typescript 复制代码
async plan(question, options = {}) {
  set({ status: { kind: "planning" } });

  const response = await globalThis.fetch("/api/education/simulations/plan", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
    },
    body: JSON.stringify({
      question: trimmedQuestion,
      selectedConcept: options.selectedConcept,
      source: options.source ?? "text",
    }),
  });

  if (!response.ok) { /* 设置 error status */ return; }

  const planned = await response.json();

  if (isSuggestionResponse(planned)) {
    // 没有精确匹配 → 显示实验建议列表
    set({ status: { kind: "suggestions", message: ..., suggestions: ... } });
    return;
  }

  // 成功 → 设置 planned + ready status
  set({ planned, status: { kind: "ready" } });
}

updateVariables() --- 参数滑块实时计算

typescript 复制代码
updateVariables(variables) {
  set((state) => {
    if (!state.planned) return state;

    if (state.planned.plan.concept === "inclined_plane") {
      const nextVariables = { ...state.planned.plan.variables, ...variables };
      const nextPlan = { ...state.planned.plan, variables: nextVariables };
      return {
        planned: {
          ...state.planned,
          plan: nextPlan,
          measurements: solveInclinedPlane(nextVariables),
        },
      };
    }
    // ... 其他实验类型类似
  });
}

8.2 auth-store.ts:认证与会话

typescript 复制代码
// apps/web/src/state/auth-store.ts
type AuthStoreState = {
  initialized: boolean;
  language: Language;           // "en" | "zh-CN"
  session: AuthSession;          // loading | anonymous | authenticated
  challenge: AuthChallenge | null;
  requestStatus: AuthRequestStatus;
  verifyStatus: AuthVerifyStatus;
  initialize: () => Promise<void>;
  requestCode: (email: string, challengeAnswer: string) => Promise<void>;
  verifyCode: (email: string, code: string) => Promise<void>;
  logout: () => Promise<void>;
  setLanguage: (language: Language) => void;
};

会话持久化 :auth token 存储在 localStorageinitialize() 时自动校验 token 有效性。

typescript 复制代码
async initialize() {
  const token = getStoredAuthToken();
  if (!token) { set({ session: { kind: "anonymous" } }); return; }

  const response = await globalThis.fetch("/api/auth/session", {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!response.ok) { setStoredAuthToken(null); set({ session: { kind: "anonymous" } }); return; }

  const { user } = await response.json();
  set({ session: { kind: "authenticated", authToken: token, user } });
}

欢迎关注收藏我,获取更多硬核技术干货 🚀

相关推荐
~kiss~1 小时前
quantizer 学习三
ai
名不经传的养虾人1 小时前
从0到1:企业级AI项目迭代日记 Vol.17|让 AI 做代码重构,要盯着它的策略,不只是看结果
人工智能·agent·ai编程·ai创业·企业ai
缝艺智研社1 小时前
誉财 YC - 10 + 双头全自动烫标机:服装商标烫印的高效智能之选
人工智能·自动化·新人首发·缝纫机·智能缝纫机
johnny2331 小时前
AI Agent社区:Moltbook、虾聊、InStreet、OpenAgents、WorldX
人工智能
knight_9___1 小时前
LLM工具调用面试篇6
人工智能·python·面试·职场和发展·llm·agent
YBAdvanceFu2 小时前
开源版Suno来了!用扩散模型生成带歌词的完整歌曲,DiffRhythm2实战详解
人工智能·深度学习·机器学习·多智能体·智能体·suno·diffrhythm2
龙孚信息2 小时前
Xometry百万流量案例分析:企业内容分发基础设施构建策略
人工智能
AI砖家2 小时前
Claude Code Superpowers 安装使用指南:让 AI 编程从“业余”走向“工程化”
前端·人工智能·python·ai编程·代码规范
YBAdvanceFu2 小时前
拆解 MusicGen:Meta 开源音乐大模型,到底是怎么跑起来的?
人工智能·深度学习·机器学习·数据挖掘·transformer·agent·智能体