合同抽取别停在 JSON:标准规则和交易日历才是硬仗
这篇复盘一套金融合同解析和交易日历规则引擎。场景来自某 A+H 上市头部券商的资管业务:输入是十万字量级的资管计划 / 基金合同,输出不是摘要,而是费用、业绩报酬、申购赎回开放规则,以及全年逐日开放状态。
这个项目里我感受最深的一点是:合同抽取不能停在 JSON。JSON 只是中间态,后面还有标准规则映射、交易日历、缓存、评测和人工复核。
核心链路
先看整体架构。
这里有两个解耦点:
- 抽取侧关注「从合同里找出候选字段」。
- 日历侧只消费标准规则 VO,不依赖模型原始输出结构。
这个解耦很重要。否则抽取 prompt 一改,日历逻辑就跟着抖。
为什么不用一个大 prompt
最短路径当然是写一个大 prompt,把费用、业绩报酬、申购赎回规则一次性抽完。
我没有这么做。
原因是字段之间的关注点差别太大。费用模块要盯分档表、百分比和费率名;业绩报酬要盯计提基准、超额收益、隐含 0% 分档;申购赎回要盯开放日、确认方式、锁定期和假期处理。一个 prompt 里全塞进去,后面每次改动都很难知道回归来自哪里。
最后拆成 5 个模块:
- 费用
- 业绩报酬
- 申购流动性
- 赎回流动性
- 其他
重点模块跑两套 prompt,单个 case 最多并发 8 路 LLM 调用。双提示词比单提示词约 +3pp,代价是 token 成本更高。
这个取舍比较朴素:关键字段先进候选池,后面再靠清洗、评测和人工复核筛。漏在第一步外面的字段,后面基本救不回来。
输出 schema 要向规则靠拢
抽取结果不能只考虑模型好不好输出,还要考虑下游规则好不好消费。脱敏简化后的开放日规则结构大概是这样:
json
{
"openDayInfo": {
"cycleType": "nyzz",
"yearMonth": 9,
"monthWeek": 4,
"weekDay": [2, 3, 4],
"openDayType": "gzr",
"holidayAlign": "sy"
},
"lockPeriod": {
"value": 0,
"unit": "day"
},
"source": {
"clause": "每年9月第三个周五下一周的周二及此后两个工作日,遇非工作日顺延",
"visibility": "desensitized"
}
}
这段 schema 解决的是规则映射问题:cycleType、monthWeek、weekDay、holidayAlign 都要能被日历引擎直接消费。
清洗层做什么
模型输出会有很多脏东西。这里不能全靠 prompt 约束。
清洗层主要做几类事:
python
def clean_field(raw):
raw = normalize_kv_shape(raw)
raw = fuzzy_enum_map(raw, {
("T+2", "T+2日", "N+2"): "n2",
("顺延",): "sy",
("提前",): "tq",
("工作日",): "gzr",
})
raw = parse_numeric_semantics(raw, {
"千分之五": 0.5,
"百分之二十五": 25,
"正收益": 0,
})
return raw
这段伪代码表达的是边界:格式和常见语义归一交给代码,业务定义不要靠清洗层硬猜。
候选融合也类似:
python
def merge_dual(left, right):
return {
"simple": choose_more_valid_key_or_longer_value(left, right),
"paramList": merge_by_left_value_key(left.paramList, right.paramList),
"liquidity": choose_more_non_empty_fields(left.liquidity, right.liquidity),
}
这不是智能算法,就是工程兜底。它的价值在于可复现,方便评测,出了问题能定位。
交易日历:9 类周期先自然日取号
日历引擎支持 9 种周期类型:
| 类型 | 含义 |
|---|---|
| y | 每月第 X 日 |
| j | 每季第 X 日 |
| z | 每周周 N |
| yzz | 每月第 M 周周 N |
| yd | 按月倒数第 X 日 |
| jd | 按季倒数第 X 日 |
| nyr | 每年 Y 月第 X 日 |
| nyzz | 每年 Y 月第 M 周周 N |
| nydr | 每年 Y 月倒数第 X 日 |
核心取舍是:所有周期先按自然日取号,不在每个周期函数里处理交易日。
python
def generate_open_days(rule, trading_calendar):
candidates = dispatch_cycle_by_calendar_day(rule)
if rule.holiday_align == "none":
return intersect_open_days(candidates, trading_calendar)
return align_holiday(
candidates=candidates,
trading_calendar=trading_calendar,
direction="forward" if rule.holiday_align == "sy" else "backward",
)
这段伪代码的重点是把「取号」和「假期对齐」拆开。否则 9 种周期会各自长出一套顺延逻辑,维护成本很快上来。
openDay=32 也在周期层处理,表示当月最后一日,按月份动态换成 28 / 29 / 30 / 31。
假期对齐:别把多个开放日挤成一天
真正容易写错的是这个。
如果同一个连续休市块里有多个候选开放日,不能全部顺延到节后第一个交易日。
做法是先找到最大连续休市块,再对块内候选日排序编号:
python
def align_holiday(candidates, trading_calendar, direction):
grouped = group_by_closed_market_block(candidates, trading_calendar)
result = []
for block, dates in grouped.items():
sorted_dates = sorted(dates)
for index, date in enumerate(sorted_dates, start=1):
aligned = nth_trading_day_from(
block=block,
n=index,
direction=direction,
limit=400,
)
if aligned:
result.append(aligned)
return sorted(set(result))
这段逻辑解决的是「散开不塌缩」。顺延取节后第 i 个交易日,提前取节前第 i 个交易日。
边界上,我设置了 400 天游走上界。找不到交易日就丢弃候选,不硬画日期。这个选择保守,但金融日历里画错比暴露异常更糟。
EDD 评测:别靠感觉调 prompt
第一版准确率低,标准规则映射经常出 bug。后面我补了 EDD 评测,才把问题从「感觉不准」变成字段级对比。
评测有三档消融:
- L0-single:单提示词
- L1-dual:双提示词
- L2-dual-chunk:双提示词 + 分块定位
它还会抓 expected null 和 spurious,也就是合同里不该有字段时,模型却补了一个看似合理的值。
失败模式大概是这样:
| 失败类型 | 脱敏样例 | 为什么错 | 处理策略 |
|---|---|---|---|
| 隐含分档漏抽 | 8% 到 15% 收取 5%,15% 以上收取 10% | 漏掉 8% 以下 0% 分档 | 双提示词提高召回,EDD 按字段比较 |
| 非约束时间过抽 | T+3 后可查询确认情况 | 查询时间不是确认口径 | expected null / spurious 校验 |
| 免收语义反向 | 免收退出费 vs 有权减免费用 | 前者是本来不收,后者是可免除 | 模型对比评估,字段规则单独看 |
| 假期顺延塌缩 | 多个候选落在同一假期 | 全顺延到节后第一天会画错日历 | 连续休市块内按候选序号散开 |
EDD 最有用的不是分数,而是归因。如果多个模型都错成同一个非空值,问题可能在 prompt 或金标;如果只有一个模型错,更像模型差异;如果上下文没进来,先改分块,不要继续堆 prompt。
缓存用参数指纹隔离
交易日历查询还有一个小坑:同一个任务的流动性规则和锁定 / 封闭期参数可能被前端改掉。如果只按 taskId + year + month 查缓存,很容易吃到旧日历。
所以我对规则参数算 md5:
python
def params_hash(rule):
payload = {
"openDayInfo": rule.open_day_info,
"lockPeriod": rule.lock_period,
"closedPeriod": rule.closed_period,
}
return md5(stable_json(payload))
def query(task_id, year, month, rule):
h = params_hash(rule)
cached = find_saved_calendar(task_id, year, month, h)
if cached:
return cached
return calculate_in_memory(rule, year, month)
未命中时现算但不落库。保存态以 save 为准,试算态不污染历史结果。
结果和边界
用户补充的结果是,合同处理耗时从数小时缩短到约 2 分钟,字段级结果从 60% 多提升到 90% 左右。
但这套系统不是无人值守。高风险字段、冲突字段、隐含条件和业务定义未完全收口的字段,仍然需要人工复核。
我的经验是:合同抽取项目要早一点承认模型会错。把错误放进 EDD,把枚举和数值放进确定性清洗,把日期语义放进规则引擎。这样系统才不会停在一个好看的 JSON demo。
如果有帮助,欢迎点赞收藏。