构建一个会调用工具和定时任务的AI智能助手

0 阅读7分钟

搭建项目

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

创建 ai 模块

nest g res ai --no-spec

根目录创建 .env 模块

OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus

在 AppModule 中配置全局 ConfigModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AiModule } from './ai/ai.module';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    AiModule,
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

使用 nest 的 DI 创建 ChatModel 的 provider

import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';

@Module({
  controllers: [AiController],
  providers: [
    AiService,
    {
      provide: 'CHAT_MODEL',
      useFactory: (configService: ConfigService) => {
        return new ChatOpenAI({
          modelName: configService.get<string>('MODEL_NAME'),
          apiKey: configService.get<string>('OPENAI_API_KEY'),
          configuration: {
            baseURL: configService.get<string>('OPENAI_BASE_URL'),
          },
        });
      },
      inject: [ConfigService],
    },
  ],
})
export class AiModule {}

同步接口

获取用户信息逻辑:

  1. 定义根据用户 id 查询信息的 tool
  2. 将工具绑定到模型,让模型知道可以调用这个工具
  3. 每次询问大模型后,检查返回结果中是否有工具调用
    1. 有工具调用 ==> 执行对应工具,将结果放入消息历史,继续询问模型
    2. 没有工具调用 ==> 直接返回模型的回答

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

type User = {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
};

const database: { users: Record<string, User> } = {
  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',
    },
  },
};

type QueryUserArgs = {
  userId: string;
};

const queryUserArgsSchema = z.object({
  userId: z.string().describe('用户的 ID'),
});

// 查询用户信息的 tool
const queryUserInfoTool = tool(
  ({ userId }: QueryUserArgs) => {
    const user = database.users[userId];

    if (!user) {
      return `未找到用户 ID 为 ${userId} 的用户信息`;
    }
    return JSON.stringify(user);
  },
  {
    name: 'query_user',
    description: '根据用户的 ID, 查询用户信息',
    schema: queryUserArgsSchema,
  },
);

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

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

  async runChain(query: string): Promise<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个智能助手,能够根据用户的查询调用工具获取信息,并给出回答。',
      ),
      new HumanMessage(query),
    ];

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

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

      for (const toolCall of aiMessage.tool_calls) {
        // 执行工具调用
        if (toolCall.name === 'query_user') {
          const parsedArgs = queryUserArgsSchema.parse(toolCall.args);
          const result = await queryUserInfoTool.invoke(parsedArgs);

          messages.push(
            new ToolMessage({
              tool_call_id: toolCall.id || '',
              name: toolCall.name,
              content: result,
            }),
          );
        }
      }
    }
  }
}

在 AIController 中添加路由

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

流式接口

获取用户信息的逻辑:

  1. 调用模型的流式接口,边接收 chunk 边实时判断:
    1. 如果当前 chunk 还未涉及工具调用 ==> 立即 yield 文本内容
    2. 将 chunk 拼接到完整的 AIMessage
  1. 若本轮没有工具调用 ==> 结束循环
  2. 如果有工具调用 ==> 执行工具,将结果包装成 ToolMessage 放入消息历史,进入下一轮循环

async *runChainStream(query: string): AsyncIterable<string> {
  const messages: BaseMessage[] = [
    new SystemMessage(
      '你是一个智能助手。当需要查询用户信息时,必须调用 query_user 工具,并提供 userId 参数(字符串类型)。如果用户没有提供 userId,请先询问用户。',
    ),
    new HumanMessage(query),
  ];

  while (true) {
    const aiMsgStream = await this.modelWithTools.stream(messages);

    let fullMessage: AIMessageChunk | null = null;

    for await (const aiMessageChunk of aiMsgStream as AsyncIterable<AIMessageChunk>) {
      fullMessage = fullMessage
        ? fullMessage.concat(aiMessageChunk)
        : aiMessageChunk;

      const hasToolCalls =
        fullMessage.tool_call_chunks &&
        fullMessage.tool_call_chunks?.length > 0;

      if (!hasToolCalls && fullMessage.content) {
        yield aiMessageChunk.content as string;
      }
    }

    if (!fullMessage) {
      return;
    }

    messages.push(fullMessage);

    const toolCalls = fullMessage.tool_calls ?? [];

    if (toolCalls.length === 0) {
      // 没有工具调用,直接返回回答
      return;
    }

    for (const toolCall of toolCalls) {
      if (toolCall.name === 'query_user') {
        const args = queryUserArgsSchema.parse(toolCall.args);
        const result = await queryUserInfoTool.invoke(args);

        messages.push(
          new ToolMessage({
            tool_call_id: toolCall.id || '',
            name: toolCall.name,
            content: result,
          }),
        );
      }
    }
  }
}

AIController 中添加路由

