合同抽取别停在 JSON:标准规则和交易日历才是硬仗

合同抽取别停在 JSON:标准规则和交易日历才是硬仗

这篇复盘一套金融合同解析和交易日历规则引擎。场景来自某 A+H 上市头部券商的资管业务:输入是十万字量级的资管计划 / 基金合同,输出不是摘要,而是费用、业绩报酬、申购赎回开放规则,以及全年逐日开放状态。

这个项目里我感受最深的一点是:合同抽取不能停在 JSON。JSON 只是中间态,后面还有标准规则映射、交易日历、缓存、评测和人工复核。

核心链路

先看整体架构。

flowchart TD A[资管合同文本] --> B[分块定位<br/>chunk=8000 overlap=800] B --> C[5 模块抽取] C --> D[重点模块双提示词] D --> E[候选融合] E --> F[枚举/数值/KV 清洗] F --> G[标准规则 VO] G --> H[交易日历规则引擎] H --> I[逐日申赎开放状态] C --> J[EDD 消融评测] E --> J F --> J

这里有两个解耦点:

  • 抽取侧关注「从合同里找出候选字段」。
  • 日历侧只消费标准规则 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 解决的是规则映射问题:cycleTypemonthWeekweekDayholidayAlign 都要能被日历引擎直接消费。

清洗层做什么

模型输出会有很多脏东西。这里不能全靠 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。

如果有帮助,欢迎点赞收藏。

相关推荐
冬哥聊AI1 小时前
滴滴Agent岗二面:RAG 系统的 LLM 幻觉怎么治?从两类根源讲到四道防线
人工智能
lyshlc1 小时前
# AI Agent的推迟判定协议:不确定性下的最优策略
人工智能
用户329901675051 小时前
用zod在运行时兜住AI返回的JSON
人工智能
George3752 小时前
第一章:本体论是什么(以及它不是什么)
人工智能
贵慜_Derek2 小时前
《从零实现 Agent 系统》连载 32|闭集 IE 与小模型:分类、意图与字段抽取
人工智能·架构·agent
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
火山引擎开发者社区3 小时前
告别长期密码:火山引擎云数据库 MySQL IAM 鉴权全解析
人工智能
火山引擎开发者社区3 小时前
从仓库维护者到架构师|首个大规模真实仓库长程任务 SWE 数据集 DeNovoSWE 发布,火山引擎云沙箱提供支撑
人工智能
火山引擎开发者社区9 小时前
火山 DTS 正式支持 MySQL 同步到 Milvus , 解决业务库到向量库最后一公里
人工智能