检索结果、工具数据、对话历史——最终的Prompt怎么拼
开篇引言
上一篇把 MCP 参数提取器的内部实现拆干净了,MCP 工具调用子系列到此收尾。回头看一下,到第 13 篇为止,前面所有阶段的数据都已经备齐:
- 第 2、3 篇产出了会话记忆——对话摘要加上最近几轮的 history
- 第 4 篇产出了改写后的子问题——指代消解、上下文补全后的检索友好查询
- 第 5~9 篇产出了意图识别结果——命中了哪些 KB 节点、哪些 MCP 节点,每个节点上挂着什么
promptTemplate和promptSnippet - 第 10、11 篇产出了5 条精排后的 KB 检索结果——三个通道 30 条粗排,后处理流水线压到 5 条
- 第 12、13 篇产出了MCP 工具执行结果——一份结构化的销售报告、一条用户年假记录,或者别的什么业务数据
这些数据现在分别在 RetrievalContext 的 kbContext、mcpContext、intentChunks 几个字段里,加上 StreamChatContext 里的 history 和 rewriteResult。但大模型只认一样东西——一个 messages 数组。怎么把这堆零件拼装成大模型能消化的形态,就是本篇要讲的事。
为什么要分场景拼 Prompt
1. 三种原料,三种吃法
直觉上,写一套万能模板似乎就够了——不管什么情况,把检索结果和工具数据一股脑塞进去。但实际跑起来会出问题。
KB 检索结果是文档片段,模型需要被告知严格基于文档回答,不能编造,链接和图片要保持原格式。MCP 工具数据是结构化 JSON,模型需要被告知把字段名转成业务术语,隐私字段要脱敏,空数据要有合理的兜底回复。如果只有 KB 结果,塞一堆 MCP 相关的规则(JSON 转述、隐私脱敏)就是噪音;反过来,只有 MCP 数据时,塞 KB 的块级引用约束也毫无意义。
所以 Ragent 把 Prompt 组装分成了三个场景,每个场景用不同的 System Prompt 模板:
| 场景 | 判定条件 | System Prompt 模板 | 侧重点 |
|---|---|---|---|
KB_ONLY | 只有 KB 检索结果 | answer-chat-kb.st | 文档问答:块级引用、禁止编造、链接图片处理 |
MCP_ONLY | 只 有 MCP 工具数据 | answer-chat-mcp.st | 数据转述:JSON → 自然语言、隐私脱敏、异常处理 |
MIXED | KB 和 MCP 都有 | answer-chat-mcp-kb-mixed.st | 综合回答:实时数据准确性优先于文档、资讯等 |
还有一个特殊情况——所有意图都命中了 SYSTEM 类型节点(打招呼、自我介绍等),这在第 1 篇讲流水线时提过,handleSystemOnly() 会提前短路,压根不走 Prompt 组装这条路。
2. 场景判定的代码
场景判定的逻辑在 RAGPromptService.plan() 方法里,简洁到只有四个 if:
private PromptBuildPlan plan(PromptContext context) {
if (context.hasMcp() && !context.hasKb()) {
return planMcpOnly(context); // 只有 MCP
}
if (!context.hasMcp() && context.hasKb()) {
return planKbOnly(context); // 只有 KB
}
if (context.hasMcp() && context.hasKb()) {
return planMixed(context); // 两者都有
}
throw new IllegalStateException(...); // 不可能走到这里
}
判定依据就是 PromptContext 上的两个方法——hasMcp() 和 hasKb(),各自检查对应的上下文字符串是否非空。到这一步时,检索引擎已经执行完了,结果有没有就是一个字符串是否为空的事。
为什么不会出现两者都为空的情况?因为流水线在
retrieve()之后有一个handleEmptyRetrieval()短路点——如果检索结果全空,直接回复未检索到与问题相关的文档内容就结束了,不会走到 Prompt 组装。
消息数组的骨架
1. 四段式结构
不管哪个场景,最终的 messages 数组都遵循同一个骨架:
[system] 系统提示词(角色定义 + 回答规则)
[...history...] 对话历史(摘要 + 近期多轮对话)
[user] 证据 + 问题(KB 文档 / MCP 数据 + 用户问题,合并为一条 user 消息)
RAGPromptService.buildStructuredMessages() 就是按这个顺序组装的:
public List<ChatMessage> buildStructuredMessages(PromptContext context,
List<ChatMessage> history,
String question,
List<String> subQuestions) {
List<ChatMessage> messages = new ArrayList<>();
// 1. 系统提示词
String systemPrompt = buildSystemPrompt(context);
if (StrUtil.isNotBlank(systemPrompt)) {
messages.add(ChatMessage.system(systemPrompt));
}
// 2. 对话历史(含摘要)
if (CollUtil.isNotEmpty(history)) {
messages.addAll(history);
}
// 3. 证据 + 问题(合并为一条 user message)
String evidenceBody = buildEvidenceBody(context);
String userQuestion = buildUserQuestion(question, subQuestions);
String userContent = mergeEvidenceAndQuestion(evidenceBody, userQuestion);
if (StrUtil.isNotBlank(userContent)) {
messages.add(ChatMessage.user(userContent));
}
return messages;
}
四行核心逻辑,对应四段内容。接下来逐段拆解。
2. 第一段:系统提示词
系统提示词决定了模型以什么角色、遵循什么规则来回答。buildSystemPrompt() 的逻辑分两步:
- 调用
plan()判定场景,得到PromptBuildPlan - 优先使用 Plan 里的
baseTemplate(来自意图节点的promptTemplate),没有就用场景对应的默认模板
public String buildSystemPrompt(PromptContext context) {
PromptBuildPlan plan = plan(context);
String template = StrUtil.isNotBlank(plan.getBaseTemplate())
? plan.getBaseTemplate()
: defaultTemplate(plan.getScene());
return StrUtil.isBlank(template) ? "" : PromptTemplateUtils.cleanupPrompt(template);
}
defaultTemplate() 根据场景返回对应的模板文件:
private String defaultTemplate(PromptScene scene) {
return switch (scene) {
case KB_ONLY -> templateLoader.load(RAG_ENTERPRISE_PROMPT_PATH);
case MCP_ONLY -> templateLoader.load(MCP_ONLY_PROMPT_PATH);
case MIXED -> templateLoader.load(MCP_KB_MIXED_PROMPT_PATH);
case EMPTY -> "";
};
}
三个场景对应三个模板文件,各自有不同的规则侧重。拿 answer-chat-kb.st(KB_ONLY 场景)来说,它定义了:
- 角色:熟悉公司业务的内部助手
- 信息源约束:
<documents>标签内的文字是唯一信息源,没出现的内容不能编造 - 块级引用规则:每个子问题只用对应的
<document>块内容,禁止跨块引用 - 链接和图片处理:链接必须保留完整 URL,图片保持
格式 - 格式规范:简单问题简单答,多子问题用二级标题区分
而 answer-chat-mcp.st(MCP_ONLY 场景)则完全换了一套规则:
- 角色:企业智能数据助手
- 信息源约束:仅基于
<tool-data>标签内的数据回答 - 数据格式化:3 条以上用表格,1~2 条用分点,单一结论直接一句话
- 字段名转述:
create_time→ 创建时间,去技术化 - 隐私合规:默认不输出手机号、身份证号、邮箱、薪酬等敏感信息
answer-chat-mcp-kb-mixed.st(MIXED 场景)则兼顾两者,额外增加了冲突处理规则——当数据和文档对同一事实有不一致时,以数据为准,文档只补充背景和定义。
3. 第二段:对话历史
对话历史直接从 StreamChatContext.history 里取,原封不动地插入 messages 数组。这个 history 是第 2、3 篇讲过的会话记忆的产物——早期对话被压缩成摘要(作为 system 类型消息),最近几轮保留原始的 user/assistant 交替。
把 history 放在系统提示词之后、证据之前,是一个刻意的位置选择。系统提示词定义了规则,history 提供了对话上下文,证据紧贴用户问题——这个顺序让模型在看到证据和问题时,已经建立了角色认知和对话上下文,能更好地理解用户的真实意图。
4. 第三段:证据体
证据体是 KB 检索结果和 MCP 工具数据的合并产物。buildEvidenceBody() 把两种上下文分别用不同的 XML 标签包裹,然后拼在一起:
private String buildEvidenceBody(PromptContext context) {
StringBuilder sb = new StringBuilder();
if (StrUtil.isNotBlank(context.getMcpContext())) {
sb.append(renderSection("mcp-evidence",
Map.of("body", context.getMcpContext().trim())));
}
if (StrUtil.isNotBlank(context.getKbContext())) {
if (!sb.isEmpty()) {
sb.append("\n\n");
}
sb.append(renderSection("kb-evidence",
Map.of("body", context.getKbContext().trim())));
}
return sb.toString().trim();
}
mcp-evidence 和 kb-evidence 是 context-format.st 模板文件里的两个 section,渲染后分别生成 <tool-data>...</tool-data> 和 <documents>...</documents> 标签。模型在 System Prompt 里被告知从这两个标签里取数据,标签名和 System Prompt 中的引用完全对应。
5. 第四段:用户问题
用户问题的拼装有两种情况:
private String buildUserQuestion(String question, List<String> subQuestions) {
if (CollUtil.isNotEmpty(subQuestions) && subQuestions.size() > 1) {
// 多个子问题:编号列表
String numbered = IntStream.range(0, subQuestions.size())
.mapToObj(i -> (i + 1) + ". " + subQuestions.get(i))
.collect(Collectors.joining("\n"));
return renderSection("multi-questions", Map.of("questions", numbered));
}
// 单个问题
return renderSection("single-question", Map.of("question", question));
}
单个问题包在 <question> 标签里,多个子问题包在 <questions> 标签里并带编号。模板定义在 context-format.st:
--- section: single-question ---
<question>{question}</question>
--- section: multi-questions ---
<questions>
{questions}
</questions>
证据体和用户问题最终通过 mergeEvidenceAndQuestion() 合并为一条 user 消息,中间用双换行分隔。
意图节点的 Prompt 注入
1. promptTemplate 和 promptSnippet 的区别
第 5 篇讲意图树时提到过,每个 IntentNode 上可以挂两个可选的 Prompt 配置:
promptTemplate:完整的 System Prompt 模板,直接替换场景默认模板promptSnippet:短规则片段,注入到上下文的<rules>标签中
两者的用途完全不同。promptTemplate 是大锤子——某个业务场景有一套独特的回答规范(比如法务合规场景要求每句话都标注法条来源),直接整体替换 System Prompt。promptSnippet 是小钉子——在默认模板的基础上追加几条针对性的规则(比如退货政策节点要求“价格数据保留两位小数”)。
2. promptTemplate 的生效规则
promptTemplate 只在单意图命中时才有机会生效。看 planPrompt() 方法:
private PromptPlan planPrompt(List<NodeScore> intents,
Map<String, List<RetrievedChunk>> intentChunks) {
// 先剔除未命中检索的意图
List<NodeScore> retained = safeIntents.stream()
.filter(ns -> {
String key = nodeKey(ns.getNode());
List<RetrievedChunk> chunks = intentChunks.get(key);
return CollUtil.isNotEmpty(chunks);
})
.toList();
if (retained.size() == 1) {
IntentNode only = retained.get(0).getNode();
String tpl = StrUtil.emptyIfNull(only.getPromptTemplate()).trim();
if (StrUtil.isNotBlank(tpl)) {
// 单意图 + 有模板 → 用节点模板
return new PromptPlan(retained, tpl);
}
// 单意图 + 无模板 → 走默认模板
return new PromptPlan(retained, null);
}
// 多意图 → 统一默认模板
return new PromptPlan(retained, null);
}
为什么多意图不能用 promptTemplate?因为如果两个意图节点各自定义了不同的 promptTemplate,合并起来可能互相矛盾。与其搞复杂的合并逻辑,不如统一走默认模板,让 promptSnippet 来处理节点级的规则追加。