RAG多崗位簡(jiǎn)歷篩選系統(tǒng)實(shí)踐:多租戶(hù)架構(gòu)設(shè)計(jì)模式與源碼解讀

我在8月底的時(shí)候,發(fā)過(guò)一篇基于 LlamaIndex+LangChain 框架,開(kāi)發(fā)的簡(jiǎn)歷篩選助手的應(yīng)用。后續(xù)有星球成員提出希望能增加多個(gè)崗位的管理功能,正好接下來(lái)的校招活動(dòng)可以用的上。
這篇在原項(xiàng)目的基礎(chǔ)上,核心實(shí)現(xiàn)了多崗位并行管理(獨(dú)立 JD、候選人池、向量索引隔離)和 HR 工作流(標(biāo)簽系統(tǒng)、分組展示、快速操作),同時(shí)進(jìn)行了架構(gòu)重構(gòu)(分層設(shè)計(jì)、數(shù)據(jù)分庫(kù)、模塊化),并增強(qiáng)了大模型分析輸出(四級(jí)推薦等級(jí)、結(jié)構(gòu)化優(yōu)劣勢(shì))和智能問(wèn)答(按崗位過(guò)濾檢索、流式輸出)。
這篇試圖說(shuō)清楚:
系統(tǒng)的實(shí)際效果演示、四層系統(tǒng)架構(gòu)拆解、五點(diǎn)核心技術(shù)實(shí)現(xiàn)、三個(gè)二次開(kāi)發(fā)場(chǎng)景指南,以及對(duì)端側(cè)模型應(yīng)用的一些感想。
1、視頻效果展示
在開(kāi)始講具體實(shí)現(xiàn)之前,老規(guī)矩先來(lái)看看整個(gè)系統(tǒng)的架構(gòu)設(shè)計(jì)。下面這張圖展示了從用戶(hù)界面到數(shù)據(jù)存儲(chǔ)的完整數(shù)據(jù)流。

2.1為啥要分四層
在上一版單崗位系統(tǒng)里,UI 代碼、業(yè)務(wù)邏輯、AI 調(diào)用全都混在一起的,一開(kāi)始寫(xiě)起來(lái)確實(shí)快,但這次升級(jí)到多崗位管理的時(shí)候,改動(dòng)起來(lái)難免顧此失彼,所以這次也算是做了下系統(tǒng)重構(gòu)。
前端交互層這部分依然采用了輕量化的 Streamlit,搭建了三個(gè) Tab 頁(yè)面:候選人概覽、候選人詳情和智能問(wèn)答。這一層只管顯示數(shù)據(jù)和響應(yīng)用戶(hù)操作,不關(guān)心數(shù)據(jù)從哪來(lái)以及怎么處理。

