# 把本地 LLM 变成会用工具的研究 Agent：Ollama + OpenAI Agents SDK + Tavily MCP 实战

> 本文整理自 Towards Data Science 文章 [From Local LLM to Tool-Using Agent](https://towardsdatascience.com/from-local-llm-to-tool-using-agent/)。原文作者 Shuai Guo 演示了一个轻量方案：用 Ollama 在本地运行 Gemma 4，再接入 OpenAI Agents SDK 和 Tavily MCP，让本地模型能搜索网页、整理证据并带引用回答问题。

## 你会做出什么

我们要搭一个最小可用的“本地研究 Agent”：

```text
用户问题
  ↓
OpenAI Agents SDK 负责 agent loop
  ↓
本地 Ollama / Gemma 4 负责生成与决策
  ↓
Tavily MCP 提供网页搜索工具
  ↓
Agent 返回带来源的简短研究答案
```

这个方案的重点不是 Tavily 或 Gemma 4 本身，而是一个可复用模式：

- 本地模型负责推理；
- Agent runtime 负责循环、工具调用和结果回传；
- MCP server 负责把外部能力暴露成工具；
- 工具可替换，例如搜索、内部知识库、本地文件、数据库只读查询等。

## 适用边界

适合：

- 想验证“本地模型 + 工具调用”是否可跑通；
- 做低风险、只读、可回滚的研究或查询任务；
- 需要把 MCP 工具接进 Agent runtime；
- 想为后续本地知识库、内部搜索、代码阅读工具做原型。

不适合直接用于：

- 生产写操作；
- 自动部署、删库、发邮件、交易下单等有外部副作用的任务；
- 完全离线场景，因为本文示例仍依赖 Tavily 云端搜索 API；
- 复杂多轮研究的可靠性结论。原文只展示了一次事实检索型演示，不能证明复杂任务稳定。

## 前置条件

硬件建议：

- 原文作者使用约 8GB VRAM 的 NVIDIA RTX 2000 Ada Laptop GPU 跑 `gemma4:e4b`；
- 如果机器更弱，可先试 `gemma4:e2b`；
- 没有 GPU 也可以尝试 CPU，但速度可能明显下降。

软件准备：

- Python 3.11+；
- Ollama；
- 一个 Tavily API Key；
- 网络能访问 Tavily MCP 服务。

> 安全边界：API Key 只放环境变量，不写进代码仓库。

## 步骤 1：安装并启动 Ollama

Linux：

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

Windows 可用官方安装包，或在 PowerShell 中使用：

```powershell
winget install Ollama.Ollama
```

Windows 安装后需要从开始菜单启动 Ollama。启动后，本地默认 API 端点通常是：

```text
http://localhost:11434
```

可用下面命令做健康检查：

```bash
curl http://localhost:11434/api/version
```

期望看到类似 JSON：

```json
{"version":"0.x.x"}
```

## 步骤 2：拉取本地模型

原文使用 `gemma4:e4b`：

```bash
ollama pull gemma4:e4b
```

低配机器可尝试：

```bash
ollama pull gemma4:e2b
```

确认模型已存在：

```bash
ollama list
```

如果你想先测试普通生成能力：

```bash
curl http://localhost:11434/api/generate -d '{
  "model": "gemma4:e4b",
  "prompt": "Say hello in one sentence.",
  "stream": false
}'
```

## 步骤 3：创建 Python 项目

```bash
mkdir local-research-agent
cd local-research-agent
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install openai-agents openai
```

如果在 Windows PowerShell：

```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install openai-agents openai
```

## 步骤 4：配置 Tavily MCP

Tavily 的远程 MCP URL 形如：

```text
https://mcp.tavily.com/mcp/?tavilyApiKey=<your-api-key>
```

不要把 Key 写进代码。用环境变量：

```bash
export TAVILY_API_KEY="tvly-..."
```

Windows PowerShell：

```powershell
$env:TAVILY_API_KEY="tvly-..."
```

## 步骤 5：写一个最小研究 Agent

创建 `main.py`：

```python
import asyncio
import os
from datetime import datetime
from urllib.parse import quote

from agents import Agent, OpenAIChatCompletionsModel, Runner
from agents.mcp import MCPServerStreamableHttp
from openai import AsyncOpenAI

MODEL_NAME = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
MAX_TURNS = 4

RESEARCH_QUESTION = (
    "Which June 23, 2026 World Cup match had the biggest group-stage stakes, "
    "and why?"
)


def build_tavily_mcp_url() -> str:
    api_key = os.environ.get("TAVILY_API_KEY")
    if not api_key:
        raise RuntimeError("Set TAVILY_API_KEY before running this script.")
    return f"https://mcp.tavily.com/mcp/?tavilyApiKey={quote(api_key)}"


def build_research_instructions() -> str:
    current_date = datetime.now().strftime("%B %d, %Y")
    return f"""
[Role]
You are a concise research assistant.

[Task]
Answer the user's question by turning it into a small web research task.
Use the current date when interpreting time-sensitive questions: {current_date}.

[Research behavior]
Start with one targeted search query.
Use follow-up searches when the first results are insufficient, conflicting, or incomplete.
Prefer relevant and credible sources.
Track which source supports each important claim.
Before answering, check whether the gathered evidence is enough to support the conclusion.

[Expected output]
Give a direct answer first, then briefly explain the evidence behind it.
Include source links for key factual claims.

[Rules]
Do not rely on memory for facts that may have changed.
Do not invent missing details.
Keep the answer concise.
""".strip()


def compact(value: object, limit: int = 220) -> str:
    text = str(value).replace("\n", " ")
    if len(text) <= limit:
        return text
    return text[:limit] + "..."


async def run_agent() -> None:
    client = AsyncOpenAI(
        api_key="ollama",
        base_url=OLLAMA_BASE_URL,
    )
    model = OpenAIChatCompletionsModel(
        model=MODEL_NAME,
        openai_client=client,
    )

    async with MCPServerStreamableHttp(
        name="tavily",
        params={"url": build_tavily_mcp_url()},
    ) as tavily_server:
        tools = await tavily_server.list_tools()
        print("Available Tavily tools:")
        for tool in tools:
            description = (tool.description or "").replace("\n", " ")
            print(f"- {tool.name}: {description[:120]}")

        agent = Agent(
            name="Local Research Agent",
            instructions=build_research_instructions(),
            model=model,
            mcp_servers=[tavily_server],
            mcp_config={"include_server_in_tool_names": True},
        )

        result = await Runner.run(agent, RESEARCH_QUESTION, max_turns=MAX_TURNS)

        print("\nTrace:")
        for step, item in enumerate(result.new_items, start=1):
            raw_item = getattr(item, "raw_item", None)
            raw_type = getattr(raw_item, "type", "")
            raw_name = getattr(raw_item, "name", "")
            raw_output = getattr(raw_item, "output", "")
            print(
                f"{step:02d} | {type(item).__name__} | "
                f"{raw_type or raw_name} | {compact(raw_output or raw_item)}"
            )

        print("\nFinal answer:")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(run_agent())
```

运行：

```bash
python main.py
```

期望看到三类输出：

```text
Available Tavily tools:
- tavily_search: ...

Trace:
01 | ToolCallItem | function_call | ... mcp_tavily__tavily_search ...
02 | ToolCallOutputItem | ...
03 | MessageOutputItem | message | ...

Final answer:
...
```

如果 trace 中出现 `mcp_tavily__tavily_search`，说明模型确实通过 MCP 调用了 Tavily 搜索工具。

## 步骤 6：加一个本地预检脚本

在真正跑 Agent 前，先检查最常见的失败点：Ollama 是否启动、模型是否已拉取、Tavily Key 是否设置。

创建 `preflight.py`：

```python
import json
import os
import sys
import urllib.error
import urllib.request

OLLAMA_BASE_URL = os.getenv("OLLAMA_HOST", "http://localhost:11434")
MODEL_NAME = os.getenv("OLLAMA_MODEL", "gemma4:e4b")


def fetch_json(url: str) -> dict:
    request = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.loads(response.read().decode("utf-8"))


def fail(message: str) -> None:
    print(f"[FAIL] {message}")
    raise SystemExit(1)


def main() -> None:
    if not os.environ.get("TAVILY_API_KEY"):
        fail("TAVILY_API_KEY is not set.")

    try:
        version = fetch_json(f"{OLLAMA_BASE_URL}/api/version")
    except urllib.error.URLError as exc:
        fail(f"Cannot reach Ollama at {OLLAMA_BASE_URL}: {exc}")

    models = fetch_json(f"{OLLAMA_BASE_URL}/api/tags")
    model_names = {model.get("name") for model in models.get("models", [])}
    if MODEL_NAME not in model_names:
        fail(f"Model {MODEL_NAME!r} is not installed. Run: ollama pull {MODEL_NAME}")

    print(f"[OK] Ollama version: {version.get('version')}")
    print(f"[OK] Model installed: {MODEL_NAME}")
    print("[OK] TAVILY_API_KEY is set")


if __name__ == "__main__":
    main()
```

运行：

```bash
python preflight.py
```

预期输出：

```text
[OK] Ollama version: 0.x.x
[OK] Model installed: gemma4:e4b
[OK] TAVILY_API_KEY is set
```

## 如何判断这个 Agent 真的在“用工具”

不要只看最终答案。至少检查这几件事：

1. trace 里出现了 Tavily 搜索工具调用；
2. final answer 中关键事实有来源链接；
3. 问一个需要实时信息的问题时，模型没有只凭记忆回答；
4. 搜索失败时，程序能报出明确错误，而不是编造结果；
5. `MAX_TURNS` 足够小，避免失控循环。

原文示例中，trace 形态类似：

```text
01 | ToolCallItem | function_call | ResponseFunctionToolCall(... name='mcp_tavily__tavily_search' ...)
02 | ToolCallOutputItem | ...
03 | MessageOutputItem | message | ... final answer ...
```

这比“回答看起来不错”更可靠，因为它证明了 Agent loop 至少发生过一次真实工具调用。

## 常见故障排查

### 1. `Connection refused` 或无法访问 Ollama

检查 Ollama 是否启动：

```bash
curl http://localhost:11434/api/version
```

Windows 上确认 Ollama 应用已经从开始菜单启动。

### 2. 找不到模型

```bash
ollama list
ollama pull gemma4:e4b
```

如果显存不足，尝试：

```bash
export OLLAMA_MODEL="gemma4:e2b"
ollama pull gemma4:e2b
python main.py
```

### 3. Tavily 工具不可用

检查环境变量：

```bash
echo "$TAVILY_API_KEY"
```

确认 Key 仍有效，并且格式正确。不要把 Key 打印到日志或提交到仓库。

### 4. Agent 不调用搜索工具

可以强化指令：

```text
For any question involving current or externally verifiable facts, call the search tool before answering.
```

但不要把它改成“无条件搜索”。对纯本地任务，无意义搜索会增加成本和延迟。

### 5. 回答有来源但结论跳得太快

把输出要求改成更保守：

```text
If evidence is weak or conflicting, say so explicitly and avoid a definitive answer.
List the source that supports each key claim.
```

## 进一步改造方向

### 替换模型

`gemma4:e4b` 不是唯一选择。你可以试：

- 更小模型：降低显存需求；
- 更强本地模型：提升复杂任务成功率；
- Qwen、Llama、Codestral 等同类模型；
- LM Studio、llama.cpp 等其他本地服务方式。

只要能提供 OpenAI-compatible endpoint，通常就能复用同一个接入思路。

### 替换工具

Tavily 只是一个搜索示例。更有价值的本地化方向是：

- 只读文件搜索 MCP；
- 内部 wiki / Obsidian 检索 MCP；
- 本地 SQLite 只读查询 MCP；
- 代码仓库阅读 MCP；
- 企业内网搜索 MCP。

### 增加安全边界

如果后续接入写操作工具，至少加这些控制：

- 工具分级：只读、低风险写入、高风险写入；
- 高风险工具必须人工确认；
- 所有工具调用记录脱敏参数、状态、错误和 correlation id；
- 默认禁止删除、部署、转账、发邮件等外部副作用；
- 先在 disposable sandbox 里跑，再接真实项目。

## 最小验收清单

- [ ] `curl http://localhost:11434/api/version` 成功；
- [ ] `ollama list` 能看到目标模型；
- [ ] `python preflight.py` 通过；
- [ ] `python main.py` 能列出 Tavily tools；
- [ ] trace 中出现搜索工具调用；
- [ ] final answer 包含来源链接；
- [ ] API Key 未写入代码、日志、截图或仓库；
- [ ] 只用于只读研究任务，没有接生产写操作。

## 这篇文章真正值得带走的东西

这篇文章的价值不在于“用 Tavily 搜索世界杯问题”，而在于它给出了一条清晰的本地 Agent 原型路线：

```text
本地模型 → Agent runtime → MCP 工具 → 可观测 trace → 带证据输出
```

先把这个链路跑通，再逐步替换模型、工具和任务类型，才是稳妥做法。不要一开始就把它包装成生产级自治系统；更合理的定位是：本地、低成本、只读、可验证的 Agent 工程实验起点。

## 参考来源

- 原文：<https://towardsdatascience.com/from-local-llm-to-tool-using-agent/>
- Ollama：<https://ollama.com/>
- Ollama API：<https://github.com/ollama/ollama/blob/main/docs/api.md>
- OpenAI Agents SDK：<https://openai.github.io/openai-agents-python/>
- OpenAI Agents SDK MCP 文档：<https://openai.github.io/openai-agents-python/mcp/>
- Tavily MCP 文档：<https://docs.tavily.com/documentation/mcp>

## 验证说明

本文代码片段基于原文与官方文档重构。本文生成时做了静态语法检查；未在当前机器上真实启动 Ollama、拉取 Gemma 4 或调用 Tavily API，因此不声称 `real_backend_smoke_ok`。真实运行前请先执行 `preflight.py`。
