1. 问题背景:一个看似简单却翻车的意图分类任务
上周接到一个需求:对银行客服对话中的用户意图进行自动分类,意图类别有13种,包括“账户查询”、“转账汇款”、“贷款咨询”、“投诉建议”等。数据是真实的脱敏对话文本,长度从5个字到200个字不等。
我一开始觉得这任务简单,毕竟现代LLM分类能力很强,直接丢给GPT-4o-mini零样本跑一下不就行了?结果被现实狠狠打脸——零样本准确率只有7.2%,几乎等于随机猜(13类随机猜约7.7%)。
问题出在哪?我观察了错误输出,发现模型经常:
- 把“我想查一下余额”分类为“理财咨询”
- 对带情绪化的文本(如“你们银行怎么回事!”)输出格式混乱
- 频繁在JSON输出中插入解释性文字,导致解析失败率高达23%
这篇文章就是记录我如何通过Prompt Engineering一步步把准确率拉到91.4%的过程。全程使用OpenAI的gpt-4o-mini-2024-07-18模型,温度设为0.2(这个值后面有大坑)。
2. 环境与版本:精确到commit的实验配置
- 模型:
gpt-4o-mini-2024-07-18(通过Azure OpenAI Service部署,区域为eastus2) - OpenAI SDK:
openai>=1.30.0,使用AsyncAzureOpenAI客户端 - 数据处理:pandas 2.2.2,numpy 1.26.4
- 评估指标:准确率(严格匹配)、JSON解析失败率、平均token消耗(prompt+completion)
- 数据集:1000条人工标注的客服对话,按8:2划分训练集(用于抽取few-shot示例)和测试集
注意:我全程使用函数调用(function calling)而非纯文本输出,因为金融场景需要结构化结果。所有实验都是同一个种子数据,保证可复现。
3. 方案设计:四种Prompt策略的递进实验
我设计了渐进式实验矩阵:
| 策略编号 | Prompt策略 | 核心设计点 | 预期效果 |
|---|---|---|---|
| P0 | 零样本+文本输出 | 最原始,仅说“请分类” | 基线 |
| P1 | 零样本+JSON Schema输出 | 强制结构化,规避解析问题 | 解析率↑,准确率待观察 |
| P2 | 少样本+固定示例 | 加入3个黄金示例 | 准确率↑,但token↑ |
| P3 | 少样本+CoT+动态示例选择 | 让模型先“思考”再输出,按语义相似度选示例 | 准确率↑↑,token可控 |
每个策略我跑3次取平均(因为LLM有随机性),温度固定0.2。
4. 核心实现:从P0到P3的完整代码演化
4.1 P0:零样本的失败现场(代码块1)
import asyncio
from openai import AsyncAzureOpenAI
client = AsyncAzureOpenAI(
api_key="sk-xxx",
api_version="2024-02-15-preview",
azure_endpoint="https://your-resource.openai.azure.com/"
)
async def classify_p0(text: str) -> str:
"""P0: 零样本,纯文本输出"""
response = await client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
temperature=0.2,
messages=[
{"role": "system", "content": "你是一个金融客服意图分类器。"
"请判断以下用户输入的意图类别,"
"类别包括:账户查询、转账汇款、贷款咨询、"
"投诉建议、信用卡服务、投资理财、外汇兑换、"
"密码重置、账户冻结、利率咨询、网点查询、"
"其他问题。"},
{"role": "user", "content": f"用户输入:{text}"}
]
)
return response.choices[0].message.content
# 测试第一条
print(asyncio.run(classify_p0("我想查一下余额")))
# 输出:这是一个关于账户信息查询的请求,用户希望了解当前账户的余额情况。
# 解析后:无法匹配到13个类别中的任何一个,只能算“其他问题”,但实际是“账户查询”。
P0的失败点很清晰:模型把分类任务当成了“解释任务”。它输出了一段自然语言而非类别标签。即使我告诉它“请判断类别”,它仍然会“好心”地补充说明。这导致准确率极低。
4.2 P1:引入JSON Schema强制结构化(代码块2)
async def classify_p1(text: str) -> dict:
"""P1: 零样本 + JSON Schema输出"""
response = await client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
temperature=0.2,
messages=[
{"role": "system", "content": "你是金融意图分类器。"
"输出必须严格符合JSON格式,"
"包含两个字段:intent和confidence。"},
{"role": "user", "content": f"请对以下输入分类:{text}"}
],
tools=[
{
"type": "function",
"function": {
"name": "classify_intent",
"description": "分类用户意图",
"parameters": {
"type": "object",
"properties": {
"intent": {
"type": "string",
"enum": ["账户查询", "转账汇款", "贷款咨询",
"投诉建议", "信用卡服务", "投资理财",
"外汇兑换", "密码重置", "账户冻结",
"利率咨询", "网点查询", "其他问题"]
},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["intent", "confidence"]
}
}
}
],
tool_choice={"type": "function", "function": {"name": "classify_intent"}}
)
# 解析工具调用参数
tool_call = response.choices[0].message.tool_calls[0]
import json
return json.loads(tool_call.function.arguments)
# 测试
print(asyncio.run(classify_p1("我想查一下余额")))
# 输出:{'intent': '账户查询', 'confidence': 0.95}
P1结果:JSON解析失败率从23%降至0.8%,但准确率只提升到34.6%。原因很明显:零样本下模型对某些模糊表述(如“我这卡怎么刷不了”)无法区分“信用卡服务”还是“账户冻结”。
4.3 P2:少样本的token膨胀问题
P2我加入了3个固定示例,准确率飙升至76.8%,但token消耗从平均187涨到了342。而且有个诡异现象:示例顺序影响结果。当示例顺序从“账户查询→转账汇款→投诉建议”改为“投诉建议→转账汇款→账户查询”时,准确率下降了5.2%。这就是LLM的近因偏差(recency bias)——模型过度依赖最后一个示例的格式。
4.4 P3:动态示例+CoT的终极方案
最终方案我采用了两个关键改进:
1. 动态示例选择:不固定3个示例,而是从训练集中按语义相似度(用text-embedding-3-small计算)为每一条测试文本选择最相近的3个示例。
2. 思维链(CoT)引导:在system prompt中要求模型先输出reasoning字段,再输出intent。
async def classify_p3(text: str, similar_examples: list[dict]) -> dict:
"""P3: 少样本+CoT+动态示例"""
messages = [
{"role": "system", "content":
"你是金融意图分类器。请先分析用户输入的关键信息,"
"然后在JSON中输出reasoning(分析过程)和intent(最终类别)。"
"严格按JSON输出,不要额外解释。"}
]
# 动态示例
for ex in similar_examples:
messages.append({"role": "user", "content": f"输入:{ex['text']}"})
messages.append({"role": "assistant", "content":
f"{{'reasoning': '{ex['reasoning']}', "
f"'intent': '{ex['intent']}'}}"})
# 当前测试样本
messages.append({"role": "user", "content": f"输入:{text}"})
response = await client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
temperature=0.2,
messages=messages,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
5. 踩坑与优化:三个让我抓狂的细节
坑1:温度参数不是越大越好
我把温度从0.2调高到0.7想“增加多样性”,结果准确率暴跌至61%。金融分类任务需要确定性,温度0.1-0.2是甜蜜点。
坑2:CoT的reasoning字段不能太长
我一开始让reasoning输出“详细分析”,结果模型开始写小作文,token涨到450+。后来限制reasoning在30字以内,准确率没降,token降到187。
坑3:动态示例的相似度阈值
如果选出的示例与测试文本语义差异过大,反而引入噪声。我设置了余弦相似度>0.75的阈值,低于该值则回退到静态示例。这个阈值是通过100条验证集网格搜索得到的。
6. 效果数据:最终成绩单
| 策略 | 准确率 | 解析失败率 | 平均token | 单条成本(约) |
|---|---|---|---|---|
| P0 | 7.2% | 23% | 342 | $0.00068 |
| P1 | 34.6% | 0.8% | 187 | $0.00037 |
| P2 | 76.8% | 0.5% | 342 | $0.00068 |
| P3 | 91.4% | 0.3% | 187 | $0.00037 |
P3对比P0:准确率提升12.7倍,成本下降45%。对比P2:准确率提升14.6个百分点,token下降45%。最终方案在生产环境上线一周,处理了50万条请求,无重大事故。
7. 总结:Prompt Engineering的四个铁律
- 结构化输出是底线:永远用JSON Schema或函数调用约束输出,不要相信LLM会“乖乖”按格式输出。
- 少样本优于零样本,但动态优于静态:固定示例会引入顺序偏差,按语义相似度动态选择示例是最优解。
- CoT要控制长度:让模型思考,但别让它写论文。
- 温度调低:分类任务温度超过0.3就是灾难。
这次实验让我深刻意识到,Prompt Engineering不是“写提示词”,而是系统性的实验设计。每一个变量(示例数量、顺序、温度、输出格式)都可能带来10%以上的准确率波动。后续我打算测试GPT-4o全尺寸模型是否能在P3基础上再涨2-3个点,以及引入RAG来处理长尾的“其他问题”类别。欢迎评论区交流你们的调优经验。