写《深入理解端侧 Agent》这个系列写到第四篇,后台一直有朋友在问同一个问题:模型推理已经跑通了,Agent 框架也能对话了,为什么一到真机部署就各种崩、各种慢、各种不可控?这个问题其实问到了点子上。端侧 Agent 的工程难度从来不在模型本身,而在怎么把一个“能跑通的 Demo”变成一个“能稳定运行、可维护、可排查”的系统。这一篇我们就接着上一篇,把 Agent 工程化的下半场聊透:决策编排、并发控制、上下文管理、沙盒隔离、异常恢复、上线前自查清单,每一块我都会结合自己踩过的坑来讲。
这篇内容适合三类人:已经跑通端侧模型推理、准备把单轮问答升级成真正会调工具、有记忆、能多步决策的 Agent 的开发者;想在手机/平板/边缘设备上把 Agent 做成产品的工程师;还有那些被“Agent 框架”搞得很迷茫、想弄清楚各个模块怎么配合的人。如果你是刚接触端侧 AI,建议先把系列前三篇关于模型部署和基础推理的内容过一遍,再回来看这篇,会顺很多。
我在实际做端侧 Agent 项目时的体感是:云端 Agent 的工程问题大多是“性能成本”问题,而端侧 Agent 的工程问题大多是“资源边界”问题。前者堆机器就能解决一大半,后者必须从架构层面就把边界想清楚。下面进入正题。
1. 端侧 Agent 工程化的核心挑战与设计思路
1.1 端侧不是“小一号的云端”,是另一套生态
很多第一次做端侧 Agent 的人,习惯性地把云端架构照搬过来:模型服务化、任务队列、分布式存储、动态扩容。这套东西在端侧一个都跑不通。端侧设备的基本盘是几 GB 内存、一颗带 NPU 但功耗受限的 SoC、随时可能断开的网络、以及完全不可控的用户使用场景。你无法假设设备在插电运行,也无法假设网络质量稳定,更无法假设后台进程不被系统回收。
换句话说,端侧 Agent 的第一个工程原则就是:所有依赖外部条件的假设,都要被当成风险来处理。模型推理可以本地做,那就要接受算力上限;工具调用需要网络,那就要做好离线降级;系统可能杀掉你的进程,那就要有会话恢复机制。我见过不少项目死在第一步,就是因为他们把“云端那套”原封不动搬到了端上,结果内存直接告警,后台进程被系统反复清理,用户体验为零。
这还引出第二个原则:端侧 Agent 的每个模块都要有“显式边界”。云端可以靠容器、微服务、K8s 来隔离边界,端侧没有这些基础设施,你必须通过代码架构人为地划定边界:推理归推理、决策归决策、工具执行归执行、记忆存储归存储。每个模块有自己的资源预算和失败处理策略,一个模块出问题不能拖垮整个 Agent。这听起来像废话,但实际落地时,很多人会把所有逻辑揉在一个大循环里,后续排查问题的时候痛不欲生。
1.2 工程化模块应该怎么拆
我的习惯是把端侧 Agent 的运行时拆成七个模块:推理运行时、Agent 内核(Harness)、策略与决策模块、工具执行层、记忆层、沙盒安全层、可观测性层。
推理运行时负责加载模型、跑推理、管理 KV Cache,对外暴露统一的调用接口,屏蔽底层不同推理引擎的差异。Agent 内核负责整个决策循环的调度,它不关心模型怎么实现,只关心“当前该调用模型、该执行工具、该结束回答”。策略模块是纯逻辑层,负责把模型产出的意图解析成可执行的动作序列。工具执行层做两件事:校验工具调用的参数,然后在一个受控环境里执行并返回结果。记忆层管理短期上下文和长期记忆的读写。沙盒安全层决定 Agent 能碰什么、不能碰什么。可观测性层记录整个链路的关键指标,方便事后回溯。
这套拆分方案不是拍脑袋来的,它的核心价值在于“替换成本”可控。推理引擎可以换,从 NCNN 换到 MNN 只需要改运行时适配;工具可以加,只要按统一 Schema 声明;模型可以升级,策略模块不需要改。我在前面的系列文章里提过一个观点:Agent 工程化做得好的标志,就是换一个模型、换一个工具、换一个硬件平台,核心调度代码一行不改。能达到这个状态,你的端侧 Agent 才算真正迈过了“工程化”的门槛。
2. 推理引擎选型与部署细节
2.1 端侧推理引擎怎么选
先解决最基础的问题:用什么推理引擎。我实测下来,目前端侧主流选项包括:MNN、NCNN、TFLite、ONNX Runtime,以及做 LLM 推理时绕不开的 llama.cpp 及其衍生项目。
如果你做的是传统 CV 或小模型任务,NCNN 和 MNN 都是成熟方案;如果你做的是端侧 LLM Agent,那核心引擎大概率是 llama.cpp 的移动端变体(比如通过 Android NN API 或 Metal 后端加速)。选型时我一般看四个维度:模型格式兼容性、硬件加速覆盖度、内存控制能力、社区活跃度。
以我最近一个项目为例,目标设备是两款 Android 手机,一个用骁龙、一个用天玑,分别支持不同的 NPU SDK。我的选择是:核心推理用 llama.cpp 的 CPU/GPU 后端保证可移植性,再用厂商 NPU SDK 做特定算子的加速通道。这里有个工程心法:不要追求所有算子都走 NPU,那会让适配成本爆炸,更合理的做法是识别出热点算子(比如大矩阵乘、注意力计算),把这几类算子做硬件加速,其余算子回退到 CPU/GPU。实测下来,这种“热点加速 + 冷路径回退”的策略,能用大概两成的工作量拿到七成的加速收益。
2.2 量化等级与内存预算的计算方法
内存是端侧 Agent 的生命线。我先给一个粗暴但好用的内存估算公式:模型权重内存 ≈ 参数量 × 每参数字节数。7B 参数模型,FP16 精度就是 14GB,这不是端侧设备能承受的;INT4/INT8 量化后约 3.5GB / 7GB。但要注意,这只是权重内存,实际峰值内存还要加上 KV Cache、激活值、临时 buffer。
KV Cache 的计算很多人会漏。公式近似是:KV Cache 大小 ≈ 层数 × 头数 × 头维度 × 序列长度 × 2(K 和 V)× 2 字节(FP16)× Batch Size。举个例子,一个 32 层、32 头、128 头维度的模型,上下文长度 4096,单 batch,KV Cache 大约是 32 × 32 × 128 × 4096 × 2 × 2 ≈ 2GB。这个数字在实际工程里是不能忽略的,特别是做长上下文 Agent 场景时,KV Cache 经常比模型权重还吃内存。
所以我现在做端侧 LLM 项目,内存预算会分成三块来算:模型权重、KV Cache(按目标上下文长度算)、以及推理工作区,然后乘以一个 1.3~1.5 的安全系数。如果目标设备内存是 8GB,系统和其他应用已经占了 3GB,那留给 Agent 的只有 5GB,这时候 DP 就能得出一个很关键的结论:模型权重、上下文长度、tokens 消耗速率三者只能取其二。这也就是为什么端侧 Agent 必须做上下文管理,不能像云端那样“无脑把历史全塞给模型”。
2.3 硬件适配的工程套路
硬件碎片化这件事,我在前面几篇聊过,这一篇说一点更偏工程的细节。我的做法是:在架构里定义一个“推理能力抽象层”,按优先级提供三种后端——NPU 后端、GPU 后端、CPU 后端,运行时根据设备能力和当前负载动态选择。
实用细节有两个。第一个是后端切换的预热问题,NPU 首次调用往往有几百毫秒的初始化开销,不能等到用户发起 Agent 对话时才初始化,需要在 Agent 进程启动后预热一个最小推理。第二个是回退策略的触发条件:默认优先 NPU,如果推理报错或超过设定耗时阈值,就回退到 GPU 后端;GPU 也不行再回退 CPU。回退要做到对上层无感,上层只需要拿到“推理结果或超时错误”,不需要关心当前是哪个后端在跑。我在代码里用一个统一的 Result 结构包装所有后端的返回,错误码和信息统一,这样无论是日志排查还是监控报警都非常顺。
3. Agent 编排、并发与工具调用工程化
3.1 决策循环的状态机实现
端侧 Agent 的“大脑”是一个决策循环,通常说的 ReAct 模式就是:模型生成思考 → 决定是否调用工具 → 执行工具 → 把结果回填给模型 → 再循环,直到模型给出最终答案。把这个循环用状态机实现,是工程化的第一步。
我的状态机会拆成这些状态:INIT(初始化)、THINKING(模型推理中)、TOOL_EXECUTING(工具执行中)、OBSERVING(观察工具结果)、RESPONDING(生成最终回复)、FAILED(异常终止)。每个状态之间有明确的转移条件,比如 THINKING 阶段模型输出一个 tool_call 动作,就转移到 TOOL_EXECUTING;如果模型输出的是最终答案,则转移到 RESPONDING。
为什么要用状态机而不是一个简单的 while 循环?因为状态机让“超时、中断、恢复”成了可管理的事情。每个状态都有超时限制,超时就走 FAILED,然后根据策略决定重试、降级或者终止。这对端侧尤其重要,因为端侧推理速度不稳定,模型可能在 3 秒内出结果,也可能因为系统负载飙到 20 秒。没有状态机的约束,一个卡死的循环就能让整个 Agent 无法响应。另外,状态机还方便做断点恢复——把当前状态和关键上下文持久化到本地,下次启动时可以直接从 FAILED 或中断点拉起,而不是从头再来。
3.2 Harness 与 Agent 的区别,以及并发控制
这个点很多人问,“Harness 是什么?”“Harness 和 Agent 到底啥区别?”我用一句大白话回答:Agent 是“大脑”,Harness 是“身体”。Harness 负责承载 Agent 运行所需的全部基础设施——模型生命周期、工具注册表、上下文窗口、安全沙盒、内存管理、调度器,而 Agent 只是 Harness 里的一个策略组件,负责产出决策。
理解了这句话,并发问题就清楚多了。端侧 Agent 要“扛并发”,不是像云端那样横向扩展实例,而是在一个进程内做好任务调度。现实场景是:用户在界面上同时发起了多个 Agent 任务(或者一个主 Agent 内部调多个子工具),如果所有任务同时跑推理,内存会瞬间被击穿。我常用的手段是引入一个“推理闸门”:一个全局信号量,限制同时进行的推理任务数量。在 Android 上,端侧 LLM 推理的内存峰值约 2~4GB,设备可用内存约 5~6GB,并发数设 1 是最稳妥的;如果模型较小(1B~3B),可以放宽到 2,但超过 2 的并发我基本不推荐。
实现上可以用一个阻塞队列,把 Agent 的推理请求按优先级排队,逐个放行。这里有个坑:CPU 密集型推理任务不能做“抢占式调度”,你已经把 A 任务的推理跑了一半,B 任务插进来只会让两个都变慢,还可能造成 OOM。所以排队要等待执行,不是协作式切换。另外,所有跨任务的共享数据(比如模型实例、tokenizer、上下文缓冲)都要加锁或做成单例,否则并发调用会导致推理结果错乱。我在生产环境遇到过模型推理结果“串话”的现象,排查到最后就是两个任务共享了同一个模型实例而没有加锁。
3.3 工具层设计:从裸命令到 Skill 化
工具调用是 Agent 的核心能力。但很多团队做工具层的时候,只做了一个很薄的壳:把模型的 tool_call 直接映射成一段代码执行,参数不校验、结果不回传、异常不处理,这等于让 Agent 裸奔。
我建议工具层做成三层:Schema 声明层、执行校验层、结果归一化层。Schema 声明层是关键,每个工具都要有清晰的 JSON Schema——工具名、入参类型、必填项、默认值。执行校验层负责两件事:参数类型强校验(防止模型输出一个 string 让代码去调 int 方法),和白名单检查(这个工具是否允许在当前上下文里执行)。结果归一化层把工具返回的任意格式转成一个标准消息结构,回传给模型。
再进一步的做法是引入 Skill。Skill 是比单个工具更高层的抽象,它打包了三样东西:触发条件(自然语言描述)、执行脚本(可能调用多个工具)、以及一段特殊的系统提示词。我的一贯观点是:如果没有 Skill 层,Agent 就只能做“单步调用”;有了 Skill 层,Agent 才能做“复杂任务编排”。比如“帮我把这篇网页内容整理成 Markdown 保存到本地”这件事,不是一个工具能做完了,它需要下载网页、清洗 HTML、转换格式、写文件、确认结果等一串动作,这就是一个 Skill。在工程实现上,Skill 本质上是一个微型的子 Agent 流程,复用同一个 Harness,但限定在更窄的上下文和工具集里。
4. 记忆、上下文窗口与数据落地
4.1 端侧记忆的分层方案
端侧 Agent 必须有记忆,但记忆不能是“把所有历史聊天记录都塞进上下文”。我的分层方案是三层记忆:工作记忆(Working Memory)、会话记忆(Session Memory)、长期记忆(Long-term Memory)。
工作记忆指当前决策循环中临时产生的数据,比如工具返回的当前结果,这部分由 Harness 管理,任务结束就清空。会话记忆指一次对话会话内的上下文,需要经过压缩、截断后存入本地的结构化文件或轻量数据库,比如 SQLite。长期记忆指跨会话持久化的用户偏好和事实知识,存成本地向量库或者尽量精简的关键词索引。
这里我专门说一下为什么长期记忆不建议一上来就搞向量库。很多人一听“记忆”,第一反应是“上向量数据库”。但在端侧,向量检索的内存和存储成本不低,而且记忆效果好不好,关键不在检索而在“哪些内容值得存”。我建议先用一个很朴素的方式起步:把用户明确表达的偏好(比如“我喜欢简短回答”“我住在杭州”)用键值对形式存 SQLite;一个固定规则触发“记住”操作;等数据量大了再引入轻量向量检索。这样能解决 80% 的记忆需求,却只花 20% 的工程成本。
4.2 上下文窗口管理的工程手段
上下文窗口是所有 LLM 的硬约束,端侧更是这样。每轮对话消耗的 token 数 = 系统提示词 + 历史会话 + 工具定义 + 模型输出。这个预算要有清晰的分配计划。我常用的分配比例是:系统提示词 10%,工具定义 15%,历史会话 55%,剩余给模型输出预留 20%。如果历史会话快超预算了,就启动压缩策略。
压缩策略我按优先级排序:第一,丢弃与当前任务无关的历史消息;第二,将过长的历史工具结果替换成一句话摘要;第三,用一个小模型对早期对话做摘要压缩;第四,在极端情况下丢弃最早的对话轮次但保留用户的最近意图。这套策略在代码里本质是一个“上下文管理器”模块,它在上一次推理结束后就主动执行压缩,而不是等 context overflow 报错了再被动处理。
这里有个很关键的细节:上下文管理器要有“记忆水印”。千万不要频繁重写上下文导致旧信息被反复裁剪,最终模型“忘了”用户最开始的需求。我会在系统提示词里注入一个轻量级的“任务状态摘要”,每轮更新,确保即使历史对话被裁剪,核心目标还保留着。
4.3 会话持久化与就地恢复
端侧进程随时可能被杀,会话持久化不能只靠“退出时保存”,一定要“隔几轮就落盘”。落盘的内容包括:当前状态机状态、已经压缩过的上下文摘要、关键临时状态、用户问的原始问题。我用 JSON 文件格式做存储,单文件不超过 1MB,放在应用私有目录下,写入频率控制在每 3~5 轮一轮。
恢复流程是这样:进程启动时检查是否有个“未完成会话”,如果有,读取状态机状态,结合上下文摘要,用一条系统消息告诉模型“之前我们正在进行任务 X,当前进度是 Y,继续”。做过几次之后你会发现,这个恢复机制带来的体验提升,比多花几千块换更好的模型明显得多。
5. 安全沙盒、权限控制与异常恢复
5.1 沙盒的本质是限制执行边界
Agent 安全是很多人忽略的工程模块,但恰恰是最不能省的。端侧 Agent 能调工具、能读本地文件、能访问网络,如果不对这些能力做限制,一旦模型被诱导输出恶意工具调用,后果就是一个 App 级别的灾难。
我强调一个基本认知:沙盒不是“虚拟化”,它是“边界管控”。端侧没有条件做完整的容器隔离,真正实用的是三层限制——文件系统白名单、网络白名单、系统调用白名单。
文件系统白名单:Agent 只能读写指定目录,其它路径一律拒绝。实现上我直接在工具层做校验,传入路径必须经过路径规范化和前缀检查,防止“../../”这种路径穿越。网络白名单:Agent 能访问的域名/IP 必须预先配置,其他请求一律拦截。系统调用白名单:需要执行 shell 命令的工具必须逐条列出允许执行的命令,禁止直接透传模型生成的命令字符串。这层要小心,我见过有项目把用户输入拼接进 shell 命令,结果一条“rm -rf”直接把沙盒目录清掉了。
5.2 崩溃隔离与超时兜底
端侧 Agent 的崩溃隔离,核心是把“不可信的、容易挂的”代码放进独立执行单元。我的做法是:工具执行放到独立的线程池里,每个工具执行设置最大超时(默认 5 秒,网络类工具可以放宽到 15 秒),超时就强杀线程并回收资源;对于访问第三方库的工具,甚至会放到一个子进程去执行,子进程崩溃不拖垮主进程。
再强调一次:模型推理和工具执行决不能放在同一个线程。模型推理是一个高内存、高 CPU 的操作,工具执行如果走网络,会发生未知的等待,这两个放一起,任何一个阻塞都会导致整个 Agent 假死。我在代码里始终维护两个独立线程池:推理线程池(核心线程数 1)和工具线程池(核心线程数 2~3)。
5.3 可观测性:崩溃之后必须能定位问题
可观测性是我做端侧 Agent 最受益的模块,没有之一。端侧没有线上日志系统,出了问题只能靠客户端日志回捞。所以我从一开始就把全链路日志当成一个一等公民模块来设计。
每个请求周期我会打五类关键日志:时间戳、事件类型(推理/工具调用/信息检索/记忆读写/错误恢复)、核心耗时、token 消耗数量、上下文长度。工具调用日志还会额外记录实际执行的工具名、参数摘要、执行结果状态码。为了控制日志体积,我会在内存里维护一个环形缓冲,只保留最近 N 条日志;当检测到异常行为(比如连续两次工具调用失败)时用对称加密的落盘方式永久保存。这样做的好处是,用户反馈问题时,可以回捞到一个完整的“Agent 事件时间线”,定位问题效率极高。
我在日志里还会记录一个很容易被忽视的指标:模型的“无效输出率”,也就是模型输出了一些既不是工具调用也不是最终答案的内容。这个指标如果高,说明工具 Schema 设计有问题或者系统提示词不够清晰,它远比单纯看“准确率”更能反映 Agent 系统的健康程度。
6. 落地实践:一套可照抄的工程化检查清单
6.1 上线前的逐项自查
这部分我把自己做端侧 Agent 上线前会过一遍的清单整理出来,做成一个“上线前强制检查表”。我每次发版前都会像过安检一样逐条打钩:
| 检查项 | 要求 | 说明 |
|---|---|---|
| 冷启动时间 | < 2 秒 | 从点击图标到可交互,超时直接体现在用户流失上 |
| 峰值内存 | 留有 20% 余量 | 记录真实设备峰值,与系统可用内存做对比 |
| 连续对话稳定性 | 50 轮不崩溃、不泄漏 | 用脚本模拟连续会话跑一轮 |
| 工具超时处理 | 所有工具都有超时兜底 | 不允许任何工具永久挂起 |
| 沙盒权限校验 | 越权调用 100% 被拦截 | 用一个恶意模型输出做对抗测试 |
| 会话恢复 | 杀掉进程后重启可恢复 | 模拟系统回收场景 |
| 功耗 | 连续对话 30 分钟温升可控 | 机身温度不得超过 45℃ 的舒适阈值 |
| 离线降级 | 断网时主流程可用 | 不依赖网络的技能要能正常执行 |
这份清单里,最常被团队忽略的是“连续对话稳定性”,很多人测试时只测单轮性能,结果用户一聊长就出问题。我强烈建议把“50 轮连续对话”写进 CI 流程,用脚本跑自动化测试,不通过就不允许合入。
6.2 实测踩坑记录
最后分享几个我在实际端侧 Agent 工程化里踩过、现在每次想都很疼的坑。
第一个坑:把所有历史聊天记录原封不动塞进上下文。最初做记忆功能时,我以为把最近 100 条消息全塞给模型,效果肯定好。结果是:每轮推理时间从 1 秒涨到 5 秒,内存峰值直接逼近设备上限,而且效果也没变好——模型被大量冗余信息干扰,反而忘了用户最开始的需求。后来改成“上下文管理器 + 摘要压缩”的方案,才真正解决。
第二个坑:工具执行不设超时。有次 Agent 调了一个外部搜索引擎工具,网络异常导致调用挂起,整个 Agent 假死了 30 多秒,用户以为应用崩溃了。排查后发现工具调用代码根本没有超时机制。打个比方:你安排一个人去办事,他不回来你就一直等着,整个团队都停摆。后来我给所有工具执行加上了超时和失败重试逻辑,这个问题消除。
第三个坑:推理线程池和工具线程池共用一线程。早期我把所有逻辑都丢进一个自定义线程池,结果推理在跑大模型时,一个网络工具请求突然进入,两个任务抢占资源,推理速度降低、工具超时。还是那句话:推理和工具连个池子都要分开。
第四个坑:并发执行多个 Agent 任务时共享模型实例不设锁。一次压测中出现了两个任务返回结果互换的诡异现象,查了半天才发现底层模型推理的输入缓冲被并发的另一个任务写坏了。最后用“全局推理信号量 + 单例模型实例 + 互斥锁”三件套解决。
这四个坑每一个都真实发生过,而且在 GitHub Issue 区和技术社区里你能看到无数人重复掉进去。写出来的目的就是让你少走点弯路。
做端侧 Agent 工程化这两年半,我真切体会到:端侧 Agent 的价值不在于它能跑多大的模型,而在于它能在极有限的资源里把“决策-行动-记忆-反馈”这个循环稳定地跑起来。对准备上手的朋友,我最后给一条经过实践检验的建议:先不要急着上多模态、上向量数据库、上复杂编排,而是先用最小的功能集——一个 1B 到 3B 的量化模型、五个以内工具、SQLite 记忆、稳定地跑完 50 轮真实对话。等你把这条主链路打磨得足够稳,再逐步加复杂能力。端侧 Agent 的工程化,永远是把“可靠性”放在“花哨”前面。