☰
Spring AI实战:RAG+Tool Calling构建岗位分析系统
2026/10/2 5:02:32 网站建设 项目流程

上个月 HR 同事给我丢来 40 份简历和 12 条岗位 JD,让我帮忙做技术画像分析。第一个小时我还在逐条读,第二个小时就开始走神——大部分 JD 的套话高度重复,但真正影响用人决策的技能组合、薪资合理性、市场热度这些信息,全藏在细节里。我当时就一个想法:这种重复劳动必须自动化。

于是我用 Spring AI 从零搭了一套"RAG + Tool Calling 岗位分析系统"。核心能力是:输入一条 JD(文本或 PDF),自动抽取岗位职责、技能要求、经验年限、薪资区间,再让模型从预先整理好的知识库里检索相关内部资料(职级体系、面试评价标准、历史招聘数据),同时通过 Tool Calling 实时查询薪资统计和技能热度,最终生成一份结构化的岗位分析报告。整个过程完全跑在 Java 技术栈里,没有引入 Python 那套生态,项目也顺利接到了已有的 Spring Boot 服务上。

这篇文章不是官方文档翻译,而是我完整复盘的实战记录:包含选型对比、架构设计、RAG 落地细节、Tool Calling 接入方式,以及从 0 跑通到稳定运行之间踩过的坑。如果你是 Java 后端工程师,正在纠结要不要在 Spring Boot 里做 AI 功能,想知道 RAG 到底怎么落地、Tool Calling 会不会把系统搞乱,这篇也许能帮你省掉几周弯路。

1. 技术选型:Spring AI、LangChain4j、LangGraph4j 到底选谁

1.1 我实际做的方案对比

动手之前,我先把市面上的 Java 系 AI 框架列了个清单,候选方案有三个:LangChain4j、LangGraph4j、Spring AI。

先说结论:我最终选了 Spring AI。理由不是说它一定比另外两个强多少,而是它和现有技术栈的融合成本最低。团队里所有人都在写 Spring Boot,Spring AI 由 Spring 官方团队维护,依赖注入、配置体系、starter 机制都是现成的,接入一个老项目基本不改变原有代码结构。这对长期维护和团队协作来说,价值比单纯的功能多少更重要。

LangChain4j 我当时也测过。它的组件设计更像是 Python 版 LangChain 的翻译,如果你有 LangChain 经验,上手很快,工具链也确实丰富。但它的 API 版本迭代比较快,我在做简单对话和多轮工具调用时,遇到过几次依赖行为随版本变化的情况,排查起来比较费劲。

LangGraph4j 是 LangGraph 的 Java 移植,定位偏 Agent 工作流编排,擅长画节点、定边、控制状态流转这种图结构。但岗位分析系统对我来说本质是"一条流水线 + 两个增强手段",不需要那么重的工作流引擎,引入它反而增加认知负担。

1.2 Spring AI 的核心抽象,一张图看懂

Spring AI 的编程模型很简单,核心就是几个接口:

  • ChatModel / ChatClient:负责对话、结构化输出、工具调用,是主要交互入口
  • EmbeddingModel:负责把文本变成向量
  • VectorStore:负责向量存储和相似度检索
  • @Tool 注解:把普通 Bean 方法注册成模型可以调用的工具

这里我最喜欢的一点是:Spring AI 没有把 AI 抽象成什么高深的东西,它就是一套普通 Spring Bean。EmbeddingModel 是 Bean,VectorStore 是 Bean,工具类也是 Bean。这意味着你完全可以用 Spring 已有的配置管理、切面、事件机制去扩展它,而不是被迫接受框架的"黑盒"。

1.3 版本怎么对,先说一个结论

Spring AI 的版本号体系和 Spring Boot 不是一回事。1.0 之前全是 M 系列(比如 1.0.0-M6),API 说变就变。一直到 1.0.0 GA 发布之后才相对稳定。我自己用的是 Spring Boot 3.4.x 搭配 Spring AI 1.0.0 GA,这个组合跑通了整套系统。如果你看到 2.0 系列正在推进,建议等它出首个稳定版再动,生产项目不要追 M 版,这个坑我在第 5 节详细讲。

