FDE 面試準備指南(四):System Design 實戰

這是系列的最後一篇。
System Design 是 FDE 面試最能展現工程深度的地方。
答得好,你就是那個「懂技術也懂業務」的人。


System Design 面試的本質

面試官在問系統設計題的時候,不是要你給出標準答案,而是想看:

  1. 你有沒有釐清問題的習慣(不假設,先問)
  2. 你的 trade-off 思維(不說「最好的方案」,說「在這個場景下我選這個,因為…」)
  3. 你有沒有考慮到生產環境的現實(Auth、Scale、Cost、Failure)

第一題:設計企業知識庫 Chatbot

這是最高頻的考題之一。

題目:設計一個供企業內部使用的 AI 知識庫問答系統,員工可以用自然語言查詢公司政策、產品說明和技術文件。

第一步:釐清需求(你要主動問)

你應該問的問題:

- 同時使用的用戶數量級?(100人 vs 10萬人,差很多)
- 文件量多大?(1GB vs 1TB)
- 需要支援多語言嗎?
- 回答需要引用文件來源嗎?
- 有合規要求嗎?(GDPR、SOC2)
- Latency 要求?(秒級 vs 毫秒級)
- 不同部門能看的文件不同嗎?

最後一個問題很關鍵,決定了你需不需要 RBAC。

第二步:高層架構

用戶
 ↓ HTTPS
API Gateway(Auth Token 驗證 + Rate Limiting)
 ↓
Chatbot Service
 ├── RBAC 權限過濾(這個用戶能看哪些文件?)
 ├── Query Processor(問題前處理)
 │    ├── 意圖分類
 │    └── 查詢改寫(Query Rewriting)
 ├── RAG Engine
 │    ├── Embedding Service
 │    ├── Vector DB(根據 RBAC 過濾結果)
 │    └── Reranker
 ├── LLM(Claude / Gemini)
 └── Response Generator(加入引用來源)
 ↓
Cache Layer(相同問題不重複查)
 ↓
Logging & Monitoring
 ↓
回應給用戶

Authentication:怎麼做

企業場景通常用 SSO + JWT

 1from fastapi import FastAPI, Depends, HTTPException
 2from fastapi.security import HTTPBearer
 3import jwt
 4
 5app = FastAPI()
 6security = HTTPBearer()
 7
 8def verify_token(credentials = Depends(security)) -> dict:
 9    token = credentials.credentials
10    try:
11        payload = jwt.decode(
12            token,
13            settings.JWT_SECRET,
14            algorithms=["HS256"]
15        )
16        return {
17            "user_id": payload["sub"],
18            "department": payload["dept"],
19            "roles": payload["roles"]
20        }
21    except jwt.ExpiredSignatureError:
22        raise HTTPException(status_code=401, detail="Token 已過期")
23    except jwt.InvalidTokenError:
24        raise HTTPException(status_code=401, detail="無效的 Token")
25
26@app.post("/chat")
27async def chat(
28    request: ChatRequest,
29    user: dict = Depends(verify_token)
30):
31    # user 已驗證,包含部門和角色資訊
32    response = await chatbot.answer(
33        question=request.question,
34        user_context=user
35    )
36    return response

RBAC:按角色控制文件存取

 1ROLE_PERMISSIONS = {
 2    "employee": ["public", "general"],
 3    "manager": ["public", "general", "internal"],
 4    "hr": ["public", "general", "internal", "hr_confidential"],
 5    "finance": ["public", "general", "internal", "finance_confidential"],
 6    "admin": ["public", "general", "internal", "hr_confidential", 
 7              "finance_confidential", "executive"]
 8}
 9
10class RBACFilter:
11    def get_allowed_categories(self, user_roles: list[str]) -> list[str]:
12        allowed = set()
13        for role in user_roles:
14            allowed.update(ROLE_PERMISSIONS.get(role, []))
15        return list(allowed)
16    
17    def filter_vector_search(
18        self,
19        query_embedding: list[float],
20        user_roles: list[str],
21        n_results: int = 5
22    ) -> list[dict]:
23        allowed_categories = self.get_allowed_categories(user_roles)
24        
25        # 在向量搜尋時加入過濾條件
26        results = vector_db.query(
27            query_embeddings=[query_embedding],
28            n_results=n_results,
29            where={
30                "category": {"$in": allowed_categories}
31            }
32        )
33        return results

