岗位分析这个需求是今年年初我在推进人力资源数字化平台时接到的。业务方给的需求其实很朴素“你帮我写一份岗位分析报告把职位职责和任职要求拆清楚对标公司历史上相似岗位的 JD再查一下目标城市的市场薪资区间和今年的供需热度。”听起来不复杂但拆开看它同时要处理三类输入历史知识库里的非结构化文档、薪酬系统里的实时数据、以及最终要生成的结构化报告。我当时的第一个念头是拿 LangChain 快速验证但环顾团队现状从基础设施到 CI/CD 全是 Java没人愿意为一个问答模块单独维护一套 Python 服务。于是我把目光锁定在 Spring AI 上用 RAG 解决知识检索用 Tool Calling 打通内部数据接口最后做成了一套能直接交付给业务运营的岗位分析问答系统。这篇文章记录了我从选型到上线两个多月的完整过程包括 ETL 管道、工具注册、Agent 链路组装以及很多文档里查不到的踩坑经验适合准备用 Spring AI 做知识库问答或 Agent 应用的后端工程师参考。1. 业务拆解与选型判断我为什么押注 Spring AI1.1 先从三类问题倒推技术架构在做选型之前我先拿真实业务场景给系统划了边界。岗位分析要处理的用户提问归纳下来就是三类问题类型典型问法需要的能力知识问答“公司近三年招过哪些数据产品经理职责有什么共性”对历史 JD 知识库做检索增强生成RAG数据问答“北京 Java 开发工程师薪资 p75 是多少”调用薪酬系统接口也就是 Tool Calling综合问答“在成都新建后端团队结合历史 JD 和当地薪资给出岗位画像与定薪建议”既要检索知识库也要调工具还要生成报告这个分类直接影响技术选型。当时我在三条 Java 生态路线里纠结LangChain4j、LangGraph4j、Spring AI。LangChain4j 对 Python 版 LangChain 的 API 复刻程度高社区讨论也很多LangGraph4j 则把图状态机的 Agent 编排思路带到了 Java 端适合流程分支复杂的场景。但我最后选择 Spring AI核心原因有三点。一是与 Spring Boot 的整合深度。我们所有服务都是 Spring Boot 微服务Spring AI 的 starter 自动装配了 ChatClient、EmbeddingModel、VectorStore接入过程基本是写配置不用自己管理各种客户端生命周期。二是工具调用的声明式写法贴合后端习惯。直接在一个普通 Spring Bean 的方法上加Tool注解框架会生成 JSON Schema 交给模型这对长期写 Spring 的团队几乎没有学习成本。LangChain4j 也有类似能力但 Spring AI 和 Spring 生态的配合更顺。三是团队风险考量。对我们的内部系统场景来说Spring AI 背后有官方团队做版本维护长期演进相对可控社区里“现在到底用 Spring AI 还是 LangGraph4j”这类讨论很多但结论往往取决于你的团队背景。Java 团队尤其已经在维护多个 Spring Boot 服务的团队选 Spring AI 的迁移成本是最低的。1.2 本地优先策略数据不出内网这个硬约束这系统处理的岗位 JD、薪酬数据都是敏感信息方案上有条红线原始数据不能上传公有云模型。这条红线直接决定了模型选型范围。网上大量“Ollama 简易本地 RAG 知识库零基础可复制教程”用的就是这套思路我的方案本质上是它的结构化升级版。最终定下来的模型栈是本地 LLMqwen2.5:7b-instruct跑在内网 GPU 服务器通过 Ollama 暴露接口Embedding 模型bge-m3中文语义表征比较稳1024 维向量库PostgreSQL 16 pgvector因为项目里已经有 PostgreSQL 实例不想再引入独立向量数据库组件重排模型bge-reranker-v2-m3放在检索之后、LLM 之前做候选精排。这里多说一句选型意识Spring AI 的 starter 把模型提供方抽象成了统一 API意味着以后合规允许用云上模型时可以直接替换配置而不用改业务代码。我后来也看过 Spring AI Alibaba 对 DashScope 的集成方案走的就是类似路径。选型阶段多保留这一层抽象将来能省很多事。2. 知识库搭建从 3000 份 JD 到可检索的向量库2.1 数据源盘点与解析链路设计先把数据源盘清楚。历史 JD 主要来自两个渠道HR 系统导出的 Word/PDF 文档以及内部任职资格体系的网页总共有 3000 多份岗位 JD外加一批薪酬调研 PDF。有些旧文档是扫描件需要 OCR文本质量差、结构化程度低我决定首期不纳入知识库只把文本型 PDF 和 Word 纳入。解析链路用的是 Spring AI 的 DocumentReader 抽象底层是 Apache Tika。核心代码其实很简洁Component public class JdIndexingService { private final VectorStore vectorStore; private final DocumentTransformer splitter; public void indexJd(Path filePath) { DocumentReader reader new TikaDocumentReader(Resource.fromFile(filePath.toFile())); ListDocument docs reader.get(); ListDocument chunks splitter.apply(docs); vectorStore.add(chunks); } }链路是解析 → 清洗去掉页眉页脚、表格噪点→ 切分 → Embedding → 入库。清洗这个步骤容易被新手忽略其实很重要。Tika 导出的纯文本经常混入重复表头、域里奇怪的回车符不处理的话这些噪声会被一起向量化稀释真正有效的语义。顺带回应一个网上常问的问题“RAG 知识库能存图片吗”如果你要索引截图类的 JD可行做法是先用视觉模型把图片抽取成结构化文本再入库而不是直接把图片向量化扔进库里。我们第一期用不到就不展开做了。2.2 中文章节切分被默认切分器坑了一晚上这里是第一个大坑。Spring AI 默认的 TokenTextSplitter 按 token 数切分它对英文估算比较准对中文文本会出现 chunk 长度虚高、语义块被拦腰切断的情况。岗位 JD 有固定结构通常包含“职位职责、任职要求、汇报关系、转岗条件”几个段落默认切分很容易把“任职要求”的后半段和“职责描述”的前半段切进同一个 chunk检索时相关性被严重稀释。我最后放弃了纯 token 切分改成了“结构感知切分”先用规则把 JD 按固定小节标题切成多个段段落超过阈值比如 800 字符时再按句子边界二次切分每段额外保留documentId、section、positionType等元数据回答可以溯源到原文位置。具体切分参数我做过一组实验用的内部 50 条真实业务问题测试集切分策略top-5 命中率回答完整性默认 TokenTextSplitter1200 token / 200 overlap61%经常缺关键任职要求800 字符按句切分overlap 10074%大部分完整结构感知切分 长段二次切分88%完整回答完整性是人工打分看答案是否覆盖职责、要求、等级三个必答维度。实验结果很明显结构化文档一定要先感知结构再切分盲目套固定 token 窗口是 RAG 项目最常见的瓶颈之一。2.3 Embedding 入库与 PgVector 配置入库流程本身不复杂配置在 application.yml 里把 Ollama 地址和 embedding 模型指好就行spring: ai: ollama: base-url: http://10.20.30.40:11434 embedding: options: model: bge-m3然后初始化向量表用的是 PgVectorStoreBean public VectorStore vectorStore(EmbeddingModel embeddingModel, DataSource dataSource) { PgVectorStore.PgVectorIndexConfig indexConfig PgVectorStore.PgVectorIndexConfig.builder() .withIndexType(PgVectorStore.PgVectorIndexType.HNSW) .withDistanceType(PgVectorStore.PgVectorDistanceType.COSINE_DISTANCE) .build(); return new PgVectorStore(dataSource, embeddingModel, 1024, indexConfig, true); }有两个细节值得说。第一HNSW 比 IVFFlat 更省心。IVFFlat 需要先建索引再大批量写入还要选好 lists 参数否则召回率波动很大HNSW 的图构建阶段就能达到很高的搜索质量。3000 份 JD 切成两万多个块这个量级 HNSW 完全够用。第二初始化 schema 参数要开填true表示表不存在时自动建表本地开发很省事但生产环境我会先手动执行建表脚本避免应用启动时拿高权限 DDL。Embedding 的耗时也评估过bge-m3 在 Ollama 上大概每秒能处理 4060 个短文本块两万个块大约 810 分钟全部入库。增量更新时按 documentId 先删后插即可不用全量重建。3. Tool Calling 设计让模型真正“会查数”3.1 从业务需求反推工具目录Tool Calling 的核心不是堆工具而是业务需要什么就做什么别让模型猜。我最后只保留了三个工具工具名能力入参返回querySalaryRange查岗位薪资分位position, city, yearsp25/p50/p75 及样本量querySupplyDemand查供需热度position, city, period供需比、投递量、招聘量computeSkillMatch技能匹配度评估jdText, cvText技能覆盖率与缺口清单所有工具返回统一成 JSON 字符串方便模型读取也方便调试日志里定位问题。3.2 Tool 声明式注册写法和背后的机制Spring AI 对工具调用的支持建立在Tool注解上写法对后端来说非常自然Component public class SalaryTool { private final SalaryApiClient salaryApiClient; public SalaryTool(SalaryApiClient salaryApiClient) { this.salaryApiClient salaryApiClient; } Tool(description 查询指定岗位、城市、经验年限的市场薪资分位返回 p25/p50/p75 和样本量) public String querySalaryRange( ToolParam(description 岗位名称例如Java开发工程师) String position, ToolParam(description 城市例如北京) String city, ToolParam(description 工作年限整数最少年限) Integer years) { SalaryStat stat salaryApiClient.fetch(position, city, years); return {position:%s,city:%s,p25:%d,p50:%d,p75:%d,samples:%d} .formatted(position, city, stat.p25(), stat.p50(), stat.p75(), stat.samples()); } }三个注意点方法返回类型统一用 String。Spring AI 的工具执行器最终要把结果拼进后续模型的上下文返回 JSON 字符串比返回对象更可控也不会把内部字段意外暴露给模型。工具描述要像写给同事看一样具体。模型选择工具完全靠 description 和参数描述写“查询薪资”这种废话等于没写。我会把城市可选值、年限整数化规则、返回字段含义都写清楚。注册时把工具 Bean 传给 ChatClient就行Bean ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore, SalaryTool salaryTool, MarketTool marketTool, SkillMatchTool skillMatchTool) { return builder .defaultSystem(loadSystemPrompt()) .defaultTools(salaryTool, marketTool, skillMatchTool) .defaultAdvisors( QuestionAnswerAdvisor.builder() .vectorStore(vectorStore) .topK(5) .build() ) .build(); }对话记忆我这里用了窗口型 ChatMemory开 12 轮够用也能控制 token 消耗。3.3 工具调用的容错与超时治理工具调用多轮之后最容易出现三个问题模型不调工具、模型反复调工具、工具结果太长把上下文塞爆。我的对策强制触发。系统提示词里明确写“涉及薪资、供需、技能匹配时必须调用对应工具后再回答如果拿不到数据直接告诉用户需要查询”再配两三个 few-shot 示例。轮次上限。整体 Agent 调用轮次限制在 3 轮超了直接截断返回已有内容。我真实遇到过模型在“查薪资 → 查供需 → 再次查薪资”之间打转的情况轮次上限能有效止损。结果裁剪。每个工具结果最多 2000 字符。computeSkillMatch 返回的技能清单很长统一在工具内部先汇总成“覆盖率 前 5 个缺口技能”再交给模型。这些机制 Spring AI 没有现成标配我在工具外层包了一个ToolExecutionInterceptor统一记录入参、出参、耗时。一旦检测到同一会话内工具被调用超过 3 次就降级为“仅知识库回答”避免死循环烧钱烧时间。4. RAG 与 Tool Calling 的协作Agent 链路是怎么组装出来的4.1 意图路由先判断用户要“查库”还是“查数”如果把 RAG 和 Tool Calling 绑定在一起每次问答都先检索再调工具很多问题会被绕远路。问“公司历史上有没有大数据岗”根本不需要查薪资工具问“北京 Java p75”也不需要先搜知识库。所以系统要有一个意图路由。我的实现分两层。第一层是规则关键词命中“薪资 / p50 / p75 / 分位 / 供需 / 投递量”等词走数据意图命中“历史 / 职责 / 任职要求 / 相似岗位 / 对标”等词走知识意图。第二层是模型兜底规则判定不出来或者同时命中两类词时把意图分类包装成一个工具交给模型决策让模型自己选择是否调用工具、是否检索知识库。这其实就是社区里说的 Agentic RAG 的一种落地形态检索动作不是每次都执行而是由 Agent 按需决策。纯 RAG 和纯 Tool Calling 都是单条腿走路路线组合起来才能覆盖真实世界的混搭问题。4.2 提示词编排与上下文压缩最终跑的 System Prompt 简化后是这样你是公司的岗位分析专家。回答问题时遵循以下规则 1. 回答岗位历史相关信息时必须基于提供的知识库资料并在引用后标注资料编号。 2. 涉及市场薪资、供需热度、技能匹配时必须先调用对应工具禁止编造数字。 3. 生成岗位报告时用二级标题组织并用表格呈现对比类数据。 4. 如果检索结果与问题不相关明确说“未检索到相关信息”不要强行作答。上下文压缩是最容易被忽略的环节。向量检索默认 topK5每个 chunk 最长 1200 token再加上历史对话和工具结果很容易把模型上下文撑爆。我做了两级压缩检索后重排。先用混合检索召回 top 20 候选再用 bge-reranker-v2-m3 精排只保留 top 5 送入提示词。chunk 精简。每个 chunk 只保留“职责描述、任职要求、等级标准”三个字段丢到页眉页脚、目录和签名噪声。4.3 实测效果与延迟数据系统跑起来之后我用真实业务问题做了一轮回归测试问题路由链路关键输出总耗时公司历史上招过哪些高级测试工程师知识RAG列出 5 条 JD 并归纳职责共性4.5s北京前端工程师薪资 p75 是多少数据Tool给出金额、样本量、数据时间6.2s在成都建 Java 团队给出岗位画像与定薪带区间混合RAG Tool 生成报告岗位画像 历史对标 定薪建议13.8s混合类问题是最出彩的。模型先检索出公司历史 JD再调用薪资工具拿到成都 p25/p50/p75最后把两者交叉呈现在报告里给出“该岗位在成都的开价相对北京低 15%20%”这种业务直接可用的结论。从提问到最终 Markdown 报告稳定在 15 秒内业务侧可以接受。5. 踩坑实录文档里查不到的麻烦事5.1 中文切分与“知识割裂”的连带反应默认切分除了丢语义块还造成了一种“知识割裂”现象同一份 JD 的职责块和要求块被分到不同 chunk模型回答时只能引用其中一块经常丢信息。结构感知切分上线后“JD 中某个岗位的职责和要求分别是什么”这类问题的完整率直接从 61% 升到 88%。后来我还考虑过给岗位体系建一个简单的本体Ontology把岗位族、职级、技能项之间的关系显式建起来类似 llm-wiki 的做法进一步缓解单一知识库的知识割裂。这个方向我列进了二期计划但首期用结构化切分已经解决了 80% 的问题。如果你也遇到“内容存在但回答不完整”的情况先别急着换模型检查一下你的 chunk 是不是把一个完整语义块切成了两半。5.2 召回率上不去从纯向量到混合检索的完整排查这是我在“RAG hit rate”上耗费最久的一个坑。现象用户问“高并发场景下的架构设计能力”知识库里明明有包含“缓存、性能优化、分布式架构”的 JD但 top-5 就是召不回来。排查过程我完整捋一遍你以后可以直接按这个顺序来先直接打印余弦相似度发现最高只有 0.62和预期的 0.75 差得远怀疑 bge-m3 对抽象表达捕捉不够换 nomic-embed-text 试结果更差调大 topK 到 20能搜到了但大量无关结果混进来尝试把领域关键词“并发”“性能”“高可用”做成同义词扩展收益有限最终方案是 Postgres 全文检索 向量检索的 RRF 融合top-5 命中率直接升到 92%。融合代码的思路是这样public ListDocument hybridSearch(String query, int topN) { // 1. 向量检索 ListDocument vectorDocs vectorStore.similaritySearch( SearchRequest.query(query).withTopK(topN * 4)); // 2. 全文检索同一张表里用 ts_rank 排序 ListDocument ftsDocs positionRepository.fullTextSearch(query, topN * 4); // 3. RRF 融合对两种排序位次的倒数求和 MapString, Double scoreMap new HashMap(); mergeWithRrf(scoreMap, vectorDocs, 1); mergeWithRrf(scoreMap, ftsDocs, 2); // 4. 按融合分排序截断返回 topN }RRF 的核心公式是score 1 / (k rank)k 取 60。它不要求两种检索的分数在一个量纲只依赖排序位次融合非常稳也不需要调权重。另一个教训是不要对相似度设阈值。我一开始把余弦相似度阈值设在 0.75 以上结果大量合法结果被过滤掉。后来改成“排序取 top 不设阈值”再用重排模型把关实际效果明显更好。5.3 模型不按规矩调用工具的根治过程上线后第一个被业务吐槽的场景用户问“深圳产品经理薪资多少”模型直接回答“根据市场数据大约 25k—35k”——根本没调用工具。查日志发现对话历史里出现过类似薪资数字模型直接凭记忆作答。我从三个方向同时入手才根治系统提示词加硬约束“没有调用工具得到的数据都视为猜测禁止输出猜测数字”加 few-shot让模型看到“查询发起 → 工具返回 → 基于返回作答”的完整样例工具层加校验如果用户问题包含“薪资”但本次会话没有任何工具调用记录就返回特殊提示词要求模型重新发起工具调用。另外还有参数幻觉问题。模型可能把“5 年以上经验优先”解析成years5或years6。我们的解法是把ToolParam的描述写成“经验年限取整数按最少年限计算”同时在工具内部再做一次归一化比如“8年以上”统一转成 8。5.4 本地模型的延迟与持续运行调优7B 模型虽然能跑但参数调不对延迟容易翻倍。几个实用的 Ollama 调优点keep_alive 必须设置。默认模型空闲 5 分钟后会释放显存下次请求冷启动要等 20 秒以上。我设置了常驻线上体验好很多。num_ctx 要够大。RAG 场景下检索块 工具结果 对话历史很容易超过模型默认上下文。我把 2K 提到 8K回答质量提升明显但显存和延迟也会升需要权衡。并发数控制。OLLAMA_NUM_PARALLEL调成 2 后排队时间可接受也不至于把 GPU 显存撑爆。Ollama 前面加代理层。公司内网如果有 API 网关建议在前面加超时和限流防止突发查询把推理服务打挂。5.5 Spring AI 版本升级的破坏性变更我在项目过程中经历过一次 Spring AI 大版本升级破坏性变更集中在三块Starter 坐标变化。不同版本的 artifactId 前缀和命名不一致升级时依赖坐标要整体对齐Tool 注解包名与参数注解变化。旧版本 Function Calling 用FunctionCallbackWrapper新版本推荐Tool注解包路径也变了ChatClient API 演进。新版本统一了prompt().call()和prompt().stream()的入口返回类型也调整过。我的建议是不要在生产项目上盲目追新。选定一个稳定 GA 版本后锁住版本号升级前先跑一遍回归测试套件。我维护了一个 20 条核心问题的冒烟测试集每次升级只跑它很快就能发现哪些链路行为变了。整个项目做下来我最深的两点体会一是 RAG 项目的瓶颈往往不在模型而在文档处理和检索链路尤其是中文场景切分和混合检索值得你花最多的时间二是 Tool Calling 真正难的不是把方法暴露成工具而是让模型在正确的时候调用正确的工具这需要提示词、参数描述和容错机制共同作用。如果让我重来一遍我会在项目第一天就把那 20 条冒烟测试集写好它帮我省掉的时间绝对超过写它的时间。最后再分享一个小技巧上线前把所有工具调用日志单独抽到一个日志文件出问题时 grep 一下就能看到模型到底调用了哪些工具、传了什么参数排查速度成倍提升。
阅读完成 · 觉得有帮助?