本文记录了将 Claude Code VSCode 插件对接本地 Ollama 大模型的完整过程,包含架构设计、关键配置细节,以及逐一排查的 7 大高频坑点。适合有一定 Python/Linux 基础的开发者参考。


前言

Claude Code 是目前公认体验最好的 AI 编程助手之一,但官方 API 按量收费,长期使用成本不低。本文的目标很简单:用本地 Ollama 运行的开源大模型,替换掉 Claude Code 背后的 Anthropic API,实现完全本地、零费用的 AI 编程助手。

最终跑通的架构如下:

Claude Code (VSCode 插件)
        ↓  Anthropic /v1/messages 协议
anthropic_proxy.py(FastAPI,端口 4001)
        ↓  OpenAI /v1/chat/completions 协议
LiteLLM(端口 4000)
        ↓
Ollama(本地模型服务)
        ↓
qwen3.5:9b / qwen2.5-coder 等本地模型

环境准备

组件 说明
操作系统 Windows 11,PowerShell
显存 16GB(RTX 系列)
Ollama 本地大模型运行时
LiteLLM 统一 LLM API 网关
Python 3.10+,用于运行自定义中间件
Claude Code VSCode 插件 v2.1.107+

第一步:安装并启动 Ollama

# 官网下载安装后,拉取模型
ollama pull qwen3.5:9b
# 或者
ollama pull qwen2.5-coder:14b

# 验证服务是否正常
curl http://localhost:11434/api/tags

⚠️ 坑 #1:PowerShell 下 curl 不是真正的 curl

Windows PowerShell 里的 curl 其实是 Invoke-WebRequest 的别名,不支持 -d 参数,执行以下命令会报错:

# ❌ 报错:找不到接受实际参数的位置形式参数
curl -X POST http://localhost:11434/api/chat -d '{"model":"qwen3.5:9b"...}'

✅ 解决方案:改用 Invoke-RestMethod

Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -Method Get

或者安装真正的 curl(https://curl.se/windows/),然后使用 curl.exe(注意要加 .exe)。


第二步:配置 LiteLLM

LiteLLM 充当 API 格式转换网关,把 OpenAI 格式的请求转发给 Ollama。

安装(建议在虚拟环境中):

pip install litellm[proxy]

创建 litellm_config.yaml

model_list:
  - model_name: "claude-3-opus-20240229"   # 对外暴露的模型名(给 Claude Code 看的)
    litellm_params:
      model: "ollama_chat/qwen3.5:9b"       # 实际调用的本地模型
      api_base: "http://localhost:11434"
      api_key: "none"
    model_info:
      supports_function_calling: true

litellm_settings:
  drop_params: true

启动 LiteLLM:

litellm --config litellm_config.yaml --port 4000

⚠️ 坑 #2:drop_params 必须设为 true

Claude Code 发出的请求会携带一些 Ollama 不认识的参数,比如 context_managementbetas 等。如果不丢弃这些参数,LiteLLM 会直接报错:

litellm.UnsupportedParamsError: ollama does not support parameters: context_management

✅ 解决方案:在 litellm_settings 下加 drop_params: true,LiteLLM 会自动过滤掉不支持的参数。


⚠️ 坑 #3:模型前缀必须用 ollama_chat/,不能用 ollama/

LiteLLM 支持两种 Ollama 前缀:

前缀 特点
ollama/ 使用旧版 Generate API,Streaming 有异常,工具调用不稳定
ollama_chat/ 使用 Chat API,支持工具调用,推荐使用

✅ 解决方案:统一改为 ollama_chat/模型名(编者注:这步配置大部分网上查询的资料以及询问元宝、千问ai都是说ollama,其实是不对的,用ollama_chat才正常)


第三步:配置 Claude Code

修改 C:\Users\你的用户名\.claude\settings.json

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:4001",
    "ANTHROPIC_AUTH_TOKEN": "fake-key",
    "ANTHROPIC_MODEL": "claude-3-opus-20240229",
    "ANTHROPIC_SMALL_FAST_MODEL": "claude-3-opus-20240229"
  }
}

