Ian Chou's Blog

技能驗證的進化:從暴力 LLM 到智慧分層策略

技能驗證的進化:從暴力 LLM 到智慧分層策略

驗證履歷素材是否覆蓋 JD 技能需求,聽起來簡單——把技能丟給 LLM 判斷就好。但當技能多達 20 項,且每項需要搜尋、判斷、可能重試時,成本和延遲就成了問題。本文分享我們如何用分層策略優化 verify 命令。

問題:驗證一次 JD 要多久?

假設一個 JD 有 15 項技能需求:

暴力做法:
15 技能 × (embedding + LLM 判定) = 15 次 LLM 調用
每次 1-2 秒 → 總計 15-30 秒

如果再加上 Self-RAG 重試(WEAK/MISSING 要再搜一次再判一次):

最壞情況:
15 技能 × 3 次嘗試 = 45 次 LLM 調用

這太慢了,也太貴了。

解決方案:分層驗證

輸入: 15 項技能
        ↓
┌────────────────────────────────────┐
│ Layer 1: Skill Graph 推論          │  ← 0 ms, 0 cost
│ 用已有技能推斷 IMPLIED             │
│ 例:有 LangChain → IMPLIED Python │
│ 結果:3 項直接標 IMPLIED           │
└────────────────────────────────────┘
        ↓ 剩 12 項
┌────────────────────────────────────┐
│ Layer 2: Hybrid Search + NLI       │  ← LLM 調用
│ Vector + BM25 搜索證據             │
│ 壓縮後丟 LLM 判定                  │
└────────────────────────────────────┘
        ↓
┌────────────────────────────────────┐
│ Layer 3: Self-RAG 重試             │  ← 只對 WEAK/MISSING
│ 改寫查詢、放寬條件                 │
│ 最多 2 次                          │
└────────────────────────────────────┘
        ↓
輸出: verifications + summary + improvement_priorities

Layer 1: Skill Graph 推論

原理

如果使用者的素材標籤包含 LangChain,而 Skill Graph 記錄了:

{
  "implies": {
    "LangChain": ["Python", "LLM", "RAG"]
  }
}

那麼當 JD 要求 Python 時,我們可以直接推斷:

「你有 LangChain 素材,LangChain implies Python → Python 是 IMPLIED」

實作

def infer_skill_status(required_skill: str, all_materials: list[dict]) -> str | None:
    """快速推論技能狀態,不需 LLM"""
    possessed = get_possessed_skills_from_materials(all_materials)
    
    # 直接有這技能的素材 → 需要 LLM 確認 COVERED vs WEAK
    if required_skill.lower() in possessed:
        return None
    
    # 檢查是否可從其他技能推斷
    if check_skill_implied(required_skill, possessed):
        return "IMPLIED"
    
    return None  # 需要 LLM

效益

Layer 2: Hybrid Search + Context Compression

單純向量搜尋可能漏掉關鍵字匹配,所以我們用:

最終分數 = 0.7 × vector_similarity + 0.3 × bm25_score

對中文還用 jieba 分詞:

def tokenize(text: str) -> list[str]:
    if is_chinese(text):
        return jieba.cut(text)
    return text.split()

Context Compression

5 段證據原文可能有 2000 tokens,壓縮後只需 300:

def compress_evidence(skill: str, evidence_texts: list[str]) -> str:
    prompt = f"""Summarize these excerpts focusing ONLY on "{skill}":
    {evidence}
    
    - Keep specific numbers and metrics
    - Maximum 500 characters"""
    
    return llm_call(prompt)

為什麼要壓縮?

  1. 減少 token 成本:最終判定的 LLM 調用更便宜
  2. 減少幻覺:無關資訊越少,判定越準確
  3. 加速推理:更短的 context 推理更快

Layer 3: Self-RAG 重試

當 LLM 判定為 WEAK 或 MISSING 時,可能是搜尋沒找對:

# 第一次搜尋
query = "Kubernetes"
results = search(query, limit=5)  # 找到 3 筆弱相關

