数据截至 (上游 commit b77d61291399)
代理层与 wrap — 零改代码接进任何 agent
30 秒导读: 前四章讲的是"怎么把一段内容压小而不弄坏它"。这一章讲怎么把那套本事送到用户面前——办法是一台伪装成大模型 API 的本地反向代理,加一条
headroom wrap <agent>命令,把编码 agent 的 base_url 指过来。agent 的代码、插件、配置逻辑一律不动。
本章覆盖两件事:
| 半场 | 讲什么 | 主要代码 |
|---|---|---|
| 服务端 | 这台代理开了哪些门、请求怎么流、三家 wire format 怎么兼容、钱怎么记 | headroom/proxy/ |
| 接入端 | 一条命令怎么"接管"一个 agent、崩了怎么自愈、怎么回滚 | headroom/cli/wrap.py |
不重复的内容:压缩算法本身见 01-pipeline-and-router.md 与 02-compressors.md;可逆检索见 03-ccr.md;缓存安全策略见 04-cache-safety.md。
1. 这是什么(零基础也能懂)
一句话定义: 一台跑在 127.0.0.1:8787 的本地 HTTP 服务,长得跟 Anthropic / OpenAI / Gemini 的 API 一模一样;请求经过它时被压缩,再原样转发给真正的上游。
解决什么问题。 假设你天天用 Claude Code 改一个大仓库。上下文越滚越长,每一轮都要把几十万 token 的历史重发一遍,账单和延迟一起涨。你想省,但你不可能去改 Claude Code 的源码——它是别人的闭源二进制。
Headroom 的答案:插在中间。 几乎所有编码 agent 都留了一个"换 API 地址"的口子(环境变量或配置文件),因为大家都要支持 Azure / Bedrock / 自建网关。Headroom 就借这个口子进来。
用起来什么样:
$ headroom wrap claude
╔═══════════════════════════════════════════════╗
║ HEADROOM WRAP: CLAUDE ║
╚═══════════════════════════════════════════════╝
Proxy already running on port 8787
Dashboard: http://127.0.0.1:8787/dashboard
Launching Claude Code (API routed through Headroom)...
ANTHROPIC_BASE_URL=http://127.0.0.1:8787
到这里 Claude Code 已经在跑了,行为和平时一模一样,只是每个请求都先过了一遍压缩。命令行长什么样见 headroom/cli/wrap.py:4607 的 wrap 命令组 docstring,里面逐条列了受支持的 agent 及其对应子命令。
一句话直觉: 把它当成装在你和模型厂商之间的一个变压器。两头的插头形状不变(wire format 不变),中间的电压变了(token 少了)。变压器坏了就直通——本章后面会反复出现这个"坏了就透传"的原则。
2. 顶层全景:一次请求在代理里走过的路
先看结构,再抠细节。这张图从上往下读,是一个 POST /v1/messages 的完整生命周期。
Claude Code / Codex / aider ...
│ POST /v1/messages (它以为这是 api.anthropic.com)
▼
┌──────────────────────────────────────────────┐
│ ① 安全闸门 _security_gate │ 最外层:可选 bearer token
│ + 安全响应头 + 管理端点审计 │ 非本机无 token → 401
├────────────────────────────────────────────── ┤
│ ② 打标中间件 _record_headroom_stack │ 剥 /p/<项目名> 前缀
│ 项目归属 / 客户端识别 / 入站计数 │ 认出 codex 就补 X-Client 头
├──────────────────────────────────────────────┤
│ ③ 路由匹配 │ 三层:精确路由 → 供应商路由
│ │ → 兜底 catch-all
├──────────────────────────────────────────────┤
│ ④ handler(按 wire format 分家) │ anthropic / openai / gemini
│ 读 body,同时留一份"原始字节" │ / batch / bedrock
├──────────────────────────────────────────────┤
│ ⑤ 该不该压? CompressionDecision │ bypass > 关闭 > 空消息 > 授权
├──────────────────────────────────────────────┤
│ ⑥ 压缩(丢进有界线程池,带超时+隔离) │ 失败 = 原样往下走,绝不报错
├──────────────────────────────────────────────┤
│ ⑦ 选出站字节 select_outbound_body │ 没改过 → 原字节;改过 → 规范 JSON
│ 剥掉所有 x-headroom-* │
└──────────────────────────────────────────────┘
│
▼ 真上游 api.anthropic.com
│
◄── 流式回包:字节原样回给客户端,
同时并行喂一台 SSE 状态机做记账
▼
记账:savings_tracker / prometheus / 落盘 ledger
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
create_app | 组装整个 FastAPI 应用,注册全部路由与中间件 | headroom/proxy/server.py:2525 |
HeadroomProxy | 有状态的核心对象:HTTP 客户端、压缩执行器、指标、缓存 | headroom/proxy/server.py:756 |
register_provider_routes | 注册所有供应商路由 + 最后的兜底 catch-all | headroom/providers/proxy_routes.py:251 |
| 五个 handler mixin | 每种 wire format 一套解析/转发/回包逻辑 | headroom/proxy/handlers/ |
select_outbound_body | 决定"到底发哪串字节给上游" | headroom/proxy/body_forwarding.py:298 |
SavingsTracker | 把省下的 token 折成钱并落盘 | headroom/proxy/savings_tracker.py:628 |
wrap 命令组 | 起代理 + 改 agent 配置 + 拉起 agent + 退出时还原 | headroom/cli/wrap.py:4607 |
主线走一遍(不进代码): 客户端发一个它以为要去 Anthropic 的请求 → 中间件打标 → 路由把它交给 Anthropic handler → handler 决定压不压、压多少 → 压完选字节 → 转发给真上游 → 回包边流边记账。整条路上任何一步出问题,兜底动作都是"把客户端原来那串字节原样发出去"。
3. 路由表:这台服务器到底开了哪些门
create_app 一共挂了四类路由。顺序很关键,因为最后有一个吃掉一切的兜底路由。
3.1 四类门
第一类:数据面——伪装成上游 API 的那些路径。它们不在 server.py 里,而是由 register_provider_routes 在最后统一注册(headroom/proxy/server.py:5186)。
| 路径 | 方法 | 交给谁 |
|---|---|---|
/v1/messages | POST | handle_anthropic_messages |
/v1/messages/batches 及其子路径 | POST/GET | handle_anthropic_batch_* |
/v1/chat/completions、/chat/completions | POST | handle_openai_chat |
/v1/responses | POST | handle_openai_responses |
/v1beta/models/{model}:generateContent 等 | POST | handle_gemini_* |
/v1beta1/projects/…/publishers/{p}/models/{m}:rawPredict | POST | Vertex 分流:Anthropic 走 handle_anthropic_messages,Google 走 Gemini |
/model/{model_id}/invoke | POST | Bedrock(仅在配了 bedrock_api_url 时才挂) |
/{path:path} | GET/POST/PUT/DELETE/HEAD | 兜底透传 |
声明式的路由表本身在 headroom/providers/route_specs.py,PROVIDER_HANDLER_ROUTES 与 PROVIDER_PASSTHROUGH_ROUTES 两个常量元组把"哪个路径交给哪个 handler"从代码里抽成了数据。
第二类:健康探针——/livez、/readyz、/health(headroom/proxy/server.py:3445、:3430、:3436)。
第三类:观测面——/stats、/stats-lifetime、/stats-history、/metrics、/dashboard、/quota。
第四类:本机专属的控制面——/v1/compress、/v1/retrieve/*、/v1/toin/*、/debug/*、/admin/upstream、/transformations/feed。
3.2 兜底 catch-all:为什么必须最后注册
register_provider_routes 的最后一行挂了一个吃掉一切的路由(headroom/providers/proxy_routes.py:517 的 passthrough):
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(request: Request, path: str): ...
这条路由存在的理由是代理必须对未知路径透明。Copilot 的内联补全、ChatGPT 的账号接口、各种私有网关路径,Headroom 都不认识,但也不能 404——404 会让 agent 直接报错。所以不认识的一律原样转发。
代价是:任何注册得比它晚的路由都永远匹配不上。代码里对此有两处显式注释,分别护住 dashboard 静态资源的挂载(headroom/proxy/server.py:3641)和 /favicon.ico(:3788)。
x-headroom-base-url 请求头可以在这里临时改上游地址,让同一台代理服务多个网关。
3.3 本机保护:两道闸,不是一道
这是本章最容易看漏但最重要的安全设计。/debug/*、/v1/retrieve/*、/v1/toin/* 这些端点只允许本机访问,判定函数是 require_loopback(headroom/proxy/loopback_guard.py:173)。它要过两道闸:
请求进来
│
├─ 闸① request.client.host 是回环地址吗? ── 否 ─▶ 404
│ (127.0.0.0/8、::1、::ffff:127.0.0.1)
▼ 是
├─ 闸② Host: 头也写着回环地址吗? ── 否 ─▶ 404
│ (127.0.0.1:port / [::1]:port / localhost:port)
▼ 是
放行
为什么光看 IP 不够? 这是 DNS rebinding(DNS 重绑定攻击:恶意站点先解析到自己的服务器,再把同一域名重解析到 127.0.0.1,于是受害者浏览器发出的请求 IP 上确实是本机)。此时 request.client.host 就是 127.0.0.1,闸① 直接放行。但浏览器的 Host: 头仍然写着 attacker.com,所以闸② 拦得住。模块 docstring(headroom/proxy/loopback_guard.py:20-37)把这个攻击链写得很清楚。
返回 404 而不是 403 也是刻意的:403 等于告诉扫描器"这里有东西但你没权限",404 则和"根本没这条路由"无法区分。
写操作还要再加一道 require_same_origin(headroom/proxy/loopback_guard.py:219)。它防的 是另一种攻击:远程页面直接用 fetch 打 http://127.0.0.1:8787/stats/reset,Host: 头写的是真实的回环地址,闸② 也过了。但浏览器会带上反映页面真实来源的 Origin: 头,于是这道闸只要 Origin 存在且不是回环就拒。CORS 拦不住这个——CORS 只阻止攻击者读响应,不阻止服务器执行。
3.4 同一个端点,两副面孔
有些端点既要给本机 dashboard 用,又要给网络里的监控用,于是采用了按调用方裁剪 payload 而不是一刀切 404 的做法。这就是 _request_is_loopback(headroom/proxy/server.py:2362)存在的原因:它和 require_loopback 判的是同一件事,但返回 bool 而不是抛异常。
| 端点 | 本机调用方看到 | 网络调用方看到 |
|---|---|---|
/health | 完整 payload,含上游 URL 和 backend 配置 | 只有 status + checks(等同 /readyz) |
/stats | 额外含 recent_requests、request_logs、config | 只有聚合计数器,没有逐请求元数据 |
/stats-lifetime | 含 projects 明细和持久化错误 | 剥掉 projects,错误置空 |
_request_is_loopback 还开了一个口子:容器化部署时浏览器经网桥网关访问,peer IP 是网关 IP 而非 127.0.0.1。运维可以用 HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS 显式白名单该 网关,默认为空(即最安全)。
/stats 的敏感块另有一条更宽的授权路径 _request_can_view_dashboard_metadata(headroom/proxy/server.py:2405),它允许 CIDR 白名单内的远程 dashboard,但要求 Host: 必须是 IP 字面量,且如果带了 Origin/Referer 就必须同源——防的是"通过受害者浏览器读走敏感元数据"。
3.5 健康三兄弟:各管各的
| 端点 | 检查什么 | 谁用 | 非 200 的含义 |
|---|---|---|---|
/livez | 只看事件循环回调是否健康 | k8s liveness | 503 = 进程该重启 |
/readyz | 先探一次上游,再看整体就绪 | k8s readiness | 503 = 暂时别派流量 |
/health | 同 /readyz,但本机调用方多拿配置块 | 人 / headroom doctor | 永远 200(状态在 body 里) |
/health 恒返 200 是刻意的:它是给人和诊断工具看的详情页,不是编排系统的判决依据。判决交给前两个(headroom/proxy/server.py:3468-3476 的注释直说了这一点)。
三者都在 _security_gate 的 _AUTH_EXEMPT_PATHS 里(headroom/proxy/server.py:3364),即使配了 HEADROOM_PROXY_TOKEN 也不要求鉴权——否则绑到非回环的容器就没法被探活。
3.6 控制面:压缩、取回、模式统计
/v1/compress(headroom/proxy/server.py:5182) 是"只压不发"的端点,给 TypeScript SDK 这类想自己发请求的调用方用。它默认只允许本机,但留了 HEADROOM_COMPRESS_ALLOW_REMOTE=1 让运维把它开给内网 sidecar(Kong、LiteLLM),且只解除这一条路由的回环依赖,其他 _require_loopback 路由不受影响(headroom/proxy/server.py:5176-5180)。
handle_compress(headroom/proxy/handlers/openai.py:9427)的 config.mode 有三档:默认无标记(压完直接发给上游,不需要 CCR 回取)、ccr(写 CCR 标记和存储)、lossy_inline(先无损折叠再 Kompress)。还有个 config.frozen_message_count 把前 N 条消息钉成逐字节不变——正是 04-cache-safety.md 讲的前缀缓存保护在 HTTP 层的旋钮。
/v1/retrieve/{hash_key}(:4990)与 /v1/retrieve/tool_call(:5022) 是 CCR 的服务端(详见 03-ccr.md)。前者按 hash 直接取回原文,后者接受 Anthropic 和 OpenAI 两种 tool_call 格式,替调用方把取回结果拼成对应供应商的 tool_result。两者都是本机专属——原文里可能有整个代码库的内容,不能给网络。
/v1/toin/*(:4882、:4900、:4955) 暴露工具输出模式(TOIN)的学习统计。同样本机专属,而且代码里对"含用户查询原文的字段"做了额外剥离——_feedback_stats_without_query_text(headroom/proxy/server.py:2480)专门把 common_queries、queried_fields 从 HTTP 响应里摘掉:这些 key 是拿 agent 的查询原文直接拼的,进程内用来做压缩决策没问题,出 HTTP 就不行。
3.7 项目归属:两种打标方式
/p/<项目名>/v1/messages 这种带前缀的路径会被中间件剥掉前缀并记下项目名(strip_project_path_prefix,headroom/proxy/project_context.py:45)。为什么要有这条路?因为 aider、Cursor、Copilot BYOK 这类客户端只能改 base URL,不能加自定义请求头。能加头的(claude/codex wrap)则走 X-Headroom-Project,且优先级更高(headroom/proxy/server.py:3254-3261)。
剥前缀必须发生在任何人读 request.url 之前,因为 Starlette 会缓存 URL 对象——这也是为什么它写在中间件的最前面,以及为什么 WebSocket 要单独一个 WebSocketProjectPrefixMiddleware(headroom/proxy/server.py:2509)。
4. 两个"宁可吵也不要静默降级"的设计
代理最坏的失败模式不是崩,是看起来在跑但什么也没做。有两处专门针对这一点。