Skip to content

Spring AI ChatClient同步、流式与Advisor调用原理

ChatClient 是 Spring AI 面向应用层的模型调用门面。它用 Fluent API 组合默认配置、System/User Message、Prompt Template、ChatOptions、Advisor、Tool 和输出转换,再把请求交给底层 ChatModel

只会写 chatClient.prompt().user(question).call().content(),只能跑通最小 Demo。生产中还必须理解默认值怎样合并、Advisor 顺序怎样改变 Prompt、完整 ChatResponse 包含什么、流式取消怎样传播、结构化解析失败怎样处理,以及为什么 ChatClient 不应直接散落在 Controller 和领域代码中。

学习目标

完成本页后,你应该能够:

  1. 区分 ChatClient.BuilderChatClient、Request Spec 和 Response Spec。
  2. 解释 ChatClientChatModelPromptMessageChatOptionsChatResponse 的关系。
  3. 说明默认 system/options/advisors/tools 与请求级配置怎样组合。
  4. 写出同步文本、完整响应、结构化对象和流式 SSE Demo。
  5. 解释 .call().stream() 的执行、延迟和错误边界。
  6. 解释 Advisor 前置/后置链和顺序为什么重要。
  7. 理解 Memory、RAG、Tool Calling 在 ChatClient 链中的位置。
  8. 设计业务封装、版本记录、输入校验、超时、成本和审计。
  9. 排查 Bean 注入失败、回答跑题、空内容、流式中断、上下文超长和 Token 暴涨。

一、ChatClient不是ChatModel

mermaid
flowchart TD
    A["业务Service"] --> B["ChatClient:应用Fluent API"]
    B --> C["Prompt、Message、Options和Advisor"]
    C --> D["ChatModel:底层模型抽象"]
    D --> E["Provider Adapter"]
    E --> F["远程模型API/本地推理服务"]
对象主要职责
ChatClient.Builder构建带默认配置的 ChatClient
ChatClient每次调用创建请求规格、执行 Advisor 和模型调用
ChatModel接受 Prompt,返回 ChatResponse 的底层抽象
PromptMessage 列表和 ChatOptions 的完整输入
MessageSystem/User/Assistant/Tool 等角色内容
ChatResponseGeneration、模型输出、Usage、Finish Reason等

ChatClient 降低了 Provider 协议耦合,但不保证不同模型在 Tool、JSON、多模态和参数上完全等价。

二、为什么需要ChatClient

自己拼HTTPChatClient提供的抽象仍需业务负责
手写厂商请求体Prompt/Message/Options场景和规则
手写同步和SSE解析call/stream外层SSE治理和取消
每处拼System PromptdefaultSystem和模板Prompt版本/评估
手工插RAG/MemoryAdvisor链ACL、Token预算
手工工具循环Tool Calling集成权限、幂等、确认
手工解析JSONentity/ConverterSchema和业务校验

三、Builder什么时候出现

Provider Starter 自动配置满足条件后,Spring Boot 通常创建 ChatModel 以及可注入的 ChatClient.Builder。业务配置使用 Builder 创建一个或多个按场景划分的 ChatClient:

java
@Configuration
public class ChatClientsConfiguration {

    @Bean
    ChatClient knowledgeChatClient(ChatClient.Builder builder) {
        return builder
                .defaultSystem("""
                        你是企业知识库助手。
                        只能根据已授权资料回答。
                        资料不足时明确拒答。
                        """)
                .build();
    }
}

建议创建场景 Client,例如 knowledge、extract、customerService,而不是一个万能 ChatClient 承载所有默认 Prompt/Advisor/Tool。

四、Builder、Client和请求对象生命周期

mermaid
flowchart TD
    A["应用启动注入Builder"] --> B["配置默认System/Options/Advisor"]
    B --> C["build得到可复用ChatClient"]
    C --> D["请求1调用prompt产生Request Spec"]
    C --> E["请求2调用prompt产生独立Request Spec"]
    D --> F["call或stream执行"]
    E --> G["call或stream执行"]

ChatClient 设计用于作为 Bean 复用;每次 prompt() 构建请求级 Spec。不要把某个请求的 mutable Map、用户身份或 conversationId 保存到单例字段中,否则并发用户会串数据。

具体线程安全保证以当前 Spring AI 版本实现和自定义 Advisor 为准。自定义 Advisor 也必须避免共享可变状态。

五、默认配置和请求级配置

常见默认项:

  • defaultSystem。
  • defaultUser。
  • defaultOptions。
  • defaultAdvisors。
  • defaultTools/Tool callbacks。

