
本文以一个生产级钉钉智能 Agent 项目为例,深入解析 Spring AI 1.1 的核心用法,包括 ChatClient、Tool(函数调用)、ChatMemory(对话记忆)和流式响应等关键能力。项目的核心目标是通过自然语言与 AI 对话,在钉钉中完成商品查询、单子创建业务操作。
1. 项目概述
1.1 技术选型
| 类别 | 技术 |
|---|---|
| 基础框架 | Spring Boot 3.5.13 + Java 21(虚拟线程) |
| AI 引擎 | Spring AI 1.1.4(OpenAI 兼容接口) |
| AI 模型 | DeepSeek V3.1(temperature=0.2) |
| 消息通道 | 钉钉 Stream 模式(长连接) |
| 对话存储 | Redis(ChatMemory 持久化) |
| 外部调用 | OpenFeign(对接内部系统) |
1.2 核心能力矩阵
markdown
用户发送消息(钉钉)
│
▼
┌───────────────────────┐
│ DingTalkStreamListener│ ← 钉钉长连接接收消息
└───────────┬───────────┘
▼
┌───────────────────────┐
│ ChatServiceImpl │ ← 消息解析、上下文注入、Prompt 组装
└───────────┬───────────┘
▼
┌───────────────────────┐
│ ChatClient (Spring AI)│ ← AI 推理 + Tool 调用
└───────────┬───────────┘
▼
┌───────┴───────┐
│ AgentTools │ ← 7 个业务 Tool(查询商品/建单/审批等)
└───────┬───────┘
▼
┌───────────────────────┐
│ DingTalkCardService │ ← 流式卡片推送回钉钉
└───────────────────────┘
2. ChatClient 核心配置
ChatClient 是 Spring AI 的核心 API,用于与 AI 大模型交互。本项目将其注册为 Spring Bean,并注入了全局 System Prompt 和工具集。
2.1 Bean 注册
java
// AiAgentConfig.java
@Configuration
public class AiAgentConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder, AgentToolsConfig toolsConfig) {
String systemPrompt = String.format(
"你是一个专业的XXXX智能体(Agent),你的职责是XXXX。" +
"当前系统时间:%s。\n" +
"【执行纪律:ReAct 模式】\n" +
"当面临需要多步操作的复杂指令时,你绝不能试图一次性调用所有工具!\n" +
"你必须严格遵守以下思考循环:\n" +
"1. Thought(思考):分析当前处于哪一步,我需要先做什么?\n" +
"2. Action(行动):调用【唯一一个】当前最需要的工具获取数据或执行操作。\n" +
"3. Observation(观察):拿到工具返回的结果后,再决定下一步做什么。\n" +
"反复执行上述循环,必须确保上一个操作返回了成功状态后,才能执行依赖它的下一个操作。",
DateUtil.now()
);
return builder
// System Prompt:设定 AI 的人设和行为规范
.defaultSystem(systemPrompt)
// 注册所有 @Tool 方法
.defaultTools(toolsConfig)
.build();
}
}
关键设计点:
- System Prompt 是整个 Agent 的"灵魂",决定了 AI 是做助手的定位,以及必须遵守 ReAct 思考模式。
defaultTools(toolsConfig)将所有@Tool方法注册为 AI 可调用的函数。AI 会根据用户意图自动选择调用哪个 Tool。
3. Tool(函数调用)设计与实现

Spring AI 的 @Tool 注解将 Java 方法暴露给 AI 大模型,让 AI 能够"调用函数"执行具体业务操作。这是对接企业自有业务系统的核心能力。
3.1 Tool 的定义规范
每个 @Tool 方法都包含两个核心部分:
description:这是 AI 理解"何时该调用此方法"的关键依据。需要用自然语言详细描述功能、触发场景、参数约束和回复格式要求。- 参数 +
ToolContext:ToolContext是 Spring AI 注入的上下文对象,包含请求级别的元数据(如当前用户的钉钉 ID)。
3.2 典型 Tool 示例
查询商品
java
@Tool(description = """
查询商品信息。
【回复要求】:使用 Markdown 表格展示商品核心信息
(名称、条码、零售价加粗带¥、单位)。
""")
public XXXXX queryGoods(
@JsonPropertyDescription("商品的数字条码 (对应 goodsCode)") String param,
ToolContext context) {
// 业务逻辑:调用系统查询商品信息
// 例如:FeignClient 调用 接口
feignClient .xxx();
return XXXX;
}
Spring AIStructured Output Converters有将 LLM 输出转换为结构化格式

