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 和领域代码中。
学习目标
完成本页后,你应该能够:
- 区分
ChatClient.Builder、ChatClient、Request Spec 和 Response Spec。 - 解释
ChatClient、ChatModel、Prompt、Message、ChatOptions、ChatResponse的关系。 - 说明默认 system/options/advisors/tools 与请求级配置怎样组合。
- 写出同步文本、完整响应、结构化对象和流式 SSE Demo。
- 解释
.call()和.stream()的执行、延迟和错误边界。 - 解释 Advisor 前置/后置链和顺序为什么重要。
- 理解 Memory、RAG、Tool Calling 在 ChatClient 链中的位置。
- 设计业务封装、版本记录、输入校验、超时、成本和审计。
- 排查 Bean 注入失败、回答跑题、空内容、流式中断、上下文超长和 Token 暴涨。
一、ChatClient不是ChatModel
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 的底层抽象 |
Prompt | Message 列表和 ChatOptions 的完整输入 |
Message | System/User/Assistant/Tool 等角色内容 |
ChatResponse | Generation、模型输出、Usage、Finish Reason等 |
ChatClient 降低了 Provider 协议耦合,但不保证不同模型在 Tool、JSON、多模态和参数上完全等价。
二、为什么需要ChatClient
| 自己拼HTTP | ChatClient提供的抽象 | 仍需业务负责 |
|---|---|---|
| 手写厂商请求体 | Prompt/Message/Options | 场景和规则 |
| 手写同步和SSE解析 | call/stream | 外层SSE治理和取消 |
| 每处拼System Prompt | defaultSystem和模板 | Prompt版本/评估 |
| 手工插RAG/Memory | Advisor链 | ACL、Token预算 |
| 手工工具循环 | Tool Calling集成 | 权限、幂等、确认 |
| 手工解析JSON | entity/Converter | Schema和业务校验 |
三、Builder什么时候出现
Provider Starter 自动配置满足条件后,Spring Boot 通常创建 ChatModel 以及可注入的 ChatClient.Builder。业务配置使用 Builder 创建一个或多个按场景划分的 ChatClient:
@Configuration
public class ChatClientsConfiguration {
@Bean
ChatClient knowledgeChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
你是企业知识库助手。
只能根据已授权资料回答。
资料不足时明确拒答。
""")
.build();
}
}建议创建场景 Client,例如 knowledge、extract、customerService,而不是一个万能 ChatClient 承载所有默认 Prompt/Advisor/Tool。
四、Builder、Client和请求对象生命周期
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。
请求级调用可添加/覆盖部分配置:
String answer = chatClient.prompt()
.system("本次只返回三条结论。")
.user("解释线程池拒绝策略")
.options(OpenAiChatOptions.builder()
.temperature(0.1)
.maxTokens(600)
.build())
.call()
.content();不同字段是覆盖、追加还是合并,可能随 API 和对象类型不同。不要凭感觉认为请求 system 一定完全替换 defaultSystem;通过单元测试或调试最终 Prompt 验证当前版本行为。
六、Prompt由什么组成
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参数绑定
String answer = chatClient.prompt()
.user(user -> user.text("""
请解释以下主题:{topic}
读者水平:{level}
输出要求:先结论,再原理,再给Demo。
""")
.param("topic", topic)
.param("level", level))
.call()
.content();模板参数仍是不可信数据。绑定可以避免手工字符串拼接混乱,但不等于防 Prompt Injection。用户输入与系统规则应有明确标记,Tool和权限由后端控制。
八、同步调用全过程
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
@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
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 会让业务与供应商耦合。封装在模型路由/配置层,并用统一业务配置映射。
十二、结构化输出
public record TicketClassification(
String category,
String priority,
String reason) {
}TicketClassification result = chatClient.prompt()
.system("你是工单分类器,只根据工单内容分类。")
.user(ticketText)
.call()
.entity(TicketClassification.class);成功反序列化不代表业务合法:
- category 必须在白名单。
- priority 必须符合场景规则。
- reason 长度和敏感内容要校验。
- 高风险分类可能需要人工复核。
详见:Prompt与结构化输出。
十三、流式调用全过程
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
@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调用链
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。越权资料已经进入上下文。
正确思路:
认证上下文
→ 权限Filter
→ Memory裁剪
→ RAG检索
→ Prompt Token预算
→ 模型
→ 输出安全/审计Memory与问题改写顺序、RAG与Token统计顺序也会改变结果。为 Advisor 链建立版本和顺序测试。
十九、请求级Advisor参数
动态 tenantId、conversationId 等不应写入单例 Advisor 字段。通过请求级上下文传递,具体 API 按版本:
String answer = chatClient.prompt()
.user(question)
.advisors(advisorSpec -> advisorSpec
.param("tenantId", authenticatedTenantId)
.param("conversationId", authorizedConversationId))
.call()
.content();Advisor 内仍需验证这些值来自受信后端上下文,不能让前端随意查询其他 conversationId。
二十、Memory怎样进入调用
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应该放在哪一层
controller
└── 接收HTTP、Bean Validation、响应转换
application/service
└── 场景编排、权限、配额、RAG/Tool决策
ai/client
└── ChatClient封装、模型调用、错误转换
prompt
└── 模板、版本和Hash
advisor
└── Memory/RAG/观测等可测试增强
tool
└── 最小化业务工具适配器
evaluation
└── 回归集和评分不要让领域 Service 到处依赖 Provider-specific Options。建立场景接口:
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不存在
检查:
- 是否引入正确 Provider Starter,而非只有 core。
- 1.x/2.x Artifact 是否混用。
- BOM 和 Boot/JDK 是否兼容。
- 自动配置是否被排除。
- Condition Evaluation Report 哪个条件未满足。
- 自定义 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兼容字段、流式是否取消。空文本不是单一错误类型。
三十三、可观测字段
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层。
三十六、学习实验与验收
- 用同一ChatModel分别调用ChatModel和ChatClient,比较对象和响应。
- 配置defaultSystem,再添加请求system,观察当前版本最终Prompt合并。
- 读取完整ChatResponse,记录Usage、Finish Reason和模型名。
- 用entity映射对象,构造合法JSON但业务非法字段并拦截。
- 编写SSE接口,测TTFT、总耗时和客户端取消。
- 创建两个Advisor,交换顺序观察RAG/Token变化。
- 使用请求级conversationId,验证跨用户授权。
- 模拟Tool Call导致content为空,正确识别响应类型。
- 模拟429、读取超时和流式中断,验证错误分类。
- 用Fake场景接口测试Prompt参数和业务校验,不依赖真实模型。
验收时必须能回答:
- Builder、ChatClient、Request Spec和Response Spec是什么关系?
- Prompt、Message和Options怎样组合?
- 默认值和请求级配置怎样验证最终行为?
- 为什么完整ChatResponse比content更适合排查?
- 流式为什么只改善TTFT而不保证总耗时?
- Advisor同步和流式链为什么都要实现?
- Memory、RAG和Tool分别在哪个阶段进入?
- 为什么共享可变Advisor会串用户?
- 如何测试自然语言输出而不逐字断言?
- Token暴涨时怎样定位是Memory、RAG、Tool还是重试?
