我们非常重视您的个人隐私,当您访问我们的网站时,请同意使用的所有cookie。有关个人数据处理的更多信息可访问《隐私政策》

400-090-9889

扒一扒Chatgpt背后的工程开发技术

技术趋势
2023-08-14

SSE 技术概述

SSE 是一种基于 HTTP 的实时通信技术,允许服务器向客户端(如 Web 浏览器)实时推送消息。SSE 的基本原理是通过建立一个持久的 HTTP 连接,服务器可以主动将事件发送到客户端,而无需客户端重复发送请求。以下是 SSE 技术的一些主要特点:

基于文本:SSE 使用纯文本格式传输数据,这使得数据的解析和处理变得相对简单。

单向通信:与 Websockets 等双向通信技术相比,SSE 主要用于服务器向客户端的单向通信,减少了通信的复杂性。

重连机制:在连接中断时,SSE 客户端会自动尝试重新连接服务器,从而确保实时数据的传输不会中断。

事件标识:SSE 支持为不同类型的事件设置标识,使客户端能够根据事件类型进行相应的处理。

1280X1280.png

与 Websockets 的比较

尽管 SSE 和 Websockets 都是实时通信技术,但它们在某些方面有所不同:

通信方向:Websockets 支持双向实时通信,而 SSE 主要用于服务器向客户端的单向通信。

协议:Websockets 使用独立的协议进行通信,而 SSE 则基于 HTTP 协议。

数据格式: Websockets 支持传输二进制和文本数据,而 SSE 仅支持文本数据。


根据项目需求和场景,开发者可以选择适合的实时通信技术。

在 ChatGPT 中,我们主要关注服务器向客户端推送消息,因此选择了 SSE 作为实现实时通信的技术。

SSE 的局限性

只能使用 GET 请求SSE 是基于 HTTP GET 请求的,因此无法直接使用 POST、PUT 等其他请求,也就是说无法在请求中传递 body 类的参数。这是因为 SSE 的设计初衷是用于服务器向客户端发送实时事件和数据,而不是用于客户端向服务器发送数据。

无法在请求中传递 header

由于浏览器的安全策略,SSE 请求无法直接携带自定义 HTTP 头部。这可能在某些情况下带来限制,例如在需要身份验证时传递令牌。虽然有以上局限性,但我们还是可以通过一些方法来解决这些问题。

使用 URL 参数来传递数据,将所需的数据作为 URL 参数附加到 SSE 请求,适合传递一些简单的参数

// 客户端
// 这里的 uid 就是要传递参数
const eventSource = new EventSource('/sse/uid');

// 服务端
@Sse('sse/:uid')
sse(Param() params): Observable<MessageEvent> {
  const { uuid } = params;
} {\n  const { uuid } = params;\n}"},"attribs":{"0":"*0|8+4v*0+1"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":45,"type":"text","selection":{"start":0,"end":176},"recordId":"MfV9doVaTo9HjFxPUZOcrKRBnqb"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">

npm 上有个EventSource的 [polyfill][2polyfill], 使用它替换掉EventSource后就可以在请求中携带 header 了,像身份令牌这种重要信息,还是建议放到 header 中。

const es = new EventSourcePolyfill('/sse', {
  headers: {
    'X-Custom-Header': 'value'
  }
});

用 SSE 试一试

既然 SSE 有这些限制,那么 ChatGPT 是怎么做到将prompt传到后端,然后后端再通过 SSE 返回答案的呢?可能有人会说通过 GET 请求的 query参数来传递prompt参数,比如sse?question=xxx,但是这样的话,如果prompt很大,比如一个几百上千字的prompt就会有问题了,一个是不安全,另一个是可能超过 URL 的长度限制。同样地,将prompt放到 header里也不太合适,毕竟是业务字段,不应该放到 header 里。

如果是局限在一个请求里面是去思考的话,可能确实无法做到,但如果是多个请求呢?我们可以通过多个请求来实现,比如先通过一个请求将prompt传到后端,后端返回一些参数给前端后,前端再通过 SSE 去后端请求答案,这样就可以避免上面的问题了。

我们可以先发送一个 POST 请求,将prompt放到请求的body里,后端接收到 POST 请求后,将prompt存到数据库里,并返回一个id给前端。


@Post('new')
async chat(@Body() data: { prompt }) {
  const { prompt } = data;
  const id = await this.chatService.createChat(prompt);
  return { id };
}

前端接收到这个 id 参数后,将 id 放到 SSE 的请求里,再向后端发送请求。

const es = new EventSourcePolyfill(`/sse/${id}`, {
  // ...
});

可以看到这里将 id 参数作为 url 的param,这样后端就可以通过 sse 请求获取到这个 id 了,然后再通过id去数据库里取出prompt,然后再去调用 ChatGPT 的 API,最后将答案返回给前端


