跳到主要内容

从评估到监控: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 SDKRemoteWorkspace 把 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/datasetui/workspace.py:731 Workspace
Project归组一批 Snapshot,持有一个 dashboardui/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 Cloudui/workspace.py:1015 / :1312
Guardrails运行时护栏,请求时校验并拦截guardrails/core.py:58 validate_guards
PromptOptimizer拿数据集迭代优化 promptllm/optimization/prompts.py:885 PromptOptimizer

主线走一遍(高层,不进代码):

  1. 你在第 02 章用 Report.run(...) 得到一个 Snapshot
  2. workspace.add_run(project_id, snapshot) 把它写进某个 Project。每个 snapshot 带一个 timestamp
  3. 同一个 Project 攒了很多 snapshot 后,project.dashboard.add_panel(...) 配一个折线面板, 指定"我要看 ToxicityValue 这个指标"。UI 就把每个 snapshot 里的这个指标值,按 timestamp 排成一条曲线
  4. evidently ui 起一个本地 web 服务,浏览器里看这些趋势图。
  5. 想上云 → 把 Workspace 换成 CloudWorkspace,同一套 add_run / add_panel API 就同步到云端。

3. 核心机制一:把一批 Snapshot 存起来、起个 dashboard 服务

3.1 它要解决的小问题

一次评估算完就是一个 Snapshot 对象。你需要一个地方把很多次评估攒起来、给每次盖上时间戳、 按项目归档,再起一个服务让人在浏览器里看。这就是 Workspace + UI service 干的事。

3.2 思路/直觉:Workspace 是"磁盘 + 项目管理器"

Workspace 本质是一个面向本地文件系统的存储后端:给定一个目录,它把项目、快照、数据集 都写成文件。它的构造函数就在初始化一堆本地存储管理器:

ui/workspace.py:745 Workspace.__init__ 里,self.state = LocalState(self.path) 建立本地状态, 后面还挂上 DatasetManagerartifacts/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):

  1. 若指定了 demo 项目,起一个 daemon 线程在后台生成 demo 数据(_create_demo_projects_task)。
  2. 主线程 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.pycreate_app / run
看本地默认配了哪些组件ui/service/local_service.pyLocalConfig / LocalServiceComponent
看 CLI 参数cli/ui.pyui
看快照怎么写盘ui/workspace.pyWorkspace._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_projectui/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 存回"套路。


5. 核心机制三:Cloud / 远程 SDK —— 一套 API,本地云端两开花

5.1 它要解决的小问题

本地跑 dashboard 适合自己看。团队协作、长期留存、跨机器共享,就要把东西放到远程 API 服务Evidently Cloud。难点是:你不想为"本地"和"云端"写两套代码。

5.2 思路:抽象基类 + 换实现

WorkspaceBase(ui/workspace.py:441)是抽象接口,定义了 add_project / add_run / add_dataset / save_dashboard 等。三个实现:

WorkspaceBase (抽象接口)
├─ Workspace 本地文件系统 ui/workspace.py:731
└─ RemoteWorkspace 连任意 Evidently API ui/workspace.py:1015
└─ CloudWorkspace 连 Evidently Cloud ui/workspace.py:1312

CloudWorkspace 直接继承 RemoteWorkspace——云端只是远程的一个特例(多了鉴权和 URL 默认值)。 所以你的业务代码从 Workspace("path") 换成 CloudWorkspace(token="sk_..."),add_run / add_panel 一个字都不用改

5.3 远程实现:把方法变成 HTTP 请求

RemoteWorkspace.add_project(ui/workspace.py:1100)就是把本地的"写文件"换成"发 POST":

# ui/workspace.py:1100 RemoteWorkspace.add_project(节选)
project_id = self._request(
"/api/v2/projects", "POST",
query_params=params, body=project.dict(), response_model=ProjectID)
return self.get_project(project_id)

CloudWorkspace 额外处理鉴权(ui/workspace.py:1380 _prepare_request):有 sk_ 开头的 API key 就 走 Authorization: Bearer,否则用 token 换 JWT 塞进 cookie。构造时(ui/workspace.py:1328)优先读 参数,退回环境变量 EVIDENTLY_API_KEY

