1. 从零搭建仿 DeepSeek 对话页:Spring AI 对接统一通道的真实场景
很多 Java 后端同学想做一个自己的对话机器人,第一反应是去翻各家大模型的 SDK 文档,结果发现每换一个模型就要改一遍依赖、改一遍配置、改一遍鉴权逻辑。我试过同时接三家模型做对比测试,光是维护三套 API Key 和 Base URL 就够头疼的。后来我把所有请求收敛到一个统一通道上,后端只认一套 OpenAI 兼容协议,前端只认一个流式接口,整个项目结构瞬间清爽了。
这篇要做的东西很具体:用 Spring AI 作为后端框架,通过 TaoToken 统一 Key/API 通道调用模型,前端用 Vue 复刻一个 DeepSeek 风格的对话页,支持流式输出和多轮会话记忆。适合谁看?有 Java 基础、想快速跑通一个可用的对话机器人、又不想被多家模型 SDK 绑死的开发者。核心检索词就是 Spring AI 对接统一通道实现对话机器人,你跟着配完 application.yml 和 Vue 请求封装,就能得到一个能保存聊天记录、能流式打字、能多轮追问的页面。
为什么选 Spring AI 而不是自己写 HTTP 客户端?因为 Spring AI 把 ChatClient、ChatMemory、Advisor 这些概念抽象好了,流式返回直接给你 Flux,多轮会话只要传一个 conversationId 就能自动带上历史消息。你不需要自己拼 messages 数组,也不需要自己处理 SSE 分帧。而统一通道的价值在于:你的 application.yml 里只写一个 base-url 和一个 api-key,想换模型只改 model 字段,代码一行不动。
整个链路是这样的:Vue 前端把用户输入和 chatId 发给后端/ai/chat接口,后端 ChatClient 带上会话记忆去请求统一通道,通道按 OpenAI 兼容格式转发给具体模型,模型流式吐字,后端用 Flux 原样推回前端,前端用 reader 逐块读取并追加渲染。下面从依赖开始,一步步把这条链路搭起来。
2. TaoToken 统一通道前置准备:Key、Base URL 与模型 ID 三件套
在写代码之前,先把通道侧的东西准备好。你需要的是三件套:Base URL、API Key、Model ID。这三个东西贯穿整个配置,缺一个都跑不起来。
Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI 兼容端点的基础路径。Spring AI 的 OpenAI starter 会自动在这个 base-url 后面拼/v1/chat/completions这类路径,所以你不要自己手动加/v1,否则会变成/api/v1/v1/...这种重复路径,请求直接 404。
API Key 的获取入口在控制台的 API Keys 页面,地址是https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=apikeys。进去之后新建一个 Key,复制出来形如sk-xxxxxxxx的字符串。这个 Key 只显示一次,建议直接存到环境变量里,不要硬编码进代码提交到 Git。我踩过的坑就是早期把 Key 写死在 yml 里,后来换 Key 要改代码重新打包,非常麻烦。
Model ID 这块要注意,统一通道支持多种模型,你填的 model 名称必须是通道侧认可的标识。比如你想用 DeepSeek 系列,就填对应的模型 ID;想用通义千问系列,就填 qwen 相关的 ID。具体支持哪些模型,可以在模型对话页面里先手动试一下,确认能出结果再写进配置。地址是https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat,在里面选模型、发一句话,能正常回复就说明这个 Model ID 可用。
如果你打算长期做编码类 Agent,或者需要频繁调用做批量任务,可以了解一下 Coding Plan,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=codingplan。它适合那种每天都要跑很多轮对话、对额度有稳定需求的场景。普通做 demo 的话,按量用 API Key 就够了。
接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc,里面写了兼容协议的具体细节,包括请求体格式、流式返回的 SSE 格式、错误码含义。遇到 401 或者返回体解析异常的时候,对照文档排查比瞎猜快得多。
把这三件套准备好之后,建议先在命令行用 curl 验证一次,确认通道本身是通的,再去写 Java 代码。这样能把「通道问题」和「代码问题」分开,排障效率高很多。
3. 可复制配置:application.yml 与 Spring AI 依赖完整片段
这一节是核心,所有配置都可以直接复制。先看 pom.xml 的依赖部分。Spring Boot 用 3.4.x,Java 17,Spring AI 用 1.0.0-M6 里程碑版本。关键依赖是spring-ai-openai-spring-boot-starter,因为统一通道走的是 OpenAI 兼容协议,所以用这个 starter 而不是 ollama 那个。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.3</version> <relativePath/> </parent> <properties> <java.version>17</java.version> <spring-ai.version>1.0.0-M6</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.22</version> <scope>provided</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后是 application.yml。这里把 base-url 指向统一通道,api-key 从环境变量读,model 填你确认可用的 Model ID。注意spring.ai.openai.chat.options.model这个字段,它决定了实际调用哪个模型。
spring: application: name: spring-ai-chat-demo ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 logging: level: org.springframework.ai: debug com.example.aichat: debug启动前设置环境变量,Linux/macOS 用export TAOTOKEN_API_KEY=sk-你的key,Windows 用set TAOTOKEN_API_KEY=sk-你的key。如果你在 IDE 里跑,直接在 Run Configuration 的 Environment variables 里加一行也行。
接下来是配置类。ChatMemory 用 InMemoryChatMemory,ChatClient 绑定模型和记忆 Advisor。这里的关键是MessageChatMemoryAdvisor,它负责在每次请求时自动把该会话的历史消息塞进上下文。
@Configuration public class ChatConfiguration { @Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory) { return ChatClient.builder(model) .defaultSystem("你是一个热心、聪明的智能助手,名字叫小小,请以小小的身份回答问题。") .defaultAdvisors( new SimpleLoggerAdvisor(), new MessageChatMemoryAdvisor(chatMemory) ) .build(); } }Controller 层提供两个接口:/ai/chat做流式对话,/ai/history/{type}/{chatId}拉取历史消息。流式接口返回Flux<String>,通过advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))把会话 ID 绑定到记忆 Advisor 上。
@RequiredArgsConstructor @RestController @RequestMapping("/ai") public class ChatController { private final ChatClient chatClient; private final ChatHistoryRepository chatHistoryRepository; @RequestMapping(value = "/chat", produces = "text/html;charset=utf-8") public Flux<String> chat(@RequestParam("prompt") String prompt, @RequestParam("chatId") String chatId) { chatHistoryRepository.save("chat", chatId); return chatClient.prompt() .user(prompt) .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)) .stream() .content(); } }前端请求封装用 fetch 的 reader 逐块读取。这里要注意,后端返回的是 SSE 流,但 Spring AI 的.content()已经把 SSE 的data:前缀剥掉了,前端拿到的就是纯文本块,直接追加即可。
const BASE_URL = 'http://localhost:8080' export const chatAPI = { async sendMessage(formData, chatId) { const url = new URL(`${BASE_URL}/ai/chat`) if (chatId) url.searchParams.append('chatId', chatId) const response = await fetch(url, { method: 'POST', body: formData }) if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.body.getReader() } }Vue 组件里维护currentMessages数组,发送时先 push 用户消息,再 push 一个空的 assistant 占位消息,然后循环reader.read(),把解码后的内容累加到占位消息上。isStreaming标志控制发送按钮禁用状态,防止重复提交。
4. 验证请求与成功结果:流式输出和多轮会话怎么测
配置写完,启动 Spring Boot 应用,看到日志里没有报错、Tomcat 在 8080 端口起来,就可以验证了。验证分两步:先验流式输出,再验多轮会话。
流式输出验证用 curl 最直接。开一个终端,执行:
curl -N "http://localhost:8080/ai/chat?prompt=你好,介绍一下你自己&chatId=test-001"-N参数关闭 curl 的缓冲,这样你能看到文字一块一块吐出来,而不是等全部生成完才一次性显示。如果看到类似「你好,我是小小……」的内容逐步出现,说明流式链路通了。如果卡住不动最后一次性返回,检查后端produces是不是设了text/html;charset=utf-8,以及前端有没有用 reader 而不是response.text()。
多轮会话验证的关键是同一个 chatId 连续发两次请求。第一次问「我叫小明」,第二次问「我叫什么名字」,如果第二次能答出「小明」,说明 ChatMemory 生效了。
curl -N "http://localhost:8080/ai/chat?prompt=我叫小明&chatId=test-002" curl -N "http://localhost:8080/ai/chat?prompt=我叫什么名字&chatId=test-002"这里有个细节:InMemoryChatMemory 是基于内存的,重启应用后历史就没了。做 demo 够用,但如果你要持久化,需要自己实现 ChatMemory 接口,把消息存到数据库或 Redis。另外,MessageChatMemoryAdvisor默认会把历史消息全部带上,如果对话轮次很多,上下文会越来越长,可能超出模型的 token 限制。生产环境建议加一个滑动窗口或者摘要机制。
前端验证的话,打开页面,输入一句话,观察文字是不是逐字出现。然后点「新对话」按钮,chatId 会重新生成,历史消息清空。再切回之前的对话,历史消息应该能通过/ai/history/chat/{chatId}拉回来。如果历史拉不回来,检查ChatHistoryRepository的 save 有没有被调用,以及chatMemory.get(chatId, Integer.MAX_VALUE)返回的列表是不是空。
成功的结果应该是:输入框回车后,用户消息立刻上屏,助手消息区域开始逐字显示,光标闪烁,生成完毕后可以复制内容,左侧历史列表出现新会话,点击能切换。整个过程不需要刷新页面。
5. 本篇常见错误排查:401、local proxy failed、reading choices 逐个击破
这一节把最容易撞上的几个报错列出来,对照着改。
401 Unauthorized。这个最常见,原因通常是 API Key 没读到或者填错了。先确认环境变量TAOTOKEN_API_KEY在当前 shell 里能echo出来。如果用的是 IDE,确认 Run Configuration 里加了环境变量。还有一种情况是 Key 复制时带了空格或者换行,建议重新复制一次。如果 Key 确认没问题还是 401,去控制台看看这个 Key 是不是被禁用或者额度用完了。
local proxy failed / connection refused。这个报错说明请求根本没发出去,卡在本地网络层。检查 base-url 是不是写成了https://taotoken.net/api/带了尾部斜杠,或者写成了http://而不是https://。另外确认你的机器能正常访问外网,公司内网如果有防火墙限制,需要放行。这个报错和 Key 无关,纯粹是网络连通性问题。
Error reading choices / 返回体解析失败。这个通常出现在流式返回时,Spring AI 解析 SSE 数据块失败。原因可能是 base-url 多写了/v1,导致请求路径变成/api/v1/chat/completions,通道返回的不是标准 OpenAI 格式。把 base-url 改成https://taotoken.net/api,不要带/v1。还有一种可能是 model 字段填了一个通道不认识的 ID,通道返回了错误结构,Spring AI 按正常结构解析就报错了。去模型对话页面确认 Model ID 拼写。
OAuth / token 相关报错。如果你在配置里误加了spring.ai.openai.oauth相关属性,或者用了某些需要 OAuth 的 starter,会走到错误的鉴权流程。统一通道走的是 API Key 鉴权,不需要 OAuth。检查 yml 里有没有多余的 auth 配置,删掉即可。
流式返回中文乱码。后端produces必须写text/html;charset=utf-8,前端TextDecoder要指定utf-8。如果前端用默认的new TextDecoder(),在某些浏览器上会按 latin1 解码,中文就成乱码了。写成new TextDecoder('utf-8')即可。
多轮会话不生效。检查两个地方:一是advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))这行有没有写,二是 chatId 在两次请求之间是不是同一个值。如果前端每次发送都生成新 chatId,那每次都是新会话,自然记不住。确保 chatId 在「新对话」时才重新生成,同一轮对话内保持不变。
排障的时候,把logging.level.org.springframework.ai: debug打开,能看到完整的请求 URL、请求体、响应状态。对照日志里的 URL 确认路径拼接是否正确,对照响应体确认返回结构是否符合预期。大部分问题看日志就能定位。
6. 语义一致收尾:把统一通道用进你的下一个 Java AI 项目
到这里,一个能跑、能流式、能多轮的仿 DeepSeek 对话页就搭完了。回头看整个链路,最省心的地方在于后端只认一套 OpenAI 兼容配置,前端只认一个流式接口。你以后想换模型,改 application.yml 里的 model 字段就行;想加新功能,比如文件上传、多模态分析,也只需要在现有接口上扩展,不用动通道层。
如果你在排障过程中遇到 401 或者流式解析问题,建议直接对照接入文档再核一遍 base-url 和请求格式,文档地址是https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc。需要新建或管理 Key 就去 API Keys 页面https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=apikeys。想先手动试试某个模型能不能用,去模型对话页面https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat发一句话最快。
下一步你可以做的:把 InMemoryChatMemory 换成基于 Redis 的实现,让会话在重启后不丢;给前端加上 Markdown 渲染和代码高亮,让助手回复的代码块更好看;或者接入文件上传,把图片和音频也走同一条通道发给多模态模型。这些都是在现有骨架上加东西,通道层不用改。