跳到主要内容

富结果:IPython 显示格式器、DataFrame 与图表抽取

30 秒导读: 你在沙箱里跑 df(一个 pandas 表)或 plt.plot(...)(一张 matplotlib 图), run_code 返回的结果里直接就有结构化的列数据、PNG 图片、甚至把图表拆成「点/柱/扇区」 的 JSON。这不是魔法——是 E2B 在 Jupyter 内核里偷偷注册了几个 IPython 显示格式器, 把富对象翻译成自定义 MIME 类型,一路流回客户端再还原成带类型的对象。本章讲透这条链。

本章是「内核桥」的下游:内核桥负责把 Jupyter 消息流回来, 本章负责讲那些消息里的富数据是怎么产生的、又是怎么被还原的


1. 这是什么(零基础也能懂)

先看一个现象

同样一句 run_code,普通代码解释器只会给你一串文本;E2B 却能给你多种格式:

# 示意,非源码 —— 客户端视角
exec = sandbox.run_code("""
import pandas as pd
df = pd.DataFrame({"city": ["Tokyo", "Paris"], "pop": [37, 11]})
df # 最后一行的表
""")

result = exec.results[0]
result.text # 表的文本形式(给人看)
result.html # 表的 HTML(给浏览器渲染)
result.data # {"city": ["Tokyo","Paris"], "pop": [37,11]} ← 结构化!能直接喂给下游

再看图表:

# 示意,非源码
exec = sandbox.run_code("""
import matplotlib.pyplot as plt
plt.bar(["a","b","c"], [3,7,2])
plt.show()
""")

result = exec.results[0]
result.png # base64 的图片(给人看 / 存文件)
result.chart # BarChart 对象:.elements = [BarData(label="a", value=3), ...] ← 拆成了数据!

关键点: result.dataresult.chart 里的东西,不是模型编出来的,是内核在你的 DataFrame / Figure 对象还活在内存里的时候,亲手把它拆出来的。对 AI agent 来说,这意味着 它拿到的是可信的结构化数据,不用再去 OCR 图片、也不用解析 print 出来的表格。

为什么要这么设计

给 agent 用代码解释器,最大的痛点是:模型看不见图,也读不懂乱七八糟的文本表格。 如果沙箱只回文本,agent 想「知道这张柱状图第二根柱子多高」就只能猜。E2B 的做法是—— 在数据还是 Python 对象的时候就抽取结构,让 agent 直接拿到 value=7

一句话直觉

把它想成内核里的一群「翻译官」:每当有个对象要被显示,IPython 就问在场的每个翻译官 「你能把它翻成你那门语言吗?」pandas 翻译官把 DataFrame 翻成 e2b/data 列字典,matplotlib 翻译官把 Figure 翻成 e2b/chart 图表字典。这些「语言」就是 MIME 类型


2. 顶层全景(富结果怎么从对象变成客户端字段)

先理解一个 IPython 的基础机制:_repr_*_ 协议与 display_formatter

  • 在普通 Jupyter 里,一个对象能显示成 HTML,是因为它有个 _repr_html_() 方法;能显示成图片, 是因为有 _repr_png_()。IPython 显示一个对象时,会遍历所有已注册的格式器(formatter), 每个格式器对应一种 MIME 类型,挨个问对象「你支持我吗」,把支持的那些收集成一个 MIME bundle({"text/plain": ..., "text/html": ..., "image/png": ...})。
  • E2B 的整个把戏就是:往这个格式器表里塞几个自定义 MIME 类型(e2b/datae2b/chart), 让 DataFrame / Figure 在被显示时,顺手也产出结构化数据。

一张图:从对象到客户端字段

怎么读:从上到下是一次显示的生命周期,左边是「东西是什么」,右边是「谁干的」。

用户代码最后一行: df (一个 pandas.DataFrame 还活在内核内存里)


IPython display_formatter.format(df)
遍历所有已注册格式器,问 df「你支持吗」 ← IPython 内建机制
├─ text/plain → df 的 __repr__
├─ text/html → df._repr_html_()
├─ e2b/data → E2BDataFormatter(df) 把表转成列字典 ← 0002_data.py 注册的
└─ e2b/chart → E2BChartFormatter(df) 不是 Figure,跳过


MIME bundle: {"text/plain":.., "text/html":.., "e2b/data": {"city":[..],"pop":[..]}}

▼ Jupyter execute_result / display_data 消息

沙箱内 FastAPI 服务 server/api/models/result.py Result.__init__
把每个 MIME key pop 进对应字段:
text/html → .html image/png → .png e2b/data → .data e2b/chart → .chart

