Press "Enter" to skip to content

编程新手如何通过ChatGPT一天完成一个MVP产品

声明:这篇文章不是 ChatGPT 写的,但大量素材来自 ChatGPT。

前言

本着在工作学习的各种场景打造多个 AI 助手,让自己只关注和处理最核心事情的初衷,昨天花了一天时间从设计、编码到调试通过,完成了基于 OpenAI 构建的第一个 MVP 产品 —— 翻译助手(第二个 MVP 产品字幕助手用于自动为视频加上中英文字幕也以完工,第三个 MVP 产品在路上),该助手从网页批量爬取、HTML 预处理到调用 OpenAI 开放接口完成中文翻译和格式优化,实现了技术文档翻译这个场景全流程 90% 工作的自动化,剩余 10% 是方案设计、工作流编排和最后的代码调试、结果审核,也就是我认为的最核心的事情。

与此同时,为了尽可能模拟一个面向未知领域的编程新手 ,在此过程中,80%以上的代码是面向 ChatGPT 编码的,即告诉 ChatGPT 我的需求,然后让它给我生成相应的代码。整个开发过程还是非常顺畅的,不得不说,生产效率相比之前面向 Google 编程还是有非常大提升的。

如果你还没有 ChatGPT 账号,请参考这篇教程获取:ChatGPT 终极指南

在今天这篇文章中,我将尽可能还原翻译助手这个 MVP 产品实现的所有细节,给大家展示如何面向 ChatGPT 编程,如何有效利用这个 AI 工具提高工作效率,以及如何围绕 OpenAI(ChatGPT 背后的公司)提供的开放接口构建 AI 产品,最后谈谈 ChatGPT 目前存在的问题,以及我们应该如何看待 AI 产品对我们工作生活带来的影响。

当然了,作为一个文本处理领域的全能小帮手,除了翻译文档、写代码之外,ChatGPT 还可以帮我们写文章。因此,在写这篇文章之前,让我们先去咨询下 ChatGPT,让它给我们梳理思路、列个提纲:

image-20230221160824703

虽然看起来像是正确的废话,但是整体思路确实就是这样是不是,所以接下来,我将按照上面的提纲来写今天这篇文章的开发流程部分。

面向 ChatGPT 开发

这个 MVP 产品是基于 Go 语言实现的,所以后续预设都是 Go 语言上下文。

产品需求

因为是 MVP 产品,所以就只实现一个核心需求 —— 以 Laravel 官网为例,批量爬取 Laravel 10 所有英文文档,然后将英文文档翻译成中文文档,最后以 Markdown 形式保存到文本文件中,以便后续使用。

用到的技术

爬虫框架

因为需要爬取网页,只有一天时间自己写来不及,通用性也不强,所以需要选择一个爬虫框架,之前我们都是 Google 搜索,现在我们可以直接问 ChatGPT:

image-20230221162042614

它会把一些比较流行的 Go 爬虫框架列举出来,并进行简单介绍,我们可以根据需求再去 Github 进行比较,选择一款最适合的,这里我选择的是 Colly

html 转化 markdown

另一个需要用到的基础功能就是将 HTML 区块代码转化为 Markdown,这一块也有很多开源组件,还是找 ChatGPT,让它推荐:

image-20230221163339510

这里,它不仅给出答案,还给出了各自的示例代码,当然,保险起见,最好去 Github 浏览下这些项目,维护情况怎么样,是否能满足特定的业务需求,当然你在 ChatGPT 问也不是不行,但是作为最终决策,肯定是要验证下的,毕竟 ChatGPT 和搜索引擎一样,目前都是无法对结果的准确性负责的。

这里,我选择使用 blackfriday 这个库转化 Markdown,同时也使用了 bluemonday 对 HTML 代码进行修剪和净化(比如去除不必要的样式代码、元素属性以及脚本代码),这个转化处理属于调用 OpenAI 接口前的预处理,因为 OpenAI 文本翻译接口按 Token(可以看作待处理文本字符数) 收费,所以必要的清理可以有效降低成本。

文本翻译

这里的翻译使用的是 OpenAI 提供的 Text Completion 接口:https://api.openai.com/v1/completions,就不存在方案选择问题了。这里我们使用了第三方开发的 OpenAI Go SDK:github.com/sashabaranov/go-gpt3

