数据截至 (上游 commit 352f1bd7c1a0)
02 · API Gateway 与模型代理
本章讲什么: 这是 v1.0 的心脏。「agent 零改动」的全部魔法都藏在代理的 URL 设计和三个改写动作里。看完你会明白:为什么 rollout_id 要进 URL、为什么网关要没收 agent 的采样参数、以及 token id 是怎么原路带回的。
1. 服务装配:一个小 FastAPI 应用
create_app(agentlightning/server/app.py:53-93)把账本路由和代理路由装进同一个应用:
| 路由组 | 前缀/路径 | 鉴权 | 文件 |
|---|---|---|---|
| Rollout API | /api/rollouts... | 需要 | agentlightning/server/routes/rollouts.py |
| Event API | /api/rollouts/{id}/.../events | 需要 | agentlightning/server/routes/events.py |
| Model API | /api/models | 需要 | agentlightning/server/routes/models.py |
| LLM 代理 | /proxy/rollout/... | 需要 | agentlightning/server/routes/proxy.py |
| 代理管理 | /proxy/pause、/proxy/resume、/proxy/state | 需要 | agentlightning/server/routes/proxy.py |
| 健康检查 | /healthz | 免鉴权 | agentlightning/server/app.py:78-80 |
鉴权是可选的单一 API key(_build_auth_dependency,app.py:34-50):接受 Authorization: Bearer <key> 或 x-api-key 头;没配 key 会打警告 “Do not use in production”(app.py:58-59)。lifespan 里挂了三样全局状态:代理暂停状态、代理路由器、共享的 httpx 客户端(300 秒超时,app.py:66-73)。整个服务由 Hydra 配置驱动启动(agentlightning/config/server.yaml:host/port/key 和默认代理 参数)。
2. 代理的 URL 设计:归账不用钩子
2.1 路由即上下文
代理端点的路径长这样(agentlightning/server/routes/proxy.py:30-33):
POST /proxy/rollout/{rollout_id}/attempt/{attempt_id}/mode/{train|val}/openai/v1/{chat/completions | completions}
这个设计是全框架最聪明的一笔:把 rollout 身份编码进 URL 路径。agent 把它的 OpenAI base_url 设成 /proxy/rollout/abc123/attempt/0/mode/train/openai/v1 之后,它发的每一个 /chat/completions 请求天然带着自己的 rollout_id——不需要任何请求头注入、SDK 钩子或 sidecar。OpenAI 兼容客户端只需支持自定义 base_url(几乎全都支持),归账就自动完成了。
Controller 在 spawn agent 时把这个 base_url 通过环境变量 AGL_OPENAI_BASE_URL 注入(见第 03 章);mode 段由 is_train 决定(train/val 两套采样参数)。
2.2 校验
进入处理前有三道门(llm_proxy,agentlightning/server/routes/proxy.py:33-49):mode 必须是 train/val;上游路径只认 chat/completions/completions;rollout 必须已存在(404)。注意流式直接拒绝——forward_request 里 stream=True 一律 400 “Streaming responses are not supported”(agentlightning/server/proxy.py:117-118)。这是刻意的:代理要拿到完整响应才能记账,而流式记账需要另一套 SSE 组装逻辑,v1.0 选择不做。
3. 三个改写动作:ProxyRouter
ProxyRouter(agentlightning/server/proxy.py:39)干两件事:选端点、改请求体。
3.1 选端点:稳定哈希 + 前缀缓存
select_server(proxy.py:52-60)在同名模型的多个注册端点里选一个。算法很讲究:
# 示意,对应 agentlightning/server/proxy.py:52-60 select_server
pool = [servers[ep] for ep in sorted(servers)] # 排序保证池顺序稳定
digest = hashlib.sha256(rollout_id.encode()).digest()
index = int.from_bytes(digest[:8], "big") % len(pool) # 同一 rollout 恒选同一端点
return pool[index]
同一个 rollout 的所有请求永远落在同一个 vLLM 副本上(源码注释点明动机:“Stable ordering pins each rollout to one endpoint for prefix-cache reuse”,proxy.py:56)。多轮 agent 轨迹的 prompt 是逐轮增长的公共前缀,钉死端点就能吃到 vLLM 的 prefix cache,省大量重复预填充。这本质是一致性哈希思想的最小实现。
3.2 改请求体:没收采样参数 + 强制 token id
prepare_body(agentlightning/server/proxy.py:62-81)对请求体做三处改写:
- 模型名换成训练目标模型:
model字段被替换为配置里的default_proxy.model_name(agentlightning/config/server.yaml:5)——agent 请求什么模型都无所谓,训谁就转发给谁。 - 温度被没收:train/val 各自的温度来自网关配置(
config/server.yaml默认 train=1.0、val=0.7),agent 自己传的temperature被覆盖。这是 RL 正确性的需要:采样参数必须由训练算法统一控制,否则同一策略在不同 rollout 下行为不同,优势估计就失真了。 - 强制
return_token_ids: True:train 模式还加logprobs: True(val 模式不加 logprobs,省开销)。
第 3 点就是旧版「token id 之战」在 v1.0 的延续——而且打得更彻底:
3.3 token id 与 logprobs:为什么必须原路带回
RL 训语言模型,梯度在 token 序列上算;on-policy 还需要采样时的 logprobs 做重要性比。两个坑:
- 重分词漂移:代理若只返回文本,训练侧重新 tokenize 可能得到与 vLLM 实际采样不同的 token 序列,RL 就在「假动作」上学。
- 重算 logprobs 漂移:即使 token 对了,事后用模型重算 logprobs 与采样瞬间的数值也不一致(浮点顺序、batch 形状不同)。
v1.0 的解法是让 vLLM 亲自交出证据:return_token_ids=True 让响应带 prompt_token_ids/token_ids,logprobs=True 让它带每个被选 token 的 logprob。响应原样落进 model_request 事件(_capture_event,proxy.py:208-237),训练侧直接取用(第 04 章),全程不过二次分词。事件消费端还会把 logprobs 解析成 per-token 浮点数组、坏数据安全降级为 None(_extract_choice_log_probs,agentlightning/server/routes/events.py:62-97,docstring 明言 malformed 响应返回 None 让训练桥丢弃该样本而不是让 HTTP 查询失败)。
4. 上游转发:重试与记账
4.1 指数退避重试
_send_upstream_with_retries(proxy.py:147-178)最多试 6 次(_UPSTREAM_MAX_ATTEMPTS,proxy.py:27):
| 可重试情况 | 最终失败的 HTTP 状态 |
|---|---|
httpx.TimeoutException | 504(超时) |
httpx.TransportError | 502(传输失败) |
408/409/429/5xx(_is_retryable_status,proxy.py:194) | 原状态码返回 |
退避 delay = min(0.5 × 2^n, 8s) × uniform(0.75, 1.25)(_retry_delay_seconds,proxy.py:198-200)——指数增长、封顶 8 秒、±25% 抖动打散并发重试。实际重试了几次会记进事件的 retry_count(响应扩展头 agl_retry_count,proxy.py:139,168)。
4.2 记账
每次转发(无论成败)都调 _capture_event(proxy.py:208-237)往账本写一条 model_request:完整请求体(改写后的)、完整响应体、命中哪个端点及版本、延迟、HTTP 状态、重试次数、usage 和 finish_reason。失败也记账——训练桥靠 status=="error" 或 http_status>=400 过滤坏样本(第 04 章),而不是假装失败没发生过。
5. 暂停与排空:给权重热更新 让路
5.1 PauseState
ProxyPauseState(agentlightning/server/proxy.py:84-90)是个带 asyncio.Lock 的小状态机:paused 标志、inflight 在途计数、建议重试秒数。暂停期间新请求立刻吃 429 + Retry-After + X-Agl-Paused: true(forward_request,proxy.py:103-114)。
三个管理端点(agentlightning/server/routes/proxy.py:96-137):POST /proxy/pause(暂停)、POST /proxy/resume(恢复)、GET /proxy/state(查状态和在途数)。
5.2 为什么需要它
训练循环里「采集」和「更新权重」交替进行。vLLM 引擎热更新权重的那一瞬间,不能还有旧请求在途——否则部分 rollout 用旧权重生成、部分用新权重,数据就脏了。所以 Trainer 在 rollout 阶段结束后调用「暂停 → 轮询 inflight 到 0 → 再去更新权重」的排空流程(_pause_and_drain_gateway,agentlightning/verl/trainer.py:167-189;细节在第 04 章)。暂停是幂等的、恢复也是幂等的(proxy.py:163-165 注释),所以这个握手可以放心重试。
6. 客户端约定:哪些操作可以重试
配套的 AgentLightningSyncClient(agentlightning/client.py:36-82)体现了同一套幂等哲学:
post_with_retry:指数退避(封顶 30 秒)重试传输错误和 5xx,docstring 明言 “Only for idempotent endpoints”——模型注册(upsert)、rollout 创建(预指定 id)、网关暂停,都满足幂等。- GET 带简单重试(
client.py:53-62);rollout manager 的查询客户端还套了一层httpx_retries.RetryTransport(agentlightning/verl/agl_rollout_manager.py:164-169)。
小结: Gateway 用「URL 即上下文」免掉了所有 instrumentation;用三个改写动作(换模型、统一温度、强制 token id/logprobs)保证了训练数据的正确性;用稳定哈希吃满前缀缓存;用暂停/排空协议守住权重切换的数据一致性。下一章看谁把 agent 放到这个代理面前。