JSONL LLM Log 持久化方案
動機
OTel 將 LLM 的 prompt 與 response 送到 Aspire Dashboard 可即時查看,但資料不持久。
JSONL 持久化的目的:
- 歷史對話存檔:保留所有 LLM 的 input/output,供後續分析
- 提示詞優化分析:比對不同 prompt 版本在相同情境下的輸出差異
- 版本追蹤:每筆 log 帶有 git commit hash,可精確對應到當時的提示詞版本,評估修改效果
架構:OTel 雙路徑輸出
ADK 預設的 OTLP pipeline 和自訂 JSONL pipeline 是兩條獨立路徑:
1 2 3 4 5 6 7 8 9
| ADK Framework(產生 OTel Log Records) │ ├──→ ADK 內建 OTLP Log Pipeline → Aspire Dashboard(即時視覺化) │ └──→ SessionEnrichProcessor(注入 session ID) ↓ BatchProcessor(緩衝) ↓ JSONLExporter → logs/llm-YYYY-MM-DD.jsonl(持久化)
|
透過 telemetry.WithLogRecordProcessors() 新增額外的 Processor,不影響原有的 OTLP pipeline。
自訂 JSONLExporter
實作 OTel sdklog.Exporter 介面,將每筆 Log Record 寫成一行 JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| type JSONLExporter struct { dir string version string mu sync.Mutex file *os.File date string }
func (e *JSONLExporter) Export(ctx context.Context, records []sdklog.Record) error { e.mu.Lock() defer e.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02") if today != e.date { e.rotate(today) }
for _, r := range records { entry := e.buildEntry(r) line, _ := json.Marshal(entry) e.file.Write(append(line, '\n')) } return nil }
|
日誌格式
每行一個 JSON 物件:
1 2 3 4 5 6 7 8 9 10 11
| { "timestamp": "2026-03-14T10:15:32.123456789Z", "event": "gen_ai.choice", "agent": "my_agent", "session": "conv-abc123def456", "invocation": "invoke-xyz789", "input_tokens": 245, "output_tokens": 1023, "version": "a3f9c12", "body": "{\"content\": \"...\", \"finish_reason\": \"STOP\"}" }
|
| 欄位 |
說明 |
timestamp |
ISO 8601 UTC,來自 OTel Record.ObservedTimestamp |
event |
OTel EventName(gen_ai.choice、gen_ai.system.message 等) |
agent |
Agent 名稱,從 gen_ai.agent.name attribute 或 service.name resource 取得 |
session |
對話 session ID(由 SessionEnrichProcessor 注入) |
invocation |
本次調用 ID,來自 gen_ai.operation.name |
input_tokens |
輸入 token 數,來自 gen_ai.usage.input_tokens |
output_tokens |
輸出 token 數,來自 gen_ai.usage.output_tokens |
version |
git commit hash(帶 -dirty 若有未提交變更),用於對照提示詞版本 |
body |
LLM 的完整 prompt 或 response 內容 |
版本追蹤設計
version 欄位自動偵測當前 git commit hash:
1 2 3 4 5 6 7 8 9 10 11 12
| func DetectVersion() string { if v := os.Getenv("AGENT_VERSION"); v != "" { return v } hash, _ := exec.Command("git", "rev-parse", "--short", "HEAD").Output() status, _ := exec.Command("git", "status", "--porcelain").Output() if hasDirtyTracked(status) { return strings.TrimSpace(string(hash)) + "-dirty" } return strings.TrimSpace(string(hash)) }
|
使用流程:
- 修改提示詞 → commit → 取得新的 hash
- 執行 agent,JSONL 每筆 log 帶有該 commit hash
- 對比不同 hash 的 log,評估提示詞修改效果
日誌輪替
- 以 UTC 日期為單位,每天建立新檔:
logs/llm-2026-03-14.jsonl
- 日期切換時,前一天的檔案自動移至
logs/archive/
- 目前寫入的檔案永遠在
logs/ 根目錄,方便 tail / jq
jq 查詢範例
1 2 3 4 5 6 7 8 9 10 11
| jq 'select(.session == "conv-abc123")' logs/llm-2026-03-14.jsonl
jq 'select(.agent == "my_agent" and .event == "gen_ai.choice")' logs/llm-*.jsonl
jq -s 'group_by(.session) | map({session: .[0].session, total_output: map(.output_tokens // 0) | add})' logs/llm-2026-03-14.jsonl
jq -s 'group_by(.version) | map({version: .[0].version, avg_output: (map(.output_tokens // 0) | add / length)})' logs/llm-*.jsonl
|