Spring AI 1.x系列写到第10篇终于轮到响应结果对象了。前面几篇我们一直在聊ChatClient怎么调、Prompt怎么拼、Message怎么组织但这些做完之后数据最终以什么形态回到我们手里很多人其实没有仔细想过——都是拿response.getResult()再getOutput()拿到一个字符串就完事了。但等你真正开始做多轮对话、做工具调用、做带Token统计的成本分析时就会发现这套结果对象模型才是Spring AI最值得吃透的部分之一。这篇文章我就把ChatResponse、Generation、Message、Metadata这几个核心类型从头到尾拆一遍讲清楚它们各自是什么、为什么这么设计、实际代码里怎么用以及我在迁移和调试过程中踩过的一些坑。1. 响应结果对象的整体设计思路先看一次最简单调用的背后到底发生了什么。你写chatClient.call(prompt)拿到的是一个ChatResponse对象这玩意儿不是一个大号String而是一个结构化的响应容器。Spring AI的模型响应统一走了一套接口设计底层无论接OpenAI、Ollama还是通义最终都会归一化成一套Java对象模型。这套模型的核心就是让上层代码不依赖具体厂商的返回格式你换模型换厂商业务代码基本不用动。1.1 核心类ChatResponse、Generation、AssistantMessage一个ChatResponse里装的是什么从类名上看它代表的是“一次模型对话响应的完整结果”。这个结果不是一个平铺的字符串而是由多个Generation组成。Generation这个词直译是“生成结果”在Spring AI里它表示一组由模型生成的内容块——大多数场景下一个响应只有一个Generation但某些模型在做候选结果采样比如n1参数或者流式分块返回时会存在多个Generation。所以设计上Spring AI直接把它做成了ListGeneration而不是单值这一点和很多直接返回Text的SDK完全不同。再往下拆Generation内部持有两个核心东西一个是真正的输出内容类型是AssistantMessage也就是AI助手角色返回的消息体另一个是伴随这次生成结果的元数据——GenerationMetadata这里面包含finishReason结束原因、token使用量usage、以及模型返回的原始响应信息。我把这个设计类比成快递包裹ChatResponse是快递箱ListGeneration是箱子里的缓冲材料Generation是内包装而AssistantMessage才是你真正想买的商品Metadata则是快递面单上的各种辅助信息重量、件数、路由记录。这样拆分的最大好处是你要取内容就只取内容要算成本可以单独看元数据二者互不干扰。AssistantMessage本身也是一个Message体系的成员。Spring AI借鉴了OpenAI的Message设计一条消息包含role消息角色和content内容。角色有System、User、Assistant、Tool等。AssistantMessage专门用来承载模型返回的角色为assistant的消息同时它还额外携带了toolCalls——工具调用请求列表。如果模型决定调用某个Function那么这个AssistantMessage里的content可能是一个空壳或说明性文本真正的action都封装在toolCalls字段里。我在刚接触这个设计时觉得很不直观明明要调工具为什么返回的是一个Message而不是一个直接的调用指令后来才理解因为对话上下文中必须保留“模型要求调用工具”这条消息后续的工具执行结果要以Tool消息的形式继续追加到上下文中所以它必须是一个完整的Message。1.2 为什么“拿到字符串”不够用很多初学者最容易犯的错就是只从响应里薅字符串其他全丢掉。这样写确实是能跑通的毕竟AssistantMessage里那个text就是模型说的话。但一旦进入生产你很快就会遇到几个躲不开的问题第一用户要看你这次请求花了多少Token你要做成本统计只有usage里有精确数字第二对话过程中模型可能因为触发了长度限制而中途截断只有finishReason能告诉你“这条回复是被length截断了还是正常结束”第三多轮对话要拼历史消息不能只拼chat里的content角色信息必须一起存这时候AssistantMessage这种结构体比裸字符串更合适。我自己在做一个带会话历史的场景时深有体会如果不保留Message结构而只存文本后面做上下文拼接时就得自己拼role前缀、处理特殊字符既麻烦又容易出错。用Spring AI的Message对象直接存进会话表下一次构造Prompt时直接new Prompt(List.of(historyMessages, userMessage))就完事了。所以响应对象不是一个“中间产物”它就是对话逻辑的核心数据结构。2. 关键API与实操解析讲完设计思路上手实操才是正事。这一节我按照常用的几种场景来展示怎么从ChatResponse里取出你想要的东西。2.1 从ChatResponse到最终文本的几种取法先说最基本的用法。假设你已经通过ChatClient拿到了响应ChatResponse response chatClient.call(Prompt.of(介绍一下你自己)); // 方法一最常用直接取第一条结果的内容 String content response.getResult().getOutput().getText(); // 方法二遍历所有Generation ListGeneration generations response.getResults(); for (Generation generation : generations) { AssistantMessage message generation.getOutput(); System.out.println(message.getText()); }这里有个方法论问题getResult()和getResults()有什么区别getResult()返回的是Generation类型是第一个生成结果可以理解为一个便捷入口getResults()返回的是ListGeneration可以处理多个结果。如果你就是问一句答一句直接用getResult()就够了。如果你做了并行生成或者候选采样那必须遍历。再往下Generation接口里我最高频用到的几个方法是getOutput()返回AssistantMessage这是实际生成的消息体getMetadata()返回GenerationMetadata具体含什么后面细说getOutput().getText()拿纯文本内容日常最常用有些老版本代码会看到generation.getMessage()但在较新的1.x里接口演进后是getOutput()。如果你在IDE里发现调不到方法先检查一下自己引的Spring AI版本同一个系列的不同minor版本API差异都可能有别硬背一套。还有一个小技巧如果你只关心文本而不想处理一堆嵌套对象可以在构造ChatClient的时候直接配置输出转换。Spring AI的ChatClient支持链式调用后的content()方法String content chatClient.prompt() .user(帮我写一首诗) .call() .content();这是ChatClient给你封装好的语法糖它内部帮你做了response.getResult().getOutput().getText()这件事。我建议日常简单场景用这种写法深入排查或需要元数据时再用底层API。2.2 元数据Metadata的深度使用如果说内容文本是响应对象的“面子”那Metadata就是“里子”。ChatResponseMetadata里面东西不少我挑关键的讲。第一个是Usage。它代表本次请求的Token消耗明细典型字段如下字段含义promptTokens输入消耗的Token数completionTokens输出消耗的Token数totalTokens总消耗Token数这个数据在成本统计时是命根子。我做一个内部工具的时候每次请求结束都会把metadata.getUsage().getTotalTokens()存进数据库月底一汇总就知道每个部门烧了多少Token财务直接拿这个数分摊成本。注意一点不同模型的usage字段可能不完整——某些本地模型比如Ollama对usage的处理不一定全面甚至返回null所以取值前加个判空是基本素养。第二个是finishReason。这个字段在排障时特别有用它告诉你模型为什么停止了生成。常见的枚举值有这么几个stop正常结束模型认为回复已完整length达到最大token限制被截断content_filter内容被安全策略拦了tool_calls因为模型要求调用工具而停下来等你执行我的经验是在流式场景里最后一个chunk的finishReason常常是stop或length如果发现是length那说明你设置的maxTokens太小或者上下文太长把生成空间挤没了。调试多轮会话时finishReasonlength是一个很常见的“回复到一半断了”的元凶。第三个是model信息它记录了响应来自哪个模型。虽然大多数情况下你自己知道调的是谁但当你用路由策略比如根据用户等级分流到不同模型时响应的model字段就成了最可靠的核对依据。另外GenerationMetadata里还藏着一个responseMetadata它存放的是模型厂商返回的原始Map信息——比如OpenAI返回的created时间戳或者某些模型特有的字段。排查问题的时候直接打开这个Map看看有没有线索比去翻网络请求日志快多了。2.3 Generation与工具调用的关联机器学习里说的工具调用Function Calling实现方式在响应对象上体现得很特殊。当模型决定调用工具时Generation内部那个AssistantMessage不是你想象的普通文本而是一个带toolCalls的消息体。看这个典型结构AssistantMessage assistantMessage generation.getOutput(); // 如果模型要求调用工具这个列表不为空 ListToolCallInfo toolCalls assistantMessage.getToolCalls(); if (toolCalls ! null !toolCalls.isEmpty()) { for (ToolCallInfo toolCall : toolCalls) { String functionName toolCall.functionName(); String arguments toolCall.arguments(); // 这里拿到的是JSON字符串形式的参数 // 执行本地方法然后把结果作为Tool消息返回给模型继续推理 } }这个设计有个关键点当你循环执行工具调用时上一轮模型输出的“请求调工具”的AssistantMessage必须原样放进下一轮上下文中然后追加一条Tool角色的消息携带工具执行结果。如果你把它丢了或者只丢工具结果进去模型就无法把函数结果和它之前的请求对应起来轻则逻辑混乱重则直接报错。我第一次做工具调用时就犯了“只取最终文本”的错模型多轮调用把中间的工具请求消息都丢了结果模型拿到工具结果后完全不知道在回答哪个问题。后来改成“把每一轮拿到的AssistantMessage都追加到上下文”问题才解决。3. 流式响应、堆叠消息与调试技巧流式响应是LLM应用中体验最关键的一环。你肯定不希望用户等好几秒才看到一句完整回答而是想让文字像打字机一样逐字蹦出来。Spring AI的流式响应同样基于响应结果对象只是形态从“一个完整的ChatResponse”变成了“一串情绪连续的ChatResponse”。3.1 流式响应Flux与普通响应的区别普通调用返回的是ChatResponse流式调用返回的是FluxChatResponse如果你用WebFlux或者直接走回调如果你用Synchronous API。这个FluxChatResponse里的每个ChatResponse都是一个chunk——代表模型生成过程中的一小段增量。怎么理解呢正常模式相当于服务员一次性给你端上来一整盘菜流式模式相当于厨师边炒边给你扒拉到盘子里筷子上夹到的每口菜都是一个chunk。在用WebFlux的Controller里可以这么写GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(String message) { return chatClient.prompt() .user(message) .stream() .content(); }这里的stream().content()是ChatClient对FluxChatResponse的进一步封装直接压成字符串流。但如果你在中间层做转发或聚合就绕不开FluxChatResponse这个原始形态了。比如我想做SSE转发给前端的同时后端把每个增量保存起来用于日志分析我会这么做AtomicReferenceString fullContent new AtomicReference(); return chatClient.prompt() .user(message) .stream() .chatResponse() .doOnNext(response - { String delta response.getResult() ! null ? response.getResult().getOutput().getText() : ; fullContent.accumulateAndGet(delta, String::concat); // 这里可以做实时日志、Token统计等 if (response.getMetadata() ! null) { FinishReason reason response.getMetadata().getFinishReason(); if (reason ! null reason FinishReason.STOP) { System.out.println(完整回复 fullContent.get()); } } }) .map(response - response.getResult().getOutput().getText());这里有几个关键点第一流式响应的最后一个chunk往往没有content文本它的用处是携带finishReason和完整的usage汇总。所以你不能假设每个chunk都能取到文本要判空。第二fullContent这个聚合是必要的因为流式chunk之间的文本是增量而不是全量每个chunk只包含“新冒出来的那几个字”。你如果以为每个chunk都是完整回复那就闹大笑话了。第三AtomicReference在多线程环境下是安全的但如果你只是单订阅用普通的StringBuilder也没问题。我习惯用Atomic是因为偶尔会做多路广播避免并发问题。3.2 日志打印与调试时常见的坑响应结果对象在调试时最大的坑就是toString输出太长太乱。一个真实的ChatResponse.toString()打出来可能包含整个Prompt上下文、usage对象、原始Map一眼望去全是噪音。我建议自备一个轻量打印工具类只打印核心字段public static String briefInfo(ChatResponse response) { Generation result response.getResult(); if (result null) { return EMPTY RESPONSE; } String content result.getOutput().getText(); String finishReason response.getMetadata() ! null response.getMetadata().getFinishReason() ! null ? response.getMetadata().getFinishReason().name() : N/A; Integer totalTokens response.getMetadata() ! null response.getMetadata().getUsage() ! null ? response.getMetadata().getUsage().getTotalTokens() : null; return String.format(finish%s, tokens%s, content%s, finishReason, totalTokens, content); }在实际排查问题时我基本只看这个简略输出而不会去翻完整对象。还有一个操作习惯在流式场景里给每个请求加一个requestId贯穿到最后日志里通过requestId把所有chunk串起来。不然并发请求一多A用户的内容和B用户的内容在日志里交错出现排错能排出心梗。4. 常见问题与排查技巧实录响应结果对象这块的坑很多不是语法问题而是“对象形态不符合预期”。我把实战里见过最多的几类问题整理成速查表同时也附上判断思路。4.1 空响应与Null陷阱新手最容易碰到的问题response.getResult()返回null。这不是框架出bug了而是某些模型在内容被安全策略拦截、或返回空内容时结果列表本身就是空的。所以写了response.getResult().getOutput()这种链式调用直接就是NullPointerException。我建议在任何地方取结果文本前都做防御性判断String text ; if (response ! null response.getResult() ! null response.getResult().getOutput() ! null) { text response.getResult().getOutput().getText(); }这段代码虽然丑但稳定。甚至包括getMetadata()也可能是null尤其是接入本地模型、定制模型时很多字段都没被填充。记得在前面那个briefInfo工具里我已经把每一层都判空了习惯要养成。还有一种特殊场景ChatResponse本身拿到的text是空字符串而不是null。这种情况多发生在流式聚合场景中某个chunk可能只包含usage信息而无实际内容。判断时要区分“空字符串”和“null”别把两者混为一谈。4.2 Token统计不准的问题Usage字段在不同模型下差异极大。OpenAI的模型对promptTokens和completionTokens统计得比较细但像某些通过One API或自研网关接入的模型可能完全没有usage或者只返回totalTokens。有一次我做一个Token统计报表发现某天凌晨的系统Token消耗异常锐减排查了半天才发现是那段时间部分流量走了本地Ollamausage字段为空统计逻辑里我直接用getUsage().getTotalTokens()空指针被上层吞了统计结果就是0。后来我写了一个兜底方法当usage为空时用“输入字符数/4 输出字符数/4”估算Token数虽然不精确但至少报表不会出现断崖。这一点真的建议所有接多模型渠道的朋友注意别假设每个模型都精确上报usage你的统计逻辑必须能优雅降级。4.3 不同版本API差异对照Spring AI 1.x系列还在快速演进中方法名和类名都在变。我自己从0.8.x迁移到1.0.0-M系列的时候就有不少代码要改。这里我列一个我印象中的对照关系但请注意版本差异很大实际以你项目中IDE提示为准功能旧版本常见写法较新版本写法获取生成结果response.getResult()response.getResult()保持拿文本内容generation.getMessage().getContent()generation.getOutput().getText()拿metadataresponse.getMetadata()相对稳定字段可能变化字符串便捷获取chatClient.call(...).getContent()chatClient.prompt(...).call().content()我的经验是在升级Spring Boot时不要一个个手动改把报错集中起来参照官方迁移文档逐项核。另外像getOutput()这种新方法老版本根本不存在你要么升级框架要么坚持用旧方法名最忌讳的是混着用。4.4 多轮对话中响应对象的持久化策略之前提到AssistantMessage要存进会话历史这里说说怎么存。我在项目里的做法是不直接序列化Spring AI的对象到数据库因为框架内部的类结构和版本耦合太紧升级时历史数据可能反序列化失败。我自己的方案是设计一个精简的会话消息表字段如图roleString类型值为system/user/assistant/toolcontentString类型存消息文本toolCallsJsonString类型如果存在工具调用把ToolCallInfo列表转成JSON存这里createdAt时间戳这样做的理由很简单解耦。从数据库里读出来时我可以自己组装AssistantMessageAssistantMessage message new AssistantMessage(content, Map.of(), // metadata toolCallsJson); // 需要按版本API适配虽然多了一次转换但换框架升级时只要改组装代码历史数据完全不受影响。这个取舍我觉得很值。写到最后的一点心得回头再看响应结果对象其实是Spring AI里最容易“被忽略但值得认真对待”的部分。你当然可以一直用content()语法糖永远不知道背后发生了什么但在做工具调用、流式转发、成本统计这些进阶功能时迟早要回来面对这些对象。我的建议是在新项目里一开始就建一个统一的响应解析工具类把判空、取文本、取usage这些操作收敛在一个地方业务代码不要散落各种取数逻辑——这样无论框架怎么升级你只要改一处解析逻辑就行。另外如果你的应用会对接多个模型供应商一定要在测试环境里用真实的响应日志跑一遍看看哪些字段稳定、哪些字段时有时无把这些差异当作需求来设计。暂时没有十全十美的封装但把对象模型吃透之后你会发现Spring AI留给你的扩展空间其实相当大。