Workshop

实战工坊:在 48GB Mac 上用 SlotStream 跑 Qwen3.8-Flash-Next

5 min read ·

这两天 Hacker News 和 Reddit r/LocalLLaMA 上,一个很适合开发者跟进的话题是:有人尝试在 48GB Apple Silicon Mac 上用 SlotStream 思路运行 104GB 级的 Qwen3.8-Flash-Next,并给出大约每秒十几个 token 的演示。参考来源:Hacker NewsReddit r/LocalLLaMAGitHub TrendingHugging Face Daily Papers

先把预期说清楚:这不是“消费级笔记本秒变推理集群”。本地跑超大模型的价值,是让开发者在离线、私有、可调试的环境里验证 agent、RAG、代码生成和长上下文策略。真正要服务多人在线,仍然需要 GPU、推理引擎、队列和监控。但如果你的目标是把一个 agent 产品原型跑通,48GB Mac 加低内存加载已经足够有研究价值。

为什么这件事值得写成工坊

本地 LLM 过去的主线是量化:从 FP16 到 INT8、INT4,再到更激进的混合量化。问题是,量化只解决权重体积的一部分。模型越大,实际运行时还会被三件事卡住:加载峰值、KV cache、上下文里的工具噪声。

加载峰值指的是模型文件在进入可推理状态前,可能短暂占用比最终常驻更多的内存。KV cache 指的是每生成一个 token,模型都要保存注意力相关状态。上下文里的工具噪声则来自 agent 场景:一个普通问答只塞用户问题和系统提示,一个 agent 会塞工具 schema、历史计划、检索片段、执行观察和错误栈。

SlotStream 这类思路有意思的地方在于,它把问题从“模型能不能放进内存”改成“模型权重和运行状态能不能按需要分段进入工作集”。对本地开发者来说,这意味着我们需要写的不只是启动命令,还要写一套推理预算。

目标架构

这篇文章用一个保守架构做演示。底层模型服务可以替换成 llama.cpp、MLX、Ollama 的实验分支,或者支持分段权重加载的自定义 runner。上层我们写一个 agent 网关,负责四件事:

  1. 限制上下文窗口
  2. 压缩工具说明
  3. 记录每轮 token 预算
  4. 在本地模型过慢时降级到更短计划

目录可以这样组织:

local-qwen-agent/
  model-server.ts
  budget.ts
  tools.ts
  agent-loop.ts
  prompts.ts

下面的代码不绑定某个具体底层 runner。你只要把 generate 函数替换成自己的本地推理 HTTP 接口即可。

第一步:建立推理预算

本地大模型最怕无意识地把上下文塞满。我们先写一个预算器,把系统提示、工具说明、检索内容和历史观察分开计数。

type ContextPart = {
  kind: "system" | "tools" | "memory" | "retrieval" | "observation" | "user";
  text: string;
  priority: number;
};

const APPROX_CHARS_PER_TOKEN = 3;

function approxTokens(text: string) {
  return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
}

function fitContext(parts: ContextPart[], maxTokens: number) {
  const sorted = [...parts].sort((a, b) => b.priority - a.priority);
  const kept: ContextPart[] = [];
  let used = 0;

  for (const part of sorted) {
    const cost = approxTokens(part.text);
    if (used + cost <= maxTokens) {
      kept.push(part);
      used += cost;
    }
  }

  return {
    used,
    text: kept.map((part) => part.text).join("\n\n"),
  };
}

这里故意使用粗略估算。开发阶段不需要每次都调用 tokenizer,只要能稳定避免把窗口顶爆即可。生产环境可以换成模型对应 tokenizer。

第二步:压缩工具说明

Agent 工具说明经常比用户问题更长。很多框架会把完整 JSON schema 全塞进去,结果本地模型还没开始思考,窗口已经被工具占掉一大块。我们改用“工具卡片”。

type ToolCard = {
  name: string;
  purpose: string;
  args: string[];
  returns: string;
  risk: "read" | "write" | "external";
};

function renderToolCards(tools: ToolCard[]) {
  return tools
    .map((tool) => {
      return [
        `tool: ${tool.name}`,
        `purpose: ${tool.purpose}`,
        `args: ${tool.args.join(", ")}`,
        `returns: ${tool.returns}`,
        `risk: ${tool.risk}`,
      ].join("\n");
    })
    .join("\n\n");
}

工具卡片的好处是稳定、短、可缓存。模型第一次规划时只需要知道工具能做什么,不需要看到每个字段的完整 schema。真正调用工具前,网关再做结构化校验。

第三步:写本地模型客户端

假设你的本地 runner 暴露一个 OpenAI 兼容接口,我们写一个很薄的客户端。关键参数是 max_tokenstemperature,不要在本地 agent 里让模型无限生成。

type GenerateOptions = {
  prompt: string;
  maxTokens: number;
  temperature: number;
};

