给 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, and sentry-cli are 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、报表视图,账是同一笔,只是换了个地方还。

判据只有一条:这条命令会不会被调用第二次。

回到那份空报表。它没坏,它只是还在用只有人能听懂的方式说话。


参考资料

  1. Anthropic, Manage costs effectively(CLI 工具与 MCP 的上下文开销对比)
  2. Anthropic, Maximizing the value of your Claude Code sessions(命令输出进入会话的说明)
  3. GitHub CLI, gh issue list 手册(--json、--jq 与退出码定义)
  4. CybeDefend, Cursor YOLO Mode and Auto-Run in 2026(终端删除与 checkpoint 的边界)

作者:唐悦玮 | 公众号同名
从后端出发,用 AI 拓展到全栈的工程师。

posted @ 2026-10-23 09:28  唐悦玮  阅读(4)  评论(0)    收藏  举报