建崗位分析系統(tǒng)的實戰(zhàn)指南)
1. 為什么我要用 Spring AI 做一套崗位分析系統(tǒng)先把結(jié)論擺在前面這套系統(tǒng)的核心目標(biāo)是把一堆非結(jié)構(gòu)化的招聘 JD職位描述文本自動拆解成結(jié)構(gòu)化的崗位畫像——包括技能棧、經(jīng)驗?zāi)晗蕖⑿劫Y區(qū)間、學(xué)歷要求、崗位職責(zé)歸類再基于這些結(jié)構(gòu)化數(shù)據(jù)做檢索問答和趨勢分析。聽起來像是 NLP 的老活兒但真正落地的時候你會發(fā)現(xiàn)純靠提示詞硬懟大模型效果飄忽不定成本還高。所以我選了Spring AI RAG Tool Calling這條路線。為什么是 Spring AI因為我的主技術(shù)棧就是 Spring Boot團(tuán)隊里沒人愿意為了一個 AI 功能再單獨維護(hù)一套 Python 服務(wù)。Spring AI 把大模型調(diào)用、向量庫、Embedding、Function Calling 這些能力都封裝成了 Spring 風(fēng)格的 Bean 和注解跟現(xiàn)有的依賴注入、配置管理、事務(wù)體系能無縫銜接。這一點對后端團(tuán)隊來說太重要了——你不需要成為算法工程師也能把 AI 能力接進(jìn)業(yè)務(wù)系統(tǒng)。為什么是 RAG因為崗位分析這個場景有個天然痛點大模型的訓(xùn)練數(shù)據(jù)是滯后的它不知道你們公司內(nèi)部的崗位職級體系也不知道某個細(xì)分領(lǐng)域最新的技術(shù)棧叫法。RAG檢索增強生成的思路很樸素——先把相關(guān)知識存進(jìn)向量庫用戶提問時先檢索出最相關(guān)的片段再把這些片段作為上下文喂給大模型。這樣模型回答時就有據(jù)可依而不是憑空編造。為什么還要 Tool Calling因為光靠檢索還不夠。比如用戶問幫我統(tǒng)計一下近三個月 Java 崗位里提到 Spring Cloud 的比例這種需要精確計算和實時查詢的問題RAG 檢索出來的文本片段是沒法直接算的。這時候就需要讓大模型去調(diào)用我們預(yù)先定義好的工具方法——查數(shù)據(jù)庫、做聚合統(tǒng)計、調(diào)外部接口把計算結(jié)果拿回來再組織成自然語言。這三者組合起來就是一套完整的Agentic RAG雛形檢索負(fù)責(zé)找料工具調(diào)用負(fù)責(zé)干活大模型負(fù)責(zé)組織和表達(dá)。下面我把整個搭建過程、關(guān)鍵決策、踩過的坑從頭到尾捋一遍。2. 整體架構(gòu)設(shè)計與技術(shù)選型思路2.1 系統(tǒng)分層與數(shù)據(jù)流向整套系統(tǒng)我分成了四層從下往上依次是數(shù)據(jù)接入層負(fù)責(zé)采集和清洗招聘 JD 數(shù)據(jù)來源包括手動導(dǎo)入的文本、爬取的結(jié)構(gòu)化數(shù)據(jù)、以及歷史積累的 Excel 表格。這一層的核心任務(wù)是把各種格式的原始數(shù)據(jù)統(tǒng)一成純文本 元數(shù)據(jù)的格式。向量化與存儲層用 Embedding 模型把文本切片轉(zhuǎn)成向量存進(jìn)向量數(shù)據(jù)庫。同時保留原始文本和元數(shù)據(jù)崗位名稱、城市、發(fā)布時間、薪資等方便后續(xù)做混合檢索。檢索與工具層這是 RAG 的核心。檢索器負(fù)責(zé)根據(jù)用戶 query 召回相關(guān)文檔片段工具層定義了一系列可被大模型調(diào)用的方法比如統(tǒng)計技能出現(xiàn)頻次、按城市聚合薪資、查詢某個崗位的技能要求等。對話與編排層基于 Spring AI 的 ChatClient 構(gòu)建對話流程把檢索結(jié)果和工具調(diào)用結(jié)果組裝成最終提示詞交給大模型生成回答。數(shù)據(jù)流向是這樣的用戶提問 → 檢索器召回相關(guān) JD 片段 → 判斷是否需要調(diào)用工具 → 如果需要大模型輸出工具調(diào)用請求 → 執(zhí)行工具方法 → 把工具結(jié)果和檢索結(jié)果一起塞回提示詞 → 大模型生成最終回答。2.2 為什么選通義千問而不是別的模型模型選型這塊我糾結(jié)了很久。最終選通義千問主要基于三個考慮第一中文理解能力。崗位 JD 里全是中文還夾雜著大量技術(shù)名詞的中英文混寫比如熟悉 Spring Cloud 微服務(wù)架構(gòu)有 Dubbo 使用經(jīng)驗者優(yōu)先。通義千問在中文語境下的語義理解明顯更穩(wěn)尤其是對技術(shù)??s寫的識別。第二Tool Calling 的支持成熟度。不是所有模型都能穩(wěn)定地輸出結(jié)構(gòu)化的函數(shù)調(diào)用請求。我實測下來通義千問在 Function Calling 的格式遵循度上表現(xiàn)不錯很少出現(xiàn)該調(diào)工具時不調(diào)、或者參數(shù)格式亂寫的情況。第三成本和延遲。崗位分析系統(tǒng)需要頻繁調(diào)用模型尤其是批量處理 JD 的時候。通義千問的定價相對友好響應(yīng)速度也能接受。當(dāng)然如果你有本地部署需求Ollama 跑開源模型也是可行的后面我會提一下怎么切換。Spring AI 的好處就在這里——它把不同模型的調(diào)用抽象成了統(tǒng)一的接口你只需要改配置文件里的 model 名稱和 API Key代碼基本不用動。這一點在我后來做模型對比測試的時候省了大量時間。2.3 RAG 還是 GraphRAG我為什么先選樸素 RAG網(wǎng)上現(xiàn)在到處在聊 GraphRAG、Ontology RAG看起來很高大上。我也研究過一陣但最后還是決定先用最樸素的向量檢索 RAG 把流程跑通。原因很簡單崗位分析這個場景實體關(guān)系并沒有復(fù)雜到需要圖結(jié)構(gòu)。GraphRAG 適合什么場景適合那種實體之間有多跳關(guān)系、需要推理鏈的場景比如張三的上級的部門負(fù)責(zé)的項目用了什么技術(shù)。但崗位分析的核心需求是找出和某個 query 最相關(guān)的 JD 片段這是典型的語義相似度匹配問題向量檢索足夠用。而且樸素 RAG 的調(diào)試成本低得多。你可以快速看到檢索出來的片段質(zhì)量判斷是切片策略有問題還是 Embedding 模型不合適。等這套跑通了如果發(fā)現(xiàn)確實有跨文檔推理的需求再往上疊 GraphRAG 也不遲。我的原則一直是先用最簡單能跑的方案驗證價值再考慮優(yōu)化。3. 核心細(xì)節(jié)解析與實操要點3.1 文本切片策略別小看這一步RAG 效果好不好切片策略占一半功勞。我一開始圖省事直接按固定字符數(shù)切每 500 字一段。結(jié)果檢索出來的片段經(jīng)常是半句話截斷的比如熟悉 Java 并發(fā)編程、JVM 調(diào)優(yōu)有——后面沒了。這種片段喂給大模型它只能瞎猜。后來我改成了按語義結(jié)構(gòu)切片。招聘 JD 通常有比較固定的結(jié)構(gòu)崗位職責(zé)、任職要求、加分項、薪資福利。我先用正則把這幾塊拆開然后每塊內(nèi)部再按段落切。每段控制在 200 到 400 字之間并且保留一定的重疊overlap防止關(guān)鍵信息剛好卡在邊界上被切斷。具體參數(shù)上我設(shè)的是 chunkSize350chunkOverlap50。這個數(shù)值不是拍腦袋定的是我拿一批 JD 做了對比測試chunkSize 太小檢索出來的片段信息量不夠模型回答時容易缺胳膊少腿chunkSize 太大一個片段里混了好幾個不相關(guān)的信息點反而稀釋了相關(guān)性。350 字大概是一段完整任職要求的長度實測召回質(zhì)量最好。還有一個細(xì)節(jié)元數(shù)據(jù)一定要帶上。我在每個切片上都附加了崗位名稱、城市、薪資范圍、發(fā)布時間這些字段。這樣檢索的時候可以做過濾比如用戶問北京的 Java 崗位我可以先在元數(shù)據(jù)層面過濾出北京的數(shù)據(jù)再做向量檢索精度提升非常明顯。3.2 Embedding 模型的選擇與向量維度Embedding 模型負(fù)責(zé)把文本轉(zhuǎn)成向量。我一開始用的是默認(rèn)的模型后來換成了通義千問的 text-embedding 系列。換的原因是對中文技術(shù)文本的語義捕捉更準(zhǔn)。向量維度這塊要注意不同模型的維度不一樣一旦選定就不能隨便換。因為向量庫里的數(shù)據(jù)是用某個模型生成的你換了模型維度對不上整個庫都得重新生成。我選的是 1536 維這個維度在表達(dá)能力和存儲成本之間比較平衡。維度太低語義區(qū)分度不夠維度太高存儲和檢索都變慢而且邊際收益遞減。還有一個坑Embedding 是要花錢和花時間的。如果你有幾十萬條 JD一次性全量生成向量可能要跑好幾個小時。我的做法是分批處理每批 100 條中間加個短暫休眠避免觸發(fā)限流。同時把已經(jīng)生成好的向量緩存起來重復(fù)的文本不重復(fù)調(diào)用。3.3 Tool Calling 的工具定義原則工具定義是這套系統(tǒng)里最容易被低估的部分。我見過很多人把工具方法寫得特別復(fù)雜參數(shù)一大堆結(jié)果大模型根本調(diào)不對。我的經(jīng)驗是工具要小而專一個工具只干一件事。比如我定義了這么幾個工具countSkillFrequency(skillName, city, months)統(tǒng)計某個技能在指定城市、指定時間范圍內(nèi)的出現(xiàn)頻次。getSalaryRange(jobTitle, city)查詢某個崗位在某個城市的薪資區(qū)間。listTopSkills(jobTitle, limit)列出某個崗位最常被要求的技能按頻次排序。searchJdByKeyword(keyword, limit)按關(guān)鍵詞搜索原始 JD 文本。每個工具的參數(shù)都控制在 2 到 3 個而且參數(shù)名要起得讓模型一看就懂。比如months比timeRange更明確skillName比keyword更具體。工具的描述description也要寫清楚這是模型判斷該不該調(diào)用這個工具的主要依據(jù)。提示工具方法的返回值盡量用結(jié)構(gòu)化格式比如 JSON不要返回一大段自然語言。模型對結(jié)構(gòu)化數(shù)據(jù)的解析能力更強而且方便你在代碼里做二次處理。3.4 提示詞模板的設(shè)計要點提示詞模板決定了模型怎么組織回答。我的模板分三部分第一部分是系統(tǒng)指令告訴模型它的角色和回答風(fēng)格。比如你是一個專業(yè)的崗位分析助手回答要基于提供的參考資料不要編造數(shù)據(jù)。第二部分是檢索到的上下文把 RAG 召回的片段和工具調(diào)用的結(jié)果拼進(jìn)去。第三部分是用戶問題。這里有個關(guān)鍵技巧明確告訴模型什么時候該用工具什么時候該用檢索結(jié)果。我在系統(tǒng)指令里寫了一段話如果用戶的問題涉及統(tǒng)計、計算、排序請優(yōu)先調(diào)用工具如果用戶的問題涉及崗位職責(zé)、技能描述等文本內(nèi)容請基于檢索到的參考資料回答。 加上這段之后工具調(diào)用的準(zhǔn)確率提升了一大截。4. 實操過程與核心環(huán)節(jié)實現(xiàn)4.1 項目初始化與依賴配置先創(chuàng)建一個標(biāo)準(zhǔn)的 Spring Boot 項目然后在pom.xml里引入 Spring AI 的依賴。核心依賴包括dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-qwen-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-redis-store-spring-boot-starter/artifactId /dependency向量庫我選的是 Redis因為團(tuán)隊本來就在用 Redis 做緩存不用額外維護(hù)一套中間件。Spring AI 對 Redis 向量存儲的支持也比較完善。配置文件里需要填幾個關(guān)鍵項spring: ai: qwen: api-key: ${QWEN_API_KEY} chat: options: model: qwen-plus temperature: 0.3 embedding: options: model: text-embedding-v2 vectorstore: redis: index: job-jd-index prefix: jd:temperature我設(shè)的是 0.3因為崗位分析需要的是準(zhǔn)確和穩(wěn)定不需要太多創(chuàng)造性。設(shè)太高的話模型容易在數(shù)字和統(tǒng)計結(jié)果上胡說八道。4.2 數(shù)據(jù)清洗與切片實現(xiàn)數(shù)據(jù)清洗這塊我寫了一個JdCleaner類核心邏輯是去掉 HTML 標(biāo)簽和多余空白字符。按崗位職責(zé)任職要求加分項等關(guān)鍵詞把 JD 拆成幾個區(qū)塊。每個區(qū)塊內(nèi)部按段落切分合并過短的段落。生成切片時附加元數(shù)據(jù)。切片的核心代碼大概長這樣public ListDocument splitJd(String rawText, MapString, Object metadata) { ListDocument documents new ArrayList(); String[] sections rawText.split((?崗位職責(zé)|任職要求|加分項|薪資福利)); for (String section : sections) { if (section.trim().isEmpty()) continue; ListString chunks splitByLength(section, 350, 50); for (String chunk : chunks) { documents.add(new Document(chunk, metadata)); } } return documents; }splitByLength是我自己寫的按長度切分并保留重疊的方法。這里要注意切分的時候盡量在句號或分號處斷開不要硬切在詞中間。4.3 向量入庫與檢索實現(xiàn)入庫就是把切片轉(zhuǎn)成向量存進(jìn) Redis。Spring AI 提供了VectorStore接口調(diào)用add()方法就行Autowired private VectorStore vectorStore; public void importJds(ListDocument documents) { vectorStore.add(documents); }檢索的時候用similaritySearchpublic ListDocument retrieve(String query, int topK) { SearchRequest request SearchRequest.query(query) .withTopK(topK) .withSimilarityThreshold(0.7); return vectorStore.similaritySearch(request); }topK我設(shè)的是 5similarityThreshold設(shè)的是 0.7。這兩個參數(shù)需要根據(jù)實際數(shù)據(jù)調(diào)。topK 太大會引入不相關(guān)的片段干擾模型太小可能漏掉關(guān)鍵信息。0.7 的閾值是我實測下來比較合適的低于這個分?jǐn)?shù)的片段基本可以認(rèn)為是噪音。4.4 Tool Calling 的注冊與調(diào)用工具方法的注冊用 Spring AI 的Tool注解不同版本可能叫Function注意看文檔。我定義了一個JobAnalysisTools類Component public class JobAnalysisTools { Tool(description 統(tǒng)計指定技能在指定城市和時間范圍內(nèi)的出現(xiàn)頻次) public SkillStat countSkillFrequency( ToolParam(description 技能名稱如 Java、Spring Cloud) String skillName, ToolParam(description 城市名稱如 北京、上海) String city, ToolParam(description 統(tǒng)計最近幾個月的數(shù)據(jù)) int months) { // 查詢數(shù)據(jù)庫并統(tǒng)計 return skillRepository.countBySkillAndCity(skillName, city, months); } }然后在 ChatClient 里注冊這些工具ChatClient chatClient ChatClient.builder(chatModel) .defaultTools(jobAnalysisTools) .build();調(diào)用的時候模型會自動判斷是否需要調(diào)用工具。如果用戶問北京 Java 崗位里 Spring Cloud 的出現(xiàn)頻次是多少模型會輸出一個工具調(diào)用請求Spring AI 框架會自動執(zhí)行對應(yīng)方法并把結(jié)果返回給模型。4.5 完整對話流程的編排把檢索和工具調(diào)用串起來的核心邏輯public String chat(String userQuestion) { // 1. 檢索相關(guān)文檔 ListDocument docs retrieve(userQuestion, 5); String context docs.stream() .map(Document::getContent) .collect(Collectors.joining(\n---\n)); // 2. 構(gòu)建提示詞 String prompt String.format( 參考資料 %s 用戶問題%s , context, userQuestion); // 3. 調(diào)用模型工具會自動觸發(fā) return chatClient.prompt() .user(prompt) .call() .content(); }這段代碼看起來簡單但里面有個細(xì)節(jié)檢索和工具調(diào)用不是二選一的。有些問題既需要檢索文本又需要工具計算。比如幫我分析一下北京 Java 崗位的技能要求并統(tǒng)計 Spring Cloud 的出現(xiàn)頻次。這種情況下模型會先基于檢索結(jié)果回答技能要求部分再調(diào)用工具獲取統(tǒng)計數(shù)據(jù)。5. 常見問題與排查技巧實錄5.1 檢索結(jié)果不相關(guān)怎么辦這是 RAG 最常見的問題。排查思路按優(yōu)先級來第一步檢查切片質(zhì)量。把檢索出來的片段打印出來看是不是有截斷、混入無關(guān)內(nèi)容的情況。如果是調(diào)整切片策略。第二步檢查 Embedding 模型。用幾個典型 query 測試看召回的片段是否語義相關(guān)。如果明顯不相關(guān)可能是模型對中文技術(shù)文本的理解不夠考慮換模型。第三步調(diào)整相似度閾值和 topK。有時候不是檢索錯了而是閾值設(shè)太低把噪音也召回了。第四步考慮混合檢索。純向量檢索對關(guān)鍵詞匹配不敏感比如用戶搜Spring Cloud向量檢索可能召回一堆微服務(wù)相關(guān)的片段但沒召回明確提到Spring Cloud的。這時候可以加一路基于關(guān)鍵詞的檢索比如 Elasticsearch把兩路結(jié)果融合。5.2 工具調(diào)用不觸發(fā)或參數(shù)錯誤模型該調(diào)工具時不調(diào)通常是因為工具描述寫得不夠清楚。我的經(jīng)驗是描述里要明確說明什么時候用這個工具而不只是這個工具是干什么的。比如不要寫統(tǒng)計技能頻次而要寫當(dāng)用戶詢問某個技能的出現(xiàn)次數(shù)、占比、趨勢時使用此工具。參數(shù)錯誤的話檢查參數(shù)名和描述。參數(shù)名要語義明確描述里最好給例子。比如city的描述寫成城市名稱如 北京、上海、深圳。還有一個坑工具方法拋異常會導(dǎo)致整個對話失敗。所以工具方法內(nèi)部一定要做好異常處理返回一個友好的錯誤信息而不是直接拋出去。5.3 模型回答里出現(xiàn)編造數(shù)據(jù)這是最危險的問題。模型可能會把檢索到的片段里的數(shù)字張冠李戴或者干脆編一個看起來合理的數(shù)字。我的應(yīng)對策略有三個第一在系統(tǒng)指令里明確禁止編造。寫清楚如果參考資料中沒有相關(guān)信息請直接說不知道不要編造。第二要求模型標(biāo)注數(shù)據(jù)來源。比如回答時帶上根據(jù) XX 條 JD 統(tǒng)計這樣你能快速判斷數(shù)據(jù)是否可信。第三關(guān)鍵數(shù)據(jù)走工具調(diào)用。凡是涉及具體數(shù)字的盡量讓模型調(diào)工具獲取而不是從檢索片段里提取。工具返回的數(shù)據(jù)是精確的模型只負(fù)責(zé)組織語言。5.4 響應(yīng)速度慢的優(yōu)化RAG Tool Calling 的鏈路比較長響應(yīng)慢是正常的。優(yōu)化方向檢索階段給向量庫加索引減少 topK用元數(shù)據(jù)過濾縮小檢索范圍。模型階段用流式輸出streaming讓用戶先看到部分結(jié)果。Spring AI 支持stream()方法。工具階段給工具方法的查詢加緩存相同參數(shù)的查詢直接返回緩存結(jié)果。并發(fā)處理檢索和工具調(diào)用如果可以并行就用CompletableFuture并行執(zhí)行。下面這張表是我整理的問題速查表方便你快速定位問題現(xiàn)象可能原因排查方向檢索結(jié)果不相關(guān)切片策略差 / Embedding 模型不合適檢查切片質(zhì)量換模型測試工具不觸發(fā)工具描述不清晰補充何時使用的說明參數(shù)錯誤參數(shù)名或描述有歧義參數(shù)名語義化描述加例子回答編造數(shù)據(jù)提示詞約束不夠加禁止編造指令關(guān)鍵數(shù)據(jù)走工具響應(yīng)慢鏈路長 / 無緩存加索引、流式輸出、查詢緩存向量入庫失敗維度不匹配 / 限流檢查模型維度分批處理5.5 幾個我踩過的坑坑一元數(shù)據(jù)過濾和向量檢索的順序。我一開始是先做向量檢索再在結(jié)果里過濾元數(shù)據(jù)。這樣會導(dǎo)致召回數(shù)量不夠——比如我要 5 條北京的向量檢索返回 10 條過濾完只剩 2 條。正確做法是在檢索請求里就帶上過濾條件讓向量庫先過濾再檢索??佣ぞ叻椒ǖ姆祷刂堤?。我有一次讓工具返回了完整的 JD 列表結(jié)果 token 直接爆了。工具返回值要精簡只返回必要字段??尤浱幚砜战Y(jié)果。檢索可能返回空工具可能查不到數(shù)據(jù)。這些情況都要有兜底邏輯不能讓模型面對空上下文硬編??铀哪P桶姹旧墝?dǎo)致行為變化。我用的是在線模型有一次服務(wù)端升級后工具調(diào)用的格式變了導(dǎo)致解析失敗。所以生產(chǎn)環(huán)境要做好版本鎖定和回歸測試。6. 一些關(guān)于擴(kuò)展方向的個人想法這套系統(tǒng)跑通之后我陸續(xù)加了一些擴(kuò)展。比如把崗位分析結(jié)果做成可視化報表用定時任務(wù)每天跑一批新 JD 入庫還接了一個簡單的 Web 界面方便非技術(shù)同事使用。如果繼續(xù)往下做我覺得有幾個方向值得嘗試。一是引入多路召回把向量檢索、關(guān)鍵詞檢索、甚至基于規(guī)則的檢索融合起來提升召回率。二是做檢索結(jié)果的重排序用一個小的交叉編碼模型對召回的片段重新打分把最相關(guān)的排到前面。三是把工具調(diào)用做得更智能比如讓模型自己決定調(diào)用哪些工具、以什么順序調(diào)用而不是每次都要我在提示詞里引導(dǎo)。不過話說回來技術(shù)方案沒有銀彈。我見過太多人一上來就追求最復(fù)雜的架構(gòu)結(jié)果連最基礎(chǔ)的檢索質(zhì)量都沒調(diào)好。我的建議始終是先把樸素 RAG 跑通把檢索質(zhì)量調(diào)到位再考慮加工具、加圖、加重排序。每一步都要有明確的收益而不是為了技術(shù)而技術(shù)。最后分享一個我在調(diào)試期常用的小技巧把每次對話的檢索片段、工具調(diào)用記錄、最終提示詞都打到日志里。出問題的時候翻日志比瞎猜快得多。這個習(xí)慣幫我省了無數(shù)時間。