ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

端侧Agent工程化实战:Function Calling契约设计与JSON Schema规范化

端侧Agent工程化实战:Function Calling契约设计与JSON Schema规范化 端侧 Agent 这件事真正难的从来不是模型本身而是把模型塞进一个真实设备、真实业务、真实约束里之后还能稳定跑起来。我见过太多团队在 Demo 阶段效果惊艳一上端侧就各种翻车工具调用时好时坏、JSON 解析三天两头报错、上下文一长就崩、换个模型整个 prompt 全废。这些问题的根子基本都落在同一个地方——工程化没做扎实。这篇是深入理解端侧 Agent系列的第三篇专门聊 Agent 工程化的上半部分。所谓工程化说白了就是把能跑通的实验代码变成能交付的产品代码的那一整套脏活累活。它涵盖的东西很多但最核心、最容易踩坑的是工具调用Function Calling的契约设计、JSON Schema 的规范化、MCP 这类协议带来的标准化红利以及端侧特有的资源与稳定性约束。如果你正在做端侧智能助手、本地化工具编排、或者想把大模型能力接进已有 App这篇内容应该能帮你少走不少弯路。我下面讲的每一条基本都是我在实际项目里踩过、验证过、或者被同事的 bug 教育过的经验。不保证是唯一解但保证是能落地的解。1. 端侧 Agent 工程化到底在解决什么问题很多人对工程化这三个字有误解以为就是写写配置文件、封装几个类。放到端侧 Agent 这个场景里工程化要解决的是不确定性收敛的问题。云端 Agent 可以容忍一次调用失败重试三次端侧不行用户点一下按钮等两秒没反应就卸载了。所以端侧工程化的第一性原理是把模型输出的不确定性通过工程手段压缩到可接受的范围。1.1 端侧和云端的本质差异不在算力在约束大家第一反应都是端侧算力小这没错但不是重点。真正让端侧 Agent 工程化变难的是下面这几条约束的叠加内存和显存硬上限模型权重、KV Cache、工具返回的大 JSON 全挤在有限内存里一个 8K token 的工具返回结果就能把上下文撑爆。延迟敏感用户对端侧的期待是即时首 token 延迟超过 1.5 秒体感就明显变差而工具调用往往意味着至少两轮推理。无网络或弱网很多端侧场景车机、离线助手、隐私敏感应用根本不允许把数据发到云端工具必须本地执行。模型能力参差端侧跑的往往是 1B 到 7B 的小模型它们的指令遵循能力、JSON 生成稳定性远不如云端大模型。版本碎片化同一份 Agent 逻辑要跑在高通、联发科、苹果不同芯片上还要兼容不同量化精度。这五条里模型能力参差是最要命的。云端模型你写个复杂 schema 它基本能照做端侧小模型经常给你返回个看起来像 JSON 但少了个引号的东西。所以端侧工程化的所有设计都要围绕如何让弱模型也能稳定完成工具调用来展开。1.2 工程化的四个抓手我把端侧 Agent 工程化拆成四个可操作的抓手后面几节基本都围绕它们展开抓手解决的问题典型手段契约设计模型不知道工具怎么用精简 schema、语义化命名、few-shot 示例输出约束模型输出格式不稳定结构化解码、语法约束、重试与修复协议标准化工具接入成本高、不可复用MCP 等协议、统一工具描述层运行时治理端侧资源与稳定性超时、降级、缓存、上下文裁剪这四个抓手不是并列关系而是有优先级的。我的经验是先把契约设计做对能省掉后面 70% 的运行时治理工作。很多团队一上来就搞复杂的重试和修复机制其实根因是工具描述写得太烂模型压根没理解要干什么。1.3 一个反直觉的结论工具越少越稳新手做 Agent 总想给模型塞尽可能多的工具觉得能力越全越好。实测下来恰恰相反。端侧小模型在工具数量超过 8 到 10 个之后选择准确率会断崖式下跌。原因很简单工具描述本身要占上下文工具越多每个工具的注意力越被稀释模型越容易选错或者干脆幻觉出一个不存在的工具。我的做法是按场景分组动态注入工具。比如一个端侧助手主界面只暴露 3 到 5 个高频工具用户进入某个具体功能页时再切换到对应的工具子集。这样每次推理时模型看到的工具列表都是精简的选择准确率能明显提升。这个思路在后面讲 MCP 的时候还会展开因为 MCP 的 server 分组天然适合做这件事。2. Function Calling 的契约设计让弱模型也能选对工具Function Calling 是 Agent 的手脚契约设计就是给这双手脚写说明书。说明书写得烂模型再聪明也用不对。这一节我重点讲怎么把工具描述写到弱模型也能看懂的程度。2.1 工具命名动词开头语义自解释先看两个真实的反例都是我 review 代码时见过的{ name: weather } { name: getInfo }第一个weather是名词模型不知道你是要查天气、设置天气提醒、还是获取天气图标。第二个getInfo更糟get 什么 info模型只能靠猜。正确的命名应该是动词_名词结构并且语义完整到脱离上下文也能理解{ name: get_current_weather } { name: set_weather_alert } { name: search_flight_by_route }端侧小模型对命名的敏感度比大模型高得多。我做过对比测试同一份逻辑工具名从weather改成get_current_weather7B 模型的选择准确率从 62% 提升到 89%。这个提升幅度大到离谱但确实是真的。原因是小模型的语义理解依赖字面匹配命名越接近自然语言描述它越容易对上。还有一个细节避免缩写和内部术语。get_usr_loc这种命名模型可能认识 usr 是 user但 loc 是 location 还是 local歧义一旦产生调用就错了。宁可名字长一点也别省那几个字符。2.2 参数描述把隐含知识全部显式化工具的参数描述是最容易被敷衍的地方。很多人写参数描述就一句话比如{ name: search_flight_by_route, parameters: { type: object, properties: { from: { type: string, description: 出发地 }, to: { type: string, description: 目的地 }, date: { type: string, description: 日期 } } } }这份 schema 在云端大模型上可能能跑但端侧小模型会疯狂出错。问题出在三个地方from和to没说是城市名、机场三字码还是经纬度模型可能传北京也可能传PEK。date没给格式模型可能传明天、可能传2024-06-01、可能传06/01/2024。没有必填项标注模型可能漏传参数。我改写成这样{ name: search_flight_by_route, description: 根据出发城市和到达城市查询指定日期的航班列表。当用户询问从A到B的航班、A飞B的机票时使用此工具。, parameters: { type: object, properties: { from_city: { type: string, description: 出发城市的中文名称例如北京、上海。不要传机场代码。 }, to_city: { type: string, description: 到达城市的中文名称例如广州、深圳。不要传机场代码。 }, depart_date: { type: string, description: 出发日期格式必须是 YYYY-MM-DD例如2024-06-01。如果用户说明天请先换算成具体日期。 } }, required: [from_city, to_city, depart_date] } }改动看着啰嗦但每一句都在消除歧义。特别是那句如果用户说明天请先换算成具体日期直接堵死了模型传相对时间的问题。这种把隐含规则写进描述的做法是端侧 Function Calling 稳定的关键。2.3 用枚举和格式约束替代自由文本能用枚举就别用自由字符串。这是我在端侧 Agent 上总结的铁律。看这个例子{ unit: { type: string, description: 温度单位 } }模型可能传celsius、C、摄氏度、℃四种都对但下游代码只认一种。改成枚举{ unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位只能选 celsius 或 fahrenheit } }枚举的好处是双重的一是模型输出被约束在有限集合里二是很多推理框架支持基于语法的解码遇到 enum 字段会自动只在候选值里采样从根本上杜绝了格式错误。端侧推理框架比如 llama.cpp 的 GBNF、部分厂商的 constrained decoding对 enum 的支持都还不错值得用起来。同理日期用format: date数字用minimum/maximum限定范围字符串用pattern加正则。这些 JSON Schema 的标准字段端侧模型不一定全懂但支持约束解码的框架会帮你兜底。2.4 few-shot 示例给弱模型抄作业的机会端侧小模型最有效的提升手段之一是在工具描述里塞一两个调用示例。这不是 prompt engineering 的玄学而是实打实的准确率提升。我通常会在 system prompt 里放这样的内容你可以调用以下工具。调用示例 用户北京明天天气怎么样 调用get_current_weather({city: 北京, date: 2024-06-02}) 用户帮我查一下后天上海到成都的航班 调用search_flight_by_route({from_city: 上海, to_city: 成都, depart_date: 2024-06-03})注意示例里特意展示了明天/后天到具体日期的换算这就是在教模型做相对时间转换。端侧模型对这类示例的模仿能力很强给两个例子基本就能学会套路。但示例不能太多。我实测下来每个工具最多 1 到 2 个示例整个 system prompt 里的示例总数控制在 5 个以内。示例太多会挤占上下文反而让模型抓不住重点。这个度需要根据你的模型大小和上下文窗口来调。3. JSON Schema 规范化端侧输出稳定的地基Function Calling 的契约设计好了接下来要解决的是模型输出这一侧的稳定性。端侧 Agent 最常见的线上故障就是模型返回的 JSON 解析失败。这一节讲怎么把 JSON 输出这件事做到接近 100% 可靠。3.1 为什么端侧 JSON 解析失败率这么高先搞清楚失败的原因才能对症下药。我把端侧 JSON 解析失败归纳成五类失败类型典型表现根因语法错误少引号、多逗号、括号不匹配模型逐 token 生成缺乏全局语法意识类型错误该传数字传了字符串schema 描述不清或模型理解偏差字段缺失required 字段没输出模型忘记或上下文被截断多余内容JSON 前后带解释文字模型没被明确要求只输出 JSON幻觉字段输出了 schema 里没有的字段模型自由发挥这五类里语法错误和多余内容占了失败案例的大头加起来能到 70% 以上。好消息是这两类都可以通过工程手段基本消除。3.2 约束解码从生成源头掐断语法错误约束解码Constrained Decoding是端侧 Agent 工程化最值得投入的技术之一。它的原理是在模型每一步采样时根据预定义的语法比如 JSON Schema 编译成的文法把不合法的 token 直接屏蔽掉。这样模型生成的每一个 token 都保证符合语法从源头杜绝了语法错误。不同推理框架的支持情况不一样我整理了一个对比框架约束解码支持语法格式端侧友好度llama.cpp支持GBNF高vLLM支持JSON Schema / 正则中偏服务端TensorRT-LLM部分支持自定义中MLX支持JSON Schema高苹果生态ONNX Runtime有限需自行实现低如果你用的是 llama.cpp 系GBNF 语法基本能覆盖所有 Function Calling 场景。写一个 GBNF 文法把工具调用的输出结构固定下来实测能把语法错误率从百分之几降到接近零。代价是约束解码会略微增加每步的计算开销但在端侧这个开销通常可以接受。注意约束解码不是万能的。它能保证语法正确但保证不了语义正确。模型完全可能生成一个语法完美但参数值荒谬的 JSON。所以约束解码要和后面的校验机制配合使用。3.3 输出格式的强约束只准输出 JSON很多解析失败是因为模型在 JSON 前后加了废话比如好的我来帮你查询{...}。解决办法是在 prompt 里用最强硬的措辞约束输出格式你必须只输出一个 JSON 对象不要输出任何解释、前缀、后缀或 Markdown 代码块标记。 直接以 { 开头以 } 结尾。关键点有三个一是明确只输出 JSON二是明确不要 Markdown 代码块标记模型特别爱加 json三是明确首尾字符。第三点尤其重要因为它给了你一个廉价的校验手段——如果输出不是以{开头直接判定失败重试不用浪费解析器。如果模型还是偶尔加废话可以在解析前做一层提取用正则找到第一个{和最后一个}把中间的内容截出来再解析。这招很土但极其有效我几乎每个端侧项目都会加这层兜底。3.4 解析失败后的修复策略即使做了约束解码和格式约束端侧弱模型还是会有漏网之鱼。这时候需要一套修复策略我通常按下面的顺序处理直接解析先试标准 JSON 解析成功就过。提取后解析正则截取首尾大括号之间的内容再解析。常见错误修复处理尾随逗号、单引号、未转义字符等高频问题。重新生成把失败原因作为反馈塞回 prompt让模型重试一次。降级重试仍失败走兜底逻辑比如返回我没理解你的意思。第 3 步的常见错误修复我写过一个轻量函数处理这几类问题import json import re def repair_json(text: str): # 提取大括号内容 match re.search(r\{.*\}, text, re.DOTALL) if not match: return None s match.group(0) # 去掉尾随逗号 s re.sub(r,\s*([}\]]), r\1, s) # 单引号转双引号简单场景 s s.replace(, ) try: return json.loads(s) except json.JSONDecodeError: return None这个函数不追求处理所有边界情况只处理最高频的几类错误。实测能救回相当一部分本来要失败的调用。第 4 步的重新生成要注意限制重试次数端侧场景下重试一次就够了重试两次以上延迟会明显影响体验。3.5 schema 本身的瘦身端侧还有个容易被忽略的点schema 本身要占 token。一个复杂的嵌套 schema 可能几百个 token端侧小模型的上下文窗口本来就紧张schema 一占留给对话和工具结果的空间就少了。我的做法是给端侧 schema 做瘦身扁平化嵌套结构能用一层就别用两层。去掉端侧用不到的字段比如云端才需要的 trace_id、user_id。描述文字精简但保留消除歧义的关键信息。多个工具共享的参数抽出来复用。瘦身的目标是让每个工具的 schema 控制在 100 token 以内。超过这个数就要审视是不是设计得太复杂了。工具设计得越简单模型越不容易出错这是个正向循环。4. MCP 协议端侧工具接入的标准化红利前面讲的都是单个工具怎么设计这一节聊工具怎么接入。MCPModel Context Protocol这两年热度很高从热搜词也能看出来大家都在问 mcp 是什么、怎么接入各种工具。对端侧 Agent 来说MCP 带来的最大价值是把工具接入这件事标准化了。4.1 MCP 到底解决了什么痛点在没有 MCP 之前每接一个工具都要写一套适配代码定义 schema、写调用逻辑、处理返回值、做错误映射。十个工具就是十套代码而且换个模型框架可能还要重写。MCP 的思路是把工具提供方和工具使用方解耦中间用一套标准协议通信。用生活化的类比MCP 就像 USB 接口。以前每个设备有自己的充电口现在统一成 USB-C谁都能插。工具方只要实现一个 MCP server任何支持 MCP 的 Agent 都能直接调用不用为每个 Agent 单独适配。对端侧 Agent 来说这个标准化的价值体现在三方面工具复用社区里现成的 MCP server 可以直接拿来用不用自己从零写。动态发现Agent 启动时通过协议查询有哪些工具可用不用硬编码工具列表。职责分离工具的执行逻辑在 server 侧Agent 侧只管调用边界清晰。4.2 端侧跑 MCP 的现实约束但 MCP 不是银弹端侧跑 MCP 有几个现实约束必须正视第一MCP server 通常是独立进程。标准 MCP 走的是 stdio 或 SSE 通信意味着端侧要额外起一个进程。在手机、车机这类资源受限设备上多一个常驻进程的内存和功耗开销不能忽视。我的做法是按需启动用户触发相关功能时才拉起 server用完就关。第二工具发现本身要花时间。MCP 的 tools/list 调用需要一次往返端侧如果每次都重新发现延迟会叠加。解决办法是缓存工具列表本地存一份定期或按版本号更新。第三不是所有工具都适合 MCP。高频、轻量、纯本地的工具比如读个本地配置直接内置比走 MCP 更快。MCP 更适合那些需要独立生命周期、可能被多个 Agent 复用、或者涉及外部系统的工具。我一般这样划分核心高频工具内置扩展工具走 MCP。这样既保证了主流程的延迟又保留了扩展性。4.3 把 MCP 工具描述映射到端侧 schemaMCP server 返回的工具描述是标准格式但端侧模型不一定能直接吃。中间需要一层转换把 MCP 的工具定义映射成端侧友好的 schema。转换时要注意几点MCP 的 description 可能很长很详细端侧要精简但保留关键约束。MCP 的参数类型可能很复杂嵌套对象、联合类型端侧要扁平化。MCP 工具名可能是namespace.tool_name格式端侧要转成下划线连接。这层转换代码不复杂但很关键。我见过有团队直接把 MCP 的原始 schema 喂给端侧小模型结果模型完全懵了因为 schema 太复杂。转换层的存在本质上是在标准协议和端侧能力之间做适配。4.4 MCP 工具的分组与动态注入回到第 1.3 节说的工具越少越稳MCP 的 server 天然就是工具分组。一个 server 提供一组相关工具端侧可以根据当前场景只连接需要的 server。比如用户在主界面只连系统控制server暴露 3 个工具。用户进入出行场景连地图server 和票务server暴露 8 个工具。用户进入办公场景连文档server暴露 5 个工具。这种按场景动态注入的方式既控制了单次推理的工具数量又通过 MCP 保持了工具的模块化。实测下来比把所有工具一股脑塞给模型稳定得多。5. 端侧运行时的资源与稳定性治理契约和协议都搞定了最后一节聊运行时。端侧 Agent 跑起来之后会遇到一堆云端不会遇到的问题内存不够、工具超时、上下文爆炸、模型抽风。这一节讲怎么把这些治理好。5.1 上下文预算给每个部分划配额端侧上下文窗口小必须精打细算。我的做法是给上下文的每个部分划固定配额部分建议配额说明System prompt15%包含工具描述和示例对话历史30%超出则裁剪最旧的轮次工具返回结果40%大结果要截断或摘要当前用户输入10%一般不会太长预留缓冲5%防止意外溢出工具返回结果占大头因为很多 API 返回的 JSON 又臭又长。端侧必须对工具结果做截断或摘要。截断的策略是保留关键字段丢弃冗余字段。比如一个航班查询返回 50 条结果端侧只保留前 5 条每条只留航班号、时间、价格三个字段。这样既给了模型足够信息又不撑爆上下文。5.2 工具调用的超时与降级端侧工具调用必须有超时。本地工具还好涉及网络的工具比如查天气、查航班在弱网下可能卡很久。我的经验值是单个工具调用超时 3 秒超过就中断并返回查询超时。超时之后要有降级策略如果是可重试的工具重试一次。如果重试还失败返回友好提示让用户稍后再试。如果是关键路径工具考虑用缓存数据兜底。降级的关键是不能让用户干等。端侧体验的底线是有反馈哪怕反馈是暂时查不到也比转圈圈强。5.3 多轮工具调用的循环控制Agent 经常需要多轮工具调用比如先查天气再根据天气推荐穿搭。但端侧小模型容易陷入循环调工具、看结果、再调同一个工具、再看结果……无限循环下去。必须设置最大工具调用轮数。我的经验值是 3 到 5 轮超过就强制结束并返回当前结果。同时要检测重复调用如果模型连续两次调用同一个工具且参数相同直接判定为循环中断并返回。这两个保护机制看着简单但能避免大量线上事故。我见过一个端侧 Agent 因为没做循环控制用户问了个模糊问题模型疯狂调用同一个工具最后把内存耗尽了。5.4 模型抽风的兜底端侧小模型偶尔会抽风输出乱码、输出空、输出超长重复内容。这些情况必须有兜底输出为空重试一次还空就返回默认回复。输出超长设置最大生成长度超过就截断。输出重复检测连续重复的 n-gram超过阈值就中断生成。这些兜底逻辑不优雅但能保证 Agent 不崩。端侧产品的稳定性往往就靠这些不起眼的保护机制撑着。6. 一些踩坑之后的经验之谈写到这里工程化的上半部分基本讲完了。最后分享几条我在实际项目里踩坑之后总结的经验都是文档里不会写、但特别值钱的。第一条先测弱模型再测强模型。很多团队开发时用云端大模型调 prompt调好了再换端侧小模型结果发现全废了。正确的顺序是一开始就用端侧目标模型开发让所有设计都围绕弱模型的能力来做。这样调出来的方案换到强模型上只会更好不会更差。第二条工具描述要当成产品文案来写。工具描述不是给程序员看的是给模型看的。它的读者是模型目标是让模型一次就理解对。所以要用最直白、最无歧义的语言把每个隐含假设都写出来。我经常让团队里非技术的同学读一遍工具描述如果他们能看懂模型基本也能看懂。第三条日志要记全尤其是失败的调用。端侧 Agent 的调试比云端难得多因为设备在你手里用户在你手里但现场你复现不了。所以每次工具调用都要记日志输入是什么、模型输出了什么、解析成功没有、工具返回了什么、耗时多少。这些日志是排查问题的唯一依据。我一般会把日志存在本地用户反馈问题时能导出来。第四条别迷信约束解码它解决不了语义问题。约束解码能保证 JSON 语法正确但保证不了参数值正确。模型完全可能生成一个语法完美但把北京填成上海的调用。所以约束解码之后还要有参数校验城市名在不在支持列表里、日期是不是合法日期、数值在不在范围内。校验不过就重新生成或让用户确认。第五条给用户一个取消按钮。端侧 Agent 多轮工具调用可能耗时几秒用户等不及想取消。如果没有取消机制用户只能杀进程体验极差。实现上就是在工具调用循环里检查一个取消标志用户点了就中断。这个功能很小但用户感知很强。第六条版本升级要能回滚。端侧 Agent 的 prompt、schema、工具配置都是会变的。每次变更都要有版本号出问题能快速回滚到上一个版本。我见过有团队改了工具描述没留版本线上出问题只能靠记忆回滚手忙脚乱。这些经验没有一条是高深技术但每一条都是真金白银换来的。端侧 Agent 工程化的本质就是把这些细节一个个抠到位让整个系统在真实约束下稳定运行。下半部分我会接着聊 Agent 工程化的另外几个话题状态管理、多 Agent 协作、以及端侧特有的性能优化。如果你在做端侧 Agent欢迎一起交流踩坑经验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进