前几天凌晨,我让 Claude Code 把沃尔什滕那个蒸汽机务段的时刻表页扒成结构化数据。Parowozownia Wolsztyn,波兰那边唯一还在日常跑蒸汽客运的地方——这个背景不重要,重要的是那个页面。
它十分钟交活。JSON 一次过 schema 校验,字段齐、类型对、时间是 ISO 格式、车次号规规矩矩是字符串不是数字。
我手已经放在 merge 上了。
画外音:那三秒是我这周离事故最近的一次。
然后我随手翻了一眼内容。
案发
它交回来的东西节选长这样:
{
"effective_from": "2024-01-01",
"operator": {
"tax_id": "9231701842",
"regon": "365338207",
"phone_hours": "Mon-Fri 07:00-15:00"
},
"services": [
{ "day": "sunday", "train": "77217", "from": "Wolsztyn", "to": "Poznań Główny",
"dep": "10:46", "arr": "12:50" },
{ "day": "saturday", "train": "77217", "from": "Wolsztyn", "to": "Poznań Główny",
"dep": "10:46", "arr": "12:50" }
]
}
最扎眼的是 effective_from:2024 年 1 月 1 日。页面上白纸黑字写着这张时刻表从 2026 年 3 月 7 日起生效。2024 是哪来的?——页面最底下那行版权声明。它把版权年当成了数据年。
然后是 operator 里那几个字段。税号、REGON、电话咨询时间,确实都印在页面上,写得清清楚楚,肉眼可见。问题是它们跟"这周六蒸汽火车几点开"没有一毛钱关系。
还有一处更隐蔽的:票务渠道它倒是抓对了——官网、车站售票处、列车长三个。你猜它放哪了。services 数组里,当成三个"服务"条目。三个购买渠道,混在列车班次里,字段名都是 train。
(第三处我没按顺序讲,故意的,下面说。)
最能说明问题的是这一条:
{ "day": "sunday", "train": "77217", ... }
页面原文写的是:周日和节假日,计划运行图里没有蒸汽列车。
我看见这条的时候愣了一下。它的车次号、发到时间,跟周六那趟一模一样。它不是凭空编了一趟假的,它把周六的班次复制了一份贴到周日下面。
而这份 JSON,如果我当时没多看那一眼,是能一路跑通的。schema 认这个结构,类型全对,"day": "sunday" 是合法字符串。没有任何一步会报错。
排查
我先怀疑自己 prompt 写得太省。翻回去看——我确实只写了"把时刻表抓成 JSON",连 schema 都没给。这是我的错,认。
但我更想搞清楚的是:它明明看见了那行字,为什么还会补一趟周日的车出来。
于是我把页面上所有跟时间、日期相关的字符串单独拎出来,让它逐条分类,只分类,不抽取。结果很干净,一条不错。包括"周日和节假日没有蒸汽列车"这句,它准确地归进了"运行规则",没归进"时刻表条目"。
所以不是看不懂。
我后来的判断是:它翻车的地方在"这句重不重要"。模型不缺阅读理解,缺的是给上下文里的信息排权重。对一个网页来说,版权声明、税号、咨询电话时间,都算页面结构上的"实心"内容——有标签、有固定位置,长得就像数据。而"周日没有蒸汽列车"是一句散文式的补充说明,没标签,夹在表格底下。在它的权重表里,前者天生比后者重。
这跟语言能力没关系,是任务定义的问题。而任务是我给的。
中途我还试过一个偷懒方案:让它自己给自己写校验。写得挺好,格式漂亮,注释也齐。但它恰好放过了自己犯的那两个错——校验的松紧是它定的,它的盲区在校验里原样复刻了一遍。这就是我不让 AI 给自己当裁判的原因,测试代码本身就是它最容易骗过去的地方。
补丁
改法说出来挺朴素的,甚至有点反直觉:我没去抠 prompt 的措辞,我去写校验了。
先把"什么算数据、什么算噪音"焊进 schema。
{
"type": "object",
"required": ["effective_from", "services"],
"properties": {
"effective_from": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"services": {
"type": "array",
"items": {
"type": "object",
"required": ["day", "train", "from", "to", "dep", "arr"],
"properties": { "day": { "enum": ["mon-fri", "saturday"] } },
"additionalProperties": false
}
}
},
"additionalProperties": false
}
两处值得说。day 的枚举里我压根没放 sunday——放了就是留口子,给它一个安放幻觉的地方。additionalProperties: false 封掉的是税号、REGON、咨询时间这一类想往里塞的东西,让它在结构层面根本塞不进来。
然后是几条不依赖模型、只依赖页面原文的断言。
# 时刻表自 2026-03-07 起生效;页面底部版权年份与它不一致时报警
assert data["effective_from"] == "2026-03-07", "锚点取到了版权年"
# 周日和节假日没有蒸汽列车,出周日条目就是幻觉
assert all(s["day"] != "sunday" for s in data["services"])
这里有个坑我得交代一下。effective_from 的校验我一开始写的是"年份 >= 2024",觉得够用了。后来发现这等于没写——2024 和 2026 都能过,正好把这次这个 bug 放行。校验的松紧得对着错误的具体形态去调,泛泛的合理性检查基本是自我安慰。
站名那条更土。工作日 77385 从 Wolsztyn 14:27 发车,15:05 到 Zbąszynek,中间停 Tuchorza、Belęcin Wlkp.、Stefanowo、Zbąszyń Przedmieście、Zbąszyń 五个站。它交回来的版本里,Zbąszyń Przedmieście 和 Zbąszyń 被合并成了一个。我猜理由是这俩名字长得像,以为是同一站的不同写法——挺合理的一个猜测,错的。带前缀的邻站在铁路数据里是两回事。
assert len(weekday_77385["stops"]) == 5
粗暴,但有效。数字对不上就先别信内容。
最后是例外日。9 月 6 日和 13 日这两天,Pt47-65 会按周六时刻表开去 Poznań——去程 10:46 发、12:50 到,返程 15:21 发、17:46 到。这是覆盖规则,不是普通的周末班次。它第一次交活时把这条塞进了 notes 字段,一整段自由文本。
我的规矩:notes 是数据的坟场。凡是被塞进 notes 的,等于宣告"我抓不准,先存着"。例外日必须进 exceptions 数组,带结构化日期,跟常规班次走同一套校验。
真凶
三个错误其实一个根:我在 prompt 里说了要什么,但没说不要什么。
抽取类任务里,模型最擅长的是把页面上的东西尽量搬过来,最不擅长的是判断哪一行该被扔掉。而页面上噪音和信号的排版权重经常是反的——税号有加粗标签,运行规则只有一句普通段落。
还有一层是我这次才真正意识到的:模型的外部常识会跟页面文本打架。"火车周日也开"是很强的先验,"这个机务段周日不发蒸汽车"是很弱的文本证据。打起来的时候,先验赢的概率不低。
解法不是让它"更仔细",那是个没用的指令。是让它在结构上没地方犯错。
复盘
这类活儿我现在的做法固定成一条:先写校验,再写 prompt。
校验写得出来的字段,说明我确实想清楚了要什么;写不出来的,说明还没想清楚,那这时候 prompt 写多漂亮都没用。上面那趟周日班次,如果我一上来就把 day 的枚举封成 ["mon-fri", "saturday"],它连出错的余地都没有。
至于那个实习生,这轮我不给它打低分。阅读理解确实没问题,输在没人告诉它什么算重要——这个锅在我这儿。它把组织方、协办方、票务渠道都老老实实抓了回来,只是抓回来的位置全凭它的直觉,而直觉不在我这边。
哦对,那行"周日和节假日没有蒸汽列车",它看见了,分类对了,最后还是没信。文本输给先验这件事,我到现在也没想明白该怎么彻底防住。先加校验吧 😅
