Spring AI Agent 漫画实战
第 03 话 CONTRACT

Prompt 即契约:模板工程与结构化输出 把提示词当字符串拼接的人,迟早会被一次 JSON 解析异常叫醒

这一话解决两个工程问题:提示词怎么管(模板化、外置化、版本化、有测试),以及模型输出怎么落(BeanOutputConverter 三层原理、JSON Schema 注入、失败自纠)。顺带把 Spring AI 那个最经典的坑 —— { } 被当成模板占位符 —— 讲透。

老 K
提示词不就是拼字符串吗?我把 system 和 user 用 StringBuilder 一拼,跑得挺好。
小 S
能跑。然后呢?多轮对话怎么把历史塞进去?工具回调结果怎么塞?截断计费怎么算?
你拼的是"文本",厂商要的是"结构化消息序列"。这是两个世界。

一、Message 体系:角色是"类型",不是"前缀"

先立个立场:Prompt 不是一个 String,它是你与模型之间的接口契约 —— 要有版本、有模板、有测试、有校验;违反契约要有可观测的失败与自纠路径。

Spring AI 的最小交付单元是 Message,它是一次 chat/completions 请求里 messages[] 数组的一项。角色由 MessageType 枚举决定,而不是靠你在文本前手写 "System: "。

MessageType实现类语义 / 工程注意
SYSTEMSystemMessage全局规则与输出格式。多数厂商只对第一条 system 敏感,不要写 5 条 system。
USERUserMessage用户输入。可携带 List<Media>(图片/音频/文档)。
ASSISTANTAssistantMessage模型输出。带工具调用时 getToolCalls() 不为空 —— 它必须与后续 TOOL_RESPONSE 成对出现。
TOOL_RESPONSEToolResponseMessage工具执行结果。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));
为什么不能拼字符串

二、PromptTemplate 与 TemplateRenderer:那个必踩的 {} 坑

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
踩坑 · 转义的隐藏第二层

三、外置到 classpath:把提示词当源码管

1. 目录结构

src/main/resources/
└── prompts/
    ├── README.md            # 每个模板的用途 / 负责人 / 变更记录
    ├── code-review.st       # v1.4  代码评审(结构化输出)
    ├── sql-audit.st         # v2.0  SQL 审核
    └── intent-router.st     # v1.1  意图分类(廉价模型 + 枚举输出)

2. 加载

@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();
}

3. 一个真实可用的系统提示词模板

# 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\}
提示词工程六件套(在 .st 里落地的位置)
  1. 角色设定:一句话身份 + 一句"不是什么",比十行形容词有效。
  2. 分隔符:用 ## 章节 / ### / XML 标签把规则分区,模型对分区的遵循度显著高于大段散文。
  3. few-shot:给 1~3 组"输入→输出"样例。示例本身就是最好的 schema 说明,比 JSON Schema 还省 token。
  4. CoT:加一句"先列出你的判断依据,再给结论"。注意:这会增加输出 token,要不要开取决于你是要准确率还是要成本。
  5. ReAct:Thought / Action / Observation 的显式循环;配合第 06 话的工具调用使用,别在纯文本场景滥用。
  6. 负向约束:明确写"不要做什么"。模型对"不要"的服从度低于"要做什么",所以负向约束要紧跟正向指令并给替代动作。
工程化建议 · 提示词也要有 CI
  1. 版本号写进模板头部注释(v1.4),并在 README.md 里记录每次变更的"原因 + 效果回测结论"。改提示词就是改代码,别在后台偷偷改。
  2. 进 Git,走 Code Review。模板文件是 src/main/resources 的一部分,PR 里必须能看到 diff。
  3. 冒烟测试:渲染后不许残留未替换占位符。
  4. 黄金样本测试(golden set):固定 20~50 条"输入→期望结构化输出"的用例,每次模板升级后跑一遍,统计通过率与 token 均耗。没有这个,你的提示词优化就是玄学。
@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
}

四、结构化输出:BeanOutputConverter 的三层原理

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
① Java 目标类型ReviewResult.class /ParameterizedTypeReference ② FormatProvidergetFormat() 反射生成JSON Schema 文本 ③ 注入 system prompt作为 {format} / {schema}随请求一起发出 ChatModel受 schema 约束输出 JSON 模型返回字符串可能带代码围栏或前言后语 conv.convert(String)ObjectMapper.readValue Java 对象失败抛异常 契约闭环:类型 → Schema → 提示词 → 文本 → 类型,任何一环掉链子都会在 convert() 处炸出来
源码解剖 · BeanOutputConverter(简化后)
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();
}
踩坑清单

多模态:UserMessage 携带 Media

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)));
多模态的三个现实约束
小 S
这个类比很到位:提示词是接口定义,schema 是从 Java 类型生成的 IDL,解析失败就是一次 400。 下一话讲拦截器 —— 你熟悉的 Filter 链回来了,但它是双向环绕的,还要能穿过响应式流。
← 上一话:三十行跑通 目录 下一话:Advisor 拦截机制 →