Chuyển đến nội dung chính

第8課:RAG代理 — 建構與評估

建構RAG Agent:結合檢索 + 工具 + 推理。 多輪對話式RAG。 評估指標:忠實度、相關性、準確性。 LLM-as-judge評估模式。 DLI S-FX-15考試準備。

1. 從RAG Pipeline到RAG Agent

1.1. 靜態RAG的限制

第7課建構了一個靜態RAG pipeline — 檢索K份文件 → 塞入提示詞 → LLM回答。這個pipeline對簡單問題效果不錯,但有三個嚴重限制:

  • 沒有推理能力 — pipeline總是先檢索再生成;它不知道「是否需要檢索?」或「是否需要額外步驟?」
  • 沒有工具使用 — 只有一個資料來源(向量資料庫)。無法呼叫API、執行計算或進行網路搜尋。
  • 單次檢索 — 只檢索一次。如果結果不足 → 無法用不同查詢重新檢索。
  • 沒有記憶 — 每個問題獨立處理。無法記住前面問題的上下文。

1.2. RAG Agent — 將檢索作為工具

RAG Agent將靜態RAG升級,把檢索轉變為LLM可以選擇使用的眾多工具之一。Agent可以:

  • 決定何時檢索 — 如果已經知道答案 → 不需要檢索
  • 選擇使用哪個工具 — 檢索器用於內部文件、網路搜尋用於新聞、計算器用於數學運算
  • 迭代推理 — 檢索 → 發現資訊不足 → 用不同查詢重新檢索
  • 綜合多個來源 — 結合多個工具的結果

1.3. ReAct模式 — Thought → Action → Observation

ReAct(Reasoning + Acting)是LLM代理最流行的模式。LLM執行一個迴圈:思考(Thought)→ 選擇動作(Action)→ 觀察結果(Observation)→ 重複直到得出最終答案。