async function generate(options: GenerateOptions) {
  const res = await fetch("http://127.0.0.1:8080/v1/completions", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model: "qwen3.8-flash-next-local",
      prompt: options.prompt,
      max_tokens: options.maxTokens,
      temperature: options.temperature,
    }),
  });

  if (!res.ok) {
    throw new Error(`local_model_error:${res.status}`);
  }

  return res.json() as Promise<{ choices: Array<{ text: string }> }>;
}

如果底层是 MLX 或 llama.cpp,接口字段可能不同,但上层思想不变:每次调用都要有硬预算。

第四步:建立短回合 agent loop

本地大模型更适合短回合、强约束的 agent loop。不要让它一次计划二十步。每轮只允许做一件事:回答、调用工具、或者要求更多信息。

type AgentDecision =
  | { type: "answer"; content: string }
  | { type: "tool"; name: string; input: Record<string, unknown> }
  | { type: "clarify"; question: string };

function parseDecision(text: string): AgentDecision {
  try {
    return JSON.parse(text) as AgentDecision;
  } catch {
    return {
      type: "answer",
      content: text.trim(),
    };
  }
}

async function runAgent(userInput: string, tools: ToolCard[]) {
  const parts: ContextPart[] = [
    { kind: "system", priority: 100, text: "You are a local coding agent. Return JSON only." },
    { kind: "tools", priority: 80, text: renderToolCards(tools) },
    { kind: "user", priority: 100, text: userInput },
  ];

  const context = fitContext(parts, 6144);
  const result = await generate({
    prompt: context.text,
    maxTokens: 512,
    temperature: 0.2,
  });

  return parseDecision(result.choices[0]?.text ?? "");
}

这个 loop 很朴素,但适合本地模型。你可以把每轮工具观察压缩成三行:工具名、关键结果、错误状态。不要把原始 JSON 巨量返回直接塞回模型。

第五步:处理慢速和失败

本地推理每秒十几个 token 时,用户体验来自节奏管理。模型慢不可怕,可怕的是没有边界。建议增加三个策略。

第一,设置首 token 超时。如果模型 20 秒没有返回任何内容,直接中断并降级到小模型摘要。

第二,限制每个任务的最大轮数。比如工具调用最多 4 轮,超过后输出当前证据和未完成项。

第三,缓存工具卡片和常用系统提示。即使底层无法做真正 prefix cache,上层也能避免重复构造长 prompt。

伪代码如下:

type RunLimits = {
  maxTurns: number;
  maxPromptTokens: number;
  maxOutputTokens: number;
};

const LOCAL_LIMITS: RunLimits = {
  maxTurns: 4,
  maxPromptTokens: 6144,
  maxOutputTokens: 512,
};

SlotStream 部署时看哪些指标

不要只看 tokens per second。至少记录五类指标。

加载耗时:从进程启动到第一轮可推理。分段加载如果让启动时间大幅变长,就要评估是否值得。

峰值内存:包括模型权重、运行时、KV cache 和系统其他进程。macOS 内存压力变黄以后,体验会明显波动。

首 token 延迟:agent 产品里,用户更容易感知首 token,而不是总吞吐。

工具回合成本:每次工具调用后重新组织上下文,会增加延迟。工具越多,越需要摘要。

失败恢复时间:模型输出坏 JSON、工具失败、上下文过长时,系统能否快速回到可用状态。

结论

Qwen3.8-Flash-Next 这类模型在 48GB Mac 上可运行,真正说明的是本地 AI 开发边界正在外扩。开发者不用把所有实验都交给云端,也不用为了验证 agent 架构先买昂贵 GPU。

但本地大模型不是免费午餐。你要用工程手段换可用性:权重分段加载降低启动和常驻压力,短工具卡片减少上下文浪费,固定 token 预算保护 KV cache,短回合 agent loop 保证失败可控。

我的建议是先把它当成“私有 agent 实验台”。在本机验证工具选择、RAG 摘要、代码生成和权限策略;等流程稳定后,再决定哪些任务值得迁移到云端高吞吐推理。这样本地大模型的价值就不是炫技,而是让你更快发现 agent 系统真正的瓶颈。

Frequently asked questions

48GB Mac 真能跑 104GB 级模型吗?
可以跑低比特量化和分段加载版本,但不能按传统全量权重常驻方式理解。吞吐、上下文长度和并发都要让步,适合开发验证,不适合高并发生产。
SlotStream 的核心价值是什么?
它把权重加载变成更细粒度的流式过程,减少一次性内存压力。对本地大模型来说,这比只讨论量化位宽更贴近实际瓶颈。
为什么 agent 场景要特别限制上下文?
Agent 会反复塞工具描述、历史观察和中间计划,KV cache 很快膨胀。长上下文会显著拖慢本地推理,所以要做摘要和工具裁剪。
本文代码能直接生产使用吗?
示例是本地实验骨架,重点是预算控制和调用流程。生产环境还需要异常重试、权限隔离、日志脱敏和服务级限流。
如果只有 32GB 内存该怎么做?
优先换更小模型或更低比特量化,并把上下文限制到短窗口。不要硬追大模型名字,稳定响应通常比勉强加载更重要。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.