Spring AI Alibaba 实战(一):5 个概念 + 第一次跟 AI 对话

这是我学习 Spring AI Alibaba 的第一篇记录。
目标很简单:搞清楚几个必须知道的 AI 概念,然后用 Spring Boot 跑通第一个对话程序。


理论篇

一、Java 工程师为什么能搞 AI Agent

开始学之前,我最大的顾虑是:“AI 不都是 Python 的吗?我一个 Java 开发,能搞得了?”

后来想明白了。AI Agent 的核心不是训练模型,而是工程化——把大模型的能力串起来,做成一个能干活的系统。这恰恰是 Java 工程师擅长的:

  • 工具注册和调度 → 你写了多年的 Spring 依赖注入
  • 多步编排和状态管理 → 你熟悉的工作流引擎
  • 高可用、可观测、分布式 → Java 生态的强项
  • 企业级落地 → Spring Boot 就是事实标准

所以问题不是"Java 能不能搞 AI",而是"有没有好用的框架"。Spring AI Alibaba 就是答案——Spring 团队和阿里一起做的,API 风格跟 Spring 全家桶一脉相承。

二、开始写代码之前,你得搞懂这 5 个概念

不需要懂 Transformer 和 Attention 机制。但下面 5 个概念直接影响你写代码时的每一个决策,跳过任何一个后面都会踩坑。

2.1 Token——LLM 的计费单位

Token 不是"字"也不是"词",是模型处理文本的最小单位。中文大约 1 个字 ≈ 1-2 个 Token。

"我想查北京到上海的机票"
→ 大约 12-15 个 Token

为什么要关心这个?因为它影响三件事:

影响 说明
API 按 Token 数计费,输入和输出分别算
容量 一次对话能装的信息有上限(Context Window)
速度 Token 越多,响应越慢

通义千问的价格参考(2025):

模型 输入(每百万 Token) 输出(每百万 Token) 上下文长度
qwen-turbo ¥2 ¥6 128K
qwen-plus ¥4 ¥12 128K
qwen-max ¥20 ¥60 32K

💡 开发建议:调试用 qwen-turbo(便宜),上线用 qwen-plus(均衡),核心场景用 qwen-max(最强)。

2.2 Prompt——你给 LLM 的指令

Prompt 是你发给大模型的文本。它不是简单的"输入",而是有结构的:

角色 作用 示例
System 设定 AI 的"人格"和规则 “你是一个专业机票分析师,只回答机票相关问题”
User 用户的实际问题 “帮我查北京到上海明天的机票”
Assistant 模型之前的回答(用于多轮对话) “已为您查到以下航班…”

System Prompt 是 Agent 的灵魂。后面你会发现,很多时候调 System Prompt 比改代码有用 10 倍。

2.3 Temperature——控制输出的"稳定性"

Temperature 是一个 0 到 1 之间的参数,决定了 LLM 回答的随机程度。用 Java 来类比:temperature=0 就像一个确定性的 switch-case,给同样的输入永远走同一个分支;temperature=1 更像 Random.nextInt(),每次执行结果都不一样。

Temperature 效果 适用场景
0 每次回答几乎相同 Agent 工具调用、数据分析
0.3 轻微变化 Agent 推荐值
0.7 默认值,有一定随机性 普通对话
1.0 每次都不一样 创意写作

怎么选一个合理的值? 看你的业务对"一致性"的要求:

场景 推荐值 原因
Function Calling / 工具调用 0 参数必须精确,随机性会导致 JSON 格式错误或传错参数
Agent 决策与推理 0 - 0.3 推理链路要稳定,但允许微小灵活性
客服 / FAQ 问答 0.3 - 0.5 回答要准确,但措辞可以自然一点,不要像机器人
普通对话 / 聊天 0.7 平衡准确性和趣味性,大多数模型的默认值
营销文案 / 创意写作 0.8 - 1.0 需要发散思维,每次生成不同的表达

