📌 环境:Kylin V10 SP3 / aarch64 / 鲲鹏 920 × 4 核 / 14GB 内存 / 无外网 / glibc 2.28
🎯 目标:CPU 上跑通 bge-m3 Embedding 服务,对外提供 OpenAI 兼容的 /v1/embeddings 接口
✅ 结果:llama.cpp 自编译 + bge-m3 Q4_K_M,单条 380ms,吞吐 4 条/秒,常驻内存 1.9GB

一、背景与约束

业务需要在内网做语义检索(FAQ 去重、相似问题匹配),选型 bge-m3(1024 维、中英双语效果好、支持长文本)。但目标服务器是内网的麒麟 V10 鲲鹏机,没有外网访问权限,所有软件必须离线带入。

这个约束直接决定了整个技术路线:不能 apt install,不能 git clone && make,一切都要在别处编译好、打包好、传进去。

二、三次失败的尝试

坑 1:官方预编译包跑不起来

llama.cpp 官方提供 ubuntu-arm64 预编译包,下载即用看似最省事。但在麒麟上报错:

version `GLIBC_2.33' not found (required by llama-server)
version `GLIBC_2.38' not found

原因:Ubuntu 24.04 构建环境链接了新版 glibc 符号,麒麟 V10 只有 glibc 2.28。预编译包的原则是“构建环境的 glibc ≤ 运行环境的 glibc”,跨发行版直接拿来用大概率死在这。

教训:无外网 + 老发行版环境,永远自己编译,别信任何预编译产物。

坑 2:手动装 gcc-9 的 .deb 全军覆没

为了满足 llama.cpp 的编译要求(CMake 3.14+、C++17),第一反应是离线下载 gcc-9 的 deb 包带过去。从镜像站逐个下载了 21 个包(gcc-9、libstdc++-9-dev、libisl23……),结果 dpkg -i 时依赖链雪崩。

原因:那些 9.3.0-22 版本的包是 Debian 11 (bullseye) 构建的,同样要求 glibc ≥ 2.29。而且 bullseye 中的 libgcc-s1、libstdc++6 等运行库实际由 gcc-10 源码包构建,想凑齐 gcc-9 的依赖要跨好几个源码池去挖,工作量大且依然绕不开 glibc 门槛。

教训:给老 glibc 环境找编译器,应该找与目标系统同源的包——即 Debian 10 (buster) 自己的 gcc-8,而不是更新的 gcc-9。

坑 3:snapshot.debian.org 把 apt 卡死

即便在匹配的 Debian 10 容器里装依赖,最初配置的 snapshot.debian.org 源限速严重(几十 KB/s),一个 apt update 能挂半小时不动。

解决:buster 已 EOL,换用国内镜像的归档源(如 mirrors.nju.edu.cn/debian-archive),速度直接到 MB/s 级。国内做老版本 Debian 离线准备时,debian-archive 的国内镜像是首选。

三、正确的技术路线

3.1 用 Docker 做同源编译环境

Mac 上跑一个 debian:10 容器(与目标系统同为 glibc 2.28 的 buster),在容器内编译 aarch64 目标产物。因为容器和目标机同为 aarch64 + 同版本 glibc,这不是严格意义的交叉编译,而是“模拟目标环境编译”——最不容易踩兼容坑的方式。

docker run -it -v ~/llama.cpp:/workspace debian:10
# 容器内:换 debian-archive 国内镜像源
echo "deb [check-valid-until=no] http://mirrors.nju.edu.cn/debian-archive/debian buster main" \
  > /etc/apt/sources.list
apt-get update && apt-get install -y build-essential libopenblas-dev git

编译器直接用 buster 自带的 gcc 8.3——放弃 gcc-9 之后发现它完全够用。

3.2 CMake 版本问题

llama.cpp 要求 CMake ≥ 3.14,buster 自带 3.13。提前准备好 CMake 3.20 的 aarch64 离线包带进容器解压即可,不折腾。

3.3 编译配置:为鲲鹏定制

两个关键决策:

cmake -B build -DCMAKE_BUILD_TYPE=Release \
  -DGGML_NATIVE=OFF \
  -DCMAKE_C_FLAGS='-march=armv8.2-a+fp16' \
  -DCMAKE_CXX_FLAGS='-march=armv8.2-a+fp16' \
  -DLLAMA_CURL=OFF -DLLAMA_BUILD_TESTS=OFF \
  -DGGML_OPENMP=ON
  • GGML_NATIVE=OFF 必开:默认会按编译机的 CPU 特性生成指令。在 Apple M4 上编译再拿到鲲鹏上跑,直接 SIGILL 崩溃;
  • -march=armv8.2-a+fp16:鲲鹏 920 是 armv8.2 核心,带 fp16,这个档位既启用 NEON fp16 优化又保证兼容。

3.4 gcc 8 的两个编译障碍及解法

障碍 1:NEON x2/x4 intrinsic 缺失。 ggml 的代码里用了 vld1q_s8_x4、vld1q_u8_x4 等四寄存器加载指令,这些 intrinsic 需要 gcc 9+。报错是编译期的 undeclared,而非运行期问题。

解法是给 ggml-cpu-impl.h 打补丁,把手写 fallback 塞进 ggml 的宏抽象层(ggml 本来就为兼容旧编译器预留了 ggml_vld1q_* 宏,只需在 gcc 8 分支提供实现):

