feat(write_attachments): add --append mode, fix batch timing and SQL Server param limit
- Add --append flag: diff source-table 总排号 against existing Common.Attachment SN and classify/write only the missing ones. --limit caps the per-run append count, --order sets the direction. Backed by new fetch_existing_attachment_sns() in db.py. - Fix batch elapsed-time accounting: summarize_results() summed each task's per-item elapsed_ms, which overcounts under ThreadPoolExecutor concurrency (cumulative work time, not real wall-clock — 100 tasks on 8 workers reported ~5x the actual runtime). Callers now time classify_batch() via perf_counter and pass wall_clock_ms; both the write_attachments [汇总] line and main.py --summary report wall-clock separately from the cumulative sum. - Format durations >=1s in seconds (88851.4 ms -> 88.85 s) in the human-readable [汇总] line; structured JSON --summary fields stay in ms. - Chunk all IN (...) lists to 2000 items to respect SQL Server's 2100 bind-parameter hard limit (previously --append --limit 5000 failed at the fetch step with "COUNT 字段不正确"). Applied to fetch_params_by_ids (sn / id paths) and to the DELETE inside upsert_attachments. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
19
README.md
19
README.md
@@ -173,9 +173,13 @@ python main.py --sn 26B742,26B743 --summary
|
||||
"meta": {"elapsed_ms": 1476.6, "attempts": 1, "llm": {"model": "deepseek-v4-flash", "elapsed_ms": 1472.8, "usage": {"prompt_tokens": 1297, "completion_tokens": 15, "total_tokens": 1312, "prompt_cache_hit_tokens": 1280, "prompt_cache_miss_tokens": 17, "cache_hit_rate": 0.9869}}}
|
||||
```
|
||||
|
||||
`--summary` 打印的整批汇总字段与 `meta.llm.usage` 对应:`count` / `total_elapsed_ms` / `llm_calls` /
|
||||
各类 `total_*_tokens` / `total_cache_hit_tokens` / `total_cache_miss_tokens` / `avg_cache_hit_rate`
|
||||
(按 token 加权的整体命中率)。
|
||||
`--summary` 打印的整批汇总字段与 `meta.llm.usage` 对应:`count` / `wall_clock_ms` / `total_elapsed_ms` /
|
||||
`llm_calls` / 各类 `total_*_tokens` / `total_cache_hit_tokens` / `total_cache_miss_tokens` /
|
||||
`avg_cache_hit_rate`(按 token 加权的整体命中率)。
|
||||
|
||||
两个耗时字段的区别(`classify_batch` 是并发执行的):
|
||||
- `wall_clock_ms`:整批分类的**真实墙钟**耗时(在 `classify_batch` 外层用 `perf_counter` 计时),即你实际等的时间。
|
||||
- `total_elapsed_ms`:各结果自身耗时 `elapsed_ms` 的**累加**(相当于把这些任务串行跑的总时长),并发下会明显大于墙钟;二者之比 ≈ 并发增益(≈ `max_workers`)。
|
||||
|
||||
## 对话日志
|
||||
|
||||
@@ -265,6 +269,11 @@ python write_attachments.py --range 800,805
|
||||
# 组合:区间内降序、再取前 3 个
|
||||
python write_attachments.py --range 800,805 --order desc --limit 3
|
||||
|
||||
# 追加模式:仅补写源表中存在、但 Common.Attachment 还没有的总排号
|
||||
# --limit 此时是"本次最多追加多少个 SN"的上限;--order 决定从哪一端补起
|
||||
python write_attachments.py --append --limit 10
|
||||
python write_attachments.py --append --order desc --limit 10
|
||||
|
||||
# 指定总排号文件(每行一个,键类型 sn)
|
||||
python write_attachments.py --ids-file ids.txt
|
||||
|
||||
@@ -293,7 +302,9 @@ python write_attachments.py --mode fine --enable-other
|
||||
```
|
||||
|
||||
运行结束后,除上面的 `ok/empty_param/not_found/error` 与"将写入行数/跳过数"统计外,还会打印一行
|
||||
`[汇总]`:本批总耗时、LLM 调用次数、token 总量(prompt/completion/total)与加权缓存命中率,便于核算成本。
|
||||
`[汇总]`:本批**总耗时**(整批分类的真实墙钟)、**LLM 累计**(各任务自身耗时的累加,并发下大于墙钟,
|
||||
括号标注并发数 `max_workers`)、LLM 调用次数、token 总量(prompt/completion/total)与加权缓存命中率,
|
||||
便于核算成本与并发效率。
|
||||
|
||||
落库约定(与 `db.py` 常量、`write_attachments.py` 的转换逻辑保持一致):
|
||||
|
||||
|
||||
@@ -265,12 +265,21 @@ def classify_single(
|
||||
)[0]
|
||||
|
||||
|
||||
def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
def summarize_results(
|
||||
results: list[dict[str, Any]],
|
||||
wall_clock_ms: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""汇总一批 classify_batch 结果的运行统计。
|
||||
|
||||
从每条结果的 meta 里累加:总耗时、实际 LLM 调用次数、各类 token 总量、
|
||||
以及整体缓存命中率(按 prompt_cache_hit_tokens / prompt_tokens 加权)。
|
||||
供 CLI 打印批统计用,不改变 classify_batch 的返回结构。
|
||||
从每条结果的 meta 里累加:各结果 elapsed_ms 的总和、实际 LLM 调用次数、
|
||||
各类 token 总量,以及整体缓存命中率(按 prompt_cache_hit_tokens /
|
||||
prompt_tokens 加权)。供 CLI 打印批统计用,不改变 classify_batch 的返回结构。
|
||||
|
||||
注意耗时两个字段的区别(classify_batch 是并发执行的):
|
||||
- total_elapsed_ms:各结果自身耗时(elapsed_ms)的**累加**,即"累计工作量"
|
||||
(相当于把这些任务串行跑的总时长),并发下会明显大于真实墙钟;
|
||||
- wall_clock_ms:调用方传入的整批分类**真实墙钟**耗时(在 classify_batch
|
||||
外层用 perf_counter 计时),二者之比 ≈ 并发增益(≈ max_workers)。
|
||||
"""
|
||||
total_elapsed = 0.0
|
||||
llm_calls = 0
|
||||
@@ -291,7 +300,7 @@ def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
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 {
|
||||
out = {
|
||||
"count": len(results),
|
||||
"total_elapsed_ms": round(total_elapsed, 1),
|
||||
"llm_calls": llm_calls,
|
||||
@@ -302,3 +311,6 @@ def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"total_cache_miss_tokens": sum_miss,
|
||||
"avg_cache_hit_rate": avg_cache_hit_rate,
|
||||
}
|
||||
if wall_clock_ms is not None:
|
||||
out["wall_clock_ms"] = round(float(wall_clock_ms), 1)
|
||||
return out
|
||||
|
||||
53
db.py
53
db.py
@@ -43,11 +43,24 @@ __all__ = [
|
||||
"fetch_params_by_ids",
|
||||
"fetch_param_by_id",
|
||||
"fetch_all_ids",
|
||||
"fetch_existing_attachment_sns",
|
||||
"upsert_attachments",
|
||||
"init_schema",
|
||||
]
|
||||
|
||||
|
||||
# SQL Server 单条语句最多 2100 个绑定参数,WHERE col IN (?, ?, ...) 每项占 1 个,
|
||||
# 超过会报 "COUNT 字段不正确或语法错误"。这里把超长 IN 列表按 2000 一批分片查询
|
||||
# (2000 < 2100,留安全余量;对 PostgreSQL 的 65535 上限也完全够用)。
|
||||
_PARAM_CHUNK = 2000
|
||||
|
||||
|
||||
def _chunked(seq: list, size: int = _PARAM_CHUNK):
|
||||
"""把列表切成 <= size 的连续片段,用于分批 IN 查询。"""
|
||||
for i in range(0, len(seq), size):
|
||||
yield seq[i : i + size]
|
||||
|
||||
|
||||
def _build_source_table(db_cfg: dict[str, Any]):
|
||||
"""按配置动态构造源表 Core 结构(带 quote,保证跨库标识符正确引用)。"""
|
||||
schema = db_cfg["schema"]
|
||||
@@ -102,8 +115,9 @@ def fetch_params_by_ids(
|
||||
def _fill_sn() -> None:
|
||||
if not sn_items:
|
||||
return
|
||||
stmt = select(sn_c, param_c).where(sn_c.in_(sn_items))
|
||||
with engine.connect() as conn:
|
||||
for chunk in _chunked(sn_items):
|
||||
stmt = select(sn_c, param_c).where(sn_c.in_(chunk))
|
||||
for row in conn.execute(stmt):
|
||||
ident = str(row[0])
|
||||
entry = result.get(ident)
|
||||
@@ -119,8 +133,9 @@ def fetch_params_by_ids(
|
||||
logger.warning(
|
||||
"未配置 database.id_field,--id 将退化为按总排号列 [%s] 查询", sn_col
|
||||
)
|
||||
stmt = select(sn_c, param_c).where(sn_c.in_(id_items))
|
||||
with engine.connect() as conn:
|
||||
for chunk in _chunked(id_items):
|
||||
stmt = select(sn_c, param_c).where(sn_c.in_(chunk))
|
||||
for row in conn.execute(stmt):
|
||||
ident = str(row[0])
|
||||
entry = result.get(ident)
|
||||
@@ -144,8 +159,9 @@ def fetch_params_by_ids(
|
||||
if not id_ints:
|
||||
return
|
||||
|
||||
stmt = select(id_c, sn_c, param_c).where(id_c.in_(id_ints))
|
||||
with engine.connect() as conn:
|
||||
for chunk in _chunked(id_ints):
|
||||
stmt = select(id_c, sn_c, param_c).where(id_c.in_(chunk))
|
||||
for row in conn.execute(stmt):
|
||||
idv = row[0]
|
||||
orig = id_int_map.get(idv)
|
||||
@@ -227,6 +243,26 @@ def fetch_all_ids(
|
||||
raise DatabaseError(f"查询总排号失败: {e}") from e
|
||||
|
||||
|
||||
def fetch_existing_attachment_sns(db_cfg: dict[str, Any]) -> set[str]:
|
||||
"""读取 Common.Attachment 中已存在的全部 SN(distinct)。
|
||||
|
||||
用于"追加"模式做差集:源表总排号集合 减去 这里返回的 SN 集合,
|
||||
得到"源表有、但目标表还没写过"的总排号,只对这部分分类写入。
|
||||
|
||||
返回 set[str](空表返回空集合);SN 统一转成 str,便于与源表总排号
|
||||
(同样是 str)直接做集合比较。NULL 值会被过滤掉。
|
||||
"""
|
||||
engine = get_engine(db_cfg)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
stmt = select(Attachment.SN).distinct()
|
||||
return {
|
||||
str(row[0]) for row in conn.execute(stmt) if row[0] is not None
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseError(f"读取已有附件 SN 失败: {e}") from e
|
||||
|
||||
|
||||
def upsert_attachments(
|
||||
db_cfg: dict[str, Any],
|
||||
rows: list[tuple[str, str, str]],
|
||||
@@ -237,6 +273,9 @@ def upsert_attachments(
|
||||
对出现的每个 SN 先 DELETE 其旧行,再批量 INSERT——保证重跑总是反映
|
||||
最新分类结果,不会因唯一约束(复合主键 (SN,大类,小类))冲突而失败。
|
||||
|
||||
DELETE 的 IN 列表按 _PARAM_CHUNK 分片执行,避免 SN 过多时单条语句超过
|
||||
SQL Server 的 2100 绑定参数上限;整个"删 + 插"在同一事务内完成,语义不变。
|
||||
|
||||
返回 (deleted_rows, inserted_rows) 计数。
|
||||
"""
|
||||
if not rows:
|
||||
@@ -247,10 +286,14 @@ def upsert_attachments(
|
||||
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
# 分片 DELETE:单条 IN (...) 最多 _PARAM_CHUNK 个参数,避免触发 2100 上限
|
||||
deleted = 0
|
||||
for chunk in _chunked(distinct_sn):
|
||||
del_res = session.execute(
|
||||
delete(Attachment).where(Attachment.SN.in_(distinct_sn))
|
||||
delete(Attachment).where(Attachment.SN.in_(chunk))
|
||||
)
|
||||
deleted = del_res.rowcount if del_res.rowcount is not None else 0
|
||||
if del_res.rowcount:
|
||||
deleted += del_res.rowcount
|
||||
session.execute(
|
||||
insert(Attachment),
|
||||
[
|
||||
|
||||
8
main.py
8
main.py
@@ -62,6 +62,7 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||
@@ -166,6 +167,10 @@ def main() -> None:
|
||||
# 只能从"关"打开成"开",不支持反过来用命令行强制关闭配置文件里已打开的设置。
|
||||
enable_other = bool(args.enable_other) or bool(cfg["business"].get("enable_other_category", False))
|
||||
|
||||
# 整批分类的真实墙钟:在并发 classify_batch 外层计时(perf_counter)。
|
||||
# summarize_results 累加的 total_elapsed_ms 是各任务自身耗时的总和(并发下的
|
||||
# 累计工作量,会明显大于墙钟),二者之比 ≈ 并发增益;wall_clock_ms 才是真实墙钟。
|
||||
t0 = time.perf_counter()
|
||||
results = classify_batch(
|
||||
db_cfg=cfg["database"],
|
||||
llm_cfg=cfg["llm"],
|
||||
@@ -175,6 +180,7 @@ def main() -> None:
|
||||
log_dir=log_dir,
|
||||
enable_other=enable_other,
|
||||
)
|
||||
wall_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if args.pretty:
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
@@ -183,7 +189,7 @@ def main() -> None:
|
||||
print(json.dumps(r, ensure_ascii=False))
|
||||
|
||||
if args.summary:
|
||||
summary = summarize_results(results)
|
||||
summary = summarize_results(results, wall_clock_ms=wall_ms)
|
||||
print("--- 运行汇总 ---", file=sys.stderr)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
python write_attachments.py --limit 10 --order desc # 按真实 ID 降序取前 10 个
|
||||
python write_attachments.py --range 800,805 # 真实 ID 在 [800,805] 闭区间内
|
||||
python write_attachments.py --range 800,805 --order desc --limit 3 # 组合:区间内降序取前3
|
||||
python write_attachments.py --append --limit 10 # 追加模式:仅补写源表中还没写过的前 10 个总排号
|
||||
python write_attachments.py --append --order desc --limit 10 # 追加模式:从最大 ID 端开始补 10 个
|
||||
python write_attachments.py --ids-file ids.txt # 指定总排号(文件,每行一个)
|
||||
python write_attachments.py --sn 26B742 # 单个总排号写入(键类型 sn)
|
||||
python write_attachments.py --sn 26B742,26B743 # 多个总排号(逗号分隔)写入
|
||||
@@ -35,6 +37,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||
@@ -49,12 +52,28 @@ from db import ( # noqa: E402
|
||||
SENTINEL_MINOR,
|
||||
NO_MINOR,
|
||||
fetch_all_ids,
|
||||
fetch_existing_attachment_sns,
|
||||
get_engine,
|
||||
init_schema,
|
||||
upsert_attachments,
|
||||
)
|
||||
|
||||
|
||||
def _format_duration(ms: float | None) -> str:
|
||||
"""把毫秒耗时格式化为人类友好的时长字符串。
|
||||
|
||||
超过 1 秒(>=1000ms)按秒输出(保留 2 位小数),否则按毫秒(保留 1 位小数)。
|
||||
例:88851.4 -> "88.85 s",458.9 -> "458.9 ms"。None / 异常值回退为 "0 ms"。
|
||||
"""
|
||||
try:
|
||||
v = float(ms) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return "0 ms"
|
||||
if v >= 1000:
|
||||
return f"{v / 1000:.2f} s"
|
||||
return f"{v:.1f} ms"
|
||||
|
||||
|
||||
def convert_results_to_rows(results: list[dict], mode: str) -> tuple[list[tuple[str, str, str]], list[str]]:
|
||||
"""将 classify_batch 的结果列表转成可写入 Common.Attachment 的行。
|
||||
|
||||
@@ -146,6 +165,13 @@ def main() -> None:
|
||||
"--ids-file", type=str, default=None,
|
||||
help="包含总排号的文本文件路径,每行一个总排号(键类型 sn);可单独使用或与 --id/--sn 合并",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--append", action="store_true",
|
||||
help="追加模式:对比源表全部总排号与 Common.Attachment 已有 SN,仅对"
|
||||
"「源表存在、Attachment 尚未写入」的总排号分类写入。--limit 此时是"
|
||||
"「本次最多追加多少个 SN」的上限,--order 决定从哪一端(升/降序)补起;"
|
||||
"与 --id/--sn/--ids-file 互斥",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode", type=str, default=None, choices=["coarse", "fine"],
|
||||
help="分类粒度,不指定则用配置文件 business.default_mode",
|
||||
@@ -194,6 +220,9 @@ def main() -> None:
|
||||
|
||||
# 1) 取总排号列表
|
||||
if args.id or args.sn or args.ids_file:
|
||||
if args.append:
|
||||
print("[错误] --append 不能与 --id/--sn/--ids-file 同时使用", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
ids = _parse_ids(args)
|
||||
if not ids:
|
||||
print("[错误] --id / --sn / --ids-file 未解析到任何有效的标识符", file=sys.stderr)
|
||||
@@ -201,6 +230,22 @@ def main() -> None:
|
||||
n_id = sum(1 for _, k in ids if k == "id")
|
||||
n_sn = sum(1 for _, k in ids if k == "sn")
|
||||
logger.info("从 --id/--sn/--ids-file 读取 %d 个标识符(id=%d, sn=%d)", len(ids), n_id, n_sn)
|
||||
elif args.append:
|
||||
# 追加模式:源表全部总排号 − Common.Attachment 已有 SN = 待补写集合。
|
||||
# 全表拉取源表总排号(按真实 ID 排序),再做差集;--order 控制补写方向,
|
||||
# --limit 此时是"本次最多追加多少个 SN"的上限。
|
||||
all_sns = fetch_all_ids(cfg["database"], order=args.order)
|
||||
existing = fetch_existing_attachment_sns(cfg["database"])
|
||||
missing = [s for s in all_sns if s not in existing]
|
||||
print(
|
||||
f"[追加] 源表 {len(all_sns)} 个总排号,Common.Attachment 已有 "
|
||||
f"{len(existing)} 个,待追加 {len(missing)} 个(order={args.order})"
|
||||
)
|
||||
if args.limit is not None:
|
||||
missing = missing[: args.limit]
|
||||
print(f"[追加] 应用上限 --limit {args.limit},本次将处理 {len(missing)} 个")
|
||||
ids = [(s, "sn") for s in missing]
|
||||
logger.info("追加模式:待处理 %d 个总排号", len(ids))
|
||||
else:
|
||||
# 全表扫描路径:--range/--order/--limit 仅在此生效
|
||||
if args.range:
|
||||
@@ -233,6 +278,10 @@ def main() -> None:
|
||||
|
||||
# 2) 分类
|
||||
logger.info("开始分类 (mode=%s, enable_other=%s)", mode, enable_other)
|
||||
# 整批分类的真实墙钟:在并发 classify_batch 外层计时(perf_counter)。
|
||||
# 注意 summarize_results 里累加的 total_elapsed_ms 是各任务自身耗时的总和
|
||||
# (并发下的"累计工作量",会明显大于这里测得的墙钟),二者之比 ≈ 并发增益。
|
||||
t0 = time.perf_counter()
|
||||
results = classify_batch(
|
||||
db_cfg=cfg["database"],
|
||||
llm_cfg=cfg["llm"],
|
||||
@@ -242,6 +291,7 @@ def main() -> None:
|
||||
log_dir=log_dir,
|
||||
enable_other=enable_other,
|
||||
)
|
||||
wall_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# 3) 转换
|
||||
rows, skipped = convert_results_to_rows(results, mode)
|
||||
@@ -261,12 +311,14 @@ def main() -> None:
|
||||
f"跳过(无附件判定=null) {len(skipped)} 个"
|
||||
)
|
||||
|
||||
# 运行汇总:总耗时/token 总量/缓存命中率,从每条结果的 meta 累加。
|
||||
# 运行汇总:墙钟耗时 + 各任务累计工作量 + token 总量/缓存命中率。
|
||||
# 走 stdout 与上面的 [统计] 一致;不属于落库动作本身,仅作诊断输出。
|
||||
summary = summarize_results(results)
|
||||
summary = summarize_results(results, wall_clock_ms=wall_ms)
|
||||
print(
|
||||
f"[汇总] 总耗时 {summary['total_elapsed_ms']} ms, "
|
||||
f"LLM 调用 {summary['llm_calls']} 次, "
|
||||
f"[汇总] 总耗时 {_format_duration(summary['wall_clock_ms'])}, "
|
||||
f"LLM 累计 {_format_duration(summary['total_elapsed_ms'])} "
|
||||
f"(并发 {cfg['business']['max_workers']}), "
|
||||
f"调用 {summary['llm_calls']} 次, "
|
||||
f"token: prompt={summary['total_prompt_tokens']} "
|
||||
f"completion={summary['total_completion_tokens']} "
|
||||
f"total={summary['total_tokens']}, "
|
||||
|
||||
Reference in New Issue
Block a user