Skip to main content
  1. Docs/

给 Agent 设计 CLI 的 N 条原则

·5841 words·12 mins· loading · ·
Agent CLI 学习
Hyoung Yan
Author
Hyoung Yan
秋风即使带凉,亦漂亮.
Table of Contents

0 前言
#

最近做了一个 Agent 项目,我越来越觉得:命令行形式天然适合 Agent 交互与处理,恰如把一个二维的 GUI 应用压扁成一维的命令行。

这当然也是最近很热门的方向。像 HKU 黄超老师做的 CLI-Anything,就是一个很好的例子。仔细想来,很多爆发性的热点,往往底层想法都很简单。

钉钉、飞书,甚至网易云都发布了 CLI,这个世界的任何行业都想和 AI 绑上关系 😂 古老的命令行宣布着:“GUI 将死,CLI 才是未来。”

1 钉钉和飞书的 CLI
#

1.1 钉钉的 CLI
#

钉钉做的事情是重写底层代码,把整个产品 CLI 化。它并不是在现有 GUI 上包一层壳,而是让 Agent 直接调用底层能力,绕过图形界面。

钉钉的命令结构是标准的“服务 / 资源 / 动作”三级,比如 dws calendar event create,并且在面向 Agent 的场景里做了一些设计细节,比如 --yes--mock--dry-run 等参数。

在安全方面,钉钉做了三件事:无感认证(Agent 自动继承企业权限)、批量熔断(防止 Agent 失控批量操作)、安全沙箱(限制 Agent 的权限范围)。

1.2 飞书的 CLI
#

飞书的命令体系是三层设计:

飞书 CLI 的三层命令体系

  • 第一层:Shortcuts(+ 前缀命令),面向人类

    这是飞书最有辨识度的设计。所有快捷命令都带 + 前缀,并且内置智能默认值:

    lark-cli calendar +agenda          # 看今天日程
    lark-cli im +messages-send --text "hello" --chat-id oc_xxx
    lark-cli contact +search-user --query "John"
    lark-cli docs +fetch --doc-id xxx
    

    这些命令做了很多参数简化。比如 +messages-send 支持 --text--markdown--image--file 这类直接参数,不需要自己拼 JSON content body

  • 第二层:API Commands,面向人类和 Agent

    有 100 多条命令,和飞书平台 API 一一对应。

  • 第三层:Raw API,面向 Agent

    这一层直接调用飞书底层 2500 多个 OpenAPI 端点,相当于一个“万能逃生舱”。不管飞书有什么 API,即使 CLI 没有封装对应的命令,Agent 也能直接调,从而避免在边缘场景中被卡住。

    同时,飞书还内置了一个 schema 命令,可以查看任意 API 方法的参数、类型和所需权限。

用一个比喻来说,钉钉 CLI 像是给企业行政部门配的数字助理,而飞书 CLI 更像是给研发团队配的效率工具。

2 为什么是 CLI?
#

ScaleKit 做过一组 benchmark,对 GitHub 官方 MCP 服务器和 gh CLI 做了一组对照实验,测试模型是 Claude Sonnet 4。

  • 查一个仓库用什么语言,CLI 消耗 1,365 tokens,MCP 消耗 44,026 tokens。
  • 查 PR 详情和审核状态,CLI 消耗 1,648 tokens,MCP 消耗 32,279 tokens,接近 20 倍。
  • 查仓库元数据和安装方式,CLI 消耗 9,386 tokens,MCP 消耗 82,835 tokens,接近 9 倍。

但问题是:CLI 调 API 如果认真做安全,你最终还是得补上 OAuth 授权、动态客户端注册、敏感操作审批、服务端鉴权……加完这些,你其实又重新发明了 MCP。

Agent 时代的第四次迁移

这篇公众号还描述了一个“第四次迁移”的框架:大型机 → PC → 移动 → Agent。每一次迁移,本质上都是因为出现了新的用户。普通人不会打命令,所以有了 GUI;手机用户不方便用鼠标,所以有了触屏。

现在又来了一种新用户:AI Agent。

CLI 是 LLM 的母语,MCP 是后天学的外语。

所以,GUI Agent 是否已经是一个伪命题?

3 给 Agent 设计 CLI 的 N 条原则
#

不同于传统的 CLI 设计规范(面向人类用户),面向 Agent 的 CLI 设计需要考虑它完全不同的认知特点和失败模式。

Agent 是怎么想的?
#

Agent 天生就对命令行有相当强的直觉。 LLM 的训练数据里有大量 shell 命令和 bash 脚本,它们分布在 GitHub 代码、Stack Overflow 问答、Linux 文档、在线教程等各种来源中。

