← 返回
AI技术

Claude Code 使用本地大模型进行代码生成的实测复盘

✍️ zhirenhun 📅 2026/10/17 👁 144 阅读 ⏱ 29 分钟
Claude Code 使用本地大模型进行代码生成的实测复盘

我想要一个本地模型,因为它能给我带来隐私。而且如果我不得不把前沿模型作为主控节点使用,问题就在于成本:前沿模型很昂贵,而且订阅使用限制似乎一直在变小。

于是问题在于:昂贵模型能否负责思考,廉价模型负责输入,以及这样做是否能产出有价值结果。(在此测试中,“本地”模型是在租用的 RunPod 显卡上运行的,这并不能对 RunPod 保证隐私。它实际上是同一个 16.8 GB 的文件,可以在你拥有的任何 24 GB 显卡上运行。)

已经有很多文章讲述如何把 Claude Code 接入 Ollama 或本地端点。我找不到有人测量这种拆分是否真的比直接让本地模型完成全部工作更好。于是我让 Claude 在同一张显卡上分别采用两种方式搭建一个有隐藏测试的小项目,并用相同的脚本进行评分。这就是结果,包括那些不利于该想法的部分。

TL;DR. 编排(Claude 编写五个小任务提示词,根据规格检查每个结果,并将缺陷连同复现步骤发回): 78/78 和 77/78 在完整测试套件上得分,其中有 58 项测试对工作者是隐藏的。对同一模型使用单一大提示词:得分为 76、74 和 71(满分 78),其中 71 分的运行轮次最多。每一次单次运行都会出现同样的 bug(重放日志会再次写入),而两次编排运行也都存在这个问题,直到审查阶段被发现。工作者是在 llama.cpp 上运行的 Qwen3.8 27B Q4_K_M,使用一张 RTX PRO 4000 Blackwell(每小时 0.57 美元),上下文长度 32K,整个会话的 GPU 费用约为四十美分。审查是缺陷被消除的环节。这种设置无法说明单纯的拆分到底带来了多少收益。

设置

有两种角色。“带有本地模型的 Claude Code”在这两种不同设置中都会被使用,下面说明它们对应哪种设置。

第一个设置将 Claude Code 指向本地端点,使本地模型成为整个智能体。这不是这里的情况。这里的 Claude 仍然是 Claude。它负责规划、编写任务提示词、读取返回的代码、对其进行探测,并决定什么内容需要返回进行返工。本地模型是它通过一个小型 harness 调用的工作节点,每个任务对应一次无状态对话,配备四种工具和一个工作区文件夹。

编排器 Claude,在我的 Mac 上的 Claude Code 会话中
工作节点 Qwen3.8-27B-Uncensored-Q4_K_M.gguf (16.8 GB),llama.cpp llama-server
GPU 1x RTX PRO 4000 Blackwell, 24 GB, RunPod Secure, $0.57/hr
上下文 32,768 tokens,--jinja,flash attention 已开启,thinking 已关闭,temperature 0.2
连接方式 SSH 隧道从 Mac 到 pod 的端口 8080

我想要每小时 0.27 美元的 RTX A5000,这是最初实验轮次使用的卡型,但 RunPod 库存中没有,当时我询问时也没有。PRO 4000 是列表中的下一个选择。该卡的速度会有变化。模型文件和设置完全相同,但两轮之间的 harness 有所不同,因此两者不能简单比较卡片性能。

pod 上的服务器只有一行:

llama-server -m /workspace/gguf/Qwen3.8-27B-Uncensored-Q4_K_M.gguf \
  --host 127.0.0.1 --port 8080 -c 32768 -ngl 99 --jinja -fa on --alias local

它仅监听 localhost,我通过隧道访问,而不是使用 RunPod 的公共代理:

ssh -f -N -p <pod-ssh-port> -i ~/.runpod/ssh/runpodctl-ssh-key \
  -o ExitOnForwardFailure=yes -L 8091:127.0.0.1:8080 root@<pod-ip>
curl -s http://127.0.0.1:8091/health

预构建的 llama.cpp 来自我自己的 Cloudflare R2 存储桶。该模型在测量运行时来源于 Hugging Face,现已镜像到同一存储桶。下面的截图运行中,一个全新的 pod 从 R2 拉取后,用了 379 秒才开始服务,略超过六分钟,其中五分钟用于下载 15.7 GiB 的模型。如果你是从零开始,vLLM 文章 介绍了如何租用显卡以及 RunPod 的计费方式。

