Client-go AIOps实战

2025-11-06T14:11:02+08:00 | 33分钟阅读 | 更新于 2025-11-06T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 讲清 Cobra 是什么、为什么它是 Go 语言构建 CLI 工具的「最佳实践」,并说出 command / 参数 / flag 三大核心概念以及全局 flag 与本地 flag 的区别。
  2. 用 Cobra CLI 从零初始化一个 Go 命令行项目,搭建 root 命令,用 add 生成 hello / world 命令和子命令,并配置 Version、persistent flag、local flag 与「已废弃」标记。
  3. 掌握通过 OpenAI 兼容接口封装一个 LLM 客户端(读取 API Key、设置 baseURL、sendMessage),并通过 System Prompt 让大模型「只输出 YAML」。
  4. 用 Function Calling 定义部署 / 查询 / 删除三类函数(tool schema),能态清楚「大模型只是告诉你它想调哪个函数和参数,真正调用必须自己在代码里写」。
  5. 用 client-go 封装客户端(Clientset / DynamicClient / DiscoveryClient),通过 unstructured + restMapping + GVR 把任意类型资源动态 deploy 到集群,并用 dynamic client 实现查询资源。
  6. 从零实现一个 K8sGPT 故障诊断工具:用 client-go 采集 Warning / Error 级别的 Event 并定位 Pod、读取其日志,把「事件 + 日志」拼接成 Prompt 交给大模型生成可操作的修复建议。
  7. 理解在 CLI 工具里用 bufio.Scanner 实现交互式终端、用 map 按 Pod 分组聚合事件与日志等工程细节。

前置知识:

  • 会写基础 Go 代码并能运行一个命令行程序;了解 Go Module(go.mod)。
  • 知道 kubectl 的基本用法(get、apply、get events),理解 Deployment / Pod / Namespace / Event 等 Kubernetes 资源。
  • 会使用 client-go 的 Clientset、unstructured、restMapping 等(前几章讲过)。
  • 理解 LLM 的 OpenAI 兼容 API 以及 Function Calling / Tool Calling 的入参与出参结构;有一个可用的 LLM API Key。

本章你会动手做的事:

  • 用 go install 装好 Cobra CLI,初始化一个名为 k8sco 的命令行项目,并用 add hello、add world 添加命令与子命令。
  • 在 root 里定义全局的 --kubeconfig 与 --namespace 持久 flag,在 world 子命令里定义一个本地 --source flag,并给命令加 Version 与 deprecated 标记。
  • 为 ask chatgpt 命令写一个交互式对话终端,首次接通 LLM,逐步实现「帮我生成并部署一个 Deployment」→「查询 default 命名空间的 Pod / Deployment / Service」→ 留删除为作业。
  • 实现 analyze events 命令:采集 Warning 事件 + 对应 Pod 日志,拼接后交给大模型,拿到可直接执行的故障修复建议。
Client-go AIOps 实战Cobra SDKChatK8s CLIFunction Callingclient-go 封装K8sGPT 诊断command/参数/flag 三概念全局 vs 本地 flag·versionask chatgpt 交互终端部署/查询/删除 K8s 资源定义 tool schemaLLM 选函数→代码执行clientset·dynamic·discoveryunstructured·restMapping·GVR采集 Warning/Error Event日志+事件→Prompt 给建议

第 1 讲:Golang CLI 实战 —— Cobra SDK

模块 6 我们讲了 Go 怎么用,这一讲则要回答另一个问题:怎么用 Go 写一个像样的命令行工具(CLI)。因为在 AIOps 里,我们要用 client-go 和 LLM 开发运维工具,而它们最终都会落成一个可在终端里敲的命令行程序。所以这里先引入一个 Go 生态里构建 CLI 的明星库:Cobra。

为什么用 Cobra 来写 CLI

Cobra 是一个用来创建 Go 命令行应用程序的库。它把命令行里最麻烦的几件事全部自动搞定:

能力说明
命令行参数解析帮你解析子命令、参数、标志(flag),不用自己手写解析逻辑
自动生成帮助文档构建出的 CLI 自带 --help,能看到所有可执行的命令、子命令和 flag
命令补全可以把 completion 命令装进 shell,实现按 Tab 自动补全
命令建议(recommendation)拼错命令时会推荐一条最相近的正确命令
自动生成使用文档基于你写的命令 / 子命令 / flag 生成一份使用文档
全局 / 本地 / persistent flag灵活控制某个 flag 是全命令可用还是仅部分命令可用
子命令很容易实现 kubectl get pod 这种「多级命令 + 子命令」的层级结构
flowchart LR
    A[Cobra 库] --> B[子命令
kubectl get 中的 get] A --> C[--help
自动生成帮助] A --> D[命令建议
拼错推荐相近命令] A --> E[补全
Tab 自动补全] A --> F[全局/本地 flag]

Cobra 的社区非常活跃。我们经常接触的 kubectl、controller 工具以及 Docker 的命令行,其实都是用 Cobra 构建的。所以在 Go 语言的 CLI 世界里,Cobra 基本就等于「最佳实践」。

复习提示:类比:Cobra 之于 Go CLI,就像 socket 框架之于 Web 后端——你当然可以手写 HTTP 解析、手写命令分发,但用一个成熟框架能把大量重复的体力活自动完成,让你专心写「每条命令真正的业务逻辑」。写 CLI 用它几乎是共识。

三大核心概念:command、参数、flag

要掌握 Cobra,理解下面三个核心概念就足够了:

概念含义例子
command(命令)表示你要执行的操作;复杂命令后面往往跟着子命令(subcommand)kubectl get、kubectl edit、kubectl delete
参数(argument)命令所作用的对象,跟在命令后面kubectl get pod、kubectl get service nginx 里的 nginx
flag(标志)命令的修饰,调整操作行为-n myns 指定命名空间、--kubeconfig 指定 kubeconfig 文件

其中 flag 又分两类,这一讲重点会演练:

  • 全局 flag / persistent flag:一旦在 root(根命令)里定义,整个 CLI 工具的所有命令和子命令都能用。比如 kubectl 里在任何命令后面都能敲 --kubeconfig,它就是一个全局持久 flag。
  • 本地 flag(local flag):只在定义它的那个命令里能用。比如在 world 子命令里定义了一个 --source,那只有 hello world 命令能用它,hello 本身或其它命令用不了。

默认情况下,flag 都是可选的(它只是修饰,不是必须参数)。想让它必填,可以用 MarkFlagRequired 标记,不填就会报错——这就跟必填参数一样。

复习提示:为什么要分全局和本地:全局 flag 适合「所有命令都关心的配置」,比如 kubeconfig、namespace;本地 flag 适合「某个命令特有的控制项」。这样既方便,又不会让每个命令的 --help 变成一锅粥。

用 Cobra CLI 初始化项目

我们的目标是把接下来两个实战要用到的 CLI 工具命名为 k8sco(K8s + Cobra)。先安装 Cobra 官方提供的一个初始化工具(Cobra CLI),再用它来生成项目骨架:

# 1. 安装 Cobra CLI(仓库地址在注释里)
go install github.com/spf13/cobra-cli@latest

# 2. 初始化一个 Go Module
go mod init k8sco

# 3. 用 Cobra CLI 初始化项目,并指定 module path
cobra-cli init --module k8sco

初始化完之后,Cobra 会给出一个它推荐的项目布局:

k8sco/
├── cmd/
│   └── root.go      # 根命令定义
├── go.mod
└── main.go

Cobra 推荐的规范是:所有命令(command / subcommand)都放在 cmd/ 目录下。如果你想让组织更有层次,还可以在 cmd/ 下面再建子目录——比如你去读 kubectl 源码,会发现它就是这样组织的:建一个 get 目录,里面放所有和 get 相关的子命令。

root.go 里定义的是根命令。根命令本质上是我们要基于它去创建更多命令和子命令来扩展的入口。所以通常在根命令里写的是比较基础、比较核心的内容,比如 Version、以及其它和 CLI 底层相关的配置。cobra-cli init 生成的 main.go 会直接调用 cmd.Execute() 来启动整个 CLI——这一步框架已经帮你写好。

配置根命令:描述 + Version

初始化完成后,root.go 里会有 short、long 等关于命令行的描述字段。把它们改成和 k8sco 相关的描述,并填入 Version:

// cmd/root.go
package cmd

import (
	"os"
	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "k8sco",
	Short: "一个结合 client-go 与 LLM 的 K8s AIOps 命令行工具",
	Long: `k8sco 是一个基于 Cobra 的 Kubernetes AIOps 命令行工具。
下面是一些用法示例:
  k8sco hello world      运行 hello 命令
  k8sco ask chatgpt      以对话方式操作 K8s 资源
  k8sco analyze events   获取集群事件并结合 LLM 做故障诊断`,
	// Version 一旦填上,命令行就自动获得 --version / version 能力
	Version: "v0.1.0",
}

func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

编译并运行,就能看到根命令的描述;因为配了 Version,现在直接就能用 --version:

go build -o k8sco .
./k8sco                # 输出根命令的 Long 描述(因为没有任何子命令,默认打印帮助)
./k8sco --version      # 直接输出 v0.1.0

你觉得,为什么只要在 root 命令里配一个 Version 字段,命令行就自动有了 --version?这正是 Cobra 在根命令里内置的、开箱即用的能力之一——类似地还有 --help、completion 等,我们不必自己实现。

添加第一个命令:hello

用 Cobra CLI 添加新命令,仍然走它的命令行工具:

cobra-cli add hello

它会自动生成一个 cmd/hello.go 文件。打开后你会看到 helloCmd 这个 *cobra.Command,里面有个 Run: func(cmd *cobra.Command, args []string) { ... } —— Run 就是定义具体业务逻辑的地方,也就是当用户敲 k8sco hello 时真正执行的那段代码。这里我们先简单打印一行日志:

// cmd/hello.go
package cmd

import (
	"fmt"
	"github.com/spf13/cobra"
)

var helloCmd = &cobra.Command{
	Use:   "hello",
	Short: "Hello 命令示例",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("hello k8sco")
	},
}

func init() {
	rootCmd.AddCommand(helloCmd)   // 把 hello 挂在根命令下面
}

关键细节:init() 里的 rootCmd.AddCommand(helloCmd) 负责把 hello 注册到根命令下。未来我们开发两个大实战时,业务逻辑就都写在每个命令对应的 Run 里。

添加多级子命令:hello world

hello 需要有个子命令 world,这样 k8sco hello world 就是一个「多级子命令」的例子。用 cobra-cli add 添加时,关键在于指定它的父命令(parent):

cobra-cli add world --parent helloCmd

注意:命令名也「沿用」了生成时代码里变量的命名(helloCmd),所以这里 --parent 传入的必须是父命令变量名 helloCmd(会和 cmd/hello.go 里的变量名一致)。运行后会在 cmd/ 下生成一个新的 world.go,并在 init() 里自动把 world 挂到 helloCmd 下。

// cmd/world.go(核心结构)
var worldCmd = &cobra.Command{
	Use:   "world",
	Short: "World 子命令示例",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("world k8sco")
	},
}

func init() {
	helloCmd.AddCommand(worldCmd)   // 关键:挂到 helloCmd 下面,而不是 rootCmd
}

编译后 k8sco hello world 就能输出了。

复习提示:工程小技巧:一般情况下,作为「父命令」的 hello 往往不会执行真正的业务逻辑,而是打印一段描述,告诉用户这个命令有哪些子命令、怎么用。只有真正运行到最末端的子命令(比如 hello world)时,业务逻辑才生效。你可以对比 kubectl get,它不加子命令时打印的就是一段 usage 描述——Cobra 的 Run 里填的正是这段描述的逻辑。

flag 的几种类型

Cobra 的 flag 本质上是绑定到一个变量上,用户传的值会写入该变量。针对不同数据类型,要用对应的方法:

// 1. string 类型(不带缩写的长 flag)—— 如 --namespace
var ns string
rootCmd.PersistentFlags().StringVar(&ns, "namespace", "default", "目标命名空间")

// 2. string 类型,带短 flag 缩写 —— 如 -n / --namespace
var ns2 string
rootCmd.PersistentFlags().StringVarP(&ns2, "namespace", "n", "default", "目标命名空间")

// 3. 字符串切片(可传多个)—— 如 --node a --node b,存进 []string
var nodes []string
rootCmd.PersistentFlags().StringSliceVar(&nodes, "node", nil, "节点列表(可多次指定)")

// 4. 布尔、整数、浮点等类型的数据也有对应方法
var verbose bool
var replicas int
var ratio float64
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "是否输出详细日志")
rootCmd.PersistentFlags().IntVar(&replicas, "replicas", 1, "副本数量")
rootCmd.PersistentFlags().Float64Var(&ratio, "ratio", 0.5, "比例")

这些方法的前三个参数几乎一致:变量指针、flag 名称、默认值,最后一个是 flag 的描述——这描述在 --help 时会被显示出来,告诉用户这个 flag 是干什么的。

StringVar 与 StringVarP 的区别只是后者多一个参数来指定短 flag 缩写(如 -n)。理解了这点,Cobra 的 flag 其实很简单。

类比:全局 flag 像公司里贴在大门口的"全楼公告"(所有人都要遵守,比如带工牌);本地 flag 像只贴在某个办公室门口的通知(只有进这间办公室的人才会看)。系统默认 flag 都是可选的——就像公告只提醒你"建议带工牌",想强制就 MarkFlagRequired,变成"不戴不让进"。

对应到工程里就是:定义在 root 上的 persistent flag 全工具任何命令都能读(如 --kubeconfig),定义在某个子命令 Flags() 上的本地 flag 只要该命令能用(如 world 的 --source)。

全局 flag:让全工具都能用

