搭建项目
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 {}
同步接口
获取用户信息逻辑:
- 定义根据用户 id 查询信息的 tool
- 将工具绑定到模型,让模型知道可以调用这个工具
- 每次询问大模型后,检查返回结果中是否有工具调用
-
- 有工具调用 ==> 执行对应工具,将结果放入消息历史,继续询问模型
- 没有工具调用 ==> 直接返回模型的回答
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 };
}
流式接口
获取用户信息的逻辑:
- 调用模型的流式接口,边接收 chunk 边实时判断:
-
- 如果当前 chunk 还未涉及工具调用 ==> 立即 yield 文本内容
- 将 chunk 拼接到完整的 AIMessage
- 若本轮没有工具调用 ==> 结束循环
- 如果有工具调用 ==> 执行工具,将结果包装成 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:
-
获取数据库中所有启用的定时任务配置
-
获取当前调度器中已注册的所有任务(包括静态装饰器注册的)
-
遍历数据库中的每个任务:
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 启动定时任务逻辑
- 获取调度其中所有已注册的定时任务
- 判断目标任务是否在注册表中
-
- 如果已注册 → 直接启动定时任务
- 如果未注册 → 创建任务实例 → 注册到调度器 → 启动任务
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 启动定时任务的逻辑
- 获取调度器中所有已注册的 Interval 任务
- 判断目标任务时都已在注册表中
-
- 如果已注册 → 直接返回( 任务已在运行,避免重复 )
- 如果未注册 → 验证时间间隔 → 创建 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 启动定时任务的逻辑
- 获取调度器中所有已注册的 Timeout 任务
- 判断目标任务是否已在注册表中
-
- 如果已注册 → 直接返回( 任务已在等待执行,避免重复注册 )
- 如果未注册 → 验证执行时间 → 计算延迟毫秒数 → 创建 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;
}
}
停止定时任务
- cron 支持暂停 / 恢复 → 使用 stop() 保留实例
- 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 类型。