普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月2日首页

前端ai对话框架semi-design-vue

作者 小趴菜bin
2025年4月2日 16:36

对于前端使用ai框架探索

semi-design-vue

实现功能 -

sse格式输出

接收指定命令处理

思考过程可折叠 - 适配deepseek等模型

清除上下文

请求错误返回

fetch请求封装

这是一个组件,可以引入vue3项目的任何一个页面里

import { Chat, MarkdownRender, Spin, Toast, Avatar, AvatarGroup, Tooltip, Space,Collapse } from '@kousum/semi-ui-vue';
import { defineComponent, ref, onMounted } from 'vue';
import { IconChevronUp } from '@kousum/semi-icons-vue';
import { getNewAgentSessionApi, sendMessageApi } from "../api/baseinfo";
import http from '../config/httpConfig';
import EventStreamRequest from '../config/httpFetch';
import { baseUrl } from '../config/baseUrl';

// 请求成功
const successMessage = {
  role: 'assistant',
  id: '1',
  createAt: 1715676751920,
  content: "请求成功"
};
// 等待中
const wattingMessage = {
  id: 'loading',
  role: 'assistant',
  status: 'loading'
};
// 请求失败
const errorMessage = {
  role: 'assistant',
  id: 'error',
  content: '请求错误',
  status: 'error'
};
const defaultMessage = [
  {
    role: 'assistant',
    id: '1',
    createAt: 1715676751919,
    content: ASSISTANT
  }
]
const roleInfo = ROLE_INFO;
const commonOuterStyle = {
  border: '1px solid var(--semi-color-border)',
  borderRadius: '16px',
  minHeight: '100%',
  height: '100%',
  margin: '0 auto',
  width: '100%',
  boxSizing: 'border-box'
};
let id = 0;
function getId() {
  return `id-${id++}`;
}
// 上传文件地址
const uploadProps = {
  action: 'https://api.semi.design/upload'
};

let post_message = ref('');// 指令输出结果
let post_switch = ref(true);//是指令输出还是问答输出
let post_think = ref(false); // 是否有思考过程


const CustomRender = defineComponent(() => {
  const sessionId = localStorage.getItem('chatSessionId');
  const intervalId = ref();
  const message = ref(defaultMessage);
  const onChatsChange = (chats) => {
    message.value = (chats);
  };
  const onMessageSend = async (content, attachment) => {
    message.value = [
      ...message.value,
      {
        role: 'assistant',
        status: 'loading',
        createAt: Date.now(),
        id: getId()
      }
    ];
    let data = {
      sessionId: sessionId,
      question: content
    };
    const form = new FormData();
    const eventStream = new EventStreamRequest(baseUrl + 'llm/chatStream', {
      data, onEvent: (eventData) => {
        if (eventData.indexOf("is running...") === -1) {
          // 判断是指令输出还是正常问答输出
          if (eventData.length >= 12) {
            // 预检查
            const regex = /^data:\{\"code\"/;
            const flag = regex.test(eventData);
            if (flag) {
              post_switch.value = true;
              // 指令输出
              let msg = eventData.slice(5);
              // let json = JSON.stringify(data);
              post_message.value = msg;
              
              const newAssistantMessage = {
                role: 'assistant',
                id: getId(),
                createAt: Date.now(),
                content: '问题检索完成',
              }
              message.value = [...message.value.slice(0, -1), newAssistantMessage]
            } else {
              const regexEnd = /^data:\[\{\{END\}\}\]/;
              const flagEnd = regexEnd.test(eventData);
              if (flagEnd) {
                if (post_switch.value){
                  // 指令抛出
                  window.parent.postMessage(post_message.value, '*');
                }else{
                  // 问答结束
                }
              }else{
                post_switch.value = false;
                post_message.value = "";
                // 问答输出
                // 空格换成 &sp;; ,换行换成&nl;;
                // 如果有思考过程 - 截取思考过程
                if (eventData.indexOf('<think>') > -1 && eventData.indexOf('</think>') === -1){
                  post_think.value = true;
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n')
                  let msg = msgStr.slice(12);
                  if (msg.indexOf('</think>')> -1){
                    // 思考结束
                    let resultStr = msg.slice(msg.indexOf(0,'</think>'));
                    let msgStr = resultStr.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n');
                    resultThinkResponse(msgStr, msgStr)
                  }else{
                    // 思考进行中
                    let resultStr = msg.slice(msg.indexOf('<think>') + 1);
                    let msgStr = resultStr.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n');
                    resultThinkResponse(msgStr, msgStr)
                  }
                  // let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br/>').replace(/<think>/g,'');
                  // let msg = msgStr.slice(5);
                  // generateMockResponse(msg);
                } else if (eventData.indexOf('<think>') > -1 && eventData.indexOf('</think>') > -1){
                  // 思考过程之后的回答结果
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br />');
                  let resultStr = msgStr.slice(msgStr.indexOf('</think>') + 8);
                  let thinkStr = msgStr.slice(12,msgStr.indexOf('</think>'));
                  // let msg = msgStr.slice(5);
                  // post_think.value = msg;
                  // resultThinkResponse(msg)
                  console.log(msgStr);
                  
                  resultThinkResponse(thinkStr, resultStr);
                }else{
                  post_think.value = false;
                  // 无思考过程返回值
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br/>').replace(/<think>/g,'');
                  let msg = msgStr.slice(5);
                  generateMockResponse(msg);
                }
                
              }
            }
          }else{
            console.log(eventData);
          }
        }
      },onError:(error)=>{
        const newAssistantMessage = {
          role: 'assistant',
          id: getId(),
          createAt: Date.now(),
          status: 'error',
          content: ERROR_TEXT,
        }
        message.value = [...message.value.slice(0, -1), newAssistantMessage]
      }
    });
    eventStream.start();
  };
  // 输出think结果
  const resultThinkResponse = (think,content) => {
    let newMessage = {
      role: 'think',
      id: getId(),
      createAt: Date.now(),
      content: content,
      think: think,
      post_think:true,
    };
    message.value = [...message.value.slice(0, -1), newMessage];
    intervalId.current = id;
  };
  // 输出结果
  const generateMockResponse = (content) => {
    const lastMessage = message.value[message.value.length - 1];
    // console.log(content);
    let newMessage = {
      role: 'assistant',
      id: getId(),
      createAt: Date.now(),
      content: content,
    };
    // console.log(lastMessage);

    message.value = [...message.value.slice(0, -1), newMessage];
    intervalId.current = id;
  };
  // 清除上下文
  const clearContext = () => {
    getNewAgentSessionApi().then((result) => {
      localStorage.setItem('chatSessionId', result);
    }).catch((err) => {
      console.log(err);
    });
  };
  // 重新提问
  const onMessageReset = (msg) => {
    generateMockResponse(msg.content);
  };
  // 停止生成
  const onStopGenerator = (msg) => {
    console.log(msg);
    http.cancelRequest();
    Toast.success('已取消');
    const cancel = {
      role: 'assistant',
      id: 'cancel',
      content: '已取消',
      createAt: 1715676751920,
    }
    setTimeout(() => {
      message.value = [...message.value.slice(0, -1), cancel]
    }, 500)
  }
  // 助手和用户对话背景色
  const renderByRole = ({ role, status }) => {
    if (status === 'error'){
      return { backgroundColor: ERROR_BG_COLOR }//错误消息背景色
    }
    return role === 'assistant'
      ? { backgroundColor: ASSISTANT_BG_COLOR } // 助理消息背景色
      : { backgroundColor: USER_BG_COLOR }; // 用户消息背景色
  }
  // 对话渲染
  const renderContent = (props) => {
    const { role, message, defaultNode, className } = props;
    console.log(message.role, post_think.value);
    if (message.content) {
      return <div class={className} style={renderByRole(message)}>
        {message.post_think && message.post_think===true ?(
          <Collapse expandIconPosition="left">
            <Collapse.Panel header="思考" showArrow={true} itemKey={message.id}>
              <MarkdownRender raw={`<myThink>${message.think}</myThink>`} components={components} />
            </Collapse.Panel>
          </Collapse>
        ):''}
        <MarkdownRender raw={message.content} />
      </div>
    } else {
      return <div class={className}>
        <Spin />
      </div>
    }
  };
  const components = () => {
    const components = {};
    components['myThink'] = ({ children, onClick }) => {
      return <p style={{ marginBottom: "12px" }}> {children} </p>
    }
  }
  const handleBefore = (file)=>{
    console.log(file);

    return
  }
  onMounted(async () => {
    try {
      const result = await getNewAgentSessionApi();
      message.value = defaultMessage;
      localStorage.setItem('chatSessionId', result);
    } catch (err) {
      message.value = [errorMessage];
    }
  });
  return () => (
    <Chat
      style={commonOuterStyle}
      chats={message.value}
      roleConfig={roleInfo}
      chatBoxRenderConfig={{ renderChatBoxContent: renderContent }}
      onChatsChange={onChatsChange}
      onMessageSend={onMessageSend}
      onStopGenerator={onStopGenerator}
      showClearContext={true}
      onClear={clearContext}
      onMessageReset={onMessageReset}
      uploadProps={{ uploadProps: uploadProps, disabled:true }}
      uploadTipProps={{ content :'上传功能开发中...'} }
    />
  );
})
export default CustomRender;

可修改配置文件 - 我是定义在全局中的

// 基础配置
const ROLE_INFO = {
  // 用户头像 - 名称
  user: {
    name: 'User',
    uuid:'user',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  },
  // 智能助手头像 - 名称
  assistant: {
    name: '智能助手',
    uuid:'assistant',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  },
  // 暂时不用管
  system: {
    name: '智能',
    uuid:'system',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  }
};
// 助手招呼用语
const ASSISTANT = "我是通用智能助手,请问有什么可以帮助您的?";
// 助手消息背景色
const ASSISTANT_BG_COLOR = '#ccf0ff';
// 用户消息背景色
const USER_BG_COLOR = '#10a2e0';
// 报错消息背景色
const ERROR_BG_COLOR = '#ff3f33';
// 报错消息提示语
const ERROR_TEXT = '请求错误';

fetch请求封装

export default class EventStreamRequest {
  constructor(url, options = {}) {
    this.url = url;
    this.options = options;
    this.controller = new AbortController();
    this.signal = this.controller.signal;
    this.isListening = false; // 新增的状态标志
    this.retryInterval = this.options.retryInterval || 3000; // 默认重试间隔为3秒
  }
  async start() {
    if (this.isListening) return; // 如果已经在监听,则不再启动新的监听
    this.isListening = true;
    const attemptConnect = async () => {
      try {
        const response = await fetch(this.url, {
          method: 'POST',
          responseType:'text/event-stream; charset=utf-8',
          headers: {
            'Content-Type': 'application/json',
            // ...this.options.headers,
          },
          signal: this.signal,
          body: JSON.stringify(this.options.data)
        });

        if (!response.ok) {
          throw new Error(`Failed to fetch event stream with status ${response.status}`);
        }

        this.processStream(response.body.getReader());
      } catch (error) {
        this.handleError(error);
        // setTimeout(attemptConnect, this.retryInterval); // 错误发生后尝试重新连接
      }
    };

    attemptConnect(); // 尝试连接
  }

  processStream(reader) {
    const decoder = new TextDecoder();
    let buffer = '';

    const processChunk = async ({ done, value }) => {
      if (done) {
        this.isListening = false; // 流结束时更新状态标志
        return;
      }

      buffer += decoder.decode(value, { stream: true });

      let index;
      while ((index = buffer.indexOf('\n\n')) !== -1) {
        const eventData = buffer.slice(0, index).trim();
        buffer = buffer.slice(index + 2);
        this.handleEvent(eventData);
      }

      reader.read().then(processChunk);
    };

    reader.read().then(processChunk);
  }

  handleEvent(eventData) {
    // console.log('Received event:', eventData);
    // 可以在这里调用外部传入的处理器
    if (typeof this.options.onEvent === 'function') {
      this.options.onEvent(eventData);
    }
  }

  handleError(error) {
    if (typeof this.options.onError === 'function') {
      this.options.onError(error);
    }
  }

  abort() {
    if (this.isListening) {
      this.controller.abort();
      this.isListening = false;
      console.log('EventStream request aborted');
    }
  }
}

实现效果:

731c4b625003e9fbd2114c3065c86465.png

b1637096e78666582eedb4c2f525e14f.png

😎 MCP 从开发到发布全流程介绍,看完不踩坑!

作者 oil欧哟
2025年4月2日 09:36

前言

在上一篇文章 🧐什么是 MCP 服务?如何利用 cursor + MCP 快速将现有服务接入大模型? 中 ,我介绍了一下 MCP 的概念以及如何快速搭建一个 MCP 服务,文中搭建了一个简单的示例在本地进行调试并顺利调用了工具。

在将上篇文章中的示例项目进行完善并发布的过程中,我对搭建 MCP 服务有了新的心得,也踩了一些坑,这篇文章我将更详细的讲讲从 MCP 服务的开发到发布的全流程。

示例项目需求

这次我打算实现一个简化权限策略配置的工具,目标就是通过 AI 来简化权限策略的配置流程。大家应该有听过 RBAC (基于角色的访问控制),通过分配角色(如管理员、用户)来控制权限,而不是直接给每个人单独设置权限。

不过 RBAC 的配置还是比较简单的,除了 RBAC 之外,如果需要更加精细化的资源权限管理,还有一种叫做 IAM (身份与访问管理)权限策略,相较于 RBAC ,IAM 中的配置更加复杂一些,角色是用一个个的策略(Policy)去组合出来的,而在策略中是通过一套语法去描述 是否允许某个资源的某种操作,就像下图展示的:

image.png

像是一些云平台,比如阿里云,AWS 这种超多资源需要精细化管理的平台就会用到这套权限管理体系,听起来是不是很复杂?实际上配置起来也确实是挺麻烦的, 因此我们需要利用 AI 来简化这些配置流程。

我希望能够用对话的方式让 AI 去分析我们对某个用户需要哪些权限,然后自动调用 API 去创建权限策略和角色,并将角色分配到具体的用户身上。而我们这次的示例项目也是从我实现的这个工具中剥离出来的,我尽量不涉及到业务细节。

项目搭建

MCP 支持使用多种语言进行开发,包括 Node.js, Python, Java, Kotlin, Go 等,这里我选择前端同学们更加熟悉的 Node.js 进行开发。

首先我们搭建项目,创建一个新的目录,这里我们就叫 IAM-policy-mcp,然后进入目录中执行 npm init 初始化项目,再安装一下需要的依赖:

pnpm add @modelcontextprotocol/sdk zod

pnpm add typescript @types/node -D

再创建一个 src 目录,写入一个 index.ts 文件:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "iam-policy-mcp",
  version: "0.0.1",
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("IAM Policy MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

这里是一个 MCP 服务的最小化实现,里面没有任何功能,仅仅只是以标准输入输出的把 MCP 服务给跑起来了,后续我们再加功能。在根目录创建一个 tsconfig.json 文件,写入如下配置:

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "~/*": ["./src/*"]
    },
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

添加工具

在 MCP 中,有很多种资源:

但这其中最重要的就是工具 tools,我们只使用工具资源就可以将一个完整的 MCP 服务给搭建起来,在我实际开发过程中,我也只使用过工具和资源(resource)这两种资源,并且目前不是每一个 AI 客户端都支持这些资源的,cursor 就只支持工具和资源这两种,所以我们先以这两种为主进行介绍。

toolserver 上的一个方法,用于让 MCP 服务向客户端公开可以执行的功能。通过工具,LLM 可以与外部系统交互、执行计算并在现实世界中采取行动,它的结构是这样的:

server.tool(工具名称,工具描述,入参格式,回调函数)

例如我想实现一个获取用户的权限策略数据的工具,我可以这么写:

import { z } from "zod";
import makeAPIRequest from "../request.js";

export const GetPermissionPolicySchema = z.object({
  id: z.string().optional(),
  search: z.string().optional(),
});

server.tool(
    "get_permission_policy",
    "基于 ID 获取权限策略详情,或根据筛选条件获取多个权限策略",
    GetPermissionPolicySchema.shape,
    async (args) => {
      const policyId = args.id;
      if (policyId) {
        return await makeAPIRequest(`/permission_policies/${policyId}`, "GET");
      } else {
        return await makeAPIRequest(
          `/permission_policies?search=${args.search}`,
          "GET"
        );
      }
    }

这里我们实现了一个名为 get_permission_policy 的工具,这个工具的作用我们在第二个参数工具描述中用自然语言描述出来,第三个参数是如果要使用我这个工具,需要传入什么格式的参数。

这里的参数格式我们要用 Zod 来定义,Zod 是一个 TypeScript 优先的模式声明和验证库,主要用于在运行时对数据进行类型校验,它的语法大家如果不熟悉可以把 typescript 的类型或者 schema 直接给 AI 让它帮我们翻译成 Zod 的语法。

前面三个参数都是给 AI 看的,AI 会解析用户对话中的语意,再基于我们的工具名称和工具描述看看是不是能满足用户的需求,如果符合的话就看看这个工具需要传递什么参数。如果参数也决定好了,那就将参数传递到第四个参数中的回调函数中,回调函数中是我们要执行的具体操作,在这里我们执行了 makeAPIRequest 操作,根据 AI 有没有传入 ID 来决定我们请求权限列表还是请求权限详情。

这里我讲讲 makeAPIRequest 中的实现:

//request.ts
import axios from "axios";
import { SERVER_URL, SERVER_PORT, TOKEN } from "./env.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

async function makeAPIRequest(path: string, method: string = "GET", data?: object): Promise<CallToolResult> {
  const baseUrl = SERVER_URL ? `${SERVER_URL}:${SERVER_PORT}` : SERVER_URL;
  const url = `${baseUrl}/api${path}`;

  try {
    const response = await axios({
      method,
      url,
      data,
      headers: {
        "X-API-KEY": TOKEN,
        "Content-Type": "application/json",
      },
    });

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(response.data, null, 2),
        },
      ],
    };
  } catch (error) {
    if (axios.isAxiosError(error)) {
      console.error(`Request failed: ${method} ${url}`);
      console.error(`Status: ${error.response?.status}, Error: ${error.message}`);

      if (error.response?.data) {
        try {
          const stringifiedData = JSON.stringify(error.response.data);
          console.error(`Response data: ${stringifiedData}`);
        } catch {
          console.error(`Response data: [Cannot parse as JSON]`);
        }
      }

      return {
        isError: true,
        content: [
          {
            type: "text",
            text: JSON.stringify(
              `Status: ${error.response?.status}\nMessage: ${error.message}
Data:\n${JSON.stringify(error.response?.data || {}, null, 2)}`,
              null,
              2
            ),
          },
        ],
      };
    } else {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: JSON.stringify(error, null, 2),
          },
        ],
      };
    }
  }
}

