我尤其着迷于 A.I.,因为在所有技术里,它最接近于直接戳探我们关于“何以为人”的想法,至少在认知和创造力层面是如此。能这么说的技术其实不多。而且我不觉得这个 A.I. 时刻的重量已经真正落到我们肩上——当内容生产的平均成本急剧下降时,身处活的、流动的历史之中到底意味着什么,我们还没有完全消化。事实上,我们这个物种在经历类似时刻时,大多数人都不可能预见印刷机之后的宗教改革及其连锁反应;也不可能预见互联网和总统选举之间会发生什么。所以做了几个概念验证(PoC)之后,我越来越觉得,传统软件写法和未来数据驱动开发的方式之间,存在一个根本差别。传统编程与机器学习之间的差别,有点像那句老话:
“给人一条鱼,你喂饱他一天。教人钓鱼,你喂饱他一辈子。”
ML/LLM 驱动开发正在改变软件开发的范式
我们大多数人本来就不太清楚软件开发里面到底发生了什么,这已经够糟了;数据驱动开发还往里面加入了大量“伪科学”式调参和概率思维,于是许多流程、工作流和工具,即使对资深工程师来说也几乎是全新的世界。这篇文章很好地梳理了两种实践的历史脉络和差异(不过很遗憾,我也注意到技术传播和演进的速度在不同地区差异太大,以至于我们其实早就活在一台时间机器里了——想到 SFTP 我就懂了……)。
问题的核心其实是:你要怎样教机器学习?在交付链路的每一步都持续实验的同时,又要怎样向最终用户提供稳定服务?从你收集的数据,到降维,到模型选择,到超参数调优,诸如此类。大型语言模型把一整套强大的自然语言、知识和推理工具带上了桌,但它们也有自己的怪脾气。所以这些挑战加在一起,让我意识到(当然是在 ChatGPT 的帮助下),我需要一种基于注册表、由接口驱动的方法,才能把这些系统扩到规模并稳定提供服务。
这有点像科学流程:总有一些反复出现的任务和工作流,也总需要持续监控结果(理想情况下,还要能自动触发服务)。所以我会列出之前做过的一些实验,整理其中反复出现的模式,并大致分享我现在如何思考这个系统的构建。
周末原型
一个周末能做出什么?结果是,能做的东西还真不少。一个东西牵出另一个东西,前面有一整个新应用和新体验的宇宙可以被造出来,但最终的约束还是开发者时间。所以我现在真正关心的是搭出那些基础积木,让任何开发者的效率都能提升 100 倍,甚至 1000 倍(希望如此)。
Bertrand - 从提示词到发布
动机 / 使用场景:我之所以想做这个,是因为当时在想,有什么东西可以让我用最少的精力和维护成本,让 1000 个人每年每月付我 100 美元;看起来“从提示词到发布”,或者说一家全自动社交媒体代运营机构,差不多就是那个东西,而且技术上也可行。于是我试着给自己正在上的一门课生成抽认卡,顺便玩一下上面那些功能,看看回答会不会更相关,也看看在一个非常有限的场景里,LLM 的生成能力到底能被拉到多远。
假设:如果把新知识接入 LLM,并给它记忆,我们就能做出更好的检索增强生成(RAG),也能把体验做得更个人化。
Bertrand - 一个从 ChatGPT 提示词到发布的插件
结果与我学到的东西:这是我第一次接触 GraphQL 和向量数据库,具体来说是 Weaviate;也让我更理解嵌入,而我之前在一次演示里已经为这东西唠叨了很久。我当时想:我需要的是一种方法,把一个个离散流程串起来,从学习新趋势,到生成内容,再到排期和发布,端到端自动化——当然,已经有人做了!我发现了 Langchain 和 Deep Lake,并在后面的 PoC 里开始更认真地折腾它们。
Arthur - 搜索与检索
动机 / 使用场景:在用 ChatGPT 构建东西的过程中,也出于我对“最少资源换最大回报”的朴素热爱,我越来越觉得,如果我的 LLM 结对程序员能自己不断学习新东西(从文档、文件、网络搜索等等),那就很美;如果它还能听命令自主写代码、编译,并根据遇到的错误修自己,那就更美。搜索和阅读,当然比直接拿到一个回答慢得多。我也对人与人沟通里那些口音、来回等待和时间成本感到非常烦躁,那为什么不直接让 A.I. 学会现有代码库,然后问它问题呢?一旦 A.I. 掌握了现有项目结构和代码库,让它按提示生成完整功能,似乎也不算太遥远的一步。
假设:如果为 LLM 设计出能有意义地处理不同知识来源的策略,我们就可以对任何东西进行查询和生成。
Arthur - 搜索并检索任何东西
结果与我学到的东西:如果生成内容的质量真的很重要,那它确实有点问题;但更大的重点是,LLM 驱动的应用把敏捷和迭代推到了一个新层级,因为一路上的每一步都可以继续迭代和优化。也许我喂它更多内容,它就会生成更好的内容。也许我帮它更好地理解内容语义,它就能做出更好的检索和生成。我做了一种按对象层级切分 .json 的方式,因为看起来合理,但后来撞上了 token 限制,所以不得不限制检索回来的分块数量;而对于每一种可能被接入的数据源——无论是文档、代码、音频、视频、图像等等——都有太多种方法可以把它切成更小或更有意义的单元,也有不同的嵌入模型、向量存储(这次我用的是 Deep Lake)和检索策略。所以我真的需要一个生产系统,它必须是:
-
稳定 - 这样用户才能获得一致的体验
-
可扩展 - 这样我才能轻松加入新的数据接入来源、新的嵌入模型等等
还有 ZOMG,虚拟环境和容器化真的太重要了。它们几乎占了你写下第一行代码之前整个 setup 流程的 3/4……
The Sound of Stories
动机 / 使用场景:我只是很想把这些技术部署到一个真实发生、实时进行的表演里,用 A.I. 增强人的能力,让我们做以前做不了的事情。我之前已经试过一些组件,它们最后可以组合成一种互动讲故事体验:观众可以听到一个故事被某个叙述者的声音、用多种语言讲出来,无论那个声音属于讲故事的人,还是属于某个所爱之人。既然已经做到这里,把这些东西组起来,放到人面前,似乎就是很自然的下一步。
假设:A.I. 可以解锁并叠加新的体验和能力,比如把文本生成和其他服务串起来,让一个生成出来的故事被朗读给你听。
The Sound of Stories
结果与我学到的东西:Streamlit 用来给 LLM 应用做 UI 原型有点烂,Chainlit 看起来更好;SeamlessM4T有点让人失望,不过在我熟悉的语言里,文本到文本、文本到语音和声音克隆基本已经是解决了的问题,真正的障碍可能是延迟和其他语言。这 3 个 PoC 都有一个反复出现的模式:我对某个数据源做接入,把它用某种方式切分(按固定字符数切、按对象切,等等),然后生成嵌入,存进向量数据库,供前两个项目检索使用;而到这个项目里,则大量变成了“提示工程”。
旁注:做完这个 PoC 之后,我其实不觉得提示工程会成为一份真正的工作。就算它真的变成一种职业,也会像现代版的打字员;整个反复调整提示词的过程,感觉就是那种自动化会做得更好的事情。事实上,到了规模化之后,最大的挑战之一会是:你怎样有效监控和评估每一次调校带来的性能变化?引入人在回路?还是尽可能用 MLOps 把流程端到端自动化?
在这个 PoC 里,我想让数据在不同服务之间流动,也想多玩一点多模态。所以 LLM 生成的输出可以交给其他服务继续处理,比如我把生成出的文本传给 SeamlessM4T 去生成音频;它也可以以某种形式直接呈现给最终用户。换句话说,这类 RAG 的使用方式或呈现层同样可以定制。
因此,除了一个在开发和生产中都稳定、可扩展的系统之外,这个系统还需要是:
-
灵活 - 这样我才能轻松重新配置工作流,或者替换组件
-
能扩到规模 - 这样在实时场景中才能对延迟和质量有一定保证;说到底,我担心的是系统到了真实规模之后的性能
第一部分:设计一个可扩展的数据接入、切分与嵌入系统
那么这个系统实际长什么样?我还在重构,也还在一步步走流程,一边搭一边学新的术语和流程。先粗略拆一下,大概是这样:
首先,需要一个注册表。
这个注册表会维护文件扩展名到对应处理器的映射。每个处理器都应该知道如何切分和解析对应的文件类型。
FILE_TYPE_PROCESSORS = {
".py": PythonFileProcessor,
".md": MarkdownFileProcessor,
# Add other file types and their processors here...
}然后是基类。
from abc import ABC, abstractmethod
class DataSource(ABC):
"""
Base class for any data source. This provides a generic interface for data ingestion.
"""
@abstractmethod
def ingest(self):
"""
Ingest data and return a consistent format for further processing.
"""
pass
@abstractmethod
def get_metadata(self):
"""
Extract metadata from the data source. This metadata can vary depending on the source type.
"""
pass
class GitRepoSource(DataSource):
def ingest(self):
# Logic to clone/pull git repos and filter relevant files.
pass
def get_metadata(self):
# Extract metadata specific to git repositories.
pass
class PDFSource(DataSource):
def ingest(self):
# Logic to ingest PDFs.
pass
def get_metadata(self):文件处理器、切分、嵌入以及流水线里其他必要步骤,都按这个方式重复一遍。
CHUNKING_STRATEGIES = {
".py": PythonCodeChunker,
".md": MarkdownChunker,
# ... other chunkers
}也就是说,我们需要可配置、够灵活的处理逻辑,例如下面这样:
def process_files(files, embedding_strategy_name):
embedding_strategy = EMBEDDING_STRATEGIES[embedding_strategy_name]()
for file_path in files:
file_extension = os.path.splitext(file_path)[1]
file_processor = FILE_TYPE_PROCESSORS.get(file_extension)
if not file_processor:
print(f"No processor found for {file_extension}. Skipping...")
continue
chunks = file_processor().process(file_path)
for chunk in chunks:
embedded_text = embedding_strategy.embed(chunk["content"])
# Store embedded_text and metadata...这种注册表加抽象类的做法,有以下好处:
-
规模扩展能力: 使用字典(
FILE_TYPE_PROCESSORS和EMBEDDING_STRATEGIES)把文件类型和嵌入策略轻松映射到对应的处理器。这种设计天然容易扩展,因为要支持新的文件类型或嵌入方法,只需要扩展字典。 -
稳健性:更清楚地拆分关注点,也能更有效地隔离故障。一个文件处理器里的改动不应该影响其他处理器。
-
可扩展性:设计上就是为了方便扩展。前面提过,要支持新的文件类型或嵌入策略,只需要创建一个新类,并把它加入对应的注册表,这样对现有代码的改动可以降到最低。
-
灵活性:灵活度很高。因为组件都注册在字典里,替换或新增组件都很直接。比如要更改 “.py” 文件的处理器,只需要更新
FILE_TYPE_PROCESSORS字典里的一项。
以上这些对快速开发和协作会非常有利,但别忘了,我们要的是一个在生产环境里稳定且一致的系统;这就轮到接口驱动的那部分登场了。这个我会在下一篇文章里讲。
最初发布于 PubPub:erniesg.pubpub.org/pub/zc0zx741。