2547 字
约 8 分钟
15
S22-B04 · Spring AI 入门:ChatClient、Advisor 与一次调用的完整链路

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. 自测

  1. Advisor 的 before 与 after 顺序为什么相反?
  2. 注入 ChatClient 报找不到 Bean,缺的是哪一步?
  3. {topic} 忘了 param(),会抛异常还是收到花括号原文?
  4. 不传 CONVERSATION_ID 会发生什么?
  5. .entity() 抛异常,先看什么再决定要不要重试?
  6. 为什么流式路径上不该套重试?
S22-B04 · Spring AI 入门:ChatClient、Advisor 与一次调用的完整链路
https://www.clxhxhhr.top/posts/6054/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。