
先说一个背景。我之前在团队里经常要做岗位分析但每次拿到一批新的招聘需求文档都要人工逐条拆解技能要求、资历门槛、职责重点几十个岗位下来半天就没了。后来我试过写死规则匹配效果很差因为岗位描述里的表述太灵活了同一件事有几十种说法。最后我决定用 Spring AI 从零搭一套岗位分析系统核心思路就两条用 RAG 把岗位知识库喂给大模型用 Tool Calling 让大模型在分析过程中真正调用代码去算数据、查资料。整套系统从零开始写前后跑了两个月中间踩了不少坑今天把整个落地过程梳理成一篇完整的记录希望能帮到正在考虑用类似方案做文本分析系统的朋友。这套方案适合谁如果你已经在用 Spring Boot对 AI 应用感兴趣但不想脱离 Java 生态或者你正在做招聘、人才分析、文本挖掘相关的项目那这篇文章可以直接当参考。即使你暂时不做岗位分析RAG 和 Tool Calling 的组合套路也能迁移到合同审查、政策问答、竞品分析等场景里。1. 整体架构与设计思路1.1 为什么最后选了 Spring AI 而不是 LangChain4j 或自己拼 SDK说实话一开始我也纠结过。LangChain4j 出现得早社区案例多Python 生态里 LangChain 的名气也大很多教程直接照搬。但我的项目背景是 Spring Boot整个团队都是 Java 工程师如果引入 Python 服务等于要额外维护一套跨语言调用链开发效率和排障成本都会翻倍。Spring AI 的优势在于它是 Spring 官方生态的一部分依赖注入、自动装配、配置管理等机制可以直接复用我们不需要在 Java 和 Python 之间来回折腾。当然Spring AI 也不是没有缺点。版本迭代太快我在开发期间就经历了 0.8.x 到 1.0.0 的跨越很多 API 在版本升级后直接变了网上搜到的旧教程基本没法照着用。所以我的建议是如果你决定用 Spring AI就把官方文档当主要参考Google 搜索结果只能当辅助线索。另外如果项目只做纯聊天机器人LangChain4j 会更顺手一些但一旦涉及工具调用、结构化输出、复杂编排Spring AI 的接口设计反而更贴合 Spring Boot 工程的习惯。1.2 RAG 和 Tool Calling 在这个系统里分别解决什么问题RAG 负责的是“知识供给”。岗位分析系统首先得知道每个岗位 JD 里写了什么但大模型本身并不知道我们手上的岗位数据也不可能把几十份 JD 全部塞进上下文那样既浪费 token又容易让模型抓不住重点。RAG 的思路就是先把岗位文档切成片段向量化后存进向量数据库分析时先根据用户提问做相似度检索把最相关的几个片段连同问题一起交给大模型。这样一来模型既拿到了证据又不用被无关信息干扰。Tool Calling 负责的是“行动能力”。岗位分析不只是读文本还经常要做计算和查证。比如 JD 里写“熟悉 Redis 缓存”但资深岗位可能要求“熟悉分布式缓存高可用方案”那我们就得让模型先判断两个表述是否在一个层次上遇到拿不准的词汇调用外部工具去查技能库或者调用统计函数去算岗位技能出现的频率。没有 Tool Calling模型只能凭直觉作答有了 Tool Calling模型就能自主决定什么时候调用计算、什么时候调用查询再基于返回结果继续分析。1.3 数据流向与整体时序整套系统的调用链路是这样的用户输入一个职位问题比如“帮我分析这份 Java 岗位 JD 的核心技能要求”系统先把问题向量化到向量库里检索相关岗位片段拿到检索结果后组装成系统提示词和用户提示词交给大模型。大模型分析过程中如果发现需要额外信息会返回一个工具调用请求Spring AI 的执行器接住这个请求执行对应的 Java 方法把结果再传回给模型模型最终生成分析结论。这个链路最关键的时序点是工具的“第二轮调用”。第一次调用模型可能只做了一个工具请求拿到结果后还要再调用一次才能给出最终答案。Spring AI 的 ChatClient 可以自动处理多轮工具回调但我们最开始不太熟悉这个机制结果出现了好几轮死循环的问题这个后面会在踩坑部分专门讲。2. 岗位数据准备与 RAG 管道设计2.1 数据来源与清洗岗位 JD 是最难处理的文本之一岗位 JD 看起来是结构化文本其实噪声极大。我收集的数据来自多个渠道有的 JD 是 PDF 导出的有的简历系统里复制出来的甚至还有带表格和图片的。文本里充斥着公司介绍、福利待遇、团队文化这些跟技能分析关系不大的内容。所以第一步不是向量化而是清洗。我做了三件很基础但收益很大的事第一统一编码为 UTF-8去掉各种不可见字符和换行符的混乱组合第二用规则把明显的噪声块剔除比如“公司福利五险一金、弹性工作、下午茶”这类关键词命中的段落直接过滤第三把每个岗位的数据切分成结构化字段比如岗位名称、城市、经验要求、薪资范围、职责描述、任职要求其中任职要求是后续分析的核心。清洗结束后我先做了一轮人工抽检确保没有把关键技术信息误删。清洗这一步千万别偷懒。我有一个同事直接拿着原始 PDF 做切块结果检索出来的片段全是“我们正在寻找一位充满激情的技术伙伴”这类空洞表述。RAG 的效果强烈依赖数据质量模型强大的推理能力弥补不了脏数据带来的信息缺失。2.2 切块策略固定长度切块的问题比想象中大切块是 RAG 流程里最容易被低估的一环。固定长度切块比如每 500 字切一块听起来简单实际用起来问题很大一个完整的技术栈列表可能被切成两半导致模型只看到“Java、Spring”漏掉后面的“MySQL、Kafka”两个完全无关的岗位要求也可能因为字数凑巧被切进同一块造成混淆。我最终采用的是按语义段落切块的策略。岗位 JD 本身有天然的分段边界比如每个要求通常以“1.”“2.”开头或以分号为颗粒度分隔。我先按换行符粗切再把短句合并成大块保证每个块至少包含一个完整的能力描述。块的长度我控制在 300 到 600 字之间既不会因为太短而丢上下文也不会因为太长而引入噪声。切完块之后我给每个块加上元数据比如所属岗位 ID、块序号、来源文件名称这样检索到的块可以轻松回溯到原始岗位。这里有个参数需要解释为什么块之间要有重叠因为过短的边界可能导致检索召回时漏掉关键句子的后半部分。我给相邻块预留了大约 50 字的重叠区域确保跨块的句子在检索时不会断。这个经验来自实际测试对比固定长度无重叠切块命中率大概提升了十几个百分点。2.3 Embedding 选型与向量库本地优先按成本切换Embedding 模型的选择直接影响检索质量。我第一阶段用了 BAAI 的 bge-small-zh-v1.5这个模型对中文支持好维度是 512部署成本低在 8GB 内存的机器上就能跑。后来数据量变大需要更高的区分度我切换到了 text-embedding-v3。这里有一个重要教训不同 embedding 模型产出的向量不能直接混用。我在测试阶段用旧模型存了一批向量后来切换新模型后没有重新向量化结果检索效果非常差因为新旧向量分布差异太大相似度计算完全失灵。换模型一定要重建索引没有例外。向量库我最初用的本地 Chroma因为部署简单直接嵌在 Spring Boot 进程里。但数据量到几万个块之后本地库的检索延迟开始明显上升而且每次发布新版本重新构建索引也很麻烦。后来我把向量库存到了服务端通过 API 调用虽然多了一层网络开销但胜在索引管理方便团队其他人也能共用一套知识库。对于个人项目或团队初期本地向量库完全够用一旦你要做成多租户或持续更新数据尽快迁到独立向量库服务。3. Tool Calling 设计让模型真正具备“动手能力”3.1 先分清哪些任务必须用工具哪些别过度设计设计 Tool Calling 的第一原则是“能不用就不用”。ChatClient 原生就能完成简单的基于上下文的问答强行引入工具只会增加延迟和出错概率。在岗位分析系统里我把任务分成了三层第一层是纯文本理解比如“提取这个岗位的主要职责”直接让模型回答第二层是需要外部知识补全的比如模型遇到一个没见过的技术名词需要查技能库这种设计成工具第三层是需要精确计算的比如统计某类技能在多条 JD 中出现的频率、薪水分位数的计算这种也设计成工具。我总共实现了三个工具技能库查询工具、岗位数量统计工具、薪资处理工具。技能库查询工具用于解决模型知识盲区岗位数量统计工具用于回答“哪些岗位要求微服务经验”这类计数问题薪资处理工具用于把文本薪资范围解析成结构化数字并计算分位数。工具数量控制在三个是有意的设计太多会让模型难以决策太少又解决不了实际问题。3.2 工具描述与参数 Schema 的写法这里写得好不好直接决定调用成败Tool Calling 里最容易翻车的地方不是工具的实现代码而是工具的描述。模型靠描述决定什么时候调用工具描述写得含糊模型就会乱来或者干脆不调用。我用的是 Spring AI 的 Tool 注解每个工具写清楚“功能边界”“参数含义”“返回内容格式”。比如岗位数量统计工具我之前写的描述是“统计岗位数量”结果模型在询问薪资分布时也去调用它因为模型觉得“统计”跟所有数据问题相关。后来我改成“统计满足特定条件的岗位数量条件参数为技能关键词返回结果为符合条件的岗位总数”模型调用准确率立刻上来了。参数 Schema 也要写得明确比如技能关键词参数我标注为“多个关键词用逗号分隔”如果模型传入的是一个列表字符串工具内部再做一次解析两边对齐。这部分的经验是写 Tool 描述时站在模型的角度想问题它只知道你描述的字面意思不会自动理解“统计”和“计算分位数”的区别。3.3 回调与结果注入Spring AI 的自动工具调用流程Spring AI 提供了两种工具调用方式手动接收工具请求再处理和自动执行工具并返回结果。在 ChatClient 里通过.tools(技能库查询工具, 岗位数量统计工具, 薪资处理工具)注册工具后当模型返回工具调用意图时Spring AI 会自动执行对应的 Java 方法并把返回结果作为消息回传给模型继续生成回答。这个机制在单次调用场景下很稳定多轮工具调用时则需要设置最大调用次数防止模型陷入循环。我在实际使用中发现有些模型在工具调用后对“工具结果如何融入最终答案”这件事处理得不够好。比如工具返回了 12 个岗位满足条件但模型生成的回答里写的是“大约 10 个”。解决办法是在工具返回结果里添加一个说明字段比如“返回数据为精确数值请直接引用”引导模型把数值原样使用。这个方法虽然简单但对输出准确率的提升非常明显。4. 环境准备、核心代码与全流程落地4.1 环境与依赖我的开发环境是 JDK 17、Spring Boot 3.3.x使用的 Spring AI 版本是 1.0.0。依赖主要引入 spring-ai-starter-model-openai、spring-ai-starter-vector-store-chroma 和 spring-ai-starter-tool-calling。如果你用本地模型可以换成 spring-ai-starter-model-ollama但要注意工具调用对模型能力要求较高小参数模型很可能不支持函数调用或调用格式不正确建议至少使用支持 fn-call 的模型。一个很重要的配置细节Spring AI 的自动配置默认会为 ChatClient 注册工具但需要开启spring.ai.retry.on-http-codes相关的重试参数。我保持默认但关闭了超时重试因为在工具调用场景下盲目重试会导致工具被重复执行。比如技能库查询工具如果第一次超时重试第二次可能返回重复数据影响统计结果。4.2 核心代码结构解析数据准备、RAG 检索、ChatClient 组装整个项目的核心类分为三层数据准备层、检索增强层、对话执行层。数据准备层负责读取 JD 文档清洗后切块调用 Embedding 模型生成向量存入向量库。检索增强层接收用户问题做向量检索返回相关片段。对话执行层组装 Prompt 和工具调用大模型生成最终答案。看一段实际的检索代码会更直观。使用 VectorStore 的相似性搜索方法我设置了 TopK 为 5即返回 5 个最相关的片段。这个数值不是拍脑袋定的我实测过 TopK 从 1 到 10 的效果TopK 太小关键信息可能遗漏TopK 太大噪声增加导致答案冗长。5 到 6 个片段是比较合适的区间。检索公众号时可以再设置一个相似度阈值低于阈值的片段直接丢弃能有效过滤不相关内容。ChatClient 的组装是另一个关键点。我用 Builder 模式创建了 ChatClient同时指定模型和工具列表。Prompt 模板里先声明“你是岗位分析助手”再定义回答结构技能要求、经验匹配、风险提示、薪资参考。模板中我用占位符{context}传入检索到的岗位片段模型参考片段生成答案。这里有个技巧在模板末尾强调“如果上下文片段中没有相关信息请明确说明”可以大幅降低模型编造的风险。4.3 关键参数设置温度、Token 上限与 TopK大模型参数对输出质量的影响比想象中大。我的配置里温度设置是 0.2。为什么这么低岗位分析是偏客观的文档分析任务我不希望模型自由发挥讲太多无关内容低温度能让输出更确定。在做“技能提取”这类事实性任务时我甚至把温度设为 0。但如果任务是生成岗位分析报告需要模型自己组织语言温度 0.3 到 0.5 会更自然。Token 上限我设为 1024因为岗位分析的回答通常不需要特别长设定上限能控制成本也能倒逼模型精简表达。不过要注意如果分析内容涉及多个岗位的横向对比1024 可能不够这时候我会临时把上限提高到 2048。检索时使用的 TopK 和相似度阈值也要注意调整。阈值我常用 0.45但这个值跟 Embedding 模型相关换模型之后必须重新标定否则会出现召回片段虽然相关但相似度均低于阈值的情况。4.4 一个完整的调用链路示例问题进来之后到底发生了什么我用一个具体例子串一遍完整流程。用户提问“Java 岗位中要求微服务经验的有多少平均薪资大概多少”系统先把这个提问向量化到向量库检索出 6 个相关岗位片段。ChatClient 把这些片段组装进模板连同问题发给大模型。模型看到问题里有两个任务计数和薪资统计于是依次调用两个工具先拿到岗位数量 7再拿到薪资分位数数据。工具返回结果后ChatClient 自动回调模型模型结合工具结果生成最终回答“共有 7 个 Java 岗位明确要求微服务经验薪资中位数为 25K其中 3 个岗位标注了高并发场景下的微服务要求。”在这个调用链路里有一个点特别值得复盘模型先是根据上下文判断需要调用工具然后工具结果被融合进第二轮生成。整个过程的耗时大概在 3 到 5 秒如果工具调用涉及外部 HTTP 请求耗时可能到 8 秒。必要的优化是给工具执行加缓存同一个技能关键词在短时间内的查询结果直接复用。我这里用了一个简单的 ConcurrentHashMap 做 30 秒过期缓存效果很好明显降低了重复查询的频率。5. 踩坑记录与排查心得5.1 类型转换与 JSON 解析工具调用最常见的崩溃点工具调用失败的第一大类原因不是模型笨而是 JSON 解析和类型转换出问题。模型返回的工具参数是 JSON 字符串Spring AI 反序列化时如果遇到类型不匹配比如参数定义为 Integer模型传了字符串 “7”就会抛异常。解决方案有两层第一层在参数设计时尽量避免基本类型多用字符串然后手动解析第二层给所有工具方法加异常兜底返回固定格式的错误信息给模型让它自己调整参数重新发起调用。我印象最深的一个坑是技能库查询工具的参数是 List 类型模型有时候传过来的是一个 JSON 对象而不是数组反序列化直接失败。后来我给参数描述里明确写了“参数为数组格式”同时在工具实现里兼容了单字符串和数组两种格式才彻底解决。总体来说工具参数越简单越好能用字符串就别用复杂对象模型对复杂 Schema 的理解能力还远不够成熟。5.2 Embedding 向量不一致检索结果差到怀疑人生这是我踩过最大的坑。开发初期我用 bge-small-zh 生成了向量后来为了追求检索效果切成 text-embedding-v3但没有重新向量化历史数据。结果就是检索时用新模型给问题生成向量在向量库里却是在跟旧模型生成的向量做相似度计算。由于两个模型的向量维度不同代码直接报了向量维度不匹配我调整维度后不报错了但检索到的内容完全不相关。后来我才意识到不同的 Embedding 模型生成的向量分布差异极大即使维度相同也不能混用。解决方案很简单每次更换 Embedding 模型后全量重建向量库一个块都不能漏。这件事我后来写进了项目文档还加了一个启动时校验的机制系统记录当前使用的 Embedding 模型标识如果与向量库元数据中的标识不一致直接提示重建索引。如果你也在做 RAG尽早加这个校验能省掉很多排查时间。5.3 工具循环调用与上下文膨胀模型卡在死循环里出不来工具调用还有一个隐藏问题模型可能会因为工具结果不明确而重复调用同一个工具。岗位数量统计工具返回结果没有明确说明“这是最终结果”模型就以为还需要补充查询导致多轮工具调用叠加中间消息后上下文快速膨胀最终触发 Token 上限输出被截断或者报错。解决路径是双管齐下。第一工具返回值里加“终态标记”直接说明“该结果为最终查询结果无需再次调用相同工具”第二设置最大工具调用次数为 3超过次数就终止调用让模型基于已有信息生成回答。Spring AI 提供了工具调用次数限制的参数默认值不高但一定要主动配置尤其是在任务比较复杂的时候。我实际调优后发现绝大多数正常分析场景只需要 1 到 2 次工具调用如果超过 3 次大概率是工具设计出了问题需要回头检查描述或参数。5.4 实测排查清单如果你也遇到了同样的问题我把排查过程整理成一张速查表按问题现象定位原因效率会高很多。问题现象可能原因排查方向检索结果与问题完全不相关Embedding 模型不一致或向量库未重建检查向量库元数据的模型标识重建索引模型回答中大量编造技能要求检索片段太少或阈值过低提高 TopK提高相似度阈值检查切块质量工具一直没被调用工具描述不清晰或模型不支持工具调用改写工具描述换支持函数调用的模型工具调用报参数解析异常JSON 格式或类型不匹配简化参数类型增加异常兜底回答内容过长且抓不住重点Token 上限过高或 Prompt 引导不足降低输出上限在模板中明确回答结构多次调用同一工具导致超时工具返回结果未标明终态返回值加终态说明设置最大工具调用次数低版本模型工具调用格式错误模型对 fn-call 支持不全升级模型版本或切换其他能力更强的模型这张表是我在排障过程中逐步积累出来的。每次遇到问题先定位到环节再追溯参数和数据不盲目调模型。整套系统迭代到后期新增功能基本不怎么会踩新的坑了因为大部分风险已经在前面的版本里暴露过一遍。回到最初的目标这套岗位分析系统目前已经稳定运行了一段时间每次拿到新的岗位数据清洗后入库就能直接回答问题。实际用下来效率提升确实明显人工分析一个岗位可能需要五到十分钟系统基本在几秒内能给出结构化结论而且技能提取的准确率经过多轮调参后也能达到八九成的水平剩下的偏差主要来自语义模糊的岗位描述。我一直觉得RAG 和 Tool Calling 不是孤立的技术它们是一个系统里互补的两半RAG 决定模型知道什么Tool Calling 决定模型能做什么。把这两件事从零搭起来绕不开文档处理、Embedding 选型、向量库管理、工具描述设计这些细碎工作没有太多捷径但每一块踩过去之后都会有实实在在的收获。这套组合方案也完全可以复用到其他文本分析场景——我做岗位分析你做简历筛选、合同合规检查或者政策解读底层的架构思路是一样的。