给 AI Agent 装上按需开启的 MCP:人工介入的艺术
发布时间:2026/10/1 5:36:16来源:尧图网络
给 AI 装上工具这件事最近两年已经成了很多人的日常操作。我自己的 pi agent 从最早只会聊天到后来接上文件系统、网页抓取、数据库查询等等一堆 MCP server功能是上去了麻烦也跟着来了。折腾了一个月之后我得出的结论是MCP 给模型带来的不是一个“更多工具”的问题而是一个“什么时候允许它动手”的问题。人工介入不是给 AI 拖后腿而是给这些工具装上闸门。这篇就记录一下我是怎么给 pi agent 设计并落地这套按需开启的 MCP 机制的。1. “全量放开”的教训MCP 工具不是越多越安全1.1 先说说 pi agent 和它面对的 MCPpi agent 是我跑在本地一台小主机上的个人智能体平时帮我干三件事读本地文档、整理日程、往团队 Wiki 里查资料。最初它没有任何外部工具模型只能靠训练数据里的知识回答问题稍微涉及一点内部资料就歇菜体验很差。我当时的想法很简单给它接上 MCP让它拥有真正“动手”的能力。MCPModel Context Protocol模型上下文协议解决的核心问题用一句话总结就是给“AI 模型”和“外部工具”之间制定了一套通用的接口规范。以前每个 AI 应用要接一个新工具都得写一套私有适配代码现在工具方只要实现一个 MCP server任何支持 MCP 的客户端都能直接调用。这很像 USB 接口的逻辑设备厂商不用关心你插的是哪台电脑你也不用关心设备内部电路怎么走插上就能用。顺带说一句我经常看到有人问“MCP 是软件协议还是硬件协议那个概念叫什么来着”。MCP 是彻头彻尾的软件协议——它定义的是数据结构、调用流程和传输方式不涉及任何硬件标准。这个困惑多半是因为“MCP”这个名字跟某款硬件芯片的型号缩写撞车了。不用纠结你只要记住它是跑在应用层、让模型和工具互相说同一门语言的协议就行。但“插上就能用”是便利也是风险。模型不是每一次决策都靠谱工具的开放能力是一把双刃剑它把调用权交给了模型而模型的判断偶尔会出人意料。于是我开始研究怎么在“能用”和“可控”之间找平衡最后落地的方案就是标题里写的这个——给 pi agent 装上按需开启的 MCP工具平时是“黑”的模型需要的时候要经过人点头才能点亮。1.2 全量开放让我吃过的三次亏之所以下决心做“按需开启”是因为我在全量开放的状态下确实吃过亏而且不止一次。第一次是文件系统工具。我挂的是一个只读文件查询 server本意是让 pi agent 帮我在项目目录里搜代码片段。结果某个周末它顺着“用户目录”一路扫下去把几个本该只放在加密目录里的文档读了个遍还像宝贝一样总结进上下文里。虽然没有任何外传动作但模型上下文里躺着敏感内容这件事本身就让人很不舒服。你想如果那条路径上有一个“读取后发送到外部 API”的工具后果就不是不舒服而是实打实的数据泄露了。第二次是网页抓取工具。模型在回答一个“最近有什么值得关注的开源项目”的问题时自动调用了五六个网页工具把首页、详情页轮着抓了一遍。单次看起来没什么可架不住它一天反复这么干。月底看统计光这一个 MCP server 的调用量就占掉了整月预算的三分之一。我这才意识到一个“免费”的抓取工具在模型手里能烧出真金白银的费用。第三次更隐蔽是工具之间的“连锁调用”。它先通过搜索工具找到一个 PDF 下载链接然后自动调文件下载工具把它存到磁盘紧接着又用文档解析工具试图读取内容。整个过程里没有一个动作是出格的但组合在一起等于在没有任何人工确认的情况下自动完成了“下载外部文件并解析”这个高风险动作。事后我回看日志每个单独调用都有合理理由可整条链路从头到尾都处于无人值守状态。这三件事让我想明白了一个道理问题不在单个工具是否安全而在于模型拥有“无人值守地连续调用多个工具”的能力。只要这个能力存在哪怕每个工具都很克制组合出来也可能产生我完全没预期到的行为。按需开启本质上就是把“无人值守”改成“有人闸门”——工具还在但调用前必须有人工确认这一道工序。2. MCP 的分层模型里人工介入最合适的落点是哪一层2.1 host / server / tool 三层结构里的两个可插手位置MCP 的架构其实不复杂典型就三层。最上层是 host也就是跑模型的客户端我的 pi agent 本体在这里大家熟悉的 Claude Desktop、Cursor 之类的应用也属于这一层。中间是 MCP server负责把具体的工具能力封装成标准接口。最下层是 tool 本身可能是本地命令、远程 API、数据库连接等等。顺着这条链看人工介入可以插在两个位置。一个位置是 server 侧在 MCP server 里做权限校验比如“这个工具只有白名单用户能调”“这个工具在工作时间之外直接拒绝”。优点是执行点离工具近就算 host 被绕过server 还能兜底缺点也很明显——server 通常是无状态的它看不到当前这轮对话的上下文也不知道模型是出于什么原因发起这次调用的。另一个位置是 host 侧在模型决定调用工具、但还没真正发起调用的那一瞬间插入一个人工确认环节。优点是能结合整段对话上下文来判断这次调用是否合理缺点是 host 承担的工作变多而且如果 host 本身不可信那这个闸门就是纸糊的。2.2 为什么我最终把“按需开关”做在 host 而不是 server 上按理说两边都做最稳妥但实际开发有取舍。我最后把主要逻辑放在 host 侧理由有三个。第一按需开关的“需”是一个会话级的状态不是静态配置。同一个工具上班时间我自己用可以放行深夜它自己触发就不该放行——这种判断只有 host 能看到完整对话流server 看不到。第二改 host 侧的成本低、见效快。不用动 server 的代码只要在客户端里包一层拦截器对工具调用做统一分发就行。而且 pi agent 本来就是我自己的项目改 host 比维护一堆第三方 server 的权限代码要可控得多。第三从实际使用场景看我的风险敞口主要集中在前面说的“连锁调用”上这恰恰是 server 侧权限覆盖不了的部分——因为每个工具单独看都是合法的只有 host 才能识别出“这一串连续动作”整体上需要人工介入。当然server 侧的兜底我还是留了一部分。比如文件删除、外部写操作这类不可逆动作我直接在 server 配置里把工具标注为“必须人工确认”双保险。两种介入方式各有分工介入维度server 侧实现host 侧实现判断粒度静态工具级动态会话级能结合上下文本吗基本不能能改造工作量每个 server 单独改客户端统一改一次兜底能力强即使 host 被绕过仍有效弱host 被绕过就失效我的使用方式放一类高风险动作的硬限制放日常按需开关的软闸门这个组合对我来说是够用的日常处理靠 host 的按需放行不可逆的高危动作靠 server 硬限制两层合起来才叫完整的“人工介入”。还有一个被很多人忽略的点MCP server 的传输方式分成 stdio 和远程Streamable HTTP / WebSocket两大类。本地工具走 stdio 就够了而远程 MCP server 意味着 agent 除了你本地环境之外还开放了一条走网络的通道。我自己的原则是远程 MCP 一律默认关闭而且不进入“可人工批准白名单”因为一旦远程通道的工具被调用请求会发到我不完全可控的外部环境这不是按需开启能兜住的。远程 MCP 只在我主动调试时临时开一下用完立刻关。3. 给 pi agent 配上按需启用的 MCP完整配置过程3.1 最小可用环境的搭建先说环境。我的 pi agent 是一套 Python 写的服务运行时用 FastAPI 暴露接口底层接的是 OpenAI 兼容的模型接口。MCP 客户端这边我用官方 Python SDK——mcp包配合anyio做异步调度配置文件就用 JSON 格式。最小配置长这样{ mcpServers: { filesystem: { command: npx, args: [ -y, modelcontextprotocol/server-filesystem, ./data ], enabled: false }, fetch: { command: npx, args: [-y, modelcontextprotocol/server-fetch], enabled: false } } }注意我给每个 server 都加了一个enabled字段默认false。这不是 MCP 协议标准要求的字段但我发现它非常实用客户端启动的时候只加载enabled: true的 server其他的一律不连。这样“装上没启用”和“彻底没装”就从代码层面区分开了后面想开哪个改一个字段就行。依赖安装没什么特殊的pip install mcp anyio pydantic然后写一个简单的加载函数import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def load_mcp_servers(config_path): with open(config_path) as f: config json.load(f) sessions {} for name, info in config[mcpServers].items(): if not info.get(enabled, False): continue params StdioServerParameters( commandinfo[command], argsinfo[args], ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: tools await session.list_tools() sessions[name] {session: session, tools: tools} return sessions这段代码的逻辑不复杂但有个细节值得强调enabled: false的 server 根本不会启动子进程。我之前试过“先启动再禁用”结果发现很多 MCP server 启动之后会自己做初始化有的会建临时目录有的会拉远程配置既耗资源又容易留下潜在风险。延后加载lazy load才是更干净的方案。如果你的某个 MCP server 走的是远程传输配置会不一样{ remote_example: { url: https://你的server域名/mcp, enabled: false } }对这种远程 server我会在 host 侧额外标记一条进入“仅调试模式”才允许连接。也就是说除了enabled之外还有一个mode: debug字段只有两个字段同时满足客户端才会真的发起连接。3.2 在 host 层实现“工具调用-人工确认-执行”的流程加载完 server 之后真正的关键在“拦截”这一步。我的做法是在 host 层统一包一层dispatch_tool_call函数所有工具调用必须经过它async def dispatch_tool_call(tool_name, arguments, session_state): policy session_state.policy # 白名单内的工具直接放行 if tool_name in policy.permanently_allowed: return await mcp_call(tool_name, arguments) # 完全拒绝列表直接拦截 if tool_name in policy.forever_blocked: return {error: f工具 {tool_name} 已被人工禁用} # 其他工具走人工确认 approval await request_human_approval( tool_nametool_name, args_summarysummarize_arguments(arguments), reasonsession_state.last_dialog_intent, ) if not approval.granted: policy.forever_blocked.add(tool_name) return {error: 用户拒绝了该调用} return await mcp_call(tool_name, arguments)request_human_approval在 pi agent 里实现成一条待确认消息用户在 Web 面板上看到“模型请求调用某某工具”点“放行”或“拒绝”操作结果会写回一个 Futuredispatch_tool_call就 await 这个 Future。这个流程的要点在于模型在工具调用这一步是被“卡住”的它拿不到结果也就不会继续往下生成后续内容必须等人点了确认才能继续。实际用下来你会发现AI 的连环调用链条在第一次需要人工确认时就会被打破你天然地获得了对整条链路的控制权。超时处理也要想清楚。我不想让模型逻辑一直等着用户审批所以给request_human_approval加了 30 秒超时超时默认按拒绝处理async def request_human_approval(tool_name, args_summary, reason): loop asyncio.get_running_loop() future loop.create_future() task_id pending_approvals.store(future, tool_name, args_summary, reason) try: return await asyncio.wait_for(future, timeout30) except asyncio.TimeoutError: pending_approvals.remove(task_id) return Approval(grantedFalse, note审批超时)这样即使人不在电脑前agent 也不会无限期挂起而是拿回一个“超时拒绝”的结果把这次调用终止掉。这个“超时即拒绝”的设定是整套系统里我认为最重要的一个安全兜底——它保证了在没人值守的情况下系统的默认状态永远是不可执行而不是可执行。3.3 按会话、按目录、按角色做动态放行“按需开启”如果只是全局开关那还不够好用。我在动态放行上加了三个维度。按会话放行。每个会话有自己的 policy 对象会话结束就销毁。比如我在调试一个脚本的时候临时放行“运行 shell 命令”工具调试完这个会话一关权限立刻收回不会污染其他会话。这个设计是为了避免“一次放行、永久放行”的暗坑。按目录放行。文件系统类工具我会绑定工作目录pi agent 只能在配置的根目录里读写目录之外的工具调用即使经过人工确认也拿不到权限因为它根本不会被加载。这相当于把“能不能调”和“调到什么范围”拆开处理。按角色放行。给 pi agent 设计了两个使用身份我自己是一个角色家里其他成员是另一个角色。我拥有大部分工具的默认确认权其他成员触发的工具请求会被额外标注风险等级高风险操作直接连审批入口都不开放。{ policy: { roles: { owner: { permanently_allowed: [filesystem:read, wiki:query], require_approval: [fetch:request, shell:exec] }, guest: { permanently_allowed: [wiki:query], require_approval: [], forever_blocked: [shell:exec, filesystem:write, fetch:request] } } } }这套配置下来的体验是绝大多数时候我的日常查询完全不需要人点头因为白名单已经覆盖了高频安全操作真正需要人工介入的基本都是之前没有明确允许过的新操作或者跨会话的非常规动作。人工介入的“工作量”其实一点都不大因为系统已经把 90% 的安全场景自动分流掉了。4. 让人工介入真正靠谱的三个细节4.1 默认拒绝把审批负担从“帮 AI 确认”降到“判断要不要拦”按需开启的系统一旦做完人就会成为流程里最薄弱的一环。因为人天然有从众和便利倾向如果界面里天天弹“是否放行”时间一长你根本不会细看直接点允许——这就是“审批疲劳”比没有审批还危险。我踩过这个坑。早期版本里我把默认值设计成“允许但我先问问你”结果不到一个星期我就开始无脑点“是”。后来我把默认值彻底倒过来所有未明确允许的工具一律拒绝只有人工明确放行的才在当前会话内有效。这个反转之后我的审批行为立刻变了——因为拒绝是零成本而放行意味着我要为这次行为负责所以每次都会多看一眼参数和理由。可以这样理解这两套逻辑的差别# 旧逻辑默认允许容易养成无脑放行 if tool_name not in blocked_tools: return await mcp_call(tool_name, arguments) # 新逻辑默认拒绝放行需要显式授权 if tool_name not in allowed_by_user: return {error: 该工具当前不可用需要人工开启}从工程角度说默认拒绝还有一个额外的好处它在安全性和可用性之间做了一个明确的取舍——宁可让 5% 的合理请求被拦下来也不让那 1% 的危险请求溜过去。对自托管 agent 这种场景这个取舍方向是正确的。4.2 审批提示里必须带上理由和上下文而不是只显示工具名第二个细节是关于审批提示的设计。最开始我弹出的确认框只有工具名比如“是否允许调用 fetch:request”结果经常是看到工具名也不知道模型想干嘛只好放行反正“看起来不太危险”。后来我把审批消息改成三段式工具要做什么比如“发起一次 HTTP GET 请求”参数摘要比如“目标地址为 news.example.com超时 5 秒”模型为什么认为需要它从对话上下文里提取比如“用户刚才问今天有什么新闻”前两个信息来自工具调用本身第三个信息来自对话上下文——我会把当前轮次里模型的最后一句计划提取出来拼进提示里。实际效果是审批决策的正确率高了很多。有一次模型想调网页抓取工具理由是“获取某个新闻页面的最新内容”但它附带的参数里写的是抓一个明显不是我想要的站点我一眼就看出来了直接在审批界面拒绝。如果还是只显示工具名这一单大概率就放过去了。很多人觉得“人工介入”就是弹个确认框但其实弹框里放什么内容直接决定人工介入有没有价值。一个没有上下文的确认框只是把“无脑执行”变成了“无脑批准”该防的事一件都没防住。4.3 每次放行都要留痕审计日志不是可选项审计日志这件事最初我觉得是“锦上添花”直到有一次想复盘一个异常行为翻遍了代码也没找到调用记录才意识到日志在可维护性上的价值。现在我给每次工具调用都会写一条结构化日志字段包括字段示例时间戳2025-06-12T09:31:2008:00会话IDsess_7f9c2e发起角色owner工具名fetch:request参数摘要urlnews.example.com, timeout5审批结果approved_by_user / rejected_by_user / timeout上下文来源dialog#42 intent获取最新新闻日志我存在本地一个独立的 SQLite 库里和业务数据分开。定时扫一遍日志已经成为我的习惯主要看两类异常审批时间异常短说明可能又在无脑放行、同一工具连续被拒说明模型在某些场景下总想干不该干的事可能需要从提示词层面去引导。日志本身不产生安全能力但它是观察“人工介入是否真的在起作用”的唯一窗口。没有日志的审批系统就像没有黑匣子的航班出事之后你根本不知道哪一环失效了。5. 实测一个月之后的复盘5.1 最明显的收益可控性这套按需开启的 MCP 机制我用了大约一个月。最直观的变化是工具调用总量下降到原来的三分之一左右但真正被有效利用的比例反而上来了。以前是全量开放模型动不动就把工具翻出来秀一圈现在工具大部分时间是休眠的模型只有在确实需要外部能力的时候才会请求而请求又大概率会被人拦住复核一次。整体给人的感觉是AI 做事情开始“有分寸感”了而不是靠堆工具来显得聪明。预算和资源上的收益也很直接网页抓取、外部 API 调用这些按量计费的请求误触发率明显下降。链条式的高风险动作从过去的自动完成变成了“人工确认后完成”我心里踏实得多。其实标题里的“人工介入的艺术”我是在做了这套系统之后才真正理解的人工介入不是让每一步都停下来等你拍板而是平时尽量不打扰只在 AI 准备跨过某条你划的线时你恰好在那里。干预得太少等于没介入干预得太多AI 就失去了工具的价值。中间这个度就是艺术所在。5.2 仍然没解决好的三个问题第一个是审批粒度的问题。现在我的实现是一次审批一次放行但实际用下来发现有时候同一个工具在同一个会话里会被连续请求四五次每次都弹窗很烦。改进方向是加一个“本会话内允许 N 次”或者“允许十分钟内自动放行”的分级策略目前还没动手。第二个是并发审批和异步的复杂度。一旦请求方不是单线程多个工具调用同时等审批pending_approvals 这个全局表就得多注意并发安全。我目前的解决方案是给每个审批条目标了 id 和会话号靠 Redis 的原子操作解决但代码复杂度比单会话版本高了不少。第三个是工具 description 太长吃上下文。MCP server 注册工具时每个工具的 description 字段都会被放进模型上下文里。我挂的工具多了之后光是工具描述就占了几千 token对模型的选择会产生影响。后面想把工具描述做瘦身或者只在模型用到某个 server 时才动态注入它的工具清单。这个第三个问题跟 MCP 的协议设计也有关系。MCP 为了让模型能“知道”有哪些工具可用会把工具名、描述、参数 schema 暴露给 hosthost 又把这些信息塞进模型上下文。工具少的时候无所谓工具一多你的上下文预算就会被这些“说明书”吃掉一大块。所以我在配置文件里加了一个description_policy: brief的开关用一句话摘要替换冗长的原始描述只保留最关键的参数说明。5.3 如果你也想动手改我建议从这几个点开始如果你也在做类似的自托管 agent想加入“按需开启”的控制我的建议是不要一上来就做大而全的审批系统。先做三件小事第一在你现有的 MCP 客户端配置里给每个 server 加上enabled字段默认 false只开启你真正信任的一两个第二在工具调用入口包一层拦截函数所有不在白名单里的调用先打日志再往外走第三把白名单做成会话级的而不是全局单例。这三步做完你就已经拥有一个最小可用的按需 MCP 系统了——剩下的审批 UI、动态策略这些东西等真的觉得不方便了再加。从全量放开到按需放行这个过程看起来变化不大但用下来的体验差别很大。人工介入不是说 AI 能力不够只是让你在 AI 做决策的时候保留最后一道可用的卡点。我这个版本还远谈不上完美但至少现在每次 pi agent 调用工具之前我知道它要干什么、为什么要干、干完会留下什么痕迹。这三件事都清楚工具再多我也不慌。
网站建设高端定制企业官网