用 Qwen 逐 token 条件概率识别 PDF 硬换行
moevis
最近一个需求:快速、批量处理一批 PDF。文本提取出来之后,发现一个典型问题——正文被折成若干行,一行十几个字就断了。这些换行是排版折行,不是段落边界。下游要按段切分,得先把硬换行删掉,把折行并回句子。
这类判断,我想到用模型来做。推理分 prefill 和 decode 两段,慢的是 decode——一个字一个字往外吐。但 prefill 做完的那一刻,整段输入的每个位置对下一个 token 的条件概率,已经全部留在 scores 里了。不生成,也能拿出来用:模型觉得”这里不该断”的地方,概率会掉得很低。
实测
判断依据很直接:一行读完,模型认为下一行首字接续在此处的概率。两行接续自然,概率高;接续生硬(例如 “证券代码:900948” 后直接另起新句),概率显著下降。PDF 硬折行恰属于后者 —— 模型 “没想到” 这里会断。
实测信号相当明显。以一份股东会决议公告为样本,对整段文本执行一次 prefill,逐位置取出换行 token 的条件概率:
-
“证券代码:900948” 之后的换行,概率 0.0007
-
“决议公告” 收尾后的换行,概率 0.96
两者相差约三个数量级,几乎无需调参即可区分。整个过程中,模型一次生成都没有发生 —— 只有一次前向,外加从 scores 里读概率。
顺带:资源与速度
这套方案跑起来很轻。模型是 Qwen2.5-0.5B 的 Q2_K 量化版,磁盘 323MB;这份 1731 字符(1181 tokens)的公告,一次 prefill 实测约 1 秒(约 1200 token/s),模型加载也是秒级。
真正省时间的是没有 decode。同样的判断如果交给模型”生成”,它要自回归地吐一段话,每吐一个 token 都是一次前向,还要再解析生成结果 —— 数量级的差距。prefill 是一次前向拿到全部位置的概率,判断与生成解耦。
一个资源细节:要取逐 token 概率,llama.cpp 需要 logits_all=True,会把整篇的 scores 矩阵留在内存里(1181 × 151936 个 float32,约 684MB)。如果只关心换行位置,可以在 eval 时按需只取对应行的 logits,这块内存可以省掉。
交互 demo
把这份公告的逐 token 概率做成了可视化页面:悬停任意字符查看它在 prefill 中的条件概率,换行单元按概率百分位着色(红 = 意外折行,应删;绿 = 真实段界,应留)。
坑一:概率可区分,但有两个硬限制
-
阈值没有绝对意义。全篇换行概率分布在 3e-6 到 0.99 之间,并不存在稳定的高低分界,最终只能采用相对分位数(本文取 34%)判定 “本篇中较为意外的换行”。这意味着概率信号只有相对价值。
-
模型不读取排版信息。段首缩进、表格行、项目符号行这类排版意图,prefill 只面对字面序列,无法识别。遇到层级缩进的文本,模型的判断与人的认知并不一致。
坑二:byte-level BPE 导致的乱码
在调试可视化阶段踩到。Qwen 采用 byte-level BPE,中文等多字节 UTF-8 字符偶尔会被拆成两三个字节级 token,单独解码必然得到 U+FFFD(�)。
首版可视化页面满屏 �,初看极易误判为模型故障,排查后才确认是 token 切碎所致 —— 单个汉字(如 “股”)被拆为 3 个 token,各占一个字节。
修复方式:页面改为按完整 UTF-8 字符渲染,每个字符为一个显示单元,记录覆盖的全部 token 及其概率;悬停时列出各字节 token 的概率。本样本中共 19 个字符被切碎,现均可逐一查看。
坑三:也试过统计方案,对比之下更能看出 prefill 的价值
作为对照,还实现过一个 2-gram 版本:统计 “行尾字 + 段界” 的共现频率,字在段尾出现概率低则合并行。它挂在两个问题上:
-
样本仅 99~100 条,统计量不足。
-
训练语料换行为
\n,真实 PDF 文本使用全角空格缩进,统计信号与线上场景错位。模型学的是 “语料中的换行”,而非 “真实文本的换行”。
统计方案对数据风格极为敏感,训练与线上风格不一致时,细微差异足以使方案失效。而 prefill 概率来自模型自身的语言理解,不依赖为任务定制的统计语料 —— 这也是 “模型内禀判断” 相对传统统计的一个实际优势。
收尾
回到开头那句话:prefill 阶段能做的事,不止是给 decode 铺路。一次前向之后,逐 token 条件概率是现成的产物,拿来就能做判断。配合可视化,悬停任意字符查看概率,意外折行与真实段界一目了然,模型的 “直觉” 变得可见。
模型没有生成过一个字,判断已经完成。