• 约 17 分钟阅读

更新于

能在终端运行,不代表 Agent 能用:从 CLI-Anything 到可靠的 CLI

从 CLI-Anything 的方法与源码出发,用真实 FFmpeg 案例讲清 Agent CLI 的参数、JSON、退出码、超时、无覆盖写入和产物验收。

目录

能在终端运行,不代表 Agent 能用:从 CLI-Anything 到可靠的 CLI

“命令已经执行成功了。”

如果你在终端旁边,接下来可能会打开输出目录,检查文件是否正确;看到警告,会判断是否需要调整参数;发现目标文件已经存在,会决定是否覆盖。

把调用者换成 Agent,这些原本由人补齐的判断,就必须进入接口设计。否则,模型可能把进程退出理解为任务完成,把超时理解为没有执行,或者在重试时覆盖上一轮留下的文件。

上一篇文章区分了 Skill、工具、MCP 与插件。这一篇继续向执行接口深入:怎样给已有软件提供一条可解析、可诊断、可验收的命令路径?

我们先看 CLI-Anything 的方法和具体源码,再用 FFmpeg 完成一个最小案例:将本地音频转换为 16 kHz、单声道、16 位 PCM WAV。FFmpeg 本来就有 CLI,本文不是为它生成命令行,而是把通用命令收敛成一个有明确契约的任务接口。

1. 什么是 Agent 友好的 CLI?

CLI 是 Command-Line Interface,即命令行接口。调用者通过命令和参数发起操作,从输出和退出状态获知结果。它不等于终端窗口,更不要求必须通过交互式 Shell 使用。

在本文中,还需要分清三个概念:

  • 包装层:接收任务参数,检查输入,调用后端,并整理结果的程序。
  • 真实后端:真正执行转换、渲染或计算的软件;这里是 FFmpeg。
  • 命令契约:调用双方对输入、输出、错误和副作用的共同约定。

结构化输出只是契约的一部分。把“转换完成”改成 {"ok": true},并不会自动让接口可靠。如果文件写错位置,或者返回的采样率只是复述请求值,JSON 依然在表达一个未经证实的结论。

对同一个音频转换任务,人可能接受进度条和一段日志,Agent 则需要更明确的答案:文件在哪里、实际是什么格式、失败发生在哪个阶段、能否安全重试。人类可读性解决理解问题,机器可判定性解决后续动作问题。

这也解释了为什么不能把“支持 --json”当作最终验收标准。参数缺失时若突然输出另一种格式,或者诊断日志混入 JSON,调用方仍然需要猜测。

2. CLI-Anything 在解决什么问题?

CLI-Anything 提供了一套面向编码 Agent 的软件适配方法。其 HARNESS.md 从分析后端引擎、数据模型和已有 CLI 开始,再设计命令、状态与输出格式,随后实现适配并测试。重点不是把按钮名称机械地改成子命令,而是寻找可以复用的程序入口。[1]

这套方法同时考虑一次性命令与交互式 REPL。前者适合明确输入后执行并退出,后者维护持续会话。对本文的单次转换任务,没有必要引入会话、撤销栈或交互提示;接口越窄,状态越容易讲清楚。

不只看方法说明,还要追踪实际调用链

本文核查的源码快照是提交 34f519533bc175d2fe287ab8316b0dd99bb9cc43。以 Audacity 适配中的 export render 为例,入口函数将项目状态和参数交给 export_mod.render_mix(),再输出结果。[2]

继续往下看,该函数进行轨道混合,并在 WAV 分支调用 write_wav() 写文件。仅凭这条路径,不能声称它启动了 Audacity 原生程序完成导出。在这个快照中,非 WAV 分支也调用了 write_wav(),而返回的格式字段来自预设;因此,接口返回的格式名称不能代替对实际文件的检查。[3]

这是静态源码观察,不是本轮运行该适配得到的测评结论。它说明了一种必要的阅读方式:项目方法、适配实现和运行结果,要分别找证据。

项目的测试方法本身也强调调用真实后端,并检查文件格式及内容,而不是只判断导出命令是否成功。[1] 本文借鉴这条原则,手工实现一个范围更小的包装层,不运行 CLI-Anything 的生成流程,也不把后面的结果称为项目复现。

3. 先定义命令契约,再写包装代码

本例只提供一个动作:将指定的本地 PCM WAV 转为固定规格。目标规格是教学约定,不是所有语音识别服务的统一要求。

python3 audio_cli.py \
  --input 'example-data/input stereo.wav' \
  --output 'example-data/output mono.wav' \
  --timeout 10

输入必须明确,副作用必须克制

输入必须是可由 Python 标准库 wave 完整读取的普通 PCM WAV,不接受 URL、目录或输入符号链接。输出目录必须已经存在,目标必须以 .wav 结尾。既有文件、目录和符号链接一律不覆盖。

超时参数必须是正且有限的数,0、负数、nan 和 inf 都属于参数错误。包装层不提供任意 FFmpeg 参数透传入口,因为这里交付的是一个固定任务,而不是第二套通用终端。

