1. 问题背景:为什么我需要对Prompt做工程化

上周接了个需求,要从客服对话记录里自动抽取“用户投诉原因”和“期望解决方案”两个字段。第一版我直接用qwen2.5:7b的OpenAI接口,写了个最简单的f"请提取以下对话中的投诉原因和期望方案:{text}"。结果测试集50条,F1只有0.42。而且每条请求平均发送1872个Token——因为我把整个对话历史全塞进去了,包括那些“亲,在吗”之类的废话。

老板说:你自己看着办。于是我开始对Prompt做结构化调优。这篇文章不想讲玄学,只讲我踩过的坑和量化数据。

2. 环境与版本:一套能复现的实验配置

  • 模型:Qwen/Qwen2.5-7B-Instruct-GGUF(q4_k_m量化),通过llama.cppserver模式部署。
  • 推理框架:llama.cpp commit b3f8d2,编译时开启LLAMA_CUBLAS=1(RTX 3090)。
  • 客户端:Python 3.10,requests库直接调/completion端点,没用OpenAI SDK。
  • 关键参数:temperature=0.2(固定),top_p=0.9max_tokens=512repeat_penalty=1.1
  • 测试集:50条人工标注的客服对话(平均长度120字),标签为原因方案两个字段。

所有实验均在同一批数据、同一模型权重下进行,只改Prompt字符串。Token统计用llama.cpp返回的tokens_evaluated字段。

3. 方案设计:7种Prompt模板的迭代路径

我按照“从裸奔到全副武装”的顺序设计了7个版本:

版本 策略 模板结构
V1 零样本裸奔 "提取原因和方案:{text}"
V2 角色扮演 "你是一个专业的客服质检员。请从对话中提取..."
V3 结构化输出约束 加上"输出格式:原因:xxx\n方案:xxx"
V4 添加负面指令 "不要输出无关内容,不要解释过程"
V5 单样本示例 在Prompt里嵌入一条人工标注的完整示例
V6 双样本示例 两条示例,覆盖不同场景
V7 压缩上下文 对原文做关键词截取(只保留含“投诉”“希望”“要求”的句子)

V7是我后来灵机一动加的——既然模型对长上下文会注意力涣散,我干脆在客户端侧先做粗粒度裁剪。

4. 核心实现:代码与实测Token消耗对比

先看V3的代码(我认为这是最基础的“能用”版本):

import requests, json

def run_prompt(text, version):
    if version == "v3":
        prompt = f"""你是一个客服质检员。从以下对话中提取投诉原因和期望解决方案。

对话内容:
{text}

输出格式(严格遵循):
投诉原因:
期望方案:
"""
    # ... 其他版本省略
    payload = {
        "prompt": prompt,
        "temperature": 0.2,
        "top_p": 0.9,
        "max_tokens": 512,
        "repeat_penalty": 1.1,
    }
    resp = requests.post("http://localhost:8080/completion", json=payload).json()
    return resp["content"], resp["tokens_evaluated"]

# 测试一条样本
sample = """用户:你们这个破路由器三天两头断网,我打游戏掉线三次了!客服:您好,非常抱歉。请问您是否尝试过重启?用户:废话,重启有用我还找你?我要求你们尽快上门换新,不然就退款!"""
output, tokens = run_prompt(sample, "v3")
print(f"Token消耗: {tokens}")
print(output)

V3跑出来的结果:Token消耗682,但输出经常是投诉原因:路由器断网——漏了“打游戏掉线”这个更具体的痛点。V4加了负面指令后,输出格式稳定了,但准确率只涨到0.55。

V5-V6的完整代码我就不贴了,核心区别是在prompt里拼入了一条这样的示例(注意我用的是---分隔符):

example = """示例对话:
用户:你们送的货少了一箱,我等着用呢!客服:我查一下,稍等。用户:快点,我下午开会要用。
示例输出:
投诉原因:少发货且响应慢
期望方案:立即补发并道歉
---
实际对话:
{text}
"""

重点来了——V6(双样本)的Token消耗飙升到1180,但准确率到了0.74。我原以为示例越多越好,结果V7直接教我做人。

5. 踩坑与优化:一次上下文截断引发的雪崩

V7的“关键词截取”我用了简单的if "投诉" in line or "要求" in line or "希望" in line去过滤对话行。结果有个测试样本里,用户说完“我要求退款”之后客服回复了20句道歉话术——我的过滤器把这些全留下了,因为每句都有“抱歉”但没触发我的关键词。最后实际截出的文本比原文还长,触发了llama.cppn_ctx限制(我设置的是2048),模型直接返回空内容。

教训:客户端预处理必须考虑“负向过滤”,我后来加了if len(line) < 5: continue以及if "客服" in line and "抱歉" in line: continue

修复后的V7效果惊人:Token消耗从V6的1180降到642,准确率反而到0.89。这说明大模型对“噪声压缩”的受益远超“示例数量”。具体数据见下表:

版本 平均Token消耗 准确率(F1) 单条推理耗时(ms)
V1 1872 0.42 340
V3 682 0.51 120
V5 940 0.68 210
V6 1180 0.74 260
V7(修复后) 642 0.89 90

6. 效果数据与结论

最终我选定了V7模板,并在代码里写死了温度0.2和repeat_penalty 1.1。上线后线上数据(200条抽样)准确率0.85,比测试集略低,原因是线上对话更长、口语更多。

几个值得分享的结论:

  1. 角色设定收益有限:V2比V1提升8%,但V3的结构化输出直接提升到0.51,可见“格式约束”比“身份设定”重要。
  2. 示例数量有边际递减:V5→V6只涨了6个点,但Token涨了25%。如果追求成本,V5可能更划算。
  3. 输入压缩是最大杠杆:V7用简单的关键词过滤砍掉60%输入Token,准确率反升15%。这背后的机制可能是减少注意力分散。
  4. 别迷信max_tokens:我设了512,但V7平均输出只有87个Token。模型学会简洁输出后,max_tokens设128就够了,还能省显存。

最后说一句:Prompt Engineering不是玄学,是工程。建议每个团队都建一个prompt_version.csv,记录模板、参数、指标,像管代码一样管Prompt。就这样。