Claude Code 教學/第 24 篇・共 25 篇

LINE 家庭 AI 小幫手(四):給它工具,會記事、會提醒、記得家裡的事、看得懂照片

看懂 Claude 的工具迴圈怎麼運作,再替小幫手加上四個能力:安全的備忘錄、重開也不會掉的提醒、每晚自己整理的長期記憶,以及有人 @ 時才看的群組照片。

上一篇之後,小欣已經會聊天、會查網路。但她還只能「說」,不能「做」:答應會提醒你,其實什麼都沒排。這篇給她真正的工具。

開始前先講一個原則:每個工具都要想清楚它最多能做到什麼。 機器人背後是會自己做決定的模型,你給它刪檔案的能力,它就有可能刪錯。這篇的每個工具,我都會指出它的邊界在哪裡。

一、工具迴圈:Claude 怎麼「動手」

Claude 本身不能存檔、不能排程。它能做的是提出要求:「我想呼叫 add_reminder,參數是這些」。真正執行的是你的程式,執行完再把結果交還給它。這個來回叫做工具迴圈:

你的程式:問題+可用工具清單 → Claude
Claude:我要用 add_reminder(when=..., context=...)       stop_reason = "tool_use"
你的程式:真的去排程,把「已建立」回給 Claude
Claude:好,那我回覆家人「設好了,週五早上九點提醒你們」   stop_reason = "end_turn"

claude_client.py 裡對應的程式:

for _ in range(TOOL_LOOP_MAX):
    response = client.messages.create(
        model=MODEL, max_tokens=4096, system=system, tools=TOOLS, messages=messages
    )
    if response.stop_reason != "tool_use":
        return text_of(response.content)        # 講完了,回給家人

    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        try:
            output = TOOL_MODULES[block.name].run(block.name, block.input, {"target_id": target_id})
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
        except Exception as e:
            results.append({"type": "tool_result", "tool_use_id": block.id,
                            "content": str(e), "is_error": True})
    messages.append({"role": "user", "content": results})

(實際檔案還多了錯誤處理和 pause_turn,這裡為了好讀省略。)

三個重點:

  • TOOL_LOOP_MAX = 8:最多來回 8 次。萬一模型一直呼叫工具停不下來,迴圈也會結束,不會無限燒錢。
  • 工具出錯不讓程式掛掉:把錯誤訊息標成 is_error 還給 Claude,它通常會自己換個方法,或跟家人說明哪裡有問題。例如時間已經過了,它會回「那個時間已經過囉,要改成明天嗎?」
  • 每個工具檔案都是同一個樣子:一個 TOOLS 清單(給 Claude 看的說明書),加上一個 run() 函式(真正做事)。要加新工具,照這個樣子多寫一個檔案就好。

工具的說明書長這樣,description 是寫給模型看的,要寫得像在教新人:

{
    "name": "add_reminder",
    "description": (
        "建立一次性提醒。label 是簡短的唯一名稱(例如「週六看牙醫」)。"
        "when 是台北時間的 ISO 字串,例如 2026-12-06T09:00:00。"
        "context 用一句話描述要提醒什麼,時間到時你會依此寫提醒訊息。"
    ),
    "input_schema": { ... },
}

二、備忘錄:只能碰一個資料夾

家人說「幫我記一下週日要帶阿公回診」,小欣會呼叫 write_note 存成 data/notes/ 裡的檔案。問「我之前記了什麼」,就用 list_notes 和 read_note 找出來。

邊界在這裡:檔名是模型決定的,萬一它(或有心人誘導它)寫出 ../.env,就會讀到你的金鑰。所以每個檔名都先經過這道檢查:

def resolve_note_path(filename: str) -> Path:
    """擋掉 ../ 之類的路徑穿越:解析後的路徑一定要在 notes 資料夾裡。"""
    candidate = (NOTES_ROOT / filename).resolve()
    if NOTES_ROOT not in candidate.parents:
        raise ValueError(f"{filename} 超出備忘錄資料夾範圍")
    if candidate.suffix.lower() not in ALLOWED_EXT:
        raise ValueError(f"只允許 {sorted(ALLOWED_EXT)} 副檔名")
    return candidate

.resolve() 會把 ../ 算成真正的位置,再確認它還在 notes 資料夾底下,而且只能是 .md 或 .txt。測試檔裡也有對應的測試,專門拿 ../.env、/etc/passwd.md 來打。

不要只靠人設寫「不准讀 .env」。人設是建議,程式檢查才是牆。

三、提醒:答應了就要真的排

提醒用 APScheduler 這個排程套件:

scheduler.add_job(
    fire,
    trigger=DateTrigger(run_date=when),
    args=[label, context, target_id, True],
    id=label,
    replace_existing=True,
)

