Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ rsync -avz --exclude='__pycache__' --exclude='*.pyc' "$SRC" root@<node>:"$DEST"

- **⛔ LoRA 用量计数器必须与请求生命周期严格配对(2026-09-14 修)**:`_resolve_lora_path()` 的 `acquire()` 原先只有两处 `release()`(正常完成 / abort 且 status∈{500,503}),**任何"丢 `rid_to_state` 却等不到调度器回复"的清理路径都会漏账**——abort 回显 `_handle_abort_req()`(等待队列/disagg 队列 abort 只回这一条,之后不会再有 batch output)、handler 失败 `_discard_pending_req_states()`。漏一笔 ⇒ 该 adapter 的 `unload_lora_adapter` 在 `wait_for_unload()` 永远等不到计数归零(§ 等待有界后 = 900s 超时 400),期间引擎仍占着 LoRA 槽位/显存,而 `/v1/models` 里它已经消失(两本不一致)。判据:`Start (load|unload) Lora adapter` 与后端每 rank 的 `LoRA adapter (loading|unloading) starts` **不配对**。新增任何"删 state"的路径时必须同时销账;已送出调度器的请求不要本地销账(改为发 abort + 保留 state,由调度器回执销账),否则可能在请求还在用 adapter 时把它卸掉。详见 `docs/agent/lora-update-wedge-fix.md` §9。

