"""SQLite 遥测记录器 — TelemetryRecorder Protocol 的生产实现。 通过 asyncio.to_thread 将 SQLite 同步写入桥接到异步接口,确保事件循环不被阻塞。 构造时建单持久连接 + 建表(对齐 app/harness/log.py:HarnessLog 的并发写模式), 写入经进程内 threading.Lock 串行化,消除多连接并发写的 database is locked。 零丢失保证范围 = 单进程、单 recorder 实例(当前 main.py / video_split_cli 均单实例 注入)。同进程多个 recorder 指向同一 db 会退回跨连接竞争——本实现不支持该场景。 """ from __future__ import annotations import asyncio import sqlite3 import threading from pathlib import Path from loguru import logger class SQLiteTelemetryRecorder: """基于 SQLite 的 LLM 调用遥测记录器。 Parameters ---------- db_path : Path SQLite 数据库文件路径,父目录必须存在。 """ _CREATE_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS llm_calls ( call_id TEXT PRIMARY KEY, parent_call_id TEXT, session_id TEXT, model_name TEXT NOT NULL, provider TEXT NOT NULL, messages TEXT NOT NULL, response TEXT NOT NULL, thinking TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL, completion_tokens INTEGER NOT NULL, latency_ms INTEGER NOT NULL, ttft_ms REAL, max_inter_token_ms REAL, cache_hit INTEGER NOT NULL DEFAULT 0, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); """ _INSERT_SQL = """ INSERT OR IGNORE INTO llm_calls ( call_id, parent_call_id, session_id, model_name, provider, messages, response, thinking, prompt_tokens, completion_tokens, latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """ def __init__(self, db_path: Path) -> None: """建单持久连接 + 进程内 Lock(对齐 app/harness/log.py:HarnessLog 并发写模式)。 把并发控制拉到进程内(threading.Lock 串行化写),消除"每次新连接并发写同一 db、靠 SQLite busy_timeout 跨连接协调"在高频下撑爆 timeout → database is locked 的根因。check_same_thread=False:record_llm_call 经 asyncio.to_thread 在线程池 不同线程调用,共享连接跨线程访问需此 flag,串行性由 self._lock 保证。 遥测哲学(P5):连接初始化失败降级不冒泡(self._conn=None,写入直接丢弃 warning), 绝不因遥测故障拖垮 LLM 调用 / 训练。 """ self._db_path = db_path self._lock = threading.Lock() self._conn: sqlite3.Connection | None = None # mkdir / connect / PRAGMA / 建表统一纳入降级边界:任一失败(OSError 含 # PermissionError、sqlite3.Error)都降级为 self._conn=None,绝不冒泡拖垮初始化。 try: Path(db_path).parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(db_path), check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") conn.execute(self._CREATE_TABLE_SQL) conn.commit() self._conn = conn except (OSError, sqlite3.Error) as exc: logger.warning("遥测连接初始化失败(已降级,后续写入丢弃): {}", exc) self._conn = None def close(self) -> None: """幂等关闭持久连接(对齐 HarnessLog;进程退出前可选调以释放 fd)。 不调也不丢数据——每次 _write 已 commit 落 WAL,进程退出 OS 回收 fd、 WAL 已提交内容下次打开自动 checkpoint 恢复。 """ with self._lock: if self._conn is not None: self._conn.close() self._conn = None def _write( self, *, call_id: str, parent_call_id: str | None, session_id: str | None, model_name: str, provider: str, messages: str, response: str, thinking: str, prompt_tokens: int, completion_tokens: int, latency_ms: int, ttft_ms: float | None, max_inter_token_ms: float | None, cache_hit: bool, error: str | None, ) -> None: """同步写入一条 LLM 调用记录(单持久连接 + Lock 串行化,对齐 HarnessLog)。 三层防御: 1. INSERT OR IGNORE — call_id 主键冲突静默忽略(幂等) 2. 进程内 threading.Lock 串行化写 — 消除并发锁竞争(非依赖 SQLite busy_timeout) 3. try/except sqlite3.Error — DB 错误降级不冒泡,遥测失败绝不拖垮 LLM 调用 """ if self._conn is None: logger.warning("遥测连接不可用(已降级),丢弃 call_id={}", call_id) return try: with self._lock: self._conn.execute( self._INSERT_SQL, ( call_id, parent_call_id, session_id, model_name, provider, messages, response, thinking, prompt_tokens, completion_tokens, latency_ms, ttft_ms, max_inter_token_ms, int(cache_hit), error, ), ) self._conn.commit() except sqlite3.Error as exc: logger.warning("遥测写入失败(已降级),call_id={}: {}", call_id, exc) async def record_llm_call( self, *, call_id: str, parent_call_id: str | None, session_id: str | None, model_name: str, provider: str, messages: str, response: str, thinking: str, prompt_tokens: int, completion_tokens: int, latency_ms: int, ttft_ms: float | None, max_inter_token_ms: float | None, cache_hit: bool, error: str | None, ) -> None: """异步记录一次 LLM 调用的遥测数据。 通过 asyncio.to_thread 将阻塞的 SQLite 写入卸载到线程池, 避免阻塞事件循环。 Parameters ---------- call_id : str 本次调用唯一标识(UUID)。 parent_call_id : str | None 父调用 ID(agent step → LLM call 链路)。 session_id : str | None epoch/step/question 关联 ID。 model_name : str 使用的模型名。 provider : str API 端点标识。 messages : str 原始输入(JSON 字符串)。 response : str 原始输出。 thinking : str 模型思考过程。 prompt_tokens : int 输入 token 用量。 completion_tokens : int 输出 token 用量。 latency_ms : int 总延迟毫秒。 ttft_ms : float | None 首 token 延迟毫秒。 max_inter_token_ms : float | None 最大 token 间隔毫秒。 cache_hit : bool 是否命中 Redis 缓存。 error : str | None 异常信息(正常为 None)。 """ await asyncio.to_thread( self._write, call_id=call_id, parent_call_id=parent_call_id, session_id=session_id, model_name=model_name, provider=provider, messages=messages, response=response, thinking=thinking, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, latency_ms=latency_ms, ttft_ms=ttft_ms, max_inter_token_ms=max_inter_token_ms, cache_hit=cache_hit, error=error, )