一、问题背景: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不是玄学

五轮迭代的核心结论:

  1. 输出格式约束 > 角色锚定 > few-shot示例。对结构化问答任务,明确告诉模型“怎么组织答案”比“你是什么角色”有效得多。
  2. 上下文数量是双刃剑。Top-3比Top-5好,截断比全量好——gpt-4-1106-preview的注意力在长上下文中会衰减。
  3. few-shot示例要小心顺序偏差。如果非用不可,把最典型的示例放最后。
  4. Token消耗是Prompt质量的直接映射。V5比V1省60%的Token,准确率反而翻倍——好的Prompt让模型少做无效推理。

最后提醒一句:以上数据基于gpt-4-1106-preview,换到gpt-4o或Claude-3.5-Sonnet,最优Prompt结构可能会有差异。Prompt Engineering没有银弹,但“系统化迭代 + 单一变量控制”这套方法论是通用的。