// 用连续的 vld1q_s8 组合模拟 x4 加载,语义等价
static inline int8x16x4_t ggml_vld1q_s8_x4_impl(const int8_t *p) {
    int8x16x4_t r;
    r.val[0] = vld1q_s8(p);
    r.val[1] = vld1q_s8(p + 16);
    r.val[2] = vld1q_s8(p + 32);
    r.val[3] = vld1q_s8(p + 48);
    return r;
}
#define ggml_vld1q_s8_x4 ggml_vld1q_s8_x4_impl
// s16_x2 / u8_x2 / u8_x4 / s8_x2 同理

障碍 2:std::filesystem 链接失败。 gcc 8 中 filesystem 是独立库,且 -lstdc++fs 必须出现在链接命令末尾(符号由后端的 libggml 引入)。常规的 CMAKE_EXE_LINKER_FLAGS 会把库放在前段导致顺序错误,最终用这个保证追加到末尾:

cmake -B build -DCMAKE_CXX_STANDARD_LIBRARIES='-lstdc++fs'

这两处是本次部署最“硬”的技术点——网上关于 llama.cpp + gcc 8 的资料很少,报错信息也不会告诉你该往宏抽象层下手。

3.5 自包含部署包

编译产物 + 依赖库 + 模型 + 启动脚本打包成完全自包含的 tar.gz(400MB),目标机解压即跑、零安装:

bge-m3-kylin-deploy/
├── start.sh                    # 启动脚本(见下)
├── bin/                        # llama-server + libllama/libggml 系列 .so
├── lib/                        # libstdc++.so.6.0.25 / libgcc_s / libgomp
│                               #   (从容器内拷出,防目标机版本不齐)
└── model/bge-m3-Q4_K_M.gguf    # 438MB 量化模型

启动脚本的核心是把两个库目录都挂进搜索路径(ggml 系列 .so 在 bin/ 下,这是个容易漏的坑):

export LD_LIBRARY_PATH=$(pwd)/bin:$(pwd)/lib:$LD_LIBRARY_PATH
exec ./bin/llama-server -m ./model/bge-m3-Q4_K_M.gguf \
  --embedding --pooling cls --host 0.0.0.0 --port 8000 \
  --threads 4 --ctx-size 8192 --batch-size 64 --parallel 2 --mlock

打包前务必核验 glibc 符号上限(readelf 提取所有 GLIBC_ 版本取最大值),确认 ≤ 目标机的 2.28:

readelf -V build/bin/llama-server | grep -oE 'GLIBC_[0-9.]+' | sort -Vu | tail -1
# 输出 GLIBC_2.27 ≤ 2.28 ✓

四、离线传输:堡垒机环境下的“缝隙”

服务器仅有天玥运维安全网关一个入口,直连端口全被 ACL 过滤,堡垒机又禁止 SSH -L 端口转发(administratively prohibited)。但发现一个缝隙:网关的本地客户端(Bastion)会把已建立的 SSH 会话映射为 Mac 上的本地回环端口(如 127.0.0.1:54023),探测该端口的 banner(SSH-2.0-OsmSSH)确认后,直接对这个本地端口 scp,400MB 约 4 分钟传完,sha256 校验一致。

这个方法的前提是“堡垒机客户端已建立一条活会话”,本质是复用网关自己开凿的隧道,不涉及绕过策略——但对大量堡垒机管控的离线内网环境来说,是比 SFTP 客户端(本文场景中网关拉起 FileZilla 一直失败)靠谱得多的路径。

五、性能实测与调参

4 核鲲鹏 920、Q4_K_M 量化、--threads 4:

指标数值
单条请求(短文本)~380ms
批量 10 条合一次请求~2.5s(比逐条快 1.5×,省的是 HTTP 与调度开销)
持续吞吐~4 条/秒(CPU 物理上限)
内存常驻~1.9GB(模型 450M + 双窗口 KV Cache,mlock 锁定)
空闲 CPU≈ 0%(推理按需,平时不耗)

调参要点:

  • --ctx-size 是总量,会被 --parallel 平分。想要“2 并发 × 每窗口 4096”,总量要设 8192,这是最容易想当然的地方;
  • --parallel 2 让两个请求同时进两个窗口(日志可见 id 0 / id 1 交错处理),批量导入之外的并发场景收益明显;
  • --mlock 把模型钉在物理内存里,杜绝 swap 抖动,内存充足时建议必加;
  • --threads 4 与核数持平。与业务应用混部时可下调让核,吞吐随之下调——CPU 推理的吞吐本质是核数 × 频率,没有免费午餐。

六、生产化收尾

  • systemd 托管:Restart=always + RestartSec=5,替代 nohup,实现开机自启和崩溃自愈;
  • 监听收敛:仅本机调用时把 --host 0.0.0.0 改 127.0.0.1;
  • 相似度阈值:bge-m3 余弦相似度经验值——≥0.60 判定相似,<0.40 过滤,中间灰区用 50~100 条真实语料校准;
  • 接口兼容性红利:/v1/embeddings 是 OpenAI 兼容格式,业务侧直接用现成 OpenAI SDK 改 base_url 即接入,无需手写 HTTP 客户端。

七、复盘:离线内网 AI 服务部署方法论

  1. 编译环境与目标系统同源(同发行版、同 glibc),比任何“高版本编译器 + 静态链接”的歪招都稳;
  2. 预编译包的 glibc 符号上限是第一检查项,readelf -V 一条命令避免半天白干;
  3. EOL 老系统找包,先找 debian-archive 的国内镜像,别在 snapshot 源上耗时间;
  4. 旧 gcc 编新代码,优先找项目的兼容宏抽象层下手(ggml 的 ggml_vld1q_* 就是为此设计的),比改业务代码侵入性小;
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