回到 k8sco,我们希望全局都能指定 --kubeconfig(指向访问集群的 kubeconfig 文件)和 --namespace(目标命名空间),就把它们定义成根命令的 persistent flag:

// cmd/root.go(定义全局 flag 的部分)
func init() {
	// 默认 kubeconfig = $HOME/.kube/config
	homeDir, _ := os.UserHomeDir()
	defaultKubeconfig := filepath.Join(homeDir, ".kube", "config")

	rootCmd.PersistentFlags().StringVarP(&kubeconfig, "kubeconfig", "k",
		defaultKubeconfig, "kubeconfig 文件路径")
	rootCmd.PersistentFlags().StringVarP(&namespace, "namespace", "n",
		"default", "目标命名空间")
}
复习提示:拼接路径别用字符串 +:给默认 kubeconfig 拼路径时,要用 filepath.Join(home, ".kube", "config") 而不是手动 home + "/.kube/" + config——后者在 Windows 等不同平台下分隔符不对会出问题。

在根命令下 k8sco --help 能看到这两个 flag;在 k8sco hello world --help 里也一样能看到——因为它们定义在根命令的 persistent flag 上,整个工具任何命令都能用,这就是「全局」的含义。

读取它们也很简单——既然它们定义在同一个 package 下,就是一批全局变量,在任意子命令的业务逻辑里直接打印、使用即可:

// cmd/world.go 的 Run 里
Run: func(cmd *cobra.Command, args []string) {
	fmt.Println("kubeconfig =", kubeconfig)
	fmt.Println("namespace =", namespace)
	// ... 未来在这里调用 client-go 去连集群
},

运行 ./k8sco hello world --kubeconfig abc --namespace ns1,就能在 hello world 里读到这两个全局值。它们之后会被透传到底层最需要 kubeconfig 和 namespace 的子命令里去初始化 client-go——这正是全局 flag 的用武之地。

本地 flag:只在某个命令里能用

本地 flag 定义在具体子命令上,只有用它所在的那个命令才能触发。比如给 world 定义一个 --source:

// cmd/world.go
var source string

func init() {
	helloCmd.AddCommand(worldCmd)
	// 本地 flag:直接挂在 worldCmd.Flags() 上
	worldCmd.Flags().StringVar(&source, "source", "", "数据来源(仅 hello world 可用)")
}

验证一下作用域:

  • k8sco hello --help:flag 里只有 --help 和全局的 --kubeconfig / --namespace,没有 --source(因为它是定义在 world 上的本地 flag)。
  • k8sco hello world --help:能看到 --source。

同一个套路也可以推广到「某条命令下的所有子命令」——如果你想给 hello 挂一个 persistent flag,让 hello 下面所有子命令都能用(就像 kubectl 给 get 定义持久 flag,让 get pod、get service 都能用),就在 helloCmd.PersistentFlags() 里定义即可。

其他基础设置:Version 与「已废弃」标记

除了 Version,Cobra 还能很容易地标记某个命令已废弃。在 Command 结构体里有一个专门用于标记废弃的字段 Deprecated,填上提示文案即可:

var worldCmd = &cobra.Command{
	Use:        "world",
	Short:      "World 子命令示例",
	Deprecated: "请使用 ask chatgpt 等其他命令替代",
	Run: func(cmd *cobra.Command, args []string) {
		// ... 这个命令仍然能运行,只是提示用户以后可能不再维护
	},
}

运行 k8sco hello world 时,会先输出一行提示:「Command “world” is deprecated, use ask chatgpt … instead」。命令功能不受影响、仍可运行,但用户一眼就知道它即将被淘汰。

到这里,Cobra 的核心内容已经掌握,可以开始进入实战了。

第 2 讲:从零开发 ChatK8s 命令行工具

这一讲我们要用 Cobra + client-go + LLM 从零开发一个 ChatK8s 命令行工具。它的核心体验是:用自然语言对话的方式去操作 K8s 集群资源——不用查 kubectl 文档,直接对大模型说「帮我部署一个 Deployment,镜像用 nginx」,它帮你生成好 YAML 并部署到集群。

要达到的效果

我们在 CLI 里提供下面这类能力:

k8sco ask chatgpt
# 进入交互终端后你敲:
#   帮我部署一个 Deployment,镜像 image=nginx
#   查询 default 命名空间下的 Pod / Service / Deployment
#   删除 default 命名空间下的 Pod xxx          (作业自己实现)
  • 你说话 → 大模型理解语义 → 调用我们封装的函数 → client-go 真正操作集群。
  • 查询资源:让 k8sco 帮你查 default 命名空间下的 Pod、Service、Deployment 等资源。
  • 删除资源:作为 客后作业,由你来补充 deleteResource 的实现。

所有这些行为都依赖上一章 AIOps 基础里讲过的 Function Calling(函数调用):大模型并不能直接和 K8s 交互,它只能告诉我们「它想调哪个函数、传什么参数」,真正调用我们写好的 Go 函数并让它落地执行,需要我们在代码里完成。

第 1 步:添加 ask / chatgpt 命令

命令要有层级。ask 是命令,chatgpt 是它的子命令(把大模型的服务以「提供方」的身份挂为子命令——以后要接豆包、千问,用同样的套路扩展即可):

cobra-cli add ask
cobra-cli add chatgpt --parent askCmd

这样就有了 cmd/chatgpt.go,我们后续的业务逻辑就写在它的 Run 里。

第 2 步:用 bufio.Scanner 实现交互式终端

我们希望能「从终端持续接收用户输入」,也就是一个交互式对话。用 Go 标准库自带的 bufio 就能实现:

// cmd/chatgpt.go
package cmd

import (
	"bufio"
	"fmt"
	"os"
	"github.com/spf13/cobra"
)

// StartChat 进入交互对话
func StartChat() error {
	scanner := bufio.NewScanner(os.Stdin) // 从标准输入读取
	fmt.Println("我是 k8sco AI 助手,请问有什么可以帮您?")

	for {
		fmt.Print("> ")                 // 打印输入标识
		if !scanner.Scan() {
			break
		}
		input := scanner.Text()          // 拿到用户输入
		if input == "exit" {
			fmt.Println("再见!")
			break
		}
		if input == "" {
			continue
		}
		fmt.Println("你输入了:", input)  // 先打出来验证能收到输入
	}
	return nil
}

var chatgptCmd = &cobra.Command{
	Use:   "chatgpt",
	Short: "用对话方式操作 K8s 资源",
	Run: func(cmd *cobra.Command, args []string) {
		if err := StartChat(); err != nil {
			fmt.Println("chatgpt err:", err)
		}
	},
}

编译运行 ./k8sco ask chatgpt,就进入可一直输入的终端了。输 exit 会因为 break 退出。这是打通「接收用户输入 → 传给大模型」的第一步,我们把 input 正确接到了。

第 3 步:封装 OpenAI 客户端

