File size: 26,402 Bytes
71dabc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
#!/usr/bin/env python3
"""Clef-Flash teacher 接入(RTX 5060 Ti 16GB · 4-bit nf4)。

模型入口是仓库自带的 ``models/clef-flash/joint_schema_model.py``(Qwen3.5-9B 视觉骨干 +
joint schema head)。脚本提供两条路径:

1. 用 bnb nf4 4-bit 在**单卡**上加载 teacher,并把 joint head 留在 bf16;
2. ``--smoke``:一张 224x224 随机图 + 一句中文 state,问一道 choice 题,打印概率后 exit 0;
3. ``--label-all``:流式读 ``data/processed/channel/messages.jsonl``,把每条文本(及其已落盘
   图片)交给 teacher,问一道 ``{"ad","normal"}`` 的 choice 题,写
   ``data/processed/teacher/channel_labels.jsonl``;每 500 条 flush 一次,重跑按 message_id 续跑。

用法::

    .venv/Scripts/python.exe scripts/clef_teacher.py --smoke
    .venv/Scripts/python.exe scripts/clef_teacher.py --label-all data/processed/channel/messages.jsonl

为什么 lm_head 必须跳过量化
---------------------------
``JointSchemaHead.forward`` 用 backbone 的输出词嵌入做 lexical 特征:
``output_embedding_weight[token_ids].mean(dim=0)``(joint_schema_model.py:382)。
bnb 4-bit 的 ``Params4bit`` 不支持按 token id 索引,量化后这条路径会直接报错/取到错值,
所以 ``llm_int8_skip_modules=["lm_head"]`` 让 lm_head 保持 bf16。
"""

from __future__ import annotations

import os

# 必须在第一次 CUDA 分配之前设置:expandable_segments 降低长序列激活的碎片化,
# 16GB 卡上 4-bit 权重(~5GB)之后剩余空间由激活独占。Windows 分配器不支持该选项。
if os.name != "nt":
    os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import argparse
import contextlib
import json
import sys
import time
from pathlib import Path
from typing import Any

import torch

REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MODEL_DIR = REPO_ROOT / "models" / "clef-flash"
DEFAULT_MESSAGES = REPO_ROOT / "data" / "processed" / "channel" / "messages.jsonl"
DEFAULT_LABELS = REPO_ROOT / "data" / "processed" / "teacher" / "channel_labels.jsonl"

# 602 条消息文本为空(其中 546 条连图片都没有)。空 state 会让 encode_record 产不出
# state token,这里给一个显式占位串,保持"无文本"这件事本身可见。
EMPTY_TEXT_STATE = "(无文本)"
LABEL_QUESTION_ID = "is_ad"
LABEL_INSTRUCTIONS = "这条频道帖子的内容是在做广告或引流推广吗?"
LABEL_CRITERIA = {
    "ad": "帖子在推广、引流、售卖商品,或诱导关注其他账号/平台",
    "normal": "帖子是正常的内容分享,没有广告或引流意图",
}

# 加载完成后至少要留出的空闲显存(GB)。低于该值说明激活空间不够,smoke 直接拒绝跑。
MIN_FREE_VRAM_GB = 2.0
# 加载前的最低空闲显存门槛(GB):4-bit 权重 + 视觉塔 + head + 激活的最保守估计。
MIN_START_VRAM_GB = 7.0


def _gib(value: int | float) -> float:
    return float(value) / 1024**3


def _vram_line(tag: str, device: torch.device) -> float:
    free, total = torch.cuda.mem_get_info(device)
    allocated = torch.cuda.memory_allocated(device)
    reserved = torch.cuda.memory_reserved(device)
    print(
        f"[vram:{tag}] total={_gib(total):.2f}GB allocated={_gib(allocated):.2f}GB "
        f"reserved={_gib(reserved):.2f}GB free={_gib(free):.2f}GB"
    )
    return _gib(free)


def _resolve_device(device: str) -> torch.device:
    parsed = torch.device(device)
    if parsed.type != "cuda":
        raise SystemExit(f"--device 必须是单张 CUDA 卡(收到 {device!r});本脚本不做 CPU/多卡路径")
    if not torch.cuda.is_available():
        raise SystemExit("torch.cuda.is_available() == False:当前解释器没有可用的 CUDA 设备")
    index = 0 if parsed.index is None else parsed.index
    if index >= torch.cuda.device_count():
        raise SystemExit(f"--device={device} 超出可见卡数 {torch.cuda.device_count()}")
    return torch.device(f"cuda:{index}")


