Portway
语言 中文

减少编程智能体的网络传输量。

Claude Code、Codex 等编程智能体每一轮都会重新发送不断增长的对话。Portway 以上一次请求为基础压缩下一次请求,减少传输到接收端的数据量。接收端还原原始请求,再转发给模型。

brew install rath/tap/portway

macOS Apple 芯片 · Linux x86_64 / ARM64

一次真实智能体会话的回放。用真实二进制跑满 40 轮。上下文从 256 KiB 起步,每轮增长 4 KiB。
智能体发送的字节
13,877,261 B
仅用 zstd
3,114,150 B
Portway 实际发出的字节
101,551 B

第 40 轮:智能体发送了 429,900 字节,实际发出的只有 1,029 字节。

在屏幕上看着字节变少

Portway 自带终端面板(portway --tui)和浏览器控制台(--web)。每个请求发出时,两者都会显示智能体发送的大小、实际经过网络的大小,以及耗时和 token 数。下面的画面截自真实的 Claude Code 和 Codex 会话。

浏览器控制台:41 个请求,节省 61.8 MB(91%)的上传、正在使用的上游表格和每轮请求大小图表

安装,然后连接你的智能体

Homebrew 安装包已包含终端面板和浏览器控制台,无需 Rust 工具链。

想要引导式设置?适用于 Codex 和 Claude Code 的 Portway 插件可以配置客户端并检查连接。通过 Homebrew 或发布版二进制安装或升级到 Portway 0.2.5 及以上即可,无需从源码构建。插件只添加 setup 和 doctor 两个技能,不会改变流量路径。设置完成后,Codex 只有在用 codex -p portway 启动时才经过 Portway;而 Claude Code 因为 setup 写入了其用户设置,之后每个新会话都会经过 Portway。

  1. 安装 Portway。支持 macOS Apple 芯片和 Linux x86_64 / ARM64(glibc 2.28 或更高版本)。

    brew install rath/tap/portway

    不使用 Homebrew?可以下载发布版二进制或从源码构建。只有从源码构建时才需要 Rust 和 C 工具链。

  2. 将 API 挂载配置保存为 portway.toml。然后启动 Portway 和浏览器控制台:

    # portway.toml
    host = "127.0.0.1"
    port = 8787
    
    [upstreams]
    anthropic = "https://api.anthropic.com"
    codex = "https://chatgpt.com/backend-api/codex"
    portway --config ./portway.toml --web
  3. 在另一个终端连接 Claude Code。 先安装 Claude Code 并正常登录。保持 Portway 运行,用以下命令为本次启动设置连接:

    env ANTHROPIC_BASE_URL=http://127.0.0.1:8787/anthropic claude
  4. 用独立配置文件连接 Codex。 先安装 Codex 并运行 codex login 登录 ChatGPT。将以下内容保存到 ~/.codex/portway.config.toml。如果设置了 CODEX_HOME,则保存在该目录中:

    model_provider = "portway"
    
    [model_providers.portway]
    name = "portway"
    base_url = "http://127.0.0.1:8787/codex"
    requires_openai_auth = true
    wire_api = "responses"

    然后在项目目录中运行:

    codex -p portway

    此配置文件继承你平时的模型和偏好设置。直接运行 codex 即可使用原来的 provider。两个 CLI 都使用现有凭据,Portway 不需要自己的 API 密钥。不要在这两个挂载 URL 后添加 /v1。

  5. 检查第一个请求。 在 CLI 中发送一条简短消息,确认收到回复,且请求出现在 Portway 日志或浏览器控制台中。这种直接连接提供商的配置不会启用增量压缩。如果你已使用远程 Portway 服务器,请将客户端设置中的 127.0.0.1:8787 替换为可访问的服务器地址。

    持久设置、API 密钥方式和故障排查

  6. 添加接收端以启用增量压缩。在你服务前面的网关后面运行 portway receive,然后让发送端指向那个网关。接收端设置里有完整步骤。

同一个仓库还提供可嵌入的 Rust 库 portway-core,以及一个自带守护模式、报表、终端面板和浏览器控制台的 CLI。它把请求元数据记进 SQLite,绝不记录请求体、凭据或词典内容。

第 40 轮,就是第 39 轮加上 4 KB

编程智能体不在服务端保存任何状态。为了往前走一步,它会把系统提示词、此前的每一条消息、每一条工具调用结果重新发一遍,末尾再附上新产生的那几 KB。所以每个请求几乎都是上一个请求的副本。

每个请求实际承载的数据,按比例绘制。亮色的一端是此前任何请求都不曾包含的部分。
  1. 第 1 轮263,966 B
  2. 第 2 轮268,145 B
  3. 第 3 轮272,556 B
  4. 第 20 轮344,723 B
  5. 第 39 轮425,707 B
  6. 第 40 轮429,900 B