# 判定結果
status = "WEAK"
reasoning = "Found mentions but no concrete examples"

# Self-RAG: 改寫查詢
refined_query = refine_query("Kubernetes", reasoning)
# → "container orchestration deployment pod management"

# 第二次搜尋(放寬限制)
results = search(refined_query, limit=10)  # 找到 2 筆強相關

# 重新判定
status = "COVERED"  # ✓ 改善了

Query Refinement Prompt

prompt = f"""The search for "{skill}" didn't find good evidence.
Previous results: {reasoning}

Generate an alternative query that might find better evidence.
Think about:
- Related skills or synonyms
- Specific technologies or frameworks
- Common project types

Return ONLY the refined query."""

重試限制

MAX_RETRY_ATTEMPTS = 2

原因:

輸出結構

{
  "verifications": [
    {
      "skill": "Python",
      "status": "COVERED",
      "severity": "-",
      "confidence": 0.92,
      "reasoning": "Multiple projects using Python...",
      "evidence_snippets": ["Built RAG using Python..."],
      "retry_history": [],
      "inferred": false
    },
    {
      "skill": "Kubernetes",
      "status": "IMPLIED",
      "reasoning": "Inferred from skill graph",
      "inferred": true  // ← 標記這是推論來的
    }
  ],
  "summary": {
    "total": 15,
    "covered": 8,
    "implied": 3,
    "weak": 2,
    "missing": 2
  },
  "improvement_priorities": ["GraphQL", "Java", "Docker", "AWS"]
}

improvement_priorities

按嚴重程度排序:

  1. MISSING 排前面(完全沒有)
  2. WEAK 排後面(有但不夠強)

這讓使用者知道該優先補強什麼。

CLI 使用

# 完整驗證(預設)
uv run career-kb verify --jd-id "abc123" --with-strategy

# 快速模式(跳過 Skill Graph)
uv run career-kb verify --skills "Python,React" --fast

# 省錢模式(不壓縮、不重試)
uv run career-kb verify --skills "Python,React" --no-compression --no-retry

輸出範例

     Skill Verification Results     
┏━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ 技能       ┃ 狀態    ┃ 嚴重性 ┃ 證據           ┃ 來源  ┃
┡━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ Python     │ COVERED │   ✅   │ Built RAG...   │ LLM   │
│ React      │ IMPLIED │   🔵   │ -              │ 推論  │
│ Kubernetes │ WEAK    │   🟡   │ Mentioned...   │ LLM+1 │
│ GraphQL    │ MISSING │   🔴   │ -              │ LLM+2 │
└────────────┴─────────┴────────┴────────────────┴───────┘

覆蓋率: 1/4 技能已覆蓋
🔴 核心缺失: 1 項
🟡 需補強: 1 項

改進優先順序: GraphQL, Kubernetes

來源 欄位說明:

效能比較

場景 暴力做法 分層策略
15 技能全 COVERED 15 LLM 12 LLM + 3 推論
5 技能需重試 20 LLM 17 LLM
最壞情況 45 LLM 36 LLM
預估省下 - 20-30%

擴展思路

1. 批次 LLM 調用

目前是逐技能調用,可以改成:

prompt = """Verify these skills against the evidence:
Skills: [Python, React, Kubernetes]
Evidence: {...}

Return JSON with status for each."""

一次調用驗多個,進一步減少延遲。

2. 緩存驗證結果

cache_key = hash(skill + evidence_hash)
if cache_key in cache:
    return cached_result

同樣的技能 + 同樣的素材 = 同樣的結果。

3. 背景預驗證

當有新素材入庫時,背景預先驗證所有歷史 JD 的覆蓋率。

總結

層級 策略 節省
Layer 1 Skill Graph 推論 LLM 調用
Layer 2 Context Compression Token 數
Layer 3 Self-RAG 重試 漏報率

分層策略讓 verify 命令從「每技能都問 LLM」進化為「能推論就推論,必要時才問,問到答案為止」。


Career Knowledge Base 是一個本地優先的履歷知識庫系統,使用 Python + LanceDB + Voyage AI 建構。