def _import_release_module(model_dir: Path):
    """按仓库约定把模型目录插进 sys.path 再 import 发布包里的入口函数。"""
    if not (model_dir / "joint_schema_model.py").is_file():
        raise SystemExit(f"找不到模型入口: {model_dir / 'joint_schema_model.py'}")
    entry = str(model_dir)
    if entry not in sys.path:
        sys.path.insert(0, entry)
    import joint_schema_model as release

    for name in ("load_release_model", "encode_record", "collate_records"):
        if not hasattr(release, name):
            raise SystemExit(f"{model_dir / 'joint_schema_model.py'} 缺少 {name}()")
    return release


def _build_image_only_processor(model_dir: Path):
    """构造只含 image processor + tokenizer 的 Qwen3VLProcessor。

    transformers 5.20 的 ``AutoProcessor.from_pretrained`` 会无条件实例化
    ``Qwen3VLVideoProcessor``,而该类模块顶部 ``import torchvision``;本机没装
    torchvision 时整条加载链会 ImportError。smoke 只走图像路径,因此这里显式组一个
    video_processor 缺省实现:真正传入 videos 时立即报错,绝不静默降级。
    """
    from transformers import AutoImageProcessor, AutoTokenizer
    from transformers.models.qwen3_vl.processing_qwen3_vl import Qwen3VLProcessor
    from transformers.video_processing_utils import VideoProcessorMixin

    # transformers 5.20.2 的 ProcessorMixin.check_argument_for_proper_class 用
    # get_possibly_dynamic_module("VideoProcessorMixin") 解析基类,但该名字没有导出到
    # transformers 顶层,回退路径又会踩 `_extra_content` 缺失的 AttributeError。
    # 这里把类补到顶层,让 isinstance 校验能正常通过(不改库文件)。
    import transformers

    if not hasattr(transformers, "VideoProcessorMixin"):
        transformers.VideoProcessorMixin = VideoProcessorMixin

    class _UnavailableVideoProcessor(VideoProcessorMixin):
        model_input_names = ["pixel_values_videos"]

        def __call__(self, *_args, **_kwargs):
            raise RuntimeError(
                "torchvision 未安装:本脚本只接入图像输入,视频路径不可用"
                "(安装 torchvision 后重跑即可使用官方 processor)"
            )

    image_processor = AutoImageProcessor.from_pretrained(str(model_dir))
    tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
    return Qwen3VLProcessor(
        image_processor=image_processor,
        tokenizer=tokenizer,
        video_processor=_UnavailableVideoProcessor(),
    )


@contextlib.contextmanager
def _patched_auto_processor(processor):
    """让 ``load_release_model`` 内部的 AutoProcessor.from_pretrained 返回现成 processor。"""
    from transformers import AutoProcessor

    saved = AutoProcessor.__dict__.get("from_pretrained")
    AutoProcessor.from_pretrained = classmethod(lambda cls, *args, **kwargs: processor)
    try:
        yield
    finally:
        if saved is None:
            del AutoProcessor.from_pretrained
        else:
            setattr(AutoProcessor, "from_pretrained", saved)


def _load_processor(model_dir: Path) -> tuple[object, bool, str]:
    """优先用官方 AutoProcessor;缺 torchvision 时退化为 image-only 构造。"""
    from transformers import AutoProcessor

    try:
        processor = AutoProcessor.from_pretrained(str(model_dir))
        return processor, False, "official"
    except ImportError as error:
        if "torchvision" not in str(error).lower():
            raise
        return _build_image_only_processor(model_dir), True, "image_only(torchvision missing)"


def _build_quant_config(dtype: torch.dtype):
    from transformers import BitsAndBytesConfig

    return BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=dtype,
        bnb_4bit_use_double_quant=True,
        # 见模块 docstring:joint head 拿 lm_head 权重按 token id 查表,必须保持浮点。
        llm_int8_skip_modules=["lm_head"],
    )


