☰
80行TypeScript实现Claude Code与Deepseek模型适配层
2026/10/20 10:10:36 网站建设 项目流程

1. 这个项目到底在折腾什么

第一次看到“80行 TypeScript Mods,把 Claude Code 改造成 Deepseek Harness”这个标题,我脑子里蹦出来的第一个念头是:这哥们儿真会玩。Claude Code 是 Anthropic 推出的命令行编程助手,Deepseek 是国产大模型里性价比极高的一家,Harness 这个词在工程语境里通常指“把某个核心能力套上一层壳,让它跑在另一套环境里”。把这三样东西串起来,本质上就是——用极少的代码量,把 Claude Code 这个 CLI 工具的后端模型从 Claude 换成 Deepseek,同时保留它原有的交互体验、工具调用能力和文件操作能力。

说白了,你平时用 Claude Code 的时候,它背后调的是 Anthropic 的 API。这个项目做的事情,就是在中间加一层适配,让请求发到 Deepseek 的接口上去,返回的结果再翻译回 Claude Code 能理解的格式。80 行 TypeScript 听起来不多,但要把这件事做干净,涉及的东西其实不少:请求格式转换、流式响应解析、工具调用(tool use)的映射、错误处理、token 计数差异等等。

这个项目适合谁看?三类人。第一类是想用 Claude Code 的交互体验但预算有限、想接国产模型的开发者;第二类是正在做 LLM 应用层适配、想了解不同模型 API 差异怎么抹平的工程师;第三类就是纯粹好奇“一个 CLI 工具的模型层到底能有多容易被替换”的技术爱好者。不管你属于哪一类,下面这些内容应该都能给你一些可以直接抄作业的东西。

我先把结论放前面:这件事技术上完全可行,核心难点不在代码量,而在于两家模型在 tool use 协议上的语义差异。80 行能跑通 demo,但要跑稳,你得理解每一行在干什么。

2. 整体设计思路与方案选型

2.1 为什么选 TypeScript 而不是 Python

很多人第一反应是这种适配层用 Python 写最顺手,毕竟 LLM 生态里 Python 库最多。但这个项目选 TypeScript 是有道理的。Claude Code 本身是 Node.js 生态的工具,它的 mods 机制(或者说扩展机制)天然就跑在 JS 运行时里。你用 Python 写一个中间层,就得额外起一个进程做代理,多一层网络跳转,延迟和调试复杂度都上去了。用 TypeScript 直接在同一个运行时里做拦截和转换,等于零额外开销。

另一个原因是类型系统。不同模型的 API 响应结构差异很大,Deepseek 返回的 JSON 字段名、嵌套层级、流式 chunk 的格式跟 Claude 都不一样。TypeScript 的 interface 和类型收窄在这种场景下特别有用,你定义好目标格式的类型,编译器会帮你抓出一堆字段映射的错误。80 行能写清楚,类型系统功不可没。

2.2 Harness 模式的核心:拦截与翻译

这个项目采用的架构模式我称之为“拦截-翻译-回写”。具体来说,Claude Code 在发起模型请求时,会走一个内部的 HTTP 客户端。Mods 机制允许你在这个客户端发出请求之前拦截它,把请求体(request body)拿到手,做格式转换,然后发到 Deepseek 的 endpoint。收到响应后,再把 Deepseek 的响应格式翻译回 Claude Code 期望的格式,塞回去让它继续处理。

这个模式的好处是对上层完全透明。Claude Code 的 UI、工具调用逻辑、文件读写、diff 展示这些全都不用改,它根本不知道自己背后换了个模型。你甚至可以在配置里做条件切换,某些任务走 Claude,某些走 Deepseek。

注意:拦截层要处理好流式响应。Claude Code 大量使用 SSE(Server-Sent Events)来实时展示模型输出,如果你把流式响应缓冲成完整响应再返回,用户体验会变得非常卡顿,一个字要等好几秒才蹦出来。