ReAct Agent Loop — RAG Agent 決策流程
══════════════════════════════════════════════════════════════════

  使用者:"比較公司Q3營收與行業平均值"
       │
       ▼
  ┌─────────────────────────────────────────────────────────┐
  │  THOUGHT: 需要2項資訊 — 內部營收 +                       │
  │           行業平均值                                     │
  │  ACTION:  retriever_tool("Q3公司營收")                   │
  └────────────────────────┬────────────────────────────────┘
                           │
                           ▼
  ┌─────────────────────────────────────────────────────────┐
  │  OBSERVATION: "Q3營收:1.5億"                            │
  │  THOUGHT: 已有內部營收。需要行業平均值 →                   │
  │           網路搜尋                                       │
  │  ACTION:  web_search_tool("2025年Q3科技行業              │
  │           平均營收")                                     │
  └────────────────────────┬────────────────────────────────┘
                           │
                           ▼
  ┌─────────────────────────────────────────────────────────┐
  │  OBSERVATION: "科技行業Q3平均值:1.2億"                    │
  │  THOUGHT: 資料齊全。需要比較 → 計算                       │
  │  ACTION:  calculator_tool("(150 - 120) / 120 * 100")   │
  └────────────────────────┬────────────────────────────────┘
                           │
                           ▼
  ┌─────────────────────────────────────────────────────────┐
  │  OBSERVATION: "25.0"                                    │
  │  THOUGHT: 所有資料齊全。綜合回答。                         │
  │  FINAL ANSWER: "公司Q3營收(1.5億)比行業平均值            │
  │   (1.2億)高25%。"                                      │
  └─────────────────────────────────────────────────────────┘

考試提示:「LLM自主決定何時檢索、何時使用其他工具」→ 答案是Agent(不是靜態RAG chain)。「哪種模式允許LLM迭代推理+行動?」→ ReAct。關鍵區別:Chain = 固定序列,Agent = 動態決策。

特性靜態RAG ChainRAG Agent
執行流程固定:檢索 → 生成動態:LLM決定每一步
工具僅1個檢索器多個:檢索器、搜尋、計算、API...
推理無 — 總是檢索ReAct迴圈:Thought → Action → Observation
多步驟單次檢索可用不同查詢多次檢索
記憶無狀態可維護對話歷史
複雜度簡單、可預測強大但較難除錯
延遲低(1次LLM呼叫)較高(多次LLM呼叫)
使用場景簡單的文件問答需要多個來源的複雜任務
RAG Agent與評估 — Agent迴圈、工具、LLM-as-Judge指標
RAG Agent與評估 — Agent迴圈、工具、LLM-as-Judge指標

2. 使用LangChain建構RAG Agent

2.1. 定義工具

第一步:定義Agent可以使用的工具。每個工具有一個名稱、描述(LLM讀取描述來決定使用哪個工具)和一個函式。


from langchain_nvidia_ai_endpoints import ChatNVIDIA, NVIDIAEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
from langchain.tools.retriever import create_retriever_tool
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.tools import tool

# === 工具1:文件檢索器 ===
loader = PyPDFLoader("company_docs.pdf")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
embeddings = NVIDIAEmbeddings(model="NV-Embed-QA")
vectorstore = FAISS.from_documents(chunks, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

retriever_tool = create_retriever_tool(
    retriever,
    name="company_docs_search",
    description="Search for information in internal company documents. "
                "Use when the user asks about policies, processes, or HR matters."
)

# === 工具2:網路搜尋 ===
web_search_tool = TavilySearchResults(
    max_results=3,
    description="Search for information on the internet. "
                "Use when you need recent news, market data, "
                "or information not available in internal documents."
)

# === 工具3:計算器 ===
@tool
def calculator_tool(expression: str) -> str:
    """Calculate mathematical expressions. Use when you need to compute
    percentages, compare figures, or perform arithmetic."""
    try:
        result = eval(expression)  # Production: use numexpr or sympy
        return str(result)
    except Exception as e:
        return f"Calculation error: {e}"

# 工具列表
tools = [retriever_tool, web_search_tool, calculator_tool]

考試提示:工具描述至關重要 — LLM讀取描述來決定使用哪個工具。描述模糊 → Agent選錯工具。考試可能會問:「Agent選錯工具,根本原因是什麼?」→ 檢查工具描述。

2.2. 建立Agent — Tool Calling Agent

LangChain提供兩種建立Agent的方式:create_react_agent(基於ReAct提示詞)和create_tool_calling_agent(使用原生tool calling API)。對於NVIDIA NIM / OpenAI相容模型,優先使用tool calling agent。


from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# 支援tool calling的LLM
llm = ChatNVIDIA(
    model="meta/llama-3.1-70b-instruct",
    temperature=0.1
)

# Agent提示詞 — 必須包含 {agent_scratchpad}
prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an intelligent company assistant. Use tools
to find accurate information. Always cite your sources.
If you can't find the information → clearly state "Information not found."
Never fabricate information."""),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

# 建立Agent
agent = create_tool_calling_agent(llm, tools, prompt)

# AgentExecutor:執行Agent迴圈
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,           # 顯示ReAct迴圈
    max_iterations=5,       # 限制迭代次數(防止無限迴圈!)
    handle_parsing_errors=True,
    return_intermediate_steps=True  # 除錯:查看使用了哪些工具
)

2.3. 執行Agent


# === 查詢1:需要檢索 ===
result = agent_executor.invoke({
    "input": "What is the company's leave policy?"
})
print(result["output"])
# Agent將:Thought → 使用company_docs_search → 回答

# === 查詢2:需要網路搜尋 ===
result = agent_executor.invoke({
    "input": "What is NVIDIA's stock price today?"
})
# Agent將:Thought → 使用web_search → 回答

# === 查詢3:多工具 ===
result = agent_executor.invoke({
    "input": "Compare Q3 company revenue with the VN tech industry average"
})
# Agent將:retriever → web_search → calculator → 綜合

# === 查看中間步驟(除錯) ===
for step in result["intermediate_steps"]:
    action, observation = step
    print(f"Tool: {action.tool}")
    print(f"Input: {action.tool_input}")
    print(f"Output: {observation[:100]}...")
    print("---")

2.4. 工具選擇邏輯

LLM根據使用者查詢與工具描述之間的語義匹配來選擇工具。過程如下:


工具選擇 — LLM如何選擇工具
═══════════════════════════════════════════════════════

  使用者查詢:"Q3營收是多少?"
       │
       ▼
  ┌──────────────────────────────────────────────────┐
  │  LLM讀取工具描述:                                │
  │                                                  │
  │  1. company_docs_search:                         │
  │     "搜尋內部公司文件。                             │
  │      用於政策、流程..."                             │
  │     → 相關性:高 ✅(內部 + 數據)                  │
  │                                                  │
  │  2. web_search:                                  │
  │     "搜尋網路。用於新聞、                           │
  │      市場資料..."                                  │
  │     → 相關性:中(如果文件中找不到                   │
  │       可能需要)                                   │
  │                                                  │
  │  3. calculator:                                  │
  │     "計算數學運算式..."                             │
  │     → 相關性:低(目前不需要計算)                   │
  └──────────────────────┬───────────────────────────┘
                         │
                         ▼
          選擇:company_docs_search ✅

考試提示:「Agent呼叫了錯誤的工具」→ 檢查工具描述是否足夠清晰。「Agent呼叫工具太多次(無限迴圈)」→ 設定max_iterations。AgentExecutor兩個最重要的參數:max_iterations(預設15,應限制為5-10)和handle_parsing_errors=True。

3. 多輪對話式RAG

3.1. 問題:沒有記憶

靜態RAG pipeline獨立處理每個查詢。當使用者提出後續問題時,pipeline無法理解上下文:


沒有聊天歷史的問題
═══════════════════════════════════════════════

  使用者:"請假政策是什麼?"
  機器人:"員工每年有12天假期..."              ✅

  使用者:"病假呢?"                   ← 後續問題
  機器人:???                          ← "病假"缺乏上下文
                                        檢索器搜尋"病假"
                                        → 可能遺漏相關文件

  使用者:"有薪嗎?"                   ← "有薪"→ 什麼有薪?
  機器人:???                          ← 上下文完全遺失

3.2. 問題脈絡化 — 根據歷史重寫問題

解決方案:在檢索之前,重寫問題以包含對話歷史中的上下文。「病假呢?」→「公司的病假政策是什麼?是否有薪?」


from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain.chains import create_history_aware_retriever

# 根據聊天歷史重寫問題的提示詞
contextualize_q_prompt = ChatPromptTemplate.from_messages([
    ("system", """Given the conversation history and the latest question,
rewrite the question as a standalone question that can be understood
without the previous context.
Do NOT answer the question — only rewrite if needed, or keep as-is."""),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.1)

# 歷史感知檢索器:重寫查詢 → 檢索
history_aware_retriever = create_history_aware_retriever(
    llm, retriever, contextualize_q_prompt
)

3.3. 完整的對話式RAG Chain


from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.messages import HumanMessage, AIMessage

# QA提示詞
qa_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an AI assistant. Answer based on the provided context.
If not found → say "Not found in the documents."

Context:
{context}"""),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

# Chain:stuff documents
question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)

