【AIAgent开发】-Agent工具封装成CLI(纯干货!!!)

面试官:函数套个 CLI 就算解耦了?Agent 工具执行流程封装 CLI 的完整工程实践

面试官敲了敲桌子:「你说把 Agent 的工具执行流程封装成 CLI,那不就是给函数套个命令行?换个入口,就算解耦了?」

这个反问很扎人,因为它戳中了一个真实的分歧:大部分人说的「CLI 封装」,其实只是给 main() 加了个 argparse,业务代码该依赖会话还是依赖会话,该读全局配置还是读全局配置。入口换了,耦合一点没动。

但这不代表 CLI 封装是伪命题。恰恰相反,Agent 工程里这几年最实在的几次转向------从 function calling 到 MCP,从 MCP 再到「让模型直接写命令」------都围绕着同一件事:工具的执行路径,到底该不该和聊天进程绑在一起。

这篇文章把这件事讲透:

  • 三个真实翻车现场,以及它们共同的根因;
  • 把「能力、入口、执行环境」拆成三层,说清 CLI 到底解决了什么、没解决什么;
  • 四年演进史:从 function calling 到代码执行,每次转向的动因;
  • 一个可以直接跑起来的 Python 工具,从业务核心、CLI 入口、执行适配器到契约测试,五步写完;
  • 给机器用的 CLI,那 12 条不写在文档里就一定会踩的规则;
  • 五种做法的横向对比表,以及什么情况下根本不该封;
  • 十道面试高频题,附答法。

代码都在本机跑过,输出贴在文中。看完全文,你应该能把自己项目里那个「只能在 Agent 里跑」的函数,拆成一个既能给模型调、也能进 CI 的独立程序。

文章目录

一、痛点场景:三个现场,同一个根因

先看三个现场。它们发生在不同团队、不同业务,但排查到最后,都撞在同一堵墙上。

现场一:Agent 报「报表生成失败」,重放十二次,每次报错都不一样

灰度发布后,运营在对话框里让 Agent 生成一份周报,返回一句「工具执行失败,请重试」。

工程师的第一反应是模型不行,换了个更大的模型,还是失败。于是开始重放:把同一段对话从头跑一遍,第一次报「文件不存在」,第二次报「权限不足」,第三次居然成功了。

查了两天才发现:工具函数是这样写的------

python 复制代码
def generate_report():
    # 从当前会话的全局上下文里取路径
    path = agent_context.session.workspace.last_file
    return build_report(path)

它没有参数。它要的东西,全部从「当前会话」这个隐式的全局状态里捞。而会话里 last_file 指向哪个文件,取决于模型上一轮说了什么。所以「同一个输入」根本不存在,重放十二次就是十二个不同的输入。

根因不是模型选错了工具,是这个函数压根没法被单独调用一次。 你想复现,先得复现一整段对话。

现场二:运营要批量跑 300 个文件,接口加不出来

业务方提了个很朴素的需求:把这 300 份合同批量转成 PDF,一次性跑完。

工程师想加个批量接口,打开代码发现加不了:转换逻辑里读了三样东西------会话里的用户身份、Agent 进程里初始化的模型连接、还有一个从环境变量里取的临时目录。要在脚本里跑,就得把这三样都模拟一遍。

最后写了 200 行胶水代码,把 Agent 的运行时初始化搬进脚本,跑是跑通了,但从此这份逻辑有了两个版本。三个月后修了一个格式 bug,只改了其中一份,另一份继续产出错误的 PDF,谁也不知道。

根因不是「缺一个批量接口」,是这份能力从来没有过独立的执行入口。

现场三:CI 日志写着 success,产物目录是空的

流水线里加了一步「用 Agent 工具做数据校验」。日志最后一行是 check finished successfully,绿了三个月。

直到有一次发布后发现,校验用的数据文件根本没被读进去------工具启动时找不到文件,打印了一句提示,然后用空数据集「成功」跑完了。

去看代码:

python 复制代码
print("check finished successfully")   # 无论发生什么都打印这句
return 0                                # 无论发生什么都返回 0

根因不是 CI 配置错了,是「成功」这件事从来没有被定义过。 没有退出码语义,没有产物校验,调用方只能去读一句人写的自然语言。

三个现场的共同点

把它们并排放,会发现三个「第一反应」都指向了错误的方向:

现场 你的第一反应 真实原因
报表失败 模型太笨,换个大模型 函数从会话全局读参数,没有明确输入
批量需求 加个批量接口 业务逻辑依赖运行时,接口加不出来
CI 假绿 偶发问题,重跑一次 没有退出码与产物校验,成功无法被判断

三个根因,其实是同一件事的三个侧面:工具和它的调用方粘在了一起。 能力、入口、执行环境,三样东西糊成一团。你想只换其中一个,就必须把另外两个一起搬走。

这就是 CLI 封装真正要处理的问题。不是「加个命令行」,是把这三层拆开。

二、它是什么:能力、入口、执行环境

先给结论,再拆开讲。

2.1 三层各管什么

第一层,工具能力。 真正干活的那部分:解析文档、查询数据、转换格式、写入文件。它应该是一个纯粹的输入输出映射------给它明确的参数,它返回明确的结果。

第二层,调用入口。 规定「请求怎么交进来,结果怎么拿回去」。函数调用是入口,命令行是入口,HTTP 接口是入口,MCP 工具也是入口。它们之间的差别只在形状:函数用进程内对象传参,CLI 用字符串参数和退出码,HTTP 用请求体和状态码。

第三层,执行环境。 决定这段代码「能碰到什么、能消耗多少、以什么身份跑」:进程属于哪个用户、能访问哪些目录、能不能连网、最多跑多久。

CLI 封装动的是第二层,顺带改善了第三层的可控性------因为有了进程边界,你能给它单独设工作目录、单独设环境变量、单独设超时。

但请注意:CLI 不会自动改变第三层的权限本质。 子进程默认继承父进程的 uid,这一点后面会单独讲。

2.2 三个常见误解

面试里被追问的时候,这三个误解最容易暴露。

误解一:「CLI 一定要有人在终端里敲。」

不是。CLI 是一种程序接口。人可以在终端里敲,另一个程序也可以直接 fork + exec 拉起它。服务器上跑的那些命令,99% 都没有「人」在场。

误解二:「CLI 就是 shell。」

不是,这两个东西经常被混为一谈,但差得很远:

  • CLI 程序 自己解析参数,--input a.csv 里的 a.csv 是一个值,不会被解释成命令;
  • shell 是命令解释器,它处理管道 |、重定向 >、变量展开 $HOME、通配符 *。

一个安全的调用方式是:把「可执行文件」和「参数列表」分开交给进程启动接口,不经过 shell 。这样用户输入里的 ; && $() 都只是普通字符。

