Qwen3.5-9B 本地部署与 Claude Code 接入:协议不兼容?手搓一个代理就好啦

Ollama:

Claude Code:

前言

上篇 折腾完 Qwen3.6-35B-A3B 之后,本地有了一个说得过去的日常对话模型。但用了一阵子发现,3B 的激活参数量在工具调用和复杂代码生成上还是差点意思——让它给 Claude Code 当 Agent 主力,经常出现工具参数填错、步骤遗漏之类的问题。

于是决定再部署一个 Qwen3.5-9B,跟 35B-A3B 做分工:9B 负责 Agent 和编码,35B-A3B 负责快速对话。一快一猛,换着用。

9B 密集模型的显存占用比 MoE 大不少,8GB 显卡刚好卡在临界点上。而且这次的目标不只是跑通模型——要让 Claude Code 和 OpenCode 都能用上它,中间还踩了个 Anthropic 和 OpenAI 协议不兼容的大坑。


一、硬件环境:8GB 显存,够吗?

组件型号
CPUAMD Ryzen 7 7700X (8C/16T, Zen 4)
GPUNVIDIA GeForce RTX 4060 Ti (8 GB GDDR6)
内存32 GB DDR5
磁盘500GB SSD, 剩余 ~141GB
系统Windows 11 Pro
CUDA12.8
驱动571.96
Python3.10.11

关键瓶颈:8GB 显存

FP16 的 9B 模型约 18GB,8GB 卡门都进不去。必须量化。好在 Ollama 默认就是 Q4_K_M,模型约 6.6GB,全塞进显存还剩约 1.5GB 给 KV Cache——刚好够用,但别指望同时跑第二个模型。


二、方案选型:为什么是 Ollama

方案显存够吗Windows 友好吗维护成本结论
Transformers + HF 原生❌ FP16=18GB一般出门左转
vLLM + AWQ⚠️ 8GB 勉强不友好别折腾
llama.cpp 原生✅ 灵活需自行编译可用但累
Ollama✅ Q4_K_M=6.6GB一行安装就它了

Ollama 的优势:Windows 原生安装包、模型管理像 docker pull 一样简单、内置 OpenAI 兼容 API(localhost:11434/v1)、自动 GPU 加速。

权衡:灵活性不如 llama.cpp 原生(比如不能精确控制 GPU offload 层数),但对个人使用场景完全够用。

量化策略对比

量化等级模型文件大小是否适合 8GB
Q4_K_M~6.6 GB✅ 部署时实测 7.5GB 显存占用
Q5_K_M~7.5 GB⚠️ 极限,KV Cache 捉襟见肘
Q8_0~9.5 GB❌ 显存放不下

选定 Q4_K_M


三、模型部署:两行命令,半小时等待

3.1 安装 Ollama

winget install Ollama.Ollama

安装后托盘出现小羊驼图标,服务自动运行。版本 0.24.0。

3.2 拉取模型

ollama pull qwen3.5:9b

6.6GB 的 GGUF 文件,在 10 MB/s 的速度下大约 11 分钟。期间我经历了两次下载中断(Windows 网络波动),好在 Ollama 支持断点续传。

3.3 创建 Agent 优化版(推荐)

默认模型上下文窗口偏小,且没有 Agent 场景的系统提示词。我写了一个 Modelfile:

FROM qwen3.5:9b
PARAMETER num_ctx 32768
SYSTEM """你是一个专业的 AI 编程助手……(此处省略200字)"""

然后:

ollama create qwen3.5:9b-agent -f Modelfile

搞定了两个模型:

  • qwen3.5:9b — 基础版,日常使用
  • qwen3.5:9b-agent — 优化版,32K 上下文 + 编程助手提示词

3.4 验证

# 终端交互测试
ollama run qwen3.5:9b "Hello"

