Prompt 工程与结构化输出:让 LLM 返回可用的 Java 对象(Java 架构师的 AI 工程笔记 04)

Prompt 工程与结构化输出:让 LLM 返回可用的 Java 对象(Java 架构师的 AI 工程笔记 04)

这是系列的第四篇,聊聊 Prompt 怎么写才能让 LLM 输出你真正能用的数据。 上一篇让 LLM 长出了"手脚"------通过 Function Calling 调用 Java 方法查真实数据。但不管是工具的 System Prompt 还是查询结果,都存在一个问题:LLM 的输出格式不可控。这一篇聚焦两件事:怎么写好 Prompt,怎么让 LLM 的输出直接变成 Java 对象。
前置知识:需要读完第三篇《Function Calling 实战:让 LLM 调用 Java 方法查真实数据》,理解工具调用的基本流程和 ToolContext 机制。

本篇速览 :这篇讲 Prompt 工程的四个设计原则、PromptTemplate 模板引擎(ST4)的源码机制、Few-shot 技巧,以及 BeanOutputConverter 如何把 LLM 的文本输出自动映射为 Java 对象。学完你能用 .entity(FlightSearchResult.class) 一行代码拿到结构化的航班数据。

最终效果预览

bash 复制代码
curl "http://localhost:8083/api/prompt/structured?q=北京到上海明天的机票"
json 复制代码
{
  "query": "北京到上海明天的机票",
  "flights": [
    {"flightNo": "MU5678", "airline": "东方航空", "departure": "北京", "arrival": "上海", "departureTime": "08:00", "arrivalTime": "10:15", "price": 520},
    {"flightNo": "CA1234", "airline": "中国国航", "departure": "北京", "arrival": "上海", "departureTime": "12:30", "arrivalTime": "14:40", "price": 680}
  ],
  "cheapest": {"flightNo": "MU5678", "price": 520},
  "summary": "共找到 2 个航班,最低价 520 元(东方航空 MU5678)"
}

LLM 返回的不再是一坨文本,而是可以直接用的 Java 对象。


理论篇

一、从"LLM 不听话"说起

上一篇的最后,我们用 chatClient.prompt(q).call().content() 拿到了 LLM 的回复。但你有没有发现一个问题------LLM 的回答格式完全不可控

同样问"北京到上海的机票",它有时候给你一段话,有时候给你一个 markdown 表格,偶尔还要加一句"希望对您有帮助"。如果下游代码要解析这个结果,直接崩溃。

这引出两个现实问题:

  1. 怎么让 LLM "听话"------按你期望的方式回答?这就是 Prompt 工程。
  2. 怎么让 LLM 的输出直接变成 Java 对象------不用手动解析 JSON?这就是结构化输出。

想一想:假设你要把 LLM 返回的航班信息存到数据库,你会怎么处理 LLM 那段自由格式的文本?正则匹配?JSON.parse?如果 LLM 某次多打了一个逗号呢?

用 Java 工程师熟悉的类比------Prompt 工程就像写 SQL 查询:SQL 写得好,数据库返回的结果精准;SQL 写得烂,要么结果不对,要么全表扫描。Prompt 写得好,LLM 给你精准的结构化数据;Prompt 写得烂,LLM 给你一篇散文。

二、Prompt 设计四原则

2.1 System Message 写规则,User Message 写任务

LLM 对 system 角色的消息有特殊处理------它被视为"不可违反的指令",权重高于 user 消息。这跟 Java 里的类级别注解 vs 方法级别注解类似:类级别定义全局规则,方法级别定义具体行为。

sql 复制代码
差:全部塞在 User Message
"你是票小蜜,只回答机票问题,用中文回答,查北京到上海的机票"

好:分层放置
System: "你是机票分析师「票小蜜」。只处理机票相关问题,无关问题礼貌拒绝。回答语言:中文。"
User: "查北京到上海明天的机票,按价格排序"

为什么好?System 定义"不变的规则",User 传递"变化的请求" ------跟 @Configuration 配置全局行为、@RequestParam 接收请求参数一个道理。

2.2 给约束条件,不给模糊形容

LLM 按概率预测下一个 Token。模糊描述("回答要详细")会激活多种 Token 分布;具体约束("列出前 5 个最便宜的航班,包含航班号、时间、价格")会把 Token 分布收窄到你想要的模式。

模糊 具体 为什么好
"详细回答" "包含航班号、起飞时间、价格三个字段" 模型知道该输出哪些字段
"简短回答" "回答限制在 50 字以内" 模型有明确的长度目标
"专业一点" "使用 IATA 航空公司代码(如 CA=国航)" 模型知道"专业"的具体含义
"不要瞎编" "如果数据库中没有该航线,回复:暂无此航线数据" 模型知道"不编"时该说什么
2.3 注入运行时上下文

LLM 没有"当前时间"概念,也不知道用户是谁。这些运行时信息必须你来注入------就像给 PreparedStatement 填参数:

java 复制代码
Map.of(
    "date", LocalDate.now().toString(),   // 当前日期------否则 LLM 不知道"明天"是哪天
    "userId", user.getId(),               // 用户身份------后续 Agent 需要
    "history", recentSearches             // 用户最近搜索------个性化推荐
)
2.4 结构化输出配合低 temperature

BeanOutputConverter 会把 JSON Schema 拼到 Prompt 末尾------本质是一种 Prompt 级别的格式约束。但 LLM 仍然可能"跑偏"------temperature 越高,Token 采样的随机性越大,越容易生成不合格的 JSON。

💡 开发建议 :结构化输出场景把 temperature 设为 0~0.3。我实测 qwen-plus + temperature: 0.3 的格式合规率在 98% 以上;temperature: 0.9 时掉到 85% 左右。

三、Prompt 工程全景图

在深入细节之前,先看 Prompt 从编写到生效的完整流程:

这张图串起了本章的所有知识点:模板渲染(第四节)、Few-shot(第五节)、结构化输出(第六节)。接下来逐个拆解。

四、PromptTemplate------先看怎么用,再看源码怎么实现

