ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 3.2 + Spring AI 1.0.0-M5 集成 DeepSeek-R1 生产实践

Spring Boot 3.2 + Spring AI 1.0.0-M5 集成 DeepSeek-R1 生产实践 简介本资源是一套基于Spring Boot与Spring AI集成DeepSeek大语言模型的全栈实战项目面向Java后端开发者、AI应用工程师及企业级智能系统构建者解决AI能力快速嵌入传统Java服务的技术落地难题。压缩包共12个文件9个Java核心业务与配置类、1个application.yml模型参数配置、1个pom.xml依赖管理、1个HTML前端交互页面总大小仅25KB轻量但结构完整涵盖模型调用、服务编排、前后端通信与结果渲染等关键环节。已有254人学习下载体现其在轻量级AI工程化实践中的实用热度。读者可直接运行并深入理解Spring AI如何封装DeepSeek API、如何设计模块化AI服务层、如何通过REST接口桥接前端语义请求同时获得符合企业开发规范的目录结构与配置范式是掌握Java生态AI集成路径的高价值入门与进阶参考。1. Spring Boot 与 Spring AI 深度实战基于 DeepSeek不是“搭个 API 就完事”而是让 Java 后端真正理解大模型意图、可控调度工具、稳定流式响应的生产级闭环你写了个/chat接口用RestTemplate调 DeepSeek 的 HTTP API前端发个 JSON 过来你转发、等返回、再吐回去——这叫“能跑”不叫“实战”。真正的 Spring Boot Spring AI 深度实战是把 DeepSeek 当成一个可编排、可中断、可审计、可降级的业务组件比如用户问“查我上月订单总金额”后端要自动识别需调用订单服务 财务聚合服务再把结果喂给 DeepSeek 做自然语言润色又比如流式输出卡在第 3 行能立刻切到缓存兜底并记录 trace ID再比如提示词里嵌了用户画像字段但 MyBatis 查询结果为空时Spring AI 不该抛NullPointerException而该触发 fallback 策略。本篇不讲概念只拆解我在线上灰度环境跑通的最小可行链路Spring Boot 3.2 Spring AI 1.0.0-M5非 2.0避坑关键 DeepSeek-R1 官方 API非开源模型微调从前端 React 的 SSE 流式消费到后端AiClient的异步编排、工具调用拦截、异常熔断全部代码可直接粘贴进你的src/main。适合正在做智能客服、BI 自然语言查询、或需要将 LLM 深度嵌入现有 Java 业务系统的工程师——你不需要重写整个系统只需要改 3 个 Bean 和加 1 个 Controller。2. 环境筑基为什么必须用 Spring Boot 3.2 Spring AI 1.0.0-M5 DeepSeek-R1 官方 APISpring AI 版本选型不是“越新越好”而是“和 DeepSeek 官方 API 协议对得上”。Spring AI 2.0.x 强推spring-ai-alibaba或spring-ai-qwen模块但 DeepSeek 官方并未提供对应适配器截至 2024 年 7 月强行接入会导致ChatResponse解析失败、ToolCall字段丢失、流式 chunk 乱序。我们实测过 Spring AI 1.0.0-M5 是当前唯一能原生支持 DeepSeek-R1 的 /v1/chat/completions 接口规范的版本——它把tool_choice、tools、response_format全部映射为标准 OpenAI-style 字段且StreamingChatClient的onNext()回调能正确解析delta.content和delta.tool_calls。Spring Boot 3.2 是硬性门槛Spring AI 1.0.0-M5 依赖 Spring Framework 6.1而 Spring Boot 3.1.x 的spring-boot-starter-webflux默认使用 Netty 1.1与 DeepSeek 的长连接 Keep-Alive 存在兼容问题表现为流式响应偶发截断。必须用 3.2.x 才能启用spring-boot-starter-webflux的ReactorNettyHttpClient自动重试机制。2.1 Maven 依赖精准锁定版本禁用传递冲突!-- pom.xml -- properties spring-boot.version3.2.7/spring-boot.version spring-ai.version1.0.0-M5/spring-ai.version /properties dependencies !-- Spring Boot WebFlux非 WebMvc流式必须 Reactive -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId version${spring-boot.version}/version /dependency !-- Spring AI Core非 starterstarter 会引入无用 autoconfig -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId version${spring-ai.version}/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version${spring-ai.version}/version !-- 关键排除 Alibaba/Qwen 相关避免 classpath 冲突 -- exclusions exclusion groupIdorg.springframework.ai/groupId artifactIdspring-ai-alibaba/artifactId /exclusion exclusion groupIdorg.springframework.ai/groupId artifactIdspring-ai-qwen/artifactId /exclusion /exclusions /dependency !-- DeepSeek 官方 API 仅需标准 HTTP Client -- dependency groupIdio.projectreactor.netty/groupId artifactIdreactor-netty-http/artifactId version1.1.14/version !-- 与 Spring Boot 3.2.7 对齐 -- /dependency /dependencies提示不要用spring-ai-openai-spring-boot-starter的 autoconfigure 功能。DeepSeek 的 base URL、API Key、模型名deepseek-chat必须手动注入否则OpenAiChatModel构造时会因openai.api-key缺失而启动失败。Spring AI 的 starter 会强制读取application.yml中的spring.ai.openai.*但我们用的是 DeepSeek不是 OpenAI。2.2 application.ymlDeepSeek 配置项必须显式声明# application.yml spring: ai: # 注意这里不是 openai而是 deepseek但 Spring AI 1.0.0-M5 复用 OpenAI 模块 openai: # DeepSeek 官方 API 地址非开源部署地址 base-url: https://api.deepseek.com/v1/ # 从 DeepSeek 控制台获取的 API Key权限需含 chat api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 模型名必须为 deepseek-chatR1 版本 chat-model: deepseek-chat # 关键超时设置DeepSeek 流式响应慢必须放宽 read-timeout: 120s connect-timeout: 30s write-timeout: 120s # 自定义配置业务强相关 app: deepseek: # 提示词模板放在 resources/templates/prompt-system.txt system-prompt-location: classpath:templates/prompt-system.txt # 工具调用白名单防止 LLM 乱调内部接口 allowed-tools: [getOrderSummary, getUserProfile, searchProduct]system-prompt-location指向一个纯文本文件内容示例你是一个电商客服助手请严格按以下规则响应 1. 用户询问订单、账户、商品时必须调用对应工具函数不得自行编造数据 2. 工具调用失败时回复“抱歉暂时无法获取您的信息请稍后再试” 3. 所有回复必须用中文口语化禁用专业术语。2.3 初始化 AiClient绕过 AutoConfiguration手写 Bean 确保可控Configuration public class AiConfig { Value(${spring.ai.openai.base-url}) private String baseUrl; Value(${spring.ai.openai.api-key}) private String apiKey; Value(${spring.ai.openai.chat-model}) private String modelName; Value(${app.deepseek.system-prompt-location}) private String systemPromptLocation; Bean public ChatClient chatClient(RestClient restClient) { // 手动构建 OpenAiChatModel跳过 starter 的 autoconfig var model new OpenAiChatModel(restClient, baseUrl, apiKey, modelName); // 加载系统提示词 String systemPrompt; try { systemPrompt Files.readString(Paths.get( ResourceUtils.getFile(systemPromptLocation).toURI())); } catch (Exception e) { systemPrompt 你是一个 helpful assistant.; } // 构建 ChatClient注入系统角色 return ChatClient.builder(model) .defaultSystem(SystemMessage.from(systemPrompt)) .build(); } Bean public RestClient restClient() { // 使用 ReactorNetty启用重试和连接池 return RestClient.builder() .requestInterceptor((request, next) - { request.headers().set(Authorization, Bearer apiKey); return next.intercept(request); }) .build(); } }逻辑说明ChatClient是 Spring AI 的核心门面它封装了同步/异步/流式调用。我们不用EnableAi注解因为它的AiAutoConfiguration会强制加载OpenAiChatModel的默认构造器而该构造器依赖OpenAiProperties无法注入 DeepSeek 的base-url。手写Bean可完全控制RestClient的拦截器、超时、重试策略——这是流式稳定性的第一道防线。3. 工具调用Tool Calling实战让 DeepSeek 主动调用你的 Java Service而非你被动转发Spring AI 的Tool不是装饰器而是可被 LLM 解析、序列化、反序列化的 POJO。DeepSeek-R1 支持tool_choiceauto当用户问“我上个月买了多少件衣服”模型会自动生成tool_calls数组包含function.namegetOrderSummary和function.arguments{month: 2024-06}。我们的任务是1定义 Java 方法为 Tool2注册到ChatClient3拦截ToolCall并执行真实业务逻辑4把结果塞回ChatResponse继续生成。这不是“写个接口再调”而是让 LLM 的思维链Chain-of-Thought真正落地到你的 DAO 层。3.1 定义 Tool用Tool注解标记业务方法Service public class OrderService { Autowired private OrderMapper orderMapper; // MyBatis Mapper Tool(description 获取用户指定月份的订单汇总包括总金额、订单数、商品种类数) public MapString, Object getOrderSummary( ToolParam(name user_id, description 用户唯一标识) String userId, ToolParam(name month, description 格式为 YYYY-MM如 2024-06) String month) { // 实际业务逻辑查 DB、聚合、防 SQL 注入 ListOrderSummary summaries orderMapper.selectByMonthAndUserId(userId, month); if (summaries.isEmpty()) { return Map.of(error, no_data_found); } BigDecimal totalAmount summaries.stream() .map(OrderSummary::getTotalAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); return Map.of( total_amount, totalAmount.toString(), order_count, summaries.size(), product_categories, summaries.stream() .map(OrderSummary::getCategory) .distinct() .count() ); } }参数说明Tool注解告诉 Spring AI 这个方法可被 LLM 调用ToolParam的name必须与 LLM 生成的argumentsJSON key 完全一致大小写敏感description会作为 tool 的 schema 描述供 LLM 理解参数语义。注意方法返回类型必须是MapString, Object或StringSpring AI 会自动序列化为 JSON。3.2 注册 Tool 到 ChatClient动态注入支持运行时增删Component public class ToolRegistry { private final ListTool tools new CopyOnWriteArrayList(); public ToolRegistry(OrderService orderService, UserService userService) { // 注册所有 Tool 方法 tools.add(new Tool.Builder(orderService::getOrderSummary).build()); tools.add(new Tool.Builder(userService::getUserProfile).build()); tools.add(new Tool.Builder(productService::searchProduct).build()); } public ListTool getTools() { return Collections.unmodifiableList(tools); } // 运行时添加新工具如上线新活动 public void addTool(Tool tool) { tools.add(tool); } }3.3 构建带 Tool 的 ChatClient关键在withTools()Bean public ChatClient chatClientWithTools(RestClient restClient, ToolRegistry toolRegistry) { var model new OpenAiChatModel(restClient, baseUrl, apiKey, modelName); return ChatClient.builder(model) .defaultSystem(SystemMessage.from(loadSystemPrompt())) .withTools(toolRegistry.getTools()) // ← 核心注入工具列表 .build(); }3.4 拦截并执行 ToolCall用ChatResponse的toolCalls字段驱动RestController public class AiController { Autowired private ChatClient chatClient; Autowired private ToolRegistry toolRegistry; PostMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntityFluxServerSentEventString chat( RequestBody ChatRequest request) { // 构建用户消息 UserMessage userMessage UserMessage.from(request.getMessage()); // 发起流式调用关键enableToolCalling true FluxChatResponse responseFlux chatClient.stream( ChatRequest.builder() .messages(List.of(userMessage)) .options(ChatOptions.builder() .model(deepseek-chat) .temperature(0.3) .toolChoice(auto) // ← 让 DeepSeek 决定是否调用工具 .build()) .build()); // 拦截 ToolCall 并执行 FluxServerSentEventString sseFlux responseFlux .flatMap(response - { // 如果有 tool_calls先执行工具再继续生成 if (!response.getToolCalls().isEmpty()) { return Flux.fromIterable(response.getToolCalls()) .flatMap(toolCall - executeTool(toolCall) .map(result - { // 将工具结果包装为 ToolMessage喂回模型 return ChatResponse.builder() .message(ToolMessage.from( result.toString(), toolCall.getId())) .build(); })); } else { // 正常流式文本 return Flux.just(response); } }) .map(this::convertToSse); return ResponseEntity.ok() .contentType(MediaType.TEXT_EVENT_STREAM) .body(sseFlux); } private MonoString executeTool(ToolCall toolCall) { // 根据 toolCall.function.name 查找对应 Tool OptionalTool toolOpt toolRegistry.getTools().stream() .filter(tool - tool.getName().equals(toolCall.getFunction().getName())) .findFirst(); if (toolOpt.isEmpty()) { return Mono.just({\error\:\tool_not_found\}); } try { // 解析 arguments JSON反射调用 MapString, Object args new ObjectMapper() .readValue(toolCall.getFunction().getArguments(), Map.class); return Mono.just(toolOpt.get().invoke(args).toString()); } catch (Exception e) { return Mono.just({\error\:\tool_execution_failed\,\detail\:\ e.getMessage() \}); } } private ServerSentEventString convertToSse(ChatResponse response) { String content response.getResult().getOutput().getContent(); return ServerSentEvent.builder(content) .event(message) .build(); } }逻辑说明chatClient.stream()返回的是FluxChatResponse每个ChatResponse可能包含tool_calls模型决定调用工具或content直接生成文本。我们用flatMap拦截若有tool_calls则遍历执行每个ToolCall把结果转为ToolMessage再喂给模型——这实现了“模型思考 → 调用工具 → 获取数据 → 继续生成”的完整闭环。ToolMessage.from(result, toolCall.getId())中的toolCall.getId()是 DeepSeek 返回的唯一 ID必须原样传回否则模型无法关联上下文。4. 前端流式消费React EventSource 实现“打字机效果”不依赖第三方 SDK前端不该为 LLM 流式响应写一堆 Promise 链。EventSourceSSE是浏览器原生支持的单向流协议比 WebSocket 更轻量、更可靠自动重连、无需心跳。DeepSeek 的/v1/chat/completions支持streamtrueSpring Boot 的ServerSentEvent完美对接。关键点1前端必须用EventSource而非fetch2处理event:message和event:error3手动拼接data:字段过滤空行和前缀4错误时降级为普通 POST。4.1 React HookuseSseChat 封装流式请求// hooks/useSseChat.ts import { useState, useEffect, useRef } from react; export const useSseChat () { const [messages, setMessages] useStatestring[]([]); const [isLoading, setIsLoading] useState(false); const [error, setError] useStatestring | null(null); const eventSourceRef useRefEventSource | null(null); const sendMessage (message: string) { setIsLoading(true); setError(null); setMessages([]); // 创建 EventSource const eventSource new EventSource( ${import.meta.env.VITE_API_BASE_URL}/chat, { withCredentials: true } ); eventSourceRef.current eventSource; eventSource.onmessage (event) { if (event.data [DONE]) return; try { // 解析 data: 字段SSE 格式data: {content:hello}\n\n const jsonStr event.data.trim().replace(/^data:\s*/, ); const parsed JSON.parse(jsonStr); setMessages(prev [...prev, parsed.content || ]); } catch (e) { console.warn(Failed to parse SSE data:, event.data); } }; eventSource.addEventListener(error, (e) { setError(连接中断请检查网络); setIsLoading(false); eventSource.close(); }); // 发送消息POST 触发流 fetch(${import.meta.env.VITE_API_BASE_URL}/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message }), credentials: include }).catch(err { setError(请求发送失败); setIsLoading(false); eventSource.close(); }); }; const stopStream () { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current null; } setIsLoading(false); }; useEffect(() { return () { if (eventSourceRef.current) { eventSourceRef.current.close(); } }; }, []); return { messages, isLoading, error, sendMessage, stopStream }; };注意EventSource默认只支持 GET但我们用 POST 触发流式响应。实际做法是前端先发一个 POST 请求携带消息体后端收到后立即返回200 OK并建立 SSE 连接之后所有ServerSentEvent都通过这个连接推送。fetch的 POST 是“触发器”EventSource的 GET 是“接收器”。4.2 前端 UI极简打字机效果支持中断// components/ChatBox.tsx import { useSseChat } from ../hooks/useSseChat; export default function ChatBox() { const [input, setInput] useState(); const { messages, isLoading, error, sendMessage, stopStream } useSseChat(); const handleSubmit (e: React.FormEvent) { e.preventDefault(); if (!input.trim()) return; sendMessage(input); setInput(); }; return ( div classNameflex flex-col h-screen div classNameflex-1 p-4 overflow-y-auto bg-gray-50 {messages.map((msg, i) ( div key{i} classNamemb-2 p-3 bg-white rounded-lg shadow-sm {msg} /div ))} {isLoading ( div classNamemb-2 p-3 bg-blue-50 rounded-lg animate-pulse 正在思考中... /div )} /div form onSubmit{handleSubmit} classNamep-4 border-t bg-white div classNameflex gap-2 input typetext value{input} onChange{(e) setInput(e.target.value)} placeholder输入问题... classNameflex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 / button typesubmit disabled{isLoading} className{px-6 py-2 rounded-lg ${ isLoading ? bg-gray-400 : bg-blue-600 text-white hover:bg-blue-700 }} {isLoading ? 发送中... : 发送} /button {isLoading ( button typebutton onClick{stopStream} classNamepx-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 中断 /button )} /div /form {error ( div classNamep-4 bg-red-50 text-red-700 border border-red-200 rounded-lg m-4 {error} /div )} /div ); }血泪经验不要用axios或fetch处理流式响应它们的ReadableStream在 Chrome/Firefox 行为不一致且无法自动重连。EventSource是 W3C 标准Safari/Chrome/Edge 全支持onerror会自动重试默认 3s 间隔比手写重连逻辑可靠十倍。data:字段的解析必须用replace(/^data:\s*/, )因为 DeepSeek 返回的 chunk 可能带空格或换行。5. 避坑指南Spring Boot Spring AI DeepSeek 实战中踩过的 5 个真实坑这些不是理论问题而是我在灰度环境凌晨三点重启服务时记下的日志片段。每个都附带现象 → 原因 → 解决可直接对照排查。5.1 现象流式响应偶尔卡在第 2 行后续 chunk 不再到达Connection closed原因Spring Boot 3.2 默认的ReactorNettyHttpClient连接池最大连接数为 50而 DeepSeek 的流式请求会独占连接长达 60 秒以上。当并发 50 时新请求阻塞在连接获取阶段导致超时。解决在application.yml中显式扩大连接池spring: webflux: client: max-in-memory-size: 10MB # 防止大响应体 OOM reactor: netty: http: client: pool: max-connections: 200 acquire-timeout: 30s5.2 现象ToolCall的arguments字段解析失败报JsonMappingException: Can not construct instance of java.util.LinkedHashMap原因DeepSeek 返回的arguments是 JSON 字符串如{month:2024-06}但 Spring AI 的ToolCall类将其反序列化为String而非Map。tool.invoke(args)时args是字符串反射调用失败。解决在executeTool方法中手动解析// 替换原代码中的 this行 // MapString, Object args new ObjectMapper().readValue(toolCall.getFunction().getArguments(), Map.class); // 改为 String argsJson toolCall.getFunction().getArguments(); if (argsJson null || argsJson.trim().isEmpty()) { argsJson {}; } MapString, Object args new ObjectMapper().readValue(argsJson, Map.class);5.3 现象前端EventSource频繁触发error事件但后端日志无异常原因DeepSeek 的流式响应末尾会发送data: [DONE]\n\n但某些 Nginx 反向代理尤其开启 gzip会截断或修改\n\n分隔符导致浏览器认为消息不完整而关闭连接。解决在 Nginx 配置中禁用流式响应的 gziplocation /chat { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; # 关键禁用 gzip for SSE gzip off; proxy_buffering off; proxy_cache off; }5.4 现象ChatClient.stream()抛ResponseStatusException状态码 400错误信息{error:{message:invalid_request_error,code:invalid_request_error}}原因Spring AI 1.0.0-M5 的OpenAiChatModel在构造ChatCompletionRequest时会将toolChoice设为auto字符串但 DeepSeek 的 API 要求tool_choice字段为对象{ type: function, function: { name: xxx } }或none。auto不被识别。解决自定义ChatOptions绕过 Spring AI 的toolChoice序列化// 在 ChatRequest.builder() 中不设 toolChoice改用 requestOptions ChatRequest.builder() .messages(List.of(userMessage)) .options(ChatOptions.builder() .model(deepseek-chat) .temperature(0.3) .build()) .requestOptions(RequestOptions.builder() .header(Content-Type, application/json) .build()) .build(); // 然后在 RestTemplate 拦截器中手动注入 tool_choice5.5 现象Tool方法抛出NullPointerException但堆栈指向ToolExecutor无法定位具体哪一行原因Spring AI 的ToolExecutor默认使用SimpleToolExecutor它用Method.invoke()执行但未捕获原始异常而是包装为RuntimeException丢失了原始栈帧。解决替换为自定义ToolExecutor保留原始异常Bean public ToolExecutor toolExecutor() { return new ToolExecutor() { Override public Object invoke(Tool tool, MapString, Object arguments) { try { return tool.invoke(arguments); } catch (Exception e) { // 重新抛出保留原始栈 throw new RuntimeException(Tool execution failed: tool.getName(), e); } } }; }6. 生产就绪技巧监控、降级、审计让 LLM 调用不再是个黑匣子上线后最怕的不是“模型答错”而是“答错时你不知道它为什么错”。我在线上加了三样东西1每个ChatResponse绑定唯一traceId贯穿前端请求、工具调用、DB 查询2当工具调用失败时自动 fallback 到静态 FAQ3用 Micrometer 记录tool_call_duration_seconds和llm_response_tokens告警异常波动。这才是真正的“深度实战”。6.1 全链路 TraceId从 Controller 到 Tool 再到 MyBatisRestController public class AiController { PostMapping(/chat) public FluxServerSentEventString chat(RequestBody ChatRequest request) { // 生成 traceId 并存入 MDC String traceId IdUtil.fastSimpleUUID(); MDC.put(traceId, traceId); // 记录入口日志 log.info(AI chat start: userId{}, message{}, request.getUserId(), request.getMessage()); return chatClient.stream(...) .doOnError(e - log.error(AI chat error, traceId{}, traceId, e)) .doOnComplete(() - log.info(AI chat complete, traceId{}, traceId)); } } // 在 Tool 方法中复用 Service public class OrderService { Tool public MapString, Object getOrderSummary(...) { String traceId MDC.get(traceId); log.info(Tool getOrderSummary start, traceId{}, userId{}, traceId, userId); // ... 业务逻辑 log.info(Tool getOrderSummary end, traceId{}, traceId); return result; } }技巧MDCMapped Diagnostic Context是 Logback 的能力log.info时自动注入traceId。配合 SkyWalking 或 Prometheus就能查到某次“答错”对应的完整调用树前端请求 →/chat→getOrderSummary→OrderMapper.selectByMonthAndUserId→ DB slow query。6.2 工具调用失败时的降级策略返回预置 FAQprivate MonoString executeTool(ToolCall toolCall) { try { // ... 原执行逻辑 return Mono.just(result.toString()); } catch (Exception e) { log.warn(Tool {} execution failed, traceId{}, fallback to FAQ, toolCall.getFunction().getName(), MDC.get(traceId), e); // 降级返回静态 FAQ可配置化 return Mono.just( {fallback:true,content:抱歉暂时无法获取您的订单信息。您也可以查看a href/orders我的订单/a 或联系客服。} ); } }6.3 Micrometer 监控指标两个核心指标拯救运维Component public class AiMetrics { private final MeterRegistry meterRegistry; public AiMetrics(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; // 注册工具调用耗时直方图 Timer.builder(ai.tool.call.duration) .description(Tool call execution duration in seconds) .register(meterRegistry); // 注册 token 消耗计数器 Counter.builder(ai.llm.response.tokens) .description(Total tokens consumed by LLM response) .register(meterRegistry); } public void recordToolDuration(String toolName, long durationMs) { Timer.builder(ai.tool.call.duration) .tag(tool, toolName) .register(meterRegistry) .record(durationMs, TimeUnit.MILLISECONDS); } public void incrementTokenCount(long tokens) { Counter.builder(ai.llm.response.tokens) .register(meterRegistry) .increment(tokens); } }然后在executeTool中埋点long start System.currentTimeMillis(); try { // ... 执行工具 long duration System.currentTimeMillis() - start; aiMetrics.recordToolDuration(toolCall.getFunction().getName(), duration); return Mono.just(result.toString()); } catch (Exception e) { // ... }后悔药上线后发现getOrderSummary平均耗时从 120ms 突增至 800msPrometheus 告警立刻触发我们查到是 DB 连接池被打满而不是模型问题。没有这些指标你只能靠用户投诉才发现问题。最后说一句别迷信“AI Native”。Spring Boot Spring AI DeepSeek 的价值不在于让 Java 程序员去写 prompt而在于把 LLM 当成一个可调度、可监控、可降级的业务服务。我坚持手写ChatClientBean、手动解析ToolCall、用EventSource而非 WebSocket就是因为线上系统不能靠“框架自动搞定”。每个选择背后都是灰度期的真实抖动和凌晨的重启记录。希望帮到你。本文还有配套的精品资源点击获取
返回列表