本文将带你从零开始,搭建一个基于豆包大模型的智能聊天机器人,并接入钉钉实现智能对话、历史记录管理、百度搜索等完整功能。
前言
2026年,AI已经像空气一样渗透到我们工作和生活的每个角落。从ChatGPT到OpenClaw,大模型技术正在颠覆我们的生活方式。作为一名开发者,如果还没有亲手写过AI项目,那真的out了!
今天,我将带你从零开始,手把手搭建一个基于豆包Seed大模型的智能聊天机器人,并接入钉钉实现以下完整功能:
- ✅ 智能对话:基于豆包Seed大模型的自然语言理解与生成
- ✅ 钉钉集成:支持钉钉机器人私聊和群聊场景,开箱即用
- ✅ 历史记忆:自动保存24小时内的聊天记录,实现上下文连贯对话
- ✅ 工具调用:集成百度搜索,AI能自主获取实时信息
- ✅ 权限管理:私聊仅回复管理员,群聊支持白名单控制
- ✅ 数据持久化:MySQL存储聊天记录,方便后续分析和调优
技术栈概览:
| 模块 | 技术选型 | 说明 |
|---|---|---|
| Web框架 | Express | 轻量灵活的Node.js框架 |
| AI模型 | 豆包Seed | 字节跳动火山方舟大模型 |
| 数据库 | MySQL + MySQL2 | 数据持久化存储 |
| 消息平台 | DingTalk Stream | 钉钉机器人Stream模式 |
| 开发语言 | TypeScript | 类型安全,开发体验好 |
| 构建工具 | tsx + tsc-alias | 快速开发和构建 |
一、准备工作
1.1 环境要求与平台准备
在开始之前,请确保开发环境和以下平台凭证均已就绪:
本地环境
- Node.js >= 20.0.0(推荐使用最新的LTS版本)
- MySQL数据库(5.7或8.0版本均可)
钉钉机器人(钉钉开放平台)
前往开放平台创建企业内部机器人应用,消息接收模式选择 Stream模式(无需公网IP),发布后获取以下凭证:
- Client ID / Client Secret:在"凭证与基础信息"中获取
- Robot Code:在"机器人"标签页中获取
- 管理员 UserId:在管理后台"通讯录"中找到自己账号,地址栏 URL 中可以看到
豆包大模型(火山引擎控制台)
进入 火山方舟 → 模型广场,开通 doubao-seed-2-0-lite 模型服务,然后在 API Key 管理 中创建并保存 SEED_API_KEY。查看文档
百度搜索API(百度智能云)
开通千帆大模型平台,在"应用接入"中创建应用获取 BAIDU_API_KEY,同时开通 AI搜索 服务。查看文档
💡 所有云服务均有免费额度,按本文步骤操作基本不会产生费用。
1.2 项目初始化
让我们从创建项目目录开始:
# 创建项目目录并进入
mkdir chat-bot
cd chat-bot
# 初始化package.json
npm init -y
安装核心依赖:
# 生产依赖
npm install express axios mysql2 dingtalk-stream dotenv dayjs node-cron
# 开发依赖
npm install -D typescript tsx tsc-alias cross-env @types/express @types/node @types/node-cron rimraf terser
初始化TypeScript配置:
npx tsc --init
创建项目目录结构:
mkdir -p src/{api,db,routes,services,stores,types,utils} scripts
二、项目架构与代码实现
5.1 项目结构详解
一个清晰的项目结构能让代码更易维护。下面是完整的项目结构:
chat-bot/
├── src/ # 源代码目录
│ ├── api/ # API 请求封装层
│ │ ├── baiduApi.ts # 百度搜索 API 封装
│ │ └── request.ts # HTTP 请求基础封装(基于axios)
│ ├── db/ # 数据库相关
│ │ ├── mysql.ts # MySQL 连接池配置
│ │ └── record.ts # 聊天记录CRUD操作
│ ├── routes/ # 路由层
│ │ ├── middleware/ # 中间件
│ │ │ └── steamAuth.ts # 开放接口认证中间件
│ │ ├── dingtalk.ts # 钉钉消息路由(Webhook方式)
│ │ └── open.ts # 开放接口路由(供外部系统调用)
│ ├── services/ # 业务服务层
│ │ ├── dingtalk/ # 钉钉机器人服务
│ │ │ ├── index.ts # 钉钉消息监听与处理(核心)
│ │ │ └── send.ts # 钉钉消息发送封装
│ │ └── doubao/ # 豆包 AI 服务
│ │ ├── index.ts # AI 对话核心逻辑(Responses API)
│ │ ├── agents/ # 不同场景的Agent
│ │ │ └── chat.ts # 闲聊Agent(调用tools)
│ │ ├── prompts/ # 角色提示词
│ │ │ └── chat.ts # 闲聊角色设定(system prompt)
│ │ └── tools/ # 工具函数
│ │ └── index.ts # 工具定义和实现(百度搜索)
│ ├── stores/ # 数据缓存层
│ │ └── record.ts # 聊天记录内存缓存(Map实现)
│ ├── types/ # TypeScript 类型定义
│ │ ├── baiduApi.d.ts # 百度 API 响应类型
│ │ └── common.d.ts # 通用类型(Message, Tool等)
│ ├── utils/ # 工具函数
│ │ ├── delay.ts # 延迟函数(用于消息分段发送)
│ │ ├── file.ts # 文件操作
│ │ └── schedule.ts # 定时任务封装(node-cron)
│ └── index.ts # 应用入口
├── scripts/ # 构建脚本
│ └── build.js # 生产构建脚本
├── package.json # 项目配置
├── tsconfig.json # TypeScript 配置
├── .env.example # 环境变量示例
└── README.md # 项目说明文档
5.2 环境变量配置
创建 .env 文件,将所有敏感信息和配置项集中管理:
# 服务配置
PORT=1801
OPEN_KEY=your_open_key_here # 开放接口调用密钥
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=chat-bot
DB_PASSWORD=your_secure_password # 请使用强密码
DB_NAME=chat-bot
# 钉钉机器人凭证
DINGTALK_ADMIN_ID=your_dingtalk_admin_id
DINGTALK_CLIENT_ID=your_dingtalk_client_id
DINGTALK_CLIENT_SECRET=your_dingtalk_client_secret
# 豆包Seed API 密钥
SEED_API_KEY=your_seed_api_key
# 百度搜索API 密钥
BAIDU_API_KEY=your_baidu_api_key
⚠️ 注意:
.env文件包含敏感信息,切勿提交到代码仓库!务必在.gitignore中添加.env。
5.3 核心代码深度解析
5.3.1 应用入口 (src/index.ts)
import express from 'express'
import { initDingtalk } from '@/services/dingtalk'
import openRoutes from '@/routes/open'
const app = express()
const PORT = Number(process.env.PORT) || 1801
// 中间件配置
app.use(express.json()) // 解析JSON请求体
app.use(express.urlencoded({ extended: true })) // 解析URL编码请求体
// 注册路由
app.use('/api/open', openRoutes)
const main = (): void => {
app.listen(PORT, () => {
console.log(`✅ 服务已启动: http://localhost:${PORT}`)
// 初始化钉钉机器人(建立Stream连接)
initDingtalk()
console.log('🤖 钉钉机器人正在连接...')
})
}
main()
5.3.2 钉钉消息监听 (src/services/dingtalk/index.ts)
这是整个项目的核心,负责接收和处理钉钉消息:
import { DWClient, EventAck, TOPIC_ROBOT } from 'dingtalk-stream'
import { post } from '@/api/request'
import { chatAgent } from '@/services/doubao/agents/chat'
import createRecordStore from '@/stores/record'
import { addChatRecord } from '@/db/record'
import { delay } from '@/utils/delay'
const clientId = process.env.DINGTALK_CLIENT_ID
const clientSecret = process.env.DINGTALK_CLIENT_SECRET
const adminId = process.env.DINGTALK_ADMIN_ID
// 检查必要配置
if (!clientId || !clientSecret) {
throw new Error('钉钉配置不完整,请检查环境变量')
}
// 初始化缓存和客户端
const recordStore = createRecordStore()
const client = new DWClient({ clientId, clientSecret })
// 注册消息监听器
client.registerCallbackListener(TOPIC_ROBOT, async event => {
try {
const data = JSON.parse(event.data as string)
// 解析消息结构
const msg = {
msgtype: data.msgtype,
content: data.text?.content || '',
userId: data.senderStaffId,
name: data.senderNick,
groupId: data.conversationType === '2' ? data.conversationId : '',
groupName: data.conversationType === '2' ? data.conversationTitle : '',
isGroup: data.conversationType === '2',
sessionWebhook: data.sessionWebhook
}
// 立即确认接收,避免钉钉重推(重要!)
client.socketCallBackResponse(event.headers.messageId, {
status: EventAck.SUCCESS
})
console.log(`📨 收到消息: [${msg.userId}] ${msg.content}`)
// === 权限控制层 ===
// 1. 私聊只回复管理员
if (!msg.isGroup && msg.userId !== adminId) {
await post(msg.sessionWebhook, {
msgtype: 'text',
text: { content: '抱歉,我只回复管理员的私信哦~' }
})
return
}
// 2. 过滤非文本消息
if (msg.msgtype !== 'text') {
await post(msg.sessionWebhook, {
msgtype: 'text',
text: { content: '我暂时只能处理文字消息呢 😊' }
})
return
}
// === 记录用户消息 ===
await addChatRecord({
userId: msg.userId,
userName: msg.name,
groupId: msg.groupId,
groupName: msg.groupName,
content: msg.content,
type: 'user'
})
// === AI处理层 ===
// 获取历史记录(用于上下文)
let records = []
if (msg.isGroup) {
records = recordStore.getByGroupId(msg.groupId)
} else {
records = recordStore.getByUserId(msg.userId)
}
// 调用AI生成回复
const responses = await chatAgent(msg.content, records)
// === 消息发送层 ===
// 分段发送,避免单条消息过长
for (const item of responses) {
await post(data.sessionWebhook, {
msgtype: 'text',
text: { content: item || '暂无回复' }
})
// 记录AI回复
await addChatRecord({
userId: msg.userId,
userName: msg.name,
groupId: msg.groupId,
groupName: msg.groupName,
content: item || '暂无回复',
type: 'ai'
})
// 延迟800ms,避免发送过快被限流
await delay(800)
}
} catch (error) {
console.error('❌ 处理消息失败:', error)
// 尝试发送错误提示
try {
await post(data.sessionWebhook, {
msgtype: 'text',
text: { content: '系统处理出现异常,请稍后重试' }
})
} catch (e) {
console.error('发送错误提示失败:', e)
}
}
})
export function initDingtalk() {
client.start()
console.log('✅ 钉钉机器人已启动,等待消息...')
}
关键设计:
- 立即确认:收到消息后立即响应,防止钉钉重复推送
- 分层处理:权限控制 → 记录 → AI处理 → 发送,职责清晰
- 异常处理:完善的try-catch,确保系统稳定性
- 限流保护:添加延迟避免触发钉钉限流
5.3.3 AI对话核心逻辑 (src/services/doubao/index.ts)
这是与豆包大模型交互的核心,实现了结构化输出和工具调用:
import { post } from '@/api/request'
import type { Message, Tool } from '@/types/common'
// 响应类型定义
interface OutputItem {
type: string
content?: Array<{ type: string; text: string }>
name?: string
arguments?: string
call_id?: string
output?: string
}
interface ResponsesResponse {
id: string
output: OutputItem[]
}
// 结构化输出格式(JSON Schema)
interface TextFormat {
type: 'json_schema'
name: string
strict: boolean
schema: {
type: 'object'
properties: {
messages: {
type: 'array'
items: { type: 'string' }
description: string
}
}
required: string[]
}
}
const SEED_URL = 'https://ark.cn-beijing.volces.com/api/v3/responses'
const getHeaders = (): Record<string, string> => {
const apiKey = process.env.SEED_API_KEY
if (!apiKey) throw new Error('SEED_API_KEY 未配置')
return {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
}
// 获取结构化输出格式配置
const getStructuredOutputFormat = (): TextFormat => ({
type: 'json_schema',
name: 'chat_response',
strict: true,
schema: {
type: 'object',
properties: {
messages: {
type: 'array',
items: { type: 'string' },
description: '聊天机器人的回复消息数组'
}
},
required: ['messages']
}
})
// 从结构化输出中提取messages数组
const extractMessages = (output: OutputItem[]): string[] => {
const message = output.find(o => o.type === 'message')
const jsonText = message?.content?.find(c => c.type === 'output_text')?.text ?? '{}'
try {
const parsed = JSON.parse(jsonText)
return parsed.messages || []
} catch (error) {
console.error('❌ 解析结构化输出失败:', jsonText)
return []
}
}
// 执行工具调用
const executeTools = async (
toolCalls: OutputItem[],
toolHandlerMap: Map<string, (args: string) => Promise<string>>
): Promise<OutputItem[]> => {
const outputs = await Promise.all(
toolCalls.map(async call => {
const handler = toolHandlerMap.get(call.name ?? '')
if (!handler) {
console.warn(`未找到工具处理器: ${call.name}`)
return {
type: 'function_call_output' as const,
call_id: call.call_id,
output: `工具 ${call.name} 不存在`
}
}
try {
const output = await handler(call.arguments ?? '')
return {
type: 'function_call_output' as const,
call_id: call.call_id,
output
}
} catch (error) {
console.error(`工具 ${call.name} 执行失败:`, error)
return {
type: 'function_call_output' as const,
call_id: call.call_id,
output: `工具执行失败: ${error.message}`
}
}
})
)
return outputs
}
// 主对话函数
export const chat = async (params: { messages: Message[]; tools?: Tool[] }): Promise<string[]> => {
const { messages, tools = [] } = params
const startTime = Date.now()
// 构建工具处理器映射
const toolHandlerMap = new Map<string, (args: string) => Promise<string>>()
for (const tool of tools) {
if (tool.handler) {
toolHandlerMap.set(tool.name, tool.handler)
}
}
// 构建请求体
const requestBody: Record<string, unknown> = {
model: 'doubao-seed-1-6-251015',
input: messages,
text: {
format: getStructuredOutputFormat()
}
}
// 如果有工具定义,添加到请求体
if (tools.length > 0) {
requestBody.tools = tools.map(({ handler, ...tool }) => tool)
}
console.log(`🤖 调用AI,消息数: ${messages.length}, 工具数: ${tools.length}`)
// 第一次调用AI
let response = await post<ResponsesResponse>(SEED_URL, requestBody, {
headers: getHeaders()
})
// 检查是否需要调用工具
const toolCalls = response.output.filter(o => o.type === 'function_call')
if (toolCalls.length > 0) {
console.log(`🔧 需要执行 ${toolCalls.length} 个工具调用`)
// 执行工具调用
const toolOutputs = await executeTools(toolCalls, toolHandlerMap)
// 第二次调用AI,传入工具执行结果
response = await post<ResponsesResponse>(
SEED_URL,
{
model: 'doubao-seed-1-6-251015',
input: messages,
previous_response_id: response.id,
tool_outputs: toolOutputs,
text: {
format: getStructuredOutputFormat()
}
},
{ headers: getHeaders() }
)
console.log(`✅ 工具调用完成,继续生成回复`)
}
const messages_result = extractMessages(response.output)
console.log(
`✅ AI调用完成,耗时 ${Date.now() - startTime}ms,生成 ${messages_result.length} 条回复`
)
return messages_result
}
技术要点:
- 结构化输出:通过JSON Schema强制AI返回规范格式,避免解析错误
- 工具调用循环:支持多轮工具调用(工具可以调用工具)
- 错误处理:完善的异常捕获,单个工具失败不影响整体流程
- 性能监控:记录调用耗时,便于优化
5.3.4 闲聊Agent (src/services/doubao/agents/chat.ts)
这个文件将历史记录、提示词和工具整合起来,形成完整的对话Agent:
import { chat } from '@/services/doubao'
import { CHAT_AGENT_PROMPT } from '@/services/doubao/prompts/chat'
import { get_baidu_search } from '@/services/doubao/tools'
import type { Message, Tool } from '@/types/common'
import type { ChatRecord } from '@/db/record'
export const chatAgent = async (content: string, records: ChatRecord[]): Promise<string[]> => {
// 构建历史记录文本(用于上下文)
let recordsContent = ''
if (records && records.length > 0) {
for (const record of records) {
const speaker = record.type === 'user' ? record.userName || '用户' : 'AI'
recordsContent += `${speaker}:${record.content}\n`
}
} else {
recordsContent = '暂无历史对话记录'
}
// 构建消息列表
const messages: Message[] = [
{ role: 'system', content: CHAT_AGENT_PROMPT },
{
role: 'user',
content: `【用户提问】\n${content}\n\n【历史聊天记录】\n${recordsContent}`
}
]
// 定义工具:百度搜索
const tools: Tool[] = [
{
type: 'function',
name: 'get_baidu_search',
description:
'根据关键词进行百度搜索,获取最新的相关信息。当用户询问时事热点、某个话题的最新动态、需要实时信息的话题时使用',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '搜索关键词,建议提取核心关键词,不要太长'
}
},
required: ['query']
},
handler: async (args: string) => {
try {
const { query } = JSON.parse(args)
console.log(`🔍 执行百度搜索: ${query}`)
const results = await get_baidu_search(query)
if (results.length === 0) {
return '未搜索到相关结果'
}
// 格式化搜索结果
let text = '搜索结果:\n'
results.forEach((item, index) => {
text += `${index + 1}. ${item.content} (${item.time} - ${item.source})\n`
})
return text
} catch (error) {
console.error('搜索执行失败:', error)
return '搜索服务暂时不可用'
}
}
}
]
// 调用AI核心
const result = await chat({ messages, tools })
return result
}
5.3.5 角色提示词 (src/services/doubao/prompts/chat.ts)
提示词的质量直接影响AI的表现,这里是一个精心设计的prompt:
export const CHAT_AGENT_PROMPT = `你是初辰科技研发的AI助手,名字叫小红帽,与用户进行轻松自然的日常对话。
【核心目标】
以友好、幽默、得体的方式回应用户的闲聊,让用户感到舒适和愉快。
【角色设定】
- 你是初辰科技开发的智能助手,名字叫小红帽
- 当用户问你是谁时,要回答:"我是初辰科技研发的小红帽,很高兴为你服务!"
- 你知识渊博但不傲慢,乐于助人不卑微
【历史聊天记录】
每次对话时,你会收到【历史聊天记录】,包含最近24小时内用户与你的对话。你需要:
- 参考上下文,保持对话的连贯性
- 记住用户之前提到的事情,适时跟进
- 如果用户追问之前的话题,基于历史记录继续回答
【对话风格】
✅ 亲切自然,像朋友一样聊天
✅ 语言简洁,避免长篇大论(单条回复控制在100字以内)
✅ 适当幽默,但不要过度开玩笑
✅ 保持礼貌和专业边界
❌ 不要说教、指责用户
❌ 不要问太多问题让用户反感
【回应范围】
- 问候与寒暄(你好/在吗/早上好等)
- 日常话题(天气、美食、生活、工作等)
- 感谢与告别(谢谢/再见等)
- 简单的鼓励与安慰
- 轻松幽默的调侃
- 时事热点、新闻资讯(使用工具获取最新信息)
【工具使用原则】
当用户询问以下内容时,必须调用 get_baidu_search 工具获取最新信息:
- 时事热点、热门话题(如"最近有什么热点"、"XX事件怎么回事")
- 需要实时信息的话题(如"今天天气"、"最新电影"、"股票行情")
- 你不确定或知识截止后发生的事件
注意:对于你已知的信息(如常识性问题、历史事实、科学原理等),直接回答即可,无需调用工具。
【输出格式 - 重要】
你必须严格按照以下 JSON 格式输出,不要输出任何其他内容:
{
"messages": ["回复内容1", "回复内容2"]
}
输出要求:
- messages 是一个字符串数组,每个元素是一条回复消息
- 如果只有一条回复,数组长度为1;如果有多条,可以有多条
- 每条消息控制在1-100字
- 语气友好自然
- 使用工具获取的信息要提炼后回复,不要直接罗列搜索结果
- 除了 JSON 外,不要输出任何其他文字、标记或格式`
提示词设计技巧:
- 明确角色:给AI一个清晰的定位
- 示例驱动:用✅❌指明什么能做、什么不能做
- 格式强制:用JSON Schema约束输出,便于程序解析
- 场景细化:详细说明什么时候该用工具
5.3.6 百度搜索API (src/api/baiduApi.ts)
import { post } from './request'
import type { NewsItem } from '@/types/baiduApi'
export interface BaiduNewsResponse {
references: {
content: string
date: string
website: string
}[]
}
/**
* 百度搜索API调用
* @param query 搜索关键词
* @param range 时间范围: week-一周内, month-一月内, year-一年内
*/
export const getBaiduSearch = async (
query: string,
range?: 'week' | 'month' | 'year'
): Promise<NewsItem[]> => {
const url = `https://qianfan.baidubce.com/v2/ai_search/web_search`
try {
const res = await post<BaiduNewsResponse>(
url,
{
messages: [
{
role: 'user',
content: query
}
],
resource_type_filter: [{ type: 'web', top_k: 10 }],
...(range && { search_recency_filter: range })
},
{
headers: {
'X-Appbuilder-Authorization': `Bearer ${process.env.BAIDU_API_KEY}`
}
}
)
if (!res?.references) {
return []
}
return res.references.map(item => ({
content: item.content?.slice(0, 360) || '',
time: item.date || '未知时间',
source: item.website || '百度搜索'
}))
} catch (error) {
console.error('百度搜索API调用失败:', error)
return []
}
}
5.3.7 聊天记录缓存 (src/stores/record.ts)
使用内存缓存提高查询效率:
import type { ChatRecord } from '@/db/record'
// 内存缓存,key为记录ID
const allChatRecords = new Map<number, ChatRecord>()
const createRecordStore = () => {
return {
/**
* 初始化缓存(服务启动时从数据库加载)
*/
init(records: ChatRecord[]) {
allChatRecords.clear()
for (const record of records) {
allChatRecords.set(record.id, record)
}
console.log(`✅ 聊天记录缓存初始化完成,共加载 ${allChatRecords.size} 条记录`)
},
/**
* 获取用户最近24小时的聊天记录
* @param userId 用户ID
*/
getByUserId(userId: string): ChatRecord[] {
const now = Date.now()
const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000
return Array.from(allChatRecords.values())
.filter(
record => record.userId === userId && record.createdAt.getTime() > twentyFourHoursAgo
)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) // 按时间正序
.map(record => ({
...record,
// 长内容截断,避免超出AI上下文
content:
record.content.length > 100 ? record.content.slice(0, 100) + '...' : record.content
}))
},
/**
* 获取群组最近24小时的聊天记录
* @param groupId 群组ID
*/
getByGroupId(groupId: string): ChatRecord[] {
const now = Date.now()
const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000
return Array.from(allChatRecords.values())
.filter(
record => record.groupId === groupId && record.createdAt.getTime() > twentyFourHoursAgo
)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map(record => ({
...record,
content:
record.content.length > 100 ? record.content.slice(0, 100) + '...' : record.content
}))
},
/**
* 添加单条记录到缓存
*/
add(record: ChatRecord): void {
allChatRecords.set(record.id, record)
// 可选:如果缓存太大,可以清理旧数据
if (allChatRecords.size > 10000) {
// 简单清理:删除最早的1000条
const keys = Array.from(allChatRecords.keys()).sort()
for (let i = 0; i < Math.min(1000, keys.length); i++) {
allChatRecords.delete(keys[i])
}
}
}
}
}
export default createRecordStore
5.3.8 数据库操作 (src/db/record.ts)
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise'
import pool from '@/db/mysql'
import createRecordStore from '@/stores/record'
const recordStore = createRecordStore()
export type MessageType = 'user' | 'ai'
export interface ChatRecord {
id: number
userId: string | null
groupId: string | null
userName: string | null
groupName: string | null
content: string
type: MessageType
createdAt: Date
}
// 创建 record 表
export async function createRecordTable(): Promise<void> {
const sql = `
CREATE TABLE IF NOT EXISTS record (
id INT PRIMARY KEY AUTO_INCREMENT,
userId VARCHAR(100) COMMENT '用户ID',
groupId VARCHAR(100) COMMENT '群组ID',
userName VARCHAR(100) COMMENT '用户名',
groupName VARCHAR(200) COMMENT '群组名',
content TEXT NOT NULL COMMENT '聊天内容',
type VARCHAR(10) NOT NULL COMMENT '消息类型:user-用户,ai-AI',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_userId (userId),
INDEX idx_groupId (groupId),
INDEX idx_type (type),
INDEX idx_createdAt (createdAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天记录表'
`
await pool.execute(sql)
console.log('✅ 数据表初始化完成')
}
// 添加聊天记录
export async function addChatRecord(params: ChatRecordBase): Promise<number> {
const { userId, groupId, userName, groupName, content, type } = params
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO record (userId, groupId, userName, groupName, content, type)
VALUES (?, ?, ?, ?, ?, ?)`,
[userId ?? null, groupId ?? null, userName ?? null, groupName ?? null, content, type]
)
// 同时缓存到内存
const record: ChatRecord = {
id: result.insertId,
userId: userId ?? null,
groupId: groupId ?? null,
userName: userName ?? null,
groupName: groupName ?? null,
content,
type,
createdAt: new Date()
}
recordStore.add(record)
return result.insertId
}
// 加载最近24小时的记录到缓存(服务启动时调用)
export async function loadRecentRecordsToCache(): Promise<void> {
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT * FROM record
WHERE createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY createdAt ASC`
)
const records = rows as ChatRecord[]
recordStore.init(records)
console.log(`✅ 已加载 ${records.length} 条最近24小时记录到缓存`)
}
数据库设计要点:
- 索引优化:对常用查询字段加索引
- 字段注释:每个字段都有清晰注释
- 时间索引:按创建时间过滤,提高查询效率
- 双写策略:同时写入数据库和缓存,保证数据一致性
三、运行与测试
6.1 配置package.json脚本
在 package.json 中添加以下脚本:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "rimraf dist && tsc && tsc-alias",
"start": "node dist/index.js",
"db:init": "tsx scripts/init-db.ts"
}
}
6.2 初始化数据库
创建数据库初始化脚本 scripts/init-db.ts:
import { createRecordTable } from '@/db/record'
import dotenv from 'dotenv'
dotenv.config()
async function init() {
console.log('开始初始化数据库...')
await createRecordTable()
console.log('数据库初始化完成!')
process.exit(0)
}
init()
运行初始化:
npm run db:init
6.3 开发模式运行
npm run dev
看到以下日志说明启动成功:
✅ 服务已启动: http://localhost:1801
✅ 钉钉机器人已启动,等待消息...
6.4 生产构建与部署
# 构建
npm run build
# 启动生产服务
npm start
6.5 功能测试
测试场景1:基础对话
- 在钉钉群聊中添加机器人
- @机器人发送:"你好,小红帽"
- 期待回复:"你好呀,我是小红帽,很高兴认识你!"
测试场景2:上下文记忆
- 发送:"我叫张三"
- 发送:"我叫什么名字?"
- 期待回复:"你刚才说过,你叫张三呀!"
测试场景3:百度搜索
- 发送:"最近有什么热点新闻?"
- 观察控制台日志,应该看到工具调用
- 回复应该包含搜索到的实时信息
测试场景4:权限控制
- 使用非管理员账号私聊机器人
- 期待回复:"抱歉,我只回复管理员的私信哦~"
四、进阶功能
7.1 开放接口(供外部系统调用)
项目还提供了HTTP接口,方便其他系统调用发送钉钉消息:
接口地址:POST /api/open/send
请求头:
| 参数 | 必填 | 说明 |
|---|---|---|
| x-system-id | 是 | 系统标识(用于日志追踪) |
| x-system-token | 是 | 系统令牌(身份验证) |
| x-open-key | 是 | 开放接口密钥(与.env中的OPEN_KEY一致) |
请求体:
{
"msgtype": "text",
"content": "这是一条来自外部系统的消息"
}
实现代码 (src/routes/open.ts):
import express from 'express'
import { authMiddleware } from './middleware/steamAuth'
import { sendDingtalkMessage } from '@/services/dingtalk/send'
const router = express.Router()
// 所有开放接口都需要认证
router.use(authMiddleware)
router.post('/send', async (req, res) => {
try {
const { msgtype, content } = req.body
if (!msgtype || !content) {
return res.status(400).json({ error: '缺少必要参数' })
}
// 调用钉钉发送服务
const result = await sendDingtalkMessage({
msgtype,
content
})
res.json({ success: true, data: result })
} catch (error) {
console.error('发送消息失败:', error)
res.status(500).json({ error: '发送失败' })
}
})
export default router
7.2 定时任务
在 src/utils/schedule.ts 中添加定时任务:
import cron from 'node-cron'
import { sendDingtalkMessage } from '@/services/dingtalk/send'
// 定时任务配置
export const scheduleTasks = () => {
// 每天早上9点发送早安问候
cron.schedule('0 9 * * *', async () => {
console.log('执行定时任务:发送早安问候')
try {
await sendDingtalkMessage({
msgtype: 'text',
content: '🌞 早安!美好的一天开始啦!记得吃早餐哦~'
})
} catch (error) {
console.error('早安问候发送失败:', error)
}
})
// 每周五下午5点发送周末提醒
cron.schedule('0 17 * * 5', async () => {
console.log('执行定时任务:发送周末提醒')
try {
await sendDingtalkMessage({
msgtype: 'text',
content: '🎉 周末到啦!祝大家周末愉快,好好放松一下!'
})
} catch (error) {
console.error('周末提醒发送失败:', error)
}
})
}
在应用入口中启动定时任务:
// src/index.ts
import { scheduleTasks } from '@/utils/schedule'
// 在main函数中调用
scheduleTasks()
五、常见问题排查
8.1 钉钉机器人收不到消息
可能原因:
- Client ID/Secret配置错误
- 机器人未发布或发布未生效
- 群聊中未正确添加机器人
解决方案:
- 检查
.env中的配置是否正确 - 在钉钉开放平台重新发布机器人
- 确认机器人已在群聊中(被@才会触发)
8.2 AI不调用工具
可能原因:
- 提示词中工具描述不够清晰
- 用户提问不属于工具使用场景
- API Key权限不足
解决方案:
- 优化提示词,明确工具使用场景
- 测试时使用明确的搜索类问题,如"搜索一下最近的科技新闻"
- 检查百度API额度是否充足
8.3 数据库连接失败
可能原因:
- MySQL服务未启动
- 连接信息配置错误
- 数据库用户权限不足
解决方案:
# 检查MySQL状态
systemctl status mysql
# 测试连接
mysql -u chat-bot -p -h localhost
# 授权命令
GRANT ALL PRIVILEGES ON chat-bot.* TO 'chat-bot'@'%';
FLUSH PRIVILEGES;
六、总结与展望
通过本文的学习,你已经从零开始搭建了一个完整的AI聊天机器人,掌握了以下核心技能:
9.1 关键技术点回顾
- 钉钉机器人开发:Stream模式的配置与消息处理
- 大模型API调用:火山方舟豆包模型的结构化输出和工具调用
- 工具增强AI:集成百度搜索,让AI具备获取实时信息的能力
- 上下文管理:MySQL持久化 + 内存缓存的双层架构
- 权限控制:基于用户ID的私聊过滤和群聊白名单
9.2 后续扩展方向
- 🔮 多Agent架构:引入意图识别,不同场景使用不同Agent
- 🔮 多模态支持:扩展图片识别、语音对话能力
- 🔮 记忆永久化:对一个月之前的聊天记录进行整理总结形成永久记忆
- 🔮 Web管理后台:可视化配置机器人、查看对话日志、数据统计
9.3 写在最后
AI技术发展日新月异,但万变不离其宗。通过这个项目,你不仅学会了搭建一个实用的AI聊天机器人,更重要的是掌握了:
- 如何封装和调用大模型API
- 如何让AI具备工具使用能力
- 如何设计健壮的消息处理系统
- 如何组织可维护的TypeScript项目
这些技能可以迁移到任何AI项目的开发中。
记住:最好的学习方式是动手实践。现在就去克隆代码,运行起来,然后根据自己的需求进行改造吧!
参考链接
- 项目源码:github.com/pbstar/chat…
- 钉钉开放平台:open.dingtalk.com/
- 火山方舟文档:www.volcengine.com/docs/82379/…
- 百度千帆搜索API:cloud.baidu.com/doc/qianfan…
- TypeScript官方文档:www.typescriptlang.org/
如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连!有任何问题可以在评论区留言,我会及时回复。