端侧 Agent 的工程化落地,最容易被低估的不是模型本身,而是模型之外那一整套"看不见的骨架"。我见过太多团队把 70% 的精力砸在选模型、调 prompt 上,结果上线后卡在并发、内存、容错、状态管理这些"脏活"上。这一篇接着上一部分往下聊,专门讲端侧 Agent 工程化里那些真正决定成败的环节:编排框架怎么选、并发怎么扛、容错怎么做、状态和记忆怎么管、安全边界怎么划。如果你正在把 Agent 往手机、车机、边缘盒子上搬,或者正在纠结自研还是用现成框架,这篇内容应该能帮你少走几个月的弯路。
1. 端侧 Agent 的编排框架:自研还是借力
端侧 Agent 和云端 Agent 最大的区别,是资源约束从"钱"变成了"物理极限"。云端你可以堆机器、加显存、横向扩容,端侧不行——手机就那么多内存,车机芯片就那个算力,边缘盒子可能连风扇都没有。这个约束直接决定了编排框架的选型逻辑。
1.1 编排框架到底在编排什么
很多人对"编排"这个词有误解,以为就是串几个 API 调用。实际上端侧 Agent 的编排层要处理的事情远比想象中复杂:
- 任务分解与调度:把用户一句模糊的指令拆成可执行的子任务,决定哪些本地跑、哪些必须走云端、哪些可以缓存复用。
- 工具调用管理:端侧能调用的工具是有限的——本地文件、传感器、系统 API、有限的几个网络接口。编排层要管好这些工具的注册、鉴权、超时和降级。
- 状态流转:Agent 不是无状态的函数,它需要在多轮交互中维护上下文、中间结果、执行进度。端侧内存紧张,状态怎么存、存多久、什么时候落盘,都是编排层要回答的问题。
- 资源仲裁:当多个 Agent 实例或者多个任务同时抢 CPU、内存、NPU 时,谁先谁后、谁让谁,需要一套仲裁机制。
我个人的判断是:如果你的端侧 Agent 只做单一场景、工具不超过 5 个、不需要多轮复杂状态,那自研一个轻量编排层完全够用,甚至更可控。但如果你要做的是通用型 Agent,工具生态会持续扩展,那借力成熟框架能省下大量重复造轮子的时间。
1.2 主流编排思路在端侧的适配性
目前业界常见的编排范式大致分几类,我按端侧适配度排个序:
| 编排范式 | 核心思路 | 端侧适配度 | 主要问题 |
|---|---|---|---|
| 状态机驱动 | 用显式状态机定义流程 | 高 | 灵活性差,复杂场景状态爆炸 |
| 图编排(DAG) | 节点+边定义执行图 | 中高 | 动态分支处理麻烦 |
| ReAct 循环 | 推理-行动-观察循环 | 中 | 容易死循环,token 消耗大 |
| 多 Agent 协作 | 多个角色分工 | 低 | 端侧资源扛不住 |
状态机驱动在端侧其实被严重低估了。它的好处是执行路径可预测、内存占用可控、调试极其方便。你想想,端侧设备最怕什么?最怕行为不可预测导致崩溃或者卡死。状态机虽然写起来啰嗦,但每个状态转换都是显式的,出了问题一眼就能定位。我做过一个车机语音助手项目,最初用 ReAct 循环,结果模型偶尔会陷入"我再想想"的死循环,在车机上直接把响应延迟拉到十几秒。后来改成状态机,把"理解意图→确认参数→执行→反馈"固化成状态,稳定性立刻上来了。
图编排(DAG)适合那种任务可以预先定义依赖关系的场景,比如"先查天气,再根据天气决定要不要提醒带伞"。但端侧 Agent 经常遇到动态分支——用户中途改主意、工具返回意外结果,这时候 DAG 的静态结构就不够用了。折中方案是用 DAG 做主干,用状态机处理局部动态。
ReAct 循环在端侧要慎用,核心原因是 token 消耗和延迟不可控。每次循环都要把完整上下文喂给模型,端侧模型本来就小,上下文窗口有限,几轮下来就爆了。如果非要用,一定要加最大循环次数硬限制和超时熔断。
1.3 自研编排层的核心模块拆解
如果你决定自研,我建议至少把这几个模块分清楚,别混在一起写成一坨:
任务解析器:负责把用户输入转成结构化任务。这里的关键是别让模型直接输出可执行代码或工具调用参数,而是输出一个中间表示(比如 JSON schema),再由解析器校验后转成实际调用。端侧模型能力有限,直接让它生成工具调用格式,出错率很高。
执行引擎:负责按编排逻辑执行任务。核心是可中断、可恢复。端侧设备随时可能被系统回收、被用户切走,执行引擎必须支持在任意步骤暂停并把状态序列化落盘。
工具注册中心:统一管理所有可调用工具。每个工具要有明确的输入输出 schema、超时时间、降级策略、权限声明。端侧尤其要注意权限——不是所有工具都该被 Agent 随便调,比如涉及支付、删除数据的工具,必须加二次确认。
资源监控器:实时盯着内存、CPU、电量。当资源紧张时,主动降级——比如把多轮对话压缩成单轮、把复杂推理降级成规则匹配。
# 一个极简的端侧任务执行引擎骨架示意 class EdgeAgentExecutor: def __init__(self, max_memory_mb=256, timeout_s=10): self.max_memory_mb = max_memory_mb self.timeout_s = timeout_s self.state_store = LocalStateStore() # 本地状态存储 def execute(self, task, context): # 1. 资源预检 if not self._check_resources(): return self._degrade(task, context) # 2. 状态恢复(支持中断续跑) state = self.state_store.load(task.id) or task.initial_state # 3. 带超时的执行循环 with timeout(self.timeout_s): while not state.is_done: step = self.planner.next_step(state) result = self._run_step_with_fallback(step) state = state.transition(result) self.state_store.save(task.id, state) # 每步落盘 return state.final_result这段代码的重点不在实现,而在每步落盘和资源预检这两个设计。端侧设备被系统杀掉是常态,不落盘就意味着用户回来发现任务丢了,体验极差。
1.4 借力框架时的取舍清单
如果你选择用现成框架,端侧场景下我建议重点考察这几个维度:
- 运行时体积:框架本身占多少内存?有没有大量反射、动态加载?端侧最怕框架比业务还重。
- 依赖树:会不会拖进来一堆用不上的库?尤其是网络库、序列化库,端侧能省则省。
- 离线能力:断网时能不能降级运行?端侧网络不稳定是常态。
- 可裁剪性:能不能只引入需要的模块,而不是整个框架?
- 调试支持:出问题时能不能看到完整的执行链路?端侧调试本来就难,框架再不透明就是灾难。
我的经验是,任何在云端跑得很爽的框架,搬到端侧都要先做一次"瘦身评估"。很多框架的设计假设是"资源无限、网络稳定",这两个假设在端侧都不成立。
2. 端侧 Agent 怎么扛并发:从资源账本算起
"AI Agent 怎么扛并发"是个高频问题,但端侧问这个问题,答案和云端完全不同。云端扛并发靠扩容,端侧扛并发靠精打细算。你得先算清楚一台设备到底有多少资源可以分给 Agent。
2.1 先算一笔端侧资源账
假设目标设备是一台中端手机,可用内存按 4GB 算(实际给 App 的更少),NPU 算力按 10 TOPS 算。一个端侧 LLM 推理任务大概占多少?
- 模型权重:一个 3B 的量化模型(INT4)大约 1.5-2GB。
- KV Cache:上下文 2048 token 时,大约几百 MB。
- 运行时开销:推理框架本身、临时张量,几百 MB。
也就是说,一个 3B 模型的单次推理就可能吃掉 2.5GB 以上。这意味着在中端手机上,你基本不可能同时跑两个模型实例。所谓"扛并发",在端侧的真实含义是:如何让多个请求排队共享同一个模型实例,同时保证响应时间可接受。
2.2 并发的三种真实形态
端侧 Agent 的"并发"其实分三种情况,处理方式完全不同:
第一种:多用户请求排队。比如车机上主驾和副驾同时说话。这种情况本质是请求队列 + 优先级调度。主驾的指令通常优先级更高(涉及驾驶安全),副驾的娱乐请求可以等。
第二种:单请求内的并行子任务。比如一个任务需要同时查本地日历和本地天气。这种情况可以并行,因为不涉及模型推理,只是工具调用。
第三种:多 Agent 实例并存。比如一个负责对话、一个负责后台监控。这种情况在端侧要极力避免,除非两个 Agent 都很轻量。
我踩过的一个坑是:早期设计时把"并发"理解成"同时跑多个推理",结果在真机上测试,两个请求一起来,内存直接爆,App 被系统杀掉。后来改成单推理实例 + 请求队列,虽然吞吐量上不去,但稳定性彻底解决了。
2.3 请求队列的设计要点
端侧请求队列和云端队列的设计目标不同。云端追求吞吐,端侧追求公平性和可预测性。几个关键设计:
- 优先级分级:至少分三级——紧急(安全相关)、普通(用户主动触发)、后台(预加载、缓存更新)。紧急请求可以抢占,后台请求可以被随时丢弃。
- 超时淘汰:排队超过一定时间的请求直接返回"忙",而不是让用户干等。端侧用户对延迟的容忍度比云端低得多。
- 批量合并:如果多个请求的意图相似,可以合并成一次推理。比如连续几条"打开空调"的指令,合并处理。
- 背压机制:当队列长度超过阈值,直接拒绝新请求,而不是无限堆积。端侧内存有限,堆积就是等死。
# 端侧请求队列的优先级调度示意 import heapq import time class EdgeRequestQueue: def __init__(self, max_size=8, max_wait_s=5): self.heap = [] self.max_size = max_size self.max_wait_s = max_wait_s self.counter = 0 def push(self, request, priority): # priority: 0=紧急, 1=普通, 2=后台 if len(self.heap) >= self.max_size: # 背压:尝试丢弃最低优先级的后台请求 if not self._evict_background(): return False # 拒绝新请求 self.counter += 1 heapq.heappush(self.heap, (priority, self.counter, time.time(), request)) return True def pop(self): while self.heap: priority, _, enqueue_time, request = heapq.heappop(self.heap) # 超时淘汰 if time.time() - enqueue_time > self.max_wait_s: continue return request return None这个队列的核心是优先级 + 超时 + 背压三件套。少了任何一个,端侧并发都会出问题。
2.4 推理实例的复用与隔离
端侧最宝贵的资源是模型实例,必须复用。但复用带来一个问题:不同请求之间的上下文会互相污染。比如用户 A 的对话历史不能泄漏给用户 B。
解决方案是逻辑隔离 + 物理复用:
- 物理上只有一个模型实例,KV Cache 复用。
- 逻辑上每个请求有独立的 session,上下文分开管理。
- 请求切换时,清空或切换 KV Cache 的对应部分。
这里有个细节:KV Cache 的清理是有成本的。频繁切换 session 会导致大量重复计算。所以实际工程中,通常会给每个 session 分配一个时间片,在时间片内连续处理同一 session 的多个请求,减少切换。
2.5 实测中的并发表现与调优
我在一台骁龙 8 Gen 2 的设备上做过测试,3B INT4 模型,单次推理延迟约 800ms。用上面的队列设计:
- 单请求:延迟 800ms,内存峰值 2.6GB。
- 3 个请求排队:平均延迟 1.2s,内存峰值 2.7GB(复用实例,内存几乎不涨)。
- 10 个请求排队:平均延迟 3.5s,开始有请求超时被淘汰。
结论是:端侧 Agent 的并发能力,瓶颈不在模型推理速度,而在内存和队列管理。把队列管好,单实例也能服务可观的并发量。
3. 容错与降级:端侧 Agent 的生存法则
端侧环境的不确定性远超云端。网络会断、内存会被回收、电量会告急、传感器会失灵。一个不能容错的端侧 Agent,上线即翻车。这一节专门讲容错和降级。
3.1 端侧 Agent 的故障分类
先把故障分清楚,才能对症下药:
| 故障类型 | 典型场景 | 影响 | 处理策略 |
|---|---|---|---|
| 模型推理失败 | 内存不足、NPU 异常 | 任务中断 | 降级到小模型或规则 |
| 工具调用失败 | 网络超时、权限拒绝 | 子任务失败 | 重试 + 替代方案 |
| 状态丢失 | App 被系统回收 | 上下文丢失 | 持久化 + 恢复 |
| 资源耗尽 | 内存/电量告急 | 整体不可用 | 主动降级 + 提示 |
| 输入异常 | 用户乱说、噪声 | 理解错误 | 澄清 + 兜底 |
这张表是我从多个项目里总结出来的,每一类故障都必须有明确的处理策略,不能靠"应该不会发生"来糊弄。
3.2 模型推理失败的降级链路
模型推理失败在端侧太常见了,尤其是内存紧张的时候。我建议设计一条多级降级链路:
- 一级降级:主模型推理失败,切换到更小的备用模型(比如 3B 切到 1B)。
- 二级降级:小模型也失败,切换到规则引擎或模板匹配。
- 三级降级:规则也搞不定,返回友好的兜底话术,并提示用户稍后重试。
关键是每一级降级都要对用户透明。用户不需要知道背后换了模型,只需要知道"系统还在正常工作"。
class ModelFallbackChain: def __init__(self, models, rule_engine, fallback_response): self.models = models # 按优先级排列的模型列表 self.rule_engine = rule_engine self.fallback_response = fallback_response def infer(self, input_text, context): for model in self.models: try: if not self._has_enough_memory(model): continue return model.generate(input_text, context) except (OOMError, InferenceError) as e: log.warning(f"Model {model.name} failed: {e}") continue # 所有模型都失败,走规则引擎 rule_result = self.rule_engine.match(input_text) if rule_result: return rule_result return self.fallback_response3.3 工具调用的重试与替代
工具调用失败的处理,核心是区分可重试和不可重试:
- 可重试:网络超时、临时性服务不可用。这类失败用指数退避重试,但端侧要限制重试次数(建议 2 次),因为用户等不起。
- 不可重试:权限拒绝、参数错误、资源不存在。这类失败重试没用,直接走替代方案或告知用户。
替代方案的设计很考验功力。比如"查天气"工具失败了,能不能用本地缓存的天气数据?"发消息"工具失败了,能不能先存草稿,等网络恢复再发?端侧 Agent 的价值,很大程度上体现在这些"退而求其次"的智慧上。
3.4 状态持久化的时机与粒度
状态持久化是端侧容错的重头戏。但持久化本身有成本(IO、电量),不能无脑存。我的经验是:
- 关键节点必存:任务开始、每个子任务完成、任务结束。这些节点丢了,任务就得重来。
- 中间状态选存:如果中间计算成本高,值得存;如果重算很快,可以不存。
- 存储介质分级:内存 > 本地文件 > 数据库。端侧优先用内存,内存不够再落盘。
持久化的粒度也要权衡。存太细,IO 频繁,耗电;存太粗,恢复时重算多,体验差。我一般按子任务粒度存,一个子任务完成就存一次。
3.5 一个真实的容错案例复盘
之前做一个端侧会议助手,功能是实时转录 + 摘要。上线后收到反馈:地铁里用着用着就卡死。排查发现,地铁里网络频繁切换,转录用的云端 ASR 接口经常超时,而代码里没有超时处理,请求一直挂着,把整个 Agent 卡住了。
修复方案分三层:
- 加超时:所有网络请求强制 3 秒超时。
- 加降级:网络不可用时,切换到本地轻量 ASR 模型(精度低但能用)。
- 加提示:降级时明确告诉用户"当前网络不佳,已切换到本地识别,精度可能下降"。
改完之后,地铁场景的可用性从"基本不可用"变成"能用,体验略降"。这个案例说明:端侧容错不是锦上添花,是雪中送炭。
4. 状态与记忆管理:端侧 Agent 的"记忆"怎么做
Agent 的记忆管理在云端是个热门话题,但端侧的记忆管理有完全不同的约束。云端可以存海量历史、做复杂检索,端侧不行——存储有限、算力有限、隐私要求还更高。
4.1 端侧记忆的分层设计
我建议把端侧记忆分成三层,各层用不同的存储和检索策略:
第一层:工作记忆(Working Memory)。当前对话的上下文,存在内存里,随用随取。容量最小(比如 2048 token),但访问最快。这一层的关键是压缩策略——上下文超了怎么办?我的做法是保留最近 N 轮完整对话,更早的用摘要替代。
第二层:短期记忆(Short-term Memory)。最近几天或几次会话的信息,存在本地文件或轻量数据库(如 SQLite)。这一层支持简单的关键词检索,用于"上次我们聊到哪了"这类场景。
第三层:长期记忆(Long-term Memory)。用户的偏好、习惯、重要事实,存在加密的本地存储里。这一层更新频率低,但价值高,是 Agent"懂你"的基础。
4.2 上下文压缩的实操技巧
端侧上下文窗口小,压缩是刚需。几个我常用的技巧:
- 摘要替代:把早期对话用模型生成摘要,摘要比原文短得多,但保留关键信息。
- 实体抽取:把对话里的关键实体(人名、时间、地点、任务)抽出来单独存,原文可以丢。
- 滑动窗口 + 锚点:保留最近 N 轮,同时保留几个"锚点"(比如任务开始时的目标描述)。
- 按需加载:不是所有历史都要塞进上下文,根据当前任务动态检索相关历史。
这里有个反直觉的经验:压缩不是越狠越好。压得太狠,模型丢失关键信息,回答质量下降。我一般会保留一个"压缩预算",比如原始上下文 4096 token,压缩后不低于 1024 token。
4.3 记忆的写入与遗忘策略
记忆不能只写不删,端侧存储有限,必须有遗忘机制。我的策略是:
- 重要性打分:每条记忆写入时打个分,基于用户显式强调、重复出现次数、任务相关性。
- 时间衰减:记忆的权重随时间衰减,但重要记忆衰减慢。
- 容量触发清理:当存储接近上限,优先清理低分、老旧的记忆。
- 用户可控:给用户一个"清除记忆"的入口,这是隐私合规的基本要求。
4.4 隐私与记忆的边界
端侧 Agent 的记忆涉及大量用户隐私,必须谨慎。几条红线:
- 敏感信息不落盘:密码、支付信息、身份证号这类,只在内存里短暂存在,用完即清。
- 本地加密:所有持久化的记忆必须加密,密钥存在安全区。
- 可审计:用户应该能查看 Agent 记住了什么,并能删除。
- 最小化原则:能不记就不记,能少记就少记。
5. 端侧 Agent 的安全边界与工程化收尾
Agent 安全是个大话题,端侧又有特殊性。这一节聚焦端侧场景下的安全工程实践,以及整个工程化体系的收尾。
5.1 端侧 Agent 的攻击面
端侧 Agent 的攻击面和云端不同,主要来自:
- 本地输入污染:用户输入、传感器数据、本地文件都可能被恶意构造。
- 工具滥用:Agent 调用的本地工具(文件、相机、通讯录)如果权限管理不当,会被滥用。
- 记忆投毒:恶意内容被写入长期记忆,影响后续所有决策。
- 模型窃取:端侧模型文件在用户设备上,存在被提取的风险。
5.2 输入校验与工具权限的最小化
端侧 Agent 的输入校验要做两层:
- 格式校验:输入是否符合预期格式,长度是否超限。
- 语义校验:输入是否包含明显的恶意指令(比如"忽略之前的指令"这类 prompt 注入)。
工具权限要遵循最小权限原则:每个工具只申请必需的权限,且在使用时二次确认。比如"删除文件"工具,不能一上来就给全盘删除权限,应该限定在特定目录,且删除前弹确认。
5.3 记忆投毒的防御
记忆投毒是端侧 Agent 容易被忽视的风险。防御思路:
- 写入审核:不是所有内容都能写入长期记忆,敏感来源的内容要过滤。
- 来源标记:每条记忆标记来源,检索时对低可信来源降权。
- 异常检测:如果某条记忆导致 Agent 行为异常,能追溯并清除。
5.4 工程化落地的检查清单
最后给一份端侧 Agent 工程化落地的检查清单,都是我踩坑后总结的:
- [ ] 模型实例是否单例复用?有没有内存泄漏?
- [ ] 请求队列是否有优先级、超时、背压?
- [ ] 所有网络请求是否有超时和重试上限?
- [ ] 关键状态是否持久化?能否中断恢复?
- [ ] 是否有至少三级降级链路?
- [ ] 记忆是否有容量上限和清理策略?
- [ ] 敏感信息是否不落盘?持久化是否加密?
- [ ] 工具权限是否最小化?是否有二次确认?
- [ ] 是否有完整的执行日志,便于端侧调试?
- [ ] 断网、低电、内存紧张场景是否都测过?
这份清单看着简单,但每一条背后都是真金白银的教训。端侧 Agent 的工程化,拼的不是谁的技术更炫,而是谁把细节抠得更死。
我个人在实际项目中的体会是:端侧 Agent 的难点从来不在模型,而在模型之外的那套工程体系。模型可以换、可以升级,但工程体系的坑,换多少个模型都绕不过去。把编排、并发、容错、记忆、安全这几块打扎实,端侧 Agent 才真正具备上线的资格。