一个实用的定值策略:先从 0.7(默认值)开始,发现输出太"飘"就往下调,太"死板"就往上调。 线上环境建议把 temperature 写进配置文件而不是硬编码,方便随时调整。

⚠️ 重点:Agent 中涉及工具调用、结构化输出的环节,建议用较低的 temperature(0-0.3)。但 Agent 不只有工具调用——如果某个环节需要生成面向用户的自然语言回复(比如总结、推荐理由),适当提高到 0.5-0.7 反而更自然。关键是按环节分别设置,而不是整个 Agent 一刀切。

2.4 Context Window——对话的"内存大小"

Context Window 是一次对话能容纳的总 Token 数。所有东西都要塞进去:System Prompt、历史对话、当前问题、工具描述、模型回答。

内存占用

超出上限怎么办?旧的对话会被截断,模型会"忘掉"之前聊过的内容。这就是为什么后面要专门学 Memory 管理(第 4 章)。

2.5 Function Calling——Agent 的核心引擎

这是最重要的概念。没有 Function Calling,就没有 Agent。

传统 LLM 只能输出文本。Function Calling 让 LLM 能够"调用函数"——准确说,是 LLM 告诉你它想调用什么函数、传什么参数,真正的执行由你的代码完成。

在这里插入图片描述

关键点:

  1. LLM 不直接调用 API——它只输出 JSON 格式的调用意图
  2. 你的代码负责执行——接收意图,调真正的 API
  3. 结果喂回给 LLM——LLM 拿到结果后,组织成自然语言回复

用 Java 类比:LLM 就像一个"智能路由器",根据用户请求决定调用哪个 Service 方法,但它自己不执行方法。你写的 @Service 才是干活的。

三、Spring AI 的分层架构

Spring AI 的设计跟 Spring Data 一个思路——统一抽象,多种实现

在这里插入图片描述

面向 ChatModel 接口编程,底层换成任何 LLM 都不需要改业务代码。跟你用 JPA 换数据库一个道理。

ChatClient 是 ChatModel 上面的高层封装,关系类似 JdbcTemplateDataSource。日常开发用 ChatClient 就够了,链式调用、拦截器、流式输出都支持。

Spring AI Alibaba 在这套抽象之上,提供了通义千问的完整实现,加上百炼平台集成、增强 RAG 等额外能力。

四、幻觉——LLM 一本正经地胡说

这个必须单独拿出来说,因为它是 Agent 开发中最容易踩的坑。

LLM 会非常自信地给出完全编造的答案:

用户: "国航 CA1234 航班什么时候起飞?"
LLM:  "国航 CA1234 航班每天 08:30 从北京首都机场起飞"  ← 完全编造的

因为 LLM 本质是"文本接龙"——预测最可能的下一个 Token,不是查事实。

这也是 Agent 存在的核心原因之一:Agent 通过 Function Calling 获取真实数据,而不是让 LLM 瞎编。我们的机票比价 Agent 不会"编造"票价,而是调用真实 API 查询。


实战篇

五、跑通第一个对话程序

5.1 申请通义千问 API Key
  1. 访问阿里云百炼平台
  2. 开通"模型服务灵积"
  3. 在"API-KEY 管理"中创建 Key
  4. 记住这个 Key,后面要用

💡 新账号有免费额度,够学完这个系列。

5.2 项目结构

整个系列用 Maven 父子工程管理,每章一个子模块:

spring-ai-alibaba-course/
├── pom.xml                           ← 父 POM(BOM 版本管理)
├── quick-start/                      ← 本章代码
│   ├── pom.xml
│   └── src/main/java/com/ai/course/quickstart/
│       ├── QuickStartApplication.java
│       └── controller/
│           └── ChatController.java
├── chat-client/                      ← 第 2 章(后续添加)
├── function-calling/                 ← 第 3 章(后续添加)
└── ...                               ← 每章一个子模块

父 POM 的核心配置(完整代码见仓库):

