Logo Ernie.SG
Berlayar:搭一套稳定、易扩展、灵活、能撑规模的……

Berlayar:搭一套稳定、易扩展、灵活、能撑规模的……

2023年9月7日
4 分钟阅读
暂无标签
Table of Contents

第一部分里,我讲了我目前做过的一些简单原型,以及它们如何把我带到现在这个位置:我需要一套稳定、易扩展、灵活、能撑规模的 LLM 产品技术栈,因为值得做的东西实在太多了。我们讨论过,如何用注册机制和基类,在我设想的这一组 A.I. 驱动软件里灵活替换组件,让开发者更容易协作;但最终用户的体验呢?以我想要的灵活度和模块化程度来看,把注册表模式和接口驱动设计结合起来,可能是比较理想的做法。在这篇文章里,我会梳理我设想的 A.I./LLM 工程技术栈;它也许能套到我关心的各种落地场景里,不管是自动重构遗留代码库、搭建覆盖邮件和文档的知识库、生成并发布社交媒体内容,还是让受众用新的方式接触和理解数据。

继续之前,先重新过一遍:在给艺术作品做注释和打标签这件事上,传统软件开发和基于 ML 的做法到底有什么不同。

比较维度

用于艺术作品注释与打标签的传统软件开发

用于艺术作品注释与打标签的机器学习开发(例如 OpenAI 的 CLIP)

它做什么

使用预先定义的规则和元数据,为艺术作品添加注释和标签。

从数据中学习模式,再动态地为艺术作品添加注释和标签。

注释深度

受限于明确写好的规则和现有元数据。

可以识别更广泛的特征、主题和元素。

适应能力

遇到新的风格和趋势,需要人工更新。

可以通过重新训练来适应新的风格和趋势。

用户参与

用户反馈需要人工纳入。

可以自动吸收用户反馈,用于模型微调。

计算复杂度

通常没有那么吃计算资源。

为了做更深入的分析,需要更多计算资源。

可解释性

因为规则是明确的,所以更容易解释。

常被视为“黑箱”,因此更难解释。

覆盖范围

依赖元数据是否存在,以及是否准确。

可以直接处理艺术作品的视觉元素。

人工投入

定义规则和模板需要大量人工。

模型能够从训练数据中泛化,因此人工投入会减少。

正因为方法不同,工作流和开发方式也会出现关键差异;任何想从 A.I. 里真正拿到好处的组织,都应该认真看这些差异。两者的交集和分野大致如下:

交集:

  1. 集成:ML 模型经常只是更大软件系统中的一个组件,而这些系统仍然要靠传统软件工程方法搭起来。

  2. 测试与验证:两种做法都需要通过测试来确认代码或模型是否按预期运行,只是具体测试方式会不一样。

  3. 部署与维护:无论用的是机器学习模型还是传统软件,都需要部署、监控和维护。

  4. 版本控制:两类开发通常都会用 Git 这类版本控制系统来管理变更和历史。不过机器学习还需要管理数据、模型、超参数和结果的版本。

差异:

  1. 确定性 vs 概率性:传统软件工程通常是确定性的,遵循明确规则和逻辑;机器学习则是概率性的,从数据里学习模式。

  2. 可解释性:传统软件一般更容易调试和解释;机器学习模型,尤其是复杂模型,则常常更难理解。

  3. 数据依赖:机器学习在训练、验证和测试上高度依赖数据;传统软件工程在这些阶段并不天然需要数据。

  4. 反馈循环:机器学习往往有一个持续反馈循环,模型会从新数据中继续学习;传统软件本身并不具备这种机制。

  5. 技能组合:传统软件工程和机器学习需要的技能并不一样,尽管随着软件工程师越来越懂数据、数据科学家越来越懂软件工程最佳实践,两者的重叠也在扩大。

    • ML 专门技能:包括统计和数学理解(概率、统计、线性代数、微积分)、数据能力(数据清洗、预处理、特征工程、可视化)、模型训练和评估(算法选择、超参数调优、评估指标)、专门工具和库(机器学习框架、数据处理库),以及实验方法(A/B 测试、模型可解释性)。

希望上面的例子能说明,为什么 A.I. 工程里的架构和设计模式,正在长成一门独立的新手艺。

基于注册表、由接口驱动的设计模式

在面向对象编程里,尤其是在 Java、C# 和 TypeScript 这类语言中,“接口”是一种类型定义,用来规定一份契约:某个类必须实现哪些方法(有时还包括属性)。接口本身不包含任何具体实现。它只定义:任何声称实现这个接口的类,都应该具备哪些方法。