@Sse('chat-stream')
chatStream(@Query('query') query: string) {
  const stream = this.aiService.runChainStream(query);
  const result = from(stream).pipe(map((chunk) => ({ data: chunk })));
  return result;
}

页面中已经可以看到流式的效果

把获取用户信息的逻辑也封装为 tool

UserService 的代码

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

interface User {
  id: string;
  name: string;
  email: string;
}

@Injectable()
export class UserService {
  private readonly users: User[] = [
    { id: '001', name: '张三', email: 'zhangsan@example.com' },
    { id: '002', name: '李四', email: 'lisi@example.com' },
    { id: '003', name: '王五', email: 'wangwu@example.com' },
    { id: '004', name: '赵六', email: 'zhaoliu@example.com' },
    { id: '005', name: '钱七', email: 'qianqi@example.com' },
  ];

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

  findOne(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }

  create(user: User): User {
    this.users.push(user);
    return user;
  }

  update(id: string, updatedUser: Partial<User>): User | undefined {
    const user = this.findOne(id);
    if (user) {
      Object.assign(user, updatedUser);
      return user;
    }
    return undefined;
  }

  delete(id: string): boolean {
    const index = this.users.findIndex((user) => user.id === id);
    if (index !== -1) {
      this.users.splice(index, 1);
      return true;
    }
    return false;
  }
}

在 AIController 中注入 UserService

{
  provide: 'QUERY_USER_TOOL',
  useFactory: (userService: UserService) => {
    return tool(
      ({ userId }: { userId: string }) => {
        const user = userService.findOne(userId);

        if (!user) {
          return `未找到用户 ID 为 ${userId} 的用户信息`;
        }
        return JSON.stringify(user);
      },
      {
        name: 'query_user',
        description:
          '根据用户 ID 查询用户详细信息。参数 userId 是必填的字符串类型,例如:userId: "001"',
        schema: z.object({
          userId: z.string().describe('用户的 ID'),
        }),
      },
    );
  },
  inject: [UserService],
},

AIService 中的调用也需要修改

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

// 同步调用的地方需要修改
const result = await this.queryUserInfoTool.invoke(parsedArgs);

// 流式调用的地方也需要修改
const result = await this.queryUserInfoTool.invoke(args);

最终的效果

QQ 邮箱发送邮件

先拿到授权码

下载发送邮件的依赖包

@nestjs-modules/mailer文档:nest-modules.github.io/mailer/docs…

nodemailer 文档:nodemailer.com/

pnpm install nodemailer @nestjs-modules/mailer

配置环境变量

MAIL_HOST=smtp.qq.com
MAIL_PORT=587
MAIL_SECURE=false
MAIL_USER=1234567890@qq.com
MAIL_PASS=mbpzuvxchqlzdhce
MAIL_FROM="No Reply" <1234567890@qq.com>

在 AppModel 中注入 MailerModule

MailerModule.forRootAsync({
  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'),
    },
  }),
}),

AiModel 中添加 发送邮件的 tool

{
  provide: 'SEND_MAIL_TOOL',
  useFactory: (
    configService: ConfigService,
    mailerService: MailerService,
  ) => {
    return tool(
      async ({ to, subject, text, html }) => {
        await mailerService.sendMail({
          to,
          subject,
          text: text ?? '没有文本内容',
          html: html ?? `<p>${text ?? '(无 HTML 内容)'}</p>`,
          from: configService.get<string>('MAIL_FROM'),
        });
      },
      {
        name: 'send_mail',
        description: '发送邮件工具,参数包括收件人邮箱、邮件主题和邮件内容',
        schema: z.object({
          to: z.email().describe('收件人邮箱地址'),
          subject: z.string().describe('邮件主题'),
          text: z.string().optional().describe('邮件内容,可选'),
          html: z.string().optional().describe('邮件的 HTML 内容,可选'),
        }),
      },
    );
  },
  inject: [ConfigService, MailerService],
},

AiService 中补充调用发送邮件 tool 的调用

else if (toolCall.name === 'send_mail') {
  const msg = await this.sendMailTool.invoke(toolCall.args);

  messages.push(
    new ToolMessage({
      tool_call_id: toolCall.id || '',
      name: toolCall.name,
      content: msg as string,
    }),
  );
}

可以看到已经可以成功发送邮件

网络搜索发送到邮箱

使用博查的服务,先创建一个 api key:open.bochaai.com/api-keys

博查的 langchain 使用手册:bocha-ai.feishu.cn/wiki/XXCsw2…

AiModel 中注入网络搜索的 tool

interface BochaWebPage {
  name: string;
  url: string;
  summary: string;
  siteName: string;
  siteIcon: string;
  dateLastCrawled: string;
}
interface BochaResponse {
  code: number;
  data?: {
    webPages: { value: BochaWebPage[] };
  };
}

