iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Claude AI

把 Claude 練成專家:30 天打造可驗證的 Agent Skills系列 第 14 篇

Day 14|讓 evidence 會過期:設計來源重驗與 freshness 維護流程

  • 分享至 

  • xImage
  •  

(系列:《把 Claude 練成專家:30 天打造可驗證的 Agent Skills》)

昨日回顧

Day 13 把失敗政策寫回 SKILL.md:OFFICIAL、UNCONFIRMED、INSUFFICIENT_INPUT、CONFIGURATION_ERROR、CONFLICT 各自有固定的 next_step,而且 OFFICIAL 也要帶著 verified_at 與 review_after 往下游傳。今天處理這兩個日期背後的問題:誰負責更新它們、多久更新一次、沒人更新時系統要怎麼反應。

來源紀錄不是寫一次就好

Day 8 建立的來源 registry 讓分類器可以離線、確定性地判斷一個網址是否命中官方來源。但 registry 裡的每一筆紀錄,都只是「某人在某天看過某個證據」的快照。

真實世界會變:

  • 售票平台換了網域或子網域
  • 主辦單位改用另一家代理商
  • 舊的官方活動頁被下架,網域過期後被別人註冊
  • 某個合作通路合約結束

如果紀錄沒有到期概念,一筆三年前確認過的 host 會永遠被當成官方。這不是小瑕疵,而是把過期資訊包裝成「已驗證」。

stale-by-default:沒人維護就降級

今天最重要的規則只有一句:

A source record is trusted only until review_after. After that date, treat it as stale and classify matches as UNCONFIRMED until the record is revalidated.

注意方向。預設是「過期就不信」,而不是「過期但先照舊用,等有空再檢查」。前者在沒人維護時會變得保守,後者會在沒人維護時悄悄變得危險。

分類器實作只要多一個日期比較:

def record_is_fresh(record: dict, today: date) -> bool:
    if record["state"] != "active":
        return False
    return today <= date.fromisoformat(record["review_after"])

today 一律由呼叫端明確傳入,不在函式內呼叫 date.today()。這樣 pytest 與 eval 才能固定日期重現結果,Day 10 的確定性原則不會被時間打破。

紀錄欄位的最小集合

把 freshness 需要的欄位寫進 schema:

{
  "id": "source:example-primary",
  "host": "tickets.example.com",
  "state": "active",
  "evidence_url": "https://www.example.org/official-ticketing",
  "verified_at": "2026-09-01",
  "verified_by": "maintainer:alex",
  "review_after": "2026-10-01",
  "history": []
}

每個欄位都有用途:

  • state:active、revoked 兩種,不用 boolean,以免之後要加狀態時破壞相容
  • evidence_url:重驗時要回去看的原始證據,而不是 host 本身
  • verified_at:最後一次人工或流程確認的日期
  • verified_by:誰確認的,方便追問
  • review_after:過了這天就視為 stale
  • history:每次變更的紀錄,下面會說明

Schema 也要檢查日期關係:

def validate_dates(record: dict) -> list[str]:
    errors = []
    v = date.fromisoformat(record["verified_at"])
    r = date.fromisoformat(record["review_after"])
    if r <= v:
        errors.append(f"{record['id']}: review_after must be after verified_at")
    if (r - v).days > MAX_REVIEW_DAYS:
        errors.append(f"{record['id']}: review window exceeds {MAX_REVIEW_DAYS} days")
    return errors

MAX_REVIEW_DAYS 防止有人為了省事把 review_after 設成十年後。

重驗週期怎麼定

不是每筆紀錄都需要同樣的頻率。可以依風險分級:

review_policy:
  high:    # 付款、登入、個資輸入入口
    max_review_days: 30
  medium:  # 資訊頁、官方公告
    max_review_days: 90
  low:     # 靜態說明文件
    max_review_days: 180

原則是:越接近交易、登入或個資輸入的入口,重驗週期越短。紀錄加上 risk 欄位後,schema 檢查就用對應的上限。

重驗流程要寫成步驟

重驗不是「再看一眼覺得沒問題」。SKILL.md 或維護文件要寫明步驟:

Revalidation steps:
1. Open evidence_url directly. Do not start from the recorded host.
2. Confirm the page still names the same host as an official channel.
3. If confirmed, set verified_at to today, set review_after within the risk window, append a history entry.
4. If the evidence page is gone or names a different host, set state to revoked and append a history entry with the reason.
5. If the evidence is ambiguous, leave the record stale. Do not extend review_after.

第 1 步很關鍵。從被驗證的 host 出發去找證據,等於讓對方自己證明自己。證據必須來自獨立的官方來源頁。

第 5 步也很重要:不確定時,不要延長日期。讓它維持過期,分類器自然會回傳 UNCONFIRMED。

撤銷和刪除不一樣

當一個 host 不再是官方來源,直覺做法是刪掉那筆紀錄。但刪除會造成兩個問題:

  1. 分類器對這個 host 的回答從「已撤銷」變成「查無資料」,兩者對使用者的意義不同
  2. 之後沒有人知道這個 host 曾經是官方,也不知道為什麼被移除

所以撤銷用狀態表示:

{
  "id": "source:example-legacy",
  "host": "old-tickets.example.com",
  "state": "revoked",
  "revoked_at": "2026-09-15",
  "revoked_reason": "Official page no longer lists this host",
  "history": [
    {"date": "2026-03-01", "action": "verified", "by": "maintainer:alex"},
    {"date": "2026-09-15", "action": "revoked", "by": "maintainer:sam"}
  ]
}

分類器遇到 revoked 紀錄時,不回傳 OFFICIAL,也不當成完全陌生。Day 13 的規則已經涵蓋:一筆 active、一筆 revoked 指向同一 host 時回傳 CONFLICT;只有 revoked 時回傳 UNCONFIRMED,並在 evidence 附上撤銷紀錄 ID,讓使用者訊息可以說明「這個網址過去曾是官方,但目前已不再列出」。

audit history 是 append-only

history 只能新增,不能修改或刪除既有項目。CI 可以檢查這件事:

def check_history_append_only(old: dict, new: dict) -> list[str]:
    errors = []
    for rid, old_rec in old.items():
        new_rec = new.get(rid)
        if new_rec is None:
            errors.append(f"{rid}: record deleted; use state=revoked")
            continue
        old_h = old_rec.get("history", [])
        if new_rec.get("history", [])[:len(old_h)] != old_h:
            errors.append(f"{rid}: history rewritten")
    return errors

在 PR 裡比對 base branch 和 head branch 的 registry,就能擋下「順手刪掉舊紀錄」或「改寫過去的確認日期」。

離線檢查和線上監控要分開

freshness 維護很容易被做成「每次執行都即時連到 evidence_url 確認」。這樣做會讓核心分類器依賴網路,結果隨時間與網路狀況變動,Day 12 刻意保持離線的 eval 也會失去意義。

所以拆成兩層:

核心分類器(離線、確定性)
  輸入:網址、registry、執行日期
  輸出:status、evidence、日期
  不連網

維護監控(線上、非同步)
  定期打開 evidence_url
  發現變化時開 issue 或 PR
  不直接改分類結果

監控只能「提出變更」,不能「直接生效」。它發現 evidence 頁面消失時,開一個 PR 把 state 改成 revoked,經過 review 與 CI 再合併。這樣所有結果仍可追溯到 registry 的某個版本。

CI 裡的 freshness 報告

除了擋下錯誤,CI 也可以產生一份到期報告,讓維護者知道接下來要處理哪些紀錄:

def freshness_report(records: list[dict], today: date, warn_days: int = 14) -> dict:
    report = {"stale": [], "expiring_soon": [], "fresh": 0}
    for r in records:
        if r["state"] != "active":
            continue
        days_left = (date.fromisoformat(r["review_after"]) - today).days
        if days_left < 0:
            report["stale"].append(r["id"])
        elif days_left <= warn_days:
            report["expiring_soon"].append(r["id"])
        else:
            report["fresh"] += 1
    return report

這份報告要不要讓 CI 失敗,是一個政策選擇。我的建議是:

  • stale 紀錄不讓 PR 失敗,因為分類器已經會自動降級,行為仍然安全
  • 但 PR 如果新增或修改紀錄,被修改的那筆必須是 fresh

否則任何無關的 PR 都會因為別人負責的紀錄過期而被卡住。

新增 eval 案例

把 freshness 行為加進 Day 11 的 JSONL eval,並固定執行日期:

{"id": "fresh-active", "today": "2026-09-20", "url": "https://tickets.example.com/e/1", "expect": {"status": "OFFICIAL"}}
{"id": "stale-active", "today": "2026-10-05", "url": "https://tickets.example.com/e/1", "expect": {"status": "UNCONFIRMED", "reason": "stale_record"}}
{"id": "boundary-day", "today": "2026-10-01", "url": "https://tickets.example.com/e/1", "expect": {"status": "OFFICIAL"}}
{"id": "revoked-only", "today": "2026-09-20", "url": "https://old-tickets.example.com/e/1", "expect": {"status": "UNCONFIRMED", "reason": "revoked_record"}}

boundary-day 確認 review_after 當天仍有效,避免有人把 <= 改成 < 而沒人發現。

今天完成的驗收標準

[ ] 每筆紀錄都有 verified_at、verified_by、review_after
[ ] review_after 晚於 verified_at,且不超過風險等級上限
[ ] 過期紀錄預設降級為 UNCONFIRMED
[ ] 執行日期由呼叫端傳入,不在函式內取今天
[ ] 重驗從 evidence_url 開始,不從 host 開始
[ ] 不確定時不延長日期
[ ] 撤銷用 state=revoked,不刪除紀錄
[ ] history append-only,CI 會檢查
[ ] 線上監控只開 PR,不直接改結果
[ ] eval 涵蓋 fresh、stale、邊界日與 revoked

明天預告

Day 15 處理 Skill 的觸發與範圍:description 要怎麼寫,才能讓 Claude 在該用時載入 Skill、不該用時保持安靜,並用 eval 驗證觸發是否準確。


上一篇
Day 13|把失敗政策寫回 SKILL.md:先分流,再決定能不能繼續
系列文
把 Claude 練成專家:30 天打造可驗證的 Agent Skills 共 14 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言