✨ 我是 Muzi 的「文章捕手」,擅长在文字的星海中打捞精华。每当新的篇章诞生,我就会像整理贝壳一样,将思想的闪光点串成珍珠项链~
本文探讨了将Prompt模板从Java代码中外部化管理的方案,解决了硬编码带来的维护困难。借鉴国际化文本管理思路,采用“文本外置 + 变量占位符 + 运行时填充”的模式,实现Prompt模板的资源文件化。介绍了Spring AI中PromptTemplate类的两种使用方式:直接从字符串创建和从资源文件加载,重点讲解了基于资源文件加载的方案及其缓存机制,提升了模板加载效率和调用简洁性。文章还分析了模板中JSON大括号与ST4变量占位符冲突的问题及解决方法,强调了关注点分离的重要性。整体方案具备良好的实用价值和团队协作优势,为AI Agent应用中的Prompt管理提供了生产级解决方案。
2026-06-24🌱上海: ☀️ 🌡️+90°F 🌬️S8mph
# Mu-ai-agent-5
# 前言
在前面的几篇文章中,我们的 prompt 都是直接硬编码在 Java 代码里的—用字符串拼接构造用户消息,或者写一个 static final String 常量当系统提示词。这种做法在小规模项目里没什么问题,但随着 prompt 越来越多、越来越长,弊端就显现出来了:
- 想调一下措辞让模型回答更好?得改 Java 代码、重新编译、重新部署
- 多个方法里都有大段的 prompt 字符串,代码可读性很差
- 想统一修改 prompt 风格?得到处翻、到处改
这个问题其实在 Web 开发中早有成熟的解决方案—把文本从代码中分离出来,放到独立的模板文件中。就像国际化(i18n)把文案放到 .properties 文件里一样,我们也可以把 prompt 放到资源文件中,运行时再加载和填充变量。
这次就来动手实现一套 Prompt 模板外部化方案,顺便把 Spring AI 内置的 PromptTemplate 类摸了一遍。
# Prompt 外置的核心思路
用一个类比来理解:
国际化(i18n):
代码: getMessage("welcome", userName)
文件: messages_zh.properties → welcome=你好,{0}
Prompt 模板:
代码: render("single-question", Map.of("topic", "JVM"))
文件: single-question.txt → 请围绕「{topic}」出一道面试题...
两者的模式完全一样:文本外置 + 变量占位符 + 运行时填充。区别只是 i18n 面向用户界面文案,Prompt 模板面向大模型的输入。
整个方案分三层:
resources/prompts/ ← 模板文件层(纯文本,运营可编辑)
├── single-question.txt
├── question-list.txt
└── ...
PromptTemplateService ← 服务层(加载 + 缓存 + 渲染)
└── render("name", vars)
StructuredOutputApp ← 业务层(调用服务获取 prompt)
└── chatClient.prompt().user(promptText)
# Spring AI 的 PromptTemplate
Spring AI 内置了一个 PromptTemplate 类,底层使用 StringTemplate(ST4)引擎。它支持两种初始化方式:
# 方式 A:从字符串创建
// 模板直接写在 Java 代码中
PromptTemplate pt = new PromptTemplate("请围绕「{topic}」出一道面试题");
String text = pt.render(Map.of("topic", "JVM"));
// 结果: "请围绕「JVM」出一道面试题"
我们之前在 ReReadingAdvisor 里就是这么用的:
// ReReadingAdvisor 中的用法
String augmented = PromptTemplate.builder()
.template("{re2_input_query}\nRead the question again: {re2_input_query}")
.variables(Map.of("re2_input_query", userText))
.build()
.render();
这种方式模板还是硬编码在代码里,并没有解决外部化的问题。
# 方式 B:从 Resource 创建
// 模板存放在资源文件中
PromptTemplate pt = new PromptTemplate(
new ClassPathResource("prompts/single-question.txt"));
// render() 返回纯文本 String
String text = pt.render(Map.of("topic", "JVM"));
// create() 返回 Prompt 对象(可直接传给 ChatClient)
Prompt prompt = pt.create(Map.of("topic", "JVM"));
这个 Resource 构造器是关键—它让 PromptTemplate 可以直接从 classpath 资源文件加载模板,不需要我们自己写文件读取逻辑。
# render() Vs create()
PromptTemplate 有两个核心渲染方法,返回值类型不同:
| 方法 | 返回类型 | 用途 |
|---|---|---|
render(Map) |
String |
拿到纯文本,传给 .user(text) |
create(Map) |
Prompt |
直接传给 chatClient.prompt(prompt) |
create() 内部就是调 render() 拿到文本,再封装成 new Prompt(new UserMessage(text))。两种写法效果一样,看个人习惯。
# 动手实现:两种方案
我实现了两种方案来对比不同的使用风格:
# 方案一:PromptTemplateService(封装层 + 缓存)
创建一个 Spring Bean 封装模板加载和渲染,内部用 ConcurrentHashMap 缓存 PromptTemplate 实例:
@Slf4j
@Component
public class PromptTemplateService {
private static final String TEMPLATE_DIR = "prompts/";
private static final String TEMPLATE_EXT = ".txt";
// 缓存 PromptTemplate 实例,避免重复读文件
private final ConcurrentHashMap<String, PromptTemplate> templateCache
= new ConcurrentHashMap<>();
// 渲染为纯文本
public String render(String templateName, Map<String, Object> variables) {
return getPromptTemplate(templateName).render(variables);
}
// 渲染为 Prompt 对象
public Prompt renderAsPrompt(String templateName, Map<String, Object> variables) {
return getPromptTemplate(templateName).create(variables);
}
// 加载模板原文(用于系统提示词等无变量场景)
public String loadTemplate(String templateName) {
return getPromptTemplate(templateName).getTemplate();
}
private PromptTemplate getPromptTemplate(String templateName) {
return templateCache.computeIfAbsent(templateName, name -> {
String path = TEMPLATE_DIR + name + TEMPLATE_EXT;
ClassPathResource resource = new ClassPathResource(path);
if (!resource.exists()) {
throw new RuntimeException("Prompt 模板文件不存在: classpath:" + path);
}
return new PromptTemplate(resource);
});
}
}
几个设计要点:
- 缓存的是 PromptTemplate 实例,不是原始字符串。因为
PromptTemplate内部持有模板文本,每次只需调render(variables)传入不同变量即可,不需要重新读文件 computeIfAbsent()是原子操作,线程安全。Spring Bean 可能被多线程并发调用,用ConcurrentHashMap而不是普通HashMap- 懒加载—模板首次使用时才从文件读取,未使用的模板不会被加载
使用时非常简洁:
public class StructuredOutputApp {
private final PromptTemplateService promptTemplateService;
public InterviewQuestion getSingleQuestion(String topic) {
// 一行代码:加载模板 + 填充变量 + 拿到文本
String promptText = promptTemplateService.render("single-question",
Map.of("topic", topic));
return chatClient.prompt()
.user(promptText)
.call()
.entity(InterviewQuestion.class);
}
}
# 方案二:直接使用 PromptTemplate(无封装层)
不创建额外的服务类,在每个方法里直接 new PromptTemplate(Resource):
public class PromptTemplateDirectApp {
public InterviewQuestion getSingleQuestion(String topic) {
// ① 从 classpath 资源文件创建 PromptTemplate
PromptTemplate template = new PromptTemplate(
new ClassPathResource("prompts/single-question.txt"));
// ② create() = render() + 封装为 Prompt 对象
Prompt prompt = template.create(Map.of("topic", topic));
// ③ 传给 ChatClient
return chatClient.prompt(prompt)
.call()
.entity(InterviewQuestion.class);
}
public List<InterviewQuestion> getQuestionList(String topic, int count) {
// 也可以用 render() 拿纯文本,再传给 .user()
PromptTemplate template = new PromptTemplate(
new ClassPathResource("prompts/question-list.txt"));
String promptText = template.render(Map.of("topic", topic, "count", count));
return chatClient.prompt()
.user(promptText)
.call()
.entity(new ParameterizedTypeReference<List<InterviewQuestion>>() {});
}
}
# 两种方案对比
| PromptTemplateService | 直接使用 PromptTemplate | |
|---|---|---|
| 额外类 | 需要创建一个 Service | 不需要 |
| 缓存 | 有,模板只加载一次 | 无,每次创建新实例 |
| 调用简洁度 | service.render("name", vars) 一行 |
每次都要 new PromptTemplate(Resource) |
| 适用场景 | 模板多、高频调用 | 模板少、一次性使用 |
实际项目中推荐方案一。模板文件虽然不大,但每次 new PromptTemplate(Resource) 都会做一次 IO 读取,高频场景下有缓存会更高效。
# 模板文件设计
所有模板文件统一放在 src/main/resources/prompts/ 下,使用 .txt 格式:
resources/prompts/
├── interview-system.txt # 系统提示词(无变量)
├── single-question.txt # 单道题 → 变量: {topic}
├── question-list.txt # 题目列表 → 变量: {topic}, {count}
├── question-map.txt # 分类Map → 变量: {categories}
├── study-plan.txt # 复习计划 → 变量: {techDirection}, {focusAreas}
└── question-manual.txt # 手动解析 → 变量: {topic}, {formatInstruction}
以 single-question.txt 为例,内容就一行:
请围绕「{topic}」出一道 Java 后端面试题,包含参考答案、关键要点、难度等级和追问方向
{topic} 就是变量占位符,运行时由 PromptTemplate 替换为实际值。语法和 ReReadingAdvisor 里的 {re2_input_query} 完全一致。
# 系统提示词的外置
InterViewApp 的系统提示词也从硬编码改成了文件加载:
// 改造前 — 硬编码在 Java 中
private static final String SYSTEM_PROMPT = "你是面试助手,专注于 Java 后端...";
chatClient = ChatClient.builder(model)
.defaultSystem(SYSTEM_PROMPT)
.build();
// 改造后 — 从资源文件加载
private static final String SYSTEM_PROMPT_TEMPLATE = "interview-system";
chatClient = ChatClient.builder(model)
.defaultSystem(promptTemplateService.loadTemplate(SYSTEM_PROMPT_TEMPLATE))
.build();
loadTemplate() 调的是 PromptTemplate.getTemplate(),返回模板文件的原始文本,不做任何变量替换。这样修改系统提示词只需要编辑 txt 文件,不用碰 Java 代码。
# 源码分析:PromptTemplate 内部实现
看了 PromptTemplate 的源码,它的核心流程很清晰:
构造阶段:
new PromptTemplate(Resource)
→ 读取 Resource 的 InputStream
→ 将文件内容存为内部字符串 template
渲染阶段:
render(Map variables)
→ 创建 StringTemplate(ST4)引擎实例
→ 将 template 字符串交给 ST4 解析
→ ST4 识别 {key} 占位符,用 variables 中对应的值替换
→ 返回替换后的纯文本 String
create(Map variables)
→ 内部调用 render(variables) 得到文本
→ 封装为 new Prompt(new UserMessage(text))
→ 返回 Prompt 对象
ST4 引擎的变量语法就是 {variableName},和我们在模板文件里写的一致。它比简单的 String.replace() 更规范—比如变量未赋值时会有明确的报错,而不是静默保留占位符。
# 新增/修改文件
src/main/java/com/muzi/muaiagent/
├── service/
│ └── PromptTemplateService.java # 模板加载服务(方案一)
└── app/
├── PromptTemplateDirectApp.java # 直接使用 PromptTemplate(方案二)
├── StructuredOutputApp.java # [修改] 改用 PromptTemplateService
└── InterViewApp.java # [修改] 系统提示词从文件加载
src/main/resources/prompts/
├── interview-system.txt # 系统提示词
├── single-question.txt # 单道题模板
├── question-list.txt # 题目列表模板
├── question-map.txt # 分类Map模板
├── study-plan.txt # 复习计划模板
└── question-manual.txt # 手动解析模板
# 遇到的问题
# 1. JSON 大括号与 ST4 变量占位符冲突
question-manual.txt 的模板中原本把 JSON 格式描述直接写在文件里:
请围绕「{topic}」出一道 Java 后端面试题。
请严格按照以下 JSON 格式返回:
{
"question": "面试问题",
"referenceAnswer": "参考答案"
}
结果运行时报错—ST4 引擎把 JSON 的 { 和 } 当成了变量占位符来解析,试图找 "question" 这个变量名,当然找不到。
解决:把 JSON 格式描述从模板文件中拿出来,定义成 Java 常量,作为 {formatInstruction} 变量传入:
// 模板文件 question-manual.txt — 只保留占位符
请围绕「{topic}」出一道 Java 后端面试题。
{formatInstruction}
// Java 代码 — JSON 格式作为变量值传入
private static final String JSON_FORMAT_INSTRUCTION = """
请严格按照以下 JSON 格式返回:
{
"question": "面试问题",
"referenceAnswer": "参考答案"
}""";
template.render(Map.of("topic", topic, "formatInstruction", JSON_FORMAT_INSTRUCTION));
经验:模板文件中不能出现字面量的 { 和 },除非它们是变量占位符。如果模板内容需要包含大括号(比如 JSON 示例、代码片段),要么把那段内容作为变量值传入,要么用 ST4 的转义语法。这一点和 Thymeleaf、Freemarker 等模板引擎类似。
# 2. PromptTemplate 的 API 在不同版本中有差异
查文档时发现 PromptTemplate 的 API 在不同 Spring AI 版本中有变化。早期的 render() 返回 Prompt,后来的版本改成了返回 String,新增了 create() 返回 Prompt。
解决:以项目中 ReReadingAdvisor 的实际用法为准—render() 返回 String。同时查了 Spring AI 官方 Javadoc 确认当前版本的完整 API:
| 方法 | 返回类型 |
|---|---|
render() |
String |
render(Map) |
String |
create() |
Prompt |
create(Map) |
Prompt |
getTemplate() |
String(返回模板原文) |
经验:Spring AI 还在快速迭代中,遇到 API 对不上文档的情况,最靠谱的办法是直接在 IDE 里看 PromptTemplate.class 的方法列表,或者用 mvn dependency:tree 确认实际引入的版本。
# 心得体会
Prompt 外部化这件事,技术上其实很简单—无非就是把字符串从代码搬到文件里,运行时再读回来。但它的价值不在于代码量的减少,而在于关注点分离:调 prompt 的人不需要懂 Java,改代码的人不需要关心措辞。这在实际团队协作中非常重要,尤其是当产品经理或运营同学也想参与 prompt 调优的时候。
PromptTemplate 的 Resource 构造器是个很贴心的设计,一行代码就能从 classpath 加载模板文件,省去了自己写文件读取的样板代码。配合 ConcurrentHashMap 做个简单的缓存,就是一个够用的生产级方案了。
JSON 大括号的坑挺有意思的。其实所有模板引擎都有类似的问题—模板语法和业务内容冲突。解决思路也都是一样的:把冲突的内容提取为变量。这个技巧在写 Thymeleaf、Freemarker 模板时也经常用到,不算什么新问题。
和之前几篇文章串联起来看:第 2 篇讲了 Advisor 链(ReReadingAdvisor 里第一次用到了 PromptTemplate),第 3 篇讲了结构化输出(entity() 和手动解析),第 4 篇讲了记忆持久化。这篇算是把 prompt 管理这一块补全了。到目前为止,一个 AI Agent 应用的核心组件—模型调用、Advisor 链、结构化输出、记忆管理、Prompt 模板—基本都过了一遍。
下一步打算把这些组件整合成一个完整的面试助手 REST API,配合 Swagger 文档暴露出去,做一个真正可以交互的 demo。
项目地址:mu-ai-agent