跳到正文
原文
Hugging Face Blog·· 2025-04-25精选AI 评分64

Hugging Face 用 50 行代码实现 MCP 驱动的智能体

Tiny Agents: an MCP-powered agent in 50 lines of code

AI 导读

Hugging Face 发布 Tiny Agents 教程,演示如何用约 50 行 TypeScript 代码在推理客户端之上实现一个 MCP 驱动的智能体。

推荐理由

文章给出一个约 50 行的 MCP 智能体实现,读者可据此理解工具调用与 MCP 客户端如何拼装成完整智能体。

正文 · AI 翻译
新!(2025年5月23日) 如果你更倾向于Python,请查看配套文章 Tiny Agents in Python。

过去几周,我一直在深入研究MCP(模型上下文协议),以了解它周围的热议究竟是怎么回事。

我的简要结论是,它相当简单,但仍然非常强大:MCP是一个标准的API,用于暴露可以挂接到LLM上的工具集。

要扩展推理客户端以同时充当MCP客户端,并将MCP服务器中的可用工具接入LLM推理,这相当简单——在HF,我们有两个官方客户端SDK:JS中的@huggingface/inference和Python中的huggingface_hub。

但在做这件事时,我有了第二个领悟:

一旦你有了MCP客户端,Agent实际上就只是在它之上加一个while循环。

在这篇短文中,我将带你了解我如何用TypeScript(JS)实现它,你如何也能采用MCP,以及它将如何让Agentic AI在未来变得更加简单。

meme

图片来自 https://x.com/adamdotdev

如何运行完整演示

如果你有NodeJS(带有pnpm或npm),只需在终端中运行:

npx @huggingface/mcp-client

或者如果使用pnpm:

pnpx @huggingface/mcp-client

这会将我的包安装到一个临时文件夹,然后执行其命令。

你会看到你的简单Agent连接到两个不同的MCP服务器(本地运行),加载它们的工具,然后提示你进行对话。

默认情况下,我们的示例Agent连接到以下两个MCP服务器:

注意:这有点反直觉,但目前所有MCP服务器实际上都是本地进程(尽管远程服务器很快会推出)。

我们在第一个视频中的输入是:

写一首关于HF Mirror社区的俳句,并将其写入我桌面上名为“hf.txt”的文件

现在让我们尝试这个涉及一些网页浏览的提示:

在Brave Search上对HF推理提供商进行网络搜索,并打开前3个结果

默认模型和提供商

在模型/提供商组合方面,我们的示例Agent默认使用:

这一切都可以通过环境变量配置!参见:

const agent = new Agent({
    provider: process.env.PROVIDER ?? "nebius",
    model: process.env.MODEL_ID ?? "Qwen/Qwen2.5-72B-Instruct",
    apiKey: process.env.HF_TOKEN,
    servers: SERVERS,
});

代码在哪里

Tiny Agent代码位于huggingface.js monorepo的mcp-client子包中,这是我们所有JS库所在的GitHub monorepo。

https://github.com/huggingface/huggingface.js/tree/main/packages/mcp-client

该代码库使用了现代JS特性(特别是异步生成器),这使得实现变得更加容易,尤其是像LLM响应这样的异步事件。 如果你还不熟悉这些JS特性,可能需要向LLM请教一下。

这一切的基础:LLM中原生支持的tool calling。

让整篇博文变得非常容易的是,最近的一批LLM(无论是闭源还是开源)都经过了函数调用(即工具使用)的训练。

工具由其名称、描述以及其参数的JSONSchema表示来定义。 从某种意义上说,它是从外部看到的任何函数接口的不透明表示(意思是,LLM不关心函数实际是如何实现的)。

const weatherTool = {
    type: "function",
    function: {
        name: "get_weather",
        description: "Get current temperature for a given location.",
        parameters: {
            type: "object",
            properties: {
                location: {
                    type: "string",
                    description: "City and country e.g. Bogotá, Colombia",
                },
            },
        },
    },
};

我在这里链接的权威文档是OpenAI的函数调用文档。(是的……OpenAI几乎为整个社区定义了LLM标准😅)。

推理引擎让你在调用 LLM 时传入一个工具列表,而 LLM 可以自由地调用其中零个、一个或多个工具。 作为开发者,你负责运行这些工具,并把它们的结果反馈回 LLM 以继续生成。

请注意,在后端(即推理引擎层面),工具只是像其他消息一样,以一种特殊格式的 chat_template 传给模型,然后从响应中解析出来(使用模型专用的特殊 token),以工具调用的形式暴露出来。可以看一个例子 在我们的 chat-template playground 中。

