跳至主内容
博客

从 MCP 到 CLI:自有平台应为 Agent 提供命令行

今年一月,我们的 AI SRE 通过内置 MCP 操作 Flashduty;五月底,我们改用生成式 CLI。本文说明为什么没有扩充 MCP、为什么放弃搜索式加载,以及切换之后付出的代价。

Flashduty 工程团队

从 MCP 到 CLI:自有平台应为 Agent 提供命令行

我们的 AI SRE 代替用户操作 Flashduty,包括查询告警、认领故障、查看值班表和查询近期变更。一月,AI SRE 通过内置的 MCP Server 获得这些能力,该 Server 包含 18 个工具。五月底,我们改用命令行工具(CLI)。一周后,我们删除了 MCP 这一路径。

我们的结论是:如果平台由自己维护,就为 Agent 提供 CLI。此时接口描述、发版流程和执行环境都由团队掌握。工具发现、凭据和版本这几项成本,原本在每次调用时都会产生。改用 CLI 后,这些成本可以转化为一次性的工程投入。如果平台由第三方维护,应当使用 MCP,我们自己也采用这种做法。

这一方案的成本高于预期。下文按时间顺序介绍遇到的问题。

18 个工具不够用

这 18 个工具覆盖故障生命周期:查询、认领、关闭、升级规则、变更记录和状态页公告。日常请求都能处理。由于工具数量少,Agent 很少选错工具。

排障场景则无法满足。调查开始时,下一步要查询的对象并不确定,可能是告警、时间线、值班记录或某个变更单。平台公开 API 有 290 个操作,每个操作都可能用到。

未扩充 MCP 的原因

扩充 MCP 有两种做法。

第一种是全量镜像,把 290 个操作都包成工具。这一方案很快被否决。几百个 schema(工具的参数定义)会持续占用上下文。工具越多,模型越容易选错。后来我们将这一判断写入了 MCP Server 的 README。

第二种是渐进式加载。Agent 只有两个入口:工具搜索和工具调用,需要哪个工具再检索哪个工具。AWS 的 MCP 采用这种方式。我们当时加载第三方连接器的方式与之类似。

我们有两个顾虑。第一,Agent 无法看到 schema,只能依据一次检索来选择工具、填写参数。检索结果有误或理解有误时,没有任何补救机制。第二,上下文只会持续增长。每次的搜索结果和加载的 schema 都会保留在会话中。一次排障需要几十次调用,上下文越长,模型表现越差。

这两个顾虑都没有实验数据支持,只是直觉判断。但两者指向同一个结论:搜索式 MCP 把工具发现的成本分摊到每次调用上,会话越长,成本越高。

换成 CLI

执行环境本身已提供 bash,CLI 恰好能消除上述两个顾虑。

  • help 按需读取,读取后只是普通文本,不会常驻上下文。
  • 输出可以通过管道过滤,无须完整读回。
  • 命令由接口描述生成,无须手工补齐覆盖率。

目前 290 个操作中有 289 个由程序生成,剩余一个是流式响应,只能手写。一个专门的测试会断言这一比例。

切换之前,我们没有进行严谨的对比实验。我们押注的是成本结构:MCP 的成本随使用时长持续产生,CLI 的成本一次性投入。事后看,这笔一次性投入相当可观。

让 Agent 找对命令

第一版技能文档只列出了八条常用命令。其余命令由 Agent 自行执行 --help 查询。我们认为这种方式最稳妥,因为 help 始终与已安装的版本一致。

问题在于,Agent 必须先猜对命令名,才能查询 help。文档中写的是 statuspage create-incident,真实命令是 status-page change-create。Agent 每猜错一次,就要查询一次,然后重新猜测。

第二版增加了推导规则:分组取接口路径的第一段,动作取其余部分,以连字符连接。推导完成后,再用 help 确认。高频误判单独列成表格。错误有所减少,但仍未消除。

现在的结构分为两层。主文件是一张中英双语的意图索引,负责把用户请求分配到对应领域。每个领域对应一张卡片,列出该领域的全部命令、参数、可选值和流程。卡片共覆盖 346 条命令。

卡片由程序生成。我们通过反射编译后的命令树生成卡片。CI 再用同一棵命令树校验卡片中的每条示例。任何一条示例有误,构建即失败。

准确率由两套机制监控。

第一套是合成题库,共有上千道题。能力更强的模型负责出题,AI SRE 负责答题,再由模型评分。题库能覆盖高频场景。但出题、答题和判卷均由模型完成,题目分布与真实提问存在差异。

因此需要第二套机制:定期审计线上会话,每轮审计五十多个会话。审计专门查找题库未覆盖的错误类型。一次审计发现,Agent 把「规则 90 天未触发」理解为「规则未配置」,并把一个 P0 检查项标记为缺失。命令和数据都正确,错误出在解释环节。

