feat: 附件分类结果写入层 + 运行统计 + --id 指定总排号

- db.py: 新增 upsert_attachments/fetch_all_ids,实现 Common.Attachment 幂等写入
  (先删受影响 NS 旧行再批量插入,依赖唯一索引,可安全重跑)
- write_attachments.py: 写入入口,支持 --limit/--ids-file/--id(单个或逗号分隔多个)
  /--mode/--dry-run/--enable-other,运行结束打印 token 与缓存命中率汇总
- llm_client.py: LLMCallResult 捕获 usage/elapsed_ms/model/attempt
- classifier.py: classify_batch 结果透传 meta(耗时分两种、上下文 token、缓存命中率),
  新增 summarize_results 聚合批统计
- main.py: 新增 --summary 把批汇总打到 stderr,stdout 保持干净 JSON Lines
- order_logger.py: 每次 LLM 尝试补充 [调用统计] 段,便于排查耗时与缓存效果
- README.md: 对齐上述接口与统计说明

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
Misaka_Company
2026-07-24 15:38:06 +08:00
parent 4ca048bd26
commit 622f348cf1
7 changed files with 608 additions and 19 deletions

View File

@@ -28,12 +28,13 @@ classify_batch/classify_single 一路透传到 llm_client.classify_raw决定
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from db import DatabaseError, fetch_params_by_ids
from llm_client import LLMClient
from llm_client import LLMCallResult, LLMClient
from order_logger import OrderLogger
from parser import FormatError, ParsedResult, parse_llm_output
@@ -46,9 +47,55 @@ def _empty_result(zong_pai_hao: str, status: str) -> dict[str, Any]:
"status": status,
"has_attachment": None,
"types": [],
# 未发起 LLM 调用的结果not_found/db_errormeta 给出一致的空壳,
# 便于下游统一读取elapsed_ms 为 None统计时按 0 处理)。
"meta": {"elapsed_ms": None, "attempts": 0, "llm": None},
}
def _build_usage(u: dict[str, Any]) -> dict[str, Any]:
"""把原始 usage dict 整理成对外结构,并算出缓存率。
缓存率 = prompt_cache_hit_tokens / prompt_tokensprompt_tokens 为 0 时按
0.0 处理,避免除零)。保留 4 位小数。
"""
prompt = u.get("prompt_tokens", 0) or 0
completion = u.get("completion_tokens", 0) or 0
total = u.get("total_tokens", 0) or 0
hit = u.get("prompt_cache_hit_tokens", 0) or 0
miss = u.get("prompt_cache_miss_tokens", 0) or 0
cache_hit_rate = round(hit / prompt, 4) if prompt else 0.0
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total,
"prompt_cache_hit_tokens": hit,
"prompt_cache_miss_tokens": miss,
"cache_hit_rate": cache_hit_rate,
}
def _make_meta(
call_result: LLMCallResult | None, start: float, attempt: int
) -> dict[str, Any]:
"""组装单条结果的 meta 对象。
elapsed_ms 为从 classify_param_text 入口到此处的总耗时(含重试+解析);
若本次有成功调用,则 llm 块记录模型名、单次调用耗时与整理后的 usage
否则(调用失败/未调用llm 为 None。
"""
elapsed_ms = (time.perf_counter() - start) * 1000
if call_result is not None and call_result.error is None and call_result.usage is not None:
llm_block = {
"model": call_result.model,
"elapsed_ms": call_result.elapsed_ms,
"usage": _build_usage(call_result.usage),
}
else:
llm_block = None
return {"elapsed_ms": elapsed_ms, "attempts": attempt, "llm": llm_block}
def classify_param_text(
llm_client: LLMClient,
param_text: str | None,
@@ -56,7 +103,7 @@ def classify_param_text(
format_retry: int = 2,
order_logger: OrderLogger | None = None,
enable_other: bool = False,
) -> tuple[str, bool | None, list[str]]:
) -> tuple[str, bool | None, list[str], dict[str, Any]]:
"""对单条"新参数"文本做分类,返回 (status, has_attachment, types)。
mode 决定分类粒度:"coarse"只输出大类(资料/配件/耗材,启用 enable_other
@@ -79,32 +126,36 @@ def classify_param_text(
order_logger 若提供会记录本次分类过程中的每一次LLM往返无论成败
"""
start = time.perf_counter()
if param_text is None or not str(param_text).strip():
if order_logger is not None:
order_logger.log_param_text(param_text)
order_logger.log_final_result("empty_param", None)
return "empty_param", False, []
return "empty_param", False, [], _make_meta(None, start, 0)
if order_logger is not None:
order_logger.log_param_text(param_text)
last_call_result: LLMCallResult | None = None
last_format_err: Exception | None = None
for attempt in range(1, format_retry + 1):
call_result = llm_client.classify_raw(param_text, mode=mode, enable_other=enable_other)
last_call_result = call_result
if call_result.error is not None:
logger.error("LLM 调用失败: %s", call_result.error)
if order_logger is not None:
order_logger.log_llm_attempt(attempt, call_result, None, None)
order_logger.log_final_result("llm_call_error", None)
return "llm_call_error", None, []
return "llm_call_error", None, [], _make_meta(call_result, start, attempt)
try:
parsed = parse_llm_output(call_result.raw_response, mode=mode, enable_other=enable_other)
if order_logger is not None:
order_logger.log_llm_attempt(attempt, call_result, parsed, None)
order_logger.log_final_result("ok", parsed)
return "ok", parsed.has_attachment, parsed.types
return "ok", parsed.has_attachment, parsed.types, _make_meta(call_result, start, attempt)
except FormatError as e:
last_format_err = e
logger.warning(
@@ -117,7 +168,7 @@ def classify_param_text(
logger.error("格式校验重试 %d 次后仍失败,放弃: %s", format_retry, last_format_err)
if order_logger is not None:
order_logger.log_final_result("llm_format_error", None)
return "llm_format_error", None, []
return "llm_format_error", None, [], _make_meta(last_call_result, start, format_retry)
def classify_batch(
@@ -162,7 +213,7 @@ def classify_batch(
def _worker(zph: str) -> tuple[str, dict[str, Any]]:
order_logger = OrderLogger(log_dir, zph, mode) if log_dir is not None else None
status, has_attachment, types = classify_param_text(
status, has_attachment, types, meta = classify_param_text(
llm_client, param_map[zph], mode=mode, order_logger=order_logger,
enable_other=enable_other,
)
@@ -171,6 +222,7 @@ def classify_batch(
"status": status,
"has_attachment": has_attachment,
"types": types,
"meta": meta,
}
if ids_to_classify:
@@ -197,3 +249,42 @@ def classify_single(
db_cfg, llm_cfg, [zong_pai_hao], max_workers=1, mode=mode, log_dir=log_dir,
enable_other=enable_other,
)[0]
def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""汇总一批 classify_batch 结果的运行统计。
从每条结果的 meta 里累加:总耗时、实际 LLM 调用次数、各类 token 总量、
以及整体缓存命中率(按 prompt_cache_hit_tokens / prompt_tokens 加权)。
供 CLI 打印批统计用,不改变 classify_batch 的返回结构。
"""
total_elapsed = 0.0
llm_calls = 0
sum_prompt = sum_completion = sum_total = sum_hit = sum_miss = 0
for r in results:
meta = r.get("meta") or {}
e = meta.get("elapsed_ms")
if e:
total_elapsed += e
llm = meta.get("llm")
if isinstance(llm, dict):
u = llm.get("usage")
if isinstance(u, dict):
llm_calls += 1
sum_prompt += u.get("prompt_tokens", 0) or 0
sum_completion += u.get("completion_tokens", 0) or 0
sum_total += u.get("total_tokens", 0) or 0
sum_hit += u.get("prompt_cache_hit_tokens", 0) or 0
sum_miss += u.get("prompt_cache_miss_tokens", 0) or 0
avg_cache_hit_rate = round(sum_hit / sum_prompt, 4) if sum_prompt else 0.0
return {
"count": len(results),
"total_elapsed_ms": round(total_elapsed, 1),
"llm_calls": llm_calls,
"total_prompt_tokens": sum_prompt,
"total_completion_tokens": sum_completion,
"total_tokens": sum_total,
"total_cache_hit_tokens": sum_hit,
"total_cache_miss_tokens": sum_miss,
"avg_cache_hit_rate": avg_cache_hit_rate,
}