▼ 流式 HTTP(见 01-sdk-client / 03-kernel-messaging)

客户端 SDK python/e2b_code_interpreter/models.py Result.__init__
同样把 dict 铺进字段;chart 字段额外 _deserialize_chart() → 带类型的 BarChart 对象


你手里的 result.data / result.png / result.chart

部件一句话职责

部件干什么在哪个文件
E2BDataFormatterDataFrame → e2b/data 列字典template/startup_scripts/0002_data.py:8
E2BChartFormattermatplotlib Figure → e2b/chart 图表字典template/startup_scripts/0002_data.py:33
E2BJSONFormatterlist/dict → JSON(处理 numpy / 非字符串键)template/startup_scripts/0002_data.py:58
图片补丁Image.save 到路径时顺手 display;禁掉无显示报错template/startup_scripts/0003_images.py
服务端 ResultMIME bundle → 结构化字段template/server/api/models/result.py:43
客户端 Result反序列化字段,chart 还原成类型对象python/e2b_code_interpreter/models.py:133
图表模型层级Chart/BarChart/… 带类型的数据结构python/e2b_code_interpreter/charts.py

核心分工一句话: 内核侧格式器负责「抽」,服务端 Result 负责「铺进字段」,客户端负责「还原成类型」


3. 核心原理(逐个机制,由浅入深)

3.1 IPython 的 _repr_*_ / display_formatter 机制

它要解决的小问题: 同一个对象,在不同前端要显示成不同东西(终端要纯文本、浏览器要 HTML、 记事本要图片)。IPython 用「一个对象 + 多个格式器」解决:每个格式器盯一种 MIME 类型。

思路: 每个格式器有两个约定:

  • format_type:它产出的 MIME 字符串(如 "text/html")。
  • print_method:它去对象上找的那个方法名(如 _repr_html_)——对象有这方法就调它。

E2B 自定义格式器就是照这个套路,只是不依赖对象自带方法,而是在格式器的 __call__ 里 自己判断对象类型、自己抽数据。看 E2BDataFormatter 的声明:

# template/startup_scripts/0002_data.py:8 E2BDataFormatter
class E2BDataFormatter(BaseFormatter):
format_type = Unicode("e2b/data") # 产出这个自定义 MIME
print_method = ObjectName("_repr_e2b_data_")
_return_type = (dict, str)

注册这一步是把格式器塞进内核那张全局的「翻译官名册」:

# template/startup_scripts/0002_data.py:76 注册进 display_formatter
ip = IPython.get_ipython()
ip.display_formatter.formatters["e2b/data"] = E2BDataFormatter(parent=ip.display_formatter)
ip.display_formatter.formatters["e2b/chart"] = E2BChartFormatter(parent=ip.display_formatter)
ip.display_formatter.formatters["application/json"] = E2BJSONFormatter(parent=ip.display_formatter)

关键细节: 这段代码在内核启动时由 startup script 执行(文件名 0002_ 保证顺序), 所以之后用户跑的每一行代码,只要显示对象,都会自动过这几个格式器。用户完全无感。

0002_data.py / 0003_images.py 属于内核启动脚本,构建沙箱模板时被放进内核的 startup 目录。 「这台机器怎么装出来的」见「沙箱模板」。

3.2 E2BDataFormatter:把 DataFrame 拆成列字典

它要解决的小问题: 让 agent 拿到 DataFrame 的结构化内容,而不是一坨对齐的文本表。

思路 + 精妙点(sys.modules 门控): IPython 对每一个被显示的对象,都会调用每一个 格式器。也就是说,你跑 1 + 1,那个 2 也会被送进 E2BDataFormatter。如果格式器上来就 import pandas,那哪怕你从没用过 pandas,每次显示都要付一次 pandas 导入代价。E2B 用一个巧妙的 门控绕开:不 import,而是查 sys.modules——

# template/startup_scripts/0002_data.py:14 E2BDataFormatter.__call__
def __call__(self, obj):
pandas = sys.modules.get("pandas") # 没导入过就是 None,不触发导入
if pandas is None or not isinstance(obj, pandas.DataFrame):
return super().__call__(obj) # 不是我的菜,走默认(通常返回 None)

result = obj.to_dict(orient="list") # {列名: [值...]}
for key, value in result.items():
result[key] = [
v.isoformat() if isinstance(v, pandas.Timestamp) else v for v in value
] # Timestamp → ISO 字符串(可 JSON 化)
return result

