项目背景#

我正在开发一个面向新加坡 2026 PSLE 考纲的小学数学 AI 辅导 App。核心理念:

  • 使用新加坡教育部推崇的 CPA (Concrete-Pictorial-Abstract) 教学法
  • 多 Agent 协作:Planner → CPA Designer + Persona 并行处理
  • 为家长和孩子分别生成不同语气的解题指导

技术栈选型比较激进:Java 25 + Spring Boot 4.0 + Spring AI 2.0 + Ollama 本地模型。这篇文章记录 Phase 1 的搭建过程和踩过的坑。

架构概览#

graph TD A[SolveRequest - question, grade P1-P6] --> B[Planner Agent - 分析题目,提取知识点,生成步骤] B --> C{StructuredTaskScope.fork} C --> D[CPA Designer - 生成 Bar Model - 可视化描述] C --> E[Persona Agent - 家长版 + 儿童版 - 双语气输出] D --> F[SolveResult] E --> F F --> G[parentGuide] F --> H[childScript] F --> I[barModelJson] F --> J[knowledgeTags] classDef request fill:#ffffff,stroke:#1976d2,stroke-width:3px,color:#0d47a1 classDef agent fill:#ffffff,stroke:#f57c00,stroke-width:3px,color:#e65100 classDef result fill:#ffffff,stroke:#388e3c,stroke-width:3px,color:#2e7d32 classDef scope fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100 class A request class B,D,E agent class C scope class F,G,H,I,J result

Planner 先跑,分析题目后输出结构化 JSON,然后 CPA Designer 和 Persona 两个 Agent 并发执行。并发使用的是 Java 25 的 Structured Concurrency(StructuredTaskScope)。

错误处理流程#

graph TD A[开始解题] --> B{Planner Agent} B -->|成功| C[解析JSON结果] B -->|失败| D[返回错误信息] C --> E{StructuredTaskScope} E --> F[CPA Designer] E --> G[Persona Agent] F -->|成功| H[CPA结果] F -->|失败| I[使用备用模板] G -->|成功| J[Persona结果] G -->|失败| K[使用默认语气] H --> L[合并结果] I --> L J --> L K --> L L --> M[返回SolveResult] D --> N[记录错误日志] classDef success fill:#ffffff,stroke:#388e3c,stroke-width:3px,color:#2e7d32 classDef error fill:#ffffff,stroke:#d32f2f,stroke-width:3px,color:#c62828 classDef backup fill:#ffffff,stroke:#f57c00,stroke-width:3px,color:#e65100 classDef process fill:#f5f5f5,stroke:#757575,stroke-width:2px,color:#424242 classDef decision fill:#fff8e1,stroke:#ffa000,stroke-width:2px,color:#e65100 class A,B,C,E,F,G,L,M,N process class B,E decision class H,J,M success class D,N error class I,K backup

兼容性踩坑记录#

这次用的技术栈都比较新,版本冲突是第一个大坑。最终跑通的版本组合:

组件 原计划 实际版本 原因
Java 25 25 (Temurin-25+36) 不变
Gradle 8.x 9.2 Gradle 8.13 在 Java 25 上直接崩溃
Spring Boot 3.5 4.0.3 插件 3.5.0 内部拉取 spring-boot:4.0.1 导致冲突
Spring Framework 6.x 7.0.5 Spring Boot 4.0 依赖
Hibernate 6.x 7.2.4 Spring Boot 4.0 依赖
Spring AI 2.0.0 2.0.0-M2 正式版尚未发布
Lombok 1.18.36 1.18.42 1.18.36 不兼容 Java 25 编译器内部 API

Gradle 9 + Spring Boot 4.0#

Gradle 8.13 在 Java 25 运行时上直接报错(错误信息只有一个 “25”),需要升级到 Gradle 9:

gradle wrapper --gradle-version 9.2

Gradle 9 的一个重大变化是 io.spring.dependency-management 插件不再兼容。需要改用 Gradle 原生的 platform() BOM 方式管理依赖:

// ❌ 旧方式 - Gradle 9 不兼容
plugins {
    id("io.spring.dependency-management") version "1.1.7"
}

// ✅ 新方式 - 原生 platform() BOM
dependencies {
    implementation(platform(
        org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
    ))
    implementation(platform(
        "org.springframework.ai:spring-ai-bom:2.0.0-M2"
    ))
}

