最近把 MiniCPM5-2B 拉到本地,配成了一个能自己决定调用工具、再根据结果回答问题的端侧 Agent。这个事做下来比我预想的要有意思得多——2B 参数放在今天的大模型阵营里确实算小个子,但正因为它小,你不需要一张昂贵的显卡,不需要连云端 API,就能在笔记本、小主机甚至开发板上拥有一套“会动手”的本地 AI。这篇文章把我从模型下载、量化选型、服务启动,到工具调用代码、循环调度、踩坑排查的完整过程都记录下来,代码可以直接拿去跑,希望能给正在折腾本地部署大语言模型的朋友一点参考。
1. 为什么选 MiniCPM5-2B 做端侧 Agent
1.1 2B 参数凭什么跑 Agent
很多人一听到 Agent,脑子里全是云端几千亿参数的大模型,觉得小参数模型做不了这件事。这个印象要分场景。MiniCPM5-2B 属于面向端侧优化的模型,经过指令微调和对齐之后,已经具备比较稳定的 Function Calling 能力,也就是能理解“有哪些函数可以用、什么时候该调用、参数怎么写”。我在实测里,只挂两三个工具的情况下,调用路径基本不出错。
端侧模型做 Agent 的真正优势在于三点:第一是隐私,数据不出设备,适合处理日程、账单、本地文件这些敏感信息;第二是零成本,没有按 token 计费的问题,跑多少次都不心疼;第三是低延迟,同一个局域网内请求本机服务,省去了公网往返。
但 2B 模型的能力边界必须认清。多步推理、超长上下文、复杂工具它都容易翻车,我在测试七个子工具同时注册时,模型明显开始“犯迷糊”,不是漏参数就是凭空编函数名。所以它的正确定位是:规则清晰、工具数量可控的轻量场景。想做家用智能助手、本地文件管家、简单查询机器人,它完全够用。
1.2 端侧 Agent 的核心链路
Agent 听起来复杂,拆开其实就一条循环:用户提问 → 模型判断是否需要工具 → 需要就输出工具调用请求 → 后端执行对应函数 → 把结果回填给模型 → 模型继续推理直到给出最终答案。
我用一个生活化的类比:模型像一个刚入职的实习生,它不直接动手做所有事,但它知道遇到什么问题该打哪个电话。你写的 Python 函数就是那部电话,工具注册表是通讯录,实习生判断“要不要打电话、拨哪个号、说什么话”,决策全在模型里。本质上我们做的事情是两件:让模型学会使用工具,以及把工具的返回值重新“翻译”成用户能听懂的话。
这个循环里最容易失控的是死循环。模型有可能反复调用同一个工具,或者调用完不总结直接又请求一次。所以无论用哪种框架,我都建议在代码层加一个循环上限,通常三轮到五轮足够,超过就强制终止并返回当前信息。
1.3 部署工具选型:Ollama 还是 llama.cpp
本地部署方式我实测过三种,这里先给出对比结论。
| 方案 | 上手难度 | 工具调用支持 | 适合场景 |
|---|---|---|---|
| Ollama | 最低,一条命令 | 支持 OpenAI 兼容 tools,但不同版本稳定度有差异 | 快速验证、个人使用 |
| llama.cpp server | 中等,需下载或编译 | 支持 tools,配合 JSON 语法约束更稳定 | 深度定制、产品化 |
| Transformers + vLLM | 较高,资源开销大 | 功能全,但端侧不划算 | 多卡服务器、大规模并发 |
我的选择是:日常验证用 Ollama,因为它把模型管理、服务启动、接口暴露全封装好了,一条ollama serve就能拉起 OpenAI 兼容的/v1端点。而做工具调用调试时,如果发现模型输出格式不稳定,我会切到 llama.cpp server,用它的 JSON 语法约束功能把输出“框”住。这两者底层都是 llama.cpp 那套推理引擎,模型文件也能共用 GGUF 格式,所以不存在迁移成本。
2. 本地部署完整流程:从量化选型到服务启动
2.1 先算一笔账:你的设备能跑起哪个量化版
动手之前,先搞清楚手里的设备能吃下多大的模型文件。2B 参数模型的全精度(FP16)权重大约占用 4.3GB 显存,这还没算 KV Cache 和运行时开销,所以 8GB 显存的显卡跑全精度勉强,但再叠加其他程序就容易爆。解决办法是量化。
| 量化级别 | 模型文件大小(估算) | 显存占用(估算) | 效果 |
|---|---|---|---|
| FP16 | 约 4.3GB | 5.5GB 起 | 基线 |
| Q8_0 | 约 2.2GB | 3GB 起 | 接近无损 |
| Q6_K | 约 1.7GB | 2.5GB 起 | 损失很小 |
| Q4_K_M | 约 1.4GB | 2GB 起 | 综合推荐 |
| IQ4_XS | 约 1.1GB | 1.5GB 起 | 效果略降 |
我这次用的就是 Q4_K_M,因为 Agent 场景里上下文和工具 Schema 会占用不少 KV Cache,量化省下来的显存正好留给长对话。如果只有 CPU,16GB 内存跑 Q4 版本也完全可行,只是生成速度慢一些,大概每秒几个 token,适合对实时性要求不高的任务。先跑通再追求精度,这是本地部署的黄金法则。
2.2 Ollama 直装部署
Ollama 的安装不多说,官方脚本一条命令。装好后,最理想的情况是官方仓库已经有可用模型:
ollama pull minicpm5-2b如果搜不到对应标签,也可以去 HuggingFace 或 ModelScope 下载 GGUF 文件,再手动导入。先创建一个Modelfile:
FROM ./MiniCPM5-2B-Q4_K_M.gguf然后执行:
ollama create minicpm5-2b -f Modelfile ollama serveollama serve默认监听本机 11434 端口。如果想同一局域网内的其他设备访问,需要设置环境变量OLLAMA_HOST=0.0.0.0:11434再启动。
验证服务是否正常,直接请求/v1/models:
curl http://localhost:11434/v1/models能看到模型列表说明服务已经就绪。Ollama 会自动拉起模型并常驻内存,第一次请求会慢一些,后面就快了。
2.3 llama.cpp server 部署
需要更强控制力时,我用 llama.cpp 的官方二进制。下载对应系统的 release 包,解压后直接运行:
llama-server -m ./MiniCPM5-2B-Q4_K_M.gguf \ --host 127.0.0.1 \ --port 8080 \ --ctx-size 8192 \ --threads 8 \ --parallel 1几个参数我说明一下:--host 127.0.0.1只允许本机访问,安全;--ctx-size 8192给工具调用留足上下文空间;--threads 8让 CPU 推理吃满多核;--parallel 1是单路并发,避免多请求互相抢占造成延迟抖动。
新版 llama.cpp 默认开启--jinja聊天模板,OpenAI 兼容接口也能直接识别 tools。如果你的版本较老,工具调用支持不完整,建议升级到新版本再试。启动后同样可以用curl http://127.0.0.1:8080/v1/models验证。
2.4 服务自检:怎么确认模型真的活着
服务起来不等于万事大吉。我习惯先发一个最简单的对话请求,确认模型能正常返回:
curl http://localhost:11434/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "minicpm5-2b", "messages": [{"role": "user", "content": "你好,只说一句话"}]}'看到返回 JSON 里有choices[0].message.content就说明链路通了。另外建议测一下响应时间,第一次请求往往包含模型加载,几十秒很正常,第二次应该降到几秒内。如果第二次还是很慢,去检查是不是量化等级太高、CPU 线程数不够,或者磁盘读取太慢导致模型加载反复。
3. 工具调用实战:写一个会自己“干活”的 Agent
3.1 工具 Schema 要这样设计,别难为 2B 模型
工具调用能否成功,一半功劳在模型,另一半在 Schema 设计。云端大模型容错率高,schema 写复杂点没关系,但 2B 模型的字段理解能力有限,schema 越简洁越不容易出错。
我的设计原则有五条:
- 工具函数名用小写英文加下划线,不要用大小写混合,例如
get_weather,模型对小写连续词更稳。 description里写清楚“什么时候该用这个工具”,比如“当用户询问天气时使用”,这比单纯写“获取天气”管用得多。- 参数数量控制在三个以内,参数类型只用
string、number、boolean这些基础类型,不要嵌套对象。 - 每个参数的
description也要写,最好带上示例值,比如"city": "城市名,如 北京",模型照抄示例就不容易编错。 - 同时注册的工具不要超过五个。超出后模型选择工具的准确率会明显下降,这是我在真实项目中反复验证过的。
一个典型的工具 Schema 长这样:
{ "type": "function", "function": { "name": "get_weather", "description": "当用户询问某个城市的天气时使用", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如 北京" } }, "required": ["city"] } } }3.2 第一次发送带工具的请求
模型部署好、Schema 写好后,第一次带工具的请求可以用 Python 直接打接口。这里我用 Ollama 的 OpenAI 兼容端点,端口 11434:
import json import requests BASE_URL = "http://localhost:11434/v1" tools = [ { "type": "function", "function": { "name": "get_weather", "description": "当用户询问某个城市的天气时使用", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如 北京" } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "当用户需要计算数学表达式时使用,例如 23*17", "parameters": { "type": "object", "properties": { "expr": { "type": "string", "description": "数学表达式,例如 (12+3)*4" } }, "required": ["expr"] } } } ] messages = [ {"role": "user", "content": "北京天气怎么样?顺便算一下 23*17"} ] payload = { "model": "minicpm5-2b", "messages": messages, "tools": tools } resp = requests.post(f"{BASE_URL}/chat/completions", json=payload, timeout=60) data = resp.json() print(json.dumps(data, ensure_ascii=False, indent=2))正常情况下的返回里会包含tool_calls字段,里面是模型决定调用的函数名和参数:
{ "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_001", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } }, { "id": "call_002", "function": { "name": "calculate", "arguments": "{\"expr\": \"23*17\"}" } } ] } } ] }看到这个结构,说明模型已经正确理解“该用哪些工具、参数是什么”。接下来的工作就是把它转化成真正的函数调用。
3.3 完整 Agent 循环代码
下面是一段可以直接跑通的完整 Agent 循环。我把每个阶段都做了日志输出,方便观察模型的一举一动:
import json import requests BASE_URL = "http://localhost:11434/v1" def get_weather(city: str) -> str: # 演示用,实际可接入天气服务 return f"{city} 今天晴,27 度,体感舒适,适合出门。" def calculate(expr: str) -> str: # 注意:eval 有安全风险,仅用于本地可信场景,生产环境请用 asteval try: result = eval(expr) return str(result) except Exception as e: return f"计算失败: {e}" TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "当用户询问某个城市的天气时使用", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如 北京"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "当用户需要计算数学表达式时使用,例如 23*17", "parameters": { "type": "object", "properties": { "expr": {"type": "string", "description": "数学表达式,例如 (12+3)*4"} }, "required": ["expr"] } } } ] TOOL_DISPATCH = { "get_weather": lambda args: get_weather(args["city"]), "calculate": lambda args: calculate(args["expr"]), } def chat_once(messages, tools=None): payload = {"model": "minicpm5-2b", "messages": messages} if tools: payload["tools"] = tools resp = requests.post(f"{BASE_URL}/chat/completions", json=payload, timeout=120) resp.raise_for_status() return resp.json()["choices"][0]["message"] def run_agent(user_input: str, max_rounds: int = 5): messages = [{"role": "user", "content": user_input}] for turn in range(max_rounds): msg = chat_once(messages, tools=TOOLS) messages.append(msg) if msg.get("tool_calls"): print(f"[第 {turn + 1} 轮] 模型决定调用工具") for tc in msg["tool_calls"]: fn_name = tc["function"]["name"] fn_args = json.loads(tc["function"]["arguments"]) print(f" 调用 {fn_name},参数:{fn_args}") result = TOOL_DISPATCH[fn_name](fn_args) print(f" 工具返回:{result}") messages.append({ "role": "tool", "tool_call_id": tc.get("id") or f"call_{turn}", "name": fn_name, "content": result, }) else: print(f"[第 {turn + 1} 轮] 模型直接回答") print(msg.get("content")) return print("达到最大轮次,强制退出循环") if __name__ == "__main__": run_agent("北京天气怎么样?顺便算一下 23*17")代码逻辑不复杂,核心是循环里不断把模型消息和工具结果重新拼回messages,让模型能够看到前面发生过什么。有个细节值得说明:有时候 Ollama 返回的tool_call_id字段名可能不一致,所以我用tc.get("id")做了兜底,避免因为字段缺失导致请求报错。
我还试过更保守的做法:直接把工具结果伪装成一条 user 消息,比如“get_weather 工具返回:北京今天晴 27 度”。对 2B 小模型来说,这种平铺直叙的文本往往比严格的role: tool消息更容易理解。如果你的模型在标准格式下总是“转不过弯”,可以试试这个降级方案。
4. 端侧 Agent 踩坑实录
4.1 上下文被撑爆怎么办
2B 模型的上下文窗口有限,而工具 Schema、工具返回结果每轮都在占用 token。我第一次跑长对话时,模型突然开始答非所问,查看服务日志才发现是上下文长度触顶被截断了。
解决思路分两层。第一层,在服务层调大上下文窗口,Ollama 里可以设置num_ctx,llama.cpp 里就是--ctx-size,我建议起步 8192。第二层,在代码层做窗口滑动,只保留最近的几轮对话,把最早的轮次丢弃。
另一个更见效的方法是截断工具返回结果。工具结果往往又长又杂,模型真正需要的只是其中的关键信息。我在代码里加了一个简单的截断:
def truncate(text, max_len=200): return text if len(text) <= max_len else text[:max_len] + "..."将工具结果统一截断到 200 字符以内,既保住关键信息,又不让上下文迅速膨胀。这个改动让连续对话的轮数大幅提升。
4.2 工具调用 JSON 总是解析失败
工具调用依赖模型输出合法 JSON,但小模型经常输出一些“调皮”的东西:有时把 JSON 放在代码块里,有时参数值少了引号,有时直接在 JSON 后面追加解释文字。
我总结了一套清洗流程。先用正则把可疑的内容摘出来:
import re import json def extract_json(text: str): # 去掉 ```json 代码块标记 text = re.sub(r"```json|```", "", text) # 直接找最外层花括号 match = re.search(r"\{.*\}", text, re.DOTALL) if not match: raise ValueError("未找到 JSON 内容") return json.loads(match.group(0))如果清洗后还是解析失败,我会降低 Schema 复杂度,参数名尽量用单个词,避免嵌套。另外一个更根治的办法是使用 llama.cpp server 的 JSON 模式,通过--json-schema把输出格式锁死,模型只能按合法 JSON 生成,解析成功率几乎能到百分之百。
4.3 响应太慢,三个方向排查
端侧模型最直接的体验问题就是慢。遇到响应慢,我按三个方向挨个排查。
第一个方向是算力分配。CPU 推理时线程数要匹配物理核心数,不要超线程拉满,否则反而变慢;有显卡时把层数全部分配给 GPU,llama.cpp 用--n-gpu-layers 99,Ollama 里可设OLLAMA_GPU_LAYERS=99之类的环境变量。
第二个方向是生成长度。2B 模型生成速度本身有限,如果你不限制max_tokens,模型可能自己写出一大段啰嗦内容。在请求体里加"max_tokens": 256,能明显缩短单次响应时间。
第三个方向是冷启动。服务刚启动时第一次请求要加载模型,几十秒很正常。如果对实时性要求高,可以在启动后立刻发一个空请求让模型预热驻留内存,后续请求就快了。另外,把频繁用到的模型放在 SSD 上,也很有帮助。
4.4 模型不肯调用工具,只说漂亮话
这是小模型 Agent 最让人头疼的问题:模型明明需要外部数据,却凭自己的“想象力”直接编答案,完全不理会工具。我遇到过模型在没调用天气工具的情况下,一本正经回答“北京今天 25 度”,编得有模有样。
这个问题的根子在于模型对任务的理解不够。我的两个改进很有效。第一个是强化 system prompt:
你是本地助手。当回答需要实时信息或计算结果时,你必须先调用提供的工具,再基于工具结果回答,严禁编造数据。第二个是提供 few-shot 示例。我在 system prompt 里塞了三段完整的“用户提问-工具调用-工具结果-最终回答”示例,模型很快学会了调用路径。这个小技巧屡试不爽,我后面单独再说。
5. 把端侧 Agent 接进真实业务
5.1 从“玩具”到“工具”:三个典型场景
工具调用链路跑通之后,Agent 就不再是聊天机器人了。我目前觉得最实用的三个场景分别是:
本地文件管理。注册一个search_files工具,让模型读取本地文件目录、按关键词过滤文件,收到指令后自己“翻箱倒柜”找文件,再汇报结果。数据不出本机,适合处理合同、笔记、个人文档。
SQLite 查询。注册一个query_sqlite工具,只暴露只读 SQL 能力,用户问“上个月花了多少钱”,模型自动转成 SQL 并执行,返回统计结果。这里的关键是工具内做 SQL 白名单,禁止DELETE、UPDATE,防止模型误操作。
智能家居控制。注册set_light、set_temperature这类工具,模型理解自然语言指令后调用 MQTT 接口控制设备。因为端侧模型就在本地局域网,响应延迟比走云端低很多,隐私也更好。
接入方式很简单,你只需要把对应的 Python 函数写好,注册进TOOL_DISPATCH,再补上 Schema 即可。流程完全是通用的。
5.2 工程化落地的几条建议
如果你想把它做成一个长期跑的服务,下面这几件事是必须做的。
- 服务只绑定
127.0.0.1或内网 IP,不要直接暴露到公网。本地模型没有鉴权机制,裸奔很危险。 - 给每次请求加超时和重试逻辑。我见过模型调用工具后卡死的情况,超时兜底能避免服务假死。
- 在
TOOL_DISPATCH层做白名单和参数校验。尤其是eval、文件读写这类危险函数,一定要做完整校验,生产环境建议用asteval代替eval。 - 记录完整日志,包括模型返回的原始 tool_calls、工具执行耗时、最终回答。调试时这些日志就是救命稻草。
- 做一个无工具回退模式。一旦模型连续三轮没有正确调用工具,就切到纯对话模式直接回答,避免体验卡死。
6. 一些个人体会与给后来者的建议
折腾完这一整套端侧 Agent,我的最大感受是:别拿它和云端大模型比智商,要比的是私密性、实时性和可控性。2B 模型在工具数量少、Schema 清晰的场景里足够可靠,但一旦你想让它扮演“万能管家”,它立刻露馅。合理的做法是给它划清边界,只暴露必要的工具,把复杂业务逻辑放在工具函数内部处理。
最后再分享一个我调试小模型 Function Calling 时最有效的技巧:先造三条完整的工具调用对话样本,包括用户提问、模型输出 tool_calls、工具返回结果、最终回答,然后把这几个样本原样写进 system prompt。有了这样的 few-shot 示范,模型调用工具的准确率能从六成直接拉到九成以上。这个技巧不花一分钱,却比调十次温度参数都管用。端侧 Agent 的路还很长,但门槛已经低到一台普通笔记本就能起步了,剩下的就是你的想象力了。