系列:ToyAgent 智能体知识库 25/26
CLI 智能体:把骨架装进终端
一句话核心:形态变了,灵魂不变——Claude Code、Aider、Gemini CLI 这类终端智能体剥掉外壳后仍是同一个
Agent.chat(input);REPL 只是 chat 的 while 循环,工具侧换成沙箱执行器,骨架零改动。
为什么需要
智能体跑起来之后,自然会遇到"它以什么形态面对用户"的问题:Web 页面、IDE 插件、终端……很多工程师的第一反应是"CLI 是不是另一套架构"。答案是:形态即壳。CLI Agent 的共同特征是"以终端为界面、以命令为手脚、以对话为交互",其中前一条是壳,后两条是早已实现的骨架能力:
- 对话交互 → 就是 chat 方法被反复调用;
- 命令为手脚 → 就是工具调用,只是工具恰好是 shell 命令,套上沙箱执行。
如果此前的骨架真的做到了"复杂度只藏在一个 chat() 方法里",那么换形态就应该是零重构、只加壳。CLI 形态是对整个架构的一次检验——它验证骨架的可复用性是否名副其实。
核心机制
CLI 版新增的东西只有三样,每一样都能找到对应的来处:
| 要点 | 实现 | 呼应 |
|---|---|---|
| shell 执行器 | run_command 工具:ProcessBuilder 按空白拆分命令,不经 shell |
工具调用 |
| 四层沙箱 | 拒绝名单 → 路径边界 → 命令白名单 → 受限工作区 cwd | 复用沙箱纵深防御 |
| 工作区上下文 | cwd 写进系统提示词,模型"知道自己在哪" | 系统提示词分层 |
// 终端形态 = Agent.chat 的 while 循环
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
while (true) {
System.out.print("you› ");
String line = in.readLine();
if (line == null || line.equalsIgnoreCase("exit")) break;
if (line.isBlank()) continue;
System.out.println("agent› " + agent.chat(line)); // 灵魂不变
}
// 手脚换成沙箱执行器:ProcessBuilder 直接拆参,不经 /bin/sh
// 没有 shell 就没有管道、重定向、命令替换这些注入面
ProcessBuilder pb = new ProcessBuilder(command.split("\\s+"));
pb.directory(workdir); // 受限工作区 = 沙箱的空间边界
Process proc = pb.start(); // 读取 stdout/stderr + 退出码回传给模型
"不经 shell"是一个容易被低估的安全决策。如果用 sh -c "整体命令字符串" 的方式执行,模型输出的字符串会被 shell 再解释一遍——cat a; rm -rf b、cat $(whoami)、管道与重定向全部成为攻击面。ProcessBuilder 把命令拆成参数数组直接交给操作系统,;、|、$() 都退化成普通字符,注入面被结构性消除。代价是丢掉了 shell 的便利(管道、通配符),这正是"安全与能力做交换"的典型取舍——需要时应该用受控的方式加回来(如白名单内的管道支持),而不是默认全开。
cwd 进系统提示词是体验上很妙的一笔:终端用户天然理解"当前目录"的概念,模型拿到 cwd 后能说出"你当前在 project-x 下,里面有这些文件",并且对相对路径的解释都有了锚点。工具的运行环境成为提示词的一部分——这是环境感知(context awareness)最朴素也最有效的实现。
graph TD
U["终端输入 stdin"] --> R{"REPL while 循环"}
R -->|exit 或 EOF| X["退出进程"]
R -->|非空一行| C["Agent.chat 复用同一骨架"]
C --> D{"模型决策"}
D -->|需要执行命令| T["调用 run_command 工具"]
T --> S["沙箱四层校验"]
S -->|通过| P["ProcessBuilder 参数数组直执行 不经 shell"]
S -->|拒绝名单命中| E["回传拒绝原因给模型"]
P --> O["stdout 与退出码回传"]
O --> C
E --> C
D -->|可直接回答| A["agent 输出 stdout 打回终端"]
C --> A
CLI 形态数据流:REPL 只是 chat 的 while 循环,换壳的两个可替换点在终端界面与沙箱执行器,骨架零改动。
举例:两种入口 + 一条沙箱轨迹
CLI 的经典双入口——交互式 REPL 与非交互单命令(后者是 Claude Code -p 习惯的同款):
# 交互式 REPL:多轮对话,记忆延续
java -cp target/classes com.example.agent.CliAgent
# 非交互单命令:调一次 chat 就退出,适合脚本管道 / CI
java -cp target/classes com.example.agent.CliAgent -p "看看工作区里有什么"
交互模式下的一条完整轨迹:
you› 运行:ls
模型决策:调用 run_command("ls")
沙箱四层:
① 白名单:ls 在允许列表 → PASS
② 拒绝名单:未命中 → PASS
③ 路径边界:无路径参数 → PASS
④ 受限工作区:cwd=/app/workspace → PASS
ProcessBuilder 执行 → stdout: hello.txt notes.md
agent› 工作区里有两个文件:hello.txt 和 notes.md
you› 运行:cat hello.txt
四层全过 → 执行 → 回传文件内容
you› 运行:rm -rf /
① 命令在拒绝名单 → DENY,工具未执行
agent› 这个命令被沙箱拒绝执行了,原因:rm -rf 属于破坏性命令
you› 列出工作区文件
注意:这里没有写死任何命令。模型自己决策"该调 run_command 跑 ls",
和上一轮"运行:ls"殊途同归 —— 区别只在触发方式(人指定 vs 模型决策)
最后两条轨迹的对比点出了一个本质:"运行:X"是人替模型做了工具决策,"列出文件"才是完整的 Agent 形态——模型自主理解意图、选择工具、组织参数。CLI 只是把这套循环搬到了终端上。
举一反三:GUI Agent 的做法完全同构——把 run_command 换成浏览器执行器(click / type / screenshot),沙箱换成页面白名单与操作域限制,骨架依旧不动。理解了"形态即壳",从 CLI 到 GUI 到 IDE 插件到聊天机器人,只是换执行器和换界面两个可替换点。
教学版 vs 生产版
| 维度 | 教学版 | 生产版 |
|---|---|---|
| 命令拆分 | split("\\s+") 简单拆 |
shlex 级解析(引号、转义),或结构化参数协议 |
| 白名单 | 硬编码命令列表 | 权限配置文件,按命令/参数细粒度授权 |
| 输出 | 原样回传 | 截断、编码处理、二进制检测 |
| 执行控制 | 无超时 | 超时 + 输出上限 + 资源限制 |
| 高危操作 | 直接 DENY | 接入审批门禁,高危命令转人工确认 |
面试考点
- "CLI Agent 和 Web Agent 架构上有什么区别?"——骨架完全相同(chat 循环 + 工具 + 记忆 + 运行时),区别只在两个可替换点:交互界面(终端 REPL/单命令)与执行器(沙箱 shell);"形态即壳"。
- "为什么执行命令不用 sh -c 而用参数数组?"——不经 shell 就消除了管道、重定向、命令替换等注入面,
;/|/$()退化为普通字符;这是用功能换安全的结构性决策。 - "怎么让 CLI Agent 知道自己'在哪'?"——把 cwd 等运行环境写进系统提示词,工具环境成为上下文的一部分;这是环境感知最朴素的实现,也让相对路径语义有一致锚点。
生产化路径
- 命令拆分升级为 shlex 级解析(正确处理引号与转义),或更进一步让模型输出结构化参数(命令 + 参数数组),由宿主拼装;
- 白名单升级为权限配置体系:按命令、参数模式、目录范围细粒度授权,支持按项目/租户差异化;
- 输出治理:超长输出截断(带截断提示)、二进制内容检测、stderr 与退出码结构化回传;
- 执行控制:超时杀进程、输出字节上限、CPU/内存/进程数配额,防止单命令拖垮宿主;
- 审批接入:拒绝名单命中的命令不直接丢弃,转审批门禁由人工放行,形成"拦截-审批-执行"闭环;交互模式与会话管理(事件溯源持久化)打通,终端断开后会话可恢复。