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:
@@ -40,6 +40,51 @@ class LLMCallResult:
|
||||
messages: list[dict[str, str]] # 实际发送给模型的完整消息列表(含system+few-shot+用户)
|
||||
raw_response: str | None # 模型原始回复;调用失败时为 None
|
||||
error: str | None # 调用失败时的错误描述;成功时为 None
|
||||
# 以下字段为性能/用量统计,默认 None(调用失败时无有效值):
|
||||
usage: dict[str, Any] | None = None # resp.usage 转成的 dict(含 prompt/completion/total
|
||||
# tokens 及 DeepSeek 的 prompt_cache_hit/miss_tokens),失败时为 None
|
||||
elapsed_ms: float | None = None # 单次成功调用的耗时(毫秒,含网络+推理),失败时为 None
|
||||
model: str | None = None # 实际服务的模型名(来自 resp.model),失败时为 None
|
||||
attempt: int | None = None # 本次返回对应的是第几次尝试(成功那次;全失败则为 max_retry)
|
||||
|
||||
|
||||
def _extract_usage(resp: Any) -> dict[str, Any] | None:
|
||||
"""从 chat.completions 响应里稳健地取出 usage 字典。
|
||||
|
||||
DeepSeek 在标准 OpenAI usage 之上,额外返回 `prompt_cache_hit_tokens` /
|
||||
`prompt_cache_miss_tokens`(与 prompt_tokens 同级)。openai SDK 未必为这些
|
||||
厂商字段建模,所以先按属性访问,取不到再回退到 pydantic 的 model_dump()
|
||||
(extra 字段通常会被带出来),两层都试,避免漏掉缓存统计。
|
||||
"""
|
||||
if resp is None or getattr(resp, "usage", None) is None:
|
||||
return None
|
||||
u = resp.usage
|
||||
out: dict[str, Any] = {}
|
||||
for key in (
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"prompt_cache_hit_tokens",
|
||||
"prompt_cache_miss_tokens",
|
||||
):
|
||||
val = getattr(u, key, None)
|
||||
if val is not None:
|
||||
out[key] = val
|
||||
if not out:
|
||||
try:
|
||||
dumped = u.model_dump() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
dumped = {}
|
||||
for key in (
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"prompt_cache_hit_tokens",
|
||||
"prompt_cache_miss_tokens",
|
||||
):
|
||||
if key in dumped and dumped[key] is not None:
|
||||
out[key] = dumped[key]
|
||||
return out or None
|
||||
|
||||
|
||||
class LLMClient:
|
||||
@@ -96,6 +141,7 @@ class LLMClient:
|
||||
extra_body["thinking"] = {"type": "disabled"}
|
||||
|
||||
for attempt in range(1, max_retry + 1):
|
||||
call_start = time.perf_counter()
|
||||
try:
|
||||
resp = self._client.chat.completions.create(
|
||||
model=self._cfg["model"],
|
||||
@@ -114,7 +160,18 @@ class LLMClient:
|
||||
f"模型返回内容为空 (content={content!r}),"
|
||||
f"若模型支持思维链,可能是推理耗尽了max_tokens预算"
|
||||
)
|
||||
return LLMCallResult(messages=messages, raw_response=content, error=None)
|
||||
# 统计:单次调用耗时 + token 用量 + 实际服务模型
|
||||
call_elapsed_ms = (time.perf_counter() - call_start) * 1000
|
||||
usage = _extract_usage(resp)
|
||||
return LLMCallResult(
|
||||
messages=messages,
|
||||
raw_response=content,
|
||||
error=None,
|
||||
usage=usage,
|
||||
elapsed_ms=call_elapsed_ms,
|
||||
model=getattr(resp, "model", None),
|
||||
attempt=attempt,
|
||||
)
|
||||
except (APIError, APITimeoutError) as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
@@ -134,4 +191,8 @@ class LLMClient:
|
||||
messages=messages,
|
||||
raw_response=None,
|
||||
error=f"LLM 调用重试 {max_retry} 次后仍失败: {last_err}",
|
||||
usage=None,
|
||||
elapsed_ms=None,
|
||||
model=None,
|
||||
attempt=max_retry,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user