劃智能體實(shí)現(xiàn)及解析:TaoToken 統(tǒng)一 Key 接入 ReAct Agent 實(shí)戰(zhàn))
1. 從一次“翻車”的 Spring Boot 任務(wù)說起為什么普通對話應(yīng)用不夠用我試過在 Spring Boot 里用 SpringAI 做一個“幫我整理本周技術(shù)資訊并生成摘要”的小工具。第一版很樸素用戶提問模型回答最多再掛一個搜索工具。結(jié)果一跑就露餡——模型搜到一堆鏈接后直接回了一句“以上是相關(guān)資訊”既沒打開頁面也沒做摘要更沒生成文件。它只完成了“一次提問 → 一次回答”剩下的步驟全得我手動追問。這就是普通對話應(yīng)用和自主規(guī)劃智能體的分水嶺。普通對話應(yīng)用是反應(yīng)式的你問一句它答一句工具調(diào)用最多一次任務(wù)分解依賴你逐步引導(dǎo)。而自主規(guī)劃智能體Autonomous Planning Agent能根據(jù)目標(biāo)自主拆解任務(wù)、制定計劃、選擇工具并一步步執(zhí)行直到任務(wù)完成。它靠的是 ReAct 模式——Reasoning推理 Acting行動的循環(huán)先思考下一步做什么再調(diào)用工具執(zhí)行拿到結(jié)果后觀察然后進(jìn)入下一輪思考。在 Spring Boot SpringAI 的技術(shù)棧里實(shí)現(xiàn)這套循環(huán)核心難點(diǎn)不在模型能力而在兩件事一是怎么把 SpringAI 默認(rèn)的“框架托管工具調(diào)用”關(guān)掉奪回循環(huán)控制權(quán)二是怎么讓模型調(diào)用一個統(tǒng)一的、穩(wěn)定的模型端點(diǎn)。前者靠ToolCallingChatOptions.internalToolExecutionEnabled(false)后者我用 TaoToken 的統(tǒng)一 Key 通道來解決——一個 Key 就能接入 OpenAI 兼容端點(diǎn)省去在application.yml里反復(fù)換 base-url 和 api-key 的麻煩。這篇文章會交付可復(fù)制的application.yml、Agent 配置片段以及啟動后驗(yàn)證“規(guī)劃-執(zhí)行-觀察”循環(huán)是否跑通的具體動作。適合已經(jīng)會用 Spring Boot 3、想從“問答應(yīng)用”跨到“自主智能體”的 Java 后端同學(xué)。讀完你能得到一個能自主完成“搜索 → 抓取 → 下載 → 生成 PDF → 結(jié)束”這類復(fù)合任務(wù)的 ReAct Agent。2. TaoToken 前置準(zhǔn)備統(tǒng)一 Key 接入 OpenAI 兼容端點(diǎn)在寫 Agent 代碼之前先把模型通道打通。SpringAI 的OpenAiChatModel需要一個 base-url、一個 api-key 和一個 model 名稱。傳統(tǒng)做法是每個供應(yīng)商配一套切換模型就得改配置重啟。TaoToken 的思路是提供一個統(tǒng)一的 OpenAI 兼容入口你只維護(hù)一份 Key模型名按需切換。先拿到 Key。訪問 TaoToken 控制臺創(chuàng)建 API Key地址是https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole。創(chuàng)建后復(fù)制那串sk-開頭的字符串后面配置里要用。如果你還沒注冊官網(wǎng)入口在https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contenthome注冊流程不復(fù)雜這里不展開。Key 拿到后API 端點(diǎn)固定為https://taotoken.net/api注意這個地址不帶任何查詢參數(shù)。它兼容 OpenAI 的/v1/chat/completions協(xié)議所以 SpringAI 的 OpenAI starter 可以直接對接不需要寫自定義的ChatModel實(shí)現(xiàn)。這里要澄清一個常見誤解TaoToken 不是“中轉(zhuǎn)”或“代理”它是一個統(tǒng)一的模型調(diào)用通道幫你把多個 OpenAI 兼容模型的接入收斂成一份配置。你在application.yml里只需要寫一個 base-url 和一個 api-key換模型時只改model字段。這對 ReAct Agent 特別重要——因?yàn)?Agent 循環(huán)里每一步都可能調(diào)用模型如果模型端點(diǎn)不穩(wěn)定或配置分散排查問題會非常痛苦。關(guān)于模型選擇ReAct Agent 對模型的“工具調(diào)用”能力有要求。不是所有模型都擅長結(jié)構(gòu)化地返回tool_calls。建議選支持 function calling 的模型比如 Claude 系列或 GPT 系列。具體哪個模型 ID 可用可以在模型對話頁面實(shí)測https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat。在頁面里發(fā)一句“調(diào)用工具查天氣”之類的測試看它是否返回結(jié)構(gòu)化的工具調(diào)用意圖。如果你打算長期跑編碼類或 Agent 類任務(wù)可以了解下 Coding Plan地址是https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan。它面向的是持續(xù)性的編碼和 Agent 場景和本文這種一次性接入是互補(bǔ)的。接入文檔在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc里面有完整的端點(diǎn)說明和參數(shù)列表。API Keys 管理頁在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keysKey 泄露或輪換時來這里重新生成。3. 可復(fù)制配置application.yml 與 Agent 裝配片段這一節(jié)是全文的核心直接給可復(fù)制的配置。先看application.yml這是 SpringAI 對接 TaoToken 的關(guān)鍵spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY:sk-你的Key} chat: options: model: claude-3-5-sonnet-20241022 temperature: 0.7 max-tokens: 4096三個字段對應(yīng)三件套base-url是 TaoToken 的 API 端點(diǎn)api-key是你的統(tǒng)一 Keymodel是模型 ID。建議把 Key 放到環(huán)境變量TAOTOKEN_API_KEY里不要硬編碼進(jìn)倉庫。temperature對 Agent 場景建議調(diào)低一點(diǎn)0.3 到 0.7 之間太高會讓模型的工具選擇變得隨機(jī)。接下來是 Agent 的裝配。先定義狀態(tài)枚舉這是循環(huán)控制的基礎(chǔ)package com.example.agent; public enum AgentState { IDLE, // 空閑可接受新任務(wù) RUNNING, // 運(yùn)行中 FINISHED, // 完成 ERROR // 異常 }然后是BaseAgent它定義執(zhí)行循環(huán)的骨架。核心是run()方法里的 while 循環(huán)循環(huán)條件是“步數(shù)沒超 且 狀態(tài)不是 FINISHED”package com.example.agent; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import java.util.ArrayList; import java.util.List; Data Slf4j public abstract class BaseAgent { private String name; private String systemPrompt; private String nextStepPrompt; private AgentState state AgentState.IDLE; private int currentStep 0; private int maxSteps 10; private ChatClient chatClient; private ListMessage messageList new ArrayList(); public String run(String userPrompt) { if (this.state ! AgentState.IDLE) { throw new RuntimeException(Cannot run agent from state: this.state); } this.state AgentState.RUNNING; this.messageList.add(new UserMessage(userPrompt)); ListString results new ArrayList(); try { while (currentStep maxSteps state ! AgentState.FINISHED) { log.info(Executing step {}/{}, currentStep, maxSteps); String stepResult step(); results.add(Step currentStep : stepResult); currentStep; } if (currentStep maxSteps) { state AgentState.FINISHED; results.add(Terminated: Reached max steps ( maxSteps )); } return String.join(\n, results); } catch (Exception e) { state AgentState.ERROR; log.error(error executing agent, e); return 執(zhí)行錯誤 e.getMessage(); } finally { this.clean(); } } public abstract String step(); protected void clean() { // 子類可重寫做資源釋放 } }ReActAgent把step()拆成think()和act()落實(shí) ReAct 模式package com.example.agent; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; EqualsAndHashCode(callSuper true) Data Slf4j public abstract class ReActAgent extends BaseAgent { public abstract boolean think(); public abstract String act(); Override public String step() { try { boolean shouldAct think(); if (shouldAct) { return act(); } return think finish, not do anything; } catch (Exception e) { log.error(單個操作執(zhí)行失敗, e); return 單個操作執(zhí)行失敗 e.getMessage(); } } }ToolCallAgent是最關(guān)鍵的一層它用 SpringAI 的工具調(diào)用能力實(shí)現(xiàn)think()和act()。注意構(gòu)造器里那行internalToolExecutionEnabled(false)這是奪回循環(huán)控制權(quán)的開關(guān)package com.example.agent; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.model.tool.ToolExecutionResult; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import java.util.List; import java.util.stream.Collectors; EqualsAndHashCode(callSuper true) Data Slf4j public class ToolCallAgent extends ReActAgent { private final ToolCallback[] availableTools; private final ToolCallbackProvider toolCallbackProvider; private ChatResponse toolCallChatResponse; private final ToolCallingManager toolCallingManager; private final ChatOptions chatOptions; public ToolCallAgent(ToolCallback[] availableTools, ToolCallbackProvider toolCallbackProvider) { super(); this.availableTools availableTools; this.toolCallbackProvider toolCallbackProvider; this.toolCallingManager ToolCallingManager.builder().build(); this.chatOptions ToolCallingChatOptions.builder() .toolCallbacks(this.availableTools) .internalToolExecutionEnabled(false) // 關(guān)鍵禁用框架托管 .build(); } Override public boolean think() { if (StrUtil.isNotBlank(getNextStepPrompt())) { getMessageList().add(new UserMessage(getNextStepPrompt())); } Prompt prompt new Prompt(getMessageList(), this.chatOptions); try { ChatResponse chatResponse getChatClient().prompt(prompt) .system(getSystemPrompt()) .tools(availableTools) .tools(toolCallbackProvider) .options(chatOptions) .call() .chatResponse(); this.toolCallChatResponse chatResponse; AssistantMessage assistantMessage chatResponse.getResult().getOutput(); ListAssistantMessage.ToolCall toolCalls assistantMessage.getToolCalls(); log.info(getName() 的思考 assistantMessage.getText()); log.info(getName() 選擇了 toolCalls.size() 個工具來使用); if (CollUtil.isEmpty(toolCalls)) { getMessageList().add(assistantMessage); return false; } return true; } catch (Exception e) { log.error(getName() 在思考過程中遇到了問題 e.getMessage()); getMessageList().add(new AssistantMessage(處理時遇到了問題 e.getMessage())); return false; } } Override public String act() { if (!toolCallChatResponse.hasToolCalls()) { return 不需要調(diào)用工具; } Prompt prompt new Prompt(getMessageList(), chatOptions); ToolExecutionResult toolExecutionResult toolCallingManager.executeToolCalls(prompt, toolCallChatResponse); setMessageList(toolExecutionResult.conversationHistory()); ToolResponseMessage toolResponseMessage (ToolResponseMessage) CollUtil.getLast(toolExecutionResult.conversationHistory()); boolean terminateToolCalled toolResponseMessage.getResponses().stream() .anyMatch(response - response.name().equals(doTerminate)); if (terminateToolCalled) { setState(AgentState.FINISHED); } String results toolResponseMessage.getResponses().stream() .map(response - 工具 response.name() 返回的結(jié)果 response.responseData()) .collect(Collectors.joining(\n)); log.info(results); return results; } }最后是LoveManus裝配工具、Prompt 和模型package com.example.agent; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; Component public class LoveManus extends ToolCallAgent { public LoveManus(ToolCallback[] toolCallbacks, ToolCallbackProvider toolCallbackProvider, Qualifier(openAiChatModel) ChatModel deepChatModel) { super(toolCallbacks, toolCallbackProvider); this.setName(LoveManus); this.setSystemPrompt( You are LoveManus, an all-capable AI assistant, aimed at solving any task presented by the user. You have various tools at your disposal that you can call upon to efficiently complete complex requests. ); this.setNextStepPrompt( Based on user needs, proactively select the most appropriate tool or combination of tools. For complex tasks, you can break down the problem and use different tools step by step to solve it. After using each tool, clearly explain the execution results and suggest the next steps. If you want to stop the interaction at any point, use the terminate tool/function call. ); this.setMaxSteps(50); ChatClient chatClient ChatClient.builder(deepChatModel) .defaultAdvisors(new MyLoggerAdvisor()) .build(); this.setChatClient(chatClient); } }三件套在這里齊了base-url和api-key在application.ymlmodel也在application.ymlAgent 通過Qualifier(openAiChatModel)注入。換模型只改 yml 里的model字段代碼不動。4. 驗(yàn)證請求確認(rèn)規(guī)劃-執(zhí)行-觀察循環(huán)跑通配置寫完怎么確認(rèn)循環(huán)真的跑通了寫一個測試給一個需要多步才能完成的復(fù)合任務(wù)SpringBootTest class LoveManusTest { Resource private LoveManus loveManus; Test void run() { String userPrompt 幫我找 3 個適合周末徒步的北京近郊路線 整理成一份帶路線簡介的清單并以 PDF 格式輸出 ; String answer loveManus.run(userPrompt); Assertions.assertNotNull(answer); System.out.println(answer); } }啟動后觀察控制臺日志。一個跑通的循環(huán)應(yīng)該長這樣Executing step 0/50 LoveManus的思考需要先搜索北京近郊徒步路線 LoveManus選擇了1個工具來使用 工具 WebSearchTool 返回的結(jié)果找到 5 條候選路線... Executing step 1/50 LoveManus的思考需要進(jìn)一步了解每條路線的難度和交通 LoveManus選擇了1個工具來使用 工具 WebScrapingTool 返回的結(jié)果路線A 難度中等地鐵可達(dá)... Executing step 2/50 LoveManus的思考信息夠了整理成 PDF LoveManus選擇了1個工具來使用 工具 PdfGenerationTool 返回的結(jié)果PDF 已生成到 /output/hiking.pdf Executing step 3/50 LoveManus的思考任務(wù)已完成 LoveManus選擇了1個工具來使用 工具 doTerminate 返回的結(jié)果任務(wù)結(jié)束看到doTerminate被調(diào)用、狀態(tài)置為FINISHED、while 循環(huán)退出就說明規(guī)劃-執(zhí)行-觀察循環(huán)跑通了。整個過程沒有用戶介入模型自主完成了“搜索 → 抓取 → 生成 PDF → 結(jié)束”的鏈路。如果日志里只出現(xiàn)一次Executing step 0/50就停了說明think()返回了 false模型沒選擇任何工具。這時候檢查兩件事一是nextStepPrompt有沒有正確注入二是模型是否支持工具調(diào)用。可以到模型對話頁面單獨(dú)測一下模型的 function calling 能力。驗(yàn)證模型本身是否正??梢栽谀P蛯υ掜摪l(fā)一條帶工具描述的請求看返回里有沒有結(jié)構(gòu)化的tool_calls。如果模型對話頁正常但 Agent 里不正常問題多半在 SpringAI 的配置或工具注冊上。5. 本篇常見錯排查401、local proxy failed 與 reading choices跑 Agent 的過程中報錯基本集中在這幾類。逐個對照排查。401 Unauthorized。這是最常見的。原因通常是api-key沒配對或者環(huán)境變量TAOTOKEN_API_KEY沒生效。檢查application.yml里的api-key是否寫成了${TAOTOKEN_API_KEY:sk-你的Key}冒號后面是默認(rèn)值。如果你在 IDE 里跑確認(rèn)環(huán)境變量真的注入了。還有一種情況是 Key 被復(fù)制時帶了空格或換行重新從 API Keys 頁面復(fù)制一次。401 的排查順序先確認(rèn) Key 字符串本身再確認(rèn)環(huán)境變量最后確認(rèn) base-url 有沒有寫錯。local proxy failed / Connection refused。這個報錯說明 SpringAI 嘗試連接base-url時失敗了。檢查base-url是不是寫成了https://taotoken.net/api注意不要多加/v1或結(jié)尾斜杠。SpringAI 的 OpenAI starter 會自動拼接/v1/chat/completions你多寫一層路徑就會 404 或連接失敗。另外確認(rèn)你的網(wǎng)絡(luò)能正常訪問這個域名公司內(nèi)網(wǎng)如果有出站限制需要放行。Error reading choices / 返回體解析失敗。這個報錯通常出現(xiàn)在模型返回了非標(biāo)準(zhǔn)格式或者你用的模型不支持 OpenAI 兼容協(xié)議。SpringAI 期望的響應(yīng)體里有choices數(shù)組如果模型端點(diǎn)返回的是別的結(jié)構(gòu)就會解析失敗。解決辦法是確認(rèn)model字段填的是 TaoToken 支持的模型 ID并且該模型走的是 OpenAI 兼容協(xié)議??梢韵扔?curl 直接打一次接口看返回體結(jié)構(gòu)curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet-20241022, messages: [{role: user, content: hi}] }如果 curl 返回正常但 SpringAI 報錯問題在配置映射上。如果 curl 也報錯問題在 Key 或模型 ID。OAuth / 認(rèn)證方式不匹配。有些模型端點(diǎn)要求 OAuth 而非 Bearer Token。TaoToken 走的是標(biāo)準(zhǔn) Bearer 認(rèn)證所以api-key直接填sk-開頭的 Key 即可。如果你看到 OAuth 相關(guān)的報錯檢查是不是誤配了別的認(rèn)證方式或者api-key字段被寫成了別的名字。工具調(diào)用返回空 / think 一直返回 false。這不是報錯但循環(huán)跑不起來。原因通常是模型不支持 function calling或者工具 Schema 沒正確注冊。檢查think()里.tools(availableTools)和.tools(toolCallbackProvider)是否都傳了以及availableTools數(shù)組是否非空。如果工具是空的模型自然沒得選。消息上下文重復(fù)膨脹。如果你發(fā)現(xiàn) Token 消耗異??鞕z查act()里是不是用了addAll而不是setMessageList。executeToolCalls返回的conversationHistory()已經(jīng)包含助手消息和工具響應(yīng)直接整體替換即可追加會導(dǎo)致每輪翻倍。排查時建議打開MyLoggerAdvisor把每次 LLM 調(diào)用的請求和響應(yīng)都打出來。這樣能清楚看到模型返回了什么、工具執(zhí)行了什么。日志級別調(diào)到 DEBUGSpringAI 的OpenAiChatModel會輸出請求體對照著看很快能定位。6. 把循環(huán)控制權(quán)握在自己手里寫到這里一個能跑的 ReAct Agent 就成型了?;仡^看最關(guān)鍵的一步不是寫循環(huán)而是那行internalToolExecutionEnabled(false)。SpringAI 默認(rèn)的自動工具調(diào)用很省事但它把“請求工具 → 執(zhí)行工具 → 回填結(jié)果 → 再請求”整條鏈路封成了黑盒。自主規(guī)劃需要在這條鏈路的每一步插入觀察、判斷和終止邏輯所以必須把控制權(quán)拿回來。TaoToken 在這里的角色是讓模型通道變簡單。一份 Key、一個 base-url換模型只改model字段。Agent 循環(huán)里每一步都調(diào)模型通道穩(wěn)定、配置集中排查問題時才不會被“到底是模型問題還是配置問題”繞暈。最后給一個實(shí)用建議maxSteps別設(shè)太大50 已經(jīng)夠跑復(fù)雜任務(wù)了。再大只會讓失控時燒更多 Token。配合doTerminate工具讓模型自己決定收手比硬性截斷優(yōu)雅得多。跑通之后你可以試著加一個AskHuman工具讓模型在關(guān)鍵決策點(diǎn)停下來問你——這是從“全自動”走向“可控自主”的下一步。