手动拼接 Prompt 的问题很明显:

java 复制代码
// 硬编码------每次改 Prompt 都要改代码、重新部署
String prompt = "查询从" + from + "到" + to + "的" + date + "的机票";

这跟直接拼 SQL 一样危险且难维护。Spring AI 的 PromptTemplate 就是 Prompt 世界的 PreparedStatement

4.1 先看用法------三种典型场景

场景一:内联模板 + 变量替换

java 复制代码
String template = """
    你是一个机票查询助手。请根据以下信息查询机票:
    - 出发城市:{from}
    - 目的城市:{to}
    - 出发日期:{date}
    请列出 3 个推荐航班,包含航班号、起飞时间、价格。
    """;

PromptTemplate promptTemplate = new PromptTemplate(template);
Prompt prompt = promptTemplate.create(Map.of(
    "from", "北京",
    "to", "上海",
    "date", "2025-07-01"
));

chatClient.prompt(prompt).call().content();

变量用 {from} 花括号语法,按名称替换,不怕参数顺序搞错。

场景二:从外部 .st 文件加载模板

java 复制代码
// 模板文件:resources/prompts/flight-analyst.st
PromptTemplate systemTemplate = new PromptTemplate(
    new ClassPathResource("prompts/flight-analyst.st")
);

String systemPrompt = systemTemplate.render(Map.of(
    "style", "严谨",
    "date", LocalDate.now().toString()
));

模板放在文件里,改 Prompt 不用改 Java 代码。这是生产环境的常见做法。

场景三:Few-shot 模板

java 复制代码
String fewShotPrompt = """
    你是机票查询助手。请严格按以下示例格式回答:

    【示例 1】
    用户:北京到上海明天的机票
    助手:为您查询到以下航班:
    | 航班号 | 起飞 | 到达 | 价格 |
    |--------|------|------|------|
    | CA1234 | 08:00 | 10:15 | ¥680 |

    【示例 2】
    用户:广州到深圳的高铁
    助手:抱歉,我只能查询机票信息。您是否需要查询广州到深圳的机票?

    ---
    现在请回答:
    用户:{question}
    """;

PromptTemplate promptTemplate = new PromptTemplate(fewShotPrompt);
Prompt prompt = promptTemplate.create(Map.of("question", q));

三个场景看下来,PromptTemplate 的用法很直觉:写模板 → 填变量 → 渲染。那它底层是怎么做到的?

4.2 再看源码------ST4 引擎

翻了下 PromptTemplate 的源码,它底层用的是 **StringTemplate 4(ST4)**引擎:

java 复制代码
// PromptTemplate 源码结构(简化)
public class PromptTemplate {
    private String template;
    private final TemplateRenderer renderer;  // 默认是 StTemplateRenderer

    public String render(Map<String, Object> variables) {
        return this.renderer.apply(this.template, variables);  // 委托给 ST4
    }
}

// StTemplateRenderer 内部用 ST4 引擎做模板渲染
public class StTemplateRenderer implements TemplateRenderer {
    public String apply(String template, Map<String, Object> variables) {
        ST st = createST(template);  // 创建 StringTemplate 实例
        variables.forEach(st::add);  // 注入变量
        return st.render();          // 渲染
    }
}

流程很清楚:PromptTemplate.render() → 委托给 StTemplateRenderer → 创建 ST4 模板实例 → 注入变量 → 渲染输出。

为什么用 ST4 而不是 String.format()

能力 String.format PromptTemplate (ST4)
变量替换 %s 按顺序 {name} 按名称,不怕顺序错
条件逻辑 不支持 {if(vip)}尊贵用户{endif}
外部文件 不支持 new PromptTemplate(new ClassPathResource("prompts/xxx.st"))
模板校验 渲染时校验变量是否都提供了
Spring 集成 支持 Resource@Value 注入

⚠️ 踩坑提醒 :ST4 的变量语法是 {variable}(花括号,不带 $)。如果你写了 ${variable},ST4 会把 $ 当普通字符,变量替换静默失效------不报错、不替换,非常隐蔽。这跟 Spring 的 @Value("${...}") 语法不同,别搞混了。

五、Few-shot------用例子"教"LLM 输出格式

Zero-shot 是什么都不给,直接问。LLM 会回答,但格式、风格、详细程度全看它心情。

Few-shot 是在 Prompt 里给几个"示范",让 LLM 照着来。这利用了 LLM 的一个核心能力------In-Context Learning(上下文学习):LLM 能从你给的几个例子中"学会"输出模式,无需重新训练。

维度 Zero-shot Few-shot
Token 消耗 多(每个例子都占 Token)
输出格式可控性 低,靠运气 高,LLM 会模仿示例格式
适用场景 简单问答、闲聊 需要固定格式、特定风格的输出
边界情况处理 可以用示例教 LLM 怎么拒绝

实用原则

  • 2-3 个示例就够,太多浪费 Token 且效果不会更好
  • 示例要覆盖正常情况边界情况(比如用户问了机票以外的问题,怎么拒绝)
  • 示例的格式就是你期望的输出格式------LLM 是"照猫画虎"
  • 如果用了 .entity() 结构化输出,JSON Schema 本身就是格式示范,通常不再需要 Few-shot

Few-shot vs .entity() 怎么选?

场景 推荐方式 原因
输出是标准 JSON,下游代码直接消费 .entity() JSON Schema 约束更严格,自动反序列化
输出是 markdown 表格/特定文本格式 Few-shot Schema 管不了文本格式,只有示例能教
LLM 总是在 JSON 里加多余字段 两者混用 Schema 定义结构,Few-shot 教"只输出这些字段"
边界情况处理(拒绝、兜底话术) Few-shot Schema 无法表达"如果查不到该说什么"

实际项目中两者混用的场景比想象中多------.entity() 管数据结构,Few-shot 管"语气"和"边界行为"。比如机票 Agent 中,我用 .entity(FlightSearchResult.class) 保证返回结构,同时在 System Prompt 的 Few-shot 部分教 LLM "查不到航班时返回空数组而不是编造数据"。