5.4 同步哪些东西:prompt / config / dataset / artifact

三种后端都在构造时挂上四组 SDK API,但指向不同后端:

SDK API本地 WorkspaceRemoteWorkspace(OSS)CloudWorkspace
promptsLocalPromptAPIPromptArtifactAdapterCloudPromptAPI
configsLocalConfigAPIConfigArtifactAdapterCloudConfigAPI
artifactsLocalArtifactAPIRemoteArtifactAPIArtifactConfigAdapter
datasetsDatasetManagerRemoteDatasetsManagerRemoteDatasetsManager

(挂载点分别在 ui/workspace.py:778-780:1071-1075:1364-1368。)

这里有个巧妙的"适配器"设计: OSS 版没有专门的 prompt / config 端点,于是用 PromptArtifactAdapter (sdk/adapters.py:43)——它实现 PromptAPI 接口,但底层复用通用 artifact API,把 prompt 当成 一种 artifact 存(_artifact_to_promptsdk/adapters.py:58 做双向转换)。云端才有真正的 CloudPromptAPI(sdk/prompts.py:292)对着 /api/prompts 端点。同一个 PromptAPI 接口,三种落地。

5.5 Prompt 版本化:prompt 运维的基础

sdk/prompts.py 把 prompt 建成带版本的对象:Prompt(:51)是元数据,PromptVersion(:82) 是某一版内容,版本号从 1 递增。RemotePrompt.bump_version(sdk/prompts.py:186)= 在最新版基础上 +1 建新版:

# sdk/prompts.py:366 CloudPromptAPI.bump_prompt_version(节选)
try:
latest = self.get_version(prompt_id) # 拿最新版
version = latest.version + 1
except EvidentlyError: # 一版都没有 → 从 1 开始
version = 1
return self.create_version(prompt_id, version, content)

为什么重要: 这让 prompt 像代码一样有版本历史——线上用哪一版可回溯、可回滚。这正是下一节 PromptOptimizer 迭代出来的新 prompt 的归档去处

5.6 数据集上传:parquet + 多部分

RemoteDatasetsManager.add(sdk/datasets.py:138)把 Dataset 转成 parquet + data_definition, multipart 上传;load(:121)再读回来 Dataset.from_pandasadd 也接受 SnapshotLink, 把数据集关联回某个快照——和 §3.3 的 add_run(include_data=True) 是同一套关联机制。


6. 核心机制四:Guardrails —— 请求时的同步护栏

6.1 它要解决的小问题:离线评估 vs 运行时拦截

前几章的评估是离线、批量、事后的:跑一批数据,出一份报告。但线上有些事等不到事后—— 一条有毒回答、一段泄露 PII 的文本,必须在发给用户之前当场挡住。

这就是 Guardrails 和评估的分工:

离线评估(01-04 章)Guardrails(本节)
时机事后、批量请求发生的当下、单条
形式算指标、出报告通过 / 抛异常
目的度量质量、看趋势实时拦截、阻断
复用——复用第 03 章的 LLM judge + wrapper

6.2 思路:一个极简的"校验器"基类

Guardrail 的抽象小到不能再小(guardrails/core.py:6):

# guardrails/core.py:6 GuardrailBase
class GuardrailBase:
def name(self) -> str: return self.__class__.__name__
@abc.abstractmethod
def validate(self, data: str):
"""通过则返回 None;不通过则 raise GuardException"""
raise NotImplementedError()

约定就是: validate 静默通过,或抛 GuardException(guardrails/core.py:28)。批量校验用 validate_guards(guardrails/core.py:58)——逐个跑,收集所有失败,有失败就抛聚合异常 GuardsException:

# guardrails/core.py:58 validate_guards
def validate_guards(data, guards):
failed = {}
for guard in guards:
try:
guard.validate(data)
except GuardException as e:
failed[e.guard] = e
if len(failed) > 0:
raise GuardsException(failed) # 一次性报告所有踩线的护栏

6.3 五种内置护栏:两类实现