stdout 是协议,stderr 是诊断

除 --help 外,成功与预期错误都在 stdout 输出一行 JSON,stderr 只保留简短诊断。成功对象包含 ok、code、output、采样率、声道、编码和时长;错误对象包含 ok、code、error。

退出码采用本例自己的约定:

  • 0:产物已通过验收并发布到目标路径。
  • 2:参数、输入或输出目录不合法。
  • 3:FFmpeg 或 ffprobe 缺失、不可执行。
  • 4:后端无法启动或转换失败。
  • 5:后端阶段超时。
  • 6:输出路径冲突。
  • 7:产物验收或原子发布失败。

这些值不是通用标准,真正重要的是含义稳定,并且与 JSON 中的 code 一致。调用方据此决定修正参数、补齐依赖、检查旧产物,还是结束任务,而不是把所有错误都送进同一个重试循环。

4. 接上真实后端:从命令到实际产物

下载配套代码包 reliable-agent-cli.zip,其中包含完整的 audio_cli.py、测试与验证脚本。验证环境使用 FFmpeg/ffprobe 8.0.1;示例固定调用 /opt/homebrew/bin/ 下的二进制。其他安装位置需要修改脚本顶部的两个路径常量,指向自己信任的软件。

准备一份可重复的输入

下面的 create_input.py 生成一秒、44.1 kHz、双声道的合成音频,不需要下载样本或使用个人录音。文件以独占创建方式打开,已有同名文件时不会覆盖。

from pathlib import Path
import math
import struct
import wave

folder = Path('example-data')
folder.mkdir(exist_ok=True)
source = folder / 'input stereo.wav'
frames = bytearray()
for i in range(44100):
    value = int(8000 * math.sin(2 * math.pi * 440 * i / 44100))
    frames.extend(struct.pack('<hh', value, value))
with source.open('xb') as handle, wave.open(handle, 'wb') as wav:
    wav.setparams((2, 2, 44100, 0, 'NONE', 'not compressed'))
    wav.writeframes(frames)
print(source.resolve())

包装层不是音频算法的替代品

实际重采样、声道转换和编码交给 FFmpeg。包装层使用参数列表调用后端,不拼接 Shell 字符串;路径中的空格无需再次手工转义。Python 官方文档也推荐将子进程参数作为序列传递。[6]

命令固定使用 -ar 16000、-ac 1 和 pcm_s16le,并通过 -nostdin 禁用标准输入交互。FFmpeg 文档明确指出,-nostdin 用于关闭这类交互。[4]

这里有一个容易误解的细节:后端使用 -y,却仍然遵守“不覆盖用户文件”的约定。因为 FFmpeg 写入的是包装层新建的私有临时文件,而不是用户指定的最终路径。最终文件要等验收完成才创建。

检查参数与输入
      ↓
在输出目录创建临时文件
      ↓
FFmpeg 转换 → ffprobe 探测 → wave 检查完整性
      ↓
无覆盖地创建最终文件名
      ↓
返回结果,清理临时文件

下面的 call_cli.py 展示程序如何调用这一接口。将它与 audio_cli.py 放在同一目录,在生成样本的工作目录运行:

import json
from pathlib import Path
import subprocess
import sys

result = subprocess.run(
    [sys.executable, str(Path(__file__).with_name('audio_cli.py')),
     '--input', 'example-data/input stereo.wav',
     '--output', 'example-data/output mono.wav', '--timeout', '10'],
    capture_output=True, text=True, timeout=20,
)
reply = json.loads(result.stdout)
assert reply['code'] == result.returncode
print(json.dumps(reply, ensure_ascii=False))
if not reply['ok']:
    raise SystemExit(result.returncode)

内层十秒约束后端阶段,外层二十秒是调用脚本的等待上限,两者不是同一个计时器。这段调用示例没有自动重试;如果外层超时,也不能据此断言最终文件不存在。

5. 命令失败后,Agent 应该知道什么?

错误反馈需要回答两个问题:失败发生在哪一层?当前留下了什么状态?

坏 WAV 应在输入检查时失败,不启动转换;后端非零退出归为执行失败;ffprobe 返回不可解析的 JSON、错误声道或不符规格,则归为验收失败。它们都不能通过“多试几次”统一解决。

超时之后,先终止执行,再报告结果

实现使用同一个单调时钟截止时间约束 FFmpeg 和 ffprobe,避免两个后端步骤各自重新获得完整预算。超时时终止直接子进程,并等待回收。

同时持续读取两个输出管道,只保留有限尾部,避免一边等待进程退出、一边让日志积满管道。原始后端诊断不直接转发给模型,以免将路径或音频元数据带进输出;接口返回的是可识别的错误阶段。

这个超时不是硬实时保证:输入全量检查在后端计时之前,程序也不管理孙进程。它适用于本例直接启动可信二进制的范围,不是通用进程树控制器。

不覆盖不等于幂等

验收通过后,包装层通过 os.link 为临时文件创建最终名称。若另一任务已抢先创建目标路径,这一步失败,不替换对方文件;随后清理本次临时文件。不支持硬链接的文件系统也会失败,不降级为覆盖写。

