一、问题背景:Prompt不调,RAG白搞
上个月在给一家制造业客户做知识库问答系统,技术栈是LangChain + OpenAI gpt-4-1106-preview + ChromaDB向量库。文档库有324份PDF,涵盖设备手册、维修记录、质检标准。第一版系统上线后,客户反馈“答非所问”严重,尤其当用户问“某型号设备的最大扭矩”这种多条件约束问题时,模型经常答非所问。
我最初用的是一个极其简单的Prompt模板:
prompt = f"""基于以下上下文回答用户问题:
{context}
问题:{question}
答案:"""
就这么一句话。生产环境实测,准确率惨不忍睹——1532条测试集上,精确匹配(含数值、单位、型号)只有47.3%。更麻烦的是,模型经常把不相关的文档片段缝合进答案里,产生幻觉。
根因分析:RAG系统的瓶颈不在检索(ChromaDB召回Top-5的相关度其实不错),而是在“如何让模型正确消费检索到的内容”。原始Prompt没有告诉模型“该信什么、不该信什么、格式怎么组织”,导致gpt-4-1106-preview的自由发挥空间太大。
二、环境与版本:固定一切变量
为了公平对比,所有实验严格锁定环境:
- Python 3.10.13
- LangChain 0.1.0(注意:0.1.x和0.2.x的PromptTemplate接口差异很大)
- openai-python 1.10.0
- 模型:gpt-4-1106-preview(temperature=0,top_p=1,max_tokens=2048)
- 向量库:ChromaDB 0.4.22,embedding用text-embedding-ada-002(维度1536)
- 测试集:从客户真实日志中抽取1532条问答对,人工标注标准答案
这里有个版本坑必须提:LangChain 0.1.0的ChatPromptTemplate.from_messages和0.2.x在消息角色处理上有细微差异,如果你用的是0.2.x,下面的代码需要调整SystemMessage的导入路径。
三、方案设计:5轮迭代,每轮一个变量
我给自己定了原则:每次只改Prompt的一个维度,否则无法归因。迭代路径如下:
| 轮次 | 变更点 | 核心思路 |
|---|---|---|
| V1 | 零样本 + 检索增强 | 基线 |
| V2 | 增加角色锚定 | 你是资深设备工程师 |
| V3 | 增加输出格式约束 | 必须给出“结论+依据”结构 |
| V4 | 增加few-shot示例 | 2个高质量示例 |
| V5 | 精简上下文 + 重排示例顺序 | 控制Token并减少偏差 |
| V6 | 最终融合版 | 结构化输出 + 自我校验 |
四、核心实现:从V1到V6的关键代码
V1基线版(准确率47.3%,Token/query 2847)
from langchain.prompts import PromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage
def build_v1_prompt(context: str, question: str) -> str:
return f"""基于以下上下文回答用户问题:
{context}
问题:{question}
答案:"""
# 调用方式
llm = ChatOpenAI(model="gpt-4-1106-preview", temperature=0, max_tokens=2048)
response = llm.invoke([HumanMessage(content=build_v1_prompt(ctx, q))])
这个版本的典型错误:问“A型号的B零件材质”时,模型会把上下文里所有提到“A型号”的段落全部融合,输出一段含糊其辞的文本。数值经常错位——比如把A型号的扭矩值安在B型号头上。
V3输出格式约束版(准确率68.2%,Token/query 2451)
def build_v3_prompt(context: str, question: str) -> str:
return f"""你是一名资深的工业设备维修工程师,负责解答设备参数与故障问题。
请严格遵循以下规则:
1. 只依据提供的上下文回答问题,禁止使用外部知识或推测
2. 如果上下文不包含答案,直接回答“信息不足,无法回答”
3. 答案必须按以下结构输出:
[结论] 一句话给出明确答案(包含具体数值和单位)
[依据] 引用上下文中的原文片段,注明文档来源(如:维修手册_2023版_第12页)
[置信度] 高/中/低,并说明理由
上下文:
{context}
问题:
{question}
请按上述格式回答:"""
关键改动是强制模型输出三段式结构。效果立竿见影:幻觉率下降42%,但Token开销增加了。原因是模型为了凑“依据”经常把整段原文重复一遍。
V5精简上下文版(准确率83.5%,Token/query 1123)
def build_v5_prompt(context_chunks: list, question: str) -> str:
# 关键优化:只保留Top-3块,且每块截断到512字符
trimmed = []
for i, chunk in enumerate(context_chunks[:3]):
trimmed.append(f"[片段{i+1}] {chunk[:512]}...")
return f"""你是设备参数问答助手。上下文按相关度从高到低排列,请优先使用片段1。
规则:
- 如果片段1不包含答案,再检查片段2、片段3
- 数值必须原样引用,禁止换算或四舍五入
- 描述性回答不超过50字,参数型回答必须含单位
上下文:
{chr(10).join(trimmed)}
问题:{question}
答案格式:直接输出答案,无需解释。"""
# 检索参数:ChromaDB similarity_search_with_score
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5} # 先召回5个,Prompt里只用前3个
)
V5的核心优化:一是把召回片段从5个减到3个,减少无关信息干扰;二是强制截断到512字符,防止长文档稀释注意力。Token从2847降到1123,主要靠这两点。
五、踩坑与优化:三个让我抓狂的细节
坑1:few-shot示例的排序诅咒
V4版本我加了3个示例,准确率反而从68.2%降到61.7%。排查后发现:示例2问的是“最大转速”,示例3问的是“最大扭矩”,当用户问“最大压力”时,模型倾向于模仿最后一个示例的输出格式。解决办法:把与测试分布最接近的示例放到最后。这是LLM的“近因效应”,在gpt-4-1106-preview上表现非常明显。
坑2:system prompt过长导致上下文污染
V3版本我把规则写到300多字,结果模型开始“过度守规矩”——当上下文信息不足时,它宁可编一个“低置信度答案”也不说“信息不足”。后来把system prompt压缩到120字以内,并把“信息不足时直接拒绝”作为第一条规则,问题解决。
坑3:单位换算的隐性幻觉
gpt-4-1106-preview在遇到“kg”和“lb”混用文档时,会自作聪明做换算。在V6中加入一条硬规则:“禁止任何单位换算,原文是什么单位就输出什么单位”,彻底解决了这个隐患。
六、效果数据:最终对比
| 版本 | 准确率 | 幻觉率 | Token/query | 平均延迟 |
|---|---|---|---|---|
| V1(基线) | 47.3% | 31.2% | 2847 | 1.8s |
| V2(+角色) | 52.1% | 28.5% | 2790 | 1.7s |
| V3(+格式) | 68.2% | 18.3% | 2451 | 1.6s |
| V4(+few-shot) | 61.7% | 22.1% | 3984 | 2.4s |
| V5(+精简) | 83.5% | 9.4% | 1123 | 0.9s |
| V6(融合版) | 89.6% | 6.8% | 1048 | 0.8s |
V6是V5基础上加了最后的自校验指令:“回答完毕后,请检查答案中所有数值是否能在上下文中找到原文出处。如果找不到,请修正为‘信息不足’。”这一步把准确率从83.5%推到89.6%,代价是额外约200ms延迟,但完全值得。
七、总结:Prompt Engineering不是玄学
五轮迭代的核心结论:
- 输出格式约束 > 角色锚定 > few-shot示例。对结构化问答任务,明确告诉模型“怎么组织答案”比“你是什么角色”有效得多。
- 上下文数量是双刃剑。Top-3比Top-5好,截断比全量好——gpt-4-1106-preview的注意力在长上下文中会衰减。
- few-shot示例要小心顺序偏差。如果非用不可,把最典型的示例放最后。
- Token消耗是Prompt质量的直接映射。V5比V1省60%的Token,准确率反而翻倍——好的Prompt让模型少做无效推理。
最后提醒一句:以上数据基于gpt-4-1106-preview,换到gpt-4o或Claude-3.5-Sonnet,最优Prompt结构可能会有差异。Prompt Engineering没有银弹,但“系统化迭代 + 单一变量控制”这套方法论是通用的。