python 复制代码
# 安全:参数是列表,shell 不参与解释
subprocess.run([sys.executable, "-m", "report_tool.cli", "summary", "--input", user_input])

# 危险:整段拼成命令字符串,shell 会解释里面的元字符
os.system(f"report-tool summary --input {user_input}")

第二种写法,当 user_input 是 a.csv; rm -rf ~ 时,你就知道后果了。

误解三:「CLI 封装就是给函数加个 main。」

这是本文开头那个面试官质问的版本。加 main 只是加了一层皮,如果函数内部还是从会话里读路径、从全局拿配置、要求先初始化模型连接,那这层皮没有解决任何问题。

真正的封装要做的是:把能力从运行时的上下文里剥离出来,让它只依赖显式传入的参数。

2.3 大白话和生活案例

用一个食堂打比方。

工具能力是后厨会做的菜------红烧肉、番茄炒蛋。这是本事,跟窗口无关。

调用入口 是打饭窗口。窗口要有菜单(参数的说明)、要能报菜名(调用)、要能拿到餐盘(返回值)、要能告诉你「今天这个菜卖完了」(错误)。食堂可以开三个窗口:一个给堂食,一个给外卖骑手,一个给夜班留餐。菜是同一锅,窗口有三个。

执行环境是后厨的钥匙串。厨师能进哪个冷库、能用多少燃气、几点必须下班------这些跟开几个窗口完全没关系。你把窗口从两个加到五个,厨师的钥匙一串没多,冷库还是那个冷库。

那么「把工具封装成 CLI」相当于什么?

相当于给这道菜开一个独立的出餐口:不经过堂食大厅,外卖骑手可以直接来取;厨师长想尝味道,也可以直接来这个口子拿一份;质检想抽查,同样走这里。

以前是什么样?以前这道菜只能从「堂食大厅」出,而且必须由一个服务员(Agent 会话)下单。骑手想拿菜,得先假装成顾客坐进去,点一桌菜,再从桌上把那一盘端走。这就是现场二里那 200 行胶水代码。

而所有人都容易搞错的一点是:多开一个出餐口,不等于后厨就安全了。 厨师还是那串钥匙。要限制他能进哪个冷库,得换锁,不是加窗口。

三、为什么用:五个收益,和三个它给不了的东西

收益一:一份能力,多个调用方

这是最直接的收益。同一份业务核心,可以同时被四类东西调用:

  • Agent 的工具适配器,拉起子进程;
  • 开发者自己,在终端里手工敲命令;
  • 定时任务与调度平台;
  • CI 流水线。

衡量「解耦」成没成,标准很朴素:换一个调用方,要不要复制一遍代码。 如果每加一个调用方就多一份实现,那不叫解耦,叫分叉。

分叉的代价不是当下的 200 行胶水,是三个月后的那次 bug 修复只改了一份。

收益二:把「模型的决策」从故障现场摘出去

这条最被低估。

Agent 任务失败,可能是三种原因:模型选错了工具、宿主传错了参数、工具内部真的坏了。如果这三件事都发生在同一次聊天里,你没法只查其中一件。

有了独立入口,排查顺序变成:

text 复制代码
先用保存下来的参数,手工跑一遍命令
  ├─ 命令成功 → 工具没问题,问题在「模型选工具」或「宿主传参数」
  └─ 命令失败 → 工具自己的问题,跟模型无关,可以单测复现

一次二分,把排查范围砍掉一半。

注意这里有个前提:你得保存下来那次调用的参数。这就是为什么工具调用日志要把结构化参数落盘,而不是只记一句「调用了报表工具」。

也要提醒一句:独立入口降低的是「复现的门槛」,不是「让结果确定」。工具读实时数据,你还是得固定数据快照;工具用系统时间,你还是得能注入时间。否则你复现的仍然是一个移动的目标。

收益三:自动化不需要模拟聊天

定时任务、批量脚本、CI 检查,这三件事的本质都是「没有人在场,按约定跑一次,看结果决定下一步」。

如果工具只能从聊天里调,这三件事就都得先搭一个假的聊天环境。而一个命令行的调用是这样:

bash 复制代码
report-tool summary --input orders.csv --top 5 --json

调度平台不需要知道什么是 Agent,它只需要知道:退出码 0 算成功,非 0 按分类告警。这就是自动化的最低耦合。

收益四:确定的步骤写进代码,模型只做选择

「工具执行流程」经常不止一个函数,而是一串有固定顺序的步骤:检查格式 → 读取内容 → 执行转换 → 校验产物。

这四步的顺序是确定的,每次都要走。如果把它们拆成四个独立工具暴露给模型,让模型每轮重新编排,会有两个后果:一是多消耗几轮交互,二是模型可能漏掉「校验产物」这一步------而这恰恰是最重要的一步。

把它们组织成一个业务操作,模型只需要回答一个问题:什么时候该做这件事。 内部的四步由代码保证。

这里要区分清楚:这个收益来自「流程封装」,本身用函数也能做到。CLI 的额外作用,是让这个封装好的流程能被独立启动、被不同语言调用。

而流程的粒度要克制。一个命令应该有清晰的业务目的、明确的输入和结果。如果内部还需要用户做新决定,就不能为了「一步完成」偷偷猜参数------那是把不确定性藏起来,不是消除它。

举个具体的边界:本地生成文件,和把文件发布到外部系统,是两种影响完全不同的操作。前者可以默认执行,后者必须有独立入口和显式确认。 如果把它们合并成一个 convert-and-publish,模型就可能在只想转换的时候,顺手把文件发布出去了。

收益五:跨语言、跨团队

另一种语言不需要把业务代码 import 进自己的进程,只要能启动程序、读标准输出、看退出码,就能用这份工具。这是进程边界天然给的。

但别把「跨语言」听成「跨平台」。目标机器上仍然要有可执行文件、运行时和依赖。通过本地 CLI 调用,也不会自动变成远程服务调用------它只是把「代码依赖」换成了「部署依赖」。

它不会给你的三样东西

这部分比收益更重要,因为面试官往往就追问这里。

第一,权限隔离。

子进程默认继承父进程的 uid、组、capabilities。加一层 CLI,攻击面一点没变。真正的隔离要靠:受限的系统用户、容器或沙箱、seccomp、只读挂载、网络出口白名单。CLI 只是提供了「把程序放进受限环境」的那个接口。

第二,性能。

每次调用要启动运行时、加载依赖、序列化输入输出。一个 30ms 的小计算,裹上 CLI 之后可能要 300ms。高频、轻量的调用,直接函数调用更合适------或者保留常驻进程,CLI 只当控制入口。

第三,语义正确。