对于需要稳定性、又需要保留实验空间的 A.I. 驱动或基于 LLM 的应用来说,接口和抽象类特别有用。A.I. 开发经常绕不开大量实验:调模型、改预处理步骤、换数据源。如果系统一开始就按模块化拆好(由接口和抽象设计模式支撑),实验时替换组件就会轻松很多,也比较不容易牵一发而动全身。等到 A.I. 系统要扩容或上线时,模块化设计也更利于稳定和维护。

简而言之,接口给了我们一份清楚的契约,让大家知道该期待什么。

把注册表、抽象类和接口结合起来:

  • 你会为每一类功能定义接口(例如文件处理、分块、嵌入、存储、检索)。这样每个组件该做什么,就会清楚很多。

  • 注册表随后可以在运行时管理并提供这些接口对应的合适实现。这样系统就能根据不同场景或配置动态调整。

举个例子,假设我们要处理不同类型的文件:

  1. 接口驱动的部分

    • 我们先定义一个接口。比如说,一个 DataSource 接口可以包含接入数据和获取元数据的方法。这份接口为每一个数据源处理器该做什么,立下一份明确契约。

      from abc import ABC, abstractmethod
       
      class DataSource(ABC):
          @abstractmethod
          def ingest(self):
              pass
       
          @abstractmethod
          def get_metadata(self):
              pass
  2. 注册表部分

    • 数据接入之后(比如来自某个 Git 仓库的文件),系统就可以查询 FILE_TYPE_PROCESSORS 注册表,根据每个文件的扩展名找到合适的处理器。

      FILE_TYPE_PROCESSORS = {
          ".py": PythonFileProcessor,
          ".md": MarkdownFileProcessor,
          # ...
      }

当系统需要接入数据时,它可以查询这个注册表,再按每个文件的扩展名找到对应的处理器。

  1. DataSource 子类里的统一工作流

    • 在一个 DataSource 子类里,你可以把这两步合起来:先接入数据,然后对每个接入后的文件使用合适的文件处理器。
class GitRepoSource(DataSource):
    def ingest(self):
        files = self._clone_and_filter_files()
        processed_data = []
        
        for file_path in files:
            ext = self._get_file_extension(file_path)
            processor_class = FILE_TYPE_PROCESSORS.get(ext)
            if processor_class:
                processor = processor_class()
                processed_data.append(processor.process(file_path))
                
        return processed_data

组合起来的好处

这种组合做法有几个优点:

  1. 容易扩展:以后需要支持新的数据源类型时,只要创建一个遵守 DataSource 接口的新类,再把它注册到 DataSourceRegistry 里就好。现有代码不必动。

  2. 预期清楚DataSource 接口确保每个数据源都遵守同一套 API,让数据接入流水线的行为更可预测。

  3. 动态适应:有了中央注册表,不同的数据源处理器可以轻松替换,不影响主工作流,也就保留了很大的灵活性。

对于那些需要不同代理来处理不同场景的应用(一个处理文档和 PDF,另一个处理代码仓库),这个组合既能通过接口提供清楚契约,也能通过注册表保留扩展和调整的能力。稳定性来自接口,灵活性来自注册表。

那么,当我们想给基于 A.I./LLM 的应用加上新超能力时,会发生什么?

扩展交互方式,以及内容的呈现与使用方式:

拿我想在“The Sound of Stories”里做的事情来说:我想把 LLM 生成的文本解析出来,作为一种额外的呈现方式,用来生成多语言旁白。你可能也会想根据生成出来的故事启用图像生成。内容可以供其他工具调用,也可以给人阅读、观看或收听。以我设想的社交媒体内容生成场景来说,系统还需要输出结构化的 .json,供社交媒体 API 调用。基于注册表、由接口驱动的做法,可以让我们像下面这样扩展这些呈现方式。

  1. 创建一个注册表:和前面的例子一样,你可以维护一份内容呈现方式的注册表(例如文本显示、语音输出、动画视频)。

  2. 内容呈现接口

class ConsumptionInterface(ABC):
    
    @abstractmethod
    def display(self, content):
        pass
  1. 具体实现
class TextDisplay(ConsumptionInterface):
    def display(self, content):
        # Simply print the content
 
class VoiceOutput(ConsumptionInterface):
    def display(self, content):
        # Use Text-to-Speech to vocalise the content
 
class AnimatedVideo(ConsumptionInterface):
    def display(self, content):
        # Convert content into animated video

好了,现在退一步,端到端看看 A.I. 应用里以数据驱动开发的完整周期。

A.I. 应用端到端的一套通用简化架构