# 完整的對話式RAG chain
rag_chain = create_retrieval_chain(
    history_aware_retriever, question_answer_chain
)

# === 多輪對話 ===
chat_history = []

# 第1輪
response1 = rag_chain.invoke({
    "input": "What is the leave policy?",
    "chat_history": chat_history
})
print(response1["answer"])
# → "員工每年有12天假期..."

chat_history.extend([
    HumanMessage(content="What is the leave policy?"),
    AIMessage(content=response1["answer"])
])

# 第2輪 — 後續問題
response2 = rag_chain.invoke({
    "input": "What about sick leave?",
    "chat_history": chat_history
})
print(response2["answer"])
# 問題被重寫為:"公司的病假政策是什麼?"
# → 檢索更精確!

3.4. 自動管理歷史:RunnableWithMessageHistory


from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

# 儲存session歷史
session_store = {}

def get_session_history(session_id: str):
    if session_id not in session_store:
        session_store[session_id] = ChatMessageHistory()
    return session_store[session_id]

# 用訊息歷史管理包裝chain
conversational_rag = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history",
    output_messages_key="answer",
)

# 使用方式 — 歷史由session_id自動管理
config = {"configurable": {"session_id": "user-123"}}

r1 = conversational_rag.invoke(
    {"input": "What is the leave policy?"},
    config=config
)
# 自動儲存歷史

r2 = conversational_rag.invoke(
    {"input": "What about sick leave?"},        # 根據歷史自動重寫
    config=config
)

多輪對話式RAG流程
════════════════════════════════════════════════════════════════════

  使用者:"請假政策是什麼?"     session_id: "user-123"
       │
       ▼
  ┌──────────────────┐     歷史:[](空)
  │ 問題脈絡化       │───► 獨立問題:"請假政策是什麼?"
  └────────┬─────────┘     (保持原樣,不需要重寫)
           │
           ▼
  ┌──────────────────┐
  │  檢索器          │───► 4個關於請假政策的文件片段
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │  LLM生成         │───► "員工每年有12天假期..."
  └────────┬─────────┘
           │
           ▼
  儲存至歷史:[Human: "請假政策是...", AI: "員工..."]

  ─────────────────────────────────────────────────────────────

  使用者:"病假呢?"              session_id: "user-123"
       │
       ▼
  ┌──────────────────┐     歷史:[請假政策問答]
  │ 問題脈絡化       │───► 重寫:"公司的病假政策
  └────────┬─────────┘              是什麼?"
           │
           ▼
  ┌──────────────────┐
  │  檢索器          │───► 用重寫的查詢搜尋 → 更精確!
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │  LLM生成         │───► "病假:每年最多30天..."
  └──────────────────┘

考試提示:「使用者提出後續問題但檢索器返回錯誤結果」→ 缺少歷史感知檢索器(需要在檢索前將問題脈絡化)。「管理多session聊天歷史」→ RunnableWithMessageHistory + session_id。DLI考試可能會問脈絡化提示詞的角色 — 要強調:重寫為獨立問題,不要回答。

4. RAG評估指標

4.1. 為什麼需要評估?

「結果看起來還不錯」對生產環境來說是不夠的。你需要系統性評估來衡量RAG pipeline品質,並比較不同配置(chunk size、embedding模型、檢索器類型...)。

4.2. 四個關鍵指標

指標衡量什麼?如何計算可接受閾值
忠實度(Faithfulness)答案是否「捏造」?答案中所有聲明是否都存在於上下文中?將答案拆分為聲明 → 逐一比對上下文≥ 0.85
答案相關性(Answer Relevance)答案是否確實回應了問題?從答案生成問題 → 與原始問題比較餘弦相似度≥ 0.80
上下文精確度(Context Precision)檢索的文件是否相關?(精確率)檢索的文件中實際相關的數量 / 總檢索文件數≥ 0.75
上下文召回率(Context Recall)是否檢索到足夠的必要文件?(召回率)標準答案中有多少聲明可以追溯到檢索的文件≥ 0.80

