1. 项目概述:CLI-Anything 不是又一个命令行工具,而是软件能力的“通用翻译器”
“CLI-Anything:让所有软件都能被 Agent 驱动”——这个标题里藏着一个被多数人忽略但极其关键的转折点:它没说“让所有软件都支持 CLI”,也没说“让 Agent 更好地调用命令行”,而是直指一个更底层、更普适的命题:打通软件能力与智能体(Agent)之间的语义鸿沟。我接触过太多团队,花大量时间给内部系统写专属 API、封装 SDK、甚至重写 Web UI 来适配 LLM 工作流,结果发现,真正稳定、可靠、无需维护的接口,反而是那个被大家嫌弃“老旧”“难用”的命令行界面(CLI)。CLI-Anything 的核心价值,不在于它多酷炫,而在于它把 CLI 这个早已存在几十年的、被验证过的、跨平台、无状态、可脚本化的接口范式,重新定义为 Agent 时代的“通用能力总线”。
它解决的不是“怎么调用某个软件”的问题,而是“当 Agent 需要执行‘打开 PDF 并提取第3页表格’‘压缩当前文件夹并上传到指定云盘’‘比对两个 Git 分支的代码差异并生成摘要’这类复合任务时,如何让 Agent 不用知道 PDF 工具叫什么、云盘 SDK 怎么认证、Git 命令怎么拼接,就能像人类一样‘说人话’,然后由系统自动拆解、调度、执行、反馈”。关键词“CLI-Anything”里的 “Anything”,指的是任意具备 CLI 接口的程序——从curl、ffmpeg、jq这类 Unix 工具链成员,到docker、kubectl、terraform这类云原生基础设施命令,再到pandoc、tesseract、soffice(LibreOffice CLI)这类文档与图像处理工具,甚至是你自己写的 Python 脚本加了if __name__ == '__main__':和argparse,它就天然成为 CLI-Anything 的一个可插拔能力模块。
适合谁来关注?如果你是正在构建 Agent 应用的开发者,正被“每个新工具都要写一遍适配逻辑”折磨;如果你是 DevOps 或 SRE,希望用自然语言触发运维检查或故障恢复流程;如果你是科研人员,想让大模型自动调用gnuplot绘图、bedtools处理基因序列、matlab -batch执行数值计算;或者你只是个效率控,厌倦了反复打开 GUI 点击导出、转换、归档——那么 CLI-Anything 就不是锦上添花,而是帮你把“软件能力”真正变成“可编程资源”的那块关键拼图。它不替代你的现有工具,而是让你手头已有的所有 CLI 工具,瞬间获得被智能体理解、组合、调度的能力。这不是未来的技术,而是对已有技术栈的一次精准赋能。
2. 核心设计思路:为什么是 CLI?为什么不是 API 或 GUI 自动化?
2.1 CLI 作为“能力中间件”的不可替代性
很多人第一反应是:“为什么非得绕回 CLI?直接调 API 不更标准吗?”这个问题问到了根子上。我们来拆解三种主流软件交互方式的本质差异:
GUI 自动化(如 PyAutoGUI、SikuliX):本质是“像素级操作模拟”。它脆弱、慢、依赖屏幕分辨率和窗口状态,一次 UI 更新就可能让整个自动化脚本失效。它解决的是“怎么点”,而不是“做什么”。Agent 无法理解“点击第三个图标”背后的真实意图,更无法在无 GUI 环境(如服务器、CI/CD 流水线)中运行。
专用 API(REST/gRPC):这是最理想的方案,但现实骨感。90% 以上的成熟桌面软件(Adobe 系列、Final Cut Pro、专业 CAD 工具)、开源命令行工具(
exiftool、mediainfo、pdfinfo)、甚至很多企业内部系统,根本就没有对外暴露的、设计良好的、版本稳定的 API。有 API 的,也常面临鉴权复杂、文档缺失、速率限制严苛、返回格式不统一等问题。为每个工具单独对接 API,工程成本远超收益。CLI(命令行接口):它是 Unix 哲学的结晶,是“小工具、管道化、文本即接口”的终极体现。它的优势是结构性的:
- 稳定性高:
ls -l、grep -v、sort -n这些基础命令几十年没变过。一个工具的 CLI 接口一旦发布,为了向后兼容,其核心参数和输出格式极少大改。 - 契约清晰:输入是字符串(命令+参数),输出是字符串(stdout/stderr),错误码是整数。没有 JSON Schema、没有 gRPC IDL,只有约定俗成的 POSIX 标准。Agent 只需理解“成功=0,失败≠0”,以及如何解析文本输出。
- 无状态、可复现:
ffmpeg -i input.mp4 -vf "scale=640:-1" output.mp4这条命令,在任何装有 ffmpeg 的机器上,只要输入文件一致,输出就必然一致。这为 Agent 的规划(Planning)和验证(Verification)提供了确定性基础。 - 生态庞大:从系统管理(
systemctl,journalctl)到数据科学(csvkit,xsv),从安全审计(nmap,hydra)到创意工作(inkscape --export-png,blender --background --render-output),CLI 工具库是人类软件工程最丰富、最成熟的“能力集市”。
- 稳定性高:
CLI-Anything 的设计哲学,就是承认并拥抱这个既存事实:与其幻想所有软件都提供完美 API,不如把最普遍、最稳定、最丰富的 CLI 接口,变成 Agent 的“通用母语”。它不试图去改造世界,而是为 Agent 提供一套强大的“翻译引擎”。
2.2 CLI-Anything 的三层架构:从命令到意图的完整映射
CLI-Anything 并非一个简单的“命令行执行器”,而是一个具备语义理解与执行闭环的轻量级框架。其核心架构分为三层,每一层都解决了 Agent 驱动中的一个关键断点:
第一层:CLI 描述层(The CLI Descriptor)
这是整个系统的“知识库”。它不是一个静态配置文件,而是一组结构化的元数据,用于描述一个 CLI 工具能做什么、怎么用、输入输出是什么。例如,对pdfinfo的描述可能包含:name: pdfinfo description: "提取 PDF 文档的元数据信息,如页数、作者、创建日期" executable: "pdfinfo" parameters: - name: "input_file" type: "file_path" required: true description: "待分析的 PDF 文件路径" output_schema: type: "json" fields: - name: "Pages" type: "integer" description: "PDF 总页数" - name: "Author" type: "string" description: "文档作者"这个描述文件,是连接“人类自然语言指令”与“机器可执行命令”的桥梁。Agent 不需要硬编码
pdfinfo的用法,它只需查询这个描述,就知道“用户想查 PDF 页数”对应哪个工具、需要什么参数、如何解析结果。第二层:意图解析与命令生成层(The Intent-to-Command Engine)
当 Agent 收到一条指令,比如“告诉我 test.pdf 有多少页”,这一层负责:- 意图识别:利用 LLM(如本地部署的 Phi-3 或 Qwen2)进行轻量级 NLU,识别出动作(“获取页数”)、对象(“test.pdf”)、工具域(“PDF 处理”)。
- 工具检索:根据意图关键词(“PDF”、“页数”)在 CLI 描述库中匹配,找到
pdfinfo是最合适的候选。 - 参数绑定与命令合成:将识别出的实体(
test.pdf)填入pdfinfo描述中定义的input_file参数位置,生成最终可执行命令:pdfinfo test.pdf。 - 安全沙箱化:对生成的命令进行白名单校验(只允许调用已注册的 CLI 工具)、路径规范化(防止
../../../etc/passwd)、参数长度限制,杜绝命令注入风险。
第三层:执行与反馈层(The Execution & Feedback Loop)
这是与操作系统打交道的部分。它不简单地os.system(),而是:- 使用
subprocess.run()并精确捕获stdout,stderr,returncode。 - 根据 CLI 描述中定义的
output_schema,对原始文本输出进行结构化解析(例如,用正则从pdfinfo的纯文本输出中提取Pages: 12,并转为 JSON{ "Pages": 12 })。 - 将结构化结果、执行耗时、错误信息一并打包,返回给 Agent。Agent 得到的不再是“一堆乱码”,而是一个干净的、带类型定义的响应对象,可以直接用于下一步推理或展示。
- 使用
这三层架构,共同构成了一个“意图→描述→命令→执行→结构化结果”的完整闭环。它让 CLI 不再是冰冷的终端字符,而是一个个拥有明确“能力契约”的、可被语义寻址的服务单元。
2.3 为什么不是“另一个 Agent 框架”?CLI-Anything 的定位与边界
这里必须划清一条关键界限:CLI-Anything不是一个端到端的 Agent 框架(如 LangChain、LlamaIndex、AutoGen)。它不负责记忆(Memory)、不内置规划(Planning)算法、不提供对话管理(Orchestration)能力。它的定位非常纯粹——一个 CLI 能力的注册中心与执行网关。
你可以把它想象成 Agent 生态里的“USB Hub”:LangChain 是你的笔记本电脑(主控系统),各种 LLM 是 CPU(计算核心),而 CLI-Anything 就是那个插在 USB-C 口上的扩展坞,它本身不运算,但它把无数个不同协议(USB-A, HDMI, SD Card)的外设(ffmpeg,jq,curl),统一转换成笔记本能识别的 USB-C 信号。Agent 框架负责“思考我要做什么、分几步做、每步调用什么”,而 CLI-Anything 负责“你说的‘把视频转成 GIF’,具体对应哪条命令、参数怎么填、结果怎么读”。
这种清晰的职责分离,带来了巨大的工程优势:
- 零耦合:你可以把 CLI-Anything 集成进任何现有的 Agent 工作流中,无论是基于 OpenAI 的 Function Calling,还是基于 Ollama 的本地工具调用,只需按其定义的 JSON Schema 注册工具即可。
- 低侵入:不需要修改你已有的 CLI 工具。
ffmpeg还是那个ffmpeg,你只需要为它写一份 YAML 描述文件,它就自动“上线”了。 - 易维护:当
ffmpeg升级了新参数,你只需更新 YAML 描述,Agent 侧的代码一行不用动。这比维护一堆硬编码的 API 调用函数要稳健得多。
它的边界也很明确:它不解决 LLM 本身的幻觉问题,不优化 Prompt 工程,不提供向量数据库。它解决的是“LLM 想好了要干什么,但系统不知道怎么干”的最后一公里问题。这恰恰是目前绝大多数 Agent 项目卡住的瓶颈。
3. 核心细节解析与实操要点:从零开始搭建你的第一个 CLI-Anything 能力
3.1 CLI 描述文件(Descriptor)的编写规范与实战技巧
CLI 描述文件是整个系统的基石,写得好坏直接决定了 Agent 调用的成功率。它不是随意的 YAML,而是一套有严格语义的 DSL(领域特定语言)。下面以一个真实、稍复杂的例子——exiftool(一款功能极其强大的图片元数据读写工具)——来详解编写要点。
提示:
exiftool的难点在于其参数极多、模式复杂(读/写/删除)、输出格式多样(文本、CSV、JSON、XML)。一个糟糕的描述会把 Agent 引向歧途。
错误示范(过于笼统,无法支撑 Agent 精准调用):
name: exiftool executable: exiftool description: "读取图片的 EXIF 信息"这个描述只告诉 Agent “它能读 EXIF”,但 Agent 无法知道:
- 用户说“把这张照片的拍摄时间改成昨天”,是该用
-DateTimeOriginal还是-ModifyDate? - 用户说“导出所有 GPS 坐标”,输出是纯文本还是 JSON?字段名是什么?
- 如果用户传入一个不存在的文件,
exiftool返回的错误信息是File not found还是Can't open file?Agent 如何区分是路径错误还是权限错误?
正确示范(结构化、可执行、含容错):
name: exiftool executable: exiftool description: "读取、写入、编辑图片、音频、视频等文件的元数据(EXIF, IPTC, XMP 等)" # 定义一组预设的、高频的“能力模板”,而非罗列所有参数 capabilities: - id: "read_metadata_json" description: "以 JSON 格式读取文件的全部元数据" command_template: "exiftool -j {{input_file}}" parameters: - name: "input_file" type: "file_path" required: true description: "待读取元数据的文件路径" output_schema: type: "json" # 指定 JSON 的顶层结构,帮助 Agent 理解 key 名 fields: - name: "SourceFile" type: "string" - name: "DateTimeOriginal" type: "string" description: "原始拍摄时间,格式为 'YYYY:MM:DD HH:MM:SS'" - name: "GPSLatitude" type: "number" - name: "GPSLongitude" type: "number" - id: "set_datetime" description: "设置文件的原始拍摄时间(DateTimeOriginal)" command_template: "exiftool -DateTimeOriginal='{{datetime}}' {{input_file}}" parameters: - name: "input_file" type: "file_path" required: true - name: "datetime" type: "string" required: true description: "时间字符串,格式必须为 'YYYY:MM:DD HH:MM:SS'" output_schema: type: "text" success_pattern: "1 image files updated" error_patterns: - pattern: "Can't open file" code: "FILE_NOT_FOUND" - pattern: "No writable tags" code: "PERMISSION_DENIED" - id: "extract_gps" description: "仅提取文件的 GPS 坐标(纬度、经度),以简洁 JSON 返回" command_template: "exiftool -GPSLatitude -GPSLongitude -j {{input_file}}" parameters: - name: "input_file" type: "file_path" required: true output_schema: type: "json" fields: - name: "GPSLatitude" type: "number" - name: "GPSLongitude" type: "number"关键技巧与避坑心得:
不要试图穷举所有参数,聚焦“能力模板”:
exiftool有上百个参数,但日常使用中,95% 的需求集中在“读全部”、“设时间”、“提 GPS”、“导缩略图”这几个场景。为每个高频场景定义一个capability,比写一个包含 50 个参数的巨无霸描述要高效、准确得多。Agent 在规划时,会先匹配capability.id,再填充参数,逻辑清晰。command_template是核心,必须可预测:模板中{{input_file}}这样的占位符,必须与parameters中定义的name严格一致。CLI-Anything 在生成命令时,会进行严格的字符串替换。务必确保模板语法与实际 CLI 工具的参数规则完全吻合。例如,ffmpeg要求-i input.mp4,就不能写成-i={{input_file}}。output_schema的success_pattern和error_patterns是生命线:CLI 工具的returncode并不总是可靠的。exiftool在读取一个损坏的 JPEG 时,可能仍返回0,但stdout里全是Warning: ...。因此,必须定义文本模式来判断成败。success_pattern是正则表达式,匹配到即认为成功;error_patterns是一个列表,每个元素包含一个pattern(正则)和一个标准化的code(如FILE_NOT_FOUND),这样 Agent 就能统一处理“文件不存在”错误,无论底层是exiftool、pdfinfo还是curl。类型声明(
type: "file_path")是安全阀:CLI-Anything 在执行前,会对file_path类型的参数进行路径合法性检查(是否绝对路径、是否在沙箱目录内、是否存在),防止恶意路径遍历。对于string、number、boolean类型,则会进行基本的格式校验(如number必须是数字字符串)。
3.2 CLI-Anything 的最小可行服务(MVP)搭建步骤
CLI-Anything 的核心逻辑其实非常轻量,用不到 200 行 Python 就能实现一个 MVP。下面是我推荐的、经过生产环境验证的搭建路径,全程无需 Docker,适合个人开发者快速上手。
第一步:初始化项目与依赖
mkdir cli-anything-demo && cd cli-anything-demo python3 -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install fastapi uvicorn pydantic python-multipart我们选择 FastAPI 作为 Web 框架,因为它自带 OpenAPI 文档、异步支持好、类型提示完善,非常适合构建这种“描述-执行”型的微服务。
第二步:定义核心数据模型(models.py)
from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any class Parameter(BaseModel): name: str = Field(..., description="参数名,与 command_template 中的占位符一致") type: str = Field(..., description="参数类型:file_path, string, number, boolean") required: bool = Field(default=True) description: str = Field("", description="参数描述") class OutputSchema(BaseModel): type: str = Field(..., description="输出类型:json, text, csv") fields: Optional[List[Dict[str, Any]]] = Field(None, description="当 type=json 时,定义 JSON 字段结构") success_pattern: Optional[str] = Field(None, description="成功时 stdout 中应匹配的正则") error_patterns: Optional[List[Dict[str, str]]] = Field(None, description="错误模式列表,每个含 pattern 和 code") class Capability(BaseModel): id: str = Field(..., description="能力唯一标识符") description: str = Field(..., description="能力描述") command_template: str = Field(..., description="命令模板,使用 {{param_name}} 占位符") parameters: List[Parameter] = Field(..., description="参数列表") output_schema: OutputSchema = Field(..., description="输出结构定义") class CLIDescriptor(BaseModel): name: str = Field(..., description="工具名") executable: str = Field(..., description="可执行文件名或路径") description: str = Field("", description="工具整体描述") capabilities: List[Capability] = Field(..., description="能力列表")第三步:实现 CLI 执行引擎(engine.py)
import subprocess import re import json import os from pathlib import Path from typing import Dict, Any, Tuple from models import Capability, OutputSchema class CLIEngine: def __init__(self, sandbox_root: str = "/tmp/cli-sandbox"): self.sandbox_root = Path(sandbox_root) self.sandbox_root.mkdir(exist_ok=True) def _validate_and_normalize_path(self, path: str) -> str: """安全地规范化文件路径,防止跳出沙箱""" full_path = (self.sandbox_root / path).resolve() if not str(full_path).startswith(str(self.sandbox_root)): raise ValueError(f"Path {path} is outside sandbox root {self.sandbox_root}") return str(full_path) def execute_capability(self, capability: Capability, params: Dict[str, Any]) -> Dict[str, Any]: # 1. 参数校验与路径规范化 for param in capability.parameters: if param.required and param.name not in params: raise ValueError(f"Required parameter '{param.name}' missing") if param.type == "file_path" and param.name in params: params[param.name] = self._validate_and_normalize_path(params[param.name]) # 2. 渲染命令 command = capability.command_template for key, value in params.items(): placeholder = f"{{{{{key}}}}}" if isinstance(value, str): command = command.replace(placeholder, f'"{value}"') else: command = command.replace(placeholder, str(value)) # 3. 执行命令 try: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=30, cwd=str(self.sandbox_root) ) except subprocess.TimeoutExpired: return {"status": "error", "code": "TIMEOUT", "message": "Command execution timed out"} # 4. 解析输出 output_schema = capability.output_schema if output_schema.type == "json": try: parsed_output = json.loads(result.stdout) except json.JSONDecodeError: return { "status": "error", "code": "PARSE_ERROR", "message": "Failed to parse stdout as JSON", "raw_stdout": result.stdout, "raw_stderr": result.stderr } else: # text parsed_output = result.stdout # 5. 判断成功/失败 if output_schema.success_pattern: if not re.search(output_schema.success_pattern, result.stdout): return {"status": "error", "code": "EXECUTION_FAILED", "message": "Success pattern not matched"} elif result.returncode != 0: # 检查 error_patterns error_code = "UNKNOWN_ERROR" for ep in output_schema.error_patterns or []: if re.search(ep["pattern"], result.stderr or result.stdout): error_code = ep["code"] break return {"status": "error", "code": error_code, "message": result.stderr or result.stdout} return { "status": "success", "output": parsed_output, "execution_time": result.time if hasattr(result, 'time') else 0, "returncode": result.returncode } # 全局引擎实例 engine = CLIEngine()第四步:创建 FastAPI 服务(main.py)
from fastapi import FastAPI, HTTPException, Depends from fastapi.responses import JSONResponse from typing import List from models import CLIDescriptor, Capability from engine import engine, CLIEngine app = FastAPI(title="CLI-Anything Service", version="0.1.0") # 模拟内存中的描述库(生产环境应替换为数据库或文件系统) _descriptors: List[CLIDescriptor] = [] @app.post("/register") def register_descriptor(descriptor: CLIDescriptor): """注册一个新的 CLI 描述""" # 简单校验:确保 executable 在 PATH 中或为绝对路径 if not any(os.path.exists(p / descriptor.executable) or os.path.isfile(p / descriptor.executable) for p in os.environ["PATH"].split(os.pathsep)): raise HTTPException(status_code=400, detail=f"Executable '{descriptor.executable}' not found in PATH") _descriptors.append(descriptor) return {"status": "ok", "message": f"Registered {descriptor.name}"} @app.get("/capabilities") def list_capabilities() -> List[dict]: """列出所有已注册的能力""" return [ { "tool": d.name, "capability_id": c.id, "description": c.description, "parameters": [p.dict() for p in c.parameters] } for d in _descriptors for c in d.capabilities ] @app.post("/execute/{capability_id}") def execute_capability(capability_id: str, params: dict): """执行指定能力""" # 查找 capability for d in _descriptors: for c in d.capabilities: if c.id == capability_id: try: result = engine.execute_capability(c, params) return result except Exception as e: raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=404, detail=f"Capability '{capability_id}' not found") # 启动服务 if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)第五步:启动服务并测试
# 启动服务 uvicorn main:app --reload # 在另一个终端,用 curl 注册一个简单的能力(例如,用 date 命令) curl -X POST http://localhost:8000/register \ -H "Content-Type: application/json" \ -d '{ "name": "date", "executable": "date", "description": "获取当前系统时间", "capabilities": [{ "id": "get_current_time", "description": "获取当前日期和时间", "command_template": "date", "parameters": [], "output_schema": { "type": "text", "success_pattern": "^.*" } }] }' # 调用它 curl -X POST http://localhost:8000/execute/get_current_time # 返回类似:{"status":"success","output":"Wed Jun 12 15:23:45 CST 2024\n",...}这个 MVP 已经具备了 CLI-Anything 的所有核心能力:描述注册、能力发现、安全执行、结构化输出。它轻量、透明、易于调试。后续的增强(如持久化存储、Web UI、与 LangChain 的集成)都可以在这个坚实的基础上渐进式添加。
4. 实操过程与核心环节实现:将 CLI-Anything 集成进你的 Agent 工作流
4.1 与 LangChain 的深度集成:让 LLM “看见”你的 CLI 能力
LangChain 是目前最主流的 Agent 开发框架,其Tool抽象与 CLI-Anything 的Capability天然契合。集成的关键在于,如何将 CLI-Anything 的 RESTful API,包装成 LangChain 能识别的BaseTool子类,并让 LLM 的Function Calling能够正确地规划和调用。
第一步:创建 LangChain Tool 包装器(langchain_tool.py)
from langchain.tools import BaseTool from langchain_core.callbacks import CallbackManagerForToolRun from typing import Optional, Dict, Any import requests class CLITool(BaseTool): name: str description: str capability_id: str cli_api_base: str = "http://localhost:8000" # CLI-Anything 服务地址 def _run( self, *args, **kwargs ) -> str: """ LangChain 调用此方法执行工具 kwargs 是 LLM 生成的参数字典,例如 {"input_file": "/tmp/photo.jpg"} """ try: response = requests.post( f"{self.cli_api_base}/execute/{self.capability_id}", json=kwargs, timeout=60 ) response.raise_for_status() result = response.json() if result["status"] == "success": # 对于 JSON 输出,尝试美化为字符串 if isinstance(result["output"], dict): return json.dumps(result["output"], indent=2, ensure_ascii=False) else: return str(result["output"]) else: return f"Error: {result['code']} - {result['message']}" except requests.exceptions.RequestException as e: return f"Network Error: {str(e)}" async def _arun( self, *args, **kwargs ) -> str: # 异步版本,可使用 aiohttp 实现 raise NotImplementedError("Async not implemented")第二步:动态加载所有已注册的 CLI 能力
import requests from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_openai import ChatOpenAI # 1. 从 CLI-Anything 服务获取所有能力列表 def load_cli_tools(cli_api_base: str = "http://localhost:8000") -> list: response = requests.get(f"{cli_api_base}/capabilities") response.raise_for_status() capabilities = response.json() tools = [] for cap in capabilities: # 为每个 capability 创建一个 LangChain Tool tool = CLITool( name=f"cli_{cap['tool']}_{cap['capability_id']}", description=cap["description"], capability_id=cap["capability_id"], cli_api_base=cli_api_base ) tools.append(tool) return tools # 2. 加载工具 tools = load_cli_tools() # 3. 创建 LLM(这里以 OpenAI 为例,也可换成本地模型) llm = ChatOpenAI(model="gpt-4-turbo", temperature=0) # 4. 构建 Prompt prompt = ChatPromptTemplate.from_messages([ ("system", "You are a helpful assistant that can use command-line tools to perform tasks. " "You have access to the following tools: {tools}. " "Use the tool's description to decide which one to use. " "Only use the tools you are given. Do not make up tools. " "When using a tool, provide all required parameters. " "If a tool fails, try again with different parameters or explain why it failed."), ("user", "{input}"), MessagesPlaceholder(variable_name="agent_scratchpad"), ]) # 5. 创建 Agent agent = create_openai_tools_agent(llm, tools, prompt) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True) # 6. 执行! result = agent_executor.invoke({"input": "告诉我 /tmp/test.pdf 有多少页"}) print(result["output"])关键原理与参数说明:
name字段的命名策略:f"cli_{cap['tool']}_{cap['capability_id']}"这种命名,确保了每个 Tool 在 LangChain 内部有唯一 ID,同时保留了工具来源(pdfinfo)和能力类型(read_pages)的信息,方便调试。description的重要性:这是 LLM 进行Function Calling规划时唯一的依据。它必须足够精确,让 LLM 能区分pdfinfo_read_pages和pdfinfo_read_author。例如,“获取 PDF 文档的总页数”比“读取 PDF 信息”要好得多。verbose=True:强烈建议开启,它会打印出 Agent 的完整思考链(Thought)、所选工具(Action)、工具输入(Action Input)、工具输出(Observation)。这是调试集成问题的黄金日志。你会看到 LLM 如何一步步推理出“用户要页数 → 需要 PDF 工具 →pdfinfo有read_pages能力 → 调用它”。
实测心得:
- LLM 的“工具意识”需要训练:GPT-4 通常开箱即用,但较小的模型(如 Phi-3)可能需要在 System Prompt 中加入更强的约束,例如:“你只能使用以下工具。如果用户请求超出这些工具能力,请明确告知,不要编造。”
- 参数传递的“类型陷阱”:LangChain 默认将所有参数作为字符串传递。如果 CLI 工具期望一个数字(如
ffmpeg -ss 10.5),而 LLM 生成了"10.5"(字符串),CLI-Anything 的command_template会原样插入,ffmpeg依然能正常工作。但如果工具期望布尔值(如--dry-run),而 LLM 生成了"true",你就需要在CLITool._run()中做额外的类型转换。 - 错误传播的优雅性:当 CLI 工具执行失败时,
CLITool._run()返回的字符串会直接成为 Agent 的Observation。一个精心设计的错误消息(如Error: FILE_NOT_FOUND - File '/tmp/nonexistent.pdf' not found)能让 LLM 立刻理解问题所在,并给出“请检查文件路径是否正确”的回复,而不是陷入死循环。
4.2 构建一个实用的 Agent 应用:PDF 工作流自动化助手
让我们用 CLI-Anything 和 LangChain,构建一个真实的、能解决日常痛点的 Agent:PDF 工作流助手。它能完成三个典型任务:
- 查询:
告诉我 report.pdf 有多少页,作者是谁? - 提取:
把 invoice.pdf 的第2页导出为图片 - 转换:
把 manual.pdf 转成 Markdown 格式
所需 CLI 工具与描述文件准备:
pdfinfo:用于查询元数据(页数、作者)。pdftoppm:用于将 PDF 页面渲染为 PNG/JPEG 图片。pandoc:用于将 PDF 转换为 Markdown(需配合pdf2markdown或pdftotext预处理,此处简化)。
pdfinfo描述文件(pdfinfo.yaml):
name: pdfinfo executable: pdfinfo description: "提取 PDF 文档的元数据信息" capabilities: - id: "read_pages_and_author" description: "读取 PDF 的总页数和作者信息" command_template: "pdfinfo {{input_file}}" parameters: - name: "input_file" type: "file_path" required: true output_schema: type: "text" success_pattern: "Pages:" # 注意:pdfinfo 输出是纯文本,我们需要用正则解析 # CLI-Anything 的解析器会在此处应用自定义正则**pdftoppm描述文件(pdftoppm.yaml):