在 Laf 中玩转 OpenAI 原生接口

这是之前在 Laf 中快速搭建 ChatGPT 的例子「优化版 流式更快」三分钟搭建自己的ChatGPT。

里面用到的 laf 模板是这样的:

import cloud from '@lafjs/cloud'
const apiKey = 'your apikey'

export default async function (ctx: FunctionContext) {
  const { ChatGPTAPI } = await import('chatgpt')
  const { body, response } = ctx

  // get chatgpt api
  let api = cloud.shared.get('api')
  if (!api) {
    api = new ChatGPTAPI({ apiKey })
    cloud.shared.set('api', api)
  }

  // set stream response type
  response.setHeader('Content-Type', 'application/octet-stream');

  // send message
  const res = await api.sendMessage(body.message, {
    onProgress: (partialResponse) => {
      if (partialResponse?.delta != undefined)
        response.write(partialResponse.delta)
    },
    parentMessageId: body.parentMessageId || ''
  })

  response.end("--!" + res.id)
}

这里用到了一个 nodejs 包:const { ChatGPTAPI } = await import('chatgpt');但它实际上并不是 openAI 的官方包。(这里附上它的项目地址:chatgpt-api,感兴趣的同学可以了解一下)

其实,我们还有另外一个选择:只使用 OpenAI 的原生接口。

这样,既减少了导入外部包时带来的不必要的依赖,也不用再担心外部包升级时可能导致的莫名其妙的报错。还可以从零实现 ChatGPT 的核心功能,非常干净,非常清爽。

如果,你也想这么干的话,那么你可以尝试一下这份代码:登录 laf.dev, 点击函数市场,选择这个函数模板:

在 Laf 中玩转 OpenAI 原生接口_第1张图片

设置一下环境变量 OPENAI_API_KEY:

在 Laf 中玩转 OpenAI 原生接口_第2张图片

小小测试一下:

在 Laf 中玩转 OpenAI 原生接口_第3张图片

在 Laf 中玩转 OpenAI 原生接口_第4张图片

OK,没问题。

这里的运行结果是由两部分组成的:{回复}--!{id}

如果发起的 POST 请求不带参数 parentMessageId(即上一条信息的 id),就会开始一个新的对话;如果带上了 parentMessageId,就会接着上一条信息继续往下聊。就像这样:

在 Laf 中玩转 OpenAI 原生接口_第5张图片

在 Laf 中玩转 OpenAI 原生接口_第6张图片

然后!点击发布(你肯定找得到这个按钮),这个函数就可以外网访问了。

laf小小解释一下这份代码在做什么

模板代码如下:

import cloud from '@lafjs/cloud'
import util from "util"

const db = cloud.database()

export default async function (ctx: FunctionContext) {
  const { v4: uuidv4 } = require('uuid')
  const { getEncoding } = require('js-tiktoken')

  const maxConversationTokens = 13000
  let curConversationTokens = 0
  const maxReplyToken = 1000

  let encoding = cloud.shared.get('encoding')
  if (!encoding) {
    encoding = getEncoding('cl100k_base')
    cloud.shared.set('encoding', encoding)
  }

  const { body, response } = ctx
  response.setHeader('Content-Type', 'application/octet-stream')

  const curQuestion = { "role": "user", "content": body.message }
  curConversationTokens += CountMessagesTokens(encoding, [curQuestion])
  const parentMessageId = body?.parentMessageId || ''
  const messageId = uuidv4()

  let conversationHistory = []
  let parentMessageIdTmp = parentMessageId
  while (parentMessageIdTmp !== '') {
    const parentMessageRes = await db.collection('messages').where({
      messageId: parentMessageIdTmp,
    }).getOne()
    if (curConversationTokens + parentMessageRes.data.tokens < maxConversationTokens) {
      conversationHistory.unshift(...parentMessageRes.data.message);
      parentMessageIdTmp = parentMessageRes.data.parentMessageId;
      curConversationTokens += parentMessageRes.data.tokens;
    } else {
      break
    }
  }

  conversationHistory.push(curQuestion)

  const data = {
    model: "gpt-3.5-turbo-16k",
    messages: conversationHistory,
    max_tokens: maxReplyToken,
    stream: true,
  }

  await streamFetch({
    data, onMessage: (partialResponse) => {
      response.write(partialResponse)
    }
  }).then((responseText) => {
    const reply = { "role": "assistant", "content": responseText };
    const message = [curQuestion, reply];
    const tokens = CountMessagesTokens(encoding, message);
    db.collection('messages').add({
      parentMessageId,
      messageId,
      message,
      tokens,
    })
  }).catch((error) => {
    console.error('Error:', error);
  })

  response.end("--!" + messageId)

}

