端侧智能体实战:用FunctionGemma跑通本地函数调用全流程

发布时间:2026/10/21 22:57:24
端侧智能体实战:用FunctionGemma跑通本地函数调用全流程
1. 先聊聊这件事的背景为什么“端侧智能体”突然成了兵家必争之地1.1 从对话到行动差的到底是什么过去一年里我和身边做 AI 应用的朋友聊得最多的一个词不是参数规模而是“Action”。大家逐渐意识到市面上绝大多数以“对话”形式出现的大模型应用本质上还停留在“文本输出”这一层。你可以让它写一份露营装备清单它会给你列得清清楚楚但它不会帮你把清单存进备忘录也不会顺手查一下天气决定是否改期。模型说得头头是道但“手”是空的。所谓“迈向行动”就是让模型的输出从文本通道扩展到操作通道。它不再以“告诉你怎么办”作为终点而是以“帮你办完”作为终点。这一步跨越需要三个能力的配合第一把用户模糊的意图拆解成明确的子任务第二把子任务映射到环境里真实可用的函数第三把函数执行后的结果再喂回给模型让它决定是继续调用还是给用户一个结论。这三个能力合在一起就是一个最简 Agent 的运行框架。为什么这件事现在才被单独提出来因为之前大家默认“只有云端的大模型才有工具调用能力”而大模型的每次调用都有延迟和成本也没法处理隐私敏感的本地数据。现在端侧小模型的能力上来了在手机和 PC 上跑一个能调用本地工具的智能体既是产品需求也是技术水到渠成的结果。模型不只在“说话”而是要真正“办事”。1.2 FunctionGemma 到底是台什么“发动机”FunctionGemma 可以理解为一组专门为工具调用做了对齐训练的小参数模型。它延续了 Gemma 系列轻量高效的骨干结构但训练重心从“下一个词”变成了“下一个动作”。在实际使用里你在系统提示词中给出当前可用的函数列表再丢给它一条用户指令它返回的不是一段散文而是一个结构化的函数调用。我见过一个很形象的比喻普通语言模型像是一个知识丰富的顾问FunctionGemma 更像一个手脚麻利的操作员。顾问会告诉你怎么做操作员会直接把事做了。这背后其实是两种技术路线的选择。大模型靠参数量堆出工具调用能力达到一定规模后“无师自通”小模型则必须靠专项训练和输出约束来稳定完成任务。FunctionGemma 能在端侧生态里流行起来关键点在于它把“函数调用准确率”当作第一优化目标为此牺牲了一些无关紧要的对话自由度。它不需要会写诗但它必须保证在 JSON 格式里把函数名和参数填对。对做产品的人来说这个取舍非常干脆。1.3 这篇文章写给谁能解决什么问题如果你正在做端侧助手、自动脚本、语音技能、智能家居看板、或者本地自动化流水线这篇文章的第三部分基本可以拿来就改。我会带你搭一个能离线运行的原型模型就拿 FunctionGemma 的量化版工具就用最普通的天气查询、日历读写、备忘录创建然后跑通“用户说话—模型选函数—执行—再回复”的完整链路。你会看到它不是简单地把一段文本打印出来而是真的往你硬盘里新建了文件、在日历里塞了一个日程。我假设你有一点 Python 基础知道怎么装依赖、跑脚本但不需要你之前写过 Agent。核心代码我会拆开讲每一步为什么要这么做也尽量说明白。那么我们先从原理切入看看一次函数调用到底经历了什么。2. 核心原理函数调用是怎么运转起来的2.1 一次函数调用的完整生命周期先把生命周期摆出来后面所有代码都是围绕它写的。以“明天杭州会不会下雨”为例系统提示词会包含当前环境的工具列表里面有一个 get_weather 函数参数是城市和日期。用户消息进来后模型不是直接回答“明天多云转小雨”而是先输出一个特殊的函数调用标记。运行时解析出函数名 get_weather 和参数 {city: 杭州, date: 明天}然后交给本地代码执行。执行完毕后运行库把结果作为“工具返回”追加到对话上下文里。模型在看到工具返回后再生成一段面向用户的最终回答“明天杭州小雨记得带伞。”这个过程和人类员工处理问题的路径很像先看手头有哪些工具再判断哪个工具适合操作完拿到结果最后向你汇报。模型在中间扮演的是“决策者”真正干活的是你注册好的那批函数。所以智能体的能力上限由两部分决定模型判断力是一部分你提供的工具覆盖度是另一部分。工具集给得全它能干的事就多工具说明写得好它选对的概率就高。2.2 函数声明格式一份写给模型的“操作手册”要让模型准确选函数你得先让模型“知道”有哪些函数。常规做法是在系统提示词里放入一份 JSON 格式的工具清单。下面是一个天气函数的声明示例{ type: function, function: { name: get_weather, description: 查询指定城市、指定日期的天气情况返回温度、降水概率和建议, parameters: { type: object, properties: { city: { type: string, description: 城市中文名例如杭州、北京 }, date: { type: string, description: 查询日期支持‘今天’‘明天’或日期字符串格式 } }, required: [city, date] } } }这个清单的每一行都是有价值的。name 是模型最终填写的函数名必须唯一description 是模型判断“这个函数适不适合当前任务”的最重要依据parameters 里的 description 则告诉模型每个字段该填什么。我在实测中最深的体会是description 写得越具体模型选错函数的概率就越低。比如不要写“查询天气”要写“查询指定城市、指定日期的天气情况返回温度、降水概率和建议”因为用户问“明天要不要带伞”模型需要知道这个函数能回答“降水概率”它才会优先选它。对端侧模型来说这份清单还有一个隐藏成本它会跟随每次请求一起进入模型上下文。工具越多、描述越冗长每次推理要处理的前置 token 就越多首字延迟和显存占用都会上涨。所以端侧工具描述要遵循“精炼但明确”的原则一个函数控制在 100 个字符左右一批工具别超过 10 到 15 个。遇到工具特别多的场景常见做法是把工具分组动态注入先让模型在组别里选再加载组内详细清单。2.3 为什么小模型必须坚持结构化输出对话模型可以自由发挥但端侧智能体不能。如果模型输出的是“好的我来查一下杭州天气请稍等”你的解析器怎么知道该执行哪个函数所以模型需要约定一套固定的函数调用格式。在我接触的实现中最常见的是给模型定义两个特殊标记让它把函数调用写在标记里面比如|start_function_call|{name: get_weather, arguments: {city: 杭州, date: 明天}}|end_function_call|标记存在的意义是让解析变得无歧义只要在文本里找到这两个标记中间就是 JSON解析器可以一眼定位。而 JSON 内部则严格按照函数声明的结构来函数名和参数名都不能拼错。这也是小模型训练时的重点——它在训练阶段就被反复要求“输出标记 JSON”久而久之会形成路径依赖。但训练再好推理时模型也可能因为上下文干扰而把 JSON 写坏。所以严格的工程实现不会只依赖模型自觉还会在解码阶段加上约束比如用带 JSON 语法约束的采样让模型在函数标记开始后只能输出合法 JSON 字符。这一点我在第四部分的排错环节还会细讲。先记住结论端侧智能体的稳定性靠的是“好的提示词”加“硬的解码约束”加“容忍错误的解析器”三层保险缺一不可。3. 动手实践跑一个端侧智能体原型3.1 环境准备与模型加载先别急着写业务代码把运行环境搭好再说。我这边的方案是 Python 加 llama-cpp-python这个库可以直接加载 GGUF 量化模型CPU 也能跑Mac 或带显卡的 PC 可以走 GPU 加速非常适合做原型验证。pip install llama-cpp-python模型文件我建议用 2B 版本的 q4_K_M 量化。2B 体积小、速度快足够演示工具调用流程q4_K_M 是量化档位里准确率和体积兼顾得比较好的一个。如果显存或内存紧张降到 q4_0 也能跑但函数调用偶尔会冒出奇怪的 JSON有条件就用 q8_0准确率会更稳。加载代码如下from llama_cpp import Llama model Llama( model_pathfunctiongemma-2b-q4_k_m.gguf, n_ctx4096, n_gpu_layers-1, # 全部层放 GPU纯 CPU 环境改成 0 verboseFalse )我把n_ctx设为 4096而不是更大。端侧内存是稀缺资源上下文窗口开得越大KV Cache 占的内存就越多而且多轮工具调用时你根本用不完 4K。实测下来一次“提问—调用—返回—总结”的链路通常只消耗 1 到 1.5K token4096 已经留有安全余量。3.2 定义一组真实可用的工具集原型阶段我不建议直接去操作操作系统级 API先从安全的本地工具开始比如天气查询、备忘录创建、待办事项读取。既能让模型跑通动作闭环也不会一上来就把电脑搞坏。工具注册表我习惯用一个字典键是函数名值是函数本体TOOL_SCHEMAS [ { type: function, function: { name: get_weather, description: 查询指定城市、指定日期的天气情况返回温度、降水概率和建议, parameters: { type: object, properties: { city: {type: string, description: 城市中文名例如杭州}, date: {type: string, description: 查询日期支持‘今天’‘明天’或具体日期} }, required: [city, date] } } }, { type: function, function: { name: create_memo, description: 在本地备忘录目录中新建一个 Markdown 笔记并返回文件路径, parameters: { type: object, properties: { title: {type: string, description: 备忘录标题}, content: {type: string, description: 备忘录正文内容} }, required: [title, content] } } } ]有读者可能问为什么不直接用返回字典的方式而要套一层type和function这是考虑到以后如果接一些标准工具调用协议格式可以平滑迁移现在多写几个字段后面省事。当然你也可以省略运行时只认 name 和 parameters不影响效果。真正执行时我写了一个最简分发器def dispatch_tool(name, args): if name get_weather: return fake_weather(args[city], args[date]) if name create_memo: return create_memo_file(args[title], args[content]) return {error: f未注册函数: {name}}原型的核心是先把链路跑通工具内部实现可以粗糙比如天气函数可以先返回一条静态数据等你确认模型能正确调用后再去替换成真实接口。这种“先跑通再填肉”的做法能帮你快速判断问题到底出在模型侧还是工具侧。3.3 核心循环对话—选函数—执行—再观察Agent 的核心其实就是一段循环。我用 messages 列表维护上下文模型每次生成后解析函数调用有调用就执行并把结果追加回上下文没有调用就代表模型要输出最终答案直接返回。实现如下import json import re SYSTEM_PROMPT 你是一个运行在设备上的智能助手。以下是你可以使用的函数列表 \ json.dumps(TOOL_SCHEMAS, ensure_asciiFalse) \ 当你需要调用函数时请严格遵守以下格式|start_function_call|{函数调用 JSON}|end_function_call|。\n \ 如果没有需要调用函数请直接回复用户。不要重复调用已经执行过的函数。 def parse_function_call(text): pattern r\|start_function_call\|(.*?)\|end_function_call\| match re.search(pattern, text, re.S) if not match: return None try: payload json.loads(match.group(1).strip()) return payload[name], payload[arguments] except (json.JSONDecodeError, KeyError): return None def agent_loop(user_request, max_steps8): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_request} ] for step in range(max_steps): output model.create_chat_completion( messagesmessages, temperature0.1, top_p0.9, max_tokens512 ) response_text output[choices][0][message][content] call parse_function_call(response_text) if call is None: return response_text name, args call scratchpad dispatch_tool(name, args) messages.append({role: assistant, content: response_text}) messages.append({role: user, content: f[工具返回] {name} 的结果{json.dumps(scratchpad, ensure_asciiFalse)}}) return 我尝试了多次仍未完成任务请补充更多信息或换个说法。注意工具返回我借用了user角色而不是专门的tool角色。这是因为本地推理引擎在tool角色上的支持参差不齐用[工具返回]前缀伪装成用户消息引擎兼容性最好。这一步踩过坑的人都知道少用特殊角色多用清晰前缀能省掉大量兼容性问题。另外我把温度压到了 0.1函数名和参数都是确定性任务温度一高就乱写。3.4 从“返回结果”到“真正执行动作”跑通上面的循环后你会发现一个有意思的现象模型不只是回答你它会在真实动作落地之后看着结果继续表达。比如你输入“帮我建一个叫‘露营清单’的备忘录内容是周六要带的五种装备”它会先调用create_memo等工具返回“文件已写入 /Users/me/memos/露营清单.md”然后回复你“已经建好了文件在备忘录目录下随时可以看。”这句回复背后你的硬盘上真的多了一个文件。这就是从“对话”到“行动”的质变。如果要把这个动作变成产品能力后面还要加一步权限确认。创建备忘录这种副作用比较小的操作可以自动执行但如果是删除文件、发送邮件、扣款这类动作应该在执行前弹一个确认界面让用户拍板。原则上智能体越“能干”权限边界就要越清晰。这个我在第五部分会展开。4. 实测调优我在跑通整个流程时踩过的坑4.1 模型输出不老实JSON 解析失败的几种解法第一个让人血压升高的坑就是模型不按格式出牌。我见过三种典型异常一是函数调用的 JSON 写到一半被截断二是模型在函数调用前后加了大量解说词比如“好的我来查一下”三是干脆把参数拼错把 city 写成 “city: 杭州” 这种带键值的错误格式。这三种情况都会让parse_function_call返回 None然后模型会被迫变成纯对话输出Action 链路断掉。我的排查顺序是这样的。先看采样参数temperature 是否超过 0.3是的话先压下来再检查max_tokens函数调用 JSON 加前后标记大概要 100 到 200 token如果设成 64截断几乎是必然最后再用容错解析兜底。容错解析可以从“回复里找不到完整 JSON”这个场景出发把可以恢复的 JSON 部分用括号补全后再试。真修不回来就在消息列表追加一条纠错指令“你刚才没有按格式输出。请直接输出函数调用标记和 JSON不要输出任何解释。”让模型重试一次。要做到根上最好在解码层面加约束。llama-cpp-python 支持在创建模型时传grammar参数或者通过create_completion传入约束文法强制模型在函数调用里只能输出合法 JSON。这对小模型效果立竿见影副作用是限制了部分自由发挥但对智能体场景来说稳定优先于灵活。4.2 多轮调用上下文膨胀内存和延时一起爆第二个坑是上下文刷爆。智能体跑得越久消息列表越长因为每一轮工具返回都在往里塞内容。天气接口返回 2K 字符的详情还好如果工具是个日志查询一次返回 10K 字符两三轮之后 4096 的上下文窗口就不够用了轻则推理变慢重则直接把最近的信息截断模型反而忘了自己最初要干嘛。我的解法是给工具返回做摘要和截断。注册表里每个工具可以带一个summarize函数执行完先跑一遍摘要只把最核心的结果塞回上下文。比如天气工具只保留“城市杭州天气小雨温度18 到 22℃降水概率70%”。对于日志读取类工具则直接截断前 200 字符。做了这层处理后相同轮次下上下文体积能减少一半以上首字延迟肉眼可见地降下来。如果任务真的复杂还有一种更工程化的做法把每轮工具调用记录在一个独立的“工作记忆”字典里系统提示词里只放当前任务状态和尚未完成的目标而不是把所有历史对话全部原样拼进去。相当于给 Agent 加了一个“便签本”重要信息写便签本旧对话留着但不占主窗口。你可以在原型的循环里试一下效果很明显。4.3 模型陷入“假忙碌”反复调用同一个函数第三种异常最隐蔽模型看起来非常努力每一轮都在输出函数调用但永远是同一个函数、同一组参数。比如一直查杭州天气查完结果没有变化继续查直到撞上 max_steps 上限。说白了模型“卡住”了它不知道该什么时候收手。我在系统提示词里加了一句硬性规则后这种现象少了很多“不要重复调用已经执行过的函数如果某函数的参数没有变化说明上一次结果仍然有效基于已有结果回复用户。”光有这句话还不够我还在工具分发器里加了一层“重复调用检测”如果同样的函数名加相同参数已经执行过第二次起直接返回“该函数已调用过结果同上一次{上次结果}”。这等于给了模型一个提示帮它跳出死循环。max_steps 本身也是最后防线。8 次对我来说是上限超过自动收尾改为直接回复。产品里还可以把这个次数做成可配置项简单任务 4 次复杂任务 12 次但无论如何都要有上限。4.4 性能参考与量化选型端侧真不靠堆硬件最后聊聊性能。我在一台普通 16GB 内存笔记本和一台测试用开发板上分别跑过。2B 模型 q4_K_M 量化后大约 1.6GB笔记本上生成速度在每秒 40 token 左右一次函数调用加回复的完整轮次大约 500 到 700 毫秒基本可以接受4B 模型同样量化后约 2.8GB速度降到每秒 20 token 左右轮次延迟大约 1 秒以上。手机端如果走 NPU 加速2B 模型能跑到每秒 30 token 上下但不同引擎差异极大不能只看纸面数据。模型档位量化格式文件大小首字延迟参考工具调用稳定性2BQ3_K_S约 1.1GB低勉强能用2BQ4_K_M约 1.6GB低推荐2BQ8_0约 2.5GB中稳定4BQ4_K_M约 2.8GB中高更稳但慢这张表的数值都是我实测的大致范围不同设备差异很大但当参考足够。选型建议很朴素内存 8GB 以内的设备优先 2B 加 Q4_K_M内存充足的桌面机可以直接上 4B 加 Q8_0函数调用准确率和复杂推理都会好一截。注意量化位数对工具调用准确率的影响比对话流畅度更明显因为一个 JSON 花括号写错整个调用就废了。所以千万别为了省那 100MB 内存去用 Q3省下来的空间迟早变成调试时间。5. 从原型到产品落地时绕不开的三个问题5.1 权限与安全边界机器越能干门禁要越严智能体最大的卖点是“能行动”最大的风险也在“能行动”。原型里你随手写的create_memo副作用很小但在真实产品里工具列表里可能出现发消息、删文件、转账、下单这类高危操作。我的建议是给所有工具打上危险等级标签只读类自动执行可逆副作用类弹窗确认不可逆高危操作直接拒绝或要求二次确认。实现上可以给dispatch_tool加一个权限检查层在执行前先根据函数名查询策略表。另一个容易忽略的点是参数校验。模型生成的参数不一定合理特别是当它把用户原话里的信息填错位置时。在执行任何外部操作之前都要用函数声明的 JSON Schema 校验一遍参数类型和必填项。尤其是date这类字符串最好再做个格式归一化把“明天”换算成具体日期。这一步能避免“把备忘录建到乱码目录里”这种看起来好笑、实际上很伤用户信任的 Bug。5.2 离线优先与降级策略工具掉线不能崩端侧智能体的优势是离线可用但工具本身未必离线。以天气查询为例断网之后工具调用必然失败。好的做法是设计离线优先把天气缓存成一份本地文件断网时返回最近一次缓存结果备忘录、待办这些纯本地工具天然离线优先级最高。如果某个工具返回错误要在系统提示词里明确告诉模型“工具返回错误时如实向用户说明不要再继续调用其他可能有副作用的函数”。我在系统提示词里加过这样一段话“如果工具执行失败请直接告诉用户失败原因并停止继续操作。不要尝试用其他工具掩盖错误。”这句话看似简单但能防止模型在断网时一遍遍重试失败工具把用户体验拖进死循环。5.3 工具版本与模型版本的配套管理模型会更新工具也会更新而这两者必须配套。你升级 App 后删掉了get_weather的date参数模型如果还在旧版本的习惯里调用参数校验就会失败。我踩过的坑是工具描述更新了但系统提示词缓存没更新模型仍然用旧函数签名生成调用落了一地错误日志。解法也不复杂函数注册表里带上 schema_version每次执行前先校验参数如果模型调用了未注册函数返回一个明确的错误码“[未注册工具]请根据可用函数列表重新选择”。模型看到这个错误码后通常会自己纠正。产品灰度时还要注意模型的旧版本与工具新版本的兼容窗口宁可让模型少做一件事也不能让它用错误的参数去做事。我个人在实际操作中的体会是端侧智能体离“完美”还很远但当你第一次看到它真的从你硬盘里读出了文件、写入了备忘录入再开口回复你时那种感觉和单纯让它写一段文字是完全不一样的。这就像你带了一个实习生他不再是给你交一份报告而是真的把手头的活干完了。接下来要打磨的核心就是让他知道哪些能自己干、哪些必须问你这是一条很长的路但起点已经足够明确。