2. 岗位分析系统的整体设计与数据流转

2.1 系统要解决哪些具体问题

设计之前,我把"岗位分析"拆解成几个子问题:

第一,JD 是非结构化文本,岗位职责和任职要求混在一起,需要抽取成结构化字段。第二,光看 JD 本身信息不够,需要结合内部知识(职级标准、面试评估维度、历史招聘数据)来解读这条 JD 的质量。第三,JD 里的薪资范围和市场真实水平需要外部数据校准,技能需求热度也需要实时数据支撑。第四,最终结果是要给 HR 和业务负责人看的,不能是一堆聊天记录,必须是一份结构化报告。

这四个问题对应的解决方案分别是:结构化输出、RAG、Tool Calling、模板化报告生成。这四件事组合起来,就是整个系统的骨架。

2.2 三条流水线怎么协作

整个系统其实由三条数据流水线组成,它们不是串行关系,而是互相补充:

第一条是抽取流水线。用户上传 JD 后,系统先让模型做结构化抽取,得到岗位名称、职责列表、技能列表、经验要求、学历要求、薪资范围等字段。这一步不依赖知识库,纯粹是模型能力。

第二条是 RAG 流水线。抽取完成后,系统拿"岗位名称 + 技能列表"去向量库里检索,召回内部知识库中与这条 JD 最相关的文档片段,比如职级对应的能力要求、历史类似岗位的评价记录、相关项目的技术背景。召回结果拼进上下文,让模型在生成报告时"有据可依"。

第三条是 Tool Calling 流水线。模型在生成报告的过程中,如果发现需要外部实时数据——比如"这个薪资在市场上处于什么水平""Spring Cloud 现在的需求量如何"——它会调用注册好的工具方法去获取数据。工具返回结果后,模型再继续生成。

三条流水线的最终汇合点是报告生成模块:把抽取结果、知识库召回片段、工具返回数据三部分拼进系统提示词,让模型输出一份包含岗位画像、技能匹配度、薪资竞争力、招聘建议的完整报告。

2.3 模块与技术栈清单

我最终的技术栈是这样:

  • Spring Boot 3.4.x + Spring AI 1.0.0 GA
  • Embedding 模型用的 OpenAI text-embedding-3-small,原因后面细说
  • 聊天模型用的 gpt-4o-mini,成本可控,中文理解够用
  • 向量库先本地跑 SimpleVectorStore,联调稳定后换成了 Redis Vector Store
  • 文档解析用 Spring AI 内置的 TikaDocumentReader,支持 PDF、DOCX、TXT
  • 文件上传和报告下载用 Spring MVC 自带能力,没有额外引入组件

这个组合比较"轻"。如果公司已经有 Milvus 或者 PostgreSQL 插件,也可以把 VectorStore 换成对应的依赖,Spring AI 的接口是统一的,业务代码不用大改。

3. RAG 落地:知识库分块、向量化与召回调参

3.1 知识库从哪来:先解决"没有数据"的问题

做 RAG 最容易犯的错就是先搞模型、再搞向量库,最后发现没有知识可检索。我一开始就确定了知识源:公司的内部岗位说明书、职级晋升标准、历年的面试评价模板、过去两年的招聘 JD 汇总,以及内部技术项目简介文档。合计大概 30 多份文档,虽然量不大,但覆盖了整套系统需要增强的核心场景。

把这些文档规整成统一格式后,我做了个批量入库脚本。入库流程很简单:读文件 → 解析出文本 → 分块 → 向量化 → 写入向量库。这个流程跑一遍大约 10 分钟,之后每次新增文档,只需要重新跑一次脚本就行。

3.2 文档解析与分块策略实测