创建单子(含安全拦截)
java
@Tool(description = """
创建单子。
【安全拦截】:
绝不能在用户初次提出建单需求时直接调用!你必须严格遵循以下流程:
1. 当用户提出建单需求,且已提取到所有必要参数时,
必须先向用户**完整展示即将提交的所有数据信息**,
展示完毕后明确询问用户:"以上是待创建的单子信息,
请确认是否立刻创建?(请回答'是'或'否')"
2. 只有当用户在当前最新消息中明确回答了"是"时,才允许触发调用本工具。
""")
public XXXXX confirmOrder(AiRequest aiRequest, ToolContext context) {
// 业务逻辑:查询商品详情 → 组装 DTO → 调用接口创建 → 返回单据详情
...
}
设计亮点:安全拦截
在 confirmOrder 的描述中,通过 Prompt 约束 AI:必须先展示预览、等待用户确认,才允许真正调用创建接口。这是对敏感操作的安全保护机制------AI 作为中间层,严格遵循"预览 → 确认 → 执行"的流程,不会因用户一句"帮我建个单"就直接提交到 ERP。
4. ChatMemory(对话记忆)持久化
AI Agent 需要记住对话上下文。Spring AI 通过 ChatMemory 接口管理对话历史,默认支持内存存储。本项目将其扩展为 Redis 持久化,实现分布式部署下的对话连续性。
4.1 Redis 存储实现
java
@Component
public class RedisChatMemoryRepository implements ChatMemoryRepository {
private static final String MEMORY_KEY_PREFIX = "agent:chat_memory:";
private static final long EXPIRE_DAYS = 3;
@Override
public void saveAll(String conversationId, List<Message> messages) {
String key = MEMORY_KEY_PREFIX + conversationId;
String json = objectMapper.writeValueAsString(messages);
stringRedisTemplate.opsForValue().set(key, json, EXPIRE_DAYS, TimeUnit.DAYS);
}
@Override
public List<Message> findByConversationId(String conversationId) {
// JSON → List<Message> 的安全反序列化
// 需要根据 MessageType 区分 UserMessage / AssistantMessage / ToolResponseMessage
...
}
}
4.2 注入到 ChatClient
java
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(redisChatMemoryRepository) // Redis 持久化
.maxMessages(5) // 最多保留 5 轮对话
.build();
}
对话 ID 的设计 :本项目使用 dingUserId(钉钉用户 ID)作为 conversationId,确保每个用户与 AI 的对话独立存储、互不干扰。
5. 流式响应与钉钉卡片推送
AI 生成内容可能耗时较长,如果等生成完毕再一次性返回,用户体验很差。本项目采用服务端推送(Server-Sent Events)+ 钉钉交互卡片的方案,实现类似 ChatGPT 的流式打字机效果。
5.1 核心流程
java
// ChatServiceImpl.java
// 1. 生成唯一 TrackId,先推送一张"加载中"卡片
String outTrackId = UUID.randomUUID().toString();
dingTalkCardService.sendInitialCard(outTrackId, dingUserId);
// 2. 调用 ChatClient 的 stream 方法获取数据流
Flux<String> streamResponse = chatClient.prompt()
.advisors(advisor) // 对话记忆
.user(u -> u.text(enrichedText)) // 用户输入
.toolContext(Map.of("dingUserId", dingUserId)) // 工具上下文
.stream()
.content();
// 3. 缓冲池订阅:将碎片拼接后实时推送更新卡片
StringBuilder fullContent = new StringBuilder();
streamResponse
.bufferTimeout(10, Duration.ofMillis(300)) // 每 300ms 打包一次
.publishOn(Schedulers.boundedElastic()) // 切换到弹性线程
.subscribe(
chunks -> {
chunks.forEach(chunk -> fullContent.append(chunk));
// 流式更新钉钉卡片(动画效果)
dingTalkCardService.streamUpdateCard(outTrackId, fullContent.toString(), false);
},
error -> {
// 异常处理:更新卡片显示错误信息
dingTalkCardService.streamUpdateCard(outTrackId,
fullContent.toString() + "\n\n⚠️ AI 助手开小差了", true);
},
() -> {
// 生成完毕:标记 Finalize,结束动画
dingTalkCardService.streamUpdateCard(outTrackId, fullContent.toString(), true);
dingTalkCardService.updateCardTitle(outTrackId, "✅ 助理回复完毕");
}
);
bufferTimeout(10, Duration.ofMillis(300)) 是关键:它将流式输出缓冲聚合,每 300ms 批量推送一次给钉钉,避免碎片过多导致 API 调用过于频繁,同时保证实时性。
5.2 钉钉交互卡片
钉钉支持通过 Webhook 推送 Markdown 格式的交互卡片。本项目的卡片支持:
- 标题更新:从"处理中..."变为"✅ 助理回复完毕"
- 内容增量更新:追加展示 AI 的实时输出
- Finalize 标记:告知前端动画结束
6. 消息解析与上下文注入
6.1 消息类型处理
java
private Pair<String, List<Media>> getMessage(JSONObject message) {
String msgtype = message.getString("msgtype");
switch (msgtype) {
case "text" -> userText = message.getJSONObject("text").getString("content").trim();
// 图片消息目前注释掉了(需要下载到本地再传给 AI)
default -> {
log.warn("暂不支持的消息类型: {}", msgtype);
return null;
}
}
return Pair.of(userText, mediaList);
}
6.2 增强 Prompt 的注入
在将用户消息发给 AI 之前,会注入丰富的上下文信息:
java
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append("当前用户是: ").append(XXX).append("。");
promptBuilder.append("当前系统时间:").append(DateUtil.now()).append("。");
promptBuilder.append("用户的指令是: ").append(XXX).append("。");
这些信息通过 System Prompt 之外的 User Prompt 注入,确保 AI 知道当前操作员是谁、系统当前时间等关键上下文。
7. 完整请求链路总结
arduino
① 用户在钉钉发送:"帮我查一下商品xxxx"
② DingTalkStreamListener 接收消息,设置 HeaderContext(钉钉用户ID)
③ ChatServiceImpl.checkUser() 验证登录状态与权限
④ 组装增强 Prompt:
"当前用户的: XXX。当前岗位是:XXXX。
当前操作人是:XXX。当前系统时间:2026-04-14 10:30:00。
用户的指令是: 帮我查一下商品xxxx。"
⑤ 构建 MessageChatMemoryAdvisor(基于 dingUserId 从 Redis 加载对话历史)
⑥ chatClient.prompt().advisors(advisor).user(text).stream().content()
⑦ AI 思考:"用户要查询商品,tool: queryGoods,param: XXXX"
⑧ AgentToolsConfig.queryGoods() 被调用 → OpenFeign → 内部系统 → 返回商品信息
⑨ AI 组织 Markdown 回复
⑩ 流式推送更新钉钉卡片
⑪ 用户看到最终结果
8. 关键设计模式与最佳实践
8.1 ReAct 模式
通过 System Prompt 约束 AI 必须遵循 Thought → Action → Observation 的循环:
makefile
Thought: 用户要建单,但我还不知道商品明细,需要先问用户提供商品信息。
Action: 无(等待用户提供商品)
Observation: 用户提供了商品条码
Thought: 现在我可以调用 confirmOrder 了
Action: confirmOrder({goodsDetails: [...]})
Observation: 单据创建成功,单号 XXXXXX
8.2 安全拦截模式
对于创建、审批、驳回 等写操作,不在 Tool 内部硬编码二次确认,而是通过 Prompt 描述约束 AI 必须先向用户确认。这比代码层面的拦截更灵活------Prompt 可以根据场景调整确认话术。
8.3 工具上下文隔离
通过 ToolContext 将请求级别的 dingUserId 传入每个 Tool,Tool 内部再通过 HeaderContext.get() 获取(利用 ThreadLocal 或类似的上下文机制),确保每个工具方法都能安全地获取当前操作用户的上下文,避免串话。
9. 技术总结
| 能力 | Spring AI 概念 | 本项目实践 |
|---|---|---|
| AI 推理 | ChatClient |
配置 System Prompt + defaultTools |
| 函数调用 | @Tool |
业务方法,含详细 description |
| 对话记忆 | ChatMemory |
Redis 持久化,maxMessages=5 |
| 流式响应 | Flux<String> + bufferTimeout |
每 300ms 聚合推送钉钉卡片 |
| 上下文传递 | ToolContext |
传递 dingUserId 隔离多租户 |
| 消息生成 | MessageChatMemoryAdvisor |
基于 conversationId 组织对话历史 |
Spring AI 1.1 提供了从Prompt 管理、Tool 调用、对话记忆到流式输出的一整套基础设施,使得构建企业级 AI Agent 从底层细节中解放出来,专注于业务逻辑本身。结合钉钉的 Stream 模式和交互卡片能力,可以快速实现一个生产可用的智能助手。