工具打印 status: completed 不会让未完成的任务真的完成。这一点后面用一整节讲。

四、它是怎么演进过来的

这段历史值得讲,因为它能解释「为什么现在大家都在提 CLI」,而不是把它当成一种风格偏好。

阶段一:2023 年上半年,插件式工具

早期做法很朴素:在 Prompt 里约定格式,让模型输出一段 JSON,程序用正则抠出来执行。工具是什么、参数是什么,全靠自然语言描述。

问题是显而易见的:模型经常输出不合法的 JSON,多一个逗号就解析失败,而且格式漂移严重。

阶段二:2023 年年中,function calling

结构化工具调用出现了:用 JSON Schema 描述工具的入参,模型输出结构化的调用请求而不是自由文本。这一代解决的是格式可靠性------参数类型、必填项、枚举值,第一次有了机器可校验的契约。

这也是今天绝大多数 Agent 框架的默认形态:注册一堆工具,模型在多选一里挑。

阶段三:工具目录膨胀

工具一多,新问题出现了。模型每次决策前,都要在 N 个工具里做一次分类:这个子任务该用哪个?参数该怎么填?

工具数量上升,选错率跟着上升。而且每个工具的描述、schema 都常驻在上下文里,不管这次用不用得上。Anthropic 的工程博客给过一个很具体的数字:接多个 MCP 服务之后,光是工具定义就可能吃掉十几万 token,模型还没读到用户的第一句话。

阶段四:2024 年底,MCP

MCP(Model Context Protocol)把工具从「进程内的函数」变成了「可被发现的服务」。这解决了复用与生态问题:一个工具写一次,多个客户端都能接。

但它没有解决成本和执行边界。工具定义仍然要进上下文,执行仍然在别人的进程里。

阶段五:2025 年,CLI-first 回潮

这一年出现了明显的一股反向声音。

一位在头部 Agent 产品做了两年后端的工程师公开复盘:他把多工具的 function calling 全部砍掉,只留一个 run(command) 工具,把所有能力暴露成 Unix 风格的命令。

他的理由不是「终端更好看」,而是三条很硬的观察:

  1. 模型对 shell 是「母语级」熟悉的。 它预训练时见过海量的命令行用法,git log --oneline -20 这种东西不需要 schema 解释。
  2. 工具选择的认知负担消失了。 只剩一个工具,就不存在「在 50 个工具里选错」的问题。
  3. 组合是免费的。 管道、重定向、脚本,这些几十年前就解决的问题,不需要为 Agent 重新发明一遍。

同一年,Claude Code 的成绩也印证了这条路:用 bash 加上一个文本编辑工具这么小的工具面,就能在 SWE-bench Verified 上跑到当时的最优水平。

阶段六:2025 年底,代码执行

Anthropic 在 2025 年 11 月发了一篇工程博客,给出了一组被反复引用的数字:把一个「Google Drive 到 Salesforce」的任务,从「逐个调用工具、结果回灌上下文」改成「模型写代码调用工具、只在沙箱里处理数据」,token 消耗从约 15 万降到约 2000,降幅 98.7%。

关键不是那个数字,是它揭示的机制:中间结果不必流经上下文。 处理一万行表格,模型只需要看处理后的五行摘要。

这也间接解释了为什么 CLI 更省:bash 是一个工具,一句描述。 它不像 MCP 那样把几十个 schema 常驻在上下文里,代价是模型需要自己去 --help 里摸索不熟悉的接口。

所以差异是有形状的:对模型已经很熟的动词(git、grep、curl),CLI 更便宜;对模型完全陌生的内部 API,结构化 schema 更便宜,因为省掉了试错。 这条曲线是 U 形的,不是单调的。

阶段七:2026 年,CLI 进 CI

最近这一年的变化更务实:Agent 的定义开始进 YAML、进 Git、进流水线。工具是可执行文件,带语义化版本,能单独发版、单独回滚。

到这,CLI 不再只是「模型的一个工具」,而是整个工程体系里的一个可交付物。

三条主线

把七年压成三句话:

  • 接口形态:自由文本 → 结构化 schema → 命令文本。不是回到原点,是发现「文本 + 强契约」比「结构化 + 弱契约」更好维护。
  • 上下文成本:从「所有 schema 常驻」到「按需加载」,再到「中间结果不进上下文」。
  • 执行边界:从「和 Agent 同进程」到「独立进程」,再到「独立沙箱」。

CLI 封装正好同时踩中了后两条。

五、怎么用:一个能跑的工具,从 0 到 1

下面这部分是全文的重点。我们写一个真实的小工具:读取订单 CSV,按地区汇总金额。选它是因为它够简单,你能一眼看完;又够真实,四层结构一个不少。

先看整体链路,知道每一段代码在哪个位置。

注意这张图里的一个事实:从「模型说要执行」到「磁盘上真的变了」,中间隔了四次交接。 这四次里任何一次含糊,线上就会变成「看起来成功了」。

5.1 目录结构

text 复制代码
report_tool/
├── __init__.py
├── core.py             # 业务核心:纯函数,不认识 argparse,不认识 Agent
├── cli.py              # 命令行入口:解析参数、调核心、按契约输出
└── agent_adapter.py    # 执行适配器:把工具请求映射成子进程调用
toolspec.json           # 给模型看的工具说明
test_contract.py        # 契约测试:只测入口,不测业务规则
sample_orders.csv       # 示例数据

这个结构本身就是答案。core.py 里不允许出现 argparse,也不允许 import 任何 Agent 框架。 这条规则如果守住了,后面所有收益都是免费的;守不住,加几层壳都没用。

5.2 第一步:抽出业务核心

python 复制代码
"""core.py ------ 只接收明确参数,返回明确结果。"""
from __future__ import annotations

import csv
from dataclasses import asdict, dataclass
from pathlib import Path


class ToolError(Exception):
    """可预期的业务错误:调用方看得懂原因,重试没有意义。"""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code
        self.message = message


@dataclass
class RegionTotal:
    region: str
    orders: int = 0
    amount: float = 0.0


