Node搭建代理清洗API请求

下面是一份从零开始的完整教程,基于 Ubuntu 环境,手把手带你用 Node 写一个本地代理,把 Claude Code 发出的请求体清洗成 DeepSeek 兼容端点能识别的格式,解决 400 报错。核心清洗逻辑来自 的"白名单 + block 级规范化 + system 归位 + 去 beta 头"四板斧。

1. 核心思路

Claude Code 发出的请求是 Anthropic 风格的,而 DeepSeek 兼容端点只认经典 Messages API,多一个字段就 400 。做法是在本地 127.0.0.1 起一个 Node 代理,做中间人:

text 复制代码
Claude Code ──→ http://127.0.0.1:<随机端口> ──→ DeepSeek 兼容上游
                      │
                      └─ proxy.js:清洗请求体、请求头

2. 从0开始:环境准备

2.1 安装 Node.js 18+

Ubuntu 下推荐用 nvm 安装:

bash 复制代码
# 安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# 重新加载 shell
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

# 安装 Node 18 或更高版本
nvm install 18
nvm use 18

# 验证
node -v

代理脚本只用 Node 内置模块,不需要 npm install 任何依赖 。

2.2 确认 Claude Code 已安装

bash 复制代码
claude --version

如果没有安装,先安装 Claude Code。

3. 创建代理脚本

创建一个工作目录,比如 ~/claude-deepseek-proxy,然后把下面的代码保存为 proxy.js

这段代码实现了 中描述的清洗逻辑:

  • 顶层字段只保留白名单;
  • 消息里的 thinkingredacted_thinking、空 text 全部丢弃;
  • tool_use 只保留 id/name/input
  • 递归删除所有 cache_control
  • messages[] 里的 role: "system" 提取出来合并到顶层 system
  • 删除 anthropic-beta 等 400 元凶头 。
javascript 复制代码
// proxy.js
const http = require("http");
const https = require("https");
const { URL } = require("url");

// 上游 DeepSeek 兼容端点地址,按需修改
const UPSTREAM_URL = process.env.UPSTREAM_URL || "https://tokenrhythm.studio";
const UPSTREAM_PATH = process.env.UPSTREAM_PATH || "/v1/messages";

//  顶层字段白名单,不在名单里的直接删除
const allowedTopLevel = new Set([
  "model",
  "max_tokens",
  "messages",
  "system",
  "stream",
  "stop_sequences",
  "temperature",
  "top_p",
  "top_k",
  "tools",
  "tool_choice",
  "metadata",
]);

//  清洗 messages 里的 block:
// 丢 thinking / redacted_thinking / 空 text,剥掉 cache_control
function cleanBlocks(blocks) {
  const result = [];
  for (const block of blocks) {
    if (!block || typeof block !== "object") continue;

    // 丢弃思考块
    if (block.type === "thinking" || block.type === "redacted_thinking") continue;
    // 丢弃空 text
    if (block.type === "text" && !block.text) continue;

    const cleanBlock = { type: block.type };

    // tool_use 只留四件套
    if (block.type === "tool_use") {
      cleanBlock.id = block.id;
      cleanBlock.name = block.name;
      cleanBlock.input = block.input || {};
      result.push(cleanBlock);
      continue;
    }

    // tool_result 保留必要字段
    if (block.type === "tool_result") {
      cleanBlock.tool_use_id = block.tool_use_id;
      cleanBlock.content = block.content;
      if (block.is_error) cleanBlock.is_error = block.is_error;
      result.push(cleanBlock);
      continue;
    }

    // 普通 text 只保留 text
    if (block.text !== undefined) cleanBlock.text = block.text;

    // cache_control 等其他扩展字段一律不复制
    result.push(cleanBlock);
  }
  return result;
}

//  整体清洗请求体
function sanitizeBody(rawBody) {
  const parsed = JSON.parse(rawBody);

  // 1. 顶层白名单
  const clean = {};
  for (const key of Object.keys(parsed)) {
    if (allowedTopLevel.has(key)) clean[key] = parsed[key];
  }

  // 2. 清洗 messages
  if (Array.isArray(clean.messages)) {
    const systemParts = [];
    const messages = [];

    for (const msg of clean.messages) {
      //  system 不能混在 messages 里,提取出来
      if (msg.role === "system") {
        if (typeof msg.content === "string") {
          systemParts.push(msg.content);
        } else if (Array.isArray(msg.content)) {
          for (const b of msg.content) {
            if (b.type === "text" && b.text) systemParts.push(b.text);
          }
        }
        continue;
      }

      // 清洗 content 里的 block
      if (Array.isArray(msg.content)) {
        msg.content = cleanBlocks(msg.content);
      }

      messages.push(msg);
    }

    clean.messages = messages;

    // 3. 提取出的 system 合并到顶层 system 字段
    if (systemParts.length > 0) {
      const existing = Array.isArray(clean.system)
        ? clean.system.map((b) => b.text).join("
")
        : clean.system || "";
      clean.system = existing ? existing + "
" + systemParts.join("
") : systemParts.join("
");
    }
  }

  return clean;
}

const server = http.createServer((req, res) => {
  // 健康检查:启动后可用 curl 确认
  if (req.url === "/__health") {
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("ok");
    return;
  }

  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    try {
      const rawBody = Buffer.concat(chunks).toString("utf-8");

      // 清洗请求体
      let payload = "";
      let cleanBody = null;
      if (rawBody) {
        cleanBody = sanitizeBody(rawBody);
        payload = JSON.stringify(cleanBody);
      }

      //  清洗请求头:删 anthropic-beta、重写 host
      const headers = { ...req.headers };
      delete headers["anthropic-beta"]; // 最大的 400 来源
      delete headers["host"];
      headers["host"] = new URL(UPSTREAM_URL).host;
      headers["content-length"] = Buffer.byteLength(payload);

      const upstreamReq = https.request(
        {
          method: "POST",
          hostname: new URL(UPSTREAM_URL).hostname,
          port: new URL(UPSTREAM_URL).port || 443,
          path: UPSTREAM_PATH,
          headers,
        },
        (upstreamRes) => {
          const respChunks = [];
          upstreamRes.on("data", (c) => respChunks.push(c));
          upstreamRes.on("end", () => {
            const responseBody = Buffer.concat(respChunks);
            if (upstreamRes.statusCode >= 400) {
              //  可观测性:至少把上游错误打到控制台
              console.error("上游返回错误:", responseBody.toString("utf-8"));
            }
            res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
            res.end(responseBody);
          });
        }
      );

      upstreamReq.on("error", (err) => {
        console.error("代理转发失败:", err.message);
        res.writeHead(502, { "content-type": "application/json" });
        res.end(JSON.stringify({ error: { message: err.message } }));
      });

      upstreamReq.end(payload);
    } catch (err) {
      console.error("清洗请求体失败:", err.message);
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: { message: err.message } }));
    }
  });
});

