TechFlow Hub

探索AI前沿 | 智驾创新 | 技术进化

端侧 AI 学习路线:从算力基线到 Agent 运行时

想让 AI 在自己的手机、车机、开发板上跑起来,最常遇到的不是「跑不通」,而是跑通了,但慢得没法用、或者一开就 OOM。

网上大部分端侧 AI 的内容停在「怎么编译 llama.cpp」「怎么转 GGUF」,再往后就断了。断了的那一段恰恰是真正难的地方:为什么慢?慢在哪?改哪个参数才有用?

这条路线想补的就是这一段。它不是资料索引,而是一条从物理限制出发的路线——先算清楚这台机器最多能跑多快,再一层层往上问「这个环节为什么会吃掉带宽 / 内存 / 时间」。


路线图

阶段 你要建立的能力 核心问题
一、算力与带宽基线 知道自己手上这台机器「理论极限」在哪 这块板子最多能跑多少 GFLOPS?内存带宽多少?
二、推理的资源开销 能算出模型跑一次要读多少字节、占多少内存 一次 decode 为什么要读一整遍模型?
三、在真机上测量 能测、能对比、能归因 实测和理论差多少?差在哪?
四、量化与精度 知道压到哪一档开始坏 INT4 之后,哪个任务先坏?
五、多任务共享推理 理解 batching 与调度的物理实现 4 个任务抢一块 NPU,谁先被饿死?
六、Agent 的时间结构 拆开一次 Agent 请求的耗时构成 用户等的那 3 秒,花在哪了?

建议顺序阅读。每一节都尽量给出可以在自己机器上复现的实验,而不是结论。


一、先测你自己的机器

在讨论任何优化之前,必须先知道这台机器的天花板。

算术强度(Arithmetic Intensity) 是整条路线的地基:

1
算术强度 = 计算量(FLOP) / 数据量(byte)

当算术强度低于「算力 ÷ 带宽」这个比值时,程序受限于内存带宽,加多少算力都没用。这个比值叫平衡点(machine balance)。

实测方式很朴素:用 numpy 写一个矩阵乘法,从 128 一路测到 4096,记录每个尺寸的 GFLOPS。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np, time

def bench(n, dtype=np.float32, iters=20):
a = np.random.rand(n, n).astype(dtype)
b = np.random.rand(n, n).astype(dtype)
np.matmul(a, b) # 预热
t0 = time.perf_counter()
for _ in range(iters):
np.matmul(a, b)
dt = (time.perf_counter() - t0) / iters
return 2 * n**3 / dt / 1e9 # GFLOPS

for n in [128, 256, 512, 1024, 2048, 4096]:
print(n, round(bench(n), 1))

这里有个坑值得单独说:在没有 SIMD 内核的情况下,fp16 会比 fp32 慢一两个数量级。它走的不是加速路径,而是软件模拟。所以测真实算力天花板要用 fp32,别一上来就用 fp16 得出「这台机器只有 1.5 GFLOPS」这种结论。

参考数据(Mac mini / arm64 / Apple Silicon / numpy 2.5.4):
fp32 在 n=4096 时约 1850 GFLOPS,平衡点约 22 FLOP/byte。
这个数量级你会在后面反复用到。


二、推理到底在花什么

2.1 一次 decode 要读一整遍模型

大模型推理分两个阶段,资源特征完全不同:

  • prefill:把整段 prompt 一次吃完。矩阵乘是「大 × 大」,算术强度高,算力受限。
  • decode:一次只生成一个 token。矩阵乘退化成矩阵-向量乘(GEMV),每个权重只参与一次乘加,算术强度约 1 FLOP/byte——远低于平衡点 22。

结论很直接:decode 阶段必然是带宽受限的。 每生成一个 token,都要把全部模型权重从内存读一遍。

于是 token/s 的上限可以被带宽直接估出来:

1
理论 token/s ≈ 有效带宽 / 模型字节数

一个 1.5B 的 Q4 模型约 1 GB,手机内存带宽算 50 GB/s(实际有效值更低),理论上限就是几十 token/s。实测往往只有它的一半——差值就是你要解释的部分。

2.2 KV Cache 是上下文长度的物理代价

attention 每次都要和所有历史 token 做计算,为了不重复计算,历史 K/V 被缓存下来。它的体积是:

1
2
KV 内存 = 2 × layers × kv_heads × head_dim × seq_len × bytes
↑ K 和 V 各一份

注意其中 kv_heads 这一项。GQA(Grouped-Query Attention) 就是把它从 heads 降到中间值:用一点点精度,换取 KV 内存大幅下降。这就是端侧几乎必选 GQA 的原因——不是为了省那点算力,是为了省内存。

自己代入数字算一遍,比看任何解释都有效。比如 32 层、32 个 head、head_dim=128、fp16、8K 上下文,KV 是多大?如果有 4 个并发会话呢?

2.3 分页 KV:为什么 vLLM 要那么做

连续分配 KV 的老办法有个硬伤:你不知道这个请求会生成多长,只能按最大长度预留,预留的部分全是浪费。序列长度参差不齐时,浪费能到 60% 以上。

