主题
单 Agent 多工具
要点
- 真正写应用时,更常见的做法是直接把几个工具交给一个 Agent,让它自己决定这一轮怎么走。
- 单 Agent 多工具适合:一句话里有多件事,但都属于同一个角色在帮用户办事。
createAgent(...)内部会自动维护「模型请求工具 -> 执行工具 -> 回传结果」这条循环。- 调试时,先看
result.messages往往比先改 Prompt 更有用。 - 当工具越来越多、出现不同角色分工、需要审批或人工介入时,才需要考虑拆成多 Agent。
1. 背景:一个很常见的使用场景
前面一篇把 Tool Calling 拆开看了一遍,原理已经清楚了。真正写应用时,我们很少停在「模型先提请求,我再手动执行工具」这一步,更常见的是直接把几个工具交给一个 Agent,让它自己决定这一轮怎么走。
技术运维助手里很容易遇到这样的请求:
text
payment 服务现在状态怎么样?如果错误率超过 5%,就创建一个高优先级工单。顺便看看明天有没有发布窗口。这不是三个独立问题,而是一句话里的三件事:
- 先查服务状态。
- 如果状态结果触发条件,再创建工单。
- 再去查一次排期。
这种场景正适合单个 Agent。因为这里还没有出现多个角色分工,也没有复杂审批流。我们只是希望有一个 Agent 能在同一轮里连续用几个工具,把事情做完,然后给用户一个自然的回复。
2. 先看最后要做成什么样
先不急着拆代码,先看最后调用时应该长什么样。
typescript
const result = await agent.invoke({
messages: [
{
role: "user",
content:
"payment 服务现在状态怎么样?如果错误率超过 5%,就创建一个高优先级工单。顺便看看明天有没有发布窗口。",
},
],
});
console.log(result.messages.at(-1)?.text);真正对外暴露的入口,最好就这么简单。调用这一行代码时,我们希望 Agent 能自己完成这些事:
- 先判断这句话里需要哪些工具。
- 先查服务状态。
- 如果状态结果满足条件,再去创建工单。
- 再查排期。
- 最后把结果整理成一段像人说的话。
前面手动循环那篇,是为了把过程讲透。从这一篇开始,就不再让调用方手动维护那条循环了。
3. 先把三个工具准备好
这三个工具本身都不复杂,关键是职责要清楚。
typescript
import { tool } from "langchain";
import { z } from "zod";
// 只负责查服务状态。
// 正式项目里,这里通常会请求监控 API。
export const getServiceStatus = tool(
async ({ service }) => {
const data: Record<string, string> = {
order: "order 服务运行正常,QPS 1200,P99 180ms,错误率 0.1%",
payment: "payment 服务出现告警,QPS 900,P99 320ms,错误率 5.2%",
inventory: "inventory 服务运行正常,QPS 800,P99 150ms,错误率 0.05%",
};
return data[service] ?? `${service}:暂无状态数据`;
},
{
name: "get_service_status",
description: "查询某个微服务当前的运行状态,包括 QPS、延迟和错误率",
schema: z.object({
service: z
.string()
.describe("要查询的服务名,例如 order、payment、inventory"),
}),
},
);
// 只负责创建工单。
// 返回对象会比纯字符串更清楚,后面模型也更容易读。
export const createTicket = tool(
async ({ title, severity }) => {
return {
ok: true,
ticketId: `TICKET-${Date.now()}`,
message: `工单已创建:${title},级别 ${severity}`,
};
},
{
name: "create_ticket",
description: "当服务出现异常时,创建一个告警工单",
schema: z.object({
title: z.string().describe("工单标题"),
severity: z
.enum(["low", "medium", "high", "critical"])
.describe("工单严重级别"),
}),
},
);
// 只负责查排期。
// 正式项目里,这里通常会查日历服务或发布系统。
export const querySchedule = tool(
async ({ date }) => {
const schedules: Record<string, string> = {
今天: "14:00 架构评审会",
明天: "10:00 发布窗口,16:00 值班交接",
后天: "全天无日程",
};
return schedules[date] ?? `${date}:没有找到排期`;
},
{
name: "query_schedule",
description: "查询团队某一天的工作安排或发布窗口",
schema: z.object({
date: z.string().describe("要查询的日期,例如 今天、明天、后天"),
}),
},
);这里最容易写乱的地方,其实不是 tool() 这个 API,而是边界。如果一个工具里既查状态又建工单又查排期,模型会更难选,也更难把参数填稳。把工具拆得明确一点,后面调试会轻松很多。
4. 把它们交给同一个 Agent
到这里,Agent 的代码反而比工具本身还短。
typescript
import { createAgent } from "langchain";
import { getServiceStatus, createTicket, querySchedule } from "./task-tools";
const agent = createAgent({
model: "openai:gpt-4.1-mini",
tools: [getServiceStatus, createTicket, querySchedule],
systemPrompt: `
你是技术运维助手。
当用户的问题涉及服务状态、工单和排期时,使用对应工具。
如果一句话里有多件事,就按顺序处理。
如果用户只是在询问技术问题,就直接回复,不要强行调用工具。
`.trim(),
});这里有一个变化很重要。前一篇里,我们自己维护:
- 模型返回
tool_calls - 程序执行工具
- 再把工具结果交还给模型
这一篇里,这段循环已经交给 createAgent(...) 了。所以你在入口上只看到一次 agent.invoke(...),但 Agent 内部可能已经跑了不止一次工具调用。
5. 一轮请求里,Agent 可能会连续做几件事
先看一段完整调用:
typescript
import { createAgent } from "langchain";
import { getServiceStatus, createTicket, querySchedule } from "./task-tools";
const agent = createAgent({
model: "openai:gpt-4.1-mini",
tools: [getServiceStatus, createTicket, querySchedule],
systemPrompt: `
你是技术运维助手。
当用户的请求涉及服务状态、工单和排期时,使用对应工具。
如果一句话里有多件事,就按顺序处理。
`.trim(),
});
const result = await agent.invoke({
messages: [
{
role: "user",
content:
"payment 服务现在状态怎么样?如果错误率超过 5%,就创建一个高优先级工单。顺便看看明天有没有发布窗口。",
},
],
});
// 最后一条消息就是这一轮最终回复。
// 前面的工具调用和中间结果,Agent 已经替我们处理好了。
console.log(result.messages.at(-1)?.text);这段代码对外只有一个入口,但 Agent 在内部很可能会走成这样:
text
1. 先调用 get_service_status({ service: "payment" })
2. 读到状态结果是 "payment 服务出现告警,错误率 5.2%"
3. 再调用 create_ticket({ title: "payment 服务错误率超标", severity: "high" })
4. 再调用 query_schedule({ date: "明天" })
5. 最后把状态、工单、排期合并成一段回复也就是说,单个 Agent 不是「一次请求只能调一个工具」。只要这句话里确实需要多步动作,它可以在同一轮里连续往下走。
如果你想把这个过程看得更明显一点,可以直接把消息打印出来:
typescript
for (const message of result.messages) {
// 这里通常能看到:
// user 消息
// assistant 发起的工具调用
// tool 返回结果
// assistant 最终回复
console.log(message.getType(), message.text ?? message.content);
}这一段很适合调试。当你怀疑 Agent 为什么没调工具、或者调错了工具,先看 result.messages 往往比先改 Prompt 更有用。
6. 什么时候一个 Agent 还够用
写到这里,单个 Agent 已经能做不少事了。像下面这种情况,单个 Agent 往往就够:
用户一句话里有几步动作,但都属于同一个角色在帮他办事。比如查状态、创建工单、查排期、查知识库、发一条总结,这些都还是同一个运维助手的职责。这时候继续拆成多个 Agent,收益不一定大,反而会先把结构变复杂。单个 Agent 先把这些工具接稳,通常是更顺的做法。
真正开始需要再往下拆,一般是因为下面这些情况开始出现了:
- 工具越来越多,描述开始互相打架。
- 一轮请求里不仅是多步动作,还开始出现不同角色分工。
- 某些步骤需要长期状态、审批、回退或人工介入。
那时就不是这一篇要解决的问题了。
7. 写这种 Agent 时,最容易卡住的地方
最常见的卡点通常不是 createAgent(...) 本身,而是下面这几种情况。
工具的描述太短,模型不知道什么时候该用它。
比如 description 只写「查询信息」,那和不写差不多。描述里最好直接点出场景和用途。
工具边界太宽,一个工具里包了太多动作。
模型要先猜 action,再猜 payload,最后还要靠你在函数里二次分发,这一层很容易出问题。
一句话里有多件事,但 systemPrompt 没告诉 Agent 可以按顺序处理。
这时模型有时会只做第一件,后面的动作直接漏掉。
还有一种很常见:工具其实已经调了,但你只盯着最终回复,没看中间消息。
这时最简单的办法不是猜,而是把 result.messages 打出来看。
单个 Agent 调多个工具,说到底没有多神秘。前面几篇准备的东西,走到这里刚好接上:消息、Prompt、LCEL、检索、Tool,最后都汇成这一个入口。