menu

推理中台

  • date_range 20/01/2023 00:48
    点击量:
    info
    sort
    推理中台
    label
    技术中台
    数据中台
    业务中台
    智算中台

Ollama + Triton 双引擎本地推理平台 · 建设全记录

在 Intel Mac(MacBook Pro 16” 2019 · i9-9980HK · macOS · Docker Desktop CPU-only)上, 用 Dynamo-Triton 补齐 Ollama 的模型盲区,与既有 LiteLLM + Langfuse + Milvus + Dify 栈打通。

最终成果:4 类 Ollama 完全无法承载的非 GGUF 模型,全部在 Triton 上 CPU 推理并对外服务, 经 Dify 工作流成功调用,全链路闭环。


0. 一句话架构

                          Dify(编排层)
              ┌──────────────┴───────────────┐
          对话 / RAG                      专业推理
              │                               │
      LiteLLM → Ollama              HTTP 节点 → Triton
      (LLM / embed / VLM)           (4 类非 GGUF 模型)
        [GGUF / transformer]              │
                                  fraud_xgb  iris_sklearn
                                  resnet18   yolov8n
                                          │ 指标
                                   Prometheus (:8002)

核心判断(P8 级架构取舍)

  • 模型能否转成 GGUF 的 transformer?是 → Ollama;否 → Triton。两者零重叠、互补。
  • 文本模型(进文本出文本)走 OpenAI 网关(LiteLLM);张量模型(进张量出张量)走 KServe v2 协议(直连 Triton)。 协议语义不同,不该硬凑。

1. 职责边界:Ollama vs Triton

模型类型 Ollama Triton 落地模型 Triton 后端
LLM(Llama/Qwen/DeepSeek)
Embedding(nomic-embed…)
多模态 VLM(llava/vision)
传统 ML(XGBoost/LightGBM) fraud_xgb FIL
传统 ML(sklearn/RandomForest) iris_sklearn ONNX Runtime
CNN / 图像分类(ResNet) resnet18 ONNX Runtime
目标检测(YOLO) yolov8n ONNX Runtime
任意 ONNX 模型 ONNX Runtime

Ollama 的能力边界 = GGUF/transformer;凡是非 GGUF、非 transformer 的(树模型、CNN、检测、自训练网络), 底层 llama.cpp 都碰不了,正是 Triton 的主场。


2. 环境与硬约束(Intel Mac)

约束 事实 影响
Triton 无 macOS 原生版 只发 Linux 容器(NGC),Mac 上跑在 Docker Desktop 的 Linux 虚机 多一层虚拟化
Docker Desktop for Mac 无 GPU 直通 --gpus 不存在 Triton 只能 CPU
Mac 无 N 卡 Intel 核显/AMD CUDA/TensorRT 后端不可用
Intel Mac 跑 x86 NGC 镜像 原生执行(M 系列要过 QEMU 模拟,极慢) 本场景占优

关键契合:本方案要跑的 4 类模型(树模型/CNN/检测/ONNX)恰好都是 CPU 可接受的, 不像 LLM 那样非 GPU 不可——所以 CPU-only 约束在这里不构成瓶颈。

已核实的事实

  • FIL 后端支持 CPU(官方称 CPU 优化模式比原生 XGBoost 还快);
  • NGC 镜像 nvcr.io/nvidia/tritonserver:25.05-py3 开箱内置 ONNX / TensorFlow / FIL / OpenVINO / Python 等后端;
  • instance_groupKIND_AUTO 时,无 GPU 自动落 CPU。

3. 目录结构

triton-gap/
├── docker-compose.yml
├── prepare_models.py                # 生成 fraud_xgb / iris_sklearn(本机 venv 跑)
├── requirements-prep.txt
└── model_repository/
    ├── fraud_xgb/                   # 树模型 (FIL)
    │   ├── config.pbtxt
    │   └── 1/xgboost.json           # xgboost 1.7.6 导出
    ├── iris_sklearn/                # sklearn 随机森林 (ONNX)
    │   ├── config.pbtxt
    │   └── 1/model.onnx             # skl2onnx,关 zipmap
    ├── resnet18/                    # CNN 图像分类 (ONNX)
    │   ├── config.pbtxt
    │   └── 1/model.onnx             # ONNX Model Zoo 现成模型
    └── yolov8n/                     # 目标检测 (ONNX)
        ├── config.pbtxt
        └── 1/model.onnx             # Docker 内 ultralytics 导出