重點:RBAC 要在 Vector DB 查詢時就過濾,不能查出來後再過濾。因為那樣你可能查到 [機密文件、公開文件、機密文件, ...],但因為前幾名都被過濾掉,最後給 LLM 的 context 品質會很差。

RAG 的 Query Rewriting

直接用用戶的原始問題去查向量資料庫,效果有時候不好。

例如用戶問:「我剛入職,想知道年假怎麼算」

這個問題包含太多無關資訊。應該先改寫:

 1async def rewrite_query(original_query: str) -> str:
 2    prompt = f"""
 3    把以下問題改寫成適合搜尋的關鍵詞查詢。
 4    移除個人背景資訊,保留核心問題。
 5    
 6    原始問題:{original_query}
 7    改寫後的搜尋查詢:
 8    """
 9    
10    rewritten = await llm.generate(prompt, temperature=0.1)
11    return rewritten
12
13# 「我剛入職,想知道年假怎麼算」
14# → 「年假計算方式 員工請假規定」

Cache Layer:哪些東西要 Cache

 1import hashlib
 2import json
 3from redis import Redis
 4
 5redis_client = Redis(host="localhost", port=6379)
 6
 7class ChatCache:
 8    def __init__(self, ttl_seconds: int = 3600):
 9        self.ttl = ttl_seconds
10    
11    def _make_key(self, question: str, user_roles: list[str]) -> str:
12        # 相同問題但不同角色,答案可能不同(因為 RBAC)
13        cache_input = json.dumps({
14            "question": question.strip().lower(),
15            "roles": sorted(user_roles)
16        })
17        return f"chat:{hashlib.md5(cache_input.encode()).hexdigest()}"
18    
19    def get(self, question: str, user_roles: list[str]) -> str | None:
20        key = self._make_key(question, user_roles)
21        cached = redis_client.get(key)
22        return cached.decode() if cached else None
23    
24    def set(self, question: str, user_roles: list[str], answer: str):
25        key = self._make_key(question, user_roles)
26        redis_client.setex(key, self.ttl, answer)

Cache 的邊界

  • ✅ 適合 Cache:「年假幾天?」「請假流程?」這類政策問題,答案穩定
  • ❌ 不適合 Cache:「我的訂單狀態?」這類個人化問題,每個人答案不同
  • ❌ 不適合 Cache:需要即時資料的問題(股價、庫存)

Logging:要記錄什麼

 1import structlog
 2from datetime import datetime
 3
 4logger = structlog.get_logger()
 5
 6async def log_chat_event(
 7    user_id: str,
 8    question: str,
 9    answer: str,
10    retrieved_docs: list[str],
11    latency_ms: float,
12    cache_hit: bool,
13    model_name: str,
14    input_tokens: int,
15    output_tokens: int
16):
17    logger.info(
18        "chat_event",
19        timestamp=datetime.utcnow().isoformat(),
20        user_id=user_id,                    # 誰問的(審計用)
21        question_hash=hash(question),        # 不記錄原文,保護隱私
22        answer_length=len(answer),
23        retrieved_doc_ids=retrieved_docs,    # 查了哪些文件(排查幻覺用)
24        latency_ms=latency_ms,              # 性能監控
25        cache_hit=cache_hit,                # Cache 命中率
26        model_name=model_name,
27        cost_usd=(input_tokens * 0.000003 + output_tokens * 0.000015)
28    )

第二題:設計 Internal AI Copilot

題目:設計一個 AI 助理,員工可以用自然語言查詢公司內部數據,例如「今年 Q3 的營收比 Q2 成長了多少?」

這題比知識庫 Chatbot 更難,因為要即時查詢結構化資料,而不是搜尋文件。

為什麼難

知識庫 Chatbot:問題 → 查文件 → 回答

Internal Copilot:問題 → 理解問題的數據意圖 → 查 BigQuery / CRM / ERP → 組合多來源數據 → 回答

高層架構

員工:「今年營收比去年成長多少?」
 ↓
Intent Classifier(這是數據查詢,不是文件查詢)
 ↓
NL2SQL Agent(把問題轉成 SQL)
 ↓
Tool Router
 ├── BigQuery Tool(財務、銷售數據)
 ├── CRM Tool(客戶、訂單數據)
 ├── ERP Tool(庫存、供應鏈)
 └── HR System Tool(員工數據)
 ↓
