前篇博客把"智能互动与练习"模块的整体设计梳理清楚——5 个接口、3 条原则、教学闭环的"学—练—诊—反馈"。这一篇专注记录把设计变成代码时的关键技术实现。

一、包结构与接口骨架

com.course.ai
├── controller/
│   ├── AiChatController.java         /api/ai/chat、/api/ai/chat/multimodal
│   └── ExerciseController.java       /api/exercises/{next,submit,targeted}
├── service/
│   ├── AiChatService / MultimodalChatService
│   ├── ExerciseSelectorService / GradingService / TargetedPracticeService
│   ├── RetrievalService / PromptBuilder / ChatHistoryStore
│   ├── ImagePreprocessor / KnowledgePointNormalizer
│   └── impl/...
├── repository/
│   └── KnowledgePointGraphRepository.java   # Neo4j 图查询
├── dto/
└── exception/

5 个接口的"信息流向"对照表:

接口 流式 写表 关键依赖
/api/ai/chat SSE 异步写 Redis 历史 RetrievalService、PromptBuilder、ChatModel
/api/ai/chat/multimodal 写 MinIO ImagePreprocessor、ChatModel(VLM)
/api/exercises/next 不写 ExerciseSelectorService、UserProfileService
/api/exercises/submit wrong_question + user_profile GradingService
/api/exercises/targeted 不写 Neo4jRepo、UserProfileService

这张表决定了两件事:流式接口必须用 SseEmitter 立即返回、写操作放异步线程;submit 这种写多表的接口需要事务,但事务里不能放 LLM 调用——LLM 平均 1~3 秒,挂在事务里数据库连接池一打就空。所以 grade() 切成"先 LLM 判分(事务外)→ 拿到结果再事务内更新画像/写错题"两段。

二、RetrievalService:混合检索

Top-K 自适应根据 query 长度分支:

int len = query == null ? 0 : query.length();
int topK = len < 20 ? 3 : len > 50 ? 8 : 5;

学科过滤采用"先召后过":pgvector 的 ivfflat / hnsw 索引最适合按相似度返回 topK,按 metadata 过滤性价比低。让向量层多取 topK * 3 条,再在 Java 侧根据 kpId → courseId → courseName 做学科过滤。3 倍冗余是经验值——单学科平均覆盖率约 30%。

int recallSize = Math.max(topK * 3, 10);
List<VectorEmbedding> raw = vectorEmbeddingService.findSimilarVectors(queryVec, recallSize);
List<VectorEmbedding> filtered = raw.stream()
        .filter(v -> courseIdFilter == null
                || courseIdFilter.equals(kpCache.get(v.getKpId()).getCourseId()))
        .limit(topK)
        .toList();

三、PromptBuilder:纯函数式拼装

PromptBuilder 不调任何 IO,输入是问题、历史、切片、画像,输出是单条 Prompt 字符串。这样可单测、可对比(同 Prompt 跑不同模型)、可灰度。

最终结构留下 5 个段:系统指令 → 学生画像 → 参考资料 → 最近对话 → 当前问题。chunks 为空时显式说明"本次未检索到相关知识库片段",防止模型幻觉式地编出"《数据结构》第三章"这种引用。

四、AiChatServiceImpl:SSE 事件设计

用 Spring MVC 的 SseEmitter 而非 WebFlux——项目其它接口全是 MVC 风格。SSE 流定义 5 类事件:

event: session     # 新会话时下发 sessionId
event: references  # RAG 切片元数据,前端即时渲染"引用"卡片
event: delta       # 模型 token,前端追加到答案区
event: done        # 流结束,data 是耗时 ms
event: error       # 错误,前端转 toast

referencesdelta 拆成两类事件,让前端用两个独立回调函数处理。核心编排:

SseEmitter emitter = new SseEmitter(0L);
emitter.send(SseEmitter.event().name("session").data(sessionId));   // 立刻 flush

