
1. 从“ax”这个标题说起一个被低估的CLI工具设计范式第一次看到“ax”这个标题很多人会一头雾水——两个字母既不像产品名也不像技术栈缩写。但如果你最近在Kubernetes、gRPC、AI agent这几个圈子里泡过就会发现“ax”其实指向的是一类非常典型的东西一个面向 agent 场景的命令行调度入口。它可能是一个内部工具也可能是一个开源项目的代号核心特征就三个字轻、快、可编排。我最早接触这类工具是在做 Kubernetes device plugin 调试的时候。当时需要频繁地在集群节点上执行诊断命令、拉取 gRPC 健康检查状态、再把结果汇总给一个上层的 agent 做决策。手工敲 kubectl exec 效率极低写 shell 脚本又难以维护于是团队里有人搞了一个叫 ax 的小 CLI把常用操作封装成子命令通过 gRPC 和本地 agent 通信。用下来最大的感受是CLI 不是给人用的是给 agent 用的。这个认知转变直接影响了后面所有的设计取舍。所以这篇博文我不打算把“ax”当成某个具体产品来讲而是把它当作一个CLI agent Kubernetes gRPC 的复合型项目来拆解。它解决的核心问题是在分布式环境里如何让一个轻量命令行工具成为 agent 的手和眼同时保持足够的可观测性和可扩展性。适合谁看如果你正在做 agent 开发、在 Kubernetes 上跑自动化任务、或者想搞清楚 gRPC 在 CLI 场景下怎么落地那这篇内容会对你有直接帮助。如果你只是听说过 codex cli、claude cli 这些工具但没动手写过也能从里面拿到一套可复用的架构思路。2. 整体架构设计为什么是 CLI gRPC Agent 这个组合2.1 核心需求拆解agent 到底需要一个什么样的入口先把需求说清楚。一个 agent 系统不管是 AI agent 还是传统的自动化 agent它在执行任务时通常面临几个现实问题第一它需要一个稳定的执行通道不能每次操作都靠临时拼 shell第二它需要结构化返回而不是一堆需要正则解析的文本第三它需要权限边界不能让 agent 随便在集群里乱跑第四它需要可观测出了问题能定位到是哪一步挂的。传统的做法是给 agent 一个 SSH 通道或者 kubectl 权限让它自己拼命令。这个方案在 demo 阶段能用一上生产就崩。原因很简单agent 生成的命令不可控返回结果不可解析出错之后没有上下文。ax 这类工具的出现本质上就是把“agent 能做什么”收敛成一组预定义的、带 schema 的子命令agent 只需要调用ax verb noun剩下的交给 CLI 内部处理。这个设计思路和 Kubernetes 的 device plugin 机制其实是一脉相承的。device plugin 通过 gRPC 向 kubelet 暴露一组标准接口kubelet 不需要知道底层是什么硬件只需要按协议调用。ax 对 agent 也是同样的角色agent 不需要知道底层是 Kubernetes 还是裸机只需要按 CLI 约定调用。2.2 为什么选 gRPC 而不是 REST 或直接 exec这是被问得最多的一个问题。我的回答通常是一句话因为 agent 和 CLI 之间的通信是高频、双向、强类型的。REST 的问题在于每次调用都要走完整的 HTTP 语义header、序列化、错误码处理都很啰嗦。对于 agent 这种每秒可能调用几十次的场景开销不划算。直接 exec 的问题更明显没有类型约束返回全靠 stdout 解析一旦输出格式变了整个链路就断。gRPC 的优势在这里体现得很充分。protobuf 定义了强类型接口agent 侧生成的 stub 直接调用编译期就能发现不匹配。流式调用支持得很好对于需要持续拉取状态比如 watch Kubernetes 资源变化的场景server streaming 比轮询优雅得多。还有一个容易被忽略的点gRPC 的 deadline 和 cancellation 传播机制能让 agent 在超时后干净地取消下游操作这在 Kubernetes 环境里非常重要否则会留下一堆僵尸请求。不过 gRPC 也不是没有代价。在 Windows 下用 Visual Studio 编译 gRPC 的体验说实话不算友好protobuf 编译器、CMake 配置、运行时库版本对不上是家常便饭。如果团队里 Windows 开发者多建议直接用 WSL 或者容器化构建环境别在原生 Windows 上硬刚。这一点我在后面的排查章节会展开。2.3 分层结构把 CLI、agent、Kubernetes 各放其位ax 的整体分层我习惯画成三层层级角色职责技术选型接入层CLI参数解析、命令路由、结果格式化Go cobra调度层Agent任务编排、状态管理、gRPC 服务端Go gRPC执行层Kubernetes / 本地实际资源操作client-go / os/execCLI 层只做最薄的事情把用户或上层 agent 的意图翻译成 gRPC 请求。它不持有状态不缓存连接每次调用都是独立的。这样做的好处是 CLI 可以随便分发不需要配置也不会有状态不一致的问题。Agent 层是真正的大脑。它持有到 Kubernetes API Server 的连接管理 device plugin 注册维护任务队列。所有需要状态的操作都在这一层完成。Agent 通常以 DaemonSet 的形式跑在每个节点上或者以 sidecar 的形式和业务容器共存。执行层就是具体干活的地方。如果是 Kubernetes 场景agent 通过 client-go 调用 API如果是本地场景agent 直接 fork 子进程。这一层的抽象做得好不好决定了 ax 能不能同时支持多种运行环境。2.4 和 codex cli、claude cli 这类工具的异同很多人会把 ax 和 codex cli、claude cli 放在一起比较。它们确实有相似之处都是 CLI 形态都面向 agent 场景都强调可编排。但定位差别很大。codex cli 和 claude cli 本质上是模型能力的命令行封装它们的核心是“把自然语言变成代码或操作”。而 ax 这类工具的核心是“把结构化意图变成分布式操作”。前者解决的是人机交互问题后者解决的是 agent 与基础设施之间的协议问题。这个区别决定了设计重点不同。codex cli 要花大量精力在 prompt 管理、上下文窗口、流式输出上ax 要花精力在连接池、重试策略、权限校验、结果 schema 上。两者可以组合使用——比如用 claude cli 生成 ax 命令再让 ax 去执行——但不要指望一个工具同时把两件事做好。3. 核心细节解析CLI 子命令设计与 gRPC 接口定义3.1 子命令命名规范动词 名词的硬约束ax 的子命令设计我踩过不少坑最后收敛成一条铁律动词 名词全小写不超过三个词。比如ax node list、ax pod exec、ax device status。为什么这么严格因为 agent 生成命令时命名越规律模型越不容易出错。早期我们允许一些“聪明”的别名比如ax ls代替ax node list结果 agent 经常混用日志里一半是 ls 一半是 list排查起来很痛苦。后来全部砍掉只保留一种写法。这个决定当时有争议但上线后 agent 调用成功率明显提升。子命令的层级也不要太深。两层足够三层就是设计问题。如果发现某个命令需要三层通常意味着应该拆成两个独立命令或者把中间层做成参数。3.2 gRPC proto 文件的关键字段设计proto 文件是整个系统的契约设计得好后面省事设计得差后面天天改。我总结几个关键点syntax proto3; package ax.v1; service AxService { rpc Execute(ExecuteRequest) returns (ExecuteResponse); rpc Stream(StreamRequest) returns (stream StreamResponse); rpc Health(HealthRequest) returns (HealthResponse); } message ExecuteRequest { string command 1; repeated string args 2; mapstring, string env 3; int32 timeout_seconds 4; string trace_id 5; } message ExecuteResponse { int32 exit_code 1; bytes stdout 2; bytes stderr 3; int64 duration_ms 4; string trace_id 5; }几个设计要点值得说明。trace_id字段是必须的它让 CLI、agent、下游操作能串成一条链路排查问题时直接按 trace_id 搜日志。timeout_seconds用 int32 而不是 Duration是因为跨语言兼容性更好agent 侧自己转成 time.Duration。stdout和stderr用 bytes 而不是 string是为了避免二进制输出被截断。Stream接口单独定义不要试图用Execute加个 flag 来兼容。流式和非流式的语义差别太大混在一起会让实现变得很脏。3.3 参数校验在 CLI 层做还是 agent 层做这个问题我纠结过很久。CLI 层做校验的好处是快速失败不用走网络agent 层做校验的好处是集中管理改规则不用重新分发 CLI。最后的方案是两层都做但职责不同。CLI 层只做语法校验参数个数对不对、类型对不对、必填项有没有。这些规则相对稳定写在 CLI 里没问题。Agent 层做语义校验这个节点存不存在、这个操作有没有权限、当前状态允不允许执行。这些规则经常变放在 agent 层方便热更新。举个具体例子。ax pod exec --namespace foo --pod bar -- cmd这个命令CLI 层检查 namespace、pod、cmd 三个参数是否齐全格式是否合法。Agent 层检查 foo 这个 namespace 是否存在、bar 这个 pod 是否在运行、当前用户有没有 exec 权限。两层各司其职边界清晰。3.4 结果格式化给 agent 看还是给人看ax 的输出默认是 JSON不是表格。这个决定一开始被团队吐槽说不够友好。但坚持下来之后发现是对的agent 消费 JSON 的稳定性远高于解析表格。当然人也要用。所以加了一个--format参数支持json、table、raw三种。默认 json人用的时候显式指定 table。这样 agent 侧不用做任何特殊处理人也能看得舒服。JSON 的 schema 要稳定。字段名一旦发布就不要改新增字段用可选删除字段要经过至少一个大版本的废弃期。这个纪律很重要否则 agent 侧的解析代码会变成一团乱麻。4. 实操过程从零搭一个可用的 ax 原型4.1 环境准备与依赖安装先说明下面这套流程是我在 Ubuntu 22.04 Go 1.21 环境下验证过的。如果你用 macOS大部分步骤一样只是包管理换成 brew。Windows 用户建议直接用 WSL2原生 Windows 下 gRPC 的坑太多不值得花时间。第一步装 Go 和 protoc# 安装 Go wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz export PATH$PATH:/usr/local/go/bin # 安装 protoc sudo apt install -y protobuf-compiler protoc --version # 应该输出 libprotoc 3.21.x # 安装 Go 插件 go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatest这里有个细节protoc-gen-go和protoc-gen-go-grpc是两个独立的插件版本要匹配。我遇到过只装了前者没装后者编译时报“plugin not found”的情况排查了半天。装完之后确认$GOPATH/bin在 PATH 里否则 protoc 找不到插件。4.2 项目骨架搭建目录结构我习惯这样组织ax/ ├── cmd/ │ └── ax/ │ └── main.go ├── internal/ │ ├── cli/ │ │ ├── root.go │ │ ├── node.go │ │ └── pod.go │ ├── client/ │ │ └── grpc.go │ └── server/ │ ├── server.go │ └── executor.go ├── api/ │ └── v1/ │ └── ax.proto ├── go.mod └── Makefilecmd/ax是 CLI 入口internal/cli放 cobra 命令定义internal/client是 gRPC 客户端封装internal/server是 agent 侧实现api/v1放 proto 文件。这个结构的好处是 CLI 和 agent 可以放在同一个仓库里共享 proto 定义改接口时两边一起改不会出现版本漂移。Makefile 里加几个常用目标.PHONY: proto build test proto: protoc --go_out. --go-grpc_out. api/v1/ax.proto build: go build -o bin/ax ./cmd/ax go build -o bin/ax-agent ./cmd/ax-agent test: go test ./...make proto生成代码make build编译两个二进制make test跑测试。简单直接不引入额外的构建工具。4.3 gRPC 服务端实现要点Agent 侧的 gRPC 服务端核心是Execute方法的实现。我把它拆成几个步骤func (s *Server) Execute(ctx context.Context, req *pb.ExecuteRequest) (*pb.ExecuteResponse, error) { // 1. 参数校验 if err : validate(req); err ! nil { return nil, status.Errorf(codes.InvalidArgument, invalid request: %v, err) } // 2. 超时控制 timeout : time.Duration(req.TimeoutSeconds) * time.Second if timeout 0 { timeout 30 * time.Second } ctx, cancel : context.WithTimeout(ctx, timeout) defer cancel() // 3. 执行 start : time.Now() stdout, stderr, exitCode, err : s.executor.Run(ctx, req.Command, req.Args, req.Env) duration : time.Since(start) // 4. 组装响应 resp : pb.ExecuteResponse{ ExitCode: int32(exitCode), Stdout: stdout, Stderr: stderr, DurationMs: duration.Milliseconds(), TraceId: req.TraceId, } // 5. 错误处理执行失败不算 RPC 失败 if err ! nil exitCode 0 { return nil, status.Errorf(codes.Internal, execution error: %v, err) } return resp, nil }这里有个关键设计命令执行失败非零退出码不算 RPC 错误。RPC 错误只用于通信层和参数层的问题。这样 agent 侧可以根据 exit_code 做业务判断而不是把所有失败都当成网络问题重试。这个区分很重要早期没做区分的时候agent 遇到命令失败就重试结果把幂等性搞坏了。4.4 CLI 侧调用封装CLI 侧的核心是把 cobra 命令和 gRPC 调用接起来。以ax node list为例var nodeListCmd cobra.Command{ Use: list, Short: List all nodes, RunE: func(cmd *cobra.Command, args []string) error { client, err : client.New(cfg.Endpoint) if err ! nil { return err } defer client.Close() ctx, cancel : context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp, err : client.Execute(ctx, pb.ExecuteRequest{ Command: node, Args: []string{list}, TimeoutSeconds: 10, TraceId: trace.NewID(), }) if err ! nil { return err } return output.Print(resp, cfg.Format) }, }注意trace.NewID()每次调用都生成新的 trace_id这样一次 CLI 调用在日志里就是一条独立的链路。如果上层 agent 有自己的 trace 体系可以通过环境变量传入CLI 优先用传入的。output.Print根据--format参数决定输出格式。json 格式直接 marshaltable 格式用 tabwriter 对齐raw 格式直接输出 stdout。三种格式的实现都不复杂但能覆盖绝大多数使用场景。4.5 Kubernetes device plugin 集成如果 ax 要管理 GPU、FPGA 这类特殊设备就需要和 Kubernetes device plugin 机制对接。Device plugin 的本质是一个 gRPC 服务向 kubelet 注册自己然后 kubelet 通过Allocate接口请求设备分配。ax 在这里的角色是device plugin 的管理者不是实现者。它负责在节点上启动、停止、监控 device plugin 进程并通过 gRPC 查询设备状态。具体来说ax agent 会扫描节点上的 device plugin socket 目录通常是/var/lib/kubelet/device-plugins/对每个 socket 建立 gRPC 连接调用ListAndWatch获取设备列表把设备状态汇总后通过 ax 的 gRPC 接口暴露给 CLI这个集成方式的好处是解耦。ax 不需要知道设备的具体类型只需要按 device plugin 的标准协议通信。新增设备类型时只要对应的 plugin 实现了标准接口ax 就能自动发现。4.6 部署与验证Agent 的部署方式取决于场景。如果是 Kubernetes 集群用 DaemonSetapiVersion: apps/v1 kind: DaemonSet metadata: name: ax-agent spec: selector: matchLabels: app: ax-agent template: metadata: labels: app: ax-agent spec: hostNetwork: true containers: - name: agent image: ax-agent:latest ports: - containerPort: 9090 volumeMounts: - name: device-plugins mountPath: /var/lib/kubelet/device-plugins volumes: - name: device-plugins hostPath: path: /var/lib/kubelet/device-pluginshostNetwork: true是为了让 agent 能直接访问节点的网络命名空间这对某些设备操作是必须的。device-plugins目录挂载进去agent 才能扫描到 socket。验证步骤# 1. 检查 agent 是否启动 kubectl get pods -l appax-agent # 2. 从本地调用 ax --endpoint localhost:9090 node list # 3. 检查 device plugin 发现 ax device status如果node list返回空先检查 agent 日志通常是 gRPC 端口没监听或者防火墙拦了。如果device status报错检查 socket 目录权限agent 需要 root 或者对应的 group 权限才能读。5. 常见问题与排查技巧实录5.1 gRPC 连接问题速查表现象可能原因排查方法解决方案connection refusedagent 未启动或端口不对ss -tlnp | grep 9090启动 agent检查端口配置deadline exceeded超时设置过短或下游阻塞查看 agent 日志中的 duration调大 timeout检查下游unavailable网络不通或 TLS 配置错误grpcurl -plaintext测试检查网络策略和证书resource exhausted并发连接数超限查看 agent 的 max_connections调大限制或加连接池unimplementedproto 版本不匹配对比两端 proto 文件重新生成代码并部署这张表是我从实际故障里总结出来的覆盖了 90% 的 gRPC 连接问题。遇到问题先查表比盲目看日志快得多。5.2 Windows 下编译 gRPC 的坑前面提过Windows 原生编译 gRPC 体验不好。具体来说有几个典型问题第一protoc 的 Windows 版本和 Linux 版本行为有差异特别是路径分隔符处理。生成的代码在 Linux 上编译可能报错。解决方案是统一在 Linux 或 WSL 里生成代码Windows 只负责编译。第二Visual Studio 的 CMake 集成对 gRPC 的依赖管理不友好。abseil、re2、zlib 这些依赖经常版本冲突。建议用 vcpkg 管理依赖虽然慢但省心。第三运行时库的 CRT 版本要一致。gRPC 编译时用的 CRT 和你的项目用的 CRT 不一致会出现链接错误。统一用/MD或者/MT不要混用。如果团队里 Windows 用户多我的建议是直接上 WSL2。开发体验和 Linux 几乎一样省去大量环境问题。5.3 agent 执行超时与取消的处理Agent 执行长任务时超时和取消是最容易出问题的地方。我遇到过几次 agent 卡死最后发现是子进程没有正确响应 context 取消。Go 的exec.CommandContext在 context 取消时会发送 SIGKILL但只杀父进程不杀子进程。如果命令是sh -c long_running_thingSIGKILL 发给 shlong_running_thing 会变成孤儿进程继续跑。解决方案是用进程组cmd : exec.CommandContext(ctx, name, args...) cmd.SysProcAttr syscall.SysProcAttr{Setpgid: true}然后在 context 取消时杀整个进程组go func() { -ctx.Done() if cmd.Process ! nil { syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } }()这个细节不处理agent 跑久了会积累一堆僵尸进程最后把节点资源耗尽。5.4 权限与安全边界Agent 通常以较高权限运行这是必要的但也是风险点。我的做法是第一agent 的 gRPC 接口只监听 localhost 或者内部网络不暴露到公网。如果必须跨节点调用走 mTLS。第二所有命令走白名单。Agent 维护一个允许执行的命令列表不在列表里的直接拒绝。这个列表通过配置文件管理支持热加载。第三审计日志。每次 Execute 调用都记录 trace_id、调用方、命令、参数、结果、耗时。日志单独存储保留至少 30 天。第四定期轮换 agent 的证书和 token。不要用长期有效的凭证。这几条看起来简单但真正落地需要纪律。我见过太多项目为了图方便把 agent 的权限开到最大最后出了安全事故才后悔。5.5 性能调优的几个实测数据最后分享几个实测数据供参考gRPC 连接复用 vs 每次新建复用情况下 QPS 提升约 3 倍延迟降低 60%protobuf 序列化 vs JSONprotobuf 快约 5 倍体积小约 40%连接池大小8 个连接时吞吐达到峰值再增加收益递减超时设置短命令 5s长命令 60s流式操作不设超时靠 context 控制这些数据是在 4 核 8G 的节点上测的不同环境会有差异但量级关系应该差不多。调优的时候先测基线再逐项调整不要凭感觉。6. 后续扩展方向与个人经验ax 这个原型跑通之后能扩展的方向其实不少。我目前在做的是把 agent 的记忆能力加进去——不是 AI 意义上的记忆而是操作历史的结构化存储。每次执行完命令把 trace_id、命令、结果、上下文存到本地的一个轻量数据库里下次遇到类似场景可以直接查询历史。这个功能对排查问题特别有用尤其是那种“上周还能跑今天不行了”的情况。另一个方向是和 AI agent 框架对接。现在很多 agent 框架比如 pi agent、hermes agent 这类都有自己的工具调用协议ax 可以作为它们的“基础设施工具”注册进去。Agent 生成 ax 命令ax 负责执行和返回结构化结果。这个组合我在内部试过效果比让 agent 直接操作 Kubernetes API 稳定得多因为 ax 把权限和校验都收敛了。踩过的坑里最值得说的是不要过早优化。我一开始就想把 ax 做成一个通用平台支持各种后端、各种协议、各种插件。结果做了两个月核心功能还没跑通。后来砍掉所有非核心的东西只保留 Kubernetes gRPC 这一条链路两周就上线了。通用性是长出来的不是设计出来的。最后分享一个小技巧ax 的 CLI 加一个--dry-run参数只打印将要执行的 gRPC 请求不实际发送。这个功能在调试 agent 生成的命令时特别有用能快速看出 agent 到底想干什么。实现成本极低但省下的排查时间不可估量。