業(yè)務(wù)服務(wù)層這部分是整個(gè)系統(tǒng)的核心處理邏輯所在。一份簡(jiǎn)歷從上傳到展示,需要經(jīng)過(guò):先提取文本,再調(diào)用大模型分析,然后存入數(shù)據(jù)庫(kù),最后建立向量索引。這些流程編排都在ResumeProcessor這個(gè)類(lèi)里完成。還有一個(gè)PositionService,專(zhuān)門(mén)管理崗位的增刪改查。這一層的好處是,如果以后想改業(yè)務(wù)流程,比如在大模型分析前加一個(gè)簡(jiǎn)歷去重檢查,只需要在這一層加幾行代碼,不用動(dòng)其他地方。
核心引擎層這部分封裝了所有大模型相關(guān)的能力。這一層最重要的是RAGEngine,包括了簡(jiǎn)歷文本向量化、存入 ChromaDB,以及在用戶(hù)提問(wèn)時(shí)檢索相關(guān)內(nèi)容等功能。
數(shù)據(jù)存儲(chǔ)層這部分用了三種存儲(chǔ)方式:SQLite 存儲(chǔ)崗位信息和候選人結(jié)構(gòu)化數(shù)據(jù),ChromaDB 存儲(chǔ)向量索引,文件系統(tǒng)存儲(chǔ)原始簡(jiǎn)歷文件。所有復(fù)雜的 SQL 邏輯都封裝在各個(gè) Store 類(lèi)里,這樣以后如果要把 SQLite 換成 MySQL,只需要改 Store 類(lèi)的實(shí)現(xiàn),上層代碼完全不用動(dòng)。
2.2多崗位數(shù)據(jù)隔離
為了保證不同崗位的數(shù)據(jù)不會(huì)串臺(tái),我在三個(gè)層面做了隔離設(shè)計(jì)。
文件系統(tǒng)按崗位分目錄
最簡(jiǎn)單直接的辦法,就是把不同崗位的簡(jiǎn)歷文件分開(kāi)存。我在uploaded_resumes/目錄下,給每個(gè)崗位創(chuàng)建一個(gè)子目錄,目錄名就是崗位 ID。比如"AI 產(chǎn)品經(jīng)理"的崗位 ID 是position_001,這樣做的好處是刪除崗位的時(shí)候可以直接把整個(gè)目錄刪掉。
關(guān)系數(shù)據(jù)庫(kù)用外鍵關(guān)聯(lián)
在 SQLite 里,我給candidates表、ai_analysis表、hr_tags表都加上了position_id字段,并且設(shè)置了外鍵約束。每次查詢(xún)候選人數(shù)據(jù),SQL 語(yǔ)句里必須帶上WHERE position_id = ?,從源頭上避免跨崗位查詢(xún)。
向量數(shù)據(jù)庫(kù)按崗位分 collection
這是整個(gè)隔離機(jī)制里最關(guān)鍵的一環(huán),ChromaDB 支持創(chuàng)建多個(gè) collection(可以理解為不同的向量數(shù)據(jù)庫(kù)),我給每個(gè)崗位創(chuàng)建一個(gè)獨(dú)立的 collection。比如position_001對(duì)應(yīng)的 collection 叫resumes_position_001,position_002對(duì)應(yīng)的叫resumes_position_002。
可能會(huì)有人問(wèn)為啥不用 metadata 過(guò)濾呢?比如所有簡(jiǎn)歷存在一個(gè) collection 里,然后在 metadata 里標(biāo)記position_id,檢索的時(shí)候再過(guò)濾。這個(gè)方案看起來(lái)更簡(jiǎn)單,但有個(gè)致命問(wèn)題是,實(shí)際使用的時(shí)候性能會(huì)隨著候選人總數(shù)線性下降。假設(shè)系統(tǒng)里有 10 個(gè)崗位,每個(gè)崗位 100 個(gè)候選人,那全局就有 1000 個(gè)候選人的向量。每次檢索都要在 1000 個(gè)向量里搜索,然后再用 metadata 過(guò)濾,這無(wú)疑會(huì)很慢。
而用 collection 隔離,每個(gè)崗位的檢索只在自己那 100 個(gè)向量里進(jìn)行,性能只跟該崗位的候選人數(shù)相關(guān),跟其他崗位完全無(wú)關(guān)。這就是物理隔離優(yōu)于邏輯過(guò)濾的典型場(chǎng)景。這點(diǎn)非常像我在做 ibm rag 冠軍賽項(xiàng)目拆解中提到的“疑一文一庫(kù)”的做法。
2.3RAG 引擎與業(yè)務(wù)邏輯的解耦
在上一版系統(tǒng)里,我把 RAG 的代碼直接寫(xiě)在業(yè)務(wù)邏輯里,結(jié)果就是代碼復(fù)用性很差。這次重構(gòu)我專(zhuān)門(mén)抽出了一個(gè)通用的RAGEngine類(lèi),只提供三個(gè)核心能力:建索引、檢索、問(wèn)答。
業(yè)務(wù)層想用的時(shí)候,把數(shù)據(jù)準(zhǔn)備好,調(diào)用對(duì)應(yīng)的方法就行。比如ResumeProcessor在處理簡(jiǎn)歷的時(shí)候,會(huì)調(diào)用RAGEngine.build_index()把簡(jiǎn)歷文本向量化;在智能問(wèn)答的時(shí)候,會(huì)調(diào)用RAGEngine.query_with_rag()執(zhí)行 RAG 流程。
這種解耦帶來(lái)的好處是,如果各位想換一個(gè)向量數(shù)據(jù)庫(kù),比如從 ChromaDB 換成 Milvus,只需要改RAGEngine的內(nèi)部實(shí)現(xiàn),業(yè)務(wù)層的代碼一行都不用動(dòng)。或者如果想把這套 RAG 引擎用到其他項(xiàng)目,比如做一個(gè)標(biāo)準(zhǔn)的企業(yè)知識(shí)庫(kù)問(wèn)答系統(tǒng),直接復(fù)制core/rag_engine_v2.py這個(gè)文件過(guò)去,寫(xiě)一個(gè)新的 Processor 類(lèi)就能跑起來(lái)。
3、核心技術(shù)實(shí)現(xiàn)拆解
前面講完了架構(gòu)設(shè)計(jì),這部分從代碼層面講幾個(gè)關(guān)鍵的技術(shù)實(shí)現(xiàn),這部分會(huì)聚焦在私以為有一定工程借鑒價(jià)值的地方。
3.1簡(jiǎn)歷完整向量化的考量
一個(gè)好用的 RAG 系統(tǒng)設(shè)計(jì),一個(gè)繞不開(kāi)問(wèn)題是如何精準(zhǔn)的切分文檔。上一版系統(tǒng)里,因?yàn)槟康氖且菔就暾南到y(tǒng)流程,所以演示的簡(jiǎn)歷部分也是針對(duì)性的進(jìn)行了設(shè)計(jì),分塊部分按照"核心技能"、"工作經(jīng)歷"等章節(jié)標(biāo)題進(jìn)行處理。但這顯然不符合實(shí)際五花八門(mén)的簡(jiǎn)歷格式情況。 這次重構(gòu),我做了一個(gè)看似偷懶實(shí)則更務(wù)實(shí)的做法,就是把每份簡(jiǎn)歷作為一個(gè)完整的 node,不做分塊處理。
class MultiPositionNodeParser(NodeParser):
"""
支持多崗位的簡(jiǎn)歷Node解析器(無(wú)分塊版本)
設(shè)計(jì)策略:
- 每份簡(jiǎn)歷作為一個(gè)完整的node,不進(jìn)行分塊
- 在metadata中添加position_id用于多崗位隔離
- 添加candidate_name用于候選人識(shí)別
優(yōu)勢(shì):
- 保留完整上下文,避免信息碎片化
- 簡(jiǎn)歷通常很短,適合整體向量化
- 檢索時(shí)一次性獲得候選人所有信息
"""
def _parse_nodes(self, documents: List[Document], **kwargs) -> List[BaseNode]:
all_nodes = []
position_id = kwargs.get('position_id')
# 按文件路徑分組(一個(gè)PDF可能有多頁(yè))
docs_by_filepath = defaultdict(list)
for doc in documents:
docs_by_filepath[doc.metadata.get("file_path")].append(doc)
for file_path, doc_parts in docs_by_filepath.items():
# 合并所有頁(yè)面為完整文本
doc_parts.sort(key=lambda d: int(d.metadata.get("page_label", "0")))
full_text = "\n\n".join([d.get_content().strip() for d in doc_parts])
# 創(chuàng)建包含整份簡(jiǎn)歷的node
metadata = {
"position_id": position_id,
"candidate_name": Path(file_path).stem,
"chunk_type": "full_resume",
"resume_length": len(full_text)
}
node = TextNode(text=full_text, metadata=metadata)
all_nodes.append(node)
return all_nodes首先有個(gè)共識(shí)是,簡(jiǎn)歷通常很短,一般 2-4 頁(yè),毛估 2000-4000 字左右,完全在主流嵌入模型的處理范圍內(nèi)。這次演示用的bge-m3模型支持 8192 tokens,處理一份完整簡(jiǎn)歷應(yīng)該說(shuō)毫無(wú)壓力。
其次,完整向量化也避免了信息碎片化。當(dāng) HR 問(wèn)“張三有沒(méi)有 RAG 項(xiàng)目經(jīng)驗(yàn)”的時(shí)候,如果簡(jiǎn)歷被切成 10 個(gè) chunk,可能需要檢索多個(gè) chunk 才能拼湊出完整答案。而整份簡(jiǎn)歷作為一個(gè) node,一次檢索就能拿到所有相關(guān)信息,LLM 可以看到完整上下文進(jìn)行推理。
再者,實(shí)現(xiàn)邏輯簡(jiǎn)單也減少了 bug 風(fēng)險(xiǎn)。代碼的核心邏輯就是把多頁(yè) PDF 合并成一個(gè)字符串,然后塞進(jìn)一個(gè) node,沒(méi)有復(fù)雜的正則匹配、沒(méi)有邊界判斷,代碼清晰好維護(hù)。在實(shí)際使用中實(shí)測(cè)效果很好,檢索準(zhǔn)確率明顯提升,而且及時(shí)是 8b 尺寸的量化模型推理過(guò)程也很穩(wěn)定。
3.2Pydantic 驅(qū)動(dòng)的結(jié)構(gòu)化分析
如何讓 LLM 穩(wěn)定地輸出結(jié)構(gòu)化數(shù)據(jù),這也是個(gè)繞不開(kāi)的挑戰(zhàn)。好的工程實(shí)踐,無(wú)非也是一套圍繞模型動(dòng)態(tài)邊界構(gòu)建的解決方案。這次我用了在歷史企業(yè)項(xiàng)目中常用的 Pydantic 模型 + 詳細(xì) Prompt 的組合,實(shí)現(xiàn)了相對(duì)可靠的結(jié)構(gòu)化輸出。先定義數(shù)據(jù)模型:
class AIResumeAnalysis(BaseModel):
"""AI簡(jiǎn)歷分析結(jié)果 - 定性分析 + 結(jié)構(gòu)化提取"""
recommendation_level: str = Field(
default="可考慮",
descriptinotallow="推薦等級(jí): 強(qiáng)烈推薦 | 推薦 | 可考慮 | 不推薦"
)
key_strengths: List[str] = Field(
default_factory=list,
descriptinotallow="具體、可驗(yàn)證的優(yōu)勢(shì),優(yōu)先列出與崗位強(qiáng)相關(guān)的亮點(diǎn)"
)
key_concerns: List[str] = Field(
default_factory=list,
descriptinotallow="需要關(guān)注的方面,誠(chéng)實(shí)指出但不夸大"
)
one_sentence_summary: str = Field(
default="",
descriptinotallow="核心特征概括,幫助HR快速建立印象"
)
total_years_experience: int = Field(default=0)
work_experience: List[WorkExperienceExtracted] = Field(default_factory=list)
project_experience: List[ProjectExperienceExtracted] = Field(default_factory=list)
# 字段校驗(yàn)器
@field_validator('recommendation_level')
@classmethod
def validate_level(cls, v):
valid_levels = ["強(qiáng)烈推薦", "推薦", "可考慮", "不推薦"]
if v not in valid_levels:
return "可考慮"
return v
@field_validator('key_strengths')
@classmethod
def validate_strengths_count(cls, v):
if not v or len(v) == 0:
return ["請(qǐng)查看簡(jiǎn)歷原文進(jìn)行人工評(píng)估"]
return v[:5] # 最多5條Pydantic 的好處是,它不僅定義了數(shù)據(jù)結(jié)構(gòu),還內(nèi)置了校驗(yàn)邏輯。比如recommendation_level必須是四個(gè)等級(jí)之一,key_strengths至少有 1 條最多 5 條,這些規(guī)則會(huì)自動(dòng)執(zhí)行。然后在 Prompt 中明確輸出格式:
RESUME_ANALYSIS_PROMPT = """
你是一位專(zhuān)業(yè)的招聘顧問(wèn),請(qǐng)根據(jù)崗位描述和候選人簡(jiǎn)歷,輸出結(jié)構(gòu)化評(píng)估。
# 輸出要求
請(qǐng)嚴(yán)格按照以下 JSON 格式輸出,不要添加任何其他文字:
{
"recommendation_level": "強(qiáng)烈推薦",
"key_strengths": [
"5年 AI 產(chǎn)品經(jīng)驗(yàn),主導(dǎo)過(guò) 3 個(gè) RAG 項(xiàng)目成功上線",
"具備完整的 B 端產(chǎn)品設(shè)計(jì)能力(需求分析 → 原型設(shè)計(jì) → 上線運(yùn)營(yíng))"
],
"key_concerns": [
"缺乏制造業(yè)行業(yè)背景(現(xiàn)有經(jīng)驗(yàn)集中在互聯(lián)網(wǎng)行業(yè))"
],
"one_sentence_summary": "技術(shù)型 AI 產(chǎn)品專(zhuān)家,RAG 項(xiàng)目經(jīng)驗(yàn)豐富,需補(bǔ)足制造業(yè)行業(yè)背景",
...
}
# 評(píng)估標(biāo)準(zhǔn)
### key_strengths(關(guān)鍵優(yōu)勢(shì))
- 輸出 3-5 條具體、可驗(yàn)證的優(yōu)勢(shì)
- 優(yōu)先列出與崗位強(qiáng)相關(guān)的亮點(diǎn)
- 盡量包含數(shù)據(jù)支撐(如"5年經(jīng)驗(yàn)"、"主導(dǎo)3個(gè)項(xiàng)目")
- 避免空洞描述(?"能力強(qiáng)" ?"5年AI產(chǎn)品經(jīng)驗(yàn),主導(dǎo)3個(gè)RAG項(xiàng)目")
...
"""Prompt 里不僅給出了 JSON 格式,還詳細(xì)說(shuō)明了每個(gè)字段的要求,以及提供了好例子和壞例子的對(duì)比。這種高指令性的 Prompt 實(shí)測(cè)可以明顯提升 LLM 輸出的質(zhì)量和穩(wěn)定性。最后在解析時(shí)做好容錯(cuò)處理:
def _parse_response(self, response_text: str) -> AIResumeAnalysis:
"""解析LLM響應(yīng)為AIResumeAnalysis對(duì)象"""
try:
# 嘗試1:直接解析
data = json.loads(response_text)
return AIResumeAnalysis(**data)
except json.JSONDecodeError:
# 嘗試2:提取JSON塊
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
if json_match:
data = json.loads(json_match.group(0))
return AIResumeAnalysis(**data)
# 嘗試3:清理后解析(移除markdown代碼塊等)
cleaned = self._clean_json_text(response_text)
if cleaned:
data = json.loads(cleaned)
return AIResumeAnalysis(**data)
# 降級(jí)策略:返回默認(rèn)值
return self._get_fallback_analysis()這套機(jī)制總結(jié)來(lái)說(shuō)有三層容錯(cuò):先嘗試直接解析,不行就用正則提取 JSON 塊,再不行就清理格式后解析,最后還有一個(gè)降級(jí)策略返回默認(rèn)值。實(shí)際使用中,絕大部分情況第一次就能成功解析,少數(shù)格式異常的也能被后續(xù)邏輯兜住,系統(tǒng)健壯性非常高。
3.3簡(jiǎn)歷處理的五步流水線
一份簡(jiǎn)歷從上傳到最終可用,需要經(jīng)歷多個(gè)步驟。原版系統(tǒng)這些邏輯散落在各處,這次我用ResumeProcessor類(lèi)把整個(gè)流程標(biāo)準(zhǔn)化成五步流水線:
def process_uploaded_file(self, uploaded_file, position_id, job_description):
"""處理上傳的簡(jiǎn)歷文件(完整流程)"""
result = {
'steps': {
'extract': False, # 步驟1:文本提取
'validate': False, # 步驟2:內(nèi)容驗(yàn)證
'analyze': False, # 步驟3:AI分析
'index': False, # 步驟4:向量索引
'save': False # 步驟5:保存數(shù)據(jù)
}
}
# 步驟1:文本提取
text, extract_success = extract_text_from_file(temp_path)
result['steps']['extract'] = extract_success
if not extract_success:
return False, f"? 文件解析失敗", result
# 步驟2:內(nèi)容驗(yàn)證
is_valid = validate_resume_content(text)
result['steps']['validate'] = is_valid
if not is_valid:
return False, f"?? 文件內(nèi)容疑似不是簡(jiǎn)歷", result
# 步驟3:AI分析
analysis, analyze_success = self.analyzer.analyze(text, job_description)
result['steps']['analyze'] = analyze_success
# 步驟4:向量索引
index_success = self.rag_engine.ingest_resume(temp_path, position_id)
result['steps']['index'] = index_success
# 步驟5:保存到數(shù)據(jù)庫(kù)
profile = self._create_candidate_profile(...)
candidate_id = self.candidate_store.save(profile, analysis)
result['steps']['save'] = candidate_id > 0
return True, "? 處理成功", result這個(gè)設(shè)計(jì)有幾個(gè)好處:
首先,每一步都有明確的輸入輸出和成功標(biāo)志。result['steps']字典記錄了每一步的執(zhí)行狀態(tài),方便調(diào)試和監(jiān)控。如果處理失敗,可以立刻看到是在哪一步出的問(wèn)題。
其次,失敗快速返回,避免無(wú)效計(jì)算。如果文本提取就失敗了,后面的大模型分析、向量索引都不用做了,直接返回錯(cuò)誤信息。這種 fail-fast 策略節(jié)省資源,也讓錯(cuò)誤信息更清晰。
最后,result字典不僅包含每一步的狀態(tài),還包含候選人 ID、分析結(jié)果、文件路徑等信息,上層 UI 可以根據(jù)這些信息給用戶(hù)精準(zhǔn)的反饋。
這種流水線模式在企業(yè)級(jí)應(yīng)用中非常常見(jiàn),它把復(fù)雜流程拆解成清晰的步驟,每步職責(zé)單一,容易測(cè)試和維護(hù)。
3.4多崗位檢索的元數(shù)據(jù)過(guò)濾機(jī)制
前面架構(gòu)部分提到,我用 metadata 過(guò)濾實(shí)現(xiàn)多崗位隔離。這里展示一下具體的檢索代碼:
def retrieve(self, query: str, position_id: int = None, top_k: int = 5):
"""檢索相關(guān)文檔節(jié)點(diǎn)"""
# 構(gòu)建metadata過(guò)濾器
filters = None
if position_id is not None:
filters = MetadataFilters(
filters=[
MetadataFilter(
key="position_id",
value=position_id,
operator=FilterOperator.EQ
)
]
)
# 創(chuàng)建檢索器
retriever = self.index.as_retriever(
similarity_top_k=top_k,
filters=filters
)
# 執(zhí)行檢索
retrieved_nodes = retriever.retrieve(query)
return retrieved_nodesLlamaIndex 的MetadataFilters功能非常強(qiáng),支持多種操作符(EQ、GT、LT、IN等),可以組合多個(gè)條件。這里我只用了最簡(jiǎn)單的等值匹配,但如果以后需要更復(fù)雜的查詢(xún),比如"工作年限大于 5 年且有制造業(yè)背景",只需要添加更多的MetadataFilter就行。在向量化這一步,我把position_id、candidate_name等信息存入 node 的 metadata:
metadata = {
"position_id": position_id,
"candidate_name": Path(file_path).stem,
"chunk_type": "full_resume",
"resume_length": len(full_text)
}
node = TextNode(text=full_text, metadata=metadata)檢索的時(shí)候,ChromaDB 會(huì)先在全局向量空間找到語(yǔ)義最相似的 top-K 個(gè)結(jié)果,然后用 metadata 過(guò)濾器篩選出符合條件的 node。語(yǔ)義匹配 + 精確過(guò)濾的組合,既保證了檢索質(zhì)量,又實(shí)現(xiàn)了數(shù)據(jù)隔離。
需要注意的是,我在架構(gòu)設(shè)計(jì)部分提到過(guò) collection 隔離性能更好。但實(shí)際開(kāi)發(fā)時(shí)我發(fā)現(xiàn) LlamaIndex 對(duì)多 collection 管理不太友好,需要為每個(gè)崗位創(chuàng)建獨(dú)立的 index 對(duì)象,代碼會(huì)變得很復(fù)雜。所以我在單 collection + metadata 過(guò)濾和多 collection 隔離之間做了權(quán)衡,選擇了前者。
這也是說(shuō)明了理論最優(yōu)方案不一定是工程最優(yōu)方案。要考慮框架限制、開(kāi)發(fā)成本、可維護(hù)性等多方面因素。目前這個(gè)方案在候選人數(shù)量不超過(guò) 200 的情況下性能完全夠用,如果以后數(shù)據(jù)量真的上去了,再重構(gòu)成多 collection 也不遲。
3.5基于 Session 的多輪對(duì)話記憶
智能問(wèn)答如果只能單輪回答,用戶(hù)體驗(yàn)無(wú)疑會(huì)很差。我用 Streamlit 的session_state實(shí)現(xiàn)了按崗位隔離的對(duì)話記憶。
def render_chat_tab(position_id: int, position_name: str, ...):
"""渲染智能問(wèn)答Tab"""
# 初始化聊天歷史 - 每個(gè)崗位獨(dú)立的對(duì)話記憶
chat_key = f"chat_history_{position_id}"
if chat_key not in st.session_state:
st.session_state[chat_key] = []
# 顯示歷史消息
recent_messages = st.session_state[chat_key][-15:] # 只顯示最近15條
for message in recent_messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 用戶(hù)輸入
if prompt := st.chat_input("請(qǐng)?jiān)诖溯斎肽膯?wèn)題..."):
# 添加用戶(hù)消息到歷史
st.session_state[chat_key].append({"role": "user", "content": prompt})
# ... 執(zhí)行RAG檢索和LLM推理 ...
# 保存AI回答到歷史
st.session_state[chat_key].append({
"role": "assistant",
"content": answer_content,
"think_content": think_content # 推理過(guò)程
})首先是按崗位 ID 隔離對(duì)話歷史。不同崗位的問(wèn)答互不干擾,HR 在"AI 產(chǎn)品經(jīng)理"崗位的對(duì)話,不會(huì)影響到"算法工程師"崗位。其次,只顯示最近 N 條消息。對(duì)話歷史完整保存在session_state里,但頁(yè)面上只顯示最近 15 條,避免頁(yè)面過(guò)長(zhǎng)影響體驗(yàn)。

