如何用「内置实验模板 + AI 路由」模式,在不生成任意代码的前提下,让 AI 辅助物理课堂实验?
痛点:当物理老师想做一个「理想气体压强随温度变化」的互动演示......
传统方案要么找一个通用物理模拟器(功能复杂、上手成本高),要么直接用静态图片讲解(缺乏交互感),要么让 AI 现场生成一段物理模拟代码(危险:生成代码的行为无法预测、无法测试)。
Shared Physics Playground 的解题思路很克制:
把 AI 定位成「理解意图 + 选择实验模板」,而不是「生成任意代码」。内置 16 个确定性物理实验模板,覆盖高中/大学典型场景;AI(大模型)负责把用户的自然语言问题路由到最合适的模板,并配置好参数。
这套方案既保证了实验的确定性(所有实验逻辑都通过公式推演,无随机性),又保留了自然语言交互的灵活性。
本文从工程视角深度拆解其核心架构,适合中高级工程师阅读。
站点地址:AI物理实验室
目录
- 整体架构概览
- [AI Planner 架构:Gemini 理解意图 → 选择实验模板](#AI Planner 架构:Gemini 理解意图 → 选择实验模板)
- [确定性实验系统:simulation-planner.ts 物理引擎](#确定性实验系统:simulation-planner.ts 物理引擎)
- [多 Provider 路由:planner-provider.ts 的 Fallback 策略](#多 Provider 路由:planner-provider.ts 的 Fallback 策略)
- [房间状态管理:room-state.ts 与 PlaygroundRoom.ts](#房间状态管理:room-state.ts 与 PlaygroundRoom.ts)
- 访问控制:套餐覆盖配置体系
- [邮箱验证码登录:SMTP + Resend 双 Provider](#邮箱验证码登录:SMTP + Resend 双 Provider)
- [前端状态管理:Zustand Store 设计](#前端状态管理:Zustand Store 设计)
- [Linear 风格 Dark Mode:设计规范落地](#Linear 风格 Dark Mode:设计规范落地)
- 总结与延伸
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(包含 objectKind、prompt、scale)。
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.ts 与 PlaygroundRoom.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) => { /* ... */ });
}
}
两种玩家加入模式:
- 未认证游客 :直接以
sessionId作为playerId,有读写权限。 - 已认证用户 :从
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 存储在 localStorage,initialize() 时自动校验 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 } });
}
欢迎关注收藏我,获取更多硬核技术干货 🚀