微型代理:一个50行代码驱动的MCP代理
新!(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在未来变得更简单。
如何运行完整演示
如果您安装了NodeJS(带pnpm
或npm
),只需在终端中运行此命令
npx @huggingface/mcp-client
或者如果使用pnpm
pnpx @huggingface/mcp-client
这会将我的包安装到临时文件夹,然后执行其命令。
您将看到您的简单代理连接到两个不同的MCP服务器(本地运行),加载它们的工具,然后提示您进行对话。
默认情况下,我们的示例代理连接到以下两个MCP服务器
- “规范”文件系统服务器,它可以访问您的桌面,
- 和Playwright MCP服务器,它知道如何为您使用沙盒Chromium浏览器。
注意:这有点反直觉,但目前所有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 客户端和一个简单的构建代理的方式,就有许多很酷的潜在后续步骤🔥
- 尝试**其他模型**
- mistralai/Mistral-Small-3.1-24B-Instruct-2503 针对函数调用进行了优化
- Gemma 3 27B,Gemma 3 QAT 模型是函数调用的热门选择,但由于它不使用原生
tools
,所以需要我们实现工具解析(欢迎提交 PR!)
- 尝试所有**推理提供商**
- Cerebras, Cohere, Fal, Fireworks, Hyperbolic, Nebius, Novita, Replicate, SambaNova, Together 等。
- 它们每个都对函数调用有不同的优化(也取决于模型),所以性能可能会有所不同!
- 使用 llama.cpp 或 LM Studio 连接**本地 LLM**
欢迎提交拉取请求和贡献!再次强调,这里的一切都是开源的!💎❤️