开发

借助Cloudflare Workers打造一个专属OpenAI API

目前已上线积薪,未来会支持本博客

2023年6月29日
借助Cloudflare Workers打造一个专属OpenAI API
本文共有1470字,预计阅读时间7分钟
积薪有一个功能,即根据文章内容生成分类、标签和摘要,此前使用的是百度的API。这也成了反馈最为集中的功能,因为百度的AI实在是太拉垮了。
不仅分类经常出错,摘要也经常是驴唇不对马嘴,甚至就是文章内容的随机拼凑,连语序通顺都做不到。
https://darmau-image-1256887306.cos.accelerate.myqcloud.com/2023_06_29_15_54_20_25683d6c5c.png
可以看出分类错得离谱
https://darmau-image-1256887306.cos.accelerate.myqcloud.com/2023_06_29_15_54_34_9180a36775.png
摘要也是胡言乱语,连日期都摘上了
当初选百度纯粹是因为用起来方便,但这个准确度实在太差。GPT的准确度比较高,不如把AI接口迁移到OpenAI上。
但使用OpenAI可以说是穿墙领域最难的事了,你要同时对抗两个大国。如果只是单纯地调用API,很容易遭到封禁。
于是利用Cloudflare或Vercel这种具有边缘计算功能的平台来调用API,成了最方便稳妥的选择。
本文将示范如何通过Workers搭建一个你的专属OpenAI API。

前提

首先你需要一个OpenAI的账号,搞定支付相关事项。至于怎么搞定,你自己想办法。
然后你需要一个Cloudflare账号。
安装wrangler,在本地编写Workers代码:
1npm install wrangler
然后新建一个Workers项目:
1npm create cloudflare@latest

通用AI接口

workers.ts是主入口,所有请求都会经过这里。
不过先不用管它,我们从最末端的功能写起。
我希望这个接口能更通用,通过不同的路由指向不同的prompt。比如请求/summary并发送一段文本,就会返回摘要;请求/category会返回这段文本的分类。
这就需要有一个函数来负责这项工作,接收两个参数:prompt和content。
1// 接收一个propmt和内容,请求OpenAI,返回结果
2interface OpenAIResponse {
3  choices: {
4    "finish_reason": string;
5    "index": number;
6    "message": {
7      "content": string;
8      "role": string;
9    }
10  } [];
11  created: number;
12  id: string;
13  model: string;
14  object: string;
15  usage: {
16    "completion_tokens": number;
17    "prompt_tokens": number;
18    "total_tokens": number;
19  }
20}
21
22async function openAI(prompt: string, content: string, key: string) {
23  const message = [
24    { "role": "system", "content": prompt },
25    { "role": "user", "content": content }
26  ];
27  const apiURL = 'https://api.openai.com/v1/chat/completions';
28  const model = content.length > 3000 ? 'gpt-3.5-turbo-16k-0613' : 'gpt-3.5-turbo-0613';
29
30  const response = await fetch(apiURL, {
31    method: 'POST',
32    headers: {
33      'Content-Type': 'application/json',
34      'Authorization': `Bearer ${key}`
35    },
36    body: JSON.stringify({
37      messages: message,
38      model: model,
39      max_tokens: 500,
40      temperature: 0.5,
41      stop: ['\n']
42    })
43  });
44
45  const data: OpenAIResponse = await response.json();
46  const result = data.choices[0].message.content;
47  return result;
48}
49
50export default openAI;
这段应该不难理解。为了节省开销,我做了个简单的判断:文本长度超出一定限制时使用gpt-3.5-turbo-16k-0613模型,未超出默认使用gpt-3.5-turbo-0613。当然文本长度并不等于token,这只是个简单的判断,够用就行。
当你向该函数传入预设条件和待处理文本时,返回的就是一个纯文本。

设定prompt

通用函数写好,接下来就是处理不同接口对应的prompt了。
假设我希望在访问/seo时,返回这段文字适用于SEO展示的摘要。
1// 导入刚才的通用函数
2import openAI from "./open-ai";
3
4interface Env {
5	API_KEY: string;
6}
7
8const prompt = "我需要根据下面的这段文字, 针对搜索引擎优化, 写一段摘要, 要求写明文字的主要内容, 字数不得超过120个汉字";
9
10export default {
11  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
12    const content = await request.text();
13
14    return new Response(await openAI(prompt, content, env.API_KEY), { headers: { "Content-Type": "text/plain" } });
15  }
16}
这里的prompt很重要,由于我们希望这是一个接口,输出的内容保持稳定,因此你可以在prompt的设置上多花些心思。比如你可以使用更严谨的语言:
根据文本内容,输出1-3个与内容相关的标签。格式如下: “标签1”, “标签2”
这里面可以优化的空间太多了,在此不赘述。

配置路由

接下来需要在workers.ts里进行设置,以便在请求 /seo的时候能访问到相应代码。
1import generateSEO from './seo';
2
3interface Env {
4	API_KEY: string;
5}
6
7// Export a default object containing event handlers
8export default {
9	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
10
11		const url = new URL(request.url);
12
13		switch (url.pathname) {
14			case '/seo':
15				return generateSEO.fetch(request, env, ctx);
16		}
17
18		return new Response(
19			`This is a private API and cannot be accessed without authorization`,
20			{ headers: { 'Content-Type': 'text/html' } }
21		);
22	},
23};
这段代码的作用是,读取环境变量中的token,根据请求的endpoint,分发到相应处理逻辑中。你可以照此增加更多prompt和逻辑。
OpenAI的token可以在Workers的Dashboard - 设置中添加。

安全性

为了防止接口被滥用,可以稍稍增加一点安全措施。你可以随便生成一段token,放在请求头里,对于token不正确的请求一律拒绝。
最终workers.ts的代码是这样的:
1import generateSEO from './seo';
2
3interface Env {
4	API_KEY: string;
5	TOKEN: string;
6}
7
8export default {
9	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
10		// verify if the bearer token is valid
11		const auth = request.headers.get('Authorization');
12		if (!auth || auth !== `Bearer ${env.TOKEN}`) {
13			return new Response(
14				`This is a private API and cannot be accessed without authorization`,
15				{ headers: { 'Content-Type': 'text/html' } }
16			);
17		}
18
19		const url = new URL(request.url);
20
21		switch (url.pathname) {
22			case '/seo':
23				return generateSEO.fetch(request, env, ctx);
24		}
25
26		return new Response(
27			`This is a private API and cannot be accessed without authorization`,
28			{ headers: { 'Content-Type': 'text/html' } }
29		);
30	},
31};

测试

为了避免封号,建议一律先部署到Cloudflare,然后在云端测试。
执行npx wrangler deploy即可上传代码。
然后在该Workers内点击“快速编辑”,就可以在窗口中进行调试了。
https://darmau-image-1256887306.cos.accelerate.myqcloud.com/2023_06_29_16_00_14_5d30ae8ed9.png
这样你就有了一个稳定的专属API,只要向其不同的endpoint发送文本,就会根据预设的prompt返回相应的回复。
目前这个接口已经上线积薪,但我计划把本博客的后端彻底从头开发一遍,到时候也在这里提供相应的AI服务。这是个大工程,不知道什么时候能完成。

评论