大模型切段抓取网页,抓着抓着人就变少了的排查
在 Auto Email Sender 里,有一项核心功能是自动解析高校学院的教师名录:从长长的目录页里提取导师姓名、个人主页,再逐个补全职称、邮箱和研究方向。
由于不少学院主页动辄列着几百名教职工,整页丢给模型不仅容易爆上下文,结构化抽取的漏采率也极高。于是系统采用了一套“分而治之”的策略:把长页面按 Token 切成若干小块扔给 LLM;要是某一块里塞的人还是太多,就递归拆小后再跑。
这套逻辑在绝大部分学院跑得好好的,直到某天遇上了一个普通的计算机学院。
页面抓取正常,网络没有报错,控制台勤勤恳恳地跑了几分钟,最终结果却让人傻眼:偌大一个学院,上百位老师,最后入库的竟然只有 3 个人。
为什么不能直接用 XPath / 正则提取,非要上 LLM?高校网站的 HTML 结构千奇百怪:有的把退休教师、行政人员和博导混在同一个表格;有的用嵌套
<table>拼接排版;还有的把名字混在一段简介文本中。固定规则很容易遇到稍微改版就全盘失效的问题,因此才依赖 LLM 的语义理解来精准区分“谁是真正带学生的导师”。
第一反应我以为是递归切分的边界阈值太死,把很多片段当成无效碎片直接丢掉了。但一路把当时送入模型的 Prompt、Raw Response 和切分日志拉出来对照,我才发现:报错日志停在最后一步,但真正致命的偏航,早在第一轮就发生了。
以为它在评估,其实它在“抄近道”
当时为了让程序能递归拆分,我让 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 个名字,模型还要喊“人太多”?
难道模型连 1、2、3 都数不明白?我把那段只有 3 个老师的 HTML 单独拎出来,写了个最直白的 Prompt 问它:“这里面有几位老师?”模型回答:“3 位”,准确无误。
再给它一份精简规则:“如果是 1 到 10 个人,请输出 completed”,它也乖乖输出 completed。
模型明明会数数,为什么一进生产环境就成了睁眼瞎?
秘密藏在生产环境那段复杂厚重的 Prompt 里。在那套提示词中,我同时要求模型干三件事:
- 识别链接是否属于在职教师;
- 统计候选人数;
- 为每位候选人提取并格式化十几个结构化字段。
关键在于,如果判定为 completed,模型需要耗费大量生成 Token,认认真真排查并输出一个巨大的 JSON 数组;而一旦判定为 too_many_candidates,它只需要吐出短短一行状态码,任务就结束了!
再加上 few-shot 示例里恰好有这样一条“人多请拆分”的范例,在面对长上下文和高负荷输出指令时,模型毫不犹豫地学会了“偷懒”——选择那条最省力、输出最短的分支。
我做了一组对照:保留全部长提示词,只把 few-shot 示例删掉,模型立刻老实了,准确输出 3 位候选人并标记 completed。但这同时导致 JSON 格式在其他边缘 case 下开始跑飞。靠调玄学 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:程序决定切分,抛弃当前明细,拆小进入下一轮。
- 如果
同时建立强契约约束:小批次必须带回候选详情;大批次只报数量、严禁输出详情(免得浪费 Token 生成废弃数据)。
| 职能划分 | 改造前 | 改造后 |
|---|---|---|
| 判断谁是教师 | LLM | LLM(保留核心语义优势) |
| 统计当前片段人数 | LLM | LLM(仅报数值) |
| 决定是否切小继续拆分 | LLM(极易偷懒) | 程序代码(严格数值判断) |
| 拆分流程流转控制 | 依赖模型返回的枚举 | 依赖确定性的条件分支 |
改完上线,重新跑那个计算机学院的主页:第一层按数量正常拆分,第二层各片段人数落入可控区间,各分支顺利解析完成。最终入库 126 位导师,耗时甚至比原来递归死循环还要快上一倍。
别把概率输出当成流程开关
这次 bug 给我们提了个醒。
我们在设计基于 LLM 的 Agent 或自动化流水线时,常常沉迷于让模型展现其“自主规划”的聪明劲儿:让它选工具、让它定分支、让它判断下一步该重试还是该放弃。
但大语言模型本质上是个追求下一词概率最优的黑盒。一旦某个分支的生成代价极低(比如简短报错),它就有可能在上下文压力下自发形成“走捷径”的局部最优解。
让模型看懂世界,让代码掌控规则。 守住这条边界,自动化系统才不会在某个猝不及防的角落,替你做出匪夷所思的“智能选择”。