v3.8 改成每个模型独立app,测试通过容器down, ray serve自动迁移模型

This commit is contained in:
2026-01-08 17:33:16 +08:00
parent 686739fea2
commit 938f84f1ba
12 changed files with 743 additions and 92 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

View File

@@ -0,0 +1,189 @@
# v3.8 方案补充:每个模型一个 Ray Serve App(隔离增删影响)
## 背景与问题复现
当前 v3.8 的实现采用 **单 application + 多模型** 的方式:
- 服务层每次 reconcile 都会构造“全量 llm_configs”并调用一次 `serve.run(app, name="argus_llm_app", route_prefix="/")`
- **新增/删除一个模型**会触发对同一个 app 的“整体更新”
- Ray Serve 在 app 更新时会对该 app 内的 deployments/replicas 做滚动更新与重新调度
因此你在 Ray Dashboard 中观察到:
- 添加/删除一个模型时,其他模型的 Serve deployment 也进入更新状态
- 内存/显存占用重新变化,甚至出现 GPU 卡位变化(replica 重新调度到不同 node/GPU)
这与“其他未变更 model 不受影响”的期望不一致。
---
## 目标
将 serving 架构调整为:
- **每个模型一个 Serve App(独立 app name)**
- 每个模型一个独立 `route_prefix`
- 新增/删除/缩放某个模型只更新该模型对应的 app,不影响其他模型 app
约束保持不变:
- 推理端口固定 `8000`
- 推理侧不接入现有 token 鉴权(OpenAI endpoint 无鉴权)
- `model_id` 前缀规则:`<user_id>-<YYYYMMDDHHMM>-<suffix>`
- `LLMConfig.accelerator_type` 由 `configs/dev.yaml` 配置(dev/h1: `H20`)
---
## 总体设计
### 1) 命名与路由
为每个 model 生成:
- `app_name`:建议直接使用 `model_key`(天然唯一且 URL-safe),例如:
- `app_name = "mvp2-alice-serve-20260106-060203-aad8"`
- `route_prefix`:建议使用 model_key,避免 model_id 中的 `.`、`_` 等带来的 URL/编码歧义:
- `route_prefix = f"/serve/{model_key}"`
于是该模型的 OpenAI base url 为:
- `openai_base_url = http://<host>:8000/serve/<model_key>/v1`
说明:
- 仍然是 **OpenAI-compatible**,只是 base_url 不再是根路径 `/v1`,而是每个模型一个前缀。
- 这样可以做到“每个模型的 OpenAI endpoint 互不影响”,也更容易做按模型的观测/下线。
### 2) 运行方式(Ray Serve)
单模型 app 的创建/更新:
- `app = build_openai_app({"llm_configs":[LLMConfig(...)]})`
- `serve.run(app, name=app_name, route_prefix=route_prefix)`
单模型 app 的删除:
- `serve.delete(app_name)`
关键点:
- **更新/删除只作用于对应 app_name**;其它 app 不会被 serve.run “整体重建”触发滚动更新。
### 3) 服务层(Scheduler/Reconciler)改造点(高层)
现状:`ServingReconciler.tick()` 每次对“全量模型集合” apply 一次 app。
目标:改成按 model_key 的“局部 reconcile”:
- 对于状态 `QUEUED` 的 model:
- 只构建该 model 的 `LLMConfig`
- `serve.run(app, name=model_key, route_prefix="/serve/<model_key>")`
- 状态:`DEPLOYING` →(probe 成功)→ `RUNNING`
- 对于状态 `DELETING` 的 model:
- `serve.delete(model_key)`
- 状态:`DELETED`
资源预检查:
- 只需要预检查“本次变更模型”需要的 GPU(`num_replicas * gpus_per_replica`)
- 不需要把其他模型资源都算入 needed_total_gpus(因为不再重建全量 app)
### 4) API/UI 返回的 endpoint 结构
现状 API 返回:
- `endpoint.openai_base_url = http://<host>:8000/v1`
- `endpoint.model = <model_id>`
建议改为(字段不变,值变化):
- `endpoint.openai_base_url = http://<host>:8000/serve/<model_key>/v1`
- `endpoint.model = <model_id>`(保持)
UI 的示例 curl 也应使用上面的 base_url。
---
## 行为变化与兼容性影响
### 1) `/v1/models` 聚合能力变化(重要)
采用“每模型一个 route_prefix”后:
- `http://<host>:8000/v1/models` **不再是“所有模型的总览”**(除非我们再提供一个聚合层)
- 每个模型的 models list 在它自己的前缀下:
- `http://<host>:8000/serve/<model_key>/v1/models`
如果仍然希望保留一个统一入口(可选增强,非本方案必做):
- 额外引入一个“稳定不重建”的 **OpenAI Router**(可以是:
- FastAPI(8080) 侧做反向代理;或
- 一个单独 Ray Serve app 只负责路由,不随模型变更重建)
- Router 读取 SQLite/内存缓存的 model 映射:
- `model_id -> route_prefix`
- 将 `/v1/chat/completions` 转发到对应 model 的 prefix
这可以作为 v3.9+ 的增强项;v3.8 的核心目标是“变更隔离”,优先保证稳定性。
### 2) 资源与调度稳定性
改为 per-app 后:
- 新增模型 B 不再引起模型 A 的 replica 重新调度 → **A 的 GPU/内存占用更稳定**
- 删除模型 B 也不会触发 A 的滚动更新
但仍需注意:
- 如果 Ray 集群发生节点故障/资源回收,Serve 本身仍可能重启个别 replica(这是系统层行为)
---
## 验证与验收流程(建议)
### A. 功能验收(API/UI)
1. 通过 UI/或 API 创建模型 A,等待 RUNNING
2. 记录 A 的:
- `model_key_A`
- `endpoint.openai_base_url_A`
3. 再创建模型 B,等待 RUNNING
4. 确认:
- A 的 endpoint 仍可用(对 A 的 base_url 发 chat completion)
- B 的 endpoint 可用
5. 删除模型 B,确认:
- B endpoint 404/不可用
- A endpoint 仍可用
### B. “不影响其它模型”的强验证(Ray actor 级别)
在 Ray 上抓取 A 对应 `LLMServer` replica 的 actor_id/node_id:
- 创建 B 前:`actor_id_A_before`
- 创建 B 后:`actor_id_A_after`
- 删除 B 后:`actor_id_A_after_delete`
预期:
- `actor_id_A_before == actor_id_A_after == actor_id_A_after_delete`
(允许 `LLMRouter` 变化,但 **LLMServer for A 不应变化**)
---
## 需要修改的代码点(清单级)
> 这里只列“改哪里”,不展开具体实现(实现时按 TDD 补单测)。
- `argus.service.serving_reconciler`:
- 从“全量 apply 单 app”改为“按 model_key 局部 apply/delete 单 app”
- GPU 预检查改为 per-model
- `argus.service.serve_client`:
- 增加 `delete_app(app_name)`(封装 `serve.delete(app_name)`)
- `apply_app` 传入 `app_name/route_prefix`(已存在,但将不再传固定 app_name)
- `argus.service.app`(Serving API 输出):
- `_serve_model_public().endpoint.openai_base_url` 改为 per-model 前缀
- `/api/v2/serve/models` list/get 的 openai_base_url 语义调整(可返回“该模型的 base_url”,列表里每条都有)
- `argus.service.ui`(Serving 页面):
- “OpenAI /v1/models” 需要调整为“选择某个模型后打开该模型的 /v1/models”
- 详情页 curl 示例使用 per-model base_url

