LM Studio 0.3.4 集成 Apple MLX

LM Studio 0.3.4 集成了一个用于在 Apple Silicon Mac 上高效运行设备端大型语言模型 (LLM) 的 MLX 引擎

这里 下载适用于 Apple Silicon 的 LM Studio。继续阅读以了解有关 LM Studio 中 MLX 的更多信息。


在 M3 Max 上运行 Llama 3.2 1B 的速度约为每秒 250 个 token

👾 对设计和构建系统感兴趣? 我们正在招聘。请查看 这里 的职位空缺。

LM Studio 0.3.4 中的 MLX 支持包括:

  • 搜索和下载 Hugging Face 上任何受支持的 MLX LLM(就像您一直在使用 GGUF 模型一样)
  • 通过聊天界面或使用在 localhost 上运行的 类似 OpenAI 的本地服务器(通过代码)使用 MLX 模型
  • 强制 LLM 响应采用特定的 JSON 格式(感谢 Outlines
  • 使用 LLaVA 等视觉模型,并通过聊天或 API 使用它们(感谢 mlx-vlm
  • 加载和运行多个同时运行的 LLM。您甚至可以混合匹配 llama.cpp 和 MLX 模型!

这篇博文的其余部分将深入探讨 LM Studio 中 MLX 的技术细节。

特别感谢 Awni Hannun (MLX) 和 Rémi Louf (.txt/Outlines) 审阅这篇博文的草稿以及 mlx-engine 代码。

什么是 MLX?

…以及我为什么应该关心它?

MLX 是 Apple 推出的一款新的开源 AI/ML 软件堆栈,专门针对 Apple Silicon 进行了优化。它利用了 Apple M 芯片强大的加速硬件。

MLX 由 Apple 的工程师开发,并得到不断壮大的开发者社区的支持,它有望成为在 Mac 上运行设备端 AI 的极具竞争力的选择。

MLX 核心库 使用 C++ 编写,并通过社区支持的 PythonSwift 前端进行补充。

我们很高兴在 LM Studio 中推出 MLX 支持。这篇博文将介绍一些关于 MLX 的一般技术细节,以及 LM Studio 的 MLX 引擎的具体细节。

LM Studio 的 mlx-engine 是开源的

LM Studio 的 MLX 引擎是一个 Python 模块,它结合使用了以下几个包:

mlx-engine 在 MIT 许可下是开源的。代码库:https://github.com/lmstudio-ai/mlx-engine

在 LM Studio 中使用…Python 来运行 MLX?!

我们将 MLX 集成到 LM Studio 的旅程始于 Swift。虽然这种方法运行良好,但最终,以下设计目标使 Python 成为更好的选择。

设计目标 1:我们希望与社区一起迭代 MLX 引擎

  • 更多开发者和研究人员熟悉 Python

设计目标 2:我们希望能够在发布后尽快支持最新的模型和技术

  • Python 的 MLX 往往能更快地获得对新模型的支持

mlx-lm 支持添加到 LM Studio 需要能够以可移植、跨平台的方式部署和运行 Python 组件。理想情况下,我们还希望能够将这些组件与 LM Studio 应用程序中已经使用的现有 C/C++ 组件完全集成(最终排除了某些潜在的候选解决方案,例如 conda 环境)。

LM Studio 最初的 Python 运行时支持构建在 python-build-standalone 项目和 Python 虚拟环境之上,使用一个即将发布的实用程序,该实用程序支持创建一组集成的、可独立下载的 Python 应用程序环境,这些环境共享通用的运行时和框架层(毕竟,如果可以合理避免,没有人希望下载和安装多个 PyTorch 或 CUDA 的副本)。

此“堆叠虚拟环境”实用程序使用 CPython 解释器的“站点自定义”功能,以及对虚拟环境内容的一些预发布和安装后调整,以允许这些虚拟环境在机器之间可靠地传输,并且包含的应用程序启动模块使用 CPython 的 -m 命令行开关调用。

请关注 10 月晚些时候发布的更详细的技术公告。


小型深入探讨:mlx-engine 的一些功能

使用 MLX 运行文本生成模型

Python MLX 生态系统的一个关键部分是 mlx_lm。这个项目提供了一种简单的方法来使用 CLI 工具或几行 Python 代码来运行大型语言模型,例如:

from mlx_lm.utils import load, generate_step
import mlx.core as mx

def mlx_stream(prompt: str):
    model, tokenizer = load("/path/to/mlx/model")
    prompt_tokens = mx.array(tokenizer.encode(prompt))
    while True:
        yield generate_step(
            model=model,
            prompt=prompt_tokens
        )

for token in mlx_stream(prompt="Hello world!"):
    print(token, end="", flush=True)

让我们来看看generate_step的内部机制,以便更好地理解其工作原理。

def generate_step(*args, **kwargs):

    # --snip--

    def sample(logits):
        logprobs = logits - mx.logsumexp(logits)
        if temp == 0:
            token = mx.argmax(logits, axis=-1)
        else:
            if top_p > 0 and top_p < 1.0:
                token = top_p_sampling(logits, top_p, temp)
            elif min_p != 0.0:
                token = min_p_sampling(logits, min_p, min_tokens_to_keep, temp)
            else:
                token = categorical_sampling(logits, temp)

        return token, logprobs

    y = prompt
    tokens = None

    def _step(y):
        logits = model(y[None], cache=cache)
        logits = logits[:, -1, :]

        nonlocal tokens
        tokens = mx.concat([tokens, y]) if tokens is not None else y
        for processor in logits_processor:
            logits = processor(tokens, logits)

        y, logprobs = sample(logits)
        return y, logprobs.squeeze(0)

    y, logprobs = _step(y)
    while True:
        next_y, next_logprobs = _step(y)
        yield y.item(), logprobs
        y, logprobs = next_y, next_logprobs

我们可以看到这里发生的一些重要操作。

  • 模型使用其__call__方法进行评估。这将返回一个 logits 数组,其中每个元素对应于模型词汇表中的一个项目。logits 定义了词汇表中项目的概率分布。
  • 使用用户提供的采样参数从 logits 数组中选择(即 *采样*)一个标记。
  • 将采样到的标记返回给调用者。

让我们看看如何向这个生成循环添加用户会喜欢的功能。

使用 Outlines 实现结构化生成

让我们为生成器添加一个功能:用户可以请求生成器输出有效的 JSON。我们可以为此使用来自.txtOutlines

Outlines 支持从大型语言模型 (LLM) 进行结构化生成(例如,创建 JSON 输出)。此包支持mlx_lm运行时,我们将利用它。Outlines 通过将用户提供的 JSON 模式转换为正则表达式来完成其工作。请查看此标题模式。

{
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1
    }
  },
  "required": [
    "title"
  ]
}

