3.从零制作 Agent 管理工具:LangChain + Node + Vue 实现多 Agent 统一调度

系列上一篇:2.从零制作第一个天气 Agent:理解 Prompt、Skill 与 LangChain

目录

  • [一、什么是 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-chatdeepseek-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/chatGET /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 提供)

总体效果如下

八、下一步:还能做什么

这个管理器目前是最小可用版本,沿着"每次只加一项能力"的思路,可以继续演进:

  1. 接入笔记问答 Agent:上一篇的本地笔记 Agent 还是命令行程序,给它包一层和天气 Agent 相同的 HTTP 接口,注册进来即可被调度------这也验证了"统一契约接入"的设计。
  2. 流式输出 :现在是一次性返回 {reply},改成 SSE 流式推送,长回答体验会好很多。
  3. 历史持久化:目前聊天记录只存在浏览器内存里,刷新即丢,可以用 localStorage 或后端存储。
  4. 总控记忆 :给 createAgent 加上 checkpointer,让总控在多轮对话中记住之前的调度上下文。
  5. 权限与审批:对"删除 Agent""调用会写操作的 Agent"等动作加人工确认,这是 Agent 走向真实业务必须补的一课。

从小而明确的管理器开始,每接入一个新 Agent、每加一条调度规则,都在加深对"Agent 如何协作"的理解------这比一开始就做复杂的万能框架要扎实得多。

相关推荐
俊哥V1 小时前
AI一周事件 · 2026-09-09 至 2026-09-15
人工智能·ai
HRaitest1 小时前
AI招聘系统架构深度拆解:传统外挂式AI vs AI原生基座的本质差异与潜能边界
人工智能·ai·系统架构·视觉检测·求职招聘
hyunbar1 小时前
LangChain 实战:玩转短期记忆
langchain·agent·harness
知了一笑1 小时前
你在用AI,还是在围观AI?
人工智能·ai
ai小陈11 小时前
CUDA Stream实战:让数据传输与GPU计算真正重叠
人工智能·深度学习·ai·pdf·云计算·gpu算力
霸道流氓气质11 小时前
DriftKit 完全指南:从入门到精通,掌握 Java AI 提示词生命周期管理
ai
米小虾11 小时前
拆给 8 个子智能体,只拿回 2.3 倍信息:多智能体分解的产出守恒律
人工智能·agent
JaydenAI12 小时前
[DeepSeek Harness深度拆解-04]完整的插件配置树如何构建?
ai·agent·deepseek·harness·cordis
Csvn13 小时前
第 23 章 性能、成本与部署
人工智能·aigc·agent