模組 06 · 第 3 課

日誌和可觀測性

寫一個幾十行的追蹤器,把智慧體每一次模型呼叫、每一次工具呼叫都記成一行 JSON。事後只讀日誌,就能算出每個問題花了多少錢和時間、最慢的是哪一步、哪個工具在出錯。

  • 約 40 分鐘
  • 難度:進階
  • 實測:2026-09-14 deepseek-flash

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

應用上線之後,使用者在你看不見的地方使用它。某天有人反饋"你們的助手回答了一個完全錯誤的東西",或者月底賬單比預期多了三倍,你打算怎麼查?

如果手裡只有使用者的問題和最終的回答,你幾乎無從下手:是檢索沒找到對的文件?是模型讀錯了?是某個工具報錯了?還是它在某一步走偏了,接下來越錯越遠?

可觀測性(observability)說的就是:系統執行時留下足夠的記錄,讓你事後能還原出它到底做了什麼。對 AI 應用來說,最重要的記錄是每一次模型呼叫和每一次工具呼叫。

要記什麼

第 03 模組第 4 課的 call_llm 已經在記賬了:每次呼叫寫一行 JSON,包括時間、模型、耗時、詞元和費用。那是一個好的開始,但對智慧體來說還不夠。智慧體回答一個問題要呼叫好幾次模型、好幾次工具,這些記錄要能串起來。

所以每條記錄還要有:

  • trace_id:同一個問題的所有記錄,共用一個編號。憑它能把一個問題的全部過程找出來。
  • span_id 和 parent_id:每條記錄自己的編號,以及它的上級是誰。比如"回答問題"是上級,下面有三次模型呼叫和三次工具呼叫。
  • kind 和 name:記錄的型別(任務、模型呼叫、工具呼叫)和具體名字(哪個模型、哪個工具)。
  • 輸入和輸出的摘要:工具的參數、返回內容的前幾十個字、模型要求呼叫了哪些工具。
  • 耗時、詞元、費用、狀態:成功、報錯,還是工具返回了錯誤資訊。

這套叫法(trace、span)來自分散式系統的追蹤技術。現成的開源工具,比如 Langfuse(可以自己部署),以及 OpenTelemetry 這一套標準,都用同樣的概念。先自己寫一個最簡單的,理解它在做什麼。

一個幾十行的追蹤器

import json
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path

_lock = threading.Lock()
_local = threading.local()  # 记录当前线程正在进行的 span,用来自动找到上级


class Tracer:
    def __init__(self, path):
        self.path = Path(path)

    def _write(self, record):
        with _lock, self.path.open("a") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")

    @contextmanager
    def span(self, kind, name, **attrs):
        """用法:with tracer.span("llm", "chat") as s: ...; s["tokens"] = 100"""
        parent = getattr(_local, "current", None)
        record = {
            "trace_id": parent["trace_id"] if parent else uuid.uuid4().hex[:12],
            "span_id": uuid.uuid4().hex[:8],
            "parent_id": parent["span_id"] if parent else None,
            "kind": kind,
            "name": name,
            "start": time.strftime("%Y-%m-%d %H:%M:%S"),
            **attrs,
        }
        _local.current = record
        start = time.time()
        try:
            yield record
            record.setdefault("status", "ok")
        except Exception as e:
            record["status"] = "error"
            record["error"] = f"{type(e).__name__}: {e}"
            raise
        finally:
            record["ms"] = round((time.time() - start) * 1000)
            _local.current = parent
            self._write(record)

用法是一個 with 語句:

with tracer.span("tool", "grep_docs", args={"keyword": "timeout"}) as s:
    result = grep_docs("timeout")
    s["result_chars"] = len(result)

with 塊結束時,追蹤器自動算出耗時,寫一行 JSON。塊裡出了異常,也會記下來,狀態是 error,然後照常把異常丟擲去。

parent_id 是自動找的:追蹤器用 threading.local() 記住"當前正在進行的是哪個 span"。在"回答問題"這個 span 裡面開始的模型呼叫,它的上級自然就是"回答問題"。每個執行緒有自己的記錄,所以多執行緒併發處理幾個問題也不會串。

接到智慧體上

不用改第 05 模組第 2 課的智慧體程式碼,只要在外面包一層。模型呼叫:

class TracedModel(agent_loop.RealModel):
    """每次调用模型,记一个 llm 类型的 span。"""

    def __call__(self, messages, tools):
        with tracer.span("llm", self.model, input_messages=len(messages)) as s:
            message, usage = super().__call__(messages, tools)
            s.update(prompt_tokens=usage.prompt_tokens, completion_tokens=usage.completion_tokens,
                     cost=round(cost(usage), 6), tool_calls=[c.function.name for c in message.tool_calls or []])
            return message, usage

工具呼叫:

def traced(name, fn):
    """包装一个工具函数,每次调用记一个 tool 类型的 span。"""
    def wrapper(**kwargs):
        with tracer.span("tool", name, args=kwargs) as s:
            result = fn(**kwargs)
            s["result_chars"] = len(result)
            s["result_preview"] = result[:80]
            if result.startswith("错误"):
                s["status"] = "tool_error"
            return result
    return wrapper


for name, t in agent_loop.TOOLS.items():
    t["fn"] = traced(name, t["fn"])

工具返回"錯誤:……"時,函式本身並沒有拋異常(第 05 模組講過,錯誤要變成觀察結果交給模型),所以要單獨標記成 tool_error,否則日誌裡看起來一切正常。

