目录
-
- 官方资源链接
- 写在前面
- 一、环境准备
- [二、后端:Spring Boot 模拟 Spring AI 六大场景](#二、后端:Spring Boot 模拟 Spring AI 六大场景)
-
- [2.1 后端架构原理](#2.1 后端架构原理)
- [2.2 初始化 Maven 工程](#2.2 初始化 Maven 工程)
- [2.3 启动类与配置](#2.3 启动类与配置)
- [2.4 CORS 配置](#2.4 CORS 配置)
- [2.5 请求 DTO](#2.5 请求 DTO)
- [2.6 模拟 AI 客户端:六大场景实现](#2.6 模拟 AI 客户端:六大场景实现)
- [2.7 REST 控制器](#2.7 REST 控制器)
- [2.8 启动并冒烟后端](#2.8 启动并冒烟后端)
- [三、前端:Vue 3 场景调试控制台](#三、前端:Vue 3 场景调试控制台)
-
- [3.1 前端分层原理](#3.1 前端分层原理)
- [3.2 用 Vite 创建 Vue 工程](#3.2 用 Vite 创建 Vue 工程)
- [3.3 Vite 代理](#3.3 Vite 代理)
- [3.4 Axios API 封装](#3.4 Axios API 封装)
- [3.5 页面入口与样式](#3.5 页面入口与样式)
- [3.6 核心页面:`App.vue`](#3.6 核心页面:
App.vue)
- 四、联调与验收
-
- [4.1 端到端请求链路](#4.1 端到端请求链路)
- [4.2 建议验收清单](#4.2 建议验收清单)
- 五、生产与演进建议
-
- [5.1 从模拟切到真实 Spring AI](#5.1 从模拟切到真实 Spring AI)
- [5.2 前端与部署](#5.2 前端与部署)
- [5.3 可继续扩展的方向](#5.3 可继续扩展的方向)

后端 :Spring Boot 3.2 · Java 17 · REST + CORS · 模拟 Spring AI 客户端(Chat / 多模态 / 结构化输出 / Memory / RAG / Tools / MCP)
前端 :Vue 3 · Vite 5 · Axios · 场景化调试控制台
目标:无需下载任何仓库,按本文章节顺序即可搭出同等效果的前后端联调工程。
官方资源链接
后端
Spring Boot 官方文档:https://docs.spring.io/spring-boot/docs/3.2.5/reference/html/
Spring Boot GitHub:https://github.com/spring-projects/spring-boot
Spring AI 官方文档:https://docs.spring.io/spring-ai/reference/
Spring AI GitHub:https://github.com/spring-projects/spring-ai
前端
Vue 官方文档:https://vuejs.org/guide/introduction.html
Vue GitHub:https://github.com/vuejs/core
Vite 官方文档:https://vitejs.dev/guide/
Vite GitHub:https://github.com/vitejs/vite
Axios 官方文档:https://axios-http.com/docs/intro
Axios GitHub:https://github.com/axios/axios
写在前面
做智能体或问答产品时,后端往往要同时暴露「对话、记忆、RAG、工具、多模态」等多类能力;前端则需要一页就能切换场景、填参数、看响应。如果一上来就接入真实模型供应商和向量库,环境、密钥、鉴权容易把学习路径打断。
本文采用「先用可运行的模拟 AI 客户端把 API 契约和前端控制台搭通,后续再替换为真实 Spring AI 」的路径:后端提供统一前缀 /ai 的 REST;前端用 Vue 控制台调用;开发期由 Vite 代理转发,避免跨域卡顿。
整体调用链路如下:
- 用户在 Vue 页面选择场景并提交;
- Axios 请求
/ai/*; - Vite Dev Server 代理到
http://localhost:8080; - Spring Boot 经 CORS 过滤器进入
AIController; SimpleAIClient按场景返回模拟结果;- 前端结果面板展示文本或 JSON。
最终工程清单
| 模块 | 目录建议 | 职责 | 默认端口 |
|---|---|---|---|
| 后端 | spring-ai |
REST API、CORS、模拟 AI 能力 | 8080 |
| 前端 | spring-ai-ui |
Vue 场景控制台、Axios 封装、Vite 代理 | 5173 |
| 文档 | docs |
本实战文章与配图 | --- |
你最终应得到这样的顶层结构:
text
your-workspace/
├── spring-ai/ # Maven 后端
└── spring-ai-ui/ # Vue 前端
后端选型说明
本项目要的不是「再造一个 Agent 框架」,而是:用 Spring Boot 把 AI 核心场景固化为清晰、可替换的 HTTP API。市面常见做法对比如下。
| 方案 | 特点 | 学习/接入成本 | 与本文目标契合度 | 本文是否选用 |
|---|---|---|---|---|
| 自研模拟客户端 + Spring MVC | 无外网/无密钥也能演示全链路,API 契约稳定 | 低 | 高(先打通契约再换实现) | 选用 |
| 真实 Spring AI + 通义千问等 | 官方抽象好,可直接对接模型 | 中(依赖与密钥) | 高(生产演进方向) | 文末给出替换思路 |
| LangChain4j | Java 侧链式编排能力强 | 中 | 中(偏编排而非本教程范围) | 否 |
| 直接调用厂商 HTTP SDK | 灵活,但业务代码易与厂商强耦合 | 低~中 | 低(不利于后续换 Spring AI) | 否 |
本文先用自研 SimpleAIClient 模拟 ChatClient / Memory / RAG / Tool / MCP 等行为;方法签名与返回形态按「真实接入时尽量少改 Controller」来设计。
前端选型说明
目标是单页场景调试台,不需要路由、状态管理或组件库全家桶。
| 方案 | 特点 | 开发体验 | 本文是否选用 |
|---|---|---|---|
| Vue 3 + Vite + Axios | 轻量、代理方便、模板直观 | 优秀 | 选用 |
| React + Vite + fetch | 生态大,但对简单表单页略「重」 | 优秀 | 否 |
| 纯 HTML + fetch | 零构建,但可维护性差 | 一般 | 否 |
| Element Plus / Ant Design Vue | UI 组件齐全,适合复杂后台 | 好,但有额外学习成本 | 否(本教程手写简洁样式) |
下方正文按「环境 → 后端 → 前端 → 联调验收 → 生产建议」展开。
一、环境准备
请确认本机具备:
| 工具 | 建议版本 | 用途 |
|---|---|---|
| JDK | 17+ | 编译运行后端 |
| Maven | 3.8+ | 依赖与启动 |
| Node.js | 18 LTS(示例以 18.x 验证;Vite 5 可运行) | 前端工具链 |
| npm | 随 Node 安装 | 安装前端依赖 |
| curl | 系统自带 | 接口冒烟 |
可选检查命令:
bash
java -version
mvn -v
node -v
npm -v
创建工作区:
bash
mkdir -p ai-demo && cd ai-demo
mkdir spring-ai spring-ai-ui
二、后端:Spring Boot 模拟 Spring AI 六大场景
2.1 后端架构原理

图 1:后端分层。CORS 过滤跨域;AIController 负责协议与入参;SimpleAIClient 承担「拟态引擎」职责,后续可替换为真实 Spring AI ChatClient。
| 组件 | 职责 |
|---|---|
CorsFilter |
允许前端源访问 /ai/** |
AIController |
REST 入口、参数绑定、媒体类型(含 multipart) |
SimpleAIClient |
六大场景业务逻辑(当前为内存模拟) |
application.yml |
端口与预留的通义/向量库配置位 |
数据流(以 Memory 为例):POST /ai/memory → 反序列化为 MemoryRequest → chatWithMemory(sessionId, message) → 读写 ConcurrentHashMap 会话历史 → 返回字符串。
2.2 初始化 Maven 工程
在 spring-ai 下创建 pom.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>springboot-spring-ai-qwen</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
建议的源码树:
text
spring-ai/
└── src/main/
├── java/com/demo/
│ ├── SpringbootDemoApplication.java
│ ├── config/CorsConfig.java
│ └── ai/
│ ├── AIController.java
│ ├── SimpleAIClient.java
│ └── model/
│ ├── PromptRequest.java
│ ├── MemoryRequest.java
│ ├── RagRequest.java
│ ├── ToolRequest.java
│ └── McpRequest.java
└── resources/application.yml
2.3 启动类与配置
启动类:
java
package com.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringbootDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootDemoApplication.class, args);
}
}
application.yml(端口 + 预留通义与向量库配置,便于后续切换真实实现):
yaml
server:
port: 8080
spring:
ai:
tongyi:
api-key: ${TONGYI_API_KEY:your-tongyi-api-key-here}
chat:
options:
model: qwen-plus
temperature: 0.7
max-tokens: 2000
embedding:
options:
model: text-embedding-v2
chromadb:
host: localhost
port: 8000
collection-name: spring-ai-docs
logging:
level:
org.springframework.ai: DEBUG
com.demo: DEBUG
2.4 CORS 配置
前端开发服务器端口为 5173,后端打开 CORS:
java
package com.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
生产环境请把
allowedOriginPatterns收紧为真实前端域名,并配合网关鉴权。
2.5 请求 DTO
五个 JSON Body 模型写法相同,字段如下(均使用 Lombok @Data):
java
package com.demo.ai.model;
import lombok.Data;
@Data
public class PromptRequest {
private String prompt;
}
java
package com.demo.ai.model;
import lombok.Data;
@Data
public class MemoryRequest {
private String sessionId;
private String message;
}
java
package com.demo.ai.model;
import lombok.Data;
@Data
public class RagRequest {
private String question;
private String context;
}
java
package com.demo.ai.model;
import lombok.Data;
@Data
public class ToolRequest {
private String functionName;
private String arguments;
}
java
package com.demo.ai.model;
import lombok.Data;
@Data
public class McpRequest {
private String query;
}
2.6 模拟 AI 客户端:六大场景实现
java
package com.demo.ai;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 简化版 AI 客户端,模拟 Spring AI 核心能力。
* 后续可将方法体替换为真实 ChatClient / Advisor / Tool 调用。
*/
@Component
public class SimpleAIClient {
private final WebClient webClient;
private final Map<String, String> conversationMemory = new ConcurrentHashMap<>();
public SimpleAIClient() {
this.webClient = WebClient.builder()
.baseUrl("https://api.example.com")
.build();
}
/** 场景一:基础对话 */
public String chat(String message) {
return "AI响应: 您说 '" + message + "',我理解您的意思。";
}
/** 场景二:结构化输出 */
public String generateStructuredResponse(String prompt) {
return "{\n \"title\": \"结构化响应\",\n \"description\": \"基于提示: "
+ prompt + "\",\n \"keyPoints\": [\"要点1\", \"要点2\", \"要点3\"]\n}";
}
/** 场景三:会话记忆 */
public String chatWithMemory(String sessionId, String message) {
String history = conversationMemory.getOrDefault(sessionId, "");
String response = "AI响应: 基于历史对话 '" + history + "',对您的新消息 '"
+ message + "' 的回复。";
conversationMemory.put(sessionId, history + " 用户: " + message + " AI: " + response);
return response;
}
/** 场景四:RAG */
public String ragQuery(String question, String context) {
return "基于提供的上下文信息,对问题 '" + question + "' 的回答是:这是一个RAG增强的回答。";
}
/** 场景五:工具调用 */
public String callTool(String functionName, String arguments) {
switch (functionName.toLowerCase()) {
case "weather":
return "天气查询结果:北京,晴朗,25°C";
case "time":
return "当前时间:" + java.time.LocalDateTime.now();
default:
return "未知工具: " + functionName;
}
}
/** 场景六:MCP */
public String mcpIntegration(String query) {
return "通过MCP协议处理查询: " + query + ",返回标准化结果。";
}
/** 多模态 */
public String multimodalChat(String text, byte[] imageData) {
return "多模态AI处理:文本 '" + text + "' 和图像数据("
+ imageData.length + "字节)的分析结果。";
}
}
原理要点:
- Chat / Structured / RAG / MCP:无状态字符串回显,便于前端校验协议。
- Memory :按
sessionId在 JVM 内累加历史(单机演示即可;生产应换 Redis / DB)。 - Tools :用
switch模拟 Function Calling;真实 Spring AI 应注册@Tool方法。 - Multimodal:只消费图片字节长度,验证 multipart 上传链路。
WebClient预留了出站 HTTP,目前未对真实 API 发请求,避免依赖外网与密钥。
2.7 REST 控制器
java
package com.demo.ai;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.demo.ai.model.*;
import java.io.IOException;
@RestController
@RequestMapping("/ai")
public class AIController {
@Autowired
private SimpleAIClient aiClient;
@GetMapping("/chat")
public String chat(@RequestParam String message) {
return aiClient.chat(message);
}
@PostMapping(value = "/multimodal", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String multimodalChat(
@RequestParam String text,
@RequestParam MultipartFile image) throws IOException {
return aiClient.multimodalChat(text, image.getBytes());
}
@PostMapping("/structured")
public String structuredOutput(@RequestBody PromptRequest request) {
return aiClient.generateStructuredResponse(request.getPrompt());
}
@PostMapping("/memory")
public String chatWithMemory(@RequestBody MemoryRequest request) {
return aiClient.chatWithMemory(request.getSessionId(), request.getMessage());
}
@PostMapping("/rag")
public String ragQuery(@RequestBody RagRequest request) {
return aiClient.ragQuery(request.getQuestion(), request.getContext());
}
@PostMapping("/tools")
public String callTool(@RequestBody ToolRequest request) {
return aiClient.callTool(request.getFunctionName(), request.getArguments());
}
@PostMapping("/mcp")
public String mcpIntegration(@RequestBody McpRequest request) {
return aiClient.mcpIntegration(request.getQuery());
}
}
API 一览:
| 场景 | 方法 | 路径 | 入参要点 |
|---|---|---|---|
| 基础对话 | GET | /ai/chat |
message |
| 多模态 | POST | /ai/multimodal |
text + image(form-data) |
| 结构化输出 | POST | /ai/structured |
{"prompt":"..."} |
| 会话记忆 | POST | /ai/memory |
sessionId + message |
| RAG | POST | /ai/rag |
question + context |
| 工具调用 | POST | /ai/tools |
functionName + arguments |
| MCP | POST | /ai/mcp |
{"query":"..."} |
2.8 启动并冒烟后端
bash
cd spring-ai
mvn spring-boot:run
另开终端:
bash
curl "http://localhost:8080/ai/chat?message=你好Spring%20AI"
curl -X POST http://localhost:8080/ai/structured \
-H "Content-Type: application/json" \
-d '{"prompt":"Spring Boot优势"}'
curl -X POST http://localhost:8080/ai/memory \
-H "Content-Type: application/json" \
-d '{"sessionId":"test123","message":"我喜欢编程"}'
若返回带「AI响应」或 JSON 字符串,后端基础链路已通。
三、前端:Vue 3 场景调试控制台
3.1 前端分层原理

图 2:UI(App.vue)只关心交互;api/ai.js 封装与后端契约;Vite server.proxy 将 /ai 转到 Spring Boot,开发态即可避免浏览器跨域。
相对常见「页面直接拼 http://localhost:8080」的做法,本分层有两个好处:
- 前端代码始终写相对路径
/ai/...,上线时可改同源网关; - 业务组件不感知 multipart / JSON 细节差异(API 层统一处理)。
3.2 用 Vite 创建 Vue 工程
若本机为 Node 18,建议固定 create-vite 大版本,避免最新脚手架要求 Node 20+:
bash
cd spring-ai-ui
# 若目录由脚手架自动创建,可在上级执行:
# npm create vite@5.5.0 . -- --template vue
npm install
npm install axios
npm install -D sass
也可手动保证 package.json 接近:
json
{
"name": "spring-ai-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.18.1",
"vue": "^3.4.35"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"sass": "^1.99.0",
"vite": "^5.4.0"
}
}
3.3 Vite 代理
js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/ai': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
3.4 Axios API 封装
js
import axios from 'axios'
const http = axios.create({
baseURL: '/ai',
timeout: 30000,
})
export function chat(message) {
return http.get('/chat', { params: { message } })
}
export function multimodalChat(text, imageFile) {
const formData = new FormData()
formData.append('text', text)
formData.append('image', imageFile)
return http.post('/multimodal', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function structuredOutput(prompt) {
return http.post('/structured', { prompt })
}
export function chatWithMemory(sessionId, message) {
return http.post('/memory', { sessionId, message })
}
export function ragQuery(question, context) {
return http.post('/rag', { question, context })
}
export function callTool(functionName, argumentsText) {
return http.post('/tools', { functionName, arguments: argumentsText })
}
export function mcpIntegration(query) {
return http.post('/mcp', { query })
}
3.5 页面入口与样式
index.html:
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spring AI 演示控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
main.js:
js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
style.css:
css
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
3.6 核心页面:App.vue
完整可复制实现(Tab 切换七个场景 + 统一提交 + 结果面板):
vue
<script setup>
import { ref } from 'vue'
import {
chat,
multimodalChat,
structuredOutput,
chatWithMemory,
ragQuery,
callTool,
mcpIntegration,
} from './api/ai'
const tabs = [
{ key: 'chat', label: '基础对话' },
{ key: 'multimodal', label: '多模态' },
{ key: 'structured', label: '结构化输出' },
{ key: 'memory', label: '会话记忆' },
{ key: 'rag', label: 'RAG问答' },
{ key: 'tools', label: '工具调用' },
{ key: 'mcp', label: 'MCP协议' },
]
const activeTab = ref('chat')
const loading = ref(false)
const result = ref('')
const error = ref('')
const chatMessage = ref('你好,Spring AI')
const multimodalText = ref('请描述这张图片')
const imageFile = ref(null)
const structuredPrompt = ref('Spring Boot介绍')
const sessionId = ref('user123')
const memoryMessage = ref('记住我喜欢Java')
const ragQuestion = ref('什么是Spring?')
const ragContext = ref('Spring是Java企业级应用开发框架。')
const toolName = ref('weather')
const toolArgs = ref('北京')
const mcpQuery = ref('查询用户信息')
function onImageChange(event) {
imageFile.value = event.target.files?.[0] || null
}
async function run(action) {
loading.value = true
error.value = ''
result.value = ''
try {
const response = await action()
result.value = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2)
} catch (e) {
error.value = e.response?.data?.message || e.message || '请求失败'
} finally {
loading.value = false
}
}
function submit() {
switch (activeTab.value) {
case 'chat':
return run(() => chat(chatMessage.value))
case 'multimodal':
if (!imageFile.value) {
error.value = '请先选择图片文件'
return
}
return run(() => multimodalChat(multimodalText.value, imageFile.value))
case 'structured':
return run(() => structuredOutput(structuredPrompt.value))
case 'memory':
return run(() => chatWithMemory(sessionId.value, memoryMessage.value))
case 'rag':
return run(() => ragQuery(ragQuestion.value, ragContext.value))
case 'tools':
return run(() => callTool(toolName.value, toolArgs.value))
case 'mcp':
return run(() => mcpIntegration(mcpQuery.value))
}
}
</script>
<template>
<div class="page">
<header class="header">
<h1>Spring AI 演示控制台</h1>
<p>调用后端 <code>spring-ai</code> 的六大核心场景接口</p>
</header>
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key; result = ''; error = ''"
>
{{ tab.label }}
</button>
</nav>
<section class="panel">
<div v-if="activeTab === 'chat'" class="form">
<label>消息内容</label>
<input v-model="chatMessage" type="text" placeholder="输入对话内容" />
</div>
<div v-else-if="activeTab === 'multimodal'" class="form">
<label>文本描述</label>
<input v-model="multimodalText" type="text" placeholder="输入文本" />
<label>图像文件</label>
<input type="file" accept="image/*" @change="onImageChange" />
</div>
<div v-else-if="activeTab === 'structured'" class="form">
<label>提示词</label>
<textarea v-model="structuredPrompt" rows="4" placeholder="输入提示词" />
</div>
<div v-else-if="activeTab === 'memory'" class="form">
<label>会话 ID</label>
<input v-model="sessionId" type="text" placeholder="sessionId" />
<label>消息</label>
<input v-model="memoryMessage" type="text" placeholder="输入消息" />
</div>
<div v-else-if="activeTab === 'rag'" class="form">
<label>问题</label>
<input v-model="ragQuestion" type="text" placeholder="输入问题" />
<label>上下文</label>
<textarea v-model="ragContext" rows="4" placeholder="输入上下文" />
</div>
<div v-else-if="activeTab === 'tools'" class="form">
<label>工具名称</label>
<select v-model="toolName">
<option value="weather">weather</option>
<option value="time">time</option>
</select>
<label>参数</label>
<input v-model="toolArgs" type="text" placeholder="工具参数" />
</div>
<div v-else class="form">
<label>MCP 查询</label>
<input v-model="mcpQuery" type="text" placeholder="输入查询" />
</div>
<button class="submit" :disabled="loading" @click="submit">
{{ loading ? '请求中...' : '发送请求' }}
</button>
</section>
<section class="result" v-if="result || error">
<h2>响应结果</h2>
<pre v-if="error" class="error">{{ error }}</pre>
<pre v-else>{{ result }}</pre>
</section>
</div>
</template>
<style scoped lang="scss">
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 20px 48px;
color: #1f2937;
}
.header {
margin-bottom: 24px;
h1 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: #6b7280;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
}
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.tab {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
border-radius: 999px;
padding: 8px 14px;
cursor: pointer;
&.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
}
.panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.form {
display: grid;
gap: 10px;
margin-bottom: 16px;
label {
font-size: 14px;
font-weight: 600;
color: #4b5563;
}
input,
textarea,
select {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
}
}
.submit {
background: #111827;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 18px;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.result {
margin-top: 20px;
h2 {
margin: 0 0 10px;
font-size: 18px;
}
pre {
margin: 0;
background: #0f172a;
color: #e2e8f0;
border-radius: 12px;
padding: 16px;
white-space: pre-wrap;
word-break: break-word;
}
.error {
background: #7f1d1d;
}
}
</style>
界面效果示意:

图 4:控制台包含场景 Tab、表单区、结果区;与真实页面布局一致,便于验收对照。
启动前端:
bash
cd spring-ai-ui
npm run dev
浏览器打开:http://localhost:5173
四、联调与验收
4.1 端到端请求链路

图 3:用户操作 → Axios → Vite 代理 → CorsFilter → AIController → SimpleAIClient → 结果面板。
4.2 建议验收清单
| 编号 | 操作 | 期望 |
|---|---|---|
| 1 | 后端 mvn spring-boot:run |
8080 监听成功 |
| 2 | curl /ai/chat?message=hi |
返回含「AI响应」的文本 |
| 3 | 前端 npm run dev |
5173 可打开控制台 |
| 4 | Tab「基础对话」发送 | 结果区出现后端文本,Network 为 /ai/chat |
| 5 | 「会话记忆」同 sessionId 连发两次 |
第二次响应内容体现更长历史 |
| 6 | 「结构化输出」 | 结果可被解析为 JSON(字符串形式) |
| 7 | 「工具调用」选 weather |
返回天气类描述 |
| 8 | 「多模态」上传任意图片 | 响应中含图片字节长度 |
若浏览器 Network 里请求打到了 8080 的 CORS 错误,请确认:
- 是否仍在走 Vite 代理(URL 应为相对路径
/ai/...); - 后端
CorsConfig是否已生效。
五、生产与演进建议
5.1 从模拟切到真实 Spring AI
路线可收敛为四步:
-
在
pom.xml增加与 Boot 3.2 兼容的 Spring AI BOM / 通义(或 OpenAI 兼容)starter; -
用正式
ChatClient/EmbeddingModel替换SimpleAIClient内部实现,尽量保持现有 Controller 路径不变; -
Memory 接 Redis 或 JDBC Chat Memory;RAG 接向量库(配置中已预留 Chroma 位置);
-
Tools 改为声明式工具注册;MCP 按官方 MCP Client 对接外部资源。
密钥仅通过环境变量注入,例如:
bash
export TONGYI_API_KEY=sk-xxxxx
切勿把真实密钥写进仓库。
5.2 前端与部署
- 开发:继续 Vite proxy;
- 生产:
npm run build后由 Nginx / Gateway 把/ai反代到后端,前端静态资源同域托管; - 限流、超时、错误码统一为 JSON,前端再升级 toast / Loading;
- 多模态注意上传大小与文件类型白名单。
5.3 可继续扩展的方向
-
流式输出(SSE / WebFlux
Flux)并在前端做打字机渲染; -
统一响应体
{ code, data, message }; -
按用户鉴权隔离
sessionId; -
为 Memory / RAG 增加自动化测试(当前可用
MockMvc覆盖 Controller)。按本文完成搭建后,你已经拥有一条「可演示、可替换、前后端分离」的 Spring AI 学习骨架:先把契约与体验跑通,再逐步把模拟实现换成真实模型与向量检索,学习与交付不会被环境阻塞。