示例怎么维护?

Few-shot 示例本质是 Prompt 的一部分,跟 Prompt 一样有版本管理问题。我的做法是把示例放在 .st 模板文件里,跟 System Prompt 模板一起管理。好处是改示例不用改 Java 代码,坏处是示例多了模板文件会很长。如果示例超过 5 个,考虑用动态加载------根据用户输入的类型,只注入最相关的 2-3 个示例,避免浪费 Token。

六、BeanOutputConverter------先看一行代码的魔法,再拆源码

6.1 先看用法------一行代码拿到 Java 对象

结构化输出最简单的写法就一行:

java 复制代码
FlightSearchResult result = chatClient.prompt()
    .system("你是机票查询助手。只输出纯 JSON,不要任何额外文字。")
    .user("北京到上海明天的机票")
    .call()
    .entity(FlightSearchResult.class);  // 关键:LLM 输出的文本直接变成 Java 对象

.entity(FlightSearchResult.class) 做了什么?它让 LLM 知道"你需要输出这种格式的 JSON",然后自动把 LLM 返回的 JSON 字符串反序列化成 Java 对象。不用手动解析,不用正则匹配。

如果返回的是列表,用 ParameterizedTypeReference

java 复制代码
List<FlightInfo> flights = chatClient.prompt()
    .system("你是机票查询助手。只输出 JSON 数组。")
    .user("列出 5 个热门航线")
    .call()
    .entity(new ParameterizedTypeReference<>() {});

看起来像魔法------传一个 Class 进去,出来就是对象。那它底层到底怎么做到的?

6.2 再看源码------三步拆解

如果你用过 Jackson 的 ObjectMapper.readValue(json, MyClass.class)BeanOutputConverter 做的事情类似------只不过它还负责告诉 LLM 应该输出什么格式

可以把它理解为 Jackson ObjectMapper + JSON Schema 生成器 + Prompt 注入器 三合一。

当你写 .entity(FlightInfo.class) 时,背后发生了三件事。

第一步:生成 JSON Schema

构造函数中调用 generateSchema(),用的是 victools/jsonschema-generator 库(不是 Jackson 自带的):

java 复制代码
// BeanOutputConverter.generateSchema() 源码简化
private void generateSchema() {
    JacksonModule jacksonModule = new JacksonModule(
        RESPECT_JSONPROPERTY_REQUIRED, RESPECT_JSONPROPERTY_ORDER);

    SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(
        SchemaVersion.DRAFT_2020_12,   // JSON Schema 2020-12 标准
        OptionPreset.PLAIN_JSON)
        .with(jacksonModule)
        .build();

    SchemaGenerator generator = new SchemaGenerator(config);
    ObjectNode schema = generator.generateSchema(this.type);  // 反射分析 Java 类
    this.jsonSchema = objectMapper.writeValueAsString(schema);
}

对于我们的 FlightSearchResult record,生成的完整 Schema 如下(这不是简化版,就是真实生成的):

json 复制代码
{
  "type" : "object",
  "properties" : {
    "query" : { "type" : "string" },
    "flights" : {
      "type" : "array",
      "items" : {
        "type" : "object",
        "properties" : {
          "flightNo" : { "type" : "string" },
          "airline" : { "type" : "string" },
          "departure" : { "type" : "string" },
          "arrival" : { "type" : "string" },
          "departureTime" : { "type" : "string" },
          "arrivalTime" : { "type" : "string" },
          "price" : { "type" : "integer" }
        }
      }
    },
    "cheapest" : {
      "type" : "object",
      "properties" : {
        "flightNo" : { "type" : "string" },
        "airline" : { "type" : "string" },
        "departure" : { "type" : "string" },
        "arrival" : { "type" : "string" },
        "departureTime" : { "type" : "string" },
        "arrivalTime" : { "type" : "string" },
        "price" : { "type" : "integer" }
      }
    },
    "summary" : { "type" : "string" }
  }
}

你可能已经注意到------光这个 Schema 就有 30 多行 。这就是 BeanOutputConverter 的 Token 开销来源:嵌套越深、字段越多,Schema 越长,每次请求都要为"格式指令"付 Token 费。

第二步:拼接到 Prompt

getFormat() 方法返回一段固定的英文指令 + Schema,追加到 User Prompt 后面。我实际打印了一下,LLM 最终收到的 Prompt 长这样:

typescript 复制代码
【System Message】
你是机票查询助手。根据用户需求生成模拟航班信息。
价格范围:经济舱 300-2000 元。
航班号格式:CA/MU/CZ + 4位数字。
只输出纯 JSON,不要任何额外文字。