对于组件如何使用,你都可以面向 ChatGPT 进行学习,不用再到搜索引擎反复搜索,一个个点开搜索结果页,把不同文章讲到的东西串联起来,得到整体认知,同时也能避开类似 CSDN 这种垃圾站,看文章还要关注、登录、付费。。。这种成本单个看起来不高,但是成天累月下来,还是会占用不少无效时间。至于何时学习,我觉得可以在编码的过程中用到什么学习什么,遇到问题再去求教,这样可以把效率做到最高,在最短的时间完成最多的事情。

流程设计

我们这个产品功能比较简单,不涉及到前端和数据库,所以只需要按照需求简单设计下流程就可以了:

image-20230221170646477

其中在爬取网页这块,我们需要做一些准备工作 —— 找到爬虫入口页面,分析页面 HTML 的 DOM 结构,包括列表页和详情页(列表页提取待爬取页面链接,详情页用于提取真正要爬取的内容,以 Laravel 10 文档为例,分别是 https://laravel.com/docs/10.xhttps://laravel.com/docs/10.x/requests,详情页通常有多个,这里这是举例),因为大多数爬虫框架都是基于 CSS 选择器对页面元素进行提取,Colly 也不例外,底层是基于 goquery 以 jQuery 语法实现对页面元素的匹配和提取的。

对页面结构做到心中有数之后,因为其他两个功能模块比较简单,就可以直接开始编写代码了。

编写代码

爬虫模块

前面我们已经选取了 Colly 作为爬虫框架,作为一个编程新手,我不知道怎么使用它,所以我们需要咨询 ChatGPT:

image-20230221184049021
image-20230221222502318

ChatGPT 除了提供 Colly 的示例代码,还会给代码注释,以及对整个代码执行的流程做解释,服务非常周到,其实有了这样的能力,基于 ChatGPT 去阅读任何语言/框架/算法的源码,都非常轻松了。比如,我们想要学习 Go 协程的底层实现源码,可以直接贴代码+咨询 ChatGPT,而不是一边自己读源码,一边去查 Google,所见即所得,非常高效,这也是我认为 ChatGPT 会成为下一代搜索引擎的原因,至少比现在的搜索引擎高效 —— 让获取知识和答案的路径更短,用户可以以更快地速度、更短的时间所见即所得获取答案。

了解了 Colly 爬虫框架的基本用法之后,接下来,就是根据我们业务要爬取的页面结构编写对应的爬取业务代码了。前面我们提到,爬取文档分两步,先要提取所有要爬取的文档详情页,再去详情页爬取真正的文档内容。

1)我们可以在爬虫入口页面 https://laravel.com/docs/10.x 的左边栏提取所有文档详情页链接:

image-20230221201133470

这些链接可以通过 CSS 筛选器 div.docs_sidebar ul li a[href] 提取。提取到页面 URL 之后,就可以访问这个 URL 进而提供文档详情页的文档内容。

2)以 https://laravel.com/docs/10.x/requests 为例,文档内容位于页面右侧主体部分,即 div#main-content 元素中的内容:

image-20230221201807178

要爬取这样的两级页面,在 Colly 框架里面怎么编码,怎么问 ChatGPT,它会给我们答案:

image-20230221223039267
image-20230221223107619

简单来说,就是定义多个 c.OnHTML 回调即可。另外这里还有一个小细节,就是 OpenAI 接口对处理的字符数是限制的,这里的细节我不展开了,官方文档有说明,一般不超过 1000 个字符为好,所以对于文档内容不能一股脑提交给 OpenAI 接口,那样会报错,需要对文档内容做拆分,这里我以 div#main-content 下的第一级子元素为拆分条件。这种情况下,我们如何去做页面元素的提取呢?还是咨询 ChatGPT:

image-20230221223615854

这样我们就心中有数了,可以通过 * 通配符匹配 div#main-content 下的所有子元素,再结合正则表达式对子元素进行筛选,因为不是所有的子元素都是有效的:

// 在找到需要提取数据的 HTML 元素时执行的回调函数
childrenRegex := regexp.MustCompile(`^(h2|h3|h4|p|pre|table|blockquote|img)$`)
c.OnHTML("#main-content *", func(e *colly.HTMLElement) {
    // 非白名单标签退出不处理
    if !childrenRegex.MatchString(e.Name) {
        return
    }

结合上面的认知,我们编写爬虫模块代码如下:

package main
​
import (
    "fmt"
    "regexp"
    "strings"
    "sync"
​
    "github.com/gocolly/colly/v2"
)
​
func main() {
    // 创建一个新的爬虫对象,同步执行,保证爬取页面元素的顺序
    c := colly.NewCollector()
​
    // 保存访问过的 URL,避免重复访问
    visited := make(map[string]bool)
    // 初始化 blocks 容器
    blocks := make(map[string][]string)
​
    // 在请求发送之前执行的回调函数
    c.OnRequest(func(r *colly.Request) {
        fmt.Println("Visiting", r.URL.String())
    })
​
    // 在请求响应之后执行的回调函数
    c.OnResponse(func(r *colly.Response) {
        fmt.Println("Response received for", r.Request.URL.String())
    })
​
    // 在找到链接元素时执行的回调函数
    c.OnHTML("div.docs_sidebar ul li a[href]", func(e *colly.HTMLElement) {
        path := e.Attr("href")
        // 只处理 /docs/10.x 开头且未访问过的链接
        if !strings.HasPrefix(path, "/docs/10.x") || visited[path] {
            return
        }
        visited[path] = true
        if path != "/docs/10.x/requests" { // 测试只爬取一个页面
            return
        }
        c.Visit(e.Request.AbsoluteURL(path))
    })
​
    // 在找到需要提取数据的 HTML 元素时执行的回调函数
    childrenRegex := regexp.MustCompile(`^(h2|h3|h4|p|pre|table|blockquote|img)$`)
    c.OnHTML("#main-content *", func(e *colly.HTMLElement) {
        // 非白名单标签退出不处理
        if !childrenRegex.MatchString(e.Name) {
            return
        }
        // 获取当前页面pathid
        path := e.Request.URL.Path
        pageId := strings.TrimPrefix(path, "/docs/10.x/")
        if path == "/docs/10.x" || pageId == "" { // 非文档页面退出不处理
            return
        }
        // 文本内容为空,则推出不处理
        if e.DOM.Text() == "" {
            return
        }
        // 将 HTML 区块代码保存到 map 中
        blockHtml, _ := e.DOM.Html()
        var outerHtml string
        if e.Name != "p" {
            outerHtml = "<" + e.Name + ">" + blockHtml + "</" + e.Name + ">" // 保留标签,比如标题、引用、表格、代码等
        } else {
            outerHtml = blockHtml // 文本段落不保留标签
        }
        // 通过转化 Markdown 去除无效的 HTML 标签,降低内存使用率
        cleanBlock := html2Md(outerHtml)
        if cleanBlock == "" {
            return
        }
        blocks[pageId] = append(blocks[pageId], cleanBlock)
    })
​
    // 在爬取所有页面结束时执行的回调函数,可以在这里调用 OpenAI 接口进行文本翻译
    c.OnScraped(func(r *colly.Response) {
        ...
    })
​
    // 发起 HTTP 请求
    c.Visit("https://laravel.com/docs/10.x")
}

这里需要注意的是为了简化页面内容拆分后的有序性,我把爬取逻辑设置为同步串行,而不是异步并发,因为这里主要是测试和跑通流程,先不考虑性能问题。

如果你想要了解提取页面元素的核心函数 OnHTML 的执行机制,以及背后是否是并发处理,也可以随时咨询 ChatGPT:

image-20230221224337016
HTML预处理

其实在上面的代码中,已经包含了 HTML 预处理函数 html2Md 的调用,之所以要做 HTML 预处理,这既是为了降低 OpenAI 接口的费用(按处理字符数收钱),也是为了降低内存的使用率,我们是在处理完成后,才将内容区块存放到容器的,HTML 的预处理逻辑也非常简单,就是调用 blackfridaybluemonday 这两个包分别进行 Markdown 转化和 HTML 修整净化:

import (
    "strings"
​
    "github.com/microcosm-cc/bluemonday"
    "github.com/russross/blackfriday/v2"
)
​
func html2Md(html string) string {
    // 将html转换为markdown
    unsafe := blackfriday.Run([]byte(html))
    // 净化 HTML
    result := string(bluemonday.UGCPolicy().SanitizeBytes(unsafe))
    return result
}
调用 OpenAI 接口

完成上述工作后,所有 Laravel 10 文档内容其实就已经存放到字典容器中,并按照页面 ID 进行了隔离,我们只需要遍历这个容器,针对每个区块依次调用 OpenAI 提供的 Text Completion 接口就好了,这里我也做了一点小优化,就是将较小的区块进行合并(兼顾请求次数太多,请求太频繁也会报错):

for pageId, html := range blocks {
     var resultText strings.Builder
     var err error
     var blockBus strings.Builder
     for _, block := range html {
         // 如果是代码区块,则直接写入结果,不翻译(降低失败率,有些代码片段很长,而且代码不需要翻译,浪费成本):
         if strings.Contains(block, "
") {
             resultText.WriteString(block)
             continue
         }
         // 如果blockbus和当前区块长度已经超过 1000,并且blockbus不为空,则发车
         if blockBus.Len() > 0 && (blockBus.Len()+len(block)) > 1000 {
             // 调用 OpenAI 接口进行文本翻译,格式后处理
             blockResult, err := callOpenAI(blockBus.String())
             if err != nil {
                 fmt.Println("调用 OpenAI 处理文本失败:", err)
                 continue
             }
             resultText.WriteString(blockResult)
             // 清空缓冲区
             blockBus.Reset()
         }
         blockBus.WriteString(block)
     }
     // 将翻译处理结果写入Markdown文件
     err = writeToFile(pageId, resultText.String())
     if err != nil {
         fmt.Println("写入Markdown文件失败:", err)
         return
     }
 }

作为 OpenAI 新人,我并不知道怎么调用它的接口,还是面向 ChatGPT 编程:

image-20230221225006263
image-20230221225051595

可以看到,ChatGPT 给了我们非常详细的步骤,包括去 OpenAI 注册账号,拿到 API_KEY, 我们把稍作调整,整合到调用 OpenAI 的 callOpenAI 函数中即可:

func callOpenAI(text string) (string, error) {
    api_key := os.Getenv("OPENAI_SECRET_KEY")
    prompt := fmt.Sprintf("将网页主体内容翻译成中文,以markdown格式输出,中英文字符串之间用空格分隔:\n%s", text)
​
    client := gogpt.NewClient(api_key)
    req := gogpt.CompletionRequest{
        Model:       "text-davinci-003",
        Prompt:      prompt,
        Temperature: 0,
        MaxTokens:   3000,
    }
    resp, err := client.CreateCompletion(context.Background(), req)
    if err != nil {
        fmt.Println("调用 OpenAI 接口失败:", err)
        return "", err
    }
​
    return resp.Choices[0].Text, err
}
保存到 Markdown 文件

最后,我们要将 OpenAI 返回的翻译后的文本按照文档ID组合起来,写入到 markdown 文件,作为编程新人,我不知道怎么把文本内容存放到文件,问 ChatGPT:

image-20230221225325331

把它返回的示例代码整合到 writeToFile 函数即可:

func writeToFile(pageId, text string) error {
    // 打开文件,如果文件不存在则创建
    file, err := os.OpenFile("./docs/"+pageId+".md", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
    if err != nil {
        return err
    }
    defer file.Close()
​
    // 创建一个写入器,使用缓存写入文件
    writer := bufio.NewWriter(file)
​
    // 写入文本
    _, err = writer.WriteString(text)
    if err != nil {
        return err
    }
​
    // 刷新缓存,确保文本被写入文件
    err = writer.Flush()
    if err != nil {
        return err
    }
​
    return nil
}

调试通过

至此,我们就完成了业务员代码的编写工作,有了 ChatGPT 这个助理,是不是很高效?这种感觉就像你是个小组 Leader,下面有几个人,每次接到任务,你根据需求编写技术方案,然后对任务做拆分,分给小组内不同的人去完成,最后再把他们提交的模块代码组合起来,调试运行通过:

image-20230221225542114

如果你觉得某些区块处理的格式不对,可以继续让 ChatGPT 帮你优化:

image-20230221225709536

PS:这里没有涉及到很多正则匹配和数据库操作,ChatGPT 在帮你写正则SQL 语句方面也非常成熟,有了 ChatGPT,再也不用害怕写复杂正则表达式和 SQL 查询语句了。

由于国内已经无法访问 OpenAI API 接口,你可以参照这篇教程设置代理访问。

更进一步

当然了,翻译助手这个基于 OpenAI 的 MVP 产品目前还很简单,只是为了完成核心需求要求的功能,并没有做任何性能、系统扩展性、用户体验更好这些层面的考虑,这些需要留待后续迭代去升级,但同时也是你从日常搬砖中解放出来后应该真正花心思去考虑的。

更高性能

爬取文档页面和提取页面元素都是可以朝着异步并行的方向去提升性能的,还有针对 OpenAI 接口的调用,目前也是同步的,不过除了为了简化系统,这里串行调用还有一个考量 —— OpenAI 的接口调用很贵。我昨天只是简单测试,就花掉了几美金,如果去批量爬取翻译海量文档,这个成本个人是无法承担的,不过公司而言财务方面的压力会好点:

此外 OpenAI 的接口也并不稳定,并发量上来之后经常挂掉,我在 Twitter 上也看到好多人吐槽,所以目前我觉得还不太适合大规模商业。比如之前一些比较火的周报生成器浏览器翻译插件(基于 ChatGPT)都因为这方面的原因终止项目了:

系统扩展点

所谓系统扩展主要是功能通用性上的,比如爬取 Laravel 文档之外的更多页面、更多网站,这个时候,可能就需要封装出一个爬虫引擎,能够适配多个网站的不同页面提取规则。

还有就是文本翻译这块,处理 OpenAI 之外,是否可以支持更多其他第三方翻译服务,比如传统的 DeepL、谷歌翻译,以及其他的 AI 文字处理接口。

更友好使用

目前这个翻译助手只提供了命令行 CLI 版本,并且很多参数都写死在代码里了,需要做一定的解耦,如果要给开发者之外的更多人使用还要开发出更多包含图形化界面的客户端,比如桌面版、Mac版、iOS 版、安卓版、Web 版等等。

限于篇幅,这里就不做更多发散了。

ChatGPT 目前存在的问题

在文本翻译、格式处理、文章(案)写作和常规代码编写这块,ChatGPT 表现的已经相当不错了,但是也存在一些硬伤,就是无法对结果准确性负责,当然搜索引擎也不行,而且我们在获取搜索引擎可用结果这件事情上要付出的成本还更高,另外就是它也无法对一些人类看来很明显的逻辑错误问题进行甄别,看起来就是一本正经的胡说八道:

image-20230221230343168
6a95d32f7dae02f1cbacb915788b887

不过,之所以中文这块逻辑硬伤明显,可能也跟用于训练的中文互联网数据太少有关系,目前整个中文互联网对 ChatGPT 的贡献值非常低,只占比不到 0.1%:

abc18a5f070951daab1642e4af938da

当然 ChatGPT 本身也在不断进化,目前使用的 GPT-3 模型参数是 1750 亿,而下一代 GPT-4 模型使用的参数达到了惊人的 100 万亿,这个数量级和人脑的神经突触相当,这也意味着更强的学习能力、预测能力、理解能力和自然语言处理能力。

我们该如何看待 ChatGPT

很多人恐慌 ChatGPT 会替代自己,我倒不这么认为。很多新事物到来的时候,都会引起部分人的恐慌,比如照相机会不会摄人心魄,火车会不会惊动地下的祖先,事后回看,都是无稽之谈,这些新事物反而都给我们带来更多便利,让我们的生活更加美好。

人工智能大抵也是这样。

以开发者为例,引入更智能的 ChatGPT 后,你的核心职责不再是编写那些低级的、重复的、流程化的代码,而是变成一个 Leader 或者说导演的角色,领到任务后先设计方案,再进行模块拆分,把 ChatGPT 当作 worker,这个 worker 不仅是专业选手,还是全能选手,一人能干所有的活,但是它不能保证做的事情结果是正确的,最后需要你去把关、验证,再组合起来完成真正的业务需求。

ChatGPT 是你的忠实助手,为你处理一切杂务,也是你的智囊库,你可以咨询它任何事情,尤其在学习新技术的时候,虽然还有进步空间,但它显然已经是一个比搜索引擎更好的问答引擎,所以,我觉得至少目前 ChatGPT 还不能替代程序员,而作为开发者,我们要始终保持主观能动性,善于利用这个工具大幅提高工作效率,去关心更核心的事情,比如项目的流程、团队的协作、客户的需求、服务的体验、性能的优化、整体的架构,多去与人打交道,更快地提升自己。

科技向善,以人为本,无论何时,人类社会还是以人为主体,所有科技的本质,也都是为人提供服务。

那除了开发者之外,该如何更好地利用 ChatGPT 为更多人提供服务呢,毕竟它是普适的文字处理助手,而不仅限于科技界。

语言的边界决定你世界的外延,在 ChatGPT 里,你的问题决定了它对你的帮助,善于提问非常关键,这里推荐一份 ChatGPT 高级用法的 prompt 问题清单,提供了很多问题模板,帮助你优化自己的工作流:FlowGPT: Amplify your workflow with best prompts

image-20230221232634509

还有一份中文版 ChatGPT 食用指南,内含注册流程,615 个 AI 技术落地的工具和179 个使用场景:ChatGPT 应用汇总及操作手册

最后,你还可以加入学院君和他的朋友们知识星球,和我一起探索和分享更多 ChatGPT 的新玩法:

发表回复