首页
/ LocalAI 构建与测试实战指南:后端 Docker 构建、覆盖率棘轮与 UI 覆盖率门禁

LocalAI 构建与测试实战指南:后端 Docker 构建、覆盖率棘轮与 UI 覆盖率门禁

2026-09-05 20:45:53作者:薛曦旖Francesca

本文基于 LocalAI 仓库中的 .agents/building-and-testing.md 展开,系统讲解如何为指定硬件平台构建 LocalAI 后端镜像(以 Makefile 的 docker-build-* 目标与 .github/backend-matrix.yml 为准),以及如何正确使用仓库的两道覆盖率棘轮:Go 核心代码的严格单调门禁(make test-coverage-check)与 React UI 的带容差 UI 覆盖率门禁(make test-ui-coverage-check)。读完本文,你将能独立完成"按需构建某一后端"的操作,并理解每道覆盖率门禁背后的 ginkgo/Playwright 实现细节、基线再生成规则以及"棘轮只升不降"的团队约定。

总原则:什么时候构建、去哪里找构建方式

LocalAI 的构建与测试成本不低——它由 Go 核心、React UI 和几十个独立后端(C++、Go、Python、Rust)组成,且不同平台(CPU/CUDA/ROCm/SYCL/JetPack/Darwin Metal)走不同构建路径。因此文档给出的首要原则是:除非用户/任务明确要求构建或测试,否则不要尝试构建或测试整个项目,因为所需上下文太多。

当确实需要构建时,查阅顺序为:

  1. 项目根目录的 Makefile:包含 Docker 内外两种构建入口,是主入口;
  2. 受你修改影响的各后端目录下的 Makefile(如 backend/<lang>/<name>/Makefile);
  3. .github/workflows 中的 CI 工作流——当不确定某个组件如何构建或测试时,工作流文件就是最可靠的可执行参考。

一个细节约定:主 Makefile 同时提供了在 Docker 内外构建的目标;如果使用者没有明确偏好,应当先询问对方希望用 Docker 构建还是原生构建,而不是擅自选择。

构建指定的后端

从哪里来:docker-build-* 目标是生成的

以"为 ROCm/hipblas 构建 coqui 后端"为例。Makefile 中每个后端都有一个形如 docker-build-coqui 的目标,这些目标并非手写的,而是由 Makefile 中的 generate-docker-build-target 宏根据一组 BACKEND_* 元数据定义批量生成(见 Makefiledefine generate-docker-build-target 及紧随其后的一长串 $(eval $(call generate-docker-build-target,...)))。元数据格式为:

BACKEND_XXX = 后端名|Dockerfile类型|构建上下文|progress标志|是否需要BACKEND参数
# 例:python 后端
BACKEND_COQUI = coqui|python|.|false|true

docker-build-$(name) 实际展开为一次 docker build,把 BUILD_TYPEBASE_IMAGECUDA_MAJOR_VERSIONCUDA_MINOR_VERSIONUBUNTU_VERSIONUBUNTU_CODENAMEAPT_MIRROR 等全部作为 --build-arg 传入,Dockerfile 按类型选择(backend/Dockerfile.pythonbackend/Dockerfile.golang、各 C++ 引擎专用 Dockerfile),产物镜像统一打为 local-ai-backend:<name>

注意:最近新增的后端可能需要新增对应的元数据行才能生成目标,这一点在动手前先核对 Makefile 中的 BACKEND_* 列表。

关键构建参数:BUILD_TYPE 与 BASE_IMAGE

构建一个后端变体,最少需要设置 BUILD_TYPEBASE_IMAGE 两个 build-arg。这两个取值的权威参考是 .github/backend-matrix.yml——它是一个纯数据 YAML(不是工作流),罗列了每个后端变体的 build-typebase-imageplatformscuda-major/minor-versionruns-on 等字段,供 scripts/changed-backends.js 消费后分别生成 backend.ymlbackend_pr.yml 两个 CI 工作流的矩阵。从源码结构看,这个矩阵文件还承担了"某后端发布到哪些操作系统"的源真相角色:文件头注释明确写着一个后端只会在它出现的矩阵中构建发布(Linux 走 include:,macOS 走 includeDarwin:),新增后端时若遗漏 Darwin 条目,该后端会在 macOS 上静默不可用。

