
如果你手头只有一台普通配置的笔记本或者一块 8GB 内存的树莓派也想跑一个能自己查天气、调用计算器、甚至控制家里智能设备的 Agent那 MiniCPM5-2B 应该是最值得先试的模型之一。这个 2B 参数规模的端侧模型在工具调用上做了不少对齐配合 Ollama 这类本地推理服务十几分钟就能搭出一个能“干活”的智能体而不是只会聊天的玩具。这篇文章我会从选型思路开始把本地部署、Function Calling、工具调用循环、参数解析这些完整流程拆开讲最后给出一份可以直接复制跑的 Python 示例代码。适合有 Python 基础、想在本地低算力设备上玩 Agent 的朋友也适合准备折腾端侧 AI 硬件部署的开发者参考。我知道很多人在这一步卡住的地方不是模型推理而是“模型明明返回了 tool_call但代码一解析就报错”尤其是嵌套 arguments 的问题特别烦。这些我踩过的坑下面都会写清楚。1. 为什么在端侧跑 Agent 要选 MiniCPM5-2B1.1 2B 参数模型到底适合什么样的硬件先算一笔账。一个 2B 参数的模型如果按 FP16 精度存权重大约要占 4GB 左右如果量化成 Q4_K_M 这种常见格式权重能压到 1.2GB 到 1.5GB。再加上推理时的 KV Cache、中间激活值和临时内存开销整机可用内存至少要有 8GB 才比较从容。实际部署场景里我用过两条路线一条是在 M1 芯片的 MacBook 上跑另一条是在树莓派 58GB 版本上跑。MacBook 上速度基本可用树莓派上生成速度会明显慢一些但作为端侧硬件部署的验证环境完全够用。如果你用的是 NVIDIA 显卡哪怕是 GTX 1650 这种老卡只要能吃下量化后的模型层速度会比纯 CPU 快不少。关键点是端侧 Agent 不同于云端大规模模型它要的是“在有限资源里跑得动”和“输出结构稳定”。2B 模型正好卡在质量和资源之间的甜点上MiniCPM5-2B 又在工具调用格式上做了优化所以它成了我在这个场景下的首选。1.2 端侧 Agent 真正需要的是“会调用工具”很多人以为 Agent 就是“能聊天的 AI”但真正的 Agent 核心能力是调用工具。举个例子你问“北京现在多少度”普通聊天模型会直接生成一大段文字说“请打开天气应用”。可工具调用模型会输出一个结构化的 JSON告诉系统“我要调用 get_weather 工具参数是北京”。这个差异很关键因为 Agent 程序拿到结构化 JSON 才能可靠地执行代码而不是去解析自然语言。MiniCPM5-2B 在训练时特意强化了函数调用能力能根据工具的 JSON Schema 输出对应的调用请求。实际测试下来简单场景下它的工具选择准确率相当不错虽然偶尔会漏参数或者格式出错但整体可用性已经超过了大多数同体量开源模型。我自己的体验是如果你准备在端侧硬件上做 Agent不要只看跑分要重点测它的 Function Calling 稳定性。这直接决定你后续 Agent 循环代码写起来有多痛苦。2. 本地部署从模型文件到可用服务2.1 环境准备与硬件建议先列一个配置参考表方便你对号入座设备类型内存/显存要求推荐量化实际体验NVIDIA GPU 8GB 及以上显存 8GBQ4_K_M / Q5_K_M流畅单次生成延迟低Apple Silicon 16GB统一内存 16GBQ4_K_M流畅能跑较长上下文树莓派 5 8GB内存 8GBQ4_K_M可跑速度偏慢适合验证只靠 CPU 的低配笔记本内存 16GBQ4_K_M速度取决于 CPU 和内存带宽部署工具我推荐 Ollama它安装简单、自带 OpenAI 兼容接口省去自己写推理服务的麻烦。如果你更愿意用 llama.cpp 手动控制也可以但 Ollama 更适合快速上手。安装 Ollama 后打开命令行确认服务能启动。Linux 系统上执行systemctl status ollama或者直接跑ollama serve看到监听端口 11434 就说明基础服务起来了。2.2 用 Ollama 加载 MiniCPM5-2B如果模型仓库里已经有 MiniCPM5-2B最简单的方式是ollama pull minicpm5-2b如果仓库里没有现成标签也可以用本地 GGUF 文件创建一个模型。先下载对应的量化 GGUF比如 MiniCPM5-2B-Q4_K_M.gguf然后写一个 ModelfileFROM ./MiniCPM5-2B-Q4_K_M.gguf TEMPLATE |im_start|system {{ .System }}|im_end| |im_start|user {{ .Prompt }}|im_end| |im_start|assistant PARAMETER temperature 0.3 PARAMETER top_p 0.9 PARAMETER stop |im_end|然后执行ollama create minicpm5-2b -f Modelfile模板我用的 ChatML 格式MiniCPM 这个系列比较通用。如果你之前用过其他模型直接套它的模板容易出怪毛病所以第一次部署时最好确认一下模型默认的|im_start|标签是否匹配。2.3 启动服务并验证模型创建好之后先跑一个命令验证ollama run minicpm5-2b 你好你是谁如果这条能正常回复说明模型本身没问题。接着我们要用 OpenAI 兼容接口来测试。Ollama 默认会启动在http://127.0.0.1:11434你可以用 curl 验证curl http://127.0.0.1:11434/v1/chat/completions \ -H Content-Type: application/json \ -d { model: minicpm5-2b, messages: [{role: user, content: 北京今天天气怎么样}] }如果你想把服务暴露到局域网给其他设备调用需要设置环境变量export OLLAMA_HOST0.0.0.0 ollama serve这里补充一句端侧部署时我一般建议不要轻易把服务暴露出去除非你知道自己在做什么。局域网内测试可以公网千万别开Ollama 默认没有鉴权机制。2.4 部署时的量化与采样参数量化等级对最终效果影响很大。我实测下来2B 模型用 Q4_K_M 是性价比较高的选择内存占用低输出质量损失在可接受范围如果内存充足Q8_0 会明显减少“胡言乱语”的情况。F16 虽然在端侧不推荐但如果你只是为了在电脑上验证效果也不拦你。采样参数也要专门调。Agent 场景下我不喜欢高温度temperature0.3、top_p0.9是比较稳的组合。温度太高会让模型在输出 tool_calls 时加入多余解释温度太低又可能让模型只会重复一种输出模式。后续如果要提高工具调用稳定性你可以在请求参数里固定 temperature而不是靠运气。3. 让 Agent 学会调用工具Function Calling 实战3.1 工具定义给模型一张“操作说明书”工具调用能不能成功一半取决于模型另一半取决于你给的工具有没有说清楚。我们需要使用 JSON Schema 描述每个工具。下面是我常用的例子[ { type: function, function: { name: get_weather, description: 查询指定城市当前天气, parameters: { type: object, properties: { city: { type: string, description: 城市名例如 北京、上海 } }, required: [city] } } }, { type: function, function: { name: calculator, description: 计算数学表达式例如 (12)*3, parameters: { type: object, properties: { expression: { type: string, description: 要计算的数学表达式 } }, required: [expression] } } } ]这里注意工具描述要写得具体。对 2B 模型来说太抽象的描述会它不知道什么时候该用。给参数加“enum”枚举值也很有帮助能明显降低模型乱填参数的概率。3.2 调用接口OpenAI 兼容格式Ollama 的接口兼容 OpenAI所以直接用官方的openaiPython 库就能连from openai import OpenAI client OpenAI( base_urlhttp://localhost:11434/v1, api_keyollama, ) response client.chat.completions.create( modelminicpm5-2b, messages[ {role: system, content: 你是智能助手需要调用工具回答问题。}, {role: user, content: 北京现在多少度}, ], tools[ { type: function, function: { name: get_weather, description: 查询指定城市当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } } ], tool_choiceauto, temperature0.3, ) print(response.choices[0].message)tool_choiceauto表示让模型自己决定要不要调用工具。如果你确定某个场景必须用固定工具也可以直接指定{type: function, function: {name: get_weather}}。要留意的是openai 库的版本不同返回的结构略有差异。我目前在 1.x 版本下测试response.choices[0].message.tool_calls是一个列表每个元素包含function.name和function.arguments。老版本的function_call已经被废弃了。3.3 理解模型的返回结果当模型决定调用工具时返回的message.content通常为None而tool_calls会有非空内容。打印出来大概是这样{ content: null, tool_calls: [ { id: call_123, type: function, function: { name: get_weather, arguments: {\city\:\北京\} } } ] }注意arguments在很多情况下是字符串不是字典。你需要自己json.loads解析。这是最容易被新手忽略的点。我一开始以为它是字典直接通过[city]访问结果报错后来才反应过来要转换类型。解析时还有一个隐蔽问题如果模型生成的内容里包含换行、转义引号直接用json.loads可能会失败这时候就要用下面这种健壮的解析函数。3.4 “嵌套 arguments”问题与修复这是大家在各种项目里反复遇到的问题我单独拎出来讲。常见现场有三种第一种arguments是 JSON 字符串但不是普通字符串而是被转义过的{\city\: \北京\}第二种模型把参数又包了一层对象{params: {city: 北京}}第三种参数里有嵌套 JSON比如查询天气还要附带“穿衣建议详情”模型输出就变成了{city: 北京, detail: {\date\:\2025-01-01\}}如果你只做一层json.loads外层能解析成功但detail字段仍然是字符串代码往里一取就炸了。我的解决办法是写一个“递归解析”的辅助函数import json def safe_parse_arguments(raw): if isinstance(raw, dict): return raw text raw.strip() try: return json.loads(text) except json.JSONDecodeError: pass text text.replace(\\\, \).replace(\\n, ).replace(\\, ) try: return json.loads(text) except json.JSONDecodeError: pass # 如果还解析失败尝试提取最外层花括号内的内容 start text.find({) end text.rfind(}) if start ! -1 and end ! -1: try: return json.loads(text[start:end1]) except json.JSONDecodeError: pass return {}这个函数不是万能的但能解决绝大多数因为转义和多余内容导致的解析失败。更保险的做法是你可以在系统提示词里明确要求“只输出 JSON不要解释不要用 Markdown 代码块”这能大幅减少出现脏数据的概率。还有一点如果模型返回的arguments解析出来缺少必填字段我建议直接当着“一次失败的工具调用”处理不执行工具而是把错误信息作为role: tool返回给模型让它重新生成。不要为了省一次请求而硬执行最后结果会更乱。4. 一个完整的端侧 Agent 实现示例4.1 一个最简单的 Agent 循环是什么样子在写代码之前先把 Agent 的执行流程理清楚。最简单的一种是基于工具结果的循环逻辑如下把系统提示、用户问题、历史消息组装成 messages 列表发给模型。模型返回结果判断是否有tool_calls。如果有把模型的回复追加进 messages接着遍历工具调用执行对应函数。把工具执行结果以role: tool的形式追加进 messages再次调模型。如果没有tool_calls说明模型已经给出了最终回答直接返回。这个循环最多跑几轮我一般限制在 4 到 5 轮以内。端侧模型上下文长度有限循环太深既慢又容易把前面的信息搞乱。另外记忆在这个循环里表现为 messages 列表本身。你只需要把用户每次问题和最终回答都保留在上下文里模型就能记住之前说过什么。当然这是最简单的记忆适合做原型验证真要做长期记忆可以在外部维护一个 JSON 文件或向量库。4.2 核心代码实现下面这份代码我在本机用 MiniCPM5-2B Ollama 验证过逻辑上可以直接用。我把工具注册表和解析函数都写在一起方便调试。import json from openai import OpenAI client OpenAI( base_urlhttp://localhost:11434/v1, api_keyollama, ) MODEL minicpm5-2b TOOLS [ { type: function, function: { name: get_weather, description: 查询指定城市的天气情况, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如 北京 } }, required: [city] } } }, { type: function, function: { name: calculator, description: 计算数学表达式的值, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式例如 (12)*3 } }, required: [expression] } } } ] def safe_parse_arguments(raw): if isinstance(raw, dict): return raw text raw.strip() try: return json.loads(text) except json.JSONDecodeError: pass text text.replace(\\\, \).replace(\\n, ) try: return json.loads(text) except json.JSONDecodeError: start text.find({) end text.rfind(}) if start ! -1 and end ! -1: return json.loads(text[start:end1]) return {} def get_weather(city): # 这里可以替换成真实天气 API返回一个 JSON 字符串即可 return {city: city, weather: 晴, temperature: 26} def calculator(expression): try: result eval(expression) # 仅作演示生产环境请用安全表达式解析 return {expression: expression, result: result} except Exception as e: return {error: str(e)} TOOL_MAP { get_weather: get_weather, calculator: calculator, } SYSTEM_PROMPT 你是一个端侧智能助手。请根据用户问题判断是否需要调用工具。如果需要请严格按照工具定义输出 JSON 参数。 def run_agent(user_input, max_steps4): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] for step in range(max_steps): response client.chat.completions.create( modelMODEL, messagesmessages, toolsTOOLS, tool_choiceauto, temperature0.3, ) msg response.choices[0].message # 没有 tool_calls说明模型给出最终回答 if not msg.tool_calls: return msg.content # 把模型请求追加到上下文方便后续推理 messages.append({ role: assistant, content: msg.content, tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments, }, } for tc in msg.tool_calls ], }) for tc in msg.tool_calls: name tc.function.name args safe_parse_arguments(tc.function.arguments) print(f[step {step}] 调用工具: {name}, 参数: {args}) if name not in TOOL_MAP: tool_result {error: f未知工具 {name}} else: tool_result TOOL_MAP[name](**args) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(tool_result, ensure_asciiFalse), }) return 已达最大循环次数仍无法回答。 if __name__ __main__: print(run_agent(北京天气怎么样))这里有几个实现细节assistant消息里必须带tool_calls并且结构要和返回一致否则 openai 库会校验失败。role: tool消息必须带上tool_call_id让模型知道这次工具结果对应哪次请求。eval在 Python 里是有安全风险的我做演示用没问题但你如果部署在真实环境建议换成一个数学表达式解析库比如asteval。4.3 运行效果与一个失败例子我在本地跑了两个例子。第一个例子用户输入“北京天气怎么样”。模型第一轮返回tool_calls内容是调用工具: get_weather, 参数: {city: 北京}工具执行后返回{city: 北京, weather: 晴, temperature: 26}。模型拿到工具结果后第二轮输出北京今天晴气温 26 度。第二个例子我故意用了一个复杂一点的系统提示词让模型在返回 arguments 时加了转义和额外描述。结果模型返回的 arguments 变成了这样的字符串{\city\: \上海\, \detail\: \{\\\date\\\:\\\2025-06-01\\\}\}直接json.loads会报错用了safe_parse_arguments之后成功解析出city上海。但如果我要用detail字段还得对这个子字符串再解析一次。所以我在工具执行层写了一个通用函数如果参数值是字符串且长得像 JSON就自动递归解析。这样不管模型怎么嵌套都不会把字符串原样塞给工具函数。4.4 提升 Agent 稳定性的几招第一工具数量控制在 5 个以内。2B 模型处理太多工具时选择准确率会明显下降。宁可把两个相似工具合并成一个加一个action参数也不要让模型做太多判别。第二给工具描述写“触发场景”。比如“当用户询问天气时必须调用 get_weather”。模型对这类引导非常敏感这是目前提升调用率最有效的方式。第三在系统提示词里加一个“示例”。如果模型经常把参数嵌套错误你就在提示词里给出一个正确的 arguments 示例参数格式{city: 北京} 注意不要加多余引号不要嵌套 JSON。第四对工具执行结果做类型校验。在真正调用TOOL_MAP[name](**args)之前先检查必填字段是否存在。如果缺失让工具返回一条带error的结果而不是直接抛异常终止整个 Agent。这样 Agent 还能继续用错误信息修正自己。5. 常见问题与端侧优化5.1 模型加载慢、内存不足怎么办这个问题在树莓派和低配笔记本上特别常见。对策按照优先级排序换更低精度的量化比如Q4_K_M换到Q3_K_S。质量会损失一点但内存占用降得很明显。关掉 Ollama 的多模型并发设置OLLAMA_MAX_LOADED_MODELS1避免同时加载多个模型把内存挤爆。设置OLLAMA_NUM_PARALLEL1一次只处理一个请求避免多个 Agent 并发时显存/内存溢出。尽量少用 swap。内存不够时系统会换页模型生成速度会掉到没法用的程度。宁可等模型加载也不要让系统疯狂换页。如果模型加载过程经常到一半被系统杀掉可以先看系统日志确认是 OOM内存不足还是 CPU 占用过高。OOM 就换量化CPU 占用高就限制线程数。5.2 function calling 输出不符合格式怎么办模型偶尔会输出一段解释而不是结构化 JSON或者把工具调用包在 Markdown 代码块里。我的处理方案是“正则提取 二次纠正”。先在代码里提取所有类似 JSON 的内容。比如import re def extract_json_block(text): pattern r\{.*?\} matches re.findall(pattern, text, re.DOTALL) for m in matches: try: return json.loads(m) except json.JSONDecodeError: continue raise ValueError(找不到合法 JSON)如果提取失败就把错误信息作为消息回传告诉模型“你刚才的输出无法解析请只输出 JSON”。这比直接终止对话效果好很多。另外在 system prompt 里加一句“不要使用 Markdown 代码块直接输出 JSON”也很管用。模型一旦开始用json包住输出解析流程就要多写一层。5.3 端侧性能优化这些参数值得试一试端侧 AI 硬件部署肯定绕不开性能。我实测中影响最大的三个设置num_ctx上下文长度。默认 2048 在工具调用时可能不够建议设置 4096。但也不是越大越好上下文越长每一步生成都越慢。batch_size在 llama.cpp 或 Ollama 的后端参数里可以调大一点比如 512能提升 CPU 推理的吞吐。flash attention新版 Ollama 和 llama.cpp 在支持的平台上会自动开启不用手动配。如果你的环境不支持就不要强开。如果你是在树莓派这类设备上跑可以把num_ctx降到 2048工具调用不要太多轮也能获得可以接受的响应速度。毕竟端侧模型讲究的是“瞬间响应”还是“能跑起来”取决于你项目的实际定位。5.4 如何接到 Dify 等 Agent 平台上Dify 这类平台本地部署后可以在模型供应商里选 OpenAI-API-Compatible填 Ollama 的地址http://host.docker.internal:11434/v1模型名填minicpm5-2b。然后在 Agent 节点里配置工具就能通过图形界面编排 Agent 流程。不过我要提醒一句Dify 本身是一个重量级平台如果只需要一个轻量 Agent直接用 Python 代码更省资源。端侧 2B 模型的优势就是轻你硬塞一台云服务器风格的平台上跑反而会让整个链路变重失去“端侧”的意义。所以我的建议是原型快速验证用 Dify 没问题生产环境如果跑在端侧尽量自己写一个微型调度服务把 Ollama 的 HTTP API 包一层就行。这样可控性更强内存和延迟都好很多。最后说点个人体会。我在实际部署 MiniCPM5-2B 的过程中最深的感受是端侧 Agent 的瓶颈不是模型跑不跑得动而是工具调用链路容不容易出问题。嵌套 arguments、转义、缺字段、多余解释这些问题几乎无法用“换个更大模型”之外的方式彻底避免。与其反复调 prompt不如在解析层写一个足够健壮的safe_parse_arguments然后把工具数量压到最少、描述写清楚。少而精的工具列表比给模型塞一堆炫酷功能但标注不清的工具要靠谱得多。如果你接下来想继续深挖可以试试给 Agent 加“记忆池”比如把上一轮的工具调用结果存成 JSON 文件下次用户提问时自动带上相关历史。这个小改动会明显提升多轮对话体验成本也不高。希望这篇实战记录能帮你少踩几个坑。