请求级调用可添加/覆盖部分配置:

java
String answer = chatClient.prompt()
        .system("本次只返回三条结论。")
        .user("解释线程池拒绝策略")
        .options(OpenAiChatOptions.builder()
                .temperature(0.1)
                .maxTokens(600)
                .build())
        .call()
        .content();

不同字段是覆盖、追加还是合并,可能随 API 和对象类型不同。不要凭感觉认为请求 system 一定完全替换 defaultSystem;通过单元测试或调试最终 Prompt 验证当前版本行为。

六、Prompt由什么组成

mermaid
flowchart TD
    A["Prompt"] --> B["System Message"]
    A --> C["User Message"]
    A --> D["Assistant历史"]
    A --> E["Tool Result"]
    A --> F["ChatOptions"]

System Message

定义任务、边界、输出和拒答策略。它不是后端权限边界,也不能防住所有 Prompt Injection。

User Message

用户当前输入。必须限制长度、内容类型和敏感信息,不能直接拼进系统规则文本。

Assistant Message

多轮历史中的模型回答。模型 API 通常无状态,历史需要应用重新发送。

Tool Message/Result

工具执行结果回传模型。必须最小化和脱敏。

七、Prompt Template参数绑定

java
String answer = chatClient.prompt()
        .user(user -> user.text("""
                请解释以下主题:{topic}

                读者水平:{level}
                输出要求:先结论,再原理,再给Demo。
                """)
                .param("topic", topic)
                .param("level", level))
        .call()
        .content();

模板参数仍是不可信数据。绑定可以避免手工字符串拼接混乱,但不等于防 Prompt Injection。用户输入与系统规则应有明确标记,Tool和权限由后端控制。

八、同步调用全过程

mermaid
flowchart TD
    A["Service调用chatClient.prompt"] --> B["合并默认值与请求配置"]
    B --> C["渲染Message模板和参数"]
    C --> D["构造Prompt"]
    D --> E["执行Advisor请求链"]
    E --> F["ChatModel转Provider请求"]
    F --> G["HTTP发送并等待完整响应"]
    G --> H["解析ChatResponse和Metadata"]
    H --> I["执行Advisor响应链"]
    I --> J["Response Spec提取content/entity/response"]

.call() 表达阻塞式业务调用:当前请求要等完整响应或错误。模型越慢、输出越长,占用连接和资源越久。

九、同步文本Demo

java
@Service
public class SummaryService {

    private final ChatClient chatClient;

    public SummaryService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public String summarize(String article, String requestId) {
        if (article == null || article.isBlank()) {
            throw new IllegalArgumentException("文章不能为空");
        }

        if (article.length() > 20_000) {
            throw new IllegalArgumentException("文章过长,请使用异步摘要");
        }

        String content = chatClient.prompt()
                .system("""
                        你是企业内容摘要助手。
                        只根据输入文章总结,不补充外部事实。
                        """)
                .user(user -> user.text("""
                        ARTICLE:
                        {article}

                        输出三部分:核心结论、关键依据、风险提醒。
                        """)
                        .param("article", article))
                .call()
                .content();

        if (content == null || content.isBlank()) {
            throw new IllegalStateException("模型返回空摘要");
        }

        return content;
    }
}

JDK 8 没有文本块和 String.isBlank(),需使用普通字符串和 trim().isEmpty()

十、为什么要读取完整ChatResponse

java
ChatResponse response = chatClient.prompt()
        .user(question)
        .call()
        .chatResponse();

完整响应可能提供:

  • 实际模型。
  • 一个或多个 Generation。
  • Assistant Message和Tool Call。
  • Finish Reason。
  • Prompt/Completion/Total Token。
  • Provider Request ID和扩展Metadata。

如果 .content() 是空,可能是 Tool Call、Safety拦截、长度截断或兼容层映射问题,不能直接统一报“模型无回答”。

API 中 getText()getContent()、Metadata 类名随版本可能变化,使用项目版本 Javadoc。

十一、ChatOptions怎样设计

参数作用常见误区
model选择模型允许前端随意选高价/越区模型
temperature控制随机性设0就以为不会幻觉
maxTokens限制最大输出忽略上下文总窗口
topP/topK采样策略同时乱调无评估
stop终止序列截断合法结构化JSON

Options 由场景策略产生,不应直接把前端 JSON 映射到 Provider Options。

Provider-specific Options 会让业务与供应商耦合。封装在模型路由/配置层,并用统一业务配置映射。