但这并不等于重试会返回相同结果。可能出现“文件已经创建,成功响应却没送达”的情况。再次调用时会返回冲突;正确动作是检查已有产物,而不是为了让重试通过而自动删除它。

同样,这个设计没有提供断电持久性、任务身份管理或操作系统沙箱。它信任本机二进制与工作目录,不防恶意并发替换输入或父目录。可靠性措施要说明保护了什么,也要说明没有保护什么。

6. 退出码为零之后,如何确认任务真的完成?

包装层的成功条件不是“FFmpeg 返回了零”,而是随后完成产物检查。ffprobe 支持选择音轨、指定输出字段和 JSON 输出,适合读取实际媒体属性。[5]

下面的 inspect_output.py 再独立回读一次最终文件。这里不仅检查元数据,还确认合成样本有预期帧数和非零内容:

import json
import subprocess
import wave
from audio_cli import FFPROBE

path = 'example-data/output mono.wav'
result = subprocess.run(
    [FFPROBE, '-v', 'error', '-select_streams', 'a',
     '-show_entries', 'stream=codec_name,sample_rate,channels:format=duration',
     '-of', 'json', path],
    capture_output=True, text=True, check=True, timeout=10,
)
data = json.loads(result.stdout)
assert len(data['streams']) == 1
stream = data['streams'][0]
assert (stream['sample_rate'], stream['channels'], stream['codec_name']) == ('16000', 1, 'pcm_s16le')
with wave.open(path, 'rb') as wav:
    assert (wav.getframerate(), wav.getnchannels(), wav.getsampwidth(), wav.getnframes()) == (16000, 1, 2, 16000)
    assert any(wav.readframes(wav.getnframes()))
print(json.dumps(data, ensure_ascii=False, indent=2))

运行顺序是 python3 create_input.py、python3 call_cli.py、python3 inspect_output.py。三段代码和完整包装层都已实际运行。

本次回读得到的关键字段为:sample_rate=16000、channels=1、codec_name=pcm_s16le、时长一秒;wave 读取到 16000 帧,样本非零,文件为 32078 字节。这证明了该合成样本的转换与产物属性,不证明任意音频的保真度,更不是识别准确率评测。

非零样本要求只适用于这份原本就有声音的测试数据。真实输入可能是合法静音,因此包装层不会把全零样本一概判成失败。

把测试层次分开

配套的 python3 verify.py 会运行测试并写入 results.json,保存版本、命令输出、产物属性和哈希。本轮复跑通过 20 个测试方法:

  • 9 个无 mock 的接口与集成测试:包括真实转换、中文空格路径、旧文件保护、坏输入、参数和路径约束。
  • 9 个内部故障注入测试:检查依赖缺失、后端错误、异常探测结果、无有效产物和发布竞争等处理分支。
  • 2 个真实子进程管理测试:验证日志缓冲上限,以及超时后直接子进程已经回收。

故障注入不等于真实软件发生过对应故障。极小超时也可能在进程启动前触发,因此不能只凭这项检查宣称“FFmpeg 已启动并被正确终止”。

更重要的是,本轮没有进行模型选参、多轮恢复或真实 Agent 成功率实验。接口测试通过,只完成了验证链路的一部分。若要评估 Agent,还要另行准备任务集,观察它是否选对参数、理解冲突,并根据结果采取正确动作。

7. 从项目思路到自己的工具,哪些值得复用?

CLI-Anything 值得借鉴的是一条工作路径:先分析已有软件入口,再定义命令和状态,最后连接后端并验证产物。生成代码可以加快适配,但不能替代对具体调用链的阅读和测试。[1]

对一个固定动作,手工包装往往更容易控制范围。对拥有大量操作和复杂状态的软件,生成式适配更有探索价值,但也需要审查每项命令的副作用、错误语义与版本兼容。

接口演进时,应分别记录后端版本和包装层契约版本。FFmpeg 升级后产物行为是否变化,JSON 字段调整后旧调用方能否继续解析,是两种不同的兼容问题。先稳定契约,再增加格式或功能,比不断透传后端选项更容易维护。

是否再加 MCP,取决于调用方需求:如果宿主已有受控的本地执行能力,CLI 可以直接工作;若需要统一的工具发现、参数描述或跨宿主接入,再增加协议适配。无论外层怎么变化,都应复用同一套输入检查、执行逻辑和产物验收,不要重新实现一份有不同语义的转换器。

Agent 友好的 CLI,不是替模型隐藏所有复杂性,而是把复杂性变成可判定的契约。

参数明确,调用才有边界;错误可区分,恢复才有依据;产物可回读,“完成”才不是一句未经核实的宣告。

参考资料

  1. CLI-Anything:HARNESS 方法论
  2. Audacity 适配:CLI 导出入口
  3. Audacity 适配:render_mix 实现
  4. FFmpeg 官方文档
  5. ffprobe 官方文档
  6. Python subprocess 官方文档

💬 评论

主题
字体
密度
语言