RAG評估 — 每個指標衡量什麼
════════════════════════════════════════════════════════════════

  問題:"退款政策是什麼?"

  檢索的上下文(3份文件):
  ┌─────────────────────────────────────────────────────────┐
  │ 文件1:"30天內可憑收據退款"                          ✅   │
  │ 文件2:"產品必須保持原始密封包裝"                     ✅   │
  │ 文件3:"本週餐廳菜單"                               ❌   │
  └─────────────────────────────────────────────────────────┘
  上下文精確度 = 2/3 = 0.67 ← 文件3不相關!

  標準答案:"30天內退款,需收據,原始密封,
                 透過email聯繫客服"
  檢索涵蓋:退款 ✅、收據 ✅、密封 ✅、email ❌
  上下文召回率 = 3/4 = 0.75  ← 缺少email相關資訊

  生成的答案:"如有收據且產品仍密封,
                     可在30天內退款。"
  聲明:[30天 ✅、收據 ✅、密封 ✅]
  忠實度 = 3/3 = 1.0    ← 所有聲明都有依據!

  答案是否回應了問題?→ 是,但不完整
  答案相關性 ≈ 0.85     ← 相關但缺少email細節

4.3. RAGAS框架

RAGAS(Retrieval Augmented Generation Assessment)是最受歡迎的RAG評估開源框架。RAGAS自動計算上述所有4個指標,無需人工標註(使用LLM進行評估)。


from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# 準備評估資料集
eval_data = {
    "question": [
        "What is the refund policy?",
        "How many days of leave?"
    ],
    "answer": [
        "Refund within 30 days with receipt.",
        "Employees get 12 days of leave per year."
    ],
    "contexts": [
        ["Refund within 30 days with original receipt.", "Product must be sealed."],
        ["Full-time employees: 12 days leave/year.", "Probation: 1 day/month."]
    ],
    "ground_truth": [
        "Customers can get a refund within 30 days with original receipt and sealed product.",
        "Full-time employees get 12 days leave/year, probation 1 day/month."
    ]
}

dataset = Dataset.from_dict(eval_data)

# 評估!
results = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)

print(results)
# {'faithfulness': 0.95, 'answer_relevancy': 0.88,
#  'context_precision': 0.83, 'context_recall': 0.75}

# 轉換為pandas進行詳細分析
df = results.to_pandas()
print(df)

考試提示:「答案包含不在檢索上下文中的資訊」→ 低忠實度。「檢索的文件與問題不相關」→ 低上下文精確度。「答案正確但未回應問題」→ 低答案相關性。「缺少重要文件」→ 低上下文召回率。最受歡迎的RAG評估框架 → RAGAS。

5. LLM-as-Judge評估

5.1. 為什麼使用LLM-as-Judge?

人工評估(人類評審)準確但無法規模化:1000個答案 × 3位標註者 = 3000次審查。LLM-as-Judge使用更強(或同等級)的LLM來自動評估另一個LLM的輸出。

評估方法優點缺點
人工評估黃金標準、細緻入微昂貴、緩慢、無法規模化
自動指標(BLEU、ROUGE)快速、便宜、可重現無法捕捉語義品質
LLM-as-Judge可規模化、捕捉語義偏差、judge LLM成本、不完美
RAGAS(基於LLM)自動化、多指標依賴judge LLM品質

5.2. 評估提示詞範本


from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser

# 忠實度評估器提示詞
faithfulness_eval_prompt = ChatPromptTemplate.from_template("""
You are an impartial judge evaluating the faithfulness of an AI answer.

**Faithfulness** means every claim in the answer must be supported by
the provided context. The answer should NOT contain information
that cannot be traced back to the context.

**Context:**
{context}

**Question:**
{question}

**Answer to evaluate:**
{answer}

Evaluate step by step:
1. List all claims made in the answer.
2. For each claim, check if it is supported by the context.
3. Count supported claims vs total claims.

Respond in JSON format:
{{
  "claims": [
    {{"claim": "...", "supported": true/false, "evidence": "..."}}
  ],
  "faithfulness_score": ,
  "reasoning": "..."
}}
""")

# Judge LLM — 使用可用的最強模型
judge_llm = ChatNVIDIA(
    model="meta/llama-3.1-70b-instruct",
    temperature=0.0           # temperature=0以獲得一致的評估結果
)

faithfulness_chain = faithfulness_eval_prompt | judge_llm | JsonOutputParser()

# 評估一個回應
eval_result = faithfulness_chain.invoke({
    "context": "The company offers refunds within 30 days with original receipt.",
    "question": "What is the refund policy?",
    "answer": "Refund within 30 days with receipt. Contact CS via email."
})

print(eval_result)
# {
#   "claims": [
#     {"claim": "Refund within 30 days", "supported": true, ...},
#     {"claim": "need receipt", "supported": true, ...},
#     {"claim": "Contact CS via email", "supported": false, ...}  ← 幻覺!
#   ],
#   "faithfulness_score": 0.67,
#   "reasoning": "2/3 claims supported. 'Contact CS via email' not in context."
# }

