如果你的RAG系统,要实现企业内部答疑,其实不需要单独弄一个网页,直接用办公软件集成进来就行了,比如说钉钉。(就像现在很火的openclaw一样,也是对接到各个IM上)
我们可以借助企业钉钉机器人来实现这样的功能,我们需要对话的时候,直接找到这个机器人,问他就行了。如:
(图片来自钉钉官网公开案例:https://open.dingtalk.com/document/development/single-chat-bot-overview )
我们的实现效果如下:

创建钉钉机器人
https://open.dingtalk.com/?spm=a2q3p.21071111.0.0.37d365eeU4YUj9
在钉钉开发者平台上,用你的钉钉的账号登录,登陆之后选择一个企业,然后就可以进到开发者后台了。
接着,进入【应用开发】tab中,创建一个钉钉应用,先输出【应用名】和【应用描述】下一步之后,进入应用页面。
在这里【添加应用能力】,添加一个【机器人】,这在里填好机器人的基本信息,主要是名称、描述、头像等等,然后最下面的【消息接收模式】选择Stream模式。
Stream 模式是钉钉开放平台提供的一种集成方式,它可以监听机器人回调、事件订阅回调和注册卡片回调。使用 Stream 模式接入,钉钉开放平台将通过 Websocket 连接与应用程序通讯,Stream 模式将极大降低接入门槛和资源依赖,不需要公网服务器、IP、域名等资源,只需集成钉钉开放平台 SDK 即可。
之后点击发布,将机器人发布。
申请权限
如果想让你的钉钉机器人具备单聊的功能,需要申请一个【企业内机器人发送消息权限】
申请路径如下:

应用发布
之后,在【版本管理与发布】这里创建一个新版本,然后发布。
可见范围可以自己调整。之后发布即可。

代码开发
接着就是代码开发了,我们可以参考钉钉官方给的demo:https://github.com/open-dingtalk/dingtalk-stream-sdk-java-quick-start 这里面是java中接入钉钉机器人的代码,把全部代码copy到我们的应用中。然后就可以启动应用了。 记得增加和修改配置:
dingtalk:
appKey: ${DINGTALK_APP_KEY}
appSecret: ${DINGTALK_APP_SECRET}
robotCode: ${DINGTALK_APP_KEY} # 企业内部应用机器人的 robotCode 通常与 appKey 相同
(也可以直接看我们项目中的代码,在cn.hollis.llm.mentor.know.engine.dingtalk这个目录下,我真是demo的代码也做了修改和精简)
如果直接复制完代码后,运行时,如果是群聊,则能正常回复,但是如果是和机器人的单聊,则会报错:
这是因为demo中给的代码默认是群聊模式的代码,单聊模式的并没有给出,于是我们需要做些改造。先定义一个RobotPrivateMessageService:
package cn.hollis.llm.mentor.rag.dingtalk.service;
import com.alibaba.fastjson.JSONObject;
import com.aliyun.dingtalkrobot_1_0.Client;
import com.aliyun.dingtalkrobot_1_0.models.*;
import com.aliyun.tea.TeaException;
import com.aliyun.teautil.models.RuntimeOptions;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Slf4j
@Service
public class RobotPrivateMessageService {
private Client robotClient;
private final AccessTokenService accessTokenService;
@Value("${robot.code}")
private String robotCode;
@Autowired
public RobotPrivateMessageService(AccessTokenService accessTokenService) {
this.accessTokenService = accessTokenService;
}
@PostConstruct
public void init() throws Exception {
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
config.protocol = "https";
config.regionId = "central";
robotClient = new Client(config);
}
public String send(String text,String userId) throws Exception {
BatchSendOTOHeaders batchSendOTOHeaders = new BatchSendOTOHeaders();
batchSendOTOHeaders.setXAcsDingtalkAccessToken(accessTokenService.getAccessToken());
BatchSendOTORequest batchSendOTORequest = new BatchSendOTORequest();
batchSendOTORequest.setMsgKey("sampleText");
batchSendOTORequest.setRobotCode(robotCode);
batchSendOTORequest.setUserIds(java.util.Arrays.asList(userId));
JSONObject msgParam = new JSONObject();
msgParam.put("content", "java-getting-start say : " + text);
batchSendOTORequest.setMsgParam(msgParam.toJSONString());
try {
BatchSendOTOResponse batchSendOTOResponse = robotClient.batchSendOTOWithOptions(batchSendOTORequest, batchSendOTOHeaders, new RuntimeOptions());
if (Objects.isNull(batchSendOTOResponse) || Objects.isNull(batchSendOTOResponse.getBody())) {
log.error("RobotPrivateMessages_send batchSendOTOResponse return error, response={}",
batchSendOTOResponse);
return null;
}
return batchSendOTOResponse.getBody().getProcessQueryKey();
} catch (TeaException e) {
log.error("RobotPrivateMessages_send batchSendOTOResponse throw TeaException, errCode={}, " +
"errorMessage={}", e.getCode(), e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error("RobotPrivateMessages_send batchSendOTOResponse throw Exception", e);
throw e;
}
}
}
紧接着在ChatBotCallbackListener中改造代码,
处理流程:
1. 从 ChatbotMessage 中取出文本内容、会话类型、发送者 StaffId。
2. 给发送方回一条"已接到您的请求,正在思考中...",降低等待焦虑。
3. 拼接 dingtalk_ 前缀作为内部 userId,调用 ChatApplicationService.chat(userId, content, null, ChatSource.STAFF_DING)。
4. 由于钉钉机器人为请求-响应模型,无法直接消费 SSE 流,通过 aggregateChatResult(...) 把 Flux
package cn.hollis.llm.mentor.know.engine.dingtalk.callback.chatbot;
import cn.hollis.llm.mentor.know.engine.business.vo.CarInfoVO;
import cn.hollis.llm.mentor.know.engine.business.vo.MyCarVO;
import cn.hollis.llm.mentor.know.engine.chat.constant.ChatSource;
import cn.hollis.llm.mentor.know.engine.chat.entity.ChatMessage;
import cn.hollis.llm.mentor.know.engine.chat.service.ChatApplicationService;
import cn.hollis.llm.mentor.know.engine.dingtalk.service.RobotGroupMessagesService;
import cn.hollis.llm.mentor.know.engine.dingtalk.service.RobotPrivateMessageService;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.dingtalk.open.app.api.callback.OpenDingTalkCallbackListener;
import com.dingtalk.open.app.api.models.bot.ChatbotMessage;
import com.dingtalk.open.app.api.models.bot.MessageContent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 机器人消息回调(支持单聊和群聊)
*
* @author Hollis
*/
@Slf4j
@Component
public class ChatBotCallbackListener implements OpenDingTalkCallbackListener<ChatbotMessage, JSONObject> {
private static final String DINGTALK_USER_PREFIX = "dingtalk_";
@Autowired
private RobotGroupMessagesService robotGroupMessagesService;
@Autowired
private RobotPrivateMessageService robotPrivateMessageService;
@Autowired
private ChatApplicationService chatApplicationService;
@Override
public JSONObject execute(ChatbotMessage message) {
try {
MessageContent text = message.getText();
if (text != null) {
String conversationType = message.getConversationType();
String msg = text.getContent();
String openConversationId = message.getConversationId();
String senderUserId = message.getSenderStaffId();
log.info("receive bot message,conversationType={}, msg={}", conversationType, msg);
String userId = DINGTALK_USER_PREFIX + senderUserId;
if (conversationType.equals("2")) {
log.info("receive bot message from group={}, msg={}", openConversationId, msg);
robotGroupMessagesService.send(openConversationId, "已接到您的请求,正在思考中...");
} else if (conversationType.equals("1")) {
log.info("receive bot message from user={}, msg={}", userId, msg);
robotPrivateMessageService.send("已接到您的请求,正在思考中...", senderUserId);
}
// 复用 ChatApplicationService 的完整对话链路(会话创建、意图识别、RAG/兜底通用对话)
Flux<String> chatFlux = chatApplicationService.chat(userId, msg, null, ChatSource.STAFF_DING);
// 钉钉机器人为请求-响应模型,无法直接消费 SSE 流,阻塞聚合为完整结果
ChatResult chatResult = aggregateChatResult(chatFlux);
// 这里需要注意,官网中说0是单聊,1是群聊,我实测发现1是单聊,2是群聊。
if (conversationType.equals("2")) {
sendGroupResult(openConversationId, chatResult);
} else if (conversationType.equals("1")) {
sendPrivateResult(senderUserId, chatResult);
}
}
return new JSONObject();
} catch (Exception e) {
log.error("receive bot message failed", e);
return new JSONObject();
}
}
/**
* 将流式对话结果聚合为结构化结果。
* <p>
* 除了 LLM 答案 token,还会收集 [REFERENCE] 引用、[CARD] 提示以及 [CARD_CHOICE_*] 车辆选择卡片,
* 便于后续根据内容类型选择钉钉消息模板(文本/Markdown/卡片)。
* 设置 120 秒超时,避免 RAG 链路异常时无限阻塞钉钉回调线程。
*/
private ChatResult aggregateChatResult(Flux<String> chatFlux) {
ChatResult result = new ChatResult();
chatFlux.doOnNext(event -> {
if (event == null || event.isEmpty()) {
return;
}
if (event.startsWith("[PROGRESS]") || event.startsWith("[DONE]")) {
return;
}
if (event.startsWith("[WARN]:")) {
result.warnMessage = event.substring("[WARN]:".length()).trim();
return;
}
if (event.startsWith("[REFERENCE]:")) {
parseReferences(event.substring("[REFERENCE]:".length()), result);
return;
}
if (event.startsWith("[CARD]:")) {
result.cardPrompt = event.substring("[CARD]:".length()).trim();
return;
}
if (event.startsWith("[CARD_CHOICE_MYCAR]:")) {
result.myCarChoices = parseMyCarChoices(event.substring("[CARD_CHOICE_MYCAR]:".length()));
return;
}
if (event.startsWith("[CARD_CHOICE_CAR]:")) {
result.carChoices = parseCarChoices(event.substring("[CARD_CHOICE_CAR]:".length()));
return;
}
result.answerBuilder.append(event);
}).then().block(Duration.ofSeconds(120));
return result;
}
private void parseReferences(String json, ChatResult result) {
try {
List<ChatMessage.RagReference> refs = JSON.parseArray(json, ChatMessage.RagReference.class);
if (!CollectionUtils.isEmpty(refs)) {
result.references.addAll(refs);
}
} catch (Exception e) {
log.warn("解析RAG引用失败: {}", json, e);
}
}
private List<MyCarVO> parseMyCarChoices(String json) {
try {
return JSON.parseArray(json, MyCarVO.class);
} catch (Exception e) {
log.warn("解析我的车辆选择卡片失败: {}", json, e);
return null;
}
}
private List<CarInfoVO> parseCarChoices(String json) {
try {
return JSON.parseArray(json, CarInfoVO.class);
} catch (Exception e) {
log.warn("解析车型选择卡片失败: {}", json, e);
return null;
}
}
private void sendGroupResult(String openConversationId, ChatResult result) throws Exception {
if (!CollectionUtils.isEmpty(result.myCarChoices)) {
String markdown = buildMyCarChoicesMarkdown(result.cardPrompt, result.myCarChoices);
robotGroupMessagesService.sendMarkdown(openConversationId, "请选择车辆", markdown);
return;
}
if (!CollectionUtils.isEmpty(result.carChoices)) {
String markdown = buildCarChoicesMarkdown(result.cardPrompt, result.carChoices);
robotGroupMessagesService.sendMarkdown(openConversationId, "请选择车型", markdown);
return;
}
String answer = resolveAnswer(result);
if (!CollectionUtils.isEmpty(result.references)) {
String markdown = buildAnswerWithReferences(answer, result.references);
robotGroupMessagesService.sendMarkdown(openConversationId, "智能问答", markdown);
} else {
robotGroupMessagesService.send(openConversationId, answer);
}
}
private void sendPrivateResult(String senderUserId, ChatResult result) throws Exception {
if (!CollectionUtils.isEmpty(result.myCarChoices)) {
String markdown = buildMyCarChoicesMarkdown(result.cardPrompt, result.myCarChoices);
robotPrivateMessageService.sendMarkdown("请选择车辆", markdown, senderUserId);
return;
}
if (!CollectionUtils.isEmpty(result.carChoices)) {
String markdown = buildCarChoicesMarkdown(result.cardPrompt, result.carChoices);
robotPrivateMessageService.sendMarkdown("请选择车型", markdown, senderUserId);
return;
}
String answer = resolveAnswer(result);
if (!CollectionUtils.isEmpty(result.references)) {
String markdown = buildAnswerWithReferences(answer, result.references);
robotPrivateMessageService.sendMarkdown("智能问答", markdown, senderUserId);
} else {
robotPrivateMessageService.send(answer, senderUserId);
}
}
private String resolveAnswer(ChatResult result) {
String answer = result.getAnswer();
if (!answer.isBlank()) {
return answer;
}
if (result.warnMessage != null && !result.warnMessage.isBlank()) {
return result.warnMessage;
}
return "抱歉,我暂时无法回答您的问题,请稍后再试。";
}
/**
* 构建带引用来源的 Markdown 回答。
*/
private String buildAnswerWithReferences(String answer, List<ChatMessage.RagReference> references) {
List<ChatMessage.RagReference> uniqueRefs = references.stream()
.filter(ref -> ref.getDocumentTitle() != null || ref.getUrl() != null)
.distinct()
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(uniqueRefs)) {
return answer;
}
StringBuilder sb = new StringBuilder();
sb.append(answer);
sb.append("\n\n---\n**参考来源:**\n");
int idx = 1;
for (ChatMessage.RagReference ref : uniqueRefs) {
String title = ref.getDocumentTitle() != null ? ref.getDocumentTitle() : "来源" + idx;
String url = ref.getUrl();
if (url != null && !url.isBlank()) {
sb.append(idx).append(". [").append(escapeMarkdown(title)).append("](").append(url).append(")\n");
} else {
sb.append(idx).append(". ").append(escapeMarkdown(title)).append("\n");
}
idx++;
}
return sb.toString();
}
private String buildMyCarChoicesMarkdown(String prompt, List<MyCarVO> cars) {
String header = (prompt != null && !prompt.isBlank()) ? prompt : "请选择您的车辆";
StringBuilder sb = new StringBuilder();
sb.append("**").append(escapeMarkdown(header)).append("**\n\n");
for (int i = 0; i < cars.size(); i++) {
MyCarVO car = cars.get(i);
sb.append(i + 1).append(". ");
if (car.getFullName() != null) {
sb.append(escapeMarkdown(car.getFullName()));
}
if (car.getPlateNumber() != null) {
sb.append(" (车牌:").append(escapeMarkdown(car.getPlateNumber())).append(")");
}
sb.append("\n");
}
sb.append("\n请回复\"选择第N个\"或直接回复车辆名称。");
return sb.toString();
}
private String buildCarChoicesMarkdown(String prompt, List<CarInfoVO> cars) {
String header = (prompt != null && !prompt.isBlank()) ? prompt : "请选择您要咨询的车型";
StringBuilder sb = new StringBuilder();
sb.append("**").append(escapeMarkdown(header)).append("**\n\n");
for (int i = 0; i < cars.size(); i++) {
CarInfoVO car = cars.get(i);
sb.append(i + 1).append(". ");
if (car.getFullName() != null) {
sb.append(escapeMarkdown(car.getFullName()));
} else if (car.getBrand() != null || car.getModelName() != null) {
sb.append(escapeMarkdown((car.getBrand() != null ? car.getBrand() + " " : "") +
(car.getModelName() != null ? car.getModelName() : "")));
}
if (car.getGuidePrice() != null) {
sb.append(" (指导价:").append(car.getGuidePrice()).append("万)");
}
sb.append("\n");
}
sb.append("\n请回复\"选择第N个\"或直接回复车型名称。");
return sb.toString();
}
/**
* 转义 Markdown 特殊字符,避免标题/链接等被破坏。
*/
private String escapeMarkdown(String text) {
if (text == null) {
return "";
}
return text.replace("\\", "\\\\")
.replace("*", "\\*")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("`", "\\`");
}
private static class ChatResult {
private final StringBuilder answerBuilder = new StringBuilder();
private final List<ChatMessage.RagReference> references = new ArrayList<>();
private String warnMessage;
private String cardPrompt;
private List<MyCarVO> myCarChoices;
private List<CarInfoVO> carChoices;
String getAnswer() {
return answerBuilder.toString().trim();
}
}
}
这里通过一个if-else来判断消息是来自群聊还是单聊,然后调用不同的服务做回复。 这里需要注意的是,官网中说conversationType如果0是单聊,1是群聊,我实测发现1是单聊,2是群聊。
复用 RAG 对话链路
为了避免在钉钉回调里重复实现对话流程,完整链路已下沉到 ChatApplicationService: - ChatApplicationService.java - ChatController.java ChatController.send() 和 ChatBotCallbackListener 共用同一个 chat(...) 方法:
// HTTP 网页端
chatApplicationService
// 钉钉机器人
chatApplicationService
通过 ChatSource 参数,RAG 流程可以区分来源: - USER_WEB:需要车辆信息时会返回 [CARD_CHOICE_MYCAR] / [CARD_CHOICE_CAR] 交互卡片。 - STAFF_DING:跳过车辆选择卡片分支,直接走 RAG 检索或返回 [WARN] 提示。
消息类型与模板
钉钉企业内部应用机器人支持多种 msgKey/msgParam 组合。项目通过 DingTalkMessageBuilder 统一构造消息体:
package cn.hollis.llm.mentor.know.engine.dingtalk.util;
import com.alibaba.fastjson2.JSONObject;
/**
* 钉钉机器人消息体构造工具。
* <p>
* 企业内部应用机器人(群聊/单聊)支持的 msgKey 与 msgParam 格式相同,
* 本类统一封装文本、Markdown、链接、ActionCard 等常用消息模板。
*
* @author Hollis
*/
public final class DingTalkMessageBuilder {
private DingTalkMessageBuilder() {
}
/**
* 文本消息:msgKey = sampleText
*/
public static JSONObject text(String content) {
JSONObject msgParam = new JSONObject();
msgParam.put("content", content);
return msgParam;
}
/**
* Markdown 消息:msgKey = sampleMarkdown
*/
public static JSONObject markdown(String title, String text) {
JSONObject msgParam = new JSONObject();
msgParam.put("title", title);
msgParam.put("text", text);
return msgParam;
}
/**
* 链接消息:msgKey = sampleLink
*/
public static JSONObject link(String title, String text, String messageUrl, String picUrl) {
JSONObject msgParam = new JSONObject();
msgParam.put("title", title);
msgParam.put("text", text);
msgParam.put("messageUrl", messageUrl);
if (picUrl != null && !picUrl.isBlank()) {
msgParam.put("picUrl", picUrl);
}
return msgParam;
}
/**
* ActionCard 卡片(单按钮):msgKey = sampleActionCard
*/
public static JSONObject actionCard(String title, String text, String singleTitle, String singleUrl) {
JSONObject msgParam = new JSONObject();
msgParam.put("title", title);
msgParam.put("text", text);
msgParam.put("singleTitle", singleTitle);
msgParam.put("singleURL", singleUrl);
return msgParam;
}
}
已封装的消息类型: | msgKey | 说明 | msgParam 示例 | | --- | --- | --- | | sampleText | 文本消息 | {"content":"..."} | | sampleMarkdown | Markdown 消息 | {"title":"...","text":"..."} | | sampleLink | 链接消息 | {"title":"...","text":"...","messageUrl":"...","picUrl":"..."} | | sampleActionCard | 单按钮卡片 | {"title":"...","text":"...","singleTitle":"...","singleURL":"..."} |
功能调试
以上改造之后,再启动应用,然后在一个群聊中把我们的机器人配置进去 :
添加机器人的时候,输入你刚刚创建的机器人的名字就可以了。然后就在群聊中了。只需要at他,就可以这几个他对话了,如:
单聊也一样,搜索框找到这个机器人,
和他对话即可。
因为我们默认返回的失败Hello World,所以机器人就会回复这个,至此,我们就实现了如何通过钉钉机器人接收用户消息,然后给用户做回复了。
群聊和单聊
单聊机器人方便查找,对话简单,不需要at他,找到对话框直接问就行了。适合做一个企业内部通用的AI助手。 群聊机器人需要在一个群聊中把他设置进去,每次对话的时候都需要at他。这种适合做一个单产品的AI客服助手,把和这个产品相关的客服、运营等人员都拉到一个答疑群,群内有问题可以直接问机器人的方式,避免大家需要记住这个机器人的名字,每次想要问问题,直接找到这个群,at机器人即可。
实现原理
在 Stream 模式下,开发者的应用程序通过集成 SDK 的方式与钉钉开放平台建立一条 WebSocket 连接,建立连接过程中开放平台将对连接进行鉴权。当有回调发生时,开放平台将通过 WebSocket 连接将数据通知到开发者的应用程序。开发者的应用程序可以接收到这些数据并进行相应处理,从而实现与钉钉开放平台的实时通信
DingTalkStreamClientConfiguration中的代码,使用钉钉官方 SDK OpenDingTalkStreamClientBuilder 创建长连接客户端,配置应用的 appKey 和 appSecret 进行身份认证,再注册机器人消息回调监听器 ChatBotCallbackListener,然后通过 @Bean(initMethod = "start") 在应用启动时自动建立连接。
ChatBotCallbackListener是一个消息回调处理器。实现 OpenDingTalkCallbackListener 接口接收钉钉推送的消息,区分单聊(conversationType=1)和群聊(conversationType=2),目前回复固定内容 "Hello World",后面会扩展为为调用 RAG 服务生成智能回答。
一个消息的处理流程如下:
用户@机器人/私聊机器人
│
▼
钉钉服务器
│
▼
Stream长连接推送
│
▼
ChatBotCallbackListener.execute()
│
├──► 解析消息内容 (message.getText().getContent())
│
├──► 判断会话类型 (conversationType)
│ ├── 1: 单聊 → RobotPrivateMessageService.send()
│ └── 2: 群聊 → RobotGroupMessagesService.send()
│
└──► 调用钉钉机器人API发送回复
│
▼
用户收到回复消息
消息扩展
上面我们只是发送了简单的文本内容(sampleText),钉钉还支持我们发送其他的类型,比如图片,markdown,卡片等等,发送方式如下: https://open.dingtalk.com/document/dingstart/types-of-messages-sent-by-robots