export const streamFetch = ({ data, onMessage }) =>
  new Promise(async (resolve, reject) => {
    let responseText = '';
    try {
      const response = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        body: JSON.stringify(data),
      });

      const reader = response.body?.getReader();
      if (!reader) {
        console.error('Response aborted.')
        return reject("Response aborted.");
      }
      const decoder = new util.TextDecoder('utf-8');

      const read = async () => {
        try {
          const { done, value } = await reader?.read();
          if (done) {
            return resolve(responseText);
          }

          const chunk = decoder.decode(value);
          const lines = chunk.split("\n");
          const parsedLines = lines
            .map((line) => line.replace(/^data: /, "").trim())
            .filter((line) => line !== "" && line !== "[DONE]")
            .map((line) => JSON.parse(line));

          for (const parsedLine of parsedLines) {
            const { choices } = parsedLine;
            const { delta } = choices[0];
            const { content } = delta;
            if (content) {
              onMessage(content);
              responseText += content;
            }
          }
          read();
        } catch (error) {
          console.error('Response aborted.')
          return reject("Response aborted.");
        }
      };
      read();
    } catch (error) {
      console.error("Error:", error);
      return reject(typeof error === 'string' ? error : error?.message || 'Request aborted.');
    }
  });

function CountMessagesTokens(encoding, messages) {
  const tokens_per_message = 3
  const tokens_per_name = 1

  let numTokens = 0;

  for (const message of messages) {
    numTokens += tokens_per_message

    for (const [key, value] of Object.entries(message)) {
      numTokens += encoding.encode(value).length

      if (key === 'name') {
        numTokens += tokens_per_name
      }
    }
  }

  return numTokens;
}

首先小小解释一下这份代码的核心:OpenAI 的原生接口

目前业内已经有大量的 gpt 相关工具;但归根结底,大家都是在调用 OpenAI 的这个 API:

在 Laf 中玩转 OpenAI 原生接口_第7张图片

这个 API 的核心参数是 messages;ChatGPT 之所以记得你说过什么,是因为我们发送的 messages 带上了过去的对话记录;messages 格式如下:

//messages
[
    {“role”:"system", "content": "$ 提示词"},
    {“role”:"user", "content": "$ 用户说的第一句话"},
    {“role”:"assistant", "content": "$AI的第一句回复"},
    ...
    {“role”:"user", "content": "$ 用户说的第N-1句话"},
    {“role”:"assistant", "content": "$AI的第N-1句回复"},
    {“role”:"user", "content": "$ 用户说的第N句话"},
]

发送过去后,OpenAI 就会返回给你一条最新的消息:{“role”:"assistant", "content": "$AI的第N句回复"}

理解了这个概念后,这份代码就好理解了:

首先,取出 POST 请求中的 message, 小小拼装一下:

curQuestion = { "role": "user", "content": body.message }
curConversationTokens += CountMessagesTokens(encoding, [curQuestion])
const parentMessageId = body?.parentMessageId || ''
const messageId = uuidv4()

请求中若带有 parentMessageId,就说明是有历史对话的;我们得去云数据库中递归查找,把所有历史对话串起来:

//递归查找所有历史对话记录
//若对话记录已超过 maxConversationToken,则停止
let conversationHistory = []
let parentMessageIdTmp = parentMessageId
while (parentMessageIdTmp !== '') {
  const parentMessageRes = await db.collection('messages').where({
    messageId: parentMessageIdTmp,
  }).getOne()
  if (curConversationTokens + parentMessageRes.data.tokens < maxConversationTokens) {
    conversationHistory.unshift(...parentMessageRes.data.message);
    parentMessageIdTmp = parentMessageRes.data.parentMessageId;
    curConversationTokens += parentMessageRes.data.tokens;
  } else {
    break
  }
}

conversationHistory.push(curQuestion)

这里有一行代码:if (curConversationTokens + parentMessageRes.data.tokens < maxConversationTokens),这是在干什么? 这是因为发送的 conversationHistory 不可能是无限长的;最新版的 gpt-3.5-turbo-16k 的 tokens 限制是 16k。 所以,我们得保证 conversationHistory 使用的 tokens 不大于 maxConversationTokens;文中的 CountMessagesTokens 函数就是用来计算每条 Message 使用的 tokens。

串起来后的会话记录 conversationHistory 长这样:

//conversationHistory
[
    {“role”:"user", "content": "$ 用户说的第N-X句话"}, // N-X 最小为 1
    {“role”:"assistant", "content": "$AI的第N-X句回复"},
    ...
    {“role”:"user", "content": "$ 用户说的第N-1句话"},
    {“role”:"assistant", "content": "$AI的第N-1句回复"},
    {“role”:"user", "content": "$ 用户说的第N句话"},
]

然后,我们使用 streamFetch 函数向 openAI 发起请求,并接收它的流式输出,再将它的流式输出再流式返回给我们的前端(狠狠套娃);关于 streamFetch的实现这里不展开,就叨一嘴我们该咋用:

await streamFetch({
  data, onMessage: (partialResponse) => {
    response.write(partialResponse)
  }
}).then((responseText) => {
  const reply = { "role": "assistant", "content": responseText };
  const message = [curQuestion, reply];
  const tokens = CountMessagesTokens(encoding, message);
  db.collection('messages').add({
    parentMessageId,
    messageId,
    message,
    tokens,
  })
}).catch((error) => {
  console.error('Error:', error);
})

onMessage 是一个委托函数,可以理解为:openAI 每流式输出一个字,你都可以用 onMessage 去处理这个字;我们的处理也很简单,直接将这个字写回 response,就实现了流式输出~

responseText 是 openAI 响应结束后,输出的完整内容;我们将它拼装一下:const message = [curQuestion, reply], 就得到了下面这个东西:

// message
[
    {“role”:"user", "content": "$ 用户说的第N句话"},
    {“role”:"assistant", "content": "$AI的第N句回复"}
]

计算一下它的 tokens,将message、messageId、parentMessageId、 tokens 存入云数据库中,结束!等待下一次用户请求的召唤 ~

laf搭个前端吧!

可以直接使用这个项目 chatGPT demo

修改项目中 src/views/chat/index.vue 的这两行代码,分别是 117 行 和 236 行:将 url 替换为你刚才发布的函数的 url~

在 Laf 中玩转 OpenAI 原生接口_第8张图片

在 Laf 中玩转 OpenAI 原生接口_第9张图片

在 Laf 中玩转 OpenAI 原生接口_第10张图片

在本地测试一下:npm run dev

在 Laf 中玩转 OpenAI 原生接口_第11张图片

非常丝滑,兄弟。

然后执行:npm run build,在当前目录下就会多出一个 dist 文件夹。

点击存储——创建Bucket(注意是公共读)——上传文件夹(将 dist 文件传上去)——开启网站托管,就可以访问这个网站了!

在 Laf 中玩转 OpenAI 原生接口_第12张图片

在 Laf 中玩转 OpenAI 原生接口_第13张图片

在 Laf 中玩转 OpenAI 原生接口_第14张图片

laf结束了吗?

我们只用 OpenAI 的原生接口,就从零搭建了自己的 ChatGPT。估计大家也能看到,最近市面经常有角色扮演、或者接入知识库的 ChatGPT;如果你认真看了上面的内容,估计你也能猜到:

messages = [
    {“role”:"system", "content": "$ 提示词"},
    {“role”:"user", "content": "$ 用户说的第一句话"},
    {“role”:"assistant", "content": "$AI的第一句回复"},
    ...
    {“role”:"user", "content": "$ 用户说的第N-1句话"},
    {“role”:"assistant", "content": "$AI的第N-1句回复"},
    {“role”:"user", "content": "$ 用户说的第N句话"},
]

只要在 messages 合适的位置中插入 role 为 system 的 message,我们就可以设置提示词去引导 GPT,让它成为自己想要的形状~


关注我们,下一期继续教大家用最低成本,从零让我们的 GPT 扮演角色、接入知识库噢~

引用链接
[1]
chatgpt-api: https://github.com/transitive-bullshit/chatgpt-api

[2]
laf.dev: https://laf.dev/

[3]
chatGPT demo: https://github.com/lifu963/chatgpt-demo

关于 Laf
Laf 是一款为所有开发者打造的集函数、数据库、存储为一体的云开发平台,助你像写博客一样写代码,随时随地发布上线应用!3 分钟上线 ChatGPT 应用!

GitHub:https://github.com/labring/laf

官网(国内):https://laf.run

官网(海外):https://laf.dev

开发者论坛:https://forum.laf.run

关注 Laf 公众号与我们一同成长

阅读 1217

Laf 开发者

发消息
人划线

sealos 以kubernetes为内核的云操作系统发行版,让云原生简单普及

laf 写代码像写博客一样简单,什么docker kubernetes统统不关心,我只关心写业务!

你可能感兴趣的:(云计算)