做端侧 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 位 LPDDR5NX 系列低功耗版的带宽还要再缩水。模型权重、激活值、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 为什么量化格式选的是 NVFP4Jetson 端侧做 4-bit 量化并不是新鲜事但选择什么样的 4-bit 表示直接决定精度和速度的平衡。FP8 的表示范围充足但体积仍是 8-bitINT8 好实现可对 Agent 里频繁出现的代码片段、工具名、数字边界不够友好INT4 则因为全是整数动态范围很受限多轮推理时输出容易退化。NVFP4 是带指数的微型浮点格式常见有 E2M1 和 E1M2 两种子格式。它保留了浮点的动态范围却把存储精简到 4 比特。在 NVIDIA 的推理栈里NVFP4 可以从权重一直用到 KV 缓存配合 TensorRT-LLM 有比较完整的支持。我用顺手的理由其实很实际同样 3B 模型FP16 权重是 6GB 左右NVFP4 权重降到 1.5GB 上下KV 缓存也能按 2 倍压缩放入 NVFP4在 Orin 这种小内存设备上省下的空间能多撑几路并发。格式位宽主要优势在端侧 Agent 的短板FP16/BF1616精度最高实现简单内存带宽占用过大3B 模型就吃掉 6GBFP88动态范围好部署成熟相对 NVFP4 体积翻倍带宽压力仍高INT88通用加速兼容性好对分布跨度大的 token 不够稳NVFP44带宽省一半KV 变小浮点动态范围需要校准对敏感层需要保留 FP16INT44体积小带宽最小动态范围窄工具类输出容易跑偏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 python3.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_blocks4096, block_token_num64) 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_blocksprefix_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_cachingMLC-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.5s1.5s8.0s仅 KV 复用0.5s1.5s2.0sKV 复用 NVFP40.3s0.9s1.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 在被重复计算再决定量化精度要保留到哪一层。数据不会骗人预填充省掉的量摆在那里整套方案的收益也就出来了。
