1. 为什么我要用 Spring AI 做一套岗位分析系统
招聘网站上的岗位描述(JD)看多了会发现一个很现实的问题:同一个岗位名称,在不同公司写出来的技能要求能差出十万八千里。我做后端开发这些年,帮朋友内推、帮团队筛简历、自己也偶尔看看市场行情,每次都要手动去比对几十份 JD,把里面的技术栈、经验年限、学历要求一条条抠出来,效率低得离谱。更麻烦的是,JD 里大量关键信息藏在非结构化的自然语言里,比如“熟悉分布式事务者优先”“有高并发场景落地经验加分”,这种话你用正则去匹配基本等于白干。
所以我一直想做一个东西:输入一批岗位描述,系统自动帮我抽取出结构化的技能标签、经验要求、薪资区间,还能针对某一份具体简历做匹配度分析,告诉我“这个岗位你缺哪几项、匹配度大概多少”。这个需求本质上就是RAG(检索增强生成)+ Tool Calling(工具调用)的典型组合场景——RAG 负责把历史岗位库、技能词典、行业知识检索出来喂给大模型,Tool Calling 负责让模型在需要的时候主动去查数据库、算匹配分、调外部接口。
技术选型上我纠结过一阵。Python 生态里 LangChain、LlamaIndex 做 RAG 确实成熟,但我这套系统最终是要嵌进一个已有的 Spring Boot 管理后台里的,团队全是 Java 技术栈,运维、部署、监控都走的是 Java 那一套。如果为了 RAG 单独拆一个 Python 服务,光是跨语言调用的序列化、超时、链路追踪就够我喝一壶。Spring AI出来之后,我决定赌一把,用纯 Java 把整条链路跑通。事实证明这个选择在工程整合上省了太多事,虽然踩的坑也不少。
这篇文章我会把从零搭建这套系统的完整过程写清楚:环境怎么配、RAG 的检索链路怎么设计、Tool Calling 怎么和业务方法绑定、通义千问怎么接、向量库怎么选、以及我在实际调试中遇到的那些文档里不会写的坑。适合有 Spring Boot 基础、想上手 Spring AI 做真实项目的后端同学,也适合正在评估“到底用 Spring AI 还是 LangChain4j”的技术负责人参考。
2. 整体架构设计与技术选型思路
2.1 系统分层:把 RAG 和 Tool Calling 拆开看
我先把整个系统的骨架画清楚,不然后面写代码容易乱。这套岗位分析系统我分成四层:
- 接入层:一个 REST 接口,接收岗位文本或简历文本,返回结构化分析结果。
- 编排层:Spring AI 的
ChatClient负责和大模型对话,决定这一轮是走 RAG 检索还是走工具调用。 - 能力层:RAG 检索器(向量检索 + 关键词检索)、Tool 工具集(岗位查询、匹配度计算、技能词典查询)。
- 数据层:向量库存岗位描述的 embedding,关系库存结构化的岗位元数据(公司、薪资、发布时间)。
这里有个关键设计决策我要解释一下:为什么 RAG 和 Tool Calling 要同时存在,而不是只用其中一个?
只用 RAG 的话,模型能拿到检索出来的岗位文本,但它没法做精确计算。比如“帮我算一下这份简历和这个岗位的匹配度”,匹配度是需要按权重算出来的,让大模型去心算一个百分比,结果每次都不一样,完全不可靠。只用 Tool Calling 的话,模型能调方法,但它不知道历史岗位库里有哪些相似的岗位可以参考,缺乏上下文。
所以我的分工是:RAG 负责“找相关信息”,Tool Calling 负责“做确定性计算和精确查询”。模型先通过 RAG 拿到一批相似岗位作为参考上下文,然后在需要精确数字的时候调用工具。这个组合在实际跑下来之后,回答的准确率和可解释性都比单用一种高出一大截。
2.2 为什么选 Spring AI 而不是 LangChain4j
热词里有人问“现在到底用 Spring AI 还是 LangChain4j”,我实际两个都试过。LangChain4j 的 API 设计更贴近 LangChain 的思路,链式调用写起来很顺,社区里 RAG 的示例也多。但最后我选 Spring AI,核心原因有三个:
第一,和 Spring Boot 的整合度。Spring AI 的 starter 直接走自动配置,ChatClient、EmbeddingModel、VectorStore都是 Bean,我可以在任何 Service 里@Autowired注入,和现有的事务、缓存、日志体系无缝衔接。LangChain4j 虽然也有 Spring Boot starter,但配置项的灵活度和自动装配的完整度还是差一档。
第二,Tool Calling 的声明式写法。Spring AI 用@Tool注解标注方法,配合@ToolParam描述参数,模型能自动理解每个工具干什么、参数是什么含义。这个在 LangChain4j 里要写更多样板代码。
第三,团队维护成本。Spring 生态的文档、版本管理、升级路径我们团队都熟,出问题好排查。引入一个全新的框架,长期维护的心智负担是要算进去的。
当然 LangChain4j 也有它的优势,比如对某些向量库的支持更早、更全。如果你的项目是纯 AI 服务、不依赖 Spring 生态,LangChain4j 也完全值得考虑。选型这事没有绝对的对错,看你的工程上下文。
2.3 模型选型:通义千问的接入考量
模型我选的是通义千问。原因很实际:国内访问稳定、有免费额度可以先把链路跑通、对中文岗位描述的理解明显比一些英文为主的模型好。Spring AI 对通义千问的支持走的是 OpenAI 兼容协议,也就是说你只要把base-url和api-key配对,用 OpenAI 的客户端就能调。
这里有个细节要注意:通义千问的 OpenAI 兼容接口地址和标准 OpenAI 不一样,配置的时候base-url要指向兼容模式的地址,model名字也要用通义千问自己的模型名(比如qwen-plus、qwen-max)。我第一次配的时候直接抄了 OpenAI 的默认配置,结果一直报 404,排查了半天才发现是地址问题。
Embedding 模型我同样用的通义千问的文本向量模型。这里要提醒一句:对话模型和 embedding 模型是两套独立的配置,别以为配了一个另一个就自动生效了。Spring AI 里ChatModel和EmbeddingModel是两个不同的 Bean,要分别配。
2.4 向量库选型:从内存到持久化
开发阶段我一开始用的是SimpleVectorStore,就是 Spring AI 自带的内存向量库,好处是零依赖、启动就能用,适合先把 RAG 链路跑通。但它有个致命问题:重启就丢数据。每次调试都要重新灌一遍岗位数据,浪费时间。
后来我换成了Redis 作为向量存储。选 Redis 的理由是团队本来就在用,不用额外引入新的中间件,运维成本为零。Spring AI 提供了RedisVectorStore,底层用的是 Redis 的向量检索能力。如果你的数据量特别大(百万级以上),可以考虑 Milvus 或 PGVector,但对我们这种几万条岗位数据的场景,Redis 完全够用。
提示:向量库的维度必须和 embedding 模型输出的维度严格一致。通义千问的 embedding 模型输出维度是固定的,建索引的时候如果维度写错,写入不会报错,但检索结果会完全乱掉,这个坑我踩过。
3. RAG 检索链路的核心细节与实操要点
3.1 文档切分:岗位描述该怎么切
RAG 的第一步是把原始文档切分成合适的块(chunk)。很多人这一步随便切,结果检索质量差得离谱。岗位描述这种文本有它的特殊性:它通常有固定的结构——岗位职责、任职要求、加分项、公司介绍。如果你按固定字数硬切,很可能把“任职要求”从中间切断,导致检索出来的块语义不完整。
我的做法是按语义段落切分,再对超长段落做二次切分。具体来说,先用换行和标题符号把 JD 拆成逻辑段落,每个段落如果超过 500 字,再按句子边界切成更小的块。Spring AI 提供了TokenTextSplitter,可以按 token 数切分,但我更推荐自己写一个基于段落和句子的切分器,因为中文的 token 边界和英文不一样,直接用 token 切分器有时候会把一个完整的技能描述切碎。
切分的时候还要注意块与块之间的重叠(overlap)。我设置的重叠大概是 50 到 100 字。为什么要重叠?因为一个技能要求可能跨两个段落,比如上一段结尾说“熟悉微服务架构”,下一段开头说“有 Spring Cloud 实战经验”,如果不重叠,检索的时候可能只命中其中一段,信息就不完整了。
3.2 元数据设计:让检索能按条件过滤
光有向量检索还不够。实际业务里我经常需要“只看三年以上经验的岗位”或者“只看某个城市的岗位”。这些是结构化条件,向量检索做不了精确过滤。所以我在写入向量库的时候,给每个块都附加了元数据(metadata):
| 元数据字段 | 类型 | 用途 |
|---|---|---|
| company | String | 按公司过滤 |
| city | String | 按城市过滤 |
| experience | Integer | 按经验年限过滤 |
| salary_min | Integer | 按薪资下限过滤 |
| publish_date | String | 按发布时间排序 |
| source_id | String | 回溯原始岗位 |
有了这些元数据,检索的时候就可以做带过滤的向量检索:先按条件筛出一批候选,再在这批候选里做向量相似度排序。这样既保证了语义相关性,又满足了业务上的硬性条件。Spring AI 的SearchRequest支持传过滤表达式,用起来还算顺手。
3.3 检索策略:纯向量检索的瓶颈在哪
一开始我只用了纯向量检索,跑了一段时间发现两个问题。
第一个问题是专有名词召回差。比如用户搜“熟悉 Kafka 消息队列”,向量检索可能把讲“RabbitMQ”的岗位也召回了,因为它们在语义空间里很近。但用户如果明确要 Kafka,那 RabbitMQ 就是不相关。这种精确匹配的需求,向量检索天生不擅长。
第二个问题是短查询效果差。用户输入“Java 后端”这种很短的查询,embedding 出来的向量信息量太少,检索结果发散。
我的解决方案是引入混合检索(Hybrid Search):向量检索 + 关键词检索(BM25 或简单的倒排索引),然后把两路结果做融合排序。融合算法我用的是 RRF(Reciprocal Rank Fusion),就是把两路结果的排名做倒数加权求和。这个算法不需要调参,鲁棒性好,实测下来比单纯加权求和稳定。
提示:混合检索的权重不要拍脑袋定。我的经验是向量检索占 0.6、关键词检索占 0.4 起步,然后根据你的实际查询日志去调。如果用户查询里专有名词多,就提高关键词权重。
3.4 重排序:把最相关的块顶上来
检索出来一批块之后,直接全塞给大模型是有问题的。一是 token 浪费,二是无关内容会干扰模型判断。所以我加了一层重排序(Rerank)。
重排序有两种做法:一种是用专门的重排序模型(比如 cross-encoder),精度高但要多部署一个模型;另一种是用大模型自己打分,简单但慢。我选了个折中方案:先用向量相似度做粗排,取 Top 20,然后用一个轻量的打分逻辑(结合关键词命中数、元数据匹配度)做精排,取 Top 5 喂给模型。
这个 Top K 的选择也有讲究。K 太小,可能漏掉关键信息;K 太大,噪声多还费 token。我实测下来,岗位分析这个场景Top 5 到 Top 8是比较舒服的区间。你可以根据自己数据的密度去试。
4. Tool Calling 的落地实现与关键配置
4.1 工具方法怎么定义才让模型看得懂
Tool Calling 的核心是让模型知道“有哪些工具可用、每个工具干什么、参数怎么传”。Spring AI 用注解的方式声明工具,我写一个岗位查询工具大概是这个结构:
@Component public class JobTools { @Tool(description = "根据技能关键词查询岗位列表,返回匹配的岗位基本信息") public List<JobSummary> searchJobs( @ToolParam(description = "技能关键词,如 Java、Spring Boot") String skill, @ToolParam(description = "最多返回条数,默认 10") int limit) { // 查询逻辑 } @Tool(description = "计算简历与指定岗位的匹配度,返回 0-100 的分数和缺失技能列表") public MatchResult calcMatch( @ToolParam(description = "简历文本") String resume, @ToolParam(description = "岗位 ID") String jobId) { // 匹配计算逻辑 } }这里最关键的是description 的写法。模型完全靠这段描述来决定要不要调这个工具、怎么填参数。描述写得太笼统,模型就不知道该在什么场景用;参数描述不清楚,模型就会传错类型。我的经验是:description 里要写清楚“什么情况下用这个工具”,参数描述里要给出示例值。比如上面skill参数我写了“如 Java、Spring Boot”,模型看到示例就知道该传什么格式。
4.2 工具注册与调用链路
定义好工具之后,要把它注册到ChatClient上。Spring AI 支持在构建ChatClient的时候传入工具对象,也支持在单次请求时动态指定。我推荐按需注册,不要把所有工具一股脑全塞进去。工具太多,模型选择困难,还容易误调。
调用链路是这样的:用户提问 → 模型判断需要调工具 → 返回工具调用请求(包含工具名和参数)→ Spring AI 框架反射调用对应方法 → 把方法返回值作为工具结果回传给模型 → 模型基于结果生成最终回答。
这个链路里有个容易忽略的点:工具方法的返回值会被序列化成文本喂回模型。所以返回值不要太大,也不要有循环引用。我一开始返回了一个包含嵌套对象的复杂结构,序列化出来一大坨 JSON,模型读起来很吃力,回答质量反而下降。后来我把返回值精简成只包含必要字段的扁平结构,效果好多了。
4.3 多轮工具调用的处理
有些复杂问题需要模型连续调多个工具。比如“帮我找几个适合我的岗位”,模型可能先调searchJobs拿到一批岗位,再对每个岗位调calcMatch算匹配度,最后综合排序。这种多轮调用 Spring AI 是支持的,但要注意设置最大调用轮数,否则模型可能陷入循环。
我设置的最大轮数是 5。超过 5 轮还没收敛,说明要么工具设计有问题,要么问题太复杂需要拆解。这时候我会直接返回当前已有的结果,并提示用户问题可能需要更明确的表述。
注意:多轮工具调用会显著增加延迟和 token 消耗。如果你的场景对响应时间敏感,要控制工具的数量和单次调用的复杂度。
4.4 工具调用的安全边界
工具方法本质上是让模型去执行你的代码,这里必须有安全边界。我的做法是:
- 工具方法只做查询和计算,不做写操作。任何会修改数据的操作都不暴露给模型。
- 参数做严格校验。模型传进来的参数不能直接拼 SQL,必须走参数化查询。
- 限制返回数据量。查询类工具强制加 limit,防止模型一次拉出几万条数据把上下文撑爆。
- 超时控制。每个工具方法设置独立的超时,避免某个慢查询拖垮整个请求。
这些边界不是可选项,是必须项。我见过有人直接把一个能删数据的接口暴露成工具,结果模型在调试时真的把测试库清了,这种教训太深刻。
5. 完整实操流程与关键环节实现
5.1 环境准备与依赖配置
先把项目骨架搭起来。我用的是 Spring Boot 3.2 加 Spring AI 的 starter。pom.xml里核心依赖是这几个:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-redis-store-spring-boot-starter</artifactId> </dependency>这里有个版本坑要提醒:Spring AI 的版本和 Spring Boot 版本有对应关系。Spring AI 1.0 之前的版本迭代很快,API 变动大,一定要看清楚你用的版本对应的文档。我一开始用了最新的 milestone 版本,结果发现和教程里的 API 对不上,白白折腾了半天。建议锁定一个稳定版本,别追最新。
配置文件里要配模型和向量库两块:
spring: ai: openai: base-url: https://dashscope.aliyuncs.com/compatible-mode api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.3 embedding: options: model: text-embedding-v2 data: redis: host: localhost port: 6379temperature我设成 0.3,因为岗位分析需要稳定、可复现的结果,不需要模型发挥创造力。如果你做的是创意类应用,可以调高。
5.2 数据灌入:把岗位描述变成向量
数据灌入是 RAG 的地基。我的流程是:读原始岗位数据 → 清洗(去掉 HTML 标签、多余空白)→ 切分 → 生成 embedding → 带元数据写入向量库。
这里有个性能问题要注意:embedding 调用是网络请求,逐条调用会非常慢。几万条数据逐条调,可能要跑几个小时。解决办法是批量调用,通义千问的 embedding 接口支持一次传多条文本,我一次传 25 条,速度提升明显。同时加个线程池并发处理,但并发数别开太大,否则容易触发限流。
灌数据的时候我还加了个去重逻辑。同一份岗位可能被多次抓取,如果不去重,向量库里会有大量重复,检索时全是重复结果。我用岗位的source_id加内容哈希做去重键,写入前先查一下是否已存在。
5.3 检索接口的实现
检索接口我封装成一个 Service,对外暴露一个方法:传入查询文本和过滤条件,返回排序后的相关块。内部流程是:
- 把查询文本生成 embedding。
- 用 embedding 加过滤条件做向量检索,取 Top 20。
- 同时用查询文本做关键词检索,取 Top 20。
- 两路结果做 RRF 融合。
- 精排取 Top 5 返回。
这个流程里,第 2 步和第 3 步可以并行执行,用CompletableFuture组合,能省不少时间。实测下来并行比串行快 40% 左右。
5.4 对话编排:把 RAG 和工具串起来
最后是把检索和工具调用编排进一次对话。我的做法是在ChatClient调用前,先做一次 RAG 检索,把检索结果作为上下文拼进 system prompt,同时把工具注册进去。这样模型既有参考资料,又能调工具。
system prompt 我写得比较明确,告诉模型它的角色是岗位分析助手,参考资料在下面,需要精确计算时用工具。prompt 里我还加了一条约束:“如果参考资料里没有相关信息,不要编造,直接说没有找到”。这条约束很关键,能显著降低幻觉。
整个请求的耗时我做了埋点,拆成检索耗时、模型耗时、工具耗时三段。这样出问题的时候能快速定位是哪一段慢。实测下来,检索大概占 200 到 500 毫秒,模型生成占大头,工具调用看具体逻辑。
6. 常见问题与排查技巧实录
6.1 检索结果不相关怎么办
这是 RAG 最常见的问题。排查思路我总结成一个顺序:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 切分是否合理 | 打印几个 chunk 看内容 | 切得太碎或太长 |
| embedding 是否正常 | 对比相似文本的向量距离 | 模型配错或维度不对 |
| 元数据过滤是否过严 | 去掉过滤条件再试 | 过滤条件把相关结果排除了 |
| 查询是否需要改写 | 手动改写查询再试 | 原始查询太短或太口语 |
| 是否需要混合检索 | 加关键词检索对比 | 纯向量对专有名词召回差 |
我遇到最多的是切分问题。有一次检索结果总是缺一半信息,查了半天发现是切分器把“任职要求”和“加分项”切到了两个块里,检索只命中了一个。后来调整了切分策略,让相关段落尽量在一个块里,问题就解决了。
6.2 工具调用不触发或调错
模型不调工具,通常是这几个原因:工具描述不清楚、参数描述缺失、或者 prompt 里没引导模型去用工具。我的排查方法是打开 Spring AI 的调试日志,看模型返回的原始响应里有没有工具调用请求。如果没有,就是模型没意识到要用工具,回去改 description 和 prompt。
调错工具的情况,一般是两个工具的功能描述太接近。比如我一开始有个“查询岗位”和“搜索岗位”两个工具,模型经常搞混。后来我把它们合并成一个,用参数区分行为,问题就没了。工具数量要克制,功能要正交,这是血泪教训。
6.3 响应慢的优化思路
响应慢一般卡在三个地方:embedding 生成、向量检索、模型生成。优化手段分别是:
- embedding 生成慢:加缓存,相同查询文本直接返回缓存的向量。
- 向量检索慢:检查索引是否建好,元数据过滤是否能走索引。
- 模型生成慢:减少喂进去的上下文长度,或者换更快的模型。
我实测下来,把喂给模型的上下文从 10 个块减到 5 个块,响应时间能快 30% 左右,而且回答质量没有明显下降。上下文不是越多越好,这是个反直觉但很重要的经验。
6.4 几个我踩过的具体坑
第一个坑是中文编码问题。Redis 存向量的时候,如果没配好序列化器,中文元数据会变成乱码,检索时按中文过滤就失效。解决办法是显式配置 String 序列化器。
第二个坑是token 超限。岗位描述加上检索上下文加上工具返回结果,很容易超过模型的上下文窗口。我加了一个 token 计数逻辑,在拼接 prompt 前先估算 token 数,超了就截断或减少检索块数量。
第三个坑是并发下的向量库连接。压测的时候发现向量库连接池不够用,请求排队。后来调大了连接池,并且给检索加了本地缓存,热点查询直接走缓存。
第四个坑是模型返回格式不稳定。我要求模型返回 JSON 格式的结构化结果,但有时候它会多包一层 markdown 代码块,导致解析失败。解决办法是在 prompt 里明确要求“只返回 JSON,不要加任何其他内容”,同时在解析前做一次清洗,把可能的代码块标记去掉。
7. 一些关于扩展方向的个人想法
这套系统跑通之后,我陆续加了一些扩展。比如把技能词典做成一个独立的工具,模型遇到不认识的技能名可以查词典;又比如加了一个“岗位趋势分析”工具,按时间维度统计某个技能的需求变化。这些扩展都是基于同一套 RAG + Tool Calling 的框架,加一个工具方法、注册进去就行,扩展成本很低。
我还试过把 GraphRAG 的思路引进来,用技能之间的关联关系建图,检索的时候沿着图扩展相关技能。这个方向对“帮我找相关技能”这类查询效果不错,但实现复杂度高了不少,数据准备也麻烦。如果你的场景对技能关联要求高,可以往这个方向深挖;如果只是做基础的岗位匹配,普通 RAG 加混合检索就够了,别过度设计。
最后分享一个我在调试期的小技巧:准备一批固定的测试查询和期望结果,每次改完检索策略或 prompt,跑一遍这批测试,看结果有没有变差。RAG 系统很容易出现“改了一个地方,另一个地方变差”的情况,没有回归测试根本发现不了。这批测试用例不用多,二三十条覆盖典型场景就行,但一定要有。