解析这块,Spring AI 的 TikaDocumentReader 开箱即用,不需要自己处理 PDF 和 DOCX 的格式差异。我直接用它读 FileSystemResource,拿到的 Document 对象里就是正文文本。

分块是关键。我一开始用的 TokenTextSplitter,默认 500 token 一块,overlap 50。跑完测试发现一个问题:中文文本被硬切的情况很常见,一句话被切成两半,检索时召回的内容看起来相关,读起来语义却不完整。比如"要求熟悉分布式系统设计"可能被切成"要求熟悉分布式"和"系统设计"两块,导致召回结果张冠李戴。

后面我把分块策略改成了组合式:先按换行符和句号粗切,保住语义完整的句子块,再对超过 500 token 的超长块用 TokenTextSplitter 二次切分,并加大 overlap 到 100。改完之后,召回结果的完整性明显提升。

3.3 Embedding 模型选型:为什么没用本地模型

中间我也试过本地部署的嵌入模型,理由是避免数据出域。但测试下来效果不理想:本地模型对中文长文本的语义理解明显弱一些,同一个查询在不同文档上的相似度区分度不高,容易召回到一堆相关性很弱的内容。

最终我选了 text-embedding-3-small,维度适中,中文效果在可用范围内,成本也很低。这里有个经验:嵌入模型的选型要拿真实查询去做回归验证,不能只看跑分。我刷了几十条岗位相关查询,对比"期望召回的文档是否出现在 Top5",本地模型只有 60% 左右的命中率,而 text-embedding-3-small 能到 85% 以上,差距非常明显。

如果你对数据安全有硬性要求,必须本地跑,建议至少用 BGE 系列等对中文优化过的模型,并且要在自己的语料上做充分测试,不要默认"能跑通就代表效果好"。

3.4 向量检索调参:TopK、阈值和召回率之间的平衡

Spring AI 的向量检索用法很直接:

SearchRequest request = SearchRequest.builder() .query("Java工程师 任职要求 Spring Cloud") .topK(5) .similarityThreshold(0.6) .build(); List<Document> hits = vectorStore.similaritySearch(request);

这里最需要调的是 topK 和 similarityThreshold 这两个参数。我实测后发现:topK 设 3 太保守,很多相关片段被挤掉;设到 8 又会让上下文里塞进太多无关内容,生成报告时模型容易被噪音带偏。最后定了 5,也就是每次最多取 5 个片段拼接进提示词。这个数量在"信息充足"和"上下文可控"之间比较平衡。

similarityThreshold 的坑更多。一开始我设 0.7,大量查询返回空结果,因为部分知识库文档本来就和 JD 的表述差异很大,余弦相似度天然偏低。后来降到 0.5,又发现召回的片段里混入了不少语义弱相关的内容。最后我统计了一批真实查询的相似度分布,把阈值定在 0.55~0.6 之间,并针对重要字段做了加权查询——比如用岗位名称和技能列表组合成一个更长的查询串,比单用岗位名称召回的准确率高不少。

3.5 向量库选型:从内存库到 Redis 的迁移

开发阶段我用 SimpleVectorStore,它是内存实现,不需要额外部署,非常适合本地验证逻辑。但它有几个问题:数据不持久化,重启就丢;没有并发控制,多个请求同时写入会出问题。联调稳定后我切到了 Redis Vector Store,公司本来就有 Redis 集群,加一个 vector 索引不需要新引入基础设施。

Spring AI 的接口设计在这时候体现出了价值,我把 VectorStore 的 Bean 定义从 SimpleVectorStore 换成 RedisVectorStore,业务代码一行没改。唯一要注意的是 Redis 里索引的维度必须和 Embedding 模型输出维度一致,如果换模型,旧索引要删掉重建,这个细节很容易被忽略。

4. Tool Calling 接入:让模型自己决定"查什么数据"

4.1 哪些场景必须上 Tool Calling,而不是让模型瞎编