export default makeAPIRequest;

这个函数 makeAPIRequest 是一个封装好的 API 请求工具,主要功能就是用 axios 发送 HTTP 请求并统一处理成功和错误的结果,这个返回结果的结构是 MCP 规定的标准格式,类似这样:

try {
  // Tool operation
  const result = performOperation();
  return {
    content: [
      {
        type: "text",
        text: `Operation successful: ${result}`
      }
    ]
  };
} catch (error) {
  return {
    isError: true,
    content: [
      {
        type: "text",
        text: `Error: ${error.message}`
      }
    ]
  };
}

这里面我们还引用了一个 env 文件,通过 process.env 获取环境变量:

export const SERVER_URL = process.env.SERVER__ADDRESS;
export const SERVER_PORT = process.env.SERVER__PORT ;
export const TOKEN = process.env.TOKEN;

环境变量在后续是可以从外部传入的,如何配置我们后面讲调试的时候介绍。实现了请求的函数后,如果用户在 AI 客户端中提到有关于获取用户权限策略的需求,AI 就会来调用我们这个工具了。同样的,如果我要实现一个用于更新角色的工具,可以这么写:

import { z } from "zod";
import makeAPIRequest from "../request.js";

export const CreateRoleSchema = z.object({
  role: z.object({
    name: z.string().min(1).max(256).describe("The role name."),
    desc: z.string().max(65536).describe("The object description."),
    labels: z.record(z.string().regex(/^.+$/).min(1).max(65536)).describe("The labels of the object."),
    policies: z
      .array(
        z
          .string()
          .regex(/^[a-zA-Z0-9-_.]+$/)
          .min(1)
          .max(256)
          .describe("The object ID.")
      )
      .describe("The polices attached to the role."),
  }),
});

server.tool("create_role", "创建一个角色", CreateRoleSchema.shape, async (args) => {
    return await makeAPIRequest(`/roles`, "POST", args.role);
});

相较于获取数据的工具,创建的工具中我们就要把入参类型给补充完整,入参的类型结构不仅会提供给 AI 用于生成参数,同时也会起到校验的作用,如果比较蠢的大模型随便编一个错误格式的入参,那么 MCP 服务校验失败会是不会调用这个工具的回调函数的,而且还会把正确的入参结构和错误的位置再次告诉大模型,帮助它构建正确的入参。

工具的概念其实很简单,通过描述工具的名称让 AI 知道我们的工具可以用来干什么,然后再描述一下工具需要的参数让 AI 去构建传入,最后我们自己来处理返回的结果,是不是还蛮容易理解的~

注意点!:有些中后台系统里面概念和资源有很多,因此我们可能会对应 API 精细化的写很多个工具,例如每个资源的增删查改都写一个,但实际上工具越多,效果反倒越差,因为我们的工具列表是要提供给 AI 选择的,过多的工具会导致过长的上下文,除了开销会更大,AI 的注意力也会被分散,导致最终的执行效果不如预期。

而且 AI 客户端支持的单个 MCP 服务工具数量是有上限的,以 cursor 为例,最多支持的工具数量是 40 个 image.png

实现资源

