Spring AI 这个系列的第二篇,我想直接从“能回话”讲到“能干活”。如果你上一期已经把 Spring AI 和模型服务接通,并且用 ChatClient 发过一条普通消息,那这篇文章基本就是你接下来几天的路线图:多轮记忆、提示词模板、结构化输出、Function Calling,以及最容易被业务同事追问的 RAG 知识库问答。
先说清楚这篇补什么。很多教程喜欢一上来就甩一个带数据库、带向量库、带工具调用的完整 Demo,看完很爽,但搬到自己的项目里往往崩得莫名其妙。因为完整 Demo 把链路一次性拉满以后,你根本不知道哪一步是命中的、哪一步是恰好凑出来的。所以这篇会保持每块都能单独跑通的方式来讲,每段代码都是我在本地实际执行过的最小形态。
我本地环境是 Java 17 + Spring Boot 3.3 + Spring AI 1.0.x,下面的代码不会依赖某个冷门内部 API,你可以直接复制到自己的工程里改。
1. 先把上一期的“聊天闭环”补全
1.1 多轮对话不能靠手拼数组
很多刚上手的同学,第一次写完 ChatClient 就会遇到一个很尴尬的问题:模型每次都是“失忆”的。因为默认情况下你发什么,模型就只看什么,服务端并不会保存历史。解决这个问题的正统做法是用 Spring AI 的 ChatMemory,而不是自己在代码里拼一个 List 然后不断 set 进去。自己拼不是不行,但你会很快遇到多轮角色错乱、token 越滚越长、以及同一段记忆被重复塞进去的问题。
Spring AI 里做多轮对话的常规姿势是 ChatMemory 加 ChatClient 的会话机制。ChatClient 每次请求时,会把你的新消息和历史消息一起打包给模型,模型本身不会记住上一次你说了什么。
@Bean ChatMemory chatMemory() { return new InMemoryChatMemory(); }InMemoryChatMemory 只是把消息存在当前进程的内存里,适合开发和单机演示。真正上线时可以换成基于 Redis 的实现,或者你自己实现 ChatMemory 接口,把消息落到数据库。重点是:不管用什么实现,第一步先把“会话上下文由框架统一管理”这个思路固定下来。
请求时带上同一个会话 ID:
String first = chatClient.prompt() .user("我叫小张,喜欢简洁的回答风格。") .call() .content(); String second = chatClient.prompt() .memory("session-001") .user("我是谁来着?") .call() .content();第二个问题能答上来,是因为使用了同一个会话 ID。如果你不想每个请求都传 sessionId,也可以在 ChatClient.Builder 上配 defaultMemory(...),这样每次请求会自动带默认会话;要区分用户时仍然用 .memory(sessionId)。另外,内存实现也支持只保留最近 N 条消息,一般在构造 ChatMemory 时指定一个窗口大小,不同小版本的 API 叫法略有差异,我这边用的是 chatMemoryRetrieverSize(10)。这个窗口别开太大,否则上下文长了对响应速度和成本都不友好。
1.2 从字符串拼接到 PromptTemplate
如果你到现在还是靠字符串拼接组织 user 内容,我建议趁早改掉。字符串拼接的问题很快会出现:一是转义地狱,二是模板稍微一变,代码和字符串都乱,三是用户输入里如果有花括号或特殊字符,容易污染模板。
Spring AI 的 PromptTemplate 用起来就是模板渲染,和写一个带占位符的文本文件没有本质区别:
Map<String, Object> params = Map.of( "product", "某型号机械键盘", "audience", "程序员", "tone", "口语化" ); PromptTemplate template = new PromptTemplate(""" 你是电商文案。请你围绕商品“{product}”写一段面向{audience}的简介, 语气要{tone},最多 40 字,不要出现夸张承诺。 """, params); String answer = chatClient.prompt(template.create()).call().content();我更推荐把模板放到 resources/prompts 目录下,比如 product.st,然后用 ClassPathResource 读取。这样文案同事可以直接改模板文件,不用翻代码。我实际项目里基本都走这条路,因为 prompt 迭代速度远超代码发布速度,把模板和代码分开,省掉一次次重新部署的烦恼。
2. 结构化输出:让模型返回能直接用的对象
2.1 用 entity() 收下对象
大多数业务系统真正需要的不是一段漂亮的散文,而是订单状态、客户姓名、金额、日期这些能被程序处理的字段。Spring AI 的 BeanOutputConverter 就是干这个的:它会在发给模型的 prompt 里追加一段 JSON Schema 说明,让模型按格式返回,然后在客户端用 Jackson 反序列化成目标类型。
最简单的方式是直接用 ChatClient 的 entity():
public record OrderSummary( String orderNo, String status, List<LineItem> items ) { public record LineItem(String name, int quantity, double price) {} } OrderSummary order = chatClient.prompt() .user("把这句话整理成结构化订单信息:用户购买了键盘2个和鼠标垫1个,订单号20240615001,状态已发货。") .call() .entity(OrderSummary.class);如果你要返回一个列表,可以这样写:
List<OrderSummary> orders = chatClient.prompt() .user("把下面的订单都整理成结构化结果:……") .call() .entity(new ParameterizedTypeReference<List<OrderSummary>>() {});我实测下来,嵌套对象和集合在大多数通用模型上都能稳定输出,但前提是你在字段名上不要反人性。比如 Java 里习惯 orderNo,模型输出也常见 order_no,建议直接在字段上加 @JsonProperty("order_no"),或者在提示词里给出一个字段映射说明。字段注释越明确,模型越不会自由发挥。
2.2 失败兜底和解析清洗
结构化输出最典型的表现是模型返回了json 开头的代码块,或者夹杂着“以下是 JSON:”这样的提示语。Spring AI 的 converter 会在很多模型上正常工作,但对于一些小模型,输出格式很难严格遵循。我的兜底做法分三层:第一,优先开启模型服务商的 JSON mode 或 response_format;第二,加一层清洗,把首尾的json 和 ``` 去掉后再反序列化;第三,如果还失败,把错误信息回传给模型,明确告诉它“上次输出不是合法 JSON,请只输出 JSON”。
另一个坑是枚举字段。模型输出“已发货”,而你代码里枚举是 SHIPPED,反序列化直接报错。这种情况要么在提示词里做映射,要么先收成 String 再翻译成业务对象。结构化输出的本质是“让模型按契约交付”,所以契约本身要简单、明确,不要指望模型自己猜你的内部约定。
3. Function Calling:给模型开一条“执行通道”
3.1 @Tool 是外壳,真正的活还是你自己干
先纠正一个常见误解:Function Calling 不是让模型在服务端执行代码,而是模型在生成回复时决定“现在该调用哪个工具、传什么参数”,框架收到这个请求后执行真实方法,再把结果喂回模型,让模型基于结果组织语言。所以工具方法里才是数据查询、权限校验这些实质逻辑。
在 Spring AI 里,一个最省事的方式是用 @Tool 注解直接标记业务方法:
@Service public class OrderToolService { @Tool(description = "根据订单号查询订单的当前状态,用于回答物流和订单进度问题") public String queryOrderStatus(String orderNo) { // 这里可以调用真实的订单服务、数据库或外部接口 return "订单 " + orderNo + " 已发货,当前位于转运中心,预计后天送达。"; } }然后把工具注册成 ToolCallbackProvider:
@Bean ToolCallbackProvider orderTools(OrderToolService orderToolService) { return MethodToolCallbackProvider.builder() .toolObjects(orderToolService) .build(); }请求时挂载给 ChatClient:
String answer = chatClient.prompt() .user("帮我查一下 20240615001 这个订单到哪了?") .tools(orderTools) .call() .content();如果你用的是 ChatClient.Builder 的 defaultTools,也可以直接把 ToolCallbackProvider 注册进去,这样所有请求都会挂载这份工具列表;本次请求临时挂载用 .tools(...) 更清晰。要注意的是,@Tool 方法所在类必须被 Spring 扫描到,否则工具根本不会出现在模型面前。
3.2 工具描述、参数和权限控制
工具方法会被序列化成 JSON Schema 给模型,方法名、参数名、注释和 @Tool 里的 description 全部会出现在模型面前。模型没有读你的代码,它只靠这份描述决定何时调用。描述写得越具体,模型就越不容易在无关问题上随机调用工具。
实际操作中我总结了四条经验:
- 参数尽量用简单类型。复杂嵌套对象模型构造失败率明显升高,能传字符串就传字符串,能传整数就传整数。
- 返回值建议直接返回给模型看的文本,而不是内部对象。比如返回一段精简文本“订单已发货,预计后天到达”,比返回一个 20 字段的 POJO 更稳,模型组织回复也更容易。
- 工具方法内部一定要做权限和参数校验。模型的调用只是“建议”,代码执行前你必须确认这个方法可以被当前用户调用。
- 多个工具互相依赖时,不要让模型自己串联。框架一般会把一次回复里的所有 tool call 执行完后再统一给模型,所以工具方法最好设计成幂等的,重复执行不能有副作用。
4. 从单轮到知识库:用 RAG 把文档变成回答依据
4.1 很多人理解错了 RAG 的角色
RAG 不是让模型去读整个知识库,而是在回答前帮你把最相关的几个片段捞出来,塞进 prompt,让模型照着这几段内容回答。打个比方:开卷考试,RAG 负责提前帮你翻到正确的几页,模型负责根据这几页组织答案,而不是闭卷背整本教材。
Spring AI 里做 RAG 的核心组件包括 Document、切分器、EmbeddingModel、VectorStore 和 QuestionAnswerAdvisor。流程看着多,实际拆开就四步:文档切分、向量化存储、相似度检索、检索结果拼进 prompt。把这四步拆开理解,后面看任何复杂架构都不会懵。
4.2 用 SimpleVectorStore 跑通最小链路
第一版不要一上来就上 PGVector 或者 Milvus,先用 SimpleVectorStore 这种进程内实现验证流程,跑通了再换基础设施。依赖加上 vector store 之后就有一个最小骨架:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-vector-store</artifactId> </dependency>配置一个 VectorStore:
@Bean VectorStore vectorStore(EmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); }写入文档时,先切分再存储:
@Service public class DocIngestionService { private final VectorStore vectorStore; public DocIngestionService(VectorStore vectorStore) { this.vectorStore = vectorStore; } public void load(String content) { Document document = new Document(content); List<Document> chunks = new TokenTextSplitter().apply(List.of(document)); vectorStore.add(chunks); } }查询时用 QuestionAnswerAdvisor 把检索和问答接起来:
var advisor = new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder() .topK(3) .similarityThreshold(0.5) .build()); String answer = chatClient.prompt() .advisors(advisor) .user("根据下面的资料,这款产品的保修期是多久?") .call() .content();这里有个容易出错的点:QuestionAnswerAdvisor 默认会往用户 prompt 里注入检索到的上下文,所以你的提问里带上“根据下面的资料”或“只根据资料回答”这类限定,可以帮助模型把回答范围锁在文档内,减少自由发挥。
SimpleVectorStore 不需要预先建 collection,向量维度由 EmbeddingModel 决定。你一旦切换了 embedding 模型,之前写入的向量大概率因为维度不匹配而检索不到,这就是很多人遇到的“换模型之后什么都查不到”的原因。
4.3 topK、阈值和上下文长度如何取舍
topK 越大,召回越多,模型能看到更全的信息,但也更容易被不相关片段干扰,prompt 也会变长。我一般起步设 3 左右。similarityThreshold 如果设成 0.7 以上,在很多文档上会出现召回为空的情况;0.4 到 0.6 是相对高频的区间,具体要看你用的 EmbeddingModel 的分数分布。
另一个实用技巧是控制切分大小。按 300 到 500 token 一段比较通用,太短会把一句话拆成两半,太长了又会把多个主题混在一起。生产环境如果要接外部知识库,除了切分策略,还需要处理文档更新、源数据标注、重复文档去重这些问题,但那是另一个话题了。先把检索链路跑通,你才有机会看到那些问题,否则一切都停留在想象里。
5. 让“大模型应用”可测试、可观察
5.1 集成测试别断言“原文”
大模型输出不是确定性纯函数,prompt 一样,结果也可能措辞不同。所有指望输出完整等于某段原文的测试都会在 CI 里浪费你的周末。正确姿势是断言关键信息是否出现,或者先让模型输出结构化结果再断言字段。
我自己常写的集成测试长这样:
@SpringBootTest class ChatClientIT { @Autowired ChatClient chatClient; @Test void answerShouldContainOrderStatus() { String answer = chatClient.prompt() .user("订单 20240615001 到什么状态了?") .call() .content(); assertThat(answer).contains("发货"); } }如果整个测试需要更稳定,我会让模型先结构化输出,再对字段做精确比较。比如让回答变成 entity(OrderStatusResult.class),然后断言 status 字段等于 SHIPPED,这比 substring 可靠得多。测试里一定要记得给模型调用留出足够的超时时间,别用默认的几秒去请求一个大模型。
5.2 观察是调优的底裤
prompt 改一版感觉没问题,过几天又复现不了,最常见的原因就是你根本没记录当时的模型版本、参数和输入输出。我建议所有 ChatClient 调用都打日志,至少记录耗时、模型名称、temperature、token 数、输入 prompt 和输出内容。生产环境出于隐私考虑可以不落盘明文,但开发环境一定要留。
最简单的观察方式就是自己包一层调用:
long start = System.currentTimeMillis(); String answer = chatClient.prompt().user(question).call().content(); log.info("question={} answer={} cost={}ms", question, answer, System.currentTimeMillis() - start);在调参时,这几个最常见的生成参数可以提前记一下:
| 参数 | 作用 | 我常用的起步值 |
|---|---|---|
| temperature | 控制随机性,越低越稳定 | 0.2 |
| maxOutputTokens | 单次最大输出长度 | 500~1500 按场景 |
| topP | 核采样阈值 | 0.9 |
| frequencyPenalty | 抑制重复措辞 | 0 |
| presencePenalty | 鼓励新话题 | 0 |
注意这些参数在不同模型服务商的接口里名称不完全一样,Spring AI 的 ChatOptions 会做一层映射,但取值范围和效果仍然由模型服务商决定。凡是遇到“我改了 temperature 怎么没反应”的问题,先去查服务商文档,不要怀疑框架坏了。
6. 常见报错、排查思路与经验速查
6.1 高频异常速查表
这部分直接整理成表格,方便你以后当速查工具用。
| 症状 | 可能原因 | 排查/解决 |
|---|---|---|
| 启动报 No qualifying bean of type 'ChatClient.Builder' | 缺少 Spring AI 自动装配 | 检查是否引入了 spring-ai-starter 相关依赖,并配置了模型 API,别只放了 web starter |
| 调用返回 401 / AuthenticationError | API Key 缺失、过期或服务未开通 | 检查配置项和模型服务商控制台 |
| JSON 解析失败 / Error while extracting response | 模型没有输出合法 JSON | 开启 json mode,清洗 markdown 围栏,或把错误回传重试 |
| @Tool 方法没有被调用 | 工具没有注册或模型不支持 function calling | 确认方法所在类被 Spring 扫描,注册了 ToolCallbackProvider,并在请求或 builder 上挂载 |
| 向量检索返回空 | topK 太小或 similarityThreshold 太高 | 降低阈值,调大 topK;如果换过 embedding 模型,清空并重建向量集合 |
| 上下文太长超出窗口 | memory 窗口大 + topK 大 + chunk 长 | 限制 ChatMemory 保留条数,降低 topK,控制 document chunk 长度 |
6.2 贯穿项目的三个习惯
第一,按“普通问答 -> PromptTemplate -> 结构化输出 -> 工具 -> RAG”的序列渐进式落地,不要第一天就挂满功能。每一步先确认输入输出,再继续下一层。
第二,把模型当作一个笨但博学的实习生来管理:提示词就是任务说明,工具就是它能操作的按钮,每次输出都要验收。模型说“我查了订单”不代表真的查了,你要看它有没有触发工具调用;模型说“根据文档”不代表真的引用了相关文档,你要看 RAG 的检索结果到底有没有被注入。
第三,记录比记忆可靠。每一条 prompt、每一组参数、每一个输出样本,都值得留下快照,否则后期你很难判断一个“看起来变好了”的变化到底是 prompt 带来的还是随机性带来的。
最后再分享一个我坚持了很久的小习惯:每次调参后,我会把输入样例、输出、模型名称、temperature、时间戳、prompt 全文加上一句“这次改了什么”粘贴到笔记里。坚持几十条之后,你对模型脾气会有一种说不清的直觉,之后别人跟你讨论方案,你也能直接说出“那次输出飘了是 temperature 改成 0.7 之后才出现的”。这期先记到这里。