4. 最终可复现配置

4.1 Python 依赖(本机 venv,仅用于生成前两个模型)

requirements-prep.txtnumpy 与 xgboost 必须钉版本,见踩坑 §6.2/§6.3):

numpy==1.26.4
xgboost==1.7.6
scikit-learn
skl2onnx
onnx
onnxruntime
opencv-python<4.10
torch
torchvision
ultralytics

系统级前置(venv 管不到,需 brew 装):

brew install libomp        # XGBoost 依赖的 OpenMP 运行时

安装(先钉 numpy,最后再强制压回,防止被 opencv/ultralytics 顶到 2.x):

python -m venv .venv && source .venv/bin/activate
pip install -r requirements-prep.txt
pip install --force-reinstall "numpy<2"     # 一锤定音
python -c "import numpy; print(numpy.__version__)"   # 必须 1.26.x

4.2 生成 fraud_xgb(XGBoost 1.7.6,避开 Treelite 代沟)

import xgboost as xgb
from sklearn.datasets import make_classification
X, y = make_classification(n_samples=2000, n_features=32, random_state=0)
clf = xgb.XGBClassifier(n_estimators=100, max_depth=6, tree_method='hist')
clf.fit(X, y)
clf.get_booster().save_model('model_repository/fraud_xgb/1/xgboost.json')

4.3 生成 iris_sklearn(关键:关掉 ZipMap)

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
from skl2onnx import to_onnx
X, y = load_iris(return_X_y=True)
clf = RandomForestClassifier(n_estimators=50, random_state=0).fit(X, y)
onx = to_onnx(clf, X[:1].astype(np.float32), target_opset=17,
              options={id(clf): {'zipmap': False}})   # ★ 否则输出是 Triton 不认的 SEQUENCE
open('model_repository/iris_sklearn/1/model.onnx','wb').write(onx.SerializeToString())

4.4 resnet18 / yolov8n(绕开本机 torch 环境)

# ResNet18:直接下 ONNX Model Zoo 现成模型(不碰 torch)
curl -L -o model_repository/resnet18/1/model.onnx \
  https://github.com/onnx/models/raw/main/validated/vision/classification/resnet/model/resnet18-v1-7.onnx

# YOLOv8n:在 Docker 容器里导出(彻底隔离本机环境)
docker run --rm -v $(pwd)/model_repository/yolov8n/1:/out \
  ultralytics/ultralytics:latest \
  bash -c "yolo export model=yolov8n.pt format=onnx opset=17 dynamic=True && cp yolov8n.onnx /out/model.onnx"

4.5 四份 config.pbtxt(张量名均已按实际模型核对)

fraud_xgb(FIL,分类器必须给 output_class + threshold):

backend: "fil"
max_batch_size: 8192
input  [ { name: "input__0", data_type: TYPE_FP32, dims: [ 32 ] } ]
output [ { name: "output__0", data_type: TYPE_FP32, dims: [ 1 ] } ]
instance_group [ { count: 1, kind: KIND_AUTO } ]
parameters [
  { key: "model_type",   value: { string_value: "xgboost_json" } },
  { key: "output_class", value: { string_value: "true" } },
  { key: "threshold",    value: { string_value: "0.5" } }
]
dynamic_batching {}

iris_sklearn(ONNX;label 是标量,用 reshape 补维):

backend: "onnxruntime"
max_batch_size: 128
input  [ { name: "X", data_type: TYPE_FP32, dims: [ 4 ] } ]
output [
  { name: "label",         data_type: TYPE_INT64, dims: [ 1 ], reshape: { shape: [ ] } },
  { name: "probabilities", data_type: TYPE_FP32,  dims: [ 3 ] }
]
instance_group [ { count: 1, kind: KIND_CPU } ]
dynamic_batching { max_queue_delay_microseconds: 2000 }

resnet18(ONNX Model Zoo 版:输入 data、输出 resnetv15_dense0_fwd):

backend: "onnxruntime"
max_batch_size: 16
input  [ { name: "data", data_type: TYPE_FP32, dims: [ 3, 224, 224 ] } ]
output [ { name: "resnetv15_dense0_fwd", data_type: TYPE_FP32, dims: [ 1000 ] } ]
instance_group [ { count: 1, kind: KIND_CPU } ]
dynamic_batching { preferred_batch_size: [ 4, 8 ], max_queue_delay_microseconds: 3000 }

yolov8n(ONNX;输出 anchors 维动态用 -1):

