2614 字
约 8 分钟
15
S22-B05 · Spring AI 提示模板入门:PromptTemplate 与多轮组织

提示靠拼字符串那一刻起,bug 就埋好了:某个变量是 null,渲染出"你是 null,请回答",模型一本正经照做;JSON 示例里的花括号被当成占位符,整段模板渲染成空。问题不在模型,在字符串拼接本身没有契约。

PromptTemplate 给的是一份契约:模板里哪些是字面量、哪些是参数、缺了参数怎么办、渲染结果长什么样。这篇按"模板 → 消息 → 记忆 → 调试"的顺序走一遍。


1. 占位符:{var} 的边界在哪

PromptTemplate tpl = PromptTemplate.builder()
    .template("""
        你是{role},回答不超过{limit}字。
        问题:{question}
        """)
    .build();

String rendered = tpl.render(Map.of(
    "role", "Redis 讲师", "limit", "80", "question", "AOF 是什么"));
System.out.println(rendered);

底层是 StringTemplate(ST4):模板语法不是 Java 的。这决定三件事:

写法 行为 注意
{var} 取值并替换 变量名不要带空格、不要用中文
{var} 但 Map 里没有 渲染为空串或抛异常(按所用 1.0.x 版本的 renderer 配置核对) 必填参数自己先校验
字面花括号 { 会被当成占位符起点 JSON 示例放模板里必踩
中文标点 原样保留 不影响渲染,但影响模型断句

JSON 冲突的解法随版本而定:转义、换 delimiter,或把 JSON 示例挪到单独的 SystemMessage。最稳是第三种——模板只留真参数,结构约束交给消息层。

// 先校验再渲染:缺值在入口就炸,而不是在模型输出里炸
static void require(Map<String, Object> args, String... keys) {
    for (String k : keys) {
        Object v = args.get(k);
        if (v == null || v.toString().isBlank())
            throw new IllegalArgumentException("模板参数缺失: " + k);
    }
}

2. 三种消息的分工

消息 谁写 作用 常见误用
SystemMessage 开发者 角色、边界、输出格式、禁止事项 把每次变化的用户数据写进去
UserMessage 用户/模板渲染 本次请求的具体内容 把长期规则塞进每轮用户消息
AssistantMessage 模型(历史) 多轮上下文,模型自己说过的话 手工伪造历史导致自相矛盾

原则:系统消息放不变的东西,用户消息放每次都变的。 把"必须输出 JSON"塞进用户消息,某轮忘了带,行为就漂移。


3. 多轮:ChatMemory 存什么、存哪儿、留多少

ChatMemory memory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(new InMemoryChatMemoryRepository())  // 换 JDBC/Redis 即持久化
    .maxMessages(20)                                          // 窗口:保留最近 20 条
    .build();

ChatClient client = ChatClient.builder(chatModel)
    .defaultSystem("你是运维助手,回答控制在三句话内。")
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
    .build();
维度 选项 影响
仓库 内存 / JDBC / 自定义 进程重启后上下文是否还在
窗口策略 按条数(maxMessages)/ 自定义裁剪 越界风险 vs 上下文完整性
会话标识 conversationId 多用户隔离的唯一字段
注入方式 MessageChatMemoryAdvisor 自动把历史拼进请求

maxMessages 是条数不是 token 数:一条长回复可能占掉半个窗口,真实约束得靠"条数 + 单条长度"双限。

String answer = client.prompt()
    .user(rendered)
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "u-42"))
    .call()
    .content();

漏掉 conversationId 的后果不是报错,而是所有用户共用一份记忆——症状是"模型突然提起别人的问题",很难归因。


4. 模板的三种形态

形态 结构 适合 风险
纯文本 角色 + 任务 + 参数 单轮问答、摘要 输出格式不稳定
few-shot 2–3 组"输入 → 输出" 分类、抽取、风格模仿 示例过长挤占窗口
结构化约束 明确字段名 + 类型 + 禁项 要程序解析 与 JSON 花括号冲突
PromptTemplate fewShot = PromptTemplate.builder()
    .template("""
        把工单分为 billing / bug / other 三类,只输出类别名。
        输入:扣了两次钱        → billing
        输入:登录后白屏        → bug
        输入:{ticket}         →
        """)
    .build();

few-shot 的关键不是示例多,而是覆盖边界:两个正例加一个"像但实际不是"的反例,比五个同类正例有效。

结构化输出则不要靠提示词硬约束,用框架能力兜:

record Ticket(String category, boolean urgent) {}

Ticket t = client.prompt().user(rendered).call().entity(Ticket.class);

.entity() 走的是输出转换器(schema 提示 + 反序列化),比"请你输出 JSON"稳得多。提示里仍要写清"只输出对象、不要解释",否则模型会在 JSON 前后加一段话。


5. 模板与 Advisor:谁拼装、谁增强

职责 归属 说明
把参数填进模板 PromptTemplate 纯字符串替换,无模型调用
组装消息列表 ChatClient / Prompt 决定 system/user 的顺序
注入历史 MessageChatMemoryAdvisor 在请求前改写消息列表
检索增强 QuestionAnswerAdvisor 拿用户问题去向量库查,再拼成上下文
日志与观测 SimpleLoggerAdvisor 打印请求/响应
重试与限流 自定义 Advisor 在链路中间介入