注意这里指向的是端口 4001(我们自己写的中间件),而不是 LiteLLM 的 4000 端口。原因见下文。(编者注:b站视频等都是配完litellm做翻译就可以正常了,不知道是不是版本问题,实测下来没有这个中间件,大模型返回的数据会报错)


第四步:为什么需要自定义中间件?

你可能会问:LiteLLM 不是已经做了格式转换吗?直接指向 4000 不行吗?

不行。 这是本次踩坑最核心的地方。
(编者注:后续调试发现部分模型传输数据不正常,无法调用工具可以尝试这个中间件,不是非要用的。比如魔塔上找了个Qwen3 14B Thinking 2507 x Claude 4.5 Opus模型可以完美适配,调用工具,就不需要这个中间件了。)

Claude Code 使用的是 Anthropic 的 /v1/messages API 协议,而 LiteLLM 的 /v1/chat/completions 是 OpenAI 格式。LiteLLM 虽然也提供了 /v1/messages 端点,但在 1.82.x 版本存在 bug

Ollama 模型通过 tool_calls 字段返回工具调用时,LiteLLM 在转换为 Anthropic 格式时,会错误地将工具调用信息塞进 text 字符串,而不是生成正确的 tool_use block。

也就是说,Claude Code 收到的响应长这样(错误的):

{
  "content": [
    {
      "type": "text",
      "text": "{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}"
    }
  ],
  "stop_reason": "end_turn"   // ← 错误,应该是 "tool_use"
}

而它期望的是这样(正确的):

{
  "content": [
    {
      "type": "tool_use",
      "name": "bash",
      "input": {"command": "ls"}
    }
  ],
  "stop_reason": "tool_use"   // ← 关键!
}

✅ 解决方案:自己写一个 FastAPI 中间件,接管 /v1/messages 端点,手动完成这个格式转换。


第五步:编写中间件 anthropic_proxy.py

完整代码如下(保存为 anthropic_proxy.py,用 uvicorn anthropic_proxy:app --port 4001 启动):

import json
import re
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import httpx

app = FastAPI()

def _try_parse_tool_call(text: str):
    """从文本/代码块中提取工具调用 JSON"""
    # 直接解析
    try:
        parsed = json.loads(text.strip())
        if isinstance(parsed, dict) and "name" in parsed and "arguments" in parsed:
            return parsed
    except json.JSONDecodeError:
        pass

    # 从 Markdown 代码块中提取
    for match in re.finditer(r'```(?:json)?\s*(.+?)\s*```', text, re.DOTALL):
        try:
            parsed = json.loads(match.group(1).strip())
            if isinstance(parsed, dict) and "name" in parsed and "arguments" in parsed:
                return parsed
        except json.JSONDecodeError:
            pass

    # 栈匹配:找 {"name": ... } 结构
    json_start = text.find('{"name"')
    if json_start != -1:
        depth, start = 0, text.rfind('{', 0, json_start)
        if start != -1:
            for i in range(start, len(text)):
                if text[i] == '{':
                    depth += 1
                elif text[i] == '}':
                    depth -= 1
                    if depth == 0:
                        try:
                            parsed = json.loads(text[start:i+1])
                            if isinstance(parsed, dict) and "name" in parsed:
                                return parsed
                        except json.JSONDecodeError:
                            pass
                        break
    return None


