ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring AI 实战:RAG + Tool Calling 构建岗位分析系统

Spring AI 实战:RAG + Tool Calling 构建岗位分析系统 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元数据字段类型用途companyString按公司过滤cityString按城市过滤experienceInteger按经验年限过滤salary_minInteger按薪资下限过滤publish_dateString按发布时间排序source_idString回溯原始岗位有了这些元数据检索的时候就可以做带过滤的向量检索先按条件筛出一批候选再在这批候选里做向量相似度排序。这样既保证了语义相关性又满足了业务上的硬性条件。Spring AI 的SearchRequest支持传过滤表达式用起来还算顺手。3.3 检索策略纯向量检索的瓶颈在哪一开始我只用了纯向量检索跑了一段时间发现两个问题。第一个问题是专有名词召回差。比如用户搜“熟悉 Kafka 消息队列”向量检索可能把讲“RabbitMQ”的岗位也召回了因为它们在语义空间里很近。但用户如果明确要 Kafka那 RabbitMQ 就是不相关。这种精确匹配的需求向量检索天生不擅长。第二个问题是短查询效果差。用户输入“Java 后端”这种很短的查询embedding 出来的向量信息量太少检索结果发散。我的解决方案是引入混合检索Hybrid Search向量检索 关键词检索BM25 或简单的倒排索引然后把两路结果做融合排序。融合算法我用的是 RRFReciprocal 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 ListJobSummary 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-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 系统很容易出现“改了一个地方另一个地方变差”的情况没有回归测试根本发现不了。这批测试用例不用多二三十条覆盖典型场景就行但一定要有。
返回列表