逻辑分三层,一层一件事:

  1. 门控: sys.modules.get("pandas")None 说明用户根本没 import pandas,那这对象不可能 是 DataFrame——直接放行。一个 DataFrame 能存在的前提就是 pandas 已被导入,所以这个判断 零漏报,又零导入成本。这是本章第一个精妙点。
  2. 抽取: to_dict(orient="list") 把表转成「列名 → 该列所有值」的字典。
  3. JSON 兼容: pandas 的 Timestamp 不能直接 JSON 序列化,逐个转成 isoformat() 字符串。

结果去向: 这个 dict 成为 MIME bundle 里 e2b/data 键的值,最后落到 result.data

3.3 E2BChartFormatter:把 matplotlib 图拆成结构化图表

它要解决的小问题: 让 agent 不用看图就知道「这张柱状图每根柱子的值」。

思路: 同样先用 sys.modules 门控(matplotlib 没导入就不可能有 Figure),命中后把 Figure 交给 e2b_charts.chart_figure_to_dict 去抽。

# template/startup_scripts/0002_data.py:39 E2BChartFormatter.__call__
def __call__(self, obj):
if sys.modules.get("matplotlib") is None: # 同款门控,避免 import 代价
return super().__call__(obj)
from matplotlib.pyplot import Figure
if not isinstance(obj, Figure):
return super().__call__(obj)
from e2b_charts import chart_figure_to_dict
try:
return chart_figure_to_dict(obj) # 真正的抽取
except: # noqa: E722
return {} # 抽不出来也不能让整次显示崩掉

真正的抽取在 e2b_charts:它遍历 Figure 的 Axes,判断图型,再拆成对应结构。

# chart_data_extractor/e2b_charts/main.py:99 chart_figure_to_chart
axes = figure.get_axes()
if not axes or is_figure_blank(axes):
return # 空图不产出
elif len(axes) > 1:
return SuperChart(figure=figure) # 多个子图 → 组合图
else:
return get_chart_from_ax(axes[0]) # 单图按类型(line/bar/pie/box…)抽

关键细节:

  • 图型由 get_chart_from_ax(main.py:70)按 Axes 上的图元判断,分成 line/scatter/bar/pie/box。
  • 多子图走 SuperChart,把每个子图各自抽一遍(对应客户端的 SuperChart.elements)。
  • 抽取失败兜底返回 {}——宁可没有 chart 字段,也不能让一次代码执行因为抽图报错而失败。

3.4 E2BJSONFormatter:让 numpy 和奇怪的键也能 JSON

它要解决的小问题: 用户显示一个含 numpy 数组、或用元组/整数当键的 dict,标准 JSON 会崩。

思路: 覆盖内建 JSONFormatter,对 list/dict 用 orjson 重新序列化再解析回来,借 orjson 的 两个选项抹平差异:

# template/startup_scripts/0002_data.py:58 E2BJSONFormatter.__call__
def __call__(self, obj):
if isinstance(obj, (list, dict)):
try:
import orjson
return orjson.loads(
orjson.dumps(
obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS
)
), {"expanded": True}
except TypeError:
pass
return super().__call__(obj)
  • OPT_SERIALIZE_NUMPY:numpy 标量/数组按原生数字序列化(标准 json 做不到)。
  • OPT_NON_STR_KEYS:非字符串键(如 int、元组)也能当 JSON 键。
  • 「dump 再 load」是为了得到一个纯 Python、保证可 JSON 化的对象,再交给 IPython 走标准路径。

3.5 图片补丁:save 到路径时顺手显示 + 禁掉无显示报错

它要解决的小问题: 两件事。(a) 沙箱里没有屏幕,PIL 想弹图片查看器会报错。(b) 用户常写 img.save("out.png") 存文件,但 agent 也想看到这张图。

0003_images.py 用两个猴补丁解决:

# template/startup_scripts/0003_images.py
def show_file(self, path, **options): # :8
return 0 # 禁掉图片查看器:无显示环境不报错

UnixViewer.show_file = show_file # :13

original_save = Image.save # :14
def save(image, fp, format=None, **options): # :17
if isinstance(fp, str): # 存到「字符串路径」时
display(image) # 顺手 display 一次 → 走格式器 → 产出 png 结果
original_save(image, fp, format, **options) # 再照常存盘
Image.save = save # :24

关键细节: 只在 fp字符串路径时才 display(存到文件对象/BytesIO 不触发,避免重复), 且先 display 再存盘——两件事都做,互不干扰。这样 img.save("x.png") 既落了盘,又让 result.png 里出现了图片。


4. 客户端侧:多 MIME 字段与图表反序列化

数据被抽出来后,要经过服务端 Result、再到客户端 Result。两处都在做「MIME bundle → 字段」, 但客户端多一步:把 chart 还原成带类型的对象

4.1 Result 的多 MIME 字段

