资讯动态

端侧Agent工程化实战:Function Calling与MCP的落地取舍

发布时间:2026/10/8 23:59:31 来源:尧图企业网站定制
端侧 Agent 的讨论前两篇我们聊了模型怎么塞进手机、推理怎么跑得快。但真到了要交付一个能用的东西你会发现模型本身只占三成工作量剩下七成全是工程问题工具怎么注册、调用怎么编排、上下文怎么裁剪、出错怎么兜底、状态怎么持久化。这一篇就专门啃这块硬骨头——Agent 工程化。我先把话说在前面端侧 Agent 的工程化和云端 Agent 完全是两码事。云端你可以随便起容器、随便调外部 API、上下文窗口不够就加钱扩端侧不行。内存就那么点电量就那么点网络还时断时续用户对延迟的容忍度是以毫秒计的。所以端侧 Agent 的工程化本质上是在极端资源约束下做取舍的艺术。这篇内容适合已经跑通过 demo、准备往产品化推进的开发者也适合想搞清楚 Function Calling 和 MCP 在端侧到底怎么落地的人。1. 端侧 Agent 工程化到底在解决什么问题1.1 从 demo 到产品之间那道鸿沟很多人跑通一个端侧 Agent 的 demo 之后会觉得这不挺简单的吗模型加载进去写个 prompt调个函数完事。但你把这个东西拿给真实用户用一天问题就全冒出来了。用户不会按照你预设的路径走他会问一些你完全没想过的问题会在网络断掉的时候继续操作会在低电量模式下期待一切照常运行。demo 和产品之间的鸿沟核心在于不确定性。云端 Agent 可以用重试、超时、降级这些手段来消化不确定性因为资源是弹性的。端侧不行端侧的每一次重试都意味着额外的电量和延迟每一次降级都意味着用户体验的折损。所以端侧 Agent 工程化的第一要务是把不确定性尽可能地在前端消化掉而不是留给运行时。我自己的经验是端侧 Agent 的工程化可以拆成四个层面工具层负责能力的注册与发现编排层负责决策与调度上下文层负责信息的裁剪与注入容错层负责异常的处理与恢复。这四个层面环环相扣任何一个没做好整个 Agent 就会表现得像个智障。1.2 端侧约束下的三个核心矛盾第一个矛盾是能力丰富度与资源占用的矛盾。你希望 Agent 能做的事情越多越好但每多一个工具就多一份 prompt 描述、多一份参数 schema、多一份运行时开销。端侧的内存和算力不允许你无限制地堆工具。第二个矛盾是响应速度与决策质量的矛盾。端侧用户对延迟极其敏感超过两秒的等待就会让人烦躁。但高质量的决策往往需要更多的推理步骤、更长的上下文。这两者天然打架。第三个矛盾是离线可用与能力更新的矛盾。端侧 Agent 的一大卖点是离线可用但工具和 prompt 是需要迭代的。你怎么在不发版的情况下更新 Agent 的能力这就涉及到配置化和动态加载的设计。这三个矛盾贯穿了端侧 Agent 工程化的始终后面讲的每一个技术选择本质上都是在回答你打算怎么平衡这三个矛盾。1.3 工程化的边界哪些该做哪些不该做这里我要泼一盆冷水。不是所有东西都适合放到端侧 Agent 里做。我的判断标准很简单如果一个任务需要频繁访问外部实时数据或者需要大量计算资源那它就不该放在端侧。端侧 Agent 应该聚焦在那些本地数据 轻量推理 快速响应的场景上。举个例子让端侧 Agent 帮你整理本地的备忘录、根据当前日程推荐下一步行动、在本地相册里找照片这些都很合适。但让端侧 Agent 去做复杂的多轮数据分析、去调用一堆外部服务做编排那就是自找麻烦。工程化的第一步不是写代码而是划边界。2. Function Calling 在端侧的落地细节2.1 Function Calling 的本质是一次结构化输出很多人把 Function Calling 想得很神秘觉得模型真的调用了什么函数。其实不是。Function Calling 的本质是让模型输出一段符合特定 schema 的结构化文本然后由你的代码去解析这段文本并执行对应的函数。模型本身不执行任何东西它只是告诉你我想调这个函数参数是这些。理解这一点非常关键因为它决定了你在端侧该怎么优化。既然本质是结构化输出那核心问题就变成了怎么让模型稳定地输出符合 schema 的文本以及怎么高效地解析这段文本。端侧模型尤其是那些参数量在 1B 到 7B 之间的在结构化输出上的稳定性远不如云端的大模型。它们经常会多输出一些解释性文字或者参数格式不对或者干脆编造一个不存在的函数名。所以端侧的 Function Calling解析层必须做得非常健壮。2.2 工具描述的精简策略工具描述是 Function Calling 里最占 token 的部分。每个工具你都要写清楚它叫什么、干什么、参数是什么、每个参数什么类型什么含义。工具一多光描述就能吃掉几千 token这在端侧是不可接受的。我的做法是分层描述。核心工具高频调用的那几个写完整的描述包括详细的参数说明和示例。边缘工具只写一句话描述加参数名参数的具体含义靠命名自解释。更进一步可以把工具按场景分组每次只把当前场景相关的工具描述注入 prompt而不是一股脑全塞进去。还有一个技巧是用枚举代替自由文本。如果一个参数只有几种可能的取值就用 enum 约束而不是让模型自由发挥。这既减少了 token又提高了输出的稳定性。比如时间范围这个参数与其让模型输出最近三天这种自然语言不如定义成enum: [today, week, month]。2.3 参数校验与容错解析端侧模型输出的 JSON 经常是坏的。可能少个引号可能多个逗号可能把数字写成字符串。你不能指望它每次都输出完美的 JSON所以解析层必须能容错。我的解析流程是这样的先尝试标准 JSON 解析失败的话走一轮修复逻辑补全括号、修正引号、去掉尾随逗号再失败的话用正则去提取关键字段。如果全都失败就把原始输出返回给模型让它重新生成一次。这个重试只做一次避免陷入死循环。参数校验也不能省。模型可能会输出一个 schema 里没定义的参数或者参数类型不对。校验层要把这些情况拦下来要么修正要么拒绝这次调用并给出明确的错误信息让模型重新决策。import json import re def parse_tool_call(raw_output, tool_schema): # 第一轮标准解析 try: data json.loads(raw_output) return validate_and_fix(data, tool_schema) except json.JSONDecodeError: pass # 第二轮修复常见问题 fixed raw_output.strip() fixed re.sub(r,\s*}, }, fixed) fixed re.sub(r,\s*], ], fixed) try: data json.loads(fixed) return validate_and_fix(data, tool_schema) except json.JSONDecodeError: pass # 第三轮正则提取 match re.search(r\{.*\}, raw_output, re.DOTALL) if match: try: data json.loads(match.group()) return validate_and_fix(data, tool_schema) except json.JSONDecodeError: pass return None # 交给上层决定是否重试这段代码看着简单但每一层修复都是踩过坑之后加上的。尤其是那个正则提取救过我好几次——模型有时候会在 JSON 前面加一句好的我来帮你调用直接解析就挂了。2.4 多工具并行的调度问题当模型一次返回多个工具调用请求时端侧怎么处理云端可以并行发起多个请求端侧得掂量一下。如果这些工具调用之间没有依赖关系且都是本地操作那并行没问题。但如果涉及 IO 或者需要排队访问某个资源就得串行化。我的策略是默认串行显式声明并行。在工具注册的时候给每个工具打一个标记说明它是否可以和其他工具并行执行。调度器根据这个标记来决定执行顺序。这样既保证了安全又不会无谓地牺牲性能。3. MCP 协议在端侧的适配与取舍3.1 MCP 解决了什么问题MCPModel Context Protocol这两年被讨论得很多它的核心价值是标准化了模型和外部能力之间的接口。在没有 MCP 之前每个工具集成都是定制化的A 模型的工具描述格式和 B 模型不兼容换个模型就得重写一遍。MCP 把这层抽象出来了工具提供方按照 MCP 的规范暴露能力模型侧按照 MCP 的规范去发现和调用。对端侧 Agent 来说MCP 的吸引力在于解耦。工具的实现和 Agent 的编排逻辑可以分开演进工具可以动态加载和卸载这对资源受限的端侧环境很友好。但 MCP 也不是银弹。它的协议本身有一定的开销完整的 MCP 握手和发现流程在端侧可能会显得笨重。所以端侧用 MCP一定要做裁剪。3.2 端侧 MCP 的裁剪方案完整的 MCP 支持工具发现、资源访问、prompt 模板、采样等多种能力。端侧没必要全都要。我的裁剪方案是只保留工具发现和工具调用这两个最核心的能力其他的要么砍掉要么简化。工具发现这块端侧不适合做动态的远程发现。我的做法是本地注册表 版本号。所有可用的工具在本地维护一份注册表每个工具带一个版本号。Agent 启动时读取注册表构建工具描述。需要更新的时候通过配置下发新的注册表而不是实时去远程拉取。工具调用这块MCP 定义了标准的请求和响应格式。端侧可以直接复用这个格式但要注意响应体的大小。有些工具返回的数据可能很大端侧要做截断或者摘要不能原样塞回上下文。3.3 MCP 与 Function Calling 的关系这里有个常见的困惑MCP 和 Function Calling 是不是一回事不是。Function Calling 是模型侧的能力是模型输出结构化调用的机制。MCP 是协议侧的规范是工具如何被描述和调用的标准。两者是互补的。你可以这样理解Function Calling 是模型怎么说MCP 是工具怎么定义。端侧 Agent 的完整链路是MCP 定义了工具长什么样Agent 把 MCP 的工具描述转换成 Function Calling 的 schema 注入 prompt模型输出调用请求Agent 解析后按照 MCP 的格式去执行工具再把结果返回给模型。理清这个链路你在做工程化的时候就知道每一层该干什么不会把职责搞混。3.4 一个端侧 MCP 工具注册表的实现下面是我在项目里用的一个简化版工具注册表核心思路是用一个 JSON 文件描述所有工具启动时加载并构建索引。{ version: 1.2.0, tools: [ { name: get_calendar_events, description: 查询指定时间范围内的日程, parallel_safe: true, parameters: { type: object, properties: { range: { type: string, enum: [today, week, month] } }, required: [range] } }, { name: search_notes, description: 在本地备忘录中搜索, parallel_safe: true, parameters: { type: object, properties: { keyword: {type: string}, limit: {type: integer, default: 5} }, required: [keyword] } } ] }这个注册表有几个设计点值得说。parallel_safe标记用来告诉调度器这个工具能不能并行执行。enum约束减少了模型自由发挥的空间。default值让模型可以省略某些参数减少输出长度。版本号用来做增量更新只下发变化的工具。4. 上下文工程端侧最稀缺的资源4.1 上下文窗口的分配策略端侧模型的上下文窗口通常比云端小得多2K 到 8K 是常态。在这个窗口里你要塞进系统 prompt、工具描述、对话历史、工具返回结果、当前用户输入。怎么分配这些空间直接决定了 Agent 的表现。我的分配原则是动态调整按需分配。系统 prompt 和工具描述是相对固定的占一个基础配额。对话历史和工具结果根据当前任务的需要动态伸缩。如果当前任务简单就少留历史如果任务复杂就压缩工具描述腾出空间。具体来说我会给每一类内容设一个上限和一个下限。系统 prompt 不超过 500 token工具描述不超过 1500 token对话历史不超过 2000 token工具结果不超过 1000 token。剩下的留给当前输入和模型输出。这些数字不是拍脑袋定的是根据实际跑下来的表现调的。4.2 对话历史的压缩与摘要对话历史是上下文里最容易膨胀的部分。用户和 Agent 来回几轮历史就满了。端侧不可能像云端那样保留完整历史必须做压缩。我的压缩策略分三级。第一级是滑动窗口只保留最近 N 轮对话。第二级是关键信息提取把历史里的关键实体和结论抽出来用简短的摘要代替原始对话。第三级是任务状态快照如果当前任务有明确的状态比如正在填一个表单就把状态序列化下来历史对话可以大幅丢弃。三级策略不是互斥的而是根据情况组合使用。简单对话用滑动窗口就够了复杂任务可能需要状态快照加关键信息提取。4.3 工具返回结果的截断与摘要工具返回的结果经常很大。比如搜索备忘录返回了 20 条每条 200 字那就是 4000 字直接把上下文撑爆。端侧必须对工具结果做处理。我的做法是结构化截断 按需展开。工具返回结果先做结构化提取出关键字段只把关键字段塞回上下文。如果模型需要更多细节它可以再发起一次调用去获取。这样既控制了上下文大小又保留了按需获取的能力。举个例子搜索备忘录返回 20 条我只把每条的前 50 字和 ID 塞回上下文。模型看到这些摘要后如果觉得某条相关可以再调用一个get_note_detail工具去拿完整内容。这种两段式的设计在端侧非常有效。4.4 上下文注入的顺序与位置上下文里内容的顺序也会影响模型的表现。我的经验是把最重要的信息放在开头和结尾中间放次要信息。这是基于模型对首尾内容注意力更强的特性。具体到端侧 Agent系统 prompt 和当前用户输入放在开头工具描述放在中间对话历史和工具结果放在结尾附近。如果工具结果特别重要就紧挨着用户输入放。这个顺序不是固定的要根据实际效果调。提示上下文顺序的调整一定要做 A/B 测试。我见过太多人凭直觉调顺序结果越调越差。端侧模型对顺序的敏感度比云端大模型高得多一定要用数据说话。5. 状态管理与持久化5.1 Agent 状态到底包含什么很多人做端侧 Agent 的时候状态管理是缺失的。Agent 跑完一轮就忘了之前发生了什么用户得反复重复自己的需求。这在产品里是致命的。Agent 的状态至少包含这几块对话状态当前聊到哪了、任务状态当前任务进行到哪一步了、工具状态哪些工具被调用过、结果是什么、用户偏好用户的习惯和偏好。这些状态需要被持久化才能在 Agent 重启或者应用切后台之后恢复。5.2 端侧持久化的选型端侧持久化的选项不多文件、SQLite、键值存储。我的建议是轻量状态用键值存储复杂状态用 SQLite。对话历史和任务状态这种结构化的东西SQLite 更合适查询和更新都方便。用户偏好这种简单的键值对用系统的键值存储就够了。要注意的是端侧的存储 IO 是有开销的不能频繁写。我的做法是内存中维护状态定期落盘。落盘的时机选在 Agent 空闲的时候或者状态发生重大变化的时候。不要每轮对话都写一次那样既费电又伤存储。5.3 状态恢复的边界情况状态恢复听起来简单实际上一堆边界情况。比如应用被杀掉的时候状态只写了一半怎么办比如状态版本和当前代码不兼容怎么办比如状态文件损坏了怎么办我的处理方式是状态带版本号 校验和。每次落盘的时候写入版本号和校验和恢复的时候先校验。校验不过就丢弃状态从干净状态开始。版本号不匹配的话走迁移逻辑迁移不了也丢弃。宁可丢状态也不能让 Agent 因为坏状态而崩溃。6. 错误处理与降级策略6.1 端侧 Agent 的错误分类端侧 Agent 的错误可以分成几类模型错误输出格式不对、幻觉、工具错误工具执行失败、超时、资源错误内存不足、电量低、环境错误网络断、权限不足。每一类错误的处理方式都不一样。模型错误靠重试和修复工具错误靠降级和替代资源错误靠裁剪和暂停环境错误靠提示和引导。把错误分类清楚处理逻辑才不会乱。6.2 模型输出异常的兜底模型输出异常是最常见的。除了前面说的解析容错还要有语义层面的兜底。比如模型输出了一个不存在的工具名你不能直接报错而应该告诉模型这个工具不存在可用的工具是这些让它重新决策。再比如模型输出了参数但参数值明显不合理比如时间范围是昨天但 enum 里只有 today/week/month你要么做映射要么拒绝并提示。这些兜底逻辑看着琐碎但正是它们决定了 Agent 在真实场景下的可用性。6.3 工具执行失败的降级路径工具执行失败的时候不能直接告诉用户失败了而要有降级路径。比如搜索工具失败了可以降级到本地缓存的结果日程查询失败了可以提示用户手动查看。降级路径要在工具注册的时候就定义好。每个工具除了主实现还要有一个降级实现或者降级提示。这样调度器在工具失败的时候能自动走降级路径而不是把错误抛给用户。6.4 资源紧张时的优雅降级端侧最怕的就是资源紧张。内存不够、电量低、CPU 被占用这些都会影响 Agent 的表现。我的做法是监控资源状态动态调整 Agent 的行为。内存紧张的时候减少上下文长度关闭非核心工具。电量低的时候降低推理频率合并请求。CPU 忙的时候延迟非紧急的工具调用。这些调整对用户来说应该是无感的Agent 只是变得保守了一些而不是直接罢工。7. 性能优化让端侧 Agent 跑得动7.1 推理次数的削减端侧 Agent 最大的性能开销是模型推理。每一次推理都要消耗算力和电量。所以优化的核心是减少推理次数。减少推理次数的办法有几个。一是合并决策把多个小决策合并成一个大决策一次推理搞定。二是缓存决策相似的输入直接复用之前的决策结果。三是规则前置能用规则判断的就不走模型。比如用户说打开设置这种明确的指令直接走规则不需要模型推理。7.2 工具调用的批处理如果模型一次返回了多个工具调用能批处理的就批处理。比如同时查询日程和备忘录可以合并成一次 IO 操作。批处理不仅减少了 IO 次数还减少了上下文往返的次数。批处理的前提是工具之间没有依赖关系。有依赖关系的工具必须串行这个不能省。7.3 预热与缓存策略端侧 Agent 的首次响应往往很慢因为模型要加载、工具要初始化。我的做法是预热。在应用启动的时候后台把模型加载好把常用工具初始化好。用户真正用的时候直接就能响应。缓存也很重要。工具的描述、schema、常用参数这些都可以缓存。模型推理的结果如果输入相同也可以缓存。缓存要注意失效策略工具更新了、用户偏好变了缓存就要失效。8. 实测中的几个坑与应对8.1 模型对工具描述的过度解读我遇到过好几次模型看到工具描述里的示例就照着示例编参数。比如工具描述里写了个示例{range: today}模型就永远只输出today不管用户实际问的是什么时候。这个坑的根源是示例太具体。解决办法是示例要多样化或者干脆不给具体值的示例只给结构示例。比如写成{range: 时间范围可选 today/week/month}模型就不容易照抄了。8.2 上下文裁剪导致的失忆上下文裁剪做过头Agent 就会失忆。用户前面说的话裁剪之后模型看不到了就会重复问或者答非所问。我的应对是关键信息不裁剪。用户明确表达的需求、已经确认的参数、当前任务的目标这些信息要单独维护不参与裁剪。裁剪只针对那些冗余的对话和工具结果。8.3 工具注册表的版本冲突工具注册表更新的时候如果新旧版本的工具描述不兼容会导致模型行为异常。比如旧版本的工具参数是range新版本改成了time_range模型可能还在用旧的参数名。解决办法是版本兼容期。新版本上线的时候同时支持新旧两套参数名给模型和用户一个过渡期。过渡期结束后再移除旧参数。这个策略在云端很常见端侧同样适用。8.4 低端设备上的内存溢出低端设备的内存是真的紧张。我遇到过在 2GB 内存的设备上Agent 跑着跑着就 OOM 了。排查下来是上下文和缓存占用了太多内存。应对办法是严格的内存预算。给 Agent 分配固定的内存配额上下文、缓存、模型各自有上限。超了就触发清理清理优先级是缓存 上下文 模型。模型是最后才动的因为重新加载模型代价太大。9. 工程化落地的检查清单9.1 上线前的自检项在把端侧 Agent 推上线之前我会过一遍这个清单工具描述是否精简到最小必要有没有冗余的示例和说明解析层是否能处理各种畸形输出有没有做重试和兜底上下文分配是否合理极端情况下会不会撑爆状态持久化是否可靠异常退出后能否恢复错误处理是否覆盖了所有分类降级路径是否可用资源监控是否到位紧张时能否优雅降级性能是否达标首次响应和后续响应的时间是否可接受这个清单不是一次性的每次迭代都要过一遍。端侧 Agent 的工程化是个持续打磨的过程没有一劳永逸。9.2 监控与迭代上线不是终点。端侧 Agent 需要监控但端侧的监控不能像云端那样实时上报要考虑隐私和流量。我的做法是本地聚合 定期上报。Agent 在本地记录关键指标推理次数、工具调用成功率、错误分布定期聚合后上报。上报的数据要脱敏不能包含用户的具体内容。拿到监控数据之后重点看几个指标工具调用的失败率、模型输出的解析失败率、上下文裁剪的触发频率、降级路径的触发频率。这些指标异常说明工程化还有问题。9.3 我个人踩过的最大一个坑最后分享一个我踩过的最大的坑。早期做端侧 Agent 的时候我为了追求能力丰富注册了三十多个工具。结果模型在这么多工具里选择准确率惨不忍睹经常选错工具或者编造工具名。而且光工具描述就吃掉了大半上下文留给对话的空间所剩无几。后来我把工具砍到八个核心工具准确率立刻上来了上下文也宽裕了。这件事让我明白一个道理端侧 Agent 的能力不在于工具多而在于工具精。与其堆一堆用不上的工具不如把核心工具做深做透。工具的选择和编排本身就是工程化的一部分而且是比写代码更重要的一部分。这个教训后来成了我做端侧 Agent 的一条原则每加一个工具都要问自己这个工具的使用频率够高吗它的描述能不能再精简它能不能和现有工具合并想不清楚这三个问题就不要加。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