资讯详情

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

📅 2026/10/3 18:58:39 | 华诺云谱 👁 阅读
Spring AI实战:RAG+Tool Calling搭建岗位分析系统
先把结论放前面这个系统我用 Spring AI 2.0.1 从零搭完跑通了“岗位知识库 RAG 检索 招聘工具 Tool Calling 联动”的完整闭环期间踩的坑比写业务代码用的时间还多。下面这些记录没有经过美颜处理都是实测下来真实有效的方案和排查过程适合正准备用 Java 技术栈做大模型应用、又不想被 Python 全家桶绑死的团队参考。做岗位分析系统之前我其实先用 Dify 快速搭过一版原型验证了“AI 读 JD、匹配简历、给面试题”这个思路是成立的。但一旦要接进公司现有 Spring Boot 招聘后台问题就来了工作流节点和 HTTP 插件在原型阶段好使真上生产就面临鉴权、数据源打通、日志链路、参数校验等一堆琐碎问题。后来换成 Spring AI 重写等于把 Dify 可视化编排里那套“知识库检索 工具调用”的底层逻辑用 Java 代码重新表达了一遍。RAG 负责让模型知道“这个岗位通常长什么样、公司内部对职级和技能的定义是什么”Tool Calling 负责让模型真正“动手”去查候选人库、查岗位库、算匹配分。1. 项目背景与整体设计思路1.1 这个岗位分析系统到底要解决什么问题业务场景不复杂但工作量非常机械化。招聘团队每天要处理大量岗位描述先人工拆出职责、硬性技能、软性技能、经验年限再拿着这些关键词去简历库里筛人最后还要给候选人写匹配理由、列面试问题。重复劳动多且不同 HR 拆解口径不一致同一个岗位在不同人手里可能得出两套差异很大的标准。系统目标是做一个“岗位分析助手”输入岗位 ID 或者一段 JD 文本自动完成四件事抽取岗位关键信息包括职责列表、技能标签、年限要求、学历要求。从候选人数据库里检索合适的人而不是让模型凭记忆编候选人。计算候选人与岗位的匹配度得分得分要有依据能追溯到具体的技能点。生成针对性的面试题以及每条面试题考察的能力项。这四件事光靠“聊天”做不了。前两件事需要知识库支撑第二件和第三件需要实时查库和计算第四件需要结合前几步结果。把 RAG 和 Tool Calling 组合起来正好各管一摊。1.2 为什么没选 LangChain 而是选 Spring AI团队现状是后端清一色 JavaSpring Boot 基础设施已经跑了六七年有现成的数据源、缓存、权限体系和部署流水线。如果为了大模型应用单独引入 Python 微服务就要额外维护一套运行环境、监控体系和代码仓库对一个小团队来说负担很重。LangChain 在 Python 生态里确实日臻成熟但 Java 团队维护成本偏高LangChain4j 也调研过社区活跃度还不错不过我们更倾向与 Spring 官方生态深度绑定的方案。Spring AI 虽然版本迭代快API 变动频繁但它的优势是原生融入 Spring Boot自动配置、Bean 管理、属性绑定、Starter 机制都用得上写出来的代码和其他业务模块没有割裂感。另一个重要原因是模型供应商接入方式统一。不管接百炼、OpenAI、Ollama 还是其他兼容服务Spring AI 在 ChatModel、EmbeddingModel、VectorStore 这些抽象层上做了统一封装。换模型供应商时多数情况下只改配置和依赖坐标业务代码不用大动。对我们这种需要同时跑云端模型和本地模型的场景来说这个收益很实在。1.3 RAG 和 Tool Calling 各自的职责边界很多新手容易把 RAG 和 Tool Calling 混在一起觉得都是在“给模型加信息”。实际上两者解决的是完全不同的问题。RAG 解决的是“静态知识”问题岗位说明书、JD 模板、职级体系、技能词典这些相对稳定、不需要实时计算的内容提前做好向量化入库。模型回答问题时先把问题转成向量从知识库捞出相关片段拼进上下文。它的本质是“帮模型找资料”。Tool Calling 解决的是“动态动作”问题候选人当前状态、岗位实时数据、匹配分数计算、数据库写入这些需要实时访问业务系统的操作模型自己做不到。它通过生成结构化调用参数让框架去执行真实函数再把函数返回值塞回给模型继续推理。它的本质是“替模型动手”。举一个直观例子模型要回答“候选人 112 是否适合岗位 3008”。如果没有 Tool Calling模型大概率会一本正经地编一个候选人出来。有了 Tool Calling模型会先去查岗位 3008 的要求再去查候选人 112 的简历最后调用匹配算法拿分。RAG 在这里的角色是补充背景比如公司内部对“资深工程师”的定义、某个技能在特定业务线的重要性评级这些不会写在候选人和岗位表里只能从知识库里捞。两者结合后系统既“知道”又“能做”岗位分析才真正从演示 Demo 变成能用的工具。2. 环境准备与技术选型2.1 Spring AI 版本选择稳定优先还是新特性优先Spring AI 的版本迭代速度在 Java 生态里算快的我到手时 1.0.x 系列已经比较稳2.0.x 系列正在推。我最终选了 2.0.1不是因为追新而是因为 2.0 开始对多模型工具调用、结构化输出、Agent 相关 API 做了重新整理后面写 Tool Calling 时少踩一些兼容坑。这里要提醒一句Spring AI 的 API 在 1.0 和 2.0 之间有断档网上很多教程是 0.8.x 或者 1.0.0-M 系列的写法照着抄大概率编译不过。遇到这种情况不要硬改直接去看当前版本对应的官方文档或者去 GitHub 上看对应 tag 下的示例工程。我自己就吃过这个亏后面讲踩坑时会细说。pom.xml 里核心依赖如下dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-dashscope/artifactId version2.0.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-ollama/artifactId version2.0.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId version2.0.1/version /dependency版本统一放在 BOM 或者父 pom 管理别混着写不同版号Spring AI 的传递依赖比较多混版本很容易拉进来一堆冲突。2.2 云端模型和本地模型双路配置生产环境用百炼的 Qwen 系列毕竟国内访问稳定、中文理解能力强岗位 JD 这种中文长文本交给它比较放心。开发调试阶段我用 Ollama 拉一个本地小模型跑通链路节省 API 调用费用也方便断网排查问题。接入百炼的配置比较直接在 application.yml 里写spring: ai: model: chat: dashscope: api-key: ${DASHSCOPE_API_KEY} model: qwen3.7 embedding: dashscope: model: text-embedding-v3注意 api-key 一定走环境变量别明文提交到仓库。如果公司网络环境对阿里云域名有限制也可以走 OpenAI 兼容协议把 base-url 切成百炼兼容模式的地址Spring AI 的 openai starter 同样能接。两条路我都试过各有取舍Dashscope starter 的配置更简洁OpenAI 兼容协议则更通用换别的供应商时不用改代码。本地 Ollama 配置长这样主要给调试用spring: ai: model: chat: ollama: base-url: http://localhost:11434 model: qwen3:4b embedding: ollama: base-url: http://localhost:11434 model: nomic-embed-text个人经验是功能开发阶段全部用 Ollama 本地模型跑通再切换云端因为本地模型报错更直观日志全在自己机器上不用反复翻云端的调用记录。2.3 向量库选型为什么最终选 PgVector常见选项有 PgVector、Milvus、Redis Enterprise、Elasticsearch。我没有纠结太久直接选了 PgVector。原因很朴素公司已经有 PostgreSQL 实例运维团队对它很熟备份、权限、监控都现成。岗位知识库的数据量初期也就是几十万字符量级PgVector 完全扛得住。Milvus 性能上限更高但等于多引入一套分布式组件部署和运维成本立刻上来。Redis 我也考虑过适合纯缓存场景但要做持久化检索还是不如 PG 省心。Spring AI 对 PgVector 有原生支持自动建表、自动建索引配置很简单spring: ai: vectorstore: pgvector: initialize-schema: true index-type: HNSW distance-type: COSINE_DISTANCE如果知识库规模未来真的涨到千万级向量再迁移 Milvus 也不迟因为业务代码面向的是 VectorStore 接口替换实现类时改动可控。3. RAG 知识库搭建实战3.1 数据清洗和文档加载知识库的质量决定 RAG 上限知识库质量比模型选择更影响最终效果这句话我是在反复调优后才彻底认同的。岗位知识库的数据来源主要有三类现有 JD 文档Word、PDF、公司岗位说明书、HR 团队整理的技能词典和职级定义。原始数据不能直接扔给模型。我遇到最典型的问题就是 PDF 扫描件里面全是图片直接用文本解析器读出来是一堆乱码。这种情况必须先过 OCR。我用的是现成的开源 OCR 服务中文识别准确率勉强够用关键字段人工抽查一遍后再入库。加载文档的代码在 Spring AI 里很直接ListDocument docs new TextDocumentReader(new PathResource(jd.md)).read();但生产环境很少只有 Markdown 和文本文件。Word 文档我先把 docx 转成文本再读PDF 先走 OCR 再读。加载过程其实不重要重要的是清洗去掉页眉页脚、表格里的重复表头、无意义的换行符。这一层不做干净后面分块切出来的片段经常是半个表格或者断行的句子。3.2 分块策略从固定长度到语义边界的调整过程第一次我把 JD 按 1000 字硬切成块结果查询“Java 后端岗位的数据库要求”时召回的内容经常是岗位编号、福利待遇这种周边信息真正的技能要求被切到相邻块里去了模型抓不到关键内容。后来改成 TokenTextSplitter这是 Spring AI 内置的一个基于 token 数的拆分器配置也灵活TokenTextSplitter splitter new TokenTextSplitter.Builder() .withChunkSize(200) .withOverlap(30) .build(); ListDocument chunks splitter.apply(docs);我的经验是 chunkSize 在 150 到 300 之间比较合适。岗位 JD 的每个技能点通常一两句话就是几十个 token200 的块能装下两个左右技能点配合 30 的 overlap前后文衔接基本够用。块太大单次检索的语义不聚焦块太小上下文碎片化严重模型拼不出完整结论。这里必须强调 overlap 的价值。没有 overlap 时一个完整的技能描述正好被切在边界上召回的那一块只有半句话模型只能猜。加了 30 个 token 的 overlap 之后这类问题大幅减少。成本当然是文档总数变多、检索时间略增但对小知识库来说完全可接受。3.3 向量存储和元数据设计别把所有文档揉成一锅粥文档入 PgVector 之前我给每个 Document 都加了 metadata至少包含 source来源文件、categoryjd 或 skill_dict、jobFamily岗位族、updateTime 这几个字段。metadata 的价值不只是标记它还能在检索时当过滤器用把搜索范围按业务维度缩小。入库代码大致是这样Document doc new Document(text, Map.of( source, 2024-java-jd.pdf, category, jd, jobFamily, backend )); vectorStore.add(List.of(doc));这样设计之后检索一个后端岗位时可以在 SearchRequest 里带上jobFamily backend的过滤条件把算法岗、运维岗的知识片段排除掉召回准确率明显提升。没有这个维度时搜“高并发经验”会把所有岗位的经验要求都捞上来模型上下文被大量无关内容污染。3.4 检索链路优化向量召回 关键词匹配 重排只靠向量检索遇到专业名词缩写或者精确匹配场景会漏招。比如候选人简历里写“SpringMVC”岗位要求写“Spring Web MVC”向量语义上勉强能关联但关键词上完全对不上。我在检索链路上加了关键词匹配把查询词里的核心实体、技能标签抽出来做一遍倒排索引匹配然后和向量结果合并去重。合并策略我试过两种一种是向量结果和关键词结果直接 union按分排序另一种是向量召回前 20 条关键词召回前 20 条合并后用一个轻量级重排模型把 Top 6 重新排序。小数据量时第一种就够效果稳定调试简单。数据量上来后建议上重排收益很明显。命中率是评估检索效果最直接的指标。我手工标注了 50 组“问题-标准答案”对比如“公司对高级工程师的年限要求是什么”检索后看标准答案片段有没有出现在 Top 5 召回结果里计算 hit5。第一次测只有 0.62调完分块、加元数据过滤、融合关键词后到 0.85 左右。没有这套评估集优化就全靠感觉效果好不好自己都不知道。3.5 被问了很多次的问题知识库到底能不能存图片搜索热词里“rag知识库能存储图片嘛”出现频率很高我直接给结论能存但不能指望把图片本身丢进向量库然后让模型“看图”。Spring AI 的向量存储处理的是文本嵌入图片本身没有向量化语义入口。真正可行的做法是分两种情况。第一种图片里全是文字信息比如 JD 的扫描件截图、表格截图先用 OCR 把文字抽出来再入库。第二种图片是流程图、架构图文字信息很少语义很难靠嵌入表达这种建议在元数据里存图片 URL 和图片对应的文字说明段落检索时文本片段会带出原图链接需要人工查看时再打开。如果你想从“存图”变成“看图”那是多模态模型的能力范围跟 RAG 的文本检索链路是两码事。岗位分析场景里绝大多数需求靠 OCR 抽取文本就能解决我最后把图片单独存了一份对象存储知识库里只放对应文字和引用路径。4. Tool Calling 机制与 Agent 编排4.1 Tool Calling 的本质从“我问你答”到“你调我算”大模型本身是一个“闭卷考试”系统所有知识都来自训练参数不会实时访问数据库也不会做算术。Tool Calling函数调用突破了这层限制模型在生成过程中识别到“这个问题需要查工具”会输出一个结构化的调用请求框架收到后执行对应函数把结果返回给模型模型再基于结果继续回答。这就像你请了一个专家当助理。专家不知道你们公司员工数据库里有什么但他知道自己该去查什么系统、打什么电话。你问他“XX 候选人合适吗”他不会瞎编而是先打电话给 HR 系统要简历再打电话给岗位库要 JD拿到真实数据后才给你出结论。Spring AI 的 Tool Calling 就是在 Java 里实现这套“助理能力”。核心价值有两个第一是消除幻觉模型不再编造候选人、编造分数第二是打通业务系统AI 应用从一个独立聊天工具变成了能真正操作企业内部数据的业务系统。4.2 用 Tool 定义一个招聘业务工具Spring AI 里定义一个工具很简单核心就是Tool注解加一个普通方法Component public class RecruitmentTools { private final CandidateRepository candidateRepository; private final JobRepository jobRepository; private final MatchingService matchingService; public RecruitmentTools(CandidateRepository candidateRepository, JobRepository jobRepository, MatchingService matchingService) { this.candidateRepository candidateRepository; this.jobRepository jobRepository; this.matchingService matchingService; } Tool(description 按候选人ID查询候选人基本信息包括姓名、工作年限、技能列表、最近工作经历) public String getCandidateInfo(Integer candidateId) { Candidate candidate candidateRepository.findById(candidateId); return JSON.toJSONString(candidate); } Tool(description 按岗位ID查询岗位描述、任职要求、技能标签、薪资范围) public String getJobRequirement(Integer jobId) { Job job jobRepository.findById(jobId); return JSON.toJSONString(job); } Tool(description 计算候选人与指定岗位的综合匹配度得分满分100分分数越高越匹配) public String computeMatchScore(Integer jobId, Integer candidateId) { double score matchingService.calculate(jobId, candidateId); return {\score\: score }; } Tool(description 根据岗位ID查询该岗位近半年的面试题库) public String listInterviewQuestions(Integer jobId) { return JSON.toJSONString(interviewQuestionRepository.findByJobId(jobId)); } }这里有几个实操要点。第一方法的出入参尽量用简单的Integer、String不要塞一个复杂对象进去。模型生成工具调用的参数时是根据 JSON Schema 来的参数越简单模型越不容易生成错。第二返回结果最好转成 JSON 字符串模型拿到后直接就能解析并引用不需要再写 ldquo描述性文本 rdquo。第三Tool的 description 一定要写清楚这直接影响模型判断“什么时候该用这个工具”描述模糊会造成该调不调或者乱调。4.3 构建 ChatClient 并注入工具Spring AI 里 Agent 编排主要通过ChatClient完成。建造 ChatClient 时可以配置一个固定的系统提示词并把工具对象传进去Configuration public class AiConfig { Bean ChatClient recruitmentChatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是资深招聘分析师擅长岗位分析与候选人评估。 回答必须基于检索到的知识库内容和工具返回的真实数据 不要编造不存在的候选人信息或岗位信息。) .defaultTools(new RecruitmentTools()) .build(); } }调用时就非常自然String analysis chatClient.prompt() .user(候选人 112 是否适合岗位 3008请结合岗位职责、任职要求、候选人技能与经验做匹配度评估并给出面试建议。) .call() .content();模型内部会先决定要不要调用工具、调用哪个工具、传什么参数。Spring AI 的框架层会自动完成“模型提出调用请求 → 框架执行方法 → 返回结果给模型 → 模型继续生成”这个循环直到模型认为信息够了输出最终回答。实测下来这个循环通常要跑两三轮。比如模型会先调getJobRequirement(3008)拿到岗位要求再调getCandidateInfo(112)拿到候选人简历然后调computeMatchScore(3008, 112)拿到分数最后综合这些结果写分析。整个过程对调用方是透明的用户体验就是一个稍慢一点的 API 请求。4.4 多个工具按需组合把 RAG 结果也交给模型Tool 和 RAG 不是两个独立模块而是可以深度联动的。我在系统提示词里给模型一段上下文模板其中就包含 RagRetriever 的检索结果String systemText 你是资深招聘分析师。 以下是公司内部岗位知识库中的相关上下文供你参考 {retrievedContext} 你可以调用工具获取实时业务数据工具返回结果优先于知识库上下文。 如果工具返回结果与知识库信息矛盾以工具结果为准。 ; String answer chatClient.prompt() .system(spec - spec.text(systemText) .param(retrievedContext, ragRetriever.retrieve(userQuery, jobAnalysis))) .user(候选人 112 是否适合岗位 3008) .tools(new RecruitmentTools()) .call() .content();这里有个细节容易被忽略检索本身也需要一个“查询词”。直接用用户原话去检索知识库效果一般因为用户的原话往往很短或者带口语。我一般会先让模型把用户问题做一次语义改写抽取出“岗位 3008 的任职要求 候选人的技能 匹配”这类检索关键词再去向量库查询召回质量提升明显。这一步相当于手工实现了一个简化版的 Query Rewriting 模块。5. 全流程代码落地与核心方案实现5.1 核心配置和初始化代码整个系统的配置集中在 application.yml模型、向量库、数据源三类配置在一起方便排查。spring: application: name: job-analysis-service datasource: url: jdbc:postgresql://localhost:5432/job_ai username: postgres password: postgres ai: model: chat: dashscope: api-key: ${DASHSCOPE_API_KEY} model: qwen3.7 embedding: dashscope: model: text-embedding-v3 vectorstore: pgvector: initialize-schema: true index-type: HNSW distance-type: COSINE_DISTANCEinitialize-schema: true会让 Spring AI 自动创建向量表和索引首次启动省事。但在生产环境建议改成 false用 Flyway 管理表结构变更否则后面升级版本时自动建表逻辑可能跟已有表冲突。嵌入模型这里要强调一下ChatModel 和 EmbeddingModel 是两回事。很多人只配了对话模型就去做 RAG向量化时走默认或者本地随机嵌入结果效果一塌糊涂。我可选的嵌入模型是百炼的 text-embedding-v3中文效果明显好于老版本模型跟 Qwen 对话模型搭配也顺。5.2 知识库导入服务的完整代码我写了一个KnowledgeBaseService启动后或者通过接口触发可以把指定目录下的文档批量清洗、拆分、向量化入库。Service public class KnowledgeBaseService { private final VectorStore vectorStore; public KnowledgeBaseService(VectorStore vectorStore) { this.vectorStore vectorStore; } public int importToVectorStore(Path dir) { ListDocument allDocs new ArrayList(); try (StreamPath paths Files.walk(dir)) { ListPath files paths.filter(Files::isRegularFile).toList(); for (Path file : files) { String text extractText(file); if (text null || text.isBlank()) { continue; } Document doc new Document(text, Map.of( source, file.getFileName().toString(), category, inferCategory(file), updateTime, LocalDate.now().toString() )); allDocs.add(doc); } } catch (IOException e) { throw new RuntimeException(读取知识库目录失败, e); } TokenTextSplitter splitter new TokenTextSplitter.Builder() .withChunkSize(200) .withOverlap(30) .build(); ListDocument chunks splitter.apply(allDocs); vectorStore.add(chunks); return chunks.size(); } }直接入库前一定要拆分。我刚开始跳过拆分把整篇 JD 作为一个 Document 直接 add结果一篇 JD 数千 token检索时向量化语义被平均稀释效果很差。后来才改成先拆后存。extractText方法根据文件扩展名分发txt 和 markdown 直接用文本读取docx 用 Apache POIpdf 先判断是否可复制文字不能再走 OCR。这层逻辑不复杂但是知识库建设里最花时间的一步。5.3 RAG 检索封装返回可读文本而非原始向量检索结果要拼成对模型友好的文本片段而不是把 Document 对象直接塞给模型。我封装了RagRetrieverService public class RagRetriever { private final VectorStore vectorStore; public RagRetriever(VectorStore vectorStore) { this.vectorStore vectorStore; } public String retrieve(String query, String category) { SearchRequest.Builder searchBuilder SearchRequest.builder() .query(query) .topK(6) .similarityThreshold(0.5); if (category ! null !category.isBlank()) { searchBuilder.filterExpression(category category ); } ListDocument docs vectorStore.similaritySearch(searchBuilder.build()); return docs.stream() .map(doc - 【来源】 doc.getMetadata().getOrDefault(source, unknown) \n doc.getText()) .collect(Collectors.joining(\n\n---\n\n)); } }similarityThreshold我最终定在 0.5 左右。阈值太高会漏召回关键上下文没进模型分析结论直接缺料阈值太低会污染上下文无关片段挤占模型注意力。0.5 这个值在不同业务里可能需要微调建议基于自己的评估集去调别照搬别人的参数。topK取 6 也是折中的结果。太少不够覆盖太多上下文超过模型窗口后反而浪费 token。给模型拼接上下文时要控制总长度Spring AI 不会自动帮你截断。5.4 完整调用RAG 工具联动输出结构化报告整个岗位分析链路最终落在一次 ChatClient 调用上但用户输入和输出都做了结构化处理。输入是岗位 ID 和候选人 ID输出是一份 JSON 报告。我定义了一个 Java record 作为结构化输出的目标类型public record AnalysisResult( String conclusion, Integer score, ListString matchedSkills, ListString gapSkills, ListString interviewQuestions ) {}调用时可以直接让 ChatClient 解析结果AnalysisResult result chatClient.prompt() .system(spec - spec.text(SYSTEM_TEMPLATE) .param(retrievedContext, ragRetriever.retrieve(query, jobAnalysis))) .user(候选人 112 是否适合岗位 3008输出 JSON 格式报告。) .tools(new RecruitmentTools()) .call() .entity(AnalysisResult.class);.entity()是 Spring AI 的结构化输出能力它会要求模型按 record 的字段结构返回 JSON并由框架完成反序列化。实测大多数情况下模型能稳定输出但字段特别多、嵌套特别深时偶尔还是会少字段我会在系统提示词里给出 JSON Schema 示例并让工具结果字段直接映射到报告字段上模型有据可依错误率下降明显。matchedSkills和gapSkills这两个字段如果只靠模型自己从文本里抽会有漏项。更好的做法是在computeMatchScore工具返回值里直接带上技能比对数组模型拿到现成的 JSON再做口语化总结准确率高得多。工具结果越结构化模型输出越稳定。6. 踩坑记录与问题排查实录6.1 分块不合理导致上下文丢失hit5 从 0.86 降到 0.61这个坑让我印象特别深。最初把 JD 按固定 500 字切块时自测几个查询都还行结果做了评估集一跑hit5 只有 0.61。查原因发现一份 3000 字的岗位说明书被切成六块查询“高并发项目经验要求”时命中的是第一块里面只有岗位概述真正的经验要求分布在第四块向量相似度不够没被召回。换成 200 token 30 overlap 的 TokenTextSplitter 后hit5 回升到 0.82再叠加元数据过滤后到 0.85。这个经历让我明白分块参数不是拍脑袋定的必须用评估集量化。没有评估集的时候你只会觉得“效果还行”但不知道丢了 20% 的召回。还有一次做知识库扩容时我往库里加了大量新文档结果 hit5 反而降了。原因是新文档里有一些和旧文档高度相似的内容把 Top 6 挤占掉了一部分。解决办法是检索时按更新时间倒序并在同分数时优先新文档这样保证知识库持续补充后老的过时内容不会被反复捞出来。6.2 Tool Calling 陷入循环一个工具被反复调用十几次开发阶段遇到过模型反复调用同一个工具、始终不输出最终回答的情况。场景是让模型“分析候选人”它调完getCandidateInfo(112)后又调一次再调一次像死循环一样。排查下来有两个原因。第一个是工具描述写得模糊模型不知道用工具拿到信息后应该做什么只能反复确认。解决方法是把 system prompt 写清楚“拿到工具结果后立即用于分析不要重复调用相同参数的工具”。第二个是入参精度问题模型传参不稳定可能这次传 112下次传 112.0框架校验不一致导致调度异常。我把Integer换成String并在工具内部做解析问题就消失了模拟数字被模型频繁误用是常见毛病。Spring AI 里也留意下最大工具迭代次数的配置。如果模型必须在多轮工具调用后才能完成推理但框架默认迭代次数不够模型会被中断反过来设置得过大一旦卡循环就会拖垮响应时间。我最终调到 8大多数分析任务三轮内能搞定。6.3 向量检索结果为空九成问题出在入库环节有段时间线上反馈“候选人分析报告经常缺岗位要求”排查发现是向量检索返回空。第一反应是查向量库数据量结果表是空的。检查发现应用重启后没有执行知识库导入任务initialize-schema: true只建了表数据不会自己进来。这个问题看似低端但在多环境部署时很常见尤其测试环境经常重建数据库。复现路径是这样的开发环境本地导入过知识库一切正常测试环境是新库忘了执行导入服务于是一查一个空。我后来在应用启动时检查向量库数量为空就自动触一次导入并打印告警日志。另外一个导致检索为空的隐蔽原因是向量维度不匹配。开发时换过嵌入模型从 1024 维换成 1536 维直接把旧数据删掉重导否则检索时查出来的内容跟模型不匹配。PgVector 建表时会把向量维度写在表结构里换模型必须重建表。6.4 从 Dify 原型迁到 Spring AI 的适配清单前面提到我先用 Dify 做了原型。迁移到 Spring AI 时最大的心理落差是“可视化编排消失了一切都要用代码表达”。这里列一份对照清单能省不少功夫Dify 的“知识检索”节点对应 Spring AI 里的vectorStore.similaritySearch加上RagRetriever的文本拼装。Dify 的“Agent 节点”里配置的工具对应Tool注解方法一个方法就是一个工具。Dify 的“开始”和“结束”节点对应 Spring Boot 的 Controller 接口入参校验、出参结构都在这里落地。Dify 里“上下文变量”的引用对应ChatClient.prompt().system(spec - spec.param(...))的模板插值。Dify 里自定义代码节点做 JSON 转换对应 Java 的 Jackson 序列化和 record 反序列化。Dify 适合快速验证 ideaSpring AI 适合把 idea 变成可维护的工程。迁移时不要试图一比一复刻工作流而是重新审视业务逻辑哪些步骤真的需要模型参与哪些步骤只是数据加工。我在 Dify 原型里有很多节点只是字段拼接和格式化迁到 Java 时直接就用普通函数实现了没有让模型参与响应快还省钱。6.5 搜索结果不准时先查数据再调模型遇到“模型回答看起来有道理但关键信息错”的情况我的排查顺序是固定的先看知识库原文确认原文里是否真的有模型回答的信息。原文都没有RAG 召回链路有问题。再查召回结果把线上同一条查询的召回片段打出来看看相关片段是否在 Top 6 里。最后才看模型推理。很多“幻觉”其实是检索没召回正确片段模型在瞎补。调试时我习惯把整个请求链路日志打到独立的 log 文件包含检索 query、召回片段前 100 字、工具调用记录、模型最终响应。有了这份日志出问题时不用猜直接定位是哪个环节掉链子。7. 经验总结与后续扩展7.1 给后来者的几个实在建议第一不要一开始就追求复杂架构。先打通一个知识库 两个工具的闭环跑通后再扩展。我一开始就是想一步到位做了四五个工具加上复杂的检索链路结果排查问题时长翻倍。第二工具方法尽量幂等尤其是调用外部系统的写操作否则模型重试时会重复执行。第三把评估集建起来。我用最简单的“问题 期望召回片段 ID”格式做了 50 条标注每次优化后都跑一遍效果环比一目了然。成本控制方面建议在系统里统计每次调用的 token 消耗。RAG 的检索结果 多轮工具调用的中间结果都会显著增加 token 开销。我把检索召回的 Top 6 降到 Top 5系统提示词精简一部分冗余描述单次分析成本下降大约 20%效果几乎不变。7.2 从 RAG 到 Agentic RAG 的演进方向当前这个版本还是单轮 ChatClient 调用 固定的推荐流程。如果后续要支持更复杂的岗位分析比如多轮澄清提问、多知识库路由、多步骤任务规划可以考虑向 Agentic RAG 演进把“决定查哪个知识库”“要不要进行额外澄清提问”也交给模型自主规划然后通过 Agent 框架执行多步骤动作。Spring AI 本身支持通过 Advisors 增强 ChatClient 的行为例如加入日志、重试、多轮记忆。再复杂一些可以配合 Spring AI Alibaba 扩展或者 MCP 规范把工具调用标准化让岗位分析系统未来能接入包括薪酬查询、组织架构在内的更多企业数据源。不过这些扩展有一个前提就是先把当前这套链路的稳定性、评估集、日志体系做好。没有这些地基Agent 化只会让问题更复杂不会更智能。最后再分享一个小技巧我在生产库里加了一张“检索日志表”每次请求都记录查询词、召回片段、用户反馈点赞/点踩。跑了两周后把点踩率最高的 100 条记录拉出来做分析发现绝大多数问题都集中在原始文档表述含糊、分块把完整句切开这两类原因上。修复数据后整体点踩率下降了一半。这个经验让我明白RAG 系统的天花板是由数据质量决定的模型和框架只是把数据潜力释放出来的工具。先把知识库做干净再去折腾模型和参数回报率要高得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