客户端 Result 就是一个「一格一种格式」的容器,一次显示可能同时填好几格:

字段来自 MIME内容
texttext/plain纯文本(给人看的主表示)
htmltext/htmlHTML
markdowntext/markdownMarkdown
svg / png / jpeg / pdfimage/svg+xml矢量图 / base64 位图 / PDF
latextext/latexLaTeX
jsonapplication/jsonJSON
datae2b/dataDataFrame 列字典(§3.2)
charte2b/chart结构化图表(§3.3)

字段定义见 python/e2b_code_interpreter/models.py:116-131。相关方法:

  • formats()(models.py:172):返回这个结果实际有哪些格式——挨个字段查非空,收进列表。 agent 可以先看 formats() 决定怎么用这个结果。
  • is_main_result(models.py:169):标记这个结果是不是「cell 的那个返回值」。一次执行里, display() 调用会产生多个结果,但只有最后一行的表达式值是 main result。这个标记在服务端 就打好了:execute_result 消息 → is_main_result=True,display_data 消息 → False (见 template/server/messaging.py:518-526)。
  • Execution.text(models.py:374):遍历所有结果,只返回 main result 的文本——所以 exec.text 拿到的永远是「这段代码最后算出来的那个值」,不会混进中途 display 的东西。

4.2 chart 字段:反序列化成带类型的对象

e2b/chart 传过来是一个普通 dict(带 type: "bar" 之类)。客户端在 Result.__init__ 里 把它喂给 _deserialize_chart,变成一个真正的 BarChart 实例:

# python/e2b_code_interpreter/models.py:162 Result.__init__ 里
if chart:
try:
self.chart = _deserialize_chart(chart) # dict → BarChart / PieChart / ...
except Exception as e:
logger.error("Error deserializing chart, ...")

_deserialize_chart 是一张按 type 分发的表:

# python/e2b_code_interpreter/charts.py:211 _deserialize_chart
if data["type"] == ChartType.LINE: chart = LineChart(**data)
elif data["type"] == ChartType.SCATTER: chart = ScatterChart(**data)
elif data["type"] == ChartType.BAR: chart = BarChart(**data)
elif data["type"] == ChartType.PIE: chart = PieChart(**data)
elif data["type"] == ChartType.BOX_AND_WHISKER: chart = BoxAndWhiskerChart(**data)
elif data["type"] == ChartType.SUPERCHART: chart = SuperChart(**data)
else: chart = Chart(**data) # 未知类型兜底

4.3 图表模型层级

图表类是一棵继承树:公共字段往上收,专有字段往下放。

怎么读:箭头是「继承自」,右边是这一层新增的东西。

Chart type, title, elements charts.py:36
├─ Chart2D + x_label/y_label/x_unit/y_unit charts.py:56
│ ├─ PointChart + x/y_ticks, x/y_scale, elements=[PointData] charts.py:79
│ │ ├─ LineChart type=line charts.py:115
│ │ └─ ScatterChart type=scatter charts.py:119
│ ├─ BarChart elements=[BarData] charts.py:134
│ └─ BoxAndWhiskerChart elements=[BoxAndWhiskerData] charts.py:184
└─ PieChart elements=[PieData](非 2D) charts.py:155
SuperChart elements=[各种子 Chart] charts.py:194

几个要点:

  • ChartType / ScaleType(charts.py:5 / charts.py:19)是字符串枚举。构造时用 ChartType(kwargs["type"]) 把服务端字符串转成枚举;ScaleType 遇到不认识的值会兜底成 ScaleType.UNKNOWN(charts.py:94-97),不会崩。
  • PieChart 直接继承 Chart(不是 Chart2D)——饼图没有 x/y 轴,这个继承选择很讲究。
  • SuperChart.elements(charts.py:201)对每个子图递归_deserialize_chart, 于是一张有多子图的图,会被还原成「图里套图」的结构。
  • 每个 Chart 都留着 self._raw_data(charts.py:47),to_dict() 原样吐回—— 服务端回传时(serialize_results,models.py:323)用它把类型对象再压回 dict。

JS 侧对称但更轻:js/src/charts.ts 定义同样的类型枚举与形状,deserializeChart (charts.ts:117)用 switch(data.type) 做同样的分发,只是 TS 里图表是纯 type(结构体), 不像 Python 是带 __init__ 的类。


