ai agent --- 实现 openclaw 定时间效果

148 阅读16分钟

一. Openclaw 的定时需求

给大模型说:你帮我定个闹钟,定到9:00,我要起床去上班。

第二天,你手机的闹钟就响了。

分析openclaw的代码,我们发现它走的是以下流程:

image.png

最主要的就是这个tool:

在大模型里面有个定时器,帮你盯着时间。还有一个网络信息搜索tool,帮你检索网上相关资讯。还有一个发送邮件的tool。以上三个通过字面意思就能理解。但是数据库呢?

为什么要用到数据库?

首先,发送邮件的邮件地址需要维护不?

你在哪里,什么时间段的信息,具体是哪个国家地区的信息,哪种信息。

其次是几点发送资讯信息,还有昨天给都发了哪些?哪些是你喜欢的,哪些是你不喜欢的,今天又发送了哪些?是不是都该记住,为明天更准确地为主人服务做准备?

二.创建nest项目,开发人员查询的tool

执行命令创建项目,并按照相关的包

nest new cron-job-tool
cd cron-job-tool
pnpm install @langchain/core @langchain/openai zod @nestjs/config

我在# ai agent ---nest+langchain实现SSE推送信息里面已经说了nest的env文件,下面就不再赘述,默认系统已经引入了 .env 文件。

1.用invoke搜索数据库里面的用户信息

invoke是一次性调用大模型获取数据。

现在直接在ai.service.ts文件里面写一个runChain方法,他的作用就是根据要求找出database对应用户的信息。

import { Inject, Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
  AIMessage,
  BaseMessage,
  HumanMessage,
  SystemMessage,
  ToolMessage,
} from '@langchain/core/messages';
import { z } from 'zod';
import { Runnable } from '@langchain/core/runnables';

const database: any = {
  users: {
    '001': {
      id: '001',
      name: '张三',
      email: 'zhangsan@example.com',
      role: 'admin',
    },
    '002': { id: '002', name: '李四', email: 'lisi@example.com', role: 'user' },
    '003': {
      id: '003',
      name: '王五',
      email: 'wangwu@example.com',
      role: 'user',
    },
  },
};

const queryUserArgsSchema = z.object({
  userId: z.string().describe('用户 ID,例如:001, 002, 003'),
});

type QueryUserArgs = {
  userId: string;
};

const queryUserTool = tool(
  async ({ userId }: QueryUserArgs) => {
    const user = database.users[userId];

    if (!user) {
      return `用户 ID ${userId} 不存在。可用的 ID: 001, 002, 003`;
    }

    return `用户信息:\n- ID: ${user.id}\n- 姓名: ${user.name}\n- 邮箱: ${user.email}\n- 角色: ${user.role}`;
  },
  {
    name: 'query_user',
    description:
      '查询数据库中的用户信息。输入用户 ID,返回该用户的详细信息(姓名、邮箱、角色)。',
    schema: queryUserArgsSchema,
  },
);

@Injectable()
export class AiService {
  private readonly modelWithTools: Runnable<BaseMessage[], AIMessage>;

  // 获取model
  constructor(@Inject('CHAT_MODEL') model: ChatOpenAI) {
    this.modelWithTools = model.bindTools([queryUserTool]);
  }

  async runChain(query: string): Promise<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个智能助手,可以在需要时调用工具(如 query_user)来查询用户信息,再用结果回答用户的问题。',
      ),
      new HumanMessage(query),
    ];

    while (true) {
      const aiMessage = await this.modelWithTools.invoke(messages);
      messages.push(aiMessage);

      const toolCalls = aiMessage.tool_calls ?? [];

      // 没有要调用的工具,直接把回答返回给调用方
      if (!toolCalls.length) {
        return aiMessage.content as string;
      }

      // 依次执行本轮需要调用的所有工具
      for (const toolCall of toolCalls) {
        const toolCallId = toolCall.id || '';
        const toolName = toolCall.name;

        if (toolName === 'query_user') {
          const args = queryUserArgsSchema.parse(toolCall.args);
          const result = await queryUserTool.invoke(args);

          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: result,
            }),
          );
        }
      }
    }
  }
}

在controller文件里面定义接口

import { Controller, Get, Query, Sse } from '@nestjs/common';
import { AiService } from './ai.service.js';
import { Observable, from, map } from 'rxjs';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  @Get('chat')
  async chat(@Query('query') query: string) {
    const answer = await this.aiService.runChain(query);
    return answer;
  }
}

启动项目测试

npm run start:dev

image.png

2.用stream搜索数据库里面的用户信息

stream是流式调用,一边生产,一边返回数据。

在ai.service.ts文件里面添加runChainStream的方法如下:

  async *runChainStream(query: string): AsyncGenerator<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个智能助手,可以在需要时调用工具(如 query_user)来查询用户信息,再用结果回答用户问题。',
      ),
      new HumanMessage(query),
    ];

    while (true) {
      const stream = await this.modelWithTools.stream(messages);
      let fullAIMessage: any = null;

      for await (const chunk of stream) {
        fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;

        const hasToolCallChunk =
          !!fullAIMessage.tool_call_chunks &&
          fullAIMessage.tool_call_chunks.length > 0;

        if (!hasToolCallChunk && chunk.content) {
          yield chunk.content as string;
        }
      }
      if (!fullAIMessage) {
        return;
      }
      messages.push(fullAIMessage);
      const toolCalls = fullAIMessage.tool_calls ?? [];
      if (!toolCalls.length) {
        return;
      }

      for (const toolCall of toolCalls) {
        const toolCallId = toolCall.id || '';
        const toolName = toolCall.name;

        if (toolName === 'query_user') {
          const args = queryUserArgsSchema.parse(toolCall.args);
          const result = await queryUserTool.invoke(args);

          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: result,
            }),
          );
        }
      }
    }
  }

然后在controller里面添加接口

  @Sse('chat/stream')
  chatStream(@Query('query') query: string): Observable<{ data: string }> {
    const stream = this.aiService.runChainStream(query);

    return from(stream).pipe(map((chunk) => ({ data: chunk })));
  }
}

具体如下:

image.png

启动项目测试

image.png

对比两个方法

image.png

从上面的对比我们可以看到

  1. const aiMessage = await this.modelWithTools.invoke(messages); 一次性可以拿到所有的返回数据,然后找到对应工具去处理,处理以后返回给用户就好。
  2. const stream = await this.modelWithTools.stream(messages); 是生成一个数据就返回一个数据,需要把这些chunk包拼接在一起,才能组成一个完整的答案。用 fullAIMessage = fullAIMessage.concat(chunk) 把流式片段拼成一个完整 AI 消息对象,因为工具调用信息可能分片到达。

3.将人员tool提取到其他文件里面

原来,关于人员的增删改查都是放在ai.service.ts里面的,这样代码显得比较凌乱。 image.png

我们进行优化,就是将 queryUserTool的这部分业务拆分出去。

在建立一个文件user.service.ts--创建 UserService 类,理由有一个数据users,对users进行增删改查。目录如下

image.png

import { Injectable } from '@nestjs/common';

type User = {
  id: string;
  name: string;
  email: string;
  role: string;
};

@Injectable()
export class UserService {
  private readonly users = new Map<string, User>([
    [
      '001',
      { id: '001', name: '张三', email: 'zhangsan@example.com', role: 'admin' },
    ],
    [
      '002',
      { id: '002', name: '李四', email: 'lisi@example.com', role: 'user' },
    ],
    [
      '003',
      { id: '003', name: '王五', email: 'wangwu@example.com', role: 'user' },
    ],
    [
      '004',
      { id: '004', name: '赵六', email: 'zhaoliu@example.com', role: 'user' },
    ],
    [
      '005',
      { id: '005', name: '孙七', email: 'sunqi@example.com', role: 'user' },
    ],
    [
      '006',
      { id: '006', name: '周八', email: 'zhouba@example.com', role: 'user' },
    ],
  ]);

