今天和大家分享一下如何使用 chatgpt 工具来开发一款 chatgpt 类应用。
先上效果图:


开发的过程中,你可以直接使用下面的地址或客户端查询你遇到的问题。
- 体验地址:gpt.hayagu.com
- 客户端地址:gpt.hayagu.com/download
准备工作
OK,我们来快速创建一个 react 项目。不清楚的,可以问 GPT,如:

为了节省时间,我简单说一下比较关键的地方。如果遇到模块安装很慢的情况,也可以直接问 gpt:

安装 create-react-app
lua
npm install -g create-react-app
使用 create-react-app 创建项目
lua
npx create-react-app chatgpt
创建成功后,执行以下命令启动项目
sql
npm start
随后就能看到以下页面:




得到一个比较干净的页面后,我们就可以开始进入主题了:

构建前端页面
从下图可以看出,页面主要分为 3 个部分:
接下来,我们让 chatgpt 帮我们写一下布局,如下图:

chatgpt 给出的 css 代码如下:
css
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
height: 42px;
background-color: #3498db;
}
.body {
flex-grow: 1;
background-color: #2ecc71;
}
.footer {
height: 42px;
background-color: #e74c3c;
}
我们把代码放到 App.css 文件中。

html代码片段如下:
html
<div class="container">
<div class="header">header</div>
<div class="body">body</div>
<div class="footer">footer</div>
</div>
我们把代码 App.js 文件中

目前的效果为:

body 之所以没有把 footer 撑到底部,是因为我们把 html 片段放到了 root 元素下,我们把它的高度设置成 100% 就行了。


稍微调整一下页面:

UI我们用到 antd 这个框架,安装一下:
css
npm i antd

调整后的效果:

至于中间的内容,为了节省时间,我们使用 antd 中 List 组件来展示数据,步骤如下:
- 在 src 下新建名为 bizComponents 的文件夹;
- 在 bizComponents 下新建 List 文件夹;
- 在 List 下分别新建 index.css 和 index.js 两个文件;
- 将 List组件 的示例代码拷贝到 index.js 文件中。
如下图所示

