生產(chǎn)落地:從架構(gòu)設(shè)計到工具調(diào)用完整拆解)
最近不少朋友在問同一件事Spring官方那個AI框架到底能不能用在生產(chǎn)項目里我的答案是能而且已經(jīng)在這么干了。今天這篇就基于我一整個項目周期的實際體驗把Spring AI從架構(gòu)設(shè)計、結(jié)構(gòu)化輸出、工具調(diào)用到記憶管理的完整鏈路拆開講清楚。項目里我們接的是DeepSeek和通義千問中間因為兼容性、上下文管理、結(jié)構(gòu)化輸出踩了不少坑這些都會逐一說明。如果你是Java開發(fā)者想在Spring Boot項目里正經(jīng)對接AI能力這篇應(yīng)該能幫你省下幾周的試錯時間。順便也解釋一下為什么我最終沒有選LangChain4j。1. Spring AI的架構(gòu)設(shè)計與核心組件——它到底解決了什么問題1.1 兼容層設(shè)計一套API統(tǒng)一接入各家模型先聊最底層的設(shè)計動機。你寫一個AI功能第一反應(yīng)是直接調(diào)OpenAI SDK或者通義的HTTP接口。單個模型這么搞沒問題但一旦業(yè)務(wù)要求你同時兼容ChatGPT、通義千問、DeepSeek、Ollama本地模型麻煩就來了——每家的接口風格不一樣參數(shù)命名不一樣返回格式不一樣換一次模型等于重寫一遍客戶端邏輯。Spring AI最核心的價值就是把這一層差異全部吃掉。它定義了一套統(tǒng)一的ChatModel、EmbeddingModel接口底層通過各個ModelClient適配具體廠商的API。你的業(yè)務(wù)代碼只依賴Spring AI的接口至于背后是OpenAI還是DashScope完全由配置決定。我在項目里的實際配置是這樣的spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus后面要換成DeepSeek只需要引入對應(yīng)依賴再把配置改成spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat業(yè)務(wù)代碼一行不用動。這個特性在LangChain4j里也可以實現(xiàn)但Spring AI作為Spring官方項目和Spring Boot的ConfigurationProperties綁定、自動裝配、Starter機制結(jié)合得更自然。你引入spring-ai-starter-model-dashscope依賴所有配置項自動就有了補全提示幾乎不會出現(xiàn)配置寫錯導(dǎo)致啟動失敗的情況。1.2 ChatClient、Prompt與Advisor的關(guān)系這三個概念搞明白Spring AI就算入門了一半。ChatClient是門面負責對外提供prompt()、stream()、call()這些方法Prompt是請求載體里面封裝了一組MessageAdvisor則是攔截器類似Spring MVC里的HandlerInterceptor在請求前后插入邏輯。我畫個粗粒度的心智模型ChatClient等同于RestTemplatePrompt等同于HttpEntityAdvisor等同于Filter。這三個組件配合起來才能實現(xiàn)上下文記憶、日志記錄、RAG檢索增強這類附加能力。Advisor是一個被很多人忽略的關(guān)鍵設(shè)計。你后續(xù)要加的日志監(jiān)控、Token統(tǒng)計、多輪記憶、敏感詞過濾全部通過實現(xiàn)Advisor接口塞進去而不是在業(yè)務(wù)代碼里寫一堆重復(fù)的前置邏輯。這是整個Spring AI框架設(shè)計最聰明的地方。2. 環(huán)境準備與第一個模型調(diào)用依賴配置、Starter選擇與跑通對話2.1 依賴引入和Starter選擇別一上來就全量引入Spring AI目前主流的Starter有這么幾個使用場景推薦Starter說明通義千問/百煉spring-ai-starter-model-dashscope阿里云百煉平臺國內(nèi)訪問穩(wěn)定OpenAI/DeepSeekspring-ai-starter-model-openaibase-url換成DeepSeek即可本地模型spring-ai-starter-model-ollamaOllama跑本地模型數(shù)據(jù)不出內(nèi)網(wǎng)智譜GLMspring-ai-starter-model-zhipuai智譜開放平臺我的建議是一開始只引入你當前需要的那一個。Spring AI的Starter之間雖然理論上可以共存但多個Starter同時存在時配置項會變復(fù)雜排查問題難度翻倍。我在早期就是同時引入了OpenAI和DashScope兩個Starter結(jié)果兩邊模型配置互相干擾花了整整半天才定位到是某個自動裝配類被條件注解誤觸發(fā)。官方文檔還有個坑Spring AI的版本號要跟Boot版本匹配。Spring AI 1.0.0 GA版本對應(yīng)Spring Boot 3.4.x如果你用的是Spring Boot 3.2或者3.3需要選擇對應(yīng)的Spring AI版本否則容器啟動會直接報NoSuchMethodError。2.2 從跑通到用對ChatClient的三種調(diào)用形態(tài)依賴配好之后第一步就是注入ChatClient跑通一個最簡單的對話。我直接貼代碼Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient builder .defaultSystem(你是一個精通Java的開發(fā)助手回答盡量簡潔必要時給出代碼示例。) .build(); } public String chat(String message) { return chatClient.prompt() .user(message) .call() .content(); } }這段代碼里有兩個容易忽略的細節(jié)。第一defaultSystem()設(shè)置的是系統(tǒng)級指令后續(xù)每次調(diào)用都會自動帶上不需要重復(fù)傳第二ChatClient.Builder由Spring AI自動裝配注入但你可以在構(gòu)建時統(tǒng)一配置系統(tǒng)提示詞、模型參數(shù)如溫度、最大Token數(shù)讓所有請求走同一套默認策略。跑通同步調(diào)用之后你要知道ChatClient還有兩種調(diào)用形態(tài)stream()流式調(diào)用和entity()結(jié)構(gòu)化調(diào)用。流式調(diào)用適合對話類場景體驗像打字機一樣逐字輸出entity()則直接返回Java對象省去手動解析JSON的步驟。這兩個后面單獨講。我再強調(diào)一個實戰(zhàn)細節(jié)call()方法返回的ChatResponse里除了content()外還有一個getMetadata()方法可以拿到Token用量。很多人只在調(diào)試時關(guān)注這個但它其實是生產(chǎn)環(huán)境成本控制的關(guān)鍵數(shù)據(jù)來源。3. 結(jié)構(gòu)化輸出把模型返回的JSON直接變成Java對象3.1 BeanOutputConverter與JsonMapper注解告別手動JSON解析大多數(shù)AI應(yīng)用不能只返回文本你要的是一個能直接塞進數(shù)據(jù)庫或回傳給前端的Java對象。比如讓模型從一段對話里提取客戶姓名、聯(lián)系電話、意向等級這時候用String接收再手動ObjectMapper.readValue()遇到模型返回格式稍微漂移就報錯。Spring AI提供了BeanOutputConverter和JsonMapper注解來解決問題。BeanOutputConverter的作用是提示模型你務(wù)必只輸出JSON結(jié)構(gòu)是XXX而JsonMapper可以在用戶提示詞中動態(tài)拼接這個結(jié)構(gòu)約束。看下我的寫法public record CustomerInfo(String name, String phone, String intentLevel, String summary) {} public CustomerInfo extractCustomer(String dialogText) { var converter new BeanOutputConverter(CustomerInfo.class); return chatClient.prompt() .user(u - u.text( 請從下面的客服對話中提取客戶信息只輸出JSON不要輸出其他任何內(nèi)容。 對話內(nèi)容 {dialog} 輸出格式要求 {format} ) .param(dialog, dialogText) .param(format, converter.getFormat())) .call() .entity(CustomerInfo.class); }這段代碼的關(guān)鍵在于converter.getFormat()會生成一段Schema描述告訴模型字段的類型和含義。entity()方法內(nèi)部自動完成JSON反序列化不需要手動寫TypeReference。這里有一個顯著的避坑提示如果字段是長文本且內(nèi)容里可能包含換行或反斜杠記得要在對應(yīng)字段上加JsonDescription注解提供足夠的字段描述否則模型可能生成不符合預(yù)期的JSON結(jié)構(gòu)。另外如果你的模型上下文窗口較小getFormat()生成的Schema建議精簡字段數(shù)量實體字段太多時模型容易忽略某些字段。3.2 結(jié)構(gòu)化輸出在業(yè)務(wù)中的實際應(yīng)用自動生成作業(yè)數(shù)據(jù)我最近在做的一個項目需要模擬各種難度的Java作業(yè)題目用來測試在線判題系統(tǒng)。這里就是結(jié)構(gòu)化輸出的完美應(yīng)用場景。定義一個記錄類型public record HomeworkQuestion( String title, String difficulty, String description, String starterCode, String solutionCode, ListString testCases ) {}然后通過BeanOutputConverter一次批量生成多道題目public ListHomeworkQuestion generateQuestions(String topic, int count) { return chatClient.prompt() .user(u - u.text( 請生成{count}道關(guān)于{topic}的Java編程題難度覆蓋簡單、中等、困難。 嚴格按JSON數(shù)組格式輸出每道題包含title、difficulty、description、starterCode、solutionCode、testCases字段。 testCases是包含輸入和期望輸出的字符串數(shù)組每個元素格式為輸入期望輸出。 輸出格式要求 {format} ) .param(topic, topic) .param(count, count) .param(format, new BeanOutputConverterListHomeworkQuestion() {} .getFormat())) .call() .entity(new ParameterizedTypeReferenceListHomeworkQuestion() {}); }這里注意一點泛型列表不能直接用entity(List.class)因為運行時泛型信息會丟失必須用ParameterizedTypeReference包裝。這個API的坑我踩過一次不傳這個類會導(dǎo)致反序列化出ListLinkedHashMap而不是ListHomeworkQuestion。這套代碼在我們當前項目里運行了約兩個月生成準確率穩(wěn)定在95%以上。有時候模型會在主題比較偏的情況下生成重復(fù)題目后續(xù)加上去重校驗和相似度檢測就能解決。4. 工具調(diào)用實戰(zhàn)用Tool注解讓模型具備操作能力4.1 Tool注解定義函數(shù)模型自己決定什么時候調(diào)用結(jié)構(gòu)化輸出解決了模型輸出數(shù)據(jù)的問題但如果業(yè)務(wù)需求是讓模型根據(jù)用戶意圖查詢數(shù)據(jù)庫、調(diào)用接口那就輪到工具調(diào)用登場了。Spring AI通過Tool注解把Java方法暴露給模型模型內(nèi)部根據(jù)用戶請求決定要不要調(diào)用、傳什么參數(shù)。用過LangChain的都知道給模型掛工具的核心工作有兩塊一是生成函數(shù)描述參數(shù)叫啥、干啥用的二是把模型返回的函數(shù)調(diào)用參數(shù)映射到真實方法上。Spring AI的Tool注解把這兩步簡化到極致你只需要在方法上標注Tool并寫上ToolParam描述框架會通過反射自動生成函數(shù)Schema。一個體驗最好的版本是把Tool標注在Component的類方法上Spring AI自動裝配時會掃描并注冊到ChatClient??创aComponent public class HomeworkTools { private final JdbcTemplate jdbcTemplate; public HomeworkTools(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Tool(name queryHomeworkByKeyword, description 根據(jù)關(guān)鍵詞查詢作業(yè)庫中的題目返回題目列表) public String queryHomeworkByKeyword( ToolParam(description 查詢關(guān)鍵詞如多線程、集合、Spring Boot) String keyword) { ListMapString, Object list jdbcTemplate.queryForList( SELECT title, difficulty, description FROM homework WHERE title LIKE ? LIMIT 5, % keyword %); if (list.isEmpty()) { return 沒有找到相關(guān)題目; } return list.toString(); } }4.2 實際使用中的體驗和限制模型調(diào)用工具并不是每次都準確。我剛才分享的經(jīng)驗是工具方法的description寫得好不好直接決定調(diào)用準確率。描述得越具體模型越容易判斷這個用戶問題應(yīng)該調(diào)用哪個工具。比如查詢作業(yè)庫中的題目這個描述就太寬泛改成當用戶提到要看題目、要刷題、要根據(jù)知識點找題目時使用此工具查詢字面匹配關(guān)鍵詞的題目會好很多。還有一個容易出問題的地方模型會自己為參數(shù)賦值所以工具方法參數(shù)類型盡量用簡單類型比如String、int。不要用自定義對象或枚舉否則模型可能構(gòu)造不出合適的參數(shù)值調(diào)用報錯。Tool當前的局限性是它無法處理需要模型連續(xù)調(diào)用多個工具再聚合結(jié)果的場景。比如先查一下用戶有哪些課程再根據(jù)課程查作業(yè)模型會調(diào)用第一個工具拿到結(jié)果后自主決定是否繼續(xù)調(diào)用第二個。Spring AI目前的實現(xiàn)是一輪工具調(diào)用之后需要你自己在代碼里再次call()不會自動循環(huán)執(zhí)行。我嘗試過用ChatClient的advisors加一層遞歸邏輯來模擬這個能力能用但比較繁瑣這塊相比LangChain的Agent機制還差一點。5. 記憶與上下文從無狀態(tài)到有狀態(tài)的會話體驗5.1 MessageChatMemoryAdvisor把聊天記錄變成上下文大模型本身是無狀態(tài)的。你每次調(diào)用chatClient.prompt().user(你好).call()它都無法記住上一輪你說了什么。做聊天機器人必須自己管理對話歷史然后每次請求把歷史拼進Prompt里發(fā)給模型。Spring AI提供的MessageChatMemoryAdvisor就是干這個活的。用法極其簡單ChatMemory chatMemory new InMemoryChatMemory(); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();MessageChatMemoryAdvisor會根據(jù)會話ID默認從請求參數(shù)conversationId中取自動保存和加載歷史消息再用一個ChatMemory實例存儲消息列表。這樣你寫業(yè)務(wù)代碼的時候完全不用關(guān)心歷史拼接用戶每輪發(fā)來的消息都會自動帶上歷史上下文。這個設(shè)計唯一的缺點是Token消耗會隨對話輪次線性增長。模型上下文窗口是有限的一旦歷史消息太長要么超限報錯要么費用飆升。Spring AI提供了MessageWindowChatMemoryAdvisor只保留最近N條消息但它默認策略比較粗糙只截斷消息條數(shù)。在客服、教育這類場景中用戶可能連續(xù)詢問多個獨立問題上下文窗口選擇不當會影響回答質(zhì)量。5.2 無狀態(tài)環(huán)境如何保存會話記憶Redis方案InMemoryChatMemory的問題是內(nèi)存存儲服務(wù)重啟就丟多實例部署也無法共享會話記錄。生產(chǎn)環(huán)境必須把消息存到Redis或數(shù)據(jù)庫里。Spring AI自帶了RedisChatMemory但當前版本對序列化方式的處理有些別扭。我項目里踩過坑這里直接給出可行的方案。用一種更可控的方式——自己實現(xiàn)ChatMemory接口把消息以JSON結(jié)構(gòu)寫入Redis的List中配合過期時間自動清理Component public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; public RedisChatMemory(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public ListMessage get(String conversationId, int lastN) { var list redisTemplate.opsForList().range(chat: conversationId, -lastN, -1); if (list null || list.isEmpty()) { return new ArrayList(); } return list.stream() .map(json - (Message) JSON.parseObject(json, AbstractMessage.class)) .collect(Collectors.toList()); } Override public void add(String conversationId, ListMessage messages) { for (Message message : messages) { redisTemplate.opsForList().rightPush(chat: conversationId, JSON.toJSONString(message)); } redisTemplate.expire(chat: conversationId, Duration.ofHours(24)); } Override public void clear(String conversationId) { redisTemplate.delete(chat: conversationId); } }這樣設(shè)置的好處是Redis的過期清理機制可以自動釋放存儲多實例部署共享一套會話記憶會話ID在網(wǎng)關(guān)層透傳保證同一用戶在多個實例間路由一致。我們用這種方式為單位內(nèi)的設(shè)備巡檢知識庫問答服務(wù)做過壓測單Pod并發(fā)100個會話完全沒有問題。6. 工程化落地可觀測性、流式響應(yīng)與多模型切換6.1 用日志Advisor做完整的調(diào)用鏈路分析平時開發(fā)調(diào)試AI功能最常遇到的問題就是模型到底返回了什么和這次調(diào)用消耗了多少Token。Spring AI默認是沒有請求日志輸出的你只能自己在調(diào)用前后打印。Spring AI提供的SimpleLoggerAdvisor可以自動記錄ChatClient的請求和響應(yīng)摘要。引入配置Bean public Advisor loggerAdvisor() { return new SimpleLoggerAdvisor(); }但我更推薦配置AbstractChatClientAdvisor自定義日志輸出因為內(nèi)置的SimpleLoggerAdvisor默認只打印請求和響應(yīng)頭信息不打印完整內(nèi)容。調(diào)試的時候你照樣看不到模型返回的完整JSON。我自己封裝了一個最精簡的日志AdvisorComponent public class LoggingAdvisor implements Advisor { private static final Logger log LoggerFactory.getLogger(LoggingAdvisor.class); Override public Advisors before(AdvisedRequest request) { log.info(AI請求 - conversationId: {}, 用戶消息: {}, request.conversationId(), request.userText()); return request; } Override public Advisors after(AdvisedRequest request, ChatResponse response) { var metadata response.getMetadata(); if (metadata ! null) { log.info(AI響應(yīng) - 耗時: {}ms, Token用量: 輸入{} / 輸出{}, metadata.get(usage) ! null ? metadata.get(usage) : N/A, metadata.get(inputTokens) ! null ? metadata.get(inputTokens) : N/A, metadata.get(outputTokens) ! null ? metadata.get(outputTokens) : N/A); } return null; } }注意after方法返回null表示不修改響應(yīng)直接放行。這里加日志的好處是你可以在日志系統(tǒng)里按會話ID聚合直接算出一段時間內(nèi)單個用戶的AI調(diào)用頻率和Token成本這在做成本歸因時價值極高。6.2 流式響應(yīng)與異步調(diào)用別讓AI拖垮你的接口接入AI之后最常見的性能問題就是接口RT飆升。一次大模型調(diào)用動輒3到10秒如果采用同步call()模式會讓整個請求鏈路阻塞前端用戶等到超時。Spring AI提供了stream()方法返回FluxString配合Spring WebFlux可以實現(xiàn)典型打字機效果GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString streamChat(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content() .doOnError(e - log.error(流式響應(yīng)出錯: , e)); }前端只需要用EventSource或fetch流式讀取即可。這個方案被評為AI應(yīng)用UI體驗最好的交互方式用戶等待耐心從3秒變成無限——只要字在動用戶就愿意等。如果你不想引入WebFlux也可以用CompletableFuture把同步調(diào)用包一層返回Callable給Spring MVC異步線程池里執(zhí)行模型調(diào)用。但這種方式不會逐字輸出只有等待中和完整結(jié)果兩個狀態(tài)。做聊天產(chǎn)品我強烈建議上stream()。流式調(diào)用有一個需要注意的地方凡是需要拿到完整Token數(shù)量做計費審計的場景流式模式拿不到最終usage信息。目前Spring AI在流式模式下ChatResponse的metadata里沒有完整Token統(tǒng)計。我現(xiàn)在的方案是為流式接口單獨做一些估算或者在前端斷開時收集最后一條流式消息中的usage字段部分模型支持。6.3 多模型切換與故障降級Spring AI Alibaba的價值生產(chǎn)系統(tǒng)最怕的不是模型回答質(zhì)量問題而是模型供應(yīng)商不穩(wěn)定。阿里、OpenAI、DeepSeek各家都有過某個時間段接口超時或限流的情況。Spring AI的多模型配置能力讓它成為模型網(wǎng)關(guān)的天然基座。我在項目里維護了一個ModelRouter組件根據(jù)優(yōu)先級和健康狀態(tài)動態(tài)選擇模型Component public class ModelRouter { private final MapString, ChatClient clients; public ModelRouter(ListChatClient clientList) { this.clients clientList.stream() .collect(Collectors.toMap( c - c.getClass().getSimpleName(), Function.identity() )); } public ChatClient route() { // 按優(yōu)先級返回可通過配置中心動態(tài)調(diào)整 return clients.get(dashscopeChatClient) ! null ? clients.get(dashscopeChatClient) : clients.values().iterator().next(); } }這里用到了Spring AI一個隱藏特性多個ChatClientBean會被自動命名為xxxChatClient的格式你可以通過Qualifier或Bean名稱精準獲取。結(jié)合Spring AI Alibaba項目還可以做更完善的降級策略。Spring AI Alibaba不只支持DashScope模型還提供RedisVectorStore、AlibabaCloudDashscopeChatModel等擴展并且在官方Spring AI基礎(chǔ)上增強了不少工程化能力。在注冊中心里給不同模型配置權(quán)重配合Sentinel做限流是這套框架在企業(yè)場景下的標準打法。寫在最后的幾點體驗跑完這套Spring AI全套流程我最想強調(diào)的體會是Spring AI非常適合標準的企業(yè)級AI功能開發(fā)尤其是那些需要和Spring Boot生態(tài)深度整合、需要多模型兼容、需要工程化保障落地的項目。對Java團隊來說學習成本遠低于Python系的LangChain跟現(xiàn)有代碼的融合也最平滑。個人覺得它當前最大的短板一是Agent能力還比較薄弱、工具多輪調(diào)用的自動編排不如LangChain靈活二是結(jié)構(gòu)化輸出對部分模型的兼容性還有優(yōu)化空間。但只要你的場景是對話客服、知識庫問答、結(jié)構(gòu)化信息抽取、作業(yè)自動生成這類偏任務(wù)型的應(yīng)用Spring AI完全夠用而且在穩(wěn)定性和維護性上比你手繞HTTP接口高出不少。如果你現(xiàn)在正在糾結(jié)團隊用什么AI框架我建議直接拿Spring AI搭個Demo跑兩周真實業(yè)務(wù)比看什么對比評測都管用。