Back to blog
AInetwork

Compressing network CLI output before it reaches the LLM

pDamascenopDamasceno
August 5, 20267 min read
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 compact parsed:csv / parsed:toon output undercuts json.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 arp made ntc-templates raise a TextFSMError — the parsed tier produced nothing. The raw-text spec still compressed it −70%.

  • NX-OS show interface transceiver is 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.