def summarize(input_path: str, top_n: int = 5) -> dict:
    """按地区汇总订单金额。

    只有两个参数,都是调用方给得出来的东西。
    不读环境变量,不读全局配置,不假设「当前会话是谁」。
    """
    path = Path(input_path)
    if not path.is_file():
        raise ToolError("input_not_found", f"找不到输入文件:{input_path}")
    if path.suffix.lower() != ".csv":
        raise ToolError("unsupported_format", f"只支持 .csv,收到 {path.suffix!r}")

    totals: dict[str, RegionTotal] = {}
    rows = 0
    with path.open("r", encoding="utf-8-sig", newline="") as fh:
        reader = csv.DictReader(fh)
        missing = {"region", "amount"} - set(reader.fieldnames or [])
        if missing:
            raise ToolError("bad_schema", f"缺少必需列:{sorted(missing)}")
        for row in reader:
            rows += 1
            region = (row.get("region") or "").strip() or "UNKNOWN"
            raw = (row.get("amount") or "").strip()
            try:
                amount = float(raw)
            except ValueError:
                raise ToolError("bad_row", f"第 {rows} 行 amount 不是数字:{raw!r}") from None
            item = totals.setdefault(region, RegionTotal(region))
            item.orders += 1
            item.amount += amount

    ranked = sorted(totals.values(), key=lambda r: r.amount, reverse=True)
    return {
        "total_rows": rows,
        "total_amount": round(sum(r.amount for r in ranked), 2),
        "regions": [asdict(r) for r in ranked[:top_n]],
        "truncated": len(ranked) > top_n,
    }

三个细节值得单独说:

第一,自定义 ToolError 并带 code。 这类错误和「程序崩了」是两回事:前者改输入就能解决,后者要修代码。把它们分开,调用方才知道该重试、该改参数、还是该找人。

第二,encoding="utf-8-sig"。 处理 CSV 时,带 BOM 的文件用 utf-8 读,第一列的列名会变成 \ufeffregion,然后你的「缺少必需列」报错会让人莫名其妙。这个坑踩过一次就记住了。

第三,raise ... from None。 让错误链干净一点:调用方关心的是「第 3 行的 amount 不是数字」,不是 float 构造函数的堆栈。

5.3 第二步:写 CLI 入口,定义契约