def _convert_openai_to_anthropic(litellm_data: dict, original_body: dict):
    """OpenAI 格式 → Anthropic /v1/messages 格式"""
    choices = litellm_data.get("choices", [])
    model = litellm_data.get("model", original_body.get("model", ""))

    if not choices:
        return {"type": "message", "role": "assistant", "model": model,
                "content": [{"type": "text", "text": "No response"}],
                "stop_reason": "end_turn", "stop_sequence": None}

    message = choices[0].get("message", {})
    tool_calls = message.get("tool_calls", [])
    text_content = message.get("content", "")
    anthropic_content = []
    found_tools = []

    # 工具名规范化映射(Qwen 模型经常乱起名)
    tool_name_map = {
        "shell": "bash", "local-exec": "bash", "exec": "bash", "run": "bash",
        "glob": "glob", "grep": "grep", "search": "grep",
        "read": "read", "cat": "read", "write": "write",
        "edit": "edit", "patch": "edit",
        "todos": "todo_write", "todowrite": "todo_write",
        "fetch": "webfetch", "notebook": "notebook",
    }

    # 处理 tool_calls 字段(标准路径)
    for tc in tool_calls:
        func = tc.get("function", {})
        name = func.get("name", "")
        raw_args = func.get("arguments", "{}")
        args_dict = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
        found_tools.append(name)
        anthropic_content.append({"type": "tool_use", "name": name, "input": args_dict})

    # 处理 content 字段(Qwen 把工具调用写在文本里的情况)
    if text_content and text_content.strip():
        parsed = _try_parse_tool_call(text_content)
        if parsed:
            raw_name = parsed["name"].lower().strip()
            final_name = tool_name_map.get(raw_name, parsed["name"])
            found_tools.append(final_name)
            anthropic_content.append({
                "type": "tool_use",
                "name": final_name,
                "input": parsed.get("arguments", {})
            })
            print(f"[Proxy] 🔧 从 text 提取工具调用: {parsed['name']}{final_name}")
        else:
            anthropic_content.append({"type": "text", "text": text_content})

    stop_reason = "tool_use" if found_tools else "end_turn"

    return {
        "type": "message",
        "role": "assistant",
        "model": model,
        "content": anthropic_content,
        "stop_reason": stop_reason,
        "stop_sequence": None,
        "usage": {
            "input_tokens": litellm_data.get("usage", {}).get("prompt_tokens", 0),
            "output_tokens": litellm_data.get("usage", {}).get("completion_tokens", 0),
        }
    }


@app.post("/v1/messages")
async def anthropic_messages(request: Request):
    body = await request.json()

    # Anthropic → OpenAI 格式转换
    messages = []
    for msg in body.get("messages", []):
        content = msg.get("content", "")
        if isinstance(content, list):
            parts = []
            for block in content:
                if block["type"] == "text":
                    parts.append(block["text"])
                elif block["type"] == "tool_result":
                    parts.append(f"[Tool Result] {block.get('content', '')}")
            content = "\n".join(parts)
        messages.append({"role": msg["role"], "content": content})

    openai_body = {
        "model": body.get("model", "qwen3.5:9b"),
        "messages": messages,
        "stream": False,
    }
    if body.get("tools"):
        openai_body["tools"] = body["tools"]
    # qwen3.5 禁用 thinking 模式
    if "qwen3" in body.get("model", "").lower():
        openai_body["options"] = {"think": False}

    async with httpx.AsyncClient(timeout=120.0) as client:
        resp = await client.post(
            "http://localhost:4000/v1/chat/completions",
            json=openai_body
        )
        litellm_data = resp.json()

    anthropic_response = _convert_openai_to_anthropic(litellm_data, body)
    print(f"[Proxy] stop_reason={anthropic_response['stop_reason']}, "
          f"content_types={[c['type'] for c in anthropic_response['content']]}")
    return JSONResponse(content=anthropic_response)

启动命令:

pip install fastapi uvicorn httpx
uvicorn anthropic_proxy:app --host 0.0.0.0 --port 4001

⚠️ 坑 #4:缺少 usage 字段导致前端崩溃

早期版本的中间件没有返回 usage 字段,Claude Code 解析时报错:

undefined is not an object (evaluating '$.input_tokens')

✅ 解决方案:在响应中补充 usage.input_tokensusage.output_tokens 字段,即使值为 0 也要有。


⚠️ 坑 #5:Qwen 模型把工具调用藏在文本里

Qwen 系列模型有时不走标准的 tool_calls 字段,而是把工具调用 JSON 直接写在 content 文本里,甚至包在 Markdown 代码块中:

我来帮你执行这个命令:
```json
{"name": "bash", "arguments": {"command": "ls -la"}}

**✅ 解决方案**:中间件的 `_try_parse_tool_call` 函数多重兜底解析:
1. 直接 JSON 解析
2. 正则提取 Markdown 代码块
3. 栈匹配算法定位嵌套 JSON 对象

---

**⚠️ 坑 #6:Qwen 生成非标准工具名**

Qwen 模型对工具名的发挥空间很大,同一个 `bash` 工具,它可能叫:`Shell`、`local-exec`、`Bash Execute a shell command`、`run`……

Claude Code 工具名大小写敏感,一旦对不上就不执行。

**✅ 解决方案**:在中间件加 `tool_name_map` 规范化映射,把常见变体统一映射到 Claude Code 原生工具名。

---

**⚠️ 坑 #7:Qwen3.5 的 Thinking 模式**

Qwen3.5 系列默认开启 "思维链" 推理(Thinking 模式),会在响应前输出大量 `<think>...</think>` 内容,严重拖慢响应速度,还可能干扰工具调用格式解析。

**✅ 解决方案**:在请求 options 中显式禁用:

```python
openai_body["options"] = {"think": False}

效果验证

中间件正常工作时,终端日志应该类似这样:

[Proxy] 🔧 从 text 提取工具调用: bash → bash
[Proxy] stop_reason=tool_use, content_types=['tool_use']

Claude Code 收到 stop_reason=tool_use 后,会触发本地工具执行,完成文件读写、命令运行等操作。


模型选择建议(16G 显存)

模型 显存占用 工具调用稳定性 推理质量 推荐指数
qwen3.5:9b ~6GB ✅ 稳定 ⭐⭐⭐ 入门首选
qwen2.5-coder:14b-instruct-q8_0 ~15GB ✅ 较稳定 ⭐⭐⭐⭐ 性价比最高
qwen2.5-coder:32b-instruct-q3_K_M ~14GB ✅ 非常稳定 ⭐⭐⭐⭐⭐ 能力天花板
mistral-nemo:12b ~8GB ✅ 格式规范 ⭐⭐⭐⭐ 速度/质量均衡
llama3.3:70b-instruct-q2_K ~15GB ✅ 原生支持 ⭐⭐⭐⭐⭐ 高风险高收益

💡 推荐路径:先用 qwen3.5:9b 验证架构跑通 → 升级到 qwen2.5-coder:14b-instruct-q8_0 提升质量 → 如有余力上 32b q3 冲性能。


完整启动流程

# 1. 启动 Ollama
ollama serve

# 2. 启动 LiteLLM(虚拟环境中)
litellm --config litellm_config.yaml --port 4000

# 3. 启动中间件
uvicorn anthropic_proxy:app --host 0.0.0.0 --port 4001

# 4. 打开 VSCode,Claude Code 即可使用本地模型

总结

坑点 根因 解法
PowerShell curl 语法错误 PS 的 curl 是 Invoke-WebRequest 别名 Invoke-RestMethodcurl.exe
LiteLLM 400 参数错误 Claude Code 发送 Ollama 不认识的参数 drop_params: true
Streaming 异常 ollama/ 前缀使用旧 API 改为 ollama_chat/ 前缀
工具调用格式错误 LiteLLM 1.82.x 的 bug 自写 FastAPI 中间件做格式转换
前端崩溃 undefined 缺少 usage 字段 中间件补充 usage 映射
工具调用藏在文本里 Qwen 模型输出位置不规范 中间件多重解析 + 代码块提取
工具名不匹配 Qwen 自创工具名 tool_name_map 规范化
Thinking 模式拖慢响应 Qwen3.5 默认开启推理链 options: {think: false}

整个调试过程挺折腾的,但架构跑通后非常丝滑——完全本地,零延迟,零费用。希望本文能帮你少走弯路。

如果你在复现过程中遇到其他问题,欢迎在评论区交流!
(编者注:除了所有编者注,其他文本均由workbuddy生成,本次调试过程也是workbuddy协助下完成,实测workbuddy大有可为,撒花完结^ ^)


参考环境:Windows 11 + Ollama 0.6.x + LiteLLM 1.82.x + Claude Code 2.1.107

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