1. Prompt 工程与结构化输出:Java 架构师的实战指南
作为一名在 Java 领域深耕多年的架构师,我最近一直在探索如何将大语言模型(LLM)更好地集成到企业级应用中。今天要分享的是 Prompt 工程与结构化输出的实战经验,这是让 LLM 真正成为生产力工具的关键技术。
1.1 为什么需要结构化输出?
想象一下这样的场景:你开发了一个机票查询系统,用户问"北京到上海明天的机票",LLM 可能会返回:
"为您查询到以下航班:
- 东方航空 MU5678,08:00-10:15,¥520
- 中国国航 CA1234,12:30-14:40,¥680
希望对您有帮助!"
这种自由格式的文本对用户很友好,但对程序极不友好。如果下游系统需要解析这些数据存入数据库,或者进行比价计算,开发者不得不写复杂的正则表达式或文本解析逻辑。
更糟的是,LLM 的输出格式不稳定 - 有时候是列表,有时候是表格,偶尔还会在前面加一句问候语。这就是为什么我们需要结构化输出:让 LLM 返回机器可读的标准格式(如 JSON),并且能直接映射为 Java 对象。
1.2 Spring AI 的解决方案
Spring AI 提供了几种实现结构化输出的方式:
- BeanOutputConverter:将 LLM 输出自动映射到 Java 对象
- ListOutputConverter:处理简单的列表输出
- MapOutputConverter:处理键值对输出
其中 BeanOutputConverter 是最强大的,它实现了三个关键功能:
- 根据 Java 类生成 JSON Schema
- 将 Schema 注入 Prompt 约束 LLM 输出格式
- 自动反序列化 JSON 到 Java 对象
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现原理与技术细节
2.1 BeanOutputConverter 的工作原理
当调用 .entity(FlightInfo.class) 时,背后发生了以下几步:
- Schema 生成:通过反射分析 Java 类结构,生成对应的 JSON Schema
- Prompt 注入:将 Schema 作为约束条件拼接到 Prompt 末尾
- 结果解析:将 LLM 返回的 JSON 反序列化为 Java 对象
2.1.1 Schema 生成过程
以 FlightInfo 类为例:
java复制public record FlightInfo(
String flightNo,
String airline,
String departure,
String arrival,
String departureTime,
String arrivalTime,
int price
) {}
生成的 JSON Schema 大约有 30 多行,定义了每个字段的类型和结构。这个 Schema 会被拼接到 Prompt 中,告诉 LLM 必须按照这个格式输出。
2.1.2 实际 Prompt 结构
LLM 实际收到的 Prompt 类似这样:
code复制【System Message】
你是机票查询助手。根据用户需求生成模拟航班信息。
价格范围:经济舱 300-2000 元。
航班号格式:CA/MU/CZ + 4位数字。
只输出纯 JSON,不要任何额外文字。
【User Message】
北京到上海明天的机票
Your response should be in JSON format.
Do not include any explanations...
Here is the JSON Schema instance your output must adhere to:
{"type":"object","properties":{"flightNo":{"type":"string"},...}}
可以看到,为了确保格式正确,我们付出了额外的 Token 成本。
2.2 性能优化技巧
- 控制 temperature 参数:结构化输出场景建议设为 0-0.3,降低随机性
- 精简 Schema:只保留必要字段,减少 Token 消耗
- 缓存 Schema:避免每次请求都重新生成
- 使用 Record 而非 Class:Record 的字段定义更简洁
3. 实战:构建机票查询服务
3.1 项目结构设计
code复制prompt-engineering/
├── pom.xml
└── src/main/
├── java/com/example/promptengineering/
│ ├── config/PromptManager.java
│ ├── controller/PromptController.java
│ ├── model/FlightInfo.java
│ └── service/SafeEntityCaller.java
└── resources/
├── application.yml
└── prompts/flight-analyst.st
3.2 核心代码实现
3.2.1 数据模型定义
java复制public record FlightInfo(
String flightNo,
String airline,
String departure,
String arrival,
String departureTime,
String arrivalTime,
int price
) {}
public record FlightSearchResult(
String query,
List<FlightInfo> flights,
FlightInfo cheapest,
String summary
) {}
3.2.2 Controller 实现
java复制@RestController
@RequestMapping("/api/prompt")
public class PromptController {
private final ChatClient chatClient;
@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);
}
}
3.3 错误处理机制
结构化输出可能失败,主要原因是:
- LLM 没有严格遵守格式要求
- JSON 解析出错
Spring AI 提供了两种处理方式:
3.3.1 StructuredOutputValidationAdvisor(推荐)
java复制var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputType(FlightSearchResult.class)
.maxRepeatAttempts(3)
.build();
FlightSearchResult result = chatClient.prompt()
.user(q)
.advisors(validationAdvisor)
.call()
.entity(FlightSearchResult.class);
这种方式会在失败时将错误信息反馈给 LLM,让它自行纠正。
3.3.2 手动重试机制
java复制public <T> T callWithRetry(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("结构化输出第 {} 次失败", i + 1);
if (i == maxRetries - 1) {
throw new RuntimeException("重试失败", e);
}
}
}
throw new IllegalStateException("unreachable");
}
4. 高级技巧与最佳实践
4.1 Prompt 模板管理
将 Prompt 模板外部化,便于维护:
java复制@Component
public class PromptManager {
private final Map<String, PromptTemplate> templates = new HashMap<>();
public PromptManager() {
register("flight-analyst", "prompts/flight-analyst.st");
}
public String render(String name, Map<String, Object> variables) {
return templates.get(name).render(variables);
}
}
模板文件示例 (flight-analyst.st):
code复制你是一个{style}的机票分析师「票小蜜」。
你的职责:
1. 只回答与机票、航班、旅行相关的问题
2. 回答时必须包含航班号、时间、价格
3. 当前日期是 {date}
4.2 Few-shot 学习技巧
通过示例教 LLM 输出格式:
java复制String fewShotPrompt = """
你是机票查询助手。请严格按以下示例格式回答:
【示例】
用户:北京到上海明天的机票
助手:{"query":"北京到上海明天的机票","flights":[{"flightNo":"CA1234",...}]}
现在请回答:
用户:{question}
""";
4.3 性能优化建议
- Token 优化:精简 Schema,移除不必要的字段描述
- 缓存:缓存常用 Prompt 的渲染结果
- 批量处理:合并多个请求减少 API 调用次数
- 异步处理:对实时性要求不高的场景使用异步调用
5. 常见问题与解决方案
5.1 LLM 不遵守格式要求
现象:LLM 在 JSON 外添加了额外文本
解决方案:
- 在 System Prompt 中强调"只输出纯 JSON"
- 降低 temperature 参数
- 使用 StructuredOutputValidationAdvisor
5.2 JSON 解析失败
现象:Jackson 抛出 JsonParseException
解决方案:
- 检查 LLM 返回的原始内容
- 添加重试逻辑
- 实现自定义的 OutputConverter 处理特殊格式
5.3 字段缺失或多余
现象:Java 对象中某些字段为 null,或有多余字段
解决方案:
- 检查 Schema 是否与 Java 类一致
- 在 Prompt 中明确字段要求
- 使用 @JsonIgnoreProperties 忽略未知字段
6. 架构演进与扩展思路
当前架构已经实现了:
- Prompt 模板化管理
- 结构化输出
- 自动重试机制
下一步可以考虑:
- 动态 Prompt 生成:根据用户画像生成个性化 Prompt
- 多模型路由:根据查询类型选择最适合的模型
- 结果验证:对 LLM 生成的数据进行业务规则校验
- 性能监控:记录每次调用的 Token 消耗和响应时间
在实际项目中引入这套技术栈后,我们的机票查询服务开发效率提升了约40%,主要得益于:
- 减少了大量的数据解析代码
- 降低了接口协议的维护成本
- 提高了系统的可扩展性
不过也要注意,结构化输出不是银弹,它最适合以下场景:
- 输出数据结构相对固定
- 需要与其他系统集成
- 对数据准确性要求较高
对于创意生成、自由对话等场景,传统的文本输出可能更合适。