python 复制代码
"""cli.py ------ 只做三件事:解析参数、调用核心、按契约输出。"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

if __package__ in (None, ""):
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from report_tool.core import ToolError, summarize

# 退出码是契约的一部分:写进 --help,也写进测试
EXIT_OK = 0        # 正常结束
EXIT_FAILED = 1    # 工具内部异常(未预期)
EXIT_USAGE = 2     # 参数用错了,看 --help
EXIT_INPUT = 3     # 输入不合法或不存在,改输入再试


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="report-tool",
        description="按地区汇总订单 CSV,输出合计与 Top N 地区。只读,不修改输入文件。",
        epilog=(
            "示例:\n"
            "  report-tool summary --input orders.csv --json\n"
            "  report-tool summary --input orders.csv --top 3\n"
            "\n退出码:0 成功 / 1 内部错误 / 2 参数错误 / 3 输入非法"
        ),
    )
    sub = p.add_subparsers(dest="command", required=True)

    s = sub.add_parser("summary", help="生成地区汇总")
    s.add_argument("--input", required=True, help="输入 CSV 路径")
    s.add_argument("--top", type=int, default=5, help="返回前 N 个地区(默认 5)")
    s.add_argument(
        "--json",
        action="store_true",
        help="机器模式:stdout 只输出一个 JSON 文档,日志全部走 stderr",
    )
    return p


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)

    # 关键一行:没有 TTY 就默认机器模式
    machine = args.json or not sys.stdout.isatty()

    if args.top < 1 or args.top > 50:
        _emit_error(machine, "bad_argument", "--top 必须在 1 到 50 之间")
        return EXIT_USAGE

    try:
        result = summarize(args.input, top_n=args.top)
    except ToolError as exc:
        _emit_error(machine, exc.code, exc.message)
        return EXIT_INPUT
    except Exception as exc:  # 未预期异常:只给结论,细节留给 --debug
        _emit_error(machine, "internal_error", f"{type(exc).__name__}: {exc}")
        return EXIT_FAILED

    if machine:
        # stdout 只有一个 JSON 文档,前后不加任何字符
        json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
        sys.stdout.write("\n")
    else:
        print(f"共 {result['total_rows']} 行,合计 {result['total_amount']:.2f}")
        print(f"{'地区':<10}{'单量':>8}{'金额':>14}")
        for r in result["regions"]:
            print(f"{r['region']:<10}{r['orders']:>8}{r['amount']:>14.2f}")
        if result["truncated"]:
            print(f"仅显示前 {args.top} 个地区,用 --top 调整", file=sys.stderr)

    return EXIT_OK


def _emit_error(machine: bool, code: str, message: str) -> None:
    """错误永远走 stderr:stdout 在失败时必须是空的。"""
    if machine:
        sys.stderr.write(
            json.dumps({"error": {"code": code, "message": message}},
                       ensure_ascii=False) + "\n"
        )
    sys.stderr.write(f"report-tool: {message}\n")
    sys.stderr.write("用法见 report-tool summary --help\n")


if __name__ == "__main__":
    raise SystemExit(main())

这里有四个设计决定,每一个都对应一条踩坑经验。

决定一:错误输出走 stderr,不走 stdout。

调用方可能这样用:out=$(report-tool summary --input a.csv --json)。如果错误也写进 stdout,out 里就是一坨混着人话和 JSON 的东西,jq 直接报错。分开之后,out 在失败时是空字符串------这是一个可以被断言的强契约。

决定二:--json 不只是开关,还要在非 TTY 时自动生效。

这是最便宜、也最少人写的一行:machine = args.json or not sys.stdout.isatty()。

Agent 调用时 stdout 一定是管道,所以永远自动进机器模式。你不需要指望模型「记得加 --json」------这种指望在工程上等于没有。

决定三:raise SystemExit(main()),而不是 sys.exit(0)。

把退出码从 main() 返回,方便单测直接调用 main([...]) 断言返回值,不用起子进程。

决定四:--help 里写退出码。

一个调用方会去分支的约定,必须写在脚本作者会看的地方。抄 gh 的做法就够了:0 成功、1 通用失败、2 用法错误、4 需要认证。「设计一个更好的分类」远不如「抄一张大家已经熟悉的表」。

5.4 第三步:写执行适配器

CLI 能独立跑了,接下来是让模型用上它。这一层单独存在,是为了把「模型的请求格式」和「CLI 的参数格式」隔开。

python 复制代码
"""agent_adapter.py ------ 把工具请求映射成一次子进程调用。"""
from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

PKG_ROOT = Path(__file__).resolve().parent.parent
TOOL_CMD = [sys.executable, "-m", "report_tool.cli"]
DEFAULT_TIMEOUT = 30


class ToolRunError(Exception):
    pass


def run_summary(
    input_path: str,
    top: int = 5,
    workdir: str | None = None,
    timeout: int = DEFAULT_TIMEOUT,
) -> dict:
    # 参数一个个进列表,用户内容不进命令字符串
    cmd = [*TOOL_CMD, "summary", "--input", input_path, "--top", str(top), "--json"]

    env = {
        "PATH": os.environ.get("PATH", ""),
        "PYTHONPATH": str(PKG_ROOT),
        "PYTHONIOENCODING": "utf-8",
        "NO_COLOR": "1",
    }

    try:
        proc = subprocess.run(
            cmd,
            cwd=workdir or str(PKG_ROOT),
            env=env,                      # 白名单重建,不继承宿主密钥
            stdin=subprocess.DEVNULL,     # 绝不等待输入
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        raise ToolRunError(
            f"工具超过 {timeout}s 未返回。注意:超时不等于没执行,"
            "需要用幂等键或产物核对真实状态。"
        ) from None

    if proc.returncode != 0:
        raise ToolRunError(
            f"工具返回 {proc.returncode}({_code_meaning(proc.returncode)}):"
            f"{_parse_error(proc.stderr) or proc.stderr.strip()[:400]}"
        )

    try:
        return json.loads(proc.stdout)
    except json.JSONDecodeError:
        raise ToolRunError(
            f"stdout 不是合法 JSON,前 200 字符:{proc.stdout[:200]!r}"
        ) from None


def _code_meaning(code: int) -> str:
    return {
        1: "工具内部错误",
        2: "参数错误,不要重试,先修参数",
        3: "输入非法,改输入再试",
    }.get(code, "未知状态")


def _parse_error(stderr: str) -> str:
    """从 stderr 里取最后一行 JSON 的 message。"""
    for line in reversed(stderr.splitlines()):
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            return json.loads(line)["error"]["message"]
        except (json.JSONDecodeError, KeyError):
            continue
    return ""


if __name__ == "__main__":
    print(json.dumps(run_summary("sample_orders.csv", top=3),
                     ensure_ascii=False, indent=2))

这一层四个动作,都是硬要求:

  1. env 白名单重建。 不是 os.environ.copy()。宿主进程里可能有 OPENAI_API_KEY、AWS_SECRET_ACCESS_KEY,工具不需要这些东西,就不该拿到。
  2. stdin=DEVNULL。 工具如果等输入,Agent 场景下会一直挂到超时。宁可让它直接失败。
  3. shell=False(默认)。 参数以列表传,用户内容永远只是「一个参数的值」。
  4. 超时提示要说清「超时不等于没执行」。 这句话本身就是给下一轮模型看的:别急着重试,先查状态。

5.5 第四步:给模型的工具说明

json 复制代码
{
  "name": "report_summary",
  "description": "按地区汇总订单 CSV,返回合计金额与 Top N 地区。只读操作,不修改输入文件,可安全重试。",
  "parameters": {
    "type": "object",
    "properties": {
      "input_path": {
        "type": "string",
        "description": "工作区内 CSV 的相对路径,例如 sample_orders.csv"
      },
      "top": {
        "type": "integer",
        "minimum": 1,
        "maximum": 50,
        "default": 5,
        "description": "返回金额最高的前 N 个地区"
      }
    },
    "required": ["input_path"],
    "additionalProperties": false
  }
}

两点值得注意:

  • description 里写了「只读、可安全重试」。 这是在告诉模型:失败可以重试。如果工具是写操作,这句话就必须换成「非幂等,重试前先查状态」。工具描述不是说明书,是给调用方的行动依据。
  • additionalProperties: false。 挡住模型自由发挥的额外字段。参数面越小,出错面越小。

5.6 第五步:契约测试

契约测试只测入口这一层:stdout 干不干净、退出码稳不稳、失败时会不会留半成品。业务规则(怎么算金额)由 core 的单元测试负责。

python 复制代码
"""test_contract.py ------ 只测入口,不测业务规则。"""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SAMPLE = ROOT / "sample_orders.csv"


def run(*args: str) -> subprocess.CompletedProcess:
    # capture_output=True 意味着 stdout 是管道,不是终端
    return subprocess.run(
        [sys.executable, "-m", "report_tool.cli", *args],
        cwd=ROOT, capture_output=True, text=True, encoding="utf-8",
    )


def test_success_stdout_is_pure_json():
    p = run("summary", "--input", str(SAMPLE), "--json")
    assert p.returncode == 0, p.stderr
    doc = json.loads(p.stdout)          # 混入一行日志这里就会抛异常
    assert doc["total_rows"] == 6
    assert doc["regions"][0]["region"] == "西南"


def test_piped_stdout_auto_switches_to_json():
    # 没有 --json,但在管道里,依然应该是纯 JSON
    p = run("summary", "--input", str(SAMPLE), "--top", "2")
    assert p.returncode == 0
    doc = json.loads(p.stdout)
    assert len(doc["regions"]) == 2
    assert doc["truncated"] is True


def test_missing_input_exits_3_and_stdout_empty():
    p = run("summary", "--input", "nope.csv", "--json")
    assert p.returncode == 3
    assert p.stdout == ""               # 失败时绝不输出半成品
    lines = [ln for ln in p.stderr.strip().splitlines() if ln.startswith("{")]
    assert json.loads(lines[-1])["error"]["code"] == "input_not_found"


def test_bad_argument_exits_2():
    p = run("summary", "--input", str(SAMPLE), "--top", "0", "--json")
    assert p.returncode == 2
    assert p.stdout == ""


def test_missing_required_flag_exits_2():
    p = run("summary")
    assert p.returncode == 2


if __name__ == "__main__":
    for name, fn in sorted(globals().items()):
        if name.startswith("test_") and callable(fn):
            fn()
            print("PASS", name)

注意 test_missing_input_exits_3_and_stdout_empty 里的 assert p.stdout == ""。这一行是整个契约里最值钱的断言:它保证失败的时候,调用方不会拿到半成品数据,然后当成结果接着往下跑。

5.7 跑起来

先看机器模式。这是 Agent 视角看到的输出:

bash 复制代码
python -m report_tool.cli summary --input sample_orders.csv --json
text 复制代码
{
  "total_rows": 6,
  "total_amount": 7870.5,
  "regions": [
    { "region": "西南", "orders": 1, "amount": 3000.0 },
    { "region": "华北", "orders": 2, "amount": 2420.0 },
    { "region": "华东", "orders": 2, "amount": 2000.5 },
    { "region": "华南", "orders": 1, "amount": 450.0 }
  ],
  "truncated": false
}

退出码 0。注意这里是纯 JSON,前后没有任何杂音,jq 能直接解析。

再看错误场景:

bash 复制代码
python -m report_tool.cli summary --input nope.csv --json
echo "exit=$?"
text 复制代码
{"error": {"code": "input_not_found", "message": "找不到输入文件:nope.csv"}}
report-tool: 找不到输入文件:nope.csv
用法见 report-tool summary --help
exit=3

stdout 是空的,错误全在 stderr,退出码是 3(输入非法)。调用方不需要读那句中文,只看退出码就知道「不要重试,改输入」。

最后跑契约测试和执行适配器:

bash 复制代码
python test_contract.py
text 复制代码
PASS test_bad_argument_exits_2
PASS test_missing_input_exits_3_and_stdout_empty
PASS test_missing_required_flag_exits_2
PASS test_piped_stdout_auto_switches_to_json
PASS test_success_stdout_is_pure_json
bash 复制代码
python -m report_tool.agent_adapter
text 复制代码
{
  "total_rows": 6,
  "total_amount": 7870.5,
  "regions": [
    { "region": "西南", "orders": 1, "amount": 3000.0 },
    { "region": "华北", "orders": 2, "amount": 2420.0 },
    { "region": "华东", "orders": 2, "amount": 2000.5 }
  ],
  "truncated": true
}

到这里,四个调用方就都通了:模型走适配器,你在终端敲 python -m report_tool.cli,cron 写一行 bash,CI 里跑 test_contract.py。同一份 core.py。

顺着这个骨架换成任何业务都一样:文档转换、数据清洗、报表导出、图片处理。变的只有 core.py,cli.py 和 agent_adapter.py 几乎是模板。

六、给机器用的 CLI:12 条硬规则

这些规则大部分来自 clig.dev 那份开源指南,但那指南默认读者是「坐在终端前的人」。Agent 时代有个关键反转:主调用方变成了一个读输出的程序,而不是看屏幕的人。 所以要把几条重点改一改。

规则 具体要求 违反了会怎样
1 业务结果只写 stdout 管道里混入日志,jq 解析失败
2 日志、进度、错误只写 stderr 同上,而且错误被当成数据
3 非 TTY 时自动切 JSON 调用方忘了加 --json,拿到表格
4 退出码 0 表示成功,非 0 分类 调用方无法分支,只能读自然语言
5 失败时 stdout 保持为空 半成品被当成结果,脏数据下游扩散
6 错误信息带一个稳定的 code 文案一改,调用方的判断全废
7 非 TTY 时绝不等待输入 整轮对话挂到超时
8 破坏性操作要显式 --force 模型重试一次,破坏翻倍
9 提供 --dry-run 无法预演,只能真跑
10 默认输出量要窄,支持分页 一万行进上下文,token 爆掉
11 接口变更必须向后兼容 老脚本静默跑错
12 --help 里写清退出码含义 没人知道 3 和 4 的区别

逐条解释几句最容易被忽视的。

第 3 条:自动切 JSON。

这不是优化,是纠错。任何依赖「调用方记得加参数」的契约,在 Agent 场景下都会失败,因为模型是概率性地生成参数的。

第 6 条:错误码要稳定。

文案会改,code 不该改。invalid --status 这种错误,最好把合法值也一起返回:

json 复制代码
{"error": "invalid --status", "allowed": ["queued", "running", "failed"]}

这样模型一次就能自我修正。只给一句 invalid status,它只能去读 --help 然后猜。

第 7 条:绝不等待输入。

人在终端看到 [y/N] 会敲个 y。Agent 没有终端,它会一直等。所以:检测到非 TTY 就直接失败并说明「需要 --force」,而不是挂住。

第 8 条:破坏性操作默认关闭。

模型会盲目重试。一个默认删除的命令,会把一次网络抖动放大成反复删除。所以不可逆操作的裸调用应该直接拒绝,并告诉它该加什么参数。

当然,这条要按影响面分级:可逆的、幂等的操作(打标签、开启开关),就别加确认了,加了纯属增加摩擦。

七、三个最容易翻车的地方

7.1 退出码不是业务状态

退出码是「进程怎么结束的」,业务状态是「事情做到哪一步」。这两件事经常不一致。

一个典型的错法是:工具内部捕获了所有异常,然后统一 return 0。这样脚本永远不会失败,也就永远不会告警。

正确的关系是:退出码负责「能不能继续」,业务状态负责「做到哪了」。

python 复制代码
# 不好
try:
    do_work()
    return 0
except Exception:
    return 0          # 反正没崩,就当成功

# 好
try:
    do_work()
    return 0
except ToolError as e:
    emit_error(e.code, str(e))
    return 3          # 输入问题,改输入
except Exception as e:
    emit_error("internal_error", str(e))
    return 1          # 程序问题,要修代码

7.2 受理不等于完成

这是异步工具最常见的坑。

一个只负责「提交后台任务」的命令,返回 0 的意思是提交成功 ,不是任务完成 。如果业务状态字段写的是 accepted,而调用方把它当成 completed,下游就会去读一个还不存在的产物。

所以「完成」这件事要过三关:

  1. 进程状态:退出码是几?
  2. 业务状态 :status 字段说什么?accepted 和 completed 是两件事。
  3. 实际证据:产物真的在吗?文件可读吗?大小合理吗?异步任务查询接口返回什么?

三关都过,才算完成。少一关,都是「看起来完成了」。

这件事有个具体的写法:在结果里同时给出状态和证据。

json 复制代码
{
  "schema_version": 1,
  "status": "completed",
  "artifact": {"path": "out/report.csv", "bytes": 24810, "sha256": "9f2c..."},
  "warnings": []
}

artifact 里带 bytes 和摘要,是为了让调用方能「验证」而不是「相信」。这也是为什么产物路径必须是调用方能访问到的路径------另一个进程或容器未必共享文件系统,你给一条只有自己看得见的路径,等于没给。

7.3 超时不等于没执行

第三个坑最贵。

timeout 杀掉的只是你启动的那个入口进程。它可能已经:写了一半个文件、发出了一次 HTTP 请求、提交了一个后台任务、起了一个子进程还在跑。

所以超时之后,正确动作不是「重试一次」,而是按操作类型分流:

操作类型 超时后该怎么办
只读查询 直接重试,最多两三次
幂等写(按 key 覆盖) 可以重试,但要带同一个幂等键
非幂等写(创建订单) 先查状态,别重试
有后台任务 用任务 ID 查实际状态

另外,杀进程要杀「执行单元」,不是一个 PID。工具如果起了子进程,只杀父进程会留下孤儿进程继续跑------你会看到一个「已经超时」的任务,五分钟后把文件写出来了。

八、竞品对比:五种做法的代价

把今天能选的五种做法放一张表里。注意这里的「竞品」不是同一个品类的东西------它们解决的是同一个需求(让 Agent 用上这个能力),但代价结构完全不同。

维度 进程内函数 CLI 封装 MCP Server HTTP 微服务 裸 shell
线上复现 要整段对话 一条命令 要起一个服务 要起一个服务 能跑,但没有边界
跨语言复用 否,必须同进程 是,进程边界 是,协议边界 是,协议边界 是
上下文开销 中,schema 常驻 低,一条命令 高,schema 全量 中 最低
权限边界 无,同进程 进程 + 环境变量 进程 + 鉴权 服务端鉴权 几乎没有
能进 CI 否 是 是,但需常驻 是,但需常驻 是
版本可追溯 跟主程序走 工具可独立发版 跟服务端 跟服务端 跟脚本
起步成本 最低 低 中 高 最低
适合谁 只服务当前 Agent 的小函数 需要多入口复用的工具 跨团队共享的工具生态 强鉴权、多租户 内部调试,不对外

怎么读这张表:

  • 「上下文开销」这一行是有前提的。 对模型已经很熟的动词(git、grep、curl、ffmpeg),CLI 最省,因为它不需要 schema,模型本来就会。对模型完全陌生的内部 API,CLI 反而更贵------模型要靠 --help 摸索和反复试错。这条曲线是 U 形的。
  • MCP 不是 CLI 的替代品,是另一个层次。 CLI 解决「独立执行」,MCP 解决「工具发现与跨客户端共享」。一个工具完全可以既有 CLI,又包一层 MCP Server,两者共用同一份 core。
  • 裸 shell 是反面教材,不是选项。 给模型一个能执行任意命令的 shell,等于给它宿主的全部权限。它适合「你完全信任这个模型 + 有强沙箱」的场景,不适合生产。

选型建议,一句话版本:

工具要独立运行、跨语言、进 CI ------ 先给 CLI;要跨团队远程共享、要按用户鉴权 ------ 再加一层服务。

九、常用场景与企业项目怎么落地

9.1 五个高频场景

场景一:数据导出与报表

Agent 负责「理解要什么」,工具负责「稳定地产出」。这是最适合 CLI 化的场景,因为报表任务往往还要被运营手工跑、被定时任务跑。

bash 复制代码
report-tool summary --input data/orders.csv --top 10 --json

场景二:文档格式转换

Word 转 PDF、Markdown 转 PPT。这类操作天然是「一个输入文件 → 一个输出文件」,参数简单,副作用明确,是 CLI 的舒适区。

bash 复制代码
doc-tool convert --input notes.md --output out/notes.pdf --json

场景三:代码检查与静态分析

lint、格式检查、依赖审计。这类工具本来就是命令行程序,Agent 只需要知道怎么调、怎么看结果。这也是为什么 Claude Code 用 bash 加一个文本编辑器就能干活------生态里已经有一万个好用的 CLI 了。

bash 复制代码
ruff check src/ --output-format json

场景四:部署与运维动作

发布、回滚、扩缩容。这类操作必须极其小心,CLI 的价值在于「把危险的动词收进一个明确的入口」,而不是给模型一个万能 shell。

bash 复制代码
deploy-tool release --service api --version v1.4.2 --dry-run --json

注意那个 --dry-run。危险操作的默认行为应该是「预演」,真跑要显式加 --yes。

场景五:批量任务

一次处理几百个文件。因为有了 CLI,这件事可以交给 xargs 或一个循环,不需要 Agent 在场。

bash 复制代码
find data/ -name "*.csv" -print0 | xargs -0 -n1 -P4 report-tool summary --json

这条命令顺手解释了「为什么 Unix 组合能力是免费收益」:进程边界 + 文本流,几十年前就解决好了。

9.2 企业里,四类调用方共用一套契约

调用方 怎么调 最需要防什么
Agent 运行时 执行适配器拉子进程,白名单命令 + 超时 + 输出截断 模型传错参数
开发者与运维 手工敲同一条命令复现 参数看不懂
定时与数据任务 调度平台按契约调用,看退出码告警 失败无法分类
CI / CD 提交后跑契约测试,产物留档 结果无法断言

这张表有个隐含的要求:四类调用方必须认同一套退出码和 JSON 结构。 只要有一类不认,工具就会出现第二份实现。而第二份实现一旦出现,修 bug 就开始漏。

9.3 上线清单

把工具从「能跑」做到「敢上生产」,检查这十项:

text 复制代码
契约层
[ ] stdout 只有业务结果,非 TTY 时是纯 JSON
[ ] stderr 只有日志与错误,错误带稳定 code
[ ] 退出码分类写进 --help,并有测试覆盖
[ ] 失败时 stdout 为空,不留半成品数据

执行层
[ ] 参数以列表传递,shell=False
[ ] 环境变量白名单重建,不继承密钥
[ ] stdin 不等待;非 TTY 时不弹确认
[ ] 超时覆盖整个执行单元,能杀掉子进程

安全与运维
[ ] 危险操作默认 dry-run,真跑要显式确认
[ ] 工具版本可单独发布,契约变更向后兼容

十、面试高频题

下面这十道,覆盖了「为什么封」「怎么封」「什么时候不该封」三个层次。每题给出「怎么答」,而不只是答案------面试考的是结构。

Q1:为什么要把 Agent 的工具执行流程封装成 CLI?直接调函数不行吗?

答法:先分层,再说收益,最后给边界。

  • 工具能力、调用入口、执行环境是三件事。直接调函数把三者绑在了一起,任何一件要变,另外两件都得跟着改。
  • 封装 CLI 后,同一份能力可以被 Agent、终端、定时任务、CI 四类调用方使用,不需要复制代码。
  • 边界:只在当前进程用一次的小函数,直接调更合适。封装的价值取决于「有多少调用方需要独立运行」。

Q2:换个入口就算解耦了吗?什么才算真的解耦?

不算。 判断标准是:这个函数还有没有从运行时里捞东西。

如果它从会话读路径、从全局读配置、要求先初始化模型连接,那换入口只是换了个壳。真解耦的标志是:参数全部显式传入,返回值全部显式返回,业务核心不知道「当前会话是谁」,也不 import 任何 Agent 框架。

一个可验证的检验方式:把 core.py 单独拷到另一个空项目里,它能不能通过单元测试。

Q3:CLI 和 shell 有什么区别?为什么强调不要用 shell?

CLI 是程序,自己解析参数;shell 是解释器,处理管道、重定向、变量展开和通配符。

不要用 shell 的理由是注入:如果把用户内容拼成命令字符串,; && $() 都会被解释执行。把可执行文件和参数列表分开传给进程启动接口,用户内容就永远只是「一个参数的值」。

注意这只解决了注入,不解决参数校验。路径是否允许访问、参数会不会触发额外动作,仍然需要工具和执行层检查。

Q4:CLI 封装能解决权限隔离吗?

不能。

fork / exec 之后,子进程沿用父进程的 uid、组和 capabilities,没有任何一道检查会问「你这段代码是干什么的」。

CLI 能做到的是「让隔离变得可行」:给它单独的工作目录、单独的环境变量、单独的超时、能把它塞进容器。但真正的边界要靠受限身份、命名空间、seccomp、网络白名单。

这是个高频陷阱:说「加了 CLI 就安全了」,基本会被继续追问。

Q5:工具输出里有进度和日志,调用方怎么判断真实结果?

三层判断:

  1. 进程状态:退出码。0 只说明按约定结束。
  2. 业务状态 :status 字段。accepted(已受理)和 completed(已完成)必须区分。
  3. 实际证据:产物是否存在、可读、大小合理、摘要匹配;异步任务查状态接口。

只靠第一层,就会出现「CI 显示 success,产物目录是空的」。

Q6:超时了应该重试吗?

看操作类型,不能一刀切。

timeout 杀掉的是入口进程,不代表远端动作没发生。只读可以重试;幂等写要带同一个幂等键;非幂等写必须先查状态;有后台任务的要按任务 ID 查。

另外,杀要杀整个执行单元(进程组 / cgroup),否则子进程会变成孤儿继续跑。

Q7:CLI 和 MCP 怎么选?

不是二选一,它们是两层。

  • CLI 解决「独立执行、跨进程复用、能进 CI」;
  • MCP 解决「工具发现、跨客户端共享、统一鉴权」。

一个工具可以既有 CLI(给人和 CI 用),又包一层 MCP Server(给多个 Agent 客户端用),两者共用同一个 core。

上下文成本上有个 U 形曲线:模型熟悉的动词,CLI 更省(不需要 schema);模型陌生的内部 API,结构化 schema 更省(不需要试错)。规模大到几十个服务时,还会引入第三种做法------把工具映射成代码 API,让模型写代码调用,中间结果不进上下文。

Q8:为什么说「确定的步骤要写进代码」?会不会限制模型的灵活性?

不会,这是分工。

顺序固定的内部步骤(校验 → 转换 → 核对产物)由代码保证,模型负责「什么时候用这个操作」。如果不封装,模型每轮都要重新编排,一是多耗交互,二是可能漏掉关键步骤------比如漏掉「核对产物」,而这恰恰是最重要的那一步。

但粒度要克制:一个命令应该有一个清晰的业务目的。如果内部还需要用户做新决定,就不能为了「一步完成」偷偷猜参数或扩大权限。本地生成文件和对外发布,必须是两个入口。

Q9:工具版本怎么管?

三件事:

  1. 工具独立发版,带语义化版本。 不和主程序绑死。
  2. 契约向后兼容。 加字段可以,改字段含义不行;破坏性变更走大版本。
  3. 结果里带 schema_version。 调用方能识别结构版本,做兼容分支。

这条的意义是:Agent 的行为可以随时变,但工具的契约不能悄悄变。否则你会遇到「模型没变、代码没变,但结果变了」的鬼故事。

Q10:什么情况下不该封装成 CLI?

四个反问:

  • 只有一个调用方,而且永远同进程 → 直接调函数。
  • 单次处理极快(几十毫秒)、调用极频繁 → 进程启动开销会超过处理本身,用常驻服务。
  • 需要长期连接或流式返回 → 常驻进程 + CLI 只做控制入口。
  • 只在内部用、不需要跨语言、不需要进 CI → 薄封装就够了。

反过来的顺序是错的:先上架构,再找场景,最后发现这个小函数根本不需要这层壳。

十一、什么时候封,什么时候不封

把上面的判断压成四个问题,按顺序问:

  1. 除了 Agent,还有别人要用吗? 没有,而且永远同进程 → 直接调函数,收工。
  2. 单次处理是不是极快、频率极高? 是 → 常驻服务 + 控制入口,别每次拉起进程。
  3. 需要跨语言、跨团队、或进 CI 吗? 不需要 → 进程内函数 + 薄封装就够了。
  4. 要按用户鉴权、审计、多租户吗? 要 → 在 CLI 之外再加一层远程服务,CLI 仍然是调试和自动化的入口。

四个都过了,才轮到「怎么封」。顺序反过来,就会变成为了架构而架构。

十二、收尾

回到开头那个问题:「函数套个 CLI,就算解耦了?」

不算。加 argparse 只是加了个门,门后面的房间有没有打通,跟门没关系。

真正的解耦是三步:

  1. 把能力从运行时里剥出来 ------ 参数显式传入,结果显式返回,不读会话,不 import 框架。
  2. 给能力装一个稳定的入口 ------ 一套退出码、一套 JSON、一条 stderr 规则,机器和人都能读。
  3. 把入口放进可控的环境 ------ 白名单环境变量、独立工作目录、真超时、危险操作默认预演。

做完这三步,你会发现收益远不止「Agent 能用了」:那份代码突然可以被单测覆盖、可以被 CI 断言、可以被运维手工复现、可以被另一个语言调用。Agent 只是又多了一个调用方而已。

而权限隔离、性能、语义正确这三件事,一步都不会自动到来------它们要靠沙箱、靠架构、靠产物校验,靠不了「加个入口」。

如果只能记住一句话:

把「请求」和「事实」分开,是这一整套设计的地基。模型输出的是请求,退出码描述的是进程,只有产物才是事实。


本文为原创文章,如需转载,请联系作者获得授权,并注明出处。

相关推荐
Flynn Liang1 小时前
Spring AI 上生产:限流熔断、密钥与成本治理、私有化/混合部署、降级与 Prompt 灰度架构全解
人工智能·spring·架构·高可用·springai·成本治理·生产架构
薛定谔的猫-菜鸟程序员1 小时前
Electron开发超详细教程(从入门到放弃)
前端·javascript·electron
飞猫的边缘AI1 小时前
边缘AI芯片的规则变了:从跑CNN到跑LLM和VLM
人工智能·语言模型·cnn·transformer·边缘ai
AntsData1 小时前
用 12K+ 条亚马逊评论信号,定位机械键盘下一版该改什么
java·大数据·计算机外设
code2cat1 小时前
【随笔】连续批处理:长短请求怎样在迭代之间补位
java·人工智能·后端·大语言模型·vllm
最强小杰1 小时前
DeepSeek V4 Flash + Codex CLI 接入教程:原生 Responses API 适配,从配置到跑通收藏这篇就够了(2026)
java·前端·python·ai
牧羊人.3331 小时前
卷积神经网络之VGG
人工智能·深度学习·神经网络·算法·cnn
Flynn Liang2 小时前
Spring AI 2.0 深度解析:ChatClient、Advisor 链与 MCP——Java 工程师的 AI 落地最优解
java·人工智能·spring·大模型·架构设计·spring ai
灿飞2 小时前
Kotlin与Java虚拟机六:基础与类型系统:一切皆对象,但对象有代价
java·kotlin