S22-B04 · Spring AI 入门:ChatClient、Advisor 与一次调用的完整链路
chatModel.call(new Prompt("你好")) 能跑通,但接进业务就开始堆代码:系统提示在每个 Service 里拼一遍、会话历史自己塞 Redis、检索结果手动贴进 user 消息、日志靠 System.out。Spring AI 的 ChatClient 把这些横切动作收进一条可组合的链——代价是你得知道链上每一环在哪、顺序怎么定。
这篇按一次请求的流转顺序走:Prompt 组装 → Advisor 链 → 模型调用 → 响应解析,最后给一个从依赖到流式输出的最小工程。
1. 一次请求经过什么
| 阶段 | 谁负责 | 你通常要碰的东西 |
|---|---|---|
| Prompt 组装 | ChatClient.prompt() + 模板渲染 |
system/user 消息、参数占位 |
| Advisor 前置 | AdvisorChain 逐个 before |
记忆注入、检索增强、内容过滤 |
| 模型调用 | ChatModel → HTTP 客户端 |
base url、model、temperature |
| 响应解析 | ChatModel → ChatResponse |
文本、结构化映射、工具调用请求 |
| Advisor 后置 | 链反向 after |
日志、记忆写入 |
prompt → [A1.before → A2.before → model.call → A2.after → A1.after] → response
链是栈式的:before 按顺序进,after 按相反顺序出。所以"谁先看到原始请求"和"谁最后处理响应"是两个不同的问题——这决定了 Advisor 顺序怎么排。
2. ChatClient 与 ChatModel:一个门面,一个执行器
ChatModel 是接口,负责真正发请求(OpenAI、Ollama、Azure 各有实现);ChatClient 是门面,负责拼 prompt、跑 advisor、做结构化转换。
| 用法 | 写法 | 适合 |
|---|---|---|
| Builder 构建 | ChatClient.builder(chatModel).defaultSystem(...).build() |
需要统一默认值 |
| 注册成 Bean 后注入 | Service 里只写"这次问什么" | 业务代码 |
直接用 ChatModel |
chatModel.call(prompt) |
调试、极简场景 |
@Bean
ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) {
return builder
.defaultSystem("你是博客站的技术助手,回答用简体中文。")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(),
new SimpleLoggerAdvisor())
.build();
}
自动配置提供的是 ChatClient.Builder,不是 ChatClient 本身——"注入 ChatClient 报找不到 Bean"是新手最常见的第一个错,得自己建这个 Bean。
3. Prompt 的组成:三种消息 + 参数占位
| 部分 | 作用 | 注意 |
|---|---|---|
| system | 设定角色与规则,通常整段会话只发一次 | 放稳定约束,别放本次数据细节 |
| user | 本次问题或数据 | 用户输入当数据,别拼进指令位 |
| assistant | 历史回复,用于多轮上下文 | 由记忆机制补,不要手写死 |
| 参数占位 | {topic} 之类,由 param() 填充 |
占位符缺失会渲染成原文,不报错 |
String answer = chatClient.prompt()
.system(s -> s.text("用 {style} 的风格解释概念。").param("style", "尽量短"))
.user(u -> u.text("解释一下 {topic}。").param("topic", "倒排索引"))
.call()
.content();
模板渲染发生在发请求之前,占位符写错的表现是"模型收到了带花括号的字面量",而不是异常。
4. Advisor 链:每一环该放哪
| Advisor | 干什么 | 位置要求 |
|---|---|---|
| 记忆 | 取历史塞进 prompt,收尾后写回 | 尽早,让后续环看到完整历史 |
| 检索增强 | 用问题查向量库,结果拼进 prompt | 在记忆之后,基于完整上下文检索 |
| 日志 | 打印请求与响应 | 靠外,看到最终形态 |
| 内容过滤 | 命中规则时短路返回 | 最外层,省掉无谓的模型调用 |
顺序由 Advisor.getOrder() 或注册顺序决定(默认值按 1.0.x 版本核对)。顺序错的症状:检索增强的 query 里混着历史对话,或日志里的 prompt 少了一段记忆。
5. ChatMemory:存哪儿、留多少
| 存储实现 | 位置 | 适用 |
|---|---|---|
InMemoryChatMemory |
JVM 堆内 Map | 单机 demo、单测 |
| JDBC / Redis 类实现 | 外部存储 | 多实例、重启不丢 |
| 自定义 | 实现接口 | 要与业务表关联、要审计 |
窗口策略决定"留多少":按消息条数截断(maxMessages,最常见)或按 token 预算截断。超窗口有两种处理——丢弃最旧的,或把旧消息压成摘要。
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(repository) // 不传则用内存版
.maxMessages(20)
.build();
chatClient.prompt().user("接着上面说")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-42"))
.call().content();
CONVERSATION_ID 是必填的:不传就没有会话键,记忆要么不生效、要么所有用户共用一份(后者是事故)。
6. 结构化输出:.entity() 靠什么映射
record BookBrief(String title, List<String> tags, String summary) {}
BookBrief brief = chatClient.prompt()
.user("把这段介绍整理成 title/tags/summary:...")
.call()
.entity(BookBrief.class);
| 依赖条件 | 说明 |
|---|---|
| 模型支持 JSON 输出或工具调用 | 优先走 native structured output,否则退化为提示词约束 |
| 目标类型可反序列化 | record / POJO 需要对应的反序列化配置 |
| 字段名与语义对得上 | 模型给 tag 而你等 tags,映射就是空 |
关键认知:.entity() 抛异常,八成不是网络问题,是模型没按格式回。捕获后先看原始文本(ChatResponse 的 content),原样重试通常得到同样的错误输出。
7. 流式:Flux 与前端 SSE 的对接点
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> chat(@RequestParam String q) {
return chatClient.prompt().user(q).stream().content();
}
| 环节 | 要做的事 |
|---|---|
| 服务端 | 返回 Flux<String>,produces = text/event-stream |
| 中间层 | 关掉缓冲(nginx proxy_buffering off),否则流会被攒成一坨 |
| 前端 | EventSource 或 fetch + ReadableStream 逐块渲染 |
| 结束 | 流正常 onComplete 才算完整,中途断开要有兜底文案 |
流式路径上不要套重试:一个字已经吐给用户了,重试会产生重复内容。要重试只能在首个 token 之前。
8. 工具调用:注册、描述、schema、回填
| 环节 | 谁做 | 要点 |
|---|---|---|
| 注册 | 把带 @Tool 的方法传给 .tools(...) |
方法所在对象要是 Bean 或显式实例 |
| 描述 | 注解里的 description |
模型靠这段文字决定调不调,写得像给人看的说明 |
| 参数 schema | 由方法签名反射生成 | 参数用简单类型(String/int/枚举),嵌套对象易失败 |
| 回填 | 框架自动发起第二轮调用 | 工具返回的字符串就是模型看到的"事实" |
class BookTools {
@Tool(description = "按书名查博客文章摘要,返回标题与摘要")
String findBook(@ToolParam(description = "完整或部分书名") String name) {
return bookService.brief(name);
}
}
chatClient.prompt().user("找一下《倒排索引入门》讲了什么")
.tools(new BookTools()).call().content();
工具返回不要塞整段 HTML 或几千字:它直接进上下文,贵且容易盖掉重点。
9. 异常与超时:重试的边界
| 情况 | 表现 | 该不该重试 |
|---|---|---|
| 模型侧 5xx | 上游错误,可能瞬时 | 可以,指数退避,上限 2–3 次 |
| 429 限流 | 明确要求退让 | 可以,但要读 Retry-After,别硬撞 |
| 连接超时 | TCP/TLS 阶段就失败 | 可以(请求还没被处理) |
| 读超时(已发出、无响应) | 不确定是否已计费/已生成 | 谨慎,业务侧要幂等 |
| 4xx 参数错 | 配置问题(模型名不存在等) | 不要重试,改配置 |
重试挂在上层(spring.ai.retry.* 或自己包 RetryTemplate)。原则:重试放在"调用一次"的边界上,不放在流式输出中途。
10. 配置放哪一层
| 配置 | 放哪 | 原因 |
|---|---|---|
| api key / base url | 环境变量或配置中心 | 不进代码、不进 git |
| model 名 | spring.ai.openai.chat.options.model |
换模型只改一处 |
| temperature / max tokens | 默认放 yml,单次覆盖用 ChatOptions |
不同任务要不同随机性 |
| 系统提示 | 代码常量或外部模板 | 要能 review、能版本化 |
spring:
ai:
openai:
api-key: ${AI_API_KEY}
base-url: https://api.example.com
chat:
options:
model: gpt-4o-mini
temperature: 0.2
max-tokens: 1024
11. 最小可跑示例
依赖(版本按所用的 Spring AI 1.0.x 核对):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
一次调用:
String text = chatClient.prompt().user("用一句话解释倒排索引").call().content();
流式见第 7 节,结构化输出见第 6 节。
12. 排错表:常见坑
| 坑 | 症状 | 改法 |
|---|---|---|
| api key 写进代码 | 提交记录里就有密钥 | 换环境变量,轮换已泄露的 key |
| 消息历史无限增长 | 越聊越慢越贵,最后超上下文 | 设 maxMessages,或摘要压缩 |
| 在流式上套重试 | 前端出现重复段落 | 重试只在首 token 前 |
.entity() 失败当网络错 |
无限重试同一段错误输出 | 看原始 content,改提示或换模型 |
| 所有用户共用一个会话 id | 串话,A 看到 B 的历史 | 每用户/每会话一个 CONVERSATION_ID |
13. 边界
| 场景 | 更合适的选择 |
|---|---|
| 一次问答、无历史无检索 | 直接 ChatModel.call() |
| 多步工具编排、循环判断 | 上更上层的 agent 框架,别用 Advisor 硬拼 |
| 需要强事务一致性 | 模型是外部不可控依赖,业务侧要能补偿 |
| 高并发低延迟 | 先压测模型配额与首 token 时间,再谈框架优化 |
14. 自测
- Advisor 的
before与after顺序为什么相反? - 注入
ChatClient报找不到 Bean,缺的是哪一步? {topic}忘了param(),会抛异常还是收到花括号原文?- 不传
CONVERSATION_ID会发生什么? .entity()抛异常,先看什么再决定要不要重试?- 为什么流式路径上不该套重试?