几个必须掌握的取值规则:

  • l4tcublas 变体:除 BUILD_TYPE/BASE_IMAGE 外还必须提供 CUDA 大版本号与小版本号(Makefile 中对应的变量是 CUDA_MAJOR_VERSION,默认 13,与 CUDA_MINOR_VERSION,默认 0)。
  • llama-cpp / ik-llama-cpp / turboquant:矩阵中这些条目额外设置 builder-base-image,指向预构建的 quay.io/go-skynet/ci-cache:base-grpc-* 缓存镜像。而本地直接执行 make backends/<name> 时默认走 BUILDER_TARGET=builder-fromsource,不需要该参数——其 Dockerfile 的 from-source 阶段会自行安装全部依赖。
  • 平台别名与歧义确认:用户口中的"AMD"可能指 ROCm(hipblas),"Intel"可能指 SYCL,"NVIDIA"可能指 l4t(JetPack)或 cublas。遇到这类歧义应当先确认,不要猜。

完整命令示例

文档给出的 coqui + hipblas 构建命令(格式化展示):

DOCKER_MAKEFLAGS=-j$(nproc --ignore=1) \
  BUILD_TYPE=hipblas \
  BASE_IMAGE=rocm/dev-ubuntu-24.04:7.2.1 \
  make docker-build-coqui

其中 DOCKER_MAKEFLAGS 会透传为容器内 make 的 --build-arg MAKEFLAGS,用于并行编译。

两条执行纪律(针对 Agent 与开发者同样重要):

  1. 除非用户明确要求执行,否则只打印命令。后端构建是长任务,输出可能溢出上下文,且并非所有 Agent 前端都擅长处理长时运行任务。
  2. 有时需要在 docker build 上追加额外参数,例如跨平台构建的 --platform 或查看完整日志的 --progress=plain。此时可以绕过 make 目标,直接生成等价的 docker build 命令(参考 docker-build-backend 宏展开出的 build-arg 集合)。

测试基座:make test 与 mock backend

覆盖率门禁与 make test 共享同一套前置条件,值得先交代一下。make test 依赖 prepare-test 目标,后者先做 protogen-go(用仓库内 protoc 从 backend/backend.proto 生成 gRPC 代码),再执行 build-mock-backend——把 tests/e2e/mock-backend 编译成二进制。core/http/app_test.go 驱动的就是这个 mock backend,因此默认测试不再下载多 GB 的 GGUF/whisper 模型,也不构建 llama-cpp 等真实后端;真实后端推理测试被移入 tests/e2e-backends/(按后端路径过滤)和 tests/e2e-aio/(夜间任务)。测试运行器是 ginkgo,默认带 --flake-attempts $(TEST_FLAKES)TEST_FLAKES 默认 5 次)与 --fail-fast,构建标签为 debug

Go 覆盖率棘轮:严格且单调递增

核心 Go 代码(./pkg./core,外加进程内集成套件 ./tests/e2e)受一道**严格、单调递增的覆盖率棘轮(ratchet)**保护,由 Makefile 中一组专门的变量与目标驱动:

  • make test-coverage:以 covermode=atomic 插桩运行各套件,把合并后的 profile 写入 coverage/coverage.out;前置依赖与 make test 相同。Makefile 中还特意不带 --fail-fast,这样单个失败不会截断覆盖率统计。
  • make test-coverage-check:先跑 test-coverage,再由 scripts/coverage-check.sh 判断总覆盖率是否低于已提交的基线 coverage-baseline.txt(当前基线值为 54.2,即 54.2%)。Linux CI 中 .github/workflows/test.yml 执行的就是 make test-coverage-check 而非 make test
  • make test-coverage-baseline:用当前运行结果重新生成并覆盖 coverage-baseline.txt

--coverpkg:把集成测试的功劳记到被测包头上

这是整套机制里最关键的技巧。Makefile 中定义了:

COVERAGE_COVERPKG?=github.com/mudler/LocalAI/core/...,github.com/mudler/LocalAI/pkg/...

通过 --coverpkg,覆盖率被归属到 core + pkg 的全部包上,而不只是"当前测试包"。这才有意义地把进程内的 tests/e2e 集成套件折叠进来——该套件通过 application.New 启动真实 HTTP 服务器、经 loopback 驱动 core/http/endpoints/... 的各处理器。没有 --coverpkg 时,这些测试只会给 e2e 测试包自己记账;有了它,endpoint 覆盖率几乎翻倍(文档给出的实例:endpoints/openai 从 13.6% 提升到 52%)。

代价是:分母变成了 core + pkg 的全部语句(生成代码除外,见下),因此这个数字不能与普通按包的覆盖率数字直接比较

