iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理系列 第 17 篇

讓程式記住它做過什麼:logging 與 Agent 的可觀測性

  • 分享至 

  • xImage
  •  

到今天為止,我們的程式在出事的時候,唯一的線索就是 print() 出來的那幾行字。而且程式一關掉,那些字就消失了。

這在寫小工具時沒什麼問題。但接下來幾天 AI 要進場,情況會變得複雜很多:

你問 Agent「幫我安排明天的行程」,它回了一段莫名其妙的建議。

它到底為什麼這樣回答?

它去查天氣了嗎?查到什麼?它呼叫了幾個工具?中間有沒有失敗?它看到的 prompt 長什麼樣?

如果沒有紀錄,這些問題你一個都答不出來。你只能一直重跑,一直猜。

今天要解決這件事。


一、為什麼不用 print 就好

print() 的問題有四個:

  1. 全部都印出來,沒辦法分級(我現在只想看錯誤)
  2. 只有內容,沒有時間、沒有來源(哪個模組印的?什麼時候?)
  3. 關掉就沒了,不會存檔
  4. 要關掉得一行一行刪(或是全部註解掉,然後下次除錯再解開)

Python 內建的 logging 模組把這四件事都解決了。


二、五個等級

import logging

logging.debug("變數 x 的值是 42")        # 開發時的細節
logging.info("成功查詢臺北市天氣")        # 正常流程的紀錄
logging.warning("快取過期,重新查詢")     # 有點怪但還能運作
logging.error("查詢天氣失敗:逾時")       # 某件事失敗了
logging.critical("無法連線到資料庫")      # 嚴重到程式沒辦法繼續

這五個等級由低到高:DEBUG < INFO < WARNING < ERROR < CRITICAL。

設定一個等級,就只會輸出那個等級以上的訊息。

logging.basicConfig(level=logging.INFO)
# DEBUG 不會顯示,INFO 以上都會

這代表開發時設 DEBUG 看全部,正式跑時設 INFO 或 WARNING——不用改任何一行 log 程式碼。

怎麼決定用哪一級?

我自己的判斷:

等級 什麼時候用 例子
DEBUG 只有我在除錯時想看的細節 完整的 API 回應、prompt 內容、變數值
INFO 「系統正常做了某件事」 呼叫了哪個工具、耗時多久、使用了多少 token
WARNING 「怪怪的但還能跑」 快取失效、重試了一次、用了預設值
ERROR 「某件事失敗了」 工具執行失敗、API 回 500
CRITICAL 「整個服務不能用了」 缺少必要的設定、無法初始化

三、基本設定

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

logging.info("程式啟動")

輸出:

2026-10-01 09:15:32 | INFO     | root | 程式啟動

常用的 format 欄位:

欄位 意思
%(asctime)s 時間
%(levelname)s 等級(INFO / ERROR…)
%(name)s logger 名稱(通常是模組名)
%(message)s 訊息內容
%(filename)s / %(lineno)d 檔名 / 行號
%(funcName)s 函式名稱

%(levelname)-8s 裡的 -8 是「靠左對齊、寬度 8」,讓每行的欄位對齊,好讀很多。


四、每個模組一個 logger

logging.info() 用的是根 logger。比較好的做法是每個模組建立自己的:

# tools/weather.py
import logging

logger = logging.getLogger(__name__)      # __name__ 會是 "tools.weather"


def get_weather(city, api_key):
    logger.info("開始查詢天氣:city=%s", city)

    cached = read_cache(city)
    if cached:
        logger.debug("使用快取結果")
        return cached

    try:
        response = requests.get(...)
        response.raise_for_status()
    except requests.Timeout:
        logger.error("查詢天氣逾時:city=%s", city)
        raise WeatherError("查詢天氣逾時")

    logger.info("查詢成功:city=%s,耗時 %.2f 秒", city, response.elapsed.total_seconds())
    return parse_forecast(response.json())

輸出會自動標明來源:

2026-10-01 09:15:32 | INFO     | tools.weather | 開始查詢天氣:city=臺北市
2026-10-01 09:15:33 | INFO     | tools.weather | 查詢成功:city=臺北市,耗時 0.84 秒

用 %s 而不是 f-string

注意上面寫的是:

logger.info("開始查詢天氣:city=%s", city)        # ✅
logger.info(f"開始查詢天氣:city={city}")          # ⚠️ 也能動,但不理想

差別在於:用 %s 的話,只有這行 log 真的要輸出時才會做字串格式化。如果等級設成 WARNING,這行 DEBUG/INFO 根本不會被格式化,省下運算。

對只跑幾次的程式沒差,但 Agent 的迴圈裡可能有上千行 log,累積起來就有感了。


五、同時輸出到螢幕和檔案

實務上我們希望:螢幕只看重要的,檔案記錄全部。

這靠 Handler 實現:

"""logger_setup.py — 統一的 logging 設定。"""

import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path

LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)


def setup_logging(console_level=logging.INFO, file_level=logging.DEBUG):
    """設定整個專案的 logging。在程式最開頭呼叫一次。"""

    root = logging.getLogger()
    root.setLevel(logging.DEBUG)      # 根 logger 收全部,由 handler 各自過濾

    # 避免重複設定時累加 handler
    root.handlers.clear()

    # --- 螢幕:簡潔 ---
    console = logging.StreamHandler(sys.stdout)
    console.setLevel(console_level)
    console.setFormatter(logging.Formatter(
        "%(levelname)-8s | %(message)s"
    ))
    root.addHandler(console)

    # --- 檔案:完整,會自動輪替 ---
    file_handler = RotatingFileHandler(
        LOG_DIR / "assistant.log",
        maxBytes=5 * 1024 * 1024,     # 單檔最大 5MB
        backupCount=5,                # 保留 5 個舊檔
        encoding="utf-8",             # 中文一定要加
    )
    file_handler.setLevel(file_level)
    file_handler.setFormatter(logging.Formatter(
        "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    ))
    root.addHandler(file_handler)

    # 把第三方套件的噪音壓下來
    logging.getLogger("urllib3").setLevel(logging.WARNING)
    logging.getLogger("httpx").setLevel(logging.WARNING)

    return root

用法:

# main.py
from logger_setup import setup_logging

setup_logging()

import logging
logger = logging.getLogger(__name__)
logger.info("生活助理啟動")

幾個重點:

RotatingFileHandler
log 檔會越長越大。這個 handler 會在超過 maxBytes 時自動把舊的改名成 assistant.log.1、.2…,只保留 backupCount 個。不加的話,跑一陣子你會發現有個 800MB 的 log 檔。

根 logger 設 DEBUG,由 handler 各自過濾
這是一個小技巧。根 logger 的等級是第一道關卡,訊息過不了這關就完全消失。設成 DEBUG 讓所有訊息都能通過,再讓每個 handler 決定自己要什麼。

encoding="utf-8"
Windows 上不加的話,中文 log 會變成亂碼。跟 Day 9 一樣的坑。

壓掉第三方的噪音
requests 底層的 urllib3 在 DEBUG 等級下會印出每一個連線細節,非常吵。把它調到 WARNING。


六、記錄例外

Day 8 學的例外處理,配上 logging:

try:
    data = get_weather(city, api_key)
except WeatherError as e:
    logger.error("天氣查詢失敗:%s", e)

但這樣只有錯誤訊息,沒有 traceback。想看完整堆疊用 exception():

except Exception as e:
    logger.exception("未預期的錯誤")

logger.exception() 只能在 except 區塊裡用,它會自動附上完整的 traceback。這在 Agent 裡非常重要——因為 Agent 不會崩潰(我們用 safe_run() 擋掉了),所以如果不主動記錄,那個 traceback 就永遠消失了。

等價寫法:logger.error("訊息", exc_info=True)。


七、幫 Agent 設計專屬的紀錄

一般的 log 記「程式做了什麼」。Agent 還需要記「它為什麼這樣決定」。

這在 AI 領域叫做 trace(執行軌跡)。我們用 JSON Lines 格式(每行一個 JSON)來記:

"""tracer.py — 記錄 Agent 的每一步決策。"""

import json
import time
import uuid
from datetime import datetime
from pathlib import Path

TRACE_DIR = Path("logs/traces")
TRACE_DIR.mkdir(parents=True, exist_ok=True)


class Tracer:
    """記錄一次 Agent 執行的完整軌跡。"""

    def __init__(self, session_id=None):
        self.session_id = session_id or uuid.uuid4().hex[:8]
        self.path = TRACE_DIR / f"{self.session_id}.jsonl"
        self.started_at = time.time()
        self.step = 0

    def _write(self, event_type, **payload):
        self.step += 1
        record = {
            "session": self.session_id,
            "step": self.step,
            "at": datetime.now().isoformat(timespec="seconds"),
            "elapsed": round(time.time() - self.started_at, 2),
            "type": event_type,
            **payload,
        }
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")
        return record

    def user_input(self, text):
        return self._write("user_input", text=text)

    def model_call(self, model, messages_count, tools_count):
        return self._write(
            "model_call",
            model=model,
            messages=messages_count,
            tools=tools_count,
        )

    def model_response(self, stop_reason, text=None, usage=None):
        return self._write(
            "model_response",
            stop_reason=stop_reason,
            text=(text or "")[:500],      # 截斷,避免檔案爆炸
            usage=usage,
        )

    def tool_call(self, name, arguments):
        return self._write("tool_call", tool=name, arguments=arguments)

    def tool_result(self, name, ok, content, duration):
        return self._write(
            "tool_result",
            tool=name,
            ok=ok,
            content=str(content)[:500],
            duration=round(duration, 3),
        )

    def final_answer(self, text):
        return self._write("final_answer", text=text)

    def error(self, message, detail=None):
        return self._write("error", message=message, detail=detail)

用起來像這樣(這是 Day 22 之後的 Agent 主迴圈的樣子,先預覽一下):

tracer = Tracer()
tracer.user_input("幫我看明天要不要帶傘")

tracer.model_call("claude-opus-5", messages_count=1, tools_count=3)
tracer.model_response("tool_use", usage={"input": 820, "output": 65})

start = time.time()
tracer.tool_call("get_weather", {"city": "臺北市"})
result = weather_tool.safe_run({"city": "臺北市"})
tracer.tool_result("get_weather", result["ok"], result["content"], time.time() - start)

tracer.final_answer("明天降雨機率 60%,建議帶傘。")

產生的 logs/traces/a3f9c1b2.jsonl:

{"session":"a3f9c1b2","step":1,"at":"2026-10-01T09:15:32","elapsed":0.0,"type":"user_input","text":"幫我看明天要不要帶傘"}
{"session":"a3f9c1b2","step":2,"at":"2026-10-01T09:15:32","elapsed":0.01,"type":"model_call","model":"claude-opus-5","messages":1,"tools":3}
{"session":"a3f9c1b2","step":3,"at":"2026-10-01T09:15:34","elapsed":2.11,"type":"model_response","stop_reason":"tool_use","text":"","usage":{"input":820,"output":65}}
{"session":"a3f9c1b2","step":4,"at":"2026-10-01T09:15:34","elapsed":2.12,"type":"tool_call","tool":"get_weather","arguments":{"city":"臺北市"}}
{"session":"a3f9c1b2","step":5,"at":"2026-10-01T09:15:35","elapsed":2.95,"type":"tool_result","tool":"get_weather","ok":true,"content":"【臺北市】未來 36 小時天氣...","duration":0.83}
{"session":"a3f9c1b2","step":6,"at":"2026-10-01T09:15:37","elapsed":5.02,"type":"final_answer","text":"明天降雨機率 60%,建議帶傘。"}

現在你可以完整重建這次執行的每一步了。

為什麼用 JSON Lines

  • 可以一直往後加,不用讀出整個檔案再寫回去
  • 每行獨立,中途當掉也不會毀掉整個檔案
  • 程式好分析——一行 json.loads() 就變成 dict

寫個小工具分析看看:

import json
from collections import Counter
from pathlib import Path


def analyze_traces():
    """統計所有 trace 的工具使用情況。"""
    tool_calls = Counter()
    tool_failures = Counter()
    durations = []

    for path in Path("logs/traces").glob("*.jsonl"):
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                record = json.loads(line)
                if record["type"] == "tool_result":
                    tool_calls[record["tool"]] += 1
                    if not record["ok"]:
                        tool_failures[record["tool"]] += 1
                    durations.append(record["duration"])

    print("工具使用統計:")
    for tool, count in tool_calls.most_common():
        fails = tool_failures[tool]
        rate = (count - fails) / count
        print(f"  {tool:20s} 呼叫 {count:3d} 次,成功率 {rate:.0%}")

    if durations:
        print(f"\n平均耗時:{sum(durations) / len(durations):.2f} 秒")

這剛好用上了昨天學的 Counter 和統計。


八、Agent 為什麼特別需要這個

傳統程式是確定性的:同樣的輸入,永遠得到同樣的輸出。出錯了,你重跑一次就能重現。

**AI 不是。**同一句話問兩次,模型可能做出不同的決定。這帶來一個很現實的問題:

Bug 可能無法重現。

所以 Agent 的除錯方式跟一般程式不同——不是「重現 bug」,而是「看紀錄」。

有了完整的 trace,你可以回答這些問題:

問題 看哪裡
它為什麼沒去查天氣? model_call 時 tools_count 是多少?工具真的有傳進去嗎?
它為什麼給了錯的日期? 有呼叫 get_current_datetime 嗎?
為什麼這麼慢? 每個 tool_result 的 duration
為什麼這次特別貴? usage 的 input / output token
它卡在無限迴圈? step 的數字,跟重複的 tool_call

最後一項特別重要。Agent 有可能陷入「呼叫工具 → 失敗 → 再呼叫同一個工具 → 再失敗」的迴圈。沒有 trace 你只會看到程式跑很久然後帳單很高。

有了 trace,你會清楚看到第 4、6、8、10 步都是同一個 tool_call,然後就知道要加上重複偵測。


九、不要記錄的東西

反面也要講:

  • API Key(Day 12 講過)
  • 使用者的敏感資料:身分證、電話、地址、健康狀況
  • 完整的長文本:我上面的 trace 都有 [:500] 截斷,不然幾次對話就幾十 MB

如果一定要記,考慮遮罩:

def mask(text, keep=4):
    if not text or len(text) <= keep:
        return "***"
    return text[:keep] + "*" * (len(text) - keep)

logger.info("使用 key: %s", mask(api_key, 8))
# 使用 key: sk-ant-a************************

小結

  • logging 比 print 多了:分級、時間戳、來源、存檔、集中控制
  • 五個等級由低到高:DEBUG / INFO / WARNING / ERROR / CRITICAL
  • 每個模組用 logging.getLogger(__name__) 建立自己的 logger
  • 用 %s 而不是 f-string,讓格式化延遲到真的要輸出時
  • RotatingFileHandler 避免 log 檔無限成長,記得 encoding="utf-8"
  • logger.exception() 在 except 區塊裡記錄完整 traceback
  • Agent 除了一般 log,還需要 trace——記錄每一步決策,用 JSON Lines 格式
  • AI 的行為不確定,bug 可能無法重現,所以「看紀錄」比「重現問題」更重要

基礎建設到這裡告一段落。明天,AI 終於要進場了。


上一篇
從一堆資料到一句結論:排序、篩選與統計
系列文
從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言