mcpField
R
runpod-vllm-claude-code

runpod-vllm-claude-code

github.com/marks97云与 DevOps0874 次安装4 周前更新
代码仓库

关于 runpod-vllm-claude-code

此 Claude Code skill 用于在 RunPod 上运行 vLLM 模型,并通过内置协议转换代理连接 Claude Code。它涵盖模型与 GPU 检查、Pod 配置、安全隔离测试及会话恢复。

主要功能

  1. 01检查模型大小与 GPU 显存需求
  2. 02创建并验证 RunPod Pod
  3. 03转换 Anthropic Messages API 与 vLLM 协议
  4. 04配置隔离的 Claude Code 调用
  5. 05更换 Pod 并恢复会话

适用场景

  1. 01部署前估算 GPU 需求
  2. 02让 Claude Code 调用 RunPod 模型
  3. 03排查工具调用缺失或上下文限制

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/marks97/runpod-vllm-claude-code --skill runpod-vllm-claude-code

安装后在 Claude Code 中输入/runpod-vllm-claude-code确认已加载。

附带文件references/transcript-surgery.mdscripts/anthropic-openai-proxy.js

详细介绍

RunPod vLLM + Claude Code 桥接器

这里要同时解决两个问题,值得明确区分:

  1. 在 RunPod 上运行模型。 很简单——选择 GPU,启动 vLLM 容器,等待健康检查通过。
  2. 让 Claude Code 真正与模型通信。 并不简单——Claude Code 使用 Anthropic 的 Messages API(/v1/messages、内容块、SSE 事件)。 vLLM 使用 OpenAI 的 /v1/chat/completions。默认情况下,没有任何东西能桥接这两者。 ANTHROPIC_BASE_URL 可以让 Claude Code 指向任何实现了 Messages API 格式的服务——因此,中间的轻量级转换代理才能让“让 Claude Code 指向我的 RunPod 机器”真正可行。scripts/anthropic-openai-proxy.js 就是这个代理,并且已经修复了两种协议不匹配问题,否则你会白白花上一小时反复重试(见下文的注意事项)。请直接使用,不要从头重新实现。

这类基础设施本质上是探索性的、一次性的——GPU Pod 一创建,每小时就会产生实际费用。创建之前,请告知用户要使用哪种 GPU 以及大致的每小时费用,除非他们已经指定了机器并接受了费用。

步骤 0:先确认模型的真实数据,再猜测硬件需求

不要相信针对某个模型名称的通用“显存需求”博客文章——GPU 租赁领域的 SEO 内容充斥着内容农场网站(例如“willitrunai.com”“canitrun.dev”“orcarouter.ai”),它们会针对任何模型名称自动生成看似合理的规格页面,包括那些几乎还不存在的模型。它们给出的说法彼此矛盾,却都言之凿凿。

请改用 HuggingFace API 获取准确数据:

# Real param count / architecture (don't trust the repo name's number):
curl -s "https://huggingface.co/<org>/<model>/raw/main/config.json"

# Real total weight size, which is what actually has to fit in VRAM:
curl -s "https://huggingface.co/<org>/<model>/raw/main/model.safetensors.index.json" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['metadata']['total_size'] / 1e9, 'GB')"

bf16 权重大小(GB)≈ 参数量(B)× 2。规划 GPU 时,应确保权重之外至少留有约 20-30GB 余量,用于 KV 缓存和激活值——如果模型是稠密 Transformer,就要留更多(完整 KV 缓存会随上下文长度增长);如果是混合/线性注意力架构(Mamba/GDN 层会保留固定大小的状态,因此长上下文的成本远低于同规模的稠密模型),则可以少留一些。

如果模型采用已知的问题架构(非常新的混合注意力机制、新近发布的模型),在确定 GPU 之前,请搜索 "<architecture class name>" vllm github issues——例如 Qwen3_5ForConditionalGeneration vllm issues——因为全新架构有时会在 vLLM 中存在尚未修复的正确性问题(输出乱码、张量格式不匹配)。请向用户说明这一风险,不要默默期待它能正常工作。

步骤 1:选择并检查 GPU 的可用性/价格(不要猜测类型字符串)

RunPod 的 REST API 在 /v1/gputypes 和 /v1/datacenters 返回 404——尽管看起来很合理,但这些并不是真实端点。请改用 GraphQL API:

curl -s -X POST https://api.runpod.io/graphql \
  -H "Authorization: Bearer $RUNPOD_API_KEY" -H "Content-Type: application/json" \
  -d '{"query":"query { gpuTypes(input: {id: \"NVIDIA H100 80GB HBM3\"}) { id lowestPrice(input: {gpuCount: 1}) { uninterruptablePrice stockStatus } } }"}'

有效的 gpuTypeIds 包括 "NVIDIA H100 80GB HBM3"(SXM)、"NVIDIA H100 PCIe"、 "NVIDIA H100 NVL"、"NVIDIA A100 80GB PCIe"、"NVIDIA A100-SXM4-80GB"——如果不确定,可通过 query { gpuTypes { id displayName memoryInGb } } 获取完整列表。创建 Pod 时传入一个可接受类型的短列表(见下文),这样如果首选型号缺货,RunPod 就会自动改用其他型号,而不会导致整个资源开通流程失败。

API 密钥的位置:先检查环境中的 $RUNPOD_API_KEY;如果未设置,请导出该变量(在 RunPod 控制台的 Settings -> API Keys 下创建密钥)。

步骤 2:创建 pod

curl -s -X POST https://rest.runpod.io/v1/pods \
  -H "Authorization: Bearer $RUNPOD_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "vllm-<model-slug>-test",
    "gpuTypeIds": ["NVIDIA H100 PCIe", "NVIDIA H100 80GB HBM3", "NVIDIA H100 NVL"],
    "imageName": "vllm/vllm-openai:latest",
    "containerDiskInGb": <weights_GB * 1.5, minimum 80>,
    "ports": ["8000/http"],
    "dockerStartCmd": [
      "--model", "<org>/<model>",
      "--host", "0.0.0.0", "--port", "8000",
      "--trust-remote-code",
      "--enforce-eager",
      "--gpu-memory-utilization", "0.90",
      "--max-model-len", "65536",
      "--served-model-name", "<served-name>",
      "--enable-auto-tool-choice",
      "--tool-call-parser", "hermes"
    ],
    "env": { "HF_HOME": "/root/.cache/huggingface" }
  }'

标志说明:

  • --max-model-len:尽量设大,越大越好。 65536 听起来已经很宽裕,但一次真实的多小时编码会话(Git 操作、海量工具输出)达到了约 127K 个令牌,并触及上限——更糟的是,/compact 本身也需要输入空间,以及为摘要预留足够的输出空间。因此,如果对话已经接近上限,compact 会在你最需要它的时候因相同的溢出错误而失败。优先使用模型原生的上下文长度——在步骤 0 的 HF config.json 中检查 max_position_embeddings——并在 GPU 内存允许时使用该值(80GB H100 上的 262144 令牌窗口对于约 28B 的模型来说很宽裕)。从 65536 直接提升到模型的实际最大值,避免了再次遇到同样的问题。
  • --tool-call-parser:选择与模型系列相匹配的解析器(对于大多数微调模型,hermes 是合理的默认值;对于 Qwen3.x-coder 风格的模型,使用 qwen3_coder + --reasoning-parser qwen3)。解析器选错不会明显报错——模型只会一直不生成 tool_calls,而只输出文本。如果 Claude Code 没有调用工具,请先检查解析器。
  • --enforce-eager:首次冒烟测试时保留此选项——它可以避免 CUDA 图编译耗时,以及检查模型是否能正常响应时可能出现的相关不稳定问题。确认功能正常并且开始关注速度后,再移除它。
  • 先轮询 GET /v1/pods,如果已有名称匹配且状态为 RUNNING 的 pod,就复用它,而不是创建重复实例。

vLLM URL 始终为 https://<podId>-8000.proxy.runpod.net。轮询 /health(预期会先返回 404,然后在拉取镜像和下载权重期间返回一串 502,最后返回 200)——对于 50GB 以上的模型,请预留几分钟。将轮询循环置于后台运行(run_in_background: true),不要让它阻塞对话。

服务健康后,在使用 Claude Code 之前,先用原始 curl 请求进行基本检查:

curl -s "$VLLM_URL/v1/chat/completions" -H "Content-Type: application/json" -d '{
  "model": "<served-name>", "messages": [{"role":"user","content":"What is 2+2? One word."}],
  "max_tokens": 20, "temperature": 0
}'

这里输出连贯,说明模型与 vLLM 的组合正常,然后才需要叠加 Claude Code 层本身的复杂性。如果这一步输出的是乱码或重复令牌,问题出在模型或 vLLM 版本,而不是桥接环节——暂时不要继续步骤 3。

步骤 3:将 Claude Code 接入其中

将 scripts/anthropic-openai-proxy.js 复制到当前工作目录旁边并运行:

VLLM_URL="https://<podId>-8000.proxy.runpod.net" PORT=<free-port> SERVED_MODEL="<served-name>" \
  node anthropic-openai-proxy.js

先选择一个确实空闲的本地端口——ss -ltn | grep :<port>——这台机器上有许多 Claude 会话并行运行,像 8787 这样的常用端口往往已被其他进程占用。不要想当然地认为端口空闲。

然后通过该代理运行一个隔离的 Claude Code 调用——所谓隔离,是指覆盖设置未生效时,调用绝不能回退到用户真实的、按量计费的 Anthropic 账户:

env -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN \
  ANTHROPIC_BASE_URL="http://localhost:<port>" \
  ANTHROPIC_API_KEY="sk-local-dummy-key" \
  CLAUDE_CODE_MAX_CONTEXT_TOKENS=<max-model-len used above> \
  claude --bare -p "<test task>" \
  --dangerously-skip-permissions \
  --model <served-name> \
  --output-format text
  • --bare 是安全性的关键:它强制身份验证严格使用 ANTHROPIC_API_KEY/apiKeyHelper,绝不使用 OAuth 或密钥链。没有它,配置错误的覆盖设置可能会悄悄回退到用户真实的已登录账户并产生费用;--bare 从结构上杜绝了这种情况,而不是依赖环境变量覆盖设置万无一失。
  • CLAUDE_CODE_MAX_CONTEXT_TOKENS:如果没有设置此项,Claude Code 遇到无法识别的 --model 名称时,会假定上下文窗口为 200k,以此进行自身的预算计算,导致它请求的 max_tokens 远超 vLLM 服务的 --max-model-len 所允许的值。将其设为传给 vLLM 的相同数值。
  • CLAUDE_CODE_MAX_OUTPUT_TOKENS:这是另一个容易忽略的独立上限。当 /compact 必须总结非常长的对话时,Claude Code 会计算所需的输出预算大小,并可能触及内部硬限制(“Claude's response exceeded the 128000 output token maximum”),在联系后端之前就由客户端拒绝请求——代理日志中没有记录,也就无从远程排查。将其设为合理的数值(例如 8192),并同时设置 CLAUDE_CODE_MAX_CONTEXT_TOKENS。
  • 运行期间请留意代理自身的标准输出日志——它会打印每轮收到的消息数和工具数,这是判断请求是否真正到达远程模型,还是在桥接过程中失败的最快方法。
  • 执行实际编码任务时,请给它 2–3 分钟或更久;运行 --dangerously-skip-permissions -p 后 180 秒仍未返回是正常现象,不代表卡住了——让它在后台继续运行,不要就此认定失败。

会话中途:在对话进行时更换 Pod

如果实际会话的上下文超出了启动时设置的窗口,无需放弃对话即可解决问题——聊天记录保存在 Claude Code 本地的会话记录中,而不在 Pod 上。Pod 可以随时弃用和替换:

  1. 终止旧 Pod,创建一个使用更大 --max-model-len 的新 Pod(使用相同镜像和相同的 served-model-name)。
  2. 按照步骤 2 检查新 Pod 的 /health。
  3. 终止正在运行的代理进程并重启,使用相同的 PORT,将 VLLM_URL 指向新 Pod。Claude Code 端无需更改任何设置——ANTHROPIC_BASE_URL 和端口都保持不变。
  4. 让用户恢复会话:运行 claude --bare --resume <session-id> ...,并将 CLAUDE_CODE_MAX_CONTEXT_TOKENS 调高至与新窗口相匹配的值。

如果有请求正在处理中时重启代理,该请求会报错(Claude Code 会显示 API 错误,并且通常会自动重试)。如果用户正处于一轮对话中,请提前告知,不要悄悄重启。

已知问题(已在捆绑的代理中修复——如需进一步调试,请留意)

  1. max_tokens 溢出。 Claude Code 默认请求的 max_tokens 可能约为 32000,与实际的 --max-model-len 无关。如果提示词加上 max_tokens 超出服务的上下文窗口,vLLM 就会在每一轮都返回 400 错误,而 Claude Code 只会不断重试,白白消耗 GPU 时间。解决方法:将转发的 max_tokens 限制在合理范围内(捆绑的代理默认值为 4096,可通过 MAX_TOKENS_CAP 覆盖)。
  2. 交错出现的 role:"system" 消息。 Claude Code 会在对话中途将 <system-reminder> 内容作为独立的 role:"system" 回合发送(不只是放在顶层的 system 字段中)。Anthropic 自有 API 可以正常处理此类消息,但 vLLM 的聊天模板强制要求“system 必须是第一条消息”,否则就会返回 400 错误。解决方法:在转发前,将任何非首位的 system 角色消息合并到 user 消息中。
  3. 匹配到当前 shell 的 pkill -f <pattern> 会杀掉当前命令(退出码 144)。 杀掉卡住的嵌套 claude 进程时,使用 ps aux | grep 找到它的确切 PID,然后运行 kill <pid>;不要在自身调用字符串也包含 "claude" 的 shell 中运行 pkill -f "claude"。这并非 claude 特有的问题:pkill -f "node proxy.js" 也会匹配到自身,因为 shell 包装器的命令行中也包含相同字符串。始终使用确切的 PID 来结束进程,不要使用可能匹配到当前运行命令的模式。
  4. 单个耗时较长的阻塞式后端响应可能超出 RunPod 边缘代理的超时时间。 常规 RunPod Pods 没有通过 API 暴露可配置的超时设置(RunPod API 中虽有 executionTimeoutMs/idleTimeout,但仅适用于 Serverless,不适用于 Pods),因此,必须先推理 100K+ 个 token 才能作答的 /compact 可能会悄然超时,代理收到的也会是 HTML 网关错误页面,而非 JSON(Unexpected token '<', "<!DOCTYPE")。解决方法:始终从 vLLM 请求 stream: true(加上 stream_options: {include_usage: true}),并在数据块到达时立即转发——只要字节持续流动,空闲超时就不会触发,无论生成需要多久。捆绑的代理已经这样处理。
  5. 即使 completion_tokens 数值正确且实际发生了生成,vLLM 也可能在最终的流式 usage 数据块中报告 prompt_tokens: 0(在工具调用密集的回合后曾观察到)。由于 Claude Code 会使用报告的输入 token 数来确定自身自动压缩的时机,错误的 0 可能让它误以为还有很大余量——悄无声息地引发本指南旨在避免的同类溢出问题。只要后端返回的数值为假值,捆绑的代理就会回退到粗略的字符数估算(fallbackInputTokens);不要删掉这部分逻辑。

从外部查看实时会话的上下文使用情况

你可以读取会话记录中最新助手回合的 usage,在不干扰正在运行的 --resume 会话的情况下监控其上下文占用程度——当用户希望在工作期间定期了解“当前占用了百分之多少”时,这很有用。

# --bare sessions land under ~/.claude/projects/<cwd-slug>/<session-id>.jsonl —
# NOT the account-specific ~/.claude-<acct>/ dirs. find it with:
find ~/.claude -iname "*<session-id>*"

python3 -c "
import json
with open('<transcript.jsonl>') as f:
    lines = f.readlines()
for line in reversed(lines):
    line = line.strip()
    if not line: continue
    try: o = json.loads(line)
    except: continue
    if o.get('type') == 'assistant':
        usage = o.get('message', {}).get('usage')
        if usage and usage.get('input_tokens'):
            print(o['timestamp'], usage['input_tokens'], usage['input_tokens']/<max_model_len>*100)
            break
"

请从文件末尾开始反向扫描,而不要使用固定的 tail -N 窗口——某个回合中的工具往返次数较多时,包含最新 usage 的行可能会被推得很远,小窗口就会漏掉它,让人误以为什么都没发生。

从错误的 /compact 中恢复

如果 /compact 生成的摘要丢失了太多信息,导致模型忘记了任务(或者更糟,因为困惑而采取破坏性操作),压缩前的原始消息仍保存在 transcript 文件中——compact 只是告诉后续轮次使用摘要,而不是重放此前的全部内容。这意味着你可以精准移除 compact,并从它发生之前继续。连续两次都弄错真的很容易(我这里就发生了)——尝试之前请先阅读 references/transcript-surgery.md,不要凭直觉操作。

步骤 4:清理——“sin dejar rastro”意味着验证,而非假定

用户说要杀掉/删除所有内容时,意味着要确认它们确实不存在,而不只是发出删除请求:

# kill exact PIDs (not pkill -f, see gotcha 3)
ps aux | grep -E "anthropic-openai-proxy|claude --bare" | grep -v grep
kill <pid> <pid>

# terminate the pod
curl -s -X DELETE "https://rest.runpod.io/v1/pods/<podId>" -H "Authorization: Bearer $RUNPOD_API_KEY" -w "\n%{http_code}\n"

# confirm nothing is left running or costing money
curl -s https://rest.runpod.io/v1/pods -H "Authorization: Bearer $RUNPOD_API_KEY"          # expect []
curl -s https://rest.runpod.io/v1/networkvolumes -H "Authorization: Bearer $RUNPOD_API_KEY" # expect [] unless one was deliberately kept
curl -s -X POST https://api.runpod.io/graphql -H "Authorization: Bearer $RUNPOD_API_KEY" \
  -H "Content-Type: application/json" -d '{"query":"query { myself { clientBalance currentSpendPerHr } }"}'
# currentSpendPerHr should read 0

# delete scratch files (proxy copy, test project, logs) from wherever they were put —
# the session scratchpad directory, never the repo
rm -rf <scratch-dir>

网络卷(持久磁盘)是独立于 pod 的计费资源——终止 pod 不会删除附加的网络卷。如果本次会话新建了网络卷(而非使用已有卷),请显式删除: DELETE /v1/networkvolumes/<id>。

请使用实际数值(余额、currentSpendPerHr、空的 pod/卷列表)报告最终状态,而不只是说“完成了”——这才是真正用证据证明“sin dejar rastro”的意思。

评论

支持 Markdown