本地搭建 Claude Code + Ollama + Gemma 4 实战教程

面向场景:想把日常代码分析、测试生成、小范围重构、调试辅助放到本地跑,降低 API 成本,并减少私有代码外发。

>

原文:<https://www.kdnuggets.com/local-agentic-programming-on-the-cheap-claude-code-ollama-gemma4>

>

来源:KDnuggets,Shittu Olumide。本文是基于原文的实战化整理,并补充了更保守的安全边界与本地验证流程。

1. 你最终会搭出什么

本文目标不是简单跑一个本地聊天模型,而是搭一个能被 Claude Code 调用的本地 Agentic 编程环境:

Claude Code CLI
    ↓ Anthropic-compatible request
Ollama local server: http://localhost:11434
    ↓ custom model variant
Gemma 4 26B MoE: gemma4-claude
    ↓
读文件 / 写补丁 / 跑测试 / 根据错误继续修复

完成后你应该能做到:

2. 适用边界

适合做:

不建议一开始就做:

3. 前置要求

3.1 硬件建议

原文针对 gemma4:26b,模型约 18GB。建议:

3.2 软件要求

当前官方文档确认:Ollama Modelfile 支持 FROMPARAMETERSYSTEMnum_ctxtemperaturerepeat_penaltynum_predict 都是有效参数。Ollama API 默认监听 http://localhost:11434/api/tags 可列出模型,/api/generate 支持 keep_alive

4. 安装 Ollama

Linux/macOS 可按 Ollama 官方方式安装:

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

验证服务是否可用:

ollama version
curl http://localhost:11434

期望看到类似:

Ollama is running

如果端口不通,先手动启动:

ollama serve

5. 拉取 Gemma 4 模型

拉取原文推荐的 26B MoE 模型:

ollama pull gemma4:26b
ollama list

确认列表里出现 gemma4:26b

注意:具体模型标签是否可用取决于你当前 Ollama registry。若 gemma4:26b 拉取失败,不要直接换成别的模型继续照抄参数;先确认 registry 中真实存在的 Gemma 4 标签,再调整 FROM

6. 安装 Claude Code

确认 Node.js 版本:

node --version

安装 Claude Code CLI:

npm install -g @anthropic-ai/claude-code
claude --version

如果你在公司内网环境,建议先用内部 npm 镜像或离线包方案;不要把代理、Token、私有 registry 密码写进项目仓库。

7. 创建面向 Claude Code 的 Ollama Modelfile

不要直接让 Claude Code 使用 gemma4:26b 原始标签。原始标签不会携带你需要的长上下文、低温度和 Agent 行为提示。

创建目录:

mkdir -p ~/.ollama/Modelfiles

写入文件 ~/.ollama/Modelfiles/gemma4-claude

FROM gemma4:26b

PARAMETER num_ctx 65536
PARAMETER temperature 0.2
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.15
PARAMETER num_predict 4096

SYSTEM """You are a senior software engineer operating as a coding agent.

When working with code:
- Read files before editing them. Never assume file contents.
- Make one focused change at a time and verify it before proceeding.
- When a tool call fails, examine the error carefully before retrying.
  Do not retry with identical parameters. Diagnose first.
- Prefer surgical edits over full file rewrites.
- Run tests after each meaningful change, not after a batch of changes.
- If you are uncertain about the codebase structure, read more files
  rather than guessing.

Be precise and methodical. Avoid explaining what you are about to do
when you could simply do it."""

构建模型变体:

ollama create gemma4-claude -f ~/.ollama/Modelfiles/gemma4-claude
ollama list

做一次普通响应 smoke test:

ollama run gemma4-claude "What is the time complexity of binary search and why?"

通过标准:能在可接受时间内返回清晰技术回答。

8. 配置 Claude Code 指向本地 Ollama

推荐先用项目级配置,不要一开始改全局配置。

在你的测试项目根目录执行:

mkdir -p .claude

创建 .claude/settings.json

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:11434",
    "ANTHROPIC_AUTH_TOKEN": "ollama",
    "ANTHROPIC_MODEL": "gemma4-claude",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemma4-claude",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemma4-claude",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemma4-claude",
    "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"
  }
}

建议同时把本地个人配置排除出 Git:

printf '\n.claude/settings.local.json\n' >> .gitignore

如果你打算提交 .claude/settings.json 给团队,先确认:

9. 启动前验证脚本

只验证普通聊天不够。Claude Code 的关键能力是工具调用,所以需要验证 tool_use 结构。

下面脚本只依赖 Python 标准库,保存为 verify_local_agentic_setup.py

