一. Openclaw 的定时需求
给大模型说:你帮我定个闹钟,定到9:00,我要起床去上班。
第二天,你手机的闹钟就响了。
分析openclaw的代码,我们发现它走的是以下流程:
最主要的就是这个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
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 })));
}
}
具体如下:
启动项目测试
对比两个方法
从上面的对比我们可以看到
- const aiMessage = await this.modelWithTools.invoke(messages); 一次性可以拿到所有的返回数据,然后找到对应工具去处理,处理以后返回给用户就好。
- const stream = await this.modelWithTools.stream(messages); 是生成一个数据就返回一个数据,需要把这些chunk包拼接在一起,才能组成一个完整的答案。用
fullAIMessage = fullAIMessage.concat(chunk)把流式片段拼成一个完整 AI 消息对象,因为工具调用信息可能分片到达。
3.将人员tool提取到其他文件里面
原来,关于人员的增删改查都是放在ai.service.ts里面的,这样代码显得比较凌乱。
我们进行优化,就是将 queryUserTool的这部分业务拆分出去。
在建立一个文件user.service.ts--创建 UserService 类,理由有一个数据users,对users进行增删改查。目录如下
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
修改完之后跑一下
三. 开发发送邮件的tool
1.获取邮件授权码
首先获取你邮箱的授权码,步骤如下:打开有设置,找到安全上设置,点击生成授权码。
2.安装插件
在项目里面安装 @nestjs-modules/mailer用来发送邮件。
pnpm install nodemailer @nestjs-modules/mailer
3.在app.module.ts里面注册邮件信息
在.env里面添加常量
在 app.module 里面注册信息
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
访问:http://localhost:3000/ai/chat/stream?query=查询下002的信息,然后发送邮件到545442742@qq.com里面,邮箱主题是002的信息,内容是查询到的信息
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
先登录,然后进入博查控制台,
2.购买资源包
进入资源包管理,购买最便宜的那个
3.将博查的apikey写到.env里面
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,
}),
);
}
}
结果就是在你的邮箱里面收到一份邮件
五.安装msql
1.安装mysel-server
安装mysql需要先安装mysql服务,安装地址:dev.mysql.com/downloads/m… 安装上之后
在这个里面设置数据库的用户名,密码等基础信息。
2.安装mysql的客户端,方便查看数据表信息
下载地址:dev.mysql.com/downloads/f…
打开后和mysql-service连接
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 设计,用于在应用中便捷操作数据库。
typeorm 的作用就是把 class 和 数据库表 对应。
4.创建user模块
执行命令:
pnpm install --save @nestjs/typeorm typeorm mysql2
nest g resource users --no-spec
选择REST API 和 Yes
最后你的目录是这样的
你可以在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上,方便管理。
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
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语句。
然后你还能在数据库看到新增的数据。
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
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
11.测试数据
在浏览器上访问地址:http://localhost:3000/ai/chat/stream?query=先帮我王数据库里面添加一个id是002的用户,姓名是snow,邮箱是12345665@qq.com
查看mysql,你会发现添加好了数据
#七.开发定时任务的tool
1.安装资源包并介绍基本用法
pnpm install cron @nestjs/schedule
@nestjs/schedule —— Nest 官方的「定时任务集成包」
-
作用:给 NestJS 提供装饰器式定时任务,让你用
@Cron()、@Interval()、@Timeout()在 Service 方法上直接声明「什么时候跑」。 -
底层:它内部就是基于
cron包(你装的另一个)来解析 cron 表达式、注册定时器。 -
用法三步走:
-
根模块
imports: [ScheduleModule.forRoot()] -
在
@Injectable()Service 里写方法 -
用装饰器标上去
-
@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);
}
}
导入定时器
class AppModule 继承 OnApplicationBootstrap,然后重写onApplicationBootstrap方法,执行测试定时器。 重启项目
npm run start:dev
3.持久化定时器
3.1 创建定时器目录job
只新增一个module文件:nest g module job
只新增一个serivice文件:nest g service job --no-spec
和上面的job对比,新增一个模块用 nest g resource job2 --no-spec
对比上述命令你发现了什么?
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
3.3 写job.service.ts
3.3.1 SchedulerRegistry和CronJob的关系是什么?
这俩是 "管家 vs 工人" 的关系,层级完全不同。
SchedulerRegistry —— NestJS 提供的注册表/容器,它自己不执行任何任务,只负责"登记、查找、销毁"。你的三类任务(cron / interval / timeout)全都挂在它名下。CronJob ——cron这个第三方库提供的具体任务对象,只服务于 cron 这一种类型。它懂得怎么解析'0 */5 * * * *'这种表达式,并自己持有start()/stop()的开关。在代码里面主要用于创建cronJob对象。
| **** | SchedulerRegistry | CronJob |
|---|---|---|
| 来源 | @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 所有方法的汇总
启动第一步
上述代码,同一个任务,同时存在于两个地方:
┌─────────────────────────┐ ┌──────────────────────────────┐
│ 数据库 job 表(持久) │ │ 内存 SchedulerRegistry(临时)│
│ │ │ │
│ id, type, cron, │ │ cron: [ 'job1', 'job3' ]│
│ everyMs, at, │ │ interval: [ 'job2' ] │
│ isEnabled, lastRun │ │ timeout: [ 'job4' ] │
│ │ │ │
│ 「应该运行吗?」 │ │ 「现在真的在跑吗?」 │
└─────────────────────────┘ └──────────────────────────────┘
▲ │
│ │
└──── stopRuntime 注销 ──────────────────┘
└──── startRuntime 注册 ─────────────────►
整个服务的所有方法,本质上都在做一件事:让这两边保持一致。
SchedulerRegistry 就是右边那个"内存中正在运行的任务花名册";CronJob 是花名册里 cron 类型任务的具体对象(自带 start/stop 开关)。
startRuntime里面的三个任务类型:
3.3.4注释
假设用户输入:"每天早上 9 点提醒我喝水"
- 前端调
addJob({ type: 'cron', cron: '0 0 9 * * *', instruction: '提醒喝水' }) addJob往数据库插一条记录,isEnabled = truesaved.isEnabled为真 → 调startRuntimestartRuntime发现内存里没有 →createCronJob造对象 → 登记 →start()- 每天早上 9 点,回调触发:打印日志,更新
lastRun - 用户点"暂停" →
toggleJob(id)→ 数据库改isEnabled=false→stopRuntime→cronJob.stop() - 用户点"恢复" →
toggleJob(id)→ 改库 →startRuntime→ 发现对象还在 → 直接start() - 服务重启 →
onApplicationBootstrap扫库 → 发现这条启用中且内存没有 → 重新挂上
3.4 导出jobService
3.5 在ai.module.ts里面引入jobmodule
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,
这三个方法在定时器的tool里面都有用到:
相当于是把tool里面用到的定时器的方法都封装到了job.service.ts里面了。
3.7 在ai.service.ts里面使用定时器的tool
3.8 测试代码
http://localhost:3000/ai/chat/stream?query=每小时提醒我起来走一走,活动一下筋骨
http://localhost:3000/ai/chat/stream?query=十分钟后提醒我喝水
http://localhost:3000/ai/chat/stream?query=每晚十点钟提醒我写今日总结
查看数据库
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里面导出
3.9.3 job导入job-agent.service.ts
在job.module.ts文件里面导aiModule就好了。
3.9.4 在job.service.ts里面使用job-agent.service.ts
注入JobAgentService服务,用于执行任务指令。
3.9.5 启动
这个错误不是 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,加好以后重启项目
浏览器访问接口
http://localhost:3000/ai/chat/stream?query=一分钟后发送邮件到我的邮箱%20545442742@qq.com%20提醒我出去条广场舞
http://localhost:3000/ai/chat/stream?query=一分钟后查询上海985高校信息,作为用户保护,用户名是学校名,用户邮箱是学校邮箱,生成精美html发到我的邮箱545442742@qq.com
八.问答总结
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 只解决了"响",另外两个解决"复杂时间的响"和"响之前能管住" 。