View File

@@ -0,0 +1,174 @@
# MVP v3.8(变更)开发计划:Per-Model Serve App(TDD)
> 目标:按 `specs/mvp/v3.8/v3.8_per_model_app.md` 将 v3.8 从“单 app 多模型(全量重建)”改为“**每个模型一个 Ray Serve app + 独立 route_prefix**”,实现增删/缩放某个模型不触发其它模型重启与重调度。
## 约束与结论
- 推理端口固定:`8000`
- 推理 endpoint **不做鉴权**
- `model_id` 前缀规则:`<user_id>-<YYYYMMDDHHMM>-<suffix>`
- `LLMConfig.accelerator_type` 由 `configs/dev.yaml` 决定(dev/h1: `H20`)
- 路由方案(本迭代固定):
- `app_name = model_key`
- `route_prefix = /serve/<model_key>`
- `openai_base_url = http://<host>:8000/serve/<model_key>/v1`
## 非目标(明确不做)
- 不提供统一 `/v1` 的“跨模型聚合路由”(如要,需要额外 router 层;可在后续迭代做)
- 不改 ServingSpec 语义(输入仍为 `model_id/model_source/num_replicas/gpus_per_replica/engine_kwargs`)
---
## M0 - 基线回归与分支保护
**目的**:确保切换架构前训练/现有功能不回退。
### 测试
- [ ] 本地:`.venv/bin/python -m pytest` 全绿(coverage ≥ 90%)
### 验收
- [ ] 基线可用;进入 M1
---
## M1 - API 输出与 endpoint 语义调整(单测驱动)
**目的**:API/DB/前端都统一 per-model 的 `openai_base_url` 语义;避免 UI/脚本继续使用 `/v1` 根路径。
### 变更点
- `GET /api/v2/serve/models`:
- 保持返回 `items[]`,但每个 item 的 `endpoint.openai_base_url` 必须是 per-model base url
- `openai_base_url`(列表层级字段)处理策略二选一:
- A(推荐):移除该字段(breaking,需同步 UI/脚本)
- B(兼容):保留但改为 `null` 或提示字符串(不再保证可用)
- `GET /api/v2/serve/models/{model_key}`:
- `model.endpoint.openai_base_url` 改为 per-model base url
### 单测(先写)
- [ ] 更新/新增 `src/mvp/py/tests/test_app_serving_api.py`
- 断言 `endpoint.openai_base_url` 包含 `/serve/<model_key>/v1`
- 断言多条 models 的 base_url 不相同(随 model_key 变化)
### 实现
- [ ] 更新 `src/mvp/py/argus/service/app.py`:
- `_serve_model_public()` 的 `endpoint.openai_base_url` 拼接 per-model prefix
- 如选择移除/调整 list 层的 `openai_base_url`,同步实现
### 验收
- [ ] API 单测通过;返回结构可被 UI/脚本消费
---
## M2 - ServeClient 扩展(delete_app)+ Reconciler 改造成 per-model(单测驱动)
**目的**:核心行为变更:每次 tick 只部署/删除一个模型对应的 app,不重建全量 app。
### 变更点(行为)
- `QUEUED`:
- 对该 `model_key` 执行 `serve.run(app, name=model_key, route_prefix=/serve/<model_key>)`
- 状态:`DEPLOYING → RUNNING`
- `DELETING`:
- 对该 `model_key` 执行 `serve.delete(model_key)`
- 状态:`DELETED`
- 资源预检查从“全量 needed_total_gpus”改为“本次变更模型所需 GPU”
### 单测(先写)
- [ ] 更新 `src/mvp/py/tests/test_serving_reconciler.py`
- `create A` 后,reconciler 只 `apply_app(app_name=A.model_key, route_prefix=/serve/A)`
- `create B` 后,reconciler 只 `apply_app(app_name=B.model_key, route_prefix=/serve/B)`(不再对 A 触发 apply)
- `delete B` 后,reconciler 只 `delete_app(B.model_key)`(不触发 A)
- GPU 不足时:保持 `QUEUED` 且 event=SERVE_PENDING_RESOURCES
### 实现
- [ ] `src/mvp/py/argus/service/serve_client.py`
- 增加 `delete_app(app_name: str)`(封装 `serve.delete`)
- [ ] `src/mvp/py/argus/service/serving_reconciler.py`
- 移除“全量 app apply”逻辑
- 每个 model_key 独立部署:`app_name=model_key`、`route_prefix=/serve/<model_key>`
- 删除路径走 `delete_app`
### 验收
- [ ] reconciler 单测全绿;逻辑可解释(events/state 正确)
---
## M3 - WebUI Serving 页面适配 per-model base_url(单测驱动)
**目的**:用户从 UI 复制的示例命令必须可用;不再指向根 `/v1`。
### 变更点
- `/ui/serving` 列表:
- “OpenAI /v1/models”按钮改为:
- A:移除(因为没有聚合 `/v1/models`)
- B:保留但文案改为“OpenAI base 需进入详情页”
- `/ui/serving/{model_key}` 详情页:
- `curl` 示例使用 per-model `openai_base_url`
- 增加一键打开:`/serve/<model_key>/v1/models`
### 单测(先写)
- [ ] 更新/新增 `src/mvp/py/tests/test_ui_serving.py`
- 断言页面包含 `/serve/` 前缀
- 断言详情页示例里包含 `/serve/<model_key>/v1/chat/completions`
### 实现
- [ ] `src/mvp/py/argus/service/ui.py` 更新 Serving UI
### 验收
- [ ] UI 单测全绿;页面内容与 API 语义一致
---
## M4 - E2E 脚本更新(v3.8 serving)
**目的**:在 dev/h1 一键验证 per-model app:A/B 增删不互相影响,且推理可用。
### 变更点
- 更新 `src/mvp/scripts/run_all_v38_serving.sh`:
- `/v1/models` 与 `chat/completions` 均改用 per-model base_url(`/serve/<model_key>/v1`)
- 增加“隔离验证”步骤:
- 部署 A → 记录 A 的 serve replica actor_id/node_id
- 部署 B → 再次记录 A 的 actor_id/node_id,必须一致
- 删除 B → 再次记录 A 的 actor_id/node_id,必须一致
- 最后删除 A
### 验收
- [ ] E2E 脚本能跑通且输出明确断言(一致/不一致)
---
## M5 - h1 端到端验证与回归
**目的**:确认实际 Ray Serve 行为满足“其它模型不滚动更新”的核心目标。
### 操作
- [ ] 同步代码到:`argus@h1:/home2/argus/infra/mvp/src/mvp`
- [ ] 重启 API:`scripts/61_stop_api.sh && scripts/60_start_api.sh`
- [ ] 执行:`MVP_INTERNAL_TOKEN=... scripts/run_all_v38_serving.sh`
### 验收标准(必须满足)
- [ ] 新增/删除 B 时,A 的 `LLMServer` replica actor_id/node_id 不变
- [ ] A/B 的 OpenAI endpoint 均可完成 `chat/completions`
- [ ] 删除 B 后 A 仍可推理
---
## M6 - 文档与迁移说明
**目的**:明确“路由语义变化”和“如何使用”。
- [ ] 更新 `src/mvp/README.md`:
- 新增 per-model base_url 说明(`/serve/<model_key>/v1`)
- 提示不再提供聚合 `/v1/models`
- [ ] 更新 `specs/mvp/v3.8/v3.8_progress.md`:
- 记录 per-model app 变更与验收结论
---
## 风险与缓解
- **风险:旧 `argus_llm_app` 残留**
- 缓解:在 E2E/迁移步骤里增加一次 best-effort `serve.delete("argus_llm_app")`(可选)
- **风险:用户仍按旧方式访问 `/v1`**
- 缓解:UI/文档/脚本统一切换到 per-model base_url,并在列表页给出明显提示