VSCode 中 Claude Code 接入 DeepSeek:协议转换与配置指南
1. 为什么要在 VSCode 里把 Claude Code 接到 DeepSeek 上Claude Code 这个命令行工具刚出来的时候很多人第一反应是这不就是个终端里的 AI 编程助手吗但真正用起来才发现它的价值在于把整个项目目录当成上下文能直接读写文件、跑命令、改代码比在网页里复制粘贴强太多。问题也很现实官方默认走的是 Anthropic 的接口用量一大成本就上来了而且国内网络环境下调用稳定性也让人头疼。DeepSeek 这两年在代码能力上的进步有目共睹尤其是它的推理模型在编程任务上的表现加上 API 价格相比国际主流模型便宜一个数量级就成了很多人的替代方案。把 Claude Code 的前端交互和 DeepSeek 的后端模型结合起来本质上是用 Claude Code 的工程化能力 DeepSeek 的性价比这个组合对个人开发者和小团队来说非常划算。这篇内容适合三类人一是已经在用 Claude Code 但想换模型的二是想用 DeepSeek 但不想自己写一套 CLI 工具的三是纯粹想搞清楚ANTHROPIC_BASE_URL这类环境变量到底怎么配的。我会从原理讲到实操把每一步为什么这么做都说清楚配置过程中容易踩的坑也会一并列出来。需要先说明一点Claude Code 本身是 Anthropic 的客户端它默认只认 Anthropic 的接口协议。DeepSeek 官方提供的是 OpenAI 兼容格式的 API两者协议不完全一样。所以中间需要一个协议转换层这是整个方案的核心后面会详细讲。2. 核心原理拆解协议转换到底在转什么2.1 Claude Code 的请求长什么样Claude Code 发出去的请求走的是 Anthropic Messages API 格式核心字段包括model、max_tokens、messages、system、tools等。它和 OpenAI 格式最大的区别在于system提示是顶层字段不是放在 messages 数组里的第一条消息角色只有user和assistant工具调用结果用user角色携带tool_result类型的内容块工具定义用的是input_schema而不是parameters流式返回的事件类型是content_block_delta、message_delta这一套这些差异意味着你不能简单地把ANTHROPIC_BASE_URL指向 DeepSeek 的地址就完事字段对不上请求会直接报 400。2.2 DeepSeek 的接口格式DeepSeek 的 API 是 OpenAI 兼容的/chat/completions端点system放在 messages 里工具用tools数组加function.parameters流式返回是choices[].delta。它支持的模型名在热词里也提到了比如deepseek-flash、deepseek-v4、deepseek-v4-pro这些具体可用名称要以你账号下的模型列表为准报错信息里通常会直接告诉你支持哪些。2.3 转换层的三种实现路径方案原理优点缺点官方兼容端点部分服务商直接提供 Anthropic 格式入口零配置最省事不是所有服务商都有本地代理转换跑一个本地服务做协议翻译完全可控可加日志需要额外进程客户端改写改 Claude Code 的请求逻辑无中间层升级会覆盖维护成本高我实测下来本地代理转换是最稳的路子。原因很简单Claude Code 更新频繁改客户端源码每次升级都要重来而本地代理只要协议映射写对了客户端怎么升级都不影响。而且代理层可以加请求日志出问题的时候能直接看到原始请求和转换后的请求排查效率高很多。提示转换层要处理的不只是字段名映射还有流式响应的 SSE 事件格式转换。很多人卡在非流式能用、流式就断就是这里没处理好。3. 环境准备与依赖安装3.1 基础环境检查动手之前先把这几样确认好缺一个后面都会卡Node.js 18 以上Claude Code 是 npm 包低版本会有兼容问题。用node -v确认。npm 或 pnpm装包用pnpm 更快但 npm 也行。DeepSeek API Key去 DeepSeek 开放平台申请注意保存好只显示一次。一个能跑本地服务的运行时Node 或 Python 都行我下面用 Node 举例因为和 Claude Code 同生态依赖少。Windows 用户如果遇到failed to connect to the docker api at npipe这类报错说明你在用 Docker 方案其实这个场景不需要 Docker直接本地跑 Node 服务更简单别被带偏了。3.2 安装 Claude Codenpm install -g anthropic-ai/claude-code装完用claude --version验证。如果提示命令找不到检查 npm 全局 bin 目录有没有加到 PATH 里。Windows 上常见的是%APPDATA%\npmmacOS/Linux 是/usr/local/bin或~/.npm-global/bin。3.3 准备转换代理项目新建一个目录初始化mkdir claude-deepseek-proxy cd claude-deepseek-proxy npm init -y npm install express axios这里选 Express 是因为它足够轻写个转发接口几十行就够。axios 用来转发请求到 DeepSeek。如果你更喜欢原生 fetchNode 18 自带可以省掉 axios 依赖。3.4 配置环境变量在项目根目录建一个.env文件DEEPSEEK_API_KEYsk-你的key DEEPSEEK_BASE_URLhttps://api.deepseek.com DEEPSEEK_MODELdeepseek-v4 PROXY_PORT8787DEEPSEEK_MODEL这里填你账号下实际可用的模型名。如果填错了DeepSeek 会返回 400 并告诉你支持的模型列表照着改就行。热词里出现的the supported api model names are deepseek-flash, deepseek-v4-pro就是这类报错属于配置问题不是代码问题。4. 协议转换代理的完整实现4.1 请求转换Anthropic 到 OpenAI核心逻辑是把 Anthropic 的请求体翻译成 OpenAI 格式。关键映射关系function anthropicToOpenAI(body) { const messages []; // system 顶层字段搬到 messages 第一条 if (body.system) { messages.push({ role: system, content: body.system }); } // 逐条转换消息 for (const msg of body.messages) { if (typeof msg.content string) { messages.push({ role: msg.role, content: msg.content }); } else if (Array.isArray(msg.content)) { // 处理内容块text / tool_use / tool_result const textParts []; const toolCalls []; const toolResults []; for (const block of msg.content) { if (block.type text) { textParts.push(block.text); } else if (block.type tool_use) { toolCalls.push({ id: block.id, type: function, function: { name: block.name, arguments: JSON.stringify(block.input) } }); } else if (block.type tool_result) { toolResults.push({ role: tool, tool_call_id: block.tool_use_id, content: typeof block.content string ? block.content : JSON.stringify(block.content) }); } } if (toolResults.length 0) { messages.push(...toolResults); } else if (toolCalls.length 0) { messages.push({ role: assistant, content: textParts.join(\n) || null, tool_calls: toolCalls }); } else { messages.push({ role: msg.role, content: textParts.join(\n) }); } } } // 工具定义转换 const tools body.tools?.map(t ({ type: function, function: { name: t.name, description: t.description, parameters: t.input_schema } })); return { model: process.env.DEEPSEEK_MODEL, messages, tools, max_tokens: body.max_tokens, stream: body.stream }; }这段代码有几个容易写错的地方。第一tool_result在 Anthropic 里是放在user消息的内容块里的转换后要变成独立的role: tool消息顺序不能乱。第二tool_use的input是对象OpenAI 要的是 JSON 字符串。第三当一条 assistant 消息同时有文本和工具调用时content和tool_calls要同时存在。4.2 响应转换OpenAI 到 Anthropic非流式响应相对简单把choices[0].message拆成 Anthropic 的 content 块数组function openAIToAnthropic(resp, model) { const choice resp.choices[0]; const content []; if (choice.message.content) { content.push({ type: text, text: choice.message.content }); } if (choice.message.tool_calls) { for (const tc of choice.message.tool_calls) { content.push({ type: tool_use, id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments) }); } } return { id: resp.id, type: message, role: assistant, model, content, stop_reason: choice.finish_reason tool_calls ? tool_use : end_turn, usage: { input_tokens: resp.usage?.prompt_tokens || 0, output_tokens: resp.usage?.completion_tokens || 0 } }; }stop_reason的映射很关键。Claude Code 靠这个字段判断要不要继续执行工具如果tool_calls存在但stop_reason给成了end_turn工具就不会被执行表现为模型说要调工具但没动静。4.3 流式响应的事件转换流式是最麻烦的部分。Anthropic 的 SSE 事件序列大致是message_start content_block_start (text 或 tool_use) content_block_delta (text_delta 或 input_json_delta) content_block_stop message_delta (带 stop_reason) message_stop而 OpenAI 的流式是每个 chunk 带choices[0].delta工具调用的参数是分片拼接的。转换时要维护状态机async function* streamConvert(openaiStream, model) { const messageId msg_ Date.now(); let contentIndex 0; let currentToolCall null; let toolArgsBuffer ; yield sse(message_start, { type: message_start, message: { id: messageId, type: message, role: assistant, model, content: [], usage: { input_tokens: 0, output_tokens: 0 } } }); for await (const chunk of openaiStream) { const delta chunk.choices?.[0]?.delta; if (!delta) continue; // 文本增量 if (delta.content) { if (contentIndex 0) { yield sse(content_block_start, { type: content_block_start, index: 0, content_block: { type: text, text: } }); } yield sse(content_block_delta, { type: content_block_delta, index: 0, delta: { type: text_delta, text: delta.content } }); } // 工具调用增量 if (delta.tool_calls) { for (const tc of delta.tool_calls) { if (tc.function?.name) { // 新工具开始 if (currentToolCall) { yield sse(content_block_stop, { type: content_block_stop, index: contentIndex }); contentIndex; } currentToolCall tc; toolArgsBuffer ; yield sse(content_block_start, { type: content_block_start, index: contentIndex, content_block: { type: tool_use, id: tc.id, name: tc.function.name, input: {} } }); } if (tc.function?.arguments) { toolArgsBuffer tc.function.arguments; yield sse(content_block_delta, { type: content_block_delta, index: contentIndex, delta: { type: input_json_delta, partial_json: tc.function.arguments } }); } } } // 结束原因 if (chunk.choices[0].finish_reason) { if (currentToolCall) { yield sse(content_block_stop, { type: content_block_stop, index: contentIndex }); } else if (contentIndex 0) { yield sse(content_block_stop, { type: content_block_stop, index: 0 }); } yield sse(message_delta, { type: message_delta, delta: { stop_reason: chunk.choices[0].finish_reason tool_calls ? tool_use : end_turn }, usage: { output_tokens: 0 } }); } } yield sse(message_stop, { type: message_stop }); }sse是个辅助函数把事件名和数据拼成event: xxx\ndata: {...}\n\n的格式。这里的状态机要特别注意工具调用的 index 管理多个工具连续调用时 index 要递增否则 Claude Code 解析会乱。4.4 主服务入口import express from express; import axios from axios; const app express(); app.use(express.json({ limit: 50mb })); app.post(/v1/messages, async (req, res) { const openaiBody anthropicToOpenAI(req.body); try { if (req.body.stream) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); const response await axios.post( ${process.env.DEEPSEEK_BASE_URL}/chat/completions, { ...openaiBody, stream: true }, { headers: { Authorization: Bearer ${process.env.DEEPSEEK_API_KEY}, Content-Type: application/json }, responseType: stream } ); // 把 axios stream 转成 async iterable for await (const event of streamConvert(parseSSE(response.data), openaiBody.model)) { res.write(event); } res.end(); } else { const response await axios.post( ${process.env.DEEPSEEK_BASE_URL}/chat/completions, openaiBody, { headers: { Authorization: Bearer ${process.env.DEEPSEEK_API_KEY}, Content-Type: application/json } } ); res.json(openAIToAnthropic(response.data, openaiBody.model)); } } catch (err) { console.error(Proxy error:, err.response?.data || err.message); res.status(err.response?.status || 500).json({ type: error, error: { type: api_error, message: err.response?.data?.error?.message || err.message } }); } }); app.listen(process.env.PROXY_PORT, () { console.log(Proxy running on http://localhost:${process.env.PROXY_PORT}); });parseSSE是个把 SSE 流解析成对象的异步生成器处理data:前缀和[DONE]结束标记。这部分代码不复杂但容易漏掉边界情况比如跨 chunk 的半行数据要缓存起来。5. 把 Claude Code 指向本地代理5.1 环境变量配置代理跑起来之后让 Claude Code 走本地export ANTHROPIC_BASE_URLhttp://localhost:8787 export ANTHROPIC_API_KEYany-string-worksANTHROPIC_API_KEY这里填什么都行因为真正的鉴权在代理层用 DeepSeek 的 key 完成。但 Claude Code 启动时会检查这个变量存不存在不填会直接报错退出。Windows PowerShell 用$env:ANTHROPIC_BASE_URLhttp://localhost:8787想持久化就写进系统环境变量。macOS/Linux 想持久化就加到~/.zshrc或~/.bashrc。5.2 验证连通性先单独测代理curl http://localhost:8787/v1/messages \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, max_tokens: 100, messages: [{role: user, content: 说一句话}] }能返回 Anthropic 格式的 JSON 就说明代理通了。然后再跑claude进交互模式随便问一句看有没有正常回复。5.3 模型名映射的处理Claude Code 内部会传它认为的模型名比如claude-3-5-sonnet-20241022。代理层要忽略这个字段统一替换成DEEPSEEK_MODEL。我在anthropicToOpenAI里就是这么做的直接写死用环境变量。如果你想支持多模型切换可以在代理里加个映射表根据请求的 model 字段路由到不同的 DeepSeek 模型。注意不要试图让 Claude Code 直接传 DeepSeek 的模型名它的模型列表是硬编码的传不认识的名字会在客户端就报错根本到不了代理层。6. 常见报错与排查速查6.1 400 类错误报错信息原因解决the supported api model names are...模型名填错改成报错里列出的名字maximum context length is 1048576 tokens上下文超限清理对话历史或换长上下文模型invalid_request_error: messages消息格式转换有误检查 tool_result 是否转成了独立 tool 消息tools[0].function.parameters工具 schema 不合法确认 input_schema 是合法 JSON Schema6.2 连接类错误failed to connect to the docker api at npipe这种是 Docker Desktop 没启动或者管道配置问题。但前面说了这个方案不需要 Docker如果你看到这个报错说明你参考的教程用了容器方案直接换成本地 Node 跑就行。login failed. check api token一般是ANTHROPIC_API_KEY没设或者代理没起来。先确认代理进程在跑再确认环境变量在当前 shell 生效。6.3 流式中断问题最常见的表现是回复到一半卡住或者工具调用参数不完整。排查思路看代理日志里 DeepSeek 返回的原始 chunk确认数据是完整的检查content_block_stop有没有在正确时机发出确认message_delta里的stop_reason和实际 finish_reason 一致我踩过的一个坑是DeepSeek 在某些情况下会返回空的 delta chunk只有 role 没有 content如果代码里没做空值判断会往content_block_delta里塞空文本Claude Code 解析时可能直接断流。6.4 工具调用不执行如果模型明确说要调用工具但 Claude Code 没动作九成是stop_reason映射错了。Anthropic 的tool_use对应 OpenAI 的tool_calls别映射成end_turn。另外工具调用的id要保证唯一重复的 id 会让客户端状态混乱。7. 实操心得与性能调优7.1 代理层的日志策略强烈建议在代理里加请求日志但要注意别把 API Key 打出来。我一般记录这些字段请求的 model、消息条数、是否有工具、响应耗时、token 用量。出问题的时候这些信息足够定位又不会泄露敏感数据。日志写到文件里用pino或简单的fs.appendFile都行。跑一段时间后你会发现大部分问题都能从日志里直接看出来比盲目调试快得多。7.2 超时和重试DeepSeek 的响应速度整体不错但高峰期偶尔会慢。代理层要设合理的超时我一般设 120 秒流式的话用responseType: stream配合 axios 的timeout只作用于建立连接阶段不会中途掐断。重试要谨慎。非流式请求可以重试流式请求不要自动重试因为流已经吐出去一部分了重试会导致内容重复。真要重试就让用户手动重发。7.3 上下文长度管理Claude Code 会把项目文件内容塞进上下文很容易撑爆。DeepSeek 不同模型的上下文窗口不一样用之前确认一下。如果经常超限可以在 Claude Code 里用/compact压缩历史或者调整它的文件读取策略别让它一次读太多文件。7.4 成本控制DeepSeek 便宜是相对的工具调用密集的场景 token 消耗还是很快。我的做法是在代理层统计每天的 token 用量超过阈值就告警。另外 Claude Code 有些操作会触发大量文件读取可以在它的配置里限制单次读取的文件数量。7.5 稳定性观察跑了大概两周整体稳定性可以。遇到过一次 DeepSeek 侧返回 503代理直接把错误透传给 Claude Code客户端显示API 错误但没崩重发就好了。这种上游抖动没法完全避免代理层做好错误透传让用户知道是上游问题而不是本地配置问题体验会好很多。8. 后续可以扩展的方向代理层跑通之后其实可以做不少有意思的事。比如加一个请求缓存相同的 prompt 直接返回缓存结果省 token 又提速。再比如加多模型路由简单任务走便宜的 flash 模型复杂推理走 pro 模型根据请求内容自动判断。还有一个方向是本地模型兜底。如果 DeepSeek 接口临时不可用代理可以自动切到本地跑的小模型虽然效果差一些但至少不断线。这个用 Ollama 之类的本地推理服务配合就能实现代理层做个 fallback 逻辑即可。工具调用这块也可以优化。Claude Code 的工具定义比较固定代理层可以针对 DeepSeek 的特点做 prompt 层面的适配比如在 system 里加一些引导让模型更规范地输出工具调用格式减少解析失败的概率。我个人在实际操作中的体会是这套方案的价值不在于省了多少钱而在于把选择权拿回自己手里。模型可以换、参数可以调、日志可以看出了问题知道去哪找原因这种掌控感是直接用官方服务给不了的。配置过程确实有点折腾但一次配好之后就很省心后面换模型也就是改个环境变量的事。