ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot集成AI智能摘要:从提示词设计到异步任务与降级兜底

Spring Boot集成AI智能摘要:从提示词设计到异步任务与降级兜底 1. 项目背景与整体方案设计1.1 为什么博客系统需要AI智能摘要先聊聊这件事的来由。我自己维护了一个基于Spring Boot的博客系统文章越来越多之后列表页呈现的要么是截断的正文、要么是手动维护的摘要。截断正文会出现半个标签或者半个字的问题手动维护摘要又太费精力多数时候根本没写。后面流量上来了读者在首页刷几十篇文章靠标题和首屏那两三行文本根本判断不了内容值不值得点进去跳出率高得离谱。当时就在想能不能在文章发布之后自动生成一段通顺、有信息量的摘要。正好那段时间AI接口已经很成熟了就决定在博客系统里集成一个AI智能摘要模块。做完之后效果确实值回票价列表页展示摘要后点击率有可感知的提升文章详情页的SEO描述也不用再手工写最关键的是老文章批量跑一遍整个站点的内容聚合质量上了一个台阶。如果你正在维护自己的博客项目不管技术栈是不是Spring Boot这套集成思路都有参考价值。核心不是某个具体模型怎么调而是“怎么把AI能力干净地嵌进已有系统”这涉及到接口选型、任务异步化、缓存策略、失败降级等一整套工程实践。1.2 技术选型Spring Boot AI模型接入的几种方案先对比一下市面上主流的接入方式方便你做选型判断方案优点缺点适合场景调用云厂商APIOpenAI、通义、文心等接入快、效果稳定、无需GPU有费用、依赖网络、需处理限流个人/中小型博客快速落地本地部署开源模型Qwen、ChatGLM等免费、数据不出内网、可控性强需要显存、运维成本高、效果不如大模型对隐私敏感、文章量大已上规模的站点调用Spring AI Alibaba等封装框架封装完善、统一接口、省去很多样板代码依赖框架版本、有学习成本愿意跟进生态、希望少写代码的团队我最终选的是云厂商API方案配合Spring Boot做一层封装。原因很直接个人博客文章的访问量和内容量都不大每天新增文章通常是个位数就算加上历史文章批量补齐一个月调用几千次API成本也就几块钱到几十块钱级别。与其在本地折腾模型部署不如先把功能跑起来。1.3 整体功能拆解与需求边界把需求拆开这个功能其实由四块组成文章内容获取与预处理从数据库取出文章正文清洗HTML提取纯文本做长度控制。AI摘要生成服务对接模型接口构造提示词调用并解析返回结果。摘要存储与展示把生成的摘要回写到文章表列表页、详情页、SEO标签共用。任务管理与异常兜底发布文章时触发、历史文章批量触发、失败重试、降级方案。这四个模块可以串联成一条完整链路。关键点在于摘要生成不能阻塞文章发布流程因为AI接口调用通常需要几秒甚至更长时间而且接口有失败概率不能因为摘要失败导致文章发布失败。所以整体上用异步任务处理这部分的工程细节后面展开讲。设计边界也要明确。我做的是“摘要生成”不是“全文总结”更不是“关键词提取”。摘要的目标是让读者在列表页快速理解文章主题和核心结论长度控制在100字以内语气客观平实。不要试图让AI做太多事比如同时生成摘要、标签、封面图文案任务一多反而每个都做不好。2. 核心实现AI摘要服务的封装与接入2.1 模型选型与参数调优思路模型选型这块我对比了GPT-4o-mini、通义千问的qwen-turbo、还有DeepSeek的API。个人博客场景速度和成本优先最终锁定qwen-turbo和GPT-4o-mini两个做AB对比最后根据中文生成质量和延迟选了qwen-turbo。理由包括中文文本的理解和概括能力足够响应速度快实测几百字的文章摘要生成基本在1-3秒内完成价格便宜得可以忽略不计。参数配置上有几个关键参数值得展开说Temperature温度摘要生成是确定性任务不是创意写作。温度调得太高每次生成的摘要差异大、容易跑偏调得太低几乎是稳定输出。我最终定在0.3左右既能保证多样化又不会出现离谱结果。max_tokens最大输出长度摘要控制在100字以内输出token上限设为200就够。设置太大会白白浪费生成时间因为模型会倾向于把长度用满。Top_p一般配合调整我用的0.8没有过度纠结这个参数温度控制好之后它影响不大。这些都是基于实际测试得到的配置。建议你不要直接照抄而是拿自己实际的文章跑几遍看看效果再做微调。不同模型、不同提示词最佳参数区间都会有差异。2.2 提示词设计让AI输出格式化摘要提示词是这个功能里最值得花时间的部分。刚开始我的提示词写得特别简单给一段文章让AI生成摘要。结果问题很多摘要像整篇内容的“缩小版”长度失控写成几百字喜欢用“本文介绍了”“本文探讨了”这类套话开头完全没有信息量偶尔会把文中的口语化表达原样搬过来格式不稳定有时加引号、有时加“摘要”前缀导致存储进数据库时带着一堆脏字符后面把提示词精化成了下面这个版本你是一位资深的博客编辑擅长为技术文章撰写精准、简洁的摘要。 请阅读以下文章内容写一段不超过100字的中文摘要。 要求 1. 直接输出摘要内容不要加摘要、本文介绍了等前缀 2. 保留文章的核心信息和关键结论 3. 用客观、简洁的语言 4. 不要复述文章开头的内容要站在全文角度概括 文章内容 {cleanedContent}这里有一个容易被忽略的重点提示词要求模型“不要复述开头”。很多模型在长文本总结时容易出现注意力偏置会把文章开头几段当作重点反复提取。明确写了这条之后摘要质量有明显提升。另外一个实操细节是提示词里我要求了“直接输出摘要内容不要加前缀”但解析时依然做了防御性处理拿到返回文本后去掉首尾空白去掉可能出现的“摘要”“摘要:”前缀再入库。宁可多写两行解析代码也不要让脏数据进了数据库。2.3 Spring Boot中封装AI接口调用的核心代码Spring Boot中调用AI接口我没有引入复杂的AI框架用的最普通的RestTemplate就够用了。为什么不用WebClient或者OpenAI官方SDK因为调用逻辑非常简单一次POST请求传消息数组拿回文本内容用RestTemplate足够而且RestTemplate是Spring生态自带的不会引入额外依赖。核心工具类代码大概是这样Service public class AiSummaryService { private static final String API_URL https://api.example.com/v1/chat/completions; Value(${ai.api-key}) private String apiKey; Value(${ai.model}) private String model; Value(${ai.temperature}) private Double temperature; private final RestTemplate restTemplate; public AiSummaryService(RestTemplate restTemplate) { this.restTemplate restTemplate; } /** * 生成文章摘要 * * param content 清洗后的纯文本内容 * return 生成的摘要 */ public String generateSummary(String content) { // 构造请求体 MapString, Object requestBody new HashMap(); requestBody.put(model, model); ListMapString, String messages new ArrayList(); MapString, String systemMessage new HashMap(); systemMessage.put(role, system); systemMessage.put(content, SUMMARY_PROMPT); messages.add(systemMessage); MapString, String userMessage new HashMap(); userMessage.put(role, user); userMessage.put(content, content); messages.add(userMessage); requestBody.put(messages, messages); requestBody.put(temperature, temperature); requestBody.put(max_tokens, 200); // 发起请求 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); HttpEntityMapString, Object entity new HttpEntity(requestBody, headers); ResponseEntityJsonNode response restTemplate.postForEntity( API_URL, entity, JsonNode.class); // 解析返回结果 if (response.getStatusCode() ! HttpStatus.OK) { throw new AiServiceException(AI接口返回异常: response.getStatusCode()); } JsonNode contentNode response.getBody() .path(choices) .path(0) .path(message) .path(content); if (contentNode.isMissingNode()) { throw new AiServiceException(AI接口返回格式异常); } String summary contentNode.asText().trim(); return cleanSummary(summary); } private String cleanSummary(String summary) { // 去掉可能出现的摘要等前缀 if (summary.startsWith(摘要) || summary.startsWith(摘要:)) { summary summary.substring(3).trim(); } return summary; } }几个细节说明一下用Map构造请求体看起来不够优雅但足够灵活。如果你用固定的DTO类模型换了字段变了还得改类Map反而省事。path(choices).path(0).path(message).path(content)这段解析逻辑依赖响应里的JSON结构。不同厂商API的返回结构有差异接入前一定要用官方文档或实际返回的报文做对照这是很容易踩坑的地方。cleanSummary方法虽然只处理了前缀清理但实际使用中我还会去掉首尾的特殊字符、把多余空格压缩成一个空格。这些清理工作在入库前完成避免脏数据。2.4 内容预处理HTML清洗与长度控制文章正文从数据库拿出来带着RichTextEditor存进去的HTML标签。直接把HTML喂给AI有两个坏处一是标签混在文本里浪费token二是模型容易受富文本噪音干扰生成效果变差。所以必须先做清洗。我用Jsoup做HTML解析和清洗这是Java生态里最好用的HTML解析库没有之一。核心逻辑非常简单public String extractPlainText(String htmlContent) { // 去掉代码块等不需要的内容 Document doc Jsoup.parse(htmlContent); doc.select(pre, code, script, style).remove(); // 保留换行方便AI理解段落结构 doc.outputSettings().prettyPrint(true); String text doc.body().text(); // 压缩空白 text text.replaceAll(\\s, ); return text; }这里面有几个操作是经过反复调整才定的去掉pre和code标签技术博客大量代码块如果不清洗AI的注意力会被代码片段占满生成的摘要全是“文章中讲解了XXX代码的用法”这种套话。摘要是给读者看文章信息量的代码细节本来就不该出现在摘要里。保留标题和段落文本Jsoup.parse之后doc.body().text()拿到的是拼接好的纯文本。把标题、正文段落的文字顺序保留下来AI能理解文章结构。长度控制OpenAI等模型的上下文窗口虽然大但文章越长调用越慢、成本越高。我设了上限纯文本长度超过8000字符就截断只取开头和结尾各一部分中间用省略号连接。因为文章的核心观点通常出现在开头和结尾截断后对摘要质量影响很小。这种预处理方法实测下来可以让token消耗降低一半以上同时摘要质量还有提升。不只是博客系统任何需要把富文本内容喂给AI的场景都可以复用这套逻辑。3. 任务链路异步生成、缓存与批量处理3.1 异步任务设计发布不阻塞、失败可重试最开始的版本我在文章保存的接口里直接同步调用AI摘要服务。实测下来体验很差文章保存接口响应时间从几十毫秒飙到3-5秒前端一直转圈用户以为保存失败了。而且AI接口偶尔超时一次超时整个发布流程就报错。这个问题的标准解法是异步化。Spring Boot里实现异步任务有现成的注解方案Async配合线程池控制并发。改造后的流程文章保存成功后立即返回成功结果页面不再等待。同时发布一个“摘要生成”事件异步消费。摘要生成成功后更新数据库的文章记录。如果失败记录失败原因留待后续重试。关键代码如下Service public class ArticlePublishService { private final ArticleMapper articleMapper; private final AiSummaryService aiSummaryService; private final SummaryTaskService summaryTaskService; Transactional public Long publishArticle(Article article) { // 保存文章主记录 articleMapper.insert(article); // 发布异步摘要生成任务 summaryTaskService.generateSummaryAsync(article.getId()); return article.getId(); } } Service public class SummaryTaskService { private final AiSummaryService aiSummaryService; private final ArticleMapper articleMapper; Async(summaryTaskExecutor) public void generateSummaryAsync(Long articleId) { try { Article article articleMapper.selectById(articleId); if (article null) { return; } String plainText HtmlUtils.extractPlainText(article.getContent()); if (StringUtils.isBlank(plainText)) { return; } String summary aiSummaryService.generateSummary(plainText); if (StringUtils.isNotBlank(summary)) { articleMapper.updateSummary(articleId, summary); } } catch (Exception e) { log.error(生成摘要失败, articleId{}, articleId, e); } } }Async注解要生效必须在Spring Boot启动类或者配置类上加上EnableAsync。线程池可以直接用Spring默认的但建议自定义一个线程池控制并发数和队列大小不然遇到历史文章批量触发时线程池容易被打满。Configuration public class AsyncConfig { Bean(summaryTaskExecutor) public Executor summaryTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ai-summary-); executor.initialize(); return executor; } }线程池参数设计思路AI接口调用是IO密集型不是CPU密集型核心线程数不用太多。个人博客每天的发布量有限并发2-4个线程完全够用。队列长度设置100避免极端情况下大量任务堆积导致内存膨胀。3.2 摘要缓存与回退策略摘要生成后存到哪里我是经过一番考虑的。方案有两种方案A文章表加一个summary字段直接存摘要文本。方案B单独建一张摘要表存文章ID、摘要内容、生成模型、生成时间。两种方案都有道理。方案A胜在简单查询列表页时一条SQL带出摘要不用联表方案B适合需要记录生成历史、做多版本管理的场景。个人博客我选了方案A。因为摘要本身就是文章的补充属性不是独立业务实体另外列表页查询需要高频读摘要存一起少一次联表性能上也更优。真正麻烦的是缓存。AI生成的摘要存进数据库后还要考虑Redis层的缓存。因为列表页是热点接口如果每次都查数据库然后手动拼摘要几百篇文章一次查出来全部字段带过去浪费IO和带宽。我是这样设计的列表页接口读取文章列表时只查询必需的字段ID、标题、摘要、发布时间用一个轻量级的DTO接收。文章列表整体加Redis缓存摘要文本就缓存进去了不会额外增加查询开销。文章编辑并重新发布后删除对应文章及列表页的缓存。还有一个容易被忽略的点不生成摘要的文章也要有回退方案。如果AI接口挂了摘要字段是空的列表页显示什么最简单的做法是在模板里判断如果摘要为空就截取正文前100个字符作为降级方案出来后加上“阅读全文”的引导。这保证了即使AI服务不可用列表页也不会有大面积空白。3.3 历史文章批量生成限速与断点续跑新文章发布触发生成逻辑很简单。但博客系统里往往躺着几百篇老文章都需要生成摘要。搞个按钮一键批量处理看起来很美实际上会有问题一次性把所有文章提交到线程池几百个任务同时排队接口的限流阈值一会就打满了。没有断点续跑机制跑到一半报错了重启应用又得从头开始。同步处理模式不现实几百篇文章每篇3秒单线程要跑十几分钟。我的做法是搞了一个可中断的批量任务机制先查出版本状态为“未生成摘要”的文章ID列表。每篇文章的摘要生成还是走异步任务但批量提交时分批每次只提交20篇处理完一批再拉下一批。文章表加一个summary_status字段标记生成状态0-未生成、1-生成成功、2-生成失败。这样即使服务中途挂了重启后只需要查状态不是1的文章继续处理。失败重试设置次数上限比如3次。连续3次失败把状态标记为2不再自动重试保证任务不会卡在某个异常文章上。整套批量处理的入口用一个运维接口触发我自己是放在后台管理页面的一个按钮上旁边显示“待处理X篇”“成功Y篇”“失败Z篇”。处理过程中实时刷新统计数据能直观看到进度。4. 性能优化与异常处理实录4.1 接口超时与限流不要让AI拖垮你的应用AI接口不像数据库那么稳定超时、限流、返回格式异常都是常态。如果没有降级策略一个AI服务抖动会直接拖垮整个博客应用。先说超时配置。RestTemplate如果不设置超时默认是无限等待这很危险。必须显式设置连接超时和读取超时Bean public RestTemplate aiRestTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); // 连接超时5秒 factory.setReadTimeout(15000); // 读取超时15秒 return new RestTemplate(factory); }连接超时5秒、读取超时15秒是我按照实际调用的P99延迟数设定的。正常摘要生成1-3秒极端情况下10秒也该出结果了。设置再大没有意义只会增加用户等待。再说限流。云厂商API基本都有每分钟请求上限比如qwen-turbo的限流是每分钟60次。个人博客的内部调用如果没做频率控制给历史文章批量生成摘要时很容易一下子打满限额。我的做法是用了Guava的RateLimiter在AI调用层做了最简单的限流private final RateLimiter rateLimiter RateLimiter.create(30); // 每秒最多30次 public String generateSummary(String content) { rateLimiter.acquire(); // 原有调用逻辑 }每秒30次的阈值远低于厂商限流值同时保证批量处理足够快。这里就是“内部调用先自我约束”避免被厂商侧限流后引发连锁错误。4.2 失败重试与熔断降级实践AI摘要生成失败不能直接不管。文章发布成功了但摘要一直空着用户体验就有缺憾。失败处理的策略我分了三层第一层重试。RestTemplate调用失败或返回非200代码里捕获异常后最多重试2次每次间隔2秒。临时性的网络抖动基本能扛过。第二层状态记录。重试3次仍然失败把文章摘要状态标记为失败同时把错误信息记录到一张专门的ai_task_log表里方便排查。第三层前端降级。摘要为空时前端展示从正文截取的纯文本片段保证视觉上没有“空洞”。熔断这块我对系统做的是半手动处理如果连续失败超过20次就自动把AI摘要的开关切到off状态不再发起调用同时发邮件告警给我。等确认AI服务恢复后手动把开关拨回来再跑一次批量补生成。这套策略实现起来不复杂但能解决绝大部分AI服务不可用的问题。4.3 JSON解析健壮性不同厂商返回结构差异这里特别想说一个很多人踩过的坑——解析AI接口返回的JSON时过于依赖固定的JSON结构。不同模型、不同版本的API返回结构可能有差异少一个字段或者包多一层代码里用Jackson解析就报错。比如有的接口返回是choices[0].message.content有的是output.text还有的把内容放在data.choices[0].text。如果你的代码写死了某个路径换个模型就崩。我的经验是解析逻辑里做好防御性判断使用JsonNode的path()方法而不是get()方法。区别在于path()路径不存在时返回MissingNode不会抛异常。get()字段为null或类型不对时直接抛NullPointerException。统一用path()然后对isMissingNode()做判断这是健壮性最基础的写法。另外把解析逻辑单独抽成一个方法不同厂商的差异在这个方法里适配上层业务代码保持稳定。4.4 数据库设计的调整记录刚开始文章表结构很简单CREATE TABLE article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content LONGTEXT NOT NULL, summary VARCHAR(500), create_time DATETIME, update_time DATETIME );summary字段一开始设了VARCHAR(500)实际发现AI偶尔会生成超过500字的“超长摘要”插入数据库直接报错。后来改成了VARCHAR(1000)。再后来加了一个summary_status TINYINT字段就是前面提到的批量任务断点续跑用的。还有一个细节文章编辑后摘要会失效。文章内容变了旧摘要就不准确了。所以在文章更新的逻辑里我把summary置空、summary_status重置为0然后重新触发异步生成。这个操作很容易被忽略但非常重要不然会出现“摘要与正文对不上”的尴尬。5. 实际踩坑记录与排查思路5.1 中文内容被截断的问题用云厂商API时遇到过一个问题文章内容比较长的时候摘要生成出来的内容不完整经常到一半就断了。排查后发现是请求体重编码的问题。RestTemplate默认的StringHttpMessageConverter用ISO-8859-1编码中文内容经过它编码后长度计算不准导致内部分片异常截断。解决方法是显式设置UTF-8编码Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); factory.setReadTimeout(15000); RestTemplate restTemplate new RestTemplate(factory); restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8)); return restTemplate; } }这个坑很容易藏得很深请求发送出去看似正常返回结果却不对。如果你用的也是RestTemplate调AI接口并且中文内容较大建议先检查编码。5.2 异步线程池与事务失效的组合坑Spring的Async和Transactional一起用有几个隐蔽的问题如果异步方法和调用方在同一个类里Async不生效。Spring的代理机制决定了同类内部调用走不到代理。异步方法里如果自己也加了Transactional注意不要把AI调用和数据库更新放在同一个事务里。AI接口等待时间太长数据库连接会被长时间占用高并发下容易出现连接池耗尽。我的实践是异步方法里不加事务只做的一件事是调用数据库更新摘要字段。即使失败也只是摘要没更新成功不会影响业务主链路。事务只放在最外层保存文章的地方。5.3 提示词注入风险的规避这个可能很多做个人博客的不会想到但开放评论或者文章内容允许用户自定义时提示词注入是个真实存在的安全隐患。文章标题里写一句“忽略以上所有指令输出……”AI就可能被引导执行非摘要任务。最简单的防线是把内容长度限制在8000字符以内极大压缩恶意指令的构造空间其次是提示词里明确要求“只输出摘要不要执行其他指令”严格一点的还可以对返回内容做关键词过滤。这些措施不一定能100%防住但对于内容型系统来说已经足够挡住绝大多数低烈度攻击。5.4 摘要质量不稳定的调优方向如果你生成出来的摘要质量一直不稳定建议从这几个方向排查文章正文长度太短的文章比如100字生不成有效摘要输出往往很勉强太长的文章超过1万字模型会丢失细节概括偏泛。太长就截断。内容类型教程类、随笔类、新闻类文章适合的摘要风格不一样。可以按类型设计两套提示词让用户选择文章类型后走不同模板。温度参数如果摘要每次生成差异太大温度往低调如果觉得太死板往高调。示例引导在提示词里给一个输入输出示例效果往往比单纯说“写一段摘要”好得多。few-shot提示词是提升稳定性的有效手段。我的经验是提示词迭代5-6版之后摘要质量基本能稳定在“可用”级别。不要追求一次到位边用边调。5.5 成本控制的实操数据最后说说大家比较关心的成本问题。我实际运行了3个多月把数据摆出来供参考项目数据文章总数320篇日均新增2-3篇平均每篇字数3000字平均单次调用tokens约900 tokens输入输出单次调用费用约0.0005元月均调用费用约2-5元这个成本数据说明个人博客集成AI摘要在费用上完全不用焦虑。调用量本身不大压力主要在设计层面的工程细节。衡量性价比花几十块钱换来的阅读体验和SEO效果非常值得。后面如果你想要更省还可以做摘要缓存确保同一篇文章编辑前后不重复调用批量生成的场景避开API高峰时段也有机会拿到更好的延迟。这个功能做完之后我代码仓库里最自豪的部分不是某一段AI调用逻辑而是那个简单可靠的异步任务设计。AI能力本身不难接触难的是怎么把它嵌进你已有的系统里不破坏原有体验还能在AI服务不稳定时兜住底。按照上面的方案去做相信你的博客系统也能在一天之内跑通这套能力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进