Spring Boot 4.0 模块化自动配置#

Spring Boot 4.0 的一个大变化是自动配置从单体 spring-boot-autoconfigure 拆分到各个独立模块。比如:

  • JPA 自动配置 → 需要 spring-boot-starter-data-jpa 中的独立模块
  • Flyway 自动配置 → 需要独立的 spring-boot-starter-flyway
  • MockMvc 测试 → AutoConfigureMockMvc 移到了 org.springframework.boot.webmvc.test.autoconfigure

如果只引了 flyway-core,Flyway 根本不会自动执行迁移。需要改为:

// ❌ Spring Boot 3.x 时代可用
implementation("org.flywaydb:flyway-core")

// ✅ Spring Boot 4.0 需要
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")

Lombok + Java 25#

Lombok 1.18.36 在 Java 25 上编译直接报 NoSuchFieldException: com.sun.tools.javac.code.TypeTag :: UNKNOWN,因为 Java 25 编译器内部 API 发生了变化。升级到 1.18.42 解决。

核心实现#

多 Agent 编排 — StructuredTaskScope#

使用 Java 25 的 Structured Concurrency 来并行执行 CPA Designer 和 Persona Agent:

public SolveResult solve(SolveRequest request) {
    // Step 1: Planner Agent(串行)
    String plannerResult = runPlannerAgent(request);

    // Step 2: CPA Designer + Persona(并行)
    try (var scope = StructuredTaskScope.open()) {
        var cpaFuture = scope.fork(() ->
            runCpaDesignerAgent(plannerResult));
        var personaFuture = scope.fork(() ->
            runPersonaAgent(plannerResult, request.grade()));

        scope.join();

        return new SolveResult(
            extractJsonField(personaFuture.get(), "parentGuide"),
            extractJsonField(personaFuture.get(), "childScript"),
            cpaFuture.get(),
            extractKnowledgeTags(plannerResult)
        );
    }
}

StructuredTaskScope 的优势:

  • scope.join() 等待所有 fork 完成
  • 如果任一子任务抛异常,可以配置策略自动取消其他子任务
  • try-with-resources 确保作用域关闭时所有子任务已终止

System Prompts — 新加坡 CPA 教学法#

Planner Agent 的 System Prompt 核心要求:

You are an expert Singapore primary school math teacher,
specializing in the 2026 MOE syllabus.

Given a math question and the student's grade level (P1-P6):
1. Identify the topic and relevant knowledge points
2. Break down the solution into clear, numbered steps
3. Use the CPA (Concrete-Pictorial-Abstract) teaching approach

Respond in JSON format:
{
  "knowledgeTags": ["algebra.substitution"],
  "steps": [{"stepNumber": 1, "description": "..."}],
  "answer": "...",
  "difficulty": "easy|medium|hard"
}

Persona Agent 则为同一道题生成两种不同语气的输出:

  • parentGuide — 给家长看的教学指导,专业、简练
  • childScript — 给孩子看的趣味解释,用类比和 emoji,让数学像冒险一样

SSE 流式输出#

解题支持 SSE (Server-Sent Events) 流式输出,前端可以逐字显示:

@PostMapping(value = "/stream",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> solveStream(@RequestBody SolveRequest request) {
    return chatClient.prompt()
            .system(SOLVE_SYSTEM_PROMPT)
            .user("Grade: P%d\nQuestion: %s"
                .formatted(request.grade(), request.question()))
            .stream()
            .content();
}

这里用了 Spring AI 的 ChatClient 流式接口,底层对接 Ollama。curl -N 可以直接在终端看到逐字输出效果。

身份认证#

使用 Spring Security + BCrypt + JWT 的标准方案:

# 注册
curl -X POST localhost:8080/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"student@test.com","password":"123456"}'
# → 201 {"message":"Registration successful","userId":"..."}

# 重复注册
# → 409 {"error":"Email already registered"}

# 登录
curl -X POST localhost:8080/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"student@test.com","password":"123456"}'
# → 200 {"token":"..."}

数据库 — pgvector + Flyway#

数据库使用 PostgreSQL 17 + pgvector 扩展,Flyway 管理迁移。初始 Schema 包含:

用途
users 用户账号
student_profiles 学生档案(年级、学习偏好)
solve_records 解题记录
sg_math_questions PSLE 题库 + 768维向量(为 Phase 2 RAG 准备)
knowledge_progress 知识点掌握度追踪
vector_store Spring AI VectorStore 内置表

向量维度使用 768(匹配 nomic-embed-text 模型),索引类型选择 HNSW(IVFFlat 无法在空表上构建)。

ALTER TABLE sg_math_questions
ADD COLUMN embedding vector(768);

CREATE INDEX idx_questions_embedding
ON sg_math_questions USING hnsw (embedding vector_cosine_ops);

本地开发环境#

系统部署架构#

graph TB subgraph "本地开发环境" A[前端界面 - React/Vue.js] --> B[Spring Boot 4.0 - Java 25] B --> C[(PostgreSQL 17 - pgvector)] B --> D[(Redis 7 - 缓存)] B -.-> E[Ollama - qwen3.5 + nomic-embed-text] end subgraph "基础设施" F[Docker Compose] --> C F --> D G[原生 Ollama] --> E end subgraph "测试工具" H[curl] --> B I[MockMvc] --> B J[Integration Tests] --> B end classDef app fill:#ffffff,stroke:#1976d2,stroke-width:3px,color:#0d47a1 classDef infra fill:#ffffff,stroke:#7b1fa2,stroke-width:3px,color:#4a148c classDef test fill:#ffffff,stroke:#388e3c,stroke-width:3px,color:#2e7d32 classDef database fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#4a148c classDef ai fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#2e7d32 class A,B app class C,D,F,G database class E ai class H,I,J test
# 启动基础设施
cd infra && docker compose up -d  # PostgreSQL 17 + Redis 7

# Ollama 运行在 macOS 原生环境
ollama pull qwen3.5          # 对话模型 6.6GB
ollama pull nomic-embed-text  # Embedding 模型 274MB

# 启动后端
cd backend
./gradlew bootRun --args='--spring.profiles.active=dev'

注意:Docker 版 Ollama 需要 NVIDIA GPU,macOS 上建议直接用原生 Ollama。我在 docker-compose.yml 中将 Ollama 服务设为 profiles: [gpu],默认不启动。

测试#

写了两类测试来验证 Phase 1 交付:

AuthControllerTest — 认证接口的 MockMvc 测试:

@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
    @Test
    void register_NewUser_Returns201() { ... }

    @Test
    void register_DuplicateEmail_Returns409() { ... }

    @Test
    void login_ValidCredentials_ReturnsToken() { ... }

    @Test
    void login_InvalidCredentials_Returns401() { ... }
}

MathSolverOrchestratorIntegrationTest — Agent 链的端到端测试(需要 Ollama 运行中):

@SpringBootTest
@EnabledIfEnvironmentVariable(
    named = "OLLAMA_AVAILABLE", matches = "true")
class MathSolverOrchestratorIntegrationTest {
    @Test
    void solve_SimpleAddition_ReturnsStructuredResult() {
        SolveResult result = orchestrator.solve(
            new SolveRequest("5 + 3 = ?", 1));
        assertNotNull(result.parentGuide());
        assertNotNull(result.childScript());
        assertNotNull(result.barModelJson());
        assertFalse(result.knowledgeTags().isEmpty());
    }
}

所有测试 ./gradlew test 全绿通过。

Phase 1 总结#

任务 状态
后端 bootRun 启动
Flyway 迁移建表
注册/登录 API
MathSolverOrchestrator 多 Agent 链
Planner Agent System Prompt
Persona Agent System Prompt
SSE 流式输出

核心收获:

  1. Java 25 + Spring Boot 4.0 + Gradle 9 这套组合目前可用,但需要留意各种兼容性问题。Gradle 9 和 Spring Boot 4.0 都有破坏性变更。
  2. Structured Concurrency 用于 Agent 并发非常自然StructuredTaskScope.open() + fork() + join() 的 API 比 CompletableFuture 更简洁、更安全。
  3. Spring AI ChatClient 的流式接口开箱即用,对接 Ollama 后 SSE 流式输出零额外代码。
  4. 本地用 qwen3.5 做数学推理效果基本可用,但响应较慢(一次完整 Agent 链约 3-4 分钟),生产环境需要用云端模型。

Phase 2 计划:搭建 RAG 知识库(向量检索 PSLE 题库),让 Agent 的回答有真题/模拟题作为参考上下文。