5.3. 成對比較 — A vs B比較

與絕對評分不同,成對比較評估兩個輸出並選出較好的一個。此方法比絕對評分更不容易產生偏差。


pairwise_prompt = ChatPromptTemplate.from_template("""
You are comparing two AI responses to the same question.

**Question:** {question}
**Context:** {context}

**Response A:**
{response_a}

**Response B:**
{response_b}

Compare on these criteria:
1. Faithfulness: grounded in context?
2. Completeness: covers all relevant info?
3. Clarity: well-structured and easy to understand?

Choose the better response. Respond in JSON:
{{
  "winner": "A" or "B" or "TIE",
  "criteria_scores": {{
    "faithfulness": {{"A": <1-5>, "B": <1-5>}},
    "completeness": {{"A": <1-5>, "B": <1-5>}},
    "clarity": {{"A": <1-5>, "B": <1-5>}}
  }},
  "reasoning": "..."
}}
""")

pairwise_chain = pairwise_prompt | judge_llm | JsonOutputParser()

5.4. LLM-as-Judge的限制

  • 冗長偏差 — LLM評審傾向給較長的輸出更高分數,即使較短的答案更好
  • 位置偏差 — 在成對評估中,傾向偏好第一個輸出(A > B)。修正:評估兩次並交換A↔B位置
  • 自我增強偏差 — LLM評審偏好自己的輸出。使用不同模型作為評審
  • 推理能力有限 — 評審可能在專業領域(醫療、法律)忽略細微錯誤

減緩LLM-as-Judge偏差
══════════════════════════════════════════

  位置偏差修正:
  ┌──────────────────────────────┐
  │  第1輪:A在前、B在後         │──► 第1輪贏家:A
  │  第2輪:B在前、A在後         │──► 第2輪贏家:A
  │  最終:一致 → A獲勝          │
  │  (如果不一致 → 平手)        │
  └──────────────────────────────┘

  冗長偏差修正:
  ┌──────────────────────────────┐
  │  提示詞:"根據準確性和          │
  │  簡潔性進行評估。              │
  │  更長 ≠ 更好。"               │
  └──────────────────────────────┘

考試提示:「大規模評估LLM輸出」→ LLM-as-Judge。「LLM評審偏好較長的答案」→ 冗長偏差。「LLM評審偏好成對中的第一個選項」→ 位置偏差。修正位置偏差 → 交換位置後取平均。DLI考試經常問:「哪種評估方法最具規模化能力?」→ LLM-as-Judge(不是人工評估)。

6. 考試準備 — DLI S-FX-15

6.1. S-FX-15考試概述

課程S-FX-15:「Generative AI with Diffusion Models and Large Language Models」以Jupyter notebook中的實作考核作為結束。你需要在時間限制內完成編程任務。

方面詳情
形式Jupyter notebook — 填寫程式碼儲存格,執行測試
時間約2小時(在實驗室session內)
通過條件完成所有必要的儲存格 + 正確輸出
可用工具課程notebook、NVIDIA文件(在DLI環境內)
重考如未通過可重考(依DLI政策)

6.2. 涵蓋的關鍵領域

S-FX-15考核涵蓋課程三個部分的關鍵領域:

部分關鍵主題可能的考核任務
第1部分:Generative AI基礎擴散模型、VAE、GAN設定擴散pipeline、生成圖像
第2部分:LLM核心Transformer、tokenizer、PEFT、推論載入模型、tokenize、LoRA微調、推論參數
第3部分:RAG與應用RAG pipeline、agent、評估建構RAG、實作評估、加入guardrails

6.3. 時間管理策略


S-FX-15 時間管理
════════════════════════════════════════════

  總計:約120分鐘

  ┌─────────────────────────────────────┐
  │  0-10分鐘:閱讀整個notebook        │ ← 不要馬上開始寫程式!
  │            標記簡單/困難的儲存格    │
  │            確認依賴關係             │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │  10-50分鐘:先做簡單的儲存格       │ ← 先取得容易的分數
  │             Import、設定、配置      │
  │             直接明瞭的任務          │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │  50-100分鐘:困難的儲存格          │ ← RAG pipeline、評估
  │              多步驟任務             │
  │              需要時進行除錯         │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │  100-120分鐘:檢查與修正           │ ← 從頭到尾執行所有儲存格
  │               確認輸出正確         │
  │               修正任何錯誤         │
  └─────────────────────────────────────┘

6.4. 常見錯誤 — 避免這些

錯誤後果修正方法
切割時忘記chunk_overlap上下文在邊界處被截斷 → 答案品質差始終設定overlap = chunk_size的10-20%
檢索器和嵌入使用不同的embedding模型維度不匹配 → 崩潰嵌入和檢索使用相同模型
評估時未設定temperature=0評估結果不可重現評估任務:temperature=0
Agent無限迴圈逾時、儲存格失敗設定max_iterations=5
忘記handle_parsing_errors=TrueLLM返回錯誤格式時Agent崩潰始終啟用此標誌
RAG提示詞中未正確格式化上下文LLM忽略上下文 → 產生幻覺在提示詞範本中清楚分隔{context}
不按順序執行儲存格變數未定義錯誤從上到下執行,或「Restart & Run All」
忘記安裝套件Import錯誤先執行!pip install儲存格

