你本地的LLM有时会胡乱使用tools?不妨用MCP规范LLM工具调用!

一个demo,带你体验创建自己的MCP

FunctionCalling Tools

有时你是否会遇到这样的情况: 我给 LLM 写了数个类似的Tools:

scss 复制代码
1. readFiles(path) // 读取整个文件
2. readFileLines(Path,start,end) // 读取指定行
3. getFileHead(path,n) // 读取前n行

我问LLM:"帮我看一下 config.json里写了什么",结果它却只返回了前10行,货不对板。这三个工具描述都包含"读取文件",LLM无法自主区分该用哪个。

这就叫LLM的决策 paralysis,也就是决策瘫痪。LLM在面对相似的工具时,缺乏一个规范让它在指定情况下能选择到指定的工具。

或者是这样的情况: 在开发过程中,我给LLM写的 Tools越来越多,结果LLM反应的速度越来越慢。

例如我定义了三十多个工具,每当我问一个问题,LLM就会把这30个工具的完整定义(名称+描述+参数JSON Schema)都会一股脑塞进上下文,留给LLM"思考和推理"的空间被严重挤压

这就叫LLM的感知过载

此时,我多么希望,有一套规范,能让LLM在诸多工具中,高效准确的找到相应的工具,解决决策瘫痪与感知过载的问题啊

MCP-模型上下文协议 (Model Context Protocol)

  • 制定一套标准,统一LLM与外部工具、系统和数据源的交互方式
  • 工具被定义为服务器(MCPServer)上可执行的功能模块,通过MCP协议暴露给客户端,供LLM调用

核心流程

rust 复制代码
客户端 -> 服务端 ->    ^^     LLM -> 服务端 -> 客户端
                      | |
                  MCPserver

MCP的核心能力:

  1. 工具搜索,LLM不需要一次性知道所有的工具,而是按需发现有哪些工具以供使用
  2. 程序化工具调用,LLM判断需要某个工具后,程序化触发调用。
rust 复制代码
LLM 返回 tool_calls -> 代码自动执行对应工具 -> 拿到结果再传回 LLM
  1. 工具使用示例,给LLM提供工具的使用返利,帮助它更准确地判断什么时候该用、怎么用

一个 MCP 服务端开发流程

我们的服务端用来接受前端的POST/chat,调用llm.chat()

事前准备,我们需要用到MCP,因此要pnpm下来

css 复制代码
pnpm i @modelcontextprotocol/sdk

Schema规范

Schema规范是附在工具定义里给LLM看的说明书,让LLM知道调用某个工具时参数该怎么填。

也就是说,LLM返回的工具调用参数要遵从Schema

例如我给LLM传的数据,按API格式组织

yaml 复制代码
{
  model: 'qwen2.5:14b',
  messages: [...],
  tools: [...],    // 工具定义里"包含"了 schema
  stream: false
}

后续我们引入 zod 来帮我们写JSONS chema

事前引入、创建mcp server

javascript 复制代码
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; 
import {z} from 'zod'
import {exec} from 'child_process'

// 创建的mcpserver不会跑在某个端口上,而是寄生在某个进程上,等待客户端链接
const server = new McpServer({
    name:'Demo',
    version:'1.0.0',
})

讲解一下引入的包用来干啥:

  1. McpServer用来注册工具
  • server.tool()挂载工具函数
  • 客户端调取listTools()的时候,他自动把注册过的工具清单返回给客户端,不需要我们手写返回逻辑。
  • 客户端调用callTool({name,arguments})的时候,自动根据name找到工具,返回结果
  1. transport用来跟进程交互
  2. 引入Zod,让JSON Schema的创建更加简洁且类型安全
  3. 引入的exec是为了后续创建一个子进程使用

注册一个工具函数,后续交给MCP

这里以一个简单的,读取当前目录下的文件工具函数。代码实现如下

