✦ 大道至简 · 时光是画在卷上的河流 · 行到水穷处,坐看云起时

agentlangchainnestjstool-calling2026.08.31 · 31 分钟阅读

用 LangChain.js + NestJS 造一个能发邮件、能定时干活的 AI 助手

一篇带你完整走过「工具调用 Agent + 定时任务」全流程的实战博客——用最小代码造一个能搜网页、发邮件、读写数据库、到点自动提醒的 AI 助手,适合想上手 LangChain.js 与 NestJS 的开发者。

L

Leo

2026.08.31 · 更新于 2026.09.13

3 次浏览
用 LangChain.js + NestJS 造一个能发邮件、能定时干活的 AI 助手

从「会说」到「会做」再到「会自己按时做」

引言:大模型「会说」,却不「会做」

你问大模型「今天的天气怎么样」,它能答得头头是道;但你问它「帮我给张三发一封会议通知邮件」,它只会告诉你邮件该怎么写,然后就没有然后了——因为它没有手。

这就是大模型的边界:知识在脑子里,行动在身体外。要把「会说」变成「会做」,业界给的答案是 工具调用(Tool Calling):给模型几把「手」(工具),让它自己决定什么时候用哪只手、怎么用。

而当「会做」再进一步,就是「会自己按时做」:让 AI 到点了自动去执行,比如「1 分钟后提醒我喝水」「每天早上 9 点给我推送行业新闻」。这就需要一个定时任务系统。

本文就用一个真实跑通的项目(cron-job-tool)完整讲一遍这三层进化:

  1. 会说 → 会做:LangChain.js 工具调用 Agent
  2. 会做 → 做得好:工具设计 + 提示词工程
  3. 会做 → 会自己按时做: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。项目把三种任务都接上了:

类型底层运行时语义结束条件
cronCronJob按 Cron 表达式循环手动停用
everysetInterval按固定毫秒间隔循环手动停用
atsetTimeout指定时间点执行一次执行后自动停用

最小可运行的代码示例(核心片段)

// 启动时:把数据库里启用的任务重新注册进调度器(重启不丢)
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 没有直接 import ToolModule,它是被 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。每加一层,都试着用中文让它「干一件跨界的事」,你会越来越理解模型的能力边界在哪。

L

Leo

博主

独立开发者 / Blogger,原博客「大道至简」维护者。正在把 WordPress 上攒了几年的文章与拾语迁移到 Next.js。

读者留言

COMMENTS · 0

发表留言

评论经审核后展示 · 请友善发言0/100