钉钉 Stream 机器人接入
钉钉 Stream 机器人接入技术文档
摘要:本文档深度解析基于 dingtalk-stream SDK 的 AI 会议室预定机器人架构设计与工程实践,涵盖长连接通信、状态机恢复、多租户隔离、日志追踪等核心技术要点。
一、系统架构总览
1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 钉钉开放平台 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ AI 机器人 │ │ Stream 网关 │ │ OpenAPI │ │
│ └──────┬───────┘ └──────┬───────┘ └──────▲───────┘ │
│ │ │ │ │
└─────────┼───────────────────┼────────────────────┼───────────┘
│ WebSocket │ HTTPS │
│ (长连接) │ │
▼ ▼ │
┌─────────────────────────────────────────────────┼───────────┐
│ o-meeting-room 应用层 │ │
│ ┌────────────────────────────────────────────┐ │ │
│ │ DingTalkStreamClient (dingtalk-stream) │ │ │
│ │ ├─ Credential 认证 │ │ │
│ │ ├─ register_callback_handler 注册处理器 │ │ │
│ │ └─ start_forever 启动长连接 │ │ │
│ └────────────────┬───────────────────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────────────────────────────────┐ │ │
│ │ DingTalkMessageHandler │ │ │
│ │ ├─ 解析 CallbackMessage │ │ │
│ │ ├─ 生成 request_id (日志追踪) │ │ │
│ │ ├─ 调用 DingTalkAssistant │ │ │
│ │ └─ reply_text 回复消息 │ │ │
│ └────────────────┬───────────────────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────────────────────────────────┐ │ │
│ │ DingTalkAssistant (LangGraph 集成) │ │ │
│ │ ├─ build_graph 构建状态机 │ │ │
│ │ ├─ _get_state 恢复对话状态 │ │ │
│ │ ├─ _start_workflow 新对话 │ │ │
│ │ ├─ _continue_workflow 恢复对话 │ │ │
│ │ ├─ _handle_room_selection 房间选择 │ │ │
│ │ └─ _handle_confirmation 确认/取消 │ │ │
│ └────────────────┬───────────────────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────────────────────────────────┐ │ │
│ │ LangGraph + MySQLSaver │ │ │
│ │ ├─ BookingContext 状态管理 │ │ │
│ │ ├─ checkpoints 表 持久化 │ │ │
│ │ └─ interrupt/resume 中断恢复 │ │ │
│ └────────────────────────────────────────────┘ │ │
│ │ │
└────────────────────────────────────────────────────┼───────────┘
│
│ HTTP
▼
┌──────────────┐
│ 钉钉 OpenAPI │
│ 发送回复消息 │
└──────────────┘
1.2 技术栈选型
| 组件 | 技术 | 版本 | 选型理由 |
|---|---|---|---|
| Stream SDK | dingtalk-stream | 最新 | 官方推荐,长连接稳定,自动重连 |
| 状态机 | LangGraph | >=0.2.0 | 支持 interrupt/resume,完美匹配多轮对话 |
| 持久化 | MySQLSaver | 最新 | 会话状态持久化,支持服务重启恢复 |
| 日志 | Loguru | >=0.7.0 | 异步日志,request_id 全链路追踪 |
| 配置 | python-dotenv | 最新 | 环境变量管理,符合 12-Factor |
二、核心模块深度解析
2.1 DingTalkStreamClient 初始化
文件: dingtalk_bot.py
from dingtalk_stream import DingTalkStreamClient, Credential
from dingtalk_stream.chatbot import ChatbotMessage
# 创建凭证对象
credential = Credential(
client_id=DINGTALK_CONFIG["app_key"],
client_secret=DINGTALK_CONFIG["app_secret"]
)
# 创建 Stream 客户端
client = DingTalkStreamClient(credential=credential)
# 注册消息处理器
handler = DingTalkMessageHandler()
client.register_callback_handler(ChatbotMessage.TOPIC, handler)
# 启动长连接(阻塞)
client.start_forever()
关键技术点:
-
Credential 对象:dingtalk-stream SDK 要求传入 Credential 对象而非字典,这是 SDK 的认证机制。
-
Topic 注册:
ChatbotMessage.TOPIC是钉钉机器人消息的主题标识,SDK 内部会将消息路由到对应的处理器。 -
长连接机制:
start_forever()是阻塞调用,内部维护 WebSocket 连接,自动处理心跳、重连、断线恢复。
2.2 消息处理器架构
文件: app/integrations/handler.py
class DingTalkMessageHandler(ChatbotHandler):
"""钉钉消息处理器,继承自 ChatbotHandler"""
async def process(self, callback_message: CallbackMessage):
# 生成 request_id 用于追踪此消息的处理
request_id = f"dingtalk-{uuid.uuid4().hex[:8]}"
request_id_ctx_var.set(request_id)
try:
# 从 CallbackMessage.data 中解析 ChatbotMessage
chatbot_message = ChatbotMessage.from_dict(callback_message.data)
# 提取消息信息
user_id = chatbot_message.sender_staff_id or chatbot_message.sender_id
content = chatbot_message.text.content if chatbot_message.text else ""
# 调用 AI 助手处理
assistant = get_assistant()
reply = assistant.chat(user_id, content)
# 发送回复
if reply:
self.reply_text(reply, chatbot_message)
# 确认收到(必须返回,否则钉钉会重推)
return AckMessage.STATUS_OK, "ok"
except Exception as e:
return AckMessage.STATUS_SYSTEM_EXCEPTION, str(e)
核心技术点:
2.2.1 CallbackMessage 解析陷阱
钉钉 Stream SDK 的 CallbackMessage 结构中,真正的聊天消息在 data 字段中,需要显式解析:
chatbot_message = ChatbotMessage.from_dict(callback_message.data)
这是最常见的坑!如果直接使用 callback_message 而不是 callback_message.data,会导致消息解析失败。
2.2.2 AckMessage 状态码规范
| 状态码 | 含义 | 使用场景 |
|---|---|---|
STATUS_OK |
成功 | 消息处理成功 |
STATUS_SYSTEM_EXCEPTION |
系统异常 | 处理异常,钉钉会重推 |
重要:必须返回 AckMessage,否则钉钉会认为消息未送达并重推,导致重复处理。
2.2.3 request_id 全链路追踪
request_id = f"dingtalk-{uuid.uuid4().hex[:8]}"
request_id_ctx_var.set(request_id)
使用 contextvars 存储 request_id,Loguru 日志格式中自动注入,实现单条消息的全链路追踪:
2026-06-25 20:15:41.046 | INFO | request_id - dingtalk-abc123ef | app.integrations.handler:process:43 - [钉钉消息] 收到消息
2.3 AI 助手与 LangGraph 集成
文件: app/integrations/assistant.py
2.3.1 单例模式
_assistant = None
def get_assistant() -> DingTalkAssistant:
"""获取助手实例(单例模式)"""
global _assistant
if _assistant is None:
_assistant = DingTalkAssistant()
return _assistant
设计理由:
- LangGraph 的
build_graph()构建成本高(加载 LLM、初始化检查点存储) - 单例避免重复创建,提升性能
- 保证全局只有一个图实例,checkpoint 状态一致
2.3.2 多租户隔离
def chat(self, user_id: str, message: str) -> str:
# 生成或使用用户专属 session_id
session_id = f"dingtalk_{user_id}"
# 检查是否有待处理的对话状态
state = self._get_state(session_id)
关键设计:
- 每个钉钉用户独立的
session_id,格式:dingtalk_{user_id} - MySQLSaver 通过
thread_id(即 session_id)隔离不同用户的对话状态 - 支持同一时间多个用户并行使用机器人
2.3.3 状态恢复机制
def _get_state(self, session_id: str) -> dict:
"""获取对话状态"""
config = {"configurable": {"thread_id": session_id}}
state = self.app.get_state(config)
return state.values if state else None
工作流程:
- 用户发送消息
- 通过
session_id从 MySQL 查询 checkpoint - 如果有状态,恢复
BookingContext - 如果没有状态,创建新对话
2.3.4 中断恢复(Interrupt/Resume)
def _continue_workflow(self, ctx: BookingContext, message: str, session_id: str) -> str:
"""继续已有工作流"""
from langgraph.types import Command
command = Command(resume=message)
result = self.app.invoke(
{"ctx": ctx, "user_message": message},
config,
command=command
)
核心机制:
- LangGraph 的
interrupt节点会暂停执行,保存状态到 MySQL - 用户回复时,使用
Command(resume=message)恢复执行 - 完美支持多轮对话:收集槽位 → 查询房间 → 选择房间 → 确认预订
2.3.5 特殊交互处理
房间选择:
def _handle_room_selection(self, ctx: BookingContext, message: str) -> str:
ctx.all_infos['selected_room_id'] = message
ctx.pending_user_kind = None
return self._continue_workflow(ctx, "确认选择", session_id)
确认/取消:
def _handle_confirmation(self, ctx: BookingContext, message: str) -> str:
if message.lower() in ['是', '确认', 'yes', 'ok', '1']:
ctx.pending_user_kind = None
return self._continue_workflow(ctx, "确认预订", session_id)
else:
return "已取消预订。如果您需要重新预订,请告诉我您的需求。"
三、状态机与流程控制
3.1 预订流程状态机
COLLECTING_SLOTS → SEARCHING → AWAITING_SELECTION → CONFIRMING → BOOKED
↓ ↓ ↓ ↓ ↓
收集信息 查询房间 等待选择 等待确认 完成
3.2 LangGraph 节点编排
ingest → react_planner → 路由 → validate → tool_search → present → await_user(interrupt)
↓
finish ← tool_book ← validate_book ← ... ← resume(Command) ← 用户输入
3.3 pending_user_kind 语义
| 值 | 阶段 | 用户操作 | 前端/客户端行为 |
|---|---|---|---|
clarify |
收集槽位 | 输入时间、人数、时长 | 显示快捷选项 |
select_room |
等待选择 | 选择房间编号 | 显示房间卡片 |
confirm |
等待确认 | 回复"确认"或"取消" | 显示确认/取消按钮 |
四、日志与可观测性
4.1 request_id 全链路追踪
# handler.py: 生成 request_id
request_id = f"dingtalk-{uuid.uuid4().hex[:8]}"
request_id_ctx_var.set(request_id)
# Loguru 配置中自动注入
logger.add(
"logs/app.log",
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | request_id - {extra[request_id]: <20} | {name}:{function}:{line} - {message}"
)
4.2 日志示例
2026-06-25 20:15:41.046 | INFO | request_id - dingtalk-abc123ef | app.integrations.handler:process:43 - [钉钉消息] 收到消息 - user_id: user123, content: 明天下午2点8人会议室
2026-06-25 20:15:41.050 | INFO | request_id - dingtalk-abc123ef | app.integrations.assistant:chat:36 - [钉钉助手] 收到用户消息 - user_id: user123, message: 明天下午2点8人会议室
2026-06-25 20:15:41.055 | INFO | request_id - dingtalk-abc123ef | app.agents.graph:build_graph:25 - [graph] 开始执行图
2026-06-25 20:15:41.100 | INFO | request_id - dingtalk-abc123ef | app.nodes.llm_node.react_planner:run:25 - [react_planner] 开始规划
2026-06-25 20:15:42.500 | INFO | request_id - dingtalk-abc123ef | app.integrations.assistant:chat:66 - [钉钉助手] 回复内容: 找到3间可用会议室...
4.3 监控指标
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
| 消息处理延迟 | Loguru 时间戳差值 | > 5s |
| Stream 连接状态 | client 心跳检测 | 断线 > 30s |
| 对话状态恢复失败率 | MySQL 查询失败计数 | > 5% |
| LLM 调用失败率 | LangGraph 异常捕获 | > 10% |
五、部署与运维
5.1 环境准备
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cat > .env << EOF
DINGTALK_APP_KEY=your_app_key
DINGTALK_APP_SECRET=your_app_secret
DINGTALK_AGENT_ID=your_agent_id
DEEPSEEK_API_KEY=your_llm_key
MYSQL_PASSWORD=your_mysql_password
EOF
5.2 生产部署(systemd)
[Unit]
Description=DingTalk Meeting Room Bot
After=network.target mysql.service
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/o-meeting-room
Environment=PATH=/opt/o-meeting-room/venv/bin
ExecStart=/opt/o-meeting-room/venv/bin/python dingtalk_bot.py
Restart=always
RestartSec=10
StandardOutput=append:/opt/o-meeting-room/logs/dingtalk_stdout.log
StandardError=append:/opt/o-meeting-room/logs/dingtalk_stderr.log
[Install]
WantedBy=multi-user.target
5.3 健康检查
# 检查进程
ps aux | grep dingtalk_bot
# 检查日志
tail -f logs/app.log | grep "钉钉消息"
# 检查 MySQL 连接
mysql -u root -p -e "SELECT COUNT(*) FROM o_meeting_room.checkpoints"
5.4 常见问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Stream 连接断开 | 网络不稳定 | 检查网络,SDK 自动重连 |
| 消息重复处理 | 未返回 AckMessage | 确保返回 STATUS_OK |
| 对话状态丢失 | MySQL 连接失败 | 检查数据库配置 |
| LLM 响应慢 | API 限流 | 检查 API Key 配额 |
六、性能优化
6.1 连接复用
- Stream 长连接:SDK 内部维护 WebSocket,避免频繁建连
- MySQL 连接池:SQLAlchemy 连接池复用,避免每次查询建连
- LLM HTTP 连接:LangChain 内部使用连接池
6.2 状态缓存
- LangGraph checkpoint:MySQL 持久化,支持服务重启恢复
- 单例 Assistant:避免重复构建图,提升性能
6.3 异步处理
- 消息处理:
async def process异步处理,不阻塞其他消息 - 日志写入:Loguru 异步日志,不阻塞业务逻辑
七、安全最佳实践
7.1 凭证管理
# ✅ 正确:使用环境变量
DINGTALK_APP_SECRET=${SECRET_FROM_VAULT}
# ❌ 错误:硬编码
DINGTALK_APP_SECRET=abc123
7.2 输入校验
if not user_id or not content:
logger.warning("收到无效消息,跳过处理")
return AckMessage.STATUS_OK, "ok"
7.3 异常处理
except Exception as e:
logger.error("[钉钉消息] 处理异常: {}", str(e))
return AckMessage.STATUS_SYSTEM_EXCEPTION, str(e)
八、扩展方向
8.1 多机器人支持
# 支持多个钉钉应用
clients = {
"app1": DingTalkStreamClient(credential1),
"app2": DingTalkStreamClient(credential2),
}
8.2 消息卡片支持
# 发送交互式卡片
self.reply_card(card_template, chatbot_message)
8.3 富媒体消息
# 支持图片、文件、语音
self.reply_image(image_url, chatbot_message)
九、总结
钉钉 Stream 机器人接入方案采用长连接 + 状态机 + 持久化架构,具备以下优势:
- 高可靠:Stream 长连接自动重连,MySQL 持久化状态
- 可扩展:单例模式 + 连接池,支持高并发
- 可观测:request_id 全链路追踪,完善日志
- 易维护:模块化设计,清晰的分层架构
通过 LangGraph 的状态机能力,完美实现了中断恢复的多轮对话体验,为用户提供了流畅的会议室预定服务。
浙公网安备 33010602011771号