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

本文以一个生产级钉钉智能 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 理解"何时该调用此方法"的关键依据。需要用自然语言详细描述功能、触发场景、参数约束和回复格式要求。
  • 参数 + ToolContextToolContext 是 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 模式和交互卡片能力,可以快速实现一个生产可用的智能助手。


相关推荐
XPoet4 小时前
AI 编程工程化:Subagent——给你的 AI 员工打造协作助手
前端·后端·ai编程
花千树-0104 小时前
多步骤 ReAct 实战:让 Agent 自主完成航司比价与订票
java·agent·function call·react agent·harness·j-langchain·多步骤推理
databook4 小时前
从写代码到问问题:2026年,AI如何重构数据科学工作流
人工智能·后端·数据分析
二月龙4 小时前
Go并发编程避坑指南:如何彻底解决死锁(Deadlock)问题
后端
m0_694845574 小时前
CRUD (Nestjsx)部署教程:自动生成RESTful接口
服务器·人工智能·后端·开源·自动化·restful
程序员柒叔4 小时前
OpenClaw 系统架构分析
系统架构·aigc·agent·openclaw·clawbot·motlbot
Go_error5 小时前
Go 并发控制 errgroup.Group
后端·go
希望永不加班5 小时前
SpringBoot 事件机制:ApplicationEvent 与监听器
java·开发语言·spring boot·后端·spring
IVAN不想说话5 小时前
为什么 Karpathy 的「LLM Wiki」突然火了?
后端