目录
- [一、什么是 Agent 管理工具,为什么需要它](#一、什么是 Agent 管理工具,为什么需要它)
- [二、整体架构:langchain + Node + Vue](#二、整体架构:langchain + Node + Vue)
- [三、搭建管理器后端(Node + langchain JS)](#三、搭建管理器后端(Node + langchain JS))
- [1. 项目结构](#1. 项目结构)
- [2. 安装依赖](#2. 安装依赖)
- [3. 配置 DeepSeek API Key](#3. 配置 DeepSeek API Key)
- [4. 编写注册表 lib/registry.js](#4. 编写注册表 lib/registry.js)
- [5. 编写总控 Agent lib/supervisor.js](#5. 编写总控 Agent lib/supervisor.js)
- [6. 编写服务入口 server/index.js](#6. 编写服务入口 server/index.js)
- [四、搭建管理器前端(Vue 3)](#四、搭建管理器前端(Vue 3))
- [五、接入天气 Agent:注册即接入](#五、接入天气 Agent:注册即接入)
- [六、总控 Agent 为什么能调度其他 Agent](#六、总控 Agent 为什么能调度其他 Agent)
- 七、实测效果
- 八、下一步:还能做什么
一、什么是 Agent 管理工具,为什么需要它
前两篇我们分别做了两个独立的小 Agent:天气 Agent 和本地笔记问答 Agent。它们各自都能干活,但 Agent 一多,问题就来了:
- 每个 Agent 有自己的启动命令、配置文件和访问入口,管理分散;
- 想和哪个 Agent 说话,就得打开哪个 Agent 的界面,来回切换;
- 每个 Agent 只会自己的一件事,用户得先想清楚"这个问题该问谁"。
Agent 管理工具解决的就是这三个问题。可以把它类比为公司里的调度中心:
- 天气 Agent、笔记 Agent 是员工,各有专业技能;
- 管理工具是总机,手里有一份员工通讯录(注册表);
- 你可以直接找某个员工 (直连对话),也可以找总机(总控 Agent),由总机判断该把问题转给谁,再把答复汇总给你。
它同时提供两种用法:
| 用法 | 说明 | 适合场景 |
|---|---|---|
| 直连对话 | 管理器把消息原样转发给目标 Agent,不多做处理 | 明确知道该问谁 |
| 总控调度 | 管理器内置一个 langchain 总控 Agent,自动判断并调用合适的子 Agent | 不想自己选,或一个问题涉及多个 Agent |
二、整体架构:langchain + Node + Vue
技术选型与职责划分:
| 层 | 技术 | 职责 |
|---|---|---|
| 前端 | Vue 3 + Vite | 左侧 Agent 列表,中间对话框 |
| 后端 | Node + Express | Agent 注册表、对话转发、总控对话接口 |
| 智能 | langchain JS | 总控 Agent(createAgent + 工具调用) |
整体数据流:
text
浏览器(Vue 前端,端口 15180)
↓ /api 请求
Node 管理器(端口 18001)
├─ agents.json 注册表:有哪些 Agent、接口地址是什么
├─ /api/agents/:id/chat:把消息转发给指定子 Agent
└─ /api/chat:langchain 总控 Agent
├─ list_agents 工具:查看通讯录
└─ ask_agent 工具 ──→ 子 Agent 服务(如天气 Agent,端口 18000)
└─ 子 Agent 自己调模型、调自己的工具
这里有一个关键设计:管理器不替子 Agent 干活,只管"问谁"和"怎么问"。
天气 Agent 本来就是一个独立的 Node 服务,暴露了 POST /api/chat 接口。管理器对子 Agent 只有一个要求:暴露同样的 HTTP 契约。这意味着任何一个 Agent,不管它是 Node 写的、Python 写的,只要提供这个接口,就能被管理器接入和调度。
text
POST {endpoint}
请求体:{"messages": [{"role": "user", "content": "..."}]}
响应体:{"reply": "..."}
三、搭建管理器后端(Node + langchain JS)
1. 项目结构
text
agent-demo-manager/
├── server/ # Node 后端
│ ├── index.js # Express:注册表 CRUD、对话转发、总控对话
│ ├── agents.json # 子 Agent 注册表
│ └── lib/
│ ├── registry.js # 注册表读写 + 健康检查
│ └── supervisor.js # langchain 总控 Agent
├── web/ # Vue 前端
│ └── src/App.vue
└── README.md
2. 安装依赖
bash
mkdir agent-demo-manager && cd agent-demo-manager
mkdir server && cd server
npm init -y
npm install express langchain @langchain/core @langchain/openai zod
package.json 中记得设置 "type": "module",使用 ES Module 语法。
依赖用途:
| 包 | 作用 |
|---|---|
| express | Web 框架,提供注册表与对话接口 |
| langchain | createAgent,创建总控 Agent |
| @langchain/core | tool,把函数包装成模型可调用的工具 |
| @langchain/openai | ChatOpenAI,通过 OpenAI 兼容协议连接 DeepSeek |
| zod | 定义工具参数的 schema |
3. 配置 DeepSeek API Key
在 server/ 目录创建 .env 文件(项目根目录也可以,两个位置都会读取):
env
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_API_KEY=sk-你的_DeepSeek_API_Key
OPENAI_MODEL=deepseek-flash
PORT=18001
注意:不要把 API Key 写进代码,也不要把 .env 上传到 GitHub。DeepSeek 获取 API Key 的方法可以参考之前的文章。模型名 deepseek-chat、deepseek-reasoner 是旧名,官方现在使用 deepseek-flash(支持工具调用、便宜)和 deepseek-v4-pro(能力更强)。
4. 编写注册表 lib/registry.js
注册表就是一份 JSON 名单,记录每个子 Agent 的 id、名称、描述和接口地址:
js
// server/lib/registry.js
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const registryPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents.json');
export function loadAgents() {
try {
return JSON.parse(readFileSync(registryPath, 'utf8'));
} catch {
return [];
}
}
function saveAgents(agents) {
writeFileSync(registryPath, JSON.stringify(agents, null, 2) + '\n', 'utf8');
}
export function findAgent(id) {
return loadAgents().find((a) => a.id === id);
}
export function addAgent({ id, name, description = '', endpoint, healthUrl = '' }) {
if (!id || !name || !endpoint) throw new Error('id、name、endpoint 为必填项');
if (!/^https?:\/\//.test(endpoint)) throw new Error('endpoint 必须是 http(s) 地址');
const agents = loadAgents();
if (agents.some((a) => a.id === id)) throw new Error(`Agent id 已存在:${id}`);
const agent = { id, name, description, endpoint };
if (healthUrl) agent.healthUrl = healthUrl;
agents.push(agent);
saveAgents(agents);
return agent;
}
export function removeAgent(id) {
const agents = loadAgents();
const next = agents.filter((a) => a.id !== id);
if (next.length === agents.length) return false;
saveAgents(next);
return true;
}
// 返回 true/false;未配置健康检查地址时返回 null(未知)
export async function checkHealth(agent, timeoutMs = 2000) {
if (!agent.healthUrl) return null;
try {
const resp = await fetch(agent.healthUrl, { signal: AbortSignal.timeout(timeoutMs) });
return resp.ok;
} catch {
return false;
}
}
healthUrl 是可选的:配置了它,前端列表就能显示这个 Agent 当前是否在线(绿色/灰色圆点)。
5. 编写总控 Agent lib/supervisor.js
这是整个管理器的"大脑"。用 langchain JS 的 createAgent 创建一个总控 Agent,给它两个工具:
list_agents:查看通讯录里有哪些子 Agent;ask_agent:把具体问题通过 HTTP 转交给指定子 Agent,返回其回答。
js
// server/lib/supervisor.js
import { createAgent } from 'langchain';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { findAgent, loadAgents } from './registry.js';
const model = new ChatOpenAI({
model: process.env.OPENAI_MODEL || 'deepseek-flash',
apiKey: process.env.OPENAI_API_KEY || 'sk-your-deepseek-api-key',
configuration: {
baseURL: process.env.OPENAI_BASE_URL || 'https://api.deepseek.com/v1',
},
temperature: 0,
});
const listAgentsTool = tool(
async () => {
const agents = loadAgents();
if (agents.length === 0) return '当前没有接入任何子 Agent。';
return agents.map((a) => `- ${a.name}(id: ${a.id}):${a.description}`).join('\n');
},
{
name: 'list_agents',
description: '列出当前接入的所有子 Agent 及其能力说明',
schema: z.object({}),
},
);
const askAgentTool = tool(
async ({ agent_id, question }) => {
const agent = findAgent(agent_id);
if (!agent) {
const available = loadAgents().map((a) => a.id).join('、') || '(无)';
return `未找到 id 为「${agent_id}」的 Agent。当前可用:${available}`;
}
try {
const resp = await fetch(agent.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: question }] }),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
const detail = (await resp.text()).slice(0, 200);
return `调用「${agent.name}」失败:HTTP ${resp.status} ${detail}`;
}
const data = await resp.json();
return data.reply ?? '(对方没有返回内容)';
} catch (err) {
return `调用「${agent.name}」失败:${err.message}`;
}
},
{
name: 'ask_agent',
description: '把用户的具体问题转交给指定子 Agent 处理并返回其回答',
schema: z.object({
agent_id: z.string().describe('子 Agent 的 id,可通过 list_agents 查询'),
question: z.string().describe('要转交给子 Agent 的完整问题'),
}),
},
);
const supervisor = createAgent({
model,
tools: [listAgentsTool, askAgentTool],
systemPrompt: `你是 Agent 管理器,负责统筹调度多个子 Agent。
规则:
1. 用户问"有哪些 Agent / 你能做什么"时,调用 list_agents。
2. 用户的问题属于某个子 Agent 的能力范围时,调用 ask_agent 转交,并用其回答回复用户。
3. 一次提问涉及多个 Agent 时,可以多次调用 ask_agent 再汇总。
4. 子 Agent 不可用时如实告知用户,不要编造结果。
5. 回复使用中文,简洁。`,
});
export async function runSupervisor(messages) {
const result = await supervisor.invoke({ messages });
const last = result.messages[result.messages.length - 1];
return { reply: last.content ?? '' };
}
注意一个细节:ask_agent 工具内部就是一次普通的 fetch POST。langchain 在这里的作用是让模型自己决定何时调用、调用谁、传什么问题------工具本身的实现可以非常朴素。
6. 编写服务入口 server/index.js
js
// server/index.js
import express from 'express';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// 极简 .env 加载:server/.env 与项目根目录 .env 都读,优先已有环境变量
const serverDir = dirname(fileURLToPath(import.meta.url));
for (const envFile of [join(serverDir, '.env'), join(serverDir, '..', '.env')]) {
if (!existsSync(envFile)) continue;
for (const line of readFileSync(envFile, 'utf8').split(/\r?\n/)) {
if (line.trim().startsWith('#')) continue;
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
if (match) process.env[match[1]] ??= match[2];
}
}
const { loadAgents, findAgent, addAgent, removeAgent, checkHealth } = await import('./lib/registry.js');
const { runSupervisor } = await import('./lib/supervisor.js');
const MODEL = process.env.OPENAI_MODEL || 'deepseek-flash';
const PORT = Number(process.env.PORT || 18001);
const app = express();
app.use(express.json());
function validMessages(messages) {
return (
Array.isArray(messages) &&
messages.length > 0 &&
messages.every((m) => m && typeof m.content === 'string' && ['user', 'assistant'].includes(m.role))
);
}
app.get('/api/health', (_req, res) => res.json({ ok: true, model: MODEL }));
// Agent 列表(附带在线状态)
app.get('/api/agents', async (_req, res) => {
const agents = await Promise.all(
loadAgents().map(async (a) => ({ ...a, online: (await checkHealth(a)) ?? true })),
);
res.json({ agents });
});
app.post('/api/agents', (req, res) => {
try {
const agent = addAgent(req.body ?? {});
res.status(201).json({ agent });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.delete('/api/agents/:id', (req, res) => {
if (!removeAgent(req.params.id)) {
return res.status(404).json({ error: `Agent 不存在:${req.params.id}` });
}
res.json({ ok: true });
});
// 与某个子 Agent 直接对话:原样转发 messages
app.post('/api/agents/:id/chat', async (req, res) => {
const { messages } = req.body ?? {};
if (!validMessages(messages)) {
return res.status(400).json({ error: 'messages 必须是非空的 user/assistant 消息数组' });
}
const agent = findAgent(req.params.id);
if (!agent) {
return res.status(404).json({ error: `Agent 不存在:${req.params.id}` });
}
try {
const resp = await fetch(agent.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: AbortSignal.timeout(120000),
});
if (!resp.ok) {
const detail = (await resp.text()).slice(0, 200);
throw new Error(`子 Agent 返回 HTTP ${resp.status}:${detail}`);
}
res.json(await resp.json());
} catch (err) {
console.error(`[chat:${agent.id}] 转发失败:`, err);
const detail = err.cause ? `${err.message}:${err.cause.message}` : err.message;
res.status(502).json({ error: `无法连接到「${agent.name}」:${detail}` });
}
});
// 与总控 Agent 对话:langchain 决定直接回答或调度子 Agent
app.post('/api/chat', async (req, res) => {
const { messages } = req.body ?? {};
if (!validMessages(messages)) {
return res.status(400).json({ error: 'messages 必须是非空的 user/assistant 消息数组' });
}
try {
res.json(await runSupervisor(messages));
} catch (err) {
console.error('[chat:supervisor] 处理失败:', err);
const detail = err.cause ? `${err.message}:${err.cause.message}` : err.message;
res.status(502).json({ error: detail });
}
});
app.listen(PORT, () => {
console.log(`Agent 管理器服务已启动:http://localhost:${PORT}`);
console.log(`总控模型:${MODEL}`);
});
后端接口一览:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/agents |
Agent 列表(含在线状态) |
| POST | /api/agents |
注册 Agent |
| DELETE | /api/agents/:id |
删除 Agent |
| POST | /api/agents/:id/chat |
与指定 Agent 直连对话 |
| POST | /api/chat |
与总控 Agent 对话 |
| GET | /api/health |
管理器自身健康检查 |
四、搭建管理器前端(Vue 3)
前端用 Vue 3 + Vite,全部界面写在一个 App.vue 里:左侧是 Agent 列表(含总控和在线状态圆点),右侧是对话框。每个 Agent 的聊天记录各自独立保存在内存中。
vite.config.js 中把 /api 代理到后端,并固定端口:
js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 15180,
strictPort: true, // 端口被占用时直接报错,而不是悄悄换端口
proxy: {
'/api': 'http://localhost:18001',
},
},
});
App.vue 的核心逻辑(完整代码见文末仓库):
vue
<script setup>
// 每个 Agent 独立的聊天记录与输入框
const histories = reactive({}); // agentId -> messages[]
const inputs = reactive({}); // agentId -> 输入框文本
async function send() {
const id = currentId.value;
const text = (inputs[id] ?? '').trim();
if (!text || loading.value) return;
historyOf(id).push({ role: 'user', content: text });
inputs[id] = '';
loading.value = true;
try {
// 总控走 /api/chat,子 Agent 走 /api/agents/:id/chat
const url = id === SUPERVISOR_ID ? '/api/chat' : `/api/agents/${encodeURIComponent(id)}/chat`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: histories[id] }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `请求失败:HTTP ${resp.status}`);
historyOf(id).push({ role: 'assistant', content: data.reply });
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
</script>
模板部分用两个 flex 区域即可:aside.sidebar(列表 + 添加/删除按钮)和 main.chat(消息列表 + 输入框)。消息气泡根据 msg.role 区分左右样式,与常见的聊天界面一致。
五、接入天气 Agent:注册即接入
天气 Agent 是上一篇做的项目,它的 Node 服务(端口 18000)已经暴露了 POST /api/chat 和 GET /api/health。接入管理器只需要在 server/agents.json 里加一条记录:
json
[
{
"id": "weather",
"name": "天气 Agent",
"description": "查询指定城市的实时天气",
"endpoint": "http://localhost:18000/api/chat",
"healthUrl": "http://localhost:18000/api/health"
}
]
也可以在页面上点「+ 添加」直接填写。接入后左侧列表立刻出现「天气 Agent」,绿点表示服务在线。
六、总控 Agent 为什么能调度其他 Agent
以"成都现在天气怎么样?"为例,走总控路径时的完整流程是:
text
用户:成都现在天气怎么样?
↓
总控 Agent(langchain createAgent + DeepSeek)
↓
判断:这属于天气 Agent 的能力范围
↓
调用 ask_agent 工具(agent_id="weather", question="成都现在天气怎么样?")
↓
管理器向 http://localhost:18000/api/chat 发起 HTTP 请求
↓
天气 Agent 收到问题,自己完成"调 DeepSeek → 调 get_weather → 组织回答"
↓
天气 Agent 返回 { "reply": "成都当前天气:..." }
↓
总控 Agent 把结果汇总,回复用户
这个链条里有两层 Agent 循环:
- 外层:总控 Agent 的"理解目标 → 选择工具 → 调用 ask_agent → 观察结果 → 生成回答";
- 内层:天气 Agent 自己的"理解目标 → 选择 get_weather → 调用 → 生成回答"。
总控 Agent 完全不知道天气是怎么查到的,它只依赖注册表里的接口契约。这就是"Agent 管理 Agent"能够成立的原因:调度依赖的是统一的通信协议,而不是了解每个 Agent 的内部实现。
七、实测效果
依次启动三个服务:
bash
# 1. 天气 Agent 服务(端口 18000)
cd agent-demo-weather/server && npm start
# 2. 管理器服务(端口 18001)
cd agent-demo-manager/server && npm start
# 3. 前端(端口 15180)
cd agent-demo-manager/web && npm run dev
浏览器打开 http://localhost:15180 。
实测一:直连天气 Agent。
text
问:成都现在天气怎么样?
答:成都当前天气:阴天,气温 18°C,体感 19°C,湿度 78%,风速 4 km/h。
实测二:问总控有哪些 Agent(触发 list_agents 工具)。
text
问:你现在管理哪些Agent?
答:当前接入的子 Agent 有 1 个:
- 天气 Agent(id: weather):查询指定城市的实时天气
需要查哪个城市的天气,告诉我即可。
实测三:让总控调度天气 Agent(触发 ask_agent 工具)。
text
问:成都现在天气怎么样?请让天气Agent回答
答:成都当前天气:阴天,气温 18°C,体感 19°C,湿度 78%,风速 4 km/h。(由天气 Agent 提供)
总体效果如下

八、下一步:还能做什么
这个管理器目前是最小可用版本,沿着"每次只加一项能力"的思路,可以继续演进:
- 接入笔记问答 Agent:上一篇的本地笔记 Agent 还是命令行程序,给它包一层和天气 Agent 相同的 HTTP 接口,注册进来即可被调度------这也验证了"统一契约接入"的设计。
- 流式输出 :现在是一次性返回
{reply},改成 SSE 流式推送,长回答体验会好很多。 - 历史持久化:目前聊天记录只存在浏览器内存里,刷新即丢,可以用 localStorage 或后端存储。
- 总控记忆 :给
createAgent加上 checkpointer,让总控在多轮对话中记住之前的调度上下文。 - 权限与审批:对"删除 Agent""调用会写操作的 Agent"等动作加人工确认,这是 Agent 走向真实业务必须补的一课。
从小而明确的管理器开始,每接入一个新 Agent、每加一条调度规则,都在加深对"Agent 如何协作"的理解------这比一开始就做复杂的万能框架要扎实得多。