A.I./LLM 应用技术栈才刚刚成形,a16z 很贴心地把它整理成几个大阶段:提示构建(检索)、提示执行(推理),以及生态里的一些工具。数据流和流水线就是这套生态背后的后端管道工程;这也是它和传统软件工程之间另一个重要差别。下面是我简化后的通用架构,以及我会重点开发的组件:

示例:艺术作品神经搜索的一条完整流程

之前我做过一个艺术作品神经搜索的演示;如果要把这个演示扩展成可用于生产的服务,大概会涉及下面这些步骤。

步骤

涉及组件

动作

备注

1

编排器

首次导入艺术作品元数据和图像嵌入

批处理模式运行

2

用户界面(UI)

用户输入搜索查询或筛选条件

3

主应用

验证用户输入,并获取配置

向配置管理器检查

4

编排器

从向量数据库中取回相关嵌入

可能会使用检索引擎

5

响应生成器

构建对用户友好的输出

把艺术作品元数据和图像格式化后用于展示

6

日志模块

记录本次操作

存储用户查询、响应时间等信息

7

验证与评估

自动评估结果

基础安全检查

8

用户界面(UI)

向用户显示搜索结果

9

反馈收集器

在有反馈时收集用户反馈

4 个月开发路线图的优先级

所以,在这套通用框架和架构之内,其实有一大堆我感兴趣的事情可以做:

  • 艺术作品神经搜索

  • 为生成的故事提供实时、多语言旁白

  • 为代码库做重构和测试编写

  • 和覆盖文档与数据的知识库聊天

  • 代码生成

  • 社交媒体内容生成与发布

为了排优先级,考虑到阅读理解和创意写作是两套不同能力,而前者会对后者很有价值,我的计划是先教 LLM 学会学习,从复杂度较低的用例开始,比如艺术作品神经搜索和知识库。粗略时间线大概可以这样走:

  • 第 1-2 个月艺术作品神经搜索 - 用来打基础数据流水线。

  • 第 2-3 个月和覆盖文档与数据的知识库聊天 - 开发数据接入、检索方法和 UI 组件。

  • 第 3-4 个月为代码库做重构和测试编写 - 利用聊天/知识库阶段发展出来的洞见和方法,做更聪明的自动化测试与重构。

在做这些事的同时,即使重点仍然放在神经搜索和知识库上,我也在想,最好可以先启动一个简化版的重构和测试生成工作流:让 LLM 查询我们的代码库,把结果传给代码质量服务,再串接其他步骤,和/或使用提示模板,执行相关自动化任务,例如重构和编写测试。初期可以把这些变更拆到不同分支里给人工审查;后面如果所有测试都通过,再进一步自动化。

与此同时,这是我现在尝试在我的 Git 仓库里组织项目的方式。这个结构我预计还会继续演化,所以下面仍然很明显是进行中版本;但我目前大致是这样划分各个文件夹职责的:

  • db - 所有数据库相关操作,例如 CRUD

  • demos - 定义一些相对“稳定”的演示和工作流

  • gui - 用户看得到的一切

  • src - 我们的接口和实现的大部分源代码

  • tests - 单元测试、集成测试,以及测试用夹具

项目结构还在整理,CI/CD、监控和评估组件也还没补上;不过我希望能和其他开发者分而治之,因为我也需要为 National Arts Council Arts x Tech Lab 交付“The Sound of Stories”(为生成的故事提供实时、多语言旁白)。如果进展顺利,这几条开发线彼此之间应该会有很强的协同效应,最后也会成为 Berlayar A.I. 这家公司需要的基础模块。;)

├── agents
│   └── __init__.py
├── dataloader.ipynb
├── db
│   ├── chunks.py
│   ├── connectors.py
│   ├── __init__.py
│   ├── main.py
│   ├── ops
│   │   ├── deeplake.py
│   │   └── __init__.py
│   └── utils.py
├── demos
│   ├── chat_adf
│   │   ├── chat.py
│   │   ├── tests.py
│   │   └── utils.py
│   ├── neural_search
│   │   └── upload.py
│   └── thesoundofstories
│       ├── audio
│       └── chat.py
├── diagram.py
├── gui
│   └── __init__.py
├── ingest_pdf.log
├── __init__.py
├── log_init.py
├── main.py
├── notebooks
│   ├── architecture.ipynb
│   ├── chat_adf.ipynb
│   └── dataloader.ipynb
├── poetry.lock
├── pyproject - Copy.toml
├── pyproject.toml

与此同时,我先把这件事搁一边,去专心准备接下来“Good Economics for Bad Times”的考试。


最初发布于 PubPub:erniesg.pubpub.org/pub/wpnv7rhs