Outlines 将该模式转换为以下正则表达式字符串

\{[ ]?"title"[ ]?:[ ]?"([^"\\\x00-\x1F\x7F-\x9F]|\\["\\]){1,}"[ ]?\}

这是该正则表达式字符串更易于理解(但不太精确)的版本:\{"title": ".{1,}"\}

使用此正则表达式字符串,Outlines 的生成循环如下所示

  • 评估模型。即,处理提示并为每个标记输出 logits。
  • 对于每个标记,评估采样它是否会违反正则表达式。如果是,我们将它的概率设置为零。我们说我们 *掩盖* 了 logits。
  • 使用掩盖后的 logits 采样一个标记。

mlx_lmgenerate_step 允许我们定义 logits 处理器,因此让我们定义一个处理器来掩盖 logits,以便输出与正则表达式匹配。

from outlines.processors.structured import JSONLogitsProcessor

class OutlinesJSONLogitsProcessor:
    def __init__(self, json_schema, tokenizer):
        self.logits_processor = JSONLogitsProcessor(json_schema, tokenizer)

    def __call__(self, tokens: mx.array, logits: mx.array):
        logits_1d = logits.flatten()  # convert to 1-dimensional array
        logits_1d = self.logits_processor(tokens, logits_1d)
        logits = logits_1d[None]  # convert back to original shape
        return logits

我们可以使用此对象的实例调用 mlx 生成步骤。

def mlx_stream(prompt: str):
    model, tokenizer = load("/path/to/mlx/model")
    prompt_tokens = mx.array(tokenizer.encode(prompt))
    json_schema='''{"type":"object","properties":{"title":{"type":"string","minLength":1}},"required":["title"]}'''  # define schema
    while True:
        yield generate_step(
            model=model,
            prompt=prompt_tokens,
            logits_processor=[OutlinesJSONLogitsProcessor(json_schema, tokenizer)]  # output valid json
        )

就是这样!现在,只要提供 JSON 模式,我们就可以生成 JSON。

使用 MLX 运行视觉模型

MLX Python 生态系统的另一个组成部分是mlx_vlm,这是一个用于运行视觉大型语言模型的包。这是mlx_vlm中的generate_step方法,为简洁起见已进行了编辑。

