面试官:函数套个 CLI 就算解耦了?Agent 工具执行流程封装 CLI 的完整工程实践

面试官敲了敲桌子:「你说把 Agent 的工具执行流程封装成 CLI,那不就是给函数套个命令行?换个入口,就算解耦了?」
这个反问很扎人,因为它戳中了一个真实的分歧:大部分人说的「CLI 封装」,其实只是给 main() 加了个 argparse,业务代码该依赖会话还是依赖会话,该读全局配置还是读全局配置。入口换了,耦合一点没动。
但这不代表 CLI 封装是伪命题。恰恰相反,Agent 工程里这几年最实在的几次转向------从 function calling 到 MCP,从 MCP 再到「让模型直接写命令」------都围绕着同一件事:工具的执行路径,到底该不该和聊天进程绑在一起。
这篇文章把这件事讲透:
- 三个真实翻车现场,以及它们共同的根因;
- 把「能力、入口、执行环境」拆成三层,说清 CLI 到底解决了什么、没解决什么;
- 四年演进史:从 function calling 到代码执行,每次转向的动因;
- 一个可以直接跑起来的 Python 工具,从业务核心、CLI 入口、执行适配器到契约测试,五步写完;
- 给机器用的 CLI,那 12 条不写在文档里就一定会踩的规则;
- 五种做法的横向对比表,以及什么情况下根本不该封;
- 十道面试高频题,附答法。
代码都在本机跑过,输出贴在文中。看完全文,你应该能把自己项目里那个「只能在 Agent 里跑」的函数,拆成一个既能给模型调、也能进 CI 的独立程序。
文章目录
- [面试官:函数套个 CLI 就算解耦了?Agent 工具执行流程封装 CLI 的完整工程实践](#面试官:函数套个 CLI 就算解耦了?Agent 工具执行流程封装 CLI 的完整工程实践)
-
- 一、痛点场景:三个现场,同一个根因
-
- [现场一:Agent 报「报表生成失败」,重放十二次,每次报错都不一样](#现场一:Agent 报「报表生成失败」,重放十二次,每次报错都不一样)
- [现场二:运营要批量跑 300 个文件,接口加不出来](#现场二:运营要批量跑 300 个文件,接口加不出来)
- [现场三:CI 日志写着 success,产物目录是空的](#现场三:CI 日志写着 success,产物目录是空的)
- 三个现场的共同点
- 二、它是什么:能力、入口、执行环境
-
- [2.1 三层各管什么](#2.1 三层各管什么)
- [2.2 三个常见误解](#2.2 三个常见误解)
- [2.3 大白话和生活案例](#2.3 大白话和生活案例)
- 三、为什么用:五个收益,和三个它给不了的东西
- 四、它是怎么演进过来的
-
- [阶段一:2023 年上半年,插件式工具](#阶段一:2023 年上半年,插件式工具)
- [阶段二:2023 年年中,function calling](#阶段二:2023 年年中,function calling)
- 阶段三:工具目录膨胀
- [阶段四:2024 年底,MCP](#阶段四:2024 年底,MCP)
- [阶段五:2025 年,CLI-first 回潮](#阶段五:2025 年,CLI-first 回潮)
- [阶段六:2025 年底,代码执行](#阶段六:2025 年底,代码执行)
- [阶段七:2026 年,CLI 进 CI](#阶段七:2026 年,CLI 进 CI)
- 三条主线
- [五、怎么用:一个能跑的工具,从 0 到 1](#五、怎么用:一个能跑的工具,从 0 到 1)
-
- [5.1 目录结构](#5.1 目录结构)
- [5.2 第一步:抽出业务核心](#5.2 第一步:抽出业务核心)
- [5.3 第二步:写 CLI 入口,定义契约](#5.3 第二步:写 CLI 入口,定义契约)
- [5.4 第三步:写执行适配器](#5.4 第三步:写执行适配器)
- [5.5 第四步:给模型的工具说明](#5.5 第四步:给模型的工具说明)
- [5.6 第五步:契约测试](#5.6 第五步:契约测试)
- [5.7 跑起来](#5.7 跑起来)
- [六、给机器用的 CLI:12 条硬规则](#六、给机器用的 CLI:12 条硬规则)
- 七、三个最容易翻车的地方
-
- [7.1 退出码不是业务状态](#7.1 退出码不是业务状态)
- [7.2 受理不等于完成](#7.2 受理不等于完成)
- [7.3 超时不等于没执行](#7.3 超时不等于没执行)
- 八、竞品对比:五种做法的代价
- 九、常用场景与企业项目怎么落地
-
- [9.1 五个高频场景](#9.1 五个高频场景)
- [9.2 企业里,四类调用方共用一套契约](#9.2 企业里,四类调用方共用一套契约)
- [9.3 上线清单](#9.3 上线清单)
- 十、面试高频题
-
- [Q1:为什么要把 Agent 的工具执行流程封装成 CLI?直接调函数不行吗?](#Q1:为什么要把 Agent 的工具执行流程封装成 CLI?直接调函数不行吗?)
- Q2:换个入口就算解耦了吗?什么才算真的解耦?
- [Q3:CLI 和 shell 有什么区别?为什么强调不要用 shell?](#Q3:CLI 和 shell 有什么区别?为什么强调不要用 shell?)
- [Q4:CLI 封装能解决权限隔离吗?](#Q4:CLI 封装能解决权限隔离吗?)
- Q5:工具输出里有进度和日志,调用方怎么判断真实结果?
- Q6:超时了应该重试吗?
- [Q7:CLI 和 MCP 怎么选?](#Q7:CLI 和 MCP 怎么选?)
- Q8:为什么说「确定的步骤要写进代码」?会不会限制模型的灵活性?
- Q9:工具版本怎么管?
- [Q10:什么情况下不该封装成 CLI?](#Q10:什么情况下不该封装成 CLI?)
- 十一、什么时候封,什么时候不封
- 十二、收尾
一、痛点场景:三个现场,同一个根因
先看三个现场。它们发生在不同团队、不同业务,但排查到最后,都撞在同一堵墙上。

现场一:Agent 报「报表生成失败」,重放十二次,每次报错都不一样
灰度发布后,运营在对话框里让 Agent 生成一份周报,返回一句「工具执行失败,请重试」。
工程师的第一反应是模型不行,换了个更大的模型,还是失败。于是开始重放:把同一段对话从头跑一遍,第一次报「文件不存在」,第二次报「权限不足」,第三次居然成功了。
查了两天才发现:工具函数是这样写的------
python
def generate_report():
# 从当前会话的全局上下文里取路径
path = agent_context.session.workspace.last_file
return build_report(path)
它没有参数。它要的东西,全部从「当前会话」这个隐式的全局状态里捞。而会话里 last_file 指向哪个文件,取决于模型上一轮说了什么。所以「同一个输入」根本不存在,重放十二次就是十二个不同的输入。
根因不是模型选错了工具,是这个函数压根没法被单独调用一次。 你想复现,先得复现一整段对话。
现场二:运营要批量跑 300 个文件,接口加不出来
业务方提了个很朴素的需求:把这 300 份合同批量转成 PDF,一次性跑完。
工程师想加个批量接口,打开代码发现加不了:转换逻辑里读了三样东西------会话里的用户身份、Agent 进程里初始化的模型连接、还有一个从环境变量里取的临时目录。要在脚本里跑,就得把这三样都模拟一遍。
最后写了 200 行胶水代码,把 Agent 的运行时初始化搬进脚本,跑是跑通了,但从此这份逻辑有了两个版本。三个月后修了一个格式 bug,只改了其中一份,另一份继续产出错误的 PDF,谁也不知道。
根因不是「缺一个批量接口」,是这份能力从来没有过独立的执行入口。
现场三:CI 日志写着 success,产物目录是空的
流水线里加了一步「用 Agent 工具做数据校验」。日志最后一行是 check finished successfully,绿了三个月。
直到有一次发布后发现,校验用的数据文件根本没被读进去------工具启动时找不到文件,打印了一句提示,然后用空数据集「成功」跑完了。
去看代码:
python
print("check finished successfully") # 无论发生什么都打印这句
return 0 # 无论发生什么都返回 0
根因不是 CI 配置错了,是「成功」这件事从来没有被定义过。 没有退出码语义,没有产物校验,调用方只能去读一句人写的自然语言。
三个现场的共同点
把它们并排放,会发现三个「第一反应」都指向了错误的方向:
| 现场 | 你的第一反应 | 真实原因 |
|---|---|---|
| 报表失败 | 模型太笨,换个大模型 | 函数从会话全局读参数,没有明确输入 |
| 批量需求 | 加个批量接口 | 业务逻辑依赖运行时,接口加不出来 |
| CI 假绿 | 偶发问题,重跑一次 | 没有退出码与产物校验,成功无法被判断 |
三个根因,其实是同一件事的三个侧面:工具和它的调用方粘在了一起。 能力、入口、执行环境,三样东西糊成一团。你想只换其中一个,就必须把另外两个一起搬走。
这就是 CLI 封装真正要处理的问题。不是「加个命令行」,是把这三层拆开。
二、它是什么:能力、入口、执行环境
先给结论,再拆开讲。

2.1 三层各管什么
第一层,工具能力。 真正干活的那部分:解析文档、查询数据、转换格式、写入文件。它应该是一个纯粹的输入输出映射------给它明确的参数,它返回明确的结果。
第二层,调用入口。 规定「请求怎么交进来,结果怎么拿回去」。函数调用是入口,命令行是入口,HTTP 接口是入口,MCP 工具也是入口。它们之间的差别只在形状:函数用进程内对象传参,CLI 用字符串参数和退出码,HTTP 用请求体和状态码。
第三层,执行环境。 决定这段代码「能碰到什么、能消耗多少、以什么身份跑」:进程属于哪个用户、能访问哪些目录、能不能连网、最多跑多久。
CLI 封装动的是第二层,顺带改善了第三层的可控性------因为有了进程边界,你能给它单独设工作目录、单独设环境变量、单独设超时。
但请注意:CLI 不会自动改变第三层的权限本质。 子进程默认继承父进程的 uid,这一点后面会单独讲。
2.2 三个常见误解
面试里被追问的时候,这三个误解最容易暴露。
误解一:「CLI 一定要有人在终端里敲。」
不是。CLI 是一种程序接口。人可以在终端里敲,另一个程序也可以直接 fork + exec 拉起它。服务器上跑的那些命令,99% 都没有「人」在场。
误解二:「CLI 就是 shell。」
不是,这两个东西经常被混为一谈,但差得很远:
- CLI 程序 自己解析参数,
--input a.csv里的a.csv是一个值,不会被解释成命令; - shell 是命令解释器,它处理管道
|、重定向>、变量展开$HOME、通配符*。
一个安全的调用方式是:把「可执行文件」和「参数列表」分开交给进程启动接口,不经过 shell 。这样用户输入里的 ; && $() 都只是普通字符。
python
# 安全:参数是列表,shell 不参与解释
subprocess.run([sys.executable, "-m", "report_tool.cli", "summary", "--input", user_input])
# 危险:整段拼成命令字符串,shell 会解释里面的元字符
os.system(f"report-tool summary --input {user_input}")
第二种写法,当 user_input 是 a.csv; rm -rf ~ 时,你就知道后果了。
误解三:「CLI 封装就是给函数加个 main。」
这是本文开头那个面试官质问的版本。加 main 只是加了一层皮,如果函数内部还是从会话里读路径、从全局拿配置、要求先初始化模型连接,那这层皮没有解决任何问题。
真正的封装要做的是:把能力从运行时的上下文里剥离出来,让它只依赖显式传入的参数。
2.3 大白话和生活案例
用一个食堂打比方。
工具能力是后厨会做的菜------红烧肉、番茄炒蛋。这是本事,跟窗口无关。
调用入口 是打饭窗口。窗口要有菜单(参数的说明)、要能报菜名(调用)、要能拿到餐盘(返回值)、要能告诉你「今天这个菜卖完了」(错误)。食堂可以开三个窗口:一个给堂食,一个给外卖骑手,一个给夜班留餐。菜是同一锅,窗口有三个。
执行环境是后厨的钥匙串。厨师能进哪个冷库、能用多少燃气、几点必须下班------这些跟开几个窗口完全没关系。你把窗口从两个加到五个,厨师的钥匙一串没多,冷库还是那个冷库。
那么「把工具封装成 CLI」相当于什么?
相当于给这道菜开一个独立的出餐口:不经过堂食大厅,外卖骑手可以直接来取;厨师长想尝味道,也可以直接来这个口子拿一份;质检想抽查,同样走这里。
以前是什么样?以前这道菜只能从「堂食大厅」出,而且必须由一个服务员(Agent 会话)下单。骑手想拿菜,得先假装成顾客坐进去,点一桌菜,再从桌上把那一盘端走。这就是现场二里那 200 行胶水代码。
而所有人都容易搞错的一点是:多开一个出餐口,不等于后厨就安全了。 厨师还是那串钥匙。要限制他能进哪个冷库,得换锁,不是加窗口。
三、为什么用:五个收益,和三个它给不了的东西

收益一:一份能力,多个调用方
这是最直接的收益。同一份业务核心,可以同时被四类东西调用:
- Agent 的工具适配器,拉起子进程;
- 开发者自己,在终端里手工敲命令;
- 定时任务与调度平台;
- CI 流水线。
衡量「解耦」成没成,标准很朴素:换一个调用方,要不要复制一遍代码。 如果每加一个调用方就多一份实现,那不叫解耦,叫分叉。
分叉的代价不是当下的 200 行胶水,是三个月后的那次 bug 修复只改了一份。
收益二:把「模型的决策」从故障现场摘出去
这条最被低估。
Agent 任务失败,可能是三种原因:模型选错了工具、宿主传错了参数、工具内部真的坏了。如果这三件事都发生在同一次聊天里,你没法只查其中一件。
有了独立入口,排查顺序变成:
text
先用保存下来的参数,手工跑一遍命令
├─ 命令成功 → 工具没问题,问题在「模型选工具」或「宿主传参数」
└─ 命令失败 → 工具自己的问题,跟模型无关,可以单测复现
一次二分,把排查范围砍掉一半。
注意这里有个前提:你得保存下来那次调用的参数。这就是为什么工具调用日志要把结构化参数落盘,而不是只记一句「调用了报表工具」。
也要提醒一句:独立入口降低的是「复现的门槛」,不是「让结果确定」。工具读实时数据,你还是得固定数据快照;工具用系统时间,你还是得能注入时间。否则你复现的仍然是一个移动的目标。
收益三:自动化不需要模拟聊天
定时任务、批量脚本、CI 检查,这三件事的本质都是「没有人在场,按约定跑一次,看结果决定下一步」。
如果工具只能从聊天里调,这三件事就都得先搭一个假的聊天环境。而一个命令行的调用是这样:
bash
report-tool summary --input orders.csv --top 5 --json
调度平台不需要知道什么是 Agent,它只需要知道:退出码 0 算成功,非 0 按分类告警。这就是自动化的最低耦合。
收益四:确定的步骤写进代码,模型只做选择
「工具执行流程」经常不止一个函数,而是一串有固定顺序的步骤:检查格式 → 读取内容 → 执行转换 → 校验产物。
这四步的顺序是确定的,每次都要走。如果把它们拆成四个独立工具暴露给模型,让模型每轮重新编排,会有两个后果:一是多消耗几轮交互,二是模型可能漏掉「校验产物」这一步------而这恰恰是最重要的一步。
把它们组织成一个业务操作,模型只需要回答一个问题:什么时候该做这件事。 内部的四步由代码保证。
这里要区分清楚:这个收益来自「流程封装」,本身用函数也能做到。CLI 的额外作用,是让这个封装好的流程能被独立启动、被不同语言调用。
而流程的粒度要克制。一个命令应该有清晰的业务目的、明确的输入和结果。如果内部还需要用户做新决定,就不能为了「一步完成」偷偷猜参数------那是把不确定性藏起来,不是消除它。
举个具体的边界:本地生成文件,和把文件发布到外部系统,是两种影响完全不同的操作。前者可以默认执行,后者必须有独立入口和显式确认。 如果把它们合并成一个 convert-and-publish,模型就可能在只想转换的时候,顺手把文件发布出去了。
收益五:跨语言、跨团队
另一种语言不需要把业务代码 import 进自己的进程,只要能启动程序、读标准输出、看退出码,就能用这份工具。这是进程边界天然给的。
但别把「跨语言」听成「跨平台」。目标机器上仍然要有可执行文件、运行时和依赖。通过本地 CLI 调用,也不会自动变成远程服务调用------它只是把「代码依赖」换成了「部署依赖」。
它不会给你的三样东西
这部分比收益更重要,因为面试官往往就追问这里。
第一,权限隔离。
子进程默认继承父进程的 uid、组、capabilities。加一层 CLI,攻击面一点没变。真正的隔离要靠:受限的系统用户、容器或沙箱、seccomp、只读挂载、网络出口白名单。CLI 只是提供了「把程序放进受限环境」的那个接口。
第二,性能。
每次调用要启动运行时、加载依赖、序列化输入输出。一个 30ms 的小计算,裹上 CLI 之后可能要 300ms。高频、轻量的调用,直接函数调用更合适------或者保留常驻进程,CLI 只当控制入口。
第三,语义正确。
工具打印 status: completed 不会让未完成的任务真的完成。这一点后面用一整节讲。
四、它是怎么演进过来的

这段历史值得讲,因为它能解释「为什么现在大家都在提 CLI」,而不是把它当成一种风格偏好。
阶段一:2023 年上半年,插件式工具
早期做法很朴素:在 Prompt 里约定格式,让模型输出一段 JSON,程序用正则抠出来执行。工具是什么、参数是什么,全靠自然语言描述。
问题是显而易见的:模型经常输出不合法的 JSON,多一个逗号就解析失败,而且格式漂移严重。
阶段二:2023 年年中,function calling
结构化工具调用出现了:用 JSON Schema 描述工具的入参,模型输出结构化的调用请求而不是自由文本。这一代解决的是格式可靠性------参数类型、必填项、枚举值,第一次有了机器可校验的契约。
这也是今天绝大多数 Agent 框架的默认形态:注册一堆工具,模型在多选一里挑。
阶段三:工具目录膨胀
工具一多,新问题出现了。模型每次决策前,都要在 N 个工具里做一次分类:这个子任务该用哪个?参数该怎么填?
工具数量上升,选错率跟着上升。而且每个工具的描述、schema 都常驻在上下文里,不管这次用不用得上。Anthropic 的工程博客给过一个很具体的数字:接多个 MCP 服务之后,光是工具定义就可能吃掉十几万 token,模型还没读到用户的第一句话。
阶段四:2024 年底,MCP
MCP(Model Context Protocol)把工具从「进程内的函数」变成了「可被发现的服务」。这解决了复用与生态问题:一个工具写一次,多个客户端都能接。
但它没有解决成本和执行边界。工具定义仍然要进上下文,执行仍然在别人的进程里。
阶段五:2025 年,CLI-first 回潮
这一年出现了明显的一股反向声音。
一位在头部 Agent 产品做了两年后端的工程师公开复盘:他把多工具的 function calling 全部砍掉,只留一个 run(command) 工具,把所有能力暴露成 Unix 风格的命令。
他的理由不是「终端更好看」,而是三条很硬的观察:
- 模型对 shell 是「母语级」熟悉的。 它预训练时见过海量的命令行用法,
git log --oneline -20这种东西不需要 schema 解释。 - 工具选择的认知负担消失了。 只剩一个工具,就不存在「在 50 个工具里选错」的问题。
- 组合是免费的。 管道、重定向、脚本,这些几十年前就解决的问题,不需要为 Agent 重新发明一遍。
同一年,Claude Code 的成绩也印证了这条路:用 bash 加上一个文本编辑工具这么小的工具面,就能在 SWE-bench Verified 上跑到当时的最优水平。
阶段六:2025 年底,代码执行
Anthropic 在 2025 年 11 月发了一篇工程博客,给出了一组被反复引用的数字:把一个「Google Drive 到 Salesforce」的任务,从「逐个调用工具、结果回灌上下文」改成「模型写代码调用工具、只在沙箱里处理数据」,token 消耗从约 15 万降到约 2000,降幅 98.7%。
关键不是那个数字,是它揭示的机制:中间结果不必流经上下文。 处理一万行表格,模型只需要看处理后的五行摘要。
这也间接解释了为什么 CLI 更省:bash 是一个工具,一句描述。 它不像 MCP 那样把几十个 schema 常驻在上下文里,代价是模型需要自己去 --help 里摸索不熟悉的接口。
所以差异是有形状的:对模型已经很熟的动词(git、grep、curl),CLI 更便宜;对模型完全陌生的内部 API,结构化 schema 更便宜,因为省掉了试错。 这条曲线是 U 形的,不是单调的。
阶段七:2026 年,CLI 进 CI
最近这一年的变化更务实:Agent 的定义开始进 YAML、进 Git、进流水线。工具是可执行文件,带语义化版本,能单独发版、单独回滚。
到这,CLI 不再只是「模型的一个工具」,而是整个工程体系里的一个可交付物。
三条主线
把七年压成三句话:
- 接口形态:自由文本 → 结构化 schema → 命令文本。不是回到原点,是发现「文本 + 强契约」比「结构化 + 弱契约」更好维护。
- 上下文成本:从「所有 schema 常驻」到「按需加载」,再到「中间结果不进上下文」。
- 执行边界:从「和 Agent 同进程」到「独立进程」,再到「独立沙箱」。
CLI 封装正好同时踩中了后两条。
五、怎么用:一个能跑的工具,从 0 到 1
下面这部分是全文的重点。我们写一个真实的小工具:读取订单 CSV,按地区汇总金额。选它是因为它够简单,你能一眼看完;又够真实,四层结构一个不少。
先看整体链路,知道每一段代码在哪个位置。

注意这张图里的一个事实:从「模型说要执行」到「磁盘上真的变了」,中间隔了四次交接。 这四次里任何一次含糊,线上就会变成「看起来成功了」。
5.1 目录结构
text
report_tool/
├── __init__.py
├── core.py # 业务核心:纯函数,不认识 argparse,不认识 Agent
├── cli.py # 命令行入口:解析参数、调核心、按契约输出
└── agent_adapter.py # 执行适配器:把工具请求映射成子进程调用
toolspec.json # 给模型看的工具说明
test_contract.py # 契约测试:只测入口,不测业务规则
sample_orders.csv # 示例数据
这个结构本身就是答案。core.py 里不允许出现 argparse,也不允许 import 任何 Agent 框架。 这条规则如果守住了,后面所有收益都是免费的;守不住,加几层壳都没用。
5.2 第一步:抽出业务核心
python
"""core.py ------ 只接收明确参数,返回明确结果。"""
from __future__ import annotations
import csv
from dataclasses import asdict, dataclass
from pathlib import Path
class ToolError(Exception):
"""可预期的业务错误:调用方看得懂原因,重试没有意义。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@dataclass
class RegionTotal:
region: str
orders: int = 0
amount: float = 0.0
def summarize(input_path: str, top_n: int = 5) -> dict:
"""按地区汇总订单金额。
只有两个参数,都是调用方给得出来的东西。
不读环境变量,不读全局配置,不假设「当前会话是谁」。
"""
path = Path(input_path)
if not path.is_file():
raise ToolError("input_not_found", f"找不到输入文件:{input_path}")
if path.suffix.lower() != ".csv":
raise ToolError("unsupported_format", f"只支持 .csv,收到 {path.suffix!r}")
totals: dict[str, RegionTotal] = {}
rows = 0
with path.open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
missing = {"region", "amount"} - set(reader.fieldnames or [])
if missing:
raise ToolError("bad_schema", f"缺少必需列:{sorted(missing)}")
for row in reader:
rows += 1
region = (row.get("region") or "").strip() or "UNKNOWN"
raw = (row.get("amount") or "").strip()
try:
amount = float(raw)
except ValueError:
raise ToolError("bad_row", f"第 {rows} 行 amount 不是数字:{raw!r}") from None
item = totals.setdefault(region, RegionTotal(region))
item.orders += 1
item.amount += amount
ranked = sorted(totals.values(), key=lambda r: r.amount, reverse=True)
return {
"total_rows": rows,
"total_amount": round(sum(r.amount for r in ranked), 2),
"regions": [asdict(r) for r in ranked[:top_n]],
"truncated": len(ranked) > top_n,
}
三个细节值得单独说:
第一,自定义 ToolError 并带 code。 这类错误和「程序崩了」是两回事:前者改输入就能解决,后者要修代码。把它们分开,调用方才知道该重试、该改参数、还是该找人。
第二,encoding="utf-8-sig"。 处理 CSV 时,带 BOM 的文件用 utf-8 读,第一列的列名会变成 \ufeffregion,然后你的「缺少必需列」报错会让人莫名其妙。这个坑踩过一次就记住了。
第三,raise ... from None。 让错误链干净一点:调用方关心的是「第 3 行的 amount 不是数字」,不是 float 构造函数的堆栈。
5.3 第二步:写 CLI 入口,定义契约
python
"""cli.py ------ 只做三件事:解析参数、调用核心、按契约输出。"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from report_tool.core import ToolError, summarize
# 退出码是契约的一部分:写进 --help,也写进测试
EXIT_OK = 0 # 正常结束
EXIT_FAILED = 1 # 工具内部异常(未预期)
EXIT_USAGE = 2 # 参数用错了,看 --help
EXIT_INPUT = 3 # 输入不合法或不存在,改输入再试
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="report-tool",
description="按地区汇总订单 CSV,输出合计与 Top N 地区。只读,不修改输入文件。",
epilog=(
"示例:\n"
" report-tool summary --input orders.csv --json\n"
" report-tool summary --input orders.csv --top 3\n"
"\n退出码:0 成功 / 1 内部错误 / 2 参数错误 / 3 输入非法"
),
)
sub = p.add_subparsers(dest="command", required=True)
s = sub.add_parser("summary", help="生成地区汇总")
s.add_argument("--input", required=True, help="输入 CSV 路径")
s.add_argument("--top", type=int, default=5, help="返回前 N 个地区(默认 5)")
s.add_argument(
"--json",
action="store_true",
help="机器模式:stdout 只输出一个 JSON 文档,日志全部走 stderr",
)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
# 关键一行:没有 TTY 就默认机器模式
machine = args.json or not sys.stdout.isatty()
if args.top < 1 or args.top > 50:
_emit_error(machine, "bad_argument", "--top 必须在 1 到 50 之间")
return EXIT_USAGE
try:
result = summarize(args.input, top_n=args.top)
except ToolError as exc:
_emit_error(machine, exc.code, exc.message)
return EXIT_INPUT
except Exception as exc: # 未预期异常:只给结论,细节留给 --debug
_emit_error(machine, "internal_error", f"{type(exc).__name__}: {exc}")
return EXIT_FAILED
if machine:
# stdout 只有一个 JSON 文档,前后不加任何字符
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
else:
print(f"共 {result['total_rows']} 行,合计 {result['total_amount']:.2f}")
print(f"{'地区':<10}{'单量':>8}{'金额':>14}")
for r in result["regions"]:
print(f"{r['region']:<10}{r['orders']:>8}{r['amount']:>14.2f}")
if result["truncated"]:
print(f"仅显示前 {args.top} 个地区,用 --top 调整", file=sys.stderr)
return EXIT_OK
def _emit_error(machine: bool, code: str, message: str) -> None:
"""错误永远走 stderr:stdout 在失败时必须是空的。"""
if machine:
sys.stderr.write(
json.dumps({"error": {"code": code, "message": message}},
ensure_ascii=False) + "\n"
)
sys.stderr.write(f"report-tool: {message}\n")
sys.stderr.write("用法见 report-tool summary --help\n")
if __name__ == "__main__":
raise SystemExit(main())
这里有四个设计决定,每一个都对应一条踩坑经验。
决定一:错误输出走 stderr,不走 stdout。
调用方可能这样用:out=$(report-tool summary --input a.csv --json)。如果错误也写进 stdout,out 里就是一坨混着人话和 JSON 的东西,jq 直接报错。分开之后,out 在失败时是空字符串------这是一个可以被断言的强契约。
决定二:--json 不只是开关,还要在非 TTY 时自动生效。
这是最便宜、也最少人写的一行:machine = args.json or not sys.stdout.isatty()。
Agent 调用时 stdout 一定是管道,所以永远自动进机器模式。你不需要指望模型「记得加 --json」------这种指望在工程上等于没有。
决定三:raise SystemExit(main()),而不是 sys.exit(0)。
把退出码从 main() 返回,方便单测直接调用 main([...]) 断言返回值,不用起子进程。
决定四:--help 里写退出码。
一个调用方会去分支的约定,必须写在脚本作者会看的地方。抄 gh 的做法就够了:0 成功、1 通用失败、2 用法错误、4 需要认证。「设计一个更好的分类」远不如「抄一张大家已经熟悉的表」。
5.4 第三步:写执行适配器
CLI 能独立跑了,接下来是让模型用上它。这一层单独存在,是为了把「模型的请求格式」和「CLI 的参数格式」隔开。
python
"""agent_adapter.py ------ 把工具请求映射成一次子进程调用。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
PKG_ROOT = Path(__file__).resolve().parent.parent
TOOL_CMD = [sys.executable, "-m", "report_tool.cli"]
DEFAULT_TIMEOUT = 30
class ToolRunError(Exception):
pass
def run_summary(
input_path: str,
top: int = 5,
workdir: str | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> dict:
# 参数一个个进列表,用户内容不进命令字符串
cmd = [*TOOL_CMD, "summary", "--input", input_path, "--top", str(top), "--json"]
env = {
"PATH": os.environ.get("PATH", ""),
"PYTHONPATH": str(PKG_ROOT),
"PYTHONIOENCODING": "utf-8",
"NO_COLOR": "1",
}
try:
proc = subprocess.run(
cmd,
cwd=workdir or str(PKG_ROOT),
env=env, # 白名单重建,不继承宿主密钥
stdin=subprocess.DEVNULL, # 绝不等待输入
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
raise ToolRunError(
f"工具超过 {timeout}s 未返回。注意:超时不等于没执行,"
"需要用幂等键或产物核对真实状态。"
) from None
if proc.returncode != 0:
raise ToolRunError(
f"工具返回 {proc.returncode}({_code_meaning(proc.returncode)}):"
f"{_parse_error(proc.stderr) or proc.stderr.strip()[:400]}"
)
try:
return json.loads(proc.stdout)
except json.JSONDecodeError:
raise ToolRunError(
f"stdout 不是合法 JSON,前 200 字符:{proc.stdout[:200]!r}"
) from None
def _code_meaning(code: int) -> str:
return {
1: "工具内部错误",
2: "参数错误,不要重试,先修参数",
3: "输入非法,改输入再试",
}.get(code, "未知状态")
def _parse_error(stderr: str) -> str:
"""从 stderr 里取最后一行 JSON 的 message。"""
for line in reversed(stderr.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
try:
return json.loads(line)["error"]["message"]
except (json.JSONDecodeError, KeyError):
continue
return ""
if __name__ == "__main__":
print(json.dumps(run_summary("sample_orders.csv", top=3),
ensure_ascii=False, indent=2))
这一层四个动作,都是硬要求:
env白名单重建。 不是os.environ.copy()。宿主进程里可能有OPENAI_API_KEY、AWS_SECRET_ACCESS_KEY,工具不需要这些东西,就不该拿到。stdin=DEVNULL。 工具如果等输入,Agent 场景下会一直挂到超时。宁可让它直接失败。shell=False(默认)。 参数以列表传,用户内容永远只是「一个参数的值」。- 超时提示要说清「超时不等于没执行」。 这句话本身就是给下一轮模型看的:别急着重试,先查状态。
5.5 第四步:给模型的工具说明
json
{
"name": "report_summary",
"description": "按地区汇总订单 CSV,返回合计金额与 Top N 地区。只读操作,不修改输入文件,可安全重试。",
"parameters": {
"type": "object",
"properties": {
"input_path": {
"type": "string",
"description": "工作区内 CSV 的相对路径,例如 sample_orders.csv"
},
"top": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 5,
"description": "返回金额最高的前 N 个地区"
}
},
"required": ["input_path"],
"additionalProperties": false
}
}
两点值得注意:
description里写了「只读、可安全重试」。 这是在告诉模型:失败可以重试。如果工具是写操作,这句话就必须换成「非幂等,重试前先查状态」。工具描述不是说明书,是给调用方的行动依据。additionalProperties: false。 挡住模型自由发挥的额外字段。参数面越小,出错面越小。
5.6 第五步:契约测试
契约测试只测入口这一层:stdout 干不干净、退出码稳不稳、失败时会不会留半成品。业务规则(怎么算金额)由 core 的单元测试负责。
python
"""test_contract.py ------ 只测入口,不测业务规则。"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SAMPLE = ROOT / "sample_orders.csv"
def run(*args: str) -> subprocess.CompletedProcess:
# capture_output=True 意味着 stdout 是管道,不是终端
return subprocess.run(
[sys.executable, "-m", "report_tool.cli", *args],
cwd=ROOT, capture_output=True, text=True, encoding="utf-8",
)
def test_success_stdout_is_pure_json():
p = run("summary", "--input", str(SAMPLE), "--json")
assert p.returncode == 0, p.stderr
doc = json.loads(p.stdout) # 混入一行日志这里就会抛异常
assert doc["total_rows"] == 6
assert doc["regions"][0]["region"] == "西南"
def test_piped_stdout_auto_switches_to_json():
# 没有 --json,但在管道里,依然应该是纯 JSON
p = run("summary", "--input", str(SAMPLE), "--top", "2")
assert p.returncode == 0
doc = json.loads(p.stdout)
assert len(doc["regions"]) == 2
assert doc["truncated"] is True
def test_missing_input_exits_3_and_stdout_empty():
p = run("summary", "--input", "nope.csv", "--json")
assert p.returncode == 3
assert p.stdout == "" # 失败时绝不输出半成品
lines = [ln for ln in p.stderr.strip().splitlines() if ln.startswith("{")]
assert json.loads(lines[-1])["error"]["code"] == "input_not_found"
def test_bad_argument_exits_2():
p = run("summary", "--input", str(SAMPLE), "--top", "0", "--json")
assert p.returncode == 2
assert p.stdout == ""
def test_missing_required_flag_exits_2():
p = run("summary")
assert p.returncode == 2
if __name__ == "__main__":
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print("PASS", name)
注意 test_missing_input_exits_3_and_stdout_empty 里的 assert p.stdout == ""。这一行是整个契约里最值钱的断言:它保证失败的时候,调用方不会拿到半成品数据,然后当成结果接着往下跑。
5.7 跑起来
先看机器模式。这是 Agent 视角看到的输出:
bash
python -m report_tool.cli summary --input sample_orders.csv --json
text
{
"total_rows": 6,
"total_amount": 7870.5,
"regions": [
{ "region": "西南", "orders": 1, "amount": 3000.0 },
{ "region": "华北", "orders": 2, "amount": 2420.0 },
{ "region": "华东", "orders": 2, "amount": 2000.5 },
{ "region": "华南", "orders": 1, "amount": 450.0 }
],
"truncated": false
}
退出码 0。注意这里是纯 JSON,前后没有任何杂音,jq 能直接解析。
再看错误场景:
bash
python -m report_tool.cli summary --input nope.csv --json
echo "exit=$?"
text
{"error": {"code": "input_not_found", "message": "找不到输入文件:nope.csv"}}
report-tool: 找不到输入文件:nope.csv
用法见 report-tool summary --help
exit=3
stdout 是空的,错误全在 stderr,退出码是 3(输入非法)。调用方不需要读那句中文,只看退出码就知道「不要重试,改输入」。
最后跑契约测试和执行适配器:
bash
python test_contract.py
text
PASS test_bad_argument_exits_2
PASS test_missing_input_exits_3_and_stdout_empty
PASS test_missing_required_flag_exits_2
PASS test_piped_stdout_auto_switches_to_json
PASS test_success_stdout_is_pure_json
bash
python -m report_tool.agent_adapter
text
{
"total_rows": 6,
"total_amount": 7870.5,
"regions": [
{ "region": "西南", "orders": 1, "amount": 3000.0 },
{ "region": "华北", "orders": 2, "amount": 2420.0 },
{ "region": "华东", "orders": 2, "amount": 2000.5 }
],
"truncated": true
}
到这里,四个调用方就都通了:模型走适配器,你在终端敲 python -m report_tool.cli,cron 写一行 bash,CI 里跑 test_contract.py。同一份 core.py。
顺着这个骨架换成任何业务都一样:文档转换、数据清洗、报表导出、图片处理。变的只有 core.py,cli.py 和 agent_adapter.py 几乎是模板。
六、给机器用的 CLI:12 条硬规则

这些规则大部分来自 clig.dev 那份开源指南,但那指南默认读者是「坐在终端前的人」。Agent 时代有个关键反转:主调用方变成了一个读输出的程序,而不是看屏幕的人。 所以要把几条重点改一改。
| 规则 | 具体要求 | 违反了会怎样 |
|---|---|---|
| 1 | 业务结果只写 stdout | 管道里混入日志,jq 解析失败 |
| 2 | 日志、进度、错误只写 stderr | 同上,而且错误被当成数据 |
| 3 | 非 TTY 时自动切 JSON | 调用方忘了加 --json,拿到表格 |
| 4 | 退出码 0 表示成功,非 0 分类 | 调用方无法分支,只能读自然语言 |
| 5 | 失败时 stdout 保持为空 | 半成品被当成结果,脏数据下游扩散 |
| 6 | 错误信息带一个稳定的 code |
文案一改,调用方的判断全废 |
| 7 | 非 TTY 时绝不等待输入 | 整轮对话挂到超时 |
| 8 | 破坏性操作要显式 --force |
模型重试一次,破坏翻倍 |
| 9 | 提供 --dry-run |
无法预演,只能真跑 |
| 10 | 默认输出量要窄,支持分页 | 一万行进上下文,token 爆掉 |
| 11 | 接口变更必须向后兼容 | 老脚本静默跑错 |
| 12 | --help 里写清退出码含义 |
没人知道 3 和 4 的区别 |
逐条解释几句最容易被忽视的。
第 3 条:自动切 JSON。
这不是优化,是纠错。任何依赖「调用方记得加参数」的契约,在 Agent 场景下都会失败,因为模型是概率性地生成参数的。
第 6 条:错误码要稳定。
文案会改,code 不该改。invalid --status 这种错误,最好把合法值也一起返回:
json
{"error": "invalid --status", "allowed": ["queued", "running", "failed"]}
这样模型一次就能自我修正。只给一句 invalid status,它只能去读 --help 然后猜。
第 7 条:绝不等待输入。
人在终端看到 [y/N] 会敲个 y。Agent 没有终端,它会一直等。所以:检测到非 TTY 就直接失败并说明「需要 --force」,而不是挂住。
第 8 条:破坏性操作默认关闭。
模型会盲目重试。一个默认删除的命令,会把一次网络抖动放大成反复删除。所以不可逆操作的裸调用应该直接拒绝,并告诉它该加什么参数。
当然,这条要按影响面分级:可逆的、幂等的操作(打标签、开启开关),就别加确认了,加了纯属增加摩擦。
七、三个最容易翻车的地方
7.1 退出码不是业务状态
退出码是「进程怎么结束的」,业务状态是「事情做到哪一步」。这两件事经常不一致。
一个典型的错法是:工具内部捕获了所有异常,然后统一 return 0。这样脚本永远不会失败,也就永远不会告警。
正确的关系是:退出码负责「能不能继续」,业务状态负责「做到哪了」。
python
# 不好
try:
do_work()
return 0
except Exception:
return 0 # 反正没崩,就当成功
# 好
try:
do_work()
return 0
except ToolError as e:
emit_error(e.code, str(e))
return 3 # 输入问题,改输入
except Exception as e:
emit_error("internal_error", str(e))
return 1 # 程序问题,要修代码
7.2 受理不等于完成

这是异步工具最常见的坑。
一个只负责「提交后台任务」的命令,返回 0 的意思是提交成功 ,不是任务完成 。如果业务状态字段写的是 accepted,而调用方把它当成 completed,下游就会去读一个还不存在的产物。
所以「完成」这件事要过三关:
- 进程状态:退出码是几?
- 业务状态 :
status字段说什么?accepted和completed是两件事。 - 实际证据:产物真的在吗?文件可读吗?大小合理吗?异步任务查询接口返回什么?
三关都过,才算完成。少一关,都是「看起来完成了」。
这件事有个具体的写法:在结果里同时给出状态和证据。
json
{
"schema_version": 1,
"status": "completed",
"artifact": {"path": "out/report.csv", "bytes": 24810, "sha256": "9f2c..."},
"warnings": []
}
artifact 里带 bytes 和摘要,是为了让调用方能「验证」而不是「相信」。这也是为什么产物路径必须是调用方能访问到的路径------另一个进程或容器未必共享文件系统,你给一条只有自己看得见的路径,等于没给。
7.3 超时不等于没执行
第三个坑最贵。
timeout 杀掉的只是你启动的那个入口进程。它可能已经:写了一半个文件、发出了一次 HTTP 请求、提交了一个后台任务、起了一个子进程还在跑。
所以超时之后,正确动作不是「重试一次」,而是按操作类型分流:
| 操作类型 | 超时后该怎么办 |
|---|---|
| 只读查询 | 直接重试,最多两三次 |
| 幂等写(按 key 覆盖) | 可以重试,但要带同一个幂等键 |
| 非幂等写(创建订单) | 先查状态,别重试 |
| 有后台任务 | 用任务 ID 查实际状态 |
另外,杀进程要杀「执行单元」,不是一个 PID。工具如果起了子进程,只杀父进程会留下孤儿进程继续跑------你会看到一个「已经超时」的任务,五分钟后把文件写出来了。
八、竞品对比:五种做法的代价

把今天能选的五种做法放一张表里。注意这里的「竞品」不是同一个品类的东西------它们解决的是同一个需求(让 Agent 用上这个能力),但代价结构完全不同。
| 维度 | 进程内函数 | CLI 封装 | MCP Server | HTTP 微服务 | 裸 shell |
|---|---|---|---|---|---|
| 线上复现 | 要整段对话 | 一条命令 | 要起一个服务 | 要起一个服务 | 能跑,但没有边界 |
| 跨语言复用 | 否,必须同进程 | 是,进程边界 | 是,协议边界 | 是,协议边界 | 是 |
| 上下文开销 | 中,schema 常驻 | 低,一条命令 | 高,schema 全量 | 中 | 最低 |
| 权限边界 | 无,同进程 | 进程 + 环境变量 | 进程 + 鉴权 | 服务端鉴权 | 几乎没有 |
| 能进 CI | 否 | 是 | 是,但需常驻 | 是,但需常驻 | 是 |
| 版本可追溯 | 跟主程序走 | 工具可独立发版 | 跟服务端 | 跟服务端 | 跟脚本 |
| 起步成本 | 最低 | 低 | 中 | 高 | 最低 |
| 适合谁 | 只服务当前 Agent 的小函数 | 需要多入口复用的工具 | 跨团队共享的工具生态 | 强鉴权、多租户 | 内部调试,不对外 |
怎么读这张表:
- 「上下文开销」这一行是有前提的。 对模型已经很熟的动词(git、grep、curl、ffmpeg),CLI 最省,因为它不需要 schema,模型本来就会。对模型完全陌生的内部 API,CLI 反而更贵------模型要靠
--help摸索和反复试错。这条曲线是 U 形的。 - MCP 不是 CLI 的替代品,是另一个层次。 CLI 解决「独立执行」,MCP 解决「工具发现与跨客户端共享」。一个工具完全可以既有 CLI,又包一层 MCP Server,两者共用同一份
core。 - 裸 shell 是反面教材,不是选项。 给模型一个能执行任意命令的 shell,等于给它宿主的全部权限。它适合「你完全信任这个模型 + 有强沙箱」的场景,不适合生产。
选型建议,一句话版本:
工具要独立运行、跨语言、进 CI ------ 先给 CLI;要跨团队远程共享、要按用户鉴权 ------ 再加一层服务。
九、常用场景与企业项目怎么落地

9.1 五个高频场景
场景一:数据导出与报表
Agent 负责「理解要什么」,工具负责「稳定地产出」。这是最适合 CLI 化的场景,因为报表任务往往还要被运营手工跑、被定时任务跑。
bash
report-tool summary --input data/orders.csv --top 10 --json
场景二:文档格式转换
Word 转 PDF、Markdown 转 PPT。这类操作天然是「一个输入文件 → 一个输出文件」,参数简单,副作用明确,是 CLI 的舒适区。
bash
doc-tool convert --input notes.md --output out/notes.pdf --json
场景三:代码检查与静态分析
lint、格式检查、依赖审计。这类工具本来就是命令行程序,Agent 只需要知道怎么调、怎么看结果。这也是为什么 Claude Code 用 bash 加一个文本编辑器就能干活------生态里已经有一万个好用的 CLI 了。
bash
ruff check src/ --output-format json
场景四:部署与运维动作
发布、回滚、扩缩容。这类操作必须极其小心,CLI 的价值在于「把危险的动词收进一个明确的入口」,而不是给模型一个万能 shell。
bash
deploy-tool release --service api --version v1.4.2 --dry-run --json
注意那个 --dry-run。危险操作的默认行为应该是「预演」,真跑要显式加 --yes。
场景五:批量任务
一次处理几百个文件。因为有了 CLI,这件事可以交给 xargs 或一个循环,不需要 Agent 在场。
bash
find data/ -name "*.csv" -print0 | xargs -0 -n1 -P4 report-tool summary --json
这条命令顺手解释了「为什么 Unix 组合能力是免费收益」:进程边界 + 文本流,几十年前就解决好了。
9.2 企业里,四类调用方共用一套契约
| 调用方 | 怎么调 | 最需要防什么 |
|---|---|---|
| Agent 运行时 | 执行适配器拉子进程,白名单命令 + 超时 + 输出截断 | 模型传错参数 |
| 开发者与运维 | 手工敲同一条命令复现 | 参数看不懂 |
| 定时与数据任务 | 调度平台按契约调用,看退出码告警 | 失败无法分类 |
| CI / CD | 提交后跑契约测试,产物留档 | 结果无法断言 |
这张表有个隐含的要求:四类调用方必须认同一套退出码和 JSON 结构。 只要有一类不认,工具就会出现第二份实现。而第二份实现一旦出现,修 bug 就开始漏。
9.3 上线清单
把工具从「能跑」做到「敢上生产」,检查这十项:
text
契约层
[ ] stdout 只有业务结果,非 TTY 时是纯 JSON
[ ] stderr 只有日志与错误,错误带稳定 code
[ ] 退出码分类写进 --help,并有测试覆盖
[ ] 失败时 stdout 为空,不留半成品数据
执行层
[ ] 参数以列表传递,shell=False
[ ] 环境变量白名单重建,不继承密钥
[ ] stdin 不等待;非 TTY 时不弹确认
[ ] 超时覆盖整个执行单元,能杀掉子进程
安全与运维
[ ] 危险操作默认 dry-run,真跑要显式确认
[ ] 工具版本可单独发布,契约变更向后兼容
十、面试高频题
下面这十道,覆盖了「为什么封」「怎么封」「什么时候不该封」三个层次。每题给出「怎么答」,而不只是答案------面试考的是结构。
Q1:为什么要把 Agent 的工具执行流程封装成 CLI?直接调函数不行吗?
答法:先分层,再说收益,最后给边界。
- 工具能力、调用入口、执行环境是三件事。直接调函数把三者绑在了一起,任何一件要变,另外两件都得跟着改。
- 封装 CLI 后,同一份能力可以被 Agent、终端、定时任务、CI 四类调用方使用,不需要复制代码。
- 边界:只在当前进程用一次的小函数,直接调更合适。封装的价值取决于「有多少调用方需要独立运行」。
Q2:换个入口就算解耦了吗?什么才算真的解耦?
不算。 判断标准是:这个函数还有没有从运行时里捞东西。
如果它从会话读路径、从全局读配置、要求先初始化模型连接,那换入口只是换了个壳。真解耦的标志是:参数全部显式传入,返回值全部显式返回,业务核心不知道「当前会话是谁」,也不 import 任何 Agent 框架。
一个可验证的检验方式:把 core.py 单独拷到另一个空项目里,它能不能通过单元测试。
Q3:CLI 和 shell 有什么区别?为什么强调不要用 shell?
CLI 是程序,自己解析参数;shell 是解释器,处理管道、重定向、变量展开和通配符。
不要用 shell 的理由是注入:如果把用户内容拼成命令字符串,; && $() 都会被解释执行。把可执行文件和参数列表分开传给进程启动接口,用户内容就永远只是「一个参数的值」。
注意这只解决了注入,不解决参数校验。路径是否允许访问、参数会不会触发额外动作,仍然需要工具和执行层检查。
Q4:CLI 封装能解决权限隔离吗?
不能。
fork / exec 之后,子进程沿用父进程的 uid、组和 capabilities,没有任何一道检查会问「你这段代码是干什么的」。
CLI 能做到的是「让隔离变得可行」:给它单独的工作目录、单独的环境变量、单独的超时、能把它塞进容器。但真正的边界要靠受限身份、命名空间、seccomp、网络白名单。
这是个高频陷阱:说「加了 CLI 就安全了」,基本会被继续追问。
Q5:工具输出里有进度和日志,调用方怎么判断真实结果?
三层判断:
- 进程状态:退出码。0 只说明按约定结束。
- 业务状态 :
status字段。accepted(已受理)和completed(已完成)必须区分。 - 实际证据:产物是否存在、可读、大小合理、摘要匹配;异步任务查状态接口。
只靠第一层,就会出现「CI 显示 success,产物目录是空的」。
Q6:超时了应该重试吗?
看操作类型,不能一刀切。
timeout 杀掉的是入口进程,不代表远端动作没发生。只读可以重试;幂等写要带同一个幂等键;非幂等写必须先查状态;有后台任务的要按任务 ID 查。
另外,杀要杀整个执行单元(进程组 / cgroup),否则子进程会变成孤儿继续跑。
Q7:CLI 和 MCP 怎么选?
不是二选一,它们是两层。
- CLI 解决「独立执行、跨进程复用、能进 CI」;
- MCP 解决「工具发现、跨客户端共享、统一鉴权」。
一个工具可以既有 CLI(给人和 CI 用),又包一层 MCP Server(给多个 Agent 客户端用),两者共用同一个 core。
上下文成本上有个 U 形曲线:模型熟悉的动词,CLI 更省(不需要 schema);模型陌生的内部 API,结构化 schema 更省(不需要试错)。规模大到几十个服务时,还会引入第三种做法------把工具映射成代码 API,让模型写代码调用,中间结果不进上下文。
Q8:为什么说「确定的步骤要写进代码」?会不会限制模型的灵活性?
不会,这是分工。
顺序固定的内部步骤(校验 → 转换 → 核对产物)由代码保证,模型负责「什么时候用这个操作」。如果不封装,模型每轮都要重新编排,一是多耗交互,二是可能漏掉关键步骤------比如漏掉「核对产物」,而这恰恰是最重要的那一步。
但粒度要克制:一个命令应该有一个清晰的业务目的。如果内部还需要用户做新决定,就不能为了「一步完成」偷偷猜参数或扩大权限。本地生成文件和对外发布,必须是两个入口。
Q9:工具版本怎么管?
三件事:
- 工具独立发版,带语义化版本。 不和主程序绑死。
- 契约向后兼容。 加字段可以,改字段含义不行;破坏性变更走大版本。
- 结果里带
schema_version。 调用方能识别结构版本,做兼容分支。
这条的意义是:Agent 的行为可以随时变,但工具的契约不能悄悄变。否则你会遇到「模型没变、代码没变,但结果变了」的鬼故事。
Q10:什么情况下不该封装成 CLI?
四个反问:
- 只有一个调用方,而且永远同进程 → 直接调函数。
- 单次处理极快(几十毫秒)、调用极频繁 → 进程启动开销会超过处理本身,用常驻服务。
- 需要长期连接或流式返回 → 常驻进程 + CLI 只做控制入口。
- 只在内部用、不需要跨语言、不需要进 CI → 薄封装就够了。
反过来的顺序是错的:先上架构,再找场景,最后发现这个小函数根本不需要这层壳。
十一、什么时候封,什么时候不封

把上面的判断压成四个问题,按顺序问:
- 除了 Agent,还有别人要用吗? 没有,而且永远同进程 → 直接调函数,收工。
- 单次处理是不是极快、频率极高? 是 → 常驻服务 + 控制入口,别每次拉起进程。
- 需要跨语言、跨团队、或进 CI 吗? 不需要 → 进程内函数 + 薄封装就够了。
- 要按用户鉴权、审计、多租户吗? 要 → 在 CLI 之外再加一层远程服务,CLI 仍然是调试和自动化的入口。
四个都过了,才轮到「怎么封」。顺序反过来,就会变成为了架构而架构。
十二、收尾
回到开头那个问题:「函数套个 CLI,就算解耦了?」
不算。加 argparse 只是加了个门,门后面的房间有没有打通,跟门没关系。
真正的解耦是三步:
- 把能力从运行时里剥出来 ------ 参数显式传入,结果显式返回,不读会话,不 import 框架。
- 给能力装一个稳定的入口 ------ 一套退出码、一套 JSON、一条 stderr 规则,机器和人都能读。
- 把入口放进可控的环境 ------ 白名单环境变量、独立工作目录、真超时、危险操作默认预演。
做完这三步,你会发现收益远不止「Agent 能用了」:那份代码突然可以被单测覆盖、可以被 CI 断言、可以被运维手工复现、可以被另一个语言调用。Agent 只是又多了一个调用方而已。
而权限隔离、性能、语义正确这三件事,一步都不会自动到来------它们要靠沙箱、靠架构、靠产物校验,靠不了「加个入口」。
如果只能记住一句话:
把「请求」和「事实」分开,是这一整套设计的地基。模型输出的是请求,退出码描述的是进程,只有产物才是事实。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。