先说最终选型结果,只列比较关键的。
- Web 前端:NextJS 、TypeScript 、TailwindCSS 、shadcn/ui
- Web 后端:NextJS 、TypeScript 、Prisma
- 纯后端服务:Golang
- 移动端(计划中):CapacitorJS
虽然在选型过程中也有一些 feature 上的考量,但最核心的原则还是:选择擅长且方便的。
很多人在做一个新项目的时候会倾向于选择新技术。虽然有时候新技术本身能带来一些优势,但因为我们不熟悉这项新技术,或新技术本身不成熟而存在的问题,都会导致我们在这方面付出很多时间成本。而除非新技术带来的优势就是你的产品的竞争力,否则这些优势几乎不可能超过你因为使用新技术而产生的额外成本。
Web 前后端为什么选择 JS/TS 技术栈?
我们团队比较熟悉的技术栈是 JavaScript/TypeScript 、Golang 、Java 、Ruby 、Rust 。对于开发 Web 应用来说,显然使用 JS/TS 全栈一站式搞定是最快捷方便的。尽管 Java 、Golang 包括 Rust 在性能方面相对 Node 会更有优势,但对于大部分开销都在数据库和网络 I/O 上的 Web 后端来说并没有多大意义。另外 Web 前后端开发由同一个人承担,那使用同样的技术栈肯定也会更加方便。所以 Web 前后端就决定使用 JS/TS 技术栈。
为什么选择 TS 而非开发速度更快的 JS ?
JS 在一人工作以及 POC 阶段效率确实很高,但当工程开始变得复杂且经过长期迭代之后,TS 会更具优势。当工程已经颇具规模之后再切换到 TS 会是一个很痛苦的过程。我一般仅在知道工程规模会长期保持轻量的时候选择使用 JS 。
很多人被 TS 灵活的类型系统搞的无所适从,花费很多时间在类型适配上,或把 TypeScript 写成了 AnyScript 。但实际上对于上层应用开发来说,我们大部分时候只需要使用第三方包暴露的类型就够了。同时我们也可以通过一些技巧来提取未被暴露的类型,例如使用 Utility Types Awaited<>
Parameters<>
ReturnType<>
等。
为什么选择 NextJS 作为 Web framework ?
NextJS 提供了一些吸引人的特性,例如:前后端同构、SEO 友好、ServerAction 、Vercel 快速部署等。当时我们因为已经脱离一线开发一段时间了,其实对当时主流 Web framework 的细节也并不了解,在快速评估了 NextJS 、Astro 和 Remix 之后,我们认为 NextJS 更加符合我们的需求。尽管这个选择帮助我们快速开发并上线了 Podwise.ai (opens in a new tab) ,但也同样给我们带来了不少麻烦,在后面我们会讨论这些麻烦。
前后端同构在一人开发时效率很高。举例来说,所有接口的出入参类型你都只需要定义一份,而无需在两种不同技术栈下分开定义。当你需要修改接口时,同一份类型也可以确保你同时修改两边而不会发生遗漏。
SEO 作为一种出海场景下有效且相对便宜的营销获客手段,是非常必要的。如果你的 SEO 内容都是 blog 文章,那完全可以为 blog 站点选择一个独立的技术栈,或者干脆使用 medium 这样的服务作为你的 blog 站点。但当你的应用内容本身就需要被 SEO 时,为你的应用开发选择一个支持 SEO 的技术栈就比较有必要。
Astro 本身定位自己是用来做 “content-driven websites” ,内容驱动网站,也就是说像营销站点、文档站点、blog 、landing page 这种场景。对于功能比较复杂的应用网站,Astro 官方也并不推荐使用 Astro 来构建。
Remix 看起来是一个不错的选择,但它的 action 将一个页面的所有请求混合在一起的写法让我有点不太能接受,而当时 NextJS 的 ServerAction 看起来会更加吸引人。
纯后端服务为什么选择 Golang 技术栈?
-
深度的 Go 经验和积累
对于做应用层的产品创业,根据自己或团队选择最熟悉,最擅长的编程语言的决定是一定不会出错的。对于独立开发者更是如此。podwise 是一款 AI 播客应用产品,所以我们也选择了积累最深,经验最丰富,使用起来最顺手的 Go 语言开发了 SaaS 的后台服务。这绝对是一个正确的选择,只管专注开发产品功能,解决问题,快速构建自己想要的基础设施,这一切都不需要花任何时间在学习、摸索、解决未知问题上面,只需要将自己大脑中的知识和经验倾注而出就可以完成。(当然,从某种方面来说,这或许有点无聊)
我们根据自己的积累、经验和能力,选择了最保险的方案。我们也极力推荐大家采用这一准则进行编程语言的选择。但你并不一定完全需要遵照这一准则。有很多的领域或者产品类型,你甚至可能需要靠其他的指标和维度来做选择,比如你正在构建的是需要高性能的 infra 产品,那你有可能会选择像 Rust,C/C++ 这类编程语言,哪怕你并不是很擅长它们。
-
资源
Go 比 Python 可以消耗更少的资源。如果你有 “白嫖” 过 AWS 的免费 EC2,你会发现这些免费的 ec2 只有 1vCPU 和 1G 内存。cpu 其实还好,毕竟作为独立开者新发布的产品,可能也没多少人使用,也没什么流量;但 1g 的内存极有可能会是一个硬伤,1g 内存被操作系统占用后,剩下给到你的也就是只有几百兆了,这远低于我们今天使用的笔记本电脑,像 python、java 这类带虚拟机的编程语言,一启动就可以占用上百M、甚至几百M 的内存,所以 1g 的 “白嫖” ec2 总体来说是捉襟见肘的。
Go 语言在这方面的表现就是非常优秀了,轻松把内存稳定控制在几十M 内,只要我愿意做,控制在十几M 也不是不可以。
资源就是成本,是钱。作为独立开发者或startup,我们可以不在意编程语言的性能,但不能忽视资源消耗的多少问题。特别是刚起步的时候,能够让你的产品轻松的部署在任意环境,节省每一分钱都是有意义的。
-
部署运维
Go 程序具有极大的可部署运维性。除 Go 以外,其他的所有主流编程语言的程序或多或少都需要解决部署环境的依赖问题,当然在 docker 出现以后,这个问题明显减少了。
大家是否想过,云计算领域诞生了云原生技术,驱动云原生应用开发的云原生技术,为什么大多数都采用了 Go 开发,包括 docker,kubernetes 等等。这其中就有 "Go 程序天然具备高可运维性" 的功劳。这种可运维性,给我们节省了大量的琐碎时间,在 Mac 电脑上采用交叉编译成一个二进制文件,然后一键将二进制文件 copy 到任意机器上就可以直接运行,甚至都不需要事先在目标机器上安装任何软件和库。
这种极大的部署、发布便利性,不光是节省了大量的零碎时间,甚至都让我忘记了 CI/CD。
我们需要 CI/CD 吗?作为刚起步的独立开发者和 startup,我可以负责任的说 “你不需要 CI/CD,更不需要 DevOps,你只需要方便和快捷”,不要害怕自己是草台班子。方便快捷的 “实现代码,跑完测试,部署发布”,这一切都可以在你的笔记本电脑上完成,这是最快,最方便的方式,超过了任何 CI/CD。
-
并发
Go 具有比 Python 强大得多的并发能力,这种强大的并发能力不只是 “cpu上的效率” ,还体现在 "语言级别原生支持 goroutine 的语法表达能力"。这就是编程体验和性能都双双兼得。
并发为什么重要?Podwise 后台服务有太多的地方需要并发处理:
- podcast 节目同步需要并发
- 节目进行 AI 处理需要并发
- 长 transcript 文稿分片后,需要并发总结
- 等等
AI 模型已经足够慢了,好的并发流程设计可以避免产品陷入 "更慢" 的窘境。所以,值得我们使用更好的并发语言。
-
开发效率和性能平衡
选择 Go 就选择了开发效率和性能的平衡,较高的运行时性能和非常不错的开发效率都兼顾了。但这个理由对独立开发者的一般应用来讲,我觉得不是特别的强烈和重要。理由有二:
- 再好的开发效率,也抵不过自己真正擅长的编程语言。几年前,在阿里云 cdn 团队的时候,要开发一个面对大流量的安全防御系统,一部分人主张用 Go,但另外有个别人主张用 C ,主张用 C 的同学在平时开发中已经用事实证明他用 C 的效率同样很高。
- 一般的独立开发者产品,很难有大流量的机会要靠编程语言的性能来顶,一般来说优化/调整一下逻辑就足够好使。
我把这段话写到这里的目的,是为了更好的告诉开发者们,一般情况不用痴迷于编程语言,选择自己最擅长的,真正能拿捏的语言就是最好的选择。
当然,我们应该追求个人工具箱里有多门擅长的,真正掌控的语言和工具栈,这能让你在构建产品的过程中做到真正的随心所欲。虽然我们没有选择用 Python 作为 SaaS 后台服务的核心语言,但构建 AI 产品的过程中,我们还是会碰到 Python,多语言本就是产品开发过程中的常态。
为什么不是 langchain
Podwise 是一款 AI 产品,技术实现层面涉及了 LLM 调用、prompt 和 prompt 管理以及长文本处理等等 AI 总结相关的技术模块。从道理上来讲,我们可以直接选择 Langchain 作为 AI 应用框架。但事实上,Podwise 最后并没有选择 Langchain,而是使用了最擅长的 Go + LLM API 直接实现 AI 部分。
-
认识 langchain langchain 框架有这样的几个核心部分:model IO,Retrieval,Chain,Agent和memory。
- model io 模块提供了访问 LLM 相关的能力,其中包括 prompt 的编写和管理,LLM 调用接口以及 LLM 返回结果的 parser (也就是 output parser)。这部分是最符合我们的直觉,也非常容易理解,本质就是写一个 prompt,然后调用 llm 提供的 api,最后再解析返回的结果(返回的结果可能是一个 json,也可能是一个 markdown 或者一个纯文本字符串等等)。
- retrieval 模块用来处理外部输入的数据,比如对一个 pdf 文档做总结,这个 pdf 文档就输入外部输入数据。这部分包括有文档 loader,文档拆分以及 embedding 。
- Chain 模块属于最核心的模块,但并不难。我们所有的开发者在实际业务开发中,大概率都实现过自己的 workflow,也就是一个流程。Chain 本质就是一个链条,对一个流程的抽象。有了 Chain 这个抽象层,我们就可以自定义各种各样的 AI 工作流,处理各种任务。任何一个稍微有点经验的程序员,都可以轻松徒手撸一个简单灵活的工作流引擎。
- Memory 理解成记忆,不是内存。memory 的作用就是存储运行期的数据状态,一些中间结果,比如chatbot 的对话记录等等。
- agent 可以调用一些 tool 执行外部任务。
对于 Podwise 的业务场景来说,除了 agent 其他几个部分都会涉及,从工作原理的角度,每一个部分本质上都非常简单,简单到根本不需要堆叠一层厚厚的抽象概念,反而实现一层最朴素的wrapper 作为工具库是最简单,最易于理解的。
-
langchain 总结场景
上图来自 langchain 官方文档,它展示的是基于 langchain 实现文档总结的工作流程。
"加载文档 -> 拆分文档 -> 编写 prompt -> 调用 LLM -> 解析获取输出结果",整个过程跑在一个 chain 链条上。
可惜的是,这个 chain 的每一个步骤,在 podwise 上都需要自定义,并不能直接使用默认提供的工具方法,比如第一个步骤 “加载文档” ,langchain 提供各种文档的 loader (比如:pdf 文档,markdown、csv、json 文档等等),但在 podwise 实际业务中,需要加载的数据直接来自 whisper 的结果,也可能直接来自某一个语音转文字的服务的 api 等,所有的这些数据源,往往都无法直接使用 langchain 的默认 loader,需要采用自定义机制实现自己的 loader。
又比如,第二个步骤的 “文档拆分”,langchain 也提供了多种对长文档进行拆分的方法,比如按文档大小拆分等等,但对 podwise 来讲,这些拆分方法都太简单粗暴了,无法从内容的上下文逻辑上进行长文档的拆分,所以这个地方又只能采用自定义实现。
到最后,为了更好的质量和效果,会发现大多数情况都要按照自己的业务场景进行自定义实现。那么,框架就只是做了封装、对接 infra 层面的组件。对于一个有经验的程序员,封装抽象 infra 恰恰是最简单,最不需要探索的事情(当然,每个人的实际情况不一样,对我来说,可能就非常擅长写这种封装抽象类的框架代码)。
-
框架的不可控性
任何一个你不熟悉的新框架,内部都会隐藏着巨大的不可控性。我们没有使用 langchain,在集成 AI 能力这个部分,遇到 “不可控情况” 就非常少。比如我们采用 next.js 作为前端框架,那就遇到了一些不可控的情况。
框架往往是复杂场景下的产物,快速打造应用层产品的 mvp,往往只需要最直接,最朴素的解决方案;引入一个厚厚的框架层,唯一的作用就是拉大你和你的目标之间的距离,解决你的目标问题之前,往往需要你先解决框架的问题。(注意:这里提到的框架都是不熟悉的新框架,那些实际业务中久经考验的框架不在此列)
BuzzFeed 的数据科学家 Max Woolf,也写过一篇很火的文章,叫 “我为什么放弃了 Langchain”,里面就有很多工程实践上的细节问题,各种不可控性,供参考 https://mp.weixin.qq.com/s/Iwe6M391b2BBWae-HmOIJQ (opens in a new tab) 。
以上基本就是我们为什么不选择 langchain 的思考。注意,我们并不是在批判 langchain,至少不是像 Max Woolf 那样,也不是教唆大家不要选择 langchain。而是展示我们对框架选择的思考方式 —— 首先是学习框架,然后将业务流程带入框架写一个简单的原型,最后再结合未来的产品发展进行评估,权衡框架带给你什么,你需要付出的风险成本又是什么。
其它选型:
- TailwindCSS 的就地编写样式的方式带来了非常高效的开发体验,免除了在样式文件和 TSX 文件之间反复切换并查找彼此关系的过程。选择 TailwindCSS 并不代表你就放弃了对样式的抽象和复用,你仍然可以命名并复用你的样式。但我的经验是仅在真的有必要时才这么做。尽管可能你的强迫症会让你不太愿意接受到处散落的 class name ,但一旦接受之后你会真正感受到效率提升带来的愉悦。
- shadcn/ui 是一个优秀的 React UI 组件库,样式简洁美观,组件可以按需安装和更新。更重要的是它基于 TailwindCSS 编写样式,并通过复制的方式安装到你的工程中。因此我们可以很轻松的使用 TailwindCSS 来覆盖样式,或直接修改代码来改变它的默认样式和行为。除了 shadcn/ui 之外,radix-ui themes 和 NextUI 也是不错的组件库选择。
这个技术选型下遇到的问题。
最大的问题来自对 NextJS 和 CapacitorJS 的不熟悉。原本我们计划使用 CapacitorJS 来包壳开发 App ,避免重复同样的业务逻辑开发。对于小团队来说,在多端用不同语言重复实现的时间成本有点不可接受,同时也是非常不利于体验一致和长期维护的。
但 CapacitorJS 需要被包壳的 Web 应用是一个标准的 SPA(Single Page Application),而 NextJS 是一个对 RSC(React Server Component)和 SSR(Server Side Render)高度优化的框架。尽管 NextJS 也可以用于开发 SPA 应用,但很多 NextJS 的有用特性就无法使用了。我们在最初并没有关注到这个限制,结果直到开始准备包壳的时候才发现。这直接导致我们需要对整个 Podwise 的 website 应用进行改造和重构,才能适用 CapacitorJS 来包壳。与此同时,我们还要确保 web 端的 SEO 内容可以被继续输出。目前我们计划通过自定义打包脚本,在尽可能复用代码的同时保留一定的差异化。
一个和 App 包壳相关联的问题是 NextJS 提供的 ServerAction 特性。ServerAction 可以让开发者以类型安全的方式,像调用本地方法一样从 browser 端调用 server 端的接口。ServerAction 使用起来非常方便,然而这也同样是一柄双刃剑。ServerAction 本身不被 NextJS 的静态导出方式(导出成 SPA)所支持,同时也不是一个标准的 RestAPI ,无法被其它二方或三方调用。我们最初用 ServerAction 用的多爽,当决定弃用它的时候就有多麻烦。
Web 启动是比较轻量的,也便于试错。但如果你在后面的计划中有 App 的需求,那么我会非常建议在动手 Web 端编码之前先预研一下后面的 App 方案。
此外 Vercel 作为我们的主要部署平台,也给我们带来了一些麻烦。像 Vercel 以及 Netlify 这些类似平台,都使用 AWS lambda 作为他们 Serverless 能力的底层设施。AWS lambda 在安装它的基础镜像不包含的依赖库时需要使用自定义 layers 来完成,而 Vercel 和 Netlify 等都不支持自定义 layers 。这就导致当我们开发的功能需要使用到一些 AWS lambda 基础镜像中并没有预先安装的依赖库时,无法被成功部署到 Vercel 或 Netlify 中。例如我们的 DAI(Dynamic ADs Insertion)检测功能需要使用到 libasound.so.2
而 AWS lambda 基础镜像中没有,我们尝试了很多种方式都没有成功完成部署,包括 Vercel 的官方 support 给出的结论也是需要等待他们支持 layers 才可以。最终我们只能把这部分功能剥离出来单独部署,我们选择了 zeabur 来部署这一小部分。
在经历这一切之后重新选择技术栈的话,我们会怎么选?
绝大多数选择我认为都没有问题,而 Web framework 我想我会考虑一下 Remix 。Remix 看起来能很好的满足 CapacitorJS 所需的 SPA 结构,无需做什么特殊处理。同时 Remix 也具备前后端同构,SEO 友好的特点。也同样能非常方便的在 Vercel 或 Netlify 等主流平台上部署。
对于我们遇到的 Vercel 无法安装额外依赖库的问题,我并不会因为这个问题而选择不使用 Vercel 或类似的 Netlify 等平台。Vercel 这样的一站式平台带来的其他价值完全值得我们继续选择它,能大大提升产品的迭代交付速度。
我们的技术选型建议:
- 选择你擅长的,而不是选择最新的
- 在 PMF(Product Market Fit)之前,可以只关注开发效率;在 PMF 之后,应当考虑长期的可维护性
- 善用基础设施 SaaS 服务,尽量只关注开发本身
- 不要在技术服务上花过多的成本,能省一分是一分