# 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` 前缀规则:`--` - `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://:8000/serve//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/")` - 状态:`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://:8000/v1` - `endpoint.model = ` 建议改为(字段不变,值变化): - `endpoint.openai_base_url = http://:8000/serve//v1` - `endpoint.model = `(保持) UI 的示例 curl 也应使用上面的 base_url。 --- ## 行为变化与兼容性影响 ### 1) `/v1/models` 聚合能力变化(重要) 采用“每模型一个 route_prefix”后: - `http://:8000/v1/models` **不再是“所有模型的总览”**(除非我们再提供一个聚合层) - 每个模型的 models list 在它自己的前缀下: - `http://:8000/serve//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