从某天开始,OpenAI API 无法从国内直接访问。而且,也不是每个人都有自己的云服务器,能够搭建独立服务。那么,还有别的办法能比较容易的访问到 OpenAI 么?当然是有的,比如 Vercel Edge Function,或者 CloudFlare Edge Function。
这里我准备结合前阵子的开发经验,分享一下使用 Vercel Edge Function 访问 OpenAI API 的注意事项,让新来的开发者能少走弯路。
推荐阅读
开始之前,我建议大家先花点时间了解一下 Edge Function,以及如何使用 Vercel Edge Function 开发 OpenAI 应用。因为我后面要分享的主要是踩过的坑,所以先系统性了解会好很多:
Building a GPT-3 app with Next.js and Vercel Edge Functions
官方教程,还有 Demo 网站 和 GitHub 项目,非常友好。虽然是英文写的,不过并不难懂,实在不行就用 Edge 浏览器自带的翻译功能吧,建议大家好好学习英文。
自有域名+CNAME 实现国内访问
Vercel 给免费版用户也提供子域名+ SSL 证书,很多时候都够用,但可惜,vercel.app
大域名被墙了,连带所有子域名都无法访问。好在 Vercel CDN 在国内也还能用,所以我们只需要一个自己的域名即可。
申请域名的选择有很多,国内几大云服务商都能注册,国外的域名供应商也可以放心使用。我比较常用的是 namecheap.com。便宜的比如 .xyz 域名首年只要几块钱,随便注册一个就能用。
注册完域名之后,在 Vercel 后台找到自己的应用,在“Setting > Domains“里添加域名,然后 Vercel 会告诉你怎么配置 DNS。复制解析目标,在域名供应商 DNS 配置页面完成 CNAME 配置,稍等片刻,解析生效后,即可得到一个国内也能正常访问的域名。
使用 Edge Function
Vercel Edge Function 与我们日常开发的 node.js 服务器略有区别。它并非完整的 node.js,而是 Edge 基于 V8 专门打造的袖珍运行时,尽可能轻量化,裁剪掉很多系统 API。功能少,但是速度很快,几乎零启动时间。(我之前将它跟 Supabase 记混了,以为它也是基于 deno 的。)
使用 Edge Function 的好处,简单来说:省运维;详细来说,大概有这么几点:
- 性能更好。比随便买个小水管强得多。
- 自带弹性伸缩。不管访问量怎么成长,都有 Vercel 集群帮我们自动伸缩。(当然可能需要付钱)
- 启动速度比 serverless 快很多,基本没有等待时间。
- 免费额度足够初期 MVP 验证。
坏处当然也有。首先,Edge Function 里跑的是 TS,这就意味着很多兼容 JS 的开源仓库都不能用。其次,Edge Function 很多原生 API 都不支持,所以没有特意兼容的仓库也不能用。举个例子,要完成网络请求,大家最熟悉的 Axios 就不能用,只能用系统原生的 fetch
。
解决超时问题
由于算法原因,OpenAI API 返回数据的总时间可能比较长,而 Edge Function 的等待时间又限制得很严。所以如果等待 OpenAI 返回全部数据再渲染,可能因为等太久,在 Edge Function 这里会超时。
解决方案就是使用流(stream
)式传播。在这种情况下,OpenAI 会逐步返回结果(差不多一个单词一个单词这样蹦),只要在客户端进行组合,就能看到类似实时输入的效果。
完整的范例代码上面的官方文章有,我就不复制粘贴了,大家注意就好。
Edge Function 的流不是最初的流
这里有个坑,虽然我们在 Edge Function 里获取了 OpenAI API 的流,然后转发出来,但实际上我们接收到的流并不是最初的流。最初的流里,每次发送的数据都是完整的 JSON 文件,可以直接解析;但是 Edge Function 里转发给我们的却是前后合并后随机切分的结果。
于是我们必须重新整理响应体。大概方案如下:
- 在每次返回的响应体里找到两个 json 的连接处
- 截断,拿到前面的 json,解析,得到自己想要的数据
- 继续查找完整的 json,如果没有,则和下一次响应体连接起来处理
核心代码如下:
class fetchGpt {
fetch() {
// 前面的代码参考官方例子
// 我从循环读取开始
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
if (!value) {
break;
}
// readableStream 读出来的是 Uint8Array,不能直接合并
if (chunkValue.length > offset) {
lastValue = concatUint8Array(lastValue, value);
} else {
lastValue = value;
offset = 0;
}
chunkValue = decoder.decode(lastValue);
[finishReason, offset] = this.parseChunk(chunkValue, offset);
}
}
parseChunk(chunk: string, offset = 0): [string, number] {
let finishReason = '';
let nextOffset = offset;
while (nextOffset !== -1) {
nextOffset = chunk.indexOf(splitter, nextOffset + 1);
const text = nextOffset !== -1
? chunk.substring(offset, nextOffset)
: chunk.substring(offset);
try {
const json = JSON.parse(text);
const [ choice ] = json.choices;
const { delta, finish_reason } = choice;
const chunkContent = delta?.content || '';
// 这里我把数据交给事件 和 pinia 处理
this.emit(MessengerEvent.MESSAGE, chunkContent, json.id, json.created);
this.store.appendTextToLastItem(chunkContent, {
id: json.id,
created: json.created,
system: this.options.system || '',
});
finishReason = finish_reason;
offset = nextOffset !== -1 ? nextOffset : chunk.length;
} catch (e) {
//- ignore
}
}
return [finishReason, offset];
}
常见 API 使用错误
大家都知道,OpenAI 按照请求响应的 token 数算钱。所以我就想精打细算,通过在请求参数里减少 max_tokens
,尽量少返回些内容。
事实证明这个做法不成立。首先,OpenAI 对请求的兼容性不高,max_tokens
如果是 NaN
或者带有小数,都会报错。其次,ChatGPT 很啰嗦,内容量少了,它不过瘾,就会反复要求继续(finish_reason: 'length'
),尤其在极端条件下,如果我们自动 continue
,可能会误入死循环。
所以我建议,max_tokens
最少 128,尽量 256 以上。
前几天,有朋友在我的博客下面留言,说 Whisper 他试过,没啥特别的。我的观点不是这样。所谓纸上得来终觉浅,绝知此事要躬行。很多东西,确实不复杂,照着官方教程弄,三下五除二,在本地跑起来,并不难。但是想弄得干净利索,在生产环境里跑顺,遇到什么问题都能快速解决,也不是随随便便就能搞定的。
希望有类似需求,寻求类似解决方案的同学能少走弯路;也欢迎大家多多分享,有意见建议,欢迎留言讨论。
欢迎吐槽,共同进步