岗位分析里有两类数据,模型是不可能"天生就知道"的:一类是实时外部数据,比如某岗位在某城市的当前薪资分位数;另一类是本地业务数据,比如我们内部某个技术方向的岗位需求趋势。如果你不提供工具,模型面对这种问题就会一本正经地编数字,而且编得很流畅,你根本发现不了。

所以我给系统定义了三个工具。第一个是薪资统计工具,输入岗位名称和城市,返回该组合的平均薪资、中位数、P25/P75 分位数。第二个是技能需求工具,输入技能关键词,返回近三个月的岗位需求量和变化趋势。第三个是内部职级匹配工具,输入技能和经验年限,返回公司内部的职级建议和参考能力模型。前两个对接外部数据源,第三个直接查询本地数据库表。

4.2 @Tool 注解定义工具,比想象中简单

Spring AI 的工具调用方式很低侵入,定义一个普通 Spring Bean,在方法上加 @Tool 注解就行:

@Component public class JobAnalysisTools { private final SalaryService salaryService; private final SkillDemandService skillDemandService; private final LevelService levelService; public JobAnalysisTools(SalaryService salaryService, SkillDemandService skillDemandService, LevelService levelService) { this.salaryService = salaryService; this.skillDemandService = skillDemandService; this.levelService = levelService; } @Tool(name = "getSalaryStats", description = "查询指定岗位在指定城市的薪资统计数据,返回平均薪资、中位数、P25/P75分位数,单位为K") public String getSalaryStats(String jobTitle, String city) { return salaryService.queryStats(jobTitle, city).toJson(); } @Tool(name = "getSkillDemand", description = "查询指定技能关键词近三个月的岗位需求量及环比变化趋势") public String getSkillDemand(String skill) { return skillDemandService.queryTrend(skill).toJson(); } @Tool(name = "matchInternalLevel", description = "根据技能列表和工作年限,返回公司内部职级建议与能力模型参考") public String matchInternalLevel(String skills, Integer years) { return levelService.recommend(skills, years).toJson(); } }

注册工具只需要在构建 ChatClient 时带上工具对象:

ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(new JobAnalysisTools()) .build();

之后调用模型时,遇到需要外部数据的问题,模型会在生成回答前自动调用对应方法,方法返回值会被拼进上下文,模型再基于真实数据继续生成。整个过程从外部看就是一次 prompt 调用,内部则是多轮"模型思考 → 调工具 → 拿结果 → 再生成"的循环。

4.3 工具方法的设计约束:返回文本越短越好

这里我想强调一个很多人忽略的点:工具方法的返回值最终是塞进模型上下文的 Token,所以返回内容必须精简结构化。我第一次写薪资工具时返回了一个完整 JSON,包含几十个字段,模型调用一次就吃掉大量 Token,对话稍微一长就触顶。后来我把返回值改成"平均薪资:K, 中位数:K, P25:K, P75:K"这种紧凑格式,效果一样,Token 少了一半多。工具描述也不能含糊,description 写得越清楚,模型按你的意图调用的概率越高。

另外,工具调用是有循环的。模型发现某个数据缺失,可能会连续调用多个工具,甚至同一个工具调用多次。如果不管控,一个长报告生成过程可能会触发十几次工具调用,Token 消耗和耗时都翻倍。我在实际运行时给工具调用循环加了上限,并要求模型优先基于已有数据生成,只有报告确实缺少关键数据时才允许发起新调用。经过这几条约束,单次报告生成的工具调用次数从平均 6 次降到了 3 次以内。

5. 实战踩坑:版本地狱、中文失真与工具调用失控

5.1 第一个坑:Spring AI 版本号与 Spring Boot 不匹配

这个坑几乎是所有入门者必踩的。Spring AI 1.0.0 之前的 M 系列版本,依赖坐标是 spring-ai-openai-spring-boot-starter 这种形式,而 1.0.0 GA 之后统一改成了 spring-ai-starter-model-openai。如果你照着网上老教程抄依赖,很容易启动时报 ClassNotFoundException 或者找不到 Bean 定义。