上述运行后得到的 pod 截图源于一个全新的 pod,因此其中时间和 token 数与表格数据不同。

测试项目

只有具备边界情况的玩具项目才有用。这个项目是 minikv,一个由五个相互依赖的模块组成的纯 Python 键值存储。

模块 功能 易错点
store.py 键、值、过期时间 ttl 取整,过期键仍留在字典中
txn.py 在存储之上的嵌套事务 事务中删除的键必须读取为缺失,即使存储中仍保留该键
protocol.py 解析并格式化文本命令 引用规则,错误顺序,KEYS 不接受参数
aof.py 仅追加的日志文件 被截断的最后一行会被忽略,空行会被保留
session.py 将其串联起来,在启动时重放日志 重放的行不应再次记录;事务会缓冲它们的行

规格仅一页。共有 78 个 unittest 测试:工作可见的 20 个(每个模块对应一个文件)以及工作不可见的 58 个,覆盖边界情况和跨模块场景。仅根据规格编写的参考解决方案能够通过全部 78/78 测试,说明这些测试是可满足的。评分器从测试文件中取得分母,而非从提交中取得,并且仅将五个规格模块复制到其沙盒中,因此工作区中的杂文件不会遮蔽任何内容。它还会以 -I 运行 Python,并统计运行器自身的结果,因而伪造的 unittest.py 或被篡改摘要无法获得任何好处。

工作器 harness

工作进程是一个 Python 脚本,worker.py,通过 OpenAI 风格 /v1/chat/completions 端点与模型进行一次对话,并为其提供四个工具:

它还具有一个重复防护机制:当同一序列(包含一到三轮的对话、工具调用及结果)连续出现四次时,会触发警告;若继续,则运行以 repeat-stop 停止。每次调用会打印一行 JSON(此处已精简;运行结束时的 done 也会携带模型的摘要):

{"turns": 6, "calls": 6, "bad_calls": 0, "completion_tokens": 693, "max_prompt_tokens": 2060, "total_prompt_tokens": 9455, "repeat_warnings": 0, "finish": "done", "secs": 55.1}

finish 是重要的字段。done 表示模型说它已经完成。max-turns 表示它用完了轮次,在我运行中通常意味着它在原地打转。

编排运行是如何工作的

五个任务,每个模块一个,按依赖顺序:

  1. 阶段 A,并行: store.py、protocol.py、aof.py。目前尚无依赖关系。三个任务针对一个 llama-server,其中最慢的任务用了 87 秒完成。
  2. 审查。 Claude 根据规格检查每个文件,并用 python3 -c 一行命令进行探测:规格中提到的边界情况、错误消息、往返测试。
  3. 返工 以修正任何错误,同时后续模块开始。
  4. 阶段 B: txn.py(需要 store),然后 session.py(需要所有内容)。
  5. 每个任务最多允许两次返工轮次。之后,现有内容将进行评分。

每个任务的提示词包括该模块的规格文本、它所使用的模块接口、测试命令,以及最关键的一行:“最终评分使用你看不见的额外测试,因此请严格遵循需求”。以下是经过裁剪的 store 提示词:

Write store.py (test file: test_store).
The workspace contains clock.py and a visible test file for your module. Plain Python 3 standard
library only. Run the test file with `python3 -m unittest <testfile without .py>` and fix failures.
The final grading uses extra tests you cannot see, so follow the requirements exactly, including
edge cases and error messages. Keep to the file named below; do not edit other files or the tests.

class Store(clock); attribute `clock` holds the clock. ...
- ttl(key) -> -2 if missing or expired, -1 if the key has no expiry, otherwise the remaining
  seconds rounded UP to an int (math.ceil(expire_at - now)).

返工提示词指出缺陷,给出简短的复现步骤,并引用规范。未给出原因的线索。此示例已裁剪:

Review of the current txn.py against the spec found that, inside a transaction, a key that was
deleted or has expired is still treated as present by some methods.

    t.delete("a")      # True, correct
    t.delete("a")      # returns True, required False (the key is no longer visible to this transaction)
    t.ttl("a")         # returns -1, required -2 (missing)

