☰
端侧智能体实战:从对话到行动的FunctionGemma方案
2026/10/21 12:38:57 网站建设 项目流程

把大模型真正用起来之后,我发现一个很明显的分水岭:会聊天的模型很多,会干活的模型很少。大部分时候我们打开一个助手,问它“明天天气怎么样”,它只会生成一段“天气可能不太好,建议带伞”的漂亮话,而不是真的去查接口、拉数据、设置提醒。这个差距,就是从“对话”到“行动”的距离。FunctionGemma 就是我在做端侧智能体时重点尝试的一套方案:它把函数调用能力直接内建到轻量级模型里,让模型不仅能理解意图,还能输出结构化的函数调用指令,然后由本机执行器完成实际操作。这篇文章我会从模型原理、端侧部署、代码实现和调优经验四个方面,把完整链路拆开聊清楚。

文章面向的读者是正在做端侧 AI 应用、智能体框架或者本地助手的开发者。哪怕你还没系统接触过函数调用,只要有一点大模型推理的基础,跟着后面的代码走一遍,也能在本地搭出一个能查时间、记备忘录、发通知的小智能体。我会尽量说人话,把那些文档里不会写的坑也一并摆出来。

1. 端侧智能体到底难在哪:为什么“会聊天”不等于“会行动”

1.1 对话模型的天然边界

先看一个最典型的场景。你问一个纯对话模型:“帮我把明天上午十点的会议改成十一点。”它会怎么回?大概率是:“好的,建议您前往日历应用手动修改。”这句回答看似礼貌,实际上什么都没做。原因很简单:普通语言模型的能力边界在“生成文本”,它没有一个可以去操作日历的“手”。

LLM 本质上是在给定上文的前提下预测下一个 token。它知道日历是什么,甚至知道很多改日程的步骤,但它没有权限、没有接口、也没有执行环境。想让它真正干活,就必须在模型外面接一层“工具层”。

这一步在云端相对容易,毕竟服务器可以随便调接口、跑代码。但到了手机、路由器、车载终端、嵌入式设备上,问题立刻变复杂:算力有限、内存吃紧、网络不稳定、隐私要求高。你不能指望设备端拖一个几百亿参数的模型,再依赖云端反馈。端侧智能体真正需要的,是一个小、快、准的本地“行动派”模型,而不是一个只会嘴上说说的“评论员”。

1.2 函数调用:把“意图”变成“指令”

函数调用(function calling)是当前把大模型变成行动体的最主流方案。核心思路很简单:模型不直接执行动作,而是输出一个结构化指令,比如create_event(title="项目评审", time="2025-06-10 10:00"),真正的执行交给本地代码。

这样做有四个好处:

  • 可解释:模型只负责“决定做什么”,每一步动作都是明确的函数名和参数。
  • 可控制:执行前可以做权限校验、参数校验,不让模型乱来。
  • 可回滚:如果某次调用产生了坏结果,很容易定位到是哪个函数、什么参数出问题。
  • 可扩展:新增能力只需要注册一个新函数,不需要重新训练模型。

还是用改日程举例:用户说“把明天的会改到十一点”,模型并不直接操作日历数据库,它输出reschedule_event(event_id="event_123", new_time="11:00")。本机代码拿到这个指令后,再调用系统日历 API 完成修改。这样一来,模型依然是模型,动作却真正发生了。

1.3 FunctionGemma 解决的三个核心问题

FunctionGemma 是一类面向端侧场景的轻量级工具调用模型方案。我不把它理解成某个单独模型,而是一条“训练策略 + 推理协议 + 端侧执行框架”的组合。它重点解决三个问题。

第一是轻量化。参数规模压缩到端侧能扛住的范围,同时通过定向训练把“工具调用”这个能力突出强化,而不是像通用模型那样什么都会一点、什么都不精。

第二是结构化输出稳定。纯对话模型输出 JSON 经常缺括号、多字、字段名漂移。FunctionGemma 在训练阶段使用了大量工具调用样本,并配合特殊 token 标记函数调用段,让输出格式的稳定性比通用模型高一个量级。

第三是多轮工具状态感知。真实智能体不可能只调用一次函数。用户查完天气可能接着问“那明天呢”,这时候模型必须能结合上一次工具返回结果继续推理。FunctionGemma 的训练数据里包含了多轮工具结果回填样本,所以它知道什么时候该继续调、什么时候该给最终答复。

2. FunctionGemma 是怎么设计出来的:原理与运行机制

2.1 模型架构与训练对象

