推理中台
-
date_range 20/01/2023 00:48
点击量:次infosort推理中台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_group用KIND_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.txt(numpy 与 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]。
- 根因:模型 label 是标量
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 成默认代理,从未生效。 - 正解:
.env加SSRF_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的算法世界