在第一部分里,我讲了我目前做过的一些简单原型,以及它们如何把我带到现在这个位置:我需要一套稳定、易扩展、灵活、能撑规模的 LLM 产品技术栈,因为值得做的东西实在太多了。我们讨论过,如何用注册机制和基类,在我设想的这一组 A.I. 驱动软件里灵活替换组件,让开发者更容易协作;但最终用户的体验呢?以我想要的灵活度和模块化程度来看,把注册表模式和接口驱动设计结合起来,可能是比较理想的做法。在这篇文章里,我会梳理我设想的 A.I./LLM 工程技术栈;它也许能套到我关心的各种落地场景里,不管是自动重构遗留代码库、搭建覆盖邮件和文档的知识库、生成并发布社交媒体内容,还是让受众用新的方式接触和理解数据。
继续之前,先重新过一遍:在给艺术作品做注释和打标签这件事上,传统软件开发和基于 ML 的做法到底有什么不同。
比较维度
用于艺术作品注释与打标签的传统软件开发
用于艺术作品注释与打标签的机器学习开发(例如 OpenAI 的 CLIP)
它做什么
使用预先定义的规则和元数据,为艺术作品添加注释和标签。
从数据中学习模式,再动态地为艺术作品添加注释和标签。
注释深度
受限于明确写好的规则和现有元数据。
可以识别更广泛的特征、主题和元素。
适应能力
遇到新的风格和趋势,需要人工更新。
可以通过重新训练来适应新的风格和趋势。
用户参与
用户反馈需要人工纳入。
可以自动吸收用户反馈,用于模型微调。
计算复杂度
通常没有那么吃计算资源。
为了做更深入的分析,需要更多计算资源。
可解释性
因为规则是明确的,所以更容易解释。
常被视为“黑箱”,因此更难解释。
覆盖范围
依赖元数据是否存在,以及是否准确。
可以直接处理艺术作品的视觉元素。
人工投入
定义规则和模板需要大量人工。
模型能够从训练数据中泛化,因此人工投入会减少。
正因为方法不同,工作流和开发方式也会出现关键差异;任何想从 A.I. 里真正拿到好处的组织,都应该认真看这些差异。两者的交集和分野大致如下:
交集:
-
集成:ML 模型经常只是更大软件系统中的一个组件,而这些系统仍然要靠传统软件工程方法搭起来。
-
测试与验证:两种做法都需要通过测试来确认代码或模型是否按预期运行,只是具体测试方式会不一样。
-
部署与维护:无论用的是机器学习模型还是传统软件,都需要部署、监控和维护。
-
版本控制:两类开发通常都会用 Git 这类版本控制系统来管理变更和历史。不过机器学习还需要管理数据、模型、超参数和结果的版本。
差异:
-
确定性 vs 概率性:传统软件工程通常是确定性的,遵循明确规则和逻辑;机器学习则是概率性的,从数据里学习模式。
-
可解释性:传统软件一般更容易调试和解释;机器学习模型,尤其是复杂模型,则常常更难理解。
-
数据依赖:机器学习在训练、验证和测试上高度依赖数据;传统软件工程在这些阶段并不天然需要数据。
-
反馈循环:机器学习往往有一个持续反馈循环,模型会从新数据中继续学习;传统软件本身并不具备这种机制。
-
技能组合:传统软件工程和机器学习需要的技能并不一样,尽管随着软件工程师越来越懂数据、数据科学家越来越懂软件工程最佳实践,两者的重叠也在扩大。
- ML 专门技能:包括统计和数学理解(概率、统计、线性代数、微积分)、数据能力(数据清洗、预处理、特征工程、可视化)、模型训练和评估(算法选择、超参数调优、评估指标)、专门工具和库(机器学习框架、数据处理库),以及实验方法(A/B 测试、模型可解释性)。
希望上面的例子能说明,为什么 A.I. 工程里的架构和设计模式,正在长成一门独立的新手艺。
基于注册表、由接口驱动的设计模式
在面向对象编程里,尤其是在 Java、C# 和 TypeScript 这类语言中,“接口”是一种类型定义,用来规定一份契约:某个类必须实现哪些方法(有时还包括属性)。接口本身不包含任何具体实现。它只定义:任何声称实现这个接口的类,都应该具备哪些方法。
对于需要稳定性、又需要保留实验空间的 A.I. 驱动或基于 LLM 的应用来说,接口和抽象类特别有用。A.I. 开发经常绕不开大量实验:调模型、改预处理步骤、换数据源。如果系统一开始就按模块化拆好(由接口和抽象设计模式支撑),实验时替换组件就会轻松很多,也比较不容易牵一发而动全身。等到 A.I. 系统要扩容或上线时,模块化设计也更利于稳定和维护。
简而言之,接口给了我们一份清楚的契约,让大家知道该期待什么。
把注册表、抽象类和接口结合起来:
-
你会为每一类功能定义接口(例如文件处理、分块、嵌入、存储、检索)。这样每个组件该做什么,就会清楚很多。
-
注册表随后可以在运行时管理并提供这些接口对应的合适实现。这样系统就能根据不同场景或配置动态调整。
举个例子,假设我们要处理不同类型的文件:
-
接口驱动的部分:
-
我们先定义一个接口。比如说,一个
DataSource接口可以包含接入数据和获取元数据的方法。这份接口为每一个数据源处理器该做什么,立下一份明确契约。from abc import ABC, abstractmethod class DataSource(ABC): @abstractmethod def ingest(self): pass @abstractmethod def get_metadata(self): pass
-
-
注册表部分:
-
数据接入之后(比如来自某个 Git 仓库的文件),系统就可以查询
FILE_TYPE_PROCESSORS注册表,根据每个文件的扩展名找到合适的处理器。FILE_TYPE_PROCESSORS = { ".py": PythonFileProcessor, ".md": MarkdownFileProcessor, # ... }
-
当系统需要接入数据时,它可以查询这个注册表,再按每个文件的扩展名找到对应的处理器。
-
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组合起来的好处
这种组合做法有几个优点:
-
容易扩展:以后需要支持新的数据源类型时,只要创建一个遵守
DataSource接口的新类,再把它注册到DataSourceRegistry里就好。现有代码不必动。 -
预期清楚:
DataSource接口确保每个数据源都遵守同一套 API,让数据接入流水线的行为更可预测。 -
动态适应:有了中央注册表,不同的数据源处理器可以轻松替换,不影响主工作流,也就保留了很大的灵活性。
对于那些需要不同代理来处理不同场景的应用(一个处理文档和 PDF,另一个处理代码仓库),这个组合既能通过接口提供清楚契约,也能通过注册表保留扩展和调整的能力。稳定性来自接口,灵活性来自注册表。
那么,当我们想给基于 A.I./LLM 的应用加上新超能力时,会发生什么?
扩展交互方式,以及内容的呈现与使用方式:
拿我想在“The Sound of Stories”里做的事情来说:我想把 LLM 生成的文本解析出来,作为一种额外的呈现方式,用来生成多语言旁白。你可能也会想根据生成出来的故事启用图像生成。内容可以供其他工具调用,也可以给人阅读、观看或收听。以我设想的社交媒体内容生成场景来说,系统还需要输出结构化的 .json,供社交媒体 API 调用。基于注册表、由接口驱动的做法,可以让我们像下面这样扩展这些呈现方式。
-
创建一个注册表:和前面的例子一样,你可以维护一份内容呈现方式的注册表(例如文本显示、语音输出、动画视频)。
-
内容呈现接口:
class ConsumptionInterface(ABC):
@abstractmethod
def display(self, content):
pass- 具体实现:
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。