iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Build on Google AI

30 天用 Google AI 打造台灣防災速報 App系列 第 9

Day 09|警報分級不能「大致正確」:用 Structured Output 讓 Gemini 產出合規 JSON

  • 分享至 

  • xImage
  •  

系列:30 天用 Google AI 打造臺灣防災速報 App(Day 9/30)

昨天的速報是給人看的,今天要產「給程式看的」輸出:警報分級。推播要不要發、前端標什麼顏色、Agent(能自己規劃步驟的 AI 程式,之後會做)要不要介入,全部取決於這個分級結果,所以它必須是格式固定的結構化資料,不能是一段還要再寫程式從中擷取資訊的文字。

為什麼不用「請輸出 JSON」就好

在 prompt 裡要求模型輸出 JSON(程式之間交換資料常用的文字格式),輸出有時會混進多餘的內容:包住程式碼用的 ``` 符號、沒有要求的欄位、全形逗號,或是在 JSON 前面多一句「好的,以下是分級結果:」。一般應用多寫一段容錯的解析程式就能處理;防災系統的分級結果直接決定要不要推播,「大致正確」是不能接受的。

Gemini API 的 **Structured Output(結構化輸出)**功能可以解決這個問題。做法是把一份 schema(欄位規格:有哪些欄位、各是什麼型別)交給 API,模型的輸出在 API 這一層就保證符合這份規格。規格說某個欄位只能從幾個固定值裡選(這種型別稱為 enum),輸出就只會是其中一個值;規格說是陣列,輸出就是陣列,事後不必再清理。要注意的是,它保證的只有格式與型別;內容對不對、字數有沒有超過,規格管不到,要靠 prompt 的規則和實測來確認。

定義分級 Schema

欄位規格用 Pydantic 來寫。Pydantic 是 Python 常用的資料驗證套件,用類別定義欄位與型別,Gemini 的 SDK 可以直接接受它。

from google import genai
from pydantic import BaseModel
from enum import Enum

class Severity(str, Enum):
    critical = "critical"   # 立即危險:強震、海嘯、大規模火災
    warning  = "warning"    # 需注意:豪雨特報、水庫放流
    info     = "info"       # 知悉即可:局部停水、一般道路封閉

class AlertAssessment(BaseModel):
    severity: Severity
    affected_areas: list[str]   # 受影響地區
    action_advice: str          # 原文中的行動指示,20 字以內;沒有就留空
    reason: str                 # 分級理由(除錯用,不對外顯示)

client = genai.Client()

def assess(alert_text: str, rules: str) -> AlertAssessment:
    # rules 是下一節的分級規則文字,和示警內容一起交給模型
    resp = client.models.generate_content(
        model="gemini-3.1-flash-lite",
        contents=f"{rules}\n\n評估這則臺灣防災示警的嚴重度:\n{alert_text}",
        config={
            "response_mime_type": "application/json",
            "response_schema": AlertAssessment,
        },
    )
    return resp.parsed

模型輸出的是 JSON 文字,SDK 會依照規格把它轉成 Pydantic 物件,用 resp.parsed 取得,欄位與型別都已確定,後面的程式可以直接使用。reason 欄位看似多餘,除錯時卻很有用:分級跟預期不符時,先看模型自己寫的理由,通常能立刻看出是 prompt 沒講清楚,還是資料本身模糊。正式的系統還要處理 API 呼叫失敗、或 resp.parsed 取不到結果的情況,之後做錯誤處理時一起處理。

分級標準寫進 prompt,不讓模型自己訂標準

Schema 只管格式,不管判斷標準。severity 的定義必須在 prompt 裡寫清楚,否則同一則示警可能今天是 warning、明天是 critical。我把六大類 × 嚴重度的判斷基準寫成對照表,放進 prompt:

六大類 critical warning info
地震海嘯 最大震度 5 弱以上,或伴隨海嘯警報 最大震度 4 級 3 級以下
氣象 颱風陸上警報、大豪雨特報 豪雨、強風、高溫特報 一般天氣提醒
水利 水庫緊急洩洪、淹水警戒紅色 水庫放流、淹水感測、土石流黃色警戒 區排警戒
火災事故 大規模火災、疏散避難 一般火災通報 消防安檢不合格
交通 重大鐵路事故 道路封閉(主要幹道) 一般封閉
民生 大規模停班停課 停水(整區) 局部停水、森林遊樂區公告

第一版的 prompt 就是把這張表逐列寫成文字:

GRADING_RULES_V1 = """你是臺灣防災警報分級器。依下表評估示警嚴重度:
地震海嘯:最大震度5弱以上或伴隨海嘯警報=critical;最大震度4級=warning;3級以下=info
氣象:颱風陸上警報、大豪雨特報=critical;豪雨、強風、高溫特報=warning;一般天氣提醒=info
水利:水庫緊急洩洪、淹水警戒紅色=critical;水庫放流、淹水感測、土石流黃色警戒=warning;區排警戒=info
火災事故:大規模火災、疏散避難=critical;一般火災通報=warning;消防安檢不合格=info
交通:重大鐵路事故=critical;主要幹道封閉=warning;一般封閉=info
民生:大規模停班停課=critical;整區停水=warning;局部停水、森林遊樂區公告=info"""

實測:第一輪找到三個問題

9/19 用三個測試案例執行(模型 gemini-3.1-flash-lite,溫度維持預設值,每個案例執行三次)。第一個案例是昨天那則 8/22 規模 5.1 地震的真實報告;另外兩個是我依照常見的示警內容寫的測試文字。程式裡的 REPORT 沿用昨天定義的那段地震報告文字:

cases = [
    REPORT,   # 昨天整理的 8/22 地震報告
    "石門水庫於今日14時起調節性放流,下游河道請勿逗留。",
    "花蓮縣秀林鄉土石流紅色警戒,請立即撤離保全對象。",
]
for text in cases:
    print(assess(text, GRADING_RULES_V1).model_dump_json())

程式可以直接執行,三個案例的 severity 三次都一致:地震(最大震度 3 級)是 info,水庫放流是 warning,土石流紅色警戒是 critical

分級都對,但讀完 reason 和其他欄位之後,發現三個問題:

  1. 表格有漏洞。上面的表只寫了「土石流黃色警戒」,沒有紅色警戒。模型的 reason 寫的是「土石流紅色警戒屬於大規模災害風險,必須執行強制疏散」,這個理由不在表裡,是模型自己推論出來的。結果是對的,但這正是前一節想避免的事。修正方式是把「土石流紅色警戒」補進表裡。
  2. action_advice 自行給了建議。地震那則的原文沒有任何行動指示,模型卻寫出「請保持冷靜,注意室內物品掉落風險,隨時留意後續地震資訊」,三次執行的內容都不一樣,也都超過 20 字。原因有兩個。第一,「20 字以內」只寫在 Python 的註解裡,註解不會傳給模型,模型根本不知道有這個要求。第二,prompt 沒有規定這個欄位的內容從哪裡來,模型就自己寫了。這與昨天「只陳述事實,不代替判斷」的原則牴觸。修正方式是規定這個欄位只能摘錄原文的行動指示,原文沒有就留空。
  3. affected_areas 列了 15 個縣市。有感的縣市全部列出,推播時沒有用處。修正方式是規定地震只列最大震度所在的縣市。

第二輪:補上規則之後

修正後的 prompt 在表裡補上土石流紅色警戒,並加上三條欄位規則:

GRADING_RULES = """你是臺灣防災警報分級器。依下表評估示警嚴重度:
地震海嘯:最大震度5弱以上或伴隨海嘯警報=critical;最大震度4級=warning;3級以下=info
氣象:颱風陸上警報、大豪雨特報=critical;豪雨、強風、高溫特報=warning;一般天氣提醒=info
水利:水庫緊急洩洪、淹水警戒紅色、土石流紅色警戒=critical;水庫放流、淹水感測、土石流黃色警戒=warning;區排警戒=info
火災事故:大規模火災、疏散避難=critical;一般火災通報=warning;消防安檢不合格=info
交通:重大鐵路事故=critical;主要幹道封閉=warning;一般封閉=info
民生:大規模停班停課=critical;整區停水=warning;局部停水、森林遊樂區公告=info

欄位規則:
- affected_areas:地震只列最大震度所在的縣市;其他示警列出原文提到的地區。
- action_advice:只能摘錄示警原文中已有的行動指示,20 字以內;原文沒有行動指示時填空字串,不得自行補充建議。
- reason:寫出依據上表的哪一條。"""

每個案例再執行三次,結果幾乎相同(以下是這次執行的紀錄,重新執行時文字可能略有不同):

地震      → info      affected_areas: ["宜蘭縣", "花蓮縣"]       action_advice: ""
                      reason: "地震海嘯:3級以下=info"
水庫放流  → warning   affected_areas: ["石門水庫下游河道"]        action_advice: "下游河道請勿逗留"
                      reason: "水利:水庫放流"
土石流    → critical  affected_areas: ["花蓮縣秀林鄉"]            action_advice: "請立即撤離保全對象"
                      reason: "水利:土石流紅色警戒=critical"

三個問題都解決了,reason 也明確寫出依據的是哪一條。還剩一個待處理的地方:水庫放流那則的原文沒有寫縣市,affected_areas 只能填「石門水庫下游河道」。水庫對應到哪些縣市需要另外建一張對照表,之後會處理。

這一輪也說明了 reason 欄位的用處:第一個問題只看 severity 是發現不了的。另外要說明,三個案例只能確認程式可以執行、規則表有作用,還不算品質評估。正式的評估之後會做,屆時的重點是 critical 級的示警一則都不能漏判。

官方等級優先:官方已經分好的,不需要 AI

昨天下載的那則 8/22 地震 CAP 電文,還提醒了我一件事。CAP 標準本身就有 severity(嚴重程度:Extreme/Severe/Moderate/Minor/Unknown)、urgency(多久之內需要行動)、certainty(事件發生的確定程度)三個欄位,那則規模 5.1、最大震度 3 級的地震被氣象署標為 severity: Minor,參數裡還有 alert_color: 綠色

也就是說,很多示警根本不需要 AI 分級,官方已經分好了。所以規則改成兩層:

  1. CAP 電文的 severity 欄位有值且不是 Unknown 時,直接對照採用(Extreme/Severe → critical,Moderate → warning,Minor → info),AI 完全不介入。
  2. 官方沒給等級、或只有摘要而拿不到完整電文時,才由模型依上表分級,並標明是「AI 判定」。

寫成程式只有幾行:

CAP_TO_LEVEL = {"Extreme": "critical", "Severe": "critical",
                "Moderate": "warning", "Minor": "info"}

def grade(alert_text: str, cap_severity: str | None = None) -> tuple[str, str]:
    if cap_severity in CAP_TO_LEVEL:          # 官方有給等級,不呼叫模型
        return CAP_TO_LEVEL[cap_severity], "官方等級"
    return assess(alert_text, GRADING_RULES).severity.value, "AI 判定"

print(grade(REPORT, "Minor"))
print(grade("石門水庫於今日14時起調節性放流,下游河道請勿逗留。"))

實測:8/22 那則地震帶著 Minor 進來,直接得到 ('info', '官方等級'),沒有呼叫模型;水庫放流那則沒有官方等級,得到 ('warning', 'AI 判定')

在防災系統裡,官方資料優先,AI 只在官方沒有提供時補上。之後的功能也照這個順序設計。

設計決策:為什麼分級門檻要寫成固定的規則

有人可能會問:既然模型這麼會讀文件,為什麼不讓它根據情境彈性判斷?三個理由:

  • 可解釋:使用者或審查者問「為什麼這則是 critical」,答案必須是「因為規則第幾條」,不能是「模型這樣認為」。
  • 可測試:門檻寫成固定的規則,才能準備有標準答案的測試題;彈性判斷沒有標準答案,修改之後也無法確認結果有沒有變差。
  • 責任歸屬:推播叫醒一個人是有成本的,推錯(該推沒推或不該推亂推)的責任要落在寫規則的人身上,不能交給一個無法追問理由的模型。昨天提到的研究指出,任務愈不確定,人愈依賴 AI,適當依賴的程度卻愈低;把判斷提前到設計階段、留在人的手上,可以直接彌補這個弱點。

模型在這個環節的工作很明確:把示警文字對應到規則表,以及在官方沒給等級時補上分級。模型負責依規則表做文字對應,分級門檻由人事先訂好。模型仍然可能對應錯誤,所以之後還要做正式的評估。

今日小結

  • Structured Output 在 API 這一層保證輸出符合欄位規格,resp.parsed 直接取得 Pydantic 物件。
  • 六大類 × 三級的分級對照表寫進 prompt;CAP 電文有 severity 時官方等級優先,AI 只在官方沒給時補上。
  • 第一輪實測靠 reason 和其他欄位找到三個問題:表格漏了土石流紅色警戒、action_advice 自行給建議(字數要求只寫在註解裡,模型看不到)、affected_areas 列出 15 個縣市。第二輪補上規則後全部解決。
  • 門檻寫成固定規則的三個理由:可解釋、可測試、責任落在人身上。

明天 Day 10 讓模型自己查資料:用 Function Calling 實作問答的第一步。


上一篇
Day 08|Prompt 四次改寫:我寫的一條規則,讓 Gemini 編出「無海嘯威脅」
系列文
30 天用 Google AI 打造台灣防災速報 App9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言