Spring AI Prompt 工程与结构化输出实战

本文覆盖 System Prompt 的设计方法、Spring AI 中三种 Prompt 管理方式、实用 Prompt 技巧,以及如何让大模型直接返回 Java 对象。面向正在用 Spring AI 做开发、想把 Prompt 写得更好并让 AI 输出可直接消费的结构化数据的 Java 开发者。


一、Prompt 工程:让 AI 听懂你的话

先搞清楚一件事------大模型的能力边界是固定的,但同一个模型,Prompt 写得好不好,输出质量差别非常大。Prompt Engineering 不是"锦上添花",而是拿到好结果的基本功。

1.1 System Prompt vs User Prompt

上一篇我们提到过消息结构里有 system、user、assistant 三种角色。在 Prompt 工程中最关键的就是前两个:

角色 定位 类比
System Prompt 全局行为设定,整个对话过程生效 员工入职培训手册
User Prompt 每次具体的任务请求 用户每次下达的具体指令

System Prompt 基本决定了一个 AI 应用的"调性"。同样的模型,有没有写好 System Prompt,效果差别很大。

1.2 好的 System Prompt 五要素

一个高质量的 System Prompt 通常包含五个要素:

要素 说明 示例
角色(Role) 告诉模型"你是谁" "你是一个专业的 Java 技术助手"
任务(Task) 你要做什么 "回答技术问题、帮助 debug 代码"
约束(Constraints) 什么能做、什么不能做 "不确定的内容要说明,不要编造"
输出格式(Format) 用什么格式返回 "使用 Markdown 格式,代码用代码块包裹"
示例(Examples) 给一两个示例让模型参考 Few-shot Prompting

不是每次都要五个全写齐,但至少要有角色 + 任务 + 约束,输出质量就能上一个台阶。

1.3 Spring AI 中的三种 Prompt 管理方式

方式一:defaultSystem ------ 在 ChatClient 构建时设定

适合全局固定的系统提示词,整个 Service 的所有调用都共享同一个 System Prompt:

java 复制代码
@Service
public class JavaTechService {

    private final ChatClient chatClient;