一次性条件在单个提示中获得了完整的规格,一次性处理所有五个模块,使用相同的工具、相同的可见测试以及 90 次回合的预算。

结果

所有运行在同一个 pod、同一个模型、同一个 harness、同一个评分器上。

运行 分数(58 隐藏 + 20 可见) 工作回合 完成 token 数 工作时间 最大提示 结束方式
编排,运行 1 78/78 84 12,594 672 s 7,832 所有任务完成
编排,运行 2 77/78 69 11,852 673 s 6,014 一次返工耗尽回合
一次性,运行 1 74/78 23 5,395 204 s 10,469 重复守卫停止它
一次性,运行 2 76/78 11 4,497 221 s 8,568 它说已完成
一次性,守卫关闭,90 回合 71/78 90 22,442 935 s 32,001 用完回合,上下文几乎已满

这些运行留下的工作区截图又进行了第二次评分。

编排运行的工作时间按任务求和。其中三个任务并行执行,因此墙钟时间更短。

头条是差距,77 对 78 相比 71 对 76,但更有用的数字在最大提示列。

没有编排提示超过 8K tokens。

一次性运行达到了 10K 和 8.5K,而长时间运行仅比 32K 限制少 800 tokens。

小任务让模型停留在上下文中仍能关注的部分。

代价是回合数。编排所需工作回合是一次性自行停止运行的三到八倍。

按小时计费的卡片上,这相当于几分钟额外时间:整个会话,包括两种条件和全部五次运行,共计 42 分钟 pod 时间,约合 0.40 美元。

审查发现

我愿意突出显示的回放 bug。每一次单次运行都交付了它:所有三次都失败于隐藏测试 test_replay_does_not_relog。编排后的运行也写入了它,两次都是如此。唯一的区别是 Claude 读取了 __init__,看到回放循环调用了 execute(),于是知道 execute() 会记录日志。只要有人在看,这个 bug 既容易写也容易发现。

模块 缺陷 运行 1 运行 2
protocol.py KEYS 未实现; parse("KEYS") 抛出 "unknown command" 一次重构后修复 一次重构后修复
txn.py 已删除或过期的键仍被计为已找到:第二次 delete 返回 True, ttl 返回 -1,过期的键仍留在 keys() 中 一次重构后修复 一次重构后修复
txn.py 在外部事务中删除的键在嵌套事务内仍然可见 未出现 在重构轮次耗尽时仍然存在
session.py 启动时重放日志会把每一行再写一次,导致文件在每次重启时大小翻倍 第二轮修复 一次重构后修复
session.py 内部的 ROLLBACK 保留了该事务的行,于是回滚的 SET 也被写入日志 第二轮修复 未出现

同样情况也出现在 txn.py 中。模型在两次编排运行中都写出了同样的 "deleted means found" bug,而且此实验的第一轮(即前一天)也中了这个 bug。这是一个稳定的盲点。

就像任何模型一样,我不会单独相信它。我需要检查它的输出。它会告诉你已经完成,而实际上回放 bug 仍然存在,所以有效的做法是让明确定义的模块输出结果,由其他部分负责读取。

评审遗漏的内容

第二次编排运行丢失了一项测试,这算是公平的失分。规范规定,当参数为空或包含空格或双引号时需要对其加引号。工作进程还对包含反斜杠的值进行了引用,于是 format_command 产生了 SET k "a\\b",而隐藏测试期望的是 SET k a\b。两种形式解析回去都是同一条命令,因此往返检查无论哪种方式都能通过。要发现这个问题,需要逐字检查 format_command 的输出,而评审没有这样做。

并且,运行 2 中的嵌套事务漏洞在其重做轮次用尽时仍然存在(只有第二轮涉及此问题)。但 58 项隐藏测试均未触发该情况,因而记分牌上未受任何影响。评审发现了测试无法看到的真实缺陷,而测试发现了评审遗漏的问题。单靠任何一方都无法进行完整检查。

更多轮次让一次性尝试变得更糟

此实验第一轮明显的反对意见在于预算:编排运行消耗了 88 次轮次,而一次性运行仅用了 40 次。因此这一次,一次性运行获得了 90 次轮次,且有一轮关闭了重复防护,以便能够用完所有轮次。

