微型代理:一个50行代码驱动的MCP代理

发布于2025年4月25日
在 GitHub 上更新

新!(25年5月23日)如果您喜欢Python,请查看配套文章 Python中的微型代理

在过去几周里,我一直在深入研究MCP(模型上下文协议),以了解其热度究竟为何。

我的简短总结是,它相当简单,但仍然非常强大:**MCP是一个标准API,用于暴露一组可以与LLM连接的工具。**

扩展Inference Client相当简单——在Hugging Face,我们有两个官方客户端SDK:JS中的@huggingface/inference和Python中的huggingface_hub——它们也可以作为MCP客户端,并将MCP服务器提供的工具连接到LLM推理中。

但在做这些的时候,我有了第二个认识

一旦有了MCP客户端,代理实际上就是它之上的一个while循环。

在这篇短文中,我将向您介绍我是如何在Typescript (JS) 中实现它的,您也可以如何采用MCP,以及它将如何使代理AI在未来变得更简单。

meme

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

如何运行完整演示

如果您安装了NodeJS(带pnpmnpm),只需在终端中运行此命令

npx @huggingface/mcp-client

或者如果使用pnpm

pnpx @huggingface/mcp-client

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

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

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

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

我们第一个视频的输入是

写一首关于Hugging Face社区的俳句,并将其保存到我桌面上的名为“hf.txt”的文件中

现在让我们尝试这个涉及网络浏览的提示

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

默认模型和提供者

在模型/提供商方面,我们的示例代理默认使用

所有这些都可以通过环境变量配置!请参阅

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 单一仓库的 mcp-client 子包中,该仓库是所有我们 JS 库的 GitHub 单一仓库。

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

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

基础:LLM中工具调用的原生支持。

这篇博客文章之所以变得非常容易,是因为近期出现的 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` 传递给模型,就像任何其他消息一样,然后从响应中解析出来(使用模型特定的特殊标记),以将其暴露为工具调用。请参阅我们聊天模板游乐场中的示例

在InferenceClient之上实现MCP客户端

既然我们知道了工具在最新的 LLM 中是什么,那么让我们来实现实际的 MCP 客户端。

官方文档 https://modelcontextprotocol.net.cn/quickstart/client 写得相当好。您只需将 Anthropic 客户端 SDK 的任何提及替换为任何其他 OpenAI 兼容的客户端 SDK。(还有一个 llms.txt 您可以将其输入您选择的 LLM 中以帮助您进行编码)。

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

如果您想跟着实际代码学习,完整的 McpClient.ts 代码文件在这里 🤓

我们的 McpClient 类拥有

  • 一个推理客户端(适用于任何推理提供商,并且 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 提供了一个带有 listTools() 方法的 Client 类。

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行代码的代理🤯

既然我们有了一个能够连接任意 MCP 服务器以获取工具列表,并且能够注入和解析 LLM 推理结果的 MCP 客户端,那么……代理到底是什么呢?

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

更详细地说,代理只是以下各项的组合

  • 一个系统提示
  • 一个 LLM 推理客户端
  • 一个 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: this.availableTools 参数就足够了。

在 Agent 上加载工具字面上就是连接我们想要的 MCP 服务器(并行,因为在 JS 中这样做太容易了)

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

我们添加了两个额外的工具(MCP 之外),LLM 可以使用它们来控制代理的流程。

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];

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

完整的while循环

看!我们完整的 while 循环。🎉

我们代理主 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 客户端和一个简单的构建代理的方式,就有许多很酷的潜在后续步骤🔥

  • 尝试**其他模型**
  • 尝试所有**推理提供商**
    • Cerebras, Cohere, Fal, Fireworks, Hyperbolic, Nebius, Novita, Replicate, SambaNova, Together 等。
    • 它们每个都对函数调用有不同的优化(也取决于模型),所以性能可能会有所不同!
  • 使用 llama.cpp 或 LM Studio 连接**本地 LLM**

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

社区

很棒🔥

🔥🔥🔥

我们真的不配拥有你

感谢这篇帖子!非常有用,解释得很好。我之前不太明白 MCP 炒作的原因,现在清楚了一点!

在运行第一个“npx”命令之前需要设置 HF_TOKEN。

解释得非常清楚,很有用!谢谢!

很酷!我非常喜欢你尽可能简化代理的想法,我认为这作为基线很有用。“这个复杂的代理框架真的比简单的方法更能提高性能吗?”这个问题对我来说并没有一个显而易见的答案。

我擅自将您的 JavaScript 代码转换为 Python,并将其实现到 any-agent 中,这样就可以轻松切换 TinyAgent 和 SmolAgent,以查看性能差异:https://github.com/mozilla-ai/any-agent/blob/main/src/any_agent/frameworks/tinyagent.py

·
文章作者

哇,这太酷了!

这是一个非常酷的想法,
我已将您的 JavaScript 代码转换为 Python,
它是一个简单的循环 + LiteLLM + MCP

https://github.com/askbudi/tinyagent

我想如果任何人都能根据自己的需求定制他们的 Tinyagent,例如添加内存层或将数据存储在 PG 中,那会很酷。
而且这里可以与此仓库聊天,并为您特定的项目添加所需的功能

https://askdev.ai/github/askbudi/tinyagent

注册登录 发表评论