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,设计结构时要考虑这个成本。

相关推荐
kong79069281 天前
Spring AI简介
人工智能·spring ai
小小工匠15 天前
大模型开发 - SpringAI之MCP Client开发:让Agent动态调用远程工具服务
spring ai·mcp·mcp client
@SmartSi17 天前
Spring AI 实战:通过 ChatMemory 构建有记忆的智能对话应用
llm·spring ai
小小工匠18 天前
大模型开发 - SpringAI之RAG应用效果评估
spring ai·rag效果评估
小小工匠18 天前
大模型开发 - SpringAI 之高级 RAG 组件
rag·spring ai
小楼v19 天前
⭐解锁RAG与Spring AI的实战应用(万字详细教学与完整步骤流程实践)
java·后端·rag·spring ai·ai大模型应用
小小工匠19 天前
大模型开发 - SpringAI之MySQL存储ChatMemory
mysql·spring ai
腾飞开源20 天前
104_Spring AI 干货笔记之开发时服务
人工智能·docker compose·容器管理·spring ai·testcontainers·开发时服务·ssl支持
小小工匠21 天前
大模型开发 - Spring AI 1.1.0 之基础使用:从零开始构建智能应用
spring ai