我们的 AI SRE 要替用户操作 Flashduty:查告警、认领故障、翻值班表、查最近的变更。它第一次拿到这个能力是今年一月,走的是我们自己的 MCP Server,内置在每个会话里,当时有 18 个工具。五月底,我们把这条路换成了命令行,一周后删掉了 MCP 这条路。从那以后,Agent 操作平台只有 CLI 一个入口。
18 个工具不够用
一开始没什么问题。这 18 个工具是按任务挑的,围着故障生命周期转:查询、认领、关闭、升级规则、变更记录、状态页公告。日常的操作请求基本都能接住,工具少,Agent 也不太会选错。
到排障场景就不行了。排障的本质是调查,调查开始的时候没人知道下一步要查什么,可能是告警,可能是时间线,也可能是值班记录或者某个变更单。平台公开 API 有 290 个操作,哪一个都可能在某次调查里用上,18 个工具盖不住。
先想到的是扩充 MCP
第一反应当然是把 MCP 做大,有两种做法。
全量镜像:290 个操作一个个包成工具。这个很快就否了。几百个工具的 schema 会一直占着模型上下文,而且工具越多,模型选起来越糊涂。后来我们把这个判断写进了 MCP Server 的 README:一比一映射整个 API,既撑爆上下文,也让工具选择变差。
渐进式加载:工具不全摆出来,只给 Agent 两个入口,一个搜工具,一个调工具,用什么搜什么。AWS 的 MCP 就是这个形态。这套机制我们不陌生,当时第三方连接器的加载就是类似的思路,Agent 报服务器名,拿回那个服务器的工具目录。
但有两个顾虑。一是怕选不准:schema 不在眼前,Agent 全靠一次检索决定用哪个工具、参数怎么填,搜不中,或者搜中了理解偏了,都没有兜底。二是上下文只增不减:每次搜索的结果、每个加载进来的 schema,都会留在会话里。AI SRE 排一次障要做几十次调用,会话越跑越长,积累的工具描述越来越多,模型的表现会跟着往下掉。
这两条当时都没有实验数据,就是直觉。但方向一致:搜索式 MCP 把发现工具的成本摊进了每一次调用,会话越长付得越多。
换成 CLI
Agent 的执行环境里本来就有 bash。CLI 正好把上面两个顾虑都翻了过来:help 按需看,看完就是普通文本,不会变成常驻上下文的工具定义;输出可以用管道直接过滤,不用全量读回来再挑;覆盖率也不用手工堆,命令从接口描述生成就行。现在 290 个操作里 289 个是生成的,剩下一个流式响应手写,测试里有断言盯着这个比例。
五月底切换完成,CLI 成了 Agent 操作平台的主路,一周后 MCP 回退配置也删了。这个决定没有严谨的对比实验撑着,就是直觉加上面那点结构分析,赌的是成本形态:MCP 的成本摊在每次调用里,用多久付多久;CLI 的成本是一次性的工程投入,做完就完了。后来发现这笔投入不小。
让 Agent 在几百条命令里找对
切完第一个问题马上就来了。第一版技能文档只写了八条常用命令,其他的让 Agent 自己跑 --help。当时觉得这样最稳,help 永远和装的版本一致,不会过期。实际情况是 Agent 得先猜对命令名,才知道去哪看 help。文档里教的是 statuspage create-incident,真实命令叫 status-page change-create,猜错一次查一次,查完接着猜。第二版加了推导规则,分组取接口路径第一段,动作取剩下的用连字符连,推完再用 help 确认,高频误判单独列成表。错误少了些,还是有。
现在是两层结构。主文件就是一张中英双语的意图索引,把用户的话路由到某个领域;每个领域一张卡片,这个领域的全部命令、参数、可选值、流程都在上面,现在总共 346 条命令。卡片不是人写的,是从编译出来的命令树反射生成的,CI 再拿同一棵命令树逐条校验卡片上的示例,写错一条构建就红。
准确率用两套东西盯。一套是合成题库:拿更强的模型批量出题,AI SRE 答,再让模型判卷,一共上千道。这套负责把高频场景铺满,但出题、答题、判卷全是模型,题目分布和真实用户提问的分布不是一回事。所以还有第二套,定期审计线上真实会话,一轮五十多个,专门找题库想不到的错法。有一次审计发现,Agent 把「规则 90 天没触发」理解成了「规则没配置」,把一个 P0 检查项标成缺失。这种错和命令选没选对没关系,数据拿对了,解释错了。审计里反复出现的问题会写回技能文档,比如「查询返回空就是没有,不要换关键词重试」「没跑过的命令不能说成查了是空」。这块的投入到现在还在花。
JSON 转义
还有个不起眼但纠缠了很久的问题:往命令里传 JSON。JSON 里有引号,bash 也有引号,两层引号套在一起,模型很容易写错。我们的执行环境还会在每条命令运行前把它完整按 shell 语法解析一遍做权限检查,引号稍有不平衡直接拒绝,这个问题暴露得比一般场景更频繁。
主要的解法是让 Agent 尽量不写 JSON。生成命令的时候,请求体里的每个平坦字段都拆成独立的参数,比如 --chat-id、--member-ids,常见调用里一个 JSON 都不出现,转义问题从根上就没了。剩下确实需要 JSON 的场景,比如嵌套结构,或者参数里带 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,不要内联」。
别跑错二进制
Agent 执行的就是一个裸命令名。宿主机上要是装了另一个版本,或者恰好有个同名程序,命令照样能跑,只是结果时对时错,查起来很费劲。
核心解法是 PATH。执行环境启动时把自带的 CLI 落到自己的目录,把这个目录插到每个 bash 会话搜索路径的最前面,裸命令名永远解析到我们自带的那份,宿主机上装了什么都不影响。名字上还有一层顺带的保险:自带的用短名 fduty,官方安装脚本默认装的是长名,本来就不重名。
有一个地方 PATH 不管用:一条不走 shell、直接起进程的路径,它解析命令名发生在环境变量生效之前,搜索路径排得再靠前也来不及,这条路上只能把命令改写成绝对路径。
还踩过一个更低级的坑:「装到哪个目录」和「把哪个目录插到最前」曾经是两处各算各的,结果装在 A,PATH 指着 B,执行环境启动一切正常,Agent 每次调用都报命令找不到。修完之后,这两个值取同一个来源。
凭据
用 MCP 的时候,连接和认证是配置里现成的;换 CLI 之后,身份、权限、凭据的交付都得自己设计。最容易漏的场景是多人会话:A 拉起作战室,B 和 C 也在里面说话,Agent 执行命令时用谁的身份就成了问题。
我们按「这句话是谁说的」来。每一轮对话给实际发消息的人签一把凭据,轮次结束马上删,校验绕开缓存,删了立刻失效。签发时网关解析身份用的函数,和这个人用浏览器登录时走的是同一个,所以 Agent 拿到的就是他本来的权限,识别不出发话人就不签。设计时否掉过按会话签发的方案,因为作战室里那等于把 A 的身份给 B 用。

