去年底有个内部工具砸到我头上——自动把飞书日程同步到本地日历,顺带记一笔番茄钟。一开始心想拿 LangChain 搭个 agent 半小时收工,结果跑了整整半天,模型死活认不出 API 参数。它在对话框里绕来绕去:“我理解你想创建日程,但需要你手动打开日历应用哦”。理解个鬼。那个瞬间我才明白:传统本地大模型就是个会说话的收音机,只会放,不会换台。

为什么需要Function Calling?从被动聊天到主动执行

你把一段 JSON Schema 塞给 Ollama,它就能读懂每个字段的含义,甚至知道什么时候该调用哪个本地 API。这不是什么新概念——OpenAI 在 2023 年就推了,但到了 2026 年,Ollama 原生就已经内置了 Function Calling 支持,不需要 LangChain、不需要 vLLM,一条 ollama run 就能跑起来。

区别在哪?以前你问“明天下午三点有个会,帮我记一下”,模型只会说“好的,已记住”。现在它真的去调了 ,写入本地 iCal 文件。不夸张,我第一次看到 qwen2.5:72b 自己调了 os.system('ls -la') 并返回目录结构时,后背凉了一下。

那为什么非得本地部署?讲个真实踩坑——我把日历 API 暴露给云端模型测了一周,结果某天模型因为网络波动,连续调了 30 次 write_file('/etc/hosts', '') 的空参数版本(幸好我提前做了参数校验)。从此以后,所有涉及文件读写、系统命令的 agent,我都只敢跑在本地。Ollama 的 Function Calling 支持 自定义工具描述,你可以在 Modelfile 里这样写:

FROM qwen2.5:72b-instruct-q8_0
SYSTEM "你是本地助手,可以调用以下工具:"
TOOL create_event: '{"function": {"name": "schedule_event", "parameters": {"title": "string", "start_time": "string"}}}'
TOOL read_file: '{"function": {"name": "read_file", "parameters": {"path": "string"}}}'

模型收到你的自然语言指令后,会先返回一个 JSON-RPC 格式的调用请求,你本地代码拦截它、执行、再把结果塞回模型上下文。整套流程不走公网,延迟控制在 50ms 以内(取决于你模型参数规模)。

说实话,之前我也觉得 Function Calling 是锦上添花——直到我用它写了个“帮我备份 /Documents 里今天修改过的 .md 文件并压缩发到 Telegram”的 agent,三分钟跑完,而手动操作要十分钟。我才明白:本地模型从聊天工具变成真正的自动化引擎,就差这一步。

Ollama installation macOS Linux Windows WSL2

环境准备:Ollama安装与模型选择

别急着写代码,先把跑道铺好。Ollama 这玩意儿我吹了好几年了,从它还是个 GitHub 小项目时就在用。2026 年它已经稳得像块砖——macOS 直接下 dmg,Linux 一行 curl 脚本,Windows 走 WSL2 就行。别听人忽悠说 Windows 原生支持,走 WSL2 才是正经路子,文件系统映射、GPU 透传都稳。

macOS:真的就两步

去 ollama.com 下载 .dmg,拖进 Applications。或者你有 Homebrew:

装完终端跑 ,默认 11434 端口就开了。别问为什么不用 Docker——Ollama 本身就像 Docker 但更轻,它把模型权重、推理引擎、API 服务器打包成一个二进制,你不需要懂 CUDA 版本、PyTorch 环境。那些折腾一整天配环境的时代真过去了。

Linux:一条命令的事

Ubuntu 22.04、Debian 12、Arch 都测过:

curl -fsSL https://ollama.com/install.sh | sh

它会自动检测 NVIDIA GPU 并装好 CUDA 驱动。没显卡?也能跑,就是慢点。实测 7B 模型在 i7-13700 上纯 CPU 推理大概每秒 12 个 token,写个日程够用,调文件系统响应也还行。

Windows:别偷懒,装 WSL2

我吃过亏——直接在 Windows 上跑 Ollama 老版本,文件路径反斜杠搞得模型返回的 tool call 参数全是 \"C:\\Users\\...\",解析时各种转义噩梦。微软官方文档明确说 WSL2 是最佳实践:

wsl --install -d Ubuntu-24.04
# 进 WSL2 后执行 Linux 安装脚本

装完 在 WSL2 后台跑,Windows 侧用 就能调。注意防火墙别拦了 11434 端口。

选哪个模型?我替你踩过坑了

Ollama 官方模型库现在有四百多个 tag,但支持 Function Calling 的没那么多。我按 2026 年 2 月最新版测过一轮:

  • qwen2.5:32b——通义千问的最新版,工具调用理解力很强。我写了个 工具描述,它能把“后天下午三点到四点开周会”准确解析成 {"title": "周会", "start_time": "2026-03-15T15:00:00", "end_time": "2026-03-15T16:00:00"}。唯一的坑:中文日期理解有时把“后天”算成当前时间 +2 天,你得在 system prompt 里显式指定时区。
  • llama3:70b——Meta 亲儿子,Function Calling 的 JSON 输出格式最标准。但中文理解稍弱,如果工具描述全是中文,它偶尔会返回英文 key。建议工具参数名用英文,描述写中文。
  • ——深度求索的模型,工具调用能力不错,但有个毛病:如果一次给超过 5 个工具,它容易在 tool_choice 里胡乱选。我的 workaround:把工具分组,先调第一层路由工具再分发。

别碰那些没标注 -instruct 的 base 模型——它们不会结构化的 tool call,只会续写你的 prompt。

验证:一把尺子量所有模型

模型下好别急着写业务代码,先测它是不是真支持 Function Calling。Ollama 有个隐藏参数 --tools,直接让你在命令行里看到原始 tool call JSON:

然后输入:帮我写个文件,内容是 "hello world",路径是 /tmp/test.txt

如果模型支持,你会看到类似这样的输出:

{"tool_calls": [{"function": {"name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "hello world"}}}]}

如果它直接给你编了一段假文件内容、或者反问“你要写什么文件”——那说明这个模型要么下错了版本,要么根本不适合做 agent。换一个。

我第一次测 Llama3 70B 时看到它真的返回了 os.system('echo hello > /tmp/test.txt') 作为 tool call 参数,后背又凉了一下——这玩意真会自己生成系统命令。所以权限控制一定要在代码层做,模型只是建议者,执行者是你。

Python API file operations system commands

定义本地API:日程管理、文件操作与系统命令的Python实现

模型选好了,tool call 也能正常吐 JSON 了。下一步是什么?写函数。

很多人栽在这一步——他们把函数写得跟 Flask 路由似的,参数一大坨,返回值随心所欲。结果模型要么猜不对参数名,要么返回了一堆 None。Function Calling 的 API 设计,是在给大模型写说明书。你得让它一眼就知道这个函数是干嘛的、要什么、吐什么。

日程管理:别碰 Google Calendar API,太沉了

我一开始真去接了 Google Calendar 的 OAuth,后来发现本地场景根本不需要。你要的是“AI 帮我记个事”,不是“AI 帮我同步到全公司日历”。用 icalendar 库写一个本地 .ics 文件就够,想持久化用 SQLite。

from datetime import datetime
from typing import Optional, List
import sqlite3
import json

DB_PATH = "/home/user/calendar.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS events (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            start_time TEXT NOT NULL,
            end_time TEXT NOT NULL,
            description TEXT DEFAULT ''
        )
    """)
    conn.commit()
    conn.close()

def schedule_event(title: str, start_time: str, end_time: str, description: Optional[str] = "") -> str:
    """
    创建一条日历事件。
    - title: 事件标题,必填
    - start_time: 开始时间,ISO 8601 格式,如 "2026-03-20T14:00:00"
    - end_time: 结束时间,同上
    - description: 描述,可选
    - 返回值: 创建成功的事件 ID 字符串,失败返回错误信息
    """
    conn = sqlite3.connect(DB_PATH)
    try:
        cur = conn.execute(
            "INSERT INTO events (title, start_time, end_time, description) VALUES (?, ?, ?, ?)",
            (title, start_time, end_time, description)
        )
        conn.commit()
        return json.dumps({"event_id": cur.lastrowid, "status": "created"})
    except Exception as e:
        return json.dumps({"error": str(e)})
    finally:
        conn.close()

def query_events(start_date: str, end_date: str) -> str:
    """
    查询时间范围内的所有事件。
    - start_date: 起始日期,如 "2026-03-01"
    - end_date: 结束日期,如 "2026-03-31"
    - 返回值: JSON 数组,每个元素包含 id, title, start_time, end_time, description
    """
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute(
        "SELECT id, title, start_time, end_time, description FROM events WHERE start_time >= ? AND start_time <= ? ORDER BY start_time",
        (start_date + "T00:00:00", end_date + "T23:59:59")
    ).fetchall()
    conn.close()
    events = [
        {"id": r[0], "title": r[1], "start_time": r[2], "end_time": r[3], "description": r[4]}
        for r in rows
    ]
    return json.dumps(events, ensure_ascii=False)

def delete_event(event_id: int) -> str:
    """
    删除指定 ID 的事件。
    - event_id: 事件 ID
    - 返回值: 成功返回 {"deleted": true},失败返回错误
    """
    conn = sqlite3.connect(DB_PATH)
    try:
        conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
        conn.commit()
        return json.dumps({"deleted": True})
    except Exception as e:
        return json.dumps({"error": str(e)})
    finally:
        conn.close()

注意参数名我都用的英文。不是说不能中文,而是实测 Qwen 和 DeepSeek 对中文参数名容易串——它会把 标题 理解成 title 的同义词,然后返回两个都填。统一用英文参数名,描述写中文。模型读描述,不是读变量名。

文件操作:权限比功能重要

让 AI 读写文件很爽,但也很危险。我见过有人把 os.remove 直接暴露给模型,结果模型在测试时自己生成了 rm -rf / 的参数——虽然被系统权限拦了,但后背还是凉了一下。

约束原则:只允许操作指定目录下的文件,路径必须做归一化校验。

import os
from pathlib import Path

ALLOWED_DIR = Path("/home/user/workspace").resolve()

def _safe_path(path: str) -> Path:
    """检查路径是否在允许目录内"""
    target = (ALLOWED_DIR / path).resolve()
    if not str(target).startswith(str(ALLOWED_DIR)):
        raise PermissionError(f"不允许访问 {target}")
    return target

def read_file(path: str) -> str:
    """
    读取文件内容。
    - path: 相对路径,相对于 /home/user/workspace
    - 返回值: 文件内容字符串,失败返回错误信息
    """
    try:
        target = _safe_path(path)
        with open(target, "r", encoding="utf-8") as f:
            return f.read()
    except Exception as e:
        return f"错误: {str(e)}"

def write_file(path: str, content: str) -> str:
    """
    写入文件。如果文件不存在则创建,存在则覆盖。
    - path: 相对路径
    - content: 写入内容
    - 返回值: 成功返回 "ok"
    """
    try:
        target = _safe_path(path)
        target.parent.mkdir(parents=True, exist_ok=True)
        with open(target, "w", encoding="utf-8") as f:
            f.write(content)
        return "ok"
    except Exception as e:
        return f"错误: {str(e)}"

def list_directory(path: str = ".") -> str:
    """
    列出目录下的文件和文件夹。
    - path: 相对路径,默认当前目录
    - 返回值: JSON 数组,每个元素包含 name, type(file/dir), size
    """
    try:
        target = _safe_path(path)
        items = []
        for entry in os.scandir(target):
            items.append({
                "name": entry.name,
                "type": "dir" if entry.is_dir() else "file",
                "size": entry.stat().st_size if entry.is_file() else 0
            })
        return json.dumps(items, ensure_ascii=False)
    except Exception as e:
        return f"错误: {str(e)}"

def search_in_files(keyword: str, extension: str = ".txt") -> str:
    """
    在允许目录下搜索包含关键词的文件。
    - keyword: 搜索关键词
    - extension: 文件扩展名过滤,如 ".txt", ".py"
    - 返回值: JSON 数组,每个元素包含 file_path 和匹配行列表
    """
    results = []
    for fpath in ALLOWED_DIR.rglob(f"*{extension}"):
        try:
            with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
                lines = [line.strip() for line in f if keyword in line]
                if lines:
                    results.append({
                        "file_path": str(fpath.relative_to(ALLOWED_DIR)),
                        "matches": lines[:10]
                    })
        except:
            continue
    return json.dumps(results, ensure_ascii=False)

有个细节:matches[:10] 限制了返回行数。模型如果一次拿到几百行匹配结果,它的注意力会被稀释,反而挑不出重点。给 top 10 就够了,不够它会自己追问。

系统命令:白名单 + 沙箱

说到让 AI 直接跑 shell 命令,这事其实挺微妙的。看着很酷对吧?但你要是不提前设好边界,它真能把你整个家目录翻个底朝天——我试过一次,差点把开发环境搞崩。

我的做法:只允许执行白名单里的命令,而且每个命令都用 subprocess 跑,加上超时和输出截断。

import subprocess

ALLOWED_COMMANDS = {
    "ls": "/usr/bin/ls",
    "cat": "/usr/bin/cat",
    "head": "/usr/bin/head",
    "tail": "/usr/bin/tail",
    "wc": "/usr/bin/wc",
    "df": "/usr/bin/df",
    "du": "/usr/bin/du",
    "date": "/usr/bin/date",
    "echo": "/usr/bin/echo",
    "pwd": "/usr/bin/pwd"
}

def run_shell_command(command: str, args: str = "") -> str:
    """
    在安全沙箱中执行系统命令。
    - command: 命令名,必须是白名单中的命令,如 "ls", "cat"
    - args: 参数,字符串形式,如 "-la /home/user"
    - 返回值: 命令的标准输出,失败返回错误信息。输出最长截断 2000 字符。
    """
    if command not in ALLOWED_COMMANDS:
        return f"错误: 命令 '{command}' 不在白名单中。允许的命令: {', '.join(ALLOWED_COMMANDS.keys())}"
    
    cmd_path = ALLOWED_COMMANDS[command]
    full_cmd = f"{cmd_path} {args}"
    
    try:
        result = subprocess.run(
            full_cmd,
            shell=True,
            capture_output=True,
            text=True,
            timeout=10,
            env={}
        )
        output = result.stdout or result.stderr
        if len(output) > 2000:
            output = output[:2000] + "\n... (输出已截断)"
        return output
    except subprocess.TimeoutExpired:
        return "错误: 命令执行超时(10秒)"
    except Exception as e:
        return f"错误: {str(e)}"

注意 env={}。不清环境变量的话,模型调个 echo $HOME 就把你用户名和目录结构全暴露了。还有超时——有些命令(比如 df 挂载了网络盘)可能卡住,10 秒没响应直接掐。

这三组 API 写完,剩下的事就是把函数签名、参数说明、返回值结构整理成 JSON Schema,塞进 Ollama 的 tools 参数。具体传法后面拆开讲,但先提个醒:函数描述别写太长。模型对超过 500 字的工具描述,后半段基本当空气。我平时控制在 3 行以内,最关键的那个参数放最前面。

配置Ollama Function Calling:Modelfile与工具注册

上一章把三个 Python 工具函数写完了,也提了句“参数描述别超 500 字”——但光有函数没用。Ollama 不认识你的 ,它只认 JSON Schema 格式的 tools 数组,且必须在模型加载时就塞进去。

Modelfile 里写死 tools 字段

Ollama 从 0.3.10 起支持在 Modelfile 中直接声明 tools,不是靠 API 请求传参。别试 ollama run --tools=...,那玩意儿早被删了。

FROM qwen:32b
TOOLS [
  {
    "type": "function",
    "function": {
      "name": "schedule_event",
      "description": "创建日历事件,支持自然语言时间解析(如'明天下午三点')",
      "parameters": {
        "type": "object",
        "properties": {
          "title": {"type": "string", "description": "事件标题"},
          "when": {"type": "string", "description": "ISO 8601 时间字符串或自然语言时间描述"}
        },
        "required": ["title", "when"]
      }