{
  provide: 'WEB_SEARCH_TOOL',
  useFactory: (configService: ConfigService) => {
    return tool(
      async ({ query, count }) => {
        const boChaUrl = 'https://api.bochaai.com/v1/web-search';

        const _postData = {
          query,
          freshness: 'noLimit',
          summary: true,
          count: count,
        };

        const response = await fetch(boChaUrl, {
          headers: {
            Authorization: `Bearer ${configService.get<string>('BOCHA_API_KEY')}`,
            'Content-Type': 'application/json',
          },
          method: 'POST',
          body: JSON.stringify(_postData),
        });

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

        let responseJson: BochaResponse;
        try {
          responseJson = (await response.json()) as BochaResponse;
        } catch (error) {
          return `搜索API请求失败,原因是:搜索结果解析失败 ${error}`;
        }

        try {
          if (responseJson.code !== 200 || !responseJson.data) {
            return `搜索API请求失败,原因是:搜索结果解析失败 ${JSON.stringify(responseJson)}`;
          }

          const webpages = responseJson.data.webPages.value ?? [];

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

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

          return formatted_results;
        } catch (e) {
          return `搜索API请求失败,原因是:搜索结果解析失败 ${e}`;
        }
      },
      {
        name: 'web_search',
        description:
          '使用 Bocha Web Search API 搜索互联网网页。输入为搜索关键词(可选 count 指定结果数量),返回包含标题、URL、摘要、网站名称、图标和时间等信息的结果列表。',
        schema: z.object({
          query: z.string().describe('搜索关键词'),
          count: z.number().optional().describe('返回结果数量,默认为 10'),
        }),
      },
    );
  },
  inject: [ConfigService],
},

AIService 中补充网络查询的 tool

else if (toolCall.name === 'web_search') {
  const msg = await this.webSearchTool.invoke(toolCall.args);

  messages.push(
    new ToolMessage({
      tool_call_id: toolCall.id || '',
      name: toolCall.name,
      content: msg as string,
    }),
  );
}

main.ts 中添加跨域

app.enableCors();

实现效果:

页面可以看到流式输出的效果

qq 邮箱也可以看到邮件

完成这个问题花费了三万两千多的 token

连接数据库

创建定时任务的数据库

安装 mysql 驱动和 typeorm 的包

pnpm install --save @nestjs/typeorm typeorm mysql2

AppModel 中注入数据库驱动

开启 synchronize ,会在项目启动的时候创建数据表

logging 为 true ,会打印 sql 语句

TypeOrmModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => ({
    type: 'mysql',
    host: configService.get<string>('DB_HOST'),
    port: Number(configService.get<string>('DB_PORT')),
    username: configService.get<string>('DB_USERNAME'),
    password: configService.get<string>('DB_PASSWORD'),
    database: configService.get<string>('DB_NAME'),
    entities: [__dirname + '/**/*.entity{.ts,.js}'],
    synchronize: true,
    connectorPackage: 'mysql2',
    logging: true,
  }),
}),

创建 user 模块

nest g resource users --no-spec

修改 user 实体

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

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

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

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

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

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

运行项目

pnpm run start:dev

可以看到控制台已经打印了创造数据库表的打印语句

在数据库中可以看到已经创建成功的 user 表

修改 UserService

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

@Injectable()
export class UsersService {
  @Inject(EntityManager)
  private readonly 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);
  }
}

安装 class-validator 库来声明式验证数据是否符合校验规则

pnpm install class-validator

修改 create-user.dto.ts

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

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

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

测试新建用户

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

在 UsersModule 中导出 UserService

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

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

在 AIModel 中引入 UserModel

imports: [UsersModule],

在 AIModel 中注入数据库增删改查的 tool