def verify_quantization(model, dtype: torch.dtype = torch.bfloat16) -> int:
    """确认真的走了 4-bit,且 lm_head/head 没被误量化。返回 Linear4bit 层数。"""
    from bitsandbytes.nn import Linear4bit, Params4bit

    backbone = model.language_model
    quantized_layers = sum(1 for module in backbone.modules() if isinstance(module, Linear4bit))
    if quantized_layers == 0:
        raise SystemExit("没有任何 Linear 被量化成 4-bit:检查 bitsandbytes 与 transformers 版本")

    lm_head = backbone.get_output_embeddings()
    weight = lm_head.weight
    if isinstance(weight, Params4bit):
        raise SystemExit(
            "lm_head 被 4-bit 量化了:joint head 的 lexical 路径会失败(joint_schema_model.py:382)"
        )

    head_dtype = next(model.head.parameters()).dtype
    sample_layer = next(m for m in backbone.modules() if isinstance(m, Linear4bit))
    quant_state = getattr(sample_layer.weight, "quant_state", None)
    quant_type = getattr(quant_state, "quant_type", None)
    print(
        f"[load] Linear4bit={quantized_layers} quant_type={quant_type} "
        f"lm_head_dtype={weight.dtype} head_dtype={head_dtype} "
        f"head_device={next(model.head.parameters()).device}"
    )
    if str(quant_type).lower() != "nf4":
        raise SystemExit(f"4-bit 量化类型不是 nf4(实际 {quant_type!r}),检查 BitsAndBytesConfig")
    if head_dtype != dtype:
        raise SystemExit(f"joint head 期望 {dtype},实际 {head_dtype}")
    return quantized_layers


def load_teacher(
    model_dir: Path,
    device: str = "cuda:0",
    dtype: torch.dtype = torch.bfloat16,
) -> tuple[object, object, object]:
    """4-bit nf4 加载 teacher,返回 (release 模块, model, processor)。"""
    target = _resolve_device(device)
    torch.cuda.set_device(target)

    free_before = _gib(torch.cuda.mem_get_info(target)[0])
    print(f"[load] device={target} free_before={free_before:.2f}GB model_dir={model_dir}")
    if free_before < MIN_START_VRAM_GB:
        raise SystemExit(
            f"加载前空闲显存只有 {free_before:.2f}GB,低于 {MIN_START_VRAM_GB}GB 门槛;"
            "先释放占用显存的进程再试"
        )

    release = _import_release_module(model_dir)
    processor, use_fallback, note = _load_processor(model_dir)
    print(f"[load] processor={type(processor).__name__} source={note}")
    if use_fallback:
        with _patched_auto_processor(processor):
            model, loaded_processor = release.load_release_model(
                model_dir,
                device=str(target),
                dtype=dtype,
                quantization_config=_build_quant_config(dtype),
            )
        if loaded_processor is not processor:
            raise SystemExit(
                f"processor 注入失败:load_release_model 返回了 {type(loaded_processor).__name__}"
            )
    else:
        model, processor = release.load_release_model(
            model_dir,
            device=str(target),
            dtype=dtype,
            quantization_config=_build_quant_config(dtype),
        )
    model.eval()
    verify_quantization(model, dtype)

    free_after = _vram_line("after_load", target)
    if free_after < MIN_FREE_VRAM_GB:
        raise SystemExit(
            f"加载后只剩 {free_after:.2f}GB 空闲显存,低于激活所需的 {MIN_FREE_VRAM_GB}GB 余量"
        )
    return release, model, processor


def build_smoke_record(seed: int) -> tuple[dict, object]:
    """一张 224x224 随机图 + 一句中文 state + 一道三选一 choice 题。"""
    import numpy as np
    from PIL import Image

    rng = np.random.default_rng(seed)
    pixels = rng.integers(0, 256, size=(224, 224, 3), dtype=np.uint8)
    image = Image.fromarray(pixels, mode="RGB")

    record = {
        "id": "smoke",
        "state": "客服工单:用户反馈订单 A1024 在支付页面连续三次失败,要求今天内给出处理方案。",
        "images": [image],
        "questions": {
            "team": {
                "type": "choice",
                "instructions": "根据状态和图片,这张工单应该交给哪个团队处理?",
                "criteria": {
                    "billing": "支付、退款、账单相关的问题",
                    "technical": "程序缺陷、服务中断或报错",
                    "general": "一般咨询、功能介绍或账户操作",
                },
            }
        },
    }
    return record, image