【User Message】
北京到上海明天的机票

Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response
following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{"type":"object","properties":{"query":{"type":"string"},"flights":{"type":"array","items":{"type":"object","properties":{"flightNo":{"type":"string"},...}}},...}}```

看到了吗?你写的 User Message 只有 1 行,但 LLM 实际收到了 10+ 行的格式指令 。这就是 .entity() 背后的"魔法"------也是它的 Token 成本。

💡 开发建议 :开发阶段打开 logging.level.org.springframework.ai.chat.model: DEBUG,在日志里看实际发送给 LLM 的完整 Prompt。很多"LLM 不听话"的问题,看了实际 Prompt 就明白了。

第三步:反序列化

convert() 方法用 Jackson ObjectMapper 把 LLM 返回的 JSON 字符串反序列化为 Java 对象:

java 复制代码
// BeanOutputConverter.convert() 源码简化
public T convert(String text) {
    // 先尝试清理 markdown 代码块标记
    String trimmed = trimMarkdown(text);
    try {
        return objectMapper.readValue(trimmed, typeRef);
    } catch (JsonProcessingException e) {
        throw new RuntimeException("Failed to convert to " + type.getName(), e);
    }
}

注意 trimMarkdown() ------Spring AI 会尝试自动去除 LLM 返回的 ```json ````标记。但它只处理最外层的代码块,如果 LLM 在 JSON 前面加了"好的,为您查询到以下结果:"这种文字,trimMarkdown` 救不了你------所以 System Prompt 里的"只输出纯 JSON"非常重要。

三种 OutputConverter 对比

Spring AI 不只有 BeanOutputConverter,还有另外两种:

Converter 输出类型 适用场景 Token 开销
BeanOutputConverter Java 对象 / record 复杂结构化数据 高(JSON Schema 较长)
ListOutputConverter List<String> 简单列表 低(逗号分隔)
MapOutputConverter Map<String, Object> 键值对,Schema 不固定

💡 开发建议 :能用 BeanOutputConverter 就不用 MapOutputConverter------有 Schema 约束比没有好。ListOutputConverter 适合"列出 5 个热门航线"这种简单场景。


实战篇

七、动手编码------创建 prompt-engineering 模块

本节完整代码在 prompt-engineering 子模块中。

7.1 项目结构
bash 复制代码
prompt-engineering/
├── pom.xml
└── src/main/
    ├── java/com/ai/course/promptengineering/
    │   ├── PromptEngineeringApplication.java
    │   ├── config/
    │   │   └── PromptManager.java          ← 模板集中管理
    │   ├── controller/
    │   │   ├── PromptController.java        ← 7 个接口:模板/角色/Few-shot/结构化/列表/重试/校验
    │   │   └── ManualParseController.java   ← 手动拆解 BeanOutputConverter
    │   ├── model/
    │   │   ├── FlightInfo.java
    │   │   └── FlightSearchResult.java
    │   └── service/
    │       └── SafeEntityCaller.java        ← 带重试的结构化输出(第八节)
    └── resources/
        ├── application.yml
        └── prompts/
            └── flight-analyst.st
7.2 数据模型------定义 LLM 输出结构
java 复制代码
package com.ai.course.promptengineering.model;

/**
 * 航班信息
 * 用 record 定义------字段名就是 LLM 需要输出的 JSON key
 */
public record FlightInfo(
    String flightNo,       // 航班号,如 CA1234
    String airline,        // 航空公司
    String departure,      // 出发城市
    String arrival,        // 目的城市
    String departureTime,  // 起飞时间
    String arrivalTime,    // 到达时间
    int price              // 价格(元)
) {}
java 复制代码
package com.ai.course.promptengineering.model;

import java.util.List;

/**
 * 航班查询结果(包含多个航班)
 * BeanOutputConverter 会根据这个 record 生成 JSON Schema
 */
public record FlightSearchResult(
    String query,              // 用户原始查询
    List<FlightInfo> flights,  // 航班列表
    FlightInfo cheapest,       // 最便宜的
    String summary             // 一句话摘要
) {}
7.3 Prompt 模板文件

src/main/resources/prompts/flight-analyst.st

markdown 复制代码
你是一个{style}的机票分析师「票小蜜」。

你的职责:
1. 只回答与机票、航班、旅行相关的问题
2. 回答时必须包含航班号、时间、价格
3. 如果用户问了无关问题,礼貌地引导回机票话题
4. 当前日期是 {date},请基于此判断用户说的"明天""后天"是哪天

回答风格:{style}
7.4 PromptManager------模板集中管理

上面的 .st 文件如果散落在各个 Controller 里加载,随着场景增多(查询/比价/推荐),会出现到处 new PromptTemplate(new ClassPathResource(...)) 的情况。抽一个 PromptManager 统一管理所有模板:

java 复制代码
package com.ai.course.promptengineering.config;

import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * Prompt 模板管理器
 * 所有模板注册在一处------改 Prompt 只需改 .st 文件,不改 Java 代码
 */
@Component
public class PromptManager {

    private final Map<String, PromptTemplate> templates = new HashMap<>();

    public PromptManager() {
        register("flight-analyst", "prompts/flight-analyst.st");
        // 后续章节会不断增加模板
        // register("flight-compare", "prompts/flight-compare.st");
        // register("flight-recommend", "prompts/flight-recommend.st");
    }

    private void register(String name, String classpath) {
        templates.put(name, new PromptTemplate(new ClassPathResource(classpath)));
    }

    public String render(String name, Map<String, Object> variables) {
        PromptTemplate template = templates.get(name);
        if (template == null) {
            throw new IllegalArgumentException("未知模板: " + name);
        }
        return template.render(variables);
    }
}

这样做的好处:

  • 一处注册,处处使用 ------Controller 只需 promptManager.render("flight-analyst", variables) 一行代码
  • 改 Prompt 不改代码 ------产品经理直接改 .st 文件就能调整效果
  • 模板复用------多个接口可以共享同一个模板,只是参数不同

后面的 Controller 会直接注入 PromptManager 来使用。

7.5 Controller------七个接口覆盖全部知识点
java 复制代码
package com.ai.course.promptengineering.controller;

import com.ai.course.promptengineering.config.PromptManager;
import com.ai.course.promptengineering.model.FlightInfo;
import com.ai.course.promptengineering.model.FlightSearchResult;
import com.ai.course.promptengineering.service.SafeEntityCaller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.StructuredOutputValidationAdvisor;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/prompt")
public class PromptController {

    private final ChatClient chatClient;
    private final PromptManager promptManager;
    private final SafeEntityCaller safeEntityCaller;

    public PromptController(ChatClient.Builder builder,
                            PromptManager promptManager,
                            SafeEntityCaller safeEntityCaller) {
        this.chatClient = builder
            .defaultSystem("你是机票查询助手。只输出纯 JSON,不要任何额外文字。")
            .build();
        this.promptManager = promptManager;
        this.safeEntityCaller = safeEntityCaller;
    }

    // ---------- 接口 1:PromptTemplate 模板替换 ----------

    /**
     * 演示 PromptTemplate 的基础用法
     * GET /api/prompt/template?from=北京&to=上海&date=2025-07-01
     */
    @GetMapping("/template")
    public String templateDemo(@RequestParam String from,
                               @RequestParam String to,
                               @RequestParam String date) {

        // ST4 语法:{variable},不是 ${variable}
        String template = """
            你是一个机票查询助手。请根据以下信息查询机票:
            - 出发城市:{from}
            - 目的城市:{to}
            - 出发日期:{date}

            请列出 3 个推荐航班,包含航班号、起飞时间、价格。
            按价格从低到高排序。
            """;

        PromptTemplate promptTemplate = new PromptTemplate(template);
        Prompt prompt = promptTemplate.create(Map.of(
            "from", from,
            "to", to,
            "date", date
        ));

        return chatClient.prompt(prompt).call().content();
    }

    // ---------- 接口 2:通过 PromptManager 使用模板 ----------

    /**
     * 用 PromptManager 渲染 .st 模板作为 System Prompt
     * GET /api/prompt/role?q=北京到上海明天的机票&style=严谨
     */
    @GetMapping("/role")
    public String roleTemplate(@RequestParam String q,
                               @RequestParam(defaultValue = "严谨") String style) {

        // 一行代码渲染模板,不用每次 new PromptTemplate + new ClassPathResource
        String systemPrompt = promptManager.render("flight-analyst", Map.of(
            "style", style,
            "date", LocalDate.now().toString()
        ));

        return chatClient.prompt()
            .system(systemPrompt)
            .user(q)
            .call()
            .content();
    }

    // ---------- 接口 3:Few-shot Prompting ----------

    /**
     * 在 Prompt 中给出示例,让 LLM 模仿输出格式
     * GET /api/prompt/few-shot?q=广州到深圳的高铁
     */
    @GetMapping("/few-shot")
    public String fewShot(@RequestParam String q) {

        String fewShotPrompt = """
            你是机票查询助手。请严格按以下示例格式回答:

            【示例 1】
            用户:北京到上海明天的机票
            助手:为您查询到以下航班:
            | 航班号 | 起飞 | 到达 | 价格 |
            |--------|------|------|------|
            | CA1234 | 08:00 | 10:15 | ¥680 |
            | MU5678 | 12:30 | 14:45 | ¥520 |
            最低价:MU5678,¥520

            【示例 2】
            用户:广州到深圳的高铁
            助手:抱歉,我只能查询机票信息,无法查询高铁票。您是否需要查询广州到深圳的机票?

            ---
            现在请回答:
            用户:{question}
            """;

        PromptTemplate promptTemplate = new PromptTemplate(fewShotPrompt);
        Prompt prompt = promptTemplate.create(Map.of("question", q));

        return chatClient.prompt(prompt).call().content();
    }

    // ---------- 接口 4:结构化输出 ----------

    /**
     * .entity() 自动将 LLM 输出映射为 Java 对象
     * GET /api/prompt/structured?q=北京到上海明天的机票
     */
    @GetMapping("/structured")
    public FlightSearchResult structuredOutput(@RequestParam String q) {
        return chatClient.prompt()
            .system("""
                你是机票查询助手。根据用户需求生成模拟航班信息。
                价格范围:经济舱 300-2000 元。
                航班号格式:CA/MU/CZ + 4位数字。
                只输出纯 JSON,不要任何额外文字。
                """)
            .user(q)
            .call()
            .entity(FlightSearchResult.class);  // 关键:自动 JSON → Java 对象
    }

    // ---------- 接口 5:List 结构化输出 ----------

    /**
     * 用 ParameterizedTypeReference 获取 List
     * GET /api/prompt/list?q=列出5个热门航线
     */
    @GetMapping("/list")
    public List<FlightInfo> listFlights(@RequestParam String q) {
        return chatClient.prompt()
            .system("你是机票查询助手。根据用户需求生成模拟航班信息。只输出 JSON 数组。")
            .user(q)
            .call()
            .entity(new ParameterizedTypeReference<>() {});
    }

    // ---------- 接口 6:带重试的结构化输出(手动封装) ----------

    @GetMapping("/safe-search")
    public FlightSearchResult safeSearch(@RequestParam String q) {
        return safeEntityCaller.call(q, FlightSearchResult.class, 3);
    }

    // ---------- 接口 7:StructuredOutputValidationAdvisor(推荐方式) ----------

    @GetMapping("/validated-search")
    public FlightSearchResult validatedSearch(@RequestParam String q) {
        var validationAdvisor = StructuredOutputValidationAdvisor.builder()
            .outputType(FlightSearchResult.class)
            .maxRepeatAttempts(3)
            .build();

        return chatClient.prompt()
            .system("""
                你是机票查询助手。根据用户需求生成模拟航班信息。
                价格范围:经济舱 300-2000 元。
                航班号格式:CA/MU/CZ + 4位数字。
                """)
            .user(q)
            .advisors(validationAdvisor)
            .call()
            .entity(FlightSearchResult.class);
    }
}
7.6 配置文件

src/main/resources/application.yml

yaml 复制代码
spring:
  ai:
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.3    # 结构化输出场景用低 temperature

server:
  port: 8083
7.7 启动类
java 复制代码
package com.ai.course.promptengineering;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PromptEngineeringApplication {
    public static void main(String[] args) {
        SpringApplication.run(PromptEngineeringApplication.class, args);
    }
}
验证一下------PromptTemplate 基础用法

启动项目后,测试模板替换:

bash 复制代码
curl "http://localhost:8083/api/prompt/template?from=北京&to=上海&date=2025-07-01"

预期输出:LLM 返回一段包含 3 个航班的文本,出发城市是北京,目的地是上海,日期是 2025-07-01。

动手试一试 :把模板里的 {from} 改成 ${from}(加一个 $),重新调用------你会发现变量没有被替换,LLM 收到的是 ${from} 这个字面量。这就是 ST4 vs Spring 占位符语法的区别。

验证一下------System Prompt 文件化
bash 复制代码
curl "http://localhost:8083/api/prompt/role?q=今天天气怎么样&style=幽默"

预期输出:LLM 会以"票小蜜"身份回复,礼貌地把话题引导回机票(因为天气不是它的职责范围)。

bash 复制代码
curl "http://localhost:8083/api/prompt/role?q=北京到上海明天的机票&style=幽默"

预期输出:以幽默风格回答航班信息。换成 style=严谨 试试------输出风格会有明显变化。

验证一下------Few-shot 效果
bash 复制代码
curl "http://localhost:8083/api/prompt/few-shot?q=广州到深圳的高铁"

预期输出:LLM 会模仿示例 2 的格式,礼貌拒绝并引导到机票查询。

bash 复制代码
curl "http://localhost:8083/api/prompt/few-shot?q=北京到上海明天的机票"

预期输出:LLM 会模仿示例 1 的 markdown 表格格式输出航班信息。

对比实验:把 Few-shot 接口的两个示例都删掉(只保留"你是机票查询助手"),然后再问"广州到深圳的高铁"------观察 LLM 还会不会拒绝。没有示例教它怎么拒绝,它大概率会尝试回答。

验证一下------结构化输出
bash 复制代码
curl "http://localhost:8083/api/prompt/structured?q=北京到上海明天的机票"

预期输出:返回格式规整的 JSON,包含 queryflightscheapestsummary 字段,可以直接被 Java 代码消费。

bash 复制代码
curl "http://localhost:8083/api/prompt/list?q=列出3个从北京出发的热门航线"

预期输出:返回一个 JSON 数组,每个元素是 FlightInfo 结构。

动手试一试 :在 FlightInfo record 中增加一个字段 String aircraft(机型),然后重新调用 /structured 接口------观察 LLM 是否自动填充了新字段。BeanOutputConverter 会重新生成 Schema,LLM 能"看到"新字段的要求。

结构化输出失败长什么样?

别光看成功的情况。把 application.yml 中的 temperature 改成 0.9,连续调 5 次 /structured 接口,大概率会碰到这样的错误:

vbnet 复制代码
// LLM 返回了格式不合规的内容(temperature 0.9 时偶发)
org.springframework.ai.converter.ConversionException:
  Failed to convert from JSON to FlightSearchResult

Caused by: com.fasterxml.jackson.core.JsonParseException:
  Unexpected character ('为' (0x4E3A)): expected a valid value
  at [Source: (String)"为您查询到以下航班信息:
  {"query":"北京到上海明天的机票",...}"; line: 1, column: 2]

问题一目了然------LLM 在 JSON 前面加了一句中文"为您查询到以下航班信息:",Jackson 解析直接挂了。trimMarkdown() 只去代码块标记,不去自然语言前缀。

解决方案的组合拳

  1. System Prompt 明确说"只输出纯 JSON,不要任何额外文字"(挡住大部分情况)
  2. temperature 调到 0.3 以下(减少随机性)
  3. 代码层加重试(兜底方案)

对比实验 :分别用 temperature: 0.3temperature: 0.9 调用 /structured 接口,各调 10 次。记录成功次数------你会直观感受到 temperature 对结构化输出稳定性的影响。我的实测结果:0.3 成功 10/10,0.9 成功 8/10。

八、带重试的结构化输出------Spring AI 提供了什么,我们还需要什么

结构化输出不是 100% 可靠的,Spring AI 对此提供了两层重试机制

第一层:HTTP 传输重试(内置)

Spring AI 自动配置了 RetryTemplate,处理网络层失败(限流 429、服务端 500):

yaml 复制代码
spring:
  ai:
    retry:
      max-attempts: 10          # 默认 10 次
      backoff:
        initial-interval: 2000  # 2 秒起步,指数退避
        multiplier: 5

但这层只管 HTTP 错误------LLM 返回 200 OK、JSON 解析失败时不会触发

第二层:StructuredOutputValidationAdvisor(推荐方式)

Spring AI 1.1 引入了 StructuredOutputValidationAdvisor------一个递归 Advisor。它比简单重试聪明得多:JSON 解析失败时,把错误信息反馈给 LLM,让 LLM 看到自己哪里错了、自行纠正,而不是盲目重试。

这个 Advisor 的工作流程:

用法很简单------构建 Advisor,挂到 ChatClient 上:

java 复制代码
import org.springframework.ai.chat.client.advisor.StructuredOutputValidationAdvisor;

// 构建校验 Advisor
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
    .outputType(FlightSearchResult.class)  // 用于生成 JSON Schema 并校验
    .maxRepeatAttempts(3)                  // 最多让 LLM 修正 3 次
    .build();

// 方式一:挂到 ChatClient 的默认 Advisor 链
var chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(validationAdvisor)
    .build();

// 方式二:在单次请求中挂载(更灵活)
FlightSearchResult result = chatClient.prompt()
    .user(q)
    .advisors(validationAdvisor)
    .call()
    .entity(FlightSearchResult.class);

为什么比盲目重试好?

对比项 盲目重试 (SafeEntityCaller) StructuredOutputValidationAdvisor
错误信息 丢弃,重发相同 Prompt 拼入 Prompt,LLM 能看到哪里错了
纠正精度 纯靠运气 LLM 定向修正错误字段
架构集成 独立组件 Advisor 链的一部分,跟 Logger/Memory 等 Advisor 协同
适用版本 任何版本 Spring AI 1.1+

💡 开发建议 :优先用 StructuredOutputValidationAdvisor------它是 Advisor 链的一部分,架构更优雅。SafeEntityCaller 作为兜底方案保留,适用于需要自定义重试逻辑的场景。

SafeEntityCaller------手动封装的兜底方案

虽然有了框架内置的 Advisor,但理解手动封装的思路仍然有价值------特别是当你需要自定义重试逻辑(比如记录失败日志用于 Prompt 优化)时:

java 复制代码
package com.ai.course.promptengineering.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

/**
 * 带重试的结构化输出封装
 * 失败时记录 LLM 的原始输出------这是优化 Prompt 的最好素材
 */
@Component
public class SafeEntityCaller {

    private static final Logger log = LoggerFactory.getLogger(SafeEntityCaller.class);

    private final ChatClient chatClient;

    public SafeEntityCaller(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("只输出纯 JSON,不要任何额外文字。")
            .build();
    }

    public <T> T call(String userMessage, Class<T> type, int maxRetries) {
        for (int i = 0; i < maxRetries; i++) {
            try {
                return chatClient.prompt(userMessage)
                    .call()
                    .entity(type);
            } catch (Exception e) {
                log.warn("结构化输出第 {} 次失败 | type={} | error={}",
                    i + 1, type.getSimpleName(), e.getMessage());
                // 失败的原始输出是优化 Prompt 的最好素材
                if (i == maxRetries - 1) {
                    throw new RuntimeException(
                        "结构化输出失败,已重试 " + maxRetries + " 次", e);
                }
            }
        }
        throw new IllegalStateException("unreachable");
    }
}

在 Controller 中两种方式都提供了:

java 复制代码
// 方式一:SafeEntityCaller(手动重试)
@GetMapping("/safe-search")
public FlightSearchResult safeSearch(@RequestParam String q) {
    return safeEntityCaller.call(q, FlightSearchResult.class, 3);
}

// 方式二:StructuredOutputValidationAdvisor(框架内置,推荐)
@GetMapping("/validated-search")
public FlightSearchResult validatedSearch(@RequestParam String q) {
    var validationAdvisor = StructuredOutputValidationAdvisor.builder()
        .outputType(FlightSearchResult.class)
        .maxRepeatAttempts(3)
        .build();

    return chatClient.prompt()
        .system("""
            你是机票查询助手。根据用户需求生成模拟航班信息。
            价格范围:经济舱 300-2000 元。
            航班号格式:CA/MU/CZ + 4位数字。
            """)
        .user(q)
        .advisors(validationAdvisor)
        .call()
        .entity(FlightSearchResult.class);
}
验证一下------两种重试方式对比
bash 复制代码
# 方式一:SafeEntityCaller 盲目重试
curl "http://localhost:8083/api/prompt/safe-search?q=北京到上海明天的机票"

# 方式二:StructuredOutputValidationAdvisor 智能纠正(推荐)
curl "http://localhost:8083/api/prompt/validated-search?q=北京到上海明天的机票"

两种方式的输出结果相同------都返回结构化的 FlightSearchResult JSON。区别在失败时的行为:

  • /safe-search 失败时,控制台输出 WARN SafeEntityCaller : 结构化输出第 1 次失败,然后用相同 Prompt 盲目重试
  • /validated-search 失败时,Advisor 自动把 JSON 解析错误拼入下一轮 Prompt,LLM 能看到"你上次输出的 JSON 在第 3 行有语法错误"------定向修正比盲目重试成功率高得多

动手试一试 :把 temperature 调到 0.9,分别用两个接口各调 10 次。比较两者的成功率------/validated-search 的表现通常优于 /safe-search,因为 LLM 能从错误反馈中学习。

九、手动使用 BeanOutputConverter

上面 .entity() 是 ChatClient 封装好的快捷方式。如果你想更精细地控制流程,可以手动拆开来用:

java 复制代码
package com.ai.course.promptengineering.controller;

import com.ai.course.promptengineering.model.FlightInfo;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ManualParseController {

    private final ChatClient chatClient;

    public ManualParseController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    /**
     * 手动拆解 BeanOutputConverter 的三步流程
     * GET /api/prompt/manual?q=北京到上海明天最便宜的航班
     */
    @GetMapping("/api/prompt/manual")
    public FlightInfo manualParse(@RequestParam String q) {
        // 第一步:创建 Converter,内部自动生成 JSON Schema
        BeanOutputConverter<FlightInfo> converter = new BeanOutputConverter<>(FlightInfo.class);

        // 第二步:获取格式指令(Schema + 英文提示词)
        String formatInstructions = converter.getFormat();

        // 拼接到 Prompt------LLM 会收到"用户问题 + 格式要求"
        String fullPrompt = q + "\n\n" + formatInstructions;

        // 第三步:调用 LLM,拿到原始文本
        String rawOutput = chatClient.prompt(fullPrompt).call().content();

        // 第四步:用 Jackson 反序列化为 Java 对象
        return converter.convert(rawOutput);
    }
}
验证一下
bash 复制代码
curl "http://localhost:8083/api/prompt/manual?q=北京到上海明天最便宜的航班"

预期输出:返回单个 FlightInfo JSON 对象。这个接口跟 /structured 效果类似,但你能清楚看到 BeanOutputConverter 的每一步。

十、与机票比价 Agent 的集成

回顾一下我们在前三章积累的能力:

章节 能力 在 Agent 中的作用
第 1 章 ChatModel 基础调用 Agent 的 LLM 底层通道
第 2 章 ChatClient + Advisor 链 Agent 的请求管道和拦截链
第 3 章 Function Calling + ToolContext Agent 的"手脚"------调用 Java 方法
第 4 章 Prompt 模板 + 结构化输出 Agent 的"语言"和"数据格式"
第 5 章(预告) Memory 对话记忆 Agent 的"记忆"------跨轮次保持状态

这一章定义的 FlightSearchResult 就是机票比价 Agent 的核心数据结构。后续不管是 Function Calling 返回航班数据,还是 RAG 从知识库查到政策信息,最终都会走结构化输出映射成 Java 对象,让下游业务代码拿到就能用。

Prompt 模板管理在 Agent 场景更为关键------不同场景切换不同的 System Prompt(查询模式 vs 比价模式 vs 推荐模式),用模板文件管理意味着"改 Prompt 不用改代码"。

十一、FAQ、踩坑记录与 Trade-offs

Q1:.entity(FlightSearchResult.class) 抛出 JSON 解析异常?

常见原因和解决方案:

  1. LLM 在 JSON 前后加了 markdown 代码块标记------在 System Prompt 中明确"不要 markdown 代码块"
  2. LLM 返回了额外说明文字------System Prompt 中说"只输出纯 JSON"
  3. Java record 字段名与 LLM 输出的 JSON key 不一致------字段命名用驼峰且语义清晰
  4. 使用低 temperature(0~0.3)显著提高格式稳定性

Q2:PromptTemplate 的变量没被替换?

检查你写的是 {variable} 还是 ${variable}。ST4 引擎只认花括号,$ 会被当作普通字符。

Q3:ListOutputConverterBeanOutputConverter<List<String>> 选哪个?

ListOutputConverter 告诉 LLM 用逗号分隔文本,然后 split------Token 消耗少,但格式不够严格。BeanOutputConverter<List<String>> 生成完整的 JSON Schema------更可靠但 Token 开销大。简单列表用前者,需要严格格式用后者。

Trade-off:Prompt 模板外部化 vs 代码内联

把 Prompt 放到 .st 文件里,好处是"改 Prompt 不用改代码、不用重新部署"。但代价也很明显:

  • 调试困难 :Prompt 的效果跟模板参数强相关,光看 .st 文件看不出最终发给 LLM 的完整文本。建议开发时打开 org.springframework.ai.chat.model: DEBUG 日志,看实际的 Prompt 内容。
  • 版本管理.st 文件的修改历史分散在文件系统中,不像 Java 代码有严格的 code review 流程。如果 Prompt 改坏了,回滚困难。建议把 .st 文件纳入 Git,跟代码一样走 PR 流程。
  • 跨模型兼容:一套 Prompt 模板不一定适用所有模型。qwen-plus 上跑得好的 Prompt,换到 deepseek-v3 上可能需要调整。如果项目要支持多模型,Prompt 模板管理的复杂度会翻倍。

我的做法是:开发阶段 Prompt 写在代码里,快速迭代;稳定后再抽到 .st 文件。 别一上来就过度设计。


架构演进图------第 04 章后的系统

对比上一章,本章新增了 Prompt 模板层 (PromptTemplate + ST4)和 结构化输出层(BeanOutputConverter + ValidationAdvisor):

对比第 03 章:上一章加入了 Tool 层,LLM 能"做事"了。本章在输入端加了 PromptTemplate(可维护的提示词管理),在输出端加了 BeanOutputConverter(LLM 输出直接变 Java 对象)。下一章给 Agent 加上记忆------让它记住多轮对话的上下文。

本章小结 & 下一章预告

知识点 核心要点 Java 类比
Prompt 设计原则 System 写规则、User 写任务;具体约束 > 模糊形容 SQL 查询优化
PromptTemplate ST4 引擎,{variable} 语法,支持外部文件 PreparedStatement
Few-shot 2-3 个示例教 LLM 输出格式 单元测试的 given/when/then
BeanOutputConverter 反射生成 Schema → 拼入 Prompt → Jackson 反序列化 Jackson ObjectMapper
三种 Converter Bean(复杂结构)、List(简单列表)、Map(动态键值) 泛型容器选型
StructuredOutputValidationAdvisor 递归 Advisor,校验失败时把错误反馈给 LLM 自动纠正 Spring Retry + 错误反馈

下一章预告:Memory 对话记忆 + Checkpoint

下一篇要解决一个关键问题------LLM 天生没有记忆,如何实现多轮对话?

现在的 Agent 每次对话都是独立的。用户说"我想从北京飞上海" → "明天的" → "最便宜的那个",三轮对话之间 Agent 记不住任何信息。下一篇来解决这个痛点:

  1. 为什么 LLM 无记忆------每次请求都是独立的 HTTP 调用
  2. InMemoryChatMemory------最简单的内存记忆
  3. MessageWindowChatMemory------滑动窗口策略,控制 Token 消耗
  4. 实战:多轮机票查询对话

延伸阅读

聊聊你的想法

几个开放性问题,欢迎在评论区讨论:

  1. 结构化输出 vs Few-shot:你更倾向哪种? .entity() 自动注入 JSON Schema,Few-shot 手动给示例。前者省事但 Token 开销大,后者灵活但要自己维护示例。你在项目中怎么选?有没有两者混用的场景?
  2. Prompt 模板该放在哪里? 代码里、.st 文件里、还是数据库里?如果你做过 Prompt 版本管理(A/B 测试、灰度发布),聊聊你的方案。
  3. LLM 输出不合格时,你选择智能纠正还是后处理? 本章展示了两种重试方案------StructuredOutputValidationAdvisor(把错误反馈给 LLM 自行纠正)和 SafeEntityCaller(盲目重试)。你在项目中怎么选?有没有用正则/字符串清洗做后处理的经验?
  4. 你踩过哪些 Prompt 的坑? 比如某个模型死活不遵守 JSON 格式、或者 Few-shot 示例"教歪"了 LLM。分享你的踩坑经验,大家少走弯路。


本文代码GitHub - prompt-engineering 模块

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。

相关推荐
火山引擎开发者社区2 小时前
基于多模态数据湖的新一代人工智能应用——Nvidia 工具链落地实践的深度洞察
人工智能
Elastic 中国社区官方博客2 小时前
用于 Elasticsearch 的 Gemini CLI 扩展,包含工具和技能
大数据·开发语言·人工智能·elasticsearch·搜索引擎·全文检索
段小二2 小时前
ChatClient 源码解析:从 HTTP 请求到 AI 响应的全链路拆解(Java 架构师的 AI 工程笔记 02)
人工智能
RuiBo_Qiu2 小时前
【LLM进阶-后训练&部署】2. 常见的全参数微调SFT方法
人工智能·深度学习·机器学习·ai-native
2501_933329552 小时前
媒介宣发技术中台架构实践:基于AI多模态的舆情处置与智能分发系统设计
人工智能·架构·系统架构
_遥远的救世主_2 小时前
OpenCode vs OpenClaw 企业级 AI 平台二开选型深度拆解
人工智能
安全菜鸟2 小时前
OpenClaw-CN 完整安装教程与避坑指南(国内镜像加速版)
人工智能·openclaw
小慧教你用AI2 小时前
OpenClaw的多Agent架构设计,揭示其实现原理
人工智能
FluxMelodySun2 小时前
机器学习(二十三) 密度聚类与层次聚类
人工智能·机器学习·聚类