资源(Resources)允许 MCP 服务公开一些可以让客户端读取并作为 LLM 交互上下文的数据和内容,在我们前面实现的工具中其实也有用于给 AI 获取数据的功能,但那个权限策略的数据相对来说比较动态一点,用户可能随时对它进行编辑,因此需要每次对话的时候执行 API 去获取一下,而资源则是相对静态的资源,例如:

  • 源代码
  • 配置文件
  • 日志文件
  • JSON/XML 数据
  • 纯文本
  • 图片
  • PDF
  • 音频文件
  • 视频文件
  • 其他非文本格式

但是想这些内容似乎也不是非常静态,所以我对于资源和工具之间的选择其实还有点模糊,因为能用资源的地方用工具似乎也行,至少在我实际调试的时候没有感受出来太大的差别。不过我还是将我的一些静态的内容通过资源的方式提供给 AI 了。

resource 也是 server 上的一个方法,它的结构是这样的:

server.resource(资源的名称,资源的 URI,回调函数)

这里举个例子:

server.resource(
  "权限策略配置示例和所有资源操作",
  "text://permission-policy-config-reference.json",
  () => {
    return {
      contents: [
        {
          uri: "text://permission-policy-config-reference.json",
          text: JSON.stringify(policyResourcesActions, null, 2),
          mimeType: "application/json",
        },
      ],
    };
  }
);

这里的资源的 URI 并不需要真的指向某个文件或路径,只是以 URI 的形式组织一个类似静态资源的名称,最终我们返回的内容还是从回调函数中返回的,在这个示例中,我返回了一个静态的 json 结构,用于 AI 编辑权限策略的时候可以用于参考。

有时我们的工具中虽然可以定义入参的格式,但是这个格式只能限制参数整体的结构,而参数中包含的内容还是 AI 自己想的,比如我们有一个结构:

z.object({
  code: z.string()
})

AI 会给我传入 {code: xxx} ,但是代码的内容还是没法规范,如果我们想让他阅读某个文档后,基于文档中的规范去传入代码,应该怎么做呢?

首先,目前 MCP 中的工具是没有什么依赖或者工作流的实现的,有时候我们选择比较聪明的模型,它可以基于我们提供的工具和资源,自己组织合适的步骤,先阅读文档,再执行操作,但大多数时候它还是会选择自己瞎编。

我自己摸索的一个方法是在工具的描述中告诉 AI 需要去先阅读某个资源后再进行操作,例如:

export const UpdatePermissionPolicySchema = z.object({
  id: IDSchema,
  permissionPolicy: PermissionPolicySchema,
  alreadyReadResource: z.boolean(),
});

  server.tool(
    "update_permission_policy",
    "更新权限策略,使用此工具前请阅读 mcp 资源 text://permission-policy-config-reference.json",
    UpdatePermissionPolicySchema.shape,
    async (args) => {
      if (!args.alreadyReadResource) {
        return {
          content: [
            {
              type: "text",
              text: "Please read the mcp resource text://permission-policy-config-reference.json before using this tool",
            },
          ],
        };
      }
      return await makeAPIRequest(
        `/permission_policies/${args.id}`,
        "PUT",
        args.permissionPolicy
      );
    }
  );

我利用 AI 会分析工具描述的特点,将前置依赖写在描述中,并且在入参格式中,我加了一个 alreadyReadResource 这个参数与业务无关,纯粹是一个补充描述,让 AI 生成参数的时候自己反思一下到底看没看,不过它就算没看有时候也会直接填写一个 true ┑( ̄Д  ̄)┍。

我自己实测的结果是像 Deepseek 或者 Claude Sonnet 3.5/3.7 这些模型在调用这个工具前确实会很大概率去看文档了,参考下面这个截图

image.png

优化项目结构

当我们的工具类型和资源比较多的时候,可以参考我的一个组织方式,我将资源,工具,类型分为三个目录,其中以不同的资源类型进行分类,然后相互导入使用,如下图:

image.png

在每一个类型的工具文件中,我会这样创建工具:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import makeAPIRequest from "../request.js";
import { AssignRoleToUserSchema, CreateRoleSchema, DeleteRoleSchema, GetRoleSchema } from "../types/role.js";

const setupRoleTools = (server: McpServer) => {

  server.tool("get_role", "Get role details by ID or list roles based on filters", GetRoleSchema.shape, async (args) => {
    const roleId = args.id;
    if (roleId) {
      return xxx;
    } else {
      return xx;
    }
  });


  server.tool("create_role", "Create a role", CreateRoleSchema.shape, async (args) => {
    return await makeAPIRequest(`/roles`, "POST", args.role);
  });

  server.tool("update_assigned_roles_for_user", "Update assigned roles for a user", AssignRoleToUserSchema.shape, async (args) => {
    return xxx;
  });

  server.tool("delete_role", "Delete a role", DeleteRoleSchema.shape, async (args) => {
    return await xxx;
  });

};

export default setupRoleTools;

通过实现一个 setupXXXTools 函数,传入 server 去添加工具,然后在 index.ts 中这样使用:

//...
import setupRoleTools from "./tools/role.js";

const server = new McpServer({
  name: "iam-policy-mcp",
  version: "0.0.1",
});

setupRoleTools(server);

//...

当然这个大家可以基于自己的习惯来,并没有什么强制规范,只是一个思路,不要把全部业务都堆在一个文件,让整个项目可读性可维护性强一点。

调试服务

在我们的服务开发完成后,在 package.json 中写入如下配置:

{
  "type": "module",
  "name": "你的项目名称",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc "
  }
//...
}

然后执行 pnpm build 构建一下项目,看到 dist 目录就是构建好了,接下来可以开始调试。

调试服务我们可以使用官方提供的一个调试工具 MCP Inspector,使用也很简单,在命令行输入:

npx @modelcontextprotocol/inspector <command> <arg1> <arg2>

其中 command 是启用我们 MCP 服务的命令, arg 是参数,当然你也可以不填写,直接执行 npx @modelcontextprotocol/inspector

image.png

浏览器打开 http://localhost:5173 后,左侧我们需要填写一些配置信息 Command 输入入 nodeArguments 输入 MCP 服务构建后的脚本位置 项目地址/dist/index.jsEnvironment Variables 添加我们需要的环境变量。

image.png

配置完成后,点击 Connect 连接,如果连接成功会亮一个小绿灯,右侧的标签栏我们选择 Tools 然后点击 List Tools 就可以看到我们 MCP 服务中所有的工具了。

image.png

此时我们点击任意一个工具,最右侧会有一个类似 API 调试的控制台,我们可以根据工具的入参格式输入参数,然后点击 Run Tool 就会调用工具并打印响应了。

image.png

注意点!:由于 MCP 服务是以标准输入输出方式运行在我们自己的主机上的,所以不同的服务可能对电脑的环境配置要求不同。

我在实际调试的过程中,macos 的电脑基本没有遇到啥问题,但是 windows 电脑上各种 MCP 服务无法正常配置,开发的时候调试也是很多问题,基本上都是 windows 上 node.js 的环境配置相关的,而且不同的 AI 客户端的实现有所不同,因此也会出现这个客户端可以配置而相同的配置另外一个客户端却报错的情况。

这里如果你是 windows 系统的用户,在开发 MCP 我推荐你直接在 wsl 的环境内进行开发,这样不论是开发还是调试都会更加顺畅。

在 AI 客户端调试服务

这里我们以 cline 为例讲讲如何调试本地项目,之所以选择 Cline 是因为每一次调试都需要消耗蛮多 token,如果使用 cursor 的话就要消耗快速查询次数,并且必须开 agent 模式,调试一次消耗两个额度。而使用 cline 我们可以配置例如 deepseek, chatgpt-4o-mini 这种比较经济的模型用于调试,整体成本会降低很多。

首先我们在 Vscode 中安装 Cline:

image.png

然后点击 Cline 右上角设置按钮,配置一下我们的 AI 提供商的 key ,这里我选择的是 openrouter,这样可以很方便的切换不同的模型进行调试。

image.png

下一步我们点击第二个服务器的图标,然后切换到已安装的服务(Installed):

image.png

再点击 Configure MCP Servers,就会弹出一个配置文件的编辑,我们写入以下格式:

{
  "mcpServers": {
    "iam-policy-mcp": {
      "command": "node",
      "args": [
        "E:\\projects\\IAM-policy-MCP\\dist\\index.js"
      ],
      "env": {
        "SERVER_URL": "https://xxx.com",
        "TOKEN": "xxx"
      }
    }
  }
}

args 中的脚本地址改成你自己服务打包后的产物地址,然后再把环境变量改一下,配置成功后 cline 中就会多一个 MCP 服务的展示,并且亮着小绿灯,如果配置失败在这个服务下方也会打印错误信息:

image.png

下一步我们点击右上角的 Done ,然后在 Cline 的对话框中输入可以调用到 MCP 中工具的指令,如果看到 Cline 中向我们申请调用 MCP 的权限时,说明我们的 MCP 服务配置成功了,后续我们只需要我们实际业务需求不断调试调用的结构即可。

image.png

Cline 的每次对话上方还会展示消耗的 token 数量,对应的金额,当前对话的上下文大小,我们也会发现 MCP 服务消耗的 token 数量是非常多的,它会把我们所有工具的信息都发送给大模型,如果调用了工具或者查看资源,还会将工具中响应的内容也提供给大模型,这也就是为什么推荐大家选择一些比较经济的模型去调试。

image.png

注意点!:每一次我们修改源代码之后,不仅要重新构建代码,还需要在对应的 AI 客户端中重启一下 MCP 服务,否则调试的时候还是用的上一次的代码跑起来的进程,虽然这是基础问题,但很容易被忽略。

发布服务

当我们的服务在本地 AI 客户端也调试完成之后,下一步我们可以将服务发布到线上。如果你有用过一些常用的 MCP 服务的话,应该会了解到在 AI 客户端配置已发布 MCP 服务的时候,我们需要在配置文件中写入如下配置:

{
  "mcpServers": {
    "apisix-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "xxx-mcp"
      ]
    }
  }
}

这段配置中,我们告诉 AI 客户端需要通过 npx -y 的方式调用 MCP 服务的运行脚本,而具体的包名则需要我们发布到 npm 上才能正常访问。我们在我们的 MCP 服务开发完成后,就需要将包发布到 npm 库中。

为了发布后可以正常使用,我们要先做两件事,第一个是在 src/index.ts 中第一行添加 Shebang

#!/usr/bin/env node

Node.js 的 Shebang(也称为 hashbang)是一个特殊的注释行,通常出现在脚本文件的开头,用于告诉操作系统如何执行这个脚本文件。它的格式是 #! 后跟解释器的路径,例如:

#!/usr/bin/env node
console.log("Hello World!");

为什么需要 Shebang?

  1. 直接运行脚本:有了 Shebang,你可以直接在终端运行脚本文件,而不需要先输入 node 命令

    • 没有 Shebang:node my-script.js
    • 有 Shebang:./my-script.js(需要先给文件可执行权限)
  2. 跨平台兼容/usr/bin/env node 这种写法会查找系统 PATH 中的 node,比直接写 /usr/local/bin/node 更灵活

