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: Record<string, 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 确认“中间层能正确转发并返回”,再接外壳确认“外壳能正确解析中间层的返回”,两步分开,定位快很多。
关于代码组织,八十行虽然短,但也建议拆成几个函数:读请求体一个、改造请求一个、转发一个、写响应一个。这样出问题时你能快速定位是哪一段。全塞在一个回调里,改起来容易顾此失彼。
关于版本管理,中间层代码建议单独放一个仓库,和外壳的配置分开。因为外壳更新频繁,而中间层相对稳定。分开管理,外壳更新时不会影响中间层,中间层改动时也不会误碰外壳配置。
最后说一个心态上的事:这类“套具”项目的价值,不在于代码多复杂,而在于它把“切换成本”降到了很低。你花一两个小时搭好这层,之后每次想换模型、想对比效果,都只是改一行配置的事。这种一次投入、长期受益的小工具,往往比那些大而全的框架更实用。我自己的习惯是,凡是发现自己要重复做三次以上的操作,就停下来想想能不能加一层薄薄的自动化。这层套具就是这么一个产物。