家里不大,但是Homelab塞了 4 台交换机,型号各不相同——两台跑 FASTPATH,一台跑华为风格的 SKS8310,一台跑 Cisco IOS 风格的 RTNOS。每次查 MAC 表或者端口状态,我得记住四个 IP,四种命令体系,三种提示符格式。谁顶得住。

正好在用 AI Agent 干活,于是在想:能不能让 LLM 帮我管交换机?这事如果直接让 LLM 自己 Telnet 进去发命令,它大概率死在第一个环节——不同交换机的登录流程、提示符形态、分页关闭方式都不一样,LLM 对这块的细节把握非常不稳定。

但换个思路:我不需要 LLM 懂交换机,我只需要它懂怎么调用一个统一的工具,让这个工具帮它跟交换机说话。

这篇文章不贴完整代码,只聊设计思路——如果你也想让 LLM 操作某种异构设备,这套方法论可以直接抄。

关键依赖:没有它这事就做不成

在往下讲之前,必须提一个库——pexpect

交换机交互本质上是三件事:起一个 Telnet 子进程,往里面发命令,等输出,从输出里判断命令跑完了没(提示符回来了)。如果你从裸 socket 开始写:

  • 每次 recv() 多少字节?读少了命令没执行完,读多了等超时
  • 怎么匹配提示符?提示符可能是一行末尾的 #,也可能是换行后的 加空格
  • 网络闪断、交换机不响应的时候,多久算超时?怎么重试?
    这些问题每一个都很琐碎,组合起来代码量轻松上千行。

pexpect 三个核心能力正好覆盖:

spawn — 起一个子进程并挂上伪终端(pty),让你像操作一个真实终端一样给它发键盘输入。底层本质是用 fork + exec 启动 telnet 命令,然后把自己的 stdin/stdout 映射到伪终端上。交换机那一端完全感知不到对面是个人还是一个程序。

expect — 等特定模式出现。传给它一个正则列表,它阻塞等待直到某个正则在子进程的输出里命中了。超时了自动抛异常。这就是识别"登陆提示出来了"、"密码提示出来了"、"命令跑完了提示符回来了"的基础。

before — 命中之后,before 属性里存的就是"从上次匹配到这次匹配之间"的所有输出。对一个命令来说,就是"从发完命令到提示符重新出现之间"交换机吐出来的所有内容——正好是命令的结果。

所以整个项目的传输层本质上就是:

session = pexpect.spawn("telnet 192.168.1.10 23")
session.expect(["User:", "login:"]); session.sendline(username)
session.expect(["Password:"]); session.sendline(password)
session.expect(["#", ">"]) # 登陆成功
session.sendline("show vlan"); session.expect(["#", ">"])
print(session.before) # 这就是命令输出

五句话完成一次完整的交换机交互。剩下的三百行全是在这基础上做适配和封装。没有 pexpect 这事也能做,但复杂度至少翻三倍。

问题拆解

当你说"让 LLM 操作交换机"的时候,其实是三个独立的问题:

  1. 连接层:怎么跟不同系统的交换机建立会话(Telnet 登录、enable 提权、关分页)
  2. 语义层:怎么把"查端口状态"这个意图映射成不同系统上的具体命令(show interfaces status all vs display interface vs show interface)
  3. 交互层:LLM 怎么知道它能干什么、怎么干、干完了怎么理解结果
    这三个问题如果放在一起解决,会搞出一个很难维护的代码。但如果你能把前两层封装成一个薄薄的 CLI 工具,第三层完全交给 LLM 的自然语言能力,事情就变得异常简单。

第一层:连接——不要写死,让它自己猜

多厂商交换机之间最烦人的差异不在命令上,在最基础的交互协议上:

FASTPATHSKS8310RTNOS
登录提示User:login:Username:
密码提示Password:password:Password:
提示符(hostname)#<hostname>hostname#
关分页terminal length 0不支持terminal length 0

如果你针对每种交换机写死一套交互逻辑,每加一个新型号就要写一堆 if-else。更麻烦的是,同一个厂商的不同固件版本都可能变。

我的做法:把差异参数化,而不是用代码分支处理

每个交换机驱动暴露一个配置字典:

TRANSPORT = {
    "login_prompts": ("login:", "User:", "Username:"),
    "password_prompts": ("password:", "Password:"),
    "prompt_chars": "<>",
    "enable_cmd": "enable",
    "disable_paging_cmd": "",   # 空字符串 = 跳过
}

这些值最终会被传给 pexpect 的 expect() 方法作为匹配模式。传输层的代码不关心对面是什么系统——它只知道:等 login_prompts 出现 → 发用户名 → 等 password_prompts 出现 → 发密码 → 然后动态检测提示符。