顺序即语义:Advisor 按注册顺序执行,QuestionAnswerAdvisor 要排在记忆 Advisor 之后,否则检索用的是没带上历史的原始问题。模板管"问什么",Advisor 管"之前补什么"。


6. 调试:先打印,再调用

// 渲染是纯函数,不花钱、不出网。任何模板上线前先跑这一行
System.out.println("=== rendered ===\n" + tpl.render(args));

四个必看项:花括号是否还在、参数是否为空、换行是否被压平、中文标点是否完整。未替换的 { 说明字面花括号被 ST4 吞了;连续空行说明某参数渲染成了空串。

# 模板放配置:改文案不用重新编译
app:
  prompt:
    ticket-classify: |
      把工单分为 billing / bug / other 三类,只输出类别名。
      输入:{ticket} →
放哪儿 优点 代价
代码内常量 类型安全、可被 IDE 索引、随版本走 改一个字要发布
配置文件 运营可改、可灰度 无编译期校验,写错要到运行时才发现
数据库/配置中心 可灰度、可回滚、按租户下发 需要缓存与版本号,排查变复杂
Prompt 管理平台 有评测与对比 多一套依赖

灰度:模板带版本号(ticket-classify@v3),按流量比例分流,同时记录版本与实际输出,否则无法回滚定位。


7. 流式:提示没变,拼接变了

流式与非流式的提示完全一样,区别只在消费端:

Flux<String> flux = client.prompt().user(rendered).stream().content();
StringBuilder sb = new StringBuilder();
flux.doOnNext(sb::append)
    .doOnComplete(() -> log.info("full answer: {}", sb))
    .blockLast();
差异点 非流式 流式
提示构造 一次成型 完全一致
输出 完整字符串 增量片段(可能按 token/字节切)
结构化输出 直接 .entity() 需先拼完整再解析
错误位置 调用即失败 可能中途断流,前半段已展示

流式的坑是"半句话已推给前端":中途失败时用户看到不完整答案。要么前端占位重试,要么服务端拼完再发(那就失去流式的意义)。


8. 最小可跑示例

// 1) 模板:只放真参数
PromptTemplate tpl = PromptTemplate.builder()
    .template("你是{role}。用不超过{limit}字回答:{question}")
    .build();

// 2) 渲染并打印(调试铁律)
Map<String, Object> args = Map.of("role", "Redis 讲师", "limit", "80",
                                  "question", "AOF 重写为什么能减小文件");
String userText = tpl.render(args);
System.out.println(userText);

// 3) 带记忆的多轮调用
ChatClient client = ChatClient.builder(chatModel)
    .defaultSystem("回答简洁,涉及配置时给一行示例。")
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
    .build();

String a1 = client.prompt().user(userText)
    .advisors(x -> x.param(ChatMemory.CONVERSATION_ID, "u-42")).call().content();
String a2 = client.prompt().user("那 RDB 呢?")   // 第二轮依赖上一轮
    .advisors(x -> x.param(ChatMemory.CONVERSATION_ID, "u-42")).call().content();

第二轮能接上"RDB",是 Advisor 把第一轮塞回了消息列表——不是模型记住了,是你每轮重发历史。这也解释了成本:token 随轮数线性上升。


9. 排错:六个高频坑

现象 根因 处理
模板里花括号消失 JSON 示例被 ST4 当占位符 示例挪到 SystemMessage,或按版本转义
渲染出 null / 空串 Map 缺键或值为空 入口处校验必填参数
行为漂移、答非所问 会变的数据写进了 system 消息 变量只放 user 消息
多轮串台 没传 conversationId 每用户/每会话固定 ID
上下文超窗报错 maxMessages 只限条数 加单条长度上限或做摘要裁剪
结构化输出解析失败 提示未禁解释文本 用 .entity() + 明确"只输出对象"
中文标点被吞或乱码 编码不一致、转义误伤 先看渲染输出,再查文件 UTF-8 声明

10. 边界

情况 说明
模板极短(一句拼接) 直接字符串拼接更省事,模板的收益在"多参数 + 复用"
需要强类型参数 用 record 承载再渲染,避免 Map 键名拼错
高频变更文案 放配置中心,带版本号与回滚路径
提示即产品逻辑 模板要与评测集一起版本化,单独改文案等于改行为

11. 自测

  1. PromptTemplate 的底层模板语法是什么?为什么不能用 Java 的字符串直觉去写?
  2. 变量缺失时会发生什么?为什么要在入口自己校验?
  3. SystemMessage 与 UserMessage 各该放什么?把规则写进用户消息会怎样?
  4. maxMessages 限制的是什么?为什么它不能防超窗?
  5. 漏传 conversationId 的症状是什么?为什么难排查?
  6. 流式下做结构化输出为什么要先拼完整再解析?
S22-B05 · Spring AI 提示模板入门:PromptTemplate 与多轮组织
https://www.clxhxhhr.top/posts/6090/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。