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

105
README.md
View File

@@ -10,13 +10,15 @@
pip install -r requirements.txt
```
`pyodbc` 需要系统已安装对应的 ODBC 驱动(如 "ODBC Driver 17 for SQL Server")。
`pyodbc` 需要系统已安装对应的 ODBC 驱动。本项目 `config.yaml` 中使用的版本为
"ODBC Driver 18 for SQL Server",请按服务器实际安装的驱动版本填写 `database.driver`
若服务器上已有 SQL Server 管理工具/客户端环境,通常已包含该驱动;否则需自行
安装 Microsoft 官方 ODBC Driver。
## 配置
编辑 `config.yaml`,填入以下三部分(模板中的 `CHANGE_ME` 必须替换):
编辑 `config.yaml`,填入以下三部分(首次使用需替换为真实值,**请勿将含真实
数据库密码 / API Key 的配置文件提交到版本库**
- `database`SQL Server 连接信息(含 `schema`)、表名、字段名
- `llm`OpenAI 兼容接口的 `base_url``api_key``model`
@@ -49,6 +51,9 @@ python main.py --id 26B742,26B743 --pretty
# 允许模型使用"其他"兜底类目
python main.py --id 26B742 --mode fine --enable-other
# 额外在 stderr 打印本批运行汇总(耗时/token/缓存命中率stdout 仍只输出干净 JSON Lines
python main.py --id 26B742,26B743 --summary
```
## 分类粒度coarse / fine
@@ -94,11 +99,14 @@ python main.py --id 26B742 --mode fine --enable-other
默认逐行输出 JSONJSON Lines便于管道处理和逐条消费
```json
{"zong_pai_hao": "26B742", "status": "ok", "has_attachment": true, "types": ["资料"]}
{"zong_pai_hao": "26B742", "status": "ok", "has_attachment": true, "types": ["资料"], "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}}}}
{"zong_pai_hao": "26B744", "status": "ok", "has_attachment": true, "types": ["配件:针型阀", "配件:表弯管"]}
{"zong_pai_hao": "26B999", "status": "not_found", "has_attachment": null, "types": []}
{"zong_pai_hao": "26B999", "status": "not_found", "has_attachment": null, "types": [], "meta": {"elapsed_ms": null, "attempts": 0, "llm": null}}
```
每条结果都额外带一个 `meta` 运行统计对象(结构见下方"meta 字段说明")。加上 `--summary`
后还会在 **stderr** 额外打印整批汇总JSON而 stdout 仍只输出上述干净的 JSON Lines便于管道/重定向。
`types` 是唯一的类型字段,不再有单独的 `fine_types`
- `coarse` 模式下,`types` 是大类列表,如 `["资料", "配件"]`
- `fine` 模式下,`types` 里每一项都是"大类:细分类目",如
@@ -119,6 +127,30 @@ python main.py --id 26B742 --mode fine --enable-other
`status``ok` 时,`has_attachment``null``types` 为空数组,
不会有猜测性的默认值混入结果。
### meta 字段说明(运行统计)
每条结果的 `meta` 提供调用层面的性能与用量统计,便于核算耗时与 token 成本:
- `elapsed_ms`:本条从进入分类到出结果的总耗时(毫秒,含格式校验重试与解析)。未发起 LLM 的情况(`not_found` / `empty_param` / `db_error`)为 `null`
- `attempts`:实际尝试次数(含格式校验重试);调用彻底失败时为重试上限。
- `llm`:本次成功调用 LLM 的统计块;未调用或调用失败(如 `not_found``llm_call_error`)时为 `null`
- `model`:实际服务的模型名(来自接口返回)。
- `elapsed_ms`:单次 LLM 调用的耗时(毫秒)。
- `usage`token 用量与缓存统计:
- `prompt_tokens`:输入 token 总量(= 缓存命中 + 未命中)。
- `completion_tokens` / `total_tokens`:输出 / 总 token。
- `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`:输入中命中 / 未命中硬盘缓存的 token 数DeepSeek 上下文缓存特性,相同前缀的后续请求会命中,见下)。
- `cache_hit_rate`:缓存命中率 = `prompt_cache_hit_tokens / prompt_tokens`,取值 0~1首次冷调用为 0.0,缓存预热后显著升高。
成功调用示例:
```json
"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 加权的整体命中率)。
## 对话日志
每个实际发起 LLM 调用的总排号,都会在日志目录(默认 `logs/`,可用
@@ -132,6 +164,7 @@ logs/26B742_20260723_153012_123456.log
- 发给模型的完整对话system prompt + few-shot 示例 + 实际用户消息)
- 模型的每一次原始回复——**包括被格式校验判定无效、触发重试的那些**
- 每次尝试的格式校验结果(通过/失败及原因)
- 每次尝试的 `[调用统计]`实际模型名、单次调用耗时、token 用量prompt/completion/total与缓存命中情况hit/miss/命中率),调用失败时相应字段显示"无"
- 最终的解析结果和 status
同一总排号被重复处理不会覆盖旧日志(文件名带精确到微秒的时间戳),方便对比
@@ -149,8 +182,10 @@ logs/26B742_20260723_153012_123456.log
如果思考阶段把预算用完,正文就会被截断成空字符串。
本工具通过两处配置应对:
- `llm.max_tokens`调大到能覆盖"思考+正文"的总量(默认 1024而不是只按
正文那两三行的长度来设。
- `llm.max_tokens`控制单次回复长度上限。本项目已开启 `llm.disable_thinking: true`
(关闭推理链),因此 `max_tokens` 设为 `200` 即可覆盖"大类+细分"的短输出;若把
`disable_thinking` 改回 `false` 启用思考,则需调大到能覆盖"思考+正文"的总量(如
1024否则思考阶段可能耗尽预算、正文被截断成空。
- `llm.disable_thinking`:设为 `true` 时会通过 `extra_body={"thinking":
{"type": "disabled"}}` 关闭推理链——分类任务规则清晰、不需要模型思考,关闭
后响应更快、更省 token也从根源上避免"思考耗尽预算"的问题。仅 DeepSeek-V4
@@ -181,20 +216,70 @@ JSON 的括号、引号、转义更容易被模型写错。约定 LLM 只输出"
`enable_other` 参数控制(对应 `--enable-other` 或配置文件
`business.enable_other_category`),并且同一次调用里,提示词(`prompts.py`
和格式校验(`parser.py`)用的 `enable_other` 必须一致,否则会出现"提示词
允许但校验拒绝"的不一致——这层一致性由 `classifier.py` 统一负责传递。
允许但校验拒绝"的不一致——这层一致性由 `classifier.py` 统一负责传递。
## 写入落库(落库到 Common.Attachment
分类结果除在控制台以 JSON Lines 输出外,还可通过写入入口落库到生产库的
`Common.Attachment` 表,供下游系统按总排号查询该订单的附件类目。
```bash
# 全量写入(按总排号遍历源表全部记录)
python write_attachments.py
# 仅前 N 个总排号(测试用,避免一次性消耗大量 LLM 额度)
python write_attachments.py --limit 10
# 指定总排号文件(每行一个)
python write_attachments.py --ids-file ids.txt
# 直接指定总排号:单个,或逗号分隔的多个(与 --ids-file 可合并,提供后不再全表扫描)
python write_attachments.py --id 26B742
python write_attachments.py --id 26B742,26B743,26B744
# 粗分类写入(小类统一填占位值);不指定 --mode 则用配置文件 business.default_mode
python write_attachments.py --mode coarse
# 先预览将写入/跳过的行,不真正落库
python write_attachments.py --dry-run
# 允许"其他"兜底类目
python write_attachments.py --mode fine --enable-other
```
运行结束后,除上面的 `ok/empty_param/not_found/error` 与"将写入行数/跳过数"统计外,还会打印一行
`[汇总]`本批总耗时、LLM 调用次数、token 总量prompt/completion/total与加权缓存命中率便于核算成本。
落库约定(与 `db.py` 常量、`write_attachments.py` 的转换逻辑保持一致):
- **有附件**`has_attachment=true``status=ok`):每个 `type` 写一行。
- `fine` 模式:`type` 形如 `大类:细分`,按首个冒号拆分为 `(NS, 大类, 细分)`。
- `coarse` 模式:`type` 是大类,小类统一填占位值 `` → `(NS, 大类, '无')`。
- **无附件**`has_attachment=false`,含 `empty_param` 与模型判无附件):写哨兵行
`(NS, '无附件', '无')`,下游用 `WHERE MajorCategory <> '无附件'` 取真实附件。
- **无法确定/失败**`has_attachment=null`,含 `not_found` / `llm_*_error` /
`db_error`):一律不写,既不当作无附件,也不留脏数据。
- **幂等**:写入时对同一总排号先删除旧行再插入本次结果(依赖 `NS, MajorCategory,
MinorCategory` 唯一索引),重跑安全。
目标表 `Common.Attachment` 字段:`NS`nvarchar(30),总排号/关联键)、
`MajorCategory`nvarchar(40),附件大类)、`MinorCategory`nvarchar(40),附件小类),
三者均 `NOT NULL`。
## 项目结构
```
├── config.yaml # 配置文件
├── main.py # 命令行入口直接 import 同目录各模块)
├── main.py # 命令行入口:分类并输出 JSON Lines支持 --summary 在 stderr 打印批汇总;直接 import 同目录各模块)
├── write_attachments.py # 写入入口:分类结果落库到 Common.Attachment全量/--limit/--ids-file/--mode/--dry-run/--enable-other/--config运行后打印 token/缓存汇总)
├── requirements.txt
├── config_loader.py # YAML 配置读取与校验
├── db.py # SQL Server 查询 (pyodbc)
├── db.py # SQL Server 查询与落库fetch_params_by_ids / fetch_all_ids / upsert_attachmentspyodbc
├── prompts.py # 提示词与细分类目枚举(唯一需要改分类边界时编辑的文件)
├── llm_client.py # LLM 调用 (OpenAI 兼容接口)
├── parser.py # LLM 输出格式校验与清洗 → 结构化数据
├── order_logger.py # 每个总排号一份的完整对话日志
├── classifier.py # 编排:查库 -> 调LLM -> 校验解析 -> 记日志 -> 组装结果
── attachment_classifier.py # 历史版本(旧 Excel 版,已弃用,保留仅供参考)
── attachment_classifier.py # 历史版本(旧 Excel 版,已弃用,保留仅供参考)
└── docs/ # 补充文档
```