ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SpringAI实战:在线考试系统知识点管理模块智能化改造

SpringAI实战:在线考试系统知识点管理模块智能化改造 1. 从一次改造聊起知识点管理模块的痛点在哪在线考试系统做了几年大部分模块其实都是增删改查真正让人头疼的往往是那个看似不起眼的知识点管理模块。别的模块顶多无聊知识点管理是既无聊又麻烦树形结构层级一深维护起来全靠手工点选同一个知识点不同老师录入的风格能差出十万八千里标签体系形同虚设挂题的时候更是全凭记忆力。最近我在做SpringAI项目落地第一个选中的改造对象就是它。之所以选这个模块是因为它的工作模式非常契合大语言模型的能力边界。知识点的核心工作无非是三类从教材/大纲中抽取知识点、为知识点补全描述和考法、把题目和知识点做关联。这三件事都属于典型的低创造性、高重复性文本任务模型做这种任务几乎没有幻觉风险而且产出结果天然适合“人审后入库”的流程。相比之下让AI去生成试卷或判定答案这种高风险场景试错成本高得多现阶段我反而不推荐一上来就动。适合参考这篇文章的读者一类是正在做在线考试、题库、知识库类系统的开发者另一类是跑通了SpringAI demo但不知道怎么和真实业务模块结合的Spring Boot用户。文章会从整体设计、数据结构调整、核心实现、Prompt工程到踩坑记录完整过一遍重点说一下哪些地方该用AI、哪些地方别用AI以及实现过程中可能会遇到的实际问题。2. 先定框架AI增强而不是AI接管2.1 三条设计原则动手写代码之前我和团队先后推翻了三个版本。第一个版本想得很激进让AI自动维护整个知识点树——知识点的新增、合并、移动全都交给模型做。第二个版本收敛了一点保留了人工操作但想让AI自动批量创建知识点。最后落地的是第三个方案所有写库操作仍然走原来的管理和人工确认流程AI只负责生成草稿、预检、打标签和关联推荐。第一条原则是写库必须过人工。原因很简单知识点在考试系统里承担着题目挂靠的锚点作用一旦结构乱了后面所有统计都不可信。AI生成的内容再快也快不过一个“撤回”操作带来的灾难性后果。第二条原则是把AI的能力拆成小粒度的Skill。不要在业务代码里到处塞“调用大模型”的代码而是把AI能力封装成独立的服务方法比如generateKnowledgePointDraft、extractTagsFromText、recommendQuestionsForPoint。每个方法做一件事输入输出都用显式的DTO定义。这样做的直接好处是AI能力可以单独调试、单独降级模型供应商切换时不需要动业务层代码。第三条原则是无AI功能降级可用。意思是无论AI服务是否可用基础的知识点CRUD、树形展示、题目挂靠都要能正常工作。实际上线时模型服务超时或触发限流是常态如果业务层强依赖AI的调用结果整个模块就会变得非常脆弱。所以我在设计AI调用层时把所有AI方法都加了try-catch包裹异常时返回空结果或默认值保证主流程不受影响。2.2 技术选型SpringAI在这里的价值关于技术选型这里单独说一点。网上关于SpringAI的讨论很多但大多数demo都停留在“搭个对话接口、问一句答一句”的层面。实际上SpringAI的核心价值不是对话而是它对模型调用做了统一抽象并且提供了结构化输出、Prompt模板管理、工具调用等面向业务集成的能力。我用了SpringAI之后最直观的感受是——切换模型厂商确实方便了很多。项目里对接过两家不同供应商代码层面只需要改配置项的spring.ai.model.chat参数和对应的api-key业务代码里的ChatModel接口完全不用动。这一点对于在线考试系统这类长期演进的业务系统很重要因为模型服务更新换代很快不希望在技术上被某一家绑死。SpringAI还提供了一类非常实用的能力结构化输出。调用模型后可以直接把它输出的JSON反序列化成Java对象不再需要自己写正则或字符串截取来处理模型返回。知识点模块里我用了BeanOutputConverter定义好KnowledgePointDraft类模型输出JSON后自动完成类型转换省掉了大量解析和异常处理代码。3. 数据结构设计先把地基打牢3.1 树形结构方案知识点管理模块首先是一棵树所以第一步要解决树怎么存的问题。常见的方案有三种邻接表parent_id、路径枚举path字段、闭包表point_closure。我曾经的惯性选择是邻接表因为简单但这次在系统里多加了几个考量点。邻接表查询子节点容易但查询“某个节点的所有祖先”比较麻烦需要递归。路径枚举维护成本不高查询祖先/后代都很方便但移动子树时更新路径字符串容易出错。闭包表查询功能最强但插入、删除、移动的维护逻辑最复杂容易出bug。考虑到在线考试系统的知识点树有几个特点——树的高度有限正常不超过5层、写操作频率远低于读操作、大部分场景是整树加载后前端展开。我最终选了邻接表 path冗余字段的组合方案。具体来说表里同时保留parent_id和path字段path存从根节点到当前节点的ID路径比如/1/18/36/。这样查询某个节点下的整棵子树时一条LIKE path%就能搞定而日常维护只需要在插入、移动、删除时同步更新path。3.2 核心表结构知识点主表结构我按如下设计CREATE TABLE knowledge_point ( id bigint(20) NOT NULL AUTO_INCREMENT, parent_id bigint(20) DEFAULT 0 COMMENT 父知识点ID0表示根, path varchar(500) NOT NULL COMMENT 从根到当前节点的ID路径格式 /1/18/36/, name varchar(200) NOT NULL COMMENT 知识点名称, description text COMMENT 知识点详细描述, subject_id bigint(20) NOT NULL COMMENT 所属科目ID, grade_id bigint(20) DEFAULT NULL COMMENT 适用年级, importance tinyint(4) DEFAULT 3 COMMENT 重要程度 1-5, difficulty tinyint(4) DEFAULT 3 COMMENT 难度系数 1-5, status tinyint(4) DEFAULT 0 COMMENT 0草稿 1待审核 2已发布 3已归档, ai_generated tinyint(4) DEFAULT 0 COMMENT 是否AI生成草稿, version int(11) DEFAULT 1 COMMENT 版本号, create_by varchar(50) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_by varchar(50) DEFAULT NULL, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_path (path), KEY idx_parent (parent_id), KEY idx_subject (subject_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;加status字段非常关键这是为了让AI生成的草稿和人工确认的数据在流程上分离。AI生成的新知识点默认状态是“草稿”经过人工编辑后变成“待审核”审核通过后才变成“已发布”。这样即使AI偶尔生成了一些不太合理的内容它也只会停留在草稿区不会污染正式数据。3.3 标签和题目关联表知识点和标签、题目的关系是多对多单独建关联表CREATE TABLE knowledge_point_tag ( id bigint(20) NOT NULL AUTO_INCREMENT, point_id bigint(20) NOT NULL, tag_id bigint(20) NOT NULL, tag_name varchar(100) NOT NULL COMMENT 冗余标签名方便直接展示, source tinyint(4) DEFAULT 1 COMMENT 1人工 2AI推荐, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_point (point_id), KEY idx_tag (tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE knowledge_point_question ( id bigint(20) NOT NULL AUTO_INCREMENT, point_id bigint(20) NOT NULL, question_id bigint(20) NOT NULL, relation_type tinyint(4) DEFAULT 1 COMMENT 1直接关联 2间接关联 3AI推荐, create_by varchar(50) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_point_question (point_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里用一个容易踩坑的点解释一下为什么关联表里要多存一个tag_name字段而不是关联tag_id后再去JOIN标签表因为知识点的标签列表在列表页、详情页、筛选面板三个地方都会高频展示JOIN标签表会有额外的查询开销而且标签表里可能会修改名称用冗余字段可以避免历史关联关系发生错乱。代价是更新标签名称时需要同步更新关联表但标签重命名的频率极低这个代价完全可接受。4. 核心功能实现把SpringAI能力真正落进去4.1 树形接口优化一次查询组装整树知识点树最常见的接口是“按科目加载整棵树”如果每次展开一个节点都查一次数据库数据量一大页面就会明显卡顿。我的实现方式是一次性查出该科目下所有知识点在内存里组装成树结构public ListKnowledgePointTreeDTO getTreeBySubject(Long subjectId) { ListKnowledgePoint allPoints knowledgePointMapper.selectBySubject(subjectId); MapLong, ListKnowledgePointTreeDTO childrenMap new HashMap(); ListKnowledgePointTreeDTO roots new ArrayList(); for (KnowledgePoint point : allPoints) { KnowledgePointTreeDTO dto convertToDTO(point); childrenMap.computeIfAbsent(point.getParentId(), k - new ArrayList()).add(dto); } for (KnowledgePointTreeDTO dto : allPoints.stream().map(this::convertToDTO).collect(Collectors.toList())) { dto.setChildren(childrenMap.getOrDefault(dto.getId(), new ArrayList())); if (dto.getParentId() 0) { roots.add(dto); } } return roots; }实现意图比较直白先查全量数据再在内存里做父子关系组装数据库只承担一次简单的查询同时由于path字段的存在后续如果要做“某节点下的所有子孙”这种统计一条SQL就能解决。实测下来科目下知识点数量在5000条以内时这个查询的响应时间能稳定在50ms以内对于在线考试系统的正常规模完全够用。4.2 AI辅助生成知识点草稿这是整个模块里最核心的AI能力。业务场景是这样的管理员有时会拿到一份新教材的章节内容需要把这些章节拆解成知识点并补全描述、标签、考法等。传统做法是管理员手工一条条录入一个章节可能要录半小时。现在我用SpringAI做一个“一键生成草稿”的功能。大致的调用逻辑是这样的控制层接收章节原文或教材内容把它交给AI生成服务服务内部通过PromptTemplate组织提示词并调用模型最终把返回的JSON解析成知识点草稿列表返回给前端Service public class KnowledgePointAIService { private final ChatModel chatModel; private final PromptTemplate promptTemplate; public ListKnowledgePointDraft generateDraftFromChapter(String chapterContent, Long parentId) { String parentContext getParentContext(parentId); Prompt prompt promptTemplate.create(Map.of( chapterContent, chapterContent, parentContext, parentContext )); String response chatModel.call(prompt).getResult().getOutput().getContent(); return parseDraftList(response); } }实际的Prompt模板要写得非常具体包括角色设定、输入内容、输出格式、约束条件。后面专门开一节讲Prompt工程设计这里先记住一点不要直接让模型输出纯文本然后自己想办法拆字段一开始就要约定JSON格式并且严格校验。我这边踩过一次坑模型输出偶尔会带“好的下面是我生成的知识点”这样的前缀做JSON解析时直接抛异常。后来在Prompt里加重了“直接输出JSON不要任何解释性文字”的约束还在解析前做了一层字符串清洗双重保险。AI生成草稿不是直接把结果插入知识点表。真实流程是管理员点击“生成草稿”按钮后返回的每条草稿都在前端以可编辑的卡片形式展示管理员可以逐条修改名称、描述、难度确认无误后点击“保存”这时才调用普通的CRUD接口写库。这个过程大概能把原来半小时的工作压缩到五分钟关键是每一步都是可控的。4.3 标签自动推荐与规范化标签体系的痛点在于同一个知识点不同人会打出完全不同的标签比如“一元二次方程解法”“一元二次方程”“解一元二次方程”这三个标签本质上是同一个意思。我尝试用AI做两件事一是给新知识点推荐标签二是把已有标签做别名归并。标签推荐的实现比较轻直接复用文本生成能力输入知识点名称和描述让模型输出若干个候选标签。标签别名归并稍微麻烦一点需要把全量标签先拉出来按知识点数量加权后分批次交给模型判断相似性。因为目标系统标签总量不大几千条所以分批次处理完全可行。这里涉及到SpringAI项目里一个比较实用的思路——AI能力尽量做得像普通工具方法一样不要为了AI而AI。比如标签推荐我并没有把它做在保存知识点的表单提交链路里而是放在一个独立的“AI工具”区域管理员完成知识点录入后可以点“智能打标”按钮触发调用。这样做的好处是AI调用失败不影响核心功能而且管理员可以对比AI给的标签和自己心中的标签反而更容易做出决定。4.4 题目与知识点的智能关联推荐题目挂靠是考试系统里使用频率最高的操作之一出题人要把一道新题关联到知识点上。传统做法是手动从树里找知识点如果对知识点体系不熟可能要找半天甚至挂错位置。我用了一个相对简单但效果不错的方案把题目的题干文本和知识点的名称/描述文本做向量化计算相似度后给出TopN推荐。这个方案在SpringAI里实现时有两条路——调用模型的Embedding接口或者在本地用文本相似度做简单匹配。我最终选择的是混合方式优先用模型Embedding计算语义相似度如果模型服务不可用就降级到基于关键词重合度的本地计算。语义相似度的效果优于关键词匹配特别是题干描述很详细但知识点名称很简短时纯关键词基本匹配不上。一个具体的落地方案是知识点数据量不大几千条所以不需要引入专门的向量数据库直接加载到内存里算余弦相似度即可。系统在启动时把已发布的知识点名称和描述做成Embedding缓存到本地Map新的题目进来时算一次向量再和缓存里的向量逐个算余弦相似度取出Top10。几千条向量遍历一次的时间在几十毫秒级别完全够用。等数据量到十万级再考虑引入向量数据库也不迟。5. Prompt工程设计AI输出质量的关键5.1 Prompt结构拆解SpringAI提供了PromptTemplate机制可以在prompt里定义变量运行时动态填充。这套机制非常适合把提示词模板维护在独立文件中。我的知识点生成Prompt模板存放在src/main/resources/prompts/knowledge-point-draft.st下内容结构如下你是一个在线教育领域的知识管理专家擅长从教材内容中抽取并整理知识点。 请从以下教材章节内容中提取知识点按每一条知识点输出JSON数组。 章节内容 {chapterContent} 当前知识点所属的上级知识点路径 {parentContext} 输出要求 1. 字段严格包含name、description、importance、difficulty、tags 2. name不超过50个字符描述需包含概念解释和常见考法 3. importance和difficulty取值1到5 4. tags为数组最多5个 5. 直接输出JSON数组不要输出任何解释性文字 6. 如果章节内容不足以提取知识点输出空数组[]之所以要写明“在线教育领域的知识管理专家”这个角色设定是因为实际测试中发现给模型一个专业角色设定后生成的描述会更贴近教师教研的思路而不是那种百科式的大白话。设定角色会显著影响输出风格这是Prompt工程里性价比最高的一个操作。5.2 结构化输出与异常兜底SpringAI的BeanOutputConverter可以直接把模型输出转成对象列表BeanOutputConverterListKnowledgePointDraft converter new BeanOutputConverter(new TypeReferenceListKnowledgePointDraft() {}); String formatInstruction converter.getFormatInstruction(); Prompt prompt new Prompt(promptTemplate.create(...).getContents() formatInstruction); String response chatModel.call(prompt).getResult().getOutput().getContent(); ListKnowledgePointDraft drafts converter.convert(response);这里要特别提醒一个容易被忽略的点converter.convert()方法有时会因为模型返回了多余的内容而抛异常比如返回了“json”这样的markdown代码块标记。为了稳妥我在转JSON之前先做一次字符串清理把常见的markdown格式标记去掉再用Jackson解析兜底private ListKnowledgePointDraft parseDraftList(String response) { String json response.trim() .replaceAll(^json\\s*, ) .replaceAll(\\s*$, ) .replaceAll(^\\s*, ); try { return objectMapper.readValue(json, new TypeReferenceListKnowledgePointDraft() {}); } catch (JsonProcessingException e) { log.error(解析AI生成的知识点列表失败, 原始内容: {}, response, e); return Collections.emptyList(); } }不要高估模型的稳定性。在项目刚上线时模型偶发多输出几个字是很正常的事情没有这一层兜底用户的正常操作会被莫名其妙打断。5.3 Few-shot示例的用法如果光靠角色设定和输出要求还不够稳定可以在Prompt里加一个few-shot示例也就是给模型展示一段“输入章节内容后我期望得到的输出”的样例。实测发现加了示例之后模型输出的质量稳定性提升非常明显尤其是描述的长度和风格会贴近示例。示例 章节内容片段一元二次方程是初中数学的核心内容常见的解法包括直接开平方法、配方法、公式法和因式分解法。 期望输出 [{name:一元二次方程的解法,description:一元二次方程常见的解法有直接开平方法、配方法、公式法和因式分解法其中公式法适用于所有可解的一元二次方程因式分解法最为快捷但适用范围有限。常见考法为给定方程让学生选择最优解法求解。,importance:5,difficulty:3,tags:[一元二次方程,方程解法,初中数学]}]注意一个细节few-shot示例里的内容要和真实使用场景的输入形态保持一致。如果真实场景里管理员会粘贴一大段章节原文那示例也应该用一段真实风格的原文而不是特意造一个很短的句子。这样模型才能学到合适的抽取粒度。6. 问题排查与优化实录6.1 常见问题速查表问题现象可能原因解决方案模型返回内容解析JSON失败输出带了markdown标记或解释性前缀增加字符串清洗加few-shot约束兜底返回空列表生成的知识点描述太短没干货Prompt里缺少对描述结构的约束明确写“包含概念解释和常见考法”两部分AI推荐标签过于口语化没有限定标签风格Prompt里增加“标签使用教材常用术语”约束长章节内容导致token超限输入内容超出模型上下文按段落切分分批处理后再合并调用并发高时被限流单key请求频率限制加本地线程池控制并发数增加失败重试和退避树形结构树加载慢多次查询数据库组装树改为一次性查询内存组装删除父节点时子节点数据残留没做级联删除处理逻辑上使用软删除递归下线AI生成的内容与已有知识点重复没有做语义查重生成前用Embedding向量相似度做预检高于阈值则提示6.2 三个让我印象深刻的坑第一个坑是版本兼容问题。SpringAI的版本迭代比较快不同小版本之间API调整过特别是关于ChatModel和PromptTemplate的包路径变化。我遇到的是用某个早期版本时配置类里引用的包路径在升级后直接编译不过。解决方案是锁定一个稳定版本不要频繁升级并且把Spring Boot的版本和SpringAI的版本对照关系查清楚再动手。第二个坑是Embedding模型的维度对齐问题。我做题目关联推荐时一开始用的Embedding接口返回的向量维度和本地已有缓存向量的维度对不上算余弦相似度时直接报数组越界。原因是测试环境和生产环境配置了不同供应商的模型两边向量维度不同。后来我在写代码时强制加了维度校验如果维度不一致直接降级为本地关键词匹配不再使用向量计算。第三个坑是AI生成标签的实体对齐问题。模型给出的标签有时候是“初中数学”“一元二次方程”这种本身就在标签库里的有时候又会出现“数学方程”这种库里没有的新词。把新词直接写入标签库会让标签库越来越膨胀。最后的处理是AI推荐的标签只有在人工确认时才会写入AI不会直接往标签表里插数据。6.3 性能与稳定性优化知识点管理模块上线后我做了几个针对稳定性的优化。第一个是超时控制。AI调用在业务链路里是不可控的可能5秒也可能30秒。如果管理员点了一下“生成草稿”接口等了半分钟才返回用户大概率已经把页面关掉了。所以我对AI调用接口设置了比较短的超时时间默认8秒超时后直接返回失败提示让用户重试或换一个更短的输入。宁可一次只生成少量草稿也不要让用户长时间等待。第二个是并发控制。系统同时在线用户不多但AI接口有QPS限制如果多个管理员同时点击“AI打标签”或“AI生成草稿”很可能触发限流。我加了一个简单的本地信号量控制并发数限制同时只能有3个AI调用在跑其余请求排队等待。实现的代码片段如下private final Semaphore aiSemaphore new Semaphore(3); public T T executeWithAiLimit(SupplierT action) { boolean acquired aiSemaphore.tryAcquire(); if (!acquired) { throw new ServiceException(AI服务繁忙请稍后再试); } try { return action.get(); } finally { aiSemaphore.release(); } }注意这个方案只适用于单机部署。如果系统做了多实例部署需要用分布式限流或者把AI调用下沉到独立的消息队列中异步处理。第三个是AI生成结果的缓存。同样一段章节内容管理员可能会多次点击生成但每次生成的结果都可能不一样。如果用户对第一次生成的结果不满意删掉重来一次得到的是另一份内容。这个没有做成缓存因为从产品角度来看用户本来就希望每次能看到不同风格的草稿。7. 后续扩展与我的实际体会这次针对知识点管理模块的优化方案按照前面的设计落到项目里之后管理员的录入效率提升是很明显的。最直接的数据是原来手工录入一个章节的知识点需要四十分钟左右现在用AI生成草稿加人工编辑十分钟左右就能完成。而且因为AI生成的描述格式比较规范知识点的内容质量整体比之前手写的更统一。后续如果有精力我还会做两个方面的扩展。一个是知识点的“相似知识点自动关联”把它做成一个独立的Skill在查看某个知识点时顺带展示可能相关的知识点帮助管理员发现树形结构之外的知识关联。另一个是知识点内容的定期AI复核对已经发布的知识点定期用AI检查是否存在名称相似但内容比较混乱的情况给出整理建议。最后分享一点这次改造过程中的体会AI落地到业务模块最难的不是模型调用而是流程设计。太激进了把AI放在主流程里一旦模型出问题整个业务都会崩太保守了AI只是锦上添花对效率提升起不到作用。我最后采用的方式是所有AI能力默认作为“辅助工具”存在用户主动点击才触发AI生成的任何数据都只是“草稿”必须经过人工确认才能进入正式流程。这个分寸拿捏住了SpringAI在项目里落地基本就不会出大问题。另外如果在你的项目里知识点数据量更大、层级更深可以优先考虑引入向量数据库来优化语义检索和重复识别这算是这套方案下一步最自然的发展方向。
返回列表