194 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""把一次 LLM 调用的输入、输出和调用明细原子落库。
调用入口只负责采集供应商响应;本模块负责把同一尝试的 raw lease、prompt、response
和 ``example_llm_call`` 放进一个事务。数据库失败时调用方不能拿到一份看似成功、却
没有审计证据的结果。
"""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
from persist_raw import _bare_sha256, _check_no_secrets # noqa: E402
CREATOR = "llm"
def _json_text(value) -> str:
"""生成稳定、可重放且不带请求头的 JSON 原文。"""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def _usage_int(usage, key):
"""读取供应商 usage,缺失或脏值按 0 记账。"""
value = (usage or {}).get(key, 0)
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _insert_raw(conn, *, lease_id, kind, content, run_id, role, creator):
"""在调用事务内幂等插入 raw,避免 append-only 表上的冲突 UPDATE。"""
sha = _bare_sha256(content)
row = conn.execute(
"INSERT INTO example_raw_content(lease_id, kind, run_id, role, content_sha256, content, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (lease_id, content_sha256) DO NOTHING "
"RETURNING id",
(lease_id, kind, run_id, role, sha, content, creator),
).fetchone()
if row:
return row[0], sha
row = conn.execute(
"SELECT id FROM example_raw_content WHERE lease_id=%s AND content_sha256=%s",
(lease_id, sha),
).fetchone()
if not row:
raise RuntimeError(f"raw {kind} 插入后无法回读 id")
return row[0], sha
def _reuse_raw(conn, *, lease_id, prompt_sha, response_sha):
"""迁移已有 raw 时只回读并校验,不复制全文或改变 append-only 数据。"""
prompt_row = conn.execute(
"SELECT id FROM example_raw_content WHERE lease_id=%s AND kind='prompt' AND content_sha256=%s",
(lease_id, prompt_sha),
).fetchone()
if not prompt_row:
raise ValueError(f"raw lease={lease_id} 不含匹配的 prompt")
response_id = None
if response_sha:
response_row = conn.execute(
"SELECT id FROM example_raw_content WHERE lease_id=%s AND kind='response' AND content_sha256=%s",
(lease_id, response_sha),
).fetchone()
if not response_row:
raise ValueError(f"raw lease={lease_id} 不含匹配的 response")
response_id = response_row[0]
return prompt_row[0], response_id
def persist_call(event, *, creator=CREATOR, dry_run=False):
"""原子写入一条成功调用。
``event`` 由 llm.chat 生成,至少包含 prompt/response、模型、usage 和调用方字段。
返回各证据行 id;dry-run 只验证事务并回滚。
"""
from db import connect
prompt = event.get("prompt")
response = event.get("response")
if not isinstance(prompt, str) or not prompt:
raise ValueError("LLM raw prompt 不能为空")
if response is not None and not isinstance(response, str):
raise ValueError("LLM raw response 必须是字符串或 NULL")
_check_no_secrets(prompt)
if response:
_check_no_secrets(response)
requested = str(event.get("requested_model_id") or "")
actual = str(event.get("actual_model_id") or "")
caller = str(event.get("caller") or "")
if not requested or not actual:
raise ValueError("LLM 调用明细缺 requested_model_id/actual_model_id")
if len(requested) > 64 or len(actual) > 64 or len(caller) > 64:
raise ValueError("LLM 调用明细字段超长")
usage = event.get("usage") or {}
cached = _usage_int((usage.get("prompt_tokens_details") or {}), "cached_tokens")
prompt_sha = _bare_sha256(prompt)
response_sha = _bare_sha256(response) if response else None
content_hashes = {"prompt": prompt_sha}
if response_sha:
content_hashes["response"] = response_sha
with connect() as conn:
try:
if event.get("raw_lease_id") is not None:
lease_id = int(event["raw_lease_id"])
prompt_id, response_id = _reuse_raw(
conn, lease_id=lease_id, prompt_sha=prompt_sha, response_sha=response_sha
)
else:
lease_id = conn.execute(
"INSERT INTO example_raw_lease(run_id, source_version, content_hashes, purpose, status, creator) "
"VALUES (%s,%s,%s::jsonb,%s,%s,%s) RETURNING id",
(
event.get("run_id"),
event.get("source_version"),
json.dumps(content_hashes, ensure_ascii=False),
event.get("purpose") or "llm_call",
"closed",
creator,
),
).fetchone()[0]
prompt_id, _ = _insert_raw(
conn,
lease_id=lease_id,
kind="prompt",
content=prompt,
run_id=event.get("run_id"),
role=event.get("role") or caller,
creator=creator,
)
response_id = None
if response:
response_id, _ = _insert_raw(
conn,
lease_id=lease_id,
kind="response",
content=response,
run_id=event.get("run_id"),
role=event.get("role") or caller,
creator=creator,
)
call_id = conn.execute(
"INSERT INTO example_llm_call(window_key, run_id, caller, requested_model_id, actual_model_id, "
"model_match, in_tokens, cached_tokens, out_tokens, cost_usd, stop_reason, duration_ms, "
"prompt_sha256, raw_content_id, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
(
event.get("window_key"),
event.get("run_id"),
caller,
requested,
actual,
requested == actual,
_usage_int(usage, "prompt_tokens"),
cached,
_usage_int(usage, "completion_tokens"),
event.get("cost_usd") or 0,
str(event.get("stop_reason") or "")[:32] or None,
event.get("duration_ms"),
prompt_sha,
response_id,
creator,
),
).fetchone()[0]
if dry_run:
conn.rollback()
return {
"status": "dry_run_ok",
"lease_id": lease_id,
"prompt_id": prompt_id,
"response_id": response_id,
"call_id": call_id,
"note": "试跑已回滚,未落库",
}
conn.commit()
return {
"status": "written",
"lease_id": lease_id,
"prompt_id": prompt_id,
"response_id": response_id,
"call_id": call_id,
}
except Exception:
conn.rollback()
raise