我的排查过程比较笨但有效:先确认 Spring Boot 版本,再去 Spring AI 官方文档查兼容矩阵,最后照着 GA 版本的快速开始重新生成依赖。另外,spring-ai-bom 一定要加进 dependencyManagement,否则不同 starter 之间的传递依赖版本可能不一致,运行时会出现"NoSuchMethodError"这种最恶心的错误。这里也提醒一句:网上搜 Spring AI 教程,一定要看发布时间,M 系列和 GA 的写法和配置差很多,照着旧文章抄会浪费大量时间。

5.2 第二个坑:中文分块后检索"看似相关实则错位"

这是 RAG 系统里最隐蔽的问题。问题表现是:检索结果里每条文档都和查询词有重叠,但拼进上下文后,模型给出的回答跟 JD 对不上。比如 JD 写"熟悉高并发场景下的系统设计",系统检索到的是"高并发压测方案"相关的内部文档,两者都含"高并发",但一个是设计一个是测试,语义方向完全不同。

根因就是我在 3.2 节说的分块问题。中文没有天然空格,按 Token 硬切很容易破坏语义边界。解决分两步:先在句子边界切分,再对超长块二次切分,同时保留足够的 overlap。另外一个有效手段是让查询串更"完整"——不要只拿岗位名称去检索,要把"岗位名称+核心技能+工作年限要求"组合成一个查询。组合查询的语义更接近知识库文档的表述方式,召回质量明显提升。调整之后,我人工抽查 20 组查询,相关性达标率从 70% 提高到了 90% 左右。

5.3 第三个坑:相似度阈值设错导致空召回或噪音

这个坑我在 3.4 节提过,这里细说排查过程。一开始我把 similarityThreshold 设为 0.7,上线试运行当天就收到反馈"某些 JD 分析报告里完全没有内部知识参考"。日志里一看,向量检索返回空列表。

我当时的怀疑方向有两个:一是 Embedding 模型有问题,二是知识库数据入库不对。排查后都排除了,因为换一条 JD 又能正常召回。后来我把所有真实 JD 的查询相似度打印出来,发现相当一部分合法查询的最高相似度只有 0.5~0.6,说明不是模型问题,是知识库文档和 JD 的表述方式本来就差异大。把阈值降到 0.55 之后空召回消失,同时我在检索后加了一步简单的重排序:在召回的 10 条候选中,按相似度降序取前 5 条,保证进入上下文的都是相对最相关的。这件事的教训是:阈值不能拍脑袋,要用真实数据分布来定。

5.4 第四个坑:多轮工具调用把上下文撑爆

工具调用循环失控是我遇到的另一个实际问题。由于我注册了三个工具,模型在一个长报告生成任务里会不停地想"再查一个数据、再查一个数据"。有一次我查日志发现,一次报告生成过程触发了 8 次工具调用,上下文里堆了大量工具返回结果,最后因为超出模型上下文窗口直接报错。

解决思路分三层:第一,给工具调用增加数量上限,超过限制就终止工具循环并基于现有信息生成;第二,优化工具返回值格式,压缩 Token;第三,系统提示词里明确"仅在数据缺失且影响结论时调用工具",从模型行为的源头减少无谓调用。这里尤其要注意第一点,因为不是所有模型都对工具调用有天然的自控能力,应用层必须兜底。

5.5 第五个坑:结构化 JSON 输出偶发失败

岗位分析报告要求输出固定结构,我最开始直接让模型输出 JSON 文本再自己去解析,结果问题不断:模型偶尔会多输出解释性文字,偶尔把 JSON 包在 Markdown 代码块里,偶尔键名大小写不一致。后来改用 Spring AI 的 BeanOutputConverter,定义好 POJO,用 .entity() 方法让框架去处理转换,成功率大幅提升。

