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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。