一、问题背景:为什么我需要对Prompt进行“工程化”

上个月接手一个法律文书解析项目,需求是从判决书中抽取“原告-被告-案由-赔偿金额”四元组。初版方案直接调用GPT-4o-mini,采用最简单的f"提取以下文本中的实体关系:{text}"模板,结果训练集500份样本的字段级准确率仅有3.2%——几乎全是格式混乱和幻觉实体。

这让我意识到,大模型不是搜索引擎,Prompt的措辞、结构、示例直接影响输出分布。本文将分享我在这个任务上从绝望到达标的完整调优过程,所有代码基于Python 3.11、LangChain 0.2.1、openai 1.30.0,模型为gpt-4o-mini-2024-07-18,温度设为0.1。

二、环境与版本:固定变量才能公平对比

为保证实验可比性,我做了以下约束:
- 同一批200条测试文本(涵盖合同纠纷、交通事故、离婚案三类)
- max_tokens=512temperature=0.1(抑制随机性)
- 每次调用前重置对话上下文(无历史记忆)
- 使用langchain_core.prompts.PromptTemplate强制模板化

# 环境核心依赖(requirements.txt片段)
langchain==0.2.1
openai==1.30.0
python-dotenv==1.0.1
tiktoken==0.7.0  # 用于token精确计数

这里有个关键坑:LangChain 0.2.x中ChatPromptTemplate.from_template对变量名校验更严格,必须显式声明input_variables,否则会报Extra inputs are not permitted错误。

三、方案设计:五轮迭代,每轮只改一个变量

我设计了以下消融实验路径,确保每个改动的影响可归因:

轮次 改动点 Prompt类型 预期目标
v1 基线 零样本指令 建立下限
v2 输出格式约束 结构化指令 解决格式混乱
v3 角色扮演 角色预设 提升法律术语准确性
v4 思维链 CoT推理 提升复杂案件逻辑
v5 Few-shot示例 少样本示范 对齐输出模式

四、核心实现:代码与Prompt全记录

v1 基线:最朴素的提问

from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini-2024-07-18", temperature=0.1)
prompt_v1 = PromptTemplate(
    input_variables=["text"],
    template="从以下文本中提取实体及关系:\n{text}"
)
chain = prompt_v1 | llm
response = chain.invoke({"text": sample_legal_text})
print(response.content)
# 输出示例:'原被告是张三和李四,赔偿5000元,案由是合同纠纷...'
# 问题:格式不统一,字段名缺失,无结构化标记

v1结果:200条样本中,仅7条能解析出完整四元组,准确率3.5%(人工校验)。模型还经常把“赔偿金额”写成“金额:人民币伍仟元整”,需要归一化。

v5 最终模板:角色+格式+思维链+双示例

经过四轮迭代(中间过程见下一节),最终保留的模板如下:

prompt_final = PromptTemplate(
    input_variables=["text"],
    template="""你是一位拥有15年经验的法律文书结构化专家。你的任务是从判决书中提取核心诉讼参与人及标的。

请严格按以下JSON Schema输出,不要输出任何解释性文字:
{{
  "case_type": "合同纠纷|交通事故|离婚纠纷|其他",
  "plaintiff": "原告全名",
  "defendant": "被告全名",
  "amount": 5000.00
}}

要求:
1. amount必须是浮点数,若原文为“五万元”则输出50000.00
2. 若字段缺失,输出null,不要编造
3. 先在心里默念:谁是原告?谁被起诉?涉及金额多少?然后一次性输出JSON

参考范例1(注意金额格式):
输入:原告王小明诉被告李强房屋租赁合同纠纷,要求支付拖欠租金人民币6,500元。
输出:{{"case_type": "合同纠纷", "plaintiff": "王小明", "defendant": "李强", "amount": 6500.0}}

参考范例2(注意缺失处理):
输入:张丽与陈刚离婚一案,本院依法判决,未涉及财产分割。
输出:{{"case_type": "离婚纠纷", "plaintiff": "张丽", "defendant": "陈刚", "amount": null}}

现在处理:
输入:{text}
输出:"""
)

注意双大括号{{}}转义——这是LangChain模板语法,否则会报模板解析错误。我在这里卡了半小时,起初以为是JSON问题,后来查文档才发现是PromptTemplate保留字冲突。

五、踩坑与优化:那些让准确率暴跌的细节

坑1:角色扮演不能贪多。 v3版本我加了“你是刑法专家、民事专家、执行法官”三重身份,结果输出居然出现“根据《刑法》第XX条”这种幻觉。后来只保留“法律文书结构化专家”,准确率反而提升12%。

坑2:思维链必须控制长度。 v4版本我要求“请逐步推理”,模型输出了200字分析再给JSON,虽然逻辑对了,但token消耗从v3的1142暴增到1864,且解析JSON前需要截断。最终改为“在心里默念”,既保留推理又压缩输出。

坑3:Few-shot示例必须覆盖边界。 v5初版我给了3个正常示例,模型学会了金额格式,但遇到“无金额”场景就随机编一个。加入范例2(缺失字段)后,null准确率从31%飙升至92%。

坑4:不要用f-string拼接大文本。 当文本长度超3000字时,f-string会截断或转义错误导致JSON解析失败。改用PromptTemplate后,内部做了正确的占位符处理。

六、效果数据:token消耗与质量权衡

实验最终结果(200条测试集):

版本 字段级准确率 平均输出tokens 平均总tokens 单条成本(美元)
v1 3.5% 412 986 $0.00148
v2 (格式约束) 21.0% 288 844 $0.00127
v3 (角色扮演) 39.5% 305 910 $0.00137
v4 (思维链) 68.0% 682 1864 $0.00280
v5 (Few-shot+精简) 87.5% 311 793 $0.00119

关键发现:
- v4→v5虽添加了两个示例,但通过压缩推理过程,总token反而下降57%
- 87.5%的准确率中,错误集中在“案由”分类混淆(如“借款纠纷”被归为“合同纠纷”)
- 最终模板的推理延迟从v4的2.1秒降至0.9秒(Azure OpenAI标准版)

成本换算:若每天处理1万条文书,v5方案日成本约11.9美元,而v4方案需要28美元——省下56%预算。

七、总结:Prompt工程是“结构化压缩”的艺术

这次实验最深的体会是:好的Prompt不是“说得更多”,而是减少模型的决策熵。每个元素(角色、格式、示例、推理指令)都在压缩输出空间。如果你想复现,建议按我的迭代顺序走一遍,你会看到准确率从个位数到80%的拐点出现在“格式约束+角色扮演”叠加时——那是模型从“生成文本”切换到“填充槽位”的质变。

最后提醒:不要迷信单一指标。我的token消耗降低,但如果你追求极致延迟,可以把Few-shot示例移入System Prompt缓存(Azure OpenAI支持),还能再省20%成本。但那是另一个故事了。

代码仓库:github.com/yourname/legal-ner-prompt-engineering(含所有实验记录与200条测试集)