<properties>
    <java.version>21</java.version>
    <spring-boot.version>3.4.3</spring-boot.version>
    <spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
</properties>

<dependencyManagement>
    <dependencies>
        <!-- Spring Boot BOM -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- Spring AI Alibaba BOM -->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-bom</artifactId>
            <version>${spring-ai-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

为什么不用 spring-boot-starter-parent 做 parent?因为它会锁死 parent 位置。通过 BOM 导入,既统一了版本,又保留了自定义父 POM 的自由度。

子模块 POM(quick-start/pom.xml)——注意依赖不写版本号,全部由父 POM 管理:

<parent>
    <groupId>com.ai.course</groupId>
    <artifactId>spring-ai-alibaba-course</artifactId>
    <version>1.0.0</version>
</parent>

<artifactId>quick-start</artifactId>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
</dependencies>
5.3 配置文件
# quick-start/src/main/resources/application.yml
spring:
  application:
    name: quick-start
  ai:
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY}  # 通过环境变量注入,别硬编码!
      chat:
        options:
          model: qwen-plus
          temperature: 0.7

server:
  port: 8080

⚠️ 安全提醒:API Key 永远不要写在代码里。设置环境变量:

  • Linux/Mac:export AI_DASHSCOPE_API_KEY=sk-xxxxx
  • Windows:set AI_DASHSCOPE_API_KEY=sk-xxxxx
  • IDEA:Run Configuration → Environment variables
5.4 启动类
package com.ai.course.quickstart;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class QuickStartApplication {
    public static void main(String[] args) {
        SpringApplication.run(QuickStartApplication.class, args);
    }
}
5.5 第一个对话接口

Spring AI 推荐用 ChatClient(高层 API)而不是底层的 ChatModel。关系就像 JdbcTemplateDataSource

package com.ai.course.quickstart.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/chat")
public class ChatController {

