FDEAI 工程實戰課AI Engineering for FDEs
中級篇 · 第 1 章Intermediate · Chapter 1

專案起步:第一版機器人Project setup: a first bot

中級篇用六章做完一個真的能用的專案:員工手冊問答機器人。每一章加一個功能,每一個功能都有測試。這一章先把專案的骨架搭好,並做出第一個能回答問題的版本。

The intermediate track spends six chapters building one real, working project: an employee-handbook Q&A bot. Each chapter adds one feature, and every feature has tests. This chapter sets up the skeleton and gets a first version answering questions.

⏱ 20 分鐘min🧪 labs/intermediate/

這章結束時你會有By the end of this chapter you'll have

  • 一個結構清楚、每個檔案各司其職的專案。
  • 一個可以替換的 LLM 介面:同一份程式,可以接真的 Claude,也可以接離線假模型。
  • 第一版機器人:搜尋手冊、組 prompt、呼叫 AI。
  • A cleanly structured project where each file has one job.
  • A swappable LLM interface: the same code runs against real Claude or an offline fake.
  • A first bot: search the handbook, build the prompt, call the AI.

最後會做出什麼What we'll end up with

完成六章之後,在終端機裡會是這樣(這是 python -m labs.intermediate.chat --fake 的實際輸出):

After all six chapters, a terminal session looks like this (actual output of python -m labs.intermediate.chat --fake):

terminal
模式:離線假模型(規則式,不是 AI)|登入者:E001

你:我今年還剩幾天特休?
機器人:你今年還剩 4 天特休(共 10 天,已休 6 天)。〔H01〕
  ↳ 使用工具 get_leave_balance → {"total": 10, "used": 6, "remaining": 4}

你:公司有員工停車位嗎?
機器人:手冊沒有寫到這件事,建議詢問人資部門。

你:/usage
呼叫 3 次|輸入 1389|輸出 154|約 US$0.0086

注意三件事:回答附上了手冊段落編號、個人資料是透過「工具」查的、手冊沒寫的事它會老實說。這三件事分別是第 2、4、3 章的主題。

Notice three things: answers cite the handbook section, personal data comes from a tool, and it admits when the handbook doesn't say. Those are the topics of chapters I2, I4 and I3.

專案結構Project structure

檔案File負責Responsibility章節Chapter
handbook.md員工手冊(12 段)The employee handbook (12 sections)I1
config.py模型名稱、價格、各種上限,集中在一個地方Model name, prices and limits, all in one placeI1
llm.py呼叫 AI 的介面:ClaudeLLM、OllamaLLM 與 FakeLLMThe AI interface: ClaudeLLM, OllamaLLM and FakeLLMI1
bot.py把所有零件串起來Wires everything togetherI1–I5
schema.py回答的格式與檢查規則Answer format and validation rulesI2
search.py搜尋手冊(三個版本)Handbook search (three versions)I3
tools.py工具:查特休餘額、今天日期Tools: leave balance, today's dateI4
evals.py, eval_set.jsonl自動測試整個機器人Automated testing of the whole botI6
chat.py在終端機聊天Chat in the terminal—

為什麼要分這麼多檔案?因為每個部分會各自改變:換搜尋方法不該動到工具,改回答格式不該動到搜尋。分開之後,每個部分都可以單獨測試。

Why so many files? Because each part changes independently: swapping the search shouldn't touch the tools, and changing the answer format shouldn't touch the search. Separated, each part can be tested on its own.

設定集中在一個地方Keep settings in one place

labs/intermediate/config.py
MODEL = "claude-opus-5-5"
MAX_TOKENS = 4000

# 每百萬 token 的價格(美元):(輸入, 輸出)。
# ⚠ 價格會變動,請以官方定價頁為準。這裡的數字查詢於 2026-10。
PRICES_PER_MTOK = {
    "claude-opus-5-5": (4.00, 20.00),
}