审计中反复出现的问题会写入技能文档。例如「查询结果为空即表示不存在,勿更换关键词重试」,再比如「未执行的命令,不得报告为返回空」。这部分工作仍在持续投入。

别让 Agent 写 JSON

在命令行中传递 JSON 容易出错。JSON 和 bash 都使用引号,模型经常写错嵌套关系。此外,我们的执行环境在运行前会按完整的 shell 语法解析每条命令,用于权限检查。引号不配对的命令会被直接拒绝。因此,这个问题在我们的环境中更常见。

主要解决办法是避免 Agent 编写 JSON。生成命令时,请求体中的每个平坦字段都会成为一个独立参数,比如 --chat-id 和 --member-ids。常见调用因此不再包含 JSON。

部分场景仍需要 JSON,例如嵌套结构,或者包含 SQL 的参数。SQL 中同时含有引号、逗号和换行。此时使用标准输入:--data - 从 stdin 读取请求体,并配合定界符带引号的 heredoc。shell 不会展开其中的任何内容,而是原样传给程序。

fduty monit-agent invoke --data - <<'FDUTY'
{"tools":[{"tool":"query","params":{"sql":"SELECT a, b FROM t WHERE s = 'x'"}}]}
FDUTY

这条规则来自线上故障。早期我们使用过一个 name=xxx,params={...} 格式,按逗号拆分参数。SQL 中的一句 SELECT a, b 就会导致参数拆分错误。我们删除了这个格式,改用 stdin。

技能文档原先写的是「参数含引号时,建议使用 heredoc」。后来在一次线上会话中,一段格式正常的内联 JSON 连续两次被拒绝。Agent 自行改用 heredoc 后才执行成功。现在文档规定:一律使用 heredoc,禁止内联 JSON。

确保执行正确的二进制文件

Agent 执行的是不带路径的命令名。宿主机上可能安装了其他版本,或存在同名程序。此时命令仍能执行,但结果时对时错,难以排查。

我们通过 PATH 解决这个问题。执行环境启动时,将自带的 CLI 写入专用目录,并把该目录置于每个 bash 会话 PATH 的最前面。这样,裸命令名始终指向自带的 CLI。

命名也提供了一层保障。自带的二进制文件名为 fduty,官方安装脚本安装的是 flashduty,两者不会重名。

PATH 在一处场景中无效:一条代码路径不经过 shell,直接启动进程。该路径在环境变量生效之前就解析了命令名。因此在这条路径上,我们把命令改写成绝对路径。

我们还遇到过一个更基础的错误。「安装目录」和「PATH 指向的目录」由两处代码分别计算。结果 CLI 安装在目录 A,PATH 却指向目录 B。执行环境启动正常,但 Agent 每次调用都报告「命令不存在」。修复后,两个值来自同一处代码。

凭据按发言人签发

使用 MCP 时,连接和认证都由配置提供。改用 CLI 后,身份、权限和凭据交付都需要自行设计。最容易遗漏的是多人会话:用户 A 创建了一个作战室,用户 B 和 C 也在其中发言。Agent 执行命令时,应当使用谁的身份?

我们的规则是:使用当前消息发送人的身份。在每一轮对话中,系统为消息发送人签发一个凭据。本轮结束后,该凭据立即删除。校验过程绕过缓存,因此删除立即生效。

网关解析身份所用的函数,与该用户通过浏览器登录时相同。因此,Agent 获得的权限与发送人本人的权限完全一致。如果无法识别发送人,系统不签发凭据。

设计评审时,我们否决了按会话签发凭据的方案。在作战室中,该方案会让 B 的命令使用 A 的身份。

每轮按发话人签发凭据:B 正在说话,钥匙经专用通道进入 CLI,不落环境变量,轮次结束即失效

凭据仅在一轮内有效,可以防止重放。但在这一轮内,凭据仍可能被打印出来。Agent 排查环境问题时,往往会执行 env 或 printenv,导致凭据进入会话记录。