一次性只解决了重放,凭据在使用期间还是可能被打印出来。Agent 是好奇的,排查环境问题时很自然会跑一把 env 或者 printenv,凭据就进会话记录了。所以每条 bash 命令执行前都过一道检查:批量导出环境的命令拦掉,env、printenv、裸 set、export -p、declare -p、compgen -e,包括读 /proc/*/environ 这种绕路的,都在名单里;字面引用凭据变量名的命令也拦。拦截的报错会顺带告诉 Agent,想看哪个变量就单独读哪个。这道检查挡不住刻意的字符串拼接,挡的是顺手打印。
再往下一层是让凭据尽量不进环境变量。在自托管的 Linux 执行环境上,我们做了 socket 桥接:起子进程之前建一对 Unix socket,凭据从这条通道递给 CLI,命令行参数和环境变量里都没有它,env 打出来也看不到。云沙箱做不到这一点,退回只活一轮的环境变量。所以「凭据永远不进环境变量」这话在我们这不成立,只能说在支持的环境上成立。
哪些不防也说一下。同一个沙箱用户下的其他进程,在凭据有效的窗口里拿去用,这个不防:CLI 能拿它认证,邻居进程理论上就拿得到,加密也没用。防的是凭据被带出去之后长期重放,手段就是活不过一轮。另外,权限目前没有做比发话人更窄的收敛,发话人要是账号所有者,Agent 这一轮就是所有者权限。
版本
CLI 不单独发版,直接嵌进执行环境的二进制里,跟着执行环境一起发。执行环境启动时把它落盘,版本号钉死在一个常量里,想升级 CLI 就发一版执行环境,两个东西永远同时升级。这样线上不存在「新环境配旧 CLI」或者「旧环境配新 CLI」的组合,不兼容这个问题在结构上就没有了,也就不需要运行时版本校验。
更灵活的方案我们真做过:控制面下发清单,运行时热更新 CLI。代码写完合完,十几个小时以后整个删掉了。省的是少发一次执行环境,换来的是一个要一直维护的版本状态,不值。
CLI 得跟着 API 走
几百条命令靠人手维护肯定漂移。我们的做法是单一源头往下生成:代码是唯一真相,接口的路由注册和实现是源头,往下每一环都由工具生成,人手不抄任何一段。

这条链不是全自动的。接口描述到 SDK 的定时任务会开合并请求但不自动合,SDK 到 CLI 要人工升依赖、重跑生成器,一个接口改动走到命令跟上,中间有三次人工确认。全链自动、并且能拦住合并的只有一道:校验卡片和真实命令树一致。这么分配是因为漂移有两种,伤害差很远。上游加了新接口 CLI 没跟上,是缺功能,Agent 查不到会照实说没有;卡片教了一条不存在的命令,是错信息,Agent 会照着反复试,甚至自己编一条像样的。自动闸门拦的是后一种。
组合逻辑放在最上面的技能层,不进 CLI。一份完整的故障小结要查六个方面,对应六条独立命令,技能包里带了个脚本,六条一次跑完,结果打进同一个块。这样小结的每一段都有真实输出垫底,Agent 没机会漏跑一条再把缺的编圆。CLI 本身保持接近一比一,只做一点顺手的修补,比如列表接口返回的是人员 ID,补一次查询换成人名。
这层也返过工。脚本一开始强制六条命令都用省 token 的紧凑格式,线上审计量出来一个故障八万字符:紧凑格式把空字段也全摊出来了,Agent 得翻三四页才读得完。后来改回每条命令的默认渲染,只给详情那条加了字段投影。给模型看的输出,目标就是一口气读完,多一次翻页、多一次筛选,就多一次出错的机会。
MCP 还在用
切换后半个月,我们把第三方连接器的加载机制换成了搜索式:Agent 启动时不加载任何工具描述,只注册搜工具和调工具两个入口。就是前面我们没给自己平台用的那个形态。
这不矛盾。第三方系统的接口和发版不在我们手里,生成 CLI、钉版本、打包分发这些手段全用不上,在没有 shell 的前提下,搜索式加载就是最好的选择。目录里现在有 35 个第三方连接器,差不多一半是监控和可观测类。Anthropic 后来公开过一组数据:多服务器场景下工具描述占约 55k token,上搜索之后省了 85% 以上,工具选择准确率从 49% 提到 74%。不过这组数字比的是搜索式和全量加载,搜索式和 CLI 之间,目前没见到公开数据。
对外的 MCP Server 也一直在,现在 23 个工具,给 Cursor、Claude Desktop 这类没有 shell 的客户端用。它 README 第一屏就写着:Agent 有 shell 的话,直接用 CLI。
回头看
这件事里真正起决定作用的变量,不是 MCP 和 CLI 谁好,是平台是不是你自己的。平台是自己的,接口描述、发版、执行环境都在手里,才有条件把发现、凭据、版本这些每次调用都要付的成本,换成一次性的工程投入。平台是别人的,这些手段一个都没有,MCP 就是对的选择,我们自己也在这么用。
当时做决定靠的是直觉,代价清单比预想的长,工具选择那块的投入现在还在继续。这套做法适合我们的场景,你的场景不一样,结论很可能也不一样。


