一、问题背景:为什么我需要对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=512,temperature=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条测试集)