1. 端侧 Agent 工程化的核心命题
端侧 Agent 这件事,从概念验证走到真正能交付的产品,中间隔着的不是模型能力,而是工程化。我接触过不少团队,Demo 阶段跑得挺漂亮,一旦要上真机、要控内存、要保证响应延迟、要处理各种边界情况,问题就全冒出来了。这一篇主要聊 Agent 工程化里最基础也最容易被低估的部分:Function Calling 的落地设计、JSON Schema 的约束策略,以及 MCP 在端侧场景下的接入思路。
先把范围界定清楚。这里说的端侧 Agent,指的是推理和决策逻辑主要跑在终端设备(手机、PC、车机、嵌入式设备)上的智能体,而不是把请求全部丢给云端大模型再等结果返回。端侧意味着几个硬约束:算力有限、内存有限、功耗敏感、网络可能不稳定甚至离线。这些约束直接决定了 Agent 工程化的设计取向——你不能照搬云端那套"模型足够大、上下文足够长、随便调工具"的思路。
Function Calling 是 Agent 和外部世界交互的核心机制。模型输出一个结构化的调用请求,运行时解析这个请求,执行对应的函数,再把结果喂回模型。听起来简单,但工程化要解决的问题一大堆:Schema 怎么定义才能让模型稳定输出?参数校验放在哪一层?调用失败怎么重试?多个工具怎么编排?MCP 作为这两年被广泛讨论的工具接入协议,在端侧又该怎么用?这些就是本文要拆开讲的内容。
适合谁看?如果你正在做端侧 AI 应用,或者准备把云端 Agent 往端侧迁移,又或者你只是想把 Function Calling 这套机制理解透,这篇应该能给你一些可以直接抄的工程方案。我会尽量把每个设计决策背后的"为什么"讲清楚,而不是只给结论。
2. Function Calling 的工程化拆解
2.1 从"能调通"到"稳定调通"的差距
很多人第一次跑通 Function Calling 是在云端 API 上,写个 JSON Schema,模型返回一个 tool_calls 数组,解析出来执行,完事。但端侧完全是另一回事。端侧模型参数量通常小得多,指令遵循能力弱,对 Schema 的理解容易出现偏差。我实测过一个 3B 级别的模型,同样的 Schema,云端大模型能 100% 正确输出,端侧模型大概只有 70% 左右的首次正确率,剩下的要么字段名拼错,要么参数类型搞混,要么干脆输出一段自然语言而不是结构化调用。
这个差距就是工程化要填的坑。核心思路是:不要指望模型一次就对,而是设计一套容错和约束机制,让整体成功率逼近可用水平。
具体来说,端侧 Function Calling 的工程化要解决四层问题:
- Schema 层:怎么定义工具描述,让模型更容易理解
- 解析层:怎么从模型输出里稳健地提取结构化调用
- 执行层:怎么安全地执行函数,处理异常
- 编排层:多工具场景下怎么决定调用顺序和依赖关系
这四层每一层都有讲究,下面逐个拆。
2.2 JSON Schema 的约束策略:让模型少犯错
JSON Schema 是 Function Calling 的契约。模型根据 Schema 生成参数,运行时根据 Schema 校验参数。端侧场景下,Schema 的设计原则和云端不太一样,核心是降低模型的认知负担。
第一个原则:字段名要语义直白,别玩缩写。我见过有人把destination_city写成dest,把departure_time写成dep_t。云端大模型见多识广,能猜出来,端侧小模型直接懵。字段名就是给模型看的提示,越直白越好。
第二个原则:枚举值优先于自由文本。如果某个参数只有几种可能取值,一定用 enum 约束死。比如查询天气的工具,unit参数就用["celsius", "fahrenheit"],不要让模型自由发挥。端侧模型在自由文本上的稳定性远不如在枚举选择上。
第三个原则:必填字段尽量少。每多一个 required 字段,模型出错的概率就上升一截。能通过默认值解决的,就不要设成必填。比如language参数,默认"zh"就行,没必要让模型每次都输出。
第四个原则:描述要写"什么时候用",而不只是"是什么"。这是最容易被忽略的一点。工具描述里除了说明参数含义,更要说明这个工具在什么场景下应该被调用。端侧模型对上下文的理解能力弱,明确的触发条件描述能显著提升调用准确率。
下面是一个对比示例,左边是容易出错的写法,右边是端侧友好的写法:
// 不推荐:字段名缩写,描述含糊 { "name": "q", "description": "query", "parameters": { "type": "object", "properties": { "c": {"type": "string"}, "t": {"type": "string"} } } } // 推荐:字段名直白,描述含触发条件 { "name": "search_weather", "description": "查询指定城市的当前天气。当用户询问某地天气、温度、是否下雨时调用此工具。", "parameters": { "type": "object", "properties": { "city_name": { "type": "string", "description": "城市名称,例如:北京、上海、深圳" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认摄氏度" } }, "required": ["city_name"] } }2.3 参数校验与类型转换的实操细节
Schema 定义好了,模型输出的参数不一定符合预期。端侧工程化必须在执行函数之前做一层严格的校验和转换。这层我通常叫它参数网关,它的职责是把模型输出的"半结构化"数据变成函数能安全消费的"强类型"数据。
校验要覆盖几个维度。类型校验是基础,模型可能把数字输出成字符串,把布尔输出成"true"字符串。这种要做类型转换,而不是直接报错。范围校验也重要,比如page_size参数模型可能输出 10000,你得 clamp 到合理范围。枚举校验必须做,模型可能输出一个不在 enum 里的值,这时候要么回退到默认值,要么触发重新生成。
我踩过的一个坑是空值和缺失值的处理。模型有时候会输出null,有时候干脆不输出某个字段。这两种情况在工程上要区分对待:null可能表示"用户明确要求空",缺失表示"模型没生成"。对于可选参数,缺失就填默认值;对于必填参数,缺失就触发重试。这个逻辑不写清楚,后面会出现各种诡异的 bug。
还有一个细节是字符串的清洗。端侧模型输出的字符串经常带多余的空格、换行,甚至带引号。比如城市名输出成" 北京 "或者'"北京"'。执行函数前统一做 trim 和去引号处理,能省掉很多莫名其妙的失败。
def validate_and_normalize(raw_args, schema): """参数网关:校验并规范化模型输出的参数""" normalized = {} properties = schema.get("properties", {}) required = schema.get("required", []) for field, spec in properties.items(): value = raw_args.get(field) # 缺失值处理 if value is None: if field in required: raise ValueError(f"必填字段缺失: {field}") if "default" in spec: normalized[field] = spec["default"] continue # 类型转换 expected_type = spec.get("type") if expected_type == "string": value = str(value).strip().strip('"').strip("'") elif expected_type == "integer": value = int(float(value)) elif expected_type == "number": value = float(value) elif expected_type == "boolean": if isinstance(value, str): value = value.lower() in ("true", "1", "yes") # 枚举校验 if "enum" in spec and value not in spec["enum"]: if "default" in spec: value = spec["default"] else: raise ValueError(f"字段 {field} 的值 {value} 不在允许范围内") # 范围校验 if expected_type in ("integer", "number"): if "minimum" in spec: value = max(value, spec["minimum"]) if "maximum" in spec: value = min(value, spec["maximum"]) normalized[field] = value return normalized这段代码看着简单,但每一个分支都是实际踩坑踩出来的。特别是类型转换那块,端侧模型输出"3"而不是3的情况太常见了,不做转换直接崩。
2.4 工具描述里的"负向约束"
除了正向描述工具能做什么,端侧场景下我强烈建议加上负向约束,也就是明确告诉模型"什么情况下不要调用这个工具"。这听起来反直觉,但实测有效。
原因在于端侧模型的"过度调用"倾向。小模型有时候会把不相关的用户输入也匹配到某个工具上。比如用户说"今天心情不错",模型可能莫名其妙去调天气工具。在工具描述里加一句"仅当用户明确询问天气相关信息时调用,其他情况不要调用",能明显降低误触发率。
这个技巧在工具数量多的时候尤其重要。工具越多,模型的选择困难越严重,误调用率越高。负向约束相当于给每个工具划定了清晰的边界。
3. MCP 在端侧 Agent 中的接入思路
3.1 MCP 到底解决了什么问题
MCP(Model Context Protocol)这两年被讨论得很多,各种工具都在接入。但很多人对它的理解停留在"又一个工具调用协议"的层面,没抓住它真正解决的问题。我的理解是:MCP 把工具的定义、发现、调用标准化了,让 Agent 和工具之间解耦。
在没有 MCP 之前,每个 Agent 框架都有自己的工具定义方式,换个框架工具就得重写。MCP 定义了一套标准的通信协议,工具以 Server 的形式暴露能力,Agent 作为 Client 去发现和调用。这意味着一个写好的 MCP Server,理论上可以被任何支持 MCP 的 Agent 使用。
对端侧来说,这个标准化的价值在于工具生态的复用。端侧设备算力有限,不可能什么都自己实现。如果社区里有现成的 MCP Server,直接接进来就能用,省掉大量开发工作。当然,端侧接入 MCP 也有自己的约束,不能照搬云端那套。
3.2 端侧 MCP 的三种接入模式
根据端侧设备的资源情况,MCP 接入大致有三种模式,各有适用场景。
模式一:本地进程内 MCP。MCP Server 和 Agent 跑在同一个进程里,通过函数调用直接通信,省掉了网络开销。这种模式适合工具逻辑简单、不需要独立生命周期的场景。优点是延迟最低,缺点是工具和 Agent 耦合紧,一个崩了另一个也受影响。
模式二:本地独立进程 MCP。MCP Server 作为独立进程运行,Agent 通过本地 IPC 或 localhost 通信。这种模式隔离性好,工具崩溃不影响 Agent 主进程,也方便单独更新工具。代价是多了一层进程间通信开销。端侧 PC 和车机场景我比较推荐这种。
模式三:远程 MCP。MCP Server 部署在远端,Agent 通过网络调用。这种模式工具能力最强,但对网络有依赖,端侧离线场景不可用。适合作为本地工具的补充,而不是主力。
选择哪种模式,核心看两个维度:工具是否需要独立生命周期,以及网络是否可靠。端侧场景下,我一般建议核心工具走本地进程内或本地独立进程,非核心的、需要强算力的工具走远程,并且做好降级处理。
3.3 MCP 工具发现与 Schema 转换
MCP 的一个核心能力是工具发现:Agent 连接上 MCP Server 后,可以拉取到 Server 暴露的所有工具及其 Schema。这个 Schema 是 MCP 协议定义的格式,需要转换成端侧模型能理解的 Function Calling Schema。
这个转换过程有几个坑。MCP 的 Schema 可能比端侧模型能处理的复杂得多,比如嵌套对象、数组、复杂的 oneOf/anyOf 结构。端侧模型对这些复杂结构的支持很差,直接喂进去基本会输出乱七八糟的结果。
我的做法是做一层Schema 简化:把嵌套结构拍平,把复杂的联合类型简化成枚举,把用不上的可选字段裁掉。宁可损失一些表达能力,也要保证模型能稳定输出。具体来说,遇到嵌套对象,要么拍平成parent_child这样的字段名,要么拆成多个工具;遇到 oneOf,如果分支不多就转成 enum 加一个 type 字段。
def simplify_mcp_schema(mcp_schema, max_depth=2): """把 MCP 的复杂 Schema 简化成端侧模型友好的扁平结构""" def flatten(schema, prefix="", depth=0): result = {} if depth > max_depth: return result props = schema.get("properties", {}) for name, spec in props.items(): full_name = f"{prefix}_{name}" if prefix else name spec_type = spec.get("type") if spec_type == "object" and "properties" in spec: # 嵌套对象拍平 result.update(flatten(spec, full_name, depth + 1)) elif spec_type == "array": # 数组简化成字符串,让模型输出逗号分隔 result[full_name] = { "type": "string", "description": f"{spec.get('description', name)},多个值用逗号分隔" } else: # 基础类型保留,但去掉复杂约束 simplified = { "type": spec_type or "string", "description": spec.get("description", "") } if "enum" in spec: simplified["enum"] = spec["enum"] result[full_name] = simplified return result return { "type": "object", "properties": flatten(mcp_schema), "required": mcp_schema.get("required", []) }这段简化逻辑看着粗暴,但在端侧实测下来,工具调用的成功率比直接用原始 Schema 高出一大截。工程上很多时候"够用"比"完备"更重要。
3.4 MCP 调用的超时与降级
端侧接入 MCP,尤其是远程 MCP,必须处理超时和降级。网络抖动、Server 无响应、返回格式异常,这些都要有兜底。
我的做法是给每个 MCP 调用设一个分级超时:本地进程内调用 500ms,本地独立进程 2s,远程调用 5s。超过阈值就中断,返回一个明确的错误给模型,让模型决定是重试还是换工具。这里的关键是错误信息要结构化,不能只返回"调用失败",要告诉模型失败原因,模型才能做出合理决策。
降级策略上,核心工具要有本地备份实现。远程 MCP 挂了,自动切到本地简化版。虽然能力弱一些,但保证功能可用。这个降级逻辑要在 Agent 编排层实现,对模型透明。
4. 多工具编排与执行链路设计
4.1 单轮调用与多轮调用的取舍
Function Calling 有两种基本模式:单轮调用和多轮调用。单轮是模型一次输出所有需要的工具调用,并行执行;多轮是模型调一个工具,拿到结果再决定下一步。
端侧场景下,我倾向于优先单轮,必要时多轮。原因是端侧模型的多轮推理能力弱,轮次越多,上下文越长,出错概率越高。能在一次调用里解决的,就不要拆成多轮。
但有些场景确实需要多轮,比如后一个工具的输入依赖前一个工具的输出。这种依赖关系要在工具描述里写清楚,或者通过编排层显式处理。我的经验是,依赖链超过三层,端侧模型基本就hold不住了,这时候要考虑把多个工具合并成一个复合工具,或者把部分逻辑下沉到代码里。
4.2 工具调用的并发与串行控制
多个工具调用之间,有些可以并发,有些必须串行。判断标准是是否存在数据依赖。无依赖的并发执行能显著降低总延迟,端侧尤其重要。
实现上,我会在编排层维护一个依赖图。模型输出的 tool_calls 数组里,每个调用标注它依赖哪些前置调用的结果。编排层根据依赖图做拓扑排序,无依赖的并发执行,有依赖的串行执行。
这里有个细节:并发数要限制。端侧设备资源有限,同时跑太多工具调用会拖垮系统。我一般限制并发数为 3 到 5,超出的排队。这个阈值要根据设备实际性能调,不能拍脑袋定。
4.3 结果回填与上下文管理
工具执行完,结果要回填给模型。这里最容易出问题的是结果太长。端侧模型上下文窗口小,工具返回一大段 JSON 或者长文本,直接把上下文撑爆。
我的处理方式是结果摘要 + 截断。工具返回的结果先做摘要,只保留模型决策需要的关键信息。比如搜索工具返回 20 条结果,只回填前 3 条的标题和摘要。如果结果本身就很长,做硬截断,并在末尾标注"结果已截断"。
另一个细节是结果的格式。回填给模型的结果最好是结构化的,字段名和工具 Schema 对应,这样模型更容易理解。纯自然语言的结果虽然可读,但模型解析起来不稳定。
def format_tool_result(tool_name, raw_result, max_tokens=500): """把工具执行结果格式化成模型友好的形式""" # 先做结构化 if isinstance(raw_result, dict): # 只保留关键字段 key_fields = ["status", "data", "error", "summary"] filtered = {k: v for k, v in raw_result.items() if k in key_fields} result_str = json.dumps(filtered, ensure_ascii=False) else: result_str = str(raw_result) # 再做长度控制 if len(result_str) > max_tokens * 4: # 粗略估算 token result_str = result_str[:max_tokens * 4] + "...[结果已截断]" return { "role": "tool", "name": tool_name, "content": result_str }4.4 失败重试与错误恢复
工具调用失败是常态,不是异常。端侧工程化必须把失败处理当成一等公民。
失败分几类:参数错误(模型输出不符合 Schema)、执行错误(函数内部抛异常)、超时(调用超过阈值)、结果异常(返回格式不对)。不同类别的处理策略不一样。
参数错误,把校验失败的详细信息回填给模型,让它重新生成参数,最多重试 2 次。执行错误,如果是可恢复的(比如临时网络问题),重试;如果是不可恢复的(比如参数本身非法),直接返回错误让模型换方案。超时,中断并返回超时信息。结果异常,尝试解析,解析不了就当失败处理。
重试要有退避策略,不能立即重试,否则可能连续失败。我一般用指数退避,第一次等 100ms,第二次 300ms,第三次 900ms。超过三次就放弃,返回最终错误。
5. 端侧 Agent 工程化的常见问题排查
5.1 模型不调用工具或乱调用工具
这是最高频的问题。表现是模型该调工具的时候不调,或者不该调的时候乱调。排查思路分几步。
先看工具描述。描述里有没有明确说明触发条件?有没有负向约束?字段名是不是够直白?这些是最常见的原因。我遇到过工具描述写得太学术,模型理解不了,改成大白话就好了。
再看 Schema 复杂度。Schema 太复杂,模型处理不了,会倾向于不调用。简化 Schema 通常能解决。
最后看模型本身的能力。如果换了几个 Schema 写法都不行,可能是模型对 Function Calling 的支持本身就弱。这时候要么换模型,要么在 Prompt 里加 few-shot 示例,手动教模型怎么调。
5.2 参数输出不稳定
同一个工具,同样的输入,模型输出的参数格式时好时坏。这种不稳定在端侧小模型上很常见。
解决思路是增加约束。能用 enum 的用 enum,能设默认值的设默认值,能限制范围的限制范围。约束越多,模型的自由度越小,输出越稳定。代价是灵活性下降,但端侧场景下稳定性优先。
另一个技巧是在工具描述里给示例。比如参数描述里写"例如:北京、上海",模型会倾向于模仿这个格式。这个技巧对字符串参数特别有效。
5.3 多工具场景下的选择困难
工具一多,模型就不知道该选哪个。表现是频繁选错工具,或者把多个工具的调用混在一起。
我的经验是控制工具数量。单次暴露给模型的工具不要超过 7 个,超过就分组,或者用两阶段选择:先让模型选工具类别,再在类别内选具体工具。这个"7"不是拍脑袋,是实测下来端侧模型能稳定处理的工具数量上限。
如果工具确实多,还可以用工具路由的思路。用一个轻量级的分类器先做工具预选,只把相关的几个工具暴露给模型。分类器可以是很小的模型,甚至基于规则的匹配,成本很低。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 模型不调用工具 | 描述缺触发条件 | 检查工具描述 | 补充"何时调用"说明 |
| 模型乱调用工具 | 缺负向约束 | 检查描述边界 | 加"何时不调用"说明 |
| 参数格式错误 | Schema 太复杂 | 检查嵌套层级 | 拍平 Schema,简化类型 |
| 参数值不稳定 | 缺约束 | 检查 enum/默认值 | 增加枚举和默认值 |
| 选错工具 | 工具数量过多 | 统计暴露工具数 | 控制在 7 个以内 |
| 调用超时 | 网络或工具慢 | 检查调用链路 | 分级超时 + 降级 |
| 上下文溢出 | 结果太长 | 检查回填内容 | 摘要 + 截断 |
| 重试风暴 | 无退避策略 | 检查重试逻辑 | 指数退避 + 次数上限 |
5.5 几个我踩过的坑
第一个坑是过度依赖模型的自我纠错。我一开始觉得模型调用失败后,把错误信息回填,模型能自己修正。实测下来,端侧小模型的自我纠错能力很弱,同一个错误可能连续犯三次。后来改成在编排层做硬性校验和修正,不指望模型自己改,成功率才上来。
第二个坑是忽略冷启动开销。端侧模型首次加载、MCP Server 首次连接,都有明显的冷启动延迟。如果不在工程上处理,用户第一次交互的体验会很差。我的做法是应用启动时预热,把模型加载和工具连接提前做掉,用户感知不到。
第三个坑是Schema 版本管理混乱。工具 Schema 改了,但模型侧的 Prompt 没同步更新,导致调用失败。后来我强制要求 Schema 变更必须走版本号,Prompt 里引用版本号,不匹配就报警。这个机制救过我好几次。
第四个坑是低估了字符串编码问题。端侧设备环境复杂,中文、emoji、特殊字符在工具参数里传输时经常出问题。统一用 UTF-8 编码,并且在参数网关里做字符清洗,能避免大部分问题。
6. 工程化落地的性能与资源考量
6.1 端侧资源预算怎么定
端侧 Agent 的资源预算是硬约束,必须在设计阶段就定清楚。我一般按这几个维度做预算:内存占用、CPU 占用、功耗、存储空间。
内存是大头。模型本身占一部分,上下文缓存占一部分,工具执行占一部分。端侧设备如果总内存 4GB,留给 Agent 的通常不超过 1GB。这 1GB 里,模型可能占 600MB,上下文缓存 200MB,工具执行 200MB。预算定死了,后面所有设计都要在这个框里做。
CPU 占用要关注峰值。工具调用并发执行时 CPU 会飙高,如果超过设备承受能力,会触发降频甚至卡死。我的做法是限制并发数,并且给工具执行设优先级,核心工具优先。
功耗在移动端特别重要。频繁的模型推理和工具调用会快速耗电。优化手段包括:合并调用减少推理次数、缓存常用结果、在设备空闲时预计算。这些优化要结合具体场景做,没有通用方案。
6.2 延迟优化的几个实操手段
端侧 Agent 的响应延迟直接影响体验。我实测下来,用户能接受的首次响应延迟大概在 2 秒以内,后续交互 1 秒以内。超过这个阈值,体验就明显下降。
优化延迟的手段,按效果排序:模型量化效果最明显,把 FP16 量化到 INT8,推理速度能提升一倍以上,精度损失可控。KV Cache 复用也很关键,多轮对话时复用之前的 KV Cache,能省掉大量重复计算。工具预执行是另一个思路,根据用户输入预测可能要调的工具,提前执行,等模型决策时结果已经就绪。
还有一个容易被忽略的点是首 token 延迟。端侧模型的首 token 延迟往往比后续 token 高很多,因为要做 prefill。优化 prefill 速度,比如用 chunked prefill,能明显改善首响应体验。
6.3 离线场景的降级设计
端侧 Agent 必须考虑离线。网络断了,远程 MCP 用不了,云端模型调不了,Agent 不能直接罢工。
我的降级设计分三层。第一层,本地模型接管,虽然能力弱,但基本功能可用。第二层,本地工具接管,远程工具的能力用本地简化版替代。第三层,纯规则兜底,连模型都用不了的时候,用预设规则处理常见请求。
这三层降级要在 Agent 启动时就配置好,运行时根据网络状态自动切换。切换过程对用户透明,最多给个提示"当前处于离线模式,部分功能受限"。
7. 一些工程之外的体会
做端侧 Agent 工程化这段时间,最大的体会是别跟端侧的约束较劲。云端那套"模型够大就行"的思路在端侧行不通。端侧的核心是在约束下找最优解,而不是突破约束。
另一个体会是工程化比模型能力更重要。我见过模型能力一般但工程做得扎实的产品,体验比模型强但工程粗糙的产品好得多。端侧尤其如此,因为模型能力的上限被硬件卡死了,能拉开差距的就是工程。
还有就是别过度设计。端侧资源有限,每一份开销都要花在刀刃上。我一开始想做一个很完备的工具编排系统,支持各种复杂依赖,后来发现实际场景里 90% 的调用都是简单的单工具或双工具,复杂编排根本用不上。砍掉那些用不上的功能,系统反而更稳。
最后分享一个小技巧:给 Agent 加一个"思考预算"。端侧模型推理慢,如果让它无限制地思考,延迟会失控。我在 Prompt 里明确告诉模型"你最多有 3 步推理",模型会倾向于快速决策而不是反复纠结。这个约束对端侧场景特别有效,实测能把平均响应延迟降低 30% 左右。
MCP 这块后续还可以继续扩展,比如 MCP Server 的本地缓存策略、多 Server 的负载均衡、Server 健康检查这些,都是端侧落地会遇到的工程问题。等我把这些在实际项目里跑通验证了,再单独写一篇聊聊。