日志、事件 Transcript、沙箱与可观测性
30 秒导读: 一次评测跑完,你想要三样东西:一份能永久归档、能重新读回的结构化结果;一条能逐步回放"模型说了什么、工具做了什么"的过程录像;以及一个隔离的执行环境,让不可信的 agent 代码不会祸害你的机器。本章讲 Inspect 怎么把这三件事拧成一条线——所有过程都被拆成一个个事件(Event),同 一条事件流同时喂给内存里的
Transcript、一个 SQLite 实时缓冲库(给 live view 做回放)、和最终的.evalZIP 文件(落盘归档);而沙箱里每一次exec/read_file/write_file也被一层 Proxy 顺手变成事件,汇进同一条流。
本章聚焦横切线:结果如何落盘、过程如何回放、工具在哪隔离。各机制自身的逻辑(主循环、Solver、模型层、工具、打分)见同组其它章,这里只讲它们留下的痕迹去了哪、怎么被读回。
1. 这是什么(零基础也能懂)
先建立三个心智模型,后面全靠它们。
痕迹分两层:结果 vs 过程。
- 结果层(log) = 一次评测的"体检报告":跑了什么任务、用了什么模型、每条样本输入输出是什么、得了几分、花了多少 token。这是结构化的、要归档的、几个月后还要读回来对比的。
- 过程层(transcript) = 一次评测的"行车记录仪":第 1 步模型看到这些消息、第 2 步它决定调这个工具、第 3 步工具在沙箱里跑了条命令、第 4 步打分器给了分……一步一步、带时间戳。
过程层的原子单位是"事件(Event)"。 模型调用是一个 ModelEvent,工具调用是一个 ToolEvent,沙箱执行是一个 SandboxEvent,打分是一个 ScoreEvent……几十种事件类型,共同一个基类。整个 transcript 就是一个只往后 追加(append-only)的事件列表。
隔离层(sandbox) = 一个"手套箱":agent 想执行代码、读写文件,都必须通过一个 SandboxEnvironment 抽象接口,底下可能是本地临时目录,也可能是 Docker 容器。手套箱在这里的意义有两层——安全隔离(别让 agent 删你的家目录),以及可观测(每次伸手进箱子都被记一笔事件)。
一句话直觉: 把 transcript 当成一条流水账,每笔账是一个事件;log 是这条账结账后的报表;sandbox 是账里"动手脚"那些笔发生的隔离车间,车间门口装了摄像头(Proxy),进出都记账。
用起来什么样。 跑完一个 eval 你会得到一个 logs/xxx.eval 文件(其实是个 ZIP)。读回来:
from inspect_ai.log import read_eval_log
log = read_eval_log("logs/2025-01-01T12-00-00_task_abc.eval")
print(log.status) # "success"
print(log.results.scores[0].metrics) # {"accuracy": ...}
for event in log.samples[0].events: # 逐事件回放第一条样本
print(event.event, event.timestamp) # "sample_init" / "model" / "tool" / ...
Inspect View(inspect view)就是把 log.samples[i].events 这条事件流渲染成可点开的时间线;而当 eval 正在跑时,它读的不是这个还没写完的 ZIP,而是旁边那个实时 SQLite 缓冲库。
2. 顶层全景(它大概怎么转)
2.1 一条事件,三个去处
先看最重要的一张图:一个事件产生后去了哪。 这是理解本章的总钥匙。
怎么读:从左边"事件源头"出发,事件先进内存 Transcript,Transcript 通过订阅回调把它同时扇出到两条落盘线——实时缓冲(给 live view)和最终 ZIP(给归档)。
事件源头 内存 落盘 / 回放
┌──────────┐ ┌───────────────┐
│ 模型调用 │──emit──▶ │ │
│ 工具调用 │──emit──▶ │ Transcript │ ┌────────────────────┐
│ 沙箱exec │──emit──▶ │ (事件列表 + │──订阅回调─▶│ SampleBufferDatabase│─▶ live view
│ 打分/状态 │──emit──▶ │ 订阅者列表) │ ① │ (SQLite,实时) │ (边跑边看)
└──────────┘ │ │ └────────────────────┘
│ _event(): │
│ 去重·追加· │──最终·整条─▶┌────────────────────┐
│ 通知·(可evict) │ ②样本 │ EvalRecorder │─▶ xxx.eval
└───────────────┘ 结束时 │ (ZIP,归档) │ (读回/归档)
- ① 实时线(边跑边看): 每来一个事件,订阅回调立刻把它写进一个 SQLite 库。live view 轮询这个库,做增量回放。
- ② 归档线(结账落盘): 样本跑完,整条事件流连同结果打进
.evalZIP,永久归档、可完整读回。
关键设计:两条线读的是同一条事件流,只是节奏不同(实时 vs 结账)。事件本身不知道自己会被写几次——它只管"被 emit 一次"。
2.2 主要部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
EvalLog | 一次评测的顶层结果对象(状态/配置/结果/样本) | log/_log.py:1085 |
EvalSample | 一条样本的完整记录(消息/输出/分数/events) | log/_log.py:374 |
EvalSampleSummary | 样本的瘦身摘要(不含 events/output,加载快) | log/_log.py:261 |
Transcript | 内存里的 append-only 事件列表 + 订阅扇出 | log/_transcript.py:380 |
Event(联合类型) | 所有事件类型的并集(model/tool/score/sandbox…) | event/_event.py:27 |
BaseEvent | 事件基类:uuid / span_id / timestamp / pending | event/_base.py:16 |
Recorder | 落盘抽象接口(log_init/log_sample/log_finish/read_log) | log/_recorders/recorder.py:24 |
EvalRecorder | .eval(ZIP)格式的落盘实现 | log/_recorders/eval.py:93 |
SampleBufferDatabase | 实时事件缓冲(SQLite),供 live view | log/_recorders/buffer/database.py:89 |
SandboxEnvironment | 隔离执行环境抽象(exec/read/write) | util/_sandbox/environment.py:92 |
SandboxEnvironmentProxy | 包一层,把每次 sandbox I/O 变成 SandboxEvent | util/_sandbox/events.py:33 |
view() | 起 Inspect View 服务器 | _view/view.py:23 |
2.3 主线走一遍(高层,不进代码)
追一条样本从生到死,看痕迹怎么攒出来:
样本开始
│ init_transcript(新建 Transcript) run.py:1126
│ _subscribe(on_sample_event) ← 挂订阅 run.py:1128
│ emit SampleInitEvent(记下样本+初始状态) run.py:1268
▼
Solver 循环(每步都 emit 事件)
│ 模型生成 → ModelEvent
│ 工具调用 → ToolEvent
│ 沙箱 exec → SandboxEvent(Proxy 自动记)
│ 每个事件 → Transcript._event() → 订阅回调 → 写 SQLite 缓冲(实时)
▼
打分
│ ScoreEvent
▼
样本结束
│ 组装 EvalSample(events=整条流,scores,output…)
│ Recorder.log_sample() → 缓冲进 ZipLogFile
▼
评测结束
│ Recorder.log_finish() → 写 header.json / summaries / reductions → 关 ZIP
▼
xxx.eval(可 read_eval_log 读回)
订阅那一步(_subscribe(on_sample_event))是实时线的接头:每个事件都会触发 on_sample_event,它把事件送进 SQLite 缓冲库(run.py:1103-1112、log/log.py:389)。
3. 核心原理(逐个机制,由浅入深)
3.1 事件模型:一个基类,几十种事件
要解决的小问题: 过程录像要能记下任何发生的事——模型调用、工具调用、状态变更、日志、报错——而且事后要能统一遍历、按时间排序、按类型过滤。
思路: 定义一个共同基类 BaseEvent,携带所有事件都需要的横切字段;各具体事件继承它、加自己的载荷;再用一个 Union 把它们并成一个 Event 类型,靠 event 字段这个**判别符(discriminator)**区分。
共同字段(event/_base.py:16 BaseEvent):
| 字段 | 含义 | 为什么横切 |
|---|---|---|
uuid | 事件全局唯一 id | 去重、增量拉取、更新定位都靠它 |
span_id | 所属 span(见 3.3) | 把平铺事件重建成树 |
timestamp | 墙上时钟时间 | 排序、回放 |
working_start | 样本内"工作时间"起点 | 算 working_time(扣掉等锁/重试) |
pending | 是否"未完成"(占位) | 流式:先记一个占位事件,完成后更新 |
一个巧妙细节:事件 id 和 span_id 是在 model_post_init 里自动生成的,但反序列化(从日志读回)时跳过,以保留原值:
真实实现见 event/_base.py:35 BaseEvent.model_post_init —— 非反序列化时,uuid 为空则 uuid(),span_id 为空则取 current_span_id()(当前活跃 span)。
具体事件类型(部分,event/_event.py:27 的 Event 联合):
| 事件类 | event 值 | 记什么 | 文件 |
|---|---|---|---|
SampleInitEvent | sample_init | 样本 + 初始 state | event/_sample_init.py |
ModelEvent | model | 一次模型调用(输入/工具/输出/原始 call) | event/_model.py:54 |
ToolEvent | tool | 一次工具调用(函数/参数/结果/错误) | event/_tool.py:13 |
SandboxEvent | sandbox | 一次 exec/read_file/write_file | event/_sandbox.py:10 |
StateEvent | state | TaskState 的 JSON diff | event/_state.py:9 |
StoreEvent | store | store(键值存储)的变更 | event/_store.py |
ScoreEvent | score | 一次打分(中间分或最终分) | event/_score.py:10 |
SubtaskEvent | subtask | 子任务派生 | event/_subtask.py |
SpanBeginEvent/SpanEndEvent | span_begin/span_end | span 边界(见 3.3) | event/_span.py |
InterruptEvent | interrupt | 取消/中断(operator/limit/system) | event/_interrupt.py |
InfoEvent/LoggerEvent | info/logger | 自定义信息 / Python 日志 | event/_info.py、_logger.py |
巧妙处 —— 事件"更新"而非只"追加": 有些事件先以 pending=True 占位(比如工具还在跑),完成后原地更新字段。ToolEvent._set_result(event/_tool.py:70)就是完成时把 result/completed/working_time 填回去,并清 pending。这让 live view 能先显示"工具执行中…",再无缝变成结果——不用先删占位再插新行。
3.2 Transcript:内存事件流 + 订阅扇出
要解决的小问题: 事件在代码各处产生(模型层、工具层、沙箱层),需要一个当前样本共享的收集点,还要能在事件到达时立刻通知关心它的人(实时落盘、live UI、检查点)。
思路: 用一个 ContextVar 存"当前 transcript",谁需要就 transcript() 拿;Transcript 内部维护事件列表 + 一份订阅者列表;每 _event() 一次,追加进列表并扇出给所有订阅者。
拿到当前 transcript(log/_transcript.py:863 transcript()): 从 ContextVar _transcript(:919)读;没有就懒创建一个。样本开始时用 init_transcript()(:915)把新 transcript 塞进这个 ContextVar。
核心是 _event()(log/_transcript.py:539):
# 示意,非源码 —— _event 的骨架
def _event(self, event):
key = self._ensure_event_key(event) # 确保有 uuid
if key in self._resident_event_ids: # 常驻事件去重
raise ValueError("Duplicate event uuid")
self._process_event(event) # 通知订阅者 + 压缩 model call
self._events.append(event) # 追加进内存列表
self._event_count += 1
self._update_pending(event) # 维护 pending 侧表
self._evict_events() # bounded 模式下按需驱逐旧事件
扇出给订阅者(_notify_subscribers,:651): 遍历订阅者逐个回调。这里有个重入护栏——若某订阅者在处理事件时又记了日志(产生新事件),不能递归地再通知它自己,否则组合式扇出会爆炸(:652-670,_notifying_subscribers 集合)。订阅者抛异常也被吞掉并打标 SKIP_TRANSCRIPT_DISPATCH,防止错误又被当成 LoggerEvent 回灌成环。
谁在订阅? 实时落盘的接头就在这:样本运行时 sample_transcript._subscribe(on_sample_event)(_eval/task/run.py:1128),回调把事件送进 SQLite 缓冲(见 3.5)。检查点(_checkpoint)、ACP 客户端也各挂一个订阅。
3.3 span 与 step:把平铺事件重建成树
要解决的小问题: 事件是一条平铺的流,但真实执行是嵌套的——一个 agent 里套一次模型调用,模型又触发几个工具,工具里又有子任务。回放时想按这个层级折叠展开。
思路: 不改变"平铺 append-only"这个简单模型,而是插入边界事件:进入一段逻辑时 emit SpanBeginEvent,退出时 emit SpanEndEvent,每个普通事件带 span_id 指向所属 span,SpanBeginEvent 带 parent_id 指向父 span。事后靠这三者把流重建成树。
SpanBeginEvent(event/_span.py:8)带 id / parent_id / name / type;普通事件的 span_id(_base.py:20)标明自己在哪个 span 内。有了 (id, parent_id, span_id) 就能还原嵌套:
事件流(平铺,带 span_id) 重建后的树
span_begin(id=A "solver") ─▶ A solver
model (span_id=A) ├─ model
span_begin(id=B,parent=A "tool") └─ B tool
sandbox (span_id=B) ├─ sandbox exec
tool (span_id=B) └─ tool
span_end(B)
span_end(A)
重建逻辑在 event/_tree.py(event_tree/EventTreeSpan),live UI 和 inspect view 用它折叠展开。
step 是 span 的旧版。 StepEvent(event/_step.py:8,action: "begin"|"end")是更早的、扁平的分段机制;Transcript.step() 上下文管理器已弃用(log/_transcript.py:471),官方让你改用 span()。二者都能圈定一段,但 span 有父子关系、能建真正的树,step 只是首尾配对。记忆:step = 一维分段,span = 二维树。
3.4 bounded transcript:长跑不撑爆内存
要解决的小问题: 一个 agent 样本可能跑几万个事件。全留在内存里,长任务会 OOM。但又不能真丢——事后回放要完整。
思路: 有界内存 + 溢出到磁盘。内存里只保留最近的一小截(默认尾部 100 个,DEFAULT_RESIDENT_TAIL,log/_transcript.py:62);更老的事件被驱逐出内存,需要时从一个 TranscriptHistoryProvider(通常就是 SQLite 缓冲库)懒加载回来。
两个不可驱逐的例外(钉住,pinned):
SampleInitEvent—— 样本的根,永远留着(:435-439)。pending事件 —— 还没完成、还要被更新,不能走(:440-445)。
关键不变式(_trailing_window,:707): 驱逐只删"最老的可驱逐事件",钉住的和 pending 的留在原位。所以内存列表中段可能有空洞(相对逻辑历史),但尾部 min(resident_tail, len) 个,永远正好是最新的那些逻辑事件、且有序。取"最近 N 个"能纯内存命中,取更早的才回落到 provider。
统一入口 TranscriptHistory(:193): recent_events(n)(:259,尽量内存)、events_from(start)(:295,按逻辑下标)、events_since_last(ModelEvent)(:347,回溯到最近一次模型调用——重试出错时抓上下文用)。取不到又无 provider 就抛 TranscriptHistoryUnavailableError(:73)。
这条机制平时不开(默认 bounded=False,_events 就是全量);只有需要长跑省内存时才启用。它和 3.5 的 SQLite 缓冲是一对:内存放尾巴,磁盘放全量,provider 当桥。
3.5 实时缓冲:边跑边看的 SQLite 库
要解决的小问题: eval 正在跑,.eval ZIP 还没写完(样本没结账),但用户想现在就看 live view。总不能读一个半截 ZIP。
思路: 跑的过程里,每个事件顺手写进一个独立的 SQLite 数据库(SampleBufferDatabase)。这个库是"进行中"的真相源:live view 轮询它、按事件 id 增量拉取、做回放。样本结账后才把整条流固化进 ZIP。
这个库长这样(log/_recorders/buffer/database.py:89 SampleBufferDatabase.SCHEMA):
| 表 | 存什么 |
|---|---|
samples | 每条样本的摘要 JSON |
events | 每个事件一行(event_id 文本 uuid + 自增 id + 事件 JSON) |
attachments | 大内容(图片/长文本)去重后按 hash 存 |
message_pool / call_pool | 消息、模型 raw call 的去重池(省空间) |
接头: on_sample_event(run.py:1103)→ logger.log_sample_event(_eval/task/log.py:389)→ buffer_db.log_events([SampleEvent(...)])(database.py:273)。样本开始时 start_sample 先插一行摘要(database.py:262)。这个缓冲库只在 log_realtime 没被关掉时才建(_eval/task/log.py:315-322)。
live view 怎么增量拉: SampleBuffer 抽象(buffer/types.py:103)提供 get_sample_data(id, epoch, after_event_id=…)——传上次拉到的最大事件 id,只取更新的部分。自增主键 id 天然是游标。UI 于是"只取新事件、追加渲染",而不是每次重拉全量。
回放桥: BufferTranscriptHistoryProvider(buffer/transcript_history_provider.py)把这个 SQLite 库包成 3.4 说的 TranscriptHistoryProvider——被驱逐的事件从这儿读回来。所以缓冲库身兼两职:给 live view 供实时流,和给 bounded transcript 当磁盘后端。
3.6 落盘:.eval 就是一个 ZIP
要解决的小问题: 最终归档要:小(评测日志很多)、能只读头部不读全样本(列目录快)、能流式写(样本一条条来)、能读回。
思路: 用 ZIP 容器,内部按约定摆放 JSON 成员;头部(header/summaries)和样本体(samples/)分开,想只看结果就只读头部。
.eval ZIP 内部布局(log/_recorders/eval.py,常量在 :82-90):
xxx.eval (其实是 ZIP)
├── _journal/
│ ├── start.json ← 开跑就写:version + eval(EvalSpec) + plan
│ └── summaries/
│ ├── 1.json ← 每次 flush 追加一批样本摘要
│ └── 2.json
├── samples/
│ ├── <id>_epoch_1.json ← 每条样本完整体(含 events)
│ └── <id>_epoch_2.json
├── summaries.json ← 结账时:合并后的全量摘要
├── reductions.json ← 结账时:跨 epoch 归并的分数
└── header.json ← 结账时:status + stats + results + error
为什么这么摆:
- 列目录/看结果不必读样本。
read_eval_log(header_only=True)只读header.json+summaries.json,跳过庞大的samples/(eval.py:_read_log:958)。 - 进行中 也能读。 还没
header.json时,从_journal/start.json+ 各summaries/N.json拼(eval.py:_read_header_async:1042、_read_all_summaries_async:1093)。 - 流式写。 样本先缓冲进内存
ZipLogFile._samples,按log_buffer批次 flush 成samples/*.json+ 一个summaries/N.json(buffer_sample:727、write_buffered_samples:773)。
懒加载读回(LazyList,eval.py:1188): eval() 返回后大多数人只看结果、不遍历样本。所以 close() 挂一个 LazyList,首次访问 .samples 才真去 ZIP 里反序列化(_LazyLogData.load:1175)。这是"读一个巨型日志却只想看分数"时的关键省时。
还有个 .json 格式。 JSONRecorder(log/_recorders/json.py)把整个 EvalLog 写成单个 JSON 文件——可读、可 diff,但比 .eval 大 5-8 倍(eval.py:110-114 注释),且不能流式/懒加载。选哪个由文件扩展名决定(handles_location,eval.py:96)。
3.7 沙箱:隔离执行 + 顺手留痕
要解决的小问题: agent 要执行任意代码、读写文件,这既危险(别祸害宿主),又需要被观测(每次动作要能回放)。
思路: 定义一个抽象接口 SandboxEnvironment,把"在哪执行"藏在实现后面(本地临时目录 / Docker / 远程);再包一层 Proxy,让每次 I/O 顺手 emit 一个 SandboxEvent。隔离由实现负责,留痕由 Proxy 负责,两件事解耦。
抽象接口(util/_sandbox/environment.py:92 SandboxEnvironment):
| 方法 | 干什么 | 抽象? |
|---|---|---|
exec(cmd, …) | 在沙箱内执行命令,返回 ExecResult | 抽象 |
read_file / write_file | 沙箱内读写文件 | 抽象 |
connection() | 返回"如何连进这个沙箱"的信息 | 可选 |
task_init / sample_init / sample_cleanup | 任务/样本级的生命周期钩子 | 部分抽象 |
默认最简实现 LocalSandboxEnvironment(util/_sandbox/local.py:20): 就是一个 tempfile.TemporaryDirectory,exec 走 subprocess,相对路径解析到这个临时目录。注意:local 只做"文件系统隔离"(独立工作目录),不做真正的进程/权限隔离——真隔离要用 Docker(util/_sandbox/docker/)。
留痕层 SandboxEnvironmentProxy(util/_sandbox/events.py:33): 它包住真实沙箱,exec/read_file/write_file 每次调用完就 transcript()._event(SandboxEvent(...)):
真实实现见 events.py:97-113(exec 分支)—— 记下 cmd、截断后的 input/output、result(返回码)、起止时间。输出用 content_display(:239)截到 20 行,避免把几 MB 日志塞进 transcript。
Proxy 什么时候套上? 样本初始化时,init_sandbox_environments_sample(util/_sandbox/context.py:241)把 sample_init 返回的每个环境都用 Proxy 包一层(:260):environments = {k: SandboxEnvironmentProxy(v) …}。于是 agent 后续拿到的全是带摄像头的沙箱。清理时再 unproxy_environments(:298)剥回真身交给 sample_cleanup。
拿到当前沙箱: sandbox(name)(context.py:41)从 ContextVar sandbox_environments_context_var(:395)取;不传名字或叫 "default" 就取第一个(约定第一个键是默认环境)。
资源限制(util/_sandbox/limits.py): exec 输出默认限 10 MiB、读文件默认限 100 MiB(SandboxEnvironmentLimits,:51),超了抛 OutputLimitExceededError。可用 INSPECT_SANDBOX_MAX_* 环境变量或 override_* 上下文管理器临时放宽(:108、:127)。这是隔离的另一面:不光防越权,也防"一条命令刷屏把日志撑爆"。
3.8 Viewer:把事件流渲染成时间线
要解决的小问题: 前面攒的痕迹要有人看——静态归档要能浏览,进行中要能实时刷。
思路: 一个本地 web 服务器,读日志目录,把 EvalLog / 事件流喂给前端渲染;进行中就读 3.5 的 SQLite 缓冲。
view()(_view/view.py:23)起一个 FastAPI 服务器(view_server,:56),默认 127.0.0.1:7575。它做端口管理(抢占旧进程的 pid 文件,view_acquire_port:74)后交给 fastapi_server 提供 REST:列日志、读某个 log 的 header/samples、以及读缓冲库做 live 刷新。前端(_view/ts-mono,一个 submodule)把事件流按 span 折叠成可点开的时间线。
回到总钥匙: live view 增量拉的是 SQLite 缓冲(3.5),事后看归档读的是 .eval ZIP(3.6)——同一条事件流,两个读法。Viewer 只是这条线的显示端。
4. 巧妙之处(可借鉴的技术)
- 一条 append-only 事件流当唯一真相源。 实时缓冲、bounded 内存、ZIP 归档三个消费者,都从同一条
_event()派生,互不知道对方存在。加消费者只需_subscribe(log/_transcript.py:843),不动生产端。 - pending 事件 = 先占位后更新。 工具/模型未完成时先记
pending=True的行,完成后原地更新(event/_tool.py:70_set_result),live UI 无需"删占位再插新行"。 - 钉住 + 尾部窗口不变式,让 bounded 内存仍能 O(1) 取"最近 N 个"。
_trailing_window(log/_transcript.py:707)的注释把这个不变式讲得很干净:中段可空洞,尾部永远是最新逻辑事件——值得抄的注释纪律。 - 去重池省空间。 缓冲库把重复的消息、模型 raw call 抽进
message_pool/call_pool(buffer/database.pySCHEMA),事件只存引用;ZIP 侧对应EvalSample.events_data(log/_log.py:503)。长对话里同一段系统提示只存一份。 - Proxy 把"隔离"和"留痕"解耦。 沙箱实现只管安全执行,
SandboxEnvironmentProxy(util/_sandbox/events.py:33)单独负责发事件;no_events()(:220)还能在沙箱服务自身的内部调用上临时静音,避免"记录基础设施调用"的噪声。 - ZIP 头体分离 + 懒加载。
header_only读只碰header.json;LazyList(eval.py:1188)让eval()返回后不碰样本就零反序列化开销——"常见路径只要结果"被认真优化。
5. 边界与局限(诚实)
- local 沙箱不是安全边界。
LocalSandboxEnvironment(util/_sandbox/local.py)只隔离工作目录,命令以当前用户身份直接subprocess跑;user参数直接被忽略并告警(:60-64)。要真隔离(不可信代码)必须 Docker/远程。 - bounded 模式下"重新追加一个已被驱逐的 uuid"会让逻辑下标和缓冲行错位。 这是
_event里明确写下的已知限制而非被守护的路径(log/_transcript.py:544-552长注释):生产路径不会这么做(事件更新走_event_updated,不加计数),所以留作文档化限制。 - transcript 里的 sandbox 输出是截断的。
content_display截到 20 行(util/_sandbox/events.py:239),二进制只显示大小。要完整输出得看ExecResult本身,别指望从事件里还原全量。 - 实时缓冲是"进行中"的临时物。
SampleBufferDatabase随 eval 结束被cleanup;live 读和 eval 拆除之间存在竞态,代码里专门用TranscriptHistoryUnavailableError(log/_transcript.py:73)和缓冲的读租约来兜。它不是归档,归档只认.eval/.json。 .eval字段顺序是格式契约。EvalLog类顶部有醒目警告:改字段顺序等于改文件格式,必须升版本号(log/_log.py:1088-1091)。score/scorer/sample_reductions等旧字段是弃用兼容层。 读老日志靠一堆model_validator迁移(如EvalSample.migrate_deprecated:565);新代码别用这些属性。
6. 横向对比
本章是 Inspect AI 子库的一章,横向请看同组其它章:
- 事件从哪来:评测主循环串起样本生命周期,Solver 与 TaskState 是
StateEvent的源头。 ModelEvent的产地:统一模型层;ToolEvent/SubtaskEvent的产地:工具调用与 Agent 循环。ScoreEvent/reductions的语义:Scorer 与 Metric——本章只讲它们怎么落盘,不讲怎么算。- 全局阅读地图见 index.md。
7. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 顶层日志对象 | src/inspect_ai/log/_log.py:1085 | EvalLog |
| 样本完整记录 / 瘦身摘要 | src/inspect_ai/log/_log.py:374 / :261 / :509 | EvalSample、EvalSampleSummary、EvalSample.summary |
| 评测标识与配置 | src/inspect_ai/log/_log.py:886 / :89 / :1063 | EvalSpec、EvalConfig、EvalStats |
| 结果与分数 | src/inspect_ai/log/_log.py:739 / :691 / :726 | EvalResults、EvalScore、EvalSampleReductions |
| 事件基类与联合 | src/inspect_ai/event/_base.py:16 / _event.py:27 | BaseEvent、Event |
| 关键事件类型 | src/inspect_ai/event/_model.py:54、_tool.py:13、_sandbox.py:10、_score.py:10 | ModelEvent、ToolEvent、SandboxEvent、ScoreEvent |
| span / step | src/inspect_ai/event/_span.py:8、_step.py:8、_tree.py | SpanBeginEvent、StepEvent、event_tree |
| 内存事件流 + 订阅 | src/inspect_ai/log/_transcript.py:380 / :863 / :539 / :651 | Transcript、transcript、_event、_notify_subscribers |
| 有界内存 / 溢出 | src/inspect_ai/log/_transcript.py:193 / :707 / :729 | TranscriptHistory、_trailing_window、_evict_events |
| 落盘抽象接口 | src/inspect_ai/log/_recorders/recorder.py:24 | Recorder |
.eval ZIP 实现 | src/inspect_ai/log/_recorders/eval.py:93 / :689 / :1188 | EvalRecorder、ZipLogFile、LazyList |
| 流式写入 | src/inspect_ai/log/_recorders/streaming.py:15、eval.py:731 | materialize_streaming_sample、buffer_sample_streaming |
| 实时缓冲(SQLite) | src/inspect_ai/log/_recorders/buffer/database.py:89、types.py:103、:18 | SampleBufferDatabase、SampleBuffer、TranscriptEventSink |
| 实时线接头 | src/inspect_ai/_eval/task/run.py:1103、_eval/task/log.py:389 / :318 | on_sample_event、log_sample_event、SampleBufferDatabase(...) |
| 沙箱抽象与解析 | src/inspect_ai/util/_sandbox/environment.py:92 / :495 / :532 | SandboxEnvironment、SandboxEnvironmentSpec、resolve_sandbox_environment |
| 沙箱留痕 Proxy | src/inspect_ai/util/_sandbox/events.py:33 / :239 | SandboxEnvironmentProxy、content_display |
| 沙箱上下文 / 生命周期 | src/inspect_ai/util/_sandbox/context.py:41 / :241 | sandbox、init_sandbox_environments_sample |
| 本地沙箱实现 / 限制 | src/inspect_ai/util/_sandbox/local.py:20、limits.py:51 | LocalSandboxEnvironment、SandboxEnvironmentLimits |
| 查看器入口 | src/inspect_ai/_view/view.py:23 | view |