本文档记录了将 Claude Code(Anthropic 的 CLI 开发者工具)的底层大模型从远程 Anthropic Claude 替换为本地 Ollama (qwen3.5:9b) 的完整过程,包括遇到的每一个问题和解决方案。
一、项目背景
Claude Code 是 Anthropic 开发的一个终端 AI 编程助手,其技术栈为:
-
运行时:Bun (TypeScript)
-
终端 UI:React + Ink(终端中的 React 渲染引擎)
-
API:Anthropic SDK(流式响应)
-
架构:包含交互式 REPL 模式和非交互式 print 模式
我们拿到的是通过 npm source map 暴露的源码归档,目标是让它完全使用本地 Ollama 模型运行,摆脱对 Anthropic 远程 API 的依赖。
二、第一阶段:让项目能跑起来
2.1 创建基础构建配置
源码归档中没有构建配置,需要手动创建:
package.json --- 定义依赖和启动脚本:
json
TypeScript
{
"scripts": {
"claude": "bun run src/entrypoints/cli.tsx"
}
}
bunfig.toml --- 配置 Bun 加载预处理文件:
toml
preload = ["./preload.ts"]
preload.ts --- Bun 启动前的模块 polyfill:
TypeScript
import { plugin } from 'bun'
// 定义编译期宏常量
;(globalThis as any).MACRO = {
VERSION: '1.0.0-source',
}
plugin({
name: 'claude-code-shims',
setup(build) {
// polyfill bun:bundle 的 feature() 函数
build.module('bun:bundle', () => ({
exports: { feature(_name: string) { return false } },
loader: 'object',
}))
// polyfill react-reconciler/constants.js
build.module('react-reconciler/constants.js', () => ({
exports: { ConcurrentRoot: 1, ... },
loader: 'object',
}))
// polyfill react/compiler-runtime
build.module('react/compiler-runtime', () => ({
exports: { c(size: number) { return new Array(size).fill(sentinel) } },
loader: 'object',
}))
},
})
2.2 Stub 缺失的原生/内部包
源码依赖了多个不可用的包,需要逐一 stub:
| 包名 | 用途 | Stub 策略 |
|---|---|---|
color-diff-napi |
终端 diff 渲染 | 空的 ColorDiff/ColorFile 类 |
@anthropic-ai/sandbox-runtime |
沙箱执行环境 | 完整的 SandboxManager mock |
@ant/claude-for-chrome-mcp |
Chrome 浏览器集成 | 空实现 |
@ant/computer-use-mcp |
电脑操作工具 | 空实现 |
@ant/computer-use-input |
原生输入 API | 返回 null/false |
@ant/computer-use-swift |
macOS Swift 桥接 | 空对象 |
@anthropic-ai/claude-agent-sdk |
Agent SDK | 空对象 |
modifiers-napi |
macOS 修饰键检测 | 始终返回 false |
2.3 安装依赖并首次运行
bash
bun install
bun run claude -- -p "hello" # print 模式测试
首次运行即报错 --- 这开启了漫长的调试之旅。
三、第二阶段:替换底层模型为 Ollama
3.1 创建 Ollama 适配器(src/services/api/ollama.ts)
核心挑战:Anthropic SDK 的调用格式和 Ollama 的 OpenAI 兼容 API 格式不同,需要一个翻译层。
请求格式转换:
text
Anthropic 格式 Ollama/OpenAI 格式
───────────────── ──────────────────
role: "user" → role: "user"
content: [{type:"text",...}] → content: "plain string"
role: "assistant" → role: "assistant"
system prompt (独立参数) → role: "system" 消息
流式响应转换:
text
Ollama SSE 流 Anthropic SSE 事件
──────────── ─────────────────
{"choices":[{"delta":{"content":"Hi"}}]} → message_start + content_block_start
+ content_block_delta(text_delta)
+ content_block_stop + message_stop
关键优化:
-
截断 system prompt(默认限制 4000 字符)------ 本地模型处理不了 Claude Code 的超长系统提示
-
不发送 tools 定义给 Ollama ------ 减少 prompt 体积,避免本地模型处理能力不足
-
处理 qwen3.5 的
reasoning字段 ------ 该模型在delta.reasoning中返回思考过程
3.2 修改 API 客户端路由(src/services/api/client.ts)
typescript
TypeScript
export async function getAnthropicClient({ ... }): Promise<Anthropic> {
const { isOllamaEnabled, createOllamaClient } = await import('./ollama.js')
if (isOllamaEnabled()) {
return createOllamaClient() as unknown as Anthropic
}
// ... 原有 Anthropic 逻辑
}
3.3 修改模型名称解析(src/utils/model/model.ts)
typescript
TypeScript
export function getMainLoopModel(): ModelName {
if (process.env.USE_OLLAMA === '1' || process.env.USE_OLLAMA === 'true') {
return process.env.OLLAMA_MODEL || 'qwen3.5:9b'
}
// ... 原有逻辑
}
3.4 跳过 Anthropic 认证(src/utils/auth.ts)
typescript
TypeScript
export function isAnthropicAuthEnabled(): boolean {
if (process.env.USE_OLLAMA === '1' || process.env.USE_OLLAMA === 'true') return false
// ... 原有逻辑
}
3.5 环境变量配置(preload.ts)
typescript
TypeScript
process.env.USE_OLLAMA ??= '1'
process.env.OLLAMA_MODEL ??= 'qwen3.5:9b'
process.env.OLLAMA_BASE_URL ??= 'http://localhost:11434'
if (process.env.USE_OLLAMA === '1' || process.env.USE_OLLAMA === 'true') {
process.env.ANTHROPIC_API_KEY ??= 'ollama-local' // 防止 CLI 因缺少 key 而挂起
process.env.DISABLE_ANALYTICS ??= '1'
process.env.DISABLE_INSTALLATION_CHECKS ??= '1'
process.env.DISABLE_TELEMETRY ??= '1'
process.env.DISABLE_BUG_REPORTS ??= '1'
process.env.DISABLE_AUTOUPDATE ??= '1'
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC ??= '1'
}
四、第三阶段:print 模式调试
问题 4.1:ColorDiff.render is not a function
现象 :运行 bun run claude -- -p "hello" 报错 new ColorDiff(...).render is not a function
原因 :color-diff-napi 的 stub 缺少 render() 和 highlight() 方法
修复:完善 stub 中的类方法
typescript
TypeScript
build.module('color-diff-napi', () => ({
exports: {
ColorDiff: class ColorDiff {
constructor() {}
diff() { return '' }
render() { return null } // ← 新增
},
ColorFile: class ColorFile {
constructor() {}
highlight() { return '' } // ← 新增
render() { return null } // ← 新增
},
getSyntaxTheme() { return null },
},
loader: 'object',
}))
问题 4.2:SandboxManager 方法缺失
现象 :运行时报 SandboxManager.xxx is not a function 系列错误
原因 :最初的 stub 只有 start/stop/isRunning 三个方法,实际代码调用了 20+ 个方法
修复 :逐一补全所有被调用的方法(isSupportedPlatform, checkDependencies, wrapWithSandbox, getFsReadConfig 等)
问题 4.3:Ollama 模型名称不匹配
现象:Ollama API 返回 404
原因 :最初配置的是 qwen3:8b,但本地实际安装的是 qwen3.5:9b
修复 :更新所有模型引用为 qwen3.5:9b
结果
print 模式成功运行,Ollama 返回了响应!
五、第四阶段:交互式 REPL 模式调试(最艰难的阶段)
交互模式(bun run claude)比 print 模式复杂得多------它使用 React + Ink 渲染终端 UI,有完整的键盘输入处理、对话框系统、keybinding 系统等。
问题 5.1:REPL 启动后无响应(第一层)
现象:输入消息后,长时间无响应
原因:本地 Ollama 模型处理 Claude Code 的超长 system prompt(几万字符)+ 全部 tools 定义需要 60+ 秒
修复:
-
截断 system prompt 为 4000 字符
-
不发送 tools 定义给 Ollama
问题 5.2:仍然无响应(第二层)------ 并发 API 调用
现象:优化 prompt 后仍无响应
原因 :generateSessionTitle() 在后台发起第二个 API 调用来生成对话标题。Ollama 是顺序处理的,这个后台调用阻塞了主查询。
修复 (src/utils/sessionTitle.ts):
typescript
TypeScript
export async function generateSessionTitle(...) {
// ...
if (isOllamaEnabled()) return null // 跳过标题生成
// ...
}
问题 5.3:仍然无响应(第三层)------ 设置对话框阻塞
现象:API 层面已通,但 REPL 仍然无反应
原因:交互模式启动时会显示一系列设置对话框(Onboarding → Trust → MCP Approvals → API Key Approval),这些对话框在等待用户操作,但在 Ollama 模式下它们不应该出现。
修复:
- 跳过 API Key 审批对话框 (
src/interactiveHelpers.tsx):
typescript
TypeScript
// 在 showSetupScreens 中,跳过 ApproveApiKey 对话框
if (process.env.USE_OLLAMA === '1' || process.env.USE_OLLAMA === 'true') {
// skip API key approval dialog
}
- 手动创建全局配置 (
~/.claude/settings.json):
json
TypeScript
{
"theme": "dark",
"hasCompletedOnboarding": true,
"customApiKeyResponses": {
"approved": ["ollama-local"],
"rejected": []
},
"projects": {
"/Users/pengli/code/claude-code-main": {
"hasTrustDialogAccepted": true,
"allowedTools": []
}
}
}
问题 5.4:仍然无响应(第四层)------ npm 安装检查提示
现象 :提示 "Claude Code has switched from npm to native installer. Run claude install"
原因:安装方式检查逻辑干扰了正常启动
修复 :process.env.DISABLE_INSTALLATION_CHECKS ??= '1'
问题 5.5:仍然无响应(第五层)------ Session Hooks 挂起
现象:设置对话框都跳过了,但输入 hello 按 Enter 后没有任何反应
诊断方法 :在关键节点添加文件日志(/tmp/repl-debug.log),逐步缩小范围
发现 :processSessionStartHooks() 中的 loadPluginHooks() 尝试加载网络插件,永远挂起
修复 (src/utils/sessionStart.ts):
typescript
TypeScript
export async function processSessionStartHooks(...) {
if (process.env.USE_OLLAMA === '1' || process.env.USE_OLLAMA === 'true') {
return [] // 跳过所有 session hooks
}
// ... 原有逻辑
}
问题 5.6:Enter 键不触发提交(第六层)------ 最隐蔽的 Bug
前面的问题都解决后,按 Enter 仍然没有反应。这是整个过程中最难调试的问题。
诊断过程(逐层深入):
| 步骤 | 添加日志位置 | 结果 |
|---|---|---|
| 1 | REPL.onSubmit |
没有被调用 |
| 2 | handlePromptSubmit |
没有被调用 |
| 3 | PromptInput.onSubmit |
没有被调用 |
| 4 | ChordInterceptor.handleInput |
Enter 被接收!result.type=match, action=chat:submit |
| 5 | BaseTextInput.useInput |
Enter 到达!isActive=true |
| 6 | useTextInput.handleEnter |
被调用!但 onSubmit 没有执行 |
| 7 | 在 handleEnter 内逐分支加日志 | 所有分支日志都没出现,onSubmit 日志也没出现 |
| 8 | 用 try/catch 包裹整个函数 | 捕获到异常! |
根本原因:
text
Cannot find package 'modifiers-napi' from '/Users/pengli/code/claude-code-main/src/utils/modifiers.ts'
完整调用链:
text
用户按 Enter
→ ChordInterceptor 识别为 chat:submit(wasInChord=false,不拦截)
→ BaseTextInput.useInput 接收(isActive=true)
→ useTextInput.onInput → mapKey(key.return) → handleEnter(key)
→ 跳过 backslash 检查('o' !== '\\')
→ 跳过 meta/shift 检查(都是 false)
→ 检查 env.terminal === 'Apple_Terminal' ← TRUE!(用户使用 Terminal.app)
→ 调用 isModifierPressed('shift')
→ require('modifiers-napi') ← 💥 包不存在,抛出异常!
→ onSubmit 永远不会被调用
修复 (preload.ts):
typescript
TypeScript
build.module('modifiers-napi', () => ({
exports: {
prewarm: () => {},
isModifierPressed: (_modifier: string) => false,
},
loader: 'object',
}))
六、最终文件修改清单
| 文件 | 修改类型 | 说明 |
|---|---|---|
preload.ts |
核心配置 | 环境变量 + 8 个原生包 stub |
src/services/api/ollama.ts |
新增 | Ollama 适配器(340 行) |
src/services/api/client.ts |
路由 | Ollama 客户端分发 |
src/utils/model/model.ts |
模型 | 返回 Ollama 模型名 |
src/utils/auth.ts |
认证 | 跳过 Anthropic 认证 |
src/utils/sessionTitle.ts |
优化 | 禁用标题生成 |
src/utils/sessionStart.ts |
优化 | 跳过 session hooks |
src/interactiveHelpers.tsx |
UI | 跳过 API Key 对话框 |
~/.claude/settings.json |
配置 | 预设 onboarding/trust 状态 |
七、关键经验总结
7.1 大型项目的替换工作是"剥洋葱"
每解决一层问题,下面还有新的一层。从最初的"模型替换"到最终的"原生包 stub",一共剥了 6 层:
-
API 格式转换
-
超长 prompt 截断
-
并发 API 调用冲突
-
启动对话框阻塞
-
Session hooks 挂起
-
原生包缺失导致异常被吞没
7.2 文件日志是终端 UI 调试的唯一可靠手段
React + Ink 渲染终端 UI 时,console.log 会干扰渲染。我们使用了:
typescript
TypeScript
require('fs').appendFileSync('/tmp/repl-debug.log', `${new Date().toISOString()} [TAG] message\n`)
在每个关键节点逐步添加,通过二分法定位问题。
7.3 异常被静默吞掉是最难调试的
最终的 modifiers-napi 问题之所以难以定位,是因为:
-
Ink 的
useInput回调中的异常被框架静默捕获 -
函数入口的日志能打印,但后续代码因异常中断,没有任何错误信息
-
只有用 try/catch 包裹整个函数体才能发现
7.4 Apple Terminal 的特殊逻辑
Claude Code 对 Apple Terminal (Terminal.app) 有特殊处理------因为它不支持自定义 Shift+Enter 键绑定,所以代码用 modifiers-napi 原生检测 Shift 键状态。这个仅在 Terminal.app 中触发的代码路径成为了最后的障碍。
八、运行方式
bash
# 确保 Ollama 运行中且模型已下载
ollama pull qwen3.5:9b
# 交互模式
bun run claude
# 非交互模式
bun run claude -- -p "你好"
附录:环境变量速查
| 变量 | 默认值 | 说明 |
|---|---|---|
USE_OLLAMA |
1 |
启用 Ollama 模式 |
OLLAMA_MODEL |
qwen3.5:9b |
使用的模型名称 |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama API 地址 |
DISABLE_INSTALLATION_CHECKS |
1 |
跳过安装检查 |
DISABLE_TELEMETRY |
1 |
禁用遥测 |
本文档记录了完整的集成过程,希望能为类似项目的本地化改造提供参考。
