提示靠拼字符串那一刻起,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. 自测
PromptTemplate的底层模板语法是什么?为什么不能用 Java 的字符串直觉去写?- 变量缺失时会发生什么?为什么要在入口自己校验?
SystemMessage与UserMessage各该放什么?把规则写进用户消息会怎样?maxMessages限制的是什么?为什么它不能防超窗?- 漏传
conversationId的症状是什么?为什么难排查? - 流式下做结构化输出为什么要先拼完整再解析?