配置里我们要先写一个 LLM 客户端的封装,对应大模型里的每个大类的 tools schema 里都对应着大模型唯一的一次 API 调用。这样可以把这个 client 封装起来让多个命令都能用它。

复习提示:直接给作者这一步的封装:为了让「后续调用 LLM」统一、简洁,这一节我们把「初始化 OpenAI client、发一条消息、取回复」封装成一个独立包,给每个命令复用。

字母意义上,我们创建一个目录 openai,再建一个文件(比如 openai.go),封装 OpenAI 兼容客户端:

package openai

import (
	"context"
	"fmt"
	"os"

	"github.com/sashabaranov/go-openai"
)

// Client 是对 OpenAI 兼容客户端的二次封装
type Client struct {
	client  *openai.Client
	context context.Context
}

// NewClient 初始化 OpenAI client
func NewClient() (*Client, error) {
	// 从环境变量读 API Key
	apiKey := os.Getenv("OPENAI_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("OPENAI_API_KEY is not set")
	}

	// 国内网络常需要自定义 baseURL(例如代理 / 某云厂商兼容端点)
	config := openai.DefaultConfig(apiKey)
	config.BaseURL = "https://api.xxx.com/v1"   // 按你的代理/供应商替换

	client := openai.NewClientWithConfig(config)
	return &Client{
		client:  client,
		context: context.Background(),
	}, nil
}

// SendMessage 向大模型发一条消息,返回回复文本
func (c *Client) SendMessage(systemPrompt, userInput string) (string, error) {
	resp, err := c.client.CreateChatCompletion(c.context, openai.ChatCompletionRequest{
		Model: "gpt-4o",
		Messages: []openai.ChatCompletionMessage{
			{Role: "system", Content: systemPrompt},
			{Role: "user",   Content: userInput},
		},
	})
	if err != nil {
		return "", err
	}
	if len(resp.Choices) == 0 {
		return "", fmt.Errorf("no choices returned")
	}
	return resp.Choices[0].Message.Content, nil
}

这里的客户端基于 github.com/sashabaranov/go-openai,它把 OpenAI API 用 Go 实现了封装;本机没有就 go get github.com/sashabaranov/go-openai,并参照它的 example 去看流式输出(stream)等能力。

在 chatgpt.go 里写一个 processInput,先用它拿到大模型的对话回复:

func processInput(input string) (string, error) {
	client, err := openai.NewClient()
	if err != nil {
		return "", err
	}
	systemPrompt := "你现在是一个 K8s 助手,帮助用户完成任务。"
	return client.SendMessage(systemPrompt, input)
}

在 Run / StartChat 里把每个用户输入交给它,并打印回复:

resp, err := processInput(input)
if err != nil { /* 处理错误 */ }
fmt.Println(resp)

用「帮我生成一个 Deployment,镜像是 nginx」去问,大模型会生成一段对应的 K8s YAML。

让 LLM 只输出纯 YAML:System Prompt 优化的威力

生成出来的 YAML 前面经常裹着一堆「废话」:先是"以下是一份 YAML 示例",结尾又补一句"你还可以通过 kubectl apply -f 去部署它"之类。这些废话对函数调用没意义——我们只需要纯 YAML 内容,才能塞进 client-go / kubectl apply 去真部署,否则会直接报错。

解决的办法就在 System Prompt 里跟它约法三章:

你现在是一个 K8s 资源生成器。当用户要求生成 YAML 文件内容时,
仅输出 YAML 文件内容即可,不要输出任何多余文字,
不要把 YAML 放在代码块(```)里。

再问一次同样的需求,这次大模型会直接把 kind: Deployment、image: nginx 这段干净的 YAML 吐出来,没有任何多余内容,可以直接拿来部署。你看,相同输入,只改一段提示词,输出的可用性天差地别——这就是 Prompt Engineering 在实战里的价值。

第 4 步:定义 Function Calling 的 tool schema

类比:你先把自己餐厅的"点菜单"(tools schema)递给大厨,大厨听完你点的菜,在菜单上圈了一道菜、写下改料要求(function name + arguments),然后把单子递回给你。大厨只负责点单,真正下锅炒菜的还是你。

对应到工程里就是:大模型只负责"选函数 + 填参数",返回的 message.ToolCalls 只是一个"点单结果";真正调用你写好的 Go 函数去操作 K8s,必须由你在代码里自己实现。

这张图在讲 Function Calling 从你的自然语言到真正操作集群的完整链路:

flowchart LR
    U[用户自然语言
帮我部署一个 Deployment] --> LLM[大模型
看工具 schema 选函数] LLM -->|返回 ToolCalls
函数名 + arguments| A{我们要不要真调用} A -->|验证阶段| P[先只打印
看大模型选没选对函数] A -->|正式阶段| F[代码里 switch
按函数名分发 callFunction] F --> K[client-go 真正操作集群]

上面的 SendMessage 是走「大模型直接回复文本」。但我们要的是让大模型调用我们写的函数去完成部署 / 查询。所以要么:

  • 定义一组函数(也就是 Function Calling),把它们作为 tools 传给大模型;
  • 大模型根据用户输入选择合适函数,并给出参数;
  • 我们根据它选择的函数名去调用真正的 Go 函数。

先定义三个函数对应的 schema(发给大模型的 JSON 描述)。用 openai 库提供的 FunctionDefinition:

// 函数 1:生成 K8s YAML 并部署资源
f1 := openai.FunctionDefinition{
	Name:        "generateAndDeployResource",
	Description: "根据用户要求生成 K8s YAML 并部署资源到集群",
	Parameters: map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"user_input": {
				"type":        "string",
				"description": "用户输入的原样内容,要求包含资源类型和镜像",
			},
		},
		"required": []string{"user_input"},
	},
}

函数 2 用于查询 K8s 资源,参数是 namespace 和 resource_type。其中 resource_type 需要在大模型的 description 里给出规范例子、要求用标准化资源类型(比如 Pod / Deployment / Service 全称),并明确要求它不要给 SVC、deploy 这类非规范缩写,否则后面 client-go 对不上:

f2 := openai.FunctionDefinition{
	Name:        "queryResource",
	Description: "查询 K8s 资源",
	Parameters: map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"namespace":     {"type": "string", "description": "资源所在的命名空间"},
			"resource_type": {
				"type":        "string",
				"description": "K8s 标准资源类型,例如 pod、deployment、service,请使用标准资源名",
			},
		},
		"required": []string{"namespace", "resource_type"},
	},
}

函数 3 用于删除 K8s 资源,参数是 namespace、resource_type 和 resource_name:

f3 := openai.FunctionDefinition{
	Name:        "deleteResource",
	Description: "删除 K8s 资源",
	Parameters: map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"namespace":     {"type": "string", "description": "资源所在的命名空间"},
			"resource_type": {"type": "string", "description": "K8s 标准资源类型"},
			"resource_name": {"type": "string", "description": "要删除的资源名称"},
		},
		"required": []string{"namespace", "resource_type", "resource_name"},
	},
}

把它们组装成 tools 数组(每个 Tool{Type: "function", Function: fn}),随请求一起发给大模型:

tools := []openai.Tool{
	{Type: "function", Function: &f1},
	{Type: "function", Function: &f2},
	{Type: "function", Function: &f3},
}

resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
	Model:    "gpt-4o",
	Messages: []openai.ChatCompletionMessage{{Role: "user", Content: input}},
	Tools:    tools,
})

CreateChatCompletion 返回的 message.ToolCalls 里,就带着大模型想调用的函数名(function.Name)和参数(function.Arguments,是一个 JSON 字符串)。

让大模型「选择正确的函数」:先只打印验证

写完 tools 后,先别急着接后端逻辑,而是把它们打印出来验证——看大模型到底能不能选对函数、填对参数。这一步非常关键:

msg := resp.Choices[0].Message
if len(msg.ToolCalls) == 0 {
	return "大模型未选择任何工具", nil
}
for _, tc := range msg.ToolCalls {
	fmt.Println("希望调用函数:", tc.Function.Name)
	fmt.Println("参数:", tc.Function.Arguments)
}

逐条试:

  • 「帮我生成一个 Deployment,镜像 nginx」 → 大模型应选择 generateAndDeployResource,参数 user_input 填上你的原话。
  • 「查询 kube-system 命名空间的 pod」 → 选择 queryResource,namespace=kube-system、resource_type=pod。
  • 「删除 default 命名空间的 pod,名称是 xxx」 → 选择 deleteResource,三个参数都填好。
复习提示:踩坑:第一次测试时发现 user_input 偶尔传的是「被大模型改写过的版本」,缺失了部分信息。修正办法是把 description 明确写成「用户输入的原始内容,请原样传递,不要修改或丢失任何信息」。即便如此,gpt 这类模型偶尔还是不稳定——多试几次,或在提示里再强调一遍。

一个特别重要、也最容易误解的认知:到这里,大模型只是「告诉我们」它想调 generateAndDeployResource、传什么参数,它并没有真正调用我们代码里的函数。大模型在云端运行,永远无法直接执行你的本地函数。真正调用函数的执行,必须由你自己在代码里实现——这是 Function Calling 的核心机制。

第 5 步:真正调用函数(callFunction)

新增一个 callFunction,根据大模型选出来的函数名做分发,去调用真正实现的函数:

func callFunction(name, args string) (string, error) {
	switch name {
	case "generateAndDeployResource":
		return generateAndDeployResource(client, args)   // 真正实现见下一步
	case "queryResource":
		return queryResource(args)                        // 真正实现见下一步
	case "deleteResource":
		return deleteResource(args)                       // 留给你的作业
	default:
		return "", fmt.Errorf("unknown function: %s", name)
	}
}

因为 arguments 是 JSON 字符串,真正调用前要先把它解析到对应的结构体:

type generateArgs struct {
	UserInput string `json:"user_input"`
}
var params generateArgs
json.Unmarshal([]byte(args), &params)
// 然后用 params.UserInput 去生成并部署

第 6 步:封装 client-go 客户端

下面要真正让 client-go 干活了。为了让 chatgpt 乃至后续的 K8sGPT 都能直接用一组客户端,我们把 client-go 封装成一个结构体 GoK8s,把 clientset、dynamic client、discovery client 都暴露出来:

package k8s

import (
	"path/filepath"
	"strings"

	"k8s.io/client-go/dynamic"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/clientcmd"
)

type GoK8s struct {
	Clientset      *kubernetes.Clientset
	DynamicClient  dynamic.Interface
	DiscoveryClient discovery.DiscoveryInterface
}

// NewClient 根据 kubeconfig 路径初始化三套客户端
func NewClient(kubeconfig string) (*GoK8s, error) {
	// 处理可能带 ~ 的路径
	if strings.HasPrefix(kubeconfig, "~/") {
		home, _ := os.UserHomeDir()
		kubeconfig = filepath.Join(home, kubeconfig[2:])
	}

	config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
	if err != nil {
		return nil, err
	}

	clientset, err := kubernetes.NewForConfig(config)
	if err != nil {
		return nil, err
	}
	dynamicClient, err := dynamic.NewForConfig(config)
	if err != nil {
		return nil, err
	}
	discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
	if err != nil {
		return nil, err
	}

	return &GoK8s{Clientset: clientset, DynamicClient: dynamicClient, DiscoveryClient: discoveryClient}, nil
}

因为我们传入的 kubeconfig 可能带波浪号 ~,BuildConfigFromFlags 不认,所以要先把 ~/ 展开成绝对路径。这是很容易踩的坑。

第 7 步:动态部署任意类型的资源

generateAndDeployResource 的核心:从用户输入生成 YAML,再把这段类型未知(可能是 Pod、也可能是 Deployment)的 YAML 部署到集群。要点是利用 unstructured + restMapping + GVR:

类比:你雇了个搬运工,但打电话前你自己都不确定送来的是"家具"还是"家电",只知道是"一车货"。unstructured 就是这个"我不关心它具体是什么,先当一箱货扛走"的万能纸箱;扛到仓库门口,再拆开看标签(GVK)决定该放哪个库房(GVR 对应的 REST 接口)。

对应到工程里就是:要部署的 YAML 类型未知,所以先用 unstructured 承载,再 GetGroupVersionKind() 拿回 GVK,经 restmapper(由 DiscoveryClient 拉全 API 资源构造)映射成 GVR,最后交给 dynamic client 去 Create。

这张图在讲"一段类型未知的 YAML 是如何被映射并部署到集群"的:

flowchart LR
    Y[用户输入
大模型翻译成一段 YAML] --> U[unstructured 对象
类型未知也能装] U --> GVK[GetGroupVersionKind 拿回 GVK] GVK --> M[DiscoveryClient
拉全集群 API 资源] M --> RM[restmapper
GVK 映射成 GVR] RM --> D[dynamic client
Create 任意类型资源]
func generateAndDeployResource(client *k8s.GoK8s, userInput string) (string, error) {
	// 1. 先用大模型把用户输入"翻译"成一段纯 YAML
	systemPrompt := "你现在是一个 K8s 资源生成器,请根据用户输入生成 K8s YAML,仅输出 YAML,不要包在代码块里。"
	yamlStr, err := openaiClient.SendMessage(systemPrompt, userInput)
	if err != nil {
		return "", err
	}

	// 2. 拿到全集群的 API 资源(GVK -> GVR 的映射需要它)
	_, apiResourceLists, err := client.DiscoveryClient.ServerGroupsAndResources()
	if err != nil {
		return "", err
	}
	restMapper := restmapper.NewDiscoveryRESTMapper(apiResourceLists)

	// 3. 把 YAML 转成 unstructured 对象(因为类型未知,用 unstructured 最通用)
	dec := yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(yamlStr)), 4096)
	var obj map[string]interface{}
	if err := dec.Decode(&obj); err != nil {
		return "", err
	}
	unstructObj := unstructured.Unstructured{Object: obj}

	// 4. 从 GVK 得到 GVR,拿到对应的 REST 接口
	gvk := unstructObj.GroupVersionKind()
	mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
	if err != nil {
		return "", err
	}

	// 5. 确定命名空间(空就用 default)
	namespace := unstructObj.GetNamespace()
	if namespace == "" {
		namespace = "default"
	}

	// 6. 用 dynamic client 创建资源
	created, err := client.DynamicClient.Resource(mapping.Resource).
		Namespace(namespace).
		Create(context.TODO(), &unstructObj, metav1.CreateOptions{})
	if err != nil {
		return "", err
	}
	return "资源创建成功: " + created.GetName(), nil
}

这段流程里踩过的几个关键点:

  • unstructured:因为要部署的 YAML 类型未知(可能是 Pod、Deployment……),用 unstructured.Unstructured 承载最通用,之后再用 GetGroupVersionKind() 拿回 GVK。
  • GVK → GVR:Create 需要的是 GVR(GroupVersionResource);从 GVK 到 GVR 的映射要靠 restmapper(用 DiscoveryClient 拉到全部 API 资源后构造)。这样写就能部署任意类型的资源,而不是写死某一种。
  • 于是你可以在对话里说「帮我创建一个 Pod,端口 8080 镜像 nginx」,它也能动态部署。

第 8 步:查询资源

queryResource 的实现相对独立:先根据 resource_type 拼出 GVR,再调用 dynamic client 去 list:

func queryResource(namespace, resourceType string) (string, error) {
	gk8s, err := k8s.NewClient(globalKubeconfig)   // global 里的 kubeconfig
	if err != nil {
		return "", err
	}
	resourceType = strings.ToLower(resourceType)

	var gvr schema.GroupVersionResource
	switch resourceType {
	case "deployment":
		gvr = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
	case "service":
		gvr = schema.GroupVersionResource{Version: "v1", Resource: "services"}
	case "pod":
		gvr = schema.GroupVersionResource{Version: "v1", Resource: "pods"}
	// ... 你可以把几乎所有资源类型都列出来
	default:
		return "", fmt.Errorf("不支持的资源类型: %s", resourceType)
	}

	list, err := gk8s.DynamicClient.Resource(gvr).
		Namespace(namespace).
		List(context.TODO(), metav1.ListOptions{})
	if err != nil {
		return "", err
	}

	var names []string
	for _, item := range list.Items {
		names = append(names, fmt.Sprintf("%s/%s", namespace, item.GetName()))
	}
	return strings.Join(names, "\n"), nil
}

注意 deployment 属于 apps 组、version 是 v1,而 service / pod 属于核心组(Group 为空,Version: "v1")。你可以在 switch 里把几乎所有的资源类型都列出来,扩展性很好。

⚠️ 新手必踩的坑:resourceType 必须标准化。queryResource 的 switch 只认 deployment / service / pod 这类标准名,一旦落入 default 就会报"不支持的资源类型"。但大模型在 Function Calling 填参数时可能偷懒给 SVC、deploy 这种缩写。所以第 4 步定义函数 2 的 description 时,一定要强制它"使用 K8s 标准资源名",否则后面 client-go 会兜底失败,排查半天才发现是大模型参数不标准。

callFunction 里对应的返回结果,再在交互终端里打印给用户。

串起来的完整效果

把 ask chatgpt 的交互终端串上 Function Calling + callFunction 后,实际体验:

$ ./k8sco ask chatgpt
我是 k8sco AI 助手,请问有什么可以帮您?
> 帮我部署一个 Deployment,镜像是 nginx
资源创建成功: nginx        # dynamic client 已把 YAML 真正 apply 到集群

> 查询 default 命名空间的 pod
default/nginx-7cbb5f7d7f-bz2k8

> 查询 kube-system 的 service
kube-system/kube-dns
...
> exit
再见!

部署的资源可以用 kubectl get pods / kubectl get deployment 验证,确实创建成功。至此 ChatK8s 的「生成 + 部署 + 查询」已经完整跑通,deleteResource 留给大家做作业。

第 3 讲:从零开发 K8sGPT 故障诊断工具

第二个实战是开发一个 K8sGPT 故障诊断工具。它的思路是把「集群里的异常」交给大模型来诊断:用 client-go 获取集群里 Warning / Error 级别的事件 → 定位到产生问题的 Pod → 读取它的日志 → 把「事件 + 日志」一起交给大模型,让它给出可操作的建议。

flowchart LR
    A[获取集群 Warning/Error 事件] --> B[定位产生事件的 Pod]
    B --> C[进入 Pod 读取容器日志]
    C --> D[把 事件+日志 拼接成 Prompt]
    D --> E[交给大模型生成修复建议]

类比:K8sGPT 就是给集群请的"急诊分诊护士"。他遇到半夜出急诊(集群出 Warning/Error 事件),别急着乱猜,先看清是哪号床的病人(定位 Pod)、再看一遍病人的化验单(读容器日志),把"主诉 + 检查单"整理成一份病历交给值班医生(大模型),医生据此开出可执行的处置方案。

对应到工程里就是:client-go 负责采集事件、定位 Pod、读日志这三步"取数",大模型只负责"看病历下诊断"这一步"理解",两者用一段组织良好的 Prompt 桥接。

命令层级是 analyze events:

./k8sco analyze events

用 client-go 采集事件 + 定位日志

先添加命令:

cobra-cli add analyze
cobra-cli add events --parent analyzeCmd

然后在 events 的命令里写核心逻辑。由于我们已经在别处封装好了 client-go 客户端(GoK8s),这里直接复用它。

定义 getHoldingEventsAndLogs,返回一个 map[string][]string:以 Pod 名为 key,值是该 Pod 对应的事件 message 和日志(每个 Pod 的事件和日志可能不止一条,所以值是字符串切片):

// getHoldingEventsAndLogs 获取所有 Warning/Error 事件,并按 Pod 分组,同时取日志
func getHoldingEventsAndLogs() (map[string][]string, error) {
	gk8s, err := k8s.NewClient(globalKubeconfig)
	if err != nil {
		return nil, err
	}
	result := make(map[string][]string)

	// 1. 列出 Warning 级别的事件(通过 FieldSelector type=Warning)
	events, err := gk8s.Clientset.CoreV1().Events("").
		List(context.TODO(), metav1.ListOptions{
			FieldSelector: "type=Warning",
		})
	if err != nil {
		return nil, err
	}

	// 2. 遍历事件,按 Pod 分组
	for _, ev := range events.Items {
		podName := ev.InvolvedObject.Name
		namespace := ev.InvolvedObject.Namespace
		message := ev.Message

		// 只处理"对象是 Pod"的事件
		if ev.InvolvedObject.Kind != "Pod" {
			continue
		}

		// 3. 读取该 Pod 的日志
		req := gk8s.Clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{})
		podLogs, err := req.Stream(context.TODO())
		if err != nil {
			continue          // Pod 可能没有日志,跳过
		}
		defer podLogs.Close()

		buf := new(bytes.Buffer)
		if _, err := buf.ReadFrom(podLogs); err != nil {
			continue
		}
		logs := buf.String()
		if logs == "" {
			continue        // 空日志跳过
		}

		// 4. 每组里先放事件信息,再接日志内容
		result[podName] = append(result[podName],
			fmt.Sprintf("event: %s", message),
			fmt.Sprintf("logs:\n%s", logs),
		)
	}
	return result, nil
}
复习提示:坑:ev.InvolvedObject.Kind 要判断为 Pod 才去取日志,因为事件的对象可能还有 Service、Node 等。另外 Pod 可能根本没有日志(或读日志报错),这类情况直接 continue 跳过,别让整个命令崩掉。

先不接大模型,直接在 Run 里调用并打印,验证能否拿到「事件 + 日志」:

Run: func(cmd *cobra.Command, args []string) {
	eventsLogs, err := getHoldingEventsAndLogs()
	if err != nil {
		fmt.Println("err:", err)
		return
	}
	for pod, infos := range eventsLogs {
		fmt.Println("===== Pod:", pod)
		for _, s := range infos {
			fmt.Println(s)
		}
	}
},

实测验证:我们先在集群里制造一个故障。还记得模块 6 里的 in-cluster config(ServiceAccount 无权限、无法以 in-cluster 方式初始化 client-go)那个 Deployment 吗?把它部署上去,它会因为权限问题一直报错、产生 Warning 事件。再跑 ./k8sco analyze events,就能看到它输出了这个 Pod 的 event + logs 两组内容。

另外,为了刻意演示「命令建议」,故意拼错 k8sco analyze evnts——Cobra 会提示「你是不是只的是 analyze events?」。这正是第一讲里说的 Cobra 内建的命令推荐能力。

把事件 + 日志拼接成 Prompt 交给大模型

拿到分组数据后,新增 sendToChatGPT,把 map 里所有 Pod 的事件和日志拼接成一个长文本,再发给大模型:

func sendToChatGPT(podInfo map[string][]string) (string, error) {
	client, err := openai.NewClient()
	if err != nil {
		return "", err
	}

	// 拼接:把所有 Pod 的事件和日志合并成一段可读文本
	var combined strings.Builder
	combined.WriteString("找到以下 Pod 的事件和日志:\n")
	for podName, infos := range podInfo {
		combined.WriteString(fmt.Sprintf("\n===== Pod: %s =====\n", podName))
		for _, s := range infos {
			combined.WriteString(s + "\n")
		}
	}
	ctxInfo := combined.String()
	fmt.Println(ctxInfo) // 先打印出来,确认拼对了

	// 构造 Prompt
	messages := []openai.ChatCompletionMessage{
		{Role: "system", Content: "你现在是一位 Kubernetes 专家,请帮助用户诊断 K8s 故障。"},
		{Role: "user", Content: fmt.Sprintf(
			"以下是多个 Pod 的 Warning/Error 事件和对应的日志:\n%s\n"+
			"请主要针对 Pod 的日志给出实质性、可操作的修复建议。", ctxInfo)},
	}

	resp, err := client.CreateChatCompletion(context.TODO(), openai.ChatCompletionRequest{
		Model:    "gpt-4o",
		Messages: messages,
	})
	if err != nil {
		return "", err
	}
	if len(resp.Choices) == 0 {
		return "", fmt.Errorf("no choices")
	}
	return resp.Choices[0].Message.Content, nil
}

SendMessage/CreateChatCompletion 在 openai 包里封装好了,这里直接使用。把上一步得到的 map 传进去,让 LLM 结合「事件 + 日志」输出诊断。在 Run 里把它的返回打印出来即可。

实战验证与 Prompt 调优

用之前在 in-cluster config 的报错 Deployment 造一个故障,跑一趟:

./k8sco analyze events

大模型给出的建议(大意)是:这个 Pod 无法以 in-cluster 方式初始化 client-go,是因为 ServiceAccount 的 default 没有对应 API 的 list 权限,需要创建 ClusterRole + ClusterRoleBinding 授予权限。这个建议对上了真实根因——服务因缺权限连 list 都做不了,所以 rest.InClusterConfig() 初始化 client-go 就失败。

但注意,第一次的回复通常有点啰嗦,还建议你去「写一个 YAML 文件再造一个 Deployment」,这对一个命令行操作能解决的事来说偏麻烦。于是我们调优提示词,在 system/user 里加约束:

请优先给出不需要编写 YAML 的命令行(kubectl)操作方法;
只有无法单纯通过命令行解决的,才需要用 YAML 文件解决。

再跑一次,大模型给出的建议变成直接创建 ClusterRole 并绑定,甚至直接给出一条 kubectl auth 命令。按它的建议操作后,Pod 恢复 Running,能用 in-cluster config 正常初始化 client-go 了——完整验证了「事件 + 日志 → 大模型诊断 → 可操作修复」的链路。

复习提示:结论:K8sGPT 的价值不在于大模型多聪明,而在于我们把**正确的数据(事件 + 日志)**喂到了正确的位置(一段组织良好的 Prompt)。数据组织得越好、提示词约束越明确,大模型的修复建议就越具体、越可直接执行——这在后续开发 Agent 时会一再用到。

总结

这一讲的三个实战层层递进,拼出了「用 Go 开发 AIOps 工具」的完整路径:

  • Cobra:动手开发任何 Go CLI 的第一块基石。我们掌握了 command / 参数 / flag 三概念,用 cobra-cli init / add 初始化项目,并在全局 persistent flag、本本地 flag、Ver版本、废弃标记上做了完整演练。写 CLI 用它就等于写了 kubectl 同款范式。
  • ChatK8s:把 LLM 变成 K8s 的操作入口。用 bufio.Scanner 做交互终端,封装 OpenAI 客户端,用 System Prompt 让大模型输出纯 YAML,用 Function Calling 定义部署 / 查询 / 删除三类函数,并用 client-go 的 unstructured + restMapping + GVR + dynamic client 动态落地。
  • K8sGPT:把 LLM 变成 K8s 的故障诊断师。用 client-go 采集 Warning / Error 事件并定位到对应 Pod、读取日志,拼接成 Prompt 交给大模型,拿到可操作的修复建议;再用 in-cluster config 权限故障做了端到端验证,并体会了提示词调优对建议质量的影响。

这三个实战共同指向一个 AIOps 核心链路:client-go 负责与集群交互(取数 / 执行),大模型负责理解与决策(生成 / 诊断),Function Calling 负责把两者桥接起来。


自测题与动手练习

面试官
Cobra 是什么?用它能给命令行工具带来哪些开箱即用的能力?相比纯手写解析,它胜在哪些地方?
候选人
Cobra 是 Go 语言创建 CLI 应用程序的库,是 Go CLI 的「最佳实践」。
开箱即得:子命令(层级命令)、–help 自动帮助、命令建议(拼错推荐相近命令)、命令补全、自动生成使用文档、全局 / 本地 / persistent flag、Version 等。
它把「命令分发、参数解析、帮助生成」这些重复体力活全部接管,我们只需在每条命令的 Run 里写真正的业务逻辑。kubectl、Docker CLI 都用它构建。
面试官
command / 参数 / flag 分别是什么?举一个 kubectl 的例子。全局 flag(persistent)和本地 flag(local)有什么区别?
候选人
  • command = 操作,复杂命令后有子命令;参数 = 命令作用的对象;flag = 命令的修饰(如 -n 指定命名空间、--kubeconfig)。
  • 例子:kubectl get pod nginx -n default —— get 是子命令,pod nginx 是参数,-n default 是 flag。
  • 区别:全局 / persistent flag 定义在 root,全工具所有命令和子命令都可用(像 --kubeconfig);本地 flag 定义在某个具体命令上,只有它所在的那个命令能用(如 world 的 --source 在 hello 里用不了)。
面试官
用 cobra-cli 初始化和添加命令的命令分别是什么?如何添加一个父命令为 helloCmd 的子命令?子命令的业务逻辑写在哪里?
候选人
  • 初始化:go install github.com/spf13/cobra-cli@latest,然后 go mod init <模块名>、cobra-cli init --module <模块名>。
  • 添加命令:cobra-cli add hello;添加子命令:cobra-cli add world --parent helloCmd(传给 --parent 的是父命令变量名)。
  • 子命令的业务逻辑写在它对应的 *cobra.Command 的 Run 字段里;注册通过 init() 里的 helloCmd.AddCommand(worldCmd) 完成。父命令通常只打印 usage 描述,不执行真正逻辑。
面试官
在 ChatK8s 里,为什么生成出来的 YAML 一开始裹着「废话」?如何让大模型只输出纯 YAML?
候选人
大模型默认会「热心」地补充示例代码、部署提示等多余文字,而这些会塞进 client-go / kubectl 导致部署报错。
解决办法是优化 System Prompt:明确告诉它「仅输出 YAML 文件内容即可、不要把 YAML 放在代码块里、不要输出任何多余文字」。只改一段提示词,输出就从「可读但不可用」变成「可直部署」,这正是 Prompt Engineering 的实战价值。
面试官
关于 Function Calling,最容易被误解的一点是什么?在 client-go 真正完成「对话生成资源并部署」时,为什么需要 unstructured 和 restMapping?
候选人
最容易被误解:大模型只是告诉你它想调哪个函数、传什么参数,它并不能直接执行你代码里的函数——真正调用函数必须在代码里自己实现(callFunction 按函数名 switch 分发)。
之所以用 unstructured,是因为用户输入生成的 YAML 类型未知(可能是 Pod、也可能是 Deployment),用 unstructured 承载最通用;用 restMapping,是因为 Create 需要 GVR,要从 GVK 经 Discovery 映射拿到 GVR,这样才能部署任意类型的资源而非写死一种。
面试官
K8sGPT 的故障诊断流程分哪几步?getHoldingEventsAndLogs 为什么返回 map[string][]string?为什么要对 InvolvedObject.Kind == "Pod" 做判断?
候选人
流程:采集 Warning/Error 事件 → 定位产生事件的 Pod → 读该 Pod 日志 → 拼接「事件 + 日志」成 Prompt → 交给大模型生成建议。
返回 map[string][]string:以 Pod 名为 key,方便把每个 Pod 对应的事件 message 和日志归组(一条 Pod 的事件和日志可能不止一段,所以值是字符串切片)。
对 InvolvedObject.Kind == "Pod" 判断:Event 涉及对象可能是 Service / Node 等,只有 Kind 是 Pod 时才适合去取该 Pod 的日志。
面试官
用 K8sGPT 做 in-cluster config 权限故障的端到端验证时,大模型给出了什么建议?为什么说「提示词约束越明确,修复建议越可操作」?
候选人
建议是:该 ServiceAccount 缺少 API 的 list 权限,需创建 ClusterRole + ClusterRoleBinding 授予相应权限后,in-cluster config 才能初始化 client-go;按此操作 Pod 恢复 Running。
我们加了一条「优先给出不需要编写 YAML 的命令行(kubectl)操作」的约束后,大模型就从"写个 YAML 再造一个 Deployment"的繁琐方案,改成直接给一条 kubectl auth/RBAC 命令,说明提示词约束越好,建议越接近可直接执行。

动手练习(建议真做一遍):

  1. 用 Cobra 从零初始化一个 k8sco 项目,添加 hello / world 命令和子命令;在 root 定义全局 --kubeconfig / --namespace 持久 flag,在 world 定义本地 --source flag;分别用 hello --help 和 hello world --help 验证作用域。
  2. 在 ChatK8s 里补全 deleteResource 的实现(参照 generateAndDeployResource / queryResource 的分组逻辑),并做得更好一点:删除前弹一次「是否删除(输入 Y/N)确认」,避免误删重要资源。
  3. 用 K8sGPT 把一个你自己能复现的故障(例如镜像写错导致 ImagePullBackOff、或 ServiceAccount 缺权限导致 CrashLoopBackOff)采集出来,调节 System Prompt 让它给出「优先命令行、可操作」的建议,跑通「事件+日志 → 诊断 → 修复」。
  4. 把 ChatK8s 扩展接入另一个大模型服务(如豆包 / 千问的 OpenAI 兼容端点),体会「只换 baseURL+model、其余复用」的扩展套路。

本章小结

复习提示:
  • Cobra = Go CLI 最佳实践:command / 参数 / flag 三概念;cobra-cli init 初始化、cobra-cli add --parent 加子命令;业务逻辑写在 Run;全局 persistent flag(root 定义、全命令可用)vs 本地 flag(仅某命令可用);Version 与 Deprecated 标记都是 Cobra 内建能力。
  • ChatK8s:bufio.Scanner 做交互终端 → 封装 OpenAI 客户端 → System Prompt 让大模型只输出纯 YAML → 定义 Function Calling 的 tool schema → 大模型只负责"选函数",真正调用靠代码 callFunction → client-go 用 unstructured + restMapping + GVR + dynamic client 动态部署任意资源 → switch GVR 实现查询。
  • K8sGPT:采集 Warning/Error 事件 → 定位 InvolvedObject.Kind == "Pod" → 读日志 → 按 Pod 用 map[string][]string 分组 → 拼接成 Prompt 交给大模型 → 得到可操作修复建议;通过 in-cluster config 权限故障端到端验证,并体验了提示词约束(优先命令行操作)对建议质量的决定性影响。
  • 谁能动手把 ChatK8s 的删除 + 确认 这块作业补全,谁才真正理解了「大模型决策 + client-go 执行」的闭环。client-go 负责与集群取数/执行、大模型负责生成/诊断、Function Calling 负责桥接,这就是 AIOps 命令行工具的核心骨架。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!