2.3 方案取舍:为什么不做全量协议兼容

一个常见的误区是试图把 Deepseek 的 API 完全模拟成 Anthropic 的 API。这个方向是错的,工作量巨大且没必要。正确的做法是只翻译 Claude Code 实际用到的那部分能力。Claude Code 核心用到的无非是:文本生成、工具调用(读文件、写文件、执行命令)、多轮对话上下文。你只需要保证这几个场景的请求和响应能正确映射就行,其他边缘能力可以暂时不管。

这个取舍直接决定了代码量能压到 80 行。如果你追求 100% 协议兼容,那至少是几千行的工程。

3. 核心细节解析与实操要点

3.1 请求格式的字段映射

Claude 的 Messages API 和 Deepseek 的 Chat Completions API 虽然都是“发消息、收回复”,但字段结构差别不小。我整理了一个对照表,这是整个适配层最基础也最容易出错的地方。

维度Claude Messages APIDeepseek Chat API处理方式
消息角色user / assistantuser / assistant / systemsystem 单独提取
内容字段content 数组,含 typecontent 字符串数组拍平为字符串
工具定义tools 数组,input_schematools 数组,function.parameters字段重命名
工具调用content 里 type=tool_usetool_calls 独立字段结构重组
最大 tokenmax_tokensmax_tokens直接透传
流式格式event: content_block_deltadata: choices[].delta逐 chunk 转换

最坑的是 content 字段。Claude 的 content 是一个数组,里面每个元素有 type 字段,可能是 text,也可能是 tool_use 或 tool_result。Deepseek 这边 content 就是一个普通字符串,工具调用走独立的 tool_calls 字段。你在转换的时候要把 Claude 的数组拍平,遇到 tool_use 类型的元素要抽出来放到 tool_calls 里,遇到 tool_result 要转成 role=tool 的消息。

function toDeepseekMessages(claudeMessages: any[]) { return claudeMessages.map(msg => { if (Array.isArray(msg.content)) { const textParts = msg.content .filter((c: any) => c.type === 'text') .map((c: any) => c.text) .join(''); const toolCalls = msg.content .filter((c: any) => c.type === 'tool_use') .map((c: any) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.input) } })); return { role: msg.role, content: textParts, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) }; } return msg; }); }

这段逻辑看着简单,但有个细节:Claude 的 tool_use 里 input 已经是对象了,Deepseek 要求 arguments 是 JSON 字符串。这个 JSON.stringify 不能忘,忘了对面直接报参数解析错误。

3.2 工具调用协议的语义差异

这是整个项目最核心的难点,也是我踩坑最多的地方。Claude 和 Deepseek 在工具调用的“触发时机”和“结束信号”上理解不一样。

Claude 的模型在决定调用工具时,会在 content 数组里输出一个 type=tool_use 的块,然后 stop_reason 变成 tool_use。Claude Code 看到这个信号就知道该执行工具了,执行完把结果作为 tool_result 塞回下一轮对话。

Deepseek 这边,模型输出 tool_calls 字段,finish_reason 变成 tool_calls。看起来差不多对吧?但问题在于流式模式下 chunk 的切分方式不同。Claude 的 tool_use 是作为一个完整的 content_block 一次性给你的,Deepseek 的 tool_calls 是分片流式返回的,arguments 字段可能被切成好几段,你得自己拼接。

// 拼接 Deepseek 流式 tool_calls 的 arguments let toolCallBuffer: Record<number, { name: string; args: string }> = {}; for (const chunk of stream) { const delta = chunk.choices[0]?.delta; if (delta?.tool_calls) { for (const tc of delta.tool_calls) { const idx = tc.index; if (!toolCallBuffer[idx]) { toolCallBuffer[idx] = { name: tc.function?.name || '', args: '' }; } if (tc.function?.arguments) { toolCallBuffer[idx].args += tc.function.arguments; } } } }

