437 lines
20 KiB
Python
437 lines
20 KiB
Python
"""TUI 后端抽象:把界面与 MLX 推理引擎解耦。
|
||
|
||
界面只依赖 ChatBackend 接口,不 import 任何 MLX 符号,从而能用 FakeBackend 做无模型测试。
|
||
load / generate 都是阻塞调用,由 UI 层放到 worker 线程执行。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Callable, Protocol
|
||
from collections import OrderedDict
|
||
|
||
|
||
@dataclass
|
||
class GenResult:
|
||
"""一轮生成的汇总。"""
|
||
|
||
text: str # 完整回答(已截断 EOS)
|
||
n_tokens: int # 新生成 token 数
|
||
tok_per_s: float # 吞吐
|
||
stopped: bool # 是否被用户中断
|
||
|
||
|
||
class ChatBackend(Protocol):
|
||
"""聊天后端接口。所有方法阻塞,调用方负责放 worker 线程。"""
|
||
|
||
def load(self, on_status: Callable[[str], None]) -> None:
|
||
"""加载模型/权重;通过 on_status(msg) 上报进度。"""
|
||
...
|
||
|
||
def generate(
|
||
self,
|
||
messages: list[dict],
|
||
on_text: Callable[[str, int], bool],
|
||
) -> GenResult:
|
||
"""跑一轮生成。每步把「累计完整文本, 已生成 token 数」传给 on_text;返回 True 表示请求中断。"""
|
||
...
|
||
|
||
|
||
@dataclass
|
||
class FakeBackend:
|
||
"""测试用假后端:不加载模型,把预设回答按字符流式吐出。
|
||
|
||
delay > 0 时每字符间 sleep,用于 --demo 模式模拟真实吐字节奏;测试默认 0(不拖慢)。
|
||
"""
|
||
|
||
reply: str = "你好,这是一个测试回答。"
|
||
status_msgs: list[str] = field(default_factory=lambda: ["加载中(模拟)…"])
|
||
delay: float = 0.0
|
||
seen_messages: list[list[dict]] = field(default_factory=list)
|
||
|
||
def load(self, on_status: Callable[[str], None]) -> None:
|
||
for m in self.status_msgs:
|
||
on_status(m)
|
||
|
||
def generate(self, messages, on_text, tools=None, sampling=None) -> GenResult:
|
||
import time
|
||
|
||
self.seen_messages.append([dict(m) for m in messages])
|
||
acc = ""
|
||
for ch in self.reply:
|
||
acc += ch
|
||
if self.delay:
|
||
time.sleep(self.delay)
|
||
# 用字符数近似 token 数(假后端无真实分词)
|
||
if on_text(acc, len(acc)):
|
||
return GenResult(acc, len(acc), 0.0, stopped=True)
|
||
return GenResult(acc, len(self.reply), 0.0, stopped=False)
|
||
|
||
|
||
def _common_prefix_len(a, b) -> int:
|
||
"""返回两个 token id 序列的最长公共前缀长度。"""
|
||
n = min(len(a), len(b))
|
||
i = 0
|
||
while i < n and a[i] == b[i]:
|
||
i += 1
|
||
return i
|
||
|
||
|
||
def _reuse_prefix_len(cached_ids, new_ids) -> int:
|
||
"""可跨轮复用的前缀长度:仅当旧 cache 的 token 是新序列的**严格前缀**且新序列更长时,
|
||
返回该前缀长度(= len(cached_ids));否则返回 0 表示需全量重建。
|
||
|
||
只在严格前缀时复用,是为了永远「只延续、不回退」cache——Qwen3-Next 的线性注意力递归态
|
||
无法裁剪回任意历史位置;而 detokenize→retokenize 不一致、/reset、编辑历史等都会让公共前缀
|
||
短于旧长度,此时回退整段重建,绝不基于错位的 cache 续算。
|
||
"""
|
||
if not cached_ids or len(new_ids) <= len(cached_ids):
|
||
return 0
|
||
c = _common_prefix_len(cached_ids, new_ids)
|
||
return c if c == len(cached_ids) else 0
|
||
|
||
|
||
class MLXBackend:
|
||
"""真实后端:封装 _build_engine + mtp_generate。MLX 相关 import 全部延迟到方法内。"""
|
||
|
||
def __init__(self, args):
|
||
self.args = args
|
||
self._model = None
|
||
self._tok = None
|
||
self._drafter = None
|
||
# 跨轮复用:持久化上一轮的 main_cache 及其对应的 token 序列(prompt + 已入 cache 的生成 token)。
|
||
self._main_cache = None
|
||
self._cached_ids: list[int] = []
|
||
# 跨会话前缀快照:{head_key: (snaps, head_ids)},LRU。agentic 场景每个新会话
|
||
# 都重复同一段长 system+tools 前缀,快照后首轮 prefill 从 ~2600 tok 降到几百。
|
||
self._head_snaps: "OrderedDict[tuple, tuple]" = OrderedDict()
|
||
# (system 文本, 工具名) → 固定头部 token 数,见 _fixed_prefix_len
|
||
self._fixed_head_key = None
|
||
self._fixed_head_len = 0
|
||
# 流式反分词器(单例,每轮 reset)。建实例要铺一张 15 万项的 id→token 表,不能每轮重建;
|
||
# 生成由 server 的单飞锁串行,单例安全。
|
||
self._detok = None
|
||
|
||
def load(self, on_status: Callable[[str], None]) -> None:
|
||
from mlx_streaming.cli import _build_engine, _warmup
|
||
|
||
self._model, self._tok, self._drafter = _build_engine(
|
||
self.args, on_status=on_status
|
||
)
|
||
# 预热:把首轮的 kernel 编译 + 专家池填充开销移到加载阶段,避免第一条消息莫名卡很久。
|
||
on_status("预热中(编译 kernel + 填专家池)…")
|
||
_warmup(self._model, self._tok, self._drafter, self.args)
|
||
# 采样参数:读模型自带 generation_config(Qwen3-Next 官方要求 do_sample
|
||
# temp=0.7/top_p=0.8/top_k=20;贪心 argmax 会诱发幻觉/编造,见 2026-07 排查)
|
||
import json
|
||
import os
|
||
self._gen_cfg = {"temperature": 0.7, "top_p": 0.8, "top_k": 20}
|
||
try:
|
||
with open(os.path.join(str(self.args.model), "generation_config.json")) as f:
|
||
gc = json.load(f)
|
||
if gc.get("do_sample", True):
|
||
for k in ("temperature", "top_p", "top_k"):
|
||
if k in gc:
|
||
self._gen_cfg[k] = gc[k]
|
||
except OSError:
|
||
pass
|
||
|
||
def _generate_sampled(self, ids, on_tokens, max_tokens, sampling,
|
||
main_cache, cached_len):
|
||
"""采样生成(非投机):prefill 后逐 token 前向 + temp/top_p/top_k 采样。
|
||
返回 (produced, forwarded):forwarded = 已写入 cache 的生成 token 数,
|
||
供跨轮复用不变式(resident == len(ids)+forwarded)判定。
|
||
|
||
Qwen3-Next 的 generation_config 要求 do_sample;贪心 argmax 在 agentic 长
|
||
提示下会编造工具结果/幻觉工具名(实测)。代价:无 MTP 加速,decode 约 4-6 tok/s。
|
||
"""
|
||
import mlx.core as mx
|
||
from mlx_lm.sample_utils import make_sampler
|
||
from mlx_streaming.mtp.generate import forward_with_hidden, prefill_chunked
|
||
|
||
sampler = make_sampler(
|
||
temp=float(sampling.get("temperature", 0.7)),
|
||
top_p=float(sampling.get("top_p") or 0.0),
|
||
top_k=int(sampling.get("top_k") or 0))
|
||
|
||
def _logprobs(logits):
|
||
"""末位 logits → float32 归一化 log-probs,这是 mlx_lm sampler 的入参契约。
|
||
|
||
必须归一化:apply_top_p 里是 `probs = mx.exp(x)` 再与 `1 - top_p` 比累积和,
|
||
喂未归一化的 logits 会让 exp() 变成天文数字、累积和瞬间越过阈值 → top_p 完全失效
|
||
(只砍掉概率 <1e-12 的尾巴)。必须转 float32:lm_head 输出是 bf16(8 位尾数),
|
||
exp/cumsum/categorical 在 bf16 里做会把分布压得面目全非。
|
||
"""
|
||
lg = logits[:, -1, :].astype(mx.float32)
|
||
return lg - mx.logsumexp(lg, axis=-1, keepdims=True)
|
||
|
||
ids_mx = ids if isinstance(ids, mx.array) else mx.array([ids])
|
||
logits, _ = prefill_chunked(self._model, ids_mx[:, cached_len:], main_cache)
|
||
cur = _logprobs(logits)
|
||
produced = []
|
||
forwarded = 0
|
||
for _ in range(max_tokens):
|
||
nxt = int(sampler(cur))
|
||
produced.append(nxt)
|
||
if on_tokens(produced[-1:]):
|
||
break # 停止:当前 token 未写入 cache
|
||
logits, _ = forward_with_hidden(self._model, mx.array([[nxt]]), main_cache)
|
||
mx.eval(logits)
|
||
forwarded += 1
|
||
cur = _logprobs(logits)
|
||
return produced, forwarded
|
||
|
||
# ---- 前缀快照磁盘持久化 ----
|
||
# 目录:models/prefix_snapshots/{key}.safetensors + {key}.json(sidecar:head ids/模型名)。
|
||
# 首个会话 prefill 一次落盘;之后(含引擎重启后)同前缀会话直接读盘恢复,消灭"首次"。
|
||
|
||
def _snap_dir(self) -> str:
|
||
import os
|
||
d = os.path.join(os.path.dirname(str(self.args.expert_dir)) or ".", "prefix_snapshots")
|
||
os.makedirs(d, exist_ok=True)
|
||
return d
|
||
|
||
@staticmethod
|
||
def _snap_key(ids, head: int) -> str:
|
||
import hashlib
|
||
import numpy as np
|
||
return hashlib.sha1(np.array(ids[:head], dtype=np.int32).tobytes()).hexdigest()[:16]
|
||
|
||
def _snap_save(self, key: str, snaps, ids, head: int) -> None:
|
||
import json
|
||
import os
|
||
|
||
import mlx.core as mx
|
||
from mlx.utils import tree_flatten
|
||
|
||
from mlx_streaming import config as _cfg
|
||
|
||
flat = {}
|
||
metas = []
|
||
for ci, (st, meta) in enumerate(snaps):
|
||
metas.append(list(meta) if isinstance(meta, (tuple, list)) else (meta or ""))
|
||
for k, v in tree_flatten(st):
|
||
flat[f"c{ci}.{k}"] = v
|
||
path = os.path.join(self._snap_dir(), key)
|
||
mx.save_safetensors(path + ".safetensors", flat)
|
||
with open(path + ".json", "w") as f:
|
||
# kv_quant 决定 cache 类型(量化/非量化),快照跨配置复用会 500 或错算,必须校验
|
||
json.dump({"head": head, "model": os.path.basename(str(self.args.model)),
|
||
"kv_quant": _cfg.kv_quant(),
|
||
"metas": metas, "ids": list(ids[:head])}, f)
|
||
|
||
def _snap_load(self, key: str, ids, head: int):
|
||
"""磁盘命中返回 snaps(供 _cache_restore);缺失/模型不符/损坏返回 None。"""
|
||
import json
|
||
import os
|
||
|
||
import mlx.core as mx
|
||
from mlx.utils import tree_flatten, tree_unflatten
|
||
|
||
path = os.path.join(self._snap_dir(), key)
|
||
try:
|
||
with open(path + ".json") as f:
|
||
side = json.load(f)
|
||
if side.get("head") != head or side.get("ids") != list(ids[:head]):
|
||
return None
|
||
if side.get("model") != os.path.basename(str(self.args.model)):
|
||
return None
|
||
# kv_quant 配置必须一致:量化 KV 的快照装进非量化 cache(或反之)会崩/错算
|
||
from mlx_streaming import config as _cfg
|
||
if bool(side.get("kv_quant", True)) != bool(_cfg.kv_quant()):
|
||
return None
|
||
flat = mx.load(path + ".safetensors")
|
||
metas = side.get("metas") or []
|
||
n_cache = max(int(k.split(".", 1)[0][1:]) for k in flat) + 1
|
||
snaps = []
|
||
for ci in range(n_cache):
|
||
sub = {k.split(".", 1)[1]: v for k, v in flat.items()
|
||
if k.startswith(f"c{ci}.")}
|
||
meta = metas[ci] if ci < len(metas) else ""
|
||
if isinstance(meta, list):
|
||
meta = tuple(meta)
|
||
snaps.append((tree_unflatten(list(sub.items())), meta))
|
||
mx.eval([v for st, _ in snaps for v in tree_flatten(st)])
|
||
return snaps
|
||
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
||
return None
|
||
|
||
def _fixed_prefix_len(self, messages, tools) -> int:
|
||
"""算出 prompt 里「与用户问题无关」的固定头部长度(system + tools 渲染后的 token 数)。
|
||
|
||
做法是把同样的 system/tools 配一个哑 user 再渲染一次,取两次渲染的公共前缀——
|
||
分叉点就是 user 内容的起始位置。这样得到的边界与提问内容无关,是快照能命中的最大头部:
|
||
再多一个 token 就把问题本身包进快照,换个问法即失效;少了则白白重算已知不变的部分
|
||
(实测 system+4 工具 = 3972 token,固定切 2048 会让每个新会话多 prefill 1900+ token)。
|
||
|
||
结果按 (system 文本, 工具名集合) 缓存:每轮只在配置变化时付一次 tokenize。
|
||
"""
|
||
from mlx_streaming.cli import _encode_chat
|
||
|
||
head_msgs = [m for m in messages if m.get("role") == "system"]
|
||
if not head_msgs:
|
||
return 0
|
||
key = ("".join(m.get("content") or "" for m in head_msgs),
|
||
tuple(sorted((t.get("function") or {}).get("name", "")
|
||
for t in (tools or []))))
|
||
if getattr(self, "_fixed_head_key", None) == key:
|
||
return self._fixed_head_len
|
||
probe = _encode_chat(self._tok, head_msgs + [{"role": "user", "content": "\x00probe"}],
|
||
tools=tools)
|
||
real = _encode_chat(self._tok, head_msgs + [{"role": "user", "content": "\x01other"}],
|
||
tools=tools)
|
||
n = 0
|
||
for a, b in zip(probe, real):
|
||
if a != b:
|
||
break
|
||
n += 1
|
||
self._fixed_head_key, self._fixed_head_len = key, n
|
||
return n
|
||
|
||
def _snapshot_prefix(self, ids, fixed_head: int = 0):
|
||
"""跨会话前缀快照。内存命中/磁盘命中:恢复到新 cache 返回 (cache, head);
|
||
未命中:prefill 头部、深拷贝存内存 + 落盘后返回 (cache, head)
|
||
(首个会话不增加前向量,只是把整段 prefill 拆两段);短 prompt 或关闭时返回 (None, 0)。
|
||
|
||
head 取 min(PREFIX_SNAPSHOT_HEAD 上限, system+tools 实际长度):快照必须停在
|
||
用户问题之前才能被后续会话命中,而停得越晚省下的 prefill 越多。
|
||
"""
|
||
import mlx.core as mx
|
||
|
||
from mlx_streaming import config
|
||
from mlx_streaming.mtp.generate import prefill_chunked
|
||
from mlx_streaming.mtp.kv_cache import _restore as _cache_restore
|
||
from mlx_streaming.mtp.kv_cache import _snapshot as _cache_snapshot
|
||
|
||
head = config.prefix_snapshot_head()
|
||
if fixed_head > 0:
|
||
head = min(head, fixed_head)
|
||
head = min(head, len(ids) - 1) # 至少留 1 个 token 走前向,否则拿不到 logits
|
||
# 门槛按 head 自身算,不按「prompt 比 head 长多少」:快照省下的就是 head 这段,
|
||
# 与后面剩多少无关。头部 3972、问题只有 15 token 恰恰是收益最大的情形。
|
||
if head < 256:
|
||
return None, 0
|
||
key = self._snap_key(ids, head)
|
||
ent = self._head_snaps.get(key)
|
||
if ent is not None:
|
||
cache = self._model.make_cache()
|
||
_cache_restore(cache, ent)
|
||
self._head_snaps.move_to_end(key)
|
||
return cache, head
|
||
snaps = self._snap_load(key, ids, head)
|
||
if snaps is not None:
|
||
cache = self._model.make_cache()
|
||
_cache_restore(cache, snaps)
|
||
self._head_snaps[key] = snaps
|
||
self._head_snaps.move_to_end(key)
|
||
return cache, head
|
||
cache = self._model.make_cache()
|
||
prefill_chunked(self._model, mx.array([ids[:head]]), cache)
|
||
snaps = _cache_snapshot(cache)
|
||
self._head_snaps[key] = snaps
|
||
self._head_snaps.move_to_end(key)
|
||
while len(self._head_snaps) > config.prefix_snapshot_entries():
|
||
self._head_snaps.popitem(last=False)
|
||
try:
|
||
self._snap_save(key, snaps, ids, head)
|
||
except Exception: # noqa: BLE001 落盘失败不影响主路径(内存快照仍生效)
|
||
pass
|
||
return cache, head
|
||
|
||
def generate(self, messages, on_text, tools=None, sampling=None) -> GenResult:
|
||
import time
|
||
|
||
import mlx.core as mx
|
||
|
||
from mlx_streaming.cli import _encode_chat, _eos_set, _truncate_eos
|
||
from mlx_streaming.mtp.generate import mtp_generate
|
||
|
||
tok = self._tok
|
||
eos = _eos_set(tok)
|
||
ids = _encode_chat(tok, messages, tools=tools)
|
||
|
||
# 跨轮复用 KV/递归态:旧 cache 是本轮 prompt 的严格前缀时,只 prefill 新增后缀,
|
||
# 不重算整段历史(prefill 从 ∝历史长度 降到 ∝新消息长度)。否则全量重建。
|
||
cached_len = (_reuse_prefix_len(self._cached_ids, ids)
|
||
if self._main_cache is not None else 0)
|
||
main_cache = self._main_cache if cached_len else None
|
||
|
||
# 跨会话前缀快照:同一段长 system+tools 头部的新会话,跳过头部 prefill。
|
||
# 首个带该头部的会话在 prefill 头部后深拷贝一份状态存下(不产生额外前向,
|
||
# 只是把一个整段 prefill 拆成两段),后续会话直接恢复。
|
||
if main_cache is None:
|
||
main_cache, cached_len = self._snapshot_prefix(
|
||
ids, fixed_head=self._fixed_prefix_len(messages, tools))
|
||
|
||
if main_cache is None:
|
||
main_cache = self._model.make_cache()
|
||
cached_len = 0
|
||
|
||
# 流式反分词:必须逐 token 喂 StreamingDetokenizer,不能每步 tok.decode(全部已产 token)。
|
||
# Qwen 是 byte-level BPE,很多汉字/符号跨 2 个 token,整体 decode 到半个字符时会按
|
||
# errors="replace" 吐出 U+FFFD;而下游按字符数算增量(delta = full[len(prev):]),下一步
|
||
# U+FFFD 被真字符替换后长度不变 → 增量为空、真字符永久丢失,客户端就留下一堆「<E5A086>」。
|
||
# detokenizer 的 .text 只包含已完整的字符(未完成字节留在 _unflushed),恒为下一步的真前缀。
|
||
detok = self._detok
|
||
if detok is None:
|
||
detok = self._detok = tok.detokenizer
|
||
detok.reset()
|
||
produced_all: list[int] = [] # 已上报的正文 token(不含 EOS 及其后)
|
||
stopped = {"v": False}
|
||
hit_eos = {"v": False}
|
||
|
||
def on_tokens(new_ids):
|
||
for t in new_ids:
|
||
if int(t) in eos:
|
||
hit_eos["v"] = True
|
||
break
|
||
detok.add_token(int(t))
|
||
produced_all.append(int(t))
|
||
if hit_eos["v"]:
|
||
detok.finalize() # 冲出尾部残留字节(正常收尾,不会有半字)
|
||
if on_text(detok.text, len(produced_all)): # 用户按 Esc 请求中断
|
||
stopped["v"] = True
|
||
return True
|
||
# 命中 EOS:完整回答已生成,提前停止,避免引擎空跑到 max_tokens 让界面
|
||
# 长时间卡在「思考中」。EOS 属正常完成,不算中断。
|
||
return hit_eos["v"]
|
||
|
||
t0 = time.perf_counter()
|
||
if sampling is not None:
|
||
# 采样模式(server 默认,质量优先):非投机逐 token 采样,无 MTP 加速。
|
||
produced, forwarded = self._generate_sampled(
|
||
ids, on_tokens, self.args.max_tokens, sampling,
|
||
main_cache, cached_len)
|
||
stats = {"resident_tokens": len(ids) + forwarded}
|
||
else:
|
||
produced, stats = mtp_generate(
|
||
self._model,
|
||
self._drafter,
|
||
tok,
|
||
mx.array([ids]),
|
||
self.args.max_tokens,
|
||
K=self.args.k,
|
||
ids_mode=True,
|
||
profile=False,
|
||
on_tokens=on_tokens,
|
||
main_cache=main_cache,
|
||
cached_len=cached_len,
|
||
)
|
||
dt = time.perf_counter() - t0
|
||
|
||
# 持久化本轮 cache 供下轮复用。正常情况下 main_cache 恰好持有 `ids + produced[:-1]`
|
||
# (produced[-1] 为 pending 未入 cache)。但末步多 token 跨 max_tokens 会 over-commit:
|
||
# cache 领先于 produced,无法用已知 token 精确表述——此时禁用复用,下轮全量重建,绝不错算。
|
||
resident = stats.get("resident_tokens")
|
||
expected = len(ids) + len(produced) - 1
|
||
if resident == expected:
|
||
self._main_cache = main_cache
|
||
self._cached_ids = list(ids) + list(produced[:-1])
|
||
else:
|
||
self._main_cache = None
|
||
self._cached_ids = []
|
||
|
||
out_ids = _truncate_eos(produced, eos)
|
||
text = tok.decode(out_ids)
|
||
tps = len(out_ids) / dt if dt > 0 else 0.0
|
||
return GenResult(text, len(out_ids), tps, stopped=stopped["v"])
|