十二、结构化输出

java
public record TicketClassification(
        String category,
        String priority,
        String reason) {
}
java
TicketClassification result = chatClient.prompt()
        .system("你是工单分类器,只根据工单内容分类。")
        .user(ticketText)
        .call()
        .entity(TicketClassification.class);

成功反序列化不代表业务合法:

  • category 必须在白名单。
  • priority 必须符合场景规则。
  • reason 长度和敏感内容要校验。
  • 高风险分类可能需要人工复核。

详见:Prompt与结构化输出

十三、流式调用全过程

mermaid
flowchart TD
    A["Controller返回Flux/SSE"] --> B["ChatClient.stream构造流式请求"]
    B --> C["Advisor流式链处理"]
    C --> D["Streaming ChatModel连接Provider"]
    D --> E["Provider逐步生成Chunk"]
    E --> F["适配器转成Flux事件"]
    F --> G["后端转发给浏览器"]
    G --> H{"完成、错误或客户端取消"}
    H --> I["释放连接并记录Usage/partial状态"]

流式主要降低 TTFT,不一定减少总耗时和 Token 成本。

十四、SSE完整Demo

java
@RestController
@RequestMapping("/api/ai")
public class AiStreamController {

    private final ChatClient chatClient;

    public AiStreamController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping(
            value = "/chat/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(
            @RequestParam
            @Size(min = 1, max = 2000)
            String message) {

        Flux<ServerSentEvent<String>> deltas = chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .map(text -> ServerSentEvent.builder(text)
                        .event("delta")
                        .build());

        return deltas
                .timeout(Duration.ofSeconds(60))
                .concatWithValues(
                        ServerSentEvent.builder("completed")
                                .event("done")
                                .build())
                .doOnCancel(() ->
                        log.info("AI stream canceled by client"));
    }
}

生产不能把内部异常堆栈发给浏览器。中途错误转换为受控 event:error,并确保取消向上游传播。

十五、流式content和ChatResponse流有什么区别

.stream().content() 只返回增量文本,使用简单;完整 Response 流可能包含 Usage、Finish Reason、Tool Call 等元数据,具体 API 依版本。

如果要准确记录 Token,不能只在每个文本 Chunk 上累加字符数。Provider Usage 常在最后事件出现;客户端中断时可能拿不到最终 Usage,需要估算和账单对账。

十六、背压和慢客户端

浏览器消费速度低于模型输出速度时,缓冲会增长。需要:

  • 限制每连接缓冲。
  • 设置超时和慢消费者策略。
  • 避免在 Reactor 事件线程执行阻塞 Tool/数据库操作。
  • 客户端断开取消上游。
  • Nginx/Ingress关闭不合适的SSE缓冲并设置空闲超时。

流式不等于可以无上限输出。

十七、Advisor调用链

mermaid
flowchart TD
    A["Request Spec"] --> B["权限/租户Advisor"]
    B --> C["Memory Advisor"]
    C --> D["RAG Advisor"]
    D --> E["审计/观测Advisor"]
    E --> F["ChatModel"]
    F --> G["响应沿Advisor链返回"]

Advisor 可以:

  • 修改或补充请求。
  • 读取请求上下文参数。
  • 调用下一环节。
  • 检查/修改响应。
  • 记录观测数据。

版本中可能区分 Call Advisor 和 Stream Advisor。自定义 Advisor 必须同时考虑同步与流式能力,不能只在 .call() 生效却让 .stream() 绕过安全规则。

十八、Advisor顺序为什么重要

错误:先全库 RAG 召回,再做权限 Advisor。越权资料已经进入上下文。

正确思路:

text
认证上下文
→ 权限Filter
→ Memory裁剪
→ RAG检索
→ Prompt Token预算
→ 模型
→ 输出安全/审计

Memory与问题改写顺序、RAG与Token统计顺序也会改变结果。为 Advisor 链建立版本和顺序测试。

十九、请求级Advisor参数

动态 tenantId、conversationId 等不应写入单例 Advisor 字段。通过请求级上下文传递,具体 API 按版本:

java
String answer = chatClient.prompt()
        .user(question)
        .advisors(advisorSpec -> advisorSpec
                .param("tenantId", authenticatedTenantId)
                .param("conversationId", authorizedConversationId))
        .call()
        .content();

Advisor 内仍需验证这些值来自受信后端上下文,不能让前端随意查询其他 conversationId。

二十、Memory怎样进入调用

