- 人工智能
- 大模型
- 提示工程
- AI 应用
【免费下载链接】ell
A language model programming library.
本指南围绕 ell(Language Model Programming Library)的「自动版本化 + 调用追踪」核心能力展开。我们会先回答一个根本问题:为什么提示词(prompt)工程需要一套类似机器学习训练时 checkpoint 的版本控制系统;随后深入 ell 如何通过词法闭包(lexical closure)把语言模型程序(LMP)及其全部依赖序列化为自包含的源码快照,并在此基础上构建版本哈希、依赖图、SQLite 存储、自动 commit message 与基于_lstr追踪对象的计算图。读完本文,你将掌握ell.init(store=..., autocommit=...)的完整用法、底层存储表结构(SerializedLMP/Invocation/InvocationContents)、闭包哈希如何产生版本,以及如何用 origin trace 还原一次多级 LMP 调用之间的数据流。
为什么提示词工程需要版本化
提示词工程本质上是对发送给语言模型的 system、user 与预组装 assistant 消息集合做快速迭代,以最大化某个显式或隐式的目标函数。理想情况下我们拥有 reward model 或自动指标来评估提示词质量,只需要修改文本与格式即可。
现实要混乱得多。工程师往往只针对几个样例反复微调提示词,一边改一边祈祷输出"看起来更好"。这个过程存在三个典型问题:
- 一次提示词改动是否会对 LMP 的质量带来一致性提升,往往并不清楚;
- 由于代码库其他位置的依赖发生变化,可能**悄悄引入回归(regression)**而无人察觉;
- 验证假设、回退测试依赖编辑器里的 undo/redo 快捷键,无法有效追踪变更历史。
把这个问题类比机器学习训练循环就能找到解:提示词工程本质是参数搜索——我们随时间用局部更新修改一个模型,目标是最大化/最小化某个全局目标函数。机器学习中每个参数实例叫 checkpoint,定期保存并评估质量,一旦超参数改动导致训练失败就快速回退。提示词版本化就是训练循环中 checkpoint 的类比。
然而用裸的 API 调用或传统框架做这件事极其笨重。常见替代方案包括:为每一次小改动把提示词代码提交进 git、把 commit hash 与输出一起存储、把提示词和输出保存到文本文件——这些做法都违背了软件工程的版本控制工作流。而一些提供版本化的框架又强制要求专用 IDE 或特定命名约定,与"调用散落在整个代码库各处的真实 LLM 应用"脱节。
ell 的关键特性正是这种后台自动运行的版本控制系统:无需改变提示词工程师的任何工作流,即可在开发与生产环境中对提示词进行比较、可视化与存储。
通过词法闭包序列化提示词
自动版本化之所以可能,是因为 ell 把提示词视为离散的函数单元——语言模型程序(LMP)。将提示词封装在函数内之后,可以使用静态与动态分析工具在任意时刻提取提示词程序的源码及其全部词法依赖,捕获复现该提示词所需的精确源码集合。
考虑下面这段深嵌在大代码库中的函数:
from myother_module import CONSTANT def other_code(): print("hello") def some_other_function(): return "to bob" @ell.simple(model="gpt-4o") def hi(): """You are a helpful assistant""" return f"say hi {some_other_function()} {CONSTANT} times." def some_other_code(): return "some other code"序列化并版本化 LMPhi意味着什么?最朴素的做法是只捕获函数体与其签名:
@ell.simple(model="gpt-4o") def hi(): """You are a helpful assistant""" return f"say hi {some_other_function()} {CONSTANT} times."但这远远不够。如果依赖some_other_function变了,LMPhi也从根本上变了,所有预期输出都会随之改变。解法是计算词法闭包:一个函数的闭包等于其源码加上它所依赖的每一个全局变量与自由变量的源码。例如:
>>> lexical_closure(hi) ''' CONSTANT = 6 def some_other_function(): return "to bob" @ell.simple(model="gpt-4o") def hi(): """You are a helpful assistant""" return f"say hi {some_other_function()} {CONSTANT} times." '''从实现上看,完整闭包由 src/ell/util/closure.py 中的lexical_closure()负责:它解析函数及其全部绑定全局变量的抽象语法树(AST),递归枚举依赖,计算出"能够复现该函数的最小源码集合"。为了保持闭包精炼,系统会自动忽略由包管理器安装的第三方库与标准库模块——它们被视为执行环境的一部分,而非函数专属闭包。判定逻辑位于 src/ell/util/should_import.py 的should_import():模块位于site-packages或标准库路径时返回 True(以 import 语句形式保留),位于项目根(默认取ELL_PROJECT_ROOT环境变量或当前工作目录)等本地路径时返回 False(展开为具体源码)。
细节上,闭包生成还会:
- 遍历函数的默认参数、参数注解与返回注解,递归捕获其中的可调用对象(
_process_default_kwargs/_process_signature_dependency); - 对多行字符串以
'''...'''形式内联,对不可变变量以# <BV>标记内联repr,对可变对象以# <BmV>占位(_process_other_variable); - 通过
get_referenced_names解析模块中被引用的属性(如math.sin),只提取这些属性对应的源码,并把module.attr解引用为attr; - 用 Black 统一格式化源码,再结合去重、import 排序等清洗逻辑(
_clean_src)。
这些行为都有测试用例印证,参见 tests/test_closure.py:例如test_lexical_closure_with_global断言"global_var = 10"出现在闭包中,test_lexical_closure_with_nested_function断言嵌套的inner函数同样被包含,test_get_referenced_names验证math.sin这类属性引用的提取。
构建依赖图
当 LMP 依赖另一个提示词(即一个 LMP 调用另一个 LMP)时,被调用的提示词会自动出现在调用方的词法闭包内。这让我们可以构建一张计算图,直观展示复杂 LMP 中不同提示词之间如何相互依赖、如何复用 test-time compute。
下面的例子演示了四个 LMP 通过互相调用组装成一个完整的故事生成管线:
import ell from typing import List @ell.simple(model="gpt-4o-mini", temperature=1.0) def generate_story_ideas(about : str): """You are an expert story ideator. Only answer in a single sentence.""" return f"Generate a story idea about {about}." @ell.simple(model="gpt-4o-mini", temperature=1.0) def write_a_draft_of_a_story(idea : str): """You are an adept story writer. The story should only be 3 paragraphs.""" return f"Write a story about {idea}." @ell.simple(model="gpt-4o", temperature=0.1) def choose_the_best_draft(drafts : List[str]): """You are an expert fiction editor.""" return f"Choose the best draft from the following list: {'\n'.join(drafts)}." @ell.simple(model="gpt-4-turbo", temperature=0.2) def write_a_really_good_story(about : str): """You are an expert novelist that writes in the style of Hemmingway. You write in lowercase.""" # Note: You can pass in api_params to control the language model call # in the case n = 4 tells OpenAI to generate a batch of 4 outputs. ideas = generate_story_ideas(about, api_params=(dict(n=4))) drafts = [write_a_draft_of_a_story(idea) for idea in ideas] best_draft = choose_the_best_draft(drafts) return f"Make a final revision of this story in your voice: {best_draft}." story = write_a_really_good_story("a dog")注意write_a_really_good_story的闭包中会递归包含generate_story_ideas、write_a_draft_of_a_story、choose_the_best_draft三个 LMP 的源码。在存储层,这种"谁使用了谁"的关系通过SerializedLMPUses多对多关联表落地(见 src/ell/stores/models/core.py 中的SerializedLMP.uses/used_by),而SQLStore.write_lmp()会依据传入的uses字典为这些 LMP 建立关联(见 src/ell/stores/sql.py)。
启用版本化:ell.init 与 Store
有了 checkpoint 与序列化能力,就可以兑现提示词工程库的关键承诺:自动版本化。它分为两种形态:
- 开发过程中的自动版本化:工程师反复调优提示词时,可以随时回退到旧版本或跨版本比较;
- 生产部署中的归档版本化:为线上排障、回归检查以及构建大规模微调/对比数据集提供基础。
ell 把两者都考虑在内。关键在于版本化完全在后台发生,不规定任何工作流。只需在初始化时传入一个存储参数:
ell.init(store='./logdir')store参数可以是本地路径,也可以是一个ell.storage.Store对象。Store 是存储提示词及其调用记录(LMP 的输入输出、调用的语言模型、生成结果及元数据)的统一接口。默认情况下,传入路径时 ell 使用本地 SQLite 数据库 + 可扩展的基于文件的 blob 存储:后者用于存放无法塞进数据库行的超大 LMP 或调用数据。
从 src/ell/configurator.py 的init()签名可以看到完整的初始化参数:
| 参数 | 类型 | 说明 |
|---|---|---|
store | Store \| str | Store 实例或本地路径(字符串会构造SQLiteStore) |
verbose | bool | 是否输出详细日志 |
autocommit | bool | 是否在版本间自动生成人类可读的 commit message |
lazy_versioning | bool | 惰性版本化,首次调用 LMP 时才计算闭包(默认开启) |
default_api_params | dict | 为语言模型调用注入默认参数 |
default_client | Any | 默认 OpenAI 客户端 |
autocommit_model | str | 自动 commit 使用的模型,默认gpt-4o-mini |
关于生产部署,文档注明:ell 的 Store 可以对接任意数据库;并预告了类似 Weights & Biases(wandb)的集中式提示词版本控制服务(支持团队协作与高级版本能力)。当前仓库已内置SQLiteStore与PostgresStore两种 SQL 实现(src/ell/stores/sql.py)。SQLiteStore的构造要求传入目录而非.db文件(源码中有assert not db_dir.endswith(".db")),它会在目录下创建ell.db数据库文件,并挂载一个 gzip 压缩、按两层目录分片存放的SQLBlobStore。
当 ell 以任意 Store 初始化后,LMP首次被调用时(惰性版本化模式)会计算其词法闭包源码并哈希成该 LMP 的版本哈希,同时计算前述依赖图并把 LMP 写入 Store。调用完成后,与该版本相关的所有输入输出数据也被存入数据库供后续分析。随着提示词工程持续进行,新版本只会在至少被调用一次后进入 Store——未被调用过的代码不会污染版本历史。
完整的运行示例:
import ell from ell.stores.sql import SQLiteStore ell.init(store='./logdir', autocommit=True) @ell.simple(model="gpt-4o-mini") def greet(name: str): """You are a friendly greeter.""" return f"Generate a greeting for {name}." result = greet("Alice") print(result) # Output: "Hello, Alice! It's wonderful to meet you."落库后发生了什么:三条核心表记录
执行结束后,SerializedLMP表可能新增一行:
lmp_id: "1a2b3c4d5e6f7g8h" name: "greet" source: "@ell.simple(model=\"gpt-4o-mini\")\ndef greet(name: str):\n \"\"\"You are a friendly greeter.\"\"\"\n return f\"Generate a greeting for {name}.\"" dependencies: "" created_at: "2023-07-15T10:30:00Z" lmp_type: "LM" api_params: {"model": "gpt-4o-mini"} initial_free_vars: {} initial_global_vars: {} num_invocations: 1 commit_message: "Initial version of greet function" version_number: 1对应的Invocation表行:
id: "9i8u7y6t5r4e3w2q" lmp_id: "1a2b3c4d5e6f7g8h" latency_ms: 250.5 prompt_tokens: 15 completion_tokens: 10 created_at: "2023-07-15T10:30:01Z"以及关联的InvocationContents:
invocation_id: "9i8u7y6t5r4e3w2q" params: {"name": "Alice"} results: ["Hello, Alice! It's wonderful to meet you."] invocation_api_params: {"temperature": 1.0, "max_tokens": 50}这三张表的真实字段定义在 src/ell/stores/models/core.py 中均可找到:SerializedLMP的lmp_id(主键)、name(带索引,即全限定名)、source、dependencies、lmp_type(取值见 src/ell/types/lmp.py 的LMPType枚举:LM/TOOL/LABELER/FUNCTION/OTHER)、api_params、initial_free_vars、initial_global_vars、num_invocations、commit_message、version_number;Invocation额外记录了latency_ms、prompt_tokens、completion_tokens、state_cache_key与used_by_id;InvocationContents则承载params、results、invocation_api_params、global_vars、free_vars五个 JSON 字段。
值得补充的底层细节:
- 版本哈希:
_generate_function_hash用 MD5 对「格式化后的函数源码 + 依赖闭包源码 + 全限定名」取哈希,前缀lmp-,即"lmp-" + hashlib.md5(...).hexdigest()。任何依赖变化都会产生全新的lmp_id。 - 版本号递增:在 src/ell/lmp/_track.py 的
serialize_lmp()中,若同名 LMP(按全限定名__qualname__归类)已存在,新版本号为latest_lmp.version_number + 1;Invocation的lmp_id引用func.__ell_hash__。 - 调用计数:
SQLStore.write_invocation()每次写入调用都会把对应SerializedLMP.num_invocations自增。 - 超大内容外部化:
InvocationContents.should_externalize会序列化五个 JSON 字段并估算大小,超过102400 字节(约 100KB)且 Store 具备 blob 能力时,is_external=True,内容被 gzip 压缩后写入 blob store(参见 src/ell/stores/sql.py 的SQLBlobStore)。
自动提交(Autocommit):为版本差异生成 commit message
由于提示词就是源码本身,版本间的差异(diff)天然可在后台自动计算,因此 ell 还能自动生成人类可读的 commit message:
ell.init(store='./logdir', autocommit=True)传入autocommit=True后,每当产生一个取代先前版本(按全限定名归类)的新版本时,ell 会调用语言模型自动生成 commit message,之后可随时回看版本间的有效变更。这既服务于本地开发时快速定位理想提示词,也服务于生产环境追溯回归与性能变化。
实现细节极具启发性:自动提交本质上是另一个 LMP。src/ell/util/differ.py 中write_commit_message_for_diff被@simple(config.autocommit_model, temperature=0.2, ...)装饰(默认模型gpt-4o-mini,可在ell.init(autocommit_model=...)覆盖)。它会用difflib.unified_diff计算新旧两个版本源码(含依赖闭包)的 unified diff,连同新旧源码一起拼进 prompt,要求模型给出「一句话高度具体的摘要 + 逐条 bullet」,规则包括:不能超过 10 个词、必须区分 system prompt(严禁称其为 docstring)、具体说明改了什么而不是为什么改、考虑 globals 与 free variables 的变化等。测试 tests/test_autocommit_model.py 中留有多组真实 diff 与claude-3-haiku/gpt-4o-mini的生成示例,例如把温度从 0.5 改为 0.7、把 "world class" 改为 "world-renowned class" 等都会逐条列出。
追踪(Tracing):理解提示词如何被使用
版本化回答的是"提示词如何变化",而追踪回答的是"提示词如何被使用"。
没有专用框架时,开发者只能手动拦截 API 调用、为生产应用自建数据库 schema 来存储输入输出——难以跨项目扩展、频繁重复造轮子。现有替代方案各有权衡:
- 函数级追踪(如 Weave):捕获任意 Python 函数的输入输出,适合生产监控,但无法跟踪本地开发迭代中同一版本内部的变更;
- 框架内版本化(如 LangChain/LangSmith):提示词被压缩为模板字符串或模板 + 版本化 Python 代码,结构清晰但约束太强,未必契合所有开发工作流。
ell 同时吸收了两种思路:它序列化的是任意 Python 代码,因此既能通过输入输出追踪 LMP 的使用方式,又能按版本组织这些使用记录以供比较——而用户全程只需要写普通 Python 代码来产出提示词字符串。
追踪对象与 _lstr:字符串上的起源标签
为追踪 LMP 在运行时如何相互作用、构建类似 PyTorch/TensorFlow 的数据流计算图,ell 为所有 LMP 的输出包裹了一个追踪对象(tracing object)。追踪对象是 Python 不可变基础类型的包装,记录起源 LMP 与相关元数据,并在任意后续操作中保留这条起源轨迹。其中最重要的就是_lstr(实现见 src/ell/types/_lstr.py)。
import ell @ell.simple(model="gpt-4o") # version: ae8f32s664200e1 def hi(): return "say hi" x = hi() # invocation id: 4hdfjhe8ehf (version: ae8f32s664200e1)这里的x功能上就是一个字符串、行为也与字符串完全一致,但它的实际类型是_lstr:
>>> type(x) <class 'ell.types._lstr.lstr'> >>> x 'hi' >>> x.__origin_trace__ {'4hdfjhe8ehf'}对字符串的持续操作会保留其起源轨迹,因为所有原生字符串操作都被覆写,产生的新不可变实例会包含或合并各自的 origin trace:
>>> x[0] 'h' >>> x[0].__origin_trace__ {'4hdfjhe8ehf'} >>> x + " there" 'hi there' >>> (x + " there").__origin_trace__ {'4hdfjhe8ehf'}当两个携带起源的对象被组合时,结果的轨迹是两者轨迹的并集:
>>> x = hi() # invocation id: 4hdfjhe8ehf >>> y = hi() # invocation id: 345hef345h >>> z = x + y >>> z.__origin_trace__ {'4hdfjhe8ehf', '345hef345h'}从源码看,_lstr直接继承str,以__origin_trace__(FrozenSet[str])保存起源,并系统性覆写了__add__、__mod__、__mul__、__rmul__、__getitem__、join、split、rsplit、splitlines、partition、rpartition等方法;__getattribute__会拦截未显式覆写的字符串方法,只要返回结果是字符串就包装回_lstr并合并参数中的轨迹。这意味着format、upper等几乎所有字符串操作都能自动延续追踪。tests/test_lstr.py 对拼接、取切片、join、split、partition、格式化等场景逐一验证了轨迹的保持与合并。
构建计算图与落到存储层的溯源
通过同时记录 LMP 的输入与输出,ell 可以用这些 origin trace 构建计算图,展示 LMP 在运行时的交互关系。基于此,你可以轻松追踪模型输出的流向、定位提示词链中的薄弱环节、识别提示词输入输出在传递中的非预期变化,并为未来对 LMP 的符号化与离散优化铺路。
例如一个包含子调用、参数中传递了其他 LMP 输出的聊天程序:
@ell.simple(model="gpt-4o-2024-08-06", temperature=1.0) def create_personality() -> str: """You are backstoryGPT. You come up with a backstory for a character incljuding name. Choose a completely random name from the list. Format as follows. Name: <name> Backstory: <3 sentence backstory>'""" # System prompt return "Come up with a backstory about " + random.choice(names_list) # User prompt def format_message_history(message_history : List[Tuple[str, str]]) -> str: return "\n".join([f"{name}: {message}" for name, message in message_history]) @ell.simple(model="gpt-4o-2024-08-06", temperature=0.3, max_tokens=20) def chat(message_history : List[Tuple[str, str]], *, personality : str): return [ ell.system(f"""Here is your description. {personality}. Your goal is to come up with a response to a chat. Only respond in one sentence (should be like a text message in informality.) Never use Emojis."""), ell.user(format_message_history(message_history)), ]追踪数据如何在存储层落地?关键链路位于 src/ell/util/serialization.py 的prepare_invocation_params():它对调用参数做 cattrs 序列化后,用正则提取 JSON 中所有"__origin_trace__"字段,得到本调用消费(consume)了哪些 invocation;随后 src/ell/lmp/_track.py 的_write_invocation()把消费列表写入Invocation(其used_by_id记录父调用),而SQLStore.write_invocation()会为每对「消费方 / 被消费方」写入一条InvocationTrace关联记录(src/ell/stores/models/core.py)。配合线程局部的调用栈(push_invocation/pop_invocation,同样在_track.py中),整个数据流图在调用发生的那一刻就被完整序列化了。
注意:当前 origin tracing 只作用于字符串原语。文档明确说明,对任意对象追踪的支持正在开发中,将在未来版本发布,以实现对 LMP 中更多数据类型的全面溯源。
总结与下一步
本文梳理了 ell 自动版本化与追踪的完整机制:词法闭包把提示词连同其全部依赖序列化为可复现源码 →MD5 版本哈希 + 按全限定名的版本号递增实现 checkpoint →SQLite + blob 双栈 Store持久化 LMP 与调用数据 →autocommit用 LLM 自动为版本差异生成 commit message →_lstr追踪对象把每次调用的起源轨迹贯穿任意字符串操作 →InvocationTrace关联表在存储层还原数据流计算图。这整套能力都以「不改动任何开发工作流」为前提:提示词工程师依旧用普通 Python 编写、调试、提交代码。
这些版本化与追踪数据的可视化、比较与排障,将由下一章介绍的工具完成:ell Studio(本地开源提示词版本控制与监控界面,启动方式见 docs/src/index.rst,命令为ell-studio --storage ./logdir),它能帮助你以经验化方式推进提示词优化并尽早发现回归。
- 人工智能
- 大模型
- 提示工程
- AI 应用
【免费下载链接】ell
A language model programming library.
相关推荐
ell 语言模型程序的序列化与版本化存储:从词法闭包到调用记录
ell 语言模型程序的序列化与版本化存储:从词法闭包到调用记录 导读 :在 ell 中,prompt 并不是一段被直接送入语言模型的纯文本,而是一个"生成这段文
人工智能大模型提示工程AI 应用ell 提示词序列化与版本存储深度解析:从 Optimizer/Serializer 设计草案到 Store 落地实现
ell 提示词序列化与版本存储深度解析:从 Optimizer/Serializer 设计草案到 Store 落地实现 本文围绕仓库内的设计草案文档 docs/
人工智能大模型提示工程AI 应用VoltAgent VoltOps 提示词分析:用量总览、版本指标与 Trace 溯源机制
VoltAgent VoltOps 提示词分析:用量总览、版本指标与 Trace 溯源机制 在 VoltAgent 的 VoltOps 提示词管理平台中, An
人工智能AI AgentAgent 框架后端多智能体RAG工具调用Agent 记忆Agent 工作流AI 评测MCP 服务MCP Clients语音
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考