这一话解决两个工程问题:提示词怎么管(模板化、外置化、版本化、有测试),以及模型输出怎么落(BeanOutputConverter 三层原理、JSON Schema 注入、失败自纠)。顺带把 Spring AI 那个最经典的坑 —— { } 被当成模板占位符 —— 讲透。
先立个立场:Prompt 不是一个 String,它是你与模型之间的接口契约 ——
要有版本、有模板、有测试、有校验;违反契约要有可观测的失败与自纠路径。
Spring AI 的最小交付单元是 Message,它是一次 chat/completions 请求里
messages[] 数组的一项。角色由 MessageType 枚举决定,而不是靠你在文本前手写
"System: "。
| MessageType | 实现类 | 语义 / 工程注意 |
|---|---|---|
SYSTEM | SystemMessage | 全局规则与输出格式。多数厂商只对第一条 system 敏感,不要写 5 条 system。 |
USER | UserMessage | 用户输入。可携带 List<Media>(图片/音频/文档)。 |
ASSISTANT | AssistantMessage | 模型输出。带工具调用时 getToolCalls() 不为空 —— 它必须与后续 TOOL_RESPONSE 成对出现。 |
TOOL_RESPONSE | ToolResponseMessage | 工具执行结果。1.0 之前叫 FUNCTION / FunctionMessage,升级时是编译错误高发区。 |
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是代码评审专家,只输出 JSON。"));
messages.add(new UserMessage("审查这段代码:\n" + code));
// assistant 声明要调工具
messages.add(AssistantMessage.builder().text("").toolCalls(List.of(
new AssistantMessage.ToolCall("call_1", "readFile", "{\"path\":\"a.java\"}"))).build()); //!hl
// 响应的 id 必须与上一条 tool_calls 一一对应,否则 OpenAI 系直接 400 //!hl
messages.add(new ToolResponseMessage(List.of(
new ToolResponseMessage.ToolResponse("call_1", "readFile", "public class A {}")))); //!hl
ChatResponse r = chatModel.call(new Prompt(messages));
"System: " 就能劫持你的角色设定(Prompt Injection 的第一入口)。tool_calls / tool 是结构化字段,字符串里根本表达不了。{} 坑PromptTemplate template = new PromptTemplate("用 {tone} 的语气,讲讲 {topic},控制在 {limit} 字以内"); //!hl
Prompt prompt = template.create(Map.of("tone", "十年 Java 老兵吐槽",
"topic", "虚拟线程与平台线程池混用", "limit", 300)); //!hl
// 只想要一条 Message 时(默认产出 SystemMessage;可显式传 MessageType 改类型)
Message sysMsg = template.createMessage(Map.of("tone", "专业", "topic", "JDBC 批处理", "limit", 200)); //!hl
{} 是 StTemplateRenderer(默认实现,底层是 ANTLR StringTemplate 4)的定界符。
所以模板里任何字面量花括号 —— 尤其是你想塞进去的 JSON 示例 —— 都会被当成占位符,然后抛
"no such property or can't access" 之类的 ST 异常。三种应对:
| TemplateRenderer | 行为 | 什么时候用 |
|---|---|---|
StTemplateRenderer(默认) | 用 StringTemplate 渲染 {var},支持 \{ \} 转义 | 普通文本模板,变量少、无大段 JSON |
NoOpTemplateRenderer.INSTANCE | 原样返回,完全不做替换 | 模板里全是 JSON/代码,变量只走 .param() 的 ChatClient 侧渲染 |
自定义 TemplateRenderer | 实现 String apply(String, Map<String,Object>) | 换成 {{var}} 或 ${var} 定界符,与前端模板习惯统一 |
// 方案 A:转义(注意 Java 字符串里反斜杠本身要再转义一次)
String tpl = "输出格式:\\{\"summary\":\"...\",\"score\":0\\}"; //!hl
PromptTemplate ptA = new PromptTemplate(tpl);
// 方案 B:直接关掉模板引擎 —— 变量改由 ChatClient 的 .param() 处理
PromptTemplate ptB = new PromptTemplate(rawTextWithJson, NoOpTemplateRenderer.INSTANCE); //!hl
// 方案 C:实现 TemplateRenderer 的 String apply(String, Map) —— 换成 {{var}} 定界符,彻底避开单花括号
PromptTemplate ptC = new PromptTemplate("输出 {{format}}", myRenderer); //!hl
"\{" 是编译错误(非法转义)。要写 "\\{" 才能得到两个字符 \{。这也是"提示词外置到 .st 文件"最实在的理由之一 —— 文件里没有这一层转义。NoOpTemplateRenderer 之后,PromptTemplate#create(Map) 就不再替换任何变量了,别以为它还在工作。src/main/resources/
└── prompts/
├── README.md # 每个模板的用途 / 负责人 / 变更记录
├── code-review.st # v1.4 代码评审(结构化输出)
├── sql-audit.st # v2.0 SQL 审核
└── intent-router.st # v1.1 意图分类(廉价模型 + 枚举输出)
@Value("classpath:/prompts/code-review.st") //!hl
private Resource codeReviewPrompt; // 亦可 new ClassPathResource("prompts/...")
public String review(String code, String schema) {
Message system = new PromptTemplate(this.codeReviewPrompt)
.createMessage(Map.of("persona", "资深 Java 架构师",
"language", "Java 17", "schema", schema)); //!hl
return chatClient.prompt()
.system(s -> s.text(system.getText()))
.user(u -> u.text("待审查代码:\n{code}").param("code", code)) //!hl
.call().content();
}
# prompts/code-review.st —— v1.4
你是 {persona},服务于一个 {language} 后端项目的自动化代码评审。
## 角色边界
- 你是评审者,不是改写者。不要输出完整重写后的代码。
- 只依据用户给出的代码片段,禁止臆测项目其余部分。
## 约束(硬性)
- 每条风险必须给出:文件路径、行号、严重级别(HIGH/MEDIUM/LOW)、可执行的修改建议。
- 不评价缩进、命名风格、注释多少这类无信息量的内容。
- 不确定时使用 "疑似",并说明需要什么信息才能确认。
- 若代码确实没有问题,也必须给出至少一条"潜在风险提示"。
## 输出格式
严格只输出符合下面 JSON Schema 的单个 JSON 对象;不要输出解释文字,不要使用代码围栏:
{schema}
## 示例(few-shot)
输入:public void run() \{ new Thread(() -> doIt()).start(); \}
输出:\{"summary":"裸线程,缺少池化","risks":[\{"file":"A.java","line":1,"level":"MEDIUM"\}],"score":68\}
## 章节 / ### / XML 标签把规则分区,模型对分区的遵循度显著高于大段散文。Thought / Action / Observation 的显式循环;配合第 06 话的工具调用使用,别在纯文本场景滥用。v1.4),并在 README.md 里记录每次变更的"原因 + 效果回测结论"。改提示词就是改代码,别在后台偷偷改。src/main/resources 的一部分,PR 里必须能看到 diff。@Test
void render_should_not_leave_unresolved_placeholders() {
var pt = new PromptTemplate(new ClassPathResource("prompts/code-review.st"));
String rendered = pt.render(Map.of("persona", "资深 Java 架构师",
"language", "Java 17", "schema", "{}")); //!hl
assertThat(rendered).contains("输出格式").doesNotContain("{persona}"); //!hl
}
public record RiskItem(String file, int line, String level, String suggestion) {}
public record ReviewResult(String summary, List<RiskItem> risks, int score) {}
// 用法一:最省事 —— 框架内部自己生成 schema、自己注入、自己解析
ReviewResult r1 = chatClient.prompt()
.user("审查这段代码:\n" + code)
.call()
.entity(ReviewResult.class); //!hl
// 用法二:泛型 —— ParameterizedTypeReference
List<RiskItem> r2 = chatClient.prompt().user(code).call()
.entity(new ParameterizedTypeReference<List<RiskItem>>() {}); //!hl
// 用法三(推荐):手动拿 schema,塞进外置 .st 模板的 {schema} 占位符,全程可控 //!hl
BeanOutputConverter<ReviewResult> conv = new BeanOutputConverter<>(ReviewResult.class); //!hl
String schema = conv.getFormat(); //!hl
String json = chatClient.prompt()
.system(s -> s.text(reviewTemplate) // 含 {schema} 的 .st 文件
.param("persona", "资深 Java 架构师")
.param("language", "Java 17")
.param("schema", schema)) //!hl
.user(code)
.options(OpenAiChatOptions.builder().withTemperature(0.0).build()) //!hl
.call().content();
ReviewResult r3 = conv.convert(json); //!hl
public class BeanOutputConverter<T> implements StructuredOutputConverter<T> { //!hl
private final ObjectMapper objectMapper; // 默认实例,可注入自定义 //!hl
private final Class<T> targetClass;
private final ParameterizedTypeReference<T> targetType;
private final JsonSchemaGenerator schemaGenerator;
private String format; // 懒加载 + 缓存 //!hl
@Override
public String getFormat() { //!hl
if (this.format == null) {
this.format = this.schemaGenerator.generate( //!hl
this.targetType != null ? this.targetType.getType() : this.targetClass);
}
return this.format;
}
@Override
public T convert(String source) { //!hl
try { return this.objectMapper.readValue(source, this.typeRef); } //!hl
catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse: " + source, e); //!hl
}
}
}
关键推论:① schema 是从 Java 类型反射生成的,所以字段名、@JsonProperty、record 的组件名直接决定契约;
② getFormat() 有缓存,同一个 converter 多次调用零成本;
③ convert() 不校验业务语义,只保证 JSON 能反序列化 —— 字段值是否合法是你的事。
即使注入了 schema,模型仍有概率输出 ```json ... ``` 围栏、前置一句"好的,这是结果:",
或者字段值违反你的业务约束。生产代码必须有一条可收敛的自纠回路。
private static final Pattern FENCE = Pattern.compile("(?s)```(?:json)?\\s*(.*?)```"); //!hl
public ReviewResult reviewWithSelfHeal(String code, String schema) {
List<Message> msgs = new ArrayList<>(List.of(
new SystemMessage(rendered(schema)), new UserMessage("审查这段代码:\n" + code)));
String feedback = null;
for (int attempt = 0; attempt < 3; attempt++) { //!hl
if (feedback != null) {
msgs.add(new UserMessage("上一次输出不符合契约:" + feedback
+ "。请严格只输出 JSON 对象,不要任何解释与代码围栏。")); //!hl
}
String raw = chatModel.call(new Prompt(msgs,
OpenAiChatOptions.builder().withTemperature(0.0).build())) //!hl
.getResult().getOutput().getText();
try {
ReviewResult result = objectMapper.readValue(stripFence(raw), ReviewResult.class); //!hl
Set<ConstraintViolation<ReviewResult>> vs = validator.validate(result); //!hl
if (vs.isEmpty()) {
return result;
}
feedback = vs.stream().map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining("; ")); //!hl
}
catch (Exception e) {
feedback = "JSON 解析失败:" + e.getMessage();
}
}
throw new OutputContractException("模型三次均未按契约输出,最近一次原因:" + feedback); //!hl
}
static String stripFence(String raw) { //!hl
Matcher m = FENCE.matcher(raw);
return m.find() ? m.group(1).trim() : raw.trim();
}
enum 在 schema 里的表示可靠得多。response_format: json_schema 是强约束;部分国内模型只做"尽力而为"。不要假设 schema 就是保险箱,解析侧必须容错。temperature=0 不等于确定性:批处理与浮点非确定性仍会给出不同结果。要严格可复现,得靠 seed(若厂商支持)+ 固定 topP + 固定提示词版本。e.getMessage() + raw 全量回灌给模型,等于把异常原文再发一次给第三方 —— 生产环境只回灌"哪条约束没满足",不回灌整段输出。UserMessage msg = UserMessage.builder()
.text("这张时序图里,哪一步存在同步阻塞?只回答步骤名称。") //!hl
.media(new Media(MimeType.valueOf("image/png"),
new ClassPathResource("diagram/order-flow.png"))) //!hl
.build(); // data 也接受 byte[] / URI 字符串,生产上优先用 Resource
ChatResponse r = chatModel.call(new Prompt(List.of(msg)));
ChatModel 层做能力判断,或走第 09 话的模型路由。第 04 话 —— 责任链复活。Advisor 的
before/after 由谁调度?DefaultAroundAdvisorChain 怎么递归推进?
为什么 ChatModelCallAdvisor 的 order 最大?以及手写一个生产级成本审计 Advisor。