Skip to main content
Glama
LiuYuWei

leave-copilot

by LiuYuWei
README.md
# Leave Copilot — 從 MCP 到專屬 Agentic 模型

一套可執行的參考實作:用 **MCP** 把一組**刻意設計得很難**的請假/差勤工具標準化,用 **Google ADK** 打造能操作它們的 Agent,用雙評測量出基座模型的不足,再微調出一個原生擅長操作這組工具的專屬模型。

這是 2026 iThome 鐵人賽 30 天系列的配套程式碼。

---

## 為什麼工具是「刻意設計得很難」的

一般設計 API 追求直觀易用,但這個專案需要相反的東西。

驗收的方式是比較微調前後的表現差異 —— 如果工具太直觀,基座模型本來就能正確呼叫,準確率一開始就接近滿分,微調自然**展示不出任何提升**。那不是因為微調沒效果,而是根本沒有可供改善的空間。

所以選擇標準只有一個:**基座模型幾乎必錯,而微調可以教會。**

### 四個刻意植入的難點

| # | 難點 | 實作方式 | 模型的典型錯誤 |
|---|---|---|---|
| ① | 跨呼叫依賴 | 編號為不可推測的格式(`LV-7f3a91`),不存在時明確報錯 | 跳過查詢,直接推測 `LV-001` |
| ② | Elicitation 三態 | 破壞性操作走 `ctx.elicit()`,accept/decline/cancel 語意各不相同 | decline 後改用其他工具繞道 |
| ③ | 狀態機約束 | 狀態只能 `draft → submitted → approved → taken` 逐級推進 | 從 draft 直接跳到 approved |
| ④ | 參數陷阱 | 時數以**小時**計(半天 = 4 不是 0.5)、`employee_id` 非姓名、ISO 8601 | 傳 `hours=0.5`、`employee_id="林筱涵"` |

**這四個難點的共同特徵:它們全部都是 JSON Schema 表達不了的規則。** Schema 管得住 `status` 必須是四個字串之一,但管不了「這個編號從哪裡來」。

---

## 快速開始

### 環境

| 套件 | 版本 | 為什麼 |
|---|---|---|
| `mcp` | `>=1.29,<2` | 本系列使用 1.x 的 FastMCP,版本範圍不要省略 |
| `google-adk` | `2.x` | 1.x 仍維護,但新專案沒理由從舊版開始 |
| Python | `>=3.10` | 兩者的共同下限 |

> 第一項特別容易中招,因為 MCP Python SDK 官網**預設顯示的是另一套 API 的文件**(`MCPServer`),與這裡使用的 `FastMCP` 寫法完全不同。

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```

### 起 MCP Server

```bash
python -m mcp_server.server        # streamable-http on 127.0.0.1:8090
```

### 驗證四個難點

```bash
python eval/verify_difficulties.py
```

會實際連上 Server,逐一觸發四個難點並檢查錯誤訊息與回傳語意:

```
難點 ① 跨呼叫依賴
  ✓ 捏造的編號被擋下
  ✓ 錯誤訊息指出正確取得方式
  ✓ search_leaves 查得到真實編號
…
難點 ④ 參數陷阱
  ✓ 餘額欄位以小時命名
  ✓ 傳姓名而非員工編號被擋下
難點 ② Elicitation 三態
  ✓ accept            → cancelled
  ✓ decline           → aborted
  ✓ cancel            → aborted
  ✓ accept_but_false  → aborted
  ✓ 撤銷後時數退回餘額
  ✓ decline 的訊息明確禁止繞道
  ✓ cancel 的訊息與 decline 語意不同