//  监听随机空闲端口,避免冲突
server.listen(0, "127.0.0.1", () => {
  const port = server.address().port;
  console.log(`代理已启动:http://127.0.0.1:${port}`);
  // 把端口写到临时文件,方便其他脚本读取
  const fs = require("fs");
  const os = require("os");
  const path = require("path");
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawgod-tokenrhythm-"));
  fs.writeFileSync(path.join(tmpDir, "port"), String(port));
});

4. 启动代理

bash 复制代码
cd ~/claude-deepseek-proxy
node proxy.js

终端会输出:

text 复制代码
代理已启动:http://127.0.0.1:PORT

先用健康检查确认就绪:

bash 复制代码
curl http://127.0.0.1:PORT/__health
# 返回 ok

5. 配置 Claude Code 指向本地代理

打开一个新终端,设置环境变量后启动 Claude Code:

bash 复制代码
export ANTHROPIC_BASE_URL="http://127.0.0.1:PORT"   # 替换成实际端口
export ANTHROPIC_AUTH_TOKEN="你的DeepSeek兼容端点API Key"

claude

此时 Claude Code 的所有请求都会先经过本地代理,清洗后再转发给 DeepSeek 兼容端点 。

6. 验证效果

你可以正常向 Claude Code 提问,例如:

text 复制代码
请写一个 Python 快速排序

如果之前报 400,现在应该能正常返回。也可以在代理控制台看到请求流。

7. 常见问题排查表

现象 可能原因 处理方式
代理启动失败 Node 版本过低 node -v 确认 18+
启动 Claude Code 后仍 400 端口配对错 / 没设环境变量 核对 ANTHROPIC_BASE_URL 是否指向实际端口
上游返回 400,代理控制台打印错误体 清洗逻辑未覆盖某些扩展字段 按 扩展白名单或 block 处理逻辑
上游返回 401/403 API Key 错误 检查 ANTHROPIC_AUTH_TOKEN
代理进程退出后 Claude Code 无法连接 没有后台运行 nohup node proxy.js & 或 systemd 守护

8. 进阶:后台运行与开机自启

8.1 nohup 后台运行

bash 复制代码
nohup node proxy.js > proxy.log 2>&1 &
echo $!   # 记录 PID

8.2 systemd 守护(可选,长期使用)

创建 /etc/systemd/system/claude-proxy.service

ini 复制代码
[Unit]
Description=Claude Code → DeepSeek Proxy
After=network.target

[Service]
ExecStart=/usr/bin/node /home/你的用户名/claude-deepseek-proxy/proxy.js
Restart=always
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

然后:

bash 复制代码
sudo systemctl daemon-reload
sudo systemctl enable --now claude-proxy
journalctl -u claude-proxy -f   # 查看日志

9. 总结

这套方案的核心不是"转发",而是 sanitize :顶层白名单、block 级规范化、system 归位、去 beta 头。只要你的 DeepSeek 兼容端点还认 Messages API,这套代理就可以稳定解决 400。后续如果上游协议变化,也只要调整 cleanBlocksallowedTopLevel 即可。


参考来源

相关推荐
Rei22487 小时前
使用llama.cpp的Qwen3.6-27B-Q8本地部署详解【Linux GPU】
linux·运维·llama
蛋先生DX20 小时前
大模型本地部署神器的瘦身进阶原理,就这两招?
llm·llama·ollama
爱学习的小白柏2 天前
【AI问数技术】多Agent协同架构:查询规划/SQL生成/洞察分析/报告生成
java·网络·人工智能·windows·sql·架构·llama
qy2016skq2 天前
OpenClaw 源码解读——入门与破局9 双插件协同:Quota Guard 负责“停“,Model Router 负责“绕“
langchain·prompt·aigc·embedding·ai编程·llama·agi
CHPCWWHSU2 天前
llama.cpp + DeepSeek-Harness 构建本地大模型推理与Agent智能体系统
llama
weipt3 天前
从ollama到llama.cpp
linux·服务器·llama
wulitoud4 天前
把 Claude、Codex、Gemini 的引擎换成本地模型:免费、离线、数据不出本机
人工智能·llama·claude·本地模型
goodlook01235 天前
LLaMa factory 大模型高效微调(三)
llama
福大大架构师每日一题7 天前
ollama v0.32.14正式发布:WebP 自动转码、Qwen 系统消息兼容升级与 llama.cpp、MLX 更新全解析
android·java·llama