Compressing network CLI output before it reaches the LLM

The problem
I use AI agents to diagnose networks by running show commands on devices and feeding the output to an LLM. Raw CLI output is extremely verbose: fixed-width tables padded with spaces, 80-character separator lines, static legends, and page after page of counters that are almost all zero.
That verbosity is expensive. One run made ~90 tool calls and burned 548K tokens — and still hit context-budget exhaustion, because a few wide tables dominated the window. As a real example for a previous test, a single show interface counters errors is 220+ lines of mostly zeros.
Every token spent on separator dashes and zero rows is a token not spent on reasoning and a step closer to truncation, where the one row that actually mattered gets cut.
The architecture — compress before the AI sees it
The compression lives in a small library, neterse, that sits at the tool boundary so the model only ever receives the compact form. It exposes one verb — compact() — that dispatches on the shape of what you hand it (a live netmiko/scrapli connection, a response object, raw text or already-parsed rows), never on the library that produced it:
Two tiers, one contract:
Raw-text tier — declarative specs (one YAML file per
vendor/command, compiled to plain dicts so the runtime carries zero dependencies) drive generic strategies: fixed-width table → CSV, line-regex table → CSV, key/value scan. Genuinely stateful formats — multi-line interface blocks, banner state machines — stay as small hand-written compressors. That escape hatch is deliberate: expressing a stateful format "declaratively" just re-invents TextFSM in worse syntax.Parsed tier (opt-in:
pip install neterse[textfsm]) — when a TextFSM/Genie template already exists, neterse re-encodes those rows header-once instead of re-parsing the text. One good encoder covers thousands of commands across vendors for free, and the compactparsed:csv/parsed:toonoutput undercutsjson.dumps(rows)by ~45–50%.
Both tiers emit candidates; the library never dictates policy — smallest-wins, ledgers, and caching are the consumer's call (compact() is just the convenience that picks the smallest). Every compressor is fail-open: an exception, a non-string, or a result that doesn't actually shrink simply drops out of the running, so the original is never lost or enlarged. And lossiness is declared — any rendering that omits data-bearing fields says so (a dropped_fields manifest), while pure noise (separators, static legends, all-zero rows kept visible via (all zero) markers) is dropped freely.
The device, the GAIT audit trail, and snapshots always keep the full raw output; only the copy bound for the LLM is compressed.
NX-OS deep dives (run b9a60760)
show interface counters errors — 12,129 → 470 chars (−96%)
220 lines, almost entirely zeros. neterse keeps only the ports with a non-zero counter; every all-zero sub-table collapses to a single labelled line so its absence stays visible.
Before (excerpt):
--------------------------------------------------------------------------------
Port Align-Err FCS-Err Xmit-Err Rcv-Err UnderSize OutDiscards
--------------------------------------------------------------------------------
mgmt0 0 0 -- -- -- --
Eth1/1 0 0 0 0 0 0
Eth1/2 0 0 0 0 0 0
… (210 more mostly-zero rows across 4 sub-tables) …
After (full):
show interface counters errors (non-zero ports only; all others 0):
port,Align-Err,FCS-Err,Xmit-Err,Rcv-Err,UnderSize,OutDiscards
Eth1/37,0,6,0,6,0,0
Eth1/38,0,119,0,119,0,0
Eth1/39,0,2064,0,2064,0,0
Eth1/40,0,41,0,41,0,0
Eth1/41,0,2587,0,2587,0,0
Eth1/44,0,96,0,96,0,0
Eth1/45,0,48,0,48,0,0
Eth1/46,0,13,0,13,0,0
Eth1/47,0,195,0,195,0,0
port,Single-Col,Multi-Col,Late-Col,Exces-Col,Carri-Sen,Runts (all zero)
port,InDiscards (all zero)
port,Stomped-CRC (all zero)
show interface ethernet1/45 (detail) — 1,999 → 156 chars (−92%)
The full interface block is ~45 lines; neterse keeps the operational essentials as one key/value line, and suppresses error counters that are zero.
Before (excerpt):
Ethernet1/45 is up
admin state is up, Dedicated Interface
Hardware: 100/1000/10000/40000 Ethernet, address: 4874.1016.a8c1 (bia 4874.1016.a8b4)
Description: RFRA3213-Eth1/48
MTU 1500 bytes, BW 10000000 Kbit , DLY 10 usec
full-duplex, 10 Gb/s, media type is 10G
30 seconds input rate 839272 bits/sec, 650 packets/sec
0 runts 0 giants 48 CRC 0 no buffer
48 input error 0 short frame 0 overrun 0 underrun 0 ignored
… (35 more lines of counters) …
After (full):
Ethernet1/45 | status=up/up | desc="RFRA3213-Eth1/48" | mtu=1500 | bw=10000000Kbit | duplex=full/10 | in_bps=839272 | out_bps=585440 | in_errors=48 | crc=48
show port-channel summary — 617 → 90 chars (−85%)
The static ~10-line "Flags" legend is pure boilerplate and is dropped.
Before (full):
Flags: D - Down P - Up in port-channel (members)
I - Individual H - Hot-standby (LACP only)
s - Suspended r - Module-removed
b - BFD Session Wait
S - Switched R - Routed
U - Up (port-channel)
p - Up in delay-lacp mode (member)
M - Not in use. Min-links not met
--------------------------------------------------------------------------------
Group Port- Type Protocol Member Ports
Channel
--------------------------------------------------------------------------------
101 Po101(SU) Eth LACP Eth1/42(P) Eth1/43(P)
After (full):
group,port_channel,type,protocol,member_ports
101,Po101(SU),Eth,LACP,Eth1/42(P) Eth1/43(P)
show ip eigrp neighbors — 1,239 → 704 chars (−43%)
The two-line wrapped column header is dropped; the process/VRF context line and every neighbor are kept as CSV.
Before (excerpt):
IP-EIGRP neighbors for process 190 VRF default
H Address Interface Hold Uptime SRTT RTO Q Seq
(sec) (ms) Cnt Num
5 192.168.13.85 Eth1/38 10 11w6d 1 50 0 549000
12 192.168.110.0 Eth1/47 14 11w6d 1 50 0 22666
… (11 more neighbors) …
After (excerpt):
IP-EIGRP neighbors for process 190 VRF default
h,address,interface,hold,uptime,srtt,rto,q,seq
5,192.168.13.85,Eth1/38,10,11w6d,1,50,0,549000
12,192.168.110.0,Eth1/47,14,11w6d,1,50,0,22666
… (11 more neighbors) …
Across the fleet — two vendors, live
The same compact() call, unchanged, against live NX-OS and Arista EOS over netmiko:
| Device | Command | raw → compact | Saved | Won by |
|---|---|---|---|---|
| NX-OS | show interface brief |
9,022 → 4,375 | −52% | raw spec |
| NX-OS | show vlan |
4,680 → 1,924 | −59% | parsed tier |
| NX-OS | show ip arp vrf all |
591 → 118 | −80% | raw spec |
| NX-OS | show interface transceiver |
7,632 → 2,397 | −69% | parsed tier |
| EOS | show interfaces status |
4,693 → 2,663 | −43% | raw spec |
| EOS | show ip arp |
244 → 72 | −70% | raw spec |
| EOS | show vlan |
186 → 40 | −78% | raw spec |
show ip arp vrf all (NX-OS) — 591 → 118 chars (−80%)
A 7-line flag legend, a caption, and an entry count wrap two rows of actual data. neterse keeps every column of the table — including the usually-empty flags column, because it's data-bearing (FHRP / CFSoE / throttled / static-down) — and drops only the boilerplate.
Before (excerpt):
Flags: * - Adjacencies learnt on non-active FHRP router
+ - Adjacencies synced via CFSoE
# - Adjacencies Throttled for Glean
… (4 more legend lines) …
IP ARP Table for all contexts
Total number of entries: 2
Address Age MAC Address Interface Flags
10.80.252.1 00:08:05 000f.5343a.1a10 mgmt0
10.80.215.30 00:03:55 7c53.42934.3001 mgmt0
After (full):
address,age,mac,interface,flags
10.80.252.1,00:08:05,000f.535a.1a10,mgmt0,
10.80.215.30,00:03:55,7c53.4a24.3001,mgmt0,
Why two tiers — the fail-open payoff
The compression numbers matter, but the guarantee that makes this safe in front of an agent is that neterse can never lose data, and the two tiers are what let it degrade gracefully instead of falling back to raw:
On that same EOS box,
show ip arpmade ntc-templates raise aTextFSMError— the parsed tier produced nothing. The raw-text spec still compressed it −70%.NX-OS
show interface transceiveris a multi-line block a table spec can't express. There the TextFSM parsed tier (−69%) beat the hand-written code compressor (−58%), and smallest-wins picked it automatically.
Neither tier alone covers the fleet. Together, with a fail-open contract underneath. The worst case is the untouched raw output, never a corrupted or lossy one.
Impact
Across the whole run, device output dropped from 58,596 → 20,239 characters (−65%) with no loss of signal. And show interface counters errors now fits in context complete, instead of being truncated mid-table.
Get the next articles
One email when something new goes up about network automation, AI Agents and infrastructure. No spam, unsubscribe anytime.