最后,保存推理過(guò)程。除了最終答案,我還把 LLM 的 thinking 過(guò)程保存下來(lái)。這樣用戶(hù)可以在st.expander里查看 AI 的推理鏈路,提升可解釋性。這個(gè)實(shí)現(xiàn)雖然簡(jiǎn)單,但在實(shí)際使用中效果很好。多輪對(duì)話讓 HR 可以不斷追問(wèn),獲得更深入的候選人情況了解。
4、二次開(kāi)發(fā)指南
在實(shí)際使用中,不同企業(yè)或者用戶(hù)會(huì)有不同的定制需求。這部分我根據(jù)和一些從業(yè)者的溝通,介紹三個(gè)高頻需求場(chǎng)景,作為二次開(kāi)發(fā)的參考。
4.1批量導(dǎo)入簡(jiǎn)歷
從招聘網(wǎng)站批量下載的簡(jiǎn)歷通常打包在 zip 文件里,每次都要手動(dòng)解壓再上傳,非常低效。如果能直接上傳 zip 包,系統(tǒng)自動(dòng)解壓并批量處理,能節(jié)省掉不必要的手動(dòng)操作。
# 核心邏輯:解壓zip并批量處理
import zipfile
from pathlib import Path
if uploaded_file.name.endswith('.zip'):
# 解壓到臨時(shí)目錄
with zipfile.ZipFile(uploaded_file) as zip_ref:
zip_ref.extractall('./temp_extract')
# 掃描所有簡(jiǎn)歷文件
resume_files = list(Path('./temp_extract').rglob('*.pdf')) + \
list(Path('./temp_extract').rglob('*.docx'))
# 調(diào)用現(xiàn)有的批量處理
processor.batch_process(resume_files, position_id, job_description)核心實(shí)現(xiàn)思路是在上傳組件支持 zip 格式,解壓后遞歸掃描所有簡(jiǎn)歷文件,然后調(diào)用現(xiàn)有的批量處理邏輯即可。
4.2薪資期望范圍提取與預(yù)算匹配
薪資是招聘決策中的關(guān)鍵因素,但人工查看每份簡(jiǎn)歷判斷是否在預(yù)算內(nèi),無(wú)疑會(huì)浪費(fèi)大量時(shí)間在預(yù)算不匹配的候選人上。如果系統(tǒng)能自動(dòng)提取薪資期望并與崗位預(yù)算對(duì)比,就可以在篩選階段就過(guò)濾掉不合適的候選人。
# 1. 擴(kuò)展數(shù)據(jù)模型
class AIResumeAnalysis(BaseModel):
# ... 原有字段 ...
expected_salary_min: int = Field(default=0, descriptinotallow="期望月薪下限(K)")
expected_salary_max: int = Field(default=0, descriptinotallow="期望月薪上限(K)")
# 2. 在Prompt中增加提取指令(analysis_prompts.py)
# "從簡(jiǎn)歷中提取薪資期望,統(tǒng)一轉(zhuǎn)為月薪,如'20-25K'或'年薪30萬(wàn)'→25K"
# 3. 在UI中顯示匹配狀態(tài)
if candidate.expected_salary_max <= position.salary_budget_max:
st.success(f"? 預(yù)算內(nèi) ({candidate.expected_salary_min}-{candidate.expected_salary_max}K)")核心實(shí)現(xiàn)思路,是在大模型分析的 Pydantic 模型中增加薪資字段,在 Prompt 中增加提取指令,大模型會(huì)自動(dòng)識(shí)別各種薪資表述("20-25K"、"年薪 30 萬(wàn)"等)并歸一化為統(tǒng)一格式。
4.3AI 生成面試問(wèn)題清單
篩選出候選人后需要準(zhǔn)備面試問(wèn)題,要仔細(xì)讀簡(jiǎn)歷找出可以深挖的點(diǎn)。如果系統(tǒng)能根據(jù)簡(jiǎn)歷和崗位要求自動(dòng)生成針對(duì)性的面試問(wèn)題,可以大幅提升面試準(zhǔn)備效率。
# 生成面試問(wèn)題的核心Prompt
PROMPT = """基于簡(jiǎn)歷和崗位要求,生成面試問(wèn)題:
- 能力驗(yàn)證: 驗(yàn)證簡(jiǎn)歷中的技能是否真實(shí)
- 項(xiàng)目深挖: 了解關(guān)鍵項(xiàng)目的實(shí)際貢獻(xiàn)
- 短板確認(rèn): 針對(duì)"{key_concerns}"提問(wèn)
輸出JSON: {{"ability": [...], "project": [...], "weakness": [...]}}
"""
# 調(diào)用LLM生成
response = llm.complete(PROMPT.format(key_cnotallow=candidate.ai_concerns))
questions = json.loads(response.text)
# 在詳情頁(yè)展示
st.subheader("?? AI生成的面試問(wèn)題")
for category, qs in questions.items():
for q in qs:
st.write(f"? {q}")核心實(shí)現(xiàn)思路是設(shè)計(jì)一個(gè)專(zhuān)門(mén)的 Prompt,讓 LLM 基于簡(jiǎn)歷內(nèi)容和分析結(jié)果,生成分類(lèi)的面試問(wèn)題(能力驗(yàn)證、項(xiàng)目深挖、短板確認(rèn)等)。
5、寫(xiě)在最后
現(xiàn)在,當(dāng)大家談?wù)摯竽P推髽I(yè)應(yīng)用落地的時(shí)候,默認(rèn)的潛臺(tái)詞都是企業(yè)主導(dǎo)、集中部署。而現(xiàn)實(shí)情況是,企業(yè)去落地一個(gè)面向于不同部門(mén)的大模型應(yīng)用,是一個(gè)道阻且長(zhǎng)的過(guò)程。但實(shí)際上像 HR、律師、會(huì)計(jì)這類(lèi)專(zhuān)業(yè)工作者,每天也在做大量重復(fù)勞動(dòng)。如果能把這套系統(tǒng)打包成一個(gè)開(kāi)箱即用的桌面應(yīng)用,內(nèi)置嵌入模型、向量數(shù)據(jù)庫(kù)、開(kāi)源 LLM,完全本地運(yùn)行,不依賴(lài)云服務(wù),可以更加短平快的的給很多崗位帶來(lái)提效或者解放雙手。
這個(gè)項(xiàng)目中演示時(shí)使用的 qwen3:8b(Q4 量化版)5.2GB大小,在我的 24GB 內(nèi)存 MacBook 上,首字響應(yīng)時(shí)間大概 5 秒,后續(xù) token 生成速度相對(duì)比較流暢。但這個(gè)性能對(duì)大部分 HR 的工作電腦來(lái)說(shuō)是個(gè)不小的挑戰(zhàn)。DeepSeek 年初開(kāi)源的蒸餾后的小尺寸模型,在智力水平和電腦性能要求上都不夠?qū)嵱茫F(xiàn)在似乎重新來(lái)到了端側(cè)模型應(yīng)用重新爆發(fā)的臨界點(diǎn)。一方面,1.5B-3B 的小模型配合垂直領(lǐng)域微調(diào),完全可以勝任簡(jiǎn)歷篩選這類(lèi)專(zhuān)業(yè)場(chǎng)景。其次,更優(yōu)秀的小尺寸開(kāi)源模型更新的速度我想也會(huì)超出大家的預(yù)期。
從創(chuàng)業(yè)視角來(lái)看,這不僅是 2B 市場(chǎng)的機(jī)會(huì),更是面向?qū)I(yè)個(gè)人用戶(hù)(像 Cursor 面向開(kāi)發(fā)者那樣)的增量市場(chǎng)。讓專(zhuān)業(yè)工作者都能擁有自己的大模型應(yīng)用助手,而不是一味的等待企業(yè)采購(gòu),這或許是接下來(lái)非常值得保持關(guān)注的長(zhǎng)尾需求。


































