Spring AI 实战:基于钉钉的智能 Agent 架构设计与实现

27 阅读9分钟

image.png

本文以一个生产级钉钉智能 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 核心能力矩阵

用户发送消息(钉钉)
        │
        ▼
┌───────────────────────┐
│  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 注册

// 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(函数调用)设计与实现

image.png

Spring AI 的 @Tool 注解将 Java 方法暴露给 AI 大模型,让 AI 能够"调用函数"执行具体业务操作。这是对接企业自有业务系统的核心能力。

3.1 Tool 的定义规范

每个 @Tool 方法都包含两个核心部分:

  • description:这是 AI 理解"何时该调用此方法"的关键依据。需要用自然语言详细描述功能、触发场景、参数约束和回复格式要求。
  • 参数 + ToolContextToolContext 是 Spring AI 注入的上下文对象,包含请求级别的元数据(如当前用户的钉钉 ID)。

3.2 典型 Tool 示例

查询商品

@Tool(description = """
    查询商品信息。
    【回复要求】:使用 Markdown 表格展示商品核心信息
    (名称、条码、零售价加粗带¥、单位)。
    """)
public XXXXX queryGoods(
        @JsonPropertyDescription("商品的数字条码 (对应 goodsCode)") String param,
        ToolContext context) {
    // 业务逻辑:调用系统查询商品信息
    // 例如:FeignClient 调用 接口
    feignClient .xxx();
   
    return XXXX;
}

Spring AIStructured Output Converters有将 LLM 输出转换为结构化格式

image.png

创建单子(含安全拦截)

@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 存储实现

@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

@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 核心流程

// 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 消息类型处理

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 之前,会注入丰富的上下文信息:

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. 完整请求链路总结

① 用户在钉钉发送:"帮我查一下商品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 的循环:

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
对话记忆ChatMemoryRedis 持久化,maxMessages=5
流式响应Flux<String> + bufferTimeout每 300ms 聚合推送钉钉卡片
上下文传递ToolContext传递 dingUserId 隔离多租户
消息生成MessageChatMemoryAdvisor基于 conversationId 组织对话历史

Spring AI 1.1 提供了从Prompt 管理、Tool 调用、对话记忆到流式输出的一整套基础设施,使得构建企业级 AI Agent 从底层细节中解放出来,专注于业务逻辑本身。结合钉钉的 Stream 模式和交互卡片能力,可以快速实现一个生产可用的智能助手。


完整的pom文件

<?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>
    <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.5.13</version>
       <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.demo</groupId>
    <artifactId>ai-agent</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name/>
    <properties>
       <java.version>21</java.version>
       <spring-ai.version>1.1.4</spring-ai.version>
       <spring-cloud.version>2025.0.1</spring-cloud.version>
    </properties>
    <dependencyManagement>
       <dependencies>
          <dependency>
             <groupId>org.springframework.cloud</groupId>
             <artifactId>spring-cloud-dependencies</artifactId>
             <version>${spring-cloud.version}</version>
             <type>pom</type>
             <scope>import</scope>
          </dependency>
          <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>
    <dependencies>
       <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-starter-model-openai</artifactId>
       </dependency>

       <dependency>
          <groupId>org.projectlombok</groupId>
          <artifactId>lombok</artifactId>
          <optional>true</optional>
       </dependency>
       <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-test</artifactId>
          <scope>test</scope>
       </dependency>


       <dependency>
          <groupId>com.dingtalk.open</groupId>
          <artifactId>dingtalk-stream</artifactId>
          <version>1.1.0</version>
       </dependency>

       <dependency>
          <groupId>com.aliyun</groupId>
          <artifactId>dingtalk</artifactId>
          <version>2.1.10</version>
       </dependency>
       <dependency>
          <groupId>com.aliyun</groupId>
          <artifactId>alibaba-dingtalk-service-sdk</artifactId>
          <version>2.0.0</version>
       </dependency>
       <dependency>
          <groupId>com.dingtalk.open</groupId>
          <artifactId>app-stream-client</artifactId>
          <version>1.3.12</version>
       </dependency>
       <dependency>
          <groupId>cn.hutool</groupId>
          <artifactId>hutool-all</artifactId>
          <version>5.8.37</version>
       </dependency>
       <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
       </dependency>
       <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-starter-openfeign</artifactId>
       </dependency>
       <dependency>
          <groupId>io.swagger</groupId>
          <artifactId>swagger-annotations</artifactId>
          <version>1.6.2</version>
       </dependency>
       <dependency>
          <groupId>com.github.pagehelper</groupId>
          <artifactId>pagehelper</artifactId>
          <version>5.3.2</version> </dependency>

       <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-actuator</artifactId>
       </dependency>
       <dependency>
          <groupId>io.micrometer</groupId>
          <artifactId>micrometer-registry-prometheus</artifactId>
       </dependency>
       <dependency>
          <groupId>io.micrometer</groupId>
          <artifactId>micrometer-tracing-bridge-otel</artifactId>
       </dependency>
       <dependency>
          <groupId>org.apache.commons</groupId>
          <artifactId>commons-lang3</artifactId>
       </dependency>
       <dependency>
          <groupId>io.github.openfeign</groupId>
          <artifactId>feign-okhttp</artifactId>
       </dependency>

       <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-aop</artifactId>
       </dependency>
    </dependencies>

    <build>
       <finalName>${project.artifactId}</finalName>
       <plugins>
          <plugin>
             <groupId>org.apache.maven.plugins</groupId>
             <artifactId>maven-compiler-plugin</artifactId>
             <configuration>
                <annotationProcessorPaths>
                   <path>
                      <groupId>org.projectlombok</groupId>
                      <artifactId>lombok</artifactId>
                   </path>
                </annotationProcessorPaths>
             </configuration>
          </plugin>
          <plugin>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-maven-plugin</artifactId>
             <configuration>
                <excludes>
                   <exclude>
                      <groupId>org.projectlombok</groupId>
                      <artifactId>lombok</artifactId>
                   </exclude>
                </excludes>
             </configuration>
          </plugin>
       </plugins>
    </build>

</project>