怎么读: 上两个是"纯本地规则,快而免费";下三个"调 LLM judge,准而花钱"。

护栏判什么怎么实现文件
WordsPresence / IncludesWords词表包含/排除纯正则 + 集合guards/word_presence.py:8
PythonFunction任意自定义函数调用 Callable[[str],bool]guards/python_function.py:7
PIICheck是否含个人信息LLM judgeguards/pii_llm.py:11
ToxicityCheck是否有毒LLM judgeguards/toxicity.py:11
NegativityCheck是否消极LLM judgeguards/negativity.py:11

LLM 类护栏是怎么复用第 03 章的: 三个 LLM 护栏结构一模一样。以 ToxicityCheck.validate (guards/toxicity.py:18)为例:

# guards/toxicity.py:18 ToxicityCheck.validate(节选)
piillm_eval = ToxicityLLMEval(provider="openai", model="gpt-4o-mini") # 复用第03章的判官 descriptor
request = LLMRequest(
messages=piillm_eval.template.get_messages({"input": data}), # 复用它的 prompt 模板
response_parser=piillm_eval.template.get_parser(),
response_type=dict)
wrapper = get_llm_wrapper(piillm_eval.provider, piillm_eval.model, Options()) # 复用第03章的 wrapper
response = wrapper.run_sync(request) # 注意:同步!
if response.get("category") != "OK":
raise GuardException(self, response.get("reasoning") or "")

重点看两处:

  • 护栏没有重新发明判官——直接实例化第 03 章的 ToxicityLLMEval,借它的 templatewrapper。 离线评估和运行时护栏共用同一个判官定义,判定口径一致。
  • 用的是 run_sync(同步阻塞),而第 03 章批量评估走异步引擎。因为护栏是卡在请求路径上的, 必须等它出结果才能放行——这正是"同步拦截"和"离线批量"的实现差异

6.4 怎么用:@guard 装饰器

guardrails/decorators.py:17@guard 让你把护栏贴到任意函数上:

# 示意,非源码 —— 用法(README 也是这个例子)
from evidently.guardrails import guard, PIICheck

@guard(PIICheck()) # 校验名为 input 的入参
def generate_response(input: str) -> str:
... # 你的 LLM 调用

装饰器实现的关键(guardrails/decorators.py:22):它用 inspect.signature 绑定参数,取出名为 input_arg(默认 "input")的那个参数去校验,通过了才执行原函数:

# guardrails/decorators.py:22 wrapper(节选)
bound = sig.bind(*args, **kwargs); bound.apply_defaults()
if input_arg not in bound.arguments:
raise Exception(f"{input_arg} is not a valid argument")
if isinstance(guard, list):
validate_guards(bound.arguments[input_arg], guard) # 多护栏
else:
guard.validate(bound.arguments[input_arg]) # 单护栏
return func(*args, **kwargs) # 校验过了才真正执行

6.5 和 tracely 集成:护栏结果落到 trace 上

@guard 里还夹了一段(guardrails/decorators.py:28):若装了 tracely 且当前有 span,就把护栏列表 写进 span 的 context。配合 GuardrailsInterceptor(guardrails/trace.py:16),就能把每条护栏 passed/failed 及原因记成 span 属性(_set_span_for_guardguardrails/trace.py:43evidently.guardrail.<id>.status 等):

# guardrails/trace.py:26 on_exception(节选)——护栏失败时标注 trace
if isinstance(ex, GuardsException):
for idx, guard in enumerate(guards):
status = "failed" if guard in ex.failed_guards else "passed"
self._set_span_for_guard(span, f"g_{idx}", guard.name(), status, ...)
return True

意义: 运行时拦截不是黑盒——每次护栏动作都能沉淀成可观测的 trace,和 §3-4 的监控面板呼应, 形成"离线趋势 + 在线拦截日志"的完整可观测闭环。(tracely 是 Evidently 的追踪库,未装则 trace.py 的 import 直接 raise——见 guardrails/trace.py:7-13。)


7. 核心机制五:Prompt 自动优化(拿标注数据迭代改 prompt)

7.1 它要解决的小问题