6.5. 通過考試的技巧

  • 仔細閱讀說明 — 每個儲存格通常有註解說明TODO。寫程式前要詳細閱讀。
  • 課程notebook是你的參考 — 考核任務通常是課程練習的變體。參考已完成的notebook。
  • NVIDIA API模式 — 記住如何匯入和初始化:ChatNVIDIA(model=...)、NVIDIAEmbeddings(model=...)。
  • 逐一測試儲存格 — 寫完後立即執行儲存格,不要等到全部寫完。
  • 輸出格式很重要 — 如果說明要求返回dict → 返回dict,不是string。

考試提示:S-FX-15考核著重實作編程,非選擇題。優先複習:RAG pipeline設定(幾乎必考)、PEFT/LoRA配置、擴散pipeline。參考課程notebook — 考核通常需要類似任務但使用不同資料/模型。

7. 速查表

概念關鍵要點
靜態RAG vs AgentChain = 固定流程;Agent = 動態,LLM決定
ReAct模式Thought → Action → Observation迴圈
工具描述LLM根據描述選擇工具 — 必須清晰!
create_tool_calling_agent使用原生tool calling API(NVIDIA NIM優先選擇)
AgentExecutor max_iterations預設15,應設為5-10以防止無限迴圈
handle_parsing_errors始終設True — 防止LLM返回錯誤格式時崩潰
歷史感知檢索器在檢索前將後續查詢重寫為獨立問題
RunnableWithMessageHistory透過session_id自動管理聊天歷史
忠實度(Faithfulness)答案是否有上下文依據?(≥ 0.85)
答案相關性(Answer Relevance)答案是否回應了問題?(≥ 0.80)
上下文精確度(Context Precision)檢索的文件是否相關?(≥ 0.75)
上下文召回率(Context Recall)是否檢索到足夠的文件?(≥ 0.80)
RAGAS衡量上述4個指標的框架,使用LLM評估
LLM-as-Judge使用強大的LLM評估另一個LLM的輸出 — 可規模化
冗長偏差評審偏好較長的答案
位置偏差評審偏好第一個選項 → 交換A↔B後取平均
S-FX-15形式實作Jupyter notebook,約2小時,基於編程
S-FX-15策略全部閱讀 → 先做簡單的 → 再做困難的 → 檢查

8. 練習題 — 編程

Q1:建構包含檢索器 + 網路搜尋的RAG Agent

建構一個包含2個工具的RAG Agent:retriever_tool(搜尋內部文件)和web_search_tool(搜尋網路)。Agent必須自主決定何時使用哪個工具。印出中間步驟以查看工具選擇邏輯。

顯示答案 Q1

from langchain_nvidia_ai_endpoints import ChatNVIDIA, NVIDIAEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.tools.retriever import create_retriever_tool
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# === 設定檢索器 ===
from langchain_core.documents import Document
docs = [
    Document(page_content="Employees get 12 days of leave per year. Probation: 1 day/month.",
             metadata={"source": "hr_policy.pdf"}),
    Document(page_content="Refund within 30 days with original receipt. Product must be sealed.",
             metadata={"source": "refund_policy.pdf"}),
]

embeddings = NVIDIAEmbeddings(model="NV-Embed-QA")
vectorstore = FAISS.from_documents(docs, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})

# === 定義工具 ===
retriever_tool = create_retriever_tool(
    retriever,
    name="internal_docs_search",
    description="Search internal company documents: HR policies, "
                "processes, refunds. Use for internal company questions."
)

web_search_tool = TavilySearchResults(
    max_results=3,
    description="Search the internet. Use when you need external information: "
                "news, stock prices, market data, public information."
)

tools = [retriever_tool, web_search_tool]

# === 建立Agent ===
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.1)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are an AI assistant. Use tools to find accurate information. "
               "Cite your sources when answering."),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=5,
    handle_parsing_errors=True,
    return_intermediate_steps=True,
)

# === 測試:內部問題 → 應使用retriever ===
result1 = agent_executor.invoke({"input": "What is the leave policy?"})
print("Answer:", result1["output"])
print("\nTools used:")
for step in result1["intermediate_steps"]:
    print(f"  → {step[0].tool}: {step[0].tool_input}")

# === 測試:外部問題 → 應使用web search ===
result2 = agent_executor.invoke({"input": "NVIDIA stock price today?"})
print("Answer:", result2["output"])
print("\nTools used:")
for step in result2["intermediate_steps"]:
    print(f"  → {step[0].tool}: {step[0].tool_input}")

工具選擇說明:Agent讀取工具描述。「Leave policy」匹配「internal company documents, HR policies」→ 選擇internal_docs_search。「NVIDIA stock price」匹配「news, stock prices, market data」→ 選擇web_search。

Q2:實作歷史感知檢索器以支援多輪RAG

