给 Agent 写命令,别只给人写
摘要:把脚本交给 Agent 跑,崩的往往不是代码,是接口。这篇文章拆开一条命令的六个契约——非交互、输出分流、退出码、机器可读、错误可自修、危险操作可预演,也讲清改造成本与不该改的场合。

周一早上,你把跑了几年的日报脚本丢给 Agent,让它重新生成上周那份。
它先不带参数跑了一遍,报错。接着开始猜:./report.sh --date 上周。脚本不认识 --date,但这个位置本来就是个透传参数,于是"上周"两个字被当成日期塞进了查询条件。数据库没报错,只是查不到东西。
最后屏幕上是这么一行:生成完成,共 0 条。Agent 在总结里写"日报已生成"。
你打开文件,空的。
脚本错了吗?你自己天天跑,从来没出过事。人看见"0 条"会停下来查,Agent 不会——它看的是退出码和输出内容,两个都在告诉它"成功了"。
变的不是脚本,是读脚本的人。你那条命令现在有两个读者,一个坐在屏幕前,一个在上下文里。
我第一反应是包一层,然后被官方文档按住
给机器用的接口,直觉答案是把命令包成 MCP server,或者干脆写个 HTTP 接口,入参出参都定死,多干净。
这个想法不算错,但它忽略了一件事:接口本身也要占地方。
Anthropic 的成本文档里写得很直接(来源):
Prefer CLI tools when available: Tools like
gh,aws,gcloud, andsentry-cliare still more context-efficient than MCP servers because they don't add any per-tool listing.
MCP 的每个工具都要在上下文里挂一份 schema。你有十个工具,就是十份清单,每一轮请求都跟着走。命令行不用:没调用的时候它不存在,调用了才留下那一段输出。
那 CLI 就白嫖了?也没有。Anthropic 那篇讲会话成本的博客里有一句更扎心的话(来源):
Add quiet flags to noisy commands, or run them in a subagent. Command output is added to the conversation just like a file, and stays there for the rest of the session.
命令的输出会像文件一样进上下文,而且一直待着,直到这个会话结束。你那条命令吵一次,后面每一轮都在替它付账。
那到底该改哪儿?答案不在"包不包",在"给谁看"。同一条命令,人看的是报表,Agent 要的是数据。这两样东西挤在同一个 stdout 里,出事是迟早的。
输出是给人读的,还是给上下文读的
官方文档里有个对比很说明问题。与其让 Agent 去读一万行日志自己找错误,不如让 hook 先 grep 出 ERROR 那几行——上下文从数万 token 掉到数百。
你的脚本是同一个道理。
一张带边框、带颜色、带"温馨提示:数据仅供参考"的表格,人扫一眼就懂,Agent 得从头读到尾,还得猜哪一列是主键。粗算一下:四十行表格、每行三十来个字符,一千二百 token 起步;同一个结果只给六个字段的 JSON,两百 token 以内。
这个数字是估算,不是实测,你就当量级看。真正要记住的是它的复利:这段输出不是付一次,是每一轮都付。
也别把 JSON 当万能。字段名本身也花 token,嵌套三层之后,一份 JSON 可能比那张表格还长。省下来的不是"格式"这两个字,是"只给需要的字段"。
六个开关,签的是同一份契约
把一条命令改造成两个读者都能用,落到手上是六件事。它们不是六条最佳实践,是一份契约的六个面。
| 契约 | 人怎么用 | Agent 怎么用 | 不做会怎样 |
|---|---|---|---|
| 非交互 | 缺参数时弹提示问一句 | 没有 stdin,只会挂住或乱猜 | 任务卡死,或者参数被猜错 |
| 输出分流 | 表格、进度、彩色日志都在屏幕上 | stdout 只认数据,日志进 stderr | 把"正在查询…"当成结果解析 |
| 退出码 | 基本不看 | 靠它决定继续、重试还是停 | 失败了也当成功 |
| 机器可读 | --json 时能看,平时不用 |
默认就要结构化,还要能选字段 | 靠正则抠表格,一改版就崩 |
| 错误可自修 | 看中文提示,自己判断 | 需要"错在哪、怎么改"的明确指引 | 同一个错反复试 |
| 危险操作可预演 | 手抖之前还有一次机会 | 需要 --dry-run 而不是 --yes |
一条命令删完,没有 undo |
六条里最容易搞反顺序的是前两条。分流该先讲——因为它决定后面所有输出长什么样。
判据很短:把 stdout 直接扔进 jq,能通就是数据,不能通就是日志。进度条、彩色提示、"正在导出…"这些东西一旦混进 stdout,调用方只能靠正则去猜哪一行才是结果——你改一次文案,它就得改一次正则。

