Skip to content

PromptTemplate 与外部化:把提示词当代码管起来 ​

1. 本节产出 ​

一套提示词管理体系:所有 prompt 放在 resources/prompts/*.st,支持变量占位、多语言/多场景切换、改动后无需重新打包(本地开发热生效),并且每个模板有版本标识,出问题能立刻回滚。

2. 前置依赖 ​

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:run
bash
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. 生产避坑 ​

  1. 模板里的变量名改了,调用方没改,不会报错只会静默输出 {xxx}。这是最隐蔽的问题——模型看到的是字面量花括号,会照着念出来。防御做法:在 PromptRegistry 里校验「渲染结果中不得再出现 { 」,出现就抛异常。
  2. 不要把用户输入拼进模板字符串。正确做法是用模板变量 + tpl.create(Map)。用户输入直接拼接不仅是注入风险,还会因为包含模板语法字符导致渲染失败。
  3. 模板改了却没改版本号,等于没有版本管理。约定:改模板必须同步改 @version。更严格的做法是在测试里断言版本号与内容哈希的对应关系,逼着改动者更新版本——这条在 01-11 测试章节会补上。

8. 延伸与锚点 ​

  • 思考题:线上发现某版 prompt 效果变差,你怎么在不发版的情况下回滚?(提示:需要运行时可切换——答案在 04-02 模型网关的灰度能力)
  • 代码锚点:git checkout ch01-09-prompt-template
  • 下一课时:01-10 异常处理与重试
  • 对应课件:L01-09 PromptTemplate 与外部化