    // 用 ChatClient 而不是 ChatModel —— 链式 API 更简洁,支持 Advisor 拦截
    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
                .defaultSystem("你是一个友好的AI助手。")
                .build();
    }

    /**
     * 同步对话
     * 试试:GET /api/chat?message=你好
     */
    @GetMapping
    public String chat(@RequestParam(defaultValue = "你好") String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }

    /**
     * 流式对话 —— 打字机效果
     * 试试:GET /api/chat/stream?message=介绍一下春季热门航线
     */
    @GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
    public Flux<String> chatStream(
            @RequestParam(defaultValue = "你好") String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

启动项目,打开浏览器访问 http://localhost:8080/api/chat?message=你好,你应该能看到通义千问的回复。

流式接口 /api/chat/stream 会一个字一个字地吐出来,体验明显好很多。LLM 生成完整回答可能要 5-10 秒,流式让用户边生成边看到,不用干等。

5.6 Temperature 对比实验

理论篇讲了 Temperature 影响输出稳定性,现在亲眼看看。加一个实验接口:

package com.ai.course.quickstart.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.web.bind.annotation.*;

import java.util.*;
import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("/api/experiment")
public class ExperimentController {

    private static final Logger log = LoggerFactory.getLogger(ExperimentController.class);

    private final ChatClient chatClient;

    public ExperimentController(ChatClient.Builder builder) {
        // 通过 System Prompt 限制回答长度,让对比更直观
        this.chatClient = builder
                .defaultSystem("你是一个简洁的助手,回答限制在20字以内。")
                .build();
    }

    /**
     * Temperature 对比实验
     * GET /api/experiment/temperature?message=用一个词形容春天
     *
     * 同一问题,分别用 temperature 0 和 1.0 各调用 3 次
     * temperature=0 的回答几乎相同,temperature=1.0 每次都不一样
     */
    @GetMapping("/temperature")
    public Map<String, List<String>> temperatureExperiment(
            @RequestParam(value = "message", defaultValue = "用一个词形容春天") String message) {

        double[] temperatures = {0.0, 1.0};
        int rounds = 3;
        log.info("开始 Temperature 对比实验,消息:{}", message);

        Map<String, CompletableFuture<List<String>>> futures = new LinkedHashMap<>();
        for (double temp : temperatures) {
            String key = "temperature_" + temp;
            futures.put(key, CompletableFuture.supplyAsync(() -> {
                List<String> responses = new ArrayList<>();
                for (int i = 0; i < rounds; i++) {
                    log.info("[{}] 第 {} 次调用开始...", key, i + 1);
                    long start = System.currentTimeMillis();
                    String content = chatClient.prompt()
                            .user(message)
                            .options(ChatOptions.builder()
                                    .temperature(temp)
                                    .build())
                            .call()
                            .content();
                    long cost = System.currentTimeMillis() - start;
                    log.info("[{}] 第 {} 次调用完成,耗时 {}ms,回答:{}", key, i + 1, cost, content);
                    responses.add(content);
                }
                return responses;
            }));
        }

        Map<String, List<String>> results = new LinkedHashMap<>();
        futures.forEach((key, future) -> results.put(key, future.join()));
        log.info("Temperature 对比实验完成");
        return results;
    }
}

直接访问 GET /api/experiment/temperature(默认问题"用一个词形容春天"),你会看到类似结果:

{
  "temperature_0.0": ["温暖", "温暖", "温暖"],
  "temperature_1.0": ["生机", "温柔", "绚烂"]
}
  • temperature=0:三次回答完全一样——输出稳定可预测
  • temperature=1.0:每次回答都不同——充满随机性和创意

这就是为什么 Agent 场景要用低 temperature——你需要 LLM 的输出稳定可预测

六、与机票比价 Agent 的集成

本章搭建的 quick-start 是整个系列的起点。后面的章节会在这个基础上逐步加能力:

  • 第 2 章:用 ChatClient 的 Advisor 和 Prompt 模板,让对话更可控
  • 第 3 章:通过 Function Calling 接入机票查询工具,从"编造数据"进化为"查真实数据"
  • 第 4 章:加 Memory,实现多轮对话(“北京飞上海” → “明天的” → “最便宜的”)

当前模块的配置(模型、温度)和父 POM 的依赖管理,会被所有后续模块复用。

七、FAQ 与踩坑记录

Q1:启动报错 No qualifying bean of type 'ChatModel'

最常见的原因是缺依赖或者环境变量没设:

  1. 确认 pom.xml 里有 spring-ai-alibaba-starter-dashscope
  2. 确认环境变量 AI_DASHSCOPE_API_KEY 已设置且值正确
  3. 确认 application.yml 里用的是 ${AI_DASHSCOPE_API_KEY} 而不是写死的 Key

Q2:调用报 401 UnauthorizedInvalidApiKey

Key 无效或过期。到百炼平台检查 Key 状态。新账号第一次使用需要先开通百炼服务并创建 API Key。另外看看环境变量里的 Key 有没有多余的空格或换行。

Q3:流式接口返回乱码

三个排查方向:

  1. @GetMappingproduces 必须是 text/event-stream;charset=UTF-8
  2. 如果有 Nginx 反代,加 proxy_buffering off;
  3. 浏览器直接访问 SSE 显示可能不友好,用 curl 或前端 EventSource

Q4:第一次调用特别慢(10 秒以上)

正常。第一次请求涉及到底层 HTTP 连接建立、模型加载等。后续请求会快很多。如果持续很慢,检查网络是否需要代理。


本章小结

理论篇 实战篇
Token 与计费 环境搭建
Prompt 三种角色 同步对话
Temperature 稳定性 流式输出
Context Window 容量 Temperature 实验
Function Calling 核心

下一章:ChatClient 的高级用法和 Prompt 工程。会用到 Advisor 拦截器、Prompt 模板、结构化输出——让 LLM 的返回直接变成 Java 对象。


本文代码GitHub - quick-start 模块

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