提示符检测也做了动态化。不硬编码 (hostname)# 或 ,而是:

  • pexpect 等任意提示符字符出现(> 或 # 或 <)
  • 从 before 缓冲区倒着找到包含这个字符的那行
  • 提取主机名,用 re.escape 转义后拼成动态正则,传给后续所有 expect 调用
    这样不管提示符长什么样,只要包含约定的字符,都能正确匹配。加新交换机时只改配置字典,不动传输层代码。

另一点:context manager 管理连接生命周期

Telnet 连接如果不干净断开,交换机那边会残留 dead session,积多了之后直接拒绝新连接。pexpect 的 spawn 对象本身可以用 close() 关闭对应的子进程,但分散在各处的 connect/disconnect 调用很容易遗漏(尤其是异常路径)。

用 with 块把连接生命周期和上下文管理器绑定:

with SwitchClient(...) as client:
    output = client.exec("show vlan")
# 出块自动 disconnect——__exit__ 里 close pexpect session,哪怕中间抛了异常

这对稳定性影响巨大,但实现成本接近于零。

第二层:语义——用 Adapter 模式屏蔽命令差异

传输层搞定之后,下一个问题是:怎么让调用者不用记住 show mac-addr-table / display mac-address / show mac-address-table 是同一个意思。

我的做法是一个驱动一个类,每个类实现相同的接口(方法名一样,底层命令不同):

port_status() → "show interfaces status all" (FASTPATH)
port_status() → "display interface" (SKS8310)
port_status() → "show interface" (RTNOS)

调用者永远只需要知道 port_status 这一个词。

这种方式加新型号的成本极低:建个文件,写个类,定义一下 TRANSPORT,写几个 action 方法,往注册表里加一行。新驱动甚至不需要理解 pexpect 的任何细节——它只管定义"这个动作应该发什么命令",剩下的传输层全接管。

第三层:LLM 交互——不让中间层做太多事

这是整套设计里最重要的一个决策:中间层不做输出解析。

所有命令返回的都是原始 CLI 文本,不转成 JSON,不做结构化提取。为什么?

因为 LLM 天然擅长理解非结构化文本。你把一坨 show mac-addr-table 的输出原样丢给它,它自己能找到需要的字段、判断有没有异常。你在中间层做结构化解析,反而会丢掉信息(解析总有遗漏),而且需要持续维护(固件升级输出格式变了怎么办)。

LLM 怎么发现它能干什么

光有工具还不行,LLM 得"知道"这个工具能干什么。三种方式:

  1. 说明书(SKILL.md)
    给 LLM 准备一份文档,写清楚有什么子命令、每个交换机型号支持哪些预定义动作、有什么已知限制。LLM 读完之后就能根据用户意图自行决定调哪个命令。
  2. 动态发现(actions 子命令)
    switch-cli actions switch0 返回该交换机所有可用的动作名称。LLM 可以实时确认"这个交换机到底有没有 spanning_tree 这个方法",不用依赖文档保持同步。以后加新功能也不需要更新文档。
  3. 万能 fallback(exec 子命令)
    预定义动作覆盖不了的场景,让 LLM 直接发原始 CLI 命令。中间层只负责传输,不限制 LLM 能干什么。

整体信息流

一个完整交互走下来是这样的:

用户: "switch0 的所有端口都起来了吗?"

LLM 推理:
  1. 读 SKILL.md → 知道有 port_status 动作
  2. 构造命令 → switch-cli run switch0 port_status
  3. 拿到输出:
     1/0/1  | Up    | 1000 Full
     1/0/2  | Down  | -
     1/0/3  | Up    | 100 Full
  4. 翻译 → "switch0 的 1/0/2 端口挂了,其余正常"

LLM 在这里扮演"翻译官"——用户说人话,LLM 翻译成 CLI 调用,交换机的输出再翻译回人话。中间层只是一根管道。

局限

  • 目前只支持 Telnet(我家交换机只开了 Telnet),加 SSH 只需要把 pexpect 的 spawn 换成对应的子类
  • 部分交换机没有"关闭分页"的等效命令(比如 SKS8310),大输出会超时,目前只能调大 pexpect 的 timeout 或者接受截断
  • 如果未来要支持配置变更(不只是诊断),需要增加模式切换(configure terminal)和 commit/diff 机制,但 pexpect 本身完全支持这种交互式会话

最后

这东西本身没什么技术含量——就三百来行 Python,核心全依赖 pexpect 的伪终端能力和 pyyaml 的配置解析。有意思的是组合方式:一个薄到不能再薄的适配层,加上 LLM 的自然语言能力,就能让四个不同系统的交换机对外呈现出完全一致的接口。

甚至,你完全可以把这个博文扔给你的Agent,让他Vibe出相同逻辑的代码,这也算一种面向Vibe的开源项目吧?