数据截至 (上游 commit a4aa4c2b37fe)
从评估到监控:Snapshot 时序化、Workspace/Project、Cloud SDK、Guardrails 与 Prompt 优化
30 秒导读: 前面四章讲的是「怎么算出一份评估结果」(descriptor / metric / judge / stattest)。 本章讲算完之后:一份
Snapshot怎么被存进Project、沿时间轴堆成趋势图(监控 UI);怎么 把本地的 prompt / config / dataset 同步到 Evidently Cloud(SDK);怎么在请求发生的那一刻用 Guardrails 同步拦截有害输出;以及怎么用PromptOptimizer拿带标注的数据集反复打磨 prompt。 一句话:从「跑一次评估」变成「持续可观测 + 运行时护栏 + prompt 运维」。
本章不重复讲评估怎么算——那在 01/02/03/04。 这里只讲评估结果如何被沉淀、可视化、跨时间监控,以及运行时护栏和 prompt 运维工具。
1. 这是什么(零基础也能懂)
一句话定义: 这是 Evidently 的运维面——把评估从"在 notebook 里跑一次、看一眼报告"升级成 "持续跑、存起来、在 dashboard 上盯趋势,并在线上实时拦截坏输出"。
解决什么问题 / 给谁用: 假设你上线了一个 LLM 客服。你已经会用 Evidently 算"这批回答里有多少条 有毒 / 跑题 / 幻觉"(第 02-03 章)。但线上是天天在变的:
- 你想看这周和上周比,有毒率是涨了还是跌了 → 需要把每天的评估结果按时间轴排起来 → 监控 UI。
- 你不想等离线批量跑完才发现问题,想在回答发给用户之前就挡住有毒内容 → Guardrails。
- 你的判官 prompt 老是判错,你手里有 200 条人工标注,想让它自己学着改好 → Prompt 优化。
- 你没有真实评测集,想用文档合成一批 RAG 问答对来打分 → datagen。
它能做什么(功能一览):
| 能力 | 干什么 | 本章小节 |
|---|---|---|
| Workspace / Project | 把一批 Snapshot 归档到项目里,起一个本地 dashboard 服务 | §3 |
| ProjectDashboard | 配 tab / panel,让同一指标沿时间轴堆成趋势图 | §3、§4 |
| Cloud SDK | 用 RemoteWorkspace 把 prompt / config / dataset 同步到云端 | §5 |
| Guardrails | 在请求时同步校验输入/输出,不合格就抛异常拦下 | §6 |
| PromptOptimizer | 拿带标注数据集迭代改判官/任务 prompt | §7 |
| datagen | 用文档合成 RAG 评测数据 | §7 末 |
一句话直觉/类比: 把评估比作"体检"。前四章是体检项目本身(量血压、验血)。本章是: 把每次体检结果存进病历本(Project)、画成随时间的曲线(Dashboard),门口装个安检门当场拦人 (Guardrails),再请个私教照着历史数据帮你改毛病(PromptOptimizer)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 左边是"算评估"(前四章,本章不展开);中间是本章主角——Workspace 把结果
存成时序;右边分出三条运维支线。实线是数据流,虚线是"运行时旁路"。
┌──────────────── 本 章 范 围 ────────────────┐
第01-04章
Report/Preset ──run──▶ Snapshot ──add_run()──▶ Workspace ──┐
(算出一份评估) (一次评估的 (Project 归档) │
序列化结果) ▼
┌─ Project ─────┐
│ N 个 Snapshot │
│ 按 timestamp │
│ 排成时间轴 │
└──┬────────────┘
│ dashboard
▼
ProjectDashboard
add_tab / add_panel
(同一指标堆成趋势图)
│
┌──────────────────────┼───────────────────────┐
▼ ▼ ▼
本地 UI 服务 Evidently Cloud (以下为旁路)
evidently ui RemoteWorkspace Guardrails §6
§3 Cloud SDK §5 请求时同步拦截
prompt/config/dataset PromptOptimizer §7
同步到云端 离线迭代改 prompt
部件一句话职责:
| 部件 | 干什么 | 在哪个文件(符号) |
|---|---|---|
Snapshot | 一次评估的序列化结果(第 02 章产物) | 见 02-metrics-engine |
Workspace | 本地文件存储后端,存 project/snapshot/dataset | ui/workspace.py:731 Workspace |
Project | 归组一批 Snapshot,持有一个 dashboard | ui/workspace.py:146 Project |
ProjectDashboard | 管理 tab / panel,决定显示哪些指标、怎么排 | ui/workspace.py:59 ProjectDashboard |
| 本地 UI 服务 | Litestar app,起 dashboard 后端 | ui/service/app.py:17 create_app |
RemoteWorkspace / CloudWorkspace | 连远程 API / Evidently Cloud | ui/workspace.py:1015 / :1312 |
| Guardrails | 运行时护栏,请求时校验并拦截 | guardrails/core.py:58 validate_guards |
PromptOptimizer | 拿数据集迭代优化 prompt | llm/optimization/prompts.py:885 PromptOptimizer |
主线走一遍(高层,不进代码):
- 你在第 02 章用
Report.run(...)得到一个Snapshot。 workspace.add_run(project_id, snapshot)把它写进某个Project。每个 snapshot 带一个timestamp。- 同一个
Project攒了很多 snapshot 后,project.dashboard.add_panel(...)配一个折线面板, 指定"我要看ToxicityValue这个指标"。UI 就把每个 snapshot 里的这个指标值,按 timestamp 排成一条曲线。 evidently ui起一个本地 web 服务,浏览器里看这些趋势图。- 想上云 → 把
Workspace换成CloudWorkspace,同一套add_run/add_panelAPI 就同步到云端。
3. 核心机制一:把一批 Snapshot 存起来、起个 dashboard 服务
3.1 它要解决的小问题
一次评估算完就是一个 Snapshot 对象。你需要一个地方把很多次评估攒起来、给每次盖上时间戳、
按项目归档,再起一个服务让人在浏览器里看。这就是 Workspace + UI service 干的事。
3.2 思路/直觉:Workspace 是"磁盘 + 项目管理器"
Workspace 本质是一个面向本地文件系统的存储后端:给定一个目录,它把项目、快照、数据集
都写成文件。它的构造函数就在初始化一堆本地存储管理器:
ui/workspace.py:745 Workspace.__init__ 里,self.state = LocalState(self.path) 建立本地状态,
后面还挂上 DatasetManager、artifacts/prompts/configs 三个本地 SDK API(见 §5)。
存一个快照的核心就一句(ui/workspace.py:841 Workspace._add_run):
# ui/workspace.py:841 Workspace._add_run(节选)
def _add_run(self, project_id, snapshot):
snapshot_id = new_id() # 生成新 id
self.state.write_snapshot(project_id, snapshot_id, # 序列化写盘
snapshot.to_snapshot_model())
return snapshot_id
这段说明:本地存储 = 把 SnapshotModel 序列化到 <workspace>/<project_id>/snapshots/<id>.json
(路径见 ui/workspace.py:838 _get_snapshot_url)。
3.3 add_run 是公共入口:不只存快照,还能连数据
用户实际调用的是基类的 WorkspaceBase.add_run(ui/workspace.py:557),它包了一层:
# ui/workspace.py:557 WorkspaceBase.add_run(节选)
def add_run(self, project_id, run, include_data=False, name=None):
if name is not None:
run.set_name(name)
snapshot_id = self._add_run(project_id, run) # ← 各后端各自实现
if include_data: # 可选:把输入数据也传上去
current, reference = run.context._input_data
self.add_dataset(project_id, current, ..., # 用 SnapshotLink 关联回这个快照
link=SnapshotLink(snapshot_id=snapshot_id, dataset_type="output", dataset_subtype="current"))
...
return SnapshotRef(id=snapshot_id, project_id=project_id,
url=self._get_snapshot_url(project_id, snapshot_id))
重点看两处:
_add_run是抽象方法,Workspace/RemoteWorkspace/CloudWorkspace各自实现——同一个add_run门面,底层可本地可远程。这是本章"本地/云端一套 API"的关键(§5 会再见到)。include_data=True时,输入数据集也被上传,并用SnapshotLink(sdk/models.py:117)挂回这个 快照,后面在 UI 里能从快照点回它用的数据。
3.4 起服务:evidently ui --demo-projects
README 里最快的上手方式是:
evidently ui --demo-projects all # 起服务并塞入内置 demo 项目
CLI 入口在 cli/ui.py:89 ui。它做两件事(cli/ui.py:116-133):
- 若指定了 demo 项目,起一个 daemon 线程在后台生成 demo 数据(
_create_demo_projects_task)。 - 主线程
run(config)起真正的服务。
服务本体在 ui/service/app.py:
# ui/service/app.py:17,26 create_app / run
def create_app(config):
with config.context() as ctx:
builder = AppBuilder(ctx)
ctx.apply(builder) # 各 Component 往 app 上挂路由/依赖
app = builder.build()
ctx.finalize(app)
return app
def run(config):
app = create_app(config)
uvicorn.run(app, host=config.service.host, port=config.service.port) # Litestar + uvicorn
这段在演示什么: 服务不是一个写死的 app,而是组件拼装出来的。LocalConfig
(ui/service/local_service.py:145)声明了一套默认组件——storage / security / dashboard / datasets /
tracing 各一块, 每块通过 apply() 往 Litestar app 上注册自己的路由和依赖。
LocalServiceComponent.get_api_route_handlers(ui/service/local_service.py:54)列出了后端 API:
projects、artifacts、prompts、llm_judges 等 router。本地默认无鉴权(NoSecurityComponent),
除非你 --secret 给个 token(会换成 TokenSecurityComponent,见 ui/service/app.py:50-52)。
代码地图小结:
| 你想 | 打开 | 符号 |
|---|---|---|
| 看服务怎么拼起来 | ui/service/app.py | create_app / run |
| 看本地默认配了哪些组件 | ui/service/local_service.py | LocalConfig / LocalServiceComponent |
| 看 CLI 参数 | cli/ui.py | ui |
| 看快照怎么写盘 | ui/workspace.py | Workspace._add_run |
4. 核心机制二:Snapshot 沿时间轴堆成趋势图(Dashboard Panel)
4.1 它要解决的小问题
你有 30 个 snapshot(比如每天一个),每个里都算了"有毒率"。你想要的不是 30 张孤立报告,
而是一条随时间走的曲线。谁负责把"每个快照里的同一个指标值"抽出来、按时间排、画成图?
—— ProjectDashboard 的 panel 概念。
4.2 三层结构:Dashboard → Tab → Panel → Series
怎么读: 从上到下是包含关系;一个 panel 里可以叠多条 series,每条 series 认领"哪个指标"。
DashboardModel (整个项目的仪表盘配置)
└─ tabs: [DashboardTabModel] (标签页,分组用)
└─ panels: [panel_id...] (tab 只存 panel 的 id 引用)
└─ panels: [DashboardPanelPlot] (真正的面板定义,平铺存一份)
└─ values: [PanelMetric] (一个面板里的 N 条曲线/序列)
├─ metric: "evidently:metric_v2:ToxicityValue" ← 认领哪个指标
├─ tags / metadata / metric_labels ← 过滤&对齐
└─ (UI 把每个 snapshot 里这个指标值,按 timestamp 连成线)
数据模型在 sdk/models.py:DashboardModel:85(整体)、DashboardTabModel:21(tab 只存 panel id 列表)、
DashboardPanelPlot:63(面板)、PanelMetric:36(一条序列——它的 metric 字段指定要画哪个指标)。
时序化的关键就在 PanelMetric.metric: 它是一个指标标识(如 ToxicityValue)。UI 拿到这个 panel 后,
遍历项目里所有 snapshot,从每个快照里抠出这个指标的值,按快照的 timestamp(sdk/models.py:147)
排序连线。所以"同一份 Snapshot 沿时间轴堆叠成趋势图"= 面板声明要哪个指标 + 后端跨快照聚合。
4.3 怎么配面板:工厂函数 + add_panel
sdk/panels.py 提供了一组面板工厂,免得你手搓 DashboardPanelPlot:
| 工厂函数 | 画成什么 | plot_type |
|---|---|---|
text_panel | 纯文字块 | text |
counter_panel | 大数 字计数器(带聚合) | counter |
line_plot_panel | 折线(最常用于趋势) | line |
bar_plot_panel | 柱状(可堆叠) | bar |
pie_plot_panel | 饼图 | pie |
一个典型用法(教学示意,基于真实 API):
# 示意,非源码 —— 把"有毒率"配成一条随时间的折线
from evidently.sdk.panels import line_plot_panel
from evidently.sdk.models import PanelMetric
project.dashboard.add_panel(
line_plot_panel(
title="Toxicity over time",
values=[PanelMetric(metric="ToxicityValue", legend="有毒率")], # 认领指标
),
tab="Quality", # 放进 Quality 这个 tab,没有就自动建
)
注意 PanelMetric.metric 有个 validator(sdk/models.py:56 metric_is_alias):你写 "ToxicityValue",
它会自动补全成 "evidently:metric_v2:ToxicityValue"。
4.4 add_panel 的真实实现:落到哪个 tab
add_panel 的实际逻辑在 _RemoteProjectDashboard.add_panel(ui/workspace.py:310,本地和远程共用这个类
——见 Workspace.add_project 在 ui/workspace.py:793 就是 new 它)。核心分支:
# ui/workspace.py:310 _RemoteProjectDashboard.add_panel(节选)
_dashboard_model = self.model()
_dashboard_model.panels.append(panel) # panel 定义平铺存一份
if tab is not None:
for dashboard_tab in _dashboard_model.tabs:
if dashboard_tab.title == tab: # 找到同名 tab
_tab_id = dashboard_tab.id
if _tab_id is None and create_if_not_exists:
...append(DashboardTabModel(title=tab, panels=[])) # 没有就建
else:
if len(_dashboard_model.tabs) == 0:
...append(DashboardTabModel(title="General", ...)) # 一个 tab 都没有 → 建 General
_tab_id = _dashboard_model.tabs[0].id
...
self._workspace.save_dashboard(self.project_id, _dashboard_model) # 整体存回
重点看: panel 的定义存在 DashboardModel.panels(平铺),而 tab 只存 panel 的 id 引用
(tab.panels 是一串 id)。这样一个 panel 理论上可被多个 tab 引用,删 tab 不必删 panel。
add_tab(ui/workspace.py:284)/ delete_panel(:350)/ clear_dashboard(:407)都是同样的
"读整个 model → 改 → save_dashboard 存回"套路。