剩下最容易被漏掉的是退出码。为什么偏偏是它?因为人对退出码几乎无感,而 Agent 只看它。
我在本机试了个很常见的写法:
if [ ! -f "$CONF" ]; then
echo "错误:找不到配置文件 $CONF,请先运行 init"
exit 0
fi
跑一下,输出是那句中文错误提示,echo $? 是 0。写脚本的人想着"我都提示得这么清楚了",但调用方拿到的信号是"一切正常"。
反过来看 gh 的做法。它的 man page 里有一张自己的退出码表:0 成功、1 出错、2 命令被取消、4 需要认证。
留意那个 2。在 shell 的惯例里,2 通常是"用法错误",127 是命令找不到,126 是找到了但不能执行。gh 把 2 定义成"命令被取消",等于告诉你一件事:退出码不是你随手的返回值,是给别人分支用的接口,定下来就得一直守。
再看 --json。字段该由谁定?gh issue list --json number,title,labels 这种写法给了答案:调用方点名,你不点就不给;再配一个 --jq,让调用方自己筛。这比"一股脑输出全部 JSON"省得多,因为决定输出什么的是调用方,不是脚本作者。
还有一处细节容易滑过去:gh issue list 的 --limit 默认是 30。不写参数,它就只给你三十条。这不是小气,是给输出设了个上限——一次调用把上下文灌满,后面几十轮都得背着它。
所以"机器可读"不只是换成 JSON,还得有界。宁可让调用方多调一次带 --limit 200,也别默认全量倾倒。
一个 Java 后端会怎么改
Java 里判断"是不是人在看",用的是 System.console()。它跟 shell 里的 [ -t 1 ] 是一回事:终端里为真,管道和重定向里为假。
boolean human = System.console() != null
&& !"false".equals(System.getenv("REPORT_HUMAN"));
if (!hasDate(args)) {
System.err.println("missing --date, e.g. report --date 2026-10-19");
System.exit(2);
}
if (human) printTable(rows); else printJson(rows);
留那个环境变量是有原因的。System.console() 在某些 IDE 的运行窗口里照样不为 null,你本地跑得好好的,一到 CI 就变了样。这种坑不值得争论谁对,留个开关最省事。
至于错误提示,别写"参数错误"四个字就完事。Agent 需要的是一句能直接拿去改命令的话:缺什么参数、正确格式长什么样、下一步跑哪条。--date 不接受"上周"这种自然语言,就把可接受的格式写进 stderr——它下一轮就不用再猜了。
别把 --yes 当便利
非交互这条,最容易做歪。
Agent 没有 stdin,问它一句它就卡住,所以"缺参数直接报错退出"是对的。但很多人顺手把 --yes 也加上了,觉得这样才叫顺畅。
没有人盯着的时候,多给点权限是不是更省事?短期看是,长期看是在攒事故。
--yes 一开,删库和生成报表走的是同一条路。十月初 Cursor 那批事故里,有几起就是终端删除造成的——据一家安全厂商整理的论坛记录,三月到十月至少有六位 Windows 用户丢了盘,rmdir /s /q 的引号一破,路径塌到盘根。更麻烦的是,从终端发起的删除不进 checkpoint,没有撤销。
所以非交互不等于无条件放行。我的做法是分两档:幂等操作给 --yes,破坏性操作只给 --dry-run,先出一份"将要删除什么"的清单,人点头了再执行。多一步,但这一步是所有自动化里最便宜的一步。

不是每条脚本都值得改
说到这儿得泼一盆水。这六个契约全做齐,成本不低。
是不是每条脚本都得照这个改一遍?不是。改造的钱花在兼容性上,只有会被反复调用的命令才还得起。
你要维护两套输出,测试也得写两份;错误码一旦发出去就不能改,改一次就得升大版本;--json 的字段集合同样是兼容性包袱。一个只有你自己跑、输出从来不超过五行的清理脚本,为它做这些属于过度设计。
判据其实只有一条:这条命令会不会被别的东西调用第二次。
会——哪怕是半年后你写个定时任务来调,那就值得改。不会,就让它保持原样,人看得懂比机器看得懂重要。
换个接口,还是同一件事
这套契约不只属于命令行。你给 Agent 暴露的任何东西都在走同一条路,只是换了张脸。
HTTP 接口的版本最直白:状态码就是退出码。200 里套一个 code: 500 的业务错误,等于把 exit 0 那套又演了一遍,调用方还是只能去猜 body 里的字段。
换到 SDK,异常信息就是那句错误提示。抛一个 NullPointerException 给调用方,等于说"你自己猜哪儿空了";补一句"哪个字段没初始化、该在哪一步赋值",才是那份契约里的"错误可自修"。
报表视图更隐蔽,问题出在列名。col1、col2 这种宽表,人对着表头能推,Agent 只能瞎猜;把列名写成人能念出来的业务词,同一份数据就同时供了两种读者。
所以判据不用重记:谁读它,它就得说谁的语言。人读的那层可以留着,只是别让它挡在机器前面。
回顾总结
你的命令现在有两个读者,这是所有改动的起点。
CLI 比 MCP 省上下文,是因为它不挂常驻清单;但命令的输出会一直留在会话里,吵一次就付一路。
六个契约里最容易被忽略的是退出码——报错却 exit 0,等于告诉 Agent 一切正常。
--json 让调用方点名字段,比全量输出更省;再给它一个默认上限,别一次灌满。
非交互要配 --dry-run,不能只配 --yes,否则顺畅的代价是不可撤销。
换到 HTTP 接口、SDK、报表视图,账是同一笔,只是换了个地方还。
判据只有一条:这条命令会不会被调用第二次。
回到那份空报表。它没坏,它只是还在用只有人能听懂的方式说话。
参考资料
- Anthropic, Manage costs effectively(CLI 工具与 MCP 的上下文开销对比)
- Anthropic, Maximizing the value of your Claude Code sessions(命令输出进入会话的说明)
- GitHub CLI, gh issue list 手册(
--json、--jq与退出码定义) - CybeDefend, Cursor YOLO Mode and Auto-Run in 2026(终端删除与 checkpoint 的边界)
作者:唐悦玮 | 公众号同名
从后端出发,用 AI 拓展到全栈的工程师。

浙公网安备 33010602011771号