最近我把一个量化的 FunctionGemma 3B 模型塞进了一块 4GB 内存的开发板,让它从一个“只会聊天”的模型,变成了一个真的能执行动作的端侧智能体:能读日历、建日程、查天气、控制智能灯。这个过程踩了不少坑,也把“对话”到“行动”之间的工程细节摸了一遍。这篇就聊聊我是怎么拆解这个项目、怎么选型、怎么落地,以及实测下来最容易翻车的地方。适合正在做端侧 AI 应用、智能硬件,或者对 Agent 落地感兴趣的开发者参考。
1. 项目拆解:端侧智能体到底“智”在哪里
1.1 从“能聊天”到“能干活”,差的不只是模型
很多刚接触 Agent 的同学会这样理解:只要模型够聪明,把指令发给它,它就能自动把所有事情干完。真实情况远不是这么回事。
拿聊天模型举例,你让它“帮我订一个明天下午三点的会议室”,它能理解你的意图,但它只能输出一段文字“好的,我帮你订明天下午三点的会议室”。至于会议室系统怎么调用、订完返回什么结果、如果会议室被占用又该怎么回复,模型本身完全不清楚。这就是“对话”和“行动”之间的那道鸿沟。
要让模型从“理解”走向“执行”,需要给它一套工具,并且让它学会在合适的时机选择合适的工具,传参、调起、拿到结果,再根据结果决定下一步说什么。这套机制在行业里叫 Function Calling,也叫工具调用。它本质上是把模型的“语言理解能力”和外部系统的“执行能力”用结构化协议连接起来。
所以这个项目的核心不是找到一个更聪明的模型,而是建立一个完整的“感知-决策-执行-反馈”闭环。模型负责感知意图、决策该调用哪个工具、把用户的话翻译成工具参数;端侧代码负责执行工具、把结果喂回给模型;模型再基于结果组织自然语言回复。任何一环断掉,智能体就退化成一个只会复述愿望的聊天机器人。
1.2 FunctionGemma 是什么,为什么拿它做端侧
FunctionGemma 是 Gemma 系列中专攻函数调用和工具使用的模型变体。简单理解,它是在小型通用模型基础上,专门用“函数调用”这类数据做了大量监督微调,让模型在有限的参数量下,也能高质量地完成工具选择、参数抽取、JSON 结构化输出这几个关键动作。
这个定位非常贴合端侧场景。端侧设备算力、内存、功耗都有限,跑不动动辄几十 B 的大家伙。FunctionGemma 提供了 1B、3B、7B 几个规格,其中 3B 是绝大多数移动设备和开发板的甜点尺寸。量化之后,3B 模型的内存占用可以压到 2GB 左右,推理速度在手机上能做到每秒几 token 到十几个 token,虽然不是飞快,但对于“理解意图-调工具-回复结果”这种短链路任务,延迟是可接受的。
另外一个关键点在于,FunctionGemma 的输出高度结构化。它不是让模型在自由文本里“碰运气”式地输出工具名和参数,而是直接输出一个 JSON 结构,里面要么包含function_call字段,要么包含natural_language_response字段。这样下游代码解析结果时非常稳定,不需要揣摩模型“今天想用什么格式”。这比我在别的模型上见过的“靠正则从自由文本里抠参数”的做法要可靠得多。
1.3 这类智能体适合做什么,不适合做什么
我做完第一版之后的结论是:端侧智能体最适合那些工具清单固定、交互链路短、实时性要求高的任务。举几个典型场景:
- 智能家居中控:用户说“把客厅灯调到最暗”,Agent 调起灯光控制函数,发指令给网关。
- 日程与办公助理:用户说“明天下午三点约某客户”,Agent 调起日历函数创建日程。
- 无障碍辅助:通过语音说出需求,Agent 帮助拨打电话、发送消息。
- 车载助手:司机说“帮我搜索附近充电站”,Agent 调用地图工具后返回结果。
这些场景的共同点是工具数量不多,也就十到二十个,每个工具的输入输出都清晰,模型不需要做太长的多步推理。
但它不适合做复杂的开放式任务。比如“帮我规划一趟 7 天旅行,顺便把所有酒店、机票、景点门票都订好”这种需求,涉及大量多步推理、状态管理和外部系统交互,单个 3B 端侧模型很难稳定完成。这种任务更适合云端大模型,或者在端侧跑一个小模型负责“理解与表达”、一个大模型在云端负责“规划与决策”的混合架构。
2. 架构设计与关键决策:为什么每个环节这么选
2.1 整体流程:从用户请求到动作执行的闭环
整个 Agent 的运行链路,我把它拆成四步。第一步,用户输入进来,端侧代码把历史对话、工具定义、用户请求拼成一个结构化的 prompt。第二步,FunctionGemma 接收 prompt,输出一个 JSON,这个 JSON 要么是要执行的函数调用,要么是直接回复用户的自然语言。第三步,如果模型输出的是函数调用,端侧代码去执行真正的函数,然后把执行结果(比如“会议室已被占用”或“日程创建成功”)作为新的消息追加到上下文里。第四步,模型再次推理,根据工具返回结果生成最终的自然语言回复。
这四步看起来简单,但每一步都有不少细节。比如 prompt 里工具清单放在哪个位置、函数描述怎么写、JSON 返回值如何转义、上下文窗口如何截断,都会直接影响成功率。我后面在第 3 节会展开讲。
2.2 函数调用范式 vs 提示词硬塞:为什么选前者
可能有人问,我不做专门的函数调用微调,直接在 prompt 里写“你是一个有工具的助手,要调用工具时输出工具名和参数”,不行吗?
我试过,效果差得很远。通用小模型在自由文本模式下,经常出现这几个问题:工具名拼错、参数漏传、把多个参数揉在一起、甚至工具和参数类型对不上。原因在于,模型在预训练阶段并没有见过“工具用 JSON 描述、调用用 JSON 表示”这种强结构,它只是按照“文本接龙”的习惯在生成,一个字符一个字符地往外蹦,蹦到哪算哪。
FunctionGemma 这类专用模型则是在微调阶段见过海量的工具调用样本,学会了“看到工具定义列表,要调用时就输出对应结构的 JSON”。它不只是记住了格式,还学会了在多个相似工具之间做选择,以及从冗长的对话里抽取关键参数。这就是“专用范式”和“通用文本”的本质差别。简单说,前者是结构化协议,后者是碰运气。
当然,为了进一步提升可靠性,我在推理时还会叠加输出约束,强制模型按 JSON Schema 输出,从根上杜绝格式漂移。
2.3 模型量化与精度平衡:Q4_K_M 是大多数设备的甜点
模型大小和量化档位是端侧项目里最纠结的决策。我把三个规格放在一起做过对比:
| 方案 | 内存占用 | 每秒生成速度(参考) | 函数调用准确率(个人实测) |
|---|---|---|---|
| 7B Q4_K_M | 约 4GB | 3~5 token/s | 很高 |
| 3B Q4_K_M | 约 2GB | 8~15 token/s | 高 |
| 3B Q8_0 | 约 3.2GB | 6~10 token/s | 略高于 Q4 |
| 1B Q4_K_M | 约 0.8GB | 20+ token/s | 一般 |
我最终选的是 3B Q4_K_M。理由很现实:7B 在我的 4GB 内存开发板上能跑,但同时跑完系统服务、语音识别和推理,内存就捉襟见肘了。3B Q4 在速度和内存之间最均衡,而且函数调用的核心动作——选工具、传参数——对量化损失并不敏感,不像数学推理那样分数掉得明显。
如果设备内存超过 6GB,可以上 3B Q8,会稍微稳一点;如果设备是手机但内存紧张,建议 1B 起步先跑通链路,再逐步往上加。这里的判断标准很简单:先保证闭环跑通,再谈模型精度。
2.4 推理引擎与接口设计:稳定大于花哨
端侧推理框架我选的是目前社区最成熟的 llama.cpp 系。原因有几个:一是它对 GGUF 量化格式支持最完整,二是各个平台都有预编译产物,三是它提供了 OpenAI 兼容的 HTTP 接口,业务代码几乎不用改就能在本地和云端之间切换。
接口设计上,我推荐走 OpenAI 兼容的/chat/completions接口,但要注意一件事:llama.cpp 在服务端套了一层模板,如果你的框架版本和 FunctionGemma 要求的聊天模板不完全匹配,容易出现两边各拼一遍模板、最终输入错乱的情况。我自己的做法是,直接用一个原始的/completion接口,自己在客户端拼好完整模板,关掉服务端的模板包装。这样虽然少了点便利,但可控性大大提升,排查问题也容易。
技术选型没有绝对的对错,关键是每个选择都有明确理由:内存不够所以量化,量化要稳定所以选 GGUF,接口要可调试所以关掉服务端模板。这套思路比背几个工具名更能应对不同硬件。
3. 实操过程:从零把 FunctionGemma 跑成可用 Agent
3.1 环境准备:模型获取与量化转换
先说明一下我的目标环境:一块带 4GB 内存的 Linux 开发板,没有 NVIDIA GPU,纯 CPU 推理。这个配置比较有代表性,能跑通就说明大多数移动设备也能跑。
第一步,拿到模型。建议先到模型仓库下载 HF 格式的 FunctionGemma 3B 原始权重,然后用转换脚本转成 GGUF 再量化。如果你下载的已经是别人转好的 GGUF,也要确认它的量化类型是 K 系列还是普通系列,这会影响后面效果。
转换和量化的命令大致如下:
# 1. 克隆推理框架仓库 git clone https://github.com/... # 根据实际框架文档操作 # 2. 把 HF 格式模型转成 FP16 GGUF python convert_hf_to_gguf.py ./functiongemma-3b-hf \ --outfile ./models/functiongemma-3b-f16.gguf # 3. 量化为 Q4_K_M ./llama-quantize ./models/functiongemma-3b-f16.gguf \ ./models/functiongemma-3b-q4_k_m.gguf Q4_K_M这里有个重要的版本提示:FunctionGemma 用了较新的架构和分词方式,推理框架版本太旧会直接报错或者输出乱码。我一开始在旧版本框架上转,出来的模型一推理就崩,后来升级到支持 Gemma 3 架构的版本才正常。如果你转换或加载时报类似“unknown architecture”的错误,先检查框架版本,别急着怀疑模型文件损坏。
启动推理服务:
./llama-server -m ./models/functiongemma-3b-q4_k_m.gguf \ --host 127.0.0.1 --port 8080 \ --ctx-size 8192 \ --threads 4 \ --no-mmap这里的--no-mmap参数很关键,在低内存设备上,mmap 模式虽然加载快,但内存容易被膨胀的上下文占用,关掉它反而更稳。实测下来,开启--no-mmap后内存峰值少了好几百 MB。
3.2 函数清单设计:给智能体配一套“手脚”
函数清单是智能体的“能力说明书”,模型根据它决定调不调、调哪个、传什么参数。这块设计得不好,后续会频繁出现“模型选择了错误的工具”或“参数缺字段”。
我的做法是给每个工具写一个 JSON Schema,字段包括name、description、parameters。举一个查天气的例子:
{ "name": "get_weather", "description": "获取指定城市当天的天气情况,包括温度、天气现象、风力等。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,比如北京、上海。" }, "date": { "type": "string", "description": "日期,格式为 YYYY-MM-DD,默认今天。" } }, "required": ["city"] } }写描述有几个经验:
- 描述要短,但信息量要够。不要写“该函数用来处理天气相关请求,内部会调用后端服务并返回格式化结果”,这种话模型看了等于没看。要写“获取某城市某日期的天气”这种直接的动作。
- 参数说明要写清楚格式和边界。比如日期格式如果不写
YYYY-MM-DD,模型可能给你输出“明天”“明天下午”这类自然语言。 - required 字段别乱用。只有这个参数没有就无法执行工具时才设为必填。否则模型会为了凑必填项而编造参数,这个坑非常隐蔽。
工具数量上,我建议控制在 15 个以内。工具太多,模型在长列表里做选择的准确率会明显下降,而且每次请求都要把整个工具列表塞进上下文,既有 token 开销,又会分散模型注意力。
另外我还发现一个小技巧:把工具分类放进不同的 system 段落,比如“日历工具”“通讯工具”“家居控制工具”,模型选择工具时更容易聚焦。虽然不是官方推荐做法,但在我实测的几轮任务中,准确率确实有提升。
3.3 Agent 主循环实现:转发、解析、执行
Agent 主循环是整个项目的心脏。我用 Python 写了一个精简但完整的版本,核心思路是:不停地把“模型输出-工具执行结果”回填到上下文,直到模型给出自然语言回复为止。
先定义一个工具注册表,把函数名和真实执行函数映射起来:
TOOL_REGISTRY = { "get_weather": weather_service.get_weather, "create_calendar_event": calendar_service.create_event, "send_message": messaging_service.send_message, "set_light_level": homekit_service.set_light_level, }再看主循环:
def run_agent(user_input: str, history: list[dict]) -> str: messages = build_messages(user_input, history) for _ in range(MAX_STEPS): # 调用端侧推理服务的 OpenAI 兼容接口 resp = client.chat.completions.create( model="functiongemma-3b-q4_k_m", messages=messages, temperature=0.1, max_tokens=512, ) raw = resp.choices[0].message.content # 解析模型输出的 JSON parsed = parse_model_output(raw) if parsed["role"] == "function_call": # 执行函数 result = execute_tool(parsed["name"], parsed["arguments"]) # 把函数名和结果追加到消息里 messages.append({ "role": "function", "name": parsed["name"], "content": json.dumps(result, ensure_ascii=False), }) else: # 模型决定直接回复用户,结束循环 return parsed["content"] # 达到最大步数还没结束,按超时处理 return "操作步骤过多,我已暂停,请继续说明您的需求。"这段代码里有几个细节值得注意:
MAX_STEPS我设置成 5。因为每个工具调用都增加上下文长度,步数过多时模型会越来越糊涂。设置上限可以防止死循环。temperature调成 0.1。函数调用这类任务,我宁可要稳定也不要发散。温度太高会出现模型随机选了一个并不该调的工具的情况。- 工具执行结果要先序列化为 JSON 再拼进消息。否则包含换行和特殊字符的返回值会把格式破坏掉。
这里有一个容易忽略但非常关键的点:模型输出的 raw text 不一定是合法的 JSON。函数模型在生成过程中可能输出多余的前缀或后缀,我在解析时会做一次“提取第一个{到最后一个}”的清洗,再交给 JSON 解析器。这个处理看似暴力,实战中救了我很多次。
3.4 消息结构构造:把工具说明和对话历史排好队
构造 messages 是决定成败的一步。FunctionGemma 支持 OpenAI 风格的格式,我们需要三种消息角色来配合工具调用。
system消息里放工具定义。把 3.2 节里的所有 JSON Schema 塞进一个列表,转换成字符串后作为 system content。对话历史放在user和assistant消息里。function消息则用来表示工具执行结果,它包含两项:name字段填工具名,content字段填工具返回的 JSON 字符串。
实际构造时,我建议把 system 消息保持在最前面,并且不要随意截断。对话历史可以在中间,但要注意长度控制。我之前试过把工具定义放在 system 最后面,模型面对长对话时明显更容易遗漏工具描述,把它固定在最前面后,选择准确率提升了不止一点。
还有一个容易踩的坑:不要在 system 里额外写“如果用户请求涉及天气,你可以调用 get_weather 函数查询”,这种重复描述看起来很贴心,实际上会干扰模型对工具本身的理解。工具定义里已经写清楚了,多写反而会稀释 Attention。
3.5 端侧性能调优:内存、延迟与上下文窗口
端侧跑 Agent,最难伺候的不是模型,而是内存和延迟。我做了三层调优。
第一层是上下文窗口。FunctionGemma 虽然支持较长的上下文,但我在端侧把--ctx-size设为 8192,然后在应用层再限制实际使用的 token 数不超过 6000。多轮对话时,我会保留最近的 4~6 轮,更早的历史做摘要压缩。这个策略最直接的效果是内存占用稳定,不会因为一轮长对话就把内存吃穿。
第二层是部署参数。--threads不是越大越好。我实测 4 线程比 8 线程更稳定,因为开发板的 CPU 核心数有限,线程开太多反而因为调度开销拖慢速度,还容易触发热降频。如果你在手机上做,还要注意别把大核全部占满,否则系统会杀掉你的后台进程。
第三层是请求格式。我把工具定义做了“预编译”,也就是只构造一次完整的工具列表字符串,每次请求时直接复用。不要在每个请求里动态生成工具描述,Python 层面可能看不出差距,但序列化和 token 化的开销在弱设备上会被放大。
最终实测下来:3B Q4_K_M 模型,8192 上下文,单次推理首 token 延迟约 0.8 秒,整轮“用户提问-工具执行-生成回复”大约 3~5 秒。对于“帮我开灯”“创建日程”这种短链路任务,这个延迟是可以接受的。
3.6 一个完整落地例子:把一句话变成一串动作
拿一个真实场景串一遍。假设用户说:“明天下午 3 点帮我在公司附近订一个会议室,四个人。”
第一步,系统把工具列表和这句话拼好发给模型。模型判断这涉及日程和会议室查询两个能力,于是输出一个 JSON:
{ "next_dialogue_turn": { "function_call": { "name": "search_meeting_room", "arguments": { "location": "公司附近", "capacity": 4, "datetime": "2025-06-20 15:00" } } } }注意,模型非常聪明地把“明天下午 3 点”翻译成了具体的日期时间,前提是它在 system 消息里看到了当前时间。所以我每次构造 messages 时,会把“当前时间”也作为一条工具参数注入。这一步如果不做,模型就不知道“明天”是哪一天。
第二步,端侧代码执行search_meeting_room,返回了两间可用的会议室。
第三步,这个消息被追加进上下文,模型再次推理,这次它调用create_meeting_reservation把第一间预订了。工具返回成功后,模型输出自然语言回复:“已为您预订 3 号会议室,明天下午 3 点,可容纳 6 人。”
整个链路里的每一步都是可观测的,我可以随时打印出当前 messages 结构,看模型为什么这么选。这个能力对调试太重要了。
4. 踩坑实录与常见问题速查
4.1 函数调用与“幻觉函数”的分界线在哪里
我最开始遇到的一个典型问题是:模型明明有两个函数send_email和send_message,用户说“发个微信告诉他”,模型却选择了send_email。表面看是“工具没选对”,背后其实是参数描述不够清晰导致的语义偏差。
排查思路分两步。第一步检查两个函数的 description 是否太相似。如果send_email写的是“给联系人发送消息”,send_message写的是“给联系人发送消息”,那模型当然分不清。正确的做法是在描述里明确介质差异,比如send_email写“发送电子邮件到对方邮箱”,send_message写“通过即时通讯软件发送聊天消息”。
第二步是检查参数里有没有“混淆项”。比如send_email的recipient描述是“收件人邮箱”,但模型抽出来的参数却是“微信号”,这明显是参数描述没对齐。
这类“幻觉函数”问题,九成以上可以通过优化工具描述解决。如果优化后仍然命中,我会在解析层加一个白名单检查:模型输出的函数名必须存在于注册表中,否则直接拦截,并要求模型重新输出。宁可拒绝一次,也不要让模型编一个不存在的工具去执行。
4.2 JSON 输出的隐形杀手:换行、转义与 Schema 不匹配
模型生成的 JSON 偶尔会让解析器直接报错,最常见的三个原因:字段顺序不对、字符串里的换行符没转义、外层被 markdown 代码块包裹。
字段顺序问题最好解决,用一个宽容的 JSON 解析器,或者先把字符串清洗。换行符问题则要小心,模型在提取长文本参数时,偶尔会输出真实的换行而不是\n两个字符。我会在把工具返回结果拼进消息时,统一用json.dumps重新序列化,而不是手写字符串拼接。
Schema 不匹配这个坑更隐蔽。比如参数里声明了date是 string,模型却输出一个数字或数组。这通常发生在参数描述不够精确的时候。我的对策是:在所有参数描述里都带上类型示例。比如“date: 日期字符串,格式 YYYY-MM-DD,例如 2025-06-20”。模型见过明确示例后,错误率会大幅下降。
执行端我也加了兜底:如果函数执行时发现参数类型不对,就返回一个明确的错误 JSON 给模型,告诉它“参数 date 格式不正确,请使用 YYYY-MM-DD 格式重新调用”。这比在应用层直接崩溃要友好得多,模型会自觉纠正。
4.3 多轮对话后的上下文污染与记忆管理
端侧 Agent 跑多轮对话后,上下文会越来越长。我遇到过两个典型症状:一是模型开始忽略早期的工具定义,二是模型把上一轮工具的返回值误当成本轮对话的历史,导致重复调用同一个工具。
第一个症状的解法是我前面提到的“工具定义放最前 + 历史截断”。第二个症状则需要更精细的处理:每轮工具调用结束后,要做摘要压缩,而不是把原始 tool result 原封不动地堆在历史里。
我目前的策略是:每完成一个完整任务(用户意图已满足),就把这一段历史压缩成一句话摘要,比如“用户预订了 3 号会议室,时间明天下午 3 点”。后续轮次只保留摘要,不保留完整工具日志。这样上下文里始终有空间容纳新的工具执行结果,模型也不会被陈旧的调用记录干扰。
4.4 设备层面的坑:降频、内存碎片与缓存穿透
软件逻辑跑通后,硬件层面的问题会一个个冒出来。开发板跑一段时间后明显变慢,一查是 CPU 热降频。解决办法是不要用--threads拉满,同时在代码里加入“冷却控制”:连续多次推理后,主动 sleep 几百毫秒让设备喘口气。
内存碎片也是端侧特有的问题。反复加载大上下文、反复分配释放,内存碎片严重时会出现明明还有几百 MB 剩余,但分配大块内存失败的诡异现象。对策是尽量复用消息结构,避免每次请求都重新创建巨大的列表对象。Python 的垃圾回收在这种场景下也要小心,我测试时会在推理循环外手动gc.collect()。
4.5 常见问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型一直调用同一个工具不结束 | 工具执行结果未正确回传 | 检查 function 消息的 name 和 content 格式 |
| 工具名出现拼写错误 | 工具定义里的 name 太复杂 | 改用小写字母和下划线短名字 |
| 输出不是合法 JSON | 上下文过长或模板拼错 | 清洗输出、截断历史、检查 prompt |
| 参数日期少了年份 | 参数描述缺少格式示例 | 在 description 里加明确格式 |
| 首 token 很慢,后续流畅 | 上下文太长或线程配置不合理 | 缩短历史、调整 threads |
| 多轮后准确率下降 | 工具定义被历史挤占 | 固定工具列表位置,压缩历史 |
| 内存持续增长 | 上下文窗口未限制或缓存未清理 | 设置 ctx-size,复用消息结构 |
做完这个小项目之后,我最大的感受是:端侧智能体的瓶颈,早就不在“模型会不会说话”,而在“愿不愿意按规矩办事”。FunctionGemma 这类专用模型把“按规矩办事”的门槛降得很低,但真正的工程量仍然在工具定义、消息编排和异常兜底上。这些工作看起来琐碎,却决定了用户会不会觉得这个智能体“真的有用”。
另外再分享一个收尾的小技巧:如果你也要在端侧调 FunctionGemma,一定要把每个工具的真实执行结果打点记录下来。不仅是排查问题要用,它还能成为你优化工具描述的素材库——看多了模型因为什么失败,你自然就知道 description 该怎么改了。