不去纠结底层实现细节,从功能视角看,FunctionGemma 的底座是一个标准的 Decoder-Only 轻量语言模型,参数档位从 1B 到 4B 不等。它要做的事情,是在常规对话能力之上,额外学会一种“工具调用方言”。

训练数据混合了三类:

  • 多轮对话数据:保证模型仍然能正常聊天、总结、问答。
  • 函数调用数据:用户在某一轮提出请求,模型在回复中插入一个特殊标记,比如<function_call>,后面跟着 JSON 形式的函数名和参数。
  • 工具结果回填数据:上一轮调用了函数,这一轮的输入是<function_result>,模型需要基于这个结果生成最终回复,或者继续发起下一个函数调用。

为什么要把函数调用训练成一种“方言”而不是靠提示词现场学会?因为端侧模型参数少,泛化能力有限。如果你只是临时在系统提示词里贴几个函数定义,小模型很容易学会“读题”,却很难学会“写标准格式”。只有把大量同类样本灌进训练过程,模型才会把“看到用户请求→判断可调函数→输出 JSON 指令”这一链路变成一种条件反射。

2.2 函数描述注入与结构化输出

你可能会好奇:模型怎么知道有哪些函数可以调?答案跟提示工程有关。推理时,我们会把当前可用的函数列表转成 JSON Schema 格式,拼到系统提示词里。

一个典型的函数声明长这样:

{ "name": "create_memo", "description": "创建一条本地备忘录,适合记录临时想法和待办事项", "parameters": { "type": "object", "properties": { "title": { "type": "string", "description": "备忘录标题,尽量简洁" }, "content": { "type": "string", "description": "备忘录正文内容" } }, "required": ["title", "content"] } }

这段描述不是给机器看的,机器其实不“理解”自然语言的全部含义,它是给模型的注意力机制做引导的。描述写得越具体,模型越容易在需要的时候选择这个函数,而不是搞混参数。

结构化输出则通过两重机制保证。第一重是训练记忆,模型见多了<function_call>的格式,自然会模仿。第二重是推理后处理,如果模型输出的 JSON 不够规整,代码层可以做一次容错解析,比如提取方括号里的文本、修复缺失的尾括号、把单引号转成双引号。这两重机制缺一不可。

2.3 一次完整调用链路的六个阶段

端侧智能体不是把函数定义拼进去就算完,真正跑起来之后,每一步都是一段可复用的逻辑。我把完整链路拆成六个阶段:

  1. 构造请求:把系统提示词、函数声明、对话历史、当前问题拼成一个 prompt。
  2. 模型推理:由 FunctionGemma 生成结果,结果可能是一次文本回复,也可能是一次函数调用。
  3. 解析输出:识别<function_call>标记,尝试提取 JSON,判断格式是否合法。
  4. 校验指令:检查函数名是否白名单、参数是否匹配 schema、必填字段是否齐全。
  5. 执行函数:调用本地函数,拿到返回值,转成字符串或 JSON。
  6. 回填结果:把工具返回值以tool角色追加到对话里,再送进模型做下一轮推理,直到模型给出最终回复。

这个循环不是无限的。我会在工程实现里设置一个最大轮次,比如 4 到 6 次。一旦超过,就返回“我还没能完成这个任务,请换个说法再试一次”。这样做是为了防止模型因为某个函数始终报错而无限重试,浪费端侧电力。

3. 实操:用 FunctionGemma 在本地搭一个可行动的智能体

3.1 准备运行环境与模型文件

动手之前先把环境准备好。我默认你手头有一台普通开发机,CPU 能跑、内存 8G 以上就可以。如果你是做部署到手机或嵌入式设备,步骤类似,只是需要先把模型量化成更小的文件。

建议的准备项:

  • Python 3.10+,装好 json、re、datetime 等标准库,以及一个支持端侧模型推理的引擎接口。
  • 从模型仓库拿一个量化好的 FunctionGemma 权重文件,优先选 int4 或 int8 的 GGUF 格式,体积小、CPU 也能跑。
  • 把权重文件放到固定目录,比如models/functiongemma-2b-int4.gguf。
  • 初始化推理引擎时,重点设置两个参数:上下文长度和线程数。上下文长度建议先给 2048,太小容易截断多轮工具结果,太大内存占用会直线上升。线程数设成 CPU 核心数的一半左右,避免推理时整个设备卡死。

3.2 定义本地工具集

第一步先把“智能体能做什么”定下来。做端侧智能体最忌讳一上来就想做全知全能,先选三五个高频、确定、可验证的工具。

我在这里定义一组很实用的工具:获取当前时间、创建本地备忘录、发送系统通知。代码很简单:

import json from datetime import datetime def get_local_time() -> str: """获取当前本地时间""" return datetime.now().isoformat() def create_memo(title: str, content: str) -> bool: """把备忘录写入本地文件""" memo = {"title": title, "content": content, "created_at": datetime.now().isoformat()} with open("memos.json", "a", encoding="utf-8") as f: f.write(json.dumps(memo, ensure_ascii=False) + "\n") return True def send_notification(title: str, message: str) -> bool: """发送一条本地系统通知""" # 这里对接平台通知接口,演示时先返回 True print(f"[通知] {title}: {message}") return True TOOL_REGISTRY = { "get_local_time": get_local_time, "create_memo": create_memo, "send_notification": send_notification, }

注意一个工程细节:TOOL_REGISTRY的 key 必须和函数声明里的name完全一致,执行层只认这个注册表,不认模型输出里的名字。这样做的好处是,即使模型偶尔输出一个不存在的函数名,执行层也能立刻拦截,而不是傻乎乎地报错。

3.3 构造函数声明与系统提示词

函数声明列表作为模型可参考的“工具手册”。前面已经展示了单个函数的 JSON Schema,实际使用时会拼成一个数组。系统提示词我通常这样写:

SYSTEM_PROMPT = """你是一个运行在本地设备上的智能助手。 你可以使用以下工具完成用户请求,但不要虚构不存在的函数。 当用户请求需要调用工具时,请严格按照下面的格式输出: <function_call>{"name": "函数名", "arguments": {"参数名": "参数值"}}</function_call> 如果用户请求不需要工具,直接使用自然语言回复。 """ FUNCTION_DEFS = [ { "name": "get_local_time", "description": "获取当前本地日期和时间。当用户问现在几点、今天日期时使用。", "parameters": { "type": "object", "properties": {}, "required": [] } }, { "name": "create_memo", "description": "把一段文字记录成本地备忘录。适用于用户说'记一下''添加到待办''写个备忘'。", "parameters": { "type": "object", "properties": { "title": {"type": "string", "description": "简短标题"}, "content": {"type": "string", "description": "完整内容"} }, "required": ["title", "content"] } }, { "name": "send_notification", "description": "发送本地系统通知。适用于用户要求提醒、通知。", "parameters": { "type": "object", "properties": { "title": {"type": "string", "description": "通知标题"}, "message": {"type": "string", "description": "通知内容"} }, "required": ["title", "message"] } } ]

为什么要单独加一句“不要虚构不存在的函数”?因为小模型在开放环境下容易把用户提到的任何动词都当成函数名。比如用户说“帮我发个邮件”,模型如果看到没有邮件函数,可能会编一个send_email出来。把约束写进系统提示词,能显著减少这类幻觉。

3.4 核心推理与执行循环代码

下面这段循环是整个智能体的心脏。它负责把对话状态、函数声明、模型输出和本地工具串起来。

def build_prompt(messages, function_defs): """把消息列表和函数声明拼成模型输入""" system_text = SYSTEM_PROMPT + "\n可用工具:\n" + json.dumps(function_defs, ensure_ascii=False) prompt = system_text + "\n" for msg in messages: role = msg["role"] content = msg["content"] if role == "user": prompt += f"\n用户: {content}\n" elif role == "tool": prompt += f"\n<function_result>{content}</function_result>\n" else: prompt += f"\n系统: {content}\n" return prompt def parse_function_call(raw_text): """从模型输出中提取函数调用 JSON""" match = re.search(r"<function_call>(.*?)</function_call>", raw_text, re.DOTALL) if not match: return None try: return json.loads(match.group(1)) except json.JSONDecodeError: return None def execute_function(func_name, func_args): """执行本地工具,返回 JSON 字符串""" if func_name not in TOOL_REGISTRY: return json.dumps({"error": f"未知函数: {func_name}"}) try: result = TOOL_REGISTRY[func_name](**func_args) return json.dumps({"result": result}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) def run_agent(user_question, max_steps=4): messages = [{"role": "user", "content": user_question}] for _ in range(max_steps): prompt = build_prompt(messages, FUNCTION_DEFS) raw_output = engine.generate(prompt) action = parse_function_call(raw_output) if action is None: # 没有函数调用,视为最终回复 return raw_output func_name = action.get("name") func_args = action.get("arguments", {}) tool_result = execute_function(func_name, func_args) messages.append({"role": "tool", "content": tool_result}) # 继续循环,让模型基于工具结果生成下一个动作或最终回复 return "已超过最大工具调用次数,任务中断"