如果不添加你就会发现通过 npx 运行线上脚本的时候会提示我们缺少依赖,下一步配置一下 package.json

//...
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "tsc && shx chmod +x dist/*.js",
    "start": "node dist/index.js",
    "lint": "eslint . --fix"
  },
+  "files": [
+    "dist"
+  ],
//...

build 命令中增加 && shx chmod +x dist/*.js,要记得先 pnpm add shx -D 安装一下依赖,这一步是为了避免权限问题导致脚本无法执行,前面 Shebang 中也提到了这个问题,也是在发布调试的过程中遇到的,所以强调一下。

接下来我们还需要增加一个 files 字段,里面是一个数组,我们只需要写 dist 一项,files 用于指定我们发布时要选择的目录路径。

完成以上步骤后,在执行 npm pack 可以打一个测试包,看看里面是不是我们需要的内容,通常来说就是我们 dist 目录中的所有东西,还有 readme.mdpackage.json 了。

如果没有问题我们就执行 npm publish 进行发布,如果本地没有登陆 npm 需要执行 npm login 先登录一下再发布。

发布完成后,我们可以将前面在 cline 中写入的配置文件修改一下,改成线上的执行命令:

{
  "mcpServers": {
    "iam-policy-mcp": {
      "command": "npx",
      "args": [
        "-y", "xxx-MCP"
      ],
      "env": {
        "SERVER_URL": "https://xxx.com",
        "TOKEN": "xxx"
      }
    }
  }
}

如果 Cline 中和本地服务一样亮了绿灯,就说明已经配置正确并发布成功了。

发布到导航站

目前 MCP 的热度很高,已经有很多的导航站了,如果需要增加曝光,让更多人用上你开发的 MCP ,可以投稿到以下几个我整理的导航站:

要注意这些站点都是要求项目开源的。

总结

实现一个 MCP 服务其实并不难,注意一下文中提到的容易踩坑的点,一两天的时间就可以完成开发到发布了,后续我会继续研究 MCP 服务如何做自动化测试,如何在和大模型对话的过程中减少 token 消耗以及优化执行效果。

如果文章对你有帮助不妨点个赞👍支持一下,respect~

昨天以前首页

秒哒首发即爆发!上线首日吸引2万用户,打造3万应用!

作者 百度Geek说
2025年4月1日 09:57

3月24日,国内首个对话式应用开发平台百度秒哒全量上线,上线24小时就迅速吸引超2万用户体验,创建应用数量突破3万个,相当于每3秒就诞生1个应用!

秒哒以 "无代码编程+多智能体协作+多工具调用" 的技术组合,颠覆传统开发流程。用户通过自然语言描述需求即可自动生成完整功能代码,实现“3分钟生成+1小时迭代”的极致开发体验,生成H5邀请函、网站、小游戏等,用户登录秒哒首页即可体验。

(体验地址miaoda.baidu.com)

图片

----------END----------

推荐阅读

秒哒,全面开放!

图灵数据洞察平台-TDF(Turing Data Finder)

两连发!文心大模型4.5及X1,上线千帆!

百度百舸万卡集群的训练稳定性系统设计和实践

LLM增强语义嵌入的模型算法综述

前端与 AI 结合的 10 个可能路径图谱

2025年3月31日 23:58

本文结合前端实际应用场景,系统梳理了当前前端与 AI 技术结合的 10 个可能路径,从可落地性、技术适配度、业务需求等角度出发,为探索者提供一套系统化的导航图谱。

路径概览对比表

序号 路径名称 入手难度 技术成熟度 可落地性
01 表单智能化 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
02 Prompt to UI ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
03 文案翻译润色 ⭐⭐⭐⭐ ⭐⭐⭐⭐
04 前端开发助手 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐
05 智能图表生成 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
06 文档问答系统 ⭐⭐⭐ ⭐⭐ ⭐⭐
07 Prompt IDE ⭐⭐ ⭐⭐ ⭐⭐
08 合规审核助手 ⭐⭐⭐ ⭐⭐ ⭐⭐
09 知识整理工具 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
10 陪伴式 UI 应用 ⭐⭐⭐

⭐ 表示难度/成熟度等级,1-4星表示从易到难/从低到高。


01. 表单智能化 · 文本识别与结构分析

  • 关键词:OCR、NLP、结构抽取、低代码字段生成
  • 应用场景:投标文件、合同、审批表等文档识别并结构化,快速生成字段配置表单。
  • 技术点:PaddleOCR、text2json 结构抽取、多模态对齐
  • 推荐实现路径:结合前端低代码平台,通过后端模型接口返回字段定义和组件类型,实现可视化配置器。

02. Prompt to UI · 自然语言生成组件

  • 关键词:Prompt Engineering、DSL、代码生成
  • 应用场景:输入自然语言,生成前端页面结构(如表单、卡片、图表)
  • 技术点:提示词设计、组件映射函数、UI 编排逻辑
  • 推荐实现路径:结合 monaco-editor + GPT API + JSON schema 实现交互式 UI DSL 编辑器。

03. 多语言文本翻译与润色助手

  • 关键词:翻译、NLP、文案助手
  • 应用场景:生成国际化 UI 内容、润色 ESG 报告、自动校对系统文案
  • 技术点:ChatGPT、Prompt 模板设计、语气控制
  • 推荐实现路径:在后台管理系统或内容编辑器中嵌入 AI 辅助面板,支持多语言输出、语境风格切换。

04. 前端智能开发助手(Code Copilot)

  • 关键词:代码补全、函数建议、低代码生成器
  • 应用场景:辅助组件开发、低代码搭建平台中的组件智能提示
  • 技术点:代码上下文抽取、提示词编排、AST 分析
  • 推荐实现路径:集成在开发平台或 IDE 插件中,结合 GPT 接口进行语义补全与推荐。

05. 数据图表智能生成

  • 关键词:数据洞察、Chart Suggestion、Echarts 配置生成
  • 应用场景:用户上传数据后,自动生成图表 + 标题 + 可视化建议
  • 技术点:数据分析、图表模板库、摘要生成
  • 推荐实现路径:结合 GPT + Echarts,自动解析表头、字段意义、生成 chart option 配置。

06. 文档问答系统(基于 PDF / RAG)

  • 关键词:PDF解析、语义检索、Chat UI
  • 应用场景:用户上传标书/合同等文档,提问并获取内容位置 + 高亮显示
  • 技术点:Embedding、向量检索、回答定位与标注
  • 推荐实现路径:前端负责文件上传、QA 对话框、内容高亮,后端负责检索与内容生成。

07. Prompt IDE 工具集成

  • 关键词:Prompt 管理、指令构建器、多模型支持
  • 应用场景:构建自己的 Prompt 管理器,用于内部业务系统交互、AI 接入开发
  • 技术点:Prompt 编排器、参数控制 UI、模型切换适配
  • 推荐实现路径:构建 Prompt 管理工作台,支持结构化参数输入、历史记录回溯、对话调试。

08. 投标文件/合规审核辅助系统

  • 关键词:文档合规性分析、逻辑校验、专家辅助工具
  • 应用场景:标书评审、内容合规校验、风险项提示
  • 技术点:结构抽取、多任务 Prompt、问题高亮
  • 推荐实现路径:结合前端高亮模块 + AI 检查结果列表,支持人工修正和反馈回传。

09. AI 驱动的知识整理工具

  • 关键词:Obsidian、自动摘要、知识图谱
  • 应用场景:笔记整理、思维关系可视化、内容生成协助
  • 技术点:摘要抽取、链接关系识别、Markdown 插件
  • 推荐实现路径:构建知识卡片 + 内容联想推荐引擎,实现基于内容的关联网络。

10. 拥有人格的陪伴式 UI 应用

  • 关键词:人格提示词、心理陪伴、学习引导
  • 应用场景:构建一个具备角色感的 AI 助手,用于学习陪伴、自我认知、压力舒缓等场景
  • 技术点:角色设定 + 语境保持、会话记忆、个性化提示词链路
  • 推荐实现路径:前端搭建 Chat UI + 人格切换器 + 回忆记忆系统,实现可“共情”的交互界面。

快速创建 TS MCP 脚手架:gen-mcp

作者 lecepin
2025年3月28日 22:17

在上一篇文章《快速开发一个 MCP 服务,增强你的 AI Agent》中,我们讨论了如何开发 MCP 服务来增强 AI Agent 的能力。但是,我注意到社区中缺乏一个好用的 MCP 脚手架工具,许多现有的工具要么不完善,要么已经停止维护。

为了解决这个问题,我开发了一个简单易用的 MCP CLI 工具:gen-mcp,它可以帮助开发者快速创建 MCP 服务项目,尤其对 TypeScript 用户更加友好。

为什么需要这个工具?

开发 MCP 服务时常常面临以下问题:

  1. 项目结构搭建繁琐
  2. 配置 TypeScript 环境需要额外工作
  3. 不同传输协议(如 SSE 和 Stdio)的实现方式不同
  4. 缺乏最佳实践的参考

gen-mcp正是为解决这些问题而生。

工具特点

  • 支持两种主流传输方式:SSE 和 Stdio
  • 提供完整的 TypeScript 支持
  • 内置笔记应用示例,展示资源、工具和提示的使用
  • 简单直观的命令行交互

快速上手

安装

# 全局安装
npm install -g gen-mcp

# 或者直接使用 npx
npx gen-mcp

image.png

创建项目

安装后,只需运行以下命令:

red-mcp
# 或
mcp
# 或
npx gen-mcp

按照交互提示:

  1. 选择模板(Stdio 或 SSE 传输)
  2. 指定安装路径
  3. 输入包名

然后,工具会自动为你创建项目,并提供后续步骤的指引。

模板介绍

MCP Server - Stdio 传输

这个模板适合开发命令行工具集成的 MCP 服务,比如与 Claude CLI 集成。

主要特点:

  • 通过标准输入/输出流通信
  • 适合开发命令行工具
  • 可以通过npm link快速全局安装进行测试

MCP Server - SSE 传输

这个模板适合开发 Web 应用集成的 MCP 服务,比如与网页版 Claude 集成。

主要特点:

  • 使用 Server-Sent Events 进行通信
  • 内置 Express 服务器
  • 支持 CORS,方便前端集成
  • 提供开发模式自动重启

调试与配置

开发 MCP 服务时,调试和配置是两个关键环节。我们的模板提供了完善的支持:

调试功能

  1. MCP Inspector 集成:每个模板都可以通过以下命令启动调试:

    npm run inspector
    

    Inspector 提供了直观的界面,可以实时查看资源、工具调用和提示的执行情况。

  2. 开发模式监控:SSE 模板内置了开发模式,会监听文件变化并自动重启服务:

    npm run dev
    

配置 MCP 服务

将您开发的 MCP 服务添加非常简单:

  1. Cursor 集成

    • 在 Cursor 中使用时,可以通过右侧栏"连接"面板添加自定义 MCP 服务
    • 位置:~/.cursor/mcp.json
    • 输入服务名称和 URL(对于 SSE 模板)
    • 对于 Stdio 模板,可配置命令路径
  2. Claude 集成

  • 同上
  • 位置:~/Library/Application Support/Claude/claude_desktop_config.json

Cli 模式:

{
  "mcpServers": {
    "server-name": {
      "command": "npx",
      "args": ["-y", "mcp-server"],
      "env": {
        "API_KEY": "value"
      }
    }
  }
}

SSE 模式:

{
  "mcpServers": {
    "server-name": {
      "url": "http://localhost:3000/sse",
      "env": {
        "API_KEY": "value"
      }
    }
  }
}

示例应用:笔记系统

两个模板都内置了一个简单的笔记系统示例,展示了 MCP 的三大核心功能:

  1. 资源(Resources):通过note:///{id}URI 访问笔记
  2. 工具(Tools)create_note工具用于创建新笔记
  3. 提示(Prompts)summarize_notes用于生成所有笔记的摘要

这个示例不仅展示了 MCP 的基本用法,也为你自己的项目提供了参考架构。

注意

如果出现报错,可能是由于 nvm 等多版本管理工具导致 node 找不到。可以 which node 后,修改 index.js 的 shebang。

github.com/lecepin/gen…


微信搜索“好朋友乐平”关注公众号。

github原文地址

HelloGitHub 第 108 期

2025年3月28日 07:52
本期共有 39 个项目,包含 C 项目 (3),C# 项目 (1),C++ 项目 (3),CSS 项目 (1),Go 项目 (3),Java 项目 (1),JavaScript 项目 (5),Kotlin 项目 (1),Python 项目 (5),Rust 项目 (2),Swift 项目 (1),人工智能 (5),其它 (5),开源书籍 (3)

前端 MCP 工具 开发教程

作者 Anguske
2025年3月27日 13:51

MCP (Model Context Protocol)工具 开发教程

目录

  1. 简介
  2. 核心概念
  3. 使用示例
  4. 最佳实践

简介

MCP (Model Context Protocol) 是一个用于构建 AI 模型与外部工具交互的协议。它允许我们创建自定义工具和资源,使 AI 模型能够执行特定的任务。 MCP工具开发使用modelcontextprotocol 这个SDK, github地址: github.com/modelcontex…

核心概念

1. StdioServerTransport

StdioServerTransport 是 MCP 的传输层,它通过标准输入输出(stdin/stdout)实现服务器与客户端之间的通信。

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";

const transport = new StdioServerTransport();

2. McpServer

McpServer 是 MCP 的核心服务器类,用于创建和管理工具与资源。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";

const server = new McpServer({
  name: "your-server-name",
  description: "服务器描述",
  version: "1.0.0"
});

3. Tool(工具)

Tool 是 MCP 中用于执行特定任务的函数。每个工具都需要:

  • 名称
  • 参数模式(使用 Zod 进行验证)
  • 执行函数
import { z } from "zod";

server.tool(
  "tool-name",
  {
    param1: z.string(),
    param2: z.number().optional()
  },
  async (params) => {
    // 工具实现
    return {
      content: [{
        type: "text",
        text: "执行结果"
      }]
    };
  }
);

4. Resource(资源)

Resource 用于管理可访问的数据或状态。每个资源都需要:

  • 名称
  • URI 模板
  • 获取内容的函数
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";

server.resource(
  "resource-name",
  new ResourceTemplate("resource://{id}", { list: undefined }),
  async (uri, { id }) => ({
    contents: [{
      uri: uri.href,
      text: `资源内容: ${id}`
    }]
  })
);

使用示例

这里我使用 modelcontextprotocol的typescript-sdk进行开发

安装依赖

首先,需要安装必要的 npm 包:

npm install @modelcontextprotocol/sdk @playwright/test pngjs pixelmatch zod

让我通过一个实际的 Playwright UI 测试工具来理解这些概念如何协同工作。这个示例将展示如何创建一个完整的视觉测试工具。

1. 类型定义

首先,我们需要定义测试配置和结果的类型:

// types.ts
export interface VisualTestConfig {
  url: string;
  selector?: string;
  waitForSelector?: string;
  waitForTimeout?: number;
  threshold?: number;
  ignoreSelectors?: string[];
  viewport?: {
    width: number;
    height: number;
  };
  baselineImagePath?: string;
  baselineImage?: string | Buffer;
  login?: {
    url: string;
    usernameSelector: string;
    passwordSelector: string;
    submitSelector: string;
    username: string;
    password: string;
    successSelector?: string;
  };
  autoLogin?: {
    username: string;
    password: string;
    usernameSelector: string;
    passwordSelector: string;
    submitSelector: string;
    successSelector?: string;
    loginUrlPattern?: string;
  };
}

export interface VisualTestResult {
  success: boolean;
  message?: string;
  error?: string;
  diffPixels?: number;
  threshold?: number;
  passed?: boolean;
  baselineCreated?: boolean;
  baselineUpdated?: boolean;
  screenshots?: {
    current?: string;
    diff?: string;
  };
}

2. 创建服务器

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";

const server = new McpServer({
  name: "visual-test",
  description: "UI visual comparison test tool",
  version: "1.0.0"
});

3. 实现核心功能

3.1 登录功能
async function login(page: any, loginConfig: VisualTestConfig["login"]) {
  if (!loginConfig) return;

  await page.goto(loginConfig.url);
  await page.waitForSelector(loginConfig.usernameSelector);
  await page.waitForSelector(loginConfig.passwordSelector);
  await page.waitForSelector(loginConfig.submitSelector);

  await page.fill(loginConfig.usernameSelector, loginConfig.username);
  await page.fill(loginConfig.passwordSelector, loginConfig.password);
  await page.click(loginConfig.submitSelector);

  if (loginConfig.successSelector) {
    await page.waitForSelector(loginConfig.successSelector);
  } else {
    await page.waitForNavigation();
  }
}
3.2 自动登录功能
async function autoLogin(page: any, config: VisualTestConfig["autoLogin"]) {
  if (!config) return false;

  try {
    await page.waitForSelector(config.usernameSelector);
    await page.waitForSelector(config.passwordSelector);
    await page.waitForSelector(config.submitSelector);

    await page.fill(config.usernameSelector, config.username);
    await page.fill(config.passwordSelector, config.password);
    await page.click(config.submitSelector);

    if (config.successSelector) {
      await page.waitForSelector(config.successSelector);
    } else {
      await page.waitForNavigation();
    }

    return true;
  } catch (error) {
    console.error("Auto login failed:", error);
    return false;
  }
}
3.3 视觉测试核心功能
async function runVisualTest(config: VisualTestConfig): Promise<VisualTestResult> {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  try {
    // 设置视口大小
    const defaultViewport = { width: 1280, height: 720 };
    await page.setViewportSize({
      width: config.viewport?.width ?? defaultViewport.width,
      height: config.viewport?.height ?? defaultViewport.height
    });

    // 登录处理
    if (config.login) {
      await login(page, config.login);
    }

    // 访问目标页面
    await page.goto(config.url);
    await page.waitForLoadState("networkidle");

    // 检查是否需要自动登录
    await checkLoginRedirect(page, config);

    // 等待指定元素
    if (config.waitForSelector) {
      await page.waitForSelector(config.waitForSelector);
    }

    // 等待指定时间
    if (config.waitForTimeout) {
      await page.waitForTimeout(config.waitForTimeout);
    }

    // 隐藏需要忽略的元素
    if (config.ignoreSelectors?.length) {
      await page.evaluate((selectors) => {
        selectors.forEach((selector) => {
          const elements = document.querySelectorAll(selector);
          elements.forEach((el) => {
            (el as HTMLElement).style.visibility = "hidden";
          });
        });
      }, config.ignoreSelectors);
    }

    // 获取页面截图
    const screenshot = await page.screenshot({
      fullPage: !config.selector,
      type: "png",
      ...(config.selector ? { selector: config.selector } : {}),
    });

    // 保存当前截图
    const currentScreenshotPath = path.join(screenshotsDir, "current.png");
    fs.writeFileSync(currentScreenshotPath, screenshot);

    // 处理基准图片
    const baselineScreenshotPath = path.join(screenshotsDir, "baseline.png");
    if (fs.existsSync(baselineScreenshotPath)) {
      console.log("使用现有基准图片");
    } else if (config.baselineImagePath) {
      const baselineBuffer = fs.readFileSync(config.baselineImagePath);
      fs.writeFileSync(baselineScreenshotPath, baselineBuffer);
    } else if (config.baselineImage) {
      let baselineBuffer: Buffer;
      if (Buffer.isBuffer(config.baselineImage)) {
        baselineBuffer = config.baselineImage;
      } else {
        baselineBuffer = Buffer.from(config.baselineImage, "base64");
      }
      fs.writeFileSync(baselineScreenshotPath, baselineBuffer);
    } else {
      fs.copyFileSync(currentScreenshotPath, baselineScreenshotPath);
      return {
        success: true,
        message: "创建新的基准截图",
        baselineCreated: true,
      };
    }

    // 图片对比
    const baseline = PNG.sync.read(fs.readFileSync(baselineScreenshotPath));
    const current = PNG.sync.read(screenshot);

    if (baseline.width !== current.width || baseline.height !== current.height) {
      fs.copyFileSync(currentScreenshotPath, baselineScreenshotPath);
      return {
        success: true,
        message: "更新基准截图",
        baselineUpdated: true,
      };
    }

    // 创建差异图片
    const { width, height } = baseline;
    const diff = new PNG({ width, height });
    const numDiffPixels = pixelmatch(
      baseline.data,
      current.data,
      diff.data,
      width,
      height,
      { threshold: config.threshold ? config.threshold / 100 : 0.1 }
    );

    // 保存差异图片
    fs.writeFileSync(
      path.join(screenshotsDir, "diff.png"),
      PNG.sync.write(diff)
    );

    return {
      success: true,
      message: "成功创建差异图片",
      diffPixels: numDiffPixels,
      threshold: config.threshold || 100,
      passed: numDiffPixels < (config.threshold || 100),
    };
  } catch (error) {
    console.error("视觉对比测试失败:", error);
    return {
      success: false,
      error: error instanceof Error ? error.message : "未知错误",
    };
  } finally {
    await browser.close();
  }
}

4. 定义 MCP 工具

server.tool(
  "playwright-ui-test",
  {
    url: z.string(),
    selector: z.string().optional(),
    waitForSelector: z.string().optional(),
    waitForTimeout: z.number().optional(),
    threshold: z.number().optional(),
    ignoreSelectors: z.array(z.string()).optional(),
    viewport: z.object({
      width: z.number(),
      height: z.number()
    }).optional(),
    baselineImagePath: z.string().optional(),
    baselineImage: z.string().optional()
  },
  async (params) => {
    // 获取自动登录配置
    const autoLoginConfig = {
      username: process.env.AUTO_LOGIN_USERNAME,
      password: process.env.AUTO_LOGIN_PASSWORD,
      usernameSelector: process.env.AUTO_LOGIN_USERNAME_SELECTOR || "#username",
      passwordSelector: process.env.AUTO_LOGIN_PASSWORD_SELECTOR || "#password",
      submitSelector: process.env.AUTO_LOGIN_SUBMIT_SELECTOR || 'button[type="submit"]',
      successSelector: process.env.AUTO_LOGIN_SUCCESS_SELECTOR,
      loginUrlPattern: process.env.AUTO_LOGIN_URL_PATTERN || "login|signin|auth",
    };

    // 获取测试配置
    const testConfig = {
      selector: process.env.TEST_SELECTOR || params.selector,
      waitForSelector: process.env.TEST_WAIT_FOR_SELECTOR || params.waitForSelector,
      waitForTimeout: process.env.TEST_WAIT_TIMEOUT ? parseInt(process.env.TEST_WAIT_TIMEOUT) : params.waitForTimeout,
      threshold: process.env.TEST_THRESHOLD ? parseInt(process.env.TEST_THRESHOLD) : params.threshold,
      ignoreSelectors: process.env.TEST_IGNORE_SELECTORS ? process.env.TEST_IGNORE_SELECTORS.split(',') : params.ignoreSelectors,
      viewport: {
        width: process.env.TEST_VIEWPORT_WIDTH ? parseInt(process.env.TEST_VIEWPORT_WIDTH) : (params.viewport?.width || 1280),
        height: process.env.TEST_VIEWPORT_HEIGHT ? parseInt(process.env.TEST_VIEWPORT_HEIGHT) : (params.viewport?.height || 720)
      }
    };

    const result = await runVisualTest({
      url: params.url,
      ...testConfig,
      baselineImagePath: params.baselineImagePath,
      baselineImage: params.baselineImage,
      autoLogin: autoLoginConfig,
    });

    if (result.success) {
      if (result.baselineCreated || result.baselineUpdated) {
        return {
          content: [{
            type: "text",
            text: result.message || "更新基准图片"
          }]
        };
      }

      return {
        content: [{
          type: "text",
          text: `差异像素: ${result.diffPixels}, 阈值: ${result.threshold}, 测试${result.passed ? '通过' : '失败'}`
        }, {
          type: "image",
          data: result.screenshots?.current || "",
          mimeType: "image/png"
        }, {
          type: "image",
          data: result.screenshots?.diff || "",
          mimeType: "image/png"
        }]
      };
    }

    return {
      content: [{
        type: "text",
        text: result.error || "未知错误"
      }]
    };
  }
);

项目地址

www.npmjs.com/package/@an…

5. 配置 Cursor

在 Cursor 中使用该工具,需要在 .cursor/mcp.json 中添加以下配置:

{
  "mcpServers": {
    "@anguske/mcp-playwright-visual-test": {
      "command": "npx",
      "args": ["-y", "@anguske/mcp-playwright-visual-test"],
      "env": {
        "AUTO_LOGIN_USERNAME": "",
        "AUTO_LOGIN_PASSWORD": "",
        "AUTO_LOGIN_USERNAME_SELECTOR": "#userNameSignIn",
        "AUTO_LOGIN_PASSWORD_SELECTOR": "#passwordSignIn",
        "AUTO_LOGIN_SUBMIT_SELECTOR": "input[type=\"submit\"]",
        "AUTO_LOGIN_SUCCESS_SELECTOR": "",
        "TEST_VIEWPORT_WIDTH": 1440,
        "TEST_VIEWPORT_HEIGHT": 800,
        "TEST_THRESHOLD": 20,
        "TEST_WAIT_TIMEOUT": 10000,
        "AUTO_LOGIN_URL_PATTERN": "login|signin|auth",
        "PROJECT_ROOT": "C:/project/root"
      }
    }
  }
}

这个配置文件定义了 MCP 服务器的配置信息:

  1. 服务器配置

    • command: 使用 npx 命令运行工具
    • args: 使用 -y 参数自动确认安装并运行 @anguske/mcp-playwright-visual-test
  2. 环境变量配置

    • 自动登录配置:
      • AUTO_LOGIN_USERNAME: 登录用户名
      • AUTO_LOGIN_PASSWORD: 登录密码
      • AUTO_LOGIN_USERNAME_SELECTOR: 用户名输入框选择器
      • AUTO_LOGIN_PASSWORD_SELECTOR: 密码输入框选择器
      • AUTO_LOGIN_SUBMIT_SELECTOR: 提交按钮选择器
      • AUTO_LOGIN_SUCCESS_SELECTOR: 登录成功标识选择器
    • 测试配置:
      • TEST_VIEWPORT_WIDTH: 视口宽度 (1440px)
      • TEST_VIEWPORT_HEIGHT: 视口高度 (800px)
      • TEST_THRESHOLD: 差异阈值 (20)
      • TEST_WAIT_TIMEOUT: 等待超时时间 (10000ms)
    • 其他配置:
      • AUTO_LOGIN_URL_PATTERN: 登录页面 URL 匹配模式
      • PROJECT_ROOT: 项目根目录路径

当你在 Cursor 中使用这个工具时:

  1. Cursor 会读取这个配置文件
  2. 根据配置启动 MCP 服务器
  3. 使用配置的环境变量运行测试
  4. 显示测试结果和截图

例如,当你在 Cursor 中输入:

/test playwright-ui-test url="https://example.com"

Cursor 会:

  1. 识别这是一个工具调用
  2. 使用配置的环境变量启动服务器
  3. 调用 playwright-ui-test 工具方法
  4. 显示测试结果和截图

6. 添加资源

server.resource(
  "testResult",
  new ResourceTemplate("test://{id}", { list: undefined }),
  async (uri, { id }) => ({
    contents: [{
      uri: uri.href,
      text: `测试结果 ID: ${id}`
    }]
  })
);

最佳实践

  1. 参数验证

    • 使用 Zod 进行严格的参数验证
    • 为可选参数提供默认值
    • 使用环境变量进行配置
  2. 错误处理

    • 使用 try-catch 捕获可能的错误
    • 返回结构化的错误信息
    • 在 finally 块中清理资源
  3. 资源管理

    • 使用有意义的 URI 模板
    • 实现适当的资源访问控制
    • 管理临时文件和目录
  4. 工具设计

    • 保持工具功能单一
    • 提供清晰的参数文档
    • 返回结构化的结果
    • 支持多种配置方式
  5. 配置管理

    • 使用环境变量进行配置
    • 提供合理的默认值
    • 支持多种配置来源

总结

MCP 提供了一个强大的框架来构建 AI 模型与外部工具的交互。通过合理使用 Tool 和 Resource,我们可以创建功能丰富且易于维护的 AI 应用。记住要遵循最佳实践,确保代码的可维护性和可扩展性。在实际开发中,要注意:

  1. 合理组织代码结构
  2. 实现完善的错误处理
  3. 提供灵活的配置选项
  4. 保持代码的可测试性
  5. 注重用户体验和反馈

AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

作者 得物技术
2025年3月27日 11:35

一、引言

从2022年12月份OpenAI发布ChatGPT产品至今已有2年多的时间,当大家已经习惯于在对话框中与AI交互,习惯于通过各种Prompt技巧让AI更好的理解并回答我们的问题,似乎默认这就是一种比较好与AI的交互方式了。

然而,这就是我们期盼的与AI交互的形式嘛?这是一种高效的方式嘛?

显然,这是不够的。

我们期望的是:告诉AI我们想要的目标或者任务,AI能够理解深度理解并分析我们的意图、自动的进行任务的拆解、自动的寻找可以使用的工具、自动的进行结果数据的汇总过滤、自动的呈现符合任务的展示形式。同时在任务处理过程中,可以自己完成异常的检测和修改。就如同一位优秀的同学,我们告诉他任务的目标,他可以自己寻找飞书文档、搜索网络知识、使用内部系统、自己编码验证方案可行性,并最终给一份好的解决方案。

二、以「对话为中心」的ChatBot

我们发送一条指令,AI被动的响应指令。即完成一轮人与AI的交互。

具体视频请前往“得物技术”微信公众号观看。

三、以「交付为中心」的多智能体Agent

我们发送一个任务,AI自动分析任务、调用可用的工具、分析结果、过滤数据并自动处理异常,最终呈现解决方案。

完成这样的一个任务,需要多智能体Agent间的协作以及对常用工具的调用。那什么是智能体Agent呢?

具体视频请前往“得物技术”微信公众号观看。

四、什么是智能体Agent

从Prompt到思维链

随着大模型的发展,Prompt工程已成为撬动大模型潜能的核心技术。即使我们普通用户在与大模型的交互中,也通过角色定义(如"资深工程师")或示例引导来优化输出效果,但这类简单提示往往难以突破模型固有的逻辑天花板——就像给赛车装自行车轮胎,再怎么调整也难以突破速度极限。

但偶然间,人们发现了一个神奇的咒语:只需要告诉大模型,你的 think 要 step by step。研究者发现只要加了这个prompt,就能极为显著地改善大模型做数学题的正确率。

大模型的数学与逻辑能力短板,是所有体验过其对话功能的用户都能直观感受到的痛点。这一缺陷严重制约了大模型的商业化落地进程,毕竟没有人敢轻易信任一个逻辑混乱的智能系统能输出可靠的决策结果。于是,提升大模型数学能力,被所有做基础模型的公司当作了第一目标。

研究者试图通过强化思维链来突破这一瓶颈。一个直观的思路是:让模型像人类解题时在草稿纸上推演那样,通过 "step by step" 的方式展开逻辑链条 —— 在这个过程中,包含假设、演绎、反思、纠错等一系列思维活动。既然人类通过这种结构化的思考方式能够有效解决数学问题,那么大模型是否也能通过类似机制实现能力跃迁?这一猜想推动着研究向纵深发展,最终形成了思维链技术的核心框架。这样的观念经过继续钻研,最终就构成了思维链,思维链是一个能以最小的代价,而非常显著提升模型智力水平(逻辑能力、解题能力、代码能力)的技术。

值得注意的是,2025 年春节期间引发广泛关注的 DeepSeek 大模型,正是思维链技术的成功实践典范。尽管 DeepSeek 并非首创者,但其通过创新性地融合混合专家(MoE)架构与强化学习技术,显著提升了思维链推理的计算效率与性能表现。这种技术优化使得 DeepSeek 在保持高精度推理的同时,大幅降低了计算成本,最终实现了屠榜级表现。

ReAct架构

如果说思维链(COT)是给 AI 装上了人类的 "草稿纸",那么 ReAct 框架就是为它配备了 "双手"—— 让 AI 不仅能在脑子里推演,还能主动采取行动获取信息。这种 "思考 + 行动" 的组合,正在把大模型从 "纸上谈兵" 的理论家,变成能解决现实问题的实干家。

ReAct 的核心在于将**推理(Reasoning)与行动(Action)**紧密结合。当模型面对复杂问题时,会先像人类一样拆解思考步骤,然后根据中间结果调用外部工具(如搜索引擎、数据库、计算器)获取实时数据,再把这些信息整合到后续推理中。

其实,实现一个ReAct很简单,只需要构建Prompt+提供工具+循环执行即可,笔者在这里不进行详细的介绍,只需要给一个Prompt例子,读者就能理解:

尽可能最好地为用户回答接下来的问题,你可以使用以下工具来辅助你:{tools} 使用以下格式:

- 问题:你需要回答的输入问题

- 思考:你需要持续思考下一步采取什么行动 

- 行动:要采取的行动,应该是 [{tool_names}] 中的一个,以及该行动的输入内容 

- 观察:行动并观测结果,并判断结果是否合理 ...(这个思考 / 行动  / 观察可以重复 N 次,直到你认为知道了最终答案 

- 最终答案:原始输入问题的最终答案 

开始! 

- 问题:{input}

Tools支持开发者自定义,比如给予LLM一个查询天气的接口、计算器接口等。

ReAct架构实现了一种**"问题拆解-工具调用-结果整合"闭环机制**,使得开发者仅需通过定义工具集(如天气API、计算器、知识图谱接口)和设计任务引导词,就能将大模型转化为可执行多步骤决策的智能体。最终可以使大模型突破纯文本推理的局限,真正具备了在动态场景中解决开放性问题的工程化能力。

Agent

Agent作为大模型技术的集大成者,通过整合思维链(CoT)的推理能力和ReAct框架的行动机制,构建了具备自主决策与执行能力的智能系统。其核心突破在于将**“大脑”与“四肢”**有机统一,标志着大模型从被动应答迈向主动干预现实的质变。

在架构上,Agent与ReAct差别不大,ReAct是Agent的核心实现范式之一,Agent进一步整合记忆存储、多智能体协作等模块,形成更完整的自主决策系统。下图是一个简单的Agent架构图:

v2ad31f685f1330333011c67eccc3cb64c_1440w.png

Agent处理流程

1-4步会循环进行,直到LLM认为问题已被回答。

1.规划(Planning):

  • 定义:规划是Agent的思维模型,负责拆解复杂任务为可执行的子任务,并评估执行策略。

  • 实现方式:通过大模型提示工程(如ReAct、CoT推理模式)实现,使Agent能够精准拆解任务,分步解决。

2.记忆(Memory):

  • 定义:记忆即信息存储与回忆,包括短期记忆和长期记忆。

  • 实现方式:短期记忆用于存储会话上下文,支持多轮对话;长期记忆则存储用户特征、业务数据等,通常通过向量数据库等技术实现快速存取。

3.工具(Tools):

  • 定义:工具是Agent感知环境、执行决策的辅助手段,如API调用、插件扩展等。

  • 实现方式:通过接入外部工具(如API、插件)扩展Agent的能力,如ChatPDF解析文档、Midjourney文生图等。

4.行动(Action):

  • 定义:行动是Agent将规划与记忆转化为具体输出的过程,包括与外部环境的互动或工具调用。

  • 实现方式:Agent根据规划与记忆执行具体行动,如智能客服回复、查询天气预报、AI机器人抓起物体等。

Manus:一个Agent典型案例

在读完前一节关于智能体(Agent)的技术解析后,读者也许会认为这类系统的工程实现并非难事,实际上也确实是这样。近期爆火的 Agent 产品 Manus 便是典型案例。当用户提出 "定制 7 天日本旅行计划" 的需求时,Manus 能够基于目标,自主进行网络搜索并将信息整合,展现出高度拟人化的任务执行逻辑

2.png

尽管 Manus 目前尚未向普通用户开放,且采用邀请制注册的封闭运营模式,但其通过官方演示视频呈现的强大智能化表现,已在技术圈引发广泛关注。值得关注的是,随着Agent技术的热度攀升,开源社区已迅速涌现出 OpenManus、OWL 等多个复刻项目。

因为Manus并非开源,我们很难了解其技术细节。但好在:

  1. "Manus 的部分技术细节,包括其提示词设计、运行机制等内容被网友通过非官方渠道披露,感兴趣的读者可自行查阅相关公开资料。

  2. 我们可以了解一下大模型上下文协议(Model Context Protocol,MCP),这是 Anthropic (Claude) 主导发布的一个开放的、通用的、有共识的协议标准,虽然Manus不一定用了这个协议,但目前一些相关开源项目也是基于MCP的,本文会在下面介绍MCP。

  3. 目前已有复刻的开源项目Openmanus,笔者会在接下来的章节剖析其源码。

大模型上下文协议(MCP)

MCP是做什么的?

MCP(Model Context Protocol)作为一项开放协议,旨在为应用程序与大型语言模型(LLMs)之间的上下文交互提供标准化框架。其设计理念可类比为数字时代的 "USB-C 接口"—— 正如 USB-C 统一了设备与外设的连接标准,MCP 通过标准化的上下文交互接口,实现了 AI 模型与多样化数据源、工具之间的无缝对接。

如下图所示,图中的MCP server都可以看成一个个工具(如搜索引擎、天气查询),通过“接口”连接到MCP clients(大模型)上,大模型可以使用各种MCP server来更好地处理用户的问题。

此外,下游工具的开发者也可以更好的开发其工具,目前在MCP官网即可了解其各种编程语言的SDK和相关概念。

3.png

MCP架构

MCP 的核心采用客户端-服务器架构,其中 host 可以连接到多个服务器,读者简单看看即可:

img_v3_02kp_bcaed6dcc3e04917a824cf74a340516g.png

  • MCP 主机(MCP Hosts):指需要通过 MCP 协议获取数据的应用程序,涵盖 AI 开发工具(如 Claude Desktop)、集成开发环境(IDEs)等智能应用场景。

  • MCP 客户端(MCP Clients):作为协议的执行者,每个客户端与对应的 MCP 服务器建立一对一的专属连接,负责协议层面的通信交互。

  • MCP 服务器(MCP Servers):轻量化的功能载体,通过标准化的 Model Context Protocol 对外开放特定能力,可视为连接模型与工具的智能桥梁。

  • 本地化数据源(Local Data Sources):包括服务器可安全访问的本地文件系统、数据库及专有服务,构成数据交互的近端生态。

  • 远程服务(Remote Services):通过互联网连接的外部系统,例如各类 API 接口服务,拓展了模型的能力边界。

为什么要用MCP?

从技术演进视角看,MCP 的诞生是提示工程(Prompt Engineering)发展的必然产物。研究表明,结构化的上下文信息能显著提升大模型的任务表现。在传统提示工程中,我们往往需要人工从数据库筛选信息或通过工具检索相关内容,再手动将这些信息注入提示词。然而,随着复杂任务场景的增多,这种手工注入信息的操作变得愈发繁琐且低效。

为解决这一痛点,主流大模型平台(如 OpenAI、Google)先后引入了函数调用(Function Call)机制。该机制允许模型在推理过程中主动调用预定义函数获取数据或执行操作,极大提升了自动化水平。然而,函数调用机制存在显著局限性:其一,不同平台的函数调用 API 存在较大差异,例如 OpenAI 与 Google 的实现方式互不兼容,开发者在切换模型时需重新编写代码,徒增适配成本;其二,该机制在安全性、交互性及复杂场景的扩展性方面仍存在优化空间。

在此背景下,MCP 协议通过标准化的上下文交互接口,为大模型构建了更具普适性的工具调用框架。它不仅解耦了模型与工具的依赖关系,还通过统一的协议规范解决了跨平台兼容性问题。更重要的是,MCP 将上下文管理提升到系统架构层面,为大模型在复杂业务场景中的深度应用提供了可扩展的技术底座。这种从碎片化的提示工程到体系化的上下文协议的演进,标志着大模型应用正在向更高效、更规范的方向迈进。

四、智能体Agent实现的源码剖析(OpenManus项目)

img_v3_02kp_7f7cdb11c5c3435e8bdcc98e38f9cddg.png

OpenManus 是一个基于 MCP 协议的开源智能体实现项目,旨在通过标准化的上下文协议实现大模型与工具的高效协同。当前项目仍处于快速迭代阶段,本文以其 2025 年 3 月 12 日的版本为分析对象。选择该项目的原因如下:

  • 团队背景与代码质量:项目作者来自MetaGPT,具备深厚的工程经验,代码结构清晰且注释完善,兼顾了技术实现与可读性。

  • 部署便捷性:只需通过虚拟环境安装依赖并配置大模型 API Key(如 OpenAI 的 API 密钥),即可快速启动,降低了技术门槛。

  • 技术前沿性:项目紧跟大模型技术发展,且目前仍在不断迭代的过程中。

在经过前面对相关概念的讨论,我们可以得知实现Agent有几个关键的点,读者可以带着问题在项目中寻找答案:

  • Prompt:其结构化的Prompt是什么样的?通过Prompt可以对其架构有一个初步认识。

  • OpenManus:怎么通过大模型思考和处理问题?

  • 工具相关:怎么进行工具注册、工具管理的?工具执行逻辑是什么的?

准备

项目地址:

github.com/mannaandpoe…

构建环境

创建一个python=3.12的虚拟环境

  • 笔者测试了一下,非3.12版本会有一个package不兼容。

  • 可以用conda或python内置的uv,项目文档提供了详细的指令。

安装playwright

  • 如果第一次使用,需要安装playwright。
playwright install
## 或者
python -m playwright install
## 以上命令会安装所有浏览器,如果只需要安装一个浏览器比如firefox
python -m playwright install firefox

配置大模型API Key

  • 可以用DeepSeek或通义千问的API Key,其中通义有免费额度,DeepSeek虽然收费但价格便宜,测试一次使用约1000token,成本不到0.01元。

  • 根据项目文档配置cofig.yaml即可,但项目调用大模型是使用基础的OpenAI API,如果使用其他大模型,可能需要基于对应的官方文档小改一下。

代码

OpenManus客户端

Python OpenManus/main.py即可在终端运行OpenManus,读者也可以尝试其Web版本。

  • 具体会调用20行代码,执行Manus类的方法run()。

img_v3_02kp_037da7610f23414cb15d567f598ac4bg.png

进入OpenManus/app/agent/manus.py查看Manus类,可以发现它继承了ToolCallAgent类,再进入会发现又是继承,有点复杂,这里我画一张关系图。

  • act()执行时使用execute_tools()进行具体的工具执行。

  • 总体来说,Manus类定义了Prompt和可使用的工具。

  • Base类定义了run(),在run()中会循环执行ReAct类的方法step(),直到Finish或达到max_step。

  • step()类会顺序执行ToolCallAgent类的think()和act()。

当然,这里只罗列了重要的组件和方法,一些方法没有画在图中。

img_v3_02kp_e50578ddab27439f91d97a3f5e38943g.jpg

Prompt

一般来说,输入给LLM的prompt分为两种:1)系统 prompt,用于定义模型的角色定位和行为规则;2)用户 prompt(OpenManus称为Next Step Prompt),用于传达具体的任务指令或信息需求。

在OpenManus/app/prompt/manus.py中即可看到Manus的Prompt,这里展示一下中文版,读者基于此可对OpenManus架构有一个初步认识:

  • 系统Prompt(SYSTEM_PROMPT):“你是 OpenManus,一个全能的人工智能助手,旨在解决用户提出的任何任务。你拥有各种可使用的工具,能调用这些工具高效地完成复杂的请求。无论是编程、信息检索、文件处理还是网页浏览,你都能应对自如。”

  • 下一步Prompt(NEXT_STEP_PROMPT):“你可以使用 PythonExecute 与计算机进行交互,通过 FileSaver 保存重要的内容和信息文件,使用 BrowserUseTool 打开浏览器,并使用 GoogleSearch 检索信息。根据用户的需求,主动选择最合适的工具或工具组合。对于复杂的任务,你可以将问题分解,逐步使用不同的工具来解决它。在使用完每个工具后,清晰地解释执行结果并给出下一步的建议。

当然,在实际执行时会对prompt有进一步优化,不过核心的系统定位与任务指导原则是不会改变的。

Manus类

img_v3_02kp_83117adc20bf418fbd98933c2671522g.png

我们先看一下OpenManus拥有的工具,工具也支持自定义,会在后文进行介绍。

  • PythonExecute:执行 Python 代码以与计算机系统交互、进行数据处理、自动化任务等等。

  • FileSaver:在本地保存文件,例如 txt、py、html 等文件。

  • BrowserUseTool:打开、浏览并使用网络浏览器。如果你打开一个本地 HTML 文件,必须提供该文件的绝对路径。

  • GoogleSearch:执行网络信息检索。

  • Terminate:如果LLM认为回答完毕,会调用这个工具终止循环。

Base类

run()

img_v3_02kp_36fbb768418d4f2892b676943131916g.jpg

  • 首先,输入的request就是用户输入的提问。

状态管理

img_v3_02kp_036ebee8ebfd4b4c94cb283d4a071aag.jpg

  • 执行时首先检查代理的当前状态是否为 IDLE(空闲状态)。如果不是空闲状态,会抛出 RuntimeError 异常,因为只有在空闲状态下才能启动代理的执行。

img_v3_02kp_1fa59b67e15247069e103f001a8b2a2g.jpg

  • 当进入循环时前,使用 state_context上下文管理器将代理的状态临时切换到 RUNNING(运行状态)。在上下文管理器中执行的代码块会在进入时将状态切换为指定状态,在退出时恢复到之前的状态。如果在执行过程中发生异常,会将状态切换为 ERROR

Memory管理

我们调用大模型的API,本质是向大模型提供方发http请求,http请求是无状态的。

  • 也就是说,服务端不会保留任何会话信息。对于每次都完成一个独立的任务,无状态是没有任何问题的。但对持续聊天来说,就会出现对之前会话一无所知的情况。

所以为了让大模型持续与用户的对话,一种常见的解决方案就是把聊天历史告诉大模型。

  • 因此,在OpenManus中会进行Memory的管理。

img_v3_02kp_8c1e4d8812b840d9804ed82c2e6b68cg.jpgimg_v3_02kp_c74745982b0042e59b77935079c3b55g.png

  • 用户提供的 request 参数,调用 update_memory 方法将该请求作为用户消息添加到代理的Memory中。

  • 除了这个函数,Manus也在进行think()、act()时也会更新Memory,同时Memory容量也不是无限大的,容量满时需要删除老的Message。

主循环

img_v3_02kp_1ce792754452405cbd686c976d9a2bfg.png

agent本质就是循环执行。

  • step实现参考react step。

  • 循环结束条件:max_steps或者FINISHED状态。

  • 每次执行一个step并获得result——step_result = await self.step()。

  • is_stuck 方法用于检查代理是否陷入了循环(即是否出现了重复的响应)。如果是,则调用 handle_stuck_state 方法处理这种情况,例如添加一个提示来改变策略。

ReAct

step()

img_v3_02kp_3999f1b8a5bb413f826ca4b7c3d8836g.png

  • 这里的逻辑很简单。

ToolcallAgent

Think()

  • 输入:不需要输入,因为用户的question是被存放在Memory中。

  • 输出:一个bool类型,当内部LLM判断需要act()时,为True,否则为Fasle。

询问LLM

img_v3_02kp_ecd6a3006d254268a783101c86d86a0g.png

  • 55行的代码用于调用LLM的API接口,获取回复。

img_v3_02kp_d194c2fca02e47b9be3c05ab5195c25g.png

对应到OpenManus/app/llm.py 233行附近,这里就是基于OpenAI提供的API接口进行对话,具体的参数可参考相应官方文档。

  • 这里会将之前定义的下一步Prompt发给LLM,LLM会根据提供的工具列表,判断是否需要且调用的是哪个工具,当然也可能是:1)不需要工具只进行回复 2)调用Terminate工具结束会话。

下图是一次返回response结果

  • 输入的question是“计算Kobe Bryant的BMI?”,LLM先分析出了要通过浏览器查询资料,因此要use the BrowserUseTool。

  • 根据传入的工具类型等信息,LLM自动构建了执行工具需要用的tool_name、action等参数。

ChatCompletionMessage(
    content="It seems there was an issue with retrieving the information about Kobe Bryant's height and weight through a Google search. To calculate Kobe Bryant's BMI, we need his height and weight. Let's try to find this information by opening a browser and visiting a reliable source. I will use the BrowserUseTool to navigate to a website that provides details about Kobe Bryant's height and weight. Let's proceed with this approach.", 
    refusal=None, 
    role='assistant', 
    annotations=None, 
    audio=None, 
    function_call=None, 
    tool_calls=[        ChatCompletionMessageToolCall(            id='call_aez57ImfIEZrqjZdcW9sFNEJ',            function=Function(            arguments='{
                "action":"navigate",
                "url":"https://www.biography.com/athlete/kobe-bryant"
                }',             name='browser_use'),             type='function')]
)

think后续逻辑

  • think()后续的逻辑比较简单,主要是更新memory(memory存储单位是message),最后在100行附近的逻辑,基于self.tool_choices等参数的设置和LLM返回的工具列表,输出bool类型结果。

  • 同时,需要被调用的工具会被记录到self.tool_calls这个列表中,后续的act()会执行对应的工具。

Act()

  • 输入:同think(),不需要输入。

  • 输出:results,根据工具结果构建的一个字符串。

img_v3_02kp_44e6894bd91540ec82dc03c8e3e970bg.png

  • 这个函数比较简单,主要是调用execute_tool()函数。

Execute_tool()

img_v3_02kp_030fab99df154e819a61d3ff3bed5aeg.png

该函数会调用Tool类提供的接口execute()。

  • Tool类接口会在后面介绍。

同时,对于预设定的special tool,会self._handle_special_tool(name=name, result=result)进行特殊处理。

  • 当前的special tool 只有一个Terminate工具,特殊处理就是设置Agent的状态为AgentState.FINISHED,结束对话。

工具相关

我们在之前介绍了MCP相关的概念,如下图所示:

img_v3_02kp_841aa8ccb6d74423a435decd316bc3bg.png

事实上,OpenManus也是基于MCP的,OpenManus的tool相当于MCP server,根据MCP协议,我们只需要定义tool类支持的方法和参数等,每次注册一个新工具,根据父类override一个子类即可。

那我们首先要了解父类都定义了什么参数和方法,也就是OpenManus/app/tool/base.py定义的Basetool类。

Base Tool

img_v3_02kp_3a61d2518cb343539aad1dd28cd6686g.png

可以看出,代码很简单,每个tool包含的参数为:name、description(提供给LLM看的,对工具的介绍)、parameters(执行工具时要用的参数)。

同时,一个tool支持的方法有execute()和to_param()。

  • execute()用于执行具体的逻辑,每个子类需要override这个方法

  • to_param()将工具调用的结果结构化输出。

当然,这里还有一个python关键字__call__,这个关键字很简单,定义了__call__,该类的实例对象可以像函数一样被调用。

工具JSON

可以根据OpenManus预定义的工具json简单了解一下,每个工具执行时需要的参数。

[
  {
    "type": "function",
    "function": {
      "name": "python_execute",
      "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
      "parameters": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "The Python code to execute."
          }
        },
        "required": ["code"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "google_search",
      "description": "Perform a Google search and return a list of relevant links.\nUse this tool when you need to find information on the web, get up-to-date data, or research specific topics.\nThe tool returns a list of URLs that match the search query.\n",
      "parameters": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "(required) The search query to submit to Google."
          },
          "num_results": {
            "type": "integer",
            "description": "(optional) The number of search results to return. Default is 10.",
            "default": 10
          }
        },
        "required": ["query"]
      }
    }
]

工具示例——google_search

OpenManus项目在OpenManus/app/tool中定义了bash工具、浏览器工具、谷歌搜索工具等,这里简单看一下谷歌搜索工具。

当然,国内可能比较难使用谷歌搜索,OpenManus社区也有大佬提供了baidu、bing等搜索引擎工具。

img_v3_02kp_970ea2580aca4c8980980b7f28db476g.png

可以看出,代码很简单,主要做了两件事。

  • 定义工具参数:name、description、parameters。

  • 定义execute:基于googlesearch库提供的函数进行搜索并返回。

五、总结

OpenManus的代码介绍到这里,主要是介绍一下核心代码,同时,原作者写了planning部分的代码但暂时没有应用到项目中,笔者也没有介绍。如果想对该项目有更进一步的了解,请大家查看github上提供的源码。而且,作者还是非常积极的,每天会有十几个commit。

同时,读者可以简单本地部署玩一下OpenManus,通过几个prompt,就可以知道该项目还是停留在**“玩具阶段”,比如笔者测试了一下,当询问“计算一下科比的BMI?”,OpenManus可以很准确的实现谷歌搜索****——浏览器访问——python计算**这个过程。但如果询问“计算科比、梅西的BMI并排序?”,无论我改写了几次prompt,OpenManus都没有给我满意的回答。

此外,无论是在工具参数信息、还是prompt、memory管理中,都可以看到agent应用大模型token消耗量巨大,即使我们不考虑token成本,但大模型的上下文仍然是有限的,这种资源消耗也会直接导致模型在处理多步骤任务时面临信息截断的风险 —— 早期的关键信息可能因上下文溢出而被丢弃,进而引发推理链条的断裂。更值得警惕的是,当模型试图在有限的上下文中 “脑补” 缺失的信息时,往往会产生与事实不符的幻觉。

鉴于此,尽管 OpenManus 展示出了利用工具链解决复杂问题的潜力,不过距离成为一个实用、高效且稳定的生产级人工智能助手仍有很长的路要走。未来,开发者们或许需要在优化工具使用逻辑、提升多任务处理能力、降低大模型 token 消耗以及增强上下文管理等方面进行深入探索与改进。同时,对于普通用户而言,在体验这类项目时,也应该保持理性和客观的态度,既看到其创新性和趣味性,也认识到其当前存在的局限性。希望在技术的不断迭代和完善下,OpenManus 以及类似的项目能够早日突破现有的瓶颈,真正为人们的工作和生活带来实质性的帮助。

往期回顾

1. 得物技术部算法项目管理实践分享

2. 商家域稳定性建设之原理探索|得物技术

3. 得物 Android Crash 治理实践

4. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

5. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术

文 / 汉堡

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