
Spring AI 2.0.0 Prompt 最小 Demo:system、user、template 到底怎么分工
很多 Spring AI Demo 一开始都是这样写的:
java
chatClient.prompt()
.user("你是一个 Java 专家,请帮我解释这段代码,回答要简洁:" + code)
.call()
.content();
能跑。
但项目一复杂,Prompt 很快就会变成一团字符串:
java
String prompt = "你是一个 Java 专家。" +
"你的任务是解释代码。" +
"回答要简洁,按步骤输出。" +
"如果代码有问题,要指出来。" +
"不要输出无关内容。" +
"代码如下:\n" + code;
角色、任务、变量、输出格式、约束条件,全塞进 user。
短期能跑,长期很难改。
下一次你想改回答风格,得去字符串里找。
再下一次你想换输出格式,还得继续拼接。
Prompt 不是不能写长。
真正麻烦的是:
不同层级的内容混在一起。
这篇就解决一个问题:
Spring AI 2.0.0 里,
system、user、template到底怎么分工?
先记住一句话:
text
system 放长期规则
user 放本次问题
template 放可复用结构
后面所有代码都围绕这句话展开。
一、先准备 ChatClient
这篇示例按 Spring AI 2.0.0 写。
默认你已经有一个能正常调用模型的 Spring Boot 项目。
如果你已经接好了 ChatClient,可以直接跳到第二部分。
如果接着前面的 DeepSeek Demo 做,关键版本是:
xml
<properties>
<java.version>17</java.version>
<spring-ai.version>2.0.0</spring-ai.version>
</properties>
依赖是:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
如果你的项目是手写 pom.xml,别忘了通过 spring-ai-bom 管理 Spring AI 版本:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
配置类似这样:
yaml
spring:
ai:
deepseek:
api-key: ${DEEPSEEK_API_KEY}
chat:
model: deepseek-v4-flash
temperature: 0.3
注意,Spring AI 2.0.0 里,DeepSeek 的模型和温度配置直接放在 spring.ai.deepseek.chat 下面。
如果你从 1.x 示例迁移过来,不要继续写成 spring.ai.deepseek.chat.options.model。
这个系列里的示例模型都统一用 deepseek-v4-flash。
如果用 IDEA 运行,在 Run/Debug Configurations 里加环境变量:
text
DEEPSEEK_API_KEY=你的 API Key
这篇重点不是模型配置,而是 Prompt 怎么组织。
二、System:放长期规则
system 适合放稳定规则。
它不是随便写一句:
text
你是一个 Java 专家。
这句话有用,但太粗。
更好的 system,通常会把长期规则说清楚:
text
你是谁
主要负责什么
什么能做,什么不能做
回答风格是什么
信息不足时怎么处理
输出结构是什么
我们做业务系统不用写得很夸张。
但有一个原则很重要:
每次都应该生效的规则,放到
system。
比如做一个 Java 代码助手:
java
chatClient.prompt()
.system("""
你是一个资深 Java 工程师,负责解释和审查 Java/Spring 代码。
请用简洁中文回答,先给结论,再解释原因。
如果代码存在风险,请指出触发条件和建议改法。
如果信息不足,请说明还需要补充什么,不要编造项目背景。
不要输出与代码无关的泛泛建议。
""")
.user("这段代码有什么问题:\n" + code)
.call()
.content();
这里的 system 不关心这次传进来的代码是什么。
它只规定一件事:
无论用户问哪段代码,都按这套规则回答。
如果每次请求都要重复写这一段,就应该提到 ChatClient 默认配置里:
java
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
你是一个资深 Java 工程师,负责解释和审查 Java/Spring 代码。
请用简洁中文回答,先给结论,再解释原因。
如果代码存在风险,请指出触发条件和建议改法。
如果信息不足,请说明还需要补充什么,不要编造项目背景。
不要输出与代码无关的泛泛建议。
""")
.build();
}
}
这样业务代码里就不用反复写固定规则。
记住:
system管"长期怎么回答",不是管"这次问什么"。
也别把 system 写成产品说明书。
它应该稳定,但不应该臃肿。和当前场景无关的公司介绍、技术栈罗列、口号,都可以删掉。
三、User:放本次输入
user 放当前这一次请求。
也就是:
text
用户这次问了什么
这次要处理什么数据
这次任务有什么特殊要求
比如解释代码:
java
chatClient.prompt()
.user("请解释这段代码:\n\n" + code)
.call()
.content();
如果下一次要生成单元测试,user 就换成:
java
chatClient.prompt()
.user("请为这段代码生成 JUnit 5 单元测试:\n\n" + code)
.call()
.content();
长期角色和回答风格,仍然由 defaultSystem 管。
所以不推荐这样:
java
chatClient.prompt()
.user("""
你是一个资深 Java 工程师。
回答要简洁、准确。
输出格式:先给结论,再解释原因。
请解释这段代码:
""" + code)
.call()
.content();
更推荐这样:
java
chatClient.prompt()
.user("请解释这段代码:\n\n" + code)
.call()
.content();
前提是 ChatClient 已经配置了 defaultSystem。
这样分工就很清楚:
text
system:长期规则
user:本次问题
四、Template:放可复用结构
如果 Prompt 有固定格式,并且要填多个变量,就适合用 Template。
比如"解释代码"这个任务,每次结构都差不多:
text
请解释以下 {language} 代码:
文件路径:{filePath}
代码内容:
{code}
重点关注:{focus}
如果用字符串拼接,会变成这样:
java
String prompt = "请解释以下 " + language + " 代码:\n" +
"文件路径:" + filePath + "\n\n" +
"代码内容:\n" + code + "\n\n" +
"重点关注:" + focus;
变量一多,就很难读。
Spring AI 的 ChatClient 可以直接在 user 里写模板:
java
chatClient.prompt()
.user(u -> u.text("""
请解释以下 {language} 代码:
文件路径:{filePath}
代码内容:
{code}
重点关注:{focus}
""")
.param("language", language)
.param("filePath", filePath)
.param("code", code)
.param("focus", focus))
.call()
.content();
这里的 {language}、{filePath}、{code}、{focus} 都是模板变量。
Spring AI 会在调用前替换成真实值。
在 Spring AI 2.0.0 里,ChatClient 默认使用 StTemplateRenderer 渲染 user 和 system 文本里的变量。默认变量语法就是 {变量名}。
所以 Template 适合这些场景:
text
固定格式
多个变量
多处复用
后续可能调整 Prompt 结构
但不要为了模板而模板。
如果只有一个变量,直接写:
java
.user("请解释这段代码:\n\n" + code)
就够了。
五、完整例子
下面写一个最小接口:接收一段代码,让模型解释它。
java
package com.example.springaideepseekdemo.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
public class CodeExplainController {
private final ChatClient chatClient;
public CodeExplainController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
你是一个资深 Java 工程师,负责解释和审查 Java/Spring 代码。
请用简洁中文回答,先给结论,再解释原因。
如果代码存在风险,请指出触发条件和建议改法。
如果信息不足,请说明还需要补充什么,不要编造项目背景。
不要输出与代码无关的泛泛建议。
输出格式:
1. 代码功能概述
2. 关键逻辑解释
3. 潜在问题或优化建议
""")
.build();
}
@PostMapping("/code/explain")
public String explain(
@RequestParam(defaultValue = "Java") String language,
@RequestParam(defaultValue = "unknown") String filePath,
@RequestParam(defaultValue = "可读性、性能、潜在 bug") String focus,
@RequestBody String code) {
return chatClient.prompt()
.user(u -> u.text("""
请解释以下 {language} 代码。
文件路径:{filePath}
代码内容:
{code}
重点关注:{focus}
""")
.param("language", language)
.param("filePath", filePath)
.param("code", code)
.param("focus", focus))
.call()
.content();
}
@PostMapping(value = "/code/explain/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> explainStream(
@RequestParam(defaultValue = "Java") String language,
@RequestParam(defaultValue = "unknown") String filePath,
@RequestParam(defaultValue = "可读性、性能、潜在 bug") String focus,
@RequestBody String code) {
return chatClient.prompt()
.user(u -> u.text("""
请解释以下 {language} 代码。
文件路径:{filePath}
代码内容:
{code}
重点关注:{focus}
""")
.param("language", language)
.param("filePath", filePath)
.param("code", code)
.param("focus", focus))
.stream()
.content();
}
}
普通输出这样测:
bash
curl -X POST "http://localhost:8080/code/explain?filePath=UserService.java" \
-H "Content-Type: text/plain" \
--data-binary 'public String getName(User user) { return user.getName(); }'
流式输出这样测:
bash
curl -N -X POST "http://localhost:8080/code/explain/stream?filePath=UserService.java" \
-H "Content-Type: text/plain" \
--data-binary 'public String getName(User user) { return user.getName(); }'
-N 的作用是关闭 curl 的缓冲,方便你看到模型一段一段返回。
这里没有在 URL 里直接写中文参数。
如果你要在 query string 里传中文,需要先做 URL 编码,否则新版本 Tomcat 可能直接返回 400 Bad Request。
正常情况下,模型会指出这段代码的核心风险:
text
如果 user 为 null,调用 user.getName() 会触发 NullPointerException。
这个例子里,分工很清楚:
text
defaultSystem:角色、风格、输出格式
user template:本次任务结构
param:本次变量
以后要调整"回答风格",改 defaultSystem。
要调整"这类任务的结构",改 user 模板。
要处理不同代码,只改参数。
六、几个常见坑
1. 把所有东西都塞进 user
这是最常见的问题。
user 里既有"你是专家",又有"回答要简洁",还有"请分析这段代码"。
短期能跑,长期一定难维护。
先问自己一句:
这句话是不是每次请求都一样?
如果是,就优先考虑放到 system。
2. system 写得太长
system 不是越长越好。
很多人会写成:
text
你是 Java 专家,精通 Spring、MyBatis、Redis、Kafka、微服务、DDD......
看起来很专业,但很多身份描述并不直接约束输出,还会浪费 token。
更好的写法是给清晰规则:
text
回答要简洁。
先给结论,再解释原因。
不确定时说明不确定,不要编造。
3. 变量名太随意
不推荐:
java
.user(u -> u.text("分析 {a} 和 {b},关注 {c}")
.param("a", oldCode)
.param("b", newCode)
.param("c", focus))
更推荐:
java
.user(u -> u.text("分析 {oldCode} 和 {newCode},关注 {focus}")
.param("oldCode", oldCode)
.param("newCode", newCode)
.param("focus", focus))
变量名就是文档。
以后回头看代码,不用猜 a、b、c 分别是什么。
4. JSON 示例和模板变量冲突
Spring AI 默认用 {} 识别模板变量。
如果 Prompt 里要放 JSON 示例,就可能被误识别成模板变量。
比如:
text
请返回:
{
"summary": "...",
"risk": "..."
}
这时可以给这次调用配置 TemplateRenderer,把变量分隔符换成 <变量名> 这类写法。
比如继续用默认的 StTemplateRenderer,但把分隔符从 {} 换成 < 和 >。
这个点不用一开始就展开,但要知道:模板里的 {} 不是普通字符,它有变量含义。
5. 忘了约束输出格式
你想让模型按固定格式回答,就要明确告诉它。
比如:
java
.system("""
你是 Java 代码审查助手。
输出格式:只返回 JSON,不要输出 Markdown。
字段包括 summary、risks、suggestions。
""")
不过,真正要让模型稳定返回 Java 对象,后面还要用结构化输出。
这块更适合单独写一篇。
写在最后
Spring AI 里的 Prompt 管理,不用想复杂。
先记住三句话:
text
system 管长期规则
user 管本次问题
template 管可复用结构
Prompt 不是越长越好。
真正重要的是分工清楚、上下文干净、规则稳定。
这一步做好了,后面的结构化输出、RAG、Tool Calling,都会更容易维护。
配套代码按文章编号放在对应分支,方便对照运行。完整系列也会同步整理在公众号「AI Agent 实战有术」。