SEARCH_TOP_N = 3          # 每次最多放幾段手冊進 prompt
MAX_HISTORY_TURNS = 4     # 只保留最近幾輪對話
MAX_TOOL_ROUNDS = 3       # 一個問題最多呼叫幾輪工具,避免無限迴圈
MAX_RETRIES = 1           # 回答格式不合格時,最多重試幾次

每一個「上限」都是刻意的:AI 系統出問題時,常常不是當掉,而是「一直重試」或「一直呼叫工具」,帳單就這樣燒掉了。把上限寫在明顯的地方,是最便宜的保險。

Every limit is deliberate: when AI systems misbehave they rarely crash; they retry forever or call tools forever, and that's how bills explode. Putting limits somewhere obvious is the cheapest insurance there is.

把 AI 包成一個可替換的介面Wrap the AI in a swappable interface

機器人的其他程式只認得一個方法:llm.create(system, messages, tools, output_schema),回傳一個 Reply。至於背後是誰在回答,它不在乎。

The rest of the bot knows exactly one method: llm.create(system, messages, tools, output_schema), which returns a Reply. It doesn't care who answers behind it.

labs/intermediate/llm.py
@dataclass
class Reply:
    content: Any                 # 原封不動放回對話歷史用的內容區塊
    text: str                    # 所有文字區塊合併
    tool_calls: list[ToolCall]
    stop_reason: str             # end_turn / tool_use / max_tokens / refusal ...
    input_tokens: int
    output_tokens: int


class ClaudeLLM:
    def __init__(self, model: str = MODEL, max_tokens: int = MAX_TOKENS):
        import anthropic  # 只有真的要呼叫 API 時才需要安裝

        self.client = anthropic.Anthropic()  # 讀取環境變數 ANTHROPIC_API_KEY
        self.model = model
        self.max_tokens = max_tokens

    def create(self, system, messages, tools=None, output_schema=None) -> Reply:
        kwargs = {"model": self.model, "max_tokens": self.max_tokens,
                  "system": system, "messages": messages}
        if tools:
            kwargs["tools"] = tools
        if output_schema:
            kwargs["output_config"] = {"format": {"type": "json_schema", "schema": output_schema}}
        r = self.client.messages.create(**kwargs)
        return Reply(
            content=r.content,
            text="".join(b.text for b in r.content if b.type == "text"),
            tool_calls=[ToolCall(b.id, b.name, b.input) for b in r.content if b.type == "tool_use"],
            stop_reason=r.stop_reason,
            input_tokens=r.usage.input_tokens,
            output_tokens=r.usage.output_tokens,
        )

第三種實作:本機開源模型A third implementation: a local open model

llm.py 裡還有 OllamaLLM:呼叫在你電腦上執行的開源模型(透過 Ollama),資料完全不離開本機。Ollama 的訊息格式和 Claude 不同(例如工具呼叫沒有 id、工具結果用 role: "tool"),這些轉換都包在 OllamaLLM 裡,bot.py 一行都不用改。這正是把 AI 包成介面的好處。

llm.py also contains OllamaLLM, which calls an open model running on your own computer via Ollama, so data never leaves the machine. Ollama's message format differs from Claude's (tool calls have no id, tool results use role: "tool"), and all of that conversion lives inside OllamaLLM; bot.py doesn't change at all. That's the payoff of the interface.

terminal
ollama pull qwen2.5:3b                          # 先安裝 Ollama(https://ollama.com)
python -m labs.intermediate.chat --ollama

什麼時候該用雲端、什麼時候該自己架,見進階篇第 08 章。

For when to use a cloud API versus self-hosting, see core chapter 08.

為什麼需要假模型Why a fake model?

FakeLLM 不是 AI,它是一組固定的規則:從提供的手冊段落中挑最相近的一段引用、問到特休餘額就呼叫工具。它的用途是:

FakeLLM isn't AI. It's a fixed set of rules: quote the most similar handbook passage, call the tool when asked about leave balance. It exists because:

  • 開發時不花錢:搜尋、工具、重試這些流程,用假模型就能測。
  • 結果可重現:真的 AI 每次回答可能不同;測試需要固定的結果,才能判斷是不是你的程式壞了。
  • 可以故意犯錯:FakeLLM(mistakes=["fake_citation"]) 會在第一次回答時捏造出處,用來測試第 2 章的重試機制。真的 AI 很難「照指示犯錯」。
  • It's free during development: search, tools and retries can all be tested against it.
  • Results are reproducible: real AI answers vary; tests need fixed results to tell whether your code broke.
  • It can make mistakes on purpose: FakeLLM(mistakes=["fake_citation"]) invents a citation on its first answer, to test chapter I2's retry logic. A real AI is hard to make fail on command.
假模型的極限Limits of the fake

假模型能驗證的是「流程對不對」,不是「回答好不好」。回答品質一定要用真的模型測(第 6 章的 --real)。

The fake verifies that the pipeline works, not that answers are good. Answer quality must be tested with the real model (chapter I6's --real).

第一版:搜尋 → 組 prompt → 呼叫First version: search → prompt → call

流程和入門篇 B4 一樣,只是現在分工清楚。規則寫在 system prompt,手冊段落與問題放在使用者訊息:

The flow is the same as basics chapter B4, now with clear responsibilities. Rules go in the system prompt; handbook passages and the question go in the user message:

labs/intermediate/bot.py
SYSTEM_PROMPT = """你是「星河軟體」的員工手冊助理,回答員工關於公司規定的問題。

規則:
1. 只根據 <手冊> 裡的段落,或工具查到的結果回答。不要用一般常識補充公司規定。
2. 回答要附上引用的段落編號(例如 H01)。
3. 如果手冊和工具都沒有答案,found 設為 false,並建議詢問人資部門。不要猜。
4. 問到個人資料(例如自己還剩幾天特休)時,使用工具查詢。
5. 用繁體中文,簡潔回答。"""


def build_user_message(question: str, hits) -> str:
    if hits:
        context = "\n".join(f"[{h.chunk.id}] {h.chunk.full}" for h in hits)
    else:
        context = "(沒有找到相關段落)"
    return f"<手冊>\n{context}\n</手冊>\n\n問題:{question}"

為什麼規則放 system、資料放 user?system prompt 是「每一輪都一樣」的部分,固定不變也比較容易被快取(第 5 章);每一題不同的內容放在使用者訊息裡。

Why rules in system and data in user? The system prompt is the part that's identical every turn, and keeping it fixed makes caching easier (chapter I5); content that changes per question goes in the user message.

動手做Do it

terminal
python -m labs.intermediate.chat --fake          # 離線假模型

# 用真的 Claude(需要 pip install anthropic 與 API key)
export ANTHROPIC_API_KEY=...                     # Windows PowerShell: $env:ANTHROPIC_API_KEY="..."
python -m labs.intermediate.chat

python -m pytest labs/intermediate -q            # 跑中級篇的測試

練習Exercise

在 config.py 把 SEARCH_TOP_N 改成 1,再用 --fake 問「病假超過幾天要附醫生證明?」。答案有變嗎?想想看:放進 prompt 的段落越少,有什麼好處和壞處?

Set SEARCH_TOP_N to 1 in config.py and ask (with --fake) 「病假超過幾天要附醫生證明?」. Did the answer change? What are the pros and cons of putting fewer passages in the prompt?

小測驗Quiz

1. 為什麼要把 LLM 包成 llm.create(...) 這樣的介面,而不是在 bot 裡直接呼叫 anthropic SDK?1. Why wrap the LLM behind llm.create(...) instead of calling the anthropic SDK directly in the bot?
bot 只依賴介面,不依賴實作,這讓測試與替換都變得容易。
The bot depends on an interface, not an implementation, which makes testing and swapping easy.
2. 假模型的測試全部通過,代表什麼?2. All tests pass against the fake model. What does that tell you?
兩種測試回答不同的問題:假模型測「程式對不對」,真模型測「回答好不好」。
The two kinds of test answer different questions: the fake tests "is the code right", the real model tests "are the answers good".