backend: "onnxruntime"
max_batch_size: 8
input  [ { name: "images",  data_type: TYPE_FP32, dims: [ 3, 640, 640 ] } ]
output [ { name: "output0", data_type: TYPE_FP32, dims: [ 84, -1 ] } ]
instance_group [ { count: 1, kind: KIND_CPU } ]
dynamic_batching { max_queue_delay_microseconds: 5000 }

4.6 docker-compose.yml(含三网络接入)

name: triton-gap

services:
  triton:
    image: nvcr.io/nvidia/tritonserver:25.05-py3
    command: >
      tritonserver
      --model-repository=/models
      --model-control-mode=explicit
      --load-model=fraud_xgb
      --load-model=iris_sklearn
      --load-model=resnet18
      --load-model=yolov8n
      --allow-metrics=true
    volumes:
      - ./model_repository:/models
    ports:
      - "8000:8000"   # HTTP (KServe v2)
      - "8001:8001"   # gRPC
      - "8002:8002"   # Prometheus 指标
    shm_size: "2gb"
    deploy:
      resources:
        limits:
          memory: 10g
    restart: unless-stopped
    networks:
      - litellm_net    # LiteLLM 用
      - dify_net       # Dify 用(解决网络隔离)

networks:
  litellm_net:
    external: true
    name: litellm_default
  dify_net:
    external: true
    name: docker_default        # Dify 主网络(docker inspect docker-api-1 确认)

4.7 Dify 侧放行 Triton(SSRF 白名单)

在 Dify 的 docker/.env 追加(这是让 Dify 能调通 Triton 的关键):

SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=triton-gap-triton-1
SSRF_PROXY_ALLOW_PRIVATE_IPS=172.16.0.0/12

重建 ssrf_proxy 生效:

docker compose up -d --force-recreate ssrf_proxy

注意:SANDBOX_HTTP_PROXY 那种改法无效——${VAR:-default}:- 语法会把空值 fallback 成默认代理。 正解是上面这两个官方白名单变量(entrypoint 会据此生成 dify_allow_private.conf, 该文件被 include 在 deny to_private_networks 之前,先命中放行)。


5. 启动与验证

cd triton-gap
docker compose up -d && sleep 15
docker compose logs triton 2>&1 | grep -E "successfully loaded|failed"
# 期望四个 successfully loaded:fraud_xgb / iris_sklearn / yolov8n / resnet18

curl -s localhost:8000/v2/health/ready -o /dev/null -w "health: %{http_code}\n"   # 200

# fraud_xgb(32 维)
curl -s -X POST localhost:8000/v2/models/fraud_xgb/infer -H 'Content-Type: application/json' \
  -d '{"inputs":[{"name":"input__0","shape":[1,32],"datatype":"FP32","data":['"$(python3 -c 'print(",".join(["0"]*32))')"']}]}'
# → outputs[0].data = [1.0]

# iris_sklearn(setosa 特征)
curl -s -X POST localhost:8000/v2/models/iris_sklearn/infer -H 'Content-Type: application/json' \
  -d '{"inputs":[{"name":"X","shape":[1,4],"datatype":"FP32","data":[5.1,3.5,1.4,0.2]}]}'
# → label [0],probabilities [0.9999, 0, 0](setosa,置信 99.99%)

# resnet18(全 0 张量,验证推理链路)
python -c "
import requests
r=requests.post('http://localhost:8000/v2/models/resnet18/infer',
 json={'inputs':[{'name':'data','shape':[1,3,224,224],'datatype':'FP32','data':[0.0]*(3*224*224)}]})
o=r.json()['outputs'][0]['data']; print('class:', o.index(max(o)))"
# → class: 623(全 0 输入的 argmax,说明推理链路通)

指标(接入 Prometheus/Thanos 的入口):

curl -s localhost:8002/metrics | grep nv_inference        # 各模型请求/耗时分段
curl -s localhost:8000/v2/models/fraud_xgb/stats | python3 -m json.tool

inference_stats 分段(queue / compute_input / compute_infer / compute_output)是调 max_queue_delay、 定位”批没喂饱 vs 算力不足”的直接依据。


6. 踩坑全记录(现象 → 根因 → 解法)

6.1 XGBoost 加载即崩:libomp.dylib could not be loaded

  • 根因:XGBoost 靠 OpenMP 做多核并行,macOS 默认不含 libomp
  • 解法brew install libomp。注意这是系统级 C 库,venv 管不到,即使用 venv 也要单独 brew 装。