它用完了所有轮次,在五次运行中得分最低,为 71/78。结束时它的提示词长度为 32,001 个 token,距离 32,768 的上限仅差 800,这是因为每轮都会把工具结果追加到对话中。较短的运行分别失败了两项和四项隐藏测试。这一次则失败了七项,其中包括较短运行能够通过的事务日志测试。更多空间,同一模型,代码更差。开启防护的两次运行分别在 23 次和 11 次轮次时自行停止,表现更好。

我遇到的坑

命令过滤器并非沙盒。 在一次会话的重做过程中,工作进程在二十次轮次中有十二次运行了同一检查脚本,只是每次使用不同的文件名:/tmp/aof1.txt、/tmp/aof2.txt,直至 /tmp/aof12.txt。这实际上包含两个问题。绝对路径位于 python3 -c 字符串内部,过滤器不会解析它,于是它在我的 Mac 上向 /tmp 写入了十一个文件。此外,由于每次轮次使用了不同的文件名,两次轮次永不相同,重复防护从未触发。工作进程代码在你的机器上以你的身份运行。请给它一个与实际无关的工作空间。

finish: done 是模型的判断。 每个任务在结束时带有 done 但仍存在缺陷,都会返回一份乐观的摘要,声称所有测试通过或一切正确。它们确实通过了——只是可见的那些测试。

max-turns 意味着读取代码。 三次工作器调用在编排运行中耗尽了轮次。其中一次是原始会话任务,留下的代码在可见测试中通过,但包含两个缺陷。另外两次是返工,但未能修复它们被指派要修复的问题。状态告诉你它停止了,而不是它留下的内容。

第二次返工需要说明位置。 第一次会话返工(缺陷、复现和规格)根本没有改动。第二次命名了方法并描述了修复,用了七轮完成。这表明编排器承担了更多工作,这与 “Claude only reviews” 的说法相悖。这在下面的诚实限制中有体现。

审查预算。 工作器的数据在表中。Claude 的则不然:读取五个模块、编写探针以及编写返工提示词是真实的编排器时间和 token,且上面未计入。

诚实限制

  • 每个条件只有两次运行仍然不足。 模型并非确定性的:使用相同的提示词,一次性运行的得分 76、74 和 71。差距在每对之间都存在,但我不愿为它添加小数位。
  • 第二次运行的审查并非完全盲测。 Claude 在审查第一次运行时未看到任何隐藏测试。在第二次运行之前,对一次性运行的评分已经显示出隐藏测试的简短失败笔记。第二次运行的审查采用了与第一次运行相同的探针和规格阅读,但笔记不可能被忽略。
  • 规格很精确,审查依赖它。 审阅者阅读更模糊的规格时会发现的问题更少。胜利的一部分在于精确的规格,当工作器是人时同样适用。
  • 有些返工提示词超出了规格。 在运行 1 的第二次会话返工中,描述了修复方案(重放标志,每事务一个缓冲区),而不仅仅是缺陷。
  • 上下文从未成为编排的限制因素。 其最大的提示词不到 8K。此项目未展示分解是否能救救无法容纳 32K 的代码库,仅表明它让模型远离该限制。
  • 编排器的成本未被计算。 Claude 的 token 是昂贵的,上面的比较仅计算了工作器的成本。

快速参考

项目 值
模型 Qwen3.8-27B-Uncensored-Q4_K_M.gguf, 16,810,714,496 字节
显卡 24 GB 在 32K 上下文时已足够(这里使用 RTX PRO 4000 Blackwell,第一轮使用 RTX A5000)
服务器 llama-server -c 32768 -ngl 99 --jinja -fa on --alias local
工作器设置 temperature 0.2, enable_thinking: false, max_tokens 4096
有效的任务规模 一个模块,20 到 150 行,其规格文本,其接口,其测试命令
返工 缺陷,复现,规格文本;最多两轮
信任 finish: done 和通过可见测试毫无意义,直到有人阅读代码

这个廉价模型足以胜任像这样的一些小型、明确指定的任务。我会相信它去操作我的数据库吗?大概不会。但我可以用它来处理诸如 Python 或 Bash 脚本之类的自动化任务。就像使用任何模型一样,需要检查输出。不要盲目运行它给出的任何内容。

——

广告 广告
🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

ai claude llm python