在 InferenceClient 之上实现一个 MCP 客户端

现在我们知道了近期 LLM 中的工具是什么,接下来让我们实现真正的 MCP 客户端。

https://modelcontextprotocol.io/quickstart/client 上的官方文档写得相当不错。你只需要把其中提到的 Anthropic 客户端 SDK 替换为任何其他兼容 OpenAI 的客户端 SDK 即可。(还有一个 llms.txt,你可以把它喂给你选择的 LLM,帮助你边写代码边参考)。

提醒一下,我们的推理客户端使用的是 HF 的 InferenceClient。

完整的 McpClient.ts 代码文件在这里,如果你想跟着实际代码一起看的话 🤓

我们的 McpClient 类包含:

  • 一个 Inference Client(可与任何 Inference Provider 配合使用,并且 huggingface/inference 同时支持远程和本地端点)
  • 一组 MCP 客户端会话,每个已连接的 MCP 服务器对应一个(是的,我们想支持多个服务器)
  • 以及一个可用工具列表,它将由已连接的服务器填充,并只是稍作重新格式化。
export class McpClient {
    protected client: InferenceClient;
    protected provider: string;
    protected model: string;
    private clients: Map<ToolName, Client> = new Map();
    public readonly availableTools: ChatCompletionInputTool[] = [];

    constructor({ provider, model, apiKey }: { provider: InferenceProvider; model: string; apiKey: string }) {
        this.client = new InferenceClient(apiKey);
        this.provider = provider;
        this.model = model;
    }
    
    // [...]
}

要连接到一个 MCP 服务器,官方的 @modelcontextprotocol/sdk/client TypeScript SDK 提供了一个 Client 类,其中有一个 listTools() 方法:

async addMcpServer(server: StdioServerParameters): Promise<void> {
    const transport = new StdioClientTransport({
        ...server,
        env: { ...server.env, PATH: process.env.PATH ?? "" },
    });
    const mcp = new Client({ name: "@huggingface/mcp-client", version: packageVersion });
    await mcp.connect(transport);

    const toolsResult = await mcp.listTools();
    debug(
        "Connected to server with tools:",
        toolsResult.tools.map(({ name }) => name)
    );

    for (const tool of toolsResult.tools) {
        this.clients.set(tool.name, mcp);
    }

    this.availableTools.push(
        ...toolsResult.tools.map((tool) => {
            return {
                type: "function",
                function: {
                    name: tool.name,
                    description: tool.description,
                    parameters: tool.inputSchema,
                },
            } satisfies ChatCompletionInputTool;
        })
    );
}

StdioServerParameters 是 MCP SDK 中的一个接口,能让你轻松启动一个本地进程:正如我们之前提到的,目前所有 MCP 服务器实际上都是本地进程。

对于我们连接的每个 MCP 服务器,我们会稍作重新格式化其工具列表,并将它们添加到 this.availableTools 中。

如何使用这些工具

很简单,你只需在惯常的消息数组之外,把 this.availableTools 传给 LLM 的聊天补全即可:

const stream = this.client.chatCompletionStream({
    provider: this.provider,
    model: this.model,
    messages,
    tools: this.availableTools,
    tool_choice: "auto",
});

tool_choice: "auto" 就是你传给 LLM 的参数,让它生成零个、一个或多个工具调用。

在解析或流式输出时,LLM 会生成一些工具调用(即一个函数名,以及一些 JSON 编码的参数),这些需要你(作为开发者)来计算。MCP 客户端 SDK 再次让这变得非常简单;它有一个 client.callTool() 方法:

const toolName = toolCall.function.name;
const toolArgs = JSON.parse(toolCall.function.arguments);

const toolMessage: ChatCompletionInputMessageTool = {
    role: "tool",
    tool_call_id: toolCall.id,
    content: "",
    name: toolName,
};

/// Get the appropriate session for this tool
const client = this.clients.get(toolName);
if (client) {
    const result = await client.callTool({ name: toolName, arguments: toolArgs });
    toolMessage.content = result.content[0].text;
} else {
    toolMessage.content = `Error: No session found for tool: ${toolName}`;
}

最后,你将得到的工具消息添加到你的 messages 数组,再送回 LLM。

我们 50 行代码的 Agent 🤯

现在我们有了一个 MCP 客户端,能够连接到任意 MCP 服务器以获取工具列表,并能够将它们注入到 LLM 推理中并从中解析出来,那么……什么是 Agent?

一旦你有了一个带有一组工具的推理客户端,那么 Agent 就只是它之上的一个 while 循环。