哪些集成套件被折叠进来

  • COVERAGE_E2E_ROOTS?=./tests/e2e:非递归运行(因此排除需要容器的 tests/e2e/distributed),并带 --label-filter=!real-models(这些 spec 需要下载真实模型),针对 prepare-test 构建的 mock backend 执行。
  • tests/integration 被刻意排除:它依赖 make backends/local-store(需要真实构建 local-store 后端),而覆盖率 CI 任务并不构建它。

Flake 注意点:把集成测试折叠进"严格"门禁意味着一次硬性的 e2e 失败(或某个 spec 悄悄停止运行)都会让覆盖率门禁变红,而不仅仅是测试本身。--flake-attempts 吸收可重试的瞬时失败;covermode=atomic 则保证行覆盖本身是确定性的,不受测试顺序与重试抖动影响。

为什么每个 root 单独跑一次 ginkgo(不要"简化"掉)

合并逻辑在 scripts/run-coverage.sh 中,脚本头部的注释把原因写得非常清楚:给同一个 ginkgo 调用传多个递归 root(例如 ginkgo -r ./pkg ./core)时,只有一个 root 的 coverprofile 会被写进 --output-dir/--coverprofile,其余被静默丢弃。这一点在 ginkgo 2.29.0 上被实际验证:-r ./pkg ./core 只产生 ./pkg 的覆盖数据,而单独 -r ./core 则能覆盖全部 34 个 core 包。

因此脚本的合并策略是:

  1. 每个单元 root(./pkg./core)单独递归运行一次 ginkgo,profile 落为 cover-<root>.out
  2. 每个 e2e root 非递归运行一次(可带 label filter);
  3. 用 glob 收集全部 per-root profile,用 awk 按语句块求和命中次数(不是简单拼接——因为 --coverpkg 会让同一个包出现在多个 root 的 profile 中,直接拼接会重复计块;对 covermode=atomic 而言按块求和是合法的),并按 COVERAGE_EXCLUDE_RE(默认 grpc/proto/.*[.]pb[.]go)丢弃生成的 protobuf,最后输出统一的 mode: atomic 头。

文档特别警告:不要把它"简化"回单次多 root 调用——此前正是这样,core/(包含全部 core/http,约 7.4k 条语句)一度从统计数字中静默消失。

构建标签:auth 不能少

COVERAGE_TAGS?=debug authGINKGO_TAGS 传给 ginkgo 的 --tags。其中 auth 标签是必需的:只有带它编译,真正的(sqlite 后端的)认证实现及其约 150 个 //go:build auth 测试才会进入构建与执行;不带该标签时这些文件根本不编译、测试不运行,门禁会拿一个 stub 给 auth 打分(约 3.7% 而非约 38%)。推论:新增带 build tag 的测试时,必须同时扩展 COVERAGE_TAGS,否则它们既不计入覆盖率,大概率也不会进 CI 运行。

React UI 覆盖率:唯一有测试的 UI 路径