javascript 复制代码
server.tool('listFiles','列出执行目录下的文件',{path:z.string()},({path})=>{
    return new Promise((resolve,reject)=>{
        // 用exec开一个子进程,才能在命令行执行
        exec(`dir ${path}`,(error,stdout,stderr)=>{
        // 错误处理
            if(error){
                console.error(`执行命令出错:${error}`)
                resolve({
                    content:[{type:'text',text:`执行命令出错:${error}`}]
                })
                return
            }
            if(stderr){
                console.error(`执行命令出错:${stderr}`)
            }
            resolve({
                content:[{type:'text',text:`已获取到目录${path}下的文件列表:\n\`\`\`${stdout}\`\`\n`}]
            })
        })
    })
   })

让MCPServer跟客户端通信

javascript 复制代码
const transport = new StdioServerTransport() // transport 用来跟进程交互 
await server.connect(transport)
console.log('MCP server connected')

transport是server跟外部通信的管道,基于stdin/stdout,客户端通过启动子进程+读写管道来跟server交互。server.connect(transport)就是把server接到这根管道上

至此,MCP的服务端就已经写好了

MCP client客户端

思路、事前引入

javascript 复制代码
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'

引入这些包,client是MCP客户端,我们在这里要做这样的事情:启动并连接MCP服务端,让外部代码能调用服务端的工具

client的核心作用,抛出创建客户端的函数

javascript 复制代码
export async function createClient(){
    const client = new Client({
        name:'Demo',
        version:'1.0.0',
    })
     const transport = new StdioClientTransport({ // 在客户端定义一个可以启用 MCPServer 的指令
        command:'node',
        args:['mcp/server.js']
     })
     try{
        await client.connect(transport)
        console.log('MCP客户端连接成功')
     }catch(error){
        console.error(`MCP客户端连接失败: ${error}`)
        throw error
     }
     return client
}

lib库,写一个llm.js来封装好并抛出一个类

事前准备,引入axios

我们引入在客户端抛出的创建客户端函数,并引入axios。axios是一个HTTP请求库,用来向LLM发POST请求

相当于浏览器的fetch,但更简洁,自动转换JSON,错误处理更方便

javascript 复制代码
import { createClient } from '../mcp/client.js'
import axios from 'axios'

类的初始化

抛出一个类。进行初始化

kotlin 复制代码
export class LLM {
  mcpClient = createClient()  // 类属性,创建 MCP 客户端实例(Promise)
  
  constructor(model, base_url, api_key = null) {
    this.model = model       // 模型名,如 'qwen2.5:14b'
    this.base_url = base_url // Ollama 地址,如 'http://localhost:11434'
    this.api_key = api_key   // API 密钥(我们用本地部署好的LLM不需要,所以默认 null)
  }

headers属性

设定好 headers属性,用get可以作为一个对象属性调用

kotlin 复制代码
get headers() {
  if (this.api_key) {
    return { 'Authorization': `Bearer ${this.api_key}` }
  } else {
    return { 'Content-Type': 'application/json' }
  }
}

listTools 拿到工具列表

javascript 复制代码
async listTools() {
  return (await (await this.mcpClient).listTools()).tools.map(tool => ({
    type: 'function',
    function: {
      name: tool.name,
      description: tool.description,
      parameters: tool.inputSchema   // zod 生成的 JSON Schema
    }
  }))
}

这里有两层await

  • await this.mcpClient 等待客户端连接成功
  • await ...listTools()调MCP客户端拿工具列表

然后把MCP格式转换成Ollama要求的tools格式,你用其他的LLM则自行查阅文档

callTool 调用工具

让MCP客户端执行工具,返回结果包装成Ollama要求的tool消息格式,方便塞进messages数组

javascript 复制代码
async callTool(tool_name, tool_args) {
  const result = await (await this.mcpClient).callTool({
    name: tool_name,
    arguments: tool_args
  })
  return {
    role: 'tool',
    name: tool_name,
    content: result.content[0].text  // 取工具返回的文本
  }
}

核心方法,chat

javascript 复制代码
async chat(messages) {
  const tools = await this.listTools()  // ① 拿工具列表
  const response = await axios.post(`${this.base_url}/api/chat`, {
    model: this.model,
    messages,
    tools,
    stream: false
  }, { headers: this.headers })

  const data = response.data
  const reply = data.message.content       // ② LLM 直接回复
  const tool_calls = data.message.tool_calls // ③ LLM 要求调工具

  if (tool_calls && tool_calls.length > 0) {
    const toolResArr = tool_calls.map((tool_call) =>
      this.callTool(tool_call.function.name, tool_call.function.arguments)
    )
    const results = await Promise.all(toolResArr)  // ④ 并行执行所有工具
    return await this.chat([...messages, ...results]) // ⑤ 递归
  }

  return reply  // ⑥ 不需要工具了,返回最终回复
}

这里重点在于递归的思想。当LLM发现调用的工具无法满足需求,则会再次返回data.message.tool_calls,一直到需求解决为止。记得在5号的地方要写return,如果没有的话会丢弃最后一次回复,返回倒数第二个回复

执行流程

markdown 复制代码
1. 拿工具列表
     ↓
2. 发给 Ollama(消息 + 工具列表)
     ↓
3. Ollama 返回两种可能:
     ├─ 直接回复 → 6. 返回
     └─ 要调工具 → 4. 并行执行工具
                       ↓
                   5. 把工具结果塞进 messages,递归调 chat()
                       ↓
                   再次发给 Ollama,直到不再需要工具

MCP和LLM连接