你的判官 prompt 或任务 prompt 效果不够好,但你手里有带标注的数据集(输入 + 期望标签)。 能不能让程序自动照着"哪些判错了"去改 prompt,反复迭代到更准?—— 这就是 llm/optimization/

7.2 顶层循环:执行 → 打分 → 让 LLM 改 prompt → 再来

怎么读: 一圈是一次迭代;FeedbackStrategy 会把"判错的行"喂回给 LLM 让它改 prompt。

起始 prompt


┌─────────────┐ executor 在数据集上跑这个 prompt
│ 执行(execute)│───────────────────────────────┐
└─────────────┘ │
│ predictions │
▼ │
┌─────────────┐ scorer 比预测 vs 标注,算准确率 │
│ 打分(score) │ │
└─────────────┘ │
│ score │
▼ │
┌──────────────────┐ strategy 把"prompt+错例" │
│ 优化(strategy.run)│ 发给 LLM,要一版新 prompt │
└──────────────────┘ │
│ new_prompt │
└───────► 回到"执行" ◄─────────────────────┘
直到 early_stop 触发

7.3 状态容器:OptimizerContext

一切状态挂在 OptimizerContext(llm/optimization/optimizer.py:552):一个 params 字典 (dataset / scorer / executor / early_stop 都塞这里,键名见 optimizer.py:34 Params)+ 一串 runs。它有个机制:配置阶段可写,lock()(optimizer.py:646)后只读——get_param 在未锁时 读会报错(optimizer.py:637)。这保证优化跑起来后参数不被偷改

BaseOptimizer(optimizer.py:695)是所有优化器的基类,持有这个 context 并转发 set_param/get_param

7.4 主类:PromptOptimizer

用户面对的是 PromptOptimizer(llm/optimization/prompts.py:885)。用法(教学示意):

# 示意,非源码 —— 用带标注数据集优化一个判官 prompt
from evidently.llm.optimization.prompts import PromptOptimizer

opt = PromptOptimizer("my-run", strategy="feedback", verbose=True) # 选反馈策略
opt.set_input_dataset(labeled_dataset) # 输入:带 target 的数据集
opt.run(executor=my_judge, repetitions=3) # 跑3次(不同种子)
print(opt.best_prompt()) # 拿最好的 prompt

run(prompts.py:907)是 arun 的同步包装。arun(:942)把 executor / scorer / early_stop 塞进 context、锁定、然后按 repetitions 并发跑多个 run:

# llm/optimization/prompts.py:942 arun(节选)
if dataset is not None: self.set_input_dataset(dataset)
executor = get_prompt_executor(executor); self.set_param(Params.Executor, executor)
if scorer is None: scorer = self.config.strategy.get_default_scorer() # 策略自带默认打分器
self.set_param(Params.Scorer, get_scorer(scorer))
self.set_param(Params.EarlyStop, early_stop or EarlyStopConfig())
self._lock() # 锁定,禁止再改参数
runs = [self._create_run(executor) for _ in range(repetitions)]
await asyncio.gather(*runs) # 并发多次

单次 run 的循环在 resume(prompts.py:1002):先评估当前 prompt → 让 strategy 产出新 prompt → 再评估 → 判断是否早停:

# llm/optimization/prompts.py:1002 resume(节选)
prompt = await _evaluate_prompt(run, prompt, executor, scorer) # 先跑一遍打个底
while not stop:
opt_log = await self.config.strategy.run(prompt, run) # ← 策略产出新 prompt
run.add_log(opt_log)
prompt = opt_log.new_prompt
await _evaluate_prompt(run, prompt, executor, scorer) # 评估新 prompt
stop = opt_log.stop or early_stop.should_stop(run) # 早停判断

7.5 两种策略:simple vs feedback

策略是可插拔的(PromptOptimizerStrategy,prompts.py:91),内置两种:

策略别名怎么改 prompt默认打分器
SimplePromptOptimizersimple直接让 LLM "把这个 prompt 改好点",一次就停NoopOptimizationScorer
FeedbackStrategyfeedback判错的行(input/期望/实际)喂给 LLM 让它针对性改AccuracyScorer