mermaid
flowchart TD
    A["本轮问题"] --> B["按conversationId读取历史"]
    B --> C["裁剪/摘要/敏感过滤"]
    C --> D["历史Message加入Prompt"]
    D --> E["调用模型"]
    E --> F["保存本轮消息"]

模型不持久记忆用户。历史越多,Prompt Token越多;必须限制轮数/Token、做摘要并防 conversationId 越权。

二十一、RAG怎样进入调用

RAG Advisor 在调用前查询 VectorStore,把授权 Chunk 添加到 Prompt。生产还要保存检索结果、分数、索引版本和引用,详见:Spring AI RAG

二十二、Tool Calling怎样进入调用

ChatClient 把 Tool Schema 发给模型;模型返回调用意图,后端执行 Java Tool,再将最小结果回传模型。模型参数不可信,详见:Spring AI Tool Calling

二十三、ChatClient应该放在哪一层

text
controller
  └── 接收HTTP、Bean Validation、响应转换
application/service
  └── 场景编排、权限、配额、RAG/Tool决策
ai/client
  └── ChatClient封装、模型调用、错误转换
prompt
  └── 模板、版本和Hash
advisor
  └── Memory/RAG/观测等可测试增强
tool
  └── 最小化业务工具适配器
evaluation
  └── 回归集和评分

不要让领域 Service 到处依赖 Provider-specific Options。建立场景接口:

java
public interface TicketClassifier {
    TicketClassification classify(String ticketText, AiRequestContext context);
}

实现内部使用 ChatClient,业务测试可替换 Fake。

二十四、为什么不直接在Controller调用

  • 无法复用权限、配额和审计。
  • Prompt和模型参数散落。
  • Controller难以单元测试。
  • 切换模型/降级污染HTTP层。
  • Tool事务和业务规则容易放错层。

二十五、测试ChatClient业务封装

不要只做真实模型集成测试。测试分层:

内容
单元测试Prompt参数、权限、Options、输出校验,使用Fake接口
契约测试Provider适配、结构化输出、Tool Schema
集成测试测试Key/本地模型,少量固定样例
评估回归质量、RAG、拒答、安全和成本

模型输出不确定,单元测试不要断言完整自然语言逐字相等;断言必须字段、禁止字段和业务结果。

二十六、超时和重试边界

ChatClient API 不替代底层 HTTP Client 超时和外层服务 Deadline。必须配置连接、读取、总请求和网关超时。

重试:

  • 401/403/400不重试。
  • 429尊重Retry-After。
  • 5xx/连接失败有限重试。
  • 读取超时可能已生成并计费。
  • Tool写操作结果未知先按幂等键确认。

详见:生产SLO、成本与事故排查

二十七、故障一:ChatClient.Builder不存在

检查:

  1. 是否引入正确 Provider Starter,而非只有 core。
  2. 1.x/2.x Artifact 是否混用。
  3. BOM 和 Boot/JDK 是否兼容。
  4. 自动配置是否被排除。
  5. Condition Evaluation Report 哪个条件未满足。
  6. 自定义 ChatModel/Builder Bean 是否冲突。

二十八、故障二:回答跑题或忽略System

检查最终 Prompt:

  • defaultSystem与请求system怎样合并。
  • Advisor是否加入冲突指令。
  • RAG文档是否含恶意指令。
  • 历史消息是否污染当前场景。
  • 用户输入是否过长导致系统规则被稀释/截断。
  • 模型是否适合该指令任务。

不能只不断加长 System Prompt。先消除冲突并用评估集比较。

二十九、故障三:多轮对话串用户

根因:

  • conversationId由前端任意指定。
  • Memory Key未包含tenant/user。
  • 单例Advisor保存可变用户字段。
  • 缓存Key只用问题文本。
  • 测试/生产共享Memory存储。

立即停止相关场景,保存访问和Memory证据,修复授权、清理泄露历史并做安全评估。

三十、故障四:流式几秒后中断

分层:浏览器取消 → CDN/Ingress/Nginx超时/缓冲 → 应用Flux异常/阻塞 → Provider连接/队列。检查是否发送SSE心跳、取消是否上游传播、Tool阶段是否阻塞事件线程。

三十一、故障五:Token突然变多

  • Memory未裁剪。
  • Advisor重复加入System/RAG。
  • RAG TopK/Chunk变大。
  • Tool结果完整回传。
  • Prompt模板重复渲染。
  • Retry产生多次调用。

记录 Advisor 前后 Prompt Token 和版本,不能只看最终总Token。

三十二、故障六:content为空