实操心得:arguments 拼接完一定要做一次 JSON.parse 校验。我遇到过模型输出的 JSON 被截断的情况,直接 parse 会抛异常,整个对话就断了。加个 try-catch,解析失败时给模型返回一个“参数格式错误,请重新生成”的 tool_result,让它自己纠正。

3.3 流式响应的转换逻辑

Claude Code 期望的流式格式是 Anthropic 的 SSE 事件流,有 message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop 这一整套事件。Deepseek 的流式格式是标准的 OpenAI 风格,每个 chunk 就是data: {"choices":[{"delta":{"content":"..."}}]}。

你要做的是把 OpenAI 风格的 chunk 实时转换成 Anthropic 风格的事件。这里的关键是状态机。你不能无脑转换,因为 Anthropic 的事件流有严格的顺序要求:必须先 message_start,然后 content_block_start,然后一堆 delta,然后 content_block_stop,最后 message_stop。

let blockStarted = false; function* transformChunk(chunk: any) { const delta = chunk.choices[0]?.delta; if (!blockStarted) { yield { type: 'message_start', message: { role: 'assistant', content: [] } }; yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }; blockStarted = true; } if (delta?.content) { yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: delta.content } }; } if (chunk.choices[0]?.finish_reason) { yield { type: 'content_block_stop', index: 0 }; yield { type: 'message_stop' }; } }

这段代码是简化版,实际还要处理 tool_use 的 content_block,逻辑会复杂一些。但核心思路就是这个状态机:用一个标志位记录当前是否已经开始了 content block,在合适的时机插入开始和结束事件。

4. 实操过程与核心环节实现

4.1 环境准备与 Mods 加载机制

动手之前你得先搞清楚 Claude Code 的 mods 是怎么加载的。通常这类 CLI 工具会有一个配置目录,里面放扩展脚本,启动时按顺序加载。你需要确认你的版本支持哪种加载方式:是配置文件里指定路径,还是约定目录自动扫描。

我建议的做法是先在本地建一个独立的 mods 目录,把脚本放进去,然后在配置里显式指向它。这样调试的时候改完直接重启就行,不用动全局配置。环境变量方面,你需要准备 Deepseek 的 API key 和 base URL,这两个东西不要硬编码在脚本里,走环境变量或者配置文件读取。

# 环境变量示例 export DEEPSEEK_API_KEY="your-key-here" export DEEPSEEK_BASE_URL="https://api.deepseek.com/v1"

注意:API key 千万不要提交到版本控制里。我见过有人把 key 写在脚本里然后推到公开仓库,第二天就收到账单了。用 .env 文件加 .gitignore,或者用系统级的密钥管理。

4.2 拦截请求的完整代码走读

下面我把核心的拦截逻辑拆开讲。整个脚本的入口是一个导出函数,Claude Code 在初始化时会调用它,把内部的 HTTP 客户端实例传进来。你在这个实例上挂一个请求拦截器。