PagedAttention 借用了操作系统分页的思路:KV 按固定大小的 block 分配,用一张表把逻辑位置映射到物理 block。好处是浪费从「按最大长度预留」降到「最后一个 block 的尾部」。

但它在端侧不一定成立。至少两条理由值得先想清楚:

  1. 端侧内存本来就小,page 调度的元数据开销占比被放大;
  2. 端侧并发数通常是个位数,调度带来的收益可能抵不过复杂度。

三、在真机上把数字测出来

理论算完,必须落到真机。这一节的做法比结论重要。

要记的四个数:

指标 含义 为什么重要
TTFT 首 token 延迟 用户感知「有没有反应」
TPOT 每 token 时间 决定吐字速度
内存峰值 运行时最高占用 决定会不会被系统杀掉
有效带宽利用率 实测 token/s ÷ 理论 token/s 这是你的归因依据

最后一项是关键。如果不算它,你只能得到「有点慢」;算了它,你才能说出「慢是因为带宽没吃满」还是「慢是因为模型太大」。

可复现性是这一节的隐形验收标准:命令、版本、量化档、预热次数,全都要写下来。否则下周的你自己也复现不了。


四、量化:压到哪一档开始坏

同一模型跑 Q4_K_M / Q8_0 / FP16 三档,对比文件大小、内存峰值、TTFT、TPOT、输出质量。

要注意的是:Q4 比 Q8 快多少,不能简单归因于「更小所以更快」。回到第一节的算术强度——如果环境本身就是带宽受限,那模型变小带来的加速应当接近字节数的比例;如果实测远低于这个比例,说明瓶颈已经在别处(内存分配、解包开销、调度)。

这个判断能力比记住「Q4 比 FP16 快 2 倍」有用得多。


五、多个任务共享一块推理后端

真实的端侧场景里,很少只有一个请求。更多时候是:几个 Agent / 几个应用,抢同一块 NPU 或同一份模型权重。

5.1 Continuous batching

传统 batching 要等一批请求凑齐、一起进、一起出。Continuous batching 是迭代级别的调度:每生成一个 token 就重新看一次队列,谁完成了就退出、谁新来了就插入。

它把「吞吐」和「延迟」的矛盾暴露得很清楚。用 llama.cpp 的 --parallel 或本地 vLLM,跑 1/2/4/8 并发,会看到一条典型的曲线:

  • 吞吐随并发线性上升,直到某个拐点;
  • 拐点之后吞吐平了,而 P99 延迟陡增。

那个拐点在几个并发,由你的内存带宽和 KV 容量共同决定。

5.2 三个调度原语

一旦有并发,就需要策略。常用的三个原语:

  1. 准入控制(admission control):拒绝还不能接的请求,而不是让它排队排到超时;
  2. deadline 保护:给每个请求一个时间预算,快超时的优先;
  3. 降级:缩短上下文、降低量化档、减少输出长度。

顺序很关键。先准入还是先降级? 先降级会让已经超载的系统更慢(每个请求都变长尾),先准入能保住一部分请求的体验,代价是另一部分直接失败。没有标准答案,取决于你能接受多少失败率。

5.3 尾延迟

平均延迟没有意义。用户记住的是最慢的那一次。

所以要看 P99,并且要能回答:这个抖动来自排队,还是来自它自己?

做法是把一次请求拆成时间碎片——排队 / 推理 / 工具调用 / IO / 空转。哪一段占了 P99 的大头,优化哪一段。这一步和渲染里拆帧时间是同一套思路:先看见,再优化。


六、Agent 的时间结构

Agent 和单次推理的区别在于它是一个循环:想一步 → 调工具 → 拿结果 → 再想一步。

真正耗资源的是「想」还是「做」,很多人的直觉是错的。不上框架、手写一个两百行的最小循环就清楚了:

1
2
3
4
while not done:
action = llm(messages) # 想
result = tool(action) # 做
messages.append(result) # 上下文变长

跑一遍,记录每次循环的模型调用次数、工具调用次数、上下文增长曲线,你会看到两件事:

  1. 上下文是单调增长的,所以 KV 内存和延迟都会随轮次上升;
  2. 工具调用期间推理后端是空转的,这段空转在并发场景下可以分配给别的请求。

第二点就是「多 Agent 共享模型」的收益来源,也是端侧运行时真正要解决的问题。


七、这条线往下还能往哪走

  • 硬件侧:NPU / DSP 的算子支持程度、异构调度、内存带宽实测工具(如 Android 上的 perfetto);
  • 系统侧:进程隔离、cgroup 限制、温度与功耗墙对推理的影响;
  • 框架侧:llama.cpp 的图执行与线程分配、vLLM 的调度器实现;
  • 应用侧:多模态输入的预处理开销,往往被严重低估。

这些方向的内容我会陆续补进来。如果你在某一节卡住了,通常不是理解问题,而是缺一个可测量的数字——去把它测出来,问题往往自己就散了。


相关阅读