1. 问题背景:明明模型更强了,为什么我的Prompt还是又贵又蠢?
上个月在维护一个供应商合同解析服务,需求是从PDF抽取“付款条款”、“违约责任”和“续约条件”三个字段。最初我直接用了一个“万能抽取Prompt”,把任务背景、输出格式、示例全塞进去,甚至加了一句“请仔细思考,不要遗漏任何信息”。结果单次调用平均输入12K tokens,输出经常带无关解释,而且关键字段的召回率不稳定。
很多人以为Prompt越长越详细越好,但在真实业务里,长Prompt带来三个问题:一是token成本线性上涨(GPT-4o-mini输入价$0.15/1M tokens,输出$0.60/1M,但量大了也是钱);二是模型对长指令的注意力会被稀释,尤其是中间部分的约束经常被忽略;三是延迟增加,影响线上服务SLA。
这篇文章不是讲Prompt的哲学,而是记录我如何把一个具体任务从“能用”压到“好用”的过程。所有代码基于Python 3.10.12、openai 1.35.3、LangChain 0.2.1,模型为gpt-4o-mini-2024-07-18。
2. 环境与版本:固定模型,控制变量
为了公平对比,我做了三件事:固定模型版本、固定temperature=0.0、固定每次输入文本(一段真实的供货合同PDF转出的Markdown,共1,024字)。所有测试在同一个Jupyter Notebook中运行,使用langchain_openai的ChatOpenAI封装。
import tiktoken
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
MODEL = "gpt-4o-mini-2024-07-18"
llm = ChatOpenAI(model=MODEL, temperature=0.0, max_tokens=1024)
enc = tiktoken.encoding_for_model(MODEL)
def count_tokens(text: str) -> int:
return len(enc.encode(text))
def run_prompt(system_prompt: str, user_content: str) -> tuple[str, int, int]:
messages = [HumanMessage(content=system_prompt + "\n\n" + user_content)]
resp = llm.invoke(messages)
input_tokens = count_tokens(system_prompt) + count_tokens(user_content)
output_tokens = count_tokens(resp.content)
return resp.content, input_tokens, output_tokens
这里有个细节:system和user的拼接方式会影响计费,openai的计费是按实际发送的message数组来算的,所以我把system_prompt和user_content分开传,但tiktoken只负责统计文本长度,实际计费会略高于这个数字(因为有消息格式的额外开销),本文所有数字均为纯文本token数,偏差在±3%以内。
3. V1基线:堆砌一切,结果F1只有0.82
第一版Prompt我写了近400字,包含任务背景、字段定义、输出JSON格式、三个示例、以及“请确保不遗漏”等模糊指令。典型如下(已截断部分):
你是一名法律文本分析专家。请从以下合同中抽取三个关键字段:付款条款(payment_terms)、违约责任(breach_liability)、续约条件(renewal_condition)。
要求:1. 必须输出JSON格式,字段名严格为... 2. 如果找不到,输出null。3. 请仔细阅读全文,不要遗漏任何隐含条款。4. 示例:...(3个示例,每个约150字)
实测结果:输入token 12,386,输出token 412,耗时2.1s。字段抽取结果我人工标注了50份合同,F1(字段级精确匹配)为0.82。问题很明显:输出里经常出现“根据上下文,推测付款条款为...”这类废话,而且有一个示例的格式和实际要求不一致,导致模型学歪了。
踩坑:示例不是越多越好。三个示例里有一个是“续约条件为空”的情况,我本意是教它输出null,结果模型反而倾向于输出“未提及”这种非标准值。直接导致所有未找到的字段都变成了“未提及”而不是null,解析端直接报错。
4. V2优化:结构重排+思维链裁剪,token降57%
第二轮我做了三个改动:
1. 把“背景描述”删掉,直接给指令。模型不需要知道“你是一个专家”,它只需要知道任务是什么。
2. 将输出格式从“JSON with explanation”改为“纯JSON,无注释”,并在system层用response_format={'type': 'json_object'}强制约束。
3. 把三个示例缩减为一个,且只保留一个正向示例(有值的),null的情况用一句规则说明替代。
system_prompt_v2 = """
抽取合同中的三个字段,输出JSON,key必须为:
payment_terms, breach_liability, renewal_condition
规则: 若未找到,值为null。不要输出任何额外文字。
示例: {"payment_terms": "30天内支付", "breach_liability": null, "renewal_condition": "自动续约一年"}
"""
同时我在user_content前面加了一行“合同全文:”,这看起来微不足道,但在tiktoken里没有额外开销,却能让模型明确区分指令和正文。实测结果:输入token 5,318(降57%),输出token 189,耗时1.2s,F1提升到0.88。
核心改动:把“请仔细阅读”这种模糊激励去掉,换成“规则”列表——模型对“规则”二字的执行优先级远高于“请”。另外,response_format强制JSON输出后,模型不再生成解释性文字,输出token直接减半。
5. V3终极压缩:利用函数调用压缩指令,token再降65%
虽然V2已经不错,但我发现system_prompt里仍有大量冗余——比如“不要输出任何额外文字”这个约束在response_format下已经隐含了。于是我做了一个更大胆的尝试:用OpenAI的function calling机制,把字段抽取定义为工具调用,让模型在工具参数里返回结构化结果,而不是自由文本。
tools = [{
"type": "function",
"function": {
"name": "extract_contract_fields",
"description": "从合同中提取指定字段",
"parameters": {
"type": "object",
"properties": {
"payment_terms": {"type": ["string", "null"], "description": "付款条款"},
"breach_liability": {"type": ["string", "null"], "description": "违约责任"},
"renewal_condition": {"type": ["string", "null"], "description": "续约条件"}
},
"required": ["payment_terms", "breach_liability", "renewal_condition"]
}
}
}]
response = llm.invoke([
HumanMessage(content="合同全文: " + contract_text)
], tools=tools, tool_choice={"type": "function", "function": {"name": "extract_contract_fields"}})
这个方案的核心是:不再用自然语言描述输出格式,而是用schema定义。模型的工具调用解析器是原生支持的,比任何Prompt约束都可靠。实测输入token 1,842(比V1降85%),输出token 102(纯工具参数),耗时0.7s,F1达到0.91。
为什么有效:函数调用时,模型不需要在自由文本里“回忆”输出格式,它直接生成结构化参数。而且description字段比正文指令更精简,token开销小得多。注意schema里我写了"type": ["string", "null"],这是允许null的标准写法,实测比在Prompt里写“若未找到输出null”更可靠。
6. 踩坑与优化:三个意想不到的陷阱
踩坑1:模型版本差异。同样Prompt在gpt-4o-mini上F1=0.91,换到gpt-3.5-turbo上直接崩到0.70,因为3.5的函数调用对null支持不稳定,经常返回空字符串。所以别拿老模型做基准。
踩坑2:tiktoken计数与计费不一致。我的count_tokens函数只统计了system和user的文本,但实际调用时openai还会加上message类型标记和工具定义,这部分大概每轮多30-50 tokens。如果你的业务量很大,这个误差会被放大,建议用responses.usage字段做精确统计。
踩坑3:示例的“负样本”污染。V1里我放了一个null示例,结果模型学成了“未提及”。后来我把示例彻底删掉,全靠schema的description字段,反而更干净。这说明对于结构化输出,schema的约束力远大于示例模仿。
| 版本 | 输入tokens | 输出tokens | 耗时(s) | F1 |
|---|---|---|---|---|
| V1 | 12,386 | 412 | 2.1 | 0.82 |
| V2 | 5,318 | 189 | 1.2 | 0.88 |
| V3 | 1,842 | 102 | 0.7 | 0.91 |
如果按GPT-4o-mini的定价(输入$0.15/1M,输出$0.60/1M)计算,单次调用成本从0.0021美元降到0.0003美元,降幅86%。对于每天10万次调用的服务,一年能省下约6.5万美元。更重要的是,耗时从2.1s降到0.7s,这意味着可以提升QPS上限。
7. 总结:Prompt不是写作文,是结构化约束
我踩过最大的坑是“把Prompt当成给人类的说明书”。模型不是人,它不会因为你的语气礼貌而更努力,也不会因为你的警告而更谨慎。真正有效的是:减少自由文本指令,用schema和工具调用定义行为边界,用规则列表代替模糊激励。
如果你也想优化自己的Prompt,建议按这个顺序检查:1) 是否能用function calling代替自然语言输出约束?2) 示例是否引入了负样本?3) 有没有多余的背景描述和情感词?4) 强制使用response_format约束输出类型。
最后留一个问题:如果你用JSON mode而不是function calling,token消耗会差多少?我测试过,JSON mode比function calling多出约200 tokens的指令开销(因为要描述JSON结构),但实现更简单。具体取舍取决于你的团队技术栈,欢迎评论区讨论。