def run_smoke(
    release,
    model,
    processor,
    max_length: int = 4096,
    seed: int = 0,
) -> int:
    device = next(model.parameters()).device
    record, image = build_smoke_record(seed)
    print(f"[smoke] image={image.size[0]}x{image.size[1]} mode={image.mode} seed={seed}")

    encoded = release.encode_record(
        processor.tokenizer,
        record,
        max_length=max_length,
        processor=processor,
    )
    print(
        f"[smoke] input_tokens={len(encoded.input_ids)} questions={len(encoded.questions)} "
        f"media={sorted((encoded.media or {}).keys())}"
    )

    batch = release.collate_records([encoded], processor.tokenizer.pad_token_id, device)
    with torch.inference_mode():
        logits = model(batch)[0]

    exit_code = 0
    for question, question_logits in zip(encoded.questions, logits):
        probabilities = question_logits.float().softmax(-1)
        if not torch.isfinite(probabilities).all():
            print(f"[smoke] FAIL: {question.question_id} 概率含 NaN/Inf", file=sys.stderr)
            exit_code = 1
            continue

        pairs = sorted(zip(question.option_ids, probabilities.tolist()), key=lambda kv: -kv[1])
        print(f"[smoke] question={question.question_id} options={len(pairs)}")
        for option_id, probability in pairs:
            print(f"  {option_id:>10s}  p={probability:.6f}")
        best_option, best_probability = pairs[0]
        total = sum(probability for _, probability in pairs)
        print(
            f"[smoke] argmax={best_option} p={best_probability:.6f} sum={total:.6f} "
            f"logits={[round(x, 4) for x in question_logits.float().tolist()]}"
        )
        if abs(total - 1.0) > 1e-4:
            print(f"[smoke] FAIL: 概率和 {total:.6f} 偏离 1", file=sys.stderr)
            exit_code = 1

    _vram_line("after_smoke", device)
    print(f"[smoke] peak_allocated={_gib(torch.cuda.max_memory_allocated(device)):.2f}GB")
    return exit_code


def _count_lines(path: Path) -> int:
    with path.open("rb") as handle:
        return sum(1 for _ in handle)


def _load_done_ids(path: Path) -> set[str]:
    """读已有输出里的 message_id;顺手截掉崩溃留下的半行。"""
    if not path.exists():
        return set()
    raw = path.read_text(encoding="utf-8")
    if raw and not raw.endswith("\n"):
        cut = raw.rfind("\n") + 1
        path.write_text(raw[:cut], encoding="utf-8")
        raw = raw[:cut]
    done: set[str] = set()
    for line in raw.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            done.add(str(json.loads(line)["message_id"]))
        except (ValueError, KeyError):
            continue
    return done


def _iter_messages(path: Path):
    """逐行流式读取,避免把 10k 条消息一次性放进内存。"""
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if line:
                yield json.loads(line)


def _build_label_record(row: dict[str, Any]) -> tuple[dict[str, Any], int, bool]:
    """消息 → Clef record。返回 (record, 成功读入的图片数, 是否声明了图片)。"""
    from PIL import Image

    text = (row.get("text") or "").strip()
    paths = [str(path) for path in (row.get("images") or [])]
    images = []
    for path in paths:
        with Image.open(path) as image:  # copy 出来,避免文件句柄跟着 PIL 对象悬空
            images.append(image.convert("RGB").copy())
    record = {
        "id": str(row.get("message_id")),
        "state": text or EMPTY_TEXT_STATE,
        "images": images,
        "questions": {
            LABEL_QUESTION_ID: {
                "type": "choice",
                "instructions": LABEL_INSTRUCTIONS,
                "criteria": dict(LABEL_CRITERIA),
            }
        },
    }
    return record, len(images), bool(paths)


def _error_row(row: dict[str, Any], has_image: bool, error: str) -> dict[str, Any]:
    return {
        "message_id": row.get("message_id"),
        "has_image": has_image,
        "error": error,
        "date": row.get("date"),
    }