  1. 服务端先借助MCPClient向MCPServer获取已注册的工具函数
  2. 配置好tools属性,与LLM通信
  3. 当LLM返回需要调用某些工具时,递归式的执行,重复向LLM通信,并合并工具执行结果
  4. 最终返回LLM最后一次的回复

应用环节,写一个后端实战

写一个Express后端服务,接受前端请求,调用LLM,返回结果。我们用apifox模拟前端发送请求(你也可以自己新建一个前端发送POST请求)

javascript 复制代码
import express from 'express'
import axios from 'axios'
import { LLM } from '../lib/llm.js'
const app = express()
const llm = new LLM('qwen2.5:14b', 'http://localhost:11434')
// 用express解析POST传递的参数
app.use(express.json()) 
app.post('/chat', async (req, res) => {
// POST用body,GET用query
    const { message } = req.body 
    const messages = [
        {
            role: 'system', 
            content: '你是一个ai助手,如果有需要,你可以调用tools函数来解决问题'
        },{
            role:'user',
            content:message
        }
    ]
    const reply = await llm.chat(messages)
    res.json({reply})
})
app.listen(3000,()=>{
    console.log('server is running on port 3000')})

在终端中输入

json 复制代码
Invoke-RestMethod -Uri "http://localhost:3000/chat" -Method Post -ContentType "application/json" -Body '{"message": "请调用工具列出 f:/AAAAIFullStackDevelop/ai/MCP/demo 这个目录下的文件"}' | ConvertTo-Json -Depth 5

可以模拟发送请求,得到

总结

MCP 让 LLM 能按标准协议发现并调用外部工具,本项目通过 Ollama 本地模型 + MCP 工具调用,实现了"AI 助手读取目录文件"的完整闭环。

相关推荐
Token炼金师1 小时前
注意力的六脉:QKV 数学、头设计、RoPE、Flash、掩码与剪枝 —— Transformer 核心机制全解
人工智能·深度学习·llm
触底反弹1 小时前
🧠 LangChain Agent 入门:为什么直接调大模型 API 远远不够?
人工智能·node.js·llm
书中枫叶2 小时前
从「上次几点喂奶」到全栈小程序:我做了个喂宝助手
mongodb·微信小程序·node.js
uncle_ll3 小时前
LangChain 实战指南:回调系统、自定义组件与有状态对话全落地
langchain·llm·embedding·agent·大模型开发·rag
Asize4 小时前
Node path 与 fs 模块:从回调地狱到 async await 的异步进化
javascript·node.js
昵称好难啊6 小时前
1. Loop Engineering详解
llm·agent
大家的林语冰6 小时前
😭 Electron 麻了,Deno 直接支持二进制应用,无需第三方桌面应用框架!
前端·javascript·node.js
武子康7 小时前
🔥 vLLM / SGLang / TGI / TensorRT-LLM / Triton 真实分工:分层定位 + 5 步决策路径
人工智能·llm·gpu
武子康7 小时前
🔥 多 GPU 推理决策顺序全解:单卡 → DP → TP → PP → EP 怎么选(vLLM/TensorRT-LLM/SGLang)
人工智能·llm·openai