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:
Misaka_Company
2026-07-28 11:17:17 +08:00
parent a014a945af
commit 2ae5621090
5 changed files with 166 additions and 42 deletions

View File

@@ -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` 的转换逻辑保持一致):

View File

@@ -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

99
db.py
View File

@@ -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,14 +115,15 @@ 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 row in conn.execute(stmt):
ident = str(row[0])
entry = result.get(ident)
if entry is not None:
entry["param"] = row[1]
entry["sn"] = ident
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)
if entry is not None:
entry["param"] = row[1]
entry["sn"] = ident
def _fill_id() -> None:
if not id_items:
@@ -119,14 +133,15 @@ 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 row in conn.execute(stmt):
ident = str(row[0])
entry = result.get(ident)
if entry is not None:
entry["param"] = row[1]
entry["sn"] = ident
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)
if entry is not None:
entry["param"] = row[1]
entry["sn"] = ident
return
id_c = src.c[id_field]
@@ -144,17 +159,18 @@ 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 row in conn.execute(stmt):
idv = row[0]
orig = id_int_map.get(idv)
if orig is None:
continue
entry = result.get(orig)
if entry is not None:
entry["param"] = row[2]
entry["sn"] = row[1]
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)
if orig is None:
continue
entry = result.get(orig)
if entry is not None:
entry["param"] = row[2]
entry["sn"] = row[1]
try:
_fill_sn()
@@ -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 中已存在的全部 SNdistinct
用于"追加"模式做差集:源表总排号集合 减去 这里返回的 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:
del_res = session.execute(
delete(Attachment).where(Attachment.SN.in_(distinct_sn))
)
deleted = del_res.rowcount if del_res.rowcount is not None else 0
# 分片 DELETE单条 IN (...) 最多 _PARAM_CHUNK 个参数,避免触发 2100 上限
deleted = 0
for chunk in _chunked(distinct_sn):
del_res = session.execute(
delete(Attachment).where(Attachment.SN.in_(chunk))
)
if del_res.rowcount:
deleted += del_res.rowcount
session.execute(
insert(Attachment),
[

View File

@@ -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)

View File

@@ -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']}, "