到今天為止,我們的程式在出事的時候,唯一的線索就是 print() 出來的那幾行字。而且程式一關掉,那些字就消失了。
這在寫小工具時沒什麼問題。但接下來幾天 AI 要進場,情況會變得複雜很多:
你問 Agent「幫我安排明天的行程」,它回了一段莫名其妙的建議。
它到底為什麼這樣回答?
它去查天氣了嗎?查到什麼?它呼叫了幾個工具?中間有沒有失敗?它看到的 prompt 長什麼樣?
如果沒有紀錄,這些問題你一個都答不出來。你只能一直重跑,一直猜。
今天要解決這件事。
print() 的問題有四個:
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」,讓每行的欄位對齊,好讀很多。
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 秒
注意上面寫的是:
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)。
一般的 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.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 和統計。
傳統程式是確定性的:同樣的輸入,永遠得到同樣的輸出。出錯了,你重跑一次就能重現。
**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,然後就知道要加上重複偵測。
反面也要講:
[: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 多了:分級、時間戳、來源、存檔、集中控制logging.getLogger(__name__) 建立自己的 logger%s 而不是 f-string,讓格式化延遲到真的要輸出時RotatingFileHandler 避免 log 檔無限成長,記得 encoding="utf-8"
logger.exception() 在 except 區塊裡記錄完整 traceback基礎建設到這裡告一段落。明天,AI 終於要進場了。