跳到主要内容

数据截至 (上游 commit 352f1bd7c1a0)

02 · API Gateway 与模型代理

本章讲什么: 这是 v1.0 的心脏。「agent 零改动」的全部魔法都藏在代理的 URL 设计和三个改写动作里。看完你会明白:为什么 rollout_id 要进 URL、为什么网关要没收 agent 的采样参数、以及 token id 是怎么原路带回的。

1. 服务装配:一个小 FastAPI 应用

create_appagentlightning/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_dependencyapp.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_proxyagentlightning/server/routes/proxy.py:33-49):mode 必须是 train/val;上游路径只认 chat/completions/completions;rollout 必须已存在(404)。注意流式直接拒绝——forward_requeststream=True 一律 400 “Streaming responses are not supported”(agentlightning/server/proxy.py:117-118)。这是刻意的:代理要拿到完整响应才能记账,而流式记账需要另一套 SSE 组装逻辑,v1.0 选择不做。

3. 三个改写动作:ProxyRouter

ProxyRouteragentlightning/server/proxy.py:39)干两件事:选端点、改请求体。

3.1 选端点:稳定哈希 + 前缀缓存

select_serverproxy.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_bodyagentlightning/server/proxy.py:62-81)对请求体做三处改写:

  1. 模型名换成训练目标模型model 字段被替换为配置里的 default_proxy.model_nameagentlightning/config/server.yaml:5)——agent 请求什么模型都无所谓,训谁就转发给谁。
  2. 温度被没收:train/val 各自的温度来自网关配置(config/server.yaml 默认 train=1.0、val=0.7),agent 自己传的 temperature 被覆盖。这是 RL 正确性的需要:采样参数必须由训练算法统一控制,否则同一策略在不同 rollout 下行为不同,优势估计就失真了。
  3. 强制 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_idslogprobs=True 让它带每个被选 token 的 logprob。响应原样落进 model_request 事件(_capture_eventproxy.py:208-237),训练侧直接取用(第 04 章),全程不过二次分词。事件消费端还会把 logprobs 解析成 per-token 浮点数组、坏数据安全降级为 None_extract_choice_log_probsagentlightning/server/routes/events.py:62-97,docstring 明言 malformed 响应返回 None 让训练桥丢弃该样本而不是让 HTTP 查询失败)。

4. 上游转发:重试与记账

4.1 指数退避重试

_send_upstream_with_retriesproxy.py:147-178)最多试 6 次(_UPSTREAM_MAX_ATTEMPTSproxy.py:27):

可重试情况最终失败的 HTTP 状态
httpx.TimeoutException504(超时)
httpx.TransportError502(传输失败)
408/409/429/5xx(_is_retryable_statusproxy.py:194原状态码返回

退避 delay = min(0.5 × 2^n, 8s) × uniform(0.75, 1.25)_retry_delay_secondsproxy.py:198-200)——指数增长、封顶 8 秒、±25% 抖动打散并发重试。实际重试了几次会记进事件的 retry_count(响应扩展头 agl_retry_countproxy.py:139,168)。

4.2 记账

每次转发(无论成败)都调 _capture_eventproxy.py:208-237)往账本写一条 model_request:完整请求体(改写后的)、完整响应体、命中哪个端点及版本、延迟、HTTP 状态、重试次数、usage 和 finish_reason。失败也记账——训练桥靠 status=="error"http_status>=400 过滤坏样本(第 04 章),而不是假装失败没发生过。

5. 暂停与排空:给权重热更新让路

5.1 PauseState

ProxyPauseStateagentlightning/server/proxy.py:84-90)是个带 asyncio.Lock 的小状态机:paused 标志、inflight 在途计数、建议重试秒数。暂停期间新请求立刻吃 429 + Retry-After + X-Agl-Paused: trueforward_requestproxy.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_gatewayagentlightning/verl/trainer.py:167-189;细节在第 04 章)。暂停是幂等的、恢复也是幂等的(proxy.py:163-165 注释),所以这个握手可以放心重试。

6. 客户端约定:哪些操作可以重试

配套的 AgentLightningSyncClientagentlightning/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.RetryTransportagentlightning/verl/agl_rollout_manager.py:164-169)。

小结: Gateway 用「URL 即上下文」免掉了所有 instrumentation;用三个改写动作(换模型、统一温度、强制 token id/logprobs)保证了训练数据的正确性;用稳定哈希吃满前缀缓存;用暂停/排空协议守住权重切换的数据一致性。下一章看谁把 agent 放到这个代理面前。