feedback 是重点。它先用 iter_mistakes(prompts.py:1175)从上一轮执行结果里挑出 pred != target 的行,填进模板,再复用 SimplePromptOptimizer 发给 LLM:

# llm/optimization/prompts.py:1247 FeedbackStrategy.run(节选)
rows = "\n".join( # 只把"错例"拼进 prompt
self.row_template.format(input=r.input, target=r.target,
llm_response=r.prediction, ...)
for r in iter_mistakes(run))
optimizer_prompt = self.add_feedback_prompt.format(..., rows=rows)
log = await SimplePromptOptimizer(optimizer_prompt=optimizer_prompt).run(prompt, run)
return PromptOptimizationLog(..., new_prompt=log.new_prompt, stop=False) # stop=False → 继续迭代

它的优化 prompt 模板(prompts.py:1219 add_feedback_prompt)明确要求 LLM "泛化这些例子、别过拟合"。 数据集会按 get_default_data_split_shares(prompts.py:1239)切成 train/val/test(0.4/0.4/0.2), 只用 train 的错例改 prompt,避免在评估集上作弊。

7.6 打分器:一堆 sklearn 指标

OptimizationScorer(llm/optimization/scorers.py:36)把预测 vs 标注算成分数,按 split 分别算 (scorescorers.py:70)。内置一排,都是薄薄地包了 sklearn:

scorer别名底层
AccuracyScoreraccuracy(pred==target).mean()
F1Scorer / PrecisionScorer / RecallScorerf1/precision/recallsklearn *_score
MCCScorer / CohenKappaScorermcc/cohen_kappa相关系数
RocAucScorer / LogLossScorer / BrierScoreScorerroc_auc/…概率类指标

(见 scorers.py:115 起。)优化的目标就是让这个分数在迭代中变高,best_prompt(prompts.py:1054) 最后按分数挑出最好的一版。这一版 prompt 可以接着用 §5.5 的 bump_version 存进 Cloud 归档。

7.7 顺带一提:datagen 合成评测数据

优化和评估都需要数据。没有真实评测集时,llm/datagen/ 能用文档合成。RAG 场景的 RagDatasetGenerator(llm/datagen/rag.py:341)从文档 chunk 出发,先生成问题、再生成答案, 产出成对的 query-response 评测集(问题模板 RagQueryPromptTemplate:36、答案模板 RagResponsePromptTemplate:59)。导出的三个生成器见 llm/datagen/__init__.py:整套的 RagDatasetGenerator、只生成问题的 RagQueryDatasetGenerator、只生成答案的 RagResponseDatasetGenerator。合成出来的数据集,正好可以喂回 §7.4 的 set_input_dataset


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

  • 一套 API,三种后端。 WorkspaceBase(ui/workspace.py:441)让 add_run/add_panel 门面不变, 本地/远程/云端各自实现底层——业务代码换个构造函数就上云。(§5.2)
  • 适配器复用通用存储。 OSS 没有 prompt 端点,就用 PromptArtifactAdapter(sdk/adapters.py:43) 把 prompt 当 artifact 存,同一个 PromptAPI 接口三处落地。(§5.4)
  • 护栏复用离线判官。 LLM 护栏(guards/toxicity.py:18)直接实例化第 03 章的判官 descriptor, 只是改用 run_sync 同步阻塞——离线/在线口径一致,代码零重复。(§6.3)
  • 护栏结果进 trace。 GuardrailsInterceptor(guardrails/trace.py:16)把每条护栏 passed/failed 写成 span 属性,拦截动作可观测。(§6.5)
  • 优化器锁参数。 OptimizerContext.lock()(optimizer.py:646)在跑起来后禁止改参数, get_param 未锁读会报错——防止迭代中途配置漂移。(§7.3)
  • 只拿错例改 prompt + 切分防作弊。 FeedbackStrategy 只把 train split 的 pred!=target 行喂回 LLM 并要求泛化(prompts.py:1247),避免过拟合、避免在评估集上作弊。(§7.5)

