我给 AI Agent 写了个下载器:293KB 的网页,不再吃掉 7 万 token

2 阅读9分钟

我给 AI Agent 写了个下载器:293KB 的网页,不再吃掉 7 万 token

一、从一个让人心疼的场景说起

我让 Agent 去查 MDN 上 HTTP Headers 索引页里某个 header 的语义。它很自然地执行了:

curl -sL https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers

然后 293,636 字节的 HTML 进了上下文。按 chars/4 粗估,这一页约 73,000 tokens,直接吃掉 128k 窗口的 57%。再查一个页面就该 compaction 了,前面推理过什么基本全丢。

有人会说:用 webfetch 啊。我实测过一次,让它「列出所有 header 名」,工具结果约 2,714 字符(~680 tokens),确实省了。但拿到的是有损投影:172 个 header 名,仅此而已。想再问「某个 header 的参数表怎么写」,得重新发起一次完整往返,重新处理整页。字节从头到尾没落盘,每次提问都要再付一次钱。

于是我发现问题根本不是「下载慢」,而是:

这些字节压根不该经过模型。

人用下载器,是因为人要「打开文件看看」;Agent 用下载器,是为了让文件存在于磁盘上,之后用 grep / python 按需取几段。它不需要把整页读进 context,也不需要进度条、不需要设置面板。

二、换一种契约:字节落盘,只回一份 JSON 回执

OpenDrain 把下载抽象成一个函数:

fetch(url, target, checksum?, mirrors?, resume?)

对应一条命令:

opendrain fetch \
  --url https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers \
  --target ~/.cache/mdn-http-headers.html

stdout 只回一个结构化 JSON,进度信息走 stderr(给人看),stdout 保持机器可读:

{
  "ok": true,
  "status": "completed",
  "target": "/Users/xxx/.cache/mdn-http-headers.html",
  "bytes": 293636,
  "sha256": "sha256:46ce52cada89...",
  "backend": "native",
  "selected_mirror": "https://developer.mozilla.org/...",
  "duration_ms": 780
}

这份回执 557 字节,约 140 tokens。Agent 拿到它就知道:文件在哪、多大、哈希是多少、能不能信。之后想查 Accept-Ranges 段落,用 grep 从磁盘取那一段,实测约 997 tokens,想取几段取几段,文件还能离线复用。

三、实测:省了多少,代价是什么

测试环境:macOS 27.0 arm64、家庭宽带、Go 1.23.4 编译、aria2c 1.37.0。完整报告在仓库的 BENCHMARK.md。

方式进入 context 的量内容保真二次提问成本
curl 直接进 context~73,000 tokens全量0(但窗口已快炸)
webfetch~680 tokens有损投影每次重付整页处理成本
OpenDrain fetch~140 tokens磁盘全量 + sha256 可验证按需 ~997 tokens/段

同等信息获取下首次消耗约 500× 差距。另外两个数字:首次下载 780ms,第二次调用同一命令命中缓存 6ms(重新哈希校验后幂等返回)。

但我得把不好看的数据也放上来。场景 2 下载 28MB 的 go1.23.4.src.tar.gz:

实现耗时有效带宽
curl 单连接基线6.67 s4.23 MB/s
OpenDrain native(4 分片并发,含 0.4s 探测)8.24 s3.42 MB/s
OpenDrain aria2(8 连接)9.63 s2.93 MB/s

多连接没跑赢 curl 单连接。 运营商对单连接已经给足带宽,分片协调反而成了开销。所以别把 OpenDrain 理解成「更快的下载器」——它的价值在 Agent 维度:零 token 消耗、状态可续传、强校验、结构化错误。速度只在后端和镜像真能帮上忙时顺带优化,不迷信并发。

四、三个「为 Agent 而不是为人」的设计

1. 进程随时会死,所以状态必须落盘

Agent 会在任意一步被杀:compaction、超时、用户 Ctrl-C。普通下载器被杀就等于从头再来,而 Agent 被杀后大概率会原样重发同一条命令。

所以每个任务按 `sha256(url绝对路径(target))生成 ID,状态写到~/.opendrain/state/.json,写入用 tmp + fsync + rename 保证原子。下载中的字节只存在于 .part,校验通过后才 os.Rename` 发布——目标路径永远不会出现半成品。

实测中断续传(75MB 文件):

第 1 次:4.0s 时 SIGINT
  {"status":"cancelled","error":{"code":"cancelled",...}}
  磁盘:done=6,537,216 / 76,280,703,控制文件保留

第 2 次:原参数重调
  {"ok":true,"resumed":true,"backend":"aria2"}   24.1s 完成
  sha256 与 go.dev 官方一致

这里还有个并发坑:两个 Agent 同时 fetch 同一个 (url, target),会互踩状态文件和同一个 .part。后来加了按任务 ID 的跨进程 flock,覆盖从读状态到原子发布的整条流水线。选 flock 而不是锁文件,是因为进程被杀时锁由内核释放,不会留下一个永远解不开的陈旧锁——这恰好是 Agent 最常见的死法。抢不到锁时按 ctx 取消退出,不无限阻塞。

2. 它不会「打开看看」,所以校验必须是强制的

人可以看着文件说「好像下全了」,Agent 不行。所以校验通过后才会发布,校验不匹配就删掉 .part 并给出结构化错误:

{
  "error": {
    "code": "checksum_mismatch",
    "message": "checksum mismatch; partial file removed",
    "detail": {"expected": "sha256:...", "actual": "sha256:..."}
  }
}