streamExecutor.submit(() -> {
    UserProfileDTO profile = userProfileService.getUserProfile(userId);
    List<RetrievalChunk> chunks = retrievalService.hybridSearchAdaptive(prompt, subject);
    emitter.send(SseEmitter.event().name("references").data(chunks));

    List<ChatHistoryItem> history = historyStore.getRecent(userId, sessionId, 10);
    String fullPrompt = promptBuilder.build(prompt, history, chunks, profile);

    chatModel.stream(new Prompt(new UserMessage(fullPrompt)))
        .doOnNext(resp -> emitter.send(SseEmitter.event().name("delta").data(token)))
        .doOnComplete(() -> {
            emitter.send(SseEmitter.event().name("done").data(cost));
            asyncSaveHistory(userId, sessionId, prompt, answer);
            emitter.complete();
        })
        .blockLast();
});
return emitter;

历史回写放在 doOnComplete 而非 doOnNext——避免影响首字延迟。

五、MultimodalChatServiceImpl:预处理 + 结构化输出

ImagePreprocessor 四步流水线:格式校验 → 长边压到 1280px → 重编码 JPEG → MinIO 上传。VLM 计费按图像 token,1280 长边相比 1024×1024 原图 token 节省约 60%、准确率几乎无感知差异。

double ratio = (double) MAX_LONG_SIDE / Math.max(w, h);
AffineTransform tx = AffineTransform.getScaleInstance(ratio, ratio);
AffineTransformOp op = new AffineTransformOp(tx, AffineTransformOp.TYPE_BILINEAR);
BufferedImage dst = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB);

VLM Prompt 强制 JSON 结构输出。模型偶尔会用 json ... 包裹或加前置说明,解析层加剥壳与降级:

String s = raw.trim();
if (s.startsWith("```")) {
    int nl = s.indexOf('\n');
    if (nl > 0) s = s.substring(nl + 1);
    if (s.endsWith("```")) s = s.substring(0, s.length() - 3);
}
try {
    return objectMapper.readValue(s, MultimodalChatResponse.class);
} catch (JsonProcessingException e) {
    return fallback(s);   // 原文塞 answer,confidence 给 0.3
}

知识点字段 knowledge_points 经过 KnowledgePointNormalizer 归一,避免后续跨库联查失败。

六、ExerciseSelectorServiceImpl:加权采样 + 难度自适应

加权采样以 (1 - mastery) 为权重,下限 0.05:

double[] weights = new double[points.size()];
double total = 0;
for (int i = 0; i < points.size(); i++) {
    double m = lookupMastery(mastery, points.get(i));
    weights[i] = Math.max(0.05, 1.0 - m);
    total += weights[i];
}
double r = ThreadLocalRandom.current().nextDouble(total);
double acc = 0;
for (int i = 0; i < points.size(); i++) {
    acc += weights[i];
    if (r <= acc) return points.get(i);
}

下限 0.05 是为了让"已掌握"的点也偶尔被翻出来巩固——画像会随时间衰退,纯按 1 - mastery 算权重会让满掌握节点永远不被抽到。

难度区间映射(适配 difficulty=1~3 的现有 schema):

if (mastery < 0.4) return new int[]{1, 2};
if (mastery < 0.7) return new int[]{2, 3};
return new int[]{3, 3};

去重用 Redis Set done:exercise:{userId},TTL 7 天。短期状态用缓存、长期事实用数据库——做成 PG 表的话,一年后会塞几百万条没意义的"已做过"记录。

七、GradingServiceImpl:三路批改

TYPE_CHOICE  → 字符串比较(多选用 Set 相等)
TYPE_FILL    → 先精确匹配,失败再走 LLM
TYPE_CODE/简答 → LLM 判分(temperature=0.1)

LLM 判分 Prompt 严格要求"按标准答案核心要点判断、每遗漏一个关键要点扣相应分数",配合 temperature=0.1

OpenAiChatOptions options = OpenAiChatOptions.builder()
        .temperature(0.1)
        .build();
Prompt p = new Prompt(new UserMessage(prompt), options);

实测 temperature 从默认 0.7 调到 0.1 后,同一份学生答案重复跑 5 次得分波动 ±5 分以内(0.7 时是 ±20 分)。AI 批改与人工抽检一致率从约 60% 升到 88%。

LLM 判分失败的降级:返回 correct=false, score=0, feedback="AI 判分服务暂不可用..."不阻断画像更新。学生仍能看到标准答案、解析、RAG 延伸阅读。

画像回流核心逻辑:

double before = mastery.getOrDefault(kpName, 0.5);
double after = clamp(before + delta);    // delta = ±0.05
mastery.put(kpName, after);
if (before < 0.8 && after >= 0.8) congratulated.add(kpName);   // 触发"恭喜掌握"

答错时附 RAG 延伸阅读:

if (Boolean.FALSE.equals(judged.getCorrect()) && kp != null) {
    extended = retrievalService.hybridSearch(kp.getKpName(), null, 3);
}

八、TargetedPracticeServiceImpl:图谱前置链编排

核心 Cypher——沿 PREREQUISITE_OF 反向遍历找前置(深度限 5 防环):

MATCH (t:KnowledgePoint {name: $name})
MATCH (p:KnowledgePoint)-[:PREREQUISITE_OF*1..5]->(t)
RETURN DISTINCT p

拓扑排序用"按入度计数排序"做近似——非严格 Kahn 算法,但足以覆盖项目里的线性前置链:

UNWIND $names AS n
MATCH (k:KnowledgePoint {name: n})
OPTIONAL MATCH (pre:KnowledgePoint)-[:PREREQUISITE_OF]->(k)
WHERE pre.name IN $names
WITH k, count(pre) AS preCount
RETURN k ORDER BY preCount ASC, k.name ASC

编排逻辑:

for (String target : weakTopics) {
    List<KnowledgePointNode> pres = graphRepo.findAllPrerequisites(target);
    for (KnowledgePointNode p : pres) {
        if (mastery.getOrDefault(p.getName(), 1.0) < 0.5) {
            nodeRole.putIfAbsent(p.getName(), "prerequisite");
        }
    }
    nodeRole.put(target, "target");
}
List<String> ordered = topologicalOrder(new ArrayList<>(nodeRole.keySet()));

每个节点配题数量按 mastery 决定:< 0.3 配 3 道、0.3~0.5 配 2 道、0.5+ 配 1 道,难度由低到高。

九、Redis 键空间

key 类型 TTL 用途
chat:history:{userId}:{sessionId} List 30min 多轮对话历史窗口
done:exercise:{userId} Set 7d 自适应选题去重

未复用原项目 message 表存历史:message 表是"全量长期事实",给历史回看、审计、分析用;Redis List 是"短期上下文窗口",给 Prompt 拼装用。两种数据结构、TTL、查询模式都不一样。

十、联调三个坑的代码级修复

坑一·SSE 在 Nginx 反代后被缓冲

Nginx 配置:

proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";

代码侧 SseEmitter 立即 send 一个 session 事件,让"流"真的成为流。

坑二·多模态成本失控

ImagePreprocessor 强制长边 ≤1280px、统一重编码 JPEG。token 节省约 60%。

坑三·知识点别名导致跨库联查失败

KnowledgePointNormalizer 维护内置别名映射("快排"、"快速排序"、"QuickSort" 全部归一到"快速排序")。所有写入跨库联查字段的地方都过一遍归一。

十一、阶段成果数据自检

指标 需求文档 本阶段实际
接口数 5 5 ✅
文本问答首字延迟 < 1s ~0.6s
RAG 召回切片数 ≥ 3 Top-K 自适应 3~8 ✅
多模态识别置信度(印刷体) ≥ 0.7 平均 0.87
AI 批改与人工抽检一致率 ≥ 80% 88%
自适应选题画像驱动 加权采样 + 难度自适应 + Redis 去重 ✅
专项练习路径编排 基于薄弱点 基于薄弱点 + Neo4j 前置链 + 拓扑排序 ✅
接口对原项目侵入 越小越好 0 改动既有代码(仅新增)

十二、下一步

"学—练—诊—反馈"的代码闭环已完整跑通。下一阶段两件事:

  1. 诊断 Agent 接入 submit 接口,把粗粒度的 ±0.05 mastery 升级为"识别错误类型 → 定位错因知识点 → 生成结构化诊断报告"。
  2. 规划 Agent 基于本模块沉淀的 exercise_attempt 数据,自动生成"今天该做什么、明天该做什么"的学习计划。

两个 Agent 的接入点已在本模块预留——GradingService 的事务边界外、TargetedPracticeService 的输入端。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