6.2 torch 报错 / ONNX 导出卡死:NumPy 1.x cannot be run in NumPy 2.2.6

  • 根因:torch 按 NumPy 1.x 编译,环境里却是 2.x,ABI 不兼容,torch 状态错乱。
  • 解法pip install --force-reinstall "numpy<2"
  • 反复反弹pip install -r 时 opencv 5.0/ultralytics 声明要 numpy>=2,会把 numpy 顶回 2.x。 对策:requirements 里 numpy==1.26.4 + opencv-python<4.10 钉死,或安装最后一步强制压回。

6.3 fraud_xgb 加载失败:Provided JSON could not be parsed as XGBoost model(cats 字段)

  • 根因:XGBoost 3.2.0 太新,导出的模型带 cats 等新字段,Triton 25.05 内置的 Treelite 解析不了。 换 UBJSON 也没用——只是换外壳,schema 未变。
  • 解法pip install "xgboost==1.7.6" 降级重导(1.7.6 的 JSON 无 cats 字段)。

6.4 iris_sklearn 加载失败(两连击)

  • Unsupported ONNX Type 'ONNX_TYPE_SEQUENCE' for 'output_probability'
    • 根因:skl2onnx 默认把概率输出导成 ZipMap(字典序列),Triton 只吃张量。
    • 解法:to_onnx(..., options={id(clf): {'zipmap': False}})
  • tensor 'label' expects 1 dims [-1] but config specifies 2 dims [-1,1]
    • 根因:模型 label 是标量 [N],config 写成 [1] 多了一维。
    • 解法:output 加 reshape: { shape: [ ] },声明模型侧标量、对外补 [1]

6.5 fraud_xgb 缺参数(两连击)

  • Required parameter output_class not found → 加 output_class: "true"
  • Required parameter threshold not found → 加 threshold: "0.5"。 FIL 分类器要求这两个参数显式声明,不像其他后端能自动推断。

6.6 config.pbtxt 通用坑

  • 张量名必须与模型完全一致。现成 ONNX 的名字常和预期不同(如 ResNet Model Zoo 版是 data / resnetv15_dense0_fwd)。改 config 前务必先查
    python -c "import onnxruntime as o; s=o.InferenceSession('model.onnx'); \
    print([i.name for i in s.get_inputs()], [x.name for x in s.get_outputs()])"
    
  • pbtxt 中避免中文 # 注释(部分版本解析不稳)。
  • shm_size 太小 Triton 会崩,本方案设 2gb

6.7 环境隔离(贯穿始终的根因)

  • 6.2、6.3 本质都是”全局 pyenv 环境依赖互相打架”。
  • 解法与心智:每个项目用独立 venv(= Python 版的 go mod 隔离)。 但 libomp(系统 C 库)不归 venv 管,仍需 brew。跑通后 pip freeze > requirements.lock 锁定可复现。

6.8 网络隔离:Triton 独立项目 vs Dify/LiteLLM 各自网络

  • 根因:Triton 在 triton-gap 项目、独立网络;Dify 在 docker_default、LiteLLM 在 litellm_default,互不相通。
  • 解法:Triton compose 用 external: true 同时接入两个既有网络,容器间用容器名互访 (docker inspect docker-api-1 确认 Dify 网络名 = docker_default)。

6.9 SSRF 双防线(最硬的坎)

Dify 有两道 SSRF 防护,拦截来源不同、报错措辞不同:

节点类型 走的路径 报错 拦截者
代码执行节点 sandbox → squid 代理 403 Forbidden ssrf_proxy(Squid)
HTTP 请求节点 Dify 主服务应用层 blocked by SSRF protection 应用层 URL 校验
  • 无效尝试:清空 SANDBOX_HTTP_PROXY——被 ${VAR:-default}:- fallback 成默认代理,从未生效。
  • 正解.envSSRF_PROXY_ALLOW_PRIVATE_DOMAINS + SSRF_PROXY_ALLOW_PRIVATE_IPS (官方白名单变量,entrypoint 生成 dify_allow_private.conf,include 在 deny to_private_networks 之前)。 重建 ssrf_proxy 后,HTTP 请求节点直连成功。
  • 副作用认知:加白名单后,代码执行节点(走 squid)也一并被放行,等于多了一条可用路径。

7. Dify 工作流接入(最终打通方式)