有个细节我后来改了:缓存命中的文件如果校验不过,不再删除用户已有的文件,而是改名为 <target>.corrupt(重名则加时间戳),路径写进 error.detail.quarantined_as。因为那个文件可能是用户自己放进去的,删掉是越界;保留下来才能复查到底哪里不对。

3. 失败要显式,不要静默降级

所有失败都带 code:no_mirror_reachable、all_mirrors_failed、http_error、backend_failed、cancelled、incomplete、state_corrupt……Agent 能据此决定重试、换源还是放弃,而不是解析一段人类日志。

五、架构:auto 路由与「别信响应头」

main.go                 CLI(fetch / status / clean / install)
internal/core
  fetch.go              编排:校验 → 探测 → 选后端 → 下载 → sha256 → 原子发布
  probe.go              镜像探测:并行 GET Range 0-0,测 TTFB 与 Range 支持
  store.go              状态落盘 + 任务锁
internal/backend
  aria2.go              aria2c 子进程 + JSON-RPC
  native.go             纯 Go:分片并发、逐 chunk 续传、30s stall 检测、镜像切换
  • 探测:所有候选并行发一个 Range: bytes=0-0,按「可达 > 支持 Range > TTFB 低」排序,主源中途失败自动切次优并保留已下载分片。
  • auto 路由:已知大小 < 8MB 走 native(aria2 进程开销不划算,实测 4.1s vs 0.85s);更大则优先 aria2,不可用回退 native。
  • native:最多 6 连接、目标分片 16MB、单 URL 重试 3 次、30 秒无进展判定 stall。
  • skill 形态:opendrain install 把 SKILL.md 装进 opencode / Claude Code / zcode / agents 的 skills 目录,Agent 自己就知道什么时候该用 fetch、什么时候不该用。

整套是纯 Go 标准库、零第三方依赖,编译出单文件二进制,Release 里有 darwin / linux / windows 共 5 个平台产物。

六、代码审查挖出的四个真 bug(这段比功能更值得写)

第一版能跑,但一轮审查下来发现四个功能性缺陷,每一个都加了回归用例:

问题现象修复
探针相信 Accept-Ranges 头有些 CDN 声明支持 Range 却对 Range 请求回 200,导致每次下载都 all_mirrors_failed以实际响应为准:拿到 200 就判定不支持 Range,退回单流
resumed 虚报残片文件丢了、实际从头重下,JSON 仍报 resumed: true把标志的计算挪到「残片存活检查」之后
相对路径下 status 查不到任务fetch 和 status 算出两个不同 IDID 统一按绝对路径归一化
状态文件损坏导致永久卡死同一命令永远返回 internal_error,Agent 会无限重试fetch 自动隔离坏文件从头开始并在 warnings 说明;status 报 state_corrupt;新增 clean

还有一次真实损坏被校验层当场拦下,值得单独记一笔:native 后端预分配的是稀疏 .part 文件,切到 aria2 后,aria2 的 continue 语义把「已有字节」当成「已下载前缀」,结果产出混入空洞的文件。校验层立刻报 checksum_mismatch,.part 被删除,目标路径始终干净。用 curl 独立从两个镜像全量下载比对,确认镜像内容没问题,损坏来自引擎跨后端复用残片——修复方式是后端切换时作废旧残片、诚实重下,resumed 不虚报。

这类问题只在「进程会被杀、后端会切换」的 Agent 场景才暴露得出来。go test ./... 现在有 20 个用例,包括一个专门谎报 Accept-Ranges 的测试服务器和一个假的 aria2 RPC。

七、边界:它不做什么

诚实列一下,省得你踩坑:

  • 要摘要进对话 → 用 webfetch;OpenDrain 只负责把字节放到磁盘。
  • 带 Cookie / Token 的 URL → 用 curl 带凭据,OpenDrain 不做认证。
  • git 仓库 → 用 git clone。
  • GUI / BT / 流媒体解析 / P2P 调度 / 跨设备同步 → 都不做。
  • Windows 上的跨进程串行化:标准库没有 advisory flock,目前该平台不做跨进程锁(状态写入本身仍是原子的)。macOS / Linux 完整生效。
  • token 估算方法学:按 chars/4 估算,webfetch 底层管道成本不可见,这一点无法精确计量。

八、跑起来

npm 包还在走发布流程(npm 上暂未上线),当前请用源码构建或 Release 二进制:

# 源码(Go 1.21+,仓库在 Go 1.23.4 下测试)
git clone https://github.com/yoyo636/opendrain.git
cd opendrain
go build -o opendrain .
./opendrain install

# 或者直接取 macOS arm64 预编译二进制
curl -L -o opendrain \
  https://github.com/yoyo636/opendrain/releases/download/v0.1.0/opendrain-darwin-arm64
chmod +x opendrain && ./opendrain install

install 会把 fetch skill 装进各 Agent 的 skills 目录,并把二进制放到 ~/.local/bin。之后:

opendrain fetch --url URL --target PATH \
  [--checksum sha256:HEX] [--mirror URL]... [--no-resume] [--backend auto|aria2|native]
opendrain status --url URL --target PATH
opendrain clean --all

九、写在最后

这个工具适合的场景很具体:你在写 Agent / skill,需要拉数据集、模型权重、发布包、大 HTML 文档,任务长到会跨会话、会被中断,且你不希望这些字节穿过模型。如果你只是偶尔 curl 个小文件,它没有任何优势。

我更想听到的反馈是「哪条边界划错了」——尤其是镜像探测的择优策略、跨后端残片作废是否过于保守、以及 Windows 下有没有不引入依赖的跨进程锁方案。

仓库与完整基准报告:github.com/yoyo636/ope… (MIT)

如果这篇文章对你有用,欢迎点个赞;有更狠的边界用例,评论区见。