山东大学软件学院项目实训-创新实训-计科智伴(五)——接通真实大模型、RAG 检索与多 Agent 调度
上一篇《测试体系与代码维护》收尾时立了个 flag——测试体系搭好了,"下周开始让它真正活起来"。这一篇就来兑现这句话。
接手的两个模块——后端 course-ai(Spring Boot 3.4 + MyBatis-Plus + Spring AI)和前端 eduagent-front(uniapp H5)——拿到时的状态其实不太体面:编译不过、依赖配置缺项、前后端 API 路径完全对不上,聊天 / 画像 / 错题这些关键链路全是 mock。所以"活起来"得从最底层做起:先让它能编译,再让前后端能真说上话,最后接上真实大模型、RAG 检索和多 Agent 调度。
本期一共落地 7 块新功能,并顺手清掉一批阻塞性 bug。先上个总览:
| 板块 | 交付物 | 状态 |
|---|---|---|
| 编译 / 启动 | 195 个 Java 文件全量编译通过 + docker-compose 起 4 个依赖 | ✅ 已交付 |
| 登录流程 | 登录前置 + 注册 Tab + 画像感知跳转 | ✅ 已交付 |
| 密码重置 | 邮箱链接 + 短信验证码 + 管理员,三选一 | ✅ 已交付 |
| 智能对话 | 真接 DashScope Qwen,SSE 流式 | ✅ 已交付 |
| 后端 endpoint | 新增 14 个,废 9 个前端 stub | ✅ 已交付 |
| Agent 调度 | 10 意图分类 + 短路 + 路由到子 agent | ✅ 已交付 |
| RAG 检索 | pgvector + DashScope 直连 embedding + 爬虫数据导入 | ✅ 已交付 |
一、让 195 个 Java 文件先能编译
"活起来"的第一步是"能编译"。第一次 mvn compile 直接报出 130+ 个错误,散落在 service / config / controller / mapper / entity 各处。Windows CMD 下错误信息是乱码,但路径和行号还算清晰,顺着排就行。
1.1 根因分析(5 类)
把 130+ 个错误归类,其实只有 5 个根因:
| 类型 | 具体表现 | 根因 |
|---|---|---|
| Lombok 失效 | @Data / @Slf4j / @RequiredArgsConstructor 生成的 setter/getter/log/构造器全找不到 |
pom.xml 硬编码 Lombok 1.18.22,与 JDK 17 + Spring Boot 3.4 不兼容 |
| 缺失包 | 8 个 service 文件 import com.course.ai.exception.BusinessException; 不存在 |
该包从未创建 |
| DTO 注释 | SubmitAnswerRequest 全文件被 // 注释 |
历史遗留 |
| Bean 重复 + 参数漂移 | StudyPlanAIConfig 用 3 参构造 AiStudyPlanGenerator(实际 5 个 final 字段),同时与 @Service 重复注册 |
后期给 AiStudyPlanGenerator 加了依赖但没同步 Config |
| 方法缺失 | MinioService.uploadBytes(byte[], String, String) 不存在 |
同包内 MinioServicePatch.java 只是注释里的"待迁入"说明,没真迁 |
熟悉的 Lombok ↔ JDK 不匹配又来了——和上一篇 L1 编译期撞上的 JCTree$JCImport.qualid 是同一类问题,只是这次表现成"找不到符号"。
1.2 修复策略
先解决最值钱的那个:把 Lombok 的硬编码版本拿掉,让 spring-boot-parent 3.4 统一管理。
<!-- pom.xml 关键改动 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- 移除版本,由 spring-boot-parent 3.4 管理为 1.18.36 -->
</dependency>
移除版本固定后,Lombok 升到 SB 管理版,50+ 条"找不到符号"瞬间消失。剩下 5 处真错误再各个击破:新建 BusinessException、恢复 SubmitAnswerRequest(含 qId + userAnswer)、删 StudyPlanAIConfig 里两个 @Bean、按注释补上 uploadBytes 方法。
经验是:编译期问题一定先解决再谈别的。Lombok 失效让 50+ 个表面错误全是假阳性,先把版本升上去,剩下的真错误自然就剥离出来了。
1.3 验证
[INFO] Compiling 195 source files with javac [debug parameters release 17]
[INFO] BUILD SUCCESS
[INFO] Total time: 7.476 s
二、登录流程重构:先登录、再画像、再首页
编译过了,第一个改的业务流程是登录。原来 App 一启动就进 onboarding 问卷向导,这次把它改成"先登录",再根据画像是否建好决定去哪。
2.1 改造前 vs 改造后
| 项 | 改造前 | 改造后 |
|---|---|---|
| App 启动落地页 | onboarding(问卷向导) | 登录页 |
| 注册 | 无独立入口 | 登录页顶部 Tab 切换 |
| 登录后跳转 | 固定跳首页 | 调 getProfile 判断画像 → 未建跳 onboarding,已建跳首页 |
| 记住 | 记住密码 | 记住手机号 / 邮箱 |
2.2 关键判定逻辑
判断"画像是否建好"的逻辑放在前端,只看两个信号——目标分数或在学课程,有任一即视为已建:
// login.vue
isProfileBuilt(profile) {
if (!profile) return false
const hasTarget = profile.targetScore != null
const hasCourses = Array.isArray(profile.currentCourses) && profile.currentCourses.length > 0
return hasTarget || hasCourses
}
2.3 期间踩中两个隐藏 bug
坑一 · OPTIONS 预检 401 把后续请求挡掉
浏览器跨域且带 authorization 头时,会先发一个 OPTIONS 预检请求。LoginInterceptor 没排除 OPTIONS,预检直接返回 401,浏览器视为预检失败,真正的 GET / PUT 根本不发出。结果是后端日志一片空白,前端只见一个 toast "提交失败",极难定位。
// LoginInterceptor.preHandle 头部加上
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
坑二 · /users/profile 永远返回登录时的旧快照
原实现直接 UserHolder.getUser() 返回 ThreadLocal 里的会话 DTO,导致 PUT 更新画像后再 GET,拿到的仍是 null 字段,前端 isProfileBuilt 永远判 false。改成按 userId 重新查 DB,并把 user 表字段(username / grade / major)补回去。
2.4 E2E 验证
# 新用户路径
GET /users/profile → {targetScore: null, currentCourses: []} → 跳 onboarding ✓
# 已建画像路径
GET /users/profile → {targetScore: 90, currentCourses: [1,2,3]} → 跳 index ✓
三、密码重置三件套:管理员 / 邮箱 / 短信
原来的"忘记密码"是个 toast 占位。这次落地 3 条互不影响的独立通路,由前端 ActionSheet 让用户自己选。
3.1 后端架构
设计上把"发"这件事抽成接口,默认实现先走零外部依赖的日志版,将来换真实通道只需加一个 @Primary 实现热替换。
service/
├── EmailService // 接口
│ └── impl/LogEmailService // 默认实现:邮件正文打到后端日志,零外部依赖
├── SmsService // 接口
│ └── impl/LogSmsService // 默认实现:同上
└── impl/PasswordResetServiceImpl // 核心:token / code 生成 + Redis 存 + 改密
controller/UserController(新增 4 端点)
POST /users/password/forgot/email // 邮箱发链接
POST /users/password/forgot/sms // 短信发码
POST /users/password/reset/token // token 改密
POST /users/password/reset/sms // 验证码改密
3.2 安全约束
密码重置最容易踩安全的坑,几条约束一开始就钉死:
| 约束 | 实现 |
|---|---|
| 不暴露账号是否存在 | 不论邮箱 / 手机号是否注册过,一律返回"若已注册,已发送" |
| 一次性消费 | Redis 验证成功立即 delete key |
| 防短信轰炸 | 同手机号 60s 内只能发 1 次(setIfAbsent + TTL) |
| 时效 | 邮件 token 15min;短信 code 5min |
| 不在登录拦截器内 | 4 个 reset 路径加入 excludePathPatterns |
3.3 邮件 / 短信怎么"发"
当前是零外部依赖的占位:把内容打到后端日志([MOCK EMAIL] / [MOCK SMS] 块),联调时人工去日志里复制链接 / code。接真实 SMTP 时,新建 SmtpEmailService implements EmailService 标上 @Primary 即可热替换,不用动其它任何代码。
3.4 E2E 验证(11 个场景全过)
| # | 场景 | 结果 |
|---|---|---|
| 1–2 | 邮箱注册新用户 + 手机号 / 邮箱两种方式登录 | 都拿到 token |
| 3–6 | 邮箱发链接 → token 改密 → 旧密码失效 → 新密码登录 | ✓ |
| 7–8 | 短信发码 + 60s 内再发 | 第 2 次被冷却拦截 |
| 9–10 | 验证码改密 + 新密码登录 | ✓ |
| 11 | 同验证码二次重放 | 提示"验证码已过期" |
四、智能对话真接通 Qwen(SSE 流式)
后端 /api/ai/chat 早就用 Spring AI 接 DashScope 跑通了,但前端 chat.vue 的 callAI 还是 setTimeout 模拟逐字渲染一段 mock 字符串——两端从没真接上。这次把它们接通。
4.1 前端改造
前端用 fetch + ReadableStream 解 SSE,按 \n\n 切事件、按 event: / data: 解析,逐 token 推进 chatStore:
// chat.vue 用 fetch + ReadableStream 解 SSE
const resp = await fetch('http://localhost:8080/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', authorization: token,
'Accept': 'text/event-stream' },
body: JSON.stringify({ prompt: message, sessionId, useRag: false })
})
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
// 按 \n\n 切事件,按 event:/data: 解析,逐 token 推 chatStore
坑 · SSE 规范要剥离前导空格,但 Spring 的输出不能剥
接通后第一版输出变成了 HelloHello!Howcan...——词间空格全没了。用 od -c 直接看原始字节才确认:Spring 把 token 原样拼在 data: 后面,比如 data: there 里这个空格属于 token 本身(词间分隔符)。如果按 SSE 规范"strip one leading SPACE"去处理,就会把所有词间空格吃掉。
这条经验和上一篇排 SSE buffering 是一脉相承的:不要按规范读,要按字节读——Spring 这里就没按规范写。
// 对策:不剥离,原样取
if (raw.startsWith('data:')) dataLines.push(raw.substring(5))
4.2 验证
$ curl -sN -X POST http://localhost:8080/api/ai/chat ...
event:session
data:b92ceb69-7975-4cc2-a0e7-1626c201bc9f
event:delta
data:红
event:delta
data:黑
event:delta
data:树
event:delta
data:是一种
...
event:delta
data:度为 $O
event:delta
data:(\log n)$
五、补齐缺失的后端 endpoint(14 个)
前端有一堆 notImplemented 占位,对应的后端路径压根不存在。这次按模块一次性补齐 14 个真实 endpoint。
5.1 全景
| 模块 | 新增方法 + 路径 |
|---|---|
| 错题 | PUT /api/mistakes/{id}/status(标记已掌握 / 待复习)<br>GET /api/mistakes/statistics |
| 练习 | POST /api/exercises/next(占位 → 真实)<br>GET /api/exercises/subjects · /subjects/{id}/topics · /records |
| 课程 | GET /api/courses |
| 对话辅助 | GET /api/ai/chat/recommend(基于薄弱点 + CS 通用题兜底)<br>POST /api/ai/chat/{sessionId}/cancel<br>DELETE /api/ai/chat/{sessionId}/history |
| 首页 / 报告 / 个人 | GET /api/home · /api/home/heatmap · /api/home/growth<br>GET /api/report?period= · /api/report/growth?days=<br>GET /users/statistics |
| 通知(含新表) | GET /api/notifications · /unread-count<br>PUT /api/notifications/{id}/read · /read-all<br>内部:NotificationService.push() 供其它服务塞通知 |
5.2 顺带修的两个老 bug
| 文件 | 问题 | 修复 |
|---|---|---|
QuestionMapper.selectExercises |
ORDER BY RAND() 是 MySQL 写法,PostgreSQL 报 "function rand() does not exist" |
改 RANDOM() |
ExerciseServiceImpl.determineTargetDifficulty |
返回 "BASIC/INTERMEDIATE/ADVANCED" 字符串,但 question.difficulty 是 Integer,永远查不到题 |
改成返回 "1"/"2"/"3",并兼容旧字符串输入 |
第二条和上一篇 L4 闭环里抓出的"key 用 kpName 还是 kpId"是同一类毛病——契约两端类型对不上,接口看着 200,数据却永远落不到点。
5.3 前端联动
同步把 api/index.js 里 9 个 notImplemented 占位换成真实路径。剩 3 个保留:refreshToken(后端用长 token,无需刷新)、generateProfile(注册时已建画像,无独立动作)、submitExercise(空数组守卫)。
六、Agent 智能调度系统
项目里已有多个独立 agent / service(TeachingAgent、AiChatService、MultimodalChatService、MistakeDiagnosisAgent、AiStudyPlanGenerator…),但全靠手动直接调用。这次在它们上面搭一层调度 agent,对用户原始输入做意图判别,再路由到正确的子 agent。
6.1 架构
POST /api/agent/dispatch
|
v
DispatcherAgent.classify() // 1) 强信号短路 → 2) LLM 分类 → 3) 兜底
|
v
AgentOrchestrator.dispatch()
|
v
┌──── TeachingAgent.{generateExercises | gradeAnswer | recommendResources
│ | getLearningProgress | generateLearningSuggestion
│ | generateTargetedPractice}
├──── AiChatService // CHAT(同步聚合 + 单次返回)
├──── MultimodalChatService // MULTIMODAL_RECOGNITION
├──── MistakeDiagnosisAgent // MISTAKE_DIAGNOSIS
└──── AiStudyPlanGenerator // STUDY_PLAN_GENERATE
6.2 意图分类策略
为了不让每次请求都白白多花一次 LLM 调用,分类做成三段式——强信号先短路,命不中再交给 LLM,再不行兜底走 CHAT:
| 阶段 | 逻辑 | 延迟 |
|---|---|---|
| ① 短路 | 请求里带 hintIntent / attachmentUrls / extras.mistakeId → 直接命中对应意图,跳过 LLM |
~0ms |
| ② LLM 分类 | 把 10 种意图描述塞进 prompt,让 Qwen 输出 {intent, confidence, reasoning, extractedParams} |
~800ms |
| ③ 兜底 | LLM 解析失败或意图未识别 → 默认 CHAT | — |
6.3 实测分类(中英文混合,8 条 case 全过)
| 输入 | 命中意图 | 抽出的参数 |
|---|---|---|
| "give me 3 medium dynamic programming questions" | EXERCISE_GENERATE | {count:3, difficulty:"2", knowledgePoint:"dynamic programming"} |
| "我下一步该学什么?" | LEARNING_SUGGESTION | {} |
| "帮我分析下我学得怎么样了" | LEARNING_PROGRESS | {} |
| "给我推荐些关于二叉树的学习资料" | RESOURCE_RECOMMEND | {knowledgePoint:"二叉树"} |
| "用一句话解释什么是哈希表" | CHAT | {} |
| {extras:{mistakeId:12}}(无 prompt) | MISTAKE_DIAGNOSIS(短路命中) | {mistakeId:12} |
| {attachmentUrls:[".png"]} | MULTIMODAL_RECOGNITION(短路命中) | {} |
| {hintIntent:EXERCISE_GENERATE, extras:{kpId:1,…}} | EXERCISE_GENERATE(短路命中) | 原样透传 |
6.4 端到端
调 /api/agent/dispatch 能直接拿到子 agent 的真实返回。比如 CHAT 路径,Qwen 同步返回"哈希表是一种通过哈希函数将键映射到值的数据结构…";EXERCISE_GENERATE 路径,真从题库里抽出 2 道 Java 语法基础题。
七、RAG 检索体系
最后一块,也是最重的一块——把爬来的学习资料接成可检索的知识库,让 AI 回答时能"引经据典"。
7.1 数据源
data_crawler/crawl/cleaned/ 下有 13 个 JSON 文件,已切好 chunks,合计约 18,908 条:
| 学科 | chunks |
|---|---|
| 数据结构(csdn + github) | 6,042 |
| 算法(csdn + github) | 2,832 |
| 数据库(csdn + github) | 2,147 |
| 计算机网络(csdn + github) | 1,897 |
| 操作系统(csdn + github) | 2,402 |
| 高等数学 / 线性代数 | 3,393 |
| 面试题 | 195 |
7.2 技术栈
- 向量库:PostgreSQL +
pgvector扩展(pgvector/pgvector:pg16镜像) - 距离:欧氏距离
<->运算符,ORDER BY embedding <-> #{q}::vector - Embedding:DashScope
text-embedding-v3,1024 维 - 相似度筛选:先取 topK × 3 召回,再按 subject → course_id → kp.course_id 过滤
7.3 三个非显然的坑
坑一 · 维度错位
表原本是 vector(768),但配置的 v3 模型实际输出 1024 维。直接 ALTER TYPE pgvector 不支持,只能趁表里 0 行的窗口期 DROP COLUMN + ADD COLUMN 重建。
坑二 · Spring AI 解不开 DashScope 的响应
OpenAiApi$EmbeddingList 没标 @JsonIgnoreProperties(ignoreUnknown=true),而 DashScope 的返回多了一个顶层 id 字段,直接触发 UnrecognizedPropertyException。改不了 Spring AI 内部的反序列化器,干脆自己写一个 HTTP 客户端绕开:DashScopeEmbeddingClient。
// DashScopeEmbeddingClient:100 行内的自包含直连
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class EmbeddingApiResponse {
public String object, model, id; // 多塞的 id 留着
public List<EmbeddingItem> data;
public Map<String, Object> usage;
}
这条的经验是:三方 SDK 的兼容性问题,绕过比对抗便宜。自己写 100 行 HTTP 客户端,比去改 Spring AI 内部转换器经济得多。
坑三 · batch 上限不是 25 而是 10
text-embedding-v3 的文档没明确写批量上限,实测超过 10 条就返回 InvalidParameter: batch size is invalid, it should not be larger than 10。把 BATCH_SIZE 从 25 改成 10 后稳定通过。
7.4 导入器设计
导入器最重要的素养是幂等——重跑不能产生副作用:
| 能力 | 实现 |
|---|---|
| 幂等 | chunk_id 入 vector_embedding.source_id 并建唯一索引;重跑会 skip 已导入 |
| 自动 anchor | 按文件名解析学科 → 自动建 course + 对应 anchor knowledge_point |
| 批量 embed | 10 条 / 批,单批失败不影响整体 |
| 统计 | 每文件返回 {total, ok, skipped, failed} |
7.5 触发方式
# 单文件
POST /api/admin/rag/import-file?path=F:/main/data_crawler/crawl/cleaned/github_database.json
# 目录(filter 可选)
POST /api/admin/rag/import-dir?dir=F:/main/data_crawler/crawl/cleaned&filter=github
7.6 验证:从导入到回答
# 1) 导入 github_database.json(150 chunks)
{"file":".../github_database.json","subject":"数据库","source":"github",
"anchorKpId":21,"total":150,"skipped":0,"ok":150,"failed":0}
# 2) 提问
POST /api/ai/chat {"prompt":"数据库事务的 ACID 分别指什么?","subject":"数据库","useRag":true}
# 3) SSE 响应里看到
event:references
data:[{"vecId":127,"content":"## 3.事务\n\n**事务**是...具有4个特性:**原子性**、**一致性**、**隔离性**、**持续性**。简称为**ACID**特性","score":1.0},
{"vecId":3,"content":"## 概念\n\n事务指的是满足 ACID 特性...","score":0.67},
{"vecId":5,"content":"### 4. 持久性(Durability)\n\n一旦事务提交...","score":0.33}]
event:delta
data:好的!以下是关于数据库事务的 ACID 特性的详细解释,结合了参考资料内容...
AI 开头明说"结合了参考资料",说明 PromptBuilder 已经把召回片段注入了 Prompt——RAG 真闭环了。
八、几条复盘经验
- 编译期问题先解决,再谈别的。Lombok 失效让 50+ 个表面错误全是假阳,先升版本,剩下的真错误自然剥离出来。
- 不要按规范读,要按字节读。SSE 的
data:行规范要剥前导空格,但 Spring 不按规范写——od -c直接看字节才确认空格的真实归属。 - 三方 SDK 兼容性问题,绕过比对抗便宜。Spring AI 解不开 DashScope 响应,自己写 100 行 HTTP 客户端,比改它内部转换器划算。
- 幂等是导入器的基本素养。
source_id+ 唯一索引,让重跑变成无副作用操作。 - OPTIONS 预检 401 的陷阱在浏览器跨域里相当常见,写拦截器时第一时间放过 OPTIONS。
九、阶段成果数据自检
| 指标 | 目标 | 本期实际 |
|---|---|---|
| L1 编译通过 | 100% | 100% ✅(升级 Lombok 后) |
| 编译错误清零 | 130+ → 0 | 0 ✅ |
| 新增 / 改写后端文件 | — | 30+ |
| 新增 endpoint | 14 | 14 ✅ |
| Agent 意图分类 case | 全过 | 8 / 8 ✅ |
| 密码重置 E2E | 全过 | 11 / 11 ✅ |
| RAG 闭环 | 跑通 | ✅ 已闭环 |
| RAG 已导入向量 | 试水 | 150 / 18,908(0.8%) |
| E2E 验证场景 | — | 40+ 条 curl,全 PASS ✅ |
十、下一步
系统"活起来"这件事本期已经办到——能编译、能登录、能对话、能检索、能调度。下一阶段把几处临时方案做成工程化:
| 项 | 当前 | 下一步 |
|---|---|---|
| 密码重置邮件 / 短信 | 打到后端日志 | 接 SMTP(QQ / Gmail)+ 阿里云 SMS |
| Agent cancel | 占位 ok | 维护 Map<sessionId, Disposable>,真断流 |
| Schema 变更 | 手动 docker exec psql |
整理成 V2__add_email_and_rag.sql,重新启用 Flyway |
| API key | 硬编码 yml | 改 ${OPENAI_API_KEY} 环境变量 |
| RAG 全量 | 已导入 150 / ~18,908 | 分批:先 csdn_data_structure(4940)+ github_offer(195)覆盖最高频科目 |
| RAG 召回质量 | 纯向量召回 | 加入 BM25 关键词召回 + RRF 融合 |
| 权限 | AdminController 任何登录用户可触发 |
加 @PreAuthorize("hasRole('ADMIN')") |
至此,"计科智伴"从"编译都过不去"走到了"接通真实大模型 + RAG + 多 Agent 调度"。系统真的活起来了,下一篇就让它跑得更稳、更全。
本文聚焦本期新增功能与关键技术决策,详细 commit / 修复列表可参见仓库 git log。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)