但这种直觉也是有边界的。 现在的模型在使用 CLI 时,通常还是跑 --help、解析输出、拼接命令、看退出码。模型能力的提升当然会让每一步判断更准确,但如果 CLI 本身设计得不好,再强的模型也会一样掉进坑里。

好的 Agent 会区分“观察到的事实”和“猜测”,先验证假设,再采取行动;差的 Agent 会把猜测当事实,然后一路错下去。

Agent 怕什么?
#

Agent 在使用 CLI 时有几个天然弱点:

  • 大小写敏感的短参数。像 grepssh 这类工具的短参数,Agent 很容易搞混,导致命令执行失败,因为它本质上是在做概率选择。
  • 交互式提示。Agent 没法回答 Are you sure? [y/N] 这种问题。
  • 幻觉参数。Agent 会很自信地使用并不存在的参数。
  • 非结构化输出。给 Agent 一段 JSON,它能立刻提取字段、做条件判断、做管道传递;给它一段人类可读的 table 输出,它就只能靠猜。

4 十条设计原则
#

原则一:名词在前
#

命令结构应该采用 noun-verb(名词-动词),而不是 verb-noun(动词-名词)。

# 好:noun-verb
docker container ls
gh pr create
lark-cli calendar +agenda
dws contactuser search

# 不好:verb-noun
create-pr
delete-image
search-user

为什么?因为 Agent 发现命令的过程,本质上是一个树搜索。

noun-verb 命令树示意

它会先跑 mytool --help,看到有 userprojectbilling 三个名词;然后再跑 mytool user --help,看到 createdeletelistsearch

这是一个确定性的、逐层缩小范围的过程。Agent 不需要猜,每一步都有 --help 可以查。

而 verb-noun 结构(create-usercreate-projectcreate-billing)把所有操作平铺在一层,Agent 面对的是一个巨大的扁平列表,没有层级引导。

Docker 的设计就是典范:containerimagevolumenetwork 是名词,lsrmcreateinspect 是动词。同一个动词在不同名词下语义保持一致。Agent 只要学会了 docker container ls,就能推断出 docker volume ls

Linus 说过:“好的代码不是简洁,而是重新概念化问题本身,让特殊情况消失在一般情况中。”

noun-verb 结构做的正是这件事:让每个新命令都只是已有模式的自然延伸,不再有特殊情况。

原则二:长参数优先
#

所有参数都应该有长格式(如 --verbose),短格式(如 -v)只能作为可选的人类便利。

这条规则对人类来说是建议,对 Agent 来说几乎应该算是硬性要求。

第一,长参数具备语义自描述能力。--dry-run 这个词本身就在告诉 Agent 它的功能:预演、试运行。而 -n 在部分工具里虽然等同于 --dry-run,但本身完全没有自描述能力。

第二,长参数可以消除歧义。-v 在很多工具里表示 verbose,但 -V 往往表示 version。一个字母的大小写差异,就可能改变全部含义;--verbose--version 之间则几乎不会混淆。

第三,长参数更符合 LLM 的统计优势。模型在训练数据里见过无数次 --output,它和“指定输出位置”之间的语义绑定已经被反复强化;而 -o 的语义绑定要弱得多,在不同工具中甚至可能完全不同。

钉钉 CLI 有个设计值得注意:它的 --yes 参数描述是“跳过确认提示(AI Agent 模式)”。参数名本身就是自描述的,Agent 一看就知道什么时候该加它。

多花几个 token 去写长参数,换来的是明显更低的出错概率。对 Agent 来说,一次错误执行带来的修复成本,远高于多花几个 token 的开销。

原则三:结构化输出
#

# stdout 输出 JSON 数据
mytool user list --format json

# stderr 输出人类可读的状态信息
# 二者严格分流

stdoutstderr 必须严格分离。JSON 数据走 stdout,进度条、警告、日志全走 stderr。这样 Agent 才能安全地用管道处理,而不用担心非 JSON 内容污染数据流。

GitHub CLI 在这方面做得很出色:检测到输出被管道传输时,会自动切换为 tab 分隔格式,去掉颜色转义符,并避免文本截断。飞书 CLI 也支持 JSON、NDJSON、table、CSV、pretty 五种格式,覆盖面很全。

但这里有一个很容易被忽略的点:结构化输出一旦发布,本质上就是 API。Kubernetes 在 v1.14 弃用 --export,又在 v1.18 正式移除,结果数千个 Helm chart 和 CI/CD 管道直接受影响,因为下游已经依赖了这个输出格式。

