Apple 的 Foundation Models 框架(macOS 26)提供了一个约 30 亿参数的 LLM,可以在设备端运行,免费,无需 API key。但有个问题:它只能使用 Swift。而且不是普通的 Swift——是 Swift 的 async 版本。

你的工具链是 Python 吗?没有绑定。用 Rust?也没支持。脚本语言 Shell?完全没法用。这个世界上最便宜的 LLM(确实免费)被困在两堵墙后:语言和并发模型。

显而易见的解决方案是搭建一个本地 HTTP 服务器,将模型暴露为 REST API,像 Ollama 那样。但这就像是用大炮打蚊子:一个进程在后台运行,占用一个端口,JSON 来回传递,每次问一个简单的问题都要用到 curl。如果只是给一个 commit 分类为 fixfeat,你不需要 HTTP,你需要的只是一个简单的 C 函数。

仅有 4 个函数的 dylib

所以我做了 libfoundationmodels:一个动态库,把 Apple 的框架编译为 .dylib,它仅导出 4 个 C 函数:

int32_t fm_init(void);
int32_t fm_is_available(void);
int32_t fm_generate(system, prompt, output, output_len);
int32_t fm_classify(system, prompt, choices, output, output_len);
---

嗯,算上 `fm_generate_json` 是 5 个函数,但核心思想未变:四个概念——初始化、检查可用性、生成自由文本,以及在选项中强制分类。全部同步。全部阻塞。输入缓冲区、输出缓冲区、返回代码。经典的 C 风格。

从 Python 调用它是这样的:

```python
import ctypes

fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()

buf = ctypes.create_string_buffer(256)
fm.fm_classify(
    None,                                    # 没有系统提示
    b"fix: handle nil response in OAuth",    # 要分类的文本
    b"fix\nfeat\nrefactor\ntest\ndocs",      # 选项
    buf, 256
)
print(buf.value.decode())  # "fix"

就是这样。没有 requests。没有 urllib。没有 JSON。没有运行的服务器。调用一个 C 函数,它会内部唤醒你 Mac 的 Neural Engine,传入文本,并将结果写回缓冲区。典型延迟:200-800ms。

有趣的问题:将 Async Swift 转为同步 C API

现在进入有趣的部分了。Foundation Models 框架是异步的:

let result = try await session.respond(to: prompt)

这个 await 就是问题所在。C 不知道什么是 await。C 没有 structured concurrency。C 的函数就是从入口开始,执行一些工作,然后返回结束。仅此而已。

你需要一个桥接器,将异步调用转换为阻塞调用。也就是说,C 的线程必须停下来等待 Swift 的异步操作完成。

经典的解决方法是用信号量(semaphore)。但在启用了 strict concurrency 的 Swift 6 中,在异步代码里使用 DispatchSemaphore 是有风险的。编译器会对你不友善,因为信号量可能会阻塞 cooperative thread pool 的线程,而这正是 Swift 6 尽力避免的。

来看一下解决方案:

private func blockingCall<T: Sendable>(
    _ body: @Sendable @escaping () async -> T?
) -> T? {
    let box = Mutex<T?>(nil)
    let semaphore = DispatchSemaphore(value: 0)

    Task {
        let value = await body()
        box.withLock { $0 = value }
        semaphore.signal()
    }

    semaphore.wait()
    return box.withLock { $0 }
}

这里有三个部分互相配合:

  1. Mutex<T?>(来自 Swift 的 Synchronization 框架,macOS 15 起可用)。这是一个用来保护返回值的锁变量。为什么不用简单的 var?因为 Task 会从一个线程写数据,而 semaphore.wait() 会从另一个线程读取数据。如果没有 mutex,会有数据竞争。Swift 6 会直接给你报错。

  2. DispatchSemaphore。用于信号同步:C 的线程在 .wait() 上阻塞,而 Task 在完成后调用 .signal() 解除阻塞。这里巧妙之处在于,信号量阻塞的是调用的线程(C 的),而不是 cooperative pool 的线程。Task 在自己的协作线程中运行自由。

  3. @_cdecl。Swift 编译器的一个属性,告诉它“用 C 名字风格导出这个函数”。有了它,fm_generate 会出现在 .dylib 的符号表中,是一个从任何语言都可以调用的普通 C 函数。

这套组合非常优雅,每个部分都能针对性地解决一个问题:mutex 保护共享内存,信号量同步线程,@_cdecl 曝露接口。没有魔法——只是一套功能严谨的底层操作。

为什么可行(以及为何它不是黑科技)

针对在现代 Swift 代码中使用 DispatchSemaphore 的批评是有道理的:如果你阻塞了 cooperative thread pool 的线程,可能会因为线程数量受限而导致死锁,因为被阻塞的线程没有空闲的线程来执行会调用 .signal() 的任务。

但在这种情况下,执行 .wait() 的是 C 的线程——这不是 cooperative pool 的一部分。Task 在指派给 Swift 的线程池后,完成它的 async 工作并发出信号。在这种设计中,不能 “饿死”(starvation)线程池,因为阻塞的线程不属于该池子。

这更像是一个餐馆的服务员(C 的线程)向厨房(Swift 的线程池)点了一个菜,然后在吧台等候。厨房有自己的厨师(线程池)忙着做饭,而不会因为服务员站在外面傻等而卡住。服务员也不会“霸占炉子”。

@_cdecl:一个鲜为人知的属性

关于 @_cdecl 的一点备注。注意下划线:@_cdecl,而不是 @cdecl。下划线表示这是“内部接口,不稳定,可能随时更改”。这个属性从 Swift 2 就有了,是将 Swift 函数导出为 C 函数的 de facto 标准方法。

Swift Evolution 批准了 SE-0495 提案 ,希望将 @cdecl(无下划线)正式纳入语言的一部分。在 Swift 6.2 中已经有了初步支持,而到 6.3 将随着新的一系列 @c 接口一并稳定。

这是否意味着 @_cdecl 将停止工作?短期来看不会。但如果你正在开发需要长期维护的东西,建议尽早迁移到你的工具链支持的 @cdecl。两者行为相同,仅名称不同。

强制分类:使用选项的技巧

这个库最有用的功能不是 fm_generate(自由文本生成),而是 fm_classify

fm_classify(
    "You classify git commit messages.",     // 系统提示
    "fix: handle nil in OAuth refresh",      // 要分类的文本
    "fix\nfeat\nrefactor\ntest\ndocs\nchore", // 可选的类别
    buffer, sizeof(buffer)
);

在内部,fm_classify 实际上在做一件 Apple 框架本身不直接支持的事情:生成了一个强制模型从多种选项中选择其一的提示符(prompt),然后验证其响应:

let constrainedPrompt = """
    \(promptStr)

    You MUST reply with exactly one of these values, nothing else:
    \(choiceList.joined(separator: "\n"))
    """

// 生成后,验证:
if choiceList.contains(where: {
    $0.caseInsensitiveCompare(raw) == .orderedSame
}) {
    return raw
}

// 回退逻辑:检查响应中是否包含选项
return choiceList.first {
    raw.localizedCaseInsensitiveContains($0)
} ?? raw

这是穷人版的 constrained generation:你不用 @Generableguided generation(需要定义一个 Swift struct),而是告诉模型 “只能选择这些”,然后检查它是否照做。如果模型回答 “The answer is fix” 而不是 “fix”,回退逻辑会检测到。

这是否如 @Generable 那样稳健?当然不是。但从 C 的角度,你无法定义一个 @Generable 的 struct。而对于简单分类——这占据了开发工具 80% 的使用场景——它已足够。

冒烟测试:9 项全通过

冒烟测试是一个 78 行的 C 文件,用来验证:

  1. fm_is_available() 返回 0 或 1(而非垃圾值)
  2. fm_init() 在模型可用时返回 0
  3. fm_classify 返回正字节数且缓冲区非空
  4. 分类功能能产生合理的响应
  5. fm_generate 能生成文本
  6. 缓冲区过小时返回 -3(截断),而非崩溃
  7. fm_generate_json 生成有效的 JSON

9 项断言,全部通过。在一台启用了 Apple Intelligence 的 Mac 上,make test 大约需 3 秒。如果设备上未开启 Apple Intelligence,生成测试会被跳过,仅验证 fm_is_available() 是否返回 0。该库在无支持硬件上不会崩溃——只会返回 -1。

为什么不用 HTTP 服务器?

这是一个显而易见的问题。Ollama、LM Studio、llama.cpp——它们都将模型暴露为本地 HTTP 服务器。为什么用 dylib C 会更好?

HTTP 服务器dylib C
延迟~10-50ms 开销(TCP + JSON 解析)~0(函数调用开销)
进程需要运行一个守护进程使用时加载
依赖需要空闲端口,HTTP 客户端一行 ctypesextern "C"
集成从任何语言通过 HTTP 调用从任何语言通过 FFI 调用
内存开销独立进程(~50-200MB)加载到当前进程

对于服务多个并发客户端的场景,HTTP 服务器是有意义的。但对于开发工具——如 pre-commit hook、本地 CI 脚本、菜单栏工具——不需要服务器。你需要的是一个函数:调用它,获取响应,然后它就消失了。

就像用 SQLite 记录购物清单,而不是安装 PostgreSQL。有时简单的解决方案更好。

如何在你的语言中使用

该库生成一个文件:libfoundationmodels.dylib,以及一个头文件:foundationmodels.h。任何支持 C FFI 的语言都可以使用:

  • Python: ctypes.CDLL("libfoundationmodels.dylib")
  • Rust: extern "C" { fn fm_classify(...) -> i32; }
  • Go: // #cgo LDFLAGS: -lfoundationmodels + import "C"
  • Ruby: FFI::Libraryffi_lib "foundationmodels"
  • Node.js: ffi-napinode-ffi

模式都很相似:加载 dylib,声明接口,调用函数,读取缓冲区。如果你会用 Python 的 ctypes 或 Rust 的 extern "C",你就已经知道怎么用了。

补充(但无法替代)

这个库并不能取代 Ollama 或 llama.cpp。它无法执行任意模型,不支持 LoRA,没有流式传输。它是一个简单的封装,让 Apple 已经给你的模型适配特定用例:开发工具需要快速分类、短文本生成或结构化 JSON,而无需搭建任何基础设施。

如果前一篇文章的主题是“你 Mac 上有一个免费的 LLM,你却不用”,那么这一篇就是“现在你可以从任何语言调用它,而不只是 Swift”。分层模型架构 的第一层刚刚向整个生态开放。

四个 C 函数。一个 dylib。没有服务器。没有 API key。没有依赖。有时候,最好的工具就是那个根本不需要说明书的。

试试吧

git clone https://github.com/frr149/libfoundationmodels
cd libfoundationmodels
make test       # 9 项冒烟测试(约 3 秒)
make examples   # C 示例
python3 examples/classify.py  # Python 示例

要求:Apple Silicon, macOS 26, Apple Intelligence 开启。如果没有 macOS 26,测试会被跳过,而不是失败。