LocalAI ds4 后端实战:把 antirez/ds4 引擎封装成 C++ gRPC 推理服务的完整技术解析
本文以 LocalAI 仓库中 ds4 后端的开发指南(.agents/ds4-backend.md)为主线,系统讲解 LocalAI 如何为 antirez/ds4(DeepSeek V4 Flash 单模型推理引擎)构建一套独立的 C++ gRPC 后端:从上游版本 pin 与依赖更新机制、gRPC RPC 接线形态、DSML 工具调用文本标记的流式解析、thinking 模式与磁盘 KV cache,到 SSD streaming 跑超大 MoE 模型、CUDA fat binary 构建陷阱与验证方法、硬件门控 e2e 测试,以及分层分布式推理(coordinator/worker)与模型导入器(DS4Importer)的接入细节。读完后你能掌握在 LocalAI 中构建、配置、验证并扩展 ds4 后端的完整技术路径。
后端总览:为什么是"全新 C++ 服务"而不是复用 llama-cpp
ds4 是 antirez 为 DeepSeek V4 Flash 编写的单模型推理引擎。LocalAI 对这个引擎的接入方式是:用一套全新的 C++ gRPC 服务包裹其 C API(ds4/ds4.h),代码位于 backend/cpp/ds4/。这里刻意没有 fork llama-cpp 的 grpc-server.cpp——因为 ds4 的会话模型、MoE 专家流式加载和分布式传输都是 llama.cpp 不具备的独立能力。
后端目录的实际组成(见 backend/cpp/ds4/):
- grpc-server.cpp:gRPC 服务主体(Health / LoadModel / Predict / PredictStream / TokenizeString 等);
- dsml_parser.{h,cpp} / dsml_renderer.{h,cpp}:DSML 工具调用标记的双向解析/渲染;
- kv_cache.{h,cpp}:基于 SHA1 文件名的磁盘 KV cache;
- worker_main.c:分布式 worker 二进制;
- Makefile / package.sh / run.sh:构建、打包与运行脚本。
版本 Pin:上游 commit 如何被锁定与更新
backend/cpp/ds4/Makefile 顶部用一行 DS4_VERSION?=<sha> 固定上游 commit(当前为 8db89fe083ae4d17c9a2428ccd29803d3ae8f577,见 Makefile):
DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577
DS4_REPO?=https://github.com/antirez/ds4
Makefile 中的 ds4 目标按该 commit 做 git fetch --depth 1 浅克隆(见 Makefile),这与仓库内 llama-cpp / ik-llama-cpp / turboquant 后端的 pin 约定完全一致。CI 侧的 bump-deps 机器人(.github/workflows/bump_deps.yaml)通过 grep 这一行 DS4_VERSION?= 找到 pin,并每日自动开 PR 更新。
手动升级上游的流程是:
- 编辑 Makefile 中的
DS4_VERSION?=行; - 执行
make purge && make重新拉取并构建(purge会删掉已克隆的ds4/目录),或直接依赖 CI 的 clean build。
RPC 接线形态:每条 gRPC 调用对应哪个 ds4 API
从 grpc-server.cpp 的 #include "ds4.h" 与全局单例状态(g_engine / g_session,ds4 按设计是单引擎/单进程)可以看到各 RPC 的实现映射:
| RPC | 实现 |
|---|---|
| Health, Free, Status | 轻量;Health 不依赖引擎 |
| LoadModel | ds4_engine_open + ds4_session_create;后端在编译期确定:DS4_NO_GPU → CPU,__APPLE__ → Metal,否则 CUDA |
| TokenizeString | ds4_tokenize_text |
| Predict | ds4_engine_generate_argmax + DsmlParser → 一条 ChatDelta,含 content / reasoning_content / tool_calls[] |
| PredictStream | 同上,按 token 逐条写 ChatDelta |
一个值得注意的实现细节:grpc-server.cpp 用 g_loaded_model_identity 记录本进程加载的模型身份,并与请求里的 PredictOptions.ModelIdentity 比对,来自过期分布式路由的请求会被拒绝而不是用错误的模型作答;取消传播则通过 ds4_session_set_cancel 桥接 gRPC 的 ServerContext::IsCancelled()。
DSML:把"字面文本标记"变成结构化 tool_calls
ds4 输出工具调用时用的不是特殊 token,而是字面文本标记(如 <|DSML|tool_calls> 等)。LocalAI 为此实现了 dsml_parser.{h,cpp} 流式状态机,把 token 字节流分类为 CONTENT / REASONING / TOOL_START / TOOL_ARGS / TOOL_END 事件(见 dsml_parser.h 的 ParserEvent)。其内部状态机为 TEXT / THINK / TOOL_CALLS / INVOKE / PARAM_VALUE(dsml_parser.h),并处理了一个流式解析的难点:chunk 尾部可能只是"疑似标记前缀",会被缓冲进 buf_ 等待下一个 chunk 消歧(TryConsumeMarker / DrainPlain)。
IsInDsmlStructural() 是另一个关键能力:当解析器处于 DSML 结构位置(TOOL_CALLS / INVOKE,即协议字节应逐字输出的区域)时返回 true,调用方应在下一次采样前据此强制贪心解码(temperature=0);普通正文与参数值位置则沿用用户的采样设置。这条规则镜像了上游 ds4-server 中 dsml_decode_state_uses_payload_sampling 的行为。
反向方向由 dsml_renderer.{h,cpp} 负责:把 OpenAI 格式的 tool_calls 与 role=tool 消息重新渲染成 DSML,供下一轮对话拼接进 prompt。
Thinking 模式:两个 Metadata 开关
Predict 的 PredictOptions.Metadata 控制思考行为:
"enable_thinking":开/关 thinking(默认开);"reasoning_effort":值为"max"或"xhigh"时选择DS4_THINK_MAX,其余任何值映射为DS4_THINK_HIGH。
选定模式后通过 ds4_chat_append_assistant_prefix 传给引擎。解析侧则依赖 DsmlParser 的 REASONING 事件把思考内容分离到 reasoning_content,最终输出到 content 的都是思考块闭合之后的正文——这也是后文 CUDA 架构缺陷(思考块永不闭合导致 content 为空)能被 e2e 测试捕获的原因。
磁盘 KV cache:SHA1 键控的文件缓存
kv_cache.{h,cpp} 实现了一个 SHA1 键控的文件缓存,基于 ds4 公开的 ds4_session_save_payload / ds4_session_load_payload API 序列化会话 KV 状态。每请求开启方式:
options:
- kv_cache_dir:/some/path
需要注意两个事实边界:其文件格式是 LocalAI 自有的,与 ds4-server 的 KVC 文件不位兼容(互通是后续计划);另外从 grpc-server.cpp 可见 g_kv_cache_dir 为空时磁盘缓存整体禁用,即完全按需启用。
引擎选项:声明式表驱动的 LoadModel 选项映射
这是 ds4 后端最具工程特色的部分。ds4_engine_options 是无反射的纯 C 结构体,因此字段集必须在某一处枚举一次。实现选择了声明式表:kEngineOptSpecs[] + apply_engine_option(grpc-server.cpp)。未来新增引擎旋钮 = 表里加一行,而不是新分支。
表结构按类型区分(Bool / Int / Uint / Float / Str / Gib / CacheExperts),关键语义:
- 未知 key 直接忽略(向后兼容:调用方可能传入混合选项集);
- 裸 flag(如不带冒号的
ssd_streaming)按true处理,所以模型 YAML 可以写options: ["ssd_streaming"](见parse_bool_option,grpc-server.cpp); - 路径型值(
mtp_path、expert_profile_path、directional_steering_file)相对路径会相对模型目录解析——gallery 条目因此可以用裸文件名引用其伴生下载文件;绝对路径直接透传; - 两个字段需要 ds4 自己的类型化解析器:
simulate_used_memory(NGB走ds4_parse_gib_arg)和ssd_streaming_cache_experts(专家数或NGB,走ds4_parse_streaming_cache_experts_arg,一次写入 experts+bytes 两个字段); - 字符串选项拷入
storage向量,且必须预留容量,避免push_back重分配导致c_str()悬空(源码注释明确警告,grpc-server.cpp)。
当前接线的 key(与 kEngineOptSpecs 一一对应):
mtp_path、mtp_draft、mtp_margin、prefill_chunk、power_percent、warm_weights、quality、ssd_streaming、ssd_streaming_cold、ssd_streaming_preload_experts、ssd_streaming_cache_experts(数量或 NGB)、simulate_used_memory(NGB)、expert_profile_path、directional_steering_file、directional_steering_attn、directional_steering_ffn。
ds4_role / ds4_layers / ds4_listen / ds4_route_timeout / kv_cache_dir 有专属处理(校验 + coordinator 接线),不进入表中——例如 ds4_layers 的 START:END / START:output 解析由 grpc-server.cpp 的 parse_layers_spec 完成,output 后缀表示"直到最后一层(含 head)"。
SSD streaming:跑下内存放不下的模型
ds4 的 SSD streaming 让非路由权重保持常驻,在 cache miss 时从 GGUF 流式读取被路由到的 MoE 专家,把"放得下/放不下"变成一个速度光谱。该能力仅限 Metal(Darwin)——在 CUDA/CPU 上是 no-op。
启用与调参:
options:
- ssd_streaming
# 可选:显式设定路由专家缓存(专家数或 NGB 预算;省略则用 ds4 自动的
# "工作集 80%" 预算)
- ssd_streaming_cache_experts:16GB
基于此构建的 gallery 条目(见 gallery/index.yaml):
deepseek-v4-flash-q4-ssd:153 GB 的 Flash 模型跑在 128 GB 内存的 Mac 上;deepseek-v4-pro-q2-ssd:433 GB 的 Pro 模型(实验性)。
从 Makefile 可以看到 ds4_ssd.o / ds4_layer_pack.o / ds4_distributed.o / ds4_tp.o 被描述为"与 GPU 无关的翻译单元",每种 GPU 模式都无条件链接——这与"SSD streaming 仅限 Metal"形成对照,说明这些对象同时服务于分布式传输与层放置等共享逻辑。
CUDA 架构:fat binary 与"JIT PTX 静默损坏"的教训
这是开发指南中最有实战价值的一节。
问题根源
Makefile 直接驱动上游的对象目标($(MAKE) -C ds4 ds4.o ds4_cuda.o ...),从而绕开了上游的自我保护:上游的 cuda 目标在没有设置 CUDA_ARCH 时拒绝构建,只提供 cuda-spark(sm_121,DGX Spark / GB10)与 cuda-generic(native)两种选择。直接编译对象文件时,nvcc 在没有任何 -arch 的情况下工作,内核以默认架构的 JIT PTX 运行。
在 GB10(sm_121)上,这导致静默的推理损坏:超过 128 token 的 prefill batch 输出与 prompt 无关的文本,且思考块永不闭合,content 因此为空;同时 prefill 吞吐掉近两个数量级(同机同模型 4.21 t/s vs 325.70 t/s,约 77 倍差距)。短 prompt 保持正确,所以该缺陷长期未被发现。
Makefile 的解决方案
Makefile 按 CUDA_MAJOR_VERSION(构建矩阵已声明的构建参数,由 Dockerfile.ds4 转发)与 uname -m 选择 gencode 列表,并以 NVCC_ARCH_FLAGS 传给子 make:
CUDA_MAJOR_VERSION=13+ amd64:sm_80 / 86 / 89 / 90a / 100a / 103a / 120a / 121a;CUDA_MAJOR_VERSION=13+ aarch64(l4t):sm_87(Orin)/ 90a / 100a / 110(Thor)/ 121a(GB10);- 架构列表直接抄自
backend/go/vllm-cpp/Makefile,保证两个 CUDA 镜像覆盖相同 GPU 集合; - 之所以用
NVCC_ARCH_FLAGS而不是CUDA_ARCH:上游的CUDA_ARCH只接受单一值(-arch=$(CUDA_ARCH)),无法表达发行镜像需要的 fat binary;而命令行赋值可以赢过上游 Makefile 里的:=。
两个兜底策略:
CUDA_MAJOR_VERSION为空 → 视为本地开发者构建,回退到上游的native(需要本机有 GPU);- 无法识别的值 → 硬错误(
$(error ...))。因为 CI runner 没有 GPU,静默native在那里正是这段逻辑要防住的失败模式。
为什么刻意不定义 DS4_CUDA_HAVE_MXF4
Makefile 明确不设置该宏:上游只为单架构 sm_120/sm_121 构建定义它,且代码用裸 #ifdef 而非 __CUDA_ARCH__ 保护,因此无法与较老架构拼进同一个 fat binary。它门控的是可选的 MXFP4 indexer 快速路径,其 #ifndef 分支返回 0 回退通用路径——省略它损失的是速度,不是正确性。
验证一次构建
第一步:只解析、不编译,查看某个配置最终会落到哪些 flag:
make -C backend/cpp/ds4 BUILD_TYPE=cublas CUDA_MAJOR_VERSION=13 NATIVE=false \
--eval='show: ; @echo [$(DS4_ARCH_MAKEVARS)]' show
注意:不要用 make -n 做这件事——该 recipe 是 +$(MAKE) ...,+ 前缀表示即使在 -n 模式下也会真正执行。
第二步:直接演练失败模式。 它只在超过一个 prefill batch 后出现,普通 predict 能力测不出,需要 long_prefill:
BACKEND_BINARY=$(pwd)/backend/cpp/ds4/package/run.sh \
BACKEND_TEST_MODEL_FILE=/path/to/ds4flash.gguf \
BACKEND_TEST_CAPS=health,load,predict,long_prefill \
go test -count=1 -timeout=30m -v ./tests/e2e-backends/...
构建矩阵
| Build | 位置 | 说明 |
|---|---|---|
cpu-ds4(amd64 + arm64) |
Linux GHA | ds4 认为 CPU 仅具调试价值;只用于接线测试 |
cuda13-ds4(amd64 + arm64) |
Linux GHA + DGX Spark 验证 | Linux 上的主要生产路径 |
ds4-darwin(arm64) |
macOS GHA runner | Metal;类似 llama-cpp-darwin,使用 scripts/build/ds4-darwin.sh |
cuda12 被有意排除;ROCm / Vulkan / SYCL 不适用。
硬件门控的验证(opt-in e2e)
tests/e2e-backends/backend_test.go 支持 BACKEND_BINARY 模式,用环境变量指定已构建的后端与测试模型:
BACKEND_BINARY=$(pwd)/backend/cpp/ds4/package/run.sh \
BACKEND_TEST_MODEL_FILE=/path/to/ds4flash.gguf \
BACKEND_TEST_CAPS=health,load,predict,stream,tools \
BACKEND_TEST_TOOL_PROMPT="What's the weather in Paris?" \
go test -count=1 -timeout=30m -v ./tests/e2e-backends/...
CI 本身不加载模型——整套测试套件完全通过环境变量主动开启,无硬件的 CI 上自动跳过,有硬件的开发者/DGX Spark 上可直接回归。
分布式模式:分层推理与"反转"拓扑
ds4 支持分层(layer-split)分布式推理:模型单主机放不下时按 transformer 层切分;GGUF 必须存在于每台机器,各机器只加载自己的切片。其拓扑与 llama.cpp 相反:coordinator 监听,worker 主动拨入。
ds4-worker 二进制
由 package.sh 与 grpc-server 一起打包进 package/。它链接同样的引擎对象加上 ds4_distributed.o,但不依赖 gRPC/protobuf(走 ds4 自己的 TCP 传输),因此即使在 grpc-server 无法构建的环境也能编译。运行时执行 worker 服务循环(ds4_dist_run)。
Coordinator 接线
当 LoadModel 的 ModelOptions.Options(来自模型 YAML 的 options:)携带以下键时,ds4 grpc-server 扮演 coordinator(ds4_role 缺失 → 单节点,向后兼容):
ds4_role:coordinator:启用分布式模式;ds4_layers:0:19:coordinator 自己的切片(含端点;N:output表示含 head);ds4_listen:0.0.0.0:1234:worker 拨入的地址;ds4_route_timeout:60(可选,默认 60 秒):Predict/PredictStream 等待路由组建完成的超时,超时返回 gRPCUNAVAILABLE。
从 grpc-server.cpp 可以看到对应的全局状态 g_distributed 与 g_route_timeout_sec,以及"等待 worker 路由覆盖全部层"(ds4_session_distributed_route_ready)的阻塞逻辑。
Worker CLI
local-ai worker ds4-distributed -- <ds4-worker args> 会解析 ds4 后端并 exec 打包的 ds4-worker(参数原样透传),例如:
local-ai worker ds4-distributed -- \
--role worker --model /models/ds4flash.gguf \
--layers 20:output --coordinator <host> 1234
分布式 e2e(opt-in)
tests/e2e-backends/backend_test.go 中的分布式测试由 BACKEND_TEST_DS4_DISTRIBUTED=1 门控,配合 BACKEND_TEST_DS4_WORKER_BINARY、BACKEND_TEST_DS4_WORKER_LAYERS、BACKEND_TEST_DS4_COORDINATOR_LAYERS、BACKEND_TEST_DS4_LISTEN 四个变量完整配置两侧拓扑。
模型导入器:DS4Importer 的自动检测
core/gallery/importers/ds4.go 中的 DS4Importer 通过两个信号自动检测 ds4 权重:
- URI 匹配
antirez/deepseek-v4-gguf仓库名; - 文件名匹配
DeepSeek-V4-Flash-*.gguf模式。
注册顺序有讲究:它必须注册在 LlamaCPPImporter 之前(defaultImporters 列表)——两者都会匹配 .gguf,但 ds4 的特征更具体,而匹配是 first-match-wins。导入器输出 backend: ds4,本地文件名统一为 ds4flash.gguf(与 ds4 自身 CLI 的默认值一致,见 ds4.go 注释),并禁用 Go 侧的自动工具解析回退——因为 C++ 后端已经通过 DsmlParser 原生输出 ChatDelta.tool_calls,无需二次解析。
此外,ds4 还出现在 core/http/endpoints/localai/backend.go 的 pref-only 后端列表中,让 /import-model UI 可以把 ds4 作为手动选项暴露给希望强制指定后端的用户(针对非规范 URI 的场景)。
关键文件速查
| 内容 | 路径 |
|---|---|
| 后端 gRPC 服务(含声明式选项表) | backend/cpp/ds4/grpc-server.cpp |
| 上游 pin 与 CUDA 架构选择 | backend/cpp/ds4/Makefile |
| DSML 流式解析状态机 | backend/cpp/ds4/dsml_parser.h / dsml_parser.cpp |
| DSML 反向渲染 | backend/cpp/ds4/dsml_renderer.h / dsml_renderer.cpp |
| 磁盘 KV cache | backend/cpp/ds4/kv_cache.h / kv_cache.cpp |
| 分布式 worker 入口 | backend/cpp/ds4/worker_main.c |
| 打包 / 运行脚本 | backend/cpp/ds4/package.sh / backend/cpp/ds4/run.sh |
| 构建镜像 | backend/Dockerfile.ds4 |
| 硬件门控 e2e 套件 | tests/e2e-backends/backend_test.go |
| 模型导入器 | core/gallery/importers/ds4.go |
| SSD streaming gallery 条目 | gallery/index.yaml |
| 依赖自动更新工作流 | .github/workflows/bump_deps.yaml |
小结
LocalAI 的 ds4 后端展示了一套可复制的"单引擎 C++ gRPC 后端"工程范式:用声明式表把无类型 C 结构体的选项面一次性收敛;用状态机解析器把引擎的字面文本协议还原成 OpenAI 语义(content / reasoning_content / tool_calls);用编译期架构矩阵 + opt-in 硬件 e2e(long_prefill、分布式门控)把"CI 无 GPU"环境下的静默正确性风险压到最低;再用分层 coordinator/worker 拓扑补齐单主机装不下大 MoE 模型的最后一块拼图。所有能力都可以通过模型 YAML 的 options: 逐键开启,对未升级配置完全向后兼容。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00