1. 端侧 Agent 工程化到底在解决什么问题
1.1 从“能跑”到“跑得住”的鸿沟
端侧 Agent 和云端 Agent 最大的区别,不在于模型大小,而在于资源边界是硬约束。云端你可以随便堆 GPU、堆内存、堆并发,端侧不行——手机就是 8GB 内存,车机可能只有 4GB 可用,IoT 设备更是只有几百 KB。所以端侧 Agent 工程化的核心命题只有一个:在有限资源下,让 Agent 稳定、可预期地完成多步任务。
我见过太多团队在 Demo 阶段跑通了一个端侧 Agent,觉得“成了”,结果一上真实设备就崩。崩的原因五花八门:内存碎片导致第三次推理就 OOM、工具调用返回超时后整个链路卡死、多轮对话到第五轮上下文爆了、模型输出格式偶尔跑偏导致解析器直接抛异常。这些问题在云端可以用“重启一个 Pod”解决,在端侧不行——用户不会接受你的 App 每隔五分钟闪退一次。
所以工程化要解决的不是“能不能做出来”,而是“能不能在 2GB 可用内存、单核 CPU、没有网络兜底的条件下,连续稳定运行 30 分钟以上”。这个目标决定了后面所有的架构选型和实现细节。
1.2 端侧 Agent 的四个工程化支柱
我把端侧 Agent 工程化拆成四根支柱,缺一不可:
- 资源调度:模型加载、推理、工具执行三者共享同一块内存池,必须做统一预算和回收。你不能让模型占着 1.5GB 不放,然后工具调用再申请 500MB。
- 编排容错:Agent 的本质是“LLM 决策 + 工具执行”的循环。端侧环境下,LLM 可能输出非法 JSON,工具可能超时,循环可能死锁。每一步都要有超时、重试、降级。
- 状态管理:多轮对话的上下文、工具调用的中间结果、Agent 的 scratchpad,这些状态在端侧不能无限增长。必须有截断、摘要、持久化策略。
- 可观测性:端侧没有云端那么方便的日志系统,但你必须能在出问题时定位到是哪一步、哪个工具、哪次推理出了岔子。轻量级埋点是刚需。
这四根支柱里,编排容错是最容易被低估的。很多人觉得“我调个 LLM,解析个 JSON,执行个函数,能有多难?”——难就难在端侧 LLM 的输出稳定性远不如云端大模型。一个 3B 参数的端侧模型,在量化到 4bit 之后,输出格式遵循能力会明显下降。你让它输出{"tool": "weather", "city": "北京"},它可能给你输出{"tool": "weather", "city": "北京"——少一个右花括号。云端你可以重试,端侧重试一次就是几百毫秒的延迟和额外的电量消耗。
2. 编排框架的选型逻辑与端侧适配
2.1 为什么不能直接搬云端编排框架
LangChain、LlamaIndex 这些云端编排框架,设计假设是“网络可靠、内存充足、CPU 随便用”。它们的抽象层次很高,一个 Chain 可能嵌套五六层,每层都有回调、有日志、有异常处理。在端侧跑,光是框架本身的初始化就可能吃掉几百毫秒和几十 MB 内存。
更致命的是,这些框架大量依赖动态反射和运行时类型检查。在 Android 上,反射调用比直接调用慢一个数量级;在 iOS 上,Swift 的运行时虽然快一些,但频繁的协议 witness table 查找也会累积成可观的开销。端侧 Agent 的编排层必须薄——薄到你能一眼看清每一步在干什么,薄到没有隐藏的内存分配。
我的经验是:端侧 Agent 的编排框架,核心代码不应该超过 2000 行。超过这个数,说明抽象过度了。你需要的是一个状态机 + 工具注册表 + 超时控制器,而不是一个通用编排引擎。
2.2 自研轻量编排器的核心设计
我推荐的自研编排器结构是这样的:
AgentLoop: 1. 构建 prompt(系统提示 + 历史 + 当前输入) 2. 调用 LLM 推理(带超时) 3. 解析输出(JSON 解析 + 格式校验) 4. 如果输出是工具调用: a. 查找工具注册表 b. 执行工具(带超时 + 重试) c. 将结果追加到 scratchpad d. 回到步骤 1 5. 如果输出是最终答案: a. 返回答案 b. 清理 scratchpad这个循环看起来简单,但每个环节都有坑。比如步骤 2 的 LLM 推理,在端侧你可能用的是 llama.cpp 或者 MLC-LLM,它们的 API 是流式的。你不能等整个输出生成完再解析——那样延迟太高。你需要流式解析:一边生成 token,一边尝试解析 JSON。一旦解析成功,立即中断生成,执行工具。这能省下大量时间。
再比如步骤 3 的 JSON 解析,端侧模型经常输出带 markdown 代码块的 JSON,比如:
```json {"tool": "weather", "city": "北京"}你的解析器必须能处理这种情况。我的做法是先用正则提取 `{...}` 或 `[...]` 之间的内容,再做标准 JSON 解析。如果解析失败,尝试修复常见错误:补全缺失的引号、补全缺失的括号、把单引号替换成双引号。修复失败才走重试或降级。 ### 2.3 工具注册表的设计要点 工具注册表是端侧 Agent 的“手脚”。每个工具就是一个函数,有名字、有参数 schema、有超时时间、有重试策略。在端侧,工具执行必须**同步且可中断**。你不能让一个工具调用阻塞整个 Agent 循环超过 5 秒——用户会以为 App 卡死了。 我的工具注册表长这样: ```kotlin data class Tool( val name: String, val description: String, val parameters: Map<String, ParamSpec>, val timeoutMs: Long = 3000, val maxRetries: Int = 1, val execute: (Map<String, Any>) -> ToolResult )关键点是timeoutMs和maxRetries。端侧工具大多是本地操作(读文件、查数据库、调系统 API),正常情况下 100ms 内完成。如果超过 3 秒,大概率是出了问题(比如文件锁死、数据库损坏),这时候重试一次就够了,再不行就返回错误让 LLM 决定下一步。
注意:端侧工具执行千万不要放在主线程。Android 上用
Dispatchers.IO,iOS 上用DispatchQueue.global()。但也不能随便开新线程——线程切换本身有开销,频繁的工具调用会导致线程池爆炸。我的做法是维护一个固定大小的工具执行线程池(通常 2-4 个线程),所有工具调用都提交到这个池里。
3. 容错机制:让 Agent 在端侧“摔不死”
3.1 LLM 输出异常的三种典型场景
端侧 LLM 的输出异常,我归纳为三类:
第一类:格式跑偏。你要求输出 JSON,它输出了一段自然语言解释,最后才跟一个 JSON。或者 JSON 里混入了注释。或者用了中文引号“”而不是英文引号""。这类问题占所有异常的 60% 以上。
第二类:工具名幻觉。LLM 编造了一个不存在的工具名,比如你注册了get_weather,它输出了fetch_weather。或者大小写不一致,GetWeathervsget_weather。这类问题占 20% 左右。
第三类:参数缺失或类型错误。工具需要city参数,LLM 没给。或者需要整数,LLM 给了字符串"25"。这类问题占 15% 左右。
剩下的 5% 是更诡异的:输出截断(生成到一半达到 max_tokens)、输出重复(陷入循环)、输出乱码(tokenizer 问题)。
3.2 分层容错策略
针对这三类异常,我的容错策略是分层的:
第一层:Prompt 工程。在系统提示里明确给出 JSON schema,并给出一个 few-shot 示例。示例要简单、格式严格。比如:
你必须严格按照以下 JSON 格式输出,不要添加任何其他文字: {"tool": "工具名", "parameters": {"参数名": "参数值"}} 示例: 用户:北京天气怎么样? 助手:{"tool": "get_weather", "parameters": {"city": "北京"}}第二层:解析器容错。前面提到的正则提取 + JSON 修复。修复规则包括:去除 markdown 代码块标记、替换中文引号、补全缺失的右括号、去除尾随逗号。这些规则能解决 80% 的格式问题。
第三层:工具名模糊匹配。如果解析出的工具名不在注册表里,用编辑距离(Levenshtein distance)找最接近的工具名。距离小于等于 2 就认为是同一个工具。比如fetch_weather和get_weather的编辑距离是 3(f→g, e→e, t→t, c→g, h→e... 实际算一下是 4),那就超过阈值了。这时候更好的做法是维护一个别名表:fetch_weather→get_weather,GetWeather→get_weather。别名表可以手动维护,也可以从历史日志里自动挖掘。
第四层:重试与降级。如果前三层都失败了,走重试。重试时在 prompt 里追加一条错误信息:“你上次的输出格式不正确,请严格按照 JSON 格式重新输出。” 重试一次通常能解决大部分问题。如果重试还失败,降级到“直接回答”模式:不调用工具,让 LLM 基于已有知识回答。虽然可能不准确,但至少不会卡死。
3.3 超时与死循环防护
端侧 Agent 最怕的是死循环。LLM 可能反复调用同一个工具,或者反复输出相同的错误格式。我的防护措施是:
- 最大循环次数:默认 10 次。超过 10 次强制退出,返回“任务过于复杂,请简化后重试”。
- 重复检测:如果连续两次工具调用完全相同(工具名 + 参数),中断循环。
- 总超时:整个 Agent 循环的总时间不超过 30 秒。超过就中断。
- Token 预算:整个循环消耗的 token 数不超过模型上下文窗口的 80%。超过就触发上下文截断或摘要。
这些阈值不是拍脑袋定的。10 次循环是基于端侧模型的能力——一个 3B 模型,在 10 步之内解决不了的任务,再多给 10 步也大概率解决不了。30 秒总超时是基于用户耐心——超过 30 秒的等待,用户会认为 App 卡死。80% 上下文窗口是留出空间给系统提示和工具返回结果。
实操心得:这些阈值最好做成可配置的,并且在开发阶段打开详细日志。我通常会在端侧维护一个环形缓冲区,记录最近 100 次 Agent 循环的每一步耗时、token 数、工具调用结果。出问题时,让用户导出这个缓冲区,比让用户描述“它卡住了”有用得多。
4. 状态管理与上下文压缩
4.1 端侧上下文的硬约束
端侧 LLM 的上下文窗口通常比云端小得多。一个 3B 模型可能只有 4K 或 8K token 的上下文窗口。而 Agent 的多轮对话 + 工具调用结果,很容易就撑爆这个窗口。
我算过一笔账:系统提示 200 token,用户输入 50 token,LLM 输出 100 token,工具返回结果 200 token。一轮下来就是 550 token。10 轮就是 5500 token。如果模型只有 4K 窗口,第 8 轮就爆了。
所以端侧 Agent 必须有上下文压缩机制。压缩策略有三种:
- 滑动窗口:只保留最近 N 轮对话。简单粗暴,但会丢失早期的重要信息。
- 摘要压缩:用 LLM 把早期对话总结成一段短文本。效果好,但需要额外的 LLM 调用,增加延迟和电量消耗。
- 关键信息提取:从早期对话中提取实体和意图,存成结构化数据。比如用户说“我要订明天去北京的机票”,提取出
{destination: "北京", date: "明天", action: "订机票"}。后续对话只带这个结构化数据,不带原始文本。
我的做法是滑动窗口 + 关键信息提取。滑动窗口保留最近 3 轮完整对话,更早的对话提取成关键信息列表。关键信息列表用 JSON 存储,每项不超过 50 token。这样 10 轮对话的上下文可以压缩到 2000 token 以内。
4.2 Scratchpad 的持久化与清理
Agent 的 scratchpad(中间推理步骤)是内存消耗大户。每一步的工具调用结果都可能很长,比如一个数据库查询返回了 100 行数据。这些数据如果一直留在内存里,很快就会 OOM。
我的策略是:scratchpad 只保留最近 3 步的完整结果,更早的结果只保留摘要。摘要由工具自己生成——每个工具在执行完后,除了返回完整结果,还要返回一个不超过 100 字的摘要。比如数据库查询工具返回完整数据的同时,返回“查询到 100 条记录,前 3 条是...”。
如果 Agent 循环结束(成功或失败),scratchpad 立即清空。如果用户中途退出 App,scratchpad 可以选择持久化到磁盘,下次打开时恢复。但持久化要加密——scratchpad 里可能包含用户隐私数据。
4.3 内存池与对象复用
端侧开发,内存分配是性能杀手。频繁的new对象会导致 GC 频繁触发,在 Android 上表现为卡顿,在 iOS 上表现为内存峰值。Agent 循环里涉及大量临时对象:prompt 字符串、JSON 解析中间对象、工具参数 Map、工具返回结果。
我的做法是维护一个对象池。比如Map<String, Any>对象池,每次工具调用前从池里取一个,用完清空放回。字符串拼接用StringBuilder复用。JSON 解析用流式解析器,避免一次性构建完整对象树。
这些优化在云端可能没必要,但在端侧,一个 3B 模型的推理本身就要几百毫秒,如果 GC 再卡个 50ms,用户体验就会明显下降。
5. 端侧部署的实操细节
5.1 模型格式选择与量化
端侧 LLM 的格式主要有 GGUF、ONNX、TFLite、Core ML。选择哪个取决于你的目标平台:
- Android:GGUF(llama.cpp)或 TFLite。GGUF 生态更成熟,量化选项多。TFLite 与 Android 集成更好,但支持的模型架构有限。
- iOS:Core ML 或 GGUF(通过 llama.cpp 的 iOS 绑定)。Core ML 能利用 Neural Engine,功耗更低。GGUF 更灵活,但只能跑 CPU/GPU。
- 跨平台:ONNX Runtime 或 MLC-LLM。MLC-LLM 支持 Vulkan 和 Metal,性能不错,但编译配置复杂。
量化方面,端侧常用 Q4_K_M 或 Q5_K_M。Q4_K_M 在 3B 模型上大约占 2GB 内存,Q5_K_M 大约 2.5GB。如果设备只有 4GB 内存,Q4_K_M 是更安全的选择。Q3_K_S 更小(约 1.5GB),但输出质量下降明显,格式遵循能力会变差,容错机制的压力会更大。
注意:量化后的模型,输出格式稳定性会下降。Q4_K_M 比 Q8_0 的格式错误率大概高 2-3 倍。所以如果你用了 Q4 量化,容错机制必须做得更厚。我的经验是,Q4 量化下,JSON 解析失败率大约 5-8%,Q8 下只有 1-2%。
5.2 推理引擎的参数调优
以 llama.cpp 为例,端侧推理的关键参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
n_ctx | 2048-4096 | 上下文窗口,越大内存占用越高 |
n_batch | 128-256 | 批处理大小,端侧不宜过大 |
n_threads | 2-4 | 线程数,超过物理核心数反而变慢 |
n_gpu_layers | 0 或全部 | 有 GPU 就全放 GPU,没有就 0 |
temp | 0.1-0.3 | 温度,端侧 Agent 需要确定性输出 |
top_p | 0.9 | 核采样,配合低温度使用 |
repeat_penalty | 1.1 | 重复惩罚,防止 LLM 陷入循环 |
温度是关键。端侧 Agent 不需要创造性,需要的是稳定和可预期。温度设 0.1 能大幅降低格式跑偏的概率。但温度太低也会导致 LLM 在遇到不确定情况时反复输出相同内容。0.1-0.3 是平衡点。
5.3 电量与热管理
端侧 Agent 跑在手机上,电量和发热是绕不开的问题。一次完整的 Agent 循环(3-5 轮)可能消耗 1-2% 的电量,手机背面温度可能上升 3-5 摄氏度。如果用户连续使用 10 分钟,电量掉 10% 以上,体验就很差了。
我的优化手段:
- 推理批量化:如果用户连续输入多个请求,合并成一次推理。但端侧 Agent 通常是交互式的,批量机会不多。
- 工具执行异步化:工具执行不阻塞推理。但 Agent 循环本身是串行的,这个优化空间有限。
- 动态降频:检测到设备温度超过阈值时,主动降低推理线程数或切换到更小的模型。这个需要与系统 API 配合。
- 缓存:相同的用户输入,如果之前处理过,直接返回缓存结果。端侧 Agent 的场景通常比较固定(比如车机上的导航、音乐、空调控制),缓存命中率可以很高。
6. 常见问题与排查技巧实录
6.1 典型问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent 卡死无响应 | 工具执行阻塞主线程 | 检查工具是否在主线程执行 | 移到 IO 线程池,加超时 |
| 反复调用同一工具 | LLM 陷入循环 | 查看 scratchpad 是否重复 | 加重复检测,中断循环 |
| JSON 解析频繁失败 | 模型量化过度 | 检查量化等级 | 升级到 Q5 或 Q8,加强解析容错 |
| 内存持续增长 | scratchpad 未清理 | 监控内存曲线 | 限制 scratchpad 长度,及时清理 |
| 推理速度突然变慢 | 设备过热降频 | 监控 CPU 频率和温度 | 降低线程数,暂停推理 |
| 工具返回结果被截断 | 上下文窗口不足 | 检查 token 数 | 压缩上下文,截断工具结果 |
| 多轮对话后回答质量下降 | 上下文污染 | 检查历史对话 | 滑动窗口 + 关键信息提取 |
| App 启动后首次推理极慢 | 模型加载耗时 | 测量加载时间 | 预加载模型,或延迟加载 |
6.2 独家避坑技巧
技巧一:用“心跳”检测 Agent 循环是否存活。在 Agent 循环的每一步之间插入一个心跳检查。如果超过 5 秒没有心跳,强制中断循环并返回错误。这能防止 LLM 推理卡死导致整个 App 无响应。
技巧二:工具返回结果做“瘦身”。工具返回的原始数据往往包含大量冗余字段。在返回给 LLM 之前,先做一次字段裁剪。比如数据库查询返回 20 个字段,但 LLM 只需要其中 3 个。裁剪后 token 数能减少 70% 以上。
技巧三:维护一个“坏输出”样本库。每次 JSON 解析失败,把原始输出存下来。积累几十条后,分析规律,针对性地改进 prompt 或解析器。我靠这个方法把 JSON 解析失败率从 8% 降到了 2% 以下。
技巧四:端侧 Agent 的日志要“可回放”。记录每一步的输入、输出、耗时、token 数。出问题时,用这些日志在开发机上回放整个循环,能快速定位问题。日志格式用 JSON Lines,每行一个步骤,方便解析。
技巧五:不要迷信“大而全”的编排框架。端侧 Agent 的编排层越薄越好。我见过一个团队用 LangChain 的 AgentExecutor 跑端侧,光是框架初始化就花了 800ms,内存占用 50MB。后来换成 200 行的自研状态机,初始化 20ms,内存占用 2MB。
6.3 性能基准测试方法
端侧 Agent 的性能测试,不能只看“跑通了没有”。要测四个指标:
- 首 token 延迟:从用户输入到 LLM 输出第一个 token 的时间。端侧目标:< 500ms。
- 每 token 生成时间:端侧 3B 模型在手机 CPU 上大约 50-100ms/token。GPU 上 20-50ms/token。
- 工具执行时间:本地工具目标 < 100ms。超过 500ms 的工具要考虑优化或异步化。
- 端到端完成时间:一个 3 轮循环的 Agent 任务,目标 < 5 秒。
测试时要用真实设备,不要用模拟器。模拟器的 CPU 性能和内存行为与真机差异很大。Android 上至少测中端机(骁龙 7 系)和高端机(骁龙 8 系)各一台。iOS 上测 A14 及以上芯片的设备。
7. 端侧 Agent 工程化的未来演进
7.1 模型与编排的协同设计
现在的端侧 Agent 工程化,模型和编排是分开设计的。模型团队负责压缩和量化,工程团队负责编排和容错。未来这两个会融合:模型在训练时就知道自己会被部署到端侧,会针对端侧的资源约束做优化。比如训练时就加入格式遵循的强化学习,让模型天生就更倾向于输出严格 JSON。或者训练时就加入工具调用的特殊 token,让解析器能更高效地提取工具调用。
7.2 端云协同的混合架构
纯端侧 Agent 的能力上限受模型大小限制。未来的趋势是端云协同:简单任务端侧处理,复杂任务云端处理。端侧 Agent 负责意图识别和任务分解,云端负责复杂推理和知识检索。端侧 Agent 的工程化要为此做好准备:编排层要支持“远程工具”,容错机制要处理网络不可靠的情况,状态管理要支持端云状态同步。
7.3 硬件加速的普及
现在端侧 LLM 推理主要靠 CPU 和 GPU。未来 NPU 会普及,专门为 Transformer 推理优化。端侧 Agent 的工程化要能利用 NPU 的算力,同时处理 NPU 的内存限制(NPU 通常只有几 MB 的片上内存,需要精细的内存调度)。这会是下一个工程化难点。
我个人在实际操作中的体会是,端侧 Agent 工程化没有银弹。每一个优化都是权衡:量化等级换内存,温度换稳定性,重试换延迟,缓存换准确性。关键是明确你的场景最不能妥协的是什么。如果是车载语音助手,延迟和稳定性最重要,量化可以激进一些,容错要厚。如果是端侧文档分析,准确性最重要,量化要保守,上下文要尽量完整。想清楚这个,后面的技术选型就顺了。