export function activate(api: ClaudeCodeAPI) { const originalFetch = api.httpClient.fetch; api.httpClient.fetch = async (url: string, options: any) => { // 只拦截发往 Anthropic 的请求 if (!url.includes('anthropic.com')) { return originalFetch(url, options); } const body = JSON.parse(options.body); const deepseekBody = { model: 'deepseek-chat', messages: toDeepseekMessages(body.messages), tools: body.tools?.map(convertTool), stream: body.stream, max_tokens: body.max_tokens }; const response = await fetch(`${process.env.DEEPSEEK_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}` }, body: JSON.stringify(deepseekBody) }); if (body.stream) { return new Response(transformStream(response.body), { headers: { 'Content-Type': 'text/event-stream' } }); } return transformResponse(await response.json()); }; }

这段代码的关键点在于:只拦截发往 Anthropic 的请求。Claude Code 可能还会发其他请求(比如遥测、更新检查),你不能把这些也劫持了。用 URL 匹配做过滤是最简单可靠的方式。

4.3 响应转换与错误兜底

响应转换分两条路:非流式和流式。非流式相对简单,把 Deepseek 的 choices[0].message 转成 Claude 的 content 数组就行。流式就是前面说的状态机。

错误兜底这块我要重点讲。Deepseek 的 API 在遇到限流、余额不足、参数错误时返回的错误格式跟 Anthropic 完全不同。如果你不处理,Claude Code 收到一个它不认识的错误结构,可能会直接崩溃或者显示一堆乱码。

function transformError(deepseekError: any) { return { type: 'error', error: { type: mapErrorType(deepseekError.error?.code), message: deepseekError.error?.message || 'Unknown error from upstream' } }; }

我建议把所有可能的错误码都映射一遍,至少覆盖 400、401、429、500 这几个常见的。429 限流的时候,最好在适配层做一个简单的重试,指数退避,重试两三次再放弃。这个逻辑加上去可能要多 20 行代码,但稳定性提升非常明显。

5. 常见问题与排查技巧实录

5.1 工具调用不触发或参数错乱

这是最高频的问题。表现是模型明明应该读文件,但它就是在那聊天,或者调用了工具但参数是空的。排查思路分三步走。

第一步,确认你的 tools 定义转换是否正确。Claude 的 input_schema 和 Deepseek 的 function.parameters 虽然都是 JSON Schema,但字段名不一样。你如果直接把 input_schema 塞到 parameters 的位置,Deepseek 会忽略这个工具定义,模型自然就不会调用。

第二步,检查流式拼接逻辑。前面说过 arguments 是分片的,如果你的拼接逻辑有 bug,比如 index 对不上或者漏了某个 chunk,最终传给模型的参数就是残缺的。加日志把每个 chunk 打出来看,对比一下完整参数应该长什么样。

第三步,看 finish_reason。如果 finish_reason 是 tool_calls 但你的转换逻辑没识别,Claude Code 就收不到工具调用信号。在转换层加一个断言,遇到 tool_calls 必须产出对应的 content_block。

问题现象可能原因排查方法
模型不调用工具tools 定义字段名错误打印转换后的 tools 对比文档
工具参数为空流式 arguments 拼接丢失逐 chunk 打日志
工具调用后对话中断tool_result 格式不对检查 role 和 tool_call_id
重复调用同一工具finish_reason 判断有误确认只在 tool_calls 时触发

5.2 流式输出卡顿或截断

流式输出卡顿通常是缓冲区的问题。Node.js 的 stream 默认有缓冲,如果你没有正确地 pipe 或者手动 flush,数据会攒在缓冲区里不出来。解决办法是在转换流的时候用 Transform stream,每处理完一个 chunk 就 push 出去。

截断问题更隐蔽。有时候模型输出到一半突然停了,你以为是网络问题,其实是 max_tokens 设置得太小。Claude Code 默认的 max_tokens 可能跟 Deepseek 的上限不一样,你要在转换的时候做一个 clamp,确保不超过 Deepseek 允许的最大值。

实操心得:我习惯在适配层加一个请求日志,把每次请求的 token 估算值和实际返回的 token 数都记下来。跑一段时间后你就能看出哪些任务的 token 消耗异常,提前发现截断风险。

5.3 多轮对话上下文丢失

多轮对话出问题,十有八九是消息历史转换有 bug。Claude 的对话历史里,assistant 的消息可能包含 tool_use 块,user 的消息可能包含 tool_result 块。你转换的时候如果只取了 text 部分,把 tool_use 和 tool_result 丢了,模型就失去了上下文,表现就是“失忆”。

正确的做法是完整保留工具调用的往返记录。assistant 的 tool_use 转成 tool_calls,user 的 tool_result 转成 role=tool 的独立消息,并且 tool_call_id 要对应上。这个对应关系断了,Deepseek 会报错说找不到对应的 tool call。

// tool_result 转换示例 { role: 'tool', tool_call_id: originalToolUseId, content: typeof result.content === 'string' ? result.content : JSON.stringify(result.content) }

5.4 性能与成本的实际观察

跑通之后我做了几组对比测试。同样的编程任务,Deepseek 的响应速度在大部分场景下跟 Claude 差距不大,首 token 延迟甚至更快一些。成本方面差距就大了,Deepseek 的定价大概是 Claude 的十分之一到二十分之一,对于高频使用的开发者来说省下来的钱很可观。

但要注意,便宜不代表可以无脑用。复杂推理任务上,Deepseek 的表现跟 Claude 还是有差距的,特别是在需要长链条工具调用的场景下,Deepseek 偶尔会“忘记”自己刚才调用了什么工具。我的建议是把这个 Harness 当作一个可切换的选项,简单任务走 Deepseek 省钱,复杂任务切回 Claude 保质量。

6. 几个容易被忽略的工程细节

6.1 Token 计数与上下文窗口管理

Claude 的上下文窗口和 Deepseek 的不一样,你在做请求转换的时候要考虑到这一点。如果 Claude Code 认为还有足够的上下文空间,继续往对话里塞消息,但 Deepseek 那边已经超了,请求就会失败。稳妥的做法是在适配层做一个 token 估算,超过 Deepseek 窗口的 80% 就主动截断最早的消息,或者返回一个提示让上层处理。

Token 估算不需要很精确,用字符数除以 3 到 4 做个粗略估计就够了。关键是留出安全边际,别等到 API 报错才处理。

6.2 并发请求的处理

Claude Code 在某些场景下会并发发起多个请求,比如同时分析多个文件。你的适配层如果用了全局状态(比如前面流式转换里的 blockStarted 变量),并发的时候就会串数据。解决办法是把状态封装在每次请求的闭包里,不要用模块级的变量。

// 错误做法:模块级状态 let blockStarted = false; // 正确做法:每次请求独立状态 function createTransformer() { let blockStarted = false; return function* transform(chunk: any) { /* ... */ }; }

这个坑我在第一次实现的时候踩过,表现是两个并发请求的输出混在一起,A 请求的内容出现在 B 请求的回复里,排查了半天才定位到是共享状态的问题。

6.3 日志与可观测性

80 行的脚本很容易写得没有日志,出了问题两眼一抹黑。我强烈建议至少加三个日志点:请求发出时记录转换后的 body 摘要,响应收到时记录 finish_reason 和 token 用量,错误发生时记录完整的错误对象。日志级别用 debug,默认不输出,需要排查的时候开环境变量打开。

有了这些日志,大部分问题你都能在几分钟内定位。没有日志的话,你可能要花几个小时去猜。

7. 后续可以怎么扩展

这个 Harness 的架构其实很通用,你把 Deepseek 换成任何一家兼容 OpenAI 接口的模型都能跑。我后来把它扩展成了一个多后端路由:根据任务类型自动选择模型,代码生成走一个,文本总结走另一个,成本和质量之间做动态平衡。

另一个方向是加缓存。相同的请求(比如重复读同一个文件)没必要每次都调模型,在适配层做一个基于请求哈希的缓存,命中直接返回,能省不少 token。缓存要注意失效策略,文件内容变了缓存就得清掉。

还有一个我觉得很有意思的扩展是请求重放。把每次请求和响应都存下来,出问题的时候可以离线重放,不用真的再调一次 API。对于调试复杂的工具调用链路特别有用。

这些扩展每一个都不复杂,加起来可能也就几百行代码,但能让这个 Harness 从一个玩具变成一个真正日常可用的工具。我个人的体会是,适配层的价值不在于代码多少,而在于你对两个系统差异的理解有多深。理解到位了,80 行就够;理解不到位,8000 行也是漏洞百出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询