前置文章JAVA25Spring Boot 4.1.0Spring AI 2.0.1 调用 DeepSeek 模型前言Spring AI 基于 Spring 生态系统中的可观测性能力为 AI 相关操作提供洞察。它为 ChatClient包括 Advisor、ChatModel、EmbeddingModel、ImageModel 和 VectorStore 等核心组件提供指标与链路追踪功能。Spring AI 官网 Observability有时候我们配置好了模型参数却并不清楚程序访问模型时到底有没有把这些参数发送出去也不了解 Spring AI 发送给模型的最原始提示词是什么。本文的目标就是把程序访问模型时最底层发送的 body 信息打印出来方便我们观察 Spring AI 在访问模型前是否对业务信息做过加工。本文在前置文章的基础上进一步扩展讲解如何在项目中输出 Spring AI 调用 DeepSeek 模型时的底层 HTTP 日志。注意不同模型调用底层的实现各不相同本篇仅适用于调用 DeepSeek 模型。一、添加 application.yml 配置# Springboot日志打印-默认使用logback打印info以上级别的日志logging:# 日志级别-此处配置优先级大于logback-spring.xml配置level:# 打印需要debug级别 DEBUG INFO ERROR OFFcom.lh:DEBUG将项目输出日志级别修改为 DEBUG。其中com.lh是本项目包路径请替换为你项目实际的包路径。二、新增客户端 HTTP 响应包装器importorg.springframework.http.HttpHeaders;importorg.springframework.http.HttpStatusCode;importorg.springframework.http.client.ClientHttpResponse;importjava.io.ByteArrayInputStream;importjava.io.IOException;importjava.io.InputStream;/** * 客户端 HTTP 响应包装器用于缓存响应体使其可以被多次读取。LoggingInterceptor 使用 * p * Spring 的 {link ClientHttpResponse#getBody()} 返回的输入流只能被读取一次 * 读取后流即关闭。在拦截器中需要读取响应体打印日志同时又要让后续流程如 Spring AI 解析 JSON * 再次读取因此必须缓存响应体并提供新的输入流实例。 * p * 此类实现了 {link ClientHttpResponse} 接口委托给原始响应对象但重写了 {link #getBody()} * 方法每次调用都返回基于缓存的字节数组创建的新的 {link ByteArrayInputStream} * 从而实现响应体的无限次读取。 * * author LIHAO * since 1.0.0 */publicclassBufferingClientHttpResponseWrapperimplementsClientHttpResponse{// 原始响应对象用于获取状态码、头信息等privatefinalClientHttpResponseresponse;// 缓存的响应体字节数组privatefinalbyte[]body;/** * 构造包装器缓存响应体。 * * param response 原始 HTTP 响应 * param body 已读取的响应体字节数组非空 */publicBufferingClientHttpResponseWrapper(ClientHttpResponseresponse,byte[]body){this.responseresponse;this.bodybody;}/** * 获取 HTTP 状态码委托给原始响应。 * * return 状态码对象 * throws IOException 如果获取失败 */OverridepublicHttpStatusCodegetStatusCode()throwsIOException{returnresponse.getStatusCode();}/** * 获取状态文本委托给原始响应。 * * return 状态描述字符串 * throws IOException 如果获取失败 */OverridepublicStringgetStatusText()throwsIOException{returnresponse.getStatusText();}/** * 获取 HTTP 响应头委托给原始响应。 * * return 响应头对象 */OverridepublicHttpHeadersgetHeaders(){returnresponse.getHeaders();}/** * 获取响应体输入流。 * p * 每次调用都基于缓存的字节数组创建一个新的 {link ByteArrayInputStream} * 因此可以多次读取而不会关闭。 * * return 可重复读取的输入流 * throws IOException 不会抛出但保留签名以符合接口 */OverridepublicInputStreamgetBody()throwsIOException{// 每次返回新流实现多次读取returnnewByteArrayInputStream(body);}/** * 关闭原始响应释放连接等资源。 * p * 缓存的内容不受影响但原始响应的底层连接会被释放。 */Overridepublicvoidclose(){// 委托给原始响应关闭底层连接response.close();}}由于 Spring 的 ClientHttpResponse#getBody() 返回的输入流只能被读取一次因此这里创建了一个客户端 HTTP 响应包装器用于缓存响应体使其可以被多次读取仅供 HTTP 请求/响应日志拦截器使用。三、新增 HTTP 请求/响应日志拦截器importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.http.HttpRequest;importorg.springframework.http.client.ClientHttpRequestExecution;importorg.springframework.http.client.ClientHttpRequestInterceptor;importorg.springframework.http.client.ClientHttpResponse;importorg.springframework.stereotype.Component;importorg.springframework.util.StreamUtils;importjava.io.IOException;importjava.nio.charset.StandardCharsets;/** * HTTP 请求/响应日志拦截器 * p * 实现 ClientHttpRequestInterceptor 接口用于拦截 RestClient 发出的所有 HTTP 请求 * 打印请求和响应的完整内容包括 Headers、Body、耗时方便开发和调试。 * p * 使用场景 * - 查看实际发送给 DeepSeek API 的 JSON 请求体 * - 查看 DeepSeek API 返回的原始 JSON 响应 * - 定位网络请求问题如超时、连接失败 * p * 注意生产环境建议通过日志级别控制是否启用仅在 DEBUG 级别打印。 */ComponentpublicclassLoggingInterceptorimplementsClientHttpRequestInterceptor{privatestaticfinalLoggerlogLoggerFactory.getLogger(LoggingInterceptor.class);/** * 拦截 HTTP 请求打印请求和响应的完整信息。 * p * 执行流程 * 1. 打印请求内容URI、方法、Headers、Body * 2. 记录请求开始时间 * 3. 执行真实的 HTTP 请求调用下一个拦截器或实际发送 * 4. 计算耗时 * 5. 读取响应体内容复制一份避免流被消费后无法再次读取 * 6. 打印响应状态、Headers、Body、耗时 * 7. 返回包装后的响应包含原始响应体以便后续处理 * * param request HTTP 请求对象包含 URI、Headers、Method 等 * param body 请求体的原始字节数组 * param execution 执行器用于调用下一个拦截器或实际发送请求 * return 包装后的响应对象保持响应体可重复读取 * throws IOException 网络或 I/O 异常 */OverridepublicClientHttpResponseintercept(HttpRequestrequest,byte[]body,ClientHttpRequestExecutionexecution)throwsIOException{// 1. 打印请求信息 // 仅在 DEBUG 级别下打印避免生产环境日志过多if(log.isDebugEnabled()){log.debug( Request );log.debug(URI: {},request.getURI());log.debug(Method: {},request.getMethod());// 打印请求头可能包含 Authorization 等敏感信息生产环境需谨慎// log.debug(Headers: {}, request.getHeaders());// 如果请求体非空打印 Body通常是 JSONif(body.length0){// 将字节数组转为 UTF-8 字符串便于阅读log.debug(Body: {},newString(body,StandardCharsets.UTF_8));}}// 2. 记录开始时间并执行请求 longstartTimeSystem.currentTimeMillis();// execution.execute() 会调用下一个拦截器如果没有则真正发送 HTTP 请求ClientHttpResponseresponseexecution.execute(request,body);// 3. 打印响应信息 if(log.isDebugEnabled()){log.debug( Response );log.debug(Status Code: {},response.getStatusCode());// log.debug(Headers: {}, response.getHeaders());// 注意response.getBody() 是一个 InputStream只能被读取一次。// 我们需要先复制一份然后再将原始流返回给调用方。// 否则后续处理如 Spring AI 解析 JSON将无法读取响应体。byte[]responseBodyStreamUtils.copyToByteArray(response.getBody());log.debug(Body: {},newString(responseBody,StandardCharsets.UTF_8));log.debug(Time: {} ms,System.currentTimeMillis()-startTime);// 4. 返回包装后的响应 // 因为我们已经消费了原始的 response.getBody()需要返回一个包装类// 使后续代码可以再次读取响应体。// 否则后续的反序列化会因流关闭而失败。returnnewBufferingClientHttpResponseWrapper(response,responseBody);}// 如果不是 DEBUG 级别直接返回原始响应不打印日志returnresponse;}}仅供 DeepSeek API 自定义配置类使用四、新增 DeepSeek API 自定义配置类/** * DeepSeek API 自定义配置类。 * p * 由于 Spring AI 2.0.1 修订了 {link DeepSeekApi}PR #6428自动配置获取的是原始的 * {code RestClient.Builder} 和 {code WebClient.Builder}不再应用全局的 * {code RestClientCustomizer}因此需要手动定义 {link DeepSeekApi} Bean * 才能让自定义的日志拦截器生效。 * p * 本类同时负责 * ul * li为同步请求RestClient注入 {link LoggingInterceptor}/li * li为流式请求WebClient注入 URL 日志过滤器与请求体日志编码器/li * li手动创建 {link DeepSeekChatModel}确保其使用自定义的 {link DeepSeekApi}/li * /ul * * author LIHAO * since 1.0.0 */Slf4jConfigurationpublicclassDeepSeekApiConfig{/** * 创建自定义的 {link DeepSeekApi} Bean覆盖 Spring AI 的自动配置。 * p * 关键点 * ul * liRestClient 上挂载 {link LoggingInterceptor}用于捕获同步请求的完整报文/li * liWebClient 上挂载 URL 日志过滤器{link ExchangeFilterFunction} * 用于捕获流式请求的 URL、Method 和 Headers/li * liWebClient 上注册自定义的 {link SimpleBodyLoggingEncoder} * 用于捕获流式请求的 JSON 请求体/li * /ul * * param properties DeepSeek 连接配置baseUrl、apiKey 等 * param restLoggingInterceptor 同步请求的日志拦截器 * param jsonMapper Spring Boot 自动配置的 Jackson 3 JsonMapper * return 自定义构建的 DeepSeekApi 实例 */BeanConditionalOnMissingBeanpublicDeepSeekApideepSeekApi(DeepSeekConnectionPropertiesproperties,LoggingInterceptorrestLoggingInterceptor,JsonMapperjsonMapper){// WebClient 的 URL 日志过滤器 // ExchangeFilterFunction 工作在请求的元数据层可获取 URL、Method、Headers// 但无法获取请求体请求体由 Encoder 负责记录。ExchangeFilterFunctionurlLoggingFilter(request,next)-{log.debug( Request );log.debug(Method: {},request.method());log.debug(URL: {},request.url());// 打印请求头可能包含敏感信息生产环境建议脱敏request.headers().forEach((name,values)-log.debug(Header: {} {},name,values));// 继续执行请求链returnnext.exchange(request);};returnnewDeepSeekApi(properties.getBaseUrl(),newSimpleApiKey(properties.getApiKey()),newHttpHeaders(),/chat/completions,/beta,// ---------- 同步请求RestClient ----------// 通过 requestInterceptor 挂载日志拦截器// 可完整捕获请求体与响应体含 Headers、耗时。RestClient.builder().requestInterceptor(restLoggingInterceptor),// ---------- 流式请求WebClient ----------// filter记录 URL、Method、Headers// codecs替换默认的 JSON 编码器注入请求体日志能力WebClient.builder().filter(urlLoggingFilter).codecs(configurer-configurer.defaultCodecs().jacksonJsonEncoder(newSimpleBodyLoggingEncoder(jsonMapper))),RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);}/** * 创建自定义的 {link DeepSeekChatModel} Bean显式注入自定义的 {link DeepSeekApi}。 * p * 如果不手动定义此 BeanSpring AI 的自动配置可能会使用其内部创建的 DeepSeekApi * 从而导致我们的自定义配置日志拦截器失效。 * * param deepSeekApi 自定义构建的 DeepSeekApi * param chatProperties DeepSeek Chat 配置model、temperature 等 * return 使用自定义 DeepSeekApi 的 DeepSeekChatModel */BeanConditionalOnMissingBeanpublicDeepSeekChatModeldeepSeekChatModel(DeepSeekApideepSeekApi,DeepSeekChatPropertieschatProperties){returnDeepSeekChatModel.builder().deepSeekApi(deepSeekApi).options(chatProperties.toOptions()).build();}/** * 自定义的 Jackson JSON 编码器用于捕获 WebClient 的请求体内容。 * p * 背景WebClient 使用 {link ExchangeFilterFunction} 只能获取请求元数据URL、Headers * 无法获取请求体。要捕获请求体需要在序列化层介入——即扩展 {link JacksonJsonEncoder} * 在其 {link #encode} 方法中拦截序列化后的 JSON 字节流。 * p * 实现要点 * ul * li继承 {link JacksonJsonEncoder}Spring Framework 7 中用于 Jackson 3 的编码器/li * li构造函数必须显式传入 MIME 类型否则父类 {code JacksonCodecSupport} 会断言失败/li * li在 {code encode} 中通过 {code doOnNext} 旁路读取 DataBuffer * 读取后必须调用 {code readPosition(0)} 重置读取位置否则会破坏后续编码/li * /ul * * author LIHAO * since 1.0.0 */publicstaticclassSimpleBodyLoggingEncoderextendsJacksonJsonEncoder{/** * 使用独立的 logger 名称避免与父类 AbstractJacksonEncoder 的 DEBUG 日志共享 logger。 * 父类日志的 logger 名称是 getClass() 的全限定名即本内部类名 * 这里显式指定为 WebClient.BodyLogger两者互不影响。 */privatestaticfinalorg.slf4j.LoggerlogEncoderorg.slf4j.LoggerFactory.getLogger(WebClient.BodyLogger);/** * 构造函数必须显式传入 MIME 类型。 * p * 传入 {code application/json} 与 {code application/*json} * 以兼容标准 JSON 以及带有供应商前缀的 JSON 类型如 {code application/vnd.apijson}。 * 如果 MIME 类型为空父类 {code JacksonCodecSupport} 会抛出 * {code IllegalArgumentException: MimeTypes must not be empty}。 * * param jsonMapper Spring Boot 自动配置的 Jackson 3 JsonMapper */publicSimpleBodyLoggingEncoder(JsonMapperjsonMapper){super(jsonMapper,MediaType.APPLICATION_JSON,newMediaType(application,*json));}/** * 带自定义 MIME 类型的构造函数。 * * param jsonMapper Spring Boot 自动配置的 Jackson 3 JsonMapper * param mimeTypes 自定义的 MIME 类型 */publicSimpleBodyLoggingEncoder(JsonMapperjsonMapper,MimeType...mimeTypes){super(jsonMapper,mimeTypes);}/** * 编码方法在序列化完成后、发送前拦截数据流记录请求体内容。 * p * 实现细节 * ol * li调用父类的 {code encode} 完成实际的序列化/li * li使用 {code doOnNext} 旁路读取每个 {link DataBuffer}/li * li将字节数组转为 UTF-8 字符串并打印/li * lib关键/b调用 {code readPosition(0)} 重置读取位置 * 否则原始数据流会被消费导致请求发送失败/li * /ol * * param inputStream 要序列化的对象流 * param bufferFactory DataBuffer 工厂 * param elementType 元素类型 * param mimeType MIME 类型 * param hints 附加提示 * return 编码后的 DataBuffer 流 */OverridepublicFluxDataBufferencode(Publisher?inputStream,DataBufferFactorybufferFactory,ResolvableTypeelementType,MimeTypemimeType,MapString,Objecthints){returnsuper.encode(inputStream,bufferFactory,elementType,mimeType,hints).doOnNext(dataBuffer-{// 读取缓冲区中的可读字节数byte[]bytesnewbyte[dataBuffer.readableByteCount()];dataBuffer.read(bytes);// 关键重置读取位置到 0保证后续流程能重新读取相同数据dataBuffer.readPosition(0);// 转换为 UTF-8 字符串并打印StringbodynewString(bytes,StandardCharsets.UTF_8);log.debug(Body: {},body);});}}}由于 Spring AI 2.0.1 修订自动配置获取的是原始的 RestClient.Builder 和 WebClient.Builder不再应用全局的 RestClientCustomizer因此需要手动定义 DeepSeekApi Bean才能让自定义的日志拦截器生效。值得注意的是同步请求和流式请求是不同的拦截方式并且流式请求只有全部返回完成后才会输出访问信息。五、测试效果启动项目发送访问模型请求查看 IDEA 控制台2026-10-30T15:38:29.53608:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Request 2026-10-30T15:38:29.53608:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : URI: https://api.deepseek.com/chat/completions 2026-10-30T15:38:29.53608:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Method: POST 2026-10-30T15:38:29.53608:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Body: {messages:[{content:请用中文回复,role:system},{content:功夫主演是谁\r\nYour response should be in JSON format.\nDo not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\nDo not include markdown code blocks in your response.\nRemove the json markdown from the output.\nHere is the JSON Schema instance your output must adhere to:\n{\r\n \$schema\ : \https://json-schema.org/draft/2020-12/schema\,\r\n \type\ : \object\,\r\n \properties\ : {\r\n \actor\ : {\r\n \type\ : \string\\r\n },\r\n \movies\ : {\r\n \type\ : \array\,\r\n \items\ : {\r\n \type\ : \string\\r\n }\r\n }\r\n },\r\n \required\ : [ \actor\, \movies\ ],\r\n \additionalProperties\ : false\r\n}\n,role:user}],model:deepseek-v4-flash,max_tokens:4096,stream:false,temperature:0.7,thinking:{type:disabled}} 2026-10-30T15:38:29.67708:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Response 2026-10-30T15:38:29.67708:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Status Code: 200 OK 2026-10-30T15:38:30.27508:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Body: {id:9df4be60-cfc5-4091-bb0f-46ded83da0d0,object:chat.completion,created:1793345900,model:deepseek-flash,choices:[{index:0,message:{role:assistant,content:{\actor\:\周星驰\,\movies\:[\功夫\]}},logprobs:null,finish_reason:stop}],usage:{prompt_tokens:219,completion_tokens:13,total_tokens:232,prompt_tokens_details:{cached_tokens:0},prompt_cache_hit_tokens:0,prompt_cache_miss_tokens:219},system_fingerprint:bc19ba97cbe016479903222bb1578db6} 2026-10-30T15:38:30.27808:00 DEBUG 13060 --- [knowledge-base-agent] [nio-8001-exec-2] c.l.c.h.deepSeek.LoggingInterceptor : Time: 742 ms六、总结与常见问题6.1 实现思路小结日志级别先行在application.yml中把项目包路径如com.lh的日志级别调到DEBUG这是所有日志能打印出来的前提且该配置优先级高于logback-spring.xml。响应体可重复读ClientHttpResponse#getBody()返回的流只能读一次因此用BufferingClientHttpResponseWrapper缓存响应体字节数组每次getBody()都返回新的ByteArrayInputStream。同步请求靠拦截器LoggingInterceptor实现ClientHttpRequestInterceptor挂到RestClient上打印 URI、Method、请求体、状态码、响应体与耗时并返回包装后的响应。流式请求靠过滤器 编码器WebClient用ExchangeFilterFunction记录 URL/Method/Headers用自定义SimpleBodyLoggingEncoder继承JacksonJsonEncoder在序列化层旁路读取请求体读完必须readPosition(0)复位。手动装配 DeepSeekApiSpring AI 2.0.1 不再应用全局RestClientCustomizer需自定义DeepSeekApi与DeepSeekChatModelBean才能让上述拦截器真正生效。6.2 常见排查场景场景一日志完全没有打印现象启动项目并调用模型后控制台看不到 Request 之类的日志。原因application.yml中配置的包路径与实际项目包路径不一致例如照抄了示例里的com.lh日志级别仍是INFO而拦截器内部用log.isDebugEnabled()做了判断级别不够时整段逻辑被跳过LoggingInterceptor没有挂到实际使用的RestClient上未走自定义DeepSeekApi。解决办法把logging.level下的 key 改成自己项目的真实包路径确认级别为DEBUG再检查DeepSeekApiConfig是否被扫描到、DeepSeekApiBean 是否确实由自定义方法创建可加断点或打印确认。场景二响应体读取后反序列化失败现象日志能打印出响应体但随后抛出类似stream closed、JSON parse error、响应内容为空的异常。原因在拦截器中直接调用了StreamUtils.copyToByteArray(response.getBody())消费了原始流却没有把包装后的响应返回给调用方导致 Spring AI 后续解析 JSON 时流已被读完。解决办法确保intercept方法在读取响应体后return new BufferingClientHttpResponseWrapper(response, responseBody);而不是返回原始response同时确认包装类的getBody()每次返回新的ByteArrayInputStream。场景三流式请求看不到请求体日志现象同步调用日志正常但streamtrue的流式调用只打印了 URL 和 Method没有Body。原因ExchangeFilterFunction只能拿到请求元数据拿不到请求体若没有替换默认的 JSON 编码器请求体日志自然缺失。解决办法在WebClient.builder().codecs(...)中用configurer.defaultCodecs().jacksonJsonEncoder(new SimpleBodyLoggingEncoder(jsonMapper))替换默认编码器并在encode中读取DataBuffer后调用readPosition(0)复位避免破坏后续发送。另外注意流式请求的日志要等全部返回完成后才会输出。