但即使这样也不能完全依赖框架。实测下来,约 2%~3% 的请求仍会解析失败,原因是模型输出内容结构与 POJO 不完全匹配。我的兜底方案是:解析失败时自动重试一次,第二次如果还失败,就降级为用户返回抽取出来的原始字段,而不是直接报错。这个降级策略对生产环境很重要,至少保证核心信息能出来。

5.6 第六个坑:并发环境下 SimpleVectorStore 撑不住

联调阶段我接到一个需求:支持多个 HR 同时上传 JD 分析。本地测试时用 SimpleVectorStore 没问题,但压测发现并发检索和写入时,SimpleVectorStore 的内存操作存在明显的性能瓶颈和偶发数据不一致。这个问题的本质是 SimpleVectorStore 定位是开发和测试场景,不适合生产并发。解决方案也没什么悬念,换成 Redis Vector Store 后,并发从几十 QPS 提上到了几百 QPS,瓶颈从向量库转移到了模型 API 本身。这个坑提醒我:选型时不能只想着本地能跑通,还要考虑部署环境。

6. 效果评估与上线前的收尾工作

6.1 测试集与评估维度

上线前,我整理了 30 条真实 JD 作为测试集,覆盖 Java 后端、前端、产品、测试、运维五个方向,每条 JD 都手动标注了期望抽取的字段和期望召回的内部知识片段。评估维度主要有三个:

第一个是检索命中率,即 RAG 召回的 5 个片段里是否包含至少一个与 JD 真正相关的知识片段。第二个是字段抽取准确率,对比模型抽取的岗位职责、技能列表、薪资范围与人工标注的差异。第三个是报告可用性,我让 HR 同事按"能否直接作为招聘参考"打分,1 到 5 分。

三个维度的权重不一样,检索命中率是基础,字段准确率决定可信度,报告可用性是最终业务价值。

6.2 实测结果与迭代方向

30 条 JD 的实测结果是这样的:检索命中率在阈值调到 0.55 后达到了 93%,剩下 7% 主要是超短 JD,本身信息量太少,模型也没有办法。字段抽取准确率在 90% 左右,最容易出错的是薪资范围,因为部分 JD 写的是"面议",需要靠外部薪资工具补数据才能给出参考值。报告可用性均分 4.2,HR 反馈最多的问题是"报告太长了,能不能直接给个结论段落",后来我在模板里加了结论置顶的调整,反馈明显好转。

这里有个经验值得分享:每次调参或换模型,都拿测试集整体重跑一遍,不要只盯着单独一条 JD 看效果。我自己就经历过"这条调好了,另一条变差了"的情况,只有回归测试能帮你守住整体水平。

6.3 上线前容易忽略的三个细节

第一个是知识库版本管理。内部知识文档是会更新的,文档变更后要重新分块、重新向量化,旧数据要清理。现在每次更新文档我都走固定的入库脚本,向量库里保留文档来源 ID,方便做增量替换。

第二个是模型参数固定。报告生成任务里 temperature 设为 0.1 甚至 0,保证同样输入尽量产生稳定输出。这个参数对"分析型任务"极其重要,如果习惯性用默认值,报告每次生成都不一样,业务方会认为系统不可靠。

第三个是留一条人工复核路径。我在系统里增加了历史报告列表,支持查看原始 JD、召回片段、工具调用日志和最终报告。这一步不是为了展示,而是出了问题能快速定位是检索问题、数据问题还是模型问题。一个月下来,这招帮忙定位了 80% 的线上反馈问题。

最后再分享一个小技巧:日志里一定要记录每次请求的工具调用明细和召回片段 ID,连同一个请求ID串起来。排查问题的时候,你会感谢当时的自己。这套系统从开始搭建到稳定运行用了大约两周时间,其中一半时间花在踩坑和调参上,但跑通之后,原来一个半小时的人工分析变成了一分钟内的自动化输出,这个投入产出比我觉得是值得的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询