5. 巧妙之处(可借鉴的技术)

  1. sys.modules 门控代替 import,零成本判类型。 格式器对每个显示对象都会被调用, 直接 import pandas 会让「显示一个 2」也付导入代价。改查 sys.modules.get("pandas")—— None 就说明 pandas 没被导入,那对象不可能是 DataFrame,零漏报零成本。 0002_data.py:200002_data.py:42

  2. 在对象活着时抽结构,而不是事后解析文本。 DataFrame/Figure 还在内存里就 to_dict / 拆 Axes,拿到的是权威结构化数据,agent 不必 OCR 图或正则解析文本表。0002_data.py:24chart_data_extractor/e2b_charts/main.py:99

  3. 抽取失败一律兜底,绝不让一次执行崩。 图表抽不出返回 {}(0002_data.py:54), scale 不认识回 UNKNOWN(charts.py:96),chart 反序列化异常只记日志(models.py:165)。 富结果是「锦上添花」,永远不能拖垮基础执行。

  4. Image.save 猴补丁,让「存文件」也顺带「被看见」。 只在字符串路径时 display, 先显示后存盘,两不误。0003_images.py:17

  5. is_main_result 把「返回值」和「中途 display」分开。 服务端按 Jupyter 消息类型打标 (messaging.py:518-526),客户端 Execution.text 只取 main result(models.py:374)—— exec.text 永远干净。


6. 边界与局限

  • 只覆盖 pandas / matplotlib / PIL 这几类。 e2b/data 只认 pandas.DataFrame(Series、 polars、numpy 表都不走这条),e2b/chart 只认 matplotlib Figure(plotly/altair 不抽结构, 但它们自带的 HTML/PNG 表示仍会正常出现在 result.html/result.png)。
  • 图表抽取有图型上限。 e2b_charts 只识别 line/scatter/bar/pie/box_and_whisker/superchart; 其余落到 ChartType.UNKNOWN,chart 里就没有拆解好的 elements
  • DataFrame 的 JSON 兼容只特判了 Timestamp 其它非 JSON 类型(如自定义对象列)由 to_dict 原样带出,是否可序列化取决于下游——代码里只显式处理了 Timestamp(0002_data.py:28)。
  • 门控的代价:先显示前必须已 import。 这是设计而非 bug——但也意味着如果用某种方式让 DataFrame 存在却没让 pandassys.modules(几乎不可能),就抽不到。

matplotlibrc(设中文字体 Noto Sans CJK JP,让含中文标签的图不乱码)和 ipython_kernel_config.py(NoColormax_seq_length=0 等,把内核输出调成适合被机器解析的 干净形态)只是配置背景板,不参与抽取逻辑;它们如何被装进模板见「沙箱模板」。


7. 横向对比

  • 富结果的产生依赖内核桥(「内核桥」)把 execute_result / display_data 消息流回来;本章讲的格式器决定了那些消息里装什么
  • 富结果的传输与消费在 SDK 客户端(「SDK 客户端」):Result 对象最终 在客户端被组装,on_result 回调按到达顺序拿到它们。
  • 服务端把 MIME bundle 铺进字段这一步,由沙箱内的 FastAPI 服务做(「沙箱内服务」)。

8. 代码地图(导航索引)

主题文件路径符号名
DataFrame → e2b/datatemplate/startup_scripts/0002_data.py:8E2BDataFormatter
Figure → e2b/charttemplate/startup_scripts/0002_data.py:33E2BChartFormatter
numpy/非串键 JSONtemplate/startup_scripts/0002_data.py:58E2BJSONFormatter
注册进内核格式器表template/startup_scripts/0002_data.py:76ip.display_formatter.formatters
图片 save 补丁 / 禁查看器template/startup_scripts/0003_images.py:17save, show_file
Figure 拆结构(遍历 Axes)chart_data_extractor/e2b_charts/main.py:99chart_figure_to_chart, chart_figure_to_dict
服务端 MIME → 字段template/server/api/models/result.py:43Result.__init__
main/display 打标template/server/messaging.py:518execute_result, display_data
客户端 Result 字段/formatspython/e2b_code_interpreter/models.py:172Result.formats, is_main_result
取主结果文本python/e2b_code_interpreter/models.py:374Execution.text
chart 回压成 dictpython/e2b_code_interpreter/models.py:323serialize_results
图表模型层级python/e2b_code_interpreter/charts.py:36Chart, Chart2D, PointChart, BarChart, PieChart, BoxAndWhiskerChart, SuperChart
chart 反序列化分发python/e2b_code_interpreter/charts.py:211_deserialize_chart
图型 / 轴刻度枚举python/e2b_code_interpreter/charts.py:5ChartType, ScaleType
JS 图表类型与反序列化js/src/charts.ts:117deserializeChart, ChartType, ScaleType
中文字体配置template/matplotlibrc:1font.family
内核输出净化template/ipython_kernel_config.pyInteractiveShell.colors, PlainTextFormatter.max_seq_length