Classification-boundary adjustments in prompts.py (the single source the parser validates against, so parser.py needs no change): - Rename fine type 检验记录过程性 -> 检验记录 in the 资料类 enumeration. - 法兰隔膜 no longer counts as an attachment: removed from the 配件类 enumeration (25 -> 24 fine types) and the prose example list, added an explicit rule bullet and 注意 note that it is ignored even when the param text mentions it, and fixed the stale comment that referenced it. - Updated the few-shot example: input still mentions 法兰隔膜 but the expected output now lists only 紧固件, actively teaching the model to ignore 法兰隔膜. - README category lists/counts synced (检验记录 rename; 配件类 25 -> 24). Verified via parser: 检验记录 and 紧固件 validate; 检验记录过程性 and 法兰隔膜 are now rejected as non-enum fine types. Co-Authored-By: Claude <noreply@anthropic.com>
布莱迪压力表 - 订单附件识别工具
根据"总排号"从数据库查询"新参数"字段,调用大语言模型判断该订单是否携带 附件,并以 JSON 输出结果。支持粗分类(资料/配件/耗材)和精分类("大类:细分 类目",具体到针型阀、说明书等)两种粒度。
数据访问层基于 SQLAlchemy 抽象,支持 SQL Server(mssql+pyodbc) 与 PostgreSQL(postgresql+psycopg2) 两种数据库,通过
config.database.db_type切换。源表/目标表的标识符引用由 SQLAlchemy 按方言自动处理(PG 用"名", MSSQL 用[名]),无需改代码。
安装
pip install -r requirements.txt
依赖包含 sqlalchemy、psycopg2-binary(PostgreSQL 驱动,已自带 libpq)与
pyodbc(SQL Server 驱动)。使用 PostgreSQL 无需额外系统组件;使用
SQL Server 需要系统已安装对应的 ODBC 驱动,本项目 config.yaml 中使用的版本为
"ODBC Driver 18 for SQL Server",请按服务器实际安装的驱动版本填写 database.driver。
若服务器上已有 SQL Server 管理工具/客户端环境,通常已包含该驱动;否则需自行
安装 Microsoft 官方 ODBC Driver。
配置
编辑 config.yaml,填入以下三部分(首次使用需替换为真实值,请勿将含真实
数据库密码 / API Key 的配置文件提交到版本库):
database:数据库连接信息与源表/字段名。db_type:数据库类型,postgresql或mssql(缺省按driver是否含 "SQL Server" 推断;PostgreSQL 无需driver)。server/port/database/username/password:连接信息(两种库通用)。schema/table/id_column/id_field/param_column:源表的 schema、表名与列名(id_column为总排号列,对应--sn;id_field为 数据库真实 ID 列,对应--id;param_column为新参数列)。这些名称 按源表实际填写,SQL Server / PostgreSQL 两端保持一致即可。- 仅
mssql需要:driver、trust_server_certificate。 - 切换到 SQL Server 时可直接用
--config config.mssql.yaml(已内置原 SQL Server 连接信息)。
llm:OpenAI 兼容接口的base_url、api_key、modelbusiness:并发数、日志级别、默认分类模式(default_mode)、日志目录(log_dir)、 是否启用"其他"兜底类目(enable_other_category,默认关闭)
使用
# 指定方式有两种键类型,可混用:
# --sn 按总排号列查询(如 26B742),即原先 --id 的语义
# --id 按数据库真实 ID 列查询(如 802)
# 无论用哪种,输出 JSON 的 zong_pai_hao 一律为回查到的总排号
# 单个总排号,默认粗分类
python main.py --sn 26B742
# 批量,逗号分隔
python main.py --sn 26B742,26B743,26B744
# 按数据库真实 ID 指定(单个 / 多个)
python main.py --id 802
python main.py --id 802,803,804
# 批量,文件输入(每行一个总排号)
python main.py --ids-file ids.txt
# 精分类:输出"大类:细分类目"组合
python main.py --sn 26B742 --mode fine
# 指定其他配置文件
python main.py --id 802 --config other_config.yaml
# 临时覆盖日志目录
python main.py --sn 26B742 --log-dir /tmp/debug_logs
# 格式化输出(默认是紧凑的 JSON Lines,每行一条)
python main.py --sn 26B742,26B743 --pretty
# 允许模型使用"其他"兜底类目
python main.py --sn 26B742 --mode fine --enable-other
# 额外在 stderr 打印本批运行汇总(耗时/token/缓存命中率);stdout 仍只输出干净 JSON Lines
python main.py --sn 26B742,26B743 --summary
分类粒度:coarse / fine
由 --mode 参数或配置文件 business.default_mode 指定,--mode 优先级更高。
- coarse(默认):只判断三个大类——资料 / 配件 / 耗材
- fine:在同一个
types字段里直接给出"大类:细分类目",例如 "配件:针型阀""资料:说明书"
两种模式对应的提示词都定义在 prompts.py 中,修改分类边界或细分类目
枚举,只需要改这一个文件。
分类体系(已与业务方确认边界)
资料类(12 个细分):出厂检测检验报告、材质证明类、说明书、 检验记录、图纸类、标定校验证书、质量证明书、原产地证明、检验合格证书 综合、其他交工文件、营业执照、型式检验报告
配件类(24 个细分):针型阀、球阀、截止阀、旋塞阀、角阀、阀组、冷凝圈、 冷凝管、冷凝弯、虹吸管、表弯管、缓冲管缓冲弯、隔离器、过压保护器、 散热器散热片、铅封、转换接头、焊接接头短节短管、卡箍抱箍、紧固件、活接头、 接线盒、电缆插头、变送器
耗材类(1 个细分):垫片(四氟/紫铜/缠绕/密封圈等各种材质,单列为 第三大类,不算在配件里,避免大量小垫片稀释"配件"标签的信息量)
其他类(可选,默认关闭):由 --enable-other 或配置文件
business.enable_other_category 开启。开启后新增第四大类"其他",只有
唯一细分值"其他",作为兜底——遇到确实是随货附件、但不属于以上任何一个
具体细分类目的情况才使用。关闭时模型和格式校验都不知道这个选项的存在,
行为与不支持"其他"之前完全一致。
明确不算配件:位号牌/铭牌/标牌(标识件);缓冲钉/阻尼钉/阻尼帽(均为 工艺处理,不算随货实物配件)
明确不算资料:装箱单/送货单/贴箱等物流包装指令;合格证(无论参数文本 是否提到,一律忽略——既不影响"是否携带附件"的判断,也不会出现在细分 类目里)
输出格式
默认逐行输出 JSON(JSON Lines),便于管道处理和逐条消费:
{"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": [], "meta": {"elapsed_ms": null, "attempts": 0, "llm": null}}
每条结果都额外带一个 meta 运行统计对象(结构见下方"meta 字段说明")。加上 --summary
后还会在 stderr 额外打印整批汇总(JSON),而 stdout 仍只输出上述干净的 JSON Lines,便于管道/重定向。
types 是唯一的类型字段,不再有单独的 fine_types:
coarse模式下,types是大类列表,如["资料", "配件"]。fine模式下,types里每一项都是"大类:细分类目",如["资料:说明书", "配件:针型阀"],同一个订单可能同时出现多个大类、多个细分。- 若启用了"其他"兜底类目,对应项会体现为
"其他:其他",格式与其余项保持一致。
status 字段说明
| status | 含义 |
|---|---|
ok |
识别成功 |
not_found |
数据库中查不到该总排号 |
empty_param |
总排号存在,但"新参数"字段为空,未调用 LLM |
llm_call_error |
LLM 调用失败(网络/接口错误),重试耗尽 |
llm_format_error |
LLM 输出内容不符合约定格式,重试耗尽 |
db_error |
数据库连接/查询失败,影响整批请求 |
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,缓存预热后显著升高。
成功调用示例:
"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 / 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)。
对话日志
每个实际发起 LLM 调用的总排号,都会在日志目录(默认 logs/,可用
--log-dir 或配置文件 business.log_dir 指定)下生成一个独立的日志文件:
logs/26B742_20260723_153012_123456.log
日志内容为纯文本,完整记录:
- 发给模型的完整对话(system prompt + few-shot 示例 + 实际用户消息)
- 模型的每一次原始回复——包括被格式校验判定无效、触发重试的那些
- 每次尝试的格式校验结果(通过/失败及原因)
- 每次尝试的
[调用统计]:实际模型名、单次调用耗时、token 用量(prompt/completion/total)与缓存命中情况(hit/miss/命中率),调用失败时相应字段显示"无" - 最终的解析结果和 status
同一总排号被重复处理不会覆盖旧日志(文件名带精确到微秒的时间戳),方便对比 "调整提示词前后,同一条记录的判断有没有变化"。
not_found(查不到)和 empty_param(参数为空)的总排号不会生成日志文件,
因为它们本就没有与 LLM 的对话内容可记。
故障排查:模型回复为空(推理型模型特有)
如果日志里出现"[调用结果] 已收到模型回复"但"[模型原始回复]"下面是空的(或
status 为 llm_call_error,错误信息提到"推理耗尽了max_tokens预算"),这是
带思维链(thinking/reasoning)能力的模型的已知行为——像 DeepSeek-V4 系列,
默认会先打一段思考草稿再给正文,而 max_tokens 限制的是"思考+正文"的总量。
如果思考阶段把预算用完,正文就会被截断成空字符串。
本工具通过两处配置应对:
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 系列等支持该参数的模型有效;换用其他不支持该参数的模型时改回false(多数接口对不认识的字段会直接忽略,但不保证所有厂商都是如此)。
设计说明
为什么 LLM 不直接输出 JSON?
JSON 的括号、引号、转义更容易被模型写错。约定 LLM 只输出"标签: 值"格式的
纯文本(coarse 模式 2 行,fine 模式 3 行),程序侧用严格的正则做格式校验——
校验通过才提取字段、组装成本工具自己定义的 JSON 结构;校验失败会触发重试
(次数由配置文件 business.format_retry 控制,与 LLM 网络层重试 llm.max_retry
分开计数),重试仍失败则标记 llm_format_error,绝不把不确定的内容硬凑进
最终结果。
fine 模式的交叉自洽校验:
第三行(细分类目)和第二行(大类)必须自洽——比如模型选了"针型阀",第二行
就必须包含"配件",否则视为模型输出自相矛盾,同样触发格式校验失败重试。
校验通过后,parser.py 会把每个细分类目和它所属的大类拼接成"大类:细分类目"
(如"配件:针型阀"),作为最终对外的 types;程序内部不再单独保留"大类列表"
和"细分类目列表"两份数据。
"其他"兜底类目是怎么接入这套校验体系的:
"其他"被当成第四个大类,且细分类目固定只有"其他"自身一个值(其他:其他),
不允许模型编造具体名称——这样既给了"枚举之外的附件"一个去处,又不破坏
"细分类目必须严格匹配枚举"这条不信任 LLM 输出的核心原则。是否启用由
enable_other 参数控制(对应 --enable-other 或配置文件
business.enable_other_category),并且同一次调用里,提示词(prompts.py)
和格式校验(parser.py)用的 enable_other 必须一致,否则会出现"提示词
允许但校验拒绝"的不一致——这层一致性由 classifier.py 统一负责传递。
写入落库(落库到 Common.Attachment)
分类结果除在控制台以 JSON Lines 输出外,还可通过写入入口落库到生产库的
Common.Attachment 表,供下游系统按总排号查询该订单的附件类目。
# 全量写入(按真实 ID 升序遍历源表全部记录)
python write_attachments.py
# 仅前 N 个(按真实 ID 升序,测试用,避免一次性消耗大量 LLM 额度)
python write_attachments.py --limit 10
# 降序取前 N 个
python write_attachments.py --limit 10 --order desc
# 按真实 ID 闭区间过滤:仅处理 ID 在 [800,805] 的记录
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
# 按总排号指定(键类型 sn,即原先 --id 的语义):单个或逗号分隔多个
python write_attachments.py --sn 26B742
python write_attachments.py --sn 26B742,26B743,26B744
# 按数据库真实 ID 指定(键类型 id):单个或逗号分隔多个
python write_attachments.py --id 802
python write_attachments.py --id 802,803
# --id / --sn / --ids-file 可混用;提供任一后不再全表扫描(--limit/--order/--range 此时无效)
# 粗分类写入(小类统一填占位值);不指定 --mode 则用配置文件 business.default_mode
python write_attachments.py --mode coarse
# 先预览将写入/跳过的行,不真正落库
python write_attachments.py --dry-run
# 首次在目标库建表(幂等:仅创建 Common schema 与 Attachment 表,已存在则跳过;
# 切换数据库或新环境首次部署前先执行一次)
python write_attachments.py --init-db
# 允许"其他"兜底类目
python write_attachments.py --mode fine --enable-other
运行结束后,除上面的 ok/empty_param/not_found/error 与"将写入行数/跳过数"统计外,还会打印一行
[汇总]:本批总耗时(整批分类的真实墙钟)、LLM 累计(各任务自身耗时的累加,并发下大于墙钟,
括号标注并发数 max_workers)、LLM 调用次数、token 总量(prompt/completion/total)与加权缓存命中率,
便于核算成本与并发效率。
落库约定(与 db.py 常量、write_attachments.py 的转换逻辑保持一致):
- 有附件(
has_attachment=true,status=ok):每个type写一行。fine模式:type形如大类:细分,按首个冒号拆分为(SN, 大类, 细分)。coarse模式:type是大类,小类统一填占位值无→(SN, 大类, '无')。
- 无附件(
has_attachment=false,含empty_param与模型判无附件):写哨兵行(SN, '无附件', '无'),下游用WHERE MajorCategory <> '无附件'取真实附件。 - 无法确定/失败(
has_attachment=null,含not_found/llm_*_error/db_error):一律不写,既不当作无附件,也不留脏数据。 - 幂等:写入时对同一总排号先删除旧行再插入本次结果(依赖
(SN, MajorCategory, MinorCategory)复合主键保证唯一),重跑安全。
目标表 Common.Attachment 字段(三列复合主键、均 NOT NULL;列类型由 ORM 模型
String(30) / String(40) 按方言统一生成:PostgreSQL 端为 varchar,SQL Server 端为
nvarchar):
SN:总排号/关联键MajorCategory:附件大类MinorCategory:附件小类
数据库抽象层(多库支持)
数据访问层已重构为 SQLAlchemy,由两层组成,业务代码(classifier.py /
write_attachments.py)只调用 db.py 的 4 个函数,无需感知底层方言:
orm.py:方言无关的底层。build_engine/get_engine:按config.database.db_type生成 SQLAlchemy Engine (postgresql+psycopg2或mssql+pyodbc),含连接池复用(pool_pre_ping)与 登录/语句超时;get_engine按连接信息缓存 Engine,避免重复建池。Attachment:目标表Common.Attachment的声明式 ORM 模型(三列复合主键,quote=True保留大小写),跨库统一的建表/读写入口。init_schema:方言感知地CREATE SCHEMA IF NOT EXISTS "Common"+create_all, 供--init-db幂等建表。
db.py:基于 SQLAlchemy Core 的查询/落库实现。- 源表(表名/列名含中文、由配置驱动)用动态
Table(..., quote=True, quote_schema=True)构造,标识符引用由 SQLAlchemy 按方言生成(PG 用"名"、 MSSQL 用[名]),彻底摆脱手写引号拼接。 - 对外 4 个函数签名与旧版完全一致:
fetch_params_by_ids(支持--id整型真实 ID 与--sn总排号双键,并回查总排号)、fetch_param_by_id、fetch_all_ids(distinct()+order_by()+limit()/offset(0),分页语法跨库自动适配)、upsert_attachments(先删后插,依赖复合主键幂等)。
- 源表(表名/列名含中文、由配置驱动)用动态
切换数据库:默认 config.yaml 指向 PostgreSQL;切回 SQL Server 只需
python write_attachments.py --config config.mssql.yaml(或 main.py --config ...)。
两库源表 schema/表名/列名一致,仅需改连接信息与 db_type,无需改代码。
项目结构
├── config.yaml # 配置文件(默认 PostgreSQL;含 db_type 切换)
├── config.mssql.yaml # SQL Server 版配置(数据库不可达时切换用,--config 指定)
├── main.py # 命令行入口:分类并输出 JSON Lines(支持 --summary 在 stderr 打印批汇总;直接 import 同目录各模块)
├── write_attachments.py # 写入入口:分类结果落库到 Common.Attachment(全表扫描支持 --limit/--order/--range 按真实 ID 排序与范围过滤;--id/--sn/--ids-file/--mode/--dry-run/--enable-other/--init-db/--config;运行后打印 token/缓存汇总)
├── requirements.txt
├── config_loader.py # YAML 配置读取与校验
├── orm.py # 数据库抽象层:SQLAlchemy 引擎构建(mssql/postgresql)、Attachment ORM 模型、init_schema 建表
├── db.py # 基于 SQLAlchemy 的查询与落库(fetch_params_by_ids 支持按 id/sn 双键查询、fetch_all_ids / upsert_attachments,跨库无关)
├── prompts.py # 提示词与细分类目枚举(唯一需要改分类边界时编辑的文件)
├── llm_client.py # LLM 调用 (OpenAI 兼容接口)
├── parser.py # LLM 输出格式校验与清洗 → 结构化数据
├── order_logger.py # 每个总排号一份的完整对话日志
├── classifier.py # 编排:查库 -> 调LLM -> 校验解析 -> 记日志 -> 组装结果
├── attachment_classifier.py # 历史版本(旧 Excel 版,已弃用,保留仅供参考)
└── docs/ # 补充文档