Data Aggregator(整合多來源結果)
 ↓
LLM(把數字轉成自然語言回答)
 ↓
Response(含數據來源標示)

NL2SQL:把問題轉成 SQL

 1async def natural_language_to_sql(
 2    question: str,
 3    schema_context: str,
 4    user_department: str
 5) -> str:
 6    prompt = f"""
 7    你是一位資深的 SQL 分析師。
 8    根據以下資料庫 Schema,將問題轉換為 SQL 查詢。
 9    
10    只能查詢用戶有權限的資料表(部門:{user_department})。
11    
12    Schema:
13    {schema_context}
14    
15    注意事項:
16    - 只生成 SELECT 語句,絕對不能生成 INSERT / UPDATE / DELETE
17    - 如果問題超出你的 Schema 範圍,請說「無法查詢此資料」
18    
19    問題:{question}
20    
21    SQL:
22    """
23    
24    sql = await llm.generate(prompt, temperature=0.0)
25    
26    # 安全驗證:確保只有 SELECT
27    if not is_safe_sql(sql):
28        raise SecurityError("生成了不安全的 SQL")
29    
30    return sql
31
32def is_safe_sql(sql: str) -> bool:
33    """確保 SQL 只包含讀取操作"""
34    dangerous_keywords = ["INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER"]
35    sql_upper = sql.upper()
36    return not any(keyword in sql_upper for keyword in dangerous_keywords)

BigQuery Tool 實作

 1from google.cloud import bigquery
 2from typing import Any
 3
 4class BigQueryTool:
 5    def __init__(self, project_id: str):
 6        self.client = bigquery.Client(project=project_id)
 7    
 8    async def execute_query(
 9        self,
10        sql: str,
11        max_rows: int = 1000,
12        timeout_seconds: int = 30
13    ) -> dict[str, Any]:
14        
15        job_config = bigquery.QueryJobConfig(
16            maximum_bytes_billed=10 * 1024 * 1024 * 1024,  # 10GB 上限
17            use_query_cache=True,
18        )
19        
20        try:
21            query_job = self.client.query(
22                sql,
23                job_config=job_config
24            )
25            
26            results = query_job.result(
27                max_results=max_rows,
28                timeout=timeout_seconds
29            )
30            
31            rows = [dict(row) for row in results]
32            
33            return {
34                "success": True,
35                "rows": rows,
36                "total_rows": results.total_rows,
37                "bytes_processed": query_job.total_bytes_processed,
38                "cache_hit": query_job.cache_hit
39            }
40            
41        except Exception as e:
42            return {
43                "success": False,
44                "error": str(e),
45                "rows": []
46            }

整合多來源資料

員工可能問:「哪個銷售員今年業績最好,他的客戶滿意度怎麼樣?」

這需要同時查 BigQuery(銷售數據)和 CRM(滿意度評分):

 1import asyncio
 2
 3class DataAggregator:
 4    async def query_multiple_sources(
 5        self,
 6        queries: list[dict],
 7        user_context: dict
 8    ) -> dict:
 9        
10        tasks = []
11        for query in queries:
12            source = query["source"]
13            sql = query["sql"]
14            
15            if source == "bigquery":
16                task = self.bq_tool.execute_query(sql)
17            elif source == "crm":
18                task = self.crm_tool.execute_query(sql)
19            elif source == "erp":
20                task = self.erp_tool.execute_query(sql)
21            
22            tasks.append(task)
23        
24        # 並行查詢所有來源
25        results = await asyncio.gather(*tasks, return_exceptions=True)
26        
27        return {
28            source["source"]: result
29            for source, result in zip(queries, results)
30        }

把數字轉成自然語言

 1async def generate_data_response(
 2    question: str,
 3    data: dict,
 4    source_labels: list[str]
 5) -> str:
 6    
 7    data_summary = format_data_for_llm(data)
 8    sources_text = "、".join(source_labels)
 9    
10    prompt = f"""
11    根據以下查詢結果,用清楚易懂的中文回答問題。
12    
13    數據來源:{sources_text}
14    
15    查詢結果:
16    {data_summary}
17    
18    問題:{question}
19    
20    要求:
21    - 直接回答問題,不要重複問題本身
22    - 如果涉及百分比,請計算並說明
23    - 在回答末尾標注「數據來源:{sources_text}24    """
25    
26    return await llm.generate(prompt, temperature=0.1)

