Appearance
PromptTemplate 与外部化:把提示词当代码管起来
1. 本节产出
一套提示词管理体系:所有 prompt 放在 resources/prompts/*.st,支持变量占位、多语言/多场景切换、改动后无需重新打包(本地开发热生效),并且每个模板有版本标识,出问题能立刻回滚。
2. 前置依赖
- 01-02 第一个 ChatClient:已有 ChatClient Bean
- 01-08 结构化输出:已用过
.param()传变量
3. 为什么提示词不能写在 Java 字符串里
java
// 反面教材:全班都这么写过
String prompt = "你是一个助手,请回答用户问题:" + question
+ ",要求回答简洁,不超过200字,不要使用markdown...";四个必然会出现的问题:
| 问题 | 后果 |
|---|---|
| 改一个字要重新编译部署 | 调 prompt 是高频动作,每次发版不可接受 |
| 无法做版本对比 | 出问题不知道是哪次改动引入的 |
| 无法复用 | 同样的角色设定在五个地方各写一遍,改的时候漏掉两处 |
| 字符串拼接即注入入口 | 用户输入里的 { } 或特殊字符会破坏模板 |
核心判断:提示词是配置,不是代码。它应该和 SQL、yml 一样被外置、被版本化、可被非开发人员(产品/运营)评审。把它硬编码进 Java 类,等于把配置文件写死在代码里——这在任何技术栈里都是坏味道。
4. 核心原理
4.1 模板引擎的选择
Spring AI 默认用 StringTemplate(.st) 语法:
你是一个{role},请以{tone}的语气回答。
{context}
用户问题:{question}为什么不用 String.format 或占位符替换:模板引擎会处理转义,避免用户输入中的特殊字符破坏结构。手写 String.format 时用户输入里带 % 会直接抛异常——这是真实会发生的线上事故。
4.2 三种加载方式对比
| 方式 | 做法 | 优点 | 适用 |
|---|---|---|---|
ClassPathResource | 从 resources/prompts/ 读 | 打包进 jar,部署简单 | 生产默认 |
| 文件系统路径 | 从外部目录读 | 改完即生效,不用打包 | 本地调优、容器挂载 |
| 数据库 / 配置中心 | 运行时拉取 | 支持热更新、灰度、A/B | 平台化阶段(04-02) |
推荐组合:默认走 ClassPathResource 保证部署简单;本地开发时通过配置开关切到文件系统路径,实现「改完刷新即可」的调优体验。等到了平台化阶段再接入配置中心做灰度。
4.3 目录组织约定
resources/prompts/
├── system/
│ ├── assistant.st # 通用助手角色
│ └── code-reviewer.st # 代码审查角色
├── task/
│ ├── ticket-extract.st # 工单抽取
│ ├── summarize.st # 摘要
│ └── classify.st # 分类
└── guard/
└── safety-prefix.st # 安全前缀,所有任务共用按「角色 / 任务 / 护栏」三类分目录,而不是按业务模块分。原因是角色和护栏会被多个业务复用,按业务分会导致重复。
4.4 版本与回滚
每个模板顶部写版本注释,并把版本写进日志:
@version v1.2
@author 木鱼
@change 2026-09-20 收紧输出长度约束为什么必须在日志里打版本号:线上出问题时,你第一个要确认的是「用的是哪版 prompt」。没有这个,排查只能靠猜。
5. 代码走查
5.1 模板文件
text
# src/main/resources/prompts/system/assistant.st
@version v1.2
你是一个 Java 技术助理,服务对象是有 {experience} 年经验的后端工程师。
回答要求:
1. 直接给结论,不做铺垫
2. 涉及代码时给出可运行的片段
3. 不确定时明说不确定,不要编造 API
4. 回答控制在 {maxWords} 字以内
安全约束:
{safetyGuard}text
# src/main/resources/prompts/guard/safety-prefix.st
@version v1.0
不得输出任何真实的密钥、密码、身份证号、手机号。
如果用户问题涉及以上内容,回答「涉及敏感信息,无法处理」。5.2 加载模板的组件
java
// ch01-basics/src/main/java/com/aitech/basics/prompt/PromptRegistry.java
@Component
public class PromptRegistry {
private static final String BASE = "prompts/";
public PromptTemplate of(String name) {
String location = BASE + name + ".st";
Resource res = new ClassPathResource(location);
if (!res.exists()) {
throw new IllegalArgumentException("提示词模板不存在: " + location);
}
return new PromptTemplate(res);
}
/** 抽取模板版本号,用于日志与审计 */
public String version(String name) {
String head = of(name).getTemplate().lines().findFirst().orElse("");
return head.contains("@version") ? head.split(" ")[1] : "unknown";
}
}5.3 在 ChatClient 里使用
java
// ch01-basics/src/main/java/com/aitech/basics/service/AssistantService.java
@Service
public class AssistantService {
private final ChatClient chatClient;
private final PromptRegistry prompts;
public String ask(String question, String level) {
PromptTemplate tpl = prompts.of("system/assistant");
Prompt prompt = tpl.create(Map.of(
"experience", level,
"maxWords", 300,
"safetyGuard", prompts.of("guard/safety-prefix").getTemplate()
));
log.info("prompt template version={}", prompts.version("system/assistant"));
return chatClient.prompt(prompt)
.user(question)
.call()
.content();
}
}注意这里用 tpl.create(Map) 生成 Prompt 再传给 chatClient.prompt(prompt),而不是自己拼字符串。这样变量替换由模板引擎完成,用户输入里的特殊字符不会破坏结构。
5.4 本地热加载(开发期)
java
// ch01-basics/src/main/java/com/aitech/basics/prompt/HotPromptRegistry.java
// 仅在 ai.prompt.hot-reload=true 时启用
@Component
@ConditionalOnProperty(name = "ai.prompt.hot-reload", havingValue = "true")
public class HotPromptRegistry extends PromptRegistry {
@Value("${ai.prompt.dir:./prompt-workspace}")
private String dir;
@Override
public PromptTemplate of(String name) {
Path p = Path.of(dir, name + ".st");
if (Files.exists(p)) {
return new PromptTemplate(new FileSystemResource(p)); // 优先外部文件
}
return super.of(name); // 回落到 classpath
}
}提示:热加载只开在开发环境。生产上开启意味着任何人改了挂载目录就能改你的模型行为,这是安全风险。用
@ConditionalOnProperty明确隔离。
5.5 用 system 与 user 分离
java
// 角色与护栏放 system,具体问题放 user
chatClient.prompt()
.system(systemPrompt) // 模板生成,稳定不变
.user(userQuestion) // 用户输入,每次都变
.call()
.content();为什么必须分开:一是语义清晰,模型对 system 的遵循度更高;二是便于做缓存——system 部分固定时,某些厂商支持缓存前缀降低成本(详见 02-15)。
6. 跑起来
bash
git checkout ch01-09-prompt-template
mvn spring-boot:runbash
curl -X POST http://localhost:8080/api/ask \
-H "Content-Type: application/json" \
-d '{"question":"讲讲 Bean 的生命周期","level":"5"}'日志应出现:
prompt template version=v1.2验证清单:
| 检查项 | 通过标准 |
|---|---|
| 变量替换 | 回答中体现「5 年经验」的定位,字数明显受 300 约束 |
| 模板缺失 | 改成一个不存在的名字,启动时或调用时报清晰错误 |
| 版本号 | 日志打印出 v1.2 |
| 特殊字符 | 问题里带 { } 和 % 不报错 |
| 热加载 | 开启开关后改外部文件,无需重启即生效 |
最后一项演示方式:在 prompt-workspace/system/assistant.st 里把「直接给结论」改成「先讲一段历史背景」,刷新页面看回答风格是否变化。这个演示最能让人信服「prompt 是配置不是代码」。
7. 生产避坑
- 模板里的变量名改了,调用方没改,不会报错只会静默输出
{xxx}。这是最隐蔽的问题——模型看到的是字面量花括号,会照着念出来。防御做法:在PromptRegistry里校验「渲染结果中不得再出现{」,出现就抛异常。 - 不要把用户输入拼进模板字符串。正确做法是用模板变量 +
tpl.create(Map)。用户输入直接拼接不仅是注入风险,还会因为包含模板语法字符导致渲染失败。 - 模板改了却没改版本号,等于没有版本管理。约定:改模板必须同步改
@version。更严格的做法是在测试里断言版本号与内容哈希的对应关系,逼着改动者更新版本——这条在 01-11 测试章节会补上。
8. 延伸与锚点
- 思考题:线上发现某版 prompt 效果变差,你怎么在不发版的情况下回滚?(提示:需要运行时可切换——答案在 04-02 模型网关的灰度能力)
- 代码锚点:
git checkout ch01-09-prompt-template - 下一课时:01-10 异常处理与重试
- 对应课件:L01-09 PromptTemplate 与外部化