React UI(core/http/react-ui没有任何组件级/单元测试,它唯一的测试就是 Playwright e2e spec,运行在 tests/e2e-ui/ 的 ui-test-server 之上(该 server 由 tests/e2e-ui/main.go 编译而来)。这些 spec 确实驱动真实 UI——点击、fillsetInputFilesgetByRole/getByText、可见性与取值断言。由于 dist 是 //go:embed 进 ui-test-server 的,每次覆盖率运行都会重新编译 server

make test-ui-coverage 的流程(见 Makefiletest-ui-coverage 目标):

  1. 构建带插桩的 bundle(COVERAGE=true,经由 vite-plugin-istanbulforceBuildInstrument: true——否则该插件会跳过生产构建的插桩;Makefile 注释说明该目标还有一条更新的 V8 原生覆盖路径 bun run build:coverage-v8 + PW_V8_COVERAGE=1,端到端约快 40%,旧的 istanbul 路径仍然保留);
  2. 重新嵌入并编译 ui-test-server;
  3. 运行 Playwright spec,把 nyc 报告写入 core/http/react-ui/coverage/
  4. spec 统一从 core/http/react-ui/e2e/coverage-fixtures.js 导入 { test, expect }——它只是 re-export Playwright 的 API,并在每个测试结束后收割 window.__coverage__.nyc_output/。插桩默认关闭(仅 COVERAGE=true 时开启),因此日常开发/生产构建和普通 make test-ui-e2e 完全不受影响(fixture 在 window.__coverage__ 不存在时自动空转)。

浏览器与基线规则

  • 浏览器:flake 开发 shell 自带 chromium 并导出 PLAYWRIGHT_CHROMIUM_PATHplaywright.config.js 通过 launchOptions.executablePath 使用它,Makefile 在此变量已设置时跳过 playwright install。这是为了绕开 Playwright 自带浏览器在 NixOS 上无法解析系统库(libglib-2.0 等)的问题。CI 中没有该变量时,Makefile 回退为 playwright install --with-deps chromium
  • SPA 特性:应用是 React SPA,覆盖数据在应用内导航时跨路由累加;一旦发生整页 page.goto/刷新即被重置。
  • 全量分母core/http/react-ui/.nycrc.json 使用 all: true,因此每个 src/** 文件都出现在报告中,包括 0% 覆盖的文件——这正是发现"完全没有测试的功能"的手段(把 HTML 报告或 coverage-summary.json 按行覆盖率升序排序即可)。
  • 门禁make test-ui-coverage-check 运行套件后调用 scripts/ui-coverage-check.sh,若总行覆盖率低于 core/http/react-ui/coverage-baseline.txt 超过 UI_COVERAGE_TOLERANCE(默认 0.8 个百分点)则失败;make test-ui-coverage-baseline 重新生成基线。CI 中由 .github/workflows/tests-ui-e2e.yml 执行 make test-ui-coverage-check

为什么 UI 门禁有容差,Go 门禁没有

这是文档中最值得内化的一条工程判断。UI e2e 覆盖率本质上不确定:大量 spec 在异步/懒加载渲染仍在进行时就已断言结束,那些行只有在"渲染跑赢覆盖率收尾"时才被采集,于是总覆盖率会随机器速度/负载漂移——而且漂移分散在几十个 spec 中(脚本注释中给出了观测值:同一棵代码树,安静的本机约 39.9%,较慢/高负载的 CI 运行器约 39.0%)。容差的作用就是吸收这种漂移,所以基线必须设在慢速 CI 的下限之下,绝不能取快速本机跑出的高点,否则 CI 会反复抖动(脚本注释还提到容差曾被临时收紧到 0.1pp 校准到一次幸运的快速本机运行,结果就是 CI 抖动)。

提升 UI 覆盖率是最便宜的

  • 写一个 render-smoke spec——导航到某路由、断言其 header 渲染出来,就会挂载懒加载页面并执行完整渲染与初始 effects,几行测试就能捕获该页面大部分覆盖行(参考 core/http/react-ui/e2e/page-render-smoke.spec.js)。测试 server 中认证是关闭的(isAdmin=true),所以 RequireAdmin/RequireFeature 路由无需 mock 即可渲染。
  • 最确定性的收益是消除竞态:让 spec 在结束前 await 某个已渲染元素(例如 core/http/react-ui/e2e/agents.spec.js 中的 AgentCreate),这样对应行每次运行都被计数。

两道门禁的共同规则

文档最后给出两条对所有维护者(尤其是 Agent)都适用的硬性规则:

  1. 不要弱化门禁:永远不要为了把红色门禁变绿而手动调低基线或放宽容差。棘轮只能向上移动。
  2. 覆盖率下降时加测试,而不是改基线:按行覆盖率升序排列 coverage-summary.json 找到未测试代码,补测试。当覆盖率确实(且可复现地)上升时,再提交重新生成的基线(make test-coverage-baseline / make test-ui-coverage-baseline)。
  3. 两个门禁的强度差异是刻意设计:Go 门禁严格、无容差covermode=atomic 保证了确定性);UI 门禁保留小容差,仅因为 e2e 覆盖率天然不确定。

小结

LocalAI 的构建测试体系可以概括为三层:以 Makefile 生成式 docker-build-* 目标 + .github/backend-matrix.yml 矩阵数据为准的按需后端构建;以 mock backend 为基座、--coverpkg 跨包记账 + 每 root 单跑 ginkgo 的 Go 严格覆盖率棘轮;以及以 Playwright 全量驱动真实 UI、带 0.8pp 容差的 UI 覆盖率棘轮。三者的共同设计哲学是"棘轮只升不降、门禁失败要能定位到具体代码",所有细节都能在 Makefilescripts/run-coverage.shscripts/coverage-check.shscripts/ui-coverage-check.sh 中逐行核对。

登录后查看全文
热门项目推荐
相关项目推荐