1. 为什么本地 Ollama 跑得动模型,却接不上 MCP 工具链
MCP(Model Context Protocol)是让大模型调用外部工具的开放协议,Ollama 是本地跑开源模型的运行时。把两者接起来,你就能让本地模型像云端 Agent 一样去调函数、查数据、执行动作,全程数据不出内网。这套组合适合三类人:想低成本验证 Agent 逻辑的开发者、对数据隐私敏感的企业内网场景、以及手头只有一张消费级显卡但想玩工具调用的个人玩家。
我试过直接拿 Ollama 的 function calling 硬怼,结果卡在工具描述格式上——Ollama 的tools参数和 MCP 的inputSchema结构对不齐,模型要么不返回工具调用,要么返回的 JSON 缺字段。后来换成 MCP 服务端暴露工具、客户端动态生成 Pydantic 模型再喂给 Ollama 的format参数,整条链路才跑通。
这里有个容易被忽略的点:本地模型调用链的入口管理。你可能有多个 MCP 服务端、多个 Ollama 实例、多个项目要切换 Key 和 Base URL。如果每个项目都硬编码地址,改一次环境就要翻遍代码。我的做法是用 TaoToken 统一管理调用入口——它提供一个兼容 OpenAI 协议的 API 通道,把本地 Ollama 和远端模型都收敛到同一个 Base URL 和 Key 下,切换模型只改一个 Model ID。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台拿 Key 即可。
这一节先把整体链路讲清楚:Ollama 负责推理,MCP 服务端负责暴露工具,客户端负责把工具转成模型能理解的 schema,TaoToken 负责统一调用入口。四者各司其职,缺一个都跑不通。下面从环境准备开始,一步步把这条链搭起来。
2. 环境准备:Ollama 拉模型与 TaoToken 统一 Key 配置
2.1 安装 Ollama 并拉取模型
Ollama 的安装很直接,Linux 一行命令,macOS 和 Windows 去官网下安装包。装完后验证:
ollama --version # 输出类似 ollama version 0.5.x拉一个支持工具调用的模型。gemma3 对结构化输出支持不错,体积也适中:
ollama pull gemma3:latest拉完后确认模型在本地:
ollama list # NAME ID SIZE MODIFIED # gemma3:latest xxxxxxxx 5.2 GB ...启动服务,默认监听 11434:
ollama serve如果你在局域网另一台机器上跑 Ollama,客户端要填局域网 IP,比如http://192.168.1.5:11434。注意 Ollama 默认只监听 127.0.0.1,跨机访问要设OLLAMA_HOST=0.0.0.0。
2.2 用 TaoToken 统一 Key 与 Base URL
本地 Ollama 不需要 Key,但一旦你的项目里同时有远端模型(比如做对比测试、或者本地模型处理不了的复杂推理),就需要一个统一的入口。TaoToken 的 API 地址是 https://taotoken.net/api ,兼容 OpenAI 的/v1/chat/completions格式。
在项目根目录建一个.env文件,把入口配置集中管理:
# .env TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=sk-你的Key OLLAMA_BASE_URL=http://127.0.0.1:11434 OLLAMA_MODEL=gemma3:latestKey 在 TaoToken 控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。创建后复制保存,页面只显示一次。
这样配置的好处是:客户端代码里读环境变量,本地调试用 Ollama,需要远端能力时把 Base URL 和 Key 换成 TaoToken 的,Model ID 换成对应模型名,其他逻辑不动。后面第五节会讲怎么在代码里做这个切换。
2.3 安装 Python 依赖
用 uv 管理依赖,比 pip 快很多:
uv init mcp-ollama-demo cd mcp-ollama-demo uv add fastmcp ollama mcp pydantic python-dotenvfastmcp用来写 MCP 服务端,ollama是官方 Python 客户端,mcp提供客户端会话能力,pydantic做动态模型,python-dotenv读环境变量。
目录结构最终是这样:
mcp-ollama-demo/ ├── .env ├── server.py ├── client.py └── pyproject.toml3. 可复制配置:MCP 服务端与客户端完整代码
3.1 server.py:暴露一个工具
MCP 服务端的核心是用@mcp.tool()装饰器把普通函数注册成工具。下面这个magicoutput接收两个字符串,返回拼接结果:
# server.py from fastmcp import FastMCP mcp = FastMCP("TestServer") @mcp.tool() def magicoutput(obj1: str, obj2: str) -> str: """使用此函数获取魔法输出""" print(f"输入参数:obj1:{obj1},obj2:{obj2}") return f"输入参数:obj1:{obj1},obj2:{obj2},魔法输出:Hello MCP,MCP Hello" if __name__ == "__main__": mcp.run()先用 inspector 验证服务端本身没问题:
uv run fastmcp dev server.py浏览器打开http://127.0.0.1:6274/#tools,能看到magicoutput工具,手动填参数调用,返回正常就说明服务端 OK。
3.2 client.py:动态生成 Pydantic 模型并接入 Ollama
客户端要做四件事:启动 MCP 服务端子进程、拉取工具列表、把工具 schema 转成 Pydantic 模型、把模型作为format参数传给 Ollama。
# client.py import asyncio import threading import queue import os from pathlib import Path from typing import Any, Optional, Union from dotenv import load_dotenv from pydantic import BaseModel, Field, create_model from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from ollama import Client load_dotenv() client = Client( host=os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434"), ) class OllamaMCP: def __init__(self, server_params: StdioServerParameters): self.server_params = server_params self.request_queue = queue.Queue() self.response_queue = queue.Queue() self.initialized = threading.Event() self.tools: list[Any] = [] self.response_model = None self.thread = threading.Thread(target=self._run_background, daemon=True) self.thread.start() def _run_background(self): asyncio.run(self._async_run()) async def _async_run(self): try: async with stdio_client(self.server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() self.session = session tools_result = await session.list_tools() self.tools = tools_result.tools self.initialized.set() while True: try: tool_name, arguments = self.request_queue.get(block=False) except queue.Empty: await asyncio.sleep(0.01) continue if tool_name is None: break try: result = await session.call_tool(tool_name, arguments) self.response_queue.put(result) except Exception as e: self.response_queue.put(f"错误: {str(e)}") except Exception as e: print("MCP会话初始化错误:", str(e)) self.initialized.set() self.response_queue.put(f"MCP初始化错误: {str(e)}") def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: if not self.initialized.wait(timeout=30): raise TimeoutError("MCP会话未能及时初始化。") self.request_queue.put((tool_name, arguments)) return self.response_queue.get() def shutdown(self): self.request_queue.put((None, None)) self.thread.join() print("持久MCP会话已关闭。") @staticmethod def convert_json_type_to_python_type(json_type: str): if json_type == "integer": return (int, ...) if json_type == "number": return (float, ...) if json_type == "string": return (str, ...) if json_type == "boolean": return (bool, ...) return (str, ...) def create_response_model(self): dynamic_classes = {} for tool in self.tools: class_name = tool.name.capitalize() properties: dict[str, Any] = {} for prop_name, prop_info in tool.inputSchema.get("properties", {}).items(): json_type = prop_info.get("type", "string") properties[prop_name] = self.convert_json_type_to_python_type(json_type) model = create_model( class_name, __base__=BaseModel, __doc__=tool.description, **properties, ) dynamic_classes[class_name] = model if dynamic_classes: all_tools_type = Union[tuple(dynamic_classes.values())] Response = create_model( "Response", __base__=BaseModel, __doc__="LLM响应类", response=(str, Field(..., description="向用户确认函数将被调用。")), tool=(all_tools_type, Field(..., description="用于运行和获取魔法输出的工具")), ) else: Response = create_model( "Response", __base__=BaseModel, __doc__="LLM响应类", response=(str, ...), tool=(Optional[Any], Field(None, description="如果不返回None则使用的工具")), ) self.response_model = Response async def ollama_chat(self, messages: list[dict[str, str]]) -> Any: conversation = [{ "role": "assistant", "content": f"你必须使用工具。你可以使用以下函数:{[tool.name for tool in self.tools]}" }] conversation.extend(messages) if self.response_model is None: raise ValueError("响应模型尚未创建。请先调用create_response_model()。") format_schema = self.response_model.model_json_schema() response = client.chat( model=os.getenv("OLLAMA_MODEL", "gemma3:latest"), messages=conversation, format=format_schema, ) print("Ollama响应", response.message.content) response_obj = self.response_model.model_validate_json(response.message.content) maybe_tool = response_obj.tool if maybe_tool: function_name = maybe_tool.__class__.__name__.lower() func_args = maybe_tool.model_dump() output = await asyncio.to_thread(self.call_tool, function_name, func_args) return output else: print("响应中未检测到工具。返回纯文本响应。") return response_obj.response async def main(): server_parameters = StdioServerParameters( command="uv", args=["run", "python", "server.py"], cwd=str(Path.cwd()), ) persistent_session = OllamaMCP(server_parameters) if persistent_session.initialized.wait(timeout=30): print("准备调用工具。") else: print("错误: 初始化超时。") persistent_session.create_response_model() messages = [ { "role": "system", "content": ( "你是一个听话的助手,上下文中有一系列工具。" "你的任务是使用这个函数获取魔法输出。" "不要自己生成魔法输出。" "简洁地回复一条简短消息,提及调用函数," "但不提供函数输出本身。" "将该简短消息放在'response'属性中。" "例如:'好的,我会运行magicoutput函数并返回输出。'" "同时用正确的参数填充'tool'属性。" ), }, { "role": "user", "content": "使用函数获取这些参数的魔法输出(obj1 = Ollama和obj2 = Gemma3)", }, ] result = await persistent_session.ollama_chat(messages) print("最终结果:", result) persistent_session.shutdown() if __name__ == "__main__": asyncio.run(main())3.3 关键配置项对照
| 配置项 | 本地 Ollama | TaoToken 远端 |
|---|---|---|
| Base URL | http://127.0.0.1:11434 | https://taotoken.net/api |
| API Key | 不需要 | sk-开头,控制台创建 |
| Model ID | gemma3:latest | 控制台模型列表里的名称 |
| 协议 | Ollama 原生 | OpenAI 兼容 |
如果你要把客户端切到 TaoToken 通道,把client.chat换成 OpenAI SDK 调用,Base URL 填https://taotoken.net/api,Key 填环境变量里的值,Model ID 换成对应模型。MCP 工具链部分完全不用改,因为工具 schema 是协议层的东西,跟模型走哪个通道无关。
4. 端到端验证:一次完整的工具调用
4.1 运行客户端
uv run python client.py预期输出分三段。第一段是服务端启动日志:
INFO Starting server "TestServer"... 准备调用工具。第二段是 Ollama 的结构化响应:
Ollama响应 {"response": "好的,我将使用magicoutput函数获取obj1和obj2的魔法输出。", "tool": {"obj1": "Ollama", "obj2": "Gemma3"}}第三段是 MCP 服务端返回的工具执行结果:
最终结果: meta=None content=[TextContent(type='text', text='输入参数:obj1:Ollama,obj2:Gemma3,魔法输出:Hello MCP,MCP Hello', annotations=None)] isError=False 持久MCP会话已关闭。看到isError=False和拼接后的文本,说明整条链路通了:Ollama 理解意图 → 生成符合 schema 的 JSON → 客户端解析出工具名和参数 → MCP 服务端执行 → 结果回传。
4.2 验证请求的三种方式
第一种,直接看客户端 stdout,上面已经展示。第二种,用 curl 单独测 Ollama 的结构化输出:
curl http://127.0.0.1:11434/api/chat -d '{ "model": "gemma3:latest", "messages": [{"role": "user", "content": "返回JSON: {\"response\": \"ok\", \"tool\": null}"}], "format": "json", "stream": false }'第三种,用 TaoToken 的模型对话页面做对照测试,地址是 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。把同样的 system prompt 和 user message 贴进去,看远端模型返回的 JSON 结构是否和本地一致。如果远端返回正常而本地异常,问题在 Ollama 的 format 支持上;如果两边都异常,问题在 prompt 或 schema 定义上。
4.3 成功结果的判定标准
一次成功的端到端调用满足三个条件:Ollama 返回的 JSON 能被model_validate_json解析通过;解析出的tool字段非空且类名小写后等于服务端注册的工具名;call_tool返回的content里有服务端函数的实际输出。三个条件缺一个,都说明链路某处断了,按下一节的排查表定位。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
5.1 401 Unauthorized
如果你把 Base URL 切到了 TaoToken 但没带 Key,或者 Key 写错,会看到:
Error code: 401 - {'error': {'message': 'Invalid API key', 'type': 'invalid_request_error'}}排查顺序:先确认.env里TAOTOKEN_API_KEY没有多余空格和引号;再确认代码里读的是这个变量而不是硬编码的空字符串;最后去控制台 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 确认 Key 没过期、没被删除。本地 Ollama 不走 Key,出现 401 一定是请求发到了远端通道。
5.2 local proxy failed
Error: local proxy failed: dial tcp 127.0.0.1:11434: connect: connection refused这是 Ollama 服务没起来,或者端口不对。先ollama serve确认服务在跑,再curl http://127.0.0.1:11434/api/tags看能不能返回模型列表。如果 Ollama 在另一台机器,检查OLLAMA_HOST是否设成0.0.0.0,以及防火墙是否放行 11434。
5.3 reading choices 报错
KeyError: 'choices'这个报错通常出现在你把 Ollama 原生客户端和 OpenAI 兼容客户端混用的时候。Ollama 的/api/chat返回的是message.content,OpenAI 的/v1/chat/completions返回的是choices[0].message.content。如果你用 OpenAI SDK 去请求 Ollama 原生端口,就会读不到choices。解决方法是统一协议:要么全用 Ollama 客户端,要么全用 OpenAI 客户端并把 Base URL 指向兼容端点。
5.4 OAuth 相关报错
OAuth error: invalid_clientMCP 的某些远端服务端需要 OAuth 认证,但本地 stdio 模式不需要。如果你在StdioServerParameters里配了需要 OAuth 的远端地址,就会报这个。本地集成场景下,command填uv、args填["run", "python", "server.py"],走 stdio 传输,不涉及 OAuth。只有接远端 MCP 服务端时才需要处理 token 刷新。
5.5 工具名大小写不匹配
AttributeError: 'Response' object has no attribute 'tool'或者工具调用时提示函数不存在。原因是create_response_model里用tool.name.capitalize()生成类名,而ollama_chat里用maybe_tool.__class__.__name__.lower()还原函数名。如果工具名本身含下划线或数字,capitalize()会改变原始大小写,导致还原失败。解决办法是维护一个类名到工具名的映射字典,不要依赖字符串变换。
5.6 模型不返回 tool 字段
Ollama 返回的 JSON 里tool是null,或者干脆不返回这个字段。两个原因:一是 system prompt 没强调必须使用工具,模型选择了纯文本回复;二是format参数传的 schema 里tool字段不是必填。检查create_response_model里tool的 Field 定义,确保在dynamic_classes非空时是Field(...)而不是Field(None)。另外把 system prompt 里的「你必须使用工具」加粗强调,gemma3 对指令的遵循度会明显提升。
6. 把调用入口收敛到 TaoToken:长期编码与 Agent 场景的配置
本地 Ollama 适合验证和隐私场景,但长期编码、多模型对比、Agent 持续运行这些场景,你需要一个稳定的远端通道做补充。TaoToken 的 Coding Plan 就是为这类场景设计的,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
配置方式和你现在项目里的.env一致,把 Base URL 换成https://taotoken.net/api,Key 换成 Coding Plan 对应的 Key,Model ID 换成你套餐里包含的模型。客户端代码里加一个开关:
import os USE_REMOTE = os.getenv("USE_REMOTE", "false").lower() == "true" if USE_REMOTE: base_url = os.getenv("TAOTOKEN_BASE_URL") api_key = os.getenv("TAOTOKEN_API_KEY") model = os.getenv("TAOTOKEN_MODEL") else: base_url = os.getenv("OLLAMA_BASE_URL") api_key = "ollama" model = os.getenv("OLLAMA_MODEL")这样本地调试和远端运行共用一套 MCP 工具链,只切换入口配置。MCP 服务端的server.py完全不用动,因为工具定义是协议层的,跟模型走哪个通道无关。
如果你用 Claude Code 做主力编码工具,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有 Base URL、Key、Model ID 三件套的完整填写示例。Claude Code 的配置文件里把ANTHROPIC_BASE_URL指向 TaoToken 的兼容端点,ANTHROPIC_API_KEY填控制台创建的 Key,Model ID 填套餐里的模型名,就能把本地 MCP 工具链和远端编码能力串起来。
最后提醒一个实操细节:MCP 服务端的cwd参数一定要填绝对路径。用Path.cwd()在 IDE 里跑没问题,但用 systemd 或 cron 拉起时工作目录会变,导致uv run python server.py找不到文件。改成Path(__file__).parent.resolve()更稳。这个坑我在部署到内网服务器时踩过,排查了半小时才定位到工作目录。