def generate_step(*args, **kwargs):
    def sample(logits: mx.array) -> Tuple[mx.array, float]:
        if temp == 0:
            token = mx.argmax(logits, axis=-1)
        else:
            if top_p > 0 and top_p < 1.0:
                token = top_p_sampling(logits, top_p, temp)
            else:
                token = mx.random.categorical(logits * (1 / temp))
        return token, logprobs

    # --snip--

    def _step(y):
        logits = model.language_model(y[None], cache=cache, mask=mask)
        logits = logits[:, -1, :]
        y, logprobs = sample(logits)
        return y, logprobs.squeeze(0)

    y = prompt
    logits = model(y, pixel_values, cache=cache, mask=mask)
    logits = logits[:, -1, :]
    y, logprobs = sample(logits)
    while True:
        next_y, next_logprobs = _step(y)
        yield y.item(), logprobs
        y, logprobs = next_y, next_logprobs

让我们比较和对比mlx_vlm实现和mlx_lm实现。

  • mlx_vlm评估使用model.__call__方法。第一次评估处理像素数据,后续评估使用底层语言模型。
  • mlx_vlm中的sample函数可用的采样方法比mlx_lm少。
  • mlx_vlm中没有logits_processor。

最好同时使用mlx_lm的 logits 处理和采样,以及mlx_vlm的视觉模型。让我们实现它!

我们将编写一个类,它将在第一次调用时评估像素数据,并在后续调用中使用语言模型。

class VisionModelWrapper:
    def __init__(self, vision_model, image_processor, pixel_values, mask):
        self.vision_model = vision_model
        self.image_processor = image_processor
        self.pixel_values = pixel_values
        self.mask = mask
        self.first_call = False

    def __call__(self, *args, **kwargs):
        if self.pixel_values is not None and not self.first_call:
            self.first_call = True
            return self.vision_model(self.input_ids, self.pixel_values, self.mask, **kwargs)
        else:
            return self.vision_model.language_model(*args, mask=self.mask, **kwargs)

现在,我们可以将其传递到mlx_lm.generate_step中。

def mlx_stream(prompt: str):
    # load and wrap the vision model
    vision_model_dict, tokenizer = load_vision_model("/path/to/mlx/vision_model", "/path/to/image")
    vision_model_wrapper = VisionModelWrapper(**vision_model_dict)

    prompt_tokens = mx.array(tokenizer.encode(prompt))
    json_schema='''{"type":"object","properties":{"title":{"type":"string","minLength":1}},"required":["title"]}'''
    while True:
        yield generate_step(
            model=vision_model_wrapper,
            prompt=prompt_tokens,
            logits_processor=[OutlinesJSONLogitsProcessor(json_schema, tokenizer)]
        )

现在我们可以用图像提示 LLM,并让它为我们创建标题!

Structured output image caption

使用 VLM 和结构化输出为图像添加标题

跨提示的 KV 缓存

跨提示的 KV(键值)缓存是一种优化技术,它使 LLM 引擎能够重用先前交互的计算结果。这可以大大缩短模型响应时间或“首个标记时间”。

在聊天场景中,KV 缓存特别有价值,因为在向模型发出生成请求时,很大一部分提示(聊天历史记录)在各个生成请求中通常是相同的。

示例

时间步 1 (T1) - 用户发送提示"Summarize this long article: <long article here...>"

{
    "User" : "Summarize this long article: <long article here...>"
}

时间步 2 (T2) - LLM 引擎对输入进行推理,计算模型权重和输入标记嵌入之间的较大矩阵乘法,以产生输出标记:"This article discusses the impact of..."

{
    "User" : "Summarize this long article: <long article here...>",
    "AI" : "This article discusses the impact of..."
}

时间步 3 (T3) - 用户发送提示"Are there any people mentioned in the article?"。将整个聊天历史记录发送到 LLM,以便为其提供适当的上下文以继续对话。

{
    "User" : "Summarize this long article: <long article here...>",
    "AI" : "This article discusses the impact of...",
    "User" : "Are there any people mentioned in the article?"
}

时间步 4 (T4) - LLM 引擎对输入(来自T1T2T3的所有标记)进行推理,计算模型权重和输入标记嵌入之间的较大矩阵乘法,以产生输出标记:"Yes, the article mentions several key figures, including..."

{
    "User" : "Summarize this long article: <long article here...>",
    "AI" : "This article discusses the impact of...",
    "User" : "Are there any people mentioned in the article?",
    "AI" : "Yes, the article mentions several key figures, including..."
}

KV 缓存

KV 缓存利用这样一个事实:到我们到达T3时,询问 LLM 关于"people mentioned in the article",我们已经在T1T2中执行了与T3中需要计算的那些相同的矩阵计算。

{
    # START OF PREVIOUSLY COMPUTED
    "User" : "Summarize this long article: <long article here...>",
    "AI" : "This article discusses the impact of..."
    # END OF PREVIOUSLY COMPUTED
    "User" : "Are there any people mentioned in the article?"
}