時間到時,fire() 會請 Claude 依 context 寫一句提醒,再用 LINE 的 Push API 送到當初說話的那個群組。

這裡有三個坑:

  1. 重開就消失。 APScheduler 預設只存在記憶體裡,程式一重開提醒全沒了。所以每建一個提醒,就同時寫一份到 data/schedules.json,啟動時再全部排回去,已經過期的單次提醒順手丟掉。
  2. 只說不做。 我家的版本早期很常回「好,我會提醒你」,但其實沒呼叫工具,時間到什麼都沒發生。人設裡那句「答應提醒之前一定要真的呼叫工具建好排程,不要只在嘴上說」就是為此加的。
  3. 推播要錢。 第一篇提過,Reply 免費、Push 計則數,推播到群組是群組幾個人就算幾則。每天固定推一次天氣給 8 人群組,一個月就是 240 則,會超過免費方案的 200 則。重複提醒要節制,或改成私訊給需要的人。

另外,「禮拜六提醒我」沒講幾點,人設要求先問清楚再建,免得半夜十二點把全家吵醒。

四、長期記憶:每晚整理一次

小欣有兩個地方存「家裡的事」:

  • knowledge.md:你手動寫的固定資訊(住哪、常用的天氣地點、誰對什麼過敏)。範本附了 knowledge.example.md。
  • data/knowledge_auto.md:小欣自己累積的長期記憶。

自動記憶有兩條路寫進去。一是對話中聽到重要的事,她可以自己呼叫 update_auto_knowledge。二是每晚 11 點回顧當天整個群組的對話,挑出值得長期記住的事實:

請找出對話裡值得長期記住的新事實:誰住哪、生日、健康狀況、過敏、職業變動、長期偏好、重要的長期計畫。
- 日常瑣事、情緒抒發、單次事件、約時間都不要記。
- 有新事實時,輸出完整更新後的清單……舊的每一條都要保留。
- 沒有值得記的新事實,只輸出 NONE。

為什麼是一天一次,而不是每句話都判斷?省錢,也比較準。 每句都問一次模型,一天可能上百次;集中在晚上看整天的對話,一次就好,而且看得到前因後果,不會把玩笑話當真。

模型偶爾會多講一句「好的,以下是更新後的清單」,所以寫回之前,clean_review_output() 只保留標題和「- 」開頭的條列,其他都丟掉。

我實測丟了兩句話進去:「我下個月要調到花蓮上班了,阿嬤也說她對蝦子過敏」和「大家晚上吃什麼」。晚上整理完,記憶多了兩條:調職、阿嬤對蝦子過敏;吃什麼那句沒有被記。

隱私提醒:這個功能代表機器人會讀群組裡每一句話。放進家庭群組前,請先讓家人知道它看得到、會記什麼。我也建議每週打開 knowledge_auto.md 看一次,刪掉記錯或不該記的。

五、看照片:有人 @ 才看

Claude 看得懂圖片,只要把圖片用 base64 編碼放進訊息:

{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg",
                             "data": base64.b64encode(img).decode("ascii")}}

但群組裡的照片,我刻意不是每張都看:

if in_group:
    # 群組照片先只記 id,等有人 @ 機器人時才真的下載來看
    state["last_image"][key] = {"id": event.message.id, "ts": datetime.now(TZ).timestamp()}
    return

有人傳照片,程式只記下那張照片的編號。三分鐘內如果有人 @ 小欣(例如「@小欣 這是什麼花」),才把照片下載來一起問;用過就清掉,避免之後不相關的問題又帶到舊照片。不 @ 的照片,小欣完全不會看,對家人的隱私、對你的帳單都比較好。

六、驗收

用 DRY_RUN=1 依序測:

venv/bin/python scripts/fake_line_event.py --group "@小欣 幫我記一下:週日要帶阿公回診"
venv/bin/python scripts/fake_line_event.py --group "@小欣 後天早上九點提醒我們帶健保卡"
venv/bin/python scripts/fake_line_event.py --group "我下個月要調到花蓮上班了"

然後檢查:

  1. data/notes/ 裡有一個記事檔,內容有回診那句。
  2. data/schedules.json 有一筆提醒,時間是後天 09:00。
  3. 重開 main.py,啟動訊息後提醒還在(schedules.json 沒被清掉)。
  4. 不想等到晚上 11 點的話,請 Claude 幫你手動執行一次 run_daily_memory_review(),看 data/knowledge_auto.md 有沒有多一條調職的事。

下一篇是最後一篇:讓小欣在電腦開機時自動上班、換成固定網址,再把帳本攤開,看一個月到底花多少錢。

參考來源

回教學列表