最近几周技术群里聊得最多的一个转向:去年大家还在兴致勃勃地接 MCP,今年不少大厂内部平台组反而开始把 AI 工具调用从 MCP 迁回 CLI。我手里正好有一个从 MCP server 改造成 CLI 适配层的项目,前后踩了不少坑,也观察到了几家头部团队的做法,说说我的判断:这次回头不是开倒车,而是规模变大之后必然的工程妥协。
MCP 本身不差,它解决了一个真实问题——让 AI 应用能动态发现和调用外部工具。但它把“标准”定义在传输层,把权限、审计、幂等、可调试性全部留给了实现者自己去造。而 CLI 这套东西已经稳定运行了几十年,退出码、标准输出、管道、进程隔离,全都是现成的契约。当调用方从人变成模型,CLI 反而成了一个比一堆 JSON-RPC 更可靠的接口。
这篇文章我会从我自己踩过的坑出发,讲讲 MCP 在实际落地里到底卡在哪,为什么 CLI 成了更顺手的操作层,以及大厂迁移时实际走的路径。
1. MCP 最初让人兴奋的点,以及第一次接内部构建工具时暴雷的地方
1.1 理想中的“万能工具插头”
先回忆一下 MCP 为什么能火。它的核心模型很简单:每个工具通过 JSON-RPC 暴露出来,AI 客户端可以动态列举有哪些工具、拿到参数 Schema、调用工具并拿到结果。对一个内部平台来说,这相当于给 AI 编程助手装了一个“万能插头”,今天接一个发布系统,明天再接一个数据查询服务,理论上都不用改核心代码。
我第一次接触时也是这个感受。我所在的某基础设施团队当时维护着一套内部构建和发布命令行工具,功能很多:编译、打包、上传产物、刷新配置、回滚版本。团队内部早就想把这些能力开放给 AI Agent,MCP 正好看起来是最标准的方案。按照官方示例写一个 server,把已有函数包成 tool,模型可以自动发现并调用,听起来确实很理想。
我们用原型验证时,还故意挑了最简单的场景:让模型调用“查询某服务的部署状态”。这能跑通,回应也符合预期。于是团队决策层面直接进入推广阶段,准备把一部分发布能力开放出来。
1.2 落地一周内遇到的现实问题:上下文爆炸、参数Schema失效、工具选择漂移
真实环境一到,问题立刻暴露,而且都不是“代码 bug”层面的问题。
第一个坑是工具列表太长,直接把上下文预算吃掉一大块。我们内部有二十多个服务模块,每个模块都有对应的构建、部署、回滚、日志查看工具。按 MCP 的动态发现机制,客户端每次会话都要加载所有工具的定义。几十个工具、每个工具还有十几个参数描述,一轮请求下来,光工具定义就占了上万 token。模型还没来得及干活,上下文先被吃掉一截。到后面工具一多,模型开始频繁选错工具,不是它笨,而是选择空间太大,描述之间还会互相干扰。
第二个坑是参数 Schema 形同虚设。MCP 工具的类型定义是声明式的,但模型的调用并不保证遵守参数约束。我们的部署工具要接收“目标环境”和“服务模块”两个枚举值,文档里写得清清楚楚。结果模型在真实对话里会传一个并不存在的“灰度环境名”,或者把服务名和模块名传反。这类错误在 Demo 里很难复现,因为演示时你是精心构造的提示词;到了真实用户的对话里,模型会根据用户的自然语言自由发挥,错误率一下子就上来。
第三个坑是返回结果的规范性。MCP 协议里结果就是一个自由 JSON,没有规定错误表达方式。有的内部服务返回{"success": false, "message": "xxx"},有的返回 HTTP 风格的状态码,还有的干脆把错误打进 stderr。模型拿到这些五花八门的返回结果后,经常不会正确理解“这次调用到底失败没有”。这比参数错误更致命,因为它会直接导致 Agent 在错误状态上继续行动,比如部署失败后还继续发布下一个模块。
1.3 为什么这些坑在 Demo 里看不见
后来我复盘为什么早期没有发现这些问题,本质原因就一个字:小。Demo 只有一两个工具,上下文自然不爆炸;提示词是我们手工写好的,模型不会传错参数;返回结果是我们挑的正常路径,没有错误分支。而真实场景永远是大集合、多分支、不规则输入。
这也是我后来对“协议类解决方案”特别警惕的原因。MCP 这类标准方案,在小规模下体验非常好,但一进到大规模生产环境,真正的成本不在“让工具能调用”,而在“让模型稳定地选择正确的工具、传正确的参数、理解正确的结果”。这三个问题,MCP 一个都没解决。它只解决了“调用怎么发生”的问题,而工程上最难的问题全在调用之外。
2. 协议标准只规定了“怎么传”,没规定“传完怎么办”:工程治理维度欠账
2.1 权限模型、审计日志、幂等控制都要 Server 自己造
MCP 对工具调用本身没有定义权限层级。也就是说,server 注册了一个“删除环境”的工具,那任何能连上这个 server 的客户端理论上都能调用它。实际项目里,我们内部自己写了一套 ACL,按用户角色去过滤工具列表。但这就意味着 MCP 的“动态发现”被我们人为地截断了——不是所有工具对所有人可见。
这引出一个很尴尬的问题:既然权限模型要自己实现,动态发现的优势就打了折扣。你为了安全,必须让模型看到的工具列表依用户身份变化;为了实现这一点,要么在 server 端维护复杂的会话上下文,要么在网关层做一层代理。最后我们等于自己开发了一个“MCP 网关”,把协议之外的活全干了。
审计也是同样的道理。MCP 一次 tool call 的记录,日志里只会有“方法名 + 参数”,但参数本身不一定是可审计的,比如“调用构建接口”和“调用构建接口并传入--force-deploy”,风险完全不一样。CLI 这边反而好办:命令本身就是审计单元,Shell 历史、执行日志、操作账号天然齐全。你可以直接回答“谁在什么时间用哪个命令干了什么”,不需要额外解析 JSON-RPC 的 payload。
2.2 常驻进程的生命周期与状态污染问题
MCP server 通常是常驻进程。早期我们为了省事,让 server 启动时加载一次配置,内部带有内存状态。这个设计在演示里没问题,但在长期运行里很要命:发布系统的 token 过期时,server 得重新登录;某个工具如果内部缓存了错误状态,后续调用会连环出错;最麻烦的是,进程一旦崩溃,所有正在进行的工具调用都会卡死。
CLI 完全不同。命令跑完进程就退出,内存和状态全部释放。就算命令崩溃,影响面也被限制在单次进程里,不会污染下一个请求。对 AI Agent 来说,这种“无状态优先”的模型其实特别友好:每次调用都是一个干净的子进程,不存在“上一次调用的残余影响”。
2.3 标准化的“实现方差”:同样的工具在不同 SDK 里行为不一致
MCP 协议摆在那,但各个编程语言的 SDK 实现质量参差不齐。有的 SDK 会自动把工具参数解析成强类型,有的只会传原始 JSON;有的支持流式返回,有的只能一次性返回;更坑的是错误码处理,一些 SDK 会把合法的业务错误当成协议层错误处理。
这意味着你写一个工具时,不能只测一遍,得在多个语言客户端里各测一遍。我们内部有 Python 和 TypeScript 两套 Agent 调用端,同样一个部署工具,在 Python 客户端下跑得很好,在 TypeScript 客户端下就出现参数里的数字被转成字符串的问题。最后查了半天,发现是某个 SDK 对 JSON Schema 类型约束的处理不一致。这类兼容性成本,不在你的代码掌控范围内,而是被第三方 SDK 锁死了。
2.4 上下文消耗与动态发现的死循环
前面提到工具列表太长的问题,这里再展开一点。MCP 的设计鼓励“动态发现”,理论上模型可以随时查看有哪些工具可用。但模型需要“看到”工具描述,才能决定是否调用;而“看到”操作本身又需要把工具描述放进上下文,这就造成了死循环:工具越多,上下文消耗越大,模型的选择准确率越低,用户越不满意,于是开发者继续往工具描述里补充更多说明,进一步加剧上下文膨胀。
这个死循环在小系统里不存在,因为工具数量少,描述也短。但是大厂内部平台动辄几百个服务、上千个操作,MCP 的动态发现机制不但没有省钱,反而成了最大的耗 token 源。这也是很多团队转向 CLI 的最直接原因:CLI 工具是“按需发现”的,模型只需要知道某个通用命令的存在,具体子命令和参数由 help 文本动态提供,而不是把所有工具定义一股脑塞进上下文。
3. CLI 为什么成了更顺手的 Agent 操作层:退出码、管道和进程边界就是天然契约
3.1 从“模型帮我填参数”到“模型帮我敲命令”:决策重心转移
用 MCP 时,模型要对“哪个工具 + 哪组参数”做出一次决策。转成 CLI 后,模型实际上是在做“执行哪条命令”的决策。前者是在一个抽象空间里选函数签名,后者是在一个具体的命令语法里选执行方式。
这个差异比我最初想象的大得多。命令行的语法经过几十年的用户训练,已经形成了一套极其稳定的表达方式:命令 + 子命令 + 参数 + 标志。模型训练数据里包含大量的命令行示例,它天然更擅长生成命令,而不是生成 JSON-RPC 调用。你想一下,git status、kubectl get pods这种命令形式,模型见得太多了;而“调用某个自定义 MCP 工具,传什么字段”这种形式,模型只能依赖你临时写的描述。
在实际项目中,我明显感觉到,把工具包装成 CLI 后,模型的少样本能力更强了。因为命令行语法本身就是一种“全局通识”,不需要为每个工具单独学习一套 Schema。
3.2 help 文本就是“活的工具描述”——不用维护第三份 Schema
这个点是我迁移过程中最开心的地方。原来维护 MCP 工具时,我们至少有四份东西要同步:工具实现的代码、工具的 JSON Schema、模型的提示词、用户文档。任何一个更新,漏了一个就是事故。
CLI 只有一样东西:程序本身。--help输出是从参数定义自动生成的,既是给用户看的文档,又是给模型看的工具描述。我们只需要要求每个命令都保证 help 文本准确、简洁,模型就能通过读取 help 来决定如何调用。少了一层同步维护,意味着少了一类 bug。而且 help 文本天然是文本格式,不像 JSON Schema 那样会产生类型嵌套问题。
3.3 进程隔离带来的安全边际:崩溃、超时、权限都更好办
再说安全。MCP server 是常驻进程,权限模型要靠自己实现,而一次工具调用的历史行为几乎不可追溯。CLI 不一样,每次执行都是一个独立子进程,可以在操作系统层面控制:以一个最小权限账号运行、设定 CPU 和内存限制、加超时机制、把标准输出和标准错误分别采集。
我们迁移之后,安全团队很买账。因为一个命令的执行记录天然就是一条审计日志,命令白名单、参数校验可以直接在 Shell 层实现。反观 MCP,工具调用可能发生在 server 内部,日志只有“这次调用了 create_deployment 接口”,至于它内部是否真的执行了某些系统命令,完全取决于 server 的实现。
这个区别对 AI Agent 尤其重要:模型会犯错,甚至可能被恶意提示词诱导,所以底层接口必须能兜住最坏情况。进程边界就是一个很可靠的兜底:子进程崩溃了,Agent 主进程没事;命令权限受限,再怎么瞎搞也影响不了宿主。
3.4 组合能力:管道让 AI 能像人类工程师一样串联操作
CLI 的另一个隐藏优势是管道和输出重定向。人类工程师排查问题时,习惯先执行一条命令,再基于输出执行下一条。这个习惯可以被模型完美复用。比如先查询服务列表,用grep过滤出目标模块,然后再把结果作为参数传给部署命令。
在 MCP 的世界里,这种组合逻辑要么靠 server 端实现复杂的工作流编排,要么靠模型连续几次工具调用并在中间态做大量上下文拼接。但在 CLI 里,管道本身就是一种原语。模型可以直接写一条 shell 命令,让多个工具在操作系统层面完成组合,不需要把中间结果塞回上下文。
3.5 存量与惯性:大厂转 CLI 最现实的原因是“already there”
这可能是很多技术讨论容易忽略的一点:大厂的工具存量几乎都是 CLI。构建系统有 CLI,部署平台有 CLI,监控系统有 CLI,数据库变更有 CLI。这些东西已经运行了很多年,有完备的用户文档、权限体系、审计机制。如果接 MCP,等于把这一整套存量再用 JSON-RPC 重新包装一遍;如果接 CLI,底层都不用动,只需要让 Agent 学会“调用命令”这一件事。
所以说“大厂抛弃 MCP 转向 CLI”,与其说是抛弃新东西,不如说是想明白了:CLI 才是那个已经过大规模验证的稳定层。在别人那里 MCP 可能很香,但在大厂,重新改造存量系统的成本高到无法接受,而让模型去适配存量 CLI 的成本低得多。这是个经济账。
4. 从 MCP 迁回 CLI 的完整路径:分层决策、薄适配层和踩坑清单
4.1 先回答“什么工具适合迁成 CLI”,用一张决策表
不是所有 MCP 工具都该迁。我建议按下面这张表去判:
| 工具特征 | 建议 | 原因 |
|---|---|---|
| 有明确的命令式语义(构建、部署、发布、配置变更) | 优先迁 CLI | 命令本身可审计、可回滚、可重试 |
| 返回结构化数据且需要强类型耦合 | 暂时保留 MCP 或走 HTTP API 适配 | CLI 的文本输出需要二次解析 |
| 强交互式操作(需要逐步确认、多轮问询) | 暂不迁 | 交互逻辑不适合无状态命令 |
| 只读类信息查询(查状态、查日志、查列表) | 迁 CLI 收益大 | 模型擅长调用xxx-cli get并解析结果 |
| 工具数量庞大且相互关联 | 先做一批“高频命令”,其余保持老接口 | 降低迁移风险 |
这个表的核心原则是:操作型工具迁 CLI,知识型工具可以考虑保留。因为操作型工具最重要的是可控、可审计、可回滚,而 CLI 恰好在这三方面都强;知识型工具更看重返回结构,MCP 的 JSON-RPC 还能多撑一会儿。
4.2 薄适配层示例:用 Python 把 CLI 包装成 MCP 工具
如果有些 Agent 机器仍然通过 MCP 调用,但后端已经改成 CLI,可以做一个薄适配层。核心思路很简单:MCP server 只是翻译器,真正干活的是子进程。
import subprocess import shlex import json def run_cli_tool(command: str, timeout: int = 30): # 只允许白名单内的命令 allowed_prefixes = ("release-cli", "build-cli", "query-cli") if not command.startswith(allowed_prefixes): raise PermissionError("command not allowed: " + command) proc = subprocess.run( shlex.split(command), capture_output=True, text=True, timeout=timeout, check=False, ) return { "exit_code": proc.returncode, "stdout": proc.stdout[:4000], "stderr": proc.stderr[:2000], } # MCP tool 回调内部只需要调用这个函数 def handle_call_tool(tool_name, arguments): command = arguments["command"] result = run_cli_tool(command) # 模型判断是否成功,看 exit_code 比看任意 JSON 更可靠 if result["exit_code"] != 0: return {"success": False, "error": result["stderr"], "stdout": result["stdout"]} return {"success": True, "stdout": result["stdout"]}这段代码里两个细节值得注意。第一,allowed_prefixes就是一个最基础的白名单,防止模型拼接出危险命令。第二,返回结果里把exit_code作为一个显式字段,让模型一眼能看出调用是否成功。这也是 CLI 模式比 MCP 模式确定性更高的原因:退出码是 C 语言时代就定下的老规矩,模型学习成本极低。
4.3 Agent 侧统一调用入口与审计:白名单、dry-run、超时
适配层的安全不能只靠一个前缀判断。我建议在 Agent 侧至少做四件事:
- 所有 CLI 调用统一走一个入口函数,不让人工提示词直接拼接命令。
- 强制开启 dry-run 模式,让模型先生成将要执行的命令,人工确认后再真正执行。
- 给每个命令定义超时上限,并按命令类型区分:查询类短超时,变更类长超时。
- 全量记录执行日志,包括命令原文、参数、执行账号、返回码、耗时。
这套机制我们跑下来,最大的感受是排障速度快了很多。以前模型调用 MCP 工具失败,需要去翻 server 日志,层层找原因。现在直接看命令执行日志,一眼就能看到哪条命令报错,错误信息就在 stderr 里。人工智能出的问题,最终还得靠传统工具去诊断。
4.4 迁移时最容易被忽略的三个问题:帮助文本质量、返回值解析、错误码语义
第一,帮助文本质量直接决定模型是否会用。原来写成给人看的长段落说明,模型未必友好;改成简短的“参数名 + 示例”格式反而更好。我的经验是,每个命令的 help 里尽量包含一个可复制的完整示例,这比任何抽象描述都有用。
第二,返回值解析。CLI 的输出默认是给人看的表格,模型解析时容易出错。我们在底层约定了一种“机器可读模式”:所有命令支持--json输出,返回统一 JSON。这条规则非常关键,否则模型会在文本表格上浪费大量 token,还容易把列对齐搞错。
第三,错误码语义要收敛。系统里原有的 CLI 有些命令退出码是 0 但业务上失败,有的退出码是 127 但表示“配置不存在”,这类不一致会让模型无所适从。迁移过程中,我们专门把所有命令的错误码语义收敛成了三档:0 成功、1 业务失败、2 参数错误。多档语义会让模型学习成本骤然降低。
5. 迁移之后 MCP 还剩下什么:它不是失败品,只是不该当默认选项
5.1 继续保留 MCP 的场景:知识检索类工具、桌面/IDE 集成、富 Schema 接口
做完全量迁移后,我们并没有删掉所有 MCP。有三个场景留了下来。
第一个是知识检索类。比如读取一篇长文档、查询知识库片段,这些场景要的是“把结构化的结果交给模型”,而不是“完成一个操作”,MCP 的 JSON-RPC 返回结构明显更顺手。CLI 虽然也能输出 JSON,但文档检索类服务往往需要流式返回、动态分页,进程模型反而不方便。
第二个是桌面端和 IDE 集成。很多编辑器和桌面应用的插件生态直接支持 MCP 客户端,如果工具要嵌入这类场景,保留 MCP server 能省去大量集成工作。这不是技术优劣问题,而是生态接入成本问题。
第三个是富 Schema 接口。如果工具的参数极其复杂,包含嵌套对象、动态数组、组合条件,命令行表达会非常笨拙,这时候 MCP 的 JSON Schema 表达能力更匹配。老实说,这种情况在我们内部不多,但确实存在。
5.2 内部“CLI 优先、MCP 按需”的落地形态
经过几轮折腾,我们最终形成了一套原则:让 CLI 成为唯一执行入口,让 MCP 成为按需暴露窗口。
具体来说,底层所有能做成命令的操作,全部收进一套统一的内部命令行工具,它有严格的子命令规范和代码风格。上层需要给模型用的时候,按场景决定是直接在 Agent 里跑命令,还是包一层 MCP server。包了 MCP 的场景,内部真正执行的也是 CLI 命令,不会绕过已有审计。
这套结构的最大好处是“核心只有一套语义”:操作语义存在于 CLI 层,MCP 只是一层翻译,翻译错了最多影响协议表现,不会污染底层操作。反过来,如果底层是 MCP,那命令语义就和协议实现绑死在一起,改起来牵一发动全身。
5.3 我个人这几轮选型的体会:先约束操作面,再谈模型自动化
最后说一点比较个人的体会。我们最初想的是“把更多工具交给 AI”,后来发现方向反了,应该先想“哪些操作永远不该乱来”。CLI 的确定性并不是因为它先进,而是因为它把自由度压制到了一个历史验证过的范围内:命令、参数、输出都是可控的;MCP 的灵活性反而成了它的负担,自由度太高,模型出错的方向也太多。
对于中小团队,我并不建议跟风全量迁移。如果你的 MCP 接入只有十几个工具,上下文膨胀问题不严重,参数 Schema 也没出过乱子,那就继续用,没必要折腾。大厂转向 CLI,本质上是被规模逼出来的选择,不是每个团队都会遇到那时的痛点。
我自己现在的偏好是:新工具默认先设计 CLI 接口,再考虑要不要包一层 MCP。这个顺序反过来,往往会在某个意想不到的晚上让你爬起来调一个“协议层状态泄漏”的问题,而大概率,那个问题用命令行重建一次就能解决。