上一篇《测试体系与代码维护》收尾时立了个 flag——测试体系搭好了下周开始让它真正活起来。这一篇就来兑现这句话。接手的两个模块——后端course-aiSpring Boot 3.4 MyBatis-Plus Spring AI和前端eduagent-frontuniapp H5——拿到时的状态其实不太体面编译不过、依赖配置缺项、前后端 API 路径完全对不上聊天 / 画像 / 错题这些关键链路全是 mock。所以活起来得从最底层做起先让它能编译再让前后端能真说上话最后接上真实大模型、RAG 检索和多 Agent 调度。本期一共落地 7 块新功能并顺手清掉一批阻塞性 bug。先上个总览板块交付物状态编译 / 启动195 个 Java 文件全量编译通过 docker-compose 起 4 个依赖✅ 已交付登录流程登录前置 注册 Tab 画像感知跳转✅ 已交付密码重置邮箱链接 短信验证码 管理员三选一✅ 已交付智能对话真接 DashScope QwenSSE 流式✅ 已交付后端 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硬编码 Lombok1.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 groupIdorg.projectlombok/groupId artifactIdlombok/artifactId !-- 移除版本由 spring-boot-parent 3.4 管理为 1.18.36 -- /dependency移除版本固定后Lombok 升到 SB 管理版50 条找不到符号瞬间消失。剩下 5 处真错误再各个击破新建BusinessException、恢复SubmitAnswerRequest含qIduserAnswer、删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 验证成功立即deletekey防短信轰炸同手机号 60s 内只能发 1 次setIfAbsent TTL时效邮件 token 15min短信 code 5min不在登录拦截器内4 个 reset 路径加入excludePathPatterns3.3 邮件 / 短信怎么发当前是零外部依赖的占位把内容打到后端日志[MOCK EMAIL]/[MOCK SMS]块联调时人工去日志里复制链接 / code。接真实 SMTP 时新建SmtpEmailService implements EmailService标上Primary即可热替换不用动其它任何代码。3.4 E2E 验证11 个场景全过#场景结果1–2邮箱注册新用户 手机号 / 邮箱两种方式登录都拿到 token3–6邮箱发链接 → token 改密 → 旧密码失效 → 新密码登录✓7–8短信发码 60s 内再发第 2 次被冷却拦截9–10验证码改密 新密码登录✓11同验证码二次重放提示验证码已过期四、智能对话真接通 QwenSSE 流式后端/api/ai/chat早就用 Spring AI 接 DashScope 跑通了但前端 chat.vue 的callAI还是setTimeout模拟逐字渲染一段 mock 字符串——两端从没真接上。这次把它们接通。4.1 前端改造前端用fetchReadableStream解 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)$五、补齐缺失的后端 endpoint14 个前端有一堆notImplemented占位对应的后端路径压根不存在。这次按模块一次性补齐 14 个真实 endpoint。5.1 全景模块新增方法 路径错题PUT/api/mistakes/{id}/status标记已掌握 / 待复习brGET/api/mistakes/statistics练习POST/api/exercises/next占位 → 真实brGET/api/exercises/subjects·/subjects/{id}/topics·/records课程GET/api/courses对话辅助GET/api/ai/chat/recommend基于薄弱点 CS 通用题兜底brPOST/api/ai/chat/{sessionId}/cancelbrDELETE/api/ai/chat/{sessionId}/history首页 / 报告 / 个人GET/api/home·/api/home/heatmap·/api/home/growthbrGET/api/report?period·/api/report/growth?daysbrGET/users/statistics通知含新表GET/api/notifications·/unread-countbrPUT/api/notifications/{id}/read·/read-allbr内部NotificationService.push()供其它服务塞通知5.2 顺带修的两个老 bug文件问题修复QuestionMapper.selectExercisesORDER 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 / serviceTeachingAgent、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_GENERATE6.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 questionsEXERCISE_GENERATE{count:3, difficulty:2, knowledgePoint:dynamic programming}我下一步该学什么LEARNING_SUGGESTION{}帮我分析下我学得怎么样了LEARNING_PROGRESS{}给我推荐些关于二叉树的学习资料RESOURCE_RECOMMEND{knowledgePoint:二叉树}用一句话解释什么是哈希表CHAT{}{extras:{mistakeId:12}}无 promptMISTAKE_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 github6,042算法csdn github2,832数据库csdn github2,147计算机网络csdn github1,897操作系统csdn github2,402高等数学 / 线性代数3,393面试题1957.2 技术栈向量库PostgreSQL pgvector扩展pgvector/pgvector:pg16镜像距离欧氏距离-运算符ORDER BY embedding - #{q}::vectorEmbeddingDashScopetext-embedding-v31024 维相似度筛选先取 topK × 3 召回再按 subject → course_id → kp.course_id 过滤7.3 三个非显然的坑坑一 · 维度错位表原本是vector(768)但配置的 v3 模型实际输出1024 维。直接ALTER TYPEpgvector 不支持只能趁表里 0 行的窗口期DROP COLUMN ADD COLUMN重建。坑二 · Spring AI 解不开 DashScope 的响应OpenAiApi$EmbeddingList没标JsonIgnoreProperties(ignoreUnknowntrue)而 DashScope 的返回多了一个顶层id字段直接触发UnrecognizedPropertyException。改不了 Spring AI 内部的反序列化器干脆自己写一个 HTTP 客户端绕开DashScopeEmbeddingClient。// DashScopeEmbeddingClient100 行内的自包含直连 Data JsonIgnoreProperties(ignoreUnknown true) public static class EmbeddingApiResponse { public String object, model, id; // 多塞的 id 留着 public ListEmbeddingItem data; public MapString, Object usage; }这条的经验是三方 SDK 的兼容性问题绕过比对抗便宜。自己写 100 行 HTTP 客户端比去改 Spring AI 内部转换器经济得多。坑三 · batch 上限不是 25 而是 10text-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 对应 anchorknowledge_point批量 embed10 条 / 批单批失败不影响整体统计每文件返回{total, ok, skipped, failed}7.5 触发方式# 单文件 POST /api/admin/rag/import-file?pathF:/main/data_crawler/crawl/cleaned/github_database.json # 目录filter 可选 POST /api/admin/rag/import-dir?dirF:/main/data_crawler/crawl/cleanedfiltergithub7.6 验证从导入到回答# 1) 导入 github_database.json150 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 → 00 ✅新增 / 改写后端文件—30新增 endpoint1414 ✅Agent 意图分类 case全过8 / 8 ✅密码重置 E2E全过11 / 11 ✅RAG 闭环跑通✅ 已闭环RAG 已导入向量试水150 / 18,9080.8%E2E 验证场景—40 条 curl全 PASS ✅十、下一步系统活起来这件事本期已经办到——能编译、能登录、能对话、能检索、能调度。下一阶段把几处临时方案做成工程化项当前下一步密码重置邮件 / 短信打到后端日志接 SMTPQQ / Gmail 阿里云 SMSAgent cancel占位 ok维护MapsessionId, Disposable真断流Schema 变更手动docker exec psql整理成V2__add_email_and_rag.sql重新启用 FlywayAPI key硬编码 yml改${OPENAI_API_KEY}环境变量RAG 全量已导入 150 / ~18,908分批先 csdn_data_structure4940 github_offer195覆盖最高频科目RAG 召回质量纯向量召回加入 BM25 关键词召回 RRF 融合权限AdminController任何登录用户可触发加PreAuthorize(hasRole(ADMIN))至此计科智伴从编译都过不去走到了接通真实大模型 RAG 多 Agent 调度。系统真的活起来了下一篇就让它跑得更稳、更全。本文聚焦本期新增功能与关键技术决策详细 commit / 修复列表可参见仓库 git log。
山东大学软件学院项目实训-创新实训-计科智伴(五)——接通真实大模型、RAG 检索与多 Agent 调度
发布时间:2026/5/25 23:45:54
上一篇《测试体系与代码维护》收尾时立了个 flag——测试体系搭好了下周开始让它真正活起来。这一篇就来兑现这句话。接手的两个模块——后端course-aiSpring Boot 3.4 MyBatis-Plus Spring AI和前端eduagent-frontuniapp H5——拿到时的状态其实不太体面编译不过、依赖配置缺项、前后端 API 路径完全对不上聊天 / 画像 / 错题这些关键链路全是 mock。所以活起来得从最底层做起先让它能编译再让前后端能真说上话最后接上真实大模型、RAG 检索和多 Agent 调度。本期一共落地 7 块新功能并顺手清掉一批阻塞性 bug。先上个总览板块交付物状态编译 / 启动195 个 Java 文件全量编译通过 docker-compose 起 4 个依赖✅ 已交付登录流程登录前置 注册 Tab 画像感知跳转✅ 已交付密码重置邮箱链接 短信验证码 管理员三选一✅ 已交付智能对话真接 DashScope QwenSSE 流式✅ 已交付后端 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硬编码 Lombok1.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 groupIdorg.projectlombok/groupId artifactIdlombok/artifactId !-- 移除版本由 spring-boot-parent 3.4 管理为 1.18.36 -- /dependency移除版本固定后Lombok 升到 SB 管理版50 条找不到符号瞬间消失。剩下 5 处真错误再各个击破新建BusinessException、恢复SubmitAnswerRequest含qIduserAnswer、删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 验证成功立即deletekey防短信轰炸同手机号 60s 内只能发 1 次setIfAbsent TTL时效邮件 token 15min短信 code 5min不在登录拦截器内4 个 reset 路径加入excludePathPatterns3.3 邮件 / 短信怎么发当前是零外部依赖的占位把内容打到后端日志[MOCK EMAIL]/[MOCK SMS]块联调时人工去日志里复制链接 / code。接真实 SMTP 时新建SmtpEmailService implements EmailService标上Primary即可热替换不用动其它任何代码。3.4 E2E 验证11 个场景全过#场景结果1–2邮箱注册新用户 手机号 / 邮箱两种方式登录都拿到 token3–6邮箱发链接 → token 改密 → 旧密码失效 → 新密码登录✓7–8短信发码 60s 内再发第 2 次被冷却拦截9–10验证码改密 新密码登录✓11同验证码二次重放提示验证码已过期四、智能对话真接通 QwenSSE 流式后端/api/ai/chat早就用 Spring AI 接 DashScope 跑通了但前端 chat.vue 的callAI还是setTimeout模拟逐字渲染一段 mock 字符串——两端从没真接上。这次把它们接通。4.1 前端改造前端用fetchReadableStream解 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)$五、补齐缺失的后端 endpoint14 个前端有一堆notImplemented占位对应的后端路径压根不存在。这次按模块一次性补齐 14 个真实 endpoint。5.1 全景模块新增方法 路径错题PUT/api/mistakes/{id}/status标记已掌握 / 待复习brGET/api/mistakes/statistics练习POST/api/exercises/next占位 → 真实brGET/api/exercises/subjects·/subjects/{id}/topics·/records课程GET/api/courses对话辅助GET/api/ai/chat/recommend基于薄弱点 CS 通用题兜底brPOST/api/ai/chat/{sessionId}/cancelbrDELETE/api/ai/chat/{sessionId}/history首页 / 报告 / 个人GET/api/home·/api/home/heatmap·/api/home/growthbrGET/api/report?period·/api/report/growth?daysbrGET/users/statistics通知含新表GET/api/notifications·/unread-countbrPUT/api/notifications/{id}/read·/read-allbr内部NotificationService.push()供其它服务塞通知5.2 顺带修的两个老 bug文件问题修复QuestionMapper.selectExercisesORDER 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 / serviceTeachingAgent、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_GENERATE6.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 questionsEXERCISE_GENERATE{count:3, difficulty:2, knowledgePoint:dynamic programming}我下一步该学什么LEARNING_SUGGESTION{}帮我分析下我学得怎么样了LEARNING_PROGRESS{}给我推荐些关于二叉树的学习资料RESOURCE_RECOMMEND{knowledgePoint:二叉树}用一句话解释什么是哈希表CHAT{}{extras:{mistakeId:12}}无 promptMISTAKE_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 github6,042算法csdn github2,832数据库csdn github2,147计算机网络csdn github1,897操作系统csdn github2,402高等数学 / 线性代数3,393面试题1957.2 技术栈向量库PostgreSQL pgvector扩展pgvector/pgvector:pg16镜像距离欧氏距离-运算符ORDER BY embedding - #{q}::vectorEmbeddingDashScopetext-embedding-v31024 维相似度筛选先取 topK × 3 召回再按 subject → course_id → kp.course_id 过滤7.3 三个非显然的坑坑一 · 维度错位表原本是vector(768)但配置的 v3 模型实际输出1024 维。直接ALTER TYPEpgvector 不支持只能趁表里 0 行的窗口期DROP COLUMN ADD COLUMN重建。坑二 · Spring AI 解不开 DashScope 的响应OpenAiApi$EmbeddingList没标JsonIgnoreProperties(ignoreUnknowntrue)而 DashScope 的返回多了一个顶层id字段直接触发UnrecognizedPropertyException。改不了 Spring AI 内部的反序列化器干脆自己写一个 HTTP 客户端绕开DashScopeEmbeddingClient。// DashScopeEmbeddingClient100 行内的自包含直连 Data JsonIgnoreProperties(ignoreUnknown true) public static class EmbeddingApiResponse { public String object, model, id; // 多塞的 id 留着 public ListEmbeddingItem data; public MapString, Object usage; }这条的经验是三方 SDK 的兼容性问题绕过比对抗便宜。自己写 100 行 HTTP 客户端比去改 Spring AI 内部转换器经济得多。坑三 · batch 上限不是 25 而是 10text-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 对应 anchorknowledge_point批量 embed10 条 / 批单批失败不影响整体统计每文件返回{total, ok, skipped, failed}7.5 触发方式# 单文件 POST /api/admin/rag/import-file?pathF:/main/data_crawler/crawl/cleaned/github_database.json # 目录filter 可选 POST /api/admin/rag/import-dir?dirF:/main/data_crawler/crawl/cleanedfiltergithub7.6 验证从导入到回答# 1) 导入 github_database.json150 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 → 00 ✅新增 / 改写后端文件—30新增 endpoint1414 ✅Agent 意图分类 case全过8 / 8 ✅密码重置 E2E全过11 / 11 ✅RAG 闭环跑通✅ 已闭环RAG 已导入向量试水150 / 18,9080.8%E2E 验证场景—40 条 curl全 PASS ✅十、下一步系统活起来这件事本期已经办到——能编译、能登录、能对话、能检索、能调度。下一阶段把几处临时方案做成工程化项当前下一步密码重置邮件 / 短信打到后端日志接 SMTPQQ / Gmail 阿里云 SMSAgent cancel占位 ok维护MapsessionId, Disposable真断流Schema 变更手动docker exec psql整理成V2__add_email_and_rag.sql重新启用 FlywayAPI key硬编码 yml改${OPENAI_API_KEY}环境变量RAG 全量已导入 150 / ~18,908分批先 csdn_data_structure4940 github_offer195覆盖最高频科目RAG 召回质量纯向量召回加入 BM25 关键词召回 RRF 融合权限AdminController任何登录用户可触发加PreAuthorize(hasRole(ADMIN))至此计科智伴从编译都过不去走到了接通真实大模型 RAG 多 Agent 调度。系统真的活起来了下一篇就让它跑得更稳、更全。本文聚焦本期新增功能与关键技术决策详细 commit / 修复列表可参见仓库 git log。