CLI is All Agents Need — *nix Agent 设计宝典
Why a single
run(command)outperforms sprawling tool catalogs当所有人在为 Agent 设计复杂的函数调用框架时,我发现:一个
run(command="...")就够了。
本文由 AI Agent 协作完成,但每一个设计决策、每一次生产踩坑、以及把它们提炼成原则的思考过程,都是本人完成的。
为什么是 *nix
Unix 在 50 年前做了一个设计决策:一切皆文本流。 程序之间不交换复杂的二进制结构,不共享内存对象——它们通过文本管道沟通。小工具各司其职,通过 | 组合成强大的工作流。程序用 --help 描述自己,用 exit code 报告成败,用 stderr 传递错误上下文。
LLM 在 50 年后做了一个几乎相同的决策:一切皆 token。 它只理解文本,只输出文本。它的"思考"是文本,它的"行动"是文本,它从世界获得的反馈也必须是文本。
这两个决策跨越半个世纪,从完全不同的出发点,收敛到了同一个接口模型。Unix 为人类终端操作者设计的文本系统——cat、grep、pipe、exit code、man page——对 LLM 来说不是"也能用",而是天然适配。LLM 在工具使用这件事上,本质就是一个终端操作者,只不过它比任何人类都快,而且它的训练数据里有大量 shell 命令与 CLI 用例。
这就是 *nix Agent 的核心哲学:不发明新的工具接口,把 Unix 已经验证了 50 年的设计直接交给 LLM。
为什么只有一个 run
单工具假说
主流 Agent 框架给 LLM 一堆独立工具:
tools: [search_web, read_file, write_file, run_code, send_email, ...]
LLM 每次调用前要做工具选择——用哪个?参数怎么填?工具越多,选择越难,准确率越低。认知负荷花在了"选工具"上,而不是"解决问题"上。
我的做法:一个 run(command="..."),所有能力通过 CLI 命令暴露。
run(command="cat notes.md")
run(command="cat log.txt | grep ERROR | wc -l")
run(command="see screenshot.png")
run(command="memory search 'deployment issue'")
run(command="clip sandbox bash 'python3 analyze.py'")
LLM 仍然需要选择"用什么命令",但这和"从 15 个不同 schema 的工具中选一个"是完全不同的认知任务。命令选择是在一个统一的 namespace 里做字符串组合——而函数选择是在多个不相关的 API 间做模式切换。
LLM 的 CLI 母语
为什么 CLI 命令比结构化函数调用更适合 LLM?
因为 LLM 的训练数据里,CLI 是最密集的工具使用范式。GitHub 上数十亿行代码充满了:
# README 里的安装指南
pip install -r requirements.txt && python main.py
# CI/CD 里的构建脚本
make build && make test && make deploy
# Stack Overflow 里的解决方案
cat /var/log/syslog | grep "Out of memory" | tail -20
我不需要教 LLM 如何使用 CLI——它已经会了。这种熟悉度是概率性的、模型相关的,但在主流模型上经验证非常可靠。
对比两种方式完成同一个任务:
任务:读取日志文件,找出错误行数
函数调用方式(3 次 tool call):
1. read_file(path="/var/log/app.log") → 返回完整文件
2. search_text(text=<entire file>, pattern="ERROR") → 返回匹配行
3. count_lines(text=<matched lines>) → 返回数字
CLI 方式(1 次 tool call):
run(command="cat /var/log/app.log | grep ERROR | wc -l")
→ "42"
一次调用替代三次。不是因为做了特殊优化——而是 Unix 管道天然支持组合。
让管道和链式执行成为可能
光有一个 run 不够。如果 run 只能执行单条命令,LLM 还是要多次调用才能完成组合任务。所以我在命令路由层实现了一个链式解析器(parseChain),支持四种 Unix 操作符:
| 管道:前一个命令的 stdout 作为后一个的 stdin
&& 与链:前一个成功才执行后一个
|| 或链:前一个失败才执行后一个
; 顺序:无论前一个成败都执行后一个
有了这个机制,LLM 的每一次 tool call 都可以是一个完整的工作流:
# 一次 tool call:下载 → 查看
curl -sL $URL -o data.csv && cat data.csv | head 5
# 一次 tool call:读文件 → 过滤 → 排序 → 取前 10
cat access.log | grep "500" | sort | head 10
# 一次 tool call:尝试 A,失败则尝试 B
cat config.yaml || echo "config not found, using defaults"
N 个命令 × 4 种操作符,组合空间急剧扩大。而在 LLM 看来,这只是一个字符串——它已经从训练数据里学会了怎么写。
命令行就是 LLM 的母语工具接口。
启发式设计:让 CLI 引导 Agent 顺滑工作
单工具 + CLI 解决了"用什么"的问题,但 Agent 还需要知道 "怎么用"。它没有搜索引擎,没有同事可以问。我通过三个递进的设计手段,让 CLI 自身成为 Agent 的导航系统。
手段一:渐进式 --help 自发现
一个设计良好的 CLI 工具,用户不需要读文档就能上手——因为 --help 告诉了一切。我把同样的理念用在 Agent 身上,并且做成了渐进式披露:Agent 不需要一次加载所有文档,而是按需逐层深入。
第零层:Tool Description → 命令列表注入
run 工具的 description 在每次对话开始时动态生成,列出当前所有已注册命令的一行摘要:
Available commands:
cat — Read a text file. For images use 'see'. For binary use 'cat -b'.
see — View an image (auto-attaches to vision)
ls — List files in current topic
write — Write file. Usage: write <path> [content] or stdin
grep — Filter lines matching a pattern (supports -i, -v, -c)
memory — Search or manage memory
clip — Operate external environments (sandboxes, services)
...
Agent 从第一轮就知道有哪些能力,但不需要知道每个命令的所有参数——那会浪费上下文。
注: 命令列表全量注入 vs 按需发现,随着命令增多这里存在上下文预算的权衡。目前还在探索更优的平衡方式,欢迎讨论。
第一层:command(无参数)→ 命令用法
Agent 对某个命令感兴趣时,直接调用它。没给参数?命令返回自己的用法:
→ run(command="memory")
[error] memory: usage: memory search|recent|store|facts|forget
→ run(command="clip")
clip list — list available clips
clip <name> — show clip details and commands
clip <name> <command> [args...] — invoke a command
clip <name> pull <remote-path> [name] — pull file from clip to local
clip <name> push <local-path> <remote> — push local file to clip
现在 Agent 知道 memory 有五个子命令,clip 可以 list/pull/push。只花了一次调用,没有多余信息。
第二层:command subcommand(缺参数)→ 具体参数
Agent 决定用 memory search,但不确定参数格式?继续深入:
→ run(command="memory search")
[error] memory: usage: memory search <query> [-t topic_id] [-k keyword]
→ run(command="clip sandbox")
Clip: sandbox
Commands:
clip sandbox bash <script>
clip sandbox read <path>
clip sandbox write <path>
File transfer:
clip sandbox pull <remote-path> [local-name]
clip sandbox push <local-path> <remote-path>
渐进式披露:概览(注入)→ 用法(探索)→ 参数(深入)。 Agent 按需探索,每一层只获取当前需要的信息量。
这和把所有文档塞进 system prompt 是完全不同的思路。system prompt 里塞 3000 字的工具文档,大部分信息在大部分时候都用不到——白白占用上下文预算。渐进式 help 让 Agent 自己决定什么时候需要更多信息。
这也对命令设计提出了要求:每个命令、每个子命令都必须有完整的 help 输出。 不仅是给人看的,更是给 Agent 看的。一条好的 help 能让 Agent 一步到位,一条缺失的 help 意味着一次盲猜。
手段二:Error Message 纠偏
Agent 不可能永远走对路。关键不是阻止它犯错,而是让每个错误都指向正确方向。
传统 CLI 的错误信息给人看——人可以 Google。Agent 不能 Google。所以我要求每个错误同时包含"出了什么问题"和"该怎么做":
传统 CLI:
$ cat photo.png
cat: binary file (standard output)
→ 人会 Google "how to view image in terminal"
我的设计:
[error] cat: binary image file (182KB). Use: see photo.png
→ Agent 直接调用 see,一步修正
更多例子:
[error] unknown command: foo
Available: cat, ls, see, write, grep, memory, clip, ...
→ Agent 立刻知道有哪些命令
[error] not an image file: data.csv (use cat to read text files)
→ Agent 从 see 切换到 cat
[error] clip "sandbox" not found. Use 'clip list' to see available clips
→ Agent 知道先列出 clips
手段一(help)解决"我能做什么"。手段二(error)解决"做错了怎么办"。两者配合,Agent 的试错成本极低——通常 1-2 步就能找到正确路径。
真实案例:stderr 被丢弃的代价
有段时间,我的代码在调用外部沙箱时,如果 stdout 非空就丢弃 stderr。Agent 运行 pip install pymupdf,exit code 127。stderr 里有 bash: pip: command not found,但 Agent 看不到。它只知道"失败了",不知道"为什么"——于是盲猜了 10 种包管理器:
pip install → 127 (不存在)
python3 -m pip → 1 (模块不存在)
uv pip install → 1 (用法错误)
pip3 install → 127
sudo apt install → 127
... 又试了 5 种 ...
uv run --with pymupdf python3 script.py → 0 ✓ (第 10 次)
修复后 stderr 永远可见,Agent 第一次就看到 "pip: command not found",立刻换方向。
stderr 是 Agent 最需要的信息,恰恰在命令失败的时候。永远不要丢弃它。
手段三:Output Format 持续反馈
前两个手段解决了 Agent 的"发现"和"纠错"。第三个让 Agent 在持续执行过程中越来越懂系统。
我在每个 tool result 末尾追加一致的元数据:
file1.txt
file2.txt
dir1/
[exit:0 | 12ms]
LLM 从中提取两个信号:
退出码(Unix 约定,LLM 已掌握):
exit:0— 成功 exit:1— 一般错误 exit:127— 命令不存在
耗时(成本感知):
12ms— 廉价操作,可以放心多调 3.2s— 中等成本 45s— 昂贵操作,需要谨慎
Agent 在对话中看到几十次 [exit:N | Xs] 后,内化了这个模式。它开始预判——看到 exit:1 就知道检查错误,看到耗时很长就减少调用。
一致的输出格式让 Agent 越用越聪明。不一致让每次都像第一次。
三个手段的递进关系:
--help → "我能做什么?" → 主动发现
Error Msg → "做错了怎么办?" → 被动纠偏
Output Fmt → "做得怎么样?" → 持续学习
双层架构:把启发式设计落地的工程
上面讲了 CLI 如何在语义层面引导 Agent。但要真正落地,还需要解决一个工程问题:CLI 执行的原始输出,和 LLM 需要看到的结果,往往不是一回事。
LLM 的两个硬约束
约束 A:上下文窗口有限且昂贵。 每个 token 消耗金钱、注意力、推理速度。一个 10MB 文件全文塞进上下文,不仅浪费,还会把之前的对话挤出窗口——Agent "失忆"。
约束 B:LLM 只能处理文本。 二进制数据经过 tokenizer 产生高熵无意义的 token。它不仅浪费上下文,还会扰乱周围有效 token 的注意力,导致推理质量下降。
这两个约束决定了:命令的原始输出不能直接扔给 LLM——需要一个呈现层做加工。但加工不能影响命令的执行逻辑——否则管道会坏。所以需要分层。
执行层 vs 呈现层
┌─────────────────────────────────┐
│ Layer 2: LLM 呈现层 │ ← 为 LLM 认知约束设计
│ 二进制拦截 | 截断+溢出 | 元数据 │
├─────────────────────────────────┤
│ Layer 1: Unix 执行层 │ ← 纯 Unix 语义
│ 命令路由 | pipe | chain | exit │
└─────────────────────────────────┘
当 cat bigfile.txt | grep error | head 10 执行时:
Layer 1 内部:
cat 输出 → [500KB 原始文本] → grep 输入
grep 输出 → [匹配的行] → head 输入
head 输出 → [前 10 行]
如果在 Layer 1 截断 cat 的输出 → grep 只搜到前 200 行,结果不完整。如果在 Layer 1 加 [exit:0] → 它作为文本流入 grep,变成搜索内容。
所以 Layer 1 必须保持原始、无损、无元数据。加工只在 Layer 2 发生——管道链执行完毕,最终结果准备返回给 LLM 时。
Layer 1 服务于 Unix 语义。Layer 2 服务于 LLM 认知。分层不是设计偏好,是逻辑必然。
Layer 2 的四个机制
机制 A:二进制守卫(应对约束 B)
在返回给 LLM 之前,检查内容是否为文本:
检测到 null byte → 二进制
UTF-8 校验失败 → 二进制
控制字符比例 > 10% → 二进制
如果是图片:[error] binary image (182KB). Use: see photo.png
如果是其他:[error] binary file (1.2MB). Use: cat -b file.bin
LLM 永远不会收到它无法处理的数据。
机制 B:溢出模式(应对约束 A)
输出 > 200 行 或 > 50KB?
→ 截断到前 200 行(rune-safe,不切断 UTF-8)
→ 完整输出写入 /tmp/cmd-output/cmd-{n}.txt
→ 返回给 LLM:
[前 200 行内容]
--- output truncated (5000 lines, 245.3KB) ---
Full output: /tmp/cmd-output/cmd-3.txt
Explore: cat /tmp/cmd-output/cmd-3.txt | grep <pattern>
cat /tmp/cmd-output/cmd-3.txt | tail 100
[exit:0 | 1.2s]
关键洞察:LLM 已经知道如何用 grep、head、tail 导航文件。溢出模式把"大数据探索"转化为 LLM 已掌握的技能。
机制 C:元数据脚注
actual output here
[exit:0 | 1.2s]
退出码 + 耗时,追加在 Layer 2 的最后一行。给 Agent 判断成败和感知成本的依据,同时不污染 Layer 1 的管道数据。
机制 D:stderr 附加
命令失败且有 stderr 时:
output + "\n[stderr] " + stderr
确保 Agent 能看到错误原因,不会盲目重试。
实现:agent-clip 核心代码
以下为简化的伪代码,突出架构骨架。完整实现见源码仓库。
单工具定义
// LLM 只看到一个工具:run(command, stdin?)
// 所有命令及帮助文本列在 description 里
func RunToolDef(commands map[string]string) ToolDef {
desc := "Execute commands via run(command=\"...\").\n"
desc += "Supports chaining: cmd1 && cmd2, cmd1 | cmd2.\n\n"
for name, help := range commands {
desc += " " + name + " — " + help + "\n"
}
return ToolDef{
Name: "run",
Description: desc,
Parameters: { command: string, stdin?: string },
}
}
Layer 1:命令路由与管道
// Exec 解析命令链,执行管道,返回原始输出 + 退出码
// 这里是纯 Unix 语义——没有截断,没有元数据,没有二进制检测
func (r *Registry) Exec(command, stdin string) (string, int) {
segments := parseChain(command) // 按 &&, ||, |, ; 分割
for i, seg := range segments {
if prevOp == OpAnd && lastExitCode != 0 { continue }
if prevOp == OpOr && lastExitCode == 0 { continue }
if prevOp == OpPipe { stdin = lastOutput }
lastOutput, lastExitCode = r.execSingle(seg, stdin)
}
return lastOutput, lastExitCode
}
func (r *Registry) execSingle(command, stdin string) (string, int) {
name, args := tokenize(command)
handler, ok := r.handlers[name]
if !ok {
return "[error] unknown command: " + name + "\nAvailable: ...", 127
}
output, err := handler(args, stdin)
if err != nil {
return "[error] " + name + ": " + err.Error(), 1
}
return output, 0
}
Layer 2:LLM 呈现层
// execToolCall 把 Layer 1 的原始结果加工成 LLM 能理解的格式
// 所有的截断、溢出、元数据包装都在这里发生
func execToolCall(registry *Registry, tc ToolCall) string {
command, stdin := parseToolCallArgs(tc)
// Layer 1: 纯执行
start := time.Now()
rawOutput, exitCode := registry.Exec(command, stdin)
duration := time.Since(start)
metadata := fmt.Sprintf("[exit:%d | %s]", exitCode, duration)
// 失败时直接返回(execSingle 已加 [error] 前缀 + stderr)
if exitCode != 0 {
return rawOutput + "\n" + metadata
}
// 截断检查(仅超限时才持久化到 /tmp)
result := rawOutput
if tooLarge(rawOutput) {
truncated := first200LinesRuneSafe(rawOutput)
file := persistToTmp(rawOutput)
result = truncated + "\n--- truncated ---\nFull output: " + file
}
return result + "\n" + metadata
}
二进制守卫
func fsCat(args []string, stdin string) (string, error) {
data := readFile(path)
// 二进制守卫:拦截 LLM 无法处理的数据,引导到正确命令
if !base64Flag && isBinary(data) {
if isImage(path) {
return error("binary image (%s). Use: see %s", size, path)
}
return error("binary file (%s). Use: cat -b %s", size, path)
}
return string(data), nil
}
func isBinary(data []byte) bool {
sample := data[:min(8192, len(data))]
if containsNull(sample) { return true } // null byte = 一定是二进制
if !utf8.Valid(sample) { return true } // 非法 UTF-8 = 大概率二进制
if controlRatio(sample) > 0.1 { return true } // 控制字符太多
return false
}
Vision 自动挂载
// Agent 不需要显式请求"看图"——see 返回的 URL 被自动挂载到 vision 通道
func extractImagesFromResult(result string) []ImageData {
urls := findImageURLs(result) // 匹配 pinix-data://...png
for _, url := range urls {
data := readFile(urlToPath(url))
images = append(images, ImageData{
Base64: base64Encode(data),
MimeType: detectMIME(url),
})
}
return images // 附加到下一轮 LLM 调用的 vision 内容
}
Agentic Loop
func RunLoop(config, context, registry, output) {
// 不设硬性迭代上限。LLM 不调用工具时循环自然结束。
// 如果 Agent 还在调工具,说明它认为任务没完成——
// 我通过前面的启发式设计(help / error / output format)
// 让它更快到达终点,而不是粗暴截断它的执行。
//
// 实际安全边界由外部机制保障:沙箱隔离、API 预算、用户取消。
for {
response := callLLM(context)
if response.hasToolCalls() {
for _, tc := range response.toolCalls {
result := execToolCall(registry, tc) // Layer 2 包装
images := extractImagesFromResult(result) // vision 挂载
context.append(toolResult(result, images))
}
continue
}
// LLM 没有调用工具 = 任务完成
return response.content
}
}
经验教训:来自生产的故事
故事 1:一张 PNG 引发的 20 轮崩溃
用户上传了一张架构图。Agent 用 cat 读取,收到 182KB 的 PNG 原始字节。LLM 的 tokenizer 把这些字节变成数千个无意义的 token,塞进上下文。LLM 完全无法理解,开始反复尝试不同的读取方式——每次都返回同样的乱码。20 轮后进程被强制终止。
根因:cat 没有二进制检测,Layer 2 没有拦截。修复:isBinary() 守卫 + 错误引导 Use: see photo.png。教训: Tool result 是 Agent 的眼睛。返回垃圾 = Agent 失明。
故事 2:沉默的 stderr 与 10 次盲猜
Agent 需要读取一个 PDF。它尝试 pip install pymupdf,exit code 127。但 stderr(bash: pip: command not found)被代码丢弃了——因为有一小段 stdout,代码逻辑是"有 stdout 就忽略 stderr"。
Agent 只看到"失败了",不知道"为什么"。接下来是漫长的试错:
pip install → 127 (不存在)
python3 -m pip → 1 (模块不存在)
uv pip install → 1 (用法错误)
pip3 install → 127
sudo apt install → 127
... 又试了 5 种 ...
uv run --with pymupdf python3 script.py → 0 ✓
10 次调用,每次 ~5 秒推理。如果第一次 stderr 可见,1 次就够了。
根因:InvokeClip 在有 stdout 时静默丢弃 stderr。修复: 失败时永远附加 stderr。教训: stderr 是 Agent 最需要的信息,恰恰在命令失败的时候。
故事 3:溢出模式的价值
Agent 分析一个 5000 行的日志文件。没有截断时,5000 行全文(~200KB)塞进上下文,LLM 的注意力被淹没,回答质量骤降,还把之前的对话挤出了上下文窗口。
加了溢出模式后:
[前 200 行日志内容]
--- output truncated (5000 lines, 198.5KB) ---
Full output: /tmp/cmd-output/cmd-3.txt
Explore: cat /tmp/cmd-output/cmd-3.txt | grep <pattern>
cat /tmp/cmd-output/cmd-3.txt | tail 100
[exit:0 | 45ms]
Agent 看到前 200 行,了解了文件结构,然后用 grep 精准定位——总共 3 次调用,上下文占用不到 2KB。
教训: 给 Agent 一个"地图"比给它"全部领土"有效得多。
边界与局限
CLI 不是银弹。以下场景中,typed API 可能是更好的选择:
- 强类型交互
:数据库查询、GraphQL API 等需要结构化输入输出的场景,schema 验证比字符串解析更可靠。 - 高安全要求
:CLI 的字符串拼接天然带有注入风险。在不可信输入场景中,typed parameters 比命令字符串更安全。agent-clip 通过沙箱隔离来缓解这个问题。 - 多模态原生
:纯音视频处理等需要二进制流的场景,CLI 的文本管道是瓶颈。
此外,"不设迭代上限"不意味着没有安全边界。实际安全由外部机制保障:
- 沙箱隔离
:命令在 BoxLite 容器内执行,无法逃逸 - API 预算
:LLM 调用有账户级别的费用上限 - 用户取消
:前端提供取消按钮,后端支持 graceful shutdown
把 Unix 哲学交给执行层,把 LLM 的认知约束交给呈现层,用 help、error message 和 output format 构建三个递进的启发式导航手段。
CLI is all agents need.
以上所有设计在 agent-clip 中均有完整实现。核心代码对应关系:
单工具命令路由 → internal/tools.go管道与链式执行 → internal/chain.go双层 agentic loop → internal/loop.go文件系统与二进制守卫 → internal/fs.go外部环境调用与 stderr 处理 → internal/clip.go浏览器自动化与 vision 挂载 → internal/browser.go语义记忆与向量搜索 → internal/memory.go
Built with Go. Powered by Unix. Designed for LLMs.