山东大学软件学院项目实训-创新实训-计科智伴(三)——交互与教学智能体的代码落地
前篇博客把"智能互动与练习"模块的整体设计梳理清楚——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
把 references 和 delta 拆成两类事件,让前端用两个独立回调函数处理。核心编排:
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 改动既有代码(仅新增) |
十二、下一步
"学—练—诊—反馈"的代码闭环已完整跑通。下一阶段两件事:
- 诊断 Agent 接入 submit 接口,把粗粒度的 ±0.05 mastery 升级为"识别错误类型 → 定位错因知识点 → 生成结构化诊断报告"。
- 规划 Agent 基于本模块沉淀的 exercise_attempt 数据,自动生成"今天该做什么、明天该做什么"的学习计划。
两个 Agent 的接入点已在本模块预留——GradingService 的事务边界外、TargetedPracticeService 的输入端。
更多推荐

所有评论(0)