建構一個能理解後續問題的對話式RAG pipeline。用3輪對話進行測試:原始問題 → 後續問題 → 再一個後續問題。

顯示答案 Q2

from langchain_nvidia_ai_endpoints import ChatNVIDIA, NVIDIAEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

# === 設定 ===
docs = [
    Document(page_content="Full-time employees: 12 days leave/year. Can carry over max 5 days to next year."),
    Document(page_content="Sick leave: up to 30 days/year with pay. Doctor's note required from day 3."),
    Document(page_content="Maternity leave: 6 months for women, 5 days for men. Per Vietnam labor law."),
]

embeddings = NVIDIAEmbeddings(model="NV-Embed-QA")
vectorstore = FAISS.from_documents(docs, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.1)

# === 歷史感知檢索器 ===
contextualize_prompt = ChatPromptTemplate.from_messages([
    ("system", "Given the conversation history, rewrite the question as a standalone question. "
               "Do NOT answer, only rewrite."),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

history_aware_retriever = create_history_aware_retriever(
    llm, retriever, contextualize_prompt
)

# === QA chain ===
qa_prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer based on context. If not found → say so.\n\n"
               "Context:\n{context}"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

qa_chain = create_stuff_documents_chain(llm, qa_prompt)
rag_chain = create_retrieval_chain(history_aware_retriever, qa_chain)

# === 3輪對話 ===
chat_history = []

# 第1輪
r1 = rag_chain.invoke({"input": "What is the leave policy?", "chat_history": chat_history})
print(f"第1輪:{r1['answer']}")
chat_history.extend([
    HumanMessage(content="What is the leave policy?"),
    AIMessage(content=r1["answer"])
])

# 第2輪 — 後續問題
r2 = rag_chain.invoke({"input": "What about sick leave?", "chat_history": chat_history})
print(f"第2輪:{r2['answer']}")
# "What about sick leave?" → 重寫為:"公司的病假政策是什麼?"
chat_history.extend([
    HumanMessage(content="What about sick leave?"),
    AIMessage(content=r2["answer"])
])

# 第3輪 — 再一個後續問題
r3 = rag_chain.invoke({"input": "Do I need any documents?", "chat_history": chat_history})
print(f"第3輪:{r3['answer']}")
# "Do I need any documents?" → 重寫為:"病假需要什麼文件?"

Q3:計算忠實度分數

實作一個calculate_faithfulness()函式,接收context和answer,使用LLM將答案拆分為聲明,檢查每個聲明是否與上下文一致,並返回忠實度分數[0.0 - 1.0]。

顯示答案 Q3

from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser

def calculate_faithfulness(context: str, answer: str) -> dict:
    """
    計算忠實度分數:答案中有依據的聲明佔比。
    返回:{"score": float, "claims": list, "reasoning": str}
    """
    llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)

    prompt = ChatPromptTemplate.from_template("""
Analyze the faithfulness of the answer given the context.

Context:
{context}

Answer:
{answer}

Steps:
1. Break the answer into individual factual claims.
2. For each claim, determine if it is supported by the context.
3. Calculate: faithfulness_score = supported_claims / total_claims

Return JSON:
{{
  "claims": [
    {{"text": "claim text", "supported": true, "evidence": "quote from context"}},
    {{"text": "claim text", "supported": false, "evidence": "not found"}}
  ],
  "supported_count": ,
  "total_count": ,
  "score": ,
  "reasoning": "summary"
}}
""")

    chain = prompt | llm | JsonOutputParser()
    result = chain.invoke({"context": context, "answer": answer})
    return result


# === 測試 ===
context = (
    "Company ABC offers refunds within 30 days from purchase date. "
    "Customers must present the original receipt. "
    "Product must still be sealed and unused."
)
answer = (
    "Company ABC offers refunds within 30 days with receipt. "
    "Product must be sealed. "
    "Call hotline 1900-xxxx for support."  # ← 不在上下文中!
)

result = calculate_faithfulness(context, answer)
print(f"忠實度分數:{result['score']}")
# 預期:約0.67(3個聲明中有2個有依據)
for claim in result["claims"]:
    status = "✅" if claim["supported"] else "❌"
    print(f"  {status} {claim['text']}")

Q4:實作帶結構化評分標準的LLM-as-Judge評估器

使用LLM-as-Judge模式建構一個評估器,包含3個評分標準:忠實度(1-5)、完整性(1-5)、清晰度(1-5)。評估器接收question、context、answer並返回分數 + 推理。同時實作帶位置交換的成對比較以減少位置偏差。

顯示答案 Q4

from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser

judge_llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)

# === 部分A:單一回應評估 ===
single_eval_prompt = ChatPromptTemplate.from_template("""
You are an expert evaluator. Score the AI response on a 1-5 scale.

**Question:** {question}
**Context:** {context}
**Response:** {response}

**Rubric:**
- Faithfulness (1-5): Is every claim supported by context? 5 = all claims grounded.
- Completeness (1-5): Does it cover all relevant info from context? 5 = comprehensive.
- Clarity (1-5): Well-structured and easy to understand? 5 = excellent.

Return JSON:
{{
  "scores": {{
    "faithfulness": <1-5>,
    "completeness": <1-5>,
    "clarity": <1-5>
  }},
  "overall": ,
  "reasoning": "..."
}}
""")