加一个新的可选字段,通常是安全的;改变已有字段的类型或名称,就是破坏性变更。CLI 输出的 schema,应该像 REST API 版本一样被严肃对待。

原则四:感知环境
#

CLI 应该检测自己运行在终端(TTY)还是管道(pipe)中,并自动调整行为。

# 终端中:彩色输出、表格、进度条
mytool status

# 管道中:纯文本、JSON、无颜色、无交互
mytool status | jq '.'

Agent 几乎永远是在非 TTY 环境中调用 CLI 的。如果你的 CLI 在非 TTY 时还弹确认框、显示 spinner、输出 ANSI 颜色码,Agent 就会卡住,或者解析出错。

GitHub CLI 的做法值得参考:非 TTY 时自动使用 tab 分隔、去颜色、不截断。钉钉 CLI 的 --yes 参数也是为此设计的,用来跳过所有确认提示,进入 AI Agent 模式。

gcloud 文档里有一句值得所有 CLI 开发者记住的话:“不要依赖 gcloud 的原始输出格式,永远使用 --format 标志。” 因为原始输出格式可能随着版本变化。

更进一步的做法是:CLI 在非 TTY 环境下默认输出 JSON,而不应该要求 Agent 额外再加 --json。飞书 CLI 的格式解析逻辑就接近这个思路:先看 --json 标志,再看 TTY 状态;非 TTY 下自动降级为 JSON。

原则五:干跑优先
#

每个会产生副作用的命令,都应该支持 --dry-run

从 Agent 的角度看,--dry-run 提供了一个低成本的试错机制。

Agent 不确定一条命令会产生什么后果时,可以先用 --dry-run 看看;看到预览结果后,再决定是否真正执行。这本质上是给了 Agent 一个“探索 - 验证”的反馈循环,而不是一上来就赌博。

钉钉和飞书 CLI 都支持 --dry-run。飞书的实现更细致一些:干跑时会输出完整的请求 URL、方法和参数,Agent 可以在执行前确认请求是否正确。

Lightning Labs 的设计更进一步:--dry-run 使用专门的退出码(exit code 10),这样 Agent 可以通过退出码区分“干跑成功”和“真正执行成功”。

好的 --dry-run 输出最好是结构化的 JSON diff,明确告诉 Agent 会创建、修改还是删除什么。只输出一句“这是干跑模式”,远远不够。

原则六:退出码控制
#

退出码对人类来说往往是可忽略的细节,但对 Agent 来说,退出码本身就是控制流。

Agent 执行完一条命令后,它看到的第一个信号通常不是输出内容,而是退出码。退出码会直接决定它的下一步:成功了就继续走管道,失败了就进入错误处理。

只用 01 远远不够。Agent 需要更细的粒度来区分失败类型:

退出码含义Agent 行为
0成功继续执行管道
1一般错误读 stderr 诊断
2参数失败修正参数重试
3资源不存在跳过或创建
4权限不足提示用户授权
5冲突/已存在跳过或更新

关键要求是:退出码必须跨版本保持稳定。退出码一旦发布,就是契约的一部分;改变退出码的含义,和改变 API 返回值一样危险。

原则七:防住幻觉
#

前沿模型的幻觉率确实下降了很多,但这不意味着你的 CLI 可以不设防。

原因很简单:输入验证本来就是基本的安全实践,不管调用方是人还是 Agent。你不会因为“大部分用户都是好人”就取消 SQL 注入防护,同样也不该因为“新模型不太幻觉了”就放松输入校验。

输入验证必须严格。Lightning Labs 的做法很值得学习:验证 URL(拒绝 javascript:file: 协议和嵌入凭据的 URL)、验证域名(拒绝路径分隔符和 shell 元字符)、验证输出路径(拒绝向 .ssh/.gnupg/ 等敏感目录写入)。

能用枚举约束的参数,就不要用自由文本。--format json|table|csv 明显比 --format <string> 安全,因为 Agent 在受限选项空间里犯错的概率会小很多。Anthropic 的 tool use 文档也推荐类似策略。

同时,CLI 最好提供 schema 自省能力,让 Agent 能查询工具自身的能力:

mytool schema --all         # 输出完整命令树的 JSON
mytool schema user create   # 输出某个命令的参数定义

飞书 CLI 已经实现了这个能力:lark-cli schema calendar.events.list --format pretty 可以输出任意 API 方法的参数、类型和所需权限。对 Agent 来说,这相当于一本随时可查的字典,比在训练数据里碰运气靠谱得多。

这里还有个反直觉的点:schema 自省应该按需查询,而不是一股脑把所有 schema 都塞进上下文。