图中的 js 代码如下:
javascript
import React from 'react';
import { Avatar, List } from 'antd';
const data = [
{
title: 'Ant Design Title 1',
},
{
title: 'Ant Design Title 2',
},
{
title: 'Ant Design Title 3',
},
{
title: 'Ant Design Title 4',
},
];
const App = () => (
<List
itemLayout="horizontal"
dataSource={data}
renderItem={(item, index) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`} />}
title={<a href="https://ant.design">{item.title}</a>}
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
/>
</List.Item>
)}
/>
);
export default App;
在 app.js 中引入我们刚才写的 List 组件:

微调一下列表的宽度和位置:

最终效果:

我们接着来完成表单部分的开发:
- 简单的表单校验:必填;
- 键盘事件
- enter,提交表单;
- shift + enter,换行
- 提交表单,获取表单数据

此时,app.js的完整代码:
ini
import "./App.css";
import { Form, Input } from "antd";
import List from "./bizComponents/List";
import { useCallback, useState } from "react";
const { TextArea } = Input;
function App() {
const [value, setValue] = useState("");
const [form] = Form.useForm();
const onFinish = useCallback(() => {
const formValues = form.getFieldsValue();
console.log("formValues:", formValues);
}, [form]);
const onchange = useCallback(
(e) => {
const value = e.target.value;
setValue(value);
},
[setValue]
);
const onkeydown = useCallback(
(e) => {
const value = e.target.value;
const keycode = e.keyCode;
// 按下Shift+Enter时换行
if (keycode === 13 && e.shiftKey) {
setValue(value);
return;
}
// 按下Enter时提交表单
if (keycode === 13 && !e.shiftKey) {
form.submit();
e.preventDefault();
}
},
[setValue, form]
);
return (
<div className="container">
<div className="header">header</div>
<div className="body">
<div className="list-wrapper">
<List />
</div>
</div>
<div className="footer">
<Form form={form} onFinish={onFinish}>
<Form.Item
name={"message"}
rules={[{ required: true, message: "请输入提示词" }]}
>
<TextArea
value={value}
autoSize={{ minRows: 1, maxRows: 15 }}
onChange={onchange}
onKeyDown={onkeydown}
/>
</Form.Item>
</Form>
</div>
</div>
);
}
export default App;
至此,前端部分我们基本上就准备好了。接下来就是到 LLM(大语言模型)中获取数据了。
后端服务
api 调用
目前最火的大语言模型就是 openai 公司的 gpt 系列模型了,这里,我们以 gpt-3.5-turbo-0613 为例进行分享。
调用 gpt 系列的模型,主要有两种方式,一种是直接使用 openai api,另一种是使用 azure openai api。下面先简单说一下他们的区别:
openai api vs. azure openai api
openai api
openai 公司提供的官方 api。
优点:
- 响应速度快,发起请求后,基本上是秒回;
- 功能更新及时,因为是官方的api,有什么新功能、新特性,立马就能直接使用;
- 功能、特性比较多,function_call、embedding、fine-tuning、dalle。
缺点:
- 不对中国开放,中国 ip 无法访问;
- 无法使用支付宝、微信支付、中国银行卡和信用卡进行支付。
azure openai api
由微软公司提供的 openai api 服务。
优点:
- 可以使用支付宝、微信支付等方式进行支付;
- 对中国大陆用户不做任何限制。
缺点:
- 响应速度偏慢,即使开启了 stream 模式,发起请求后,平均下来,也要 7 秒左右才有返回;
- 更新偏慢,每次 openai 发布新功能或新特性之后,要等好长一段时间才有;
- 功能缺失,不是所有的功能都有,有的功能干脆就是阉割版。比如: 无法使用function_call,embedding 功能的最大并发数是1,不过 token 数提高到了 8k;
- 只对企业用户开放,一般的企业用户,审核周期比较长,要 3 周左右的时间。
调用 openai api
打开终端,在 chatgpt 项目的同级目录下创建 chatgpt-server 文件夹
arduino
mkdir chatgpt-server
初始化一下项目,进入 chatgpt-server,然后执行
csharp
npm init
输入后,已知回车即可,初始化后,在当前目录下就生成了一个 package.json 文件。
服务端,我们主要使用 koajs,先安装几个我们会用到的模块:
css
npm i koa koa-router -D
接下来,我们让 gpt 帮我们生成代码:

图中代码
nodejs
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
router.get('/hello', (ctx, next) => {
ctx.body = 'Hello World';
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
在 chatgpt-server 文件夹下创建 index.js 文件,把生成的代码粘贴进去。
为了方便调试,我使用 node-dev 来启动服务。我们先安装一下 node-dev 模块:
css
npm i -g node-dev
安装完成后,启动服务:
node-dev index.js
启动成功后,将看到以下输出(避免端口占用,我把端口号改成了3003):

访问 http://localhost:3003/hello, 如果看到以下响应即表示代码、服务都没问题了。

为了获取请求体里面的参数,我需要添加 koa-bodyparser 模块。
安装模块:
css
npm i koa-bodyparser -D
引入模块:

此时,chatgpt-server.js 的代码为:
javascript
const Koa = require('koa')
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const router = new Router()
app.use( bodyParser())
router.get('/hello', (ctx, next) => {
ctx.body = "Hello World";
})
app.use(router.routes()).use(router.allowedMethods())
app.listen(3003, () => {
console.log('Server listening on port 3003')
})
添加新的接口:/generate
安装需要的新模块:
css
npm i openai koa-sse-stream dotenv -D
添加新接口:/generate

使用 postman 直接请求我们的接口,其返回值如下:

此时,chatgpt-server.js 的完整代码为:
javascript
const Koa = require('koa')
const Router = require('koa-router')
const { Configuration, OpenAIApi } = require('openai')
const bodyParser = require('koa-bodyparser')
const cors = require('@koa/cors')
const dotenv = require('dotenv')
dotenv.config() // 设置了之后,就可以通过 process.env 访问 .env 文件里面的内容
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
})
const openai = new OpenAIApi(configuration)
const app = new Koa()
const router = new Router()
app.use(cors()) // 支持跨域请求
app.use(bodyParser())
router.get('/hello', (ctx, next) => {
ctx.body = 'Hello World'
})
router.post('/generate', async (ctx, next) => {
const { messages } = ctx.request?.body
const completion = await openai.createChatCompletion({
model: 'gpt-3.5-turbo-0613',
messages,
})
ctx.body = completion.data?.choices[0]
})
app.use(router.routes()).use(router.allowedMethods())
app.listen(3003, () => {
console.log('Server listening on port 3003')
})
接下来,我们完成前端的部分。
修改 onFinish 方法:

添加获取数据的方法:
(图中的接口应该是 3003 )
图中的代码:
javascript
const onFinish = useCallback(() => {
const message = form.getFieldValue("message");
getChatMessage([
...contexts,
{
role: "user",
content: message,
},
]);
// 表单提交后清空输入框
form.setFieldValue("message", "");
}, [form, contexts]);
javascript
const [contexts, setContexts] = useState([]);
const getChatMessage = useCallback(async (contexts) => {
const res = await fetch(`http://localhost:3003/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: contexts,
}),
}).then(async (res) => await res.json());
setContexts([...contexts, res.message]);
}, []);
把数据传给 List 组件

