ei-pipe-sdk 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ei_infer/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """ei-infer: minimal inference SDK for algorithm models on ei-pipeline.
2
+
3
+ Public surface for an algorithm engineer:
4
+
5
+ - ``EiInfer`` base class to implement (``init`` + ``inference``)
6
+ - ``EI_REGISTER`` decorator to put a model into the in-process registry
7
+
8
+ Everything else (registry internals, task runner, worker entry) lives in
9
+ submodules and is not re-exported here on purpose.
10
+ """
11
+
12
+ from .model import EiInfer
13
+ from .registry import EI_REGISTER
14
+ from .worker import run_loop
15
+
16
+ __all__ = ["EiInfer", "EI_REGISTER", "run_loop"]
ei_infer/env.py ADDED
@@ -0,0 +1,59 @@
1
+ """SDK environment helpers: EI_INFER_* with fallback to the shared Redis vars."""
2
+
3
+ import os
4
+
5
+ DEFAULT_DATA_ROOT = "/lpai/pvc/ei-autolabel-bd-ga-infer"
6
+
7
+
8
+ def resolve_env() -> str:
9
+ """Resolve active environment, mirroring ei-pipeline's envget_core_env."""
10
+ return os.getenv("EI_ENV") or os.getenv("LI_ENV") or "local"
11
+
12
+
13
+ def infer_redis_url() -> str:
14
+ """Redis the worker consumes from.
15
+
16
+ EI_INFER_REDIS_URL overrides; otherwise fall back to EI_ARQ_REDIS_URL, then
17
+ EI_REDIS_URL, then a local dev default. Allows a zero-config local run and
18
+ reuse of the ei-pipeline cluster in production.
19
+ """
20
+ return (
21
+ os.getenv("EI_INFER_REDIS_URL")
22
+ or os.getenv("EI_ARQ_REDIS_URL")
23
+ or os.getenv("EI_REDIS_URL")
24
+ or "redis://localhost:6379/0"
25
+ )
26
+
27
+
28
+ def infer_queue_name() -> str:
29
+ """Task queue this worker consumes.
30
+
31
+ Default matches the ei-pipeline ei_infer node's build_default_queue_name()
32
+ (env segment hashed when > 32 chars), so a zero-config pipe and worker land
33
+ on the same queue.
34
+ """
35
+ return os.getenv("EI_INFER_QUEUE_NAME") or f"arq:{resolve_env()}:ei:model-infer"
36
+
37
+
38
+ def infer_max_jobs() -> int:
39
+ """Concurrent jobs per worker (arq max_jobs). Default: 4. Must be positive."""
40
+ value = int(os.getenv("EI_INFER_MAX_JOBS") or "4")
41
+ if value <= 0:
42
+ raise ValueError("EI_INFER_MAX_JOBS must be positive")
43
+ return value
44
+
45
+
46
+ def infer_max_threads() -> int:
47
+ """Thread pool size for sync inference, at least one per concurrent job."""
48
+ return max(4, infer_max_jobs())
49
+
50
+
51
+ def infer_data_root() -> str:
52
+ """PFS data_root mount point inside the pod.
53
+
54
+ The pipeline sends an input_path relative to this mount, and the worker
55
+ forwards it unchanged to ``model.inference(input_path, node_info)``; the
56
+ algorithm joins this root to read the input file. Default matches the
57
+ ei-pipeline PFS data root.
58
+ """
59
+ return os.getenv("EI_INFER_DATA_ROOT") or DEFAULT_DATA_ROOT
ei_infer/model.py ADDED
@@ -0,0 +1,42 @@
1
+ """Algorithm model abstraction: implement exactly two methods to join the pipeline."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class EiInfer(ABC):
7
+ """What an algorithm engineer implements to plug a model into ei-pipeline.
8
+
9
+ Convention
10
+ ----------
11
+ - ``init`` runs once per worker process (model warm-up). Fail fast here:
12
+ a model that cannot initialize must crash the worker on boot, not the
13
+ first job.
14
+ - ``inference`` receives the absolute input path, an optional absolute
15
+ output directory, and the caller's ``node_info``; it **returns a dict**.
16
+ The worker returns that dict to the pipeline and does NOT persist it -
17
+ the model decides whether to write result files into ``output_path``.
18
+ """
19
+
20
+ @abstractmethod
21
+ def init(self, model_cfg: dict | None = None) -> None:
22
+ """Load model artifacts (weights, tokenizer, device).
23
+
24
+ Called once per worker process before the first inference. ``model_cfg``
25
+ is a free-form dict the worker passes at startup (e.g. parsed from a
26
+ local yaml). Raise to abort worker startup.
27
+ """
28
+ raise NotImplementedError
29
+
30
+ @abstractmethod
31
+ def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
32
+ """Run one inference and return the result dict.
33
+
34
+ ``input_path`` is the absolute path of the input the pipeline staged
35
+ (a file or a directory of material). ``output_path`` is the absolute
36
+ directory (may be empty) where the model may persist result files when
37
+ it chooses to; whether to write is the model's decision. ``node_info``
38
+ carries caller context (pipeline_id, node_name, params, ...), may be an
39
+ empty dict but is always provided. The returned dict must be
40
+ JSON-serializable; it becomes the pipeline node's result.
41
+ """
42
+ raise NotImplementedError
ei_infer/registry.py ADDED
@@ -0,0 +1,74 @@
1
+ """Process-local, thread-safe model registry: name -> EiInfer instance."""
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from typing import TypeVar
6
+
7
+ from .model import EiInfer
8
+
9
+ T = TypeVar("T", bound=EiInfer)
10
+
11
+ _lock = threading.RLock()
12
+ _registry: dict[str, EiInfer] = {}
13
+
14
+
15
+ def _register_instance(name: str, model: EiInfer) -> None:
16
+ """Register ``model`` under ``name`` (internal, used by the decorator).
17
+
18
+ Re-registering the *same* instance under the same name is idempotent;
19
+ registering a *different* instance under an existing name raises, so a
20
+ running worker can never silently swap a model mid-flight.
21
+ """
22
+ if not isinstance(name, str) or not name.strip():
23
+ raise ValueError("model name must be a non-empty string")
24
+ if not isinstance(model, EiInfer):
25
+ raise TypeError("model must be an EiInfer instance")
26
+ with _lock:
27
+ existing = _registry.get(name)
28
+ if existing is not None and existing is not model:
29
+ raise ValueError(f"model '{name}' already registered")
30
+ _registry[name] = model
31
+
32
+
33
+ def EI_REGISTER(model_name: str) -> Callable[[type[T]], type[T]]:
34
+ """Register an EiInfer subclass as a decorator.
35
+
36
+ Usage::
37
+
38
+ @EI_REGISTER("example-model")
39
+ class ExampleModel(EiInfer):
40
+ ...
41
+
42
+ The model is instantiated with no constructor arguments and put into the
43
+ registry at class-definition time; the worker pre-loads it via ``init()``
44
+ on startup, so load model artifacts in ``init()`` rather than
45
+ ``__init__``. Registering a different class under an existing name raises.
46
+ """
47
+ if not isinstance(model_name, str) or not model_name.strip():
48
+ raise ValueError("model name must be a non-empty string")
49
+
50
+ def decorator(model_cls: type[T]) -> type[T]:
51
+ _register_instance(model_name, model_cls(model_name)) # type: ignore
52
+ return model_cls
53
+
54
+ return decorator
55
+
56
+
57
+ def get_model(name: str) -> EiInfer:
58
+ """Return the registered model, raising KeyError when unknown."""
59
+ with _lock:
60
+ if name not in _registry:
61
+ raise KeyError(f"model '{name}' is not registered")
62
+ return _registry[name]
63
+
64
+
65
+ def get_registered_names() -> list[str]:
66
+ """Return sorted registered model names (for startup pre-loading)."""
67
+ with _lock:
68
+ return sorted(_registry)
69
+
70
+
71
+ def clear_registry() -> None:
72
+ """Drop all registrations. Test / restart hook only."""
73
+ with _lock:
74
+ _registry.clear()
ei_infer/task.py ADDED
@@ -0,0 +1,113 @@
1
+ """arq task that turns a pipeline job into a call into the model registry."""
2
+
3
+ import asyncio
4
+ from concurrent.futures.thread import ThreadPoolExecutor
5
+
6
+ from .env import infer_data_root
7
+ from ei_path.paths import get_shared_dirpath
8
+ from .registry import get_model
9
+
10
+
11
+ class ModelTaskError(Exception):
12
+ """A deterministic job failure (bad payload / unregistered model / bad result)."""
13
+
14
+
15
+ def resolve_absolute_paths(
16
+ data: dict,
17
+ node_info: dict | None,
18
+ data_root: str,
19
+ ) -> tuple[str, str]:
20
+ """Resolve input/output to absolute paths on the shared PFS root.
21
+
22
+ The pipeline sends paths relative to the pipe shared dir; this joins them
23
+ onto {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id} with traversal
24
+ checks (mirrors the pipeline's pfs_storage.paths helpers).
25
+ Returns ``(absolute_input, absolute_output)``; output is "" when data has
26
+ no output_path.
27
+ """
28
+ input_path = str(data.get("input_path") or "").strip()
29
+ if not input_path:
30
+ raise ModelTaskError("job data requires 'input_path'")
31
+ base = get_shared_dirpath(data_root, node_info) # {root}/{time_bucket}/{pipe_id}
32
+ absolute_input = str(base / input_path)
33
+ output_path = str(data.get("output_path") or "").strip()
34
+ absolute_output = str(base / output_path).rstrip("/") if output_path else ""
35
+ return absolute_input, absolute_output
36
+
37
+
38
+ class _WorkerExecutor:
39
+ """Process-wide, lazily created thread pool for running sync inference.
40
+
41
+ arq runs each job as an async task; model.inference() is synchronous and
42
+ must not block the event loop, so it is executed in this pool. Created once
43
+ and shut down at the very end of the process.
44
+ """
45
+
46
+ _pool: ThreadPoolExecutor | None = None
47
+
48
+ @classmethod
49
+ def get(cls) -> ThreadPoolExecutor:
50
+ if cls._pool is None:
51
+ from .env import infer_max_threads
52
+
53
+ cls._pool = ThreadPoolExecutor(
54
+ max_workers=infer_max_threads(), thread_name_prefix="model-infer"
55
+ )
56
+ return cls._pool
57
+
58
+ @classmethod
59
+ def shutdown(cls) -> None:
60
+ if cls._pool is not None:
61
+ cls._pool.shutdown(wait=True)
62
+ cls._pool = None
63
+
64
+
65
+ async def run_inference(ctx: dict, data: dict, node_info: dict | None = None) -> dict:
66
+ """arq task served by ``ei_infer.worker``.
67
+
68
+ Protocol (shared with the ei-pipeline ``ei_infer`` node):
69
+ data = {"name": str, "input_path": str, "output_path": str, ...}
70
+ - ``name`` model registered in the in-process registry
71
+ - ``input_path`` PFS path relative to the pipe shared dir
72
+ {time_bucket}/{pipe_id}/... (may be a file or a dir)
73
+ - ``output_path``(optional) PFS dir the model may write result files to
74
+ - ``version`` optional, passed through untouched (reserved)
75
+
76
+ The worker resolves absolute paths from node_info + EI_INFER_DATA_ROOT:
77
+ absolute = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<path>
78
+ then calls ``model.inference(absolute_input, absolute_output, node_info)``.
79
+ The result dict is returned to the pipeline; the worker does NOT persist it
80
+ - writing result files is up to the model (into output_path if given).
81
+
82
+ Returns ``{"status": "success", "result": <inference dict>, "name": ...}``.
83
+ """
84
+ name = data.get("name") or ""
85
+ input_path = data.get("input_path") or ""
86
+ if not name:
87
+ raise ModelTaskError("job data requires 'name'")
88
+ if not input_path:
89
+ raise ModelTaskError("job data requires 'input_path'")
90
+
91
+ model = get_model(name) # KeyError when unknown -> job fails cleanly
92
+
93
+ try:
94
+ absolute_input, absolute_output = resolve_absolute_paths(
95
+ data, node_info, infer_data_root()
96
+ )
97
+ except ValueError as error:
98
+ raise ModelTaskError(f"invalid data path (name={name}): {error}") from None
99
+
100
+ loop = asyncio.get_event_loop()
101
+ result = await loop.run_in_executor(
102
+ _WorkerExecutor.get(),
103
+ model.inference,
104
+ absolute_input,
105
+ absolute_output,
106
+ node_info or {},
107
+ )
108
+ if not isinstance(result, dict):
109
+ raise ModelTaskError(
110
+ f"model '{name}' inference returned {type(result).__name__}, expected dict"
111
+ )
112
+
113
+ return {"status": "success", "result": result, "name": name}
ei_infer/worker.py ADDED
@@ -0,0 +1,88 @@
1
+ """The ei-infer arq worker entry: ``python -m ei_infer.worker``.
2
+
3
+ The worker consumes the ``arq:{env}:ei:model-infer`` queue (override with
4
+ EI_INFER_QUEUE_NAME), pre-loads every model registered in the process on
5
+ startup, and dispatches each job to the matching model via a thread pool.
6
+
7
+ All tuning knobs come from the environment (EI_INFER_*); there are no CLI
8
+ arguments on purpose so containers only need env vars.
9
+ """
10
+
11
+ from arq import cron # noqa: F401 (documentation convenience)
12
+ from arq.connections import RedisSettings
13
+
14
+ from .env import infer_max_jobs, infer_queue_name, infer_redis_url
15
+ from .registry import get_registered_names
16
+ from .task import ModelTaskError, _WorkerExecutor, run_inference # noqa: F401
17
+
18
+ # Re-export so a project entry script can do `from ei_infer import worker`.
19
+ __all__ = ["WorkerSettings", "build_worker_settings", "run_loop"]
20
+
21
+
22
+ async def startup(ctx: dict) -> None:
23
+ """Pre-load every registered model. Any init() failure crashes the worker
24
+ on boot instead of surfacing on the first job."""
25
+ from .registry import get_model
26
+
27
+ for name in get_registered_names():
28
+ model = get_model(name)
29
+ model.init()
30
+
31
+
32
+ async def shutdown(ctx: dict) -> None:
33
+ _WorkerExecutor.shutdown()
34
+
35
+
36
+ def build_worker_settings() -> type:
37
+ """Build WorkerSettings at call time so env (EI_INFER_*) is read fresh.
38
+
39
+ ``WorkerSettings`` is a snapshot at import time; building lazily keeps the
40
+ worker config aligned with the env set at boot.
41
+ """
42
+
43
+ class _WorkerSettings:
44
+ functions = [run_inference]
45
+ queue_name = infer_queue_name()
46
+ redis_settings = RedisSettings.from_dsn(infer_redis_url())
47
+ on_startup = startup
48
+ on_shutdown = shutdown
49
+ max_jobs = infer_max_jobs()
50
+
51
+ return _WorkerSettings
52
+
53
+
54
+ # Default settings snapshot: reads env at import. Prefer ``run()`` so env is
55
+ # evaluated at boot (see module docstring).
56
+ class WorkerSettings:
57
+ """arq worker configuration consumed by `arq` (and the example app)."""
58
+
59
+ functions = [run_inference]
60
+ queue_name = infer_queue_name()
61
+ redis_settings = RedisSettings.from_dsn(infer_redis_url())
62
+ on_startup = startup
63
+ on_shutdown = shutdown
64
+ max_jobs = infer_max_jobs()
65
+
66
+
67
+ def run_loop() -> None:
68
+ """Run the worker with config read from EI_INFER_* env vars.
69
+
70
+ Entry for ``python -m ei_infer.worker``; blocks until interrupted.
71
+ """
72
+ settings = build_worker_settings()
73
+
74
+ from arq.worker import Worker
75
+
76
+ worker = Worker(
77
+ functions=settings.functions,
78
+ queue_name=settings.queue_name,
79
+ redis_settings=settings.redis_settings,
80
+ on_startup=settings.on_startup,
81
+ on_shutdown=settings.on_shutdown,
82
+ max_jobs=settings.max_jobs,
83
+ handle_signals=False, # let the container runtime own signal handling
84
+ )
85
+ try:
86
+ worker.run()
87
+ finally:
88
+ _WorkerExecutor.shutdown()
ei_path/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared-dir path helpers for the algorithm worker (available as ``ei_path``)."""
ei_path/paths.py ADDED
@@ -0,0 +1,93 @@
1
+ """Shared-dir path helpers for the algorithm worker.
2
+
3
+ Port of the pipeline's pfs_storage.paths core (time bucket + pipe shared dir
4
+ resolution) that the ei_infer node protocol relies on. The worker receives
5
+ ``node_info`` (dict) with ``time_bucket`` and ``pipeline_id`` plus relative
6
+ ``input_path`` / ``output_path``; these helpers join them onto the mounted PFS
7
+ data root (EI_INFER_DATA_ROOT) and defend against traversal.
8
+
9
+ Kept dependency-free on purpose: the SDK runs in the algo pod without the
10
+ ei-pipeline util package.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import posixpath
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ PIPE_RESERVED_DIR_NAME = "__pipe__"
22
+
23
+ _TIME_BUCKET_PATTERN = re.compile(r"^\d{12}$")
24
+ _WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:")
25
+
26
+
27
+ def get_time_bucket(node_info: dict[str, Any] | None = None) -> str:
28
+ """Return the validated time bucket from node_info (YYYYMMDDHHMM)."""
29
+ bucket = str((node_info or {}).get("time_bucket") or "").strip()
30
+ if not _TIME_BUCKET_PATTERN.fullmatch(bucket):
31
+ raise ValueError(
32
+ f"node_info.time_bucket must match YYYYMMDDHHMM, got: {bucket!r}"
33
+ )
34
+ return bucket
35
+
36
+
37
+ def get_pipeline_id(node_info: dict[str, Any] | None = None) -> int:
38
+ """Return the validated pipeline id from node_info."""
39
+ pipe_id = (node_info or {}).get("pipeline_id")
40
+ if pipe_id is None:
41
+ raise ValueError("node_info.pipeline_id is required for shared directory paths")
42
+ try:
43
+ return int(pipe_id)
44
+ except (TypeError, ValueError):
45
+ raise ValueError(f"node_info.pipeline_id must be an integer, got: {pipe_id!r}")
46
+
47
+
48
+ def normalize_relative_path(path: str | os.PathLike[str]) -> str:
49
+ """Validate a PFS-relative path and normalize it (no abs / '..' / NUL)."""
50
+ raw = str(path).strip()
51
+ if not raw:
52
+ raise ValueError("PFS relative path must not be empty")
53
+ if "\x00" in raw:
54
+ raise ValueError("PFS relative path must not contain NUL bytes")
55
+ if raw.startswith("/") or raw.startswith("//") or _WINDOWS_DRIVE_PATTERN.match(raw):
56
+ raise ValueError(f"PFS path must be relative: {raw}")
57
+
58
+ normalized = posixpath.normpath(raw.replace("\\", "/"))
59
+ if normalized in {"", "."}:
60
+ raise ValueError("PFS relative path must not point to current directory")
61
+ if normalized == ".." or normalized.startswith("../"):
62
+ raise ValueError(f"PFS path must not escape the time bucket: {raw}")
63
+ return normalized
64
+
65
+
66
+ def get_shared_dirpath(
67
+ data_root: str,
68
+ node_info: dict[str, Any] | None = None,
69
+ ) -> Path:
70
+ """Return {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}."""
71
+ bucket = get_time_bucket(node_info)
72
+ pipe_id = get_pipeline_id(node_info)
73
+ return Path(data_root) / bucket / str(pipe_id)
74
+
75
+
76
+ def get_shared_child_path(
77
+ data_root: str,
78
+ node_info: dict[str, Any] | None = None,
79
+ relative_path: str | os.PathLike[str] = "",
80
+ ) -> Path:
81
+ """Return a validated child path within a pipe's shared directory.
82
+
83
+ The first path segment must not be the reserved ``__pipe__`` directory,
84
+ which is owned by the pipeline itself.
85
+ """
86
+ normalized = normalize_relative_path(relative_path)
87
+ if normalized.split("/", 1)[0] == PIPE_RESERVED_DIR_NAME:
88
+ raise ValueError(
89
+ f"PFS path must not use reserved dir '{PIPE_RESERVED_DIR_NAME}': "
90
+ f"{relative_path}"
91
+ )
92
+ base = get_shared_dirpath(data_root, node_info)
93
+ return base if not normalized else base / normalized
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: ei-pipe-sdk
3
+ Version: 0.1.4
4
+ Summary: Minimal inference SDK for algorithm engineers on the ei-pipeline ei_infer node
5
+ Requires-Python: <3.15,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: arq>=0.26
8
+ Requires-Dist: redis>=8.0.1
9
+
10
+ # ei-pipe-sdk
11
+
12
+ 面向算法同学的极简模型接入 SDK。把你的模型写成一个 `EiInfer` 的两个方法,ei-pipeline
13
+ 就能调度它推理:**你不用关心数据怎么进来、结果怎么提交,也不用关心部署**。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pip install ei-pipe-sdk
19
+ # 或先在仓库 ei_pipe_sdk/ 下本地构建
20
+ uv build && pip install dist/ei_pipe_sdk-*.whl
21
+ ```
22
+
23
+ ## 三步接入
24
+
25
+ ```python
26
+ # my_model.py
27
+ from ei_infer import EiInfer, register
28
+
29
+
30
+ class MyModel(EiInfer):
31
+ def init(self, model_cfg: dict | None = None) -> None:
32
+ # 进程启动时调用一次:加载权重、tokenizer、选设备。失败会让 worker 启动即崩溃。
33
+ self.model = load_weights(...)
34
+
35
+ def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
36
+ # input_path / output_path: worker 已恢复好的绝对路径
37
+ # = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<相对路径>
38
+ # input_path 是输入(文件或目录);output_path 是允许写结果文件的目录(可能为空)。
39
+ # 是否落盘由模型自己决定;返回的 dict 会被 worker 回传给 pipeline。
40
+ return {"detections": self.model(open(input_path))}
41
+
42
+
43
+ register("my-model", MyModel()) # 注册名 = 管道节点 params.name 的值
44
+ ```
45
+
46
+ ```bash
47
+ pip install ei-pipe-sdk
48
+ # 在算法部署里跑(pod 内需挂载 PFS data_root 到 EI_INFER_DATA_ROOT)
49
+ EI_INFER_DATA_ROOT=/lpai/pvc/ei-autolabel-bd-ga-infer python -m ei_infer.worker
50
+ ```
51
+
52
+ ## 协议
53
+
54
+ 调度端(ei-pipeline 的 `ei_infer` 节点)会按统一协议给你发任务:
55
+
56
+ | 字段 | 类型 | 说明 |
57
+ |---|---|---|
58
+ | `name` | str | 模型注册名,由你 `register("name", ...)` 时命名 |
59
+ | `input_path` | str | 输入路径(相对 pipe 共享目录,如 `data/`),worker 恢复为绝对路径 |
60
+ | `output_path` | str? | 允许写结果文件的目录(相对 pipe 共享目录,可选) |
61
+ | `version` | str? | 版本提示,透传保留 |
62
+
63
+ worker 用 `node_info.time_bucket + pipeline_id + EI_INFER_DATA_ROOT` 把 `input_path` /
64
+ `output_path` 恢复成绝对路径,调用 `inference(input_path, output_path, node_info)`。
65
+ **是否把结果落盘由模型自己决定**,worker 不替模型落盘,只把模型返回的 dict 原样回传。
66
+ 整个链路里**你的函数只看绝对路径 input_path / output_path 和 node_info**,其余(数据就位、
67
+ 并发、轮询)全部由 SDK 兜底。
68
+
69
+ ## 配置(环境变量)
70
+
71
+ | 变量 | 默认 | 说明 |
72
+ |---|---|---|
73
+ | `EI_INFER_REDIS_URL` | 回退 `EI_ARQ_REDIS_URL` → `EI_REDIS_URL` → `redis://localhost:6379/0` | 消费队列的 Redis |
74
+ | `EI_INFER_QUEUE_NAME` | `arq:{env}:ei:model-infer` | 消费的任务队列,默认与 ei-pipeline `ei_infer` 节点指向一致 |
75
+ | `EI_INFER_MAX_JOBS` | `4` | 每 worker 并发任务数(arq `max_jobs`) |
76
+ | `EI_INFER_MAX_THREADS` | `max(4, max_jobs)` | 跑同步推理的线程池大小 |
77
+ | `EI_INFER_DATA_ROOT` | `/lpai/pvc/ei-autolabel-bd-ga-infer` | PFS data_root 在 pod 内的挂载根 |
78
+
79
+ ## 多模型
80
+
81
+ 多个模型可在同一个 worker 进程里 `register` 多个名字;每个模型各自实现自己的一套动作。
82
+ 一个队列可由多个模型 worker 消费,任务按 `name` 路由到注册了同名模型的进程。
83
+
84
+ ## 失败模式
85
+
86
+ - `inference` 返回非 dict → 任务失败(错误信息含模型名)。
87
+ - `name` 未注册 → 任务失败,节点错误信息会指出未知模型名。
88
+ - `init` 抛异常 → worker 启动即退出,方便在你自己的日志里早发现。
89
+
90
+ ## 本地联调
91
+
92
+ ```bash
93
+ # 起一个本地 redis 后(redis-server)
94
+ python -m ei_infer.worker # 默认队列 arq:local:ei:model-infer
95
+ ```
96
+
97
+ 然后在 ei-pipeline 用 `ei_infer` 节点提交一条任务即可走通。
@@ -0,0 +1,12 @@
1
+ ei_infer/__init__.py,sha256=zfgvtFcwjzXAXfl4OqzsxrSpcoNzDWb99-q73JeZdm8,529
2
+ ei_infer/env.py,sha256=gIY73kJ3Z1-aYArN7N8l639RSX-ORJjDyV_goQx4hyA,1968
3
+ ei_infer/model.py,sha256=0cssj2gCxlkWGgpMTWZXU4XsZ47_dBph361GwYNF1Uw,1877
4
+ ei_infer/registry.py,sha256=jHm6ERdDOoi2J2b3r4Hem30y56T1W_sRhw2YKvWkP5g,2467
5
+ ei_infer/task.py,sha256=Kfmuyr0TETJLsYz9ysr1gAprf_-Ub0PxokipCPSQsrE,4275
6
+ ei_infer/worker.py,sha256=wcXxxTQUbBjHA8y1R4DzyRPVtU4edKHXviLz3M0J5Qw,2896
7
+ ei_path/__init__.py,sha256=-R45xZlqESmeFXLTpZGfZKPs-VRxT_cj3qt4kOLjXMk,83
8
+ ei_path/paths.py,sha256=AJIZ4cGCR78A7kbJSSTkaBpA2HQIcgRgJACN-qfMAPQ,3497
9
+ ei_pipe_sdk-0.1.4.dist-info/METADATA,sha256=BZndB8_PCk8svW9PrVOtZ4uUYVlkR0q7UFyKNpuFB9o,4058
10
+ ei_pipe_sdk-0.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ ei_pipe_sdk-0.1.4.dist-info/top_level.txt,sha256=5pUVUkkJ6Ms-rnXDaVnK7m98M6rfSN-S90JT43COG50,17
12
+ ei_pipe_sdk-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ ei_infer
2
+ ei_path