# API 测试
curl http://localhost:11434/v1/chat/completions `
  -H "Content-Type: application/json" `
  -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}]}'

两测均通过。此时如果只是用 ChatGPT-Next-Web 之类的 OpenAI 客户端,故事已经结束了。

但你猜怎么着?我要接的是 Claude Code


四、Claude Code 接入:一场持续 3 小时的协议战争

4.1 第一次尝试:直接配 openaiProvider(失败)

Claude Code 文档说支持 openaiProvider,配置如下:

{
  "openaiProvider": {
    "baseURL": "http://localhost:11434/v1",
    "apiKey": "ollama",
    "model": "qwen3.5:9b-agent"
  }
}

结果:Not logged in · Please run /login

原因是 Claude Code v2.1.79 在检测到 openaiProvider 后,仍然会先走 Anthropic 认证流程。认证不过,直接拒绝服务。这就像你告诉出租车司机"我有导航",司机说"不行,我得先用我的导航确认一下路"。

4.2 第二次尝试:用 Anthropic 环境变量冒充(失败)

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:11434/v1",
    "ANTHROPIC_AUTH_TOKEN": "ollama",
    "ANTHROPIC_MODEL": "qwen3.5:9b-agent"
  }
}

我想的是:让 Claude Code 以为它在跟 Anthropic 服务器说话,实际上指向 Ollama。

现实:Claude Code 发送的是 Anthropic Messages API 格式的请求(/v1/messages,JSON 结构是 Anthropic 风格),Ollama 只理解 OpenAI Chat Completions 格式(/v1/chat/completions,JSON 结构是 OpenAI 风格)。

结果:There's an issue with the selected model。Ollama 看不懂 Anthropic 的请求格式,回复了 404。

这个思路方向对了,但缺了一个翻译层

4.3 第三次尝试:Litellm 代理(差点成功)

Litellm 是一个 LLM API 网关,支持 Anthropic ↔ OpenAI 协议转换。但它的完整功能(proxy mode)需要安装 30+ 个依赖包,其中包括 websockets、fastapi、uvicorn 等。

pip install "litellm[proxy]"

安装成功后启动:

litellm --model ollama_chat/qwen3.5:9b-agent --port 8787

结果请求被转发到了 DeepSeek 的 API 地址,而不是本地的 Ollama。原因是 litellm 读取了环境变量里的 ANTHROPIC_BASE_URL(之前配 CC Switch 时留下的 DeepSeek 地址),自作主张地把请求转给了 DeepSeek。

我陷入了凝视 /dev/null 的深渊。

4.4 第四次尝试:手搓协议转译代理(成功)

"既然 litellm 这么复杂,不如自己写一个。"

核心需求极其简单:

  1. 监听 8787 端口
  2. 接收 Anthropic /v1/messages 格式请求
  3. 转成 OpenAI /v1/chat/completions 格式发给 Ollama
  4. 把 Ollama 的 OpenAI 格式响应转回 Anthropic 格式
  5. 返回给 Claude Code

Python 标准库就够了(http.server + urllib.request + json),不到 200 行代码。

遇到的坑与解

症状解法
Query string 匹配失败Claude Code 请求 /v1/messages?beta=true,代理只匹配 /v1/messagespath = self.path.split("?")[0]
冒号模型名qwen3.5:9b-agent 被 Claude Code 预检拦截Ollama cp 创建无冒号副本
流式传输(SSE)Claude Code 默认开启 streaming,期待 text/event-stream实现 Anthropic SSE 事件格式(message_startcontent_block_deltamessage_stop
思考模型输出Qwen 返回 reasoning 字段 + 空 content合并 reasoningcontent 为文本输出
端口残留多次重启代理导致 CLOSE_WAIT 堆积重启前 kill 旧进程

核心代码片段

请求转译(Anthropic → OpenAI):

# Anthropic system message → OpenAI user message with prefix
messages = []
for m in anthropic_request["messages"]:
    role = m["role"]
    text = flatten_content(m["content"])
    if role == "system":
        messages.append({"role": "user", "content": f"[System]\n{text}"})
    else:
        messages.append({"role": role, "content": text})

响应转译(OpenAI → Anthropic):

anthropic_resp = {
    "id": "msg_" + uuid.uuid4().hex[:12],
    "type": "message",
    "role": "assistant",
    "content": [{"type": "text", "text": text}],
    "model": model,
    "stop_reason": "end_turn",
    "usage": {"input_tokens": ..., "output_tokens": ...}
}

完整代码放在项目目录下的 proxy.py,约 180 行。


五、架构总览:最终的调用链

┌──────────────┐     Anthropic格式      ┌─────────────────┐     OpenAI格式      ┌─────────────────┐
│  Claude Code │ ──────────────────────> │  proxy.py:8787   │ ──────────────────> │  Ollama:11434   │
│              │ <────────────────────── │  (协议转译代理)    │ <────────────────── │  qwen3.5:9b     │
│              │     Anthropic格式       │                  │     OpenAI格式      │  (Q4_K_M,6.6GB) │
└──────────────┘                        └─────────────────┘                     └─────────────────┘


                                                                               ┌──────────────┐
                                                                               │  RTX 4060 Ti │
                                                                               │  8GB VRAM    │
                                                                               │  实测 7.5GB  │
                                                                               └──────────────┘

三条黄金法则:

  1. Claude Code 只吃 Anthropic 格式——给它 OpenAI 格式会噎着
  2. Ollama 只吃 OpenAI 格式——给它 Anthropic 格式它不认识
  3. 代理什么都吃,什么都吐——像一个尽职的翻译官

六、CC Switch 无缝集成

CC Switch 是 Claude Code 的多 Provider 管理器——我日常在 DeepSeek、智谱、本地模型间切换。

在 CC Switch 中新建一个 Claude Provider,配置为:

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:8787",
    "ANTHROPIC_AUTH_TOKEN": "ollama",
    "ANTHROPIC_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3.5-9b-agent"
  }
}

注意:ANTHROPIC_BASE_URL 填的是代理端口 8787,不是 Ollama 的 11434。

启动方式:

# 方式一:通过 CC Switch 图形界面切换并启动
# 方式二:通过临时配置文件
claude --settings .cc-settings.json

重要:代理必须保持运行!建议开一个独立的终端 python proxy.py 常驻,或配置为 Windows 服务。


七、模型分工策略

启动 CC Switch 的 Local Proxy 特性后,可以利用故障切换(Failover)功能实现智能路由:

模型角色场景CC Switch 配置
DeepSeek V4 Pro主 Provider日常编码、复杂推理默认选中
qwen3.5:9b-agent本地后备断网时、隐私敏感任务、高并发Failover Queue
Qwen3.6:35B-A3B快速对话轻量问答、翻译按需切换

这样平常走 DeepSeek 云端的强推理能力,断网或需要隐私时自动切到本地模型——鱼和熊掌,能兼得。


八、服务管理与常用命令

操作命令
启动 Ollama开始菜单 → Ollama(桌面应用自动启动服务)
停止 Ollama托盘右键 → Quit
释放显存(不关服务)ollama stop
查看模型ollama list
查看显存nvidia-smi
启动代理python proxy.py
模型永不卸载设置环境变量 OLLAMA_KEEP_ALIVE=-1(需重启 Ollama)

九、故障排查速查表

问题可能原因解决
Claude Code: "Not logged in"Anthropic 认证未过检查 ANTHROPIC_BASE_URL 是否指向代理
Claude Code: "Interrupted"代理没收到请求 / 路径匹配失败检查 proxy.log,确认 query string 被正确 strip
Claude Code: "model not exist"模型名含特殊字符ollama cp 创建无冒号/无特殊字符副本
代理超时无响应旧进程残留taskkill /F /IM python.exe 后重启
Ollama 响应慢模型未预加载发一个请求预热;或设置 OLLAMA_KEEP_ALIVE=-1
CC Switch 配置不生效currentProviderClaude 未更新检查 C:\Users\Administrator\.cc-switch\settings.json

十、总结

这次部署让我深刻体会到:AI 模型的部署本身已经简单到令人发指(两条命令),但让它跟你已有的工具链谈恋爱,才是真正的修罗场。

核心收获:

  1. 8GB 显存够用但不宽裕。Q4_K_M 量化 + 32K 上下文,实测 7.5GB 显存占用,勉强塞下。别同时跑两个模型。
  2. Ollama + Claude Code 的协议鸿沟是真实存在的。Ollama 讲 OpenAI 方言,Claude Code 只认 Anthropic 普通话。需要中间人翻译。
  3. 手搓代理虽不优雅但有效。180 行 Python 标准库代码解决了 3 小时的踩坑。有时候最朴素的方法最好用。
  4. CC Switch 是管理多 Provider 的神器。云端国产 API + 本地模型,一键切换,互不干扰。
  5. 冒号能杀人qwen3.5:9b-agent 中的冒号被 Claude Code 预检拦截,改成 qwen3.5-9b-agent 就通了。有时候 bug 就是一个小字符。

最后送上一句:如果你也在 Windows + 8GB 显卡上部署本地 LLM 给 Claude Code 用——记住,这不是技术问题,这是翻译工作。雇一个代理就好。


附录

A. Modelfile — Agent 优化版模型定义

FROM qwen3.5:9b

# Agent 优化版 — 用于 Claude Code / OpenCode 等编码 Agent
# 基于 Q4_K_M 量化,显存占用约 6.6GB

# 延长上下文窗口(Agent 场景需要更长的对话历史)
PARAMETER num_ctx 32768

# 系统提示词:优化工具调用和代码能力
SYSTEM """你是一个专业的 AI 编程助手。你的核心能力包括:

1. 代码生成与修改:编写清晰、可维护的代码,遵循最佳实践
2. 工具调用:准确使用提供的工具完成任务
3. 调试分析:分析错误信息,定位问题根因
4. 架构设计:理解代码结构,提出合理的设计建议

回答规则:
- 优先使用工具来完成任务,而不是猜测
- 代码修改时使用精确的编辑操作,避免重写整个文件
- 如果信息不足,先询问而不是假设
- 回复简洁直接,避免冗余解释
"""

创建命令:

ollama create qwen3.5:9b-agent -f Modelfile

B. proxy.py — Anthropic ↔ OpenAI 协议转译代理

"""Anthropic -> OpenAI 转译代理 v4(含请求日志)"""
import json, http.server, urllib.request, uuid, time, threading

OLLAMA = "http://localhost:11434/v1"
PORT = 8787
LOG_FILE = "proxy.log"

def log(msg):
    ts = time.strftime("%H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(line + "\n")

def sse(event, data):
    return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()

class Proxy(http.server.BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        log(fmt % args)

    def _respond(self, code, body):
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(body).encode())

    def do_GET(self):
        log(f"GET {self.path}")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        if "/models" in self.path:
            try:
                r = urllib.request.urlopen(f"{OLLAMA}/models", timeout=10)
                d = json.loads(r.read())
                models = [{"id": m["id"], "object": "model"} for m in d.get("data", [])]
                self.wfile.write(json.dumps({"object": "list", "data": models}).encode())
            except:
                self.wfile.write(json.dumps({"object": "list", "data": [
                    {"id": "qwen3.5-9b-agent", "object": "model"}
                ]}).encode())
        else:
            self.wfile.write(json.dumps({"status": "ok"}).encode())

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)
        req = json.loads(raw)

        # 去掉 query string(如 ?beta=true)
        path = self.path.split("?")[0]
        is_stream = req.get("stream", False)
        model = req.get("model", "qwen3.5-9b-agent")
        log(f"POST {self.path} | model={model} | stream={is_stream} | msgs={len(req.get('messages',[]))} | tools={len(req.get('tools',[]))}")

        if path not in ("/v1/messages", "/anthropic/v1/messages"):
            return self._respond(200, {"status": "ok"})

        # 转换消息格式
        msgs = []
        for m in req.get("messages", []):
            role = m["role"]
            text = self._flatten(m.get("content", ""))
            if role == "system":
                msgs.append({"role": "user", "content": f"[System]\n{text}"})
            else:
                msgs.append({"role": role, "content": text})

        body = {
            "model": model,
            "messages": msgs,
            "max_tokens": req.get("max_tokens", 4096),
            "stream": is_stream,
        }
        log(f"  -> Ollama: {len(msgs)} msgs, max_tokens={body['max_tokens']}")

        try:
            data = json.dumps(body).encode()
            r = urllib.request.urlopen(urllib.request.Request(
                f"{OLLAMA}/chat/completions",
                data=data,
                headers={"Content-Type": "application/json"}
            ), timeout=300)

            if is_stream:
                self._stream(req, r)
            else:
                self._nonstream(req, r)
        except Exception as e:
            log(f"ERROR: {e}")
            self._respond(500, {"error": str(e)})

    def _nonstream(self, req, r):
        resp = json.loads(r.read())
        choice = resp["choices"][0]
        msg = choice["message"]
        text = msg.get("content", "") or msg.get("reasoning", "")
        if msg.get("reasoning") and msg.get("content"):
            text = msg["reasoning"] + "\n\n" + msg["content"]

        body = {
            "id": "msg_" + uuid.uuid4().hex[:12],
            "type": "message",
            "role": "assistant",
            "content": [{"type": "text", "text": text.strip()}],
            "model": req.get("model", "qwen3.5-9b-agent"),
            "stop_reason": "end_turn",
            "stop_sequence": None,
            "usage": {"input_tokens": resp.get("usage",{}).get("prompt_tokens",1),
                      "output_tokens": resp.get("usage",{}).get("completion_tokens",1)}
        }
        log(f"OK nonstream: {len(text)} chars")
        self._respond(200, body)

    def _stream(self, req, r):
        log("STREAM start")
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()

        mid = "msg_" + uuid.uuid4().hex[:12]
        model = req.get("model", "qwen3.5-9b-agent")
        out_tok = 0
        started = False
        finish = "end_turn"

        # Anthropic SSE 头部事件
        self.wfile.write(sse("message_start", {
            "type": "message_start",
            "message": {"id": mid, "type": "message", "role": "assistant",
                        "content": [], "model": model, "stop_reason": None,
                        "stop_sequence": None, "usage": {"input_tokens": 0, "output_tokens": 0}}
        }))
        self.wfile.flush()
        time.sleep(0.02)

        self.wfile.write(sse("ping", {"type": "ping"}))
        self.wfile.flush()

        buf = b""
        for chunk in r:
            buf += chunk
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                line = line.strip()
                if not line or not line.startswith(b"data: "):
                    continue
                if line == b"data: [DONE]":
                    break
                try:
                    d = json.loads(line[6:])
                except:
                    continue

                choices = d.get("choices", [])
                if not choices:
                    continue
                delta = choices[0].get("delta", {})
                content = delta.get("content", "")

                if content:
                    if not started:
                        self.wfile.write(sse("content_block_start", {
                            "type": "content_block_start", "index": 0,
                            "content_block": {"type": "text", "text": ""}
                        }))
                        self.wfile.flush()
                        started = True
                    self.wfile.write(sse("content_block_delta", {
                        "type": "content_block_delta", "index": 0,
                        "delta": {"type": "text_delta", "text": content}
                    }))
                    self.wfile.flush()
                    time.sleep(0.01)

                fr = choices[0].get("finish_reason")
                if fr == "stop":
                    finish = "end_turn"
                elif fr == "length":
                    finish = "max_tokens"
                if d.get("usage", {}).get("completion_tokens"):
                    out_tok = d["usage"]["completion_tokens"]

        if started:
            self.wfile.write(sse("content_block_stop", {"type": "content_block_stop", "index": 0}))
            self.wfile.flush()

        self.wfile.write(sse("message_delta", {
            "type": "message_delta",
            "delta": {"stop_reason": finish, "stop_sequence": None},
            "usage": {"output_tokens": out_tok or 1}
        }))
        self.wfile.flush()

        self.wfile.write(sse("message_stop", {"type": "message_stop"}))
        self.wfile.flush()
        log(f"STREAM done: started={started}, finish={finish}, out_tok={out_tok}")

    def _flatten(self, content):
        if isinstance(content, str):
            return content
        if isinstance(content, list):
            parts = []
            for c in content:
                if isinstance(c, dict):
                    t = c.get("type", "")
                    if t == "text":
                        parts.append(c.get("text", ""))
                    elif t == "tool_use":
                        parts.append(f"[Tool: {c.get('name', '')}({json.dumps(c.get('input',{}))})]")
                    elif t == "tool_result":
                        parts.append(f"[ToolResult: {c.get('content','')}]")
                else:
                    parts.append(str(c))
            return "\n".join(parts)
        return str(content)

if __name__ == "__main__":
    log(f"Proxy started on port {PORT}")
    http.server.HTTPServer(("127.0.0.1", PORT), Proxy).serve_forever()

C. .cc-settings.json — Claude Code 代理配置

{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "ollama",
    "ANTHROPIC_BASE_URL": "http://localhost:8787",
    "ANTHROPIC_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3.5-9b-agent",
    "ANTHROPIC_REASONING_MODEL": "qwen3.5-9b-agent",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
    "API_TIMEOUT_MS": "3000000"
  },
  "enabledPlugins": {
    "superpowers@superpowers-marketplace": true
  },
  "extraKnownMarketplaces": {
    "superpowers-marketplace": {
      "source": {
        "source": "github",
        "repo": "obra/superpowers-marketplace"
      }
    }
  }
}
声明:本站所有文章,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。-- mikigo