注意上面代码里的engine.generate是一个抽象接口。不同推理引擎的调用方式不一样,但是整体逻辑完全一致。我建议你先把这段循环写稳,再替换成具体的引擎。我见过太多人一上来就去调某个推理框架的 API,反而忽略了智能体本身的调度逻辑。

3.5 部署到低算力设备的注意点

如果目标设备不是 PC,而是手机、开发板这类低算力环境,有几个点要提前处理。

第一,量化精度要够。4bit 量化通常是性价比最高的选择,模型大小能压到原来的三分之一甚至四分之一。但量化后可能会损失少量工具调用准确率,所以我建议准备两套模型:开发调试用高精度版,正式部署用 int4 版。如果在正式版上发现某个函数频繁选错,优先回查 schema 描述是否清晰,而不是急着换回高精度模型。

第二,控制上下文长度。端侧设备的内存是硬约束,一次推理能塞进去的 token 有限。多轮对话时不要无限追加历史,只保留最近两到三轮。工具结果如果很长,先让模型自己写一个摘要,再拼回上下文,而不是把全套 JSON 塞进去。

第三,做好功耗控制。函数调用循环如果设计成无限重试,设备会长时间保持高负载。我刚才在代码里加了max_steps限制,实际项目里我还会再加一条:单次任务总耗时超过 10 秒就主动结束,并返回到主界面。端侧智能体不能像云端服务那样无限“思考”。

4. 调优与避坑:把准确率从“碰运气”抬到“能交付”

4.1 函数 Schema 设计原则

函数声明看起来简单,真正决定模型能不能调对。我总结了几条实用原则。

函数名要动词开头,像是get_local_time、create_memo,不要用time、memo、notification这类名词,否则模型区分不了“查时间”和“设置时间”。

描述里要写清楚触发条件。不要只写“获取时间”,要写“用户问现在几点、当前日期时使用”。端侧模型参数小,对隐式语义的把握弱,描述越直白,选择准确率越高。

参数必填项越少越好。能用默认值就用默认值。比如create_memo如果允许省略标题,就可以把title设为非必填,模型出错的可能性会降低一个档次。

一次暴露的函数数量要控制。测试时我遇到过模型在 30 个函数里反复犹豫的情况,最后把函数按场景拆成多组,根据用户第一轮问题动态选择暴露哪一组,准确率立刻上来。如果设备只做备忘助手,那就别把“控制灯泡”的函数也塞进去。

4.2 采样参数怎么调

函数调用不是创作,不需要模型“自由发挥”。采样参数应该往确定性方向调。

温度调低是首选。0.2 以下通常比较稳,0.0 则完全贪心。我建议从 0.1 起步,如果发现模型经常输出重复内容再尝试小幅提高。top_p保持 0.9 到 0.95,主要用来过滤尾部低概率 token。top_k设成 20 到 50 之间,防止模型在函数名这种关键位置突然发散。

如果你的推理引擎支持约束解码,一定要用起来。约束解码可以限制模型只能按照预定义的 JSON Schema 生成 token,把格式错误直接扼杀在生成阶段。这是当前提升函数调用成功率最有效的工程手段,比任何提示词技巧都管用。不支持也没关系,代码层做一套健壮的 JSON 解析兜底。

4.3 多轮状态与上下文裁剪

真正跑起来之后,最容易被忽略的是多轮上下文。用户说“现在几点了?”模型调了get_local_time,返回2025-06-10T09:30:00。用户接着问“那一个小时后呢?”这时候模型必须能看到刚才的函数结果,才能计算10:30。

实现上要记住两点。

第一,工具结果一旦被模型消费,就不再需要保留原始 JSON。可以在第二轮推理前把结果压缩成一句话,比如“当前时间是 2025-06-10 09:30:00”,然后继续拼进上下文。这样既保留了信息,又不占 token。

第二,不要让工具结果“污染”新一轮对话的角色。工具结果必须以独立的tool角色放入消息列表,不能拼在 user 消息里。否则模型容易把工具结果误当成用户新指令,导致角色混乱。我遇到过好几次模型在工具返回后突然说“好的,我会记住”,就是角色没分清楚。

4.4 安全边界设计

让模型调用本地函数,等于给外部文本输入开了个执行口子,安全必须前置。

最基础的做法是白名单机制。TOOL_REGISTRY就是白名单,所有执行入口只能从这里查函数。永远不要让模型直接执行任意 Python 代码,哪怕它“发誓”只是做个加法。