  findAll(): User[] {
    return Array.from(this.users.values());
  }

  findById(id: string): User | undefined {
    return this.users.get(id);
  }

  create(user: User): User {
    this.users.set(user.id, user);
    return user;
  }

  update(id: string, partial: Partial<Omit<User, 'id'>>): User | undefined {
    const existing = this.users.get(id);

    if (!existing) {
      return undefined;
    }

    const updated: User = {
      ...existing,
      ...partial,
      id: existing.id,
    };

    this.users.set(id, updated);

    return updated;
  }

  remove(id: string): boolean {
    return this.users.delete(id);
  }
}

然后在ai.module.ts 里面注册

    {
      provide: 'QUERY_USER_TOOL',
      useFactory: (userService: UserService) => {
        const queryUserArgSchema = z.object({
          userId: z.string().describe('用户ID, 例如001,002,003'),
        });

        return tool(
          async ({ userId }: { userId: string }) => {
            const user = await userService.findById(userId);
            if (!user) {
              return '未找到用户: ${userId}';
            }

            return `用户信息:\n- ID: ${user.id}\n- 姓名: ${user.name}\n- 邮箱: ${user.email}\n- 角色: ${user.role}`;
          },
          {
            name: 'query_user',
            description:
              '查询数据库中的用户信息。输入用户 ID,返回该用户的详细信息(姓名、邮箱、角色)。',
            schema: queryUserArgSchema,
          },
        );
      },
      inject: [ConfigService],
    },

在ai.service.ts里面修改原来的代码,先在构造函数里面注入QUERY_USER_TOOL

image.png

image.png

image.png

修改完之后跑一下

image.png

三. 开发发送邮件的tool

1.获取邮件授权码

首先获取你邮箱的授权码,步骤如下:打开有设置,找到安全上设置,点击生成授权码。

image.png

image.png

2.安装插件

在项目里面安装 @nestjs-modules/mailer用来发送邮件。

pnpm install nodemailer @nestjs-modules/mailer

3.在app.module.ts里面注册邮件信息

在.env里面添加常量

image.png 在 app.module 里面注册信息 image.png

4.在ai.module.ts里面写一个发送邮件的tool

  {
      provide: 'SEND_MAIL_TOOL',
      inject: [MailerService, ConfigService],
      useFactory: (
        mailerService: MailerService,
        configService: ConfigService,
      ) => {
        const sendMailArgSchema = z.object({
          to: z
            .string()
            .email()
            .describe('收件人邮箱地址,例如:545442742@qq.com'),
          subject: z.string().describe('邮件主题'),
          text: z
            .string()
            .describe(
              '邮件纯文本内容。若任务是发送查询结果,必须把 query_user 返回的用户信息完整放入这里。不能为空。',
            ),
          html: z
            .string()
            .optional()
            .describe('邮件HTML内容,可选。不传会根据text生成。'),
        });

        return tool(
          async ({
            to,
            subject,
            text,
            html,
          }: {
            to: string;
            subject: string;
            text: string;
            html?: string;
          }) => {
            const from = configService.get('MAIL_FROM');

            const safeText = (text || '').trim();
            if (!safeText) {
              throw new Error(
                'send_mail 的 text 不能为空,请传入查询到的用户信息',
              );
            }

            const finalHtml =
              html?.trim() ||
              `<div style="font-family:Arial,sans-serif;line-height:1.8;padding:16px;">
             <h2 style="margin:0 0 12px;font-size:18px;color:#333;">${subject}</h2>
             <pre style="white-space:pre-wrap;font-family:Arial,sans-serif;font-size:14px;color:#333;margin:0;">${safeText}</pre>
           </div>`;

            await mailerService.sendMail({
              to,
              subject,
              from,
              text: safeText,
              html: finalHtml,
            });

            return '邮件已发送成功!';
          },
          {
            name: 'send_mail',
            description:
              '发送邮件。to/subject/text 必填。若前面调用了 query_user,必须把用户信息完整放到 text。html 可选。',
            schema: sendMailArgSchema,
          },
        );
      },
    },

5.在ai.service.ts里面添加发邮件的功能

在方法里面要大模型调用发送邮件的tool

image.png

访问:http://localhost:3000/ai/chat/stream?query=查询下002的信息,然后发送邮件到545442742@qq.com里面,邮箱主题是002的信息,内容是查询到的信息

image.png

6.优化代码

将runChain和runChainStream里面,大模型执行tool的代码提取出来--executeToolCalls

import { Inject, Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
  AIMessage,
  AIMessageChunk,
  BaseMessage,
  HumanMessage,
  SystemMessage,
  ToolMessage,
} from '@langchain/core/messages';
import { z } from 'zod';
import { Runnable } from '@langchain/core/runnables';

const queryUserArgsSchema = z.object({
  userId: z.string().describe('用户 ID,例如:001, 002, 003'),
});

type QueryUserArgs = {
  userId: string;
};

@Injectable()
export class AiService {
  private readonly modelWithTools: Runnable<BaseMessage[], AIMessage>;

  constructor(
    @Inject('CHAT_MODEL') model: ChatOpenAI,
    @Inject('QUERY_USER_TOOL') private readonly queryUserTool: any,
    @Inject('SEND_MAIL_TOOL') private readonly sendMailTool: any,
  ) {
    this.modelWithTools = model.bindTools([
      this.queryUserTool,
      this.sendMailTool,
    ]);
  }

  private async executeToolCalls(
    toolCalls: any[],
    messages: BaseMessage[],
  ): Promise<void> {
    for (const toolCall of toolCalls) {
      const toolCallId = toolCall.id || '';
      const toolName = toolCall.name;

      let result: any;

      if (toolName === 'query_user') {
        const args = queryUserArgsSchema.parse(toolCall.args);
        result = await this.queryUserTool.invoke(args);
      } else if (toolName === 'send_mail') {
        result = await this.sendMailTool.invoke(toolCall.args);
      } else {
        result = `未知工具: ${toolName}`;
      }

      messages.push(
        new ToolMessage({
          tool_call_id: toolCallId,
          name: toolName,
          content: result,
        }),
      );
    }
  }

  async runChain(query: string): Promise<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个智能助手,可以在需要时调用工具(如 query_user)来查询用户信息,再用结果回答用户的问题。',
      ),
      new HumanMessage(query),
    ];

    while (true) {
      const aiMessage = await this.modelWithTools.invoke(messages);
      messages.push(aiMessage);

      const toolCalls = aiMessage.tool_calls ?? [];

      // 没有要调用的工具,直接把回答返回给调用方
      if (!toolCalls.length) {
        return aiMessage.content as string;
      }

      // 依次执行本轮需要调用的所有工具
      await this.executeToolCalls(toolCalls, messages);
    }
  }

  async *runChainStream(query: string): AsyncGenerator<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个智能助手。\n' +
          '1. 如果需要查询用户信息,调用 query_user 工具。\n' +
          '2. 如果需要发送邮件,调用 send_mail 工具。\n' +
          '3. 当用户要求"把查询结果发邮件"时,你必须:\n' +
          '   a. 先调用 query_user 获取用户信息\n' +
          '   b. 再调用 send_mail,把查询到的用户信息完整复制到 text 参数中\n' +
          '   c. subject 按用户要求填写,to 按用户要求填写\n' +
          '   d. 不要把 text 留空,也不要省略 text 参数',
      ),
      new HumanMessage(query),
    ];

    while (true) {
      const stream = await this.modelWithTools.stream(messages);
      let fullAIMessage: any = null;

      for await (const chunk of stream) {
        fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;

        const hasToolCallChunk =
          !!fullAIMessage.tool_call_chunks &&
          fullAIMessage.tool_call_chunks.length > 0;

        if (!hasToolCallChunk && chunk.content) {
          yield chunk.content as string;
        }
      }
      if (!fullAIMessage) {
        return;
      }
      messages.push(fullAIMessage);
      const toolCalls = fullAIMessage.tool_calls ?? [];
      if (!toolCalls.length) {
        return;
      }

      await this.executeToolCalls(toolCalls, messages);
    }
  }
}

