JSONL LLM Log 持久化方案

動機

OTel 將 LLM 的 prompt 與 response 送到 Aspire Dashboard 可即時查看,但資料不持久。

JSONL 持久化的目的:

  1. 歷史對話存檔:保留所有 LLM 的 input/output,供後續分析
  2. 提示詞優化分析:比對不同 prompt 版本在相同情境下的輸出差異
  3. 版本追蹤:每筆 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 // 目前寫入的日期(UTC)
}

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.choicegen_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))
}

使用流程:

  1. 修改提示詞 → commit → 取得新的 hash
  2. 執行 agent,JSONL 每筆 log 帶有該 commit hash
  3. 對比不同 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
# 查看特定 session 的所有 LLM 對話
jq 'select(.session == "conv-abc123")' logs/llm-2026-03-14.jsonl

# 查看特定 agent 的所有輸出
jq 'select(.agent == "my_agent" and .event == "gen_ai.choice")' logs/llm-*.jsonl

# 統計每個 session 的 token 用量
jq -s 'group_by(.session) | map({session: .[0].session, total_output: map(.output_tokens // 0) | add})' logs/llm-2026-03-14.jsonl

# 比較兩個版本的平均 output token(提示詞效率)
jq -s 'group_by(.version) | map({version: .[0].version, avg_output: (map(.output_tokens // 0) | add / length)})' logs/llm-*.jsonl