如果你手头只有一台普通配置的笔记本,或者一块 8GB 内存的树莓派,也想跑一个能自己查天气、调用计算器、甚至控制家里智能设备的 Agent,那 MiniCPM5-2B 应该是最值得先试的模型之一。这个 2B 参数规模的端侧模型,在工具调用上做了不少对齐,配合 Ollama 这类本地推理服务,十几分钟就能搭出一个能“干活”的智能体,而不是只会聊天的玩具。
这篇文章我会从选型思路开始,把本地部署、Function Calling、工具调用循环、参数解析这些完整流程拆开讲,最后给出一份可以直接复制跑的 Python 示例代码。适合有 Python 基础、想在本地低算力设备上玩 Agent 的朋友,也适合准备折腾端侧 AI 硬件部署的开发者参考。我知道很多人在这一步卡住的地方不是模型推理,而是“模型明明返回了 tool_call,但代码一解析就报错”,尤其是嵌套 arguments 的问题特别烦。这些我踩过的坑,下面都会写清楚。
1. 为什么在端侧跑 Agent 要选 MiniCPM5-2B
1.1 2B 参数模型到底适合什么样的硬件
先算一笔账。一个 2B 参数的模型,如果按 FP16 精度存权重,大约要占 4GB 左右;如果量化成 Q4_K_M 这种常见格式,权重能压到 1.2GB 到 1.5GB。再加上推理时的 KV Cache、中间激活值和临时内存开销,整机可用内存至少要有 8GB 才比较从容。
实际部署场景里,我用过两条路线:一条是在 M1 芯片的 MacBook 上跑,另一条是在树莓派 5(8GB 版本)上跑。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 及以上 | 显存 8GB | Q4_K_M / Q5_K_M | 流畅,单次生成延迟低 |
| Apple Silicon 16GB | 统一内存 16GB | Q4_K_M | 流畅,能跑较长上下文 |
| 树莓派 5 8GB | 内存 8GB | Q4_K_M | 可跑,速度偏慢,适合验证 |
| 只靠 CPU 的低配笔记本 | 内存 16GB | Q4_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,然后写一个 Modelfile:
FROM ./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_HOST=0.0.0.0 ollama serve这里补充一句:端侧部署时我一般建议不要轻易把服务暴露出去,除非你知道自己在做什么。局域网内测试可以,公网千万别开,Ollama 默认没有鉴权机制。
2.4 部署时的量化与采样参数
量化等级对最终效果影响很大。我实测下来,2B 模型用 Q4_K_M 是性价比较高的选择,内存占用低,输出质量损失在可接受范围;如果内存充足,Q8_0 会明显减少“胡言乱语”的情况。F16 虽然在端侧不推荐,但如果你只是为了在电脑上验证效果,也不拦你。
采样参数也要专门调。Agent 场景下我不喜欢高温度,temperature=0.3、top_p=0.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": "计算数学表达式,例如 (1+2)*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_url="http://localhost:11434/v1", api_key="ollama", ) response = client.chat.completions.create( model="minicpm5-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_choice="auto", temperature=0.3, ) print(response.choices[0].message)tool_choice="auto"表示让模型自己决定要不要调用工具。如果你确定某个场景必须用固定工具,也可以直接指定{"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:end+1]) 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_url="http://localhost:11434/v1", api_key="ollama", ) 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": "数学表达式,例如 (1+2)*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:end+1]) 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_steps=4): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(max_steps): response = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto", temperature=0.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_ascii=False), }) 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_MODELS=1,避免同时加载多个模型把内存挤爆。 - 设置
OLLAMA_NUM_PARALLEL=1,一次只处理一个请求,避免多个 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 文件,下次用户提问时自动带上相关历史。这个小改动会明显提升多轮对话体验,成本也不高。希望这篇实战记录能帮你少踩几个坑。