def _forward_encoded(release, model, processor, encoded, device) -> list[dict[str, float]]:
    batch = release.collate_records(encoded, processor.tokenizer.pad_token_id, device)
    with torch.inference_mode():
        logits = model(batch)
    probabilities: list[dict[str, float]] = []
    for encoding, record_logits in zip(encoded, logits):
        per_question: dict[str, float] = {}
        for question, question_logits in zip(encoding.questions, record_logits):
            values = question_logits.float().softmax(-1).tolist()
            per_question.update(zip(question.option_ids, values))
        probabilities.append(per_question)
    return probabilities


def _forward_encoded_safe(release, model, processor, encoded, device) -> list[tuple[dict | None, str | None]]:
    """单批前向;OOM / 批级异常时二分重试,把坏样本隔离到单条。"""
    try:
        return [(probabilities, None) for probabilities in _forward_encoded(release, model, processor, encoded, device)]
    except torch.OutOfMemoryError as error:
        torch.cuda.empty_cache()
        if len(encoded) == 1:
            return [(None, f"cuda_oom: {error}")]
    except Exception as error:  # noqa: BLE001 - 批级失败先二分,单条再抛出
        if len(encoded) == 1:
            return [(None, f"{type(error).__name__}: {error}")]
    midpoint = max(1, len(encoded) // 2)
    return _forward_encoded_safe(
        release, model, processor, encoded[:midpoint], device
    ) + _forward_encoded_safe(release, model, processor, encoded[midpoint:], device)


def run_label_all(release, model, processor, args) -> int:
    """批量打标:messages.jsonl → channel_labels.jsonl(流式 + 每 500 条 flush + 断点续跑)。"""
    input_path = Path(args.label_all).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()
    errors_path = output_path.with_suffix(".errors.jsonl")
    if not input_path.is_file():
        raise SystemExit(f"--label-all 输入不存在: {input_path}")
    output_path.parent.mkdir(parents=True, exist_ok=True)

    device = next(model.parameters()).device
    total = _count_lines(input_path)
    done = set() if args.no_resume else _load_done_ids(output_path)
    target = max(0, total - len(done))
    if args.limit is not None:
        target = min(target, args.limit)
    print(
        f"[label-all] input={input_path} lines={total} skip_done={len(done)} target={target} "
        f"output={output_path} batch={args.batch_size} flush_every={args.flush_every}"
    )

    rows: list[dict] = []
    error_rows: list[dict] = []
    counters = {"written": 0, "processed": 0, "errors": 0, "skipped": 0}
    started = time.monotonic()

    def flush():
        if rows:
            with output_path.open("a", encoding="utf-8") as handle:
                handle.write("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows))
                handle.flush()
                os.fsync(handle.fileno())
            counters["written"] += len(rows)
            rows.clear()
        if error_rows:
            with errors_path.open("a", encoding="utf-8") as handle:
                handle.write("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in error_rows))
                handle.flush()
                os.fsync(handle.fileno())
            error_rows.clear()
        elapsed = time.monotonic() - started
        rate = counters["processed"] / elapsed if elapsed > 0 else 0.0
        remaining = max(0, target - counters["processed"]) / rate / 60 if rate > 0 else float("nan")
        print(
            f"[label-all] {counters['processed']}/{target} written={counters['written']} "
            f"errors={counters['errors']} {rate:.2f} rows/s elapsed={elapsed / 60:.1f}m eta={remaining:.1f}m"
        )

    def process_batch(items: list[tuple]) -> None:
        encoded, kept = [], []
        for row, record, has_image, images_used in items:
            try:
                encoding = release.encode_record(
                    processor.tokenizer,
                    record,
                    max_length=args.max_length,
                    processor=processor,
                )
            except Exception as error:  # noqa: BLE001 - 坏样本进 errors,不中断整批
                counters["errors"] += 1
                error_rows.append(_error_row(row, has_image, f"encode {type(error).__name__}: {error}"))
                continue
            encoded.append(encoding)
            kept.append((row, has_image, images_used))

        if encoded:
            for (row, has_image, images_used), (option_probs, error) in zip(
                kept, _forward_encoded_safe(release, model, processor, encoded, device)
            ):
                if option_probs is None:
                    counters["errors"] += 1
                    error_rows.append(_error_row(row, has_image, error or "forward failed"))
                    continue
                p_ad = float(option_probs["ad"])
                p_normal = float(option_probs["normal"])
                rows.append(
                    {
                        "message_id": row.get("message_id"),
                        "p_ad": round(p_ad, 6),
                        "label": "ad" if p_ad >= p_normal else "normal",
                        "has_image": has_image,
                        "p_normal": round(p_normal, 6),
                        "images_used": images_used,
                        "date": row.get("date"),
                        "iso_time": row.get("iso_time"),
                    }
                )

    batch_items: list[tuple] = []
    for row in _iter_messages(input_path):
        message_id = str(row.get("message_id"))
        if message_id in done:
            counters["skipped"] += 1
            continue
        if args.limit is not None and counters["processed"] + len(batch_items) >= args.limit:
            break
        try:
            record, images_used, has_image = _build_label_record(row)
        except Exception as error:  # noqa: BLE001 - 单条图片/构造失败进 errors
            counters["errors"] += 1
            counters["processed"] += 1
            error_rows.append(_error_row(row, bool(row.get("images")), f"build {type(error).__name__}: {error}"))
            continue

        batch_items.append((row, record, has_image, images_used))
        if len(batch_items) >= args.batch_size:
            process_batch(batch_items)
            counters["processed"] += len(batch_items)
            batch_items.clear()
            if len(rows) + len(error_rows) >= args.flush_every:
                flush()

    if batch_items:
        process_batch(batch_items)
        counters["processed"] += len(batch_items)
    flush()

    elapsed = time.monotonic() - started
    output_lines = _count_lines(output_path) if output_path.exists() else 0
    error_lines = _count_lines(errors_path) if errors_path.exists() else 0
    print(
        f"[label-all] DONE processed={counters['processed']} written_this_run={counters['written']} "
        f"skip_done={counters['skipped']} errors={counters['errors']} elapsed={elapsed / 60:.1f}m"
    )
    print(f"[label-all] output={output_path} lines={output_lines} error_file={errors_path} error_lines={error_lines}")
    return 0 if counters["errors"] == 0 else 1



def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Clef-Flash teacher:4-bit nf4 单卡加载 + smoke 自检",
    )
    parser.add_argument(
        "--model-dir",
        type=Path,
        default=DEFAULT_MODEL_DIR,
        help=f"模型目录(默认 {DEFAULT_MODEL_DIR})",
    )
    parser.add_argument("--device", default="cuda:0", help="单卡设备,默认 cuda:0")
    parser.add_argument(
        "--max-length",
        type=int,
        default=4096,
        help="encode_record 的 token 上限(默认 4096,给 16GB 卡的激活留余量)",
    )
    parser.add_argument("--seed", type=int, default=0, help="随机图种子(默认 0)")

    mode = parser.add_mutually_exclusive_group(required=True)
    mode.add_argument("--smoke", action="store_true", help="跑一张随机图 + 中文 state 的 choice 题")
    mode.add_argument(
        "--label-all",
        metavar="INPUT",
        default=None,
        help=f"批量打标:读 messages.jsonl(默认 {DEFAULT_MESSAGES}),写 channel_labels.jsonl",
    )

    parser.add_argument(
        "--output",
        type=Path,
        default=DEFAULT_LABELS,
        help=f"--label-all 输出路径(默认 {DEFAULT_LABELS})",
    )
    parser.add_argument("--batch-size", type=int, default=8, help="每次前向的记录数(默认 8)")
    parser.add_argument("--flush-every", type=int, default=500, help="每多少条落盘一次(默认 500)")
    parser.add_argument("--limit", type=int, default=None, help="只处理前 N 条(调试用,默认全部)")
    parser.add_argument(
        "--no-resume",
        action="store_true",
        help="不续跑:忽略输出文件里已有的 message_id(注意输出仍是追加写,需先自行删除旧文件)",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    model_dir = args.model_dir.expanduser().resolve()

    release, model, processor = load_teacher(model_dir, device=args.device)
    if args.smoke:
        return run_smoke(release, model, processor, max_length=args.max_length, seed=args.seed)
    return run_label_all(release, model, processor, args)


if __name__ == "__main__":
    sys.exit(main())