刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。
摘要
把 4 份真实企业文档丢给 AI Agent 做信息抽取:第一份全对,往后连环翻车——Prompt 换文档就废、同一份跑三次口径就漂、结果散在聊天记录、金额错了没人拦。挖到底,四个断点全在模型外面。本文用 Agent 两种用法(裸对话 / 工程化)对照 Scriber"字段定义—抽取—审核—交付"全链路,实测数字全部摊开。
一、麻烦复现:一个"看起来能跑"的 Agent 抽取流程
做企业数据的人迟早会接到这样的需求:业务部门要一张结构化表,数据来源是一堆合同、公告和年报。拿模型抽,还是拿人填?现在的答案通常是前者——大模型这么强,丢进去让它输出 JSON 就完事了。
我也是这么开始的。测试物料 4 份,都是真实业务文档,不是造出来的:
编号文档类型页数字段口径D1贵州茅台 2025 年年度报告富格式长文档(信披)14316 个字段 + 前十大股东关系表D2广州市政府采购委托代理协议富格式长文档(合同)517 个字段D3白云电器 2026 年半年度权益分派实施公告KV 制式文件416 个字段D4乐心股份 2026 年半年度权益分派实施公告KV 制式文件4同 D3
任务口径就是三张字段表。以权益分派公告为例,业务要的字段长这样(节选):
{
"证券代码": "603861",
"每股现金红利(元)": 0.02,
"每10股派现金额(含税,元)": 0.20,
"股权登记日": "2026-10-14",
"除权(息)日": "2026-10-15",
"现金红利发放日": "2026-10-15",
"是否差异化分红送转": true,
"参与分配股本基数(股)": 536006217
}
Agent 侧的用法,用企业里最主流、最不折腾的那套,不刻意搭稻草人:
你是文档信息抽取助手。请从文档中提取以下字段,以 JSON 输出:
证券代码、证券简称、公告编号、每股现金红利(元)、每10股派现金额(含税,元)、
是否送红股、股权登记日、除权(息)日、现金红利发放日、是否差异化分红送转、
参与分配股本基数(股)、分红总额(元)。
字段值必须来自原文,没有的置 null。
第一份,D3 白云电器公告,抽得相当不错。证券代码、每股 0.02 元、每 10 股 0.20 元、三个日期、差异化分红为"是",全对,JSON 格式工整。如果测评到此为止,结论会是"Agent 已经够用了"。
但企业场景没有"到此为止"。接着往下走,四个现场依次出现:
现场一:换一份文档类型,Prompt 作废。 把 D2 委托代理协议喂进去,还是这条 Prompt。模型很"诚实"地把证券代码返回了 null——委托代理协议里当然没有证券代码,但委托人、受托人、法定代表人、采购预算、采购方式这 17个真正要的字段,它一个都没抽。字段口径换了文档类型就换了整套,Prompt 得重写。
现场二:同一份文档,跑三次,输出没能完全一致。
还是 D3,还是同一条 Prompt,连跑 3 次。输出大体正确,但三个金额字段在数字与字符串之间摇摆:每股现金红利一次是0.02、两次是 “0.02”;最典型的是参与分配股本基数——一次是 536006217,两次是 “536,006,217”,带千分位逗号的字符串。同一个数,两种形态。下游按整数接收的代码,收到后者当场解析失败。(关键字段对照见第三章实验二。)
现场三:4 份文档 = 4 轮对话 + 4 次手工复制。 D1 年报 143 页,一次对话窗口塞不下还要想办法切片;4 份文档抽完,结果散在 4 段聊天记录里,要进 Excel 还得手工整理。抽完了,但"数据"还不存在。
现场四:这次没抄串——但没有任何机制在兜底。 白云电器公告同时写着"每股现金红利 0.02 元"和"每 10 股派注意到金红利 0.20 元(含税)"。3 次运行都没有抄串——但注意这个“没出事”的性质:流程中没有任何一环在核对这两处金额。这次没抄是运气,哪天抄了,它会 静默地直接进下游。
四个现场对应四个麻烦:换文档要重写 Prompt、输出不稳定、没法批量、没有审核。这不是某个模型菜——同样的模型,同样的文档,单次抽取质量并不差。麻烦出在别处。
二、原因分析:四个断点,都断在模型外面
先说结论:四个现场没有一个是"模型能力不足"。把每个现象拆到工程原理层,会注意到缺的是围绕模型的生产设施。
2.1 断点一:Prompt 是"无契约的配置"
Prompt 的本质是一份配置:字段名、业务口径、输出格式,全部写在自然语言里。而配置在工程里是要被管理的——有版本、能 diff、可回归测试。自然语言 Prompt 三样全无:
- 无 Schema:字段约束(类型、枚举、是否必填)没有机器可读的定义,模型靠"理解"执行,读者靠"肉眼"验收;
- 无版本:改一个字段口径,没法知道哪些历史输出用的是旧口径;
- 无回归:调 Prompt 的人都知道,修好一个 case 惊动三个 case,因为没有测试集帮你盯。
对比一个有契约的世界长什么样:
{
"name": "每股现金红利(元)",
"type": "数字",
"提取规则说明": "重要内容提示「A股每股现金红利」",
"check": "逻辑校验:每10股派现金额 = 本字段 × 10"
}
字段名、类型、原文定位提示、校验规则,四件事写死在定义里。这是后面平台化方案的出发点。
2.2 断点二:无约束解码 = 不稳定输出
第二个现场(同 Prompt 跑 3 次口径就漂)的机制有三层。
采样层:大模型解码是概率采样,温度不为零,同一输入不保证同一输出。这是特性不是缺陷——对话产品需要多样性。但数据生产要的是确定性。
约束层:会话式输出没有结构化绑定。JSON Schema、function calling、structured output 这些能力在 API 层是存在的,但在"把文档丢进对话框"这个用法里,输出契约完全靠模型自觉——字段名漂移(每股现金红利→每股红利)、类型漂移(0.02 vs “0.02” vs “2分”)都是这么来的。
定位层:拿现场四的两处金额做个解剖。白云电器公告里,0.02 元在文首"重要内容提示"区,0.20 元(每 10 股)在第二节的"分配方案"里——两处相隔约 800 字、跨越一个章节边界。模型读的是整段线性文本,没有任何机制告诉它"每股红利"这个字段的锚点位置,它靠注意力在全文里"找回"这个值,而注意力有近因偏好、有噪声。同一个值离哪个数字更近,是文档排版决定的,不是字段口径决定的。当字段没有锚点,排版就是命运。(实测 3次没有抄串——运气站在了这边,但机制并不存在。)
还有一种漂移更隐蔽:表格串列。D1 茅台年报的"主要会计数据"表长这样:
指标本期上年同期增减幅营业收入(元)168,838,102,514.79170,899,152,276.34-1.21%基本每股收益(元/股)65.6668.64-4.34%
三列并排,业务只要"本期"。模型没有错认数字,它只是偶尔取错列——而"取哪列"这件事,Prompt 里往往忘了说。这不是识别问题,是口径没有随字段一起被定义。
2.3 断点三:会话式 I/O 不是数据流水线
第三个现场(4 份文档 4 轮对话)的本质:会话是给人用的交互形态,不是给数据用的。
企业批量抽取要的东西,会话模式一样都不给出:没有队列(100 份文档排队)、没有并发编排、没有失败重跑(第 37 份超时了怎么办)、没有结果汇聚(抽完自动合成一张表)。要让 Agent 批量跑,得自己写胶水:
# 把会话包装成流水线,需要自己补的部分
docs = load_documents("inbox/") # 1. 文件汇聚:自己写
results, failed = [], []
for doc in docs: # 2. 队列:就是个 for 循环,无并发
try:
out = agent.extract(doc, PROMPT) # 3. Prompt 硬编码在代码里
results.append(out)
except Exception as e:
failed.append((doc, e)) # 4. 失败处理:自己定义
save(results) # 5. 汇聚入库:自己写
二十行脚本看着不难,难的是它没有尽头:口径变了要改 Prompt、模型升级要回归全量、老板要个失败率报表还得再加两百行。"抽取完成"和"数据可用"之间,隔着一整条数据工程的距离。
2.4 断点四:没有校验层,错误直接入库
第四个现场(没有任何机制在兜底)是四个里最危险的,因为它不可见——前三个现场至少肉眼可查,这一个要靠人对着原文逐字核。
数据生产系统的常识是:上游错误率不为零,所以要建校验层。文档抽取场景里,至少四类校验是刚需:
```
def review(record):
# 为空校验:核心字段缺失即拦截
assert record["证券代码"], "证券代码为空"
# 正则校验:格式契约
assert re.fullmatch(r"\d{6}", record["证券代码"]), "证券代码格式错误"
# 逻辑校验:字段间关系
assert record["股权登记日"]
本次分享就到这里。技术这东西越研究越有意思,后续有新的收获我也会继续更新。
评论 (0)
暂无评论