9. 边界与局限(诚实)

  • 本地默认无鉴权。 Workspace + LocalConfigNoSecurityComponent(local_service.py:146), 只适合本机;要挡写操作得 --secretTokenSecurityComponent
  • LLM 护栏是同步阻塞、要花钱。 PIICheck/ToxicityCheck/NegativityCheck 每次都实打实调一次 gpt-4o-mini(guards/toxicity.py:19),会给请求路径加延迟和成本;词表/函数类护栏才是"免费快"的。
  • 护栏只"抛异常",不改写。 validate 语义是通过或 raise(guardrails/core.py:14),内置护栏不做 脱敏/改写,拦下后怎么降级由调用方自己处理。
  • 优化器 checkpoint 是半成品。 BaseOptimizer.__init__(optimizer.py:702)里 load/save checkpoint 的代码被注释掉了(:712-717),OptimizerContext.load/save(optimizer.py:568-573)也是注释—— 目前跑崩了不能断点续跑
  • 打分器口径限制。 多数 scorer 是分类指标;RocAucScorer/LogLossScorer 需要概率型预测 (scorers.py:187),用错预测形态会直接报错。
  • 时序聚合逻辑不在本章代码里。 panel 声明"要哪个指标"(sdk/models.py:36),但"跨快照按 timestamp 抠值连线"的实际渲染由 UI 后端/前端完成,本章只覆盖到配置与存储层。

10. 横向对比(同组其它章)

关切本章不讲,去哪看
行级评估怎么把分数长到数据上01-data-model
Metric 类型、Report→Snapshot、Preset、Test 条件02-metrics-engine
判官 Descriptor、Prompt 模板、异步批量 LLM 引擎03-llm-as-judge
数据漂移与 20+ stattest04-drift-and-stats
Evidently 是什么 / 全景 / 阅读地图index

本章与它们的关系: 前四章产出"一份评估结果"(Snapshot),本章负责把结果沉淀、可视化、 跨时间监控,并加上运行时护栏prompt 运维。评估的计算原理本身都在前四章,本章不重复。


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

主题文件路径符号
本地工作区(存快照/项目)src/evidently/ui/workspace.pyWorkspace / Workspace._add_run
工作区抽象接口src/evidently/ui/workspace.pyWorkspaceBase / add_run
项目 / 仪表盘对象src/evidently/ui/workspace.pyProject / ProjectDashboard
加 tab / panel 实现src/evidently/ui/workspace.py_RemoteProjectDashboard.add_tab / add_panel
远程 / 云端工作区src/evidently/ui/workspace.pyRemoteWorkspace / CloudWorkspace
UI 服务装配src/evidently/ui/service/app.pycreate_app / run
本地服务默认组件src/evidently/ui/service/local_service.pyLocalConfig / LocalServiceComponent
CLI 入口src/evidently/cli/ui.pyui
仪表盘数据模型src/evidently/sdk/models.pyDashboardModel / DashboardPanelPlot / PanelMetric
面板工厂src/evidently/sdk/panels.pyline_plot_panel / counter_panel / …
Prompt 版本化 SDKsrc/evidently/sdk/prompts.pyPromptVersion / CloudPromptAPI / bump_prompt_version
数据集远程管理src/evidently/sdk/datasets.pyRemoteDatasetsManager
SDK 适配器src/evidently/sdk/adapters.pyPromptArtifactAdapter
护栏核心src/evidently/guardrails/core.pyGuardrailBase / validate_guards
@guard 装饰器src/evidently/guardrails/decorators.pyguard
护栏 trace 集成src/evidently/guardrails/trace.pyGuardrailsInterceptor
内置护栏src/evidently/guardrails/guards/ToxicityCheck / PIICheck / WordsPresence / PythonFunction
优化器状态/基类src/evidently/llm/optimization/optimizer.pyOptimizerContext / BaseOptimizer
Prompt 优化主类/策略src/evidently/llm/optimization/prompts.pyPromptOptimizer / FeedbackStrategy / SimplePromptOptimizer
打分器src/evidently/llm/optimization/scorers.pyOptimizationScorer / AccuracyScorer
RAG 合成数据src/evidently/llm/datagen/rag.pyRagDatasetGenerator