都2026了,你说你还没写过AI项目?手把手教你从0到1搭建AI聊天机器人

37 阅读16分钟

本文将带你从零开始,搭建一个基于豆包大模型的智能聊天机器人,并接入钉钉实现智能对话、历史记录管理、百度搜索等完整功能。


前言

2026年,AI已经像空气一样渗透到我们工作和生活的每个角落。从ChatGPT到OpenClaw,大模型技术正在颠覆我们的生活方式。作为一名开发者,如果还没有亲手写过AI项目,那真的out了!

今天,我将带你从零开始,手把手搭建一个基于豆包Seed大模型的智能聊天机器人,并接入钉钉实现以下完整功能:

  • 智能对话:基于豆包Seed大模型的自然语言理解与生成
  • 钉钉集成:支持钉钉机器人私聊和群聊场景,开箱即用
  • 历史记忆:自动保存24小时内的聊天记录,实现上下文连贯对话
  • 工具调用:集成百度搜索,AI能自主获取实时信息
  • 权限管理:私聊仅回复管理员,群聊支持白名单控制
  • 数据持久化:MySQL存储聊天记录,方便后续分析和调优

项目源码github.com/pbstar/chat…

技术栈概览

模块技术选型说明
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:基础对话

  1. 在钉钉群聊中添加机器人
  2. @机器人发送:"你好,小红帽"
  3. 期待回复:"你好呀,我是小红帽,很高兴认识你!"

测试场景2:上下文记忆

  1. 发送:"我叫张三"
  2. 发送:"我叫什么名字?"
  3. 期待回复:"你刚才说过,你叫张三呀!"

测试场景3:百度搜索

  1. 发送:"最近有什么热点新闻?"
  2. 观察控制台日志,应该看到工具调用
  3. 回复应该包含搜索到的实时信息

测试场景4:权限控制

  1. 使用非管理员账号私聊机器人
  2. 期待回复:"抱歉,我只回复管理员的私信哦~"

四、进阶功能

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 钉钉机器人收不到消息

可能原因

  1. Client ID/Secret配置错误
  2. 机器人未发布或发布未生效
  3. 群聊中未正确添加机器人

解决方案

  • 检查 .env 中的配置是否正确
  • 在钉钉开放平台重新发布机器人
  • 确认机器人已在群聊中(被@才会触发)

8.2 AI不调用工具

可能原因

  1. 提示词中工具描述不够清晰
  2. 用户提问不属于工具使用场景
  3. API Key权限不足

解决方案

  • 优化提示词,明确工具使用场景
  • 测试时使用明确的搜索类问题,如"搜索一下最近的科技新闻"
  • 检查百度API额度是否充足

8.3 数据库连接失败

可能原因

  1. MySQL服务未启动
  2. 连接信息配置错误
  3. 数据库用户权限不足

解决方案

# 检查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 关键技术点回顾

  1. 钉钉机器人开发:Stream模式的配置与消息处理
  2. 大模型API调用:火山方舟豆包模型的结构化输出和工具调用
  3. 工具增强AI:集成百度搜索,让AI具备获取实时信息的能力
  4. 上下文管理:MySQL持久化 + 内存缓存的双层架构
  5. 权限控制:基于用户ID的私聊过滤和群聊白名单

9.2 后续扩展方向

  • 🔮 多Agent架构:引入意图识别,不同场景使用不同Agent
  • 🔮 多模态支持:扩展图片识别、语音对话能力
  • 🔮 记忆永久化:对一个月之前的聊天记录进行整理总结形成永久记忆
  • 🔮 Web管理后台:可视化配置机器人、查看对话日志、数据统计

9.3 写在最后

AI技术发展日新月异,但万变不离其宗。通过这个项目,你不仅学会了搭建一个实用的AI聊天机器人,更重要的是掌握了:

  • 如何封装和调用大模型API
  • 如何让AI具备工具使用能力
  • 如何设计健壮的消息处理系统
  • 如何组织可维护的TypeScript项目

这些技能可以迁移到任何AI项目的开发中。

记住:最好的学习方式是动手实践。现在就去克隆代码,运行起来,然后根据自己的需求进行改造吧!


参考链接


如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连!有任何问题可以在评论区留言,我会及时回复。