    public JavaTechService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("""
                        你是一个专业的 Java 技术助手。

                        职责:
                        - 回答 Java、Spring Boot、Spring AI 相关的技术问题
                        - 帮助用户 debug 代码
                        - 提供最佳实践建议

                        规则:
                        - 代码示例使用 Java 17+ 语法
                        - 回答简洁,不要过度解释
                        - 不确定的内容要说明,不要编造
                        - 非技术问题礼貌拒绝

                        输出格式:
                        - 使用 Markdown 格式
                        - 代码用代码块包裹
                        """)
                .build();
    }

    public String ask(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

这个例子把五要素中的四个都写进去了------角色、任务、约束、输出格式,写得清清楚楚。

方式二:动态 .system() ------ 每次调用动态设定

适合需要根据业务参数动态组装 System Prompt 的场景。注意 .param() 方法可以做变量替换:

java 复制代码
@Service
public class InlineTemplateService {

    private final ChatClient chatClient;

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

    public String ask(String role, String domain, String concept) {
        return chatClient.prompt()
                .system(s -> s.text("你是一个 {role},擅长 {domain} 领域的问题。")
                        .param("role", role)
                        .param("domain", domain))
                .user(u -> u.text("请解释 {concept} 的工作原理")
                        .param("concept", concept))
                .call()
                .content();
    }
}

调用时传入不同的 role 和 domain,同一个 Service 就能变成不同领域的专家。

方式三:外部模板文件 ------ .st 文件 + PromptTemplate

适合 Prompt 较长、需要版本管理、希望非开发人员也能修改的场景。Spring AI 使用 StringTemplate(.st 文件)作为模板引擎。

先看模板文件 prompts/code-review.st

复制代码
你是一个资深 {language} 工程师,有 {years} 年开发经验。

请 review 以下代码,找出:
1. Bug 和潜在风险
2. 性能优化点
3. 代码风格问题

代码:
{code}

请用中文回答,格式:每个问题独立列出,标注严重程度(高/中/低)。

然后在 Service 中加载模板:

java 复制代码
@Service
public class CodeReviewService {

    private final ChatClient chatClient;

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

    public String review(String language, String code) {
        PromptTemplate template = new PromptTemplate(
                new ClassPathResource("prompts/code-review.st")
        );

        Prompt prompt = template.create(Map.of(
                "language", language,
                "years", "10",
                "code", code
        ));

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

也可以不用外部文件,直接在代码里用 PromptTemplate

java 复制代码
public String translate(String text, String targetLanguage) {
    PromptTemplate template = new PromptTemplate("""
            请将下面这段文字翻译成 {targetLanguage},
            保持原文的语气和风格,不要意译:

            {text}
            """);

    Prompt prompt = template.create(Map.of(
            "targetLanguage", targetLanguage,
            "text", text
    ));

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

1.4 三种方式怎么选

方式 适用场景 优点 缺点
defaultSystem 全局固定角色 简单,一处设定全局生效 不够灵活,改了要重启
动态 .system() 角色/参数随请求变化 灵活,支持变量替换 Prompt 散落在代码里
.st 模板文件 Prompt 较长、需要版本管理 Prompt 和代码分离,好维护 多了一层文件管理

生产建议:简单场景用 defaultSystem,需要动态参数用 .system() 配合 .param(),Prompt 较复杂或需要多人协作时用 .st 模板文件。


二、Prompt 实践技巧

掌握了基本用法,接下来看几个让 Prompt 效果显著提升的实践技巧。

2.1 角色扮演 vs 裸指令

对比两种写法:

复制代码
裸指令:
  "帮我 review 这段代码"

角色扮演:
  "你是一个资深 Java 工程师,做 code review。
   找出 Bug、性能问题、代码风格问题,每个问题注明严重程度(高/中/低)。"

效果差别很大。角色扮演让模型的注意力集中在对应领域的知识上,回答的专业度和针对性明显更好。

看实际代码中的做法:

java 复制代码
public String codeReview(String language, String code) {
    return chatClient.prompt()
            .system(s -> s.text("""
                            你是一个资深 {language} 工程师,做 code review。
                            找出 Bug、性能问题、代码风格问题,每个问题注明严重程度(高/中/低)。
                            """)
                    .param("language", language))
            .user(u -> u.text("请 review 这段代码:\n```\n{code}\n```")
                    .param("code", code))
            .call()
            .content();
}

2.2 "先思考再回答"------提升推理质量

对于需要推理的任务,在 Prompt 中加一句"请先分析再给出结论",效果会好不少。这就是 Chain-of-Thought(思维链)的简化用法。

复制代码
差的写法:
  "这段代码有什么问题?"

好的写法:
  "请先逐行分析这段代码的执行逻辑,然后指出存在的问题。"

原理是:让模型在生成最终答案之前先生成推理过程,推理 Token 会影响后续 Token 的概率分布,相当于给了模型一个"打草稿"的机会。

2.3 知识库场景:教模型说"我不知道"

做企业知识库问答时,一个常见问题是模型在知识库里找不到答案时会"编"一个。解决方法很简单------在 System Prompt 里明确告诉模型:不确定就说不知道。

java 复制代码
public String domainQA(String domain, String question) {
    return chatClient.prompt()
            .system(s -> s.text("""
                            你是一个 {domain} 领域的专家顾问。
                            只回答和 {domain} 相关的问题,其他问题拒绝回答。
                            不确定的内容要明确说明,不要编造。
                            """)
                    .param("domain", domain))
            .user(question)
            .call()
            .content();
}

这三条约束缺一不可:限定领域、拒绝越界、禁止编造。

2.4 Prompt 版本管理建议

实践 说明
存文件,不硬编码 长 Prompt 用 .st 文件管理,改 Prompt 不需要改代码
有意义的命名 code-review.sttranslate.st,一看就知道干什么
纳入版本控制 Prompt 文件放 resources/prompts/ 目录,跟代码一起进 Git
分环境管理 开发环境可以输出详细推理过程,生产环境只要最终结果

三、结构化输出:让 AI 返回 Java 对象

3.1 为什么需要结构化输出

大模型默认返回的是自然语言文本------对人类友好,但对程序不友好。当 AI 的输出需要被下游系统消费(存数据库、调接口、做聚合展示)时,我们需要的是结构化数据,而不是一段文字。

复制代码
// 我们不想要这个:
"这部电影是《星际穿越》,导演是克里斯托弗·诺兰,2014年上映,属于科幻片..."

// 我们想要这个:
{
  "title": "星际穿越",
  "director": "克里斯托弗·诺兰",
  "year": 2014,
  "genre": "科幻"
}

3.2 Spring AI 的结构化输出原理

Spring AI 的做法非常巧妙,整个过程是自动的:

复制代码
① 分析你传入的 Java 类(Record / POJO)
② 根据字段名、类型、注解,自动生成 JSON Schema
③ 把 JSON Schema 附加到发送给模型的 Prompt 中
④ 模型按照 Schema 返回 JSON
⑤ Spring AI 自动反序列化为 Java 对象

开发者只需要做一件事:定义好 Java 类,调用 .entity(YourClass.class)

3.3 基础用法:返回单个对象

最简单的例子------让 AI 推荐一部电影并返回结构化数据:

java 复制代码
@RestController
@RequestMapping("/movie")
public class MovieController {

    record MovieRecommendation(
            String title,
            String director,
            int year,
            String genre,
            String reason
    ) {
    }

    private final ChatClient chatClient;

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

    @GetMapping("/recommend")
    public MovieRecommendation recommend() {
        return chatClient.prompt()
                .user("推荐一部经典科幻电影")
                .call()
                .entity(MovieRecommendation.class);
    }
}

关键就一行:.entity(MovieRecommendation.class)。Spring AI 会根据 Record 的字段名自动生成 JSON Schema,模型返回的 JSON 直接变成 Java 对象。

3.4 返回列表:ParameterizedTypeReference

当需要模型返回多个对象时,用 ParameterizedTypeReference 来处理泛型类型擦除的问题:

java 复制代码
@RestController
@RequestMapping("/book")
public class BookController {

    record BookSummary(String title, String author, String oneLinerSummary) {}

    private final ChatClient chatClient;

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

    @GetMapping("/list")
    public List<BookSummary> list() {
        return chatClient.prompt()
                .user("列出 5 本经典的 Java 技术书籍")
                .call()
                .entity(new ParameterizedTypeReference<List<BookSummary>>() {});
    }
}

注意:这里不能直接写 .entity(List.class),因为 Java 泛型擦除后 Spring AI 不知道 List 里装的是什么类型。必须用 ParameterizedTypeReference 来保留泛型信息。

3.5 用注解增强 Schema 描述

字段名有时不够表意,模型可能猜不准你想要什么。用 Jackson 注解可以给 Schema 加上更精确的描述:

java 复制代码
record ProductReview(
        @JsonProperty("product_name")
        @JsonPropertyDescription("商品名称,从评论中提取")
        String productName,

        @JsonProperty("sentiment")
        @JsonPropertyDescription("情感倾向:POSITIVE(正面)、NEGATIVE(负面)、NEUTRAL(中性)")
        String sentiment,

        @JsonProperty("score")
        @JsonPropertyDescription("评分,1-5分,根据评论语气推断")
        int score,

        @JsonProperty("key_points")
        @JsonPropertyDescription("评论中提到的关键点,最多3条")
        List<String> keyPoints,

        @JsonProperty("improvement_suggestions")
        @JsonPropertyDescription("改进建议,如果没有则为空列表")
        List<String> improvementSuggestions
) {
}
注解 作用
@JsonProperty 指定 JSON 字段名(解决 Java 驼峰 vs JSON 下划线的问题)
@JsonPropertyDescription 给字段加描述,模型能"看懂"你要什么

@JsonPropertyDescription 的内容会被写进 JSON Schema,发送给模型。描述越清晰,模型输出越准确。

3.6 用 Enum 约束分类字段

当某个字段只能是有限的几个值时,用 Enum 比用 String 好------Spring AI 会把 Enum 的所有值写进 Schema,模型只会从这些值里选:

java 复制代码
enum Priority {LOW, MEDIUM, HIGH, CRITICAL}

enum Category {BUG, FEATURE, IMPROVEMENT, DOCUMENTATION}

record IssueClassification(
        String title,
        Category category,
        Priority priority,
        String assignTo,
        String reason
) {
}

调用时配合 System Prompt 效果更好:

java 复制代码
@PostMapping("/classify")
public IssueClassification classify(@RequestBody IssueRequest request) {
    return chatClient.prompt()
            .system("你是项目经理,负责对 Issue 进行分类和优先级评估。")
            .user("请对这个 Issue 进行分类:" + request.description())
            .call()
            .entity(IssueClassification.class);
}

3.7 复杂嵌套结构

结构化输出支持嵌套对象和 List,比如简历分析:

java 复制代码
record SkillLevel(String name, String level) {
}

record ResumeAnalysis(
        String name,
        String email,
        String summary,
        List<SkillLevel> technicalSkills,
        List<String> workHistory,
        String overallAssessment
) {
}

@PostMapping("/analyze")
public ResumeAnalysis analyze(@RequestBody ResumeRequest request) {
    return chatClient.prompt()
            .system("你是一个专业的HR,帮助分析候选人简历。字段为空时填null,技能等级只能是:入门/熟练/精通。")
            .user("分析这份简历:\n" + request.content())
            .call()
            .entity(ResumeAnalysis.class);
}

这里有几个值得注意的点:

  • List<SkillLevel> 嵌套对象列表,Spring AI 能正确处理
  • System Prompt 里约束了"技能等级只能是:入门/熟练/精通",配合结构化输出效果更可控
  • "字段为空时填 null"------这个约束很重要,否则模型可能编一个值出来

四、手动转换:BeanOutputConverter

4.1 什么时候需要手动

前面的 .entity() 方法是全自动的------生成 Schema、附加到 Prompt、反序列化一条龙。但有些场景你需要更多控制权:

场景 建议
标准场景,快速出活 .entity() 自动方式
需要自定义 Prompt 格式 BeanOutputConverter 手动方式
需要在 Prompt 中精确控制 Schema 位置 BeanOutputConverter 手动方式
需要调试或查看生成的 Schema BeanOutputConverter 手动方式

4.2 手动方式三步走

java 复制代码
@PostMapping("/analyze")
public ResumeAnalysis analyze(@RequestBody ResumeRequest request) {
    // 第一步:创建转换器,自动生成 JSON Schema
    BeanOutputConverter<ResumeAnalysis> converter =
            new BeanOutputConverter<>(ResumeAnalysis.class);

    // 第二步:手动拼装 Prompt,把 Schema 放进去
    String prompt = """
            分析这份简历,按照以下 JSON 格式输出:
            %s

            简历内容:%s
            """.formatted(converter.getFormat(), request.content());

    // 第三步:调用模型,拿到纯文本 JSON,手动转换
    String jsonResponse = chatClient.prompt()
            .user(prompt)
            .call()
            .content();

    return converter.convert(jsonResponse);
}

三个关键方法:

方法 作用
converter.getFormat() 获取自动生成的 JSON Schema 字符串
chatClient...content() 拿到模型返回的原始 JSON 文本
converter.convert() 把 JSON 文本反序列化为 Java 对象

开发调试时可以打印 converter.getFormat() 看看生成的 Schema 长什么样,有助于理解 Spring AI 在背后做了什么。


五、实战:文章智能分析

把前面学的 System Prompt + 结构化输出组合起来,看一个贴近真实业务的例子------文章智能分析接口。

5.1 定义分析结果结构

java 复制代码
record ArticleAnalysis(
        @JsonPropertyDescription("文章标题,如果没有则根据内容生成")
        String title,

        @JsonPropertyDescription("文章类型:NEWS/OPINION/TUTORIAL/RESEARCH/OTHER")
        String type,

        @JsonPropertyDescription("100字以内的摘要")
        String summary,

        @JsonPropertyDescription("关键词列表,最多5个")
        List<String> keywords,

        @JsonPropertyDescription("文章的主要观点,最多3条")
        List<String> mainPoints,

        @JsonPropertyDescription("情感倾向:POSITIVE/NEGATIVE/NEUTRAL")
        String sentiment,

        @JsonPropertyDescription("可读性评分,1-10分,10分最易读")
        int readabilityScore
) {
}

每个字段都用 @JsonPropertyDescription 写清楚了约束------类型范围、长度限制、数量上限。这些描述会变成 JSON Schema 的一部分发给模型。

5.2 文章分析接口

java 复制代码
@RestController
@RequestMapping("/api/article")
public class ArticleAnalysisController {

    private final ChatClient chatClient;

    public ArticleAnalysisController(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("""
                        你是一个专业的文章分析助手。
                        分析准确,不要添加原文没有的内容。
                        """)
                .build();
    }

    @PostMapping("/analyze")
    public ArticleAnalysis analyze(@RequestBody ArticleRequest request) {
        return chatClient.prompt()
                .user("请分析以下文章:\n\n" + request.content())
                .call()
                .entity(ArticleAnalysis.class);
    }

    record ArticleRequest(String content) {
    }
}

System Prompt 里的"不要添加原文没有的内容"这条约束很关键------防止模型在分析时"发挥想象力"。

5.3 关键词提取接口

同一个 Controller 里还可以做关键词提取,直接返回 List<String>

java 复制代码
@PostMapping("/keywords")
public List<String> extractKeywords(@RequestBody ArticleRequest request) {
    return chatClient.prompt()
            .user("从以下文章中提取5个最重要的关键词:\n\n" + request.content())
            .call()
            .entity(new ParameterizedTypeReference<List<String>>() {
            });
}

这个例子展示了结构化输出不一定要用复杂对象------简单的 List<String> 也行。


六、写在最后

API 速查表

需求 API 示例
全局 System Prompt builder.defaultSystem(...) 构建时设定角色
动态 System Prompt .system(s -> s.text(...).param(...)) 按请求动态调整
外部模板文件 new PromptTemplate(new ClassPathResource(...)) 加载 .st 文件
内联模板 new PromptTemplate("...") + .create(Map.of(...)) 代码内模板
结构化输出(单对象) .entity(SomeClass.class) 返回一个 Java 对象
结构化输出(列表) .entity(new ParameterizedTypeReference<List<T>>() {}) 返回对象列表
手动转换 BeanOutputConverter 需要自定义 Prompt 格式时
字段描述增强 @JsonPropertyDescription 让 Schema 更精确
字段名映射 @JsonProperty 驼峰转下划线等
约束分类值 enum 字段 限定取值范围

生产环境的几点建议

  1. Prompt 和代码分离 :长 Prompt 用 .st 文件管理,放在 resources/prompts/ 目录下,纳入 Git 版本控制。
  2. 结构化输出优先用 .entity() :自动方式覆盖 90% 的场景,只有需要精细控制时才用 BeanOutputConverter
  3. 善用 @JsonPropertyDescription:字段名不够表意时,描述越详细,模型输出越准确。
  4. Enum 约束分类:凡是"只能从几个选项里选"的字段,用 Enum 而不是 String,能避免模型输出意料之外的值。
  5. System Prompt 加防御性约束:尤其是知识库场景,"不确定就说不知道""不要添加原文没有的内容"这类约束必须加上。
  6. 注意 Token 消耗:结构化输出会在 Prompt 中附加 JSON Schema,复杂结构的 Schema 本身就会占用不少 Token,设计结构时要考虑这个成本。

相关推荐
种时光的人17 小时前
Spring AI 工具调用(ToolCalling)完整使用教程
java·人工智能·后端·spring·ai·java开发·spring ai
haiyangyiba3 天前
学习Spring Ai的摸索实践
学习·spring ai
LucaJu3 天前
分布式智能体|A2A Agent实战
agent·智能体·spring ai·a2a·spring ai alibaba
gujunge4 天前
Spring with AI (6): 记忆保持——会话与长期记忆
ai·大模型·llm·openai·qwen·rag·spring ai·deepseek
种时光的人4 天前
Java+AI 无缝衔接:Spring AI 聊天模型入门到精通
java·人工智能·spring·ai·spring ai
光仔December7 天前
【从0学习Spring AI Alibaba】3、阿里云百炼平台API Key 申请指南
人工智能·ai大模型·spring ai·阿里云百炼·apikey申请
光仔December7 天前
【从0学习Spring AI Alibaba】2、Spring AI Alibaba版本选型及环境搭建
人工智能·大模型·saa·spring ai·ai alibaba
空城.依旧8 天前
找到了一个学习Spring AI的教程,感觉还不错,示例可以直接运行,学习Spring AI推荐
spring ai
超级无敌大好人9 天前
程序运行卡住排查
java·spring ai·qdrant
gujunge10 天前
Spring with AI (5): 搜索扩展——向量数据库与RAG(下)
ai·大模型·llm·openai·qwen·rag·spring ai·deepseek