Spring AI企业级应用实战(2):ChatClient、Advisor与Prompt Template构建统一调用层
文章摘要
上一篇完成了Spring Boot 4.1、Spring AI 2.0与DeepSeek V4接入,但业务代码仍然只是直接调用ChatClient。真实企业项目需要统一管理System Prompt、模板变量、租户信息、请求日志、调用耗时和响应格式。本文从零实现一个可复用的Spring AI调用层,系统讲解ChatClient、Prompt Template与Advisor Chain的协作方式,并实现自定义租户Advisor、日志Advisor、Prompt资源文件和统一AI Service。
一、为什么不能在每个Controller里直接调用ChatClient
最简单的调用:
@PostMapping("/chat")
String chat(@RequestBody ChatRequest request) {
return chatClient.prompt()
.user(request.message())
.call()
.content();
}
随着业务增加,会出现:
客服Controller
售前Controller
知识库Controller
报告Controller
代码助手Controller
每个类都开始重复:
- System Prompt;
- 模型参数;
- 日志;
- 用户身份;
- 租户信息;
- Prompt变量;
- 错误处理;
- Token统计;
- 输出转换。
最终形成:
业务逻辑
+模型调用
+Prompt
+权限
+日志
全部耦合在一起。
本篇目标是形成:
Controller
→ 领域AI Service
→ 统一EnterpriseAiClient
→ ChatClient
→ Advisor Chain
→ ChatModel
二、项目结构
spring-ai-enterprise
├── pom.xml
└── src/main
├── java/com/zyentor/ai
│ ├── config
│ │ └── AiClientConfig.java
│ ├── advisor
│ │ ├── TenantContextAdvisor.java
│ │ ├── AiLoggingAdvisor.java
│ │ └── AdvisorOrders.java
│ ├── client
│ │ ├── EnterpriseAiClient.java
│ │ └── SpringAiEnterpriseClient.java
│ ├── prompt
│ │ └── PromptResourceLoader.java
│ ├── service
│ │ └── CustomerAnswerService.java
│ └── web
│ └── CustomerAnswerController.java
└── resources
├── application.yml
└── prompts
├── common-system.st
└── customer-answer.st
三、核心依赖
org.springframework.ai
spring-ai-bom
2.0.0
pom
import
org.springframework.ai
spring-ai-starter-model-deepseek
org.springframework.boot
spring-boot-starter-webflux
org.springframework.boot
spring-boot-starter-validation
org.springframework.boot
spring-boot-starter-actuator
配置:
spring:
ai:
model:
chat: deepseek
deepseek:
api-key: ${DEEPSEEK_API_KEY}
chat:
model: deepseek-v4-flash
temperature: 0.2
max-tokens: 2048
四、ChatModel与ChatClient的职责
ChatModel
底层调用:
ChatResponse response =
chatModel.call(prompt);
负责接收Prompt、调用Provider、返回ChatResponse和处理模型级Options。
ChatClient
应用层调用:
String response = chatClient.prompt()
.system(...)
.user(...)
.advisors(...)
.call()
.content();
负责Fluent API、Prompt模板、Advisor、Memory、RAG、Tool Calling和结构化输出。
本项目以ChatClient作为统一调用入口。
五、定义Advisor顺序
AdvisorOrders.java:
package com.zyentor.ai.advisor;
public final class AdvisorOrders {
public static final int SECURITY = -1000;
public static final int TENANT = -800;
public static final int LOGGING = 800;
private AdvisorOrders() {
}
}
请求阶段:
Security
→ Tenant
→ Logging
→ Model
响应阶段反向返回。
六、定义上下文键
package com.zyentor.ai.advisor;
public final class AiContextKeys {
public static final String REQUEST_ID =
"requestId";
public static final String USER_ID =
"userId";
public static final String TENANT_ID =
"tenantId";
public static final String PROMPT_KEY =
"promptKey";
public static final String PROMPT_VERSION =
"promptVersion";
private AiContextKeys() {
}
}
不要散落字符串:
tenantId
tenant_id
TenantId
七、实现租户上下文Advisor
目标:
- 检查tenantId;
- 将租户信息加入上下文;
- 为后续RAG和工具权限提供依据;
- 不把租户数据直接拼进用户输入。
package com.zyentor.ai.advisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class TenantContextAdvisor
implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(
ChatClientRequest request,
CallAdvisorChain chain
) {
Object value = request.context()
.get(AiContextKeys.TENANT_ID);
String tenantId =
value == null
? null
: value.toString();
if (!StringUtils.hasText(tenantId)) {
throw new IllegalArgumentException(
"缺少tenantId"
);
}
ChatClientRequest updated =
request.mutate()
.context(
AiContextKeys.TENANT_ID,
tenantId
)
.build();
return chain.nextCall(updated);
}
@Override
public String getName() {
return "tenantContextAdvisor";
}
@Override
public int getOrder() {
return AdvisorOrders.TENANT;
}
}
具体Builder方法可能随2.0.x小版本发生变化,应以当前API为准;核心逻辑是:
读取Context
→ 校验
→ 构建新Request
→ 调用nextCall
八、实现日志Advisor
不要记录完整Prompt和API Key。
记录:
requestId
tenantId
userId
promptKey
promptVersion
duration
status
package com.zyentor.ai.advisor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.stereotype.Component;
@Component
public class AiLoggingAdvisor
implements CallAdvisor {
private static final Logger log =
LoggerFactory.getLogger(
AiLoggingAdvisor.class
);
@Override
public ChatClientResponse adviseCall(
ChatClientRequest request,
CallAdvisorChain chain
) {
long start = System.nanoTime();
Object requestId = request.context()
.get(AiContextKeys.REQUEST_ID);
Object tenantId = request.context()
.get(AiContextKeys.TENANT_ID);
try {
ChatClientResponse response =
chain.nextCall(request);
long durationMs =
(System.nanoTime() - start)
/ 1_000_000;
log.info(
"ai.call success requestId={} "
+ "tenantId={} durationMs={}",
requestId,
tenantId,
durationMs
);
return response;
}
catch (RuntimeException exception) {
long durationMs =
(System.nanoTime() - start)
/ 1_000_000;
log.error(
"ai.call failed requestId={} "
+ "tenantId={} durationMs={}",
requestId,
tenantId,
durationMs,
exception
);
throw exception;
}
}
@Override
public String getName() {
return "aiLoggingAdvisor";
}
@Override
public int getOrder() {
return AdvisorOrders.LOGGING;
}
}
生产日志应进一步加入模型、Token、Provider错误码、重试次数、结果状态和Trace ID。
九、将Prompt移出Java代码
common-system.st:
你是智元界企业AI助手。
规则:
1. 回答必须准确、清晰和可执行。
2. 不确定的信息必须明确说明。
3. 不得创造产品功能、数据或案例。
4. 涉及企业数据时遵守租户和用户权限。
5. 输出应优先使用简洁结构。
customer-answer.st:
客户问题:
可用背景信息:
请给出:
1. 直接结论;
2. 处理步骤;
3. 需要补充的信息。
变量使用 ``,避免JSON花括号冲突。
十、实现PromptResourceLoader
package com.zyentor.ai.prompt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
@Component
public class PromptResourceLoader {
public String load(Resource resource) {
try {
return StreamUtils.copyToString(
resource.getInputStream(),
StandardCharsets.UTF_8
);
}
catch (IOException exception) {
throw new IllegalStateException(
"读取Prompt资源失败:"
+ resource.getDescription(),
exception
);
}
}
}
十一、配置ChatClient
package com.zyentor.ai.config;
import com.zyentor.ai.advisor.AiLoggingAdvisor;
import com.zyentor.ai.advisor.TenantContextAdvisor;
import com.zyentor.ai.prompt.PromptResourceLoader;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
@Configuration
public class AiClientConfig {
@Bean
ChatClient enterpriseChatClient(
ChatClient.Builder builder,
TenantContextAdvisor tenantAdvisor,
AiLoggingAdvisor loggingAdvisor,
PromptResourceLoader loader,
@Value(
"classpath:/prompts/common-system.st"
)
Resource systemPrompt
) {
String systemText =
loader.load(systemPrompt);
return builder
.defaultSystem(systemText)
.defaultAdvisors(
tenantAdvisor,
loggingAdvisor
)
.templateRenderer(
StTemplateRenderer.builder()
.startDelimiterToken('')
.build()
)
.build();
}
}
通用Advisor适合在构建ChatClient时通过 defaultAdvisors(...) 注册。
十二、定义统一请求上下文
package com.zyentor.ai.client;
public record AiRequestContext(
String requestId,
String userId,
String tenantId
) {
public AiRequestContext {
if (
requestId == null
|| requestId.isBlank()
) {
throw new IllegalArgumentException(
"requestId不能为空"
);
}
if (
tenantId == null
|| tenantId.isBlank()
) {
throw new IllegalArgumentException(
"tenantId不能为空"
);
}
}
}
十三、定义EnterpriseAiClient
package com.zyentor.ai.client;
import java.util.Map;
import reactor.core.publisher.Flux;
public interface EnterpriseAiClient {
String call(
String promptKey,
String promptTemplate,
Map variables,
AiRequestContext context
);
Flux stream(
String promptKey,
String promptTemplate,
Map variables,
AiRequestContext context
);
}
十四、实现统一客户端
package com.zyentor.ai.client;
import java.util.Map;
import com.zyentor.ai.advisor.AiContextKeys;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class SpringAiEnterpriseClient
implements EnterpriseAiClient {
private final ChatClient chatClient;
public SpringAiEnterpriseClient(
@Qualifier("enterpriseChatClient")
ChatClient chatClient
) {
this.chatClient = chatClient;
}
@Override
public String call(
String promptKey,
String promptTemplate,
Map variables,
AiRequestContext context
) {
return chatClient.prompt()
.advisors(spec -> spec
.param(
AiContextKeys.REQUEST_ID,
context.requestId()
)
.param(
AiContextKeys.USER_ID,
context.userId()
)
.param(
AiContextKeys.TENANT_ID,
context.tenantId()
)
.param(
AiContextKeys.PROMPT_KEY,
promptKey
)
)
.user(user -> user
.text(promptTemplate)
.params(variables)
)
.call()
.content();
}
@Override
public Flux stream(
String promptKey,
String promptTemplate,
Map variables,
AiRequestContext context
) {
return chatClient.prompt()
.advisors(spec -> spec
.param(
AiContextKeys.REQUEST_ID,
context.requestId()
)
.param(
AiContextKeys.USER_ID,
context.userId()
)
.param(
AiContextKeys.TENANT_ID,
context.tenantId()
)
.param(
AiContextKeys.PROMPT_KEY,
promptKey
)
)
.user(user -> user
.text(promptTemplate)
.params(variables)
)
.stream()
.content();
}
}
注意:
自定义Advisor如果只实现CallAdvisor,流式接口不会自动执行该Advisor。
生产项目需提供对应StreamAdvisor,或明确同步与流式使用不同Advisor组合。
十五、定义领域Service
package com.zyentor.ai.service;
import java.util.Map;
import com.zyentor.ai.client.AiRequestContext;
import com.zyentor.ai.client.EnterpriseAiClient;
import com.zyentor.ai.prompt.PromptResourceLoader;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
public class CustomerAnswerService {
private final EnterpriseAiClient aiClient;
private final String promptTemplate;
public CustomerAnswerService(
EnterpriseAiClient aiClient,
PromptResourceLoader loader,
@Value(
"classpath:/prompts/customer-answer.st"
)
Resource promptResource
) {
this.aiClient = aiClient;
this.promptTemplate =
loader.load(promptResource);
}
public String answer(
String question,
String context,
AiRequestContext requestContext
) {
return aiClient.call(
"customer-answer",
promptTemplate,
Map.of(
"question",
question,
"context",
context
),
requestContext
);
}
}
业务Service只知道:
- 使用哪个Prompt;
- 传什么变量;
- 当前用户和租户;
- 需要什么返回。
它不再负责模型SDK细节。
十六、Controller
package com.zyentor.ai.web;
import java.util.UUID;
import com.zyentor.ai.client.AiRequestContext;
import com.zyentor.ai.service.CustomerAnswerService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/customer-ai")
public class CustomerAnswerController {
private final CustomerAnswerService service;
public CustomerAnswerController(
CustomerAnswerService service
) {
this.service = service;
}
@PostMapping("/answer")
public AnswerResponse answer(
@RequestHeader("X-User-Id")
String userId,
@RequestHeader("X-Tenant-Id")
String tenantId,
@Valid
@RequestBody
AnswerRequest request
) {
AiRequestContext context =
new AiRequestContext(
UUID.randomUUID()
.toString(),
userId,
tenantId
);
String answer = service.answer(
request.question(),
request.context(),
context
);
return new AnswerResponse(
context.requestId(),
answer
);
}
public record AnswerRequest(
@NotBlank
String question,
String context
) {
}
public record AnswerResponse(
String requestId,
String answer
) {
}
}
生产环境不应直接信任请求头中的用户和租户,应从认证上下文读取。
十七、Prompt Template变量治理
变量要有明确规则:
变量名称稳定
必填变量校验
最大长度
数据类型
是否敏感
是否可信
是否允许用户直接控制
不要把用户输入直接插进System Prompt。
推荐:
System:平台规则
User:用户问题
Context:外部数据
外部数据应标记为不可信内容。
十八、Advisor应该负责什么
适合Advisor:
- 日志;
- Trace;
- Memory;
- RAG;
- 权限上下文;
- 内容审核;
- 成本统计;
- 工具执行循环;
- 缓存。
不适合Advisor:
- 核心业务事务;
- 支付;
- 订单状态变更;
- 复杂领域规则;
- 最终权限决策。
Advisor负责调用链增强,业务规则仍应保留在领域Service中。
十九、Advisor顺序示例
-1000 SecurityAdvisor
-800 TenantContextAdvisor
-500 MemoryAdvisor
-200 QuestionAnswerAdvisor
800 AiLoggingAdvisor
模型调用
请求阶段从小到大,响应阶段反向。
多个Advisor不要使用相同order。
二十、同步和流式设计
同步:
CallAdvisor
→ call()
流式:
StreamAdvisor
→ stream()
如果日志、租户和安全规则必须同时覆盖两种模式,需要实现两个接口或封装公共逻辑。
二十一、单元测试Prompt
@SpringBootTest
class CustomerAnswerServiceTest {
@Test
void promptShouldContainVariables() {
String rendered =
renderPrompt(
Map.of(
"question",
"如何查询库存?",
"context",
"当前用户拥有仓储查询权限"
)
);
assertThat(rendered)
.contains("如何查询库存?");
assertThat(rendered)
.contains("仓储查询权限");
assertThat(rendered)
.doesNotContain("");
assertThat(rendered)
.doesNotContain("");
}
}
Prompt测试不需要每次调用模型。
二十二、测试Advisor顺序
可以建立测试Advisor记录事件:
tenant.request
logging.request
model
logging.response
tenant.response
断言顺序,避免新增Advisor后破坏链路。
二十三、错误处理
统一客户端应转换:
- 模型超时;
- 限流;
- 鉴权失败;
- 输出为空;
- 模板变量缺失;
- Advisor阻断;
- 结构化输出失败。
定义:
public enum AiClientError {
INVALID_PROMPT,
MODEL_TIMEOUT,
MODEL_UNAVAILABLE,
RATE_LIMITED,
ACCESS_DENIED,
INVALID_RESPONSE
}
不要把Provider异常原样返回前端。
二十四、生产环境需要补齐
本文完成统一调用层基础,生产上线还需要:
- StreamAdvisor;
- Prompt版本管理;
- Token和成本;
- 模型路由;
- 限流;
- 配额;
- 重试和熔断;
- OpenTelemetry;
- 数据脱敏;
- 内容安全;
- RAG权限;
- Chat Memory;
- Tool Calling;
- 自动化评测。
二十五、完整调用链
HTTP请求
→ 认证上下文
→ CustomerAnswerService
→ Prompt Resource
→ EnterpriseAiClient
→ ChatClient
→ Security Advisor
→ Tenant Advisor
→ Logging Advisor
→ DeepSeek ChatModel
→ 返回响应
→ 统一错误和Trace
总结
ChatClient、Advisor与Prompt Template三者的分工是:
ChatClient
→ 组织模型调用
Advisor
→ 增强和治理调用链
Prompt Template
→ 管理可复用指令和变量
在它们之上增加统一AI Service后,业务代码不再直接耦合模型、Prompt和治理逻辑。
下一篇将继续实现:
Spring AI流式输出深度实战:SSE、背压、取消与异常恢复。
延伸阅读
如果你正在关注企业级 AI 应用、Agent、RAG、MCP 与大模型工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。