调整后的 List 组件的代码:
javascript
import React from "react";
import { Avatar, List } from "antd";
const App = ({ data }) => (
<List
itemLayout="horizontal"
dataSource={data}
renderItem={(item, index) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`}
/>
}
title={item.role}
description={item.content}
/>
</List.Item>
)}
/>
);
export default App;
提问后的效果:

调用 azure openai api
调用 azure openai api 有以下几种方法:
这里有个我在网上找到的 教程,有需要可以看看。
stream mode
直接调用 LLM,等待时间比较长,用户体验很差。所以,通常我们在连接 LLM 的时候,都会使用 stream mode,一有结果马上返回,然后以"打字机"效果,逐渐展示内容。
安装所需模块:
css
npm i koa-sse-stream eventsource-parser showdown -D
在 chatgpt-server.js 中导入模块:

图中代码为:
php
const sse = require('koa-sse-stream')
const { createParser } = require('eventsource-parser')
const sseMiddleware = sse({
maxClients: 5000,
pingInterval: 30000,
})
修改 /generate 接口的代码:

此时的代码为:
javascript
router.post('/generate', sseMiddleware, async (ctx, next) => {
const { messages } = ctx.request?.body
let chunks = []
const onParse = (event) => {
if (event.data === '[DONE]') return
if (event.type === 'event') {
try {
const data = JSON.parse(event.data)
const text = data.choices[0].delta.content || ''
ctx.sse.send({
data: text,
event: 'data',
})
} catch (e) {
console.error('出错了:', e)
ctx.sse.sendEnd({
data: `出错了`,
event: 'error',
})
}
}
}
const parser = createParser(onParse)
const completion = await openai.createChatCompletion(
{
model: 'gpt-3.5-turbo-0613',
messages,
stream: true,
},
{
responseType: 'stream',
}
)
completion.data.on('data', (chunk) => {
try {
const dataStr = chunk.toString()
chunks.push(chunk)
parser.feed(dataStr)
} catch (e) {
console.warn('出错了:', e)
}
})
completion.data.on('end', () => {
ctx.sse.sendEnd()
})
})

kotlin
data: {"id":"chatcmpl-7s6V06L16jQBLxgY3mDfdB6ZakMco","object":"chat.completion.chunk","created":1693129050,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
接下来修改 App.js 的代码:
导入模块:

javascript
import { createParser } from "eventsource-parser";
修改 getChatMessage 方法:

javascript
const getChatMessage = useCallback(async (contexts) => {
const res = await fetch(`http://localhost:3003/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: contexts,
}),
});
let result = "";
const onParse = (event) => {
if (event.event === "data") {
result += event.data;
console.log("result:", result);
}
updateLastContext({
contexts,
context: {
role: "assistant",
content: converter.makeHtml(result),
},
});
};
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
const parser = createParser(onParse);
while (true) {
// 取值, value 是后端返回流信息, done 表示后端结束流的输出
const { value, done } = await reader.read();
if (done) break;
try {
let text = decoder.decode(value);
parser.feed(text);
} catch (e) {
console.log("出错了:", e);
}
}
}, []);
代码中用到的 updateLastContext 的代码:
javascript
const updateLastContext = useCallback(({ context, contexts }) => {
const len = contexts.length;
const lastContext = contexts[len === 0 ? 0 : len - 1];
if (lastContext?.role !== "user" && contexts?.length > 0) {
if (lastContext) {
lastContext.content = context.content;
}
setContexts([...contexts]);
} else {
setContexts([...contexts, context]);
}
}, []);
新建一个名为 utils.js 的文件,然后贴入以下代码:
javascript
import showdown from "showdown";
export const converter = new showdown.Converter({
tables: true,
tasklists: true,
parseImgDimensions: true,
simplifiedAutoLink: true,
strikethrough: true,
emoji: true,
underline: true,
ghMentions: true,
smartIndentationFix: true,
smoothLivePreview: true,
});
在 app.js 中导入该方法:

javascript
import { converter } from "./utils";
调整 bizComponents/List/index.js 的代码:

javascript
import React from "react";
import { Avatar, List } from "antd";
const App = ({ data }) => (
<List
itemLayout="horizontal"
dataSource={data}
renderItem={(item, index) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`}
/>
}
title={item.role}
description={
<div
dangerouslySetInnerHTML={{
__html: `${item.content}`,
}}
></div>
}
/>
</List.Item>
)}
/>
);
export default App;
至此,我们已经通过 stream mode 实现了打字效果。
接入 GPT4
前不久,openai已经开放了 gpt4 的权限,不需要排队,直接使用即可。整体的过程和使用 3.5 的时候一样。只要把模型换成 gpt4 就行了。修改 chatgpt-server.js 的代码:

javascript
const completion = await openai.createChatCompletion(
{
model: 'gpt-4-0613',
messages,
stream: true,
},
{
responseType: 'stream',
}
)
值得一提的是,就算你使用的是 gpt4 的模型,当你询问它"你是GPT几"的时候,它依然会回答你,它是 gpt3.
曾经遇到的问题
流模式下的错误捕获
调用 openai api 的时候,是通过 try...catch 的方式捕获错误:
javascript
try {
...
const completion = await openai.createChatCompletion(
...
)
...
} catch(e) {
const { status, data = {} } = e.response || {}
e.response.data.on('data', (data) => {
const message = data.toString()
console.log('error:', message)
})
}
代码高亮(返回的内容中,代码部分不全,无法解析)
开启了流模式后,在内容返回的过程中,在某一段时间内,由于返回的内容不全,导致在将 markdown 转成 html 时,会出现异常(比如在展示代码的时候),导致没法正常展示,如下图所示

我的解决方案是,在渲染前,先判断是否有缺失"```":
javascript
...
const getSemiFinishedContent = (string, identifier) => {
let regex = new RegExp(identifier, 'img')
let matches = []
let match = null
// 找到标识符所在的所有位置
while ((match = regex.exec(string))) {
matches.push(match.index)
}
// 找到半闭合标签的位置
const matchLen = matches.length
const num = matchLen % 2
return {
pos: num !== 0 ? matches[matchLen - 1] : -1,
matchStr: num !== 0 ? string.slice(matches[matchLen - 1]) : '',
}
}
// 处理一下半闭合的代码块内的标签转义问题(既然缺少"```",那就给它补上)
const semiFinishedInfo = getSemiFinishedContent(newResult, '```')
if (semiFinishedInfo.pos !== -1) newResult += '\n```'
...
stream mode 下的 token数计算
开启了 stream 模式后,openai 就不会再返回我们消费的 token 数的信息,需要自己手动计算。这里,我们用到了 tiktoken-node 这个模块:
javascript
import tiktoken from 'tiktoken-node'
const enc = tiktoken.encodingForModel('gpt-3.5-turbo-0613')
enc.encode(str).length // 这里得到的就是 str 对应的 token 数