{
  provide: 'DB_USERS_CRUD_TOOL',
  useFactory: (usersService: UsersService) => {
    return tool(
      async ({ action, name, email, id }) => {
        switch (action) {
          case 'create': {
            if (!name || !email) {
              return '创建用户时,name 和 email 是必填的';
            }
            const users = await usersService.create({ name, email });
            return `用户创建成功,ID: ${users.id}, Name: ${users.name}, Email: ${users.email}`;
          }
          case 'list': {
            const users = await usersService.findAll();
            if (!users.length) {
              return '当前没有用户';
            }
            return users
              .map(
                (user) =>
                  `ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`,
              )
              .join('\n');
          }
          case 'get': {
            if (!id) {
              return '缺少用户id';
            }
            const user = await usersService.findOne(id);
            return `查询到的用户id为${user.id},邮箱为: ${user.email},姓名:${user.name},创建时间:${user?.createdAt?.toISOString()},修改时间:${user.updatedAt?.toISOString()}`;
          }
          case 'update': {
            if (!id) {
              return '缺少用户id';
            }
            if (!name && !email) {
              return '更新用户时,name 或 email 至少需要提供一个';
            }
            const updateResult = await usersService.update(id, {
              name,
              email,
            });
            if (!updateResult.affected) {
              return `未找到 ID 为 ${id} 的用户,更新失败`;
            }
            const updatedUser = await usersService.findOne(id);
            if (!updatedUser) {
              return `用户更新成功,但未查询到更新后的用户信息,ID: ${id}`;
            }
            return `用户更新成功,ID: ${updatedUser.id}, Name: ${updatedUser.name}, Email: ${updatedUser.email}`;
          }
          case 'delete': {
            if (!id) {
              return '缺少用户id';
            }
            const existUser = await usersService.findOne(id);
            if (!existUser) {
              return `未找到 ID 为 ${id} 的用户,删除失败`;
            }
            const deleteResult = await usersService.remove(id);
            if (deleteResult.affected === 0) {
              return `未找到 ID 为 ${id} 的用户,删除失败`;
            }
            return `用户删除成功,ID: ${id}`;
          }
        }
      },
      {
        name: 'db_users_crud',
        description:
          '这是一个数据库用户信息 CRUD 工具,目前仅支持。通过 action 字段选择 create/list/get/update/delete,并按需提供 id、name、email 等参数。',
        schema: z.object({
          action: z
            .enum(['create', 'list', 'get', 'update', 'delete'])
            .describe(
              '要执行的操作,目前仅支持 "create/list/get/update/delete"',
            ),
          name: z
            .string()
            .optional()
            .describe('用户的名字,仅在创建或更新时需要'),
          email: z
            .string()
            .optional()
            .describe('用户的邮箱,仅在创建或更新时需要'),
          id: z
            .number()
            .optional()
            .describe('用户的 ID,在 get/update/delete 时需要'),
        }),
      },
    );
  },
  inject: [UsersService],
},

在 AIService 中添加用户增删改查的 tool

@Inject('DB_USERS_CRUD_TOOL') private readonly dbUsersCrudTool,

this.modelWithTools = model.bindTools([
  // ...
  this.dbUsersCrudTool,
]);

在 AIService 中补充用户增删改查的 tool

else if (toolCall.name === 'db_users_crud') {
  const msg = await this.dbUsersCrudTool.invoke(toolCall.args);

  messages.push(
    new ToolMessage({
      tool_call_id: toolCall.id || '',
      name: toolCall.name,
      content: msg as string,
    }),
  );
}

在页面输入一个创建用户的命令【添加一个用户张三,邮箱为zhangsan@example.com

可以看到控制台也有 sql 语句输出

查看数据库,可以看到已经添加了新用户

定时任务

三种定时任务

  • at:在制定时间点执行一次
  • every:每隔固定的时间执行一次
  • cron:用【 分 时 日 月 周 】的表达式定义具体时刻重复

nest 的定时任务:Documentation | NestJS - A progressive Node.js framework

cron 的文档:en.wikipedia.org/wiki/Cron

安装定时任务依赖

pnpm install cron @nestjs/schedule

安装 cron 的类型

pnpm install --D @types/cron

AppModel 中引入定时任务模块

imports:[
  // ...
  ScheduleModule.forRoot(),
]

创建 Job 模块

nest g module job
nest g service job --no-spec 

添加 Job 实体

  • id:使用 uuid 创建一个 id
  • instruction:定时任务的内容
  • type:定时任务的类型( cron、every、at )
  • everyMs:每隔多长时间执行一次定时任务
  • cron:cron 类型的间隔
  • at:执行定时任务的时间
  • isEnabled:是否开启定时任务
  • lastRun:最后一次跑定时任务的时间
  • createdAt:创建定时任务的时间
  • updatedAt:定时任务更新时间
import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

export type JobType = 'cron' | 'every' | 'at';

@Entity()
export class Job {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({
    type: 'text',
  })
  instruction: string;

  @Column({
    type: 'varchar',
    length: 10,
    default: 'cron',
  })
  type: JobType;

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

  // cron 类型的间隔为 cron 表达式
  @Column({
    type: 'varchar',
    length: 100,
    nullable: true,
  })
  cron: string | null;

  // at 类型的时间为 Date 对象的 ISO 字符串
  @Column({
    type: 'timestamp',
    nullable: true,
  })
  at: Date | null;

  @Column({
    default: true,
  })
  isEnabled: boolean;

  @Column({ type: 'timestamp', nullable: true })
  lastRun: Date | null;

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

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

数据库中已经创建好了一张数据表

在 JobService 中管理定时任务

定时任务初始化逻辑 - onApplicationBootstrap:

  1. 获取数据库中所有启用的定时任务配置

