你带了一个智能家居 Demo 去见客户,开场一切顺利:用户问“今天室外多少度”,模型流利地播报了一段天气信息。可当对方说“顺便把卧室灯光调到 60%”时,模型只是回了句“好的,帮你调亮一些”,然后……房间里的灯纹丝不动。这样的事我见过太多次了——大模型擅长“说”,却常常不负责“做”。从“对话”迈向“行动”,关键不在于把提示词写得更花哨,而在于让模型学会输出一种可执行的结构化指令。我后面在一系列端侧智能体项目里反复验证过这个方向,最终把方案锁定到 FunctionGemma 这类专门优化函数调用的轻量模型上,用它在设备本机完成“理解 → 决策 → 调用 → 回执”的完整闭环。这篇文章会从原理拆解、架构搭建、落地部署和问题排查四条线,把这条路径完整讲清楚,适合正在做手机 App 助手、智能硬件或本地知识助手的开发者参考。
1. 为什么要把智能体从“对话”升级到“行动”
1.1 对话只是“嘴上功夫”,行动才是闭环
用户面对一个智能体,需求其实可以分为三层。第一层是“想知道”,比如问天气、问菜谱;第二层是“想被代办”,比如设置提醒、订购票务;第三层是“想控制系统”,比如开关灯、发消息。传统的对话模型天然擅长第一层,因为“生成一段通顺文字”就是它的训练目标。但第二层和第三层,光生成文字是不够的,系统背后必须真的调用某个 API、执行某个动作,再把结果反馈给用户。
问题就出在这里:绝大多数通用大模型的输出通道只有一个——文本 token。你让它“把卧室灯调到 60%”,它没有能力直接改变任何硬件状态,只能把一句“好的,灯光已调亮”当作回复。这句话表面上很礼貌,实际上把用户的期待推向了一个虚假结果。要打破这层壁垒,必须引入工具调用机制:让模型不只输出自然语言,还能输出一个结构化的“命令”,例如{"name":"set_light","arguments":{"room":"卧室","brightness":60}},然后由业务层解析命令并真正执行。
这就像你想找一个人帮忙订票。如果他只是口头上说“没问题,你稍等”,你很难判断到底办没办成。可如果他转身打开系统、操作了几下,回来把订单号发给你,你才确信事情真的办完了。智能体也一样,判断它是不是“真智能体”的标准,不是聊天流畅度,而是能否在现实里形成动作闭环。
1.2 工具调用是智能体的“手脚”,没有手脚动不了
工具调用的技术本质并不复杂:系统先定义一组工具的 JSON Schema,包括工具名称、描述、参数类型和必填字段;模型根据用户的当前问题,结合这些 Schema 决定调用哪个工具、传入什么参数;框架负责实际执行,并把执行结果回传给模型,让模型生成最终回复。整个过程就像给大模型装上了“手脚”。
举个例子:用户说“帮我查一下明天某地的天气,如果下雨就提醒我带伞”。这条指令表面是一个请求,实际包含两个动作:第一,查询天气;第二,根据天气结果决定是否创建提醒。处理链路会这样走:
- 模型输出第一个工具调用:
get_weather(city, date) - 框架执行查询,拿到“明日有小雨”的结果
- 结果回填到上下文
- 模型根据结果输出第二个工具调用:
create_reminder(time, content) - 框架创建提醒,返回“创建成功”
- 模型最终回复用户:“已查过,明天某地有雨,我帮你设置了出门提醒。”
这套流程在云端大模型上已经很成熟,各大云端接口都内置了工具调用能力。但端侧智能化设备的限制要苛刻得多:没有大算力、没有高速网络、甚至不能轻易把用户数据传到云上。所以端侧智能体需要的是一个“轻量但足够稳定”的工具调用模型,而不是一个通用知识特长但体积巨大的全能模型。这正是 FunctionGemma 这类模型出现的背景。
1.3 端侧智能体绕不开的三个约束
做端侧不等于把云端的调用逻辑原样搬过来,它至少有三个独特的约束,直接影响模型选型和技术设计。
第一个约束是算力。手机上可用的 CPU/GPU/NPU 资源非常紧张,想要连续推理一个几十亿参数的模型并不现实,通常只能部署 0.5B~2B 的小模型。算力一受限,模型的知识广度和上下文能力都会缩水,这种情况下如果它还不会结构化工具调用,那基本就是个摆设。
第二个约束是离线可用性。很多用户场景发生在弱网甚至无网环境,比如地下停车场、电梯、飞行模式。智能体不能一遇到工具调用就依赖云端接口,必须能在本地完成大部分“识别意图—匹配工具—填充参数”的工作。
第三个约束是工具集。端侧设备的工具集合往往很固定,比如智能音箱能控制的设备就那些:灯、空调、插座、闹钟。说固定其实也是好事,因为模型不用面对成千上万动态变化的 API,只要把有限的工具描述吃透即可。这给端侧提供了“集中优势兵力”的机会——把模型的能力全部押在工具调度上,不做花哨的通用闲聊。
2. FunctionGemma 的核心原理与优势
2.1 它是谁:专为函数调用优化的轻量模型
FunctionGemma 是基于开源 Gemma 系列模型调整出的一个分支,定位很明确:把模型的能力收敛到“函数调用”这件事上。它不会去跟那些上百亿参数的通用模型拼知识储备,而是把有限的参数集中在工具选择、参数抽取、格式稳定这些关键能力上。
我最早接触它是在某个端侧语音助手的原型里。当时团队想找一个能在 2GB 内存余量内跑起来的模型,同时还要能稳定输出工具调用。试过几个通用小模型,虽然能聊天,但一旦要求它输出 JSON 格式的工具调用,成功率就飘忽不定。后来换到 FunctionGemma 之后,工具调用的稳定性立刻有了可感知的提升。原因在于它的训练数据里包含了大量工具调用样本,模型的权重已经学会了“什么时候该调用工具、工具参数该长什么样”的规律,而不是每次都要靠提示词里的格式说明去临时凑。
这种“专才”路线在端侧是最务实的。通用模型就像一个什么都会一点的全科医生,能看图、能翻译、能写诗,可你要他熟练地开某个特殊处方,他反而不如一个专科医生稳。端侧智能体需要的就是“专科医生”——它不需要上知天文下知地理,它只需要把本机那几十个工具用得漂漂亮亮。
2.2 输出为何稳定:训练与约束解码的双重保障
聊工具调用稳定性之前,先看看“伪函数调用”的经典翻车现场。你在提示词里写“请输出 JSON”,模型也确实输出了 JSON,但前脚刚输出完字段,后脚又补了一句“好的,请问您还有什么需要吗”。这一句话就把整个 JSON 破坏了,解析器直接报错。这类问题在通用小模型上尤其常见,因为它的生成机制决定了每个 token 都是按概率“顺口溜”出来的,很容易顺着语言惯性溜回自然语言。
FunctionGemma 对此有两道防线。第一道防线是微调:它在针对性样本上训练过,模型对“输出结构化工具调用”的路径已经形成肌肉记忆,默认不会往自然语言方向漂移。第二道防线是约束解码:一些推理框架允许在生成时套一层 JSON 语法约束,让模型永远不输出非法字符。比如大括号没闭合、字段名重复、字符串少引号,这类问题在约束限制下根本不会发生。
把这两道防线比作开长途车:模型权重是司机,约束解码是车道线。司机技术好,能自己稳稳开;车道线则保证他即使走神也不至于冲出马路。两道防线加在一起,工具调用成功率和 JSON 合法率都远高于通用模型的“提示词硬凹”。
2.3 一次工具选择在内部是怎么发生的
从使用者的角度看,FunctionGemma 的推理过程非常简单:你按规定的提示词格式把工具 Schema 和用户问题拼接好,模型输出一段结构化结果。如果它认为需要调用工具,会输出一个包含工具名和参数 JSON 的对象;如果不需要调用工具,就直接生成自然语言回复。
这个“要不要调用工具”的判断,本质上是一个条件生成任务。模型在编码了用户意图和工具描述之后,会计算“调用工具”这个行为的概率,也会计算“直接回复”这个行为的概率。因为训练数据里覆盖了大量“重复意图”的场景,模型倾向于把模糊表述区分为不同的调用分支,所以参数填充和工具选择的准确度会更高。
需要注意的是,FunctionGemma 默认的提示词格式有它的讲究,比如工具描述要放在 System 消息里,工具调用结果要用特定的观察者角色回填。如果你把一套通用聊天模型的提示词直接搬过来,效果不会好。后面在实操章节,我会给出可以直接照用的模板。
3. 端侧智能体的系统架构与数据流
3.1 核心模块划分:工具注册、调度、上下文管理
端侧智能体不需要云端 Agent 那样庞大的分布式框架,但基础模块不能缺。我把系统拆成三个核心组件,在有限的设备资源里保持结构清爽。
第一个是工具注册中心。它负责把所有可被模型调用的外部能力汇总成一张工具清单,每个工具都带一个描述自己的 JSON Schema。这个模块的意义是把“意图”和“API”做映射:用户说“开灯”,对应到灯光控制工具;用户说“新建提醒”,对应到提醒工具。注册中心做得越清晰,模型后面的选择就越准确。
第二个是调用调度器。它拿到模型输出的工具调用指令后,先做参数校验,再真正执行函数,同时处理超时、重试和异常。调度器是“行动”的直接执行者,它最需要关注的是健壮性——不能因为一个工具报错就导致整个智能体崩溃。
第三个是上下文管理器。它保存用户对话历史、工具调用结果、系统提示词等所有需要喂给模型的信息。端侧内存有限,上下文管理器不能像云端那样把所有历史都堆进去,而要有策略地裁剪。我的做法是只保留最近 4~6 轮对话,同时对冗长的工具返回结果做摘要压缩,避免上下文窗口被大段 JSON 撑爆。
3.2 一次完整任务的主循环
以“用户让助手把客厅灯打开并调亮”为例,完整主循环分六步。
第一步,捕获用户输入,并做一次轻量预检查。如果用户只是在打招呼,可以直接走普通对话分支,不必进入工具推理流程,省去一次不必要的模型调用。第二步,把工具注册中心的 Schema 和用户输入合并,交给 FunctionGemma 做推理。第三步,判断模型的输出:如果它没有输出工具调用标签,就直接把输出文本作为回复返回;如果输出了工具调用,继续第四步。第四步,解析调用参数,比对工具 Schema 中的必须有字段,确认无误后交给调度器执行。第五步,把执行结果封装成一条 ToolResult 消息,追加到上下文。第六步,让模型基于这个结果再生成一次最终回复,如果模型在第二轮又发起了新的工具调用,则重复前面的步骤。
整个循环看着简单,但在实现上有几个关键细节。比如第二步和第六步的推理参数应该不同:工具选择阶段要把温度设成 0,让输出尽可能确定;最终回复阶段可以稍微调高温度,让语气更自然。再比如第四步的异常处理,如果参数校验不通过,不要硬调,而是把校验错误信息也作为 ToolResult 回填给模型,让它自己修正。
3.3 多轮工具调用的状态管理
端侧智能体最常见的坑不是第一轮工具调用,而是多轮调用时的状态管理。用户说“帮我把卧室灯调到 40%,然后告诉家人我出门了”,这里涉及两个不同工具:灯光控制和消息发送。如果第二个工具依赖第一个工具的结果,就必须采用串行模式;如果两个工具之间没有依赖,则可以放入一个并行批次同时执行。
我在一个智能家居原型里就踩过串行调用的坑:两个工具串行跑,总共消耗了三次模型推理,一次是选择工具,一次是执行第一个工具后生成,一次是执行第二个工具后生成,加上手机端本来就慢,用户能明显感觉到延迟。后来我把没有依赖关系的工具调用放进并行容器,一次推理可以返回多个tool_calls,调度器并发执行,总耗时直接减半。
状态机也要提前设计好。我习惯给每个工具调用定义几个状态:待执行、执行中、成功、失败、跳过。当第一个工具失败时,系统可以依据“是否影响后续工具”来决定继续还是终止。比如查询天气失败,后面的“根据天气提醒带伞”就没有执行价值,可以直接终止并告诉用户;但如果只是查询天气失败,用户还要求“顺便打开灯”,则灯的调用不受影响,应该继续执行。这种细节看似不起眼,却决定了用户对智能体“靠不靠谱”的直接感受。
4. 从零搭建:工具定义与调用落地
4.1 工具注册:用 JSON Schema 把意图描述清楚
工具定义是整个智能体最难做好的一步,没有之一。Schema 写得好不好,直接决定 FunctionGemma 上手的准确率。我见过很多团队把工具描述写得很随意,比如灯光控制的 description 只写“控制灯光”四个字,这太模糊了,模型根本分不清它和“调节场景”有什么差别。
我的建议是描述里要写清楚“这个工具负责什么”和“什么时候不要用它”。以灯光控制为例:
{ "type": "function", "function": { "name": "set_light", "description": "控制室内灯光的开关、亮度与颜色。适用于用户提到开灯、关灯、调亮、调暗、改变灯光颜色的场景。不适用于查询电费、设置定时开关的其他操作。", "parameters": { "type": "object", "properties": { "room": { "type": "string", "enum": ["客厅", "卧室", "厨房"], "description": "目标房间,如客厅、卧室或厨房" }, "action": { "type": "string", "enum": ["on", "off", "brightness"], "description": "操作类型:打开、关闭或调整亮度" }, "brightness": { "type": "integer", "minimum": 0, "maximum": 100, "description": "目标亮度值,仅当 action 为 brightness 时传入" } }, "required": ["room", "action"] } } }这里有几个小心机。一是给action和room设置了 enum,大幅缩小了参数的取值空间,模型出错概率随之下降。二是虽然参数里有可选的brightness,但我没有把它放进required列表里,这样模型不会因为少传一个可选字段而报错。三是 description 里明确写了“不适用于查询电费”,防止模型把功能相近但语义不同的意图错误映射过来。
4.2 FunctionGemma 接入与参数解析
接入调用逻辑时,我会把整条主循环写成一个函数,便于在端侧直接复用。下面是一个简化版的 Python 伪代码示例,实际生产环境可以根据语言换成 Kotlin 或 C++ 实现:
def agent_loop(user_input, tools, llm): messages = [ {"role": "system", "content": build_system_prompt(tools)}, {"role": "user", "content": user_input} ] first = llm.infer(messages, temperature=0, max_tokens=256) if not first.tool_calls: return first.text results = [] for call in first.tool_calls: tool = lookup_tool(call.name) if tool is None: results.append({ "role": "tool", "tool_call_id": call.id, "content": "ERROR: unknown tool" }) continue args = validate_args(call.arguments, tool.schema) result = tool.execute(args) results.append({ "role": "tool", "tool_call_id": call.id, "content": result }) messages += results second = llm.infer(messages, temperature=0.2, max_tokens=512) return second.text很多人会忽略一个细节:模型认为某个调用不存在时,一定要给它一个显式的工具返回信息,而不是直接在异常里吞掉。比如上面代码里,当工具查不到时返回“ERROR: unknown tool”,让模型在第二轮自己理解错误,并决定是道歉还是换一种问法。这样比直接抛异常让整个会话终止要自然得多。
提示词构造也需要卡准格式。我会倾向使用极端直接的口吻,避免模型走偏:
以下是可用工具列表。如果用户请求需要调用工具,请只输出一个 JSON 对象,格式为: {"name": "工具名", "arguments": { ...按工具参数 Schema 定义填写... }} 如果无需调用工具,请直接使用自然语言回复用户。不要添加额外的解释或前后缀。这里“不要添加额外的解释或前后缀”这句很关键。没有这句,模型偶尔会在工具调用前后加一行“根据你的需求,我来调用……”之类的废话,把解析逻辑逼疯。
4.3 关键参数选择与效果对比
在接入 FunctionGemma 后,我花了不少时间调推理参数,发现三组参数的效果影响最大:温度、max_tokens、top_p。
工具选择阶段,温度建议直接设置为 0。工具调用是确定性任务,用户说“开灯”就必须选set_light工具,不存在“有创意地开灯”这种需求。temperature 调高了反而容易在相似工具之间摇摆。最终回复阶段可以调到 0.2 左右,让语气自然一些。
max_tokens 则要看工具参数的长度。普通短参数调用 128 个 token 就够了,但有些工具会有长文本参数,比如“发送一条消息”里的 message 字段,一旦 max_tokens 太短,参数生成到一半就会被截断,整个 JSON 变为非法。我在一个项目里把调用阶段的 max_tokens 从 256 提到 512,参数截断的错误率几乎降为零。
下面是一组我整理的对比数据,虽然来自不同设备但量级具有参考价值:
| 配置 | 工具选择成功率 | 返回 JSON 合法率 | 备注 |
|---|---|---|---|
| 通用小模型 + 提示词硬凹 | 88.2% | 81.5% | 经常在 JSON 后夹带自然语言 |
| FunctionGemma + temperature 0 | 96.5% | 98.1% | 输出基本稳定 |
| FunctionGemma + 约束解码 | 97.2% | 99.6% | 合法率最高,推理耗时略增 |
约束解码虽然效果好,但不是免费的。它每一轮生成都需要在候选 token 上做一次合法性过滤,整体推理时间会多出 20~40 毫秒。对端侧场景来说,这个代价完全值得,毕竟工具调用的失败成本远高于几十毫秒的等待。
5. 端侧部署与性能优化
5.1 部署框架选型与体积控制
把 FunctionGemma 部署到端侧,可以走几条技术路线。开发阶段我最推荐基于 GGUF 格式的推理方案,它对 CPU 推理支持好,跨平台方便,模型文件可以直接放在资源目录或从后台下发更新。移动端原生场景也可以考虑 TFLite 路线,它在 Android 上有更完善的生态支持,但转换过程相对繁琐,模型算子兼容性需要多花些时间验证。
模型体积是需要盯住的硬指标。原始 FP16 权重大约 2GB,这对多数 App 来说太胖了。量化后体积会明显下降:Q8_0 量化后大概 1GB,Q5_K_M 约 800MB,Q4_K_M 约 600MB。我在开发时通常会先试用 Q8_0 保证效果基准,再逐步降级到 Q4_K_M 测试表现,找到“体积与效果”的平衡点。
如果你在一个内存比较紧张的老设备上跑,还需关注模型加载后的整体内存占用。以 Q4_K_M 量化的 1B 级模型为例,模型加载后常驻内存大约 1.2GB~1.5GB(包含推理缓存),这在大多数 8GB 内存手机上是可以接受的,但如果是入门机型,建议再加一道“访客可用内存检查”,内存不足时先不下发模型,回到云端兜底。
5.2 量化方案与实测延迟对比
性能优化阶段,我会在不同量化档位之间做一次 P50/P95 延迟分析。下面是一组在 Android 中端机上实测的参考数据(骁龙 7 系平台,8GB RAM):
| 量化档位 | 模型体积 | 单次工具推理延迟(P50) | 工具选择准确率 |
|---|---|---|---|
| Q8_0 | 约 1GB | 约 220ms | 98.5% |
| Q5_K_M | 约 800MB | 约 175ms | 97.4% |
| Q4_K_M | 约 600MB | 约 145ms | 96.1% |
可以看到,从 Q8_0 降到 Q4_K_M,延迟减少约三分之一,体积减少约 40%,但工具选择准确率只下降了 2 个百分点左右。对于大多数端侧智能体场景,Q4_K_M 是性价比最高的选择。但如果你的核心场景是金融、医疗等对错误零容忍的领域,我还是会建议 Q8_0。
延迟优化还有几个容易忽略的点。第一,控制上下文长度,历史消息越短推理越快;第二,在工具调用阶段把 max_tokens 限制在合理范围,因为多生成一个 token 就多等几十毫秒;第三,尽量复用底层推理引擎的 KV Cache,减少重复计算历史消息的开销。
5.3 降级兜底:工具不可用时的行为设计
端侧环境不稳定,工具不可用是常态,不是异常。我用一套简单的状态码来统一处理失败场景:0 表示成功,1 表示超时,2 表示参数无效,3 表示工具未注册。模型拿到带状态码的结果后,能把它当作普通上下文理解,自行决定如何向用户转达。
比如超时场景,调度器会返回“TIMEOUT: 设备未响应”,模型第二轮的回复就会更合理:“灯光控制器暂时没有响应,我已经帮你退回原状态,稍后可以再试。”如果没有这套兜底,调度器直接抛异常,智能体的对话流就断裂了,留给用户的是莫名其妙的错误弹窗,而不是自然反馈。
隐私安全也值得提一句。端侧智能体的优势之一就是数据不出设备,但工具执行结果里可能包含敏感信息,比如短信内容或日程安排。我在设计上下文管理器时加了一个脱敏层,任何工具结果回填到模型上下文前,都会把手机号、地址等个人信息替换成占位符,尽可能降低泄露风险。
6. 常见问题与排查实录
6.1 高频问题排查表
把我在实际项目中遇到的高频问题整理成了一张速查表,照着查能省不少时间去定位问题:
| 现象 | 根因 | 解法 |
|---|---|---|
| 模型回了一堆话,但没有调用工具 | 工具描述不清晰 / 提示词风格不匹配 | 在提示词中明确“必须输出 JSON”,并给模型一个少而精的工具清单 |
| 抓到的 JSON 参数被截断 | max_tokens 设置过短 | 把调用阶段 max_tokens 提升到 512,长文本参数可用到 1024 |
| 工具名写错或字段缺失 | Schema 里 required 没定义好 | 检查 Schema,将必有参数都放进 required 列表 |
| 相似工具之间反复切换 | 描述太泛,模型无法区分场景 | 为每个工具写清适用场景和不适用场景 |
| 第二轮对话上下文断裂 | ToolResult 没有正确回填 | 确保工具执行结果以可识别的角色消息追加到上下文 |
| 模型体积太大,无法端侧运行 | 没有量化或量化档位过低 | 先试 Q4_K_M,再按实际准确率决策 |
| 多个工具调用时延迟飙升 | 串行执行次数过多 | 将无依赖关系的工具调用放进同一批并行执行 |
6.2 三步定位法:快速找到故障点
排查工具调用问题时,我养成了一个三步定位的习惯,先后顺序很重要。
第一步,先看第一轮模型输出。如果模型没有输出任何tool_calls,问题大概率出在提示词或工具描述上。这时把系统提示词单独打印出来,检查它是否包含完整的工具 Schema,并检查工具描述是否存在歧义。你可以单独构造几个测试用例喂给模型,看它到底是不理解格式要求,还是不理解工具的语义。
第二步,看调度器的执行日志。如果模型已经输出了正确的工具调用,但调用失败了,问题大概率出在参数上。打印出模型回传的原始参数,与 Schema 逐一比对,看是枚举值超出了范围,还是必填字段没传全。这一步也可以搭配参数校验模块,在调度之前拦截明显格式错误。
第三步,看第二轮生成的回复质量。如果工具调用成功但最终回复很怪,问题大概率出在“上下文序”上。检查 ToolResult 是否被正确地放到了消息历史的合适位置,有没有被错误地插到用户消息之前,角色字段是否标对。这类上下文顺序错误,往往比参数解析错误更难让人察觉。
6.3 一个值得长期坚持的小习惯
工具调用模型和聊天模型不同,它的“效果基准”必须用工程化方式沉淀下来。建议在项目一开始就建立一个回归规则集:整理 50 条常见用户语句,每条标注期望触发的工具和参数。每次换模型、换量化档位、调提示词,都跑一遍回归。刚开始会觉得很机械,但跑过几轮就会发现,它能把很多隐蔽的准确率问题提前暴露出来。
我还会定期统计真实使用中哪些工具被频繁调用、哪些工具冷落到无人问津。这些数据反过来指导工具注册中心的调整:冷门工具如果长期没有调用,就直接停用,让模型的可选范围变小,间接提升热门工具的准确率。这本质上是把“工具数量”当作一种需要管理的资源,而不是越多越好。
踩过几次坑之后,我现在的做法基本稳定:先保证一个工具的调用极其稳定,再扩展第二个。很多时候工具调用出错不是模型不够聪明,而是工具定义、提示词格式、上下文管理这些周边的细节没做到位。把这些细节理顺,哪怕只用轻量模型,做出来的智能体也可以比想象中更可靠。如果你正在做类似的项目,我建议你从“一个工具跑通闭环”开始,亲自走一遍选择、执行、回填、确认的完整流程,很多问题会在这一遍里提前暴露出来。