检查完整 ChatResponse:Tool Call、Safety、Finish Reason、Generation列表、Provider兼容字段、流式是否取消。空文本不是单一错误类型。

三十三、可观测字段

text
requestId, traceId, scene, tenantScopeHash,
chatClientName, model, routeVersion,
promptVersion, advisorChainVersion,
messageCount, estimatedInputTokens,
ragIndexVersion, retrievedChunkIds,
toolNames, inputTokens, outputTokens,
ttftMs, totalMs, finishReason, errorCode

不记录 API Key、完整 Prompt、敏感 RAG 文本和完整 Tool 结果。

三十四、常见误区

误区准确结论后果
ChatClient就是模型它是应用门面,底层是ChatModel/Provider排障层级错误
一个Client适合所有场景默认Prompt/Tool/Advisor不同规则冲突
request system一定覆盖default合并语义需按版本验证Prompt重复/冲突
content为空就是模型挂了可能Tool/Safety/截断错误降级
stream让总耗时更短主要改善TTFT成本/总延迟误判
System Prompt能做权限权限必须后端强制越权
Advisor顺序无关会改变权限、上下文和Token泄露/质量下降
Memory模型自动保存应用每次重新加入历史上下文/越权误解
流式可直接做完整JSON中途片段不是完整结构解析失败
所有异常重试即可结果未知和写Tool会重复重复计费/副作用

三十五、面试标准回答

ChatClient和ChatModel区别

ChatModel是底层模型调用抽象,接受Prompt并返回ChatResponse;ChatClient是在其上面向应用的Fluent门面,组合默认/请求级Message、Template、Options、Advisor、Tool和输出转换。它减少Provider协议耦合,但模型能力差异、权限、评估和故障治理仍需业务处理。

ChatClient一次call怎样执行

prompt创建请求Spec,合并默认值与请求配置,渲染Message模板并构造Prompt;请求经过Advisor前置链,ChatModel适配为Provider请求并等待完整响应;适配器解析ChatResponse和Usage,再经Advisor后置链,由Response Spec提取content、完整response或结构化entity。任一后续解析/Advisor异常都可能让业务调用失败。

call和stream有什么区别

call等待完整响应,适合短任务和结构化输出;stream返回响应式流,让首Token更早到达,适合聊天长文本,但总计算时间不一定降低。流式还要处理客户端取消、背压、SSE代理、心跳、中途错误、Tool阶段和最终Usage缺失。

Advisor顺序为什么重要

Advisor可在模型调用前后加入权限上下文、Memory、RAG、安全和观测。权限必须在检索前生效,Memory裁剪会影响问题改写,RAG加入后影响Token预算。顺序错误可能召回越权文档、重复上下文或让stream绕过安全,因此Advisor链要版本化并分别测试call/stream。

ChatClient为什么不应直接写Controller

Controller只负责HTTP参数和响应;场景路由、权限、配额、Prompt版本、RAG/Tool编排、输出校验和降级应放应用Service与AI Client层。否则规则散落、无法复用/测试/回滚,也容易把Provider Options和Tool事务污染Web层。

三十六、学习实验与验收

  1. 用同一ChatModel分别调用ChatModel和ChatClient,比较对象和响应。
  2. 配置defaultSystem,再添加请求system,观察当前版本最终Prompt合并。
  3. 读取完整ChatResponse,记录Usage、Finish Reason和模型名。
  4. 用entity映射对象,构造合法JSON但业务非法字段并拦截。
  5. 编写SSE接口,测TTFT、总耗时和客户端取消。
  6. 创建两个Advisor,交换顺序观察RAG/Token变化。
  7. 使用请求级conversationId,验证跨用户授权。
  8. 模拟Tool Call导致content为空,正确识别响应类型。
  9. 模拟429、读取超时和流式中断,验证错误分类。
  10. 用Fake场景接口测试Prompt参数和业务校验,不依赖真实模型。

验收时必须能回答:

  • Builder、ChatClient、Request Spec和Response Spec是什么关系?
  • Prompt、Message和Options怎样组合?
  • 默认值和请求级配置怎样验证最终行为?
  • 为什么完整ChatResponse比content更适合排查?
  • 流式为什么只改善TTFT而不保证总耗时?
  • Advisor同步和流式链为什么都要实现?
  • Memory、RAG和Tool分别在哪个阶段进入?
  • 为什么共享可变Advisor会串用户?
  • 如何测试自然语言输出而不逐字断言?
  • Token暴涨时怎样定位是Memory、RAG、Tool还是重试?

关联知识点