因此,如果我们将T1T2中的计算结果保存到KV 缓存中,并在T3时让引擎访问KV 缓存,则引擎只需要对提示的新部分"Are there any people mentioned in the article?"进行计算。

{
    KV CACHE,
    "User" : "Are there any people mentioned in the article?"
}

这可以大大缩短T4的响应时间。在我们的测试中,对于大约 3000 个标记的文章和Meta-Llama-3.1-8B-Instruct-4bit,如果没有 KV 缓存,T4的响应时间大约为 10 秒,而使用 KV 缓存后则仅为 0.11 秒。

我们当前的 MLX KV 缓存实现

在实现时,mlx-lm 向其 generate_step 函数公开了 cache_history 参数

def generate_step(
    *args,
    cache_history: Optional[List[Tuple[mx.array, mx.array]]] = None,
    **kwargs
) -> Generator[Tuple[mx.array, mx.array], None, None]:

通过传递正确的 cache_history(类似于上面的 **KV CACHE**),我们能够在我们的 MLX 引擎中实现 KV 缓存的初始版本。

我们通过调整 mlx-lm 的 PR 添加从文件加载 KV 缓存的功能 来实现这一点,在这个 PR 中,我们在缓存包装器内通过模型预处理提示。

    def process_prompt(self, prompt_tokens, cache_wrapper, generate_args) -> mx.array:
        """
        This method processes the prompt and adds its tokens to the cache history
        """

        # --snip--

        # prefill cache with prompt_tokens, except those that need to have a repetition penalty applied
        # (repetition penalty not currently possible for cached tokens)
        if "repetition_context_size" not in generate_args:
            generate_args["repetition_context_size"] = (
                20  # default value for mlx_lm.utils.generate_step
            )
        repetition_context_size = generate_args["repetition_context_size"]

        cache_history, generate_step_input = cache_wrapper.update_cache(
            prompt_tokens,
            num_tokens_to_exclude=repetition_context_size
        )

        generate_args["cache_history"] = cache_history

        return generate_step_input

cache_wrapper.update_cache(如上所示)利用 cache_prompt.py 分块填充缓存。

    # adapted from https://github.com/ml-explore/mlx-examples/blob/324184d670ec11916a5e92314171d497b312eefe/llms/mlx_lm/cache_prompt.py#L121-L137
    step_size = 512
    processed: int = 0
    while processed < len(tokens_to_process):
        # Here we evaluate the input prompt chunk by chunk to fill the cache
        chunk: mx.array = tokens_to_process[processed:processed+step_size]
        self.model(chunk[None], cache=self.cache)
        mx.eval([c.state for c in self.cache])
        self.tokens: mx.array = mx.concatenate([self.tokens, chunk]) if self.tokens is not None else chunk
        processed += chunk.size

现在缓存已经创建并保存到 generate_args["cache_history"] 中,我们可以简单地将 generate_argsgenerate_step_input 传递给 mlx_lm.utils.generate_step

    # `process_prompt` function from above
    generate_step_input = process_prompt(prompt_tokens, cache_wrapper, generate_args)

    max_tokens = generate_args.pop("max_tokens")

    for (token, _), n in zip(
        # generate_step_input is now just the uncached repetition penalty tokens
        # generate_args has "cache_history" member, set in `process_prompt`
        mlx_lm.utils.generate_step(generate_step_input, model, **generate_args),
        range(max_tokens),
    ):

这使得 generate_step 函数能够利用存储在 cache_history 中的先前计算结果,从而与对整个提示进行原始处理相比,大大缩短了响应时间。

然后,我们可以跨提示处理调用存储此 cache_history 对象,并在其上构建以保持聊天场景的响应性,即使在非常长的对话中也是如此。但是,至关重要的是要确保处理到 cache_history 中的标记在这样做时仍然对应于提示中的起始标记。有关此的更多信息,请查看 update_cache 函数 中的缓存重置行为。

LM Studio 0.3.4 的其他新功能

新增功能

  • 任务控制:Cmd+Shift+M 用于搜索模型,Cmd+Shift+R 用于管理 LM 运行时
  • 从 UI 设置结构化输出(JSON 模式)

错误修复

  • 修复长时间使用后出现黑屏的问题(参考:lmstudio-bug-tracker#98
  • 修复本地服务器仅端口 1234 可用的问题(参考:lms#80
  • 修复 Obsidian 中嵌入 API 无法工作的问题(参考:tracker#142
  • 修复 RAG 在文档处理过程中有时会失败的问题

更多内容