From 2de3201a4a2328ec3cbf82550f6d045540dbdc7f Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 23 Oct 2026 23:55:57 +0800 Subject: [PATCH 1/4] Fix visual-only RPyC payload deserialization --- .../visualserver/model_infer/model_rpc.py | 3 +- .../model_infer/model_rpc_client.py | 3 +- lightllm/server/visualserver/objs.py | 10 + lightllm/server/visualserver/proxy_manager.py | 9 +- lightllm/server/visualserver/rpc_payload.py | 175 ++++++++++++++++++ .../visualserver/visual_only_manager.py | 54 ++++-- unit_tests/server/test_visual_only_rpyc.py | 149 +++++++++++++++ 7 files changed, 378 insertions(+), 25 deletions(-) create mode 100644 lightllm/server/visualserver/rpc_payload.py create mode 100644 unit_tests/server/test_visual_only_rpyc.py diff --git a/lightllm/server/visualserver/model_infer/model_rpc.py b/lightllm/server/visualserver/model_infer/model_rpc.py index 22d5a43e73..c4a4a41095 100644 --- a/lightllm/server/visualserver/model_infer/model_rpc.py +++ b/lightllm/server/visualserver/model_infer/model_rpc.py @@ -16,6 +16,7 @@ from lightllm.models.gemma4.gemma4_visual import Gemma4VisionModel from lightllm.models.vit.model import VisionTransformer from lightllm.server.multimodal_params import MultimodalParams, ImageItem +from lightllm.server.visualserver.rpc_payload import unpack_image_items from lightllm.models.qwen2_vl.qwen2_visual import Qwen2VisionTransformerPretrainedModel from lightllm.models.qwen2_5_vl.qwen2_5_visual import Qwen2_5_VisionTransformerPretrainedModel from lightllm.models.qwen3_vl.qwen3_visual import Qwen3VisionTransformerPretrainedModel @@ -181,7 +182,7 @@ def _hold_vision_peak_vram(self): def exposed_run_task(self, images: List["ImageItem"], ref_event_list: List[threading.Event]): try: - images = obtain(images) + images = unpack_image_items(images, ImageItem) for i in range(len(images)): images[i].event = ref_event_list[i] images[i].start_time = time.time() diff --git a/lightllm/server/visualserver/model_infer/model_rpc_client.py b/lightllm/server/visualserver/model_infer/model_rpc_client.py index 682d6affcc..e3f79fcd01 100644 --- a/lightllm/server/visualserver/model_infer/model_rpc_client.py +++ b/lightllm/server/visualserver/model_infer/model_rpc_client.py @@ -3,6 +3,7 @@ import threading from typing import Dict, List, Tuple, Deque, Optional, Union from lightllm.server.multimodal_params import ImageItem +from lightllm.server.visualserver.rpc_payload import pack_image_items from .model_rpc import VisualModelRpcServer @@ -32,5 +33,5 @@ async def init_model(self, kvargs): return async def run_task(self, images: List[ImageItem], ref_event_list: List[threading.Event]): - ans = self._run_task(images, ref_event_list) + ans = self._run_task(pack_image_items(images), ref_event_list) return await ans diff --git a/lightllm/server/visualserver/objs.py b/lightllm/server/visualserver/objs.py index 656f3d3eae..8fedae6c9a 100644 --- a/lightllm/server/visualserver/objs.py +++ b/lightllm/server/visualserver/objs.py @@ -10,6 +10,16 @@ "allow_setattr": True, } +visual_only_rpyc_config = { + "allow_pickle": False, + "allow_all_attrs": False, + "allow_getattr": True, + "allow_setattr": False, + "allow_delattr": False, + "allow_public_attrs": False, + "allow_exposed_attrs": True, +} + @dataclass class VIT_Obj: diff --git a/lightllm/server/visualserver/proxy_manager.py b/lightllm/server/visualserver/proxy_manager.py index f5b7d58acf..aa17135bcf 100644 --- a/lightllm/server/visualserver/proxy_manager.py +++ b/lightllm/server/visualserver/proxy_manager.py @@ -27,7 +27,8 @@ from rpyc.utils.classic import obtain from lightllm.server.embed_cache.utils import read_shm, get_shm_name_data from .manager import VisualManager -from .objs import VIT_Obj +from .objs import VIT_Obj, visual_only_rpyc_config +from .rpc_payload import RemoteEvent, pack_image_items logger = init_logger(__name__) @@ -129,7 +130,7 @@ def run_task(self, conn: rpyc.Connection, images: List[ImageItem]): if self.args.detail_log: start = time.time() logger.info(f"Start to remote infer images {[image.md5 for image in images]}") - conn.root.remote_infer_images(images, event) + conn.root.remote_infer_images(pack_image_items(images), RemoteEvent(event)) event.wait(timeout=600) if self.args.detail_log: logger.info( @@ -166,9 +167,7 @@ async def loop_to_connect_remote_visual_server(self): if node_id not in self.id_to_rpyc_conn: def _connect(): - from .objs import rpyc_config - - conn = rpyc.connect(vit_obj.host_ip, vit_obj.port, config=rpyc_config) + conn = rpyc.connect(vit_obj.host_ip, vit_obj.port, config=visual_only_rpyc_config) conn._channel.stream.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) conn._bg_thread = rpyc.BgServingThread(conn, sleep_interval=0.001) logger.info( diff --git a/lightllm/server/visualserver/rpc_payload.py b/lightllm/server/visualserver/rpc_payload.py new file mode 100644 index 0000000000..29e6941674 --- /dev/null +++ b/lightllm/server/visualserver/rpc_payload.py @@ -0,0 +1,175 @@ +from math import isfinite +from threading import Event +from typing import Any, Callable, List, Optional, Sequence, Tuple + + +_IMAGE_FIELDS = ( + "_type", + "_data", + "uuid", + "token_id", + "start_index_in_embed_cache", + "token_num", + "md5", + "start_idx", + "grid_thwd", + "image_w", + "image_h", + "extra_params", + "data_bytes", +) +_PAYLOAD_VERSION = 1 +_MAX_IMAGE_COUNT = 4096 +_MAX_CONTAINER_ITEMS = 4096 +_MAX_NESTING = 16 + + +def _pack_value(value: Any, depth: int = 0) -> Tuple[Any, ...]: + if depth > _MAX_NESTING: + raise ValueError("visual RPC value is nested too deeply") + if value is None: + return ("none",) + if type(value) is bool: + return ("bool", value) + if type(value) is int: + if value < -(2 ** 63) or value >= 2 ** 63: + raise ValueError("visual RPC integer is out of range") + return ("int", value) + if type(value) is float: + if not isfinite(value): + raise ValueError("visual RPC float must be finite") + return ("float", value) + if type(value) is str: + return ("str", value) + if type(value) is bytes: + return ("bytes", value) + if type(value) in (list, tuple): + if len(value) > _MAX_CONTAINER_ITEMS: + raise ValueError("visual RPC container is too large") + tag = "list" if type(value) is list else "tuple" + return (tag, tuple(_pack_value(item, depth + 1) for item in value)) + if type(value) is dict: + if len(value) > _MAX_CONTAINER_ITEMS: + raise ValueError("visual RPC mapping is too large") + if any(type(key) is not str for key in value): + raise ValueError("visual RPC mapping keys must be strings") + return ( + "dict", + tuple((key, _pack_value(item, depth + 1)) for key, item in value.items()), + ) + raise ValueError("visual RPC values must use supported built-in types") + + +def _unpack_value(value: Any, depth: int = 0) -> Any: + if depth > _MAX_NESTING or type(value) is not tuple or not value: + raise ValueError("invalid visual RPC value") + + tag = value[0] + if tag == "none" and len(value) == 1: + return None + if tag in ("bool", "int", "float", "str", "bytes") and len(value) == 2: + item = value[1] + expected = { + "bool": bool, + "int": int, + "float": float, + "str": str, + "bytes": bytes, + }[tag] + if type(item) is not expected: + raise ValueError("invalid visual RPC scalar") + if tag == "int" and (item < -(2 ** 63) or item >= 2 ** 63): + raise ValueError("visual RPC integer is out of range") + if tag == "float" and not isfinite(item): + raise ValueError("visual RPC float must be finite") + return item + if tag in ("list", "tuple") and len(value) == 2 and type(value[1]) is tuple: + items = value[1] + if len(items) > _MAX_CONTAINER_ITEMS: + raise ValueError("visual RPC container is too large") + unpacked = [_unpack_value(item, depth + 1) for item in items] + return unpacked if tag == "list" else tuple(unpacked) + if tag == "dict" and len(value) == 2 and type(value[1]) is tuple: + entries = value[1] + if len(entries) > _MAX_CONTAINER_ITEMS: + raise ValueError("visual RPC mapping is too large") + result = {} + for entry in entries: + if type(entry) is not tuple or len(entry) != 2 or type(entry[0]) is not str: + raise ValueError("invalid visual RPC mapping entry") + if entry[0] in result: + raise ValueError("duplicate visual RPC mapping key") + result[entry[0]] = _unpack_value(entry[1], depth + 1) + return result + raise ValueError("invalid visual RPC value tag") + + +def pack_image_items(images: Sequence[Any]) -> Tuple[Any, ...]: + if type(images) not in (list, tuple) or len(images) > _MAX_IMAGE_COUNT: + raise ValueError("invalid visual RPC image batch") + + records = [] + for image in images: + records.append(tuple(_pack_value(getattr(image, field, None)) for field in _IMAGE_FIELDS)) + return (_PAYLOAD_VERSION, tuple(records)) + + +def unpack_image_items( + payload: Any, + image_item_factory: Callable[..., Any], + max_count: Optional[int] = None, + require_image_data: bool = False, +) -> List[Any]: + if type(payload) is not tuple or len(payload) != 2: + raise ValueError("visual RPC payload must be a versioned tuple") + if type(payload[0]) is not int or payload[0] != _PAYLOAD_VERSION: + raise ValueError("unsupported visual RPC payload version") + records = payload[1] + if type(records) is not tuple or len(records) > _MAX_IMAGE_COUNT: + raise ValueError("invalid visual RPC image records") + if max_count is not None and len(records) > max_count: + raise ValueError("visual RPC image batch exceeds the configured limit") + + images = [] + for record in records: + if type(record) is not tuple or len(record) != len(_IMAGE_FIELDS): + raise ValueError("invalid visual RPC image record") + values = [_unpack_value(value) for value in record] + image = image_item_factory(type=values[0], data=values[1]) + for field, value in zip(_IMAGE_FIELDS[2:], values[2:]): + if field == "data_bytes" and value is None: + continue + setattr(image, field, value) + if image._type not in ("url", "base64", "image_size"): + raise ValueError("invalid visual RPC image type") + data_bytes = getattr(image, "data_bytes", None) + if data_bytes is not None and type(data_bytes) is not bytes: + raise ValueError("visual RPC image data must be bytes") + if require_image_data and (type(data_bytes) is not bytes or not data_bytes): + raise ValueError("visual RPC image data is required") + for field in ("uuid", "md5"): + value = getattr(image, field) + if value is not None and type(value) is not str: + raise ValueError("invalid visual RPC image metadata") + if require_image_data and (not image.uuid or not image.md5): + raise ValueError("visual RPC image identifiers are required") + for field in ("token_id", "start_index_in_embed_cache", "token_num", "start_idx", "image_w", "image_h"): + value = getattr(image, field) + if value is not None and type(value) is not int: + raise ValueError("invalid visual RPC image dimensions or token metadata") + if image.grid_thwd is not None and ( + type(image.grid_thwd) not in (tuple, list) or any(type(value) is not int for value in image.grid_thwd) + ): + raise ValueError("invalid visual RPC image grid") + if type(image.extra_params) is not dict: + raise ValueError("invalid visual RPC image options") + images.append(image) + return images + + +class RemoteEvent: + def __init__(self, event: Event): + self._event = event + + def exposed_set(self) -> None: + self._event.set() diff --git a/lightllm/server/visualserver/visual_only_manager.py b/lightllm/server/visualserver/visual_only_manager.py index 835a25fe3e..26d1912461 100644 --- a/lightllm/server/visualserver/visual_only_manager.py +++ b/lightllm/server/visualserver/visual_only_manager.py @@ -26,9 +26,10 @@ from lightllm.utils.process_check import start_parent_check_thread from lightllm.utils.envs_utils import get_unique_server_name from lightllm.utils.shm_port_args import get_shm_port_args -from rpyc.utils.classic import obtain from lightllm.server.embed_cache.utils import create_shm, get_shm_name_data, free_shm from .manager import VisualManager +from .objs import visual_only_rpyc_config +from .rpc_payload import unpack_image_items logger = init_logger(__name__) @@ -40,6 +41,7 @@ def __init__( args: StartArgs, ): self.args = args + self.rpc_host = get_hostname_ip() if args.host == "0.0.0.0" else args.host self.model_weightdir = args.model_dir self.vit_dp = args.visual_dp assert self.vit_dp == 1 @@ -64,10 +66,7 @@ async def register_to_config_server_loop(self, args: StartArgs): os.kill(os.getppid(), signal.SIGTERM) sys.exit(-1) - if args.host in ["0.0.0.0"]: - host_ip = get_hostname_ip() - else: - host_ip = args.host + host_ip = self.rpc_host ports = get_shm_port_args() while True: @@ -135,9 +134,25 @@ async def infer_images(self, dp_index: int, images, events): def clean_up(self): return - def exposed_remote_infer_images(self, images: List[ImageItem], ref_event: threading.Event): + def exposed_remote_infer_images(self, payload: tuple, ref_event): + images = unpack_image_items( + payload, + ImageItem, + max_count=self.infer_batch_size, + require_image_data=True, + ) + created_shm_names = [] + + def _cleanup_shared_memory(): + for shm_name in created_shm_names: + try: + free_shm(shm_name) + except FileNotFoundError: + pass + except Exception: + logger.exception(f"Failed to clean up visual inference shared memory {shm_name}") + try: - images = obtain(images) logger.info(f"Received infer_images request with {len(images)} images md5s: {[img.md5 for img in images]}") start = time.time() # 将 images 的内容写入到 shm 中,这里修改了原始的uuid,主要是在远端的vit @@ -145,7 +160,9 @@ def exposed_remote_infer_images(self, images: List[ImageItem], ref_event: thread # 目标的 md5 一致即可,这样调用端一样可以拿到准确的数据。 for image in images: image.uuid = str(uuid.uuid4()) - create_shm(get_shm_name_data(image.uuid), image.data_bytes) + shm_name = get_shm_name_data(image.uuid) + create_shm(shm_name, image.data_bytes) + created_shm_names.append(shm_name) del image.data_bytes handle = asyncio.run_coroutine_threadsafe(self.handle_images(images_need_infer=images), loop=self.new_loop) @@ -156,10 +173,10 @@ def _callback(fut): f"Finished infer_images for images {[image.md5 for image in images]}" f" with latency {time.time() - start} seconds" ) - ref_event.set() - # 清理资源 - for image in images: - free_shm(get_shm_name_data(image.uuid)) + try: + ref_event.set() + finally: + _cleanup_shared_memory() logger.info( f"Finished infer_images request for images {[image.md5 for image in images]}" f" and cleaned up shm resources" @@ -168,9 +185,7 @@ def _callback(fut): handle.add_done_callback(_callback) except BaseException as e: logger.exception(str(e)) - # 清理资源 - for image in images: - free_shm(get_shm_name_data(image.uuid)) + _cleanup_shared_memory() raise e return @@ -198,9 +213,12 @@ def handle_exception(loop, context): visualserver.register_to_config_server_loop(args=args), loop=visualserver.new_loop ) - from .objs import rpyc_config - - t = rpyc.ThreadedServer(visualserver, port=get_shm_port_args().visual_rpyc_port, protocol_config=rpyc_config) + t = rpyc.ThreadedServer( + visualserver, + hostname=visualserver.rpc_host, + port=get_shm_port_args().visual_rpyc_port, + protocol_config=visual_only_rpyc_config, + ) except Exception as e: logger.exception(str(e)) visualserver.clean_up() diff --git a/unit_tests/server/test_visual_only_rpyc.py b/unit_tests/server/test_visual_only_rpyc.py new file mode 100644 index 0000000000..09190e0087 --- /dev/null +++ b/unit_tests/server/test_visual_only_rpyc.py @@ -0,0 +1,149 @@ +import os +import tempfile +import threading +import time +import unittest + +import rpyc +from rpyc import ThreadedServer, connect + +from lightllm.server.visualserver.objs import visual_only_rpyc_config +from lightllm.server.visualserver.rpc_payload import RemoteEvent, pack_image_items, unpack_image_items + + +class ImageItem: + def __init__(self, type, data): + self._type = type + self._data = data + self.uuid = None + self.token_id = None + self.start_index_in_embed_cache = None + self.token_num = None + self.md5 = None + self.start_idx = None + self.grid_thwd = None + self.image_w = 0 + self.image_h = 0 + self.extra_params = {} + + +class PayloadService(rpyc.Service): + def exposed_accept(self, payload): + images = unpack_image_items(payload, ImageItem, max_count=4, require_image_data=True) + return tuple((image.md5, image.data_bytes) for image in images) + + def exposed_signal(self, event): + event.set() + + +class MaliciousPayload: + def __init__(self, marker_path): + self.marker_path = marker_path + + def __reduce__(self): + return (os.mkdir, (self.marker_path,)) + + +def _start_server(): + server = ThreadedServer( + PayloadService, + hostname="127.0.0.1", + port=0, + protocol_config=visual_only_rpyc_config, + auto_register=False, + ) + thread = threading.Thread(target=server.start, daemon=True) + thread.start() + for _ in range(100): + try: + client = connect("127.0.0.1", server.port, config=visual_only_rpyc_config) + return server, thread, client + except OSError: + time.sleep(0.01) + server.close() + raise RuntimeError("local RPyC service did not start") + + +class VisualOnlyRpcTests(unittest.TestCase): + def test_image_payload_round_trip_preserves_supported_fields(self): + image = ImageItem("base64", "encoded source") + image.uuid = "image-1" + image.token_id = 17 + image.start_index_in_embed_cache = 23 + image.token_num = 2 + image.md5 = "0123456789abcdef0123456789abcdef" + image.start_idx = 31 + image.grid_thwd = [1, 2, 3] + image.image_w = 640 + image.image_h = 480 + image.extra_params = {"crop": [0, 1], "options": {"enabled": True}} + image.data_bytes = b"image bytes" + + result = unpack_image_items(pack_image_items([image]), ImageItem) + + self.assertEqual(len(result), 1) + restored = result[0] + self.assertEqual(restored._type, image._type) + self.assertEqual(restored._data, image._data) + self.assertEqual(restored.uuid, image.uuid) + self.assertEqual(restored.token_id, image.token_id) + self.assertEqual(restored.start_index_in_embed_cache, image.start_index_in_embed_cache) + self.assertEqual(restored.token_num, image.token_num) + self.assertEqual(restored.md5, image.md5) + self.assertEqual(restored.start_idx, image.start_idx) + self.assertEqual(restored.grid_thwd, image.grid_thwd) + self.assertEqual(restored.image_w, image.image_w) + self.assertEqual(restored.image_h, image.image_h) + self.assertEqual(restored.extra_params, image.extra_params) + self.assertEqual(restored.data_bytes, image.data_bytes) + + def test_unpack_rejects_invalid_payload_shapes(self): + for payload in ([], (2, ()), (1, (("invalid",),)), (1, (("none",),))): + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + unpack_image_items(payload, ImageItem) + + def test_unpack_enforces_batch_limit(self): + image = ImageItem("base64", "source") + payload = pack_image_items([image, image]) + + with self.assertRaisesRegex(ValueError, "configured limit"): + unpack_image_items(payload, ImageItem, max_count=1) + + def test_secure_rpyc_payload_and_callback_do_not_unpickle(self): + with tempfile.TemporaryDirectory() as temp_dir: + marker = os.path.join(temp_dir, "unpickled") + image = ImageItem("base64", "encoded source") + image.uuid = "image-1" + image.md5 = "0123456789abcdef0123456789abcdef" + image.data_bytes = b"image bytes" + server, thread, client = _start_server() + try: + self.assertEqual( + client.root.accept(pack_image_items([image])), + ((image.md5, image.data_bytes),), + ) + + event = threading.Event() + client.root.signal(RemoteEvent(event)) + self.assertTrue(event.wait(1)) + + attacker_config = dict(visual_only_rpyc_config, allow_pickle=True, allow_all_attrs=True) + attacker = connect("127.0.0.1", server.port, config=attacker_config) + try: + with self.assertRaises(Exception): + attacker.root.accept(MaliciousPayload(marker)) + finally: + attacker.close() + + self.assertFalse(os.path.exists(marker)) + finally: + client.close() + server.close() + thread.join(1) + + def test_visual_only_rpyc_config_disables_unsafe_protocol_features(self): + self.assertFalse(visual_only_rpyc_config["allow_pickle"]) + self.assertFalse(visual_only_rpyc_config["allow_all_attrs"]) + self.assertFalse(visual_only_rpyc_config["allow_setattr"]) + self.assertFalse(visual_only_rpyc_config["allow_delattr"]) From c02909d2765a88cba9af135ff09d23579b59b0ac Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 09:19:02 +0800 Subject: [PATCH 2/4] Harden visual-only RPyC transport --- .../visualserver/model_infer/model_rpc.py | 1 + .../model_infer/model_rpc_client.py | 1 + lightllm/server/visualserver/objs.py | 3 + lightllm/server/visualserver/proxy_manager.py | 2 + lightllm/server/visualserver/rpc_payload.py | 49 ++++++- lightllm/server/visualserver/rpyc_server.py | 74 ++++++++++ .../visualserver/visual_only_manager.py | 42 +++--- unit_tests/server/test_visual_only_rpyc.py | 126 ++++++++++++++++-- 8 files changed, 266 insertions(+), 32 deletions(-) create mode 100644 lightllm/server/visualserver/rpyc_server.py diff --git a/lightllm/server/visualserver/model_infer/model_rpc.py b/lightllm/server/visualserver/model_infer/model_rpc.py index c4a4a41095..65e0e2cdbf 100644 --- a/lightllm/server/visualserver/model_infer/model_rpc.py +++ b/lightllm/server/visualserver/model_infer/model_rpc.py @@ -181,6 +181,7 @@ def _hold_vision_peak_vram(self): ) def exposed_run_task(self, images: List["ImageItem"], ref_event_list: List[threading.Event]): + """Validate image items received from the visual proxy and enqueue them.""" try: images = unpack_image_items(images, ImageItem) for i in range(len(images)): diff --git a/lightllm/server/visualserver/model_infer/model_rpc_client.py b/lightllm/server/visualserver/model_infer/model_rpc_client.py index e3f79fcd01..89295b4338 100644 --- a/lightllm/server/visualserver/model_infer/model_rpc_client.py +++ b/lightllm/server/visualserver/model_infer/model_rpc_client.py @@ -33,5 +33,6 @@ async def init_model(self, kvargs): return async def run_task(self, images: List[ImageItem], ref_event_list: List[threading.Event]): + """Serialize images before dispatching them to the model RPC worker.""" ans = self._run_task(pack_image_items(images), ref_event_list) return await ans diff --git a/lightllm/server/visualserver/objs.py b/lightllm/server/visualserver/objs.py index 8fedae6c9a..6c766e199c 100644 --- a/lightllm/server/visualserver/objs.py +++ b/lightllm/server/visualserver/objs.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from lightllm.utils.log_utils import init_logger +from .rpc_payload import MAX_RPC_FRAME_BYTES logger = init_logger(__name__) +VISUAL_ONLY_RPC_HOST = "127.0.0.1" rpyc_config = { "allow_pickle": True, @@ -18,6 +20,7 @@ "allow_delattr": False, "allow_public_attrs": False, "allow_exposed_attrs": True, + "max_message_size": MAX_RPC_FRAME_BYTES, } diff --git a/lightllm/server/visualserver/proxy_manager.py b/lightllm/server/visualserver/proxy_manager.py index aa17135bcf..727d02655b 100644 --- a/lightllm/server/visualserver/proxy_manager.py +++ b/lightllm/server/visualserver/proxy_manager.py @@ -120,6 +120,7 @@ def select_vit_conn(self) -> Optional[rpyc.Connection]: return self.id_to_rpyc_conn[id] def run_task(self, conn: rpyc.Connection, images: List[ImageItem]): + """Copy image data into the visual RPC payload and await inference completion.""" event = threading.Event() # 避免修改原始的 image 对象,主要是为了避免在后续的流程中出现问题,因为后续的流程可能会对 image 对象进行访问, # 尤其是一些 cache 的逻辑,如果直接修改了原始的 image 对象,可能会导致一些不可预期的问题。 @@ -140,6 +141,7 @@ def run_task(self, conn: rpyc.Connection, images: List[ImageItem]): return async def loop_to_connect_remote_visual_server(self): + """Refresh registered visual servers and maintain their RPC connections.""" counter = 0 error_counter = 0 while True: diff --git a/lightllm/server/visualserver/rpc_payload.py b/lightllm/server/visualserver/rpc_payload.py index 29e6941674..3f20930096 100644 --- a/lightllm/server/visualserver/rpc_payload.py +++ b/lightllm/server/visualserver/rpc_payload.py @@ -1,5 +1,6 @@ +import logging from math import isfinite -from threading import Event +from threading import Event, Thread from typing import Any, Callable, List, Optional, Sequence, Tuple @@ -22,9 +23,28 @@ _MAX_IMAGE_COUNT = 4096 _MAX_CONTAINER_ITEMS = 4096 _MAX_NESTING = 16 +MAX_IMAGE_BYTES = 32 * 1024 * 1024 +MAX_BATCH_IMAGE_BYTES = 64 * 1024 * 1024 +MAX_RPC_FRAME_BYTES = MAX_BATCH_IMAGE_BYTES + 1024 * 1024 +logger = logging.getLogger(__name__) + + +def _validate_image_data_size(data_bytes: Any, total_bytes: int) -> int: + """Validate one image payload and update the batch byte total.""" + if data_bytes is None: + return total_bytes + if type(data_bytes) is not bytes: + raise ValueError("visual RPC image data must be bytes") + if len(data_bytes) > MAX_IMAGE_BYTES: + raise ValueError("visual RPC image exceeds the per-image byte limit") + total_bytes += len(data_bytes) + if total_bytes > MAX_BATCH_IMAGE_BYTES: + raise ValueError("visual RPC image batch exceeds the aggregate byte limit") + return total_bytes def _pack_value(value: Any, depth: int = 0) -> Tuple[Any, ...]: + """Encode a supported value as a tagged tuple of built-in values.""" if depth > _MAX_NESTING: raise ValueError("visual RPC value is nested too deeply") if value is None: @@ -61,6 +81,7 @@ def _pack_value(value: Any, depth: int = 0) -> Tuple[Any, ...]: def _unpack_value(value: Any, depth: int = 0) -> Any: + """Decode one tagged value while validating its type and nesting.""" if depth > _MAX_NESTING or type(value) is not tuple or not value: raise ValueError("invalid visual RPC value") @@ -105,11 +126,14 @@ def _unpack_value(value: Any, depth: int = 0) -> Any: def pack_image_items(images: Sequence[Any]) -> Tuple[Any, ...]: + """Serialize image items and enforce their per-image and batch byte budgets.""" if type(images) not in (list, tuple) or len(images) > _MAX_IMAGE_COUNT: raise ValueError("invalid visual RPC image batch") records = [] + total_bytes = 0 for image in images: + total_bytes = _validate_image_data_size(getattr(image, "data_bytes", None), total_bytes) records.append(tuple(_pack_value(getattr(image, field, None)) for field in _IMAGE_FIELDS)) return (_PAYLOAD_VERSION, tuple(records)) @@ -120,6 +144,7 @@ def unpack_image_items( max_count: Optional[int] = None, require_image_data: bool = False, ) -> List[Any]: + """Validate a versioned image payload and reconstruct its image items.""" if type(payload) is not tuple or len(payload) != 2: raise ValueError("visual RPC payload must be a versioned tuple") if type(payload[0]) is not int or payload[0] != _PAYLOAD_VERSION: @@ -131,10 +156,12 @@ def unpack_image_items( raise ValueError("visual RPC image batch exceeds the configured limit") images = [] + total_bytes = 0 for record in records: if type(record) is not tuple or len(record) != len(_IMAGE_FIELDS): raise ValueError("invalid visual RPC image record") values = [_unpack_value(value) for value in record] + total_bytes = _validate_image_data_size(values[-1], total_bytes) image = image_item_factory(type=values[0], data=values[1]) for field, value in zip(_IMAGE_FIELDS[2:], values[2:]): if field == "data_bytes" and value is None: @@ -168,8 +195,28 @@ def unpack_image_items( class RemoteEvent: + """Expose a narrow callback for signaling an event owned by the client.""" + def __init__(self, event: Event): + """Store the event that the remote peer may signal.""" self._event = event def exposed_set(self) -> None: + """Signal the stored event.""" self._event.set() + + +def notify_remote_event_async(remote_event: Any) -> None: + """Signal a client-owned event on a daemon thread without blocking inference.""" + + def _notify() -> None: + try: + remote_event.set() + except Exception: + logger.exception("Failed to notify the visual inference client") + + worker = Thread(target=_notify, name="visual-rpc-callback", daemon=True) + try: + worker.start() + except RuntimeError: + logger.exception("Failed to start visual RPC callback thread") diff --git a/lightllm/server/visualserver/rpyc_server.py b/lightllm/server/visualserver/rpyc_server.py new file mode 100644 index 0000000000..176cc91849 --- /dev/null +++ b/lightllm/server/visualserver/rpyc_server.py @@ -0,0 +1,74 @@ +import socket +import zlib +from typing import Any + +from rpyc.core.channel import Channel +from rpyc.core.stream import SocketStream +from rpyc.utils.server import ThreadedServer + +from .rpc_payload import MAX_RPC_FRAME_BYTES + + +class BoundedChannel(Channel): + """Reject oversized RPyC frames before reading or decompressing their payloads.""" + + __slots__ = ("_max_message_size",) + + def __init__(self, stream: SocketStream, max_message_size: int) -> None: + """Create a channel with a maximum serialized frame size.""" + if type(max_message_size) is not int or max_message_size <= 0: + raise ValueError("max_message_size must be a positive integer") + super().__init__(stream) + self._max_message_size = max_message_size + + def recv(self) -> bytes: + """Read one frame while bounding both compressed and expanded data.""" + header = self.stream.read(self.FRAME_HEADER.size) + length, compressed = self.FRAME_HEADER.unpack(header) + if length > self._max_message_size: + self.stream.close() + raise EOFError("visual RPC frame exceeds the configured byte limit") + + data = self.stream.read(length + len(self.FLUSHER))[: -len(self.FLUSHER)] + if compressed: + decompressor = zlib.decompressobj() + try: + data = decompressor.decompress(data, self._max_message_size + 1) + except zlib.error as error: + self.stream.close() + raise EOFError("invalid compressed visual RPC frame") from error + if ( + len(data) > self._max_message_size + or decompressor.unconsumed_tail + or not decompressor.eof + or decompressor.unused_data + ): + self.stream.close() + raise EOFError("visual RPC frame exceeds the configured byte limit or is invalid") + return data + + +class VisualOnlyThreadedServer(ThreadedServer): + """Threaded RPyC server that applies the visual endpoint's frame limit.""" + + def _serve_client(self, sock: socket.socket, credentials: Any) -> None: + """Serve one client using the bounded channel instead of RPyC's default channel.""" + addrinfo = sock.getpeername() + if credentials: + self.logger.info(f"welcome {addrinfo} ({credentials!r})") + else: + self.logger.info(f"welcome {addrinfo}") + + try: + config = dict( + self.protocol_config, + credentials=credentials, + endpoints=(sock.getsockname(), addrinfo), + logger=self.logger, + ) + # RPyC 5.3.1 hard-codes Channel in Server._serve_client. + channel = BoundedChannel(SocketStream(sock), config.get("max_message_size", MAX_RPC_FRAME_BYTES)) + conn = self.service._connect(channel, config) + self._handle_connection(conn) + finally: + self.logger.info(f"goodbye {addrinfo}") diff --git a/lightllm/server/visualserver/visual_only_manager.py b/lightllm/server/visualserver/visual_only_manager.py index 26d1912461..65d5a19dcd 100644 --- a/lightllm/server/visualserver/visual_only_manager.py +++ b/lightllm/server/visualserver/visual_only_manager.py @@ -8,13 +8,9 @@ import pickle import websockets import socket -import sys -import os -import signal import time -from lightllm.utils.net_utils import get_hostname_ip from .objs import VIT_Obj -from typing import List +from typing import Any, List, Tuple from lightllm.server.core.objs import StartArgs asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) @@ -28,8 +24,9 @@ from lightllm.utils.shm_port_args import get_shm_port_args from lightllm.server.embed_cache.utils import create_shm, get_shm_name_data, free_shm from .manager import VisualManager -from .objs import visual_only_rpyc_config -from .rpc_payload import unpack_image_items +from .objs import VISUAL_ONLY_RPC_HOST, visual_only_rpyc_config +from .rpc_payload import notify_remote_event_async, unpack_image_items +from .rpyc_server import VisualOnlyThreadedServer logger = init_logger(__name__) @@ -40,8 +37,9 @@ def __init__( self, args: StartArgs, ): + """Initialize the visual inference service and its worker event loop.""" self.args = args - self.rpc_host = get_hostname_ip() if args.host == "0.0.0.0" else args.host + self.rpc_host = VISUAL_ONLY_RPC_HOST self.model_weightdir = args.model_dir self.vit_dp = args.visual_dp assert self.vit_dp == 1 @@ -52,7 +50,7 @@ def __init__( self.new_loop = asyncio.new_event_loop() - def _event_loop(): + def _event_loop() -> None: asyncio.set_event_loop(self.new_loop) self.new_loop.run_forever() @@ -60,12 +58,7 @@ def _event_loop(): t.start() async def register_to_config_server_loop(self, args: StartArgs): - if args.host in ["127.0.0.1", "localhost"]: - logger.error("remote visual server must specify host ip, can not be localhost or 127.0.0.1") - # kill father process to trigger graceful exit, avoid orphan process - os.kill(os.getppid(), signal.SIGTERM) - sys.exit(-1) - + """Register this loopback-only endpoint with the local config service.""" host_ip = self.rpc_host ports = get_shm_port_args() @@ -134,7 +127,8 @@ async def infer_images(self, dp_index: int, images, events): def clean_up(self): return - def exposed_remote_infer_images(self, payload: tuple, ref_event): + def exposed_remote_infer_images(self, payload: Tuple[Any, ...], ref_event: Any) -> None: + """Validate remote image data, stage it in shared memory, and queue inference.""" images = unpack_image_items( payload, ImageItem, @@ -143,7 +137,8 @@ def exposed_remote_infer_images(self, payload: tuple, ref_event): ) created_shm_names = [] - def _cleanup_shared_memory(): + def _cleanup_shared_memory() -> None: + """Release shared-memory segments created for this request.""" for shm_name in created_shm_names: try: free_shm(shm_name) @@ -167,16 +162,17 @@ def _cleanup_shared_memory(): handle = asyncio.run_coroutine_threadsafe(self.handle_images(images_need_infer=images), loop=self.new_loop) - def _callback(fut): + def _callback(fut: Any) -> None: + """Release request storage before notifying the client asynchronously.""" if time.time() - start > 0.05: logger.info( f"Finished infer_images for images {[image.md5 for image in images]}" f" with latency {time.time() - start} seconds" ) try: - ref_event.set() - finally: _cleanup_shared_memory() + finally: + notify_remote_event_async(ref_event) logger.info( f"Finished infer_images request for images {[image.md5 for image in images]}" f" and cleaned up shm resources" @@ -191,6 +187,7 @@ def _callback(fut): def start_visual_process(args: StartArgs, pipe_writer): + """Initialize the visual inference process and start its loopback RPC server.""" import lightllm.utils.rpyc_fix_utils as _ # 注册graceful 退出的处理 @@ -201,7 +198,8 @@ def start_visual_process(args: StartArgs, pipe_writer): try: visualserver = VisualOnlyManager(args=args) - def handle_exception(loop, context): + def handle_exception(loop: asyncio.AbstractEventLoop, context: Any) -> None: + """Log unhandled errors raised by the visual inference event loop.""" logger.exception(f"VisualServer Caught exception: {str(context)}") visualserver.new_loop.set_exception_handler(handle_exception) @@ -213,7 +211,7 @@ def handle_exception(loop, context): visualserver.register_to_config_server_loop(args=args), loop=visualserver.new_loop ) - t = rpyc.ThreadedServer( + t = VisualOnlyThreadedServer( visualserver, hostname=visualserver.rpc_host, port=get_shm_port_args().visual_rpyc_port, diff --git a/unit_tests/server/test_visual_only_rpyc.py b/unit_tests/server/test_visual_only_rpyc.py index 09190e0087..1a0bc52e8f 100644 --- a/unit_tests/server/test_visual_only_rpyc.py +++ b/unit_tests/server/test_visual_only_rpyc.py @@ -1,18 +1,30 @@ import os +import socket import tempfile import threading import time import unittest +import zlib +from unittest.mock import patch import rpyc -from rpyc import ThreadedServer, connect +from rpyc import connect +from rpyc.core.channel import Channel -from lightllm.server.visualserver.objs import visual_only_rpyc_config -from lightllm.server.visualserver.rpc_payload import RemoteEvent, pack_image_items, unpack_image_items +from lightllm.server.visualserver.objs import VISUAL_ONLY_RPC_HOST, visual_only_rpyc_config +from lightllm.server.visualserver.rpc_payload import ( + MAX_RPC_FRAME_BYTES, + RemoteEvent, + notify_remote_event_async, + pack_image_items, + unpack_image_items, +) +from lightllm.server.visualserver.rpyc_server import VisualOnlyThreadedServer class ImageItem: def __init__(self, type, data): + """Create a lightweight image stand-in for payload tests.""" self._type = type self._data = data self.uuid = None @@ -28,35 +40,45 @@ def __init__(self, type, data): class PayloadService(rpyc.Service): + """Expose the visual payload helpers through a local test server.""" + def exposed_accept(self, payload): + """Validate a remote image payload and return its stable fields.""" images = unpack_image_items(payload, ImageItem, max_count=4, require_image_data=True) return tuple((image.md5, image.data_bytes) for image in images) def exposed_signal(self, event): + """Signal a client-owned event through the restricted protocol.""" event.set() class MaliciousPayload: def __init__(self, marker_path): + """Store the path that an unsafe unpickler would create.""" self.marker_path = marker_path def __reduce__(self): + """Return a pickle reduction that would create the marker directory.""" return (os.mkdir, (self.marker_path,)) -def _start_server(): - server = ThreadedServer( +def _start_server(max_message_size=None): + """Start a loopback visual RPC server for transport tests.""" + protocol_config = dict(visual_only_rpyc_config) + if max_message_size is not None: + protocol_config["max_message_size"] = max_message_size + server = VisualOnlyThreadedServer( PayloadService, - hostname="127.0.0.1", + hostname=VISUAL_ONLY_RPC_HOST, port=0, - protocol_config=visual_only_rpyc_config, + protocol_config=protocol_config, auto_register=False, ) thread = threading.Thread(target=server.start, daemon=True) thread.start() for _ in range(100): try: - client = connect("127.0.0.1", server.port, config=visual_only_rpyc_config) + client = connect(VISUAL_ONLY_RPC_HOST, server.port, config=protocol_config) return server, thread, client except OSError: time.sleep(0.01) @@ -65,7 +87,10 @@ def _start_server(): class VisualOnlyRpcTests(unittest.TestCase): + """Exercise the serialized visual RPC contract and its transport limits.""" + def test_image_payload_round_trip_preserves_supported_fields(self): + """Preserve supported image fields through pack and unpack.""" image = ImageItem("base64", "encoded source") image.uuid = "image-1" image.token_id = 17 @@ -98,19 +123,100 @@ def test_image_payload_round_trip_preserves_supported_fields(self): self.assertEqual(restored.data_bytes, image.data_bytes) def test_unpack_rejects_invalid_payload_shapes(self): + """Reject unknown versions, malformed records, and invalid tags.""" for payload in ([], (2, ()), (1, (("invalid",),)), (1, (("none",),))): with self.subTest(payload=payload): with self.assertRaises(ValueError): unpack_image_items(payload, ImageItem) def test_unpack_enforces_batch_limit(self): + """Enforce the configured image-count limit while decoding.""" image = ImageItem("base64", "source") payload = pack_image_items([image, image]) with self.assertRaisesRegex(ValueError, "configured limit"): unpack_image_items(payload, ImageItem, max_count=1) + def test_pack_and_unpack_enforce_image_byte_limits(self): + """Enforce per-image and aggregate byte limits on both RPC boundaries.""" + oversized = ImageItem("base64", "source") + oversized.data_bytes = b"12345" + oversized_payload = pack_image_items([oversized]) + with patch("lightllm.server.visualserver.rpc_payload.MAX_IMAGE_BYTES", 4): + with self.assertRaisesRegex(ValueError, "per-image"): + pack_image_items([oversized]) + with self.assertRaisesRegex(ValueError, "per-image"): + unpack_image_items(oversized_payload, ImageItem) + + image_a = ImageItem("base64", "source") + image_a.data_bytes = b"1234" + image_b = ImageItem("base64", "source") + image_b.data_bytes = b"5678" + batch_payload = pack_image_items([image_a, image_b]) + with patch("lightllm.server.visualserver.rpc_payload.MAX_BATCH_IMAGE_BYTES", 7): + with self.assertRaisesRegex(ValueError, "aggregate"): + pack_image_items([image_a, image_b]) + with self.assertRaisesRegex(ValueError, "aggregate"): + unpack_image_items(batch_payload, ImageItem) + + def test_remote_event_notification_does_not_block_the_caller(self): + """Run a slow client callback on a daemon worker thread.""" + callback_started = threading.Event() + callback_release = threading.Event() + caller_finished = threading.Event() + + class SlowRemoteEvent: + """Block until released to model a slow client connection.""" + + def set(self): + """Mark that notification began, then wait for the test to release it.""" + callback_started.set() + callback_release.wait(2) + + def notify(): + """Dispatch the callback and report when dispatch has returned.""" + notify_remote_event_async(SlowRemoteEvent()) + caller_finished.set() + + threading.Thread(target=notify, daemon=True).start() + try: + self.assertTrue(callback_started.wait(1)) + self.assertTrue(caller_finished.wait(0.5)) + finally: + callback_release.set() + + def test_server_binds_loopback_and_rejects_oversized_frame_headers(self): + """Bind only to loopback and reject an oversized frame before its body.""" + server, thread, client = _start_server() + try: + self.assertEqual(server.listener.getsockname()[0], VISUAL_ONLY_RPC_HOST) + with socket.create_connection((VISUAL_ONLY_RPC_HOST, server.port), timeout=2) as raw_client: + raw_client.settimeout(2) + raw_client.sendall(Channel.FRAME_HEADER.pack(MAX_RPC_FRAME_BYTES + 1, 0)) + self.assertEqual(raw_client.recv(1), b"") + finally: + client.close() + server.close() + thread.join(1) + + def test_server_caps_compressed_frame_expansion(self): + """Reject a compressed frame whose expanded data exceeds the cap.""" + max_message_size = 32 + server, thread, client = _start_server(max_message_size=max_message_size) + try: + compressed = zlib.compress(b"x" * (max_message_size + 1)) + frame = Channel.FRAME_HEADER.pack(len(compressed), 1) + compressed + Channel.FLUSHER + with socket.create_connection((VISUAL_ONLY_RPC_HOST, server.port), timeout=2) as raw_client: + raw_client.settimeout(2) + raw_client.sendall(frame) + self.assertEqual(raw_client.recv(1), b"") + finally: + client.close() + server.close() + thread.join(1) + def test_secure_rpyc_payload_and_callback_do_not_unpickle(self): + """Round-trip RPC data and ensure restricted transport rejects pickle.""" with tempfile.TemporaryDirectory() as temp_dir: marker = os.path.join(temp_dir, "unpickled") image = ImageItem("base64", "encoded source") @@ -129,7 +235,7 @@ def test_secure_rpyc_payload_and_callback_do_not_unpickle(self): self.assertTrue(event.wait(1)) attacker_config = dict(visual_only_rpyc_config, allow_pickle=True, allow_all_attrs=True) - attacker = connect("127.0.0.1", server.port, config=attacker_config) + attacker = connect(VISUAL_ONLY_RPC_HOST, server.port, config=attacker_config) try: with self.assertRaises(Exception): attacker.root.accept(MaliciousPayload(marker)) @@ -143,7 +249,9 @@ def test_secure_rpyc_payload_and_callback_do_not_unpickle(self): thread.join(1) def test_visual_only_rpyc_config_disables_unsafe_protocol_features(self): + """Keep pickle and unrestricted attribute access disabled.""" self.assertFalse(visual_only_rpyc_config["allow_pickle"]) self.assertFalse(visual_only_rpyc_config["allow_all_attrs"]) self.assertFalse(visual_only_rpyc_config["allow_setattr"]) self.assertFalse(visual_only_rpyc_config["allow_delattr"]) + self.assertEqual(visual_only_rpyc_config["max_message_size"], MAX_RPC_FRAME_BYTES) From e84f54e5f74ea86808dc116466ceecd9abba6d2b Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 10:01:49 +0800 Subject: [PATCH 3/4] Clarify visual-only same-host deployment --- lightllm/server/visualserver/visual_only_manager.py | 1 + skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lightllm/server/visualserver/visual_only_manager.py b/lightllm/server/visualserver/visual_only_manager.py index 65d5a19dcd..4384bfaf80 100644 --- a/lightllm/server/visualserver/visual_only_manager.py +++ b/lightllm/server/visualserver/visual_only_manager.py @@ -39,6 +39,7 @@ def __init__( ): """Initialize the visual inference service and its worker event loop.""" self.args = args + # Proxies for this service must run on the same host because its RPC endpoint is loopback-only. self.rpc_host = VISUAL_ONLY_RPC_HOST self.model_weightdir = args.model_dir self.vit_dp = args.visual_dp diff --git a/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md b/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md index 2224c35ae3..a4ee867fa0 100644 --- a/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md +++ b/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md @@ -4,6 +4,8 @@ description: >- LightLLM Qwen3-VL-8B-Instruct visual separation (ViT sep / proxy): three processes in order—config_server on 8090; internal Redis on 6000; visual_only with visual_rpyc 8091 and afs_image_embed_dir; normal api_server tp 2 port 8089 with visual_use_proxy_mode. + The visual_only worker and normal proxy must share a host because RPyC advertises loopback; + the config_server may be remote. After HTTP /v1/models on normal, lmms_eval mmmu_val (openai_compatible, batch 900, OPENAI_API_BASE http://HOST:8089/v1); restore https_proxy for Hub while no_proxy includes 127.0.0.1. lmms_eval_out, console log, mmmu_acc in summary. pipefail for tee exit code. @@ -22,6 +24,8 @@ description: >- | **`visual_only` RPyC** | **`8091`**(**`--visual_rpyc_port`**) | | **`normal` HTTP** | **`8089`**(**`--port`**) | +**部署约束**:`visual_only` 的 RPyC 服务只监听并向 `config_server` 注册 `127.0.0.1`。因此,使用 `--visual_use_proxy_mode` 的 `normal` 服务必须与对应的 `visual_only` 服务运行在同一主机;把两者放在不同主机上会使 proxy 连接到自己的 loopback,无法访问视觉服务。`--host` 和 `--config_server_host` 不会改变 RPyC 的绑定地址。不要通过开放防火墙端口或改绑外部地址来绕过此限制。 + **算力**:**`visual_only`** 默认 **1 张 GPU**;**`normal`** 默认 **`--tp 2`** → **2 张 GPU**;**三组进程不得争抢同一物理 GPU**(脚本示例为 **visual:`0`**,**LLM:`6,7`**;实际以 **`nvidia-smi`** 选定)。 ## 依赖 @@ -44,7 +48,7 @@ description: >- 3. **`MODEL_DIR`**:指向 **Qwen3-VL-8B-Instruct**;默认试跑 **`/mtc/models/Qwen3-VL-8B-Instruct`**;不存在或加载失败时 **向用户询问** 本机路径。 4. **`AFS_IMAGE_EMBED_DIR`**:**`--afs_image_embed_dir`** 指向的目录须存在或可创建;默认试跑 **`/mtc/afs/vit_embed_dir`**;不可用时 **向用户询问**。 5. **显卡**:**`visual_only`** 与 **`normal`** 的 **`CUDA_VISIBLE_DEVICES`** **不得重叠**;先 **`nvidia-smi`** 再 **`export`**(**不要写死**示例卡号)。 -6. **`CONFIG_SERVER_HOST`**:各进程 **`--config_server_host`** 须能访问 **`config_server` 的 HTTP 服务**。**三进程同机**时常见为 **`0.0.0.0`**(与历史脚本一致);多机时改为 **对端可达的 IP**,并保证防火墙放行 **`config_server` 端口(8090)**、**其内部 Redis 暴露端口(6000)**、**`8091`** 等。 +6. **`CONFIG_SERVER_HOST`**:各进程 **`--config_server_host`** 须能访问 **`config_server` 的 HTTP 服务**。**三进程同机**时常见为 **`0.0.0.0`**(与历史脚本一致);`config_server` 可单独部署并通过可达 IP 提供服务,但 `visual_only` 与其 `normal` proxy 必须同机。跨主机时只开放所需的 **`config_server` 端口(8090)** 和 **Redis 暴露端口(6000)**;**8091 始终仅供本机 proxy 使用,不要对网络开放**。 7. **代理**:每条 **`python`(LightLLM)** 前 **`export http_proxy=`**、**`export https_proxy=`**(与仓库其它 acc 测试一致)。**`lmms_eval` 拉取 `lmms-lab/MMMU` 时**若需经企业代理访问 Hugging Face,应在 **`no_proxy` 已包含 `127.0.0.1`** 的前提下**恢复 `https_proxy`(或等价镜像)**;否则易出现 Hub **`ConnectionError`**。仅当本机已有完整离线缓存且确认 **`datasets` 可纯离线命中**时,才可全程无代理。 8. **`lmms-eval`**:**`python3 -m lmms_eval --help`** 可执行;否则无法完成必跑 **`mmmu_val`**。 @@ -97,7 +101,7 @@ python -m lightllm.server.api_server \ ### 2)`visual_only`(**`config_server` 已在 8090 / 6000 就绪后**) -**`--visual_rpyc_port 8091`** 为 **visual_only 模式必需**,供其它服务调用本机视觉推理接口。 +**`--visual_rpyc_port 8091`** 为 **visual_only 模式必需**,供同一主机上的 `normal` proxy 调用视觉推理接口。 ```bash CUDA_VISIBLE_DEVICES="${VISUAL_CUDA_DEVICES}" python -m lightllm.server.api_server \ @@ -114,7 +118,7 @@ CUDA_VISIBLE_DEVICES="${VISUAL_CUDA_DEVICES}" python -m lightllm.server.api_serv --visual_rpyc_port 8091 ``` -**`--host`** 为 **本进程监听地址**;与 **`--config_server_host`** 含义不同:后者为 **config_server 的可达地址**。 +**`--host`** 为 **本进程监听地址**;与 **`--config_server_host`** 含义不同:后者为 **config_server 的可达地址**。这两个参数均不改变 visual-only RPyC 的 loopback 绑定。 ### 3)`normal`(visual 就绪后) From 3fc242f58d053397b56e7b46ee6cf4d064cd8bd5 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 12:41:26 +0800 Subject: [PATCH 4/4] docs: restrict access to visual Redis port --- skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md b/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md index a4ee867fa0..b6b27572ef 100644 --- a/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md +++ b/skills/test_model/qwen3-vl-8b-vit-sep-mode/SKILL.md @@ -48,7 +48,7 @@ description: >- 3. **`MODEL_DIR`**:指向 **Qwen3-VL-8B-Instruct**;默认试跑 **`/mtc/models/Qwen3-VL-8B-Instruct`**;不存在或加载失败时 **向用户询问** 本机路径。 4. **`AFS_IMAGE_EMBED_DIR`**:**`--afs_image_embed_dir`** 指向的目录须存在或可创建;默认试跑 **`/mtc/afs/vit_embed_dir`**;不可用时 **向用户询问**。 5. **显卡**:**`visual_only`** 与 **`normal`** 的 **`CUDA_VISIBLE_DEVICES`** **不得重叠**;先 **`nvidia-smi`** 再 **`export`**(**不要写死**示例卡号)。 -6. **`CONFIG_SERVER_HOST`**:各进程 **`--config_server_host`** 须能访问 **`config_server` 的 HTTP 服务**。**三进程同机**时常见为 **`0.0.0.0`**(与历史脚本一致);`config_server` 可单独部署并通过可达 IP 提供服务,但 `visual_only` 与其 `normal` proxy 必须同机。跨主机时只开放所需的 **`config_server` 端口(8090)** 和 **Redis 暴露端口(6000)**;**8091 始终仅供本机 proxy 使用,不要对网络开放**。 +6. **`CONFIG_SERVER_HOST`**:各进程 **`--config_server_host`** 须能访问 **`config_server` 的 HTTP 服务**。**三进程同机**时常见为 **`0.0.0.0`**(与历史脚本一致);`config_server` 可单独部署并通过可达 IP 提供服务,但 `visual_only` 与其 `normal` proxy 必须同机。跨主机时只开放所需的 **`config_server` HTTP 端口(8090)**;**Redis 暴露端口(6000)只能向受控可信私有网络中的 allowlist 服务主机开放,且仅允许 `visual_only` 和 `normal` 主机访问**。当前 Redis 未配置认证或 TLS,**不得将其暴露到不受信任的网络**;**`8091` 始终仅供本机 proxy 使用,不要对网络开放**。 7. **代理**:每条 **`python`(LightLLM)** 前 **`export http_proxy=`**、**`export https_proxy=`**(与仓库其它 acc 测试一致)。**`lmms_eval` 拉取 `lmms-lab/MMMU` 时**若需经企业代理访问 Hugging Face,应在 **`no_proxy` 已包含 `127.0.0.1`** 的前提下**恢复 `https_proxy`(或等价镜像)**;否则易出现 Hub **`ConnectionError`**。仅当本机已有完整离线缓存且确认 **`datasets` 可纯离线命中**时,才可全程无代理。 8. **`lmms-eval`**:**`python3 -m lmms_eval --help`** 可执行;否则无法完成必跑 **`mmmu_val`**。