其次要做参数校验。函数名合法不代表参数合法。create_memo的content如果允许任意字符串,那模型可能把用户输入的恶意内容写进文件。文件路径类参数更要小心,绝对不能让模型传一个../../etc/passwd进来。我会在每个函数内部再做一轮自己的校验,不依赖模型输出的正确性。

最后是敏感操作二次确认。删除文件、发送不可撤回的通知、修改系统配置,这类操作不能模型一说就执行。要先把待执行动作展示给用户确认,用户点击确认后再真正调用。端侧智能体追求效率,但安全底线一点不能松。

5. 常见问题排查实录

5.1 高频报错与对应解法

我在做 FunctionGemma 落地时遇到过不少问题,这里整理成一张速查表。

现象可能原因解决思路
模型输出普通文本而不是函数调用系统提示词没有说清输出格式,或函数声明没拼进去检查 build_prompt,确认 FUNCTION_DEFS 已拼到系统提示词中;把触发条件写得更直白
函数名拼错、多字少字模型对函数名的记忆不稳定,尤其函数名过长函数名控制在 2-4 个词,动词开头;在提示词里重复一遍可用函数名
参数类型错误,比如把数字写成字符串函数 schema 里没有写清楚类型检查函数定义中的parameters,必要时在描述里补类型示例
同一函数被无限重复调用工具结果没有正确回填,模型看不到结果就只能重试检查messages.append是否正确,工具角色是否被截断
工具结果存在,但最终回复却没有参考它上下文被裁剪,或者工具结果写在了不显眼的位置增加上下文长度,把最近的工具结果放在 prompt 最后面
推理速度很慢上下文太长、线程数太少、模型未量化裁剪历史,调整线程数,换 int4 量化版本
JSON 解析经常失败模型生成时没有约束解码,输出多余字符优化提示词,或者在解析层使用更宽容的 JSON 提取正则

表格里面的问题我全都真实踩过,尤其是“工具结果没回填导致重试”那个月几乎天天碰到。后来我加了一行日志,专门打印每次循环后的完整 prompt,问题立刻暴露出来:上下文被裁剪时,把工具结果裁掉了。从那以后我再也没让上下文长度超过设定值。

5.2 我的排查顺序

遇到问题不要急着调模型,先按这个顺序排查,能省很多时间。

第一步看输入,是不是 prompt 拼接错了。先把每次发给引擎的完整文本打出来,确认函数声明、历史消息、工具结果都在,并且顺序合理。这一步能解决一半问题。

第二步看输出,模型到底生成了什么。把原始 token 序列或原始文本保存下来,不要只看解析后的结果。很多时候模型生成的是正确的<function_call>,但你的正则没匹配到。

第三步看执行,工具本身有没有报错。给每个工具写一个独立的单元测试,确认get_local_time()确实返回时间、create_memo()确实写入了文件。工具有问题,再智能的模型也白搭。

第四步才回头调模型。这时候再考虑改温度、改 schema、加少样本示例。顺序反了会很痛苦,我见过有人花一周时间调提示词,最后发现是个底层 JSON 解析 bug 导致的。

6. 我的几点实操体会

做端侧智能体越久,越觉得“从对话迈向行动”这个说法不是一句口号,而是整个范式转换的本质。纯对话模型是知识引擎,重心在“答得好不好”;函数调用模型是行动引擎,重心在“做得对不对”。FunctionGemma 这类方案的真正价值,是把模型体积、工具调用能力、端侧资源约束放到同一个天平上,做出一个工程上可用的折中。

我自己的体会有三点值得分享。

第一,永远不要追求全知全能。端侧智能体最适合的场景一定是边界清晰的垂直场景。我就让它管备忘录、查时间、发通知,三个函数用得非常稳定。一旦加了太多工具,准确率下降的速度会超出你的预期。

第二,把模型看作“意图理解器”,把本机代码看作“安全执行器”。模型负责判断用户想干什么,剩下的事情全部由你写的代码负责。这条路比让模型直接生成代码安全得多,也更容易排查问题。

第三,每一轮工具调用都要有日志和可复现性。我在完成函数调用后都会把当时的 prompt、模型输出、工具结果完整存一份,标记为 trace。后续优化模型或调 schema 时,直接回放这些 trace,能快速验证改动是否有效。这个习惯帮我省下了大量回归测试的时间。

最后再分享一个小技巧。刚开始调通智能体时,可以故意在用户请求里塞一些模棱两可的话,比如“随便记一下”“提醒我明天”,观察模型是调用工具还是乱猜。只要把这类边界情况覆盖到,端侧智能体才算真正从“能跑”进化到“能用”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询