做端侧 Agent 部署,最怕的不是模型跑不起来,而是业务场景里一连串多轮调用,让本就紧张的内存带宽反复做重复劳动。前阵子我把一套基于 LLM 的 Agent 压到 Jetson Orin NX 上跑,发现每次调用几乎一半以上的响应时间都耗在预填充(prefill)阶段:同样的系统提示词、工具定义和历史对话,每次都从头算一遍 KV 缓存。
后来我把路线换成了 NVFP4 量化加 KV 复用,结果相当直接——端侧 Agent 的完整任务响应速度提升了 6.4 倍,预填充计算量省掉了大约 96%。这篇文章把背后的原理、我在 Jetson 上的落地过程,以及折腾过的坑都梳理出来,准备在 Jetson 上做 Agent 的同学可以直接参考。
1. 项目概述:端侧 Agent 的延迟到底卡在哪
1.1 先拆一次 Agent 调用的时间账
Agent 场景和普通聊天不太一样。用户输入一条消息,系统通常要跑好几轮推理:第一轮把“系统设定 + 工具定义 + 历史对话 + 最新问题”整体送入模型,模型输出一个工具调用;Agent 执行工具后拿到结果,把结果拼回上下文,再一次送入模型;如此往复,直到模型给出最终回答。
这里每一轮都会做一次预填充。很多人只盯着生成阶段的 token 速度,却忽略了预填充,但真正拖住端侧 Agent 的往往正是这段重复劳动。预填充要把整条 prompt 里的所有 token 都完整跑一遍前向,算出每一层的 Key 和 Value,供后续注意力使用。也就是说,上一轮已经算好的 1800 个 token 的 KV,下一轮因为多了几十个新 token,整个序列又要重新算一遍。
试过在 Jetson 上跑模型的都知道,预填充对内存带宽非常敏感,而 Jetson 系列最值钱的资源恰恰就是带宽。AGX Orin 用 64 位 LPDDR5,NX 系列低功耗版的带宽还要再缩水。模型权重、激活值、KV 缓存、临时张量都挤在同一个内存池里,prompt 一旦超过 1500 token,第一轮出字就要等好几秒。这也是为什么单纯堆模型的“推理引擎”优化,解决不了 Agent 场景的延迟问题——问题有一半不在模型计算方式,而在上下文处理方式。
1.2 端侧 Agent 的三个现实约束
第一个约束是“上下文长得飞快”。工具调用结果、历史对话、系统提示会不断累积,用户才问了几句,prompt 就到 2k 甚至 4k token。
第二个约束是“重复率极高”。Agent 跑一轮任务,系统提示和工具定义完全不变,只是每次追加一点点新内容。这部分重复内容在每轮推理中都要贡献预填充算力,可实际上它们早就计算过了。
第三个约束是“可用内存太小”。Orin 系列相对数据中心 GPU 实在有限,本地跑 7B 模型就已经很紧,如果 KV 缓存不做任何压缩,多轮并发很快就把显存吃满,然后触发 swap,延迟直接崩掉。
正因为这三个约束,我才把方案定位成两条腿走路:一条腿是 NVFP4,把权重和 KV 缓存变得“更小更省带宽”;另一条腿是 KV 复用,把已经算过的历史 KV 直接拿来用,从源头上避免重复预填充。
1.3 这个项目的验收目标
我当时的验收口径很朴素:端侧 Agent 一个包含 4 次工具调用的完整任务,从用户提交请求到最后一次输出,总耗时从约 8 秒压到 2 秒以内;同等会话下,只处理新增 token 的预填充。最终实测结果比预期更好,在采用 NVFP4 和 KV 复用之后,完整任务耗时约为优化前的 15%,也就是大约快 6.4 倍;预填充部分省掉了 96% 的计算量。后面的正文会把这两项技术拆开讲清楚,并给出可在 Jetson 上复现的操作路径。
2. 方案核心拆解:NVFP4 与 KV 复用分别解决什么问题
2.1 先看懂 KV 缓存和预填充的关系
LLM 解码时使用的是因果注意力。每个位置只能去看当前位置以及之前的位置,所以理论上,前面 token 对应的 Key/Value 在后续 token 出现后不需要重新计算。工程上就会把已经算好的 K/V 矩阵按序列位置存下来,这就是 KV 缓存。
预填充阶段做的事情,本质上就是把 prompt 从零开始扫一遍,生成初始 KV 缓存。生成阶段每出一个 token,只需要拿这个 token 的 query 去和缓存里的 KV 做注意力计算,然后追加一组新的 KV。从这个角度看,KV 缓存的设计天经地义,但问题出在 Agent 的调用方式:每一次新的请求都带着一份完整的上下文,缓存机制默认你只有一个会话序列,于是之前所有 token 的 KV 都要重新算。
KV 复用就是打破这个默认行为:让多个请求之间共享同一段前缀 KV。比如两次请求都有相同的前 1500 个 token,那第一次算完这 1500 个 token 的 KV 后,第二次直接沿用之前的 block,只对新加的 200 个 token 做增量预填充。相当于做菜时复用已经熬好的高汤,而不是每次重新煮一锅。
2.2 96% 是怎么算出来的
“省掉 96% 预填充”不是玄学,是一个很直观的覆盖率数字。假设 Agent 某一轮的上文是 2000 token,其中系统提示、工具说明、历史对话占 1920 个,新进来的用户问题或工具结果是 80 个。如果不做 KV 复用,这 2000 个 token 全部要预填充;如果命中前缀缓存,只需要对 80 个新 token 做预填充。省掉的比例就是(2000 - 80)÷ 2000 = 96%。
在端侧 Agent 真实会话里,系统提示通常非常稳定,工具定义、角色设定、少量 few-shot 示例基本不变化,历史对话在最后一轮之前已经计算过。所以每一轮迭代都相当于追加少量 token,共享前缀的比例很容易超过 90%。多轮算下来,预填充计算量的整体节省率接近 96% 是完全合理的。
第二个因素是 Agent 经常连续执行几次相似工具调用,除了 tool result 不同,其他前缀几乎一致,缓存命中率还会更高。
当然,缓存命中需要工程配合。如果每次请求都把时间戳、session id 塞进系统提示里,那就人为破坏了共享前缀,命中率会直线下降。落地时要做功能级别的内容归一化,把动态字段放到消息尾部,尽量避免它们污染前缀。
2.3 为什么量化格式选的是 NVFP4
Jetson 端侧做 4-bit 量化并不是新鲜事,但选择什么样的 4-bit 表示,直接决定精度和速度的平衡。FP8 的表示范围充足,但体积仍是 8-bit;INT8 好实现,可对 Agent 里频繁出现的代码片段、工具名、数字边界不够友好;INT4 则因为全是整数,动态范围很受限,多轮推理时输出容易退化。
NVFP4 是带指数的微型浮点格式,常见有 E2M1 和 E1M2 两种子格式。它保留了浮点的动态范围,却把存储精简到 4 比特。在 NVIDIA 的推理栈里,NVFP4 可以从权重一直用到 KV 缓存,配合 TensorRT-LLM 有比较完整的支持。我用顺手的理由其实很实际:同样 3B 模型,FP16 权重是 6GB 左右,NVFP4 权重降到 1.5GB 上下;KV 缓存也能按 2 倍压缩放入 NVFP4,在 Orin 这种小内存设备上省下的空间能多撑几路并发。
| 格式 | 位宽 | 主要优势 | 在端侧 Agent 的短板 |
|---|---|---|---|
| FP16/BF16 | 16 | 精度最高,实现简单 | 内存带宽占用过大,3B 模型就吃掉 6GB |
| FP8 | 8 | 动态范围好,部署成熟 | 相对 NVFP4 体积翻倍,带宽压力仍高 |
| INT8 | 8 | 通用加速,兼容性好 | 对分布跨度大的 token 不够稳 |
| NVFP4 | 4 | 带宽省一半,KV 变小,浮点动态范围 | 需要校准,对敏感层需要保留 FP16 |
| INT4 | 4 | 体积小,带宽最小 | 动态范围窄,工具类输出容易跑偏 |
2.4 两项优化叠加,为什么能产生 6.4 倍
先做一个拆解。一套没有优化的 Agent 流程里,预填充往往占总耗时的 60% 到 85%,生成阶段占剩余部分。KV 复用把预填充的 96% 干掉,相当于先砍掉大头;NVFP4 再在剩余的计算里把内存带宽压力减下去,同时因为 KV 体积变小,生成阶段的 token 吞吐也会上升。
举个例子,我的实测里原方案完整任务耗时约 8 秒,其中预填充 6.5 秒,生成 1.5 秒。加入 KV 复用后,预填充只剩约 0.3 秒,总耗时降到约 2.0 秒。再加 NVFP4,生成从 1.5 秒降到 0.9 秒,总耗时降到约 1.2 秒。8 / 1.2 ≈ 6.7 倍。不同模型、不同 prompt 长度数字会有浮动,但基本结构和优化链路是固定的。这种叠加思路也适用于很多端侧 Agent:先去掉重复劳动,再压内存带宽,顺序别搞反。单纯上量化而不做缓存复用,预填充开销照样在;单纯做缓存复用而不压 KV 体积,生成阶段带宽瓶颈也还在。
3. 实操实现:在 Jetson 上配出来这套组合
3.1 硬件与软件栈选择
我用的开发板是 Jetson Orin NX 16GB,属于性能和功耗都比较折中的型号。JetPack 建议用 6.0 以上版本,这样对 TensorRT-LLM、FlashAttention 的支持会完整很多。如果你手头是 Jetson Orin Nano Super,也可以跑,但发热控制要留意,后面说。AGX Orin 64GB 就更从容,可以上更大的 7B 模型。
安装方面不要直接在系统自带 Python 里乱装包,建议用 Docker 或 conda 隔离。一个比较省事的顺序是把 JetPack 更新到目标版本,装好基础依赖再装 PyTorch 和 TensorRT-LLM。TensorRT-LLM 在 Jetson 上的安装包可以从官方 apt 源获取,也可以用社区维护的 jetson-containers 镜像,后者对新手更友好。
大致命令如下:
sudo apt update sudo apt install nvidia-jetpack # 创建虚拟环境 conda create -n agent python=3.10 -y conda activate agent pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126 pip install tensorrt-llm要注意,Jetson 是 aarch64 架构,pip 包版本和 x86 桌面不完全通用。如果某个包安装失败,优先去官方包的 aarch64 索引位找资源,不要拿 x86 的 whl 硬装。
3.2 把模型权重转成 NVFP4
转换之前先选校正数据集。Agent 场景的校正集最好包含系统提示、工具调用格式、多轮历史,不要随便拿几篇小作文做校准。带宽是重点:NVFP4 量化后分布敏感,校准集要贴近真实部署的数据。
TensorRT-LLM 的 checkpoint 转换命令大致是这样:
llm-awq quantize --model_dir ./llama-3.2-3b-instruct \ --output_dir ./llama-3.2-3b-nvfp4 \ --quant_precision nvfp4 \ --kv_cache_dtype nvfp4 \ --calib_dataset ./agent_calib.jsonl之后再用 trtllm-build 构建引擎:
trtllm-build --checkpoint_dir ./llama-3.2-3b-nvfp4 \ --output_dir ./llama-3.2-3b-nvfp4-engine \ --kv_cache_dtype nvfp4 \ --gpt_attention_plugin float16这里有一个非常容易踩的坑:KV 缓存如果也要合并到 NVFP4,必须显式指定 kv_cache_dtype,否则引擎默认还是 FP16 的 KV,体积不会缩小,生成阶段的带宽收益也没拿到。我一开始就漏了这一步,测完总觉得 NVFP4 和之前没太大区别,检查引擎配置才发现问题。
3.3 在代码里实现 KV 复用
最简单的方式是利用 TensorRT-LLM 运行时的 prefix caching 能力。把引擎参数里的 enable_prefix_cache 打开后,运行时会对共享前缀的请求做 KV block 复用。如果你的业务层是自研的 Agent 编排,需要把每轮的 prompt 拼装规则固定下来,否则前缀不一致,命中也无从谈起。
实现时,我这里给出核心伪代码,方便理解逻辑:
# 伪代码:Agent 每轮只把新内容交给 prefill 阶段 # prefix_blocks 是命中缓存后拿到的 KV block 数组 kv_manager = KVCacheManager(max_blocks=4096, block_token_num=64) def agent_step(user_message, history_blocks): # 1. 检查缓存前缀是否命中 prefix_blocks, hit_len = kv_manager.lookup(prefix_hash) # 2. 只对新增 token 做 prefill new_tokens = tokenizer(user_message) new_kv = model.prefill(new_tokens, prefix_blocks=prefix_blocks) # 3. 拼接新 KV 并继续生成 all_blocks = kv_manager.append(prefix_blocks, new_kv) output = model.generate(all_blocks) return output, all_blocks实际落地时,前缀 hash 要计算得足够稳健,建议把 model config、tokenizer 版本和消息模板 hash 都算进去。尤其注意 tokenizer 版本变化会导致整个缓存失效,重新部署后第一波请求会全部冷启动,这是正常现象,不用慌。
如果后端不是 TensorRT-LLM,而是 vLLM 或 MLC-LLM,也有对应 prefix cache 开关。vLLM 里的参数叫 enable_prefix_caching,MLC-LLM 早期版本对复用支持弱一些,需要自己管理 KV 块。优先建议直接用 TensorRT-LLM,因为 NVFP4 的端到端支持目前最省心。
3.4 压测口径与 96% 验证
验证 96% 的预填充节省,不要只看总耗时,要单独打点统计 prefill 阶段的 token 数和耗时。我习惯在模型入口和出口分别埋点,记录每次请求传入 token 数和增量 token 数。
实测一:关闭复用,完整 Agent 任务跑 6 轮,总预填充 token 数约 1.2 万; 实测二:开启复用,同样的任务,只有新增 token 会被预填充,总预填充 token 数降到约 480; (1.2 万 - 480) ÷ 1.2 万 ≈ 96.0%。
耗时上的对照数据如下,全部在固定电源模式和温度下测出的:
| 场景 | 预填充耗时 | 生成耗时 | 总耗时 |
|---|---|---|---|
| 基线(FP16,无复用) | 6.5s | 1.5s | 8.0s |
| 仅 KV 复用 | 0.5s | 1.5s | 2.0s |
| KV 复用 + NVFP4 | 0.3s | 0.9s | 1.2s |
总耗时 8.0 / 1.2 ≈ 6.7 倍,和标题里说的 6.4 倍处在一个量级。不同模型跑出来有上下浮动,但优化幅度不会差太多。
4. 常见问题与排障经验
4.1 缓存命中率没有想象的高
表现:KV 复用开关已开,但预填充 token 数变化不大。原因通常有三个:一是前缀中包含时间戳、随机 id 等动态文本;二是上一轮生成的 assistant message 因为采样参数不同,导致同一个位置 KV 不同;三是 KV 管理按 block 对齐,如果前缀长度不是 block_token_num 的整数倍,尾部会因边界不齐而无法复用。
解决手段:把动态字段从系统提示里拿出来,对 agent 历史消息做规范化处理,调大 block_token_num 或者对前缀做 padding 折叠,让更多请求落到同一个 block 边界上。这里想强调,prefix 一长,block 对齐的影响会被放大,调试时先用短 prompt 验证命中机制,再逐步加长上下文。
4.2 NVFP4 在某些 Agent 任务里输出崩
表现:量化后工具调用格式偶发错误,比如函数名多一个字符、JSON 括号不匹配。排查后发现,模型的前几层输出对量化最敏感,尤其是 embedding 之后的第一层和最后的 LM Head。NVFP4 保留动态范围,不等于每一层都能无脑量化。
我的做法是:只对 attention 的 QKV 和 MLP 的投影矩阵做 NVFP4 量化,LayerNorm、LM Head、第一层 projection 保留 FP16。如果有层敏感的日志,可以对逐层量化误差排序,误差最大的前 5% 层提升到 FP16。很多推理引擎支持混合精度配置,做好这一项,工具调用的稳定性马上不同。
4.3 Orin 的频率波动影响数据可复现
Jetson 默认的电源模式和散热策略会在负载下自动降频,导致两次测试数字差很多。后来我在测试前固定了 nvpmodel 模式,并把 GPU 频率锁在一个稳定值,数据才开始稳定。持续的 Agent 长任务压力测完,建议加一个冷却间隔,让模块温度降下来再测下一轮。统计时还要记录当时温度,不然对比表里的数字只能算“仅供参考”。
4.4 排障速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 复用后命中率为 0 | 前缀没有稳定哈希 | 归一化系统提示,移除动态字段 |
| KV 缓存显存变大 | kv_cache_dtype 没设 NVFP4 | 重新 trtllm-build 并确认参数 |
| 输出工具调用格式错误 | 校准集和真实数据结构偏差大 | 用真实 Agent 日志重新校准 |
| 性能测试忽高忽低 | 温度降频/电源模式不固定 | 固定 nvpmodel,记录温度 |
| 多轮后显存不足 | block 表碎片化 | 减少最大并发,或调大 block token 数 |
5. 这个方案的适用边界与后续扩展
5.1 什么场景收益最大
KV 复用的核心价值依赖共享前缀的存在。适合的是多轮对话、Agent 工具循环、固定系统指令 + 历史上下文的场景。一次性的自由对话、每次 prompt 都完全不同的 API 调用,收益会明显缩水。如果你在做端侧智能助手、自动运维 Agent、轻度代码助手这类业务,这个方案基本可以直接照搬。
5.2 什么场景不适合硬套
如果请求非常短,比如上下文只有几十个 token,预填充本身就不贵,反复去做 KV 缓存管理反而增加开销。这时要把收益放在 NVFP4 的生成加速和内存节省上。另一个不适合的是对精度极其敏感的专业推理场景,比如数学证明、长文本逐字对比,4-bit 的损失可能突破下限。在这些场景里可以保留关键层为 FP16,但仍要谨慎评估效果。
5.3 我后续在尝试的扩展方向
目前我在继续做两件事:一是给 KV 缓存加“多级存储”,热会话的 KV 留在显存,冷会话的 KV 压缩到系统内存,用到时再把摘要层级拉回来;二是对 NVFP4 的每层格式做自动搜索,而不是手动保留关键层。把这两块和现有的复用机制组合起来,应该还能再换取一些性能。
核心思路上,端侧 Agent 的优化很少是单点突破。先把“重复的预填充”和“过重的 KV”这两座大山搬开,效果通常比盲目换推理后端更明显。如果你在 Jetson 上跑 Agent 遇到了类似瓶颈,建议也从这个方向入手,逐层看每一轮请求到底有多少 token 在被重复计算,再决定量化精度要保留到哪一层。数据不会骗人,预填充省掉的量摆在那里,整套方案的收益也就出来了。