分段抽取网页时,导师人数越抓越少,排查记录
2488 字约 9 分钟
摘要某学院的教师目录本应有百余位老师,抓取后却只剩 3 位。排查发现问题不在分块阈值,而是把流程分支的判断权交给了 LLM,它学会了走捷径。
在 Auto Email Sender 中,有一项核心功能是自动解析高校学院的教师名录:从较长的目录页中提取导师姓名和个人主页,再逐个补全职称、邮箱和研究方向。
许多学院的主页一次列出几百名教职工,如果整页交给模型,不仅容易超出上下文长度,结构化抽取的遗漏率也很高。因此系统采用了“分而治之”的策略:把长页面按 Token 切成若干小块交给 LLM;如果某一块中的人数仍然太多,就递归地再切小,然后继续处理。
这套逻辑在大部分学院都运行正常,直到遇到一个普通的计算机学院。
页面抓取正常,网络没有报错,控制台运行了几分钟。最终的结果却让人意外:一个有上百位老师的学院,最后入库的只有 3 个人。
为什么不直接用 XPath 或正则提取,而要用 LLM?高校网站的 HTML 结构很不统一。有的网站把退休教师、行政人员和博导放在同一个表格里;有的用嵌套的
<table>排版;还有的把姓名混在简介段落中。固定规则一旦遇到网站改版就容易失效,因此需要依靠 LLM 的语义理解,区分出真正带学生的导师。
我最初以为是递归切分的阈值设得太死,把很多片段当作无效碎片丢弃了。但我把当时发送给模型的 Prompt、原始响应和切分日志拉出来对照后,发现:报错出现在最后一步,但真正的偏差早在第一轮就已经发生了。
模型不是在评估,而是在走捷径
当时为了支持递归拆分,我让 LLM 在 JSON 输出中返回一个状态字段:
no_candidates:当前片段中没有老师;completed:候选人数合适,直接解析输出的字段;too_many_candidates:人数太多,建议继续切细。
后端的逻辑很简单:只要收到 too_many_candidates,就丢弃当前的明细,把这个片段一分为二,交给下一轮递归处理。
从逻辑上看,这套流程没有明显漏洞。但回看抓取记录,数据很不正常:
| 片段中实际包含的教师链接数 | 模型给出的判定 | 后端执行的动作 |
|---|---|---|
| 0 | no_candidates | 正常跳过 |
| 61 | too_many_candidates | 一分为二后继续处理(正常) |
| 9 | too_many_candidates | 继续切分(不合理) |
| 3 | too_many_candidates | 仍然切分,直到触发最小尺寸保护才退出 |
通俗比喻:偷懒的学生与反复切分的试卷为什么明明只有 3 个人,模型还要说“人太多”? 假设老师布置了两种作答规则:
- 规则 1:如果试卷的题目数量合适,学生需要认真写出 1000 字的详细解题步骤(需要生成大量的 JSON);
- 规则 2:如果题目太多,学生只需要勾选“题目太多,建议拆分”,然后就可以交卷。
学生很快就发现了这个漏洞:不管题目是 10 道还是 3 道,都勾选“题目太多”。老师(程序)每次收到后就把试卷一分为二,再重新发下去。直到试卷被切成碎片,触发了“不可再分”的保护,整份试卷才被作废。
即使一个片段中只有 3 个名字,模型仍然输出 too_many_candidates。
后端只能按指令继续切分。切到最后,片段小到只有几十个 Token,触发了“不可再分”的保护,任务随之退出。一百多位老师就这样被一次次切碎、丢弃,最后只有 3 个被判为 completed 的人留下了。
递归切分中的最小尺寸保护为了防止无限切分,文本分割器通常会设定一个下限,例如每个片段不少于 150 个 Token。一旦片段小于该值却仍要求拆分,程序就会报错并停止这个分支。这个保护机制本身是合理的,问题在于触发它的前提已经不成立。
模型真的不会数数吗?
为什么只有 3 个名字,模型还会说人太多?
我把那段只有 3 位老师的 HTML 单独取出来,用一个很简单的 Prompt 询问:“这里面有几位老师?”模型回答“3 位”,是正确的。
我又加上一条简单的规则:“如果是 1 到 10 人,输出 completed”,它也正确输出了 completed。
模型明明会数数,为什么在生产环境中会出错?
问题出在生产环境那份复杂的 Prompt 上。在这份提示词中,我同时要求模型完成三件事:
- 判断链接是否属于在职教师;
- 统计候选人数;
- 为每位候选人提取并格式化十几个结构化字段。
关键在于,判定为 completed 时,模型需要生成大量 Token,认真检查每个人并输出一个很大的 JSON 数组;而判定为 too_many_candidates 时,它只需要输出一个简短的状态值,任务就结束了。
而且,few-shot 示例中恰好有一条“人数多,请拆分”的例子。在上下文很长、输出要求很多的情况下,模型倾向于选择输出最短、最省力的分支。
我做了一组对照实验:保留完整的长 Prompt,只删除 few-shot 示例。结果模型恢复正常,准确输出了 3 位候选人,并标记为 completed。但这样一来,其他边界情况下的 JSON 格式又开始出错。通过调整 Prompt 来压制模型的“偷懒”倾向,并不是长久之计。
150 Token 只是一个错误线索
片段被丢弃之后,我曾经怀疑分块大小是问题所在。当时的切分器把 150 Token 设为不可突破的下限,很多小片段都在这里被丢弃。
于是我想:“把阈值改成 50 甚至更小,问题是不是就解决了?”
我用出问题的页面做了回放测试,逐步调低阈值。结果很明显:真正的大片段(名单很长的那些)体积都远超 150 Token;而被丢弃的小片段,都只有 3 到 8 个名字,本来就应该直接解析。
把阈值调小,并没有挽回任何一位老师的信息,反而让模型继续沿着错误的方向处理,递归层数更深,API 费用也成倍增加。
不要被下游的报错误导系统在最后一步报错退出时,工程师很容易想到调高重试次数或调低保护阈值。但如果决策在源头就已经出错,放宽下游的保护只会让程序更体面地空转。
排查过程中,我还发现了另一个隐蔽的消耗来源:overlap(切片重叠)。为了避免教师姓名恰好被切成两段,父子块之间设置了较大的重叠区域。结果同一个人名会出现在好几个子块中,模型需要反复处理。收紧重叠区域后,整个递归树明显变小了。
计算交给程序,语义交给模型
找到根本原因后,方案就很清楚了:不要把流程控制的判断权交给有概率性的生成模型。
模型擅长非结构化的理解。它能一眼看出页面上哪些人是博导,哪些是已经离职的老教师。但“这个片段人数是否超过上限”“是否需要继续拆分”,这些都是严格的算术和条件判断,不应该由模型来决定。
我重新划分了模型与程序之间的职责:
- 模型只负责识别和计数: 提示词中不再提供
too_many_candidates这类状态选项,只要求模型返回整数candidate_count和候选人数组。 - 状态判断由后端完成:
- 如果
candidate_count == 0:标记为无候选,流程结束; - 如果
candidate_count <= MAX_LIMIT:保存候选列表,结束当前分支; - 如果
candidate_count > MAX_LIMIT:由程序决定拆分,丢弃当前明细,切小后进入下一轮。
- 如果
同时,还增加了一条约束:人数在上限之内时,必须返回候选详情;人数超过上限时,只返回数量,不输出详情,避免生成之后会被丢弃的内容。
| 职责 | 改造前 | 改造后 |
|---|---|---|
| 判断谁是教师 | LLM | LLM(保留其语义理解的优势) |
| 统计当前片段的人数 | LLM | LLM(只返回数值) |
| 决定是否继续拆分 | LLM(容易偷懒) | 程序代码(严格的数值判断) |
| 拆分流程的走向 | 依据模型返回的枚举值 | 依据确定的条件分支 |
改完之后,重新处理那个计算机学院的主页:第一层按数量正常拆分,第二层各片段的人数都落在可控范围内,各分支都顺利完成解析。最终入库 126 位导师,耗时比原来的无限递归还少了一半左右。
不要把概率输出当作流程开关
这次问题给了我一个提醒。
在设计基于 LLM 的 Agent 或自动化流水线时,我们常常希望模型展示“自主规划”的能力:让它选择工具,让它决定分支,让它判断下一步应该重试还是放弃。
但大语言模型本质上是一个以下一个词的概率为目标的黑盒。当某个分支的生成成本极低(例如只输出一个简短的状态值)时,它就可能在上下文压力下形成“走捷径”的倾向。
让模型理解内容,让代码掌握规则。 守住这条边界,自动化系统才不会在意想不到的地方替你做出奇怪的“智能”选择。