四.开发网络搜索的tool

利用博查的api:open.bochaai.com/ ,deepceek就是用这个搜索网页的。所以比较靠谱。

1.登录博查AI获取apiKey

先登录,然后进入博查控制台,

image.png

2.购买资源包

进入资源包管理,购买最便宜的那个

image.png

3.将博查的apikey写到.env里面

image.png

4.在aiModule里面添加搜索网页的tool

他里面最主要的代码就是用fetch请求 api.bocha.cn/v1/web-sear… 获取数据。在将获取到的数据格式化返回出去就好了。

    {
      provide: 'WEB_SEARCH_TOOL',
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const webSearchArgSchema = z.object({
          query: z.string().min(1).describe('搜索关键字,例如:公司年报等'),
          count: z
            .number()
            .int()
            .min(1)
            .max(20)
            .optional()
            .describe('搜索结果数量,默认10 条'),
        });

        return tool(
          async ({ query, count }: { query: string; count?: number }) => {
            const apiKey = configService.get('BOCHA_API_KEY');

            if (!apiKey) {
              return 'Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY),请先在服务端配置后再重试。';
            }

            const url = 'https://api.bocha.cn/v1/web-search';
            const body = {
              query,
              freshness: 'noLimit',
              summary: true,
              count: count ?? 10,
            };

            const response = await fetch(url, {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${apiKey}`,
              },
              body: JSON.stringify(body),
            });

            if (!response.ok) {
              const errorText = await response.text();
              return `请求失败,状态码:${response.status}。错误信息:${errorText}`;
            }

            let json: any;
            try {
              json = await response.json();
            } catch (e: any) {
              return `搜索 API 请求失败,原因是:搜索结果解析失败,错误信息:${e.message}`;
            }

            try {
              if (json.code !== 100 || !json.data) {
                return `搜索 API 请求失败,原因是:${json.msg} 位置错误`;
              }

              const webpages = json.data.webpages?.value ?? [];

              if (!webpages.length) {
                return '未找到相关结果。';
              }

              const formatted = webpages
                .map((page: any, idx: number) => {
                  return `
                    引用: ${idx + 1}:\n
                    标题: ${page.name}\n
                    URL: ${page.url}\n
                    摘要: ${page.summary}\n
                    网站名称: ${page.siteName}\n
                    网站图标: ${page.siteIcon}\n
                    发布时间: ${page.dateLastCrawled}\n}
                  `;
                })
                .join('\n\n');

              return formatted;
            } catch (e: any) {
              return `搜索 API 请求失败,原因是:结果解析出错,错误信息:${e.message}`;
            }
          },
          {
            name: 'web_search',
            description:
              '使用 Bocha Web Search API 搜索互联网网页。输入为搜索关键词(可选 count 指定结果数量),返回包含标题、URL、摘要、网站名称、图标和时间等信息的结果列表。',
            schema: webSearchArgSchema,
          },
        );
      },
    },

5.在aiService文件里面使用这个tool

上一接我们已经将调用tool的代码提取出来了,现在直接修改executeToolCalls方法就好了

  private async executeToolCalls(
    toolCalls: any[],
    messages: BaseMessage[],
  ): Promise<void> {
    for (const toolCall of toolCalls) {
      const toolCallId = toolCall.id || '';
      const toolName = toolCall.name;

      let result: any;

      if (toolName === 'query_user') {
        const args = queryUserArgsSchema.parse(toolCall.args);
        result = await this.queryUserTool.invoke(args);
      } else if (toolName === 'send_mail') {
        result = await this.sendMailTool.invoke(toolCall.args);
      } else if (toolName === 'web_search') {
        result = await this.webSearchTool.invoke(toolCall.args);
      } else {
        result = `未知工具: ${toolName}`;
      }

      messages.push(
        new ToolMessage({
          tool_call_id: toolCallId,
          name: toolName,
          content: result,
        }),
      );
    }
  }

用浏览器访问地址:http://localhost:3000/ai/chat/stream?query=请你将2026年%20ai%20相关的资讯,整理后生成一个精美的%20html%20发到我的邮箱,邮箱是:545442742@qq.com%20邮件主题是:ai资讯,邮件内容是:查询结果

结果就是在你的邮箱里面收到一份邮件

image.png

五.安装msql

1.安装mysel-server

安装mysql需要先安装mysql服务,安装地址:dev.mysql.com/downloads/m… 安装上之后

image.png

image.png 在这个里面设置数据库的用户名,密码等基础信息。

2.安装mysql的客户端,方便查看数据表信息

下载地址:dev.mysql.com/downloads/f…

image.png 打开后和mysql-service连接

image.png

3.nodejs项目里面使用

在nodejs里面安装mysql2

npm install mysql2

在代码里面写连接信息

// db.js
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: '127.0.0.1',
  port: 3306,
  user: 'root',
  password: '你的root密码',
  database: 'mydb',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0,
});

module.exports = pool;

这样在项目里面就能使用mysql了。

六.连接mysql改写user模块

上面案例里面把user的增删改查写到了本地内存里面。现在我们改成mysql管理。

1.安装资源包,解释typeorm

pnpm install --save @nestjs/typeorm typeorm mysql2

TypeORM 是 Node.js 生态中一款成熟的‌对象关系映射(ORM)框架‌,专为 JavaScript 和 TypeScript 设计,用于在应用中便捷操作数据库。

image.png

image.png

typeorm 的作用就是把 class 和 数据库表 对应。

4.创建user模块

执行命令:

pnpm install --save @nestjs/typeorm typeorm mysql2
nest g resource users --no-spec

选择REST API 和 Yes

image.png

最后你的目录是这样的

image.png

你可以在dto里面校验数据,在entities里面定义字段。

修改entities里面的user文件:定义数据表的基本信息

import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    length: 50
  })
  name: string;

  @Column({
    length: 50
  })
  email: string;

  @CreateDateColumn({
    type: 'timestamp'
  })
  createdAt: Date;

  @UpdateDateColumn({
    type: 'timestamp'
  })
  updatedAt: Date;
}

3.注入mysql的连接信息

前提条件是你的mysql是开启的,最好运行在docker上,方便管理。

image.png

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { createObserveModule } from '@nestjs/observe';

import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AiModule } from './ai/ai.module.js';
import { UsersModule } from './users/users.module.js';
import { User } from './users/entities/user.entity.js';
import { TypeOrmModule } from '@nestjs/typeorm';

export const { ObserveModule, ObserveInstrument } = createObserveModule();

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '123456',
      database: 'job',
      synchronize: true,
      logging: true,
      entities: [User],
    }),
    MailerModule.forRootAsync({
      imports: [],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        transport: {
          host: configService.get<string>('MAIL_HOST'),
          port: Number(configService.get<string>('MAIL_PORT')),
          secure: configService.get<string>('MAIL_SECURE') === 'true',
          auth: {
            user: configService.get<string>('MAIL_USER'),
            pass: configService.get<string>('MAIL_PASS'),
          },
        },
        defaults: {
          from: configService.get<string>('MAIL_FROM'),
        },
      }),
    }),
    AiModule,
    UsersModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

测试,启动项目以后,打开mysql客户端你就会发现typeOrm已经帮我们在mysql上创建了一个数据表

npm run start:dev

image.png

4.写增删改查

在users.service.ts里面写对数据库的增删改查

import { Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js';
import { EntityManager } from 'typeorm';
import { User } from './entities/user.entity.js';

@Injectable()
export class UsersService {
  @Inject(EntityManager)
  entityManager: EntityManager;

  create(createUserDto: CreateUserDto) {
    return this.entityManager.save(User, createUserDto);
  }

  findAll() {
    return this.entityManager.find(User);
  }

  findOne(id: number) {
    return this.entityManager.findOne(User, { where: { id } });
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    return this.entityManager.update(User, id, updateUserDto);
  }

  remove(id: number) {
    return this.entityManager.delete(User, id);
  }
}

5.做校验

给新增数据做数据校验users\dto\create-user.dto.ts

先安装校验包class-validator

pnpm install class-validator
import { IsEmail, IsNotEmpty, MaxLength } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty()
  @MaxLength(50)
  name: string;

  @IsNotEmpty()
  @IsEmail()
  @MaxLength(50)
  email: string;
}

6利用curl测试代码

在ios上

//新增
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice",
    "email": "alice@example.com"
  }'
  
  //查询
  curl http://localhost:3000/users

在window上

使用crul测试接口

curl.exe --% -X POST http://localhost:3000/users -H "Content-Type: application/json" -d "{\"name\":\"Alice\",\"email\":\"alice@example.com\"}"

curl.exe http://localhost:3000/users

利用curl测试的时候,你可以在控制台上看到此时执行的sql语句。

image.png

然后你还能在数据库看到新增的数据。

7.导出userService

在user.module.ts文件里面导出userService就好了

import { Module } from '@nestjs/common';
import { UsersService } from './users.service.js';
import { UsersController } from './users.controller.js';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

8.导入usermodule

image.png

9.写一个数据库增删改查的tool

在ai.module.ts文件里面做增删改查的tool

   {
      provide: 'DB_USER_CRUD_TOOL',
      inject: [UsersService],
      useFactory: (usersService: UsersService) => {
        const dbUserCrudArgsSchema = z.object({
          action: z
            .enum(['create', 'list', 'get', 'update', 'delete'])
            .describe(
              '要执行的操作类型,可选值:create, list, get, update, delete',
            ),
          id: z
            .number()
            .int()
            .positive()
            .optional()
            .describe('用户ID,仅当 action 为 get, update, delete 时有效'),
          name: z
            .string()
            .min(1)
            .max(50)
            .optional()
            .describe('用户名,仅当 action 为 create 和 update 时有效'),
          email: z
            .string()
            .email()
            .max(50)
            .optional()
            .describe('用户邮箱,仅当 action 为 create 时有效'),
        });

        return tool(
          async ({
            action,
            id,
            name,
            email,
          }: {
            action: 'create' | 'list' | 'get' | 'update' | 'delete';
            id?: number;
            name?: string;
            email?: string;
          }) => {
            switch (action) {
              case 'create': {
                if (!name || !email) {
                  return '创建用户时,用户名和邮箱是必填的。';
                }
                const created = await usersService.create({ name, email });
                return `已成功创建用户:${created.id} - ${created.name}`;
              }

              case 'list': {
                const allUsers = await usersService.findAll();
                if (!allUsers.length) {
                  return '没有用户。';
                }

                const lines = allUsers.map(
                  (user) => `${user.id} - ${user.name}`,
                );

                return `当前数据库users列表的用户有是:\n${lines.join('\n')}`;
              }

              case 'get': {
                if (!id) {
                  return '获取用户时,ID 是必填的。';
                }
                const user = await usersService.findOne(id);
                if (!user) {
                  return `找不到 ID 为 ${id} 的用户`;
                }
                return `用户信息: ID:${user.id}-名字:${user.name}-邮箱:${user.email}`;
              }

              case 'update': {
                if (!id) {
                  return '更新用户时,ID 和用户名是必填的。';
                }

                const payload: any = {};
                if (name) {
                  payload.name = name;
                }
                if (email) {
                  payload.email = email;
                }
                const existing = await usersService.findOne(id);
                if (!existing) {
                  return `用户 ID 为 ${id} 的用户不存在。`;
                }
                await usersService.update(id, payload);
                return `已更新用户:${existing.id} - ${existing.name}`;
              }

              case 'delete': {
                if (!id) {
                  return '删除用户时,ID 是必填的。';
                }
                const existing = await usersService.findOne(id);
                if (!existing) {
                  return `用户 ID 为 ${id} 的用户不存在。`;
                }
                await usersService.remove(id);
                return `已删除用户:${existing.id} - ${existing.name}`;
              }

              default: {
                return '无效的操作类型。';
              }
            }
          },
          {
            name: 'db_user_crud',
            description:
              '对数据库 users 嘉兴增删改查,通过 actions 字段选择 操作类型,可选值:create, list, get, update, delete。并按照需要提供 id, name 和 email 字段。',
            schema: dbUserCrudArgsSchema,
          },
        );
      },
    },

10.让大模型使用这个tool

在ai.service.ts文件里面使用 @Inject('DB_USER_CRUD_TOOL') private readonly dbUserTool: any,注入tool

image.png

11.测试数据

在浏览器上访问地址:http://localhost:3000/ai/chat/stream?query=先帮我王数据库里面添加一个id是002的用户,姓名是snow,邮箱是12345665@qq.com

查看mysql,你会发现添加好了数据

image.png

#七.开发定时任务的tool

1.安装资源包并介绍基本用法

pnpm install cron @nestjs/schedule

@nestjs/schedule —— Nest 官方的「定时任务集成包」

  • 作用:给 NestJS 提供装饰器式定时任务,让你用 @Cron()、@Interval()、@Timeout() 在 Service 方法上直接声明「什么时候跑」。

  • 底层:它内部就是基于 cron 包(你装的另一个)来解析 cron 表达式、注册定时器。

  • 用法三步走:

    1. 根模块 imports: [ScheduleModule.forRoot()]

    2. 在 @Injectable() Service 里写方法

    3. 用装饰器标上去

@Injectable()
export class TasksService {
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { timeZone: 'Asia/Shanghai' })
  async dailyJob() {
    // 查数据 / 调 query_user / 发邮件 send_mail
  }
}

  • @Cron():cron 表达式(6 段:秒 分 时 日 月 周) -** @Interval(10000)**:每 10 秒(底层 setInterval)
  • @Timeout(5000):启动后 5 秒跑一次(底层 setTimeout)

示例: 在module里面注入ScheduleModule

//app.module.ts
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { AiModule } from './ai/ai.module.js';

@Module({
  imports: [ScheduleModule.forRoot(), AiModule],
})
export class AppModule {}

在service里面用@Cron使用

//tasks.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { AiService } from './ai/ai.service.js';

@Injectable()
export class TasksService {
  constructor(private readonly ai: AiService) {}

  @Cron('0 30 9 * * *', { timeZone: 'Asia/Shanghai' }) // 每天 9:30
  async dailyUserMail() {
    await this.ai.runChain(
      '查询下001的信息,然后发送邮件到545442742@qq.com里面,邮箱主题是001的信息,内容是查询到的信息',
    );
  }
}

2.在项目里面测试定时器

修改app.module.ts

import { Module, OnApplicationBootstrap, Inject } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { createObserveModule } from '@nestjs/observe';

import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AiModule } from './ai/ai.module.js';
import { UsersModule } from './users/users.module.js';
import { User } from './users/entities/user.entity.js';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
  CronExpression,
  ScheduleModule,
  SchedulerRegistry,
} from '@nestjs/schedule';
import { CronJob } from 'cron';

export const { ObserveModule, ObserveInstrument } = createObserveModule();

@Module({
  imports: [
    ScheduleModule.forRoot(),
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '123456',
      database: 'job', // 数据库名
      synchronize: true, // 自动同步数据库结构,他能够自动建表,正式环境下不建议开启,容易造成数据丢失
      logging: true, // 开启日志模式,打印SQL语句
      entities: [User],
    }),
    MailerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        transport: {
          host: configService.get<string>('MAIL_HOST'),
          port: Number(configService.get<string>('MAIL_PORT')),
          secure: configService.get<string>('MAIL_SECURE') === 'true',
          auth: {
            user: configService.get<string>('MAIL_USER'),
            pass: configService.get<string>('MAIL_PASS'),
          },
        },
        defaults: {
          from: configService.get<string>('MAIL_FROM'),
        },
      }),
    }),
    AiModule,
    UsersModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

//测试定时任务
export class AppModule implements OnApplicationBootstrap {
  constructor(private readonly schedulerRegistry: SchedulerRegistry) {}

  async onApplicationBootstrap() {
    // 1. Cron 任务:每秒执行
    const cronJob = new CronJob('* * * * * *', () => {
      console.log('cron job 执行中');
    });
    this.schedulerRegistry.addCronJob('my-cron', cronJob);
    cronJob.start();

    // 2. Interval:每 1 秒
    const intervalId = setInterval(() => {
      console.log('interval 执行中');
    }, 1000);
    this.schedulerRegistry.addInterval('my-interval', intervalId);

    // 3. Timeout:3 秒后执行
    const timeoutId = setTimeout(() => {
      console.log('timeout 执行中');
    }, 3000);
    this.schedulerRegistry.addTimeout('my-timeout', timeoutId);

    // 4. 5 秒后统一清理
    setTimeout(() => {
      this.schedulerRegistry.deleteCronJob('my-cron');
      this.schedulerRegistry.deleteInterval('my-interval');
      this.schedulerRegistry.deleteTimeout('my-timeout');
      console.log('所有定时任务已移除');
    }, 5000);
  }
}

导入定时器 image.png

class AppModule 继承 OnApplicationBootstrap,然后重写onApplicationBootstrap方法,执行测试定时器。 重启项目

npm run start:dev

image.png

3.持久化定时器

3.1 创建定时器目录job

只新增一个module文件:nest g module job

image.png

只新增一个serivice文件:nest g service job --no-spec

image.png

和上面的job对比,新增一个模块用 nest g resource job2 --no-spec

image.png

对比上述命令你发现了什么?

3.2 创建job数据表

在job下面添加 entities\job.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

/** 任务类型:cron 表达式 / 固定间隔 / 指定时间点 */
export type JobType = 'cron' | 'every' | 'at';

@Entity()
export class Job {
  @PrimaryGeneratedColumn()
  id: number;

  /** 任务指令 / 描述 */
  @Column()
  instruction: string;

  /** 任务类型 */
  @Column({ type: 'varchar', length: 20 })
  type: JobType;

  /** cron 类型使用(cron 表达式) */
  @Column({ type: 'varchar', length: 100, nullable: true })
  cron: string | null;

  /** every 类型使用(间隔毫秒) */
  @Column({ type: 'int', nullable: true })
  everyMs: number | null;

  /** at 类型使用(指定触发时间点) */
  @Column({ type: 'datetime', nullable: true })
  at: Date | null;

  /** 是否启用 */
  @Column({ default: true })
  isEnabled: boolean;

  /** 上次执行时间 */
  @Column({ type: 'datetime', nullable: true })
  lastRun: Date | null;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

测试重启项目

npm run start:dev

查看mysql

image.png

3.3 写job.service.ts

3.3.1 SchedulerRegistry和CronJob的关系是什么?

这俩是 "管家 vs 工人" 的关系,层级完全不同。

  • SchedulerRegistry​ —— NestJS 提供的注册表/容器,它自己不执行任何任务,只负责"登记、查找、销毁"。你的三类任务(cron / interval / timeout)全都挂在它名下。
  • CronJob​ —— cron 这个第三方库提供的具体任务对象,只服务于 cron 这一种类型。它懂得怎么解析 '0 */5 * * * *' 这种表达式,并自己持有 start() / stop() 的开关。在代码里面主要用于创建cronJob对象。
****SchedulerRegistryCronJob
来源@nestjs/schedule(NestJS 官方封装)cron(第三方 npm 包)
角色管理者 / 花名册被管理的任务实例 / 工人
管理范围三类:cron、interval、timeout只有 cron 一类
关键能力addCronJob getCronJobs deleteInterval getTimeouts …start() stop() running nextDate()
是否懂 cron 表达式不懂,它只当字符串名字存起来懂,它负责算"下一次什么时候触发"
生命周期全应用单例(Nest 注入)每个 cron 任务一个实例
3.3.2 实现代码

知道了上述区别,我们现在只需要将任务当成一条条数据存在数据库里,程序运行时可以随时新增、启停、查询,进程重启后还能自己恢复。具体代码如下:

import {
  Injectable,        // 声明为 NestJS 可注入的服务(Provider)
  Inject,            // 属性注入装饰器
  Logger,            // NestJS 官方日志工具
  NotFoundException, // HTTP 404 异常
  OnApplicationBootstrap, // 应用启动完成后的生命周期钩子
} from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule'; // 定时任务注册表:统一管理 cron / interval / timeout
import { CronJob } from 'cron';                       // cron 表达式任务对象
import { EntityManager } from 'typeorm';              // TypeORM 的实体管理器,用于数据库读写
import { Job } from './entities/job.entity.js';       // 任务实体(对应数据库 job 表)

/**
 * 新增任务的入参类型:一个「可辨识联合类型」
 * 通过 type 字段区分三种任务形态,TypeScript 会据此收窄后续字段
 */
type AddJobInput =
  | {
      type: 'cron';       // cron 表达式任务,如 '0 */5 * * * *'
      cron: string;
      instruction: string; // 任务要执行的「指令/描述内容」
      isEnabled?: boolean; // 是否启用,默认 true
    }
  | {
      type: 'every';      // 固定间隔任务
      everyMs: number;    // 间隔毫秒数
      instruction: string;
      isEnabled?: boolean;
    }
  | {
      type: 'at';         // 一次性定时任务
      at: Date;           // 触发时间点
      instruction: string;
      isEnabled?: boolean;
    };

@Injectable()
export class JobService implements OnApplicationBootstrap {
  private readonly logger = new Logger(JobService.name);

  // 注入 TypeORM 的 EntityManager,用于操作 Job 表
  @Inject(EntityManager)
  private readonly entityManager: EntityManager;

  // 注入调度注册表,所有运行时任务都登记在这里,便于启停和查询
  @Inject(SchedulerRegistry)
  private readonly schedulerRegistry: SchedulerRegistry;

  /**
   * 应用启动完成后自动调用:
   * 从数据库加载所有 isEnabled = true 的任务,重新注册到调度器中(进程重启后恢复任务)
   */
  async onApplicationBootstrap() {
    const enabledJobs: any = await this.entityManager.find(Job, {
      where: { isEnabled: true },
    });

    // 取出当前调度器里已注册的三类任务名称,避免重复注册
    const cronJobs = this.schedulerRegistry.getCronJobs();      // Map<string, CronJob>
    const intervals = this.schedulerRegistry.getIntervals();    // string[]
    const timeouts = this.schedulerRegistry.getTimeouts();      // string[]

    for (const job of enabledJobs) {
      const alreadyRegistered =
        (job.type === 'cron' && cronJobs.has(job.id)) ||
        (job.type === 'every' && intervals.includes(job.id)) ||
        (job.type === 'at' && timeouts.includes(job.id));

      // 已注册则跳过,否则启动运行时
      if (alreadyRegistered) continue;

      await this.startRuntime(job);
    }
  }

  /**
   * 查询任务列表:
   * 返回数据库中的所有任务,并额外计算一个 running 字段,
   * 表示该任务「当前是否真的在调度器里运行」
   */
  async listJobs() {
    const jobs = await this.entityManager.find(Job, {
      order: { createdAt: 'DESC' }, // 按创建时间倒序
    });

    const cronJobs = this.schedulerRegistry.getCronJobs();
    const intervalNames = this.schedulerRegistry.getIntervals();
    const timeoutNames = this.schedulerRegistry.getTimeouts();

    return jobs.map((job: any) => {
      const running =
        job.isEnabled && // 数据库标记为启用
        ((job.type === 'cron' && cronJobs.has(job.id)) || // 且调度器里确实存在
          (job.type === 'every' && intervalNames.includes(job.id)) ||
          (job.type === 'at' && timeoutNames.includes(job.id)));

      return {
        ...job,
        running, // 「启用」≠「运行中」,两者都满足才算真正运行
      };
    });
  }

  /**
   * 新增一个任务:
   * 1. 把任务落库
   * 2. 如果启用,立刻注册到调度器开始运行
   */
  async addJob(input: AddJobInput) {
    const job = this.entityManager.create(Job, {
      instruction: input.instruction,
      type: input.type,
      // 按类型只填对应字段,其余置 null,避免脏数据
      cron: input.type === 'cron' ? input.cron : null,
      everyMs: input.type === 'every' ? input.everyMs : null,
      at: input.type === 'at' ? input.at : null,
      isEnabled: input.isEnabled ?? true, // 未传则默认启用
    });

    const saved = await this.entityManager.save(job);

    // 保存后立即调度
    if (saved.isEnabled) {
      await this.startRuntime(saved);
    }

    return saved;
  }

  /**
   * 启停切换:
   * enabled 传了就用传的值,没传就取反(切换)
   * 状态变化时先落库,再同步调度器的启停
   */
  async toggleJob(jobId: string, enabled?: boolean) {
    const job = await this.entityManager.findOne(Job, {
      where: { id: jobId },
    });

    if (!job) {
      throw new NotFoundException(`Job ${jobId} not found`);
    }

    const nextEnabled = enabled ?? !job.isEnabled;

    // 仅在状态真的变化时才写库
    if (nextEnabled !== job.isEnabled) {
      job.isEnabled = nextEnabled;
      await this.entityManager.save(job);
    }

    // 同步运行时状态
    if (job.isEnabled) {
      await this.startRuntime(job);
    } else {
      await this.stopRuntime(job);
    }

    return job;
  }

  /**
   * 核心:把任务「注册并启动」到调度器中
   * 三种类型分别处理,且都先判断是否已注册,保证幂等
   */
  private async startRuntime(job: Job) {
    // ===== 1. cron 类型 =====
    if (job.type === 'cron') {
      const cronJobs = this.schedulerRegistry.getCronJobs();
      const existing = cronJobs.get(job.id);

      // 已注册:直接启动(可能是被 stop 过的任务,stop 不会从注册表移除)
      if (existing) {
        existing.start();
        return;
      }

      // 未注册:创建 → 注册 → 启动
      const cronJob = this.createCronJob(job);
      this.schedulerRegistry.addCronJob(job.id, cronJob);
      cronJob.start();
    }

    // ===== 2. every 类型(固定间隔重复执行)=====
    if (job.type === 'every') {
      const names = this.schedulerRegistry.getIntervals();
      if (names.includes(job.id)) return; // 已注册,直接返回

      // 参数校验:间隔必须为正数
      if (typeof job.everyMs !== 'number' || job.everyMs <= 0) {
        throw new Error(`Invalid everyMs for job ${job.id}`);
      }

      const ref = setInterval(async () => {
        this.logger.log(`run job ${job.id}, ${job.instruction}`);
        // 每次执行后更新最后运行时间
        await this.entityManager.update(Job, job.id, { lastRun: new Date() });
      }, job.everyMs);

      this.schedulerRegistry.addInterval(job.id, ref); // 交给注册表管理,便于后续统一清理
      return;
    }

    // ===== 3. at 类型(指定时间点执行一次)=====
    if (job.type === 'at') {
      const names = this.schedulerRegistry.getTimeouts();
      if (names.includes(job.id)) return;

      if (!job.at) {
        throw new Error(`Invalid at for job ${job.id}`);
      }

      // 计算延迟;若时间已过则立即执行(Math.max 保证不为负)
      const delay = Math.max(0, job.at.getTime() - Date.now());

      const ref = setTimeout(async () => {
        this.logger.log(`run job ${job.id}, ${job.instruction}`);
        await this.entityManager.update(Job, job.id, {
          lastRun: new Date(),
          isEnabled: false, // at 任务执行一次后自动禁用(一次性任务)
        });

        // 执行完把自己从调度器移除,避免注册表残留
        try {
          this.schedulerRegistry.deleteTimeout(job.id);
        } catch {
          // ignore:已删除或不存在时忽略
        }
      }, delay);

      this.schedulerRegistry.addTimeout(job.id, ref);
      return;
    }
  }

  /**
   * 停止任务运行:
   * - cron:调用 CronJob.stop(),对象仍留在注册表中(所以可以再次 start)
   * - every:从注册表删除 interval(内部会 clearInterval)
   * - at:从注册表删除 timeout(内部会 clearTimeout)
   * 删除可能因为「不存在」而抛错,这里统一忽略
   */
  private stopRuntime(job: Job) {
    if (job.type === 'cron') {
      const cronJob = this.schedulerRegistry.getCronJobs().get(job.id);
      cronJob?.stop(); // 可选链:不存在时静默跳过
    } else if (job.type === 'every') {
      try {
        this.schedulerRegistry.deleteInterval(job.id);
      } catch {
        // ignore
      }
    } else if (job.type === 'at') {
      try {
        this.schedulerRegistry.deleteTimeout(job.id);
      } catch {
        // ignore
      }
    }
  }

  /**
   * 构造一个 CronJob 实例:
   * 触发时打印日志并把 lastRun 写回数据库
   */
  private createCronJob(job: Job) {
    const cronExpression = job.cron ?? ''; // 防御性取空串

    return new CronJob(cronExpression, async () => {
      console.log(`run ${job.id}, ${job.instruction}`);
      await this.entityManager.update(Job, job.id, {
        lastRun: new Date(),
      });
    });
  }
}
3.3.3 所有方法的汇总

image.png

启动第一步

image.png

上述代码,同一个任务,同时存在于两个地方:

┌─────────────────────────┐        ┌──────────────────────────────┐
│   数据库 job 表(持久)  │        │  内存 SchedulerRegistry(临时)│
│                         │        │                              │
│  id, type, cron,        │        │  cron:     [ 'job1', 'job3' ]│
│  everyMs, at,           │        │  interval: [ 'job2' ]        │
│  isEnabled, lastRun     │        │  timeout:  [ 'job4' ]        │
│                         │        │                              │
│  「应该运行吗?」        │        │  「现在真的在跑吗?」         │
└─────────────────────────┘        └──────────────────────────────┘
        ▲                                        │
        │                                        │
        └──── stopRuntime 注销 ──────────────────┘
        └──── startRuntime 注册 ─────────────────►

整个服务的所有方法,本质上都在做一件事:让这两边保持一致。

SchedulerRegistry 就是右边那个"内存中正在运行的任务花名册";CronJob 是花名册里 cron 类型任务的具体对象(自带 start/stop 开关)。

startRuntime里面的三个任务类型: image.png

3.3.4注释

假设用户输入:"每天早上 9 点提醒我喝水"

  1. 前端调 addJob({ type: 'cron', cron: '0 0 9 * * *', instruction: '提醒喝水' })
  2. addJob 往数据库插一条记录,isEnabled = true
  3. saved.isEnabled 为真 → 调 startRuntime
  4. startRuntime 发现内存里没有 → createCronJob 造对象 → 登记 → start()
  5. 每天早上 9 点,回调触发:打印日志,更新 lastRun
  6. 用户点"暂停" → toggleJob(id) → 数据库改 isEnabled=false → stopRuntime → cronJob.stop()
  7. 用户点"恢复" → toggleJob(id) → 改库 → startRuntime → 发现对象还在 → 直接 start()
  8. 服务重启 → onApplicationBootstrap 扫库 → 发现这条启用中且内存没有 → 重新挂上

3.4 导出jobService

image.png

3.5 在ai.module.ts里面引入jobmodule

image.png

3.6 在ai.module.ts里面写定时器tool

  {
      provide: 'CRON_JOB_TOOL',
      inject: [JobService],
      useFactory: (jobService: JobService) => {
        const cronJobArgSchema = z.object({
          action: z
            .enum(['add', 'list', 'toggle'])
            .describe('要执行的操作类型'),
          id: z
            .string()
            .optional()
            .describe('任务ID,仅当 action 为 toggle 时有效'),
          enabled: z
            .boolean()
            .optional()
            .describe('任务是否启用,仅当 action 为 toggle 时有效'),
          type: z.enum(['cron', 'every', 'at']).optional().describe('任务类型'),
          instruction: z.string().optional().describe('任务指令'),
          cron: z
            .string()
            .optional()
            .describe('cron 表达式,仅当 type 为 cron 时有效'),
          everyMs: z
            .number()
            .int()
            .positive()
            .optional()
            .describe('间隔毫秒数,仅当 type 为 every 时有效'),
          at: z
            .string()
            .optional()
            .describe('指定时间点,仅当 type 为 at 时有效'),
        });
        //type 说明:cron=按表达式循环;every=按间隔循环;at=到点执行一次后自动停用)

        return tool(
          async ({
            action,
            id,
            enabled,
            type,
            instruction,
            cron,
            everyMs,
            at,
          }: any) => {
            switch (action) {
              case 'list': {
                const jobs = await jobService.listJobs();
                if (!jobs.length) return '当前没有任何定时任务。';
                const lines = jobs
                  .map(
                    (job) =>
                      `${job.id} - ${job.instruction} - ${job.type} - ${job.isEnabled}`,
                  )
                  .join('\n');

                return `当前数据库jobs列表的定时任务有是:\n${lines}`;
              }
              case 'add': {
                if (!type || !instruction) {
                  return '添加定时任务时,任务类型和指令是必填的。';
                }

                if (type === 'cron') {
                  if (!cron) return '添加定时任务时,cron表达式是必填的。';
                  const created = await jobService.addJob({
                    type: 'cron',
                    instruction,
                    cron,
                    isEnabled: true,
                  });

                  return `已新增定时任务:${created.id} - ${instruction}`;
                }

                if (type === 'every') {
                  if (!everyMs || typeof everyMs != 'number' || everyMs <= 0)
                    return '添加定时任务时,间隔毫秒数是必填的。';

                  const created = await jobService.addJob({
                    type: 'every',
                    instruction,
                    everyMs,
                    isEnabled: true,
                  });

                  return `已新增定时任务:${created.id} - ${instruction}`;
                }

                if (type === 'at') {
                  if (!at) return '添加定时任务时,指定时间点是必填的。';
                  const date = new Date(at);

                  if (Number.isNaN(date.getTime())) {
                    return '添加定时任务时,指定时间点格式不正确。';
                  }

                  const created = await jobService.addJob({
                    type: 'at',
                    instruction,
                    at: date,
                    isEnabled: true,
                  });
                  return `已新增定时任务:${created.id} - ${instruction}`;
                }
              }
              case 'toggle': {
                if (!id) return '启停定时任务时,ID 是必填的。';
                const updated = await jobService.toggleJob(id, enabled);
                return `已更新任务状态:${updated.id} - ${instruction}- ${updated.isEnabled}`;
              }
            }
          },
          {
            name: 'cron_job',
            description:
              '对定时任务进行增删改查,通过 actions 字段选择 操作类型,可选值:add, list, toggle。并按照需要提供 id, type, instruction, cron, everyMs, at 字段。',
            schema: cronJobArgSchema,
          },
        );
      },
    },

在jobService里面有三个对外的方法listJobs,addJob,toggleJob,

image.png 这三个方法在定时器的tool里面都有用到:

image.png

相当于是把tool里面用到的定时器的方法都封装到了job.service.ts里面了。

3.7 在ai.service.ts里面使用定时器的tool

image.png

3.8 测试代码

http://localhost:3000/ai/chat/stream?query=每小时提醒我起来走一走,活动一下筋骨
http://localhost:3000/ai/chat/stream?query=十分钟后提醒我喝水
http://localhost:3000/ai/chat/stream?query=每晚十点钟提醒我写今日总结

查看数据库

image.png

3.9 开发实际工作的agent

当我调用接口,输入指令:一分钟后查询上海985高校信息,作为用户保护,用户名是学校名,用户邮箱是学校邮箱,生成精美html发到我的邮箱545442742@qq.com 的时候,ai.service.ts只能帮我先把这条数据存到数据库里面,不能立即执行。这个发邮件的动作一定是在一分钟之后,执行的。

那一分钟之后谁来执行?就变成了问题,我们需要重新写一个agent,来负责一分钟后他去发邮件。所以需要开发job-agent.service.ts文件。

job-agent.service.ts文件里面肯定得用到(CHAT_MODEL,SEND_MAIL_TOOL,WEB_SEARCH_TOOL,DB_USER_CRUD_TOOL,TIME_NOW_TOOL),但是这些tool,都在ai下面。我们不能跨文件调用tool,所以需要把job-agent.service.ts放到ai目录下面,而不是job下面。

那这个job-agent.service.ts在哪里使用呢?

肯定是在job.service.ts里面使用,在job.service.ts里面,当执行addJob的时候,会根据指令区分数据类型,比如在十分钟后提醒我喝水,就是at类型,利用setTimeout定时十分钟后做要做的事情。怎么做就靠job-agent.service.ts。

3.9.1 开发agent

job-agent.service.ts代码如下:

import { Inject, Injectable, Logger } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import {
  AIMessage,
  BaseMessage,
  HumanMessage,
  SystemMessage,
  ToolMessage,
} from '@langchain/core/messages';
import { Runnable } from '@langchain/core/runnables';

@Injectable()
export class JobAgentService {
  private readonly logger = new Logger(JobAgentService.name);
  private readonly modelWithTools: Runnable<BaseMessage[], AIMessage>;

  constructor(
    @Inject('CHAT_MODEL') model: ChatOpenAI,
    @Inject('SEND_MAIL_TOOL') private readonly sendMailTool: any,
    @Inject('WEB_SEARCH_TOOL') private readonly webSearchTool: any,
    @Inject('DB_USER_CRUD_TOOL') private readonly dbUserTool: any,
    @Inject('TIME_NOW_TOOL') private readonly timeNowTool: any,
  ) {
    this.modelWithTools = model.bindTools([
      this.sendMailTool,
      this.webSearchTool,
      this.dbUserTool,
      this.timeNowTool,
    ]);
  }

  private async executeToolCalls(
    toolCalls: any[],
    messages: BaseMessage[],
  ): Promise<void> {
    for (const toolCall of toolCalls) {
      const toolCallId = toolCall.id || '';
      const toolName = toolCall.name;

      let result: any;

      if (toolName === 'send_mail') {
        result = await this.sendMailTool.invoke(toolCall.args);
      } else if (toolName === 'web_search') {
        result = await this.webSearchTool.invoke(toolCall.args);
      } else if (toolName === 'db_user_crud') {
        result = await this.dbUserTool.invoke(toolCall.args);
      } else if (toolName === 'time_now') {
        result = await this.timeNowTool.invoke(toolCall.args);
      } else {
        result = `未知工具: ${toolName}`;
      }

      messages.push(
        new ToolMessage({
          tool_call_id: toolCallId,
          name: toolName,
          content: result,
        }),
      );
    }
  }

  async runJob(instruction: string): Promise<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个用于执行后台任务的只能代理,你会根据给定的任务指令,比要时候调用工具(如:db_user_crud, web_search, send_mail, time_now等)来查询或者改写数据,然后给出清晰的步骤和结果说明。',
      ),
      new HumanMessage(instruction),
    ];

    while (true) {
      const aiMessage = await this.modelWithTools.invoke(messages);
      messages.push(aiMessage);

      const toolCalls = aiMessage.tool_calls ?? [];

      // 没有要调用的工具,直接把回答返回给调用方
      if (!toolCalls.length) {
        return aiMessage.content.toString();
      }

      // 依次执行本轮需要调用的所有工具
      await this.executeToolCalls(toolCalls, messages);
    }
  }
}

他里面的代码和ai.service.ts里面的代码很像,就是要大模型调用tool的agent。

3.9.2 ai导出job-agent.service.ts

因为这个文件是放在ai下面的,最终是在job.service.ts文件里面使用。所以要在ai.module.ts里面导出

image.png

3.9.3 job导入job-agent.service.ts

在job.module.ts文件里面导aiModule就好了。

image.png

3.9.4 在job.service.ts里面使用job-agent.service.ts

注入JobAgentService服务,用于执行任务指令。 image.png

image.png

image.png

3.9.5 启动

image.png 这个错误不是 Nest 的依赖注入问题,而是 JS 模块层的循环引用(circular import)

因为ai.module里面引用了job.module,现在job.module里面又引用了ai.module

解决办法:

// job.module.ts
import { forwardRef, Module } from '@nestjs/common';

@Module({
  imports: [forwardRef(() => AiModule)],   // ✅ 延迟求值
  providers: [JobService],
})
export class JobModule {}

// ai.module.ts
@Module({
  imports: [forwardRef(() => JobModule)],  // ✅ 双向都要加
  providers: [JobAgentService, /* tokens */],
  exports: [JobAgentService],
})
export class AiModule {}

在ai.module和job.module里面imports导入的时候,使用forwardRef,加好以后重启项目

image.png

浏览器访问接口

http://localhost:3000/ai/chat/stream?query=一分钟后发送邮件到我的邮箱%20545442742@qq.com%20提醒我出去条广场舞

http://localhost:3000/ai/chat/stream?query=一分钟后查询上海985高校信息,作为用户保护,用户名是学校名,用户邮箱是学校邮箱,生成精美html发到我的邮箱545442742@qq.com

image.png

八.问答总结

8.1 nestjs 的生命周期函数介绍

NestJS 一共有 5 个生命周期钩子,分布在「初始化 / 运行 / 终止」三个阶段:

钩子方法所属阶段触发时机
onModuleInit()初始化宿主模块的依赖解析完成时调用
onApplicationBootstrap()初始化所有模块初始化完成、但尚未开始监听连接时调用
onModuleDestroy()终止收到终止信号(如 SIGTERM)后调用
beforeApplicationShutdown(signal)终止所有 onModuleDestroy() 完成后调用;它结束后才会关闭所有连接(app.close())
onApplicationShutdown(signal)终止连接全部关闭后(app.close() resolve 后)调用

NestJS 一共有 5 个生命周期钩子,分布在「初始化 / 运行 / 终止」三个阶段:

钩子方法所属阶段触发时机
onModuleInit()初始化宿主模块的依赖解析完成时调用
onApplicationBootstrap()初始化所有模块初始化完成、但尚未开始监听连接时调用
onModuleDestroy()终止收到终止信号(如 SIGTERM)后调用
beforeApplicationShutdown(signal)终止所有 onModuleDestroy() 完成后调用;它结束后才会关闭所有连接(app.close())
onApplicationShutdown(signal)终止连接全部关闭后(app.close() resolve 后)调用

两个维度的分类

按作用域分:

  • 模块级:onModuleInit、onModuleDestroy —— 每个模块各自触发
  • 应用级:onApplicationBootstrap、beforeApplicationShutdown、onApplicationShutdown —— 全局只触发一次

使用案例:

import {
  Injectable,
  OnModuleInit,
  OnApplicationBootstrap,
  OnModuleDestroy,
  BeforeApplicationShutdown,
  OnApplicationShutdown,
} from '@nestjs/common';

@Injectable()
export class LifecycleService implements
  OnModuleInit,
  OnApplicationBootstrap,
  OnModuleDestroy,
  BeforeApplicationShutdown,
  OnApplicationShutdown {

  async onModuleInit() {
    // 建立数据库连接、Redis 连接
  }

  async onApplicationBootstrap() {
    // 缓存预热、数据 seeding、启动定时任务
  }

  async onModuleDestroy() {
    // 模块级清理:取消订阅、释放本模块资源
  }

  async beforeApplicationShutdown(signal?: string) {
    // 停止接收新任务、排空任务队列(此时连接还开着)
  }

  async onApplicationShutdown(signal?: string) {
    // 最终清理:刷日志、上报监控(此时连接已关闭)
  }
}
  • 钩子可以声明为 async,Nest 会 await 前一个完成才执行下一个,所以执行顺序严格依赖模块的 imports 顺序。
  • 钩子抛错会导致应用启动失败,务必用 try/catch 包住关键初始化逻辑。
  • 这些钩子在 provider、controller、module​ 类上都可以注册,不限于 service。

8.2 为什么在job.service.ts里面要使用SchedulerRegistry和CronJob,实际做定时工作的明明是setTimeout?

这三者之间的关系是什么?

打个比方:你家里的一堆闹钟

setTimeout = 一个最笨的闹钟

你拧一下: "60 分钟后响一次" 。它就会响,完事。

但它有三个毛病:

  • 它不认识日历。你没法跟它说"每周一早上 7 点响",它只听得懂"多少毫秒之后"。
  • 响完它就消失了,你不知道它还在不在。
  • 你设了 10 个这样的闹钟,转头就忘了哪个是哪个,想关掉其中一个,找不着。

你截图里那个 setTimeout,就是这么一个最笨的闹钟——它只负责"到时候响一下" 。


CronJob = 一个听得懂人话的高级闹钟

你想说: "每周一到周五早上 9 点半响" 。

笨闹钟做不到。因为"周一到周五"不是固定的毫秒数——周五到下周一中间隔了三天,笨闹钟不会算。

CronJob 就是来干这个的:你把 0 30 9 * * 1-5 这种"人话时间"丢给它,它负责帮你算出"下一次该定到几点" 。

(偷偷说一句:它内部其实也是用笨闹钟实现的——每次响完,自己算下一次,再定一个新的。只是它替你把"算时间"这个脏活干了。)


SchedulerRegistry = 一个登记本

你家里现在有一堆闹钟:笨的、高级的、一次性的、循环的。问题来了:

你想做的事没有登记本有登记本
我现在有几个闹钟在响?不知道翻本子
把"叫起床"那个关掉找不到它本子上查到 → 关掉
我是不是已经设过一个了?可能设重了,响两遍翻本子,有了就不设
搬家了闹钟还在空房子里响照着本子全部拆走

SchedulerRegistry 就是这个本子。

setTimeout 让闹钟能响,addTimeout 把这个闹钟记到本子上。


回到你截图的那段代码

const ref = setTimeout(async () => { ... }, 60000);          // 买个闹钟,60秒后响
this.schedulerRegistry.addTimeout(job.id.toString(), ref);   // 在本子上记一笔:1号任务 = 这个闹钟
  • 第一行:让它能响。
  • 第二行:让系统知道它存在,以后能查到、能关掉、服务关停时能自动清掉。

少了第二行,这个闹钟就"野"了——能响,但你再也管不了它。


一句话收尾

setTimeout 是闹钟,CronJob 是会算日子的闹钟,SchedulerRegistry 是记着所有闹钟的本子。

闹钟负责响,本子负责让你还管得住它。

所以你看到的不是"既然有 setTimeout 干嘛还要另外两个",而是:setTimeout 只解决了"响",另外两个解决"复杂时间的响"和"响之前能管住" 。