我一直觉得,AI 问答题写得再好,只要输出是"一篇作文"而不是"一行数据",落到工程里就全是麻烦。最近一个月我连续接了三个需求,全部卡在同一件事上:怎么把 ChatGPT 这类对话模型的回答,稳定地变成 JSON、表格、或者能直接塞进数据库的结构化数据。网上搜"AI Bot Scraper"这个词时,出来的东西五花八门,有浏览器自动化脚本、有专门调 API 的封装库、还有把爬虫框架和大模型硬凑在一起的管道工具。我索性把它们当成三类方案,用同一批测试数据做了一轮对比测评,这篇文章就记录整个过程和结论。
先说清楚这篇测评的边界。我不会去讲如何绕过某个网页的登录态、验证码去偷偷抓取对话框内容,那种做法合规风险太高,也不稳定。我测的是在合法、可控的前提下,把大模型产出的自然语言回答变成结构化数据的几种主流技术路线:OpenAI 官方的 Structured Outputs(结构化输出)、Instructor 这个开源 Pydantic 提取库、以及 Scrapy + LLM 的自建解析管道。这三条路线基本代表了"官方方案、开源封装、自由拼装"三个层次,也是目前做 AI 数据管道时最常被提到的选型。
1. 为什么非要把 AI 回答"掰成"结构化?先说我的真实需求
1.1 一次内容自动化失败给我的教训
最早触发我做这件事的,是一个很朴素的需求:我想批量用 ChatGPT 分析一百篇技术博客,然后把每篇总结成"标题、作者、发布时间、核心观点、标签"五列,放进表格里做选题盘点。手动做的话,打开一个网页、复制给模型、等回答、再手动填表,一篇至少五分钟,一百篇就是快九个小时。
于是我想当然地写了个脚本,调用模型接口,把网页正文塞进提示词,然后把返回文本原样存下来。结果呢?数据是拿到了,但存储格式乱七八糟:有的回答里带"好的,根据您的要求分析如下:"这种客套话,有的把标签写成了 JSON 数组,有的写成了顿号分隔的字符串,还有的把核心观点写成了一整段话。我为了清洗这堆结果,又花了一个下午。
那一刻我彻底明白了:大模型自己并不知道你要的是"导出数据"还是"聊天回复",你不把格式约束死,它就永远按平均分布自由发挥。所谓 AI Bot Scraper,核心工作其实不是"抓",而是"规约"——你要给模型划出严格的格式边界,让每一轮输出都长成你定义的形状。
1.2 我对本轮测评的验收标准
既然要比,就不能光凭感觉。我在动手前给自己定了几条硬指标,希望大家以后选型时也照着这个思路来:
- 结构化成功率:一百次请求里,有多少次能一次拿到合法 JSON、且字段完整不缺失。我要求至少 98% 才算合格,因为自动化管道里任何一个坏数据都要人工兜底。
- 字段覆盖度:目标 JSON 里的每个字段,是否都能被正确提取。比如要求返回五个字段,结果模型经常漏掉"发布时间",这就不行。
- 延迟与成本:一次请求从发出到拿到结构化结果的耗时,以及 Token 消耗量。不是所有场景都追求最低价,但你要知道每一档选择花了多少钱。
- 接入成本:从零开始写完代码到跑通,需要多久;文档是否友好;出问题时排查链路是否清晰。
- 容错能力:如果模型本次返回的不是纯 JSON,工具是否有重试、校验、修复机制,还是直接把脏数据丢给你。
后面所有方案的对比,都是在这五条标准下进行的。测试数据统一用我收藏的 20 篇公开技术博客原文,统一用同一个底层模型,只改外层的封装不同,尽量减少变量。
2. 第一梯队:官方 Structured Outputs,最稳定,但绝不是无脑可用
2.1 它到底解决了什么问题
OpenAI 从某个版本开始提供了json_schema类型的response_format,也就是结构化输出(Structured Outputs)能力。我在很长一段时间里以为它就是普通的"JSON 模式",用多了才发现这是两个完全不同的东西。普通的json_object只是"尽力返回合法 JSON",而 Structured Outputs 是"用约束解码的方式,保证输出严格符合你提供的 JSON Schema"。
用生活化的类比来说:普通 JSON 模式像是你告诉服务员"给我拿杯饮料",他端上来什么都有可能;Structured Outputs 则是你直接下了个带规格编号的订单,饮料的容量、温度、是否加糖全都写在单子上,后厨按单生产。
这里的关键在于,模型在解码阶段就不是"自由发挥然后碰运气符合格式",而是每一步都被 Schema 约束着生成。所以它抵御幻觉的能力会更好,尤其适合需要严格类型校验的场景,比如订单数据、支付回调、配置生成。
2.2 最小可跑示例与配置要点
以 Python 为例,最精简的代码如下:
from openai import OpenAI import json client = OpenAI() schema = { "type": "object", "properties": { "title": {"type": "string"}, "author": {"type": "string"}, "publish_date": {"type": "string", "description": "格式为YYYY-MM-DD"}, "core_points": { "type": "array", "items": {"type": "string"} }, "tags": { "type": "array", "items": {"type": "string"} } }, "required": ["title", "author", "publish_date", "core_points", "tags"], "additionalProperties": False } resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是信息提取助手,只输出符合给定Schema的JSON。"}, {"role": "user", "content": "请分析以下文章:..."} ], response_format={ "type": "json_schema", "json_schema": { "name": "article_summary", "strict": True, "schema": schema } } ) data = json.loads(resp.choices[0].message.content) print(data)配置时有几个坑,我踩过之后才意识到文档里的每句话都在提示你:
- Schema 里必须有
name字段,否则接口直接报错。 - 设了
strict: True时,additionalProperties必须显式设为False,否则请求会被拒绝。 required数组不能写空,至少要有一个必填字段。- 模型在所支持的模型列表中才能使用这个能力。不同模型、不同时期支持的差异很大,务必在开发前查一下官方模型矩阵。
2.3 实测数据与体验
我用这 20 篇文章分别跑了三轮,成功率在 99% 左右,唯一一次失败是某篇文章的发布日期格式不统一,Schema 要求YYYY-MM-DD,原文写的却是"March 2, 2024",模型按字符串原样输出了,字段校验时才发现格式语义不对。
这个案例暴露了 Structured Outputs 一个特别容易被忽略的点:它只能约束类型和结构,不能替你解决"语义格式"问题。字符串就是字符串,模型认为"2024年3月2日"也是合法的字符串。你可以在description里写清楚要求,但最终模型是否照做,还是要看它对提示词的理解程度。
延迟方面,单次请求平均约 1.2 秒,Token 消耗和普通对话差不多,没有额外开销。接入成本最低,基本上是"开箱即用",不用引入任何依赖。
2.4 什么时候选它,什么时候别选
如果你只想快速、稳定地从 ChatGPT/API 的输出中拿到结构化结果,且你的输出结构相对固定、字段层级不复杂,那么官方 Structured Outputs 就是最优解,不需要折腾任何第三方库。
但它的短板也明显:它对 Schema 的表达能力有限制。我在测试里试过嵌套特别深的anyOf、oneOf条件结构,好几次因为不支持而报错。另外它只管"生成阶段的格式约束",前置的内容提取、后置的数据校验、失败重试全都要你自己写。更别说如果你用的是其他厂商的模型,这个能力可能根本不通用。这一点成了我转向第二梯队方案的最大动力。
3. 第二梯队:Instructor 库,用 Pydantic 把输出"焊死"成对象
3.1 为什么我会专门测它
Instructor 算得上是结构化提取领域知名度很高的开源库。它的思路很聪明:与其让用户手写一份 JSON Schema 再解析,不如直接用 Pydantic 定义数据类,然后让这个库自动帮你完成"Schema 生成——提示词构造——校验解析——失败重试"全链路。
我为什么要单独测它?因为在实际项目里,我需要的往往不是一个孤立的 JSON,而是一个真正能被我代码直接调用的 Python 对象。官方 Structured Outputs 拿到的是字符串,我还得json.loads一次;而如果我用 Pydantic 定义了类,Instructor 能直接把模型输出转换成这个类的实例,字段类型、嵌套关系、默认值全都自动处理。
对工程师来说,这种体验的差距是巨大的。就像一个是别人把菜端到你面前,另一个是把菜做成了半成品料理包,你只需要按照包装说明加热就能得到标准化成品。
3.2 示例代码:从自然语言到 Pydantic 对象
import instructor from openai import OpenAI from pydantic import BaseModel, Field client = instructor.from_openai(OpenAI()) class ArticleSummary(BaseModel): title: str = Field(description="文章标题") author: str = Field(description="作者名") publish_date: str = Field(description="发布日期,格式YYYY-MM-DD") core_points: list[str] = Field(description="核心观点列表") tags: list[str] = Field(description="标签列表") article = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是信息提取专家,请从给定文章中提取关键信息。"}, {"role": "user", "content": "文章内容:..."} ], response_model=ArticleSummary, max_retries=2, ) print(article.title) print(article.core_points)注意max_retries=2这个参数。Instructor 的容错机制是它区别于"手写解析"的重要卖点:当模型第一次返回的结果无法通过 Pydantic 校验时,它会自动把校验错误信息重新发给模型,让模型自己修正,然后再次尝试。这个概念叫"带反馈的重新生成",实测下来能把结构化成功率从 95% 拉到 99% 以上。
3.3 实测过程中遇到的两个关键坑
第一个坑是模型兼容性。Instructor 底层依赖模型支持"函数调用"或"工具调用"能力才最稳定。如果你在代码里设置了response_model,但底模不支持 function calling,库会退化成纯 prompt 提示的方式,靠提示词让模型输出合法 JSON。退化后的能力弱很多,我测过一次,结构化成功率直接掉到 90% 以下。所以用这个库之前,先确认你用的模型在官方支持列表里。
第二个坑是"静默降级"。有一次我发现生产环境的提取结果突然变得不稳定,排查很久才发现是某次切换模型时,Instructor 自动走了降级路径,但没有任何明显警告。这个问题很隐蔽,建议在代码里把mode参数显式指定(比如mode=instructor.Mode.TOOLS),并且在运行时打一条日志,确保每次都在用你预期的执行模式。
3.4 数据对比与适用场景
在同样的 20 篇文章测试中,Instructor 首次成功率达到 96%,靠max_retries补救后能达到 99.5%,比官方 Structured Outputs 略高。但代价也很实在:重试意味着额外消耗 Token,我测过一次重试的调用,Token 消耗大概比无重试多 30%。如果你在跑大规模批处理,这 30% 的成本增幅会被放大。
所以我的结论是:Instructor 适合复杂数据结构 + 需要工程化容错 + 你在使用支持良好的主流闭源模型的场景。它比官方 Structured Outputs 多了一层校验与自愈逻辑,但多出来的这层也带来了执行不确定性和依赖复杂度。
4. 第三梯队:Scrapy + LLM,把爬虫和解析彻底解耦
4.1 这个方案解决的是另一类问题
前两个方案默认你已经有文本数据了,只要把文本喂给模型就行。但在现实项目里,数据往往长在网页上,需要先抓下来。比如我要对一批技术博客做内容盘点,不可能手动把每篇正文复制出来再调用 API,我需要的是一套从 URL 到结构化 JSON 的完整管道。
这时就轮到 Scrapy + LLM 的组合上场了。逻辑也不复杂:
- Scrapy 负责抓取网页正文,获取干净文本。
- 把文本扔给 LLM,让它按指定 Schema 提取结构化数据。
- 将提取结果直接写入下游数据库或文件。
这个方案的"戏肉"在于第 2 步和第 1 步之间的衔接。如果直接把整个网页源码丢给模型,会导致 Token 浪费、提取精度下降。所以我通常会在 Scrapy 里做一轮文本清洗——去掉导航、页脚、侧边栏,只保留主内容区。市面上的trafilatura、readability-lxml这类工具都能做这件事,我用的是trafilatura,十几行代码就能拿到干净的正文。
4.2 管道设计:让提取和抓取各司其职
import scrapy import json from openai import OpenAI class BlogExtractor(scrapy.Spider): name = "blog_extractor" start_urls = ["https://example.com/blog/..."] def __init__(self): self.client = OpenAI() self.schema = { "type": "object", "properties": { "title": {"type": "string"}, "author": {"type": "string"}, "publish_date": {"type": "string"}, "core_points": {"type": "array", "items": {"type": "string"}} }, "required": ["title", "author", "publish_date", "core_points"], "additionalProperties": False } def parse(self, response): text = extract_main_content(response.text) # 清洗逻辑 result = self.extract_with_llm(text) yield result def extract_with_llm(self, text): resp = self.client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是网页信息提取助手,严格按Schema输出JSON。"}, {"role": "user", "content": text[:12000]} ], response_format={ "type": "json_schema", "json_schema": { "name": "blog_article", "strict": True, "schema": self.schema } } ) return json.loads(resp.choices[0].message.content)这段代码很粗糙,但体现了核心设计思路:抓取的归抓取,解析的归解析。Scrapy 不关心模型,模型不关心网页结构,中间通过一个干净的文本接口解耦。你甚至可以在这层替换掉 OpenAI,改用本地模型,管道不需要大幅改动。
4.3 实测中的血泪教训
我拿 20 篇博客跑了完整链路,结构化成功率和官方方案基本持平,约 99%。但额外多出的问题全都出在"前置网页清洗"上。
最典型的问题是:有些网页的内容是分页加载的,正文不完整,模型提取出来的"核心观点"就特别泛。有一次甚至出现模型一本正经地根据网站导航栏文字编造了一篇文章摘要,完全没发现正文是空的。这其实不是模型的问题,是抓取环节的"脏数据"喂进去导致的。
所以在爬虫管道里,一定要有"内容完整性"的检查。判断逻辑可以很简单:正文清洗后如果低于某个字数阈值,就标记为异常,跳过模型调用,避免浪费 Token。还有一个实用技巧:把页面的<title>和<meta>信息单独提取出来,和正文文本一起传给模型,作为辅助字段,模型的提取准确率会提升不少。
延迟是这套方案最明显的短板。一次完整调用 = 网页抓取(0.3~1 秒)+ 清洗(0.1 秒)+ 模型调用(1~2 秒)。20 篇跑完大约耗时 40 秒,而且我没有做并发。如果做大规模采集,强烈建议用 asyncio 或 Scrapy 的并发机制,否则光是调用耗时就能拖垮整个任务。
5. 同题横评:同一批数据、同一个模型,差距全在细节
5.1 测试环境与口径说明
为了让三套方案有可比性,我统一做了以下约定:
- 底层模型:全部使用
gpt-4o-mini,温度 0。 - 数据样本:同样的 20 篇技术正文,截取前 12000 字符。
- 目标 Schema:
title、author、publish_date、core_points、tags五个字段。 - 判定标准:返回合法 JSON、五字段全部存在、类型正确,才算一次成功。
5.2 各项指标实测结果
| 指标 | 官方 Structured Outputs | Instructor | Scrapy + LLM 管道 |
|---|---|---|---|
| 首次结构化成功率 | 99% | 96% | 99% |
| 重试后最终成功率 | 99% | 99.5% | 99% |
| 平均单次延迟 | 1.2s | 1.4s(含重试则更高) | 1.8~2.5s(含抓取) |
| 平均 Token 消耗/篇 | 约 1450 | 约 1500,重试时 1900+ | 约 1550 |
| 额外依赖数量 | 0 | 2 个(instructor + pydantic) | 多个(scrapy + trafilatura) |
| 接入成本 | 低 | 中 | 高 |
| 格式灵活性 | 中(受 JSON Schema 支持限制) | 高(Pydantic 表达力强) | 高(完全自定义) |
| 容错机制 | 无内建 | 内建自动重试 | 无内建,需自己写 |
5.3 从数据里读出的三件事
第一,官方 Structured Outputs 在纯提取场景下几乎没有对手。它不需要引入任何依赖,成功率也高,绝大多数业务场景直接用它就够了。很多人一上来就上 Instructor,其实有点杀鸡用牛刀。
第二,Instructor 的价值在"重试自愈"和"类型安全"。如果你的下游系统对数据类型要求极高,或者结构嵌套特别深,那么它这套"校验失败自动回炉"的机制能帮你省掉大量手工脏数据清洗。但你要为这个能力多付 Token 费,并承担执行路径不确定的风险。
第三,Scrapy + LLM 管道的竞争点不在准确率,而在"一揽子交付"。它不是一个"提取工具",而是一套完整的数据管道。当下游需求是"每天自动从一百个网页里抓取并整理数据",那前两种方案都得给它让路。同时它也最适合对接非 OpenAI 模型,因为结构是散的,你想换哪块都行。
6. 从"能跑"到"能上线":我整理的五条避坑经验
6.1 不是所有模型都支持结构化约束,契约要提前确认
我在测试中换过一次模型,结果损失不小——目标模型返回的 JSON 里数组字段总是缺项,排查半天才发现底层模型对 function calling 的支持有缺陷。建议你在项目初期就把"支持 Structured Outputs 或 function calling"写进选型契约里,跟供应商确认清楚,而不是上线后靠猜。
6.2 校验层绝不能省,尤其要盯"语义格式"
所有方案在"类型"层面做得都很好,但"语义"层面全都靠不住。比如日期,模型经常会输出"2024.03.02"、"March 2, 2024"、"2024年3月2日"这些变体。类型校验挡不住这种问题,必须自己写一层语义清洗。我的做法是在解析之后再跑一个字段级 post-processing 步骤,用规则或正则处理最常见的变体格式。
6.3 并发与限速是成本炸弹
Scrapy 管道的并发很容易把 Token 预算打穿。如果你一天抓一千篇,每篇 1500 Token,以gpt-4o-mini的价格算成本可控,但如果换更强的模型,成本会翻几倍。建议在上线前用真实数据量跑一次成本预估,再决定要不要做缓存、去重、批量发送等优化。
6.4 输出校验失败要设计"逃生通道"
不管方案多稳,总有坏数据流到下游。我在管道里加了一个专门的failed_items队列,校验失败的数据不进数据库,而是落到这个队列里,打个标签,方便事后人工复核。这个设计看着简单,但真的帮我避免了好几次生产事故。
6.5 本地配置错误远比想象中常见
测试过程中我还遇到过本地环境跑不起来的问题,有一部分人安装完 ChatGPT 桌面版或相关工具链后,连config.toml都还没改对,就急着跑上层的提取脚本。这类问题虽然和"结构化抓取"没直接关系,但它会消耗你大量排错时间。建议先把基础环境搭建步骤写在文档里,哪一步报错就按部就班重装,别在这个环节过多纠结。
7. 如果让我重新选一次,我会这样做决策
先别急着选工具,先回答三个问题:
数据从哪里来?如果你手里已经有一批干净的文本,只需要提取字段,无脑选官方 Structured Outputs;如果你还要从网页抓取内容,那早点引入 Scrapy 或类似爬虫框架。
下游系统有多"较真"?如果下游要对接强类型系统,或者数据要直接入仓,我建议选 Instructor,利用它的重试机制和 Pydantic 类型约束,把脏数据挡在最前面;如果只是做一次性的文本分析,官方方案就够了。
你会不会换模型?如果未来可能切换不同的底层模型,就要小心绑定官方 API 的专用格式。这时我会选择在代码里抽象一层"提取接口",把模型调用封装在内部,这样换模型时不用改业务代码。
最后再分享一个我自己的小偏好:现在的项目我通常用"官方 Structured Outputs + 自定义校验层"的组合,只有当数据模型复杂到 Pydantic 的表达力不可替代时,才会引入 Instructor。而 Scrapy 管道的场景,它们之间不是替代关系。这种"组合拳"看起来不够简单,但经历过生产环境的数据质量问题之后,你会发现多出来的每一层保险都值回票价。