──────────────────────────────────────────────
19/19 通過
```

### 重置測試資料

`update_leave_status`、`cancel_approved_leave` 會**真的改變資料**。每一輪評測開始前都必須重置,否則第二輪的前置條件跟第一輪不同,結果不可比。

```bash
python eval/reset.py
```

---

## 工具集

九個工具,加上一個評測腳本專用的管理端點。

| 類別 | 工具 | `readOnlyHint` |
|---|---|---|
| 假單 | `search_leaves`、`get_leave` | ✅ |
| 員工 | `list_employees`、`get_leave_balance` | ✅ |
| 簽核 | `update_leave_status`、`add_comment` | ✗ |
| 交接 | `schedule_handover` | ✗ |
| 撤銷 | `withdraw_leave`、`cancel_approved_leave` | ✗(走 Elicitation) |
| 管理 | `_reset_fixtures` | ✗ |

`readOnlyHint` 不只是文件 —— 評測工具靠它計算「唯讀合規性」:Agent 有沒有在唯讀任務中動用寫入工具。

> **`_reset_fixtures` 在 Agent 端必須用 `tool_filter` 排除掉。** 一個叫「重置」的工具對 LLM 有莫名的吸引力。

---

## 為什麼錯誤訊息要寫這麼清楚

工具的錯誤訊息會**原封不動回到模型手上**,成為它下一步的依據。

```python
# ✗ 模型只知道錯了,得猜哪裡錯
raise ValueError("Invalid status transition")

# ✓ 模型知道錯在哪、也知道該改成什麼
raise ValueError(
    f"狀態不可從 {current} 跳至 {target},下一個合法狀態為 {next_valid}"
)
```

這是**用工具設計補償模型能力**的典型手法,成本只是多寫幾個字。

---

## 專案結構

```text
.
├── mcp_server/          # ✅ MCP Server:九個工具 + 四個難點
│   ├── server.py
│   ├── store.py         #    模擬資料層
│   └── fixtures.py      #    初始資料與 reset
├── eval/                # ✅ 驗證與重置腳本
│   ├── verify_difficulties.py   # 19/19
│   ├── verify_agent.py          # 架構驗證 10/10
│   └── reset.py
├── agents/leave_copilot/ # ✅ Google ADK Agent(含 elicitation callback)
├── plugins/             # ⏳ 軌跡記錄與生產防禦 Plugin
├── data/                # ⏳ 軌跡萃取與資料擴增
├── training/            # ⏳ SFT 訓練腳本
└── deploy/              # ⏳ 權重合併、量化、vLLM 部署
```

✅ 已完成並實測 ⏳ 建置中

---

## Port 分配

⚠️ **FastMCP 與 Google ADK `api_server` 的預設 port 都是 8000**,必須改掉其中一個。本專案把 MCP Server 移到 8090。

| 服務 | Port |
|---|---|
| MCP Server(streamable-http) | **8090** |
| Google ADK api_server | 8000 |
| 評測工具 Web UI | 8080 |
| vLLM | 8001 |
| Ollama | 11434 |

---

## 相關專案

- [ADEval](https://github.com/ap-mic-inc/ADEval) — Google ADK Agent 評測工具(Apache-2.0)
- [Twinkle Eval](https://github.com/ai-twinkle/Eval) — 標準 Benchmark 評測(MIT)

## 授權

Apache-2.0

---

## 已驗證的部分

`eval/verify_difficulties.py` 與 `eval/verify_agent.py` 都是**實際跑過**的,
不是「文件上這樣寫」。

```
MCP Server 層(eval/verify_difficulties.py)              19/19
  四個難點的錯誤訊息、Elicitation 四條路徑

Google ADK 層(eval/verify_agent.py,A 段架構驗證)        10/10
  McpToolset 載入、tool_filter 排除管理端點
  accept / decline / cancel / accept-but-false 四條路徑
  都確認走到 Client callback,且語意正確回報
```

環境:`mcp` 1.29.1 + `google-adk` 2.7.1 + `gemini-3.7-flash`。

### 基座模型的行為觀察

`verify_agent.py` 的 B 段**不做斷言,只記錄**——模型答錯不代表測試失敗,
那正是要量的東西。實跑下來最值得注意的是這個失敗模式:

> **模型用文字回應代替工具呼叫。** 面對破壞性操作,`gemini-3.7-flash`
> 傾向自己在對話裡問「你確定嗎?」,而不是呼叫 `cancel_approved_leave`
> 讓 Server 發出 Elicitation。結果是:確認流程從**協定層**掉回**對話層**,
> 而對話層的確認是沒有強制力的。

更嚴重的一個變體是**幻覺型成功**——模型回覆「我已將假單送出審核」,
但工具序列裡根本沒有 `update_leave_status`,假單狀態也沒變。使用者以為做完了。

這類失敗無法靠 Prompt 消除,因為它源自模型對「安全」的內建傾向。
這正是後續要用微調處理的東西。