更详细地说,Agent 就是以下这些的组合:

  • 一个系统提示词
  • 一个 LLM Inference 客户端
  • 一个 MCP 客户端,用于从一堆 MCP 服务器接入一组工具
  • 一些基本的控制流(见下面的 while 循环)

完整的 Agent.ts 代码文件在这里。

我们的 Agent 类只是简单地继承了 McpClient:

export class Agent extends McpClient {
    private readonly servers: StdioServerParameters[];
    protected messages: ChatCompletionInputMessage[];

    constructor({
        provider,
        model,
        apiKey,
        servers,
        prompt,
    }: {
        provider: InferenceProvider;
        model: string;
        apiKey: string;
        servers: StdioServerParameters[];
        prompt?: string;
    }) {
        super({ provider, model, apiKey });
        this.servers = servers;
        this.messages = [
            {
                role: "system",
                content: prompt ?? DEFAULT_SYSTEM_PROMPT,
            },
        ];
    }
}

默认情况下,我们使用一个非常简单的系统提示词,灵感来自GPT-4.1 提示指南中分享的那个。

尽管这来自 OpenAI 😈,但这句话尤其适用于越来越多的模型,无论是闭源还是开源:

我们鼓励开发者专门使用 tools 字段来传递工具,而不是像过去一些人报告的那样,手动将工具描述注入到提示词中并为工具调用编写单独解析器。

也就是说,我们不需要在提示词中提供精心格式化的工具使用示例列表。tools: this.availableTools 参数就足够了。

在 Agent 上加载工具实际上只是连接到我们想要的 MCP 服务器(并行进行,因为在 JS 中这太容易了):

async loadTools(): Promise<void> {
    await Promise.all(this.servers.map((s) => this.addMcpServer(s)));
}

我们添加了两个额外的工具(MCP 之外),供 LLM 用于我们 Agent 的控制流:

const taskCompletionTool: ChatCompletionInputTool = {
    type: "function",
    function: {
        name: "task_complete",
        description: "Call this tool when the task given by the user is complete",
        parameters: {
            type: "object",
            properties: {},
        },
    },
};
const askQuestionTool: ChatCompletionInputTool = {
    type: "function",
    function: {
        name: "ask_question",
        description: "Ask a question to the user to get more info required to solve or clarify their problem.",
        parameters: {
            type: "object",
            properties: {},
        },
    },
};
const exitLoopTools = [taskCompletionTool, askQuestionTool];

当调用这些工具中的任何一个时,Agent 将中断其循环,并将控制权交还给用户以获取新输入。

完整的 while 循环

见证我们完整的 while 循环。🎉

我们 Agent 主 while 循环的要点是,我们简单地与 LLM 迭代,在工具调用和向其提供工具结果之间交替,我们这样做直到 LLM 开始连续响应两条非工具消息。

这是完整的 while 循环:

let numOfTurns = 0;
let nextTurnShouldCallTools = true;
while (true) {
    try {
        yield* this.processSingleTurnWithTools(this.messages, {
            exitLoopTools,
            exitIfFirstChunkNoTool: numOfTurns > 0 && nextTurnShouldCallTools,
            abortSignal: opts.abortSignal,
        });
    } catch (err) {
        if (err instanceof Error && err.message === "AbortError") {
            return;
        }
        throw err;
    }
    numOfTurns++;
    const currentLast = this.messages.at(-1)!;
    if (
        currentLast.role === "tool" &&
        currentLast.name &&
        exitLoopTools.map((t) => t.function.name).includes(currentLast.name)
    ) {
        return;
    }
    if (currentLast.role !== "tool" && numOfTurns > MAX_NUM_TURNS) {
        return;
    }
    if (currentLast.role !== "tool" && nextTurnShouldCallTools) {
        return;
    }
    if (currentLast.role === "tool") {
        nextTurnShouldCallTools = false;
    } else {
        nextTurnShouldCallTools = true;
    }
}

后续步骤

一旦你有了一个运行的 MCP Client 和一种构建 Agent 的简单方法,就有许多很酷的潜在后续步骤 🔥

  • Experiment with other models
  • Experiment with all the Inference Providers:
    • Cerebras、Cohere、Fal、Fireworks、Hyperbolic、Nebius、Novita、Replicate、SambaNova、Together 等。
    • 它们各自对函数调用有不同的优化(也取决于模型),因此性能可能会有所不同!
  • 使用 llama.cpp 或 LM Studio 挂接本地 LLM

欢迎提交 pull request 和贡献! 再次强调,这里的一切都是开源的!💎❤️

来源:Hugging Face Blog · hf-mirror.com