  2. 获取当前调度器中已注册的所有任务(包括静态装饰器注册的)

  3. 遍历数据库中的每个任务:

a. 检查该任务是否已在调度器中注册(通过ID判断)

b. 如果已注册 → 跳过,避免重复注册

c. 如果未注册 → 调用 startRuntime() 动态注册该任务

private readonly logger: Logger = new Logger(JobService.name);

@Inject(EntityManager)
private readonly entityManager: EntityManager;

@Inject(SchedulerRegistry)
private readonly schedulerRegister: SchedulerRegistry;

async onApplicationBootstrap() {
  const enabledJobs = await this.entityManager.find(Job, {
    where: { isEnabled: true },
  });

  const cronJobs = this.schedulerRegister.getCronJobs();
  const intervals = this.schedulerRegister.getIntervals();
  const timeouts = this.schedulerRegister.getTimeouts();

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

    if (!hasRegistered) {
      this.startRuntime(job);
    }
  }
}

列出所有的定时任务

async listJobs() {
  const jobs = await this.entityManager.find(Job, {
    order: {
      createdAt: 'DESC',
    },
  });

  const cronJobs = this.schedulerRegister.getCronJobs();
  const intervalsList = this.schedulerRegister.getIntervals();
  const timeoutsList = this.schedulerRegister.getTimeouts();

  return jobs.map((job) => {
    const isRunning =
      job.isEnabled &&
      ((job.type === 'cron' && cronJobs.has(job.id)) ||
        (job.type === 'every' && intervalsList.includes(job.id)) ||
        (job.type === 'at' && timeoutsList.includes(job.id)));

    return {
      ...job,
      isRunning,
    };
  });
}

添加定时任务

async addJob(payload: AddJobDto) {
  const _postData = this.entityManager.create(Job, {
    instruction: payload.instruction,
    type: payload.type,
    everyMs: payload.type === 'every' ? payload.everyMs : null,
    cron: payload.type === 'cron' ? payload.cron : null,
    at: payload.type === 'at' ? payload.at : null,
    isEnabled: payload.isEnabled ?? true,
    lastRun: null,
  });

  const newJob = await this.entityManager.save(Job, _postData);

  if (newJob.isEnabled) {
    this.startRuntime(newJob);
  }

  return newJob;
}

切换定时任务状态

async toggleJob(jobId: string, isEnabled?: boolean) {
  const targetJob = await this.entityManager.findOne(Job, {
    where: { id: jobId },
  });

  if (!targetJob) {
    return '没有找到相关任务';
  }

  const currentlyRunning = isEnabled ?? !targetJob.isEnabled;

  // 只有状态改变时才触发
  if (currentlyRunning !== targetJob.isEnabled) {
    await this.entityManager.save(Job, {
      ...targetJob,
      isEnabled: currentlyRunning,
    });
  }

  if (targetJob.isEnabled) {
    this.startRuntime(targetJob);
  } else {
    this.stopRuntime(targetJob);
  }
}

cron 启动定时任务逻辑

  1. 获取调度其中所有已注册的定时任务
  2. 判断目标任务是否在注册表中
    1. 如果已注册 → 直接启动定时任务
    2. 如果未注册 → 创建任务实例 → 注册到调度器 → 启动任务

private startRuntime(job: Job) {
  if (job.type === 'cron') {
    const cronJobs = this.schedulerRegister.getCronJobs();
  
    const existingFlag = cronJobs.get(job.id);
  
    if (existingFlag) {
      existingFlag.start();
      return;
    }
  
    const newCronJob = new CronJob(job.cron, async () => {
      this.logger.log('执行定时任务:' + job.instruction);
      await this.entityManager.update(
        Job,
        { id: job.id },
        { lastRun: new Date() },
      );
    });
  
    this.schedulerRegister.addCronJob(job.id, newCronJob);
    newCronJob.start();
    return;
  }
}

every 启动定时任务的逻辑

  1. 获取调度器中所有已注册的 Interval 任务
  2. 判断目标任务时都已在注册表中
    1. 如果已注册 → 直接返回( 任务已在运行,避免重复 )
    2. 如果未注册 → 验证时间间隔 → 创建 setInterval 定时器,注册到调度器

every 任务和 cron 任务的区别:

  • cron 任务可以多次启动/暂停
  • every 任务只能创建和删除
  • cron 任务需要手动调用 start 执行
  • every 任务一旦注册直接开始执行
private startRuntime(job: Job) {
  // ...
 if (job.type === 'every') {
    const intervalJobs = this.schedulerRegister.getIntervals();
    if (intervalJobs.includes(job.id)) return;

    if (job.everyMs <= 0 || typeof job.everyMs !== 'number') {
      throw new Error(`任务 ${job.id} 的 everyMs 配置不合法,无法启动`);
    }

    const ref = setInterval(async () => {
      await this.entityManager.update(Job, job.id, { lastRun: new Date() });
    }, job.everyMs);

    this.schedulerRegister.addInterval(job.id, ref);
    return;
  }
}