- **⛔ PD 下请求内隐式重载 adapter 会把 bootstrap 卡死 600s(2026-09-14 修)**:请求引用的 `lora_path` 在当前引擎未加载时,**prefill 与 decode 都在请求路径内**触发隐式重载(日志 `Reloading evicted adapter` → `Start load Lora adapter`);decode 在加载期间**不分配 KV、不回 KV 索引** → prefill 干等 → `Prefill bootstrap failed ... timed out after 600.0s in KVPoll.Bootstrapping` → 客户端看到 **静默 600s 后失败**(jobs 侧表现为 `empty SSE response`、0 token)。**KV 索引协商与 radix 无关**(KV 池在 decode,decode 必须先回 `dst_kv_indices`,`--disable-radix-cache` 只让命中=0 全量传)。判据三条一起看:① prefill `timed out after 600.0s in KVPoll.Bootstrapping`;② decode 侧 `num_queue_reqs=0` 且该 bootstrap_room **零记录**;③ 两引擎 `/v1/models` 不一致。**修复**:① 引擎 `tokenizer_manager.py::_resolve_lora_path` 在 `disaggregation_mode` 非空时**禁止隐式重载**,直接 400 并给出 `POST /load_lora_adapter` 指引;② `sgl-model-gateway` jobs 层派发前用 `ensure_lora_loaded`(逐引擎 `/v1/models`)预检、缺失即 fail 该任务。**运维铁律:PD 下 adapter 必须先显式加载到每个引擎,绝不依赖"请求触发自动加载"**。详见 `docs/agent/lora-pd-implicit-reload-stall.md`。
- **移植的 Triton 融合 kernel 有 kill-switch**:`SGLANG_OPT_USE_TRITON_VOCAB_PARALLEL_EMBEDDING=0` 可关闭 TP vocab embedding 融合路径(默认开);topk1 draft kernel 无开关(topk=1 + CUDA 自动启用,异常时走 `topk1_chain_fits` fallback)。
- **HiCache local-only prefetch 是死锁温床**:任何依赖 per-rank 状态的 collective gate 都会出问题;判断"rank-invariant"再动。
- **`seq_len` 在 CP token split 前是 rank-invariant 的,`extend_seq_lens_cpu` 不是**(HiCache 会改)。
Expand Down Expand Up @@ -392,6 +393,9 @@ rsync -avz --exclude='__pycache__' --exclude='*.pyc' "$SRC" root@<node>:"$DEST"
| `docs/agent/dcp-virtual-id-domain-fix.md` | **DCP 虚拟 id 域 + draft pool 尺寸双重修复(2026-08-20,`9db63a6abb`+`371a991947`)**:§1-5 = 虚拟 id 域(page_size=64×dcp, capacity=size×dcp)下双转/sanitize/无 rank 过滤的修复;**§6 = 终局根因:merge v0.5.16 丢 draft_pool_token_multiplier → draft pool 缩到 1.85M → 虚拟 id 越界 → 压缩 workaround 摧毁 draft 域 → accept 0.07**。修复=draft pool 恢复 size×dcp + 移除全部转换 + move_accept_tokens 双侧转换。**accept 健康基准:len 2.2-3.2;~5.9=死循环病态**。判据:draft #tokens=target×dcp、DRAFT-LOC-OOB=0 |
| `docs/agent/2p3d-cluster-config.md` | **2P3D(1P2D)集群完整配置手册(2026-08-20)**:拓扑/IP/venv/二进制路径(smg=/usr/local/bin/smg、mol-stack proxy)、prefill/decode/router/gateway/proxy 全部实际启动命令与 env、部署与重启流程(prefill 重启后 decode 必须重启配对 RDMA)、LoRA 管理 API、验证判据命令、已知坑(start_pd.sh 过时路径、MOL_UPSTREAM_RUNTIME=sglang 必须显式、pkill -f 禁令、模型名两层) |
| `docs/agent/lora-multi-adapter-garbling.md` | **MoL 多 LoRA 乱码修复结案(2026-08-21,`f070d3d466`)**:第 5 个 uid(base+4 adapter)超 max_loras_per_batch=4 触发 LRU 驱逐 → cuda graph token_lora_mapping 尾部 stale id + rank-0→slot-0 → 乱码 sticky 传染(上游 #29157/#29468 同源)。含复现序列/修复/验证数据/方法论教训(污染状态毁对照实验、"输出≠decode 应用 LoRA"判据、lora_pool_slots_used 验证法)与判据工具 |
| `docs/agent/jobs-continue-api.md` | **训练 Job 续写 API(`continue_from`)面向训练侧的快速上手文档(2026-09-14)**:✦ **两个方向别搞混**(结果方向 `output_ids`/`output_token_logprobs` 完全不变;只省掉**续写请求**里的 token 回传);job 级/任务级续写字段表;参数继承(temperature/top_p/lora_path 继承源任务,max_tokens 不继承);`prompt_tokens == 源 prompt_tokens + len(源 output_ids)` 自检锚点(实测 32=8+24、987=21+966、89=25+64 逐位吻合);cancel→续写端到端 Python 示例;错误码;**§9 PD 下 adapter 必须先显式加载到每个引擎**(未加载 → 现在秒级失败并附 `POST /load_lora_adapter` 指引,旧行为是 600s 静默);成本(已生成 token 必须重 prefill + 整个上下文 KV 传输);与裸 `/v1/completions` 的区别 |
| `docs/agent/training-jobs-api.md` | **smg 训练任务接口(jobs)完整手册**:提交/轮询/取消/下载、token id+logprob 三元组对齐契约、结果结构与压缩传输、错误码、运维环境变量;**§10 = 续跑(cancel → continue)**:`continue_from`(job 级/任务级)让服务端自己拼 `源输入ids ++ 源已生成ids`,客户端不需回传 token;含历史数据(上线前落盘 job)兼容、继承语义(lora/temperature/top_p 继承源任务)、成本(已生成 token 重 prefill + 整个上下文 KV 传输)与错误码 |
| `docs/agent/lora-update-wedge-fix.md` | **LoRA 更新通道永不卡死(2026-09-13/14,#19+#20)**:根因 = `_resolve_lora_path` 的 `acquire()` 只有两处 `release()`,abort 回显/丢弃 pending 两条清理路径漏账 → `wait_for_unload()` 永不归零 → 持 `lora_update_lock` 卡死全部 LoRA op(推理不受影响);修复 = 等待有界(`SGLANG_LORA_UPDATE_TIMEOUT_SECS`)+ 清理路径销账配对 + 送出调度器的请求改发 abort 保留 state。含 LRU 排除判据、第二种失败模式(TOS 403 → curl 22 → 400)、诊断工具 `tools/analyze_lora_log.py` |
| `docs/agent/lora-deploy-400-fix.md` | **LoRA URL 部署 400 双 Bug + 训练任务接口(2026-08-21)**:smg load 无超时/DELETE 30min 挂起/引擎注册键(name≠path)错配;engine 侧 URL 拼 cache 目录与 archive 文件名非法(`join(cache_root, URL)`)→ curl exit 22;`LoRAUpdateOutput` 字段是 `error_message` 非 `message`;FanOutCommunicator 类型过滤加固。含 smg jobs 训练任务 API(token id+logprob 三元组对齐)、OSS 下载慢根因(GDS 调度新加坡双向跨境 0.5-2.4MB/s vs 北京直连 9.6-14.6MB/s)与四端点测速、阿里工单 request id |
| `docs/correctness-war-retro-2026-08.md` | **正确性战争人类可读复盘(2026-08-18~22)**:面向全员(含非引擎同学)的完整叙事——LoRA+CP 四层、draft 崩溃/accept 断崖/虚拟 id 域、DCP 双 bug 终局,含方法论与弯路记录。新人了解系统架构(PD/KV/TP/CP/DCP/DSA/EAGLE/MoL 逐个白话解释)的入口文档 |
| `docs/dsv4-dspark-pd-tech-report-2026-08.md` | **V4 Pro DSpark PD 技术报告(2026-08-23)**:对外展示版——hidden state 流式动态传输算法(搭车+直发回退+ACK 流控+窗口化准入含形式化证明)、双端 radix、CP=8 3.5×、bug 排查方法论(内容金丝雀/判别阶梯/数字指纹)与跨请求 KV 污染终局案例、最终验收数据与残余边界 |
Expand Down
249 changes: 249 additions & 0 deletions docs/agent/jobs-continue-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# 训练 Job 续写 API(`continue_from`)

> **一句话**:取消或截断后的 job 想接着生成,客户端**只发一个 job 引用**即可 —— 服务端自己把
> `源任务输入 token ++ 源任务已生成 token` 拼成上下文重走 prefill,**不需要客户端回传任何 token**。
>
> 适用对象:训练/RL 侧同学。实现见 `sgl-model-gateway/src/control_plane/jobs.rs`(`continue_from`
> / `resolve_input_ids` / `ensure_lora_loaded`)。

---

## 0. 两个方向,别搞混(最重要的一条)

| 方向 | 内容 | 本 API 是否改变 |
|---|---|---|
| **结果方向**(服务端 → 客户端) | `output_text` / `output_ids` / `output_token_logprobs` | **完全没有变**。训练数据一个字段都不少,照常返回。 |
| **请求方向**(客户端 → 服务端) | 续写时要让模型"接着上次往下写" | **这里省掉了 token 回传**:客户端给引用,服务端拼上下文。 |

即:**续写 ≠ 不给客户端 token**。客户端拿到的仍是完整 token + 逐 token logprob;省掉的只是"续写请求里"那份几万~几十万 token 的 JSON。

---

## 1. 30 秒上手

```bash
# ① 提交一个普通 job(老接口,不变)
curl -sS -X POST "$BASE/v1/control/jobs" \
-H "Authorization: Bearer $CONTROL_KEY" -H 'Content-Type: application/json' \
-d '[{"prompt":"用一句话说明什么是张量并行。","max_tokens":512,"temperature":0.0}]'
# → {"job_id":"job_1789375041513_000_7705","status":"queued",...}

# ② 结束(或先 cancel)后,直接续写:请求体里只有一个 job id
curl -sS -X POST "$BASE/v1/control/jobs" \
-H "Authorization: Bearer $CONTROL_KEY" -H 'Content-Type: application/json' \
-d '{"continue_from":{"job_id":"job_1789375041513_000_7705"},"max_tokens":512}'
```

服务端会构造 `input_ids = 源任务输入 token ++ 源任务已生成 token` 再发 `prefill → decode`。

> 实测(B300 1021/1022,GLM-5.3 MoL):
> 源任务 `prompt_tokens=8`、产出 24 token → 续写任务 `prompt_tokens=32 == 8 + 24` ✅
> 源任务 `prompt_tokens=21`、产出 966 token → 续写任务 `prompt_tokens=987 == 21 + 966` ✅

---

## 2. 输入的三种形态(三选一,必须且只能给一个)

| 形态 | 字段 | 用途 |
|---|---|---|
| 文本 | `prompt` | 普通请求(原行为) |
| token | `input_ids` | 客户端自己拼好了整段 token |
| **引用** | **`continue_from`** | **本 API 主角**:服务端自己拼,客户端不回传 token |

三者同时给或都不给 → **400**,报文明确:
`task must provide exactly one of 'prompt', 'input_ids' or 'continue_from'`。

---

## 3. `continue_from` 的两级用法

### 3.1 job 级(最小请求体,推荐)

```json
{"continue_from": {"job_id": "job_1789375041513_000_7705"}, "max_tokens": 512}
```

- 与源 job **任务一一对应**(顺序一致,`tasks[i]` 续写源 `tasks[i]`);
- 可同时给覆盖项:`max_tokens` / `temperature` / `top_p` / `lora_path` / `n`;
- 与 `requests` 同时出现 → 400(语义冲突)。

### 3.2 任务级(可精确指定)

```json
[
{"continue_from": {"job_id": "job_x", "task_id": "job_x_t0000"}, "max_tokens": 512},
{"continue_from": {"job_id": "job_x", "index": 1, "sample_index": 1}, "temperature": 0.0}
]
```

| 字段 | 必填 | 说明 |
|---|---|---|
| `job_id` | ✅ | 源 job |
| `task_id` | ❌ | 源任务 id(全局唯一,提交响应里就有) |
| `index` | ❌ | 源任务序号(`task_id` 的替代写法) |
| `sample_index` | ❌ | `n>1` 时从第几个 sample 续,默认 0 |

源 job 只有 1 个任务时可都省略;**多任务且不指定 → 400**(提示补 `task_id`/`index`)。

---

## 4. 参数继承:省略 = 沿用源任务

| 参数 | 省略时的行为 |
|---|---|
| `temperature` / `top_p` | **继承源任务**(同一条实验的分布) |
| `lora_path` | **继承源任务**(同 adapter) |
| `max_tokens` | **不继承**,默认 4096 —— 新预算是新请求的事 |
| `n` | 不继承,默认 1 |

想强制基模:显式 `"lora_path": null`。

---

## 5. 结果形态

```json
{"results":[{"task_id":"...","index":0,"samples":[{
"output_text":" …续写的正文…",
"output_ids":[...], // 只含【本次新增】token
"output_token_logprobs":[...], // 与 output_ids 一一对齐
"finish_reason":"stop",
"prompt_tokens":32, // = 源 prompt_tokens + 源已生成 token 数
"completion_tokens":24
}]}]}
```

- **只含本次新增 token**:历史 token 客户端上次已经拿到了,不重复;
- `prompt_tokens` 是**自检锚点**:应当等于 `源.prompt_tokens + len(源.output_ids)`,
不等就说明上下文拼错了(目前实现下逐位吻合)。

---

## 6. 端到端:cancel → 续写(Python)

```python
import json, time, urllib.request

BASE, KEY = "http://<smg-host>:31000", "<CONTROL_KEY>"

def call(method, path, payload=None):
r = urllib.request.Request(BASE + path,
data=None if payload is None else json.dumps(payload).encode(),
method=method)
r.add_header("Content-Type", "application/json")
r.add_header("Authorization", "Bearer " + KEY)
with urllib.request.urlopen(r, timeout=600) as resp:
return json.loads(resp.read().decode() or "null")

job = call("POST", "/v1/control/jobs",
[{"prompt": "写一个 3000 行的 CSV 生成器说明。", "max_tokens": 4096}])
jid = job["job_id"]

time.sleep(5)
call("POST", f"/v1/control/jobs/{jid}/cancel") # 取消(已生成的 token 会保留)

partial = call("GET", f"/v1/control/jobs/{jid}/result") # 需要的话看看已生成了什么
s0 = partial["results"][0]["samples"][0]
print("已生成 token:", len(s0["output_ids"]), "| logprob 数:", len(s0["output_token_logprobs"]))

# 续写:只发引用,不回传 token
cont = call("POST", "/v1/control/jobs",
{"continue_from": {"job_id": jid}, "max_tokens": 2048})
cid = cont["job_id"]
while True:
st = call("GET", f"/v1/control/jobs/{cid}")
if st["status"] in ("completed", "partial", "failed", "cancelled"):
break
time.sleep(2)

s1 = call("GET", f"/v1/control/jobs/{cid}/result")["results"][0]["samples"][0]
assert s1["prompt_tokens"] == s0["prompt_tokens"] + len(s0["output_ids"]) # 上下文自检
print("续写新增 token:", len(s1["output_ids"]), "| logprob 数:", len(s1["output_token_logprobs"]))
```

---

## 7. 语义与保真

| 项 | 行为 |
|---|---|
| 拼接粒度 | **token 级**(不经文本)→ 不存在边界重 tokenize 导致的漂移 |
| 续写的续写 | 支持:组合后的上下文会落盘(`input_XXXX.json`),下一跳直接复用 |
| 运行中的源 | 支持:源还在跑时取**实时 partial** 快照 |
| 源一条都没生成 | 明确报错(不会静默产空样本) |
| 历史数据 | **上线前落盘的 job 无需迁移**即可作为源(文本 prompt 由引擎 tokenizer 现场还原成 id) |
| 采样 | 要"同一条轨迹"请用贪心(`temperature=0`)或固定 seed;KV 复用 ≠ 采样事件复用 |

---

## 8. 错误码

| HTTP / 字段 | 含义 |
|---|---|
| 400 `task must provide exactly one of 'prompt', 'input_ids' or 'continue_from'` | 输入来源 0 个或 ≥2 个 |
| 400 `continue_from: unknown job_id 'xxx'` | 源 job 已删除或超出保留期 |
| 400 `continue_from: job 'x' has N tasks — specify 'task_id' or 'index'` | 多任务 job 未指定源任务 |
| 400 `body cannot contain both 'requests' and 'continue_from'` | 两种形态混用 |
| 任务级 error `no generated tokens yet` | 源任务还没产出任何 token |
| 任务级 error **`LoRA adapter is not loaded on N of M engine(s): ...`** | **见 §9**:adapter 未在每个引擎加载 |

---

## 9. ⚠️ LoRA adapter 前置条件(PD 下必读)

PD(prefill/decode 分离)下,**adapter 必须在每个引擎都处于已加载状态**,否则请求会在
bootstrap 握手里干等 600s 才失败(历史行为;现已改为**秒级失败**)。

现在的行为:

- 引擎侧:PD 模式下**禁止"请求触发自动重载 adapter"**,未加载 → 立刻 400 并给出指引;
- jobs 侧:派发前逐引擎 `GET /v1/models` 预检,缺失 → **任务立即失败**并给出同一条指引:

```
LoRA adapter is not loaded on 2 of 2 engine(s): http://prefill:30100, http://decode:30200.
A PD request for a missing adapter hangs until the bootstrap timeout (600s).
Load it on every engine first:
POST <engine>/load_lora_adapter {"lora_name": "<path>", "lora_path": "<path>"}
and resubmit afterwards.
```

实测:3 秒返回失败(旧行为是 600s 静默)。

**正确流程**:先对**每个**引擎(prefill + decode)显式加载 adapter(或走 smg 控制面部署到全量引擎),
再提交引用它的 job / 续写请求。**不要依赖"请求触发自动加载"。**

---

## 10. 成本与性能(决定值不值得续写)

| 部分 | 是否需要重算/重传 |
|---|---|
| 原 prompt | prefill 端命中 radix / HiCache 前缀缓存 → 基本不重算 |
| **已生成的 token** | **必须重新 prefill**(它们从未进过 prefill 的缓存) |
| KV 传输 | decode 端 `--disable-radix-cache` → **整个上下文的 KV 都要从 prefill 传过去**,传输量 ∝ 上下文长度 |

结论:续写几万 token 的 rollout 很划算;**100k+ 上下文时传输是大头**。
(要"零重算零重传"需 KV 驻留能力,属另一条线,当前不支持。)

---

## 11. 运维备注

| 变量 / 项 | 说明 |
|---|---|
| `SMG_JOBS_ENGINE_URLS` | 逗号分隔的引擎地址;不设则自动取 router 启动参数里的 worker URL(用于 tokenizer 还原 prompt id / adapter 预检) |
| `SMG_JOBS_DIR` | job 持久化目录(默认 `./smg-jobs`),重启后自动恢复(本集群 67 个 job 实测恢复) |
| 保留期 | 结果在磁盘保留 48h,超期后不能作为续写源 |
| 控制键 | `Authorization: Bearer <CONTROL_KEY>`(见部署密钥) |

---

## 附:与 `/v1/completions` 的区别

| | `/v1/control/jobs`(本文档) | 裸 `/v1/completions` |
|---|---|---|
| 续写引用 | ✅ `continue_from`(服务端拼 token) | ❌ 客户端自己拼 |
| token + logprob 三元组 | ✅ 结构化返回 | 需自行解析 |
| 作业语义(提交/轮询/取消/恢复) | ✅ | ❌ |
| 适用 | 训练 / RL rollout | 单次推理

Loading
Loading