因此,每条 bash 命令执行前都要经过一道检查。以下批量导出环境变量的命令会被拦截:env、printenv、不带参数的 set、export -p、declare -p、compgen -e,以及读取 /proc/*/environ 的命令。字面引用凭据变量名的命令同样会被拦截。报错信息会提示 Agent 单独读取所需的变量。

这项检查无法拦截刻意拼接的字符串,只能拦截无意的打印。

更深一层的措施是让凭据不进入环境变量。在自托管的 Linux 执行环境中,我们实现了 socket 桥接:启动子进程前,先创建一对 Unix socket,凭据通过这对 socket 传给 CLI。命令行参数和环境变量中都不含凭据,env 也无法显示凭据。

云沙箱无法实现桥接,只能退回到仅在一轮内有效的环境变量。因此,「凭据永远不进入环境变量」这一说法,只在支持桥接的环境中成立。

以下风险目前未做防护。

  • 同一沙箱用户下的其他进程,可以在凭据有效期内使用该凭据。CLI 能够使用该凭据认证,同一用户下的其他进程原则上也能获取该凭据,加密无法消除这一风险。我们防护的是凭据被外泄后重放,手段是凭据仅在一轮内有效。
  • 权限范围与发送人一致,没有进一步收窄。如果发送人是账号所有者,Agent 在这一轮中就拥有所有者权限。

CLI 随执行环境一同发版

CLI 不单独发版,而是嵌入在执行环境的二进制文件中,版本号固定在一个常量里。执行环境启动时将 CLI 写入磁盘。升级 CLI 需要发布新版执行环境。

两者始终同时升级,线上只存在版本匹配的组合。版本不兼容的问题在结构上已经排除,因此我们没有实现运行时版本校验。

我们也实现过更灵活的方案:由控制面下发清单,在运行时热更新 CLI。代码合入主干约十几个小时后,我们删除了全部代码。该方案只能节省一次执行环境发版,却会增加一份需要长期维护的版本状态,收益低于成本。

保持 CLI 与 API 同步

几百条命令如果由人工维护,必然出现漂移。我们的做法是保留单一源头,逐层向下生成。代码是唯一的事实来源,生成的起点是接口的路由注册和实现。其下的每一层都由工具生成,不经人工复制。

从平台代码到 Agent 卡片的生成链:每一环由工具生成,人工确认,最后一环由 CI 校验

这条生成链并非全自动。从接口描述到 SDK,由定时任务创建合并请求,但不自动合并。从 SDK 到 CLI,需要人工升级依赖并重新运行生成器。一个接口改动传递到 CLI 命令,需要经过三次人工确认。

整条链上只有一道自动且会阻止合并的检查:校验卡片与真实命令树是否一致。

只在这一处设置自动检查,是因为两种漂移的后果差别很大。上游新增接口而 CLI 尚未同步,属于功能缺失,Agent 查不到时会如实报告。卡片列出一条不存在的命令,属于错误信息,Agent 会反复重试,甚至自行编造一条看似合理的命令。自动检查拦截的是后一种漂移。

组合逻辑放在最上层的技能层,而不放入 CLI。一份完整的故障小结需要查询六个方面,对应六条独立命令。技能包中的一个脚本会一次执行六条命令,并把结果合并为一块输出。小结的每一段都有真实输出作为依据,Agent 无法遗漏某条命令后再编造内容。

CLI 本身尽量与 API 保持一一对应,只做少量修补。例如,列表接口返回人员 ID,CLI 会再查询一次,将其转换为人名。

该脚本也经过返工。最初,脚本强制六条命令都使用节省 token 的紧凑格式。线上审计测得,单个故障的输出约有 80,000 字符。紧凑格式会展开所有空字段,Agent 需要分三四次才能读完。后来我们恢复了每条命令的默认输出,只为详情命令增加字段筛选。面向模型的输出应当一次读完,每多翻一页,就多一次出错的机会。

MCP 仍在使用

切换后两周,我们把第三方连接器的加载方式改成了搜索式。Agent 启动时不加载任何工具描述,只注册两个入口:工具搜索和工具调用。我们没有为自有平台采用这种方式。

这两种选择并不矛盾。第三方系统的接口和发版不由我们控制,生成 CLI、锁定版本和打包二进制文件等手段均不适用。在没有 shell 的前提下,搜索式加载是最好的选择。目前目录中有 35 个第三方连接器,约一半属于监控和可观测性系统。

Anthropic 后来公布的一组数据得出了相同方向的结论。在多服务器场景下,工具描述约占用 55k token。改用搜索式发现后,这部分占用降低了 85% 以上,工具选择准确率从 49% 提高到 74%。需要说明,这组数据比较的是搜索式与全量加载,而非搜索式与 CLI。关于后者,我们尚未看到公开数据。

我们对外提供的 MCP Server 仍在运行,目前有 23 个工具。该服务面向 Cursor、Claude Desktop 等没有 shell 的客户端。README 首屏注明:如果 Agent 可以使用 shell,请直接使用 CLI。

还有一种情况两种方式都不适用:对方是另一个能够自主推理的 Agent。此时我们不调用工具,而是通过 A2A 协议把整个任务交给对方。这一主题另有专文介绍。

回顾

这个决定基于直觉。实际代价比预想的多,工具选择相关的工作至今仍在投入。这套做法适合我们的场景。读者的场景不同,结论也可能不同。

相关阅读