用 HTTP 请求节点(不是代码节点——代码节点受 sandbox 限制更多):

  • 方法 POST
  • URL http://triton-gap-triton-1:8000/v2/models/iris_sklearn/infer容器名,非内网 IP)
  • Header Content-Type: application/json
  • Body(JSON):{"inputs":[{"name":"X","shape":[1,4],"datatype":"FP32","data":[5.1,3.5,1.4,0.2]}]}

结果美化:HTTP 节点后接一个纯解析代码节点(不发网络请求、不受 SSRF 限制):

import json
def main(body: str) -> dict:
    data = json.loads(body) if isinstance(body, str) else body
    out = data["outputs"]
    label = next(o["data"][0] for o in out if o["name"] == "label")
    probs = next(o["data"] for o in out if o["name"] == "probabilities")
    return {"species": ["setosa","versicolor","virginica"][int(label)],
            "confidence": float(max(probs))}

参数化:Body 里的固定值可改为引用「开始/用户输入」节点变量(Dify 变量插值)。


8. 待办 / 优化项(可选)

  • YOLO NMS 后处理output0 是未解码的 [84, anchors](4 坐标+80 类)。加 Python 后端做 NMS, 与 yolov8n 串成 ensemble,对外一个端点;或调用方做 NMS。
  • OpenVINO 加速:Intel i9 上 ResNet/YOLO 换 OpenVINO 执行加速器明显更快 (config 加 optimization { execution_accelerators { cpu_execution_accelerator: [{name:"openvino"}] } })。
  • 真实图片验证:tritonclient + PIL 预处理(resize/归一化)测 ResNet 分类、YOLO 检测。
  • resnet/yolo 接入 Dify:同 iris,做成 HTTP 节点(走已打通的 squid 白名单路径)。
  • IP 漂移192.168.8.3 随网络变化会变;已改用容器名,无此问题。

9. 面试话术提炼(本地推理平台建设)

一句话定位

本地 AI 中台里,用 Ollama 承载 GGUF/transformer(LLM、embedding、VLM), 用 Triton 补齐它的盲区——树模型、CNN、目标检测、任意 ONNX 这些非 GGUF 模型, 由 LiteLLM 统一文本侧、Dify 编排、Prometheus 观测,形成双引擎推理平台。

关键架构取舍(可被追问)

我没有为了”统一入口”把所有模型都塞进 LiteLLM 的 OpenAI 格式。 文本模型(进文本出文本)走 OpenAI 网关;张量模型(进张量出张量)走 KServe v2 协议直连 Triton。 协议语义不同,强行统一会失真——这是刻意的分流设计,不是偷懒。

工程深度佐证(真实排障经历,非纸上谈兵):

落地中处理了一串真实问题:Python 依赖地狱(libomp/numpy/opencv 版本冲突,靠 venv 隔离 + 版本钉死解决)、 模型格式代沟(XGBoost 3.x 与 Triton 内置 Treelite 不兼容,降级重导)、 Triton config 与模型张量的精确对齐(FIL 必填参数、sklearn 的 ZipMap 与标量 reshape、现成 ONNX 张量名核对)、 容器网络隔离(external network 接入既有栈)、 以及 Dify 的 SSRF 双防线(sandbox squid 代理 + 应用层校验,用官方白名单变量精准放行)。

可迁移到生产的映射

本地这套”中心托管 + 按模型类型/协议分流”的结构,可平移到 K8s: Triton 用 KServe 编排(自动扩缩容/金丝雀),模型仓库分地域用 Harbor/FluxCD 分发, 边缘用 Karmada 联邦下发——与我做 SDN 控制面”意图→决策→执行”的分层同构。


10. 成果核对清单

✅ fraud_xgb    树模型 (FIL / XGBoost 1.7.6)        successfully loaded
✅ iris_sklearn 随机森林 (ONNX, 关 zipmap + reshape) successfully loaded
✅ resnet18     CNN 图像分类 (ONNX Model Zoo)        successfully loaded
✅ yolov8n      目标检测 (ONNX, Docker 导出)          successfully loaded
✅ health 200,ResNet 推理返回类别 623
✅ Dify HTTP 节点经 squid 白名单调通 iris_sklearn → setosa
✅ :8002 指标暴露,可接 Prometheus/Thanos

Ollama 的四类盲区(传统 ML、CNN、自训练网络、ONNX)——从判断到落地,全部闭环。


评论:


技术文章推送

手机、电脑实用软件分享

微信搜索公众号: AndrewYG的算法世界
wechat 微信公众号:AndrewYG的算法世界

热门文章