资讯详情

资讯详情

端侧Agent工程化实战:Function Calling与MCP协议裁剪落地

1. 端侧 Agent 工程化的核心命题1.1 为什么端侧 Agent 不能照搬云端那一套端侧 Agent 和云端 Agent 最大的区别不在于模型大小而在于资源边界和运行环境的不可控性。云端你可以随便起一个 72B 模型的推理集群用 vLLM 做连续批处理后面挂一堆工具服务网络延迟稳定在个位数毫秒。端侧完全不是这个逻辑——手机、车机、IoT 设备上的算力、内存、电量都是硬约束模型可能只有 1B 到 7B 参数量量化到 4bit 之后还要跟系统抢内存。我最早做端侧 Agent 的时候犯过一个很典型的错误直接把云端那套 Function Calling 的 prompt 模板搬过来结果模型连工具名都记不全JSON 格式输出十次有三次是坏的。后来才想明白端侧 Agent 的工程化本质上是在有限算力下做确定性交付。你不能指望模型每次都完美输出你得在架构层面把不确定性兜住。这一篇主要聊工程化落地的上半部分核心围绕三件事Function Calling 在端侧怎么设计才稳、MCP 协议在端侧怎么裁剪、LLM 推理链路怎么和工具调用串起来。这三个问题解决了端侧 Agent 才真正具备可交付性。1.2 端侧 Agent 工程化的三个核心约束在展开具体方案之前先把约束条件摆清楚后面所有设计决策都围绕这三条展开。算力约束端侧 NPU 的算力通常在 1 到 10 TOPS 之间跑 4bit 量化的 3B 模型prefill 阶段大概能到 20 到 50 tokens/sdecode 阶段可能只有 10 到 20 tokens/s。这意味着你的 prompt 不能太长工具描述不能太啰嗦否则光 prefill 就把时间吃光了。内存约束一个 3B 模型 4bit 量化后大约占 1.8GB加上 KV Cache 和运行时开销2.5GB 是常态。如果设备还要跑其他应用留给 Agent 的空间可能只有 3GB 左右。所以工具数量不能太多每个工具的描述要精简到极致。确定性约束端侧模型能力弱幻觉率高JSON 输出不稳定。工程化必须假设模型会出错然后在解析层、重试层、降级层做兜底。这一点和云端 Agent 的信任模型思路完全相反。我个人的经验是端侧 Agent 的 prompt 工程本质上是把模型当实习生用——你不能给它太复杂的指令要把任务拆到它闭着眼都能做对的程度。2. Function Calling 在端侧的裁剪与落地2.1 端侧 Function Calling 和云端的本质差异云端 Function Calling 现在主流是两种模式一种是 OpenAI 的 tools 参数模型直接输出 tool_calls 结构另一种是 ReAct 风格的文本解析模型输出 Thought/Action/Action Input 然后正则提取。端侧两种都能用但都有坑。OpenAI 风格的 tool_calls 依赖模型经过专门的 function calling 微调端侧小模型很少有这个能力。我实测过几个 3B 级别的模型原生支持 tool_calls 的只有少数几个而且输出格式经常飘。ReAct 风格虽然土但胜在可控——你可以在 prompt 里把输出格式约束死然后用正则去匹配匹配失败就重试。所以端侧我推荐混合方案优先用模型原生的 tool_calls 能力如果没有或者不稳定就降级到 ReAct 文本解析。两条链路共用同一套工具注册表只是输出解析层不同。2.2 工具描述的精简策略端侧 prompt 长度直接决定响应速度工具描述必须精简。我的做法是给每个工具定义三个字段name、description、parameters。description 控制在 20 个字以内parameters 只保留必要字段类型用简写。举个例子一个查天气的工具云端可能这么写{ name: get_weather, description: 获取指定城市的当前天气信息包括温度、湿度、风力等详细数据, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位 } }, required: [city] } }端侧我会砍成这样{ name: weather, description: 查天气, params: {city: str} }省下来的 token 全部留给对话历史和推理空间。实测下来工具描述从平均 80 token 压到 25 token5 个工具的 prompt 就能省 275 tokenprefill 时间能快 30% 左右。2.3 工具数量与选择策略端侧工具数量建议控制在5 到 8 个。超过 8 个之后模型选择工具的准确率会明显下降而且 prompt 长度也扛不住。如果业务上确实需要更多工具用两级路由先让模型选工具类别再在类别内选具体工具。我做过一个车载场景的 Agent工具包括导航、音乐、空调、车窗、座椅、电话、天气、充电站查询正好 8 个。再往上加模型就开始乱选了。后来把充电站查询归到导航类别下变成 7 个一级工具准确率就回来了。工具选择的另一个技巧是给工具名加前缀。比如nav_开头的都是导航相关media_开头的都是媒体相关。模型看到前缀就能快速归类选择准确率能提升 10 到 15 个百分点。2.4 输出解析的容错设计端侧模型输出 JSON 经常出问题常见的有多了 markdown 代码块标记、少了大括号、字符串没加引号、参数名拼错。解析层必须做容错。我的解析流程是这样的先用正则提取{...}或[...]包裹的内容尝试标准 JSON 解析失败则用宽松解析器补全缺失的引号和大括号再失败则用正则逐字段提取全部失败则触发重试重试时在 prompt 里加一句只输出 JSON不要其他内容这套流程下来解析成功率能从 70% 提到 95% 以上。剩下的 5% 走降级逻辑比如直接告诉用户我没理解你的意思能再说一遍吗。注意重试次数不要超过 2 次端侧推理本来就慢重试太多用户体验会很差。第二次重试还失败就直接降级。3. MCP 协议在端侧的适配与裁剪3.1 MCP 是什么为什么端侧需要它MCP 全称 Model Context Protocol是一套让模型和外部工具、数据源通信的标准化协议。它的核心价值在于解耦——工具提供方按 MCP 规范实现服务端Agent 按 MCP 规范实现客户端两边不用互相知道对方的存在。端侧为什么需要 MCP因为端侧 Agent 要接入的工具太多了系统 API、第三方应用、本地文件、传感器数据。如果每个工具都写一套适配代码维护成本会爆炸。MCP 提供了一套统一的接口描述和调用约定工具接入变成配置问题而不是编码问题。但完整的 MCP 协议对端侧来说太重了。它包含资源、提示、工具、采样等多个原语还有 stdio、HTTP、SSE 等多种传输方式。端侧只需要其中一小部分必须裁剪。3.2 端侧 MCP 的最小可用子集我建议端侧只实现 MCP 的tools 原语传输方式用stdio 或本地 socket其他全部砍掉。tools 原语的核心接口就三个tools/list列出可用工具tools/call调用指定工具tools/result返回调用结果这三个接口用 JSON-RPC 2.0 格式通信请求和响应都是标准 JSON。端侧 Agent 作为 MCP 客户端工具服务作为 MCP 服务端两边通过本地 socket 或管道通信。资源、提示、采样这些原语端侧用不上。资源是给模型提供上下文数据的端侧上下文本来就紧张提示是预定义的 prompt 模板端侧 prompt 都是动态生成的采样是让服务端反向调用模型端侧模型只有一个没必要。3.3 MCP 工具注册表的本地缓存端侧每次启动都去拉 tools/list 太慢了而且很多工具是静态的不会变。我的做法是本地缓存工具注册表启动时先读缓存后台异步刷新。缓存结构大概是这样{ version: 1.0, updated_at: 1700000000, tools: [ { name: weather, description: 查天气, params: {city: str}, endpoint: local://weather_service } ] }缓存有效期设 24 小时过期后强制刷新。如果刷新失败继续用旧缓存但标记为 stale。这样既保证了启动速度又不会因为工具变更导致功能失效。3.4 MCP 调用的超时与降级端侧工具调用必须设超时因为本地服务也可能卡死。我的经验值是轻量工具 500ms重量工具 2s。超过就返回超时错误让模型决定是重试还是换工具。降级策略分三级一级降级工具调用失败返回错误信息给模型让模型重新选择二级降级连续两次调用失败直接告诉用户这个功能暂时不可用三级降级MCP 服务整体不可用切换到内置的兜底工具集兜底工具集是硬编码在 Agent 里的几个最基础的工具比如时间查询、简单计算保证核心功能不挂。4. LLM 推理链路与工具调用的串联4.1 端侧推理引擎的选型考量端侧 LLM 推理引擎现在主流的有 llama.cpp、MLC LLM、MNN、NCNN 几个。选型主要看三点量化支持、硬件加速、工具调用友好度。llama.cpp 的 GGUF 格式支持最全4bit、5bit、8bit 都有CPU 推理性能也不错但 NPU 加速支持一般。MLC LLM 对移动端 GPU 优化好但量化格式选择少。MNN 和 NCNN 是阿里和腾讯的国内设备适配好但生态相对封闭。我目前主力用 llama.cpp原因是它的grammar 约束功能对工具调用太友好了。你可以用 GBNF 语法定义输出格式模型只能在合法 token 里选JSON 输出稳定性直接拉满。这个功能在端侧简直是救命稻草。4.2 Grammar 约束下的 JSON 输出Grammar 约束的原理是在采样阶段屏蔽非法 token。比如你定义了一个 JSON schema模型在生成每个 token 时只能从符合当前 schema 状态的 token 里选。这样输出一定是合法 JSON不需要解析容错。一个简单的工具调用 grammar 大概长这样root :: { ws \tool\ ws : ws string ws , ws \params\ ws : ws object ws } string :: \ [a-zA-Z0-9_] \ object :: { ws (pair (ws , ws pair)*)? ws } pair :: string ws : ws value value :: string | number | true | false | null number :: [0-9] ws :: [ \t\n]*这个 grammar 保证输出一定是{tool: ..., params: {...}}格式。实测下来JSON 解析成功率从 70% 提到 99.9%基本不用重试了。注意grammar 约束会增加采样开销大概多 5% 到 10% 的推理时间。但换来的是稳定性这笔账划算。4.3 多轮工具调用的状态管理端侧 Agent 经常需要多轮工具调用比如先查天气再推荐穿衣先导航再查路况。多轮调用需要管理状态包括对话历史、工具调用记录、中间结果。我的做法是用一个Agent State对象贯穿整个会话class AgentState: def __init__(self): self.history [] # 对话历史 self.tool_calls [] # 工具调用记录 self.tool_results {} # 工具结果缓存 self.turn 0 # 当前轮次 self.max_turns 5 # 最大轮次每轮推理前把 history 和 tool_results 拼成 prompt推理后把模型输出和工具结果追加到 state。轮次超过 max_turns 就强制结束避免死循环。history 要做滑动窗口裁剪只保留最近 N 轮。端侧上下文有限全量 history 塞不下。我的经验是保留最近 3 轮对话加所有工具调用记录这样既能维持上下文又不会爆 token。4.4 流式输出与工具调用的冲突处理端侧 Agent 通常要流式输出让用户看到模型在思考。但工具调用需要完整 JSON 才能解析流式输出和工具调用有冲突。解决方案是分段流式模型先输出思考过程流式展示给用户检测到工具调用开始时暂停流式展示等 JSON 完整后再解析执行工具执行完继续流式输出结果。具体实现上在推理引擎的输出回调里做状态机状态 A普通文本输出直接流式展示状态 B检测到{tool开头切换到缓冲模式状态 CJSON 完整解析并执行工具状态 D工具结果注入回到状态 A这套状态机跑下来用户看到的是思考中... 调用工具... 得到结果... 继续回答的流畅体验不会因为工具调用卡住。5. 端侧 Agent 工程化的常见坑与排查5.1 工具调用失败的五种典型场景端侧工具调用失败的原因和云端很不一样我整理了五种最常见的失败场景典型表现排查方向解决方案工具名幻觉模型编造不存在的工具名检查 prompt 里的工具列表加 grammar 约束工具名枚举参数缺失JSON 里少了必填参数检查工具 schema 定义解析层补默认值或触发重试参数类型错数字传成字符串检查模型输出格式grammar 约束类型调用超时工具服务无响应检查本地服务状态设超时降级结果解析错工具返回格式不符检查 MCP 服务实现统一结果格式规范这五种里工具名幻觉和参数缺失占了 80% 以上。grammar 约束能解决工具名幻觉参数缺失靠解析层补默认值。5.2 推理速度优化的几个实操技巧端侧推理速度是用户体验的生命线我踩过的优化点大概有这些KV Cache 复用多轮对话时前面几轮的 KV Cache 可以复用不用重新计算。llama.cpp 支持 session 保存和恢复开启后第二轮推理能快 40% 左右。Prompt 缓存系统 prompt 和工具描述是固定的可以预计算 KV Cache 并缓存。每次新会话直接加载省掉 prefill 时间。批处理如果有多个请求排队可以合并成一个 batch 推理。端侧一般用不上但如果是车机这种多用户场景批处理能提升吞吐。量化选择4bit 量化是速度和质量的平衡点。3bit 虽然更快但质量下降明显工具调用准确率会掉 20% 以上。5bit 质量好但速度慢看场景取舍。线程数调优CPU 推理时线程数不是越多越好一般设成大核数量。8 核 CPU 设 4 线程比设 8 线程快因为小核会拖后腿。5.3 内存泄漏的排查与预防端侧 Agent 长时间运行内存泄漏是隐形杀手。我遇到过一次Agent 跑 2 小时后内存从 2.5GB 涨到 4GB直接被系统杀掉。排查下来是 KV Cache 没释放。每次新会话都新建一个 cache旧的没清。修复方案是会话结束时显式释放 cache并且加一个 cache 池做复用。预防内存泄漏的几个习惯所有 native 对象都要有明确的释放路径会话结束、超时、异常都要走统一的清理逻辑加内存监控超过阈值就主动 GC 或重启推理引擎定期做长时间运行测试至少跑 4 小时我现在的做法是给推理引擎加一个 watchdog内存超过 3GB 就自动重启引擎并恢复会话状态。虽然粗暴但有效。5.4 模型更新与工具变更的兼容性端侧 Agent 上线后模型和工具都可能更新。模型换了prompt 可能要调工具加了注册表要刷新。这些变更如果处理不好会导致线上故障。我的做法是版本化 灰度模型版本、prompt 版本、工具注册表版本都打上 tag新版本先在小流量灰度观察工具调用成功率成功率低于阈值自动回滚工具注册表变更走热更新不重启 Agent这套机制跑下来模型和工具更新基本能做到无感。唯一要注意的是 prompt 和模型的匹配关系换模型必须重新验证 prompt不能直接复用。6. 工程化落地的个人体会端侧 Agent 工程化这件事说到底是在约束下做取舍。你不能既要模型能力强又要响应快还要内存占用低这三者天然矛盾。我的取舍原则是优先保证核心场景的确定性非核心场景可以降级。比如车载 Agent导航和空调控制是核心必须 100% 可靠音乐推荐和闲聊是次要可以容忍偶尔失败。工程资源就这么多投在核心场景上回报最高。另一个体会是测试要覆盖真实设备。模拟器上跑得好好的真机上可能因为 NPU 驱动、内存碎片、后台限制各种问题。我现在的流程是模拟器跑通后必须在至少 3 款真机上跑 24 小时稳定性测试才能算通过。最后说一个容易被忽略的点日志。端侧 Agent 的日志不能太啰嗦但关键路径必须有。我的做法是分级日志ERROR 级别全量上报WARN 级别采样上报INFO 级别本地保留最近 1000 条。出问题时能快速定位又不影响性能。这套工程化方案我在几个项目上跑下来工具调用成功率稳定在 95% 以上端到端响应时间控制在 2 秒以内内存占用稳定在 3GB 以下。当然不同设备不同场景还要微调但整体框架是通用的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →