用 LangChain.js + NestJS 造一个能发邮件、能定时干活的 AI 助手
一篇带你完整走过「工具调用 Agent + 定时任务」全流程的实战博客——用最小代码造一个能搜网页、发邮件、读写数据库、到点自动提醒的 AI 助手,适合想上手 LangChain.js 与 NestJS 的开发者。
Leo
2026.08.31 · 更新于 2026.09.13
从「会说」到「会做」再到「会自己按时做」
引言:大模型「会说」,却不「会做」
你问大模型「今天的天气怎么样」,它能答得头头是道;但你问它「帮我给张三发一封会议通知邮件」,它只会告诉你邮件该怎么写,然后就没有然后了——因为它没有手。
这就是大模型的边界:知识在脑子里,行动在身体外。要把「会说」变成「会做」,业界给的答案是 工具调用(Tool Calling):给模型几把「手」(工具),让它自己决定什么时候用哪只手、怎么用。
而当「会做」再进一步,就是「会自己按时做」:让 AI 到点了自动去执行,比如「1 分钟后提醒我喝水」「每天早上 9 点给我推送行业新闻」。这就需要一个定时任务系统。
本文就用一个真实跑通的项目(cron-job-tool)完整讲一遍这三层进化:
- 会说 → 会做:LangChain.js 工具调用 Agent
- 会做 → 做得好:工具设计 + 提示词工程
- 会做 → 会自己按时做:NestJS 定时任务 + 到点再唤醒一个 Agent
本文所有代码取自项目
cron-job-tool,关键文件会标注路径,方便你对照完整源码。
先看整体:我们要造一个什么样的应用
用户对 AI 助手说一句话,助手自己判断要调哪些工具、按什么顺序调,最后给出答案。如果是「到点做什么」的请求,助手不立即执行,而是创建一个定时任务;到点后系统再唤醒一个「JobAgent」去真正执行。
下面从最基础的概念往上搭。
一、把能力包装成「工具」:tool() 三要素
这是什么
在 LangChain.js 里,一个「工具」就是一个普通函数 + 一份「使用说明书」。函数负责真正干活,说明书负责让模型知道「这个工具什么时候用、参数是什么」。模型不会看你的函数体,它只看说明书。
最小可运行的代码示例
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const queryUser = tool(
// ① 真正干活的函数
async ({ userId }: { userId: string }) => {
const user = usersMap[userId];
return user
? `用户:${user.name},邮箱:${user.email}`
: `用户 ${userId} 不存在`;
},
{
name: "query_user", // ② 模型调用时用的名字
description: "根据用户 ID 查询用户信息,返回姓名、邮箱。", // ③ 写给模型看的说明书
schema: z.object({ // ④ 参数约束,模型据此生成参数
userId: z.string().describe("用户 ID,例如 001、002"),
}),
}
);
要点
- name 是模型在
tool_calls里引用的标识,一旦定了就别改; - description 是写给 LLM 看的,不是给人看的——要写清「何时用、参数语义」,写得好不好直接决定模型会不会在正确时机调用它;
- schema 用 zod 描述参数结构,模型会根据它生成符合格式的 JSON 参数;
- 每个字段加上
.describe(),等于给模型画重点。
二、让模型自己决定「用哪个工具」:Agent 主循环
这是什么
工具定义好了,怎么让模型「用」它们?答案是 bindTools():把工具数组绑到模型上,模型在推理时就「看得见」这些工具,会输出一个 tool_calls 数组,告诉我们它想调哪个工具、传什么参数。
真正的 Agent 是一个循环,业界叫 ReAct(Reason-Act):思考 → 行动 → 观察 → 再思考……
最小可运行的代码示例
import { ChatOpenAI } from "@langchain/openai";
import {
SystemMessage, HumanMessage, AIMessage, ToolMessage,
} from "@langchain/core/messages";
const model = new ChatOpenAI({ model: "qwen-plus", apiKey: process.env.OPENAI_API_KEY });
const modelWithTools = model.bindTools([queryUser]); // 绑定工具
async function runAgent(userQuery: string) {
const messages = [
new SystemMessage("你是一个助手,必要时可以调用工具获取信息。"),
new HumanMessage(userQuery),
];
while (true) {
const aiMessage: AIMessage = await modelWithTools.invoke(messages);
messages.push(aiMessage); // ① 模型本轮输出回填
const toolCalls = aiMessage.tool_calls ?? [];
if (!toolCalls.length) {
return aiMessage.content; // ② 没有要调的工具 → 这就是最终回答
}
for (const call of toolCalls) { // ③ 逐个执行工具
const result = await queryUser.invoke(call.args);
messages.push(new ToolMessage({ // ④ 把结果回填给模型
tool_call_id: call.id, // —— 必须带上这个 id!
name: call.name,
content: result,
}));
}
// ⑤ 带着新信息进入下一轮,直到模型不再调用工具
}
}
要点
bindTools()让模型「看见」工具;不绑就永远不会有tool_calls;- 循环的终止条件是「某一轮没有
tool_calls」,此时content就是最终回答; ToolMessage必须带tool_call_id,并指向对应AIMessage的id——模型靠它把「工具结果」和「自己的调用意图」对应起来,丢了它就乱套;- 工具可以连续调用多轮,比如「先搜用户 → 再发邮件」,模型会分步进行。
三、流式输出:别把模型的「思考过程」漏给用户
这是什么
invoke 要等模型全部生成完才返回,体验像打字机没开。要实时看到文字,用 stream() 逐块拿 AIMessageChunk。但这里有个大坑:当模型本轮要调用工具时,它会先「嘟囔」一堆文字再输出 tool_calls,这些嘟囔是思考过程,不该给用户看。
最小可运行的代码示例
for await (const chunk of modelWithTools.stream(messages)) {
// 边收边拼,得到「到目前为止的完整消息」
fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
// 一旦整条消息里出现过任何工具调用影子,就不再输出文本
const isToolCalling = (fullAIMessage.tool_call_chunks?.length ?? 0) > 0;
if (!isToolCalling && chunk.content) {
yield chunk.content;
}
}
要点
AIMessageChunk用.concat()逐步拼接成完整消息,才能正确判断整体状态;- 用
tool_call_chunks判断本轮是否在调工具——一旦出现影子,后面所有文本都「吞掉」,只执行工具; - 判断的是「整条消息」而不是「当前 chunk」,因为工具调用参数可能拆在多个 chunk 里;
- 流式配合后端 SSE(Server-Sent Events):
@Sse()返回Observable<MessageEvent>,前端用EventSource一行接住。
四、让工具真正干活:接入真实世界
前面用查内存里的用户做例子。真正到项目里,工具要接外部世界:发邮件、搜网页、读写数据库。这一节看几个真实工具的设计套路。
4.1 发邮件:用 z.email() 校验参数
const sendMail = tool(
async ({ to, subject, text }: { to: string; subject: string; text?: string }) => {
await mailerService.sendMail({ to, subject, text: text ?? "(无内容)" });
return `邮件已发送到 ${to},主题「${subject}」`;
},
{
name: "send_mail",
description: "发送电子邮件。需要收件人邮箱、主题,可选文本内容。",
schema: z.object({
to: z.email().describe("收件人邮箱,例如 someone@example.com"),
subject: z.string().describe("邮件主题"),
text: z.string().optional().describe("纯文本内容,可选"),
}),
}
);
要点:zod 有丰富的校验器(z.email()、z.string().min(1)、z.number().int().positive()、z.enum([...])),等于给模型画的参数填上了「类型安全网」,模型生成非法参数的概率大降。
4.2 搜索网页:宁可返回「错误说明」,也不要抛异常
const response = await fetch(url, { /* Bocha Web Search API */ });
if (!response.ok) {
// 把失败原因当普通字符串返回,模型能读懂并据此调整策略
return `搜索 API 请求失败,状态码: ${response.status}, 错误: ${await response.text()}`;
}
要点:工具函数别抛异常,而是把「哪里错了、该怎么补」写成文字返回。因为异常到不了模型,模型只会看到「工具失败」四个字;而友好字符串能让模型在下一轮自己修正参数重试。
4.3 读写数据库:一个工具、一个 action 枚举
一个表五个操作,如果每个都建一个工具会很多。实践上更常用「一个工具 + action 枚举字段」:
const dbUsersCrud = tool(
async ({ action, id, name, email }) => {
switch (action) {
case "list": return (await usersService.findAll()).map(fmt).join("\n");
case "get": return fmt(await usersService.findOne(id));
case "create": return `已创建用户 ID=${id}`;
case "update": return `已更新用户 ID=${id}`;
case "delete": return `已删除用户 ID=${id}`;
default: return `不支持的操作: ${action}`;
}
},
{
name: "db_users_crud",
description: "对数据库 users 表增删改查,通过 action 选择 create/list/get/update/delete。",
schema: z.object({
action: z.enum(["create", "list", "get", "update", "delete"]),
id: z.number().int().positive().optional(),
name: z.string().min(1).max(50).optional(),
email: z.email().optional(),
}),
}
);
要点:CRUD 类工具适合「一工具多 action」——省 token、好维护、模型也更不容易混淆。
五、提示词工程:告诉模型「什么时候用哪个」
这是什么
工具是「手」,但什么时候伸哪只手,取决于 SystemMessage 里的规则。这是决定 Agent 行为正确与否的最关键的提示词。
项目里的真实规则(摘自 ai.service.ts)
定时任务类型选择规则(非常重要):
- "X分钟/小时/天后""在某个时间点""到点提醒"(一次性)
=> 用 cron_job + type=at(执行一次后自动停用)
- "每X分钟/每小时/每天""定期/循环/一直"(重复执行)
=> 用 cron_job + type=every,everyMs=换算成毫秒
- 给出 Cron 表达式 => 用 cron_job + type=cron
要点
- 把「决策规则」写进 SystemMessage:模型不是神,你不告诉它规则,它就自由发挥;
- 拆分「何时做」与「做什么」:明确要求
instruction字段只填任务内容、保持用户原话,不填时间信息、不改成工具调用/脚本; - 阻断当前轮的即时执行:明确写「设置定时任务即可,不要现在就去发邮件」——否则模型会在这一轮把未来任务提前干掉了;
- 这一块是 Agent 应用的「隐藏工作量」,调得好不好,直接决定模型会不会滥用工具。
六、给 AI 装个闹钟:三类定时任务
这是什么
「会做」之后,我们要让它「到点自动做」。NestJS 的 @nestjs/schedule 提供 SchedulerRegistry 调度器,底层其实就是三个东西:CronJob(cron 表达式)、setInterval、setTimeout。项目把三种任务都接上了:
| 类型 | 底层运行时 | 语义 | 结束条件 |
|---|---|---|---|
cron | CronJob | 按 Cron 表达式循环 | 手动停用 |
every | setInterval | 按固定毫秒间隔循环 | 手动停用 |
at | setTimeout | 指定时间点执行一次 | 执行后自动停用 |
最小可运行的代码示例(核心片段)
// 启动时:把数据库里启用的任务重新注册进调度器(重启不丢)
async onApplicationBootstrap() {
const jobs = await this.entityManager.find(Job, { where: { isEnabled: true } });
for (const job of jobs) {
if (this.isAlreadyRegistered(job)) continue; // 幂等,避免重复注册
await this.startRuntime(job);
}
}
private startRuntime(job: Job) {
if (job.type === "cron") {
const j = new CronJob(job.cron!, () => this.onTick(job));
this.schedulerRegistry.addCronJob(job.id, j);
j.start();
} else if (job.type === "every") {
const ref = setInterval(() => this.onTick(job), job.everyMs!);
this.schedulerRegistry.addInterval(job.id, ref);
} else if (job.type === "at") {
const delay = Math.max(0, job.at!.getTime() - Date.now());
const ref = setTimeout(async () => {
await this.onTick(job);
await this.disableAndRemove(job); // at:跑完自动停用并移除
}, delay);
this.schedulerRegistry.addTimeout(job.id, ref);
}
}
要点
- 三个类型共用一张
Job表,cron / everyMs / at三个字段互斥,非本类型字段置null; - 持久化 + 启动重注册:任务存 MySQL,应用重启时
onApplicationBootstrap重新挂到调度器,实现「重启不丢」; - 幂等注册:注册前查调度器是否已有同名任务,防止重复注册导致重复执行;
running状态双核对:数据库isEnabled为真 且 调度器里已注册,才算真的在跑;at类型跑完要deleteTimeout移除,并落库isEnabled=false。
七、到点怎么执行:JobAgent 二次唤醒
这是什么
定时任务到点后,instruction 字段里存的是一句自然语言(比如「提醒我喝水」「给某用户发一封周报」)。谁去执行这句话?答案是——再唤醒一个 Agent(JobAgentService)。它和主 Agent 是同一个循环,只是 SystemMessage 换成「你是执行后台任务的代理」,让它自己决定调 send_mail 还是 db_users_crud。
要点
- 这就是「Agent 调用 Agent」:主 Agent 负责规划,JobAgent 负责执行,职责分离;
- 因为 instruction 被提示词约束为自然语言(不允许写成
send_mail(...)脚本),执行细节完全交给 JobAgent 现场判断,天然安全、可扩展; - 主 Agent 的
cron_job.add工具负责「写入」,JobAgent 负责「读取并执行」,两者通过数据库解耦。
八、附:模块依赖怎么搭起来的
这是什么
前面讲了 Agent、工具、定时任务,这些服务在工程里是怎么串起来的?答案是 NestJS 的模块化依赖注入:每个模块把自己的「能力」通过 exports 提供出去,别的模块 imports 之后才能注入使用。在这个项目里,一个工具类、一个 Agent,在 NestJS 眼里都只是「一个可注入的 provider」。
整体:模块之间的 import 关系
注意:
AppModule没有直接 importToolModule,它是被AiModule、JobModule间接拉进来的。AiModule和JobModule都从ToolModule拿工具,但各自的CHAT_MODEL不是同一个(见下)。
循环依赖与 forwardRef
ToolModule 的 CronJobToolService 需要 JobService(在 JobModule);而 JobModule 的 JobAgentService 需要 ToolModule 的工具 token。两个模块互相 import,Nest 在启动时就会撞上「先有鸡还是先有蛋」,所以必须用 forwardRef(() => ...) 打破:
要点
- 跨模块依赖的铁律:提供方
exports、使用方imports,缺一不可; - 字符串 token 注入:
@Inject('SEND_MAIL_TOOL')注入的是「不是类」的对象,靠provide + useFactory + inject三件套生成; - 同名 token 模块隔离:两个
CHAT_MODEL各归各模块,AiService和JobAgentService拿到的是不同实例——验证方法是在两个构造器里各打一份model,配置是不一样的; - 第三方依赖也能注入:
ConfigService / MailerService / EntityManager / SchedulerRegistry都是通过 import 对应模块拿到的。
九、结尾总结:一张图记住全流程
对照表:什么时候用哪层能力
| 需求 | 用到的能力 |
|---|---|
| 「查一下张三是谁」 | 工具 + Agent 循环 |
| 「把结果实时打出来」 | 流式输出 + SSE |
| 「帮我把表里的数据整理好」 | 工具设计 + 提示词工程 |
| 「1 分钟后提醒我喝水」 | cron_job + type=at |
| 「每 10 分钟检查一次邮箱」 | cron_job + type=every |
「按 */5 * * * * 执行」 | cron_job + type=cron |
| 「到点了谁去执行」 | JobAgent 二次唤醒 |
收尾
从「会说」到「会做」,靠的是 工具调用 + Agent 循环;从「会做」到「做得好」,靠的是 工具设计 + 提示词工程;从「会做」到「会自己按时做」,靠的是 定时任务 + JobAgent 二次唤醒。这三层加起来,就是一个能真正替人干活的 AI 助手——而每一层都只用到了最基本的几个概念:一个 tool()、一个 while(true)、一个 setTimeout。
建议动手顺序:先跑通一个 query_user 工具的 Agent 循环 → 加上流式输出 → 把工具换成真实的发邮件/搜索 → 再加 cron_job 和 JobAgent。每加一层,都试着用中文让它「干一件跨界的事」,你会越来越理解模型的能力边界在哪。
读者留言
COMMENTS · 0发表留言