@Sse('sse/:id')
sse(Param() params): Observable<MessageEvent> {
  const { id } = params;
  const prompt = await this.chatService.getChat(id);
  const answer = await this.chatService.getAnswer(prompt);
  // 这里的 answer 就是 ChatGPT 返回的答案,然后通过 sse 返回给前端
} {\n  const { id } = params;\n  const prompt = await this.chatService.getChat(id);\n  const answer = await this.chatService.getAnswer(prompt);\n  // 这里的 answer 就是 ChatGPT 返回的答案,然后通过 sse 返回给前端\n}"},"attribs":{"0":"*0|6+6x*0+1"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":61,"type":"text","selection":{"start":0,"end":250},"recordId":"NFTGd6j9DoHhUdxkBDrcwM4Enke"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">


前端技术

ChatGPT 前端技术在聊天机器人应用中扮演着重要的角色,它不仅能够帮助开发人员构建自然、智能的对话界面,还可以支持各种文本渲染效果。

Markdown 在 ChatGPT 中的应用

Markdown 是一种轻量级标记语言,它使用简单的标记符号,如星号和井号等,来标记文本的样式和格式,例如加粗、斜体、标题、列表等。Markdown 文本可以很容易地转换成 HTML 或其他格式,因此被广泛用于写作、博客、文档等领域。ChatGPT 后端返回的对话文本中,可能会包含 Markdown 格式的文本内容,如代码块、表格、数学公式等。ChatGPT 需要能够解析这些文本内容,并将其渲染成人类可以识别的格式。由于 Markdown 简单易用、具有可读性、可扩展性等优点,因此 ChatGPT 中选择使用 Markdown 作为标记语言,并借助相关工具将 Markdown 文本转换成 HTML 格式,实现各种文本效果,使 ChatGPT 能够更好地展示内容给用户。

marked 介绍

marked 是一个流行的、高性能的 Markdown 解析器和编译器,使用 JavaScript 编写。它可以将 Markdown 文本快速转换为 HTML,因此广泛应用于前端项目和 Node.js 环境中。

type OptionsType = {
    highlight: (function(code: string, lang: string, callback: function(err: Error, code: string)))=,
    renderer: marked.Renderer=,
    gfm: boolean=,
    tables: boolean=,
    breaks: boolean=,
    pedantic: boolean=,
    sanitize: boolean=,
    smartLists: boolean=
};
marked(markdownString: string, options: OptionsType=, callback: Function=): string;

  • code(string code, string language)

  • blockquote(string quote)

  • html(string html)

  • heading(string text, number level)

  • hr()

  • list(string body, boolean ordered)

  • listitem(string text)

  • paragraph(string text)

  • table(string header, string body)

  • tablerow(string content)

  • tablecell(string content, object flags)

  • strong(string text)

  • em(string text)

  • codespan(string code)

  • br()

  • del(string text)

  • link(string href, string title, string text)

  • image(string href, string title, string text)

在 ChatGPT 的前端页面中,marked 扮演了重要角色。由于 ChatGPT 需要处理和展示大量富文本内容,使用 marked 解析和编译 Markdown 文本成为了一个理想的解决方案。ChatGPT 利用 marked 的高性能特点,在实时预览功能中快速地将用户输入的 Markdown 文本转换为 HTML,以便用户能即时查看格式化后的效果,这为用户提供了一种高效的阅读体验。

渲染代码

在 ChatGPT 的前端页面中,使用 marked 结合 highlight.js 来渲染回答里的代码,marked 负责将 Markdown 文本转换为 HTML,而 highlight.js 则用于为代码片段提供语法高亮。


output.png

首先,引入 highlight.js 库,安装并导入所需的样式。接下来,创建一个自定义的 marked 渲染器,并重写代码块渲染方法。在这个方法中,使用 highlight.js 的 highlight() 函数对代码片段进行高亮处理,代码示例如下。

import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css'; // 或其他样式

这里我选择了 github 暗色主题的样式,highlight 还有很多种样式库,可以在`node_modules/highlight.js/styles/`目录下找到。如果你想自定义样式,可以在`node_modules/highlight.js/styles/`目录下找到`github-dark.css`,然后复制到你的项目中,修改样式后引入即可。 

 然后是创建一个自定义的 marked 渲染器,并重写代码块渲染方法。在这个方法中,使用 highlight.js 的 highlight() 函数对代码片段进行高亮处理,代码示例如下。


const renderer = new marked.Renderer();

renderer.code = (code, language) => {
  const validLang = hljs.getLanguage(language) ? language : 'plaintext';
  const highlightedCode = hljs.highlight(code, { language: validLang }).value;
  return `<pre><code class="hljs ${validLang}">${highlightedCode}</code></pre>`;
}; {\n  const validLang = hljs.getLanguage(language) ? language : 'plaintext';\n  const highlightedCode = hljs.highlight(code, { language: validLang }).value;\n  return `${highlightedCode}`;\n};"},"attribs":{"0":"*0|6+8o*0+2"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":100,"type":"text","selection":{"start":0,"end":314},"recordId":"Y2x0dbgClosA8TxLiIGcgfjNnPb"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">


如果你是使用 React 来开发的话,可以使用 React 的 dangerouslySetInnerHTML 来渲染代码,代码示例如下。

<div dangerouslySetInnerHTML={{__html: marked.parse(message)}} />"},"attribs":{"0":"*0+1t"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":112,"type":"text","selection":{"start":0,"end":65},"recordId":"PD76dtqndotQ23x0uMuc32NZn6g"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">

渲染表格

ChatGPT 不仅可以渲染代码,还可以渲染表格。在 ChatGPT 的前端页面中,使用 marked 结合自定义样式的方式来渲染回答里的表格。

output.png

和渲染代码块一样,我们需要在 marked 渲染器上重写表格的渲染方法,代码示例如下。

renderer.table = (header, body) => {
  return `<table class="your-table-style">
    <thead>${header}</thead>
    <tbody>${body}</tbody>
  </table>`;
}; {\n  return `\n${header}\n${body}\n`;\n};"},"attribs":{"0":"*0|5+45*0+2"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":122,"type":"text","selection":{"start":0,"end":151},"recordId":"CbN2dqOIVoGyChxfTmDcwWQDnMg"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">


渲染数学公式

除了代码和表格,ChatGPT 还可以渲染数学公式。在 ChatGPT 的前端页面中,使用 marked 结合 KaTeX 的方式来渲染回答里的数学公式。

output.png

KaTeX 是一个高性能的数学公式渲染库,可以在浏览器和服务器端快速渲染 [LaTeX 数学公式][3LaTeX 数学公式]。KaTeX 具有出色的性能和高度兼容性,适用于各种现代浏览器。与其他数学公式渲染库相比,KaTeX 更轻量且渲染速度更快。

首先引入 KaTeX 库及其相关的样式:

import katex from 'katex';
import 'katex/dist/katex.min.css';

接着定义内联公式解析和渲染规则:

const katexInline = {
  name: 'katexInline',
  level: 'inline',
  start(src: string) {
    return src.indexOf('$');
  },
  tokenizer(src: string) {
    const match = src.match(/^\$+([^$\n]+?)\$+/);
    if (match) {
      return {
        type: 'katexInline',
        raw: match[0],
        text: match[1].trim(),
      };
    }
  },
  renderer(token: marked.Tokens.Generic) {
    return katex.renderToString(token.text, katexOptions);
  },
};

这里创建了一个名为katexInline的对象,用于处理内联公式。tokenizer方法用正则表达式匹配以$符号包围的内联公式。如果匹配成功,返回一个包含type、raw和text属性的对象。renderer 方法使用 KaTeX 的 renderToString 方法将匹配到的公式文本转换为 HTML 字符串。

然后定义块级公式解析和渲染规则:


const katexBlock = {
  name: 'katexBlock',
  level: 'block',
  start(src: string) {
    return src.indexOf('\n$$');
  },
  tokenizer(src: string) {
    const match = src.match(/^\$\$+\n([^$]+?)\n\$\$+\n/);
    if (match) {
      return {
        type: 'katexBlock',
        raw: match[0],
        text: match[1].trim(),
      };
    }
  },
  renderer(token: marked.Tokens.Generic) {
    return `<p>${katex.renderToString(token.text, katexOptions)}</p>`;
  },
};${katex.renderToString(token.text, katexOptions)}`;\n  },\n};"},"attribs":{"0":"*0|j+cr*0+2"}},"apool":{"numToAttrib":{"0":["author","7076085030484705284"]},"nextNum":1}},"type":"text","referenceRecordMap":{},"extra":{"mention_page_title":{},"external_mention_url":{}},"isKeepQuoteContainer":false,"isFromCode":true,"selection":[{"id":146,"type":"text","selection":{"start":0,"end":461},"recordId":"A23hd1Ln5oQqyaxJT7Oc39oInkf"}],"payloadMap":{},"isCut":false}" data-lark-record-format="docx/text" class="lark-record-clipboard" style="white-space: normal;">


这样,marked 库就能在解析和渲染 Markdown 文本时,应用这两个扩展来处理内联公式和块级公式了,借助 KaTeX 库呈现出美观的数学公式效果。

总结

SSE 作为一种基于 HTTP 的实时通信技术,使得服务器能够主动将事件发送到客户端,而无需客户端重复发送请求。尽管 SSE 存在一定的局限性,在特定场景下,其简单、易用的特点使其成为实现单向实时数据传输的理想选择。

ChatGPT 页面中几种常见的渲染效果,包括代码、表格和数学公式,以及如何使用 marked 库和其他库来实现这些渲染效果。通过以上的例子,你应该已经掌握了如何在前端项目中使用 marked 库结合其他库来渲染页面。这些技术可以帮助你在开发过程中轻松实现高质量的内容展示,为用户提供更优质的阅读体验。




方案咨询