说实话,Claude Code 是真好用,但也是真的贵。我连续两周把日常开发全挂在它上面,先后经历了订阅配额提前见底、API 账单看着肉疼、以及“代码全在别人服务器上”的隐隐不安,最后决定给自己造一个本地优先的多引擎平替。这篇文章不是软件发布稿,也不打算做成某个项目的宣传文案,而是把从发现问题到搭出可用工具的全过程复盘一遍:它为什么贵、我的选型逻辑、核心骨架怎么搭、多引擎怎么接、以及拦路最多的几个坑。如果你也在被订阅费或者 token 账单困扰,又不想放弃 Claude Code 那种 Agent 式编程体验,这篇应该正好是你需要的。
1. 账单让我重新审视“贵”这件事
1.1 订阅和 API 的双重消耗
Claude Code 的付费方式,说到底分两条路。一条是订阅,Pro 一个月 20 美元,Max 有 100 美元和 200 美元两档;另一条是直接充 API 用,按 token 计费。问题就出在“Agent 型工作负载”的 token 消耗方式上。普通代码补全一次可能就几百 token,但要让它做一个“全库搜索然后重构公共函数”的任务,它会反复读文件、写文件、跑测试、读报错,一轮下来经常是几万 token 起步。Sonnet 的输出价格是每百万 token 15 美元,Opus 是 75 美元,单价看着好像不算离谱,可一旦让 Agent 连续干活,一天烧掉几十美元是真实发生过的事情,不是夸张。
订阅那一路也没好到哪去。Pro 档看起来 20 美元很便宜,但实际用起来有明显的配额限制,深度使用一两天就能碰到顶,限速之后的体验非常难受。Max 档解决了配额问题,但 200 美元一个月不是所有人都愿意掏。我身边不少朋友是“订阅也买了、API 也充了”,结果月底一看开销,比自己的云服务器账单还高。这还没把多开几个会话、团队里每人各来一发的场景算进去——那种场景下成本是指数级往上翻的。
1.2 比钱更重的隐性成本
比钱更重的其实是隐性成本。第一,代码数据全部要上传到云端,公司项目直接有合规红线,连讨论的余地都没有;个人项目虽然没人管,但把私有仓库完整喂给外部服务这件事,我每次做心里都打鼓。第二,行为不透明,官方客户端对上下文的处理是个黑盒,模型到底读了哪几个文件、为什么得出某个结论,你很难回溯。我一开始觉得“反正它能干活就行”,直到有一次它改错了文件,我花了一晚上排查才发现它把两个同名文件搞混了。这种时候,你就特别想要一个本地优先、行为可控的方案。
所以我对“贵”的定义,不只是钱的问题,而是为了获得 Agent 编程体验,我被迫放弃了本地数据主权、可观测性和故障可回溯性。本地优先的平替方案,目标不是把同样的参数规模的模型搬到本机跑——那在消费级显卡上不现实——而是把工作流默认放在本地,把代码分析、文件读写、命令执行这些环节留在本地,模型本身做成可插拔的多引擎。本地小模型干得快、成本低,云端大模型用来兜底复杂任务。这样既保住隐私,也保住钱包。
2. 选型逻辑:为什么要“多引擎”而不是“换个工具”
2.1 现成方案差了一口气
定下要自建之后,我第一反应是去看现成的开源方案。老实说,现在市面上类似 Claude Code 的开源 Agent 工具已经不少,直接拿来改也不是不行。但我很快发现两个问题:一是不少项目已经把模型供应商写死了,或者只支持 OpenAI 接口,换个偏门本地模型就要改源码;二是它们的权限确认、会话管理和文件工具集都按别人的习惯设计,我要的“本地优先 + 命令白名单 + 成本统计”这三样,没有哪个开箱即用。我评估了一下改造成本,最后还是决定自己搭一个极简的套壳,把核心放在调度层和 Provider 层。
所谓“多引擎”,本质上是把模型当成可以随时拔插的组件,而不是跟某个厂商绑死。今天本地跑一个 Qwen2.5 Coder,明天想换 DeepSeek-R1 蒸馏版,后天想把重活发给云端大模型,都只改配置不改逻辑。这样设计的原因很实际:模型迭代太快了,今天的最优解三个月后可能就过时,平替工具本身的价值在于提供稳定的工作流,而不是绑定某一个模型。把引擎抽象出来,等于给工具上了保险。
2.2 架构分层与需求边界
我采用的架构分三层。最上层是 CLI 交互层,负责接收用户输入、展示进度条和彩色 diff;中间是 Agent 调度层,维护会话上下文、解析模型输出、调用工具、把执行结果回填给模型;最底层是 Provider 适配层,每个引擎实现同一个接口,Ollama 一个适配器、OpenAI 兼容 API 一个适配器。三层各管各的,哪一层出问题都不会连坐。以后就算遇到没见过的模型服务,只要它兼容 OpenAI 格式,十分钟就能接进来。
需求清单我也写得很克制,尽量避免一上来就陷入大工程:
- 支持 Ollama 本地模型和 OpenAI 兼容 API,引擎可配置可切换
- Agent 核心循环能读文件、写文件、列目录、跑命令
- 命令执行带安全确认,危险命令强制审批
- 会话可持久化,关掉终端后能接着聊
- 每次请求的 token 和花费自动记录,方便本地统计
技术栈我选了 TypeScript + Node.js。原因有几点:跨平台能力好;CLI 生态成熟,commander、ink 这些库直接拿来就能画出好看的终端界面;后续如果要做 VS Code 插件,Node 路径是现成的。Python 我也熟,但做终端交互工具,Node 确实更顺手。另外,Node 18 之后原生支持 fetch,去调各种模型 API 连第三方依赖都不用加,这对想要少装包的 CLI 工具来说非常友好。
3. 核心骨架搭建:Agent 循环、工具集与上下文控制
3.1 先把 Agent 循环跑通
很多人一上来就研究模型选哪个、提示词怎么写,但我建议先把核心循环写出来。Agent 循环说白了就是一个 while:用户给指令,我们把消息列表发给模型,模型返回两种结果之一——要么是最终回答,要么是工具调用请求;如果是后者,我们执行工具,把结果作为一条 tool 消息回填,然后再次请求模型,直到它给出最终回答。这个循环是整个平替的中枢,其他所有功能都是挂在它上面的枝节。
消息结构我直接复用 OpenAI 的 role 体系:system、user、assistant、tool。维护一个 messages 数组,每轮就是 push 新消息然后发给 Provider。这里有一个容易踩的坑:每次请求不是只发当轮对话,而是要把完整的消息历史发过去,模型才能记住前面发生了什么。但完整历史又会带来上下文膨胀,这个矛盾我下一节专门说。核心循环的简化版代码如下,去掉了错误处理和日志,主干是完整的:
while (true) { const reply = await provider.chat(messages); if (reply.toolCalls.length === 0) { console.log(reply.content); break; } messages.push({ role: "assistant", content: reply.content, toolCalls: reply.toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments }, })), }); const results = []; for (const tc of reply.toolCalls) { const tool = tools[tc.name]; if (!tool) { results.push({ tool_call_id: tc.id, role: "tool", content: "未知工具: " + tc.name, }); continue; } let args; try { args = JSON.parse(stripJsonFence(tc.arguments)); } catch (e) { results.push({ tool_call_id: tc.id, role: "tool", content: "参数解析失败: " + tc.arguments, }); continue; } const ok = await confirm(tc.name, args); if (!ok) { results.push({ tool_call_id: tc.id, role: "tool", content: "用户拒绝了此次操作", }); continue; } const output = await tool.execute(args); results.push({ tool_call_id: tc.id, role: "tool", content: typeof output === "string" ? output : JSON.stringify(output), }); } messages.push(...results); }注意一个细节:stripJsonFence是我自己写的小函数。本地模型经常不老实,tool call 的参数会在 JSON 外面包一层 markdown 的 ```json 代码块,所以解析前要先去掉这些装饰。
3.2 工具集设计与安全确认
工具集我第一版只做了五个:list_files、read_file、write_file、run_command、search_code。为什么是这几个?因为它们覆盖了日常编码中最常见的闭环:先看目录结构,再读具体文件,定位问题后修改代码,然后跑命令验证,遇到不确定的引用就搜索一下。Claude Code 里我也是这五个用得最频繁,其他高级工具都是锦上添花。
安全确认我用了一个三层策略。第一层是自动放行白名单,比如list_files、read_file、search_code这些只读操作,直接执行,不打断人。第二层是危险命令黑名单,比如rm -rf、git push --force、drop database这类不可逆操作,必须二次确认,而且确认界面会把完整命令展开显示,免得模型拼接参数时偷梁换柱。第三层是未知状态,也就是不在这两类里的命令,默认也确认一次,但可以按目录配置例外。这套策略是从一个真实教训里改出来的:最开始所有工具调用都问一遍,十分钟能弹几十次确认框,我点着点着就麻木了,反而更容易放行真正危险的命令。
工具执行还有一个容易忽略的问题:命令输出可能很长。run_command调用npm test或者git diff,输出动辄上千行,如果全部塞回给模型,上下文很快就会爆。我在工具层做了一个截断策略:默认只保留 stdout 的前 200 行和最后 100 行,中间用... [已截断 X 行]代替。模型真需要看完整输出时,它会自己再调一次命令加| head -n 300来限定范围,这就把上下文消耗控制住了。
3.3 上下文成本与压缩
上下文是本方案里最要命的资源。本地小模型的窗口通常在 32K 到 128K,看着不小,但一次read_file读入一个 800 行的文件,可能就是 15K token,加上系统提示词和历史消息,三轮对话就能把一个 128K 窗口塞满。所以我在架构里强制做三件事。
第一,默认不自动读文件。模型只能看到工作区的文件树和文件名摘要,它想读哪个文件必须显式调用read_file。第二,单文件读取有行数上限,默认 300 行,超过部分按行号区间分段读取。模型要分析一个大文件,它会自己规划分三次读,而不是一次吞进 2000 行。第三,加入简单的上下文压缩:当消息历史的总 token 数超过窗口的 60% 时,把最靠前的若干轮对话用一段摘要替代,摘要由当前模型生成,然后作为一条 system 级别的概要消息插入历史。这个方案牺牲了一部分历史细节,但能保证长会话不中断。
3.4 系统提示词与成本统计
系统提示词对 Agent 行为的影响,比很多人想象中大得多。同一个模型,你给我一段让它“直接给出答案”的提示词,和一段让它“先用工具确认再回答”的提示词,产出质量完全是两回事。我在第一版里用的是通用提示词,结果模型经常凭记忆回答代码问题,不去读文件,改出来的东西自然是空中楼阁。后来我改成现在这版,核心要求只有四条:
第一,先调查后动手。涉及任何“修改代码”的请求,必须先list_files+read_file摸清现状,禁止凭空猜测。第二,给结论先给依据,读到的文件路径和行号要写出来。第三,命令执行失败时,把报错原文贴出来再分析,不要自己脑补原因。第四,小步走。每次只改一个文件,改完立刻跑验证命令,而不是攒一堆改动一起跑。
成本统计也是这个版本里我最后加上去的,但实用价值非常高。Provider 每次返回 usage 信息后,我会把它追加到一个本地 JSON 行文件里,按日期聚合。这样到月底我能直接回答“这个月本地模型花了多少电费、云端 API 花了多少人民币”,而不是对着一个订阅账单发呆。这种透明感,恰恰是用官方客户端时最缺失的。
4. 多引擎接入实战:Ollama 与 OpenAI 兼容 API 的统一封装
4.1 Provider 接口定义
多引擎的核心是 Provider 接口。我把它收敛得非常窄,只有chat(messages)一个方法,返回值包含生成内容、工具调用和 token 消耗三样。这样设计是为了让新引擎接入的代价降到最低。你不需要关心模型内部是自回归还是别的机制,只要给我消息数组,还我结构化结果就行。
interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; toolCalls?: ToolCall[]; tool_call_id?: string; } interface ToolCall { id: string; name: string; arguments: string; } interface ChatReply { content: string; toolCalls: ToolCall[]; usage?: { inputTokens: number; outputTokens: number }; } interface Provider { chat(messages: ChatMessage[]): Promise<ChatReply>; }4.2 Ollama 本地引擎适配
Ollama 是目前本地跑模型最省心的方案。我用的模型是qwen2.5-coder:7b,负责默认的轻量任务;还拉了一个deepseek-r1:8b,专门处理需要一步一步推理的任务。Ollama 的/api/chat接口原生支持 tool_calls 字段,这一点比很多国产本地推理框架做得好。适配器核心代码如下:
class OllamaProvider implements Provider { constructor( private endpoint: string, private model: string ) {} async chat(messages: ChatMessage[]): Promise<ChatReply> { const res = await fetch(`${this.endpoint}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: this.model, messages, stream: false, options: { temperature: 0.2, }, }), }); const data = await res.json(); return { content: data.message.content, toolCalls: (data.message.tool_calls ?? []).map((tc: any) => ({ id: tc.function.name + "-" + Math.random().toString(36).slice(2), name: tc.function.name, arguments: JSON.stringify(tc.function.arguments), })), usage: data.prompt_eval_count ? { inputTokens: data.prompt_eval_count, outputTokens: data.eval_count, } : undefined, }; } }有个细节值得说一下:Ollama 返回的 tool_call 里,arguments本身已经是对象了,但我的统一接口要求它是字符串,因为 OpenAI 兼容 API 返回的就是字符串。所以我在这里做了一个JSON.stringify,让两边格式对齐。这种小差异,只有在实际接完两个引擎之后才发现,文档里根本不会写。
4.3 云端 DeepSeek 与 OpenAI 兼容 API
本地模型不是万能的,复杂架构设计、长链路重构、从零搭一个新模块,7B 模型经常力不从心。所以我额外接了一个云端引擎,用的是 DeepSeek 的 API。原因有两个:一是价格便宜,相比 Claude 官方 API 低了不止一个量级,几块钱人民币能跑完全天的开发任务;二是它兼容 OpenAI 的接口格式,baseURL 指过去、API key 一填就能用。
适配器其实就是把请求发到 config 里指定的 baseURL,v1/chat/completions路径,然后返回choices[0].message。代码跟 OllamaProvider 差不多,唯一区别是请求体里多传一个api_key,以及接口返回已经带了 role 和 tool_calls 的字段,不用额外映射。这种“几乎一样”的状态,恰恰证明了 Provider 抽象的价值:以后就算换一家新的模型服务,只要它自称 OpenAI 兼容,我的适配器连字节都不用改。
4.4 引擎路由规则
引擎多了之后,自然要面对“什么时候用哪个引擎”的问题。我第一版是纯手动切换:命令行加一个--engine qwen或者--engine deepseek。用了一周后我嫌麻烦,改成半自动路由。路由规则挂在 config 里,按任务关键词和会话长度判断。
准确说,我把任务分成三类。第一类是“探索和修 bug”,默认给本地模型,因为这类任务最常见的动作是读文件、找线索,7B 模型够用,而且本地延迟低。第二类是“结构性改动”,比如重构接口、调整模块依赖,自动切到云端 DeepSeek,因为它对整体架构的把握明显更强。第三类是“已有清晰方案,但需要写大量代码”,比如“按照刚才定的设计,把那个服务实现出来”,我默认也走云端,因为这种长输出任务,本地小模型后半段容易逻辑断裂,要反复校验,反而浪费时间。
5. 踩坑复盘:本地模型的工具调用与行为陷阱
5.1 工具调用格式不稳是头号敌人
这是我踩得最深的一个坑,也最花时间。本地 7B 模型的 tool_call 格式稳定性和云端大模型完全不在一个量级。症状有三:一是参数缺括号,JSON 写到一半直接结束;二是把 tool_call 当正文输出,给了一段人类语言然后夹一条 JSON;三是无视 schema,该传path字段偏传成file_path。我一开始是用 JSON.parse 硬解,失败就让模型重新生成,但一轮失败重试又吃掉几百甚至上千 token,特别浪费。
最后是三重方案一起上才解决的。第一,在系统提示词里放了一个完整的 few-shot 示例,让模型照着抄格式。第二,写了一个后处理修复函数:先提取输出里第一个{和最后一个}之间的内容,再补全缺失的引号和括号,最后按常见字段名做别名映射,比如把file_path自动映射到path。第三,给 Ollama 的请求体加上了format: "json"强制模式。本地小模型在强制 JSON 模式下虽然可能影响一点自然语言质量,但工具调用场景下,结构化比文采重要。
5.2 上下文爆掉与“读文件一大段”的连锁反应
前两版最容易翻车的场景是:让模型分析一个中间件文件,它读入了 60% 的上下文,然后显意识里为了省事,不回填读到的内容,直接“凭印象”回答。结果当然是错得离谱。后来我想明白一个道理:本地模型遇到上下文紧张时,它的自救措施不是“读少一点”,而是“编一个合理的答案”。这是最危险的。
所以我在 3.3 里提到的压缩策略还不够,还要加一个硬约束:模型角度上,如果上一次工具返回的内容超过一定量,系统提示词里就要提醒它“你刚读入的内容已经很多了,请优先基于这些内容回答,不要继续读新文件”。这一个提示词改动,让长文件的回答准确率提升了一个档次。另一个连锁反应是:如果本地模型要读一个很大的文件,我会让它先读前 100 行,总结后再决定是否继续读。这个“分段读 + 中间总结”的策略,比一次性读完全文更省 token,也更符合真实开发者的阅读习惯。
5.3 权限确认太频繁,会磨掉人的耐心
前面我提到,最初版本里我把所有工具调用都设为“必须确认”。结果在实际使用中,交互密度比 Claude Code 还高,十分钟能弹几十次确认框,我为了效率开始快速按回车,这时候确认机制就形同虚设了。这其实是我设计上的失误:安全确认的价值不是每步都拦,而是拦在真正关键的地方。
我改成三层策略之后,表面上是少了很多确认弹窗,实际上安全系数反而提高了。为什么?因为“全确认”带来的确认疲劳会让人无意识放行危险命令,而“白名单 + 黑名单”设计,让我可以把有限的注意力放在真正危险的命令上。现在list_files、read_file、search_code这类完全只读的操作用一个全局开关放行;write_file默认确认,但每次改完文件会显示 diff,我扫一眼就知道改动是否符合预期;run_command里危险前缀走强制二次确认,其他命令按目录配置。这套策略让我既不会被频繁打断,也不会在关键操作上翻车。
5.4 本地小模型的心理预期要放低
最后一条心得,可能也是最容易被忽略的:本地 7B 模型和 Claude 这类大模型,能力差距是全方位的,你不能用同一个标准要求它。具体来说,本地小模型有三个明显缺点:第一,喜欢臆造不存在的文件路径,所以我强制它先list_files再read_file,禁止直接写死路径;第二,多轮对话时容易丢失需求细节,尤其当中间插入好几次工具调用的结果之后;第三,输出带有“一本正经胡说八道”的特质,它会很自信地给出一个看似合理但完全错误的结论。
我的对策是降低单次任务的复杂度,把“大任务”拆成“小步骤”,每步都验证。原本我对 Claude 说“这个模块重构一下”,它自己能规划好;本地模型不行,我会先让它列出现在模块里的核心函数,然后逐个说明哪些可以提取、哪些要保留,等共识建立了,再让模型动手。这个过程虽然多花几轮对话,但因为全部在本地运行,没有 token 成本,所以怎么折腾都不心疼。
6. 实测对比:哪些活该留在本地,哪些要交给云端
6.1 三类典型任务的真实表现
我把这套平替用于一个有点年头的中型 Node.js 项目,既有历史代码,也有继续迭代的新功能。实际跑了整整两周之后,我整理了一张任务对比表,这里直接贴出来:
| 任务类型 | 本地 qwen2.5-coder:7b | 云端 DeepSeek API | 我的选择 |
|---|---|---|---|
| 批量加注释、补文档 | 稳定,偶尔注释风格过重需要收敛 | 效果好但杀鸡用牛刀 | 本地 |
| 定位小范围 bug | 能定位常见问题,复杂逻辑容易跑偏 | 定位精准,能给出关联代码 | 按难度判断 |
| 从零搭一个新模块 | 容易给空壳,需要反复追问补全 | 结构合理,一次成型 | 云端 |
| 重构既有接口 | 能改,但经常漏调用方 | 能自动追踪所有引用并修改 | 云端 |
| 快速回答“某个函数在哪用” | 很好,本地读代码速度飞快 | 一般,还要等一下响应 | 本地 |
这个表格和测试结论,我觉得能代表这一类本地优先工具的真实定位:不是全面替代 Claude Code,而是给日常 80% 的轻量任务一个便宜、可靠的底座。真正复杂的设计和系统性重构,云端大模型依然是更好的选择。
6.2 成本对比:从“肉疼”到“基本可以忽略”
成本数据是这次平替让我最满意的地方。两周的使用中,本地 Ollama 模型的成本约等于零,硬要算就是电费,一天撑死几毛钱。云端 DeepSeek API 的开销也很低,两周加起来不到十块钱人民币,这里面还包括了几次大规模重构。而同样的任务量,按我之前的 Clude Code 用法估算,账单至少是数百元人民币起步,差距在一个数量级以上。
比例上看,这套方案把“每天高频用 Agent 编程”的边际成本压到了接近于零。你可能会说本地模型的产出质量不如 Claude,这是事实,但在“读文件找线索、补注释、改小 bug、快速搜索调用关系”这些任务上,7B 模型的表现已经足够好,而且胜在即时反馈、零等待、不出域。当工具的价格低到可以随意挥霍时,你会更敢让它跑更多轮,反而弥补了一部分能力差距。
6.3 我对“平替”的最终判断
两周用下来,我的结论很明确:这个方案没法完全替代 Claude Code 的全部体验,但它解决了一个非常现实的问题——验证一个想法、改一个脚本、看看某个历史项目的结构,这类“低价值高频率”的操作,不再需要付出大模型成本。我可以随手打开终端起一个会话,让模型帮我搜代码、改注释、跑测试,不去想 token 烧了多少。
更重要的是,这套平替把控制权真正还给了开发者。本地优先意味着代码不出域,多引擎意味着模型选型不被厂商锁定,成本统计意味着每一分 token 都花得明明白白。对我来说,这三点比“模型会写更多代码”更有价值。
如果你也想做类似的平替,我最大的建议是:先把接口抽象写好,再追求模型效果。模型迭代快、供应商政策也在变,唯独你自己的工作流骨架可以长期沉淀。最后再说一个我在实操里攒下来的小技巧:我往会话记录里加了一个本地 SQLite 的 cost tracker,每个请求结束自动记 token 数,两周下来我对自己到底烧了多少 token 有了一个确切的数字。这种透明感,恰恰是用官方客户端时最缺失的。后面我打算把工具集扩展到 Git 操作,再接一个 VS Code 插件,不过那都是后话——先把这套跑起来,比什么都强。