1. 这个标题到底在说什么先拆概念再谈动手先把标题里的几个词拆开看不然后面全是糊涂账。Claude Code在这里指的是一类终端里的 AI 编程助手工具它本身是一个可交互的命令行程序能读写文件、执行命令、跑测试。Harness这个词在工程语境里指的是“套具、挂具”也就是把某个核心能力套起来、驱动起来的那层外壳。Deepseek是一个大语言模型服务提供兼容 OpenAI 风格的接口。TypeScript Mods指的是用 TypeScript 写的一层轻量改造代码行数很少大概八十行上下。把这几件事串起来标题想表达的是用大约八十行 TypeScript 代码给一个终端 AI 编程助手做一层“转接套具”让它底层实际调用的是另一个模型服务而不是它默认绑定的那个。说白了就是换引擎——车壳还是那个车壳方向盘、油门、刹车的手感尽量保持不变但发动机换成了另一台。这件事的价值在哪很多人用终端 AI 助手时会遇到几个现实问题默认模型的调用成本、响应速度、特定任务上的表现差异或者单纯想对比不同模型在同一个交互界面下的实际手感。如果每次换模型都要换一套工具、重新适应一套交互逻辑那成本太高。所以“保留交互外壳、替换底层模型”这个思路本质上是在降低切换成本让你用熟悉的操作方式去驱动不同的模型。适合谁来读这篇三类人。第一类是有一定 TypeScript 基础、想在本地折腾 AI 工具链的开发者第二类是想理解“AI 编程助手到底是怎么把模型接进来的”这个机制的工程师第三类是想给自己的内部工具做模型适配层、但不知道从哪下手的人。如果你完全没写过代码也能看懂原理部分但动手环节需要你至少能跑npm命令、能看懂 JSON 配置。需要提前说清楚一点下面所有涉及具体接口地址、字段名、参数的地方我都会用通用写法因为不同模型服务的接口细节会有差异你需要按自己实际使用的服务文档去替换。核心逻辑是通用的细节参数是你要自己填的。2. 为什么是“套具”而不是“改源码”架构选择的底层逻辑2.1 直接改源码和加一层套具差别在哪很多人第一反应是既然要换模型那直接去改那个工具的源码不就行了找到它调用模型的那一行把地址和密钥换掉收工。这个思路在理论上成立但在实操里会踩一堆坑。第一个坑是升级即失效。这类终端 AI 工具迭代非常快可能一周一个小版本。你改了源码下次它更新你的改动要么被覆盖要么产生冲突你得重新找那一行、重新改。改一次两次还行改十次你就烦了。第二个坑是耦合太深。工具的源码里模型调用往往不是孤立的一行它可能和会话管理、上下文裁剪、工具调用协议、流式输出解析这些东西缠在一起。你动一处可能影响另一处的行为调试成本很高。第三个坑是不可复用。你这次改的是这个工具下次想换另一个工具又得重来一遍。而“套具”这层是独立的理论上可以适配多个不同的外壳。所以“加一层套具”的核心逻辑是在外壳和真实模型服务之间插一个中间层。外壳以为自己在跟原来的服务说话实际上话被中间层接住转发给了另一个服务再把结果按外壳期望的格式送回去。这就是典型的适配器模式或者叫代理层。2.2 八十行能做完是因为大部分脏活被协议兼容吃掉了你可能会疑惑适配两个不同的模型服务格式转换、字段映射、流式处理这些活加起来怎么可能八十行搞定答案是如果目标服务提供了兼容接口那大部分脏活就不用你干。现在很多模型服务都提供 OpenAI 兼容的接口格式也就是说请求体的结构、返回体的结构、流式输出的分块格式都遵循同一套约定。而很多终端 AI 工具底层也是按这套约定去调用的。两边都遵循同一套约定中间层就只需要做“地址转发 密钥替换 少量字段微调”自然就短。反过来说如果两边协议完全不兼容那八十行肯定不够你得写完整的请求转换、响应转换、错误码映射、流式解析。所以这个标题能成立前提是协议兼容这个红利。这一点在动手前必须先确认否则你会写到一半发现根本接不上。2.3 这层套具到底拦截了什么具体来说中间层要处理的事情有这么几类请求地址重写外壳原本请求的是 A 地址中间层把它改成 B 地址。认证信息替换外壳带的密钥是给 A 用的中间层换成给 B 用的密钥。模型名映射外壳请求的模型名可能是 A 家的命名中间层把它映射成 B 家认识的模型名。请求体微调有些字段 A 支持但 B 不支持或者默认值不同需要删掉或改写。响应透传B 返回的结果如果格式和 A 一致直接原样送回外壳即可。这五件事里前三件是必做的第四件看情况第五件在协议兼容时几乎不用动。八十行的代码量基本就花在这几件事上。提示动手前先用curl分别打一次外壳默认服务和目标服务把两边的请求体和返回体都打印出来对比。这一步花十分钟能省你后面两小时的瞎猜。3. 动手前的环境盘点别急着写代码3.1 你需要准备的东西清单在写第一行代码之前把下面这些东西确认齐缺一个都会卡住项目说明是否必须Node.js 运行环境建议 18 以上因为要用到较新的 fetch 和流处理必须TypeScript 编译能力可以直接用 ts-node 跑也可以先编译成 js必须目标模型服务的接口地址从服务方文档获取必须目标模型服务的密钥从服务方控制台获取必须外壳工具的配置入口知道它从哪里读模型地址和密钥必须一个能发 HTTP 请求的调试工具curl 或 Postman 都行建议这里重点说两个容易被忽略的外壳工具的配置入口和调试工具。很多人卡在“我代码写好了但外壳根本不走我的中间层”原因就是没找到外壳真正读取配置的地方。有些工具读环境变量有些读配置文件有些两者都读且优先级不同。你得先确认它到底认哪个。3.2 确认外壳的请求到底长什么样这一步是整个项目的地基。你要让中间层“骗过”外壳就必须知道外壳期望收到什么样的响应。操作方法很直接把外壳的模型地址临时指向一个你自己起的、只做日志打印的假服务然后在外壳里随便发一句话看它发出来的请求体长什么样。重点看这几个字段model它请求的模型名是什么。messages消息数组的结构role 和 content 怎么组织的。stream是不是流式流式的分块格式是什么。其他附加字段比如temperature、max_tokens、tools之类。把这些记下来你就有了“外壳期望的输入格式”这份基准。中间层要做的就是让目标服务的返回尽量贴合这份基准。3.3 确认目标服务的返回格式同样地用 curl 直接打一次目标服务把返回体存下来看。重点确认返回体的顶层结构是不是choices[0].message.content这种。流式返回时每个数据块的格式是不是data: {...}这种 SSE 格式。结束标志是什么是data: [DONE]还是别的。错误返回的结构方便后面做错误透传。如果两边格式高度一致恭喜你中间层会非常薄。如果发现字段名对不上比如一边叫content一边叫text那中间层就要加一层字段映射代码量会涨一些但依然可控。注意流式格式的差异是最容易出问题的地方。非流式请求往往格式一致但流式请求各家在分块边界、结束标志、心跳包上的处理都不一样。如果你的外壳默认走流式务必把流式这条链路单独测通。4. 八十行代码的骨架每一段在干什么4.1 起一个本地 HTTP 服务中间层本质上就是一个本地的小型 HTTP 服务。外壳把请求发给它它转发给目标服务再把结果送回去。用 Node 自带的http模块就能起不需要引入 Express 这种重框架因为我们要的就是“薄”。起服务的核心代码大概是这样几行import http from http; const server http.createServer(async (req, res) { // 这里处理请求 }); server.listen(8787, () { console.log(harness listening on 8787); });端口随便选一个不冲突的比如 8787。选端口的原则是避开常见服务占用的端口避免和外壳自己可能用的端口撞车。起好之后先把外壳的模型地址指向http://127.0.0.1:8787确认外壳能连上哪怕中间层现在什么都不做、直接返回一个错误也要先确认“连通性”这一环。4.2 把请求体读全再转发HTTP 请求体是流式的不能假设一次就能读完。要监听data事件累积监听end事件表示读完。这一步如果偷懒遇到大一点的上下文就会截断表现为“模型收到的消息不完整”很难排查。let body ; req.on(data, chunk { body chunk; }); req.on(end, async () { const payload JSON.parse(body); // 接下来处理 payload });读全之后解析成对象这时候你就可以对payload做各种改造了改模型名、删字段、加字段都在这一步。4.3 改写请求并转发给目标服务改造的核心就三件事换地址、换密钥、换模型名。const targetUrl 目标服务的完整接口地址; const targetKey 目标服务的密钥; payload.model 目标服务认识的模型名; const upstream await fetch(targetUrl, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${targetKey}, }, body: JSON.stringify(payload), });这里有个细节外壳原本带的 Authorization 头要丢掉换成目标服务的。如果你直接把外壳的请求头原样转发目标服务会因为密钥不对而拒绝。所以要么重建请求头要么在转发前把旧的头删掉。4.4 把目标服务的返回送回外壳如果目标服务返回的是非流式结果直接读出来、原样写回res就行。如果是流式就要把上游的流“管道”到下游边收边发。if (payload.stream) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); for await (const chunk of upstream.body as any) { res.write(chunk); } res.end(); } else { const data await upstream.json(); res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify(data)); }流式这段是整个中间层里最需要小心的地方。for await逐块读取上游流然后原样res.write下去中间不做缓冲、不做合并这样延迟最低。如果你在中间做了 JSON 解析再重新序列化很容易破坏 SSE 的分块边界导致外壳解析失败。4.5 错误处理不能省八十行的代码里错误处理可能只占五行但这五行决定了你出问题时能不能快速定位。try { // 转发逻辑 } catch (err) { console.error(harness error:, err); res.writeHead(502, { Content-Type: application/json }); res.end(JSON.stringify({ error: String(err) })); }关键是把错误打到控制台同时给外壳返回一个明确的错误响应。如果中间层崩了但外壳收到的是空响应外壳可能表现成“卡住不动”你会以为是模型慢其实是中间层挂了。5. 跑通之后才会暴露的坑我的排查记录5.1 外壳连不上本地服务先查端口和协议第一次跑最常见的问题是外壳报“连接失败”。排查顺序是这样的用curl http://127.0.0.1:8787直接打中间层看有没有响应。如果没有说明服务没起来或者端口不对。如果 curl 能通但外壳不通检查外壳配置里填的地址是不是http而不是https。本地服务一般没有证书填 https 会握手失败。检查地址末尾有没有多余的路径。有些外壳会在你填的地址后面自动拼/v1/chat/completions如果你填的地址已经带了这段就会拼成两遍导致 404。这个坑我踩过表现是外壳一直转圈然后超时日志里什么都没有。最后发现是地址拼接重复。解决办法是外壳配置里只填到域名和端口路径部分让它自己拼。5.2 流式输出到一半断掉多半是缓冲问题流式跑到一半突然停住外壳显示“生成中断”但中间层日志显示上游还在正常返回。这种情况通常是中间层做了缓冲把本该立即发出去的数据攒着结果外壳那边等超时了。排查方法在res.write前后各打一条日志看数据是不是及时写出去了。如果发现写出的时间点和上游返回的时间点差很多就是缓冲问题。解决方式是确保res.write之后不要等也不要设置过大的缓冲。另一个可能的原因是Content-Type没设对。SSE 必须是text/event-stream如果设成了application/json外壳可能按普通 JSON 解析读到一半就报错。5.3 模型名对不上报“模型不存在”外壳请求的模型名是它默认的命名目标服务不认识这个名字就会返回“模型不存在”。这时候中间层要做一次映射把外壳请求的模型名替换成目标服务实际支持的模型名。映射关系可以写死也可以做成一个简单的字典const modelMap: Recordstring, string { 外壳默认模型名: 目标服务模型名, }; payload.model modelMap[payload.model] || payload.model;如果外壳会请求多个不同的模型名就把字典补全。如果目标服务只有一个模型那就无脑替换成那一个。5.4 上下文太长被截断检查字段是否被目标服务忽略有些目标服务对某些字段的支持和外壳默认服务不一样。比如外壳可能传了一个max_tokens但目标服务用的是另一个字段名结果这个限制没生效或者反过来被当成非法字段拒绝。排查方式是把中间层收到的请求体完整打印出来再把转发给目标服务的请求体完整打印出来逐字段对比。发现目标服务不认识的字段就在转发前删掉。这一步没有捷径就是对着两边的文档逐个核对。提示删字段要谨慎有些字段虽然目标服务文档里没写但实际是支持的。删之前先用 curl 单独测一下带这个字段的请求能不能通。5.5 密钥泄露风险别把密钥写进代码中间层代码里如果硬编码了目标服务的密钥一旦这份代码被分享出去密钥就泄露了。正确做法是从环境变量读取const targetKey process.env.TARGET_API_KEY;启动中间层时通过环境变量传入。这样代码可以随便分享密钥留在本地。6. 这套思路还能怎么扩展6.1 同时接多个模型服务做对比中间层既然是一层代理那它完全可以按请求内容做路由。比如根据请求里的某个标记决定这次转发给 A 服务还是 B 服务。这样你就能在同一个外壳里用同一套操作方式快速对比不同模型对同一个问题的回答。实现方式是在中间层加一个判断const target payload.model.includes(a) ? serviceA : serviceB;更优雅的做法是维护一个服务注册表每个服务有自己的地址、密钥、模型名映射中间层根据请求特征查表转发。这样扩展新服务只需要加一条配置不用改逻辑。6.2 加一层请求日志方便复盘中间层是天然的日志采集点。所有经过它的请求和响应都可以落盘保存。这对于复盘“为什么这次回答质量差”特别有用——你可以看到当时到底发了什么上下文、模型返回了什么。日志要注意脱敏把密钥、可能的敏感内容过滤掉再存。存储格式用 JSON Lines 比较方便一行一条追加写入不会因为程序崩溃丢历史。6.3 做请求重试和降级如果目标服务偶发超时或限流中间层可以加一层重试逻辑第一次失败后等一小段时间重试重试仍失败就返回一个明确的错误。这样外壳那边的体验会稳定很多不会因为一次网络抖动就整个会话中断。重试要注意幂等性。对于对话类请求重试一般没问题因为每次请求都是独立的。但如果请求里带了工具调用之类的副作用操作重试就要小心避免重复执行。6.4 把中间层做成可配置的通用适配器如果这套东西你打算长期用、并且会换不同的外壳和目标服务那可以把中间层抽象成一个通用适配器外壳的请求格式、目标服务的请求格式都做成配置项。换外壳或换服务时只改配置不改代码。这一步的投入比八十行大但如果你确实有频繁切换的需求长期看是划算的。判断标准很简单如果你一个月内切换超过两次就值得抽象如果只是偶尔折腾一次八十行的硬编码版本足够了。7. 一些实操层面的零散心得关于端口选择我建议固定用一个不常见的端口并且在中间层启动时打印出来。这样你每次排查时不用回忆“我上次用的哪个端口”。关于调试顺序我的习惯是先用 curl 把中间层的转发链路单独测通再接外壳。因为外壳的报错信息往往很模糊直接接外壳会把问题搅在一起。先用 curl 确认“中间层能正确转发并返回”再接外壳确认“外壳能正确解析中间层的返回”两步分开定位快很多。关于代码组织八十行虽然短但也建议拆成几个函数读请求体一个、改造请求一个、转发一个、写响应一个。这样出问题时你能快速定位是哪一段。全塞在一个回调里改起来容易顾此失彼。关于版本管理中间层代码建议单独放一个仓库和外壳的配置分开。因为外壳更新频繁而中间层相对稳定。分开管理外壳更新时不会影响中间层中间层改动时也不会误碰外壳配置。最后说一个心态上的事这类“套具”项目的价值不在于代码多复杂而在于它把“切换成本”降到了很低。你花一两个小时搭好这层之后每次想换模型、想对比效果都只是改一行配置的事。这种一次投入、长期受益的小工具往往比那些大而全的框架更实用。我自己的习惯是凡是发现自己要重复做三次以上的操作就停下来想想能不能加一层薄薄的自动化。这层套具就是这么一个产物。