CH 01Spring AI 入门
这一章只有一个目标:建立正确的心智模型,并跑通第一个能用的 AI 接口。用一句话概括本章的核心结论——你以后写的 90% 的代码,都是在跟 ChatClient 打交道,而 ChatModel 才是真正连接大模型的那根线。
1.1先建立心智模型:Spring AI 的三层抽象
新手最容易犯的错,是把 Spring AI 当成「一个 HTTP 客户端」。它不是。它是一套把不同厂商大模型的能力抹平成统一接口的抽象层。理解下面这张分层图,你后面 10 章都不会迷路。
| 层次 | 代表类型 | 它负责什么(一句话) | 你要不要写 |
|---|---|---|---|
| 应用层 你写的代码 |
Controller / Service / Agent / RAG 链路 | 业务逻辑:什么场景、什么提示词、什么工具、怎么组装结果 | ✅ 主要工作 |
| 门面层 ChatClient |
ChatClient / Advisor |
提供流式(fluent)API、默认系统提示、默认 Advisor 链,是官方推荐的入口 | ✅ 天天用 |
| 模型抽象层 Model |
ChatModel / EmbeddingModel / ImageModel |
定义「对话」「向量化」「生图」的能力契约,屏蔽厂商差异 | 🔸 需要精细控制时用 |
| 厂商实现层 Provider |
OpenAiChatModel / OllamaChatModel / AnthropicChatModel |
真正发 HTTP 请求、解析厂商特有协议、映射参数 | ❌ 不写,自动装配 |
四个必记的核心抽象
① ChatModel —— 连接模型的插座
接口只定义了「我能接收一个 Prompt,返回一个 ChatResponse」。换厂商时,这个接口名一个字都不用改,改的是 yml 配置和依赖坐标。
关键方法call(Prompt) / stream(Prompt)
② ChatClient —— 对开发者友好的门面
在 ChatModel 之上封装了流式 API、默认系统提示、默认 Advisor、结构化输出等便利能力。业务代码优先用它。
最小用法chatClient.prompt().user("...").call().content()
③ Prompt —— 一次请求的完整输入
它不是字符串,而是「消息列表 + 模型参数」的组合。第 2 章会讲透消息角色,第 3 章会加 format 占位符约束输出。
结构Prompt(List<Message>, ChatOptions)
④ Advisor —— 横切关注点的插槽
类似 Spring MVC 的 Interceptor / Servlet Filter。可以在请求前后插入逻辑:打日志、算 Token、加记忆、做 RAG 检索、做内容安全。
典型MessageChatMemoryAdvisor(第 6 章)、QuestionAnswerAdvisor(第 5 章)
ChatClient 是你点单的窗口,Prompt 是你写的订单,Advisor 是中间传菜的厨师(可以加料、改单、记账),ChatModel 是后厨的灶,厂商实现是具体哪家的燃气。
完整调用时序(建议对照代码看三遍)
ChatClient 调用一行都不用动。这就是抽象层的价值。
1.2创建项目与依赖
方式一:IDEA / start.spring.io 生成(推荐新手)
3.x」—— 那是 Spring Boot 4 发布之前的写法,
已经过时。Spring Boot 没有 LTS 概念,每个小版本只有约 12 个月 OSS 支持期:
3.5.x 已于 2026-06-30 停止 OSS 支持,并被移出了 Initializr。
实测官方 start.spring.io 现在只提供 4.x
(4.1.1 为默认,另有 4.0.8 及若干 SNAPSHOT),下拉列表里根本没有 3.x。
选 4.1.1。
- 打开
https://start.spring.io,或 IDEA 的 New Project → Spring Initializr。 - Project 选 Maven,Language Java,Spring Boot 选 4.1.1(当前默认版)。
- Group
com.example,Artifactspringai-lab,JDK 选 21(最低 17)。 - 依赖勾 Spring Web、Lombok,以及 AI 分组里的 OpenAI。
注意:勾「Spring Web」生成出来的坐标是
spring-boot-starter-webmvc—— Boot 4 已把spring-boot-starter-web改名(旧名废弃)。 - 生成后解压到工作根目录
D:\springai-lab(见 0.4 节的目录约定;放别处也行,后续命令里的路径按实际替换),用 IDEA 打开pom.xml。
实测 Initializr 有一个完整的 AI 分组,共 50 项依赖:OpenAI / Anthropic / DeepSeek /
Ollama / Mistral / Google GenAI / MCP Client & Server / pgvector / Milvus / Qdrant /
Redis / JDBC 记忆库……该有的都有。而且生成出来的项目会自动带上
spring-ai.version = 2.0.1,本身就是最新版。
真正需要手改 pom 的场景是:课程后面章节要用到的其他 Spring AI 模块 (向量库、记忆库、MCP Server 等),按需追加即可 —— 这才是下面「方式二」的用途, 而不是因为 Initializr 版本旧。
方式二:直接改 pom(本课程采用)
只需在 0.4.7 节的完整 pom 基础上,第 1 章至少要保证下面三个依赖存在:
<!-- Web:提供 HTTP 接口 -->
<!-- ★ 注意是 starter-**webmvc**,不是 3.x 教程里的 starter-web:-->
<!-- Spring Boot 4 已废弃旧名。写成旧名不会报错,但迟早被移除,-->
<!-- 详见 index.html 0.4.7 节的完整说明。-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- ★ Spring AI 的核心模型接入:OpenAI 协议及其所有兼容实现 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- ★ BOM:统一管理所有 spring-ai-* 的版本,缺了它就要逐个写版本号 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
/v1/chat/completions、同样的 JSON 结构)。
Spring AI 的 OpenAI starter 本质上只是按 OpenAI 协议发请求,所以换个 base-url 就能指到别家。
只有当某家完全不兼容(比如 Anthropic Claude 的原生协议)时,才需要换成对应的 starter。
验证依赖是否装配成功
# 命令行验证(在项目根目录)
mvn -q dependency:tree -Dincludes=org.springframework.ai
# 期望能看到类似:
# org.springframework.ai:spring-ai-starter-model-openai:jar:2.0.1
# org.springframework.ai:spring-ai-model:jar:2.0.1
# org.springframework.ai:spring-ai-client-chat:jar:2.0.1
# org.springframework.ai:spring-ai-openai:jar:2.0.1
spring-ai-bom 却没加具体 starter,启动时不会报错(因为根本没触发自动配置),
但你在 Service 里注入 ChatModel 时会得到
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.ai.chat.model.ChatModel'。
看到这个异常,先检查是不是漏了 starter,其次检查 api-key 是不是空的——
Spring AI 在密钥为空时可能不注册 ChatModel bean,这是最隐蔽的一种。
1.3模型连接配置(换厂商对照表)
先写一份第 1 章用的 application.yml:
spring:
application:
name: springai-lab
ai:
openai:
# 从环境变量读;冒号后为空表示"没有也要启动",避免本地没配就起不来
api-key: ${OPENAI_API_KEY:}
# ★ 换厂商的核心开关
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
temperature: 0.7
max-tokens: 2048
# 超时设置:大模型首字延迟经常 2~10 秒,读超时要给足
connect-timeout: 30s
read-timeout: 120s
server:
port: 8080
logging:
level:
com.example.springai: debug
# 打开后能看到发给模型的完整请求体,调提示词时必开
org.springframework.ai.chat.client: debug
常见厂商配置对照表(可直接抄)
| 厂商 | base-url | model 示例 | 备注 |
|---|---|---|---|
| DeepSeek | https://api.deepseek.com | deepseek-chat / deepseek-reasoner | 性价比高,本课程默认;无 embedding 接口 |
| 阿里通义 | https://dashscope.aliyuncs.com/compatible-mode | qwen-plus / qwen-max | 有 text-embedding-v3,第 4 章可直接用 |
| 智谱 AI | https://open.bigmodel.cn/api/paas/v4 | glm-4-plus | 有 embedding-3。注意:智谱的专用 Starter 已移出 Spring AI 主仓库,但走 OpenAI 兼容协议仍然可用——就用 spring-ai-starter-model-openai,只改 base-url |
| 月之暗面 | https://api.moonshot.cn | moonshot-v1-8k | 长上下文有优势 |
| OpenAI | https://api.openai.com | gpt-4o-mini | 需要网络可达;embedding 用 text-embedding-3-small |
| Ollama 本地 | http://localhost:11434(换 starter) | qwen2.5:7b | 需把依赖换成 spring-ai-starter-model-ollama |
/v1(starter 会自己拼),
写了反而变成 /v1/v1/chat/completions 而 404。遇到 404 第一件事就是把 base-url 简化到域名。
1.4最底层调用:直接使用 ChatModel
先看「不加任何糖」的写法,这样你才知道 ChatClient 帮你做了什么。新建 ch01/ChatModelBasics.java:
package com.example.springai.ch01;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 第 1 章 · 最底层的调用方式:只用 ChatModel 接口。
* 目的:看清一次对话请求到底由哪些部分组成。
*/
@Component
public class ChatModelBasics {
// 构造器注入。这个 bean 由 spring-ai-starter-model-openai 自动装配,
// 类型实际是 OpenAiChatModel,但我们只依赖抽象接口,方便日后替换/测试打桩。
private final ChatModel chatModel;
public ChatModelBasics(ChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* 最简单的调用:只传一句用户话。
* 内部会自动包成一个只含 UserMessage 的 Prompt。
*/
public String simple(String userText) {
// call(String) 是 ChatModel 的便捷方法,等价于 new Prompt(userText)
return chatModel.call(userText);
}
/**
* 带系统提示 + 用户消息的标准写法。
* 这是理解"消息列表"最直观的例子。
*/
public String withSystemPrompt(String userText) {
// SystemMessage:设定角色与规则,模型会当作最高优先级的背景指令
SystemMessage system = new SystemMessage(
"你是一名严谨的企业知识库助手,回答必须简洁,不确定时明确说不知道,禁止编造。");
// UserMessage:用户这一轮真正说的话
UserMessage user = new UserMessage(userText);
// Prompt 的本质 = 有序的消息列表(+ 可选的模型参数)
Prompt prompt = new Prompt(List.of(system, user));
// call 返回的是 ChatResponse,里面不止有文本,还有元数据
ChatResponse response = chatModel.call(prompt);
// 逐层取值:response -> generation -> output(AssistantMessage) -> text
return response.getResult().getOutput().getText();
}
/**
* 取出本次调用的元数据:用了哪个模型、消耗多少 Token。
* 第 10 章做成本统计时就用这里的数字。
*/
public String inspectMetadata(String userText) {
ChatResponse response = chatModel.call(new Prompt(userText));
String text = response.getResult().getOutput().getText();
// Usage 里含 promptTokens / completionTokens / totalTokens
var usage = response.getMetadata().getUsage();
// 模型名(如 deepseek-chat / gpt-4o-mini)
String model = response.getMetadata().getModel();
return """
模型:%s
输入Token:%s 输出Token:%s 合计:%s
回答:%s
""".formatted(model,
usage.getPromptTokens(), usage.getCompletionTokens(),
usage.getTotalTokens(), text);
}
}
ChatResponse → getResult() → getOutput() → getText()。
为什么这么绕?因为未来会有「一次返回多个候选结果」和「多模态输出」的场景,
getResults() 是列表、output 是消息对象(可能含图片)。记住这条路径,第 7 章工具调用时你会频繁用到 getOutput().getToolCalls()。
1.5推荐用法:ChatClient 门面
实际项目里几乎不会直接调 ChatModel,而是用 ChatClient。它把「默认系统提示、默认 Advisor、参数覆盖、结构化输出」都收进了一套流式 API 里。
第一步:把 ChatClient 做成一等公民(配置类)
package com.example.springai.common;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 全局 ChatClient 配置。
* 为什么不直接在 Service 里 new?—— 因为默认系统提示、默认 Advisor 应该全应用统一。
* 注意:容器里已经有一个 ChatClient.Builder(Spring AI 自动装配),
* 我们注入它再 build,比自己 new Builder 更省事。
*/
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
// 全局默认角色设定:所有请求都会自动带上(可在单次调用时覆盖)
.defaultSystem("""
你是"智汇企业助手",服务于公司内部员工。
规则:
1. 用简体中文回答,语气专业但不啰嗦。
2. 不确定的信息必须说明"我需要更多信息",禁止编造。
3. 涉及金额、日期、人名时,严格使用原文给出的数据。
""")
// 默认 Advisor:打印每次请求/响应,调试期必开(第 3 章会写自定义的)
.defaultAdvisors(new SimpleLoggerAdvisor())
// 不在这里写 defaultOptions,留给各场景按需覆盖
.build();
}
}
第二步:在 Service 里使用 fluent API
package com.example.springai.ch01;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class HelloAiService {
private final ChatClient chatClient;
public HelloAiService(ChatClient chatClient) {
this.chatClient = chatClient;
}
/** 最小可用:一句话进,一句话出 */
public String ask(String question) {
return chatClient.prompt() // ① 开启一次请求构建
.user(question) // ② 设置用户消息(也可 .system(...) 覆盖默认)
.call() // ③ 同步执行:阻塞等待完整结果
.content(); // ④ 取出纯文本内容
}
/** 流式:一个字一个字往回推,前端体验好,适合聊天框 */
public Flux<String> askStream(String question) {
return chatClient.prompt()
.user(question)
.stream() // ③' 换成 stream(),返回 Flux<String>
.content();
}
/** 想看完整响应对象(含 Token 用量),用 chatResponse() 而不是 content() */
public String askWithUsage(String question) {
var response = chatClient.prompt()
.user(question)
.call()
.chatResponse(); // ④' 拿到 ChatResponse
var usage = response.getMetadata().getUsage();
return "用量:" + usage.getTotalTokens() + " tokens\n"
+ response.getResult().getOutput().getText();
}
}
ChatClient fluent API 速查
| 写法 | 含义 | 返回 |
|---|---|---|
.prompt() | 开始构建一次请求 | ChatClientRequestSpec |
.system("…") / .user("…") | 设置对应角色的消息,可传字符串、Resource、模板 | 本对象(可链式) |
.options(ChatOptions) | 本次请求覆盖模型参数(温度、模型名等) | 本对象 |
.advisors(a -> a.param("k","v")) | 本次请求附加 Advisor 或传参 | 本对象 |
.tools(Object…) | 第 7 章:注册可调用的工具对象 | 本对象 |
.call() | 同步执行 | CallResponseSpec |
.stream() | 流式执行 | StreamResponseSpec |
.content() | 取纯文本 | String / Flux<String> |
.chatResponse() | 取完整响应(含元数据) | ChatResponse |
.entity(Class) | 第 3 章:直接映射成 Java 对象 | T |
1.6流式输出(SSE)—— 让前端"打字机"效果
非流式的问题:模型生成 500 字要 8 秒,用户盯着白屏 8 秒,体验极差。流式的价值是把首字延迟从 8 秒降到 0.5 秒。
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final HelloAiService service;
public ChatController(HelloAiService service) {
this.service = service;
}
/** 同步:适合后台任务、脚本调用 */
@PostMapping("/ask")
public String ask(@RequestBody AskRequest req) {
return service.ask(req.question());
}
/**
* 流式:produces 必须是 text/event-stream(SSE 协议),
* 前端用 EventSource 或 fetch + ReadableStream 接收。
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody AskRequest req) {
return service.askStream(req.question());
}
/** 用 record 接收请求体,比 Map 更安全 */
public record AskRequest(String question) {}
}
前端怎么接(可以直接贴进浏览器控制台验证):
// 浏览器端:fetch + 流式读取(比 EventSource 更灵活,因为要 POST)
const res = await fetch('http://localhost:8080/api/v1/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: '用三句话介绍 Spring AI' })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// SSE 每行形如 "data:xxx\n\n",这里做最简解析
const chunk = decoder.decode(value, { stream: true });
chunk.split('\n').forEach(line => {
if (line.startsWith('data:')) {
full += line.slice(5);
document.title = full.slice(-30); // 快速验证:看标签页标题在变
}
});
}
console.log(full);
- 没加
produces:Spring 会按application/json返回,前端收到一坨,没有流式效果。 - 中间有网关/反向代理:Nginx 默认开启缓冲(
proxy_buffering on),会把流式结果攒起来一次性下发。需要在 location 里加proxy_buffering off;。 - Reactor 线程被阻塞:在
.stream()之后又调用了.block()或阻塞式 JDBC,会破坏背压。流式链路上所有 IO 都要是响应式的。
1.7完整可运行示例:Hello AI 服务
还差一个统一异常处理——模型调用是最容易抛异常的地方(超时、限流、余额不足),不兜住会把 500 堆栈直接暴露给前端:
package com.example.springai.common;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.ResourceAccessException;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 网络层:连不上 / 读超时。99% 是网络或 base-url 写错 */
@ExceptionHandler(ResourceAccessException.class)
public ResponseEntity<Map<String, Object>> onNetwork(ResourceAccessException e) {
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
.body(Map.of("code", 504,
"message", "模型服务不可达,请检查网络或 base-url 配置",
"detail", e.getMessage()));
}
/** 业务层:模型返回了错误码(401 密钥错、429 限流、402 余额不足) */
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> onRuntime(RuntimeException e) {
// 生产环境应打日志 + 上报,不要把原始堆栈返回给前端
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("code", 500,
"message", "AI 服务调用失败:" + e.getMessage()));
}
}
1.8运行与验证
# 1) 起数据库(本章其实还用不到,但保持习惯)
cd D:/springai-lab && docker compose up -d mysql redis pgvector
# 2) 起应用
mvn spring-boot:run
# 或者 IDEA 里运行 SpringAiLabApplication
# 3) 同步接口
curl -X POST http://localhost:8080/api/v1/chat/ask \
-H "Content-Type: application/json" \
-d '{"question":"用一句话解释 Spring AI 是什么"}'
# 期望输出:一段中文回答
# 4) 流式接口(-N 关闭 curl 缓冲,才能看到逐字输出)
curl -N -X POST http://localhost:8080/api/v1/chat/stream \
-H "Content-Type: application/json" \
-d '{"question":"从 1 数到 10,每个数字换一行"}'
-N;
Windows 的 Git Bash 里有时即使加了 -N 也会缓冲,改用 curl --no-buffer
或在 IDEA 的 HTTP Client(.http 文件)里测试,那边对 SSE 支持更好。
1.9常见坑排错对照表
| 报错 / 现象 | 根本原因 | 解决 |
|---|---|---|
No qualifying bean of type 'ChatModel' |
没加 starter,或 api-key 为空导致自动配置没生效 | 确认 pom 里有 spring-ai-starter-model-openai;确认 echo $OPENAI_API_KEY 不为空 |
401 Unauthorized |
密钥错误、过期,或环境变量没被读到 | 用 curl 直连厂商 API 验证密钥;检查 IDEA 启动配置里是否勾了 Include system environment variables |
404 Not Found |
base-url 多写了 /v1,或路径重复 |
把 base-url 简化成纯域名,让 starter 自己拼路径 |
429 Too Many Requests |
触发厂商限流 | 加重试(第 6 章 Resilience4j),或降低并发;课堂环境建议每人独立密钥 |
SocketTimeoutException: Read timed out |
默认读超时太短,长回答没读完就断了 | 在 yml 里加 read-timeout: 120s;流式接口不受此限 |
| 中文输出乱码 / 问号 | 控制台或 IDE 编码非 UTF-8 | IDEA → Settings → Editor → File Encodings 全设 UTF-8;启动加 -Dfile.encoding=UTF-8 |
回答里带着 ```json 代码块标记 |
提示词没约束格式 | 第 3 章会系统解决,临时可以先在提示词里加「只输出 JSON,不要任何解释和代码块标记」 |
IDEA 里 ChatClient.Builder 注入失败 |
容器里只有一个 Builder 时才能按类型注入;自定义了多个会歧义 | 用 @Qualifier 指定,或直接在方法参数里注入 ChatClient.Builder(Spring AI 会提供默认的) |
1.10自测题(先自己想,再点开)
Q1. ChatModel 和 ChatClient 的区别是什么?什么时候必须直接用 ChatModel?
ChatModel 是模型能力的抽象接口,只提供 call(Prompt) / stream(Prompt),返回结构化对象;
ChatClient 是建立在其上的门面,提供流式 API、默认系统提示、默认 Advisor、entity() 结构化输出等便利能力。
必须直接用 ChatModel 的场景:需要拿到最原始的 ChatResponse 元数据做精细处理、需要绕过 Advisor 链、
或者在写框架/公共组件(比如自定义 Advisor、路由组件)时。
Q2. 把 DeepSeek 换成通义千问,需要改哪几处?
因为两者都兼容 OpenAI 协议:只改 yml 三行——api-key、base-url、chat.options.model。
业务代码零改动。若不兼容(如 Anthropic 原生),则还需换 starter 依赖。
Q3. 为什么 ChatResponse 取值要写成三层 getResult().getOutput().getText()?
因为一次调用可能返回多个候选结果(getResults() 是列表),
每个结果里的 output 是消息对象(可能包含文本、工具调用、多模态内容),
所以要先选结果、再取消息、最后取文本。这样设计是为了支持多候选、工具调用和多模态。
Q4. 流式接口为什么一定要写 produces = MediaType.TEXT_EVENT_STREAM_VALUE?
Spring MVC 需要靠它选择正确的 HttpMessageConverter 并按 SSE 协议(text/event-stream)输出。
不写的话会走 JSON 序列化,Flux 会被聚合成一个数组一次性返回,前端就看不到逐字效果了。
Q5.【动手题】改造本章代码:让 /ask 接口支持前端传入 temperature,并限幅在 0~1 之间。
提示:用 OpenAiChatOptions.builder().temperature(t).build(),通过
chatClient.prompt().options(options) 覆盖本次请求参数;限幅用 Math.max(0, Math.min(1, t)) 或
@Min/@Max 注解 + @Validated。
- 能说出 Spring AI 三层抽象各自的职责,并画出调用时序。
- 能独立把模型从 DeepSeek 切换到通义千问或本地 Ollama,且业务代码不改。
/ask与/stream两个接口都能通过 curl 验证。- 能读懂
getMeta.getMetadata().getUsage()里的 Token 数字。