一、问题背景:为什么合同抽取值得单独调Prompt

先说场景。我们做的是一个采购合规系统,需要从供应商合同里抽取结构化字段:甲方、乙方、合同总金额、付款节点(数组)、违约责任条款原文。下游要做金额校验和节点提醒,所以字段必须准,格式必须稳。

一开始我觉得这活儿不难,直接丢给GPT-4o就行。结果第一版跑下来,50份合同里有14份返回了非法JSON,还有一堆把“含税总价”和“不含税总价”搞混的。更离谱的是付款节点,模型有时候返回字符串,有时候返回对象数组,下游解析直接崩。

于是老老实实做Prompt Engineering。这篇文章就是把整个过程摊开讲,包括每一版为什么改、改完Token和质量怎么变。

二、环境与版本

  • 模型:gpt-4o-2024-08-06(支持 Structured Outputs)
  • SDK:openai==1.40.0
  • Python:3.11.9
  • 测试集:50份真实采购合同,平均长度3800字(约5200 Token)
  • 评测方式:字段级 exact match + JSON 可解析率
  • 参数:temperature=0top_p=1max_tokens=1024
  • 成本按官方价:输入 $2.5/1M Token,输出 $10/1M Token

三、方案设计:6版Prompt的演进路线

我按“控制变量”的思路,每次只改一个维度:

版本 核心改动 关键参数
V1 自然语言描述任务 无格式约束
V2 加 JSON 示例 few-shot 1例
V3 加字段定义和边界规则 few-shot 2例
V4 改用 Structured Outputs response_format=json_schema
V5 压缩指令,去冗余 system+user 拆分
V6 动态裁剪合同+字段级校验 只传相关段落

V4 是质变点,V5/V6 是成本优化。

四、核心实现:从V1到V6的关键代码

先看 V1,最朴素的写法:

from openai import OpenAI
import json

client = OpenAI(api_key="sk-xxx")

def extract_v1(contract_text: str) -> dict:
    prompt = f"""请从下面的合同中抽取甲方、乙方、合同金额、付款节点、违约责任。
合同内容:
{contract_text}
"""
    resp = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=1024,
    )
    return resp.choices[0].message.content

V1 的问题很明显:返回的是自然语言,我得再写正则去抠。50份里只有36份能解析出完整字段。

V4 改用 Structured Outputs,这是官方在 2024-08-06 版本引入的能力,能保证输出严格符合 JSON Schema:

from openai import OpenAI
from pydantic import BaseModel
from typing import List

client = OpenAI(api_key="sk-xxx")

class PaymentNode(BaseModel):
    stage: str
    ratio: str
    condition: str

class ContractInfo(BaseModel):
    party_a: str
    party_b: str
    total_amount: str
    payment_nodes: List[PaymentNode]
    breach_clause: str

def extract_v4(contract_text: str) -> ContractInfo:
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {"role": "system", "content": "你是合同信息抽取引擎,只输出结构化结果,不解释。"},
            {"role": "user", "content": f"抽取以下合同字段:\n{contract_text}"},
        ],
        response_format=ContractInfo,
        temperature=0,
    )
    return completion.choices[0].message.parsed

V4 一上,JSON 可解析率直接到 100%。但 Token 消耗上去了,因为 Schema 本身要占 Token,而且我把整份合同都塞进去了。

V5 做了两件事:把 system prompt 压到 28 个字;把字段定义从 prompt 移到 Schema 的 description 里(OpenAI 的 Structured Outputs 支持 description,模型会读)。这样 prompt 部分省了约 180 Token。

V6 是真正的成本杀手:先用 text-embedding-3-small 做段落召回,只把和“金额、付款、违约”相关的段落送进去。合同平均 5200 Token,裁剪后平均 1900 Token。

import numpy as np
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")

def split_paragraphs(text: str, max_len: int = 400):
    paras, buf = [], ""
    for line in text.split("\n"):
        if len(buf) + len(line) > max_len:
            paras.append(buf.strip())
            buf = line
        else:
            buf += "\n" + line
    if buf.strip():
        paras.append(buf.strip())
    return paras

def embed(texts):
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts,
    )
    return np.array([d.embedding for d in resp.data])

def recall_relevant(contract: str, top_k: int = 8) -> str:
    paras = split_paragraphs(contract)
    if len(paras) <= top_k:
        return contract
    query = "合同金额 付款节点 付款比例 违约责任 违约金 甲方 乙方"
    q_vec = embed([query])[0]
    p_vecs = embed(paras)
    sims = p_vecs @ q_vec / (np.linalg.norm(p_vecs, axis=1) * np.linalg.norm(q_vec) + 1e-8)
    idx = np.argsort(sims)[::-1][:top_k]
    idx = sorted(idx)
    return "\n".join(paras[i] for i in idx)

V6 的完整调用就是把 recall_relevant 的结果喂给 V5 的抽取函数。注意 embedding 也要算钱,text-embedding-3-small 是 $0.02/1M Token,50份合同总共花了不到 $0.001,可以忽略。

五、踩坑与优化

坑1:temperature=0 不等于完全确定。我跑了两遍 V3,付款节点字段有 3 份合同结果不一样。后来换 Structured Outputs 才彻底稳定。

坑2:Schema 里 payment_nodes 一开始写成 List[str],模型把所有节点拼成一句话塞进一个元素。改成嵌套对象后才正常。

坑3:金额字段。合同里同时有“含税总价”“不含税总价”“大写金额”,V2/V3 经常抽错。V5 在 Schema description 里明确写“抽取含税总价,若无则取合同总金额”,准确率从 78% 提到 96%。

坑4:V6 的召回会漏。有 2 份合同的违约条款写在附件里,被裁掉了。后来我把 top_k 从 6 提到 8,并在 query 里加了“附件”,漏抽降到 0。

坑5:max_tokens=1024 对长付款节点不够。有一份合同 12 个付款节点,输出被截断。改成 2048 后正常,但因为只有这一份超,我最后是按合同长度动态设 max_tokens

六、效果数据

50份合同,每版跑一次,统计如下:

版本 平均输入Token 平均输出Token 平均总Token JSON可解析率 字段准确率 单份成本
V1 5320 210 5530 72% 68% $0.0154
V2 5480 240 5720 88% 76% $0.0161
V3 5610 260 5870 92% 81% $0.0166
V4 5720 280 6000 100% 89% $0.0171
V5 5540 270 5810 100% 91% $0.0165
V6 1820 260 2080 100% 94% $0.0072

注:V6 的输入 Token 含 embedding 的费用,但 embedding 单价极低,实际单份成本约 $0.0068,比 V1 降了 56%,准确率反而涨了 26 个百分点。

延迟方面,V1 平均 3.2s,V6 平均 1.8s,因为输入短了,首 Token 时间也降了。

七、总结

这轮迭代最大的体会是:Prompt Engineering 不是“把话说得更漂亮”,而是把不确定性一点点挤出去。

  • Structured Outputs 是分水岭,它把“格式问题”从 Prompt 里彻底拿掉,省下的精力可以全砸在语义准确率上。
  • 字段定义不要堆在 prompt 里,塞进 Schema 的 description,既省 Token 又更稳。
  • 成本优化的大头在输入侧。RAG 式裁剪对长文本任务几乎是必选项,50份合同省了一半以上成本。
  • 别迷信 temperature=0,要确定性就上结构化输出。

如果你的任务也是长文本抽取,建议直接从 V4 起步,再按 V6 的思路做召回裁剪。省下来的钱和时间,够你多跑几轮评测了。