at 启动定时任务的逻辑

  1. 获取调度器中所有已注册的 Timeout 任务
  2. 判断目标任务是否已在注册表中
    1. 如果已注册 → 直接返回( 任务已在等待执行,避免重复注册 )
    2. 如果未注册 → 验证执行时间 → 计算延迟毫秒数 → 创建 setTimeout 定时器 → 注册到调度器
private startRuntime(job: Job) {
  // ...
  if (job.type === 'at') {
    const timeoutJobs = this.schedulerRegister.getTimeouts();
    if (timeoutJobs.includes(job.id)) return;

    if (!job.at) {
      throw new Error(`任务 ${job.id} 的 at 配置不合法,无法启动`);
    }

    const delay = Math.max(0, job.at.getTime() - Date.now());

    const ref = setTimeout(async () => {
      await this.entityManager.update(Job, job.id, {
        lastRun: new Date(),
        isEnabled: false,
      });

      try {
        this.schedulerRegister.deleteTimeout(job.id);
      } catch (err) {
        this.logger.error(`删除定时器 ${job.id} 失败,可能已经被删除了`, err);
      }
    }, delay);

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

停止定时任务

  1. cron 支持暂停 / 恢复 → 使用 stop() 保留实例
  2. Every / At 不支持暂停 → 只能 delete 彻底清除
private stopRuntime(job: Job) {
  if (job.type === 'cron') {
    const cronJobs = this.schedulerRegister.getCronJobs();
    const existingFlag = cronJobs.get(job.id);
    if (existingFlag) {
      existingFlag.stop();
    }
    return;
  }

  if (job.type === 'every') {
    const intervalsJobs = this.schedulerRegister.getIntervals();
    if (intervalsJobs.includes(job.id)) {
      this.schedulerRegister.deleteInterval(job.id);
    }
    return;
  }

  if (job.type === 'at') {
    const timeoutJobs = this.schedulerRegister.getTimeouts();
    if (timeoutJobs.includes(job.id)) {
      this.schedulerRegister.deleteTimeout(job.id);
    }
    return;
  }
}

JobModel 中导出定时任务

exports: [JobService]

AIModel 中引入定时任务

imports: [JobModule],

AIModel 中引入定时任务的 tool

{
  provide: 'JOB_TOOL',
  useFactory: (jobService: JobService) => {
    return tool(
      async ({
        action,
        instruction,
        type,
        cron,
        everyMs,
        at,
        id,
        enabled,
      }) => {
        switch (action) {
          case 'list': {
            const jobs = await jobService.listJobs();
            if (!jobs.length) {
              return '当前没有定时任务';
            }
            return jobs
              .map(
                (job) =>
                  `ID: ${job.id}, Instruction: ${job.instruction}, Type: ${job.type}, Cron: ${job.cron}, EveryMs: ${job.everyMs}, At: ${job.at?.toISOString()}, IsEnabled: ${job.isEnabled}`,
              )
              .join('\n');
          }
          case 'add': {
            if (!instruction || !type) {
              return '添加任务时,instruction 和 type 是必填的';
            }
            if (type === 'cron' && !cron) {
              return '当 type 为 cron 时,cron 字段是必填的';
            }
            if (type === 'every' && !everyMs) {
              return '当 type 为 every 时,everyMs 字段是必填的';
            }
            if (type === 'at' && !at) {
              return '当 type 为 at 时,at 字段是必填的';
            }

            if (type === 'cron') {
              const createdJob = await jobService.addJob({
                instruction,
                type,
                cron,
                isEnabled: true,
              });
              return `定时任务添加成功,ID: ${createdJob.id}`;
            }

            if (type === 'every') {
              if (typeof everyMs !== 'number' || everyMs <= 0) {
                return 'everyMs 字段必须是一个正整数,表示每隔多少毫秒执行一次';
              }
              const createdJob = await jobService.addJob({
                instruction,
                type,
                everyMs,
                isEnabled: true,
              });
              return `定时任务添加成功,ID: ${createdJob.id}`;
            }

            if (type === 'at') {
              const createdJob = await jobService.addJob({
                instruction,
                type,
                at,
                isEnabled: true,
              });
              return `定时任务添加成功,ID: ${createdJob.id}`;
            }

            return;
          }
          case 'toggle': {
            if (!id) {
              return '切换任务状态时,id 是必填的';
            }
            await jobService.toggleJob(id, enabled ?? false);
            return `任务 ${id} 已经被${enabled ? '启用' : '禁用'}`;
          }

          default:
            return `不支持的操作类型 ${action},请使用 "add"、"list" 或 "toggle"`;
        }
      },
      {
        name: 'job_tool',
        description:
          '这是一个定时任务管理工具,目前提供添加任务和查看任务列表的功能。要添加任务,请提供 instruction(任务内容)和 type(任务类型,cron/every/at)。对于 cron 类型,还需要提供 cron 表达式;对于 every 类型,需要提供每隔多少毫秒执行一次;对于 at 类型,需要提供具体的执行时间。要查看任务列表,可以直接调用这个工具,无需参数。',
        schema: z.object({
          action: z
            .enum(['add', 'list', 'toggle'])
            .describe(
              '要执行的操作,add 表示添加任务,list 表示查看任务列表,toggle 表示切换任务状态',
            ),
          id: z
            .string()
            .optional()
            .describe('任务 ID,在 toggle 操作时需要'),
          enabled: z
            .boolean()
            .optional()
            .describe(
              '任务状态,在 toggle 操作时需要,true 表示启用,false 表示禁用',
            ),
          instruction: z
            .string()
            .optional()
            .describe('任务内容,在添加任务时需要'),
          type: z
            .enum(['cron', 'every', 'at'])
            .optional()
            .describe(
              '任务类型,在添加任务时需要,cron 表示使用 cron 表达式,every 表示每隔一段时间,at 表示在特定时间执行',
            ),
          cron: z
            .string()
            .optional()
            .describe('cron 表达式,在 type 为 cron 时需要'),
          everyMs: z
            .number()
            .optional()
            .describe('每隔多少毫秒执行一次,在 type 为 every 时需要'),
          at: z
            .string()
            .optional()
            .describe(
              '具体的执行时间,格式为 ISO 8601 字符串,例如 "2024-12-31T23:59:00Z",在 type 为 at 时需要',
            ),
        }),
      },
    );
  },
  inject: [JobService],
},

AIService 中引入定时任务的 tool,并绑定到模型

  constructor(
    // ...
    @Inject('JOB_TOOL') private readonly jobTool,
  ) {
    this.modelWithTools = model.bindTools([
      // ...
      this.jobTool,
    ]);
  }

AIService 中补充调用定时任务的 tool

else if (toolCall.name === 'job_tool') {
    const msg = await this.jobTool.invoke(toolCall.args);

    messages.push(
      new ToolMessage({
        tool_call_id: toolCall.id || '',
        name: toolCall.name,
        content: msg as string,
      }),
    );
  }
}

修改大模型的提示词

`你是一个通用任务助手,可以根据用户的目标规划步骤,并在需要时调用工具:`query_user` 查询或校验用户信息、`send_mail` 发送邮件、`web_search` 进行互联网搜索、`db_users_crud` 读写数据库 users 表、`cron_job` 创建和管理定时/周期任务(`list`/`add`/`toggle`),从而实现提醒、定期任务、数据同步等各种自动化需求。

定时任务类型选择规则(非常重要):
- 用户说“X分钟/小时/天后”“在某个时间点”“到点提醒”(一次性)=> 用 `cron_job` + `type=at`(执行一次后自动停用),`at`=当前时间+X 或解析出的时间点
- 用户说“每X分钟/每小时/每天”“定期/循环/一直”(重复执行)=> 用 `cron_job` + `type=every`(每次执行),`everyMs`=X换算成毫秒
- 用户给出 Cron 表达式或明确说“用 cron 表达式”(重复执行)=> 用 `cron_job` + `type=cron`

在调用 `cron_job.add` 创建任务时,需要把用户原始自然语言拆成两部分:一部分是“什么时候执行”(用来决定 type/at/everyMs/cron),另一部分是“要做什么任务本身”。`instruction` 字段只能填“要做什么”的那部分文本(保持原语言和原话),不能再改写、翻译或总结。

当用户请求“在未来某个时间点执行某个动作”(例如“1分钟后给我发一个笑话到邮箱”)时,本轮对话只需要使用 `cron_job` 设置/更新定时任务,不要在当前轮直接完成这个动作本身:不要直接调用 `send_mail` 给他发邮件,也不要在当前轮就真正“执行”指令,只需把要执行的动作写进 `instruction` 里,交给将来的定时任务去跑。

注意:像“`1分钟后提醒我喝水`”,时间相关信息用于计算下一次执行时间,而 `instruction` 应该是“提醒我喝水”;本轮不需要立刻提醒。`

提取出 tool

import { Module } from '@nestjs/common';
import { UsersModule } from 'src/users/users.module';
import { LlmService } from './llm.service';
import { SendMailToolService } from './send_mail_tool.service';
import { TimeNowToolService } from './time-now-tool.service';
import { JobModule } from 'src/job/job.module';
import { WebSearchToolService } from './web-search-tool.service';
import { DbUsersCrudToolService } from './db-users-crud-tool.service';
import { CronJobToolService } from './cron-job-tool.service';

@Module({
  imports: [UsersModule, JobModule],
  providers: [
    LlmService,
    SendMailToolService,
    WebSearchToolService,
    DbUsersCrudToolService,
    CronJobToolService,
    TimeNowToolService,
    {
      provide: 'CHAT_MODEL',
      useFactory: (llmService: LlmService) => llmService.getModel(),
      inject: [LlmService],
    },
    {
      provide: 'SEND_MAIL_TOOL',
      useFactory: (svc: SendMailToolService): unknown => svc.tool,
      inject: [SendMailToolService],
    },
    {
      provide: 'WEB_SEARCH_TOOL',
      useFactory: (svc: WebSearchToolService): unknown => svc.tool,
      inject: [WebSearchToolService],
    },
    {
      provide: 'DB_USERS_CRUD_TOOL',
      useFactory: (svc: DbUsersCrudToolService): unknown => svc.tool,
      inject: [DbUsersCrudToolService],
    },
    {
      provide: 'TIME_NOW_TOOL',
      useFactory: (svc: TimeNowToolService): unknown => svc.tool,
      inject: [TimeNowToolService],
    },
    {
      provide: 'CRON_JOB_TOOL',
      useFactory: (svc: CronJobToolService): unknown => svc.tool,
      inject: [CronJobToolService],
    },
  ],
  exports: [
    'CHAT_MODEL',
    'SEND_MAIL_TOOL',
    'WEB_SEARCH_TOOL',
    'DB_USERS_CRUD_TOOL',
    'TIME_NOW_TOOL',
    'CRON_JOB_TOOL',
  ],
})
export class ToolModule {}

定时任务的 agent loop

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

@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_USERS_CRUD_TOOL') private readonly dbUsersCrudTool: any,
    @Inject('TIME_NOW_TOOL') private readonly timeNowTool: any,
  ) {
    this.modelWithTools = model.bindTools([
      this.sendMailTool,
      this.webSearchTool,
      this.dbUsersCrudTool,
      this.timeNowTool,
    ]);
  }

  async runJob(instruction: string): Promise<string> {
    const messages: BaseMessage[] = [
      new SystemMessage(
        '你是一个用于执行后台任务的智能代理。你会根据给定的任务指令,必要时调用工具(如 db_users_crud、send_mail、web_search、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 String(aiMessage.content ?? '');
      }
      for (const toolCall of toolCalls) {
        const toolCallId = toolCall.id || '';
        const toolName = toolCall.name;

        if (toolName === 'send_mail') {
          const result = await this.sendMailTool.invoke(toolCall.args);
          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: result,
            }),
          );
        } else if (toolName === 'web_search') {
          const result = await this.webSearchTool.invoke(toolCall.args);
          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: result,
            }),
          );
        } else if (toolName === 'db_users_crud') {
          const result = await this.dbUsersCrudTool.invoke(toolCall.args);
          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: result,
            }),
          );
        } else if (toolName === 'time_now') {
          const result = await this.timeNowTool.invoke({});
          messages.push(
            new ToolMessage({
              tool_call_id: toolCallId,
              name: toolName,
              content: JSON.stringify(result),
            }),
          );
        } else {
          this.logger.warn(`未知工具调用: ${toolName}`);
        }
      }
    }

    // return Promise.resolve('00');
  }
}