最外層,每個問題開一個 task 型別的 span:

    for q in QUESTIONS:
        with tracer.span("task", "answer_question", question=q) as s:
            answer, stats = agent_loop.run_agent(model, q, verbose=False)
            s["answer_preview"] = (answer or "")[:60]

跑三個問題,然後只看日誌

三個問題,最後一個的檔名是故意寫錯的(正確的是 timeouts.md)。跑完之後,程式只讀日誌檔案做分析,就像事後排查問題時那樣(完整程式碼在 code/06-production/traced_agent.py):

日志共 25 条记录,最后一条:
{"trace_id": "173ee49bde3e", "span_id": "f9e2c3a5", "parent_id": null, "kind": "task", "name": "answer_question", "start": "2026-09-14 23:07:41", "question": "advanced/timeout.md 里写了什么?", "answer_preview": "`advanced/timeouts.md` 的内容如下(文件共 71 行;注意文件名是复数 timeouts,没有 `", "status": "ok", "ms": 5729}

「httpx 的超时分成哪几种?」 4006 毫秒,3 次模型调用,3 次工具调用,0.00104 美元
「httpx 怎么上传文件?」 5352 毫秒,3 次模型调用,6 次工具调用,0.00210 美元
「advanced/timeout.md 里写了什么?」 5729 毫秒,4 次模型调用,3 次工具调用,0.00135 美元

最慢的一步:llm deepseek-flash,3120 毫秒
工具报错次数:无

三個問題一共 25 條記錄。按 trace_id 分組,每個問題的耗時、呼叫次數、費用一目瞭然。"怎麼上傳檔案"呼叫了 6 次工具,比別的問題多一倍,費用也高一倍,開啟它的記錄就能看到它先後搜了 files=multipartupload 三個關鍵詞,又讀了三個檔案。最慢的一步是一次模型呼叫,3 秒多,工具呼叫都只要幾毫秒。

我原本以為第三個問題會留下一條"檔案不存在"的工具報錯,結果沒有:智慧體先呼叫了 list_docs 看了看有哪些檔案,發現正確的名字是 timeouts.md,然後直接讀了它,回答裡還提醒了使用者"注意檔名是複數"。這也是日誌的價值:它記下的是真實發生的事,而不是你以為會發生的事。

用日誌排查問題

有了這樣的日誌,常見問題的排查方式:

現象 看什麼
回答錯了 按 trace_id 找出這個問題的全部記錄,依次看:檢索到了什麼,模型要求呼叫了什麼,工具返回了什麼。通常能定位到出錯的那一步
某個問題特別慢 看這個問題下面每個 span 的 ms,是哪次模型呼叫慢,還是哪個工具慢
賬單漲了 按天、按問題彙總 cost,找出最貴的問題,看它們是步數多,還是輸入太長
某個工具經常出錯 統計 statustool_error 的記錄,看出錯時的參數,說明書可能要改(第 05 模組第 3 課)
智慧體陷入迴圈 看步數接近上限的問題,看它是不是在反覆呼叫同一個工具

JSONL 檔案簡單、可靠,用 Python 幾行程式碼就能分析,適合個人專案和早期的產品。量大了之後,可以把這些記錄發到專門的工具裡(比如 Langfuse),它會提供介面來瀏覽每一條 trace、按條件搜尋、畫統計圖。概念是一樣的。

注意隱私

日誌裡會記下使用者的問題、工具的參數、回答的開頭。如果使用者在問題裡寫了自己的手機號、密碼、公司內部資訊,這些都會進入日誌。

  • 只記必要的內容。上面的程式碼只記了回答的前 60 個字、工具結果的前 80 個字,不記完整內容。
  • 過濾敏感資訊。在寫日誌之前,把看起來像金鑰、手機號、身份證號的內容替換掉。下下一課"護欄"會寫一個簡單的檢測函式。
  • 設定保留期限。日誌不要永遠留著,比如只保留 30 天。
  • 控制訪問許可權。能看日誌的人,就能看到使用者問了什麼。

練習

  1. 執行 traced_agent.py,然後自己寫幾行程式碼讀取 traces.jsonl,找出每個問題裡輸入詞元最多的那次模型呼叫。
  2. TracedModel 加一個欄位,記錄每次呼叫時輸入的總字元數,看看智慧體每一步的上下文是怎麼增長的。
  3. 故意製造一次工具報錯(比如臨時修改 read_doc,讓它對某個檔案總是返回"錯誤:……"),確認日誌的統計能發現它。

自測

1. trace_id、span_id、parent_id 分別有什麼用?

trace_id 把同一個任務(比如回答一個問題)的所有記錄串在一起;span_id 是每一條記錄自己的編號;parent_id 指向它的上級記錄。有了這三個欄位,就能從日誌裡還原出一個任務的完整結構:先做了什麼,裡面又呼叫了什麼。

2. 工具返回"錯誤:沒有這個檔案"時,函式並沒有丟擲異常。日誌裡怎麼才能發現它?

要在包裝工具的程式碼裡檢查返回值,發現是錯誤資訊時,單獨把狀態標記成 tool_error 之類的值。否則日誌裡這次呼叫的狀態是正常的,統計錯誤時就會漏掉。

3. 為什麼日誌裡不要記錄完整的使用者問題和回答?

使用者的輸入裡可能有個人資訊、密碼、公司機密,日誌一旦洩露或者被不該看的人看到,就會造成隱私問題。只記排查問題所需的部分,寫入前過濾敏感資訊,並設定保留期限和訪問許可權。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…