

家里不大,但是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 操作交换机"的时候,其实是三个独立的问题:
- 连接层:怎么跟不同系统的交换机建立会话(Telnet 登录、enable 提权、关分页)
- 语义层:怎么把"查端口状态"这个意图映射成不同系统上的具体命令(show interfaces status all vs display interface vs show interface)
- 交互层:LLM 怎么知道它能干什么、怎么干、干完了怎么理解结果
这三个问题如果放在一起解决,会搞出一个很难维护的代码。但如果你能把前两层封装成一个薄薄的 CLI 工具,第三层完全交给 LLM 的自然语言能力,事情就变得异常简单。
第一层:连接——不要写死,让它自己猜
多厂商交换机之间最烦人的差异不在命令上,在最基础的交互协议上:
| FASTPATH | SKS8310 | RTNOS | |
| 登录提示 | 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 得"知道"这个工具能干什么。三种方式:
- 说明书(SKILL.md)
给 LLM 准备一份文档,写清楚有什么子命令、每个交换机型号支持哪些预定义动作、有什么已知限制。LLM 读完之后就能根据用户意图自行决定调哪个命令。 - 动态发现(actions 子命令)
switch-cli actions switch0 返回该交换机所有可用的动作名称。LLM 可以实时确认"这个交换机到底有没有 spanning_tree 这个方法",不用依赖文档保持同步。以后加新功能也不需要更新文档。 - 万能 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的开源项目吧?

Comments NOTHING