于是会话的上传量随长度的平方增长。一旦这些上传要你等待、要你付费,差距很快就会显现:

短小的单次请求收效甚微——没有更早的请求可以充当基线。

上一轮成了词典

Portway 分两处运行:一是智能体旁边的发送端,二是它调用的服务前面的接收端。两端各自保留着上一次达成一致的请求体。发送端用它压缩下一个请求;接收端则在你的服务看到一个字节之前,把原文还原出来。

  1. 智能体Claude Code、Codex,以及任何能配置 base URL 的客户端
  2. portway发送端,127.0.0.1:8787
  3. portway receive由你掌控的主机
  4. 你的服务模型服务、网关或服务商 API
上图会话的第 2 轮。模型收到的是智能体发来的同一批字节;只有中间那一跳承载增量。

两端如何就基线达成一致

  1. 第 1 轮播下词典。这个请求以 zstd 压缩后发出,并带上 X-Dict-Store: 1。接收端解压后转发出去,把请求体留下。

  2. 接收端确认自己留了什么。响应里带上 X-Dict-Stored,其中是它实际保存的字节的 SHA-256。只有当这个哈希与自己的副本一致时,发送端才会把该请求体当作基线。

  3. 第 2 轮用哈希指定第 1 轮。这个请求以 DCZ 格式发出:先是一段 40 字节的报头,携带词典的 SHA-256;接着是针对该词典压缩、带内容校验和的 zstd 帧。发送端为每条路由保留最多 8 个已确认的请求体,挑出与新请求共享最长前缀的那个。在只做追加的对话里,它就是上一轮。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     0x5E      |     0x2A      |     0x4D      |     0x18      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     0x20      |     0x00      |     0x00      |     0x00      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+             SHA-256 of the dictionary, 32 bytes               +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+         zstd frame compressed against the dictionary,         +
|         ending in a content checksum                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
DCZ 请求体,出自 RFC 9842 第 5 节。开头 8 个字节是 zstd 可跳过帧(skippable frame)的签名。能力通告、存储确认和重试标记属于 Portway 自己的约定,写在协议文档里。

基线不见了,就逐级退让

接收端会重启、会淘汰旧的请求体,也可能遇到从未见过的发送端。无论哪种情况,请求都不会失败。接收端会在你的应用跑起来之前拒绝它无法还原的请求,留下拒绝标记;发送端则降低压缩再试一次。

  1. 以已保存的请求体为基线的 DCZ 碰到 412(词典已不在),或 400、415(帧被拒),就退让
  2. 只用 zstd,不带词典 碰到 415(编码被拒),就退让
  3. 不压缩,照智能体发来的原样发送 始终接受

每个请求最多发送三次。Portway 只重放带有接收端标记的拒绝,而接收端会把这个标记从应用响应里抹掉。所以你的应用无法触发重放,同一个请求也绝不会跑两遍。应用返回的错误会原样回到智能体那里。

一轮的代价就是它新增的部分

同一个基准在四种上下文规模下各跑 20 轮。只用 zstd 能把每个请求减少约五分之四;用上一轮词典,无论上下文多大,最后都只剩约 1 KB。

首轮之后,每一轮在链路上的字节数
第 1 轮的上下文 未压缩 仅 zstd Portway 20 轮累计节省
128 KiB175,40541,9621,10498.5%
256 KiB306,50369,6081,05898.7%
512 KiB570,438122,0891,03498.8%
1 MiB1,094,276222,9371,04898.9%

最后一列包含了第一个请求,它要带上整个上下文来播下词典。合成文本经过调校,让只用 zstd 的压缩效果与它在 Portway 自身源码上大致相同。每次运行都能逐字节复现,每个还原出来的请求体都会与发送时计算的 SHA-256 比对。

cargo build --release --locked
python3 scripts/bench.py --turns 20 --context-kb 1024

它不做什么

它节省的是字节,不是 token。
模型收到的请求一模一样,计费的 token 也一模一样。服务商的提示缓存省下处理重复前缀的开销;Portway 省下发送它的开销。两者配合使用。
它需要一个接收端。
如果指向一个并不声明该协议的 API,发送端会原样、不压缩地转发请求。接收端可以是 portway receive,也可以是任何实现了该协议的服务。
它一重启就忘。
词典只存在两端的内存里,并按凭据彼此隔离。默认情况下,小于 32 KiB 的请求体不会成为词典。
它只支持普通的 HTTP/1.1。
没有 WebSocket、CONNECT、HTTP/2,也没有入站 TLS;TLS 和认证请交给前面的网关。请求最多缓冲到可配置的 256 MiB,响应则是流式的。