大模型应用上线实战:从 Prompt 设计、RAG 检索到效果评测的完整落地指南
很多团队做大模型应用,第一阶段都很像:先把 API 接起来,做个聊天框,跑通几个 Demo,感觉“已经差不多了”。但一旦进入真实业务,问题就会突然变多:
- 为什么测试环境回答得挺好,线上一接真实数据就开始“胡说”?
- 为什么 Prompt 改一下,某些场景变好了,另一些场景却退化?
- 为什么 RAG 检索明明召回了文档,模型还是答非所问?
- 为什么团队每周都在调参数,但没人能说清“到底有没有变好”?
我自己做这类系统时,最大的感受是:大模型应用不是单点优化问题,而是一个链路工程问题。Prompt、检索、上下文拼装、模型调用、结果评测、线上观测,这些环节彼此耦合,任何一个点没设计好,都会在上线后放大。
这篇文章我会从架构落地视角,带你完整走一遍:
- 如何把 Prompt 设计成“可维护的系统部件”
- 如何把 RAG 检索做成稳定可扩展的知识增强链路
- 如何建立一套能指导迭代的效果评测体系
- 如何在安全、性能、成本之间做取舍
文章会尽量避免“只讲概念不讲落地”,并给出可运行代码示例。
背景与问题
为什么 Demo 容易,线上难
在 Demo 阶段,我们常常默认几个理想条件:
- 用户问题比较规整
- 知识库数据质量还不错
- 请求量不高,不考虑成本和延迟
- 出错也没关系,人能兜底
而线上完全不同。真实用户会:
- 提问口语化、跳跃、含糊
- 混合多个意图
- 输入错别字、简称、历史版本术语
- 期待答案“既准确又简洁,还要有依据”
如果没有完整的工程设计,大模型应用通常会落入三类问题:
1. Prompt 变成“魔法字符串”
很多项目一开始把 Prompt 写在代码里:
prompt = f"你是一个客服助手,请根据以下内容回答:{context}。问题:{query}"
前期看起来简单,后期就会变成:
- 谁改过 Prompt 不知道
- 哪个版本在线上生效不清楚
- 为什么这次修改影响了另一个场景难以追溯
Prompt 本质上不是文案,而是行为控制配置。
2. RAG 只有“检索”,没有“知识工程”
不少团队把文档切块、向量化、相似度搜索做完,就认为 RAG 完成了。但上线后会发现:
- 召回块不完整,关键定义被切断
- 多文档冲突,模型不会裁决
- 文档过长,拼接后上下文爆炸
- 检索到了“相似内容”,但不是“回答问题所需证据”
RAG 的难点不是向量库本身,而是如何让检索结果成为可用证据。
3. 没有评测,优化靠感觉
团队常说:
- “这个版本感觉更稳了”
- “老板试了两个问题,说还可以”
- “用户反馈少了,可能是变好了”
这都不能算评测。没有离线集、没有指标、没有回归对比,任何优化都可能只是局部偶然。
核心原理
从上线架构看,一个典型的大模型问答系统,可以拆成下面几个核心层次:
flowchart LR
A[用户问题] --> B[输入预处理]
B --> C[意图识别/路由]
C --> D[Prompt 模板选择]
C --> E[RAG 检索]
E --> F[召回重排]
F --> G[上下文构造]
D --> H[LLM 推理]
G --> H
H --> I[后处理/结构化输出]
I --> J[日志与评测]
J --> K[迭代优化]
这个链路里,最关键的是三个问题:
- 模型该怎么被约束? —— Prompt
- 模型该看什么证据? —— RAG
- 系统到底有没有变好? —— 评测
下面逐个展开。
一、Prompt 设计:把“会说话”变成“可控输出”
Prompt 设计的核心,不是把提示词写得花哨,而是让模型在目标任务上表现稳定。比较实用的结构一般包含这几部分:
- 角色定义:你是谁,负责什么,不负责什么
- 任务目标:你要完成什么输出
- 约束规则:不能编造、引用依据、输出格式
- 上下文注入:知识片段、历史对话、用户画像
- 示例 Few-shot:提供输入输出样例
- 失败策略:不知道就说不知道,需要追问就追问
一个可维护的 Prompt 模板,大致像这样:
你是企业知识助手,负责根据提供的资料回答问题。
规则:
1. 只能根据“参考资料”回答,不允许编造。
2. 如果参考资料不足以回答,明确说明“资料不足”。
3. 回答时优先给结论,再给依据。
4. 输出格式为 JSON。
参考资料:
{context}
用户问题:
{query}
Prompt 设计的几个工程原则
原则 1:Prompt 要模块化
不要把系统提示、格式要求、领域规则、Few-shot 全部揉成一坨。建议拆成:
- system prompt
- task prompt
- output schema
- domain rules
- examples
这样版本管理和 AB 实验都更容易做。
原则 2:先定义失败行为
很多线上事故并不是“回答不够好”,而是“明明不知道还瞎答”。
所以比起提升上限,先控制下限更重要。
例如:
- 资料不足时必须拒答
- 发现问题歧义时先追问
- 高风险领域必须给出处
原则 3:结构化输出优先
如果后续还要接业务系统,尽量让模型输出 JSON 或者固定字段。自然语言很适合给人看,但不适合被程序稳定消费。
二、RAG 检索:让模型“带着证据说话”
RAG(Retrieval-Augmented Generation)不是为了让模型知道更多,而是为了让它在回答时依赖外部知识,降低幻觉并引入可更新知识。
一个最常见的 RAG 流程如下:
sequenceDiagram
participant U as 用户
participant S as 应用服务
participant R as 检索系统
participant L as 大模型
U->>S: 提问
S->>R: query embedding / keyword search
R-->>S: top-k 文档片段
S->>S: 重排 + 上下文拼装
S->>L: Prompt + 检索证据
L-->>S: 生成答案
S-->>U: 返回结果 + 引用依据
RAG 做得好不好,通常不止看“有没有搜到”,而是看下面四层。
1. 文档治理
这是很多项目最容易忽略的一层。原始文档如果混乱,后面做再多检索优化都很吃力。
建议至少处理:
- 去除页眉页脚、重复段落
- 保留标题层级
- 给文档加元数据:来源、版本、生效时间、部门、权限等级
- 标记失效内容和冲突内容
如果知识库没有版本信息,模型答错时你甚至不知道是 Prompt 问题还是文档过期问题。
2. 切块策略
切块不是越小越好,也不是越大越好。
- 太小:语义不完整,召回后无法回答
- 太大:噪声太多,浪费上下文窗口
经验上可以从以下策略开始:
- 按标题层级切分
- 段落窗口切块(例如 300~800 中文字)
- 使用 overlap 保留上下文连续性
- 对 FAQ、表格、流程说明分别采用不同切块方式
如果是规章制度类文档,我更倾向于“标题 + 段落块”;
如果是 API 文档,往往要把“参数说明 + 示例”打在一个块里,不然召回出来没法用。
3. 检索与重排
单纯向量检索并不总是最优。实践里常见的是混合方案:
- BM25 / 关键词检索:适合专有名词、版本号、错误码
- 向量检索:适合语义相近表达
- 重排模型:把候选集按“是否真正回答问题”重新排序
一个稳妥的组合通常是:
- 关键词召回一批
- 向量召回一批
- 合并去重
- 用 reranker 重排
- 取 top-n 拼上下文
4. 上下文构造
很多回答差,不是因为没检索到,而是因为把证据喂给模型的方式有问题。
常见错误包括:
- 直接把 top-k 原文暴力拼接
- 没有标注来源
- 没有按主题分组
- 多段内容互相冲突时不提示模型裁决规则
我比较推荐的上下文格式是:
[资料1]
来源:员工手册 v3.2
标题:请假制度
内容:...
[资料2]
来源:HR FAQ 2024-01
标题:年假折算规则
内容:...
这样模型更容易引用,也更容易做追踪。
三、效果评测:没有指标,就没有迭代
大模型应用上线后,最怕两种情况:
- 改了很多,但不知道是否真的提升
- 指标看起来提升了,但用户体验变差
所以评测一定要分层。
flowchart TD
A[评测集构建] --> B[检索评测]
A --> C[生成评测]
A --> D[业务评测]
B --> E[Recall@K / MRR / NDCG]
C --> F[正确性/完整性/引用一致性]
D --> G[转化率/解决率/人工转接率]
1. 检索评测
先回答一个问题:检索系统有没有把该找的内容找出来?
常用指标:
- Recall@K:前 K 个结果里是否包含正确证据
- MRR:正确结果排名是否靠前
- NDCG:综合考虑排序质量
如果 Recall@5 都很差,那先别急着调 Prompt,问题可能根本不在模型。
2. 生成评测
常见评测维度包括:
- 正确性:答案是否符合事实
- 完整性:是否漏掉关键条件
- 忠实性:是否超出证据乱编
- 格式合规:是否符合结构化要求
- 可读性:是否清晰易用
生成评测可以用两种方式结合:
- 人工标注:小规模高质量
- LLM-as-a-Judge:大规模低成本初筛
但要注意,LLM 评测本身也会有偏差,尤其在细粒度事实判断上,不能完全替代人工。
3. 业务评测
真正上线后,最终还是要回到业务指标:
- 首次解决率
- 用户追问率
- 转人工率
- 平均响应时延
- 单次请求成本
- 失败率 / 降级率
很多项目只盯模型分数,最后发现业务价值没提升。因为用户并不关心你用了多强的模型,他只关心“问题有没有解决”。
方案对比与取舍分析
上线时常见的几种技术路线,我建议这样理解。
路线 1:只做 Prompt,不做 RAG
适用场景
- 任务以改写、摘要、分类、抽取为主
- 不依赖实时知识
- 领域知识已经在模型能力范围内
优点
- 系统简单
- 延迟低
- 成本可控
缺点
- 容易幻觉
- 无法引入私有知识
- 难做依据追溯
路线 2:Prompt + 基础 RAG
适用场景
- 企业知识问答
- 客服助手
- 文档问答
- 内部流程咨询
优点
- 可引入外部知识
- 幻觉显著降低
- 知识更新不依赖重新训练
缺点
- 文档治理成本高
- 检索链路复杂
- 评测难度提升
路线 3:Prompt + RAG + 重排 + 评测闭环
这通常是我更推荐的“上线版本”形态。
优点
- 稳定性更高
- 可持续迭代
- 能进行回归验证
缺点
- 工程复杂度上升
- 需要更多数据治理和平台能力
- 团队要具备观察和调参能力
一句话总结:
如果只是做展示,Prompt 足够;如果要上线服务真实用户,至少要有 RAG;如果要长期运营,必须加评测闭环。
容量估算与架构思路
在中等规模场景里,可以先按下面思路估算:
1. 请求链路耗时拆分
假设一次请求总耗时目标是 3 秒内:
- 输入预处理:50~100ms
- 检索召回:100~300ms
- 重排:100~400ms
- LLM 推理:1000~2500ms
- 后处理与日志:50~100ms
一般最大头还是模型推理,所以优化优先级通常是:
- 控制上下文长度
- 控制输出长度
- 减少不必要的多轮调用
- 对热点问题做缓存
2. 成本估算
成本主要受三项影响:
- 输入 token 数
- 输出 token 数
- 调用次数
一个很常见但容易忽略的问题是:
RAG 的成本,不只是检索成本,更是“把召回内容塞进上下文”的 token 成本。
所以检索不是越多越好,而是要“以足够少的上下文,提供足够强的证据”。
实战代码(可运行)
下面用一个简化版 Python 示例,演示一个最小可运行的 RAG 问答服务。
为了便于本地运行,这里不依赖真实向量库,而是用 TfidfVectorizer 模拟检索,用可替换接口模拟 LLM 调用。
安装依赖:
pip install flask scikit-learn numpy
1. 准备一个最小服务
from flask import Flask, request, jsonify
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import json
app = Flask(__name__)
documents = [
{
"id": "doc1",
"source": "员工手册 v3.2",
"title": "请假制度",
"content": "员工请病假需要提交医院证明,连续请假超过3天需部门负责人审批。年假需至少提前2个工作日申请。"
},
{
"id": "doc2",
"source": "HR FAQ 2024-01",
"title": "年假折算规则",
"content": "当年入职员工的年假按剩余自然月折算,不足一个月的部分不计入。离职时未休年假按公司制度折算。"
},
{
"id": "doc3",
"source": "报销制度 v2.1",
"title": "差旅报销",
"content": "差旅报销需在出差结束后15个自然日内提交,发票抬头必须为公司全称,超过时限需说明原因。"
}
]
corpus = [f"{d['title']} {d['content']}" for d in documents]
vectorizer = TfidfVectorizer()
doc_matrix = vectorizer.fit_transform(corpus)
def retrieve(query, top_k=2):
query_vec = vectorizer.transform([query])
sims = cosine_similarity(query_vec, doc_matrix)[0]
scored = sorted(enumerate(sims), key=lambda x: x[1], reverse=True)[:top_k]
results = []
for idx, score in scored:
doc = documents[idx]
results.append({
"id": doc["id"],
"source": doc["source"],
"title": doc["title"],
"content": doc["content"],
"score": float(score)
})
return results
def build_prompt(query, contexts):
context_text = "\n\n".join([
f"[资料{i+1}]\n来源:{c['source']}\n标题:{c['title']}\n内容:{c['content']}"
for i, c in enumerate(contexts)
])
prompt = f"""
你是企业知识助手,请根据参考资料回答用户问题。
规则:
1. 只能依据参考资料回答,不允许编造。
2. 如果资料不足,请明确说明“资料不足,无法准确回答”。
3. 先给简短结论,再给依据。
4. 输出 JSON,字段包括:
- answer: 最终回答
- citations: 引用资料标题数组
参考资料:
{context_text}
用户问题:
{query}
"""
return prompt.strip()
def mock_llm(prompt, contexts, query):
# 用简单规则模拟模型输出,方便本地运行验证
joined = " ".join([c["content"] for c in contexts])
if "病假" in query:
if "医院证明" in joined:
result = {
"answer": "病假需要提交医院证明;如果连续请假超过3天,还需要部门负责人审批。",
"citations": [c["title"] for c in contexts if "请假" in c["title"]]
}
else:
result = {
"answer": "资料不足,无法准确回答。",
"citations": []
}
elif "年假" in query and "入职" in query:
if "折算" in joined:
result = {
"answer": "当年入职员工的年假按剩余自然月折算,不足一个月的部分不计入。",
"citations": [c["title"] for c in contexts if "年假" in c["title"]]
}
else:
result = {
"answer": "资料不足,无法准确回答。",
"citations": []
}
else:
result = {
"answer": "资料不足,无法准确回答。",
"citations": []
}
return result
@app.route("/ask", methods=["POST"])
def ask():
data = request.get_json()
query = data.get("query", "").strip()
if not query:
return jsonify({"error": "query is required"}), 400
contexts = retrieve(query, top_k=2)
prompt = build_prompt(query, contexts)
llm_output = mock_llm(prompt, contexts, query)
return jsonify({
"query": query,
"retrieved_contexts": contexts,
"prompt": prompt,
"result": llm_output
})
if __name__ == "__main__":
app.run(debug=True, port=5001)
运行服务:
python app.py
测试请求:
curl -X POST http://127.0.0.1:5001/ask \
-H "Content-Type: application/json" \
-d '{"query":"病假需要什么材料?"}'
你会得到类似结果:
{
"query": "病假需要什么材料?",
"retrieved_contexts": [
{
"id": "doc1",
"source": "员工手册 v3.2",
"title": "请假制度",
"content": "员工请病假需要提交医院证明,连续请假超过3天需部门负责人审批。年假需至少提前2个工作日申请。",
"score": 0.42
},
{
"id": "doc2",
"source": "HR FAQ 2024-01",
"title": "年假折算规则",
"content": "当年入职员工的年假按剩余自然月折算,不足一个月的部分不计入。离职时未休年假按公司制度折算。",
"score": 0.0
}
],
"prompt": "...",
"result": {
"answer": "病假需要提交医院证明;如果连续请假超过3天,还需要部门负责人审批。",
"citations": [
"请假制度"
]
}
}
2. 加一个最小评测脚本
下面这个脚本用于验证“回答是否包含期望关键词”,虽然简化,但足以体现评测闭环思路。
import requests
testset = [
{
"query": "病假需要什么材料?",
"expected_keywords": ["医院证明"]
},
{
"query": "当年入职员工年假怎么算?",
"expected_keywords": ["剩余自然月", "不足一个月"]
},
{
"query": "报销最晚什么时候提交?",
"expected_keywords": ["15个自然日"]
}
]
passed = 0
for item in testset:
resp = requests.post(
"http://127.0.0.1:5001/ask",
json={"query": item["query"]},
timeout=10
)
data = resp.json()
answer = data["result"]["answer"]
ok = all(k in answer for k in item["expected_keywords"])
print(f"Q: {item['query']}")
print(f"A: {answer}")
print(f"PASS: {ok}")
print("-" * 50)
if ok:
passed += 1
print(f"Score: {passed}/{len(testset)}")
这个评测方式当然不够完整,但它有一个好处:
先让团队形成“改动后必须回归”的习惯。
很多团队不是不会做复杂评测,而是连最基础的回归集都没有。
常见坑与排查
下面这些坑,我基本都踩过,而且它们往往比“模型不够强”更常见。
1. 召回结果看着像对的,实际答不上来
现象
- top-k 结果语义相关
- 但答案总缺关键条件
原因
- 文档切块过碎
- 只召回定义,没有召回限制条件
- 重排没按“可回答性”排序
排查方法
- 打印每次 query 的召回块
- 人工检查是否“仅相关,但不充分”
- 观察正确证据是否排在前 5 名之外
建议
- 调大 chunk size 或增加 overlap
- 引入 reranker
- 将“定义 + 约束 + 示例”打包切块
2. Prompt 越写越长,效果反而变差
现象
- 规则写了十几条
- 模型还是不稳定
- 延迟和成本上升明显
原因
- 指令冲突
- 核心约束被埋没
- 上下文过长造成注意力稀释
排查方法
- 精简成最小 Prompt 做 AB 对比
- 检查是否有重复或冲突要求
- 观察模型是否忽略后置规则
建议
- 保留最关键的 3~5 条规则
- 复杂要求放到结构化后处理或业务逻辑里
- Prompt 模板做版本化管理
3. 明明有资料,模型却说“资料不足”
现象
- 检索结果里有答案
- 模型仍然拒答
原因
- 上下文格式不清晰
- 召回片段太多,正确证据被淹没
- Prompt 中拒答约束过强
排查方法
- 缩减上下文到 top-1 / top-2 测试
- 给每段资料增加编号与标题
- 对比不同 Prompt 的拒答率
建议
- 先保证 evidence clarity,再调模型
- 给“高置信命中”更明显的展示格式
- 将拒答策略从“绝对保守”调整为“有依据即可回答”
4. 离线评测很好,线上反馈很差
现象
- 回归集分数稳定
- 用户仍然频繁追问或转人工
原因
- 评测集不覆盖真实问题分布
- 测试样本过于标准化
- 没有覆盖模糊提问、错别字、跨意图问题
排查方法
- 回放线上日志
- 把失败问题加入评测集
- 区分“知识错误”和“交互错误”
建议
- 评测集每周增量更新
- 单独维护“线上高频失败集”
- 指标同时看质量、时延、业务转化
安全/性能最佳实践
大模型上线,不只是“能回答”,还要“回答得安全、跑得动”。
一、安全最佳实践
1. 注入攻击防护
用户输入可能包含:
- “忽略你之前的规则”
- “输出系统提示词”
- “不要参考资料,直接自由回答”
对于这类注入,需要多层防护:
- 系统提示词明确优先级
- 输入做敏感模式检测
- 高风险请求直接拒答或降级
- 不把内部 Prompt 原样暴露给前端
2. 权限隔离
如果知识库有权限边界,比如财务、人事、法务文档,不能仅靠检索后再过滤。
更稳妥的做法是:
- 检索前按用户身份过滤可访问文档
- 元数据里带权限标签
- 日志中记录命中文档来源,便于审计
3. 敏感信息脱敏
对话和日志里经常会出现:
- 手机号
- 身份证号
- 合同编号
- 客户隐私信息
建议在日志落库前做脱敏处理。
一个简单示例:
import re
def mask_sensitive(text: str) -> str:
text = re.sub(r'1[3-9]\d{9}', '***手机号***', text)
text = re.sub(r'\b\d{17}[\dXx]\b', '***身份证号***', text)
return text
print(mask_sensitive("用户手机号是13812345678,身份证号是110101199003071234"))
二、性能最佳实践
1. 缓存热点问题
对于高频且答案稳定的问题,可以缓存:
- 检索结果缓存
- 最终回答缓存
- 向量化结果缓存
但要注意知识版本更新时失效。
2. 控制上下文长度
这是优化成本和时延最有效的方法之一。
可采用:
- top-k 动态裁剪
- 长文摘要后再注入
- 按问题类型选不同上下文模板
3. 分层降级
当外部依赖异常或成本超限时,不要直接全站失败。可以设计:
- 优先走小模型
- 跳过重排
- 只返回检索结果摘要
- 返回人工兜底入口
下面是一个简化的服务状态图:
stateDiagram-v2
[*] --> Normal
Normal --> Degraded: 模型超时/成本超限
Degraded --> RetrievalOnly: LLM 不可用
RetrievalOnly --> ManualFallback: 检索失败或高风险问题
Degraded --> Normal: 服务恢复
RetrievalOnly --> Normal: 服务恢复
ManualFallback --> Normal: 人工或服务恢复
4. 结构化日志必须有
至少记录这些字段:
- request_id
- user_query
- normalized_query
- retrieved_doc_ids
- prompt_version
- model_name
- latency_ms
- input_tokens / output_tokens
- final_answer
- fallback_reason
没有日志,问题复盘基本靠猜。
一个更适合上线的最小架构建议
如果你现在正准备做第一版上线系统,我建议从下面这个“够用但不臃肿”的架构起步:
-
知识入库层
- 文档清洗
- 切块
- 元数据打标
- 索引构建
-
在线服务层
- 输入预处理
- 混合检索
- 轻量重排
- Prompt 组装
- LLM 调用
- 输出校验
-
观测评测层
- 日志采集
- 离线评测集
- 回归测试
- 线上指标看板
这类系统一开始不一定要最先进,但一定要可观测、可回滚、可迭代。
总结
把大模型应用真正上线,我认为最重要的认知转变是:
不要把它当成一个“模型调用问题”,而要把它当成一个“知识与决策链路工程问题”。
落地时可以抓住这三条主线:
-
Prompt 负责约束模型行为
- 角色、目标、规则、失败策略都要明确
- Prompt 要版本化、模块化、可回归
-
RAG 负责提供可用证据
- 重点不是“搜到了”,而是“证据能支撑回答”
- 文档治理、切块、重排、上下文构造同样重要
-
评测负责驱动持续优化
- 先做最小回归集,再逐步扩展
- 检索、生成、业务三层指标都要看
- 离线指标好,不代表线上就一定好
如果你现在要启动一个中级复杂度的大模型应用,我给的可执行建议是:
-
第一阶段:先做一个最小可用链路
Prompt 模板化 + 基础 RAG + 简单回归集 -
第二阶段:补齐稳定性
混合检索 + 重排 + 结构化输出 + 日志观测 -
第三阶段:进入运营优化
线上失败样本回流 + 自动评测 + 成本与时延治理
边界条件也要说清楚:
如果你的业务是高风险领域,比如医疗、法律、金融合规,那么仅靠 Prompt 和 RAG 还不够,必须增加更严格的人工审核、规则校验和权限控制。大模型可以提效,但不应该直接替代最终责任链路。
最后一句比较实在:
先把链路跑稳,再追求“更聪明”。
很多项目不是败在模型能力不够,而是败在工程基本功没打牢。