输入一个【10秒后查询2026年五月份的假期发送到我的邮箱,我的邮箱为xxx@qq.com

数据库中添加一条at提醒

10秒后可以看到邮件

输入【每隔10秒发邮件到我的邮箱提醒我走动,我的邮箱为xxx@qq.com

可以看到数据库中新增一条cron任务

每隔10秒

小结

  • 实现了一个循环调用机制,模型每次返回后,检查是否有工具调用。如果有,执行完把结果放回消息历史,继续让模型处理,直到不再调用工具为止。这样AI就能完成‘先查用户、再发邮件’这类多步骤任务,这不是简单的一问一答,而是循环处理。
  • 在进行流式输出时,模型返回的 chunk 里可能混着工具调用信息,在输出时,要判断tool_call_chunks字段,只输出纯文本内容,这样就能看到实时的用户恢复。
  • 数据库持久化保证任务不丢失,业务层管理调度器,工具层让 AI 能创建任务,处理了三种任务类型的差异 --- cron 可以暂停恢复,every 和 at 只能删除重建。还有一个关键点:应用启动时会自动从数据库中恢复所有的启用的任务,并且会检查是否已注册,避免重复注册。
  • 通过提示词工程来引导模型调用工具,用户说“10秒后提醒我喝水”,就用 at 类型,“每隔10秒”,就用 every 类型。