Agent 对常用命令通常已经有很强的统计记忆,过量文档反而会干扰判断。这也是 CLI 相比 MCP 的核心优势之一:MCP 往往会把所有工具 schema 一次性注入上下文,而 CLI 则允许 Agent 按需跑 --help,只读取眼前这一条命令的说明。

原则八:幂等设计
#

能用声明式,就尽量别用命令式。

# 命令式:资源已存在会报错
mytool user create --name "john"

# 声明式:无论调用多少次,结果一致
mytool user ensure --name "john"

# 或
mytool user create --name "john" --if-not-exists

Agent 会重试。网络超时了会重试,上一次执行结果不确定了会重试,任务中断恢复后也可能继续重试。如果命令不是幂等的,那么重试就可能导致两个重复用户、两封重复邮件,甚至更糟。

kubectl apply 就是声明式设计的教科书案例:定义期望状态,Kubernetes 负责协调实际状态。不管 Agent 跑多少次 kubectl apply -f deployment.yaml,结果都一致。

飞书 CLI 的 +messages-send 支持 --idempotency-key 参数,也属于这个思路:Agent 传入一个唯一标识符,即使命令被重复执行,服务端也只处理一次。这个设计值得在更多命令里推广。

原则九:错误即指南
#

Agent 犯错之后,错误信息往往是它唯一的修复依据。

{
  "error": "permission_denied",
  "message": "缺少 calendar:read 权限",
  "suggestion": "运行 lark-cli auth login --domain calendar 完成授权",
  "retryable": false
}

好的错误信息应该至少包含四个要素:错误类型(机器可读,便于 Agent 判断是重试还是放弃)、具体描述(到底发生了什么)、修复建议(下一步该怎么做)、是否可重试(网络超时值得重试,权限不足通常不值得)。

飞书 CLI 在权限不足时会自动告诉你缺什么权限、该如何补齐,这对 Agent 非常友好。

还记得前面提到的那个 693 行幻觉案例吗?模型连续 22 轮坚持自己的方案是对的,却没有根据错误反馈调整方向。错误信息如果足够强,就能迫使 Agent 重新审视自己的判断。

原则十:帮助即大脑
#

最后一条,也是最重要的一条。

Anthropic 的 tool use 文档里反复强调一个发现:“描述是影响工具使用准确率的最关键因素。” 他们仅仅通过优化工具描述,就在 SWE-bench 上显著降低了错误率、提高了完成率。

映射到 CLI,--help 的质量几乎直接决定了 Agent 的表现。

好的 --help 应该做到:

  • 以示例开头。 clig.dev 的建议是:用户(包括 Agent)看到帮助文本时,第一眼找的是示例。最常用的 2 到 3 个示例,应该放在最前面。
  • 明确标注必需和可选。 比如 --chat-id <required>,或 --format <optional, default: json>。Agent 需要知道哪些参数必须传。
  • 参数描述包含值域。 不要只写 --format string,而要写 --format json|table|csv
  • 保持简短。 过长的帮助文本反而会降低 Agent 的准确率。对常用命令来说,50 行以内通常更合适。

Checklist
#

最后,我再给出一份可以直接拿去用的 checklist,供你在用 Claude Code / Codex 开发 CLI 时参考:

  • 命令结构采用 noun-verb 层级,支持树搜索
  • 所有参数都有长格式,短格式只作为可选便利
  • --json 输出结构化数据到 stdout,状态信息走 stderr
  • 检测 TTY 状态,非 TTY 环境自动调整输出
  • 所有副作用操作都支持 --dry-run,并输出结构化 diff
  • 退出码具备文档化的细粒度语义
  • 参数尽量用枚举约束值域,并进行严格输入验证
  • 支持 schema 自省命令
  • 关键操作设计为幂等,或提供 --if-not-exists
  • 错误信息包含类型、描述、修复建议和可重试标志
  • --help 以示例开头,标注必需 / 可选,并尽量控制在 50 行以内
  • 提供 --yes / --no-interactive 之类的参数,跳过所有交互式提示

这些原则来自 POSIX 标准、GNU 编码规范、clig.dev 社区指南、12 Factor CLI 框架、Anthropic 的 tool use 文档,以及钉钉和飞书 CLI 的实战检验。

Related

【算法】常用算法模版总结
·3682 words·8 mins· loading
算法 学习 C++
【算法】C++ STL的使用
·3709 words·8 mins· loading
算法 学习 C++
Hello 算法, 带有动画图解的数据结构与算法教程
·93 words·1 min· loading
算法 数据结构 学习
解放双手👐:用 Telegram 远程指挥 Claude Code 干活
·1287 words·3 mins· loading
技巧 Claude Code 效率工具