#!/usr/bin/env python3
"""Verify Claude Code + Ollama + local model readiness."""

from __future__ import annotations

import json
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any


OLLAMA_BASE_URL = "http://localhost:11434"
MODEL_NAME = "gemma4-claude"
TIMEOUT_SECONDS = 120


@dataclass(frozen=True)
class CheckResult:
    name: str
    ok: bool
    detail: str


def request_json(method: str, url: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
    data = None
    headers = {"Content-Type": "application/json"}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["x-api-key"] = "ollama"
        headers["anthropic-version"] = "2023-06-01"

    request = urllib.request.Request(url, data=data, headers=headers, method=method)
    with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
        return json.loads(response.read().decode("utf-8"))


def request_text(url: str) -> str:
    with urllib.request.urlopen(url, timeout=10) as response:
        return response.read().decode("utf-8", errors="replace")


def check_ollama_health() -> CheckResult:
    try:
        text = request_text(OLLAMA_BASE_URL)
    except urllib.error.URLError as exc:
        return CheckResult("Ollama health", False, f"cannot connect: {exc}")

    if "Ollama is running" in text:
        return CheckResult("Ollama health", True, "localhost:11434 is reachable")
    return CheckResult("Ollama health", False, f"unexpected response: {text[:100]}")


def check_model_available() -> CheckResult:
    try:
        data = request_json("GET", f"{OLLAMA_BASE_URL}/api/tags")
    except Exception as exc:
        return CheckResult("Model availability", False, f"cannot list models: {exc}")

    models = [model.get("name", "") for model in data.get("models", [])]
    normalized = {name.split(":", 1)[0] for name in models}
    if MODEL_NAME in models or MODEL_NAME in normalized:
        return CheckResult("Model availability", True, f"found {MODEL_NAME}")
    return CheckResult("Model availability", False, f"available models: {models}")


def check_messages_api() -> CheckResult:
    payload = {
        "model": MODEL_NAME,
        "max_tokens": 100,
        "messages": [
            {"role": "user", "content": "Reply with exactly: VERIFICATION_OK"}
        ],
    }

    try:
        data = request_json("POST", f"{OLLAMA_BASE_URL}/v1/messages", payload)
    except Exception as exc:
        return CheckResult("Messages API", False, f"request failed: {exc}")

    content_blocks = data.get("content", [])
    text_blocks = [block for block in content_blocks if block.get("type") == "text"]
    if not text_blocks:
        return CheckResult("Messages API", False, f"no text block: {data}")
    return CheckResult("Messages API", True, text_blocks[0].get("text", "")[:80])


def check_tool_calling() -> CheckResult:
    payload = {
        "model": MODEL_NAME,
        "max_tokens": 256,
        "tools": [
            {
                "name": "read_file",
                "description": "Read the contents of a file at the given path.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The file path to read.",
                        }
                    },
                    "required": ["path"],
                },
            }
        ],
        "tool_choice": {"type": "any"},
        "messages": [
            {"role": "user", "content": "Read the file at /tmp/test.py."}
        ],
    }

    try:
        data = request_json("POST", f"{OLLAMA_BASE_URL}/v1/messages", payload)
    except Exception as exc:
        return CheckResult("Tool calling", False, f"request failed: {exc}")

    content_blocks = data.get("content", [])
    tool_blocks = [block for block in content_blocks if block.get("type") == "tool_use"]
    if not tool_blocks:
        return CheckResult("Tool calling", False, f"no tool_use block: {data}")

    tool_call = tool_blocks[0]
    if tool_call.get("name") != "read_file":
        return CheckResult("Tool calling", False, f"wrong tool: {tool_call}")

    tool_input = tool_call.get("input", {})
    if "path" not in tool_input:
        return CheckResult("Tool calling", False, f"missing path: {tool_call}")

    return CheckResult("Tool calling", True, f"tool_use input={tool_input}")


def main() -> int:
    checks = [
        check_ollama_health(),
        check_model_available(),
        check_messages_api(),
        check_tool_calling(),
    ]

    for result in checks:
        status = "PASS" if result.ok else "FAIL"
        print(f"[{status}] {result.name}: {result.detail}")

    if all(result.ok for result in checks):
        print("All checks passed. Local Claude Code agent setup is ready.")
        return 0

    print("Resolve failed checks before using this setup on a real codebase.")
    return 1


if __name__ == "__main__":
    sys.exit(main())

运行:

python3 verify_local_agentic_setup.py

期望输出形态:

[PASS] Ollama health: localhost:11434 is reachable
[PASS] Model availability: found gemma4-claude
[PASS] Messages API: VERIFICATION_OK
[PASS] Tool calling: tool_use input={'path': '/tmp/test.py'}
All checks passed. Local Claude Code agent setup is ready.

如果第 4 项失败,不要进入真实代码库。Claude Code 依赖工具调用读文件、写补丁、执行命令;模型如果只能输出文字解释,agentic 编程会失败。

10. 用一个 demo 项目做闭环验证

不要直接拿生产仓库试。先创建一个最小 Python 项目:

mkdir -p ~/tmp/local-agent-demo/src ~/tmp/local-agent-demo/tests
cd ~/tmp/local-agent-demo
cat > src/user_service.py <<'PY'
from __future__ import annotations


class ValidationError(ValueError):
    pass


class UserService:
    def normalize_email(self, email: str) -> str:
        if "@" not in email:
            raise ValidationError("invalid email")
        return email.strip().lower()

    def display_name(self, first_name: str, last_name: str) -> str:
        return f"{first_name.strip()} {last_name.strip()}"
PY
cat > pyproject.toml <<'TOML'
[project]
name = "local-agent-demo"
version = "0.1.0"
requires-python = ">=3.10"

[tool.pytest.ini_options]
pythonpath = ["."]
TOML

如果本机没有 pytest,建议在隔离虚拟环境里安装:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install pytest

启动 Claude Code:

claude

给它一个明确、低风险、可验证的任务:

请分析 src/user_service.py,为 UserService 写 pytest 测试。
要求:
1. 先读取文件,不要猜测内容。
2. 只新增或修改 tests/test_user_service.py。
3. 覆盖 normalize_email 和 display_name 的正常与异常输入。
4. 写完后运行 python -m pytest -q。
5. 如果失败,基于错误信息修复测试或代码。
6. 不要修改 pyproject.toml,不要安装新依赖。
成功标准:pytest 全部通过。

你希望看到的行为:

read_file("src/user_service.py")
write_file("tests/test_user_service.py", ...)
bash("python -m pytest -q")

如果它只是解释“我会写测试”,但没有工具调用,说明本地模型没有进入可用的 agentic 状态。

11. 常见故障与处理

11.1 Claude Code 仍然提示 Anthropic API Key

检查:

pwd
ls -la .claude/settings.json

处理:

11.2 反复出现 Invalid tool parameters

症状:Claude Code 报工具参数无效,模型道歉后用相同参数重试。

处理顺序:

  1. 确认模型名是 gemma4-claude,不是原始 gemma4:26b
  2. 确认 Modelfile 里 temperature 0.2 生效;
  3. 重新构建:
ollama create gemma4-claude -f ~/.ollama/Modelfiles/gemma4-claude
  1. 仍失败时,把温度降到 0.1 后重建。

11.3 多轮后速度突然很慢

可能是上下文太大导致 KV cache 换到磁盘。

先降上下文:

PARAMETER num_ctx 32768

然后重建:

ollama create gemma4-claude -f ~/.ollama/Modelfiles/gemma4-claude

可选优化:设置 KV cache 量化并重启 Ollama:

export OLLAMA_KV_CACHE_TYPE=q8_0

11.4 每次对话开头都冷启动

保持模型常驻:

export OLLAMA_KEEP_ALIVE=-1
curl http://localhost:11434/api/generate -d '{"model": "gemma4-claude", "keep_alive": -1}'

Ollama 官方 FAQ 说明:keep_alive 可以是时长、秒数、负数或 0;负数表示保持加载,0 表示立即卸载。

11.5 Beta header 被拒绝

确认配置里有:

{
  "env": {
    "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"
  }
}

如果你通过 shell 设置,确认同一个 shell 中能看到:

echo "$CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"

期望输出:

1

12. 建议的落地策略

第一阶段:只读验证。

第二阶段:demo 项目闭环。

第三阶段:低风险真实项目。

第四阶段:形成团队规范。

13. 最小验收清单

正式用于真实代码前,至少通过这些检查:

14. 生产边界提醒

这套方案可以降低成本和提高隐私控制,但不要把它误解成“本地模型等于生产级自动工程师”。

更稳妥的定位是:

本地模型:高频、低风险、可回滚、可测试的小任务
云端强模型:复杂架构、跨仓库推理、关键设计评审
人工 review:所有进入生产分支的最终判断

如果你把这套流程用于团队内部,建议把“只读默认、写入需明确授权、破坏性操作禁止、测试必须运行、diff 必须人工 review”写进项目级 Agent 使用规范。