GPT 出世半年了,一个个『大模型』如雨后春笋般不断涌现。这场春雨能否滋润『已死』的前端呢?
个人认为 GPT 为软件点亮了新的技能点,对前端会产生『不大』的影响。这一点可以类比语音识别、自然语言处理、图像识别对前端的影响,语音识别+自然语言处理催生了一批『语音输入』的场景;图像识别则催生了一批『扫一扫』、『搜图』的场景。尽管这没有 『端』的变革 (比如智能机、Vision Pro) 对前端影响大,端的开发者也应该与时俱进地了解与 GPT 交互的方式。
闲扯结束,正片开始…
GPT 场景特征
GPT 的『问答式』交互很容易让人想到更熟悉的『聊天室』。两者有很多相同点:
- 对话内容:多模态。支持文字、图片、语音…
- 整体交互:发送消息、消息列表、历史记录
两者也存在细节的差异。
- 对话双方:GPT 一方固定是机器人,而聊天室是两个用户
- 对话机制:GPT 是半双工通信,人提问机器人回答,机器人不会主动说话。而聊天室是全双工通信,双方自由讨论
- GPT 机器人消息可逐句输出
- GPT 提问方可打断回答
通信方式
- WebSocket:参考聊天室很自然的选择,协议基于TCP实现。
- 全双工通信
- 不限制传输内容,适合多模态对话
- 保持连接,服务端可以用『连接』实例对应 GPT 所需对话上下文。
- SSE:半双工、可『一问多答』,协议基于HTTP实现。
- 限制条件:请求传参纯文本、服务端维护会话上下文、HTTP1.1浏览器的连接数限制
后文只介绍WebSocket的实现
WebSocket 封装
WebSocket 并不限制通信双方传输的内容,双方消息都以纯文本传输。
不加规范的纯文本会产生歧义,服务端收到『STOP』可以认为这是一个用户消息,也可以认为是『停止生成』的指令。
因此产生了各式各样的封装,比如stompjs。使用类似的封装意味着服务端也要实现其中定义的规范。 在问答场景中就大材小用了。
这里选取JSON形式封装消息,通信中双方仅互认符合JSON规范的内容。eventType
用于描述指令,比如 SEND(提问)、ACK(确认)、STOP(打断);body
类似 HTTP 消息体,承载消息内容。
{
eventType: '指令名',
body: any
}
为了减少 WebSocket 连接数,我希望一次页面访问同一时刻只维护一个 WebSocket 链接。
let ws: WebSocket | null = null;
const chat = {
// 建立连接
open() {},
// 断开连接
close() {},
// 发送消息
send() {},
// 重新生成
rebuild() {},
// 停止生成
stop() {},
};
open/close
断开连接时调用原生的 close
后释放实例即可。
let ws: WebSocket | null = null;
const chat = {
// ...
close() {
ws?.close();
ws = null;
},
};
建立连接的过程是异步的,可能产生连接可用和连接失败两个结果,因此返回 Promise 。
let ws: WebSocket | null = null;
const chat = {
// ...
open(): Promise<boolean> {
if (ws) {
return Promise.resolve(true);
}
const protocol = location.protocol.startsWith("https:") ? "wss" : "ws";
return new Promise((res, rej) => {
ws = new WebSocket(`${protocol}://${location.host}/ws-chat`);
onceListener("open")
.then(() => {
// 开启心跳
pingCyclically();
sendMessage("OPEN", {})
.then(() => subscribeOnce("OPEN"))
.then(() => res(true))
.catch(() => res(false));
})
.catch(() => {
res(false);
});
onceListener("error").finally(() => {
rej(new Error("连接意外关闭"));
});
onceListener("close").finally(() => {
res(false);
});
});
},
};
- onceListener: 封装了原生的 addEventListener,只监听最近一次事件触发。
- sendMessage: 封装了原生的 send ,包装约定的 eventType JSON 结构,并支持消息延迟发送。
- subscribeOnce: 封装了原生的 addEventListener,只监听最近一次特定的 eventType 的 message 事件触发
心跳机制
在信道空闲的情况下,WebSocket 实例无法实时更新连接状态。 这会导致需要通信时,发送方不能提前感知信道状态。
想象我们打电话时沉默一段时间后问对方『你在听吗』的意图。解决方案是定期向信道内放『噪声』,类似投石问路,这就是心跳机制。
let ws: WebSocket | null = null;
let pingTimer: number = 0;
function pingCyclically() {
clearInterval(pingTimer);
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
sendMessage("OPEN", {});
} else {
clearInterval(pingTimer);
}
}, 5000);
这里约定了 eventType: OPEN
作为心跳事件。
send
发送消息的会话模式如下:
ACK 是用户提问的确认,当前提问在历史中的结构。MESSAGE 是逐句返回的指令,以 FINISH 指令结尾。生成期间穿插 ERROR 指令表示异常终止。
let ws: WebSocket | null = null;
}
const sendChat = async (
sentEventType: string,
data: Record<string, any>,
cb: (position: string, data: ServerLiveMessage | null, error?: Error) => void
) => {
const hasSent = await sendMessage(sentEventType, data);
if (hasSent) {
let count = 0;
const currentMessageRemover = subscribe("MESSAGE", (data, _, error) => {
if (error) {
cb(count ? "NORMAL" : "FIRST", null, new Error("连接失效"));
} else {
cb(count ? "NORMAL" : "FIRST", data);
}
count++;
});
const endRemover = subscribe(
/^ERROR|FINISH$/,
(data, res) => {
if (res.eventType === "FINISH") {
cb("LAST", null);
} else {
cb("LAST", data, new Error("服务异常"));
}
},
true
);
try {
return await subscribeOnce("ACK");
} catch (e) {
throw new Error("发送失败");
}
}
throw new Error("发送失败");
};
const chat = {
// ...
send: sendChat.bind(null, "SEND"),
};
参考 SEND – ACK 期间的状态返回 Promise ,顺序监听后续的服务端指令,触发外部回调处理消息,
stop
停止生成的会话模式是STOP的一问一答。
let ws: WebSocket | null = null;
const chat = {
// ...
async stop(data: Record<string, any>) {
const hasSent = await sendMessage("STOP", data);
if (hasSent) {
let data: ServerLiveMessage | null = null;
try {
data = await subscribeOnce("STOP");
} catch (e) {
throw new Error("发送失败");
}
return data;
}
throw new Error("发送失败");
},
};
参考来回两个 STOP 期间的状态返回 Promise。
rebuild
重新生成的会话模式与提问类似,只是提问的指令改为 REBUILD。
const chat = {
// ...
send: sendChat.bind(null, "REBUILD"),
};
细节处理
一旦触发停止生成,当前正在生成的回答就不应该监听了。可以将前面的 currentMessageRemover
和 endRemover
推入 eventRemoverList
,适时清除监听。
let eventRemoverList: Array<() => void> = [];
const clearEvent = () => {
eventRemoverList.forEach((fun) => fun?.());
eventRemoverList = [];
};
经过封装后,上层业务只需要调用对应的意图,处理消息列表渲染,即可与 GPT 交互。
完整代码
GPT 头脑风暴
- 希望保留无 GPT 的选项。鼠标提升了计算机的易用性,但是老手们也需要快捷键。同理,输入自然语言或者语音很易用,但对于意图明确的用户不够高效。
- 希望不要用 GPT 取代人工客服。目前的智能机器人还无法缓解人类用户的焦虑。某些平台切换到英文版才能召唤人工客服。
- 希望 GPT 能够减少各语言文化圈的隔阂。一个本地化的 GPT 可以成为世界了解当地的向导、语言学习的帮手、翻译阅读的助手。