single_eval_chain = single_eval_prompt | judge_llm | JsonOutputParser()

# === 部分B:帶位置偏差緩解的成對比較 ===
pairwise_prompt = ChatPromptTemplate.from_template("""
Compare two responses. Which is better overall?

**Question:** {question}
**Context:** {context}

**Response 1:**
{response_1}

**Response 2:**
{response_2}

Return JSON:
{{
  "winner": "1" or "2" or "TIE",
  "scores_1": {{"faithfulness": <1-5>, "completeness": <1-5>, "clarity": <1-5>}},
  "scores_2": {{"faithfulness": <1-5>, "completeness": <1-5>, "clarity": <1-5>}},
  "reasoning": "..."
}}
""")

pairwise_chain = pairwise_prompt | judge_llm | JsonOutputParser()


def pairwise_eval_debiased(question, context, resp_a, resp_b):
    """帶位置偏差緩解的成對評估:評估兩次,交換順序。"""

    # 第1輪:A在前
    r1 = pairwise_chain.invoke({
        "question": question, "context": context,
        "response_1": resp_a, "response_2": resp_b
    })

    # 第2輪:B在前(交換)
    r2 = pairwise_chain.invoke({
        "question": question, "context": context,
        "response_1": resp_b, "response_2": resp_a
    })

    # 標準化:將r2的贏家映射回來
    r2_winner_mapped = {"1": "2", "2": "1", "TIE": "TIE"}[r2["winner"]]

    # 決定最終贏家
    if r1["winner"] == r2_winner_mapped:
        final_winner = r1["winner"]  # 一致 → 有信心
        confidence = "高"
    else:
        final_winner = "TIE"         # 不一致 → 可能是位置偏差
        confidence = "低(偵測到位置偏差)"

    return {
        "final_winner": f"回應{'A' if final_winner == '1' else 'B' if final_winner == '2' else '平手'}",
        "confidence": confidence,
        "round1": r1,
        "round2_swapped": r2,
    }


# === 測試 ===
question = "What is the refund policy?"
context = "Refund within 30 days with receipt. Product must be sealed."
resp_a = "Refund within 30 days with receipt and sealed product."
resp_b = "The company supports refunds. Contact the hotline for details."

# 單一評估
score_a = single_eval_chain.invoke({
    "question": question, "context": context, "response": resp_a
})
print(f"回應A總分:{score_a['overall']}")

# 成對比較(去偏差)
comparison = pairwise_eval_debiased(question, context, resp_a, resp_b)
print(f"贏家:{comparison['final_winner']}({comparison['confidence']})")

Q5:除錯 — Agent無限迴圈

以下程式碼有bug:Agent持續呼叫工具而不停止(無限迴圈)。找出根本原因並修正。提示:檢查max_iterations和handle_parsing_errors。


# BUG CODE — 找出並修正問題
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.9)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant."),
    ("human", "{input}"),
    # BUG:缺少agent_scratchpad!
])

agent = create_tool_calling_agent(llm, tools, prompt)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    # BUG:沒有max_iterations → 預設15,太高
    # BUG:沒有handle_parsing_errors → 解析失敗時崩潰
)

result = agent_executor.invoke({"input": "Compare Q3 revenue with industry"})
顯示答案 Q5

# 修正後的程式碼 — 修正了4個bug

llm = ChatNVIDIA(
    model="meta/llama-3.1-70b-instruct",
    temperature=0.1          # 修正1:低temperature → 輸出更穩定
                              # temperature=0.9 → Agent太「有創意」→ 隨機選擇工具
)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant. Use tools to find accurate information."),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
    # 修正2:必須包含agent_scratchpad!
    # 這是LangChain注入Thought/Action/Observation歷史的位置
    # 缺少它 → Agent看不到工具結果 → 無止盡地再次呼叫工具
])

agent = create_tool_calling_agent(llm, tools, prompt)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=5,            # 修正3:限制迭代次數
    handle_parsing_errors=True,  # 修正4:優雅處理解析錯誤
    return_intermediate_steps=True,
)

result = agent_executor.invoke({"input": "Compare Q3 revenue with industry"})

# === 4個bug摘要 ===
# 1. temperature=0.9太高 → 工具選擇不穩定
# 2. 缺少MessagesPlaceholder("agent_scratchpad") → Agent看不到
#    來自工具的觀察結果 → 無限呼叫工具(根本原因!)
# 3. 沒有max_iterations → 如果Agent不收斂就會永遠執行
# 4. 沒有handle_parsing_errors → 解析失敗時崩潰而非重試

根本原因:缺少agent_scratchpad是主要問題。這是LangChain注入Thought/Action/Observation歷史的佔位符。沒有它 → Agent不知道自己已經呼叫過工具 → 無限重複呼叫。max_iterations是安全網,較低的temperature有助於Agent做出更穩定的決策。