成本控制:這題必須說

BigQuery 按資料掃描量計費($5 / TB)。如果 AI 生成的 SQL 每次掃整張 table,成本會很高。

 1class CostAwareBigQueryTool:
 2    MAX_BYTES_PER_QUERY = 10 * 1024 ** 3  # 10 GB
 3    BYTES_PER_TB = 1024 ** 4
 4    PRICE_PER_TB = 5.0  # USD
 5    
 6    async def execute_with_cost_estimate(self, sql: str) -> dict:
 7        # 先用 Dry Run 估算成本
 8        dry_run_config = bigquery.QueryJobConfig(dry_run=True)
 9        dry_run_job = self.client.query(sql, job_config=dry_run_config)
10        
11        estimated_bytes = dry_run_job.total_bytes_processed
12        estimated_cost = (estimated_bytes / self.BYTES_PER_TB) * self.PRICE_PER_TB
13        
14        if estimated_bytes > self.MAX_BYTES_PER_QUERY:
15            return {
16                "success": False,
17                "error": f"查詢預估掃描 {estimated_bytes/1e9:.1f} GB,超出限制。請縮小查詢範圍。"
18            }
19        
20        # 成本可接受,執行查詢
21        return await self.execute_query(sql)

兩題的對比分析

知識庫 ChatbotInternal Copilot
資料來源文件(非結構化)資料庫(結構化)
核心技術RAG + Vector DBNL2SQL + 多來源查詢
幻覺風險中(有文件 context)低(直接查資料)但有 SQL 錯誤風險
RBAC 難度中(文件分類)高(資料行 / 欄 level 的控制)
Latency較低(向量搜尋快)較高(SQL 查詢可能慢)
Cache 效益高(政策類問題重複率高)中(數據每天更新,TTL 要短)

面試中容易被追問的細節

Q:Cache 過期了怎麼辦?

知識庫 Chatbot 的 Cache TTL 設多長?文件更新時要主動 invalidate cache。

1async def update_document(doc_id: str, new_content: str):
2    # 1. 更新向量資料庫
3    await vector_db.update(doc_id, new_content)
4    
5    # 2. 清除相關 Cache(使用 tag-based invalidation)
6    await cache.delete_by_tag(f"doc:{doc_id}")

Q:LLM 生成了錯誤的 SQL 怎麼辦?

 1async def safe_nl2sql(question: str, max_retries: int = 3) -> str:
 2    for attempt in range(max_retries):
 3        sql = await nl_to_sql(question)
 4        
 5        # 語法驗證
 6        is_valid, error = validate_sql(sql)
 7        if not is_valid:
 8            # 把錯誤告訴 LLM,讓它自我修正
 9            question = f"""
10            之前生成的 SQL 有錯誤:{error}
11            請修正後重新生成。
12            原始問題:{question}
13            """
14            continue
15        
16        return sql
17    
18    return None  # 三次都失敗,回報無法處理

Q:用戶問了 Schema 裡沒有的資料怎麼辦?

系統要能識別這種情況並優雅地告知,而不是生成一個查不到資料的 SQL 或直接幻覺。


完整的面試回答結構

系統設計題,我建議這樣走:

第一分鐘:
"在開始設計之前,我想先確認幾個需求..."
(問 1-2 個關鍵問題:用戶規模、RBAC 需求、Latency 要求)

第二到五分鐘:
"好,根據這些需求,我的高層架構是這樣..."
(畫圖,解釋每個元件的職責)

第六到十五分鐘:
"我想深入說明幾個關鍵設計決策..."
(選 2-3 個最重要的部分:RBAC 實作、Cache 策略、Failure 處理)

最後幾分鐘:
"這個設計的主要 trade-off 是..."
"最可能出問題的地方是..."

系列總結

四篇下來,我想說的核心只有一件事:

FDE 是橋接器,不是工具使用者。

能說出「我會用 LangGraph 做這個」,只是入門。

能說出「在這個場景下,Multi-Agent 帶來的複雜度不值得,我會選擇 Single Agent + 多個 Tools,因為…」,才是 FDE 的水準。

面試考的是判斷力,不是背誦力。


系列文章索引

Yen

Yen

Yen